Draw a shader in passes with a Pass node (#301)
game-maker / gate (push) Successful in 3m3s
game-maker / suite (push) Successful in 14m43s

A Blur stops at radius 8 in one pass, and bloom needs a softened copy
of the picture beside the original. A Pass node cuts the graph: what
feeds it is drawn into a picture of its own first, and the nodes that
read a picture read that one when it feeds them.

- Pass has color and alpha like the Output, a Resolution setting
  (Full, Half, Quarter) and a picture output. Texture color, Texture
  alpha, Sample texture, Blur and Texture size get a picture input.
  Unconnected it is the wearer's own picture, so the original stays
  readable beside any pass.
- picture is a port type of its own. It plugs only into a picture
  input, not even into an any port.
- The walk shared by the compilers can stop at a node (cut). The
  shader compiler draws each Pass it meets as a fragment of its own,
  earlier passes first. A ring through a picture says Feeds itself,
  and more than 8 passes say so at the node.
- A pass draws untinted. The tint comes on when the last part reads a
  picture, so a particle source draws its passes once for all of its
  particles (user decision).
- A pass's picture is the wearer's picture and its Padding, at the
  pixels of the picture times Resolution, at most 2048 per side. It is
  read with the picture's own filter and continues past its frame as
  the Output's Edge says.
- PassPictures keeps one render texture per pass and wearer. It draws
  again only when picture, size, Padding or a uniform changed. A
  field's picture is a render texture that changes under the same id,
  so a field draws its passes every time.
- Sprite, sliced sprite, text, particle source, field shader and the
  Shader tab's preview all draw passes. syncShader takes a fixed
  picture for a 9-slice, which holds its corners but keeps the margin.
- texelAt and blur take the sampler they read. Existing shaders
  compile to the same program apart from that argument.
- A field shader's Blur and Texture size count a pixel as a unit of
  the frame (uPixelScale), not of the screen. Before, a field's blur
  grew when zooming out and shrank when zooming in. This came with
  #170 and #300 and is fixed here because passes inherit it.
- tools/perf-shaders.mjs and tools/perf-field-shaders.mjs measure two
  passes with GM_PERF_PASSES=1 and GM_PERF_PASS_RESOLUTION. Below Full
  an open Pass shrinks the picture first, as the wiki's recipe does.

Found on the way:
- Destroying the pictures before the shader made Pixi warn that a
  texture was still bound. The shader goes first now, and a picture of
  a new size is resized rather than replaced.
- Two passes in a row could draw plain white the first time. Pixi
  builds the code that binds a program's resources once and finds them
  by position. A pass of one graph is often the same program as a pass
  of another with fewer slots, so it bound a placeholder. Every shader
  now declares all 8 pass slots and its uniform group.

Tests:
- Gate on melektaus-atx: 225 files, 3480 tests (3467 + 13), exit 0.
- e2e 28 and 31 on melektaus-atx: 97 checks (94 + 3) and 44 checks
  (43 + 1), all passed, no Pixi warning in the console.
- 28l-vii: a Blur across, a Pass and a Blur down draw the 5x5 spot of
  a Blur of Both (difference 0.000). On a grey sprite a pass's picture
  added to the original is 231/60/60, each tinted once. Two Passes in
  a row after a graph with one show red and green, not white.
- 31v-iii: a Blur of 8 fades a field's frame edge over 8.0 units at
  50 % and at 200 % zoom.
- Counter-checks: a tinted pass fails 1 unit test. Pictures never
  bound fail both e2e checks (spot 0.20 instead of 0.04, 243/158/158).
  Pass slots by the number of passes draw #ffffff, and a field without
  its pixel scale ramps over 16 and 4 units: exactly the two new
  checks fail.
- e2e sum 1716 is counted, not yet measured (1712 + 4).
- Measured on stein2 (HD 630, one run per row): 50 field shaders at
  radius 8 go from 11.4 to 41.3 fps in two passes, 44.4 at radius 16
  (three passes at Half). 800 sprites at radius 8 go from 13.9 to
  24.5 fps. At radius 4 one pass stays faster from 400 sprites on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 14:51:18 +02:00
1 parent df61b08bd1
commit f03ca0d382
23 files changed
+1111 -134

No files matched your search

+12
View File
@@ -144,4 +144,16 @@ describe('the frame', () => {
const capped = fieldFrame(m, 256, 256, 40);
expect(apply(capped.toPicture, { x: 128, y: 128 })).toEqual({ x: FIELD_MAX_PIXELS, y: FIELD_MAX_PIXELS });
});
it('counts a pixel of its picture as a unit of the frame, at any zoom and under the cap', () => {
const m = transformMatrix(0, 0, 0, 1, false, false);
expect(fieldFrame(m, 256, 128, 2).unitsPerPixel).toEqual({ x: 0.5, y: 0.5 });
expect(fieldFrame(m, 256, 128, 0.5).unitsPerPixel).toEqual({ x: 2, y: 2 });
expect(fieldFrame(m, 256, 256, 40).unitsPerPixel).toEqual({ x: 0.125, y: 0.125 });
// A source grown twice has twice the pixels for the same frame: half a unit each.
expect(fieldFrame(transformMatrix(0, 0, 0, 2, false, false), 100, 50, 1).unitsPerPixel).toEqual({
x: 0.5,
y: 0.5,
});
});
});
+17 -4
View File
@@ -10,6 +10,7 @@ import { SHADER_PROPERTY } from '../../src/core/properties/shaders';
import { compileShader, perCornerProblem } from '../../src/core/shader/compile';
import {
BLUR_FN,
PIXEL_SCALE_UNIFORM,
SHADER_BLUR,
SHADER_OUTPUT,
SHADER_VERTEX_OUTPUT,
@@ -50,7 +51,7 @@ describe('Blur (#300)', () => {
const fragment = blurred().build().compiled!.fragment;
expect(fragment).toContain(BLUR_FN);
expect(fragment).toContain(
'vec4 n0_prepared = blur(vec2(vTextureCoord.x, vTextureCoord.y), 2.0, vec2(1.0, 1.0), 0.0) * vColor;',
'vec4 n0_prepared = blur(uTexture, vec2(vTextureCoord.x, vTextureCoord.y), 2.0, vec2(1.0, 1.0), 0.0) * vColor;',
);
expect(fragment).toContain('vec3 n1_color = straight(n0_prepared);');
expect(fragment).toContain('float n2_alpha = n0_prepared.a;');
@@ -71,12 +72,16 @@ describe('Blur (#300)', () => {
it('holds the radius at 0 to 8 in the program, and takes it from a Property as a uniform', () => {
expect(BLUR_FN).toContain('float r = clamp(radius, 0.0, 8.0);');
const s = blurred({ radius: 3.5 });
expect(s.build().compiled!.fragment).toContain('blur(vec2(vTextureCoord.x, vTextureCoord.y), 3.5,');
expect(s.build().compiled!.fragment).toContain(
'blur(uTexture, vec2(vTextureCoord.x, vTextureCoord.y), 3.5,',
);
const soft = s.add(SHADER_PROPERTY, { name: 'soft', default: 2, min: 0, max: 8 });
s.link(soft, 'value', s.blur, 'radius');
const compiled = s.build().compiled!;
expect(compiled.uniforms).toEqual([{ name: 'soft', kind: 'number', default: 2 }]);
expect(compiled.fragment).toMatch(/= blur\(vec2\(vTextureCoord\.x, vTextureCoord\.y\), n\d+_value,/);
expect(compiled.fragment).toMatch(
/= blur\(uTexture, vec2\(vTextureCoord\.x, vTextureCoord\.y\), n\d+_value,/,
);
});
it('reads at the UV it is fed', () => {
@@ -85,7 +90,9 @@ describe('Blur (#300)', () => {
const flip = s.add('ShaderSubtract', { a: 1 });
s.link(uv, 'u', flip, 'b');
s.link(flip, 'result', s.blur, 'u');
expect(s.build().compiled!.fragment).toMatch(/= blur\(vec2\(n\d+_result, vTextureCoord\.y\), 2\.0,/);
expect(s.build().compiled!.fragment).toMatch(
/= blur\(uTexture, vec2\(n\d+_result, vTextureCoord\.y\), 2\.0,/,
);
});
it('leaves the helper out of a shader that does not reach it', () => {
@@ -93,9 +100,15 @@ describe('Blur (#300)', () => {
s.add(SHADER_BLUR);
const fragment = s.build().compiled!.fragment;
expect(fragment).not.toContain('blurWeight');
expect(fragment).not.toContain(PIXEL_SCALE_UNIFORM);
expect(calls(fragment)).toBe(0);
});
it('steps a pixel as the wearer counts one, so a field’s blur does not follow the zoom', () => {
expect(BLUR_FN).toContain(`vec2 texel = 1.0 / (vec2(textureSize(picture, 0)) * ${PIXEL_SCALE_UNIFORM});`);
expect(blurred().build().compiled!.fragment).toContain(`uniform vec2 ${PIXEL_SCALE_UNIFORM};`);
});
it('reads past the border, so the picture comes with the Output’s Edge', () => {
expect(blurred().build().compiled!.edge).toBe('clamp');
expect(blurred({}, { edge: 'repeat' }).build().compiled!.edge).toBe('repeat');
+197
View File
@@ -0,0 +1,197 @@
/**
* A shader's `Pass` node (#301): a cut in the graph, whose inputs are drawn into a picture of their
* own first, read by the nodes that read a picture.
*/
import { describe, expect, it } from 'vitest';
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { portsCompatible } from '../../src/core/graph/Graph';
import { findAssetOf } from '../../src/core/model/Project';
import { compileShader, perCornerProblem, TOO_MANY_PASSES } from '../../src/core/shader/compile';
import {
MAX_PASSES,
ShaderAdd,
SHADER_BLUR,
SHADER_OUTPUT,
SHADER_PASS,
SHADER_SAMPLE,
SHADER_TEXTURE_ALPHA,
SHADER_TEXTURE_COLOR,
SHADER_TEXTURE_SIZE,
SHADER_VERTEX_OUTPUT,
} from '../../src/core/shader/nodes';
import { MAX_PASS_PIXELS, passPixels } from '../../src/viewport/quadPadding';
import { makeSpriteScene } from '../helpers';
function shader(outParams = {}) {
const f = makeSpriteScene();
const create = new CreateAssetCommand('shader', 'Bloom');
f.stack.execute(create);
const id = create.asset.id;
const asset = () => findAssetOf(f.project, id, 'shader')!;
const add = (type: string, params = {}) => {
const command = new AddNodeCommand(id, type, 0, 0, params);
f.stack.execute(command);
return command.node.id;
};
const link = (from: string, fromPort: string, to: string, toPort: string) =>
f.stack.execute(new ConnectCommand(id, { node: from, port: fromPort }, { node: to, port: toPort }));
/** An edge no command would lay, for what a hand-edited file may hold. */
const wire = (from: string, fromPort: string, to: string, toPort: string) =>
asset().graph.edges.push({
id: `e${asset().graph.edges.length}`,
from: { node: from, port: fromPort },
to: { node: to, port: toPort },
});
const out = add(SHADER_OUTPUT, outParams);
return { add, link, wire, out, build: () => compileShader(asset().graph) };
}
/** A Pass whose picture a Texture color hands to the Output. */
function passedOn(passParams = {}, outParams = {}) {
const s = shader(outParams);
const pass = s.add(SHADER_PASS, passParams);
const read = s.add(SHADER_TEXTURE_COLOR);
s.link(pass, 'picture', read, 'picture');
s.link(read, 'color', s.out, 'color');
return { ...s, pass, read };
}
describe('Pass (#301)', () => {
it('draws the Pass first, untinted, and the Output reads its picture through the padding', () => {
const compiled = passedOn().build().compiled!;
expect(compiled.passes).toHaveLength(1);
const [pass] = compiled.passes;
expect(pass!.fragment).toContain('const vec4 vColor = vec4(1.0);');
expect(pass!.fragment).not.toContain('in vec4 vColor;');
expect(pass!.fragment).not.toContain('uPass');
expect(compiled.fragment).toContain('in vec4 vColor;');
expect(compiled.fragment).toContain('uniform sampler2D uPass0;');
expect(compiled.fragment).toContain('uniform vec2 uPassPad;');
expect(compiled.fragment).toMatch(
/vec3 n\d+_color = straight\(texelAt\(uPass0, passUv\(vTextureCoord\)\) \* vColor\);/,
);
});
it('leaves a shader without a Pass as it was: no picture to draw, no sampler to bind', () => {
const s = shader();
s.link(s.add(SHADER_TEXTURE_COLOR), 'color', s.out, 'color');
const compiled = s.build().compiled!;
expect(compiled.passes).toEqual([]);
expect(compiled.fragment).not.toContain('uPass');
expect(compiled.fragment).not.toContain('passUv');
});
it('draws a pass before the pass that reads it, and each program declares only what it reads', () => {
const s = shader();
const first = s.add(SHADER_PASS);
const across = s.add(SHADER_BLUR, { direction: 'horizontal' });
const second = s.add(SHADER_PASS);
const down = s.add(SHADER_SAMPLE);
s.link(first, 'picture', across, 'picture');
s.link(across, 'color', second, 'color');
s.link(across, 'alpha', second, 'alpha');
s.link(second, 'picture', down, 'picture');
s.link(down, 'color', s.out, 'color');
const compiled = s.build().compiled!;
expect(compiled.passes).toHaveLength(2);
expect(compiled.passes[0]!.fragment).not.toContain('uPass');
expect(compiled.passes[1]!.fragment).toContain('uniform sampler2D uPass0;');
expect(compiled.passes[1]!.fragment).toContain(
'= blur(uPass0, passUv(vec2(vTextureCoord.x, vTextureCoord.y)),',
);
expect(compiled.fragment).toContain('uniform sampler2D uPass1;');
expect(compiled.fragment).not.toContain('uPass0');
});
it('draws a Pass two nodes read once, and one nothing reads not at all', () => {
const s = passedOn();
const alpha = s.add(SHADER_TEXTURE_ALPHA);
s.link(s.pass, 'picture', alpha, 'picture');
s.link(alpha, 'value', s.out, 'alpha');
s.add(SHADER_PASS);
expect(s.build().compiled!.passes).toHaveLength(1);
});
it('takes the size from Resolution and the edge from the Output', () => {
expect(passedOn().build().compiled!.passes[0]!.scale).toBe(1);
expect(passedOn({ resolution: 'half' }).build().compiled!.passes[0]!.scale).toBe(0.5);
expect(passedOn({ resolution: 'quarter' }).build().compiled!.passes[0]!.scale).toBe(0.25);
expect(passedOn({}, { edge: 'repeat' }).build().compiled!.passEdge).toBe('repeat');
});
it('measures the picture it is fed', () => {
const s = passedOn();
const size = s.add(SHADER_TEXTURE_SIZE);
const add = s.add(ShaderAdd.type);
s.link(s.pass, 'picture', size, 'picture');
s.link(size, 'width', add, 'a');
s.link(add, 'result', s.out, 'alpha');
expect(s.build().compiled!.fragment).toContain('(vec2(textureSize(uPass0, 0)) * uPixelScale).x');
});
it('names a ring through a picture on the Pass, and compiles nothing', () => {
const s = shader();
const pass = s.add(SHADER_PASS);
const read = s.add(SHADER_TEXTURE_COLOR);
const out = s.add(SHADER_TEXTURE_COLOR);
s.wire(pass, 'picture', read, 'picture');
s.wire(read, 'color', pass, 'color');
s.link(pass, 'picture', out, 'picture');
s.link(out, 'color', s.out, 'color');
const build = s.build();
expect(build.compiled).toBeNull();
expect(build.problems[pass]).toBe('Feeds itself');
});
it(`stops at ${MAX_PASSES} passes`, () => {
const s = shader();
let picture = '';
for (let n = 0; n <= MAX_PASSES; n++) {
const pass = s.add(SHADER_PASS);
if (picture) {
const read = s.add(SHADER_TEXTURE_COLOR);
s.link(picture, 'picture', read, 'picture');
s.link(read, 'color', pass, 'color');
}
picture = pass;
}
const last = s.add(SHADER_TEXTURE_COLOR);
s.link(picture, 'picture', last, 'picture');
s.link(last, 'color', s.out, 'color');
const build = s.build();
expect(build.compiled).toBeNull();
expect(Object.values(build.problems)).toContain(TOO_MANY_PASSES);
});
it('lets a picture go only where a picture is read', () => {
expect(portsCompatible('picture', 'picture')).toBe(true);
expect(portsCompatible('picture', 'any')).toBe(false);
expect(portsCompatible('any', 'picture')).toBe(false);
expect(portsCompatible('picture', 'vec3')).toBe(false);
const s = shader();
const pass = s.add(SHADER_PASS);
const add = s.add(ShaderAdd.type);
s.wire(pass, 'picture', add, 'a');
s.link(add, 'result', s.out, 'color');
expect(s.build().problems[add]).toBe('"a" cannot take a picture');
});
it('has no picture per corner', () => {
const s = shader();
const pass = s.add(SHADER_PASS);
const alpha = s.add(SHADER_TEXTURE_ALPHA);
const corner = s.add(SHADER_VERTEX_OUTPUT);
s.link(pass, 'picture', alpha, 'picture');
s.link(alpha, 'value', corner, 'x');
expect(s.build().problems[corner]).toBe(perCornerProblem('Pass'));
});
it('sizes its picture as the wearer’s with the margin, at its resolution, within limits', () => {
expect(passPixels(16, 16, null, 1)).toEqual({ w: 16, h: 16 });
expect(passPixels(16, 8, { u: 2 / 16, v: 2 / 8 }, 1)).toEqual({ w: 20, h: 12 });
expect(passPixels(16, 8, { u: 2 / 16, v: 2 / 8 }, 0.5)).toEqual({ w: 10, h: 6 });
expect(passPixels(2, 2, null, 0.25)).toEqual({ w: 1, h: 1 });
expect(passPixels(4096, 100, null, 1)).toEqual({ w: MAX_PASS_PIXELS, h: 100 });
});
});
+10 -9
View File
@@ -223,8 +223,8 @@ describe('compileShader', () => {
for (const name of ['brightness', 'contrast', 'saturation'])
expect(fragment).toContain(`uniform float u_${name};`);
// Drawn in the sprite's own pass: the tint goes in before the graph, premultiplied like the texel.
expect(fragment).toContain('vec4 texelAt(vec2 uv) { return texture(uTexture, uv); }');
expect(fragment).toContain('vec4 texel = texelAt(vTextureCoord) * vColor;');
expect(fragment).toContain('vec4 texelAt(sampler2D picture, vec2 uv) { return texture(picture, uv); }');
expect(fragment).toContain('vec4 texel = texelAt(uTexture, vTextureCoord) * vColor;');
expect(fragment).toContain('vec3 straight(vec4 c) { return c.a > 0.0 ? c.rgb / c.a : c.rgb; }');
expect(fragment).toContain('vec3 rgb = straight(texel);');
expect(compiled).toMatchObject({ time: false, edge: 'mirror' });
@@ -474,12 +474,13 @@ describe('compileShader', () => {
expect(fragment).toMatch(/float n\d+_u = vTextureCoord\.x;/);
expect(fragment).toMatch(/float n\d+_result = \(1\.0 - n\d+_u\);/);
expect(fragment).toMatch(
/vec3 n\d+_color = straight\(texelAt\(vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\);/,
/vec3 n\d+_color = straight\(texelAt\(uTexture, vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\);/,
);
expect(fragment).toMatch(
/float n\d+_alpha = \(texelAt\(vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\)\.a;/,
/float n\d+_alpha = \(texelAt\(uTexture, vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\)\.a;/,
);
expect(fragment).toMatch(/float n\d+_width = float\(textureSize\(uTexture, 0\)\.x\);/);
expect(fragment).toMatch(/float n\d+_width = \(vec2\(textureSize\(uTexture, 0\)\) \* uPixelScale\)\.x;/);
expect(fragment).toContain('uniform vec2 uPixelScale;');
expect(fragment).toMatch(/float n\d+_value = uTime;/);
expect(fragment).toMatch(/float n\d+_result = sin\(n\d+_value\);/);
});
@@ -551,13 +552,13 @@ describe('compileShader', () => {
// No copy of the picture with a sampler of its own: the fragment empties the outside itself.
expect(compiled!.edge).toBe(SHEET_EDGE);
expect(compiled!.fragment).toContain(
'vec4 texelAt(vec2 uv) { vec2 inside = step(vec2(0.0), uv) * step(uv, vec2(1.0)); return texture(uTexture, uv) * (inside.x * inside.y); }',
'vec4 texelAt(sampler2D picture, vec2 uv) { vec2 inside = step(vec2(0.0), uv) * step(uv, vec2(1.0)); return texture(picture, uv) * (inside.x * inside.y); }',
);
// The sprite's own pixel goes through the same cut, so the margin itself is empty.
expect(compiled!.fragment).toContain('vec4 texel = texelAt(vTextureCoord) * vColor;');
expect(compiled!.fragment).toContain('vec4 texel = texelAt(uTexture, vTextureCoord) * vColor;');
for (const edge of ['clamp', 'mirror', 'repeat'])
expect(plain({ edge, padding: 2 }).build().compiled!.fragment).toContain(
'vec4 texelAt(vec2 uv) { return texture(uTexture, uv); }',
'vec4 texelAt(sampler2D picture, vec2 uv) { return texture(picture, uv); }',
);
});
@@ -572,7 +573,7 @@ describe('compileShader', () => {
const { compiled } = build();
expect(compiled!.edge).toBe(SHEET_EDGE);
expect(compiled!.fragment).toMatch(
/straight\(texelAt\(vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\)/,
/straight\(texelAt\(uTexture, vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\)/,
);
expect(compiled!.fragment).not.toMatch(/straight\(texture\(/);
});