Shader assets: graph-built shaders, the Shader tab and sprite rows from the graph (#62)
game-maker / suite (push) Failing after 3m59s
game-maker / suite (push) Failing after 3m59s
A shader is an asset carrying a node graph (Texture color, Output, Property, Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite wears a shader through its Shader picker, and the rows below it are the graph's Property nodes. Property schema, resolution and deviation take the project because a sprite's rows now live in another asset. The built-in registry of the prototype is gone; "Adjust colors" ships as a graph in the fixture and the three example games. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
6141d07d4f
commit
42cafa7543
62 files changed
+1682
-144
No files matched your search
@@ -28,6 +28,7 @@ describe('asset commands', () => {
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
shader: '',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -67,15 +67,15 @@ describe('asset deviation', () => {
|
||||
|
||||
it('marks an asset only once a property really differs from its default', () => {
|
||||
const f = makeAsset('sprite', 'Box');
|
||||
expect(assetDeviates(f.asset())).toBe(false);
|
||||
expect(assetDeviates(f.project, f.asset())).toBe(false);
|
||||
// Written, but the default value – the inspector shows no gold, so neither does the list.
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.assetId, 'width', 100));
|
||||
expect(f.asset().props.width).toBe(100);
|
||||
expect(assetDeviates(f.asset())).toBe(false);
|
||||
expect(assetDeviates(f.project, f.asset())).toBe(false);
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.assetId, 'width', 250));
|
||||
expect(assetDeviates(f.asset())).toBe(true);
|
||||
expect(assetDeviates(f.project, f.asset())).toBe(true);
|
||||
f.stack.execute(new ResetAssetPropertyCommand(f.assetId, 'width'));
|
||||
expect(assetDeviates(f.asset())).toBe(false);
|
||||
expect(assetDeviates(f.project, f.asset())).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a behavior unmarked for its variables, which the inspector does not mark either', () => {
|
||||
@@ -83,8 +83,8 @@ describe('asset deviation', () => {
|
||||
f.stack.execute(new AddVariableCommand(f.assetId, 'speed', 'number'));
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.assetId, 'speed', 42));
|
||||
expect(f.asset().props.speed).toBe(42);
|
||||
expect(assetDeviates(f.asset())).toBe(false);
|
||||
expect(assetDeviates(f.project, f.asset())).toBe(false);
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.assetId, 'enabled', false));
|
||||
expect(assetDeviates(f.asset())).toBe(true);
|
||||
expect(assetDeviates(f.project, f.asset())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ describe('the enabled switch', () => {
|
||||
const f = makeSpriteScene();
|
||||
const { project, stack, sceneId, spriteId, object } = f;
|
||||
const box = place(f, spriteId, { x: 0, y: 0 });
|
||||
expect(resolveSceneObjectProps(findAsset(project, spriteId)!, object(box)).enabled).toBe(true);
|
||||
expect(resolveSceneObjectProps(project, findAsset(project, spriteId)!, object(box)).enabled).toBe(true);
|
||||
|
||||
// Switching an instance off is an ordinary override – and survives a round trip.
|
||||
stack.execute(new SetOverrideCommand(sceneId, box, 'enabled', false));
|
||||
|
||||
@@ -74,6 +74,8 @@ describe('node registry', () => {
|
||||
expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length);
|
||||
// An event is seeded by the runtime, a state is a name in a machine: neither runs.
|
||||
if (def.category === 'event' || def.category === 'state') expect(def.inputs).toHaveLength(0);
|
||||
// A shader node is compiled to GLSL, never run.
|
||||
else if (def.category === 'shader') expect('emit' in def).toBe(true);
|
||||
else expect(def.run).toBeTypeOf('function');
|
||||
expect(keys.every((k) => k.length > 0)).toBe(true);
|
||||
}
|
||||
|
||||
@@ -72,10 +72,10 @@ describe('a list as a value', () => {
|
||||
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 2]));
|
||||
const placed = f.object(object);
|
||||
expect(deviatesFromAsset(placed, asset, 'row')).toBe(false);
|
||||
expect(deviatesFromAsset(f.project, placed, asset, 'row')).toBe(false);
|
||||
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 3]));
|
||||
expect(deviatesFromAsset(placed, asset, 'row')).toBe(true);
|
||||
expect(deviatesFromAsset(f.project, placed, asset, 'row')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a loaded item as the kind it is, kind by kind', () => {
|
||||
|
||||
+18
-13
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAsset } from '../../src/core/commands/assetCommands';
|
||||
import { createEmptyProject } from '../../src/core/model/Project';
|
||||
import { createSceneObject } from '../../src/core/model/SceneObject';
|
||||
import { coerceValue, defaultsFor, schemaFor } from '../../src/core/properties/PropertySchema';
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ describe('PropertySchema', () => {
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
shader: '',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,15 +45,17 @@ describe('PropertySchema', () => {
|
||||
});
|
||||
|
||||
describe('resolve', () => {
|
||||
const project = createEmptyProject('p', 'P');
|
||||
it('fills asset props with defaults and ignores unknown keys', () => {
|
||||
const sprite = buildAsset('sprite', 'Box');
|
||||
sprite.props = { width: 50, bogus: 1 };
|
||||
expect(resolveAssetProps(sprite)).toEqual({
|
||||
expect(resolveAssetProps(project, sprite)).toEqual({
|
||||
enabled: true,
|
||||
width: 50,
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
shader: '',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,44 +63,45 @@ describe('resolve', () => {
|
||||
const sprite = buildAsset('sprite', 'Box');
|
||||
sprite.props.color = 0x00ff00;
|
||||
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
|
||||
expect(resolveSceneObjectProps(sprite, object)).toEqual({
|
||||
expect(resolveSceneObjectProps(project, sprite, object)).toEqual({
|
||||
enabled: true,
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: 0x00ff00,
|
||||
texture: '',
|
||||
shader: '',
|
||||
});
|
||||
|
||||
object.overrides.color = 0x0000ff;
|
||||
expect(resolveSceneObjectProps(sprite, object).color).toBe(0x0000ff);
|
||||
expect(inheritedValue(sprite, 'color')).toBe(0x00ff00);
|
||||
expect(resolveSceneObjectProps(project, sprite, object).color).toBe(0x0000ff);
|
||||
expect(inheritedValue(project, sprite, 'color')).toBe(0x00ff00);
|
||||
});
|
||||
|
||||
it('detects asset values that differ from the schema default', () => {
|
||||
const sprite = buildAsset('sprite', 'Box');
|
||||
expect(isModified(sprite, 'width')).toBe(false); // explicit default counts as unmodified
|
||||
expect(isModified(project, sprite, 'width')).toBe(false); // explicit default counts as unmodified
|
||||
sprite.props.width = 250;
|
||||
expect(isModified(sprite, 'width')).toBe(true);
|
||||
expect(isModified(project, sprite, 'width')).toBe(true);
|
||||
delete sprite.props.height; // missing → resolved from default → unmodified
|
||||
expect(isModified(sprite, 'height')).toBe(false);
|
||||
expect(isModified(sprite, 'bogus')).toBe(false);
|
||||
expect(isModified(project, sprite, 'height')).toBe(false);
|
||||
expect(isModified(project, sprite, 'bogus')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores an override that carries the asset value – nothing to reset or apply', () => {
|
||||
const sprite = buildAsset('sprite', 'Box');
|
||||
sprite.props.width = 250;
|
||||
const object = createSceneObject('o1', sprite.id, { x: 0, y: 0 });
|
||||
expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); // no override at all
|
||||
expect(deviatesFromAsset(project, object, sprite, 'width')).toBe(false); // no override at all
|
||||
|
||||
object.overrides.width = 250; // same value as the asset → not a deviation
|
||||
expect(deviatesFromAsset(object, sprite, 'width')).toBe(false);
|
||||
expect(deviatesFromAsset(project, object, sprite, 'width')).toBe(false);
|
||||
|
||||
object.overrides.width = 300;
|
||||
expect(deviatesFromAsset(object, sprite, 'width')).toBe(true);
|
||||
expect(deviatesFromAsset(project, object, sprite, 'width')).toBe(true);
|
||||
|
||||
// The asset moving to the object's value settles the difference again.
|
||||
sprite.props.width = 300;
|
||||
expect(deviatesFromAsset(object, sprite, 'width')).toBe(false);
|
||||
expect(deviatesFromAsset(project, object, sprite, 'width')).toBe(false);
|
||||
});
|
||||
|
||||
it('compares an instance against its template as overrides, not as values', () => {
|
||||
@@ -120,6 +125,6 @@ describe('resolve', () => {
|
||||
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
|
||||
expect(object.position).toEqual({ x: 10, y: 20, z: 0 });
|
||||
expect('position' in sprite).toBe(false);
|
||||
expect(resolveSceneObjectProps(sprite, object)).not.toHaveProperty('x');
|
||||
expect(resolveSceneObjectProps(project, sprite, object)).not.toHaveProperty('x');
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ describe('serialize', () => {
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
shader: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* 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, ConnectCommand } 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 { 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 { compileShader } from '../../src/core/shader/compile';
|
||||
import { SHADER_NODES, SHADER_OUTPUT, SHADER_TEXTURE_COLOR } 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; rgb = (rgb - 0.5) * contrast + 0.5; rgb = mix(luma, rgb, saturation) –
|
||||
* eleven nodes, the same maths the built-in prototype had.
|
||||
*/
|
||||
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 bright = s.add('ShaderMultiply');
|
||||
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', 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, 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('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};`);
|
||||
expect(fragment).toContain('vec3 rgb = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;');
|
||||
expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_color \* vec3\(n\d+_value\)\);/);
|
||||
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).toMatch(/finalColor = vec4\(clamp\(n\d+_result, 0\.0, 1\.0\) \* texel\.a, texel\.a\);/);
|
||||
// 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) * texel.a');
|
||||
|
||||
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('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' });
|
||||
});
|
||||
});
|
||||
|
||||
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', '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',
|
||||
'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(6);
|
||||
});
|
||||
|
||||
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(6);
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,7 @@ describe('CreateAssetCommand with starting props', () => {
|
||||
height: 32,
|
||||
color: 0xffffff,
|
||||
texture: texture.asset.id,
|
||||
shader: '',
|
||||
});
|
||||
stack.undo();
|
||||
expect(findAsset(project, sprite.asset.id)).toBeUndefined();
|
||||
|
||||
Reference in new issue
Block a user