game-maker / suite (push) Failing after 11m29s
#63: a shader Property node named "width" quietly took over the sprite row of the same name. isSpriteProp lives in PropertySchema now and is derived from FULL_SCHEMAS.sprite instead of being a second list that drifts. isValidPropertyName rejects those ten names, and the compiler reports it at the node as its own problem: "width" is a sprite property, told apart from "not a word" and from a duplicate. #64: Divide emitted (a / b) and handed NaN to the driver. It emits divide(a, b) now, with the guard in the prologue next to straight(), scalar and per channel: zero divided by zero is zero. That was already the house rule in the behavior and particle graphs - only the shader had never been told. Clamp comes along as a shader node for the same reason: the behavior catalog has had it all along.
736 lines
33 KiB
TypeScript
736 lines
33 KiB
TypeScript
/**
|
||
* A shader is an asset whose graph is GLSL in nodes: `Property` nodes declare the rows a sprite
|
||
* wearing it shows, the compiler turns everything reachable from `Output` into a fragment. The
|
||
* one graph every test builds is the "Adjust colors" recipe (brightness, contrast, saturation).
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import {
|
||
AddNodeCommand,
|
||
CollapseNodesCommand,
|
||
ConnectCommand,
|
||
FoldEdgeCommand,
|
||
MoveNodeCommand,
|
||
SetNodeParamCommand,
|
||
} from '../../src/core/commands/graphCommands';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import type { Graph } from '../../src/core/graph';
|
||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||
import type { ShaderAsset } from '../../src/core/model/Asset';
|
||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||
import { findAssetOf } from '../../src/core/model/Project';
|
||
import { assetDeviates, objectDeviates } from '../../src/core/properties/deviation';
|
||
import {
|
||
allShaderParamRows,
|
||
schemaFor,
|
||
schemaForAsset,
|
||
schemaForObject,
|
||
} from '../../src/core/properties/PropertySchema';
|
||
import { resolveSceneObjectProps } from '../../src/core/properties/resolve';
|
||
import {
|
||
nextPropertyName,
|
||
SHADER_KEY,
|
||
SHADER_PROPERTY,
|
||
shaderParamDefs,
|
||
} from '../../src/core/properties/shaders';
|
||
import { inlineInputs } from '../../src/core/graph/NodeRegistry';
|
||
import { compileShader, shaderFingerprint } from '../../src/core/shader/compile';
|
||
import {
|
||
DEFAULT_EDGE,
|
||
EDGE_PARAM,
|
||
SHADER_NODES,
|
||
SHADER_NUMBER,
|
||
SHADER_OUTPUT,
|
||
SHADER_SAMPLE,
|
||
SHADER_TEXTURE_ALPHA,
|
||
SHADER_TEXTURE_COLOR,
|
||
SHADER_TEXTURE_SIZE,
|
||
SHADER_TIME,
|
||
SHADER_UV,
|
||
ShaderOutput,
|
||
ShaderSample,
|
||
SHEET_EDGE,
|
||
} from '../../src/core/shader/nodes';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||
import { makeProject, makeSpriteScene, place, type ProjectFixture, roundTrip } from '../helpers';
|
||
|
||
/** Command-driven editing of a shader asset's graph, the way the tab does it. */
|
||
function shaderEditor(f: ProjectFixture, name = 'Adjust colors') {
|
||
const create = new CreateAssetCommand('shader', name);
|
||
f.stack.execute(create);
|
||
const id = create.asset.id;
|
||
const asset = () => findAssetOf(f.project, id, 'shader')!;
|
||
const add = (type: string, params = {}) => {
|
||
const command = new AddNodeCommand(id, type, 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 }));
|
||
return { id, asset, add, link };
|
||
}
|
||
|
||
const property = (name: string, value = 1, min = 0, max = 4) => ({ name, default: value, min, max });
|
||
|
||
/**
|
||
* rgb += brightness - 1; rgb = (rgb - 0.5) * contrast + 0.5; rgb = mix(luma, rgb, saturation) –
|
||
* twelve nodes, the recipe `tools/lib/shaders.mjs` ships.
|
||
*/
|
||
function buildAdjustColors(s: ReturnType<typeof shaderEditor>) {
|
||
const texel = s.add(SHADER_TEXTURE_COLOR);
|
||
const brightness = s.add(SHADER_PROPERTY, property('brightness'));
|
||
const contrast = s.add(SHADER_PROPERTY, property('contrast'));
|
||
const saturation = s.add(SHADER_PROPERTY, property('saturation'));
|
||
const offset = s.add('ShaderSubtract', { b: 1 });
|
||
const bright = s.add('ShaderAdd');
|
||
const shifted = s.add('ShaderSubtract', { b: 0.5 });
|
||
const scaled = s.add('ShaderMultiply');
|
||
const centred = s.add('ShaderAdd', { b: 0.5 });
|
||
const luma = s.add('ShaderLuminance');
|
||
const mixed = s.add('ShaderMix');
|
||
const output = s.add(SHADER_OUTPUT);
|
||
s.link(texel, 'color', bright, 'a');
|
||
s.link(brightness, 'value', offset, 'a');
|
||
s.link(offset, 'result', bright, 'b');
|
||
s.link(bright, 'result', shifted, 'a');
|
||
s.link(shifted, 'result', scaled, 'a');
|
||
s.link(contrast, 'value', scaled, 'b');
|
||
s.link(scaled, 'result', centred, 'a');
|
||
s.link(centred, 'result', luma, 'color');
|
||
s.link(luma, 'value', mixed, 'a');
|
||
s.link(centred, 'result', mixed, 'b');
|
||
s.link(saturation, 'value', mixed, 't');
|
||
s.link(mixed, 'result', output, 'color');
|
||
return {
|
||
texel,
|
||
brightness,
|
||
contrast,
|
||
saturation,
|
||
offset,
|
||
bright,
|
||
shifted,
|
||
scaled,
|
||
centred,
|
||
luma,
|
||
mixed,
|
||
output,
|
||
};
|
||
}
|
||
|
||
describe('a shader asset', () => {
|
||
it('is created with an empty graph, is not placeable, and is a graph asset for the commands', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f);
|
||
expect(isPlaceableType('shader')).toBe(false);
|
||
expect(s.asset().graph).toEqual({ nodes: [], edges: [] });
|
||
expect(schemaFor('shader')).toEqual([]);
|
||
expect(compileShader(s.asset().graph).reason).toBe('No Output node');
|
||
});
|
||
|
||
it('offers shader nodes and nothing else, the Output only once, properties numbered', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f);
|
||
const offered = () => paletteEntries(f.project, s.asset()).map((e) => e.def.type);
|
||
expect(offered()).toEqual(SHADER_NODES.map((d) => d.type));
|
||
expect(offered()).not.toContain('OnStart');
|
||
s.add(SHADER_OUTPUT);
|
||
expect(offered()).not.toContain(SHADER_OUTPUT);
|
||
const entry = () => paletteEntries(f.project, s.asset()).find((e) => e.def.type === SHADER_PROPERTY)!;
|
||
expect(entry().params).toEqual({ name: 'value' });
|
||
s.add(SHADER_PROPERTY, entry().params);
|
||
expect(nextPropertyName(s.asset().graph)).toBe('value2');
|
||
expect(entry().params).toEqual({ name: 'value2' });
|
||
});
|
||
|
||
it('keeps its nodes out of every behavior palette and out of the built-in set', () => {
|
||
const f = makeProject();
|
||
const behavior = new CreateAssetCommand('behavior', 'B');
|
||
f.stack.execute(behavior);
|
||
const asset = findAssetOf(f.project, behavior.asset.id, 'behavior')!;
|
||
const offered = paletteEntries(f.project, asset).map((e) => e.def.type);
|
||
for (const def of SHADER_NODES) expect(offered).not.toContain(def.type);
|
||
expect(BUILT_IN_NODES.some((d) => d.category === 'shader')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('shaderParamDefs', () => {
|
||
it('turns every well-named Property node into a number row, in node order', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f);
|
||
s.add(SHADER_PROPERTY, property('brightness'));
|
||
s.add(SHADER_PROPERTY, { name: 'amount', default: 0.25, min: 0, max: 1 });
|
||
s.add(SHADER_PROPERTY, { name: 'not a name', default: 1 });
|
||
s.add(SHADER_PROPERTY, { name: 'brightness', default: 7 });
|
||
const rows = shaderParamDefs(s.asset().graph);
|
||
expect(rows.map((r) => r.key)).toEqual(['brightness', 'amount']);
|
||
expect(rows[0]).toEqual({
|
||
key: 'brightness',
|
||
label: 'brightness',
|
||
kind: 'number',
|
||
default: 1,
|
||
min: 0,
|
||
max: 4,
|
||
step: 0.05,
|
||
});
|
||
expect(rows[1]).toMatchObject({ default: 0.25, min: 0, max: 1, step: 0.05 });
|
||
});
|
||
|
||
it('drops a max at or below the min instead of pinning the value', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f);
|
||
s.add(SHADER_PROPERTY, { name: 'wide', default: 50, min: 10, max: 10 });
|
||
const [row] = shaderParamDefs(s.asset().graph);
|
||
expect(row).toMatchObject({ min: 10, default: 50 });
|
||
expect(row).not.toHaveProperty('max');
|
||
});
|
||
});
|
||
|
||
describe('allShaderParamRows', () => {
|
||
it('offers one row per name, naming every shader that declares it', () => {
|
||
const f = makeProject();
|
||
const adjust = shaderEditor(f, 'Adjust colors');
|
||
adjust.add(SHADER_PROPERTY, property('brightness'));
|
||
const neon = shaderEditor(f, 'Neon glow');
|
||
neon.add(SHADER_PROPERTY, property('brightness'));
|
||
neon.add(SHADER_PROPERTY, property('glow'));
|
||
expect(allShaderParamRows(f.project).map((row) => `${row.def.key} (${row.shaders.join(', ')})`)).toEqual([
|
||
'brightness (Adjust colors, Neon glow)',
|
||
'glow (Neon glow)',
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('compileShader', () => {
|
||
it('compiles the Adjust colors graph to a fragment with one uniform per property', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f);
|
||
buildAdjustColors(s);
|
||
const build = compileShader(s.asset().graph);
|
||
expect(build.problems).toEqual({});
|
||
expect(build.reason).toBe('');
|
||
const compiled = build.compiled!;
|
||
expect(compiled.uniforms).toEqual([
|
||
{ name: 'brightness', default: 1 },
|
||
{ name: 'contrast', default: 1 },
|
||
{ name: 'saturation', default: 1 },
|
||
]);
|
||
const { fragment } = compiled;
|
||
for (const name of ['brightness', 'contrast', 'saturation'])
|
||
expect(fragment).toContain(`uniform float u_${name};`);
|
||
// Drawn in the sprite's own pass: the tint goes in before the graph, premultiplied like the texel.
|
||
expect(fragment).toContain('vec4 texel = texture(uTexture, vTextureCoord) * vColor;');
|
||
expect(fragment).toContain('vec3 straight(vec4 c) { return c.a > 0.0 ? c.rgb / c.a : c.rgb; }');
|
||
expect(fragment).toContain('vec3 rgb = straight(texel);');
|
||
expect(compiled).toMatchObject({ time: false, edge: 'mirror' });
|
||
expect(fragment).toMatch(/float n\d+_result = \(n\d+_value - 1\.0\);/);
|
||
expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_color \+ vec3\(n\d+_result\)\);/);
|
||
expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_result - vec3\(0\.5\)\);/);
|
||
expect(fragment).toMatch(/float n\d+_value = dot\(n\d+_result, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/);
|
||
expect(fragment).toMatch(/vec3 n\d+_result = mix\(vec3\(n\d+_value\), n\d+_result, n\d+_value\);/);
|
||
expect(fragment).toContain('float alpha = clamp(texel.a, 0.0, 1.0);');
|
||
expect(fragment).toMatch(/finalColor = vec4\(clamp\(n\d+_result, 0\.0, 1\.0\) \* alpha, alpha\);/);
|
||
// Each local is declared before it is used.
|
||
const declared = new Set<string>();
|
||
for (const line of fragment.split('\n')) {
|
||
const m = /^\s+(?:float|vec3) (n\d+_\w+) = (.*);$/.exec(line);
|
||
if (!m) continue;
|
||
for (const used of m[2]!.match(/n\d+_\w+/g) ?? []) expect(declared.has(used)).toBe(true);
|
||
declared.add(m[1]!);
|
||
}
|
||
});
|
||
|
||
it('passes the texel through an Output with nothing connected, and types literals as floats', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f);
|
||
s.add(SHADER_OUTPUT);
|
||
expect(compileShader(s.asset().graph).compiled!.fragment).toContain('clamp(rgb, 0.0, 1.0) * alpha');
|
||
|
||
const g = shaderEditor(f, 'Scaled');
|
||
const times = g.add('ShaderMultiply', { a: 2, b: 0.75 });
|
||
const output = g.add(SHADER_OUTPUT);
|
||
g.link(times, 'result', output, 'color');
|
||
const { fragment } = compileShader(g.asset().graph).compiled!;
|
||
// Two numbers make a number; feeding it to the colour broadcasts it.
|
||
expect(fragment).toMatch(/float n0_result = \(2\.0 \* 0\.75\);/);
|
||
expect(fragment).toContain('clamp(vec3(n0_result), 0.0, 1.0)');
|
||
|
||
const c = shaderEditor(f, 'Tinted');
|
||
const tint = c.add('ShaderColor', { value: 0xff8000 });
|
||
const times2 = c.add('ShaderMultiply');
|
||
const out2 = c.add(SHADER_OUTPUT);
|
||
c.link(tint, 'color', times2, 'b');
|
||
c.link(times2, 'result', out2, 'color');
|
||
const tinted = compileShader(c.asset().graph).compiled!.fragment;
|
||
expect(tinted).toContain('vec3 n0_color = vec3(1.0000, 0.5020, 0.0000);');
|
||
// An unconnected `a` is its typed-in default, 1 for a Multiply, lifted to a colour.
|
||
expect(tinted).toMatch(/vec3 n1_result = \(vec3\(1\.0\) \* n0_color\);/);
|
||
});
|
||
|
||
it('turns a Number node into one float literal: decimal point kept, sign kept, NaN made 0', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f);
|
||
const half = s.add(SHADER_NUMBER, { value: 0.5 });
|
||
const two = s.add(SHADER_NUMBER, { value: 2 });
|
||
const minus = s.add(SHADER_NUMBER, { value: -0.25 });
|
||
const broken = s.add(SHADER_NUMBER, { value: Number.NaN });
|
||
const sum = s.add('ShaderAdd');
|
||
const times = s.add('ShaderMultiply');
|
||
const diff = s.add('ShaderSubtract');
|
||
const output = s.add(SHADER_OUTPUT);
|
||
s.link(half, 'value', sum, 'a');
|
||
s.link(two, 'value', sum, 'b');
|
||
s.link(sum, 'result', times, 'a');
|
||
s.link(minus, 'value', times, 'b');
|
||
s.link(times, 'result', diff, 'a');
|
||
s.link(broken, 'value', diff, 'b');
|
||
s.link(diff, 'result', output, 'color');
|
||
const { fragment } = compileShader(s.asset().graph).compiled!;
|
||
// Locals are numbered in visiting order; the literal's form is what matters here.
|
||
expect(fragment).toMatch(/float n\d+_value = 0\.5;/);
|
||
expect(fragment).toMatch(/float n\d+_value = 2\.0;/);
|
||
expect(fragment).toMatch(/float n\d+_value = -0\.25;/);
|
||
expect(fragment).toMatch(/float n\d+_value = 0\.0;/);
|
||
expect(fragment).toMatch(/float n\d+_result = \(n\d+_value \+ n\d+_value\);/);
|
||
expect(fragment).toMatch(/clamp\(vec3\(n\d+_result\), 0\.0, 1\.0\)/);
|
||
expect(fragment).not.toContain('uniform float u_');
|
||
});
|
||
|
||
it('keeps the texture’s alpha until the Output’s alpha is fed, then clamps what feeds it', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f);
|
||
const alpha = s.add(SHADER_TEXTURE_ALPHA);
|
||
const opacity = s.add(SHADER_PROPERTY, { name: 'opacity', default: 1, min: 0, max: 1 });
|
||
const times = s.add('ShaderMultiply');
|
||
const output = s.add(SHADER_OUTPUT);
|
||
// Nothing to type on the alpha port: a typed-in 0 would hide every sprite by default.
|
||
expect(inlineInputs(ShaderOutput).map((p) => p.key)).toEqual([]);
|
||
let { fragment } = compileShader(s.asset().graph).compiled!;
|
||
expect(fragment).toContain('float alpha = clamp(texel.a, 0.0, 1.0);');
|
||
expect(fragment).toContain('finalColor = vec4(clamp(rgb, 0.0, 1.0) * alpha, alpha);');
|
||
|
||
s.link(alpha, 'value', times, 'a');
|
||
s.link(opacity, 'value', times, 'b');
|
||
s.link(times, 'result', output, 'alpha');
|
||
({ fragment } = compileShader(s.asset().graph).compiled!);
|
||
expect(fragment).toMatch(/float n0_value = texel\.a;/);
|
||
expect(fragment).toMatch(/float n1_value = u_opacity;/);
|
||
expect(fragment).toMatch(/float n2_result = \(n0_value \* n1_value\);/);
|
||
expect(fragment).toContain('float alpha = clamp(n2_result, 0.0, 1.0);');
|
||
expect(fragment).toContain('finalColor = vec4(clamp(rgb, 0.0, 1.0) * alpha, alpha);');
|
||
|
||
// A colour reaching the alpha port through an `any` result is the Output's problem.
|
||
const wrong = shaderEditor(f, 'Wrong');
|
||
const texel = wrong.add(SHADER_TEXTURE_COLOR);
|
||
const doubled = wrong.add('ShaderMultiply', { b: 2 });
|
||
const out = wrong.add(SHADER_OUTPUT);
|
||
wrong.link(texel, 'color', doubled, 'a');
|
||
wrong.link(doubled, 'result', out, 'alpha');
|
||
const build = compileShader(wrong.asset().graph);
|
||
expect(build.compiled).toBeNull();
|
||
expect(build.problems).toEqual({ [out]: '"alpha" needs a number, not a colour' });
|
||
});
|
||
|
||
it('names the problem on the node, and compiles nothing while there is one', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f);
|
||
// A colour cannot be wired to a number port; a Multiply's `any` result can, and carries one.
|
||
const texel = s.add(SHADER_TEXTURE_COLOR);
|
||
const doubled = s.add('ShaderMultiply', { b: 2 });
|
||
const mixed = s.add('ShaderMix');
|
||
const output = s.add(SHADER_OUTPUT);
|
||
s.link(texel, 'color', doubled, 'a');
|
||
s.link(doubled, 'result', mixed, 't');
|
||
s.link(mixed, 'result', output, 'color');
|
||
let build = compileShader(s.asset().graph);
|
||
expect(build.compiled).toBeNull();
|
||
expect(build.problems).toEqual({ [mixed]: '"t" needs a number, not a colour' });
|
||
expect(build.reason).toBe('"t" needs a number, not a colour');
|
||
|
||
const dup = shaderEditor(f, 'Dup');
|
||
dup.add(SHADER_OUTPUT);
|
||
const first = dup.add(SHADER_PROPERTY, property('amount'));
|
||
const second = dup.add(SHADER_PROPERTY, property('amount'));
|
||
const odd = dup.add(SHADER_PROPERTY, property('2fast'));
|
||
build = compileShader(dup.asset().graph);
|
||
expect(build.compiled).toBeNull();
|
||
expect(build.problems).toEqual({
|
||
[second]: 'Another property is named "amount"',
|
||
[odd]: 'Name must be one word: letters, digits, _',
|
||
});
|
||
expect(build.problems[first]).toBeUndefined();
|
||
|
||
// A node cannot be wired to itself, but two can be wired into a ring.
|
||
const loop = shaderEditor(f, 'Loop');
|
||
const one = loop.add('ShaderAdd');
|
||
const two = loop.add('ShaderAdd');
|
||
const out = loop.add(SHADER_OUTPUT);
|
||
loop.link(one, 'result', two, 'a');
|
||
loop.link(two, 'result', one, 'b');
|
||
loop.link(two, 'result', out, 'color');
|
||
build = compileShader(loop.asset().graph);
|
||
expect(build.compiled).toBeNull();
|
||
expect(build.problems).toEqual({ [two]: 'Feeds itself' });
|
||
});
|
||
|
||
it('refuses a property named like a row the sprite brings itself (#63)', () => {
|
||
const f = makeSpriteScene();
|
||
const s = shaderEditor(f);
|
||
s.add(SHADER_OUTPUT);
|
||
// One of the sprite's own rows, one of the transform rows, one legitimate name.
|
||
const wide = s.add(SHADER_PROPERTY, property('width'));
|
||
const turned = s.add(SHADER_PROPERTY, property('rotation'));
|
||
const fine = s.add(SHADER_PROPERTY, property('amount'));
|
||
const build = compileShader(s.asset().graph);
|
||
expect(build.compiled).toBeNull();
|
||
expect(build.problems).toEqual({
|
||
[wide]: '"width" is a sprite property',
|
||
[turned]: '"rotation" is a sprite property',
|
||
});
|
||
expect(build.problems[fine]).toBeUndefined();
|
||
// The row is not declared either, so the sprite keeps one width row – the one that is 100.
|
||
expect(shaderParamDefs(s.asset().graph).map((r) => r.key)).toEqual(['amount']);
|
||
const sprite = findAssetOf(f.project, f.spriteId, 'sprite')!;
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, s.id));
|
||
const rows = schemaForAsset(f.project, sprite).filter((d) => d.key === 'width');
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0]).toMatchObject({ label: 'Width', default: 100 });
|
||
});
|
||
|
||
it('divides through a guard, so dividing by zero is 0 and not the driver’s choice (#64)', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f);
|
||
const zero = s.add(SHADER_NUMBER, { value: 0 });
|
||
const half = s.add('ShaderDivide', { a: 1 });
|
||
const held = s.add('ShaderClamp', { min: 0, max: 0.5 });
|
||
const output = s.add(SHADER_OUTPUT);
|
||
s.link(zero, 'value', half, 'b');
|
||
s.link(half, 'result', held, 'value');
|
||
s.link(held, 'result', output, 'color');
|
||
const { fragment } = compileShader(s.asset().graph).compiled!;
|
||
expect(fragment).toContain('float divide(float a, float b) { return b == 0.0 ? 0.0 : a / b; }');
|
||
expect(fragment).toMatch(/float n\d+_result = divide\(1\.0, n\d+_value\);/);
|
||
expect(fragment).toMatch(/float n\d+_result = clamp\(n\d+_result, 0\.0, 0\.5\);/);
|
||
// Nothing writes the bare operator any more – that was the NaN (#64).
|
||
expect(fragment).not.toMatch(/n\d+_\w+ \/ n\d+_\w+/);
|
||
|
||
// A colour divides per channel, through the vec3 overload of the same guard.
|
||
const c = shaderEditor(f, 'Faded');
|
||
const texel = c.add(SHADER_TEXTURE_COLOR);
|
||
const by = c.add('ShaderDivide', { b: 2 });
|
||
const out = c.add(SHADER_OUTPUT);
|
||
c.link(texel, 'color', by, 'a');
|
||
c.link(by, 'result', out, 'color');
|
||
const coloured = compileShader(c.asset().graph).compiled!.fragment;
|
||
expect(coloured).toContain(
|
||
'vec3 divide(vec3 a, vec3 b) { return vec3(divide(a.x, b.x), divide(a.y, b.y), divide(a.z, b.z)); }',
|
||
);
|
||
expect(coloured).toMatch(/vec3 n\d+_result = divide\(n\d+_color, vec3\(2\.0\)\);/);
|
||
// A clamped colour keeps its type: clamp(vec3, float, float) is one of the GLSL overloads.
|
||
const k = shaderEditor(f, 'Held');
|
||
const rgb = k.add(SHADER_TEXTURE_COLOR);
|
||
const clamped = k.add('ShaderClamp', { min: 0.25, max: 0.75 });
|
||
const out2 = k.add(SHADER_OUTPUT);
|
||
k.link(rgb, 'color', clamped, 'value');
|
||
k.link(clamped, 'result', out2, 'color');
|
||
expect(compileShader(k.asset().graph).compiled!.fragment).toMatch(
|
||
/vec3 n\d+_result = clamp\(n\d+_color, 0\.25, 0\.75\);/,
|
||
);
|
||
});
|
||
|
||
it('gives the graph the pixel’s place, the picture at another spot, its size and the clock', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f, 'Flip');
|
||
const uv = s.add(SHADER_UV);
|
||
const flipped = s.add('ShaderSubtract', { a: 1 });
|
||
const sample = s.add(SHADER_SAMPLE);
|
||
const size = s.add(SHADER_TEXTURE_SIZE);
|
||
const time = s.add(SHADER_TIME);
|
||
const wave = s.add('ShaderSin');
|
||
const sum = s.add('ShaderAdd');
|
||
const output = s.add(SHADER_OUTPUT, { edge: 'repeat' });
|
||
s.link(uv, 'u', flipped, 'b');
|
||
s.link(flipped, 'result', sample, 'u');
|
||
s.link(sample, 'color', output, 'color');
|
||
s.link(time, 'value', wave, 'value');
|
||
s.link(size, 'width', sum, 'a');
|
||
s.link(wave, 'result', sum, 'b');
|
||
s.link(sum, 'result', output, 'alpha');
|
||
// Nothing to type on u and v: unconnected, each is the pixel's own, and the node is Texture color.
|
||
expect(inlineInputs(ShaderSample)).toEqual([]);
|
||
const build = compileShader(s.asset().graph);
|
||
expect(build.problems).toEqual({});
|
||
const compiled = build.compiled!;
|
||
expect(compiled).toMatchObject({ time: true, edge: 'repeat', uniforms: [] });
|
||
const { fragment } = compiled;
|
||
expect(fragment).toContain('uniform float uTime;');
|
||
expect(fragment).toMatch(/float n\d+_u = vTextureCoord\.x;/);
|
||
expect(fragment).toMatch(/float n\d+_result = \(1\.0 - n\d+_u\);/);
|
||
expect(fragment).toMatch(
|
||
/vec3 n\d+_color = straight\(texture\(uTexture, vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\);/,
|
||
);
|
||
expect(fragment).toMatch(
|
||
/float n\d+_alpha = \(texture\(uTexture, vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\)\.a;/,
|
||
);
|
||
expect(fragment).toMatch(/float n\d+_width = float\(textureSize\(uTexture, 0\)\.x\);/);
|
||
expect(fragment).toMatch(/float n\d+_value = uTime;/);
|
||
expect(fragment).toMatch(/float n\d+_result = sin\(n\d+_value\);/);
|
||
});
|
||
|
||
it('takes the clock only for a Time node the Output reaches, and the Edge from the Output', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f, 'Idle');
|
||
s.add(SHADER_TIME);
|
||
const out = s.add(SHADER_OUTPUT);
|
||
const params = s.asset().graph.nodes.find((n) => n.id === out)!.params;
|
||
// A new Output carries the default, so a graph never silently changes when the default does.
|
||
expect(params[EDGE_PARAM]).toBe('clamp');
|
||
let compiled = compileShader(s.asset().graph).compiled!;
|
||
expect(compiled).toMatchObject({ time: false, edge: SHEET_EDGE });
|
||
expect(compiled.fragment).not.toContain('uTime');
|
||
// Nothing reads past the border here, so no mode can show – and none costs a second picture.
|
||
params[EDGE_PARAM] = 'repeat';
|
||
expect(compileShader(s.asset().graph).compiled!.edge).toBe(SHEET_EDGE);
|
||
const uv = s.add(SHADER_UV);
|
||
const shift = s.add('ShaderAdd', { b: 0.5 });
|
||
const sample = s.add(SHADER_SAMPLE);
|
||
s.link(uv, 'u', shift, 'a');
|
||
s.link(shift, 'result', sample, 'u');
|
||
s.link(sample, 'color', out, 'color');
|
||
expect(compileShader(s.asset().graph).compiled!.edge).toBe('repeat');
|
||
// A value the dropdown never offers reads as the default rather than as a mode.
|
||
params[EDGE_PARAM] = 'sideways';
|
||
compiled = compileShader(s.asset().graph).compiled!;
|
||
expect(compiled.edge).toBe(DEFAULT_EDGE);
|
||
// A graph from before the setting existed has no value at all and takes the default too (#81).
|
||
delete params[EDGE_PARAM];
|
||
expect(compileShader(s.asset().graph).compiled!.edge).toBe('clamp');
|
||
});
|
||
|
||
it('writes Fract, Step and Floor as the GLSL calls, per channel for a colour', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f, 'Lines');
|
||
const uv = s.add(SHADER_UV);
|
||
const scaled = s.add('ShaderMultiply', { b: 4 });
|
||
const saw = s.add('ShaderFract');
|
||
const cut = s.add('ShaderStep', { edge: 0.5 });
|
||
const tint = s.add('ShaderColor', { value: 0xff8000 });
|
||
const whole = s.add('ShaderFloor');
|
||
const output = s.add(SHADER_OUTPUT);
|
||
s.link(uv, 'v', scaled, 'a');
|
||
s.link(scaled, 'result', saw, 'value');
|
||
s.link(saw, 'result', cut, 'value');
|
||
s.link(cut, 'result', output, 'alpha');
|
||
s.link(tint, 'color', whole, 'value');
|
||
s.link(whole, 'result', output, 'color');
|
||
const build = compileShader(s.asset().graph);
|
||
expect(build.problems).toEqual({});
|
||
const { fragment } = build.compiled!;
|
||
expect(fragment).toMatch(/float n\d+_result = \(n\d+_v \* 4\.0\);/);
|
||
expect(fragment).toMatch(/float n\d+_result = fract\(n\d+_result\);/);
|
||
expect(fragment).toMatch(/float n\d+_result = step\(0\.5, n\d+_result\);/);
|
||
expect(fragment).toMatch(/vec3 n\d+_result = floor\(n\d+_color\);/);
|
||
expect(fragment).toMatch(/float alpha = clamp\(n\d+_result, 0\.0, 1\.0\);/);
|
||
});
|
||
|
||
it('writes Pow as the GLSL call with the base held at 0, the exponent broadcast for a colour', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f, 'Gamma');
|
||
const uv = s.add(SHADER_UV);
|
||
const eased = s.add('ShaderPow', { exponent: 2 });
|
||
const tint = s.add('ShaderColor', { value: 0xff8000 });
|
||
const gamma = s.add('ShaderPow', { exponent: 2.2 });
|
||
const output = s.add(SHADER_OUTPUT);
|
||
s.link(uv, 'u', eased, 'value');
|
||
s.link(eased, 'result', output, 'alpha');
|
||
s.link(tint, 'color', gamma, 'value');
|
||
s.link(gamma, 'result', output, 'color');
|
||
const build = compileShader(s.asset().graph);
|
||
expect(build.problems).toEqual({});
|
||
const { fragment } = build.compiled!;
|
||
expect(fragment).toMatch(/float n\d+_result = pow\(max\(n\d+_u, 0\.0\), 2\.0\);/);
|
||
expect(fragment).toMatch(/vec3 n\d+_result = pow\(max\(n\d+_color, 0\.0\), vec3\(2\.2\)\);/);
|
||
});
|
||
|
||
it('writes Sqrt as the GLSL call with the value held at 0, per channel for a colour', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f, 'Root');
|
||
const uv = s.add(SHADER_UV);
|
||
const root = s.add('ShaderSqrt');
|
||
const tint = s.add('ShaderColor', { value: 0xff8000 });
|
||
const rolled = s.add('ShaderSqrt');
|
||
const output = s.add(SHADER_OUTPUT);
|
||
s.link(uv, 'u', root, 'value');
|
||
s.link(root, 'result', output, 'alpha');
|
||
s.link(tint, 'color', rolled, 'value');
|
||
s.link(rolled, 'result', output, 'color');
|
||
const build = compileShader(s.asset().graph);
|
||
expect(build.problems).toEqual({});
|
||
const { fragment } = build.compiled!;
|
||
expect(fragment).toMatch(/float n\d+_result = sqrt\(max\(n\d+_u, 0\.0\)\);/);
|
||
expect(fragment).toMatch(/vec3 n\d+_result = sqrt\(max\(n\d+_color, 0\.0\)\);/);
|
||
});
|
||
|
||
it('writes Abs as the GLSL call, per channel for a colour', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f, 'Fold');
|
||
const uv = s.add(SHADER_UV);
|
||
const centred = s.add('ShaderSubtract', { b: 0.5 });
|
||
const folded = s.add('ShaderAbs');
|
||
const tint = s.add('ShaderColor', { value: 0xff8000 });
|
||
const magnitude = s.add('ShaderAbs');
|
||
const output = s.add(SHADER_OUTPUT);
|
||
s.link(uv, 'u', centred, 'a');
|
||
s.link(centred, 'result', folded, 'value');
|
||
s.link(folded, 'result', output, 'alpha');
|
||
s.link(tint, 'color', magnitude, 'value');
|
||
s.link(magnitude, 'result', output, 'color');
|
||
const build = compileShader(s.asset().graph);
|
||
expect(build.problems).toEqual({});
|
||
const { fragment } = build.compiled!;
|
||
expect(fragment).toMatch(/float n\d+_result = abs\(n\d+_result\);/);
|
||
expect(fragment).toMatch(/vec3 n\d+_result = abs\(n\d+_color\);/);
|
||
});
|
||
});
|
||
|
||
describe('a sprite wearing a shader', () => {
|
||
function wearing() {
|
||
const f = makeSpriteScene();
|
||
const s = shaderEditor(f);
|
||
buildAdjustColors(s);
|
||
const sprite = () => findAssetOf(f.project, f.spriteId, 'sprite')!;
|
||
return { ...f, s, sprite };
|
||
}
|
||
|
||
it('has the shader picker and, once one is picked, that shader’s rows', () => {
|
||
const f = wearing();
|
||
const keys = () => schemaForAsset(f.project, f.sprite()).map((d) => d.key);
|
||
expect(keys()).toEqual([
|
||
'enabled',
|
||
'scale',
|
||
'rotation',
|
||
'mirrorX',
|
||
'mirrorY',
|
||
'width',
|
||
'height',
|
||
'color',
|
||
'texture',
|
||
'shader',
|
||
]);
|
||
expect(schemaFor('sprite').find((d) => d.key === SHADER_KEY)).toMatchObject({
|
||
kind: 'asset',
|
||
assetTypes: ['shader'],
|
||
default: '',
|
||
});
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, f.s.id));
|
||
expect(keys()).toEqual([
|
||
'enabled',
|
||
'scale',
|
||
'rotation',
|
||
'mirrorX',
|
||
'mirrorY',
|
||
'width',
|
||
'height',
|
||
'color',
|
||
'texture',
|
||
'shader',
|
||
'brightness',
|
||
'contrast',
|
||
'saturation',
|
||
]);
|
||
expect(assetDeviates(f.project, f.sprite())).toBe(true);
|
||
// A dangling id adds no rows and draws nothing – the picker still shows it.
|
||
f.sprite().props[SHADER_KEY] = 'gone';
|
||
expect(keys()).toHaveLength(10);
|
||
});
|
||
|
||
it('gives a placed object the rows of the shader it overrides, filled with the node defaults', () => {
|
||
const f = wearing();
|
||
const id = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const object = () => f.object(id);
|
||
expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(10);
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, f.s.id));
|
||
expect(schemaForObject(f.project, f.sprite(), object()).map((d) => d.key)).toContain('saturation');
|
||
expect(resolveSceneObjectProps(f.project, f.sprite(), object())).toMatchObject({
|
||
shader: f.s.id,
|
||
brightness: 1,
|
||
contrast: 1,
|
||
saturation: 1,
|
||
});
|
||
// A parameter override passes through its row: clamped to the node's range.
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'brightness', 9));
|
||
expect(object().overrides.brightness).toBe(4);
|
||
expect(objectDeviates(f.project, f.sceneOf(), object())).toBe(true);
|
||
// Back to no shader the parameter row is gone, so the stored value marks nothing.
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, ''));
|
||
expect(resolveSceneObjectProps(f.project, f.sprite(), object())).not.toHaveProperty('brightness');
|
||
expect(objectDeviates(f.project, f.sceneOf(), object())).toBe(false);
|
||
});
|
||
|
||
it('rejects a parameter no shader of the sprite declares', () => {
|
||
const f = wearing();
|
||
const id = place(f, f.spriteId, { x: 0, y: 0 });
|
||
expect(() => f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'brightness', 0.5))).toThrow(
|
||
/not defined/,
|
||
);
|
||
});
|
||
|
||
it('survives a round trip: the graph, the sprite’s values and an override-only shader’s values', () => {
|
||
const f = wearing();
|
||
const other = shaderEditor(f, 'Fade');
|
||
other.add(SHADER_OUTPUT);
|
||
other.add(SHADER_PROPERTY, { name: 'amount', default: 0.5, min: 0, max: 1 });
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, f.s.id));
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'contrast', 2));
|
||
const id = place(f, f.spriteId, { x: 0, y: 0 });
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, other.id));
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'amount', 0.25));
|
||
|
||
const loaded = roundTrip(f.project);
|
||
const shader = findAssetOf(loaded, f.s.id, 'shader')!;
|
||
expect(shader.graph).toEqual(f.s.asset().graph);
|
||
expect(compileShader(shader.graph).compiled!.fragment).toBe(
|
||
compileShader(f.s.asset().graph).compiled!.fragment,
|
||
);
|
||
const sprite = findAssetOf(loaded, f.spriteId, 'sprite')!;
|
||
expect(sprite.props).toMatchObject({ shader: f.s.id, contrast: 2, brightness: 1 });
|
||
const placed = findAssetOf(loaded, f.sceneId, 'scene')!.objects.find((o) => o.id === id)!;
|
||
expect(placed.overrides).toEqual({ shader: other.id, amount: 0.25 });
|
||
});
|
||
|
||
it('drops anything but shader nodes from a shader graph on load', () => {
|
||
const graph: Graph = {
|
||
nodes: [
|
||
{ id: 'o', type: SHADER_OUTPUT, x: 0, y: 0, params: {} },
|
||
{ id: 'e', type: 'OnStart', x: 0, y: 0, params: {} },
|
||
],
|
||
edges: [],
|
||
};
|
||
const loaded = deserializeProject(
|
||
{ id: 'p', name: 'P', assets: [{ id: 'sh', type: 'shader', name: 'S', props: {}, graph }] },
|
||
'p',
|
||
);
|
||
const shader = loaded.assets[0] as ShaderAsset;
|
||
expect(shader.graph.nodes.map((n) => n.id)).toEqual(['o']);
|
||
});
|
||
});
|
||
|
||
describe('the fingerprint the shader library caches on', () => {
|
||
it('ignores a move, a collapse and a fold, and changes with a param or an edge', () => {
|
||
const f = makeProject();
|
||
const s = shaderEditor(f);
|
||
const n = buildAdjustColors(s);
|
||
const before = shaderFingerprint(s.asset().graph);
|
||
f.stack.execute(new MoveNodeCommand(s.id, n.bright, 300, 200));
|
||
f.stack.execute(new CollapseNodesCommand(s.id, [n.bright, n.luma], true));
|
||
f.stack.execute(new FoldEdgeCommand(s.id, s.asset().graph.edges[0]!.id, true));
|
||
expect(s.asset().graph.nodes.find((x) => x.id === n.bright)).toMatchObject({ x: 300, collapsed: true });
|
||
expect(shaderFingerprint(s.asset().graph)).toBe(before);
|
||
f.stack.execute(new SetNodeParamCommand(s.id, n.shifted, 'b', 0.25));
|
||
const withParam = shaderFingerprint(s.asset().graph);
|
||
expect(withParam).not.toBe(before);
|
||
s.link(n.brightness, 'value', n.output, 'alpha');
|
||
expect(shaderFingerprint(s.asset().graph)).not.toBe(withParam);
|
||
});
|
||
});
|