Shaders draw in the sprite's own pass: a Mesh with the graph's fragment, no filter (#77)
game-maker / suite (push) Successful in 8m24s
game-maker / suite (push) Successful in 8m24s
A sprite's picture is now a Mesh (unit quad, sibling of its Graphics node, which keeps only the hit area); a worn shader replaces Pixi's fragment in that draw, so there is no intermediate texture and no extra render pass. Measured on the HD 630: 400 shaded sprites hold 60 fps (was 34), 800 hold 56 (was 20). Two Pixi facts the code leans on: a mesh's texture is bound only for Pixi's own shader, so GraphShader.wear hands it over; and MeshPipe caches whether a mesh batches and re-checks it only in a frame without structure changes, so switching a shader on or off calls mesh.unload(). The console panel mirrors console.group/groupCollapsed/groupEnd and fills the %s %d %o %c directives, so Pixi's deprecation warning shows its head, not only its stack. E2E 12 guards against the addChild-on-Graphics warning, 28 counts shaders instead of filters. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
82effb2c03
commit
0e5e5ef925
15 files changed
+328
-88
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user