prts-terrarchive 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/GAME_ASSETS.md +26 -0
- package/LICENSE +21 -0
- package/README.en.md +274 -0
- package/README.md +423 -0
- package/THIRD_PARTY_NOTICES.md +61 -0
- package/bin/bake-rhine-details.mjs +34 -0
- package/bin/install.js +273 -0
- package/bin/pack-map-assets.mjs +110 -0
- package/bin/preview-rhine.mjs +71 -0
- package/bin/rhine-detail-baker.ts +84 -0
- package/contracts/cloud_tools_v1.schema.json +243 -0
- package/contracts/corpus_i18n_v1.schema.json +179 -0
- package/contracts/corpus_tools_v1.schema.json +1801 -0
- package/cordis.patch.yml +32 -0
- package/docs/endfield-official-i18n.md +103 -0
- package/lib/client.js +3912 -0
- package/lib/endfield-map/map.js.br +0 -0
- package/lib/endfield-map/map.js.gz +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.br +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.gz +0 -0
- package/lib/endfield-map/resources/map-01616f534aaaeca1.png +0 -0
- package/lib/endfield-map/resources/map-1562e0dff4be3d23.png +0 -0
- package/lib/endfield-map/resources/map-160fe040f080f6a3.png +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.br +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.gz +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.br +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.gz +0 -0
- package/lib/endfield-map/resources/map-1d30baaaef3e29cc.png +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.br +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.gz +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.br +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.gz +0 -0
- package/lib/endfield-map/resources/map-26caf8d71247abb7.png +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.br +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.gz +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.br +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.br +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.br +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.gz +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.br +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.gz +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.br +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.gz +0 -0
- package/lib/endfield-map/resources/map-3d486e887ae121ac.png +0 -0
- package/lib/endfield-map/resources/map-418dc4865608fce5.png +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.br +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.gz +0 -0
- package/lib/endfield-map/resources/map-4371259d3ab7aac7.png +0 -0
- package/lib/endfield-map/resources/map-48dcc0fd55e13f7f.png +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.br +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.gz +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.br +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.gz +0 -0
- package/lib/endfield-map/resources/map-5177e9d1d46f47e2.png +0 -0
- package/lib/endfield-map/resources/map-51baeb59b7f8c28d.png +0 -0
- package/lib/endfield-map/resources/map-549561b46e621874.png +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.br +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.gz +0 -0
- package/lib/endfield-map/resources/map-5a6963909423b674.png +0 -0
- package/lib/endfield-map/resources/map-5a9c533c0552d7b7.png +0 -0
- package/lib/endfield-map/resources/map-5d20ae9ffa9a02b0.png +0 -0
- package/lib/endfield-map/resources/map-626c8847393f60bf.png +0 -0
- package/lib/endfield-map/resources/map-63d43cbec3bed3b8.png +0 -0
- package/lib/endfield-map/resources/map-661b6ec56e722574.png +0 -0
- package/lib/endfield-map/resources/map-6ddd384eca190828.png +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.br +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.gz +0 -0
- package/lib/endfield-map/resources/map-738ebaa002c33cfc.png +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.br +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.gz +0 -0
- package/lib/endfield-map/resources/map-78929f26d6844509.png +0 -0
- package/lib/endfield-map/resources/map-7f09c142950a0f74.png +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.br +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.gz +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.br +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.gz +0 -0
- package/lib/endfield-map/resources/map-8d1ade3f01d952b0.png +0 -0
- package/lib/endfield-map/resources/map-8fef3495bb7ed60f.png +0 -0
- package/lib/endfield-map/resources/map-907a7ecf8a0ce352.png +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.br +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.gz +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.br +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-99722e61da5f3e53.png +0 -0
- package/lib/endfield-map/resources/map-9be9596cc059abe3.png +0 -0
- package/lib/endfield-map/resources/map-a4db9a4763e47fbf.png +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.br +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.gz +0 -0
- package/lib/endfield-map/resources/map-ab35d06875ee418f.png +0 -0
- package/lib/endfield-map/resources/map-ac51bb3e7623cd8b.png +0 -0
- package/lib/endfield-map/resources/map-ac830a66296bc83a.png +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.br +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8164f1af7c5d895.png +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.br +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.br +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.gz +0 -0
- package/lib/endfield-map/resources/map-bfc58277d5ba649d.png +0 -0
- package/lib/endfield-map/resources/map-c013f82048c6891a.png +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.br +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.gz +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.br +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.gz +0 -0
- package/lib/endfield-map/resources/map-c607f8b4c8ad8967.png +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.br +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.gz +0 -0
- package/lib/endfield-map/resources/map-d51adf5952af1e8e.png +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.br +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.gz +0 -0
- package/lib/endfield-map/resources/map-db11d394456a7465.png +0 -0
- package/lib/endfield-map/resources/map-dcc4d218581e3dc6.png +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.br +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-defe5db7dabf748a.png +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.br +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.gz +0 -0
- package/lib/endfield-map/resources/map-e25ee9af6e493500.png +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.br +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.gz +0 -0
- package/lib/endfield-map/resources/map-e694a91e630b1bd6.png +0 -0
- package/lib/endfield-map/resources/map-eaa93af79d12a87d.png +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.br +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.gz +0 -0
- package/lib/endfield-map/resources/map-ede421a329dc78a4.png +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.br +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.gz +0 -0
- package/lib/endfield-map/resources/map-fb5f4a59a181452c.png +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.br +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.gz +0 -0
- package/lib/rhine/assets/archive-assembly.glb +0 -0
- package/lib/rhine/assets/archive-cassette.glb +0 -0
- package/lib/rhine/fonts/MiSans-Bold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Demibold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Light.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Regular.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-license.pdf +0 -0
- package/lib/rhine/fonts/NOTICE.txt +6 -0
- package/lib/rhine/licenses/RhineLabUI-MIT.txt +21 -0
- package/lib/rhine/licenses/entities-BSD-2-Clause.txt +11 -0
- package/lib/rhine/licenses/linkify-it-MIT.txt +22 -0
- package/lib/rhine/licenses/markdown-it-MIT.txt +22 -0
- package/lib/rhine/licenses/mdurl-MIT.txt +45 -0
- package/lib/rhine/licenses/punycode.js-MIT.txt +20 -0
- package/lib/rhine/licenses/rolling-number-MIT.txt +21 -0
- package/lib/rhine/licenses/three-MIT.txt +21 -0
- package/lib/rhine/licenses/uc.micro-MIT.txt +20 -0
- package/lib/rhine/rhine.css +1 -0
- package/lib/rhine/rhine.js +5106 -0
- package/lib/skins/common.css +31 -0
- package/lib/skins/endfield-aic.css +141 -0
- package/lib/skins/prts-agent.css +136 -0
- package/lib/skins/rhine-lab.css +70 -0
- package/package.json +112 -0
- package/presets/definition.js +26 -0
- package/presets/prts/agent.cordis.yml +25 -0
- package/presets/prts/preset.yml +3 -0
- package/presets/register.js +86 -0
- package/resources/entity-routing.json +21 -0
- package/skills/prts-investigation/SKILL.md +121 -0
- package/skills/prts-retrieval/SKILL.md +35 -0
- package/skills/prts-retrieval/references/cloud-tools.md +13 -0
- package/skills/prts-retrieval/references/diagnostics.md +15 -0
- package/skills/prts-retrieval/references/local-tools.md +24 -0
- package/skills/prts-retrieval/references/module-arknights.md +23 -0
- package/skills/prts-retrieval/references/module-dual.md +15 -0
- package/skills/prts-retrieval/references/module-endfield.md +21 -0
- package/skills/prts-retrieval/references/retrieval-process.md +44 -0
- package/skills/prts-retrieval/references/retrieval-recipes-dual.md +34 -0
- package/skills/prts-retrieval/references/retrieval-recipes-endfield.md +37 -0
- package/skills/prts-retrieval/references/retrieval-recipes.md +39 -0
- package/skills/prts-retrieval/references/tools-runtime.md +48 -0
- package/skills/prts-retrieval/references/wiki-schema.md +38 -0
- package/src/archive-presentation.js +287 -0
- package/src/cloud-projection.js +124 -0
- package/src/cloud.js +498 -0
- package/src/entity-recognizer.js +311 -0
- package/src/entity-routing.js +82 -0
- package/src/evidence-state.js +332 -0
- package/src/i18n.js +365 -0
- package/src/index.js +1261 -0
- package/src/installer.js +1362 -0
- package/src/investigation-review.js +87 -0
- package/src/investigation-store.js +579 -0
- package/src/investigation-tools.js +113 -0
- package/src/localization-format.js +23 -0
- package/src/manual-search.js +103 -0
- package/src/read.js +1097 -0
- package/src/release-compatibility.js +73 -0
- package/src/release-watch.js +40 -0
- package/src/search-projection.js +139 -0
- package/src/search.js +1522 -0
- package/src/skill.js +98 -0
- package/src/source-map.js +348 -0
- package/src/state.js +314 -0
- package/src/store.js +1569 -0
- package/src/timeline.js +386 -0
- package/src/ui.js +1353 -0
- package/src/wiki.js +111 -0
- package/tsconfig.rhine.json +15 -0
- package/ui/rhine/archive-navigation-limit.ts +31 -0
- package/ui/rhine/archive-rack.ts +83 -0
- package/ui/rhine/assets/evidence-observatory.webp +0 -0
- package/ui/rhine/board-plane-transform.ts +24 -0
- package/ui/rhine/catalogue.ts +72 -0
- package/ui/rhine/deferred-preparation.ts +146 -0
- package/ui/rhine/evidence-board-controls.css +22 -0
- package/ui/rhine/evidence-board-history.ts +73 -0
- package/ui/rhine/evidence-board-model.ts +189 -0
- package/ui/rhine/evidence-board-panel.ts +762 -0
- package/ui/rhine/evidence-board-scene.ts +548 -0
- package/ui/rhine/evidence-board.css +191 -0
- package/ui/rhine/evidence-card-art.ts +470 -0
- package/ui/rhine/evidence-card-style.ts +17 -0
- package/ui/rhine/evidence-inbox-scene.ts +43 -0
- package/ui/rhine/evidence-inbox.css +29 -0
- package/ui/rhine/evidence-inbox.ts +104 -0
- package/ui/rhine/index.ts +12 -0
- package/ui/rhine/interaction.css +20 -0
- package/ui/rhine/investigation-board-plane.ts +5 -0
- package/ui/rhine/investigation-board.css +34 -0
- package/ui/rhine/investigation-board.ts +348 -0
- package/ui/rhine/investigation-edits.ts +46 -0
- package/ui/rhine/investigation-layout.ts +29 -0
- package/ui/rhine/label-prefetch.ts +34 -0
- package/ui/rhine/looping-title.ts +40 -0
- package/ui/rhine/manual-search.css +41 -0
- package/ui/rhine/manual-search.ts +89 -0
- package/ui/rhine/original/appearance.ts +180 -0
- package/ui/rhine/original/archive-label.ts +84 -0
- package/ui/rhine/original/archive-lighting.ts +39 -0
- package/ui/rhine/original/archive-loop.ts +82 -0
- package/ui/rhine/original/archive-refill.ts +64 -0
- package/ui/rhine/original/array-detail-data.ts +2 -0
- package/ui/rhine/original/array-detail.ts +41 -0
- package/ui/rhine/original/array-interior.ts +411 -0
- package/ui/rhine/original/array-occlusion.ts +166 -0
- package/ui/rhine/original/array-shell.ts +38 -0
- package/ui/rhine/original/array-visibility.ts +309 -0
- package/ui/rhine/original/asset-url.ts +4 -0
- package/ui/rhine/original/brand.ts +12 -0
- package/ui/rhine/original/cassette-lod.ts +81 -0
- package/ui/rhine/original/data.ts +21 -0
- package/ui/rhine/original/decryption.ts +185 -0
- package/ui/rhine/original/detail-signature.ts +18 -0
- package/ui/rhine/original/glass-reveal.ts +45 -0
- package/ui/rhine/original/internal-optics.ts +70 -0
- package/ui/rhine/original/label-renderer.ts +119 -0
- package/ui/rhine/original/label-textures.ts +117 -0
- package/ui/rhine/original/model-viewer.ts +658 -0
- package/ui/rhine/original/motion.ts +116 -0
- package/ui/rhine/original/opaque-backfaces.ts +64 -0
- package/ui/rhine/original/program-preparation.ts +111 -0
- package/ui/rhine/original/quality-renderer.ts +97 -0
- package/ui/rhine/original/quality-settings.ts +111 -0
- package/ui/rhine/original/render-quality.ts +163 -0
- package/ui/rhine/original/scene.ts +2051 -0
- package/ui/rhine/original/shelf-interior.ts +142 -0
- package/ui/rhine/original/ui-transitions.ts +111 -0
- package/ui/rhine/original/viewer-camera.ts +92 -0
- package/ui/rhine/original/viewport-layout.ts +41 -0
- package/ui/rhine/original-ui.css +2334 -0
- package/ui/rhine/readability.css +46 -0
- package/ui/rhine/reading-object.ts +96 -0
- package/ui/rhine/reading-origin.ts +28 -0
- package/ui/rhine/reading-room.css +55 -0
- package/ui/rhine/report-markdown.ts +98 -0
- package/ui/rhine/scene-edge-navigation.ts +84 -0
- package/ui/rhine/scene.ts +1726 -0
- package/ui/rhine/shelf-layout.ts +38 -0
- package/ui/rhine/snapshot-update-gate.ts +22 -0
- package/ui/rhine/source-reading.ts +15 -0
- package/ui/rhine/source-selection.css +164 -0
- package/ui/rhine/temporary-performance-observers.ts +163 -0
- package/ui/rhine/temporary-performance-panel.ts +124 -0
- package/ui/rhine/temporary-performance.css +19 -0
- package/ui/rhine/temporary-performance.ts +552 -0
- package/ui/rhine/temporary-render-diagnostics.ts +105 -0
- package/ui/rhine/tool-activity.ts +69 -0
- package/ui/rhine/types.ts +219 -0
- package/ui/rhine/visible-scene-hit.ts +13 -0
- package/ui/rhine/workbench.css +818 -0
- package/ui/rhine/workbench.ts +2245 -0
- package/ui/rhine/workspace-travel.ts +32 -0
- package/vite.rhine.config.ts +19 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// TEMPORARY RHINE PROFILER — isolated so it can be removed after hardware testing.
|
|
2
|
+
import type { Texture, WebGLRenderer } from 'three';
|
|
3
|
+
import { installRenderDiagnostics, type DiagnosticPass } from './temporary-render-diagnostics.ts';
|
|
4
|
+
|
|
5
|
+
type TimerExtension = { TIME_ELAPSED_EXT: number; GPU_DISJOINT_EXT: number; QUERY_COUNTER_BITS_EXT: number };
|
|
6
|
+
export type GpuStatus = 'pending' | 'valid' | 'unsupported' | 'busy' | 'disjoint' | 'context-lost' | 'timeout' | 'error' | 'disabled';
|
|
7
|
+
export interface GpuReading { gpuMs: number | null; gpuStatus: GpuStatus; gpuQueryLatencyMs: number | null }
|
|
8
|
+
export interface RenderSample {
|
|
9
|
+
id: number; at: number; renderer: string; state: string;
|
|
10
|
+
gpuMode?: 'whole' | 'segments'; detailReason?: 'periodic' | 'interaction';
|
|
11
|
+
gpuStages?: Record<string, GpuReading>; passWork?: Record<string, { cpuMs: number; calls: number; triangles: number }>;
|
|
12
|
+
glWork?: Record<string, { calls: number; cpuMs: number; sourceBytes: number; sourcePixels: number; allocationBytes: number }>;
|
|
13
|
+
drawFamilies?: { family: string; part: string; material: string; phase: string; calls: number; triangles: number }[];
|
|
14
|
+
detailProbeMs?: number;
|
|
15
|
+
intervalMs: number | null; cpuMs: number; gpuMs: number | null; gpuStatus: GpuStatus;
|
|
16
|
+
calls: number; triangles: number; geometries: number; textures: number; programs: number;
|
|
17
|
+
width: number; height: number; failed: boolean;
|
|
18
|
+
rafAt: number | null; callbackIntervalMs: number | null; rafLagMs: number | null; outsideFrameMs: number | null;
|
|
19
|
+
submissions: Record<string, { calls: number; triangles: number }>;
|
|
20
|
+
stages: Record<string, number>; counters: Record<string, number | boolean>;
|
|
21
|
+
probeBeginMs: number; probeEndMs: number; gpuPending: number; gpuQueryLatencyMs: number | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Never read QUERY_RESULT until available; never finish/flush/readPixels to time a frame. */
|
|
25
|
+
export class AsyncGpuTimer {
|
|
26
|
+
private ext: TimerExtension | null;
|
|
27
|
+
private pending: { query: WebGLQuery; sample: GpuReading; submittedAt?: number }[] = [];
|
|
28
|
+
private free: WebGLQuery[] = [];
|
|
29
|
+
private active: { query: WebGLQuery; sample: GpuReading } | undefined;
|
|
30
|
+
readonly supported: boolean;
|
|
31
|
+
private gl: WebGL2RenderingContext;
|
|
32
|
+
private now: () => number;
|
|
33
|
+
private readonly capacity: number;
|
|
34
|
+
constructor(gl: WebGL2RenderingContext, now: () => number = () => performance.now(), capacity = 12) {
|
|
35
|
+
this.now = now; this.capacity = capacity;
|
|
36
|
+
this.gl = gl;
|
|
37
|
+
this.ext = gl.getExtension('EXT_disjoint_timer_query_webgl2');
|
|
38
|
+
this.supported = Boolean(this.ext && Number(gl.getQuery(this.ext.TIME_ELAPSED_EXT, this.ext.QUERY_COUNTER_BITS_EXT)) > 0);
|
|
39
|
+
if (!this.supported) this.ext = null;
|
|
40
|
+
if (this.ext) gl.getParameter(this.ext.GPU_DISJOINT_EXT); // Clear pre-capture disjoint state.
|
|
41
|
+
}
|
|
42
|
+
get waiting() { return this.pending.length; }
|
|
43
|
+
begin(sample: GpuReading) {
|
|
44
|
+
if (this.gl.isContextLost()) { sample.gpuStatus = 'context-lost'; return; }
|
|
45
|
+
if (!this.ext) { sample.gpuStatus = 'unsupported'; return; }
|
|
46
|
+
this.poll();
|
|
47
|
+
if (this.active || this.pending.length >= this.capacity || this.gl.getQuery(this.ext.TIME_ELAPSED_EXT, this.gl.CURRENT_QUERY)) {
|
|
48
|
+
sample.gpuStatus = 'busy'; return;
|
|
49
|
+
}
|
|
50
|
+
const query = this.free.pop() || this.gl.createQuery();
|
|
51
|
+
if (!query) { sample.gpuStatus = 'error'; return; }
|
|
52
|
+
this.gl.beginQuery(this.ext.TIME_ELAPSED_EXT, query);
|
|
53
|
+
sample.gpuStatus = 'pending';
|
|
54
|
+
this.active = { query, sample };
|
|
55
|
+
}
|
|
56
|
+
end(sample: GpuReading) {
|
|
57
|
+
if (this.active?.sample !== sample || !this.ext) return;
|
|
58
|
+
if (this.gl.isContextLost()) { this.dispose('context-lost'); return; }
|
|
59
|
+
this.gl.endQuery(this.ext.TIME_ELAPSED_EXT);
|
|
60
|
+
this.pending.push({ ...this.active, submittedAt: this.now() });
|
|
61
|
+
this.active = undefined;
|
|
62
|
+
}
|
|
63
|
+
poll() {
|
|
64
|
+
if (!this.ext || !this.pending.length) return;
|
|
65
|
+
if (this.gl.isContextLost()) { this.dispose('context-lost'); return; }
|
|
66
|
+
if (this.gl.getParameter(this.ext.GPU_DISJOINT_EXT)) {
|
|
67
|
+
for (const item of this.pending) {
|
|
68
|
+
item.sample.gpuStatus = 'disjoint';
|
|
69
|
+
this.free.push(item.query);
|
|
70
|
+
}
|
|
71
|
+
this.pending.length = 0;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Commands on this context are ordered; an unavailable oldest query is enough.
|
|
75
|
+
while (this.pending.length) {
|
|
76
|
+
const item = this.pending[0];
|
|
77
|
+
if (!this.gl.getQueryParameter(item.query, this.gl.QUERY_RESULT_AVAILABLE)) break;
|
|
78
|
+
const ms = Number(this.gl.getQueryParameter(item.query, this.gl.QUERY_RESULT)) / 1e6;
|
|
79
|
+
item.sample.gpuQueryLatencyMs = item.submittedAt === undefined ? null : this.now() - item.submittedAt;
|
|
80
|
+
item.sample.gpuMs = Number.isFinite(ms) && ms >= 0 ? ms : null;
|
|
81
|
+
item.sample.gpuStatus = item.sample.gpuMs === null ? 'error' : 'valid';
|
|
82
|
+
this.pending.shift(); this.free.push(item.query);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
dispose(reason: GpuStatus = 'timeout') {
|
|
86
|
+
if (this.active) {
|
|
87
|
+
if (!this.gl.isContextLost() && this.ext) this.gl.endQuery(this.ext.TIME_ELAPSED_EXT);
|
|
88
|
+
this.pending.push(this.active); this.active = undefined;
|
|
89
|
+
}
|
|
90
|
+
for (const item of this.pending) {
|
|
91
|
+
item.sample.gpuStatus = reason;
|
|
92
|
+
this.gl.deleteQuery(item.query);
|
|
93
|
+
}
|
|
94
|
+
for (const query of this.free) this.gl.deleteQuery(query);
|
|
95
|
+
this.pending.length = this.free.length = 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function distribution(values: number[]) {
|
|
100
|
+
if (!values.length) return { count: 0, mean: null, p50: null, p95: null, p99: null, max: null };
|
|
101
|
+
values.sort((a, b) => a - b);
|
|
102
|
+
const percentile = (p: number) => values[Math.max(0, Math.ceil(values.length * p) - 1)];
|
|
103
|
+
return { count: values.length, mean: values.reduce((sum, n) => sum + n, 0) / values.length,
|
|
104
|
+
p50: percentile(.5), p95: percentile(.95), p99: percentile(.99), max: values[values.length - 1] };
|
|
105
|
+
}
|
|
106
|
+
export function summarizeFrames(samples: RenderSample[]) {
|
|
107
|
+
const intervals = samples.flatMap(s => s.intervalMs === null ? [] : [s.intervalMs]);
|
|
108
|
+
const frame = distribution(intervals);
|
|
109
|
+
const gpuStatuses: Record<string, number> = {};
|
|
110
|
+
for (const sample of samples) gpuStatuses[sample.gpuStatus] = (gpuStatuses[sample.gpuStatus] || 0) + 1;
|
|
111
|
+
return { frames: samples.length, fps: frame.mean ? 1000 / frame.mean : null, frameMs: frame,
|
|
112
|
+
callbackMs: distribution(samples.flatMap(s => s.callbackIntervalMs == null ? [] : [s.callbackIntervalMs])),
|
|
113
|
+
rafLagMs: distribution(samples.flatMap(s => s.rafLagMs == null ? [] : [s.rafLagMs])),
|
|
114
|
+
outsideFrameMs: distribution(samples.flatMap(s => s.outsideFrameMs == null ? [] : [s.outsideFrameMs])),
|
|
115
|
+
stages: Object.fromEntries([...new Set(samples.flatMap(s => Object.keys(s.stages || {})))].map(name =>
|
|
116
|
+
[name, distribution(samples.flatMap(s => s.stages?.[name] == null ? [] : [s.stages[name]]))])),
|
|
117
|
+
probeMs: distribution(samples.map(s => (s.probeBeginMs || 0) + (s.probeEndMs || 0) + (s.detailProbeMs || 0))),
|
|
118
|
+
gpuQueryLatencyMs: distribution(samples.flatMap(s => s.gpuQueryLatencyMs == null ? [] : [s.gpuQueryLatencyMs])),
|
|
119
|
+
callbackOver50ms: samples.filter(s => (s.callbackIntervalMs || 0) > 50).length,
|
|
120
|
+
cpuMs: distribution(samples.map(s => s.cpuMs)),
|
|
121
|
+
gpuMs: distribution(samples.flatMap(s => s.gpuStatus === 'valid' && s.gpuMs !== null ? [s.gpuMs] : [])),
|
|
122
|
+
gpuModes: Object.fromEntries(['whole', 'segments'].map(mode => [mode, samples.filter(s => (s.gpuMode || 'whole') === mode).length])),
|
|
123
|
+
gpuStages: Object.fromEntries([...new Set(samples.flatMap(s => Object.keys(s.gpuStages || {})))].map(name => [name,
|
|
124
|
+
distribution(samples.flatMap(s => s.gpuStages?.[name]?.gpuStatus === 'valid' ? [s.gpuStages[name].gpuMs!] : []))])),
|
|
125
|
+
gpuStatuses, over33ms: intervals.filter(n => n > 33.34).length, over50ms: intervals.filter(n => n > 50).length,
|
|
126
|
+
calls: distribution(samples.map(s => s.calls)), triangles: distribution(samples.map(s => s.triangles)),
|
|
127
|
+
failedFrames: samples.filter(s => s.failed).length };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Bounded live display: never walk/sort the entire capture in the 1Hz panel. */
|
|
131
|
+
export function liveFrameSummary(samples: RenderSample[], end: number, windowMs = 2000, limit = 512) {
|
|
132
|
+
const recent: RenderSample[] = [];
|
|
133
|
+
for (let i = samples.length - 1; i >= 0 && recent.length < limit; i--) {
|
|
134
|
+
const sample = samples[i];
|
|
135
|
+
if (sample.at < end - windowMs) break;
|
|
136
|
+
recent.push(sample);
|
|
137
|
+
}
|
|
138
|
+
const values = (read: (s: RenderSample) => number | null) =>
|
|
139
|
+
distribution(recent.flatMap(s => { const value = read(s); return value == null ? [] : [value]; }));
|
|
140
|
+
return { frames: recent.length, windowMs, limit, truncated: recent.length === limit && samples.length > limit
|
|
141
|
+
&& samples[samples.length - limit - 1].at >= end - windowMs,
|
|
142
|
+
callbackMs: values(s => s.callbackIntervalMs), rafLagMs: values(s => s.rafLagMs),
|
|
143
|
+
outsideFrameMs: values(s => s.outsideFrameMs), cpuMs: values(s => s.cpuMs),
|
|
144
|
+
gpuMs: values(s => s.gpuStatus === 'valid' ? s.gpuMs : null),
|
|
145
|
+
probeMs: values(s => s.probeBeginMs + s.probeEndMs + (s.detailProbeMs || 0)),
|
|
146
|
+
callbackOver50ms: recent.filter(s => (s.callbackIntervalMs || 0) > 50).length,
|
|
147
|
+
over50ms: recent.filter(s => (s.intervalMs || 0) > 50).length };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type RendererRecord = { passes?: () => DiagnosticPass[]; restoreHooks?: () => void; frame?: FrameToken; name: string; renderer: WebGLRenderer; timer?: AsyncGpuTimer; events: AbortController; adapter?: Record<string, unknown>; snapshot?: () => unknown };
|
|
151
|
+
export type FrameToken = { gpuPart?: GpuReading; gpuStage?: string; detailed?: boolean; sample: RenderSample; record: RendererRecord; cpuStart: number; checkpointAt: number; lastCalls: number; lastTriangles: number; autoReset: boolean };
|
|
152
|
+
export type DiagnosticChannel = 'heartbeat' | 'longAnimationFrames' | 'interactionSummary' | 'interactions' | 'resources' | 'layoutShifts' | 'backgroundWork' | 'overhead' | 'uiWork' | 'loads' | 'slowWork' | 'resourceUpdates';
|
|
153
|
+
export type CaptureOptions = { gpuTiming?: boolean; detailed?: boolean };
|
|
154
|
+
const channelLimits: Record<DiagnosticChannel, number> = { heartbeat: 240, longAnimationFrames: 2000, interactions: 2000, interactionSummary: 2000, resources: 2000, layoutShifts: 1000, backgroundWork: 4000, overhead: 2000, uiWork: 6000, loads: 2000, slowWork: 2000, resourceUpdates: 4000 };
|
|
155
|
+
|
|
156
|
+
export class RenderPerformanceCapture {
|
|
157
|
+
running = false;
|
|
158
|
+
samples: RenderSample[] = [];
|
|
159
|
+
events: { at: number; kind: string; detail?: unknown }[] = [];
|
|
160
|
+
longTasks: { at: number; durationMs: number }[] = [];
|
|
161
|
+
longTasksSupported = false;
|
|
162
|
+
startedAt = 0;
|
|
163
|
+
stoppedAt = 0;
|
|
164
|
+
startedISO = '';
|
|
165
|
+
metadata: unknown;
|
|
166
|
+
endMetadata: unknown;
|
|
167
|
+
diagnostics: Record<DiagnosticChannel, { at: number; [key: string]: unknown }[]> = this.emptyDiagnostics();
|
|
168
|
+
capabilities: Record<string, { supported: boolean; active: boolean; reason?: string }> = {};
|
|
169
|
+
dropped: Record<string, number> = {};
|
|
170
|
+
options = { gpuTiming: true, detailed: true };
|
|
171
|
+
private burstRemaining = 0;
|
|
172
|
+
private detailFrames = 0;
|
|
173
|
+
private segmentSamples = new Set<RenderSample>();
|
|
174
|
+
private textures = new Map<Texture, { id: number; restore: () => void }>();
|
|
175
|
+
private textureSequence = 0;
|
|
176
|
+
longTaskMaxMs = 0;
|
|
177
|
+
private buckets = new Map<string, { at: number; [key: string]: unknown }>();
|
|
178
|
+
private pendingLoads = new Map<number, { id: number; kind: string; start: number; detail: Record<string, unknown> }>();
|
|
179
|
+
private loadRecords = new Map<number, { at: number; [key: string]: unknown }>();
|
|
180
|
+
private loadSequence = 0;
|
|
181
|
+
private loadHistory: Record<string, unknown>[] = [];
|
|
182
|
+
private initialLoading: unknown;
|
|
183
|
+
private lastCallback: { renderer: WebGLRenderer; start: number; end: number | null } | undefined;
|
|
184
|
+
private session = 0;
|
|
185
|
+
private emptyDiagnostics() { return Object.fromEntries(Object.keys(channelLimits).map(key => [key, []])) as unknown as typeof this.diagnostics; }
|
|
186
|
+
private records = new Map<WebGLRenderer, RendererRecord>();
|
|
187
|
+
private lastRaf: number | null = null;
|
|
188
|
+
private lastRenderer: WebGLRenderer | undefined;
|
|
189
|
+
private lastState = '';
|
|
190
|
+
private lastSize = '';
|
|
191
|
+
private limitMs = 60_000;
|
|
192
|
+
private stopReason = '';
|
|
193
|
+
private now: () => number;
|
|
194
|
+
constructor(now: () => number = () => performance.now()) { this.now = now; }
|
|
195
|
+
|
|
196
|
+
register(name: string, renderer: WebGLRenderer, snapshot?: () => unknown, passes?: () => DiagnosticPass[]) {
|
|
197
|
+
const record: RendererRecord = { name, renderer, snapshot, passes, events: new AbortController() };
|
|
198
|
+
this.records.set(renderer, record);
|
|
199
|
+
renderer.domElement.addEventListener('webglcontextlost', () => {
|
|
200
|
+
record.timer?.dispose('context-lost'); record.timer = undefined;
|
|
201
|
+
this.breakTimeline('context-lost');
|
|
202
|
+
}, { signal: record.events.signal });
|
|
203
|
+
renderer.domElement.addEventListener('webglcontextrestored', () => {
|
|
204
|
+
if (this.running) this.prepare(record);
|
|
205
|
+
this.breakTimeline('context-restored');
|
|
206
|
+
}, { signal: record.events.signal });
|
|
207
|
+
if (this.running) this.prepare(record);
|
|
208
|
+
return () => { record.restoreHooks?.(); record.timer?.dispose('context-lost'); record.events.abort(); this.records.delete(renderer); };
|
|
209
|
+
}
|
|
210
|
+
private prepare(record: RendererRecord) {
|
|
211
|
+
record.timer?.dispose(); record.restoreHooks?.();
|
|
212
|
+
const gl = record.renderer.getContext() as WebGL2RenderingContext;
|
|
213
|
+
record.timer = this.options.gpuTiming ? new AsyncGpuTimer(gl, this.now, this.options.detailed ? 96 : 12) : undefined;
|
|
214
|
+
if (this.options.detailed) record.restoreHooks = installRenderDiagnostics(record.renderer, () => record.frame,
|
|
215
|
+
stage => this.gpuStage(record.renderer, stage), record.passes?.() || [], this.now,
|
|
216
|
+
detail => this.diagnostic('resourceUpdates', { renderer: record.name, ...detail }));
|
|
217
|
+
const debug = gl.getExtension('WEBGL_debug_renderer_info');
|
|
218
|
+
const renderer = String(gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER) || 'unknown');
|
|
219
|
+
record.adapter = { name: record.name, renderer,
|
|
220
|
+
vendor: gl.getParameter(debug ? debug.UNMASKED_VENDOR_WEBGL : gl.VENDOR),
|
|
221
|
+
software: /swiftshader|llvmpipe|softpipe|software/i.test(renderer),
|
|
222
|
+
identityUnmasked: Boolean(debug), gpuTimerSupported: record.timer?.supported ?? null, gpuTimingEnabled: this.options.gpuTiming };
|
|
223
|
+
}
|
|
224
|
+
start(metadata: unknown, seconds = 60, options: CaptureOptions = {}) {
|
|
225
|
+
for (const record of this.records.values()) { record.timer?.dispose(); record.restoreHooks?.(); record.frame = undefined; }
|
|
226
|
+
this.restoreTextureHooks(); this.segmentSamples.clear(); this.burstRemaining = 0; this.detailFrames = 0;
|
|
227
|
+
this.samples = []; this.events = []; this.longTasks = [];
|
|
228
|
+
this.workStack = []; this.workSequence = 0;
|
|
229
|
+
this.diagnostics = this.emptyDiagnostics(); this.capabilities = {}; this.dropped = {};
|
|
230
|
+
this.buckets.clear(); this.loadRecords.clear(); this.longTaskMaxMs = 0;
|
|
231
|
+
this.options = { gpuTiming: options.gpuTiming !== false, detailed: options.detailed !== false }; this.lastCallback = undefined; this.session++;
|
|
232
|
+
this.metadata = metadata; this.endMetadata = undefined;
|
|
233
|
+
this.startedISO = new Date().toISOString(); this.stoppedAt = 0;
|
|
234
|
+
this.limitMs = Math.max(1, Math.min(120, seconds)) * 1000;
|
|
235
|
+
this.stopReason = ''; this.lastState = ''; this.lastSize = '';
|
|
236
|
+
// Extension discovery happens before the measured interval.
|
|
237
|
+
for (const record of this.records.values()) this.prepare(record);
|
|
238
|
+
this.startedAt = this.now(); this.running = true;
|
|
239
|
+
this.lastRaf = null; this.lastRenderer = undefined;
|
|
240
|
+
this.mark('start', { limitSeconds: this.limitMs / 1000 });
|
|
241
|
+
this.initialLoading = this.loadingState();
|
|
242
|
+
for (const load of this.pendingLoads.values()) this.recordLoad(load);
|
|
243
|
+
}
|
|
244
|
+
stop(reason = 'manual') {
|
|
245
|
+
if (!this.running) return;
|
|
246
|
+
this.mark('stop', reason); this.running = false;
|
|
247
|
+
this.stopReason = reason; this.stoppedAt = this.now();
|
|
248
|
+
for (const record of this.records.values()) { record.restoreHooks?.(); record.restoreHooks = undefined; }
|
|
249
|
+
this.restoreTextureHooks(); this.lastRaf = null; this.lastCallback = undefined;
|
|
250
|
+
}
|
|
251
|
+
mark(kind: string, detail?: unknown) {
|
|
252
|
+
if (!this.running) return;
|
|
253
|
+
if (['archive-navigation', 'location', 'detail', 'board-fullscreen', 'user-hitch-marker', 'automatic-scan-step'].includes(kind)) this.burstRemaining = 6;
|
|
254
|
+
if (this.events.length < 4000) this.events.push({ at: this.now() - this.startedAt, kind, detail });
|
|
255
|
+
else this.dropped.events = (this.dropped.events || 0) + 1;
|
|
256
|
+
}
|
|
257
|
+
breakTimeline(reason: string) { this.lastRaf = null; this.lastCallback = undefined; this.mark('pause-boundary', reason); }
|
|
258
|
+
diagnostic(channel: DiagnosticChannel, detail: Record<string, unknown>, startTime = this.now()) {
|
|
259
|
+
if (!this.startedISO || startTime < this.startedAt || startTime > (this.running ? this.now() : this.stoppedAt)) return;
|
|
260
|
+
const at = startTime - this.startedAt;
|
|
261
|
+
const list = this.diagnostics[channel];
|
|
262
|
+
// Separate 1s buckets by kind: observer traffic cannot consume panel/heartbeat capacity.
|
|
263
|
+
if (channel === 'overhead' || channel === 'layoutShifts' || channel === 'uiWork' || channel === 'interactionSummary') {
|
|
264
|
+
const key = [channel, Math.floor(at / 1000), detail.kind, detail.entryType, detail.visible, detail.hadRecentInput].join(':');
|
|
265
|
+
const value = Number(channel === 'layoutShifts' ? detail.value : detail.durationMs) || 0;
|
|
266
|
+
if (channel !== 'layoutShifts' && channel !== 'interactionSummary' && value >= 8)
|
|
267
|
+
this.diagnostic('slowWork', { ...detail, channel }, startTime);
|
|
268
|
+
const bucket = this.buckets.get(key);
|
|
269
|
+
if (bucket) {
|
|
270
|
+
bucket.count = Number(bucket.count) + 1; bucket.lastAt = Math.max(Number(bucket.lastAt), at);
|
|
271
|
+
bucket.firstAt = Math.min(Number(bucket.firstAt), at);
|
|
272
|
+
bucket.total = Number(bucket.total) + value;
|
|
273
|
+
bucket.entries = Number(bucket.entries) + (Number(detail.entries) || 0);
|
|
274
|
+
if (value > Number(bucket.max)) { bucket.max = value; bucket.worstAt = at; bucket.worst = detail; }
|
|
275
|
+
return bucket;
|
|
276
|
+
}
|
|
277
|
+
if (list.length >= channelLimits[channel]) { this.dropped[channel] = (this.dropped[channel] || 0) + 1; return; }
|
|
278
|
+
const next = { at: Math.floor(at / 1000) * 1000, firstAt: at, lastAt: at, count: 1,
|
|
279
|
+
kind: detail.kind, entryType: detail.entryType, visible: detail.visible, hadRecentInput: detail.hadRecentInput,
|
|
280
|
+
unit: channel === 'layoutShifts' ? 'layout-shift-value' : 'ms', total: value, max: value,
|
|
281
|
+
entries: Number(detail.entries) || 0, worstAt: at, worst: detail };
|
|
282
|
+
list.push(next); this.buckets.set(key, next); return next;
|
|
283
|
+
}
|
|
284
|
+
if (list.length < channelLimits[channel]) {
|
|
285
|
+
const entry = { ...detail, at }; list.push(entry); return entry;
|
|
286
|
+
}
|
|
287
|
+
this.dropped[channel] = (this.dropped[channel] || 0) + 1;
|
|
288
|
+
}
|
|
289
|
+
private recordLoad(load: { id: number; kind: string; start: number; detail: Record<string, unknown> }) {
|
|
290
|
+
const row = this.diagnostic('loads', { id: load.id, kind: load.kind, ...load.detail, status: 'pending',
|
|
291
|
+
startedBeforeCapture: load.start < this.startedAt, elapsedBeforeCaptureMs: Math.max(0, this.startedAt - load.start) },
|
|
292
|
+
Math.max(load.start, this.startedAt));
|
|
293
|
+
if (row) this.loadRecords.set(load.id, row);
|
|
294
|
+
}
|
|
295
|
+
/** Small lifecycle inventory survives Start; never retain request payloads. */
|
|
296
|
+
beginLoad(kind: string, detail: Record<string, unknown> = {}) {
|
|
297
|
+
const load = { id: ++this.loadSequence, kind, start: this.now(), detail };
|
|
298
|
+
this.pendingLoads.set(load.id, load);
|
|
299
|
+
if (this.running) this.recordLoad(load);
|
|
300
|
+
let finished = false;
|
|
301
|
+
return (status: 'complete' | 'error' | 'aborted' = 'complete') => {
|
|
302
|
+
if (finished) return; finished = true;
|
|
303
|
+
this.pendingLoads.delete(load.id);
|
|
304
|
+
const end = this.now(), durationMs = end - load.start;
|
|
305
|
+
this.loadHistory.push({ id: load.id, kind, ...detail, status, durationMs });
|
|
306
|
+
if (this.loadHistory.length > 64) this.loadHistory.shift();
|
|
307
|
+
const row = this.loadRecords.get(load.id);
|
|
308
|
+
if (row) Object.assign(row, { status, endAt: end - this.startedAt, durationMs, finishedAfterCapture: !this.running });
|
|
309
|
+
this.loadRecords.delete(load.id);
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
async trackLoad<T>(kind: string, task: () => Promise<T>, detail: Record<string, unknown> = {}): Promise<T> {
|
|
313
|
+
const done = this.beginLoad(kind, detail);
|
|
314
|
+
try { const result = await task(); done(); return result; }
|
|
315
|
+
catch (error) { done(error instanceof Error && error.name === 'AbortError' ? 'aborted' : 'error'); throw error; }
|
|
316
|
+
}
|
|
317
|
+
loadingState() { return { active: [...this.pendingLoads.values()].map(load => ({
|
|
318
|
+
id: load.id, kind: load.kind, ...load.detail, elapsedMs: this.now() - load.start })),
|
|
319
|
+
recent: this.loadHistory.slice() }; }
|
|
320
|
+
private workSequence = 0;
|
|
321
|
+
private workStack: { id: number; session: number }[] = [];
|
|
322
|
+
private traceSpan(kind: string, start: number, end: number) {
|
|
323
|
+
// Public operation categories only. Clearing the entry bounds the User Timing
|
|
324
|
+
// buffer; an attached browser trace still receives the timing measure.
|
|
325
|
+
if (!this.options.detailed || typeof performance === 'undefined' || typeof performance.measure !== 'function') return;
|
|
326
|
+
const name = `rhine:${kind}`;
|
|
327
|
+
try { performance.measure(name, { start, end }); performance.clearMeasures(name); } catch { /* optional trace correlation */ }
|
|
328
|
+
}
|
|
329
|
+
beginUiWork(kind: string, detail: Record<string, unknown> = {}) {
|
|
330
|
+
if (!this.running) return undefined;
|
|
331
|
+
const start = this.now(), session = this.session;
|
|
332
|
+
const parent = this.workStack.at(-1), span = { id: ++this.workSequence, session };
|
|
333
|
+
const parentId = parent?.session === session ? parent.id : null;
|
|
334
|
+
this.workStack.push(span);
|
|
335
|
+
let finished = false;
|
|
336
|
+
return () => {
|
|
337
|
+
if (finished) return; finished = true;
|
|
338
|
+
const index = this.workStack.indexOf(span); if (index >= 0) this.workStack.splice(index, 1);
|
|
339
|
+
if (this.session !== session) return;
|
|
340
|
+
const end = this.now(), durationMs = end - start;
|
|
341
|
+
this.diagnostic('uiWork', { kind, ...detail, spanId: span.id, parentId, durationMs }, start);
|
|
342
|
+
if (durationMs >= 8) this.traceSpan(kind, start, end);
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
measureWork<T>(kind: string, task: () => T): T {
|
|
346
|
+
const done = this.beginUiWork(kind);
|
|
347
|
+
try { return task(); } finally { done?.(); }
|
|
348
|
+
}
|
|
349
|
+
rendererStates() { return Object.fromEntries([...this.records.values()].map(r => [r.name, r.snapshot?.() ?? null])); }
|
|
350
|
+
beginWork(kind: string) {
|
|
351
|
+
if (!this.running) return undefined;
|
|
352
|
+
const start = this.now(), session = this.session;
|
|
353
|
+
// A task crossing into another capture must never contaminate that run.
|
|
354
|
+
return (succeeded: boolean, asynchronous = false) => {
|
|
355
|
+
if (session !== this.session) return;
|
|
356
|
+
this.diagnostic('backgroundWork', { kind, durationMs: this.now() - start, succeeded, asynchronous, finishedAfterCapture: !this.running }, start);
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
private restoreTextureHooks() {
|
|
360
|
+
for (const item of this.textures.values()) item.restore();
|
|
361
|
+
this.textures.clear(); this.textureSequence = 0;
|
|
362
|
+
}
|
|
363
|
+
/** Observe cached prints too: context restoration may upload without another paint. */
|
|
364
|
+
observeTexture(texture: Texture, family: 'hero-label' | 'moving-label' | 'shelf-label', cacheEntry?: number) {
|
|
365
|
+
if (!this.running || !this.options.detailed) return;
|
|
366
|
+
const session = this.session;
|
|
367
|
+
let item = this.textures.get(texture);
|
|
368
|
+
if (!item) {
|
|
369
|
+
if (this.textures.size >= 256) { this.dropped.textureHooks = (this.dropped.textureHooks || 0) + 1; return; }
|
|
370
|
+
const id = ++this.textureSequence, original = texture.onUpdate, capture = this;
|
|
371
|
+
const wrapped = function(this: Texture, ...args: Parameters<NonNullable<Texture['onUpdate']>>) {
|
|
372
|
+
original?.apply(this, args);
|
|
373
|
+
if (!capture.running || capture.session !== session) return;
|
|
374
|
+
const frame = [...capture.records.values()].find(r => r.frame)?.frame;
|
|
375
|
+
capture.diagnostic('resourceUpdates', { kind: 'texture-upload-complete', texture: id, family, cacheEntry, version: texture.version,
|
|
376
|
+
frame: frame?.sample.id ?? null, stage: frame?.gpuStage ?? (frame ? 'whole-frame' : 'outside-frame') });
|
|
377
|
+
};
|
|
378
|
+
texture.onUpdate = wrapped;
|
|
379
|
+
const release = () => { item?.restore(); this.textures.delete(texture); };
|
|
380
|
+
texture.addEventListener('dispose', release);
|
|
381
|
+
item = { id, restore: () => {
|
|
382
|
+
texture.removeEventListener('dispose', release);
|
|
383
|
+
if (texture.onUpdate === wrapped) texture.onUpdate = original;
|
|
384
|
+
} };
|
|
385
|
+
this.textures.set(texture, item);
|
|
386
|
+
}
|
|
387
|
+
return item;
|
|
388
|
+
}
|
|
389
|
+
/** Records dimensions/version only; never retains canvas pixels or document identifiers. */
|
|
390
|
+
texturePaint(texture: Texture, family: 'hero-label' | 'moving-label' | 'shelf-label', paint: () => void, cacheEntry?: number) {
|
|
391
|
+
if (!this.running || !this.options.detailed) { paint(); return; }
|
|
392
|
+
const start = this.now(); this.burstRemaining = Math.max(this.burstRemaining, 6);
|
|
393
|
+
const item = this.observeTexture(texture, family, cacheEntry);
|
|
394
|
+
if (!item) { paint(); return; }
|
|
395
|
+
try { paint(); } finally {
|
|
396
|
+
const image = texture.image as { width?: number; height?: number } | undefined;
|
|
397
|
+
this.diagnostic('resourceUpdates', { kind: 'texture-dirty', texture: item.id, family, cacheEntry, version: texture.version,
|
|
398
|
+
width: image?.width ?? 0, height: image?.height ?? 0, rgba8BaseBytes: (image?.width || 0) * (image?.height || 0) * 4,
|
|
399
|
+
generateMipmaps: texture.generateMipmaps, anisotropy: texture.anisotropy, minFilter: texture.minFilter, magFilter: texture.magFilter, cpuMs: this.now() - start }, start);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
gpuStage(renderer: WebGLRenderer, name: string) {
|
|
403
|
+
const token = this.records.get(renderer)?.frame;
|
|
404
|
+
if (!token?.detailed || token.gpuStage === name) return;
|
|
405
|
+
const begin = this.now();
|
|
406
|
+
token.gpuStage = name;
|
|
407
|
+
if (!token.sample.gpuStages) return;
|
|
408
|
+
if (token.gpuPart) token.record.timer?.end(token.gpuPart);
|
|
409
|
+
const part: GpuReading = { gpuMs: null, gpuStatus: 'unsupported', gpuQueryLatencyMs: null };
|
|
410
|
+
// Repeated pass names remain separate intervals, with no nested queries.
|
|
411
|
+
let key = name, suffix = 2;
|
|
412
|
+
while (token.sample.gpuStages[key]) key = name + ':' + suffix++;
|
|
413
|
+
token.sample.gpuStages[key] = part; token.gpuPart = part;
|
|
414
|
+
token.record.timer?.begin(part);
|
|
415
|
+
token.sample.detailProbeMs! += this.now() - begin;
|
|
416
|
+
}
|
|
417
|
+
private settleSegments() {
|
|
418
|
+
for (const sample of this.segmentSamples) {
|
|
419
|
+
const parts = Object.values(sample.gpuStages || {});
|
|
420
|
+
if (parts.some(p => p.gpuStatus === 'pending')) continue;
|
|
421
|
+
const invalid = parts.find(p => p.gpuStatus !== 'valid');
|
|
422
|
+
sample.gpuStatus = invalid?.gpuStatus || 'valid';
|
|
423
|
+
sample.gpuMs = invalid ? null : parts.reduce((sum, p) => sum + (p.gpuMs || 0), 0);
|
|
424
|
+
sample.gpuQueryLatencyMs = Math.max(0, ...parts.map(p => p.gpuQueryLatencyMs || 0));
|
|
425
|
+
this.segmentSamples.delete(sample);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
checkpoint(token: FrameToken | undefined, name: string) {
|
|
429
|
+
if (!token) return;
|
|
430
|
+
const now = this.now(); token.sample.stages[name] = (token.sample.stages[name] || 0) + now - token.checkpointAt;
|
|
431
|
+
token.checkpointAt = now;
|
|
432
|
+
const render = token.record.renderer.info.render;
|
|
433
|
+
const calls = render.calls - token.lastCalls, triangles = render.triangles - token.lastTriangles;
|
|
434
|
+
if (calls || triangles) token.sample.submissions[name] = { calls, triangles };
|
|
435
|
+
token.lastCalls = render.calls; token.lastTriangles = render.triangles;
|
|
436
|
+
}
|
|
437
|
+
addLongTask(startTime: number, duration: number) {
|
|
438
|
+
const end = this.running ? this.now() : this.stoppedAt;
|
|
439
|
+
if (startTime < this.startedAt || startTime > end) return;
|
|
440
|
+
this.traceSpan("longtask", startTime, startTime + duration);
|
|
441
|
+
this.longTaskMaxMs = Math.max(this.longTaskMaxMs, duration);
|
|
442
|
+
if (this.longTasks.length < 4000) this.longTasks.push({ at: startTime - this.startedAt, durationMs: duration });
|
|
443
|
+
else this.dropped.longTasks = (this.dropped.longTasks || 0) + 1;
|
|
444
|
+
}
|
|
445
|
+
begin(rafTime: number | null, renderer: WebGLRenderer, state: string): FrameToken | undefined {
|
|
446
|
+
if (!this.running) return;
|
|
447
|
+
const callbackStart = this.now();
|
|
448
|
+
if (callbackStart - this.startedAt >= this.limitMs || this.samples.length >= 24_000) { this.stop('limit'); return; }
|
|
449
|
+
const record = this.records.get(renderer);
|
|
450
|
+
if (!record) return;
|
|
451
|
+
const size = `${record.name}:${renderer.domElement.width}x${renderer.domElement.height}`;
|
|
452
|
+
if (state !== this.lastState) { this.mark('state', state); this.lastState = state; }
|
|
453
|
+
if (size !== this.lastSize) { this.mark('render-surface', size); this.lastSize = size; }
|
|
454
|
+
const intervalMs = rafTime !== null && this.lastRaf !== null && this.lastRenderer === renderer && rafTime > this.lastRaf
|
|
455
|
+
? rafTime - this.lastRaf : null;
|
|
456
|
+
if (rafTime !== null) { this.lastRaf = rafTime; this.lastRenderer = renderer; }
|
|
457
|
+
const sample: RenderSample = { id: this.samples.length, at: callbackStart - this.startedAt, renderer: record.name, state,
|
|
458
|
+
rafAt: rafTime === null ? null : rafTime - this.startedAt,
|
|
459
|
+
rafLagMs: rafTime === null ? null : callbackStart - rafTime,
|
|
460
|
+
callbackIntervalMs: rafTime !== null && this.lastCallback?.renderer === renderer ? callbackStart - this.lastCallback.start : null,
|
|
461
|
+
outsideFrameMs: rafTime !== null && this.lastCallback?.renderer === renderer && this.lastCallback.end !== null ? callbackStart - this.lastCallback.end : null,
|
|
462
|
+
stages: {}, submissions: {}, counters: {}, probeBeginMs: 0, probeEndMs: 0, gpuPending: record.timer?.waiting || 0, gpuQueryLatencyMs: null,
|
|
463
|
+
intervalMs, cpuMs: 0, gpuMs: null, gpuStatus: this.options.gpuTiming ? 'unsupported' : 'disabled', calls: 0, triangles: 0,
|
|
464
|
+
geometries: 0, textures: 0, programs: 0, width: renderer.domElement.width, height: renderer.domElement.height, failed: false };
|
|
465
|
+
this.samples.push(sample);
|
|
466
|
+
const requestedDetail = this.options.detailed && (sample.id % 30 === 0 || this.burstRemaining > 0);
|
|
467
|
+
const detailed = requestedDetail && this.detailFrames < 1200;
|
|
468
|
+
if (requestedDetail && !detailed) this.dropped.detailFrames = (this.dropped.detailFrames || 0) + 1;
|
|
469
|
+
if (detailed) {
|
|
470
|
+
sample.detailReason = this.burstRemaining > 0 ? 'interaction' : 'periodic';
|
|
471
|
+
sample.detailProbeMs = 0; sample.passWork = {}; sample.drawFamilies = []; this.detailFrames++;
|
|
472
|
+
}
|
|
473
|
+
if (this.burstRemaining > 0) this.burstRemaining--;
|
|
474
|
+
sample.gpuMode = detailed && this.options.gpuTiming ? 'segments' : 'whole';
|
|
475
|
+
if (sample.gpuMode === 'segments') { sample.gpuStatus = 'pending'; sample.gpuStages = {}; this.segmentSamples.add(sample); }
|
|
476
|
+
else record.timer?.begin(sample);
|
|
477
|
+
// Include every pass, especially transmission, shadows and SMAA in the viewer.
|
|
478
|
+
const autoReset = renderer.info.autoReset;
|
|
479
|
+
renderer.info.autoReset = false; renderer.info.reset();
|
|
480
|
+
const cpuStart = this.now(); sample.probeBeginMs = cpuStart - callbackStart;
|
|
481
|
+
if (rafTime !== null) this.lastCallback = { renderer, start: callbackStart, end: null };
|
|
482
|
+
else this.lastCallback = undefined; // Synchronous renders interrupt callback/outside-frame continuity.
|
|
483
|
+
const token: FrameToken = { sample, record, detailed, autoReset, cpuStart, checkpointAt: cpuStart, lastCalls: 0, lastTriangles: 0 };
|
|
484
|
+
record.frame = token;
|
|
485
|
+
if (detailed) this.gpuStage(renderer, 'update');
|
|
486
|
+
return token;
|
|
487
|
+
}
|
|
488
|
+
end(token: FrameToken | undefined, failed = false) {
|
|
489
|
+
if (!token) return;
|
|
490
|
+
const { record, sample } = token;
|
|
491
|
+
const cpuEnd = this.now();
|
|
492
|
+
sample.cpuMs = cpuEnd - token.cpuStart;
|
|
493
|
+
sample.stages.tail = cpuEnd - token.checkpointAt;
|
|
494
|
+
sample.failed = failed;
|
|
495
|
+
record.timer?.end(token.gpuPart || sample); record.frame = undefined;
|
|
496
|
+
const info = record.renderer.info;
|
|
497
|
+
if (info.render.calls !== token.lastCalls || info.render.triangles !== token.lastTriangles)
|
|
498
|
+
sample.submissions.tail = { calls: info.render.calls - token.lastCalls, triangles: info.render.triangles - token.lastTriangles };
|
|
499
|
+
sample.calls = info.render.calls; sample.triangles = info.render.triangles;
|
|
500
|
+
sample.geometries = info.memory.geometries; sample.textures = info.memory.textures; sample.programs = info.programs?.length || 0;
|
|
501
|
+
record.renderer.info.autoReset = token.autoReset;
|
|
502
|
+
const end = this.now(); sample.probeEndMs = end - cpuEnd;
|
|
503
|
+
if (this.lastCallback?.renderer === record.renderer) this.lastCallback.end = end;
|
|
504
|
+
}
|
|
505
|
+
poll() {
|
|
506
|
+
if (this.running && this.now() - this.startedAt >= this.limitMs) this.stop('limit');
|
|
507
|
+
for (const record of this.records.values()) {
|
|
508
|
+
record.timer?.poll();
|
|
509
|
+
if (!this.running && this.stoppedAt && (!record.timer?.waiting || this.now() - this.stoppedAt > 5000)) record.timer?.dispose();
|
|
510
|
+
}
|
|
511
|
+
this.settleSegments();
|
|
512
|
+
}
|
|
513
|
+
get pendingGpu() { return [...this.records.values()].reduce((n, r) => n + (r.timer?.waiting || 0), 0); }
|
|
514
|
+
get adapters() { return [...this.records.values()].flatMap(r => r.adapter ? [r.adapter] : []); }
|
|
515
|
+
get elapsedMs() { return this.startedISO ? (this.running ? this.now() : this.stoppedAt) - this.startedAt : 0; }
|
|
516
|
+
report() {
|
|
517
|
+
this.settleSegments();
|
|
518
|
+
const byState: Record<string, ReturnType<typeof summarizeFrames>> = {};
|
|
519
|
+
for (const state of new Set(this.samples.map(s => s.state))) byState[state] = summarizeFrames(this.samples.filter(s => s.state === state));
|
|
520
|
+
return { schema: 'rhine-render-capture/v3', startedAt: this.startedISO, elapsedMs: this.elapsedMs, running: this.running,
|
|
521
|
+
initialLoading: this.initialLoading, detailSampling: { everyFrames: 30, burstFrames: 6, sampledFrames: this.detailFrames, maxFrames: 1200 },
|
|
522
|
+
options: this.options, capabilities: this.capabilities, dropped: this.dropped, diagnostics: this.diagnostics,
|
|
523
|
+
timeOrigin: typeof performance === 'undefined' ? null : performance.timeOrigin, captureStartTime: this.startedAt,
|
|
524
|
+
stopReason: this.stopReason, pendingGpu: this.pendingGpu, adapters: this.adapters, metadata: this.metadata, endMetadata: this.endMetadata,
|
|
525
|
+
scope: { frame: 'intervalMs is the RAF timestamp delta, NOT actual callback cadence or presentation FPS. callbackIntervalMs is the actual performance.now() callback-entry gap; rafAt and at share capture origin. Known visibility/active/context boundaries reset both.',
|
|
526
|
+
cpu: 'Synchronous scene update + WebGL submission wall time; excludes begin/end bookkeeping and tasks outside the render callback; includes small checkpoint/counter instrumentation.',
|
|
527
|
+
gpu: 'Elapsed GPU query interval around WebGL commands, including shadow/transmission/postprocessing. Not exclusive shader execution or utilization; driver synchronization and scheduling can affect it. Browser composition and other clients are not separately attributed.',
|
|
528
|
+
detailed: 'gpuMode=segments replaces the whole-frame query with the SUM of sequential query intervals, not an independently measured whole-frame duration; driver/query overhead and boundary gaps differ. Inspect gpuMode when comparing. Draw families/passWork are sampled; GL texture upload counters cover every captured frame, buffer counters only detailed frames. GL call CPU time is submission wall time, not GPU duration. payload bytes/pixels are source estimates, not bus traffic. texture-upload-complete is the Three onUpdate callback, not proof of GPU completion.',
|
|
529
|
+
resources: 'Renderer resource COUNTS, not bytes or VRAM usage. Heartbeats include actual composer, label-depth and shadow target sizes.',
|
|
530
|
+
scheduling: 'rafLagMs = callback entry - RAF timestamp. outsideFrameMs starts at prior probe end; it includes other page work, browser scheduling and waiting, not a CPU attribution.',
|
|
531
|
+
stages: 'Synchronous wall time per stage; composer includes all its passes. Tail and stages sum to cpuMs; passWork CPU intervals are nested inside composer, never add them again. Sampled gpuStages are sequential non-overlapping queries; scene-color still includes its shadow/transmission work.',
|
|
532
|
+
gpuQueryLatency: 'Time from query submission until availability is observed; depends on polling cadence. NOT GPU execution or queue time.',
|
|
533
|
+
diagnostics: 'Browser-supported LoAF, Event Timing, Resource Timing, layout shifts, JS heap and 1Hz heartbeat. No OS-wide utilization, VRAM, GC attribution or compositor trace.',
|
|
534
|
+
overhead: 'Probe begin/end, observer, heartbeat and panel work wall time is measured approximately. JS allocations/GC and driver perturbation cannot be fully isolated; use GPU timing off for comparison.',
|
|
535
|
+
submissions: 'Per-stage deltas of renderer.info calls/triangles, not GPU timings. Composer includes shadow/transmission/postprocess. Counters describe submitted scene families; heartbeat batches are inventory/culling state, not pixel visibility.',
|
|
536
|
+
loads: 'Explicit asset/setup/API lifecycles include native IPC requests absent from Resource Timing. In-flight loads at Start are retained. Setup spans can include decode/clone/upload; do not add nested spans. Recent pre-capture history is limited to 64.',
|
|
537
|
+
aggregates: 'slowWork additionally retains exact overhead/UI intervals >=8ms. overhead/layoutShifts/uiWork use 1s buckets by kind. count/total/max/worstAt/worst preserve frequency, sum and the exact slowest event. at is bucket origin. UI spans may nest. Layout totals are NOT CLS. Other entries remain raw; dropped counts are explicit.',
|
|
538
|
+
privacy: 'No document content, input text, source IDs, error messages/stacks, full URLs, DOM nodes or element selectors.',
|
|
539
|
+
longTasks: 'Page-wide main-thread tasks >= 50ms; may overlap CPU samples. Do not add CPU and GPU time.',
|
|
540
|
+
warmup: 'No automatic exclusions: first-use hitches after Start are retained.',
|
|
541
|
+
limits: '120 seconds / 24000 frames maximum; 12 whole-mode or 96 detail-mode asynchronous queries per context; at most 1200 detailed frames; UI refresh at 1Hz over at most 512 frames in the last 2s; exact cumulative distributions are export-only; bounded diagnostics with dropped counters.' },
|
|
542
|
+
summary: summarizeFrames(this.samples), byState,
|
|
543
|
+
longTasksSupported: this.longTasksSupported, longTaskMs: distribution(this.longTasks.map(t => t.durationMs)),
|
|
544
|
+
longTasks: this.longTasks, events: this.events, frames: this.samples };
|
|
545
|
+
}
|
|
546
|
+
dispose() {
|
|
547
|
+
this.stop('dispose');
|
|
548
|
+
for (const record of this.records.values()) { record.restoreHooks?.(); record.timer?.dispose(); record.events.abort(); }
|
|
549
|
+
this.settleSegments(); this.restoreTextureHooks();
|
|
550
|
+
this.records.clear();
|
|
551
|
+
}
|
|
552
|
+
}
|