Shader assets: graph-built shaders, the Shader tab and sprite rows from the graph (#62)
game-maker / suite (push) Failing after 3m59s
game-maker / suite (push) Failing after 3m59s
A shader is an asset carrying a node graph (Texture color, Output, Property, Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite wears a shader through its Shader picker, and the rows below it are the graph's Property nodes. Property schema, resolution and deviation take the project because a sprite's rows now live in another asset. The built-in registry of the prototype is gone; "Adjust colors" ships as a graph in the fixture and the three example games. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
6141d07d4f
commit
42cafa7543
62 files changed
+1682
-144
No files matched your search
@@ -0,0 +1,143 @@
|
||||
import { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph';
|
||||
import { nodeDef, type PortDef } from '../graph/NodeRegistry';
|
||||
import { colorOf } from '../model/Properties';
|
||||
import { isPropertyNode, isValidPropertyName, propertyName, shaderParamDefs } from '../properties/shaders';
|
||||
import {
|
||||
asVec3,
|
||||
type Expr,
|
||||
floatLiteral,
|
||||
isShaderNodeDef,
|
||||
SHADER_OUTPUT,
|
||||
TEXEL_COLOR,
|
||||
uniformName,
|
||||
vec3Literal,
|
||||
} from './nodes';
|
||||
|
||||
export interface ShaderUniform {
|
||||
name: string;
|
||||
default: number;
|
||||
}
|
||||
|
||||
export interface CompiledShader {
|
||||
fragment: string;
|
||||
uniforms: ShaderUniform[];
|
||||
}
|
||||
|
||||
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 a Pixi filter fragment: every node reachable from the Output becomes one
|
||||
* local per output, in dependency order, and every Property a float uniform. Pixi hands a filter
|
||||
* premultiplied pixels, 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 (!isValidPropertyName(name)) problem(node, 'Name must be one word: letters, digits, _');
|
||||
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 – the texel. */
|
||||
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' };
|
||||
return 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 first = graph.nodes.find((n) => n.id in problems);
|
||||
if (first || !color)
|
||||
return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' };
|
||||
|
||||
const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default }));
|
||||
const fragment = [
|
||||
'in vec2 vTextureCoord;',
|
||||
'out vec4 finalColor;',
|
||||
'uniform sampler2D uTexture;',
|
||||
...uniforms.map((u) => `uniform float ${uniformName(u.name)};`),
|
||||
'void main()',
|
||||
'{',
|
||||
' vec4 texel = texture(uTexture, vTextureCoord);',
|
||||
` vec3 ${TEXEL_COLOR.code} = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;`,
|
||||
...lines.map((line) => ` ${line}`),
|
||||
` finalColor = vec4(clamp(${color.code}, 0.0, 1.0) * texel.a, texel.a);`,
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
return { compiled: { fragment, uniforms }, problems, reason: '' };
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { flatDef } from '../properties/PropertySchema';
|
||||
import {
|
||||
PROPERTY_DEFAULT_PARAM,
|
||||
PROPERTY_MAX_PARAM,
|
||||
PROPERTY_MIN_PARAM,
|
||||
PROPERTY_NAME_PARAM,
|
||||
propertyName,
|
||||
SHADER_PROPERTY,
|
||||
} from '../properties/shaders';
|
||||
|
||||
export type GlslType = 'float' | 'vec3';
|
||||
|
||||
/** A GLSL expression and what it evaluates to. */
|
||||
export interface Expr {
|
||||
code: string;
|
||||
type: GlslType;
|
||||
}
|
||||
|
||||
/**
|
||||
* A node of a shader graph. It never runs: `emit` writes the GLSL of its outputs from the GLSL of
|
||||
* its inputs. An input typed `number` arrives as a float, one typed `vec3` as a vec3 (a float is
|
||||
* broadcast), one typed `any` as whatever feeds it.
|
||||
*/
|
||||
export interface ShaderNodeDef extends NodeDef {
|
||||
category: 'shader';
|
||||
emit(inputs: Record<string, Expr>, params: PropertyBag): Record<string, Expr>;
|
||||
}
|
||||
|
||||
export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef {
|
||||
return def.category === 'shader' && 'emit' in def;
|
||||
}
|
||||
|
||||
export const SHADER_TEXTURE_COLOR = 'ShaderTextureColor';
|
||||
export const SHADER_OUTPUT = 'ShaderOutput';
|
||||
export const SHADER_COLOR = 'ShaderColor';
|
||||
|
||||
/** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */
|
||||
export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' };
|
||||
|
||||
const NUMBER_STEP = 0.05;
|
||||
|
||||
const vec3Port = (key: string): PortDef => ({ key, label: key, type: 'vec3' });
|
||||
|
||||
/** A number input with an inline value, so a constant needs no node of its own. */
|
||||
const floatIn = (key: string, value: number): PortDef => ({
|
||||
key,
|
||||
label: key,
|
||||
type: 'number',
|
||||
default: value,
|
||||
field: { key, label: key, kind: 'number', default: value, step: NUMBER_STEP },
|
||||
});
|
||||
|
||||
/** An input taking a number or a colour; typed in, it is a number. */
|
||||
const anyIn = (key: string, value: number): PortDef => ({ ...floatIn(key, value), type: 'any' });
|
||||
|
||||
const anyOut = (key: string): PortDef => ({ key, label: key, type: 'any' });
|
||||
|
||||
export const asVec3 = (e: Expr): string => (e.type === 'vec3' ? e.code : `vec3(${e.code})`);
|
||||
|
||||
/** Two operands lifted to one type: a colour on either side makes the result a colour. */
|
||||
function lifted(a: Expr, b: Expr): { a: string; b: string; type: GlslType } {
|
||||
if (a.type === 'vec3' || b.type === 'vec3') return { a: asVec3(a), b: asVec3(b), type: 'vec3' };
|
||||
return { a: a.code, b: b.code, type: 'float' };
|
||||
}
|
||||
|
||||
function arithmetic(
|
||||
type: string,
|
||||
label: string,
|
||||
op: string,
|
||||
description: string,
|
||||
bDefault: number,
|
||||
): ShaderNodeDef {
|
||||
return {
|
||||
type,
|
||||
label,
|
||||
category: 'shader',
|
||||
description,
|
||||
inputs: [anyIn('a', bDefault === 1 ? 1 : 0), anyIn('b', bDefault)],
|
||||
outputs: [anyOut('result')],
|
||||
params: [],
|
||||
emit: ({ a, b }) => {
|
||||
const l = lifted(a!, b!);
|
||||
return { result: { code: `(${l.a} ${op} ${l.b})`, type: l.type } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ShaderTextureColor: ShaderNodeDef = {
|
||||
type: SHADER_TEXTURE_COLOR,
|
||||
label: 'Texture color',
|
||||
category: 'shader',
|
||||
description: "The sprite's own pixel, before the shader: its texture tinted by its color.",
|
||||
inputs: [],
|
||||
outputs: [vec3Port('color')],
|
||||
params: [],
|
||||
emit: () => ({ color: TEXEL_COLOR }),
|
||||
};
|
||||
|
||||
/** The one node the compiler starts from; an unconnected color leaves the pixel as it is. */
|
||||
export const ShaderOutput: ShaderNodeDef = {
|
||||
type: SHADER_OUTPUT,
|
||||
label: 'Output',
|
||||
category: 'shader',
|
||||
description: 'The colour the sprite is drawn with. Its alpha stays the texture’s.',
|
||||
inputs: [vec3Port('color')],
|
||||
outputs: [],
|
||||
params: [],
|
||||
emit: () => ({}),
|
||||
};
|
||||
|
||||
/** `name` is the sprite property and the uniform; the numbers are the row the inspector shows. */
|
||||
export const ShaderProperty: ShaderNodeDef = {
|
||||
type: SHADER_PROPERTY,
|
||||
label: 'Property',
|
||||
category: 'shader',
|
||||
description: 'A number the sprite sets: a property row below the Shader dropdown, and a value here.',
|
||||
inputs: [],
|
||||
outputs: [{ key: 'value', label: 'value', type: 'number' }],
|
||||
params: [
|
||||
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
|
||||
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step: NUMBER_STEP },
|
||||
],
|
||||
derivePorts: ({ node }) => ({ label: propertyName(node) || ShaderProperty.label }),
|
||||
emit: (_inputs, params) => ({
|
||||
value: { code: uniformName(textOf(params[PROPERTY_NAME_PARAM] ?? '')), type: 'float' },
|
||||
}),
|
||||
};
|
||||
|
||||
export function uniformName(property: string): string {
|
||||
return `u_${property}`;
|
||||
}
|
||||
|
||||
export const ShaderColor: ShaderNodeDef = {
|
||||
type: SHADER_COLOR,
|
||||
label: 'Color',
|
||||
category: 'shader',
|
||||
description: 'A constant colour.',
|
||||
inputs: [],
|
||||
outputs: [vec3Port('color')],
|
||||
params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })],
|
||||
emit: (_inputs, params) => ({
|
||||
color: { code: vec3Literal(Number(params.value ?? 0xffffff)), type: 'vec3' },
|
||||
}),
|
||||
};
|
||||
|
||||
export const ShaderAdd = arithmetic('ShaderAdd', 'Add', '+', 'a + b, per channel for colours.', 0);
|
||||
export const ShaderSubtract = arithmetic(
|
||||
'ShaderSubtract',
|
||||
'Subtract',
|
||||
'-',
|
||||
'a - b, per channel for colours.',
|
||||
0,
|
||||
);
|
||||
export const ShaderMultiply = arithmetic(
|
||||
'ShaderMultiply',
|
||||
'Multiply',
|
||||
'*',
|
||||
'a × b, per channel for colours.',
|
||||
1,
|
||||
);
|
||||
export const ShaderDivide = arithmetic('ShaderDivide', 'Divide', '/', 'a / b, per channel for colours.', 1);
|
||||
|
||||
export const ShaderMix: ShaderNodeDef = {
|
||||
type: 'ShaderMix',
|
||||
label: 'Mix',
|
||||
category: 'shader',
|
||||
description: 'Blends from a to b: t = 0 is a, t = 1 is b, beyond that it extrapolates.',
|
||||
inputs: [anyIn('a', 0), anyIn('b', 1), floatIn('t', 0.5)],
|
||||
outputs: [anyOut('result')],
|
||||
params: [],
|
||||
emit: ({ a, b, t }) => {
|
||||
const l = lifted(a!, b!);
|
||||
return { result: { code: `mix(${l.a}, ${l.b}, ${t!.code})`, type: l.type } };
|
||||
},
|
||||
};
|
||||
|
||||
export const ShaderLuminance: ShaderNodeDef = {
|
||||
type: 'ShaderLuminance',
|
||||
label: 'Luminance',
|
||||
category: 'shader',
|
||||
description: 'How bright a colour is (Rec. 709 weights), as one number.',
|
||||
inputs: [vec3Port('color')],
|
||||
outputs: [{ key: 'value', label: 'value', type: 'number' }],
|
||||
params: [],
|
||||
emit: ({ color }) => ({
|
||||
value: { code: `dot(${color!.code}, vec3(0.2126, 0.7152, 0.0722))`, type: 'float' },
|
||||
}),
|
||||
};
|
||||
|
||||
/** Every node a shader graph offers. Registered by `graph/nodes`, offered by no behavior palette. */
|
||||
export const SHADER_NODES: readonly ShaderNodeDef[] = [
|
||||
ShaderTextureColor,
|
||||
ShaderOutput,
|
||||
ShaderProperty,
|
||||
ShaderColor,
|
||||
ShaderAdd,
|
||||
ShaderSubtract,
|
||||
ShaderMultiply,
|
||||
ShaderDivide,
|
||||
ShaderMix,
|
||||
ShaderLuminance,
|
||||
];
|
||||
|
||||
/** A GLSL float literal: always with a decimal point, so `1` does not become an int. */
|
||||
export function floatLiteral(n: number): string {
|
||||
if (!Number.isFinite(n)) return '0.0';
|
||||
return Number.isInteger(n) ? n.toFixed(1) : String(n);
|
||||
}
|
||||
|
||||
export function vec3Literal(packed: number): string {
|
||||
const channel = (shift: number) => ((packed >> shift) & 0xff) / 255;
|
||||
return `vec3(${channel(16).toFixed(4)}, ${channel(8).toFixed(4)}, ${channel(0).toFixed(4)})`;
|
||||
}
|
||||
Reference in new issue
Block a user