Two compilers that walk a graph the same way walk it through one emitter (#85)
game-maker / suite (push) Failing after 2m22s
game-maker / gate (push) Successful in 2m53s

Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection,
duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on
FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own
contract. The three Create*Command share ImportFileCommand, the three renames share
RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in
portSection.ts replace in-file clones, and three comments that told history now state the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-16 19:28:28 +02:00
1 parent bf0be66c9d
commit d3563897d8
16 files changed
+376 -379

No files matched your search

+49 -87
View File
@@ -1,5 +1,6 @@
import { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph';
import { nodeDef, type PortDef } from '../graph/NodeRegistry';
import { graphEmitter } from '../graph/emitGraph';
import { edgesInto, type Graph } from '../graph/Graph';
import { nodeDef } from '../graph/NodeRegistry';
import { colorOf } from '../model/Properties';
import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema';
import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders';
@@ -14,6 +15,7 @@ import {
SHADER_OUTPUT,
SHADER_SAMPLE,
SHADER_TIME,
type ShaderNodeDef,
SHEET_EDGE,
STRAIGHT_FN,
TEXEL_ALPHA,
@@ -55,101 +57,61 @@ export interface ShaderBuild {
* arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back.
*/
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;
};
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 colour`);
return null;
}
if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' };
return expr;
},
});
const { problems } = walk;
/**
* 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;
};
walk.checkNames(graph.nodes.filter(isPropertyNode), propertyName, (name) =>
!isValidFieldName(name)
? 'Name must be one word: letters, digits, _'
: isSpriteProp(name)
? `"${name}" is a sprite property`
: '',
);
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 color = walk.input(output, outputDef.inputs[0]!);
const alpha = walk.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 failed = walk.firstProblem();
if (failed !== null || !color || !alpha)
return { compiled: null, problems, 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 && emitted.has(n.id));
const time = 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
@@ -179,7 +141,7 @@ export function compileShader(graph: Graph): ShaderBuild {
const shifted = graph.nodes.some(
(n) =>
n.type === SHADER_SAMPLE &&
emitted.has(n.id) &&
walk.reached(n) &&
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
);
const edge = shifted ? edgeOf(output.params) : SHEET_EDGE;