Let a shader move the corners of the picture it wears (#79)
A shader could only say what colour a pixel has. Where the picture is drawn was fixed to the four corners of its rectangle, so nothing could wave, bend or wobble. - The new node Vertex output takes x and y, in pixels of the picture, and moves every corner of the wearer's grid by them. The UVs stay with the corners, so the picture bends along. - Subdivisions on that node says how fine the grid is: cells per axis, 1 to 64, 8 by default. 1 is the plain quad. A shader without the node keeps the quad every sprite shares. - A graph holds one Vertex output at most. UV, Time, Property, Number and the math nodes may feed it, and one node may feed both outputs. - Texture color, Texture alpha, Sample texture, Texture size and Color cannot feed it. The node says "Not available per corner" and the shader does not compile. - Sprites, texts and the preview in the shader tab wear the grid. A nine-slice sprite and particles draw as if the node were not there. - Picking, the selection frame, the size and colliders stay with the rectangle. A wearer with a Vertex output is never culled, as with Padding, and with Padding the grid covers the room around the picture too. - tools/perf-shaders.mjs takes GM_PERF_SUBDIVISIONS to measure a grid against the plain quad. Measured on stein2 (Intel HD 630), frames per second, one run per row: at 800 sprites 58 with a shader on the quad, 46 with 8 cells, 41 with 32. Up to 400 sprites a grid hardly shows. Every sprite holds its own grid. Tests: - Gate on melektaus-atx: 220 files, 3416 tests, CHECK EXIT 0. - 15 new unit tests for the compiler and the grid. - Four new e2e checks in modules 28 and 30: a fixed offset moves the picture, 8 cells bend only the lower half where 1 cell shears it all, Texture alpha is refused at the node, a text moves. - e2e 28,30 on melektaus-atx: 110 checks, all pass. - The e2e total of 1699 is counted (1695 + 4), the full suite did not run. - No counter-probe, no screenshot. Not looked at: the preview, Time in the browser, flipped sprites, Padding together with the grid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
963f8a152a
commit
4716726b6b
14 files changed
+586
-75
No files matched your search
+105
-40
@@ -1,5 +1,5 @@
|
||||
import { graphEmitter } from '../graph/emitGraph';
|
||||
import { edgesInto, type Graph } from '../graph/Graph';
|
||||
import { edgesInto, type Graph, type GraphNode } from '../graph/Graph';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import { colorOf } from '../model/Properties';
|
||||
import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema';
|
||||
@@ -16,9 +16,11 @@ import {
|
||||
SHADER_OUTPUT,
|
||||
SHADER_SAMPLE,
|
||||
SHADER_TIME,
|
||||
SHADER_VERTEX_OUTPUT,
|
||||
type ShaderNodeDef,
|
||||
SHEET_EDGE,
|
||||
STRAIGHT_FN,
|
||||
subdivisionsOf,
|
||||
TEXEL_ALPHA,
|
||||
TEXEL_AT,
|
||||
TEXEL_COLOR,
|
||||
@@ -33,10 +35,25 @@ export interface ShaderUniform {
|
||||
default: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Vertex output node adds to the wearer's vertex stage (#79). It reads the corner's
|
||||
* `vTextureCoord` and the same uniforms the fragment does.
|
||||
*/
|
||||
export interface ShaderVertex {
|
||||
/** Declarations ahead of `main`: the uniforms, at the fragment's precision, and the helpers. */
|
||||
head: string;
|
||||
/** Statements inside `main`, ending in `vec2 offset`: the corner's move in pixels of the picture. */
|
||||
body: string;
|
||||
/** Cells per axis of the wearer's grid; 1 is the plain quad. */
|
||||
subdivisions: number;
|
||||
}
|
||||
|
||||
export interface CompiledShader {
|
||||
fragment: string;
|
||||
/** Null without a Vertex output node: the wearer then draws on the quad every sprite shares. */
|
||||
vertex: ShaderVertex | null;
|
||||
uniforms: ShaderUniform[];
|
||||
/** A Time node feeds the Output: the fragment takes `uTime`, and a sprite wearing it redraws every tick. */
|
||||
/** A Time node feeds an output: the program takes `uTime`, and a sprite wearing it redraws every tick. */
|
||||
time: boolean;
|
||||
/**
|
||||
* The texture variant the sprite's mesh draws with – the Output's Edge where the graph samples
|
||||
@@ -59,6 +76,7 @@ export interface ShaderBuild {
|
||||
}
|
||||
|
||||
export const PADDING_CLAMP_HINT = 'Padding with Clamp smears the border';
|
||||
export const perCornerProblem = (what: string) => `Not available per corner: ${what}`;
|
||||
|
||||
/**
|
||||
* A shader graph to the fragment of the sprite's own draw: every node reachable from the Output
|
||||
@@ -67,38 +85,9 @@ export const PADDING_CLAMP_HINT = 'Padding with Clamp smears the border';
|
||||
*/
|
||||
export function compileShader(graph: Graph): ShaderBuild {
|
||||
const lines: string[] = [];
|
||||
let count = 0;
|
||||
|
||||
const walk = graphEmitter<ShaderNodeDef, Expr>(graph, {
|
||||
isDef: isShaderNodeDef,
|
||||
noun: 'shader',
|
||||
emit: (def, inputs, node) => {
|
||||
const outputs: Record<string, Expr> = {};
|
||||
for (const [key, expr] of Object.entries(def.emit(inputs, node.params))) {
|
||||
const local = `n${count++}_${key}`;
|
||||
lines.push(`${expr.type} ${local} = ${expr.code};`);
|
||||
outputs[key] = { code: local, type: expr.type };
|
||||
}
|
||||
return outputs;
|
||||
},
|
||||
// With nothing to type and nothing the node says, an input is the texel: its colour for a
|
||||
// colour port, its alpha for a number.
|
||||
unfed: (node, port, def) => {
|
||||
const typed = node.params[port.key] ?? port.default;
|
||||
if (port.field?.kind === 'color')
|
||||
return { code: vec3Literal(colorOf(typed ?? 0xffffff, 0xffffff)), type: 'vec3' };
|
||||
if (port.field) return { code: floatLiteral(Number(typed ?? 0)), type: 'float' };
|
||||
return def?.unfed?.[port.key] ?? (port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR);
|
||||
},
|
||||
conform: (node, port, expr) => {
|
||||
if (port.type === 'number' && expr.type === 'vec3') {
|
||||
walk.problem(node, `"${port.key}" needs a number, not a color`);
|
||||
return null;
|
||||
}
|
||||
if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' };
|
||||
return expr;
|
||||
},
|
||||
});
|
||||
/** What the vertex walk met that only a pixel has; the Vertex output node reports the first. */
|
||||
const perPixel: string[] = [];
|
||||
const walk = walker(graph, lines, 'n', null);
|
||||
const { problems } = walk;
|
||||
|
||||
walk.checkNames(graph.nodes.filter(isPropertyNode), propertyName, (name) =>
|
||||
@@ -119,12 +108,40 @@ export function compileShader(graph: Graph): ShaderBuild {
|
||||
const color = walk.input(output, outputDef.inputs[0]!);
|
||||
const alpha = walk.input(output, outputDef.inputs[1]!);
|
||||
|
||||
const failed = walk.firstProblem();
|
||||
if (failed !== null || !color || !alpha)
|
||||
const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default }));
|
||||
|
||||
// The per-corner part is a walk of its own from the Vertex output: a node feeding both outputs is
|
||||
// emitted once for each stage.
|
||||
const corner = graph.nodes.find((n) => n.type === SHADER_VERTEX_OUTPUT);
|
||||
const cornerLines: string[] = [];
|
||||
const cornerWalk = walker(graph, cornerLines, 'c', perPixel);
|
||||
let vertex: ShaderVertex | null = null;
|
||||
if (corner) {
|
||||
const cornerDef = nodeDef(SHADER_VERTEX_OUTPUT)!;
|
||||
const x = cornerWalk.input(corner, cornerDef.inputs[0]!);
|
||||
const y = cornerWalk.input(corner, cornerDef.inputs[1]!);
|
||||
if (perPixel.length > 0) cornerWalk.problem(corner, perCornerProblem(perPixel[0]!));
|
||||
for (const [id, text] of Object.entries(cornerWalk.problems)) if (!(id in problems)) problems[id] = text;
|
||||
const reachesTime = graph.nodes.some((n) => n.type === SHADER_TIME && cornerWalk.reached(n));
|
||||
if (x && y)
|
||||
vertex = {
|
||||
// A uniform both stages declare has to have one precision; the fragment's default is mediump.
|
||||
head: [
|
||||
...(reachesTime ? [`uniform mediump float ${TIME_UNIFORM};`] : []),
|
||||
...uniforms.map((u) => `uniform mediump float ${uniformName(u.name)};`),
|
||||
DIVIDE_FN,
|
||||
].join('\n'),
|
||||
body: [...cornerLines, `vec2 offset = vec2(${x.code}, ${y.code});`].join('\n'),
|
||||
subdivisions: subdivisionsOf(corner.params),
|
||||
};
|
||||
}
|
||||
|
||||
const failed = firstProblem(graph, problems);
|
||||
if (failed !== null || !color || !alpha || (corner && !vertex))
|
||||
return { compiled: null, problems, hints, reason: failed ?? 'Cannot compile' };
|
||||
|
||||
const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default }));
|
||||
const time = graph.nodes.some((n) => n.type === SHADER_TIME && walk.reached(n));
|
||||
const time = graph.nodes.some((n) => n.type === SHADER_TIME && (walk.reached(n) || cornerWalk.reached(n)));
|
||||
const fragmentTime = graph.nodes.some((n) => n.type === SHADER_TIME && walk.reached(n));
|
||||
// The fragment runs in the sprite's own draw (#77). `vColor` is the tint, premultiplied like the
|
||||
// texel; it goes in before the graph, so `Texture color` is the sprite as it draws without a shader.
|
||||
// The version line is what makes Pixi compile the program as GLSL ES 3.00 (`GlProgram` looks for
|
||||
@@ -135,7 +152,7 @@ export function compileShader(graph: Graph): ShaderBuild {
|
||||
'in vec4 vColor;',
|
||||
'out vec4 finalColor;',
|
||||
'uniform sampler2D uTexture;',
|
||||
...(time ? [`uniform float ${TIME_UNIFORM};`] : []),
|
||||
...(fragmentTime ? [`uniform float ${TIME_UNIFORM};`] : []),
|
||||
...uniforms.map((u) => `uniform float ${uniformName(u.name)};`),
|
||||
STRAIGHT_FN,
|
||||
DIVIDE_FN,
|
||||
@@ -161,5 +178,53 @@ export function compileShader(graph: Graph): ShaderBuild {
|
||||
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
|
||||
);
|
||||
const edge = (shifted || padding > 0) && mode !== 'transparent' ? mode : SHEET_EDGE;
|
||||
return { compiled: { fragment, uniforms, time, edge, padding }, problems, hints, reason: '' };
|
||||
return { compiled: { fragment, vertex, uniforms, time, edge, padding }, problems, hints, reason: '' };
|
||||
}
|
||||
|
||||
function firstProblem(graph: Graph, problems: Record<string, string>): string | null {
|
||||
const first = graph.nodes.find((n) => n.id in problems);
|
||||
return first ? problems[first.id]! : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[] | null) {
|
||||
let count = 0;
|
||||
const walk = graphEmitter<ShaderNodeDef, Expr>(graph, {
|
||||
isDef: isShaderNodeDef,
|
||||
noun: 'shader',
|
||||
emit: (def, inputs, node) => {
|
||||
if (def.perPixel) perPixel?.push(def.label);
|
||||
const outputs: Record<string, Expr> = {};
|
||||
for (const [key, expr] of Object.entries(def.emit(inputs, node.params))) {
|
||||
const local = `${prefix}${count++}_${key}`;
|
||||
lines.push(`${expr.type} ${local} = ${expr.code};`);
|
||||
outputs[key] = { code: local, type: expr.type };
|
||||
}
|
||||
return outputs;
|
||||
},
|
||||
// With nothing to type and nothing the node says, an input is the texel: its colour for a
|
||||
// colour port, its alpha for a number.
|
||||
unfed: (node, port, def) => {
|
||||
const typed = node.params[port.key] ?? port.default;
|
||||
if (port.field?.kind === 'color')
|
||||
return { code: vec3Literal(colorOf(typed ?? 0xffffff, 0xffffff)), type: 'vec3' };
|
||||
if (port.field) return { code: floatLiteral(Number(typed ?? 0)), type: 'float' };
|
||||
const said = def?.unfed?.[port.key];
|
||||
if (said) return said;
|
||||
perPixel?.push('the picture');
|
||||
return port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR;
|
||||
},
|
||||
conform: (node: GraphNode, port, expr) => {
|
||||
if (port.type === 'number' && expr.type === 'vec3') {
|
||||
walk.problem(node, `"${port.key}" needs a number, not a color`);
|
||||
return null;
|
||||
}
|
||||
if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' };
|
||||
return expr;
|
||||
},
|
||||
});
|
||||
return walk;
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export interface ShaderNodeDef extends NodeDef {
|
||||
emit(inputs: Record<string, Expr>, params: PropertyBag): Record<string, Expr>;
|
||||
/** What an input with nothing to type on reads while unconnected; without it, the texel. */
|
||||
unfed?: Record<string, Expr>;
|
||||
/** Reads the picture or is a colour: there is none per corner, so it cannot feed the Vertex output (#79). */
|
||||
perPixel?: boolean;
|
||||
}
|
||||
|
||||
export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef {
|
||||
@@ -33,6 +35,7 @@ export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef {
|
||||
export const SHADER_TEXTURE_COLOR = 'ShaderTextureColor';
|
||||
export const SHADER_TEXTURE_ALPHA = 'ShaderTextureAlpha';
|
||||
export const SHADER_OUTPUT = 'ShaderOutput';
|
||||
export const SHADER_VERTEX_OUTPUT = 'ShaderVertexOutput';
|
||||
export const SHADER_COLOR = 'ShaderColor';
|
||||
export const SHADER_NUMBER = 'ShaderNumber';
|
||||
export const SHADER_UV = 'ShaderUV';
|
||||
@@ -87,6 +90,19 @@ export function paddingOf(params: PropertyBag): number {
|
||||
return Number.isFinite(value) ? Math.min(MAX_PADDING, Math.max(0, value)) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many cells per axis the wearer's quad is cut into (#79) – a setting of the Vertex output node,
|
||||
* because how fine the grid has to be is the effect's to say. 1 is the plain quad.
|
||||
*/
|
||||
export const SUBDIVISIONS_PARAM = 'subdivisions';
|
||||
export const DEFAULT_SUBDIVISIONS = 8;
|
||||
export const MAX_SUBDIVISIONS = 64;
|
||||
|
||||
export function subdivisionsOf(params: PropertyBag): number {
|
||||
const value = Math.round(Number(params[SUBDIVISIONS_PARAM] ?? DEFAULT_SUBDIVISIONS));
|
||||
return Number.isFinite(value) ? Math.min(MAX_SUBDIVISIONS, Math.max(1, value)) : DEFAULT_SUBDIVISIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the picture at a UV. No sampler state makes the outside empty, so *Transparent* is cut
|
||||
* here, and with it needs no copy of the picture with an address mode of its own.
|
||||
@@ -185,6 +201,7 @@ export const ShaderTextureColor: ShaderNodeDef = {
|
||||
inputs: [],
|
||||
outputs: [vec3Port('color')],
|
||||
params: [],
|
||||
perPixel: true,
|
||||
emit: () => ({ color: TEXEL_COLOR }),
|
||||
};
|
||||
|
||||
@@ -196,6 +213,7 @@ export const ShaderTextureAlpha: ShaderNodeDef = {
|
||||
inputs: [],
|
||||
outputs: [{ key: 'value', label: 'value', type: 'number' }],
|
||||
params: [],
|
||||
perPixel: true,
|
||||
emit: () => ({ value: TEXEL_ALPHA }),
|
||||
};
|
||||
|
||||
@@ -216,6 +234,36 @@ export const ShaderOutput: ShaderNodeDef = {
|
||||
emit: () => ({}),
|
||||
};
|
||||
|
||||
/**
|
||||
* The second node the compiler starts from (#79): what feeds it runs once per corner of the
|
||||
* wearer's grid and moves that corner. Without it the wearer keeps the quad every sprite shares.
|
||||
*/
|
||||
export const ShaderVertexOutput: ShaderNodeDef = {
|
||||
type: SHADER_VERTEX_OUTPUT,
|
||||
label: 'Vertex output',
|
||||
category: 'shader',
|
||||
description:
|
||||
'Moves the corners of the grid the picture is drawn on: x to the right and y down, in pixels of the picture. The picture bends with them. Subdivisions is how many cells the grid has each way; 1 is the plain rectangle.',
|
||||
inputs: [
|
||||
{ key: 'x', label: 'x', type: 'number', inline: false },
|
||||
{ key: 'y', label: 'y', type: 'number', inline: false },
|
||||
],
|
||||
outputs: [],
|
||||
params: [
|
||||
{
|
||||
key: SUBDIVISIONS_PARAM,
|
||||
label: 'Subdivisions',
|
||||
kind: 'number',
|
||||
default: DEFAULT_SUBDIVISIONS,
|
||||
min: 1,
|
||||
max: MAX_SUBDIVISIONS,
|
||||
step: 1,
|
||||
},
|
||||
],
|
||||
unfed: { x: { code: '0.0', type: 'float' }, y: { code: '0.0', type: 'float' } },
|
||||
emit: () => ({}),
|
||||
};
|
||||
|
||||
export const ShaderUV: ShaderNodeDef = {
|
||||
type: SHADER_UV,
|
||||
label: 'UV',
|
||||
@@ -240,6 +288,7 @@ export const ShaderSample: ShaderNodeDef = {
|
||||
],
|
||||
outputs: [vec3Port('color'), numberOut('alpha')],
|
||||
params: [],
|
||||
perPixel: true,
|
||||
unfed: { u: OWN_U, v: OWN_V },
|
||||
emit: ({ u, v }) => {
|
||||
const sample = `${TEXEL_AT}(vec2(${u!.code}, ${v!.code})) * vColor`;
|
||||
@@ -258,6 +307,7 @@ export const ShaderTextureSize: ShaderNodeDef = {
|
||||
inputs: [],
|
||||
outputs: [numberOut('width'), numberOut('height')],
|
||||
params: [],
|
||||
perPixel: true,
|
||||
emit: () => ({
|
||||
width: { code: 'float(textureSize(uTexture, 0).x)', type: 'float' },
|
||||
height: { code: 'float(textureSize(uTexture, 0).y)', type: 'float' },
|
||||
@@ -303,6 +353,7 @@ export const ShaderColor: ShaderNodeDef = {
|
||||
inputs: [],
|
||||
outputs: [vec3Port('color')],
|
||||
params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })],
|
||||
perPixel: true,
|
||||
emit: (_inputs, params) => ({
|
||||
color: { code: vec3Literal(Number(params.value ?? 0xffffff)), type: 'vec3' },
|
||||
}),
|
||||
@@ -497,6 +548,7 @@ export const SHADER_NODES: readonly ShaderNodeDef[] = [
|
||||
ShaderTextureSize,
|
||||
ShaderTime,
|
||||
ShaderOutput,
|
||||
ShaderVertexOutput,
|
||||
ShaderProperty,
|
||||
ShaderColor,
|
||||
ShaderNumber,
|
||||
|
||||
Reference in new issue
Block a user