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

+1 -1
View File
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1707 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1712 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+19 -8
View File
@@ -6,6 +6,7 @@ import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema';
import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders';
import {
asVec3,
BLUR_FN,
DIVIDE_FN,
type EdgeMode,
edgeOf,
@@ -13,6 +14,7 @@ import {
floatLiteral,
isShaderNodeDef,
paddingOf,
SHADER_BLUR,
SHADER_OUTPUT,
SHADER_SAMPLE,
SHADER_TIME,
@@ -150,6 +152,7 @@ export function compileShader(graph: Graph): ShaderBuild {
const time = graph.nodes.some((n) => n.type === SHADER_TIME && (walk.reached(n) || cornerWalk.reached(n)));
const fragmentTime = graph.nodes.some((n) => n.type === SHADER_TIME && walk.reached(n));
const blurs = graph.nodes.some((n) => n.type === SHADER_BLUR && walk.reached(n));
// The fragment runs in the sprite's own draw (#77). `vColor` is the tint, premultiplied like the
// texel; it goes in before the graph, so `Texture color` is the sprite as it draws without a shader.
// The version line is what makes Pixi compile the program as GLSL ES 3.00 (`GlProgram` looks for
@@ -165,6 +168,7 @@ export function compileShader(graph: Graph): ShaderBuild {
STRAIGHT_FN,
DIVIDE_FN,
texelAtFn(mode),
...(blurs ? [BLUR_FN] : []),
'void main()',
'{',
` vec4 texel = ${TEXEL_AT}(vTextureCoord) * vColor;`,
@@ -178,13 +182,15 @@ export function compileShader(graph: Graph): ShaderBuild {
// The edge only shows where a Sample reads somewhere other than its own pixel. A graph that never
// shifts a UV stays on the plain sheet, so the common shader costs no second copy of the picture.
// With padding every pixel of the margin reads past the border; Transparent is cut in the
// fragment and takes the plain sheet as well.
const shifted = graph.nodes.some(
(n) =>
n.type === SHADER_SAMPLE &&
walk.reached(n) &&
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
);
// fragment and takes the plain sheet as well. A Blur reads its neighbours, so it always shifts.
const shifted =
blurs ||
graph.nodes.some(
(n) =>
n.type === SHADER_SAMPLE &&
walk.reached(n) &&
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
);
const edge = (shifted || padding > 0) && mode !== 'transparent' ? mode : SHEET_EDGE;
return { compiled: { fragment, vertex, uniforms, time, edge, padding }, problems, hints, reason: '' };
}
@@ -205,8 +211,13 @@ function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[
noun: 'shader',
emit: (def, inputs, node) => {
if (def.perPixel) perPixel?.push(def.label);
let prepared: string | undefined;
if (def.prepare) {
prepared = `${prefix}${count++}_prepared`;
lines.push(`vec4 ${prepared} = ${def.prepare(inputs, node.params)};`);
}
const outputs: Record<string, Expr> = {};
for (const [key, expr] of Object.entries(def.emit(inputs, node.params))) {
for (const [key, expr] of Object.entries(def.emit(inputs, node.params, prepared))) {
const local = `${prefix}${count++}_${key}`;
lines.push(`${expr.type} ${local} = ${expr.code};`);
outputs[key] = { code: local, type: expr.type };
+107 -1
View File
@@ -27,11 +27,13 @@ export interface Expr {
*/
export interface ShaderNodeDef extends NodeDef {
category: 'shader';
emit(inputs: Record<string, Expr>, params: PropertyBag): Record<string, Expr>;
emit(inputs: Record<string, Expr>, params: PropertyBag, prepared?: string): Record<string, Expr>;
/** What an input with nothing to type on reads while unconnected; without it, the texel. */
unfed?: Record<string, Expr>;
/** Reads the picture or is a colour: there is none per corner, so it cannot feed the Vertex output (#79). */
perPixel?: boolean;
/** A vec4 all outputs read, worked out once into a local `emit` gets as `prepared` (#300). */
prepare?: (inputs: Record<string, Expr>, params: PropertyBag) => string;
}
export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef {
@@ -48,6 +50,7 @@ export const SHADER_UV = 'ShaderUV';
export const SHADER_SAMPLE = 'ShaderSample';
export const SHADER_TEXTURE_SIZE = 'ShaderTextureSize';
export const SHADER_TIME = 'ShaderTime';
export const SHADER_BLUR = 'ShaderBlur';
/** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */
export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' };
@@ -305,6 +308,108 @@ export const ShaderSample: ShaderNodeDef = {
},
};
export const MAX_BLUR_RADIUS = 8;
const BLUR = 'blur';
const BLUR_KIND_PARAM = 'kind';
const BLUR_DIRECTION_PARAM = 'direction';
const BLUR_AXES: Record<string, string> = {
both: 'vec2(1.0, 1.0)',
horizontal: 'vec2(1.0, 0.0)',
vertical: 'vec2(0.0, 1.0)',
};
/**
* The prelude's blur: the premultiplied samples of a square (or a line) around `uv`, weighted and
* averaged, so a transparent neighbour adds no colour and no dark seam. The outermost ring counts
* by the fraction of the radius that reaches it, so a radius can change smoothly. Gaussian weights
* fall off with sigma = radius / 2.
*/
export const BLUR_FN = [
'float blurWeight(int k, float r, float gaussian) {',
' float d = abs(float(k));',
' float w = clamp(r + 1.0 - d, 0.0, 1.0);',
' float s = max(r, 0.0001) * 0.5;',
' return gaussian > 0.5 ? w * exp(-d * d / (2.0 * s * s)) : w;',
'}',
`vec4 ${BLUR}(vec2 uv, float radius, vec2 axis, float gaussian) {`,
` float r = clamp(radius, 0.0, ${floatLiteral(MAX_BLUR_RADIUS)});`,
' ivec2 reach = ivec2(axis) * int(ceil(r));',
' vec2 texel = 1.0 / vec2(textureSize(uTexture, 0));',
' vec4 sum = vec4(0.0);',
' float total = 0.0;',
' for (int j = -reach.y; j <= reach.y; j++) {',
' for (int i = -reach.x; i <= reach.x; i++) {',
' float w = blurWeight(i, r, gaussian) * blurWeight(j, r, gaussian);',
` sum += ${TEXEL_AT}(uv + vec2(float(i), float(j)) * texel) * w;`,
' total += w;',
' }',
' }',
' return sum / total;',
'}',
].join('\n');
/**
* The picture softened around a spot in one pass (#300). The loop is in the prelude's `blur`, so a
* radius from a Property changes without a new program.
*/
export const ShaderBlur: ShaderNodeDef = {
type: SHADER_BLUR,
label: 'Blur',
category: 'shader',
description: `The sprite's picture softened around a spot, tinted like Texture color. radius is in pixels of the picture, at most ${MAX_BLUR_RADIUS}, and may be a fraction. Box weighs every pixel alike, Gaussian the middle more. Past the border the picture continues as the Output's Edge says.`,
inputs: [
{
...floatIn('radius', 2),
field: {
key: 'radius',
label: 'radius',
kind: 'number',
default: 2,
min: 0,
max: MAX_BLUR_RADIUS,
step: 0.5,
},
},
{ key: 'u', label: 'u', type: 'number', inline: false },
{ key: 'v', label: 'v', type: 'number', inline: false },
],
outputs: [vec3Port('color'), numberOut('alpha')],
params: [
{
key: BLUR_KIND_PARAM,
label: 'Kind',
kind: 'enum',
default: 'box',
options: [
{ value: 'box', label: 'Box' },
{ value: 'gaussian', label: 'Gaussian' },
],
},
{
key: BLUR_DIRECTION_PARAM,
label: 'Direction',
kind: 'enum',
default: 'both',
options: [
{ value: 'both', label: 'Both' },
{ value: 'horizontal', label: 'Horizontal' },
{ value: 'vertical', label: 'Vertical' },
],
},
],
perPixel: true,
unfed: { u: OWN_U, v: OWN_V },
prepare: ({ radius, u, v }, params) => {
const axis = BLUR_AXES[textOf(params[BLUR_DIRECTION_PARAM] ?? '')] ?? BLUR_AXES.both!;
const gaussian = textOf(params[BLUR_KIND_PARAM] ?? '') === 'gaussian' ? '1.0' : '0.0';
return `${BLUR}(vec2(${u!.code}, ${v!.code}), ${radius!.code}, ${axis}, ${gaussian}) * vColor`;
},
emit: (_inputs, _params, prepared) => ({
color: { code: `straight(${prepared})`, type: 'vec3' },
alpha: { code: `${prepared}.a`, type: 'float' },
}),
};
export const ShaderTextureSize: ShaderNodeDef = {
type: SHADER_TEXTURE_SIZE,
label: 'Texture size',
@@ -570,6 +675,7 @@ export const SHADER_NODES: readonly ShaderNodeDef[] = [
ShaderTextureAlpha,
ShaderUV,
ShaderSample,
ShaderBlur,
ShaderTextureSize,
ShaderTime,
ShaderOutput,
+114
View File
@@ -0,0 +1,114 @@
/**
* A shader's `Blur` node (#300): the picture softened around a spot in one pass, the loop written
* by the compiler into the prelude.
*/
import { describe, expect, it } from 'vitest';
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { findAssetOf } from '../../src/core/model/Project';
import { SHADER_PROPERTY } from '../../src/core/properties/shaders';
import { compileShader, perCornerProblem } from '../../src/core/shader/compile';
import {
BLUR_FN,
SHADER_BLUR,
SHADER_OUTPUT,
SHADER_VERTEX_OUTPUT,
SHEET_EDGE,
} from '../../src/core/shader/nodes';
import { makeSpriteScene } from '../helpers';
function shader(outParams = {}) {
const f = makeSpriteScene();
const create = new CreateAssetCommand('shader', 'Soften');
f.stack.execute(create);
const id = create.asset.id;
const asset = () => findAssetOf(f.project, id, 'shader')!;
const add = (type: string, params = {}) => {
const command = new AddNodeCommand(id, type, 0, 0, params);
f.stack.execute(command);
return command.node.id;
};
const link = (from: string, fromPort: string, to: string, toPort: string) =>
f.stack.execute(new ConnectCommand(id, { node: from, port: fromPort }, { node: to, port: toPort }));
const out = add(SHADER_OUTPUT, outParams);
return { add, link, out, build: () => compileShader(asset().graph) };
}
/** A Blur feeding both outputs of the Output node. */
function blurred(params = {}, outParams = {}) {
const s = shader(outParams);
const blur = s.add(SHADER_BLUR, params);
s.link(blur, 'color', s.out, 'color');
s.link(blur, 'alpha', s.out, 'alpha');
return { ...s, blur };
}
const calls = (fragment: string) => fragment.split('void main()')[1]!.match(/blur\(/g)?.length ?? 0;
describe('Blur (#300)', () => {
it('samples once per pixel, premultiplied, and both outputs read that one sum', () => {
const fragment = blurred().build().compiled!.fragment;
expect(fragment).toContain(BLUR_FN);
expect(fragment).toContain(
'vec4 n0_prepared = blur(vec2(vTextureCoord.x, vTextureCoord.y), 2.0, vec2(1.0, 1.0), 0.0) * vColor;',
);
expect(fragment).toContain('vec3 n1_color = straight(n0_prepared);');
expect(fragment).toContain('float n2_alpha = n0_prepared.a;');
expect(calls(fragment)).toBe(1);
});
it('turns Kind and Direction into the arguments of the one helper', () => {
const call = (params: object) =>
blurred(params)
.build()
.compiled!.fragment.match(/= blur\(.*;/)![0];
expect(call({ kind: 'gaussian' })).toContain('vec2(1.0, 1.0), 1.0)');
expect(call({ direction: 'horizontal' })).toContain('vec2(1.0, 0.0), 0.0)');
expect(call({ direction: 'vertical' })).toContain('vec2(0.0, 1.0), 0.0)');
expect(call({ kind: 'nonsense', direction: 'nonsense' })).toContain('vec2(1.0, 1.0), 0.0)');
});
it('holds the radius at 0 to 8 in the program, and takes it from a Property as a uniform', () => {
expect(BLUR_FN).toContain('float r = clamp(radius, 0.0, 8.0);');
const s = blurred({ radius: 3.5 });
expect(s.build().compiled!.fragment).toContain('blur(vec2(vTextureCoord.x, vTextureCoord.y), 3.5,');
const soft = s.add(SHADER_PROPERTY, { name: 'soft', default: 2, min: 0, max: 8 });
s.link(soft, 'value', s.blur, 'radius');
const compiled = s.build().compiled!;
expect(compiled.uniforms).toEqual([{ name: 'soft', kind: 'number', default: 2 }]);
expect(compiled.fragment).toMatch(/= blur\(vec2\(vTextureCoord\.x, vTextureCoord\.y\), n\d+_value,/);
});
it('reads at the UV it is fed', () => {
const s = blurred();
const uv = s.add('ShaderUV');
const flip = s.add('ShaderSubtract', { a: 1 });
s.link(uv, 'u', flip, 'b');
s.link(flip, 'result', s.blur, 'u');
expect(s.build().compiled!.fragment).toMatch(/= blur\(vec2\(n\d+_result, vTextureCoord\.y\), 2\.0,/);
});
it('leaves the helper out of a shader that does not reach it', () => {
const s = shader();
s.add(SHADER_BLUR);
const fragment = s.build().compiled!.fragment;
expect(fragment).not.toContain('blurWeight');
expect(calls(fragment)).toBe(0);
});
it('reads past the border, so the picture comes with the Output’s Edge', () => {
expect(blurred().build().compiled!.edge).toBe('clamp');
expect(blurred({}, { edge: 'repeat' }).build().compiled!.edge).toBe('repeat');
expect(blurred({}, { edge: 'transparent' }).build().compiled!.edge).toBe(SHEET_EDGE);
});
it('reads the picture, so it cannot feed the Vertex output', () => {
const s = shader();
const corner = s.add(SHADER_VERTEX_OUTPUT);
const blur = s.add(SHADER_BLUR);
s.link(blur, 'alpha', corner, 'x');
const build = s.build();
expect(build.compiled).toBeNull();
expect(build.problems[corner]).toBe(perCornerProblem('Blur'));
});
});
+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 |');