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
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:
1 parent
82effb2c03
commit
0e5e5ef925
15 files changed
+328
-88
No files matched your search
@@ -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};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user