@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,216 @@
1
+ import { assert } from "../../../core/assert.js";
2
+ import { m4_invert } from "../../../core/geom/3d/mat4/m4_invert.js";
3
+ import { m4_multiply } from "../../../core/geom/3d/mat4/m4_multiply.js";
4
+ import { m4_rigidity_defect } from "../../../core/geom/3d/mat4/m4_rigidity_defect.js";
5
+ import { Transform64 } from "../../../engine/ecs/transform/Transform64.js";
6
+ import { skin_bind_transform } from "./skin_bind_transform.js";
7
+ import { skin_frame_defect } from "./skin_frame_defect.js";
8
+
9
+ /**
10
+ * How far from the bind transform a mesh node may sit and still be left alone, and how large a
11
+ * residual counts as landed.
12
+ *
13
+ * A `Transform64` recomposes its local matrix from f32-ish components, and the correction is applied
14
+ * through that round trip, so the frame arrives with rounding on it. `1e-4` is far below anything
15
+ * that shows — the smallest real violation seen is `99` — and far above the noise.
16
+ *
17
+ * @type {number}
18
+ */
19
+ const TOLERANCE = 1e-4;
20
+
21
+ /**
22
+ * Put a skin's meshes into the frame the skin actually deforms in.
23
+ *
24
+ * ## What it does and why
25
+ *
26
+ * The skinning pass writes each clone's vertices in `inverse(mesh.global)` space and the rasterizer
27
+ * re-applies `mesh.global`, so `mesh.global` is the *frame* a skinned mesh's vertices live in. The
28
+ * pass gets there by converting `inverse(mesh.global) × joint.global × inverse_bind` to a dual
29
+ * quaternion, which carries a rotation and a translation and **nothing else** — so that product has
30
+ * to be rigid, and whether it is depends entirely on `mesh.global`.
31
+ *
32
+ * glTF says a skinned mesh's node transform is ignored, so it is not the asset's to choose. This
33
+ * makes it the engine's, and sets it to the one frame that is guaranteed to work: the skin's own
34
+ * bind transform, `joint.global × inverse_bind` at rest — see {@link skin_bind_transform}. After
35
+ * this, `inverse(mesh.global) × joint.global × inverse_bind` is the joint's **displacement from
36
+ * bind**, which is rigid whenever the clip rotates and translates and does not scale. That is the
37
+ * honest precondition of dual-quaternion skinning, and it is a property of the animation rather than
38
+ * of the exporter's unit conventions.
39
+ *
40
+ * `SKINNING_FRAME_PLAN.md` is the whole argument, including what a violation looks like on screen.
41
+ *
42
+ * ## Properties worth knowing
43
+ *
44
+ * - **Idempotent.** Afterwards `mesh.global` *is* the bind transform, so a second call computes the
45
+ * identity and changes nothing. Re-registering a skin is free.
46
+ * - **Placement-invariant.** The correction is `mesh.global⁻¹ × bind`, and anything applied above
47
+ * both the mesh node and the joints — a placement, a walk cycle moving the character — multiplies
48
+ * both on the left and cancels. Computed once, correct forever.
49
+ * - **A no-op for an asset that already agrees.** A file whose inverse-bind matrices are authored
50
+ * before its root's unit conversion has `bind == mesh.global` already and is not touched. That is
51
+ * the shape this engine's creature models are said to have, and it is what keeps this from being
52
+ * a change to them.
53
+ *
54
+ * ## What it cannot fix
55
+ *
56
+ * A skin whose *own* bind frame does not work — a rest pose carrying a non-uniform scale or a shear,
57
+ * or joints that disagree about more than a rigid motion. No choice of frame fixes those; a dual
58
+ * quaternion cannot represent them at all. `frame_defect` in the report is that case, and when it
59
+ * fires nothing is written: the mesh is left as the asset had it rather than moved somewhere equally
60
+ * wrong. See `SKINNING_FRAME_PLAN.md` §4 for the ladder out.
61
+ *
62
+ * @param {Skin} skin its joints and meshes must have been walked — this reads `transform_global`,
63
+ * which is derived. A skin that has never been walked at all is refused rather than
64
+ * measured; one walked and then moved is the caller's to walk again, and the residual
65
+ * below is what notices if the mesh half of that was missed
66
+ * @returns {{
67
+ * frame_defect: number,
68
+ * absorbed: number,
69
+ * corrected: number,
70
+ * unchanged: number,
71
+ * residual: number,
72
+ * obstructed: Node3D[],
73
+ * unwalked: Node3D[],
74
+ * }} `frame_defect` — how far the skin is from being deformable in *any* frame, zero when it is
75
+ * fine and `NaN` when `unwalked` stopped it being asked; `absorbed` — how far from rigid the largest correction was, which for a violation is the
76
+ * number the soup was made of; `corrected` and `unchanged` — meshes moved and meshes already
77
+ * right; `residual` — the worst `|mesh.global − bind|` left afterwards; `obstructed` — corrected
78
+ * mesh nodes that had children, which have been scaled along with them; `unwalked` — joints or
79
+ * meshes whose `transform_global` has never been derived, which stops everything else
80
+ */
81
+ export function skin_normalize_mesh_frame(skin) {
82
+ assert.defined(skin, 'skin');
83
+ assert.equal(skin.isSkin, true, 'skin.isSkin !== true');
84
+
85
+ const unwalked = skin.joints.filter(joint => joint.version === 0)
86
+ .concat(skin.meshes.filter(mesh => mesh.version === 0));
87
+
88
+ const report = {
89
+ // not measured until there is something worth measuring — see the refusal below
90
+ frame_defect: Number.NaN,
91
+ absorbed: 0,
92
+ corrected: 0,
93
+ unchanged: 0,
94
+ residual: 0,
95
+ obstructed: [],
96
+ unwalked,
97
+ };
98
+
99
+ if (unwalked.length > 0) {
100
+ /*
101
+ Everything below reads `transform_global`, which is derived and starts at the **identity** —
102
+ not at zero. A skeleton nobody has walked therefore answers "every joint is at the origin"
103
+ rather than answering with a singular matrix, and that answer is well-formed enough to pass
104
+ every check here: the folds come out `inverse_bind_0⁻¹ × inverse_bind_j`, which for a real
105
+ character differ by rigid motions and so read as perfectly rigid. A correction would then be
106
+ computed from nothing and applied, and the residual below would agree with itself, because
107
+ both halves of it come from the same unwalked state.
108
+
109
+ `version` is the one thing that tells the two apart: `updateMatrices` bumps it, and nothing
110
+ else does. Refusing here rather than asserting because a caller who has not walked has a
111
+ scene that is not ready, not a bug in this.
112
+ */
113
+ return report;
114
+ }
115
+
116
+ const bind = new Float64Array(16);
117
+
118
+ skin_bind_transform(bind, skin);
119
+
120
+ report.frame_defect = skin_frame_defect(skin, bind);
121
+
122
+ if (!(report.frame_defect <= TOLERANCE)) {
123
+ // `!(x <= t)` rather than `x > t` so that a NaN — a singular joint matrix — lands here too
124
+ return report;
125
+ }
126
+
127
+ const inverse_global = new Float64Array(16);
128
+ const correction_matrix = new Float64Array(16);
129
+
130
+ const correction = new Transform64();
131
+
132
+ const meshes = skin.meshes;
133
+
134
+ for (let i = 0; i < meshes.length; i++) {
135
+ const mesh = meshes[i];
136
+
137
+ if (!m4_invert(inverse_global, mesh.transform_global)) {
138
+ // a mesh node with no inverse is not a frame either; the skinning pass inverts this
139
+ report.residual = Infinity;
140
+ continue;
141
+ }
142
+
143
+ m4_multiply(correction_matrix, inverse_global, bind);
144
+
145
+ const absorbed = m4_rigidity_defect(correction_matrix);
146
+
147
+ report.absorbed = Math.max(report.absorbed, absorbed);
148
+
149
+ if (matrix_distance(correction_matrix, IDENTITY) <= TOLERANCE) {
150
+ report.unchanged++;
151
+ continue;
152
+ }
153
+
154
+ /*
155
+ The correction goes on the **local** transform, on the right. `transform_global` is derived —
156
+ `updateMatrices` recomputes it from the local and the parent chain — so a frame written
157
+ there is discarded by the next walk; and the mesh node is the last node on the mesh-only
158
+ path, so this is the one place a correction reaches the mesh without reaching the joints.
159
+ */
160
+ correction.fromMatrix(correction_matrix);
161
+
162
+ mesh.transform_local.multiply(correction);
163
+
164
+ mesh.updateMatrices();
165
+
166
+ report.corrected++;
167
+ report.residual = Math.max(report.residual, matrix_distance(mesh.transform_global, bind));
168
+
169
+ if (mesh.children.length > 0) {
170
+ /*
171
+ Children of a skinned mesh node inherit the correction, and it is not a transform they
172
+ asked for. In this engine sockets, effect anchors and projectile spawns are authored as
173
+ bones, so a skinned mesh node should have none — but that is a convention, and this is
174
+ where it would be broken silently.
175
+ */
176
+ report.obstructed.push(mesh);
177
+ }
178
+ }
179
+
180
+ return report;
181
+ }
182
+
183
+ /**
184
+ * @type {Float64Array}
185
+ */
186
+ const IDENTITY = new Float64Array([
187
+ 1, 0, 0, 0,
188
+ 0, 1, 0, 0,
189
+ 0, 0, 1, 0,
190
+ 0, 0, 0, 1,
191
+ ]);
192
+
193
+ /**
194
+ * The largest element-wise difference between two matrices.
195
+ *
196
+ * Element-wise rather than a norm because it is read against a tolerance, and because the two things
197
+ * it has to catch — a linear part that did not land and a translation that did not — are both
198
+ * element-wise. Mixing units is the point: any of them being wrong is the same answer.
199
+ *
200
+ * @param {ArrayLike<number>} a
201
+ * @param {ArrayLike<number>} b
202
+ * @returns {number}
203
+ */
204
+ function matrix_distance(a, b) {
205
+ let worst = 0;
206
+
207
+ for (let i = 0; i < 16; i++) {
208
+ const difference = Math.abs(a[i] - b[i]);
209
+
210
+ if (difference > worst) {
211
+ worst = difference;
212
+ }
213
+ }
214
+
215
+ return worst;
216
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * A rigged character in one of the two shapes.
3
+ *
4
+ * The rest pose **is** the bind pose, which is what a well-formed file has. The bind matrices are
5
+ * taken off the skeleton itself rather than composed by hand: the fixture walks the hierarchy once
6
+ * with the root's conversion in place and once without, and the two snapshots *are* the two bind
7
+ * spaces. Nothing here can drift from the hierarchy it describes, because it is read from it.
8
+ *
9
+ * The placement node goes on last, after the bind matrices are settled — which is what happens for
10
+ * real, and is what makes "the correction does not depend on where the character stands" a fact this
11
+ * fixture can be asked about rather than an assumption.
12
+ *
13
+ * @param {object} options
14
+ * @param {BindSpace} options.bind_space
15
+ * @param {number} [options.scale] the root's uniform conversion
16
+ * @param {boolean} [options.up_axis_rotation] whether the root also carries the −90° about x that an
17
+ * FBX-derived file pairs with its conversion. On by default: a correction that handles only
18
+ * the scale passes without it.
19
+ * @param {number[]} [options.placement] a translation applied above the whole character
20
+ * @returns {{
21
+ * placement: Node3D,
22
+ * root: Node3D,
23
+ * mesh: SkinnedMesh,
24
+ * skin: Skin,
25
+ * joints: Node3D[],
26
+ * }}
27
+ */
28
+ export function make_character({ bind_space, scale, up_axis_rotation, placement, }: {
29
+ bind_space: BindSpace;
30
+ scale?: number;
31
+ up_axis_rotation?: boolean;
32
+ placement?: number[];
33
+ }): {
34
+ placement: Node3D;
35
+ root: Node3D;
36
+ mesh: SkinnedMesh;
37
+ skin: Skin;
38
+ joints: Node3D[];
39
+ };
40
+ /**
41
+ * The two shapes a glTF character actually arrives in, built as scene graphs.
42
+ *
43
+ * Both are legal glTF and both are in the wild; the difference is only the space the file's author
44
+ * wrote the inverse-bind matrices in, and it decides whether `inverse(mesh.global) × joint.global ×
45
+ * inverse_bind` — the matrix the skinning pass converts to a dual quaternion — comes out rigid. See
46
+ * `SKINNING_FRAME_PLAN.md`.
47
+ *
48
+ * One hierarchy serves both, because the difference is not in the hierarchy:
49
+ *
50
+ * ```
51
+ * "placement" added at runtime, above the file's own roots
52
+ * └── "Character" the file's unit conversion and up-axis rotation
53
+ * ├── "Mesh" the skinned mesh, no local transform of its own
54
+ * └── "Joint0" the joint root — the mesh node's *sibling*, as every rigged glTF has it
55
+ * └── "Joint1"
56
+ * └── "Joint2"
57
+ * ```
58
+ *
59
+ * @author Alex Goldring
60
+ * @copyright Company Named Limited (c) 2026
61
+ */
62
+ /**
63
+ * What every one of this game's creature models puts on its glTF root node — an FBX unit
64
+ * conversion, uniform, and inherited by the joints and the mesh node alike.
65
+ *
66
+ * @type {number}
67
+ */
68
+ export const EXPORT_SCALE: number;
69
+ /**
70
+ * The centimetre→metre conversion `Michelle.glb` carries on hers.
71
+ *
72
+ * @type {number}
73
+ */
74
+ export const MICHELLE_SCALE: number;
75
+ /**
76
+ * Where the file's author wrote the inverse-bind matrices.
77
+ */
78
+ export type BindSpace = string;
79
+ export namespace BindSpace {
80
+ let PreScale: string;
81
+ let World: string;
82
+ }
83
+ import { Node3D } from "../scene/Node3D.js";
84
+ import { SkinnedMesh } from "../scene/SkinnedMesh.js";
85
+ import { Skin } from "./Skin.js";
86
+ //# sourceMappingURL=skin_test_fixtures.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skin_test_fixtures.d.ts","sourceRoot":"","sources":["../../../../../src/shade/renderer/animation/skin_test_fixtures.js"],"names":[],"mappings":"AAsFA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH;IAd8B,UAAU,EAA7B,SAAS;IACQ,KAAK,GAAtB,MAAM;IACY,gBAAgB,GAAlC,OAAO;IAGY,SAAS,GAA5B,MAAM,EAAE;;eAED,MAAM;UACX,MAAM;UACN,WAAW;UACX,IAAI;YACF,MAAM,EAAE;EAgFtB;AAjLD;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH;;;;;GAKG;AACH,2BAFU,MAAM,CAEiB;AAEjC;;;;GAIG;AACH,6BAFU,MAAM,CAEmB;;;;wBAKzB,MAAM;;;;;uBA7CO,oBAAoB;4BACf,yBAAyB;qBAChC,WAAW"}
@@ -0,0 +1,250 @@
1
+ import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js";
2
+ import { platform_compute_endianness } from "../../../core/binary/platform_compute_endianness.js";
3
+ import { m4_invert } from "../../../core/geom/3d/mat4/m4_invert.js";
4
+ import { MeshletGeometry } from "../geometry/MeshletGeometry.js";
5
+ import {
6
+ MESHLET_METADATA_STRUCT
7
+ } from "../geometry/meshlet/encoding/MESHLET_METADATA_STRUCT.js";
8
+ import { StandardShadeMaterial } from "../material/StandardShadeMaterial.js";
9
+ import { write_wgsl_type_value } from "../shader/type/serde/write_gpu_typed_buffer.js";
10
+ import { Node3D } from "../scene/Node3D.js";
11
+ import { SkinnedMesh } from "../scene/SkinnedMesh.js";
12
+ import { Skin } from "./Skin.js";
13
+
14
+ /**
15
+ * The two shapes a glTF character actually arrives in, built as scene graphs.
16
+ *
17
+ * Both are legal glTF and both are in the wild; the difference is only the space the file's author
18
+ * wrote the inverse-bind matrices in, and it decides whether `inverse(mesh.global) × joint.global ×
19
+ * inverse_bind` — the matrix the skinning pass converts to a dual quaternion — comes out rigid. See
20
+ * `SKINNING_FRAME_PLAN.md`.
21
+ *
22
+ * One hierarchy serves both, because the difference is not in the hierarchy:
23
+ *
24
+ * ```
25
+ * "placement" added at runtime, above the file's own roots
26
+ * └── "Character" the file's unit conversion and up-axis rotation
27
+ * ├── "Mesh" the skinned mesh, no local transform of its own
28
+ * └── "Joint0" the joint root — the mesh node's *sibling*, as every rigged glTF has it
29
+ * └── "Joint1"
30
+ * └── "Joint2"
31
+ * ```
32
+ *
33
+ * @author Alex Goldring
34
+ * @copyright Company Named Limited (c) 2026
35
+ */
36
+
37
+ /**
38
+ * What every one of this game's creature models puts on its glTF root node — an FBX unit
39
+ * conversion, uniform, and inherited by the joints and the mesh node alike.
40
+ *
41
+ * @type {number}
42
+ */
43
+ export const EXPORT_SCALE = 0.35;
44
+
45
+ /**
46
+ * The centimetre→metre conversion `Michelle.glb` carries on hers.
47
+ *
48
+ * @type {number}
49
+ */
50
+ export const MICHELLE_SCALE = 0.01;
51
+
52
+ /**
53
+ * Where the file's author wrote the inverse-bind matrices.
54
+ *
55
+ * @enum {string}
56
+ */
57
+ export const BindSpace = {
58
+ /**
59
+ * Before the root's conversion — the matrices invert the joints' transforms in the mesh's own
60
+ * pre-conversion space, so `joint.global × inverse_bind` carries the conversion and equals the
61
+ * mesh node's world transform. This is the arrangement `chunk_skin_blend_mesh_local` was written
62
+ * for, and the one where the fold cancels.
63
+ */
64
+ PreScale: 'pre-scale',
65
+
66
+ /**
67
+ * After it — the matrices invert the joints' world transforms as the file leaves them, so
68
+ * `joint.global × inverse_bind` is the **identity** and the geometry's positions are already in
69
+ * converted units. `Michelle.glb` is this shape, and the fold leaves a bare `1 / scale` behind.
70
+ */
71
+ World: 'world',
72
+ };
73
+
74
+ /**
75
+ * Rest offsets down the joint chain, in the space the geometry's positions live in — a hip, a knee
76
+ * and an ankle rather than three copies of the origin, so that a defect which only shows on a joint
77
+ * away from the origin cannot pass.
78
+ *
79
+ * @type {number[][]}
80
+ */
81
+ const REST_OFFSETS = [
82
+ [0, 1, 0],
83
+ [0.25, -0.5, 0.1],
84
+ [-0.1, -0.45, 0],
85
+ ];
86
+
87
+ /**
88
+ * A rigged character in one of the two shapes.
89
+ *
90
+ * The rest pose **is** the bind pose, which is what a well-formed file has. The bind matrices are
91
+ * taken off the skeleton itself rather than composed by hand: the fixture walks the hierarchy once
92
+ * with the root's conversion in place and once without, and the two snapshots *are* the two bind
93
+ * spaces. Nothing here can drift from the hierarchy it describes, because it is read from it.
94
+ *
95
+ * The placement node goes on last, after the bind matrices are settled — which is what happens for
96
+ * real, and is what makes "the correction does not depend on where the character stands" a fact this
97
+ * fixture can be asked about rather than an assumption.
98
+ *
99
+ * @param {object} options
100
+ * @param {BindSpace} options.bind_space
101
+ * @param {number} [options.scale] the root's uniform conversion
102
+ * @param {boolean} [options.up_axis_rotation] whether the root also carries the −90° about x that an
103
+ * FBX-derived file pairs with its conversion. On by default: a correction that handles only
104
+ * the scale passes without it.
105
+ * @param {number[]} [options.placement] a translation applied above the whole character
106
+ * @returns {{
107
+ * placement: Node3D,
108
+ * root: Node3D,
109
+ * mesh: SkinnedMesh,
110
+ * skin: Skin,
111
+ * joints: Node3D[],
112
+ * }}
113
+ */
114
+ export function make_character({
115
+ bind_space,
116
+ scale = EXPORT_SCALE,
117
+ up_axis_rotation = true,
118
+ placement = [0, 0, 0],
119
+ }) {
120
+
121
+ const root = new Node3D();
122
+
123
+ root.name = 'Character';
124
+
125
+ const mesh = new SkinnedMesh();
126
+
127
+ mesh.name = 'Mesh';
128
+ mesh.geometry = make_geometry();
129
+ mesh.material = new StandardShadeMaterial();
130
+
131
+ const joints = REST_OFFSETS.map((offset, i) => {
132
+ const joint = new Node3D();
133
+
134
+ joint.name = `Joint${i}`;
135
+ joint.transform_local.setTranslation(offset[0], offset[1], offset[2]);
136
+
137
+ return joint;
138
+ });
139
+
140
+ for (let i = 1; i < joints.length; i++) {
141
+ joints[i - 1].addChild(joints[i]);
142
+ }
143
+
144
+ root.addChildren([mesh, joints[0]]);
145
+
146
+ /*
147
+ Pass one: the root at the identity, so the joints stand in the pre-conversion space an exporter
148
+ writing `PreScale` bind matrices was working in. Pass two adds the conversion, and the joints
149
+ stand where the file leaves them — the space a `World` exporter inverts.
150
+ */
151
+ root.updateMatrices();
152
+
153
+ const bind = bind_space === BindSpace.PreScale ? snapshot_joint_globals(joints) : null;
154
+
155
+ root.transform_local.setScale(scale, scale, scale);
156
+
157
+ if (up_axis_rotation) {
158
+ // −90° about x, as a quaternion: the Z-up of the file into the renderer's Y-up
159
+ root.transform_local.setRotation(-Math.SQRT1_2, 0, 0, Math.SQRT1_2);
160
+ }
161
+
162
+ root.updateMatrices();
163
+
164
+ const inverse_bind_matrices = new Float32Array(joints.length * 16);
165
+
166
+ const source = bind ?? snapshot_joint_globals(joints);
167
+
168
+ const inverse = new Float64Array(16);
169
+
170
+ for (let i = 0; i < joints.length; i++) {
171
+ m4_invert(inverse, source[i]);
172
+
173
+ inverse_bind_matrices.set(inverse, i * 16);
174
+ }
175
+
176
+ const skin = Skin.from({
177
+ name: 'fixture',
178
+ joints,
179
+ inverse_bind_matrices,
180
+ meshes: [mesh],
181
+ });
182
+
183
+ const placement_node = new Node3D();
184
+
185
+ placement_node.name = 'placement';
186
+ placement_node.transform_local.setTranslation(placement[0], placement[1], placement[2]);
187
+ placement_node.addChild(root);
188
+ placement_node.updateMatrices();
189
+
190
+ return { placement: placement_node, root, mesh, skin, joints };
191
+ }
192
+
193
+ /**
194
+ * Every joint's world matrix as it stands, copied out — a `Transform64` is live, and these are read
195
+ * after the hierarchy has been walked again.
196
+ *
197
+ * @param {Node3D[]} joints
198
+ * @returns {Float64Array[]}
199
+ */
200
+ function snapshot_joint_globals(joints) {
201
+ return joints.map(joint => {
202
+ const copy = new Float64Array(16);
203
+
204
+ // a Transform64's matrix starts at element 0
205
+ for (let i = 0; i < 16; i++) {
206
+ copy[i] = joint.transform_global[i];
207
+ }
208
+
209
+ return copy;
210
+ });
211
+ }
212
+
213
+ /**
214
+ * One triangle in one meshlet, with enough metadata behind it to be cloned.
215
+ *
216
+ * A bare {@link MeshletGeometry} is enough for the frame arithmetic, which only ever reads bounds —
217
+ * but `register_skin` reaches `GPUMeshSkinningContext#bind`, which allocates a per-instance vertex
218
+ * clone and refuses a batch of no meshlets. A fixture that stops short of that cannot be handed to
219
+ * the manager at all, and the manager is where half of what this fixture is for gets tested.
220
+ *
221
+ * @returns {MeshletGeometry}
222
+ */
223
+ function make_geometry() {
224
+ const box = [-1, 0, -1, 1, 2, 1];
225
+
226
+ const geometry = new MeshletGeometry();
227
+
228
+ geometry.name = 'fixture';
229
+ geometry.primitive_count = 1;
230
+ geometry.bounding_box.set(box);
231
+ geometry.bounding_sphere.set([0, 1, 0, 1.8]);
232
+
233
+ const writer = BinaryBuffer.fromEndianness(platform_compute_endianness());
234
+
235
+ writer.setCapacity(MESHLET_METADATA_STRUCT.size);
236
+
237
+ write_wgsl_type_value({
238
+ bounds_box: box,
239
+ address: 0,
240
+ primitive_count: 1,
241
+ vertex_count: 3,
242
+ flags: 0,
243
+ }, writer, MESHLET_METADATA_STRUCT);
244
+
245
+ geometry.meshlets.count = 1;
246
+ geometry.meshlets.data_buffer = new ArrayBuffer(3 * 3 * 4);
247
+ geometry.meshlets.metadata_buffer = writer.data.slice(0, MESHLET_METADATA_STRUCT.size);
248
+
249
+ return geometry;
250
+ }
@@ -17,10 +17,24 @@
17
17
  * and authors the inverse bind matrices in the pre-scale mesh space, so the skin matrix carries the
18
18
  * scale by construction.
19
19
  *
20
- * Preconditions: `m0`–`m3` are `joint.global × joint.inverse_bind` as
21
- * {@link shader_skin_matrix_prep} writes them, `inv_mesh_world` is the inverse of the skinned
22
- * mesh's own world matrix, and `weights` sums to 1. The four products must be rigid, which holds
23
- * whenever the joints and the mesh node share the scaling ancestors the shape a glTF skin has.
20
+ * ## Preconditions
21
+ *
22
+ * `m0`–`m3` are `joint.global × joint.inverse_bind` as {@link shader_skin_matrix_prep} writes them,
23
+ * `inv_mesh_world` is the inverse of the frame the rasterizer will re-apply, and `weights` sums to
24
+ * 1. **The four products must be rigid.**
25
+ *
26
+ * That is a condition on the *frame*, and the frame is chosen — it is not a property of the asset to
27
+ * be hoped for. It used to be stated here as "the joints and the mesh node share the scaling
28
+ * ancestors", which is neither necessary nor sufficient: `Michelle.glb`'s joints and mesh node share
29
+ * theirs and her products came out at a hundredfold scale, because her inverse-bind matrices are
30
+ * authored *after* the conversion rather than before it and the fold therefore had nothing to
31
+ * cancel against. Both shapes are legal glTF and both are in the wild.
32
+ *
33
+ * The condition that does hold is `mesh.global == the skin's bind transform`, and
34
+ * {@link skin_normalize_mesh_frame} makes it true at `register_skin` for every asset rather than
35
+ * leaving it to the exporter. The product is then the joint's displacement from bind, which is rigid
36
+ * whenever the clip rotates and translates and does not scale. `SKINNING_FRAME_PLAN.md` is the
37
+ * argument, and what a violation looks like on screen.
24
38
  */
25
39
  export const chunk_skin_blend_mesh_local: CodeChunk;
26
40
  import { CodeChunk } from "../../shader/compiler/CodeChunk.js";
@@ -1 +1 @@
1
- {"version":3,"file":"chunk_skin_blend_mesh_local.d.ts","sourceRoot":"","sources":["../../../../../../src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.js"],"names":[],"mappings":"AAQA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,oDAuBE;0BAvDwB,oCAAoC"}
1
+ {"version":3,"file":"chunk_skin_blend_mesh_local.d.ts","sourceRoot":"","sources":["../../../../../../src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.js"],"names":[],"mappings":"AAQA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,oDAuBE;0BArEwB,oCAAoC"}
@@ -25,10 +25,24 @@ import {
25
25
  * and authors the inverse bind matrices in the pre-scale mesh space, so the skin matrix carries the
26
26
  * scale by construction.
27
27
  *
28
- * Preconditions: `m0`–`m3` are `joint.global × joint.inverse_bind` as
29
- * {@link shader_skin_matrix_prep} writes them, `inv_mesh_world` is the inverse of the skinned
30
- * mesh's own world matrix, and `weights` sums to 1. The four products must be rigid, which holds
31
- * whenever the joints and the mesh node share the scaling ancestors the shape a glTF skin has.
28
+ * ## Preconditions
29
+ *
30
+ * `m0`–`m3` are `joint.global × joint.inverse_bind` as {@link shader_skin_matrix_prep} writes them,
31
+ * `inv_mesh_world` is the inverse of the frame the rasterizer will re-apply, and `weights` sums to
32
+ * 1. **The four products must be rigid.**
33
+ *
34
+ * That is a condition on the *frame*, and the frame is chosen — it is not a property of the asset to
35
+ * be hoped for. It used to be stated here as "the joints and the mesh node share the scaling
36
+ * ancestors", which is neither necessary nor sufficient: `Michelle.glb`'s joints and mesh node share
37
+ * theirs and her products came out at a hundredfold scale, because her inverse-bind matrices are
38
+ * authored *after* the conversion rather than before it and the fold therefore had nothing to
39
+ * cancel against. Both shapes are legal glTF and both are in the wild.
40
+ *
41
+ * The condition that does hold is `mesh.global == the skin's bind transform`, and
42
+ * {@link skin_normalize_mesh_frame} makes it true at `register_skin` for every asset rather than
43
+ * leaving it to the exporter. The product is then the joint's displacement from bind, which is rigid
44
+ * whenever the clip rotates and translates and does not scale. `SKINNING_FRAME_PLAN.md` is the
45
+ * argument, and what a violation looks like on screen.
32
46
  */
33
47
  export const chunk_skin_blend_mesh_local = CodeChunk.from(
34
48
  //language=WGSL
@@ -25,7 +25,7 @@ node graphs and lowered to that bytecode by a testable JS compiler.
25
25
  ## Layered architecture
26
26
 
27
27
  ```
28
- authoring: ParticleGraph (nodes+ports) ──compile──▶ Program (bytecode)
28
+ authoring: NodeGraph (nodes+ports) ──compile──▶ Program (bytecode)
29
29
  standard node library │
30
30
 
31
31
  runtime CPU: EmitterDefinition ── GPUDatabase(emitters) ─┤ ProgramArena (packed u32 buffer)
@@ -90,7 +90,7 @@ executors cover every opcode in the ISA table.
90
90
 
91
91
  ### Node graph → bytecode
92
92
 
93
- `ParticleGraph` holds typed nodes (ports + params) for SPAWN and UPDATE. `compile()`:
93
+ A core `NodeGraph` holds particle nodes (ports + parameters) for SPAWN and UPDATE. `compile()`:
94
94
  1. topologically orders reachable nodes,
95
95
  2. lowers each node to VM ops (nodes emit via a small builder that allocates result registers),
96
96
  3. linear-scan register allocation with liveness,
@@ -143,9 +143,9 @@ src/webgpu/renderer/particles/
143
143
  isa/ParticleAssembler.js tiny assembler (build programs in tests/library)
144
144
  vm/ParticleVMReference.js JS reference executor
145
145
  vm/chunk_particle_vm.js WGSL interpreter CodeChunk (parity target)
146
- graph/ParticleGraph.js node graph
147
- graph/ParticleNode.js node base + ports
148
- graph/nodes/* standard node library
146
+ graph/ParticleNodeDescription.js node type base (ports + lowering)
147
+ graph/ParticleNodeRegistry.js standard node library (a NodeRegistry)
148
+ graph/particle_graph_authoring.js authoring sugar over core/model/node-graph
149
149
  graph/compile_particle_graph.js graph → Program (register alloc, const intern)
150
150
  layout/ParticleLayout.js attribute schema ↔ record word offsets
151
151
  data/*_STRUCT.js EMITTER_STRUCT, counters, render binding, sort key