@woosh/meep-engine 3.14.3 → 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 (137) 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/Engine.d.ts.map +1 -1
  6. package/src/engine/Engine.js +733 -726
  7. package/src/engine/network/NetworkSession.d.ts +24 -4
  8. package/src/engine/network/NetworkSession.d.ts.map +1 -1
  9. package/src/engine/network/NetworkSession.js +28 -4
  10. package/src/engine/network/orchestrator/NetworkPeer.d.ts +96 -7
  11. package/src/engine/network/orchestrator/NetworkPeer.d.ts.map +1 -1
  12. package/src/engine/network/orchestrator/NetworkPeer.js +392 -68
  13. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts +6 -1
  14. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts.map +1 -1
  15. package/src/engine/network/orchestrator/ServerAuthoritativeClient.js +6 -0
  16. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts +105 -13
  17. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts.map +1 -1
  18. package/src/engine/network/orchestrator/ServerAuthoritativeServer.js +306 -59
  19. package/src/engine/network/replication/Replicator.d.ts +43 -7
  20. package/src/engine/network/replication/Replicator.d.ts.map +1 -1
  21. package/src/engine/network/replication/Replicator.js +309 -111
  22. package/src/engine/network/sim/ActionLog.d.ts +23 -3
  23. package/src/engine/network/sim/ActionLog.d.ts.map +1 -1
  24. package/src/engine/network/sim/ActionLog.js +26 -3
  25. package/src/engine/network/sim/ActionRecordCursor.d.ts +82 -0
  26. package/src/engine/network/sim/ActionRecordCursor.d.ts.map +1 -0
  27. package/src/engine/network/sim/ActionRecordCursor.js +120 -0
  28. package/src/engine/network/sim/RewindEngine.d.ts.map +1 -1
  29. package/src/engine/network/sim/RewindEngine.js +13 -29
  30. package/src/engine/network/sim/SimAction.d.ts +15 -0
  31. package/src/engine/network/sim/SimAction.d.ts.map +1 -1
  32. package/src/engine/network/sim/SimAction.js +15 -0
  33. package/src/engine/network/sim/SimActionExecutor.d.ts +51 -2
  34. package/src/engine/network/sim/SimActionExecutor.d.ts.map +1 -1
  35. package/src/engine/network/sim/SimActionExecutor.js +74 -7
  36. package/src/engine/network/transport/Channel.d.ts +1 -1
  37. package/src/engine/network/transport/Channel.js +1 -1
  38. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  39. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  40. package/src/engine/simulation/Ticker.js +4 -1
  41. package/src/engine/ui/GUIEngine.d.ts.map +1 -1
  42. package/src/engine/ui/GUIEngine.js +6 -0
  43. package/src/shade/playground/particle_system/README.md +98 -0
  44. package/src/shade/playground/particle_system/index.html +124 -0
  45. package/src/shade/playground/particle_system/main.d.ts +2 -0
  46. package/src/shade/playground/particle_system/main.d.ts.map +1 -0
  47. package/src/shade/playground/particle_system/main.js +180 -0
  48. package/src/shade/playground/particle_system/particle_scene.d.ts +52 -0
  49. package/src/shade/playground/particle_system/particle_scene.d.ts.map +1 -0
  50. package/src/shade/playground/particle_system/particle_scene.js +553 -0
  51. package/src/shade/playground/skinned_mesh_soup/README.md +143 -0
  52. package/src/shade/playground/skinned_mesh_soup/index.html +202 -0
  53. package/src/shade/playground/skinned_mesh_soup/main.d.ts +7 -0
  54. package/src/shade/playground/skinned_mesh_soup/main.d.ts.map +1 -0
  55. package/src/shade/playground/skinned_mesh_soup/main.js +567 -0
  56. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts +29 -0
  57. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts.map +1 -0
  58. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.js +428 -0
  59. package/src/shade/renderer/animation/GPUAnimationManager.d.ts.map +1 -1
  60. package/src/shade/renderer/animation/GPUAnimationManager.js +1958 -1796
  61. package/src/shade/renderer/animation/SKINNING_FRAME_PLAN.md +357 -0
  62. package/src/shade/renderer/animation/skin_bind_transform.d.ts +25 -0
  63. package/src/shade/renderer/animation/skin_bind_transform.d.ts.map +1 -0
  64. package/src/shade/renderer/animation/skin_bind_transform.js +33 -0
  65. package/src/shade/renderer/animation/skin_clip_scale_conflicts.d.ts +67 -0
  66. package/src/shade/renderer/animation/skin_clip_scale_conflicts.d.ts.map +1 -0
  67. package/src/shade/renderer/animation/skin_clip_scale_conflicts.js +199 -0
  68. package/src/shade/renderer/animation/skin_frame_defect.d.ts +34 -0
  69. package/src/shade/renderer/animation/skin_frame_defect.d.ts.map +1 -0
  70. package/src/shade/renderer/animation/skin_frame_defect.js +132 -0
  71. package/src/shade/renderer/animation/skin_joint_matrix.d.ts +24 -0
  72. package/src/shade/renderer/animation/skin_joint_matrix.d.ts.map +1 -0
  73. package/src/shade/renderer/animation/skin_joint_matrix.js +46 -0
  74. package/src/shade/renderer/animation/skin_normalize_mesh_frame.d.ts +70 -0
  75. package/src/shade/renderer/animation/skin_normalize_mesh_frame.d.ts.map +1 -0
  76. package/src/shade/renderer/animation/skin_normalize_mesh_frame.js +216 -0
  77. package/src/shade/renderer/animation/skin_test_fixtures.d.ts +86 -0
  78. package/src/shade/renderer/animation/skin_test_fixtures.d.ts.map +1 -0
  79. package/src/shade/renderer/animation/skin_test_fixtures.js +250 -0
  80. package/src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.d.ts +18 -4
  81. package/src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.d.ts.map +1 -1
  82. package/src/shade/renderer/animation/skinning/chunk_skin_blend_mesh_local.js +18 -4
  83. package/src/shade/renderer/particles/DESIGN.md +5 -5
  84. package/src/shade/renderer/particles/cull/chunk_particle_cull.d.ts.map +1 -1
  85. package/src/shade/renderer/particles/cull/chunk_particle_cull.js +3 -1
  86. package/src/shade/renderer/particles/data/chunk_particle_emitter.js +1 -1
  87. package/src/shade/renderer/particles/graph/ParticleNodeDescription.d.ts +43 -0
  88. package/src/shade/renderer/particles/graph/ParticleNodeDescription.d.ts.map +1 -0
  89. package/src/shade/renderer/particles/graph/ParticleNodeDescription.js +44 -0
  90. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.d.ts +33 -59
  91. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.d.ts.map +1 -1
  92. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.js +185 -118
  93. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts +15 -10
  94. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts.map +1 -1
  95. package/src/shade/renderer/particles/graph/compile_particle_graph.js +205 -170
  96. package/src/shade/renderer/particles/graph/particle_graph_authoring.d.ts +44 -0
  97. package/src/shade/renderer/particles/graph/particle_graph_authoring.d.ts.map +1 -0
  98. package/src/shade/renderer/particles/graph/particle_graph_authoring.js +108 -0
  99. package/src/shade/renderer/particles/isa/InstructionStream.d.ts +89 -0
  100. package/src/shade/renderer/particles/isa/InstructionStream.d.ts.map +1 -0
  101. package/src/shade/renderer/particles/isa/InstructionStream.js +162 -0
  102. package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts +1 -48
  103. package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts.map +1 -1
  104. package/src/shade/renderer/particles/isa/ParticleAssembler.js +25 -114
  105. package/src/shade/renderer/particles/particle_prototype.d.ts.map +1 -1
  106. package/src/shade/renderer/particles/particle_prototype.js +181 -180
  107. package/src/shade/renderer/particles/runtime/create_particle_effect.d.ts +4 -4
  108. package/src/shade/renderer/particles/runtime/create_particle_effect.d.ts.map +1 -1
  109. package/src/shade/renderer/particles/runtime/create_particle_effect.js +19 -19
  110. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.d.ts.map +1 -1
  111. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.js +4 -2
  112. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts +4 -4
  113. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts.map +1 -1
  114. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.js +7 -5
  115. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.d.ts.map +1 -1
  116. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.js +3 -1
  117. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  118. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +3 -1
  119. package/src/shade/renderer/particles/shaders/shader_particle_finalize.d.ts.map +1 -1
  120. package/src/shade/renderer/particles/shaders/shader_particle_finalize.js +5 -2
  121. package/src/shade/renderer/particles/shaders/shader_particle_render.d.ts.map +1 -1
  122. package/src/shade/renderer/particles/shaders/shader_particle_render.js +9 -2
  123. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  124. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +3 -1
  125. package/src/shade/renderer/particles/vm/chunk_particle_vm.js +1 -1
  126. package/src/shade/renderer/scene/SkinnedMesh.d.ts +11 -0
  127. package/src/shade/renderer/scene/SkinnedMesh.d.ts.map +1 -1
  128. package/src/shade/renderer/scene/SkinnedMesh.js +11 -0
  129. package/src/shade/renderer/shader/chunk/geometry/quaternion/dual/chunk_dual_quat_from_m4.d.ts +33 -3
  130. package/src/shade/renderer/shader/chunk/geometry/quaternion/dual/chunk_dual_quat_from_m4.d.ts.map +1 -1
  131. package/src/shade/renderer/shader/chunk/geometry/quaternion/dual/chunk_dual_quat_from_m4.js +58 -37
  132. package/src/shade/renderer/particles/graph/ParticleGraph.d.ts +0 -54
  133. package/src/shade/renderer/particles/graph/ParticleGraph.d.ts.map +0 -1
  134. package/src/shade/renderer/particles/graph/ParticleGraph.js +0 -99
  135. package/src/shade/renderer/particles/prototypeParticleSystem.d.ts +0 -2
  136. package/src/shade/renderer/particles/prototypeParticleSystem.d.ts.map +0 -1
  137. package/src/shade/renderer/particles/prototypeParticleSystem.js +0 -448
@@ -0,0 +1,553 @@
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 { NodeGraph } from "../../../core/model/node-graph/NodeGraph.js";
11
+ import { particle_node, particle_wire } from "../../renderer/particles/graph/particle_graph_authoring.js";
12
+ import { VM_BUILTIN, VM_RANDOM_MODE } from "../../renderer/particles/isa/ParticleVMISA.js";
13
+ import { ParticleLayout } from "../../renderer/particles/layout/ParticleLayout.js";
14
+ import { GPUParticleSystem } from "../../renderer/particles/GPUParticleSystem.js";
15
+ import { create_particle_effect } from "../../renderer/particles/runtime/create_particle_effect.js";
16
+ import { fire_effect } from "../../renderer/particles/particle_prototype.js";
17
+ import {
18
+ PARTICLE_BILLBOARD_RESOURCES,
19
+ create_particle_billboard_pipeline,
20
+ } from "../../renderer/particles/shaders/shader_particle_render.js";
21
+ import { shader_particle_emit } from "../../renderer/particles/shaders/shader_particle_emit.js";
22
+ import {
23
+ shader_particle_build_indirect,
24
+ shader_particle_reset_counters,
25
+ } from "../../renderer/particles/shaders/shader_particle_finalize.js";
26
+ import {
27
+ PARTICLE_SIMULATE_WORKGROUP_SIZE,
28
+ shader_particle_simulate,
29
+ } from "../../renderer/particles/shaders/shader_particle_simulate.js";
30
+
31
+ /**
32
+ * The scene the particle playground draws, with every reference to the page removed: it takes a
33
+ * {@link GraphicsContext} and a colour target, and encodes one frame into a command context.
34
+ *
35
+ * Separate from `main.js` so the whole frame — every pass, binding, buffer usage and immediate
36
+ * block — can be encoded against {@link ../../device/mock/SoftwareGPUDevice.js} in a vitest spec
37
+ * (see `particle_scene.spec.js`). That is the only tier available to CI here; the mock validates
38
+ * the WebGPU API surface but executes no shaders, so what a spec can prove is that the frame is
39
+ * *legal*, not what it looks like. The picture is the browser's job.
40
+ *
41
+ * What the scene contains:
42
+ *
43
+ * - lit solid geometry (a ground plane + boxes) rendered into a reverse-Z depth buffer,
44
+ * - four emitters with distinct settings — an additive fire, an alpha soft-depth smoke column,
45
+ * additive velocity-stretched sparks, and a wide soft-depth dust drift,
46
+ * - a billboard pass that depth-tests against the scene, so particles are occluded by the boxes and
47
+ * soft-depth emitters fade as they approach surfaces.
48
+ *
49
+ * The loop is fully GPU-driven: the simulate dispatch runs via `dispatchWorkgroupsIndirect` and the
50
+ * billboard draw via `drawIndirect`, both from args the build-indirect pass writes on the GPU. No
51
+ * CPU readback participates in sizing anything — a stale CPU count would orphan alive-list entries
52
+ * when the population grows and double-free slots when it shrinks.
53
+ */
54
+
55
+ /** Maximum simultaneous particles across every emitter — they share one pool. */
56
+ const PARTICLE_CAPACITY = 1 << 15; // 32k
57
+
58
+ /** Camera near plane. Also the numerator of the reverse-Z depth linearisation (see {@link write_camera}). */
59
+ const PARTICLE_NEAR = 0.2;
60
+
61
+ /**
62
+ * The largest immediate block any of the three compute passes declares (emit's 20-byte settings
63
+ * struct). Immediate data is an optional WebGPU capability, so callers check for it up front rather
64
+ * than letting pipeline creation fail with an error that names neither pass nor feature.
65
+ */
66
+ export const PARTICLE_IMMEDIATE_BYTES = 20;
67
+
68
+ /** Bytes of stats {@link ParticleScene#encode_stats_copy} writes: 8 counters, then both arg buffers. */
69
+ export const PARTICLE_STATS_BYTES = 64;
70
+
71
+ const STATS_OFFSET_DISPATCH_ARGS = 32;
72
+ const STATS_OFFSET_DRAW_ARGS = 48;
73
+
74
+ /**
75
+ * @typedef {object} ParticleSceneStats
76
+ * @property {number} alive particles alive at the end of the captured frame
77
+ * @property {number} dead free slots left in the pool
78
+ * @property {number} alive_in the snapshot next frame's simulate reads as its input length
79
+ * @property {number[]} dispatch simulate's indirect dispatch args, `[x, y, z]` workgroups
80
+ * @property {number[]} draw the billboard draw's indirect args, `[vertices, instances]`
81
+ */
82
+
83
+ /**
84
+ * Build the scene against a live (or mock) device.
85
+ *
86
+ * @param {GraphicsContext} graphics
87
+ * @param {object} options
88
+ * @param {GPUTextureFormat} options.color_format format of the colour target frames are encoded into
89
+ * @param {number} [options.capacity] particle pool size
90
+ * @returns {object} the scene, see the returned object's own members
91
+ */
92
+ export function create_particle_scene(graphics, { color_format, capacity = PARTICLE_CAPACITY }) {
93
+ const device = graphics.device;
94
+
95
+ // --- emitters: four, each with different settings -----------------------------------------
96
+ const system = new GPUParticleSystem(graphics, { capacity });
97
+
98
+ // fire — additive, billboard, at the base of the tall pillar
99
+ system.addEmitter({
100
+ ...fire_effect().emitter,
101
+ spawn_rate: 500, flipbook: [1, 1], atlas_region: [0, 0, 1, 1],
102
+ flags: { blend: EMITTER_BLEND.ADDITIVE, projection: EMITTER_PROJECTION.BILLBOARD },
103
+ transform: translation(-1, 0, 0),
104
+ });
105
+ // smoke — alpha, soft-depth, rising column above the fire
106
+ system.addEmitter({
107
+ 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] }),
108
+ spawn_rate: 60, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
109
+ flags: { blend: EMITTER_BLEND.ALPHA, projection: EMITTER_PROJECTION.BILLBOARD, soft_depth: true },
110
+ render: { position: "position", size: "size", color: "color" },
111
+ transform: translation(-1, 2.5, 0),
112
+ });
113
+ // sparks — additive, velocity-stretched, fast, short-lived, gravity, near the box
114
+ system.addEmitter({
115
+ 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] }),
116
+ spawn_rate: 350, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
117
+ flags: { blend: EMITTER_BLEND.ADDITIVE, projection: EMITTER_PROJECTION.STRETCHED },
118
+ render: { position: "position", size: "size", color: "color", velocity: "velocity" },
119
+ transform: translation(2, 1.9, -1),
120
+ });
121
+ // dust — alpha, soft-depth, slow wide drift across the ground
122
+ system.addEmitter({
123
+ 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] }),
124
+ spawn_rate: 120, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
125
+ flags: { blend: EMITTER_BLEND.ALPHA, projection: EMITTER_PROJECTION.BILLBOARD, soft_depth: true },
126
+ render: { position: "position", size: "size", color: "color" },
127
+ transform: translation(0, 0.3, 0),
128
+ });
129
+
130
+ const built = system.buildEmitterData();
131
+
132
+ // --- particle GPU buffers -----------------------------------------------------------------
133
+ const storage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
134
+ const pool = device.createBuffer({ label: "particles/pool", size: capacity * PARTICLE_RECORD_WORD_COUNT * 4, usage: storage });
135
+ const alive_a = device.createBuffer({ label: "particles/alive_a", size: capacity * 4, usage: storage });
136
+ const alive_b = device.createBuffer({ label: "particles/alive_b", size: capacity * 4, usage: storage });
137
+ const dead = device.createBuffer({ label: "particles/dead", size: capacity * 4, usage: storage });
138
+ const counters = device.createBuffer({ label: "particles/counters", size: PARTICLE_COUNTER_COUNT * 4, usage: storage });
139
+ const spawn_requests = device.createBuffer({ label: "particles/spawn_requests", size: capacity * 4, usage: storage });
140
+ const emitters = data_buffer(device, "particles/emitters", built.emitters, storage);
141
+ const program = data_buffer(device, "particles/program", built.program, storage);
142
+ const constants = data_buffer(device, "particles/constants", built.constants, storage);
143
+
144
+ // Indirect args, written on-GPU by the build-indirect pass. Zero-initialised (WebGPU spec), so
145
+ // the first frame's indirect dispatch/draw are no-ops until the first snapshot lands. COPY_SRC
146
+ // only so the stats copy can *display* them; nothing read back here sizes a pass.
147
+ const args_usage = GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_SRC;
148
+ const dispatch_args = device.createBuffer({ label: "particles/dispatch_args", size: 3 * 4, usage: args_usage });
149
+ const draw_args = device.createBuffer({ label: "particles/draw_args", size: 4 * 4, usage: args_usage });
150
+
151
+ const initial = system.initialFreeList();
152
+ device.queue.writeBuffer(dead, 0, initial.dead);
153
+ device.queue.writeBuffer(counters, 0, initial.counters);
154
+
155
+ // --- camera + geometry --------------------------------------------------------------------
156
+ const camera_buffer = device.createBuffer({ label: "particles/camera", size: CAMERA_UNIFORM_STRUCT.aligned_size, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
157
+ const vp_buffer = device.createBuffer({ label: "particles/vp", size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
158
+
159
+ const geometry = build_geometry();
160
+ const geometry_buffer = device.createBuffer({ label: "particles/geometry", size: geometry.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
161
+ device.queue.writeBuffer(geometry_buffer, 0, geometry);
162
+ const geometry_vertex_count = geometry.length / GEOMETRY_FLOATS_PER_VERTEX;
163
+ const { geometry_pipeline, geometry_bind_group } = make_geometry_pipeline(device, color_format, vp_buffer);
164
+
165
+ // atlas sprite — soft round falloff — and the filtering sampler the billboard shader declares
166
+ const atlas_view = make_sprite_texture(device).createView();
167
+ const atlas_sampler = device.createSampler({ magFilter: "linear", minFilter: "linear", addressModeU: "clamp-to-edge", addressModeV: "clamp-to-edge" });
168
+
169
+ // billboard pipeline: reverse-Z depth test (same compare as the geometry pass), no write;
170
+ // soft-depth reads the scene depth texture
171
+ const billboard_pipeline = create_particle_billboard_pipeline({
172
+ color_format,
173
+ depth_format: DEPTH_FORMAT,
174
+ depth_compare: "greater",
175
+ });
176
+
177
+ // reverse-Z depth buffer, recreated on resize
178
+ let depth_texture = null;
179
+ let depth_view = null;
180
+ let depth_width = 0;
181
+ let depth_height = 0;
182
+
183
+ function ensure_depth(width, height) {
184
+ if (depth_view !== null && depth_width === width && depth_height === height) {
185
+ return;
186
+ }
187
+
188
+ depth_texture?.destroy();
189
+ depth_texture = device.createTexture({
190
+ label: "particles/scene_depth",
191
+ size: [width, height],
192
+ format: DEPTH_FORMAT,
193
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
194
+ });
195
+ depth_view = depth_texture.createView();
196
+ depth_width = width;
197
+ depth_height = height;
198
+ }
199
+
200
+ let alive_read = alive_a;
201
+ let alive_write = alive_b;
202
+
203
+ return {
204
+ system,
205
+ capacity,
206
+
207
+ /** Buffers a harness may inspect. Not for the frame loop, which never reads them back. */
208
+ buffers: { pool, alive_a, alive_b, dead, counters, spawn_requests, emitters, program, constants, dispatch_args, draw_args },
209
+
210
+ /**
211
+ * Encode one whole frame: geometry, then emit + simulate + build-indirect, then the
212
+ * billboard draw. Does not submit — the caller finishes the context.
213
+ *
214
+ * @param {ShadeGPUCommandContext} cmd
215
+ * @param {object} frame
216
+ * @param {GPUTextureView} frame.color_view where the frame is drawn
217
+ * @param {number} frame.width colour target width, in texels
218
+ * @param {number} frame.height colour target height, in texels
219
+ * @param {number} frame.dt seconds since the previous frame
220
+ * @param {number} frame.time seconds since start, for the camera orbit and the VM's clock
221
+ */
222
+ encode_frame(cmd, { color_view, width, height, dt, time }) {
223
+ ensure_depth(width, height);
224
+ write_camera(device, camera_buffer, vp_buffer, width / height, time);
225
+
226
+ const requests = system.update(dt);
227
+ if (requests.length > 0) {
228
+ device.queue.writeBuffer(spawn_requests, 0, requests);
229
+ }
230
+
231
+ // --- geometry pass: clear colour + reverse-Z depth (far = 0), lit, writes depth ---
232
+ const geometry_pass = cmd.beginRenderPass({
233
+ label: "particles/geometry",
234
+ colorAttachments: [{ view: color_view, loadOp: "clear", storeOp: "store", clearValue: { r: 0.03, g: 0.035, b: 0.05, a: 1 } }],
235
+ depthStencilAttachment: { view: depth_view, depthLoadOp: "clear", depthStoreOp: "store", depthClearValue: 0.0 },
236
+ });
237
+ geometry_pass.setPipeline(geometry_pipeline);
238
+ geometry_pass.setBindGroup(0, geometry_bind_group);
239
+ geometry_pass.setVertexBuffer(0, geometry_buffer);
240
+ geometry_pass.draw(geometry_vertex_count);
241
+ geometry_pass.end();
242
+
243
+ // --- emit + simulate (simulate is sized by LAST frame's GPU-written dispatch args) ---
244
+ shader_particle_reset_counters.dispatch({ encoder: cmd, bindings: { counters }, group_count_x: 1 });
245
+
246
+ if (requests.length > 0) {
247
+ shader_particle_emit.dispatchThreads({
248
+ encoder: cmd,
249
+ bindings: {
250
+ emitters, program, vm_constants: constants, pool, spawn_requests,
251
+ alive_out: alive_write, dead, counters,
252
+ settings: { u_dt: dt, u_time: time, u_frame: system.frame, u_count: requests.length, u_capacity: capacity },
253
+ },
254
+ thread_count_x: requests.length,
255
+ });
256
+ }
257
+
258
+ shader_particle_simulate.dispatchIndirect({
259
+ encoder: cmd,
260
+ bindings: {
261
+ emitters, program, vm_constants: constants, pool,
262
+ alive_in: alive_read, alive_out: alive_write, dead, counters,
263
+ settings: { u_dt: dt, u_time: time, u_frame: system.frame },
264
+ },
265
+ command: dispatch_args,
266
+ });
267
+
268
+ // Snapshot the final alive count (next frame's simulate input) + write this frame's draw args.
269
+ shader_particle_build_indirect.dispatch({
270
+ encoder: cmd,
271
+ bindings: { counters, dispatch_args, draw_args, u_workgroup_size: PARTICLE_SIMULATE_WORKGROUP_SIZE },
272
+ group_count_x: 1,
273
+ });
274
+
275
+ // --- billboard pass: load colour, depth-test against the scene (read-only), indirect draw ---
276
+ const bindings = PARTICLE_BILLBOARD_RESOURCES.generateBindings({
277
+ camera: camera_buffer, pool, sorted: alive_write, emitters,
278
+ atlas: atlas_view, atlas_sampler, scene_depth: depth_view,
279
+ }, cmd);
280
+
281
+ const billboard_pass = cmd.constructRenderPass({
282
+ pipeline: billboard_pipeline,
283
+ bindings,
284
+ colorAttachments: [{ view: color_view, loadOp: "load", storeOp: "store" }],
285
+ depthStencilAttachment: { view: depth_view, depthReadOnly: true },
286
+ });
287
+ billboard_pass.drawIndirect(draw_args, 0);
288
+ billboard_pass.end();
289
+
290
+ const previous = alive_read;
291
+ alive_read = alive_write;
292
+ alive_write = previous;
293
+ },
294
+
295
+ /**
296
+ * Copy the counters and both indirect arg buffers into a {@link PARTICLE_STATS_BYTES}-byte
297
+ * MAP_READ destination, for display. Read back to *look at*: nothing here may size a pass.
298
+ *
299
+ * @param {ShadeGPUCommandContext} cmd
300
+ * @param {GPUBuffer} destination
301
+ */
302
+ encode_stats_copy(cmd, destination) {
303
+ cmd.copyBufferToBuffer(counters, 0, destination, 0, PARTICLE_COUNTER_COUNT * 4);
304
+ cmd.copyBufferToBuffer(dispatch_args, 0, destination, STATS_OFFSET_DISPATCH_ARGS, 3 * 4);
305
+ cmd.copyBufferToBuffer(draw_args, 0, destination, STATS_OFFSET_DRAW_ARGS, 4 * 4);
306
+ },
307
+
308
+ /**
309
+ * Interpret the bytes {@link encode_stats_copy} wrote.
310
+ *
311
+ * @param {ArrayBuffer} bytes
312
+ * @returns {ParticleSceneStats}
313
+ */
314
+ read_stats(bytes) {
315
+ const words = new Uint32Array(bytes);
316
+ const dispatch_base = STATS_OFFSET_DISPATCH_ARGS / 4;
317
+ const draw_base = STATS_OFFSET_DRAW_ARGS / 4;
318
+
319
+ return {
320
+ alive: words[0],
321
+ dead: words[1],
322
+ alive_in: words[4],
323
+ dispatch: [words[dispatch_base], words[dispatch_base + 1], words[dispatch_base + 2]],
324
+ draw: [words[draw_base], words[draw_base + 1]],
325
+ };
326
+ },
327
+ };
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------------------------
331
+ // Effect authoring
332
+ // ---------------------------------------------------------------------------------------------
333
+
334
+ /**
335
+ * A parametric point-emission effect: emit with a base velocity + jitter from a disk, integrate
336
+ * under a vertical acceleration, and die at end of life. Only nAge.x is consumed (kill), so the
337
+ * NaN lanes from the scalar age/lifetime divide are harmless.
338
+ */
339
+ function make_effect({ velocity, velocityJitter = 0, posJitter = 0, accel = 0, life = [1, 2], size = 1, color = [1, 1, 1, 1] }) {
340
+ const layout = new ParticleLayout([
341
+ { name: "position", components: 3 }, { name: "velocity", components: 3 },
342
+ { name: "age", components: 1 }, { name: "lifetime", components: 1 },
343
+ { name: "size", components: 1 }, { name: "color", components: 4 },
344
+ ]);
345
+
346
+ const spawn = new NodeGraph();
347
+ let posN = particle_node(spawn, "builtin", { id: VM_BUILTIN.EMITTER_POSITION });
348
+ if (posJitter > 0) {
349
+ const d = particle_node(spawn, "random", { mode: VM_RANDOM_MODE.UNIT_DISK });
350
+ const j = particle_node(spawn, "scale"); particle_wire(spawn, j, "v", d); particle_wire(spawn, j, "s", [posJitter]);
351
+ const p = particle_node(spawn, "add"); particle_wire(spawn, p, "a", posN); particle_wire(spawn, p, "b", j); posN = p;
352
+ }
353
+ particle_wire(spawn, particle_node(spawn, "setAttribute", { name: "position" }), "value", posN);
354
+
355
+ let velN = particle_node(spawn, "const", { value: [velocity[0], velocity[1], velocity[2], 0] });
356
+ if (velocityJitter > 0) {
357
+ const r = particle_node(spawn, "random", { mode: VM_RANDOM_MODE.UNIFORM_SIGNED });
358
+ const rs = particle_node(spawn, "scale"); particle_wire(spawn, rs, "v", r); particle_wire(spawn, rs, "s", [velocityJitter]);
359
+ const vv = particle_node(spawn, "add"); particle_wire(spawn, vv, "a", velN); particle_wire(spawn, vv, "b", rs); velN = vv;
360
+ }
361
+ particle_wire(spawn, particle_node(spawn, "setAttribute", { name: "velocity" }), "value", velN);
362
+ particle_wire(spawn, particle_node(spawn, "setAttribute", { name: "age" }), "value", [0]);
363
+ const lr = particle_node(spawn, "random", { mode: VM_RANDOM_MODE.UNIFORM });
364
+ const lm = particle_node(spawn, "mad"); particle_wire(spawn, lm, "a", lr); particle_wire(spawn, lm, "b", [life[1] - life[0]]); particle_wire(spawn, lm, "c", [life[0]]);
365
+ particle_wire(spawn, particle_node(spawn, "setAttribute", { name: "lifetime" }), "value", lm);
366
+ particle_wire(spawn, particle_node(spawn, "setAttribute", { name: "size" }), "value", [size]);
367
+ particle_wire(spawn, particle_node(spawn, "setAttribute", { name: "color" }), "value", color);
368
+
369
+ const update = new NodeGraph();
370
+ const dt = particle_node(update, "builtin", { id: VM_BUILTIN.DELTA_TIME });
371
+ const age = particle_node(update, "attribute", { name: "age" });
372
+ const nage = particle_node(update, "add"); particle_wire(update, nage, "a", age); particle_wire(update, nage, "b", dt);
373
+ particle_wire(update, particle_node(update, "setAttribute", { name: "age" }), "value", nage);
374
+ const lifeA = particle_node(update, "attribute", { name: "lifetime" });
375
+ const na = particle_node(update, "div"); particle_wire(update, na, "a", nage); particle_wire(update, na, "b", lifeA);
376
+ const dead = particle_node(update, "compare", { op: "ge" }); particle_wire(update, dead, "a", na); particle_wire(update, dead, "b", [1]);
377
+ particle_wire(update, particle_node(update, "kill"), "condition", dead);
378
+ let velN2 = particle_node(update, "attribute", { name: "velocity" });
379
+ if (accel !== 0) {
380
+ const g = particle_node(update, "const", { value: [0, accel, 0, 0] });
381
+ const gd = particle_node(update, "scale"); particle_wire(update, gd, "v", g); particle_wire(update, gd, "s", dt);
382
+ const nv = particle_node(update, "add"); particle_wire(update, nv, "a", velN2); particle_wire(update, nv, "b", gd);
383
+ particle_wire(update, particle_node(update, "setAttribute", { name: "velocity" }), "value", nv); velN2 = nv;
384
+ }
385
+ const pos = particle_node(update, "attribute", { name: "position" });
386
+ const pd = particle_node(update, "scale"); particle_wire(update, pd, "v", velN2); particle_wire(update, pd, "s", dt);
387
+ const np = particle_node(update, "add"); particle_wire(update, np, "a", pos); particle_wire(update, np, "b", pd);
388
+ particle_wire(update, particle_node(update, "setAttribute", { name: "position" }), "value", np);
389
+
390
+ return create_particle_effect({ layout, spawn, update });
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------------------------
394
+ // Geometry + camera + helpers
395
+ // ---------------------------------------------------------------------------------------------
396
+
397
+ const DEPTH_FORMAT = "depth32float";
398
+
399
+ /** Interleaved `[x, y, z, nx, ny, nz]`. */
400
+ const GEOMETRY_FLOATS_PER_VERTEX = 6;
401
+
402
+ const GEOM_WGSL = /* wgsl */ `
403
+ @group(0) @binding(0) var<uniform> vp : mat4x4<f32>;
404
+ struct VOut { @builtin(position) clip : vec4<f32>, @location(0) normal : vec3<f32> }
405
+ @vertex fn vs(@location(0) pos : vec3<f32>, @location(1) normal : vec3<f32>) -> VOut {
406
+ var o : VOut;
407
+ o.clip = vp * vec4<f32>(pos, 1.0);
408
+ o.normal = normal;
409
+ return o;
410
+ }
411
+ @fragment fn fs(in : VOut) -> @location(0) vec4<f32> {
412
+ let N = normalize(in.normal);
413
+ let L = normalize(vec3<f32>(0.35, 1.0, 0.4));
414
+ let diffuse = max(dot(N, L), 0.0);
415
+ let albedo = select(vec3<f32>(0.30, 0.32, 0.36), vec3<f32>(0.22, 0.24, 0.28), N.y < 0.9);
416
+ let color = albedo * (0.18 + diffuse * 0.9);
417
+ return vec4<f32>(color, 1.0);
418
+ }
419
+ `;
420
+
421
+ function make_geometry_pipeline(device, format, vp_buffer) {
422
+ const module = device.createShaderModule({ label: "particles/geometry", code: GEOM_WGSL });
423
+ const layout = device.createBindGroupLayout({
424
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
425
+ });
426
+
427
+ const geometry_pipeline = device.createRenderPipeline({
428
+ label: "particles/geometry",
429
+ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
430
+ vertex: {
431
+ module,
432
+ entryPoint: "vs",
433
+ buffers: [{
434
+ arrayStride: GEOMETRY_FLOATS_PER_VERTEX * 4,
435
+ attributes: [
436
+ { shaderLocation: 0, offset: 0, format: "float32x3" },
437
+ { shaderLocation: 1, offset: 12, format: "float32x3" },
438
+ ],
439
+ }],
440
+ },
441
+ fragment: { module, entryPoint: "fs", targets: [{ format }] },
442
+ primitive: { topology: "triangle-list", cullMode: "none" },
443
+ depthStencil: { format: DEPTH_FORMAT, depthWriteEnabled: true, depthCompare: "greater" }, // reverse-Z
444
+ });
445
+
446
+ const geometry_bind_group = device.createBindGroup({ layout, entries: [{ binding: 0, resource: { buffer: vp_buffer } }] });
447
+
448
+ return { geometry_pipeline, geometry_bind_group };
449
+ }
450
+
451
+ /** Ground plane + a few boxes as an interleaved `[x,y,z, nx,ny,nz]` vertex array. */
452
+ function build_geometry() {
453
+ const v = [];
454
+ 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]); };
455
+ const box = (cx, cy, cz, sx, sy, sz) => {
456
+ 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;
457
+ quad([x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [0, 0, 1]);
458
+ quad([x1, y0, z0], [x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [0, 0, -1]);
459
+ quad([x1, y0, z1], [x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [1, 0, 0]);
460
+ quad([x0, y0, z0], [x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [-1, 0, 0]);
461
+ quad([x0, y1, z1], [x1, y1, z1], [x1, y1, z0], [x0, y1, z0], [0, 1, 0]);
462
+ quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [0, -1, 0]);
463
+ };
464
+ quad([-15, 0, -15], [15, 0, -15], [15, 0, 15], [-15, 0, 15], [0, 1, 0]); // ground
465
+ box(-1, 1.5, 0, 1, 3, 1); // tall pillar next to the fire
466
+ box(2, 0.9, -1, 1.6, 1.8, 1.6); // block near the sparks
467
+ box(0, 0.6, 3.5, 7, 1.2, 0.6); // low wall the dust drifts against
468
+ return new Float32Array(v);
469
+ }
470
+
471
+ // Scratch for write_camera. One scene drives one camera, and the loop runs every frame, so these
472
+ // are reused rather than reallocated 60 times a second.
473
+ const camera_view = m4_allocate();
474
+ const camera_world = m4_allocate();
475
+ const camera_projection = m4_allocate();
476
+ const camera_view_projection = m4_allocate();
477
+ const camera_bytes = new Float32Array(CAMERA_UNIFORM_STRUCT.aligned_size / 4);
478
+
479
+ /**
480
+ * Reverse-Z, infinite-far perspective (WebGPU 0..1 depth: near -> 1, far -> 0).
481
+ *
482
+ * @param {Float32Array} out column-major 4x4
483
+ * @param {number} fov_y vertical field of view, radians
484
+ * @param {number} aspect width / height
485
+ * @param {number} near near plane distance
486
+ * @returns {Float32Array} `out`
487
+ */
488
+ function perspective_reverse_z(out, fov_y, aspect, near) {
489
+ const f = 1 / Math.tan(fov_y / 2);
490
+
491
+ out.fill(0);
492
+ out[0] = f / aspect;
493
+ out[5] = f;
494
+ out[11] = -1;
495
+ out[14] = near;
496
+
497
+ return out;
498
+ }
499
+
500
+ /**
501
+ * Orbit the camera and upload both camera buffers: the engine's full camera struct for the
502
+ * billboard shader, and a bare view-projection for the geometry pass.
503
+ */
504
+ function write_camera(device, camera_buffer, vp_buffer, aspect, t) {
505
+ const radius = 12, height = 5.5;
506
+ const eye = [Math.cos(t * 0.22) * radius, height, Math.sin(t * 0.22) * radius];
507
+ const center = [0, 1.6, 0];
508
+
509
+ const view = m4_look_at(camera_view, eye, center, [0, 1, 0]);
510
+ // A look-at basis is orthonormal plus a translation, so this never reports singular.
511
+ m4_invert(camera_world, view);
512
+ const world = camera_world;
513
+ const projection = perspective_reverse_z(camera_projection, (58 * Math.PI) / 180, aspect, PARTICLE_NEAR);
514
+ const view_projection = m4_multiply(camera_view_projection, projection, view);
515
+
516
+ const put = (field, m) => camera_bytes.set(m, CAMERA_UNIFORM_STRUCT.get(field).offset / 4);
517
+ put("transform", world);
518
+ put("view_matrix", view);
519
+ put("projection_matrix", projection);
520
+ put("view_projection_matrix", view_projection);
521
+ // Reverse-Z infinite-far linearisation: view_depth = A / (device_depth + B), with A=near, B=0.
522
+ camera_bytes.set([0, PARTICLE_NEAR, 0, 0], CAMERA_UNIFORM_STRUCT.get("device_depth_to_view_space").offset / 4);
523
+
524
+ device.queue.writeBuffer(camera_buffer, 0, camera_bytes);
525
+ device.queue.writeBuffer(vp_buffer, 0, view_projection);
526
+ }
527
+
528
+ function translation(x, y, z) {
529
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
530
+ }
531
+
532
+ function data_buffer(device, label, data, usage) {
533
+ const buffer = device.createBuffer({ label, size: Math.max(data.byteLength, 4), usage });
534
+ device.queue.writeBuffer(buffer, 0, data);
535
+ return buffer;
536
+ }
537
+
538
+ /** A 64x64 radial-falloff sprite (white, alpha falloff) for soft additive/alpha blobs. */
539
+ function make_sprite_texture(device) {
540
+ const N = 64;
541
+ const px = new Uint8Array(N * N * 4);
542
+ for (let y = 0; y < N; y++) {
543
+ for (let x = 0; x < N; x++) {
544
+ const dx = (x + 0.5) / N * 2 - 1, dy = (y + 0.5) / N * 2 - 1;
545
+ const a = Math.max(0, 1 - Math.hypot(dx, dy));
546
+ const i = (y * N + x) * 4;
547
+ px[i] = 255; px[i + 1] = 255; px[i + 2] = 255; px[i + 3] = Math.round(a * a * 255);
548
+ }
549
+ }
550
+ const tex = device.createTexture({ label: "particles/sprite", size: [N, N], format: "rgba8unorm", usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
551
+ device.queue.writeTexture({ texture: tex }, px, { bytesPerRow: N * 4, rowsPerImage: N }, [N, N]);
552
+ return tex;
553
+ }