Presentation helper for skin color and makeup on a skinned model renderer. It creates per-renderer material copies, applies color tints, handles makeup textures, neck textures, and caches/refreshes resources; also manages eye presentation via EyeColorPresentation.
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);
}
}
}