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
+109
-1
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user