From 0af2886351f9054007054c53a44c9e2aedc96c65 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 14 Sep 2026 11:06:55 +0200 Subject: [PATCH] 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. --- Architecture.md | 10 ++++++++++ Development.md | 4 ++-- Particles.md | 19 +++++++++++++++++++ Shaders.md | 6 ++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Architecture.md b/Architecture.md index d4d488e..28e34a6 100644 --- a/Architecture.md +++ b/Architecture.md @@ -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. diff --git a/Development.md b/Development.md index 16484b1..3c05641 100644 --- a/Development.md +++ b/Development.md @@ -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. diff --git a/Particles.md b/Particles.md index 8c4dd5b..26b04d5 100644 --- a/Particles.md +++ b/Particles.md @@ -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. diff --git a/Shaders.md b/Shaders.md index 7e47956..7ce3a0b 100644 --- a/Shaders.md +++ b/Shaders.md @@ -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