@woosh/meep-engine 3.0.0 → 3.0.1

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 (122) hide show
  1. package/editor/particles/rebuildParticleEmitter.js +8 -26
  2. package/editor/process/symbolic/CameraSymbolicDisplay.js +146 -146
  3. package/editor/tools/SelectionTool.js +1 -2
  4. package/editor/tools/TransformToolV2.js +2 -3
  5. package/editor/tools/v2/BlenderCameraOrientationGizmo.js +501 -501
  6. package/editor/tools/v2/TransformControls.js +1 -1
  7. package/package.json +95 -96
  8. package/src/core/geom/3d/mat4/m4_look_at.d.ts +25 -0
  9. package/src/core/geom/3d/mat4/m4_look_at.d.ts.map +1 -0
  10. package/src/core/geom/3d/mat4/m4_look_at.js +96 -0
  11. package/src/core/geom/3d/mat4/m4_make_rotation_x.d.ts +18 -0
  12. package/src/core/geom/3d/mat4/m4_make_rotation_x.d.ts.map +1 -0
  13. package/src/core/geom/3d/mat4/m4_make_rotation_x.js +47 -0
  14. package/src/core/geom/3d/mat4/m4_make_rotation_y.d.ts +18 -0
  15. package/src/core/geom/3d/mat4/m4_make_rotation_y.d.ts.map +1 -0
  16. package/src/core/geom/3d/mat4/m4_make_rotation_y.js +47 -0
  17. package/src/core/geom/3d/mat4/m4_make_rotation_z.d.ts +18 -0
  18. package/src/core/geom/3d/mat4/m4_make_rotation_z.d.ts.map +1 -0
  19. package/src/core/geom/3d/mat4/m4_make_rotation_z.js +47 -0
  20. package/src/core/geom/3d/mat4/m4_perspective.d.ts +25 -0
  21. package/src/core/geom/3d/mat4/m4_perspective.d.ts.map +1 -0
  22. package/src/core/geom/3d/mat4/m4_perspective.js +66 -0
  23. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts.map +1 -1
  24. package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js +2045 -2052
  25. package/src/engine/ecs/gui/hud/testHudEdgeStick.js +2 -2
  26. package/src/engine/ecs/terrain/ecs/TerrainSystem.d.ts.map +1 -1
  27. package/src/engine/ecs/terrain/ecs/TerrainSystem.js +14 -27
  28. package/src/engine/ecs/terrain/util/loadVisibleTerrainTiles.d.ts.map +1 -1
  29. package/src/engine/ecs/terrain/util/loadVisibleTerrainTiles.js +129 -126
  30. package/src/engine/graphics/ecs/camera/Camera.d.ts +36 -48
  31. package/src/engine/graphics/ecs/camera/Camera.d.ts.map +1 -1
  32. package/src/engine/graphics/ecs/camera/Camera.js +165 -303
  33. package/src/engine/graphics/ecs/camera/camera_active_frustum.d.ts +14 -0
  34. package/src/engine/graphics/ecs/camera/camera_active_frustum.d.ts.map +1 -0
  35. package/src/engine/graphics/ecs/camera/camera_active_frustum.js +25 -0
  36. package/src/engine/graphics/ecs/camera/camera_find_active.d.ts +16 -0
  37. package/src/engine/graphics/ecs/camera/camera_find_active.d.ts.map +1 -0
  38. package/src/engine/graphics/ecs/camera/camera_find_active.js +28 -0
  39. package/src/engine/graphics/ecs/camera/camera_traverse_active.d.ts +12 -0
  40. package/src/engine/graphics/ecs/camera/camera_traverse_active.d.ts.map +1 -0
  41. package/src/engine/graphics/ecs/camera/camera_traverse_active.js +18 -0
  42. package/src/engine/graphics/ecs/camera/pp/PerfectPanner.js +170 -170
  43. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.d.ts +5 -1
  44. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.d.ts.map +1 -1
  45. package/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.js +296 -281
  46. package/src/engine/graphics/ecs/render_layers_compute_depth_range.d.ts +1 -1
  47. package/src/engine/graphics/ecs/render_layers_compute_depth_range.js +1 -1
  48. package/src/engine/graphics/geometry/AttributeData.d.ts +52 -0
  49. package/src/engine/graphics/geometry/AttributeData.d.ts.map +1 -0
  50. package/src/engine/graphics/geometry/AttributeData.js +84 -0
  51. package/src/engine/graphics/particles/particular/engine/ParticularEngine.d.ts +1 -14
  52. package/src/engine/graphics/particles/particular/engine/ParticularEngine.d.ts.map +1 -1
  53. package/src/engine/graphics/particles/particular/engine/ParticularEngine.js +229 -253
  54. package/src/engine/graphics/particles/particular/engine/emitter/ParticleEmitter.d.ts +25 -12
  55. package/src/engine/graphics/particles/particular/engine/emitter/ParticleEmitter.d.ts.map +1 -1
  56. package/src/engine/graphics/particles/particular/engine/emitter/ParticleEmitter.js +1149 -1194
  57. package/src/engine/graphics/particles/particular/engine/emitter/ParticlePool.d.ts +4 -8
  58. package/src/engine/graphics/particles/particular/engine/emitter/ParticlePool.d.ts.map +1 -1
  59. package/src/engine/graphics/particles/particular/engine/emitter/ParticlePool.js +472 -540
  60. package/src/engine/graphics/particles/particular/engine/shader/ShaderManager.d.ts +19 -90
  61. package/src/engine/graphics/particles/particular/engine/shader/ShaderManager.d.ts.map +1 -1
  62. package/src/engine/graphics/particles/particular/engine/shader/ShaderManager.js +135 -514
  63. package/src/engine/graphics3/CameraSystem3.d.ts.map +1 -1
  64. package/src/engine/graphics3/CameraSystem3.js +10 -1
  65. package/src/engine/graphics3/GraphicsEngine3.d.ts +3 -3
  66. package/src/engine/graphics3/GraphicsEngine3.js +3 -3
  67. package/src/engine/graphics3/ParticleEmitterSystem3.d.ts +11 -0
  68. package/src/engine/graphics3/ParticleEmitterSystem3.d.ts.map +1 -1
  69. package/src/engine/graphics3/ParticleEmitterSystem3.js +289 -265
  70. package/src/engine/graphics3/ShadeCameraAdapter.d.ts +3 -3
  71. package/src/engine/graphics3/ShadeCameraAdapter.js +3 -3
  72. package/src/engine/graphics3/camera_sync_from_transform.d.ts +2 -2
  73. package/src/engine/graphics3/camera_sync_from_transform.js +2 -2
  74. package/src/engine/graphics3/shade_camera_projection_ray.js +2 -2
  75. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  76. package/src/shade/renderer/camera/Camera.d.ts +3 -5
  77. package/src/shade/renderer/camera/Camera.d.ts.map +1 -1
  78. package/src/shade/renderer/camera/Camera.js +281 -280
  79. package/src/shade/renderer/camera/GPUCameraContext.js +233 -233
  80. package/src/shade/renderer/loader/usd/parse_usda.js +1 -1
  81. package/src/shade/renderer/loader/usd/usd_build_scene.d.ts.map +1 -1
  82. package/src/shade/renderer/loader/usd/usd_build_scene.js +658 -652
  83. package/src/shade/renderer/loader/usd/usd_compose_transform.d.ts.map +1 -1
  84. package/src/shade/renderer/loader/usd/usd_compose_transform.js +58 -23
  85. package/src/shade/renderer/particles/prototypeParticleSystem.js +450 -443
  86. package/src/shade/renderer/shadow/map/DirectionalLightShadowmap.d.ts.map +1 -1
  87. package/src/shade/renderer/shadow/map/DirectionalLightShadowmap.js +7 -3
  88. package/src/view/minimap/dom/MinimapCameraView.d.ts +2 -2
  89. package/src/view/minimap/dom/MinimapCameraView.js +206 -206
  90. package/src/engine/graphics/ecs/camera/CameraSystem.d.ts +0 -74
  91. package/src/engine/graphics/ecs/camera/CameraSystem.d.ts.map +0 -1
  92. package/src/engine/graphics/ecs/camera/CameraSystem.js +0 -331
  93. package/src/engine/graphics/ecs/camera/auto_set_camera_clipping_planes.d.ts +0 -10
  94. package/src/engine/graphics/ecs/camera/auto_set_camera_clipping_planes.d.ts.map +0 -1
  95. package/src/engine/graphics/ecs/camera/auto_set_camera_clipping_planes.js +0 -105
  96. package/src/engine/graphics/ecs/camera/build_three_camera_object.d.ts +0 -7
  97. package/src/engine/graphics/ecs/camera/build_three_camera_object.d.ts.map +0 -1
  98. package/src/engine/graphics/ecs/camera/build_three_camera_object.js +0 -29
  99. package/src/engine/graphics/ecs/camera/frustum_from_camera.d.ts +0 -8
  100. package/src/engine/graphics/ecs/camera/frustum_from_camera.d.ts.map +0 -1
  101. package/src/engine/graphics/ecs/camera/frustum_from_camera.js +0 -20
  102. package/src/engine/graphics/ecs/camera/set_camera_aspect_ratio.d.ts +0 -9
  103. package/src/engine/graphics/ecs/camera/set_camera_aspect_ratio.d.ts.map +0 -1
  104. package/src/engine/graphics/ecs/camera/set_camera_aspect_ratio.js +0 -46
  105. package/src/engine/graphics/ecs/camera/unprojectPoint.d.ts +0 -9
  106. package/src/engine/graphics/ecs/camera/unprojectPoint.d.ts.map +0 -1
  107. package/src/engine/graphics/ecs/camera/unprojectPoint.js +0 -14
  108. package/src/engine/graphics/ecs/camera/update_camera_transform.d.ts +0 -6
  109. package/src/engine/graphics/ecs/camera/update_camera_transform.d.ts.map +0 -1
  110. package/src/engine/graphics/ecs/camera/update_camera_transform.js +0 -17
  111. package/src/engine/graphics/particles/ecs/ParticleEmitterSystem.d.ts +0 -67
  112. package/src/engine/graphics/particles/ecs/ParticleEmitterSystem.d.ts.map +0 -1
  113. package/src/engine/graphics/particles/ecs/ParticleEmitterSystem.js +0 -291
  114. package/src/engine/graphics/particles/particular/engine/renderers/billboard/ParticleBillboardMaterial.d.ts +0 -11
  115. package/src/engine/graphics/particles/particular/engine/renderers/billboard/ParticleBillboardMaterial.d.ts.map +0 -1
  116. package/src/engine/graphics/particles/particular/engine/renderers/billboard/ParticleBillboardMaterial.js +0 -497
  117. package/src/engine/graphics/particles/particular/engine/shader/MaterialRecord.d.ts +0 -40
  118. package/src/engine/graphics/particles/particular/engine/shader/MaterialRecord.d.ts.map +0 -1
  119. package/src/engine/graphics/particles/particular/engine/shader/MaterialRecord.js +0 -47
  120. package/src/engine/graphics/texture/sampler/writeSampler2DDataToDataTexture.d.ts +0 -7
  121. package/src/engine/graphics/texture/sampler/writeSampler2DDataToDataTexture.d.ts.map +0 -1
  122. package/src/engine/graphics/texture/sampler/writeSampler2DDataToDataTexture.js +0 -40
@@ -1,443 +1,450 @@
1
- import { mat4 } from "gl-matrix";
2
- import { f32, u32 } from "../shader/type/WEBGPU_WGSL_PRIMITIVE_TYPES.js";
3
- import { GraphicsContext } from "../GraphicsContext.js";
4
- import { ShadeGPUCommandContext } from "../../device/ShadeGPUCommandContext.js";
5
- import { CAMERA_UNIFORM_STRUCT } from "../camera/CAMERA_UNIFORM_STRUCT.js";
6
- import { PARTICLE_RECORD_WORD_COUNT } from "./ParticleConstants.js";
7
- import { PARTICLE_COUNTER_COUNT } from "./data/PARTICLE_COUNTERS.js";
8
- import { EMITTER_BLEND, EMITTER_PROJECTION } from "./data/PARTICLE_EMITTER_LAYOUT.js";
9
- import { ParticleGraph } from "./graph/ParticleGraph.js";
10
- import { VM_BUILTIN, VM_RANDOM_MODE } from "./isa/ParticleVMISA.js";
11
- import { ParticleLayout } from "./layout/ParticleLayout.js";
12
- import { GPUParticleSystem } from "./GPUParticleSystem.js";
13
- import { create_particle_effect } from "./runtime/create_particle_effect.js";
14
- import { fire_effect } from "./particle_prototype.js";
15
- import {
16
- PARTICLE_BILLBOARD_RESOURCES,
17
- create_particle_billboard_pipeline,
18
- } from "./shaders/shader_particle_render.js";
19
- import { shader_particle_emit } from "./shaders/shader_particle_emit.js";
20
- import {
21
- shader_particle_build_indirect,
22
- shader_particle_reset_counters,
23
- } from "./shaders/shader_particle_finalize.js";
24
- import {
25
- PARTICLE_SIMULATE_WORKGROUP_SIZE,
26
- shader_particle_simulate,
27
- } from "./shaders/shader_particle_simulate.js";
28
-
29
- /**
30
- * Rich, runnable demonstration of the GPU particle system in a real scene:
31
- *
32
- * - lit solid geometry (a ground plane + boxes) rendered into a reverse-Z depth buffer,
33
- * - four emitters with distinct settings an additive fire, an alpha soft-depth smoke column,
34
- * additive velocity-stretched sparks, and a wide soft-depth dust drift,
35
- * - the billboard pass depth-tests against the scene so particles are occluded by the boxes, and
36
- * soft-depth emitters fade as they approach surfaces.
37
- *
38
- * The loop is fully GPU-driven: the simulate dispatch runs via dispatchWorkgroupsIndirect and the
39
- * billboard draw via drawIndirect, both from args the build-indirect pass writes on the GPU. No CPU
40
- * readback participates in sizing anything — a stale CPU count would orphan alive-list entries when
41
- * the population grows and double-free slots when it shrinks.
42
- *
43
- * Everything runs against a live WebGPU device (no Renderer dependency): geometry pass, then the
44
- * real emit + simulate compute passes, then one instanced billboard draw over all particles (a
45
- * single premultiplied blend state expresses both additive and alpha). `index.html` points vite at
46
- * this module; run with `npm run start` (HTTPS) or `VITE_HTTP=1 npx vite` for headless browsers.
47
- */
48
-
49
- const CAPACITY = 1 << 15; // 32k particles
50
- const EMIT_WG = 64;
51
- const NEAR = 0.2;
52
-
53
- async function main() {
54
- document.body.style.cssText = "margin:0;background:#000;overflow:hidden";
55
- const canvas = document.createElement("canvas");
56
- document.body.appendChild(canvas);
57
-
58
- if (navigator.gpu === undefined) return show_message("WebGPU is not available in this browser.");
59
-
60
- const adapter = await navigator.gpu.requestAdapter();
61
- const device = await adapter.requestDevice();
62
- device.addEventListener?.("uncapturederror", (e) => console.error("WebGPU error:", e.error.message));
63
-
64
- const graphics = new GraphicsContext(device);
65
- const context = canvas.getContext("webgpu");
66
- const format = navigator.gpu.getPreferredCanvasFormat();
67
- context.configure({ device, format, alphaMode: "opaque" });
68
-
69
- // --- scene: four emitters, each with different settings -----------------------------------
70
- const system = new GPUParticleSystem(graphics, { capacity: CAPACITY });
71
-
72
- // fire additive, billboard, at the base of the tall pillar
73
- system.addEmitter({
74
- ...fire_effect().emitter,
75
- spawn_rate: 500, flipbook: [1, 1], atlas_region: [0, 0, 1, 1],
76
- flags: { blend: EMITTER_BLEND.ADDITIVE, projection: EMITTER_PROJECTION.BILLBOARD },
77
- transform: translation(-1, 0, 0),
78
- });
79
- // smoke alpha, soft-depth, rising column above the fire
80
- system.addEmitter({
81
- 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] }),
82
- spawn_rate: 60, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
83
- flags: { blend: EMITTER_BLEND.ALPHA, projection: EMITTER_PROJECTION.BILLBOARD, soft_depth: true },
84
- render: { position: "position", size: "size", color: "color" },
85
- transform: translation(-1, 2.5, 0),
86
- });
87
- // sparks additive, velocity-stretched, fast, short-lived, gravity, near the box
88
- system.addEmitter({
89
- 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] }),
90
- spawn_rate: 350, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
91
- flags: { blend: EMITTER_BLEND.ADDITIVE, projection: EMITTER_PROJECTION.STRETCHED },
92
- render: { position: "position", size: "size", color: "color", velocity: "velocity" },
93
- transform: translation(2, 1.9, -1),
94
- });
95
- // dust alpha, soft-depth, slow wide drift across the ground
96
- system.addEmitter({
97
- 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] }),
98
- spawn_rate: 120, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
99
- flags: { blend: EMITTER_BLEND.ALPHA, projection: EMITTER_PROJECTION.BILLBOARD, soft_depth: true },
100
- render: { position: "position", size: "size", color: "color" },
101
- transform: translation(0, 0.3, 0),
102
- });
103
- const built = system.buildEmitterData();
104
-
105
- // --- particle GPU buffers -----------------------------------------------------------------
106
- const S = () => GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
107
- const pool = device.createBuffer({ label: "particles/pool", size: CAPACITY * PARTICLE_RECORD_WORD_COUNT * 4, usage: S() });
108
- const aliveA = device.createBuffer({ label: "particles/alive_a", size: CAPACITY * 4, usage: S() });
109
- const aliveB = device.createBuffer({ label: "particles/alive_b", size: CAPACITY * 4, usage: S() });
110
- const dead = device.createBuffer({ label: "particles/dead", size: CAPACITY * 4, usage: S() });
111
- const counters = device.createBuffer({ label: "particles/counters", size: PARTICLE_COUNTER_COUNT * 4, usage: S() });
112
- const spawnRequests = device.createBuffer({ label: "particles/spawn_requests", size: CAPACITY * 4, usage: S() });
113
- const emitters = data_buffer(device, "particles/emitters", built.emitters, S());
114
- const program = data_buffer(device, "particles/program", built.program, S());
115
- const constants = data_buffer(device, "particles/constants", built.constants, S());
116
- // Debug instrumentation (counters readback + manual frame stepping) — opt in with ?debug.
117
- // Useful for verifying the loop numerically in a headless / hidden tab where rAF is suspended.
118
- const DEBUG = new URLSearchParams(location.search).has("debug");
119
-
120
- // Indirect args, written on-GPU by the build-indirect pass. Zero-initialised (WebGPU spec), so
121
- // the first frame's indirect dispatch/draw are no-ops until the first snapshot lands.
122
- const argsUsage = GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | (DEBUG ? GPUBufferUsage.COPY_SRC : 0);
123
- const dispatchArgs = device.createBuffer({ label: "particles/dispatch_args", size: 3 * 4, usage: argsUsage });
124
- const drawArgs = device.createBuffer({ label: "particles/draw_args", size: 4 * 4, usage: argsUsage });
125
- const initial = system.initialFreeList();
126
- device.queue.writeBuffer(dead, 0, initial.dead);
127
- device.queue.writeBuffer(counters, 0, initial.counters);
128
-
129
- // --- camera + geometry --------------------------------------------------------------------
130
- const cameraBuffer = device.createBuffer({ label: "particles/camera", size: CAMERA_UNIFORM_STRUCT.aligned_size, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
131
- const vpBuffer = device.createBuffer({ label: "vp", size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
132
-
133
- const geo = build_geometry();
134
- const geoBuffer = device.createBuffer({ label: "geometry", size: geo.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
135
- device.queue.writeBuffer(geoBuffer, 0, geo);
136
- const geoVertexCount = geo.length / 6;
137
- const { geoPipeline, geoBindGroup } = make_geometry_pipeline(device, format, vpBuffer);
138
-
139
- // atlas sprite + soft round falloff, filtering sampler
140
- const atlasView = make_sprite_texture(device).createView();
141
- const atlasSampler = device.createSampler({ magFilter: "linear", minFilter: "linear", addressModeU: "clamp-to-edge", addressModeV: "clamp-to-edge" });
142
-
143
- // reverse-Z depth buffer (recreated on resize)
144
- let depthTex = null, depthView = null, depthW = 0, depthH = 0;
145
- function ensure_depth(w, h) {
146
- if (depthView && depthW === w && depthH === h) return;
147
- depthTex?.destroy();
148
- depthTex = device.createTexture({ label: "scene_depth", size: [w, h], format: "depth32float", usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING });
149
- depthView = depthTex.createView();
150
- depthW = w; depthH = h;
151
- }
152
-
153
- // billboard pipeline: reverse-Z depth test (same compare as the geometry pass), no write;
154
- // soft-depth reads the scene depth texture
155
- const particlePipeline = create_particle_billboard_pipeline({ color_format: format, depth_format: "depth32float", depth_compare: "greater" });
156
-
157
- let aliveRead = aliveA, aliveWrite = aliveB;
158
- let last = performance.now();
159
-
160
- // ?debug: heartbeat + occasional counters/args readback exposed on window.__stats.
161
- const dbgBuf = DEBUG ? device.createBuffer({ label: "dbg", size: 64, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }) : null;
162
- let dbgPending = false;
163
- let frame_index = 0;
164
-
165
- function frame(now) {
166
- const dt = Math.min((now - last) / 1000, 0.05);
167
- last = now;
168
-
169
- const w = Math.max(1, window.innerWidth), h = Math.max(1, window.innerHeight);
170
- if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
171
- ensure_depth(w, h);
172
- write_camera(device, cameraBuffer, vpBuffer, w / h, now / 1000);
173
-
174
- const requests = system.update(dt);
175
- if (requests.length > 0) device.queue.writeBuffer(spawnRequests, 0, requests);
176
-
177
- const cmd = ShadeGPUCommandContext.create(graphics);
178
- const u = (type, value) => cmd.allocateTransientValueBuffer(type, value, GPUBufferUsage.UNIFORM);
179
- const colorView = context.getCurrentTexture().createView();
180
-
181
- // --- geometry pass: clear colour + reverse-Z depth (far = 0), lit, writes depth ---
182
- const gpass = cmd.beginRenderPass({
183
- colorAttachments: [{ view: colorView, loadOp: "clear", storeOp: "store", clearValue: { r: 0.03, g: 0.035, b: 0.05, a: 1 } }],
184
- depthStencilAttachment: { view: depthView, depthLoadOp: "clear", depthStoreOp: "store", depthClearValue: 0.0 },
185
- });
186
- gpass.setPipeline(geoPipeline);
187
- gpass.setBindGroup(0, geoBindGroup);
188
- gpass.setVertexBuffer(0, geoBuffer);
189
- gpass.draw(geoVertexCount);
190
- gpass.end();
191
-
192
- // --- emit + simulate (simulate is sized by LAST frame's GPU-written dispatch args) ---
193
- shader_particle_reset_counters.dispatch({ encoder: cmd, bindings: { counters }, group_count_x: 1 });
194
- if (requests.length > 0) {
195
- shader_particle_emit.dispatch({
196
- encoder: cmd,
197
- bindings: { emitters, program, vm_constants: constants, pool, spawn_requests: spawnRequests, alive_out: aliveWrite, dead, counters, u_dt: u(f32, dt), u_time: u(f32, now / 1000), u_frame: u(u32, system.frame), u_count: u(u32, requests.length), u_capacity: u(u32, CAPACITY) },
198
- group_count_x: Math.ceil(requests.length / EMIT_WG),
199
- });
200
- }
201
- shader_particle_simulate.dispatchIndirect({
202
- encoder: cmd,
203
- bindings: { emitters, program, vm_constants: constants, pool, alive_in: aliveRead, alive_out: aliveWrite, dead, counters, u_dt: u(f32, dt), u_time: u(f32, now / 1000), u_frame: u(u32, system.frame) },
204
- command: dispatchArgs,
205
- });
206
- // Snapshot the final alive count (next frame's simulate input) + write this frame's draw args.
207
- shader_particle_build_indirect.dispatch({
208
- encoder: cmd,
209
- bindings: { counters, dispatch_args: dispatchArgs, draw_args: drawArgs, u_workgroup_size: u(u32, PARTICLE_SIMULATE_WORKGROUP_SIZE) },
210
- group_count_x: 1,
211
- });
212
-
213
- // --- billboard pass: load colour, depth-test against the scene (read-only), exact indirect draw ---
214
- const bindings = PARTICLE_BILLBOARD_RESOURCES.generateBindings({
215
- camera: cameraBuffer, pool, sorted: aliveWrite, emitters,
216
- atlas: atlasView, atlas_sampler: atlasSampler, scene_depth: depthView,
217
- }, cmd);
218
- const ppass = cmd.constructRenderPass({
219
- pipeline: particlePipeline,
220
- bindings,
221
- colorAttachments: [{ view: colorView, loadOp: "load", storeOp: "store" }],
222
- depthStencilAttachment: { view: depthView, depthReadOnly: true },
223
- });
224
- ppass.drawIndirect(drawArgs, 0);
225
- ppass.end();
226
-
227
- // ?debug: copy counters + args for readback every ~30 frames (stats only; never used for sizing).
228
- const capture_stats = DEBUG && !dbgPending && frame_index % 30 === 0;
229
- if (capture_stats) {
230
- cmd.copyBufferToBuffer(counters, 0, dbgBuf, 0, PARTICLE_COUNTER_COUNT * 4);
231
- cmd.copyBufferToBuffer(dispatchArgs, 0, dbgBuf, 32, 12);
232
- cmd.copyBufferToBuffer(drawArgs, 0, dbgBuf, 48, 16);
233
- }
234
-
235
- cmd.finish();
236
-
237
- frame_index++;
238
- if (capture_stats) {
239
- dbgPending = true;
240
- dbgBuf.mapAsync(GPUMapMode.READ).then(() => {
241
- const v = new Uint32Array(dbgBuf.getMappedRange().slice(0));
242
- window.__stats = { f: frame_index, alive: v[0], dead: v[1], alive_in: v[4], dispatch: [v[8], v[9], v[10]], draw: [v[12], v[13]] };
243
- dbgBuf.unmap(); dbgPending = false;
244
- }).catch(() => { dbgPending = false; });
245
- }
246
-
247
- const t = aliveRead; aliveRead = aliveWrite; aliveWrite = t;
248
- requestAnimationFrame(frame);
249
- }
250
- if (DEBUG) {
251
- // Drive frames manually when the tab is hidden (rAF suspended) — headless verification.
252
- window.__step = (n = 1) => { for (let i = 0; i < n; i++) frame(performance.now()); };
253
- }
254
- requestAnimationFrame(frame);
255
- }
256
-
257
- // ---------------------------------------------------------------------------------------------
258
- // Effect authoring
259
- // ---------------------------------------------------------------------------------------------
260
-
261
- /**
262
- * A parametric point-emission effect: emit with a base velocity + jitter from a disk, integrate
263
- * under a vertical acceleration, and die at end of life. Only nAge.x is consumed (kill), so the
264
- * NaN lanes from the scalar age/lifetime divide are harmless.
265
- */
266
- function make_effect({ velocity, velocityJitter = 0, posJitter = 0, accel = 0, life = [1, 2], size = 1, color = [1, 1, 1, 1] }) {
267
- const layout = new ParticleLayout([
268
- { name: "position", components: 3 }, { name: "velocity", components: 3 },
269
- { name: "age", components: 1 }, { name: "lifetime", components: 1 },
270
- { name: "size", components: 1 }, { name: "color", components: 4 },
271
- ]);
272
-
273
- const spawn = new ParticleGraph();
274
- let posN = spawn.add("builtin", { id: VM_BUILTIN.EMITTER_POSITION });
275
- if (posJitter > 0) {
276
- const d = spawn.add("random", { mode: VM_RANDOM_MODE.UNIT_DISK });
277
- const j = spawn.add("scale"); spawn.wire(j, "v", d); spawn.wire(j, "s", [posJitter]);
278
- const p = spawn.add("add"); spawn.wire(p, "a", posN); spawn.wire(p, "b", j); posN = p;
279
- }
280
- spawn.wire(spawn.add("setAttribute", { name: "position" }), "value", posN);
281
-
282
- let velN = spawn.add("const", { value: [velocity[0], velocity[1], velocity[2], 0] });
283
- if (velocityJitter > 0) {
284
- const r = spawn.add("random", { mode: VM_RANDOM_MODE.UNIFORM_SIGNED });
285
- const rs = spawn.add("scale"); spawn.wire(rs, "v", r); spawn.wire(rs, "s", [velocityJitter]);
286
- const vv = spawn.add("add"); spawn.wire(vv, "a", velN); spawn.wire(vv, "b", rs); velN = vv;
287
- }
288
- spawn.wire(spawn.add("setAttribute", { name: "velocity" }), "value", velN);
289
- spawn.wire(spawn.add("setAttribute", { name: "age" }), "value", [0]);
290
- const lr = spawn.add("random", { mode: VM_RANDOM_MODE.UNIFORM });
291
- const lm = spawn.add("mad"); spawn.wire(lm, "a", lr); spawn.wire(lm, "b", [life[1] - life[0]]); spawn.wire(lm, "c", [life[0]]);
292
- spawn.wire(spawn.add("setAttribute", { name: "lifetime" }), "value", lm);
293
- spawn.wire(spawn.add("setAttribute", { name: "size" }), "value", [size]);
294
- spawn.wire(spawn.add("setAttribute", { name: "color" }), "value", color);
295
-
296
- const update = new ParticleGraph();
297
- const dt = update.add("builtin", { id: VM_BUILTIN.DELTA_TIME });
298
- const age = update.add("attribute", { name: "age" });
299
- const nage = update.add("add"); update.wire(nage, "a", age); update.wire(nage, "b", dt);
300
- update.wire(update.add("setAttribute", { name: "age" }), "value", nage);
301
- const lifeA = update.add("attribute", { name: "lifetime" });
302
- const na = update.add("div"); update.wire(na, "a", nage); update.wire(na, "b", lifeA);
303
- const dead = update.add("compare", { op: "ge" }); update.wire(dead, "a", na); update.wire(dead, "b", [1]);
304
- update.wire(update.add("kill"), "condition", dead);
305
- let velN2 = update.add("attribute", { name: "velocity" });
306
- if (accel !== 0) {
307
- const g = update.add("const", { value: [0, accel, 0, 0] });
308
- const gd = update.add("scale"); update.wire(gd, "v", g); update.wire(gd, "s", dt);
309
- const nv = update.add("add"); update.wire(nv, "a", velN2); update.wire(nv, "b", gd);
310
- update.wire(update.add("setAttribute", { name: "velocity" }), "value", nv); velN2 = nv;
311
- }
312
- const pos = update.add("attribute", { name: "position" });
313
- const pd = update.add("scale"); update.wire(pd, "v", velN2); update.wire(pd, "s", dt);
314
- const np = update.add("add"); update.wire(np, "a", pos); update.wire(np, "b", pd);
315
- update.wire(update.add("setAttribute", { name: "position" }), "value", np);
316
-
317
- return create_particle_effect({ layout, spawn, update });
318
- }
319
-
320
- // ---------------------------------------------------------------------------------------------
321
- // Geometry + camera + helpers
322
- // ---------------------------------------------------------------------------------------------
323
-
324
- const GEOM_WGSL = /* wgsl */ `
325
- @group(0) @binding(0) var<uniform> vp : mat4x4<f32>;
326
- struct VOut { @builtin(position) clip : vec4<f32>, @location(0) normal : vec3<f32> }
327
- @vertex fn vs(@location(0) pos : vec3<f32>, @location(1) normal : vec3<f32>) -> VOut {
328
- var o : VOut;
329
- o.clip = vp * vec4<f32>(pos, 1.0);
330
- o.normal = normal;
331
- return o;
332
- }
333
- @fragment fn fs(in : VOut) -> @location(0) vec4<f32> {
334
- let N = normalize(in.normal);
335
- let L = normalize(vec3<f32>(0.35, 1.0, 0.4));
336
- let diffuse = max(dot(N, L), 0.0);
337
- let albedo = select(vec3<f32>(0.30, 0.32, 0.36), vec3<f32>(0.22, 0.24, 0.28), N.y < 0.9);
338
- let color = albedo * (0.18 + diffuse * 0.9);
339
- return vec4<f32>(color, 1.0);
340
- }
341
- `;
342
-
343
- function make_geometry_pipeline(device, format, vpBuffer) {
344
- const module = device.createShaderModule({ code: GEOM_WGSL });
345
- const bgl = device.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }] });
346
- const geoPipeline = device.createRenderPipeline({
347
- layout: device.createPipelineLayout({ bindGroupLayouts: [bgl] }),
348
- vertex: { module, entryPoint: "vs", buffers: [{ arrayStride: 24, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" }, { shaderLocation: 1, offset: 12, format: "float32x3" }] }] },
349
- fragment: { module, entryPoint: "fs", targets: [{ format }] },
350
- primitive: { topology: "triangle-list", cullMode: "none" },
351
- depthStencil: { format: "depth32float", depthWriteEnabled: true, depthCompare: "greater" }, // reverse-Z
352
- });
353
- const geoBindGroup = device.createBindGroup({ layout: bgl, entries: [{ binding: 0, resource: { buffer: vpBuffer } }] });
354
- return { geoPipeline, geoBindGroup };
355
- }
356
-
357
- /** Ground plane + a few boxes as an interleaved [x,y,z, nx,ny,nz] vertex array. */
358
- function build_geometry() {
359
- const v = [];
360
- 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]); };
361
- const box = (cx, cy, cz, sx, sy, sz) => {
362
- 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;
363
- quad([x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [0, 0, 1]);
364
- quad([x1, y0, z0], [x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [0, 0, -1]);
365
- quad([x1, y0, z1], [x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [1, 0, 0]);
366
- quad([x0, y0, z0], [x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [-1, 0, 0]);
367
- quad([x0, y1, z1], [x1, y1, z1], [x1, y1, z0], [x0, y1, z0], [0, 1, 0]);
368
- quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [0, -1, 0]);
369
- };
370
- quad([-15, 0, -15], [15, 0, -15], [15, 0, 15], [-15, 0, 15], [0, 1, 0]); // ground
371
- box(-1, 1.5, 0, 1, 3, 1); // tall pillar next to the fire
372
- box(2, 0.9, -1, 1.6, 1.8, 1.6); // block near the sparks
373
- box(0, 0.6, 3.5, 7, 1.2, 0.6); // low wall the dust drifts against
374
- return new Float32Array(v);
375
- }
376
-
377
- /** Reverse-Z, infinite-far perspective (WebGPU 0..1 depth: near -> 1, far -> 0). */
378
- function perspective_reverse_z(fovY, aspect, near) {
379
- const f = 1 / Math.tan(fovY / 2);
380
- return new Float32Array([
381
- f / aspect, 0, 0, 0,
382
- 0, f, 0, 0,
383
- 0, 0, 0, -1,
384
- 0, 0, near, 0,
385
- ]);
386
- }
387
-
388
- function write_camera(device, cameraBuffer, vpBuffer, aspect, t) {
389
- const radius = 12, height = 5.5;
390
- const eye = [Math.cos(t * 0.22) * radius, height, Math.sin(t * 0.22) * radius];
391
- const center = [0, 1.6, 0];
392
- const view = mat4.lookAt(mat4.create(), eye, center, [0, 1, 0]);
393
- const world = mat4.invert(mat4.create(), view);
394
- const proj = perspective_reverse_z((58 * Math.PI) / 180, aspect, NEAR);
395
- const vp = mat4.multiply(mat4.create(), proj, view);
396
-
397
- const data = new Float32Array(CAMERA_UNIFORM_STRUCT.aligned_size / 4);
398
- const put = (field, m) => data.set(m, CAMERA_UNIFORM_STRUCT.get(field).offset / 4);
399
- put("transform", world);
400
- put("view_matrix", view);
401
- put("projection_matrix", proj);
402
- put("view_projection_matrix", vp);
403
- // Reverse-Z infinite-far linearisation: view_depth = A / (device_depth + B), with A=near, B=0.
404
- data.set([0, NEAR, 0, 0], CAMERA_UNIFORM_STRUCT.get("device_depth_to_view_space").offset / 4);
405
- device.queue.writeBuffer(cameraBuffer, 0, data);
406
- device.queue.writeBuffer(vpBuffer, 0, vp);
407
- }
408
-
409
- function translation(x, y, z) {
410
- return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
411
- }
412
-
413
- function data_buffer(device, label, data, usage) {
414
- const buffer = device.createBuffer({ label, size: Math.max(data.byteLength, 4), usage });
415
- device.queue.writeBuffer(buffer, 0, data);
416
- return buffer;
417
- }
418
-
419
- /** A 64x64 radial-falloff sprite (white, alpha falloff) for soft additive/alpha blobs. */
420
- function make_sprite_texture(device) {
421
- const N = 64;
422
- const px = new Uint8Array(N * N * 4);
423
- for (let y = 0; y < N; y++) {
424
- for (let x = 0; x < N; x++) {
425
- const dx = (x + 0.5) / N * 2 - 1, dy = (y + 0.5) / N * 2 - 1;
426
- const a = Math.max(0, 1 - Math.hypot(dx, dy));
427
- const i = (y * N + x) * 4;
428
- px[i] = 255; px[i + 1] = 255; px[i + 2] = 255; px[i + 3] = Math.round(a * a * 255);
429
- }
430
- }
431
- const tex = device.createTexture({ label: "sprite", size: [N, N], format: "rgba8unorm", usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
432
- device.queue.writeTexture({ texture: tex }, px, { bytesPerRow: N * 4, rowsPerImage: N }, [N, N]);
433
- return tex;
434
- }
435
-
436
- function show_message(text) {
437
- const d = document.createElement("div");
438
- d.textContent = text;
439
- d.style.cssText = "position:fixed;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-family:sans-serif;font-size:20px";
440
- document.body.appendChild(d);
441
- }
442
-
443
- main().catch((e) => { console.error(e); show_message("Particle prototype failed to start — see console. " + e.message); });
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 { f32, u32 } from "../shader/type/WEBGPU_WGSL_PRIMITIVE_TYPES.js";
6
+ import { GraphicsContext } from "../GraphicsContext.js";
7
+ import { ShadeGPUCommandContext } from "../../device/ShadeGPUCommandContext.js";
8
+ import { CAMERA_UNIFORM_STRUCT } from "../camera/CAMERA_UNIFORM_STRUCT.js";
9
+ import { PARTICLE_RECORD_WORD_COUNT } from "./ParticleConstants.js";
10
+ import { PARTICLE_COUNTER_COUNT } from "./data/PARTICLE_COUNTERS.js";
11
+ import { EMITTER_BLEND, EMITTER_PROJECTION } from "./data/PARTICLE_EMITTER_LAYOUT.js";
12
+ import { ParticleGraph } from "./graph/ParticleGraph.js";
13
+ import { VM_BUILTIN, VM_RANDOM_MODE } from "./isa/ParticleVMISA.js";
14
+ import { ParticleLayout } from "./layout/ParticleLayout.js";
15
+ import { GPUParticleSystem } from "./GPUParticleSystem.js";
16
+ import { create_particle_effect } from "./runtime/create_particle_effect.js";
17
+ import { fire_effect } from "./particle_prototype.js";
18
+ import {
19
+ PARTICLE_BILLBOARD_RESOURCES,
20
+ create_particle_billboard_pipeline,
21
+ } from "./shaders/shader_particle_render.js";
22
+ import { shader_particle_emit } from "./shaders/shader_particle_emit.js";
23
+ import {
24
+ shader_particle_build_indirect,
25
+ shader_particle_reset_counters,
26
+ } from "./shaders/shader_particle_finalize.js";
27
+ import {
28
+ PARTICLE_SIMULATE_WORKGROUP_SIZE,
29
+ shader_particle_simulate,
30
+ } from "./shaders/shader_particle_simulate.js";
31
+
32
+ /**
33
+ * Rich, runnable demonstration of the GPU particle system in a real scene:
34
+ *
35
+ * - lit solid geometry (a ground plane + boxes) rendered into a reverse-Z depth buffer,
36
+ * - four emitters with distinct settings an additive fire, an alpha soft-depth smoke column,
37
+ * additive velocity-stretched sparks, and a wide soft-depth dust drift,
38
+ * - the billboard pass depth-tests against the scene so particles are occluded by the boxes, and
39
+ * soft-depth emitters fade as they approach surfaces.
40
+ *
41
+ * The loop is fully GPU-driven: the simulate dispatch runs via dispatchWorkgroupsIndirect and the
42
+ * billboard draw via drawIndirect, both from args the build-indirect pass writes on the GPU. No CPU
43
+ * readback participates in sizing anything a stale CPU count would orphan alive-list entries when
44
+ * the population grows and double-free slots when it shrinks.
45
+ *
46
+ * Everything runs against a live WebGPU device (no Renderer dependency): geometry pass, then the
47
+ * real emit + simulate compute passes, then one instanced billboard draw over all particles (a
48
+ * single premultiplied blend state expresses both additive and alpha). `index.html` points vite at
49
+ * this module; run with `npm run start` (HTTPS) or `VITE_HTTP=1 npx vite` for headless browsers.
50
+ */
51
+
52
+ const CAPACITY = 1 << 15; // 32k particles
53
+ const EMIT_WG = 64;
54
+ const NEAR = 0.2;
55
+
56
+ async function main() {
57
+ document.body.style.cssText = "margin:0;background:#000;overflow:hidden";
58
+ const canvas = document.createElement("canvas");
59
+ document.body.appendChild(canvas);
60
+
61
+ if (navigator.gpu === undefined) return show_message("WebGPU is not available in this browser.");
62
+
63
+ const adapter = await navigator.gpu.requestAdapter();
64
+ const device = await adapter.requestDevice();
65
+ device.addEventListener?.("uncapturederror", (e) => console.error("WebGPU error:", e.error.message));
66
+
67
+ const graphics = new GraphicsContext(device);
68
+ const context = canvas.getContext("webgpu");
69
+ const format = navigator.gpu.getPreferredCanvasFormat();
70
+ context.configure({ device, format, alphaMode: "opaque" });
71
+
72
+ // --- scene: four emitters, each with different settings -----------------------------------
73
+ const system = new GPUParticleSystem(graphics, { capacity: CAPACITY });
74
+
75
+ // fire additive, billboard, at the base of the tall pillar
76
+ system.addEmitter({
77
+ ...fire_effect().emitter,
78
+ spawn_rate: 500, flipbook: [1, 1], atlas_region: [0, 0, 1, 1],
79
+ flags: { blend: EMITTER_BLEND.ADDITIVE, projection: EMITTER_PROJECTION.BILLBOARD },
80
+ transform: translation(-1, 0, 0),
81
+ });
82
+ // smoke alpha, soft-depth, rising column above the fire
83
+ system.addEmitter({
84
+ 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] }),
85
+ spawn_rate: 60, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
86
+ flags: { blend: EMITTER_BLEND.ALPHA, projection: EMITTER_PROJECTION.BILLBOARD, soft_depth: true },
87
+ render: { position: "position", size: "size", color: "color" },
88
+ transform: translation(-1, 2.5, 0),
89
+ });
90
+ // sparks additive, velocity-stretched, fast, short-lived, gravity, near the box
91
+ system.addEmitter({
92
+ 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] }),
93
+ spawn_rate: 350, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
94
+ flags: { blend: EMITTER_BLEND.ADDITIVE, projection: EMITTER_PROJECTION.STRETCHED },
95
+ render: { position: "position", size: "size", color: "color", velocity: "velocity" },
96
+ transform: translation(2, 1.9, -1),
97
+ });
98
+ // dust alpha, soft-depth, slow wide drift across the ground
99
+ system.addEmitter({
100
+ 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] }),
101
+ spawn_rate: 120, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
102
+ flags: { blend: EMITTER_BLEND.ALPHA, projection: EMITTER_PROJECTION.BILLBOARD, soft_depth: true },
103
+ render: { position: "position", size: "size", color: "color" },
104
+ transform: translation(0, 0.3, 0),
105
+ });
106
+ const built = system.buildEmitterData();
107
+
108
+ // --- particle GPU buffers -----------------------------------------------------------------
109
+ const S = () => GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
110
+ const pool = device.createBuffer({ label: "particles/pool", size: CAPACITY * PARTICLE_RECORD_WORD_COUNT * 4, usage: S() });
111
+ const aliveA = device.createBuffer({ label: "particles/alive_a", size: CAPACITY * 4, usage: S() });
112
+ const aliveB = device.createBuffer({ label: "particles/alive_b", size: CAPACITY * 4, usage: S() });
113
+ const dead = device.createBuffer({ label: "particles/dead", size: CAPACITY * 4, usage: S() });
114
+ const counters = device.createBuffer({ label: "particles/counters", size: PARTICLE_COUNTER_COUNT * 4, usage: S() });
115
+ const spawnRequests = device.createBuffer({ label: "particles/spawn_requests", size: CAPACITY * 4, usage: S() });
116
+ const emitters = data_buffer(device, "particles/emitters", built.emitters, S());
117
+ const program = data_buffer(device, "particles/program", built.program, S());
118
+ const constants = data_buffer(device, "particles/constants", built.constants, S());
119
+ // Debug instrumentation (counters readback + manual frame stepping) — opt in with ?debug.
120
+ // Useful for verifying the loop numerically in a headless / hidden tab where rAF is suspended.
121
+ const DEBUG = new URLSearchParams(location.search).has("debug");
122
+
123
+ // Indirect args, written on-GPU by the build-indirect pass. Zero-initialised (WebGPU spec), so
124
+ // the first frame's indirect dispatch/draw are no-ops until the first snapshot lands.
125
+ const argsUsage = GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | (DEBUG ? GPUBufferUsage.COPY_SRC : 0);
126
+ const dispatchArgs = device.createBuffer({ label: "particles/dispatch_args", size: 3 * 4, usage: argsUsage });
127
+ const drawArgs = device.createBuffer({ label: "particles/draw_args", size: 4 * 4, usage: argsUsage });
128
+ const initial = system.initialFreeList();
129
+ device.queue.writeBuffer(dead, 0, initial.dead);
130
+ device.queue.writeBuffer(counters, 0, initial.counters);
131
+
132
+ // --- camera + geometry --------------------------------------------------------------------
133
+ const cameraBuffer = device.createBuffer({ label: "particles/camera", size: CAMERA_UNIFORM_STRUCT.aligned_size, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
134
+ const vpBuffer = device.createBuffer({ label: "vp", size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
135
+
136
+ const geo = build_geometry();
137
+ const geoBuffer = device.createBuffer({ label: "geometry", size: geo.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
138
+ device.queue.writeBuffer(geoBuffer, 0, geo);
139
+ const geoVertexCount = geo.length / 6;
140
+ const { geoPipeline, geoBindGroup } = make_geometry_pipeline(device, format, vpBuffer);
141
+
142
+ // atlas sprite + soft round falloff, filtering sampler
143
+ const atlasView = make_sprite_texture(device).createView();
144
+ const atlasSampler = device.createSampler({ magFilter: "linear", minFilter: "linear", addressModeU: "clamp-to-edge", addressModeV: "clamp-to-edge" });
145
+
146
+ // reverse-Z depth buffer (recreated on resize)
147
+ let depthTex = null, depthView = null, depthW = 0, depthH = 0;
148
+ function ensure_depth(w, h) {
149
+ if (depthView && depthW === w && depthH === h) return;
150
+ depthTex?.destroy();
151
+ depthTex = device.createTexture({ label: "scene_depth", size: [w, h], format: "depth32float", usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING });
152
+ depthView = depthTex.createView();
153
+ depthW = w; depthH = h;
154
+ }
155
+
156
+ // billboard pipeline: reverse-Z depth test (same compare as the geometry pass), no write;
157
+ // soft-depth reads the scene depth texture
158
+ const particlePipeline = create_particle_billboard_pipeline({ color_format: format, depth_format: "depth32float", depth_compare: "greater" });
159
+
160
+ let aliveRead = aliveA, aliveWrite = aliveB;
161
+ let last = performance.now();
162
+
163
+ // ?debug: heartbeat + occasional counters/args readback exposed on window.__stats.
164
+ const dbgBuf = DEBUG ? device.createBuffer({ label: "dbg", size: 64, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }) : null;
165
+ let dbgPending = false;
166
+ let frame_index = 0;
167
+
168
+ function frame(now) {
169
+ const dt = Math.min((now - last) / 1000, 0.05);
170
+ last = now;
171
+
172
+ const w = Math.max(1, window.innerWidth), h = Math.max(1, window.innerHeight);
173
+ if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
174
+ ensure_depth(w, h);
175
+ write_camera(device, cameraBuffer, vpBuffer, w / h, now / 1000);
176
+
177
+ const requests = system.update(dt);
178
+ if (requests.length > 0) device.queue.writeBuffer(spawnRequests, 0, requests);
179
+
180
+ const cmd = ShadeGPUCommandContext.create(graphics);
181
+ const u = (type, value) => cmd.allocateTransientValueBuffer(type, value, GPUBufferUsage.UNIFORM);
182
+ const colorView = context.getCurrentTexture().createView();
183
+
184
+ // --- geometry pass: clear colour + reverse-Z depth (far = 0), lit, writes depth ---
185
+ const gpass = cmd.beginRenderPass({
186
+ colorAttachments: [{ view: colorView, loadOp: "clear", storeOp: "store", clearValue: { r: 0.03, g: 0.035, b: 0.05, a: 1 } }],
187
+ depthStencilAttachment: { view: depthView, depthLoadOp: "clear", depthStoreOp: "store", depthClearValue: 0.0 },
188
+ });
189
+ gpass.setPipeline(geoPipeline);
190
+ gpass.setBindGroup(0, geoBindGroup);
191
+ gpass.setVertexBuffer(0, geoBuffer);
192
+ gpass.draw(geoVertexCount);
193
+ gpass.end();
194
+
195
+ // --- emit + simulate (simulate is sized by LAST frame's GPU-written dispatch args) ---
196
+ shader_particle_reset_counters.dispatch({ encoder: cmd, bindings: { counters }, group_count_x: 1 });
197
+ if (requests.length > 0) {
198
+ shader_particle_emit.dispatch({
199
+ encoder: cmd,
200
+ bindings: { emitters, program, vm_constants: constants, pool, spawn_requests: spawnRequests, alive_out: aliveWrite, dead, counters, u_dt: u(f32, dt), u_time: u(f32, now / 1000), u_frame: u(u32, system.frame), u_count: u(u32, requests.length), u_capacity: u(u32, CAPACITY) },
201
+ group_count_x: Math.ceil(requests.length / EMIT_WG),
202
+ });
203
+ }
204
+ shader_particle_simulate.dispatchIndirect({
205
+ encoder: cmd,
206
+ bindings: { emitters, program, vm_constants: constants, pool, alive_in: aliveRead, alive_out: aliveWrite, dead, counters, u_dt: u(f32, dt), u_time: u(f32, now / 1000), u_frame: u(u32, system.frame) },
207
+ command: dispatchArgs,
208
+ });
209
+ // Snapshot the final alive count (next frame's simulate input) + write this frame's draw args.
210
+ shader_particle_build_indirect.dispatch({
211
+ encoder: cmd,
212
+ bindings: { counters, dispatch_args: dispatchArgs, draw_args: drawArgs, u_workgroup_size: u(u32, PARTICLE_SIMULATE_WORKGROUP_SIZE) },
213
+ group_count_x: 1,
214
+ });
215
+
216
+ // --- billboard pass: load colour, depth-test against the scene (read-only), exact indirect draw ---
217
+ const bindings = PARTICLE_BILLBOARD_RESOURCES.generateBindings({
218
+ camera: cameraBuffer, pool, sorted: aliveWrite, emitters,
219
+ atlas: atlasView, atlas_sampler: atlasSampler, scene_depth: depthView,
220
+ }, cmd);
221
+ const ppass = cmd.constructRenderPass({
222
+ pipeline: particlePipeline,
223
+ bindings,
224
+ colorAttachments: [{ view: colorView, loadOp: "load", storeOp: "store" }],
225
+ depthStencilAttachment: { view: depthView, depthReadOnly: true },
226
+ });
227
+ ppass.drawIndirect(drawArgs, 0);
228
+ ppass.end();
229
+
230
+ // ?debug: copy counters + args for readback every ~30 frames (stats only; never used for sizing).
231
+ const capture_stats = DEBUG && !dbgPending && frame_index % 30 === 0;
232
+ if (capture_stats) {
233
+ cmd.copyBufferToBuffer(counters, 0, dbgBuf, 0, PARTICLE_COUNTER_COUNT * 4);
234
+ cmd.copyBufferToBuffer(dispatchArgs, 0, dbgBuf, 32, 12);
235
+ cmd.copyBufferToBuffer(drawArgs, 0, dbgBuf, 48, 16);
236
+ }
237
+
238
+ cmd.finish();
239
+
240
+ frame_index++;
241
+ if (capture_stats) {
242
+ dbgPending = true;
243
+ dbgBuf.mapAsync(GPUMapMode.READ).then(() => {
244
+ const v = new Uint32Array(dbgBuf.getMappedRange().slice(0));
245
+ window.__stats = { f: frame_index, alive: v[0], dead: v[1], alive_in: v[4], dispatch: [v[8], v[9], v[10]], draw: [v[12], v[13]] };
246
+ dbgBuf.unmap(); dbgPending = false;
247
+ }).catch(() => { dbgPending = false; });
248
+ }
249
+
250
+ const t = aliveRead; aliveRead = aliveWrite; aliveWrite = t;
251
+ requestAnimationFrame(frame);
252
+ }
253
+ if (DEBUG) {
254
+ // Drive frames manually when the tab is hidden (rAF suspended) — headless verification.
255
+ window.__step = (n = 1) => { for (let i = 0; i < n; i++) frame(performance.now()); };
256
+ }
257
+ requestAnimationFrame(frame);
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------------------------
261
+ // Effect authoring
262
+ // ---------------------------------------------------------------------------------------------
263
+
264
+ /**
265
+ * A parametric point-emission effect: emit with a base velocity + jitter from a disk, integrate
266
+ * under a vertical acceleration, and die at end of life. Only nAge.x is consumed (kill), so the
267
+ * NaN lanes from the scalar age/lifetime divide are harmless.
268
+ */
269
+ function make_effect({ velocity, velocityJitter = 0, posJitter = 0, accel = 0, life = [1, 2], size = 1, color = [1, 1, 1, 1] }) {
270
+ const layout = new ParticleLayout([
271
+ { name: "position", components: 3 }, { name: "velocity", components: 3 },
272
+ { name: "age", components: 1 }, { name: "lifetime", components: 1 },
273
+ { name: "size", components: 1 }, { name: "color", components: 4 },
274
+ ]);
275
+
276
+ const spawn = new ParticleGraph();
277
+ let posN = spawn.add("builtin", { id: VM_BUILTIN.EMITTER_POSITION });
278
+ if (posJitter > 0) {
279
+ const d = spawn.add("random", { mode: VM_RANDOM_MODE.UNIT_DISK });
280
+ const j = spawn.add("scale"); spawn.wire(j, "v", d); spawn.wire(j, "s", [posJitter]);
281
+ const p = spawn.add("add"); spawn.wire(p, "a", posN); spawn.wire(p, "b", j); posN = p;
282
+ }
283
+ spawn.wire(spawn.add("setAttribute", { name: "position" }), "value", posN);
284
+
285
+ let velN = spawn.add("const", { value: [velocity[0], velocity[1], velocity[2], 0] });
286
+ if (velocityJitter > 0) {
287
+ const r = spawn.add("random", { mode: VM_RANDOM_MODE.UNIFORM_SIGNED });
288
+ const rs = spawn.add("scale"); spawn.wire(rs, "v", r); spawn.wire(rs, "s", [velocityJitter]);
289
+ const vv = spawn.add("add"); spawn.wire(vv, "a", velN); spawn.wire(vv, "b", rs); velN = vv;
290
+ }
291
+ spawn.wire(spawn.add("setAttribute", { name: "velocity" }), "value", velN);
292
+ spawn.wire(spawn.add("setAttribute", { name: "age" }), "value", [0]);
293
+ const lr = spawn.add("random", { mode: VM_RANDOM_MODE.UNIFORM });
294
+ const lm = spawn.add("mad"); spawn.wire(lm, "a", lr); spawn.wire(lm, "b", [life[1] - life[0]]); spawn.wire(lm, "c", [life[0]]);
295
+ spawn.wire(spawn.add("setAttribute", { name: "lifetime" }), "value", lm);
296
+ spawn.wire(spawn.add("setAttribute", { name: "size" }), "value", [size]);
297
+ spawn.wire(spawn.add("setAttribute", { name: "color" }), "value", color);
298
+
299
+ const update = new ParticleGraph();
300
+ const dt = update.add("builtin", { id: VM_BUILTIN.DELTA_TIME });
301
+ const age = update.add("attribute", { name: "age" });
302
+ const nage = update.add("add"); update.wire(nage, "a", age); update.wire(nage, "b", dt);
303
+ update.wire(update.add("setAttribute", { name: "age" }), "value", nage);
304
+ const lifeA = update.add("attribute", { name: "lifetime" });
305
+ const na = update.add("div"); update.wire(na, "a", nage); update.wire(na, "b", lifeA);
306
+ const dead = update.add("compare", { op: "ge" }); update.wire(dead, "a", na); update.wire(dead, "b", [1]);
307
+ update.wire(update.add("kill"), "condition", dead);
308
+ let velN2 = update.add("attribute", { name: "velocity" });
309
+ if (accel !== 0) {
310
+ const g = update.add("const", { value: [0, accel, 0, 0] });
311
+ const gd = update.add("scale"); update.wire(gd, "v", g); update.wire(gd, "s", dt);
312
+ const nv = update.add("add"); update.wire(nv, "a", velN2); update.wire(nv, "b", gd);
313
+ update.wire(update.add("setAttribute", { name: "velocity" }), "value", nv); velN2 = nv;
314
+ }
315
+ const pos = update.add("attribute", { name: "position" });
316
+ const pd = update.add("scale"); update.wire(pd, "v", velN2); update.wire(pd, "s", dt);
317
+ const np = update.add("add"); update.wire(np, "a", pos); update.wire(np, "b", pd);
318
+ update.wire(update.add("setAttribute", { name: "position" }), "value", np);
319
+
320
+ return create_particle_effect({ layout, spawn, update });
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------------------------
324
+ // Geometry + camera + helpers
325
+ // ---------------------------------------------------------------------------------------------
326
+
327
+ const GEOM_WGSL = /* wgsl */ `
328
+ @group(0) @binding(0) var<uniform> vp : mat4x4<f32>;
329
+ struct VOut { @builtin(position) clip : vec4<f32>, @location(0) normal : vec3<f32> }
330
+ @vertex fn vs(@location(0) pos : vec3<f32>, @location(1) normal : vec3<f32>) -> VOut {
331
+ var o : VOut;
332
+ o.clip = vp * vec4<f32>(pos, 1.0);
333
+ o.normal = normal;
334
+ return o;
335
+ }
336
+ @fragment fn fs(in : VOut) -> @location(0) vec4<f32> {
337
+ let N = normalize(in.normal);
338
+ let L = normalize(vec3<f32>(0.35, 1.0, 0.4));
339
+ let diffuse = max(dot(N, L), 0.0);
340
+ let albedo = select(vec3<f32>(0.30, 0.32, 0.36), vec3<f32>(0.22, 0.24, 0.28), N.y < 0.9);
341
+ let color = albedo * (0.18 + diffuse * 0.9);
342
+ return vec4<f32>(color, 1.0);
343
+ }
344
+ `;
345
+
346
+ function make_geometry_pipeline(device, format, vpBuffer) {
347
+ const module = device.createShaderModule({ code: GEOM_WGSL });
348
+ const bgl = device.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }] });
349
+ const geoPipeline = device.createRenderPipeline({
350
+ layout: device.createPipelineLayout({ bindGroupLayouts: [bgl] }),
351
+ vertex: { module, entryPoint: "vs", buffers: [{ arrayStride: 24, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" }, { shaderLocation: 1, offset: 12, format: "float32x3" }] }] },
352
+ fragment: { module, entryPoint: "fs", targets: [{ format }] },
353
+ primitive: { topology: "triangle-list", cullMode: "none" },
354
+ depthStencil: { format: "depth32float", depthWriteEnabled: true, depthCompare: "greater" }, // reverse-Z
355
+ });
356
+ const geoBindGroup = device.createBindGroup({ layout: bgl, entries: [{ binding: 0, resource: { buffer: vpBuffer } }] });
357
+ return { geoPipeline, geoBindGroup };
358
+ }
359
+
360
+ /** Ground plane + a few boxes as an interleaved [x,y,z, nx,ny,nz] vertex array. */
361
+ function build_geometry() {
362
+ const v = [];
363
+ 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]); };
364
+ const box = (cx, cy, cz, sx, sy, sz) => {
365
+ 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;
366
+ quad([x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [0, 0, 1]);
367
+ quad([x1, y0, z0], [x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [0, 0, -1]);
368
+ quad([x1, y0, z1], [x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [1, 0, 0]);
369
+ quad([x0, y0, z0], [x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [-1, 0, 0]);
370
+ quad([x0, y1, z1], [x1, y1, z1], [x1, y1, z0], [x0, y1, z0], [0, 1, 0]);
371
+ quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [0, -1, 0]);
372
+ };
373
+ quad([-15, 0, -15], [15, 0, -15], [15, 0, 15], [-15, 0, 15], [0, 1, 0]); // ground
374
+ box(-1, 1.5, 0, 1, 3, 1); // tall pillar next to the fire
375
+ box(2, 0.9, -1, 1.6, 1.8, 1.6); // block near the sparks
376
+ box(0, 0.6, 3.5, 7, 1.2, 0.6); // low wall the dust drifts against
377
+ return new Float32Array(v);
378
+ }
379
+
380
+ /** Reverse-Z, infinite-far perspective (WebGPU 0..1 depth: near -> 1, far -> 0). */
381
+ function perspective_reverse_z(fovY, aspect, near) {
382
+ const f = 1 / Math.tan(fovY / 2);
383
+ return new Float32Array([
384
+ f / aspect, 0, 0, 0,
385
+ 0, f, 0, 0,
386
+ 0, 0, 0, -1,
387
+ 0, 0, near, 0,
388
+ ]);
389
+ }
390
+
391
+ function write_camera(device, cameraBuffer, vpBuffer, aspect, t) {
392
+ const radius = 12, height = 5.5;
393
+ const eye = [Math.cos(t * 0.22) * radius, height, Math.sin(t * 0.22) * radius];
394
+ const center = [0, 1.6, 0];
395
+ const view = m4_look_at(m4_allocate(), eye, center, [0, 1, 0]);
396
+
397
+ const world = m4_allocate();
398
+ m4_invert(world, view);
399
+
400
+ const proj = perspective_reverse_z((58 * Math.PI) / 180, aspect, NEAR);
401
+
402
+ const vp = m4_multiply(m4_allocate(), proj, view);
403
+
404
+ const data = new Float32Array(CAMERA_UNIFORM_STRUCT.aligned_size / 4);
405
+ const put = (field, m) => data.set(m, CAMERA_UNIFORM_STRUCT.get(field).offset / 4);
406
+ put("transform", world);
407
+ put("view_matrix", view);
408
+ put("projection_matrix", proj);
409
+ put("view_projection_matrix", vp);
410
+ // Reverse-Z infinite-far linearisation: view_depth = A / (device_depth + B), with A=near, B=0.
411
+ data.set([0, NEAR, 0, 0], CAMERA_UNIFORM_STRUCT.get("device_depth_to_view_space").offset / 4);
412
+ device.queue.writeBuffer(cameraBuffer, 0, data);
413
+ device.queue.writeBuffer(vpBuffer, 0, vp);
414
+ }
415
+
416
+ function translation(x, y, z) {
417
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
418
+ }
419
+
420
+ function data_buffer(device, label, data, usage) {
421
+ const buffer = device.createBuffer({ label, size: Math.max(data.byteLength, 4), usage });
422
+ device.queue.writeBuffer(buffer, 0, data);
423
+ return buffer;
424
+ }
425
+
426
+ /** A 64x64 radial-falloff sprite (white, alpha falloff) for soft additive/alpha blobs. */
427
+ function make_sprite_texture(device) {
428
+ const N = 64;
429
+ const px = new Uint8Array(N * N * 4);
430
+ for (let y = 0; y < N; y++) {
431
+ for (let x = 0; x < N; x++) {
432
+ const dx = (x + 0.5) / N * 2 - 1, dy = (y + 0.5) / N * 2 - 1;
433
+ const a = Math.max(0, 1 - Math.hypot(dx, dy));
434
+ const i = (y * N + x) * 4;
435
+ px[i] = 255; px[i + 1] = 255; px[i + 2] = 255; px[i + 3] = Math.round(a * a * 255);
436
+ }
437
+ }
438
+ const tex = device.createTexture({ label: "sprite", size: [N, N], format: "rgba8unorm", usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
439
+ device.queue.writeTexture({ texture: tex }, px, { bytesPerRow: N * 4, rowsPerImage: N }, [N, N]);
440
+ return tex;
441
+ }
442
+
443
+ function show_message(text) {
444
+ const d = document.createElement("div");
445
+ d.textContent = text;
446
+ d.style.cssText = "position:fixed;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-family:sans-serif;font-size:20px";
447
+ document.body.appendChild(d);
448
+ }
449
+
450
+ main().catch((e) => { console.error(e); show_message("Particle prototype failed to start — see console. " + e.message); });