import { colourDistance } from '../lib/harness.mjs'; import { FIND_HALVES, HALVES_LEFT as LEFT, HALVES_RIGHT as RIGHT, PASTE_HALVES } from './lib/pictures.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. 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 clickLast('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()'); 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(canvas.x + 420, 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(canvas.x + 420, 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 + 180, y: cam.y + 100, 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. 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. 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.500', 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); // 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 }, ); // 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()')), ); 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, ); }