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 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 is a decoration of `SceneViewport`, which is editor-only for free because `GameViewport` never syncs
any. 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 Comments state contracts
and reasons, not what the code does: a `/** */` on every export whose name does not say it all, and reasons, not what the code does: a `/** */` on every export whose name does not say it all,
`//` only for a decision or gotcha. `//` 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 # code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html npm run build # writes dist/ and regenerates index.html
./build.sh # the same under `set -o pipefail`, for callers that pipe the output ./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 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
@@ -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 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 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 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. | | **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. | | **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. |
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
can. Overriding one of them on a placed object is an ordinary override, with reset and apply. 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* 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.
+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 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. 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 ## 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 A shader runs in the sprite's own draw: the mesh is drawn once, with the graph's fragment in place