From df61b08bd11c698c48db8b4c96787c0b474770d7 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 3 Oct 2026 13:11:31 +0200 Subject: [PATCH] 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=. 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 --- README.md | 2 +- src/core/shader/compile.ts | 27 +++++--- src/core/shader/nodes.ts | 108 ++++++++++++++++++++++++++++++- tests/core/shader-blur.test.ts | 114 +++++++++++++++++++++++++++++++++ tools/e2e/28-shader.mjs | 110 ++++++++++++++++++++++++++++++- tools/e2e/lib/pictures.mjs | 15 +++++ tools/perf-field-shaders.mjs | 21 +++++- tools/perf-shaders.mjs | 17 ++++- 8 files changed, 398 insertions(+), 16 deletions(-) create mode 100644 tests/core/shader-blur.test.ts diff --git a/README.md b/README.md index 790c21b9..6b15e546 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1707 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1712 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index cb213fd8..26299335 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -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 = {}; - 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 }; diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index d55f7df9..c8e62886 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -27,11 +27,13 @@ export interface Expr { */ export interface ShaderNodeDef extends NodeDef { category: 'shader'; - emit(inputs: Record, params: PropertyBag): Record; + emit(inputs: Record, params: PropertyBag, prepared?: string): Record; /** What an input with nothing to type on reads while unconnected; without it, the texel. */ unfed?: Record; /** 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, 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 = { + 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, diff --git a/tests/core/shader-blur.test.ts b/tests/core/shader-blur.test.ts new file mode 100644 index 00000000..6d271d92 --- /dev/null +++ b/tests/core/shader-blur.test.ts @@ -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')); + }); +}); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 466aad58..3549cc4e 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -1,5 +1,12 @@ import { colourDistance } from '../lib/harness.mjs'; -import { FIND_HALVES, HALVES_LEFT as LEFT, HALVES_RIGHT as RIGHT, PASTE_HALVES } from './lib/pictures.mjs'; +import { + FIND_DOT, + FIND_HALVES, + HALVES_LEFT as LEFT, + HALVES_RIGHT as RIGHT, + PASTE_DOT, + PASTE_HALVES, +} from './lib/pictures.mjs'; /** * Shader assets: made from the + menu, built from nodes in the Shader tab, worn by a sprite through @@ -948,6 +955,107 @@ export async function shader(t) { ); await mutate(`delete __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.tint`); + // 28l-vi. Blur softens the picture in one pass (#300), its radius in pixels of the picture. Texel 15 + // of the halves is the last red one: a radius of 4 across takes in 5 red and 4 green. + // Nearest, so a screen pixel beside a texel's centre still reads that texel alone. + const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + const texel = (i, j, size = 32) => pixel((i + 0.5) / size - 0.5, (j + 0.5) / size - 0.5); + const filterOf = (id, filter) => + mutate(` + const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(id)}); + if (a) a.props.filter = ${JSON.stringify(filter)};`); + await filterOf(picture?.id, 'nearest'); + const blurred = (blurParams, outParams = {}, more = [[], []]) => + graph( + [ + node('blur', 'ShaderBlur', blurParams, 260, 0), + node('out', 'ShaderOutput', outParams, 780, 0), + ...more[0], + ], + [edge('blur', 'color', 'out', 'color'), ...more[1]], + ); + await texelOnly({}); + const unblurred = [await texel(15, 6), await texel(16, 6)]; + await blurred({ direction: 'horizontal' }, {}, [ + [node('soft', 'ShaderProperty', { name: 'soft', default: 0, min: 0, max: 8 }, 0, 0)], + [edge('soft', 'value', 'blur', 'radius')], + ]); + const zero = [await texel(15, 6), await texel(16, 6)]; + check('Blur with radius 0 draws the picture unchanged, byte for byte', zero.join() === unblurred.join(), { + unblurred, + zero, + }); + const compilesBefore = await json('__gm.shaderCompiles()'); + const softened = []; + for (const soft of [0.5, 1, 4]) { + await assetProps({ soft }); + softened.push(rgb(await texel(15, 6))[0]); + } + const compilesAfter = await json('__gm.shaderCompiles()'); + // Red share at texel 15: 0.5 weighs 0.5 + 1 + 0.5 (3/4 red), 1 is 2 of 3, 4 is 5 of 9. + const redAt = (share) => Math.round(230 * share + 60 * (1 - share)); + check( + 'a radius from a Property softens step by step, fractions included, without a new program', + rgb(zero[0])[0] > softened[0] && + softened[0] > softened[1] && + softened[1] > softened[2] && + Math.abs(softened[0] - redAt(3 / 4)) < 10 && + Math.abs(softened[2] - redAt(5 / 9)) < 10 && + compilesAfter === compilesBefore, + { zero, softened, expected: [redAt(3 / 4), redAt(2 / 3), redAt(5 / 9)], compilesBefore, compilesAfter }, + ); + await mutate(`delete __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.soft`); + const radiusFour = async (params) => { + await blurred({ radius: 4, ...params }); + return rgb(await texel(15, 6)); + }; + const boxed = await radiusFour({ direction: 'horizontal' }); + const gaussian = await radiusFour({ direction: 'horizontal', kind: 'gaussian' }); + const vertical = await radiusFour({ direction: 'vertical' }); + check( + 'a Box of 4 mixes 5 red with 4 green, Vertical stays red, Gaussian leans to the middle', + Math.abs(boxed[0] - redAt(5 / 9)) < 10 && + colourDistance(`#${vertical.map((c) => c.toString(16).padStart(2, '0')).join('')}`, LEFT) < 12 && + gaussian[0] > boxed[0] + 4 && + gaussian[1] < boxed[1] - 3, + { boxed, gaussian, vertical }, + ); + // At the transparent outside the colour stays red; only the opacity drops, to 5 of 9. + const ground = rgb(await pixel(-0.62, -0.3)); + await blurred({ radius: 4, direction: 'horizontal' }, { edge: 'transparent' }); + const borderColour = await texel(0, 6); + await blurred({ radius: 4, direction: 'horizontal' }, { edge: 'transparent' }, [ + [], + [edge('blur', 'alpha', 'out', 'alpha')], + ]); + const borderShare = (rgb(await texel(0, 6))[0] - ground[0]) / (rgb(LEFT)[0] - ground[0]); + check( + 'at a transparent border the colour stays and only the opacity drops: no dark seam', + colourDistance(borderColour, LEFT) < 12 && Math.abs(borderShare - 5 / 9) < 0.08, + { borderColour, borderShare, ground }, + ); + // One white pixel blurred by 1 is a 3x3 spot of 1/9 each: its opacity adds up to the pixel's. The + // pixel sits off the sprite's middle, where a symbol may draw over it. + await evaluate(PASTE_DOT); + await sleep(2500); + const dot = await json(FIND_DOT); + await evaluate('__gm.state.select(null)'); + await filterOf(dot, 'nearest'); + await assetProps({ texture: dot ?? '' }); + await blurred({ radius: 1 }, { edge: 'transparent' }, [[], [edge('blur', 'alpha', 'out', 'alpha')]]); + const spot = []; + for (let j = 2; j <= 6; j++) + for (let i = 2; i <= 6; i++) spot.push((rgb(await texel(i, j, 16))[0] - ground[0]) / (255 - ground[0])); + const lit = spot.filter((a) => a > 0.05).length; + const opacity = spot.reduce((sum, a) => sum + a, 0); + check( + 'one white pixel blurred by 1 becomes a 3x3 spot whose opacity adds up to the pixel’s', + Boolean(dot) && lit === 9 && Math.abs(opacity - 1) < 0.1, + { dot, lit, opacity: opacity.toFixed(3), spot: spot.map((a) => a.toFixed(2)).join(' ') }, + ); + await assetProps({ texture: picture?.id ?? '' }); + await filterOf(picture?.id, 'linear'); + // 28l-ii. The sprite wears a picture and a shader: both open from the object's menu, from the // asset's menu and from the inspector, whose Texture and Shader labels are links (#221). const shadedProps = await json("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').props"); diff --git a/tools/e2e/lib/pictures.mjs b/tools/e2e/lib/pictures.mjs index 5f95a36a..7e72945f 100644 --- a/tools/e2e/lib/pictures.mjs +++ b/tools/e2e/lib/pictures.mjs @@ -14,6 +14,21 @@ export const PASTE_HALVES = `(async () => { export const HALVES_LEFT = '#e63c3c'; export const HALVES_RIGHT = '#3cc85a'; +/** A 16 px transparent picture with one white pixel at (4, 4), for the Blur's spot (28l-vi). */ +export const PASTE_DOT = `(async () => { + const canvas = document.createElement('canvas'); + canvas.width = 16; + canvas.height = 16; + const g = canvas.getContext('2d'); + g.fillStyle = '#ffffff'; g.fillRect(4, 4, 1, 1); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + const transfer = new DataTransfer(); + transfer.items.add(new File([blob], 'dot.png', { type: 'image/png' })); + window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); +})()`; +export const FIND_DOT = + "__gm.state.project.assets.filter((a) => a.type === 'texture' && a.name === 'dot' && a.width === 16).map((a) => a.id).at(-1) ?? null"; + /** * The pasted picture's asset, once the paste has landed: the newest one named after the pasted * file. Size alone is not enough – in the full run a module before 47 leaves a 32 px "wedge" diff --git a/tools/perf-field-shaders.mjs b/tools/perf-field-shaders.mjs index 5522fd56..0dcce538 100644 --- a/tools/perf-field-shaders.mjs +++ b/tools/perf-field-shaders.mjs @@ -11,6 +11,7 @@ * * Usage: node tools/seed-e2e.mjs && node tools/perf-field-shaders.mjs * GM_PERF_COUNTS=... source counts (default 1,10,50), GM_PERF_FRAME=... frame edge in units + * GM_PERF_BLUR=... puts a Blur (#300) with that radius between picture and Output * * It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it. * Read the rate, not the median – see `perf-shaders.mjs` for why, and for the 60 Hz cap. @@ -23,6 +24,20 @@ const { projectId, baseUrl } = cliArgs('perf-field-shaders.mjs', PROJECTS.fixtur const COUNTS = (process.env.GM_PERF_COUNTS ?? '1,10,50').split(',').map(Number); const FRAME = Number(process.env.GM_PERF_FRAME ?? 256); +const BLUR = Number(process.env.GM_PERF_BLUR ?? 0); +const FIELD_GRAPH = + BLUR > 0 + ? { + nodes: [ + { id: 'pf-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: BLUR } }, + { id: 'pf-out', type: 'ShaderOutput', x: 300, y: 0, params: {} }, + ], + edges: [ + { id: 'pf-c', from: { node: 'pf-blur', port: 'color' }, to: { node: 'pf-out', port: 'color' } }, + { id: 'pf-a', from: { node: 'pf-blur', port: 'alpha' }, to: { node: 'pf-out', port: 'alpha' } }, + ], + } + : { nodes: [{ id: 'pf-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] }; const WARMUP_MS = 1500; const SAMPLE_MS = 4000; @@ -40,7 +55,7 @@ await runChecks({ projectId, baseUrl, port: 9348, shotPrefix: 'gm-perf-field' }, const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'perf-source' && a.id !== 'perf-field'); p.assets.push({ id: 'perf-field', type: 'shader', name: 'Perf field', props: {}, - graph: { nodes: [{ id: 'pf-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] } }); + graph: ${JSON.stringify(FIELD_GRAPH)} }); p.assets.push({ id: 'perf-source', type: 'particles', name: 'Perf source', props: { rate: 50, lifetime: 1, size: 24, color: 0x4080ff, fieldShader: '', fieldWidth: ${FRAME}, fieldHeight: ${FRAME} }, @@ -131,7 +146,9 @@ await runChecks({ projectId, baseUrl, port: 9348, shotPrefix: 'gm-perf-field' }, await shot('heaviest'); await stop(); - console.log(`\nframe ${FRAME}x${FRAME} units, pass-through field shader, ${SAMPLE_MS} ms per point`); + console.log( + `\nframe ${FRAME}x${FRAME} units, ${BLUR > 0 ? `Blur radius ${BLUR}` : 'pass-through'} field shader, ${SAMPLE_MS} ms per point`, + ); console.log('| sources | no field shader | field shader | picture |'); console.log('| --- | --- | --- | --- |'); for (const r of rows) diff --git a/tools/perf-shaders.mjs b/tools/perf-shaders.mjs index 4a181d5a..5b4dd94b 100644 --- a/tools/perf-shaders.mjs +++ b/tools/perf-shaders.mjs @@ -12,6 +12,7 @@ * GM_PERF_COUNTS=... sprite counts, GM_PERF_SIZE=... edge length of one sprite * GM_PERF_SUBDIVISIONS=... gives the shader a Vertex output with that grid (#79); nothing feeds * it, so the pixels stay the same and only the corners are more + * GM_PERF_BLUR=... wears a shader of one Blur (#300) with that radius instead of Adjust colors * * It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it. * @@ -31,6 +32,8 @@ const { projectId, baseUrl } = cliArgs('perf-shaders.mjs', PROJECTS.fixture); const COUNTS = (process.env.GM_PERF_COUNTS ?? '50,100,200,400,800').split(',').map(Number); const SIZE = Number(process.env.GM_PERF_SIZE ?? 48); const SUBDIVISIONS = Number(process.env.GM_PERF_SUBDIVISIONS ?? 0); +const BLUR = Number(process.env.GM_PERF_BLUR ?? 0); +const SHADER = BLUR > 0 ? 'perf-blur' : 'adjust-colors'; const WARMUP_MS = 1500; const SAMPLE_MS = 4000; @@ -47,9 +50,17 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async // One sprite asset for all of them: flipping its `shader` prop switches every instance at once. await mutate(` const p = __gm.state.project; - p.assets = p.assets.filter((a) => a.id !== 'perf-sprite'); + p.assets = p.assets.filter((a) => a.id !== 'perf-sprite' && a.id !== 'perf-blur'); p.assets.push({ id: 'perf-sprite', type: 'sprite', name: 'Perf sprite', props: { width: ${SIZE}, height: ${SIZE}, color: 0x4080ff, shader: '' } }); + p.assets.push({ id: 'perf-blur', type: 'shader', name: 'Perf blur', props: {}, + graph: { nodes: [ + { id: 'pb-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: ${BLUR} } }, + { id: 'pb-out', type: 'ShaderOutput', x: 300, y: 0, params: {} }, + ], edges: [ + { id: 'pb-c', from: { node: 'pb-blur', port: 'color' }, to: { node: 'pb-out', port: 'color' } }, + { id: 'pb-a', from: { node: 'pb-blur', port: 'alpha' }, to: { node: 'pb-out', port: 'alpha' } }, + ] } }); `); await mutate(` @@ -80,7 +91,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async const setShader = (on) => mutate( - `__gm.state.project.assets.find((a) => a.id === 'perf-sprite').props.shader = ${on ? "'adjust-colors'" : "''"}`, + `__gm.state.project.assets.find((a) => a.id === 'perf-sprite').props.shader = ${on ? `'${SHADER}'` : "''"}`, ); /** Frame times as the page itself sees them, over `SAMPLE_MS` after a warm-up. */ @@ -157,7 +168,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async console.log( `\nsprite ${SIZE}x${SIZE}, ${SUBDIVISIONS > 0 ? `grid of ${SUBDIVISIONS}` : 'plain quad'}, ` + - `${SAMPLE_MS} ms per point, neutral values, ` + + `${BLUR > 0 ? `Blur radius ${BLUR}` : 'Adjust colors, neutral values'}, ${SAMPLE_MS} ms per point, ` + `60 Hz cap (cannot be lifted in headless)`, ); console.log('| sprites | no shader | with shader | per sprite | shaders | filters |');