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

+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'));
});
});