Files
feuerwerk/tools/e2e/28-shader.mjs
T
rootandClaude Opus 5 0e0a21dee1
game-maker / suite (push) Successful in 10m4s
One clock under everything that moves by itself (#90)
Particle age and shader time each read performance.now() off their own
library, and the world got the frame's delta straight from the ticker. Three
clocks, so a global time scale would have been three multiplications and a
pause three separate freezes.

core/time.ts holds one clock instead: it accumulates dt x scale per frame
rather than reading the wall clock, and Editor.tick hands the very step it
took to the play session, the editor run and the animation preview. Delta
time, particle age, shader time and that preview are the same quantity now,
so the scale is a single multiplication and a pause is that scale at 0 —
nothing underneath needs rebasing, because the clock itself stands still.
The Math.min(dt, 0.1) caps in PlaySession and EditorRun are gone: the cap
(MAX_FRAME) now sits on the raw frame before scaling, and capping twice would
make the world run slower than the particles at scale 2.

A pause gates the scale rather than overwriting it — effectiveScale() answers
0 while paused, and for anything that is not a positive finite number, while
gameClock.scale keeps its value. A slider later survives a pause that way;
the issue asks for none yet.

The clock goes back to zero at both ends of a run, so a run is reproducible
from second 0 and the scene view looks freshly loaded after Stop. Resetting
only on Stop would not do it: the editor clock runs on afterwards, and the
next Play would start at "seconds since the last Stop".

The trap in that: ParticleField keeps its origin as an absolute second of
this clock, so after a reset it lies in the future and every particle would
age backwards. One line in sync() starts such a field over, which covers the
scene, the game and the tab preview at once — and fixes an older bug of the
same shape, where releasing a particleTime() freeze left the preview empty
for good.

Found while measuring that: ParticlePreview stood at second 0.5 by computing
the clock reading that would mean 0.5 since the field started. Over 200000
random origins the round trip misses the last bit in 18.8% of them and shows
one particle too few in 9.4%, because floor(0.5 * 20) comes out 9. The check
had been flaky since it was written. The panel hands the field the age itself
now (ParticleField.hold), and the faked clock is gone.

833 checks (824 + 9), 931 unit tests (921 + 10).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 09:31:53 +02:00

754 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
} = 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);
await sleep(200);
};
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)');
await sleep(200);
// 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 sleep(200);
await clickLast('Shader');
await sleep(400);
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');
await sleep(300);
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 sleep(150);
await clickLast('Texture color');
await sleep(300);
await click(canvas.x + 420, canvas.y + 60, 'right');
await sleep(300);
await clickLast('Output');
await sleep(300);
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');
await sleep(300);
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');
await sleep(200);
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');
await sleep(300);
// Property is the twelfth entry, below the palette's scroll mask: search it into view first.
await typeText('Property');
await sleep(150);
await clickLast('Property');
await sleep(300);
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');
await sleep(300);
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';`);
await sleep(300);
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';`);
await sleep(300);
// 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);
await sleep(300);
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);
await sleep(300);
const compiledAfterParam = await json('__gm.shaderCompiles()');
check(
'changing a param compiles it again, once',
compiledAfterParam === compiledAfterMove + 1,
JSON.stringify({ compiledAfterMove, compiledAfterParam }),
);
await setDefault(defaultBefore);
await sleep(300);
// 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 evaluate("__gm.showTab('scene')");
await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' });
await sleep(400);
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);
await sleep(300);
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')));
await sleep(400);
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();
await sleep(300);
check('undoing the choice takes the rows away again', (await field('brightness')) === null);
await select(null);
await sleep(200);
// 28e. Each property is visible in the pixels, with 1 neutral.
await assetProps({ shader: ADJUST });
await sleep(300);
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: '' });
await sleep(300);
const passesOff = await json(passes);
await assetProps({ shader: ADJUST });
await sleep(300);
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 });
await sleep(300);
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 });
await sleep(300);
const flat = await centre();
check('contrast 0 flattens the colour to mid grey', colourDistance(flat, '#808080') < 24, flat);
await assetProps({ contrast: 1, saturation: 0 });
await sleep(300);
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();
await sleep(300);
// 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 });
await sleep(300);
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' } });`);
await sleep(400);
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' } });`);
await sleep(400);
const opaque = await centre();
check(
'an opacity property at its default 1 changes nothing',
opaque === red,
JSON.stringify({ red, opaque, info: await info() }),
);
await assetProps({ opacity: 0 });
await sleep(300);
const behind = await centre();
check('opacity 0 shows what is behind the sprite', colourDistance(behind, red) > 60, { red, behind });
await assetProps({ opacity: 0.5 });
await sleep(300);
const half = await centre();
const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
const expected =
'#' +
[1, 3, 5]
.map((i) =>
Math.round((channel(red, i) + channel(behind, i)) / 2)
.toString(16)
.padStart(2, '0'),
)
.join('');
check('opacity 0.5 blends the sprite half with what is behind', colourDistance(half, expected) < 24, {
red,
behind,
half,
expected,
});
await assetProps({ opacity: 1 });
await sleep(300);
check('opacity back at 1 draws the pixel from before, byte for byte', (await centre()) === red);
// 28i. 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' } });`);
await sleep(400);
const dimmed = await centre();
check('a Number of 0.5 into a Multiply halves the red', colourDistance(dimmed, '#800000') < 6, {
red,
dimmed,
});
await evaluate("__gm.showTab('shader')");
await sleep(300);
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 evaluate("__gm.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`);
await sleep(400);
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');
await sleep(400);
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');
await sleep(400);
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);
await sleep(400);
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);
await sleep(400);
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'),
],
);
await sleep(400);
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 },
);
// 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')],
);
await sleep(400);
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 },
);
await sleep(400);
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,
);
await sleep(400);
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 evaluate("__gm.showTab('shader')");
await sleep(300);
await shot('28-shader-scroll-graph');
await evaluate("__gm.showTab('scene')");
await sleep(300);
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')],
);
await sleep(400);
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();
await sleep(300);
check('and so does the game view while playing', playing.game === true, JSON.stringify(playing));
await assetProps({ shader: '' });
await sleep(400);
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 });
await sleep(400);
// 28j. Back to None: no filter at all, the plain colour returns bit for bit.
await assetProps({ shader: '' });
await sleep(300);
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)');
await sleep(200);
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,
);
}