CharacterKit/SkinColorPresentation.cs

Presentation helper for character skin color and makeup. It applies per-renderer material copies with tint and optional normalization, manages makeup/neck textures, handles native dark-base corrections, and refreshes or clears overrides.

File Access
using System;

namespace CharacterEditor;
/// <summary>Per-renderer complexion and cosmetics retain native shading; optional iris color applies separately.</summary>
public sealed class SkinColorPresentation
{
	readonly Dictionary<int, Material> copies = new();
	SkinnedModelRenderer target;
	Model model;
	string group;
	Color? applied;
	bool normalized;
	string makeupSignature;
	Texture appliedNeck;
	readonly Dictionary<int, HumanMakeupPresentation> makeupCopies = new();
	// These native materials contain a darker photographed base than the other curated faces.
	// Correct their base when a palette/custom color is chosen, leaving Native/legacy looks exact.
	static readonly HashSet<string> DarkBases = new(StringComparer.OrdinalIgnoreCase)
	{
		"models/citizen_human/bodies/male/body_mode_dark.vmat",
		"models/citizen_human/bodies/female/female_body_mode_dark.vmat",
		"models/citizen_human/heads/citizen_human_malehead01/citizen_human_malehead01.vmat",
		"models/citizen_human/heads/citizen_human_malehead04/citizen_human_malehead04.vmat",
		"models/citizen_human/heads/citizen_human_femalehead01/citizen_human_femalehead01.vmat"
	};
	public static Color MaterialTint(Material original, Color color, bool normalize) => original.GetColor("g_vColorTint") * color * (normalize && DarkBases.Contains(original.Name) ? new Color(1.7f, 1.91f, 2.155f) : Color.White);
	readonly EyeColorPresentation eyes = new();
	public void Apply(SkinnedModelRenderer body, Color color, bool normalize = false, CharacterMakeupData makeup = null, string skinPath = "", CharacterAppearanceData appearance = null)
	{
		ApplySkin(body, color, normalize, makeup, skinPath, false, appearance?.BaseShape is not null, appearance?.BaseShape is not null);
		eyes.Apply(body, appearance);
	}

	public void ApplyEmbeddedSkin(SkinnedModelRenderer body, Color color, bool normalize = false) => ApplySkin(body, color, normalize, null, "", true);
	public void ApplyEmbeddedSkin(SkinnedModelRenderer body, CharacterAppearanceData appearance) => ApplySkin(body, appearance.SkinColor, appearance.NormalizeSkinTone, null, appearance.SkinPath, true, true, appearance.BaseShape is not null);
	void ApplySkin(SkinnedModelRenderer body, Color color, bool normalize, CharacterMakeupData makeup, string skinPath, bool preserveNonSkin = false, bool unified = false, bool unifiedBody = false)
	{
		if (!body.IsValid() || body.Model is null)
			return;
		if (target != body || model != body.Model || group != body.MaterialGroup)
		{
			target = body;
			model = body.Model;
			group = body.MaterialGroup;
			copies.Clear();
			applied = null;
			foreach (var item in makeupCopies.Values)
				item.Dispose();
			makeupCopies.Clear();
			makeupSignature = null;
			Clear(body, preserveNonSkin);
		}

		var cosmetics = "matched-neck-albedo" + makeup?.PresentationKey() + skinPath + unified + unifiedBody + (unifiedBody ? UnifiedHuman.BodyToneTint(skinPath) : Color.White).ToString();
		var neck = unifiedBody ? UnifiedNeckAssets.ColorTexture(skinPath) : null;
		if (applied == color && normalized == normalize && makeupSignature == cosmetics && appliedNeck == neck)
		{
			RefreshMakeup();
			return;
		}

		appliedNeck = neck;
		makeupSignature = cosmetics;
		if (makeup?.Active != true)
		{
			foreach (var item in makeupCopies.Values)
				item.Dispose();
			makeupCopies.Clear();
		}

		if (color == Color.White && !normalize && makeup?.Active != true && !unified)
		{
			Clear(body, preserveNonSkin);
			applied = color;
			normalized = false;
			return;
		}

		// MaterialAccessor uses original model slots, including alternatives. GetMaterials(group)
		// returns a compact group list and cannot be used as renderer override indices.
		for (var i = 0; i < body.Materials.Count; i++)
		{
			var slot = body.Materials.GetOriginal(i);
			var original = unified ? UnifiedHuman.SkinMaterial(slot, skinPath, unifiedBody) : HumanMarkingModels.NativeMaterial(slot);
			var armSide = HumanMarkingModels.ArmSide(slot);
			if (original?.ShaderName != "shaders/skin.shader" || original.Name.EndsWith("/mouth.vmat", StringComparison.OrdinalIgnoreCase))
				continue;
			if (!copies.TryGetValue(i, out var copy) || unified)
				copies[i] = copy = original.CreateCopy($"fate-skin-{body.Id}-{i}.vmat");
			var tint = MaterialTint(original, color, normalize) * (unifiedBody ? UnifiedHuman.BodyToneTint(original, skinPath) : Color.White);
			var selectedHead = IsSelectedHead(original, skinPath);
			var sourceColor = unifiedBody && selectedHead ? UnifiedNeckAssets.ColorTexture(skinPath) : original.GetTexture("g_tColor");
			var bodyInk = original.Name.Contains("/bodies/", StringComparison.OrdinalIgnoreCase) && makeup?.Markings.Any(x => x.Style != "none" && x.Ink.Amount > 0 && MarkingCatalog.OnMaterial(x, armSide)) == true;
			if (makeup?.Active == true && (selectedHead || bodyInk))
			{
				if (!makeupCopies.TryGetValue(i, out var presentation))
					makeupCopies[i] = presentation = new();
				// Keep complexion correction in the native skin shader on every surface.
				// Baking it into an 8-bit makeup texture changes color-space behavior and
				// clips bright corrections, creating a visible head/body seam.
				copy.Set("g_tColor", presentation.Apply(original, makeup, skinPath, selectedHead, armSide, sourceColor));
				copy.Set("g_vColorTint", tint);
			}
			else
			{
				copy.Set("g_tColor", sourceColor);
				copy.Set("g_vColorTint", tint);
				if (makeupCopies.Remove(i, out var unused))
					unused.Dispose();
			}

			body.Materials.SetOverride(i, copy);
		}

		applied = color;
		normalized = normalize;
	}

	public static bool IsSelectedHead(Material original, string skinPath)
	{
		var id = System.IO.Path.GetFileNameWithoutExtension(skinPath);
		var head = id switch
		{
			"human01" => "malehead01",
			"human02" => "femalehead01",
			"human03" => "malehead02",
			"human04" => "femalehead02",
			"human05" => "malehead03",
			"human06" => "malehead04",
			"human07" => "malehead05",
			_ => ""
		};
		return head != "" && original.Name.EndsWith($"citizen_human_{head}.vmat", StringComparison.OrdinalIgnoreCase);
	}

	public void Dispose()
	{
		eyes.Dispose();
		foreach (var item in makeupCopies.Values)
			item.Dispose();
		makeupCopies.Clear();
	}

	void RefreshMakeup()
	{
		foreach (var pair in makeupCopies)
		{
			var texture = pair.Value.Refresh();
			if (copies.TryGetValue(pair.Key, out var copy) && copy.GetTexture("g_tColor") != texture)
				copy.Set("g_tColor", texture);
		}
	}

	public void Refresh()
	{
		eyes.Refresh();
		RefreshMakeup();
	}

	public void Invalidate()
	{
		applied = null;
	}

	static void Clear(SkinnedModelRenderer body, bool preserveNonSkin = false)
	{
		for (var i = 0; i < body.Materials.Count; i++)
		{
			var slot = body.Materials.GetOriginal(i);
			if (preserveNonSkin && (HumanMarkingModels.NativeMaterial(slot)?.ShaderName != "shaders/skin.shader" || HumanMarkingModels.NativeMaterial(slot).Name.EndsWith("/mouth.vmat", StringComparison.OrdinalIgnoreCase)))
				continue;
			if (HumanMarkingModels.ArmSide(slot) != 0)
				body.Materials.SetOverride(i, HumanMarkingModels.NativeMaterial(slot));
			else if (body.Materials.HasOverride(i))
				body.Materials.SetOverride(i, null);
		}
	}
}