@woosh/meep-engine 3.18.0 → 3.19.0
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.
- package/build/bundle-worker-image-decoder.js +1 -1
- package/editor/view/particles/effect/ParticleCurveEditorView.d.ts.map +1 -1
- package/editor/view/particles/effect/ParticleCurveEditorView.js +303 -120
- package/editor/view/particles/effect/ParticleGradientEditorView.d.ts.map +1 -1
- package/editor/view/particles/effect/ParticleGradientEditorView.js +108 -63
- package/editor/view/particles/effect/ParticleGraphEditorView.js +1 -1
- package/editor/view/particles/effect/ParticleNodeParametersView.d.ts.map +1 -1
- package/editor/view/particles/effect/ParticleNodeParametersView.js +3 -1
- package/editor/view/particles/effect/particle-editor.css +60 -30
- package/package.json +1 -2
- package/samples/engine/README.md +1 -1
- package/src/core/binary/compression/decompress_bytes.d.ts +13 -0
- package/src/core/binary/compression/decompress_bytes.d.ts.map +1 -0
- package/src/core/binary/compression/decompress_bytes.js +28 -0
- package/src/core/model/node-graph/visual/layout/layout_assign_coordinates.js +67 -22
- package/src/engine/asset/loaders/image/ImageDecoderWorker.js +12 -27
- package/src/engine/asset/loaders/image/prototypePNG.js +8 -7
- package/src/engine/ecs/storage/populateEngineSerializationRegistry.d.ts.map +1 -1
- package/src/engine/ecs/storage/populateEngineSerializationRegistry.js +4 -0
- package/src/engine/graphics/ecs/particles/ParticleEffect.d.ts +266 -0
- package/src/engine/graphics/ecs/particles/ParticleEffect.d.ts.map +1 -0
- package/src/engine/graphics/ecs/particles/ParticleEffect.js +455 -0
- package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.d.ts +58 -0
- package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.d.ts.map +1 -0
- package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.js +219 -0
- package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts +178 -25
- package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts.map +1 -1
- package/src/engine/graphics3/GPUParticleEmitterSystem.js +809 -310
- package/src/format/image/png/PNGReader.d.ts +7 -6
- package/src/format/image/png/PNGReader.d.ts.map +1 -1
- package/src/format/image/png/PNGReader.js +13 -12
- package/src/format/image/png/chunk/png_chunk_decode_iTXt.d.ts +3 -2
- package/src/format/image/png/chunk/png_chunk_decode_iTXt.d.ts.map +1 -1
- package/src/format/image/png/chunk/png_chunk_decode_iTXt.js +5 -4
- package/src/format/image/png/chunk/png_chunk_decode_zTXt.d.ts +3 -2
- package/src/format/image/png/chunk/png_chunk_decode_zTXt.d.ts.map +1 -1
- package/src/format/image/png/chunk/png_chunk_decode_zTXt.js +5 -4
- package/src/format/image/png/png_inflate.d.ts +3 -3
- package/src/format/image/png/png_inflate.d.ts.map +1 -1
- package/src/format/image/png/png_inflate.js +29 -39
- package/src/format/texture/ktx2/ktx2_read.d.ts +4 -4
- package/src/format/texture/ktx2/ktx2_read.d.ts.map +1 -1
- package/src/format/texture/ktx2/ktx2_read.js +18 -21
- package/src/shade/playground/particle_ecs/README.md +203 -0
- package/src/shade/playground/particle_ecs/bonfire_editor.d.ts +39 -0
- package/src/shade/playground/particle_ecs/bonfire_editor.d.ts.map +1 -0
- package/src/shade/playground/particle_ecs/bonfire_editor.js +315 -0
- package/src/shade/playground/particle_ecs/bonfire_effects.d.ts +145 -0
- package/src/shade/playground/particle_ecs/bonfire_effects.d.ts.map +1 -0
- package/src/shade/playground/particle_ecs/bonfire_effects.js +202 -0
- package/src/shade/playground/particle_ecs/bonfire_sprites.d.ts +23 -0
- package/src/shade/playground/particle_ecs/bonfire_sprites.d.ts.map +1 -0
- package/src/shade/playground/particle_ecs/bonfire_sprites.js +315 -0
- package/src/shade/playground/particle_ecs/bonfire_world.d.ts +86 -0
- package/src/shade/playground/particle_ecs/bonfire_world.d.ts.map +1 -0
- package/src/shade/playground/particle_ecs/bonfire_world.js +303 -0
- package/src/shade/playground/particle_ecs/effects/embers.json +1634 -0
- package/src/shade/playground/particle_ecs/effects/flame.json +1882 -0
- package/src/shade/playground/particle_ecs/effects/smoke.json +1860 -0
- package/src/shade/playground/particle_ecs/effects/soot.json +1606 -0
- package/src/shade/playground/particle_ecs/index.html +330 -0
- package/src/shade/playground/particle_ecs/main.d.ts +2 -0
- package/src/shade/playground/particle_ecs/main.d.ts.map +1 -0
- package/src/shade/playground/particle_ecs/main.js +566 -0
- package/src/shade/playground/particle_ecs/moonlit_environment.d.ts +16 -0
- package/src/shade/playground/particle_ecs/moonlit_environment.d.ts.map +1 -0
- package/src/shade/playground/particle_ecs/moonlit_environment.js +144 -0
- package/src/shade/playground/particle_editor/README.md +5 -4
- package/src/shade/playground/profile_hotkey.d.ts +58 -0
- package/src/shade/playground/profile_hotkey.d.ts.map +1 -0
- package/src/shade/playground/profile_hotkey.js +325 -0
- package/src/shade/playground/ssr_variance/README.md +105 -0
- package/src/shade/playground/ssr_variance/capture.d.ts +16 -0
- package/src/shade/playground/ssr_variance/capture.d.ts.map +1 -0
- package/src/shade/playground/ssr_variance/capture.js +96 -0
- package/src/shade/playground/ssr_variance/index.html +22 -0
- package/src/shade/playground/ssr_variance/main.d.ts +2 -0
- package/src/shade/playground/ssr_variance/main.d.ts.map +1 -0
- package/src/shade/playground/ssr_variance/main.js +246 -0
- package/src/shade/playground/ssr_variance/reference.d.ts +18 -0
- package/src/shade/playground/ssr_variance/reference.d.ts.map +1 -0
- package/src/shade/playground/ssr_variance/reference.js +78 -0
- package/src/shade/playground/ssr_variance/scene.d.ts +11 -0
- package/src/shade/playground/ssr_variance/scene.d.ts.map +1 -0
- package/src/shade/playground/ssr_variance/scene.js +61 -0
- package/src/shade/playground/ssr_variance/statistics.d.ts +44 -0
- package/src/shade/playground/ssr_variance/statistics.d.ts.map +1 -0
- package/src/shade/playground/ssr_variance/statistics.js +51 -0
- package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts.map +1 -1
- package/src/shade/renderer/loader/gltf/tiny-gltf.js +9 -3
- package/src/shade/renderer/loader/usd/usd_decode_image.d.ts +4 -4
- package/src/shade/renderer/loader/usd/usd_decode_image.d.ts.map +1 -1
- package/src/shade/renderer/loader/usd/usd_decode_image.js +4 -4
- package/src/shade/renderer/particles/DESIGN.md +748 -703
- package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts +11 -4
- package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts.map +1 -1
- package/src/shade/renderer/particles/runtime/ParticleEmitter.js +431 -424
- package/src/shade/renderer/postprocess/ssr/SSR.d.ts.map +1 -1
- package/src/shade/renderer/postprocess/ssr/SSR.js +2 -1
- package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.d.ts.map +1 -1
- package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.js +0 -6
- package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.d.ts.map +1 -1
- package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.js +19 -5
- package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.d.ts +4 -0
- package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.d.ts.map +1 -0
- package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.js +133 -0
- package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.d.ts +0 -8
- package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.d.ts.map +1 -1
- package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.js +16 -113
- package/src/shade/renderer/texture/source/texel_data_from_ktx2.d.ts +2 -2
- package/src/shade/renderer/texture/source/texel_data_from_ktx2.d.ts.map +1 -1
- package/src/shade/renderer/texture/source/texel_data_from_ktx2.js +3 -3
- package/src/shade/renderer/particles/shaders/chunk_particle_emitter_warmup.d.ts +0 -20
- package/src/shade/renderer/particles/shaders/chunk_particle_emitter_warmup.d.ts.map +0 -1
- package/src/shade/renderer/particles/shaders/chunk_particle_emitter_world_sphere.d.ts +0 -14
- package/src/shade/renderer/particles/shaders/chunk_particle_emitter_world_sphere.d.ts.map +0 -1
- package/src/shade/renderer/particles/shaders/shader_particle_reclaim.d.ts +0 -50
- package/src/shade/renderer/particles/shaders/shader_particle_reclaim.d.ts.map +0 -1
- package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts +0 -17
- package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts.map +0 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Bonfire — GPU particles as ECS entities
|
|
2
|
+
|
|
3
|
+
A moonlit fire pit, and the demonstrator for the ECS particle API. Everything in the set is an
|
|
4
|
+
entity carrying a `Transform64` and one more component — the ground and the logs a `ShadedGeometry`,
|
|
5
|
+
the moon and the firelight a `Light`, and the flame, smoke, embers and soot a **`ParticleEffect`**.
|
|
6
|
+
Four systems observe those pairs and put rows into one `Scene`. The page owns no scene graph, no
|
|
7
|
+
frame graph, no pass and no emitter object.
|
|
8
|
+
|
|
9
|
+
Spawning a fire is therefore this, and nothing else:
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
new Entity()
|
|
13
|
+
.add(new Transform64())
|
|
14
|
+
.add(ParticleEffect.from({ ...create_particle_effect({ layout, init, update }), spawn_rate: 560 }))
|
|
15
|
+
.build(dataset);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
with `entityManager.addSystem(new GPUParticleEmitterSystem(graphics, scene, assetManager))` once, at
|
|
19
|
+
boot. Move the entity and the fire moves; destroy it and the fire goes out. That is the point of the
|
|
20
|
+
page: an effect is placed, moved, saved and destroyed by the same machinery as everything else, and
|
|
21
|
+
`../particle_system/` next door is what it used to take — emitters built as scene nodes and added to
|
|
22
|
+
a `Scene` by hand.
|
|
23
|
+
|
|
24
|
+
## Running it
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm run dev --workspace @woosh/meep-engine
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
then open <http://localhost:5173/src/shade/playground/particle_ecs/index.html>.
|
|
31
|
+
|
|
32
|
+
It has to be `http://localhost` rather than `file://`: WebGPU needs a secure context, and module
|
|
33
|
+
imports from `file://` are blocked by CORS anyway.
|
|
34
|
+
|
|
35
|
+
**It needs `subgroups` and immediate data**, both of which the renderer requires of any device it
|
|
36
|
+
accepts. Chrome/Edge 113+ with immediates; Claude Code's in-app browser pane is **not** one — it has
|
|
37
|
+
reported no adapter at all since 2026-09-02.
|
|
38
|
+
|
|
39
|
+
## What is on screen
|
|
40
|
+
|
|
41
|
+
| entity | component | what it is there to show |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| flame | `ParticleEffect` | additive billboards, short-lived, stirred by noise and pinched inward so the column narrows |
|
|
44
|
+
| smoke | `ParticleEffect` | alpha, soft-depth, expanding, firelit at the base and moonlit above it |
|
|
45
|
+
| embers | `ParticleEffect` | small additive motes, buoyant then falling, flickering on their own clock |
|
|
46
|
+
| soot | `ParticleEffect` | dark tumbling flakes that ride the plume and fall back through the firelight |
|
|
47
|
+
| ground, logs, stones | `ShadedGeometry` | something for the fire to light, and for the smoke to fade against |
|
|
48
|
+
| moon | `Light` (directional) | the cool half of the lighting, aimed at the moon in the environment map |
|
|
49
|
+
| firelight | `Light` (point) | the warm half, flickered every frame by writing the component |
|
|
50
|
+
|
|
51
|
+
The sky is a generated octahedral environment map (`moonlit_environment.js`) and the four sprites are
|
|
52
|
+
generated PNGs (`bonfire_sprites.js`), so a fresh checkout with no `test_assets/` looks exactly like
|
|
53
|
+
everybody else's. The four effects are saved documents under `effects/` — the editor opens them, and
|
|
54
|
+
what it writes is what the page reads.
|
|
55
|
+
|
|
56
|
+
### The two controls, and why they are the whole API
|
|
57
|
+
|
|
58
|
+
- **The checkboxes** write `effect.emitting`. No system call, no node, and nothing to remember the
|
|
59
|
+
rate with — `emitting` gates `spawn_rate` without clearing it, and the system's next comparison
|
|
60
|
+
finds the change. Particles already alive burn out on their own, which is what putting a fire out
|
|
61
|
+
looks like.
|
|
62
|
+
- **"Stoke the fire"** calls `particles.burst(entity, count)`. The request is queued and handed over
|
|
63
|
+
after the next frame's membership sweep, so it works before the first frame has run, and it goes
|
|
64
|
+
down the same GPU spawn path the continuous rate does.
|
|
65
|
+
|
|
66
|
+
### Editing an effect while it burns
|
|
67
|
+
|
|
68
|
+
<kbd>E</kbd> switches between looking at the fire and editing it. The editor takes the **whole
|
|
69
|
+
window** — the graph, the instructions it compiles to, the emitter settings and a CPU preview, over
|
|
70
|
+
the effect the selector names — and <kbd>E</kbd> again puts you back in the scene. Not a panel with a
|
|
71
|
+
share of the screen: a node graph wants every pixel it can get, and so does the fire.
|
|
72
|
+
|
|
73
|
+
Nothing is drawn while it is up. The editor covers the window, so a frame underneath it is a whole
|
|
74
|
+
scene — the transparency pipeline, the particle passes, the resolve — composited behind an opaque
|
|
75
|
+
panel and thrown away. Measured at 1075x1216, the ten-a-second trickle this replaces cost 3.5 ms of
|
|
76
|
+
CPU to encode and about 21 ms from submission to completion, each. The machine goes to the graph
|
|
77
|
+
editor, which is the thing you are actually looking at.
|
|
78
|
+
|
|
79
|
+
An edit still reaches the GPU; it waits for the way back. `GPUParticleEmitterSystem` publishes the
|
|
80
|
+
emitter table from `prepare` at FrameStart rather than from its tick, and that is idempotent by
|
|
81
|
+
design — a frame must not depend on a simulation tick having preceded it — so the first frame after
|
|
82
|
+
<kbd>Esc</kbd> carries everything you changed. What you give up is that the fire no longer advances
|
|
83
|
+
behind the editor: the simulation is GPU work, so you come back to the instant you left rather than
|
|
84
|
+
a few seconds on. An edit kills and restarts the effect anyway, which is where that would otherwise
|
|
85
|
+
have shown.
|
|
86
|
+
|
|
87
|
+
Change anything and the fire changes. What happens is worth knowing, because it is the reason a live
|
|
88
|
+
edit is safe: the document recompiles and the entity's `ParticleEffect` component is **removed and
|
|
89
|
+
added back**, not mutated. An edit can change the attribute layout, and a layout change repoints
|
|
90
|
+
every word of a particle record — replacing the component retires the emitter's table row and takes
|
|
91
|
+
its generation with it, so the particles running the old program are killed rather than reinterpreted
|
|
92
|
+
under the new one. The effect restarts; for a fire that is what you wanted to see anyway. An effect
|
|
93
|
+
that does not compile leaves the previous one burning and says so in the toolbar.
|
|
94
|
+
|
|
95
|
+
**The round trip is the point.** Each effect *is* a saved document — `effects/flame.json` and its
|
|
96
|
+
three siblings, which is what `bonfire_effects.js` loads. Press **Download**, drop the file over the
|
|
97
|
+
one it came from, reload, and the page is running what you edited. There is no porting step and no
|
|
98
|
+
authoring code to keep in step, because the file the editor writes is the file the page reads.
|
|
99
|
+
**Import…** does the same without the reload, for trying a file out.
|
|
100
|
+
|
|
101
|
+
Two things to know before editing one:
|
|
102
|
+
|
|
103
|
+
- **One store per attribute per phase.** Every store runs and nothing orders them, so a second store
|
|
104
|
+
does not refine the first, it replaces it. That is how the smoke's drag silently ate its own
|
|
105
|
+
velocity integration — the picture only looked slightly wrong — until the editor's validator was
|
|
106
|
+
pointed at it. The graph editor calls it an error and `bonfire.spec.js` pins that none of the four
|
|
107
|
+
has one; nothing on the runtime path checks.
|
|
108
|
+
- **The register budget is shared.** See below.
|
|
109
|
+
|
|
110
|
+
## What to look at
|
|
111
|
+
|
|
112
|
+
- **The smoke fades where it meets the ground and the logs** rather than showing the hard line a
|
|
113
|
+
camera-facing quad cuts into a surface. That is `soft_depth`, sampling the same depth buffer the
|
|
114
|
+
AVBOIT draw depth-tests against.
|
|
115
|
+
- **The flame does not darken what is behind it.** Additive emitters write the emission accumulator,
|
|
116
|
+
which the resolve adds outside the coverage normalization — so the fire lights the frame instead of
|
|
117
|
+
taking coverage from it. The smoke, which is matter, does cover.
|
|
118
|
+
- **The soot flakes cross in front of the flame and are lit by nothing**, and still read, because
|
|
119
|
+
they are dark against something bright. That is the cheapest depth cue in the set.
|
|
120
|
+
- **Orbit around it.** The particles and the transparent surfaces composite against each other with
|
|
121
|
+
no order between them — both write extinction into one voxel volume and both resolve out of the
|
|
122
|
+
same accumulators. Switch the renderer to MBOIT and the particles vanish while everything else
|
|
123
|
+
stays: MBOIT is the legacy path and has no particle channel.
|
|
124
|
+
|
|
125
|
+
### Profiling
|
|
126
|
+
|
|
127
|
+
<kbd>T</kbd> starts a GPU capture, <kbd>T</kbd> again stops it and saves an `.sgpt`. The status line
|
|
128
|
+
bottom-right says what it is doing and what the file was called; the console repeats the name, because
|
|
129
|
+
a browser that declines the save says nothing about which capture it declined.
|
|
130
|
+
|
|
131
|
+
Worth having on this page in particular: four distinct compiled programs means the simulate pass runs
|
|
132
|
+
four coherence buckets, and what that costs relative to the emit, the bounds measurement and the three
|
|
133
|
+
AVBOIT side-channel passes is not something anybody can guess. The capture's note carries which
|
|
134
|
+
effects were emitting and how many particles were alive when it was taken.
|
|
135
|
+
|
|
136
|
+
The wiring is `../profile_hotkey.js`, four lines at the bottom of `main.js`. The engine never
|
|
137
|
+
constructs a `GPUProfileSession` — `Renderer.profile_session` is a nullable field and the null checks
|
|
138
|
+
around it are the whole integration, which is what keeps the profiler out of a bundle that does not
|
|
139
|
+
ask for it.
|
|
140
|
+
|
|
141
|
+
## What it deliberately does not do
|
|
142
|
+
|
|
143
|
+
- **No emitter opts into `EMITTER_FLAG.LIGHTING`.** The shipped hook is
|
|
144
|
+
`chunk_particle_lighting_unlit`, a pass-through, so the flag would cost a bit in a record and
|
|
145
|
+
change no pixel. The smoke and the soot are *painted* lit instead: their gradients carry the fire's
|
|
146
|
+
warmth low down and the moon's blue higher up, over the particle's own age. When a lit hook lands,
|
|
147
|
+
those gradients are the thing to reconsider.
|
|
148
|
+
- **No emitter opts into culling.** One fire, always on screen; the frustum test in the tick pass
|
|
149
|
+
would never be taken. The measured bounds are still published every frame and still read by the
|
|
150
|
+
occupancy pass.
|
|
151
|
+
- **No flipbooks.** Every sprite here is a single frame. `../particle_system/` has the flipbook path.
|
|
152
|
+
|
|
153
|
+
## Verifying it
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
npx vitest run src/shade/playground/particle_ecs
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`bonfire_effects.js`, `bonfire_world.js` and `moonlit_environment.js` have every reference to the
|
|
160
|
+
page removed, so the spec opens the real documents, compiles them, and builds the real world with no
|
|
161
|
+
device:
|
|
162
|
+
|
|
163
|
+
- every saved document opens, compiles, and carries no diagnostic the editor would show — which is
|
|
164
|
+
the duplicate-store check above, pinned against the data rather than against the detector,
|
|
165
|
+
- every effect's compiled program fits `PARTICLE_VM_FAST_REGISTER_SLOTS`. That is a **shared**
|
|
166
|
+
budget, and the reason it is a test rather than a comment: one program needing more than the
|
|
167
|
+
register file holds swaps emit and simulate for storage-backed variants *for the whole system*, so
|
|
168
|
+
an effect that grows past it does not slow itself down, it slows down every effect in the scene,
|
|
169
|
+
- the render channels resolve to attributes that exist, and the blend modes are what each effect is,
|
|
170
|
+
- every entity built carries a `Transform64` and exactly one more component,
|
|
171
|
+
- the sky averages a hundredth of daylight and its brightest texel is where `MOON_DIRECTION` points,
|
|
172
|
+
so the shadows on the ground fall away from the moon that is actually in the map.
|
|
173
|
+
|
|
174
|
+
What a particle *frame* encodes is not this page's business: that is pinned next to the code, against
|
|
175
|
+
`SoftwareGPUDevice`, in `renderer/particles/graph_particles.spec.js` (the simulation, pass by pass)
|
|
176
|
+
and `graph_particles_avboit.spec.js` (the three side-channel passes). The ECS half —
|
|
177
|
+
membership, placement, the field comparison, the atlas — is pinned in
|
|
178
|
+
`engine/graphics3/GPUParticleEmitterSystem.spec.js`.
|
|
179
|
+
|
|
180
|
+
The picture is the browser's job; no spec here can see it.
|
|
181
|
+
|
|
182
|
+
### Stepping frames by hand
|
|
183
|
+
|
|
184
|
+
Add `?debug` to the URL. `requestAnimationFrame` is then not used at all, and `window.__step(n)` runs
|
|
185
|
+
exactly `n` frames and returns the frame counter — which is what makes the loop drivable in a hidden
|
|
186
|
+
or headless tab, where rAF is suspended. `window.__stats` carries the last counters snapshot, and
|
|
187
|
+
`camera_state()` / `apply_camera(...)` copy a view between sessions. `globalThis` also carries
|
|
188
|
+
`graphics`, `scene`, `dataset`, `entities`, `particles` and `world`, which is enough to drive the
|
|
189
|
+
whole ECS from a console or a CDP session.
|
|
190
|
+
|
|
191
|
+
## Cost
|
|
192
|
+
|
|
193
|
+
About 1,600 live particles at the default rates, four distinct compiled programs, and a frame that is
|
|
194
|
+
GPU-driven end to end: the host walks no emitters and counts no particles. The emitter tick pass
|
|
195
|
+
integrates every rate on the GPU, a prefix scan of its counts sizes the emit dispatch, and
|
|
196
|
+
`shader_particle_build_indirect` writes the simulate dispatch args and the draw args. The one thing
|
|
197
|
+
the CPU asks for is the burst button, and it asks through the same path.
|
|
198
|
+
|
|
199
|
+
The four programs are around 620 VM instructions each in UPDATE, most of which is the three
|
|
200
|
+
`simplex3` samples the turbulence is made of. That is deliberately not the library's `curl3`, which
|
|
201
|
+
is twelve of them for a divergence-free field: over a metre of flame nobody can tell, and the four
|
|
202
|
+
effects share the register budget above — the editor's toolbar reports both numbers after every
|
|
203
|
+
recompile, which is the cheapest way to notice an edit that has gone past them.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {object} EffectEditorHandle
|
|
3
|
+
* @property {function(): boolean} is_open
|
|
4
|
+
* @property {function(): void} toggle
|
|
5
|
+
* @property {function(string): void} select which effect the editor shows
|
|
6
|
+
* @property {function(): void} dispose
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} params
|
|
10
|
+
* @param {import("../../../engine/ecs/EntityComponentDataset.js").EntityComponentDataset} params.dataset
|
|
11
|
+
* @param {import("./bonfire_world.js").BonfireWorld} params.world
|
|
12
|
+
* @param {Object<string, string|null>} params.sprites image URL per effect name
|
|
13
|
+
* @param {HTMLElement} params.host the panel the editor is mounted into; shown and hidden by this
|
|
14
|
+
* @param {HTMLSelectElement} params.selector which effect is being edited
|
|
15
|
+
* @param {HTMLElement} params.status one line about the last compile
|
|
16
|
+
* @param {HTMLElement} params.mount where the editor view's element goes, inside `host`
|
|
17
|
+
* @returns {EffectEditorHandle}
|
|
18
|
+
*/
|
|
19
|
+
export function install_effect_editor({ dataset, world, sprites, host, selector, status, mount }: {
|
|
20
|
+
dataset: import("../../../engine/ecs/EntityComponentDataset.js").EntityComponentDataset;
|
|
21
|
+
world: import("./bonfire_world.js").BonfireWorld;
|
|
22
|
+
sprites: {
|
|
23
|
+
[x: string]: string | null;
|
|
24
|
+
};
|
|
25
|
+
host: HTMLElement;
|
|
26
|
+
selector: HTMLSelectElement;
|
|
27
|
+
status: HTMLElement;
|
|
28
|
+
mount: HTMLElement;
|
|
29
|
+
}): EffectEditorHandle;
|
|
30
|
+
export type EffectEditorHandle = {
|
|
31
|
+
is_open: () => boolean;
|
|
32
|
+
toggle: () => void;
|
|
33
|
+
/**
|
|
34
|
+
* which effect the editor shows
|
|
35
|
+
*/
|
|
36
|
+
select: (arg0: string) => void;
|
|
37
|
+
dispose: () => void;
|
|
38
|
+
};
|
|
39
|
+
//# sourceMappingURL=bonfire_editor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bonfire_editor.d.ts","sourceRoot":"","sources":["../../../../../src/shade/playground/particle_ecs/bonfire_editor.js"],"names":[],"mappings":"AA0CA;;;;;;GAMG;AAEH;;;;;;;;;;GAUG;AACH;IATkG,OAAO,EAA9F,OAAO,+CAA+C,EAAE,sBAAsB;IAC5B,KAAK,EAAvD,OAAO,oBAAoB,EAAE,YAAY;IACL,OAAO;YAApC,MAAM,GAAE,MAAM,GAAC,IAAI;;IACN,IAAI,EAAxB,WAAW;IACe,QAAQ,EAAlC,iBAAiB;IACG,MAAM,EAA1B,WAAW;IACS,KAAK,EAAzB,WAAW;IACT,kBAAkB,CA+P9B;;mBA9QyB,OAAO;kBACP,IAAI;;;;mBACP,MAAM,KAAG,IAAI;mBACV,IAAI"}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { ActionProcessor } from "../../../core/process/undo/ActionProcessor.js";
|
|
2
|
+
import { ParticleEffect } from "../../../engine/graphics/ecs/particles/ParticleEffect.js";
|
|
3
|
+
import {
|
|
4
|
+
ParticleEffectEditorView
|
|
5
|
+
} from "../../../../editor/view/particles/effect/ParticleEffectEditorView.js";
|
|
6
|
+
import {
|
|
7
|
+
BONFIRE_EFFECT_NAMES,
|
|
8
|
+
bonfire_effect_component,
|
|
9
|
+
bonfire_effect_document,
|
|
10
|
+
particle_node_visuals,
|
|
11
|
+
} from "./bonfire_effects.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The particle editor, over the fire that is burning.
|
|
15
|
+
*
|
|
16
|
+
* Open it, pick an effect, change something, and the fire changes — while it burns, in the scene it
|
|
17
|
+
* belongs to, lit by the light it is lighting. That is the point: an effect is a thing you judge by
|
|
18
|
+
* looking at it in place, and an editor on a page of its own can only ever show you a preview of it
|
|
19
|
+
* against nothing.
|
|
20
|
+
*
|
|
21
|
+
* ## How an edit reaches the GPU
|
|
22
|
+
*
|
|
23
|
+
* The document recompiles and the entity's component is **replaced** — removed and added back — not
|
|
24
|
+
* mutated. That is deliberate and it is the reason a live edit is safe at all: an edit can change the
|
|
25
|
+
* attribute layout, and a layout change repoints every word of a particle record. Replacing the
|
|
26
|
+
* component retires the emitter's table row and takes its generation with it, so the particles that
|
|
27
|
+
* were running the old program are killed on the next simulate rather than being reinterpreted
|
|
28
|
+
* under the new one. Mutating `effect.program` in place would keep the row, keep the particles, and
|
|
29
|
+
* read their records through a layout that no longer describes them.
|
|
30
|
+
*
|
|
31
|
+
* The cost is that the effect restarts. For a fire that is what you want to see anyway.
|
|
32
|
+
*
|
|
33
|
+
* ## What it does not do
|
|
34
|
+
*
|
|
35
|
+
* It does not write files. A downloaded document is the whole effect — drop it over
|
|
36
|
+
* `effects/<name>.json` and the page loads it next time, with no porting step, because the file the
|
|
37
|
+
* editor writes is the file the page reads.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** How long the editor waits after the last keystroke before recompiling. Milliseconds. */
|
|
41
|
+
const APPLY_DELAY_MS = 250;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {object} EffectEditorHandle
|
|
45
|
+
* @property {function(): boolean} is_open
|
|
46
|
+
* @property {function(): void} toggle
|
|
47
|
+
* @property {function(string): void} select which effect the editor shows
|
|
48
|
+
* @property {function(): void} dispose
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {object} params
|
|
53
|
+
* @param {import("../../../engine/ecs/EntityComponentDataset.js").EntityComponentDataset} params.dataset
|
|
54
|
+
* @param {import("./bonfire_world.js").BonfireWorld} params.world
|
|
55
|
+
* @param {Object<string, string|null>} params.sprites image URL per effect name
|
|
56
|
+
* @param {HTMLElement} params.host the panel the editor is mounted into; shown and hidden by this
|
|
57
|
+
* @param {HTMLSelectElement} params.selector which effect is being edited
|
|
58
|
+
* @param {HTMLElement} params.status one line about the last compile
|
|
59
|
+
* @param {HTMLElement} params.mount where the editor view's element goes, inside `host`
|
|
60
|
+
* @returns {EffectEditorHandle}
|
|
61
|
+
*/
|
|
62
|
+
export function install_effect_editor({ dataset, world, sprites, host, selector, status, mount }) {
|
|
63
|
+
/**
|
|
64
|
+
* The open documents, by effect name. Kept across close/open, because closing the editor to look
|
|
65
|
+
* at the fire and losing the edit would make the editor useless for the one thing it is for.
|
|
66
|
+
*
|
|
67
|
+
* @type {Map<string, import("../../../../editor/particles/effect/ParticleEffectDocument.js").ParticleEffectDocument>}
|
|
68
|
+
*/
|
|
69
|
+
const documents = new Map();
|
|
70
|
+
|
|
71
|
+
/** @type {Map<string, function():void>} the change subscription of each open document */
|
|
72
|
+
const subscriptions = new Map();
|
|
73
|
+
|
|
74
|
+
const entities = {
|
|
75
|
+
flame: world.flame,
|
|
76
|
+
smoke: world.smoke,
|
|
77
|
+
embers: world.embers,
|
|
78
|
+
soot: world.soot,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const actionProcessor = new ActionProcessor({});
|
|
82
|
+
|
|
83
|
+
/** @type {ParticleEffectEditorView|null} */
|
|
84
|
+
let view = null;
|
|
85
|
+
|
|
86
|
+
/** @type {string} */
|
|
87
|
+
let active = BONFIRE_EFFECT_NAMES[0];
|
|
88
|
+
|
|
89
|
+
/** @type {number} */
|
|
90
|
+
let apply_timer = 0;
|
|
91
|
+
|
|
92
|
+
/** @type {boolean} */
|
|
93
|
+
let open = false;
|
|
94
|
+
|
|
95
|
+
for (const name of BONFIRE_EFFECT_NAMES) {
|
|
96
|
+
const option = document.createElement("option");
|
|
97
|
+
|
|
98
|
+
option.value = name;
|
|
99
|
+
option.textContent = name;
|
|
100
|
+
|
|
101
|
+
selector.appendChild(option);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} name
|
|
106
|
+
* @returns {import("../../../../editor/particles/effect/ParticleEffectDocument.js").ParticleEffectDocument}
|
|
107
|
+
*/
|
|
108
|
+
function open_document(name) {
|
|
109
|
+
const existing = documents.get(name);
|
|
110
|
+
|
|
111
|
+
if (existing !== undefined) {
|
|
112
|
+
return existing;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const opened = bonfire_effect_document(name);
|
|
116
|
+
|
|
117
|
+
documents.set(name, opened);
|
|
118
|
+
|
|
119
|
+
const on_changed = () => {
|
|
120
|
+
clearTimeout(apply_timer);
|
|
121
|
+
|
|
122
|
+
// Debounced, because a change is announced per keystroke and per dragged node, and a
|
|
123
|
+
// recompile is a graph lowering plus the optimizer.
|
|
124
|
+
apply_timer = setTimeout(() => apply(name), APPLY_DELAY_MS);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
opened.on.changed.add(on_changed);
|
|
128
|
+
subscriptions.set(name, () => opened.on.changed.remove(on_changed));
|
|
129
|
+
|
|
130
|
+
return opened;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Recompile one effect and put the result on its entity.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} name
|
|
137
|
+
*/
|
|
138
|
+
function apply(name) {
|
|
139
|
+
const source = documents.get(name);
|
|
140
|
+
|
|
141
|
+
if (source === undefined) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const component = bonfire_effect_component(source, sprites[name]);
|
|
146
|
+
const index = BONFIRE_EFFECT_NAMES.indexOf(name);
|
|
147
|
+
const previous = world.effects[index];
|
|
148
|
+
|
|
149
|
+
if (component === null) {
|
|
150
|
+
const errors = source.compile().diagnostics.filter(entry => entry.severity === "error");
|
|
151
|
+
|
|
152
|
+
set_status(
|
|
153
|
+
`${name} does not compile — ${errors.length} error${errors.length === 1 ? "" : "s"}`
|
|
154
|
+
+ `, the previous one is still burning`,
|
|
155
|
+
true
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Whether the effect is currently switched off is the page's state rather than the
|
|
162
|
+
// document's, so it survives the swap: unticking a box and then editing the graph should not
|
|
163
|
+
// relight it.
|
|
164
|
+
component.emitting = previous.emitting;
|
|
165
|
+
|
|
166
|
+
const entity = entities[name];
|
|
167
|
+
|
|
168
|
+
/*
|
|
169
|
+
Removed and added rather than assigned: see the note at the top. The system's `unlink` takes
|
|
170
|
+
the emitter node out of the scene, which retires its table row and kills the particles that
|
|
171
|
+
were running the old program; `link` builds a new one on the next comparison.
|
|
172
|
+
*/
|
|
173
|
+
dataset.removeComponentFromEntity(entity, ParticleEffect);
|
|
174
|
+
dataset.addComponentToEntity(entity, component);
|
|
175
|
+
|
|
176
|
+
world.effects[index] = component;
|
|
177
|
+
|
|
178
|
+
const warnings = source.compile().diagnostics.length;
|
|
179
|
+
|
|
180
|
+
set_status(
|
|
181
|
+
`${name} applied — ${component.program.init.length / 4 | 0} + `
|
|
182
|
+
+ `${component.program.update.length / 4 | 0} instructions, `
|
|
183
|
+
+ `${component.program.register_count} registers`
|
|
184
|
+
+ (warnings > 0 ? `, ${warnings} diagnostic${warnings === 1 ? "" : "s"}` : "")
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string} text
|
|
190
|
+
* @param {boolean} [failed]
|
|
191
|
+
*/
|
|
192
|
+
function set_status(text, failed = false) {
|
|
193
|
+
status.textContent = text;
|
|
194
|
+
status.className = failed ? "failed" : "";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Mount the editor over the document for `name`, replacing whatever was mounted.
|
|
199
|
+
*
|
|
200
|
+
* A view per document rather than one view re-pointed: `ParticleEffectEditorView` takes its
|
|
201
|
+
* effect at construction and every panel inside it has subscribed to that one.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} name
|
|
204
|
+
*/
|
|
205
|
+
function mount_view(name) {
|
|
206
|
+
if (view !== null) {
|
|
207
|
+
view.unlink();
|
|
208
|
+
view.el.remove();
|
|
209
|
+
view = null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
view = new ParticleEffectEditorView({ effect: open_document(name), actionProcessor });
|
|
213
|
+
|
|
214
|
+
mount.appendChild(view.el);
|
|
215
|
+
|
|
216
|
+
view.link();
|
|
217
|
+
|
|
218
|
+
resize();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function resize() {
|
|
222
|
+
if (view === null) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
view.size.set(mount.clientWidth, mount.clientHeight);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {string} name
|
|
231
|
+
*/
|
|
232
|
+
function select(name) {
|
|
233
|
+
if (!BONFIRE_EFFECT_NAMES.includes(name)) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
active = name;
|
|
238
|
+
selector.value = name;
|
|
239
|
+
|
|
240
|
+
if (open) {
|
|
241
|
+
mount_view(name);
|
|
242
|
+
set_status(`editing ${name}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function show() {
|
|
247
|
+
open = true;
|
|
248
|
+
host.hidden = false;
|
|
249
|
+
|
|
250
|
+
mount_view(active);
|
|
251
|
+
|
|
252
|
+
set_status(`editing ${active}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function hide() {
|
|
256
|
+
open = false;
|
|
257
|
+
host.hidden = true;
|
|
258
|
+
|
|
259
|
+
if (view !== null) {
|
|
260
|
+
view.unlink();
|
|
261
|
+
view.el.remove();
|
|
262
|
+
view = null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function toggle() {
|
|
267
|
+
if (open) {
|
|
268
|
+
hide();
|
|
269
|
+
} else {
|
|
270
|
+
show();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
selector.addEventListener("change", () => select(selector.value));
|
|
275
|
+
|
|
276
|
+
window.addEventListener("resize", resize);
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
is_open: () => open,
|
|
280
|
+
toggle,
|
|
281
|
+
select,
|
|
282
|
+
|
|
283
|
+
/** The document currently being edited, for the page's download and import buttons. */
|
|
284
|
+
current: () => open_document(active),
|
|
285
|
+
|
|
286
|
+
/** @param {object} json */
|
|
287
|
+
import_json: json => {
|
|
288
|
+
const target = open_document(active);
|
|
289
|
+
|
|
290
|
+
target.fromJSON(json, particle_node_visuals());
|
|
291
|
+
|
|
292
|
+
// `fromJSON` batches, so the change lands as one — but the apply is debounced off it and
|
|
293
|
+
// a freshly imported effect should be on screen now rather than in a quarter second.
|
|
294
|
+
clearTimeout(apply_timer);
|
|
295
|
+
apply(active);
|
|
296
|
+
|
|
297
|
+
if (open) {
|
|
298
|
+
mount_view(active);
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
dispose: () => {
|
|
303
|
+
hide();
|
|
304
|
+
|
|
305
|
+
for (const unsubscribe of subscriptions.values()) {
|
|
306
|
+
unsubscribe();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
subscriptions.clear();
|
|
310
|
+
documents.clear();
|
|
311
|
+
|
|
312
|
+
window.removeEventListener("resize", resize);
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|