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

+8 -3
View File
@@ -5,10 +5,11 @@ export type { DeclaredPortKind } from '../properties/kinds';
/**
* `exec` carries control flow, the others values; `any` accepts every value type. `vec3` is a
* colour inside a shader graph, where the values are GLSL and no behavior node ever sees one.
* colour inside a shader graph, where the values are GLSL and no behavior node ever sees one;
* `picture` is what a shader's Pass draws (#301), for the nodes that read a picture.
*/
export type PortType =
'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'vec3' | 'any';
'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'vec3' | 'picture' | 'any';
export interface PortRef {
node: string;
@@ -164,9 +165,13 @@ export function graphFingerprint(graph: Graph): string {
});
}
/** A number may feed a colour: the shader compiler spreads it over the three channels. */
/**
* A number may feed a colour: the shader compiler spreads it over the three channels. A picture is
* no value a calculation could take, so like `exec` it goes only where a picture is read.
*/
export function portsCompatible(from: PortType, to: PortType): boolean {
if (from === 'exec' || to === 'exec') return from === to;
if (from === 'picture' || to === 'picture') return from === to;
if (from === 'number' && to === 'vec3') return true;
return from === to || from === 'any' || to === 'any';
}
+1 -1
View File
@@ -205,7 +205,7 @@ export function inlineInputs(def: NodeDef): PortDef[] {
function isInlinePort(port: PortDef): boolean {
if (port.inline === false) return false;
if (port.type === 'vec3') return port.field !== undefined;
return port.type !== 'exec' && port.type !== 'object' && port.type !== 'struct';
return port.type !== 'exec' && port.type !== 'object' && port.type !== 'struct' && port.type !== 'picture';
}
/** Untyped, an input writes the zero of its kind; as its default the node body shows it, not just the name. */
+10
View File
@@ -11,6 +11,11 @@ export interface EmitRules<D extends NodeDef, T> {
unfed: (node: GraphNode, port: PortDef, def: D | undefined) => T | null;
/** An edge's value on its way into `port`; null (after reporting a problem) when it cannot go in. */
conform?: (node: GraphNode, port: PortDef, value: T) => T | null;
/**
* A node that ends this walk: its outputs as they are, without walking what feeds it (null after
* reporting a problem). Undefined for every other node.
*/
cut?: (def: D, node: GraphNode) => Record<string, T> | null | undefined;
}
export interface GraphEmitter<T> {
@@ -56,6 +61,11 @@ export function graphEmitter<D extends NodeDef, T>(graph: Graph, rules: EmitRule
problem(node, `Not a ${rules.noun} node`);
return null;
}
const cut = rules.cut?.(def, node);
if (cut !== undefined) {
if (cut) emitted.set(node.id, cut);
return cut;
}
visiting.add(node.id);
const inputs: Record<string, T> = {};
let complete = true;
+188 -55
View File
@@ -1,4 +1,4 @@
import { graphEmitter } from '../graph/emitGraph';
import { type GraphEmitter, graphEmitter } from '../graph/emitGraph';
import { edgesInto, type Graph, type GraphNode } from '../graph/Graph';
import { nodeDef } from '../graph/NodeRegistry';
import { colorOf } from '../model/Properties';
@@ -13,10 +13,19 @@ import {
type Expr,
floatLiteral,
isShaderNodeDef,
MAX_PASSES,
ORIGINAL,
PASS_PAD_UNIFORM,
PASS_UV_FN,
passSampler,
passScaleOf,
paddingOf,
PIXEL_SCALE_UNIFORM,
SHADER_BLUR,
SHADER_OUTPUT,
SHADER_PASS,
SHADER_SAMPLE,
SHADER_TEXTURE_SIZE,
SHADER_TIME,
SHADER_VERTEX_OUTPUT,
type ShaderNodeDef,
@@ -54,8 +63,24 @@ export interface ShaderVertex {
subdivisions: number;
}
/**
* A picture a Pass node draws before the wearer's own draw (#301). Its frame is the wearer's
* picture and Padding, and it is drawn untinted: a particle's tint is its own, and the passes of a
* particle source are drawn once for all of them.
*/
export interface ShaderPass {
/** Reads `uTexture` and the earlier passes' pictures, the same way the wearer's fragment does. */
fragment: string;
/** Its size as a share of the wearer's picture per side: the Pass's Resolution. */
scale: number;
}
export interface CompiledShader {
fragment: string;
/** What the Pass nodes draw ahead of `fragment`, in drawing order: `uPass<i>` is the i-th picture. */
passes: ShaderPass[];
/** How a pass's picture continues past its frame: the Output's Edge, whatever `edge` says. */
passEdge: EdgeMode;
/** Null without a Vertex output node: the wearer then draws on the quad every sprite shares. */
vertex: ShaderVertex | null;
uniforms: ShaderUniform[];
@@ -83,17 +108,30 @@ export interface ShaderBuild {
export const PADDING_CLAMP_HINT = 'Padding with Clamp smears the border';
export const perCornerProblem = (what: string) => `Not available per corner: ${what}`;
export const TOO_MANY_PASSES = `At most ${MAX_PASSES} passes`;
/** A Pass's outputs where a walk meets it; null after reporting a problem. */
type PassCut = (node: GraphNode) => Record<string, Expr> | null;
/**
* A shader graph to the fragment of the sprite's own draw: every node reachable from the Output
* becomes one local per output, in dependency order, and every Property a uniform. The texel
* arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back.
* arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back. Every
* Pass the Output reaches is a fragment of its own, drawn first into a picture (#301).
*/
export function compileShader(graph: Graph): ShaderBuild {
const output = graph.nodes.find((n) => n.type === SHADER_OUTPUT);
const mode = output ? edgeOf(output.params) : edgeOf({});
const uniforms = shaderParamDefs(graph).map((row) => ({
name: row.key,
kind: row.kind,
default: row.default,
}));
const lines: string[] = [];
/** What the vertex walk met that only a pixel has; the Vertex output node reports the first. */
const perPixel: string[] = [];
const walk = walker(graph, lines, 'n', null);
const reads = new Set<number>();
const passes = passDrawer(graph, mode, uniforms);
const walk = walker(graph, lines, 'n', null, passes.cut(reads));
const { problems } = walk;
walk.checkNames(graph.nodes.filter(isPropertyNode), propertyName, (name) =>
@@ -104,9 +142,7 @@ export function compileShader(graph: Graph): ShaderBuild {
: '',
);
const output = graph.nodes.find((n) => n.type === SHADER_OUTPUT);
if (!output) return { compiled: null, problems, hints: {}, reason: 'No Output node' };
const mode = edgeOf(output.params);
const padding = paddingOf(output.params);
const hints: Record<string, string> =
padding > 0 && mode === 'clamp' ? { [output.id]: PADDING_CLAMP_HINT } : {};
@@ -114,25 +150,20 @@ export function compileShader(graph: Graph): ShaderBuild {
const color = walk.input(output, outputDef.inputs[0]!);
const alpha = walk.input(output, outputDef.inputs[1]!);
const uniforms = shaderParamDefs(graph).map((row) => ({
name: row.key,
kind: row.kind,
default: row.default,
}));
// The per-corner part is a walk of its own from the Vertex output: a node feeding both outputs is
// emitted once for each stage.
// emitted once for each stage. A Pass has no picture per corner.
/** What the vertex walk met that only a pixel has; the Vertex output node reports the first. */
const perPixel: string[] = [];
const corner = graph.nodes.find((n) => n.type === SHADER_VERTEX_OUTPUT);
const cornerLines: string[] = [];
const cornerWalk = walker(graph, cornerLines, 'c', perPixel);
const cornerWalk = walker(graph, cornerLines, 'c', perPixel, () => ({ picture: ORIGINAL }));
let vertex: ShaderVertex | null = null;
if (corner) {
const cornerDef = nodeDef(SHADER_VERTEX_OUTPUT)!;
const x = cornerWalk.input(corner, cornerDef.inputs[0]!);
const y = cornerWalk.input(corner, cornerDef.inputs[1]!);
if (perPixel.length > 0) cornerWalk.problem(corner, perCornerProblem(perPixel[0]!));
for (const [id, text] of Object.entries(cornerWalk.problems)) if (!(id in problems)) problems[id] = text;
const reachesTime = graph.nodes.some((n) => n.type === SHADER_TIME && cornerWalk.reached(n));
const reachesTime = reaches(graph, cornerWalk, SHADER_TIME);
if (x && y)
vertex = {
// A uniform both stages declare has to have one precision; the fragment's default is mediump.
@@ -145,54 +176,39 @@ export function compileShader(graph: Graph): ShaderBuild {
subdivisions: subdivisionsOf(corner.params),
};
}
for (const other of [cornerWalk.problems, passes.problems, ...passes.walks.map((w) => w.problems)])
for (const [id, text] of Object.entries(other)) if (!(id in problems)) problems[id] = text;
const failed = firstProblem(graph, problems);
if (failed !== null || !color || !alpha || (corner && !vertex))
return { compiled: null, problems, hints, reason: failed ?? 'Cannot compile' };
const time = graph.nodes.some((n) => n.type === SHADER_TIME && (walk.reached(n) || cornerWalk.reached(n)));
const fragmentTime = graph.nodes.some((n) => n.type === SHADER_TIME && walk.reached(n));
const blurs = graph.nodes.some((n) => n.type === SHADER_BLUR && walk.reached(n));
const pictureWalks = [walk, ...passes.walks];
const time = [cornerWalk, ...pictureWalks].some((w) => reaches(graph, w, SHADER_TIME));
// The fragment runs in the sprite's own draw (#77). `vColor` is the tint, premultiplied like the
// texel; it goes in before the graph, so `Texture color` is the sprite as it draws without a shader.
// The version line is what makes Pixi compile the program as GLSL ES 3.00 (`GlProgram` looks for
// it in the fragment); without it `texture()` is a `#define` and `textureSize` does not exist.
const fragment = [
'#version 300 es',
'in vec2 vTextureCoord;',
'in vec4 vColor;',
'out vec4 finalColor;',
'uniform sampler2D uTexture;',
...(fragmentTime ? [`uniform float ${TIME_UNIFORM};`] : []),
...uniforms.map((u) => `uniform ${glslOf(u)} ${uniformName(u.name)};`),
STRAIGHT_FN,
DIVIDE_FN,
texelAtFn(mode),
...(blurs ? [BLUR_FN] : []),
'void main()',
'{',
` vec4 texel = ${TEXEL_AT}(vTextureCoord) * vColor;`,
` vec3 ${TEXEL_COLOR.code} = straight(texel);`,
...lines.map((line) => ` ${line}`),
` float alpha = clamp(${alpha.code}, 0.0, 1.0);`,
` finalColor = vec4(clamp(${color.code}, 0.0, 1.0) * alpha, alpha);`,
'}',
'',
].join('\n');
const fragment = fragmentOf(graph, { walk, lines, color, alpha, mode, uniforms, reads, tinted: true });
// The edge only shows where a Sample reads somewhere other than its own pixel. A graph that never
// shifts a UV stays on the plain sheet, so the common shader costs no second copy of the picture.
// With padding every pixel of the margin reads past the border; Transparent is cut in the
// fragment and takes the plain sheet as well. A Blur reads its neighbours, so it always shifts.
const shifted =
blurs ||
graph.nodes.some(
(n) =>
n.type === SHADER_SAMPLE &&
walk.reached(n) &&
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
);
const shifted = pictureWalks.some(
(w) =>
reaches(graph, w, SHADER_BLUR) ||
graph.nodes.some(
(n) =>
n.type === SHADER_SAMPLE &&
w.reached(n) &&
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
),
);
const edge = (shifted || padding > 0) && mode !== 'transparent' ? mode : SHEET_EDGE;
return { compiled: { fragment, vertex, uniforms, time, edge, padding }, problems, hints, reason: '' };
return {
compiled: { fragment, passes: passes.drawn, passEdge: mode, vertex, uniforms, time, edge, padding },
problems,
hints,
reason: '',
};
}
function firstProblem(graph: Graph, problems: Record<string, string>): string | null {
@@ -200,11 +216,115 @@ function firstProblem(graph: Graph, problems: Record<string, string>): string |
return first ? problems[first.id]! : null;
}
function reaches(graph: Graph, walk: GraphEmitter<Expr>, type: string): boolean {
return graph.nodes.some((n) => n.type === type && walk.reached(n));
}
interface FragmentParts {
walk: GraphEmitter<Expr>;
lines: string[];
color: Expr;
alpha: Expr;
mode: EdgeMode;
uniforms: ShaderUniform[];
/** The passes whose pictures the walk read. */
reads: Set<number>;
/** False for a pass: it draws the picture as it is, and only the wearer's own draw tints it. */
tinted: boolean;
}
/**
* A walk's fragment. The version line is what makes Pixi compile the program as GLSL ES 3.00
* (`GlProgram` looks for it in the fragment); without it `texture()` is a `#define` and
* `textureSize` does not exist.
*/
function fragmentOf(graph: Graph, p: FragmentParts): string {
const reads = [...p.reads].sort((a, b) => a - b);
return [
'#version 300 es',
'in vec2 vTextureCoord;',
p.tinted ? 'in vec4 vColor;' : 'const vec4 vColor = vec4(1.0);',
'out vec4 finalColor;',
'uniform sampler2D uTexture;',
...reads.map((index) => `uniform sampler2D ${passSampler(index)};`),
...(reads.length > 0 ? [`uniform vec2 ${PASS_PAD_UNIFORM};`] : []),
...(reaches(graph, p.walk, SHADER_TIME) ? [`uniform float ${TIME_UNIFORM};`] : []),
...(reaches(graph, p.walk, SHADER_BLUR) || reaches(graph, p.walk, SHADER_TEXTURE_SIZE)
? [`uniform vec2 ${PIXEL_SCALE_UNIFORM};`]
: []),
...p.uniforms.map((u) => `uniform ${glslOf(u)} ${uniformName(u.name)};`),
STRAIGHT_FN,
DIVIDE_FN,
texelAtFn(p.mode),
...(reads.length > 0 ? [PASS_UV_FN] : []),
...(reaches(graph, p.walk, SHADER_BLUR) ? [BLUR_FN] : []),
'void main()',
'{',
` vec4 texel = ${TEXEL_AT}(${ORIGINAL.code}, vTextureCoord) * vColor;`,
` vec3 ${TEXEL_COLOR.code} = straight(texel);`,
...p.lines.map((line) => ` ${line}`),
` float alpha = clamp(${p.alpha.code}, 0.0, 1.0);`,
` finalColor = vec4(clamp(${p.color.code}, 0.0, 1.0) * alpha, alpha);`,
'}',
'',
].join('\n');
}
/**
* Compiles every Pass a walk meets, once, before the walk goes on: what feeds a Pass is a walk of
* its own, so a Pass it meets in turn is drawn earlier. `drawn` is the drawing order.
*/
function passDrawer(graph: Graph, mode: EdgeMode, uniforms: ShaderUniform[]) {
const drawn: ShaderPass[] = [];
const walks: GraphEmitter<Expr>[] = [];
/** What keeps a Pass itself from being drawn, rather than something feeding it. */
const problems: Record<string, string> = {};
const indexOf = new Map<string, number>();
const drawing = new Set<string>();
const cut =
(reads: Set<number>): PassCut =>
(node) => {
const index = draw(node);
if (index === null) return null;
reads.add(index);
return { picture: { code: passSampler(index), type: 'picture' } };
};
const draw = (node: GraphNode): number | null => {
const done = indexOf.get(node.id);
if (done !== undefined) return done;
const report = (text: string) => {
if (!(node.id in problems)) problems[node.id] = text;
return null;
};
if (drawing.has(node.id)) return report('Feeds itself');
if (drawing.size + drawn.length >= MAX_PASSES) return report(TOO_MANY_PASSES);
drawing.add(node.id);
const lines: string[] = [];
const reads = new Set<number>();
const walk = walker(graph, lines, 'n', null, cut(reads));
const def = nodeDef(SHADER_PASS)!;
const color = walk.input(node, def.inputs[0]!);
const alpha = walk.input(node, def.inputs[1]!);
walks.push(walk);
drawing.delete(node.id);
if (!color || !alpha || Object.keys(walk.problems).length > 0) return null;
const fragment = fragmentOf(graph, { walk, lines, color, alpha, mode, uniforms, reads, tinted: false });
drawn.push({ fragment, scale: passScaleOf(node.params) });
indexOf.set(node.id, drawn.length - 1);
return drawn.length - 1;
};
return { drawn, walks, problems, cut };
}
/**
* One walk of the graph into `lines`, a local per node output. `perPixel` is the list a per-corner
* walk notes in what it met that only a pixel has; the fragment's walk hands in null.
* walk notes in what it met that only a pixel has; the fragment's walk hands in null. `pass` is
* what a Pass node is to this walk.
*/
function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[] | null) {
function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[] | null, pass: PassCut) {
let count = 0;
const walk = graphEmitter<ShaderNodeDef, Expr>(graph, {
isDef: isShaderNodeDef,
@@ -224,6 +344,11 @@ function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[
}
return outputs;
},
cut: (def, node) => {
if (def.type !== SHADER_PASS) return undefined;
perPixel?.push(def.label);
return pass(node);
},
// With nothing to type and nothing the node says, an input is the texel: its colour for a
// colour port, its alpha for a number.
unfed: (node, port, def) => {
@@ -237,6 +362,14 @@ function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[
return port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR;
},
conform: (node: GraphNode, port, expr) => {
// A picture is a sampler: it goes where a picture is read and nowhere else.
if ((port.type === 'picture') !== (expr.type === 'picture')) {
walk.problem(
node,
port.type === 'picture' ? `"${port.key}" needs a picture` : `"${port.key}" cannot take a picture`,
);
return null;
}
if (port.type === 'number' && expr.type === 'vec3') {
walk.problem(node, `"${port.key}" needs a number, not a color`);
return null;
+118 -26
View File
@@ -12,7 +12,8 @@ import {
SHADER_PROPERTY,
} from '../properties/shaders';
export type GlslType = 'float' | 'vec3';
/** `picture` is a sampler: the wearer's own picture or the one a Pass drew (#301). */
export type GlslType = 'float' | 'vec3' | 'picture';
/** A GLSL expression and what it evaluates to. */
export interface Expr {
@@ -51,13 +52,44 @@ export const SHADER_SAMPLE = 'ShaderSample';
export const SHADER_TEXTURE_SIZE = 'ShaderTextureSize';
export const SHADER_TIME = 'ShaderTime';
export const SHADER_BLUR = 'ShaderBlur';
export const SHADER_PASS = 'ShaderPass';
/** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */
export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' };
export const TEXEL_ALPHA: Expr = { code: 'texel.a', type: 'float' };
/** This pixel's place on the picture, 0..1 – the vertex stage's `vTextureCoord`. */
const OWN_U: Expr = { code: 'vTextureCoord.x', type: 'float' };
const OWN_V: Expr = { code: 'vTextureCoord.y', type: 'float' };
const OWN_UV = 'vTextureCoord';
const OWN_U: Expr = { code: `${OWN_UV}.x`, type: 'float' };
const OWN_V: Expr = { code: `${OWN_UV}.y`, type: 'float' };
/** The wearer's own picture: what a picture input reads while nothing feeds it. */
export const ORIGINAL: Expr = { code: 'uTexture', type: 'picture' };
/** The sampler of the picture the wearer's `index`th Pass draws, in drawing order. */
export const passSampler = (index: number) => `uPass${index}`;
/** The Padding as a share of the picture per side; a Pass's picture is the picture plus it. */
export const PASS_PAD_UNIFORM = 'uPassPad';
/** A place on the wearer's picture to the same place on a Pass's, which takes in the padding as well. */
export const PASS_UV_FN = `vec2 passUv(vec2 uv) { return (uv + ${PASS_PAD_UNIFORM}) / (1.0 + 2.0 * ${PASS_PAD_UNIFORM}); }`;
/** `uv`, a place on the wearer's picture, as the place `picture` keeps there. */
function uvOn(picture: Expr, uv: string): string {
return picture.code === ORIGINAL.code ? uv : `passUv(${uv})`;
}
/** `picture` read at `uv` and tinted: premultiplied, like the texel. */
const sampleOf = (picture: Expr, uv: string) => `${TEXEL_AT}(${picture.code}, ${uvOn(picture, uv)}) * vColor`;
/**
* The pixels a node counts per pixel of the picture it reads, per axis. 1 but on a source's field,
* whose picture has the frame's screen pixels: there a pixel counts as a unit of the frame, so a
* Blur or a Texture size does not change with the zoom.
*/
export const PIXEL_SCALE_UNIFORM = 'uPixelScale';
/** `picture`'s size in the pixels a node counts. */
const countedSize = (picture: Expr) => `(vec2(textureSize(${picture.code}, 0)) * ${PIXEL_SCALE_UNIFORM})`;
/** The input a node reads a picture from; unconnected, the wearer's own. */
const pictureIn: PortDef = { key: 'picture', label: 'picture', type: 'picture', inline: false };
export const TIME_UNIFORM = 'uTime';
/** The prelude's helper: a premultiplied sample to its straight colour. */
export const STRAIGHT_FN = 'vec3 straight(vec4 c) { return c.a > 0.0 ? c.rgb / c.a : c.rgb; }';
@@ -119,8 +151,8 @@ export function subdivisionsOf(params: PropertyBag): number {
export const TEXEL_AT = 'texelAt';
export function texelAtFn(edge: EdgeMode): string {
return edge === 'transparent'
? `vec4 ${TEXEL_AT}(vec2 uv) { vec2 inside = step(vec2(0.0), uv) * step(uv, vec2(1.0)); return texture(uTexture, uv) * (inside.x * inside.y); }`
: `vec4 ${TEXEL_AT}(vec2 uv) { return texture(uTexture, uv); }`;
? `vec4 ${TEXEL_AT}(sampler2D picture, vec2 uv) { vec2 inside = step(vec2(0.0), uv) * step(uv, vec2(1.0)); return texture(picture, uv) * (inside.x * inside.y); }`
: `vec4 ${TEXEL_AT}(sampler2D picture, vec2 uv) { return texture(picture, uv); }`;
}
const NUMBER_STEP = 0.05;
@@ -206,24 +238,37 @@ export const ShaderTextureColor: ShaderNodeDef = {
type: SHADER_TEXTURE_COLOR,
label: 'Texture color',
category: 'shader',
description: "The sprite's own pixel, before the shader: its texture tinted by its color.",
inputs: [],
description:
"The sprite's own pixel, before the shader: its texture tinted by its color. Fed a picture, that picture's pixel at the same spot.",
inputs: [pictureIn],
outputs: [vec3Port('color')],
params: [],
perPixel: true,
emit: () => ({ color: TEXEL_COLOR }),
unfed: { picture: ORIGINAL },
emit: ({ picture }) => ({
color:
picture!.code === ORIGINAL.code
? TEXEL_COLOR
: { code: `straight(${sampleOf(picture!, OWN_UV)})`, type: 'vec3' },
}),
};
export const ShaderTextureAlpha: ShaderNodeDef = {
type: SHADER_TEXTURE_ALPHA,
label: 'Texture alpha',
category: 'shader',
description: "The sprite's own pixel's opacity: 0 transparent, 1 opaque.",
inputs: [],
description: "The sprite's own pixel's opacity: 0 transparent, 1 opaque. Fed a picture, that picture's.",
inputs: [pictureIn],
outputs: [{ key: 'value', label: 'value', type: 'number' }],
params: [],
perPixel: true,
emit: () => ({ value: TEXEL_ALPHA }),
unfed: { picture: ORIGINAL },
emit: ({ picture }) => ({
value:
picture!.code === ORIGINAL.code
? TEXEL_ALPHA
: { code: `(${sampleOf(picture!, OWN_UV)}).a`, type: 'float' },
}),
};
/** The one node the compiler starts from; an unconnected input leaves that part of the pixel as it is. */
@@ -243,6 +288,47 @@ export const ShaderOutput: ShaderNodeDef = {
emit: () => ({}),
};
/** How large a Pass's picture is, as a share of the wearer's picture per side. */
export const PASS_RESOLUTION_PARAM = 'resolution';
const PASS_RESOLUTIONS: Record<string, number> = { full: 1, half: 0.5, quarter: 0.25 };
/** Pictures a shader may draw before its Output: each is one more draw of the wearer's picture. */
export const MAX_PASSES = 8;
export function passScaleOf(params: PropertyBag): number {
return PASS_RESOLUTIONS[textOf(params[PASS_RESOLUTION_PARAM] ?? '')] ?? 1;
}
/**
* A cut in the graph (#301): 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. The compiler never emits it in place – each
* Pass is a program of its own, drawn before the one that reads it.
*/
export const ShaderPass: ShaderNodeDef = {
type: SHADER_PASS,
label: 'Pass',
category: 'shader',
description:
'Draws color and alpha into a picture of their own first, the size of the sprite’s picture and its Padding. Nodes fed that picture read it whole, so a second Blur can soften the first one’s result. Resolution draws it smaller: Half has a quarter of the pixels, and a blur across it reaches twice as far. Before the last part of the graph the picture is untinted.',
inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }],
outputs: [{ key: 'picture', label: 'picture', type: 'picture' }],
params: [
{
key: PASS_RESOLUTION_PARAM,
label: 'Resolution',
kind: 'enum',
default: 'full',
options: [
{ value: 'full', label: 'Full' },
{ value: 'half', label: 'Half' },
{ value: 'quarter', label: 'Quarter' },
],
},
],
perPixel: true,
unfed: { color: TEXEL_COLOR, alpha: TEXEL_ALPHA },
emit: () => ({}),
};
/**
* The second node the compiler starts from (#79): what feeds it runs once per corner of the
* wearer's grid and moves that corner. Without it the wearer keeps the quad every sprite shares.
@@ -294,13 +380,14 @@ export const ShaderSample: ShaderNodeDef = {
inputs: [
{ key: 'u', label: 'u', type: 'number', inline: false },
{ key: 'v', label: 'v', type: 'number', inline: false },
pictureIn,
],
outputs: [vec3Port('color'), numberOut('alpha')],
params: [],
perPixel: true,
unfed: { u: OWN_U, v: OWN_V },
emit: ({ u, v }) => {
const sample = `${TEXEL_AT}(vec2(${u!.code}, ${v!.code})) * vColor`;
unfed: { u: OWN_U, v: OWN_V, picture: ORIGINAL },
emit: ({ u, v, picture }) => {
const sample = sampleOf(picture!, `vec2(${u!.code}, ${v!.code})`);
return {
color: { code: `straight(${sample})`, type: 'vec3' },
alpha: { code: `(${sample}).a`, type: 'float' },
@@ -331,16 +418,16 @@ export const BLUR_FN = [
' float s = max(r, 0.0001) * 0.5;',
' return gaussian > 0.5 ? w * exp(-d * d / (2.0 * s * s)) : w;',
'}',
`vec4 ${BLUR}(vec2 uv, float radius, vec2 axis, float gaussian) {`,
`vec4 ${BLUR}(sampler2D picture, vec2 uv, float radius, vec2 axis, float gaussian) {`,
` float r = clamp(radius, 0.0, ${floatLiteral(MAX_BLUR_RADIUS)});`,
' ivec2 reach = ivec2(axis) * int(ceil(r));',
' vec2 texel = 1.0 / vec2(textureSize(uTexture, 0));',
` vec2 texel = 1.0 / ${countedSize({ code: 'picture', type: 'picture' })};`,
' vec4 sum = vec4(0.0);',
' float total = 0.0;',
' for (int j = -reach.y; j <= reach.y; j++) {',
' for (int i = -reach.x; i <= reach.x; i++) {',
' float w = blurWeight(i, r, gaussian) * blurWeight(j, r, gaussian);',
` sum += ${TEXEL_AT}(uv + vec2(float(i), float(j)) * texel) * w;`,
` sum += ${TEXEL_AT}(picture, uv + vec2(float(i), float(j)) * texel) * w;`,
' total += w;',
' }',
' }',
@@ -356,7 +443,7 @@ export const ShaderBlur: ShaderNodeDef = {
type: SHADER_BLUR,
label: 'Blur',
category: 'shader',
description: `The sprite's picture softened around a spot, tinted like Texture color. radius is in pixels of the picture, at most ${MAX_BLUR_RADIUS}, and may be a fraction. Box weighs every pixel alike, Gaussian the middle more. Past the border the picture continues as the Output's Edge says.`,
description: `The sprite's picture softened around a spot, tinted like Texture color. radius is in pixels of the picture, at most ${MAX_BLUR_RADIUS}, and may be a fraction. Box weighs every pixel alike, Gaussian the middle more. Past the border the picture continues as the Output's Edge says. In a field shader a pixel is a unit of the frame, whatever the zoom.`,
inputs: [
{
...floatIn('radius', 2),
@@ -372,6 +459,7 @@ export const ShaderBlur: ShaderNodeDef = {
},
{ key: 'u', label: 'u', type: 'number', inline: false },
{ key: 'v', label: 'v', type: 'number', inline: false },
pictureIn,
],
outputs: [vec3Port('color'), numberOut('alpha')],
params: [
@@ -398,11 +486,12 @@ export const ShaderBlur: ShaderNodeDef = {
},
],
perPixel: true,
unfed: { u: OWN_U, v: OWN_V },
prepare: ({ radius, u, v }, params) => {
unfed: { u: OWN_U, v: OWN_V, picture: ORIGINAL },
prepare: ({ radius, u, v, picture }, params) => {
const axis = BLUR_AXES[textOf(params[BLUR_DIRECTION_PARAM] ?? '')] ?? BLUR_AXES.both!;
const gaussian = textOf(params[BLUR_KIND_PARAM] ?? '') === 'gaussian' ? '1.0' : '0.0';
return `${BLUR}(vec2(${u!.code}, ${v!.code}), ${radius!.code}, ${axis}, ${gaussian}) * vColor`;
const uv = uvOn(picture!, `vec2(${u!.code}, ${v!.code})`);
return `${BLUR}(${picture!.code}, ${uv}, ${radius!.code}, ${axis}, ${gaussian}) * vColor`;
},
emit: (_inputs, _params, prepared) => ({
color: { code: `straight(${prepared})`, type: 'vec3' },
@@ -414,14 +503,16 @@ export const ShaderTextureSize: ShaderNodeDef = {
type: SHADER_TEXTURE_SIZE,
label: 'Texture size',
category: 'shader',
description: "The sprite's picture in pixels, for effects that need to know where its pixels are.",
inputs: [],
description:
"The sprite's picture in pixels, for effects that need to know where its pixels are. Fed a Pass's picture, that one's, Padding included. In a field shader a pixel is a unit of the frame, whatever the zoom.",
inputs: [pictureIn],
outputs: [numberOut('width'), numberOut('height')],
params: [],
perPixel: true,
emit: () => ({
width: { code: 'float(textureSize(uTexture, 0).x)', type: 'float' },
height: { code: 'float(textureSize(uTexture, 0).y)', type: 'float' },
unfed: { picture: ORIGINAL },
emit: ({ picture }) => ({
width: { code: `${countedSize(picture!)}.x`, type: 'float' },
height: { code: `${countedSize(picture!)}.y`, type: 'float' },
}),
};
@@ -679,6 +770,7 @@ export const SHADER_NODES: readonly ShaderNodeDef[] = [
ShaderTextureSize,
ShaderTime,
ShaderOutput,
ShaderPass,
ShaderVertexOutput,
ShaderProperty,
ShaderColorProperty,
+2
View File
@@ -88,6 +88,8 @@ export function portColor(type: PortType): number {
return c.portStruct;
case 'vec3':
return c.portVec3;
case 'picture':
return c.portPicture;
case 'any':
return c.portAny;
}
+1
View File
@@ -77,6 +77,7 @@ export const Theme = {
portList: 0xf2a75c,
portStruct: 0xd4c25a,
portVec3: 0xff9ad5,
portPicture: 0x9b8cff,
portAny: 0xbdbdbd,
/** Ring around the port a running edge drag would connect to. */
portTarget: 0xffffff,
+6
View File
@@ -13,6 +13,11 @@ export interface FieldFrame {
pixels: { w: number; h: number };
/** World units to pixels of the picture: the frame's top left corner is 0,0. */
toPicture: Mat;
/**
* The frame's own units one pixel of the picture covers, per axis: what the shader counts a pixel
* as, so a Blur of 4 stays 4 units at any zoom.
*/
unitsPerPixel: { x: number; y: number };
/** The world rectangle around the frame, turn included – what decides whether it is on screen. */
extent: Extent;
}
@@ -31,6 +36,7 @@ export function fieldFrame(m: Mat, w: number, h: number, pixelsPerUnit: number):
return {
pixels,
toPicture: multiply(fit, invert(m)),
unitsPerPixel: { x: w / pixels.w, y: h / pixels.h },
extent: extentOfPoints(boxCorners(m, w, h)),
};
}
+3 -2
View File
@@ -218,7 +218,7 @@ export class ParticleField {
// Without a picture a particle is its plain square, and a pixel is one unit of its size.
const size = Math.max(0.01, numberOf(props[PARTICLE_SIZE_KEY], 16));
const picture = texture ? { w: texture.width, h: texture.height } : { w: size, h: size };
syncParticleShader(this.container, props, shaders, picture);
syncParticleShader(this.container, props, shaders, picture, texture);
}
/**
@@ -268,7 +268,8 @@ export class ParticleField {
placeNode(mesh, m, size.w, size.h);
mesh.texture = picture;
// Nothing to count pixels in: the frame is the edge, so neither Padding nor Vertex output moves it.
syncShader(mesh, props, shaders, null, id);
// A pixel the shader reads counts as a unit of the frame, not of the screen.
syncShader(mesh, props, shaders, null, id, frame.unitsPerPixel);
this.fieldPicture = picture;
// Only now: the shader kept the old picture bound until `syncShader` handed it the new one.
if (previous && previous !== picture) previous.destroy(true);
+18
View File
@@ -62,6 +62,24 @@ export function gridQuad(
return { positions: corners, uvs: new Float32Array(corners), indices };
}
/** Pixels per side a Pass's picture may have (#301), as many as a field's (#170). */
export const MAX_PASS_PIXELS = 2048;
/**
* The pixels of a Pass's picture (#301): the wearer's picture of `w`×`h` pixels with its margin, at
* `scale` of them per pixel – the Pass's Resolution. Never below one, so there is always a pixel.
*/
export function passPixels(
w: number,
h: number,
pad: QuadPadding | null,
scale: number,
): { w: number; h: number } {
const side = (n: number, share: number) =>
Math.min(MAX_PASS_PIXELS, Math.max(1, Math.round(n * (1 + 2 * share) * scale)));
return { w: side(w, pad?.u ?? 0), h: side(h, pad?.v ?? 0) };
}
export function paddingKey(pad: QuadPadding | null): string {
return pad ? `${pad.u},${pad.v}` : '';
}
+206
View File
@@ -0,0 +1,206 @@
import {
Container,
GlProgram,
Mesh,
MeshGeometry,
RenderTexture,
Shader,
Texture,
type TextureSource,
UniformGroup,
} from 'pixi.js';
import type { CompiledShader } from '../core/shader/compile';
import { MAX_PASSES, PASS_PAD_UNIFORM, passSampler } from '../core/shader/nodes';
import { ui } from '../ui';
import { paddedQuad, paddingKey, passPixels, quadPadding } from './quadPadding';
import { ADDRESS_MODE } from './TextureLibrary';
/**
* A pass draws the frame of the wearer's picture into its own: the quad's positions are the
* picture's pixels (the mesh is scaled to them), its UVs the wearer's own, padding included.
*/
const PASS_VERTEX = `
in vec2 aPosition;
in vec2 aUV;
out vec2 vTextureCoord;
uniform mat3 uProjectionMatrix;
uniform mat3 uWorldTransformMatrix;
uniform mat3 uTransformMatrix;
void main()
{
mat3 m = uProjectionMatrix * uWorldTransformMatrix * uTransformMatrix;
gl_Position = vec4((m * vec3(aPosition, 1.0)).xy, 0.0, 1.0);
vTextureCoord = aUV;
}
`;
const unitQuad = () => ({
positions: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
});
/** What `Mesh.shader` asks of a shader. */
class PassShader extends Shader {
texture: Texture = Texture.WHITE;
}
type PassFrame = UniformGroup<{ [PASS_PAD_UNIFORM]: { value: Float32Array; type: 'vec2<f32>' } }>;
/**
* The pictures a wearer's Pass nodes draw before its own draw (#301), one render texture each. They
* belong to one wearer – a particle source counts as one – and are drawn again only when what they
* show can have changed: the picture, its size and padding, or a uniform (the clock among them).
*/
export class PassPictures {
private readonly frame: PassFrame;
private readonly meshes: Mesh[] = [];
private readonly stage = new Container();
private targets: RenderTexture[] = [];
/** One quad all passes draw with: they share the frame. */
private geometry = new MeshGeometry(unitQuad());
private geometryKey = '';
/** What the pictures were last drawn from; empty forces a draw. */
private drawn = '';
/** `uniforms` is the wearer's group, empty when the graph has none (see `slots`). */
constructor(
private readonly compiled: CompiledShader,
private readonly uniforms: UniformGroup,
) {
this.frame = new UniformGroup({ [PASS_PAD_UNIFORM]: { value: new Float32Array(2), type: 'vec2<f32>' } });
compiled.passes.forEach((pass, index) => {
const shader = new PassShader({
glProgram: GlProgram.from({
vertex: PASS_VERTEX,
fragment: pass.fragment,
name: `shader-pass-${index}`,
}),
resources: {
...this.slots(),
uTexture: Texture.WHITE.source,
uSampler: Texture.WHITE.source.style,
shaderUniforms: uniforms,
},
});
const mesh = new Mesh({ geometry: this.geometry, texture: Texture.WHITE, shader });
this.meshes.push(mesh);
});
}
/**
* The resources a shader reading these pictures declares up front; `bind` fills them. Always all
* of them, however many passes the graph has: 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. A pass of one graph is often the very program of a pass in another.
*/
slots(): Record<string, TextureSource | PassFrame> {
const slots: Record<string, TextureSource | PassFrame> = { passFrame: this.frame };
for (let index = 0; index < MAX_PASSES; index++) slots[passSampler(index)] = Texture.WHITE.source;
return slots;
}
/**
* Draws every pass of `texture`. `picture` is what the Padding is counted in, as for the wearer's
* own quad; null draws no margin (a field's frame is its edge).
*/
draw(texture: Texture, picture: { w: number; h: number } | null): void {
const pad = picture ? quadPadding(this.compiled.padding, picture.w, picture.h) : null;
this.frame.uniforms[PASS_PAD_UNIFORM][0] = pad?.u ?? 0;
this.frame.uniforms[PASS_PAD_UNIFORM][1] = pad?.v ?? 0;
// Pixels of the picture as the GPU has it; a sprite without a picture counts in its own units.
const plain = texture === Texture.WHITE;
const base = plain
? { w: picture?.w ?? 1, h: picture?.h ?? 1 }
: { w: texture.source.pixelWidth, h: texture.source.pixelHeight };
const sizes = this.compiled.passes.map((pass) => passPixels(base.w, base.h, pad, pass.scale));
const scaleMode = texture.source.style.scaleMode;
// A render texture (a field's picture) changes under the same id every frame: never cached.
const key =
texture instanceof RenderTexture
? ''
: [texture.uid, scaleMode, paddingKey(pad), JSON.stringify(sizes), this.uniformKey()].join('|');
if (key && key === this.drawn) return;
this.drawn = key;
this.syncGeometry(pad);
const renderer = ui().app.renderer;
sizes.forEach((size, index) => {
const target = this.target(index, size, scaleMode);
const mesh = this.meshes[index]!;
const shader = mesh.shader as PassShader;
shader.texture = texture;
shader.resources.uTexture = texture.source;
shader.resources.uSampler = texture.source.style;
for (let earlier = 0; earlier < index; earlier++)
shader.resources[passSampler(earlier)] = this.targets[earlier]!.source;
mesh.texture = texture;
// The quad runs from -pad to 1 + pad; scaled and moved, that is 0..size of the target.
const sx = size.w / (1 + 2 * (pad?.u ?? 0));
const sy = size.h / (1 + 2 * (pad?.v ?? 0));
mesh.scale.set(sx, sy);
mesh.position.set((pad?.u ?? 0) * sx, (pad?.v ?? 0) * sy);
this.stage.addChild(mesh);
renderer.render({ container: this.stage, target, clear: true });
this.stage.removeChild(mesh);
});
}
/** Hands the pictures to a shader that read them, under the names the fragment declares. */
bind(resources: Record<string, unknown>): void {
this.targets.forEach((target, index) => {
resources[passSampler(index)] = target.source;
});
}
destroy(): void {
for (const mesh of this.meshes) {
mesh.shader?.destroy();
mesh.destroy();
}
for (const target of this.targets) target.destroy(true);
this.targets = [];
this.geometry.destroy();
this.stage.destroy();
}
private uniformKey(): string {
return Object.values(this.uniforms.uniforms as Record<string, number | Float32Array>)
.map((value) => (value instanceof Float32Array ? Array.from(value).join(',') : String(value)))
.join(';');
}
private syncGeometry(pad: ReturnType<typeof quadPadding>): void {
const key = paddingKey(pad);
if (key === this.geometryKey) return;
const previous = this.geometry;
this.geometry = new MeshGeometry(pad ? paddedQuad(pad) : unitQuad());
this.geometryKey = key;
for (const mesh of this.meshes) mesh.geometry = this.geometry;
previous.destroy();
}
/**
* The index-th pass's picture at `size`. It is sampled like the wearer's picture (Nearest stays
* sharp) and goes on past its frame as the Output's Edge says.
*/
private target(index: number, size: { w: number; h: number }, scaleMode: string): RenderTexture {
let target = this.targets[index];
if (!target) {
target = RenderTexture.create({ width: size.w, height: size.h });
this.targets[index] = target;
} else if (target.width !== size.w || target.height !== size.h) {
// Resized, not replaced: the wearer's shader keeps it bound.
target.resize(size.w, size.h);
}
const style = target.source.style;
const addressMode = ADDRESS_MODE[this.compiled.passEdge];
if (style.scaleMode !== scaleMode || style.addressMode !== addressMode) {
style.scaleMode = scaleMode as typeof style.scaleMode;
style.addressMode = addressMode;
style.update();
}
return target;
}
}
+93 -16
View File
@@ -12,8 +12,9 @@ import {
import { colorOf, type PropertyBag } from '../core/model/Properties';
import { shaderOf } from '../core/properties/shaders';
import type { CompiledShader, ShaderVertex } from '../core/shader/compile';
import { TIME_UNIFORM, uniformName } from '../core/shader/nodes';
import { PIXEL_SCALE_UNIFORM, TIME_UNIFORM, uniformName } from '../core/shader/nodes';
import { type QuadPadding, quadPadding } from './quadPadding';
import { PassPictures } from './shaderPasses';
/** Where a node's shader comes from: the compiled form of the asset the sprite names, if it compiles. */
export interface ShaderSource {
@@ -119,8 +120,23 @@ void main(void)
}
`;
/** One uniform per graph parameter, plus the clock where a Time node reads one. */
function uniformGroupOf(compiled: CompiledShader): UniformGroup<Record<string, UniformValue>> | null {
function samePasses(a: CompiledShader, b: CompiledShader): boolean {
return (
a.passEdge === b.passEdge &&
a.passes.length === b.passes.length &&
a.passes.every(
(pass, index) => pass.fragment === b.passes[index]!.fragment && pass.scale === b.passes[index]!.scale,
)
);
}
/**
* One uniform per graph parameter, the clock where a Time node reads one, and the pixel scale. A
* program that declares none of them leaves the group unread, but it is always there: with passes,
* two graphs share a program that only one of them feeds uniforms to (a Time only a pass reads), and
* Pixi finds a program's resources by position.
*/
function uniformGroupOf(compiled: CompiledShader): UniformGroup<Record<string, UniformValue>> {
const entries: [string, UniformValue][] = compiled.uniforms.map((u) => [
uniformName(u.name),
u.kind === 'color'
@@ -128,10 +144,14 @@ function uniformGroupOf(compiled: CompiledShader): UniformGroup<Record<string, U
: { value: u.default, type: 'f32' },
]);
if (compiled.time) entries.push([TIME_UNIFORM, { value: 0, type: 'f32' }]);
return entries.length > 0 ? new UniformGroup(Object.fromEntries(entries)) : null;
entries.push([PIXEL_SCALE_UNIFORM, { value: new Float32Array([1, 1]), type: 'vec2<f32>' }]);
return new UniformGroup(Object.fromEntries(entries));
}
type UniformValue = { value: number; type: 'f32' } | { value: Float32Array; type: 'vec3<f32>' };
type UniformValue =
| { value: number; type: 'f32' }
| { value: Float32Array; type: 'vec3<f32>' }
| { value: Float32Array; type: 'vec2<f32>' };
function readUniforms(
into: Record<string, number | Float32Array>,
@@ -162,11 +182,13 @@ function readUniforms(
* constructor declared, so a subclass names them all up front.
*/
abstract class CompiledGraphShader extends Shader {
private readonly uniforms: Record<string, number | Float32Array> | null;
private readonly uniforms: Record<string, number | Float32Array>;
private readonly compiled: CompiledShader;
readonly fragment: string;
/** The vertex stage this was built with; a particle field's never comes from the graph. */
private readonly vertex: string;
/** What the graph's Pass nodes draw ahead of this draw (#301); null without any. */
private readonly pictures: PassPictures | null;
constructor(
readonly assetId: string,
@@ -175,30 +197,61 @@ abstract class CompiledGraphShader extends Shader {
resources: Record<string, unknown>,
) {
const group = uniformGroupOf(compiled);
const pictures = compiled.passes.length > 0 ? new PassPictures(compiled, group) : null;
super({
glProgram: GlProgram.from({ vertex: program.vertex, fragment: compiled.fragment, name: program.name }),
resources: { ...resources, ...(group ? { shaderUniforms: group } : {}) },
resources: { ...resources, shaderUniforms: group, ...pictures?.slots() },
});
this.fragment = compiled.fragment;
this.vertex = program.vertex;
this.compiled = compiled;
this.uniforms = group ? group.uniforms : null;
this.uniforms = group.uniforms;
this.pictures = pictures;
}
/**
* The units one pixel of the picture counts as (`PIXEL_SCALE_UNIFORM`): 1 for a picture of its
* own pixels, a frame unit's share on a field's picture of screen pixels.
*/
scalePixels(scale: { x: number; y: number } | null): void {
const slot = this.uniforms[PIXEL_SCALE_UNIFORM] as Float32Array;
slot[0] = scale?.x ?? 1;
slot[1] = scale?.y ?? 1;
}
/** True while this is still the shader `props` ask for, so it can be updated instead of rebuilt. */
wears(id: string, compiled: CompiledShader): boolean {
return (
this.assetId === id && this.fragment === compiled.fragment && this.vertex === this.vertexFor(compiled)
this.assetId === id &&
this.fragment === compiled.fragment &&
this.vertex === this.vertexFor(compiled) &&
samePasses(this.compiled, compiled)
);
}
/**
* Draws the Pass nodes' pictures of `texture` and binds them for this draw – after `read`, so
* they see this frame's uniforms. `picture` is what the Padding is counted in.
*/
drawPasses(texture: Texture, picture: { w: number; h: number } | null): void {
if (!this.pictures) return;
this.pictures.draw(texture, picture);
this.pictures.bind(this.resources);
}
/** The shader first: it lets go of the pictures, which may then go without Pixi's warning. */
override destroy(): void {
super.destroy();
this.pictures?.destroy();
}
/** The vertex stage this kind of wearer draws `compiled` with. */
protected vertexFor(_compiled: CompiledShader): string {
return this.vertex;
}
read(props: PropertyBag, now: number): void {
if (this.uniforms) readUniforms(this.uniforms, this.compiled, props, now);
readUniforms(this.uniforms, this.compiled, props, now);
}
}
@@ -232,11 +285,12 @@ class GraphShader extends CompiledGraphShader {
return spriteVertexOf(compiled);
}
/** The picture the Vertex output's pixels are counted in; null holds every corner in place. */
count(picture: { w: number; h: number } | null): void {
/** The picture the Vertex output's pixels are counted in; null or a fixed one holds every corner in place. */
count(picture: WornPicture | null): void {
if (!this.perPixel) return;
this.perPixel[0] = picture && picture.w > 0 ? 1 / picture.w : 0;
this.perPixel[1] = picture && picture.h > 0 ? 1 / picture.h : 0;
const counted = picture && !picture.fixed ? picture : null;
this.perPixel[0] = counted && counted.w > 0 ? 1 / counted.w : 0;
this.perPixel[1] = counted && counted.h > 0 ? 1 / counted.h : 0;
}
/** Pixi binds a mesh's texture only for its own shader, so a custom one takes it here. */
@@ -260,33 +314,51 @@ export function wornShaderId(mesh: Mesh): string {
return wornBy(mesh)?.assetId ?? '';
}
/**
* What a wearer's Padding and Vertex output count their pixels in (#69, #79). A `fixed` one keeps
* its corners where they are – a 9-slice's parts are not a grid – while its passes still have a
* margin to draw (#301).
*/
export interface WornPicture {
w: number;
h: number;
fixed?: boolean;
}
/**
* Makes the mesh's shader match `props`: updated in place while asset and fragment are the same,
* rebuilt when either changed, dropped for no shader – and for one that does not compile, which
* leaves the mesh on Pixi's batched draw.
* leaves the mesh on Pixi's batched draw. `picture` null counts nothing: no corner moves, and a
* pass has no margin.
*/
export function syncShader(
mesh: Mesh,
props: PropertyBag,
shaders: ShaderSource,
picture: { w: number; h: number } | null = null,
picture: WornPicture | null = null,
/** The shader asset to wear; a source's field names its own (#170). */
id = shaderOf(props),
/** The units a pixel of the picture counts as; null counts pixels. A field counts frame units. */
unitsPerPixel: { x: number; y: number } | null = null,
): void {
const compiled = id ? shaders.compiled(id) : null;
const worn = wornBy(mesh);
if (worn && compiled && worn.wears(id, compiled)) {
worn.read(props, shaders.now);
worn.scalePixels(unitsPerPixel);
worn.wear(mesh.texture);
worn.count(picture);
worn.drawPasses(mesh.texture, picture);
return;
}
disposeShader(mesh);
if (compiled) {
const shader = new GraphShader(id, compiled);
shader.read(props, shaders.now);
shader.scalePixels(unitsPerPixel);
shader.wear(mesh.texture);
shader.count(picture);
shader.drawPasses(mesh.texture, picture);
mesh.shader = shader;
}
// Pixi caches whether a mesh batches (MeshPipe's meshData) and re-checks it only in
@@ -351,14 +423,18 @@ export function syncParticleShader(
props: PropertyBag,
shaders: ShaderSource,
picture: { w: number; h: number },
/** The particles' picture, which the passes draw from; null while there is none. */
texture: Texture | null = null,
): void {
const id = shaderOf(props);
const compiled = id ? shaders.compiled(id) : null;
const pad = compiled ? quadPadding(compiled.padding, picture.w, picture.h) : null;
const worn = wornByField(container);
// The passes see the particles' picture untinted, so they are drawn once for every particle.
if (worn && compiled && worn.wears(id, compiled)) {
worn.read(props, shaders.now);
worn.grow(pad);
worn.drawPasses(texture ?? Texture.WHITE, picture);
return;
}
disposeParticleShader(container);
@@ -366,6 +442,7 @@ export function syncParticleShader(
const shader = new ParticleGraphShader(id, compiled);
shader.read(props, shaders.now);
shader.grow(pad);
shader.drawPasses(texture ?? Texture.WHITE, picture);
container.shader = shader;
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ export function syncSpriteMesh(
mesh.texture = texture ?? Texture.WHITE;
mesh.tint = Number(item.props.color ?? 0xffffff);
// A sliced sprite keeps its nine parts where they are: its corners are not the grid's (#79).
syncShader(mesh, item.props, shaders, slice ? null : picture);
syncShader(mesh, item.props, shaders, slice ? { ...picture, fixed: true } : picture);
}
/**