Files
feuerwerk/tests/core/shader-vertex.test.ts
T
adminandClaude Opus 5.5 bf27e42e8a
game-maker / gate (push) Successful in 3m1s
game-maker / suite (push) Successful in 14m11s
Let a shader declare a color the sprite sets (#298)
A shader could only ask its sprites for numbers. A tint, an outline
colour or a glow colour had to be built from three Property nodes.

- New node Color property next to Property: Name and Default (a
  colour, white unless picked), output `color`. It becomes a colour
  row below the Shader dropdown and a vec3 uniform in the program.
- Number and colour properties share one set of names. A clash is
  the existing "Another property is named" problem.
- Like Color it cannot feed the Vertex output.
- Placed from the palette it is called tint, then tint2.
- The preview in the Shader tab shows a colour field for it. Like
  the sliders it starts at the sprite's value and never writes into
  the project.
- `declaredRows` takes the row builder, so the particle graph keeps
  its number rows unchanged.

Tests:
- Gate: 222 files, 3452 tests (3445 + 7)
- e2e 28: 89 checks (28l-v: default colour drawn, then the colour
  the sprite sets)
- e2e total 1704, counted (1703 + 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:43:54 +02:00

192 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A shader's Vertex output (#79): what feeds it runs once per corner of the wearer's grid and moves
* that corner, in pixels of the picture. Without the node nothing of a shader changes.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
import { findAssetOf } from '../../src/core/model/Project';
import { SHADER_PROPERTY } from '../../src/core/properties/shaders';
import { compileShader, perCornerProblem } from '../../src/core/shader/compile';
import {
DEFAULT_SUBDIVISIONS,
MAX_SUBDIVISIONS,
SHADER_COLOR,
SHADER_NUMBER,
SHADER_OUTPUT,
SHADER_SAMPLE,
SHADER_TEXTURE_ALPHA,
SHADER_TEXTURE_COLOR,
SHADER_TEXTURE_SIZE,
SHADER_TIME,
SHADER_UV,
SHADER_VERTEX_OUTPUT,
subdivisionsOf,
} from '../../src/core/shader/nodes';
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
import { gridQuad, quadPadding } from '../../src/viewport/quadPadding';
import { makeProject } from '../helpers';
function shader(vertexParams: Record<string, unknown> | null = {}) {
const f = makeProject();
const create = new CreateAssetCommand('shader', 'Wave');
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 }));
const out = add(SHADER_OUTPUT);
const corner = vertexParams ? add(SHADER_VERTEX_OUTPUT, vertexParams) : '';
return { f, asset, add, link, out, corner, build: () => compileShader(asset().graph) };
}
describe('Vertex output (#79)', () => {
it('leaves a shader without the node exactly as it was', () => {
const plain = shader(null).build();
expect(plain.compiled!.vertex).toBeNull();
// The fragment does not know whether there is a per-corner part.
expect(shader().build().compiled!.fragment).toBe(plain.compiled!.fragment);
});
it('holds every corner in place while nothing feeds it', () => {
const { compiled } = shader().build();
expect(compiled!.vertex!.body).toBe('vec2 offset = vec2(0.0, 0.0);');
expect(compiled!.vertex!.subdivisions).toBe(DEFAULT_SUBDIVISIONS);
});
it('moves a corner by what feeds x and y, from the corner’s own UV', () => {
const s = shader();
const uv = s.add(SHADER_UV);
const wave = s.add('ShaderSin');
const scaled = s.add('ShaderMultiply', { b: 4 });
s.link(uv, 'v', wave, 'value');
s.link(wave, 'result', scaled, 'a');
s.link(scaled, 'result', s.corner, 'x');
const { vertex } = s.build().compiled!;
expect(vertex!.body.split('\n')).toEqual([
'float c0_u = vTextureCoord.x;',
'float c1_v = vTextureCoord.y;',
'float c2_result = sin(c1_v);',
'float c3_result = (c2_result * 4.0);',
'vec2 offset = vec2(c3_result, 0.0);',
]);
});
it('emits a node feeding both outputs once for each stage', () => {
const s = shader();
const amount = s.add(SHADER_PROPERTY, { name: 'amount', default: 1, min: 0, max: 8 });
s.link(amount, 'value', s.corner, 'y');
s.link(amount, 'value', s.out, 'alpha');
const { compiled } = s.build();
expect(compiled!.uniforms).toEqual([{ name: 'amount', kind: 'number', default: 1 }]);
expect(compiled!.fragment).toContain('float n0_value = u_amount;');
expect(compiled!.vertex!.body).toContain('float c0_value = u_amount;');
// One precision for a uniform both stages declare: the fragment's default.
expect(compiled!.vertex!.head).toContain('uniform mediump float u_amount;');
});
it('redraws every tick for a Time node only the corners read', () => {
const s = shader();
const time = s.add(SHADER_TIME);
s.link(time, 'value', s.corner, 'x');
const { compiled } = s.build();
expect(compiled!.time).toBe(true);
expect(compiled!.vertex!.head).toContain('uniform mediump float uTime;');
expect(compiled!.fragment).not.toContain('uTime');
// And the other way round the vertex stage does not declare what it never reads.
const other = shader();
const clock = other.add(SHADER_TIME);
other.link(clock, 'value', other.out, 'alpha');
expect(other.build().compiled!.vertex!.head).not.toContain('uTime');
expect(other.build().compiled!.fragment).toContain('uniform float uTime;');
});
it.each([
[SHADER_TEXTURE_ALPHA, 'value', 'Texture alpha'],
[SHADER_SAMPLE, 'alpha', 'Sample texture'],
[SHADER_TEXTURE_SIZE, 'width', 'Texture size'],
])('says at the node that %s is not there per corner', (type, port, label) => {
const s = shader();
s.link(s.add(type), port, s.corner, 'x');
const build = s.build();
expect(build.compiled).toBeNull();
expect(build.problems).toEqual({ [s.corner]: perCornerProblem(label) });
expect(build.reason).toBe(perCornerProblem(label));
});
it('says so too for a colour arriving through a calculation', () => {
for (const type of [SHADER_TEXTURE_COLOR, SHADER_COLOR]) {
const s = shader();
const luminance = s.add('ShaderLuminance');
s.link(s.add(type), 'color', luminance, 'color');
s.link(luminance, 'value', s.corner, 'y');
expect(s.build().compiled).toBeNull();
expect(s.build().problems[s.corner]).toMatch(/^Not available per corner: /);
}
// An input that would read the picture while unconnected is the same thing.
const unfed = shader();
unfed.link(unfed.add('ShaderLuminance'), 'value', unfed.corner, 'y');
expect(unfed.build().problems).toEqual({ [unfed.corner]: perCornerProblem('the picture') });
});
it('still lets the same nodes feed the Output', () => {
const s = shader();
s.link(s.add(SHADER_TEXTURE_ALPHA), 'value', s.out, 'alpha');
s.link(s.add(SHADER_NUMBER, { value: 2 }), 'value', s.corner, 'x');
const build = s.build();
expect(build.problems).toEqual({});
expect(build.compiled!.vertex!.body).toContain('vec2 offset = vec2(c0_value, 0.0);');
});
it('reads Subdivisions as whole cells between 1 and the limit', () => {
expect(subdivisionsOf({})).toBe(DEFAULT_SUBDIVISIONS);
expect(subdivisionsOf({ subdivisions: 1 })).toBe(1);
expect(subdivisionsOf({ subdivisions: 0 })).toBe(1);
expect(subdivisionsOf({ subdivisions: -3 })).toBe(1);
expect(subdivisionsOf({ subdivisions: 2.6 })).toBe(3);
expect(subdivisionsOf({ subdivisions: MAX_SUBDIVISIONS + 9 })).toBe(MAX_SUBDIVISIONS);
expect(subdivisionsOf({ subdivisions: 'fine' })).toBe(DEFAULT_SUBDIVISIONS);
expect(shader({ subdivisions: 32 }).build().compiled!.vertex!.subdivisions).toBe(32);
});
it('is offered once, like the Output', () => {
const without = shader(null);
const offered = (s: typeof without) =>
paletteEntries(s.f.project, s.asset()).filter((e) => e.def.type === SHADER_VERTEX_OUTPUT).length;
expect(offered(without)).toBe(1);
expect(offered(shader())).toBe(0);
});
});
describe('gridQuad', () => {
it('is the unit quad cut into cells, corners row by row', () => {
const grid = gridQuad(null, 2);
expect([...grid.positions]).toEqual([0, 0, 0.5, 0, 1, 0, 0, 0.5, 0.5, 0.5, 1, 0.5, 0, 1, 0.5, 1, 1, 1]);
expect([...grid.uvs]).toEqual([...grid.positions]);
expect([...grid.indices.slice(0, 6)]).toEqual([0, 1, 4, 0, 4, 3]);
expect(grid.indices.length).toBe(2 * 2 * 6);
});
it('has as many corners as the cells need, and every index is one of them', () => {
for (const cells of [1, 8, 64]) {
const grid = gridQuad(null, cells);
expect(grid.positions.length).toBe((cells + 1) ** 2 * 2);
expect(Math.max(...grid.indices)).toBe((cells + 1) ** 2 - 1);
}
});
it('covers the padded rectangle, the picture still at 0..1', () => {
const grid = gridQuad(quadPadding(4, 16, 32), 4);
const xs = [...grid.positions].filter((_, i) => i % 2 === 0);
const ys = [...grid.positions].filter((_, i) => i % 2 === 1);
expect([Math.min(...xs), Math.max(...xs)]).toEqual([-0.25, 1.25]);
expect([Math.min(...ys), Math.max(...ys)]).toEqual([-0.125, 1.125]);
expect([...grid.uvs]).toEqual([...grid.positions]);
});
});