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>
This commit is contained in:
1 parent
d6d38a6422
commit
df61b08bd1
8 files changed
+398
-16
No files matched your search
@@ -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'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user