@driftengine/tools 4.0.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/LICENSE +202 -0
- package/NOTICE +29 -0
- package/README.md +54 -0
- package/dist/command.d.ts +44 -0
- package/dist/command.js +53 -0
- package/dist/console.d.ts +75 -0
- package/dist/console.js +175 -0
- package/dist/divergence.d.ts +23 -0
- package/dist/divergence.js +36 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +9 -0
- package/dist/inspector.d.ts +86 -0
- package/dist/inspector.js +265 -0
- package/dist/network.d.ts +58 -0
- package/dist/network.js +120 -0
- package/dist/panel.d.ts +79 -0
- package/dist/panel.js +90 -0
- package/dist/profiler.d.ts +56 -0
- package/dist/profiler.js +142 -0
- package/dist/selection.d.ts +29 -0
- package/dist/selection.js +52 -0
- package/package.json +63 -0
- package/src/command.ts +103 -0
- package/src/console.ts +260 -0
- package/src/divergence.ts +39 -0
- package/src/index.ts +115 -0
- package/src/inspector.ts +350 -0
- package/src/network.ts +185 -0
- package/src/panel.ts +147 -0
- package/src/profiler.ts +197 -0
- package/src/selection.ts +73 -0
package/src/profiler.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the frame's milliseconds went, per pass.
|
|
3
|
+
*
|
|
4
|
+
* **Unmeasured is not zero, and this panel is the reason Wave 1A drew that line.** `passMs` returns
|
|
5
|
+
* null where nothing sampled a pass, and this renders it as `—`. A profiler that showed 0.00 there
|
|
6
|
+
* could not be told apart from a pass that genuinely ran in no time, and the person reading it
|
|
7
|
+
* would go and optimise a pass that never ran. A *measured* zero still reads as `0.00`, because
|
|
8
|
+
* that one is a fact.
|
|
9
|
+
*
|
|
10
|
+
* **A pass that did nothing this frame keeps its row.** That is what Wave 1A's labels persisting
|
|
11
|
+
* while its timings reset buys: the rows stop jumping about as quality settings toggle passes on
|
|
12
|
+
* and off, which is exactly when somebody is reading them.
|
|
13
|
+
*
|
|
14
|
+
* **Nothing here allocates on a rebuild.** This panel is rebuilt every frame by definition, so the
|
|
15
|
+
* rows and the nodes are both pooled and refilled; a profiler that allocated would be reporting on
|
|
16
|
+
* a frame it was making worse.
|
|
17
|
+
*/
|
|
18
|
+
import { addUiChild, createUiNode } from '@driftengine/ui2d';
|
|
19
|
+
import type { UiNode } from '@driftengine/ui2d';
|
|
20
|
+
import { passLabel, passMs, type PassTimings } from '@driftengine/core';
|
|
21
|
+
import { residentCount, type ResidencyTable } from '@driftengine/texture';
|
|
22
|
+
import type { Command } from './command.ts';
|
|
23
|
+
import { createPanelRoot, emptyPanel, type Panel } from './panel.ts';
|
|
24
|
+
|
|
25
|
+
export interface ProfilerRow {
|
|
26
|
+
label: string;
|
|
27
|
+
/** Milliseconds, or null where nothing measured this pass. Null is not zero. */
|
|
28
|
+
ms: number | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `—` for unmeasured. Two decimals, because a pass under a hundredth is not the one to look at. */
|
|
32
|
+
export function formatMs(ms: number | null): string {
|
|
33
|
+
return ms === null ? '—' : ms.toFixed(2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One row per *labelled* pass, oldest index first, written into `out`.
|
|
38
|
+
*
|
|
39
|
+
* A pass with no label was never part of this frame's composition, so it is not a row — as against
|
|
40
|
+
* a pass that has a label and no sample, which is a row reading `—`.
|
|
41
|
+
*/
|
|
42
|
+
export function profilerRows(timings: PassTimings, out: ProfilerRow[]): ProfilerRow[] {
|
|
43
|
+
let at = 0;
|
|
44
|
+
for (let pass = 0; pass < timings.labels.length; pass += 1) {
|
|
45
|
+
const label = passLabel(timings, pass);
|
|
46
|
+
if (label === '') continue;
|
|
47
|
+
const row = out[at] ?? { label: '', ms: null };
|
|
48
|
+
row.label = label;
|
|
49
|
+
row.ms = passMs(timings, pass);
|
|
50
|
+
out[at] = row;
|
|
51
|
+
at += 1;
|
|
52
|
+
}
|
|
53
|
+
out.length = at;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A fixed window of frame times, oldest first.
|
|
59
|
+
*
|
|
60
|
+
* A ring rather than a growing array, because this is pushed once a frame forever and the window
|
|
61
|
+
* is the only part anybody looks at.
|
|
62
|
+
*/
|
|
63
|
+
export interface FrameHistory {
|
|
64
|
+
readonly ms: Float64Array;
|
|
65
|
+
/** Where the next frame goes. */
|
|
66
|
+
head: number;
|
|
67
|
+
/** How many of `ms` are live, up to its length. */
|
|
68
|
+
filled: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createFrameHistory(capacity: number): FrameHistory {
|
|
72
|
+
return { ms: new Float64Array(Math.max(0, capacity)), head: 0, filled: 0 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function pushFrame(history: FrameHistory, ms: number): void {
|
|
76
|
+
const capacity = history.ms.length;
|
|
77
|
+
if (capacity === 0) return;
|
|
78
|
+
history.ms[history.head] = ms;
|
|
79
|
+
history.head = (history.head + 1) % capacity;
|
|
80
|
+
if (history.filled < capacity) history.filled += 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The window, oldest first. Allocates, so it is for a reader rather than for a frame. */
|
|
84
|
+
export function historyValues(history: FrameHistory): number[] {
|
|
85
|
+
const capacity = history.ms.length;
|
|
86
|
+
const out: number[] = [];
|
|
87
|
+
const first = history.filled < capacity ? 0 : history.head;
|
|
88
|
+
for (let at = 0; at < history.filled; at += 1) {
|
|
89
|
+
out.push(history.ms[(first + at) % capacity] as number);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* How full the page cache is, or null where there is no cache to be full.
|
|
96
|
+
*
|
|
97
|
+
* **Null rather than zero for a capacity of nothing.** A cache that is not there is not an empty
|
|
98
|
+
* cache, and a bar reading 0% would say the streamer had failed rather than that it was absent.
|
|
99
|
+
*/
|
|
100
|
+
export function occupancy(table: ResidencyTable | null): number | null {
|
|
101
|
+
if (table === null || table.capacity <= 0) return null;
|
|
102
|
+
return residentCount(table) / table.capacity;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ProfilerWorld {
|
|
106
|
+
readonly timings: PassTimings;
|
|
107
|
+
readonly history: FrameHistory;
|
|
108
|
+
readonly residency: ResidencyTable | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ProfilerView {
|
|
112
|
+
readonly root: UiNode;
|
|
113
|
+
rows: ProfilerRow[];
|
|
114
|
+
rowHeight: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createProfilerView(options: { rowHeight?: number }): ProfilerView {
|
|
118
|
+
return {
|
|
119
|
+
root: createPanelRoot(profilerPanel),
|
|
120
|
+
rows: [],
|
|
121
|
+
rowHeight: options.rowHeight ?? 18,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A pooled row, shown; rows past the end are hidden rather than removed, so the pool survives. */
|
|
126
|
+
function lineAt(root: UiNode, index: number, text: string, height: number): void {
|
|
127
|
+
const existing = root.children[index];
|
|
128
|
+
if (existing !== undefined) {
|
|
129
|
+
existing.hidden = false;
|
|
130
|
+
existing.text = text;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
addUiChild(
|
|
134
|
+
root,
|
|
135
|
+
createUiNode({ width: 'grow', height, text, interactive: false, name: `line:${index}` }),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hideFrom(root: UiNode, index: number): void {
|
|
140
|
+
for (let at = index; at < root.children.length; at += 1) {
|
|
141
|
+
(root.children[at] as UiNode).hidden = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The mean of the window, which is the number worth showing beside the current frame. */
|
|
146
|
+
function meanOf(history: FrameHistory): number | null {
|
|
147
|
+
if (history.filled === 0) return null;
|
|
148
|
+
let total = 0;
|
|
149
|
+
for (let at = 0; at < history.filled; at += 1) total += history.ms[at] as number;
|
|
150
|
+
return total / history.filled;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const profilerPanel: Panel<ProfilerWorld, ProfilerView> = {
|
|
154
|
+
id: 'profiler',
|
|
155
|
+
title: 'Profiler',
|
|
156
|
+
|
|
157
|
+
build(world, view, root): void {
|
|
158
|
+
view.rows = profilerRows(world.timings, view.rows);
|
|
159
|
+
/*
|
|
160
|
+
* **Nothing at all, rather than no *passes*.** A host with no device still knows how long its
|
|
161
|
+
* frames took, and this returned before the frame line was ever added — so a panel holding
|
|
162
|
+
* sixty samples said `No timings yet`. Reporting nothing where there is something is the same
|
|
163
|
+
* fault as reporting a zero where there is nothing, which the row formatter exists to avoid.
|
|
164
|
+
*/
|
|
165
|
+
if (view.rows.length === 0 && world.history.filled === 0) {
|
|
166
|
+
emptyPanel(root, 'No timings yet');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let at = 0;
|
|
171
|
+
for (const row of view.rows) {
|
|
172
|
+
lineAt(root, at, `${row.label} ${formatMs(row.ms)}`, view.rowHeight);
|
|
173
|
+
at += 1;
|
|
174
|
+
}
|
|
175
|
+
lineAt(
|
|
176
|
+
root,
|
|
177
|
+
at,
|
|
178
|
+
`frame ${formatMs(meanOf(world.history))} mean of ${world.history.filled}`,
|
|
179
|
+
view.rowHeight,
|
|
180
|
+
);
|
|
181
|
+
at += 1;
|
|
182
|
+
const full = occupancy(world.residency);
|
|
183
|
+
lineAt(
|
|
184
|
+
root,
|
|
185
|
+
at,
|
|
186
|
+
`residency ${full === null ? '—' : `${(full * 100).toFixed(0)}%`}`,
|
|
187
|
+
view.rowHeight,
|
|
188
|
+
);
|
|
189
|
+
at += 1;
|
|
190
|
+
hideFrom(root, at);
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
route(): Command | null {
|
|
194
|
+
/* A profiler reads. There is nothing here a person could ask the world to change. */
|
|
195
|
+
return null;
|
|
196
|
+
},
|
|
197
|
+
};
|
package/src/selection.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is selected, which of it is primary, and in what order.
|
|
3
|
+
*
|
|
4
|
+
* **Order is kept, and that is not cosmetic.** A multi-entity gizmo pivots on the primary
|
|
5
|
+
* selection; if the order drifts between frames the pivot jumps, and a drag that started around one
|
|
6
|
+
* object finishes around another. Insertion order with the primary as the most recent addition is
|
|
7
|
+
* both what a person expects and what keeps the pivot still.
|
|
8
|
+
*/
|
|
9
|
+
export interface Selection {
|
|
10
|
+
/** Selected entities, in the order they were added. */
|
|
11
|
+
entities: number[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createSelection(): Selection {
|
|
15
|
+
return { entities: [] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function selectOnly(selection: Selection, entity: number): void {
|
|
19
|
+
selection.entities.length = 0;
|
|
20
|
+
selection.entities.push(entity);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function addToSelection(selection: Selection, entity: number): void {
|
|
24
|
+
if (selection.entities.includes(entity)) return;
|
|
25
|
+
selection.entities.push(entity);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function toggleSelection(selection: Selection, entity: number): void {
|
|
29
|
+
const at = selection.entities.indexOf(entity);
|
|
30
|
+
if (at === -1) selection.entities.push(entity);
|
|
31
|
+
else selection.entities.splice(at, 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Select everything between two entities in a caller-supplied order.
|
|
36
|
+
*
|
|
37
|
+
* The order is the caller's because only the caller knows it — a scene tree's order is its own
|
|
38
|
+
* flattening, and an asset browser's is its filter and sort. Inclusive at both ends, and the same
|
|
39
|
+
* set whichever end is given first.
|
|
40
|
+
*/
|
|
41
|
+
export function selectRange(
|
|
42
|
+
selection: Selection,
|
|
43
|
+
from: number,
|
|
44
|
+
to: number,
|
|
45
|
+
order: readonly number[],
|
|
46
|
+
): void {
|
|
47
|
+
const a = order.indexOf(from);
|
|
48
|
+
const b = order.indexOf(to);
|
|
49
|
+
if (a === -1 || b === -1) return;
|
|
50
|
+
const lo = Math.min(a, b);
|
|
51
|
+
const hi = Math.max(a, b);
|
|
52
|
+
selection.entities.length = 0;
|
|
53
|
+
for (let i = lo; i <= hi; i += 1) selection.entities.push(order[i] as number);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function clearSelection(selection: Selection): void {
|
|
57
|
+
selection.entities.length = 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function selectedEntities(selection: Selection): readonly number[] {
|
|
61
|
+
return selection.entities;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isSelected(selection: Selection, entity: number): boolean {
|
|
65
|
+
return selection.entities.includes(entity);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The most recently added, which is what an inspector shows and what a gizmo pivots on. */
|
|
69
|
+
export function primarySelection(selection: Selection): number | null {
|
|
70
|
+
return selection.entities.length === 0
|
|
71
|
+
? null
|
|
72
|
+
: (selection.entities[selection.entities.length - 1] as number);
|
|
73
|
+
}
|