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

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

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

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

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

No files matched your search

+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);