import { colourDistance } from '../lib/harness.mjs'; import { FIND_DOT, FIND_HALVES, HALVES_LEFT as LEFT, HALVES_RIGHT as RIGHT, PASTE_DOT, PASTE_HALVES, } from './lib/pictures.mjs'; import { pickAssetType } from './lib/scaffold.mjs'; /** * Shader assets: made from the + menu, built from nodes in the Shader tab, worn by a sprite through * its Shader dropdown – whose Property nodes are the rows below it – and visible in the pixels, in * the scene view and in the game – and in the tab's own preview of the picture selected last (28n). * The fixture ships "Adjust colors"; the step removes what it made. */ /** (64, 128, 255): three distinct channels, so brightness, contrast and saturation each read differently. */ const BASE = '#4080ff'; const ADJUST = 'adjust-colors'; export async function shader(t) { const { check, sleep, json, evaluate, mutate, drag, click, clickText, locate, press, select, undo, play, stop, shot, showTab, } = t; const { doubleClick, typeText } = t; const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`); const info = () => json('__gm.shader.info()'); const shaders = () => json(`__gm.state.project.assets .filter((a) => a.type === 'shader') .map((a) => ({ id: a.id, name: a.name, nodes: a.graph.nodes.map((n) => ({ id: n.id, type: n.type, name: n.params.name })) }))`); /** The overlay is drawn last, so a menu entry wins over a tab button or a row saying the same. */ const clickLast = async (text) => { const p = await locate(text, true); await click(p.x, p.y); }; const assetProps = (patch) => mutate( `Object.assign(__gm.state.project.assets.find((a) => a.id === 'shade-sprite').props, ${JSON.stringify(patch)})`, ); await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); // 28a. The fixture ships Adjust colors as an asset; the + menu makes another and opens its tab on it. const seeded = (await shaders()).find((s) => s.id === ADJUST); check( 'the fixture carries the Adjust colors shader as an asset of twelve nodes', Boolean(seeded) && seeded.nodes.length === 12, JSON.stringify(seeded), ); await clickText('+'); await pickAssetType(t, 'Shader'); const made = (await shaders()).find((s) => s.id !== ADJUST); const selection = await json('__gm.state.selection'); check( 'the + menu creates a shader asset, selected, and its tab opens on it', Boolean(made) && selection?.kind === 'asset' && selection.assetId === made.id && (await json('__gm.tab()')) === 'shader' && (await json('__gm.shader.current()')) === made.id, JSON.stringify({ made, selection, tab: await json('__gm.tab()') }), ); if (!made) return; check('the tab button has room for its label', (await json("__gm.shownLabel('Shader')")) === 'Shader'); // Named after its type, the asset's row in the Assets panel would say "Shader" like the inspector row. await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).name = 'Glow'`); made.name = 'Glow'; check('an empty shader says what it lacks', (await info()) === 'No Output node', await info()); // 28b. Right-click offers shader nodes only; Texture color and Output placed from the palette. const canvas = await json('__gm.shader.canvasRect()'); // The preview takes the right of the tab (#168); the second column stays inside what is left. const right = canvas.x + Math.min(420, canvas.w - 100); await click(canvas.x + 80, canvas.y + 60, 'right'); const offered = await json('__gm.overlayTexts()'); check( 'right-click offers shader nodes and no behavior node', ['Texture color', 'Output', 'Property', 'Number', 'Mix', 'Luminance'].every((text) => offered.includes(text), ) && !offered.some((text) => /On start|Boolean|Branch|Set state/.test(text)), JSON.stringify(offered), ); // A dozen entries scroll; searched, the row is the only one and in view. await typeText('Texture color'); await clickLast('Texture color'); await click(right, canvas.y + 60, 'right'); await clickLast('Output'); const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? []; check( 'both nodes are in the graph, the Output where its right-click was', (await madeNodes()).map((n) => n.type).join(',') === 'ShaderTextureColor,ShaderOutput', JSON.stringify(await madeNodes()), ); await click(right, canvas.y + 320, 'right'); const again = await json('__gm.overlayTexts()'); check( 'the palette offers the Output only once', !again.includes('Output') && again.includes('Property'), JSON.stringify(again), ); await press('Escape'); check( 'an Output with nothing connected compiles: the texel passes through, no property declared', (await info()) === '0 properties', await info(), ); // 28c. A Property node: its name is the row a sprite shows; a bad name is a problem on the node. await click(canvas.x + 80, canvas.y + 320, 'right'); // Property is the twelfth entry, below the palette's scroll mask: search it into view first. await typeText('Property'); await clickLast('Property'); const propertyNode = (await madeNodes()).find((n) => n.type === 'ShaderProperty'); check( 'the placed property is named "value" and counted', propertyNode?.name === 'value' && (await info()) === '1 property', JSON.stringify({ propertyNode, info: await info() }), ); const nameField = await json("__gm.inspectorField('Name', true)"); check('the inspector shows a Name field for the property', Boolean(nameField), JSON.stringify(nameField)); await doubleClick(nameField.x, nameField.y); await typeText('glow'); await press('Enter'); check( 'typing in the Name field renames the property, on the node too', (await madeNodes()).find((n) => n.id === propertyNode?.id)?.name === 'glow' && (await json(`__gm.shader.nodeLabel(${JSON.stringify(propertyNode?.id)})`)) === 'glow', JSON.stringify(await madeNodes()), ); await shot('28-shader-tab'); await mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'no good';`); const problem = await json(`__gm.shader.nodeProblem(${JSON.stringify(propertyNode?.id)})`); check( 'a name that is not one word is a problem on the node and the reason in the readout', typeof problem === 'string' && problem.startsWith('Name must be') && (await info()) === problem, JSON.stringify({ problem, info: await info() }), ); await mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'glow';`); // 28c-ii. Where a node sits is the editor's business: dropping it somewhere else compiles nothing // again, a changed param does – once (#65). const nodeRef = JSON.stringify(propertyNode?.id); const compiledBefore = await json('__gm.shaderCompiles()'); const grab = await json(`__gm.shader.nodeAt(${nodeRef})`); await drag(grab.x, grab.y, grab.x + 60, grab.y + 40); const dropped = await json(`__gm.shader.nodeAt(${nodeRef})`); const compiledAfterMove = await json('__gm.shaderCompiles()'); check( 'dropping a node somewhere else moves it without compiling the graph again', dropped.x > grab.x + 30 && compiledAfterMove === compiledBefore, JSON.stringify({ grab, dropped, compiledBefore, compiledAfterMove }), ); const defaultBefore = await json( `__gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}).graph.nodes.find((n) => n.id === ${nodeRef}).params.default`, ); const setDefault = (value) => mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); a.graph.nodes.find((n) => n.id === ${nodeRef}).params.default = ${JSON.stringify(value)};`); await setDefault(2); const compiledAfterParam = await json('__gm.shaderCompiles()'); check( 'changing a param compiles it again, once', compiledAfterParam === compiledAfterMove + 1, JSON.stringify({ compiledAfterMove, compiledAfterParam }), ); await setDefault(defaultBefore); // 28d. A flat sprite on the camera, drawn on top of everything; its Shader dropdown lists the assets. await mutate(` const project = __gm.state.project; const sprite = structuredClone(project.assets.find((a) => a.id === 'crate')); sprite.id = 'shade-sprite'; sprite.name = 'Shaded'; sprite.props = { ...sprite.props, width: 160, height: 120, color: 0x4080ff, texture: '', shader: '' }; project.assets.push(sprite); const scene = __gm.state.activeScene; const cam = scene.objects.find((o) => o.id === 'o3').position; scene.objects.push({ id: 'oshade', assetId: 'shade-sprite', name: '', position: { x: cam.x - 190, y: cam.y - 105, z: 950 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} }); __gm.state.select(null)`); await sleep(600); // The pixels are the scene view's; the tab is still the shader's. await showTab('scene'); await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' }); const box = await json("__gm.objectRect('oshade')"); check('the shaded sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box)); if (!box) return; const sample = (fx, fy) => json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`); // Off centre: a symbol may sit on the sprite's middle, and symbols draw above objects. const centre = () => sample(0.3, 0.3); const base = await centre(); check('with no shader the sprite is its plain colour', colourDistance(base, BASE) < 12, base); // Every later check reads pixels at its own spot on this sprite, so nothing may be drawn over it. // The step parks the sprite away from the camera for that reason: symbols draw above objects, and // by this point the earlier steps have left containers and colliders around the origin. Without // this guard their outlines show up as a wrong colour in whichever check happens to sample them. // Since names stand on plates, a plate covers what a thin name let through: the sprite sat right // of the camera once, where the Crate pair container's plate reaches into it, so it sits up left. const dirty = []; for (let fx = -0.4; fx <= 0.401; fx += 0.1) for (let fy = -0.4; fy <= 0.401; fy += 0.1) { const seen = await sample(fx, fy); if (colourDistance(seen, BASE) >= 12) dirty.push(`${fx.toFixed(1)},${fy.toFixed(1)}:${seen}`); } check('nothing is drawn over the sprite the pixel checks read', dirty.length === 0, dirty.join(' ')); // The Shader row's dropdown reads "None"; it is the last such text in the inspector (Parent and // Texture say it above). The label itself is no anchor: the tab button says "Shader" too. // With its groups under bands (#290) the inspector is taller than the window: scrolled to its end. const panelRect = await json('__gm.inspectorRect()'); await t.wheel(panelRect.x + 40, panelRect.y + panelRect.h / 2, 2000); await t.settle(); const shaderField = await locate('None', true); check( 'a sprite has a Shader row reading None, and no shader brings no parameter rows', shaderField.x > box.x + box.w && (await field('brightness')) === null, JSON.stringify(shaderField), ); await click(shaderField.x, shaderField.y); const choices = await json('__gm.overlayTexts()'); check( 'the dropdown offers None and every shader asset', choices.some((text) => text.includes('None')) && choices.some((text) => text.includes('Adjust colors')) && choices.some((text) => text.includes(made.name)), JSON.stringify({ choices, shaderField }), ); await clickLast(choices.find((text) => text.includes('Adjust colors'))); const rows = await Promise.all([field('brightness'), field('contrast'), field('saturation')]); check( 'picking Adjust colors adds its three property rows below the dropdown', rows.every((r) => r !== null) && (await json("__gm.state.activeScene.objects.find((o) => o.id === 'oshade').overrides.shader")) === ADJUST, JSON.stringify(rows), ); await shot('28-shader-rows'); await undo(); check('undoing the choice takes the rows away again', (await field('brightness')) === null); await select(null); // 28e. Each property is visible in the pixels, with 1 neutral. await assetProps({ shader: ADJUST }); const neutral = await centre(); check( 'Adjust colors at its defaults draws exactly what no shader draws', colourDistance(neutral, base) < 6, JSON.stringify({ base, neutral }), ); // What the cost was made of (#67) and is not any more (#77): the shader draws in the sprite's own // pass, there is no filter and so no extra render pass. const passes = '({ shaders: __gm.shaderCount(), filters: __gm.filterCount() })'; const passesOn = await json(passes); await assetProps({ shader: '' }); const passesOff = await json(passes); await assetProps({ shader: ADJUST }); check( 'a shaded sprite draws with its own shader and no filter pass – and plain without', passesOn.shaders === 1 && passesOn.filters === 0 && passesOff.shaders === 0 && passesOff.filters === 0, { on: passesOn, off: passesOff, }, ); await assetProps({ brightness: 1.25 }); const lighter = await centre(); check('brightness 1.25 adds a quarter to every channel', colourDistance(lighter, '#80c0ff') < 24, lighter); await assetProps({ brightness: 1, contrast: 0 }); const flat = await centre(); check('contrast 0 flattens the colour to mid grey', colourDistance(flat, '#808080') < 24, flat); await assetProps({ contrast: 1, saturation: 0 }); const grey = await centre(); check('saturation 0 leaves the luma as a grey', colourDistance(grey, '#7c7c7c') < 24, grey); await shot('28-shader-desaturated'); // 28f. The game draws through the same path, so play shows the shader too. await assetProps({ saturation: 1, brightness: 1.25 }); await play(); await sleep(500); const at = await json("__gm.gameScreen('oshade')"); const played = at ? await json(`__gm.pixelAt(${Math.round(at.x + 20)}, ${Math.round(at.y + 20)})`) : null; check( 'during play the sprite is drawn with its shader', played !== null && colourDistance(played, '#80c0ff') < 24, JSON.stringify({ at, played }), ); await stop(); // 28g. Editing a shader's graph reaches every sprite wearing it: a Color wired into the new one's Output. await assetProps({ brightness: 1, shader: made.id }); const through = await centre(); check( 'the new shader passes the texel through', colourDistance(through, base) < 6, JSON.stringify({ base, through }), ); 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-red', type: 'ShaderColor', x: 0, y: 200, params: { value: 0xff0000 } }); a.graph.edges.push({ id: 'sh-red-edge', from: { node: 'sh-red', port: 'color' }, to: { node: out.id, port: 'color' } });`); const red = await centre(); check( 'wiring a red Color into the Output recompiles: the sprite turns red', colourDistance(red, '#ff0000') < 24, red, ); // 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' } });`); 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 }); const behind = await centre(); check('opacity 0 shows what is behind the sprite', colourDistance(behind, red) > 60, { red, behind }); await assetProps({ opacity: 0.5 }); 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 }); check('opacity back at 1 draws the pixel from before, byte for byte', (await centre()) === red); // 28i. A Number node is a constant in the fragment: red times 0.5 is half red; typed back to 1, // the pixel is the red from before. Its value is a setting row on the node. 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-half', type: 'ShaderNumber', x: 200, y: 200, params: { value: 0.5 } }, { id: 'sh-dim', type: 'ShaderMultiply', x: 400, y: 200, params: {} }); a.graph.edges = a.graph.edges.filter((e) => e.id !== 'sh-red-edge'); a.graph.edges.push( { id: 'sh-dim-a', from: { node: 'sh-red', port: 'color' }, to: { node: 'sh-dim', port: 'a' } }, { id: 'sh-dim-b', from: { node: 'sh-half', port: 'value' }, to: { node: 'sh-dim', port: 'b' } }, { id: 'sh-dim-out', from: { node: 'sh-dim', port: 'result' }, to: { node: out.id, port: 'color' } });`); const dimmed = await centre(); check('a Number of 0.5 into a Multiply halves the red', colourDistance(dimmed, '#800000') < 6, { red, dimmed, }); await showTab('shader'); check( 'the Number node draws its value as a setting row', (await json("__gm.shader.nodeSettings('sh-half')"))?.join() === 'Value: 0.5', JSON.stringify(await json("__gm.shader.nodeSettings('sh-half')")), ); await shot('28-number-node'); await showTab('scene'); await mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); a.graph.nodes.find((n) => n.id === 'sh-half').params.value = 1`); check('the Number typed to 1 gives the red from before, byte for byte', (await centre()) === red, { red, now: await centre(), }); // 28k. UV into Step: the pixel's place on the picture decides its colour – a hard cut at u = 0.5, // then at v = 0.5, which shows v runs top to bottom. `box` is the sprite's centre and size. const node = (id, type, params = {}, x = 0, y = 0) => ({ id, type, x, y, params }); const edge = (from, fromPort, to, toPort) => ({ id: `${from}-${to}-${toPort}`, from: { node: from, port: fromPort }, to: { node: to, port: toPort }, }); const graph = (nodes, edges) => mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); a.graph.nodes = ${JSON.stringify(nodes)}; a.graph.edges = ${JSON.stringify(edges)};`); const pixel = (fx, fy) => json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`); const redOrBlue = (t, edges) => graph( [ ...t, node('red', 'ShaderColor', { value: 0xff0000 }, 260, 200), node('blue', 'ShaderColor', { value: 0x0000ff }, 260, 340), node('mix', 'ShaderMix', {}, 520, 200), node('out', 'ShaderOutput', {}, 780, 200), ], [ ...edges, edge('red', 'color', 'mix', 'a'), edge('blue', 'color', 'mix', 'b'), edge('mix', 'result', 'out', 'color'), ], ); const cutAt = (port) => redOrBlue( [node('uv', 'ShaderUV'), node('cut', 'ShaderStep', { edge: 0.5 }, 260, 0)], [edge('uv', port, 'cut', 'value'), edge('cut', 'result', 'mix', 't')], ); await cutAt('u'); const uLeft = await pixel(-0.3, -0.3); const uRight = await pixel(0.3, -0.3); check( 'UV into Step: left of u = 0.5 the sprite is red, right of it blue', colourDistance(uLeft, '#ff0000') < 6 && colourDistance(uRight, '#0000ff') < 6, { uLeft, uRight }, ); await cutAt('v'); const vTop = await pixel(-0.3, -0.3); const vBottom = await pixel(-0.3, 0.3); check( 'v runs top to bottom: above v = 0.5 red, below it blue', colourDistance(vTop, '#ff0000') < 6 && colourDistance(vBottom, '#0000ff') < 6, { vTop, vBottom }, ); // 28k-ii. Pow bends the cut: u squared crosses 0.5 at u = 0.707, its square root at u = 0.25. const cutAfterPow = (exponent) => redOrBlue( [ node('uv', 'ShaderUV'), node('pow', 'ShaderPow', { exponent }, 260, 0), node('cut', 'ShaderStep', { edge: 0.5 }, 260, 120), ], [ edge('uv', 'u', 'pow', 'value'), edge('pow', 'result', 'cut', 'value'), edge('cut', 'result', 'mix', 't'), ], ); await cutAfterPow(2); const squaredNear = await pixel(0.1, -0.3); const squaredFar = await pixel(0.3, -0.3); check( 'Pow 2 moves the cut to u = 0.707: still red at u = 0.6, blue at 0.8', colourDistance(squaredNear, '#ff0000') < 6 && colourDistance(squaredFar, '#0000ff') < 6, { squaredNear, squaredFar }, ); await cutAfterPow(0.5); const rootNear = await pixel(-0.3, -0.3); const rootFar = await pixel(-0.15, -0.3); check( 'Pow 0.5 pulls it to u = 0.25: red at u = 0.2, blue at 0.35', colourDistance(rootNear, '#ff0000') < 6 && colourDistance(rootFar, '#0000ff') < 6, { rootNear, rootFar }, ); // 28k-iii. Abs folds u around 0.5 into a V: |u - 0.5| is below 0.25 only in the middle half. await redOrBlue( [ node('uv', 'ShaderUV'), node('centred', 'ShaderSubtract', { b: 0.5 }, 260, 0), node('fold', 'ShaderAbs', {}, 260, 120), node('cut', 'ShaderStep', { edge: 0.25 }, 260, 240), ], [ edge('uv', 'u', 'centred', 'a'), edge('centred', 'result', 'fold', 'value'), edge('fold', 'result', 'cut', 'value'), edge('cut', 'result', 'mix', 't'), ], ); const foldLeft = await pixel(-0.4, -0.3); const foldMiddle = await pixel(0, -0.3); const foldRight = await pixel(0.4, -0.3); check( 'Abs folds the cut into a V: red in the middle, blue at both edges', colourDistance(foldMiddle, '#ff0000') < 6 && colourDistance(foldLeft, '#0000ff') < 6 && colourDistance(foldRight, '#0000ff') < 6, { 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'); 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'); 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); const halved = await pixel(-0.3, -0.3); await dividedBy(0); 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')], ); 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. await evaluate(PASTE_HALVES); await sleep(2500); const picture = await json(FIND_HALVES); check( 'a two-colour picture is pasted in for the sampling checks', Boolean(picture), JSON.stringify(picture), ); await evaluate('__gm.state.select(null)'); await assetProps({ texture: picture?.id ?? '', color: 0xffffff }); await graph( [node('texel', 'ShaderTextureColor'), node('out', 'ShaderOutput')], [edge('texel', 'color', 'out', 'color')], ); await sleep(600); const plainLeft = await pixel(-0.3, -0.3); const plainRight = await pixel(0.3, -0.3); check( 'through Texture color the picture is on: red left, green right', colourDistance(plainLeft, LEFT) < 12 && colourDistance(plainRight, RIGHT) < 12, { plainLeft, plainRight }, ); const sampleAt = (uNodes, uEdges, outParams = {}) => graph( [ node('uv', 'ShaderUV'), ...uNodes, node('sample', 'ShaderSample', {}, 520, 0), node('out', 'ShaderOutput', outParams, 780, 0), ], [...uEdges, edge('sample', 'color', 'out', 'color')], ); await sampleAt( [node('flip', 'ShaderSubtract', { a: 1 }, 260, 0)], [edge('uv', 'u', 'flip', 'b'), edge('flip', 'result', 'sample', 'u')], ); const flipLeft = await pixel(-0.3, -0.3); const flipRight = await pixel(0.3, -0.3); check( 'sampling at 1 - u mirrors the picture: green left, red right', colourDistance(flipLeft, RIGHT) < 12 && colourDistance(flipRight, LEFT) < 12, { flipLeft, flipRight }, ); const scrolled = async (edgeMode) => { await sampleAt( [node('shift', 'ShaderAdd', { b: 0.5 }, 260, 0)], [edge('uv', 'u', 'shift', 'a'), edge('shift', 'result', 'sample', 'u')], { edge: edgeMode }, ); return { left: await pixel(-0.3, -0.3), right: await pixel(0.3, -0.3) }; }; const repeat = await scrolled('repeat'); check( 'u + 0.5 with Edge Repeat: the near half shows the far one, and past the border the picture starts over', colourDistance(repeat.left, RIGHT) < 12 && colourDistance(repeat.right, LEFT) < 12, repeat, ); const mirror = await scrolled('mirror'); check( 'the same with Edge Mirror: past the border the picture turns back on itself', colourDistance(mirror.left, RIGHT) < 12 && colourDistance(mirror.right, RIGHT) < 12, mirror, ); // u + 0.5 cannot tell Clamp from Mirror: both hold green past the border. u + 0.8 puts the near // half on 1.6, where Mirror is already back in the far half and Clamp still holds the last texel. const scrolledFar = async (outParams) => { await sampleAt( [node('far', 'ShaderAdd', { b: 0.8 }, 260, 0)], [edge('uv', 'u', 'far', 'a'), edge('far', 'result', 'sample', 'u')], outParams, ); return pixel(0.3, -0.3); }; const byDefault = await scrolledFar({}); const byMirror = await scrolledFar({ edge: 'mirror' }); check( 'with nothing set on the Output the edge is Clamp: at u + 0.8 the border texel goes on, where Mirror shows the near half again', colourDistance(byDefault, RIGHT) < 12 && colourDistance(byMirror, LEFT) < 12, { byDefault, byMirror }, ); await showTab('shader'); await shot('28-shader-scroll-graph'); await showTab('scene'); const sizeAtLeast = async (px) => { await redOrBlue( [node('size', 'ShaderTextureSize'), node('cut', 'ShaderStep', { edge: px }, 260, 0)], [edge('size', 'width', 'cut', 'value'), edge('cut', 'result', 'mix', 't')], ); return pixel(-0.3, -0.3); }; const wide = await sizeAtLeast(31.5); const wider = await sizeAtLeast(32.5); check( 'Texture size reads the picture’s pixel width: at least 31.5, not 32.5', colourDistance(wide, '#0000ff') < 6 && colourDistance(wider, '#ff0000') < 6, { wide, wider }, ); // 28l-iii. Padding is room around the picture the sprite draws into as well (#69), counted in // pixels of the picture: 8 of this 32-px one is a quarter of the sprite per side. The // picture itself stays where it is, and what the margin reads is the Output's Edge. const texelOnly = (outParams) => graph( [node('texel', 'ShaderTextureColor'), node('out', 'ShaderOutput', outParams, 780, 0)], [edge('texel', 'color', 'out', 'color')], ); const around = async () => ({ left: await pixel(-0.3, -0.3), right: await pixel(0.3, -0.3), marginLeft: await pixel(-0.62, -0.3), marginRight: await pixel(0.62, -0.3), beyond: await pixel(0.9, -0.3), }); await texelOnly({ edge: 'transparent' }); const bare = await around(); await texelOnly({ edge: 'transparent', padding: 8 }); const roomy = await around(); check( 'Padding with Edge Transparent leaves the picture where it is and the margin empty', colourDistance(roomy.left, LEFT) < 12 && colourDistance(roomy.right, RIGHT) < 12 && roomy.marginLeft === bare.marginLeft && roomy.marginRight === bare.marginRight, { bare, roomy }, ); // A sprite far off screen is not drawn at all; one whose shader has padding is, because its // margin may reach in while its rectangle is still outside. const awayBy = (dx) => mutate(`__gm.state.activeScene.objects.find((o) => o.id === 'oshade').position.x += ${dx};`); await awayBy(100000); const farPadded = await json('__gm.shaderCount()'); await texelOnly({ edge: 'transparent' }); const farPlain = await json('__gm.shaderCount()'); await awayBy(-100000); check( 'off screen a shaded sprite is left out, a padded one is still drawn for its margin', farPadded === farPlain + 1, { farPadded, farPlain }, ); const shiftedRight = async (padding) => { await sampleAt( [node('shift', 'ShaderAdd', { b: -0.25 }, 260, 0)], // The sample's own opacity: in the margin the pixel's own is none, with Edge Transparent. [ edge('uv', 'u', 'shift', 'a'), edge('shift', 'result', 'sample', 'u'), edge('sample', 'alpha', 'out', 'alpha'), ], { edge: 'transparent', padding }, ); return around(); }; const clipped = await shiftedRight(0); const drawn = await shiftedRight(8); check( 'a picture shifted right is cut at the sprite’s rectangle without padding, and drawn past it with', clipped.marginRight === bare.marginRight && colourDistance(drawn.marginRight, RIGHT) < 12 && drawn.marginLeft === bare.marginLeft && drawn.beyond === bare.beyond, { bare, clipped, drawn }, ); await texelOnly({ padding: 8 }); const smeared = await around(); await showTab('shader'); await clickText('Fit'); await sleep(300); const hint = await json("__gm.shader.nodeProblem('out')"); const readout = await info(); await shot('28-shader-padding-hint'); await showTab('scene'); check( 'with Edge Clamp the border pixel smears into the margin, and the Output says so while the shader still compiles', colourDistance(smeared.marginLeft, LEFT) < 12 && colourDistance(smeared.marginRight, RIGHT) < 12 && smeared.beyond === bare.beyond && hint === 'Padding with Clamp smears the border' && readout !== hint, { smeared, hint, readout }, ); await shot('28-shader-padding-scene'); // 28l-iv. A Vertex output moves the corners of the sprite's grid (#79), in pixels of the picture: // 16 of this 32-px one is half the sprite. A constant moves the whole picture; a cut on v // bends it only where the grid has corners, and with one cell there is just a shear. const cornered = (cornerNodes, cornerEdges, subdivisions) => graph( [ node('texel', 'ShaderTextureColor'), node('out', 'ShaderOutput', { edge: 'transparent' }, 780, 0), ...cornerNodes, node('vout', 'ShaderVertexOutput', { subdivisions }, 780, 260), ], [edge('texel', 'color', 'out', 'color'), ...cornerEdges], ); await cornered( [node('by', 'ShaderNumber', { value: 16 }, 260, 260)], [edge('by', 'value', 'vout', 'x')], 8, ); const moved = { left: await pixel(-0.3, -0.3), right: await pixel(0.3, -0.3), past: await pixel(0.8, -0.3), }; check( 'a Vertex output fed 16 moves the picture half a sprite to the right, past its rectangle', colourDistance(moved.left, LEFT) > 60 && colourDistance(moved.right, LEFT) < 12 && colourDistance(moved.past, RIGHT) < 12 && colourDistance(bare.left, LEFT) < 12, { moved, bare }, ); const lowerHalf = async (subdivisions) => { await cornered( [ node('uv', 'ShaderUV', {}, 0, 260), node('cut', 'ShaderStep', { edge: 0.5 }, 260, 260), node('far', 'ShaderMultiply', { b: 16 }, 520, 260), ], [ edge('uv', 'v', 'cut', 'value'), edge('cut', 'result', 'far', 'a'), edge('far', 'result', 'vout', 'x'), ], subdivisions, ); return { upper: await pixel(0.6, -0.2), lower: await pixel(0.8, 0.35) }; }; const bent = await lowerHalf(8); const sheared = await lowerHalf(1); await shot('28-shader-vertex-scene'); check( 'with 8 subdivisions only the lower half moves; with 1 there are four corners, and the upper half leans along', colourDistance(bent.lower, RIGHT) < 12 && colourDistance(bent.upper, RIGHT) > 60 && colourDistance(sheared.upper, RIGHT) < 12, { bent, sheared }, ); await cornered( [node('opacity', 'ShaderTextureAlpha', {}, 260, 260)], [edge('opacity', 'value', 'vout', 'x')], 8, ); const perCorner = await json("__gm.shader.nodeProblem('vout')"); const perCornerReadout = await info(); check( 'what reads the picture cannot feed the Vertex output: the node says so, and the shader does not compile', perCorner === 'Not available per corner: Texture alpha' && JSON.stringify(perCornerReadout).includes(perCorner), { perCorner, perCornerReadout }, ); // 28l-v. A Color property is a colour row on the sprite and a vec3 uniform (#298): unset, the // sprite shows the declared default; set on the sprite, that colour. await graph( [ node('tint', 'ShaderColorProperty', { name: 'tint', default: 0x3cc85a }), node('out', 'ShaderOutput', {}, 780, 0), ], [edge('tint', 'color', 'out', 'color')], ); const tinted = async () => ({ a: await pixel(-0.3, -0.3), b: await pixel(0.3, 0.3) }); const unsetTint = await tinted(); await assetProps({ tint: 0xe63c3c }); const setTint = await tinted(); check( 'a Color property draws its default, and the colour the sprite sets once it sets one', colourDistance(unsetTint.a, '#3cc85a') < 12 && colourDistance(unsetTint.b, '#3cc85a') < 12 && colourDistance(setTint.a, '#e63c3c') < 12 && colourDistance(setTint.b, '#e63c3c') < 12, { unsetTint, setTint }, ); await mutate(`delete __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.tint`); // 28l-vi. Blur softens the picture in one pass (#300), its radius in pixels of the picture. Texel 15 // of the halves is the last red one: a radius of 4 across takes in 5 red and 4 green. // Nearest, so a screen pixel beside a texel's centre still reads that texel alone. const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); const texel = (i, j, size = 32) => pixel((i + 0.5) / size - 0.5, (j + 0.5) / size - 0.5); const filterOf = (id, filter) => mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(id)}); if (a) a.props.filter = ${JSON.stringify(filter)};`); await filterOf(picture?.id, 'nearest'); const blurred = (blurParams, outParams = {}, more = [[], []]) => graph( [ node('blur', 'ShaderBlur', blurParams, 260, 0), node('out', 'ShaderOutput', outParams, 780, 0), ...more[0], ], [edge('blur', 'color', 'out', 'color'), ...more[1]], ); await texelOnly({}); const unblurred = [await texel(15, 6), await texel(16, 6)]; await blurred({ direction: 'horizontal' }, {}, [ [node('soft', 'ShaderProperty', { name: 'soft', default: 0, min: 0, max: 8 }, 0, 0)], [edge('soft', 'value', 'blur', 'radius')], ]); const zero = [await texel(15, 6), await texel(16, 6)]; check('Blur with radius 0 draws the picture unchanged, byte for byte', zero.join() === unblurred.join(), { unblurred, zero, }); const compilesBefore = await json('__gm.shaderCompiles()'); const softened = []; for (const soft of [0.5, 1, 4]) { await assetProps({ soft }); softened.push(rgb(await texel(15, 6))[0]); } const compilesAfter = await json('__gm.shaderCompiles()'); // Red share at texel 15: 0.5 weighs 0.5 + 1 + 0.5 (3/4 red), 1 is 2 of 3, 4 is 5 of 9. const redAt = (share) => Math.round(230 * share + 60 * (1 - share)); check( 'a radius from a Property softens step by step, fractions included, without a new program', rgb(zero[0])[0] > softened[0] && softened[0] > softened[1] && softened[1] > softened[2] && Math.abs(softened[0] - redAt(3 / 4)) < 10 && Math.abs(softened[2] - redAt(5 / 9)) < 10 && compilesAfter === compilesBefore, { zero, softened, expected: [redAt(3 / 4), redAt(2 / 3), redAt(5 / 9)], compilesBefore, compilesAfter }, ); await mutate(`delete __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.soft`); const radiusFour = async (params) => { await blurred({ radius: 4, ...params }); return rgb(await texel(15, 6)); }; const boxed = await radiusFour({ direction: 'horizontal' }); const gaussian = await radiusFour({ direction: 'horizontal', kind: 'gaussian' }); const vertical = await radiusFour({ direction: 'vertical' }); check( 'a Box of 4 mixes 5 red with 4 green, Vertical stays red, Gaussian leans to the middle', Math.abs(boxed[0] - redAt(5 / 9)) < 10 && colourDistance(`#${vertical.map((c) => c.toString(16).padStart(2, '0')).join('')}`, LEFT) < 12 && gaussian[0] > boxed[0] + 4 && gaussian[1] < boxed[1] - 3, { boxed, gaussian, vertical }, ); // At the transparent outside the colour stays red; only the opacity drops, to 5 of 9. const ground = rgb(await pixel(-0.62, -0.3)); await blurred({ radius: 4, direction: 'horizontal' }, { edge: 'transparent' }); const borderColour = await texel(0, 6); await blurred({ radius: 4, direction: 'horizontal' }, { edge: 'transparent' }, [ [], [edge('blur', 'alpha', 'out', 'alpha')], ]); const borderShare = (rgb(await texel(0, 6))[0] - ground[0]) / (rgb(LEFT)[0] - ground[0]); check( 'at a transparent border the colour stays and only the opacity drops: no dark seam', colourDistance(borderColour, LEFT) < 12 && Math.abs(borderShare - 5 / 9) < 0.08, { borderColour, borderShare, ground }, ); // One white pixel blurred by 1 is a 3x3 spot of 1/9 each: its opacity adds up to the pixel's. The // pixel sits off the sprite's middle, where a symbol may draw over it. await evaluate(PASTE_DOT); await sleep(2500); const dot = await json(FIND_DOT); await evaluate('__gm.state.select(null)'); await filterOf(dot, 'nearest'); await assetProps({ texture: dot ?? '' }); await blurred({ radius: 1 }, { edge: 'transparent' }, [[], [edge('blur', 'alpha', 'out', 'alpha')]]); const spot = []; for (let j = 2; j <= 6; j++) for (let i = 2; i <= 6; i++) spot.push((rgb(await texel(i, j, 16))[0] - ground[0]) / (255 - ground[0])); const lit = spot.filter((a) => a > 0.05).length; const opacity = spot.reduce((sum, a) => sum + a, 0); check( 'one white pixel blurred by 1 becomes a 3x3 spot whose opacity adds up to the pixel’s', Boolean(dot) && lit === 9 && Math.abs(opacity - 1) < 0.1, { dot, lit, opacity: opacity.toFixed(3), spot: spot.map((a) => a.toFixed(2)).join(' ') }, ); // 28l-vii. A Pass draws first into a picture of its own (#301): blurred across, then that picture // blurred down, is the spot a Blur of Both draws in one go. const spotOf = async () => { const values = []; for (let j = 2; j <= 6; j++) for (let i = 2; i <= 6; i++) values.push((rgb(await texel(i, j, 16))[0] - ground[0]) / (255 - ground[0])); return values; }; await blurred({ radius: 2 }, { edge: 'transparent' }, [[], [edge('blur', 'alpha', 'out', 'alpha')]]); const oneGo = await spotOf(); await graph( [ node('across', 'ShaderBlur', { radius: 2, direction: 'horizontal' }, 0, 0), node('pass', 'ShaderPass', {}, 260, 0), node('down', 'ShaderBlur', { radius: 2, direction: 'vertical' }, 520, 0), node('out', 'ShaderOutput', { edge: 'transparent' }, 780, 0), ], [ edge('across', 'color', 'pass', 'color'), edge('across', 'alpha', 'pass', 'alpha'), edge('pass', 'picture', 'down', 'picture'), edge('down', 'color', 'out', 'color'), edge('down', 'alpha', 'out', 'alpha'), ], ); const twoPasses = await spotOf(); const apart = Math.max(...oneGo.map((a, k) => Math.abs(a - twoPasses[k]))); check( 'a Blur across, a Pass and a Blur down draw the spot a Blur of Both draws in one pass', oneGo.filter((a) => a > 0.01).length === 25 && apart < 0.02, { apart: apart.toFixed(3), oneGo: oneGo.map((a) => a.toFixed(2)).join(' '), twoPasses: twoPasses.map((a) => a.toFixed(2)).join(' '), }, ); // Bloom's shape: the passed picture added to the untouched original. The original stays readable // beside a pass, and the sprite's colour tints only the last part, not what the Pass drew. await assetProps({ texture: picture?.id ?? '', color: 0x808080 }); await graph( [ node('pass', 'ShaderPass', {}, 0, 0), node('passed', 'ShaderTextureColor', {}, 260, 0), node('own', 'ShaderTextureColor', {}, 260, 200), node('add', 'ShaderAdd', {}, 520, 0), node('out', 'ShaderOutput', {}, 780, 0), ], [ edge('pass', 'picture', 'passed', 'picture'), edge('passed', 'color', 'add', 'a'), edge('own', 'color', 'add', 'b'), edge('add', 'result', 'out', 'color'), ], ); const bloomed = rgb(await texel(4, 6)); await mutate(`delete __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.color`); // Grey halves red twice, once in the pass and once in the original: 230 / 2 + 230 / 2. check( 'a Pass’s picture and the original read side by side, each tinted once at the end', Math.abs(bloomed[0] - 230) < 8 && Math.abs(bloomed[1] - 60) < 8, { bloomed }, ); // The open Pass above is the very program of the first of two passes here. Pixi binds a program's // pictures by position, so the second graph read a placeholder: white, until the page reloaded. await graph( [ node('first', 'ShaderPass', {}, 0, 0), node('again', 'ShaderTextureColor', {}, 260, 0), node('second', 'ShaderPass', {}, 520, 0), node('passed', 'ShaderTextureColor', {}, 780, 0), node('out', 'ShaderOutput', {}, 1040, 0), ], [ edge('first', 'picture', 'again', 'picture'), edge('again', 'color', 'second', 'color'), edge('second', 'picture', 'passed', 'picture'), edge('passed', 'color', 'out', 'color'), ], ); const chained = [await texel(4, 6), await texel(27, 6)]; check( 'two Passes in a row show the picture, also where the first shares its program with an earlier graph', colourDistance(chained[0], LEFT) < 12 && colourDistance(chained[1], RIGHT) < 12, { chained }, ); // 28l-viii. A shader function (#303): Grayscale as an asset of its own, called from the shader, // draws what the same nodes draw in place. A change to the function reaches the sprite // without the shader being touched, and a double-click on the call opens the function. const FN = 'fn28-gray'; const fnEdge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort }, }); await mutate(` __gm.state.project.assets.push({ id: '${FN}', type: 'shaderfunction', name: 'Grayscale 28', props: {}, graph: { nodes: ${JSON.stringify([ { ...node('in', 'ShaderFunctionInput', {}, 0, 0), ports: [{ key: 'color', kind: 'color' }] }, node('lum', 'ShaderLuminance', {}, 260, 0), { ...node('out', 'ShaderFunctionOutput', {}, 520, 0), ports: [{ key: 'gray', kind: 'color' }] }, ])}, edges: ${JSON.stringify([fnEdge('e1', 'in', 'color', 'lum', 'color'), fnEdge('e2', 'lum', 'value', 'out', 'gray')])}, }, })`); const ends = [node('own', 'ShaderTextureColor', {}, 0, 0), node('out', 'ShaderOutput', {}, 520, 0)]; await graph( [...ends, node('lum', 'ShaderLuminance', {}, 260, 0)], [edge('own', 'color', 'lum', 'color'), edge('lum', 'value', 'out', 'color')], ); const inPlace = [await texel(4, 6), await texel(27, 6)]; await graph( [...ends, node('call', 'ShaderCall', { function: FN }, 260, 0)], [edge('own', 'color', 'call', 'color'), edge('call', 'gray', 'out', 'color')], ); const called = [await texel(4, 6), await texel(27, 6)]; const isGrey = (hex) => new Set(rgb(hex)).size === 1; check( 'a function called from the shader draws what its nodes draw in place, byte for byte', called.join() === inPlace.join() && called.every(isGrey) && called[0] !== called[1], { inPlace, called }, ); await mutate(` const g = __gm.state.project.assets.find((a) => a.id === '${FN}').graph; g.nodes.push(${JSON.stringify(node('half', 'ShaderMultiply', { b: 0.5 }, 390, 140))}); g.edges = g.edges.filter((e) => e.id !== 'e2'); g.edges.push(...${JSON.stringify([fnEdge('e3', 'lum', 'value', 'half', 'a'), fnEdge('e4', 'half', 'result', 'out', 'gray')])})`); const fnHalved = [await texel(4, 6), await texel(27, 6)]; check( 'a change to the function reaches the sprite, its shader untouched', fnHalved.every((hex, k) => isGrey(hex) && Math.abs(rgb(hex)[0] - rgb(called[k])[0] / 2) <= 2), { called, fnHalved }, ); await select({ kind: 'asset', assetId: made.id }); await showTab('shader'); await clickText('Fit'); const callNode = await json("__gm.shader.nodeAt('call')"); const callLabel = await json("__gm.shader.nodeLabel('call')"); if (callNode) await doubleClick(callNode.x, callNode.y); await sleep(200); const openedFunction = { current: await json('__gm.shader.current()'), info: await json('__gm.shader.info()'), subject: (await json('__gm.shader.preview()')).subject, }; await mutate(` __gm.state.project.assets.find((a) => a.id === '${FN}').graph.nodes.push( ${JSON.stringify(node('self', 'ShaderCall', { function: FN }, 260, 260))})`); const selfProblem = await json("__gm.shader.nodeProblem('self')"); check( 'the call is named after the function, a double-click opens it, and a call of itself has a problem', callLabel === 'Grayscale 28' && openedFunction.current === FN && openedFunction.info === '1 input, 1 output' && openedFunction.subject === 'A function shows in the shaders calling it' && selfProblem === 'A function cannot call itself', JSON.stringify({ callNode, callLabel, openedFunction, selfProblem }), ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== '${FN}')`); await evaluate('__gm.state.select(null)'); await showTab('scene'); await assetProps({ texture: picture?.id ?? '' }); await filterOf(picture?.id, 'linear'); // 28l-ii. The sprite wears a picture and a shader: both open from the object's menu, from the // asset's menu and from the inspector, whose Texture and Shader labels are links (#221). const shadedProps = await json("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').props"); const docs = () => json('__gm.documents()'); const openBefore = (await docs()).docs; const opened = async () => (await docs()).docs.filter((doc) => !openBefore.includes(doc)); const closeAll = async () => { for (const doc of await opened()) await evaluate(`__gm.closeDocument(${JSON.stringify(doc)})`); await showTab('scene'); }; // A missing entry is a failed check, not a dead run: picked only when it is there. const pick = async (text) => { const entry = await t.find(text, true); if (entry) await click(entry.x, entry.y); else await press('Escape'); }; await showTab('scene'); await click(Math.round(box.x + 0.3 * box.w), Math.round(box.y + 0.3 * box.h), 'right'); const objectRows = ((await json('__gm.menuRows()')) ?? []).filter(Boolean); await pick('Open shader'); const shaderOpened = await docs(); check( 'the object menu offers Open texture and Open shader, and Open shader opens the one it wears', objectRows.slice(0, 4).join('|') === 'Select asset|Open asset|Open texture|Open shader' && shaderOpened.active === `asset:${shadedProps.shader}`, JSON.stringify({ objectRows, shaderOpened, shadedProps }), ); await closeAll(); const spriteRow = await json("__gm.assetRow('shade-sprite')"); await click(spriteRow.x, spriteRow.y, 'right'); const assetRows = ((await json('__gm.menuRows()')) ?? []).filter(Boolean); await pick('Open texture'); const textureOpened = await docs(); check( 'the asset menu starts with the same two entries, and Open texture opens the picture', assetRows.slice(0, 2).join('|') === 'Open texture|Open shader' && textureOpened.active === `asset:${picture?.id}`, JSON.stringify({ assetRows, textureOpened }), ); await closeAll(); await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' }); const link = (text) => json(`__gm.inspectorLink(${JSON.stringify(text)})`); const textureLink = await link('Texture'); const nameLink = await link('Name'); check( 'a set Texture reads as a link in the accent colour, underlined; a plain row does not', textureLink?.underlined === true && textureLink.pressable === true && textureLink.color === (await json("__gm.themeColor('accent')")) && nameLink?.underlined === false && nameLink.pressable === false, JSON.stringify({ textureLink, nameLink }), ); await shot('28-shader-links'); // Folded first, so the ping has to find the row itself. const textures = await json("__gm.assetRow('group:texture')"); await click(textures.x, textures.y); const foldedBefore = (await json('__gm.assetGroups()')).find((g) => g.type === 'texture').expanded === false; const rebuildsBefore = (await json('__gm.inspectorInfo()')).rebuilds; await click(textureLink.x, textureLink.y); const afterClick = { ping: await json('__gm.assetPing()'), row: await json(`__gm.assetRowShown(${JSON.stringify(picture?.id)})`), selection: await json('__gm.state.selection'), rebuilds: (await json('__gm.inspectorInfo()')).rebuilds - rebuildsBefore, opened: await opened(), tab: await json('__gm.tab()'), }; await shot('28-shader-ping'); check( 'a click on the link pings the picture in the asset panel: its group unfolds, its row lights up, the inspector stays', foldedBefore && afterClick.ping?.id === picture?.id && afterClick.ping.alpha > 0 && Boolean(afterClick.row) && afterClick.selection?.kind === 'object' && afterClick.selection.objectId === 'oshade' && afterClick.rebuilds === 0 && afterClick.opened.length === 0 && afterClick.tab === 'scene', JSON.stringify({ foldedBefore, afterClick }), ); // Past the glow and the double-click window, so the next press is a fresh one. await sleep(1200); const faded = await json('__gm.assetPing()'); check('the glow fades out by itself', faded === null, JSON.stringify(faded)); // Still the sprite's inspector after the ping; were it not, the link is missing and the check fails. const shaderLink = await link('Shader'); if (shaderLink) await doubleClick(shaderLink.x, shaderLink.y); const afterDouble = { selection: await json('__gm.state.selection'), docs: await docs() }; check( 'a double-click on the Shader link opens the shader', afterDouble.selection?.assetId === shadedProps.shader && afterDouble.docs.active === `asset:${shadedProps.shader}`, JSON.stringify({ shaderLink, afterDouble }), ); await closeAll(); // What the object was placed from is a link too: its name beside it only reads. await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' }); const spriteName = await json("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').name"); const assetLink = await link('Asset'); if (assetLink?.field) await click(assetLink.x + 150, assetLink.y); const focusedAfter = await json('__gm.focusedRect()'); check( 'the Asset row is a link with the name in a read-only field that takes no focus', assetLink?.underlined === true && assetLink.pressable === true && assetLink.color === (await json("__gm.themeColor('accent')")) && assetLink.field?.value === spriteName && assetLink.field.readOnly === true && focusedAfter === null, JSON.stringify({ assetLink, spriteName, focusedAfter }), ); const assetRebuilds = (await json('__gm.inspectorInfo()')).rebuilds; if (assetLink) await click(assetLink.x, assetLink.y); const assetPing = { ping: await json('__gm.assetPing()'), selection: await json('__gm.state.selection'), rebuilds: (await json('__gm.inspectorInfo()')).rebuilds - assetRebuilds, }; await mutate("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').name = 'Renamed sprite'"); const renamedField = (await link('Asset'))?.field?.value; await mutate( `__gm.state.project.assets.find((a) => a.id === 'shade-sprite').name = ${JSON.stringify(spriteName)}`, ); check( 'a click pings the sprite and the inspector stays; renaming the sprite updates the field', assetPing.ping?.id === 'shade-sprite' && assetPing.selection?.objectId === 'oshade' && assetPing.rebuilds === 0 && renamedField === 'Renamed sprite', JSON.stringify({ assetPing, renamedField }), ); await sleep(1200); const assetAgain = await link('Asset'); if (assetAgain) await doubleClick(assetAgain.x, assetAgain.y); const assetOpened = await json('__gm.state.selection'); check( 'a double-click on the Asset link opens the sprite', assetOpened?.kind === 'asset' && assetOpened.assetId === 'shade-sprite', JSON.stringify({ assetAgain, assetOpened }), ); await closeAll(); await select(null); // 28m. Time: with the clock held the pixel is a function of the second; released, a sprite wearing // a Time shader redraws by itself in the scene view, where nothing else ticks, and in play. await redOrBlue( [ node('time', 'ShaderTime'), node('saw', 'ShaderFract', {}, 260, 0), node('cut', 'ShaderStep', { edge: 0.5 }, 260, 120), ], [ edge('time', 'value', 'saw', 'value'), edge('saw', 'result', 'cut', 'value'), edge('cut', 'result', 'mix', 't'), ], ); await evaluate('__gm.shaderTime(0.2)'); await sleep(400); const early = await pixel(-0.3, -0.3); await evaluate('__gm.shaderTime(0.7)'); await sleep(400); const late = await pixel(-0.3, -0.3); check( 'Time into Fract and Step: red at 0.2 s, blue at 0.7 s', colourDistance(early, '#ff0000') < 6 && colourDistance(late, '#0000ff') < 6, { early, late }, ); await redOrBlue( [node('time', 'ShaderTime'), node('saw', 'ShaderFract', {}, 260, 0)], [edge('time', 'value', 'saw', 'value'), edge('saw', 'result', 'mix', 't')], ); await evaluate('__gm.shaderTime(null)'); await sleep(400); const first = await pixel(-0.3, -0.3); await sleep(400); const second = await pixel(-0.3, -0.3); const animates = await json('__gm.animates()'); check( 'released, the scene view redraws every tick for the Time shader: the colour moves with nothing running', animates.scene === true && animates.game === false && first !== second, { animates, first, second }, ); // (#90) The shader clock is the global one: a scale of 0 holds the colour where it is without // freezing anything shader-specific, and 1 lets it move on. await evaluate('__gm.setTimeScale(0)'); await sleep(400); const frozenEarly = await pixel(-0.3, -0.3); await sleep(400); const frozenLate = await pixel(-0.3, -0.3); await evaluate('__gm.setTimeScale(1)'); await sleep(400); const thawed = await pixel(-0.3, -0.3); check( 'a global time scale of 0 stops the shader clock, and 1 starts it again', frozenEarly === frozenLate && thawed !== frozenLate, { frozenEarly, frozenLate, thawed }, ); await play(); await sleep(500); const playing = await json('__gm.animates()'); await stop(); check('and so does the game view while playing', playing.game === true, JSON.stringify(playing)); await assetProps({ shader: '' }); check( 'with the shader off the scene view stops redrawing', (await json('__gm.animates()')).scene === false, JSON.stringify(await json('__gm.animates()')), ); // 28n. The Shader tab previews the picture selected last (#168), through sliders of its own that // never touch the project, and falls back to a built-in test image once that picture is gone. await graph( [ node('uv', 'ShaderUV'), node('flip', 'ShaderSubtract', { a: 1 }, 260, 0), node('sample', 'ShaderSample', {}, 520, 0), node('level', 'ShaderProperty', { name: 'level', default: 1, min: 0, max: 1 }, 520, 200), node('dim', 'ShaderMultiply', {}, 780, 0), node('out', 'ShaderOutput', {}, 1040, 0), ], [ edge('uv', 'u', 'flip', 'b'), edge('flip', 'result', 'sample', 'u'), edge('sample', 'color', 'dim', 'a'), edge('level', 'value', 'dim', 'b'), edge('dim', 'result', 'out', 'color'), ], ); const openPreview = async (selection) => { await select(selection); await select({ kind: 'asset', assetId: made.id }); await showTab('shader'); await sleep(400); return json('__gm.shader.preview()'); }; const inPicture = async (fx, fy) => { const r = await json('__gm.shader.pictureRect()'); return json(`__gm.pixelAt(${Math.floor(r.x + fx * r.w)}, ${Math.floor(r.y + fy * r.h)})`); }; const halvesName = await json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(picture?.id ?? '')})?.name ?? null`, ); const onHalves = await openPreview({ kind: 'asset', assetId: picture?.id ?? '' }); const flippedLeft = await inPicture(0.25, 0.5); const flippedRight = await inPicture(0.75, 0.5); check( 'the preview shows the texture selected last, through the shader: 1 - u puts green left, red right', onHalves.subject === halvesName && (await json('__gm.shader.current()')) === made.id && colourDistance(flippedLeft, RIGHT) < 12 && colourDistance(flippedRight, LEFT) < 12, { halvesName, onHalves, flippedLeft, flippedRight }, ); check( 'the preview has a slider for the property at its default, and no clock without a Time node', onHalves.values.level === 1 && onHalves.timed === false && onHalves.time === '', onHalves, ); await shot('28-shader-preview'); const levelSlider = await json("__gm.shader.sliderAt('level')"); await click(levelSlider.x + 2, levelSlider.y + levelSlider.h / 2); await sleep(300); const pulled = await json('__gm.shader.preview()'); const dark = await inPicture(0.25, 0.5); const written = await json(`({ sprite: __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.level ?? null, declared: __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).graph.nodes.find((n) => n.id === 'level').params.default, })`); check( 'the slider pulled to its left end darkens the preview to black and writes nothing into the project', pulled.values.level === 0 && colourDistance(dark, '#000000') < 12 && written.sprite === null && written.declared === 1, { pulled, dark, written }, ); // A changed range rebuilds the row while the column keeps its height: the new slider still gets // its width, and its right end is the new max. await mutate(` __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}) .graph.nodes.find((n) => n.id === 'level').params.max = 2;`); await sleep(300); const widened = await json("__gm.shader.sliderAt('level')"); await click(widened.x + widened.w - 2, widened.y + widened.h / 2); await sleep(300); const pushed = await json('__gm.shader.preview()'); check( 'a new max rebuilds the slider at full width, and its right end is the new max', widened.w > 40 && Math.abs(widened.w - levelSlider.w) < 1 && pushed.values.level === 2, { levelSlider, widened, pushed }, ); await assetProps({ texture: picture?.id ?? '', color: 0xffffff, shader: made.id, level: 0.5 }); const onSprite = await openPreview({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade', }); const halfGreen = await inPicture(0.25, 0.5); const halfOfRight = `#${[1, 3, 5] .map((i) => Math.round(parseInt(RIGHT.slice(i, i + 2), 16) / 2) .toString(16) .padStart(2, '0'), ) .join('')}`; check( 'a sprite wearing the shader starts the slider at its own value: level 0.5 halves the green', onSprite.subject === 'Shaded' && onSprite.values.level === 0.5 && colourDistance(halfGreen, halfOfRight) < 12, { onSprite, halfGreen, halfOfRight }, ); await redOrBlue( [ node('time', 'ShaderTime'), node('saw', 'ShaderFract', {}, 260, 0), node('cut', 'ShaderStep', { edge: 0.5 }, 260, 120), ], [ edge('time', 'value', 'saw', 'value'), edge('saw', 'result', 'cut', 'value'), edge('cut', 'result', 'mix', 't'), ], ); await evaluate('__gm.shader.hold(0.2)'); await sleep(300); const heldEarly = { preview: await json('__gm.shader.preview()'), pixel: await inPicture(0.5, 0.5) }; await evaluate('__gm.shader.hold(0.7)'); await sleep(300); const heldLate = { preview: await json('__gm.shader.preview()'), pixel: await inPicture(0.5, 0.5) }; check( 'with a Time node the preview has a clock of its own: held at 0.2 s red, at 0.7 s blue, the button reads Run', heldEarly.preview.timed && heldLate.preview.hold === 'Run' && heldLate.preview.time === '0.70 s' && colourDistance(heldEarly.pixel, '#ff0000') < 6 && colourDistance(heldLate.pixel, '#0000ff') < 6, { heldEarly, heldLate }, ); await evaluate('__gm.shader.hold(null)'); await sleep(300); const runningFirst = await json('__gm.shader.preview()'); await sleep(600); const runningLater = await json('__gm.shader.preview()'); check( 'let go, the clock runs on from the held second', runningFirst.hold === 'Hold' && runningFirst.seconds >= 0.7 && runningLater.seconds > runningFirst.seconds && runningLater.time !== runningFirst.time, { runningFirst, runningLater }, ); // The halves texture selected once more, then deleted: the preview has nothing left to show. await graph( [node('texel', 'ShaderTextureColor'), node('out', 'ShaderOutput', {}, 260, 0)], [edge('texel', 'color', 'out', 'color')], ); await openPreview({ kind: 'asset', assetId: picture?.id ?? '' }); await mutate(` const project = __gm.state.project; project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(picture?.id ?? '')}); project.assets.find((a) => a.id === 'shade-sprite').props.texture = '';`); await sleep(400); const fallback = await json('__gm.shader.preview()'); // Texel (16, 48) of the 64-px test image, hit in its middle: u 0.26, v 0.76, a light checker cell. const testTexel = await inPicture(16.5 / 64, 48.5 / 64); const testEdge = await inPicture(0.5 / 64, 0.5); check( 'with that picture gone the preview shows the test image: colour runs with u and v, the edge fades out', fallback.subject === 'Test image' && colourDistance(testTexel, '#42c1bd') < 12 && colourDistance(testEdge, testTexel) > 60, { fallback, testTexel, testEdge }, ); await select(null); await showTab('scene'); await assetProps({ texture: '', color: 0x4080ff, shader: made.id }); // 28j. Back to None: no filter at all, the plain colour returns bit for bit. await assetProps({ shader: '' }); const back = await centre(); check( 'switching back to None restores the plain colour', colourDistance(back, base) < 6, JSON.stringify({ base, back }), ); await mutate(` const project = __gm.state.project; const gone = new Set(['shade-sprite', ${JSON.stringify(made.id)}, ${JSON.stringify(picture?.id ?? '')}]); project.assets = project.assets.filter((a) => !gone.has(a.id)); const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== 'oshade')`); await evaluate('__gm.state.select(null)'); check( 'the step leaves nothing behind but the fixture’s shader', (await json("__gm.state.project.assets.some((a) => a.id === 'shade-sprite')")) === false && (await json("__gm.state.activeScene.objects.some((o) => o.id === 'oshade')")) === false && (await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(picture?.id ?? '')})`)) === false && (await shaders()).map((s) => s.id).join(',') === ADJUST, ); }