Skip to content

Commit 26f6430

Browse files
feat: spring bones (#60)
* feat: unity upgrade + jetbrains folders into gitignore * fix: spring bones integration * fix: apply color to all the submeshes * feat: js bridge to modify bones params * fix: SetSpringBonesParam was returning bool for no reason * chore: logs * fix: now SetSpringChainsForWearable replaces the whole spring bones hierarchy. * feat: read wearablesDTO instead of the SpringBoneJointComponent * fix: issue when parsing bones in root chains * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: spring bones for builder base64 wearables * fix: chain per tagged bone, linear first-child walk * fix: spring bones without params now depend on their parents worldmatrix transform * fix: code review applied --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent da4fef9 commit 26f6430

33 files changed

Lines changed: 1061 additions & 101 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
# Visual Studio cache directory
2828
.vs/
2929

30+
# JetBrains IDE directory
31+
.idea/
32+
3033
# Gradle cache directory
3134
.gradle/
3235

Assets/Scenes/Main.unity

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ GameObject:
130130
- component: {fileID: 9355361}
131131
- component: {fileID: 9355362}
132132
- component: {fileID: 9355363}
133+
- component: {fileID: 9355364}
133134
m_Layer: 0
134135
m_Name: AvatarRoot
135136
m_TagString: Untagged
@@ -233,6 +234,7 @@ MonoBehaviour:
233234
- {fileID: 979165189}
234235
- {fileID: 979165180}
235236
- {fileID: 979165181}
237+
springBonesDriver: {fileID: 9355364}
236238
setsHighlight: 0
237239
highlightCenter: {x: 0, y: 0.18, z: 0}
238240
highlightSize: {x: 0.57, y: 2.3}
@@ -254,6 +256,18 @@ MonoBehaviour:
254256
autoRotateSpeed: 10
255257
autoRotateDelay: 1
256258
returnSpeed: 2
259+
--- !u!114 &9355364
260+
MonoBehaviour:
261+
m_ObjectHideFlags: 0
262+
m_CorrespondingSourceObject: {fileID: 0}
263+
m_PrefabInstance: {fileID: 0}
264+
m_PrefabAsset: {fileID: 0}
265+
m_GameObject: {fileID: 9355360}
266+
m_Enabled: 1
267+
m_EditorHideFlags: 0
268+
m_Script: {fileID: 11500000, guid: 95ea887eb11d7f94eaef9ace0bb62be9, type: 3}
269+
m_Name:
270+
m_EditorClassIdentifier: Assembly-CSharp::SpringBones.SpringBonesDriver
257271
--- !u!1001 &64509934
258272
PrefabInstance:
259273
m_ObjectHideFlags: 0
@@ -2747,6 +2761,7 @@ GameObject:
27472761
m_Component:
27482762
- component: {fileID: 8085770638703914882}
27492763
- component: {fileID: 8085770638703914883}
2764+
- component: {fileID: 8085770638703914884}
27502765
m_Layer: 0
27512766
m_Name: AvatarRoot
27522767
m_TagString: Untagged
@@ -4566,9 +4581,22 @@ MonoBehaviour:
45664581
- {fileID: 64509949}
45674582
- {fileID: 64509940}
45684583
- {fileID: 64509941}
4584+
springBonesDriver: {fileID: 8085770638703914884}
45694585
setsHighlight: 1
45704586
highlightCenter: {x: 0, y: 0.18, z: 0}
45714587
highlightSize: {x: 0.57, y: 2.3}
4588+
--- !u!114 &8085770638703914884
4589+
MonoBehaviour:
4590+
m_ObjectHideFlags: 0
4591+
m_CorrespondingSourceObject: {fileID: 0}
4592+
m_PrefabInstance: {fileID: 0}
4593+
m_PrefabAsset: {fileID: 0}
4594+
m_GameObject: {fileID: 2042814726327143098}
4595+
m_Enabled: 1
4596+
m_EditorHideFlags: 0
4597+
m_Script: {fileID: 11500000, guid: 95ea887eb11d7f94eaef9ace0bb62be9, type: 3}
4598+
m_Name:
4599+
m_EditorClassIdentifier: Assembly-CSharp::SpringBones.SpringBonesDriver
45724600
--- !u!114 &8186094461447319126
45734601
MonoBehaviour:
45744602
m_ObjectHideFlags: 0

Assets/Scripts/AangConfiguration.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,8 @@ public static void RecreateFrom(string url)
246246
foreach (var parameter in split)
247247
{
248248
var keyValueSplit = parameter.Split('=');
249-
var key = HttpUtility.UrlDecode(keyValueSplit[0]);
250-
var value = keyValueSplit.Length > 1 ? HttpUtility.UrlDecode(keyValueSplit[1]) : string.Empty;
249+
var key = HttpUtility.UrlDecode(keyValueSplit[0]).Trim();
250+
var value = (keyValueSplit.Length > 1 ? HttpUtility.UrlDecode(keyValueSplit[1]) : string.Empty).Trim();
251251

252252
switch (key)
253253
{
@@ -298,7 +298,7 @@ public static void RecreateFrom(string url)
298298
break;
299299
case "env":
300300
APIService.Environment = value == "dev" ? "zone" : "org";
301-
Debug.Log($"Using environment {APIService.Environment}");
301+
Debug.Log($"Using environment {APIService.Environment} (env value=[{value}])");
302302
break;
303303
case "disableLoader":
304304
Instance.DisableLoader = bool.Parse(value);

Assets/Scripts/Data/ActiveEntityResponse.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public class Data
4343
public string[] replaces = Array.Empty<string>();
4444
public string[] removesDefaultHiding = Array.Empty<string>();
4545
public bool loop; // For emotes only
46+
public SpringBonesDto springBones;
4647
}
4748

4849
[Serializable]

Assets/Scripts/Data/EntityDefinition.cs

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,25 @@ public class EntityDefinition
1919
public readonly EntityType Type;
2020
public readonly EntityFlags Flags;
2121

22+
[CanBeNull] private readonly SpringBonesDto _springBones;
23+
2224
[ItemCanBeNull] private readonly Dictionary<BodyShape, Representation> _representations;
2325

26+
/// <summary>
27+
/// Returns the spring-bone params (boneName → params) declared in the wearable
28+
/// metadata for the given body shape's mainFile, or null if none.
29+
/// </summary>
30+
[CanBeNull]
31+
public IReadOnlyDictionary<string, SpringBoneParamsDto> GetSpringBoneParams(BodyShape shape)
32+
{
33+
if (_springBones?.models == null) return null;
34+
if (!HasRepresentation(shape)) return null;
35+
36+
var mainFileHash = _representations[shape].MainFileHash;
37+
if (string.IsNullOrEmpty(mainFileHash)) return null;
38+
return _springBones.models.TryGetValue(mainFileHash, out var map) ? map : null;
39+
}
40+
2441
public Representation[] GetAllRepresentations()
2542
{
2643
// TODO: Cleanup
@@ -48,28 +65,32 @@ public bool HasRepresentation(BodyShape bodyShape)
4865
public Representation this[BodyShape shape] => _representations[shape] ?? throw new InvalidOperationException(
4966
$"Missing {shape} representation for {URN}");
5067

51-
private EntityDefinition(string urn, string category, string thumbnail, EntityType type, EntityFlags flags, Dictionary<BodyShape, Representation> representations)
68+
private EntityDefinition(string urn, string category, string thumbnail, EntityType type, EntityFlags flags,
69+
Dictionary<BodyShape, Representation> representations, [CanBeNull] SpringBonesDto springBones)
5270
{
5371
URN = urn;
5472
Category = category;
5573
Thumbnail = thumbnail;
5674
Type = type;
5775
_representations = representations;
5876
Flags = flags;
77+
_springBones = springBones;
5978
}
6079

6180
public class Representation
6281
{
6382
public readonly Dictionary<string, string> Files;
6483
public readonly string MainFile;
84+
public readonly string MainFileHash;
6585
public readonly string[] Hides;
6686
public readonly string[] RemovesDefaultHiding;
6787

68-
private Representation(Dictionary<string, string> files, string mainFile, string[] hides,
69-
string[] removesDefaultHiding)
88+
private Representation(Dictionary<string, string> files, string mainFile, string mainFileHash,
89+
string[] hides, string[] removesDefaultHiding)
7090
{
7191
Files = files;
7292
MainFile = mainFile;
93+
MainFileHash = mainFileHash;
7394
Hides = hides;
7495
RemovesDefaultHiding = removesDefaultHiding;
7596
}
@@ -112,9 +133,13 @@ public static Representation ForBodyShape(string bodyShape, ActiveEntity entity)
112133
StringComparer.OrdinalIgnoreCase
113134
);
114135

136+
var mainFileHash = entity.content
137+
.FirstOrDefault(c => string.Equals(c.file, main, StringComparison.OrdinalIgnoreCase))?.hash;
138+
115139
var representation = new Representation(
116140
filesDict,
117141
entityRepresentation.mainFile,
142+
mainFileHash,
118143
entityRepresentation.overrideHides is { Length: > 0 }
119144
? entityRepresentation.overrideHides
120145
: data.hides.Union(entityRepresentation.overrideReplaces is { Length: > 0 }
@@ -135,7 +160,7 @@ public static Representation ForEmbeddedEmote(string emote)
135160
{
136161
["main"] = Path.Combine(Application.streamingAssetsPath, $"{emote}.glb")
137162
},
138-
"main", Array.Empty<string>(),
163+
"main", null, Array.Empty<string>(),
139164
Array.Empty<string>()
140165
);
141166
}
@@ -161,7 +186,8 @@ public static EntityDefinition FromActiveEntity(ActiveEntity entity)
161186
[BodyShape.Female] = Representation.ForBodyShape(WearablesConstants.BODY_SHAPE_FEMALE, entity)
162187
};
163188

164-
return new EntityDefinition(urn, category, thumbnail, type, flags, representations);
189+
return new EntityDefinition(urn, category, thumbnail, type, flags, representations,
190+
entity.IsEmote ? null : entity.metadata?.data?.springBones);
165191
}
166192

167193
public static EntityDefinition FromBase64(byte[] b64)
@@ -173,6 +199,23 @@ public static EntityDefinition FromBase64(byte[] b64)
173199
var raw = JsonUtility.FromJson<RawActiveEntity>(base64String);
174200
var entity = raw.ToActiveEntity();
175201

202+
// JsonUtility can't deserialize the nested Dictionary in springBones.models,
203+
// so re-parse just that field with Newtonsoft and inject it into the entity.
204+
if (!entity.IsEmote && entity.metadata?.data != null)
205+
{
206+
try
207+
{
208+
var jo = Newtonsoft.Json.Linq.JObject.Parse(base64String);
209+
var springBonesToken = jo["data"]?["springBones"];
210+
if (springBonesToken != null)
211+
entity.metadata.data.springBones = springBonesToken.ToObject<SpringBonesDto>();
212+
}
213+
catch (Exception e)
214+
{
215+
Debug.LogError($"[Base64] failed to parse springBones: {e.Message}");
216+
}
217+
}
218+
176219
return FromActiveEntity(entity);
177220
}
178221

@@ -188,7 +231,8 @@ public static EntityDefinition FromEmbeddedEmote(string emote, bool loop)
188231
{
189232
[BodyShape.Male] = Representation.ForEmbeddedEmote(emote),
190233
[BodyShape.Female] = Representation.ForEmbeddedEmote(emote)
191-
}
234+
},
235+
null
192236
);
193237
}
194238
}

Assets/Scripts/Data/RawActiveEntity.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ public class Translation
5454
public string text;
5555
}
5656

57+
static string ExtractHashFromUrl(string url)
58+
{
59+
if (string.IsNullOrEmpty(url)) return null;
60+
var trimmed = url.TrimEnd('/');
61+
var slash = trimmed.LastIndexOf('/');
62+
return slash >= 0 && slash + 1 < trimmed.Length ? trimmed[(slash + 1)..] : null;
63+
}
64+
5765
public ActiveEntity ToActiveEntity()
5866
{
5967
var reps = IsEmote ? emoteDataADR74.representations : data.representations;
@@ -64,7 +72,14 @@ public ActiveEntity ToActiveEntity()
6472
type = IsEmote ? "emote" : "wearable",
6573
content = reps
6674
.SelectMany(r => r.contents
67-
.Select(c => new ActiveEntity.Content { file = c.key, url = c.url }))
75+
.Select(c => new ActiveEntity.Content
76+
{
77+
file = c.key,
78+
url = c.url,
79+
// Spring-bone metadata keys models by content hash (CID). The CID is the
80+
// last path segment of the builder-api URL — extract it so the lookup works.
81+
hash = ExtractHashFromUrl(c.url),
82+
}))
6883
.GroupBy(c => c.file)
6984
.Select(g => g.First())
7085
.ToArray(),
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using Newtonsoft.Json;
5+
using Newtonsoft.Json.Linq;
6+
using UnityEngine;
7+
8+
namespace Data
9+
{
10+
[Serializable]
11+
public class SpringBonesDto
12+
{
13+
public int version;
14+
public Dictionary<string, Dictionary<string, SpringBoneParamsDto>> models;
15+
}
16+
17+
[Serializable]
18+
public class SpringBoneParamsDto
19+
{
20+
public float stiffness;
21+
public float drag;
22+
23+
[JsonConverter(typeof(Vector3JsonConverter))]
24+
public Vector3 gravityDir;
25+
26+
public float gravityPower;
27+
public float hitRadius;
28+
public bool isRoot;
29+
}
30+
31+
public class Vector3JsonConverter : JsonConverter<Vector3>
32+
{
33+
public override void WriteJson(JsonWriter writer, Vector3 value, JsonSerializer serializer)
34+
{
35+
writer.WriteStartObject();
36+
writer.WritePropertyName("x");
37+
writer.WriteValue(value.x);
38+
writer.WritePropertyName("y");
39+
writer.WriteValue(value.y);
40+
writer.WritePropertyName("z");
41+
writer.WriteValue(value.z);
42+
writer.WriteEndObject();
43+
}
44+
45+
public override Vector3 ReadJson(JsonReader reader, Type objectType, Vector3 existingValue, bool hasExistingValue, JsonSerializer serializer)
46+
{
47+
if (reader.TokenType == JsonToken.Null) return existingValue;
48+
var t = JToken.Load(reader);
49+
if (t.Type == JTokenType.Array)
50+
{
51+
return new Vector3(
52+
t.Count() > 0 ? t[0].Value<float>() : 0f,
53+
t.Count() > 1 ? t[1].Value<float>() : 0f,
54+
t.Count() > 2 ? t[2].Value<float>() : 0f);
55+
}
56+
return new Vector3(
57+
t["x"]?.Value<float>() ?? 0f,
58+
t["y"]?.Value<float>() ?? 0f,
59+
t["z"]?.Value<float>() ?? 0f);
60+
}
61+
}
62+
}

Assets/Scripts/Data/SpringBonesDto.cs.meta

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Assets/Scripts/JSBridge.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,20 @@ public void SetUrns(string value) =>
8181
[UsedImplicitly]
8282
public void SetUsername(string value) => AangConfiguration.Instance.Username = value;
8383

84+
[UsedImplicitly]
85+
public void SetSpringBonesParams(string value)
86+
{
87+
SpringBones.SpringBonesParamsPayload payload;
88+
try { payload = SpringBones.SpringBonesParamsPayload.Parse(value); }
89+
catch (Exception e)
90+
{
91+
Debug.LogError($"[SpringBones] failed to parse SetSpringBonesParams payload: {e.Message}");
92+
return;
93+
}
94+
if (payload == null) return;
95+
previewController.SetSpringBonesParams(payload);
96+
}
97+
8498
[UsedImplicitly]
8599
public void GetElementBounds(string elementName) => configuratorUIPresenter.GetElementBounds(elementName);
86100

0 commit comments

Comments
 (0)