diff --git a/README.md b/README.md index 6b15e546..35c77af2 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1712 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1716 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index c03bbc32..ff99e2ac 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -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'; } diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index ac1c94c9..6ad44387 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -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. */ diff --git a/src/core/graph/emitGraph.ts b/src/core/graph/emitGraph.ts index f719a1ec..efd90df5 100644 --- a/src/core/graph/emitGraph.ts +++ b/src/core/graph/emitGraph.ts @@ -11,6 +11,11 @@ export interface EmitRules { 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 | null | undefined; } export interface GraphEmitter { @@ -56,6 +61,11 @@ export function graphEmitter(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 = {}; let complete = true; diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index 26299335..713861ae 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -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` 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 | 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(); + 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 = 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 | null { @@ -200,11 +216,115 @@ function firstProblem(graph: Graph, problems: Record): string | return first ? problems[first.id]! : null; } +function reaches(graph: Graph, walk: GraphEmitter, type: string): boolean { + return graph.nodes.some((n) => n.type === type && walk.reached(n)); +} + +interface FragmentParts { + walk: GraphEmitter; + lines: string[]; + color: Expr; + alpha: Expr; + mode: EdgeMode; + uniforms: ShaderUniform[]; + /** The passes whose pictures the walk read. */ + reads: Set; + /** 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[] = []; + /** What keeps a Pass itself from being drawn, rather than something feeding it. */ + const problems: Record = {}; + const indexOf = new Map(); + const drawing = new Set(); + + const cut = + (reads: Set): 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(); + 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(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; diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index c8e62886..354fd005 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -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 = { 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, diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 1c7b9511..33d7c5f9 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -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; } diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 49d53cc8..4af1fe95 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -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, diff --git a/src/viewport/fieldFrame.ts b/src/viewport/fieldFrame.ts index 0854bc90..92f39273 100644 --- a/src/viewport/fieldFrame.ts +++ b/src/viewport/fieldFrame.ts @@ -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)), }; } diff --git a/src/viewport/particles.ts b/src/viewport/particles.ts index 00389fee..6c3373c6 100644 --- a/src/viewport/particles.ts +++ b/src/viewport/particles.ts @@ -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); diff --git a/src/viewport/quadPadding.ts b/src/viewport/quadPadding.ts index 9fc91137..093ecc3e 100644 --- a/src/viewport/quadPadding.ts +++ b/src/viewport/quadPadding.ts @@ -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}` : ''; } diff --git a/src/viewport/shaderPasses.ts b/src/viewport/shaderPasses.ts new file mode 100644 index 00000000..f4862779 --- /dev/null +++ b/src/viewport/shaderPasses.ts @@ -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' } }>; + +/** + * 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' } }); + 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 { + const slots: Record = { 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): 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) + .map((value) => (value instanceof Float32Array ? Array.from(value).join(',') : String(value))) + .join(';'); + } + + private syncGeometry(pad: ReturnType): 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; + } +} diff --git a/src/viewport/shaders.ts b/src/viewport/shaders.ts index e5d37cdb..3a203077 100644 --- a/src/viewport/shaders.ts +++ b/src/viewport/shaders.ts @@ -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> | 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> { const entries: [string, UniformValue][] = compiled.uniforms.map((u) => [ uniformName(u.name), u.kind === 'color' @@ -128,10 +144,14 @@ function uniformGroupOf(compiled: CompiledShader): UniformGroup 0 ? new UniformGroup(Object.fromEntries(entries)) : null; + entries.push([PIXEL_SCALE_UNIFORM, { value: new Float32Array([1, 1]), type: 'vec2' }]); + return new UniformGroup(Object.fromEntries(entries)); } -type UniformValue = { value: number; type: 'f32' } | { value: Float32Array; type: 'vec3' }; +type UniformValue = + | { value: number; type: 'f32' } + | { value: Float32Array; type: 'vec3' } + | { value: Float32Array; type: 'vec2' }; function readUniforms( into: Record, @@ -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 | null; + private readonly uniforms: Record; 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, ) { 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; } } diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 18063c90..8fbe81e5 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -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); } /** diff --git a/tests/core/particle-field-shader.test.ts b/tests/core/particle-field-shader.test.ts index aa98f160..1d3eef64 100644 --- a/tests/core/particle-field-shader.test.ts +++ b/tests/core/particle-field-shader.test.ts @@ -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, + }); + }); }); diff --git a/tests/core/shader-blur.test.ts b/tests/core/shader-blur.test.ts index 6d271d92..a297579a 100644 --- a/tests/core/shader-blur.test.ts +++ b/tests/core/shader-blur.test.ts @@ -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'); diff --git a/tests/core/shader-passes.test.ts b/tests/core/shader-passes.test.ts new file mode 100644 index 00000000..309734fe --- /dev/null +++ b/tests/core/shader-passes.test.ts @@ -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 }); + }); +}); diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 35909399..2abf5cba 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -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\(/); }); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 3549cc4e..ddf10428 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -1053,6 +1053,93 @@ export async function shader(t) { Boolean(dot) && lit === 9 && Math.abs(opacity - 1) < 0.1, { dot, lit, opacity: opacity.toFixed(3), spot: spot.map((a) => a.toFixed(2)).join(' ') }, ); + + // 28l-vii. A Pass draws first into a picture of its own (#301): blurred across, then that picture + // blurred down, is the spot a Blur of Both draws in one go. + const spotOf = async () => { + const values = []; + for (let j = 2; j <= 6; j++) + for (let i = 2; i <= 6; i++) + values.push((rgb(await texel(i, j, 16))[0] - ground[0]) / (255 - ground[0])); + return values; + }; + await blurred({ radius: 2 }, { edge: 'transparent' }, [[], [edge('blur', 'alpha', 'out', 'alpha')]]); + const oneGo = await spotOf(); + await graph( + [ + node('across', 'ShaderBlur', { radius: 2, direction: 'horizontal' }, 0, 0), + node('pass', 'ShaderPass', {}, 260, 0), + node('down', 'ShaderBlur', { radius: 2, direction: 'vertical' }, 520, 0), + node('out', 'ShaderOutput', { edge: 'transparent' }, 780, 0), + ], + [ + edge('across', 'color', 'pass', 'color'), + edge('across', 'alpha', 'pass', 'alpha'), + edge('pass', 'picture', 'down', 'picture'), + edge('down', 'color', 'out', 'color'), + edge('down', 'alpha', 'out', 'alpha'), + ], + ); + const twoPasses = await spotOf(); + const apart = Math.max(...oneGo.map((a, k) => Math.abs(a - twoPasses[k]))); + check( + 'a Blur across, a Pass and a Blur down draw the spot a Blur of Both draws in one pass', + oneGo.filter((a) => a > 0.01).length === 25 && apart < 0.02, + { + apart: apart.toFixed(3), + oneGo: oneGo.map((a) => a.toFixed(2)).join(' '), + twoPasses: twoPasses.map((a) => a.toFixed(2)).join(' '), + }, + ); + // Bloom's shape: the passed picture added to the untouched original. The original stays readable + // beside a pass, and the sprite's colour tints only the last part, not what the Pass drew. + await assetProps({ texture: picture?.id ?? '', color: 0x808080 }); + await graph( + [ + node('pass', 'ShaderPass', {}, 0, 0), + node('passed', 'ShaderTextureColor', {}, 260, 0), + node('own', 'ShaderTextureColor', {}, 260, 200), + node('add', 'ShaderAdd', {}, 520, 0), + node('out', 'ShaderOutput', {}, 780, 0), + ], + [ + edge('pass', 'picture', 'passed', 'picture'), + edge('passed', 'color', 'add', 'a'), + edge('own', 'color', 'add', 'b'), + edge('add', 'result', 'out', 'color'), + ], + ); + const bloomed = rgb(await texel(4, 6)); + await mutate(`delete __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.color`); + // Grey halves red twice, once in the pass and once in the original: 230 / 2 + 230 / 2. + check( + 'a Pass’s picture and the original read side by side, each tinted once at the end', + Math.abs(bloomed[0] - 230) < 8 && Math.abs(bloomed[1] - 60) < 8, + { bloomed }, + ); + // The open Pass above is the very program of the first of two passes here. Pixi binds a program's + // pictures by position, so the second graph read a placeholder: white, until the page reloaded. + await graph( + [ + node('first', 'ShaderPass', {}, 0, 0), + node('again', 'ShaderTextureColor', {}, 260, 0), + node('second', 'ShaderPass', {}, 520, 0), + node('passed', 'ShaderTextureColor', {}, 780, 0), + node('out', 'ShaderOutput', {}, 1040, 0), + ], + [ + edge('first', 'picture', 'again', 'picture'), + edge('again', 'color', 'second', 'color'), + edge('second', 'picture', 'passed', 'picture'), + edge('passed', 'color', 'out', 'color'), + ], + ); + const chained = [await texel(4, 6), await texel(27, 6)]; + check( + 'two Passes in a row show the picture, also where the first shares its program with an earlier graph', + colourDistance(chained[0], LEFT) < 12 && colourDistance(chained[1], RIGHT) < 12, + { chained }, + ); await assetProps({ texture: picture?.id ?? '' }); await filterOf(picture?.id, 'linear'); diff --git a/tools/e2e/31-particles.mjs b/tools/e2e/31-particles.mjs index 8e14ca37..06e41f4e 100644 --- a/tools/e2e/31-particles.mjs +++ b/tools/e2e/31-particles.mjs @@ -718,7 +718,65 @@ export async function particles(t) { JSON.stringify({ inFrame, pastFrame }), ); - // 31v-iii. Taken off again, the source draws its particles itself and no picture is left. + // 31v-iii. A pixel of the field's picture counts as a unit of the frame, not of the screen: a + // Blur of 8 across a flat picture ramps the frame's edge down over the same 8 units at + // 50 % and at 200 % (only the inner half: past the frame nothing is drawn). Counted in + // screen pixels it would be 16 units, and 4. Unselected, so the frame's dashes and the + // gizmo stay off the row. + await select(null); + await setField({ + nodes: [ + { id: 'fd-red', type: 'ShaderColor', x: 0, y: 0, params: { value: 0xe63c3c } }, + { id: 'fd-one', type: 'ShaderNumber', x: 0, y: 80, params: { value: 1 } }, + { id: 'fd-pass', type: 'ShaderPass', x: 200, y: 0, params: {} }, + { id: 'fd-blur', type: 'ShaderBlur', x: 400, y: 0, params: { radius: 8, direction: 'horizontal' } }, + { id: 'fd-out', type: 'ShaderOutput', x: 600, y: 0, params: { edge: 'transparent' } }, + ], + edges: [ + { from: { node: 'fd-red', port: 'color' }, to: { node: 'fd-pass', port: 'color' } }, + { from: { node: 'fd-one', port: 'value' }, to: { node: 'fd-pass', port: 'alpha' } }, + { from: { node: 'fd-pass', port: 'picture' }, to: { node: 'fd-blur', port: 'picture' } }, + { from: { node: 'fd-blur', port: 'color' }, to: { node: 'fd-out', port: 'color' } }, + { from: { node: 'fd-blur', port: 'alpha' }, to: { node: 'fd-out', port: 'alpha' } }, + ], + }); + const zoomBy = async (steps) => { + const spot = await json(`__gm.objectScreen(${JSON.stringify(SOURCE)})`); + for (let i = 0; i < Math.abs(steps); i++) { + await t.wheel(Math.round(spot.x), Math.round(spot.y), steps > 0 ? -240 : 240); + await sleep(150); + } + await at(0.3); + }; + const rampAtEdge = async () => { + const spot = await json(`__gm.objectScreen(${JSON.stringify(SOURCE)})`); + const scale = (await json(`__gm.objectRect(${JSON.stringify(SOURCE)})`)).w / 24; + const from = Math.round(spot.x + 100 * scale); + const row = await json(`__gm.pixelRow(${from}, ${Math.round(spot.y)}, ${Math.round(45 * scale)})`); + const red = (hex) => parseInt(hex.slice(1, 3), 16); + const full = red(row[0]); + const ground = red(row[row.length - 1]); + const shares = row.map((hex) => (red(hex) - ground) / (full - ground)); + const partial = shares.filter((s) => s > 0.05 && s < 0.95).length; + return { scale, units: Number((partial / scale).toFixed(1)), full: row[0], ground: row[row.length - 1] }; + }; + await zoomBy(-1); + const zoomedOut = await rampAtEdge(); + await zoomBy(2); + const zoomedIn = await rampAtEdge(); + await shot('31-particles-field-blur'); + await zoomBy(-1); + await selectSource(); + check( + 'a field’s Blur reaches as many frame units at 50 % as at 200 %', + zoomedOut.scale < unit && + zoomedIn.scale > unit && + Math.abs(zoomedOut.units - 8) <= 2 && + Math.abs(zoomedIn.units - 8) <= 2, + JSON.stringify({ zoomedOut, zoomedIn, unit }), + ); + + // 31v-iv. Taken off again, the source draws its particles itself and no picture is left. await setField(null); const back = await fallen(); check( diff --git a/tools/lib/shaders.mjs b/tools/lib/shaders.mjs index 60ba60eb..a72b94d1 100644 --- a/tools/lib/shaders.mjs +++ b/tools/lib/shaders.mjs @@ -45,3 +45,46 @@ export function adjustColorsShader(id = 'adjust-colors', name = 'Adjust colors') }, }; } + +/** + * A blur in two passes as a shader graph (#301): a Blur across into a Pass at `resolution`, its + * picture blurred down into the Output. Below full resolution an open Pass shrinks the picture + * first, so both Blurs read pixels of that size and reach `radius` times 2 (half) or 4 (quarter). + * `clocked` ties the radius to Time (times 0), which leaves the pixels alone but draws the Passes + * again every frame – a still one is drawn once. + */ +export function twoPassBlurGraph(radius, resolution = 'full', clocked = false) { + const shrunk = resolution !== 'full'; + return { + nodes: [ + ...(shrunk ? [node('tp-shrink', 'ShaderPass', -260, 0, { resolution })] : []), + ...(clocked + ? [ + node('tp-time', 'ShaderTime', 0, 200), + node('tp-still', 'ShaderMultiply', 260, 200, { b: 0 }), + node('tp-radius', 'ShaderAdd', 520, 200, { b: radius }), + ] + : []), + node('tp-across', 'ShaderBlur', 0, 0, { radius, direction: 'horizontal' }), + node('tp-pass', 'ShaderPass', 260, 0, { resolution }), + node('tp-down', 'ShaderBlur', 520, 0, { radius, direction: 'vertical' }), + node('tp-out', 'ShaderOutput', 780, 0), + ], + edges: [ + ...(clocked + ? [ + edge('tp-e1', 'tp-time', 'value', 'tp-still', 'a'), + edge('tp-e2', 'tp-still', 'result', 'tp-radius', 'a'), + edge('tp-e3', 'tp-radius', 'result', 'tp-across', 'radius'), + edge('tp-e4', 'tp-radius', 'result', 'tp-down', 'radius'), + ] + : []), + ...(shrunk ? [edge('tp-e0', 'tp-shrink', 'picture', 'tp-across', 'picture')] : []), + edge('tp-e5', 'tp-across', 'color', 'tp-pass', 'color'), + edge('tp-e6', 'tp-across', 'alpha', 'tp-pass', 'alpha'), + edge('tp-e7', 'tp-pass', 'picture', 'tp-down', 'picture'), + edge('tp-e8', 'tp-down', 'color', 'tp-out', 'color'), + edge('tp-e9', 'tp-down', 'alpha', 'tp-out', 'alpha'), + ], + }; +} diff --git a/tools/perf-field-shaders.mjs b/tools/perf-field-shaders.mjs index 0dcce538..62fec859 100644 --- a/tools/perf-field-shaders.mjs +++ b/tools/perf-field-shaders.mjs @@ -12,6 +12,9 @@ * Usage: node tools/seed-e2e.mjs && node tools/perf-field-shaders.mjs * GM_PERF_COUNTS=... source counts (default 1,10,50), GM_PERF_FRAME=... frame edge in units * GM_PERF_BLUR=... puts a Blur (#300) with that radius between picture and Output + * GM_PERF_PASSES=1 makes that Blur two (#301): across, a Pass, then down; a field's picture is + * new every frame, so is the Pass. GM_PERF_PASS_RESOLUTION=half|quarter puts + * an open Pass first, so the radius reaches twice or four times as far * * It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it. * Read the rate, not the median – see `perf-shaders.mjs` for why, and for the 60 Hz cap. @@ -19,25 +22,30 @@ import { readFileSync } from 'node:fs'; import { runChecks } from './lib/harness.mjs'; import { PROJECTS, cliArgs } from './lib/projects.mjs'; +import { twoPassBlurGraph } from './lib/shaders.mjs'; const { projectId, baseUrl } = cliArgs('perf-field-shaders.mjs', PROJECTS.fixture); const COUNTS = (process.env.GM_PERF_COUNTS ?? '1,10,50').split(',').map(Number); const FRAME = Number(process.env.GM_PERF_FRAME ?? 256); const BLUR = Number(process.env.GM_PERF_BLUR ?? 0); +const PASSES = process.env.GM_PERF_PASSES === '1'; +const PASS_RESOLUTION = process.env.GM_PERF_PASS_RESOLUTION ?? 'full'; const FIELD_GRAPH = - BLUR > 0 - ? { - nodes: [ - { id: 'pf-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: BLUR } }, - { id: 'pf-out', type: 'ShaderOutput', x: 300, y: 0, params: {} }, - ], - edges: [ - { id: 'pf-c', from: { node: 'pf-blur', port: 'color' }, to: { node: 'pf-out', port: 'color' } }, - { id: 'pf-a', from: { node: 'pf-blur', port: 'alpha' }, to: { node: 'pf-out', port: 'alpha' } }, - ], - } - : { nodes: [{ id: 'pf-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] }; + BLUR > 0 && PASSES + ? twoPassBlurGraph(BLUR, PASS_RESOLUTION) + : BLUR > 0 + ? { + nodes: [ + { id: 'pf-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: BLUR } }, + { id: 'pf-out', type: 'ShaderOutput', x: 300, y: 0, params: {} }, + ], + edges: [ + { id: 'pf-c', from: { node: 'pf-blur', port: 'color' }, to: { node: 'pf-out', port: 'color' } }, + { id: 'pf-a', from: { node: 'pf-blur', port: 'alpha' }, to: { node: 'pf-out', port: 'alpha' } }, + ], + } + : { nodes: [{ id: 'pf-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] }; const WARMUP_MS = 1500; const SAMPLE_MS = 4000; @@ -147,7 +155,7 @@ await runChecks({ projectId, baseUrl, port: 9348, shotPrefix: 'gm-perf-field' }, await stop(); console.log( - `\nframe ${FRAME}x${FRAME} units, ${BLUR > 0 ? `Blur radius ${BLUR}` : 'pass-through'} field shader, ${SAMPLE_MS} ms per point`, + `\nframe ${FRAME}x${FRAME} units, ${BLUR > 0 ? `Blur radius ${BLUR}${PASSES ? `, in two passes at ${PASS_RESOLUTION}` : ''}` : 'pass-through'} field shader, ${SAMPLE_MS} ms per point`, ); console.log('| sources | no field shader | field shader | picture |'); console.log('| --- | --- | --- | --- |'); diff --git a/tools/perf-shaders.mjs b/tools/perf-shaders.mjs index 5b4dd94b..21edd599 100644 --- a/tools/perf-shaders.mjs +++ b/tools/perf-shaders.mjs @@ -13,6 +13,10 @@ * GM_PERF_SUBDIVISIONS=... gives the shader a Vertex output with that grid (#79); nothing feeds * it, so the pixels stay the same and only the corners are more * GM_PERF_BLUR=... wears a shader of one Blur (#300) with that radius instead of Adjust colors + * GM_PERF_PASSES=1 makes that Blur two (#301): across, a Pass, then down. Its radius is tied to + * Time (times 0), so the Pass is drawn again every frame – the case that costs, + * where a still one is drawn once. GM_PERF_PASS_RESOLUTION=half|quarter puts + * an open Pass first, so the radius reaches twice or four times as far * * It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it. * @@ -26,6 +30,7 @@ import { readFileSync } from 'node:fs'; import { runChecks } from './lib/harness.mjs'; import { PROJECTS, cliArgs } from './lib/projects.mjs'; +import { twoPassBlurGraph } from './lib/shaders.mjs'; const { projectId, baseUrl } = cliArgs('perf-shaders.mjs', PROJECTS.fixture); @@ -33,6 +38,8 @@ const COUNTS = (process.env.GM_PERF_COUNTS ?? '50,100,200,400,800').split(',').m const SIZE = Number(process.env.GM_PERF_SIZE ?? 48); const SUBDIVISIONS = Number(process.env.GM_PERF_SUBDIVISIONS ?? 0); const BLUR = Number(process.env.GM_PERF_BLUR ?? 0); +const PASSES = process.env.GM_PERF_PASSES === '1'; +const PASS_RESOLUTION = process.env.GM_PERF_PASS_RESOLUTION ?? 'full'; const SHADER = BLUR > 0 ? 'perf-blur' : 'adjust-colors'; const WARMUP_MS = 1500; const SAMPLE_MS = 4000; @@ -54,7 +61,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async p.assets.push({ id: 'perf-sprite', type: 'sprite', name: 'Perf sprite', props: { width: ${SIZE}, height: ${SIZE}, color: 0x4080ff, shader: '' } }); p.assets.push({ id: 'perf-blur', type: 'shader', name: 'Perf blur', props: {}, - graph: { nodes: [ + graph: ${PASSES} ? ${JSON.stringify(twoPassBlurGraph(BLUR, PASS_RESOLUTION, true))} : { nodes: [ { id: 'pb-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: ${BLUR} } }, { id: 'pb-out', type: 'ShaderOutput', x: 300, y: 0, params: {} }, ], edges: [ @@ -168,7 +175,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async console.log( `\nsprite ${SIZE}x${SIZE}, ${SUBDIVISIONS > 0 ? `grid of ${SUBDIVISIONS}` : 'plain quad'}, ` + - `${BLUR > 0 ? `Blur radius ${BLUR}` : 'Adjust colors, neutral values'}, ${SAMPLE_MS} ms per point, ` + + `${BLUR > 0 ? `Blur radius ${BLUR}${PASSES ? `, in two passes at ${PASS_RESOLUTION}` : ''}` : 'Adjust colors, neutral values'}, ${SAMPLE_MS} ms per point, ` + `60 Hz cap (cannot be lifted in headless)`, ); console.log('| sprites | no shader | with shader | per sprite | shaders | filters |');