Files
feuerwerk/tests/core/shader-functions.test.ts
T
adminandClaude Opus 5.5 a526e1b40f
game-maker / gate (push) Successful in 3m16s
game-maker / suite (push) Failing after 14m37s
Use a shader graph as a node in other shaders (#303)
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>
2026-10-03 17:09:53 +02:00

322 lines
14 KiB
TypeScript

/**
* Shader functions (#303): a graph of its own, used as a node in other shaders and set in by the
* compiler where it is called.
*/
import { describe, expect, it } from 'vitest';
import { CommandError } from '../../src/core/commands/Command';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
import { AddPortCommand, RemovePortCommand, RenamePortCommand } from '../../src/core/commands/portCommands';
import { callProblem, FUNCTION_PARAM, resolveDef, SHADER_CALL } from '../../src/core/graph';
import { SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT } from '../../src/core/graph/nested';
import { graphLookup, shaderFunctionsOf } from '../../src/core/model/graphLookup';
import type { ShaderAsset, ShaderFunctionAsset } from '../../src/core/model/Asset';
import { findAssetOf, findGraphAsset } from '../../src/core/model/Project';
import { SHADER_PROPERTY } from '../../src/core/properties/shaders';
import { checkShaderFunction, compileShader } from '../../src/core/shader/compile';
import {
SHADER_BLUR,
SHADER_OUTPUT,
SHADER_PASS,
SHADER_TEXTURE_COLOR,
SHADER_TIME,
SHADER_VERTEX_OUTPUT,
} from '../../src/core/shader/nodes';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
import { shaderLibrary } from '../../src/viewport/ShaderLibrary';
import { makeSpriteScene, type SceneFixture } from '../helpers';
type Fixture = SceneFixture;
/** A graph asset with the helpers every test here builds with. */
function graphOf<T extends 'shader' | 'shaderfunction'>(f: Fixture, type: T, name: string) {
const create = new CreateAssetCommand(type, name);
f.stack.execute(create);
const id = create.asset.id;
const asset = () =>
findAssetOf(f.project, id, type)! as T extends 'shader' ? ShaderAsset : ShaderFunctionAsset;
const graph = () => findGraphAsset(f.project, id)!.graph;
const add = (nodeType: string, params = {}) => {
const command = new AddNodeCommand(id, nodeType, 0, 0, params);
f.stack.execute(command);
return command.node.id;
};
const link = (from: string, fromPort: string, to: string, toPort: string) =>
f.stack.execute(new ConnectCommand(id, { node: from, port: fromPort }, { node: to, port: toPort }));
/** An edge no command would lay, for what a hand-edited file may hold. */
const wire = (from: string, fromPort: string, to: string, toPort: string) =>
graph().edges.push({
id: `w${graph().edges.length}`,
from: { node: from, port: fromPort },
to: { node: to, port: toPort },
});
const port = (node: string, key: string, kind: 'number' | 'color') =>
f.stack.execute(new AddPortCommand(id, node, { key, kind }));
return { id, asset, add, link, wire, port };
}
/** "Grayscale": a color in, its luminance out as a color. */
function grayscale(f: Fixture, name = 'Grayscale') {
const fn = graphOf(f, 'shaderfunction', name);
const input = fn.add(SHADER_FUNCTION_INPUT);
const output = fn.add(SHADER_FUNCTION_OUTPUT);
fn.port(input, 'color', 'color');
fn.port(output, 'gray', 'color');
const luminance = fn.add('ShaderLuminance');
fn.link(input, 'color', luminance, 'color');
fn.link(luminance, 'value', output, 'gray');
return { ...fn, input, output, luminance };
}
/** A shader handing its texel through a call of `fnId` to the Output. */
function calling(f: Fixture, fnId: string, name = 'Uses') {
const s = graphOf(f, 'shader', name);
const out = s.add(SHADER_OUTPUT);
const texel = s.add(SHADER_TEXTURE_COLOR);
const call = s.add(SHADER_CALL, { [FUNCTION_PARAM]: fnId });
const build = () => compileShader(s.asset().graph, shaderFunctionsOf(f.project));
return { ...s, out, texel, call, build };
}
describe('shader functions (#303)', () => {
it('draws through a call in two shaders exactly what the same nodes draw in place', () => {
const f = makeSpriteScene();
const fn = grayscale(f);
const direct = graphOf(f, 'shader', 'Direct');
const out = direct.add(SHADER_OUTPUT);
const luminance = direct.add('ShaderLuminance');
direct.link(direct.add(SHADER_TEXTURE_COLOR), 'color', luminance, 'color');
direct.link(luminance, 'value', out, 'color');
const expected = compileShader(direct.asset().graph).compiled!.fragment;
for (const name of ['First', 'Second']) {
const s = calling(f, fn.id, name);
s.link(s.texel, 'color', s.call, 'color');
s.link(s.call, 'gray', s.out, 'color');
const build = s.build();
expect(build.problems).toEqual({});
expect(build.compiled!.fragment).toBe(expected);
}
});
it('takes a change to the function into every shader calling it', () => {
const f = makeSpriteScene();
const fn = grayscale(f);
const shaders = ['First', 'Second'].map((name) => {
const s = calling(f, fn.id, name);
s.link(s.texel, 'color', s.call, 'color');
s.link(s.call, 'gray', s.out, 'color');
return s;
});
const before = shaders.map((s) => shaderLibrary.buildFor(s.asset(), f.project).compiled!.fragment);
const compiles = shaderLibrary.compiles;
expect(shaders.map((s) => shaderLibrary.buildFor(s.asset(), f.project))).toBeTruthy();
expect(shaderLibrary.compiles).toBe(compiles);
const sin = fn.add('ShaderSin');
fn.link(fn.luminance, 'value', sin, 'value');
fn.link(sin, 'result', fn.output, 'gray');
const after = shaders.map((s) => shaderLibrary.buildFor(s.asset(), f.project).compiled!.fragment);
expect(shaderLibrary.compiles).toBe(compiles + 2);
for (const [i, fragment] of after.entries()) {
expect(fragment).not.toBe(before[i]);
expect(fragment).toMatch(/= sin\(n\d+_value\);/);
}
});
it('grows the call in another shader with a new input, and an undo takes it back in both', () => {
const f = makeSpriteScene();
const fn = grayscale(f);
const s = calling(f, fn.id);
const lookup = graphLookup(f.project);
const inputsOfCall = () => {
const node = s.asset().graph.nodes.find((n) => n.id === s.call)!;
return resolveDef(node, s.asset().graph, lookup)!.inputs.map((p) => `${p.key}:${p.type}`);
};
expect(inputsOfCall()).toEqual(['color:vec3']);
fn.port(fn.input, 'amount', 'number');
expect(inputsOfCall()).toEqual(['color:vec3', 'amount:number']);
f.stack.undo();
expect(inputsOfCall()).toEqual(['color:vec3']);
expect(
fn
.asset()
.graph.nodes.find((n) => n.id === fn.input)!
.ports!.map((p) => p.key),
).toEqual(['color']);
});
it('carries the edges at every call along when a port is renamed or removed', () => {
const f = makeSpriteScene();
const fn = grayscale(f);
const s = calling(f, fn.id);
s.link(s.texel, 'color', s.call, 'color');
s.link(s.call, 'gray', s.out, 'color');
const ports = () => s.asset().graph.edges.map((e) => (e.to.node === s.call ? e.to.port : e.from.port));
f.stack.execute(new RenamePortCommand(fn.id, fn.input, 'color', 'tone'));
expect(ports()).toEqual(['tone', 'gray']);
expect(s.build().problems).toEqual({});
f.stack.execute(new RemovePortCommand(fn.id, fn.output, 'gray'));
expect(ports()).toEqual(['tone']);
f.stack.undo();
expect(ports()).toEqual(['tone', 'gray']);
expect(s.build().compiled).not.toBeNull();
});
it('declares numbers and colors only', () => {
const f = makeSpriteScene();
const fn = grayscale(f);
expect(() => f.stack.execute(new AddPortCommand(fn.id, fn.input, { key: 'name', kind: 'text' }))).toThrow(
CommandError,
);
expect(() =>
f.stack.execute(new AddPortCommand(fn.id, fn.output, { key: 'more', kind: 'flow' })),
).toThrow(CommandError);
});
it('reports a function calling itself, directly or through another, and compiles nothing endlessly', () => {
const f = makeSpriteScene();
const a = grayscale(f, 'A');
const b = grayscale(f, 'B');
const lookup = graphLookup(f.project);
const self = a.add(SHADER_CALL, { [FUNCTION_PARAM]: a.id });
expect(
callProblem(
a.asset().graph.nodes.find((n) => n.id === self)!,
lookup,
a.id,
),
).toBe('A function cannot call itself');
a.asset().graph.nodes.splice(
a.asset().graph.nodes.findIndex((n) => n.id === self),
1,
);
const toB = a.add(SHADER_CALL, { [FUNCTION_PARAM]: b.id });
const toA = b.add(SHADER_CALL, { [FUNCTION_PARAM]: a.id });
a.port(a.output, 'twice', 'color');
a.wire(a.input, 'color', toB, 'color');
a.wire(toB, 'gray', a.output, 'twice');
expect(
callProblem(
b.asset().graph.nodes.find((n) => n.id === toA)!,
lookup,
b.id,
),
).toBe('A function cannot call itself');
expect(checkShaderFunction(a.asset().graph, shaderFunctionsOf(f.project), a.id).problems[toB]).toBe(
'In B: A function cannot call itself',
);
const s = calling(f, a.id);
s.link(s.texel, 'color', s.call, 'color');
s.link(s.call, 'gray', s.out, 'color');
const build = s.build();
expect(build.compiled).toBeNull();
expect(build.problems[s.call]).toBe('In A: In B: A function cannot call itself');
expect(paletteEntries(f.project, b.asset()).some((e) => e.id === `${SHADER_CALL}:${a.id}`)).toBe(false);
});
it('feeds an unconnected input of the call, and an unconnected output, with the value typed in', () => {
const f = makeSpriteScene();
const fn = graphOf(f, 'shaderfunction', 'Scale');
const input = fn.add(SHADER_FUNCTION_INPUT);
const output = fn.add(SHADER_FUNCTION_OUTPUT, { spare: 0.25 });
fn.port(input, 'amount', 'number');
fn.port(output, 'result', 'number');
fn.port(output, 'spare', 'number');
fn.link(input, 'amount', output, 'result');
const s = calling(f, fn.id);
s.asset().graph.nodes.find((n) => n.id === s.call)!.params.amount = 0.75;
const add = s.add('ShaderAdd');
s.link(s.call, 'result', add, 'a');
s.link(s.call, 'spare', add, 'b');
s.link(add, 'result', s.out, 'alpha');
const fragment = s.build().compiled!.fragment;
expect(fragment).toMatch(/float n\d+_value = 0\.75;/);
expect(fragment).toMatch(/float n\d+_value = 0\.25;/);
});
it('sets in two calls of one function apart, and a Time or a Pass inside counts for the caller', () => {
const f = makeSpriteScene();
const fn = graphOf(f, 'shaderfunction', 'Wide blur');
const output = fn.add(SHADER_FUNCTION_OUTPUT);
fn.port(output, 'color', 'color');
const pass = fn.add(SHADER_PASS);
const blur = fn.add(SHADER_BLUR);
const time = fn.add(SHADER_TIME);
fn.link(pass, 'picture', blur, 'picture');
fn.link(time, 'value', blur, 'radius');
fn.link(blur, 'color', output, 'color');
const s = calling(f, fn.id);
const second = s.add(SHADER_CALL, { [FUNCTION_PARAM]: fn.id });
const mix = s.add('ShaderMix');
s.link(s.call, 'color', mix, 'a');
s.link(second, 'color', mix, 'b');
s.link(mix, 'result', s.out, 'color');
const compiled = s.build().compiled!;
expect(compiled.passes).toHaveLength(2);
expect(compiled.time).toBe(true);
expect(compiled.fragment.match(/= blur\(/g)).toHaveLength(2);
});
it('reports what inside a function goes wrong at the call, and what has no place in it at the node', () => {
const f = makeSpriteScene();
const fn = grayscale(f);
const property = fn.add(SHADER_PROPERTY);
const own = checkShaderFunction(fn.asset().graph, shaderFunctionsOf(f.project), fn.id);
expect(own.problems[property]).toBe('A function cannot hold Property');
const s = calling(f, fn.id);
s.link(s.call, 'gray', s.out, 'color');
expect(s.build().problems[s.call]).toBe('In Grayscale: A function cannot hold Property');
const lone = graphOf(f, 'shaderfunction', 'Lone');
expect(checkShaderFunction(lone.asset().graph, shaderFunctionsOf(f.project), lone.id).reason).toBe(
'No Function output node',
);
const typed = grayscale(f, 'Typed');
typed.port(typed.output, 'level', 'number');
typed.wire(typed.input, 'color', typed.output, 'level');
expect(checkShaderFunction(typed.asset().graph, shaderFunctionsOf(f.project), typed.id).problems).toEqual(
{
[typed.output]: '"level" needs a number, not a color',
},
);
});
it('offers a function its own ends and no Output, Property or Vertex output, and a shader every function', () => {
const f = makeSpriteScene();
const fn = grayscale(f);
const fresh = graphOf(f, 'shaderfunction', 'Fresh');
const types = (asset: Parameters<typeof paletteEntries>[1]) =>
paletteEntries(f.project, asset).map((e) => e.id);
const inFunction = types(fresh.asset());
expect(inFunction).toEqual(
expect.arrayContaining([SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT, SHADER_TIME]),
);
for (const absent of [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT, SHADER_PROPERTY, `${SHADER_CALL}:${fresh.id}`])
expect(inFunction).not.toContain(absent);
expect(inFunction).toContain(`${SHADER_CALL}:${fn.id}`);
expect(types(fn.asset())).not.toContain(SHADER_FUNCTION_INPUT);
const s = calling(f, fn.id);
const inShader = types(s.asset());
expect(inShader).not.toContain(SHADER_FUNCTION_INPUT);
const entry = paletteEntries(f.project, s.asset()).find((e) => e.id === `${SHADER_CALL}:${fn.id}`)!;
expect(entry.def.label).toBe('Grayscale');
});
it('keeps a function and its declared ports through saving and loading', () => {
const f = makeSpriteScene();
const fn = grayscale(f);
const loaded = deserializeProject(serializeProject(f.project));
const back = findAssetOf(loaded, fn.id, 'shaderfunction')!;
expect(back.graph.nodes.map((n) => [n.type, n.ports?.map((p) => `${p.key}:${p.kind}`) ?? []])).toEqual(
fn.asset().graph.nodes.map((n) => [n.type, n.ports?.map((p) => `${p.key}:${p.kind}`) ?? []]),
);
expect(back.graph.edges).toHaveLength(2);
});
});