Describe the field shader of a particle source (#170)

- Particles: rows Field shader, Field width and Field height, and a
  section on how the frame, its UV, its resolution and the checks
  work.
- Shaders: a pointer to it, and the measurement with 1, 10 and 50
  sources under What it costs.
- Development: e2e total 1707.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
adminandClaude Opus 5.5 committed 2026-10-03 12:25:17 +02:00
1 parent 7bddb77b5f
commit 315ee9215c
3 files changed
+50 -1

No files matched your search

+1 -1
@@ -18,7 +18,7 @@ php tests/php/accounts.test.php # the API's accoun
# has and the web server's container has not (SKIP there)
php tests/php/snapshots.test.php # the snapshot store (#231), the same way
php tests/php/releases.test.php # the release store (#266), the same way
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1704 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1707 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
+28
@@ -87,6 +87,8 @@ mirror), so hanging one on a moving ship turns the whole spray with it. Its own
| **Color** | white | The tint, and what an unconnected `color` output reads. |
| **Move with source** | off | Off: a particle stays where it was born, so a moving source leaves a trail (rocket smoke). On: it hangs on the source and travels with it (sparks on a carried torch). The difference shows only while the source moves. |
| **Shader** | none | A shader asset worn by **every particle** of this source, the way a sprite wears one. Its `Property` rows appear below, like a sprite's. |
| **Field shader** | none | A shader asset laid over the **whole field** at once (see *A shader over the whole field*). Its rows appear below as well. |
| **Field width**, **Field height** | 256 | The frame the field shader covers, in units, centred on the source. Shown only while a field shader is picked. |
Below them come the rows the asset's graph declares, one per `Property` node — so two fountains of
the same asset can spray at different strengths, exactly the way two sprites wearing the same shader
@@ -110,6 +112,32 @@ own row wins and the uniform of that name keeps its declared default: two rows c
one row with two meanings. Nothing is rejected on the shader's side — the same shader on a sprite is
perfectly fine, it is only this source that already has the name.
**A shader over the whole field** (#170). The *Field shader* row lays a shader over everything the
source draws at once: the particles are drawn into a picture of a fixed frame, *Field width* by
*Field height* units centred on the source, and the shader draws that picture. So it can do what a
shader per particle cannot: a glow or blur where particles overlap, an outline round the whole
cloud, a ripple through all of it. It is an ordinary shader asset; its `u`/`v` run from 0 to 1
across the frame and never jump, however the cloud changes, and `Texture color`, `Texture alpha`
and `Sample texture` read the picture of the particles (`Texture size` is that picture in pixels).
The frame is the edge: whatever flies past it is cut off, so make it as large as the effect reaches.
The scene view draws it as a dashed rectangle round the selected source. It turns and grows with the
source.
Both shaders may be worn together: the one per particle draws into the picture, the field shader
over it. A name both of them declare is one row, and both read it. The field shader's *Padding* and
*Vertex output* do nothing here: the frame already is the room around the field, and its corners
stay put, as with a nine-sliced sprite.
The picture has as many pixels as the frame covers on screen, so it stays sharp at any zoom, but at
most 2048 per side. A source whose frame is off screen draws no picture at all. What it costs is one
extra pass per source, sized by the frame, not by the number of particles; see *What it costs* in
[Shaders](Shaders) for the measurement.
Measured in the checks (step 31): a field shader that changes nothing draws the field where it was
and the right way up, with a picture of 256 pixels at zoom 1; a flat red fills the frame 100 units
off the source where no particle is, and stops before 140; taken off again, the particles draw as
before.
A **behavior** can start a source over with the `Emit` node (its `source` input names which one): in *Burst*
mode that is another burst, in *Continuous* mode the stream begins again at particle 0.
+21
@@ -260,6 +260,12 @@ never there to lose. The difference is what the fragment can see — a particle'
age or index — and that a source, having rows of its own graph as well, keeps a name the shader
also declares: the uniform then stays on its default rather than taking the row.
**A source can also wear one over its whole field** (#170, the *Field shader* row, see
*A shader over the whole field* in [Particles](Particles)). There the shader draws a picture of
all the particles inside a fixed frame round the source, so its `u`/`v` span the frame and
`Sample texture` reaches across neighbouring particles: glow, blur and outlines of the whole cloud.
Unlike a shader per particle this is an extra pass, one per source.
**A text object wears one the same way** (#169), through a *Shader* row below Align. Its glyphs are
rasterised into a picture of their own and drawn on the sprite quad, so the graph sees exactly what a
sprite's graph sees: `Texture color` is the glyph pixel in the text's colour, `Texture alpha` the
@@ -314,6 +320,21 @@ Up to 400 wearers a grid hardly shows; at 800 it costs a quarter of the frame ra
with 32 subdivisions is below the 57 at 400, so single values here are good to a few frames, not
to one.
A field shader on a particle source (#170) is the one place a shader costs a pass: the source's
particles are drawn into a picture of its frame first. One run per row with
`tools/perf-field-shaders.mjs` on the same HD 630, sources spread over the 640x360 camera, a frame
of 256 units (256x256 pixels at that zoom), 50 particles a second each, a field shader that hands
the picture through unchanged, in frames per second:
| sources | 1 | 10 | 50 |
| --- | --- | --- | --- |
| no field shader | 60.1 | 60.0 | 59.2 |
| field shader | 60.0 | 60.0 | 57.6 |
Up to 10 sources nothing shows; at 50 the field shaders cost about 1.6 frames a second. The 60 Hz
cap hides what a pass costs inside a frame that is not full, and a graph that samples the picture
many times (a blur) adds its own price per pixel on top of the pass.
## In the code
`src/core/shader/nodes.ts` is the catalogue, `src/core/shader/compile.ts` turns a graph into the