diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index e06d5b0d..d42c7415 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -306,6 +306,17 @@ export function schemaFor(type: AssetType): PropertySchema { return FULL_SCHEMAS[type]; } +const SPRITE_KEYS = new Set(FULL_SCHEMAS.sprite.map((d) => d.key)); + +/** + * A row every sprite has already: the switch, the transform and the sprite's own. A shader + * parameter under one of these names would be a second row on the same bag key, and its uniform + * would read the sprite's value instead of the default the Property node declares (#63). + */ +export function isSpriteProp(key: string): boolean { + return SPRITE_KEYS.has(key); +} + /** * The type schema, for behaviors followed by their declared variables, for sprites by the * parameters of the shader asset their bag names – which is why the project is needed. diff --git a/src/core/properties/shaders.ts b/src/core/properties/shaders.ts index 2b573fb0..a4583f4d 100644 --- a/src/core/properties/shaders.ts +++ b/src/core/properties/shaders.ts @@ -1,6 +1,6 @@ import type { Graph, GraphNode } from '../graph/Graph'; import { type PropertyBag, textOf } from '../model/Properties'; -import { type FlatDef, isValidFieldName } from './PropertySchema'; +import { type FlatDef, isSpriteProp, isValidFieldName } from './PropertySchema'; /** The sprite property naming the shader asset it wears; `''` is no shader and no render pass. */ export const SHADER_KEY = 'shader'; @@ -27,9 +27,13 @@ export function propertyName(node: GraphNode): string { return textOf(node.params[PROPERTY_NAME_PARAM] ?? ''); } -/** A property's name is a bag key and a uniform, so it has to be an identifier. */ +/** + * A property's name is a bag key and a uniform, so it has to be an identifier – and not one of the + * rows the sprite brings itself: both rows would share the key and the uniform would read the + * sprite's width instead of the declared default (#63). The compiler names which of the two it is. + */ export function isValidPropertyName(name: string): boolean { - return isValidFieldName(name); + return isValidFieldName(name) && !isSpriteProp(name); } /** diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index 60a03dc4..b99826b0 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -1,9 +1,11 @@ import { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph'; import { nodeDef, type PortDef } from '../graph/NodeRegistry'; import { colorOf } from '../model/Properties'; -import { isPropertyNode, isValidPropertyName, propertyName, shaderParamDefs } from '../properties/shaders'; +import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema'; +import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders'; import { asVec3, + DIVIDE_FN, type EdgeMode, edgeOf, type Expr, @@ -74,7 +76,8 @@ export function compileShader(graph: Graph): ShaderBuild { for (const node of graph.nodes) { if (!isPropertyNode(node)) continue; const name = propertyName(node); - if (!isValidPropertyName(name)) problem(node, 'Name must be one word: letters, digits, _'); + if (!isValidFieldName(name)) problem(node, 'Name must be one word: letters, digits, _'); + else if (isSpriteProp(name)) problem(node, `"${name}" is a sprite property`); else if (names.has(name)) problem(node, `Another property is named "${name}"`); names.add(name); } @@ -172,6 +175,7 @@ export function compileShader(graph: Graph): ShaderBuild { ...(time ? [`uniform float ${TIME_UNIFORM};`] : []), ...uniforms.map((u) => `uniform float ${uniformName(u.name)};`), STRAIGHT_FN, + DIVIDE_FN, 'void main()', '{', ' vec4 texel = texture(uTexture, vTextureCoord) * vColor;', diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index c5c2d6b3..dcf898a7 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -287,7 +287,43 @@ export const ShaderMultiply = arithmetic( 'a × b, per channel for colours.', 1, ); -export const ShaderDivide = arithmetic('ShaderDivide', 'Divide', '/', 'a / b, per channel for colours.', 1); +/** + * Division with an answer at zero. `a / 0.0` is NaN, and the epilog's `clamp(NaN, 0.0, 1.0)` is + * undefined in GLSL – the driver decides the pixel, so the same graph looks different on Intel, + * Nvidia and a phone (#64). Zero is the answer, the same one the `Divide` of a behavior graph and + * of a particle graph give. + */ +export const DIVIDE_FN = [ + 'float divide(float a, float b) { return b == 0.0 ? 0.0 : a / b; }', + 'vec3 divide(vec3 a, vec3 b) { return vec3(divide(a.x, b.x), divide(a.y, b.y), divide(a.z, b.z)); }', +].join('\n'); + +export const ShaderDivide: ShaderNodeDef = { + type: 'ShaderDivide', + label: 'Divide', + category: 'shader', + description: 'a / b, per channel for colours. Dividing by 0 is 0, not a pixel the driver picks.', + inputs: [anyIn('a', 1), anyIn('b', 1)], + outputs: [anyOut('result')], + params: [], + emit: ({ a, b }) => { + const l = lifted(a!, b!); + return { result: { code: `divide(${l.a}, ${l.b})`, type: l.type } }; + }, +}; + +export const ShaderClamp: ShaderNodeDef = { + type: 'ShaderClamp', + label: 'Clamp', + category: 'shader', + description: 'Holds the value inside min..max, per channel for colours.', + inputs: [anyIn('value', 0), floatIn('min', 0), floatIn('max', 1)], + outputs: [anyOut('result')], + params: [], + emit: ({ value, min, max }) => ({ + result: { code: `clamp(${value!.code}, ${min!.code}, ${max!.code})`, type: value!.type }, + }), +}; export const ShaderMix: ShaderNodeDef = { type: 'ShaderMix', @@ -423,6 +459,7 @@ export const SHADER_NODES: readonly ShaderNodeDef[] = [ ShaderStep, ShaderPow, ShaderSqrt, + ShaderClamp, ]; /** A GLSL float literal: always with a decimal point, so `1` does not become an int. */ diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 2453c6e7..8103720c 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -373,6 +373,71 @@ describe('compileShader', () => { expect(build.problems).toEqual({ [two]: 'Feeds itself' }); }); + it('refuses a property named like a row the sprite brings itself (#63)', () => { + const f = makeSpriteScene(); + const s = shaderEditor(f); + s.add(SHADER_OUTPUT); + // One of the sprite's own rows, one of the transform rows, one legitimate name. + const wide = s.add(SHADER_PROPERTY, property('width')); + const turned = s.add(SHADER_PROPERTY, property('rotation')); + const fine = s.add(SHADER_PROPERTY, property('amount')); + const build = compileShader(s.asset().graph); + expect(build.compiled).toBeNull(); + expect(build.problems).toEqual({ + [wide]: '"width" is a sprite property', + [turned]: '"rotation" is a sprite property', + }); + expect(build.problems[fine]).toBeUndefined(); + // The row is not declared either, so the sprite keeps one width row – the one that is 100. + expect(shaderParamDefs(s.asset().graph).map((r) => r.key)).toEqual(['amount']); + const sprite = findAssetOf(f.project, f.spriteId, 'sprite')!; + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, s.id)); + const rows = schemaForAsset(f.project, sprite).filter((d) => d.key === 'width'); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ label: 'Width', default: 100 }); + }); + + it('divides through a guard, so dividing by zero is 0 and not the driver’s choice (#64)', () => { + const f = makeProject(); + const s = shaderEditor(f); + const zero = s.add(SHADER_NUMBER, { value: 0 }); + const half = s.add('ShaderDivide', { a: 1 }); + const held = s.add('ShaderClamp', { min: 0, max: 0.5 }); + const output = s.add(SHADER_OUTPUT); + s.link(zero, 'value', half, 'b'); + s.link(half, 'result', held, 'value'); + s.link(held, 'result', output, 'color'); + const { fragment } = compileShader(s.asset().graph).compiled!; + expect(fragment).toContain('float divide(float a, float b) { return b == 0.0 ? 0.0 : a / b; }'); + expect(fragment).toMatch(/float n\d+_result = divide\(1\.0, n\d+_value\);/); + expect(fragment).toMatch(/float n\d+_result = clamp\(n\d+_result, 0\.0, 0\.5\);/); + // Nothing writes the bare operator any more – that was the NaN (#64). + expect(fragment).not.toMatch(/n\d+_\w+ \/ n\d+_\w+/); + + // A colour divides per channel, through the vec3 overload of the same guard. + const c = shaderEditor(f, 'Faded'); + const texel = c.add(SHADER_TEXTURE_COLOR); + const by = c.add('ShaderDivide', { b: 2 }); + const out = c.add(SHADER_OUTPUT); + c.link(texel, 'color', by, 'a'); + c.link(by, 'result', out, 'color'); + const coloured = compileShader(c.asset().graph).compiled!.fragment; + expect(coloured).toContain( + 'vec3 divide(vec3 a, vec3 b) { return vec3(divide(a.x, b.x), divide(a.y, b.y), divide(a.z, b.z)); }', + ); + expect(coloured).toMatch(/vec3 n\d+_result = divide\(n\d+_color, vec3\(2\.0\)\);/); + // A clamped colour keeps its type: clamp(vec3, float, float) is one of the GLSL overloads. + const k = shaderEditor(f, 'Held'); + const rgb = k.add(SHADER_TEXTURE_COLOR); + const clamped = k.add('ShaderClamp', { min: 0.25, max: 0.75 }); + const out2 = k.add(SHADER_OUTPUT); + k.link(rgb, 'color', clamped, 'value'); + k.link(clamped, 'result', out2, 'color'); + expect(compileShader(k.asset().graph).compiled!.fragment).toMatch( + /vec3 n\d+_result = clamp\(n\d+_color, 0\.25, 0\.75\);/, + ); + }); + it('gives the graph the pixel’s place, the picture at another spot, its size and the clock', () => { const f = makeProject(); const s = shaderEditor(f, 'Flip'); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 68c67446..ed1c956b 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -546,6 +546,82 @@ export async function shader(t) { { foldLeft, foldMiddle, foldRight }, ); + // 28k-iv. A property named like a row the sprite has anyway is a problem on the node (#63): both + // rows would sit on the same bag key and the uniform would read the sprite's width, 160 here, + // instead of the default the node declares. Renaming it compiles the same graph. + const namedProperty = (name) => + graph( + [ + node('named', 'ShaderProperty', { name, default: 0.25, min: 0, max: 1 }), + node('red', 'ShaderColor', { value: 0xff0000 }, 260, 200), + node('dim', 'ShaderMultiply', {}, 520, 0), + node('out', 'ShaderOutput', {}, 780, 0), + ], + [ + edge('named', 'value', 'dim', 'b'), + edge('red', 'color', 'dim', 'a'), + edge('dim', 'result', 'out', 'color'), + ], + ); + await namedProperty('width'); + await sleep(400); + const spriteProp = await info(); + check( + 'a property named like a sprite row is a problem on the node, not a second width row (#63)', + (await json("__gm.shader.nodeProblem('named')")) === '"width" is a sprite property' && + spriteProp === '"width" is a sprite property', + JSON.stringify({ spriteProp, problem: await json("__gm.shader.nodeProblem('named')") }), + ); + await namedProperty('amount'); + await sleep(400); + check( + 'the same graph under a free name compiles: a quarter of the red', + (await info()) === '1 property' && colourDistance(await pixel(-0.3, -0.3), '#400000') < 6, + JSON.stringify({ info: await info(), pixel: await pixel(-0.3, -0.3) }), + ); + + // 28k-v. Dividing by zero has an answer (#64). `a / 0.0` is NaN, and the epilog's clamp(NaN) is + // undefined in GLSL – the pixel would be the driver's choice. The guard makes it 0, so the sprite + // goes black instead of "whatever this machine does". + const dividedBy = (value) => + graph( + [ + node('red', 'ShaderColor', { value: 0xff0000 }), + node('by', 'ShaderNumber', { value }, 260, 200), + node('div', 'ShaderDivide', {}, 520, 0), + node('out', 'ShaderOutput', {}, 780, 0), + ], + [ + edge('red', 'color', 'div', 'a'), + edge('by', 'value', 'div', 'b'), + edge('div', 'result', 'out', 'color'), + ], + ); + await dividedBy(2); + await sleep(400); + const halved = await pixel(-0.3, -0.3); + await dividedBy(0); + await sleep(400); + const byZero = await pixel(-0.3, -0.3); + check( + 'Divide by 2 halves the red, and dividing by 0 is 0 – a black pixel, not the driver’s choice (#64)', + colourDistance(halved, '#800000') < 6 && colourDistance(byZero, '#000000') < 6, + JSON.stringify({ halved, byZero }), + ); + + // 28k-vi. Clamp holds a value inside a range, per channel for a colour. + await graph( + [ + node('red', 'ShaderColor', { value: 0xff0000 }), + node('hold', 'ShaderClamp', { min: 0, max: 0.25 }, 260, 0), + node('out', 'ShaderOutput', {}, 520, 0), + ], + [edge('red', 'color', 'hold', 'value'), edge('hold', 'result', 'out', 'color')], + ); + await sleep(400); + 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); + // 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.