CharacterVisual is a presentation helper that creates and updates a character's visible model, clothing, skin, hair and deformation for portraits, editor and world prefabs. It loads required resources, composes skinned renderers, applies colors/deformation and toggles clothing, and remembers applied state for incremental updates. VoiceIds is a small helper that validates a set of supported voice id strings.
using System;
using System.Text.Json;
using System.Threading.Tasks;
namespace CharacterEditor;
/// <summary>The common presentation used by portraits, the editor and world prefabs.</summary>
public sealed class CharacterVisual : IDisposable
{
public static readonly string[] OutfitPaths = WardrobeCatalog.DefaultPaths;
readonly GameObject owner;
readonly bool ownsBody;
readonly Dresser dresser;
readonly SkinColorPresentation colors = new();
readonly SkinColorPresentation neckColors = new();
SkinnedModelRenderer replacementNeck;
readonly List<GameObject> details = new(), clothes = new();
readonly Dictionary<GameObject, Clothing> detailItems = new();
readonly Dictionary<SkinnedModelRenderer, SkinColorPresentation> clothingSkin = new();
GameObject deformation;
string requested, composition, shaped;
int appliedCosmeticRevision;
Task resources;
bool clothing, bodyGroupsDirty;
CharacterAppearanceData appliedAppearance;
Model appliedModel;
string appliedGroup;
ulong appliedBodyGroups;
bool appliedDresser;
readonly List<ClothingContainer.ClothingEntry> appliedWardrobe = new();
readonly List<(SkinnedModelRenderer Renderer, Model Model, string Group)> appliedRenderers = new();
public SkinnedModelRenderer Body { get; private set; }
public string Error { get; private set; }
public bool Loading => resources is not null && !resources.IsCompleted;
public CharacterVisual(GameObject owner, SkinnedModelRenderer body = null, Dresser dresser = null)
{
this.owner = owner;
Body = body;
ownsBody = body is null;
this.dresser = dresser;
}
public static async Task Load(CharacterAppearanceData data)
{
await AppearanceCatalog.EnsureLoadedAsync(data);
await WardrobeCatalog.Load(data);
}
public void Update(CharacterAppearanceData data, bool showClothing = true)
{
// Keep streaming cosmetics alive, but leave unchanged geometry and wardrobe alone.
// Compare values, not object identity: callers can edit the public data in place.
if (Error is null && appliedCosmeticRevision == CharacterMakeupData.CosmeticRevision && appliedAppearance is not null && AppearanceComparison.Same(appliedAppearance, data))
{
if (showClothing == clothing && PresentationUnchanged())
{
colors.Refresh();
return;
}
if (Body.IsValid() && (Body.Model != appliedModel || Body.MaterialGroup != appliedGroup))
composition = null;
Invalidate();
}
if (CharacterAppearanceData.Validate(data)is string invalid)
{
Error = invalid;
return;
}
// Cosmetic inputs can change without changing mesh composition.
var loadKey = CharacterAppearanceData.CompositionKey(data) + "|" + data.SkinPath + "|" + data.Makeup.Active + "|" + data.CustomEyeColor + "|matched-neck-v2";
if (requested != loadKey)
{
requested = loadKey;
resources = Load(data.DeepCopy());
shaped = null;
Error = null;
}
if (Loading)
return;
if (resources?.IsFaulted == true)
{
Error = resources.Exception.GetBaseException().Message;
return;
}
try
{
UpdateLoaded(data, showClothing);
if (Error is null && !Loading)
Remember(data);
}
catch (Exception failure)
{
Error = failure.Message;
}
}
public void Invalidate()
{
appliedAppearance = null;
shaped = null;
bodyGroupsDirty = true;
colors.Invalidate();
}
bool PresentationUnchanged()
{
if (!Body.IsValid() || !deformation.IsValid() || Body.Model != appliedModel || Body.MaterialGroup != appliedGroup || Body.BodyGroups != appliedBodyGroups)
return false;
if (dresser.IsValid() != appliedDresser)
return false;
if (dresser.IsValid())
{
if (dresser.Clothing.Count != appliedWardrobe.Count)
return false;
for (int i = 0; i < appliedWardrobe.Count; i++)
{
var a = dresser.Clothing[i];
var b = appliedWardrobe[i];
if (a.Clothing != b.Clothing || a.Tint != b.Tint || a.ItemDefinitionId != b.ItemDefinitionId || a.Bone != b.Bone || a.Transform != b.Transform)
return false;
}
}
int index = 0;
foreach (var renderer in Body.GameObject.GetComponentsInChildren<SkinnedModelRenderer>(true, true))
{
if (index >= appliedRenderers.Count)
return false;
var previous = appliedRenderers[index++];
if (renderer != previous.Renderer || renderer.Model != previous.Model || renderer.MaterialGroup != previous.Group)
return false;
}
return index == appliedRenderers.Count;
}
void Remember(CharacterAppearanceData data)
{
appliedCosmeticRevision = CharacterMakeupData.CosmeticRevision;
appliedAppearance = data.DeepCopy();
appliedModel = Body.Model;
appliedGroup = Body.MaterialGroup;
appliedBodyGroups = Body.BodyGroups;
appliedDresser = dresser.IsValid();
appliedWardrobe.Clear();
if (dresser.IsValid())
foreach (var e in dresser.Clothing)
appliedWardrobe.Add(new() { Clothing = e.Clothing, Tint = e.Tint, ItemDefinitionId = e.ItemDefinitionId, Bone = e.Bone, Transform = e.Transform });
appliedRenderers.Clear();
foreach (var r in Body.GameObject.GetComponentsInChildren<SkinnedModelRenderer>(true, true))
appliedRenderers.Add((r, r.Model, r.MaterialGroup));
}
void UpdateLoaded(CharacterAppearanceData data, bool showClothing)
{
if (CharacterAppearance.ValidateForApply(data)is string error)
{
Error = error;
return;
}
if (!Body.IsValid())
{
var go = new GameObject
{
Parent = owner,
Name = "Human",
NetworkMode = NetworkMode.Never
};
go.LocalTransform = Transform.Zero;
Body = go.Components.Create<SkinnedModelRenderer>();
}
var key = CharacterAppearanceData.CompositionKey(data);
if (dresser.IsValid())
key += "|" + new ClothingContainer
{
Clothing = dresser.Clothing
}.Serialize();
var skin = ClothingResources.Get(UnifiedHuman.PresentationSkin(data));
if (composition != key)
{
Body.Model = Model.Load(data.BaseShape is null ? HumanMarkingModels.ModelPath(skin.HumanSkinModel, data.Makeup) : UnifiedHuman.ModelPath);
Body.MaterialGroup = data.BaseShape is null ? skin.HumanSkinMaterialGroup : "skin_light";
Body.BodyGroups = data.BaseShape is null ? skin.HumanSkinBodyGroups : ulong.MaxValue;
foreach (var presentation in clothingSkin.Values)
presentation.Dispose();
clothingSkin.Clear();
if (!dresser.IsValid())
foreach (var go in details.Concat(clothes))
if (go.IsValid())
go.Destroy();
details.Clear();
detailItems.Clear();
clothes.Clear();
var selected = new List<Clothing>
{
skin
};
selected.AddRange(new[] { data.HairPath, data.FacialHairPath }.Where(x => !string.IsNullOrEmpty(x)).Select(ClothingResources.Get));
var outfit = WardrobeCatalog.Paths(data).Select(ClothingResources.Get).ToArray();
var all = selected.Concat(outfit).ToArray();
var tags = new TagSet(skin.HumanSkinTags.TryGetAll());
tags.Add("human");
if (!dresser.IsValid())
{
foreach (var item in selected.Skip(1))
Add(item, details, data.HairColor);
foreach (var item in outfit)
Add(item, clothes, item.AllowTintSelect ? item.TintSelection.Evaluate(item.TintDefault) : Color.White);
}
void Add(Clothing item, List<GameObject> list, Color tint)
{
var model = UnifiedWardrobe.Resolve(item, all, tags, data);
var go = new GameObject
{
Parent = Body.GameObject,
Name = item.Title,
NetworkMode = NetworkMode.Never
};
go.LocalTransform = Transform.Zero;
var r = go.Components.Create<SkinnedModelRenderer>();
r.Model = model;
r.MaterialGroup = item.MaterialGroup;
r.Tint = tint;
r.BoneMergeTarget = Body;
list.Add(go);
if (list == details)
detailItems[go] = item;
}
composition = key;
shaped = null;
clothing = !showClothing;
}
if (dresser.IsValid())
{
var expected = Model.Load(UnifiedHuman.ModelPath);
if (Body.Model != expected)
{
Body.Model = expected;
Body.MaterialGroup = "skin_light";
colors.Invalidate();
shaped = null;
}
details.RemoveAll(go => !go.IsValid());
clothes.RemoveAll(go => !go.IsValid());
foreach (var renderer in clothingSkin.Keys.Where(r => !r.IsValid()).ToArray())
{
clothingSkin[renderer].Dispose();
clothingSkin.Remove(renderer);
}
var entries = dresser.Clothing.Where(e => e.Clothing is not null).ToArray();
var tags = new TagSet(skin.HumanSkinTags.TryGetAll());
tags.Add("human");
var selectedItems = entries.Select(e => e.Clothing).ToArray();
foreach (var renderer in Body.GameObject.GetComponentsInChildren<SkinnedModelRenderer>(true, true).Where(r => r != Body && r != replacementNeck))
{
var item = entries.FirstOrDefault(e => renderer.GameObject.Name == "Clothing - " + e.Clothing.ResourceName);
var model = item is not null ? UnifiedWardrobe.Resolve(item.Clothing, selectedItems, tags, data) : renderer.Model;
if (model is not null && renderer.Model != model)
{
renderer.Model = model;
shaped = null;
}
bool hair = item is not null && (ClothingResources.IsHair(item.Clothing) || ClothingResources.IsFacialHair(item.Clothing));
if (hair && item.Tint is null)
renderer.Tint = data.HairColor;
var list = hair ? details : clothes;
if (hair)
detailItems[renderer.GameObject] = item.Clothing;
if (!list.Contains(renderer.GameObject))
{
list.Add(renderer.GameObject);
shaped = null;
}
}
}
var signature = JsonSerializer.Serialize(data) + key + data.Makeup.PresentationKey();
if (shaped != signature)
{
deformation = CharacterDeformation.Update(deformation, Body, data);
colors.Apply(Body, data.SkinColor, data.NormalizeSkinTone, data.Makeup, data.SkinPath, data);
// Some garments contain exposed arms/legs and hide the corresponding body mesh.
// Their native skin slots must use the same selected surface as the main human.
foreach (var go in clothes)
{
if (!go.IsValid())
continue;
// Hidden clothing still needs current skin settings before it is shown again.
var renderer = go.Components.Get<SkinnedModelRenderer>(FindMode.InSelf | FindMode.Enabled | FindMode.Disabled);
if (!renderer.IsValid())
continue;
if (!clothingSkin.TryGetValue(renderer, out var presentation))
clothingSkin[renderer] = presentation = new();
presentation.ApplyEmbeddedSkin(renderer, data);
UnifiedWardrobe.Apply(renderer, data);
}
if (!dresser.IsValid())
foreach (var go in details)
go.Components.Get<SkinnedModelRenderer>(FindMode.InSelf | FindMode.Enabled | FindMode.Disabled).Tint = data.HairColor;
shaped = signature;
}
else
colors.Refresh();
UnifiedHuman.Apply(Body, data);
if (clothing != showClothing || bodyGroupsDirty)
{
foreach (var go in clothes)
if (go.IsValid())
go.Enabled = showClothing;
Body.BodyGroups = data.BaseShape is null ? skin.HumanSkinBodyGroups : ulong.MaxValue;
var selected = dresser.IsValid() ? dresser.Clothing.Select(e => e.Clothing).Where(c => c is not null) : new[]
{
skin
}.Concat(new[] { data.HairPath, data.FacialHairPath }.Where(x => !string.IsNullOrEmpty(x)).Select(ClothingResources.Get));
if (showClothing && !dresser.IsValid())
selected = selected.Concat(WardrobeCatalog.Paths(data).Select(ClothingResources.Get));
if (dresser.IsValid() && !showClothing)
selected = selected.Where(i => ClothingResources.IsBaseSkin(i) || ClothingResources.IsHair(i) || ClothingResources.IsFacialHair(i));
var activeItems = selected.ToArray();
// Hairstyles are stored separately from outfits. Respect the native slot
// compatibility without deleting the chosen style when headwear covers it.
foreach (var go in details)
if (go.IsValid() && detailItems.TryGetValue(go, out var hair))
go.Enabled = !activeItems.Any(other => other != hair && !ClothingResources.IsBaseSkin(other) && !hair.CanBeWornWith(other));
foreach (var group in new ClothingContainer
{
PrefersHuman = true
}.GetBodyGroups(activeItems, Body.Model))
{
if (group.Item1 == "Head" && !activeItems.Any(i => !ClothingResources.IsBaseSkin(i) && (i.HideBody & Clothing.BodyGroups.Head) != 0) && (data.BaseShape is not null || group.Item2 == 0))
continue;
Body.SetBodyGroup(group.Item1, group.Item2);
}
clothing = showClothing;
bodyGroupsDirty = false;
}
if (data.BaseShape is not null)
{
var worn = dresser.IsValid() ? dresser.Clothing.Where(e => e.Clothing is not null).Select(e => e.Clothing.ResourcePath) : WardrobeCatalog.Paths(data);
bool covered = showClothing && worn.Any(path => (ClothingResources.Get(path)?.HideBody.ToString() ?? "").Split(',').Any(x => x.Trim() == "Chest"));
Body.SetBodyGroup("UnderwearTop", data.BaseShape.MaleBody > .60f || covered ? 1 : 0);
}
bool needsNeck = data.BaseShape is not null && showClothing && Body.GetBodyGroup("Head") == 1;
if (needsNeck)
{
var wornHeads = dresser.IsValid() ? dresser.Clothing.Where(e => e.Clothing is not null).Select(e => e.Clothing) : WardrobeCatalog.Paths(data).Select(ClothingResources.Get);
needsNeck = !wornHeads.Any(item => (item.HideBody & Clothing.BodyGroups.Head) != 0 && !UnifiedWardrobe.RetainsHumanNeck(item));
}
if (needsNeck && !replacementNeck.IsValid())
{
var go = new GameObject { Parent = Body.GameObject, Name = "Replacement head neck", NetworkMode = NetworkMode.Never };
go.LocalTransform = Transform.Zero;
replacementNeck = go.Components.Create<SkinnedModelRenderer>();
replacementNeck.Model = AutomaticWardrobeFit.ReplacementNeck();
replacementNeck.BoneMergeTarget = Body;
}
if (replacementNeck.IsValid())
{
replacementNeck.GameObject.Enabled = needsNeck;
if (needsNeck)
{
UnifiedWardrobe.Apply(replacementNeck, data);
neckColors.ApplyEmbeddedSkin(replacementNeck, data);
}
}
Error = null;
}
public void Dispose()
{
colors.Dispose();
neckColors.Dispose();
if (replacementNeck.IsValid()) replacementNeck.GameObject.Destroy();
foreach (var presentation in clothingSkin.Values)
presentation.Dispose();
clothingSkin.Clear();
if (deformation.IsValid())
deformation.Destroy();
if (ownsBody && Body.IsValid())
Body.GameObject.Destroy();
}
}
public static class VoiceIds
{
public static bool IsSupported(string id) => id is "boy-teen" or "elven-woman" or "girl-young" or "man-adult" or "man-adult-deep" or "scientist" or "woman-adult" or "woman-young-adult";
}