diff --git a/README.md b/README.md index 31f6331e..2ec796fe 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 699 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 703 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index a62bb3b8..02111f32 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -14,6 +14,8 @@ export interface PortDef { default?: PropertyValue; /** The property this port carries; its typed-in value is edited as that property (color, enum, …). */ field?: PropertyDef; + /** `false`: nothing to type in even for a number – unconnected, the node's own rule applies. */ + inline?: boolean; } /** @@ -145,6 +147,7 @@ export function inlineInputs(def: NodeDef): PortDef[] { } function isInlinePort(port: PortDef): boolean { + if (port.inline === false) return false; if (port.type === 'vec3') return port.field !== undefined; return port.type !== 'exec' && port.type !== 'object' && port.type !== 'struct'; } diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index 3df0c405..15cdeea1 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -8,6 +8,7 @@ import { floatLiteral, isShaderNodeDef, SHADER_OUTPUT, + TEXEL_ALPHA, TEXEL_COLOR, uniformName, vec3Literal, @@ -89,7 +90,10 @@ export function compileShader(graph: Graph): ShaderBuild { return outputs; }; - /** What feeds an input: the edge's source, the typed-in value, or – with nothing to type – the texel. */ + /** + * What feeds an input: the edge's source, the typed-in value, or – with nothing to type – the + * texel: its colour for a colour port, its alpha for a number port. + */ const input = (node: GraphNode, port: PortDef): Expr | null => { const edge = edgesInto(graph, node.id, port.key)[0]; if (edge) { @@ -103,7 +107,7 @@ export function compileShader(graph: Graph): ShaderBuild { if (port.field?.kind === 'color') return { code: vec3Literal(colorOf(typed ?? 0xffffff, 0xffffff)), type: 'vec3' }; if (port.field) return { code: floatLiteral(Number(typed ?? 0)), type: 'float' }; - return TEXEL_COLOR; + return port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR; }; const conform = (node: GraphNode, port: PortDef, expr: Expr): Expr | null => { @@ -119,9 +123,10 @@ export function compileShader(graph: Graph): ShaderBuild { if (!output) return { compiled: null, problems, reason: 'No Output node' }; const outputDef = nodeDef(SHADER_OUTPUT)!; const color = input(output, outputDef.inputs[0]!); + const alpha = input(output, outputDef.inputs[1]!); const first = graph.nodes.find((n) => n.id in problems); - if (first || !color) + if (first || !color || !alpha) return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' }; const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default })); @@ -135,7 +140,8 @@ export function compileShader(graph: Graph): ShaderBuild { ' vec4 texel = texture(uTexture, vTextureCoord);', ` vec3 ${TEXEL_COLOR.code} = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;`, ...lines.map((line) => ` ${line}`), - ` finalColor = vec4(clamp(${color.code}, 0.0, 1.0) * texel.a, texel.a);`, + ` float alpha = clamp(${alpha.code}, 0.0, 1.0);`, + ` finalColor = vec4(clamp(${color.code}, 0.0, 1.0) * alpha, alpha);`, '}', '', ].join('\n'); diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index 14c74b4f..916b2228 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -33,11 +33,13 @@ export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef { } export const SHADER_TEXTURE_COLOR = 'ShaderTextureColor'; +export const SHADER_TEXTURE_ALPHA = 'ShaderTextureAlpha'; export const SHADER_OUTPUT = 'ShaderOutput'; export const SHADER_COLOR = 'ShaderColor'; /** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */ export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' }; +export const TEXEL_ALPHA: Expr = { code: 'texel.a', type: 'float' }; const NUMBER_STEP = 0.05; @@ -98,13 +100,24 @@ export const ShaderTextureColor: ShaderNodeDef = { emit: () => ({ color: TEXEL_COLOR }), }; -/** The one node the compiler starts from; an unconnected color leaves the pixel as it is. */ +export const ShaderTextureAlpha: ShaderNodeDef = { + type: SHADER_TEXTURE_ALPHA, + label: 'Texture alpha', + category: 'shader', + description: "The sprite's own pixel's opacity: 0 transparent, 1 opaque.", + inputs: [], + outputs: [{ key: 'value', label: 'value', type: 'number' }], + params: [], + emit: () => ({ value: TEXEL_ALPHA }), +}; + +/** The one node the compiler starts from; an unconnected input leaves that part of the pixel as it is. */ export const ShaderOutput: ShaderNodeDef = { type: SHADER_OUTPUT, label: 'Output', category: 'shader', - description: 'The colour the sprite is drawn with. Its alpha stays the texture’s.', - inputs: [vec3Port('color')], + description: 'The colour and opacity the sprite is drawn with; unconnected, each is the texture’s.', + inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }], outputs: [], params: [], emit: () => ({}), @@ -194,6 +207,7 @@ export const ShaderLuminance: ShaderNodeDef = { /** Every node a shader graph offers. Registered by `graph/nodes`, offered by no behavior palette. */ export const SHADER_NODES: readonly ShaderNodeDef[] = [ ShaderTextureColor, + ShaderTextureAlpha, ShaderOutput, ShaderProperty, ShaderColor, diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 99bd0510..e6b54efd 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -21,8 +21,15 @@ import { SHADER_PROPERTY, shaderParamDefs, } from '../../src/core/properties/shaders'; +import { inlineInputs } from '../../src/core/graph/NodeRegistry'; import { compileShader } from '../../src/core/shader/compile'; -import { SHADER_NODES, SHADER_OUTPUT, SHADER_TEXTURE_COLOR } from '../../src/core/shader/nodes'; +import { + SHADER_NODES, + SHADER_OUTPUT, + SHADER_TEXTURE_ALPHA, + SHADER_TEXTURE_COLOR, + ShaderOutput, +} from '../../src/core/shader/nodes'; import { deserializeProject } from '../../src/core/store/serialize'; import { paletteEntries } from '../../src/editor/graph/paletteEntries'; import { makeProject, makeSpriteScene, place, type ProjectFixture, roundTrip } from '../helpers'; @@ -181,7 +188,8 @@ describe('compileShader', () => { expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_result - vec3\(0\.5\)\);/); expect(fragment).toMatch(/float n\d+_value = dot\(n\d+_result, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/); expect(fragment).toMatch(/vec3 n\d+_result = mix\(vec3\(n\d+_value\), n\d+_result, n\d+_value\);/); - expect(fragment).toMatch(/finalColor = vec4\(clamp\(n\d+_result, 0\.0, 1\.0\) \* texel\.a, texel\.a\);/); + expect(fragment).toContain('float alpha = clamp(texel.a, 0.0, 1.0);'); + expect(fragment).toMatch(/finalColor = vec4\(clamp\(n\d+_result, 0\.0, 1\.0\) \* alpha, alpha\);/); // Each local is declared before it is used. const declared = new Set(); for (const line of fragment.split('\n')) { @@ -196,7 +204,7 @@ describe('compileShader', () => { const f = makeProject(); const s = shaderEditor(f); s.add(SHADER_OUTPUT); - expect(compileShader(s.asset().graph).compiled!.fragment).toContain('clamp(rgb, 0.0, 1.0) * texel.a'); + expect(compileShader(s.asset().graph).compiled!.fragment).toContain('clamp(rgb, 0.0, 1.0) * alpha'); const g = shaderEditor(f, 'Scaled'); const times = g.add('ShaderMultiply', { a: 2, b: 0.75 }); @@ -219,6 +227,41 @@ describe('compileShader', () => { expect(tinted).toMatch(/vec3 n1_result = \(vec3\(1\.0\) \* n0_color\);/); }); + it('keeps the texture’s alpha until the Output’s alpha is fed, then clamps what feeds it', () => { + const f = makeProject(); + const s = shaderEditor(f); + const alpha = s.add(SHADER_TEXTURE_ALPHA); + const opacity = s.add(SHADER_PROPERTY, { name: 'opacity', default: 1, min: 0, max: 1 }); + const times = s.add('ShaderMultiply'); + const output = s.add(SHADER_OUTPUT); + // Nothing to type on the alpha port: a typed-in 0 would hide every sprite by default. + expect(inlineInputs(ShaderOutput).map((p) => p.key)).toEqual([]); + let { fragment } = compileShader(s.asset().graph).compiled!; + expect(fragment).toContain('float alpha = clamp(texel.a, 0.0, 1.0);'); + expect(fragment).toContain('finalColor = vec4(clamp(rgb, 0.0, 1.0) * alpha, alpha);'); + + s.link(alpha, 'value', times, 'a'); + s.link(opacity, 'value', times, 'b'); + s.link(times, 'result', output, 'alpha'); + ({ fragment } = compileShader(s.asset().graph).compiled!); + expect(fragment).toMatch(/float n0_value = texel\.a;/); + expect(fragment).toMatch(/float n1_value = u_opacity;/); + expect(fragment).toMatch(/float n2_result = \(n0_value \* n1_value\);/); + expect(fragment).toContain('float alpha = clamp(n2_result, 0.0, 1.0);'); + expect(fragment).toContain('finalColor = vec4(clamp(rgb, 0.0, 1.0) * alpha, alpha);'); + + // A colour reaching the alpha port through an `any` result is the Output's problem. + const wrong = shaderEditor(f, 'Wrong'); + const texel = wrong.add(SHADER_TEXTURE_COLOR); + const doubled = wrong.add('ShaderMultiply', { b: 2 }); + const out = wrong.add(SHADER_OUTPUT); + wrong.link(texel, 'color', doubled, 'a'); + wrong.link(doubled, 'result', out, 'alpha'); + const build = compileShader(wrong.asset().graph); + expect(build.compiled).toBeNull(); + expect(build.problems).toEqual({ [out]: '"alpha" needs a number, not a colour' }); + }); + it('names the problem on the node, and compiles nothing while there is one', () => { const f = makeProject(); const s = shaderEditor(f); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 0f8a1daf..ed63de43 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -276,7 +276,54 @@ export async function shader(t) { red, ); - // 28h. Back to None: no filter at all, the plain colour returns bit for bit. + // 28h. Alpha: Texture alpha times a Property into the Output's alpha blends the sprite with what + // is behind it; at 1 the pixel is the one from before, byte for byte. + await mutate(` + const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); + const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput'); + a.graph.nodes.push( + { id: 'sh-alpha', type: 'ShaderTextureAlpha', x: 0, y: 300, params: {} }, + { id: 'sh-opacity', type: 'ShaderProperty', x: 0, y: 400, params: { name: 'opacity', default: 1, min: 0, max: 1 } }, + { id: 'sh-fade', type: 'ShaderMultiply', x: 200, y: 350, params: {} }); + a.graph.edges.push( + { id: 'sh-fade-a', from: { node: 'sh-alpha', port: 'value' }, to: { node: 'sh-fade', port: 'a' } }, + { id: 'sh-fade-b', from: { node: 'sh-opacity', port: 'value' }, to: { node: 'sh-fade', port: 'b' } }, + { id: 'sh-fade-out', from: { node: 'sh-fade', port: 'result' }, to: { node: out.id, port: 'alpha' } });`); + await sleep(400); + const opaque = await centre(); + check( + 'an opacity property at its default 1 changes nothing', + opaque === red, + JSON.stringify({ red, opaque, info: await info() }), + ); + await assetProps({ opacity: 0 }); + await sleep(300); + const behind = await centre(); + check('opacity 0 shows what is behind the sprite', colourDistance(behind, red) > 60, { red, behind }); + await assetProps({ opacity: 0.5 }); + await sleep(300); + const half = await centre(); + const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); + const expected = + '#' + + [1, 3, 5] + .map((i) => + Math.round((channel(red, i) + channel(behind, i)) / 2) + .toString(16) + .padStart(2, '0'), + ) + .join(''); + check('opacity 0.5 blends the sprite half with what is behind', colourDistance(half, expected) < 24, { + red, + behind, + half, + expected, + }); + await assetProps({ opacity: 1 }); + await sleep(300); + check('opacity back at 1 draws the pixel from before, byte for byte', (await centre()) === red); + + // 28i. Back to None: no filter at all, the plain colour returns bit for bit. await assetProps({ shader: '' }); await sleep(300); const back = await centre();