Files
feuerwerk/src/core/shader/compile.ts
T
admin c990dbcb26
game-maker / suite (push) Failing after 11m29s
A name that a sprite already owns cannot name a property, and a division by zero cannot answer (#63, #64)
#63: a shader Property node named "width" quietly took over the sprite
row of the same name. isSpriteProp lives in PropertySchema now and is
derived from FULL_SCHEMAS.sprite instead of being a second list that
drifts. isValidPropertyName rejects those ten names, and the compiler
reports it at the node as its own problem: "width" is a sprite
property, told apart from "not a word" and from a duplicate.

#64: Divide emitted (a / b) and handed NaN to the driver. It emits
divide(a, b) now, with the guard in the prologue next to straight(),
scalar and per channel: zero divided by zero is zero. That was already
the house rule in the behavior and particle graphs - only the shader
had never been told. Clamp comes along as a shader node for the same
reason: the behavior catalog has had it all along.
2026-09-15 18:43:40 +02:00

200 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph';
import { nodeDef, type PortDef } from '../graph/NodeRegistry';
import { colorOf } from '../model/Properties';
import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema';
import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders';
import {
asVec3,
DIVIDE_FN,
type EdgeMode,
edgeOf,
type Expr,
floatLiteral,
isShaderNodeDef,
SHADER_OUTPUT,
SHADER_SAMPLE,
SHADER_TIME,
SHEET_EDGE,
STRAIGHT_FN,
TEXEL_ALPHA,
TEXEL_COLOR,
TIME_UNIFORM,
uniformName,
vec3Literal,
} from './nodes';
export interface ShaderUniform {
name: string;
default: number;
}
export interface CompiledShader {
fragment: string;
uniforms: ShaderUniform[];
/** A Time node feeds the Output: the fragment 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
* past the border, and the plain sheet where it cannot.
*/
edge: EdgeMode;
}
export interface ShaderBuild {
/** Null while the graph has a problem – the sprite then draws without a filter. */
compiled: CompiledShader | null;
/** Per node id, what keeps the graph from compiling. */
problems: Record<string, string>;
/** One line for the tab: empty once compiled. */
reason: string;
}
/**
* 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 float uniform. The texel
* arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back.
*/
/**
* What the fragment depends on and nothing else: node ids, types, params and declared ports, and
* which port feeds which. Positions, collapsing and folding are the editor's; they stay out so a
* moved node does not compile again (#65).
*/
export function shaderFingerprint(graph: Graph): string {
return JSON.stringify({
nodes: graph.nodes.map((n) => [n.id, n.type, n.params, n.ports ?? null]),
edges: graph.edges.map((e) => [e.from.node, e.from.port, e.to.node, e.to.port]),
});
}
export function compileShader(graph: Graph): ShaderBuild {
const problems: Record<string, string> = {};
const problem = (node: GraphNode, text: string) => {
if (!(node.id in problems)) problems[node.id] = text;
};
const names = new Set<string>();
for (const node of graph.nodes) {
if (!isPropertyNode(node)) continue;
const name = propertyName(node);
if (!isValidFieldName(name)) problem(node, 'Name must be one word: letters, digits, _');
else if (isSpriteProp(name)) problem(node, `"${name}" is a sprite property`);
else if (names.has(name)) problem(node, `Another property is named "${name}"`);
names.add(name);
}
const lines: string[] = [];
const emitted = new Map<string, Record<string, Expr>>();
const visiting = new Set<string>();
let count = 0;
const emitNode = (node: GraphNode): Record<string, Expr> | null => {
const done = emitted.get(node.id);
if (done) return done;
if (visiting.has(node.id)) {
problem(node, 'Feeds itself');
return null;
}
const def = nodeDef(node.type);
if (!def || !isShaderNodeDef(def)) {
problem(node, 'Not a shader node');
return null;
}
visiting.add(node.id);
const inputs: Record<string, Expr> = {};
let complete = true;
for (const port of def.inputs) {
const expr = input(node, port);
if (expr) inputs[port.key] = expr;
else complete = false;
}
visiting.delete(node.id);
if (!complete) return null;
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 };
}
emitted.set(node.id, outputs);
return outputs;
};
/**
* What feeds an input: the edge's source, the typed-in value, or – with nothing to type – what
* the node says (`unfed`), else the texel: its colour for a colour port, its alpha for a number.
*/
const input = (node: GraphNode, port: PortDef): Expr | null => {
const edge = edgesInto(graph, node.id, port.key)[0];
if (edge) {
const source = findNode(graph, edge.from.node);
const outputs = source && emitNode(source);
const expr = outputs?.[edge.from.port];
if (!expr) return null;
return conform(node, port, expr);
}
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 def = nodeDef(node.type);
const unfed = def && isShaderNodeDef(def) ? def.unfed?.[port.key] : undefined;
return unfed ?? (port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR);
};
const conform = (node: GraphNode, port: PortDef, expr: Expr): Expr | null => {
if (port.type === 'number' && expr.type === 'vec3') {
problem(node, `"${port.key}" needs a number, not a colour`);
return null;
}
if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' };
return expr;
};
const output = graph.nodes.find((n) => n.type === SHADER_OUTPUT);
if (!output) return { compiled: null, problems, reason: 'No Output node' };
const outputDef = nodeDef(SHADER_OUTPUT)!;
const color = input(output, outputDef.inputs[0]!);
const alpha = input(output, outputDef.inputs[1]!);
const first = graph.nodes.find((n) => n.id in problems);
if (first || !color || !alpha)
return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' };
const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default }));
const time = graph.nodes.some((n) => n.type === SHADER_TIME && emitted.has(n.id));
// The fragment runs in the sprite's own draw (#77). `vColor` is the tint, premultiplied like the
// texel; it goes in before the graph, so `Texture color` is the sprite as it draws without a shader.
// The version line is what makes Pixi compile the program as GLSL ES 3.00 (`GlProgram` looks for
// it in the fragment); without it `texture()` is a `#define` and `textureSize` does not exist.
const fragment = [
'#version 300 es',
'in vec2 vTextureCoord;',
'in vec4 vColor;',
'out vec4 finalColor;',
'uniform sampler2D uTexture;',
...(time ? [`uniform float ${TIME_UNIFORM};`] : []),
...uniforms.map((u) => `uniform float ${uniformName(u.name)};`),
STRAIGHT_FN,
DIVIDE_FN,
'void main()',
'{',
' vec4 texel = texture(uTexture, 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
// shifts a UV stays on the plain sheet, so the common shader costs no second copy of the picture.
const shifted = graph.nodes.some(
(n) =>
n.type === SHADER_SAMPLE &&
emitted.has(n.id) &&
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
);
const edge = shifted ? edgeOf(output.params) : SHEET_EDGE;
return { compiled: { fragment, uniforms, time, edge }, problems, reason: '' };
}