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
@@ -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
|
||||
|
||||
@@ -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);`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+68
-25
@@ -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;
|
||||
const shader = new GraphShader(id, compiled.fragment, compiled.uniforms);
|
||||
shader.read(props);
|
||||
node.filters = [shader];
|
||||
disposeShader(mesh);
|
||||
if (compiled) {
|
||||
const shader = new GraphShader(id, compiled.fragment, compiled.uniforms);
|
||||
shader.read(props);
|
||||
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
@@ -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,
|
||||
|
||||
@@ -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\)\);/);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user