diff --git a/.gitignore b/.gitignore index 975cf4a..bb3c408 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ # Visual Studio cache directory .vs/ +# JetBrains IDE directory +.idea/ + # Gradle cache directory .gradle/ diff --git a/Assets/Scenes/Main.unity b/Assets/Scenes/Main.unity index 6948f0d..42e9794 100644 --- a/Assets/Scenes/Main.unity +++ b/Assets/Scenes/Main.unity @@ -130,6 +130,7 @@ GameObject: - component: {fileID: 9355361} - component: {fileID: 9355362} - component: {fileID: 9355363} + - component: {fileID: 9355364} m_Layer: 0 m_Name: AvatarRoot m_TagString: Untagged @@ -233,6 +234,7 @@ MonoBehaviour: - {fileID: 979165189} - {fileID: 979165180} - {fileID: 979165181} + springBonesDriver: {fileID: 9355364} setsHighlight: 0 highlightCenter: {x: 0, y: 0.18, z: 0} highlightSize: {x: 0.57, y: 2.3} @@ -254,6 +256,18 @@ MonoBehaviour: autoRotateSpeed: 10 autoRotateDelay: 1 returnSpeed: 2 +--- !u!114 &9355364 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9355360} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 95ea887eb11d7f94eaef9ace0bb62be9, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::SpringBones.SpringBonesDriver --- !u!1001 &64509934 PrefabInstance: m_ObjectHideFlags: 0 @@ -2747,6 +2761,7 @@ GameObject: m_Component: - component: {fileID: 8085770638703914882} - component: {fileID: 8085770638703914883} + - component: {fileID: 8085770638703914884} m_Layer: 0 m_Name: AvatarRoot m_TagString: Untagged @@ -4566,9 +4581,22 @@ MonoBehaviour: - {fileID: 64509949} - {fileID: 64509940} - {fileID: 64509941} + springBonesDriver: {fileID: 8085770638703914884} setsHighlight: 1 highlightCenter: {x: 0, y: 0.18, z: 0} highlightSize: {x: 0.57, y: 2.3} +--- !u!114 &8085770638703914884 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2042814726327143098} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 95ea887eb11d7f94eaef9ace0bb62be9, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::SpringBones.SpringBonesDriver --- !u!114 &8186094461447319126 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/Assets/Scripts/AangConfiguration.cs b/Assets/Scripts/AangConfiguration.cs index 9479ac1..8fea09c 100644 --- a/Assets/Scripts/AangConfiguration.cs +++ b/Assets/Scripts/AangConfiguration.cs @@ -246,8 +246,8 @@ public static void RecreateFrom(string url) foreach (var parameter in split) { var keyValueSplit = parameter.Split('='); - var key = HttpUtility.UrlDecode(keyValueSplit[0]); - var value = keyValueSplit.Length > 1 ? HttpUtility.UrlDecode(keyValueSplit[1]) : string.Empty; + var key = HttpUtility.UrlDecode(keyValueSplit[0]).Trim(); + var value = (keyValueSplit.Length > 1 ? HttpUtility.UrlDecode(keyValueSplit[1]) : string.Empty).Trim(); switch (key) { @@ -298,7 +298,7 @@ public static void RecreateFrom(string url) break; case "env": APIService.Environment = value == "dev" ? "zone" : "org"; - Debug.Log($"Using environment {APIService.Environment}"); + Debug.Log($"Using environment {APIService.Environment} (env value=[{value}])"); break; case "disableLoader": Instance.DisableLoader = bool.Parse(value); diff --git a/Assets/Scripts/Data/ActiveEntityResponse.cs b/Assets/Scripts/Data/ActiveEntityResponse.cs index 9051bdc..04dbd77 100644 --- a/Assets/Scripts/Data/ActiveEntityResponse.cs +++ b/Assets/Scripts/Data/ActiveEntityResponse.cs @@ -43,6 +43,7 @@ public class Data public string[] replaces = Array.Empty(); public string[] removesDefaultHiding = Array.Empty(); public bool loop; // For emotes only + public SpringBonesDto springBones; } [Serializable] diff --git a/Assets/Scripts/Data/EntityDefinition.cs b/Assets/Scripts/Data/EntityDefinition.cs index 47fbe86..7156797 100644 --- a/Assets/Scripts/Data/EntityDefinition.cs +++ b/Assets/Scripts/Data/EntityDefinition.cs @@ -19,8 +19,25 @@ public class EntityDefinition public readonly EntityType Type; public readonly EntityFlags Flags; + [CanBeNull] private readonly SpringBonesDto _springBones; + [ItemCanBeNull] private readonly Dictionary _representations; + /// + /// Returns the spring-bone params (boneName → params) declared in the wearable + /// metadata for the given body shape's mainFile, or null if none. + /// + [CanBeNull] + public IReadOnlyDictionary GetSpringBoneParams(BodyShape shape) + { + if (_springBones?.models == null) return null; + if (!HasRepresentation(shape)) return null; + + var mainFileHash = _representations[shape].MainFileHash; + if (string.IsNullOrEmpty(mainFileHash)) return null; + return _springBones.models.TryGetValue(mainFileHash, out var map) ? map : null; + } + public Representation[] GetAllRepresentations() { // TODO: Cleanup @@ -48,7 +65,8 @@ public bool HasRepresentation(BodyShape bodyShape) public Representation this[BodyShape shape] => _representations[shape] ?? throw new InvalidOperationException( $"Missing {shape} representation for {URN}"); - private EntityDefinition(string urn, string category, string thumbnail, EntityType type, EntityFlags flags, Dictionary representations) + private EntityDefinition(string urn, string category, string thumbnail, EntityType type, EntityFlags flags, + Dictionary representations, [CanBeNull] SpringBonesDto springBones) { URN = urn; Category = category; @@ -56,20 +74,23 @@ private EntityDefinition(string urn, string category, string thumbnail, EntityTy Type = type; _representations = representations; Flags = flags; + _springBones = springBones; } public class Representation { public readonly Dictionary Files; public readonly string MainFile; + public readonly string MainFileHash; public readonly string[] Hides; public readonly string[] RemovesDefaultHiding; - private Representation(Dictionary files, string mainFile, string[] hides, - string[] removesDefaultHiding) + private Representation(Dictionary files, string mainFile, string mainFileHash, + string[] hides, string[] removesDefaultHiding) { Files = files; MainFile = mainFile; + MainFileHash = mainFileHash; Hides = hides; RemovesDefaultHiding = removesDefaultHiding; } @@ -112,9 +133,13 @@ public static Representation ForBodyShape(string bodyShape, ActiveEntity entity) StringComparer.OrdinalIgnoreCase ); + var mainFileHash = entity.content + .FirstOrDefault(c => string.Equals(c.file, main, StringComparison.OrdinalIgnoreCase))?.hash; + var representation = new Representation( filesDict, entityRepresentation.mainFile, + mainFileHash, entityRepresentation.overrideHides is { Length: > 0 } ? entityRepresentation.overrideHides : data.hides.Union(entityRepresentation.overrideReplaces is { Length: > 0 } @@ -135,7 +160,7 @@ public static Representation ForEmbeddedEmote(string emote) { ["main"] = Path.Combine(Application.streamingAssetsPath, $"{emote}.glb") }, - "main", Array.Empty(), + "main", null, Array.Empty(), Array.Empty() ); } @@ -161,7 +186,8 @@ public static EntityDefinition FromActiveEntity(ActiveEntity entity) [BodyShape.Female] = Representation.ForBodyShape(WearablesConstants.BODY_SHAPE_FEMALE, entity) }; - return new EntityDefinition(urn, category, thumbnail, type, flags, representations); + return new EntityDefinition(urn, category, thumbnail, type, flags, representations, + entity.IsEmote ? null : entity.metadata?.data?.springBones); } public static EntityDefinition FromBase64(byte[] b64) @@ -173,6 +199,23 @@ public static EntityDefinition FromBase64(byte[] b64) var raw = JsonUtility.FromJson(base64String); var entity = raw.ToActiveEntity(); + // JsonUtility can't deserialize the nested Dictionary in springBones.models, + // so re-parse just that field with Newtonsoft and inject it into the entity. + if (!entity.IsEmote && entity.metadata?.data != null) + { + try + { + var jo = Newtonsoft.Json.Linq.JObject.Parse(base64String); + var springBonesToken = jo["data"]?["springBones"]; + if (springBonesToken != null) + entity.metadata.data.springBones = springBonesToken.ToObject(); + } + catch (Exception e) + { + Debug.LogError($"[Base64] failed to parse springBones: {e.Message}"); + } + } + return FromActiveEntity(entity); } @@ -188,7 +231,8 @@ public static EntityDefinition FromEmbeddedEmote(string emote, bool loop) { [BodyShape.Male] = Representation.ForEmbeddedEmote(emote), [BodyShape.Female] = Representation.ForEmbeddedEmote(emote) - } + }, + null ); } } diff --git a/Assets/Scripts/Data/RawActiveEntity.cs b/Assets/Scripts/Data/RawActiveEntity.cs index 460c2ad..d2a771c 100644 --- a/Assets/Scripts/Data/RawActiveEntity.cs +++ b/Assets/Scripts/Data/RawActiveEntity.cs @@ -54,6 +54,14 @@ public class Translation public string text; } + static string ExtractHashFromUrl(string url) + { + if (string.IsNullOrEmpty(url)) return null; + var trimmed = url.TrimEnd('/'); + var slash = trimmed.LastIndexOf('/'); + return slash >= 0 && slash + 1 < trimmed.Length ? trimmed[(slash + 1)..] : null; + } + public ActiveEntity ToActiveEntity() { var reps = IsEmote ? emoteDataADR74.representations : data.representations; @@ -64,7 +72,14 @@ public ActiveEntity ToActiveEntity() type = IsEmote ? "emote" : "wearable", content = reps .SelectMany(r => r.contents - .Select(c => new ActiveEntity.Content { file = c.key, url = c.url })) + .Select(c => new ActiveEntity.Content + { + file = c.key, + url = c.url, + // Spring-bone metadata keys models by content hash (CID). The CID is the + // last path segment of the builder-api URL — extract it so the lookup works. + hash = ExtractHashFromUrl(c.url), + })) .GroupBy(c => c.file) .Select(g => g.First()) .ToArray(), diff --git a/Assets/Scripts/Data/SpringBonesDto.cs b/Assets/Scripts/Data/SpringBonesDto.cs new file mode 100644 index 0000000..02d577a --- /dev/null +++ b/Assets/Scripts/Data/SpringBonesDto.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace Data +{ + [Serializable] + public class SpringBonesDto + { + public int version; + public Dictionary> models; + } + + [Serializable] + public class SpringBoneParamsDto + { + public float stiffness; + public float drag; + + [JsonConverter(typeof(Vector3JsonConverter))] + public Vector3 gravityDir; + + public float gravityPower; + public float hitRadius; + public bool isRoot; + } + + public class Vector3JsonConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Vector3 value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("x"); + writer.WriteValue(value.x); + writer.WritePropertyName("y"); + writer.WriteValue(value.y); + writer.WritePropertyName("z"); + writer.WriteValue(value.z); + writer.WriteEndObject(); + } + + public override Vector3 ReadJson(JsonReader reader, Type objectType, Vector3 existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) return existingValue; + var t = JToken.Load(reader); + if (t.Type == JTokenType.Array) + { + return new Vector3( + t.Count() > 0 ? t[0].Value() : 0f, + t.Count() > 1 ? t[1].Value() : 0f, + t.Count() > 2 ? t[2].Value() : 0f); + } + return new Vector3( + t["x"]?.Value() ?? 0f, + t["y"]?.Value() ?? 0f, + t["z"]?.Value() ?? 0f); + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Data/SpringBonesDto.cs.meta b/Assets/Scripts/Data/SpringBonesDto.cs.meta new file mode 100644 index 0000000..7333e87 --- /dev/null +++ b/Assets/Scripts/Data/SpringBonesDto.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 6078dceae9683604d8ac67c9892e95a3 +timeCreated: 1745500000 \ No newline at end of file diff --git a/Assets/Scripts/JSBridge.cs b/Assets/Scripts/JSBridge.cs index b7190b1..a3e1a97 100644 --- a/Assets/Scripts/JSBridge.cs +++ b/Assets/Scripts/JSBridge.cs @@ -81,6 +81,20 @@ public void SetUrns(string value) => [UsedImplicitly] public void SetUsername(string value) => AangConfiguration.Instance.Username = value; + [UsedImplicitly] + public void SetSpringBonesParams(string value) + { + SpringBones.SpringBonesParamsPayload payload; + try { payload = SpringBones.SpringBonesParamsPayload.Parse(value); } + catch (Exception e) + { + Debug.LogError($"[SpringBones] failed to parse SetSpringBonesParams payload: {e.Message}"); + return; + } + if (payload == null) return; + previewController.SetSpringBonesParams(payload); + } + [UsedImplicitly] public void GetElementBounds(string elementName) => configuratorUIPresenter.GetElementBounds(elementName); diff --git a/Assets/Scripts/Loading/AvatarLoader.cs b/Assets/Scripts/Loading/AvatarLoader.cs index 97532a9..f234be6 100644 --- a/Assets/Scripts/Loading/AvatarLoader.cs +++ b/Assets/Scripts/Loading/AvatarLoader.cs @@ -7,6 +7,7 @@ using JetBrains.Annotations; using Rendering; using Services; +using SpringBones; using UnityEngine; using UnityEngine.Assertions; using UnityEngine.Serialization; @@ -24,6 +25,7 @@ public class AvatarLoader : MonoBehaviour [SerializeField] private Animation avatarAnimation; [SerializeField] private Transform avatarRootBone; [SerializeField] private Transform[] avatarBones; + [SerializeField] private SpringBonesDriver springBonesDriver; [Header("Highlight"), SerializeField] private bool setsHighlight; [SerializeField] private Vector3 highlightCenter = new(0, 0.18f, 0); @@ -35,6 +37,10 @@ public class AvatarLoader : MonoBehaviour private readonly Dictionary _loadedFacialFeatures = new(); private LoadedEmote? _loadedEmote; + // JSBridge spring-bone overrides keyed by itemId; these take precedence over the + // params declared in the wearable definition and are re-applied after every reload. + private readonly Dictionary> _springBoneOverrides = new(); + private readonly Dictionary _defaultBodyFacialFeatures = new(); private readonly HashSet _hiddenCategories = new(); @@ -154,8 +160,7 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable(avatarBones.Length); + foreach (var b in avatarBones) + if (b != null) liveBoneMap[b.name] = b; + springBonesDriver.AvatarBoneMap = liveBoneMap; + + springBonesDriver.UnregisterAll(); + + // Prune overrides whose wearable is no longer loaded so the dict cannot grow unbounded + foreach (var itemId in _springBoneOverrides.Keys.ToList()) + { + if (!TryFindWearableByItemId(itemId, out _)) + _springBoneOverrides.Remove(itemId); + } + + var ownersWithOverride = new HashSet(); + foreach (var (itemId, paramsByBone) in _springBoneOverrides) + { + if (TryFindWearableByItemId(itemId, out var owner)) + { + springBonesDriver.SetSpringChainsForWearable(owner, paramsByBone); + ownersWithOverride.Add(owner); + } + } + + foreach (var loaded in _loadedModels.Values.Where(m => m.Root.activeSelf)) + { + if (ownersWithOverride.Contains(loaded.Root)) continue; + var meta = ConvertMetadataParams(loaded.Entity.GetSpringBoneParams(bodyShape)); + if (meta != null && meta.Count > 0) + springBonesDriver.SetSpringChainsForWearable(loaded.Root, meta); + } + } + else + { + Debug.LogError("[SpringBones] springBonesDriver not wired on AvatarLoader"); + } + // If there is a new emote to be played if (emoteChanged) { @@ -182,6 +229,69 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable ConvertMetadataParams( + IReadOnlyDictionary source) + { + if (source == null || source.Count == 0) return null; + var result = new Dictionary(source.Count); + foreach (var (boneName, m) in source) + { + result[boneName] = new SpringBoneParamsDTO + { + stiffness = m.stiffness, + drag = m.drag, + gravityPower = m.gravityPower, + gravityDir = new[] { m.gravityDir.x, m.gravityDir.y, m.gravityDir.z }, + isRoot = m.isRoot, + }; + } + return result; + } + + private bool TryFindWearableByItemId(string itemId, out GameObject owner) + { + if (_loadedModels.TryGetValue(itemId, out var exact)) + { + owner = exact.Root; + return true; + } + foreach (var m in _loadedModels.Values) + { + if (m.Entity.URN != null && m.Entity.URN.EndsWith(itemId, StringComparison.Ordinal)) + { + owner = m.Root; + return true; + } + } + foreach (var m in _loadedModels.Values) + { + if (m.Entity.URN != null && m.Entity.URN.Contains(itemId)) + { + owner = m.Root; + return true; + } + } + owner = null; + return false; + } + public void HideFacialFeatures() { var bodyGO = _loadedModels.Values.FirstOrDefault(er => er.Entity.Type == EntityType.Body).Root; diff --git a/Assets/Scripts/Loading/GLTFLoader.cs b/Assets/Scripts/Loading/GLTFLoader.cs index a5b38da..4cb543f 100644 --- a/Assets/Scripts/Loading/GLTFLoader.cs +++ b/Assets/Scripts/Loading/GLTFLoader.cs @@ -42,7 +42,8 @@ public static async Task LoadModel(BodyShape bodyShape, EntityDefin root.SetActive(false); root.transform.SetParent(parent, false); - await importer.InstantiateSceneAsync(root.transform); + var instantiator = new DclGameObjectInstantiator(importer, root.transform); + await importer.InstantiateSceneAsync(instantiator); Sanitize(root.transform); diff --git a/Assets/Scripts/Loading/WearableLoader.cs b/Assets/Scripts/Loading/WearableLoader.cs index fb337f3..550075b 100644 --- a/Assets/Scripts/Loading/WearableLoader.cs +++ b/Assets/Scripts/Loading/WearableLoader.cs @@ -81,9 +81,8 @@ public async Awaitable LoadWearable(EntityDefinition entityDefinition, BodyShape throw new NotSupportedException($"Trying to load unsupported wearable type: {entityDefinition.Type}"); } - // Colors _outlineRenderers.Clear(); - AvatarUtils.SetupColors(_wearableGO, colors, _outlineRenderers); + AvatarUtils.SetupWearable(_wearableGO, colors, _outlineRenderers); } private void Update() diff --git a/Assets/Scripts/Preview/PreviewController.cs b/Assets/Scripts/Preview/PreviewController.cs index 5046281..842656a 100644 --- a/Assets/Scripts/Preview/PreviewController.cs +++ b/Assets/Scripts/Preview/PreviewController.cs @@ -84,6 +84,9 @@ private void OnShowAvatarClicked() avatarRotator.ResetRotation(); } + public void SetSpringBonesParams(SpringBones.SpringBonesParamsPayload payload) => + avatarLoader.SetSpringBonesParams(payload); + public float GetEmoteLength() => emoteAnimationController.GetEmoteLength(); public bool IsEmotePlaying() => emoteAnimationController.IsEmotePlaying(); diff --git a/Assets/Scripts/Services/APIService.cs b/Assets/Scripts/Services/APIService.cs index bb0da21..620d4f6 100644 --- a/Assets/Scripts/Services/APIService.cs +++ b/Assets/Scripts/Services/APIService.cs @@ -1,5 +1,6 @@ using System; using Data; +using Newtonsoft.Json; using UnityEngine; using UnityEngine.Networking; using Utils; @@ -59,29 +60,25 @@ public static Awaitable GetMarketplaceItemFromID(string public static Awaitable GetMarketplaceItemFromToken(string contract, string tokenID) => GetWithRetry(APIMarketplaceTokenID, contract, tokenID); - public static Awaitable GetActiveEntities(string[] pointers) => - PostWithRetryArray(APIActiveEntities, new ActiveEntitiesRequest(pointers)); - - - private static async Awaitable PostWithRetryArray(string url, object data) + public static async Awaitable GetActiveEntities(string[] pointers) { var retries = 0; - UnityWebRequest request; do { - request = UnityWebRequest.Post(url, JsonUtility.ToJson(data), "application/json"); + request = UnityWebRequest.Post(APIActiveEntities, + JsonUtility.ToJson(new ActiveEntitiesRequest(pointers)), "application/json"); await request.SendWebRequest(); retries++; } while (request.result != UnityWebRequest.Result.Success && retries <= RETRY_COUNT); if (request.result != UnityWebRequest.Result.Success) - { throw new Exception(request.error); - } - return FromJsonArray(request.downloadHandler.text); + // Newtonsoft (not JsonUtility) because ActiveEntity.springBones contains a + // Dictionary> that JsonUtility can't bind. + return JsonConvert.DeserializeObject(request.downloadHandler.text) ?? Array.Empty(); } @@ -106,15 +103,5 @@ private static async Awaitable GetWithRetry(string url, params object[] ar return JsonUtility.FromJson(request.downloadHandler.text); } - private static T[] FromJsonArray(string json) - { - return JsonUtility.FromJson>($"{{\"items\":{json}}}").items; - } - - [Serializable] - private class JsonArrayWrapper - { - public T[] items; - } } } \ No newline at end of file diff --git a/Assets/Scripts/SpringBones.meta b/Assets/Scripts/SpringBones.meta new file mode 100644 index 0000000..869ca3d --- /dev/null +++ b/Assets/Scripts/SpringBones.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cfcf18df84ddcb44ea36dac1cea97307 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SpringBones/SpringBoneJointConfig.cs b/Assets/Scripts/SpringBones/SpringBoneJointConfig.cs new file mode 100644 index 0000000..5c96e52 --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneJointConfig.cs @@ -0,0 +1,15 @@ +using Unity.Mathematics; + +namespace SpringBones +{ + public struct SpringBoneJointConfig + { + public float Stiffness; + public float Drag; + public float3 GravityDir; + public float GravityPower; + public float3 BoneAxis; + public float Length; + public quaternion LocalRotation; + } +} diff --git a/Assets/Scripts/SpringBones/SpringBoneJointConfig.cs.meta b/Assets/Scripts/SpringBones/SpringBoneJointConfig.cs.meta new file mode 100644 index 0000000..7606e8e --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneJointConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 69d1785c5fd338e46a8d66f738e4bc58 \ No newline at end of file diff --git a/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs b/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs new file mode 100644 index 0000000..d0d2672 --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace SpringBones +{ + /// + /// Runtime spring bones tweak payload from builder. Mirrors + /// decentraland/schemas src/dapps/preview/spring-bone-params.ts. + /// + [Serializable] + public class SpringBonesParamsPayload + { + public string itemId; + [JsonProperty("params")] + public Dictionary parameters; + + public static SpringBonesParamsPayload Parse(string json) => + JsonConvert.DeserializeObject(json); + } + + [Serializable] + public class SpringBoneParamsDTO + { + public float stiffness; + public float gravityPower; + public float[] gravityDir; + public float drag; + public string center; + public bool isRoot; + } +} diff --git a/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs.meta b/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs.meta new file mode 100644 index 0000000..0afd64c --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b630aae664877dd498d74c66a7831fde \ No newline at end of file diff --git a/Assets/Scripts/SpringBones/SpringBoneParentData.cs b/Assets/Scripts/SpringBones/SpringBoneParentData.cs new file mode 100644 index 0000000..18d3576 --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneParentData.cs @@ -0,0 +1,10 @@ +using Unity.Mathematics; + +namespace SpringBones +{ + public struct SpringBoneParentData + { + public quaternion Rotation; + public float4x4 LocalToWorldMatrix; + } +} diff --git a/Assets/Scripts/SpringBones/SpringBoneParentData.cs.meta b/Assets/Scripts/SpringBones/SpringBoneParentData.cs.meta new file mode 100644 index 0000000..1961881 --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneParentData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 526faf577c54b334c85cafc69f567ddf \ No newline at end of file diff --git a/Assets/Scripts/SpringBones/SpringBoneService.cs b/Assets/Scripts/SpringBones/SpringBoneService.cs new file mode 100644 index 0000000..3cec569 --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneService.cs @@ -0,0 +1,189 @@ +using System; +using Unity.Mathematics; +using UnityEngine; + +namespace SpringBones +{ + public class SpringBoneService : IDisposable + { + public const int MAX_JOINTS_PER_SPRING = 8; + const int INITIAL_SLOT_CAPACITY = 32; + const float FIXED_STEP = 1f / 60f; + const int MAX_SUBSTEPS = 4; + + int slotCapacity; + Transform[] managedTransforms; + SpringBoneTransformData[] transforms; + float3[] prevTails; + float3[] currentTails; + float3[] nextTails; + SpringBoneJointConfig[] jointConfigs; + int[] slotJointCounts; + SpringBoneParentData[] parentData; + float accumulatedDt; + + public SpringBoneService() + { + slotCapacity = INITIAL_SLOT_CAPACITY; + AllocateArrays(); + } + + void AllocateArrays() + { + int totalJoints = slotCapacity * MAX_JOINTS_PER_SPRING; + + managedTransforms = new Transform[totalJoints]; + transforms = new SpringBoneTransformData[totalJoints]; + prevTails = new float3[totalJoints]; + currentTails = new float3[totalJoints]; + nextTails = new float3[totalJoints]; + jointConfigs = new SpringBoneJointConfig[totalJoints]; + slotJointCounts = new int[slotCapacity]; + parentData = new SpringBoneParentData[slotCapacity]; + } + + public int RegisterSpring(Transform[] jointTransforms, SpringBoneJointConfig[] configs, float3[] initialTailPositions) + { + int jointCount = jointTransforms.Length; + + int slotIndex = FindEmptySlot(); + if (slotIndex < 0) + { + Grow(); + slotIndex = FindEmptySlot(); + } + + slotJointCounts[slotIndex] = jointCount; + int baseIndex = slotIndex * MAX_JOINTS_PER_SPRING; + + for (int j = 0; j < jointCount; j++) + { + int idx = baseIndex + j; + managedTransforms[idx] = jointTransforms[j]; + jointConfigs[idx] = configs[j]; + prevTails[idx] = initialTailPositions[j]; + currentTails[idx] = initialTailPositions[j]; + nextTails[idx] = initialTailPositions[j]; + } + + return slotIndex; + } + + public void UnregisterSpring(int slotIndex) + { + slotJointCounts[slotIndex] = 0; + int baseIndex = slotIndex * MAX_JOINTS_PER_SPRING; + + for (int j = 0; j < MAX_JOINTS_PER_SPRING; j++) + managedTransforms[baseIndex + j] = null; + } + + public void SetParentData(int slotIndex, quaternion rotation, float4x4 localToWorldMatrix) + { + parentData[slotIndex] = new SpringBoneParentData + { + Rotation = rotation, + LocalToWorldMatrix = localToWorldMatrix, + }; + } + + public void Simulate(float deltaTime) + { + // Decouple physics from render fps. Step at fixed 60 Hz so authored stiffness/drag + // produce identical motion regardless of frame rate. + accumulatedDt += deltaTime; + + int steps = 0; + while (accumulatedDt >= FIXED_STEP && steps < MAX_SUBSTEPS) + { + SimulateStep(FIXED_STEP); + accumulatedDt -= FIXED_STEP; + steps++; + } + + // Drop residual after a stall to avoid spiral-of-death. + if (steps == MAX_SUBSTEPS) + accumulatedDt = 0f; + } + + void SimulateStep(float deltaTime) + { + for (int slot = 0; slot < slotCapacity; slot++) + { + int jointCount = slotJointCounts[slot]; + if (jointCount == 0) continue; + + int baseIndex = slot * MAX_JOINTS_PER_SPRING; + for (int j = 0; j < jointCount; j++) + { + int idx = baseIndex + j; + transforms[idx] = SpringBoneTransformData.FromTransform(managedTransforms[idx]); + } + } + + // Rotate ring buffer: prev gets old current, current gets old next, next becomes scratch (old prev). + (prevTails, currentTails, nextTails) = (currentTails, nextTails, prevTails); + + for (int slot = 0; slot < slotCapacity; slot++) + { + int jointCount = slotJointCounts[slot]; + if (jointCount == 0) continue; + + SpringBoneSimulator.SimulateSlot( + slot, jointCount, jointConfigs, parentData[slot], + transforms, prevTails, currentTails, nextTails, deltaTime); + } + + for (int slot = 0; slot < slotCapacity; slot++) + { + int jointCount = slotJointCounts[slot]; + if (jointCount == 0) continue; + + int baseIndex = slot * MAX_JOINTS_PER_SPRING; + for (int j = 0; j < jointCount; j++) + { + int idx = baseIndex + j; + managedTransforms[idx].rotation = transforms[idx].Rotation; + } + } + } + + int FindEmptySlot() + { + for (int i = 0; i < slotCapacity; i++) + if (slotJointCounts[i] == 0) return i; + return -1; + } + + void Grow() + { + int newCapacity = slotCapacity * 2; + int oldTotalJoints = slotCapacity * MAX_JOINTS_PER_SPRING; + int newTotalJoints = newCapacity * MAX_JOINTS_PER_SPRING; + + Array.Resize(ref managedTransforms, newTotalJoints); + Array.Resize(ref transforms, newTotalJoints); + Array.Resize(ref prevTails, newTotalJoints); + Array.Resize(ref currentTails, newTotalJoints); + Array.Resize(ref nextTails, newTotalJoints); + Array.Resize(ref jointConfigs, newTotalJoints); + Array.Resize(ref slotJointCounts, newCapacity); + Array.Resize(ref parentData, newCapacity); + + slotCapacity = newCapacity; + } + + public void Dispose() + { + managedTransforms = null; + transforms = null; + prevTails = null; + currentTails = null; + nextTails = null; + jointConfigs = null; + slotJointCounts = null; + parentData = null; + accumulatedDt = 0f; + } + } +} diff --git a/Assets/Scripts/SpringBones/SpringBoneService.cs.meta b/Assets/Scripts/SpringBones/SpringBoneService.cs.meta new file mode 100644 index 0000000..c4fa83a --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 637e6f7d272c15a409991df29ed756c4 \ No newline at end of file diff --git a/Assets/Scripts/SpringBones/SpringBoneSimulator.cs b/Assets/Scripts/SpringBones/SpringBoneSimulator.cs new file mode 100644 index 0000000..1834138 --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneSimulator.cs @@ -0,0 +1,127 @@ +using System.Runtime.CompilerServices; +using Unity.Mathematics; + +namespace SpringBones +{ + internal static class SpringBoneSimulator + { + const float EPSILON = 0.0001f; + + public static void SimulateSlot( + int slotIndex, + int jointCount, + SpringBoneJointConfig[] jointConfigs, + SpringBoneParentData parentData, + SpringBoneTransformData[] transforms, + float3[] prevTails, + float3[] currentTails, + float3[] nextTails, + float deltaTime) + { + int baseIndex = slotIndex * SpringBoneService.MAX_JOINTS_PER_SPRING; + + quaternion parentRotation = parentData.Rotation; + float4x4 parentLocalToWorld = parentData.LocalToWorldMatrix; + + for (int j = 0; j < jointCount; j++) + { + int idx = baseIndex + j; + var config = jointConfigs[idx]; + var head = transforms[idx]; + + head = UpdateParentMatrix(head, parentRotation, parentLocalToWorld); + + float3 gravity = config.GravityDir * config.GravityPower * deltaTime; + + float3 stiffnessForce = math.mul(math.mul(parentRotation, config.LocalRotation), config.BoneAxis) + * config.Stiffness * deltaTime; + + float3 currentTail = currentTails[idx]; + float3 prevTail = prevTails[idx]; + + float3 nextTail = currentTail + + (currentTail - prevTail) * (1f - config.Drag) + + stiffnessForce + + gravity; + + float3 headToTail = nextTail - head.Position; + float len = math.length(headToTail); + + nextTail = len > EPSILON + ? head.Position + (headToTail / len) * config.Length + : head.Position + math.mul(math.mul(parentRotation, config.LocalRotation), config.BoneAxis) * config.Length; + + nextTails[idx] = nextTail; + + quaternion currentRot = math.mul(parentRotation, config.LocalRotation); + float3 currentDir = math.mul(currentRot, config.BoneAxis); + float3 targetDir = nextTail - head.Position; + + quaternion newRotation = math.mul(FromToRotation(currentDir, targetDir), currentRot); + head = UpdateRotation(head, newRotation, parentRotation, parentLocalToWorld); + transforms[idx] = head; + + parentRotation = head.Rotation; + parentLocalToWorld = head.LocalToWorldMatrix; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static SpringBoneTransformData UpdateParentMatrix(SpringBoneTransformData head, quaternion parentRotation, float4x4 parentLocalToWorld) + { + quaternion newRotation = math.mul(parentRotation, head.LocalRotation); + float4x4 newLocalToWorld = math.mul(parentLocalToWorld, float4x4.TRS(head.LocalPosition, head.LocalRotation, head.LocalScale)); + + return new SpringBoneTransformData( + newRotation, + head.LocalPosition, + head.LocalRotation, + head.LocalScale, + newLocalToWorld); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static SpringBoneTransformData UpdateRotation(SpringBoneTransformData head, quaternion newWorldRotation, quaternion parentRotation, float4x4 parentLocalToWorld) + { + quaternion newLocalRotation = math.normalize(math.mul(math.inverse(parentRotation), newWorldRotation)); + float4x4 newLocalToWorld = math.mul(parentLocalToWorld, float4x4.TRS(head.LocalPosition, newLocalRotation, head.LocalScale)); + + return new SpringBoneTransformData( + newWorldRotation, + head.LocalPosition, + newLocalRotation, + head.LocalScale, + newLocalToWorld); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static quaternion FromToRotation(in float3 from, in float3 to) + { + float fromLenSq = math.lengthsq(from); + float toLenSq = math.lengthsq(to); + + if (fromLenSq < EPSILON || toLenSq < EPSILON) + return quaternion.identity; + + float3 f = math.normalize(from); + float3 t = math.normalize(to); + float dot = math.dot(f, t); + + if (dot >= 1f) return quaternion.identity; + + if (dot <= -1f) + { + float3 axis = math.cross(f, new float3(1, 0, 0)); + + if (math.lengthsq(axis) < EPSILON) + axis = math.cross(f, new float3(0, 1, 0)); + + return quaternion.AxisAngle(math.normalize(axis), math.PI); + } + + float angle = math.acos(dot); + float3 rotAxis = math.normalize(math.cross(f, t)); + return quaternion.AxisAngle(rotAxis, angle); + } + } +} diff --git a/Assets/Scripts/SpringBones/SpringBoneSimulator.cs.meta b/Assets/Scripts/SpringBones/SpringBoneSimulator.cs.meta new file mode 100644 index 0000000..fee24da --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneSimulator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 851641f2626d6ec44ba9c72c3d600cf5 \ No newline at end of file diff --git a/Assets/Scripts/SpringBones/SpringBoneTransformData.cs b/Assets/Scripts/SpringBones/SpringBoneTransformData.cs new file mode 100644 index 0000000..c3d3d7b --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneTransformData.cs @@ -0,0 +1,37 @@ +using System.Runtime.CompilerServices; +using Unity.Mathematics; +using UnityEngine; + +namespace SpringBones +{ + public readonly struct SpringBoneTransformData + { + public readonly float3 Position; + public readonly quaternion Rotation; + public readonly float3 LocalPosition; + public readonly quaternion LocalRotation; + public readonly float3 LocalScale; + public readonly float4x4 LocalToWorldMatrix; + + public SpringBoneTransformData(quaternion rotation, float3 localPosition, quaternion localRotation, float3 localScale, float4x4 localToWorldMatrix) + { + Position = localToWorldMatrix.c3.xyz; + Rotation = rotation; + LocalPosition = localPosition; + LocalRotation = localRotation; + LocalScale = localScale; + LocalToWorldMatrix = localToWorldMatrix; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static SpringBoneTransformData FromTransform(Transform t) + { + return new SpringBoneTransformData( + t.rotation, + t.localPosition, + t.localRotation, + t.localScale, + t.localToWorldMatrix); + } + } +} diff --git a/Assets/Scripts/SpringBones/SpringBoneTransformData.cs.meta b/Assets/Scripts/SpringBones/SpringBoneTransformData.cs.meta new file mode 100644 index 0000000..65c1477 --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneTransformData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b43c1feda0e0afd4ebf9da3e02ac9382 \ No newline at end of file diff --git a/Assets/Scripts/SpringBones/SpringBonesDriver.cs b/Assets/Scripts/SpringBones/SpringBonesDriver.cs new file mode 100644 index 0000000..591196d --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBonesDriver.cs @@ -0,0 +1,252 @@ +using System.Collections.Generic; +using Unity.Mathematics; +using UnityEngine; + +namespace SpringBones +{ + public class SpringBonesDriver : MonoBehaviour + { + SpringBoneService service; + readonly List slotIndices = new(); + readonly List chainWearableParents = new(); + readonly List chainAvatarParents = new(); + readonly List chainOwners = new(); + readonly List chainRootBoneNames = new(); + readonly List chainBones = new(); + readonly List chainInitialRotations = new(); + + readonly List chainJoints = new(); + readonly List chainConfigs = new(); + + // Live avatar skeleton bones keyed by name. Used to find the avatar bone that the + // spring chain's wearable parent should follow each frame. + public IReadOnlyDictionary AvatarBoneMap { get; set; } + + void Awake() => service = new SpringBoneService(); + + void OnDestroy() + { + service?.Dispose(); + service = null; + } + + public void UnregisterAll() + { + if (service == null) return; + // Restore each chain's bones to their captured rest pose before clearing, + // otherwise re-registration would capture stale sim rotations as the new rest. + for (int i = 0; i < slotIndices.Count; i++) + { + var bones = chainBones[i]; + var rots = chainInitialRotations[i]; + for (int j = 0; j < bones.Length; j++) + if (bones[j] != null) bones[j].localRotation = rots[j]; + service.UnregisterSpring(slotIndices[i]); + } + slotIndices.Clear(); + chainWearableParents.Clear(); + chainAvatarParents.Clear(); + chainOwners.Clear(); + chainRootBoneNames.Clear(); + chainBones.Clear(); + chainInitialRotations.Clear(); + } + + /// + /// Replace the full set of spring chains for a wearable with the ones declared in + /// . Empty / null map clears all chains owned by + /// , restoring the bones to their captured rest pose (normal bones). + /// Every entry in becomes its own chain root; descendants + /// are collected by walking first-child until no children remain or the joint cap is hit. + /// Untagged bones stay rigid (driven by their armature parent via normal skinning). + /// + public void SetSpringChainsForWearable(GameObject owner, Dictionary paramsByBone) + { + if (service == null || owner == null) return; + + RemoveChainsForOwner(owner); + + if (paramsByBone == null || paramsByBone.Count == 0) return; + + var skinned = owner.GetComponentInChildren(); + if (skinned == null) + { + Debug.LogError($"[SpringBones] '{owner.name}' has no SkinnedMeshRenderer; cannot build chains"); + return; + } + + var bonesByName = new Dictionary(skinned.bones.Length); + foreach (var b in skinned.bones) + if (b != null) bonesByName[b.name] = b; + + foreach (var (boneName, paramsDto) in paramsByBone) + { + if (!bonesByName.TryGetValue(boneName, out var rootBone)) continue; + + chainJoints.Clear(); + chainConfigs.Clear(); + var rootConfig = BuildConfigFromDTO(paramsDto, rootBone.localRotation); + chainJoints.Add(rootBone); + chainConfigs.Add(rootConfig); + + // Linear first-child walk. Tagged bone is the chain anchor; armature sits above it + // and is never visited because traversal only goes down. Untagged descendants are + // pulled in as joints sharing the root's params. + var current = rootBone; + while (chainJoints.Count < SpringBoneService.MAX_JOINTS_PER_SPRING && current.childCount > 0) + { + var next = current.GetChild(0); + var c = rootConfig; + c.LocalRotation = next.localRotation; + chainJoints.Add(next); + chainConfigs.Add(c); + current = next; + } + + Transform avatarParent = null; + if (rootBone.parent != null && AvatarBoneMap != null) + AvatarBoneMap.TryGetValue(rootBone.parent.name, out avatarParent); + + // Snap wearable parent to live avatar bone NOW so chain tails get initialized at + // the correct world positions (otherwise first sim frame integrates against stale + // wearable-hierarchy positions). Also align scale so authored local positions + // translate to the expected world distances. + if (avatarParent != null && rootBone.parent != null && rootBone.parent != avatarParent) + { + var grandparent = rootBone.parent.parent; + var grandparentLossy = grandparent != null ? grandparent.lossyScale : Vector3.one; + var avatarLossy = avatarParent.lossyScale; + rootBone.parent.localScale = new Vector3( + SafeDiv(avatarLossy.x, grandparentLossy.x), + SafeDiv(avatarLossy.y, grandparentLossy.y), + SafeDiv(avatarLossy.z, grandparentLossy.z)); + rootBone.parent.SetPositionAndRotation(avatarParent.position, avatarParent.rotation); + } + + FlushChain(owner, rootBone.parent, avatarParent, rootBone.name); + } + } + + static float SafeDiv(float a, float b) => Mathf.Abs(b) > 1e-6f ? a / b : 1f; + + int RemoveChainsForOwner(GameObject owner) + { + int removed = 0; + for (int i = slotIndices.Count - 1; i >= 0; i--) + { + if (chainOwners[i] != owner) continue; + + var bones = chainBones[i]; + var rots = chainInitialRotations[i]; + for (int j = 0; j < bones.Length; j++) + if (bones[j] != null) bones[j].localRotation = rots[j]; + + service.UnregisterSpring(slotIndices[i]); + slotIndices.RemoveAt(i); + chainWearableParents.RemoveAt(i); + chainAvatarParents.RemoveAt(i); + chainOwners.RemoveAt(i); + chainRootBoneNames.RemoveAt(i); + chainBones.RemoveAt(i); + chainInitialRotations.RemoveAt(i); + removed++; + } + return removed; + } + + void FlushChain(GameObject owner, Transform wearableParent, Transform avatarParent, string rootBoneName) + { + for (int j = 0; j < chainJoints.Count; j++) + { + var c = chainConfigs[j]; + Transform tail = j + 1 < chainJoints.Count ? chainJoints[j + 1] : null; + + if (tail != null) + { + float3 localPos = (float3)tail.localPosition; + float3 scale = (float3)tail.lossyScale; + float3 scaledPos = localPos * scale; + float len = math.length(scaledPos); + c.BoneAxis = len > 0.0001f ? scaledPos / len : new float3(0, 1, 0); + c.Length = len; + } + else + { + c.BoneAxis = new float3(0, 1, 0); + c.Length = 0.1f; + } + + chainConfigs[j] = c; + } + + var tails = new float3[chainJoints.Count]; + for (int j = 0; j < chainJoints.Count; j++) + tails[j] = j + 1 < chainJoints.Count ? (float3)chainJoints[j + 1].position : (float3)chainJoints[j].position; + + var jointsCopy = chainJoints.ToArray(); + var initRots = new Quaternion[jointsCopy.Length]; + for (int j = 0; j < jointsCopy.Length; j++) initRots[j] = jointsCopy[j].localRotation; + + int slot = service.RegisterSpring(jointsCopy, chainConfigs.ToArray(), tails); + slotIndices.Add(slot); + chainWearableParents.Add(wearableParent); + chainAvatarParents.Add(avatarParent); + chainOwners.Add(owner); + chainRootBoneNames.Add(rootBoneName); + chainBones.Add(jointsCopy); + chainInitialRotations.Add(initRots); + } + + static SpringBoneJointConfig BuildConfigFromDTO(SpringBoneParamsDTO p, Quaternion initialRotation) + { + float3 gravityDir = p.gravityDir != null && p.gravityDir.Length == 3 + ? new float3(p.gravityDir[0], p.gravityDir[1], p.gravityDir[2]) + : new float3(0, -1, 0); + return new SpringBoneJointConfig + { + Stiffness = p.stiffness, + Drag = p.drag, + GravityDir = gravityDir, + GravityPower = p.gravityPower, + LocalRotation = initialRotation, + }; + } + + void LateUpdate() + { + if (service == null || slotIndices.Count == 0) return; + + for (int i = 0; i < slotIndices.Count; i++) + { + var wearableParent = chainWearableParents[i]; + var avatarParent = chainAvatarParents[i]; + if (avatarParent == null) + { + // No live avatar bone match — fall back to whatever the chain root's parent is. + if (wearableParent == null) continue; + service.SetParentData(slotIndices[i], wearableParent.rotation, wearableParent.localToWorldMatrix); + continue; + } + + // Snap the wearable's intermediate parent to follow the live avatar bone every frame. + // Spring bone is a child of wearableParent, so its world transform follows the avatar + // while preserving the authored local pose under the wearable hierarchy. + if (wearableParent != null && wearableParent != avatarParent) + { + var grandparent = wearableParent.parent; + var grandparentLossy = grandparent != null ? grandparent.lossyScale : Vector3.one; + var avatarLossy = avatarParent.lossyScale; + wearableParent.localScale = new Vector3( + SafeDiv(avatarLossy.x, grandparentLossy.x), + SafeDiv(avatarLossy.y, grandparentLossy.y), + SafeDiv(avatarLossy.z, grandparentLossy.z)); + wearableParent.SetPositionAndRotation(avatarParent.position, avatarParent.rotation); + } + + service.SetParentData(slotIndices[i], avatarParent.rotation, avatarParent.localToWorldMatrix); + } + + service.Simulate(Time.deltaTime); + } + } +} diff --git a/Assets/Scripts/SpringBones/SpringBonesDriver.cs.meta b/Assets/Scripts/SpringBones/SpringBonesDriver.cs.meta new file mode 100644 index 0000000..025bb8b --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBonesDriver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 95ea887eb11d7f94eaef9ace0bb62be9 \ No newline at end of file diff --git a/Assets/Scripts/Utils/AvatarUtils.cs b/Assets/Scripts/Utils/AvatarUtils.cs index f9c0981..86847a8 100644 --- a/Assets/Scripts/Utils/AvatarUtils.cs +++ b/Assets/Scripts/Utils/AvatarUtils.cs @@ -163,50 +163,54 @@ public static void HideBodyShapeFacialFeatures(GameObject bodyShape, bool hideEy } } - public static void SetupColors(GameObject go, AvatarColors colors, + public static void SetupWearable(GameObject go, AvatarColors colors, List outlineRenderers, Transform avatarRootBone = null, Transform[] avatarBones = null) { Dictionary avatarBoneMap = null; var renderers = go.GetComponentsInChildren(); foreach (var renderer in renderers) { - if (renderer.material.name.Contains("skin", StringComparison.OrdinalIgnoreCase)) + var materials = renderer.materials; + foreach (var material in materials) { - renderer.material.SetColor(WearablesConstants.Shaders.BASE_COLOR_ID, colors.Skin); - } - else if (renderer.material.name.Contains("hair", StringComparison.OrdinalIgnoreCase)) - { - renderer.material.SetColor(WearablesConstants.Shaders.BASE_COLOR_ID, colors.Hair); - } - - if (avatarRootBone != null && avatarBones != null) - { - if (renderer.bones.Length <= avatarBones.Length) + if (material.name.Contains("skin", StringComparison.OrdinalIgnoreCase)) { - renderer.rootBone = avatarRootBone; - renderer.bones = avatarBones; + material.SetColor(WearablesConstants.Shaders.BASE_COLOR_ID, colors.Skin); } - else + else if (material.name.Contains("hair", StringComparison.OrdinalIgnoreCase)) { - // Wearable has extra bones (e.g. spring bones). - // Remap standard avatar bones by name, preserve extra ones. - avatarBoneMap ??= BuildBoneMap(avatarBones); - RemapBonesPreservingExtras(renderer, avatarRootBone, avatarBoneMap); + material.SetColor(WearablesConstants.Shaders.BASE_COLOR_ID, colors.Hair); } } - if (renderer.material.shader.name == "DCL/DCL_Toon" && renderer.sharedMaterial.renderQueue is >= 2000 and < 3000) + if (avatarRootBone != null && avatarBones != null) + { + // Always remap by name — preserves any extra bones (spring chains, attachment + // nodes) regardless of bone count vs avatar skeleton. The previous fast path + // (renderer.bones = avatarBones) only worked when wearables shipped with the + // canonical skeleton intact and dropped extras for any wearable with fewer bones. + avatarBoneMap ??= BuildBoneMap(avatarBones); + RemapBonesPreservingExtras(renderer, avatarRootBone, avatarBoneMap); + } + + foreach (var sharedMaterial in renderer.sharedMaterials) { - outlineRenderers.Add(renderer); + if (sharedMaterial != null && sharedMaterial.shader.name == "DCL/DCL_Toon" + && sharedMaterial.renderQueue is >= 2000 and < 3000) + { + outlineRenderers.Add(renderer); + break; + } } } - // Re-parent extra bones (spring bone chains) under their nearest avatar skeleton ancestor - // so they follow the avatar during emotes instead of staying at a fixed world position. + // Reparent extra-bone chain tops under live avatar bones so wearable extras (e.g. + // ponytail rigs) follow animation by default — independent of spring-bone tagging. + // Without this, untagged or partially-tagged chains stay anchored to the wearable's + // static skeleton copy and don't move. When chains ARE tagged, SpringBonesDriver + // sees rootBone.parent == avatarParent and skips its wearable-parent snap (no-op). if (avatarBoneMap != null) - { ReparentExtraBonesUnderAvatarSkeleton(go, avatarBoneMap); - } } public static void SetupFacialFeatures(GameObject go, AvatarColors colors, @@ -308,7 +312,9 @@ private static void RemapBonesPreservingExtras(SkinnedMeshRenderer renderer, /// Re-parents the roots of extra-bone chains (e.g. spring bone chains) under their /// nearest avatar skeleton ancestor so they follow the avatar during emotes. /// Only chain roots are re-parented; descendants stay under their chain parent, - /// preserving the chain hierarchy. + /// preserving the chain hierarchy. Authored local pose is preserved (worldPositionStays=false) + /// — the wearable-copy parent world transform is stale, so we want the authored local + /// applied against the live avatar bone's world transform instead. /// private static void ReparentExtraBonesUnderAvatarSkeleton(GameObject wearableRoot, Dictionary avatarBoneMap) @@ -317,20 +323,14 @@ private static void ReparentExtraBonesUnderAvatarSkeleton(GameObject wearableRoo foreach (var transform in allTransforms) { - // Skip the root itself if (transform == wearableRoot.transform) continue; - - // Skip transforms that correspond to standard avatar bones (by name). if (avatarBoneMap.ContainsKey(transform.name)) continue; - // Extra bone (e.g. spring bone) whose direct parent is a wearable copy of an avatar bone. - // Re-parent under the live avatar bone so it follows the avatar during emotes. - // Descendants within the chain keep their existing parent, preserving chain structure. if (transform.parent != null && avatarBoneMap.TryGetValue(transform.parent.name, out var liveParent) && transform.parent != liveParent) { - transform.SetParent(liveParent, true); + transform.SetParent(liveParent, false); } } } diff --git a/Packages/manifest.json b/Packages/manifest.json index a276094..5e41246 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -1,14 +1,14 @@ { "dependencies": { - "com.atteneder.gltfast": "https://github.com/atteneder/glTFast.git", + "com.atteneder.gltfast": "https://github.com/decentraland/unity-gltf.git#chore/gltfast-update", "com.decentraland.unity-shared-dependencies": "git@github.com:decentraland/unity-shared-dependencies.git#main", "com.unity.cinemachine": "3.1.4", - "com.unity.ide.rider": "3.0.38", - "com.unity.ide.visualstudio": "2.0.26", - "com.unity.inputsystem": "1.18.0", - "com.unity.render-pipelines.universal": "17.3.0", + "com.unity.ide.rider": "3.0.39", + "com.unity.ide.visualstudio": "2.0.27", + "com.unity.inputsystem": "1.19.0", + "com.unity.render-pipelines.universal": "17.4.0", "com.unity.test-framework": "1.6.0", - "com.unity.visualeffectgraph": "17.3.0", + "com.unity.visualeffectgraph": "17.4.0", "decentraland.renderfeatures": "git@github.com:decentraland/unity-explorer-packages.git?path=/RenderGraphs/RenderFeatures", "com.unity.modules.accessibility": "1.0.0", "com.unity.modules.adaptiveperformance": "1.0.0", @@ -22,6 +22,7 @@ "com.unity.modules.imageconversion": "1.0.0", "com.unity.modules.imgui": "1.0.0", "com.unity.modules.jsonserialize": "1.0.0", + "com.unity.nuget.newtonsoft-json": "3.2.1", "com.unity.modules.particlesystem": "1.0.0", "com.unity.modules.physics": "1.0.0", "com.unity.modules.physics2d": "1.0.0", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 2d70f61..f83cf67 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -1,7 +1,7 @@ { "dependencies": { "com.atteneder.gltfast": { - "version": "https://github.com/atteneder/glTFast.git", + "version": "https://github.com/decentraland/unity-gltf.git#chore/gltfast-update", "depth": 0, "source": "git", "dependencies": { @@ -11,7 +11,7 @@ "com.unity.burst": "1.8.24", "com.unity.collections": "1.2.4" }, - "hash": "3360ccae0a91cbc1bafb32d4aa0132fc82add141" + "hash": "59e20d09a7665395eb9141180c55abdce61fdbc7" }, "com.decentraland.unity-shared-dependencies": { "version": "git@github.com:decentraland/unity-shared-dependencies.git#main", @@ -21,7 +21,7 @@ "hash": "0132f23fb5d1bfa1634a872ef28e131067df7a69" }, "com.unity.burst": { - "version": "1.8.27", + "version": "1.8.28", "depth": 1, "source": "registry", "dependencies": { @@ -41,17 +41,16 @@ "url": "https://packages.unity.com" }, "com.unity.collections": { - "version": "2.6.2", + "version": "6.4.0", "depth": 1, - "source": "registry", + "source": "builtin", "dependencies": { "com.unity.burst": "1.8.23", "com.unity.mathematics": "1.3.2", - "com.unity.test-framework": "1.4.6", "com.unity.nuget.mono-cecil": "1.11.5", + "com.unity.test-framework": "1.4.6", "com.unity.test-framework.performance": "3.0.3" - }, - "url": "https://packages.unity.com" + } }, "com.unity.ext.nunit": { "version": "2.0.5", @@ -60,7 +59,7 @@ "dependencies": {} }, "com.unity.ide.rider": { - "version": "3.0.38", + "version": "3.0.39", "depth": 0, "source": "registry", "dependencies": { @@ -69,7 +68,7 @@ "url": "https://packages.unity.com" }, "com.unity.ide.visualstudio": { - "version": "2.0.26", + "version": "2.0.27", "depth": 0, "source": "registry", "dependencies": { @@ -78,7 +77,7 @@ "url": "https://packages.unity.com" }, "com.unity.inputsystem": { - "version": "1.18.0", + "version": "1.19.0", "depth": 0, "source": "registry", "dependencies": { @@ -100,8 +99,15 @@ "dependencies": {}, "url": "https://packages.unity.com" }, + "com.unity.nuget.newtonsoft-json": { + "version": "3.2.1", + "depth": 0, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, "com.unity.render-pipelines.core": { - "version": "17.3.0", + "version": "17.4.0", "depth": 1, "source": "builtin", "dependencies": { @@ -109,27 +115,26 @@ "com.unity.mathematics": "1.3.2", "com.unity.ugui": "2.0.0", "com.unity.collections": "2.4.3", - "com.unity.modules.physics": "1.0.0", "com.unity.modules.terrain": "1.0.0", "com.unity.modules.jsonserialize": "1.0.0" } }, "com.unity.render-pipelines.universal": { - "version": "17.3.0", + "version": "17.4.0", "depth": 0, "source": "builtin", "dependencies": { - "com.unity.render-pipelines.core": "17.3.0", - "com.unity.shadergraph": "17.3.0", - "com.unity.render-pipelines.universal-config": "17.0.3" + "com.unity.render-pipelines.core": "17.4.0", + "com.unity.shadergraph": "17.4.0", + "com.unity.render-pipelines.universal-config": "17.4.0" } }, "com.unity.render-pipelines.universal-config": { - "version": "17.0.3", + "version": "17.4.0", "depth": 1, "source": "builtin", "dependencies": { - "com.unity.render-pipelines.core": "17.0.3" + "com.unity.render-pipelines.core": "17.4.0" } }, "com.unity.searcher": { @@ -147,16 +152,16 @@ "url": "https://packages.unity.com" }, "com.unity.shadergraph": { - "version": "17.3.0", + "version": "17.4.0", "depth": 1, "source": "builtin", "dependencies": { - "com.unity.render-pipelines.core": "17.3.0", + "com.unity.render-pipelines.core": "17.4.0", "com.unity.searcher": "4.9.3" } }, "com.unity.splines": { - "version": "2.8.2", + "version": "2.8.3", "depth": 1, "source": "registry", "dependencies": { @@ -196,12 +201,12 @@ } }, "com.unity.visualeffectgraph": { - "version": "17.3.0", + "version": "17.4.0", "depth": 0, "source": "builtin", "dependencies": { - "com.unity.shadergraph": "17.3.0", - "com.unity.render-pipelines.core": "17.3.0" + "com.unity.shadergraph": "17.4.0", + "com.unity.render-pipelines.core": "17.4.0" } }, "decentraland.renderfeatures": { diff --git a/ProjectSettings/ProjectVersion.txt b/ProjectSettings/ProjectVersion.txt index a4b5984..8a87982 100644 --- a/ProjectSettings/ProjectVersion.txt +++ b/ProjectSettings/ProjectVersion.txt @@ -1,2 +1,2 @@ -m_EditorVersion: 6000.3.6f1 -m_EditorVersionWithRevision: 6000.3.6f1 (bbb010bdb8a3) +m_EditorVersion: 6000.4.0f1 +m_EditorVersionWithRevision: 6000.4.0f1 (8cf496087c8f)