/** * 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 | 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]); }); });