@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
@@ -278,7 +278,10 @@ class Ticker {
278
278
 
279
279
  this.#addLifecycleListeners();
280
280
 
281
- requestAnimationFrame(animationFrameCallback);
281
+ //recorded, so a stop() taken before the first frame lands can cancel it. The generation guard
282
+ //already makes the callback a no-op, but an uncancelled frame request keeps the closures (and
283
+ //everything the tick listeners hold) alive until it fires
284
+ this.#animationFrameHandle = requestAnimationFrame(animationFrameCallback);
282
285
 
283
286
  //if we are starting while the page is already in the background, suspend right away
284
287
  const doc = globalThis.document;
@@ -1 +1 @@
1
- {"version":3,"file":"GUIEngine.d.ts","sourceRoot":"","sources":["../../../../src/engine/ui/GUIEngine.js"],"names":[],"mappings":";AA4BA;IACI,mBAAqB;IAErB;;;OAGG;IACH,eAFU,gBAAc,IAAI,CAEP;IAErB;;;OAGG;IACH,eAAc;IAEd;;;OAGG;IACH,eAFU,eAAe,eAAe,CAAC,CAEX;IAE9B;;;OAGG;IACH,UAFU,cAAc,CAEQ;IAEhC;;;OAGG;IACH,cAFU,iBAAiB,CAEyB;IAGpD;;;OAGG;IACH,QAFU,MAAM,CAEM;IAEtB,gBAOG;IAEH;;;OAGG;IACH,KAFU,SAAS,CAEG;IAEtB;;;OAGG;IACH,QAFU,cAAc,CAEuB;IAG/C;;;OAGG;IACH,cAFU,eAAa,IAAI,CAEP;IAqBpB;;;;;;OAMG;IACH,mDANW,OAAO,GAIL,MAAM,CAoClB;IAED;;;;;;OAMG;IACH,0CALW,IAAI,GAGF,eAAe,CA+D3B;IAED;;;;;;OAMG;IACH,iEALW,MAAM,GAGJ,QAAQ,GAAG,CAAC,CA0DxB;IAED;;;;OAIG;IACH,mCAJW,MAAM,gBAWhB;IAED;;;;;OAKG;IACH,iCAJW,MAAM,gBAUhB;IAED;;;;;;;;OAQG;IACH,gEAPW,IAAI,gBA0Dd;IAED;;;;OAIG;IACH,kCAFY,eAAe,CAgB1B;IAED;;OAEG;IACH,yBAFa,eAAe,GAAC,IAAI,CAkBhC;IAED;;;OAGG;IACH,8BAKC;IAED;;;OAGG;IACH,wCAiEC;IAED,2BAYC;CACJ;iBAxgBgB,oCAAoC;gCAyBrB,4BAA4B;+BAf7B,sCAAsC;kCAFnC,yCAAyC;mBAexD,yBAAyB;sBAjBtB,kCAAkC;0BAG9B,qCAAqC;2BAPpC,oCAAoC;mBAiB5C,kBAAkB;iBAPpB,oBAAoB;gCATqB,uCAAuC"}
1
+ {"version":3,"file":"GUIEngine.d.ts","sourceRoot":"","sources":["../../../../src/engine/ui/GUIEngine.js"],"names":[],"mappings":";AA4BA;IACI,mBAAqB;IAErB;;;OAGG;IACH,eAFU,gBAAc,IAAI,CAEP;IAErB;;;OAGG;IACH,eAAc;IAEd;;;OAGG;IACH,eAFU,eAAe,eAAe,CAAC,CAEX;IAE9B;;;OAGG;IACH,UAFU,cAAc,CAEQ;IAEhC;;;OAGG;IACH,cAFU,iBAAiB,CAEyB;IAGpD;;;OAGG;IACH,QAFU,MAAM,CAEM;IAEtB,gBAOG;IAEH;;;OAGG;IACH,KAFU,SAAS,CAEG;IAEtB;;;OAGG;IACH,QAFU,cAAc,CAEuB;IAG/C;;;OAGG;IACH,cAFU,eAAa,IAAI,CAEP;IAqBpB;;;;;;OAMG;IACH,mDANW,OAAO,GAIL,MAAM,CAoClB;IAED;;;;;;OAMG;IACH,0CALW,IAAI,GAGF,eAAe,CA+D3B;IAED;;;;;;OAMG;IACH,iEALW,MAAM,GAGJ,QAAQ,GAAG,CAAC,CA0DxB;IAED;;;;OAIG;IACH,mCAJW,MAAM,gBAWhB;IAED;;;;;OAKG;IACH,iCAJW,MAAM,gBAUhB;IAED;;;;;;;;OAQG;IACH,gEAPW,IAAI,gBA0Dd;IAED;;;;OAIG;IACH,kCAFY,eAAe,CAgB1B;IAED;;OAEG;IACH,yBAFa,eAAe,GAAC,IAAI,CAkBhC;IAED;;;OAGG;IACH,8BAKC;IAED;;;OAGG;IACH,wCAiEC;IAED,2BAkBC;CACJ;iBA9gBgB,oCAAoC;gCAyBrB,4BAA4B;+BAf7B,sCAAsC;kCAFnC,yCAAyC;mBAexD,yBAAyB;sBAjBtB,kCAAkC;0BAG9B,qCAAqC;2BAPpC,oCAAoC;mBAiB5C,kBAAkB;iBAPpB,oBAAoB;gCATqB,uCAAuC"}
@@ -506,6 +506,12 @@ class GUIEngine {
506
506
  }
507
507
 
508
508
  shutdown() {
509
+ // startup() armed this loop, so shutdown() is what has to disarm it. Left running it keeps
510
+ // animating views against a GUI that has been torn down, and its frame/timer chain outlives
511
+ // the host it belonged to - under a test environment that means calling
512
+ // requestAnimationFrame after the environment that provided it is gone.
513
+ this.ticker.stop();
514
+
509
515
  this.windows.reset();
510
516
  this.entityManager = null;
511
517
 
@@ -0,0 +1,98 @@
1
+ # GPU particle system — playground
2
+
3
+ The whole GPU particle path running in a real scene: emitters, the VM, the alive/dead lists and the
4
+ billboard renderer, driven end to end by the GPU with no renderer, no assets and no `Renderer`
5
+ dependency. It moved here from `renderer/particles/prototypeParticleSystem.js`, which had no page to
6
+ open it with.
7
+
8
+ ## Running it
9
+
10
+ ```bash
11
+ npm run dev --workspace @woosh/meep-engine
12
+ ```
13
+
14
+ then open <http://localhost:5173/src/shade/playground/particle_system/index.html>.
15
+
16
+ It has to be `http://localhost` rather than `file://`: WebGPU needs a secure context, and module
17
+ imports from `file://` are blocked by CORS anyway. The page imports nothing but meep's own relative
18
+ ESM, so any static server rooted at the repo also serves it.
19
+
20
+ **It needs immediate data**, and refuses to start without it. Emit, simulate and build-indirect each
21
+ carry their per-dispatch state (`u_dt`, `u_time`, `u_frame`, and for emit the request count and the
22
+ pool capacity) as an immediate block rather than as a transient uniform buffer, so an adapter whose
23
+ WGSL lacks the `immediate` address space cannot build any of the three. The refusal names the byte
24
+ counts. Chrome/Edge 113+ with immediates; Claude Code's in-app browser pane is **not** one — it
25
+ reports no adapter at all today, and no `immediate_address_space` in `wgslLanguageFeatures`.
26
+
27
+ ## What is on screen
28
+
29
+ Four emitters sharing one 32k pool, chosen so each renderer path is visible at once:
30
+
31
+ | emitter | blend | projection | what it is there to show |
32
+ |---|---|---|---|
33
+ | fire | additive | billboard | `fire_effect()` straight out of `particle_prototype.js`, at the foot of the pillar |
34
+ | smoke | alpha | billboard, soft-depth | a rising column that fades where it meets the pillar rather than cutting into it |
35
+ | sparks | additive | **stretched** | short-lived, gravity-fed, stretched along velocity — the one non-billboard projection |
36
+ | dust | alpha | billboard, soft-depth | a wide slow drift, so the ground plane and the low wall both get a soft intersection |
37
+
38
+ Ground plane and three boxes are drawn first into a reverse-Z `depth32float` buffer. The billboard
39
+ pass loads that colour target and depth-tests against the same buffer read-only, so particles are
40
+ **occluded** by the boxes and the soft-depth emitters sample the depth they were occluded by.
41
+
42
+ Things worth looking at:
43
+
44
+ - particles disappearing behind the pillar as the camera orbits — that is the depth test,
45
+ - smoke and dust softening where they touch a surface instead of showing a hard intersection line —
46
+ that is `PARTICLE_FLAG_SOFT_DEPTH` and the reverse-Z linearisation,
47
+ - sparks leaning into their own velocity — `EMITTER_PROJECTION.STRETCHED`,
48
+ - both blend modes in **one** draw with one pipeline: `particle_premultiply` picks per particle, so
49
+ there is no additive pass and no alpha pass.
50
+
51
+ The overlay shows fps and, every 30 frames, the alive count, the free-list length and the two
52
+ indirect arg buffers. Those are read back **to look at**. Nothing on the CPU sizes a pass.
53
+
54
+ ## The property the page exists to demonstrate
55
+
56
+ The frame is GPU-driven end to end. `shader_particle_build_indirect` writes both the simulate
57
+ dispatch args and the billboard draw args on the GPU; the next frame's simulate runs through
58
+ `dispatchWorkgroupsIndirect` and this frame's draw through `drawIndirect`. The host's only sizing
59
+ decision is how many *new* particles to spawn, which is a per-emitter rate times `dt` and has
60
+ nothing to do with how many are already alive.
61
+
62
+ Sizing either from a CPU readback instead would be a correctness bug rather than a latency one: a
63
+ stale count that is too small leaves the tail of the alive list unsimulated, so those slots are
64
+ never freed, and one that is too large re-simulates entries past the end, pushing a dead slot onto
65
+ the free list twice — it is then popped twice and two live particles share one record.
66
+
67
+ ## Verifying it
68
+
69
+ `particle_scene.js` holds the scene with every reference to the page removed, so
70
+ `particle_scene.spec.js` can encode the same frames against `SoftwareGPUDevice`:
71
+
72
+ ```bash
73
+ npx vitest run src/shade/playground/particle_system/particle_scene.spec.js
74
+ ```
75
+
76
+ That mock runs no shaders, so the spec asserts nothing about what a pass computed. What it does
77
+ assert is that the frame is legal and wired the way the design requires — pass order and
78
+ attachments, the alive lists ping-ponging so no frame reads the list it appends to, the immediate
79
+ blocks written before every dispatch that declares them, and simulate and the draw both taking their
80
+ counts from the indirect buffers. Any WebGPU validation failure anywhere in the frame arrives as an
81
+ uncaptured error, which every test fails on.
82
+
83
+ The picture is the browser's job; the spec cannot see it.
84
+
85
+ ### Stepping frames by hand
86
+
87
+ Add `?debug` to the URL. `requestAnimationFrame` is then not used at all, and `window.__step(n)`
88
+ runs exactly `n` frames and returns the frame counter — which is what makes the loop drivable in a
89
+ hidden or headless tab, where rAF is suspended. `window.__stats` carries the last counters snapshot.
90
+
91
+ ## What this page does not cover
92
+
93
+ Depth sorting. `EMITTER_FLAG.SORT` and the bucketed depth sort in `particles/sort/` (histogram →
94
+ csdldf scan → scatter) are not wired in here: the billboard pass draws the alive list directly, so
95
+ alpha-blended emitters are in emission order rather than back-to-front. The two soft-depth emitters are faint and diffuse enough that it
96
+ does not read as wrong, which is exactly why it should not be taken as evidence that sorting works.
97
+ Lighting is the unlit hook, too — `chunk_particle_lighting_unlit`, not the shading-chunk-backed one
98
+ the renderer feature swaps in.
@@ -0,0 +1,124 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>meep — GPU particle system</title>
6
+ <style>
7
+ :root {
8
+ color-scheme: dark;
9
+ --bg: #05070a;
10
+ --panel: #1c1f25e0;
11
+ --line: #2b3038;
12
+ --text: #d6dae0;
13
+ --muted: #8b939f;
14
+ --fail: #ff6b6b;
15
+ }
16
+
17
+ * { box-sizing: border-box; }
18
+
19
+ html, body { height: 100%; }
20
+
21
+ body {
22
+ margin: 0;
23
+ overflow: hidden;
24
+ background: var(--bg);
25
+ color: var(--text);
26
+ font: 14px/1.5 system-ui, -apple-system, Segoe UI, sans-serif;
27
+ }
28
+
29
+ #view {
30
+ display: block;
31
+ width: 100%;
32
+ height: 100%;
33
+ }
34
+
35
+ #hud {
36
+ position: fixed;
37
+ top: 12px;
38
+ left: 12px;
39
+ min-width: 210px;
40
+ padding: 10px 12px;
41
+ border: 1px solid var(--line);
42
+ border-radius: 8px;
43
+ background: var(--panel);
44
+ backdrop-filter: blur(6px);
45
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
46
+ font-size: 12px;
47
+ pointer-events: none;
48
+ }
49
+
50
+ #hud h1 {
51
+ font: inherit;
52
+ font-weight: 600;
53
+ margin: 0 0 6px;
54
+ letter-spacing: 0.04em;
55
+ text-transform: uppercase;
56
+ color: var(--muted);
57
+ }
58
+
59
+ #hud div { display: flex; gap: 10px; justify-content: space-between; }
60
+ #hud .key { color: var(--muted); }
61
+
62
+ #legend {
63
+ position: fixed;
64
+ bottom: 12px;
65
+ left: 12px;
66
+ max-width: 62ch;
67
+ padding: 10px 12px;
68
+ border: 1px solid var(--line);
69
+ border-radius: 8px;
70
+ background: var(--panel);
71
+ backdrop-filter: blur(6px);
72
+ font-size: 12.5px;
73
+ color: var(--muted);
74
+ pointer-events: none;
75
+ }
76
+
77
+ #legend b { color: var(--text); font-weight: 600; }
78
+ #legend p { margin: 0 0 4px; }
79
+ #legend p:last-child { margin-bottom: 0; }
80
+
81
+ #message {
82
+ position: fixed;
83
+ inset: 0;
84
+ display: none;
85
+ align-items: center;
86
+ justify-content: center;
87
+ padding: 32px;
88
+ text-align: center;
89
+ background: #05070ae6;
90
+ color: var(--fail);
91
+ font-size: 16px;
92
+ white-space: pre-wrap;
93
+ }
94
+
95
+ #message:not(:empty) { display: flex; }
96
+ </style>
97
+ </head>
98
+ <body>
99
+
100
+ <canvas id="view"></canvas>
101
+
102
+ <div id="hud">
103
+ <h1>GPU particles</h1>
104
+ <div><span class="key">fps</span><span id="stat-fps">—</span></div>
105
+ <div><span class="key">alive</span><span id="stat-alive">—</span></div>
106
+ <div><span class="key">free</span><span id="stat-dead">—</span></div>
107
+ <div><span class="key">draw instances</span><span id="stat-draw">—</span></div>
108
+ <div><span class="key">sim workgroups</span><span id="stat-dispatch">—</span></div>
109
+ </div>
110
+
111
+ <div id="legend">
112
+ <p><b>Four emitters, one pool.</b> Additive fire at the pillar, an alpha soft-depth smoke column
113
+ above it, velocity-stretched sparks by the block, and a wide soft-depth dust drift.</p>
114
+ <p>Emit, simulate and the billboard draw are all GPU-sized — the simulate dispatch and the draw
115
+ read indirect args the build-indirect pass writes. The counters above are read back purely to
116
+ display; nothing on the CPU sizes a pass.</p>
117
+ </div>
118
+
119
+ <div id="message"></div>
120
+
121
+ <script type="module" src="./main.js"></script>
122
+
123
+ </body>
124
+ </html>
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=main.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../../../../src/shade/playground/particle_system/main.js"],"names":[],"mappings":""}
@@ -0,0 +1,180 @@
1
+ import { ShadeGPUCommandContext } from "../../device/ShadeGPUCommandContext.js";
2
+ import {
3
+ device_supports_immediate_size,
4
+ immediate_data_supported,
5
+ } from "../../device/immediate_data_supported.js";
6
+ import { GraphicsContext } from "../../renderer/GraphicsContext.js";
7
+ import {
8
+ PARTICLE_IMMEDIATE_BYTES,
9
+ PARTICLE_STATS_BYTES,
10
+ create_particle_scene,
11
+ } from "./particle_scene.js";
12
+
13
+ /**
14
+ * The page for the GPU particle playground: acquire a device, build the scene
15
+ * ({@link ./particle_scene.js}, which is where the interesting part lives), and drive it from
16
+ * `requestAnimationFrame`.
17
+ *
18
+ * Serve the workspace with `npm run dev --workspace @woosh/meep-engine` and open
19
+ * `/src/shade/playground/particle_system/index.html`. See README.md for what to look at, and for
20
+ * the `?debug` query flag that swaps rAF for manual stepping.
21
+ */
22
+
23
+ /** How often the counters are copied back for the overlay. Display only — nothing sizes a pass. */
24
+ const STATS_INTERVAL_FRAMES = 30;
25
+
26
+ /** Device pixels per CSS pixel, capped: 32k billboards at 3x cost more than they show. */
27
+ const MAX_PIXEL_RATIO = 2;
28
+
29
+ async function main() {
30
+ const canvas = document.getElementById("view");
31
+
32
+ if (navigator.gpu === undefined) {
33
+ return show_message("WebGPU is not available in this browser.");
34
+ }
35
+
36
+ const adapter = await navigator.gpu.requestAdapter();
37
+
38
+ if (adapter === null) {
39
+ return show_message("No WebGPU adapter — this browser exposes the API but has no usable GPU.");
40
+ }
41
+
42
+ const device = await adapter.requestDevice();
43
+ device.addEventListener?.("uncapturederror", (e) => console.error("WebGPU error:", e.error.message));
44
+
45
+ // Every compute pass here carries its per-dispatch state as immediate data, an optional
46
+ // capability. Refusing by name beats the pipeline-creation error a device without it raises,
47
+ // which names neither the shader nor the feature.
48
+ if (!immediate_data_supported() || !device_supports_immediate_size(device, PARTICLE_IMMEDIATE_BYTES)) {
49
+ return show_message(
50
+ "This browser's WebGPU offers no usable immediate data "
51
+ + `(needs ${PARTICLE_IMMEDIATE_BYTES} bytes, offers ${device.limits.maxImmediateSize ?? 0}).\n`
52
+ + "The particle emit / simulate / build-indirect passes all carry their per-dispatch\n"
53
+ + "state that way, so none of them can be built here."
54
+ );
55
+ }
56
+
57
+ const graphics = new GraphicsContext(device);
58
+ const context = canvas.getContext("webgpu");
59
+ const format = navigator.gpu.getPreferredCanvasFormat();
60
+ context.configure({ device, format, alphaMode: "opaque" });
61
+
62
+ const scene = create_particle_scene(graphics, { color_format: format });
63
+
64
+ // Manual frame stepping instead of rAF — opt in with ?debug. Useful for driving the loop a
65
+ // known number of frames in a headless / hidden tab, where rAF is suspended.
66
+ const debug = new URLSearchParams(location.search).has("debug");
67
+
68
+ const stats_buffer = device.createBuffer({
69
+ label: "particles/stats",
70
+ size: PARTICLE_STATS_BYTES,
71
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
72
+ });
73
+ let stats_pending = false;
74
+
75
+ let last = performance.now();
76
+ let frame_index = 0;
77
+ let fps = 0;
78
+
79
+ function frame(now) {
80
+ const dt = Math.min((now - last) / 1000, 0.05);
81
+ last = now;
82
+ // A rAF-driven loop steps by wall clock; ?debug steps by hand, where dt can be 0.
83
+ fps = dt > 0 ? fps * 0.9 + 0.1 / dt : fps;
84
+
85
+ // Back the canvas with device pixels, so a HiDPI display gets a sharp image rather than an
86
+ // upscaled CSS-pixel one.
87
+ const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
88
+ const width = Math.max(1, Math.round(canvas.clientWidth * ratio));
89
+ const height = Math.max(1, Math.round(canvas.clientHeight * ratio));
90
+
91
+ if (canvas.width !== width || canvas.height !== height) {
92
+ canvas.width = width;
93
+ canvas.height = height;
94
+ }
95
+
96
+ const cmd = ShadeGPUCommandContext.create(graphics, "particles/frame");
97
+
98
+ scene.encode_frame(cmd, {
99
+ color_view: context.getCurrentTexture().createView(),
100
+ width,
101
+ height,
102
+ dt,
103
+ time: now / 1000,
104
+ });
105
+
106
+ const capture_stats = !stats_pending && frame_index % STATS_INTERVAL_FRAMES === 0;
107
+ if (capture_stats) {
108
+ scene.encode_stats_copy(cmd, stats_buffer);
109
+ }
110
+
111
+ cmd.finish();
112
+
113
+ frame_index++;
114
+
115
+ if (capture_stats) {
116
+ stats_pending = true;
117
+ stats_buffer.mapAsync(GPUMapMode.READ).then(() => {
118
+ const bytes = stats_buffer.getMappedRange().slice(0);
119
+ stats_buffer.unmap();
120
+ stats_pending = false;
121
+
122
+ window.__stats = { frame: frame_index, fps, ...scene.read_stats(bytes) };
123
+ write_hud(window.__stats);
124
+ }).catch(() => {
125
+ stats_pending = false;
126
+ });
127
+ }
128
+
129
+ if (!debug) {
130
+ requestAnimationFrame(frame);
131
+ }
132
+ }
133
+
134
+ if (debug) {
135
+ // Drive frames by hand when the tab is hidden (rAF suspended) — headless verification.
136
+ // Nothing is scheduled: a stray rAF frame interleaved with __step would make the count the
137
+ // caller asked for and the count that ran two different numbers.
138
+ window.__step = (n = 1) => {
139
+ for (let i = 0; i < n; i++) {
140
+ frame(performance.now());
141
+ }
142
+ return frame_index;
143
+ };
144
+ return;
145
+ }
146
+
147
+ requestAnimationFrame(frame);
148
+ }
149
+
150
+ /** Push one stats snapshot into the overlay. Absent elements are simply skipped. */
151
+ function write_hud({ fps, alive, dead, dispatch, draw }) {
152
+ const put = (id, value) => {
153
+ const node = document.getElementById(id);
154
+ if (node !== null) {
155
+ node.textContent = value;
156
+ }
157
+ };
158
+
159
+ put("stat-fps", fps.toFixed(0));
160
+ put("stat-alive", alive.toLocaleString());
161
+ put("stat-dead", dead.toLocaleString());
162
+ put("stat-draw", draw[1].toLocaleString());
163
+ put("stat-dispatch", dispatch[0].toLocaleString());
164
+ }
165
+
166
+ function show_message(text) {
167
+ const existing = document.getElementById("message");
168
+ const node = existing ?? document.body.appendChild(document.createElement("div"));
169
+
170
+ if (existing === null) {
171
+ node.style.cssText = "position:fixed;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-family:sans-serif;font-size:16px;white-space:pre-wrap;text-align:center";
172
+ }
173
+
174
+ node.textContent = text;
175
+ }
176
+
177
+ main().catch((e) => {
178
+ console.error(e);
179
+ show_message("The particle playground failed to start — see the console.\n\n" + e.message);
180
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @typedef {object} ParticleSceneStats
3
+ * @property {number} alive particles alive at the end of the captured frame
4
+ * @property {number} dead free slots left in the pool
5
+ * @property {number} alive_in the snapshot next frame's simulate reads as its input length
6
+ * @property {number[]} dispatch simulate's indirect dispatch args, `[x, y, z]` workgroups
7
+ * @property {number[]} draw the billboard draw's indirect args, `[vertices, instances]`
8
+ */
9
+ /**
10
+ * Build the scene against a live (or mock) device.
11
+ *
12
+ * @param {GraphicsContext} graphics
13
+ * @param {object} options
14
+ * @param {GPUTextureFormat} options.color_format format of the colour target frames are encoded into
15
+ * @param {number} [options.capacity] particle pool size
16
+ * @returns {object} the scene, see the returned object's own members
17
+ */
18
+ export function create_particle_scene(graphics: GraphicsContext, { color_format, capacity }: {
19
+ color_format: GPUTextureFormat;
20
+ capacity?: number;
21
+ }): object;
22
+ /**
23
+ * The largest immediate block any of the three compute passes declares (emit's 20-byte settings
24
+ * struct). Immediate data is an optional WebGPU capability, so callers check for it up front rather
25
+ * than letting pipeline creation fail with an error that names neither pass nor feature.
26
+ */
27
+ export const PARTICLE_IMMEDIATE_BYTES: 20;
28
+ /** Bytes of stats {@link ParticleScene#encode_stats_copy} writes: 8 counters, then both arg buffers. */
29
+ export const PARTICLE_STATS_BYTES: 64;
30
+ export type ParticleSceneStats = {
31
+ /**
32
+ * particles alive at the end of the captured frame
33
+ */
34
+ alive: number;
35
+ /**
36
+ * free slots left in the pool
37
+ */
38
+ dead: number;
39
+ /**
40
+ * the snapshot next frame's simulate reads as its input length
41
+ */
42
+ alive_in: number;
43
+ /**
44
+ * simulate's indirect dispatch args, `[x, y, z]` workgroups
45
+ */
46
+ dispatch: number[];
47
+ /**
48
+ * the billboard draw's indirect args, `[vertices, instances]`
49
+ */
50
+ draw: number[];
51
+ };
52
+ //# sourceMappingURL=particle_scene.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"particle_scene.d.ts","sourceRoot":"","sources":["../../../../../src/shade/playground/particle_system/particle_scene.js"],"names":[],"mappings":"AAyEA;;;;;;;GAOG;AAEH;;;;;;;;GAQG;AACH;IAJqC,YAAY;IACrB,QAAQ,GAAzB,MAAM;IACJ,MAAM,CA8OlB;AA3QD;;;;GAIG;AACH,0CAA2C;AAE3C,wGAAwG;AACxG,sCAAuC;;;;;WAOzB,MAAM;;;;UACN,MAAM;;;;cACN,MAAM;;;;cACN,MAAM,EAAE;;;;UACR,MAAM,EAAE"}