A source wears a shader: what it costs, and what the fragment can see (#88)

Particles: the Shader row, why it is free where a sprite's is not, that the
fragment sees a particle's picture and not its life, and which row wins when a
name arrives from both graphs.

Shaders: the same from the shader's side.

Architecture: where a shader is cheap depends on what already breaks the batch,
and that rows from two places need an owner.

Development: 833 -> 837 checks.
root committed 2026-09-14 11:06:55 +02:00
1 parent 7c3ee88c95
commit 0af2886351
4 files changed
+37 -2

No files matched your search

+10
@@ -126,6 +126,16 @@ by adding it there would have taken its particles out of the game. The two quest
functions — `hasSceneIcon` for the drawing, `isSymbolType` for the decoration — and the icon itself
is a decoration of `SceneViewport`, which is editor-only for free because `GameViewport` never syncs
any.
**Where a shader is cheap depends on what already breaks the batch.** A sprite wearing one leaves
Pixi's batched draw and costs a call of its own; a particle field is a single call for its whole
field either way, because the pipe breaks the batch for it regardless — so the same feature that is
expensive per sprite is free per field. The fragment the compiler emits needed no change for it,
only a second vertex stage: Pixi's particle vertex is GLSL ES 1.00 and ours is ES 3.00, so it is
translated rather than invented, and what it hands on is named what the fragment already reads.
**Where rows come from two places, one of them has to be the owner.** A source is the only asset
whose rows are drawn from its own graph *and* a worn shader's, so a name declared twice is a row
with two meanings — #63 again. The source's own row wins and the uniform keeps its default: the
shader cannot be blamed for a collision that only exists at this one wearer.
Comments state contracts
and reasons, not what the code does: a `/** */` on every export whose name does not say it all,
`//` only for a decision or gotcha.
+2 -2
@@ -12,7 +12,7 @@ npm run check # typecheck + lint + format:check + tests, the gate CI runs
# code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html
./build.sh # the same under `set -o pipefail`, for callers that pipe the output
node tools/seed-e2e.mjs && node tools/e2e.mjs # 833 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 837 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
@@ -165,4 +165,4 @@ widths to find click points. The **render device is not in this repo** — `/dev
and a 1 GB `/dev/shm` come from `container.options` in the runner's own `config.yaml`, which
applies to every job on that runner. Without them chromium falls back to llvmpipe, which gets
WebGL 1.0 only. Measured in the job container: `ANGLE (Intel, Vulkan 1.3.230, HD Graphics 630)`,
WebGL 2.0, and the same 833 checks as here.
WebGL 2.0, and the same 837 checks as here.
+19
@@ -86,11 +86,30 @@ mirror), so hanging one on a moving ship turns the whole spray with it. Its own
| **Texture** | none | The picture each particle is drawn with. Without one it is a plain square. |
| **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. |
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
can. Overriding one of them on a placed object is an ordinary override, with reset and apply.
**Wearing a shader.** The *Shader* row puts a shader asset on every particle of the source, and the
shader's own rows follow underneath — the same picker a sprite has. It is the cheapest place in the
editor to wear one: a shaded sprite gives up its batch and costs a draw call of its own, while a
source is already a single draw call for its whole field, so five hundred shaded particles cost
exactly what five hundred plain ones did.
What the shader sees is one particle's **picture**, not its **life**. The fragment gets the quad's
texel, its own UV and the tint the graph computed — but not the particle's age, index or random
number: the buffer a field is drawn from carries five fixed attributes and takes no sixth. That is
less of a loss than it sounds, because colour, alpha, size and rotation over age are what the
particle graph itself is for; the shader adds what that graph cannot do — sampling, distorting UVs,
reading the texture's own size.
A source is the one asset drawing rows from **two** graphs, so a name can arrive twice. The source's
own row wins and the uniform of that name keeps its declared default: two rows called *Rate* would be
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 **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.
+6
@@ -119,6 +119,12 @@ A placed object may override the shader itself; it then shows *that* shader's ro
shader keeps the values of the old one's rows in the bag — they are not visible and do not count as
deviations while their shader is not worn.
**A particle source wears one the same way** (see *Particles*), and there it is free: a field is a
single draw call whether or not it wears a shader, so the batch that a shaded sprite gives up was
never there to lose. The difference is what the fragment can see — a particle's picture, not its
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.
## What it costs, and where it stops
A shader runs in the sprite's own draw: the mesh is drawn once, with the graph's fragment in place