A calculation needed in several shaders (a greyscale, a noise, an outline) had to be built again in every one of them. A shader function keeps it as an asset of its own, called like a behavior is since #16. - New asset type `shaderfunction` ("Shader function"), edited in the Shader tab. The panel beside the graph shows no picture, the readout counts its inputs and outputs. - Two new nodes: `Function input` and `Function output`. Their declared ports, numbers or colours only, are the ports of every call. One of each per function, like Start and End. - The call is node `ShaderCall`, named after the function. The palette lists every function, it can be dragged in from the Assets panel, and a double-click opens it. - The compiler sets the function in where it is called (`inlineFunctions`). It renames the copied nodes `<call>/<node>` and turns an unconnected port into a constant. Passes, Time, the Edge cut and the corner walk then see a plain graph. - A problem inside a function is reported at the call, in the function's words. The function's own tab checks it on its own (`checkShaderFunction`). - `ShaderLibrary` keys a shader on its graph and on every function it calls, however deep, so a change reaches every caller. - The port commands mirror a new, renamed or removed port into every call, as for a behavior's Start and End. `NodeDef.portKinds` holds the two ends to Number and Color. - As agreed in the issue: no `Property`, `Color property`, Output or Vertex output in a function, and `Time` is the caller's clock. - Own decision: a `Pass` may stand in a function. It becomes a pass of the calling shader, so a wide blur can be a function. - A function calling itself, also through another, says so at the node and is not set in. The palette does not offer such a call. - The Shader tab showed only the compiler's problems. A call nothing reaches was never compiled and looked healthy, so a call now also shows its own problem there, as in a behavior. Tests: - Gate: 228 files, 3510 tests, CHECK EXIT 0 (+13: 11 in tests/core/shader-functions.test.ts, 2 for the new icon) - e2e 28: 100 checks, all passed (+3 in 28l-viii). Called and in place both #606060/#a2a2a2, halved in the function #303030/#515151. - Counter-check: a fingerprint without the called functions fails 1 unit test, and in the browser the change check (#606060 stays). - The first e2e runs found two faults, both fixed: a double-click only selected the function, and an unreached call of itself showed no problem. - e2e total 1728 is computed (1725 + 3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
805 lines
29 KiB
TypeScript
805 lines
29 KiB
TypeScript
import type { PortType } from '../graph/Graph';
|
||
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||
import type { SharedPortGroup } from '../graph/portTypes';
|
||
import { type PropertyBag, textOf } from '../model/Properties';
|
||
import { PROPERTY_DEFAULT_PARAM, propertyNodeParams } from '../properties/declaredRows';
|
||
import { flatDef } from '../properties/PropertySchema';
|
||
import {
|
||
DEFAULT_PROPERTY_COLOR,
|
||
PROPERTY_NAME_PARAM,
|
||
propertyName,
|
||
SHADER_COLOR_PROPERTY,
|
||
SHADER_PROPERTY,
|
||
} from '../properties/shaders';
|
||
|
||
/** `picture` is a sampler: the wearer's own picture or the one a Pass drew (#301). */
|
||
export type GlslType = 'float' | 'vec3' | 'picture';
|
||
|
||
/** 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, prepared?: string): Record<string, Expr>;
|
||
/** What an input with nothing to type on reads while unconnected; without it, the texel. */
|
||
unfed?: Record<string, Expr>;
|
||
/** Reads the picture or is a colour: there is none per corner, so it cannot feed the Vertex output (#79). */
|
||
perPixel?: boolean;
|
||
/** A vec4 all outputs read, worked out once into a local `emit` gets as `prepared` (#300). */
|
||
prepare?: (inputs: Record<string, Expr>, params: PropertyBag) => string;
|
||
}
|
||
|
||
export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef {
|
||
return def.category === 'shader' && 'emit' in def;
|
||
}
|
||
|
||
export const SHADER_TEXTURE_COLOR = 'ShaderTextureColor';
|
||
export const SHADER_TEXTURE_ALPHA = 'ShaderTextureAlpha';
|
||
export const SHADER_OUTPUT = 'ShaderOutput';
|
||
export const SHADER_VERTEX_OUTPUT = 'ShaderVertexOutput';
|
||
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';
|
||
export const SHADER_BLUR = 'ShaderBlur';
|
||
export const SHADER_PASS = 'ShaderPass';
|
||
|
||
/** 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_UV = 'vTextureCoord';
|
||
const OWN_U: Expr = { code: `${OWN_UV}.x`, type: 'float' };
|
||
const OWN_V: Expr = { code: `${OWN_UV}.y`, type: 'float' };
|
||
|
||
/** The wearer's own picture: what a picture input reads while nothing feeds it. */
|
||
export const ORIGINAL: Expr = { code: 'uTexture', type: 'picture' };
|
||
/** The sampler of the picture the wearer's `index`th Pass draws, in drawing order. */
|
||
export const passSampler = (index: number) => `uPass${index}`;
|
||
/** The Padding as a share of the picture per side; a Pass's picture is the picture plus it. */
|
||
export const PASS_PAD_UNIFORM = 'uPassPad';
|
||
/** A place on the wearer's picture to the same place on a Pass's, which takes in the padding as well. */
|
||
export const PASS_UV_FN = `vec2 passUv(vec2 uv) { return (uv + ${PASS_PAD_UNIFORM}) / (1.0 + 2.0 * ${PASS_PAD_UNIFORM}); }`;
|
||
|
||
/** `uv`, a place on the wearer's picture, as the place `picture` keeps there. */
|
||
function uvOn(picture: Expr, uv: string): string {
|
||
return picture.code === ORIGINAL.code ? uv : `passUv(${uv})`;
|
||
}
|
||
|
||
/** `picture` read at `uv` and tinted: premultiplied, like the texel. */
|
||
const sampleOf = (picture: Expr, uv: string) => `${TEXEL_AT}(${picture.code}, ${uvOn(picture, uv)}) * vColor`;
|
||
|
||
/**
|
||
* The pixels a node counts per pixel of the picture it reads, per axis. 1 but on a source's field,
|
||
* whose picture has the frame's screen pixels: there a pixel counts as a unit of the frame, so a
|
||
* Blur or a Texture size does not change with the zoom.
|
||
*/
|
||
export const PIXEL_SCALE_UNIFORM = 'uPixelScale';
|
||
/** `picture`'s size in the pixels a node counts. */
|
||
const countedSize = (picture: Expr) => `(vec2(textureSize(${picture.code}, 0)) * ${PIXEL_SCALE_UNIFORM})`;
|
||
|
||
/** The input a node reads a picture from; unconnected, the wearer's own. */
|
||
const pictureIn: PortDef = { key: 'picture', label: 'picture', type: 'picture', inline: false };
|
||
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.
|
||
*/
|
||
export type EdgeMode = 'repeat' | 'mirror' | 'clamp' | 'transparent';
|
||
export const EDGE_PARAM = 'edge';
|
||
/**
|
||
* Room around the picture the sprite draws into as well (#69), in pixels of the picture per side –
|
||
* a setting of the Output node, so an outline or a glow has somewhere to go.
|
||
*/
|
||
export const PADDING_PARAM = 'padding';
|
||
export const MAX_PADDING = 256;
|
||
/**
|
||
* The address mode the shared sheet carries anyway (`TextureLibrary`): within a texel it reads
|
||
* like clamp, and no other mode can have it without a cut of its own. Asking for it costs nothing,
|
||
* so it is what a sprite without a shader draws with.
|
||
*/
|
||
export const SHEET_EDGE: EdgeMode = 'mirror';
|
||
/** Clamp: the border texel goes on, which is what a sprite without a shader shows (#81). */
|
||
export const DEFAULT_EDGE: EdgeMode = 'clamp';
|
||
const EDGE_OPTIONS: { value: EdgeMode; label: string }[] = [
|
||
{ value: 'clamp', label: 'Clamp' },
|
||
{ value: 'mirror', label: 'Mirror' },
|
||
{ value: 'repeat', label: 'Repeat' },
|
||
{ value: 'transparent', label: 'Transparent' },
|
||
];
|
||
|
||
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;
|
||
}
|
||
|
||
export function paddingOf(params: PropertyBag): number {
|
||
const value = Number(params[PADDING_PARAM] ?? 0);
|
||
return Number.isFinite(value) ? Math.min(MAX_PADDING, Math.max(0, value)) : 0;
|
||
}
|
||
|
||
/**
|
||
* How many cells per axis the wearer's quad is cut into (#79) – a setting of the Vertex output node,
|
||
* because how fine the grid has to be is the effect's to say. 1 is the plain quad.
|
||
*/
|
||
export const SUBDIVISIONS_PARAM = 'subdivisions';
|
||
export const DEFAULT_SUBDIVISIONS = 8;
|
||
export const MAX_SUBDIVISIONS = 64;
|
||
|
||
export function subdivisionsOf(params: PropertyBag): number {
|
||
const value = Math.round(Number(params[SUBDIVISIONS_PARAM] ?? DEFAULT_SUBDIVISIONS));
|
||
return Number.isFinite(value) ? Math.min(MAX_SUBDIVISIONS, Math.max(1, value)) : DEFAULT_SUBDIVISIONS;
|
||
}
|
||
|
||
/**
|
||
* Reads the picture at a UV. No sampler state makes the outside empty, so *Transparent* is cut
|
||
* here, and with it needs no copy of the picture with an address mode of its own.
|
||
*/
|
||
export const TEXEL_AT = 'texelAt';
|
||
export function texelAtFn(edge: EdgeMode): string {
|
||
return edge === 'transparent'
|
||
? `vec4 ${TEXEL_AT}(sampler2D picture, vec2 uv) { vec2 inside = step(vec2(0.0), uv) * step(uv, vec2(1.0)); return texture(picture, uv) * (inside.x * inside.y); }`
|
||
: `vec4 ${TEXEL_AT}(sampler2D picture, vec2 uv) { return texture(picture, uv); }`;
|
||
}
|
||
|
||
export 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 => ({
|
||
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})`);
|
||
|
||
/** What operands of these types come out as: a colour on any side makes the result a colour. */
|
||
export function liftedType(types: readonly GlslType[]): GlslType {
|
||
return types.includes('vec3') ? 'vec3' : 'float';
|
||
}
|
||
|
||
/** Two operands lifted to one type. */
|
||
function lifted(a: Expr, b: Expr): { a: string; b: string; type: GlslType } {
|
||
const type = liftedType([a.type, b.type]);
|
||
return type === 'vec3' ? { a: asVec3(a), b: asVec3(b), type } : { a: a.code, b: b.code, type };
|
||
}
|
||
|
||
/**
|
||
* `liftedType` for the ports the editor draws (#219). An input nothing feeds is the number typed
|
||
* into it. One fed by a port that does not know its type yet leaves the result open, unless a
|
||
* colour on another input has settled it already.
|
||
*/
|
||
export function liftedPortType(sources: readonly (PortType | undefined)[]): PortType {
|
||
const types = sources.map(glslTypeOf);
|
||
const known = types.filter((type) => type !== undefined);
|
||
if (liftedType(known) === 'vec3') return 'vec3';
|
||
return known.length === types.length ? 'number' : 'any';
|
||
}
|
||
|
||
function glslTypeOf(source: PortType | undefined): GlslType | undefined {
|
||
if (source === undefined || source === 'number') return 'float';
|
||
return source === 'vec3' ? 'vec3' : undefined;
|
||
}
|
||
|
||
/** `result` shows what the compiler makes of these inputs. */
|
||
const lifts = (...inputs: string[]): SharedPortGroup => ({
|
||
inputs,
|
||
outputs: ['result'],
|
||
agree: liftedPortType,
|
||
});
|
||
|
||
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')],
|
||
sharedType: lifts('a', 'b'),
|
||
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. Fed a picture, that picture's pixel at the same spot.",
|
||
inputs: [pictureIn],
|
||
outputs: [vec3Port('color')],
|
||
params: [],
|
||
perPixel: true,
|
||
unfed: { picture: ORIGINAL },
|
||
emit: ({ picture }) => ({
|
||
color:
|
||
picture!.code === ORIGINAL.code
|
||
? TEXEL_COLOR
|
||
: { code: `straight(${sampleOf(picture!, OWN_UV)})`, type: 'vec3' },
|
||
}),
|
||
};
|
||
|
||
export const ShaderTextureAlpha: ShaderNodeDef = {
|
||
type: SHADER_TEXTURE_ALPHA,
|
||
label: 'Texture alpha',
|
||
category: 'shader',
|
||
description: "The sprite's own pixel's opacity: 0 transparent, 1 opaque. Fed a picture, that picture's.",
|
||
inputs: [pictureIn],
|
||
outputs: [{ key: 'value', label: 'value', type: 'number' }],
|
||
params: [],
|
||
perPixel: true,
|
||
unfed: { picture: ORIGINAL },
|
||
emit: ({ picture }) => ({
|
||
value:
|
||
picture!.code === ORIGINAL.code
|
||
? TEXEL_ALPHA
|
||
: { code: `(${sampleOf(picture!, OWN_UV)}).a`, type: 'float' },
|
||
}),
|
||
};
|
||
|
||
/** The one node the compiler starts from; an unconnected input leaves that part of the pixel as it is. */
|
||
export const ShaderOutput: ShaderNodeDef = {
|
||
type: SHADER_OUTPUT,
|
||
label: 'Output',
|
||
category: 'shader',
|
||
description:
|
||
'The color 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. Padding is room around the picture to draw into, in pixels of the picture.',
|
||
inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }],
|
||
outputs: [],
|
||
params: [
|
||
{ key: EDGE_PARAM, label: 'Edge', kind: 'enum', default: DEFAULT_EDGE, options: EDGE_OPTIONS },
|
||
{ key: PADDING_PARAM, label: 'Padding', kind: 'number', default: 0, min: 0, max: MAX_PADDING, step: 1 },
|
||
],
|
||
unfed: { color: TEXEL_COLOR, alpha: TEXEL_ALPHA },
|
||
emit: () => ({}),
|
||
};
|
||
|
||
/** How large a Pass's picture is, as a share of the wearer's picture per side. */
|
||
export const PASS_RESOLUTION_PARAM = 'resolution';
|
||
const PASS_RESOLUTIONS: Record<string, number> = { full: 1, half: 0.5, quarter: 0.25 };
|
||
/** Pictures a shader may draw before its Output: each is one more draw of the wearer's picture. */
|
||
export const MAX_PASSES = 8;
|
||
|
||
export function passScaleOf(params: PropertyBag): number {
|
||
return PASS_RESOLUTIONS[textOf(params[PASS_RESOLUTION_PARAM] ?? '')] ?? 1;
|
||
}
|
||
|
||
/**
|
||
* A cut in the graph (#301): what feeds it is drawn into a picture of its own first, and the nodes
|
||
* that read a picture read that one when it feeds them. The compiler never emits it in place – each
|
||
* Pass is a program of its own, drawn before the one that reads it.
|
||
*/
|
||
export const ShaderPass: ShaderNodeDef = {
|
||
type: SHADER_PASS,
|
||
label: 'Pass',
|
||
category: 'shader',
|
||
description:
|
||
'Draws color and alpha into a picture of their own first, the size of the sprite’s picture and its Padding. Nodes fed that picture read it whole, so a second Blur can soften the first one’s result. Resolution draws it smaller: Half has a quarter of the pixels, and a blur across it reaches twice as far. Before the last part of the graph the picture is untinted.',
|
||
inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }],
|
||
outputs: [{ key: 'picture', label: 'picture', type: 'picture' }],
|
||
params: [
|
||
{
|
||
key: PASS_RESOLUTION_PARAM,
|
||
label: 'Resolution',
|
||
kind: 'enum',
|
||
default: 'full',
|
||
options: [
|
||
{ value: 'full', label: 'Full' },
|
||
{ value: 'half', label: 'Half' },
|
||
{ value: 'quarter', label: 'Quarter' },
|
||
],
|
||
},
|
||
],
|
||
perPixel: true,
|
||
unfed: { color: TEXEL_COLOR, alpha: TEXEL_ALPHA },
|
||
emit: () => ({}),
|
||
};
|
||
|
||
/**
|
||
* The second node the compiler starts from (#79): what feeds it runs once per corner of the
|
||
* wearer's grid and moves that corner. Without it the wearer keeps the quad every sprite shares.
|
||
*/
|
||
export const ShaderVertexOutput: ShaderNodeDef = {
|
||
type: SHADER_VERTEX_OUTPUT,
|
||
label: 'Vertex output',
|
||
category: 'shader',
|
||
description:
|
||
'Moves the corners of the grid the picture is drawn on: x to the right and y down, in pixels of the picture. The picture bends with them. Subdivisions is how many cells the grid has each way; 1 is the plain rectangle.',
|
||
inputs: [
|
||
{ key: 'x', label: 'x', type: 'number', inline: false },
|
||
{ key: 'y', label: 'y', type: 'number', inline: false },
|
||
],
|
||
outputs: [],
|
||
params: [
|
||
{
|
||
key: SUBDIVISIONS_PARAM,
|
||
label: 'Subdivisions',
|
||
kind: 'number',
|
||
default: DEFAULT_SUBDIVISIONS,
|
||
min: 1,
|
||
max: MAX_SUBDIVISIONS,
|
||
step: 1,
|
||
},
|
||
],
|
||
unfed: { x: { code: '0.0', type: 'float' }, y: { code: '0.0', type: 'float' } },
|
||
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 },
|
||
pictureIn,
|
||
],
|
||
outputs: [vec3Port('color'), numberOut('alpha')],
|
||
params: [],
|
||
perPixel: true,
|
||
unfed: { u: OWN_U, v: OWN_V, picture: ORIGINAL },
|
||
emit: ({ u, v, picture }) => {
|
||
const sample = sampleOf(picture!, `vec2(${u!.code}, ${v!.code})`);
|
||
return {
|
||
color: { code: `straight(${sample})`, type: 'vec3' },
|
||
alpha: { code: `(${sample}).a`, type: 'float' },
|
||
};
|
||
},
|
||
};
|
||
|
||
export const MAX_BLUR_RADIUS = 8;
|
||
const BLUR = 'blur';
|
||
const BLUR_KIND_PARAM = 'kind';
|
||
const BLUR_DIRECTION_PARAM = 'direction';
|
||
const BLUR_AXES: Record<string, string> = {
|
||
both: 'vec2(1.0, 1.0)',
|
||
horizontal: 'vec2(1.0, 0.0)',
|
||
vertical: 'vec2(0.0, 1.0)',
|
||
};
|
||
|
||
/**
|
||
* The prelude's blur: the premultiplied samples of a square (or a line) around `uv`, weighted and
|
||
* averaged, so a transparent neighbour adds no colour and no dark seam. The outermost ring counts
|
||
* by the fraction of the radius that reaches it, so a radius can change smoothly. Gaussian weights
|
||
* fall off with sigma = radius / 2.
|
||
*/
|
||
export const BLUR_FN = [
|
||
'float blurWeight(int k, float r, float gaussian) {',
|
||
' float d = abs(float(k));',
|
||
' float w = clamp(r + 1.0 - d, 0.0, 1.0);',
|
||
' float s = max(r, 0.0001) * 0.5;',
|
||
' return gaussian > 0.5 ? w * exp(-d * d / (2.0 * s * s)) : w;',
|
||
'}',
|
||
`vec4 ${BLUR}(sampler2D picture, vec2 uv, float radius, vec2 axis, float gaussian) {`,
|
||
` float r = clamp(radius, 0.0, ${floatLiteral(MAX_BLUR_RADIUS)});`,
|
||
' ivec2 reach = ivec2(axis) * int(ceil(r));',
|
||
` vec2 texel = 1.0 / ${countedSize({ code: 'picture', type: 'picture' })};`,
|
||
' vec4 sum = vec4(0.0);',
|
||
' float total = 0.0;',
|
||
' for (int j = -reach.y; j <= reach.y; j++) {',
|
||
' for (int i = -reach.x; i <= reach.x; i++) {',
|
||
' float w = blurWeight(i, r, gaussian) * blurWeight(j, r, gaussian);',
|
||
` sum += ${TEXEL_AT}(picture, uv + vec2(float(i), float(j)) * texel) * w;`,
|
||
' total += w;',
|
||
' }',
|
||
' }',
|
||
' return sum / total;',
|
||
'}',
|
||
].join('\n');
|
||
|
||
/**
|
||
* The picture softened around a spot in one pass (#300). The loop is in the prelude's `blur`, so a
|
||
* radius from a Property changes without a new program.
|
||
*/
|
||
export const ShaderBlur: ShaderNodeDef = {
|
||
type: SHADER_BLUR,
|
||
label: 'Blur',
|
||
category: 'shader',
|
||
description: `The sprite's picture softened around a spot, tinted like Texture color. radius is in pixels of the picture, at most ${MAX_BLUR_RADIUS}, and may be a fraction. Box weighs every pixel alike, Gaussian the middle more. Past the border the picture continues as the Output's Edge says. In a field shader a pixel is a unit of the frame, whatever the zoom.`,
|
||
inputs: [
|
||
{
|
||
...floatIn('radius', 2),
|
||
field: {
|
||
key: 'radius',
|
||
label: 'radius',
|
||
kind: 'number',
|
||
default: 2,
|
||
min: 0,
|
||
max: MAX_BLUR_RADIUS,
|
||
step: 0.5,
|
||
},
|
||
},
|
||
{ key: 'u', label: 'u', type: 'number', inline: false },
|
||
{ key: 'v', label: 'v', type: 'number', inline: false },
|
||
pictureIn,
|
||
],
|
||
outputs: [vec3Port('color'), numberOut('alpha')],
|
||
params: [
|
||
{
|
||
key: BLUR_KIND_PARAM,
|
||
label: 'Kind',
|
||
kind: 'enum',
|
||
default: 'box',
|
||
options: [
|
||
{ value: 'box', label: 'Box' },
|
||
{ value: 'gaussian', label: 'Gaussian' },
|
||
],
|
||
},
|
||
{
|
||
key: BLUR_DIRECTION_PARAM,
|
||
label: 'Direction',
|
||
kind: 'enum',
|
||
default: 'both',
|
||
options: [
|
||
{ value: 'both', label: 'Both' },
|
||
{ value: 'horizontal', label: 'Horizontal' },
|
||
{ value: 'vertical', label: 'Vertical' },
|
||
],
|
||
},
|
||
],
|
||
perPixel: true,
|
||
unfed: { u: OWN_U, v: OWN_V, picture: ORIGINAL },
|
||
prepare: ({ radius, u, v, picture }, params) => {
|
||
const axis = BLUR_AXES[textOf(params[BLUR_DIRECTION_PARAM] ?? '')] ?? BLUR_AXES.both!;
|
||
const gaussian = textOf(params[BLUR_KIND_PARAM] ?? '') === 'gaussian' ? '1.0' : '0.0';
|
||
const uv = uvOn(picture!, `vec2(${u!.code}, ${v!.code})`);
|
||
return `${BLUR}(${picture!.code}, ${uv}, ${radius!.code}, ${axis}, ${gaussian}) * vColor`;
|
||
},
|
||
emit: (_inputs, _params, prepared) => ({
|
||
color: { code: `straight(${prepared})`, type: 'vec3' },
|
||
alpha: { code: `${prepared}.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. Fed a Pass's picture, that one's, Padding included. In a field shader a pixel is a unit of the frame, whatever the zoom.",
|
||
inputs: [pictureIn],
|
||
outputs: [numberOut('width'), numberOut('height')],
|
||
params: [],
|
||
perPixel: true,
|
||
unfed: { picture: ORIGINAL },
|
||
emit: ({ picture }) => ({
|
||
width: { code: `${countedSize(picture!)}.x`, type: 'float' },
|
||
height: { code: `${countedSize(picture!)}.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,
|
||
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: propertyNodeParams(NUMBER_STEP),
|
||
derivePorts: ({ node }) => ({ label: propertyName(node) || ShaderProperty.label }),
|
||
emit: (_inputs, params) => ({
|
||
value: { code: uniformName(textOf(params[PROPERTY_NAME_PARAM] ?? '')), type: 'float' },
|
||
}),
|
||
};
|
||
|
||
/** The same for a colour (#298); like `Color`, there is none per corner. */
|
||
export const ShaderColorProperty: ShaderNodeDef = {
|
||
type: SHADER_COLOR_PROPERTY,
|
||
label: 'Color property',
|
||
category: 'shader',
|
||
description: 'A color the sprite sets: a color row below the Shader dropdown, and a color here.',
|
||
inputs: [],
|
||
outputs: [vec3Port('color')],
|
||
params: [
|
||
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
|
||
flatDef(PROPERTY_DEFAULT_PARAM, 'color', { label: 'Default', value: DEFAULT_PROPERTY_COLOR }),
|
||
],
|
||
perPixel: true,
|
||
derivePorts: ({ node }) => ({ label: propertyName(node) || ShaderColorProperty.label }),
|
||
emit: (_inputs, params) => ({
|
||
color: { code: uniformName(textOf(params[PROPERTY_NAME_PARAM] ?? '')), type: 'vec3' },
|
||
}),
|
||
};
|
||
|
||
export function uniformName(property: string): string {
|
||
return `u_${property}`;
|
||
}
|
||
|
||
export const ShaderColor: ShaderNodeDef = {
|
||
type: SHADER_COLOR,
|
||
label: 'Color',
|
||
category: 'shader',
|
||
description: 'A constant color.',
|
||
inputs: [],
|
||
outputs: [vec3Port('color')],
|
||
params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })],
|
||
perPixel: true,
|
||
emit: (_inputs, params) => ({
|
||
color: { code: vec3Literal(Number(params.value ?? 0xffffff)), type: 'vec3' },
|
||
}),
|
||
};
|
||
|
||
export const ShaderNumber: ShaderNodeDef = {
|
||
type: SHADER_NUMBER,
|
||
label: 'Number',
|
||
category: 'shader',
|
||
description: 'A constant number.',
|
||
inputs: [],
|
||
outputs: [{ key: 'value', label: 'value', type: 'number' }],
|
||
params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: NUMBER_STEP }],
|
||
emit: (_inputs, params) => ({ value: { code: floatLiteral(Number(params.value ?? 0)), type: 'float' } }),
|
||
};
|
||
|
||
export const ShaderAdd = arithmetic('ShaderAdd', 'Add', '+', 'a + b, per channel for colors.', 0);
|
||
export const ShaderSubtract = arithmetic(
|
||
'ShaderSubtract',
|
||
'Subtract',
|
||
'-',
|
||
'a - b, per channel for colors.',
|
||
0,
|
||
);
|
||
export const ShaderMultiply = arithmetic(
|
||
'ShaderMultiply',
|
||
'Multiply',
|
||
'*',
|
||
'a × b, per channel for colors.',
|
||
1,
|
||
);
|
||
/**
|
||
* Division with an answer at zero. `a / 0.0` is NaN, and the epilog's `clamp(NaN, 0.0, 1.0)` is
|
||
* undefined in GLSL – the driver decides the pixel, so the same graph looks different on Intel,
|
||
* Nvidia and a phone (#64). Zero is the answer, the same one the `Divide` of a behavior graph and
|
||
* of a particle graph give.
|
||
*/
|
||
export const DIVIDE_FN = [
|
||
'float divide(float a, float b) { return b == 0.0 ? 0.0 : a / b; }',
|
||
'vec3 divide(vec3 a, vec3 b) { return vec3(divide(a.x, b.x), divide(a.y, b.y), divide(a.z, b.z)); }',
|
||
].join('\n');
|
||
|
||
export const ShaderDivide: ShaderNodeDef = {
|
||
type: 'ShaderDivide',
|
||
label: 'Divide',
|
||
category: 'shader',
|
||
description: 'a / b, per channel for colors. Dividing by 0 is 0, not a pixel the driver picks.',
|
||
inputs: [anyIn('a', 1), anyIn('b', 1)],
|
||
outputs: [anyOut('result')],
|
||
sharedType: lifts('a', 'b'),
|
||
params: [],
|
||
emit: ({ a, b }) => {
|
||
const l = lifted(a!, b!);
|
||
return { result: { code: `divide(${l.a}, ${l.b})`, type: l.type } };
|
||
},
|
||
};
|
||
|
||
export const ShaderClamp: ShaderNodeDef = {
|
||
type: 'ShaderClamp',
|
||
label: 'Clamp',
|
||
category: 'shader',
|
||
description: 'Holds the value inside min..max, per channel for colors.',
|
||
inputs: [anyIn('value', 0), floatIn('min', 0), floatIn('max', 1)],
|
||
outputs: [anyOut('result')],
|
||
sharedType: lifts('value'),
|
||
params: [],
|
||
emit: ({ value, min, max }) => ({
|
||
result: { code: `clamp(${value!.code}, ${min!.code}, ${max!.code})`, type: value!.type },
|
||
}),
|
||
};
|
||
|
||
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')],
|
||
sharedType: lifts('a', 'b'),
|
||
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 color 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' },
|
||
}),
|
||
};
|
||
|
||
function unary(type: string, label: string, fn: string, description: string): ShaderNodeDef {
|
||
return {
|
||
type,
|
||
label,
|
||
category: 'shader',
|
||
description,
|
||
inputs: [anyIn('value', 0)],
|
||
outputs: [anyOut('result')],
|
||
sharedType: lifts('value'),
|
||
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 colors.',
|
||
);
|
||
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 ShaderAbs = unary(
|
||
'ShaderAbs',
|
||
'Abs',
|
||
'abs',
|
||
'The distance from 0, sign dropped: -0.3 becomes 0.3. A value crossing 0 becomes a V. Per channel for colors.',
|
||
);
|
||
|
||
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 colors.',
|
||
inputs: [floatIn('edge', 0.5), anyIn('value', 0)],
|
||
outputs: [anyOut('result')],
|
||
sharedType: lifts('value'),
|
||
params: [],
|
||
emit: ({ edge, value }) => ({
|
||
result: { code: `step(${edge!.code}, ${value!.code})`, type: value!.type },
|
||
}),
|
||
};
|
||
|
||
/** GLSL leaves `pow` undefined below 0, so the base is held at 0 there. */
|
||
export const ShaderPow: ShaderNodeDef = {
|
||
type: 'ShaderPow',
|
||
label: 'Pow',
|
||
category: 'shader',
|
||
description:
|
||
'value to the power of exponent: 2 squares it, 0.5 is the square root. Below 0 the value counts as 0. Per channel for colors.',
|
||
inputs: [anyIn('value', 0), floatIn('exponent', 2)],
|
||
outputs: [anyOut('result')],
|
||
sharedType: lifts('value'),
|
||
params: [],
|
||
emit: ({ value, exponent }) => {
|
||
const power = value!.type === 'vec3' ? `vec3(${exponent!.code})` : exponent!.code;
|
||
return { result: { code: `pow(max(${value!.code}, 0.0), ${power})`, type: value!.type } };
|
||
},
|
||
};
|
||
|
||
/** GLSL leaves `sqrt` undefined below 0, so the value is held at 0 there. */
|
||
export const ShaderSqrt: ShaderNodeDef = {
|
||
type: 'ShaderSqrt',
|
||
label: 'Sqrt',
|
||
category: 'shader',
|
||
description:
|
||
'The square root: 0.25 becomes 0.5, 4 becomes 2. Below 0 the value counts as 0. Per channel for colors.',
|
||
inputs: [anyIn('value', 0)],
|
||
outputs: [anyOut('result')],
|
||
sharedType: lifts('value'),
|
||
params: [],
|
||
emit: ({ value }) => ({
|
||
result: { code: `sqrt(max(${value!.code}, 0.0))`, 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,
|
||
ShaderBlur,
|
||
ShaderTextureSize,
|
||
ShaderTime,
|
||
ShaderOutput,
|
||
ShaderPass,
|
||
ShaderVertexOutput,
|
||
ShaderProperty,
|
||
ShaderColorProperty,
|
||
ShaderColor,
|
||
ShaderNumber,
|
||
ShaderAdd,
|
||
ShaderSubtract,
|
||
ShaderMultiply,
|
||
ShaderDivide,
|
||
ShaderMix,
|
||
ShaderLuminance,
|
||
ShaderSin,
|
||
ShaderFloor,
|
||
ShaderFract,
|
||
ShaderAbs,
|
||
ShaderStep,
|
||
ShaderPow,
|
||
ShaderSqrt,
|
||
ShaderClamp,
|
||
];
|
||
|
||
/** 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)})`;
|
||
}
|