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
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1707 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1712 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema';
|
|||||||
import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders';
|
import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders';
|
||||||
import {
|
import {
|
||||||
asVec3,
|
asVec3,
|
||||||
|
BLUR_FN,
|
||||||
DIVIDE_FN,
|
DIVIDE_FN,
|
||||||
type EdgeMode,
|
type EdgeMode,
|
||||||
edgeOf,
|
edgeOf,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
floatLiteral,
|
floatLiteral,
|
||||||
isShaderNodeDef,
|
isShaderNodeDef,
|
||||||
paddingOf,
|
paddingOf,
|
||||||
|
SHADER_BLUR,
|
||||||
SHADER_OUTPUT,
|
SHADER_OUTPUT,
|
||||||
SHADER_SAMPLE,
|
SHADER_SAMPLE,
|
||||||
SHADER_TIME,
|
SHADER_TIME,
|
||||||
@@ -150,6 +152,7 @@ export function compileShader(graph: Graph): ShaderBuild {
|
|||||||
|
|
||||||
const time = graph.nodes.some((n) => n.type === SHADER_TIME && (walk.reached(n) || cornerWalk.reached(n)));
|
const time = graph.nodes.some((n) => n.type === SHADER_TIME && (walk.reached(n) || cornerWalk.reached(n)));
|
||||||
const fragmentTime = graph.nodes.some((n) => n.type === SHADER_TIME && walk.reached(n));
|
const fragmentTime = graph.nodes.some((n) => n.type === SHADER_TIME && walk.reached(n));
|
||||||
|
const blurs = graph.nodes.some((n) => n.type === SHADER_BLUR && walk.reached(n));
|
||||||
// The fragment runs in the sprite's own draw (#77). `vColor` is the tint, premultiplied like the
|
// The fragment runs in the sprite's own draw (#77). `vColor` is the tint, premultiplied like the
|
||||||
// texel; it goes in before the graph, so `Texture color` is the sprite as it draws without a shader.
|
// texel; it goes in before the graph, so `Texture color` is the sprite as it draws without a shader.
|
||||||
// The version line is what makes Pixi compile the program as GLSL ES 3.00 (`GlProgram` looks for
|
// The version line is what makes Pixi compile the program as GLSL ES 3.00 (`GlProgram` looks for
|
||||||
@@ -165,6 +168,7 @@ export function compileShader(graph: Graph): ShaderBuild {
|
|||||||
STRAIGHT_FN,
|
STRAIGHT_FN,
|
||||||
DIVIDE_FN,
|
DIVIDE_FN,
|
||||||
texelAtFn(mode),
|
texelAtFn(mode),
|
||||||
|
...(blurs ? [BLUR_FN] : []),
|
||||||
'void main()',
|
'void main()',
|
||||||
'{',
|
'{',
|
||||||
` vec4 texel = ${TEXEL_AT}(vTextureCoord) * vColor;`,
|
` vec4 texel = ${TEXEL_AT}(vTextureCoord) * vColor;`,
|
||||||
@@ -178,13 +182,15 @@ export function compileShader(graph: Graph): ShaderBuild {
|
|||||||
// The edge only shows where a Sample reads somewhere other than its own pixel. A graph that never
|
// The edge only shows where a Sample reads somewhere other than its own pixel. A graph that never
|
||||||
// shifts a UV stays on the plain sheet, so the common shader costs no second copy of the picture.
|
// shifts a UV stays on the plain sheet, so the common shader costs no second copy of the picture.
|
||||||
// With padding every pixel of the margin reads past the border; Transparent is cut in the
|
// With padding every pixel of the margin reads past the border; Transparent is cut in the
|
||||||
// fragment and takes the plain sheet as well.
|
// fragment and takes the plain sheet as well. A Blur reads its neighbours, so it always shifts.
|
||||||
const shifted = graph.nodes.some(
|
const shifted =
|
||||||
(n) =>
|
blurs ||
|
||||||
n.type === SHADER_SAMPLE &&
|
graph.nodes.some(
|
||||||
walk.reached(n) &&
|
(n) =>
|
||||||
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
|
n.type === SHADER_SAMPLE &&
|
||||||
);
|
walk.reached(n) &&
|
||||||
|
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
|
||||||
|
);
|
||||||
const edge = (shifted || padding > 0) && mode !== 'transparent' ? mode : SHEET_EDGE;
|
const edge = (shifted || padding > 0) && mode !== 'transparent' ? mode : SHEET_EDGE;
|
||||||
return { compiled: { fragment, vertex, uniforms, time, edge, padding }, problems, hints, reason: '' };
|
return { compiled: { fragment, vertex, uniforms, time, edge, padding }, problems, hints, reason: '' };
|
||||||
}
|
}
|
||||||
@@ -205,8 +211,13 @@ function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[
|
|||||||
noun: 'shader',
|
noun: 'shader',
|
||||||
emit: (def, inputs, node) => {
|
emit: (def, inputs, node) => {
|
||||||
if (def.perPixel) perPixel?.push(def.label);
|
if (def.perPixel) perPixel?.push(def.label);
|
||||||
|
let prepared: string | undefined;
|
||||||
|
if (def.prepare) {
|
||||||
|
prepared = `${prefix}${count++}_prepared`;
|
||||||
|
lines.push(`vec4 ${prepared} = ${def.prepare(inputs, node.params)};`);
|
||||||
|
}
|
||||||
const outputs: Record<string, Expr> = {};
|
const outputs: Record<string, Expr> = {};
|
||||||
for (const [key, expr] of Object.entries(def.emit(inputs, node.params))) {
|
for (const [key, expr] of Object.entries(def.emit(inputs, node.params, prepared))) {
|
||||||
const local = `${prefix}${count++}_${key}`;
|
const local = `${prefix}${count++}_${key}`;
|
||||||
lines.push(`${expr.type} ${local} = ${expr.code};`);
|
lines.push(`${expr.type} ${local} = ${expr.code};`);
|
||||||
outputs[key] = { code: local, type: expr.type };
|
outputs[key] = { code: local, type: expr.type };
|
||||||
|
|||||||
+107
-1
@@ -27,11 +27,13 @@ export interface Expr {
|
|||||||
*/
|
*/
|
||||||
export interface ShaderNodeDef extends NodeDef {
|
export interface ShaderNodeDef extends NodeDef {
|
||||||
category: 'shader';
|
category: 'shader';
|
||||||
emit(inputs: Record<string, Expr>, params: PropertyBag): Record<string, Expr>;
|
emit(inputs: Record<string, Expr>, params: PropertyBag, prepared?: string): Record<string, Expr>;
|
||||||
/** What an input with nothing to type on reads while unconnected; without it, the texel. */
|
/** What an input with nothing to type on reads while unconnected; without it, the texel. */
|
||||||
unfed?: Record<string, Expr>;
|
unfed?: Record<string, Expr>;
|
||||||
/** Reads the picture or is a colour: there is none per corner, so it cannot feed the Vertex output (#79). */
|
/** Reads the picture or is a colour: there is none per corner, so it cannot feed the Vertex output (#79). */
|
||||||
perPixel?: boolean;
|
perPixel?: boolean;
|
||||||
|
/** A vec4 all outputs read, worked out once into a local `emit` gets as `prepared` (#300). */
|
||||||
|
prepare?: (inputs: Record<string, Expr>, params: PropertyBag) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef {
|
export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef {
|
||||||
@@ -48,6 +50,7 @@ export const SHADER_UV = 'ShaderUV';
|
|||||||
export const SHADER_SAMPLE = 'ShaderSample';
|
export const SHADER_SAMPLE = 'ShaderSample';
|
||||||
export const SHADER_TEXTURE_SIZE = 'ShaderTextureSize';
|
export const SHADER_TEXTURE_SIZE = 'ShaderTextureSize';
|
||||||
export const SHADER_TIME = 'ShaderTime';
|
export const SHADER_TIME = 'ShaderTime';
|
||||||
|
export const SHADER_BLUR = 'ShaderBlur';
|
||||||
|
|
||||||
/** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */
|
/** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */
|
||||||
export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' };
|
export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' };
|
||||||
@@ -305,6 +308,108 @@ export const ShaderSample: ShaderNodeDef = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MAX_BLUR_RADIUS = 8;
|
||||||
|
const BLUR = 'blur';
|
||||||
|
const BLUR_KIND_PARAM = 'kind';
|
||||||
|
const BLUR_DIRECTION_PARAM = 'direction';
|
||||||
|
const BLUR_AXES: Record<string, string> = {
|
||||||
|
both: 'vec2(1.0, 1.0)',
|
||||||
|
horizontal: 'vec2(1.0, 0.0)',
|
||||||
|
vertical: 'vec2(0.0, 1.0)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prelude's blur: the premultiplied samples of a square (or a line) around `uv`, weighted and
|
||||||
|
* averaged, so a transparent neighbour adds no colour and no dark seam. The outermost ring counts
|
||||||
|
* by the fraction of the radius that reaches it, so a radius can change smoothly. Gaussian weights
|
||||||
|
* fall off with sigma = radius / 2.
|
||||||
|
*/
|
||||||
|
export const BLUR_FN = [
|
||||||
|
'float blurWeight(int k, float r, float gaussian) {',
|
||||||
|
' float d = abs(float(k));',
|
||||||
|
' float w = clamp(r + 1.0 - d, 0.0, 1.0);',
|
||||||
|
' float s = max(r, 0.0001) * 0.5;',
|
||||||
|
' return gaussian > 0.5 ? w * exp(-d * d / (2.0 * s * s)) : w;',
|
||||||
|
'}',
|
||||||
|
`vec4 ${BLUR}(vec2 uv, float radius, vec2 axis, float gaussian) {`,
|
||||||
|
` float r = clamp(radius, 0.0, ${floatLiteral(MAX_BLUR_RADIUS)});`,
|
||||||
|
' ivec2 reach = ivec2(axis) * int(ceil(r));',
|
||||||
|
' vec2 texel = 1.0 / vec2(textureSize(uTexture, 0));',
|
||||||
|
' vec4 sum = vec4(0.0);',
|
||||||
|
' float total = 0.0;',
|
||||||
|
' for (int j = -reach.y; j <= reach.y; j++) {',
|
||||||
|
' for (int i = -reach.x; i <= reach.x; i++) {',
|
||||||
|
' float w = blurWeight(i, r, gaussian) * blurWeight(j, r, gaussian);',
|
||||||
|
` sum += ${TEXEL_AT}(uv + vec2(float(i), float(j)) * texel) * w;`,
|
||||||
|
' total += w;',
|
||||||
|
' }',
|
||||||
|
' }',
|
||||||
|
' return sum / total;',
|
||||||
|
'}',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The picture softened around a spot in one pass (#300). The loop is in the prelude's `blur`, so a
|
||||||
|
* radius from a Property changes without a new program.
|
||||||
|
*/
|
||||||
|
export const ShaderBlur: ShaderNodeDef = {
|
||||||
|
type: SHADER_BLUR,
|
||||||
|
label: 'Blur',
|
||||||
|
category: 'shader',
|
||||||
|
description: `The sprite's picture softened around a spot, tinted like Texture color. radius is in pixels of the picture, at most ${MAX_BLUR_RADIUS}, and may be a fraction. Box weighs every pixel alike, Gaussian the middle more. Past the border the picture continues as the Output's Edge says.`,
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
...floatIn('radius', 2),
|
||||||
|
field: {
|
||||||
|
key: 'radius',
|
||||||
|
label: 'radius',
|
||||||
|
kind: 'number',
|
||||||
|
default: 2,
|
||||||
|
min: 0,
|
||||||
|
max: MAX_BLUR_RADIUS,
|
||||||
|
step: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'u', label: 'u', type: 'number', inline: false },
|
||||||
|
{ key: 'v', label: 'v', type: 'number', inline: false },
|
||||||
|
],
|
||||||
|
outputs: [vec3Port('color'), numberOut('alpha')],
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: BLUR_KIND_PARAM,
|
||||||
|
label: 'Kind',
|
||||||
|
kind: 'enum',
|
||||||
|
default: 'box',
|
||||||
|
options: [
|
||||||
|
{ value: 'box', label: 'Box' },
|
||||||
|
{ value: 'gaussian', label: 'Gaussian' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: BLUR_DIRECTION_PARAM,
|
||||||
|
label: 'Direction',
|
||||||
|
kind: 'enum',
|
||||||
|
default: 'both',
|
||||||
|
options: [
|
||||||
|
{ value: 'both', label: 'Both' },
|
||||||
|
{ value: 'horizontal', label: 'Horizontal' },
|
||||||
|
{ value: 'vertical', label: 'Vertical' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
perPixel: true,
|
||||||
|
unfed: { u: OWN_U, v: OWN_V },
|
||||||
|
prepare: ({ radius, u, v }, params) => {
|
||||||
|
const axis = BLUR_AXES[textOf(params[BLUR_DIRECTION_PARAM] ?? '')] ?? BLUR_AXES.both!;
|
||||||
|
const gaussian = textOf(params[BLUR_KIND_PARAM] ?? '') === 'gaussian' ? '1.0' : '0.0';
|
||||||
|
return `${BLUR}(vec2(${u!.code}, ${v!.code}), ${radius!.code}, ${axis}, ${gaussian}) * vColor`;
|
||||||
|
},
|
||||||
|
emit: (_inputs, _params, prepared) => ({
|
||||||
|
color: { code: `straight(${prepared})`, type: 'vec3' },
|
||||||
|
alpha: { code: `${prepared}.a`, type: 'float' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export const ShaderTextureSize: ShaderNodeDef = {
|
export const ShaderTextureSize: ShaderNodeDef = {
|
||||||
type: SHADER_TEXTURE_SIZE,
|
type: SHADER_TEXTURE_SIZE,
|
||||||
label: 'Texture size',
|
label: 'Texture size',
|
||||||
@@ -570,6 +675,7 @@ export const SHADER_NODES: readonly ShaderNodeDef[] = [
|
|||||||
ShaderTextureAlpha,
|
ShaderTextureAlpha,
|
||||||
ShaderUV,
|
ShaderUV,
|
||||||
ShaderSample,
|
ShaderSample,
|
||||||
|
ShaderBlur,
|
||||||
ShaderTextureSize,
|
ShaderTextureSize,
|
||||||
ShaderTime,
|
ShaderTime,
|
||||||
ShaderOutput,
|
ShaderOutput,
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* A shader's `Blur` node (#300): the picture softened around a spot in one pass, the loop written
|
||||||
|
* by the compiler into the prelude.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
|
||||||
|
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||||
|
import { findAssetOf } from '../../src/core/model/Project';
|
||||||
|
import { SHADER_PROPERTY } from '../../src/core/properties/shaders';
|
||||||
|
import { compileShader, perCornerProblem } from '../../src/core/shader/compile';
|
||||||
|
import {
|
||||||
|
BLUR_FN,
|
||||||
|
SHADER_BLUR,
|
||||||
|
SHADER_OUTPUT,
|
||||||
|
SHADER_VERTEX_OUTPUT,
|
||||||
|
SHEET_EDGE,
|
||||||
|
} from '../../src/core/shader/nodes';
|
||||||
|
import { makeSpriteScene } from '../helpers';
|
||||||
|
|
||||||
|
function shader(outParams = {}) {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const create = new CreateAssetCommand('shader', 'Soften');
|
||||||
|
f.stack.execute(create);
|
||||||
|
const id = create.asset.id;
|
||||||
|
const asset = () => findAssetOf(f.project, id, 'shader')!;
|
||||||
|
const add = (type: string, params = {}) => {
|
||||||
|
const command = new AddNodeCommand(id, type, 0, 0, params);
|
||||||
|
f.stack.execute(command);
|
||||||
|
return command.node.id;
|
||||||
|
};
|
||||||
|
const link = (from: string, fromPort: string, to: string, toPort: string) =>
|
||||||
|
f.stack.execute(new ConnectCommand(id, { node: from, port: fromPort }, { node: to, port: toPort }));
|
||||||
|
const out = add(SHADER_OUTPUT, outParams);
|
||||||
|
return { add, link, out, build: () => compileShader(asset().graph) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Blur feeding both outputs of the Output node. */
|
||||||
|
function blurred(params = {}, outParams = {}) {
|
||||||
|
const s = shader(outParams);
|
||||||
|
const blur = s.add(SHADER_BLUR, params);
|
||||||
|
s.link(blur, 'color', s.out, 'color');
|
||||||
|
s.link(blur, 'alpha', s.out, 'alpha');
|
||||||
|
return { ...s, blur };
|
||||||
|
}
|
||||||
|
|
||||||
|
const calls = (fragment: string) => fragment.split('void main()')[1]!.match(/blur\(/g)?.length ?? 0;
|
||||||
|
|
||||||
|
describe('Blur (#300)', () => {
|
||||||
|
it('samples once per pixel, premultiplied, and both outputs read that one sum', () => {
|
||||||
|
const fragment = blurred().build().compiled!.fragment;
|
||||||
|
expect(fragment).toContain(BLUR_FN);
|
||||||
|
expect(fragment).toContain(
|
||||||
|
'vec4 n0_prepared = blur(vec2(vTextureCoord.x, vTextureCoord.y), 2.0, vec2(1.0, 1.0), 0.0) * vColor;',
|
||||||
|
);
|
||||||
|
expect(fragment).toContain('vec3 n1_color = straight(n0_prepared);');
|
||||||
|
expect(fragment).toContain('float n2_alpha = n0_prepared.a;');
|
||||||
|
expect(calls(fragment)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns Kind and Direction into the arguments of the one helper', () => {
|
||||||
|
const call = (params: object) =>
|
||||||
|
blurred(params)
|
||||||
|
.build()
|
||||||
|
.compiled!.fragment.match(/= blur\(.*;/)![0];
|
||||||
|
expect(call({ kind: 'gaussian' })).toContain('vec2(1.0, 1.0), 1.0)');
|
||||||
|
expect(call({ direction: 'horizontal' })).toContain('vec2(1.0, 0.0), 0.0)');
|
||||||
|
expect(call({ direction: 'vertical' })).toContain('vec2(0.0, 1.0), 0.0)');
|
||||||
|
expect(call({ kind: 'nonsense', direction: 'nonsense' })).toContain('vec2(1.0, 1.0), 0.0)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds the radius at 0 to 8 in the program, and takes it from a Property as a uniform', () => {
|
||||||
|
expect(BLUR_FN).toContain('float r = clamp(radius, 0.0, 8.0);');
|
||||||
|
const s = blurred({ radius: 3.5 });
|
||||||
|
expect(s.build().compiled!.fragment).toContain('blur(vec2(vTextureCoord.x, vTextureCoord.y), 3.5,');
|
||||||
|
const soft = s.add(SHADER_PROPERTY, { name: 'soft', default: 2, min: 0, max: 8 });
|
||||||
|
s.link(soft, 'value', s.blur, 'radius');
|
||||||
|
const compiled = s.build().compiled!;
|
||||||
|
expect(compiled.uniforms).toEqual([{ name: 'soft', kind: 'number', default: 2 }]);
|
||||||
|
expect(compiled.fragment).toMatch(/= blur\(vec2\(vTextureCoord\.x, vTextureCoord\.y\), n\d+_value,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads at the UV it is fed', () => {
|
||||||
|
const s = blurred();
|
||||||
|
const uv = s.add('ShaderUV');
|
||||||
|
const flip = s.add('ShaderSubtract', { a: 1 });
|
||||||
|
s.link(uv, 'u', flip, 'b');
|
||||||
|
s.link(flip, 'result', s.blur, 'u');
|
||||||
|
expect(s.build().compiled!.fragment).toMatch(/= blur\(vec2\(n\d+_result, vTextureCoord\.y\), 2\.0,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the helper out of a shader that does not reach it', () => {
|
||||||
|
const s = shader();
|
||||||
|
s.add(SHADER_BLUR);
|
||||||
|
const fragment = s.build().compiled!.fragment;
|
||||||
|
expect(fragment).not.toContain('blurWeight');
|
||||||
|
expect(calls(fragment)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads past the border, so the picture comes with the Output’s Edge', () => {
|
||||||
|
expect(blurred().build().compiled!.edge).toBe('clamp');
|
||||||
|
expect(blurred({}, { edge: 'repeat' }).build().compiled!.edge).toBe('repeat');
|
||||||
|
expect(blurred({}, { edge: 'transparent' }).build().compiled!.edge).toBe(SHEET_EDGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the picture, so it cannot feed the Vertex output', () => {
|
||||||
|
const s = shader();
|
||||||
|
const corner = s.add(SHADER_VERTEX_OUTPUT);
|
||||||
|
const blur = s.add(SHADER_BLUR);
|
||||||
|
s.link(blur, 'alpha', corner, 'x');
|
||||||
|
const build = s.build();
|
||||||
|
expect(build.compiled).toBeNull();
|
||||||
|
expect(build.problems[corner]).toBe(perCornerProblem('Blur'));
|
||||||
|
});
|
||||||
|
});
|
||||||
+109
-1
@@ -1,5 +1,12 @@
|
|||||||
import { colourDistance } from '../lib/harness.mjs';
|
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
|
* 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`);
|
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
|
// 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).
|
// 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");
|
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_LEFT = '#e63c3c';
|
||||||
export const HALVES_RIGHT = '#3cc85a';
|
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
|
* 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"
|
* file. Size alone is not enough – in the full run a module before 47 leaves a 32 px "wedge"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
*
|
*
|
||||||
* Usage: node tools/seed-e2e.mjs && node tools/perf-field-shaders.mjs
|
* 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_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
|
||||||
*
|
*
|
||||||
* It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it.
|
* 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.
|
* Read the rate, not the median – see `perf-shaders.mjs` for why, and for the 60 Hz cap.
|
||||||
@@ -23,6 +24,20 @@ const { projectId, baseUrl } = cliArgs('perf-field-shaders.mjs', PROJECTS.fixtur
|
|||||||
|
|
||||||
const COUNTS = (process.env.GM_PERF_COUNTS ?? '1,10,50').split(',').map(Number);
|
const COUNTS = (process.env.GM_PERF_COUNTS ?? '1,10,50').split(',').map(Number);
|
||||||
const FRAME = Number(process.env.GM_PERF_FRAME ?? 256);
|
const FRAME = Number(process.env.GM_PERF_FRAME ?? 256);
|
||||||
|
const BLUR = Number(process.env.GM_PERF_BLUR ?? 0);
|
||||||
|
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: [] };
|
||||||
const WARMUP_MS = 1500;
|
const WARMUP_MS = 1500;
|
||||||
const SAMPLE_MS = 4000;
|
const SAMPLE_MS = 4000;
|
||||||
|
|
||||||
@@ -40,7 +55,7 @@ await runChecks({ projectId, baseUrl, port: 9348, shotPrefix: 'gm-perf-field' },
|
|||||||
const p = __gm.state.project;
|
const p = __gm.state.project;
|
||||||
p.assets = p.assets.filter((a) => a.id !== 'perf-source' && a.id !== 'perf-field');
|
p.assets = p.assets.filter((a) => a.id !== 'perf-source' && a.id !== 'perf-field');
|
||||||
p.assets.push({ id: 'perf-field', type: 'shader', name: 'Perf field', props: {},
|
p.assets.push({ id: 'perf-field', type: 'shader', name: 'Perf field', props: {},
|
||||||
graph: { nodes: [{ id: 'pf-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] } });
|
graph: ${JSON.stringify(FIELD_GRAPH)} });
|
||||||
p.assets.push({ id: 'perf-source', type: 'particles', name: 'Perf source',
|
p.assets.push({ id: 'perf-source', type: 'particles', name: 'Perf source',
|
||||||
props: { rate: 50, lifetime: 1, size: 24, color: 0x4080ff, fieldShader: '',
|
props: { rate: 50, lifetime: 1, size: 24, color: 0x4080ff, fieldShader: '',
|
||||||
fieldWidth: ${FRAME}, fieldHeight: ${FRAME} },
|
fieldWidth: ${FRAME}, fieldHeight: ${FRAME} },
|
||||||
@@ -131,7 +146,9 @@ await runChecks({ projectId, baseUrl, port: 9348, shotPrefix: 'gm-perf-field' },
|
|||||||
await shot('heaviest');
|
await shot('heaviest');
|
||||||
await stop();
|
await stop();
|
||||||
|
|
||||||
console.log(`\nframe ${FRAME}x${FRAME} units, pass-through field shader, ${SAMPLE_MS} ms per point`);
|
console.log(
|
||||||
|
`\nframe ${FRAME}x${FRAME} units, ${BLUR > 0 ? `Blur radius ${BLUR}` : 'pass-through'} field shader, ${SAMPLE_MS} ms per point`,
|
||||||
|
);
|
||||||
console.log('| sources | no field shader | field shader | picture |');
|
console.log('| sources | no field shader | field shader | picture |');
|
||||||
console.log('| --- | --- | --- | --- |');
|
console.log('| --- | --- | --- | --- |');
|
||||||
for (const r of rows)
|
for (const r of rows)
|
||||||
|
|||||||
+14
-3
@@ -12,6 +12,7 @@
|
|||||||
* GM_PERF_COUNTS=... sprite counts, GM_PERF_SIZE=... edge length of one sprite
|
* GM_PERF_COUNTS=... sprite counts, GM_PERF_SIZE=... edge length of one sprite
|
||||||
* GM_PERF_SUBDIVISIONS=... gives the shader a Vertex output with that grid (#79); nothing feeds
|
* 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
|
* 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
|
||||||
*
|
*
|
||||||
* It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it.
|
* It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it.
|
||||||
*
|
*
|
||||||
@@ -31,6 +32,8 @@ const { projectId, baseUrl } = cliArgs('perf-shaders.mjs', PROJECTS.fixture);
|
|||||||
const COUNTS = (process.env.GM_PERF_COUNTS ?? '50,100,200,400,800').split(',').map(Number);
|
const COUNTS = (process.env.GM_PERF_COUNTS ?? '50,100,200,400,800').split(',').map(Number);
|
||||||
const SIZE = Number(process.env.GM_PERF_SIZE ?? 48);
|
const SIZE = Number(process.env.GM_PERF_SIZE ?? 48);
|
||||||
const SUBDIVISIONS = Number(process.env.GM_PERF_SUBDIVISIONS ?? 0);
|
const SUBDIVISIONS = Number(process.env.GM_PERF_SUBDIVISIONS ?? 0);
|
||||||
|
const BLUR = Number(process.env.GM_PERF_BLUR ?? 0);
|
||||||
|
const SHADER = BLUR > 0 ? 'perf-blur' : 'adjust-colors';
|
||||||
const WARMUP_MS = 1500;
|
const WARMUP_MS = 1500;
|
||||||
const SAMPLE_MS = 4000;
|
const SAMPLE_MS = 4000;
|
||||||
|
|
||||||
@@ -47,9 +50,17 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
|
|||||||
// One sprite asset for all of them: flipping its `shader` prop switches every instance at once.
|
// One sprite asset for all of them: flipping its `shader` prop switches every instance at once.
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const p = __gm.state.project;
|
const p = __gm.state.project;
|
||||||
p.assets = p.assets.filter((a) => a.id !== 'perf-sprite');
|
p.assets = p.assets.filter((a) => a.id !== 'perf-sprite' && a.id !== 'perf-blur');
|
||||||
p.assets.push({ id: 'perf-sprite', type: 'sprite', name: 'Perf sprite',
|
p.assets.push({ id: 'perf-sprite', type: 'sprite', name: 'Perf sprite',
|
||||||
props: { width: ${SIZE}, height: ${SIZE}, color: 0x4080ff, shader: '' } });
|
props: { width: ${SIZE}, height: ${SIZE}, color: 0x4080ff, shader: '' } });
|
||||||
|
p.assets.push({ id: 'perf-blur', type: 'shader', name: 'Perf blur', props: {},
|
||||||
|
graph: { nodes: [
|
||||||
|
{ id: 'pb-blur', type: 'ShaderBlur', x: 0, y: 0, params: { radius: ${BLUR} } },
|
||||||
|
{ id: 'pb-out', type: 'ShaderOutput', x: 300, y: 0, params: {} },
|
||||||
|
], edges: [
|
||||||
|
{ id: 'pb-c', from: { node: 'pb-blur', port: 'color' }, to: { node: 'pb-out', port: 'color' } },
|
||||||
|
{ id: 'pb-a', from: { node: 'pb-blur', port: 'alpha' }, to: { node: 'pb-out', port: 'alpha' } },
|
||||||
|
] } });
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await mutate(`
|
await mutate(`
|
||||||
@@ -80,7 +91,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
|
|||||||
|
|
||||||
const setShader = (on) =>
|
const setShader = (on) =>
|
||||||
mutate(
|
mutate(
|
||||||
`__gm.state.project.assets.find((a) => a.id === 'perf-sprite').props.shader = ${on ? "'adjust-colors'" : "''"}`,
|
`__gm.state.project.assets.find((a) => a.id === 'perf-sprite').props.shader = ${on ? `'${SHADER}'` : "''"}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Frame times as the page itself sees them, over `SAMPLE_MS` after a warm-up. */
|
/** Frame times as the page itself sees them, over `SAMPLE_MS` after a warm-up. */
|
||||||
@@ -157,7 +168,7 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async
|
|||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`\nsprite ${SIZE}x${SIZE}, ${SUBDIVISIONS > 0 ? `grid of ${SUBDIVISIONS}` : 'plain quad'}, ` +
|
`\nsprite ${SIZE}x${SIZE}, ${SUBDIVISIONS > 0 ? `grid of ${SUBDIVISIONS}` : 'plain quad'}, ` +
|
||||||
`${SAMPLE_MS} ms per point, neutral values, ` +
|
`${BLUR > 0 ? `Blur radius ${BLUR}` : 'Adjust colors, neutral values'}, ${SAMPLE_MS} ms per point, ` +
|
||||||
`60 Hz cap (cannot be lifted in headless)`,
|
`60 Hz cap (cannot be lifted in headless)`,
|
||||||
);
|
);
|
||||||
console.log('| sprites | no shader | with shader | per sprite | shaders | filters |');
|
console.log('| sprites | no shader | with shader | per sprite | shaders | filters |');
|
||||||
|
|||||||
Reference in new issue
Block a user