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.
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;
}
}