Files
feuerwerk/tests/core/shader-vertex.test.ts
T
adminandClaude Opus 5.5 8118b8042b
game-maker / gate (push) Successful in 4m16s
game-maker / suite (push) Successful in 17m10s
Let Texture size feed the Vertex output to scale about the middle (#409)
A scale about the picture's middle moves each corner by its distance
to the middle times (scale - 1). In pixels that distance needs the
picture's size, and Texture size was locked at the Vertex output as a
per-pixel node. So the size had to be typed in by hand and was only
right for pictures of exactly that size.

- At a corner Texture size reads the uniform uPictureSize. It holds
  the size the Vertex output counts its pixels in, the same number the
  node gives in the fragment. The offset is divided by that size again,
  so a scale comes out right for every picture and sprite size.
- A node can say what it is at a corner through emitPerCorner. The
  walker takes it there and emit everywhere else.
- Fed a Pass's picture, Texture size stays locked at the Vertex output.
  A Pass has no picture at a corner.
- 9-slice, field and camera shaders do not move corners. Their size is
  0 there and nothing changes for them.

Tests:
- Unit shader-vertex: the node compiles at a corner to uPictureSize
  and the Output still reads textureSize. shader-passes: a Pass's
  picture into Texture size at the Vertex output is refused.
- Against the old perPixel flag the new unit test fails.
- e2e 28l-iv: width * 0.5 at x moves the 32 px picture as far as the
  constant 16 did, same three colours. With the size forced to 0 on
  the GPU the check fails and the picture stands still.
- The case "Texture size is refused at a corner" left the it.each
  that lists refused nodes, so the test count stays the same.
- e2e 28 on melektaus-atx: ALL PASSED, 101 checks.
- Gate on melektaus-atx: CHECK EXIT 0, 297 files, 4562 tests.
- tsc, eslint, Prettier clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:35:20 +02:00

214 lines
9.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'],
])('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('reads the picture’s size at a corner, the one its pixels are counted in (#409)', () => {
// A scale of 1.2 about the middle: x = (u - 0.5) * width * 0.2.
const s = shader();
const uv = s.add(SHADER_UV);
const size = s.add(SHADER_TEXTURE_SIZE);
const centred = s.add('ShaderSubtract', { b: 0.5 });
const wide = s.add('ShaderMultiply');
const grown = s.add('ShaderMultiply', { b: 0.2 });
s.link(uv, 'u', centred, 'a');
s.link(centred, 'result', wide, 'a');
s.link(size, 'width', wide, 'b');
s.link(wide, 'result', grown, 'a');
s.link(grown, 'result', s.corner, 'x');
s.link(size, 'height', s.out, 'alpha');
const build = s.build();
expect(build.problems).toEqual({});
const { vertex, fragment } = build.compiled!;
expect(vertex!.body).toMatch(/float c\d+_width = uPictureSize\.x;/);
expect(vertex!.body).not.toContain('textureSize');
// The Output still reads the texture itself.
expect(fragment).toMatch(/float n\d+_height = \(vec2\(textureSize\(uTexture, 0\)\) \* uPixelScale\)\.y;/);
});
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]);
});
});