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>
192 lines
8.3 KiB
TypeScript
192 lines
8.3 KiB
TypeScript
/**
|
||
* 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]);
|
||
});
|
||
});
|