Let a shader move the corners of the picture it wears (#79)

A shader could only say what colour a pixel has. Where the picture
is drawn was fixed to the four corners of its rectangle, so nothing
could wave, bend or wobble.

- The new node Vertex output takes x and y, in pixels of the
  picture, and moves every corner of the wearer's grid by them. The
  UVs stay with the corners, so the picture bends along.
- Subdivisions on that node says how fine the grid is: cells per
  axis, 1 to 64, 8 by default. 1 is the plain quad. A shader without
  the node keeps the quad every sprite shares.
- A graph holds one Vertex output at most. UV, Time, Property,
  Number and the math nodes may feed it, and one node may feed both
  outputs.
- Texture color, Texture alpha, Sample texture, Texture size and
  Color cannot feed it. The node says "Not available per corner"
  and the shader does not compile.
- Sprites, texts and the preview in the shader tab wear the grid.
  A nine-slice sprite and particles draw as if the node were not
  there.
- Picking, the selection frame, the size and colliders stay with
  the rectangle. A wearer with a Vertex output is never culled, as
  with Padding, and with Padding the grid covers the room around
  the picture too.
- tools/perf-shaders.mjs takes GM_PERF_SUBDIVISIONS to measure a
  grid against the plain quad.

Measured on stein2 (Intel HD 630), frames per second, one run per
row: at 800 sprites 58 with a shader on the quad, 46 with 8 cells,
41 with 32. Up to 400 sprites a grid hardly shows. Every sprite
holds its own grid.

Tests:
- Gate on melektaus-atx: 220 files, 3416 tests, CHECK EXIT 0.
- 15 new unit tests for the compiler and the grid.
- Four new e2e checks in modules 28 and 30: a fixed offset moves
  the picture, 8 cells bend only the lower half where 1 cell shears
  it all, Texture alpha is refused at the node, a text moves.
- e2e 28,30 on melektaus-atx: 110 checks, all pass.
- The e2e total of 1699 is counted (1695 + 4), the full suite did
  not run.
- No counter-probe, no screenshot. Not looked at: the preview, Time
  in the browser, flipped sprites, Padding together with the grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 00:14:00 +02:00
1 parent 963f8a152a
commit 4716726b6b
14 files changed
+586 -75

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
/**
* 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', 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]);
});
});