Shaders draw in the sprite's own pass: a Mesh with the graph's fragment, no filter (#77)
game-maker / suite (push) Successful in 8m24s

A sprite's picture is now a Mesh (unit quad, sibling of its Graphics node, which keeps
only the hit area); a worn shader replaces Pixi's fragment in that draw, so there is no
intermediate texture and no extra render pass. Measured on the HD 630: 400 shaded
sprites hold 60 fps (was 34), 800 hold 56 (was 20).

Two Pixi facts the code leans on: a mesh's texture is bound only for Pixi's own shader,
so GraphShader.wear hands it over; and MeshPipe caches whether a mesh batches and
re-checks it only in a frame without structure changes, so switching a shader on or
off calls mesh.unload().

The console panel mirrors console.group/groupCollapsed/groupEnd and fills the %s %d
%o %c directives, so Pixi's deprecation warning shows its head, not only its stack.
E2E 12 guards against the addChild-on-Graphics warning, 28 counts shaders instead of
filters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-13 15:31:08 +02:00
1 parent 82effb2c03
commit 0e5e5ef925
15 files changed
+328 -88

No files matched your search

+9 -5
View File
@@ -186,10 +186,10 @@ export async function textures(t) {
);
await shot('33-texture-filter-smooth');
// 12c-ii. A shader is a filter pass: the sprite goes into an intermediate texture and back. At a
// camera zoom that puts the picture on a fractional pixel grid, that copy must not resample the
// nearest-filtered border. Measured in play, where the camera's zoom is the world scale; one play
// per setting, since the play world is built from the project when it starts.
// 12c-ii. A shader draws in the sprite's own pass (#77) and samples the texture itself. At a
// camera zoom that puts the picture on a fractional pixel grid, it must find the very same
// nearest-filtered border as the plain draw. Measured in play, where the camera's zoom is the
// world scale; one play per setting, since the play world is built from the project when it starts.
await mutate(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = 'nearest'`,
);
@@ -223,7 +223,7 @@ export async function textures(t) {
JSON.stringify(bare),
);
check(
'a neutral shader draws the very same pixels there: the filter pass does not resample',
'a neutral shader draws the very same pixels there: the shader does not resample',
JSON.stringify(filtered.pixels) === JSON.stringify(bare.pixels),
JSON.stringify({ bare: bare.pixels, filtered: filtered.pixels }),
);
@@ -233,6 +233,10 @@ export async function textures(t) {
darkened.pixels.every((c, i) => colourDistance(c, bare.pixels[i]) > 100 && !pure(c)),
JSON.stringify(darkened),
);
// The sprite's picture is a mesh next to its node: put under the Graphics instead, Pixi warns
// once per page, through `groupCollapsed`, which the panel mirrors since step 27b-ii.
const complaints = (await json('__gm.console.lines()')).filter((line) => /Deprecation/.test(line.text));
check('drawing sprites this way raises no Pixi deprecation warning', complaints.length === 0, complaints);
await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === 'otex');
o.position.x = ${placed.x}; o.position.y = ${placed.y};
+14
View File
@@ -75,6 +75,20 @@ export async function consolePanel(t) {
formatted,
);
// 27b-ii. A group keeps its head and indents what follows: Pixi's deprecation warnings put the
// message into `groupCollapsed` with `%c` styles and only the stack into `warn`, so without this
// the panel showed a stack with no head.
await evaluate(
`console.groupCollapsed('%cgrouped 27: %c%s', 'color:red', 'color:blue', 'head'); console.warn(' at frame 27'); console.groupEnd(); console.log('after 27')`,
);
await settle();
const grouped = (await texts()).slice(-3);
check(
'a group head reads without its %c styles and the lines under it are indented',
grouped[0] === 'grouped 27: head' && grouped[1] === ' at frame 27' && grouped[2] === 'after 27',
grouped,
);
// 27c. The real path a project uses: a Log node during play writes through `console.log`.
const rig = await scaffoldBehavior(t, {
assetId: 'talker',
+7 -5
View File
@@ -230,16 +230,18 @@ export async function shader(t) {
colourDistance(neutral, base) < 6,
JSON.stringify({ base, neutral }),
);
// What the cost is made of (#67): one filter per sprite wearing a shader, so one render pass.
const passesOn = await json('__gm.filterCount()');
// What the cost was made of (#67) and is not any more (#77): the shader draws in the sprite's own
// pass, there is no filter and so no extra render pass.
const passes = '({ shaders: __gm.shaderCount(), filters: __gm.filterCount() })';
const passesOn = await json(passes);
await assetProps({ shader: '' });
await sleep(300);
const passesOff = await json('__gm.filterCount()');
const passesOff = await json(passes);
await assetProps({ shader: ADJUST });
await sleep(300);
check(
'a shaded sprite carries one filter – one render pass – and none without',
passesOn === 1 && passesOff === 0,
'a shaded sprite draws with its own shader and no filter pass – and plain without',
passesOn.shaders === 1 && passesOn.filters === 0 && passesOff.shaders === 0 && passesOff.filters === 0,
{
on: passesOn,
off: passesOff,
+14 -11
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env node
/**
* What a shader costs: the same scene of N sprites drawn with and without `Adjust colors`, measured
* as frame time in the running game. Every sprite wearing a shader gets its own Pixi filter, so it
* gets its own render pass into an intermediate texture and back (#67).
* as frame time in the running game. Up to #67 every sprite wearing a shader got its own Pixi
* filter, so its own render pass into an intermediate texture and back; since #77 the fragment
* runs in the sprite's own draw, which leaves the batch – one draw call per shaded sprite, no pass.
*
* The values are neutral (1), so both passes put the very same pixels on screen – the only
* difference between the two runs is the pass itself.
* The values are neutral (1), so both runs put the very same pixels on screen – the only
* difference between them is how the sprite is drawn.
*
* Usage: node tools/seed-e2e.mjs && node tools/perf-shaders.mjs
* GM_PERF_COUNTS=... sprite counts, GM_PERF_SIZE=... edge length of one sprite
@@ -89,6 +90,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
await sleep(SAMPLE_MS);
const frames = await json('(window.__perfOn = false, window.__perfSamples.slice(1))');
const reported = await json('__gm.fps()');
const shaders = await json('__gm.shaderCount()');
const filters = await json('__gm.filterCount()');
const elapsed = frames.reduce((a, b) => a + b, 0);
return {
@@ -98,6 +100,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
p95: round(percentile(frames, 0.95)),
fps: round((frames.length * 1000) / elapsed, 1),
reported,
shaders,
filters,
};
};
@@ -114,15 +117,15 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
await stop();
}
check(
`${n} sprites: a filter per sprite with the shader on, none without`,
run.on.filters === n && run.off.filters === 0,
{ on: run.on.filters, off: run.off.filters },
`${n} sprites: a shader per sprite and no filter with the shader on, neither without`,
run.on.shaders === n && run.on.filters === 0 && run.off.shaders === 0 && run.off.filters === 0,
{ on: run.on, off: run.off },
);
rows.push({ n, ...run });
console.log(
` n=${String(n).padStart(4)} off ${String(run.off.mean).padStart(6)} ms (${run.off.fps} fps)` +
` on ${String(run.on.mean).padStart(6)} ms (${run.on.fps} fps)` +
` +${round(((run.on.mean - run.off.mean) * 1000) / n, 1)} us/pass load ${run.off.load} / ${run.on.load}`,
` +${round(((run.on.mean - run.off.mean) * 1000) / n, 1)} us/sprite load ${run.off.load} / ${run.on.load}`,
);
}
@@ -145,12 +148,12 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
`\nsprite ${SIZE}x${SIZE}, ${SAMPLE_MS} ms per point, neutral values, ` +
`60 Hz cap (cannot be lifted in headless)`,
);
console.log('| sprites | no shader | with shader | per pass | filters |');
console.log('| --- | --- | --- | --- | --- |');
console.log('| sprites | no shader | with shader | per sprite | shaders | filters |');
console.log('| --- | --- | --- | --- | --- | --- |');
for (const r of rows)
console.log(
`| ${r.n} | ${r.off.mean} ms / ${r.off.fps} fps | ${r.on.mean} ms / ${r.on.fps} fps |` +
` ${round(((r.on.mean - r.off.mean) * 1000) / r.n, 1)} us | ${r.on.filters} |`,
` ${round(((r.on.mean - r.off.mean) * 1000) / r.n, 1)} us | ${r.on.shaders} | ${r.on.filters} |`,
);
console.log(JSON.stringify(rows));
});
+1 -1
View File
@@ -238,7 +238,7 @@ const project = {
frames,
},
...walkAnimations,
// Present but worn by nothing: the smoke reads the actor's pixels, a filter pass must not touch them.
// Present but worn by nothing: the smoke reads the actor's pixels, a shader must not touch them.
adjustColorsShader(),
{
id: 'actor-sprite',