Lay a shader over a particle source's whole field (#170)
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m6s

A shader on a source runs per particle and stops at each quad, so a
glow or blur across the whole cloud was not possible. A source now has
a second shader row that is laid over everything it draws at once.

- New rows Field shader, Field width and Field height (256) on a
  particle source. The size rows show only while a field shader is
  picked (new `shownWhenSet` on a row definition).
- The particles are drawn into a RenderTexture of the frame, and a
  sprite mesh wearing the field shader draws that picture. The frame
  is centred on the source and turns and grows with it.
- UV run 0 to 1 across the frame. Texture color, Texture alpha and
  Sample texture read the picture of the particles.
- The picture has as many device pixels as the frame covers on
  screen, at most 2048 per side. A frame off screen draws no pass.
- The field shader's Padding and Vertex output do nothing: the frame
  is the edge, and its corners stay put as on a nine-sliced sprite.
- A name both shaders declare is one row, and both uniforms read it.
- The scene view draws the frame dashed round a selected source. The
  Particles tab preview wears the field shader too.
- `tools/perf-field-shaders.mjs` measures 1, 10 and 50 sources. On
  the HD 630 they ran at 60.0, 60.0 and 57.6 fps against 60.1, 60.0
  and 59.2 without, one run per row.

Tests:
- Gate: 223 files, 3460 tests (3452 + 8), CHECK EXIT 0.
- e2e 31: 43 checks, 3 of them new. Sum 1707 is computed (1704 + 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 12:24:51 +02:00
1 parent bf27e42e8a
commit d6d38a6422
14 files changed
+656 -28

No files matched your search

+74
View File
@@ -655,6 +655,80 @@ export async function particles(t) {
__gm.state.project.assets = __gm.state.project.assets.filter(
(a) => a.id !== ${JSON.stringify(SHADER)});`);
// 31v. (#170) A field shader draws the particles into a picture of a 256-unit frame round the
// source and lays the shader over that. A graph with nothing in it hands the picture through:
// at 0.3 s the burst has fallen ½·800·0.3² = 36 units, so it has to stand below the source and
// not above – a picture upside down would show it mirrored about the source.
const FIELD = 'pt-field';
const setField = (graph) =>
mutate(`
const project = __gm.state.project;
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(FIELD)});
if (${JSON.stringify(graph)}) project.assets.push({
id: ${JSON.stringify(FIELD)}, type: 'shader', name: 'Field', props: {}, graph: ${JSON.stringify(graph)} });
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
if (${JSON.stringify(graph)}) o.overrides.fieldShader = ${JSON.stringify(FIELD)};
else delete o.overrides.fieldShader;`);
const fallen = async () => {
await at(0.3);
const pick = (dy) => json(`__gm.pixelAt(${Math.round(spotOn.x)}, ${Math.round(spotOn.y + dy * unit)})`);
return {
below: await pick(36),
above: await pick(-36),
pixels: await json(`__gm.fieldPixels(${JSON.stringify(SOURCE)})`),
};
};
await rewind();
const unshaded = await fallen();
await setField({ nodes: [{ id: 'fd-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] });
const handedThrough = await fallen();
const resolution = (await json('__gm.pixelGrid()')).resolution;
check(
'a field shader that changes nothing draws the field where it was, the right way up',
unshaded.pixels === null &&
colourDistance(unshaded.below, '#3cc85a') < 12 &&
colourDistance(handedThrough.below, unshaded.below) < 12 &&
colourDistance(handedThrough.above, unshaded.above) < 12 &&
Math.abs((handedThrough.pixels?.w ?? 0) - 256 * unit * resolution) <= 2,
JSON.stringify({ unshaded, handedThrough, unit, resolution }),
);
// 31v-ii. (#170) The shader covers the frame, not the particles: a flat red fills it 100 units off
// the source, where no particle ever is, and stops at its edge 128 units out.
await setField({
nodes: [
{ id: 'fd-red', type: 'ShaderColor', x: 0, y: 0, params: { value: 0xe63c3c } },
{ id: 'fd-one', type: 'ShaderNumber', x: 0, y: 80, params: { value: 1 } },
{ id: 'fd-out', type: 'ShaderOutput', x: 200, y: 0, params: {} },
],
edges: [
{ from: { node: 'fd-red', port: 'color' }, to: { node: 'fd-out', port: 'color' } },
{ from: { node: 'fd-one', port: 'value' }, to: { node: 'fd-out', port: 'alpha' } },
],
});
await at(0.3);
const offAt = (dx, dy) =>
json(`__gm.pixelAt(${Math.round(spotOn.x + dx * unit)}, ${Math.round(spotOn.y + dy * unit)})`);
const inFrame = await offAt(100, -100);
const pastFrame = await offAt(140, 0);
await shot('31-particles-field');
check(
'the field shader fills its whole frame, past every particle, and nothing beyond it',
colourDistance(inFrame, '#e63c3c') < 12 && colourDistance(pastFrame, '#e63c3c') > 60,
JSON.stringify({ inFrame, pastFrame }),
);
// 31v-iii. Taken off again, the source draws its particles itself and no picture is left.
await setField(null);
const back = await fallen();
check(
'without a field shader the particles are drawn as before',
back.pixels === null &&
colourDistance(back.below, unshaded.below) < 12 &&
colourDistance(back.above, unshaded.above) < 12,
JSON.stringify({ back, unshaded }),
);
// 31p. The graph tab draws no Pixi complaint of its own.
const logged = await json('__gm.console.lines()');
check(