diff --git a/README.md b/README.md index c8686cb9..e8427680 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index 15cdeea1..9f30ead6 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -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);`, diff --git a/src/editor/animation/AnimationCanvas.ts b/src/editor/animation/AnimationCanvas.ts index fab80c82..f3a52170 100644 --- a/src/editor/animation/AnimationCanvas.ts +++ b/src/editor/animation/AnimationCanvas.ts @@ -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; } diff --git a/src/editor/console/log.ts b/src/editor/console/log.ts index 8e65a0a7..e7f63dc2 100644 --- a/src/editor/console/log.ts +++ b/src/editor/console/log.ts @@ -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 void>; -const LEVEL_OF: Record = { +const LEVEL_OF: Record = { 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 () => { diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 1014dbbf..47be625c 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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, diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index ef276364..6ae297ed 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -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(); /** Text content of text items: a sibling of the item's node, kept next to it in z. */ private readonly texts = new Map(); + /** Pictures of sprite items: siblings as well, each right under its node. */ + private readonly sprites = new Map(); 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); diff --git a/src/viewport/shaders.ts b/src/viewport/shaders.ts index 8b257d0c..001489f3 100644 --- a/src/viewport/shaders.ts +++ b/src/viewport/shaders.ts @@ -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 | 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(); } diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 1655738d..e64fb7e7 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -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, diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 199f7491..6604feb8 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -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\)\);/); diff --git a/tests/editor/console-log.test.ts b/tests/editor/console-log.test.ts index 45f6ae2f..b76a09ce 100644 --- a/tests/editor/console-log.test.ts +++ b/tests/editor/console-log.test.ts @@ -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; diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs index 710ddbca..52c94bdb 100644 --- a/tools/e2e/12-textures.mjs +++ b/tools/e2e/12-textures.mjs @@ -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}; diff --git a/tools/e2e/27-console.mjs b/tools/e2e/27-console.mjs index 95c242b3..0dda9aa6 100644 --- a/tools/e2e/27-console.mjs +++ b/tools/e2e/27-console.mjs @@ -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', diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index cdab2ca7..8814752f 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -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, diff --git a/tools/perf-shaders.mjs b/tools/perf-shaders.mjs index 0a0d62a8..5a3e660b 100644 --- a/tools/perf-shaders.mjs +++ b/tools/perf-shaders.mjs @@ -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)); }); diff --git a/tools/seed-actor.mjs b/tools/seed-actor.mjs index c6493e4f..57db4671 100644 --- a/tools/seed-actor.mjs +++ b/tools/seed-actor.mjs @@ -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',