Files
feuerwerk/tests/core/shader-blur.test.ts
T
adminandClaude Opus 5.5 df61b08bd1
game-maker / gate (push) Successful in 3m3s
game-maker / suite (push) Successful in 14m26s
Soften a shader's picture in one pass with a Blur node (#300)
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>
2026-10-03 13:11:31 +02:00

115 lines
4.8 KiB
TypeScript
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.
/**
* 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'));
});
});