Shader graph: UV, Sample texture, Texture size, Time, Sin/Floor/Fract/Step; edge mode on Output (#78)
game-maker / suite (push) Successful in 8m38s

Fragment-side coordinate nodes instead of vertex nodes: the quad stays the
quad, a UV read plus a sample at a computed coordinate covers flip, scroll,
wave, pixelate and scanlines. Time is a uniform only when a Time node reaches
the Output; a viewport that drew a Time shader redraws from the ticker so the
Scene tab animates without a running world. Edge (Mirror/Repeat/Clamp) is a
second texture variant in TextureLibrary because WebGL binds no TextureStyle
resource of the shader. The compiler now emits #version 300 es first: Pixi
only compiles as ES 3.00 when the fragment says so, and textureSize needs it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-13 16:18:22 +02:00
1 parent 0e5e5ef925
commit f85606427b
12 files changed
+586 -38

No files matched your search

+25 -8
View File
@@ -4,12 +4,17 @@ import { colorOf } from '../model/Properties';
import { isPropertyNode, isValidPropertyName, propertyName, shaderParamDefs } from '../properties/shaders';
import {
asVec3,
type EdgeMode,
edgeOf,
type Expr,
floatLiteral,
isShaderNodeDef,
SHADER_OUTPUT,
SHADER_TIME,
STRAIGHT_FN,
TEXEL_ALPHA,
TEXEL_COLOR,
TIME_UNIFORM,
uniformName,
vec3Literal,
} from './nodes';
@@ -22,6 +27,10 @@ export interface ShaderUniform {
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;
/** How the picture continues past its border – the texture variant the sprite's mesh draws with. */
edge: EdgeMode;
}
export interface ShaderBuild {
@@ -34,9 +43,9 @@ export interface ShaderBuild {
}
/**
* 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.
* 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.
*/
export function compileShader(graph: Graph): ShaderBuild {
const problems: Record<string, string> = {};
@@ -91,8 +100,8 @@ export function compileShader(graph: Graph): ShaderBuild {
};
/**
* What feeds an input: the edge's source, the typed-in value, or – with nothing to type – the
* texel: its colour for a colour port, its alpha for a number port.
* 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];
@@ -107,7 +116,9 @@ export function compileShader(graph: Graph): ShaderBuild {
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 port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR;
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 => {
@@ -130,23 +141,29 @@ export function compileShader(graph: Graph): ShaderBuild {
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,
'void main()',
'{',
' vec4 texel = texture(uTexture, vTextureCoord) * vColor;',
` vec3 ${TEXEL_COLOR.code} = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;`,
` 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');
return { compiled: { fragment, uniforms }, problems, reason: '' };
return { compiled: { fragment, uniforms, time, edge: edgeOf(output.params) }, problems, reason: '' };
}
+149 -2
View File
@@ -26,6 +26,8 @@ export interface Expr {
export interface ShaderNodeDef extends NodeDef {
category: 'shader';
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>;
}
export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef {
@@ -37,14 +39,44 @@ export const SHADER_TEXTURE_ALPHA = 'ShaderTextureAlpha';
export const SHADER_OUTPUT = 'ShaderOutput';
export const SHADER_COLOR = 'ShaderColor';
export const SHADER_NUMBER = 'ShaderNumber';
export const SHADER_UV = 'ShaderUV';
export const SHADER_SAMPLE = 'ShaderSample';
export const SHADER_TEXTURE_SIZE = 'ShaderTextureSize';
export const SHADER_TIME = 'ShaderTime';
/** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */
export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' };
export const TEXEL_ALPHA: Expr = { code: 'texel.a', type: 'float' };
/** This pixel's place on the picture, 0..1 – the vertex stage's `vTextureCoord`. */
const OWN_U: Expr = { code: 'vTextureCoord.x', type: 'float' };
const OWN_V: Expr = { code: 'vTextureCoord.y', type: 'float' };
export const TIME_UNIFORM = 'uTime';
/** The prelude's helper: a premultiplied sample to its straight colour. */
export const STRAIGHT_FN = 'vec3 straight(vec4 c) { return c.a > 0.0 ? c.rgb / c.a : c.rgb; }';
/**
* How the picture continues past its edge when a `Sample texture` reads outside 0..1 – a setting
* of the Output node, because the sampler state is one per draw. Mirror is what every picture has
* anyway (`TextureLibrary`); repeat is for tiles.
*/
export type EdgeMode = 'repeat' | 'mirror' | 'clamp';
export const EDGE_PARAM = 'edge';
export const DEFAULT_EDGE: EdgeMode = 'mirror';
const EDGE_OPTIONS: { value: EdgeMode; label: string }[] = [
{ value: 'mirror', label: 'Mirror' },
{ value: 'repeat', label: 'Repeat' },
{ value: 'clamp', label: 'Clamp' },
];
export function edgeOf(params: PropertyBag): EdgeMode {
const value = textOf(params[EDGE_PARAM] ?? '');
return EDGE_OPTIONS.some((o) => o.value === value) ? (value as EdgeMode) : DEFAULT_EDGE;
}
const NUMBER_STEP = 0.05;
const vec3Port = (key: string): PortDef => ({ key, label: key, type: 'vec3' });
const numberOut = (key: string): PortDef => ({ key, label: key, type: 'number' });
/** A number input with an inline value, so a constant needs no node of its own. */
const floatIn = (key: string, value: number): PortDef => ({
@@ -117,13 +149,75 @@ export const ShaderOutput: ShaderNodeDef = {
type: SHADER_OUTPUT,
label: 'Output',
category: 'shader',
description: 'The colour and opacity the sprite is drawn with; unconnected, each is the texture’s.',
description:
'The colour and opacity the sprite is drawn with; unconnected, each is the texture’s. Edge says how the picture continues past its border when sampled outside it.',
inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }],
outputs: [],
params: [],
params: [{ key: EDGE_PARAM, label: 'Edge', kind: 'enum', default: DEFAULT_EDGE, options: EDGE_OPTIONS }],
unfed: { color: TEXEL_COLOR, alpha: TEXEL_ALPHA },
emit: () => ({}),
};
export const ShaderUV: ShaderNodeDef = {
type: SHADER_UV,
label: 'UV',
category: 'shader',
description: "Where this pixel is on the sprite's picture: 0 to 1, u left to right, v top to bottom.",
inputs: [],
outputs: [numberOut('u'), numberOut('v')],
params: [],
emit: () => ({ u: OWN_U, v: OWN_V }),
};
/** Unconnected, `u` and `v` are this pixel's own, which makes the node Texture color. */
export const ShaderSample: ShaderNodeDef = {
type: SHADER_SAMPLE,
label: 'Sample texture',
category: 'shader',
description:
"The sprite's picture at another spot, tinted like Texture color; past the border it continues as the Output's Edge says.",
inputs: [
{ key: 'u', label: 'u', type: 'number', inline: false },
{ key: 'v', label: 'v', type: 'number', inline: false },
],
outputs: [vec3Port('color'), numberOut('alpha')],
params: [],
unfed: { u: OWN_U, v: OWN_V },
emit: ({ u, v }) => {
const sample = `texture(uTexture, vec2(${u!.code}, ${v!.code})) * vColor`;
return {
color: { code: `straight(${sample})`, type: 'vec3' },
alpha: { code: `(${sample}).a`, type: 'float' },
};
},
};
export const ShaderTextureSize: ShaderNodeDef = {
type: SHADER_TEXTURE_SIZE,
label: 'Texture size',
category: 'shader',
description: "The sprite's picture in pixels, for effects that need to know where its pixels are.",
inputs: [],
outputs: [numberOut('width'), numberOut('height')],
params: [],
emit: () => ({
width: { code: 'float(textureSize(uTexture, 0).x)', type: 'float' },
height: { code: 'float(textureSize(uTexture, 0).y)', type: 'float' },
}),
};
export const ShaderTime: ShaderNodeDef = {
type: SHADER_TIME,
label: 'Time',
category: 'shader',
description:
'Seconds, counting up since the editor started. A sprite wearing a graph with it keeps redrawing.',
inputs: [],
outputs: [numberOut('value')],
params: [],
emit: () => ({ value: { code: TIME_UNIFORM, type: 'float' } }),
};
/** `name` is the sprite property and the uniform; the numbers are the row the inspector shows. */
export const ShaderProperty: ShaderNodeDef = {
type: SHADER_PROPERTY,
@@ -216,10 +310,59 @@ export const ShaderLuminance: ShaderNodeDef = {
}),
};
function unary(type: string, label: string, fn: string, description: string): ShaderNodeDef {
return {
type,
label,
category: 'shader',
description,
inputs: [anyIn('value', 0)],
outputs: [anyOut('result')],
params: [],
emit: ({ value }) => ({ result: { code: `${fn}(${value!.code})`, type: value!.type } }),
};
}
export const ShaderSin = unary(
'ShaderSin',
'Sin',
'sin',
'The sine of a number in radians: one full wave every 6.283. Per channel for colours.',
);
export const ShaderFloor = unary(
'ShaderFloor',
'Floor',
'floor',
'Rounds down to a whole number: 2.75 becomes 2.',
);
export const ShaderFract = unary(
'ShaderFract',
'Fract',
'fract',
'The part after the decimal point: 2.75 becomes 0.75. A rising value becomes a sawtooth from 0 to 1.',
);
export const ShaderStep: ShaderNodeDef = {
type: 'ShaderStep',
label: 'Step',
category: 'shader',
description: '0 while value is below edge, 1 from edge on: a hard cut. Per channel for colours.',
inputs: [floatIn('edge', 0.5), anyIn('value', 0)],
outputs: [anyOut('result')],
params: [],
emit: ({ edge, value }) => ({
result: { code: `step(${edge!.code}, ${value!.code})`, type: value!.type },
}),
};
/** Every node a shader graph offers. Registered by `graph/nodes`, offered by no behavior palette. */
export const SHADER_NODES: readonly ShaderNodeDef[] = [
ShaderTextureColor,
ShaderTextureAlpha,
ShaderUV,
ShaderSample,
ShaderTextureSize,
ShaderTime,
ShaderOutput,
ShaderProperty,
ShaderColor,
@@ -230,6 +373,10 @@ export const SHADER_NODES: readonly ShaderNodeDef[] = [
ShaderDivide,
ShaderMix,
ShaderLuminance,
ShaderSin,
ShaderFloor,
ShaderFract,
ShaderStep,
];
/** A GLSL float literal: always with a decimal point, so `1` does not become an int. */