@volter/editor-game 0.5.65
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 +661 -0
- package/NOTICE +23 -0
- package/contributions/asset-budget-asset.menu.ts +26 -0
- package/contributions/asset-budget.action.ts +18 -0
- package/contributions/asset-budget.document.tsx +21 -0
- package/contributions/asset-budget.menu.ts +22 -0
- package/contributions/audio-unlock.service.ts +17 -0
- package/contributions/audio.utility.tsx +15 -0
- package/contributions/autoplay.service.ts +48 -0
- package/contributions/bridge.command.ts +172 -0
- package/contributions/build-profiles.document.tsx +21 -0
- package/contributions/build-progress.status.tsx +45 -0
- package/contributions/build.action.ts +26 -0
- package/contributions/build.command.ts +27 -0
- package/contributions/build.header.tsx +37 -0
- package/contributions/build.menu.ts +31 -0
- package/contributions/build.service.ts +31 -0
- package/contributions/connection.status.tsx +43 -0
- package/contributions/coverage.service.ts +118 -0
- package/contributions/edit-mode-audio.service.ts +23 -0
- package/contributions/edit-mode-networking.service.ts +30 -0
- package/contributions/game-document.service.ts +25 -0
- package/contributions/game-eval.command.ts +210 -0
- package/contributions/game.layout.ts +19 -0
- package/contributions/gameplay.command.ts +255 -0
- package/contributions/generation.service.ts +96 -0
- package/contributions/generations.status.tsx +53 -0
- package/contributions/ingest.service.ts +80 -0
- package/contributions/instances.command.ts +75 -0
- package/contributions/navmesh.menu.ts +39 -0
- package/contributions/navmesh.service.ts +19 -0
- package/contributions/network.utility.tsx +17 -0
- package/contributions/play.command.ts +355 -0
- package/contributions/profiler.action.ts +16 -0
- package/contributions/profiler.menu.ts +22 -0
- package/contributions/profiler.utility.tsx +17 -0
- package/contributions/react/component-board.service.ts +21 -0
- package/contributions/react/design-time-mount.service.ts +60 -0
- package/contributions/react/pasteboard.action.ts +41 -0
- package/contributions/react/react-inspector.service.ts +85 -0
- package/contributions/react/story-documents.service.ts +44 -0
- package/contributions/scene-document.service.ts +32 -0
- package/contributions/state-watch.action.ts +17 -0
- package/contributions/state-watch.menu.ts +23 -0
- package/contributions/state-watch.utility.tsx +20 -0
- package/contributions/team-playtest.service.ts +124 -0
- package/contributions/three/camera-runtime.inspector.tsx +34 -0
- package/contributions/three/component-board.service.ts +24 -0
- package/contributions/three/component-verbs.command.ts +110 -0
- package/contributions/three/component-verbs.service.ts +92 -0
- package/contributions/three/constraints.inspector.tsx +34 -0
- package/contributions/three/model-asset-sections.service.ts +26 -0
- package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
- package/contributions/three/story-documents.service.ts +31 -0
- package/contributions/three/three-authoring.service.ts +66 -0
- package/contributions/transport.header.tsx +19 -0
- package/contributions/xstate-behavior.action.ts +42 -0
- package/contributions/xstate-behavior.document.tsx +73 -0
- package/contributions/xstate-behavior.inspector.tsx +28 -0
- package/contributions/xstate-behavior.menu.ts +23 -0
- package/package.json +144 -0
- package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
- package/src/asset-budget/asset-budget-model.ts +799 -0
- package/src/asset-budget/basis-encoder.ts +165 -0
- package/src/asset-budget/gltf-io.ts +154 -0
- package/src/asset-budget/gltf-optimize.ts +384 -0
- package/src/asset-budget/image-dims.ts +78 -0
- package/src/asset-budget/optimize-apply.ts +221 -0
- package/src/audio/AudioDebuggerPanel.tsx +457 -0
- package/src/audio/audio-debugger-model.ts +87 -0
- package/src/bridge/call.ts +60 -0
- package/src/bridge/dispatch.ts +459 -0
- package/src/bridge/live-frames.ts +25 -0
- package/src/bridge/screenshot.ts +316 -0
- package/src/build/BuildProfilesPanel.tsx +476 -0
- package/src/build/build-session.ts +247 -0
- package/src/build/format-bytes.ts +14 -0
- package/src/command-results.ts +36 -0
- package/src/coverage/live-authoring-surface.ts +30 -0
- package/src/coverage/live-project-verbs.ts +162 -0
- package/src/coverage/native-system-coverage.ts +143 -0
- package/src/coverage/root-coverage.ts +79 -0
- package/src/coverage/session-coverage.ts +193 -0
- package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
- package/src/edit-mode/edit-mode-audio.ts +56 -0
- package/src/edit-mode/edit-mode-networking.ts +112 -0
- package/src/game-document/DevicePresetPicker.tsx +84 -0
- package/src/game-document/GameCaptureFrameButton.tsx +45 -0
- package/src/game-document/GameDocument.tsx +215 -0
- package/src/game-document/GamePanel.tsx +662 -0
- package/src/game-document/InstanceInspectorPicker.tsx +183 -0
- package/src/game-document/crowd-debug.ts +183 -0
- package/src/game-document/device-preview.ts +336 -0
- package/src/game-document/game-view-store.ts +107 -0
- package/src/game-document/physics-debug.ts +187 -0
- package/src/generation/GenerationActivity.tsx +421 -0
- package/src/generation/GenerationGallery.css +231 -0
- package/src/generation/generation-documents.tsx +338 -0
- package/src/generation/generation-jobs.ts +128 -0
- package/src/generation/generation-presentation.ts +257 -0
- package/src/host/adapter-reach.ts +445 -0
- package/src/host/adapter-runtime-bindings.ts +303 -0
- package/src/host/after-paint.ts +112 -0
- package/src/host/api/configurations.ts +66 -0
- package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
- package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
- package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
- package/src/host/authoring/contract-scenes-stories.ts +204 -0
- package/src/host/authoring/creation-site-related.ts +55 -0
- package/src/host/authoring/ephemeral-persistence.ts +29 -0
- package/src/host/authoring/gesture-persist.ts +84 -0
- package/src/host/authoring/ingest-data-writer.ts +232 -0
- package/src/host/authoring/ingest-source-persistence.ts +826 -0
- package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
- package/src/host/authoring/mounted-authoring.ts +41 -0
- package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
- package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
- package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
- package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
- package/src/host/authoring/pixi-isolation-assets.ts +25 -0
- package/src/host/authoring/pixi-live-write-target.ts +979 -0
- package/src/host/authoring/pixi-source-identity.ts +141 -0
- package/src/host/authoring/pixi-still-presentation.ts +71 -0
- package/src/host/authoring/pixi-structure-history.ts +237 -0
- package/src/host/authoring/pixi-transform-channels.ts +205 -0
- package/src/host/authoring/selection-remount-handoff.ts +23 -0
- package/src/host/authoring/source-persistence-backend.ts +373 -0
- package/src/host/authoring/source-refresh-revisions.ts +81 -0
- package/src/host/authoring/struct-write-pipe.ts +143 -0
- package/src/host/auto-frame-window.ts +89 -0
- package/src/host/binding-resolver.ts +393 -0
- package/src/host/browser-transpile.ts +631 -0
- package/src/host/canvas-entry-runtime.ts +95 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
- package/src/host/components/HeaderTelemetry.tsx +341 -0
- package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
- package/src/host/components/ResolutionPicker.tsx +89 -0
- package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
- package/src/host/components/frame-debugger-model.ts +579 -0
- package/src/host/components/header-telemetry-model.ts +74 -0
- package/src/host/components/scene-document.tsx +447 -0
- package/src/host/components/utility-view-state.ts +87 -0
- package/src/host/components/world-root-stage-binding.tsx +133 -0
- package/src/host/components/world-root-stage.ts +1046 -0
- package/src/host/coverage/authoring-read-probe.ts +583 -0
- package/src/host/coverage/capability-coverage.ts +1587 -0
- package/src/host/coverage/coverage-accounting.ts +261 -0
- package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
- package/src/host/coverage/project-verb-coverage.ts +148 -0
- package/src/host/coverage/system-adapter-coverage.ts +373 -0
- package/src/host/design-system-stories/StoryLayout.tsx +104 -0
- package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
- package/src/host/document-preview-three.ts +110 -0
- package/src/host/entry-adjudication.ts +89 -0
- package/src/host/game-location-guard.ts +150 -0
- package/src/host/game-module-access.ts +196 -0
- package/src/host/game-realm-page.ts +358 -0
- package/src/host/game-realm-reclaim.ts +55 -0
- package/src/host/game-realm-storage.ts +104 -0
- package/src/host/gameplay-export.ts +288 -0
- package/src/host/gameplay-recording.ts +717 -0
- package/src/host/gated-globals.ts +1511 -0
- package/src/host/history/json-history-resource.ts +254 -0
- package/src/host/ingest/registry.ts +261 -0
- package/src/host/instance-extract-actions.ts +120 -0
- package/src/host/instance-fork-actions.ts +120 -0
- package/src/host/play-control-hook.ts +26 -0
- package/src/host/playwright-shim.ts +479 -0
- package/src/host/projection/dom.ts +295 -0
- package/src/host/projection/pixi.ts +288 -0
- package/src/host/r3f-entry-runtime.ts +77 -0
- package/src/host/react-mount-runtime.ts +162 -0
- package/src/host/realm-services.ts +148 -0
- package/src/host/recording-preview.ts +106 -0
- package/src/host/roots/module-root.ts +203 -0
- package/src/host/roots/react-root.ts +181 -0
- package/src/host/same-realm-loop-gate.ts +544 -0
- package/src/host/scene-view-drawability.ts +69 -0
- package/src/host/sdk/tools.ts +31 -0
- package/src/host/served-bundle-runtime-modules.ts +331 -0
- package/src/host/server-log-bridge.ts +60 -0
- package/src/host/staged-projects.ts +24 -0
- package/src/host/stories/mounted-story-viewport-source.ts +64 -0
- package/src/host/stories/story-arg-descriptors.ts +50 -0
- package/src/host/stories/story-media-presence.ts +152 -0
- package/src/host/surface-content.ts +87 -0
- package/src/host/take-named-export.ts +23 -0
- package/src/host/three-ingest-runtime.ts +76 -0
- package/src/host/types-fastnoise-lite.d.ts +7 -0
- package/src/host/types-mikktspace.d.ts +20 -0
- package/src/host/types-troika-three-text.d.ts +7 -0
- package/src/host/use-active-performance-source.ts +53 -0
- package/src/host/viewport-pose-memory.ts +48 -0
- package/src/host/viewport-root-presentation.ts +40 -0
- package/src/ingest/active-ingest.ts +251 -0
- package/src/ingest/active-scene-navigation.ts +43 -0
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
- package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
- package/src/ingest/capture-wait-report.ts +122 -0
- package/src/ingest/deferred-ingest-play.ts +216 -0
- package/src/ingest/deferred-ingest-session.ts +23 -0
- package/src/ingest/discovery-public-ingest.ts +242 -0
- package/src/ingest/dom-stub-mark.ts +7 -0
- package/src/ingest/entry-load.ts +56 -0
- package/src/ingest/game-contract-realm.ts +34 -0
- package/src/ingest/game-pointer-lock.ts +89 -0
- package/src/ingest/held-scene-repaint.ts +73 -0
- package/src/ingest/host-surface-box.ts +174 -0
- package/src/ingest/ingest-boot-viewport.ts +54 -0
- package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
- package/src/ingest/ingest-canvas-scene.ts +50 -0
- package/src/ingest/ingest-evidence-hook.ts +131 -0
- package/src/ingest/ingest-frame-snapshot.ts +183 -0
- package/src/ingest/ingest-play-commands.ts +134 -0
- package/src/ingest/ingest-play-control.ts +299 -0
- package/src/ingest/ingest-render-debug.ts +320 -0
- package/src/ingest/ingest-siblings.ts +487 -0
- package/src/ingest/ingest-status.ts +62 -0
- package/src/ingest/live-ingest-facet.ts +46 -0
- package/src/ingest/module-mode.ts +229 -0
- package/src/ingest/mount-canvas-ingest-root.ts +899 -0
- package/src/ingest/mount-coverage.ts +296 -0
- package/src/ingest/mount-dom-ingest-root.ts +282 -0
- package/src/ingest/mount-ingest-root.ts +744 -0
- package/src/ingest/mount-three-ingest-root.ts +366 -0
- package/src/ingest/resolve-canvas.ts +47 -0
- package/src/ingest/resolve-three.ts +123 -0
- package/src/ingest/served-bundle.ts +109 -0
- package/src/ingest/served-html-boot.ts +292 -0
- package/src/ingest/surface-canvas.ts +84 -0
- package/src/ingest/surface-dom.ts +84 -0
- package/src/ingest/surface-three.ts +128 -0
- package/src/ingest/types.ts +76 -0
- package/src/ingest/unmount-ingest-root.ts +224 -0
- package/src/navmesh/navmesh-actions.ts +27 -0
- package/src/navmesh/navmesh-handler.ts +237 -0
- package/src/navmesh/navmesh-workflow-store.ts +78 -0
- package/src/network/NetworkInspectorPanel.tsx +644 -0
- package/src/network/network-inspector-model.ts +225 -0
- package/src/play/play-boot-stall.ts +118 -0
- package/src/play/play-log-events.ts +26 -0
- package/src/play/play-mode.ts +2553 -0
- package/src/play/play-recording.ts +335 -0
- package/src/play/react-play-live-authoring.ts +165 -0
- package/src/play-bar/PlayBar.tsx +488 -0
- package/src/play-bar/PlayerCountPicker.tsx +100 -0
- package/src/profiler/FrameDebuggerPanel.tsx +458 -0
- package/src/profiler/PerformancePanel.tsx +1068 -0
- package/src/profiler/ProfilerPanel.tsx +53 -0
- package/src/profiler/frame-debugger-store.ts +97 -0
- package/src/profiler/main-thread-busy.ts +90 -0
- package/src/react/design-time-react-mount.ts +782 -0
- package/src/react/dom-authoring-adapter.ts +764 -0
- package/src/react/pasteboard-materialize.ts +154 -0
- package/src/react/react-inspector-section.tsx +2496 -0
- package/src/react/react-world-authoring-adapter.ts +3795 -0
- package/src/react/story-documents/story-args-section.ts +19 -0
- package/src/react/story-documents/story-documents.tsx +1380 -0
- package/src/react/story-paint-bounds.ts +94 -0
- package/src/react/ui-board-document.tsx +101 -0
- package/src/react/ui-board-title.ts +9 -0
- package/src/react/ui-component-board.ts +72 -0
- package/src/services/audio-pose-guard.ts +81 -0
- package/src/services/game-audio-unlock.ts +48 -0
- package/src/state-watch/StateWatchPanel.tsx +535 -0
- package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
- package/src/three/authoring/constraint-inspector-section.tsx +189 -0
- package/src/three/authoring/design-time-renderer.ts +189 -0
- package/src/three/authoring/model-asset-inspector-section.css +41 -0
- package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
- package/src/three/authoring/oid-source-persistence.ts +557 -0
- package/src/three/authoring/r3f-design-session.ts +1174 -0
- package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
- package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
- package/src/three/authoring/spatial-audio-handles.ts +215 -0
- package/src/three/authoring/spatial-collider-handles.ts +229 -0
- package/src/three/authoring/spatial-joint-handles.ts +114 -0
- package/src/three/authoring/spatial-light-handles.ts +178 -0
- package/src/three/authoring/spatial-lod-handles.ts +93 -0
- package/src/three/authoring/spatial-particle-handles.ts +368 -0
- package/src/three/authoring/three-authoring-adapter.ts +1953 -0
- package/src/three/authoring/three-scene-identity.ts +19 -0
- package/src/three/authoring/three-spatial-handles.ts +161 -0
- package/src/three/authoring/typed-three-inspector.ts +528 -0
- package/src/three/component-verbs/extract-menu.ts +71 -0
- package/src/three/component-verbs/fork-menu.ts +78 -0
- package/src/three/component-verbs/internals-menu.ts +91 -0
- package/src/three/story-documents/three-story-documents.tsx +607 -0
- package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
- package/src/three/three-board/board-framing.ts +412 -0
- package/src/three/three-board/board-layout.ts +401 -0
- package/src/three/three-board/board-scene.ts +901 -0
- package/src/three/three-board/three-component-board.ts +62 -0
- package/src/xstate/XStateBehaviorSection.tsx +130 -0
- package/src/xstate/XStateMachineInspector.tsx +566 -0
- package/src/xstate/character-animation-machine.fixture.ts +74 -0
- package/src/xstate/live-behaviors.ts +106 -0
- package/src/xstate/use-live-actor-state.ts +44 -0
- package/src/xstate/xstate-graph.ts +235 -0
- package/src/xstate/xstate-layout.ts +76 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure data-path logic for the Frame debugger tab and the Profiler's
|
|
3
|
+
* Flamegraph/Memory views (W4b, F11) — no React/DOM, so the whole consumer
|
|
4
|
+
* side of the `RenderDebugAdapter` capabilities plus the flamegraph geometry
|
|
5
|
+
* is headless-testable (`frame-debugger-model.test.ts`). The rendering
|
|
6
|
+
* components (`FrameDebuggerPanel.tsx`, `PerformancePanel.tsx`) own only JSX;
|
|
7
|
+
* capability detection, the bounded capture ring, the draw-call grouping, and
|
|
8
|
+
* every bit of layout math live here (the W3b `network-inspector-model.ts`
|
|
9
|
+
* split, applied to render debugging).
|
|
10
|
+
*
|
|
11
|
+
* HONESTY (adapters never fabricate): a `null` capability map (no adapter)
|
|
12
|
+
* stays `null` — the panel renders the register-an-adapter notice rather than
|
|
13
|
+
* a fabricated all-false map; `readJsHeap` feature-detects the Chromium-only
|
|
14
|
+
* `performance.memory` and returns the REASON string when it is absent, never
|
|
15
|
+
* a zeroed reading. Editor code speaks only the `RenderDebugAdapter` interface
|
|
16
|
+
* and the plain capture/frame shapes — never the WebGL2 instrument itself.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { PerformanceFrame } from '@volter/game-runtime/dev/performance-profiler';
|
|
20
|
+
import type {
|
|
21
|
+
FrameCapture,
|
|
22
|
+
FrameCaptureDrawCall,
|
|
23
|
+
} from '@volter/game-runtime/dev/webgl-frame-capture';
|
|
24
|
+
import type { RenderDebugAdapter } from '@volter/editor-project/adapter';
|
|
25
|
+
|
|
26
|
+
/** Which optional render-debug capabilities the active adapter provides — the
|
|
27
|
+
* degradation ladder's per-section verdict. `null` input (no adapter = no
|
|
28
|
+
* running session, or a headless mount that registered none) stays `null`:
|
|
29
|
+
* the panel renders the register-an-adapter notice instead of a fabricated
|
|
30
|
+
* all-false map. `captureFrame` is a REQUIRED member, so `capture` is `true`
|
|
31
|
+
* whenever an adapter is present; `memorySnapshot` is the optional-within-
|
|
32
|
+
* optional capability (a mount without a real `renderer.info` omits it). */
|
|
33
|
+
export interface RenderDebugCapabilityMap {
|
|
34
|
+
capture: boolean;
|
|
35
|
+
memory: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function deriveRenderDebugCapabilities(
|
|
39
|
+
adapter: RenderDebugAdapter | null | undefined,
|
|
40
|
+
): RenderDebugCapabilityMap | null {
|
|
41
|
+
if (!adapter) return null;
|
|
42
|
+
return {
|
|
43
|
+
capture: typeof adapter.captureFrame === 'function',
|
|
44
|
+
memory: typeof adapter.memorySnapshot === 'function',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Bounded ring of the last N frame captures (default 10) with a selection.
|
|
50
|
+
* This is a plain LIST of independent single-frame captures — not a replay
|
|
51
|
+
* timeline — so `add` appends and evicts the oldest, and selecting a capture
|
|
52
|
+
* that has been evicted is a no-op (the selection stays on whatever is still
|
|
53
|
+
* held). A fresh capture becomes the selection.
|
|
54
|
+
*/
|
|
55
|
+
export class CaptureHistoryModel {
|
|
56
|
+
private captures: FrameCapture[] = [];
|
|
57
|
+
private selectedId: number | null = null;
|
|
58
|
+
|
|
59
|
+
constructor(private readonly capacity = 10) {}
|
|
60
|
+
|
|
61
|
+
add(capture: FrameCapture): void {
|
|
62
|
+
this.captures.push(capture);
|
|
63
|
+
if (this.captures.length > this.capacity) {
|
|
64
|
+
this.captures.splice(0, this.captures.length - this.capacity);
|
|
65
|
+
}
|
|
66
|
+
this.selectedId = capture.id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get all(): readonly FrameCapture[] {
|
|
70
|
+
return this.captures;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Select an existing capture by id. No-op for an unknown/evicted id (the
|
|
74
|
+
* prior selection is kept — never cleared to a fabricated empty view). */
|
|
75
|
+
select(id: number): void {
|
|
76
|
+
if (this.captures.some((c) => c.id === id)) this.selectedId = id;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get selectedIdOrNull(): number | null {
|
|
80
|
+
return this.selectedId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get selected(): FrameCapture | null {
|
|
84
|
+
if (this.selectedId === null) return null;
|
|
85
|
+
return this.captures.find((c) => c.id === this.selectedId) ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
clear(): void {
|
|
89
|
+
this.captures = [];
|
|
90
|
+
this.selectedId = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One target's draws in a capture, for the grouped list view. */
|
|
95
|
+
export interface DrawCallGroup {
|
|
96
|
+
/** `'canvas'` for the default framebuffer, else the capture-local
|
|
97
|
+
* framebuffer identity label (`framebuffer#N`). */
|
|
98
|
+
readonly target: string;
|
|
99
|
+
readonly draws: readonly FrameCaptureDrawCall[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Group a capture's draws by render target, preserving first-seen order
|
|
103
|
+
* (draws render in list order; a target header appears where its first draw
|
|
104
|
+
* did). */
|
|
105
|
+
export function groupDrawCallsByTarget(capture: FrameCapture): DrawCallGroup[] {
|
|
106
|
+
const groups = new Map<string, FrameCaptureDrawCall[]>();
|
|
107
|
+
const order: string[] = [];
|
|
108
|
+
for (const draw of capture.drawCalls) {
|
|
109
|
+
const key = draw.target.kind === 'canvas' ? 'canvas' : (draw.target.label ?? 'framebuffer');
|
|
110
|
+
let bucket = groups.get(key);
|
|
111
|
+
if (!bucket) {
|
|
112
|
+
bucket = [];
|
|
113
|
+
groups.set(key, bucket);
|
|
114
|
+
order.push(key);
|
|
115
|
+
}
|
|
116
|
+
bucket.push(draw);
|
|
117
|
+
}
|
|
118
|
+
return order.map((target) => ({ target, draws: groups.get(target) ?? [] }));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One-line rollup of a capture for the history strip. */
|
|
122
|
+
export interface CaptureSummary {
|
|
123
|
+
readonly id: number;
|
|
124
|
+
readonly drawCalls: number;
|
|
125
|
+
readonly unattributed: number;
|
|
126
|
+
readonly truncated: number;
|
|
127
|
+
readonly targets: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function summarizeCapture(capture: FrameCapture): CaptureSummary {
|
|
131
|
+
const targets = new Set<string>();
|
|
132
|
+
for (const draw of capture.drawCalls) {
|
|
133
|
+
targets.add(draw.target.kind === 'canvas' ? 'canvas' : (draw.target.label ?? 'framebuffer'));
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
id: capture.id,
|
|
137
|
+
drawCalls: capture.totals.drawCalls,
|
|
138
|
+
unattributed: capture.totals.unattributed,
|
|
139
|
+
truncated: capture.totals.truncated,
|
|
140
|
+
targets: targets.size,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- Flamegraph geometry ----------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/** One rectangle in the flamegraph: `depth` is the nesting row (0 = frame,
|
|
147
|
+
* 1 = phase, 2 = system, 3 = component), `x`/`w` in pixels within `width`. */
|
|
148
|
+
export interface FlameRect {
|
|
149
|
+
readonly x: number;
|
|
150
|
+
readonly w: number;
|
|
151
|
+
readonly depth: number;
|
|
152
|
+
readonly label: string;
|
|
153
|
+
readonly ms: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Absolutely-positioned rectangles for a frame's flamegraph — pure layout math
|
|
158
|
+
* from the profiler's MEASURED per-span `startMs`/`ms` (never synthesized).
|
|
159
|
+
* Rows by kind: the frame root (depth 0) spans its `cpuMs`; phases (depth 1),
|
|
160
|
+
* systems (depth 2), and — while recording — components (depth 3) sit at their
|
|
161
|
+
* measured start offsets. GPU is never a row: only measured CPU spans exist
|
|
162
|
+
* (the profiler's `gpuMs` is a whole-frame aggregate, drawn as an n/a header,
|
|
163
|
+
* not a fabricated lane). Scaled so the whole frame fits `width`; a system that
|
|
164
|
+
* ran within its phase stays within that phase's x-span by construction.
|
|
165
|
+
*/
|
|
166
|
+
export function flamegraphRows(frame: PerformanceFrame, width: number): FlameRect[] {
|
|
167
|
+
let maxEnd = Math.max(0, frame.cpuMs);
|
|
168
|
+
for (const p of frame.phases) maxEnd = Math.max(maxEnd, (p.startMs ?? 0) + p.ms);
|
|
169
|
+
for (const s of frame.systems) maxEnd = Math.max(maxEnd, s.startMs + s.ms);
|
|
170
|
+
for (const c of frame.components) maxEnd = Math.max(maxEnd, c.startMs + c.ms);
|
|
171
|
+
const total = maxEnd > 0 ? maxEnd : 1;
|
|
172
|
+
const scale = width / total;
|
|
173
|
+
|
|
174
|
+
const rect = (startMs: number, ms: number, depth: number, label: string): FlameRect => ({
|
|
175
|
+
x: Math.max(0, startMs * scale),
|
|
176
|
+
w: Math.max(0, ms * scale),
|
|
177
|
+
depth,
|
|
178
|
+
label,
|
|
179
|
+
ms,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const rows: FlameRect[] = [rect(0, frame.cpuMs, 0, `frame ${frame.id}`)];
|
|
183
|
+
for (const p of frame.phases) rows.push(rect(p.startMs ?? 0, p.ms, 1, p.name));
|
|
184
|
+
for (const s of frame.systems) rows.push(rect(s.startMs, s.ms, 2, s.name));
|
|
185
|
+
for (const c of frame.components) rows.push(rect(c.startMs, c.ms, 3, c.name));
|
|
186
|
+
return rows;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** One bar in the frame-history selector strip. */
|
|
190
|
+
export interface FrameBar {
|
|
191
|
+
readonly index: number;
|
|
192
|
+
readonly frameId: number;
|
|
193
|
+
readonly x: number;
|
|
194
|
+
readonly w: number;
|
|
195
|
+
readonly h: number;
|
|
196
|
+
readonly cpuMs: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Bars for the frame-selector strip — the `sparklinePoints` idiom as discrete
|
|
201
|
+
* bars: one per recorded frame, height scaled to the tallest `cpuMs` (min 1 so
|
|
202
|
+
* an all-zero series draws flat, not NaN). Empty input → `[]` (nothing yet).
|
|
203
|
+
*/
|
|
204
|
+
export function frameHistoryBars(
|
|
205
|
+
frames: readonly PerformanceFrame[],
|
|
206
|
+
width: number,
|
|
207
|
+
height: number,
|
|
208
|
+
): FrameBar[] {
|
|
209
|
+
if (frames.length === 0) return [];
|
|
210
|
+
const max = Math.max(1, ...frames.map((f) => f.cpuMs));
|
|
211
|
+
const barW = width / frames.length;
|
|
212
|
+
return frames.map((frame, index) => ({
|
|
213
|
+
index,
|
|
214
|
+
frameId: frame.id,
|
|
215
|
+
x: index * barW,
|
|
216
|
+
w: barW,
|
|
217
|
+
h: Math.max(0, (frame.cpuMs / max) * height),
|
|
218
|
+
cpuMs: frame.cpuMs,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- JS heap ----------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/** Human-readable byte count (`1.50 MB`), for the memory tables/tiles. */
|
|
225
|
+
export function formatBytes(bytes: number): string {
|
|
226
|
+
if (!Number.isFinite(bytes) || bytes < 0) return '—';
|
|
227
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
228
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
229
|
+
let value = bytes / 1024;
|
|
230
|
+
let unit = 0;
|
|
231
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
232
|
+
value /= 1024;
|
|
233
|
+
unit++;
|
|
234
|
+
}
|
|
235
|
+
return `${value < 10 ? value.toFixed(2) : value.toFixed(1)} ${units[unit]}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Off-loop accounting (the "14fps but the loop measures 1.3ms" story)
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The per-source profiler measures only what runs INSIDE the registered
|
|
244
|
+
* render loop's rAF callback (its `editor`/`render` phases). Everything else
|
|
245
|
+
* that spends the frame budget — compositor/GPU work (post-effects, glass
|
|
246
|
+
* chrome, pixel ratio), React commits, other timers, GC — is invisible to it,
|
|
247
|
+
* so a GPU-bound editor reads as "1ms of CPU at 14fps" with no explanation.
|
|
248
|
+
* These helpers give the Overview an honest account of that gap. Found the
|
|
249
|
+
* hard way (2026-07-31): a full-window glass chain on the center documents
|
|
250
|
+
* group cost ~20ms/frame of pure compositor time that no phase could see.
|
|
251
|
+
*/
|
|
252
|
+
export interface OffLoopAccount {
|
|
253
|
+
/** Representative frame interval (p95), ms. */
|
|
254
|
+
readonly frameMs: number;
|
|
255
|
+
/** frameMs minus the loop's measured CPU — time the profiler cannot see. */
|
|
256
|
+
readonly gapMs: number;
|
|
257
|
+
/**
|
|
258
|
+
* True when the frame rate is actually low AND the measured loop explains
|
|
259
|
+
* less than half of it — the exact situation where the phase table is
|
|
260
|
+
* misleading without a callout. Never true at healthy frame rates: at
|
|
261
|
+
* 60fps the "gap" is just vsync idle, not a problem to report.
|
|
262
|
+
*/
|
|
263
|
+
readonly dominated: boolean;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function offLoopAccount(p95FrameMs: number, cpuMs: number): OffLoopAccount {
|
|
267
|
+
const frameMs = Math.max(0, p95FrameMs);
|
|
268
|
+
const gapMs = Math.max(0, frameMs - Math.max(0, cpuMs));
|
|
269
|
+
return { frameMs, gapMs, dominated: frameMs > 20 && gapMs > frameMs / 2 };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Structural shapes for `long-animation-frame` entries (not yet in TS lib). */
|
|
273
|
+
export interface LongAnimationFrameScriptLike {
|
|
274
|
+
readonly duration: number;
|
|
275
|
+
readonly invoker?: string;
|
|
276
|
+
readonly sourceURL?: string;
|
|
277
|
+
readonly sourceFunctionName?: string;
|
|
278
|
+
}
|
|
279
|
+
export interface LongAnimationFrameEntryLike {
|
|
280
|
+
readonly startTime?: number;
|
|
281
|
+
readonly duration: number;
|
|
282
|
+
readonly scripts?: readonly LongAnimationFrameScriptLike[];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface LoafScriptRow {
|
|
286
|
+
/** `function @ tail-of-source-url` — stable, human-readable aggregation key. */
|
|
287
|
+
readonly key: string;
|
|
288
|
+
readonly totalMs: number;
|
|
289
|
+
readonly count: number;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface LoafSummary {
|
|
293
|
+
readonly longFrameCount: number;
|
|
294
|
+
readonly totalLongMs: number;
|
|
295
|
+
/** Long-frame time no script accounts for: style/layout, GC, engine work. */
|
|
296
|
+
readonly unattributedMs: number;
|
|
297
|
+
/** Script rows, largest total first. */
|
|
298
|
+
readonly scripts: readonly LoafScriptRow[];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Aggregate the browser's own long-animation-frame report (frames >50ms of
|
|
303
|
+
* main-thread work, WITH script attribution) into top offenders. This is the
|
|
304
|
+
* half of the off-loop gap the main thread can name; a large gap with an
|
|
305
|
+
* EMPTY summary is the other half — GPU/compositor — and the panel says so
|
|
306
|
+
* rather than showing nothing.
|
|
307
|
+
*/
|
|
308
|
+
export function aggregateLongAnimationFrames(
|
|
309
|
+
entries: readonly LongAnimationFrameEntryLike[],
|
|
310
|
+
): LoafSummary {
|
|
311
|
+
const rows = new Map<string, { totalMs: number; count: number }>();
|
|
312
|
+
let totalLongMs = 0;
|
|
313
|
+
let attributedMs = 0;
|
|
314
|
+
for (const entry of entries) {
|
|
315
|
+
totalLongMs += entry.duration;
|
|
316
|
+
for (const script of entry.scripts ?? []) {
|
|
317
|
+
attributedMs += script.duration;
|
|
318
|
+
const source = script.sourceURL ? script.sourceURL.split('/').slice(-2).join('/') : '';
|
|
319
|
+
const name = script.sourceFunctionName || script.invoker || '(anonymous)';
|
|
320
|
+
const key = source ? `${name} @ ${source}` : name;
|
|
321
|
+
const row = rows.get(key) ?? { totalMs: 0, count: 0 };
|
|
322
|
+
row.totalMs += script.duration;
|
|
323
|
+
row.count += 1;
|
|
324
|
+
rows.set(key, row);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
longFrameCount: entries.length,
|
|
329
|
+
totalLongMs: Math.round(totalLongMs),
|
|
330
|
+
unattributedMs: Math.round(Math.max(0, totalLongMs - attributedMs)),
|
|
331
|
+
scripts: [...rows.entries()]
|
|
332
|
+
.map(([key, row]) => ({ key, totalMs: Math.round(row.totalMs), count: row.count }))
|
|
333
|
+
.sort((a, b) => b.totalMs - a.totalMs)
|
|
334
|
+
.slice(0, 8),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The full per-frame accounting the Overview renders as "Where the frame
|
|
340
|
+
* goes" — the identity is `frameMs = loopMs + otherMainMs + pipelineMs`, so
|
|
341
|
+
* the breakdown always ADDS UP to the observed frame interval instead of
|
|
342
|
+
* leaving a mystery between "1ms of phases" and "14fps".
|
|
343
|
+
*
|
|
344
|
+
* - `loopMs` — measured: the profiled loop's own rAF callback CPU.
|
|
345
|
+
* - `otherMainMs` — measured (busy sampler, `main-thread-busy.ts`) minus the
|
|
346
|
+
* loop's share; `null` when the sampler has not covered the window, never
|
|
347
|
+
* a fabricated 0.
|
|
348
|
+
* - `pipelineMs` — the arithmetic remainder: GPU + compositor (post-effects,
|
|
349
|
+
* glass chrome) + waiting for vsync. Not directly measurable from JS —
|
|
350
|
+
* `gpuDrawMs` (timer queries, when the context supports them) names the
|
|
351
|
+
* loop's own share of it.
|
|
352
|
+
*/
|
|
353
|
+
export interface FrameBudget {
|
|
354
|
+
readonly frames: number;
|
|
355
|
+
readonly fps: number;
|
|
356
|
+
readonly frameMs: number;
|
|
357
|
+
readonly loopMs: number;
|
|
358
|
+
readonly otherMainMs: number | null;
|
|
359
|
+
readonly pipelineMs: number;
|
|
360
|
+
/** Mean resolved GPU time of the loop's own draw; null when unmeasured. */
|
|
361
|
+
readonly gpuDrawMs: number | null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Below this many frames (or busy samples) the window is noise, not data. */
|
|
365
|
+
const BUDGET_MIN_FRAMES = 5;
|
|
366
|
+
const BUDGET_MIN_BUSY_SAMPLES = 20;
|
|
367
|
+
|
|
368
|
+
export function frameBudget(
|
|
369
|
+
frames: readonly PerformanceFrame[],
|
|
370
|
+
windowStartMs: number,
|
|
371
|
+
busy: { busyMs: number; sampleCount: number } | null,
|
|
372
|
+
): FrameBudget | null {
|
|
373
|
+
const windowed = frames.filter(
|
|
374
|
+
(frame) => frame.timestamp >= windowStartMs && frame.intervalMs > 0,
|
|
375
|
+
);
|
|
376
|
+
if (windowed.length < BUDGET_MIN_FRAMES) return null;
|
|
377
|
+
let sumInterval = 0;
|
|
378
|
+
let sumCpu = 0;
|
|
379
|
+
let sumGpu = 0;
|
|
380
|
+
let gpuCount = 0;
|
|
381
|
+
for (const frame of windowed) {
|
|
382
|
+
sumInterval += frame.intervalMs;
|
|
383
|
+
sumCpu += frame.cpuMs;
|
|
384
|
+
if (frame.render.gpuMs !== null) {
|
|
385
|
+
sumGpu += frame.render.gpuMs;
|
|
386
|
+
gpuCount++;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const count = windowed.length;
|
|
390
|
+
const frameMs = sumInterval / count;
|
|
391
|
+
const loopMs = sumCpu / count;
|
|
392
|
+
const otherMainMs =
|
|
393
|
+
busy !== null && busy.sampleCount >= BUDGET_MIN_BUSY_SAMPLES
|
|
394
|
+
? Math.max(0, busy.busyMs - sumCpu) / count
|
|
395
|
+
: null;
|
|
396
|
+
return {
|
|
397
|
+
frames: count,
|
|
398
|
+
fps: frameMs > 0 ? 1000 / frameMs : 0,
|
|
399
|
+
frameMs,
|
|
400
|
+
loopMs,
|
|
401
|
+
otherMainMs,
|
|
402
|
+
pipelineMs: Math.max(0, frameMs - loopMs - (otherMainMs ?? 0)),
|
|
403
|
+
gpuDrawMs: gpuCount > 0 ? sumGpu / gpuCount : null,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// The hierarchical "time spent" tree (owner direction, 2026-07-31)
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* One row of the Overview's structural time-spent breakdown. Every row is a
|
|
413
|
+
* MEAN MS PER FRAME over the same window, so children sum to their parent
|
|
414
|
+
* and depth-1 rows sum to the frame interval — one tree, one denominator,
|
|
415
|
+
* replacing the flat budget table + latest-frame phase table + standalone
|
|
416
|
+
* systems table that each answered with a different unit.
|
|
417
|
+
*
|
|
418
|
+
* `parallel` marks an overlapping measure (the GPU draw executes while the
|
|
419
|
+
* CPU rows run) — rendered as an annotation, excluded from sum checking.
|
|
420
|
+
* `ms: null` is honest absence (e.g. the busy sampler has no coverage yet).
|
|
421
|
+
*/
|
|
422
|
+
export interface TimeSpentRow {
|
|
423
|
+
readonly label: string;
|
|
424
|
+
readonly depth: number;
|
|
425
|
+
readonly ms: number | null;
|
|
426
|
+
readonly note?: string;
|
|
427
|
+
readonly parallel?: boolean;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Remainder rows below this are noise, not information. */
|
|
431
|
+
const TIME_SPENT_EPSILON_MS = 0.05;
|
|
432
|
+
const MAX_SYSTEMS_PER_PHASE = 3;
|
|
433
|
+
const MAX_SCRIPT_ROWS = 4;
|
|
434
|
+
|
|
435
|
+
function windowedFrames(
|
|
436
|
+
frames: readonly PerformanceFrame[],
|
|
437
|
+
windowStartMs: number,
|
|
438
|
+
): PerformanceFrame[] {
|
|
439
|
+
return frames.filter((frame) => frame.timestamp >= windowStartMs && frame.intervalMs > 0);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function meanByKey(
|
|
443
|
+
entries: readonly (readonly [string, number])[],
|
|
444
|
+
count: number,
|
|
445
|
+
): Map<string, number> {
|
|
446
|
+
const sums = new Map<string, number>();
|
|
447
|
+
for (const [key, ms] of entries) sums.set(key, (sums.get(key) ?? 0) + ms);
|
|
448
|
+
const means = new Map<string, number>();
|
|
449
|
+
for (const [key, sum] of sums) means.set(key, sum / count);
|
|
450
|
+
return means;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function phaseRows(windowed: readonly PerformanceFrame[], loopMs: number): TimeSpentRow[] {
|
|
454
|
+
const count = windowed.length;
|
|
455
|
+
const phases = meanByKey(
|
|
456
|
+
windowed.flatMap((frame) => frame.phases.map((p) => [p.name, p.ms] as const)),
|
|
457
|
+
count,
|
|
458
|
+
);
|
|
459
|
+
const systems = meanByKey(
|
|
460
|
+
windowed.flatMap((frame) => frame.systems.map((s) => [`${s.phase} ${s.name}`, s.ms] as const)),
|
|
461
|
+
count,
|
|
462
|
+
);
|
|
463
|
+
const rows: TimeSpentRow[] = [];
|
|
464
|
+
let phaseSum = 0;
|
|
465
|
+
for (const [name, ms] of [...phases.entries()].sort((a, b) => b[1] - a[1])) {
|
|
466
|
+
phaseSum += ms;
|
|
467
|
+
rows.push({ label: name, depth: 2, ms });
|
|
468
|
+
const inPhase = [...systems.entries()]
|
|
469
|
+
.filter(([key]) => key.startsWith(`${name} `))
|
|
470
|
+
.map(([key, sysMs]) => {
|
|
471
|
+
// Span names are `scope / phase / name` — the tree already says the
|
|
472
|
+
// phase, so show the tail.
|
|
473
|
+
const spanName = key.slice(name.length + 1);
|
|
474
|
+
return [spanName.split(' / ').at(-1) ?? spanName, sysMs] as const;
|
|
475
|
+
})
|
|
476
|
+
.sort((a, b) => b[1] - a[1]);
|
|
477
|
+
for (const [sysName, sysMs] of inPhase.slice(0, MAX_SYSTEMS_PER_PHASE)) {
|
|
478
|
+
rows.push({ label: sysName, depth: 3, ms: sysMs });
|
|
479
|
+
}
|
|
480
|
+
const rest = inPhase.slice(MAX_SYSTEMS_PER_PHASE);
|
|
481
|
+
if (rest.length > 0) {
|
|
482
|
+
rows.push({
|
|
483
|
+
label: `${rest.length} more system${rest.length === 1 ? '' : 's'}`,
|
|
484
|
+
depth: 3,
|
|
485
|
+
ms: rest.reduce((sum, [, sysMs]) => sum + sysMs, 0),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const unphased = loopMs - phaseSum;
|
|
490
|
+
if (unphased > TIME_SPENT_EPSILON_MS) {
|
|
491
|
+
rows.push({ label: 'outside the phases', depth: 2, ms: unphased });
|
|
492
|
+
}
|
|
493
|
+
return rows;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function otherMainRows(
|
|
497
|
+
otherMainMs: number | null,
|
|
498
|
+
loafEntries: readonly LongAnimationFrameEntryLike[],
|
|
499
|
+
windowStartMs: number,
|
|
500
|
+
frameCount: number,
|
|
501
|
+
): TimeSpentRow[] {
|
|
502
|
+
if (otherMainMs === null) return [];
|
|
503
|
+
const loaf = aggregateLongAnimationFrames(
|
|
504
|
+
loafEntries.filter((entry) => (entry.startTime ?? 0) >= windowStartMs),
|
|
505
|
+
);
|
|
506
|
+
const rows: TimeSpentRow[] = [];
|
|
507
|
+
let attributed = 0;
|
|
508
|
+
for (const script of loaf.scripts.slice(0, MAX_SCRIPT_ROWS)) {
|
|
509
|
+
const ms = Math.min(script.totalMs / frameCount, otherMainMs - attributed);
|
|
510
|
+
if (ms <= TIME_SPENT_EPSILON_MS) continue;
|
|
511
|
+
attributed += ms;
|
|
512
|
+
rows.push({ label: script.key, depth: 2, ms, note: 'named by the browser (long frames)' });
|
|
513
|
+
}
|
|
514
|
+
const unattributed = otherMainMs - attributed;
|
|
515
|
+
if (unattributed > TIME_SPENT_EPSILON_MS || rows.length === 0) {
|
|
516
|
+
rows.push({
|
|
517
|
+
label: 'unattributed',
|
|
518
|
+
depth: 2,
|
|
519
|
+
ms: Math.max(0, unattributed),
|
|
520
|
+
note: "below the browser's 50ms attribution threshold",
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
return rows;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Build the whole tree, or null while the window lacks data. Invariants
|
|
528
|
+
* (unit-tested): depth-1 non-parallel rows sum to the frame interval; each
|
|
529
|
+
* level's non-parallel children sum to their parent (within epsilon).
|
|
530
|
+
*/
|
|
531
|
+
export function timeSpentRows(
|
|
532
|
+
frames: readonly PerformanceFrame[],
|
|
533
|
+
windowStartMs: number,
|
|
534
|
+
busy: { busyMs: number; sampleCount: number } | null,
|
|
535
|
+
loafEntries: readonly LongAnimationFrameEntryLike[],
|
|
536
|
+
): { rows: TimeSpentRow[]; budget: FrameBudget } | null {
|
|
537
|
+
const budget = frameBudget(frames, windowStartMs, busy);
|
|
538
|
+
if (budget === null) return null;
|
|
539
|
+
const windowed = windowedFrames(frames, windowStartMs);
|
|
540
|
+
const rows: TimeSpentRow[] = [
|
|
541
|
+
{
|
|
542
|
+
label: 'Frame interval',
|
|
543
|
+
depth: 0,
|
|
544
|
+
ms: budget.frameMs,
|
|
545
|
+
note: `${budget.frames} frames at ${budget.fps.toFixed(0)} fps`,
|
|
546
|
+
},
|
|
547
|
+
{ label: 'Scene loop (CPU)', depth: 1, ms: budget.loopMs },
|
|
548
|
+
...phaseRows(windowed, budget.loopMs),
|
|
549
|
+
{
|
|
550
|
+
label: 'Other main thread',
|
|
551
|
+
depth: 1,
|
|
552
|
+
ms: budget.otherMainMs,
|
|
553
|
+
note: budget.otherMainMs === null ? 'not sampled yet' : 'React, timers, observers',
|
|
554
|
+
},
|
|
555
|
+
...otherMainRows(budget.otherMainMs, loafEntries, windowStartMs, budget.frames),
|
|
556
|
+
{
|
|
557
|
+
label: 'Display pipeline',
|
|
558
|
+
depth: 1,
|
|
559
|
+
ms: budget.pipelineMs,
|
|
560
|
+
note: 'everything after the main thread — the remainder',
|
|
561
|
+
},
|
|
562
|
+
];
|
|
563
|
+
if (budget.gpuDrawMs !== null) {
|
|
564
|
+
rows.push({
|
|
565
|
+
label: 'viewport GPU draw',
|
|
566
|
+
depth: 2,
|
|
567
|
+
ms: budget.gpuDrawMs,
|
|
568
|
+
note: 'measured; runs in parallel with the CPU rows',
|
|
569
|
+
parallel: true,
|
|
570
|
+
});
|
|
571
|
+
rows.push({
|
|
572
|
+
label: 'compositor + vsync wait',
|
|
573
|
+
depth: 2,
|
|
574
|
+
ms: Math.max(0, budget.pipelineMs - budget.gpuDrawMs),
|
|
575
|
+
note: 'post-effects, glass chrome, waiting for the next frame slot',
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
return { rows, budget };
|
|
579
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { meterPercent } from '@volter/game-runtime/adapter/audio-meter';
|
|
2
|
+
import type { PerformanceFrame } from '@volter/game-runtime/dev/performance-profiler';
|
|
3
|
+
|
|
4
|
+
export const AUDIO_METER_SEGMENTS = 8;
|
|
5
|
+
export const PERFORMANCE_SPARKLINE_SAMPLES = 24;
|
|
6
|
+
export const FRAME_BUDGET_60_FPS_MS = 1000 / 60;
|
|
7
|
+
|
|
8
|
+
export type PerformanceTelemetryTone = 'normal' | 'warning' | 'danger';
|
|
9
|
+
|
|
10
|
+
/** Discrete VU fill for the header: instantaneous magnitude, not history. */
|
|
11
|
+
export function audioMeterSegmentCount(level: number, segments = AUDIO_METER_SEGMENTS): number {
|
|
12
|
+
if (!(level > 0) || segments <= 0) return 0;
|
|
13
|
+
return Math.min(segments, Math.max(1, Math.round((meterPercent(level) / 100) * segments)));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FrameIntervalSample {
|
|
17
|
+
/** Newest frame consumed, even when no valid interval was available. */
|
|
18
|
+
readonly frameId: number;
|
|
19
|
+
/** P95 interval across the fresh frames, preserving spikes without plotting
|
|
20
|
+
* every noisy render tick. Null means the batch had no measured interval. */
|
|
21
|
+
readonly intervalMs: number | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Collapse every frame after `afterFrameId` into one stable history point.
|
|
25
|
+
* The header calls this on a slow cadence; the full profiler retains raw
|
|
26
|
+
* per-frame history for detailed inspection. */
|
|
27
|
+
export function sampleFrameInterval(
|
|
28
|
+
frames: readonly PerformanceFrame[],
|
|
29
|
+
afterFrameId: number,
|
|
30
|
+
): FrameIntervalSample {
|
|
31
|
+
const fresh = frames.filter((frame) => frame.id > afterFrameId);
|
|
32
|
+
const frameId = fresh.at(-1)?.id ?? afterFrameId;
|
|
33
|
+
const intervals = fresh
|
|
34
|
+
.map((frame) => frame.intervalMs)
|
|
35
|
+
.filter((interval) => Number.isFinite(interval) && interval > 0)
|
|
36
|
+
.sort((a, b) => a - b);
|
|
37
|
+
if (intervals.length === 0) return { frameId, intervalMs: null };
|
|
38
|
+
const index = Math.floor((intervals.length - 1) * 0.95);
|
|
39
|
+
return { frameId, intervalMs: intervals[index] ?? null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Alert only on a sustained trend. A single hitch remains visible as a spike
|
|
43
|
+
* but does not flash the whole instrument; three consecutive samples over the
|
|
44
|
+
* 60 FPS budget warn, and two severe half-second samples mark danger. */
|
|
45
|
+
export function performanceTelemetryTone(intervals: readonly number[]): PerformanceTelemetryTone {
|
|
46
|
+
const recent = intervals.slice(-3);
|
|
47
|
+
if (recent.slice(-2).length === 2 && recent.slice(-2).every((value) => value >= 50)) {
|
|
48
|
+
return 'danger';
|
|
49
|
+
}
|
|
50
|
+
if (recent.length === 3 && recent.every((value) => value > FRAME_BUDGET_60_FPS_MS * 1.05)) {
|
|
51
|
+
return 'warning';
|
|
52
|
+
}
|
|
53
|
+
return 'normal';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** SVG point string for a time-series sparkline. Unlike the segmented audio
|
|
57
|
+
* meter this preserves ordering: slow-frame spikes move across the strip. */
|
|
58
|
+
export function frameSparklinePoints(
|
|
59
|
+
intervals: readonly number[],
|
|
60
|
+
width: number,
|
|
61
|
+
height: number,
|
|
62
|
+
ceilingMs = 50,
|
|
63
|
+
): string {
|
|
64
|
+
if (intervals.length === 0 || width <= 0 || height <= 0) return '';
|
|
65
|
+
const denominator = Math.max(1, intervals.length - 1);
|
|
66
|
+
return intervals
|
|
67
|
+
.map((interval, index) => {
|
|
68
|
+
const x = (index / denominator) * width;
|
|
69
|
+
const normalized = Math.min(1, Math.max(0, interval / ceilingMs));
|
|
70
|
+
const y = height - normalized * height;
|
|
71
|
+
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
72
|
+
})
|
|
73
|
+
.join(' ');
|
|
74
|
+
}
|