Draw a shader in passes with a Pass node (#301)
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:
1 parent
df61b08bd1
commit
f03ca0d382
23 files changed
+1111
-134
No files matched your search
@@ -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';
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user