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) # 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/snapshots.test.php # the snapshot store (#231), the same way
php tests/php/releases.test.php # the release store (#266), 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 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 start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result ./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. | | **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. | | **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. | | **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 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 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 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. 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* 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. 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 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. 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 **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 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 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 with 32 subdivisions is below the 57 at 400, so single values here are good to a few frames, not
to one. 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 ## In the code
`src/core/shader/nodes.ts` is the catalogue, `src/core/shader/compile.ts` turns a graph into the `src/core/shader/nodes.ts` is the catalogue, `src/core/shader/compile.ts` turns a graph into the