From f02adabcf93a88b88974cc23658bb0e31ece80c3 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 2 Oct 2026 16:50:50 +0200 Subject: [PATCH] Show the type an arithmetic shader node really puts out (#219) The result of Add, Multiply, Mix and the other arithmetic nodes of a shader graph was drawn as any, whatever went in. The compiler already lifts a colour on any side to a colour result, so the pin said less than the code knew. A colour result could also be wired into a number input, and the shader then failed to compile. - liftedType in core/shader/nodes.ts is the one rule. The compiler reads it when it lifts two operands, and the editor reads it through liftedPortType for the ports it draws. - A SharedPortGroup may bring its own rule as agree. Without one it is agreedType, as before. The thirteen arithmetic nodes share their type by liftedPortType. - Only the any inputs decide. An input nothing feeds counts as the number typed into it. t, edge, exponent, min and max have no say. - The type travels through a chain of such nodes without code of its own, because #210 hands a shared type on. - In a ring the result stays any, unless a colour has settled it. - A number may feed a colour input. Without that a number result no longer fitted Output.color, although the compiler spreads it over three channels. The rule sits in portsCompatible, but only the shader graph has colour ports. - A colour result no longer fits a number input. An edge laid before the colour came in stays and is drawn in the danger colour, and the node at its end names the problem. - The nine shaders in the stored projects (108 edges) all stay valid under the new rule. Tests: - Gate on melektaus-atx: 212 files, 3260 tests (3240 + 20), exit 0. The gate right before this commit ran on the tree of this and the next commit together, with the same numbers. - e2e 28 on melektaus-atx: 81 checks, all pass. - e2e 1-31: 1059 pass, 1 fail in module 18 (the editor clock read 1.97 s after a stop). A rerun of 1-18 passed with 602 checks and 0.18 s. 1-31 was not run in full again. - No full run and no smokes. The sum of 1676 (1673 + 3) is reckoned. - Twenty unit tests in shader-port-types.test.ts, one of them runs all thirteen nodes through every mix of number and colour and compares what is drawn with the code made. - Two tests in shaders.test.ts now lay the edge first and bring the colour in after, the only order that still wires such a graph. - Three e2e checks in module 28 with the real mouse: a colour into Multiply, the red edge with its message, and the colour result refused at alpha and taken at color. - Fifteen unit counter-probes and two browser counter-probes all fail their tests. - Not checked in the browser: the one-input nodes, Mix and the ring, and a number wired straight into a colour input. Nothing was looked at after the build. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/graph/Graph.ts | 2 + src/core/graph/nested.ts | 3 +- src/core/graph/portTypes.ts | 2 + src/core/shader/nodes.ts | 47 +++- src/editor/debug/graphHooks.ts | 5 + tests/core/shader-port-types.test.ts | 323 +++++++++++++++++++++++++++ tests/core/shaders.test.ts | 10 +- tools/e2e/28-shader.mjs | 88 ++++++++ 9 files changed, 472 insertions(+), 10 deletions(-) create mode 100644 tests/core/shader-port-types.test.ts diff --git a/README.md b/README.md index 975a64b2..582b5688 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,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, 1673 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1676 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index dd55b5c5..c03bbc32 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -164,7 +164,9 @@ export function graphFingerprint(graph: Graph): string { }); } +/** A number may feed a colour: the shader compiler spreads it over the three channels. */ export function portsCompatible(from: PortType, to: PortType): boolean { if (from === 'exec' || to === 'exec') return from === to; + if (from === 'number' && to === 'vec3') return true; return from === to || from === 'any' || to === 'any'; } diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 696e7322..85c3aacc 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -219,7 +219,8 @@ function withSharedType( lookup: GraphLookup, trail: Trail, ): NodeDef { - const type = agreedType(group.inputs.map((key) => feedingType(node, key, graph, lookup, trail))); + const agree = group.agree ?? agreedType; + const type = agree(group.inputs.map((key) => feedingType(node, key, graph, lookup, trail))); if (type === 'any') return def; return { ...def, diff --git a/src/core/graph/portTypes.ts b/src/core/graph/portTypes.ts index 92d81a33..b7e8133b 100644 --- a/src/core/graph/portTypes.ts +++ b/src/core/graph/portTypes.ts @@ -3,10 +3,12 @@ import type { PortType } from './Graph'; /** * Ports of one node that carry the same value: the outputs take the type the inputs agree on * (#207). Inputs keep their own type, so a connected one can always be swapped for another type. + * `agree` is the rule they agree by; without one it is `agreedType`. */ export interface SharedPortGroup { inputs: string[]; outputs: string[]; + agree?: (sources: readonly (PortType | undefined)[]) => PortType; } /** diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index 36fd0372..2ee5ee4b 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -1,4 +1,6 @@ +import type { PortType } from '../graph/Graph'; import type { NodeDef, PortDef } from '../graph/NodeRegistry'; +import type { SharedPortGroup } from '../graph/portTypes'; import { type PropertyBag, textOf } from '../model/Properties'; import { propertyNodeParams } from '../properties/declaredRows'; import { flatDef } from '../properties/PropertySchema'; @@ -94,12 +96,41 @@ const anyOut = (key: string): PortDef => ({ key, label: key, type: 'any' }); export const asVec3 = (e: Expr): string => (e.type === 'vec3' ? e.code : `vec3(${e.code})`); -/** Two operands lifted to one type: a colour on either side makes the result a colour. */ -function lifted(a: Expr, b: Expr): { a: string; b: string; type: GlslType } { - if (a.type === 'vec3' || b.type === 'vec3') return { a: asVec3(a), b: asVec3(b), type: 'vec3' }; - return { a: a.code, b: b.code, type: 'float' }; +/** What operands of these types come out as: a colour on any side makes the result a colour. */ +export function liftedType(types: readonly GlslType[]): GlslType { + return types.includes('vec3') ? 'vec3' : 'float'; } +/** Two operands lifted to one type. */ +function lifted(a: Expr, b: Expr): { a: string; b: string; type: GlslType } { + const type = liftedType([a.type, b.type]); + return type === 'vec3' ? { a: asVec3(a), b: asVec3(b), type } : { a: a.code, b: b.code, type }; +} + +/** + * `liftedType` for the ports the editor draws (#219). An input nothing feeds is the number typed + * into it. One fed by a port that does not know its type yet leaves the result open, unless a + * colour on another input has settled it already. + */ +export function liftedPortType(sources: readonly (PortType | undefined)[]): PortType { + const types = sources.map(glslTypeOf); + const known = types.filter((type) => type !== undefined); + if (liftedType(known) === 'vec3') return 'vec3'; + return known.length === types.length ? 'number' : 'any'; +} + +function glslTypeOf(source: PortType | undefined): GlslType | undefined { + if (source === undefined || source === 'number') return 'float'; + return source === 'vec3' ? 'vec3' : undefined; +} + +/** `result` shows what the compiler makes of these inputs. */ +const lifts = (...inputs: string[]): SharedPortGroup => ({ + inputs, + outputs: ['result'], + agree: liftedPortType, +}); + function arithmetic( type: string, label: string, @@ -114,6 +145,7 @@ function arithmetic( description, inputs: [anyIn('a', bDefault === 1 ? 1 : 0), anyIn('b', bDefault)], outputs: [anyOut('result')], + sharedType: lifts('a', 'b'), params: [], emit: ({ a, b }) => { const l = lifted(a!, b!); @@ -294,6 +326,7 @@ export const ShaderDivide: ShaderNodeDef = { description: 'a / b, per channel for colors. Dividing by 0 is 0, not a pixel the driver picks.', inputs: [anyIn('a', 1), anyIn('b', 1)], outputs: [anyOut('result')], + sharedType: lifts('a', 'b'), params: [], emit: ({ a, b }) => { const l = lifted(a!, b!); @@ -308,6 +341,7 @@ export const ShaderClamp: ShaderNodeDef = { description: 'Holds the value inside min..max, per channel for colors.', inputs: [anyIn('value', 0), floatIn('min', 0), floatIn('max', 1)], outputs: [anyOut('result')], + sharedType: lifts('value'), params: [], emit: ({ value, min, max }) => ({ result: { code: `clamp(${value!.code}, ${min!.code}, ${max!.code})`, type: value!.type }, @@ -321,6 +355,7 @@ export const ShaderMix: ShaderNodeDef = { description: 'Blends from a to b: t = 0 is a, t = 1 is b, beyond that it extrapolates.', inputs: [anyIn('a', 0), anyIn('b', 1), floatIn('t', 0.5)], outputs: [anyOut('result')], + sharedType: lifts('a', 'b'), params: [], emit: ({ a, b, t }) => { const l = lifted(a!, b!); @@ -349,6 +384,7 @@ function unary(type: string, label: string, fn: string, description: string): Sh description, inputs: [anyIn('value', 0)], outputs: [anyOut('result')], + sharedType: lifts('value'), params: [], emit: ({ value }) => ({ result: { code: `${fn}(${value!.code})`, type: value!.type } }), }; @@ -386,6 +422,7 @@ export const ShaderStep: ShaderNodeDef = { description: '0 while value is below edge, 1 from edge on: a hard cut. Per channel for colors.', inputs: [floatIn('edge', 0.5), anyIn('value', 0)], outputs: [anyOut('result')], + sharedType: lifts('value'), params: [], emit: ({ edge, value }) => ({ result: { code: `step(${edge!.code}, ${value!.code})`, type: value!.type }, @@ -401,6 +438,7 @@ export const ShaderPow: ShaderNodeDef = { 'value to the power of exponent: 2 squares it, 0.5 is the square root. Below 0 the value counts as 0. Per channel for colors.', inputs: [anyIn('value', 0), floatIn('exponent', 2)], outputs: [anyOut('result')], + sharedType: lifts('value'), params: [], emit: ({ value, exponent }) => { const power = value!.type === 'vec3' ? `vec3(${exponent!.code})` : exponent!.code; @@ -417,6 +455,7 @@ export const ShaderSqrt: ShaderNodeDef = { 'The square root: 0.25 becomes 0.5, 4 becomes 2. Below 0 the value counts as 0. Per channel for colors.', inputs: [anyIn('value', 0)], outputs: [anyOut('result')], + sharedType: lifts('value'), params: [], emit: ({ value }) => ({ result: { code: `sqrt(max(${value!.code}, 0.0))`, type: value!.type }, diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 4c1dcd04..510b9b81 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -127,6 +127,11 @@ export function graphHooks({ state, editor }: HookContext) { nodeRect: (nodeId: string) => editor.shaderEditor.canvas.nodeRect(nodeId), nodeProblem: (nodeId: string) => editor.shaderEditor.canvas.nodeProblem(nodeId), nodeSettings: (nodeId: string) => editor.shaderEditor.canvas.nodeSettings(nodeId), + portAt: (nodeId: string, port: string, isOutput: boolean) => + editor.shaderEditor.canvas.portGlobal(nodeId, port, isOutput), + /** What an arithmetic node's result is drawn as: the type that really comes out (#219). */ + portTypes: (nodeId: string) => editor.shaderEditor.canvas.portTypes(nodeId), + edgeColor: (edgeId: string) => editor.shaderEditor.canvas.edgeColor(edgeId), canvasRect: () => editor.shaderEditor.canvas.canvasRect(), emptySpot: () => editor.shaderEditor.canvas.emptySpotGlobal(), center: () => centreOf(editor.shaderEditor.canvas), diff --git a/tests/core/shader-port-types.test.ts b/tests/core/shader-port-types.test.ts new file mode 100644 index 00000000..ca215962 --- /dev/null +++ b/tests/core/shader-port-types.test.ts @@ -0,0 +1,323 @@ +/** + * The arithmetic nodes of a shader graph show the type that really comes out (#219): a colour on + * any input makes `result` a colour, numbers alone make it a number. The rule is the compiler's + * (`liftedType`), so what the editor draws and what the fragment declares cannot drift apart. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; +import { + canConnect, + drawnDef, + findNode, + portsCompatible, + type PortType, + resolveDef, +} from '../../src/core/graph'; +import { graphLookup } from '../../src/core/model/graphLookup'; +import { findAssetOf } from '../../src/core/model/Project'; +import { compileShader } from '../../src/core/shader/compile'; +import { + type Expr, + type GlslType, + liftedPortType, + liftedType, + SHADER_NODES, + SHADER_NUMBER, + SHADER_OUTPUT, + SHADER_TEXTURE_ALPHA, + SHADER_TEXTURE_COLOR, +} from '../../src/core/shader/nodes'; +import { makeProject } from '../helpers'; + +function shader() { + const f = makeProject(); + const create = new CreateAssetCommand('shader', 'S'); + f.stack.execute(create); + const id = create.asset.id; + const graph = () => findAssetOf(f.project, id, 'shader')!.graph; + 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 def = (node: string) => resolveDef(findNode(graph(), node)!, graph(), graphLookup(f.project))!; + const result = (node: string) => def(node).outputs.find((p) => p.key === 'result')!.type; + const pin = (node: string, key: string) => { + const lookup = graphLookup(f.project); + const real = findNode(graph(), node)!; + return drawnDef(real, graph(), def(node), lookup).inputs.find((p) => p.key === key)!; + }; + const fits = (from: string, fromPort: string, to: string, toPort: string) => + canConnect( + graph(), + { nodeId: from, port: fromPort, isOutput: true }, + { nodeId: to, port: toPort, isOutput: false }, + graphLookup(f.project), + ); + return { ...f, graph, add, link, def, result, pin, fits }; +} + +describe('the type operands come out as', () => { + it('is a colour as soon as one of them is, in the compiler', () => { + expect(liftedType(['float', 'float'])).toBe('float'); + expect(liftedType(['vec3', 'float'])).toBe('vec3'); + expect(liftedType(['float', 'vec3'])).toBe('vec3'); + expect(liftedType(['vec3', 'vec3'])).toBe('vec3'); + expect(liftedType(['float'])).toBe('float'); + }); + + it('is the same for the ports, where an unconnected input is its typed number', () => { + expect(liftedPortType(['number', 'number'])).toBe('number'); + expect(liftedPortType([undefined, undefined])).toBe('number'); + expect(liftedPortType(['number', undefined])).toBe('number'); + expect(liftedPortType(['vec3', undefined])).toBe('vec3'); + expect(liftedPortType([undefined, 'vec3'])).toBe('vec3'); + expect(liftedPortType(['vec3', 'vec3'])).toBe('vec3'); + expect(liftedPortType([undefined])).toBe('number'); + expect(liftedPortType(['vec3'])).toBe('vec3'); + }); + + it('stays open while an input does not know its type, unless a colour has settled it', () => { + expect(liftedPortType(['any', 'number'])).toBe('any'); + expect(liftedPortType(['any', undefined])).toBe('any'); + expect(liftedPortType(['any'])).toBe('any'); + expect(liftedPortType(['any', 'vec3'])).toBe('vec3'); + expect(liftedPortType(['string', 'number'])).toBe('any'); + }); +}); + +describe('an arithmetic node of a shader graph', () => { + it('hands out a number while nothing is connected', () => { + const s = shader(); + for (const type of ['ShaderAdd', 'ShaderSubtract', 'ShaderMultiply', 'ShaderDivide', 'ShaderMix']) + expect(s.result(s.add(type)), type).toBe('number'); + }); + + it('hands out a colour once a colour feeds either input', () => { + const s = shader(); + const texel = s.add(SHADER_TEXTURE_COLOR); + const left = s.add('ShaderMultiply'); + const right = s.add('ShaderMultiply'); + s.link(texel, 'color', left, 'a'); + s.link(texel, 'color', right, 'b'); + expect(s.result(left)).toBe('vec3'); + expect(s.result(right)).toBe('vec3'); + }); + + it('stays a colour with a number on the other input, and a number with two numbers', () => { + const s = shader(); + const times = s.add('ShaderMultiply'); + s.link(s.add(SHADER_TEXTURE_ALPHA), 'value', times, 'b'); + expect(s.result(times)).toBe('number'); + s.link(s.add(SHADER_NUMBER), 'value', times, 'a'); + expect(s.result(times)).toBe('number'); + s.link(s.add(SHADER_TEXTURE_COLOR), 'color', times, 'a'); + expect(s.result(times)).toBe('vec3'); + }); + + it('is a number again once the colour edge is undone', () => { + const s = shader(); + const add = s.add('ShaderAdd'); + s.link(s.add(SHADER_TEXTURE_COLOR), 'color', add, 'a'); + expect(s.result(add)).toBe('vec3'); + s.stack.undo(); + expect(s.result(add)).toBe('number'); + }); + + it('keeps its inputs any and paints the connected one', () => { + const s = shader(); + const add = s.add('ShaderAdd'); + s.link(s.add(SHADER_TEXTURE_COLOR), 'color', add, 'a'); + expect(s.def(add).inputs.map((p) => p.type)).toEqual(['any', 'any']); + expect(s.pin(add, 'a').paintType).toBe('vec3'); + expect(s.pin(add, 'b').paintType).toBeUndefined(); + }); + + it('lets Mix follow a and b only: a colour on t is not what comes out', () => { + const s = shader(); + const mix = s.add('ShaderMix'); + expect(s.def(mix).inputs.find((p) => p.key === 't')!.type).toBe('number'); + s.link(s.add(SHADER_TEXTURE_ALPHA), 'value', mix, 't'); + expect(s.result(mix)).toBe('number'); + s.link(s.add(SHADER_TEXTURE_COLOR), 'color', mix, 'b'); + expect(s.result(mix)).toBe('vec3'); + }); +}); + +describe('a node of one value in a shader graph', () => { + const ONE_VALUE = [ + 'ShaderSin', + 'ShaderFloor', + 'ShaderFract', + 'ShaderAbs', + 'ShaderSqrt', + 'ShaderStep', + 'ShaderPow', + 'ShaderClamp', + ]; + + it('hands on what feeds its value, and a number while nothing does', () => { + for (const type of ONE_VALUE) { + const s = shader(); + const node = s.add(type); + expect(s.result(node), type).toBe('number'); + s.link(s.add(SHADER_TEXTURE_ALPHA), 'value', node, 'value'); + expect(s.result(node), type).toBe('number'); + s.link(s.add(SHADER_TEXTURE_COLOR), 'color', node, 'value'); + expect(s.result(node), type).toBe('vec3'); + } + }); + + it('does not follow its number inputs: edge, exponent, min and max say nothing', () => { + const s = shader(); + const alpha = s.add(SHADER_TEXTURE_ALPHA); + const texel = s.add(SHADER_TEXTURE_COLOR); + for (const [type, key] of [ + ['ShaderStep', 'edge'], + ['ShaderPow', 'exponent'], + ['ShaderClamp', 'min'], + ['ShaderClamp', 'max'], + ] as const) { + const node = s.add(type); + s.link(alpha, 'value', node, key); + s.link(texel, 'color', node, 'value'); + expect(s.result(node), `${type}.${key}`).toBe('vec3'); + } + }); +}); + +describe('every node of a shader graph with an open result', () => { + const OPEN = SHADER_NODES.filter((def) => def.outputs.some((p) => p.type === 'any')); + const expr = (type: GlslType): Expr => ({ code: 'x', type }); + const portOf = (type: GlslType): PortType => (type === 'vec3' ? 'vec3' : 'number'); + + it('declares the group that types it', () => { + expect(OPEN.length).toBe(13); + for (const def of OPEN) { + expect(def.sharedType?.outputs, def.type).toEqual(['result']); + const open = def.inputs.filter((p) => p.type === 'any').map((p) => p.key); + expect(def.sharedType?.inputs, def.type).toEqual(open); + } + }); + + it('shows the type the compiler emits, for every mix of numbers and colours', () => { + for (const def of OPEN) { + const group = def.sharedType!; + const mixes = + group.inputs.length === 1 + ? [['float'], ['vec3']] + : [ + ['float', 'float'], + ['vec3', 'float'], + ['float', 'vec3'], + ['vec3', 'vec3'], + ]; + for (const mix of mixes as GlslType[][]) { + const inputs: Record = {}; + for (const port of def.inputs) inputs[port.key] = expr('float'); + group.inputs.forEach((key, i) => (inputs[key] = expr(mix[i]!))); + const emitted = def.emit(inputs, {}).result!.type; + expect(group.agree!(mix.map(portOf)), `${def.type} ${mix.join('+')}`).toBe(portOf(emitted)); + } + } + }); +}); + +describe('an edge out of a typed result', () => { + it('no longer fits a number input once the result is a colour', () => { + const s = shader(); + const times = s.add('ShaderMultiply'); + const output = s.add(SHADER_OUTPUT); + const mix = s.add('ShaderMix'); + expect(s.fits(times, 'result', output, 'alpha')).toBe(true); + expect(s.fits(times, 'result', mix, 't')).toBe(true); + + s.link(s.add(SHADER_TEXTURE_COLOR), 'color', times, 'a'); + expect(s.fits(times, 'result', output, 'alpha')).toBe(false); + expect(s.fits(times, 'result', mix, 't')).toBe(false); + expect(s.fits(times, 'result', output, 'color')).toBe(true); + expect(() => s.link(times, 'result', output, 'alpha')).toThrow('Cannot connect vec3 to number'); + }); + + it('still fits a colour input while the result is a number: the compiler spreads it', () => { + const s = shader(); + const times = s.add('ShaderMultiply'); + const output = s.add(SHADER_OUTPUT); + s.link(s.add(SHADER_TEXTURE_ALPHA), 'value', times, 'a'); + expect(s.result(times)).toBe('number'); + expect(s.fits(times, 'result', output, 'color')).toBe(true); + s.link(times, 'result', output, 'color'); + const { compiled } = compileShader(s.graph()); + expect(compiled!.fragment).toMatch( + /finalColor = vec4\(clamp\(vec3\(n\d+_result\), 0\.0, 1\.0\) \* alpha, alpha\);/, + ); + }); + + it('lets a plain number feed a colour port, and never a colour a number port', () => { + expect(portsCompatible('number', 'vec3')).toBe(true); + expect(portsCompatible('vec3', 'number')).toBe(false); + expect(portsCompatible('boolean', 'vec3')).toBe(false); + expect(portsCompatible('number', 'string')).toBe(false); + + const s = shader(); + const output = s.add(SHADER_OUTPUT); + expect(s.fits(s.add(SHADER_NUMBER), 'value', output, 'color')).toBe(true); + expect(s.fits(s.add(SHADER_TEXTURE_COLOR), 'color', output, 'alpha')).toBe(false); + }); + + it('stays in place and turns invalid when a colour arrives behind it', () => { + const s = shader(); + const times = s.add('ShaderMultiply'); + const output = s.add(SHADER_OUTPUT); + s.link(times, 'result', output, 'alpha'); + s.link(s.add(SHADER_TEXTURE_COLOR), 'color', times, 'a'); + expect(s.graph().edges).toHaveLength(2); + const alpha = s.def(output).inputs.find((p) => p.key === 'alpha')!.type; + expect(portsCompatible(s.result(times), alpha)).toBe(false); + expect(compileShader(s.graph()).problems).toEqual({ [output]: '"alpha" needs a number, not a color' }); + }); +}); + +describe('a result handed on through several arithmetic nodes (#210)', () => { + it('carries the colour to the end of the chain', () => { + const s = shader(); + const times = s.add('ShaderMultiply'); + const plus = s.add('ShaderAdd'); + const wave = s.add('ShaderSin'); + s.link(times, 'result', plus, 'a'); + s.link(plus, 'result', wave, 'value'); + expect([times, plus, wave].map(s.result)).toEqual(['number', 'number', 'number']); + + s.link(s.add(SHADER_TEXTURE_COLOR), 'color', times, 'b'); + expect([times, plus, wave].map(s.result)).toEqual(['vec3', 'vec3', 'vec3']); + expect(s.pin(wave, 'value').paintType).toBe('vec3'); + }); + + it('takes a colour joining in the middle: in front of it the chain stays a number', () => { + const s = shader(); + const times = s.add('ShaderMultiply'); + const plus = s.add('ShaderAdd'); + const wave = s.add('ShaderSin'); + s.link(times, 'result', plus, 'a'); + s.link(plus, 'result', wave, 'value'); + s.link(s.add(SHADER_TEXTURE_COLOR), 'color', plus, 'b'); + expect([times, plus, wave].map(s.result)).toEqual(['number', 'vec3', 'vec3']); + }); + + it('ends a ring with an open result instead of asking forever', () => { + const s = shader(); + const one = s.add('ShaderAdd'); + const two = s.add('ShaderAdd'); + s.link(one, 'result', two, 'a'); + s.link(two, 'result', one, 'a'); + expect(s.result(one)).toBe('any'); + expect(s.result(two)).toBe('any'); + + s.link(s.add(SHADER_TEXTURE_COLOR), 'color', two, 'b'); + expect(s.result(two)).toBe('vec3'); + expect(s.result(one)).toBe('vec3'); + }); +}); diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index ac64e3e7..ce7db9f1 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -320,13 +320,14 @@ describe('compileShader', () => { 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. + // A result wired to the alpha port while it was a number, and fed a colour afterwards, 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'); + wrong.link(texel, 'color', doubled, 'a'); const build = compileShader(wrong.asset().graph); expect(build.compiled).toBeNull(); expect(build.problems).toEqual({ [out]: '"alpha" needs a number, not a color' }); @@ -335,13 +336,14 @@ describe('compileShader', () => { it('names the problem on the node, and compiles nothing while there is one', () => { const f = makeProject(); const s = shaderEditor(f); - // A colour cannot be wired to a number port; a Multiply's `any` result can, and carries one. + // A colour cannot be wired to a number port; a Multiply's result can while it is a number, and + // the edge stays when a colour feeds the Multiply afterwards. const texel = s.add(SHADER_TEXTURE_COLOR); const doubled = s.add('ShaderMultiply', { b: 2 }); const mixed = s.add('ShaderMix'); const output = s.add(SHADER_OUTPUT); - s.link(texel, 'color', doubled, 'a'); s.link(doubled, 'result', mixed, 't'); + s.link(texel, 'color', doubled, 'a'); s.link(mixed, 'result', output, 'color'); let build = compileShader(s.asset().graph); expect(build.compiled).toBeNull(); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 852e438a..ef1bf19b 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -581,6 +581,94 @@ export async function shader(t) { const held = await pixel(-0.3, -0.3); check('Clamp at 0..0.25 holds full red down to a quarter', colourDistance(held, '#400000') < 6, held); + // 28k-vii. An arithmetic node shows the type that really comes out (#219): a number until a + // colour is dragged into it, a colour from then on – along the chain behind it too. A result + // that is a colour no longer lands on a number input, and an edge wired while it was a number + // stays and turns red. + await graph( + [ + node('tex', 'ShaderTextureColor', {}, 0, 0), + node('times', 'ShaderMultiply', {}, 0, 140), + node('sum', 'ShaderAdd', {}, 0, 300), + node('out', 'ShaderOutput', {}, 0, 460), + ], + [edge('times', 'result', 'sum', 'a'), edge('times', 'result', 'out', 'alpha')], + ); + await showTab('shader'); + await clickText('Fit'); + await sleep(150); + const types = (id) => json(`__gm.shader.portTypes(${JSON.stringify(id)})`); + const portOf = (id, port, isOutput) => + json(`__gm.shader.portAt(${JSON.stringify(id)}, ${JSON.stringify(port)}, ${isOutput})`); + const wire = async (from, fromPort, to, toPort) => { + const a = await portOf(from, fromPort, true); + const b = await portOf(to, toPort, false); + await drag(a.x, a.y, b.x, b.y, 16); + }; + const edgeIds = () => + json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).graph.edges.map((e) => e.from.node + '.' + e.from.port + '>' + e.to.node + '.' + e.to.port)`, + ); + const edgeColor = (id) => json(`__gm.shader.edgeColor(${JSON.stringify(id)})`); + const theme = (name) => json(`__gm.themeColor(${JSON.stringify(name)})`); + const plain = { + times: await types('times'), + sum: await types('sum'), + toAlpha: await edgeColor('times-out-alpha'), + info: await info(), + }; + await wire('tex', 'color', 'times', 'a'); + const lifted = { + times: await types('times'), + sum: await types('sum'), + chain: await edgeColor('times-sum-a'), + toAlpha: await edgeColor('times-out-alpha'), + info: await info(), + problem: await json("__gm.shader.nodeProblem('out')"), + }; + check( + 'a Multiply is a number until a colour is dragged into it, then a colour, and so is the Add behind it', + plain.times.outputs.result === 'number' && + plain.sum.outputs.result === 'number' && + lifted.times.outputs.result === 'vec3' && + lifted.times.inputs.a === 'any' && + lifted.times.paint.a === 'vec3' && + lifted.sum.outputs.result === 'vec3' && + lifted.sum.paint.a === 'vec3' && + lifted.chain === (await theme('portVec3')), + JSON.stringify({ plain, lifted }), + ); + check( + 'the edge wired to alpha while the result was a number stays, turns red and is the Output’s problem', + plain.toAlpha === (await theme('portNumber')) && + plain.info === '0 properties' && + lifted.toAlpha === (await theme('danger')) && + lifted.problem === '"alpha" needs a number, not a color' && + lifted.info === lifted.problem && + (await edgeIds()).includes('times.result>out.alpha'), + JSON.stringify({ plain, lifted, edges: await edgeIds() }), + ); + await shot('28-lifted-result'); + await mutate(` + const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); + a.graph.edges = a.graph.edges.filter((e) => e.to.node !== 'out');`); + const before = await edgeIds(); + await wire('sum', 'result', 'out', 'alpha'); + const refused = await edgeIds(); + await press('Escape'); + await wire('sum', 'result', 'out', 'color'); + const taken = await edgeIds(); + check( + 'a result that is a colour does not land on the number input alpha, and does on color', + before.length === 2 && + refused.length === 2 && + taken.length === 3 && + taken.includes('sum.result>out.color') && + (await info()) === '0 properties', + JSON.stringify({ before, refused, taken, info: await info() }), + ); + await showTab('scene'); + // 28l. Sample texture reads the picture where the graph says. On a pasted two-colour picture: // flipped u swaps the halves; u + 0.5 past the border shows the Output's Edge – Repeat continues // with the far half, Mirror with the near one.