Soften a shader's picture in one pass with a Blur node (#300)
game-maker / gate (push) Successful in 3m3s
game-maker / suite (push) Successful in 14m26s

A blur already worked with Sample texture, but only by wiring every
sample by hand: 25 nodes for a 5x5. Averaging their colours also gave
a dark seam wherever a neighbour was transparent. The Blur node writes
the loop itself and averages the right way.

- Blur takes radius (pixels of the picture, 0 to 8, fractions allowed)
  and u/v like Sample texture, and gives color and alpha.
- Kind is Box (default) or Gaussian, Direction is Both, Horizontal or
  Vertical. Both are arguments of one prelude helper, blur().
- The samples are averaged premultiplied and only then made straight,
  so a transparent neighbour lowers the opacity and adds no black.
- The outermost ring counts by the part of the radius that reaches
  it, so a radius from a Property changes smoothly and compiles
  nothing again.
- A node may now declare prepare, a vec4 the compiler works out once
  into a local. Color and alpha read that one sum instead of blurring
  twice.
- A reached Blur counts as a shifted sample, so the picture comes
  with the Output's Edge. It cannot feed the Vertex output.
- Both perf tools take GM_PERF_BLUR=<radius>.

Wider blurs and bloom need several passes. That is #301.

Measured on stein2 (HD 630, one run per value, fps), Box blur:
- 48x48 sprites at 100/400/800: radius 2 59.8/58.4/55.6,
  radius 4 53.1/51.4/36.3, radius 8 46.6/24.4/14.4.
- 256x256 field shaders at 1/10/50: radius 2 60.2/56.0/49.0,
  radius 4 60.1/49.0/27.1, radius 8 52.4/34.9/11.4.

Tests:
- Gate: 224 files, 3467 tests, CHECK EXIT 0 (+7).
- e2e 28: ALL PASSED, 94 checks (+5 in 28l-vi). Pixels match the
  arithmetic to 1: the last red texel at radius 0.5/1/4 reads
  187/173/154 (expected 188/173/154).
- Counter-checks: Blur not counted as shifted fails 1 unit test.
  Blur output left premultiplied fails the seam and spot checks.
- e2e total 1712 is computed (1707 + 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 13:11:31 +02:00
1 parent d6d38a6422
commit df61b08bd1
8 files changed
+398 -16

No files matched your search

+109 -1
View File
@@ -1,5 +1,12 @@
import { colourDistance } from '../lib/harness.mjs';
import { FIND_HALVES, HALVES_LEFT as LEFT, HALVES_RIGHT as RIGHT, PASTE_HALVES } from './lib/pictures.mjs';
import {
FIND_DOT,
FIND_HALVES,
HALVES_LEFT as LEFT,
HALVES_RIGHT as RIGHT,
PASTE_DOT,
PASTE_HALVES,
} from './lib/pictures.mjs';
/**
* Shader assets: made from the + menu, built from nodes in the Shader tab, worn by a sprite through
@@ -948,6 +955,107 @@ export async function shader(t) {
);
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(' ') },
);
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");
+15
View File
@@ -14,6 +14,21 @@ export const PASTE_HALVES = `(async () => {
export const HALVES_LEFT = '#e63c3c';
export const HALVES_RIGHT = '#3cc85a';
/** A 16 px transparent picture with one white pixel at (4, 4), for the Blur's spot (28l-vi). */
export const PASTE_DOT = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 16;
canvas.height = 16;
const g = canvas.getContext('2d');
g.fillStyle = '#ffffff'; g.fillRect(4, 4, 1, 1);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'dot.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
export const FIND_DOT =
"__gm.state.project.assets.filter((a) => a.type === 'texture' && a.name === 'dot' && a.width === 16).map((a) => a.id).at(-1) ?? null";
/**
* The pasted picture's asset, once the paste has landed: the newest one named after the pasted
* file. Size alone is not enough – in the full run a module before 47 leaves a 32 px "wedge"
+19 -2
View File
@@ -11,6 +11,7 @@
*
* Usage: node tools/seed-e2e.mjs && node tools/perf-field-shaders.mjs
* GM_PERF_COUNTS=... source counts (default 1,10,50), GM_PERF_FRAME=... frame edge in units
* GM_PERF_BLUR=... puts a Blur (#300) with that radius between picture and Output
*
* It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it.
* Read the rate, not the median – see `perf-shaders.mjs` for why, and for the 60 Hz cap.
@@ -23,6 +24,20 @@ const { projectId, baseUrl } = cliArgs('perf-field-shaders.mjs', PROJECTS.fixtur
const COUNTS = (process.env.GM_PERF_COUNTS ?? '1,10,50').split(',').map(Number);
const FRAME = Number(process.env.GM_PERF_FRAME ?? 256);
const BLUR = Number(process.env.GM_PERF_BLUR ?? 0);
const FIELD_GRAPH =
BLUR > 0
? {
nodes: [
{ id: 'pf-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: BLUR } },
{ id: 'pf-out', type: 'ShaderOutput', x: 300, y: 0, params: {} },
],
edges: [
{ id: 'pf-c', from: { node: 'pf-blur', port: 'color' }, to: { node: 'pf-out', port: 'color' } },
{ id: 'pf-a', from: { node: 'pf-blur', port: 'alpha' }, to: { node: 'pf-out', port: 'alpha' } },
],
}
: { nodes: [{ id: 'pf-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] };
const WARMUP_MS = 1500;
const SAMPLE_MS = 4000;
@@ -40,7 +55,7 @@ await runChecks({ projectId, baseUrl, port: 9348, shotPrefix: 'gm-perf-field' },
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'perf-source' && a.id !== 'perf-field');
p.assets.push({ id: 'perf-field', type: 'shader', name: 'Perf field', props: {},
graph: { nodes: [{ id: 'pf-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] } });
graph: ${JSON.stringify(FIELD_GRAPH)} });
p.assets.push({ id: 'perf-source', type: 'particles', name: 'Perf source',
props: { rate: 50, lifetime: 1, size: 24, color: 0x4080ff, fieldShader: '',
fieldWidth: ${FRAME}, fieldHeight: ${FRAME} },
@@ -131,7 +146,9 @@ await runChecks({ projectId, baseUrl, port: 9348, shotPrefix: 'gm-perf-field' },
await shot('heaviest');
await stop();
console.log(`\nframe ${FRAME}x${FRAME} units, pass-through field shader, ${SAMPLE_MS} ms per point`);
console.log(
`\nframe ${FRAME}x${FRAME} units, ${BLUR > 0 ? `Blur radius ${BLUR}` : 'pass-through'} field shader, ${SAMPLE_MS} ms per point`,
);
console.log('| sources | no field shader | field shader | picture |');
console.log('| --- | --- | --- | --- |');
for (const r of rows)
+14 -3
View File
@@ -12,6 +12,7 @@
* GM_PERF_COUNTS=... sprite counts, GM_PERF_SIZE=... edge length of one sprite
* GM_PERF_SUBDIVISIONS=... gives the shader a Vertex output with that grid (#79); nothing feeds
* it, so the pixels stay the same and only the corners are more
* GM_PERF_BLUR=... wears a shader of one Blur (#300) with that radius instead of Adjust colors
*
* It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it.
*
@@ -31,6 +32,8 @@ const { projectId, baseUrl } = cliArgs('perf-shaders.mjs', PROJECTS.fixture);
const COUNTS = (process.env.GM_PERF_COUNTS ?? '50,100,200,400,800').split(',').map(Number);
const SIZE = Number(process.env.GM_PERF_SIZE ?? 48);
const SUBDIVISIONS = Number(process.env.GM_PERF_SUBDIVISIONS ?? 0);
const BLUR = Number(process.env.GM_PERF_BLUR ?? 0);
const SHADER = BLUR > 0 ? 'perf-blur' : 'adjust-colors';
const WARMUP_MS = 1500;
const SAMPLE_MS = 4000;
@@ -47,9 +50,17 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
// One sprite asset for all of them: flipping its `shader` prop switches every instance at once.
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'perf-sprite');
p.assets = p.assets.filter((a) => a.id !== 'perf-sprite' && a.id !== 'perf-blur');
p.assets.push({ id: 'perf-sprite', type: 'sprite', name: 'Perf sprite',
props: { width: ${SIZE}, height: ${SIZE}, color: 0x4080ff, shader: '' } });
p.assets.push({ id: 'perf-blur', type: 'shader', name: 'Perf blur', props: {},
graph: { nodes: [
{ id: 'pb-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: ${BLUR} } },
{ id: 'pb-out', type: 'ShaderOutput', x: 300, y: 0, params: {} },
], edges: [
{ id: 'pb-c', from: { node: 'pb-blur', port: 'color' }, to: { node: 'pb-out', port: 'color' } },
{ id: 'pb-a', from: { node: 'pb-blur', port: 'alpha' }, to: { node: 'pb-out', port: 'alpha' } },
] } });
`);
await mutate(`
@@ -80,7 +91,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
const setShader = (on) =>
mutate(
`__gm.state.project.assets.find((a) => a.id === 'perf-sprite').props.shader = ${on ? "'adjust-colors'" : "''"}`,
`__gm.state.project.assets.find((a) => a.id === 'perf-sprite').props.shader = ${on ? `'${SHADER}'` : "''"}`,
);
/** Frame times as the page itself sees them, over `SAMPLE_MS` after a warm-up. */
@@ -157,7 +168,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
console.log(
`\nsprite ${SIZE}x${SIZE}, ${SUBDIVISIONS > 0 ? `grid of ${SUBDIVISIONS}` : 'plain quad'}, ` +
`${SAMPLE_MS} ms per point, neutral values, ` +
`${BLUR > 0 ? `Blur radius ${BLUR}` : 'Adjust colors, neutral values'}, ${SAMPLE_MS} ms per point, ` +
`60 Hz cap (cannot be lifted in headless)`,
);
console.log('| sprites | no shader | with shader | per sprite | shaders | filters |');