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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user