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

+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,