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:
1 parent
963f8a152a
commit
4716726b6b
14 files changed
+586
-75
No files matched your search
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user