@woosh/meep-engine 3.14.4 → 3.14.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/mat4/m4_rigidity_defect.d.ts +30 -0
  3. package/src/core/geom/3d/mat4/m4_rigidity_defect.d.ts.map +1 -0
  4. package/src/core/geom/3d/mat4/m4_rigidity_defect.js +71 -0
  5. package/src/engine/network/orchestrator/NetworkPeer.d.ts +56 -8
  6. package/src/engine/network/orchestrator/NetworkPeer.d.ts.map +1 -1
  7. package/src/engine/network/orchestrator/NetworkPeer.js +263 -67
  8. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts +6 -1
  9. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts.map +1 -1
  10. package/src/engine/network/orchestrator/ServerAuthoritativeClient.js +6 -0
  11. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts +13 -7
  12. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts.map +1 -1
  13. package/src/engine/network/orchestrator/ServerAuthoritativeServer.js +103 -138
  14. package/src/engine/network/replication/Replicator.d.ts +29 -6
  15. package/src/engine/network/replication/Replicator.d.ts.map +1 -1
  16. package/src/engine/network/replication/Replicator.js +265 -108
  17. package/src/engine/network/sim/ActionLog.d.ts +2 -0
  18. package/src/engine/network/sim/ActionLog.d.ts.map +1 -1
  19. package/src/engine/network/sim/ActionLog.js +2 -0
  20. package/src/engine/network/sim/ActionRecordCursor.d.ts +82 -0
  21. package/src/engine/network/sim/ActionRecordCursor.d.ts.map +1 -0
  22. package/src/engine/network/sim/ActionRecordCursor.js +120 -0
  23. package/src/engine/network/sim/RewindEngine.d.ts.map +1 -1
  24. package/src/engine/network/sim/RewindEngine.js +13 -29
  25. package/src/engine/network/sim/SimActionExecutor.d.ts +2 -3
  26. package/src/engine/network/sim/SimActionExecutor.d.ts.map +1 -1
  27. package/src/engine/network/sim/SimActionExecutor.js +10 -11
  28. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  29. package/src/shade/playground/particle_system/particle_scene.d.ts.map +1 -1
  30. package/src/shade/playground/particle_system/particle_scene.js +38 -37
  31. package/src/shade/playground/skinned_mesh_soup/README.md +39 -18
  32. package/src/shade/playground/skinned_mesh_soup/index.html +9 -6
  33. package/src/shade/playground/skinned_mesh_soup/main.d.ts.map +1 -1
  34. package/src/shade/playground/skinned_mesh_soup/main.js +16 -13
  35. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts +8 -2
  36. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts.map +1 -1
  37. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.js +23 -4
  38. package/src/shade/renderer/animation/GPUAnimationManager.d.ts.map +1 -1
  39. package/src/shade/renderer/animation/GPUAnimationManager.js +1958 -1796
  40. package/src/shade/renderer/animation/SKINNING_FRAME_PLAN.md +357 -0
  41. package/src/shade/renderer/animation/skin_bind_transform.d.ts +25 -0
  42. package/src/shade/renderer/animation/skin_bind_transform.d.ts.map +1 -0
  43. package/src/shade/renderer/animation/skin_bind_transform.js +33 -0
  44. package/src/shade/renderer/animation/skin_clip_scale_conflicts.d.ts +67 -0
  45. package/src/shade/renderer/animation/skin_clip_scale_conflicts.d.ts.map +1 -0
  46. package/src/shade/renderer/animation/skin_clip_scale_conflicts.js +199 -0
  47. package/src/shade/renderer/animation/skin_frame_defect.d.ts +34 -0
  48. package/src/shade/renderer/animation/skin_frame_defect.d.ts.map +1 -0
  49. package/src/shade/renderer/animation/skin_frame_defect.js +132 -0
  50. package/src/shade/renderer/animation/skin_joint_matrix.d.ts +24 -0
  51. package/src/shade/renderer/animation/skin_joint_matrix.d.ts.map +1 -0
  52. package/src/shade/renderer/animation/skin_joint_matrix.js +46 -0
  53. package/src/shade/renderer/animation/skin_normalize_mesh_frame.d.ts +70 -0
  54. package/src/shade/renderer/animation/skin_normalize_mesh_frame.d.ts.map +1 -0
  55. package/src/shade/renderer/animation/skin_normalize_mesh_frame.js +216 -0
  56. package/src/shade/renderer/animation/skin_test_fixtures.d.ts +86 -0
  57. package/src/shade/renderer/animation/skin_test_fixtures.d.ts.map +1 -0
  58. package/src/shade/renderer/animation/skin_test_fixtures.js +250 -0
  59. package/src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.d.ts +18 -4
  60. package/src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.d.ts.map +1 -1
  61. package/src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.js +18 -4
  62. package/src/shade/renderer/particles/DESIGN.md +5 -5
  63. package/src/shade/renderer/particles/graph/ParticleNodeDescription.d.ts +43 -0
  64. package/src/shade/renderer/particles/graph/ParticleNodeDescription.d.ts.map +1 -0
  65. package/src/shade/renderer/particles/graph/ParticleNodeDescription.js +44 -0
  66. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.d.ts +33 -59
  67. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.d.ts.map +1 -1
  68. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.js +185 -118
  69. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts +15 -10
  70. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts.map +1 -1
  71. package/src/shade/renderer/particles/graph/compile_particle_graph.js +205 -170
  72. package/src/shade/renderer/particles/graph/particle_graph_authoring.d.ts +44 -0
  73. package/src/shade/renderer/particles/graph/particle_graph_authoring.d.ts.map +1 -0
  74. package/src/shade/renderer/particles/graph/particle_graph_authoring.js +108 -0
  75. package/src/shade/renderer/particles/particle_prototype.d.ts.map +1 -1
  76. package/src/shade/renderer/particles/particle_prototype.js +181 -180
  77. package/src/shade/renderer/particles/runtime/create_particle_effect.d.ts +4 -4
  78. package/src/shade/renderer/particles/runtime/create_particle_effect.d.ts.map +1 -1
  79. package/src/shade/renderer/particles/runtime/create_particle_effect.js +19 -19
  80. package/src/shade/renderer/particles/vm/chunk_particle_vm.js +1 -1
  81. package/src/shade/renderer/scene/SkinnedMesh.d.ts +11 -0
  82. package/src/shade/renderer/scene/SkinnedMesh.d.ts.map +1 -1
  83. package/src/shade/renderer/scene/SkinnedMesh.js +11 -0
  84. package/src/shade/renderer/shader/chunk/geometry/quaternion/dual/chunk_dual_quat_from_m4.d.ts +33 -3
  85. package/src/shade/renderer/shader/chunk/geometry/quaternion/dual/chunk_dual_quat_from_m4.d.ts.map +1 -1
  86. package/src/shade/renderer/shader/chunk/geometry/quaternion/dual/chunk_dual_quat_from_m4.js +58 -37
  87. package/src/shade/renderer/particles/graph/ParticleGraph.d.ts +0 -54
  88. package/src/shade/renderer/particles/graph/ParticleGraph.d.ts.map +0 -1
  89. package/src/shade/renderer/particles/graph/ParticleGraph.js +0 -99
@@ -0,0 +1,357 @@
1
+ # The skinning frame
2
+
3
+ **Status:** phase 1 landed, §9 with it, both confirmed on a device. §4 is detected but not
4
+ carried.
5
+
6
+ A skinned mesh's vertices are written in a frame, and the renderer re-applies that frame. Today the
7
+ frame is whatever the asset's mesh node happens to carry, and nothing checks that it is a frame the
8
+ skinning pass can actually work in. This plan makes the frame the engine's, derived from the skin,
9
+ and makes the condition it must satisfy a checked one.
10
+
11
+ ## 1. The defect
12
+
13
+ The pipeline is two multiplications:
14
+
15
+ ```
16
+ A_j(t) = joint.global(t) · inverse_bind shader_skin_matrix_prep writes this
17
+ M_j(t) = mesh.global⁻¹ · A_j(t) chunk_skin_blend_mesh_local converts this to a DQ
18
+ ```
19
+
20
+ `dual_quat_from_m4` builds a unit quaternion out of `sqrt(max(0, 1 ± m00 ± m11 ± m22))`. It can
21
+ represent a rotation and a translation and nothing else, so **`M_j` must be rigid**. Whether it is
22
+ depends on `mesh.global` — a value the exporter chose, that glTF declares meaningless for a skinned
23
+ mesh, and that this engine neither derives nor validates.
24
+
25
+ `compute_skin_world_bounds` already refuses to use `mesh.global` for exactly that reason, and says
26
+ so in its doc comment. The skinning pass still does, and inverts it.
27
+
28
+ ### What it looks like when it is violated
29
+
30
+ Measured on `test_assets/Michelle.glb`, in `playground/skinned_mesh_soup/__soup_blend_probe.spec.js`
31
+ — the shader's own chunks ported to JS and run over the character's own matrices:
32
+
33
+ | | |
34
+ | --- | --- |
35
+ | `joint.global · inverse_bind` at rest | the **identity**, for all 65 joints |
36
+ | `mesh.global` | `0.01` scale × the file's up-axis rotation |
37
+ | column length of `M_j` | **100.0000**, at rest and under the dance, where rigid is 1 |
38
+
39
+ At rest every joint folds to the *same* matrix, `100·Rx(90°)`, whose `w` and `x` terms are both
40
+ `1 + 100` and whose `y` and `z` both clamp to zero — the normalize recovers `Rx(90°)` by luck, and
41
+ only the scale is lost. Output is `expected / 100` to the digit: the right character, 1/100 the
42
+ size.
43
+
44
+ Under the dance the joints' matrices differ, each loses a different amount, and the mesh comes
45
+ apart. Mean error 121 in a mesh-local space where the body is 224 across; worst 165, on vertices
46
+ bound to **a single joint at weight 1**, so the blend is not even involved. That is the soup.
47
+
48
+ ### What it is not
49
+
50
+ Ruled out on the CPU, on the parsed asset, before a GPU is involved
51
+ (`playground/skinned_mesh_soup/__soup_probe.spec.js`):
52
+
53
+ - the geometry is a surface — 28 106 triangles, no degenerates, longest edge 0.093 against a 2.24
54
+ diagonal, every meshlet index in range;
55
+ - the skinning attributes are sound — all 19 106 vertices land back on their rest position under
56
+ `joint.global · inverse_bind`, no zero-weight vertices, no out-of-range joint;
57
+ - the source BLAS is exact — 56 211 nodes all reachable, 28 106 leaves each naming a distinct
58
+ triangle whose box matches to the bit, all 28 105 internal nodes exactly their children's union.
59
+
60
+ Worth stating plainly: `skinned_blas_refit`'s `verify()` is structurally blind to this. It holds the
61
+ tree's root box against the bounds chain's box, and **both derive from the clone's vertex region**.
62
+ Vertices wrong in the same way agree with each other perfectly. A green `verify()` and a soup
63
+ picture are consistent, and were.
64
+
65
+ ### The precondition as currently written is the wrong one
66
+
67
+ `chunk_skin_blend_mesh_local` states it as *"the four products must be rigid, which holds whenever
68
+ the joints and the mesh node share the scaling ancestors — the shape a glTF skin has."* Michelle's
69
+ joints and mesh node **do** share their scaling ancestor (`Character`, carrying the 0.01), and it
70
+ still breaks. Sharing ancestors is neither necessary nor sufficient. §2 is the condition that is.
71
+
72
+ ## 2. The invariant
73
+
74
+ At rest, `A_j` collapses to one matrix for every joint — `compute_skin_world_bounds` already names
75
+ it, as "the character's placement in the world". Call it `A_bind`.
76
+
77
+ > **A skinned mesh's node transform is not the file's. It is the skin's bind transform:
78
+ > `mesh.global == A_bind`.**
79
+
80
+ Three consequences, each of them testable:
81
+
82
+ 1. `M_j(rest) = I`. The clone at rest is bit-identical to the source geometry — which `SkinnedMesh`'s
83
+ class doc already claims ("until bound … its vertices stay at the source's bind-pose values") and
84
+ today only accidentally delivers.
85
+ 2. `M_j(t) = A_bind⁻¹ · A_j(t)` — the joint's **displacement from bind**. Rigid exactly when the clip
86
+ rotates and translates and does not scale. That is the honest precondition of dual-quaternion
87
+ skinning: a property of the *animation*, not of the exporter's unit conventions.
88
+ 3. It is *checkable* — from data the engine already holds, on the CPU, once per skin.
89
+
90
+ ## 3. Enforcement
91
+
92
+ At `register_skin`, per skinned mesh:
93
+
94
+ ```
95
+ C := mesh.global⁻¹ · A_bind
96
+ mesh.transform_local := mesh.transform_local · C
97
+ mesh.updateMatrices()
98
+ ```
99
+
100
+ - **`C` is constant.** Anything applied above both the mesh node and the joints — placement,
101
+ movement, re-parenting — left-multiplies `mesh.global` and `A_bind` alike and cancels. Compute it
102
+ once.
103
+ - **It is idempotent.** After the correction `mesh.global == A_bind`, so a second `C` is the
104
+ identity. Re-registering a skin changes nothing.
105
+ - **It is a no-op for an asset that already satisfies the invariant.** An FBX-derived model whose
106
+ inverse-bind matrices are authored in pre-scale space has `A_bind == mesh.global` and gets
107
+ `C = I`. That is the no-regression pin, and it is a test rather than an assumption.
108
+ - For Michelle, `C = 100·R⁻¹` on `Ch03`, whose local transform is the identity today.
109
+
110
+ ### Why this layer
111
+
112
+ The mesh node is the last node on the mesh-only path — the one place a correction reaches the mesh
113
+ without disturbing the joints. Correcting it fixes the frame identically for the rasterizer, the
114
+ path tracer, the TLAS instance transform, the bounds chain and the motion vectors, because all of
115
+ them read one transform. **No shader changes. No per-vertex cost.** One matrix per skin, once.
116
+
117
+ `register_skin` rather than the loader because every skin passes through it — glTF, USD, the
118
+ editor, `instantiate_scene_bundle`, and skins built in code, which never see a loader at all.
119
+
120
+ ### The sharp edge
121
+
122
+ Children of a skinned mesh node would inherit the correction. In this engine sockets, effect anchors
123
+ and projectile spawns are authored as *bones*, so a skinned mesh node should have no children — but
124
+ "should" is what got us here. The normalisation asserts it, and says which node and which children if
125
+ it fires.
126
+
127
+ ### Where it is not representable
128
+
129
+ `Transform64` recomposes its local from TRS components, so a correction carrying shear does not
130
+ survive the round trip. Rather than test `C` for shear, the normalisation applies it and then
131
+ measures the residual directly — `max |mesh.global − A_bind|` — which is the end-to-end quantity and
132
+ catches decomposition loss, a non-agreeing skeleton and a bad `C` in one number.
133
+
134
+ ## 4. What no frame can fix
135
+
136
+ The correction absorbs a *constant* linear part. A clip that scales a joint makes `A_j` non-rigid
137
+ per joint and per frame, and no choice of frame fixes that. The ladder:
138
+
139
+ | `A_j` | answer |
140
+ | --- | --- |
141
+ | rigid | DQS, unchanged. Every asset in practice. |
142
+ | per-joint **uniform** scale | DQS with scale: factor `M_j = S(s_j) · (Rot \| t/s_j)`, DQ the rigid part, blend `s = Σ wⱼsⱼ` alongside, multiply the transformed point by `s`. One divide and one multiply per joint. |
143
+ | **non-uniform** or sheared | a dual quaternion cannot represent it at all. Either a per-skin linear-blend path — correct, candy-wrapper on those assets only — or a hard import error. |
144
+
145
+ ### The detector — landed
146
+
147
+ Rows 2 and 3 are contingencies rather than requirements, so neither is carried. What *is* built is
148
+ the thing that says whether they are ever needed, and it says it without a device:
149
+ `skin_clip_scale_conflicts`, called from `register_skin` and `register_clip` alike.
150
+
151
+ The condition is sharper than this section first stated. "A clip animating a `Scale` channel on a
152
+ joint" understates it in one direction and overstates it in another, and both matter:
153
+
154
+ - a scale on the character's **own root** reaches the mesh *and* every joint, enters `mesh.global`
155
+ and every `joint.global` alike, and cancels out of the fold. That is how a model is made to grow
156
+ and it has to stay legal — a joint-targeting test would have called it clean, which is right by
157
+ luck, and a "does it scale anything under the skin" test would have called it broken, which is
158
+ wrong;
159
+ - a scale on a node **above only some of the joints** never targets a joint at all and breaks the
160
+ fold exactly as a joint-targeted one does.
161
+
162
+ So the question is reachability, not targeting: **a scale is safe exactly when the node carrying it
163
+ reaches the mesh and all of the joints, or none of them.** Anything between is a conflict.
164
+
165
+ Two further refinements the curves themselves settle. A channel whose curves never leave 1 is not a
166
+ scale, whatever it targets — clips arrive unoptimized and an identity channel on a joint is noise.
167
+ And the range comes from `animation_curve_compute_aabb`, which follows the Hermite spline between
168
+ keys rather than reading the keys alone: a curve that leaves 1 and returns to it scales the joints
169
+ for most of the clip, and reading its endpoints would call it flat.
170
+
171
+ The report says which rung would be needed. `uniform` is true only when all three axes are driven by
172
+ equal curves, so an axis left unanimated — a squash — reads as non-uniform, which is the safe
173
+ direction.
174
+
175
+ **Cost.** `clip_scale_channels` is asked first and is one pass over a clip's channel list with a
176
+ `property !== Scale` early-out. A clip that scales nothing is remembered by nothing and compared
177
+ against nothing, which is every clip a character normally carries. Only a clip that scales something
178
+ is held in the manager's index and paired off against the registered skins, in both registration
179
+ orders — a clip can arrive before its skin as easily as after, and neither may pass in silence.
180
+
181
+ ## 5. Rejected
182
+
183
+ - **Patch `dual_quat_from_m4` to swallow the scale.** Moves an asset-conditioning problem into the
184
+ per-vertex hot loop; correct only for a uniform scale, so it still needs the check; and it leaves
185
+ the invariant unstated, so the next asset shape breaks it silently. The band-aid.
186
+ - **Drop the fold; write clones in world space.** The cleanest mathematically — glTF says skinned
187
+ vertices are world-space and the node transform is ignored. But f32 vertex storage at world
188
+ coordinates costs about a millimetre of precision per ten kilometres from the origin, and it forces
189
+ a skinned special case into the rasterizer's uniform "apply `node.global`" path. Correctness bought
190
+ with a precision bug.
191
+ - **Bake the scale into geometry at import.** Does not defend the editor's drag-and-drop or anything
192
+ the importer never saw; mutates geometry that content-keyed registration now keys on; costs a full
193
+ vertex rewrite per asset. Worth doing *additionally*, offline, so the runtime correction is a
194
+ no-op — not instead of it.
195
+
196
+ ## 6. Tiers
197
+
198
+ Kept as low as they will go, for as long as they will go. Every assertion below runs without a
199
+ device and without an asset.
200
+
201
+ | tier | what it holds | file |
202
+ | --- | --- | --- |
203
+ | 1 — pure arithmetic | a rigid matrix has zero defect; a scaled, sheared, degenerate or reflected one is reported as it should be | `core/geom/3d/mat4/m4_rigidity_defect.spec.js` |
204
+ | 2 — the skin, on the CPU | what each asset shape's bind transform *is*, and that the mesh node is a frame for one and not the other; the correction lands `mesh.global` on the bind transform for both; the pre-scale shape is untouched, element for element; idempotent; survives the walk; placement-invariant; children named; a skin no frame can fix is reported and left alone | `skin_bind_transform.spec.js`, `skin_normalize_mesh_frame.spec.js` |
205
+ | 3 — the shader's own WGSL, in the emulator | a vertex lands where its skin matrix says — every joint, both bind spaces, three conversions, at rest and posed apart — plus the negative control: the same vertex on the asset's own mesh node, most of a body away | `skinning/chunk_skin_blend_mesh_local.spec.js` |
206
+ | 2b — the clip against the skin | a scale on a joint conflicts and a scale on the character's root does not; a curve flat at 1 is not a scale and one that leaves 1 between its keys is; uniform told from squash; and the manager reports it in both registration orders | `skin_clip_scale_conflicts.spec.js` |
207
+ | 3b — the real asset, on the CPU | the whole chain over `Michelle.glb`'s own matrices and her `SambaDance`: the folded scale, the correction, and where every sampled vertex lands, before and after | `playground/skinned_mesh_soup/__soup_blend_probe.spec.js` |
208
+ | 4 — the browser | `?skin=bind` is a full-size character; `?skin=animated` dances | `playground/skinned_mesh_soup/` |
209
+
210
+ Tier 3 is the one that would have caught this: it already ran the real chunk over a synthetic model,
211
+ and it only ever built the one asset shape. Adding the other shape was the whole test change.
212
+
213
+ **Run, on an NVIDIA RTX 4090.** Phase 1: the console reported `absorbing 99.0000 of non-rigidity the
214
+ skinning pass could not have carried`, `?skin=bind` drew a full-size character, `?skin=animated`
215
+ danced, and the published box came back `1.4556 × 1.6644 × 0.3872 m` — a character, not a coin.
216
+ Then §9, whose WGSL nothing offline type-checks: compiles and runs. Then `await diagnose()` at
217
+ `?skin=bind`, which reads the clone's vertices back off the device and holds them against the bind
218
+ pose — **all 19 106 of them, worst error `7.96e-7`**, no wrong meshlets and no wrong vertex slots.
219
+ That is the f32 floor of a matrix product, a quaternion conversion and a screw motion at single
220
+ precision, and it is four orders below the smallest violation the tool can report.
221
+
222
+ Held red by hand before landing: with the correction's body removed, 11 tests fail — four of them
223
+ tier 3, and only the world-space rows. Every pre-scale row stays green, which is the no-regression
224
+ claim stated as an experiment rather than as a hope.
225
+
226
+ Measured on `Michelle.glb`, 2 486 vertices sampled, in a mesh-local space where the body is 2.24
227
+ across:
228
+
229
+ | | folded scale | mean error | worst | worst on a single-joint vertex |
230
+ | --- | --- | --- | --- | --- |
231
+ | bind, mesh node as the file gave it | 100.0000 | 121.2470 | 165.4281 | 165.4281 |
232
+ | posed at 0.5 s, the same | 100.0000 | 121.6197 | 165.5332 | — |
233
+ | bind, on the skin frame | 1.0000 | 0.0002 | 0.0016 | 0.0014 |
234
+ | posed at 0.5 s, on the skin frame | 1.0000 | 0.0001 | 0.0062 | 0.000014 |
235
+ | bind, skin frame **and §9** | 1.0000 | 0.0000 | 0.0000 | **0** |
236
+ | posed at 0.5 s, skin frame and §9 | 1.0000 | 0.0001 | 0.0062 | **0.000001** |
237
+
238
+ The one residual left is the blended vertices — 0.0062 at 0.76 / 0.23 across a bent joint — which is
239
+ dual-quaternion skinning declining to be a linear blend, and is what it is for. The 0.0014 at bind
240
+ was §9, and is gone.
241
+
242
+ ## 7. What landed
243
+
244
+ 1. `m4_rigidity_defect` — tier 1.
245
+ 2. `skin_joint_matrix`, `skin_bind_transform`, `skin_frame_defect` — the three questions, one file
246
+ each; `skin_test_fixtures` builds both asset shapes as scene graphs, off the hierarchy rather
247
+ than by hand.
248
+ 3. `skin_normalize_mesh_frame` — the correction, with the report `register_skin` logs from.
249
+ 4. `GPUAnimationManager#register_skin` calls it ahead of the bind loop, and names an asset it cannot
250
+ put right.
251
+ 5. Doc corrections: `chunk_skin_blend_mesh_local`'s stated precondition, `SkinnedMesh`'s class doc
252
+ (`transform_global` on a skinned mesh is now the engine's, and says so), and the fixture header in
253
+ `SkinnedMesh.spec.js` that had drawn the wrong conclusion from the right observation.
254
+
255
+ Then §9, and §4's detector — `skin_clip_scale_conflicts`, with the manager keeping an index of
256
+ skins by id and of the clips that scale anything, so the pairing runs whichever order they arrive in.
257
+
258
+ Not carried: §4's rows 2 and 3 themselves. The detector is what would say they are needed.
259
+
260
+ ## 8. Risks
261
+
262
+ - **Something reads a skinned mesh's `transform_global` expecting the file's value.**
263
+ `compute_skin_world_bounds` does not, and glTF says the value is meaningless for a skinned mesh.
264
+ After this it *means* something: the frame the mesh's vertices are expressed in. Any consumer that
265
+ wanted the file's node transform wanted the wrong thing.
266
+ - **An asset whose rest pose is not its bind pose** has no single `A_bind`. The deviation number says
267
+ so by name rather than the engine guessing. Not seen in practice; the check is what would find it.
268
+ - **The eighteen creature models.** Whether they satisfy the invariant today cannot be measured from
269
+ this repository — they are the game's. The check answers it on their first frame, which is itself
270
+ the argument for landing the check before the correction.
271
+
272
+ ## 9. Landed: `dual_quat_from_m4` was ill-conditioned near the identity
273
+
274
+ Not caused by phase 1, and exposed by it. The conversion took all four quaternion components as
275
+
276
+ ```wgsl
277
+ sqrt(max(0, 1 ± m00 ± m11 ± m22)) * 0.5
278
+ ```
279
+
280
+ and near the identity three of those radicands are near zero. A square root turns an error of `e`
281
+ into one of `sqrt(e)`, and it lands on the rotation *axis* rather than its angle, so it does not
282
+ average out.
283
+
284
+ Measured on Michelle after the frame correction: every folded matrix is the identity to `1.131e-6`
285
+ (worst joint, `mixamorig:LeftUpLeg`), predicting `sqrt(3 × 1.131e-6) / 2 = 9.21e-4` per component, an
286
+ angular error of `1.84e-3` rad, and `1.418e-3` at the worst vertex's radius. Observed: `1.402e-3` —
287
+ a millimetre and a half on a 1.66 m character. It showed **only at bind**, because that is the one
288
+ pose where the fold lands every joint on the identity; posed at 0.5 s the same measurement gave
289
+ `1.4e-5`.
290
+
291
+ **The fix was already in the tree.** `chunk_quat_from_m3` is Shoemake's branch-picking form —
292
+ compute whichever component the diagonal says is largest, derive the other three by division — and
293
+ it was written, correct, and used by nothing but a doc reference. `dual_quat_from_m4` now delegates
294
+ its real part to it. One `sqrt` and a reciprocal instead of four `sqrt`s and a normalize, every
295
+ branch dividing by at least 2, and the error passes through in proportion rather than as its square
296
+ root.
297
+
298
+ On Michelle: `1.402e-3` → `0` at bind, `1.4e-5` → `1e-6` posed.
299
+
300
+ Three tiers, all device-free. `chunk_quat_from_m3.spec.js` is new, because delegating put a chunk
301
+ with no spec of its own on the path of every skinned vertex: a sweep of rotations about four axes
302
+ against what the matrix does to three off-axis probes, all four branches reached and each exact
303
+ where it takes over, the near-identity scaling law over three decades, and a hair either side of a
304
+ half turn. `chunk_dual_quat_from_m4.spec.js` gained the composed form of the same question and a
305
+ case for a translation carried by a rotation that is barely there. `__soup_blend_probe.spec.js`'s
306
+ ported chunk follows the real one, and its single-joint tolerance went from `5e-3` to `1e-5`.
307
+
308
+ Held red by hand, with the four-`sqrt` form swapped back in: four tests fail. The near-identity one
309
+ reports `0.000863` against the `8.66e-4` predicted above — three digits — and, as a bonus nobody was
310
+ looking for, a genuine rotation of `1e-5` came back as **exactly zero**: f32 rounds the diagonal to 1,
311
+ every radicand to 0 or 4, and the rotation is not amplified but destroyed.
312
+
313
+ One consequence worth naming: the old form recovered `Rx(90°)` exactly from `100·Rx(90°)`, because
314
+ its `w` and `x` radicands were both `1 + 100` and its `y` and `z` both clamped — the coincidence §1
315
+ describes, where only the scale was lost and the character came out the right shape at 1/100 size.
316
+ The branch form has no such luck, so a frame violation now looks wrong rather than small. That is the
317
+ better failure, and the negative controls in `chunk_skin_blend_mesh_local.spec.js` still hold.
318
+
319
+ ### Still open
320
+
321
+ `quat_from_m3` reads a matrix's upper 3×3 as a rotation, which is the same contract
322
+ `dual_quat_from_m4` has and the same one `m4_rigidity_defect` exists to check. Nothing calls it with
323
+ a scale on purpose now, and nothing warns if something starts to.
324
+
325
+ ## 10. Adversarial review
326
+
327
+ Of §§1–9's own work, after it was green. Findings in the order they matter.
328
+
329
+ | | severity | finding | resolution |
330
+ | --- | --- | --- | --- |
331
+ | A1 | **must-fix** | `transform_global` is derived and a fresh `Transform64` is the **identity**, not zeros — so a skin whose scene has never been walked answers "every joint is at the origin", and that answer passes every check in §3. The folds come out `inverse_bind_0⁻¹ × inverse_bind_j`, which on a real character differ by rigid motions and read as perfectly rigid; a correction would be computed from nothing, applied, and then confirmed by a residual whose two halves both come from the same unwalked state. The exact class of silent wrongness this whole change exists to end, reintroduced inside its own guard. | **Fixed.** `Node3D#version` is bumped by `updateMatrices` and by nothing else, so a node at version 0 has never been derived. `skin_normalize_mesh_frame` refuses before measuring, reports the nodes, and `register_skin` names one. Pinned twice: the refusal, and the premise — that an all-identity skeleton scores under `1e-6` on the defect. |
332
+ | A2 | **must-fix** | A reflection scores **zero** on `m4_rigidity_defect`, because it is orthonormal and that is what the defect measures. So a mirrored joint passed the frame check and `quat_from_m3` would answer with a rotation, silently. The one way a fold can be unrepresentable and still look perfect. | **Fixed.** `skin_frame_defect` checks the sign of the fold's linear determinant and answers `Infinity`. A reflection the frame and every joint share cancels and never reaches it, so what does reach it is a skeleton disagreeing with itself about handedness, which no frame reconciles. |
333
+ | A3 | should-fix | `skin_frame_defect_by_joint` — the diagnostic that says *which* joint — did not make A2's refusal, so the gate and the explanation of the gate would have disagreed about which joints were the problem. | **Fixed** alongside A2, and the test now asserts both halves: joint 0 clean, joints 1 and 2 `Infinity`. |
334
+ | A4 | should-fix | `__soup_probe.spec.js`'s bind-pose probe compared against `mesh.transform_global × rest` — a composition the renderer *cancels* — and printed "worst bind-pose deviation 1.672182" next to a `skinned` column that was exactly right. A trap rather than a measurement, and it asserted nothing. | **Fixed.** It compares against the skin's bind transform, prints `5.65e-7`, and now asserts: no zero-weight vertices, no out-of-range joints, worst under `1e-5`. |
335
+ | A5 | nit | `skin_normalize_mesh_frame` measured `frame_defect` before the unwalked check could reject the data it was measured from, leaving a meaningful-looking number in a report where it meant nothing. | **Fixed**: measured after the refusal, `NaN` before it, and said so in the return doc. |
336
+ | A6 | nit | `chunk_dual_quat_from_m4`'s doc claimed "one `sqrt` and a reciprocal, against four `sqrt`s and a normalize" while keeping the normalize. | **Fixed**, and the normalize's actual job — a degenerate 3×3, where `quat_from_m3` lands on a branch whose answer has length 0.5 — is stated instead of implied. |
337
+ | A7 | nit | `verify_clone_vertices` dereferenced an unregistered source geometry's record. | **Fixed**: reported as a verdict rather than a `TypeError`. |
338
+ | A8 | nit | The rotation fixture in `chunk_quat_from_m3.spec.js` recomputed one cross product three times, and — found the hard way — took its axis on trust. `[0.4243, -0.5657, 0.7071]` has norm `0.99982`, and that `1.8e-4` came back out as `1.15e-4` of "disagreement" that read as a defect in the conversion for a good ten minutes. | **Fixed**: normalized in the fixture, with the incident in its doc comment so the next person does not spend the ten minutes. |
339
+
340
+ ### Left, with reasons
341
+
342
+ - **`skin_bind_transform` takes joint 0's matrix.** In a file whose rest pose is its bind pose every
343
+ joint answers the same one. In a file where they differ there is no single answer, and it does not
344
+ matter: the frame cancels out of the rendered position entirely — `mesh.global × (mesh.global⁻¹ ×
345
+ A_j) = A_j` for any invertible frame — so the frame's only job is to make `M_j` *representable*, and
346
+ `skin_frame_defect` is what checks that. Joints disagreeing by rigid motions cost nothing.
347
+ - **A skeleton walked, then moved, then not walked again.** Not detectable by `version`, which counts
348
+ walks rather than staleness. The mesh half of it *is* caught: the correction is computed from the
349
+ stale global but applied to the local, and the residual is measured against the live one, so it
350
+ fires. The joint half is the caller's contract.
351
+ - **The `TOLERANCE` constant gates two different quantities** — a dimensionless rigidity measure and
352
+ a matrix distance mixing linear and translation units. Defensible at `1e-4` against a smallest real
353
+ violation of `99`, and splitting it would invite two numbers nobody can justify separately.
354
+ - **No offline tier in this engine type-checks WGSL.** The emulator builds the whole skinning shader
355
+ from its chunk graph, which catches an undefined function and a syntax error, and that is where the
356
+ coverage stops. §9's `mat3x3<f32>(m[0].xyz, …)` was settled by a page load rather than by a test,
357
+ and the next chunk edit will be too.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The transform that maps this skin's mesh-space geometry into the world at rest.
3
+ *
4
+ * `compute_skin_world_bounds` already names the thing: *"with every joint at its bind pose
5
+ * `joint.global × inverse_bind` collapses to one and the same matrix, the character's placement in
6
+ * the world"*. This returns that matrix. It is the frame a skinned mesh's vertices belong in, and
7
+ * under {@link skin_normalize_mesh_frame} it becomes the mesh node's transform — see
8
+ * `SKINNING_FRAME_PLAN.md`.
9
+ *
10
+ * **Joint 0's is the one taken.** In a file whose rest pose is its bind pose every joint answers the
11
+ * same matrix, so which one is asked is not a choice; in a file where they differ there is no single
12
+ * answer and no amount of averaging makes one. What matters is not that they agree but that
13
+ * `frame⁻¹ · joint_matrix` comes out rigid for every joint, which is a weaker condition and a
14
+ * different question — {@link skin_frame_defect} is that question, and it is the one to ask.
15
+ *
16
+ * Reads the joints' `transform_global`, so the skeleton has to have been walked
17
+ * ({@link Node3D#updateMatrices}) since it was last moved. Joints under
18
+ * {@link TransformAuthority.GPU} hold the rest pose there, which is what this wants.
19
+ *
20
+ * @param {Float64Array|Float32Array|number[]} out 16 elements, column-major
21
+ * @param {Skin} skin
22
+ * @returns {Float64Array|Float32Array|number[]} `out`
23
+ */
24
+ export function skin_bind_transform(out: Float64Array | Float32Array | number[], skin: Skin): Float64Array | Float32Array | number[];
25
+ //# sourceMappingURL=skin_bind_transform.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skin_bind_transform.d.ts","sourceRoot":"","sources":["../../../../../src/shade/renderer/animation/skin_bind_transform.js"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,yCAJW,YAAY,GAAC,YAAY,GAAC,MAAM,EAAE,eAEhC,YAAY,GAAC,YAAY,GAAC,MAAM,EAAE,CAQ9C"}
@@ -0,0 +1,33 @@
1
+ import { assert } from "../../../core/assert.js";
2
+ import { skin_joint_matrix } from "./skin_joint_matrix.js";
3
+
4
+ /**
5
+ * The transform that maps this skin's mesh-space geometry into the world at rest.
6
+ *
7
+ * `compute_skin_world_bounds` already names the thing: *"with every joint at its bind pose
8
+ * `joint.global × inverse_bind` collapses to one and the same matrix, the character's placement in
9
+ * the world"*. This returns that matrix. It is the frame a skinned mesh's vertices belong in, and
10
+ * under {@link skin_normalize_mesh_frame} it becomes the mesh node's transform — see
11
+ * `SKINNING_FRAME_PLAN.md`.
12
+ *
13
+ * **Joint 0's is the one taken.** In a file whose rest pose is its bind pose every joint answers the
14
+ * same matrix, so which one is asked is not a choice; in a file where they differ there is no single
15
+ * answer and no amount of averaging makes one. What matters is not that they agree but that
16
+ * `frame⁻¹ · joint_matrix` comes out rigid for every joint, which is a weaker condition and a
17
+ * different question — {@link skin_frame_defect} is that question, and it is the one to ask.
18
+ *
19
+ * Reads the joints' `transform_global`, so the skeleton has to have been walked
20
+ * ({@link Node3D#updateMatrices}) since it was last moved. Joints under
21
+ * {@link TransformAuthority.GPU} hold the rest pose there, which is what this wants.
22
+ *
23
+ * @param {Float64Array|Float32Array|number[]} out 16 elements, column-major
24
+ * @param {Skin} skin
25
+ * @returns {Float64Array|Float32Array|number[]} `out`
26
+ */
27
+ export function skin_bind_transform(out, skin) {
28
+ assert.defined(skin, 'skin');
29
+ assert.equal(skin.isSkin, true, 'skin.isSkin !== true');
30
+ assert.greaterThanOrEqual(skin.joints.length, 1, 'skin.joints.length');
31
+
32
+ return skin_joint_matrix(out, skin, 0);
33
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The channels of a clip that would scale this skin's joints out from under the frame it is
3
+ * deformed in.
4
+ *
5
+ * ## Why this is worth asking
6
+ *
7
+ * {@link skin_normalize_mesh_frame} makes `inverse(mesh.global) × joint.global × inverse_bind`
8
+ * rigid, and rigid is what a dual quaternion can carry. It does that with a **constant** correction,
9
+ * which is sound because the frame and the joints share their ancestors and a placement multiplies
10
+ * both on the left. A clip that scales a joint breaks that: the scale enters `joint.global` and not
11
+ * the frame, the fold stops being rigid, and no choice of frame fixes it —
12
+ * `SKINNING_FRAME_PLAN.md` §4 is the ladder out and neither of its rungs is built.
13
+ *
14
+ * The failure is per-frame and on the GPU, where nothing can see it. This is the same question asked
15
+ * **statically**, off the clip's own curves, at registration: exact, cheap, and it needs no device.
16
+ *
17
+ * ## What counts
18
+ *
19
+ * Not "the channel targets a joint" — that understates it. What matters is whether the scale is
20
+ * *shared* between the frame and the joints, and a scale is shared exactly when the node carrying it
21
+ * is an ancestor of the mesh **and** of every joint. So:
22
+ *
23
+ * - a scale on the character's own root reaches the mesh and all the joints, cancels out of the
24
+ * fold, and is fine — this is how a model is made to grow;
25
+ * - a scale on a joint, or on any node above only *some* of them, reaches the joints and not the
26
+ * mesh, and is a conflict;
27
+ * - a scale on a node that reaches neither is somebody else's business.
28
+ *
29
+ * A channel whose curves are all the constant 1 is not a scale at all, whatever it targets. The
30
+ * range is taken with {@link animation_curve_compute_aabb}, which follows the Hermite spline between
31
+ * keys rather than only reading the keys — a curve that leaves 1 and returns to it is a scale.
32
+ *
33
+ * @param {Skin} skin
34
+ * @param {ShadeAnimationClip} clip
35
+ * @returns {Array<{
36
+ * channel: ShadeAnimationChannel,
37
+ * node: Node3D,
38
+ * uniform: boolean,
39
+ * joints_reached: number,
40
+ * reaches_mesh: boolean,
41
+ * }>} one entry per conflicting channel, empty when the clip and the skin can coexist. `uniform`
42
+ * says which rung of §4's ladder would be needed: a uniform scale is one a dual quaternion could
43
+ * be taught to carry alongside, a non-uniform one is not. It is `true` only when all three
44
+ * components are animated by equal curves, so a channel that is uniform by coincidence of the
45
+ * node's own scale reads as non-uniform — the safe direction for a report
46
+ */
47
+ export function skin_clip_scale_conflicts(skin: Skin, clip: ShadeAnimationClip): Array<{
48
+ channel: ShadeAnimationChannel;
49
+ node: Node3D;
50
+ uniform: boolean;
51
+ joints_reached: number;
52
+ reaches_mesh: boolean;
53
+ }>;
54
+ /**
55
+ * The channels of a clip that scale something, anywhere — before any question of *whose* joints.
56
+ *
57
+ * Separated out because it is the cheap half and almost always the whole answer. A character's clips
58
+ * rotate and translate; a clip with no scale channel at all can be dismissed in one pass over its
59
+ * channel list, which is what keeps the per-`(skin, clip)` pair check from costing anything at a
60
+ * few hundred dancers. {@link GPUAnimationManager} asks this first and only remembers a clip that
61
+ * says yes.
62
+ *
63
+ * @param {ShadeAnimationClip} clip
64
+ * @returns {ShadeAnimationChannel[]}
65
+ */
66
+ export function clip_scale_channels(clip: ShadeAnimationClip): ShadeAnimationChannel[];
67
+ //# sourceMappingURL=skin_clip_scale_conflicts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skin_clip_scale_conflicts.d.ts","sourceRoot":"","sources":["../../../../../src/shade/renderer/animation/skin_clip_scale_conflicts.js"],"names":[],"mappings":"AAyBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,iFAZa,MAAM;IAClB,OAAa,wBAAwB;IACrC,IAAU,SAAS;IACnB,OAAa,EAAE,OAAO,CAAC;IACvB,cAAoB,EAAE,MAAM,CAAC;IAC7B,YAAkB,EAAE,OAAO,CAAC;CACzB,CAAC,CAqDJ;AAED;;;;;;;;;;;GAWG;AACH,+DAFa,uBAAuB,CAuBnC"}