Draw a shader in passes with a Pass node (#301)
A Blur stops at radius 8 in one pass, and bloom needs a softened copy of the picture beside the original. A Pass node cuts the graph: what feeds it is drawn into a picture of its own first, and the nodes that read a picture read that one when it feeds them. - Pass has color and alpha like the Output, a Resolution setting (Full, Half, Quarter) and a picture output. Texture color, Texture alpha, Sample texture, Blur and Texture size get a picture input. Unconnected it is the wearer's own picture, so the original stays readable beside any pass. - picture is a port type of its own. It plugs only into a picture input, not even into an any port. - The walk shared by the compilers can stop at a node (cut). The shader compiler draws each Pass it meets as a fragment of its own, earlier passes first. A ring through a picture says Feeds itself, and more than 8 passes say so at the node. - A pass draws untinted. The tint comes on when the last part reads a picture, so a particle source draws its passes once for all of its particles (user decision). - A pass's picture is the wearer's picture and its Padding, at the pixels of the picture times Resolution, at most 2048 per side. It is read with the picture's own filter and continues past its frame as the Output's Edge says. - PassPictures keeps one render texture per pass and wearer. It draws again only when picture, size, Padding or a uniform changed. A field's picture is a render texture that changes under the same id, so a field draws its passes every time. - Sprite, sliced sprite, text, particle source, field shader and the Shader tab's preview all draw passes. syncShader takes a fixed picture for a 9-slice, which holds its corners but keeps the margin. - texelAt and blur take the sampler they read. Existing shaders compile to the same program apart from that argument. - A field shader's Blur and Texture size count a pixel as a unit of the frame (uPixelScale), not of the screen. Before, a field's blur grew when zooming out and shrank when zooming in. This came with #170 and #300 and is fixed here because passes inherit it. - tools/perf-shaders.mjs and tools/perf-field-shaders.mjs measure two passes with GM_PERF_PASSES=1 and GM_PERF_PASS_RESOLUTION. Below Full an open Pass shrinks the picture first, as the wiki's recipe does. Found on the way: - Destroying the pictures before the shader made Pixi warn that a texture was still bound. The shader goes first now, and a picture of a new size is resized rather than replaced. - Two passes in a row could draw plain white the first time. Pixi builds the code that binds a program's resources once and finds them by position. A pass of one graph is often the same program as a pass of another with fewer slots, so it bound a placeholder. Every shader now declares all 8 pass slots and its uniform group. Tests: - Gate on melektaus-atx: 225 files, 3480 tests (3467 + 13), exit 0. - e2e 28 and 31 on melektaus-atx: 97 checks (94 + 3) and 44 checks (43 + 1), all passed, no Pixi warning in the console. - 28l-vii: a Blur across, a Pass and a Blur down draw the 5x5 spot of a Blur of Both (difference 0.000). On a grey sprite a pass's picture added to the original is 231/60/60, each tinted once. Two Passes in a row after a graph with one show red and green, not white. - 31v-iii: a Blur of 8 fades a field's frame edge over 8.0 units at 50 % and at 200 % zoom. - Counter-checks: a tinted pass fails 1 unit test. Pictures never bound fail both e2e checks (spot 0.20 instead of 0.04, 243/158/158). Pass slots by the number of passes draw #ffffff, and a field without its pixel scale ramps over 16 and 4 units: exactly the two new checks fail. - e2e sum 1716 is counted, not yet measured (1712 + 4). - Measured on stein2 (HD 630, one run per row): 50 field shaders at radius 8 go from 11.4 to 41.3 fps in two passes, 44.4 at radius 16 (three passes at Half). 800 sprites at radius 8 go from 13.9 to 24.5 fps. At radius 4 one pass stays faster from 400 sprites on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df61b08bd1
commit
f03ca0d382
23 files changed
+1111
-134
No files matched your search
@@ -1053,6 +1053,93 @@ export async function shader(t) {
|
||||
Boolean(dot) && lit === 9 && Math.abs(opacity - 1) < 0.1,
|
||||
{ dot, lit, opacity: opacity.toFixed(3), spot: spot.map((a) => a.toFixed(2)).join(' ') },
|
||||
);
|
||||
|
||||
// 28l-vii. A Pass draws first into a picture of its own (#301): blurred across, then that picture
|
||||
// blurred down, is the spot a Blur of Both draws in one go.
|
||||
const spotOf = async () => {
|
||||
const values = [];
|
||||
for (let j = 2; j <= 6; j++)
|
||||
for (let i = 2; i <= 6; i++)
|
||||
values.push((rgb(await texel(i, j, 16))[0] - ground[0]) / (255 - ground[0]));
|
||||
return values;
|
||||
};
|
||||
await blurred({ radius: 2 }, { edge: 'transparent' }, [[], [edge('blur', 'alpha', 'out', 'alpha')]]);
|
||||
const oneGo = await spotOf();
|
||||
await graph(
|
||||
[
|
||||
node('across', 'ShaderBlur', { radius: 2, direction: 'horizontal' }, 0, 0),
|
||||
node('pass', 'ShaderPass', {}, 260, 0),
|
||||
node('down', 'ShaderBlur', { radius: 2, direction: 'vertical' }, 520, 0),
|
||||
node('out', 'ShaderOutput', { edge: 'transparent' }, 780, 0),
|
||||
],
|
||||
[
|
||||
edge('across', 'color', 'pass', 'color'),
|
||||
edge('across', 'alpha', 'pass', 'alpha'),
|
||||
edge('pass', 'picture', 'down', 'picture'),
|
||||
edge('down', 'color', 'out', 'color'),
|
||||
edge('down', 'alpha', 'out', 'alpha'),
|
||||
],
|
||||
);
|
||||
const twoPasses = await spotOf();
|
||||
const apart = Math.max(...oneGo.map((a, k) => Math.abs(a - twoPasses[k])));
|
||||
check(
|
||||
'a Blur across, a Pass and a Blur down draw the spot a Blur of Both draws in one pass',
|
||||
oneGo.filter((a) => a > 0.01).length === 25 && apart < 0.02,
|
||||
{
|
||||
apart: apart.toFixed(3),
|
||||
oneGo: oneGo.map((a) => a.toFixed(2)).join(' '),
|
||||
twoPasses: twoPasses.map((a) => a.toFixed(2)).join(' '),
|
||||
},
|
||||
);
|
||||
// Bloom's shape: the passed picture added to the untouched original. The original stays readable
|
||||
// beside a pass, and the sprite's colour tints only the last part, not what the Pass drew.
|
||||
await assetProps({ texture: picture?.id ?? '', color: 0x808080 });
|
||||
await graph(
|
||||
[
|
||||
node('pass', 'ShaderPass', {}, 0, 0),
|
||||
node('passed', 'ShaderTextureColor', {}, 260, 0),
|
||||
node('own', 'ShaderTextureColor', {}, 260, 200),
|
||||
node('add', 'ShaderAdd', {}, 520, 0),
|
||||
node('out', 'ShaderOutput', {}, 780, 0),
|
||||
],
|
||||
[
|
||||
edge('pass', 'picture', 'passed', 'picture'),
|
||||
edge('passed', 'color', 'add', 'a'),
|
||||
edge('own', 'color', 'add', 'b'),
|
||||
edge('add', 'result', 'out', 'color'),
|
||||
],
|
||||
);
|
||||
const bloomed = rgb(await texel(4, 6));
|
||||
await mutate(`delete __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.color`);
|
||||
// Grey halves red twice, once in the pass and once in the original: 230 / 2 + 230 / 2.
|
||||
check(
|
||||
'a Pass’s picture and the original read side by side, each tinted once at the end',
|
||||
Math.abs(bloomed[0] - 230) < 8 && Math.abs(bloomed[1] - 60) < 8,
|
||||
{ bloomed },
|
||||
);
|
||||
// The open Pass above is the very program of the first of two passes here. Pixi binds a program's
|
||||
// pictures by position, so the second graph read a placeholder: white, until the page reloaded.
|
||||
await graph(
|
||||
[
|
||||
node('first', 'ShaderPass', {}, 0, 0),
|
||||
node('again', 'ShaderTextureColor', {}, 260, 0),
|
||||
node('second', 'ShaderPass', {}, 520, 0),
|
||||
node('passed', 'ShaderTextureColor', {}, 780, 0),
|
||||
node('out', 'ShaderOutput', {}, 1040, 0),
|
||||
],
|
||||
[
|
||||
edge('first', 'picture', 'again', 'picture'),
|
||||
edge('again', 'color', 'second', 'color'),
|
||||
edge('second', 'picture', 'passed', 'picture'),
|
||||
edge('passed', 'color', 'out', 'color'),
|
||||
],
|
||||
);
|
||||
const chained = [await texel(4, 6), await texel(27, 6)];
|
||||
check(
|
||||
'two Passes in a row show the picture, also where the first shares its program with an earlier graph',
|
||||
colourDistance(chained[0], LEFT) < 12 && colourDistance(chained[1], RIGHT) < 12,
|
||||
{ chained },
|
||||
);
|
||||
await assetProps({ texture: picture?.id ?? '' });
|
||||
await filterOf(picture?.id, 'linear');
|
||||
|
||||
|
||||
@@ -718,7 +718,65 @@ export async function particles(t) {
|
||||
JSON.stringify({ inFrame, pastFrame }),
|
||||
);
|
||||
|
||||
// 31v-iii. Taken off again, the source draws its particles itself and no picture is left.
|
||||
// 31v-iii. A pixel of the field's picture counts as a unit of the frame, not of the screen: a
|
||||
// Blur of 8 across a flat picture ramps the frame's edge down over the same 8 units at
|
||||
// 50 % and at 200 % (only the inner half: past the frame nothing is drawn). Counted in
|
||||
// screen pixels it would be 16 units, and 4. Unselected, so the frame's dashes and the
|
||||
// gizmo stay off the row.
|
||||
await select(null);
|
||||
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-pass', type: 'ShaderPass', x: 200, y: 0, params: {} },
|
||||
{ id: 'fd-blur', type: 'ShaderBlur', x: 400, y: 0, params: { radius: 8, direction: 'horizontal' } },
|
||||
{ id: 'fd-out', type: 'ShaderOutput', x: 600, y: 0, params: { edge: 'transparent' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: { node: 'fd-red', port: 'color' }, to: { node: 'fd-pass', port: 'color' } },
|
||||
{ from: { node: 'fd-one', port: 'value' }, to: { node: 'fd-pass', port: 'alpha' } },
|
||||
{ from: { node: 'fd-pass', port: 'picture' }, to: { node: 'fd-blur', port: 'picture' } },
|
||||
{ from: { node: 'fd-blur', port: 'color' }, to: { node: 'fd-out', port: 'color' } },
|
||||
{ from: { node: 'fd-blur', port: 'alpha' }, to: { node: 'fd-out', port: 'alpha' } },
|
||||
],
|
||||
});
|
||||
const zoomBy = async (steps) => {
|
||||
const spot = await json(`__gm.objectScreen(${JSON.stringify(SOURCE)})`);
|
||||
for (let i = 0; i < Math.abs(steps); i++) {
|
||||
await t.wheel(Math.round(spot.x), Math.round(spot.y), steps > 0 ? -240 : 240);
|
||||
await sleep(150);
|
||||
}
|
||||
await at(0.3);
|
||||
};
|
||||
const rampAtEdge = async () => {
|
||||
const spot = await json(`__gm.objectScreen(${JSON.stringify(SOURCE)})`);
|
||||
const scale = (await json(`__gm.objectRect(${JSON.stringify(SOURCE)})`)).w / 24;
|
||||
const from = Math.round(spot.x + 100 * scale);
|
||||
const row = await json(`__gm.pixelRow(${from}, ${Math.round(spot.y)}, ${Math.round(45 * scale)})`);
|
||||
const red = (hex) => parseInt(hex.slice(1, 3), 16);
|
||||
const full = red(row[0]);
|
||||
const ground = red(row[row.length - 1]);
|
||||
const shares = row.map((hex) => (red(hex) - ground) / (full - ground));
|
||||
const partial = shares.filter((s) => s > 0.05 && s < 0.95).length;
|
||||
return { scale, units: Number((partial / scale).toFixed(1)), full: row[0], ground: row[row.length - 1] };
|
||||
};
|
||||
await zoomBy(-1);
|
||||
const zoomedOut = await rampAtEdge();
|
||||
await zoomBy(2);
|
||||
const zoomedIn = await rampAtEdge();
|
||||
await shot('31-particles-field-blur');
|
||||
await zoomBy(-1);
|
||||
await selectSource();
|
||||
check(
|
||||
'a field’s Blur reaches as many frame units at 50 % as at 200 %',
|
||||
zoomedOut.scale < unit &&
|
||||
zoomedIn.scale > unit &&
|
||||
Math.abs(zoomedOut.units - 8) <= 2 &&
|
||||
Math.abs(zoomedIn.units - 8) <= 2,
|
||||
JSON.stringify({ zoomedOut, zoomedIn, unit }),
|
||||
);
|
||||
|
||||
// 31v-iv. Taken off again, the source draws its particles itself and no picture is left.
|
||||
await setField(null);
|
||||
const back = await fallen();
|
||||
check(
|
||||
|
||||
@@ -45,3 +45,46 @@ export function adjustColorsShader(id = 'adjust-colors', name = 'Adjust colors')
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A blur in two passes as a shader graph (#301): a Blur across into a Pass at `resolution`, its
|
||||
* picture blurred down into the Output. Below full resolution an open Pass shrinks the picture
|
||||
* first, so both Blurs read pixels of that size and reach `radius` times 2 (half) or 4 (quarter).
|
||||
* `clocked` ties the radius to Time (times 0), which leaves the pixels alone but draws the Passes
|
||||
* again every frame – a still one is drawn once.
|
||||
*/
|
||||
export function twoPassBlurGraph(radius, resolution = 'full', clocked = false) {
|
||||
const shrunk = resolution !== 'full';
|
||||
return {
|
||||
nodes: [
|
||||
...(shrunk ? [node('tp-shrink', 'ShaderPass', -260, 0, { resolution })] : []),
|
||||
...(clocked
|
||||
? [
|
||||
node('tp-time', 'ShaderTime', 0, 200),
|
||||
node('tp-still', 'ShaderMultiply', 260, 200, { b: 0 }),
|
||||
node('tp-radius', 'ShaderAdd', 520, 200, { b: radius }),
|
||||
]
|
||||
: []),
|
||||
node('tp-across', 'ShaderBlur', 0, 0, { radius, direction: 'horizontal' }),
|
||||
node('tp-pass', 'ShaderPass', 260, 0, { resolution }),
|
||||
node('tp-down', 'ShaderBlur', 520, 0, { radius, direction: 'vertical' }),
|
||||
node('tp-out', 'ShaderOutput', 780, 0),
|
||||
],
|
||||
edges: [
|
||||
...(clocked
|
||||
? [
|
||||
edge('tp-e1', 'tp-time', 'value', 'tp-still', 'a'),
|
||||
edge('tp-e2', 'tp-still', 'result', 'tp-radius', 'a'),
|
||||
edge('tp-e3', 'tp-radius', 'result', 'tp-across', 'radius'),
|
||||
edge('tp-e4', 'tp-radius', 'result', 'tp-down', 'radius'),
|
||||
]
|
||||
: []),
|
||||
...(shrunk ? [edge('tp-e0', 'tp-shrink', 'picture', 'tp-across', 'picture')] : []),
|
||||
edge('tp-e5', 'tp-across', 'color', 'tp-pass', 'color'),
|
||||
edge('tp-e6', 'tp-across', 'alpha', 'tp-pass', 'alpha'),
|
||||
edge('tp-e7', 'tp-pass', 'picture', 'tp-down', 'picture'),
|
||||
edge('tp-e8', 'tp-down', 'color', 'tp-out', 'color'),
|
||||
edge('tp-e9', 'tp-down', 'alpha', 'tp-out', 'alpha'),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,9 @@
|
||||
* 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
|
||||
* GM_PERF_PASSES=1 makes that Blur two (#301): across, a Pass, then down; a field's picture is
|
||||
* new every frame, so is the Pass. GM_PERF_PASS_RESOLUTION=half|quarter puts
|
||||
* an open Pass first, so the radius reaches twice or four times as far
|
||||
*
|
||||
* 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.
|
||||
@@ -19,25 +22,30 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runChecks } from './lib/harness.mjs';
|
||||
import { PROJECTS, cliArgs } from './lib/projects.mjs';
|
||||
import { twoPassBlurGraph } from './lib/shaders.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('perf-field-shaders.mjs', PROJECTS.fixture);
|
||||
|
||||
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 PASSES = process.env.GM_PERF_PASSES === '1';
|
||||
const PASS_RESOLUTION = process.env.GM_PERF_PASS_RESOLUTION ?? 'full';
|
||||
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: [] };
|
||||
BLUR > 0 && PASSES
|
||||
? twoPassBlurGraph(BLUR, PASS_RESOLUTION)
|
||||
: 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;
|
||||
|
||||
@@ -147,7 +155,7 @@ await runChecks({ projectId, baseUrl, port: 9348, shotPrefix: 'gm-perf-field' },
|
||||
await stop();
|
||||
|
||||
console.log(
|
||||
`\nframe ${FRAME}x${FRAME} units, ${BLUR > 0 ? `Blur radius ${BLUR}` : 'pass-through'} field shader, ${SAMPLE_MS} ms per point`,
|
||||
`\nframe ${FRAME}x${FRAME} units, ${BLUR > 0 ? `Blur radius ${BLUR}${PASSES ? `, in two passes at ${PASS_RESOLUTION}` : ''}` : 'pass-through'} field shader, ${SAMPLE_MS} ms per point`,
|
||||
);
|
||||
console.log('| sources | no field shader | field shader | picture |');
|
||||
console.log('| --- | --- | --- | --- |');
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
* 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
|
||||
* GM_PERF_PASSES=1 makes that Blur two (#301): across, a Pass, then down. Its radius is tied to
|
||||
* Time (times 0), so the Pass is drawn again every frame – the case that costs,
|
||||
* where a still one is drawn once. GM_PERF_PASS_RESOLUTION=half|quarter puts
|
||||
* an open Pass first, so the radius reaches twice or four times as far
|
||||
*
|
||||
* It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it.
|
||||
*
|
||||
@@ -26,6 +30,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runChecks } from './lib/harness.mjs';
|
||||
import { PROJECTS, cliArgs } from './lib/projects.mjs';
|
||||
import { twoPassBlurGraph } from './lib/shaders.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('perf-shaders.mjs', PROJECTS.fixture);
|
||||
|
||||
@@ -33,6 +38,8 @@ const COUNTS = (process.env.GM_PERF_COUNTS ?? '50,100,200,400,800').split(',').m
|
||||
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 PASSES = process.env.GM_PERF_PASSES === '1';
|
||||
const PASS_RESOLUTION = process.env.GM_PERF_PASS_RESOLUTION ?? 'full';
|
||||
const SHADER = BLUR > 0 ? 'perf-blur' : 'adjust-colors';
|
||||
const WARMUP_MS = 1500;
|
||||
const SAMPLE_MS = 4000;
|
||||
@@ -54,7 +61,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
|
||||
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: [
|
||||
graph: ${PASSES} ? ${JSON.stringify(twoPassBlurGraph(BLUR, PASS_RESOLUTION, true))} : { nodes: [
|
||||
{ id: 'pb-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: ${BLUR} } },
|
||||
{ id: 'pb-out', type: 'ShaderOutput', x: 300, y: 0, params: {} },
|
||||
], edges: [
|
||||
@@ -168,7 +175,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
|
||||
|
||||
console.log(
|
||||
`\nsprite ${SIZE}x${SIZE}, ${SUBDIVISIONS > 0 ? `grid of ${SUBDIVISIONS}` : 'plain quad'}, ` +
|
||||
`${BLUR > 0 ? `Blur radius ${BLUR}` : 'Adjust colors, neutral values'}, ${SAMPLE_MS} ms per point, ` +
|
||||
`${BLUR > 0 ? `Blur radius ${BLUR}${PASSES ? `, in two passes at ${PASS_RESOLUTION}` : ''}` : '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 |');
|
||||
|
||||
Reference in new issue
Block a user