CharacterKit/HumanMakeupPresentation.cs

Presentation component that composes local GPU albedo for a human character using various makeup and skin-related textures, producing a composed Texture via a compute shader. It tracks inputs, streams readiness, refresh timing, and exposes Apply/Refresh/Dispose; MakeupRefreshWindow throttles composition steps across frames.

Native InteropNetworking
using System;

namespace CharacterEditor;
/// <summary>Local GPU albedo composition retains the native skin shader and geometry.</summary>
public sealed class HumanMakeupPresentation : IDisposable
{
	static ComputeShader compositor;
	Texture result;
	Material original;
	CharacterMakeupData makeup;
	string skinPath;
	bool head;
	int armSide;
	readonly MakeupRefreshWindow refresh = new();
	Texture source;
	Texture baseColorOverride;
	int sourceWidth, sourceHeight;
	Texture[] inputs = new Texture[8];
	double lastRefreshTime = double.NaN;
	public Texture Apply(Material original, CharacterMakeupData makeup, string skinPath, bool head = true, int armSide = 0, Texture baseColorOverride = null)
	{
		this.original = original;
		this.makeup = makeup.DeepCopy();
		this.skinPath = skinPath;
		this.head = head;
		this.armSide = armSide;
		this.baseColorOverride = baseColorOverride;
		source = baseColorOverride ?? original.GetTexture("g_tColor");
		refresh.Reset();
		lastRefreshTime = double.NaN;
		// A material can exist while its native albedo is still loading. Keep that
		// original bound until the compute inputs are ready instead of baking its fallback.
		return Refresh();
	}

	public Texture Refresh()
	{
		var input = baseColorOverride ?? original.GetTexture("g_tColor");
		if (input != source || input?.Width != sourceWidth || input?.Height != sourceHeight)
		{
			source = input;
			sourceWidth = input?.Width ?? 0;
			sourceHeight = input?.Height ?? 0;
			refresh.Reset();
		}

		var inputs = Inputs();
		foreach (var texture in inputs)
			if (texture is not null)
				texture.MarkUsed(Math.Max(texture.Width, texture.Height));
		bool ready = true;
		foreach (var texture in inputs)
			if (texture is not { IsValid: true, IsError: false, IsLoaded: true })
			{
				ready = false;
				break;
			}

		var now = Time.NowDouble;
		var delta = now == lastRefreshTime ? 0 : Math.Clamp(Time.Delta, 0, .1f);
		lastRefreshTime = now;
		// Count actual presentation updates, not wall time spent in the client's load screen.
		// Streaming refresh is finite once the real inputs become ready.
		if (refresh.Step(ready, delta))
			Compose();
		return ready && result is not null ? result : source;
	}

	Texture[] Inputs()
	{
		// Live hotload can retain an older presentation's input array.
		if (inputs is null || inputs.Length != 8) inputs = new Texture[8];
		inputs[0] = source;
		inputs[1] = HumanMakeupAssets.Mask(skinPath);
		inputs[2] = HumanMakeupAssets.Marking(skinPath);
		inputs[3] = HumanMakeupAssets.EyeDetail(skinPath);
		inputs[4] = HumanMakeupAssets.Surface(skinPath, "position_hi", head);
		inputs[5] = HumanMakeupAssets.Surface(skinPath, "position_lo", head);
		inputs[6] = HumanMakeupAssets.Surface(skinPath, "normal", head);
		inputs[7] = HumanMakeupAssets.LinerDetail;
		return inputs;
	}

	Texture Compose()
	{
		source.MarkUsed(Math.Max(source.Width, source.Height));
		if (result is null || result.Width != source.Width || result.Height != source.Height)
		{
			result?.Dispose();
			result = Texture.Create(source.Width, source.Height).WithFormat(ImageFormat.RGBA8888).WithUAVBinding().WithName("Local human makeup").Finish();
		}

		var mask = HumanMakeupAssets.Mask(skinPath);
		var marking = HumanMakeupAssets.Marking(skinPath);
		mask.MarkUsed(Math.Max(mask.Width, mask.Height));
		marking.MarkUsed(Math.Max(marking.Width, marking.Height));
		var attributes = new RenderAttributes();
		attributes.Set("BaseColor", source);
		attributes.Set("Coverage", mask);
		attributes.Set("Marking", marking);
		attributes.Set("Result", result);
		var eyeDetail = HumanMakeupAssets.EyeDetail(skinPath);
		eyeDetail.MarkUsed(Math.Max(eyeDetail.Width, eyeDetail.Height));
		attributes.Set("EyeDetail", eyeDetail);
		attributes.Set("LinerDetail", HumanMakeupAssets.LinerDetail);
		attributes.Set("LinerOptions", new Vector4(makeup.EyelinerLowerStrength, makeup.EyelinerInnerStrength, makeup.EyelinerWing == "wing" ? 4f : makeup.EyelinerWing == "downward" ? 3f : makeup.EyelinerWing == "cat-eye" ? 2f : makeup.EyelinerWing == "flick" ? 1f : 0f, makeup.EyelinerWingStrength));
		attributes.Set("WingTip", new Vector4(makeup.WingTipHorizontal, makeup.WingTipVertical, makeup.EyelinerWingThickness, 0));
		attributes.Set("Liner", new Vector4(makeup.Eyeliner.Red, makeup.Eyeliner.Green, makeup.Eyeliner.Blue, makeup.Eyeliner.Amount));
		attributes.Set("LowerEye", new Vector4(makeup.LowerEyeshadow.Red, makeup.LowerEyeshadow.Green, makeup.LowerEyeshadow.Blue, makeup.LowerEyeshadow.Amount));
		attributes.Set("Lip", new Vector4(makeup.Lipstick.Red, makeup.Lipstick.Green, makeup.Lipstick.Blue, makeup.Lipstick.Amount));
		attributes.Set("Eye", new Vector4(makeup.Eyeshadow.Red, makeup.Eyeshadow.Green, makeup.Eyeshadow.Blue, makeup.Eyeshadow.Amount));
		attributes.Set("Blush", new Vector4(makeup.Blush.Red, makeup.Blush.Green, makeup.Blush.Blue, makeup.Blush.Amount));
		attributes.Set("Tattoo", new Vector4(makeup.Tattoo.Red, makeup.Tattoo.Green, makeup.Tattoo.Blue, makeup.TattooStyle == "none" ? 0 : makeup.Tattoo.Amount));
		attributes.Set("IsHead", head ? 1f : 0f);
		attributes.Set("ArmSide", (float)armSide);
		foreach (var kind in new[]
		{
			"position_hi",
			"position_lo",
			"normal"
		}

		)
		{
			var texture = HumanMakeupAssets.Surface(skinPath, kind, head);
			texture.MarkUsed(Math.Max(texture.Width, texture.Height));
			attributes.Set(kind == "position_hi" ? "PositionHigh" : kind == "position_lo" ? "PositionLow" : "SurfaceNormal", texture);
		}

		var layers = makeup.Markings.Where(x => head ? MarkingCatalog.OnHead(x) : MarkingCatalog.OnMaterial(x, armSide)).ToArray();
		for (var i = 0; i < MarkingCatalog.Maximum; i++)
		{
			var name = i.ToString();
			if (i >= layers.Length)
			{
				attributes.Set("Ink" + name, new Vector4(0, 0, 0, 0));
				continue;
			}

			var layer = layers[i];
			var region = MarkingCatalog.Region(layer.Region);
			var style = MarkingCatalog.Style(layer.Style);
			var frame = MarkingCatalog.Frame(layer);
			var center = frame.Center;
			var scale = region.Size * MarkingCatalog.StampScale(layer);
			var width = style.Band ? MarkingCatalog.BandScale(layer) : .38f * scale;
			var height = style.Band ? MarkingCatalog.BandSpacing(layer) : .45f * scale;
			attributes.Set("Center" + name, new Vector4(center.x, center.y, center.z, style.ShaderId));
			attributes.Set("Right" + name, new Vector4(frame.Right.x, frame.Right.y, frame.Right.z, width));
			attributes.Set("Up" + name, new Vector4(frame.Up.x, frame.Up.y, frame.Up.z, height));
			attributes.Set("Normal" + name, new Vector4(frame.Normal.x, frame.Normal.y, frame.Normal.z, (layer.Rotation - .5f) * MathF.PI * 2));
			attributes.Set("Min" + name, new Vector4(region.Min.x, region.Min.y, region.Min.z, frame.Projection));
			attributes.Set("Max" + name, new Vector4(region.Max.x, region.Max.y, region.Max.z, style.Band ? Math.Clamp(MathF.Round(MathF.PI * 2 * region.Radius / (1.6f * height)), 3, 36) : frame.Phase));
			attributes.Set("Ink" + name, new Vector4(layer.Ink.Red, layer.Ink.Green, layer.Ink.Blue, style.ShaderId == 0 ? 0 : layer.Ink.Amount));
		}

		compositor ??= new(HumanMakeupAssets.ShaderPath);
		compositor.DispatchWithAttributes(attributes, source.Width, source.Height, 1);
		return result;
	}

	public void Dispose()
	{
		result?.Dispose();
		result = null;
	}
}

/// <summary>Finite mip refresh starts when native input textures are ready, measured in presentation updates.</summary>
public sealed class MakeupRefreshWindow
{
	bool ready;
	float elapsed, next;
	public void Reset()
	{
		ready = false;
		elapsed = 0;
		next = 0;
	}

	public bool Step(bool inputsReady, float delta)
	{
		if (!inputsReady)
		{
			Reset();
			return false;
		}

		if (!ready)
		{
			ready = true;
			elapsed = 0;
			next = 0;
		}

		elapsed += Math.Clamp(delta, 0, .1f);
		if (elapsed > 3 || elapsed < next)
			return false;
		next = elapsed + .25f;
		return true;
	}
}