Two compilers that walk a graph the same way walk it through one emitter (#85)
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:
1 parent
bf0be66c9d
commit
d3563897d8
16 files changed
+376
-379
No files matched your search
@@ -11,6 +11,25 @@ export interface Command {
|
||||
undo(project: Project): void;
|
||||
}
|
||||
|
||||
/** A rename that is undone by renaming back. */
|
||||
export abstract class RenameCommand implements Command {
|
||||
constructor(
|
||||
readonly label: string,
|
||||
private readonly from: string,
|
||||
private readonly to: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.rename(project, this.from, this.to);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.rename(project, this.to, this.from);
|
||||
}
|
||||
|
||||
protected abstract rename(project: Project, from: string, to: string): void;
|
||||
}
|
||||
|
||||
/** A rejected edit with a message fit for the user; the editor shows it as a notice. */
|
||||
export class CommandError extends Error {
|
||||
constructor(message: string) {
|
||||
|
||||
@@ -110,22 +110,24 @@ export function buildTextureAsset(name: string, image: TextureImage, id = newId(
|
||||
}
|
||||
|
||||
/**
|
||||
* The image is uploaded before the command runs; this only records the result, so undo and redo
|
||||
* The file is uploaded before the command runs; this only records the result, so undo and redo
|
||||
* never touch the network.
|
||||
*/
|
||||
export class CreateTextureCommand implements Command {
|
||||
abstract class ImportFileCommand<A extends Asset & { file: string }> implements Command {
|
||||
readonly label: string;
|
||||
readonly asset: TextureAsset;
|
||||
|
||||
constructor(name: string, image: TextureImage) {
|
||||
this.asset = buildTextureAsset(name, image);
|
||||
this.label = `Import texture "${name}"`;
|
||||
constructor(
|
||||
readonly asset: A,
|
||||
private readonly fileNoun: string,
|
||||
private readonly isStored: (file: string) => boolean,
|
||||
) {
|
||||
this.label = `Import ${asset.type} "${asset.name}"`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
// The name is the hash the server derived from the bytes; anything else was never uploaded.
|
||||
if (!isTextureFile(this.asset.file))
|
||||
throw new CommandError(`"${this.asset.file}" is not a stored image file`);
|
||||
if (!this.isStored(this.asset.file))
|
||||
throw new CommandError(`"${this.asset.file}" is not a stored ${this.fileNoun} file`);
|
||||
project.assets.push(this.asset);
|
||||
}
|
||||
|
||||
@@ -134,28 +136,19 @@ export class CreateTextureCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
export class CreateTextureCommand extends ImportFileCommand<TextureAsset> {
|
||||
constructor(name: string, image: TextureImage) {
|
||||
super(buildTextureAsset(name, image), 'image', isTextureFile);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFontAsset(name: string, file: string, id = newId()): FontAsset {
|
||||
return { id, name, props: defaultsFor('font'), type: 'font', file };
|
||||
}
|
||||
|
||||
/** The typeface is uploaded before the command runs, as an image is for `CreateTextureCommand`. */
|
||||
export class CreateFontCommand implements Command {
|
||||
readonly label: string;
|
||||
readonly asset: FontAsset;
|
||||
|
||||
export class CreateFontCommand extends ImportFileCommand<FontAsset> {
|
||||
constructor(name: string, file: string) {
|
||||
this.asset = buildFontAsset(name, file);
|
||||
this.label = `Import font "${name}"`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
if (!isFontFile(this.asset.file))
|
||||
throw new CommandError(`"${this.asset.file}" is not a stored font file`);
|
||||
project.assets.push(this.asset);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
project.assets = removeById(project.assets, this.asset.id);
|
||||
super(buildFontAsset(name, file), 'font', isFontFile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,24 +156,9 @@ export function buildSoundAsset(name: string, file: string, id = newId()): Sound
|
||||
return { id, name, props: defaultsFor('sound'), type: 'sound', file };
|
||||
}
|
||||
|
||||
/** The sound is uploaded before the command runs, as a typeface is for `CreateFontCommand`. */
|
||||
export class CreateSoundCommand implements Command {
|
||||
readonly label: string;
|
||||
readonly asset: SoundAsset;
|
||||
|
||||
export class CreateSoundCommand extends ImportFileCommand<SoundAsset> {
|
||||
constructor(name: string, file: string) {
|
||||
this.asset = buildSoundAsset(name, file);
|
||||
this.label = `Import sound "${name}"`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
if (!isSoundFile(this.asset.file))
|
||||
throw new CommandError(`"${this.asset.file}" is not a stored sound file`);
|
||||
project.assets.push(this.asset);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
project.assets = removeById(project.assets, this.asset.id);
|
||||
super(buildSoundAsset(name, file), 'sound', isSoundFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { type Command, CommandError, RenameCommand } from './Command';
|
||||
import { nextFieldName } from './naming';
|
||||
import { requireBehavior, requireNode, requireNodeDef } from './require';
|
||||
|
||||
@@ -260,27 +260,17 @@ export class SetPortWriteCommand implements Command {
|
||||
}
|
||||
|
||||
/** Rewrites the edges and inline values pointing at the port too, in every graph that has any. */
|
||||
export class RenamePortCommand implements Command {
|
||||
readonly label: string;
|
||||
|
||||
export class RenamePortCommand extends RenameCommand {
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly nodeId: string,
|
||||
private readonly from: string,
|
||||
private readonly to: string,
|
||||
from: string,
|
||||
to: string,
|
||||
) {
|
||||
this.label = `Rename ${from}`;
|
||||
super(`Rename ${from}`, from, to);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.rename(project, this.from, this.to);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.rename(project, this.to, this.from);
|
||||
}
|
||||
|
||||
private rename(project: Project, from: string, to: string): void {
|
||||
protected rename(project: Project, from: string, to: string): void {
|
||||
if (from === to) return;
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const sites = portSites(project, this.assetId, node);
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
variableDef,
|
||||
} from '../properties/PropertySchema';
|
||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { type Command, CommandError, RenameCommand } from './Command';
|
||||
import { renameVariablePorts } from './portCommands';
|
||||
import { requireVariableOwner } from './require';
|
||||
|
||||
@@ -117,26 +117,16 @@ export class RemoveVariableCommand implements Command {
|
||||
}
|
||||
|
||||
/** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */
|
||||
export class RenameVariableCommand implements Command {
|
||||
readonly label: string;
|
||||
|
||||
export class RenameVariableCommand extends RenameCommand {
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly from: string,
|
||||
private readonly to: string,
|
||||
from: string,
|
||||
to: string,
|
||||
) {
|
||||
this.label = `Rename variable ${from}`;
|
||||
super(`Rename variable ${from}`, from, to);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.rename(project, this.from, this.to);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.rename(project, this.to, this.from);
|
||||
}
|
||||
|
||||
private rename(project: Project, from: string, to: string): void {
|
||||
protected rename(project: Project, from: string, to: string): void {
|
||||
const asset = requireVariableOwner(project, this.assetId);
|
||||
if (from === to) return;
|
||||
if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME);
|
||||
@@ -316,27 +306,17 @@ export class RemoveStructFieldCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
export class RenameStructFieldCommand implements Command {
|
||||
readonly label: string;
|
||||
|
||||
export class RenameStructFieldCommand extends RenameCommand {
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly from: string,
|
||||
private readonly to: string,
|
||||
from: string,
|
||||
to: string,
|
||||
) {
|
||||
this.label = `Rename field ${from}`;
|
||||
super(`Rename field ${from}`, from, to);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.rename(project, this.from, this.to);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.rename(project, this.to, this.from);
|
||||
}
|
||||
|
||||
private rename(project: Project, from: string, to: string): void {
|
||||
protected rename(project: Project, from: string, to: string): void {
|
||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
||||
if (from === to) return;
|
||||
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
|
||||
|
||||
@@ -19,8 +19,8 @@ const MAX_STEPS_PER_FIRE = 100_000;
|
||||
|
||||
/**
|
||||
* How far exec edges may nest. The interpreter recurses along them, so this is what keeps a flow
|
||||
* feeding back into itself off the call stack – a job the step budget can no longer do now that a
|
||||
* legitimate pulse may run tens of thousands of steps.
|
||||
* feeding back into itself off the call stack – the step budget cannot, since a legitimate pulse
|
||||
* may run tens of thousands of steps.
|
||||
*/
|
||||
const MAX_DEPTH = 1000;
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { edgesInto, findNode, type Graph, type GraphNode } from './Graph';
|
||||
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
||||
|
||||
export interface EmitRules<D extends NodeDef, T> {
|
||||
isDef: (def: NodeDef) => def is D;
|
||||
/** Names the node kind in "Not a … node". */
|
||||
noun: string;
|
||||
/** A node's outputs from its inputs; called once per node, in dependency order. */
|
||||
emit: (def: D, inputs: Record<string, T>, node: GraphNode) => Record<string, T>;
|
||||
/** An input without an edge: the typed-in value or what the node says it means unfed. */
|
||||
unfed: (node: GraphNode, port: PortDef, def: D | undefined) => T | null;
|
||||
/** An edge's value on its way into `port`; null (after reporting a problem) when it cannot go in. */
|
||||
conform?: (node: GraphNode, port: PortDef, value: T) => T | null;
|
||||
}
|
||||
|
||||
export interface GraphEmitter<T> {
|
||||
/** Per node id, the first thing that keeps the graph from compiling. */
|
||||
readonly problems: Record<string, string>;
|
||||
problem(node: GraphNode, text: string): void;
|
||||
/** What feeds an input, emitting whatever it depends on first. */
|
||||
input(node: GraphNode, port: PortDef): T | null;
|
||||
/** Whether the walk reached the node. */
|
||||
reached(node: GraphNode): boolean;
|
||||
/** Reports property names that are invalid (`invalid` returns the text) or taken twice. */
|
||||
checkNames(
|
||||
nodes: GraphNode[],
|
||||
nameOf: (node: GraphNode) => string,
|
||||
invalid: (name: string) => string,
|
||||
): void;
|
||||
/** The problem of the first node in graph order, or null when there is none. */
|
||||
firstProblem(): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The walk shared by the graph compilers: every node reachable from where `input` is first asked
|
||||
* is emitted once, before anything that reads it, and a node feeding itself is a problem.
|
||||
*/
|
||||
export function graphEmitter<D extends NodeDef, T>(graph: Graph, rules: EmitRules<D, T>): GraphEmitter<T> {
|
||||
const problems: Record<string, string> = {};
|
||||
const emitted = new Map<string, Record<string, T>>();
|
||||
const visiting = new Set<string>();
|
||||
|
||||
const problem = (node: GraphNode, text: string) => {
|
||||
if (!(node.id in problems)) problems[node.id] = text;
|
||||
};
|
||||
|
||||
const emitNode = (node: GraphNode): Record<string, T> | 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 || !rules.isDef(def)) {
|
||||
problem(node, `Not a ${rules.noun} node`);
|
||||
return null;
|
||||
}
|
||||
visiting.add(node.id);
|
||||
const inputs: Record<string, T> = {};
|
||||
let complete = true;
|
||||
for (const port of def.inputs) {
|
||||
const value = input(node, port);
|
||||
if (value) inputs[port.key] = value;
|
||||
else complete = false;
|
||||
}
|
||||
visiting.delete(node.id);
|
||||
if (!complete) return null;
|
||||
const outputs = rules.emit(def, inputs, node);
|
||||
emitted.set(node.id, outputs);
|
||||
return outputs;
|
||||
};
|
||||
|
||||
const input = (node: GraphNode, port: PortDef): T | null => {
|
||||
const edge = edgesInto(graph, node.id, port.key)[0];
|
||||
if (edge) {
|
||||
const source = findNode(graph, edge.from.node);
|
||||
const value = (source && emitNode(source))?.[edge.from.port];
|
||||
if (!value) return null;
|
||||
return rules.conform ? rules.conform(node, port, value) : value;
|
||||
}
|
||||
const def = nodeDef(node.type);
|
||||
return rules.unfed(node, port, def && rules.isDef(def) ? def : undefined);
|
||||
};
|
||||
|
||||
return {
|
||||
problems,
|
||||
problem,
|
||||
input,
|
||||
reached: (node) => emitted.has(node.id),
|
||||
checkNames(nodes, nameOf, invalid) {
|
||||
const names = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
const name = nameOf(node);
|
||||
const text = invalid(name);
|
||||
if (text) problem(node, text);
|
||||
else if (names.has(name)) problem(node, `Another property is named "${name}"`);
|
||||
names.add(name);
|
||||
}
|
||||
},
|
||||
firstProblem() {
|
||||
const first = graph.nodes.find((n) => n.id in problems);
|
||||
return first ? problems[first.id]! : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 type { Graph } from '../graph/Graph';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import type { FlatDef } from '../properties/PropertySchema';
|
||||
import {
|
||||
isParticlePropertyNode,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
PARTICLE_OUTPUTS,
|
||||
PARTICLE_TIME,
|
||||
type ParticleEnv,
|
||||
type ParticleNodeDef,
|
||||
type ParticleExpr,
|
||||
type ParticleOutputKey,
|
||||
} from './nodes';
|
||||
@@ -44,79 +46,40 @@ export interface ParticlesBuild {
|
||||
* particle. There is no interpreter and no eval – the compiler composes closures.
|
||||
*/
|
||||
export function compileParticles(graph: Graph): ParticlesBuild {
|
||||
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 (!isParticlePropertyNode(node)) continue;
|
||||
const name = propertyNameOf(node);
|
||||
if (!isValidParticlePropertyName(name))
|
||||
problem(node, 'Name must be one word: letters, digits, _ – and not one of the source’s own rows');
|
||||
else if (names.has(name)) problem(node, `Another property is named "${name}"`);
|
||||
names.add(name);
|
||||
}
|
||||
|
||||
const params = particleParamDefs(graph);
|
||||
const paramIndex = (name: string) => params.findIndex((row) => row.key === name);
|
||||
|
||||
const ops: ParticleExpr[] = [];
|
||||
const emitted = new Map<string, Record<string, ParticleExpr>>();
|
||||
const visiting = new Set<string>();
|
||||
|
||||
const emitNode = (node: GraphNode): Record<string, ParticleExpr> | 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 || !isParticleNodeDef(def)) {
|
||||
problem(node, 'Not a particle node');
|
||||
return null;
|
||||
}
|
||||
visiting.add(node.id);
|
||||
const inputs: Record<string, ParticleExpr> = {};
|
||||
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, ParticleExpr> = {};
|
||||
for (const [key, expr] of Object.entries(
|
||||
def.emit(inputs, node.params, { nodeId: node.id, paramIndex }),
|
||||
)) {
|
||||
const slot = ops.length;
|
||||
ops.push(expr);
|
||||
outputs[key] = (_env, locals) => locals[slot]!;
|
||||
}
|
||||
emitted.set(node.id, outputs);
|
||||
return outputs;
|
||||
};
|
||||
const walk = graphEmitter<ParticleNodeDef, ParticleExpr>(graph, {
|
||||
isDef: isParticleNodeDef,
|
||||
noun: 'particle',
|
||||
emit: (def, inputs, node) => {
|
||||
const outputs: Record<string, ParticleExpr> = {};
|
||||
for (const [key, expr] of Object.entries(
|
||||
def.emit(inputs, node.params, { nodeId: node.id, paramIndex }),
|
||||
)) {
|
||||
const slot = ops.length;
|
||||
ops.push(expr);
|
||||
outputs[key] = (_env, locals) => locals[slot]!;
|
||||
}
|
||||
return outputs;
|
||||
},
|
||||
unfed: (node, port, def) => {
|
||||
if (port.field) {
|
||||
const typed = Number(node.params[port.key] ?? port.default ?? 0);
|
||||
const value = Number.isFinite(typed) ? typed : 0;
|
||||
return () => value;
|
||||
}
|
||||
return def?.unfed?.[port.key] ?? (() => 0);
|
||||
},
|
||||
});
|
||||
const { problems } = walk;
|
||||
|
||||
/** What feeds an input: the edge's source, the typed-in value, or what the node says (`unfed`). */
|
||||
const input = (node: GraphNode, port: PortDef): ParticleExpr | null => {
|
||||
const edge = edgesInto(graph, node.id, port.key)[0];
|
||||
if (edge) {
|
||||
const source = findNode(graph, edge.from.node);
|
||||
const outputs = source && emitNode(source);
|
||||
return outputs?.[edge.from.port] ?? null;
|
||||
}
|
||||
if (port.field) {
|
||||
const typed = Number(node.params[port.key] ?? port.default ?? 0);
|
||||
const value = Number.isFinite(typed) ? typed : 0;
|
||||
return () => value;
|
||||
}
|
||||
const def = nodeDef(node.type);
|
||||
const unfed = def && isParticleNodeDef(def) ? def.unfed?.[port.key] : undefined;
|
||||
return unfed ?? (() => 0);
|
||||
};
|
||||
walk.checkNames(graph.nodes.filter(isParticlePropertyNode), propertyNameOf, (name) =>
|
||||
isValidParticlePropertyName(name)
|
||||
? ''
|
||||
: 'Name must be one word: letters, digits, _ – and not one of the source’s own rows',
|
||||
);
|
||||
|
||||
const output = graph.nodes.find((n) => n.type === PARTICLE_OUTPUT);
|
||||
if (!output) return { compiled: null, problems, reason: 'No Output node' };
|
||||
@@ -124,17 +87,17 @@ export function compileParticles(graph: Graph): ParticlesBuild {
|
||||
const reads: ParticleExpr[] = [];
|
||||
for (const key of PARTICLE_OUTPUTS) {
|
||||
const port = outputDef.inputs.find((p) => p.key === key)!;
|
||||
const expr = input(output, port);
|
||||
const expr = walk.input(output, port);
|
||||
if (!expr) break;
|
||||
reads.push(expr);
|
||||
}
|
||||
|
||||
const first = graph.nodes.find((n) => n.id in problems);
|
||||
if (first || reads.length !== PARTICLE_OUTPUTS.length)
|
||||
return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' };
|
||||
const failed = walk.firstProblem();
|
||||
if (failed !== null || reads.length !== PARTICLE_OUTPUTS.length)
|
||||
return { compiled: null, problems, reason: failed ?? 'Cannot compile' };
|
||||
|
||||
const locals = new Float64Array(ops.length);
|
||||
const time = graph.nodes.some((n) => n.type === PARTICLE_TIME && emitted.has(n.id));
|
||||
const time = graph.nodes.some((n) => n.type === PARTICLE_TIME && walk.reached(n));
|
||||
const compiled: CompiledParticles = {
|
||||
params,
|
||||
time,
|
||||
|
||||
+49
-87
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user