/** * A shader's `Pass` node (#301): a cut in the graph, whose inputs are drawn into a picture of their * own first, read by the nodes that read a picture. */ import { describe, expect, it } from 'vitest'; import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { portsCompatible } from '../../src/core/graph/Graph'; import { findAssetOf } from '../../src/core/model/Project'; import { compileShader, perCornerProblem, TOO_MANY_PASSES } from '../../src/core/shader/compile'; import { MAX_PASSES, ShaderAdd, SHADER_BLUR, SHADER_OUTPUT, SHADER_PASS, SHADER_SAMPLE, SHADER_TEXTURE_ALPHA, SHADER_TEXTURE_COLOR, SHADER_TEXTURE_SIZE, SHADER_VERTEX_OUTPUT, } from '../../src/core/shader/nodes'; import { MAX_PASS_PIXELS, passPixels } from '../../src/viewport/quadPadding'; import { makeSpriteScene } from '../helpers'; function shader(outParams = {}) { const f = makeSpriteScene(); const create = new CreateAssetCommand('shader', 'Bloom'); 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 })); /** An edge no command would lay, for what a hand-edited file may hold. */ const wire = (from: string, fromPort: string, to: string, toPort: string) => asset().graph.edges.push({ id: `e${asset().graph.edges.length}`, from: { node: from, port: fromPort }, to: { node: to, port: toPort }, }); const out = add(SHADER_OUTPUT, outParams); return { add, link, wire, out, build: () => compileShader(asset().graph) }; } /** A Pass whose picture a Texture color hands to the Output. */ function passedOn(passParams = {}, outParams = {}) { const s = shader(outParams); const pass = s.add(SHADER_PASS, passParams); const read = s.add(SHADER_TEXTURE_COLOR); s.link(pass, 'picture', read, 'picture'); s.link(read, 'color', s.out, 'color'); return { ...s, pass, read }; } describe('Pass (#301)', () => { it('draws the Pass first, untinted, and the Output reads its picture through the padding', () => { const compiled = passedOn().build().compiled!; expect(compiled.passes).toHaveLength(1); const [pass] = compiled.passes; expect(pass!.fragment).toContain('const vec4 vColor = vec4(1.0);'); expect(pass!.fragment).not.toContain('in vec4 vColor;'); expect(pass!.fragment).not.toContain('uPass'); expect(compiled.fragment).toContain('in vec4 vColor;'); expect(compiled.fragment).toContain('uniform sampler2D uPass0;'); expect(compiled.fragment).toContain('uniform vec2 uPassPad;'); expect(compiled.fragment).toMatch( /vec3 n\d+_color = straight\(texelAt\(uPass0, passUv\(vTextureCoord\)\) \* vColor\);/, ); }); it('leaves a shader without a Pass as it was: no picture to draw, no sampler to bind', () => { const s = shader(); s.link(s.add(SHADER_TEXTURE_COLOR), 'color', s.out, 'color'); const compiled = s.build().compiled!; expect(compiled.passes).toEqual([]); expect(compiled.fragment).not.toContain('uPass'); expect(compiled.fragment).not.toContain('passUv'); }); it('draws a pass before the pass that reads it, and each program declares only what it reads', () => { const s = shader(); const first = s.add(SHADER_PASS); const across = s.add(SHADER_BLUR, { direction: 'horizontal' }); const second = s.add(SHADER_PASS); const down = s.add(SHADER_SAMPLE); s.link(first, 'picture', across, 'picture'); s.link(across, 'color', second, 'color'); s.link(across, 'alpha', second, 'alpha'); s.link(second, 'picture', down, 'picture'); s.link(down, 'color', s.out, 'color'); const compiled = s.build().compiled!; expect(compiled.passes).toHaveLength(2); expect(compiled.passes[0]!.fragment).not.toContain('uPass'); expect(compiled.passes[1]!.fragment).toContain('uniform sampler2D uPass0;'); expect(compiled.passes[1]!.fragment).toContain( '= blur(uPass0, passUv(vec2(vTextureCoord.x, vTextureCoord.y)),', ); expect(compiled.fragment).toContain('uniform sampler2D uPass1;'); expect(compiled.fragment).not.toContain('uPass0'); }); it('draws a Pass two nodes read once, and one nothing reads not at all', () => { const s = passedOn(); const alpha = s.add(SHADER_TEXTURE_ALPHA); s.link(s.pass, 'picture', alpha, 'picture'); s.link(alpha, 'value', s.out, 'alpha'); s.add(SHADER_PASS); expect(s.build().compiled!.passes).toHaveLength(1); }); it('takes the size from Resolution and the edge from the Output', () => { expect(passedOn().build().compiled!.passes[0]!.scale).toBe(1); expect(passedOn({ resolution: 'half' }).build().compiled!.passes[0]!.scale).toBe(0.5); expect(passedOn({ resolution: 'quarter' }).build().compiled!.passes[0]!.scale).toBe(0.25); expect(passedOn({}, { edge: 'repeat' }).build().compiled!.passEdge).toBe('repeat'); }); it('measures the picture it is fed', () => { const s = passedOn(); const size = s.add(SHADER_TEXTURE_SIZE); const add = s.add(ShaderAdd.type); s.link(s.pass, 'picture', size, 'picture'); s.link(size, 'width', add, 'a'); s.link(add, 'result', s.out, 'alpha'); expect(s.build().compiled!.fragment).toContain('(vec2(textureSize(uPass0, 0)) * uPixelScale).x'); }); it('names a ring through a picture on the Pass, and compiles nothing', () => { const s = shader(); const pass = s.add(SHADER_PASS); const read = s.add(SHADER_TEXTURE_COLOR); const out = s.add(SHADER_TEXTURE_COLOR); s.wire(pass, 'picture', read, 'picture'); s.wire(read, 'color', pass, 'color'); s.link(pass, 'picture', out, 'picture'); s.link(out, 'color', s.out, 'color'); const build = s.build(); expect(build.compiled).toBeNull(); expect(build.problems[pass]).toBe('Feeds itself'); }); it(`stops at ${MAX_PASSES} passes`, () => { const s = shader(); let picture = ''; for (let n = 0; n <= MAX_PASSES; n++) { const pass = s.add(SHADER_PASS); if (picture) { const read = s.add(SHADER_TEXTURE_COLOR); s.link(picture, 'picture', read, 'picture'); s.link(read, 'color', pass, 'color'); } picture = pass; } const last = s.add(SHADER_TEXTURE_COLOR); s.link(picture, 'picture', last, 'picture'); s.link(last, 'color', s.out, 'color'); const build = s.build(); expect(build.compiled).toBeNull(); expect(Object.values(build.problems)).toContain(TOO_MANY_PASSES); }); it('lets a picture go only where a picture is read', () => { expect(portsCompatible('picture', 'picture')).toBe(true); expect(portsCompatible('picture', 'any')).toBe(false); expect(portsCompatible('any', 'picture')).toBe(false); expect(portsCompatible('picture', 'vec3')).toBe(false); const s = shader(); const pass = s.add(SHADER_PASS); const add = s.add(ShaderAdd.type); s.wire(pass, 'picture', add, 'a'); s.link(add, 'result', s.out, 'color'); expect(s.build().problems[add]).toBe('"a" cannot take a picture'); }); it('has no picture per corner', () => { const s = shader(); const pass = s.add(SHADER_PASS); const alpha = s.add(SHADER_TEXTURE_ALPHA); const corner = s.add(SHADER_VERTEX_OUTPUT); s.link(pass, 'picture', alpha, 'picture'); s.link(alpha, 'value', corner, 'x'); expect(s.build().problems[corner]).toBe(perCornerProblem('Pass')); // Not even its size, though a Texture size reads the wearer's own there (#409). const sized = shader(); const sizedPass = sized.add(SHADER_PASS); const size = sized.add(SHADER_TEXTURE_SIZE); const sizedCorner = sized.add(SHADER_VERTEX_OUTPUT); sized.link(sizedPass, 'picture', size, 'picture'); sized.link(size, 'width', sizedCorner, 'x'); expect(sized.build().problems[sizedCorner]).toBe(perCornerProblem('Pass')); }); it('sizes its picture as the wearer’s with the margin, at its resolution, within limits', () => { expect(passPixels(16, 16, null, 1)).toEqual({ w: 16, h: 16 }); expect(passPixels(16, 8, { u: 2 / 16, v: 2 / 8 }, 1)).toEqual({ w: 20, h: 12 }); expect(passPixels(16, 8, { u: 2 / 16, v: 2 / 8 }, 0.5)).toEqual({ w: 10, h: 6 }); expect(passPixels(2, 2, null, 0.25)).toEqual({ w: 1, h: 1 }); expect(passPixels(4096, 100, null, 1)).toEqual({ w: MAX_PASS_PIXELS, h: 100 }); }); });