@aethervtt/shard-core 0.0.1 → 0.2.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/dist/ecs/host-write.d.ts +5 -0
- package/dist/ecs/host-write.js +24 -0
- package/dist/ecs/index.d.ts +1 -0
- package/dist/ecs/index.js +10 -0
- package/dist/ecs/owners.d.ts +113 -0
- package/dist/ecs/owners.js +297 -0
- package/dist/ecs/world.d.ts +31 -0
- package/dist/ecs/world.js +67 -0
- package/dist/math/index.d.ts +1 -0
- package/dist/math/index.js +2 -0
- package/dist/math/polygon.d.ts +17 -0
- package/dist/math/polygon.js +307 -0
- package/dist/scheduler/capture/capture.d.ts +192 -0
- package/dist/scheduler/capture/capture.js +410 -0
- package/dist/scheduler/capture/index.d.ts +3 -0
- package/dist/scheduler/capture/index.js +17 -0
- package/dist/scheduler/capture/recorder.d.ts +83 -0
- package/dist/scheduler/capture/recorder.js +224 -0
- package/dist/scheduler/capture/samples.d.ts +49 -0
- package/dist/scheduler/capture/samples.js +91 -0
- package/dist/scheduler/index.d.ts +3 -1
- package/dist/scheduler/index.js +29 -2
- package/dist/scheduler/profiler.d.ts +180 -5
- package/dist/scheduler/profiler.js +430 -17
- package/dist/scheduler/schedule.d.ts +5 -1
- package/dist/scheduler/schedule.js +33 -3
- package/dist/schema/component.d.ts +8 -0
- package/dist/schema/component.js +3 -2
- package/dist/schema/field.d.ts +12 -0
- package/dist/schema/field.js +1 -0
- package/dist/schema/resource.d.ts +9 -0
- package/dist/schema/resource.js +3 -1
- package/dist/test-env.d.ts +107 -10
- package/dist/test-env.js +72 -4
- package/package.json +6 -2
|
@@ -1,22 +1,281 @@
|
|
|
1
|
+
import { ShardError } from "../error.js";
|
|
1
2
|
import { defineResource } from "../schema/resource.js";
|
|
3
|
+
function defineSpan(name) {
|
|
4
|
+
return { name, id: -1 };
|
|
5
|
+
}
|
|
6
|
+
const TRACK = { main: 0, gpu: 1, async: 2, gc: 3, worker: 4 };
|
|
7
|
+
function trackName(track) {
|
|
8
|
+
if (track === TRACK.main) return "main";
|
|
9
|
+
if (track === TRACK.gpu) return "gpu";
|
|
10
|
+
if (track === TRACK.async) return "async";
|
|
11
|
+
if (track === TRACK.gc) return "gc";
|
|
12
|
+
return "worker";
|
|
13
|
+
}
|
|
14
|
+
function spanCovers(key, name) {
|
|
15
|
+
if (name === key) return true;
|
|
16
|
+
const n = key.length;
|
|
17
|
+
if (n === 0 || name.length <= n || !name.startsWith(key)) return false;
|
|
18
|
+
return key.charCodeAt(n - 1) === SLASH || name.charCodeAt(n) === SLASH;
|
|
19
|
+
}
|
|
20
|
+
const SLASH = 47;
|
|
21
|
+
const MAX_SPANS = 65536;
|
|
22
|
+
const MAX_DEPTH = 64;
|
|
23
|
+
const ASYNC_SLOTS = 256;
|
|
24
|
+
const spanNames = [];
|
|
25
|
+
const spanTracks = [];
|
|
26
|
+
const spanIds = /* @__PURE__ */ new Map();
|
|
27
|
+
function internSpan(name, track = defaultTrack(name)) {
|
|
28
|
+
const known = spanIds.get(name);
|
|
29
|
+
if (known !== void 0) return known;
|
|
30
|
+
const id = spanNames.length;
|
|
31
|
+
if (id >= MAX_SPANS) {
|
|
32
|
+
throw new ShardError("perf/too-many-spans", `More than ${MAX_SPANS} span names`, {
|
|
33
|
+
hint: 'Span names are fixed strings (e.g. "terrain/encode"), not per-entity or per-frame text.'
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
spanNames.push(name);
|
|
37
|
+
spanTracks.push(track);
|
|
38
|
+
spanIds.set(name, id);
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
function spanCount() {
|
|
42
|
+
return spanNames.length;
|
|
43
|
+
}
|
|
44
|
+
function spanId(name) {
|
|
45
|
+
return spanIds.get(name) ?? -1;
|
|
46
|
+
}
|
|
47
|
+
function spanName(id) {
|
|
48
|
+
return spanNames[id] ?? `span#${id}`;
|
|
49
|
+
}
|
|
50
|
+
function spanTrack(id) {
|
|
51
|
+
return spanTracks[id] ?? TRACK.main;
|
|
52
|
+
}
|
|
53
|
+
function defaultTrack(name) {
|
|
54
|
+
if (name.startsWith("gpu:")) return TRACK.gpu;
|
|
55
|
+
if (name.startsWith("worker/")) return TRACK.worker;
|
|
56
|
+
if (name === "gc" || name.startsWith("gc/")) return TRACK.gc;
|
|
57
|
+
return TRACK.main;
|
|
58
|
+
}
|
|
59
|
+
function idOf(span, track) {
|
|
60
|
+
if (typeof span === "string") return spanIds.get(span) ?? internSpan(span, track);
|
|
61
|
+
let id = span.id;
|
|
62
|
+
if (id < 0) {
|
|
63
|
+
id = internSpan(span.name, track);
|
|
64
|
+
span.id = id;
|
|
65
|
+
}
|
|
66
|
+
return id;
|
|
67
|
+
}
|
|
68
|
+
const ProfilerSettings = defineResource("core/ProfilerSettings", {
|
|
69
|
+
description: "Turns the profiler (0074) on or off, and how many runs its aggregates cover. Off, spans cost a branch.",
|
|
70
|
+
hostWritable: true,
|
|
71
|
+
init: () => ({ enabled: true, window: 120 })
|
|
72
|
+
});
|
|
73
|
+
const host = globalThis;
|
|
74
|
+
function defaultNow() {
|
|
75
|
+
return host.performance ? host.performance.now() : Date.now();
|
|
76
|
+
}
|
|
77
|
+
let measuredClock;
|
|
78
|
+
function clockInfo() {
|
|
79
|
+
if (measuredClock) return measuredClock;
|
|
80
|
+
let resolution = Number.POSITIVE_INFINITY;
|
|
81
|
+
let prev = defaultNow();
|
|
82
|
+
let steps = 0;
|
|
83
|
+
for (let i = 0; i < 2e6 && steps < 20; i++) {
|
|
84
|
+
const t = defaultNow();
|
|
85
|
+
if (t !== prev) {
|
|
86
|
+
if (t - prev < resolution) resolution = t - prev;
|
|
87
|
+
prev = t;
|
|
88
|
+
steps++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const isolated = host.document === void 0 ? true : host.crossOriginIsolated === true;
|
|
92
|
+
measuredClock = {
|
|
93
|
+
resolutionMs: Number.isFinite(resolution) ? Math.round(resolution * 1e6) / 1e6 : 0,
|
|
94
|
+
isolated
|
|
95
|
+
};
|
|
96
|
+
return measuredClock;
|
|
97
|
+
}
|
|
2
98
|
class Profiler {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
99
|
+
settings;
|
|
100
|
+
/** The clock, in ms. */
|
|
101
|
+
now;
|
|
102
|
+
/** Receives every span while a capture runs. */
|
|
103
|
+
sink;
|
|
104
|
+
/** Gets `perf/span-mismatch` and similar warnings; the app logs them. */
|
|
105
|
+
onWarning;
|
|
106
|
+
/** `Time.frame` of the frame running now (or that ran last). */
|
|
107
|
+
frame = 0;
|
|
108
|
+
/** The last span `end` measured, in ms. */
|
|
109
|
+
lastMs = 0;
|
|
110
|
+
/** What the renderer said about GPU timing: `unavailable` without timestamp queries. */
|
|
111
|
+
gpu = {
|
|
112
|
+
status: "unknown",
|
|
113
|
+
quantized: false
|
|
114
|
+
};
|
|
115
|
+
windowSize;
|
|
116
|
+
series = [];
|
|
117
|
+
stackIds = new Int32Array(MAX_DEPTH);
|
|
118
|
+
stackStarts = new Float64Array(MAX_DEPTH);
|
|
119
|
+
depth = 0;
|
|
120
|
+
frameStartAt = 0;
|
|
121
|
+
warned = new Uint8Array(256);
|
|
122
|
+
asyncIds = new Int32Array(ASYNC_SLOTS).fill(-1);
|
|
123
|
+
asyncKeys = new Array(ASYNC_SLOTS);
|
|
124
|
+
asyncStarts = new Float64Array(ASYNC_SLOTS);
|
|
125
|
+
asyncCount = 0;
|
|
126
|
+
frameSpan = defineSpan("frame");
|
|
127
|
+
constructor(options = {}) {
|
|
128
|
+
const o = typeof options === "number" ? { settings: { enabled: true, window: options } } : options;
|
|
129
|
+
this.settings = o.settings ?? { enabled: true, window: 120 };
|
|
130
|
+
this.now = o.now ?? defaultNow;
|
|
131
|
+
this.windowSize = Math.max(1, this.settings.window | 0);
|
|
132
|
+
}
|
|
133
|
+
get window() {
|
|
134
|
+
return this.windowSize;
|
|
135
|
+
}
|
|
136
|
+
get enabled() {
|
|
137
|
+
return this.settings.enabled;
|
|
7
138
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
139
|
+
// --- spans -------------------------------------------------------------------
|
|
140
|
+
/** Starts a span on the main track. Returns a token for `end`; -1 when the profiler is off. */
|
|
141
|
+
begin(span) {
|
|
142
|
+
if (!this.settings.enabled) return -1;
|
|
143
|
+
const id = span.id >= 0 ? span.id : idOf(span, TRACK.main);
|
|
144
|
+
const d = this.depth;
|
|
145
|
+
if (d >= MAX_DEPTH) {
|
|
146
|
+
this.warn(id, WARN_DEPTH);
|
|
147
|
+
return -1;
|
|
13
148
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
149
|
+
this.stackIds[d] = id;
|
|
150
|
+
this.depth = d + 1;
|
|
151
|
+
this.stackStarts[d] = this.now();
|
|
152
|
+
return d * MAX_SPANS + id;
|
|
153
|
+
}
|
|
154
|
+
/** Ends the span `begin` returned `token` for, recording it. */
|
|
155
|
+
end(token) {
|
|
156
|
+
if (token < 0) return;
|
|
157
|
+
const at = this.now();
|
|
158
|
+
const d = token / MAX_SPANS | 0;
|
|
159
|
+
const id = token - d * MAX_SPANS;
|
|
160
|
+
if (d !== this.depth - 1 || this.stackIds[d] !== id) {
|
|
161
|
+
this.warn(id, WARN_ORDER);
|
|
162
|
+
if (d >= this.depth || this.stackIds[d] !== id) return;
|
|
163
|
+
}
|
|
164
|
+
this.depth = d;
|
|
165
|
+
const start = this.stackStarts[d];
|
|
166
|
+
const ms = at - start;
|
|
167
|
+
this.lastMs = ms;
|
|
168
|
+
this.push(id, ms);
|
|
169
|
+
const sink = this.sink;
|
|
170
|
+
if (sink !== void 0) sink.event(id, TRACK.main, d, start, ms, -1);
|
|
17
171
|
}
|
|
172
|
+
/** Drops the span `token` and everything begun inside it, recording nothing (an exception unwound it). */
|
|
173
|
+
cancel(token) {
|
|
174
|
+
if (token < 0) return;
|
|
175
|
+
const d = token / MAX_SPANS | 0;
|
|
176
|
+
if (d < this.depth) this.depth = d;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Time measured elsewhere (a GPU pass, a worker job): its aggregate, and a timeline event on
|
|
180
|
+
* `track` that ended now, or started at `start`. `frame`: the frame it belongs to, when it lands
|
|
181
|
+
* later (GPU timings).
|
|
182
|
+
*/
|
|
183
|
+
record(span, ms, track, start, frame = -1) {
|
|
184
|
+
if (!this.settings.enabled) return;
|
|
185
|
+
const id = idOf(span, track);
|
|
186
|
+
this.push(id, ms);
|
|
187
|
+
const sink = this.sink;
|
|
188
|
+
if (sink !== void 0) {
|
|
189
|
+
const t = track ?? spanTracks[id];
|
|
190
|
+
sink.event(id, t, t === TRACK.main ? this.depth : 0, start ?? this.now() - ms, ms, frame);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Only the aggregate (`render/<node>` sums its views into one sample a frame). */
|
|
194
|
+
sample(span, ms) {
|
|
195
|
+
if (!this.settings.enabled) return;
|
|
196
|
+
this.push(idOf(span), ms);
|
|
197
|
+
}
|
|
198
|
+
/** Only the timeline event, when a capture runs. */
|
|
199
|
+
event(span, track, start, ms, frame = -1) {
|
|
200
|
+
const sink = this.sink;
|
|
201
|
+
if (sink === void 0 || !this.settings.enabled) return;
|
|
202
|
+
sink.event(idOf(span, track), track, track === TRACK.main ? this.depth : 0, start, ms, frame);
|
|
203
|
+
}
|
|
204
|
+
/** Whether a capture is listening (callers skip work that only feeds the timeline). */
|
|
205
|
+
get capturing() {
|
|
206
|
+
return this.sink !== void 0;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Work that starts in one frame and ends in another (an asset load, a page fetch): an async span,
|
|
210
|
+
* matched to its `endAsync` by span and key.
|
|
211
|
+
*/
|
|
212
|
+
beginAsync(span, key) {
|
|
213
|
+
if (!this.settings.enabled) return;
|
|
214
|
+
const id = span.id >= 0 ? span.id : idOf(span, TRACK.async);
|
|
215
|
+
if (this.asyncCount >= ASYNC_SLOTS) {
|
|
216
|
+
this.warn(id, WARN_ASYNC);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (let i = 0; i < ASYNC_SLOTS; i++) {
|
|
220
|
+
if (this.asyncIds[i] !== -1) continue;
|
|
221
|
+
this.asyncIds[i] = id;
|
|
222
|
+
this.asyncKeys[i] = key;
|
|
223
|
+
this.asyncStarts[i] = this.now();
|
|
224
|
+
this.asyncCount++;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
endAsync(span, key) {
|
|
229
|
+
if (!this.settings.enabled || span.id < 0 || this.asyncCount === 0) return;
|
|
230
|
+
const id = span.id;
|
|
231
|
+
for (let i = 0; i < ASYNC_SLOTS; i++) {
|
|
232
|
+
if (this.asyncIds[i] !== id || this.asyncKeys[i] !== key) continue;
|
|
233
|
+
const start = this.asyncStarts[i];
|
|
234
|
+
const ms = this.now() - start;
|
|
235
|
+
this.asyncIds[i] = -1;
|
|
236
|
+
this.asyncKeys[i] = void 0;
|
|
237
|
+
this.asyncCount--;
|
|
238
|
+
this.push(id, ms);
|
|
239
|
+
const sink = this.sink;
|
|
240
|
+
if (sink !== void 0) sink.event(id, TRACK.async, 0, start, ms, -1);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// --- frames ------------------------------------------------------------------
|
|
245
|
+
/**
|
|
246
|
+
* A frame starts: spans left open from before are dropped (and reported once), and the `frame`
|
|
247
|
+
* span begins. Returns its token for `endFrame`.
|
|
248
|
+
*/
|
|
249
|
+
beginFrame(frame) {
|
|
250
|
+
if (!this.settings.enabled) return -1;
|
|
251
|
+
if (this.depth > 0) {
|
|
252
|
+
const id = this.stackIds[this.depth - 1];
|
|
253
|
+
this.warn(id, WARN_OPEN_AT_START);
|
|
254
|
+
this.depth = 0;
|
|
255
|
+
}
|
|
256
|
+
if (this.settings.window !== this.windowSize) this.resize();
|
|
257
|
+
this.frame = frame;
|
|
258
|
+
const token = this.begin(this.frameSpan);
|
|
259
|
+
this.frameStartAt = this.stackStarts[0];
|
|
260
|
+
const sink = this.sink;
|
|
261
|
+
if (sink !== void 0) sink.frameStart(frame, this.frameStartAt);
|
|
262
|
+
return token;
|
|
263
|
+
}
|
|
264
|
+
endFrame(token) {
|
|
265
|
+
if (token < 0) return;
|
|
266
|
+
if (this.depth > 1) {
|
|
267
|
+
const id = this.stackIds[this.depth - 1];
|
|
268
|
+
this.warn(id, WARN_OPEN_AT_END);
|
|
269
|
+
this.depth = 1;
|
|
270
|
+
}
|
|
271
|
+
this.end(token);
|
|
272
|
+
const sink = this.sink;
|
|
273
|
+
if (sink !== void 0) sink.frameEnd(this.frame, this.lastMs);
|
|
274
|
+
}
|
|
275
|
+
// --- reading -----------------------------------------------------------------
|
|
18
276
|
timing(name) {
|
|
19
|
-
const
|
|
277
|
+
const id = spanIds.get(name);
|
|
278
|
+
const series = id === void 0 ? void 0 : this.series[id];
|
|
20
279
|
if (!series || series.count === 0) return void 0;
|
|
21
280
|
let sum = 0;
|
|
22
281
|
let max = 0;
|
|
@@ -25,20 +284,174 @@ class Profiler {
|
|
|
25
284
|
sum += v;
|
|
26
285
|
if (v > max) max = v;
|
|
27
286
|
}
|
|
28
|
-
const last = series.samples[(series.cursor - 1 + this.
|
|
287
|
+
const last = series.samples[(series.cursor - 1 + this.windowSize) % this.windowSize];
|
|
29
288
|
return { last, avg: sum / series.count, max, samples: series.count };
|
|
30
289
|
}
|
|
290
|
+
/** A span's samples over the window, in ring order (not by age); undefined if it never ran. */
|
|
291
|
+
samples(name) {
|
|
292
|
+
const id = spanIds.get(name);
|
|
293
|
+
const series = id === void 0 ? void 0 : this.series[id];
|
|
294
|
+
return series?.count ? series.samples.subarray(0, series.count) : void 0;
|
|
295
|
+
}
|
|
296
|
+
/** Every span that ran, by name. */
|
|
31
297
|
all() {
|
|
32
298
|
const out = {};
|
|
33
|
-
for (
|
|
299
|
+
for (let id = 0; id < this.series.length; id++) {
|
|
300
|
+
if (!this.series[id]?.count) continue;
|
|
301
|
+
const name = spanNames[id];
|
|
302
|
+
out[name] = this.timing(name);
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* How many samples a span has had since the profiler started (or its aggregates were reset), by
|
|
308
|
+
* id: 0 if none. Allocates nothing, for per-frame readers such as feature budgets (0075).
|
|
309
|
+
*/
|
|
310
|
+
runsOf(id) {
|
|
311
|
+
return this.series[id]?.total ?? 0;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The span that was open around a span's latest sample (its parent on the main track), by id, or
|
|
315
|
+
* -1: what lets aggregates count time two covering keys share once (0075's slices).
|
|
316
|
+
*/
|
|
317
|
+
parentOf(id) {
|
|
318
|
+
return this.series[id]?.parent ?? -1;
|
|
319
|
+
}
|
|
320
|
+
/** A span's latest sample in ms, by id, or 0 if it has none. Allocates nothing. */
|
|
321
|
+
lastOf(id) {
|
|
322
|
+
const series = this.series[id];
|
|
323
|
+
if (series === void 0 || series.count === 0) return 0;
|
|
324
|
+
return series.samples[(series.cursor - 1 + this.windowSize) % this.windowSize];
|
|
325
|
+
}
|
|
326
|
+
/** Names of every span with samples. */
|
|
327
|
+
names() {
|
|
328
|
+
const out = [];
|
|
329
|
+
for (let id = 0; id < this.series.length; id++)
|
|
330
|
+
if (this.series[id]?.count) out.push(spanNames[id]);
|
|
34
331
|
return out;
|
|
35
332
|
}
|
|
333
|
+
/** Forgets every aggregate (`perf.reset`). */
|
|
334
|
+
reset() {
|
|
335
|
+
for (const series of this.series) {
|
|
336
|
+
if (!series) continue;
|
|
337
|
+
series.count = 0;
|
|
338
|
+
series.cursor = 0;
|
|
339
|
+
series.total = 0;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// --- internals ---------------------------------------------------------------
|
|
343
|
+
push(id, ms) {
|
|
344
|
+
let series = this.series[id];
|
|
345
|
+
if (series === void 0) series = this.createSeries(id);
|
|
346
|
+
series.samples[series.cursor] = ms;
|
|
347
|
+
series.cursor = series.cursor + 1 === this.windowSize ? 0 : series.cursor + 1;
|
|
348
|
+
if (series.count < this.windowSize) series.count++;
|
|
349
|
+
series.total++;
|
|
350
|
+
series.parent = this.depth > 0 ? this.stackIds[this.depth - 1] : -1;
|
|
351
|
+
}
|
|
352
|
+
createSeries(id) {
|
|
353
|
+
while (this.series.length <= id) this.series.push(void 0);
|
|
354
|
+
const series = {
|
|
355
|
+
samples: new Float64Array(this.windowSize),
|
|
356
|
+
cursor: 0,
|
|
357
|
+
count: 0,
|
|
358
|
+
total: 0,
|
|
359
|
+
parent: -1
|
|
360
|
+
};
|
|
361
|
+
this.series[id] = series;
|
|
362
|
+
return series;
|
|
363
|
+
}
|
|
364
|
+
resize() {
|
|
365
|
+
this.windowSize = Math.max(1, this.settings.window | 0);
|
|
366
|
+
for (let id = 0; id < this.series.length; id++) {
|
|
367
|
+
if (this.series[id])
|
|
368
|
+
this.series[id] = {
|
|
369
|
+
samples: new Float64Array(this.windowSize),
|
|
370
|
+
cursor: 0,
|
|
371
|
+
count: 0,
|
|
372
|
+
total: this.series[id].total,
|
|
373
|
+
parent: this.series[id].parent
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Reports a problem once per span name. */
|
|
378
|
+
warn(id, kind) {
|
|
379
|
+
if (id >= this.warned.length) {
|
|
380
|
+
const grown = new Uint8Array(Math.max(id + 1, this.warned.length * 2));
|
|
381
|
+
grown.set(this.warned);
|
|
382
|
+
this.warned = grown;
|
|
383
|
+
}
|
|
384
|
+
if (this.warned[id]) return;
|
|
385
|
+
this.warned[id] = 1;
|
|
386
|
+
this.onWarning?.(warningOf(kind, spanName(id)));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const WARN_ORDER = 0;
|
|
390
|
+
const WARN_OPEN_AT_START = 1;
|
|
391
|
+
const WARN_OPEN_AT_END = 2;
|
|
392
|
+
const WARN_DEPTH = 3;
|
|
393
|
+
const WARN_ASYNC = 4;
|
|
394
|
+
const MISMATCH = [
|
|
395
|
+
"ended out of order: spans end in the reverse order they began",
|
|
396
|
+
"was still open when a frame began",
|
|
397
|
+
"was still open when its frame ended"
|
|
398
|
+
];
|
|
399
|
+
function warningOf(kind, name) {
|
|
400
|
+
if (kind === WARN_DEPTH) {
|
|
401
|
+
return new ShardError(
|
|
402
|
+
"perf/span-overflow",
|
|
403
|
+
`Spans nest deeper than ${MAX_DEPTH} at "${name}"`,
|
|
404
|
+
{
|
|
405
|
+
hint: "A span that begins in a loop needs its end inside the loop too.",
|
|
406
|
+
path: name
|
|
407
|
+
}
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
if (kind === WARN_ASYNC) {
|
|
411
|
+
return new ShardError("perf/async-overflow", `More than ${ASYNC_SLOTS} async spans are open`, {
|
|
412
|
+
hint: "Every beginAsync(span, key) needs an endAsync(span, key), even when the work fails.",
|
|
413
|
+
path: name
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return new ShardError("perf/span-mismatch", `Span "${name}" ${MISMATCH[kind]}`, {
|
|
417
|
+
hint: "Every profiler.begin(span) needs one profiler.end(token), in the same frame.",
|
|
418
|
+
path: name
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
function spanStats(profiler, name) {
|
|
422
|
+
const timing = profiler.timing(name);
|
|
423
|
+
const samples = profiler.samples(name);
|
|
424
|
+
if (!timing || !samples) return void 0;
|
|
425
|
+
const sorted = Float64Array.from(samples).sort();
|
|
426
|
+
const at = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
|
|
427
|
+
const out = {
|
|
428
|
+
span: name,
|
|
429
|
+
track: trackName(spanTrack(spanIds.get(name))),
|
|
430
|
+
...timing,
|
|
431
|
+
p50: at(0.5),
|
|
432
|
+
p95: at(0.95)
|
|
433
|
+
};
|
|
434
|
+
const resolution = clockInfo().resolutionMs;
|
|
435
|
+
if (out.track !== "gpu" && resolution > 0 && timing.avg < 4 * resolution) out.coarse = true;
|
|
436
|
+
return out;
|
|
36
437
|
}
|
|
37
438
|
const ProfilerResource = defineResource("core/Profiler", {
|
|
38
|
-
description: "
|
|
439
|
+
description: "Span timings (0074): systems, schedules, frames, render passes, GPU and workers.",
|
|
39
440
|
init: () => new Profiler()
|
|
40
441
|
});
|
|
41
442
|
export {
|
|
42
443
|
Profiler,
|
|
43
|
-
ProfilerResource
|
|
444
|
+
ProfilerResource,
|
|
445
|
+
ProfilerSettings,
|
|
446
|
+
TRACK,
|
|
447
|
+
clockInfo,
|
|
448
|
+
defineSpan,
|
|
449
|
+
internSpan,
|
|
450
|
+
spanCount,
|
|
451
|
+
spanCovers,
|
|
452
|
+
spanId,
|
|
453
|
+
spanName,
|
|
454
|
+
spanStats,
|
|
455
|
+
spanTrack,
|
|
456
|
+
trackName
|
|
44
457
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { World } from '../ecs/world.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type Profiler } from './profiler.js';
|
|
3
3
|
import { SystemConfig, type SystemDef, type SystemSetConfig } from './system.js';
|
|
4
4
|
export interface ScheduleLabel {
|
|
5
5
|
readonly kind: 'schedule';
|
|
@@ -33,6 +33,10 @@ export interface SystemDescription {
|
|
|
33
33
|
*/
|
|
34
34
|
export declare class Schedule {
|
|
35
35
|
readonly label: ScheduleLabel;
|
|
36
|
+
/** `schedule/<Label>`: one run of the schedule (0074). */
|
|
37
|
+
private readonly span;
|
|
38
|
+
/** `commands/<Label>`: command application over one run. */
|
|
39
|
+
private readonly commandsSpan;
|
|
36
40
|
private readonly entries;
|
|
37
41
|
private readonly setConfigs;
|
|
38
42
|
private order;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Commands } from "../ecs/commands.js";
|
|
2
2
|
import { ShardError } from "../error.js";
|
|
3
|
+
import { defineSpan, TRACK } from "./profiler.js";
|
|
3
4
|
import {
|
|
4
5
|
conditionLabel,
|
|
5
6
|
SystemConfig
|
|
@@ -35,16 +36,23 @@ class Context {
|
|
|
35
36
|
}
|
|
36
37
|
class Schedule {
|
|
37
38
|
label;
|
|
39
|
+
/** `schedule/<Label>`: one run of the schedule (0074). */
|
|
40
|
+
span;
|
|
41
|
+
/** `commands/<Label>`: command application over one run. */
|
|
42
|
+
commandsSpan;
|
|
38
43
|
entries = [];
|
|
39
44
|
setConfigs = [];
|
|
40
45
|
order;
|
|
41
46
|
constructor(label) {
|
|
42
47
|
this.label = label;
|
|
48
|
+
this.span = defineSpan(`schedule/${label.name}`);
|
|
49
|
+
this.commandsSpan = defineSpan(`commands/${label.name}`);
|
|
43
50
|
}
|
|
44
51
|
add(system) {
|
|
45
52
|
const config = system instanceof SystemConfig ? system : new SystemConfig(system);
|
|
46
53
|
this.entries.push({
|
|
47
54
|
config,
|
|
55
|
+
span: defineSpan(config.system.name),
|
|
48
56
|
index: this.entries.length,
|
|
49
57
|
state: void 0,
|
|
50
58
|
ctx: void 0,
|
|
@@ -77,6 +85,9 @@ class Schedule {
|
|
|
77
85
|
}
|
|
78
86
|
run(world, options) {
|
|
79
87
|
const order = this.resolve();
|
|
88
|
+
const profiler = options.profiler;
|
|
89
|
+
const scheduleToken = profiler !== void 0 ? profiler.begin(this.span) : -1;
|
|
90
|
+
let commandsMs = 0;
|
|
80
91
|
for (let i = 0; i < order.length; i++) {
|
|
81
92
|
const entry = order[i];
|
|
82
93
|
const conditions = entry.conditions;
|
|
@@ -95,10 +106,11 @@ class Schedule {
|
|
|
95
106
|
}
|
|
96
107
|
const ctx = entry.ctx;
|
|
97
108
|
ctx.thisRunTick = world.incrementTick();
|
|
98
|
-
const
|
|
109
|
+
const token = profiler !== void 0 ? profiler.begin(entry.span) : -1;
|
|
99
110
|
try {
|
|
100
111
|
system.run(entry.state, world, ctx);
|
|
101
112
|
} catch (err) {
|
|
113
|
+
profiler?.cancel(scheduleToken);
|
|
102
114
|
const message = err instanceof Error ? err.message : String(err);
|
|
103
115
|
const code = err instanceof ShardError ? ` [${err.code}]` : "";
|
|
104
116
|
throw new ShardError(
|
|
@@ -110,10 +122,28 @@ class Schedule {
|
|
|
110
122
|
}
|
|
111
123
|
);
|
|
112
124
|
}
|
|
113
|
-
|
|
125
|
+
if (profiler !== void 0) profiler.end(token);
|
|
114
126
|
ctx.lastRunTick = ctx.thisRunTick;
|
|
115
127
|
world.incrementTick();
|
|
116
|
-
if (ctx.commands.length > 0)
|
|
128
|
+
if (ctx.commands.length > 0) {
|
|
129
|
+
if (profiler === void 0) ctx.commands.apply();
|
|
130
|
+
else {
|
|
131
|
+
const start = profiler.now();
|
|
132
|
+
try {
|
|
133
|
+
ctx.commands.apply();
|
|
134
|
+
} catch (err) {
|
|
135
|
+
profiler.cancel(scheduleToken);
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
const ms = profiler.now() - start;
|
|
139
|
+
commandsMs += ms;
|
|
140
|
+
profiler.event(this.commandsSpan, TRACK.main, start, ms);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (profiler !== void 0) {
|
|
145
|
+
profiler.sample(this.commandsSpan, commandsMs);
|
|
146
|
+
profiler.end(scheduleToken);
|
|
117
147
|
}
|
|
118
148
|
}
|
|
119
149
|
describe() {
|
|
@@ -25,6 +25,12 @@ export interface ComponentOptions {
|
|
|
25
25
|
* rebuilds itself, or that belongs to the session rather than the playthrough.
|
|
26
26
|
*/
|
|
27
27
|
save?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Only host code holding a grant writes it (ownership, 0061). Scene files, prefabs and the
|
|
30
|
+
* protocol refuse it, and `world.add`, `set` and `remove` throw `core/owner-not-authorable`
|
|
31
|
+
* outside the grant. Implies `serialize: false`.
|
|
32
|
+
*/
|
|
33
|
+
hostOnly?: boolean;
|
|
28
34
|
}
|
|
29
35
|
export interface ComponentDef<F extends Fields = Fields> {
|
|
30
36
|
readonly kind: 'component';
|
|
@@ -43,6 +49,8 @@ export interface ComponentDef<F extends Fields = Fields> {
|
|
|
43
49
|
readonly serializable: boolean;
|
|
44
50
|
/** Whether saved games include it: serializable, and not `save: false`. */
|
|
45
51
|
readonly saved: boolean;
|
|
52
|
+
/** Written only by host code holding a grant (0061). */
|
|
53
|
+
readonly hostOnly: boolean;
|
|
46
54
|
defaults(): InferFields<F>;
|
|
47
55
|
serialize(value: InferFields<F>): JsonObject;
|
|
48
56
|
/** Validates, then converts. Throws the first validation error. */
|
package/dist/schema/component.js
CHANGED
|
@@ -76,8 +76,9 @@ function buildSchema(name, fields, options, reuseId) {
|
|
|
76
76
|
version,
|
|
77
77
|
layout,
|
|
78
78
|
requires: options.requires ?? [],
|
|
79
|
-
serializable: options.serialize ?? true,
|
|
80
|
-
saved: (options.serialize ?? true) && (options.save ?? true),
|
|
79
|
+
serializable: !options.hostOnly && (options.serialize ?? true),
|
|
80
|
+
saved: !options.hostOnly && (options.serialize ?? true) && (options.save ?? true),
|
|
81
|
+
hostOnly: options.hostOnly ?? false,
|
|
81
82
|
defaults: () => defaultsOf(fields),
|
|
82
83
|
serialize: (value) => objectToJson(fields, value),
|
|
83
84
|
deserialize(json, ctx) {
|
package/dist/schema/field.d.ts
CHANGED
|
@@ -28,6 +28,18 @@ export interface FieldOptions<V> {
|
|
|
28
28
|
* resolves to the number on load. The JSON Schema lists the names as alternatives.
|
|
29
29
|
*/
|
|
30
30
|
presets?: Readonly<Record<string, number>>;
|
|
31
|
+
/**
|
|
32
|
+
* `false`: data only the host reads, kept out of GPU structs (`wgslLayout`), so adding such a
|
|
33
|
+
* field changes no shader.
|
|
34
|
+
*/
|
|
35
|
+
gpu?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Struct fields: the WGSL struct GPU structs (`wgslLayout`) pack it as, `module::path::Name`.
|
|
38
|
+
* That module declares it from the same fields. Without it a struct isn't GPU data.
|
|
39
|
+
*/
|
|
40
|
+
wgsl?: string;
|
|
41
|
+
/** Sample values (presets), listed as the JSON Schema's `examples`. */
|
|
42
|
+
examples?: readonly V[];
|
|
31
43
|
}
|
|
32
44
|
/** A reference to an asset. `guid` is the identity; `path` is for readers. */
|
|
33
45
|
export interface AssetRef<T extends string = string> {
|
package/dist/schema/field.js
CHANGED
|
@@ -19,6 +19,13 @@ export interface ResourceDef<T> {
|
|
|
19
19
|
readonly schema: ComponentDef | undefined;
|
|
20
20
|
/** Saved games include it (0038). Needs `schema`. */
|
|
21
21
|
readonly persist: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Host code (UI, protocol handlers) writes it between frames, through `world.patchResource` or
|
|
24
|
+
* `world.touchResource` (0052). The on-demand runner's write check watches it.
|
|
25
|
+
*/
|
|
26
|
+
readonly hostWritable: boolean;
|
|
27
|
+
/** Only host code writes it: the protocol's `resource.set` refuses it (ownership, 0061). */
|
|
28
|
+
readonly hostOnly: boolean;
|
|
22
29
|
readonly __type?: T;
|
|
23
30
|
}
|
|
24
31
|
export interface ResourceOptions<T> {
|
|
@@ -27,6 +34,8 @@ export interface ResourceOptions<T> {
|
|
|
27
34
|
reload?: 'keep' | 'replace';
|
|
28
35
|
schema?: ComponentDef;
|
|
29
36
|
persist?: boolean;
|
|
37
|
+
hostWritable?: boolean;
|
|
38
|
+
hostOnly?: boolean;
|
|
30
39
|
}
|
|
31
40
|
export declare function defineResource<T>(name: string, options?: ResourceOptions<T>): ResourceDef<T>;
|
|
32
41
|
/** Every resource defined in this process (latest definitions), sorted by name. */
|
package/dist/schema/resource.js
CHANGED
|
@@ -21,7 +21,9 @@ function defineResource(name, options = {}) {
|
|
|
21
21
|
init: options.init,
|
|
22
22
|
reload: options.reload ?? "keep",
|
|
23
23
|
schema: options.schema,
|
|
24
|
-
persist: options.persist ?? false
|
|
24
|
+
persist: options.persist ?? false,
|
|
25
|
+
hostWritable: options.hostWritable ?? false,
|
|
26
|
+
hostOnly: options.hostOnly ?? false
|
|
25
27
|
};
|
|
26
28
|
resources.set(name, def);
|
|
27
29
|
if (previous) {
|