From 1ebc7e01154507f2a56b3e067913d8e51d7868e1 Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Mon, 20 Apr 2026 13:07:45 +0200 Subject: [PATCH 01/16] feat: unity upgrade + jetbrains folders into gitignore --- .gitignore | 3 ++ Packages/manifest.json | 10 +++---- Packages/packages-lock.json | 44 ++++++++++++++---------------- ProjectSettings/ProjectVersion.txt | 4 +-- 4 files changed, 31 insertions(+), 30 deletions(-) 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/Packages/manifest.json b/Packages/manifest.json index a276094..5653a1a 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -3,12 +3,12 @@ "com.atteneder.gltfast": "https://github.com/atteneder/glTFast.git", "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", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 2d70f61..8d6c1cc 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -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": { @@ -101,7 +100,7 @@ "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 +108,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 +145,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 +194,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) From 7dff74bd9174a589de03d2ebdc486b65f4f11e10 Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Tue, 21 Apr 2026 15:46:31 +0200 Subject: [PATCH 02/16] fix: spring bones integration --- Assets/Scenes/Main.unity | 28 +++ Assets/Scripts/AangConfiguration.cs | 6 +- Assets/Scripts/Loading/AvatarLoader.cs | 15 ++ Assets/Scripts/Loading/GLTFLoader.cs | 3 +- Assets/Scripts/SpringBones.meta | 8 + Assets/Scripts/SpringBones/SpringBoneData.cs | 35 ++++ .../SpringBones/SpringBoneData.cs.meta | 2 + .../SpringBones/SpringBoneJointConfig.cs | 15 ++ .../SpringBones/SpringBoneJointConfig.cs.meta | 2 + .../SpringBones/SpringBoneParentData.cs | 10 ++ .../SpringBones/SpringBoneParentData.cs.meta | 2 + .../Scripts/SpringBones/SpringBoneService.cs | 166 ++++++++++++++++++ .../SpringBones/SpringBoneService.cs.meta | 2 + .../SpringBones/SpringBoneSimulator.cs | 125 +++++++++++++ .../SpringBones/SpringBoneSimulator.cs.meta | 2 + .../SpringBones/SpringBoneTransformData.cs | 37 ++++ .../SpringBoneTransformData.cs.meta | 2 + .../Scripts/SpringBones/SpringBonesDriver.cs | 130 ++++++++++++++ .../SpringBones/SpringBonesDriver.cs.meta | 2 + Assets/Scripts/Utils/AvatarUtils.cs | 53 ++++++ Packages/manifest.json | 2 +- Packages/packages-lock.json | 4 +- 22 files changed, 644 insertions(+), 7 deletions(-) create mode 100644 Assets/Scripts/SpringBones.meta create mode 100644 Assets/Scripts/SpringBones/SpringBoneData.cs create mode 100644 Assets/Scripts/SpringBones/SpringBoneData.cs.meta create mode 100644 Assets/Scripts/SpringBones/SpringBoneJointConfig.cs create mode 100644 Assets/Scripts/SpringBones/SpringBoneJointConfig.cs.meta create mode 100644 Assets/Scripts/SpringBones/SpringBoneParentData.cs create mode 100644 Assets/Scripts/SpringBones/SpringBoneParentData.cs.meta create mode 100644 Assets/Scripts/SpringBones/SpringBoneService.cs create mode 100644 Assets/Scripts/SpringBones/SpringBoneService.cs.meta create mode 100644 Assets/Scripts/SpringBones/SpringBoneSimulator.cs create mode 100644 Assets/Scripts/SpringBones/SpringBoneSimulator.cs.meta create mode 100644 Assets/Scripts/SpringBones/SpringBoneTransformData.cs create mode 100644 Assets/Scripts/SpringBones/SpringBoneTransformData.cs.meta create mode 100644 Assets/Scripts/SpringBones/SpringBonesDriver.cs create mode 100644 Assets/Scripts/SpringBones/SpringBonesDriver.cs.meta 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/Loading/AvatarLoader.cs b/Assets/Scripts/Loading/AvatarLoader.cs index 97532a9..394442a 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); @@ -163,6 +165,19 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable m.Root.activeSelf) + .Select(m => AvatarUtils.BuildSpringBoneData(m.Root))); + } + else + { + Debug.LogWarning("[SpringBones] springBonesDriver not wired on AvatarLoader"); + } + // If there is a new emote to be played if (emoteChanged) { 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/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/SpringBoneData.cs b/Assets/Scripts/SpringBones/SpringBoneData.cs new file mode 100644 index 0000000..9bc4dc7 --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneData.cs @@ -0,0 +1,35 @@ +using UnityEngine; + +namespace SpringBones +{ + public readonly struct SpringBoneData + { + public readonly Transform ManagedTransform; + public readonly bool IsRoot; + public readonly float Stiffness; + public readonly float Drag; + public readonly Vector3 GravityDir; + public readonly float GravityPower; + public readonly float HitRadius; + public readonly Quaternion InitialLocalRotation; + + public SpringBoneData(Transform managedTransform, + bool isRoot, + float stiffness, + float drag, + Vector3 gravityDir, + float gravityPower, + float hitRadius, + Quaternion initialLocalRotation) + { + ManagedTransform = managedTransform; + IsRoot = isRoot; + Stiffness = stiffness; + Drag = drag; + GravityDir = gravityDir; + GravityPower = gravityPower; + HitRadius = hitRadius; + InitialLocalRotation = initialLocalRotation; + } + } +} diff --git a/Assets/Scripts/SpringBones/SpringBoneData.cs.meta b/Assets/Scripts/SpringBones/SpringBoneData.cs.meta new file mode 100644 index 0000000..f504321 --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c4e324c1bfeaf7946af92b57fbd3fbdd \ No newline at end of file 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/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..2af94a3 --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneService.cs @@ -0,0 +1,166 @@ +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; + + int slotCapacity; + Transform[] managedTransforms; + SpringBoneTransformData[] transforms; + float3[] prevTails; + float3[] currentTails; + float3[] nextTails; + SpringBoneJointConfig[] jointConfigs; + int[] slotJointCounts; + SpringBoneParentData[] parentData; + + 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) + { + 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; + } + } +} 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..61ff885 --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneSimulator.cs @@ -0,0 +1,125 @@ +using System.Runtime.CompilerServices; +using Unity.Mathematics; + +namespace SpringBones +{ + internal static class SpringBoneSimulator + { + 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 > 0.0001f + ? 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 < 0.0001f || toLenSq < 0.0001f) + 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) < 0.0001f) + 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..9d8644f --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBonesDriver.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using Unity.Mathematics; +using UnityEngine; + +namespace SpringBones +{ + public class SpringBonesDriver : MonoBehaviour + { + SpringBoneService service; + readonly List slotIndices = new(); + readonly List chainRootParents = new(); + + readonly List chainJoints = new(); + readonly List chainConfigs = new(); + + void Awake() => service = new SpringBoneService(); + + void OnDestroy() + { + service?.Dispose(); + service = null; + } + + public void UnregisterAll() + { + if (service == null) return; + foreach (int s in slotIndices) service.UnregisterSpring(s); + slotIndices.Clear(); + chainRootParents.Clear(); + } + + public void RegisterAll(IEnumerable wearableSprings) + { + UnregisterAll(); + foreach (var springs in wearableSprings) + { + if (springs == null || springs.Length == 0) continue; + RegisterWearable(springs); + } + Debug.Log($"[SpringBones] driver registered {slotIndices.Count} chain(s)"); + } + + void RegisterWearable(SpringBoneData[] springs) + { + chainJoints.Clear(); + chainConfigs.Clear(); + Transform chainRootParent = null; + + for (int i = 0; i < springs.Length; i++) + { + var sb = springs[i]; + + if (sb.IsRoot && chainJoints.Count > 0) + { + FlushChain(chainRootParent); + chainJoints.Clear(); + chainConfigs.Clear(); + } + + sb.ManagedTransform.localRotation = sb.InitialLocalRotation; + + if (sb.IsRoot) + chainRootParent = sb.ManagedTransform.parent; + + chainJoints.Add(sb.ManagedTransform); + chainConfigs.Add(BuildJointConfig(sb)); + } + + if (chainJoints.Count > 0) + FlushChain(chainRootParent); + } + + void FlushChain(Transform rootParent) + { + 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; + + int slot = service.RegisterSpring(chainJoints.ToArray(), chainConfigs.ToArray(), tails); + slotIndices.Add(slot); + chainRootParents.Add(rootParent); + } + + static SpringBoneJointConfig BuildJointConfig(SpringBoneData d) => new() + { + Stiffness = d.Stiffness, + Drag = d.Drag, + GravityDir = d.GravityDir, + GravityPower = d.GravityPower, + LocalRotation = d.InitialLocalRotation, + }; + + void LateUpdate() + { + if (service == null || slotIndices.Count == 0) return; + + for (int i = 0; i < slotIndices.Count; i++) + { + var p = chainRootParents[i]; + if (p == null) continue; + service.SetParentData(slotIndices[i], p.rotation, p.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..e05f867 100644 --- a/Assets/Scripts/Utils/AvatarUtils.cs +++ b/Assets/Scripts/Utils/AvatarUtils.cs @@ -5,6 +5,7 @@ using JetBrains.Annotations; using Loading; using Runtime.Wearables; +using SpringBones; using UnityEngine; using UnityEngine.Pool; @@ -304,6 +305,58 @@ private static void RemapBonesPreservingExtras(SkinnedMeshRenderer renderer, renderer.bones = remapped; } + /// + /// Walks a wearable's skeleton, extracts spring bone chains tagged with + /// , and returns flat data for the + /// SpringBones simulator. Chain order: each tagged root is followed by its untagged + /// descendants that participate in the skinning, in DFS order. + /// + public static SpringBoneData[] BuildSpringBoneData(GameObject wearable) + { + var skeleton = wearable.GetComponentInChildren(); + if (skeleton == null) return Array.Empty(); + + var boneSet = new HashSet(skeleton.bones); + var result = new List(); + + foreach (var bone in skeleton.bones) + { + if (bone == null) continue; + var joint = bone.GetComponent(); + if (joint == null) continue; + + result.Add(new SpringBoneData( + bone, joint.IsRoot, + joint.Stiffness, joint.Drag, joint.GravityDir, joint.GravityPower, joint.HitRadius, + bone.localRotation)); + + if (joint.IsRoot) + CollectSpringBoneChain(bone, joint, boneSet, result); + } + + Debug.Log($"[SpringBones] {wearable.name}: found {result.Count} spring joints (skeleton bones={skeleton.bones.Length})"); + return result.Count > 0 ? result.ToArray() : Array.Empty(); + } + + private static void CollectSpringBoneChain(Transform parent, + GLTFast.SpringBoneJointComponent rootCfg, + HashSet boneSet, List output) + { + for (int i = 0; i < parent.childCount; i++) + { + var child = parent.GetChild(i); + if (!boneSet.Contains(child)) continue; + if (child.GetComponent() != null) continue; + + output.Add(new SpringBoneData( + child, false, + rootCfg.Stiffness, rootCfg.Drag, rootCfg.GravityDir, rootCfg.GravityPower, rootCfg.HitRadius, + child.localRotation)); + + CollectSpringBoneChain(child, rootCfg, boneSet, output); + } + } + /// /// 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. diff --git a/Packages/manifest.json b/Packages/manifest.json index 5653a1a..37c99ea 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -1,6 +1,6 @@ { "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.39", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 8d6c1cc..43df5ef 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", From 0ac1e8ce27b7bef0d646deccd1308e87f2332f77 Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Wed, 22 Apr 2026 09:17:09 +0200 Subject: [PATCH 03/16] fix: apply color to all the submeshes --- Assets/Scripts/Utils/AvatarUtils.cs | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/Utils/AvatarUtils.cs b/Assets/Scripts/Utils/AvatarUtils.cs index e05f867..05a4532 100644 --- a/Assets/Scripts/Utils/AvatarUtils.cs +++ b/Assets/Scripts/Utils/AvatarUtils.cs @@ -171,13 +171,17 @@ public static void SetupColors(GameObject go, AvatarColors colors, 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 (material.name.Contains("skin", StringComparison.OrdinalIgnoreCase)) + { + material.SetColor(WearablesConstants.Shaders.BASE_COLOR_ID, colors.Skin); + } + else if (material.name.Contains("hair", StringComparison.OrdinalIgnoreCase)) + { + material.SetColor(WearablesConstants.Shaders.BASE_COLOR_ID, colors.Hair); + } } if (avatarRootBone != null && avatarBones != null) @@ -196,9 +200,14 @@ public static void SetupColors(GameObject go, AvatarColors colors, } } - if (renderer.material.shader.name == "DCL/DCL_Toon" && renderer.sharedMaterial.renderQueue is >= 2000 and < 3000) + 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; + } } } From d1a3434766f94353e0a5d594d9c990c9a7b183d6 Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Wed, 22 Apr 2026 13:27:03 +0200 Subject: [PATCH 04/16] feat: js bridge to modify bones params --- Assets/Scripts/JSBridge.cs | 13 +++++ Assets/Scripts/Loading/AvatarLoader.cs | 49 ++++++++++++++++++- Assets/Scripts/Preview/PreviewController.cs | 3 ++ .../SpringBones/SpringBoneParamsPayload.cs | 30 ++++++++++++ .../SpringBoneParamsPayload.cs.meta | 2 + .../Scripts/SpringBones/SpringBoneService.cs | 18 +++++++ .../Scripts/SpringBones/SpringBonesDriver.cs | 48 +++++++++++++++--- Packages/manifest.json | 1 + Packages/packages-lock.json | 7 +++ 9 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs create mode 100644 Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs.meta diff --git a/Assets/Scripts/JSBridge.cs b/Assets/Scripts/JSBridge.cs index b7190b1..638a318 100644 --- a/Assets/Scripts/JSBridge.cs +++ b/Assets/Scripts/JSBridge.cs @@ -81,6 +81,19 @@ 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; + } + 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 394442a..f39cd40 100644 --- a/Assets/Scripts/Loading/AvatarLoader.cs +++ b/Assets/Scripts/Loading/AvatarLoader.cs @@ -171,7 +171,7 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable m.Root.activeSelf) - .Select(m => AvatarUtils.BuildSpringBoneData(m.Root))); + .Select(m => (m.Root, AvatarUtils.BuildSpringBoneData(m.Root)))); } else { @@ -197,6 +197,53 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable 0; + } + + 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/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/SpringBones/SpringBoneParamsPayload.cs b/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs new file mode 100644 index 0000000..5973454 --- /dev/null +++ b/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs @@ -0,0 +1,30 @@ +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; + public Dictionary @params; + + 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; + } +} 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/SpringBoneService.cs b/Assets/Scripts/SpringBones/SpringBoneService.cs index 2af94a3..3a09663 100644 --- a/Assets/Scripts/SpringBones/SpringBoneService.cs +++ b/Assets/Scripts/SpringBones/SpringBoneService.cs @@ -75,6 +75,24 @@ public void UnregisterSpring(int slotIndex) managedTransforms[baseIndex + j] = null; } + public void UpdateSlotParams(int slotIndex, float stiffness, float drag, float3 gravityDir, float gravityPower) + { + int jointCount = slotJointCounts[slotIndex]; + if (jointCount == 0) return; + + int baseIndex = slotIndex * MAX_JOINTS_PER_SPRING; + for (int j = 0; j < jointCount; j++) + { + int idx = baseIndex + j; + var c = jointConfigs[idx]; + c.Stiffness = stiffness; + c.Drag = drag; + c.GravityDir = gravityDir; + c.GravityPower = gravityPower; + jointConfigs[idx] = c; + } + } + public void SetParentData(int slotIndex, quaternion rotation, float4x4 localToWorldMatrix) { parentData[slotIndex] = new SpringBoneParentData diff --git a/Assets/Scripts/SpringBones/SpringBonesDriver.cs b/Assets/Scripts/SpringBones/SpringBonesDriver.cs index 9d8644f..89e5b77 100644 --- a/Assets/Scripts/SpringBones/SpringBonesDriver.cs +++ b/Assets/Scripts/SpringBones/SpringBonesDriver.cs @@ -9,6 +9,8 @@ public class SpringBonesDriver : MonoBehaviour SpringBoneService service; readonly List slotIndices = new(); readonly List chainRootParents = new(); + readonly List chainOwners = new(); + readonly List chainRootBoneNames = new(); readonly List chainJoints = new(); readonly List chainConfigs = new(); @@ -27,24 +29,27 @@ public void UnregisterAll() foreach (int s in slotIndices) service.UnregisterSpring(s); slotIndices.Clear(); chainRootParents.Clear(); + chainOwners.Clear(); + chainRootBoneNames.Clear(); } - public void RegisterAll(IEnumerable wearableSprings) + public void RegisterAll(IEnumerable<(GameObject owner, SpringBoneData[] springs)> wearableSprings) { UnregisterAll(); - foreach (var springs in wearableSprings) + foreach (var (owner, springs) in wearableSprings) { if (springs == null || springs.Length == 0) continue; - RegisterWearable(springs); + RegisterWearable(owner, springs); } Debug.Log($"[SpringBones] driver registered {slotIndices.Count} chain(s)"); } - void RegisterWearable(SpringBoneData[] springs) + void RegisterWearable(GameObject owner, SpringBoneData[] springs) { chainJoints.Clear(); chainConfigs.Clear(); Transform chainRootParent = null; + string chainRootBoneName = null; for (int i = 0; i < springs.Length; i++) { @@ -52,7 +57,7 @@ void RegisterWearable(SpringBoneData[] springs) if (sb.IsRoot && chainJoints.Count > 0) { - FlushChain(chainRootParent); + FlushChain(owner, chainRootParent, chainRootBoneName); chainJoints.Clear(); chainConfigs.Clear(); } @@ -60,17 +65,20 @@ void RegisterWearable(SpringBoneData[] springs) sb.ManagedTransform.localRotation = sb.InitialLocalRotation; if (sb.IsRoot) + { chainRootParent = sb.ManagedTransform.parent; + chainRootBoneName = sb.ManagedTransform.name; + } chainJoints.Add(sb.ManagedTransform); chainConfigs.Add(BuildJointConfig(sb)); } if (chainJoints.Count > 0) - FlushChain(chainRootParent); + FlushChain(owner, chainRootParent, chainRootBoneName); } - void FlushChain(Transform rootParent) + void FlushChain(GameObject owner, Transform rootParent, string rootBoneName) { for (int j = 0; j < chainJoints.Count; j++) { @@ -102,6 +110,32 @@ void FlushChain(Transform rootParent) int slot = service.RegisterSpring(chainJoints.ToArray(), chainConfigs.ToArray(), tails); slotIndices.Add(slot); chainRootParents.Add(rootParent); + chainOwners.Add(owner); + chainRootBoneNames.Add(rootBoneName); + } + + /// + /// Override physics params per chain. Keys in match the + /// chain's root bone name. Returns number of chains updated. + /// + public int UpdateParamsForWearable(GameObject owner, Dictionary paramsByBone) + { + if (service == null || owner == null || paramsByBone == null) return 0; + + int updated = 0; + for (int i = 0; i < slotIndices.Count; i++) + { + if (chainOwners[i] != owner) continue; + if (!paramsByBone.TryGetValue(chainRootBoneNames[i], out var p)) continue; + + 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); + + service.UpdateSlotParams(slotIndices[i], p.stiffness, p.drag, gravityDir, p.gravityPower); + updated++; + } + return updated; } static SpringBoneJointConfig BuildJointConfig(SpringBoneData d) => new() diff --git a/Packages/manifest.json b/Packages/manifest.json index 37c99ea..5e41246 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -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 43df5ef..f83cf67 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -99,6 +99,13 @@ "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.4.0", "depth": 1, From 5e82f259bdc7bcd33f1b06ef1b5684f34b44db6b Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Wed, 22 Apr 2026 13:31:19 +0200 Subject: [PATCH 05/16] fix: SetSpringBonesParam was returning bool for no reason --- Assets/Scripts/Loading/AvatarLoader.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/Loading/AvatarLoader.cs b/Assets/Scripts/Loading/AvatarLoader.cs index f39cd40..3b885ef 100644 --- a/Assets/Scripts/Loading/AvatarLoader.cs +++ b/Assets/Scripts/Loading/AvatarLoader.cs @@ -197,24 +197,23 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable 0; } private bool TryFindWearableByItemId(string itemId, out GameObject owner) From 2152e1d533a5955693fbe4259c386488aa5b384b Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Wed, 22 Apr 2026 13:52:33 +0200 Subject: [PATCH 06/16] chore: logs --- Assets/Scripts/JSBridge.cs | 7 +++++++ Assets/Scripts/Loading/AvatarLoader.cs | 12 +++++++++--- Assets/Scripts/SpringBones/SpringBonesDriver.cs | 14 +++++++++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/JSBridge.cs b/Assets/Scripts/JSBridge.cs index 638a318..2ae6e13 100644 --- a/Assets/Scripts/JSBridge.cs +++ b/Assets/Scripts/JSBridge.cs @@ -84,6 +84,7 @@ public void SetUrns(string value) => [UsedImplicitly] public void SetSpringBonesParams(string value) { + Debug.Log($"[SpringBones] JSBridge.SetSpringBonesParams raw payload ({value?.Length ?? 0} chars): {value}"); SpringBones.SpringBonesParamsPayload payload; try { payload = SpringBones.SpringBonesParamsPayload.Parse(value); } catch (Exception e) @@ -91,6 +92,12 @@ public void SetSpringBonesParams(string value) Debug.LogError($"[SpringBones] failed to parse SetSpringBonesParams payload: {e.Message}"); return; } + if (payload == null) + { + Debug.LogError("[SpringBones] parsed payload is null"); + return; + } + Debug.Log($"[SpringBones] parsed itemId='{payload.itemId}' paramsKeys=[{(payload.@params == null ? "null" : string.Join(",", payload.@params.Keys))}]"); previewController.SetSpringBonesParams(payload); } diff --git a/Assets/Scripts/Loading/AvatarLoader.cs b/Assets/Scripts/Loading/AvatarLoader.cs index 3b885ef..6082ee2 100644 --- a/Assets/Scripts/Loading/AvatarLoader.cs +++ b/Assets/Scripts/Loading/AvatarLoader.cs @@ -199,19 +199,25 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable GameObject '{owner.name}'"); int updated = springBonesDriver.UpdateParamsForWearable(owner, payload.@params); Debug.Log($"[SpringBones] applied {updated} chain(s) to '{payload.itemId}'"); } diff --git a/Assets/Scripts/SpringBones/SpringBonesDriver.cs b/Assets/Scripts/SpringBones/SpringBonesDriver.cs index 89e5b77..1d01408 100644 --- a/Assets/Scripts/SpringBones/SpringBonesDriver.cs +++ b/Assets/Scripts/SpringBones/SpringBonesDriver.cs @@ -122,19 +122,31 @@ public int UpdateParamsForWearable(GameObject owner, Dictionary stiffness={p.stiffness} drag={p.drag} gravityPower={p.gravityPower} gravityDir=({gravityDir.x},{gravityDir.y},{gravityDir.z})"); updated++; } + + if (ownedChains == 0) + Debug.Log($"[SpringBones] '{owner.name}' owns 0 chains in driver (nothing to update)"); + return updated; } From 472b4565801528176f2c52c66ab10f496e23ab5e Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Wed, 22 Apr 2026 14:22:25 +0200 Subject: [PATCH 07/16] fix: now SetSpringChainsForWearable replaces the whole spring bones hierarchy. --- Assets/Scripts/Loading/AvatarLoader.cs | 7 +- .../Scripts/SpringBones/SpringBoneService.cs | 18 -- .../Scripts/SpringBones/SpringBonesDriver.cs | 158 ++++++++++++++---- 3 files changed, 124 insertions(+), 59 deletions(-) diff --git a/Assets/Scripts/Loading/AvatarLoader.cs b/Assets/Scripts/Loading/AvatarLoader.cs index 6082ee2..b5b3b4b 100644 --- a/Assets/Scripts/Loading/AvatarLoader.cs +++ b/Assets/Scripts/Loading/AvatarLoader.cs @@ -199,9 +199,9 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable GameObject '{owner.name}'"); - int updated = springBonesDriver.UpdateParamsForWearable(owner, payload.@params); - Debug.Log($"[SpringBones] applied {updated} chain(s) to '{payload.itemId}'"); + springBonesDriver.SetSpringChainsForWearable(owner, payload.@params); } private bool TryFindWearableByItemId(string itemId, out GameObject owner) diff --git a/Assets/Scripts/SpringBones/SpringBoneService.cs b/Assets/Scripts/SpringBones/SpringBoneService.cs index 3a09663..2af94a3 100644 --- a/Assets/Scripts/SpringBones/SpringBoneService.cs +++ b/Assets/Scripts/SpringBones/SpringBoneService.cs @@ -75,24 +75,6 @@ public void UnregisterSpring(int slotIndex) managedTransforms[baseIndex + j] = null; } - public void UpdateSlotParams(int slotIndex, float stiffness, float drag, float3 gravityDir, float gravityPower) - { - int jointCount = slotJointCounts[slotIndex]; - if (jointCount == 0) return; - - int baseIndex = slotIndex * MAX_JOINTS_PER_SPRING; - for (int j = 0; j < jointCount; j++) - { - int idx = baseIndex + j; - var c = jointConfigs[idx]; - c.Stiffness = stiffness; - c.Drag = drag; - c.GravityDir = gravityDir; - c.GravityPower = gravityPower; - jointConfigs[idx] = c; - } - } - public void SetParentData(int slotIndex, quaternion rotation, float4x4 localToWorldMatrix) { parentData[slotIndex] = new SpringBoneParentData diff --git a/Assets/Scripts/SpringBones/SpringBonesDriver.cs b/Assets/Scripts/SpringBones/SpringBonesDriver.cs index 1d01408..18f7331 100644 --- a/Assets/Scripts/SpringBones/SpringBonesDriver.cs +++ b/Assets/Scripts/SpringBones/SpringBonesDriver.cs @@ -11,6 +11,8 @@ public class SpringBonesDriver : MonoBehaviour readonly List chainRootParents = 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(); @@ -31,6 +33,8 @@ public void UnregisterAll() chainRootParents.Clear(); chainOwners.Clear(); chainRootBoneNames.Clear(); + chainBones.Clear(); + chainInitialRotations.Clear(); } public void RegisterAll(IEnumerable<(GameObject owner, SpringBoneData[] springs)> wearableSprings) @@ -78,6 +82,101 @@ void RegisterWearable(GameObject owner, SpringBoneData[] springs) FlushChain(owner, chainRootParent, chainRootBoneName); } + /// + /// 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). + /// Bones present in the map that were not spring bones before become spring bones. + /// + public void SetSpringChainsForWearable(GameObject owner, Dictionary paramsByBone) + { + if (service == null || owner == null) return; + + int removed = RemoveChainsForOwner(owner); + + if (paramsByBone == null || paramsByBone.Count == 0) + { + Debug.Log($"[SpringBones] cleared {removed} chain(s) on '{owner.name}'"); + return; + } + + var skinned = owner.GetComponentInChildren(); + if (skinned == null) + { + Debug.Log($"[SpringBones] '{owner.name}' has no SkinnedMeshRenderer; cannot build chains"); + return; + } + + var boneSet = new HashSet(skinned.bones); + var nameIndex = new Dictionary(); + foreach (var b in skinned.bones) + if (b != null && !nameIndex.ContainsKey(b.name)) + nameIndex[b.name] = b; + + int added = 0; + foreach (var kv in paramsByBone) + { + if (!nameIndex.TryGetValue(kv.Key, out var rootBone)) + { + Debug.Log($"[SpringBones] bone '{kv.Key}' not found in '{owner.name}'; skipped"); + continue; + } + + chainJoints.Clear(); + chainConfigs.Clear(); + var rootConfig = BuildConfigFromDTO(kv.Value, rootBone.localRotation); + chainJoints.Add(rootBone); + chainConfigs.Add(rootConfig); + CollectChainDescendants(rootBone, boneSet, paramsByBone, rootConfig); + FlushChain(owner, rootBone.parent, rootBone.name); + Debug.Log($"[SpringBones] chain '{rootBone.name}' on '{owner.name}' -> {chainJoints.Count} joint(s), stiffness={kv.Value.stiffness} drag={kv.Value.drag} gravityPower={kv.Value.gravityPower}"); + added++; + } + + Debug.Log($"[SpringBones] rebuilt '{owner.name}': removed {removed}, added {added}"); + } + + 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); + chainRootParents.RemoveAt(i); + chainOwners.RemoveAt(i); + chainRootBoneNames.RemoveAt(i); + chainBones.RemoveAt(i); + chainInitialRotations.RemoveAt(i); + removed++; + } + return removed; + } + + void CollectChainDescendants(Transform parent, HashSet boneSet, + Dictionary paramsByBone, SpringBoneJointConfig inheritedConfig) + { + for (int i = 0; i < parent.childCount; i++) + { + var child = parent.GetChild(i); + if (!boneSet.Contains(child)) continue; + if (paramsByBone.ContainsKey(child.name)) continue; + + var c = inheritedConfig; + c.LocalRotation = child.localRotation; + chainJoints.Add(child); + chainConfigs.Add(c); + CollectChainDescendants(child, boneSet, paramsByBone, inheritedConfig); + } + } + void FlushChain(GameObject owner, Transform rootParent, string rootBoneName) { for (int j = 0; j < chainJoints.Count; j++) @@ -107,47 +206,17 @@ void FlushChain(GameObject owner, Transform rootParent, string rootBoneName) for (int j = 0; j < chainJoints.Count; j++) tails[j] = j + 1 < chainJoints.Count ? (float3)chainJoints[j + 1].position : (float3)chainJoints[j].position; - int slot = service.RegisterSpring(chainJoints.ToArray(), chainConfigs.ToArray(), tails); + 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); chainRootParents.Add(rootParent); chainOwners.Add(owner); chainRootBoneNames.Add(rootBoneName); - } - - /// - /// Override physics params per chain. Keys in match the - /// chain's root bone name. Returns number of chains updated. - /// - public int UpdateParamsForWearable(GameObject owner, Dictionary paramsByBone) - { - if (service == null || owner == null || paramsByBone == null) return 0; - - int ownedChains = 0; - int updated = 0; - for (int i = 0; i < slotIndices.Count; i++) - { - if (chainOwners[i] != owner) continue; - ownedChains++; - - if (!paramsByBone.TryGetValue(chainRootBoneNames[i], out var p)) - { - Debug.Log($"[SpringBones] chain root '{chainRootBoneNames[i]}' on '{owner.name}' not in payload, skipped"); - continue; - } - - 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); - - service.UpdateSlotParams(slotIndices[i], p.stiffness, p.drag, gravityDir, p.gravityPower); - Debug.Log($"[SpringBones] updated chain '{chainRootBoneNames[i]}' on '{owner.name}' -> stiffness={p.stiffness} drag={p.drag} gravityPower={p.gravityPower} gravityDir=({gravityDir.x},{gravityDir.y},{gravityDir.z})"); - updated++; - } - - if (ownedChains == 0) - Debug.Log($"[SpringBones] '{owner.name}' owns 0 chains in driver (nothing to update)"); - - return updated; + chainBones.Add(jointsCopy); + chainInitialRotations.Add(initRots); } static SpringBoneJointConfig BuildJointConfig(SpringBoneData d) => new() @@ -159,6 +228,21 @@ public int UpdateParamsForWearable(GameObject owner, Dictionary Date: Fri, 24 Apr 2026 11:06:05 +0200 Subject: [PATCH 08/16] feat: read wearablesDTO instead of the SpringBoneJointComponent --- Assets/Scripts/Data/ActiveEntityResponse.cs | 2 + Assets/Scripts/Data/EntityDefinition.cs | 25 ++++++++-- Assets/Scripts/Data/SpringBonesDto.cs | 54 +++++++++++++++++++++ Assets/Scripts/Data/SpringBonesDto.cs.meta | 3 ++ Assets/Scripts/Loading/AvatarLoader.cs | 16 +++++- Assets/Scripts/Services/APIService.cs | 27 +++-------- Assets/Scripts/Utils/AvatarUtils.cs | 38 +++++++++------ 7 files changed, 125 insertions(+), 40 deletions(-) create mode 100644 Assets/Scripts/Data/SpringBonesDto.cs create mode 100644 Assets/Scripts/Data/SpringBonesDto.cs.meta diff --git a/Assets/Scripts/Data/ActiveEntityResponse.cs b/Assets/Scripts/Data/ActiveEntityResponse.cs index 9051bdc..6d7948c 100644 --- a/Assets/Scripts/Data/ActiveEntityResponse.cs +++ b/Assets/Scripts/Data/ActiveEntityResponse.cs @@ -11,6 +11,8 @@ public class ActiveEntity public Content[] content; public Metadata metadata; + public SpringBonesDto springBones; + public bool IsEmote => type == "emote"; [Serializable] diff --git a/Assets/Scripts/Data/EntityDefinition.cs b/Assets/Scripts/Data/EntityDefinition.cs index 47fbe86..dfe2451 100644 --- a/Assets/Scripts/Data/EntityDefinition.cs +++ b/Assets/Scripts/Data/EntityDefinition.cs @@ -19,8 +19,24 @@ 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 mainFile = _representations[shape].MainFile; + return _springBones.models.TryGetValue(mainFile, out var map) ? map : null; + } + public Representation[] GetAllRepresentations() { // TODO: Cleanup @@ -48,7 +64,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,6 +73,7 @@ private EntityDefinition(string urn, string category, string thumbnail, EntityTy Type = type; _representations = representations; Flags = flags; + _springBones = springBones; } public class Representation @@ -161,7 +179,7 @@ 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.springBones); } public static EntityDefinition FromBase64(byte[] b64) @@ -188,7 +206,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/SpringBonesDto.cs b/Assets/Scripts/Data/SpringBonesDto.cs new file mode 100644 index 0000000..fdcce83 --- /dev/null +++ b/Assets/Scripts/Data/SpringBonesDto.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +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); + 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/Loading/AvatarLoader.cs b/Assets/Scripts/Loading/AvatarLoader.cs index b5b3b4b..dca5559 100644 --- a/Assets/Scripts/Loading/AvatarLoader.cs +++ b/Assets/Scripts/Loading/AvatarLoader.cs @@ -37,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(); @@ -171,7 +175,14 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable m.Root.activeSelf) - .Select(m => (m.Root, AvatarUtils.BuildSpringBoneData(m.Root)))); + .Select(m => (m.Root, AvatarUtils.BuildSpringBoneData(m.Root, m.Entity.GetSpringBoneParams(bodyShape))))); + + // JSBridge overrides win over wearable definition params. + foreach (var (itemId, paramsByBone) in _springBoneOverrides) + { + if (TryFindWearableByItemId(itemId, out var owner)) + springBonesDriver.SetSpringChainsForWearable(owner, paramsByBone); + } } else { @@ -210,6 +221,9 @@ public void SetSpringBonesParams(SpringBones.SpringBonesParamsPayload payload) return; } + // Cache so the override is re-applied after every reload (wins over wearable definition). + _springBoneOverrides[payload.itemId] = payload.@params; + if (!TryFindWearableByItemId(payload.itemId, out var owner)) { var loadedUrns = string.Join(", ", _loadedModels.Keys); 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/Utils/AvatarUtils.cs b/Assets/Scripts/Utils/AvatarUtils.cs index 05a4532..dfafc8e 100644 --- a/Assets/Scripts/Utils/AvatarUtils.cs +++ b/Assets/Scripts/Utils/AvatarUtils.cs @@ -315,13 +315,17 @@ private static void RemapBonesPreservingExtras(SkinnedMeshRenderer renderer, } /// - /// Walks a wearable's skeleton, extracts spring bone chains tagged with - /// , and returns flat data for the - /// SpringBones simulator. Chain order: each tagged root is followed by its untagged - /// descendants that participate in the skinning, in DFS order. + /// Walks a wearable's skeleton and returns flat spring-bone data for the SpringBones + /// simulator, using the per-bone params declared in the wearable definition. + /// Chain order: each root is followed by its untagged descendants that participate in + /// the skinning, in DFS order. /// - public static SpringBoneData[] BuildSpringBoneData(GameObject wearable) + public static SpringBoneData[] BuildSpringBoneData(GameObject wearable, + IReadOnlyDictionary springBoneParams) { + if (springBoneParams == null || springBoneParams.Count == 0) + return Array.Empty(); + var skeleton = wearable.GetComponentInChildren(); if (skeleton == null) return Array.Empty(); @@ -331,16 +335,15 @@ public static SpringBoneData[] BuildSpringBoneData(GameObject wearable) foreach (var bone in skeleton.bones) { if (bone == null) continue; - var joint = bone.GetComponent(); - if (joint == null) continue; + if (!springBoneParams.TryGetValue(bone.name, out var paramsDto)) continue; result.Add(new SpringBoneData( - bone, joint.IsRoot, - joint.Stiffness, joint.Drag, joint.GravityDir, joint.GravityPower, joint.HitRadius, + bone, paramsDto.isRoot, + paramsDto.stiffness, paramsDto.drag, paramsDto.gravityDir, paramsDto.gravityPower, paramsDto.hitRadius, bone.localRotation)); - if (joint.IsRoot) - CollectSpringBoneChain(bone, joint, boneSet, result); + if (paramsDto.isRoot) + CollectSpringBoneChain(bone, paramsDto, boneSet, springBoneParams, result); } Debug.Log($"[SpringBones] {wearable.name}: found {result.Count} spring joints (skeleton bones={skeleton.bones.Length})"); @@ -348,21 +351,24 @@ public static SpringBoneData[] BuildSpringBoneData(GameObject wearable) } private static void CollectSpringBoneChain(Transform parent, - GLTFast.SpringBoneJointComponent rootCfg, - HashSet boneSet, List output) + SpringBoneParamsDto rootCfg, + HashSet boneSet, + IReadOnlyDictionary springBoneParams, + List output) { for (int i = 0; i < parent.childCount; i++) { var child = parent.GetChild(i); if (!boneSet.Contains(child)) continue; - if (child.GetComponent() != null) continue; + // If the child has its own entry, it's an independent root — skip + if (springBoneParams.ContainsKey(child.name)) continue; output.Add(new SpringBoneData( child, false, - rootCfg.Stiffness, rootCfg.Drag, rootCfg.GravityDir, rootCfg.GravityPower, rootCfg.HitRadius, + rootCfg.stiffness, rootCfg.drag, rootCfg.gravityDir, rootCfg.gravityPower, rootCfg.hitRadius, child.localRotation)); - CollectSpringBoneChain(child, rootCfg, boneSet, output); + CollectSpringBoneChain(child, rootCfg, boneSet, springBoneParams, output); } } From c055f1b91d765f5c1881640a5d44c53b36fa4f49 Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Wed, 29 Apr 2026 15:17:50 +0200 Subject: [PATCH 09/16] fix: issue when parsing bones in root chains --- Assets/Scripts/Loading/AvatarLoader.cs | 38 ++++++++++-- Assets/Scripts/SpringBones/SpringBoneData.cs | 35 ----------- .../SpringBones/SpringBoneData.cs.meta | 2 - .../Scripts/SpringBones/SpringBonesDriver.cs | 54 ----------------- Assets/Scripts/Utils/AvatarUtils.cs | 59 ------------------- 5 files changed, 33 insertions(+), 155 deletions(-) delete mode 100644 Assets/Scripts/SpringBones/SpringBoneData.cs delete mode 100644 Assets/Scripts/SpringBones/SpringBoneData.cs.meta diff --git a/Assets/Scripts/Loading/AvatarLoader.cs b/Assets/Scripts/Loading/AvatarLoader.cs index dca5559..ef6a044 100644 --- a/Assets/Scripts/Loading/AvatarLoader.cs +++ b/Assets/Scripts/Loading/AvatarLoader.cs @@ -171,22 +171,32 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable m.Root.activeSelf) - .Select(m => (m.Root, AvatarUtils.BuildSpringBoneData(m.Root, m.Entity.GetSpringBoneParams(bodyShape))))); + springBonesDriver.UnregisterAll(); - // JSBridge overrides win over wearable definition params. + 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.LogWarning("[SpringBones] springBonesDriver not wired on AvatarLoader"); + Debug.LogError("[SpringBones] springBonesDriver not wired on AvatarLoader"); } // If there is a new emote to be played @@ -235,6 +245,24 @@ public void SetSpringBonesParams(SpringBones.SpringBonesParamsPayload payload) springBonesDriver.SetSpringChainsForWearable(owner, payload.@params); } + private static Dictionary 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 }, + }; + } + return result; + } + private bool TryFindWearableByItemId(string itemId, out GameObject owner) { if (_loadedModels.TryGetValue(itemId, out var exact)) diff --git a/Assets/Scripts/SpringBones/SpringBoneData.cs b/Assets/Scripts/SpringBones/SpringBoneData.cs deleted file mode 100644 index 9bc4dc7..0000000 --- a/Assets/Scripts/SpringBones/SpringBoneData.cs +++ /dev/null @@ -1,35 +0,0 @@ -using UnityEngine; - -namespace SpringBones -{ - public readonly struct SpringBoneData - { - public readonly Transform ManagedTransform; - public readonly bool IsRoot; - public readonly float Stiffness; - public readonly float Drag; - public readonly Vector3 GravityDir; - public readonly float GravityPower; - public readonly float HitRadius; - public readonly Quaternion InitialLocalRotation; - - public SpringBoneData(Transform managedTransform, - bool isRoot, - float stiffness, - float drag, - Vector3 gravityDir, - float gravityPower, - float hitRadius, - Quaternion initialLocalRotation) - { - ManagedTransform = managedTransform; - IsRoot = isRoot; - Stiffness = stiffness; - Drag = drag; - GravityDir = gravityDir; - GravityPower = gravityPower; - HitRadius = hitRadius; - InitialLocalRotation = initialLocalRotation; - } - } -} diff --git a/Assets/Scripts/SpringBones/SpringBoneData.cs.meta b/Assets/Scripts/SpringBones/SpringBoneData.cs.meta deleted file mode 100644 index f504321..0000000 --- a/Assets/Scripts/SpringBones/SpringBoneData.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c4e324c1bfeaf7946af92b57fbd3fbdd \ No newline at end of file diff --git a/Assets/Scripts/SpringBones/SpringBonesDriver.cs b/Assets/Scripts/SpringBones/SpringBonesDriver.cs index 18f7331..10452ef 100644 --- a/Assets/Scripts/SpringBones/SpringBonesDriver.cs +++ b/Assets/Scripts/SpringBones/SpringBonesDriver.cs @@ -37,51 +37,6 @@ public void UnregisterAll() chainInitialRotations.Clear(); } - public void RegisterAll(IEnumerable<(GameObject owner, SpringBoneData[] springs)> wearableSprings) - { - UnregisterAll(); - foreach (var (owner, springs) in wearableSprings) - { - if (springs == null || springs.Length == 0) continue; - RegisterWearable(owner, springs); - } - Debug.Log($"[SpringBones] driver registered {slotIndices.Count} chain(s)"); - } - - void RegisterWearable(GameObject owner, SpringBoneData[] springs) - { - chainJoints.Clear(); - chainConfigs.Clear(); - Transform chainRootParent = null; - string chainRootBoneName = null; - - for (int i = 0; i < springs.Length; i++) - { - var sb = springs[i]; - - if (sb.IsRoot && chainJoints.Count > 0) - { - FlushChain(owner, chainRootParent, chainRootBoneName); - chainJoints.Clear(); - chainConfigs.Clear(); - } - - sb.ManagedTransform.localRotation = sb.InitialLocalRotation; - - if (sb.IsRoot) - { - chainRootParent = sb.ManagedTransform.parent; - chainRootBoneName = sb.ManagedTransform.name; - } - - chainJoints.Add(sb.ManagedTransform); - chainConfigs.Add(BuildJointConfig(sb)); - } - - if (chainJoints.Count > 0) - FlushChain(owner, chainRootParent, chainRootBoneName); - } - /// /// Replace the full set of spring chains for a wearable with the ones declared in /// . Empty / null map clears all chains owned by @@ -219,15 +174,6 @@ void FlushChain(GameObject owner, Transform rootParent, string rootBoneName) chainInitialRotations.Add(initRots); } - static SpringBoneJointConfig BuildJointConfig(SpringBoneData d) => new() - { - Stiffness = d.Stiffness, - Drag = d.Drag, - GravityDir = d.GravityDir, - GravityPower = d.GravityPower, - LocalRotation = d.InitialLocalRotation, - }; - static SpringBoneJointConfig BuildConfigFromDTO(SpringBoneParamsDTO p, Quaternion initialRotation) { float3 gravityDir = p.gravityDir != null && p.gravityDir.Length == 3 diff --git a/Assets/Scripts/Utils/AvatarUtils.cs b/Assets/Scripts/Utils/AvatarUtils.cs index dfafc8e..67a0770 100644 --- a/Assets/Scripts/Utils/AvatarUtils.cs +++ b/Assets/Scripts/Utils/AvatarUtils.cs @@ -5,7 +5,6 @@ using JetBrains.Annotations; using Loading; using Runtime.Wearables; -using SpringBones; using UnityEngine; using UnityEngine.Pool; @@ -314,64 +313,6 @@ private static void RemapBonesPreservingExtras(SkinnedMeshRenderer renderer, renderer.bones = remapped; } - /// - /// Walks a wearable's skeleton and returns flat spring-bone data for the SpringBones - /// simulator, using the per-bone params declared in the wearable definition. - /// Chain order: each root is followed by its untagged descendants that participate in - /// the skinning, in DFS order. - /// - public static SpringBoneData[] BuildSpringBoneData(GameObject wearable, - IReadOnlyDictionary springBoneParams) - { - if (springBoneParams == null || springBoneParams.Count == 0) - return Array.Empty(); - - var skeleton = wearable.GetComponentInChildren(); - if (skeleton == null) return Array.Empty(); - - var boneSet = new HashSet(skeleton.bones); - var result = new List(); - - foreach (var bone in skeleton.bones) - { - if (bone == null) continue; - if (!springBoneParams.TryGetValue(bone.name, out var paramsDto)) continue; - - result.Add(new SpringBoneData( - bone, paramsDto.isRoot, - paramsDto.stiffness, paramsDto.drag, paramsDto.gravityDir, paramsDto.gravityPower, paramsDto.hitRadius, - bone.localRotation)); - - if (paramsDto.isRoot) - CollectSpringBoneChain(bone, paramsDto, boneSet, springBoneParams, result); - } - - Debug.Log($"[SpringBones] {wearable.name}: found {result.Count} spring joints (skeleton bones={skeleton.bones.Length})"); - return result.Count > 0 ? result.ToArray() : Array.Empty(); - } - - private static void CollectSpringBoneChain(Transform parent, - SpringBoneParamsDto rootCfg, - HashSet boneSet, - IReadOnlyDictionary springBoneParams, - List output) - { - for (int i = 0; i < parent.childCount; i++) - { - var child = parent.GetChild(i); - if (!boneSet.Contains(child)) continue; - // If the child has its own entry, it's an independent root — skip - if (springBoneParams.ContainsKey(child.name)) continue; - - output.Add(new SpringBoneData( - child, false, - rootCfg.stiffness, rootCfg.drag, rootCfg.gravityDir, rootCfg.gravityPower, rootCfg.hitRadius, - child.localRotation)); - - CollectSpringBoneChain(child, rootCfg, boneSet, springBoneParams, output); - } - } - /// /// 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. From 08cf387c2ac1239600185523ed5863943d5f9c66 Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Wed, 29 Apr 2026 16:36:42 +0200 Subject: [PATCH 10/16] fix: align spring bones payload with definitive wearable JSON springBones now read from metadata.data.springBones (was top-level on ActiveEntity) and models keyed by main-file hash (was filename). Vector3 converter accepts both array and object forms. Driver respects isRoot flag and walks chains in skinned.bones order. Co-Authored-By: Claude Opus 4.7 (1M context) --- Assets/Scripts/Data/ActiveEntityResponse.cs | 3 +- Assets/Scripts/Data/EntityDefinition.cs | 20 +++++-- Assets/Scripts/Data/SpringBonesDto.cs | 8 +++ Assets/Scripts/JSBridge.cs | 8 +-- Assets/Scripts/Loading/AvatarLoader.cs | 17 ++---- .../SpringBones/SpringBoneParamsPayload.cs | 1 + .../Scripts/SpringBones/SpringBonesDriver.cs | 59 ++++++++++--------- 7 files changed, 60 insertions(+), 56 deletions(-) diff --git a/Assets/Scripts/Data/ActiveEntityResponse.cs b/Assets/Scripts/Data/ActiveEntityResponse.cs index 6d7948c..04dbd77 100644 --- a/Assets/Scripts/Data/ActiveEntityResponse.cs +++ b/Assets/Scripts/Data/ActiveEntityResponse.cs @@ -11,8 +11,6 @@ public class ActiveEntity public Content[] content; public Metadata metadata; - public SpringBonesDto springBones; - public bool IsEmote => type == "emote"; [Serializable] @@ -45,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 dfe2451..455a11c 100644 --- a/Assets/Scripts/Data/EntityDefinition.cs +++ b/Assets/Scripts/Data/EntityDefinition.cs @@ -33,8 +33,9 @@ public IReadOnlyDictionary GetSpringBoneParams(Body if (_springBones?.models == null) return null; if (!HasRepresentation(shape)) return null; - var mainFile = _representations[shape].MainFile; - return _springBones.models.TryGetValue(mainFile, out var map) ? map : 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() @@ -80,14 +81,16 @@ 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; } @@ -130,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 } @@ -153,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() ); } @@ -179,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, entity.springBones); + return new EntityDefinition(urn, category, thumbnail, type, flags, representations, + entity.IsEmote ? null : entity.metadata?.data?.springBones); } public static EntityDefinition FromBase64(byte[] b64) diff --git a/Assets/Scripts/Data/SpringBonesDto.cs b/Assets/Scripts/Data/SpringBonesDto.cs index fdcce83..02d577a 100644 --- a/Assets/Scripts/Data/SpringBonesDto.cs +++ b/Assets/Scripts/Data/SpringBonesDto.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEngine; @@ -45,6 +46,13 @@ public override Vector3 ReadJson(JsonReader reader, Type objectType, Vector3 exi { 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, diff --git a/Assets/Scripts/JSBridge.cs b/Assets/Scripts/JSBridge.cs index 2ae6e13..a3e1a97 100644 --- a/Assets/Scripts/JSBridge.cs +++ b/Assets/Scripts/JSBridge.cs @@ -84,7 +84,6 @@ public void SetUrns(string value) => [UsedImplicitly] public void SetSpringBonesParams(string value) { - Debug.Log($"[SpringBones] JSBridge.SetSpringBonesParams raw payload ({value?.Length ?? 0} chars): {value}"); SpringBones.SpringBonesParamsPayload payload; try { payload = SpringBones.SpringBonesParamsPayload.Parse(value); } catch (Exception e) @@ -92,12 +91,7 @@ public void SetSpringBonesParams(string value) Debug.LogError($"[SpringBones] failed to parse SetSpringBonesParams payload: {e.Message}"); return; } - if (payload == null) - { - Debug.LogError("[SpringBones] parsed payload is null"); - return; - } - Debug.Log($"[SpringBones] parsed itemId='{payload.itemId}' paramsKeys=[{(payload.@params == null ? "null" : string.Join(",", payload.@params.Keys))}]"); + if (payload == null) return; previewController.SetSpringBonesParams(payload); } diff --git a/Assets/Scripts/Loading/AvatarLoader.cs b/Assets/Scripts/Loading/AvatarLoader.cs index ef6a044..f978637 100644 --- a/Assets/Scripts/Loading/AvatarLoader.cs +++ b/Assets/Scripts/Loading/AvatarLoader.cs @@ -220,28 +220,18 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable GameObject '{owner.name}'"); springBonesDriver.SetSpringChainsForWearable(owner, payload.@params); } @@ -258,6 +248,7 @@ private static Dictionary ConvertMetadataParams( drag = m.drag, gravityPower = m.gravityPower, gravityDir = new[] { m.gravityDir.x, m.gravityDir.y, m.gravityDir.z }, + isRoot = m.isRoot, }; } return result; diff --git a/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs b/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs index 5973454..f70e11e 100644 --- a/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs +++ b/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs @@ -26,5 +26,6 @@ public class SpringBoneParamsDTO public float[] gravityDir; public float drag; public string center; + public bool isRoot; } } diff --git a/Assets/Scripts/SpringBones/SpringBonesDriver.cs b/Assets/Scripts/SpringBones/SpringBonesDriver.cs index 10452ef..76982af 100644 --- a/Assets/Scripts/SpringBones/SpringBonesDriver.cs +++ b/Assets/Scripts/SpringBones/SpringBonesDriver.cs @@ -47,48 +47,40 @@ public void SetSpringChainsForWearable(GameObject owner, Dictionary(); if (skinned == null) { - Debug.Log($"[SpringBones] '{owner.name}' has no SkinnedMeshRenderer; cannot build chains"); + Debug.LogError($"[SpringBones] '{owner.name}' has no SkinnedMeshRenderer; cannot build chains"); return; } var boneSet = new HashSet(skinned.bones); - var nameIndex = new Dictionary(); - foreach (var b in skinned.bones) - if (b != null && !nameIndex.ContainsKey(b.name)) - nameIndex[b.name] = b; - int added = 0; + int rootCount = 0; foreach (var kv in paramsByBone) + if (kv.Value.isRoot) rootCount++; + // Fallback: if no entry is flagged as root (e.g. JSBridge payload, legacy data), + // treat every entry as a root so chains still register. + var treatAllAsRoots = rootCount == 0; + + foreach (var bone in skinned.bones) { - if (!nameIndex.TryGetValue(kv.Key, out var rootBone)) - { - Debug.Log($"[SpringBones] bone '{kv.Key}' not found in '{owner.name}'; skipped"); - continue; - } + if (bone == null) continue; + if (!paramsByBone.TryGetValue(bone.name, out var paramsDto)) continue; + if (!treatAllAsRoots && !paramsDto.isRoot) continue; chainJoints.Clear(); chainConfigs.Clear(); - var rootConfig = BuildConfigFromDTO(kv.Value, rootBone.localRotation); - chainJoints.Add(rootBone); + var rootConfig = BuildConfigFromDTO(paramsDto, bone.localRotation); + chainJoints.Add(bone); chainConfigs.Add(rootConfig); - CollectChainDescendants(rootBone, boneSet, paramsByBone, rootConfig); - FlushChain(owner, rootBone.parent, rootBone.name); - Debug.Log($"[SpringBones] chain '{rootBone.name}' on '{owner.name}' -> {chainJoints.Count} joint(s), stiffness={kv.Value.stiffness} drag={kv.Value.drag} gravityPower={kv.Value.gravityPower}"); - added++; + CollectChainDescendants(bone, boneSet, paramsByBone, rootConfig); + FlushChain(owner, bone.parent, bone.name); } - - Debug.Log($"[SpringBones] rebuilt '{owner.name}': removed {removed}, added {added}"); } int RemoveChainsForOwner(GameObject owner) @@ -122,10 +114,21 @@ void CollectChainDescendants(Transform parent, HashSet boneSet, { var child = parent.GetChild(i); if (!boneSet.Contains(child)) continue; - if (paramsByBone.ContainsKey(child.name)) continue; - var c = inheritedConfig; - c.LocalRotation = child.localRotation; + SpringBoneJointConfig c; + if (paramsByBone.TryGetValue(child.name, out var childParams)) + { + // Tagged as its own root: independent chain — let outer loop handle it. + if (childParams.isRoot) continue; + // Non-root entry: use its own params for this joint. + c = BuildConfigFromDTO(childParams, child.localRotation); + } + else + { + c = inheritedConfig; + c.LocalRotation = child.localRotation; + } + chainJoints.Add(child); chainConfigs.Add(c); CollectChainDescendants(child, boneSet, paramsByBone, inheritedConfig); From d7e9311f4f3c7eb5d5f34b2f9049c2b158775f22 Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Thu, 30 Apr 2026 12:18:26 +0200 Subject: [PATCH 11/16] fix: align spring bones simulation with unity-explorer Switch from reparenting extras under the live avatar skeleton to keeping spring bones in the wearable hierarchy and snapping the wearable's intermediate parent to the live avatar bone every frame. Run the sim at fixed 60 Hz sub-steps so authored stiffness/drag are framerate-invariant. Restore captured rest rotations in UnregisterAll so re-registration after emote/reload doesn't lock onto stale sim state. Match unity-explorer's chain expansion: only isRoot entries seed chains and tagged children are skipped during descendant collection. Co-Authored-By: Claude Opus 4.7 (1M context) --- Assets/Scripts/Loading/AvatarLoader.cs | 5 + .../Scripts/SpringBones/SpringBoneService.cs | 22 ++++ .../Scripts/SpringBones/SpringBonesDriver.cs | 103 ++++++++++++------ Assets/Scripts/Utils/AvatarUtils.cs | 9 +- 4 files changed, 102 insertions(+), 37 deletions(-) diff --git a/Assets/Scripts/Loading/AvatarLoader.cs b/Assets/Scripts/Loading/AvatarLoader.cs index f978637..a91e2cc 100644 --- a/Assets/Scripts/Loading/AvatarLoader.cs +++ b/Assets/Scripts/Loading/AvatarLoader.cs @@ -174,6 +174,11 @@ 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(); var ownersWithOverride = new HashSet(); diff --git a/Assets/Scripts/SpringBones/SpringBoneService.cs b/Assets/Scripts/SpringBones/SpringBoneService.cs index 2af94a3..5808551 100644 --- a/Assets/Scripts/SpringBones/SpringBoneService.cs +++ b/Assets/Scripts/SpringBones/SpringBoneService.cs @@ -8,6 +8,8 @@ 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; @@ -18,6 +20,7 @@ public class SpringBoneService : IDisposable SpringBoneJointConfig[] jointConfigs; int[] slotJointCounts; SpringBoneParentData[] parentData; + float accumulatedDt; public SpringBoneService() { @@ -85,6 +88,25 @@ public void SetParentData(int slotIndex, quaternion rotation, float4x4 localToWo } 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++) { diff --git a/Assets/Scripts/SpringBones/SpringBonesDriver.cs b/Assets/Scripts/SpringBones/SpringBonesDriver.cs index 76982af..f8ab222 100644 --- a/Assets/Scripts/SpringBones/SpringBonesDriver.cs +++ b/Assets/Scripts/SpringBones/SpringBonesDriver.cs @@ -8,7 +8,8 @@ public class SpringBonesDriver : MonoBehaviour { SpringBoneService service; readonly List slotIndices = new(); - readonly List chainRootParents = new(); + readonly List chainWearableParents = new(); + readonly List chainAvatarParents = new(); readonly List chainOwners = new(); readonly List chainRootBoneNames = new(); readonly List chainBones = new(); @@ -17,6 +18,10 @@ public class SpringBonesDriver : MonoBehaviour 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() @@ -28,9 +33,19 @@ void OnDestroy() public void UnregisterAll() { if (service == null) return; - foreach (int s in slotIndices) service.UnregisterSpring(s); + // 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(); - chainRootParents.Clear(); + chainWearableParents.Clear(); + chainAvatarParents.Clear(); chainOwners.Clear(); chainRootBoneNames.Clear(); chainBones.Clear(); @@ -60,18 +75,12 @@ public void SetSpringChainsForWearable(GameObject owner, Dictionary(skinned.bones); - int rootCount = 0; - foreach (var kv in paramsByBone) - if (kv.Value.isRoot) rootCount++; - // Fallback: if no entry is flagged as root (e.g. JSBridge payload, legacy data), - // treat every entry as a root so chains still register. - var treatAllAsRoots = rootCount == 0; - foreach (var bone in skinned.bones) { if (bone == null) continue; if (!paramsByBone.TryGetValue(bone.name, out var paramsDto)) continue; - if (!treatAllAsRoots && !paramsDto.isRoot) continue; + // Match unity-explorer: only roots register chains; non-root tagged entries are skipped. + if (!paramsDto.isRoot) continue; chainJoints.Clear(); chainConfigs.Clear(); @@ -79,10 +88,33 @@ public void SetSpringChainsForWearable(GameObject owner, Dictionary Mathf.Abs(b) > 1e-6f ? a / b : 1f; + int RemoveChainsForOwner(GameObject owner) { int removed = 0; @@ -97,7 +129,8 @@ int RemoveChainsForOwner(GameObject owner) service.UnregisterSpring(slotIndices[i]); slotIndices.RemoveAt(i); - chainRootParents.RemoveAt(i); + chainWearableParents.RemoveAt(i); + chainAvatarParents.RemoveAt(i); chainOwners.RemoveAt(i); chainRootBoneNames.RemoveAt(i); chainBones.RemoveAt(i); @@ -115,19 +148,12 @@ void CollectChainDescendants(Transform parent, HashSet boneSet, var child = parent.GetChild(i); if (!boneSet.Contains(child)) continue; - SpringBoneJointConfig c; - if (paramsByBone.TryGetValue(child.name, out var childParams)) - { - // Tagged as its own root: independent chain — let outer loop handle it. - if (childParams.isRoot) continue; - // Non-root entry: use its own params for this joint. - c = BuildConfigFromDTO(childParams, child.localRotation); - } - else - { - c = inheritedConfig; - c.LocalRotation = child.localRotation; - } + // Match unity-explorer: skip any tagged child (root or not). Tagged children are + // either standalone roots (handled by outer loop) or filtered out entirely. + if (paramsByBone.ContainsKey(child.name)) continue; + + var c = inheritedConfig; + c.LocalRotation = child.localRotation; chainJoints.Add(child); chainConfigs.Add(c); @@ -135,7 +161,7 @@ void CollectChainDescendants(Transform parent, HashSet boneSet, } } - void FlushChain(GameObject owner, Transform rootParent, string rootBoneName) + void FlushChain(GameObject owner, Transform wearableParent, Transform avatarParent, string rootBoneName) { for (int j = 0; j < chainJoints.Count; j++) { @@ -170,7 +196,8 @@ void FlushChain(GameObject owner, Transform rootParent, string rootBoneName) int slot = service.RegisterSpring(jointsCopy, chainConfigs.ToArray(), tails); slotIndices.Add(slot); - chainRootParents.Add(rootParent); + chainWearableParents.Add(wearableParent); + chainAvatarParents.Add(avatarParent); chainOwners.Add(owner); chainRootBoneNames.Add(rootBoneName); chainBones.Add(jointsCopy); @@ -198,9 +225,23 @@ void LateUpdate() for (int i = 0; i < slotIndices.Count; i++) { - var p = chainRootParents[i]; - if (p == null) continue; - service.SetParentData(slotIndices[i], p.rotation, p.localToWorldMatrix); + 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) + wearableParent.SetPositionAndRotation(avatarParent.position, avatarParent.rotation); + + service.SetParentData(slotIndices[i], avatarParent.rotation, avatarParent.localToWorldMatrix); } service.Simulate(Time.deltaTime); diff --git a/Assets/Scripts/Utils/AvatarUtils.cs b/Assets/Scripts/Utils/AvatarUtils.cs index 67a0770..a5cbf08 100644 --- a/Assets/Scripts/Utils/AvatarUtils.cs +++ b/Assets/Scripts/Utils/AvatarUtils.cs @@ -210,12 +210,9 @@ public static void SetupColors(GameObject go, AvatarColors colors, } } - // 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. - if (avatarBoneMap != null) - { - ReparentExtraBonesUnderAvatarSkeleton(go, avatarBoneMap); - } + // Spring bone chains stay in the wearable hierarchy. SpringBonesDriver snaps + // each chain's wearable parent to the live avatar bone every frame so the chain + // follows animation while preserving its authored local pose. } public static void SetupFacialFeatures(GameObject go, AvatarColors colors, From f5b2a8a9a15a2a49d78862b7e307ae75315030bb Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Thu, 30 Apr 2026 12:44:27 +0200 Subject: [PATCH 12/16] fix: sync wearable parent scale per frame for spring bones Emotes that animate avatar bone scale (e.g. head-explode) now propagate to spring-bone wearables. Previously scale was aligned only at chain registration so the wearable parent kept its initial scale while the avatar bone grew. Co-Authored-By: Claude Opus 4.7 (1M context) --- Assets/Scripts/SpringBones/SpringBonesDriver.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Assets/Scripts/SpringBones/SpringBonesDriver.cs b/Assets/Scripts/SpringBones/SpringBonesDriver.cs index f8ab222..1d1e40e 100644 --- a/Assets/Scripts/SpringBones/SpringBonesDriver.cs +++ b/Assets/Scripts/SpringBones/SpringBonesDriver.cs @@ -239,7 +239,16 @@ void LateUpdate() // 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); } From f7dcc3d9bbf6fd2422d730de0817cc69cc537d08 Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Thu, 30 Apr 2026 15:32:07 +0200 Subject: [PATCH 13/16] fix: spring bones for builder base64 wearables --- Assets/Scripts/Data/EntityDefinition.cs | 17 +++++++++++++++++ Assets/Scripts/Data/RawActiveEntity.cs | 17 ++++++++++++++++- Assets/Scripts/Utils/AvatarUtils.cs | 18 ++++++------------ 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/Assets/Scripts/Data/EntityDefinition.cs b/Assets/Scripts/Data/EntityDefinition.cs index 455a11c..7156797 100644 --- a/Assets/Scripts/Data/EntityDefinition.cs +++ b/Assets/Scripts/Data/EntityDefinition.cs @@ -199,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); } 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/Utils/AvatarUtils.cs b/Assets/Scripts/Utils/AvatarUtils.cs index a5cbf08..26ca711 100644 --- a/Assets/Scripts/Utils/AvatarUtils.cs +++ b/Assets/Scripts/Utils/AvatarUtils.cs @@ -185,18 +185,12 @@ public static void SetupColors(GameObject go, AvatarColors colors, if (avatarRootBone != null && avatarBones != null) { - if (renderer.bones.Length <= avatarBones.Length) - { - renderer.rootBone = avatarRootBone; - renderer.bones = avatarBones; - } - else - { - // Wearable has extra bones (e.g. spring bones). - // Remap standard avatar bones by name, preserve extra ones. - avatarBoneMap ??= BuildBoneMap(avatarBones); - RemapBonesPreservingExtras(renderer, avatarRootBone, avatarBoneMap); - } + // 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) From dff4c2b67d2ec0fa93effed5e46292494270b772 Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Fri, 1 May 2026 09:14:56 +0200 Subject: [PATCH 14/16] fix: chain per tagged bone, linear first-child walk --- .../Scripts/SpringBones/SpringBonesDriver.cs | 69 +++++++++---------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/Assets/Scripts/SpringBones/SpringBonesDriver.cs b/Assets/Scripts/SpringBones/SpringBonesDriver.cs index 1d1e40e..591196d 100644 --- a/Assets/Scripts/SpringBones/SpringBonesDriver.cs +++ b/Assets/Scripts/SpringBones/SpringBonesDriver.cs @@ -56,7 +56,9 @@ public void UnregisterAll() /// 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). - /// Bones present in the map that were not spring bones before become spring 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) { @@ -73,43 +75,55 @@ public void SetSpringChainsForWearable(GameObject owner, Dictionary(skinned.bones); + var bonesByName = new Dictionary(skinned.bones.Length); + foreach (var b in skinned.bones) + if (b != null) bonesByName[b.name] = b; - foreach (var bone in skinned.bones) + foreach (var (boneName, paramsDto) in paramsByBone) { - if (bone == null) continue; - if (!paramsByBone.TryGetValue(bone.name, out var paramsDto)) continue; - // Match unity-explorer: only roots register chains; non-root tagged entries are skipped. - if (!paramsDto.isRoot) continue; + if (!bonesByName.TryGetValue(boneName, out var rootBone)) continue; chainJoints.Clear(); chainConfigs.Clear(); - var rootConfig = BuildConfigFromDTO(paramsDto, bone.localRotation); - chainJoints.Add(bone); + var rootConfig = BuildConfigFromDTO(paramsDto, rootBone.localRotation); + chainJoints.Add(rootBone); chainConfigs.Add(rootConfig); - CollectChainDescendants(bone, boneSet, paramsByBone, 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 (bone.parent != null && AvatarBoneMap != null) - AvatarBoneMap.TryGetValue(bone.parent.name, out avatarParent); + 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 && bone.parent != null && bone.parent != avatarParent) + if (avatarParent != null && rootBone.parent != null && rootBone.parent != avatarParent) { - var grandparent = bone.parent.parent; + var grandparent = rootBone.parent.parent; var grandparentLossy = grandparent != null ? grandparent.lossyScale : Vector3.one; var avatarLossy = avatarParent.lossyScale; - bone.parent.localScale = new Vector3( + rootBone.parent.localScale = new Vector3( SafeDiv(avatarLossy.x, grandparentLossy.x), SafeDiv(avatarLossy.y, grandparentLossy.y), SafeDiv(avatarLossy.z, grandparentLossy.z)); - bone.parent.SetPositionAndRotation(avatarParent.position, avatarParent.rotation); + rootBone.parent.SetPositionAndRotation(avatarParent.position, avatarParent.rotation); } - FlushChain(owner, bone.parent, avatarParent, bone.name); + FlushChain(owner, rootBone.parent, avatarParent, rootBone.name); } } @@ -140,27 +154,6 @@ int RemoveChainsForOwner(GameObject owner) return removed; } - void CollectChainDescendants(Transform parent, HashSet boneSet, - Dictionary paramsByBone, SpringBoneJointConfig inheritedConfig) - { - for (int i = 0; i < parent.childCount; i++) - { - var child = parent.GetChild(i); - if (!boneSet.Contains(child)) continue; - - // Match unity-explorer: skip any tagged child (root or not). Tagged children are - // either standalone roots (handled by outer loop) or filtered out entirely. - if (paramsByBone.ContainsKey(child.name)) continue; - - var c = inheritedConfig; - c.LocalRotation = child.localRotation; - - chainJoints.Add(child); - chainConfigs.Add(c); - CollectChainDescendants(child, boneSet, paramsByBone, inheritedConfig); - } - } - void FlushChain(GameObject owner, Transform wearableParent, Transform avatarParent, string rootBoneName) { for (int j = 0; j < chainJoints.Count; j++) From ad0afbfd85a47238370fbd4acd0ee3e14ed67fbf Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Fri, 1 May 2026 10:00:29 +0200 Subject: [PATCH 15/16] fix: spring bones without params now depend on their parents worldmatrix transform --- Assets/Scripts/Loading/AvatarLoader.cs | 5 ++--- Assets/Scripts/Loading/WearableLoader.cs | 3 +-- Assets/Scripts/Utils/AvatarUtils.cs | 24 ++++++++++++------------ 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/Assets/Scripts/Loading/AvatarLoader.cs b/Assets/Scripts/Loading/AvatarLoader.cs index a91e2cc..61d6ee6 100644 --- a/Assets/Scripts/Loading/AvatarLoader.cs +++ b/Assets/Scripts/Loading/AvatarLoader.cs @@ -160,8 +160,7 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable outlineRenderers, Transform avatarRootBone = null, Transform[] avatarBones = null) { Dictionary avatarBoneMap = null; @@ -204,9 +204,13 @@ public static void SetupColors(GameObject go, AvatarColors colors, } } - // Spring bone chains stay in the wearable hierarchy. SpringBonesDriver snaps - // each chain's wearable parent to the live avatar bone every frame so the chain - // follows animation while preserving its authored local pose. + // 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); } } } From a559401269c9c2ac42dfbff386057748dc46cfc7 Mon Sep 17 00:00:00 2001 From: Alejandro Jimenez Date: Wed, 6 May 2026 14:05:51 +0200 Subject: [PATCH 16/16] fix: code review applied --- Assets/Scripts/Loading/AvatarLoader.cs | 11 +++++++++-- Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs | 3 ++- Assets/Scripts/SpringBones/SpringBoneService.cs | 1 + Assets/Scripts/SpringBones/SpringBoneSimulator.cs | 8 +++++--- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/Loading/AvatarLoader.cs b/Assets/Scripts/Loading/AvatarLoader.cs index 61d6ee6..f234be6 100644 --- a/Assets/Scripts/Loading/AvatarLoader.cs +++ b/Assets/Scripts/Loading/AvatarLoader.cs @@ -180,6 +180,13 @@ public async Awaitable LoadAvatar(BodyShape bodyShape, IEnumerable(); foreach (var (itemId, paramsByBone) in _springBoneOverrides) { @@ -232,11 +239,11 @@ public void SetSpringBonesParams(SpringBones.SpringBonesParamsPayload payload) } // Cache so the override is re-applied after every reload (wins over wearable definition). - _springBoneOverrides[payload.itemId] = payload.@params; + _springBoneOverrides[payload.itemId] = payload.parameters; if (!TryFindWearableByItemId(payload.itemId, out var owner)) return; - springBonesDriver.SetSpringChainsForWearable(owner, payload.@params); + springBonesDriver.SetSpringChainsForWearable(owner, payload.parameters); } private static Dictionary ConvertMetadataParams( diff --git a/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs b/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs index f70e11e..d0d2672 100644 --- a/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs +++ b/Assets/Scripts/SpringBones/SpringBoneParamsPayload.cs @@ -12,7 +12,8 @@ namespace SpringBones public class SpringBonesParamsPayload { public string itemId; - public Dictionary @params; + [JsonProperty("params")] + public Dictionary parameters; public static SpringBonesParamsPayload Parse(string json) => JsonConvert.DeserializeObject(json); diff --git a/Assets/Scripts/SpringBones/SpringBoneService.cs b/Assets/Scripts/SpringBones/SpringBoneService.cs index 5808551..3cec569 100644 --- a/Assets/Scripts/SpringBones/SpringBoneService.cs +++ b/Assets/Scripts/SpringBones/SpringBoneService.cs @@ -183,6 +183,7 @@ public void Dispose() jointConfigs = null; slotJointCounts = null; parentData = null; + accumulatedDt = 0f; } } } diff --git a/Assets/Scripts/SpringBones/SpringBoneSimulator.cs b/Assets/Scripts/SpringBones/SpringBoneSimulator.cs index 61ff885..1834138 100644 --- a/Assets/Scripts/SpringBones/SpringBoneSimulator.cs +++ b/Assets/Scripts/SpringBones/SpringBoneSimulator.cs @@ -5,6 +5,8 @@ namespace SpringBones { internal static class SpringBoneSimulator { + const float EPSILON = 0.0001f; + public static void SimulateSlot( int slotIndex, int jointCount, @@ -45,7 +47,7 @@ public static void SimulateSlot( float3 headToTail = nextTail - head.Position; float len = math.length(headToTail); - nextTail = len > 0.0001f + nextTail = len > EPSILON ? head.Position + (headToTail / len) * config.Length : head.Position + math.mul(math.mul(parentRotation, config.LocalRotation), config.BoneAxis) * config.Length; @@ -98,7 +100,7 @@ static quaternion FromToRotation(in float3 from, in float3 to) float fromLenSq = math.lengthsq(from); float toLenSq = math.lengthsq(to); - if (fromLenSq < 0.0001f || toLenSq < 0.0001f) + if (fromLenSq < EPSILON || toLenSq < EPSILON) return quaternion.identity; float3 f = math.normalize(from); @@ -111,7 +113,7 @@ static quaternion FromToRotation(in float3 from, in float3 to) { float3 axis = math.cross(f, new float3(1, 0, 0)); - if (math.lengthsq(axis) < 0.0001f) + if (math.lengthsq(axis) < EPSILON) axis = math.cross(f, new float3(0, 1, 0)); return quaternion.AxisAngle(math.normalize(axis), math.PI);