@woosh/meep-engine 3.14.3 → 3.14.4

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 (75) hide show
  1. package/package.json +1 -1
  2. package/src/engine/Engine.d.ts.map +1 -1
  3. package/src/engine/Engine.js +733 -726
  4. package/src/engine/network/NetworkSession.d.ts +24 -4
  5. package/src/engine/network/NetworkSession.d.ts.map +1 -1
  6. package/src/engine/network/NetworkSession.js +28 -4
  7. package/src/engine/network/orchestrator/NetworkPeer.d.ts +47 -6
  8. package/src/engine/network/orchestrator/NetworkPeer.d.ts.map +1 -1
  9. package/src/engine/network/orchestrator/NetworkPeer.js +184 -56
  10. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts +97 -11
  11. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts.map +1 -1
  12. package/src/engine/network/orchestrator/ServerAuthoritativeServer.js +304 -22
  13. package/src/engine/network/replication/Replicator.d.ts +14 -1
  14. package/src/engine/network/replication/Replicator.d.ts.map +1 -1
  15. package/src/engine/network/replication/Replicator.js +44 -3
  16. package/src/engine/network/sim/ActionLog.d.ts +21 -3
  17. package/src/engine/network/sim/ActionLog.d.ts.map +1 -1
  18. package/src/engine/network/sim/ActionLog.js +24 -3
  19. package/src/engine/network/sim/SimAction.d.ts +15 -0
  20. package/src/engine/network/sim/SimAction.d.ts.map +1 -1
  21. package/src/engine/network/sim/SimAction.js +15 -0
  22. package/src/engine/network/sim/SimActionExecutor.d.ts +51 -1
  23. package/src/engine/network/sim/SimActionExecutor.d.ts.map +1 -1
  24. package/src/engine/network/sim/SimActionExecutor.js +73 -5
  25. package/src/engine/network/transport/Channel.d.ts +1 -1
  26. package/src/engine/network/transport/Channel.js +1 -1
  27. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  28. package/src/engine/simulation/Ticker.js +4 -1
  29. package/src/engine/ui/GUIEngine.d.ts.map +1 -1
  30. package/src/engine/ui/GUIEngine.js +6 -0
  31. package/src/shade/playground/particle_system/README.md +98 -0
  32. package/src/shade/playground/particle_system/index.html +124 -0
  33. package/src/shade/playground/particle_system/main.d.ts +2 -0
  34. package/src/shade/playground/particle_system/main.d.ts.map +1 -0
  35. package/src/shade/playground/particle_system/main.js +180 -0
  36. package/src/shade/playground/particle_system/particle_scene.d.ts +52 -0
  37. package/src/shade/playground/particle_system/particle_scene.d.ts.map +1 -0
  38. package/src/shade/playground/particle_system/particle_scene.js +552 -0
  39. package/src/shade/playground/skinned_mesh_soup/README.md +122 -0
  40. package/src/shade/playground/skinned_mesh_soup/index.html +199 -0
  41. package/src/shade/playground/skinned_mesh_soup/main.d.ts +7 -0
  42. package/src/shade/playground/skinned_mesh_soup/main.d.ts.map +1 -0
  43. package/src/shade/playground/skinned_mesh_soup/main.js +564 -0
  44. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts +23 -0
  45. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts.map +1 -0
  46. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.js +409 -0
  47. package/src/shade/renderer/particles/cull/chunk_particle_cull.d.ts.map +1 -1
  48. package/src/shade/renderer/particles/cull/chunk_particle_cull.js +3 -1
  49. package/src/shade/renderer/particles/data/chunk_particle_emitter.js +1 -1
  50. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts +1 -1
  51. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts.map +1 -1
  52. package/src/shade/renderer/particles/isa/InstructionStream.d.ts +89 -0
  53. package/src/shade/renderer/particles/isa/InstructionStream.d.ts.map +1 -0
  54. package/src/shade/renderer/particles/isa/InstructionStream.js +162 -0
  55. package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts +1 -48
  56. package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts.map +1 -1
  57. package/src/shade/renderer/particles/isa/ParticleAssembler.js +25 -114
  58. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.d.ts.map +1 -1
  59. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.js +4 -2
  60. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts +4 -4
  61. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts.map +1 -1
  62. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.js +7 -5
  63. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.d.ts.map +1 -1
  64. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.js +3 -1
  65. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  66. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +3 -1
  67. package/src/shade/renderer/particles/shaders/shader_particle_finalize.d.ts.map +1 -1
  68. package/src/shade/renderer/particles/shaders/shader_particle_finalize.js +5 -2
  69. package/src/shade/renderer/particles/shaders/shader_particle_render.d.ts.map +1 -1
  70. package/src/shade/renderer/particles/shaders/shader_particle_render.js +9 -2
  71. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  72. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +3 -1
  73. package/src/shade/renderer/particles/prototypeParticleSystem.d.ts +0 -2
  74. package/src/shade/renderer/particles/prototypeParticleSystem.d.ts.map +0 -1
  75. package/src/shade/renderer/particles/prototypeParticleSystem.js +0 -448
@@ -0,0 +1,552 @@
1
+ import { m4_allocate } from "../../../core/geom/3d/mat4/m4_allocate.js";
2
+ import { m4_invert } from "../../../core/geom/3d/mat4/m4_invert.js";
3
+ import { m4_look_at } from "../../../core/geom/3d/mat4/m4_look_at.js";
4
+ import { m4_multiply } from "../../../core/geom/3d/mat4/m4_multiply.js";
5
+ import { ShadeGPUCommandContext } from "../../device/ShadeGPUCommandContext.js";
6
+ import { CAMERA_UNIFORM_STRUCT } from "../../renderer/camera/CAMERA_UNIFORM_STRUCT.js";
7
+ import { PARTICLE_RECORD_WORD_COUNT } from "../../renderer/particles/ParticleConstants.js";
8
+ import { PARTICLE_COUNTER_COUNT } from "../../renderer/particles/data/PARTICLE_COUNTERS.js";
9
+ import { EMITTER_BLEND, EMITTER_PROJECTION } from "../../renderer/particles/data/PARTICLE_EMITTER_LAYOUT.js";
10
+ import { ParticleGraph } from "../../renderer/particles/graph/ParticleGraph.js";
11
+ import { VM_BUILTIN, VM_RANDOM_MODE } from "../../renderer/particles/isa/ParticleVMISA.js";
12
+ import { ParticleLayout } from "../../renderer/particles/layout/ParticleLayout.js";
13
+ import { GPUParticleSystem } from "../../renderer/particles/GPUParticleSystem.js";
14
+ import { create_particle_effect } from "../../renderer/particles/runtime/create_particle_effect.js";
15
+ import { fire_effect } from "../../renderer/particles/particle_prototype.js";
16
+ import {
17
+ PARTICLE_BILLBOARD_RESOURCES,
18
+ create_particle_billboard_pipeline,
19
+ } from "../../renderer/particles/shaders/shader_particle_render.js";
20
+ import { shader_particle_emit } from "../../renderer/particles/shaders/shader_particle_emit.js";
21
+ import {
22
+ shader_particle_build_indirect,
23
+ shader_particle_reset_counters,
24
+ } from "../../renderer/particles/shaders/shader_particle_finalize.js";
25
+ import {
26
+ PARTICLE_SIMULATE_WORKGROUP_SIZE,
27
+ shader_particle_simulate,
28
+ } from "../../renderer/particles/shaders/shader_particle_simulate.js";
29
+
30
+ /**
31
+ * The scene the particle playground draws, with every reference to the page removed: it takes a
32
+ * {@link GraphicsContext} and a colour target, and encodes one frame into a command context.
33
+ *
34
+ * Separate from `main.js` so the whole frame — every pass, binding, buffer usage and immediate
35
+ * block — can be encoded against {@link ../../device/mock/SoftwareGPUDevice.js} in a vitest spec
36
+ * (see `particle_scene.spec.js`). That is the only tier available to CI here; the mock validates
37
+ * the WebGPU API surface but executes no shaders, so what a spec can prove is that the frame is
38
+ * *legal*, not what it looks like. The picture is the browser's job.
39
+ *
40
+ * What the scene contains:
41
+ *
42
+ * - lit solid geometry (a ground plane + boxes) rendered into a reverse-Z depth buffer,
43
+ * - four emitters with distinct settings — an additive fire, an alpha soft-depth smoke column,
44
+ * additive velocity-stretched sparks, and a wide soft-depth dust drift,
45
+ * - a billboard pass that depth-tests against the scene, so particles are occluded by the boxes and
46
+ * soft-depth emitters fade as they approach surfaces.
47
+ *
48
+ * The loop is fully GPU-driven: the simulate dispatch runs via `dispatchWorkgroupsIndirect` and the
49
+ * billboard draw via `drawIndirect`, both from args the build-indirect pass writes on the GPU. No
50
+ * CPU readback participates in sizing anything — a stale CPU count would orphan alive-list entries
51
+ * when the population grows and double-free slots when it shrinks.
52
+ */
53
+
54
+ /** Maximum simultaneous particles across every emitter — they share one pool. */
55
+ const PARTICLE_CAPACITY = 1 << 15; // 32k
56
+
57
+ /** Camera near plane. Also the numerator of the reverse-Z depth linearisation (see {@link write_camera}). */
58
+ const PARTICLE_NEAR = 0.2;
59
+
60
+ /**
61
+ * The largest immediate block any of the three compute passes declares (emit's 20-byte settings
62
+ * struct). Immediate data is an optional WebGPU capability, so callers check for it up front rather
63
+ * than letting pipeline creation fail with an error that names neither pass nor feature.
64
+ */
65
+ export const PARTICLE_IMMEDIATE_BYTES = 20;
66
+
67
+ /** Bytes of stats {@link ParticleScene#encode_stats_copy} writes: 8 counters, then both arg buffers. */
68
+ export const PARTICLE_STATS_BYTES = 64;
69
+
70
+ const STATS_OFFSET_DISPATCH_ARGS = 32;
71
+ const STATS_OFFSET_DRAW_ARGS = 48;
72
+
73
+ /**
74
+ * @typedef {object} ParticleSceneStats
75
+ * @property {number} alive particles alive at the end of the captured frame
76
+ * @property {number} dead free slots left in the pool
77
+ * @property {number} alive_in the snapshot next frame's simulate reads as its input length
78
+ * @property {number[]} dispatch simulate's indirect dispatch args, `[x, y, z]` workgroups
79
+ * @property {number[]} draw the billboard draw's indirect args, `[vertices, instances]`
80
+ */
81
+
82
+ /**
83
+ * Build the scene against a live (or mock) device.
84
+ *
85
+ * @param {GraphicsContext} graphics
86
+ * @param {object} options
87
+ * @param {GPUTextureFormat} options.color_format format of the colour target frames are encoded into
88
+ * @param {number} [options.capacity] particle pool size
89
+ * @returns {object} the scene, see the returned object's own members
90
+ */
91
+ export function create_particle_scene(graphics, { color_format, capacity = PARTICLE_CAPACITY }) {
92
+ const device = graphics.device;
93
+
94
+ // --- emitters: four, each with different settings -----------------------------------------
95
+ const system = new GPUParticleSystem(graphics, { capacity });
96
+
97
+ // fire — additive, billboard, at the base of the tall pillar
98
+ system.addEmitter({
99
+ ...fire_effect().emitter,
100
+ spawn_rate: 500, flipbook: [1, 1], atlas_region: [0, 0, 1, 1],
101
+ flags: { blend: EMITTER_BLEND.ADDITIVE, projection: EMITTER_PROJECTION.BILLBOARD },
102
+ transform: translation(-1, 0, 0),
103
+ });
104
+ // smoke — alpha, soft-depth, rising column above the fire
105
+ system.addEmitter({
106
+ effect: make_effect({ velocity: [0, 1.3, 0], velocityJitter: 0.35, posJitter: 0.5, accel: 0.25, life: [3, 5], size: 1.4, color: [0.45, 0.45, 0.48, 0.28] }),
107
+ spawn_rate: 60, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
108
+ flags: { blend: EMITTER_BLEND.ALPHA, projection: EMITTER_PROJECTION.BILLBOARD, soft_depth: true },
109
+ render: { position: "position", size: "size", color: "color" },
110
+ transform: translation(-1, 2.5, 0),
111
+ });
112
+ // sparks — additive, velocity-stretched, fast, short-lived, gravity, near the box
113
+ system.addEmitter({
114
+ effect: make_effect({ velocity: [0, 5, 0], velocityJitter: 4.5, posJitter: 0.15, accel: -14, life: [0.3, 0.8], size: 0.09, color: [1.0, 0.8, 0.35, 1.0] }),
115
+ spawn_rate: 350, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
116
+ flags: { blend: EMITTER_BLEND.ADDITIVE, projection: EMITTER_PROJECTION.STRETCHED },
117
+ render: { position: "position", size: "size", color: "color", velocity: "velocity" },
118
+ transform: translation(2, 1.9, -1),
119
+ });
120
+ // dust — alpha, soft-depth, slow wide drift across the ground
121
+ system.addEmitter({
122
+ effect: make_effect({ velocity: [0.3, 0.15, 0.1], velocityJitter: 0.35, posJitter: 6, accel: 0, life: [4, 7], size: 0.7, color: [0.55, 0.5, 0.42, 0.16] }),
123
+ spawn_rate: 120, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
124
+ flags: { blend: EMITTER_BLEND.ALPHA, projection: EMITTER_PROJECTION.BILLBOARD, soft_depth: true },
125
+ render: { position: "position", size: "size", color: "color" },
126
+ transform: translation(0, 0.3, 0),
127
+ });
128
+
129
+ const built = system.buildEmitterData();
130
+
131
+ // --- particle GPU buffers -----------------------------------------------------------------
132
+ const storage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
133
+ const pool = device.createBuffer({ label: "particles/pool", size: capacity * PARTICLE_RECORD_WORD_COUNT * 4, usage: storage });
134
+ const alive_a = device.createBuffer({ label: "particles/alive_a", size: capacity * 4, usage: storage });
135
+ const alive_b = device.createBuffer({ label: "particles/alive_b", size: capacity * 4, usage: storage });
136
+ const dead = device.createBuffer({ label: "particles/dead", size: capacity * 4, usage: storage });
137
+ const counters = device.createBuffer({ label: "particles/counters", size: PARTICLE_COUNTER_COUNT * 4, usage: storage });
138
+ const spawn_requests = device.createBuffer({ label: "particles/spawn_requests", size: capacity * 4, usage: storage });
139
+ const emitters = data_buffer(device, "particles/emitters", built.emitters, storage);
140
+ const program = data_buffer(device, "particles/program", built.program, storage);
141
+ const constants = data_buffer(device, "particles/constants", built.constants, storage);
142
+
143
+ // Indirect args, written on-GPU by the build-indirect pass. Zero-initialised (WebGPU spec), so
144
+ // the first frame's indirect dispatch/draw are no-ops until the first snapshot lands. COPY_SRC
145
+ // only so the stats copy can *display* them; nothing read back here sizes a pass.
146
+ const args_usage = GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_SRC;
147
+ const dispatch_args = device.createBuffer({ label: "particles/dispatch_args", size: 3 * 4, usage: args_usage });
148
+ const draw_args = device.createBuffer({ label: "particles/draw_args", size: 4 * 4, usage: args_usage });
149
+
150
+ const initial = system.initialFreeList();
151
+ device.queue.writeBuffer(dead, 0, initial.dead);
152
+ device.queue.writeBuffer(counters, 0, initial.counters);
153
+
154
+ // --- camera + geometry --------------------------------------------------------------------
155
+ const camera_buffer = device.createBuffer({ label: "particles/camera", size: CAMERA_UNIFORM_STRUCT.aligned_size, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
156
+ const vp_buffer = device.createBuffer({ label: "particles/vp", size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
157
+
158
+ const geometry = build_geometry();
159
+ const geometry_buffer = device.createBuffer({ label: "particles/geometry", size: geometry.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
160
+ device.queue.writeBuffer(geometry_buffer, 0, geometry);
161
+ const geometry_vertex_count = geometry.length / GEOMETRY_FLOATS_PER_VERTEX;
162
+ const { geometry_pipeline, geometry_bind_group } = make_geometry_pipeline(device, color_format, vp_buffer);
163
+
164
+ // atlas sprite — soft round falloff — and the filtering sampler the billboard shader declares
165
+ const atlas_view = make_sprite_texture(device).createView();
166
+ const atlas_sampler = device.createSampler({ magFilter: "linear", minFilter: "linear", addressModeU: "clamp-to-edge", addressModeV: "clamp-to-edge" });
167
+
168
+ // billboard pipeline: reverse-Z depth test (same compare as the geometry pass), no write;
169
+ // soft-depth reads the scene depth texture
170
+ const billboard_pipeline = create_particle_billboard_pipeline({
171
+ color_format,
172
+ depth_format: DEPTH_FORMAT,
173
+ depth_compare: "greater",
174
+ });
175
+
176
+ // reverse-Z depth buffer, recreated on resize
177
+ let depth_texture = null;
178
+ let depth_view = null;
179
+ let depth_width = 0;
180
+ let depth_height = 0;
181
+
182
+ function ensure_depth(width, height) {
183
+ if (depth_view !== null && depth_width === width && depth_height === height) {
184
+ return;
185
+ }
186
+
187
+ depth_texture?.destroy();
188
+ depth_texture = device.createTexture({
189
+ label: "particles/scene_depth",
190
+ size: [width, height],
191
+ format: DEPTH_FORMAT,
192
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
193
+ });
194
+ depth_view = depth_texture.createView();
195
+ depth_width = width;
196
+ depth_height = height;
197
+ }
198
+
199
+ let alive_read = alive_a;
200
+ let alive_write = alive_b;
201
+
202
+ return {
203
+ system,
204
+ capacity,
205
+
206
+ /** Buffers a harness may inspect. Not for the frame loop, which never reads them back. */
207
+ buffers: { pool, alive_a, alive_b, dead, counters, spawn_requests, emitters, program, constants, dispatch_args, draw_args },
208
+
209
+ /**
210
+ * Encode one whole frame: geometry, then emit + simulate + build-indirect, then the
211
+ * billboard draw. Does not submit — the caller finishes the context.
212
+ *
213
+ * @param {ShadeGPUCommandContext} cmd
214
+ * @param {object} frame
215
+ * @param {GPUTextureView} frame.color_view where the frame is drawn
216
+ * @param {number} frame.width colour target width, in texels
217
+ * @param {number} frame.height colour target height, in texels
218
+ * @param {number} frame.dt seconds since the previous frame
219
+ * @param {number} frame.time seconds since start, for the camera orbit and the VM's clock
220
+ */
221
+ encode_frame(cmd, { color_view, width, height, dt, time }) {
222
+ ensure_depth(width, height);
223
+ write_camera(device, camera_buffer, vp_buffer, width / height, time);
224
+
225
+ const requests = system.update(dt);
226
+ if (requests.length > 0) {
227
+ device.queue.writeBuffer(spawn_requests, 0, requests);
228
+ }
229
+
230
+ // --- geometry pass: clear colour + reverse-Z depth (far = 0), lit, writes depth ---
231
+ const geometry_pass = cmd.beginRenderPass({
232
+ label: "particles/geometry",
233
+ colorAttachments: [{ view: color_view, loadOp: "clear", storeOp: "store", clearValue: { r: 0.03, g: 0.035, b: 0.05, a: 1 } }],
234
+ depthStencilAttachment: { view: depth_view, depthLoadOp: "clear", depthStoreOp: "store", depthClearValue: 0.0 },
235
+ });
236
+ geometry_pass.setPipeline(geometry_pipeline);
237
+ geometry_pass.setBindGroup(0, geometry_bind_group);
238
+ geometry_pass.setVertexBuffer(0, geometry_buffer);
239
+ geometry_pass.draw(geometry_vertex_count);
240
+ geometry_pass.end();
241
+
242
+ // --- emit + simulate (simulate is sized by LAST frame's GPU-written dispatch args) ---
243
+ shader_particle_reset_counters.dispatch({ encoder: cmd, bindings: { counters }, group_count_x: 1 });
244
+
245
+ if (requests.length > 0) {
246
+ shader_particle_emit.dispatchThreads({
247
+ encoder: cmd,
248
+ bindings: {
249
+ emitters, program, vm_constants: constants, pool, spawn_requests,
250
+ alive_out: alive_write, dead, counters,
251
+ settings: { u_dt: dt, u_time: time, u_frame: system.frame, u_count: requests.length, u_capacity: capacity },
252
+ },
253
+ thread_count_x: requests.length,
254
+ });
255
+ }
256
+
257
+ shader_particle_simulate.dispatchIndirect({
258
+ encoder: cmd,
259
+ bindings: {
260
+ emitters, program, vm_constants: constants, pool,
261
+ alive_in: alive_read, alive_out: alive_write, dead, counters,
262
+ settings: { u_dt: dt, u_time: time, u_frame: system.frame },
263
+ },
264
+ command: dispatch_args,
265
+ });
266
+
267
+ // Snapshot the final alive count (next frame's simulate input) + write this frame's draw args.
268
+ shader_particle_build_indirect.dispatch({
269
+ encoder: cmd,
270
+ bindings: { counters, dispatch_args, draw_args, u_workgroup_size: PARTICLE_SIMULATE_WORKGROUP_SIZE },
271
+ group_count_x: 1,
272
+ });
273
+
274
+ // --- billboard pass: load colour, depth-test against the scene (read-only), indirect draw ---
275
+ const bindings = PARTICLE_BILLBOARD_RESOURCES.generateBindings({
276
+ camera: camera_buffer, pool, sorted: alive_write, emitters,
277
+ atlas: atlas_view, atlas_sampler, scene_depth: depth_view,
278
+ }, cmd);
279
+
280
+ const billboard_pass = cmd.constructRenderPass({
281
+ pipeline: billboard_pipeline,
282
+ bindings,
283
+ colorAttachments: [{ view: color_view, loadOp: "load", storeOp: "store" }],
284
+ depthStencilAttachment: { view: depth_view, depthReadOnly: true },
285
+ });
286
+ billboard_pass.drawIndirect(draw_args, 0);
287
+ billboard_pass.end();
288
+
289
+ const previous = alive_read;
290
+ alive_read = alive_write;
291
+ alive_write = previous;
292
+ },
293
+
294
+ /**
295
+ * Copy the counters and both indirect arg buffers into a {@link PARTICLE_STATS_BYTES}-byte
296
+ * MAP_READ destination, for display. Read back to *look at*: nothing here may size a pass.
297
+ *
298
+ * @param {ShadeGPUCommandContext} cmd
299
+ * @param {GPUBuffer} destination
300
+ */
301
+ encode_stats_copy(cmd, destination) {
302
+ cmd.copyBufferToBuffer(counters, 0, destination, 0, PARTICLE_COUNTER_COUNT * 4);
303
+ cmd.copyBufferToBuffer(dispatch_args, 0, destination, STATS_OFFSET_DISPATCH_ARGS, 3 * 4);
304
+ cmd.copyBufferToBuffer(draw_args, 0, destination, STATS_OFFSET_DRAW_ARGS, 4 * 4);
305
+ },
306
+
307
+ /**
308
+ * Interpret the bytes {@link encode_stats_copy} wrote.
309
+ *
310
+ * @param {ArrayBuffer} bytes
311
+ * @returns {ParticleSceneStats}
312
+ */
313
+ read_stats(bytes) {
314
+ const words = new Uint32Array(bytes);
315
+ const dispatch_base = STATS_OFFSET_DISPATCH_ARGS / 4;
316
+ const draw_base = STATS_OFFSET_DRAW_ARGS / 4;
317
+
318
+ return {
319
+ alive: words[0],
320
+ dead: words[1],
321
+ alive_in: words[4],
322
+ dispatch: [words[dispatch_base], words[dispatch_base + 1], words[dispatch_base + 2]],
323
+ draw: [words[draw_base], words[draw_base + 1]],
324
+ };
325
+ },
326
+ };
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------------------------
330
+ // Effect authoring
331
+ // ---------------------------------------------------------------------------------------------
332
+
333
+ /**
334
+ * A parametric point-emission effect: emit with a base velocity + jitter from a disk, integrate
335
+ * under a vertical acceleration, and die at end of life. Only nAge.x is consumed (kill), so the
336
+ * NaN lanes from the scalar age/lifetime divide are harmless.
337
+ */
338
+ function make_effect({ velocity, velocityJitter = 0, posJitter = 0, accel = 0, life = [1, 2], size = 1, color = [1, 1, 1, 1] }) {
339
+ const layout = new ParticleLayout([
340
+ { name: "position", components: 3 }, { name: "velocity", components: 3 },
341
+ { name: "age", components: 1 }, { name: "lifetime", components: 1 },
342
+ { name: "size", components: 1 }, { name: "color", components: 4 },
343
+ ]);
344
+
345
+ const spawn = new ParticleGraph();
346
+ let posN = spawn.add("builtin", { id: VM_BUILTIN.EMITTER_POSITION });
347
+ if (posJitter > 0) {
348
+ const d = spawn.add("random", { mode: VM_RANDOM_MODE.UNIT_DISK });
349
+ const j = spawn.add("scale"); spawn.wire(j, "v", d); spawn.wire(j, "s", [posJitter]);
350
+ const p = spawn.add("add"); spawn.wire(p, "a", posN); spawn.wire(p, "b", j); posN = p;
351
+ }
352
+ spawn.wire(spawn.add("setAttribute", { name: "position" }), "value", posN);
353
+
354
+ let velN = spawn.add("const", { value: [velocity[0], velocity[1], velocity[2], 0] });
355
+ if (velocityJitter > 0) {
356
+ const r = spawn.add("random", { mode: VM_RANDOM_MODE.UNIFORM_SIGNED });
357
+ const rs = spawn.add("scale"); spawn.wire(rs, "v", r); spawn.wire(rs, "s", [velocityJitter]);
358
+ const vv = spawn.add("add"); spawn.wire(vv, "a", velN); spawn.wire(vv, "b", rs); velN = vv;
359
+ }
360
+ spawn.wire(spawn.add("setAttribute", { name: "velocity" }), "value", velN);
361
+ spawn.wire(spawn.add("setAttribute", { name: "age" }), "value", [0]);
362
+ const lr = spawn.add("random", { mode: VM_RANDOM_MODE.UNIFORM });
363
+ const lm = spawn.add("mad"); spawn.wire(lm, "a", lr); spawn.wire(lm, "b", [life[1] - life[0]]); spawn.wire(lm, "c", [life[0]]);
364
+ spawn.wire(spawn.add("setAttribute", { name: "lifetime" }), "value", lm);
365
+ spawn.wire(spawn.add("setAttribute", { name: "size" }), "value", [size]);
366
+ spawn.wire(spawn.add("setAttribute", { name: "color" }), "value", color);
367
+
368
+ const update = new ParticleGraph();
369
+ const dt = update.add("builtin", { id: VM_BUILTIN.DELTA_TIME });
370
+ const age = update.add("attribute", { name: "age" });
371
+ const nage = update.add("add"); update.wire(nage, "a", age); update.wire(nage, "b", dt);
372
+ update.wire(update.add("setAttribute", { name: "age" }), "value", nage);
373
+ const lifeA = update.add("attribute", { name: "lifetime" });
374
+ const na = update.add("div"); update.wire(na, "a", nage); update.wire(na, "b", lifeA);
375
+ const dead = update.add("compare", { op: "ge" }); update.wire(dead, "a", na); update.wire(dead, "b", [1]);
376
+ update.wire(update.add("kill"), "condition", dead);
377
+ let velN2 = update.add("attribute", { name: "velocity" });
378
+ if (accel !== 0) {
379
+ const g = update.add("const", { value: [0, accel, 0, 0] });
380
+ const gd = update.add("scale"); update.wire(gd, "v", g); update.wire(gd, "s", dt);
381
+ const nv = update.add("add"); update.wire(nv, "a", velN2); update.wire(nv, "b", gd);
382
+ update.wire(update.add("setAttribute", { name: "velocity" }), "value", nv); velN2 = nv;
383
+ }
384
+ const pos = update.add("attribute", { name: "position" });
385
+ const pd = update.add("scale"); update.wire(pd, "v", velN2); update.wire(pd, "s", dt);
386
+ const np = update.add("add"); update.wire(np, "a", pos); update.wire(np, "b", pd);
387
+ update.wire(update.add("setAttribute", { name: "position" }), "value", np);
388
+
389
+ return create_particle_effect({ layout, spawn, update });
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------------------------
393
+ // Geometry + camera + helpers
394
+ // ---------------------------------------------------------------------------------------------
395
+
396
+ const DEPTH_FORMAT = "depth32float";
397
+
398
+ /** Interleaved `[x, y, z, nx, ny, nz]`. */
399
+ const GEOMETRY_FLOATS_PER_VERTEX = 6;
400
+
401
+ const GEOM_WGSL = /* wgsl */ `
402
+ @group(0) @binding(0) var<uniform> vp : mat4x4<f32>;
403
+ struct VOut { @builtin(position) clip : vec4<f32>, @location(0) normal : vec3<f32> }
404
+ @vertex fn vs(@location(0) pos : vec3<f32>, @location(1) normal : vec3<f32>) -> VOut {
405
+ var o : VOut;
406
+ o.clip = vp * vec4<f32>(pos, 1.0);
407
+ o.normal = normal;
408
+ return o;
409
+ }
410
+ @fragment fn fs(in : VOut) -> @location(0) vec4<f32> {
411
+ let N = normalize(in.normal);
412
+ let L = normalize(vec3<f32>(0.35, 1.0, 0.4));
413
+ let diffuse = max(dot(N, L), 0.0);
414
+ let albedo = select(vec3<f32>(0.30, 0.32, 0.36), vec3<f32>(0.22, 0.24, 0.28), N.y < 0.9);
415
+ let color = albedo * (0.18 + diffuse * 0.9);
416
+ return vec4<f32>(color, 1.0);
417
+ }
418
+ `;
419
+
420
+ function make_geometry_pipeline(device, format, vp_buffer) {
421
+ const module = device.createShaderModule({ label: "particles/geometry", code: GEOM_WGSL });
422
+ const layout = device.createBindGroupLayout({
423
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
424
+ });
425
+
426
+ const geometry_pipeline = device.createRenderPipeline({
427
+ label: "particles/geometry",
428
+ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
429
+ vertex: {
430
+ module,
431
+ entryPoint: "vs",
432
+ buffers: [{
433
+ arrayStride: GEOMETRY_FLOATS_PER_VERTEX * 4,
434
+ attributes: [
435
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
436
+ { shaderLocation: 1, offset: 12, format: "float32x3" },
437
+ ],
438
+ }],
439
+ },
440
+ fragment: { module, entryPoint: "fs", targets: [{ format }] },
441
+ primitive: { topology: "triangle-list", cullMode: "none" },
442
+ depthStencil: { format: DEPTH_FORMAT, depthWriteEnabled: true, depthCompare: "greater" }, // reverse-Z
443
+ });
444
+
445
+ const geometry_bind_group = device.createBindGroup({ layout, entries: [{ binding: 0, resource: { buffer: vp_buffer } }] });
446
+
447
+ return { geometry_pipeline, geometry_bind_group };
448
+ }
449
+
450
+ /** Ground plane + a few boxes as an interleaved `[x,y,z, nx,ny,nz]` vertex array. */
451
+ function build_geometry() {
452
+ const v = [];
453
+ const quad = (a, b, c, d, n) => { for (const p of [a, b, c, a, c, d]) v.push(p[0], p[1], p[2], n[0], n[1], n[2]); };
454
+ const box = (cx, cy, cz, sx, sy, sz) => {
455
+ const x0 = cx - sx / 2, x1 = cx + sx / 2, y0 = cy - sy / 2, y1 = cy + sy / 2, z0 = cz - sz / 2, z1 = cz + sz / 2;
456
+ quad([x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [0, 0, 1]);
457
+ quad([x1, y0, z0], [x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [0, 0, -1]);
458
+ quad([x1, y0, z1], [x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [1, 0, 0]);
459
+ quad([x0, y0, z0], [x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [-1, 0, 0]);
460
+ quad([x0, y1, z1], [x1, y1, z1], [x1, y1, z0], [x0, y1, z0], [0, 1, 0]);
461
+ quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [0, -1, 0]);
462
+ };
463
+ quad([-15, 0, -15], [15, 0, -15], [15, 0, 15], [-15, 0, 15], [0, 1, 0]); // ground
464
+ box(-1, 1.5, 0, 1, 3, 1); // tall pillar next to the fire
465
+ box(2, 0.9, -1, 1.6, 1.8, 1.6); // block near the sparks
466
+ box(0, 0.6, 3.5, 7, 1.2, 0.6); // low wall the dust drifts against
467
+ return new Float32Array(v);
468
+ }
469
+
470
+ // Scratch for write_camera. One scene drives one camera, and the loop runs every frame, so these
471
+ // are reused rather than reallocated 60 times a second.
472
+ const camera_view = m4_allocate();
473
+ const camera_world = m4_allocate();
474
+ const camera_projection = m4_allocate();
475
+ const camera_view_projection = m4_allocate();
476
+ const camera_bytes = new Float32Array(CAMERA_UNIFORM_STRUCT.aligned_size / 4);
477
+
478
+ /**
479
+ * Reverse-Z, infinite-far perspective (WebGPU 0..1 depth: near -> 1, far -> 0).
480
+ *
481
+ * @param {Float32Array} out column-major 4x4
482
+ * @param {number} fov_y vertical field of view, radians
483
+ * @param {number} aspect width / height
484
+ * @param {number} near near plane distance
485
+ * @returns {Float32Array} `out`
486
+ */
487
+ function perspective_reverse_z(out, fov_y, aspect, near) {
488
+ const f = 1 / Math.tan(fov_y / 2);
489
+
490
+ out.fill(0);
491
+ out[0] = f / aspect;
492
+ out[5] = f;
493
+ out[11] = -1;
494
+ out[14] = near;
495
+
496
+ return out;
497
+ }
498
+
499
+ /**
500
+ * Orbit the camera and upload both camera buffers: the engine's full camera struct for the
501
+ * billboard shader, and a bare view-projection for the geometry pass.
502
+ */
503
+ function write_camera(device, camera_buffer, vp_buffer, aspect, t) {
504
+ const radius = 12, height = 5.5;
505
+ const eye = [Math.cos(t * 0.22) * radius, height, Math.sin(t * 0.22) * radius];
506
+ const center = [0, 1.6, 0];
507
+
508
+ const view = m4_look_at(camera_view, eye, center, [0, 1, 0]);
509
+ // A look-at basis is orthonormal plus a translation, so this never reports singular.
510
+ m4_invert(camera_world, view);
511
+ const world = camera_world;
512
+ const projection = perspective_reverse_z(camera_projection, (58 * Math.PI) / 180, aspect, PARTICLE_NEAR);
513
+ const view_projection = m4_multiply(camera_view_projection, projection, view);
514
+
515
+ const put = (field, m) => camera_bytes.set(m, CAMERA_UNIFORM_STRUCT.get(field).offset / 4);
516
+ put("transform", world);
517
+ put("view_matrix", view);
518
+ put("projection_matrix", projection);
519
+ put("view_projection_matrix", view_projection);
520
+ // Reverse-Z infinite-far linearisation: view_depth = A / (device_depth + B), with A=near, B=0.
521
+ camera_bytes.set([0, PARTICLE_NEAR, 0, 0], CAMERA_UNIFORM_STRUCT.get("device_depth_to_view_space").offset / 4);
522
+
523
+ device.queue.writeBuffer(camera_buffer, 0, camera_bytes);
524
+ device.queue.writeBuffer(vp_buffer, 0, view_projection);
525
+ }
526
+
527
+ function translation(x, y, z) {
528
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
529
+ }
530
+
531
+ function data_buffer(device, label, data, usage) {
532
+ const buffer = device.createBuffer({ label, size: Math.max(data.byteLength, 4), usage });
533
+ device.queue.writeBuffer(buffer, 0, data);
534
+ return buffer;
535
+ }
536
+
537
+ /** A 64x64 radial-falloff sprite (white, alpha falloff) for soft additive/alpha blobs. */
538
+ function make_sprite_texture(device) {
539
+ const N = 64;
540
+ const px = new Uint8Array(N * N * 4);
541
+ for (let y = 0; y < N; y++) {
542
+ for (let x = 0; x < N; x++) {
543
+ const dx = (x + 0.5) / N * 2 - 1, dy = (y + 0.5) / N * 2 - 1;
544
+ const a = Math.max(0, 1 - Math.hypot(dx, dy));
545
+ const i = (y * N + x) * 4;
546
+ px[i] = 255; px[i + 1] = 255; px[i + 2] = 255; px[i + 3] = Math.round(a * a * 255);
547
+ }
548
+ }
549
+ const tex = device.createTexture({ label: "particles/sprite", size: [N, N], format: "rgba8unorm", usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
550
+ device.queue.writeTexture({ texture: tex }, px, { bytesPerRow: N * 4, rowsPerImage: N }, [N, N]);
551
+ return tex;
552
+ }
@@ -0,0 +1,122 @@
1
+ # skinned mesh soup — repro
2
+
3
+ The character from `skinned_blas_refit`, loaded the way the game loads one, with the pieces that
4
+ could be making soup on switches.
5
+
6
+ ```bash
7
+ npm run dev
8
+ ```
9
+
10
+ then open `/src/shade/playground/skinned_mesh_soup/index.html`.
11
+
12
+ `?count=N` for N characters, `?skin=off|bind|animated`, `?trace=1` for the path tracer. Every switch
13
+ is in the url so a state worth showing someone can be sent as a link.
14
+
15
+ ## What it turned out to be
16
+
17
+ **`chunk_skin_blend_mesh_local` requires `inverse(mesh.global) × joint.global × inverse_bind` to be
18
+ rigid. For `Michelle.glb` it carries a uniform scale of exactly 100, and a dual quaternion cannot
19
+ carry a scale.**
20
+
21
+ The chunk's own doc comment states the precondition — *"the four products must be rigid, which holds
22
+ whenever the joints and the mesh node share the scaling ancestors"* — and names the shape it was
23
+ written for: an FBX-derived glTF that puts its unit conversion on a root node and authors the
24
+ inverse-bind matrices in **pre-scale** mesh space, so `joint.global × inverse_bind` carries the same
25
+ scale `mesh.global` does and the fold cancels it.
26
+
27
+ Michelle is the other legal shape. Her POSITION data and her inverse-bind matrices are authored in
28
+ the **already-scaled** space — 1.66 m tall, Y-up, as they stand — so
29
+
30
+ ```
31
+ joint.global × inverse_bind = I for every one of her 65 joints
32
+ mesh.global = 0.01 scale × the up-axis rotation, from her "Character" root
33
+ ```
34
+
35
+ and the fold is `inverse(mesh.global) × I`, a bare **100×**. Measured, at bind and under the dance:
36
+ every folded matrix's column length is `100.0000`, where rigid is 1.
37
+
38
+ `dual_quat_from_m4` builds its quaternion from `sqrt(max(0, 1 ± m00 ± m11 ± m22))`. Those terms are
39
+ `1 + 100·(…)`, so the `1` is swamped and every component whose sum goes negative is **clamped to
40
+ zero** by the `max`. The rotation it extracts is not the matrix's rotation, and how wrong it is
41
+ depends on the matrix — so it is wrong differently for every joint. That is the soup: not one
42
+ transform applied badly, but sixty-five of them disagreeing.
43
+
44
+ The two modes make the mechanism visible:
45
+
46
+ | `?skin=` | what you see | why |
47
+ | --- | --- | --- |
48
+ | `bind` | the right character, exactly 1/100 the size | every joint folds to the *same* matrix, `100·Rx(90°)`. Its `w` and `x` terms are both `1 + 100`, its `y` and `z` both clamp to zero, and the normalize recovers `Rx(90°)` — by luck. Only the scale is lost. Sampled output is `expected / 100` to the digit. |
49
+ | `animated` | soup | the joints' matrices differ, each loses a different amount, and the mesh comes apart. Mean error 121 in a mesh-local space where the body is 224 across; worst 165, on vertices bound to **a single joint at weight 1** — so the blend is not even involved. |
50
+
51
+ `__soup_blend_probe.spec.js` is that measurement: the WGSL of `dual_quat_from_m4`,
52
+ `dual_quat_blend4` and `dual_quat_transform_point` ported to JS, run over the character's own
53
+ matrices at bind and at `t = 0.5 s` of `SambaDance`, against the position the glTF formula puts each
54
+ vertex at. No device.
55
+
56
+ ### What it is not
57
+
58
+ `__soup_probe.spec.js` is the ruling-out, and every one of these passes on the parsed asset before a
59
+ GPU is involved:
60
+
61
+ - the geometry is a surface, not a soup — 28 106 triangles, no degenerates, longest edge 0.093
62
+ against a 2.24 diagonal, every meshlet index in range;
63
+ - the skinning attributes are sound — all 19 106 vertices land back on their rest position under
64
+ `joint.global × inverse_bind`, no zero-weight vertices, no out-of-range joint;
65
+ - the source BLAS is exact — 56 211 nodes all reachable, 28 106 leaves each naming a distinct
66
+ triangle whose box matches to the bit, all 28 105 internal nodes exactly their children's union.
67
+
68
+ So it is not the loader, not the meshlet build, not the tree, and not the refit. `skinned_blas_refit`'s
69
+ `verify()` cannot see this at all, and that is worth saying plainly: it holds the tree's root box
70
+ against the bounds chain's box, and **both are derived from the clone's vertex region**. Vertices
71
+ that are wrong in the same way agree with each other perfectly. A green `verify()` and a soup
72
+ picture are consistent.
73
+
74
+ ## The fix, and why it is a choice
75
+
76
+ The scale is uniform, so it factors: `M = s · Rigid` means `M · p = s · (Rigid · p)`. Dividing the
77
+ folded matrix's 3×3 and its translation by `s = length(column 0)` before the conversion, and scaling
78
+ the transformed position by `s` after, is exact for any uniform scale and costs a length and three
79
+ multiplies per joint. It does not help a *non-uniform* one, which a dual quaternion cannot carry
80
+ either — that would want the linear-blend path the DQ one replaced.
81
+
82
+ The other end is the loader: normalise a skin on load so `joint.global × inverse_bind` and
83
+ `mesh.global` always agree about the scale, and the shader's precondition becomes true by
84
+ construction rather than by the exporter's habits. That is a bigger change and it touches every
85
+ asset, not just this one.
86
+
87
+ Not taken here — this page is the repro and the measurement. Whichever end it is fixed at,
88
+ `chunk_skin_blend_mesh_local.spec.js` is where the red test belongs: a folded matrix carrying a
89
+ uniform scale, and the position it must still produce.
90
+
91
+ ## The switches
92
+
93
+ - **`?skin=off`** — no skin registered. A `SkinnedMesh` still gets a vertex clone
94
+ (`GPUMeshSkinningContext#obtain_geometry_index` allocates one for every skinned mesh), but nothing
95
+ deforms it, so what is drawn is the memcpy of the source: the rest pose under the mesh node's own
96
+ transform. For Michelle a *correct* result is a 1.7 cm figure on her side. Soup here would be the
97
+ geometry upload — the meshlet copy or its address patch — and nothing to do with skinning.
98
+ - **`?skin=bind`** — the skin registered, no clip. `await diagnose()` in the console reads the
99
+ clone's vertices back off the GPU and holds them against `inverse(mesh.global) × rest`, which is
100
+ what the bind pose must produce. It reports which vertices are wrong and in what shape: a
101
+ vertex-slot range says the workgroup stride loop, whole meshlets say addressing, and a uniform
102
+ factor says this bug. It checks the clone's meshlet records address a region of their own first —
103
+ a clone still pointing at the source's vertex data would be deforming the shared rest pose in
104
+ place.
105
+ - **`?skin=animated`** — the file's dance, as `skinned_blas_refit` runs it.
106
+ - **Path tracer** — off by default. The rasterizer reads the deformed vertices directly, with no
107
+ tree in the way, so it is the reading to establish the answer on: soup in *both* is the vertices,
108
+ soup in the tracer alone is the tree.
109
+
110
+ ## Why through `GLTFSceneBundleAssetLoader`
111
+
112
+ `skinned_blas_refit` calls `load_gltf` directly and parses the file once per character. The asset
113
+ loader is what the game and the editor register, and it hands out **the same bundle** to every
114
+ caller — so a second character is not a second parse, it is `instantiate_scene_bundle`: its own node
115
+ tree, its own skins bound to that tree's joints, its own clips retargeted onto it, sharing the
116
+ geometry and the inverse-bind matrices. Running the repro on that path is what makes a fix found
117
+ here a fix for the engine rather than for one prototype page.
118
+
119
+ ## Assets
120
+
121
+ `/test_assets/Michelle.glb`, gitignored and unpublished — see `test_assets/README.md`. The two
122
+ probes skip themselves when it is absent.