Show entities and time per area while playing in the Game View (#275)
A game that stutters gave no hint where the frame goes. The Game tab now has a Performance switch. It shows a small overlay in the top left corner of the Game View while a game runs: fps, the number of entities and the mean milliseconds per frame for each area, averaged over half a second like the fps counter. - src/editor/frameMeter.ts holds the pure rule. FrameMeter sums the areas per frame, closes a window every 0.5 s and formats the lines. - World.timeSystem times every system the world ticks. PlaySession maps the systems to Behaviors, Physics and Animation and sets the hook again after Load scene. - Animation has its own line. Counting the AnimationSystem under Behaviors would have skewed that number. - Drawing is the build of the game picture plus app.render(). It is main thread time only. The GPU work after it is not in the number, and app.render() draws the whole canvas including the panels. - Editor is the rest of the editor tick after the systems and the game picture: mirroring into the model, panels and console. - The overlay lets clicks through to the game. It shows dashes until the first window closes. The switch is off by default and is not saved. The released game (#266) runs without a meter. Tests: - Unit frame-meter: 9 passed. - Gate on melektaus-atx: 197 files, 2853 tests, CHECK EXIT 0. - e2e 59 on melektaus-atx: ALL PASSED, 18 checks (13 before, +5 in 59e with a real mouse on the switch). Run twice. - Three mutations were caught: the editor share not subtracting the systems (2 unit tests), systems running in pause not reported (1 unit test), the overlay getting no times (59e). - The e2e total of 1601 is computed (1596 + 5), not measured. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d471150efb
commit
77f4c2c1a3
13 files changed
+436
-5
No files matched your search
@@ -14,6 +14,7 @@ import {
|
||||
clamp,
|
||||
HStack,
|
||||
isRendering,
|
||||
lastRenderTime,
|
||||
Menu,
|
||||
type MenuEntry,
|
||||
Panel,
|
||||
@@ -904,6 +905,7 @@ export class Editor implements PlayHost {
|
||||
|
||||
showGame(): void {
|
||||
this.showTab('game');
|
||||
this.showPerformance();
|
||||
}
|
||||
|
||||
leaveGame(): void {
|
||||
@@ -911,6 +913,8 @@ export class Editor implements PlayHost {
|
||||
}
|
||||
|
||||
private tick(dt: number): void {
|
||||
const started = performance.now();
|
||||
const drawnBefore = this.gameView.drawTime;
|
||||
this.measureFps(dt);
|
||||
// One step for everything below: a world gets exactly the seconds the particle, shader and
|
||||
// animation clocks moved, so the global time scale cannot pull them apart.
|
||||
@@ -932,6 +936,14 @@ export class Editor implements PlayHost {
|
||||
if (this.tab === 'shader') this.shaderEditor.tick();
|
||||
if (this.tab === 'pixelart') this.pixelArtEditor.tick();
|
||||
if (this.movesByItself()) requestRender();
|
||||
const drawn = this.gameView.drawTime - drawnBefore;
|
||||
if (this.play.measureFrame(dt, performance.now() - started, drawn, lastRenderTime()))
|
||||
this.showPerformance();
|
||||
}
|
||||
|
||||
private showPerformance(): void {
|
||||
const reading = this.play.performance();
|
||||
if (reading) this.gameView.performanceOverlay.show({ fps: this.fps, ...reading });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1007,6 +1019,7 @@ export class Editor implements PlayHost {
|
||||
time: number;
|
||||
sceneId: string;
|
||||
panelUpdates: { structure: number; values: number };
|
||||
performance: ReturnType<PlaySession['performance']>;
|
||||
} {
|
||||
return {
|
||||
playing: this.play.isPlaying,
|
||||
@@ -1015,6 +1028,7 @@ export class Editor implements PlayHost {
|
||||
time: this.play.time,
|
||||
sceneId: this.play.sceneId,
|
||||
panelUpdates: this.play.panelUpdates(),
|
||||
performance: this.play.performance(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
|
||||
import { namedValue } from '../core/graph/values';
|
||||
import { GameRun, type RunServices } from '../runtime/GameRun';
|
||||
import { AnimationSystem } from '../runtime/systems/AnimationSystem';
|
||||
import { type BehaviorSystem, behaviorError, type Trace } from '../runtime/systems/BehaviorSystem';
|
||||
import type { AnimationPlayer } from '../core/model/animation';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
@@ -15,6 +16,7 @@ import { Input } from '../runtime/systems/Input';
|
||||
import type { World } from '../runtime/World';
|
||||
import { ui } from '../ui';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import { FrameMeter, type FrameTimes } from './frameMeter';
|
||||
import { saveArea } from './saves';
|
||||
import { PanelClock, panelPolicy, type PanelUpdate } from './playThrottle';
|
||||
import type { GameViewport } from './viewport/GameViewport';
|
||||
@@ -62,6 +64,8 @@ export class PlaySession {
|
||||
private readonly panels = new PanelClock();
|
||||
/** How often the panels were brought up to date this run (test hook). */
|
||||
private updates = { structure: 0, values: 0 };
|
||||
/** Where the frames of this run go (#275). */
|
||||
private readonly meter = new FrameMeter();
|
||||
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
@@ -157,6 +161,8 @@ export class PlaySession {
|
||||
};
|
||||
this.run = GameRun.start(runtime, scene.id, this.input, services);
|
||||
this.updates = { structure: 0, values: 0 };
|
||||
this.meter.reset();
|
||||
this.timeSystems(this.run);
|
||||
this.input.attach();
|
||||
this.host.gameView.setWorld(this.run.world);
|
||||
this.host.showGame();
|
||||
@@ -166,6 +172,7 @@ export class PlaySession {
|
||||
|
||||
/** A `Load scene` swapped the world (see `GameRun.loadScene`). */
|
||||
private sceneLoaded(run: GameRun): void {
|
||||
this.timeSystems(run);
|
||||
this.host.gameView.setWorld(run.world);
|
||||
this.state.setNotice(this.playNotice(), 'quiet');
|
||||
if (run.world.stopRequested) this.stop();
|
||||
@@ -280,6 +287,29 @@ export class PlaySession {
|
||||
if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw();
|
||||
}
|
||||
|
||||
/** The world tells the meter what each system took; a `Load scene` brings a world of its own. */
|
||||
private timeSystems(run: GameRun): void {
|
||||
run.world.timeSystem = (system, ms) => {
|
||||
const area =
|
||||
system === run.behaviors ? 'behaviors' : system instanceof AnimationSystem ? 'animation' : 'physics';
|
||||
this.meter.addSystem(area, ms);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the editor's frame for the meter (see `FrameMeter.endFrame`); true when a new average is
|
||||
* ready. Nothing is measured while nothing plays.
|
||||
*/
|
||||
measureFrame(dt: number, busy: number, drawn: number, rendered: number): boolean {
|
||||
return this.isPlaying && this.meter.endFrame(dt, busy, drawn, rendered);
|
||||
}
|
||||
|
||||
/** How many entities run and where the frames went lately; null while nothing plays. */
|
||||
performance(): { entities: number; times: FrameTimes | null } | null {
|
||||
const world = this.world;
|
||||
return world ? { entities: world.entities.length, times: this.meter.average } : null;
|
||||
}
|
||||
|
||||
/** How often the panels caught up with this run, by kind (test hook). */
|
||||
panelUpdates(): { structure: number; values: number } {
|
||||
return { ...this.updates };
|
||||
|
||||
@@ -126,6 +126,22 @@ export function sceneHooks({ state, editor }: HookContext) {
|
||||
headerButton: shownOnStage(gamePane.fullscreenButton) ? centreOf(gamePane.fullscreenButton) : null,
|
||||
corner: shownOnStage(gameView.fullscreenButton) ? centreOf(gameView.fullscreenButton) : null,
|
||||
}),
|
||||
/**
|
||||
* The performance overlay (#275): the header switch, whether the overlay is on screen, where,
|
||||
* and the lines it shows. The numbers behind them are in `playInfo().performance`.
|
||||
*/
|
||||
gamePerformance: () => {
|
||||
const overlay = gameView.performanceOverlay;
|
||||
const origin = overlay.getGlobalPosition();
|
||||
const button = gamePane.performanceButton;
|
||||
return {
|
||||
button: { ...centreOf(button), lit: button.toggled },
|
||||
shown: shownOnStage(overlay),
|
||||
box: { x: origin.x, y: origin.y, w: overlay.width, h: overlay.height },
|
||||
view: rectOf(gameView),
|
||||
lines: overlay.lines,
|
||||
};
|
||||
},
|
||||
/** Which typeface a text ended up drawn in, and how wide it made it. */
|
||||
textInfo: (id: string) => viewport.textInfo(id),
|
||||
gameTextInfo: (id: string) => gameView.textInfo(id),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Where the time of a frame goes while playing (#275): the game's systems one by one, drawing the
|
||||
* game, and what the editor does around them. Averaged per frame over half a second, the same
|
||||
* window the fps readout counts over.
|
||||
*/
|
||||
export const FRAME_AREAS = ['behaviors', 'physics', 'animation', 'drawing', 'editor'] as const;
|
||||
export type FrameArea = (typeof FRAME_AREAS)[number];
|
||||
export type FrameTimes = Record<FrameArea, number>;
|
||||
|
||||
/** Seconds one average runs over. */
|
||||
export const FRAME_WINDOW = 0.5;
|
||||
|
||||
const LABELS: Record<FrameArea, string> = {
|
||||
behaviors: 'Behaviors',
|
||||
physics: 'Physics',
|
||||
animation: 'Animation',
|
||||
drawing: 'Drawing',
|
||||
editor: 'Editor',
|
||||
};
|
||||
|
||||
export interface PerformanceReading {
|
||||
fps: number | null;
|
||||
entities: number;
|
||||
times: FrameTimes | null;
|
||||
}
|
||||
|
||||
/** What the overlay shows, one area a line; a dash stands for what has not been measured yet. */
|
||||
export function performanceLines({ fps, entities, times }: PerformanceReading): string[] {
|
||||
const width = Math.max(...FRAME_AREAS.map((area) => LABELS[area].length));
|
||||
return [
|
||||
`${fps ?? '–'} fps · ${entities} ${entities === 1 ? 'entity' : 'entities'}`,
|
||||
...FRAME_AREAS.map((area) => {
|
||||
const ms = times ? `${times[area].toFixed(2)} ms` : '– ms';
|
||||
return `${LABELS[area].padEnd(width)} ${ms.padStart(9)}`;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function noTimes(): FrameTimes {
|
||||
return { behaviors: 0, physics: 0, animation: 0, drawing: 0, editor: 0 };
|
||||
}
|
||||
|
||||
export class FrameMeter {
|
||||
private sums = noTimes();
|
||||
/** What the systems took in the frame under way, so the editor's share can be told apart. */
|
||||
private systems = 0;
|
||||
private frames = 0;
|
||||
private elapsed = 0;
|
||||
/** Milliseconds per frame by area over the last whole window; null until one has passed. */
|
||||
average: FrameTimes | null = null;
|
||||
|
||||
/** One of the game's systems took `ms` in this frame. */
|
||||
addSystem(area: 'behaviors' | 'physics' | 'animation', ms: number): void {
|
||||
this.sums[area] += ms;
|
||||
this.systems += ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a frame `dt` seconds long. `busy` is what the editor's tick took in all, systems and
|
||||
* `drawn` included; `drawn` is the game view drawing the run, `rendered` the renderer putting the
|
||||
* frame on screen. Returns true when the frame ended a window and `average` is new.
|
||||
*/
|
||||
endFrame(dt: number, busy: number, drawn: number, rendered: number): boolean {
|
||||
this.sums.drawing += drawn + rendered;
|
||||
this.sums.editor += Math.max(0, busy - this.systems - drawn);
|
||||
this.systems = 0;
|
||||
this.frames++;
|
||||
this.elapsed += dt;
|
||||
if (this.elapsed < FRAME_WINDOW) return false;
|
||||
const average = noTimes();
|
||||
for (const area of FRAME_AREAS) average[area] = this.sums[area] / this.frames;
|
||||
this.average = average;
|
||||
this.sums = noTimes();
|
||||
this.frames = 0;
|
||||
this.elapsed = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.sums = noTimes();
|
||||
this.systems = 0;
|
||||
this.frames = 0;
|
||||
this.elapsed = 0;
|
||||
this.average = null;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@ import type { GameViewport } from './GameViewport';
|
||||
/** Room for the longest label, "Auto · 400%". */
|
||||
const SCALE_WIDTH = 110;
|
||||
|
||||
/** The game tab (#225): a header with the scale picker and the fullscreen button above the game view. */
|
||||
/**
|
||||
* The game tab (#225): a header with the scale picker, the performance switch and the fullscreen
|
||||
* button above the game view.
|
||||
*/
|
||||
export class GamePane extends VStack {
|
||||
readonly header = new TabHeader();
|
||||
/** Public for the check scripts. */
|
||||
@@ -18,13 +21,24 @@ export class GamePane extends VStack {
|
||||
onClick: () => void toggleFullscreen(),
|
||||
});
|
||||
|
||||
/** Switches the performance overlay over the running game (#275). */
|
||||
readonly performanceButton: Button;
|
||||
|
||||
constructor(readonly view: GameViewport) {
|
||||
super();
|
||||
this.scalePicker = new Dropdown(scaleLabel(view.scaleMode, null), {
|
||||
width: SCALE_WIDTH,
|
||||
onClick: () => this.openScalePicker(),
|
||||
});
|
||||
this.performanceButton = new Button('Performance', {
|
||||
toggle: true,
|
||||
onClick: () => {
|
||||
view.setPerformanceShown(!view.showsPerformance);
|
||||
this.performanceButton.setToggled(view.showsPerformance);
|
||||
},
|
||||
});
|
||||
this.header.add(this.scalePicker);
|
||||
this.header.add(this.performanceButton);
|
||||
this.header.add(new Spacer(), 1);
|
||||
this.header.add(this.fullscreenButton);
|
||||
this.add(this.header);
|
||||
|
||||
@@ -6,13 +6,14 @@ import { Text } from 'pixi.js';
|
||||
import { isSymbolType } from '../../core/model/AssetTypes';
|
||||
import type { EditorState } from '../state';
|
||||
import type { World } from '../../runtime/World';
|
||||
import { Theme, textStyle, ui } from '../../ui';
|
||||
import { requestRender, Theme, textStyle, ui } from '../../ui';
|
||||
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
|
||||
import { FullscreenCorner } from '../../viewport/FullscreenCorner';
|
||||
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
|
||||
import { routePointer } from '../../viewport/gamePointer';
|
||||
import { cameraFactor, cameraView, type GameScale, type View } from '../../viewport/view';
|
||||
import { type Frame, WorldViewport } from '../../viewport/WorldViewport';
|
||||
import { PerformanceOverlay } from './PerformanceOverlay';
|
||||
|
||||
export class GameViewport extends WorldViewport<EditorState> {
|
||||
private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') });
|
||||
@@ -30,6 +31,11 @@ export class GameViewport extends WorldViewport<EditorState> {
|
||||
/** The way back out of fullscreen (#267); the tab header has the way in. */
|
||||
readonly fullscreenButton = new FullscreenCorner(this);
|
||||
private fullscreen = false;
|
||||
/** Shown over a running game while switched on in the tab header (#275). */
|
||||
readonly performanceOverlay = new PerformanceOverlay();
|
||||
private performanceShown = false;
|
||||
/** Milliseconds spent in `redraw` so far; the meter takes the difference over a frame. */
|
||||
drawTime = 0;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state);
|
||||
@@ -40,6 +46,9 @@ export class GameViewport extends WorldViewport<EditorState> {
|
||||
this.addChild(this.editorRunCaption);
|
||||
this.addChild(this.fullscreenButton);
|
||||
this.fullscreenButton.sync(false, false);
|
||||
this.performanceOverlay.position.set(Theme.gap * 2, Theme.gap * 2);
|
||||
this.performanceOverlay.visible = false;
|
||||
this.addChild(this.performanceOverlay);
|
||||
routePointer(
|
||||
this,
|
||||
() => this.pointerWorld(),
|
||||
@@ -51,9 +60,26 @@ export class GameViewport extends WorldViewport<EditorState> {
|
||||
setWorld(world: World | null): void {
|
||||
this.playWorld = world;
|
||||
this.fullscreenButton.sync(this.fullscreen, world !== null);
|
||||
this.syncPerformance();
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
get showsPerformance(): boolean {
|
||||
return this.performanceShown;
|
||||
}
|
||||
|
||||
setPerformanceShown(on: boolean): void {
|
||||
this.performanceShown = on;
|
||||
this.syncPerformance();
|
||||
}
|
||||
|
||||
private syncPerformance(): void {
|
||||
const visible = this.performanceShown && this.playWorld !== null;
|
||||
if (visible === this.performanceOverlay.visible) return;
|
||||
this.performanceOverlay.visible = visible;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
get isFullscreen(): boolean {
|
||||
return this.fullscreen;
|
||||
}
|
||||
@@ -81,7 +107,9 @@ export class GameViewport extends WorldViewport<EditorState> {
|
||||
*/
|
||||
override redraw(): void {
|
||||
if (!this.visible) return;
|
||||
const start = performance.now();
|
||||
super.redraw();
|
||||
this.drawTime += performance.now() - start;
|
||||
}
|
||||
|
||||
get scaleMode(): GameScale {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Container, Graphics, Text } from 'pixi.js';
|
||||
import { Theme, textStyle } from '../../ui';
|
||||
import { performanceLines, type PerformanceReading } from '../frameMeter';
|
||||
|
||||
/** Frame rate, entities and the time per frame by area over a running game (#275), top left. */
|
||||
export class PerformanceOverlay extends Container {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly text = new Text({ text: '', style: textStyle('mono') });
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.eventMode = 'none';
|
||||
this.text.position.set(Theme.padding, Theme.padding / 2);
|
||||
this.addChild(this.bg, this.text);
|
||||
}
|
||||
|
||||
get lines(): string[] {
|
||||
return this.text.text.split('\n');
|
||||
}
|
||||
|
||||
show(reading: PerformanceReading): void {
|
||||
this.text.text = performanceLines(reading).join('\n');
|
||||
this.bg
|
||||
.clear()
|
||||
.roundRect(
|
||||
0,
|
||||
0,
|
||||
Math.ceil(this.text.width) + Theme.padding * 2,
|
||||
Math.ceil(this.text.height) + Theme.padding,
|
||||
4,
|
||||
)
|
||||
.fill({ color: Theme.colors.background, alpha: 0.75 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user