@driftengine/tools 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/overlay.d.ts +70 -0
- package/dist/overlay.js +275 -0
- package/dist/profiler.d.ts +29 -1
- package/dist/profiler.js +40 -1
- package/package.json +6 -6
- package/src/index.ts +24 -0
- package/src/overlay.ts +342 -0
- package/src/profiler.ts +52 -1
package/README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# `@driftengine/tools`
|
|
2
2
|
|
|
3
3
|
The editor's panels, as something a shipped game can carry: an inspector, a console, a profiler and
|
|
4
|
-
a network panel, with the command stack that makes their edits undoable
|
|
4
|
+
a network panel, with the command stack that makes their edits undoable, and the overlay that puts
|
|
5
|
+
them on screen on a key.
|
|
5
6
|
|
|
6
|
-
**
|
|
7
|
+
**5,105 bytes gzipped**, measured by `scripts/size-gate.test.mjs` against
|
|
7
8
|
`scripts/fixtures/size/tools-only.ts`. Optional — nothing in `@driftengine/core` imports it, so a
|
|
8
9
|
game that never asks for these pays nothing.
|
|
9
10
|
|
|
@@ -37,6 +38,45 @@ const command = inspectorPanel.route(world, view, event);
|
|
|
37
38
|
if (command !== null) undo.push(command);
|
|
38
39
|
```
|
|
39
40
|
|
|
41
|
+
## The overlay, because a panel that nothing mounts is a tree nobody sees
|
|
42
|
+
|
|
43
|
+
Every panel above builds a `UiNode` tree and stops there. For 4.0.0 the half that turns a tree into
|
|
44
|
+
something a person can look at — geometry, event routing, painting — lived only in the editor
|
|
45
|
+
application, which is private and ships to nobody, so a game wanting an in-game inspector had to
|
|
46
|
+
write it again. `createToolsOverlay` is that half.
|
|
47
|
+
|
|
48
|
+
A **column rather than a dock**: nobody arranges a debug overlay, they press a key, read a number
|
|
49
|
+
and press it again, so the panels stack down one edge in the order they are given.
|
|
50
|
+
|
|
51
|
+
The **painter is supplied**. Four calls — a rectangle, a line of text, a clip and its close — which
|
|
52
|
+
is every operation the overlay makes. A host with a 2D context writes four lines; a host drawing
|
|
53
|
+
through `@driftengine/ui2d` writes them over a sprite batch; a host with no screen writes them into
|
|
54
|
+
an array, which is what this package's own tests do. Nothing here needs a graphics device, which is
|
|
55
|
+
what makes the whole of it assertable.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { bindPanel, createToolsOverlay, paintOverlay, profilerPanel } from '@driftengine/tools';
|
|
59
|
+
|
|
60
|
+
const overlay = createToolsOverlay({
|
|
61
|
+
panels: [bindPanel(profilerPanel, () => profilerWorld, profilerView)],
|
|
62
|
+
key: 'F3',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// once, and whenever the surface changes size
|
|
66
|
+
overlay.resize(canvas.width, canvas.height);
|
|
67
|
+
|
|
68
|
+
// every frame, after the scene
|
|
69
|
+
overlay.invalidate();
|
|
70
|
+
overlay.frame(now);
|
|
71
|
+
paintOverlay(painter, overlay);
|
|
72
|
+
|
|
73
|
+
// wherever input arrives; true means the overlay took it and the game should not
|
|
74
|
+
if (overlay.route(keyEvent(event.key, event.shiftKey, event.ctrlKey))) event.preventDefault();
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
While it is closed `frame` returns before it builds anything and `route` refuses every event but the
|
|
78
|
+
one that opens it, so a game that never presses the key pays for a boolean.
|
|
79
|
+
|
|
40
80
|
## Building it in or leaving it out
|
|
41
81
|
|
|
42
82
|
Import it and it is in the bundle; do not and it is not. There is no flag, because a flag would be a
|
package/dist/index.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export { MIXED, addComponentCommand, createInspectorView, fieldEditable, inspect
|
|
|
29
29
|
export type { ConsoleView, ConsoleWorld, LogEntry, LogFilter, LogRing, Severity, SourceCursor, } from './console.ts';
|
|
30
30
|
export { DISPLAY_LIMIT, appendLog, consolePanel, createConsoleView, createLogFilter, createLogRing, displayText, filteredEntries, jumpToSourceCommand, logEntries, } from './console.ts';
|
|
31
31
|
export type { FrameHistory, ProfilerRow, ProfilerView, ProfilerWorld } from './profiler.ts';
|
|
32
|
-
export { createFrameHistory, createProfilerView, formatMs, historyValues, occupancy, profilerPanel, profilerRows, pushFrame, } from './profiler.ts';
|
|
32
|
+
export { createFrameHistory, createGpuPassTimings, createProfilerView, formatMs, historyValues, occupancy, profilerPanel, profilerRows, pushFrame, recordGpuSample, } from './profiler.ts';
|
|
33
33
|
export type { NetworkReadout, NetworkView, NetworkWorld, RollbackHistory } from './network.ts';
|
|
34
34
|
export { componentDivergence, createNetworkView, createRollbackHistory, networkPanel, pushRollback, rollbackValues, } from './network.ts';
|
|
35
|
+
export type { OverlayPainter, OverlaySite, PanelBinding, ToolsOverlay, ToolsOverlayOptions, } from './overlay.ts';
|
|
36
|
+
export { bindPanel, createToolsOverlay, paintOverlay } from './overlay.ts';
|
package/dist/index.js
CHANGED
|
@@ -5,5 +5,6 @@ export { addToSelection, clearSelection, createSelection, isSelected, primarySel
|
|
|
5
5
|
export { divergenceIsUnaccounted, divergentComponents } from './divergence.js';
|
|
6
6
|
export { MIXED, addComponentCommand, createInspectorView, fieldEditable, inspectorPanel, inspectorRows, removeComponentCommand, rowText, setFieldCommand, } from './inspector.js';
|
|
7
7
|
export { DISPLAY_LIMIT, appendLog, consolePanel, createConsoleView, createLogFilter, createLogRing, displayText, filteredEntries, jumpToSourceCommand, logEntries, } from './console.js';
|
|
8
|
-
export { createFrameHistory, createProfilerView, formatMs, historyValues, occupancy, profilerPanel, profilerRows, pushFrame, } from './profiler.js';
|
|
8
|
+
export { createFrameHistory, createGpuPassTimings, createProfilerView, formatMs, historyValues, occupancy, profilerPanel, profilerRows, pushFrame, recordGpuSample, } from './profiler.js';
|
|
9
9
|
export { componentDivergence, createNetworkView, createRollbackHistory, networkPanel, pushRollback, rollbackValues, } from './network.js';
|
|
10
|
+
export { bindPanel, createToolsOverlay, paintOverlay } from './overlay.js';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { UiNode } from '@driftengine/ui2d';
|
|
2
|
+
import { type Command, type UndoStack } from './command.ts';
|
|
3
|
+
import { type Panel, type UiEvent } from './panel.ts';
|
|
4
|
+
/**
|
|
5
|
+
* A panel with its world and its view already attached.
|
|
6
|
+
*
|
|
7
|
+
* **The generics are closed here rather than carried by the host.** `Panel<W, V>` is two type
|
|
8
|
+
* parameters per panel, and a host holding several at once cannot express them in one list without
|
|
9
|
+
* erasing to `unknown` and casting on the way out. Binding closes them where both types are known
|
|
10
|
+
* and hands back something with no parameters at all, so the host never casts and the panel keeps
|
|
11
|
+
* the signature that makes its own rules checkable.
|
|
12
|
+
*/
|
|
13
|
+
export interface PanelBinding {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly title: string;
|
|
16
|
+
build(root: UiNode): void;
|
|
17
|
+
route(event: UiEvent): Command | null;
|
|
18
|
+
}
|
|
19
|
+
export declare function bindPanel<W, V>(panel: Panel<W, V>, world: () => Readonly<W>, view: V): PanelBinding;
|
|
20
|
+
/** Where one panel came out. Read rather than recomputed, so a hit test cannot disagree with a draw. */
|
|
21
|
+
export interface OverlaySite {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly x: number;
|
|
24
|
+
readonly y: number;
|
|
25
|
+
readonly w: number;
|
|
26
|
+
readonly h: number;
|
|
27
|
+
}
|
|
28
|
+
/** What a host draws with. Colours are CSS, `#rrggbb` or `#rrggbbaa`. */
|
|
29
|
+
export interface OverlayPainter {
|
|
30
|
+
rect(x: number, y: number, w: number, h: number, colour: string): void;
|
|
31
|
+
/** Text on a baseline at `y`, twelve pixels tall. */
|
|
32
|
+
text(content: string, x: number, y: number, colour: string): void;
|
|
33
|
+
clip(x: number, y: number, w: number, h: number): void;
|
|
34
|
+
unclip(): void;
|
|
35
|
+
}
|
|
36
|
+
export interface ToolsOverlayOptions {
|
|
37
|
+
readonly panels: readonly PanelBinding[];
|
|
38
|
+
/** `KeyboardEvent.key` of the key that opens and closes it. Defaults to `F3`. */
|
|
39
|
+
readonly key?: string;
|
|
40
|
+
readonly side?: 'left' | 'right';
|
|
41
|
+
readonly width?: number;
|
|
42
|
+
readonly undoLimit?: number;
|
|
43
|
+
readonly visible?: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface ToolsOverlay {
|
|
46
|
+
/** The tree the whole overlay is in. One absolutely placed child per panel. */
|
|
47
|
+
readonly root: UiNode;
|
|
48
|
+
readonly undo: UndoStack;
|
|
49
|
+
readonly visible: boolean;
|
|
50
|
+
/** How many times the tree has been rebuilt and laid out. Read by tests, and by nothing else. */
|
|
51
|
+
readonly layoutCount: number;
|
|
52
|
+
sites(): readonly OverlaySite[];
|
|
53
|
+
setVisible(visible: boolean): void;
|
|
54
|
+
toggle(): void;
|
|
55
|
+
resize(width: number, height: number): void;
|
|
56
|
+
frame(now: number): void;
|
|
57
|
+
/** Deliver one event. True where the overlay took it, so a host knows not to pass it on. */
|
|
58
|
+
route(event: UiEvent): boolean;
|
|
59
|
+
/** Say the world changed, so the next frame rebuilds. A profiler calls this every frame. */
|
|
60
|
+
invalidate(): void;
|
|
61
|
+
dispose(): void;
|
|
62
|
+
}
|
|
63
|
+
export declare function createToolsOverlay(options: ToolsOverlayOptions): ToolsOverlay;
|
|
64
|
+
/**
|
|
65
|
+
* Draw the overlay, or draw nothing because it is closed.
|
|
66
|
+
*
|
|
67
|
+
* A free function rather than a method, so the overlay itself never holds a painter and a host may
|
|
68
|
+
* draw the same overlay into two surfaces — a screen and a capture — without either knowing.
|
|
69
|
+
*/
|
|
70
|
+
export declare function paintOverlay(painter: OverlayPainter, overlay: ToolsOverlay): void;
|
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panels, on a key, over a running game.
|
|
3
|
+
*
|
|
4
|
+
* **The panels shipped without anything to mount them, and that was the gap.** `@driftengine/tools`
|
|
5
|
+
* exported an inspector, a console, a profiler and a network panel, each of which builds a `UiNode`
|
|
6
|
+
* tree and nothing more. What turned a tree into something a person could see lived in the editor
|
|
7
|
+
* application — a dock, a layout, a painter and the event routing — and that workspace is private
|
|
8
|
+
* and ships to nobody. So every consumer wanting an in-game inspector had to write that half again,
|
|
9
|
+
* and six of them writing it six times is the drift `AGENTS.md` opens by describing.
|
|
10
|
+
*
|
|
11
|
+
* **A column, not a dock.** The editor's dock splits, drags and persists because somebody arranges
|
|
12
|
+
* an editor and then keeps that arrangement. Nobody arranges a debug overlay: they press a key,
|
|
13
|
+
* read a number and press it again. So the panels stack down one edge in the order they were
|
|
14
|
+
* given, and what a dock would have bought is not bought.
|
|
15
|
+
*
|
|
16
|
+
* **The painter is supplied, never reached for.** Four calls — a rectangle, a line of text, a clip
|
|
17
|
+
* and its close — which is every operation `paintOverlay` makes. A host with a 2D context
|
|
18
|
+
* implements them in four lines, a host drawing through `@driftengine/ui2d`'s sprite pass
|
|
19
|
+
* implements them over a batch, and a host with no screen at all implements them into an array,
|
|
20
|
+
* which is what this module's own tests do. That is `AGENTS.md`'s platform rule applied to
|
|
21
|
+
* drawing: take the capability as a parameter, and let the consumer decide what it is.
|
|
22
|
+
*
|
|
23
|
+
* **Nothing here needs a graphics device**, which is the property that makes the whole of it
|
|
24
|
+
* testable. The geometry is arithmetic over a tree, the routing is arithmetic over rectangles, and
|
|
25
|
+
* the drawing is somebody else's four functions. An overlay that could only run against a live
|
|
26
|
+
* adapter is an overlay whose behaviour nobody can assert.
|
|
27
|
+
*
|
|
28
|
+
* **Hidden costs a frame nothing.** While it is closed, `frame` returns before it builds anything
|
|
29
|
+
* and `route` refuses every event but the one that opens it, so a game that never presses the key
|
|
30
|
+
* pays for a boolean.
|
|
31
|
+
*/
|
|
32
|
+
import { createUiNode, layoutUiTree } from '@driftengine/ui2d';
|
|
33
|
+
import { createUndoStack } from './command.js';
|
|
34
|
+
import { createPanelRoot } from './panel.js';
|
|
35
|
+
export function bindPanel(panel, world, view) {
|
|
36
|
+
return {
|
|
37
|
+
id: panel.id,
|
|
38
|
+
title: panel.title,
|
|
39
|
+
build: (root) => panel.build(world(), view, root),
|
|
40
|
+
route: (event) => panel.route(world(), view, event),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const DEFAULT_WIDTH = 320;
|
|
44
|
+
const DEFAULT_KEY = 'F3';
|
|
45
|
+
export function createToolsOverlay(options) {
|
|
46
|
+
const root = createUiNode({ name: 'tools-overlay', width: 'grow', height: 'grow' });
|
|
47
|
+
const undo = createUndoStack(options.undoLimit ?? 100);
|
|
48
|
+
const key = options.key ?? DEFAULT_KEY;
|
|
49
|
+
const side = options.side ?? 'right';
|
|
50
|
+
const columnWidth = options.width ?? DEFAULT_WIDTH;
|
|
51
|
+
const panels = options.panels;
|
|
52
|
+
const roots = new Map();
|
|
53
|
+
/* Pooled and refilled: this is read every frame the overlay is open and must not allocate. */
|
|
54
|
+
const sites = [];
|
|
55
|
+
let width = 0;
|
|
56
|
+
let height = 0;
|
|
57
|
+
let visible = options.visible ?? false;
|
|
58
|
+
let dirty = true;
|
|
59
|
+
let disposed = false;
|
|
60
|
+
let layoutCount = 0;
|
|
61
|
+
let focused = null;
|
|
62
|
+
const relayout = () => {
|
|
63
|
+
const x = side === 'left' ? 0 : Math.max(0, width - columnWidth);
|
|
64
|
+
const w = Math.min(columnWidth, width);
|
|
65
|
+
const each = panels.length === 0 ? 0 : Math.floor(height / panels.length);
|
|
66
|
+
root.absolute = true;
|
|
67
|
+
root.x = x;
|
|
68
|
+
root.y = 0;
|
|
69
|
+
root.width = w;
|
|
70
|
+
root.height = height;
|
|
71
|
+
root.children.length = 0;
|
|
72
|
+
sites.length = 0;
|
|
73
|
+
for (let at = 0; at < panels.length; at += 1) {
|
|
74
|
+
const panel = panels[at];
|
|
75
|
+
if (panel === undefined)
|
|
76
|
+
continue;
|
|
77
|
+
/* The last panel takes the remainder, so a height that does not divide leaves no gap. */
|
|
78
|
+
const top = each * at;
|
|
79
|
+
const tall = at === panels.length - 1 ? height - top : each;
|
|
80
|
+
let panelRoot = roots.get(panel.id);
|
|
81
|
+
if (panelRoot === undefined) {
|
|
82
|
+
panelRoot = createPanelRoot(panel);
|
|
83
|
+
roots.set(panel.id, panelRoot);
|
|
84
|
+
}
|
|
85
|
+
panelRoot.absolute = true;
|
|
86
|
+
panelRoot.x = 0;
|
|
87
|
+
panelRoot.y = top;
|
|
88
|
+
panelRoot.width = w;
|
|
89
|
+
panelRoot.height = tall;
|
|
90
|
+
panel.build(panelRoot);
|
|
91
|
+
root.children.push(panelRoot);
|
|
92
|
+
sites.push({ id: panel.id, x: 0, y: top, w, h: tall });
|
|
93
|
+
}
|
|
94
|
+
layoutUiTree(root, x, 0, w, height);
|
|
95
|
+
layoutCount += 1;
|
|
96
|
+
dirty = false;
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
root,
|
|
100
|
+
undo,
|
|
101
|
+
get visible() {
|
|
102
|
+
return visible;
|
|
103
|
+
},
|
|
104
|
+
get layoutCount() {
|
|
105
|
+
return layoutCount;
|
|
106
|
+
},
|
|
107
|
+
sites() {
|
|
108
|
+
if (visible && dirty)
|
|
109
|
+
relayout();
|
|
110
|
+
return sites;
|
|
111
|
+
},
|
|
112
|
+
setVisible(next) {
|
|
113
|
+
if (disposed || next === visible)
|
|
114
|
+
return;
|
|
115
|
+
visible = next;
|
|
116
|
+
dirty = true;
|
|
117
|
+
},
|
|
118
|
+
toggle() {
|
|
119
|
+
this.setVisible(!visible);
|
|
120
|
+
},
|
|
121
|
+
resize(next, nextHeight) {
|
|
122
|
+
if (disposed)
|
|
123
|
+
return;
|
|
124
|
+
if (next === width && nextHeight === height)
|
|
125
|
+
return;
|
|
126
|
+
width = next;
|
|
127
|
+
height = nextHeight;
|
|
128
|
+
dirty = true;
|
|
129
|
+
},
|
|
130
|
+
frame(_now) {
|
|
131
|
+
if (disposed || !visible)
|
|
132
|
+
return;
|
|
133
|
+
if (dirty)
|
|
134
|
+
relayout();
|
|
135
|
+
},
|
|
136
|
+
route(event) {
|
|
137
|
+
if (disposed)
|
|
138
|
+
return false;
|
|
139
|
+
/*
|
|
140
|
+
* The key is answered whether the overlay is open or closed, and it is the only thing that
|
|
141
|
+
* is. A host that had to know which state it was in to decide whether to forward a press
|
|
142
|
+
* would be holding the same boolean twice.
|
|
143
|
+
*/
|
|
144
|
+
if (event.kind === 'key' && event.key === key && !event.ctrl) {
|
|
145
|
+
visible = !visible;
|
|
146
|
+
dirty = true;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (!visible)
|
|
150
|
+
return false;
|
|
151
|
+
if (event.kind === 'key') {
|
|
152
|
+
if (event.ctrl && event.key === 'z') {
|
|
153
|
+
const moved = event.shift ? undo.redo() : undo.undo();
|
|
154
|
+
if (moved)
|
|
155
|
+
dirty = true;
|
|
156
|
+
return moved;
|
|
157
|
+
}
|
|
158
|
+
if (event.ctrl && event.key === 'y') {
|
|
159
|
+
const moved = undo.redo();
|
|
160
|
+
if (moved)
|
|
161
|
+
dirty = true;
|
|
162
|
+
return moved;
|
|
163
|
+
}
|
|
164
|
+
const target = panelWith(focused);
|
|
165
|
+
return target === null ? false : take(target.route(event));
|
|
166
|
+
}
|
|
167
|
+
if (dirty)
|
|
168
|
+
relayout();
|
|
169
|
+
const site = siteAt(event.x, event.y);
|
|
170
|
+
if (site === null)
|
|
171
|
+
return false;
|
|
172
|
+
if (event.kind === 'pointer' && event.phase === 'down')
|
|
173
|
+
focused = site.id;
|
|
174
|
+
const panel = panelWith(site.id);
|
|
175
|
+
if (panel === null)
|
|
176
|
+
return false;
|
|
177
|
+
/* In the panel's own space: a panel never learns where on screen it is. */
|
|
178
|
+
return take(panel.route(shift(event, -(root.rect.x + site.x), -site.y)));
|
|
179
|
+
},
|
|
180
|
+
invalidate() {
|
|
181
|
+
dirty = true;
|
|
182
|
+
},
|
|
183
|
+
dispose() {
|
|
184
|
+
if (disposed)
|
|
185
|
+
return;
|
|
186
|
+
disposed = true;
|
|
187
|
+
visible = false;
|
|
188
|
+
undo.clear();
|
|
189
|
+
root.children.length = 0;
|
|
190
|
+
roots.clear();
|
|
191
|
+
sites.length = 0;
|
|
192
|
+
focused = null;
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
function panelWith(id) {
|
|
196
|
+
if (id === null)
|
|
197
|
+
return null;
|
|
198
|
+
for (const panel of panels)
|
|
199
|
+
if (panel.id === id)
|
|
200
|
+
return panel;
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
function siteAt(x, y) {
|
|
204
|
+
const localX = x - root.rect.x;
|
|
205
|
+
if (localX < 0 || localX >= root.rect.w)
|
|
206
|
+
return null;
|
|
207
|
+
for (const site of sites) {
|
|
208
|
+
if (y >= site.y && y < site.y + site.h)
|
|
209
|
+
return site;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/*
|
|
214
|
+
* **`push` applies, so this must not.** `createUndoStack.push` calls `apply` on the way in — and
|
|
215
|
+
* on the merge path it says in so many words that an absorbed command has already been applied
|
|
216
|
+
* and must not be applied again. Applying here as well ran every panel edit twice, which the
|
|
217
|
+
* row panel's test saw as two rows added by one press and a real inspector would have shown as a
|
|
218
|
+
* field moved twice as far as the pointer.
|
|
219
|
+
*/
|
|
220
|
+
function take(command) {
|
|
221
|
+
if (command === null)
|
|
222
|
+
return false;
|
|
223
|
+
undo.push(command);
|
|
224
|
+
dirty = true;
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** The same event moved by a vector. Returns the event itself where there is nothing to move. */
|
|
229
|
+
function shift(event, dx, dy) {
|
|
230
|
+
if (event.kind === 'pointer' || event.kind === 'wheel') {
|
|
231
|
+
return { ...event, x: event.x + dx, y: event.y + dy };
|
|
232
|
+
}
|
|
233
|
+
return event;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Draw the overlay, or draw nothing because it is closed.
|
|
237
|
+
*
|
|
238
|
+
* A free function rather than a method, so the overlay itself never holds a painter and a host may
|
|
239
|
+
* draw the same overlay into two surfaces — a screen and a capture — without either knowing.
|
|
240
|
+
*/
|
|
241
|
+
export function paintOverlay(painter, overlay) {
|
|
242
|
+
if (!overlay.visible)
|
|
243
|
+
return;
|
|
244
|
+
paintNode(painter, overlay.root);
|
|
245
|
+
}
|
|
246
|
+
/** Where a twelve-pixel line sits inside a row. Rows here are sixteen to eighteen pixels tall. */
|
|
247
|
+
const TEXT_BASELINE = 12;
|
|
248
|
+
function paintNode(painter, node) {
|
|
249
|
+
if (node.hidden)
|
|
250
|
+
return;
|
|
251
|
+
const { x, y, w, h } = node.rect;
|
|
252
|
+
if (node.background !== null)
|
|
253
|
+
painter.rect(x, y, w, h, cssColour(node.background));
|
|
254
|
+
if (node.text.length > 0) {
|
|
255
|
+
painter.text(node.text, x, y + TEXT_BASELINE, cssColour(node.tint));
|
|
256
|
+
}
|
|
257
|
+
if (node.children.length === 0)
|
|
258
|
+
return;
|
|
259
|
+
if (node.clip)
|
|
260
|
+
painter.clip(x, y, w, h);
|
|
261
|
+
for (const child of node.children)
|
|
262
|
+
paintNode(painter, child);
|
|
263
|
+
if (node.clip)
|
|
264
|
+
painter.unclip();
|
|
265
|
+
}
|
|
266
|
+
function cssColour(rgba) {
|
|
267
|
+
if (rgba === null)
|
|
268
|
+
return '#ffffff';
|
|
269
|
+
let out = '#';
|
|
270
|
+
for (let at = 0; at < 3; at += 1) {
|
|
271
|
+
const channel = Math.round(Math.min(1, Math.max(0, rgba[at] ?? 0)) * 255);
|
|
272
|
+
out += channel.toString(16).padStart(2, '0');
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
package/dist/profiler.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { UiNode } from '@driftengine/ui2d';
|
|
2
|
-
import { type PassTimings } from '@driftengine/core';
|
|
2
|
+
import { type GpuSample, type PassTimings } from '@driftengine/core';
|
|
3
3
|
import { type ResidencyTable } from '@driftengine/texture';
|
|
4
4
|
import { type Panel } from './panel.ts';
|
|
5
5
|
export interface ProfilerRow {
|
|
@@ -54,3 +54,31 @@ export declare function createProfilerView(options: {
|
|
|
54
54
|
rowHeight?: number;
|
|
55
55
|
}): ProfilerView;
|
|
56
56
|
export declare const profilerPanel: Panel<ProfilerWorld, ProfilerView>;
|
|
57
|
+
/**
|
|
58
|
+
* A `PassTimings` labelled with the engine's own GPU brackets.
|
|
59
|
+
*
|
|
60
|
+
* **The engine fills no timings for anybody.** `PassTimings` is a container and
|
|
61
|
+
* `RendererApi.gpuTimer` is a source, and joining the two is the same twenty lines wherever it is
|
|
62
|
+
* written — the slots are `GPU_SLOTS` and never the game's, because a second backend measuring the
|
|
63
|
+
* same frame owes the same three numbers. So it is written here once rather than in every consumer
|
|
64
|
+
* that wants a frame readout, which is the argument that put the overlay in this package.
|
|
65
|
+
*
|
|
66
|
+
* Labelled and unsampled, so every row shows `—` until a frame resolves. That is the distinction
|
|
67
|
+
* `profilerRows` exists to keep: a bracket nothing has measured yet is not a bracket that ran in no
|
|
68
|
+
* time.
|
|
69
|
+
*/
|
|
70
|
+
export declare function createGpuPassTimings(): PassTimings;
|
|
71
|
+
/**
|
|
72
|
+
* Write one resolved sample over the three rows.
|
|
73
|
+
*
|
|
74
|
+
* **It resets first, and the first draft did not.** `recordPassSample` accumulates on purpose,
|
|
75
|
+
* because a pass may be sampled several times in one frame — a shadow pass runs per cascade — and
|
|
76
|
+
* the row a reader wants is what that pass cost altogether. A `GpuSample` is the opposite: it is
|
|
77
|
+
* one resolved frame's three totals, already summed by the timer. Adding it to what was there left
|
|
78
|
+
* the rows climbing forever, which reads as a leak in whatever the game last changed rather than as
|
|
79
|
+
* a defect in the readout. `resetPassTimings` is how the engine's own frame starts, and the labels
|
|
80
|
+
* survive it, which is why the rows do not flicker.
|
|
81
|
+
*
|
|
82
|
+
* A caller wanting an average across frames has `FrameHistory` for it.
|
|
83
|
+
*/
|
|
84
|
+
export declare function recordGpuSample(timings: PassTimings, sample: GpuSample): void;
|
package/dist/profiler.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* a frame it was making worse.
|
|
17
17
|
*/
|
|
18
18
|
import { addUiChild, createUiNode } from '@driftengine/ui2d';
|
|
19
|
-
import { passLabel, passMs } from '@driftengine/core';
|
|
19
|
+
import { GPU_SLOTS, createPassTimings, passLabel, passMs, recordPassLabel, recordPassSample, resetPassTimings, } from '@driftengine/core';
|
|
20
20
|
import { residentCount } from '@driftengine/texture';
|
|
21
21
|
import { createPanelRoot, emptyPanel } from './panel.js';
|
|
22
22
|
/** `—` for unmeasured. Two decimals, because a pass under a hundredth is not the one to look at. */
|
|
@@ -140,3 +140,42 @@ export const profilerPanel = {
|
|
|
140
140
|
return null;
|
|
141
141
|
},
|
|
142
142
|
};
|
|
143
|
+
/**
|
|
144
|
+
* A `PassTimings` labelled with the engine's own GPU brackets.
|
|
145
|
+
*
|
|
146
|
+
* **The engine fills no timings for anybody.** `PassTimings` is a container and
|
|
147
|
+
* `RendererApi.gpuTimer` is a source, and joining the two is the same twenty lines wherever it is
|
|
148
|
+
* written — the slots are `GPU_SLOTS` and never the game's, because a second backend measuring the
|
|
149
|
+
* same frame owes the same three numbers. So it is written here once rather than in every consumer
|
|
150
|
+
* that wants a frame readout, which is the argument that put the overlay in this package.
|
|
151
|
+
*
|
|
152
|
+
* Labelled and unsampled, so every row shows `—` until a frame resolves. That is the distinction
|
|
153
|
+
* `profilerRows` exists to keep: a bracket nothing has measured yet is not a bracket that ran in no
|
|
154
|
+
* time.
|
|
155
|
+
*/
|
|
156
|
+
export function createGpuPassTimings() {
|
|
157
|
+
const timings = createPassTimings(GPU_SLOTS.length);
|
|
158
|
+
for (let slot = 0; slot < GPU_SLOTS.length; slot += 1) {
|
|
159
|
+
recordPassLabel(timings, slot, GPU_SLOTS[slot] ?? '');
|
|
160
|
+
}
|
|
161
|
+
return timings;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Write one resolved sample over the three rows.
|
|
165
|
+
*
|
|
166
|
+
* **It resets first, and the first draft did not.** `recordPassSample` accumulates on purpose,
|
|
167
|
+
* because a pass may be sampled several times in one frame — a shadow pass runs per cascade — and
|
|
168
|
+
* the row a reader wants is what that pass cost altogether. A `GpuSample` is the opposite: it is
|
|
169
|
+
* one resolved frame's three totals, already summed by the timer. Adding it to what was there left
|
|
170
|
+
* the rows climbing forever, which reads as a leak in whatever the game last changed rather than as
|
|
171
|
+
* a defect in the readout. `resetPassTimings` is how the engine's own frame starts, and the labels
|
|
172
|
+
* survive it, which is why the rows do not flicker.
|
|
173
|
+
*
|
|
174
|
+
* A caller wanting an average across frames has `FrameHistory` for it.
|
|
175
|
+
*/
|
|
176
|
+
export function recordGpuSample(timings, sample) {
|
|
177
|
+
resetPassTimings(timings);
|
|
178
|
+
recordPassSample(timings, 0, sample.shadows);
|
|
179
|
+
recordPassSample(timings, 1, sample.reflection);
|
|
180
|
+
recordPassSample(timings, 2, sample.rest);
|
|
181
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@driftengine/tools",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "The editor panels a shipped game can carry, and the command stack that makes their edits undoable",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
],
|
|
28
28
|
"sideEffects": false,
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@driftengine/core": "4.
|
|
31
|
-
"@driftengine/editor": "4.
|
|
32
|
-
"@driftengine/network": "4.
|
|
33
|
-
"@driftengine/texture": "4.
|
|
34
|
-
"@driftengine/ui2d": "4.
|
|
30
|
+
"@driftengine/core": "4.1.0",
|
|
31
|
+
"@driftengine/editor": "4.1.0",
|
|
32
|
+
"@driftengine/network": "4.1.0",
|
|
33
|
+
"@driftengine/texture": "4.1.0",
|
|
34
|
+
"@driftengine/ui2d": "4.1.0"
|
|
35
35
|
},
|
|
36
36
|
"author": "Drift Technologies",
|
|
37
37
|
"repository": {
|
package/src/index.ts
CHANGED
|
@@ -95,6 +95,7 @@ export {
|
|
|
95
95
|
export type { FrameHistory, ProfilerRow, ProfilerView, ProfilerWorld } from './profiler.ts';
|
|
96
96
|
export {
|
|
97
97
|
createFrameHistory,
|
|
98
|
+
createGpuPassTimings,
|
|
98
99
|
createProfilerView,
|
|
99
100
|
formatMs,
|
|
100
101
|
historyValues,
|
|
@@ -102,6 +103,7 @@ export {
|
|
|
102
103
|
profilerPanel,
|
|
103
104
|
profilerRows,
|
|
104
105
|
pushFrame,
|
|
106
|
+
recordGpuSample,
|
|
105
107
|
} from './profiler.ts';
|
|
106
108
|
|
|
107
109
|
export type { NetworkReadout, NetworkView, NetworkWorld, RollbackHistory } from './network.ts';
|
|
@@ -113,3 +115,25 @@ export {
|
|
|
113
115
|
pushRollback,
|
|
114
116
|
rollbackValues,
|
|
115
117
|
} from './network.ts';
|
|
118
|
+
|
|
119
|
+
/*
|
|
120
|
+
* What mounts the four panels over a running game, on a key.
|
|
121
|
+
*
|
|
122
|
+
* **The panels shipped in 4.0.0 with nothing to show them**, which is the gap this closes: every
|
|
123
|
+
* panel above builds a `UiNode` tree and stops there, and the dock, layout, painting and routing
|
|
124
|
+
* that turn a tree into something a person sees lived only in the editor application, which is
|
|
125
|
+
* private. A consumer wanting an in-game inspector had to write that half again, and the six
|
|
126
|
+
* writing it six times is the drift `AGENTS.md` opens by describing.
|
|
127
|
+
*
|
|
128
|
+
* `OverlayPainter` is four calls, so a host with a 2D context, a host drawing through a sprite
|
|
129
|
+
* pass and a host with no screen at all each satisfy it — which is the platform rule applied to
|
|
130
|
+
* drawing, and what keeps this testable with no graphics device anywhere near it.
|
|
131
|
+
*/
|
|
132
|
+
export type {
|
|
133
|
+
OverlayPainter,
|
|
134
|
+
OverlaySite,
|
|
135
|
+
PanelBinding,
|
|
136
|
+
ToolsOverlay,
|
|
137
|
+
ToolsOverlayOptions,
|
|
138
|
+
} from './overlay.ts';
|
|
139
|
+
export { bindPanel, createToolsOverlay, paintOverlay } from './overlay.ts';
|
package/src/overlay.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panels, on a key, over a running game.
|
|
3
|
+
*
|
|
4
|
+
* **The panels shipped without anything to mount them, and that was the gap.** `@driftengine/tools`
|
|
5
|
+
* exported an inspector, a console, a profiler and a network panel, each of which builds a `UiNode`
|
|
6
|
+
* tree and nothing more. What turned a tree into something a person could see lived in the editor
|
|
7
|
+
* application — a dock, a layout, a painter and the event routing — and that workspace is private
|
|
8
|
+
* and ships to nobody. So every consumer wanting an in-game inspector had to write that half again,
|
|
9
|
+
* and six of them writing it six times is the drift `AGENTS.md` opens by describing.
|
|
10
|
+
*
|
|
11
|
+
* **A column, not a dock.** The editor's dock splits, drags and persists because somebody arranges
|
|
12
|
+
* an editor and then keeps that arrangement. Nobody arranges a debug overlay: they press a key,
|
|
13
|
+
* read a number and press it again. So the panels stack down one edge in the order they were
|
|
14
|
+
* given, and what a dock would have bought is not bought.
|
|
15
|
+
*
|
|
16
|
+
* **The painter is supplied, never reached for.** Four calls — a rectangle, a line of text, a clip
|
|
17
|
+
* and its close — which is every operation `paintOverlay` makes. A host with a 2D context
|
|
18
|
+
* implements them in four lines, a host drawing through `@driftengine/ui2d`'s sprite pass
|
|
19
|
+
* implements them over a batch, and a host with no screen at all implements them into an array,
|
|
20
|
+
* which is what this module's own tests do. That is `AGENTS.md`'s platform rule applied to
|
|
21
|
+
* drawing: take the capability as a parameter, and let the consumer decide what it is.
|
|
22
|
+
*
|
|
23
|
+
* **Nothing here needs a graphics device**, which is the property that makes the whole of it
|
|
24
|
+
* testable. The geometry is arithmetic over a tree, the routing is arithmetic over rectangles, and
|
|
25
|
+
* the drawing is somebody else's four functions. An overlay that could only run against a live
|
|
26
|
+
* adapter is an overlay whose behaviour nobody can assert.
|
|
27
|
+
*
|
|
28
|
+
* **Hidden costs a frame nothing.** While it is closed, `frame` returns before it builds anything
|
|
29
|
+
* and `route` refuses every event but the one that opens it, so a game that never presses the key
|
|
30
|
+
* pays for a boolean.
|
|
31
|
+
*/
|
|
32
|
+
import { createUiNode, layoutUiTree } from '@driftengine/ui2d';
|
|
33
|
+
import type { UiNode } from '@driftengine/ui2d';
|
|
34
|
+
import { createUndoStack, type Command, type UndoStack } from './command.ts';
|
|
35
|
+
import { createPanelRoot, type Panel, type UiEvent } from './panel.ts';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A panel with its world and its view already attached.
|
|
39
|
+
*
|
|
40
|
+
* **The generics are closed here rather than carried by the host.** `Panel<W, V>` is two type
|
|
41
|
+
* parameters per panel, and a host holding several at once cannot express them in one list without
|
|
42
|
+
* erasing to `unknown` and casting on the way out. Binding closes them where both types are known
|
|
43
|
+
* and hands back something with no parameters at all, so the host never casts and the panel keeps
|
|
44
|
+
* the signature that makes its own rules checkable.
|
|
45
|
+
*/
|
|
46
|
+
export interface PanelBinding {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly title: string;
|
|
49
|
+
build(root: UiNode): void;
|
|
50
|
+
route(event: UiEvent): Command | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function bindPanel<W, V>(
|
|
54
|
+
panel: Panel<W, V>,
|
|
55
|
+
world: () => Readonly<W>,
|
|
56
|
+
view: V,
|
|
57
|
+
): PanelBinding {
|
|
58
|
+
return {
|
|
59
|
+
id: panel.id,
|
|
60
|
+
title: panel.title,
|
|
61
|
+
build: (root: UiNode): void => panel.build(world(), view, root),
|
|
62
|
+
route: (event: UiEvent): Command | null => panel.route(world(), view, event),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Where one panel came out. Read rather than recomputed, so a hit test cannot disagree with a draw. */
|
|
67
|
+
export interface OverlaySite {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly x: number;
|
|
70
|
+
readonly y: number;
|
|
71
|
+
readonly w: number;
|
|
72
|
+
readonly h: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What a host draws with. Colours are CSS, `#rrggbb` or `#rrggbbaa`. */
|
|
76
|
+
export interface OverlayPainter {
|
|
77
|
+
rect(x: number, y: number, w: number, h: number, colour: string): void;
|
|
78
|
+
/** Text on a baseline at `y`, twelve pixels tall. */
|
|
79
|
+
text(content: string, x: number, y: number, colour: string): void;
|
|
80
|
+
clip(x: number, y: number, w: number, h: number): void;
|
|
81
|
+
unclip(): void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ToolsOverlayOptions {
|
|
85
|
+
readonly panels: readonly PanelBinding[];
|
|
86
|
+
/** `KeyboardEvent.key` of the key that opens and closes it. Defaults to `F3`. */
|
|
87
|
+
readonly key?: string;
|
|
88
|
+
readonly side?: 'left' | 'right';
|
|
89
|
+
readonly width?: number;
|
|
90
|
+
readonly undoLimit?: number;
|
|
91
|
+
readonly visible?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ToolsOverlay {
|
|
95
|
+
/** The tree the whole overlay is in. One absolutely placed child per panel. */
|
|
96
|
+
readonly root: UiNode;
|
|
97
|
+
readonly undo: UndoStack;
|
|
98
|
+
readonly visible: boolean;
|
|
99
|
+
/** How many times the tree has been rebuilt and laid out. Read by tests, and by nothing else. */
|
|
100
|
+
readonly layoutCount: number;
|
|
101
|
+
sites(): readonly OverlaySite[];
|
|
102
|
+
setVisible(visible: boolean): void;
|
|
103
|
+
toggle(): void;
|
|
104
|
+
resize(width: number, height: number): void;
|
|
105
|
+
frame(now: number): void;
|
|
106
|
+
/** Deliver one event. True where the overlay took it, so a host knows not to pass it on. */
|
|
107
|
+
route(event: UiEvent): boolean;
|
|
108
|
+
/** Say the world changed, so the next frame rebuilds. A profiler calls this every frame. */
|
|
109
|
+
invalidate(): void;
|
|
110
|
+
dispose(): void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const DEFAULT_WIDTH = 320;
|
|
114
|
+
const DEFAULT_KEY = 'F3';
|
|
115
|
+
|
|
116
|
+
export function createToolsOverlay(options: ToolsOverlayOptions): ToolsOverlay {
|
|
117
|
+
const root = createUiNode({ name: 'tools-overlay', width: 'grow', height: 'grow' });
|
|
118
|
+
const undo = createUndoStack(options.undoLimit ?? 100);
|
|
119
|
+
const key = options.key ?? DEFAULT_KEY;
|
|
120
|
+
const side = options.side ?? 'right';
|
|
121
|
+
const columnWidth = options.width ?? DEFAULT_WIDTH;
|
|
122
|
+
|
|
123
|
+
const panels = options.panels;
|
|
124
|
+
const roots = new Map<string, UiNode>();
|
|
125
|
+
/* Pooled and refilled: this is read every frame the overlay is open and must not allocate. */
|
|
126
|
+
const sites: OverlaySite[] = [];
|
|
127
|
+
|
|
128
|
+
let width = 0;
|
|
129
|
+
let height = 0;
|
|
130
|
+
let visible = options.visible ?? false;
|
|
131
|
+
let dirty = true;
|
|
132
|
+
let disposed = false;
|
|
133
|
+
let layoutCount = 0;
|
|
134
|
+
let focused: string | null = null;
|
|
135
|
+
|
|
136
|
+
const relayout = (): void => {
|
|
137
|
+
const x = side === 'left' ? 0 : Math.max(0, width - columnWidth);
|
|
138
|
+
const w = Math.min(columnWidth, width);
|
|
139
|
+
const each = panels.length === 0 ? 0 : Math.floor(height / panels.length);
|
|
140
|
+
|
|
141
|
+
root.absolute = true;
|
|
142
|
+
root.x = x;
|
|
143
|
+
root.y = 0;
|
|
144
|
+
root.width = w;
|
|
145
|
+
root.height = height;
|
|
146
|
+
root.children.length = 0;
|
|
147
|
+
sites.length = 0;
|
|
148
|
+
|
|
149
|
+
for (let at = 0; at < panels.length; at += 1) {
|
|
150
|
+
const panel = panels[at];
|
|
151
|
+
if (panel === undefined) continue;
|
|
152
|
+
/* The last panel takes the remainder, so a height that does not divide leaves no gap. */
|
|
153
|
+
const top = each * at;
|
|
154
|
+
const tall = at === panels.length - 1 ? height - top : each;
|
|
155
|
+
|
|
156
|
+
let panelRoot = roots.get(panel.id);
|
|
157
|
+
if (panelRoot === undefined) {
|
|
158
|
+
panelRoot = createPanelRoot(panel);
|
|
159
|
+
roots.set(panel.id, panelRoot);
|
|
160
|
+
}
|
|
161
|
+
panelRoot.absolute = true;
|
|
162
|
+
panelRoot.x = 0;
|
|
163
|
+
panelRoot.y = top;
|
|
164
|
+
panelRoot.width = w;
|
|
165
|
+
panelRoot.height = tall;
|
|
166
|
+
panel.build(panelRoot);
|
|
167
|
+
root.children.push(panelRoot);
|
|
168
|
+
sites.push({ id: panel.id, x: 0, y: top, w, h: tall });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
layoutUiTree(root, x, 0, w, height);
|
|
172
|
+
layoutCount += 1;
|
|
173
|
+
dirty = false;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
root,
|
|
178
|
+
undo,
|
|
179
|
+
get visible(): boolean {
|
|
180
|
+
return visible;
|
|
181
|
+
},
|
|
182
|
+
get layoutCount(): number {
|
|
183
|
+
return layoutCount;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
sites(): readonly OverlaySite[] {
|
|
187
|
+
if (visible && dirty) relayout();
|
|
188
|
+
return sites;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
setVisible(next: boolean): void {
|
|
192
|
+
if (disposed || next === visible) return;
|
|
193
|
+
visible = next;
|
|
194
|
+
dirty = true;
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
toggle(): void {
|
|
198
|
+
this.setVisible(!visible);
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
resize(next: number, nextHeight: number): void {
|
|
202
|
+
if (disposed) return;
|
|
203
|
+
if (next === width && nextHeight === height) return;
|
|
204
|
+
width = next;
|
|
205
|
+
height = nextHeight;
|
|
206
|
+
dirty = true;
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
frame(_now: number): void {
|
|
210
|
+
if (disposed || !visible) return;
|
|
211
|
+
if (dirty) relayout();
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
route(event: UiEvent): boolean {
|
|
215
|
+
if (disposed) return false;
|
|
216
|
+
/*
|
|
217
|
+
* The key is answered whether the overlay is open or closed, and it is the only thing that
|
|
218
|
+
* is. A host that had to know which state it was in to decide whether to forward a press
|
|
219
|
+
* would be holding the same boolean twice.
|
|
220
|
+
*/
|
|
221
|
+
if (event.kind === 'key' && event.key === key && !event.ctrl) {
|
|
222
|
+
visible = !visible;
|
|
223
|
+
dirty = true;
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
if (!visible) return false;
|
|
227
|
+
|
|
228
|
+
if (event.kind === 'key') {
|
|
229
|
+
if (event.ctrl && event.key === 'z') {
|
|
230
|
+
const moved = event.shift ? undo.redo() : undo.undo();
|
|
231
|
+
if (moved) dirty = true;
|
|
232
|
+
return moved;
|
|
233
|
+
}
|
|
234
|
+
if (event.ctrl && event.key === 'y') {
|
|
235
|
+
const moved = undo.redo();
|
|
236
|
+
if (moved) dirty = true;
|
|
237
|
+
return moved;
|
|
238
|
+
}
|
|
239
|
+
const target = panelWith(focused);
|
|
240
|
+
return target === null ? false : take(target.route(event));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (dirty) relayout();
|
|
244
|
+
const site = siteAt(event.x, event.y);
|
|
245
|
+
if (site === null) return false;
|
|
246
|
+
if (event.kind === 'pointer' && event.phase === 'down') focused = site.id;
|
|
247
|
+
const panel = panelWith(site.id);
|
|
248
|
+
if (panel === null) return false;
|
|
249
|
+
/* In the panel's own space: a panel never learns where on screen it is. */
|
|
250
|
+
return take(panel.route(shift(event, -(root.rect.x + site.x), -site.y)));
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
invalidate(): void {
|
|
254
|
+
dirty = true;
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
dispose(): void {
|
|
258
|
+
if (disposed) return;
|
|
259
|
+
disposed = true;
|
|
260
|
+
visible = false;
|
|
261
|
+
undo.clear();
|
|
262
|
+
root.children.length = 0;
|
|
263
|
+
roots.clear();
|
|
264
|
+
sites.length = 0;
|
|
265
|
+
focused = null;
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
function panelWith(id: string | null): PanelBinding | null {
|
|
270
|
+
if (id === null) return null;
|
|
271
|
+
for (const panel of panels) if (panel.id === id) return panel;
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function siteAt(x: number, y: number): OverlaySite | null {
|
|
276
|
+
const localX = x - root.rect.x;
|
|
277
|
+
if (localX < 0 || localX >= root.rect.w) return null;
|
|
278
|
+
for (const site of sites) {
|
|
279
|
+
if (y >= site.y && y < site.y + site.h) return site;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/*
|
|
285
|
+
* **`push` applies, so this must not.** `createUndoStack.push` calls `apply` on the way in — and
|
|
286
|
+
* on the merge path it says in so many words that an absorbed command has already been applied
|
|
287
|
+
* and must not be applied again. Applying here as well ran every panel edit twice, which the
|
|
288
|
+
* row panel's test saw as two rows added by one press and a real inspector would have shown as a
|
|
289
|
+
* field moved twice as far as the pointer.
|
|
290
|
+
*/
|
|
291
|
+
function take(command: Command | null): boolean {
|
|
292
|
+
if (command === null) return false;
|
|
293
|
+
undo.push(command);
|
|
294
|
+
dirty = true;
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The same event moved by a vector. Returns the event itself where there is nothing to move. */
|
|
300
|
+
function shift(event: UiEvent, dx: number, dy: number): UiEvent {
|
|
301
|
+
if (event.kind === 'pointer' || event.kind === 'wheel') {
|
|
302
|
+
return { ...event, x: event.x + dx, y: event.y + dy };
|
|
303
|
+
}
|
|
304
|
+
return event;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Draw the overlay, or draw nothing because it is closed.
|
|
309
|
+
*
|
|
310
|
+
* A free function rather than a method, so the overlay itself never holds a painter and a host may
|
|
311
|
+
* draw the same overlay into two surfaces — a screen and a capture — without either knowing.
|
|
312
|
+
*/
|
|
313
|
+
export function paintOverlay(painter: OverlayPainter, overlay: ToolsOverlay): void {
|
|
314
|
+
if (!overlay.visible) return;
|
|
315
|
+
paintNode(painter, overlay.root);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Where a twelve-pixel line sits inside a row. Rows here are sixteen to eighteen pixels tall. */
|
|
319
|
+
const TEXT_BASELINE = 12;
|
|
320
|
+
|
|
321
|
+
function paintNode(painter: OverlayPainter, node: UiNode): void {
|
|
322
|
+
if (node.hidden) return;
|
|
323
|
+
const { x, y, w, h } = node.rect;
|
|
324
|
+
if (node.background !== null) painter.rect(x, y, w, h, cssColour(node.background));
|
|
325
|
+
if (node.text.length > 0) {
|
|
326
|
+
painter.text(node.text, x, y + TEXT_BASELINE, cssColour(node.tint));
|
|
327
|
+
}
|
|
328
|
+
if (node.children.length === 0) return;
|
|
329
|
+
if (node.clip) painter.clip(x, y, w, h);
|
|
330
|
+
for (const child of node.children) paintNode(painter, child);
|
|
331
|
+
if (node.clip) painter.unclip();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function cssColour(rgba: Float32Array | null): string {
|
|
335
|
+
if (rgba === null) return '#ffffff';
|
|
336
|
+
let out = '#';
|
|
337
|
+
for (let at = 0; at < 3; at += 1) {
|
|
338
|
+
const channel = Math.round(Math.min(1, Math.max(0, rgba[at] ?? 0)) * 255);
|
|
339
|
+
out += channel.toString(16).padStart(2, '0');
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
package/src/profiler.ts
CHANGED
|
@@ -17,7 +17,17 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { addUiChild, createUiNode } from '@driftengine/ui2d';
|
|
19
19
|
import type { UiNode } from '@driftengine/ui2d';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
GPU_SLOTS,
|
|
22
|
+
createPassTimings,
|
|
23
|
+
passLabel,
|
|
24
|
+
passMs,
|
|
25
|
+
recordPassLabel,
|
|
26
|
+
recordPassSample,
|
|
27
|
+
resetPassTimings,
|
|
28
|
+
type GpuSample,
|
|
29
|
+
type PassTimings,
|
|
30
|
+
} from '@driftengine/core';
|
|
21
31
|
import { residentCount, type ResidencyTable } from '@driftengine/texture';
|
|
22
32
|
import type { Command } from './command.ts';
|
|
23
33
|
import { createPanelRoot, emptyPanel, type Panel } from './panel.ts';
|
|
@@ -195,3 +205,44 @@ export const profilerPanel: Panel<ProfilerWorld, ProfilerView> = {
|
|
|
195
205
|
return null;
|
|
196
206
|
},
|
|
197
207
|
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A `PassTimings` labelled with the engine's own GPU brackets.
|
|
211
|
+
*
|
|
212
|
+
* **The engine fills no timings for anybody.** `PassTimings` is a container and
|
|
213
|
+
* `RendererApi.gpuTimer` is a source, and joining the two is the same twenty lines wherever it is
|
|
214
|
+
* written — the slots are `GPU_SLOTS` and never the game's, because a second backend measuring the
|
|
215
|
+
* same frame owes the same three numbers. So it is written here once rather than in every consumer
|
|
216
|
+
* that wants a frame readout, which is the argument that put the overlay in this package.
|
|
217
|
+
*
|
|
218
|
+
* Labelled and unsampled, so every row shows `—` until a frame resolves. That is the distinction
|
|
219
|
+
* `profilerRows` exists to keep: a bracket nothing has measured yet is not a bracket that ran in no
|
|
220
|
+
* time.
|
|
221
|
+
*/
|
|
222
|
+
export function createGpuPassTimings(): PassTimings {
|
|
223
|
+
const timings = createPassTimings(GPU_SLOTS.length);
|
|
224
|
+
for (let slot = 0; slot < GPU_SLOTS.length; slot += 1) {
|
|
225
|
+
recordPassLabel(timings, slot, GPU_SLOTS[slot] ?? '');
|
|
226
|
+
}
|
|
227
|
+
return timings;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Write one resolved sample over the three rows.
|
|
232
|
+
*
|
|
233
|
+
* **It resets first, and the first draft did not.** `recordPassSample` accumulates on purpose,
|
|
234
|
+
* because a pass may be sampled several times in one frame — a shadow pass runs per cascade — and
|
|
235
|
+
* the row a reader wants is what that pass cost altogether. A `GpuSample` is the opposite: it is
|
|
236
|
+
* one resolved frame's three totals, already summed by the timer. Adding it to what was there left
|
|
237
|
+
* the rows climbing forever, which reads as a leak in whatever the game last changed rather than as
|
|
238
|
+
* a defect in the readout. `resetPassTimings` is how the engine's own frame starts, and the labels
|
|
239
|
+
* survive it, which is why the rows do not flicker.
|
|
240
|
+
*
|
|
241
|
+
* A caller wanting an average across frames has `FrameHistory` for it.
|
|
242
|
+
*/
|
|
243
|
+
export function recordGpuSample(timings: PassTimings, sample: GpuSample): void {
|
|
244
|
+
resetPassTimings(timings);
|
|
245
|
+
recordPassSample(timings, 0, sample.shadows);
|
|
246
|
+
recordPassSample(timings, 1, sample.reflection);
|
|
247
|
+
recordPassSample(timings, 2, sample.rest);
|
|
248
|
+
}
|