Shaders: the shader draws in the sprite's own pass, measured again (#77)

admin committed 2026-09-13 15:31:16 +02:00
1 parent bd26824b01
commit 01d0602654
2 files changed
+30 -21

No files matched your search

+1 -1
@@ -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 # 728 editor checks, screenshots in /tmp/gm-e2e-*.png node tools/seed-e2e.mjs && node tools/e2e.mjs # 730 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
+29 -20
@@ -14,7 +14,7 @@ a bad property name, ...). A node that keeps the graph from compiling shows the
and a red frame. and a red frame.
A shader graph is compiled on every change. A sprite wearing a graph that does not compile is drawn A shader graph is compiled on every change. A sprite wearing a graph that does not compile is drawn
without a filter, as if it wore none. plain, as if it wore none.
## The nodes ## The nodes
@@ -66,8 +66,8 @@ saturation 0 gives `#7c7c7c` — all byte-exact against the formula.
## Wearing a shader ## Wearing a shader
The sprite's `Shader` row is a picker over the project's shader assets (`None` is no shader and no The sprite's `Shader` row is a picker over the project's shader assets (`None` is no shader: the
extra render pass). The rows below it are the shader's `Property` nodes, in node order; they are sprite draws in Pixi's batch like any other). The rows below it are the shader's `Property` nodes, in node order; they are
ordinary sprite properties: overridable per placed object, reset (↺) and apply (↑) like any other, ordinary sprite properties: overridable per placed object, reset (↺) and apply (↑) like any other,
readable and writable by a `Properties` node at runtime — a behavior can fade a sprite by writing readable and writable by a `Properties` node at runtime — a behavior can fade a sprite by writing
its `brightness` every frame. That node's `+ Property` list (under *Type: Sprite*) offers the its `brightness` every frame. That node's `+ Property` list (under *Type: Sprite*) offers the
@@ -82,24 +82,26 @@ deviations while their shader is not worn.
## What it costs, and where it stops ## What it costs, and where it stops
A shader is a Pixi filter: the sprite is rendered into an intermediate texture and drawn back with A shader runs in the sprite's own draw: the mesh is drawn once, with the graph's fragment in place
the fragment, one pass per sprite wearing one. Measured with `tools/perf-shaders.mjs` on an Intel of Pixi's, so there is no intermediate texture and no extra pass. What it costs is the batch — a
HD 630 (the GPU CI uses as well), sprites of 48x48 inside a 640x360 camera, neutral values, three sprite drawing with a shader of its own cannot share a draw call with the plain ones, so every
runs agreeing to within 1 fps: shaded sprite is one draw call. Measured with `tools/perf-shaders.mjs` on an Intel HD 630 (the GPU
CI uses as well), sprites of 48x48 inside a 640x360 camera, neutral values, three runs agreeing to
within 2 fps:
| shaded sprites | 25 | 50 | 100 | 200 | 400 | 800 | | sprites | 50 | 100 | 200 | 400 | 800 | 1600 | 3200 |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- |
| frames per second | 58 | 56 | 54 | 48 | 34 | 20 | | all wearing the shader | 60 | 60 | 60 | 60 | 56 | 34 | 17 |
| the same scene, no shader | 60 | 60 | 60 | 60 | 60 | 39 | 21 |
The very same scene without shaders holds 60 fps at every one of those counts, 800 included, so the Up to 400 shaded sprites nothing shows; at 800 the shader costs about 1.5 microseconds per sprite,
whole drop is the passes: about 20 microseconds each up to 200 sprites, 40 at 800. Sprite size from 1600 on about 3 — and there the plain scene misses ticks by itself. For comparison, as a
hardly matters — four times the area (96x96) measures the same, so what costs is the pass, not the filter pass per sprite (up to #77) the same counts measured 56 / 54 / 48 / 34 / 20 fps from 50 to
pixels in it. A few dozen shaded sprites are free in practice; a few hundred cost half the frame 800: what cost then was the change of render target, not the pixels. The shader samples the
rate. The pass runs at texture itself, so pixel art stays sharp: measured in play at a fractional camera scale (1.43
screen resolution, so pixel art stays sharp: measured in play at a fractional camera scale (1.43
screen pixels per world unit), a *Nearest* texture keeps its hard border pixel for pixel with a 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 pass runs there, and neutral shader on, and brightness 0.5 darkens every one of those pixels — the shader draws there,
it does not resample. Only sprites wear shaders; text objects do not. The and it does not resample. Only sprites wear shaders; text objects do not. The
node set is deliberately small — no channel split, no texture sampling at other coordinates, no node set is deliberately small — no channel split, no texture sampling at other coordinates, no
time input; each is one node in `src/core/shader/nodes.ts` (a `ShaderNodeDef` with an `emit`) and time input; each is one node in `src/core/shader/nodes.ts` (a `ShaderNodeDef` with an `emit`) and
one row in the table above. one row in the table above.
@@ -108,8 +110,15 @@ one row in the table above.
`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
fragment (every node reachable from the Output becomes one local per output, in dependency order; fragment (every node reachable from the Output becomes one local per output, in dependency order;
every Property a `uniform float u_<name>`), `src/viewport/shaders.ts` wraps the result in a filter every Property a `uniform float u_<name>`), `src/viewport/shaders.ts` wraps the result in a Pixi
and `src/viewport/ShaderLibrary.ts` caches it per asset. The rows come from `Shader` for the sprite's mesh (`GraphShader`, with a vertex stage of its own) and
`src/viewport/ShaderLibrary.ts` caches the compiled graph per asset. A sprite's picture is a `Mesh`
(a unit quad sized by its scale, `symbols.ts`), a sibling of the sprite's `Graphics` node under the
same parent: Pixi 8 lets only plain containers have children, and the node keeps just the hit area.
Two Pixi facts the code leans on: a mesh's texture is bound only for Pixi's own shader, so
`GraphShader.wear` hands it over itself; and Pixi caches whether a mesh batches and re-checks that
only in a frame without structure changes, so switching a shader on or off calls `mesh.unload()`
to drop that cache — otherwise a mesh that ever drew batched keeps drawing plain, shader ignored. The rows come from
`shaderParamDefs(graph)` in `src/core/properties/shaders.ts`; `schemaForAsset` and `shaderParamDefs(graph)` in `src/core/properties/shaders.ts`; `schemaForAsset` and
`schemaForObject` (`PropertySchema.ts`) append them to a sprite's schema, which is why both take `schemaForObject` (`PropertySchema.ts`) append them to a sprite's schema, which is why both take
the project. the project.