Describe drawing a shader in passes (#301)

- Shaders: the Pass node in the catalogue, the section Drawing in
  passes with the bloom recipe and Wider than 8, the measurements and
  In the code.
- Particles: a field shader counts a pixel as a unit of its frame, so
  a Blur reaches as far at every zoom.
- Development: 1716 editor checks.
admin committed 2026-10-03 14:51:44 +02:00
1 parent e28129747e
commit d36fc4ad1c
3 files changed
+132 -17

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 # 1712 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1716 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
+8 -3
@@ -118,7 +118,8 @@ source draws at once: the particles are drawn into a picture of a fixed frame, *
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).
and `Sample texture` read the picture of the particles (`Texture size` is *Field width* by *Field
height*: a pixel of a field shader is a unit of the frame).
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.
@@ -129,13 +130,17 @@ over it. A name both of them declare is one row, and both read it. The field sha
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
most 2048 per side. Still the shader counts in units of the frame, not in those pixels: a `Blur` of
4 reaches 4 units at 50 % and at 400 %, and a step of `1 / Texture size` is one unit. Zoomed far in,
the samples of a Blur therefore lie several pixels apart. 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
off the source where no particle is, and stops before 140; a Blur of 8 across a flat picture fades
the frame's edge over 8 units at 50 % and at 200 % alike; 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*
+123 -13
@@ -74,14 +74,15 @@ plugged into a colour port, straight from a `Number` or a `Luminance` as well.
| Node | Ports | What it does |
|---|---|---|
| **Texture color** | out `color` | The sprite's own pixel before the shader: its texture tinted by its colour, straight (unpremultiplied). |
| **Texture alpha** | out `value` | The opacity of the sprite's own pixel: 0 transparent, 1 opaque. |
| **Texture color** | in `picture` · out `color` | The sprite's own pixel before the shader: its texture tinted by its colour, straight (unpremultiplied). Fed a Pass's `picture`, that picture's pixel at the same spot (see *Drawing in passes*). |
| **Texture alpha** | in `picture` · out `value` | The opacity of the sprite's own pixel: 0 transparent, 1 opaque. Fed a Pass's `picture`, that picture's. |
| **UV** | out `u`, `v` | Where this pixel is on the sprite's picture, 0 to 1: `u` left to right, `v` top to bottom. |
| **Sample texture** | in `u`, `v` (number) · out `color`, `alpha` | The picture at another spot, tinted like Texture color. Unconnected, `u` and `v` are this pixel's own. Past the border the picture continues as the Output's *Edge* says. |
| **Blur** | in `radius` (number, 2), `u`, `v` (number) · out `color`, `alpha` · *Kind*, *Direction* | The picture softened around a spot, tinted like Texture color. `radius` is in pixels of the picture, 0 to 8, fractions allowed. *Kind* Box (default) weighs every pixel alike, Gaussian the middle more; *Direction* Both, Horizontal or Vertical. Unconnected, `u` and `v` are this pixel's own. See *Softening the picture*. |
| **Texture size** | out `width`, `height` | The picture in pixels: of the frame, when the sprite wears one. |
| **Sample texture** | in `u`, `v` (number), `picture` · out `color`, `alpha` | The picture at another spot, tinted like Texture color. Unconnected, `u` and `v` are this pixel's own and `picture` is the sprite's. Past the border the picture continues as the Output's *Edge* says. |
| **Blur** | in `radius` (number, 2), `u`, `v` (number), `picture` · out `color`, `alpha` · *Kind*, *Direction* | The picture softened around a spot, tinted like Texture color. `radius` is in pixels of the picture, 0 to 8, fractions allowed. *Kind* Box (default) weighs every pixel alike, Gaussian the middle more; *Direction* Both, Horizontal or Vertical. Unconnected, `u` and `v` are this pixel's own and `picture` is the sprite's. See *Softening the picture*. |
| **Texture size** | in `picture` · out `width`, `height` | The picture in pixels: of the frame, when the sprite wears one. Fed a Pass's `picture`, that one's, Padding included. |
| **Time** | out `value` | Seconds since the editor started; see *Coordinates and time* below. |
| **Output** | in `color`, `alpha` (number) | What the sprite is drawn with: the colour, clamped to 0-1, and the opacity, clamped too. Each input left unconnected stays the texture's. One per graph. Its *Edge* setting says how the picture continues past its border when sampled outside it: *Clamp* (the default) holds the border pixel, *Mirror* turns the picture back on itself, *Repeat* starts it over for tiles, *Transparent* leaves everything outside empty. Its *Padding* is room around the picture to draw into, in pixels of the picture per side (see *Room around the picture*). |
| **Pass** | in `color`, `alpha` (number) · out `picture` | Draws what feeds it into a picture of its own first; the nodes with a `picture` input read that picture when it feeds them (see *Drawing in passes*). Unconnected, `color` and `alpha` are the texel's. Its *Resolution* is *Full* (the default), *Half* or *Quarter* of the sprite's picture per side. Up to 8 per graph. |
| **Vertex output** | in `x`, `y` (number) | Moves the corners of the grid the picture is drawn on: `x` to the right and `y` down, in pixels of the picture; the picture bends with them. One per graph, and optional: without it the wearer draws on a plain rectangle. Its *Subdivisions* setting is how many cells the grid has each way, 1 to 64, 8 by default; 1 is the plain rectangle (see *Moving the corners*). |
| **Property** | out `value` | Declares a number the sprite sets: `Name` is the row below the sprite's Shader dropdown and the uniform in the fragment; `Default`, `Min`, `Max` are the row's range. A name is one word (letters, digits, `_`), unique in the graph, and not one of the rows a sprite has anyway — `enabled`, `scale`, `rotation`, `mirrorX`, `mirrorY`, `width`, `height`, `color`, `texture`, `shader`. Both rows would sit on the same key and the uniform would read the sprite's width instead of the declared default, so the node says *"width" is a sprite property* and nothing compiles. |
| **Color property** | out `color` | Declares a colour the sprite sets: the same as **Property**, with a colour row (swatch and hex) instead of a number row and a `vec3` uniform. `Default` is a colour, white unless picked; there is no `Min` and `Max`. Placed from the palette it is called `tint`, then `tint2`. Number and colour properties share one set of names: a second node of the same name says *Another property is named "tint"*. Like `Color` it cannot feed the Vertex output. |
@@ -209,13 +210,14 @@ go where a `Sample texture`'s would; for the plain blurred sprite, both into the
the neighbours half, 1 fully. A radius from a `Property` can therefore be animated smoothly, and
changing it compiles nothing again.
- **Radius 0** is the picture unchanged. Above 8 the radius stays at 8: that is 289 samples per
pixel. Wider blurs and bloom need several passes (#301).
pixel. Wider blurs and bloom are drawn in passes (see *Drawing in passes*).
- **Past the border** the picture continues as the Output's *Edge* says. For a soft edge that
stands free around the sprite, set *Transparent* and give it *Padding* at least as wide as the
radius.
- **The pixel** is the wearer's picture's: a pixel of the frame for a sprite, of the rastered text
for a text (which depends on the zoom with *Linear*), and of the field picture, that is a screen
pixel, for a field shader.
- **The pixel** is the wearer's picture's: a pixel of the frame for a sprite, and of the rastered
text for a text (which depends on the zoom with *Linear*). In a field shader a pixel is a unit of
the frame: the field's picture has as many pixels as the frame covers on screen, but a radius of
4 reaches 4 units of the frame at every zoom.
- **Kind:** *Box* weighs every pixel alike; *Gaussian* weighs the middle more (sigma = radius / 2)
and looks softer at the same radius and the same cost.
@@ -230,6 +232,68 @@ horizontally through a Property, goes to 3/4, 2/3 and 5/9 red at 0.5, 1 and 4 wi
between; Vertical keeps it red, Gaussian keeps more red than Box; with Edge *Transparent* the first
pixel stays red at 5/9 opacity; one white pixel blurred by 1 is a 3x3 spot of 1/9 each.
## Drawing in passes
A **Pass** node (#301) cuts the graph in two. What feeds its `color` and `alpha` is drawn first,
into a picture of its own; its `picture` output then feeds the nodes that read a picture
(`Texture color`, `Texture alpha`, `Sample texture`, `Blur`, `Texture size`), and they read that
picture instead of the sprite's. A `picture` input left unconnected is the sprite's own picture, so
the original is always there beside any pass. A picture plugs only into a `picture` input.
**A wide blur in two passes:** `Blur` *Horizontal* into a Pass (`color` and `alpha` both), its
`picture` into a second `Blur` *Vertical*, that one into the Output. That is the same picture as
one `Blur` *Both* of the same radius, for 2*(2r+1) samples per pixel instead of (2r+1)*(2r+1).
**Wider than 8:** a `Blur` counts its radius in pixels of the picture it reads. Put an open Pass at
*Half* first (nothing into its `color` and `alpha`: it is the picture at half size), feed its
`picture` to the `Blur` *Horizontal*, and set the second Pass to *Half* as well. Both Blurs then
read pixels twice the size, and a radius of 8 reaches 16 pixels of the sprite's picture; *Quarter*
reaches 32. Give the Output a Padding as wide as that. With *Nearest* the shrinking Pass skips
every other pixel and thin details flicker; a `Blur` of 1 into it, instead of leaving it open,
smooths that out.
**Bloom**, the bright parts glowing over the picture:
1. `Texture color` into `Luminance`, that into a `Step` (its `edge` is the threshold, say 0.7).
The Step times `Texture color` is the bright colour, the Step times `Texture alpha` its opacity.
Both into a Pass at *Half*.
2. That picture into a `Blur` *Horizontal* (radius 8), both outputs into a second Pass at *Half*.
3. That picture into a `Blur` *Vertical* (radius 8): the glow, with its `color` and `alpha`.
4. Glow `color` times glow `alpha` (times a strength), `Add`ed to `Texture color`, into the
Output's `color`. For a glow beyond the sprite, the Output's `alpha` as in the glow above (`Mix`
with `a` the glow's `alpha`, `b` 1, `t` `Texture alpha`), Edge *Transparent* and Padding.
What a pass's picture is:
- **The frame** is the sprite's picture and its Padding, the same rectangle the sprite draws
into. Padding is set once, at the Output, and holds for every pass; `Texture size` fed a pass
counts the margin in.
- **Resolution** *Full* has as many pixels as the sprite's picture, *Half* and *Quarter* a half
and a quarter of them per side. A smaller picture is cheaper and blurs further at the same radius;
it is read with the picture's own filter, so *Linear* smooths it and *Nearest* shows its pixels.
- **Untinted.** A pass sees the picture without the sprite's colour; the colour comes on when the
last part (the one into the Output) reads a picture. A pass's picture added to the original is
therefore tinted once, not twice. For a blur that makes no difference; for a threshold like the
Step in Bloom it means the threshold looks at the untinted picture.
- **Past its frame** it continues as the Output's *Edge* says.
- **Drawn when needed.** A pass is drawn again when the picture, its size, the Padding or a
property changes; a still sprite draws its passes once. With `Time` in the graph it is drawn
every frame. Every wearer has pictures of its own.
- **Limits:** up to 8 passes per graph (*At most 8 passes* at the node otherwise) and 2048 pixels
per side. A pass that reads its own picture says *Feeds itself*. A corner has no picture, so a
Pass cannot feed the Vertex output.
Who draws passes: a sprite (a sliced one over its unsliced picture), a text, a particle source and
a field shader (over the field's picture), and the preview in the Shader tab. A particle source
draws its passes once for all its particles, from the particles' picture, and the particle's own
colour comes on at the end.
Measured in the checks (step 28): one white pixel on a 16-pixel picture, blurred by 2 across, a
Pass and blurred down, is the same 5x5 spot of 1/25 each that a `Blur` *Both* draws (difference
0.000); on a sprite tinted grey, the red half's pass picture added to the original comes out at
231/60/60, red once halved in each; two Passes in a row, the first the very program of an earlier
graph's only Pass, still show the picture and not white.
## Moving the corners
Everything above decides what colour a pixel gets. A **Vertex output** node decides where the
@@ -252,7 +316,7 @@ there are no corners in between, and the whole picture leans instead. For a wave
What may feed it is what a corner can know: `UV` (the corner's own place on the picture), `Time`,
`Property`, `Number` and the calculating nodes. A corner has no pixel, so `Texture color`,
`Texture alpha`, `Sample texture`, `Blur`, `Texture size`, `Color` and `Color property` cannot feed it; the Vertex output
`Texture alpha`, `Sample texture`, `Blur`, `Texture size`, `Pass`, `Color` and `Color property` cannot feed it; the Vertex output
then says "Not available per corner: …" and the shader does not compile. The same node may feed
both outputs: one `Property` can drive the bend and the colour.
@@ -332,7 +396,7 @@ texture itself, so pixel art stays sharp: measured in play at a fractional camer
screen pixels per world unit), a *Nearest* texture keeps its hard border pixel for pixel with a
neutral shader on, and brightness 0.5 darkens every one of those pixels — the shader draws there,
and it does not resample. The node set is still
small — no channel split, no second picture; each is one node in
small — no channel split; each is one node in
`src/core/shader/nodes.ts` (a `ShaderNodeDef` with an `emit`) and one row in the table above. A
picture sampled past its border shows what Edge says, never the sprite's neighbour on the sheet: a
frame is a texture of its own. Without a shader the question never comes up — the quad's own
@@ -387,8 +451,37 @@ Output, in frames per second. Without a shader the same scenes held 59.0 to 60.1
| radius 8 | 52.4 | 34.9 | 11.4 |
Radius 2 is cheap. From 4 on the samples set the price: a single field of 256x256 at radius 8 is
289 samples for each of its 65536 pixels and already costs 8 frames a second. Two passes (#301)
would bring that to 34 samples per pixel.
289 samples for each of its 65536 pixels and already costs 8 frames a second.
The same blur in two passes (#301: across, a Pass, down) is 2*(2r+1) samples per pixel, plus a
change of render target per pass. One run per row with `GM_PERF_PASSES=1` in both tools, the same
HD 630 and scenes; the one-pass rows were measured again in the same sitting. Without a shader
the scenes held 58.3 to 60.2. Radius 16 is radius 8 on two passes at *Half* behind an open Pass at
*Half* (see *Wider than 8*), so three draws instead of two. On sprites the radius is tied to
Time, so their Pass is drawn every frame, as a field's always is; a still sprite draws it once and
then only pays the last part.
| sprites of 48x48 | 100 | 400 | 800 |
| --- | --- | --- | --- |
| radius 4, one pass | 53.1 | 50.7 | 35.5 |
| radius 8, one pass | 49.4 | 24.2 | 13.9 |
| radius 4, two passes | 59.1 | 45.6 | 24.1 |
| radius 8, two passes | 56.2 | 46.0 | 24.5 |
| radius 16, three passes at Half | 51.5 | 27.1 | 14.2 |
| field shaders, 256x256 pixels each | 1 | 10 | 50 |
| --- | --- | --- | --- |
| radius 4, one pass | 59.0 | 49.7 | 27.2 |
| radius 8, one pass | 54.5 | 35.0 | 11.4 |
| radius 4, two passes | 60.0 | 55.1 | 49.2 |
| radius 8, two passes | 59.9 | 51.3 | 41.3 |
| radius 16, three passes at Half | 59.9 | 55.6 | 44.4 |
On a large picture two passes pay off: 50 fields at radius 8 go from 11 to 41 frames a second, and
radius 16 at *Half* costs only a little more than that. On small sprites the change of target costs
more than the samples save: the radius hardly matters there, at radius 4 one pass is faster from
400 sprites on, and radius 16, with its third draw, is as slow at 800 sprites as radius 8 in one
pass. Passes are for wide blurs and large pictures, not for every soft edge.
## In the code
@@ -408,6 +501,23 @@ the grid is `gridQuad` in `src/viewport/quadPadding.ts`.
a Blur is reached), and Kind and Direction are its arguments. A node with expensive work declares
it as `prepare` (a vec4 the compiler puts into one local), so `color` and `alpha` read one result
instead of blurring twice.
A Pass is where a walk stops (`cut` in `emitGraph.ts`): the compiler's `passDrawer` walks what feeds
it as a fragment of its own, drawing any Pass it meets in turn first, so `CompiledShader.passes` is
in drawing order and `uPass<i>` is the i-th picture. A pass's fragment has `vColor` as a constant 1,
which is what leaves it untinted. Every program reads a picture through `texelAt(sampler, uv)`; a
pass's picture covers the Padding too, so a place on the sprite's picture becomes one on the pass's
through `passUv` and the uniform `uPassPad`. `src/viewport/shaderPasses.ts` (`PassPictures`) holds
one render texture per pass for each wearer, sized by `passPixels` in `quadPadding.ts`, and draws
them only when the key of picture, size, Padding and uniform values changed (a field's picture is a
render texture that changes under the same id, so it is drawn every time). Destroyed in that order:
the shader first, which lets go of the pictures, then the pictures; the other way round Pixi warns
that a texture was destroyed while still bound. Pixi builds the code that binds a program's
resources once, from the first shader that draws with it, and finds them by position after that;
since two graphs often share a pass's program, every shader declares all 8 pass slots and its
uniform group, even an empty one. Otherwise the second graph reads a placeholder and draws white.
`Blur` and `Texture size` count the picture's size through the uniform `uPixelScale`: 1 on every
wearer but a field, where it is the frame's units per pixel of its picture (`unitsPerPixel` in
`src/viewport/fieldFrame.ts`).
`src/viewport/ShaderLibrary.ts` caches the compiled graph per asset, keyed by a fingerprint of what
the fragment depends on — node ids, types, params, declared ports, and which port feeds which — so
moving, collapsing or folding compiles nothing again; a changed param or link does, once