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
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -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
|
* `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 =
|
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 {
|
export interface PortRef {
|
||||||
node: string;
|
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 {
|
export function portsCompatible(from: PortType, to: PortType): boolean {
|
||||||
if (from === 'exec' || to === 'exec') return from === to;
|
if (from === 'exec' || to === 'exec') return from === to;
|
||||||
|
if (from === 'picture' || to === 'picture') return from === to;
|
||||||
if (from === 'number' && to === 'vec3') return true;
|
if (from === 'number' && to === 'vec3') return true;
|
||||||
return from === to || from === 'any' || to === 'any';
|
return from === to || from === 'any' || to === 'any';
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ export function inlineInputs(def: NodeDef): PortDef[] {
|
|||||||
function isInlinePort(port: PortDef): boolean {
|
function isInlinePort(port: PortDef): boolean {
|
||||||
if (port.inline === false) return false;
|
if (port.inline === false) return false;
|
||||||
if (port.type === 'vec3') return port.field !== undefined;
|
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. */
|
/** 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;
|
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. */
|
/** 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;
|
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> {
|
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`);
|
problem(node, `Not a ${rules.noun} node`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const cut = rules.cut?.(def, node);
|
||||||
|
if (cut !== undefined) {
|
||||||
|
if (cut) emitted.set(node.id, cut);
|
||||||
|
return cut;
|
||||||
|
}
|
||||||
visiting.add(node.id);
|
visiting.add(node.id);
|
||||||
const inputs: Record<string, T> = {};
|
const inputs: Record<string, T> = {};
|
||||||
let complete = true;
|
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 { edgesInto, type Graph, type GraphNode } from '../graph/Graph';
|
||||||
import { nodeDef } from '../graph/NodeRegistry';
|
import { nodeDef } from '../graph/NodeRegistry';
|
||||||
import { colorOf } from '../model/Properties';
|
import { colorOf } from '../model/Properties';
|
||||||
@@ -13,10 +13,19 @@ import {
|
|||||||
type Expr,
|
type Expr,
|
||||||
floatLiteral,
|
floatLiteral,
|
||||||
isShaderNodeDef,
|
isShaderNodeDef,
|
||||||
|
MAX_PASSES,
|
||||||
|
ORIGINAL,
|
||||||
|
PASS_PAD_UNIFORM,
|
||||||
|
PASS_UV_FN,
|
||||||
|
passSampler,
|
||||||
|
passScaleOf,
|
||||||
paddingOf,
|
paddingOf,
|
||||||
|
PIXEL_SCALE_UNIFORM,
|
||||||
SHADER_BLUR,
|
SHADER_BLUR,
|
||||||
SHADER_OUTPUT,
|
SHADER_OUTPUT,
|
||||||
|
SHADER_PASS,
|
||||||
SHADER_SAMPLE,
|
SHADER_SAMPLE,
|
||||||
|
SHADER_TEXTURE_SIZE,
|
||||||
SHADER_TIME,
|
SHADER_TIME,
|
||||||
SHADER_VERTEX_OUTPUT,
|
SHADER_VERTEX_OUTPUT,
|
||||||
type ShaderNodeDef,
|
type ShaderNodeDef,
|
||||||
@@ -54,8 +63,24 @@ export interface ShaderVertex {
|
|||||||
subdivisions: number;
|
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 {
|
export interface CompiledShader {
|
||||||
fragment: string;
|
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. */
|
/** Null without a Vertex output node: the wearer then draws on the quad every sprite shares. */
|
||||||
vertex: ShaderVertex | null;
|
vertex: ShaderVertex | null;
|
||||||
uniforms: ShaderUniform[];
|
uniforms: ShaderUniform[];
|
||||||
@@ -83,17 +108,30 @@ export interface ShaderBuild {
|
|||||||
|
|
||||||
export const PADDING_CLAMP_HINT = 'Padding with Clamp smears the border';
|
export const PADDING_CLAMP_HINT = 'Padding with Clamp smears the border';
|
||||||
export const perCornerProblem = (what: string) => `Not available per corner: ${what}`;
|
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
|
* 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
|
* 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 {
|
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[] = [];
|
const lines: string[] = [];
|
||||||
/** What the vertex walk met that only a pixel has; the Vertex output node reports the first. */
|
const reads = new Set<number>();
|
||||||
const perPixel: string[] = [];
|
const passes = passDrawer(graph, mode, uniforms);
|
||||||
const walk = walker(graph, lines, 'n', null);
|
const walk = walker(graph, lines, 'n', null, passes.cut(reads));
|
||||||
const { problems } = walk;
|
const { problems } = walk;
|
||||||
|
|
||||||
walk.checkNames(graph.nodes.filter(isPropertyNode), propertyName, (name) =>
|
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' };
|
if (!output) return { compiled: null, problems, hints: {}, reason: 'No Output node' };
|
||||||
const mode = edgeOf(output.params);
|
|
||||||
const padding = paddingOf(output.params);
|
const padding = paddingOf(output.params);
|
||||||
const hints: Record<string, string> =
|
const hints: Record<string, string> =
|
||||||
padding > 0 && mode === 'clamp' ? { [output.id]: PADDING_CLAMP_HINT } : {};
|
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 color = walk.input(output, outputDef.inputs[0]!);
|
||||||
const alpha = walk.input(output, outputDef.inputs[1]!);
|
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
|
// 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 corner = graph.nodes.find((n) => n.type === SHADER_VERTEX_OUTPUT);
|
||||||
const cornerLines: string[] = [];
|
const cornerLines: string[] = [];
|
||||||
const cornerWalk = walker(graph, cornerLines, 'c', perPixel);
|
const cornerWalk = walker(graph, cornerLines, 'c', perPixel, () => ({ picture: ORIGINAL }));
|
||||||
let vertex: ShaderVertex | null = null;
|
let vertex: ShaderVertex | null = null;
|
||||||
if (corner) {
|
if (corner) {
|
||||||
const cornerDef = nodeDef(SHADER_VERTEX_OUTPUT)!;
|
const cornerDef = nodeDef(SHADER_VERTEX_OUTPUT)!;
|
||||||
const x = cornerWalk.input(corner, cornerDef.inputs[0]!);
|
const x = cornerWalk.input(corner, cornerDef.inputs[0]!);
|
||||||
const y = cornerWalk.input(corner, cornerDef.inputs[1]!);
|
const y = cornerWalk.input(corner, cornerDef.inputs[1]!);
|
||||||
if (perPixel.length > 0) cornerWalk.problem(corner, perCornerProblem(perPixel[0]!));
|
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 = reaches(graph, cornerWalk, SHADER_TIME);
|
||||||
const reachesTime = graph.nodes.some((n) => n.type === SHADER_TIME && cornerWalk.reached(n));
|
|
||||||
if (x && y)
|
if (x && y)
|
||||||
vertex = {
|
vertex = {
|
||||||
// A uniform both stages declare has to have one precision; the fragment's default is mediump.
|
// 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),
|
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);
|
const failed = firstProblem(graph, problems);
|
||||||
if (failed !== null || !color || !alpha || (corner && !vertex))
|
if (failed !== null || !color || !alpha || (corner && !vertex))
|
||||||
return { compiled: null, problems, hints, reason: failed ?? 'Cannot compile' };
|
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 pictureWalks = [walk, ...passes.walks];
|
||||||
const fragmentTime = graph.nodes.some((n) => n.type === SHADER_TIME && walk.reached(n));
|
const time = [cornerWalk, ...pictureWalks].some((w) => reaches(graph, w, SHADER_TIME));
|
||||||
const blurs = graph.nodes.some((n) => n.type === SHADER_BLUR && walk.reached(n));
|
|
||||||
// The fragment runs in the sprite's own draw (#77). `vColor` is the tint, premultiplied like the
|
// 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.
|
// 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
|
const fragment = fragmentOf(graph, { walk, lines, color, alpha, mode, uniforms, reads, tinted: true });
|
||||||
// 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');
|
|
||||||
// The edge only shows where a Sample reads somewhere other than its own pixel. A graph that never
|
// 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.
|
// 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
|
// 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.
|
// fragment and takes the plain sheet as well. A Blur reads its neighbours, so it always shifts.
|
||||||
const shifted =
|
const shifted = pictureWalks.some(
|
||||||
blurs ||
|
(w) =>
|
||||||
graph.nodes.some(
|
reaches(graph, w, SHADER_BLUR) ||
|
||||||
(n) =>
|
graph.nodes.some(
|
||||||
n.type === SHADER_SAMPLE &&
|
(n) =>
|
||||||
walk.reached(n) &&
|
n.type === SHADER_SAMPLE &&
|
||||||
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
|
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;
|
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 {
|
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;
|
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
|
* 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;
|
let count = 0;
|
||||||
const walk = graphEmitter<ShaderNodeDef, Expr>(graph, {
|
const walk = graphEmitter<ShaderNodeDef, Expr>(graph, {
|
||||||
isDef: isShaderNodeDef,
|
isDef: isShaderNodeDef,
|
||||||
@@ -224,6 +344,11 @@ function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[
|
|||||||
}
|
}
|
||||||
return outputs;
|
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
|
// 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.
|
// colour port, its alpha for a number.
|
||||||
unfed: (node, port, def) => {
|
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;
|
return port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR;
|
||||||
},
|
},
|
||||||
conform: (node: GraphNode, port, expr) => {
|
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') {
|
if (port.type === 'number' && expr.type === 'vec3') {
|
||||||
walk.problem(node, `"${port.key}" needs a number, not a color`);
|
walk.problem(node, `"${port.key}" needs a number, not a color`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+118
-26
@@ -12,7 +12,8 @@ import {
|
|||||||
SHADER_PROPERTY,
|
SHADER_PROPERTY,
|
||||||
} from '../properties/shaders';
|
} 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. */
|
/** A GLSL expression and what it evaluates to. */
|
||||||
export interface Expr {
|
export interface Expr {
|
||||||
@@ -51,13 +52,44 @@ export const SHADER_SAMPLE = 'ShaderSample';
|
|||||||
export const SHADER_TEXTURE_SIZE = 'ShaderTextureSize';
|
export const SHADER_TEXTURE_SIZE = 'ShaderTextureSize';
|
||||||
export const SHADER_TIME = 'ShaderTime';
|
export const SHADER_TIME = 'ShaderTime';
|
||||||
export const SHADER_BLUR = 'ShaderBlur';
|
export const SHADER_BLUR = 'ShaderBlur';
|
||||||
|
export const SHADER_PASS = 'ShaderPass';
|
||||||
|
|
||||||
/** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */
|
/** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */
|
||||||
export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' };
|
export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' };
|
||||||
export const TEXEL_ALPHA: Expr = { code: 'texel.a', type: 'float' };
|
export const TEXEL_ALPHA: Expr = { code: 'texel.a', type: 'float' };
|
||||||
/** This pixel's place on the picture, 0..1 – the vertex stage's `vTextureCoord`. */
|
/** 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_UV = 'vTextureCoord';
|
||||||
const OWN_V: Expr = { code: 'vTextureCoord.y', type: 'float' };
|
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';
|
export const TIME_UNIFORM = 'uTime';
|
||||||
/** The prelude's helper: a premultiplied sample to its straight colour. */
|
/** 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; }';
|
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 const TEXEL_AT = 'texelAt';
|
||||||
export function texelAtFn(edge: EdgeMode): string {
|
export function texelAtFn(edge: EdgeMode): string {
|
||||||
return edge === 'transparent'
|
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}(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}(vec2 uv) { return texture(uTexture, uv); }`;
|
: `vec4 ${TEXEL_AT}(sampler2D picture, vec2 uv) { return texture(picture, uv); }`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NUMBER_STEP = 0.05;
|
const NUMBER_STEP = 0.05;
|
||||||
@@ -206,24 +238,37 @@ export const ShaderTextureColor: ShaderNodeDef = {
|
|||||||
type: SHADER_TEXTURE_COLOR,
|
type: SHADER_TEXTURE_COLOR,
|
||||||
label: 'Texture color',
|
label: 'Texture color',
|
||||||
category: 'shader',
|
category: 'shader',
|
||||||
description: "The sprite's own pixel, before the shader: its texture tinted by its color.",
|
description:
|
||||||
inputs: [],
|
"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')],
|
outputs: [vec3Port('color')],
|
||||||
params: [],
|
params: [],
|
||||||
perPixel: true,
|
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 = {
|
export const ShaderTextureAlpha: ShaderNodeDef = {
|
||||||
type: SHADER_TEXTURE_ALPHA,
|
type: SHADER_TEXTURE_ALPHA,
|
||||||
label: 'Texture alpha',
|
label: 'Texture alpha',
|
||||||
category: 'shader',
|
category: 'shader',
|
||||||
description: "The sprite's own pixel's opacity: 0 transparent, 1 opaque.",
|
description: "The sprite's own pixel's opacity: 0 transparent, 1 opaque. Fed a picture, that picture's.",
|
||||||
inputs: [],
|
inputs: [pictureIn],
|
||||||
outputs: [{ key: 'value', label: 'value', type: 'number' }],
|
outputs: [{ key: 'value', label: 'value', type: 'number' }],
|
||||||
params: [],
|
params: [],
|
||||||
perPixel: true,
|
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. */
|
/** 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: () => ({}),
|
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
|
* 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.
|
* 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: [
|
inputs: [
|
||||||
{ key: 'u', label: 'u', type: 'number', inline: false },
|
{ key: 'u', label: 'u', type: 'number', inline: false },
|
||||||
{ key: 'v', label: 'v', type: 'number', inline: false },
|
{ key: 'v', label: 'v', type: 'number', inline: false },
|
||||||
|
pictureIn,
|
||||||
],
|
],
|
||||||
outputs: [vec3Port('color'), numberOut('alpha')],
|
outputs: [vec3Port('color'), numberOut('alpha')],
|
||||||
params: [],
|
params: [],
|
||||||
perPixel: true,
|
perPixel: true,
|
||||||
unfed: { u: OWN_U, v: OWN_V },
|
unfed: { u: OWN_U, v: OWN_V, picture: ORIGINAL },
|
||||||
emit: ({ u, v }) => {
|
emit: ({ u, v, picture }) => {
|
||||||
const sample = `${TEXEL_AT}(vec2(${u!.code}, ${v!.code})) * vColor`;
|
const sample = sampleOf(picture!, `vec2(${u!.code}, ${v!.code})`);
|
||||||
return {
|
return {
|
||||||
color: { code: `straight(${sample})`, type: 'vec3' },
|
color: { code: `straight(${sample})`, type: 'vec3' },
|
||||||
alpha: { code: `(${sample}).a`, type: 'float' },
|
alpha: { code: `(${sample}).a`, type: 'float' },
|
||||||
@@ -331,16 +418,16 @@ export const BLUR_FN = [
|
|||||||
' float s = max(r, 0.0001) * 0.5;',
|
' float s = max(r, 0.0001) * 0.5;',
|
||||||
' return gaussian > 0.5 ? w * exp(-d * d / (2.0 * s * s)) : w;',
|
' 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)});`,
|
` float r = clamp(radius, 0.0, ${floatLiteral(MAX_BLUR_RADIUS)});`,
|
||||||
' ivec2 reach = ivec2(axis) * int(ceil(r));',
|
' 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);',
|
' vec4 sum = vec4(0.0);',
|
||||||
' float total = 0.0;',
|
' float total = 0.0;',
|
||||||
' for (int j = -reach.y; j <= reach.y; j++) {',
|
' for (int j = -reach.y; j <= reach.y; j++) {',
|
||||||
' for (int i = -reach.x; i <= reach.x; i++) {',
|
' for (int i = -reach.x; i <= reach.x; i++) {',
|
||||||
' float w = blurWeight(i, r, gaussian) * blurWeight(j, r, gaussian);',
|
' 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;',
|
' total += w;',
|
||||||
' }',
|
' }',
|
||||||
' }',
|
' }',
|
||||||
@@ -356,7 +443,7 @@ export const ShaderBlur: ShaderNodeDef = {
|
|||||||
type: SHADER_BLUR,
|
type: SHADER_BLUR,
|
||||||
label: 'Blur',
|
label: 'Blur',
|
||||||
category: 'shader',
|
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: [
|
inputs: [
|
||||||
{
|
{
|
||||||
...floatIn('radius', 2),
|
...floatIn('radius', 2),
|
||||||
@@ -372,6 +459,7 @@ export const ShaderBlur: ShaderNodeDef = {
|
|||||||
},
|
},
|
||||||
{ key: 'u', label: 'u', type: 'number', inline: false },
|
{ key: 'u', label: 'u', type: 'number', inline: false },
|
||||||
{ key: 'v', label: 'v', type: 'number', inline: false },
|
{ key: 'v', label: 'v', type: 'number', inline: false },
|
||||||
|
pictureIn,
|
||||||
],
|
],
|
||||||
outputs: [vec3Port('color'), numberOut('alpha')],
|
outputs: [vec3Port('color'), numberOut('alpha')],
|
||||||
params: [
|
params: [
|
||||||
@@ -398,11 +486,12 @@ export const ShaderBlur: ShaderNodeDef = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
perPixel: true,
|
perPixel: true,
|
||||||
unfed: { u: OWN_U, v: OWN_V },
|
unfed: { u: OWN_U, v: OWN_V, picture: ORIGINAL },
|
||||||
prepare: ({ radius, u, v }, params) => {
|
prepare: ({ radius, u, v, picture }, params) => {
|
||||||
const axis = BLUR_AXES[textOf(params[BLUR_DIRECTION_PARAM] ?? '')] ?? BLUR_AXES.both!;
|
const axis = BLUR_AXES[textOf(params[BLUR_DIRECTION_PARAM] ?? '')] ?? BLUR_AXES.both!;
|
||||||
const gaussian = textOf(params[BLUR_KIND_PARAM] ?? '') === 'gaussian' ? '1.0' : '0.0';
|
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) => ({
|
emit: (_inputs, _params, prepared) => ({
|
||||||
color: { code: `straight(${prepared})`, type: 'vec3' },
|
color: { code: `straight(${prepared})`, type: 'vec3' },
|
||||||
@@ -414,14 +503,16 @@ export const ShaderTextureSize: ShaderNodeDef = {
|
|||||||
type: SHADER_TEXTURE_SIZE,
|
type: SHADER_TEXTURE_SIZE,
|
||||||
label: 'Texture size',
|
label: 'Texture size',
|
||||||
category: 'shader',
|
category: 'shader',
|
||||||
description: "The sprite's picture in pixels, for effects that need to know where its pixels are.",
|
description:
|
||||||
inputs: [],
|
"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')],
|
outputs: [numberOut('width'), numberOut('height')],
|
||||||
params: [],
|
params: [],
|
||||||
perPixel: true,
|
perPixel: true,
|
||||||
emit: () => ({
|
unfed: { picture: ORIGINAL },
|
||||||
width: { code: 'float(textureSize(uTexture, 0).x)', type: 'float' },
|
emit: ({ picture }) => ({
|
||||||
height: { code: 'float(textureSize(uTexture, 0).y)', type: 'float' },
|
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,
|
ShaderTextureSize,
|
||||||
ShaderTime,
|
ShaderTime,
|
||||||
ShaderOutput,
|
ShaderOutput,
|
||||||
|
ShaderPass,
|
||||||
ShaderVertexOutput,
|
ShaderVertexOutput,
|
||||||
ShaderProperty,
|
ShaderProperty,
|
||||||
ShaderColorProperty,
|
ShaderColorProperty,
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export function portColor(type: PortType): number {
|
|||||||
return c.portStruct;
|
return c.portStruct;
|
||||||
case 'vec3':
|
case 'vec3':
|
||||||
return c.portVec3;
|
return c.portVec3;
|
||||||
|
case 'picture':
|
||||||
|
return c.portPicture;
|
||||||
case 'any':
|
case 'any':
|
||||||
return c.portAny;
|
return c.portAny;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const Theme = {
|
|||||||
portList: 0xf2a75c,
|
portList: 0xf2a75c,
|
||||||
portStruct: 0xd4c25a,
|
portStruct: 0xd4c25a,
|
||||||
portVec3: 0xff9ad5,
|
portVec3: 0xff9ad5,
|
||||||
|
portPicture: 0x9b8cff,
|
||||||
portAny: 0xbdbdbd,
|
portAny: 0xbdbdbd,
|
||||||
/** Ring around the port a running edge drag would connect to. */
|
/** Ring around the port a running edge drag would connect to. */
|
||||||
portTarget: 0xffffff,
|
portTarget: 0xffffff,
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export interface FieldFrame {
|
|||||||
pixels: { w: number; h: number };
|
pixels: { w: number; h: number };
|
||||||
/** World units to pixels of the picture: the frame's top left corner is 0,0. */
|
/** World units to pixels of the picture: the frame's top left corner is 0,0. */
|
||||||
toPicture: Mat;
|
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. */
|
/** The world rectangle around the frame, turn included – what decides whether it is on screen. */
|
||||||
extent: Extent;
|
extent: Extent;
|
||||||
}
|
}
|
||||||
@@ -31,6 +36,7 @@ export function fieldFrame(m: Mat, w: number, h: number, pixelsPerUnit: number):
|
|||||||
return {
|
return {
|
||||||
pixels,
|
pixels,
|
||||||
toPicture: multiply(fit, invert(m)),
|
toPicture: multiply(fit, invert(m)),
|
||||||
|
unitsPerPixel: { x: w / pixels.w, y: h / pixels.h },
|
||||||
extent: extentOfPoints(boxCorners(m, w, h)),
|
extent: extentOfPoints(boxCorners(m, w, h)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// 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 size = Math.max(0.01, numberOf(props[PARTICLE_SIZE_KEY], 16));
|
||||||
const picture = texture ? { w: texture.width, h: texture.height } : { w: size, h: size };
|
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);
|
placeNode(mesh, m, size.w, size.h);
|
||||||
mesh.texture = picture;
|
mesh.texture = picture;
|
||||||
// Nothing to count pixels in: the frame is the edge, so neither Padding nor Vertex output moves it.
|
// 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;
|
this.fieldPicture = picture;
|
||||||
// Only now: the shader kept the old picture bound until `syncShader` handed it the new one.
|
// Only now: the shader kept the old picture bound until `syncShader` handed it the new one.
|
||||||
if (previous && previous !== picture) previous.destroy(true);
|
if (previous && previous !== picture) previous.destroy(true);
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ export function gridQuad(
|
|||||||
return { positions: corners, uvs: new Float32Array(corners), indices };
|
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 {
|
export function paddingKey(pad: QuadPadding | null): string {
|
||||||
return pad ? `${pad.u},${pad.v}` : '';
|
return pad ? `${pad.u},${pad.v}` : '';
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import {
|
||||||
|
Container,
|
||||||
|
GlProgram,
|
||||||
|
Mesh,
|
||||||
|
MeshGeometry,
|
||||||
|
RenderTexture,
|
||||||
|
Shader,
|
||||||
|
Texture,
|
||||||
|
type TextureSource,
|
||||||
|
UniformGroup,
|
||||||
|
} from 'pixi.js';
|
||||||
|
import type { CompiledShader } from '../core/shader/compile';
|
||||||
|
import { MAX_PASSES, PASS_PAD_UNIFORM, passSampler } from '../core/shader/nodes';
|
||||||
|
import { ui } from '../ui';
|
||||||
|
import { paddedQuad, paddingKey, passPixels, quadPadding } from './quadPadding';
|
||||||
|
import { ADDRESS_MODE } from './TextureLibrary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pass draws the frame of the wearer's picture into its own: the quad's positions are the
|
||||||
|
* picture's pixels (the mesh is scaled to them), its UVs the wearer's own, padding included.
|
||||||
|
*/
|
||||||
|
const PASS_VERTEX = `
|
||||||
|
in vec2 aPosition;
|
||||||
|
in vec2 aUV;
|
||||||
|
out vec2 vTextureCoord;
|
||||||
|
uniform mat3 uProjectionMatrix;
|
||||||
|
uniform mat3 uWorldTransformMatrix;
|
||||||
|
uniform mat3 uTransformMatrix;
|
||||||
|
void main()
|
||||||
|
{
|
||||||
|
mat3 m = uProjectionMatrix * uWorldTransformMatrix * uTransformMatrix;
|
||||||
|
gl_Position = vec4((m * vec3(aPosition, 1.0)).xy, 0.0, 1.0);
|
||||||
|
vTextureCoord = aUV;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const unitQuad = () => ({
|
||||||
|
positions: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
|
||||||
|
uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
|
||||||
|
indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** What `Mesh.shader` asks of a shader. */
|
||||||
|
class PassShader extends Shader {
|
||||||
|
texture: Texture = Texture.WHITE;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PassFrame = UniformGroup<{ [PASS_PAD_UNIFORM]: { value: Float32Array; type: 'vec2<f32>' } }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pictures a wearer's Pass nodes draw before its own draw (#301), one render texture each. They
|
||||||
|
* belong to one wearer – a particle source counts as one – and are drawn again only when what they
|
||||||
|
* show can have changed: the picture, its size and padding, or a uniform (the clock among them).
|
||||||
|
*/
|
||||||
|
export class PassPictures {
|
||||||
|
private readonly frame: PassFrame;
|
||||||
|
private readonly meshes: Mesh[] = [];
|
||||||
|
private readonly stage = new Container();
|
||||||
|
private targets: RenderTexture[] = [];
|
||||||
|
/** One quad all passes draw with: they share the frame. */
|
||||||
|
private geometry = new MeshGeometry(unitQuad());
|
||||||
|
private geometryKey = '';
|
||||||
|
/** What the pictures were last drawn from; empty forces a draw. */
|
||||||
|
private drawn = '';
|
||||||
|
|
||||||
|
/** `uniforms` is the wearer's group, empty when the graph has none (see `slots`). */
|
||||||
|
constructor(
|
||||||
|
private readonly compiled: CompiledShader,
|
||||||
|
private readonly uniforms: UniformGroup,
|
||||||
|
) {
|
||||||
|
this.frame = new UniformGroup({ [PASS_PAD_UNIFORM]: { value: new Float32Array(2), type: 'vec2<f32>' } });
|
||||||
|
compiled.passes.forEach((pass, index) => {
|
||||||
|
const shader = new PassShader({
|
||||||
|
glProgram: GlProgram.from({
|
||||||
|
vertex: PASS_VERTEX,
|
||||||
|
fragment: pass.fragment,
|
||||||
|
name: `shader-pass-${index}`,
|
||||||
|
}),
|
||||||
|
resources: {
|
||||||
|
...this.slots(),
|
||||||
|
uTexture: Texture.WHITE.source,
|
||||||
|
uSampler: Texture.WHITE.source.style,
|
||||||
|
shaderUniforms: uniforms,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const mesh = new Mesh({ geometry: this.geometry, texture: Texture.WHITE, shader });
|
||||||
|
this.meshes.push(mesh);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resources a shader reading these pictures declares up front; `bind` fills them. Always all
|
||||||
|
* of them, however many passes the graph has: Pixi builds the code that binds a program's
|
||||||
|
* resources once, from the first shader that draws with it, and finds them by position after
|
||||||
|
* that. A pass of one graph is often the very program of a pass in another.
|
||||||
|
*/
|
||||||
|
slots(): Record<string, TextureSource | PassFrame> {
|
||||||
|
const slots: Record<string, TextureSource | PassFrame> = { passFrame: this.frame };
|
||||||
|
for (let index = 0; index < MAX_PASSES; index++) slots[passSampler(index)] = Texture.WHITE.source;
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws every pass of `texture`. `picture` is what the Padding is counted in, as for the wearer's
|
||||||
|
* own quad; null draws no margin (a field's frame is its edge).
|
||||||
|
*/
|
||||||
|
draw(texture: Texture, picture: { w: number; h: number } | null): void {
|
||||||
|
const pad = picture ? quadPadding(this.compiled.padding, picture.w, picture.h) : null;
|
||||||
|
this.frame.uniforms[PASS_PAD_UNIFORM][0] = pad?.u ?? 0;
|
||||||
|
this.frame.uniforms[PASS_PAD_UNIFORM][1] = pad?.v ?? 0;
|
||||||
|
// Pixels of the picture as the GPU has it; a sprite without a picture counts in its own units.
|
||||||
|
const plain = texture === Texture.WHITE;
|
||||||
|
const base = plain
|
||||||
|
? { w: picture?.w ?? 1, h: picture?.h ?? 1 }
|
||||||
|
: { w: texture.source.pixelWidth, h: texture.source.pixelHeight };
|
||||||
|
const sizes = this.compiled.passes.map((pass) => passPixels(base.w, base.h, pad, pass.scale));
|
||||||
|
const scaleMode = texture.source.style.scaleMode;
|
||||||
|
|
||||||
|
// A render texture (a field's picture) changes under the same id every frame: never cached.
|
||||||
|
const key =
|
||||||
|
texture instanceof RenderTexture
|
||||||
|
? ''
|
||||||
|
: [texture.uid, scaleMode, paddingKey(pad), JSON.stringify(sizes), this.uniformKey()].join('|');
|
||||||
|
if (key && key === this.drawn) return;
|
||||||
|
this.drawn = key;
|
||||||
|
|
||||||
|
this.syncGeometry(pad);
|
||||||
|
const renderer = ui().app.renderer;
|
||||||
|
sizes.forEach((size, index) => {
|
||||||
|
const target = this.target(index, size, scaleMode);
|
||||||
|
const mesh = this.meshes[index]!;
|
||||||
|
const shader = mesh.shader as PassShader;
|
||||||
|
shader.texture = texture;
|
||||||
|
shader.resources.uTexture = texture.source;
|
||||||
|
shader.resources.uSampler = texture.source.style;
|
||||||
|
for (let earlier = 0; earlier < index; earlier++)
|
||||||
|
shader.resources[passSampler(earlier)] = this.targets[earlier]!.source;
|
||||||
|
mesh.texture = texture;
|
||||||
|
// The quad runs from -pad to 1 + pad; scaled and moved, that is 0..size of the target.
|
||||||
|
const sx = size.w / (1 + 2 * (pad?.u ?? 0));
|
||||||
|
const sy = size.h / (1 + 2 * (pad?.v ?? 0));
|
||||||
|
mesh.scale.set(sx, sy);
|
||||||
|
mesh.position.set((pad?.u ?? 0) * sx, (pad?.v ?? 0) * sy);
|
||||||
|
this.stage.addChild(mesh);
|
||||||
|
renderer.render({ container: this.stage, target, clear: true });
|
||||||
|
this.stage.removeChild(mesh);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands the pictures to a shader that read them, under the names the fragment declares. */
|
||||||
|
bind(resources: Record<string, unknown>): void {
|
||||||
|
this.targets.forEach((target, index) => {
|
||||||
|
resources[passSampler(index)] = target.source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
for (const mesh of this.meshes) {
|
||||||
|
mesh.shader?.destroy();
|
||||||
|
mesh.destroy();
|
||||||
|
}
|
||||||
|
for (const target of this.targets) target.destroy(true);
|
||||||
|
this.targets = [];
|
||||||
|
this.geometry.destroy();
|
||||||
|
this.stage.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private uniformKey(): string {
|
||||||
|
return Object.values(this.uniforms.uniforms as Record<string, number | Float32Array>)
|
||||||
|
.map((value) => (value instanceof Float32Array ? Array.from(value).join(',') : String(value)))
|
||||||
|
.join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncGeometry(pad: ReturnType<typeof quadPadding>): void {
|
||||||
|
const key = paddingKey(pad);
|
||||||
|
if (key === this.geometryKey) return;
|
||||||
|
const previous = this.geometry;
|
||||||
|
this.geometry = new MeshGeometry(pad ? paddedQuad(pad) : unitQuad());
|
||||||
|
this.geometryKey = key;
|
||||||
|
for (const mesh of this.meshes) mesh.geometry = this.geometry;
|
||||||
|
previous.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The index-th pass's picture at `size`. It is sampled like the wearer's picture (Nearest stays
|
||||||
|
* sharp) and goes on past its frame as the Output's Edge says.
|
||||||
|
*/
|
||||||
|
private target(index: number, size: { w: number; h: number }, scaleMode: string): RenderTexture {
|
||||||
|
let target = this.targets[index];
|
||||||
|
if (!target) {
|
||||||
|
target = RenderTexture.create({ width: size.w, height: size.h });
|
||||||
|
this.targets[index] = target;
|
||||||
|
} else if (target.width !== size.w || target.height !== size.h) {
|
||||||
|
// Resized, not replaced: the wearer's shader keeps it bound.
|
||||||
|
target.resize(size.w, size.h);
|
||||||
|
}
|
||||||
|
const style = target.source.style;
|
||||||
|
const addressMode = ADDRESS_MODE[this.compiled.passEdge];
|
||||||
|
if (style.scaleMode !== scaleMode || style.addressMode !== addressMode) {
|
||||||
|
style.scaleMode = scaleMode as typeof style.scaleMode;
|
||||||
|
style.addressMode = addressMode;
|
||||||
|
style.update();
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
-16
@@ -12,8 +12,9 @@ import {
|
|||||||
import { colorOf, type PropertyBag } from '../core/model/Properties';
|
import { colorOf, type PropertyBag } from '../core/model/Properties';
|
||||||
import { shaderOf } from '../core/properties/shaders';
|
import { shaderOf } from '../core/properties/shaders';
|
||||||
import type { CompiledShader, ShaderVertex } from '../core/shader/compile';
|
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 { 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. */
|
/** Where a node's shader comes from: the compiled form of the asset the sprite names, if it compiles. */
|
||||||
export interface ShaderSource {
|
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 samePasses(a: CompiledShader, b: CompiledShader): boolean {
|
||||||
function uniformGroupOf(compiled: CompiledShader): UniformGroup<Record<string, UniformValue>> | null {
|
return (
|
||||||
|
a.passEdge === b.passEdge &&
|
||||||
|
a.passes.length === b.passes.length &&
|
||||||
|
a.passes.every(
|
||||||
|
(pass, index) => pass.fragment === b.passes[index]!.fragment && pass.scale === b.passes[index]!.scale,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One uniform per graph parameter, the clock where a Time node reads one, and the pixel scale. A
|
||||||
|
* program that declares none of them leaves the group unread, but it is always there: with passes,
|
||||||
|
* two graphs share a program that only one of them feeds uniforms to (a Time only a pass reads), and
|
||||||
|
* Pixi finds a program's resources by position.
|
||||||
|
*/
|
||||||
|
function uniformGroupOf(compiled: CompiledShader): UniformGroup<Record<string, UniformValue>> {
|
||||||
const entries: [string, UniformValue][] = compiled.uniforms.map((u) => [
|
const entries: [string, UniformValue][] = compiled.uniforms.map((u) => [
|
||||||
uniformName(u.name),
|
uniformName(u.name),
|
||||||
u.kind === 'color'
|
u.kind === 'color'
|
||||||
@@ -128,10 +144,14 @@ function uniformGroupOf(compiled: CompiledShader): UniformGroup<Record<string, U
|
|||||||
: { value: u.default, type: 'f32' },
|
: { value: u.default, type: 'f32' },
|
||||||
]);
|
]);
|
||||||
if (compiled.time) entries.push([TIME_UNIFORM, { value: 0, type: 'f32' }]);
|
if (compiled.time) entries.push([TIME_UNIFORM, { value: 0, type: 'f32' }]);
|
||||||
return entries.length > 0 ? new UniformGroup(Object.fromEntries(entries)) : null;
|
entries.push([PIXEL_SCALE_UNIFORM, { value: new Float32Array([1, 1]), type: 'vec2<f32>' }]);
|
||||||
|
return new UniformGroup(Object.fromEntries(entries));
|
||||||
}
|
}
|
||||||
|
|
||||||
type UniformValue = { value: number; type: 'f32' } | { value: Float32Array; type: 'vec3<f32>' };
|
type UniformValue =
|
||||||
|
| { value: number; type: 'f32' }
|
||||||
|
| { value: Float32Array; type: 'vec3<f32>' }
|
||||||
|
| { value: Float32Array; type: 'vec2<f32>' };
|
||||||
|
|
||||||
function readUniforms(
|
function readUniforms(
|
||||||
into: Record<string, number | Float32Array>,
|
into: Record<string, number | Float32Array>,
|
||||||
@@ -162,11 +182,13 @@ function readUniforms(
|
|||||||
* constructor declared, so a subclass names them all up front.
|
* constructor declared, so a subclass names them all up front.
|
||||||
*/
|
*/
|
||||||
abstract class CompiledGraphShader extends Shader {
|
abstract class CompiledGraphShader extends Shader {
|
||||||
private readonly uniforms: Record<string, number | Float32Array> | null;
|
private readonly uniforms: Record<string, number | Float32Array>;
|
||||||
private readonly compiled: CompiledShader;
|
private readonly compiled: CompiledShader;
|
||||||
readonly fragment: string;
|
readonly fragment: string;
|
||||||
/** The vertex stage this was built with; a particle field's never comes from the graph. */
|
/** The vertex stage this was built with; a particle field's never comes from the graph. */
|
||||||
private readonly vertex: string;
|
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(
|
constructor(
|
||||||
readonly assetId: string,
|
readonly assetId: string,
|
||||||
@@ -175,30 +197,61 @@ abstract class CompiledGraphShader extends Shader {
|
|||||||
resources: Record<string, unknown>,
|
resources: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
const group = uniformGroupOf(compiled);
|
const group = uniformGroupOf(compiled);
|
||||||
|
const pictures = compiled.passes.length > 0 ? new PassPictures(compiled, group) : null;
|
||||||
super({
|
super({
|
||||||
glProgram: GlProgram.from({ vertex: program.vertex, fragment: compiled.fragment, name: program.name }),
|
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.fragment = compiled.fragment;
|
||||||
this.vertex = program.vertex;
|
this.vertex = program.vertex;
|
||||||
this.compiled = compiled;
|
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. */
|
/** True while this is still the shader `props` ask for, so it can be updated instead of rebuilt. */
|
||||||
wears(id: string, compiled: CompiledShader): boolean {
|
wears(id: string, compiled: CompiledShader): boolean {
|
||||||
return (
|
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. */
|
/** The vertex stage this kind of wearer draws `compiled` with. */
|
||||||
protected vertexFor(_compiled: CompiledShader): string {
|
protected vertexFor(_compiled: CompiledShader): string {
|
||||||
return this.vertex;
|
return this.vertex;
|
||||||
}
|
}
|
||||||
|
|
||||||
read(props: PropertyBag, now: number): void {
|
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);
|
return spriteVertexOf(compiled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The picture the Vertex output's pixels are counted in; null holds every corner in place. */
|
/** The picture the Vertex output's pixels are counted in; null or a fixed one holds every corner in place. */
|
||||||
count(picture: { w: number; h: number } | null): void {
|
count(picture: WornPicture | null): void {
|
||||||
if (!this.perPixel) return;
|
if (!this.perPixel) return;
|
||||||
this.perPixel[0] = picture && picture.w > 0 ? 1 / picture.w : 0;
|
const counted = picture && !picture.fixed ? picture : null;
|
||||||
this.perPixel[1] = picture && picture.h > 0 ? 1 / picture.h : 0;
|
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. */
|
/** 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 ?? '';
|
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,
|
* 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
|
* 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(
|
export function syncShader(
|
||||||
mesh: Mesh,
|
mesh: Mesh,
|
||||||
props: PropertyBag,
|
props: PropertyBag,
|
||||||
shaders: ShaderSource,
|
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). */
|
/** The shader asset to wear; a source's field names its own (#170). */
|
||||||
id = shaderOf(props),
|
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 {
|
): void {
|
||||||
const compiled = id ? shaders.compiled(id) : null;
|
const compiled = id ? shaders.compiled(id) : null;
|
||||||
const worn = wornBy(mesh);
|
const worn = wornBy(mesh);
|
||||||
if (worn && compiled && worn.wears(id, compiled)) {
|
if (worn && compiled && worn.wears(id, compiled)) {
|
||||||
worn.read(props, shaders.now);
|
worn.read(props, shaders.now);
|
||||||
|
worn.scalePixels(unitsPerPixel);
|
||||||
worn.wear(mesh.texture);
|
worn.wear(mesh.texture);
|
||||||
worn.count(picture);
|
worn.count(picture);
|
||||||
|
worn.drawPasses(mesh.texture, picture);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
disposeShader(mesh);
|
disposeShader(mesh);
|
||||||
if (compiled) {
|
if (compiled) {
|
||||||
const shader = new GraphShader(id, compiled);
|
const shader = new GraphShader(id, compiled);
|
||||||
shader.read(props, shaders.now);
|
shader.read(props, shaders.now);
|
||||||
|
shader.scalePixels(unitsPerPixel);
|
||||||
shader.wear(mesh.texture);
|
shader.wear(mesh.texture);
|
||||||
shader.count(picture);
|
shader.count(picture);
|
||||||
|
shader.drawPasses(mesh.texture, picture);
|
||||||
mesh.shader = shader;
|
mesh.shader = shader;
|
||||||
}
|
}
|
||||||
// Pixi caches whether a mesh batches (MeshPipe's meshData) and re-checks it only in
|
// Pixi caches whether a mesh batches (MeshPipe's meshData) and re-checks it only in
|
||||||
@@ -351,14 +423,18 @@ export function syncParticleShader(
|
|||||||
props: PropertyBag,
|
props: PropertyBag,
|
||||||
shaders: ShaderSource,
|
shaders: ShaderSource,
|
||||||
picture: { w: number; h: number },
|
picture: { w: number; h: number },
|
||||||
|
/** The particles' picture, which the passes draw from; null while there is none. */
|
||||||
|
texture: Texture | null = null,
|
||||||
): void {
|
): void {
|
||||||
const id = shaderOf(props);
|
const id = shaderOf(props);
|
||||||
const compiled = id ? shaders.compiled(id) : null;
|
const compiled = id ? shaders.compiled(id) : null;
|
||||||
const pad = compiled ? quadPadding(compiled.padding, picture.w, picture.h) : null;
|
const pad = compiled ? quadPadding(compiled.padding, picture.w, picture.h) : null;
|
||||||
const worn = wornByField(container);
|
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)) {
|
if (worn && compiled && worn.wears(id, compiled)) {
|
||||||
worn.read(props, shaders.now);
|
worn.read(props, shaders.now);
|
||||||
worn.grow(pad);
|
worn.grow(pad);
|
||||||
|
worn.drawPasses(texture ?? Texture.WHITE, picture);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
disposeParticleShader(container);
|
disposeParticleShader(container);
|
||||||
@@ -366,6 +442,7 @@ export function syncParticleShader(
|
|||||||
const shader = new ParticleGraphShader(id, compiled);
|
const shader = new ParticleGraphShader(id, compiled);
|
||||||
shader.read(props, shaders.now);
|
shader.read(props, shaders.now);
|
||||||
shader.grow(pad);
|
shader.grow(pad);
|
||||||
|
shader.drawPasses(texture ?? Texture.WHITE, picture);
|
||||||
container.shader = shader;
|
container.shader = shader;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function syncSpriteMesh(
|
|||||||
mesh.texture = texture ?? Texture.WHITE;
|
mesh.texture = texture ?? Texture.WHITE;
|
||||||
mesh.tint = Number(item.props.color ?? 0xffffff);
|
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).
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -144,4 +144,16 @@ describe('the frame', () => {
|
|||||||
const capped = fieldFrame(m, 256, 256, 40);
|
const capped = fieldFrame(m, 256, 256, 40);
|
||||||
expect(apply(capped.toPicture, { x: 128, y: 128 })).toEqual({ x: FIELD_MAX_PIXELS, y: FIELD_MAX_PIXELS });
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -10,6 +10,7 @@ import { SHADER_PROPERTY } from '../../src/core/properties/shaders';
|
|||||||
import { compileShader, perCornerProblem } from '../../src/core/shader/compile';
|
import { compileShader, perCornerProblem } from '../../src/core/shader/compile';
|
||||||
import {
|
import {
|
||||||
BLUR_FN,
|
BLUR_FN,
|
||||||
|
PIXEL_SCALE_UNIFORM,
|
||||||
SHADER_BLUR,
|
SHADER_BLUR,
|
||||||
SHADER_OUTPUT,
|
SHADER_OUTPUT,
|
||||||
SHADER_VERTEX_OUTPUT,
|
SHADER_VERTEX_OUTPUT,
|
||||||
@@ -50,7 +51,7 @@ describe('Blur (#300)', () => {
|
|||||||
const fragment = blurred().build().compiled!.fragment;
|
const fragment = blurred().build().compiled!.fragment;
|
||||||
expect(fragment).toContain(BLUR_FN);
|
expect(fragment).toContain(BLUR_FN);
|
||||||
expect(fragment).toContain(
|
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('vec3 n1_color = straight(n0_prepared);');
|
||||||
expect(fragment).toContain('float n2_alpha = n0_prepared.a;');
|
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', () => {
|
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);');
|
expect(BLUR_FN).toContain('float r = clamp(radius, 0.0, 8.0);');
|
||||||
const s = blurred({ radius: 3.5 });
|
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 });
|
const soft = s.add(SHADER_PROPERTY, { name: 'soft', default: 2, min: 0, max: 8 });
|
||||||
s.link(soft, 'value', s.blur, 'radius');
|
s.link(soft, 'value', s.blur, 'radius');
|
||||||
const compiled = s.build().compiled!;
|
const compiled = s.build().compiled!;
|
||||||
expect(compiled.uniforms).toEqual([{ name: 'soft', kind: 'number', default: 2 }]);
|
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', () => {
|
it('reads at the UV it is fed', () => {
|
||||||
@@ -85,7 +90,9 @@ describe('Blur (#300)', () => {
|
|||||||
const flip = s.add('ShaderSubtract', { a: 1 });
|
const flip = s.add('ShaderSubtract', { a: 1 });
|
||||||
s.link(uv, 'u', flip, 'b');
|
s.link(uv, 'u', flip, 'b');
|
||||||
s.link(flip, 'result', s.blur, 'u');
|
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', () => {
|
it('leaves the helper out of a shader that does not reach it', () => {
|
||||||
@@ -93,9 +100,15 @@ describe('Blur (#300)', () => {
|
|||||||
s.add(SHADER_BLUR);
|
s.add(SHADER_BLUR);
|
||||||
const fragment = s.build().compiled!.fragment;
|
const fragment = s.build().compiled!.fragment;
|
||||||
expect(fragment).not.toContain('blurWeight');
|
expect(fragment).not.toContain('blurWeight');
|
||||||
|
expect(fragment).not.toContain(PIXEL_SCALE_UNIFORM);
|
||||||
expect(calls(fragment)).toBe(0);
|
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', () => {
|
it('reads past the border, so the picture comes with the Output’s Edge', () => {
|
||||||
expect(blurred().build().compiled!.edge).toBe('clamp');
|
expect(blurred().build().compiled!.edge).toBe('clamp');
|
||||||
expect(blurred({}, { edge: 'repeat' }).build().compiled!.edge).toBe('repeat');
|
expect(blurred({}, { edge: 'repeat' }).build().compiled!.edge).toBe('repeat');
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -223,8 +223,8 @@ describe('compileShader', () => {
|
|||||||
for (const name of ['brightness', 'contrast', 'saturation'])
|
for (const name of ['brightness', 'contrast', 'saturation'])
|
||||||
expect(fragment).toContain(`uniform float u_${name};`);
|
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.
|
// 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 texelAt(sampler2D picture, vec2 uv) { return texture(picture, uv); }');
|
||||||
expect(fragment).toContain('vec4 texel = texelAt(vTextureCoord) * vColor;');
|
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 straight(vec4 c) { return c.a > 0.0 ? c.rgb / c.a : c.rgb; }');
|
||||||
expect(fragment).toContain('vec3 rgb = straight(texel);');
|
expect(fragment).toContain('vec3 rgb = straight(texel);');
|
||||||
expect(compiled).toMatchObject({ time: false, edge: 'mirror' });
|
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+_u = vTextureCoord\.x;/);
|
||||||
expect(fragment).toMatch(/float n\d+_result = \(1\.0 - n\d+_u\);/);
|
expect(fragment).toMatch(/float n\d+_result = \(1\.0 - n\d+_u\);/);
|
||||||
expect(fragment).toMatch(
|
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(
|
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+_value = uTime;/);
|
||||||
expect(fragment).toMatch(/float n\d+_result = sin\(n\d+_value\);/);
|
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.
|
// 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!.edge).toBe(SHEET_EDGE);
|
||||||
expect(compiled!.fragment).toContain(
|
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.
|
// 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'])
|
for (const edge of ['clamp', 'mirror', 'repeat'])
|
||||||
expect(plain({ edge, padding: 2 }).build().compiled!.fragment).toContain(
|
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();
|
const { compiled } = build();
|
||||||
expect(compiled!.edge).toBe(SHEET_EDGE);
|
expect(compiled!.edge).toBe(SHEET_EDGE);
|
||||||
expect(compiled!.fragment).toMatch(
|
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\(/);
|
expect(compiled!.fragment).not.toMatch(/straight\(texture\(/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1053,6 +1053,93 @@ export async function shader(t) {
|
|||||||
Boolean(dot) && lit === 9 && Math.abs(opacity - 1) < 0.1,
|
Boolean(dot) && lit === 9 && Math.abs(opacity - 1) < 0.1,
|
||||||
{ dot, lit, opacity: opacity.toFixed(3), spot: spot.map((a) => a.toFixed(2)).join(' ') },
|
{ 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 assetProps({ texture: picture?.id ?? '' });
|
||||||
await filterOf(picture?.id, 'linear');
|
await filterOf(picture?.id, 'linear');
|
||||||
|
|
||||||
|
|||||||
@@ -718,7 +718,65 @@ export async function particles(t) {
|
|||||||
JSON.stringify({ inFrame, pastFrame }),
|
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);
|
await setField(null);
|
||||||
const back = await fallen();
|
const back = await fallen();
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
* Usage: node tools/seed-e2e.mjs && node tools/perf-field-shaders.mjs
|
* 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_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_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.
|
* 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.
|
* 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 { readFileSync } from 'node:fs';
|
||||||
import { runChecks } from './lib/harness.mjs';
|
import { runChecks } from './lib/harness.mjs';
|
||||||
import { PROJECTS, cliArgs } from './lib/projects.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 { projectId, baseUrl } = cliArgs('perf-field-shaders.mjs', PROJECTS.fixture);
|
||||||
|
|
||||||
const COUNTS = (process.env.GM_PERF_COUNTS ?? '1,10,50').split(',').map(Number);
|
const COUNTS = (process.env.GM_PERF_COUNTS ?? '1,10,50').split(',').map(Number);
|
||||||
const FRAME = Number(process.env.GM_PERF_FRAME ?? 256);
|
const FRAME = Number(process.env.GM_PERF_FRAME ?? 256);
|
||||||
const BLUR = Number(process.env.GM_PERF_BLUR ?? 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 FIELD_GRAPH =
|
const FIELD_GRAPH =
|
||||||
BLUR > 0
|
BLUR > 0 && PASSES
|
||||||
? {
|
? twoPassBlurGraph(BLUR, PASS_RESOLUTION)
|
||||||
nodes: [
|
: BLUR > 0
|
||||||
{ id: 'pf-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: BLUR } },
|
? {
|
||||||
{ id: 'pf-out', type: 'ShaderOutput', x: 300, y: 0, params: {} },
|
nodes: [
|
||||||
],
|
{ id: 'pf-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: BLUR } },
|
||||||
edges: [
|
{ id: 'pf-out', type: 'ShaderOutput', x: 300, y: 0, params: {} },
|
||||||
{ 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' } },
|
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: [] };
|
],
|
||||||
|
}
|
||||||
|
: { nodes: [{ id: 'pf-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] };
|
||||||
const WARMUP_MS = 1500;
|
const WARMUP_MS = 1500;
|
||||||
const SAMPLE_MS = 4000;
|
const SAMPLE_MS = 4000;
|
||||||
|
|
||||||
@@ -147,7 +155,7 @@ await runChecks({ projectId, baseUrl, port: 9348, shotPrefix: 'gm-perf-field' },
|
|||||||
await stop();
|
await stop();
|
||||||
|
|
||||||
console.log(
|
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('| sources | no field shader | field shader | picture |');
|
||||||
console.log('| --- | --- | --- | --- |');
|
console.log('| --- | --- | --- | --- |');
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
* GM_PERF_SUBDIVISIONS=... gives the shader a Vertex output with that grid (#79); nothing feeds
|
* 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
|
* 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_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.
|
* 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 { readFileSync } from 'node:fs';
|
||||||
import { runChecks } from './lib/harness.mjs';
|
import { runChecks } from './lib/harness.mjs';
|
||||||
import { PROJECTS, cliArgs } from './lib/projects.mjs';
|
import { PROJECTS, cliArgs } from './lib/projects.mjs';
|
||||||
|
import { twoPassBlurGraph } from './lib/shaders.mjs';
|
||||||
|
|
||||||
const { projectId, baseUrl } = cliArgs('perf-shaders.mjs', PROJECTS.fixture);
|
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 SIZE = Number(process.env.GM_PERF_SIZE ?? 48);
|
||||||
const SUBDIVISIONS = Number(process.env.GM_PERF_SUBDIVISIONS ?? 0);
|
const SUBDIVISIONS = Number(process.env.GM_PERF_SUBDIVISIONS ?? 0);
|
||||||
const BLUR = Number(process.env.GM_PERF_BLUR ?? 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 SHADER = BLUR > 0 ? 'perf-blur' : 'adjust-colors';
|
||||||
const WARMUP_MS = 1500;
|
const WARMUP_MS = 1500;
|
||||||
const SAMPLE_MS = 4000;
|
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',
|
p.assets.push({ id: 'perf-sprite', type: 'sprite', name: 'Perf sprite',
|
||||||
props: { width: ${SIZE}, height: ${SIZE}, color: 0x4080ff, shader: '' } });
|
props: { width: ${SIZE}, height: ${SIZE}, color: 0x4080ff, shader: '' } });
|
||||||
p.assets.push({ id: 'perf-blur', type: 'shader', name: 'Perf blur', props: {},
|
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-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: ${BLUR} } },
|
||||||
{ id: 'pb-out', type: 'ShaderOutput', x: 300, y: 0, params: {} },
|
{ id: 'pb-out', type: 'ShaderOutput', x: 300, y: 0, params: {} },
|
||||||
], edges: [
|
], edges: [
|
||||||
@@ -168,7 +175,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
|
|||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`\nsprite ${SIZE}x${SIZE}, ${SUBDIVISIONS > 0 ? `grid of ${SUBDIVISIONS}` : 'plain quad'}, ` +
|
`\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)`,
|
`60 Hz cap (cannot be lifted in headless)`,
|
||||||
);
|
);
|
||||||
console.log('| sprites | no shader | with shader | per sprite | shaders | filters |');
|
console.log('| sprites | no shader | with shader | per sprite | shaders | filters |');
|
||||||
|
|||||||
Reference in new issue
Block a user