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>
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', 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]);
|
||
});
|
||
});
|