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
+326 -86

No files matched your search

+1 -1
View File
@@ -37,7 +37,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:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 728 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 730 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+4 -1
View File
@@ -130,14 +130,17 @@ export function compileShader(graph: Graph): ShaderBuild {
return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' };
const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default }));
// 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.
const fragment = [
'in vec2 vTextureCoord;',
'in vec4 vColor;',
'out vec4 finalColor;',
'uniform sampler2D uTexture;',
...uniforms.map((u) => `uniform float ${uniformName(u.name)};`),
'void main()',
'{',
' vec4 texel = texture(uTexture, vTextureCoord);',
' vec4 texel = texture(uTexture, vTextureCoord) * vColor;',
` vec3 ${TEXEL_COLOR.code} = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;`,
...lines.map((line) => ` ${line}`),
` float alpha = clamp(${alpha.code}, 0.0, 1.0);`,
+1 -2
View File
@@ -21,7 +21,6 @@ import {
Widget,
zoomFactor,
} from '../../ui';
import { spriteFill } from '../../viewport/symbols';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
import { drawChecker } from '../texture/checker';
@@ -423,7 +422,7 @@ export class AnimationCanvas extends Widget {
}
drawChecker(g, rect.x, rect.y, rect.w, rect.h, this.w, this.h);
const texture = textureLibrary.get(picture);
if (texture) g.rect(rect.x, rect.y, rect.w, rect.h).fill(spriteFill(texture, 0xffffff));
if (texture) g.rect(rect.x, rect.y, rect.w, rect.h).fill({ texture });
return rect;
}
+48 -8
View File
@@ -36,30 +36,63 @@ function formatValue(value: unknown): string {
return String(value);
}
/** One line out of the arguments of a `console.*` call, the way the browser joins them. */
const HAS_DIRECTIVE = /%[sdifoOc%]/;
const DIRECTIVE = /%([sdifoOc%])/g;
/**
* One line out of the arguments of a `console.*` call, the way the browser joins them: a leading
* format string consumes one argument per directive, `%c` swallows its style, the rest follow.
*/
export function formatArgs(args: readonly unknown[]): string {
return args.map(formatValue).join(' ');
const [first, ...rest] = args;
if (typeof first !== 'string' || !HAS_DIRECTIVE.test(first)) return args.map(formatValue).join(' ');
const text = first.replace(DIRECTIVE, (match, spec: string) => {
if (spec === '%') return '%';
if (rest.length === 0) return match;
const value = rest.shift();
if (spec === 'c') return '';
if (spec === 'd' || spec === 'i') return String(Math.trunc(Number(value)));
if (spec === 'f') return String(Number(value));
return formatValue(value);
});
return [text, ...rest.map(formatValue)].join(' ');
}
export class ConsoleLog {
private buffer: LogLine[] = [];
private readonly listeners = new Set<() => void>();
private depth = 0;
constructor(private readonly now: () => number = Date.now) {}
/** Appends `text`; an embedded newline starts a new line, so a stack stays readable. */
/**
* Appends `text`; an embedded newline starts a new line, so a stack stays readable. Lines inside
* a group are indented under its head.
*/
push(level: LogLevel, text: string): void {
const time = this.now();
for (const part of text.split('\n')) this.buffer.push({ level, text: part, time });
const indent = ' '.repeat(this.depth);
for (const part of text.split('\n')) this.buffer.push({ level, text: indent + part, time });
if (this.buffer.length > LOG_CAPACITY) this.buffer = this.buffer.slice(-LOG_CAPACITY);
for (const listener of this.listeners) listener();
}
/** A group's head is a line like any other; what follows until `closeGroup` sits under it. */
openGroup(text: string): void {
this.push('info', text);
this.depth += 1;
}
closeGroup(): void {
this.depth = Math.max(0, this.depth - 1);
}
get lines(): readonly LogLine[] {
return this.buffer;
}
clear(): void {
this.depth = 0;
if (this.buffer.length === 0) return;
this.buffer = [];
for (const listener of this.listeners) listener();
@@ -72,29 +105,36 @@ export class ConsoleLog {
export const consoleLog = new ConsoleLog();
const METHODS = ['log', 'info', 'debug', 'warn', 'error'] as const;
const METHODS = ['log', 'info', 'debug', 'warn', 'error', 'group', 'groupCollapsed', 'groupEnd'] as const;
type ConsoleMethod = (typeof METHODS)[number];
export type ConsoleLike = Record<ConsoleMethod, (...args: unknown[]) => void>;
const LEVEL_OF: Record<ConsoleMethod, LogLevel> = {
const LEVEL_OF: Record<ConsoleMethod, LogLevel | 'open' | 'close'> = {
log: 'info',
info: 'info',
debug: 'info',
warn: 'warn',
error: 'error',
group: 'open',
groupCollapsed: 'open',
groupEnd: 'close',
};
/**
* Mirrors every `console.*` call into `log` and keeps calling the original, so devtools and the
* check scripts still see it. Returns the undo, which only the tests use.
* check scripts still see it. Groups are mirrored too: Pixi's deprecation warnings put the message
* into `groupCollapsed` and only the stack into `warn`. Returns the undo, which only the tests use.
*/
export function captureConsole(target: ConsoleLike = console, log: ConsoleLog = consoleLog): () => void {
const originals = METHODS.map((method) => [method, target[method]] as const);
for (const [method, original] of originals) {
const level = LEVEL_OF[method];
target[method] = (...args: unknown[]) => {
original.apply(target, args);
log.push(LEVEL_OF[method], formatArgs(args));
if (level === 'close') log.closeGroup();
else if (level === 'open') log.openGroup(formatArgs(args));
else log.push(level, formatArgs(args));
};
}
return () => {
+15 -2
View File
@@ -1,4 +1,4 @@
import { type Application, Color, type ColorSource, type Container, Rectangle, Text } from 'pixi.js';
import { type Application, Color, type ColorSource, type Container, Mesh, Rectangle, Text } from 'pixi.js';
import { resolveObjects } from '../core/model/instance';
import { findObjectHolder } from '../core/model/Project';
import type { FrameRect } from '../core/model/texture';
@@ -171,6 +171,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
};
(window as unknown as { __gm: unknown }).__gm = {
state,
/** The Pixi application, for probes that need the renderer or the stage. */
app,
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
locate,
locateAll,
@@ -386,7 +388,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
};
},
fps: () => editor.fps,
/** Visible nodes carrying a filter – one extra render pass each, which is what shader cost is made of. */
/** Visible nodes carrying a filter – one extra render pass each; since #77 a shader is none. */
filterCount: () => {
let n = 0;
const walk = (node: Container): void => {
@@ -397,6 +399,17 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
walk(app.stage);
return n;
},
/** Visible sprite meshes drawing with a shader of their own – one draw call each, no pass (#77). */
shaderCount: () => {
let n = 0;
const walk = (node: Container): void => {
if (!node.visible) return;
if (node instanceof Mesh && node.shader) n += 1;
for (const child of node.children) walk(child);
};
walk(app.stage);
return n;
},
lowLatency: () =>
(app.renderer as unknown as { gl?: WebGL2RenderingContext }).gl?.getContextAttributes()
?.desynchronized === true,
+29 -6
View File
@@ -1,4 +1,4 @@
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
import { Container, type FederatedPointerEvent, Graphics, type Mesh, Text, TextStyle } from 'pixi.js';
import { isSymbolType } from '../../core/model/AssetTypes';
import { sortByZ, type Vec2, textOf } from '../../core/model/Properties';
import { footprintOf, textAnchor } from '../../core/properties/bounds';
@@ -6,9 +6,9 @@ import { rectAround, rectsIntersect } from '../../core/properties/camera';
import type { EditorState } from '../state';
import { type Size, Theme, Widget } from '../../ui';
import type { DrawItem } from '../../viewport/items';
import { disposeShader } from '../../viewport/shaders';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { drawItem } from '../../viewport/symbols';
import type { ShaderSource } from '../../viewport/shaders';
import { disposeSpriteMesh, drawItem, newSpriteMesh, syncSpriteMesh } from '../../viewport/symbols';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { type View, worldBounds, worldToScreen } from '../../viewport/view';
@@ -29,6 +29,8 @@ export abstract class WorldViewport extends Widget {
protected readonly nodes = new Map<string, Graphics>();
/** Text content of text items: a sibling of the item's node, kept next to it in z. */
private readonly texts = new Map<string, Text>();
/** Pictures of sprite items: siblings as well, each right under its node. */
private readonly sprites = new Map<string, Mesh>();
private visibleCount = 0;
protected lastItems: DrawItem[] = [];
@@ -165,17 +167,20 @@ export abstract class WorldViewport extends Widget {
node.eventMode = this.itemsInteractive && onScreen ? 'static' : 'none';
this.syncDecorations(item, onScreen, view.scale, size);
this.syncText(item, onScreen, view.scale);
this.syncSprite(item, onScreen, size, shaders);
if (!onScreen) continue;
this.visibleCount++;
node.position.set(item.x, item.y);
drawItem(node, item, size, view.scale, textureLibrary, shaders);
drawItem(node, item, size, view.scale);
}
this.afterItems(items, view);
for (const [id, node] of this.nodes) {
if (seen.has(id)) continue;
this.texts.get(id)?.destroy();
this.texts.delete(id);
disposeShader(node);
const sprite = this.sprites.get(id);
if (sprite) disposeSpriteMesh(sprite);
this.sprites.delete(id);
node.destroy();
this.nodes.delete(id);
this.disposeDecorations(id);
@@ -191,14 +196,32 @@ export abstract class WorldViewport extends Widget {
const ordered = sortByZ(items.filter((item) => !isSymbolType(item.type)));
this.objects.removeChildren();
for (const item of ordered) {
// Picture and text are siblings because Graphics may not have children: the picture right
// under its node, which only holds the hit area, the text right above its box.
const sprite = this.sprites.get(item.id);
if (sprite) this.objects.addChild(sprite);
const node = this.nodes.get(item.id);
if (node) this.objects.addChild(node);
// Text is a sibling because Graphics may not have children; keep it right above its box.
const text = this.texts.get(item.id);
if (text) this.objects.addChild(text);
}
}
private syncSprite(item: DrawItem, onScreen: boolean, size: Size, shaders: ShaderSource): void {
let sprite = this.sprites.get(item.id);
if (item.type !== 'sprite' || !onScreen) {
if (sprite) sprite.visible = false;
return;
}
if (!sprite) {
sprite = newSpriteMesh();
this.sprites.set(item.id, sprite);
this.objects.addChild(sprite);
}
sprite.visible = true;
syncSpriteMesh(sprite, item, size, textureLibrary, shaders);
}
/** Resolution follows the zoom in coarse steps: re-rendering the glyphs per wheel notch is wasteful. */
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
let text = this.texts.get(item.id);
+66 -23
View File
@@ -1,4 +1,4 @@
import { defaultFilterVert, Filter, GlProgram, type Graphics, UniformGroup } from 'pixi.js';
import { GlProgram, type Mesh, Shader, Texture, UniformGroup } from 'pixi.js';
import type { PropertyBag } from '../core/model/Properties';
import { shaderOf } from '../core/properties/shaders';
import type { CompiledShader } from '../core/shader/compile';
@@ -10,11 +10,38 @@ export interface ShaderSource {
}
/**
* A shader asset worn by one sprite node. The program is shared (`GlProgram.from` caches by
* source), the uniforms are this sprite's; `read` takes them from the item's bag.
* Pixi's own mesh vertex stage, minus what a sprite quad does not need: a frame is a whole texture
* (`TextureLibrary`), so `aUV` maps 1:1 and there is no texture matrix. `uRound` is left out –
* Pixi's uniform sync skips uniforms the program does not declare.
*/
class GraphShader extends Filter {
const SPRITE_VERTEX = `
in vec2 aPosition;
in vec2 aUV;
out vec2 vTextureCoord;
out vec4 vColor;
uniform mat3 uProjectionMatrix;
uniform mat3 uWorldTransformMatrix;
uniform vec4 uWorldColorAlpha;
uniform mat3 uTransformMatrix;
uniform vec4 uColor;
void main()
{
mat3 m = uProjectionMatrix * uWorldTransformMatrix * uTransformMatrix;
gl_Position = vec4((m * vec3(aPosition, 1.0)).xy, 0.0, 1.0);
vTextureCoord = aUV;
vColor = uColor * uWorldColorAlpha;
}
`;
/**
* A shader asset worn by one sprite mesh: the fragment replaces Pixi's in the sprite's own draw,
* so there is no filter and no extra pass (#77). The program is shared (`GlProgram.from` caches
* by source), the uniforms are this sprite's; `read` takes them from the item's bag.
*/
class GraphShader extends Shader {
private readonly uniforms: Record<string, number> | null;
/** What `Mesh.shader` asks of a shader; kept in step with the mesh's own by `wear`. */
texture: Texture = Texture.WHITE;
constructor(
readonly assetId: string,
@@ -30,10 +57,12 @@ class GraphShader extends Filter {
)
: null;
super({
glProgram: GlProgram.from({ vertex: defaultFilterVert, fragment, name: `shader-${assetId}` }),
resources: group ? { shaderUniforms: group } : {},
resolution: 'inherit',
antialias: 'inherit',
glProgram: GlProgram.from({ vertex: SPRITE_VERTEX, fragment, name: `shader-${assetId}` }),
resources: {
uTexture: Texture.WHITE.source,
uSampler: Texture.WHITE.source.style,
...(group ? { shaderUniforms: group } : {}),
},
});
this.uniforms = group ? group.uniforms : null;
}
@@ -45,37 +74,51 @@ class GraphShader extends Filter {
this.uniforms[uniformName(u.name)] = Number.isFinite(n) ? n : u.default;
}
}
/** Pixi binds a mesh's texture only for its own shader, so a custom one takes it here. */
wear(texture: Texture): void {
this.texture = texture;
this.resources.uTexture = texture.source;
this.resources.uSampler = texture.source.style;
}
}
function wornBy(node: Graphics): GraphShader | null {
// Declared `readonly Filter[]`, but the getter yields undefined until a filter was ever set.
const first = (node.filters as readonly Filter[] | undefined)?.[0];
return first instanceof GraphShader ? first : null;
function wornBy(mesh: Mesh): GraphShader | null {
return mesh.shader instanceof GraphShader ? mesh.shader : null;
}
/**
* Makes the node's filter match `props`: updated in place while asset and fragment are the same,
* rebuilt when either changed, dropped for no shader – and for one that does not compile.
* Makes the mesh's shader match `props`: updated in place while asset and fragment are the same,
* rebuilt when either changed, dropped for no shader – and for one that does not compile, which
* leaves the mesh on Pixi's batched draw.
*/
export function syncShader(node: Graphics, props: PropertyBag, shaders: ShaderSource): void {
export function syncShader(mesh: Mesh, props: PropertyBag, shaders: ShaderSource): void {
const id = shaderOf(props);
const compiled = id ? shaders.compiled(id) : null;
const worn = wornBy(node);
const worn = wornBy(mesh);
if (worn && compiled && worn.assetId === id && worn.fragment === compiled.fragment) {
worn.read(props);
worn.wear(mesh.texture);
return;
}
disposeShader(node);
if (!compiled) return;
disposeShader(mesh);
if (compiled) {
const shader = new GraphShader(id, compiled.fragment, compiled.uniforms);
shader.read(props);
node.filters = [shader];
shader.wear(mesh.texture);
mesh.shader = shader;
}
// Pixi caches whether a mesh batches (MeshPipe's meshData) and re-checks it only in
// validateRenderable, which it skips in any frame whose structure changed – and the draw order
// re-attaches every object per redraw. A mesh that ever drew batched would so keep drawing
// batched, plain, its shader ignored. `unload` drops that cache; the next frame reads `batched` afresh.
if (Boolean(worn) !== Boolean(compiled)) mesh.unload();
}
/** `Container.destroy` leaves filters alone, so a node going away releases its shader here. */
export function disposeShader(node: Graphics): void {
const worn = wornBy(node);
/** `Mesh.destroy` only lets go of its shader, so a mesh going away releases it here. */
export function disposeShader(mesh: Mesh): void {
const worn = wornBy(mesh);
if (!worn) return;
node.filters = null;
mesh.shader = null;
worn.destroy();
}
+49 -20
View File
@@ -1,36 +1,73 @@
/** Everything here draws in world units at the item's origin. */
import { type Graphics, Rectangle, type Texture } from 'pixi.js';
import { type Graphics, Mesh, MeshGeometry, Rectangle, Texture } from 'pixi.js';
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds';
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { Theme } from '../ui';
import type { DrawItem } from './items';
import { type ShaderSource, syncShader } from './shaders';
import { disposeShader, type ShaderSource, syncShader } from './shaders';
import type { TextureLibrary } from './TextureLibrary';
/** Screen-pixel half-width of the grab band along a collider's outline. */
const COLLIDER_GRAB_BAND = 6;
/**
* One quad for every sprite, sized by the mesh's scale. A frame is a texture of its own
* (`TextureLibrary`), so the 0..1 UVs cover exactly the frame.
*/
const UNIT_QUAD = new MeshGeometry({
positions: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
});
/**
* A sprite's picture: a unit quad, centred by its pivot and sized by its scale. It is a sibling of
* the sprite's node (a Graphics may not have children), which keeps only the hit area, and it draws
* with a shader of its own when the sprite wears one (#77).
*/
export function newSpriteMesh(): Mesh {
const mesh = new Mesh({ geometry: UNIT_QUAD, texture: Texture.WHITE });
mesh.eventMode = 'none';
mesh.pivot.set(0.5, 0.5);
return mesh;
}
export function syncSpriteMesh(
mesh: Mesh,
item: DrawItem,
size: { w: number; h: number },
textures: TextureLibrary,
shaders: ShaderSource,
): void {
// Null while the picture is still loading: the box shows its plain colour until then, and once
// the picture is there the colour tints it.
const texture = item.texture ? textures.get(item.texture) : null;
mesh.position.set(item.x, item.y);
mesh.scale.set(size.w, size.h);
mesh.texture = texture ?? Texture.WHITE;
mesh.tint = Number(item.props.color ?? 0xffffff);
syncShader(mesh, item.props, shaders);
}
/** `Mesh.destroy` leaves the shared geometry alone and only lets go of its shader. */
export function disposeSpriteMesh(mesh: Mesh): void {
disposeShader(mesh);
mesh.destroy();
}
/** `scale` is the view scale in pixels per world unit, so strokes stay constant on screen. */
export function drawItem(
node: Graphics,
item: DrawItem,
size: { w: number; h: number },
scale: number,
textures: TextureLibrary,
shaders: ShaderSource,
): void {
node.clear();
node.hitArea = null;
switch (item.type) {
case 'sprite': {
const color = Number(item.props.color ?? 0xffffff);
// Null while the picture is still loading: the box shows its plain colour until then, and
// once the picture is there the colour tints it.
const texture = item.texture ? textures.get(item.texture) : null;
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(spriteFill(texture, color));
syncShader(node, item.props, shaders);
case 'sprite':
node.hitArea = new Rectangle(-size.w / 2, -size.h / 2, size.w, size.h);
return;
}
case 'camera':
node
.rect(-size.w / 2, -size.h / 2, size.w, size.h)
@@ -90,14 +127,6 @@ export function drawItem(
}
}
/** A fill spreads the whole texture over the shape; a frame is a texture of its own (`TextureLibrary`). */
export function spriteFill(
texture: Texture | null,
color: number,
): number | { texture: Texture; color: number } {
return texture ? { texture, color } : color;
}
/** Hit area covering only the outline band, in local coordinates and world units. */
function colliderOutlineHitArea(
shape: ColliderShape,
+2
View File
@@ -203,6 +203,8 @@ describe('compileShader', () => {
const { fragment } = compiled;
for (const name of ['brightness', 'contrast', 'saturation'])
expect(fragment).toContain(`uniform float u_${name};`);
// Drawn in the sprite's own pass: the tint goes in before the graph, premultiplied like the texel.
expect(fragment).toContain('vec4 texel = texture(uTexture, vTextureCoord) * vColor;');
expect(fragment).toContain('vec3 rgb = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;');
expect(fragment).toMatch(/float n\d+_result = \(n\d+_value - 1\.0\);/);
expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_color \+ vec3\(n\d+_result\)\);/);
+66 -1
View File
@@ -15,7 +15,16 @@ import {
import { EditorState } from '../../src/editor/state';
function fakeConsole(): ConsoleLike {
return { log: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() };
return {
log: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
group: vi.fn(),
groupCollapsed: vi.fn(),
groupEnd: vi.fn(),
};
}
describe('the log buffer', () => {
@@ -88,6 +97,26 @@ describe('formatting the arguments of a console call', () => {
it('keeps null and undefined visible', () => {
expect(formatArgs([null, undefined, false])).toBe('null undefined false');
});
it('fills the directives of a leading format string the way the browser does', () => {
// Pixi's deprecation head: two `%c` styles that the panel cannot show, then the message.
expect(
formatArgs([
'%cPixiJS Deprecation Warning: %c%s',
'color:#614108',
'font-weight:normal',
'addChild: no',
]),
).toBe('PixiJS Deprecation Warning: addChild: no');
expect(formatArgs(['%d of %i at %f%%', 7.9, '3', 0.5, 'left'])).toBe('7 of 3 at 0.5% left');
expect(formatArgs(['%o and %O', { a: 1 }, [2]])).toBe('{"a":1} and [2]');
expect(formatArgs(['%s only', 'one'])).toBe('one only');
});
it('leaves a directive standing that has no argument, and a % elsewhere alone', () => {
expect(formatArgs(['%s and %s', 'one'])).toBe('one and %s');
expect(formatArgs(['100% done', 'x'])).toBe('100% done x');
});
});
describe('capturing console', () => {
@@ -112,6 +141,42 @@ describe('capturing console', () => {
expect(log.lines).toHaveLength(5);
});
it('keeps a group head and indents what follows until the group ends', () => {
const target = fakeConsole();
const log = new ConsoleLog();
captureConsole(target, log);
target.groupCollapsed(
'%cPixiJS Deprecation Warning: %c%s',
'css',
'css',
'addChild: no\nDeprecated since v8',
);
target.warn(' at one\n at two');
target.group('inner');
target.log('deep');
target.groupEnd();
target.groupEnd();
target.groupEnd();
target.log('after');
expect(log.lines.map((l) => `${l.level}|${l.text}`)).toEqual([
'info|PixiJS Deprecation Warning: addChild: no',
'info|Deprecated since v8',
'warn| at one',
'warn| at two',
'info| inner',
'info| deep',
'info|after',
]);
});
it('forgets an open group on clear', () => {
const log = new ConsoleLog();
log.openGroup('head');
log.clear();
log.push('info', 'flat');
expect(log.lines.map((l) => l.text)).toEqual(['flat']);
});
it('leaves the original reachable, so devtools and the check scripts still see the call', () => {
const target = fakeConsole();
const original = target.warn;
+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',