diff --git a/bun.lock b/bun.lock index 192ce841c3..a7c5c711c9 100644 --- a/bun.lock +++ b/bun.lock @@ -358,6 +358,7 @@ "@pascal/typescript-config": "*", "@types/node": "^22", "@types/react": "^19.2.2", + "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", "typescript": "6.0.3", }, @@ -366,6 +367,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", + "react-dom": "^18 || ^19", "three": "^0.185", }, }, diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 6fd94da848..f4c77a75f3 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -26,7 +26,7 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { beginPerfAction, cancelPerfAction, commitPerfAction, useViewer } from '@pascal-app/viewer' import { type ComponentProps, memo, @@ -247,6 +247,7 @@ export function cancelFloorplanAffordanceDrag( for (const id of drag.session.affectedIds) effects.clearPreview(id) effects.endReshapeScope(drag) effects.clearDragFeedback?.() + cancelPerfAction() return true } @@ -311,6 +312,14 @@ export function floorplanAffordanceReshapeScope( return null } +function floorplanAffordancePerfAction(node: AnyNode, affordance: string): string { + if (affordance.includes('endpoint')) return `drag:${node.type}-endpoint` + if (affordance.includes('resize')) return 'drag:resize' + if (affordance.includes('rotate')) return 'drag:rotate' + if (affordance.includes('move')) return 'drag:move' + return 'drag:reshape' +} + /** * Transient live-rotation readout state. Rebuilt each pointer-move while a * rotate-arrow is dragged and cleared on release. World-plan coords. @@ -1094,6 +1103,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { event.stopPropagation() suppressBoxSelectForPointer(event) + beginPerfAction(floorplanAffordancePerfAction(node, affordance), `${node.type}:${node.id}`) const session = handler.start({ node, payload, @@ -1256,6 +1266,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { drag.historyPaused = false } drag.session.commit() + commitPerfAction() sfxEmitter.emit('sfx:structure-build') clearSurfacePlanSnapFeedback() endReshapeScope(drag) @@ -1297,6 +1308,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { drag.historyPaused = false } useScene.getState().updateNodes(finalUpdates) + commitPerfAction() sfxEmitter.emit('sfx:structure-build') } else { // Either no net change or canCommit() rejected — revert and @@ -1310,6 +1322,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { } const overrides = useLiveNodeOverrides.getState() for (const id of drag.session.affectedIds) overrides.clear(id) + cancelPerfAction() } clearSurfacePlanSnapFeedback() diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index 2a413d54fa..d14e8775ac 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -14,7 +14,7 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { markPerfAction, useViewer } from '@pascal-app/viewer' import { Plane, Vector2, Vector3 } from 'three' import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help' import { clientToPlan } from '../../lib/floorplan/plan-coords' @@ -575,6 +575,11 @@ export function deleteSelection(): boolean { if (selectedIds.length === 0) return false const commitDelete = () => { + const detail = + selectedIds.length === 1 + ? (useScene.getState().nodes[selectedIds[0]!]?.type ?? selectedIds[0]!) + : String(selectedIds.length) + markPerfAction('delete', detail) if (selectedIds.length === 1) { emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type) } else { diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 5c23bc6648..65ec105aa2 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -14,11 +14,22 @@ import { import { type HoverStyles, InteractiveSystem, + PERF_OVERLAY_ENABLED, + recordPerfSample, SceneEnvironment, useViewer, Viewer, } from '@pascal-app/viewer' -import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react' +import { + memo, + Profiler, + type ProfilerOnRenderCallback, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react' import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' @@ -101,6 +112,9 @@ const PAINT_CURSOR_BADGE_OFFSET_X = 14 const PAINT_CURSOR_BADGE_OFFSET_Y = 14 const SCENE_READY_FALLBACK_MS = 8000 type PaintCursorBadgeState = 'empty' | 'ready' | 'blocked' +const recordEditorRender: ProfilerOnRenderCallback = (_id, _phase, actualDuration) => { + if (PERF_OVERLAY_ENABLED) recordPerfSample('react-render', actualDuration) +} const EDITOR_HOVER_STYLES: HoverStyles = { default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true }, delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false }, @@ -1196,7 +1210,7 @@ function PreviewStage({ ) } -export default function Editor({ +function EditorContent({ layoutVersion = 'v1', appMenuButton, sidebarTop, @@ -1634,3 +1648,11 @@ export default function Editor({ ) } + +export default function Editor(props: EditorProps) { + return ( + + + + ) +} diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index 0f683c8b0a..1836a2ea67 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -6,7 +6,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { beginPerfAction, commitPerfAction, useViewer } from '@pascal-app/viewer' import { useCallback, useMemo, useRef } from 'react' import type { Vector3 } from 'three' import usePlacementPreview from '../../../store/use-placement-preview' @@ -220,6 +220,7 @@ export function useDraftNode(): DraftNodeHandle { const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId if (!parentId) return null + beginPerfAction('place:item', draft.id) // Delete draft while paused (invisible to undo) useScene.getState().deleteNode(draft.id) draftRef.current = null @@ -268,6 +269,7 @@ export function useDraftNode(): DraftNodeHandle { adoptedRef.current = false originalStateRef.current = null + commitPerfAction() return committedNode.id }, [], diff --git a/packages/editor/src/components/ui/command-palette/index.tsx b/packages/editor/src/components/ui/command-palette/index.tsx index 2979d80f47..a974415c78 100644 --- a/packages/editor/src/components/ui/command-palette/index.tsx +++ b/packages/editor/src/components/ui/command-palette/index.tsx @@ -2,7 +2,7 @@ import type { AnyNodeId, LevelNode } from '@pascal-app/core' import { useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { markPerfAction, useViewer } from '@pascal-app/viewer' import { Command, useCommandState } from 'cmdk' import { ChevronRight, Search } from 'lucide-react' import type { ReactNode } from 'react' @@ -408,7 +408,12 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm key={level.id} label={getLevelDisplayName(level)} onSelect={() => - run(() => useViewer.getState().setSelection({ levelId: level.id })) + run(() => { + if (level.id !== useViewer.getState().selection.levelId) { + markPerfAction('level-switch', level.id) + } + useViewer.getState().setSelection({ levelId: level.id }) + }) } /> ))} diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index 18a3e93f78..7e88c4af06 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -27,7 +27,7 @@ import { LevelNode, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { markPerfAction, useViewer } from '@pascal-app/viewer' import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react' import { type ButtonHTMLAttributes, @@ -631,13 +631,14 @@ export function FloatingLevelSelector() { onDuplicate={(preset) => handleDuplicateLevel(level, preset)} onPaste={() => handlePasteToLevel(level)} onRequestDelete={() => setDeletingLevel(level)} - onSelect={() => + onSelect={() => { + if (!isSelected) markPerfAction('level-switch', level.id) setSelection( resolvedBuildingId ? { buildingId: resolvedBuildingId, levelId: level.id } : { levelId: level.id }, ) - } + }} /> {showGapBelow && !draggingLevelId && ( diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index 3f4cae036a..7520477e95 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -11,7 +11,7 @@ import { useScene, type ZoneNode, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { markPerfAction, useViewer } from '@pascal-app/viewer' import { Camera, ChevronDown, @@ -709,7 +709,8 @@ const LevelItem = memo(function LevelItem({ ? (level.parentId as BuildingNode['id']) : undefined - const selectLevel = (levelId: LevelNode['id']) => { + const selectLevel = (levelId: LevelNode['id'], measure = true) => { + if (measure && selectedLevelId !== levelId) markPerfAction('level-switch', levelId) setSelection(buildingId ? { buildingId, levelId } : { levelId }) } @@ -748,7 +749,7 @@ const LevelItem = memo(function LevelItem({ ) } createNodes(createOps) - selectLevel(newLevelId as LevelNode['id']) + selectLevel(newLevelId as LevelNode['id'], false) setDuplicateDialogOpen(false) } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx index 168c39d7a6..c5b0c5197c 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx @@ -1,5 +1,5 @@ import { type LevelNode, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { markPerfAction, useViewer } from '@pascal-app/viewer' import { Layers } from 'lucide-react' import { memo, useCallback, useState } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -30,7 +30,10 @@ export const LevelTreeNode = memo(function LevelTreeNode({ const isHovered = useViewer((state) => state.hoveredId === nodeId) const setSelection = useViewer((state) => state.setSelection) - const handleClick = useCallback(() => setSelection({ levelId: nodeId }), [nodeId, setSelection]) + const handleClick = useCallback(() => { + if (!isSelected) markPerfAction('level-switch', nodeId) + setSelection({ levelId: nodeId }) + }, [isSelected, nodeId, setSelection]) const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId]) const handleToggle = useCallback(() => setExpanded((prev) => !prev), []) const handleStartEditing = useCallback(() => setIsEditing(true), []) diff --git a/packages/editor/src/components/viewer/viewer-scene-header.tsx b/packages/editor/src/components/viewer/viewer-scene-header.tsx index 1d44a9be00..474400adaf 100644 --- a/packages/editor/src/components/viewer/viewer-scene-header.tsx +++ b/packages/editor/src/components/viewer/viewer-scene-header.tsx @@ -9,7 +9,7 @@ import { useScene, type ZoneNode, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { markPerfAction, useViewer } from '@pascal-app/viewer' import { ArrowLeft, ChevronRight, Layers } from 'lucide-react' import Link from 'next/link' import type { ReactNode } from 'react' @@ -76,6 +76,7 @@ export const ViewerSceneHeader = ({ const handleLevelClick = (levelId: LevelNode['id']) => { // When switching levels, deselect zone and items + if (levelId !== selection.levelId) markPerfAction('level-switch', levelId) useViewer.getState().setSelection({ levelId }) } diff --git a/packages/editor/src/components/viewer/viewer-stage.tsx b/packages/editor/src/components/viewer/viewer-stage.tsx index d3cc8a03e2..572ba3f3fa 100644 --- a/packages/editor/src/components/viewer/viewer-stage.tsx +++ b/packages/editor/src/components/viewer/viewer-stage.tsx @@ -1,7 +1,7 @@ 'use client' import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { markPerfAction, useViewer } from '@pascal-app/viewer' import type { ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -134,6 +134,9 @@ export function ViewerStage({ const chooseLevel = useCallback( (nextLevelId: string, notify = true) => { setInternalLevelId(nextLevelId) + if (notify && nextLevelId !== useViewer.getState().selection.levelId) { + markPerfAction('level-switch', nextLevelId) + } selectViewerLevel(scene?.nodes ?? useScene.getState().nodes, nextLevelId) if (notify) onLevelChange?.(nextLevelId) }, diff --git a/packages/editor/src/hooks/use-drag-action.ts b/packages/editor/src/hooks/use-drag-action.ts index b0c9523716..dee069fd89 100644 --- a/packages/editor/src/hooks/use-drag-action.ts +++ b/packages/editor/src/hooks/use-drag-action.ts @@ -14,6 +14,7 @@ import { type SpatialQuery, useScene, } from '@pascal-app/core' +import { beginPerfAction, cancelPerfAction, commitPerfAction } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' const sceneApi = createSceneApi(useScene) @@ -74,14 +75,26 @@ export function useDragAction(args: UseDragActionArgs) { useEffect(() => { if (!args.active) return + const initial = argsRef.current.initial + const nodeType = initial.node?.type + const isEndpoint = initial.handleId === 'start' || initial.handleId === 'end' + const actionName = isEndpoint && nodeType ? `drag:${nodeType}-endpoint` : 'drag:move' + const session = createDragSession(argsRef.current.action, sceneApi, { spatialQuery: argsRef.current.spatialQuery, childQuery: argsRef.current.childQuery, - onCommit: () => argsRef.current.onCommit?.(), - onCancel: () => argsRef.current.onCancel?.(), + onCommit: () => { + commitPerfAction() + argsRef.current.onCommit?.() + }, + onCancel: () => { + cancelPerfAction() + argsRef.current.onCancel?.() + }, }) - session.start(argsRef.current.initial) + beginPerfAction(actionName, initial.node?.id ?? nodeType ?? '') + session.start(initial) const activatedAt = Date.now() const graceMs = argsRef.current.activationGraceMs ?? 150 @@ -119,6 +132,7 @@ export function useDragAction(args: UseDragActionArgs) { } // If the parent flipped `active` to false (or unmounted) while we were // still mid-drag, treat it as a cancel — no dangling history pause. + if (session.isActive()) cancelPerfAction() session.dispose() } }, [args.active]) diff --git a/packages/editor/src/hooks/use-grid-events.ts b/packages/editor/src/hooks/use-grid-events.ts index a93533d076..9606cb1483 100644 --- a/packages/editor/src/hooks/use-grid-events.ts +++ b/packages/editor/src/hooks/use-grid-events.ts @@ -5,7 +5,7 @@ import { type GridEvent, sceneRegistry, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { timeSpan, useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { Plane, Raycaster, Vector2, Vector3 } from 'three' @@ -109,7 +109,7 @@ export function useGridEvents(gridY: number) { const handlePointerMove = (e: PointerEvent) => { // Emit move even if camera is dragging, so tools like PolygonEditor still work - emit('move', e) + timeSpan('pointer', () => emit('move', e)) } const handleDoubleClick = (e: MouseEvent) => { diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 723e3cd793..e1ff9154a0 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -7,7 +7,7 @@ import { resumeSpaceDetection, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { cancelPerfAction, markPerfAction, useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' import { Vector3 } from 'three' import { @@ -102,6 +102,9 @@ function rotateGroupSelection(direction: 1 | -1): boolean { let _toolCancelConsumed = false export const markToolCancelConsumed = () => { _toolCancelConsumed = true + // A consumed cancel means the active gesture reverted — the perf ledger must + // not measure the restore as a committed action's settle. + cancelPerfAction() } // Escape's fall-through when no tool consumed the cancel: drop back to the @@ -458,8 +461,10 @@ export const useKeyboard = ({ const currentIdx = levelId ? levels.indexOf(levelId as any) : -1 const nextIdx = currentIdx < levels.length - 1 ? currentIdx + 1 : currentIdx if (nextIdx !== -1 && nextIdx !== currentIdx) { + markPerfAction('level-switch', levels[nextIdx] as string) useViewer.getState().setSelection({ levelId: levels[nextIdx] as any }) } else if (currentIdx === -1) { + markPerfAction('level-switch', levels[0] as string) useViewer.getState().setSelection({ levelId: levels[0] as any }) } } @@ -479,8 +484,10 @@ export const useKeyboard = ({ const currentIdx = levelId ? levels.indexOf(levelId as any) : -1 const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx if (prevIdx !== -1 && prevIdx !== currentIdx) { + markPerfAction('level-switch', levels[prevIdx] as string) useViewer.getState().setSelection({ levelId: levels[prevIdx] as any }) } else if (currentIdx === -1) { + markPerfAction('level-switch', levels[levels.length - 1] as string) useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any }) } } diff --git a/packages/editor/src/lib/history.ts b/packages/editor/src/lib/history.ts index 9eb1834c56..c0d4afee5d 100644 --- a/packages/editor/src/lib/history.ts +++ b/packages/editor/src/lib/history.ts @@ -1,4 +1,5 @@ import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core' +import { markPerfAction } from '@pascal-app/viewer' export type HistoryCommandState = { canRedo: boolean @@ -72,16 +73,28 @@ function refreshSceneAfterHistoryJump() { } export function runUndo(): HistoryCommandResult { - if (historyCommandDelegate) return historyCommandDelegate.undo() + if (historyCommandDelegate) { + const result = historyCommandDelegate.undo() + // Mark only real jumps: a no-op undo must not open a receipt (or + // interrupt one that is still settling). + if (result.kind !== 'empty') markPerfAction('undo') + return result + } if (useScene.temporal.getState().pastStates.length === 0) return { kind: 'empty' } + markPerfAction('undo') useScene.temporal.getState().undo() refreshSceneAfterHistoryJump() return { kind: 'applied', persistence: 'local' } } export function runRedo(): HistoryCommandResult { - if (historyCommandDelegate) return historyCommandDelegate.redo() + if (historyCommandDelegate) { + const result = historyCommandDelegate.redo() + if (result.kind !== 'empty') markPerfAction('redo') + return result + } if (useScene.temporal.getState().futureStates.length === 0) return { kind: 'empty' } + markPerfAction('redo') useScene.temporal.getState().redo() refreshSceneAfterHistoryJump() return { kind: 'applied', persistence: 'local' } diff --git a/packages/editor/src/store/use-interaction-scope.ts b/packages/editor/src/store/use-interaction-scope.ts index 6a9423e9e3..b5c41bc639 100644 --- a/packages/editor/src/store/use-interaction-scope.ts +++ b/packages/editor/src/store/use-interaction-scope.ts @@ -1,6 +1,12 @@ 'use client' import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' +import { + beginPerfAction, + commitPerfAction, + getActivePerfActionId, + hasUncommittedPerfAction, +} from '@pascal-app/viewer' import { useRef } from 'react' import { create } from 'zustand' import { useShallow } from 'zustand/react/shallow' @@ -45,9 +51,60 @@ export type InteractionScopeState = { endIf: (match: (scope: ActiveInteractionScope) => boolean) => void } +// Perf action ledger (`?perf`): every 3D gesture funnels through this store, so +// begin/end are the one generic bracket for action-cost receipts. A more +// specific call site (use-drag-action, the 2D floorplan layer) may have begun +// its own action first — yield to it while ITS gesture is in flight, but a +// merely-settling previous action must not swallow a new gesture. The scope +// remembers the id it began and commits only that action at `end`, so a +// specific site's receipt (or a cancelled one finalized via +// markToolCancelConsumed) is never committed by the generic bracket. Known +// limit: a scope-begun gesture cancelled through a path that skips +// markToolCancelConsumed still commits at end and bills its revert as settle. +let scopePerfActionId: number | null = null + +function beginScopePerfAction(scope: ActiveInteractionScope): void { + if (hasUncommittedPerfAction()) return + switch (scope.kind) { + case 'moving': + scopePerfActionId = beginPerfAction('drag:move', scope.nodeType) + break + case 'placing': + scopePerfActionId = beginPerfAction( + `place:${scope.node?.type ?? 'node'}`, + scope.node?.id ?? '', + ) + break + case 'reshaping': { + const nodeType = useScene.getState().nodes[scope.nodeId as AnyNodeId]?.type + scopePerfActionId = beginPerfAction( + `drag:${nodeType ? `${nodeType}-` : ''}${scope.reshape}`, + scope.nodeId, + ) + break + } + case 'handle-drag': + scopePerfActionId = beginPerfAction(`drag:${scope.handle}`, scope.nodeId) + break + default: + // drafting / mesh-editing are long-lived modes, not gestures + break + } +} + +function commitScopePerfAction(): void { + if (scopePerfActionId !== null && getActivePerfActionId() === scopePerfActionId) { + commitPerfAction() + } + scopePerfActionId = null +} + const useInteractionScope = create((set, get) => ({ scope: IDLE_SCOPE, - begin: (scope) => set({ scope }), + begin: (scope) => { + beginScopePerfAction(scope) + set({ scope }) + }, update: (patch) => set((state) => { if (state.scope.kind === 'idle') return state @@ -56,12 +113,16 @@ const useInteractionScope = create((set, get) => ({ }), end: () => { if (get().scope.kind === 'idle') return + commitScopePerfAction() set({ scope: IDLE_SCOPE }) }, endIf: (match) => { const scope = get().scope if (scope.kind === 'idle') return - if (match(scope)) set({ scope: IDLE_SCOPE }) + if (match(scope)) { + commitScopePerfAction() + set({ scope: IDLE_SCOPE }) + } }, })) diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 8415e956a9..4097c35b9a 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -27,6 +27,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", + "react-dom": "^18 || ^19", "three": "^0.185" }, "dependencies": { @@ -39,6 +40,7 @@ "@pascal/typescript-config": "*", "@types/node": "^22", "@types/react": "^19.2.2", + "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", "typescript": "6.0.3" }, diff --git a/packages/viewer/src/components/viewer/frame-limiter.tsx b/packages/viewer/src/components/viewer/frame-limiter.tsx index 0a1bff5beb..ccd1859939 100644 --- a/packages/viewer/src/components/viewer/frame-limiter.tsx +++ b/packages/viewer/src/components/viewer/frame-limiter.tsx @@ -1,5 +1,6 @@ import { useThree } from '@react-three/fiber' import { useLayoutEffect, useRef } from 'react' +import { timeSpan } from '../../lib/perf-tracks' import useViewer from '../../store/use-viewer' type FrameLimiterProps = { @@ -86,13 +87,13 @@ const FrameLimiter: React.FC = ({ fps = 50, paused = false }) const frameTime = clock.sample(t, interval) if (frameTime === null) return nextFrameTimeRef.current = frameTime - advance(frameTime) + timeSpan('frame-cpu', () => advance(frameTime)) } function kick() { syncSize() const frameTime = clock.step(1 / 1000) nextFrameTimeRef.current = frameTime - advance(frameTime) + timeSpan('frame-cpu', () => advance(frameTime)) } function onVisibilityChange() { if (document.visibilityState === 'visible') kick() @@ -103,7 +104,7 @@ const FrameLimiter: React.FC = ({ fps = 50, paused = false }) timer = setInterval(() => { const frameTime = clock.step(interval / 1000) nextFrameTimeRef.current = frameTime - advance(frameTime) + timeSpan('frame-cpu', () => advance(frameTime)) }, interval) } else { // Kick off custom render loop diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index f6a6cb7e0f..c942080f0c 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -18,7 +18,7 @@ import { } from 'react' import * as THREE from 'three/webgpu' import { hasDrawableGeometry } from '../../lib/drawable-geometry' -import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' +import { PERF_OVERLAY_ENABLED } from '../../lib/gpu-perf' import { applyIsolation, clearIsolation } from '../../lib/isolation' import { ensureKtx2Support } from '../../lib/ktx2-loader' import type { ColorPreset, RenderShading } from '../../lib/materials' @@ -28,11 +28,13 @@ import { installTextureNodeNullGuard } from '../../lib/texture-node-guard' import useViewer, { type RenderContext } from '../../store/use-viewer' import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system' import { GeometrySystem } from '../../systems/geometry/geometry-system' +import { PerfActionSettleSystem } from '../../systems/perf-action-settle/perf-action-settle-system' import { ErrorBoundary } from '../error-boundary' import { SceneRenderer } from '../renderers/scene-renderer' import FrameLimiter from './frame-limiter' import { Lights } from './lights' import { PerfMonitor } from './perf-monitor' +import { PerfPanel } from './perf-panel' import { PointerRaycastLayers } from './pointer-raycast-layers' import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing' import { RegisteredSystems } from './registered-systems' @@ -515,127 +517,126 @@ const Viewer = forwardRef(function Viewer( return } return ( - { - const canvas = props.canvas - const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined - if (cached) return cached - const promise = (async () => { - const result = await initializeGpuRenderer({ - // Supplying `device` makes three skip its own `requestAdapter`, - // so R3F's `powerPreference` only reaches the GPU if we forward it. - powerPreference: props.powerPreference, - createRenderer: (backendParameters) => { - const renderer = new THREE.WebGPURenderer({ - ...(props as any), - ...backendParameters, - alpha: true, - }) - renderer.toneMapping = THREE.ACESFilmicToneMapping - renderer.toneMappingExposure = getSceneTheme( - useViewer.getState().sceneTheme, - ).toneMappingExposure - return renderer - }, - }) - if (result.status === 'ready') { - installEmptyDrawGuard(result.renderer) - return result.renderer - } - - if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas) - console.error('[viewer] WebGPURenderer init failed', result.error) - setRendererInitFailed(true) - // Never settles on purpose. Rejecting is what produced - // MONOREPO-EDITOR-59: R3F awaits this inside its own configure() - // with no catch, so a rejection surfaces as an unhandled rejection. - // Resolving is worse still — R3F would call render() on a renderer - // that has no context. The state update above unmounts this Canvas, - // which is what releases the pending configure(). - return new Promise(() => undefined) - })() - if (canvas) WEBGPU_RENDERER_CACHE.set(canvas, promise) - return promise - }) as any - } - resize={{ - debounce: 100, - }} - shadows={{ - type: THREE.PCFShadowMap, - enabled: shadowsEnabled, - }} - > - - - - - - - - - {/* + {/* DOM overlay, deliberately outside — drei Html wrappers carry + a camera transform that defeats position:fixed (see perf-panel.tsx). */} + {(perf || PERF_OVERLAY_ENABLED) && } + { + const canvas = props.canvas + const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined + if (cached) return cached + const promise = (async () => { + const result = await initializeGpuRenderer({ + // Supplying `device` makes three skip its own `requestAdapter`, + // so R3F's `powerPreference` only reaches the GPU if we forward it. + powerPreference: props.powerPreference, + createRenderer: (backendParameters) => { + const renderer = new THREE.WebGPURenderer({ + ...(props as any), + ...backendParameters, + alpha: true, + // Allocates the backend's timestamp query pool so + // `resolveTimestampsAsync()` can report real GPU render-pass + // time (post-processing.tsx). The backend self-disables it + // when the device lacks 'timestamp-query'. + trackTimestamp: PERF_OVERLAY_ENABLED, + }) + renderer.toneMapping = THREE.ACESFilmicToneMapping + renderer.toneMappingExposure = getSceneTheme( + useViewer.getState().sceneTheme, + ).toneMappingExposure + return renderer + }, + }) + if (result.status === 'ready') { + installEmptyDrawGuard(result.renderer) + return result.renderer + } + + if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas) + console.error('[viewer] WebGPURenderer init failed', result.error) + setRendererInitFailed(true) + // Never settles on purpose. Rejecting is what produced + // MONOREPO-EDITOR-59: R3F awaits this inside its own configure() + // with no catch, so a rejection surfaces as an unhandled rejection. + // Resolving is worse still — R3F would call render() on a renderer + // that has no context. The state update above unmounts this Canvas, + // which is what releases the pending configure(). + return new Promise(() => undefined) + })() + if (canvas) WEBGPU_RENDERER_CACHE.set(canvas, promise) + return promise + }) as any + } + resize={{ + debounce: 100, + }} + shadows={{ + type: THREE.PCFShadowMap, + enabled: shadowsEnabled, + }} + > + + + + + + + + + {/* */} - - {useBvh ? ( - + + {useBvh ? ( + + + + ) : ( - - ) : ( - - )} + )} - {/* Generic slab-elevation lift for any kind that declares + {/* Generic slab-elevation lift for any kind that declares `capabilities.floorPlaced`. Runs at frame priority 1 so it lands its mesh.position.y override before the priority-2 systems below clear the dirty mark. */} - - {/* Generic geometry rebuild loop for any registered kind that + + {/* Generic geometry rebuild loop for any registered kind that ships `def.geometry`. Reads dirtyNodes, calls the kind's pure builder, swaps the registered group's children. See wiki/architecture/node-definitions.md. */} - - {/* Automated stair opening sync — updates slab/ceiling cutouts + + {/* Automated stair opening sync — updates slab/ceiling cutouts whenever stairs, slabs, or levels change. */} - - {/* Mounts systems contributed by registry-backed kinds. Each + + {/* Mounts systems contributed by registry-backed kinds. Each kind's `def.system` is loaded via lazy() and rendered here, ordered by `system.priority`. */} - - - {selectionManager === 'default' && } - {(perf || PERF_OVERLAY_ENABLED) && } - {children} - - + + + {selectionManager === 'default' && } + {(perf || PERF_OVERLAY_ENABLED) && } + {/* Feeds the action-cost ledger the frame's settle state (dirty + queue + deferred wall rebuilds) at a priority after every other + system, so a receipt closes when the user can actually see the + edit. */} + {(perf || PERF_OVERLAY_ENABLED) && } + {children} + + + ) }) -const DebugRenderer = () => { - useFrame(({ gl, scene, camera }) => { - const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0 - gl.render(scene, camera) - if (PERF_OVERLAY_ENABLED) { - const queue = (gl as any).backend?.device?.queue as - | { onSubmittedWorkDone?: () => Promise } - | undefined - queue?.onSubmittedWorkDone?.().then(() => { - pushGpuSample(performance.now() - submittedAt) - }) - } - }) - return null -} - export default Viewer diff --git a/packages/viewer/src/components/viewer/perf-monitor.tsx b/packages/viewer/src/components/viewer/perf-monitor.tsx index 2972a6b5f1..d248e32730 100644 --- a/packages/viewer/src/components/viewer/perf-monitor.tsx +++ b/packages/viewer/src/components/viewer/perf-monitor.tsx @@ -1,40 +1,103 @@ -import { useScene } from '@pascal-app/core' -import { Html } from '@react-three/drei' +import { sceneRegistry, useScene } from '@pascal-app/core' import { useFrame, useThree } from '@react-three/fiber' -import { useEffect, useRef, useState } from 'react' -import { drainGpuSamples } from '../../lib/gpu-perf' +import { useEffect, useRef } from 'react' +import { Vector3 } from 'three' +import { initPerfObservers } from '../../lib/perf-observers' +import { publishPerfStats } from '../../lib/perf-panel-store' +import { clearPerfMeasures, drainPerfCounters, type PerfCounterBucket } from '../../lib/perf-tracks' const SAMPLE_INTERVAL = 0.5 // seconds between display updates +// Walking the scene graph is the overlay's own biggest cost on large projects, +// and the counts barely move between ticks — sample it at 2s instead of 0.5s. +const CENSUS_EVERY_TICKS = 4 +const MAX_TRACK_LINES = 8 +// Tracks printed on their own lines above (render path + the frame-limiter's +// whole-frame span); everything else drained from perf-tracks lands in TRACKS. +const RENDER_TRACKS = new Set(['gpu-render', 'gpu-queue', 'render-encode', 'frame-cpu']) + +type TrackLine = { name: string; totalMs: number; count: number; maxMs: number } + +type Census = { meshes: number; lines: number; sprites: number; lights: number } + +/** + * `scene.traverse` descends into hidden subtrees, so a collapsed level or an + * isolated-away wing still inflated the counts. Recurse manually and cut at the + * first invisible node — that matches what the renderer actually walks. + */ +function countVisible(object: any, out: Census): void { + if (object.visible === false) return + if (object.isMesh) out.meshes++ + else if (object.isLine || object.isLineSegments || object.isLineLoop) out.lines++ + else if (object.isSprite) out.sprites++ + else if (object.isLight) out.lights++ + const children = object.children + if (!children) return + for (let i = 0; i < children.length; i++) countVisible(children[i], out) +} + +function averageOf(bucket: PerfCounterBucket | undefined): number | null { + if (!bucket || bucket.count === 0) return null + return bucket.totalMs / bucket.count +} + +/** + * Headless collector. Runs inside (it needs useFrame + gl.info) and + * publishes each window's stats to perf-panel-store; the visible panel is + * , mounted outside the canvas — see perf-panel.tsx for why. + */ export const PerfMonitor = () => { - const [stats, setStats] = useState({ - fps: 0, - frameMs: 0, - gpuMs: 0, - gpuMaxMs: 0, - drawCalls: 0, - triangles: 0, - dirty: 0, - dirtyDetail: '', - meshes: 0, - lines: 0, - sprites: 0, - lights: 0, - }) const frameCount = useRef(0) const elapsed = useRef(0) - const lastMs = useRef(0) + const tickCount = useRef(0) // Carry the previous tick's reading forward when no fresh samples arrive, // so the display doesn't flicker to "—" on slow resolve windows. - const lastGpuMs = useRef(0) - const lastGpuMaxMs = useRef(0) + const lastFrame = useRef({ ms: 0, max: 0 }) + const lastGpu = useRef({ ms: 0, max: 0, seen: false }) + const lastQueue = useRef({ ms: 0, max: 0 }) + const lastEncode = useRef({ ms: 0, max: 0 }) + const lastCensus = useRef({ meshes: 0, lines: 0, sprites: 0, lights: 0 }) // Take ownership of info reset. The custom RenderPipeline.render() path // we use in post-processing doesn't trigger three.js's automatic per-frame - // info reset, so calls/triangles accumulate across frames and the display + // info reset, so drawCalls/triangles accumulate across frames and the display // shows lifetime totals. Disabling autoReset and explicitly resetting at // each window gives true per-frame averages. const gl = useThree((s) => s.gl) + const getThree = useThree((s) => s.get) + useEffect(() => { + initPerfObservers() + }, []) + // Scripted-probe hooks for the scaling-matrix runner (scripts/perf/…): only + // mounted with `?perf`, so nothing reaches `window` in normal sessions. + // `projectNode` returns CSS pixels relative to the canvas, ready for a + // synthetic click on the node. + useEffect(() => { + const probe = { + listNodes(type: string): string[] { + return Object.values(useScene.getState().nodes) + .filter((n) => n.type === type) + .map((n) => n.id as string) + }, + projectNode(nodeId: string): { x: number; y: number; behindCamera: boolean } | null { + const object = sceneRegistry.nodes.get(nodeId) + if (!object) return null + const { camera, size } = getThree() + const v = new Vector3() + object.getWorldPosition(v) + v.project(camera) + return { + x: ((v.x + 1) / 2) * size.width, + y: ((1 - v.y) / 2) * size.height, + behindCamera: v.z > 1, + } + }, + } + ;(window as any).__pascalPerf = probe + return () => { + if ((window as any).__pascalPerf === probe) delete (window as any).__pascalPerf + } + }, [getThree]) useEffect(() => { if (!gl?.info) return const previousAutoReset = gl.info.autoReset @@ -45,115 +108,112 @@ export const PerfMonitor = () => { } }, [gl]) - useFrame(({ gl, scene, clock }, delta) => { + useFrame(({ gl, scene, clock }) => { frameCount.current++ + const now = clock.elapsedTime const dt = now - elapsed.current + if (dt < SAMPLE_INTERVAL) return - if (dt >= SAMPLE_INTERVAL) { - const fps = Math.round(frameCount.current / dt) - const frameMs = lastMs.current - const info = gl.info - // calls/triangles have been accumulating since the last reset (start of - // window). Divide by frameCount to get a per-frame average. - const totalCalls = info.render?.calls ?? 0 - const totalTriangles = info.render?.triangles ?? 0 - const drawCalls = Math.round(totalCalls / Math.max(1, frameCount.current)) - const triangles = totalTriangles / Math.max(1, frameCount.current) - info.reset() - const sceneState = useScene.getState() - const dirty = sceneState.dirtyNodes.size - let dirtyDetail = '' - if (dirty > 0) { - const counts = new Map() - for (const id of sceneState.dirtyNodes) { - const type = sceneState.nodes[id]?.type ?? 'missing' - counts.set(type, (counts.get(type) ?? 0) + 1) - } - dirtyDetail = [...counts.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([type, count]) => `${count} ${type}`) - .join(', ') - } + tickCount.current++ + const fps = Math.round(frameCount.current / dt) - // Count visible drawables by type so we can match scene contents - // against the renderer's draw count and find hidden contributors. - let meshes = 0 - let lines = 0 - let sprites = 0 - let lights = 0 - scene.traverse((obj: any) => { - if (!obj.visible) return - if (obj.isMesh) meshes++ - else if (obj.isLine || obj.isLineSegments || obj.isLineLoop) lines++ - else if (obj.isSprite) sprites++ - else if (obj.isLight) lights++ - }) - - // GPU samples are pushed by post-processing.tsx after each pipeline - // render via device.queue.onSubmittedWorkDone(). We drain whatever - // has accumulated since the last tick. - const samples = drainGpuSamples() - if (samples.length > 0) { - let sum = 0 - let max = 0 - for (const s of samples) { - sum += s - if (s > max) max = s - } - lastGpuMs.current = sum / samples.length - lastGpuMaxMs.current = max + const info = gl.info as any + // drawCalls (NOT `calls`, which counts renderer.render() invocations for the + // lifetime of the renderer and is never cleared by reset()) has been + // accumulating since the last reset at the start of this window. + const totalDrawCalls = info.render?.drawCalls ?? 0 + const totalTriangles = info.render?.triangles ?? 0 + const drawCalls = Math.round(totalDrawCalls / Math.max(1, frameCount.current)) + const triangles = totalTriangles / Math.max(1, frameCount.current) + const memory = info.memory ?? {} + info.reset() + + const sceneState = useScene.getState() + const dirty = sceneState.dirtyNodes.size + let dirtyDetail = '' + if (dirty > 0) { + const counts = new Map() + for (const id of sceneState.dirtyNodes) { + const type = sceneState.nodes[id]?.type ?? 'missing' + counts.set(type, (counts.get(type) ?? 0) + 1) } + dirtyDetail = [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => `${count} ${type}`) + .join(', ') + } - setStats({ - fps, - frameMs, - gpuMs: lastGpuMs.current, - gpuMaxMs: lastGpuMaxMs.current, - drawCalls, - triangles, - dirty, - dirtyDetail, - meshes, - lines, - sprites, - lights, - }) - frameCount.current = 0 - elapsed.current = now + if (tickCount.current % CENSUS_EVERY_TICKS === 1) { + const census: Census = { meshes: 0, lines: 0, sprites: 0, lights: 0 } + countVisible(scene, census) + lastCensus.current = census } - lastMs.current = Math.round(delta * 1000 * 10) / 10 + const counters = drainPerfCounters() + // Whole-frame main-thread work measured around FrameLimiter's advance() + // call — this is CPU time per frame, unlike FPS which is just cadence. + const frameAvg = averageOf(counters.get('frame-cpu')) + if (frameAvg !== null) { + lastFrame.current = { ms: frameAvg, max: counters.get('frame-cpu')?.maxMs ?? 0 } + } + const gpuAvg = averageOf(counters.get('gpu-render')) + if (gpuAvg !== null) { + lastGpu.current = { ms: gpuAvg, max: counters.get('gpu-render')?.maxMs ?? 0, seen: true } + } + const queueAvg = averageOf(counters.get('gpu-queue')) + if (queueAvg !== null) { + lastQueue.current = { ms: queueAvg, max: counters.get('gpu-queue')?.maxMs ?? 0 } + } + const encodeAvg = averageOf(counters.get('render-encode')) + if (encodeAvg !== null) { + lastEncode.current = { ms: encodeAvg, max: counters.get('render-encode')?.maxMs ?? 0 } + } + const tracks: TrackLine[] = [...counters.entries()] + .filter(([name, bucket]) => !RENDER_TRACKS.has(name) && bucket.count > 0) + .map(([name, bucket]) => ({ + name, + totalMs: bucket.totalMs, + count: bucket.count, + maxMs: bucket.maxMs, + })) + .sort((a, b) => b.totalMs - a.totalMs) + .slice(0, MAX_TRACK_LINES) + + publishPerfStats({ + fps, + frameMs: lastFrame.current.ms, + frameMaxMs: lastFrame.current.max, + encodeMs: lastEncode.current.ms, + encodeMaxMs: lastEncode.current.max, + gpuMs: lastGpu.current.ms, + gpuMaxMs: lastGpu.current.max, + gpuTracked: lastGpu.current.seen, + queueMs: lastQueue.current.ms, + queueMaxMs: lastQueue.current.max, + drawCalls, + triangles, + dirty, + dirtyDetail, + geometries: memory.geometries ?? 0, + textures: memory.textures ?? 0, + gpuBytes: memory.total ?? 0, + heapBytes: (performance as any).memory?.usedJSHeapSize ?? 0, + meshes: lastCensus.current.meshes, + lines: lastCensus.current.lines, + sprites: lastCensus.current.sprites, + lights: lastCensus.current.lights, + tracks, + }) + + // perf-tracks emits a `performance.measure` per span for the DevTools + // custom tracks. The recording already captured them; without this the + // timeline buffer grows for the whole session. + clearPerfMeasures() + + frameCount.current = 0 + elapsed.current = now }) - return ( - -
- {`FPS ${stats.fps} -GPU ${stats.gpuMs > 0 ? `${stats.gpuMs.toFixed(1)}ms (max ${stats.gpuMaxMs.toFixed(1)})` : '—'} -DRAW ${stats.drawCalls} -TRI ${(stats.triangles / 1000).toFixed(1)}k -DIRTY ${stats.dirty}${stats.dirtyDetail ? ` (${stats.dirtyDetail})` : ''} -MESH ${stats.meshes} -LINE ${stats.lines} -SPRITE ${stats.sprites} -LIGHT ${stats.lights}`} -
- - ) + return null } diff --git a/packages/viewer/src/components/viewer/perf-panel.tsx b/packages/viewer/src/components/viewer/perf-panel.tsx new file mode 100644 index 0000000000..e8ce63ff23 --- /dev/null +++ b/packages/viewer/src/components/viewer/perf-panel.tsx @@ -0,0 +1,308 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { usePerfActionReceipts } from '../../lib/perf-actions' +import { usePerfStats } from '../../lib/perf-panel-store' + +// Rendered OUTSIDE (drei wrappers carry a camera-driven +// transform, which turns position:fixed into "fixed relative to the wrapper" +// and made the old overlay drift with the camera). Portal to so no +// ancestor transform/overflow can capture it. + +const STORAGE_KEY = 'pascal-perf-panel' +const PANEL_WIDTH = 248 + +type PanelPlacement = { x: number; y: number; docked: 'left' | 'right' | null } + +function loadPlacement(): PanelPlacement { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) return JSON.parse(raw) as PanelPlacement + } catch {} + return { x: 8, y: 8, docked: null } +} + +function savePlacement(placement: PanelPlacement): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(placement)) + } catch {} +} + +function fpsColor(fps: number): string { + return fps < 30 ? '#f87171' : fps < 48 ? '#fbbf24' : '#4ade80' +} + +function mb(bytes: number): string { + return `${Math.round(bytes / (1024 * 1024))}MB` +} + +const label: React.CSSProperties = { color: '#8b90a0' } +const value: React.CSSProperties = { textAlign: 'right', color: '#e7e9f0' } +const grid: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'auto 1fr', + columnGap: 12, +} +const section: React.CSSProperties = { + marginTop: 6, + paddingTop: 6, + borderTop: '1px solid rgba(255,255,255,0.07)', +} +const clip: React.CSSProperties = { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} + +const Row = ({ name, children }: { name: string; children: React.ReactNode }) => ( + <> + {name} + {children} + +) + +const ACTION_TRACK_LINES = 4 +const OLDER_ACTION_LINES = 2 + +/** + * Cost of the last edit gesture, from the action ledger — see lib/perf-actions.ts + * for what counts as settled. Amber total = the action never settled (the user + * started another one, or it blew the settle budget). + */ +const LastAction = () => { + const [latest, ...older] = usePerfActionReceipts() + if (!latest) return null + return ( +
+
last action
+
+ + {latest.detail ? `${latest.name} ${latest.detail}` : latest.name} + + + {latest.outcome === 'settled' + ? `${latest.totalMs.toFixed(0)}ms` + : `${latest.totalMs.toFixed(0)}ms ${latest.outcome}`} + +
+
+ {`drag ${latest.dragMs.toFixed(0)} / settle ${latest.settleMs.toFixed(0)} (${latest.settleFrames} frames)`} +
+ {latest.tracks.slice(0, ACTION_TRACK_LINES).map((track) => ( +
+ {track.name} + {`${track.totalMs.toFixed(1)}ms (${track.count}×)`} +
+ ))} + {older.slice(0, OLDER_ACTION_LINES).map((receipt) => ( +
+ {receipt.name} + {`${receipt.totalMs.toFixed(0)}ms`} +
+ ))} +
+ ) +} + +export const PerfPanel = () => { + const stats = usePerfStats() + const [placement, setPlacement] = useState(loadPlacement) + const dragRef = useRef<{ pointerId: number; dx: number; dy: number } | null>(null) + const panelRef = useRef(null) + + useEffect(() => savePlacement(placement), [placement]) + + const onPointerDown = useCallback((e: React.PointerEvent) => { + const el = panelRef.current + if (!el) return + const rect = el.getBoundingClientRect() + dragRef.current = { + pointerId: e.pointerId, + dx: e.clientX - rect.left, + dy: e.clientY - rect.top, + } + ;(e.target as HTMLElement).setPointerCapture(e.pointerId) + e.preventDefault() + }, []) + + const onPointerMove = useCallback((e: React.PointerEvent) => { + const drag = dragRef.current + if (!drag || drag.pointerId !== e.pointerId) return + const el = panelRef.current + const w = el?.offsetWidth ?? PANEL_WIDTH + const h = el?.offsetHeight ?? 200 + setPlacement((p) => ({ + ...p, + x: Math.min(Math.max(0, e.clientX - drag.dx), window.innerWidth - w), + y: Math.min(Math.max(0, e.clientY - drag.dy), window.innerHeight - h), + })) + }, []) + + const onPointerUp = useCallback((e: React.PointerEvent) => { + if (dragRef.current?.pointerId === e.pointerId) dragRef.current = null + }, []) + + const dock = useCallback(() => { + setPlacement((p) => ({ + ...p, + docked: p.x + PANEL_WIDTH / 2 < window.innerWidth / 2 ? 'left' : 'right', + })) + }, []) + + if (typeof document === 'undefined') return null + + if (placement.docked) { + const side = placement.docked + return createPortal( + , + document.body, + ) + } + + return createPortal( +
+
+ Performance + + {stats ? `${stats.fps} fps` : '—'} + + +
+ {stats ? ( +
+
+ + {stats.frameMs > 0 + ? `${stats.frameMs.toFixed(1)}ms cpu (max ${stats.frameMaxMs.toFixed(1)})` + : '—'} + + + {stats.encodeMs > 0 + ? `${stats.encodeMs.toFixed(1)}ms (max ${stats.encodeMaxMs.toFixed(1)})` + : '—'} + + + {stats.gpuTracked + ? `${stats.gpuMs.toFixed(1)}ms (max ${stats.gpuMaxMs.toFixed(1)})` + : 'no timestamp-query'} + + + {stats.queueMs > 0 + ? `${stats.queueMs.toFixed(1)}ms (max ${stats.queueMaxMs.toFixed(1)})` + : '—'} + + {stats.drawCalls} + {`${(stats.triangles / 1000).toFixed(1)}k`} + + {`${stats.geometries} geo ${stats.textures} tex ${mb(stats.gpuBytes)}`} + + {stats.heapBytes > 0 ? mb(stats.heapBytes) : '—'} + + {stats.dirty} + {stats.dirtyDetail ? ` (${stats.dirtyDetail})` : ''} + + + {`${stats.meshes} mesh ${stats.lines} line ${stats.lights} light`} + +
+ + {stats.tracks.length > 0 && ( +
+ {stats.tracks.map((t) => ( +
+ {t.name} + + {`${t.totalMs.toFixed(1)}ms (${t.count}×, max ${t.maxMs.toFixed(1)})`} + +
+ ))} +
+ )} +
+ ) : ( +
waiting for samples…
+ )} +
, + document.body, + ) +} + +export default PerfPanel diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index b4e83eaf76..e2c379cae8 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -24,13 +24,14 @@ import { vec3, vec4, } from 'three/tsl' -import { RenderPipeline, type WebGPURenderer } from 'three/webgpu' +import { RenderPipeline, TimestampQuery, type WebGPURenderer } from 'three/webgpu' import { backdropGradient, deepSkyColor, horizonHazeColor } from '../../lib/backdrop' import { edgeColorFor, edgeOpacityScaleFor } from '../../lib/edge-style' -import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' +import { PERF_OVERLAY_ENABLED } from '../../lib/gpu-perf' import { inkedEdges } from '../../lib/ink-edges' import { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from '../../lib/layers' import { mergedOutline } from '../../lib/merged-outline-node' +import { recordPerfSample, timeSpan } from '../../lib/perf-tracks' import { getSceneTheme } from '../../lib/scene-themes' import { packNormalToRGB, unpackRGBToNormal } from '../../lib/tsl-compat' import useViewer from '../../store/use-viewer' @@ -152,6 +153,36 @@ function sanitizeOutlineObjects(objects: Object3D[]) { objects.length = nextIndex } +// Two independent GPU readings per frame, both `?perf`-only: +// - `gpu-render`: three's WebGPU timestamp queries — the summed GPU duration of +// the frame's render passes, measured on the device. The only honest "GPU ms". +// - `gpu-queue`: submit → `onSubmittedWorkDone()` wall time. That covers queue +// backlog and CPU work that ran before the microtask got to resume, so it is +// a backpressure signal, not GPU time. +// `resolveTimestampsAsync` returns the previous resolve's value while one is in +// flight, so calling it every frame is safe (and required — the query pool warns +// once it fills). +function recordFrameGpuTiming(renderer: any, submittedAt: number): void { + const queue = renderer.backend?.device?.queue as + | { onSubmittedWorkDone?: () => Promise } + | undefined + queue?.onSubmittedWorkDone?.().then(() => { + recordPerfSample('gpu-queue', performance.now() - submittedAt) + }) + + // Off unless the device advertised 'timestamp-query' at init — the backend + // clears its own flag when the feature is missing, so this is the truth. + if (renderer.backend?.trackTimestamp !== true) return + renderer + .resolveTimestampsAsync?.(TimestampQuery.RENDER) + ?.then((ms: number | undefined) => { + if (typeof ms === 'number' && ms > 0) recordPerfSample('gpu-render', ms) + }) + .catch(() => { + // Pool disposed mid-flight (pipeline rebuild / unmount) — nothing to report. + }) +} + const PostProcessingPasses = ({ hoverStyles = DEFAULT_HOVER_STYLES, disablePostFx = false, @@ -719,40 +750,26 @@ const PostProcessingPasses = ({ ;(renderer as any).setClearAlpha(clearAlpha) } const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0 - ;(renderer as any).render(scene, camera) - if (PERF_OVERLAY_ENABLED) { - const queue = (renderer as any).backend?.device?.queue as - | { onSubmittedWorkDone?: () => Promise } - | undefined - queue?.onSubmittedWorkDone?.().then(() => { - pushGpuSample(performance.now() - submittedAt) - }) - } + timeSpan('render-encode', () => { + ;(renderer as any).render(scene, camera) + }) + if (PERF_OVERLAY_ENABLED) recordFrameGpuTiming(renderer, submittedAt) } catch (fallbackError) { console.error('[viewer/post-processing] Fallback render failed.', fallbackError) } return } + const pipeline = renderPipelineRef.current try { // Clear alpha=0 so background pixels in the output MRT attachment (index 0) get a=0, // making scenePassColor.a a reliable geometry mask (geometry pixels write a=1 via output node). ;(renderer as any).setClearAlpha(0) const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0 - renderPipelineRef.current.render() - if (PERF_OVERLAY_ENABLED) { - // device.queue.onSubmittedWorkDone() resolves once the GPU has - // finished the work we just submitted — the delta from our submit - // timestamp is a clean per-frame GPU duration. Doesn't block CPU - // (no await) and works for the custom RenderPipeline path that - // bypasses three.js's timestamp-query infrastructure. - const queue = (renderer as any).backend?.device?.queue as - | { onSubmittedWorkDone?: () => Promise } - | undefined - queue?.onSubmittedWorkDone?.().then(() => { - pushGpuSample(performance.now() - submittedAt) - }) - } + timeSpan('render-encode', () => { + pipeline.render() + }) + if (PERF_OVERLAY_ENABLED) recordFrameGpuTiming(renderer, submittedAt) } catch (error) { hasPipelineErrorRef.current = true // A failed MRT pass may leave its target bound; clear it before the fallback render. diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 65827797bf..9aab13dbfc 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -76,6 +76,7 @@ export { } from './lib/csg-utils' export { disposeObject3DResources } from './lib/dispose-object3d' export type { EdgeMode } from './lib/edge-style' +export { PERF_OVERLAY_ENABLED } from './lib/gpu-perf' export { computeHeroFraming, DEFAULT_FRAMING_EXCLUDED_TYPES, @@ -129,6 +130,8 @@ export { WHITE_PALETTE, } from './lib/materials' export { mergedOutline } from './lib/merged-outline-node' +export * from './lib/perf-actions' +export * from './lib/perf-tracks' export { detectRendererCapability, initializeGpuRenderer, diff --git a/packages/viewer/src/lib/gpu-perf.ts b/packages/viewer/src/lib/gpu-perf.ts index a5b2cd1f47..94eb299b8b 100644 --- a/packages/viewer/src/lib/gpu-perf.ts +++ b/packages/viewer/src/lib/gpu-perf.ts @@ -1,26 +1,11 @@ -// GPU work-time measurement, gated by `?perf` in the URL. +// `?perf` gate. Kept in its own module because both the overlay and +// `lib/perf-tracks.ts` (the instrumentation sink every system writes to) read +// it, and perf-tracks must not import a React component tree. // -// We can't use WebGPU timestamp queries here because the editor renders via -// a custom `RenderPipeline.render()` path that bypasses three.js's built-in -// timestamp infrastructure. Instead we use `device.queue.onSubmittedWorkDone()`, -// which resolves when the GPU finishes all submitted work — measuring the -// CPU→GPU-done delta gives a clean approximation of per-frame GPU duration -// regardless of which render path produced it. +// Timing itself lives in perf-tracks: `gpu-render` carries three's WebGPU +// timestamp-query total for the frame's render passes, `gpu-queue` the +// submit→onSubmittedWorkDone fence, `render-encode` the synchronous CPU cost of +// building and submitting the frame. See components/viewer/post-processing.tsx. export const PERF_OVERLAY_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('perf') - -const MAX_SAMPLES = 256 -const samples: number[] = [] - -export function pushGpuSample(ms: number): void { - samples.push(ms) - if (samples.length > MAX_SAMPLES) samples.shift() -} - -export function drainGpuSamples(): number[] { - if (samples.length === 0) return [] - const out = samples.slice() - samples.length = 0 - return out -} diff --git a/packages/viewer/src/lib/perf-actions.ts b/packages/viewer/src/lib/perf-actions.ts new file mode 100644 index 0000000000..1422733fc0 --- /dev/null +++ b/packages/viewer/src/lib/perf-actions.ts @@ -0,0 +1,251 @@ +// Action-cost ledger for `?perf`. +// +// An "action" is one user edit gesture: a wall-endpoint drag, a door move, an +// undo, a level switch. The editor package brackets the gesture with +// `beginPerfAction` / `commitPerfAction`; every perf-tracks sample recorded in +// between (wall-csg, geometry, react-render, gpu-render, …) is attributed to +// it. The action is "settled" only when the scene has finished digesting the +// edit: dirty queue empty, deferred wall neighbour rebuilds flushed, and one +// more GPU sample resolved after that — i.e. the user actually sees the final +// result. The settle system in the viewer feeds that state per frame via +// `notifyPerfActionFrame`. +// +// Everything is a no-op without `?perf`. + +import { useSyncExternalStore } from 'react' +import { PERF_OVERLAY_ENABLED } from './gpu-perf' +import { subscribePerfSamples } from './perf-tracks' + +export type PerfActionReceipt = { + name: string + /** Free-form context, e.g. the node id or kind. */ + detail: string + /** begin → commit (the human gesture; 0 for instant actions like undo). */ + dragMs: number + /** commit → fully settled (rebuilds + one GPU sample after quiet). */ + settleMs: number + /** begin → settled. */ + totalMs: number + /** Frames observed between commit and settled. */ + settleFrames: number + /** Per-track attribution over begin → settled, sorted by totalMs desc. */ + tracks: Array<{ name: string; totalMs: number; count: number }> + outcome: 'settled' | 'interrupted' | 'timeout' + endedAt: number +} + +type ActiveAction = { + id: number + name: string + detail: string + startedAt: number + committedAt: number | null + settleFrames: number + /** Set once dirty+pending hit zero after commit; we then wait for one GPU sample. */ + awaitingGpu: boolean + buckets: Map + unsubscribe: () => void +} + +const SETTLE_TIMEOUT_MS = 5000 +// A gesture that begins and never commits (a hover preview, a drag whose +// pointerup never reached the caller) is never settle-checked, so without an +// absolute cap it would hold its sample subscription — and keep growing its +// buckets — for the rest of the session. +const UNCOMMITTED_TIMEOUT_MS = 60_000 +const MAX_RECEIPTS = 5 + +let active: ActiveAction | null = null +let actionSeq = 0 +// Whether this device has ever produced a real timestamp-query sample. Without +// `timestamp-query` support no 'gpu-render' sample can ever arrive, so settle +// falls back to the queue fence — see the sample listener below. +let gpuTimestampsSeen = false +let receipts: PerfActionReceipt[] = [] +const listeners = new Set<() => void>() + +function emitReceipts(): void { + for (const listener of listeners) listener() +} + +function finalize(outcome: PerfActionReceipt['outcome']): void { + const action = active + if (!action) return + active = null + action.unsubscribe() + const now = performance.now() + const committedAt = action.committedAt ?? now + const receipt: PerfActionReceipt = { + name: action.name, + detail: action.detail, + dragMs: committedAt - action.startedAt, + settleMs: now - committedAt, + totalMs: now - action.startedAt, + settleFrames: action.settleFrames, + tracks: [...action.buckets.entries()] + .map(([name, b]) => ({ name, totalMs: b.totalMs, count: b.count })) + .sort((a, b) => b.totalMs - a.totalMs), + outcome, + endedAt: now, + } + receipts = [receipt, ...receipts].slice(0, MAX_RECEIPTS) + emitReceipts() + // One timeline entry per action so recordings show the full span with its + // breakdown attached. + try { + performance.measure(`${action.name}${action.detail ? ` ${action.detail}` : ''}`, { + start: action.startedAt, + end: now, + detail: { + devtools: { + dataType: 'track-entry', + track: 'Actions', + trackGroup: 'Pascal', + color: outcome === 'settled' ? 'secondary' : 'error', + properties: [ + ['outcome', outcome], + ['drag ms', receipt.dragMs.toFixed(1)], + ['settle ms', receipt.settleMs.toFixed(1)], + ...receipt.tracks + .slice(0, 6) + .map((t): [string, string] => [t.name, `${t.totalMs.toFixed(1)}ms (${t.count}×)`]), + ], + }, + }, + }) + } catch {} + // eslint-disable-next-line no-console + console.log( + `[perf] ${action.name}${action.detail ? ` (${action.detail})` : ''}: ` + + `${receipt.totalMs.toFixed(0)}ms total — drag ${receipt.dragMs.toFixed(0)}, ` + + `settle ${receipt.settleMs.toFixed(0)} over ${receipt.settleFrames} frames [${outcome}] — ` + + receipt.tracks + .slice(0, 6) + .map((t) => `${t.name} ${t.totalMs.toFixed(1)}ms`) + .join(', '), + ) +} + +/** + * Start attributing samples to a named action. Interrupts any active one. + * Returns an id the caller can compare against `getActivePerfActionId()` to + * commit only the action it actually began. + */ +export function beginPerfAction(name: string, detail = ''): number | null { + if (!PERF_OVERLAY_ENABLED) return null + if (active) finalize('interrupted') + const id = ++actionSeq + const buckets = new Map() + active = { + id, + name, + detail, + startedAt: performance.now(), + committedAt: null, + settleFrames: 0, + awaitingGpu: false, + buckets, + unsubscribe: subscribePerfSamples((track, ms) => { + const bucket = buckets.get(track) + if (bucket) { + bucket.totalMs += ms + bucket.count += 1 + } else { + buckets.set(track, { totalMs: ms, count: 1 }) + } + if (track === 'gpu-render') gpuTimestampsSeen = true + // A GPU sample landing after the quiet point is the settle signal. On + // devices without timestamp-query no 'gpu-render' sample ever arrives — + // the queue fence is the closest "the user saw it" stand-in there. + if ( + active?.awaitingGpu && + (track === 'gpu-render' || (!gpuTimestampsSeen && track === 'gpu-queue')) + ) { + finalize('settled') + } + }), + } + return id +} + +/** The gesture ended (pointer up / operation dispatched); settling begins. */ +export function commitPerfAction(): void { + if (!active || active.committedAt !== null) return + active.committedAt = performance.now() +} + +/** The gesture was aborted (Escape mid-drag); discard without a settle wait. */ +export function cancelPerfAction(): void { + if (!active) return + finalize('interrupted') +} + +/** Convenience for instant actions (undo, level switch): begin + commit. */ +export function markPerfAction(name: string, detail = ''): void { + beginPerfAction(name, detail) + commitPerfAction() +} + +/** + * Whether an action is currently being attributed. Lets a generic call site + * (the interaction scope) yield to a more specific one that began first. + */ +export function hasActivePerfAction(): boolean { + return active !== null +} + +/** + * Like `hasActivePerfAction`, but false once the active action has committed. + * A generic bracket yields to an UNCOMMITTED action (a gesture in flight) but + * must be free to start a new receipt while the previous one is merely + * settling — beginPerfAction then finalizes the settling one as interrupted. + */ +export function hasUncommittedPerfAction(): boolean { + return active !== null && active.committedAt === null +} + +/** Id of the action currently attributing samples, if any. */ +export function getActivePerfActionId(): number | null { + return active?.id ?? null +} + +/** + * Called once per frame by the viewer settle system with the current dirty + * count and the wall system's deferred-neighbour backlog. Also the ledger's + * only heartbeat, so it is where a stuck action gets released. + */ +export function notifyPerfActionFrame(dirtyCount: number, pendingRebuilds: number): void { + const action = active + if (!action) return + const now = performance.now() + if (action.committedAt === null) { + if (now - action.startedAt > UNCOMMITTED_TIMEOUT_MS) finalize('interrupted') + return + } + action.settleFrames += 1 + if (now - action.committedAt > SETTLE_TIMEOUT_MS) { + finalize('timeout') + return + } + if (!action.awaitingGpu && dirtyCount === 0 && pendingRebuilds === 0) { + action.awaitingGpu = true + } +} + +// Module-level so the panel's twice-a-second re-render doesn't tear the +// subscription down and rebuild it; `receipts` is replaced, never mutated, so +// the snapshot is stable between finalizes. +function subscribeReceipts(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +function getReceipts(): PerfActionReceipt[] { + return receipts +} + +export function usePerfActionReceipts(): PerfActionReceipt[] { + return useSyncExternalStore(subscribeReceipts, getReceipts, getReceipts) +} diff --git a/packages/viewer/src/lib/perf-observers.ts b/packages/viewer/src/lib/perf-observers.ts new file mode 100644 index 0000000000..ed11051087 --- /dev/null +++ b/packages/viewer/src/lib/perf-observers.ts @@ -0,0 +1,18 @@ +import { PERF_OVERLAY_ENABLED } from './gpu-perf' +import { recordPerfSample } from './perf-tracks' + +let initialized = false + +export function initPerfObservers(): void { + if (!PERF_OVERLAY_ENABLED || initialized) return + initialized = true + + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + recordPerfSample('long-task', entry.duration) + } + }) + observer.observe({ type: 'longtask', buffered: true }) + } catch {} +} diff --git a/packages/viewer/src/lib/perf-panel-store.ts b/packages/viewer/src/lib/perf-panel-store.ts new file mode 100644 index 0000000000..854a71feb8 --- /dev/null +++ b/packages/viewer/src/lib/perf-panel-store.ts @@ -0,0 +1,55 @@ +// Bridge between the in-canvas collector (perf-monitor.tsx, R3F reconciler) +// and the DOM panel (perf-panel.tsx). react-dom portals can't cross the R3F +// renderer boundary, so the collector publishes here and the panel — mounted +// outside — subscribes via useSyncExternalStore. + +import { useSyncExternalStore } from 'react' + +export type PerfTrackLine = { name: string; totalMs: number; count: number; maxMs: number } + +export type PerfStats = { + fps: number + frameMs: number + frameMaxMs: number + encodeMs: number + encodeMaxMs: number + gpuMs: number + gpuMaxMs: number + gpuTracked: boolean + queueMs: number + queueMaxMs: number + drawCalls: number + triangles: number + dirty: number + dirtyDetail: string + geometries: number + textures: number + gpuBytes: number + heapBytes: number + meshes: number + lines: number + sprites: number + lights: number + tracks: PerfTrackLine[] +} + +let current: PerfStats | null = null +const listeners = new Set<() => void>() + +export function publishPerfStats(stats: PerfStats): void { + current = stats + for (const listener of listeners) listener() +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function usePerfStats(): PerfStats | null { + return useSyncExternalStore( + subscribe, + () => current, + () => null, + ) +} diff --git a/packages/viewer/src/lib/perf-tracks.ts b/packages/viewer/src/lib/perf-tracks.ts new file mode 100644 index 0000000000..92cde763f5 --- /dev/null +++ b/packages/viewer/src/lib/perf-tracks.ts @@ -0,0 +1,159 @@ +// Shared sink for `?perf` instrumentation. +// +// Two outputs from one call site: +// 1. Chrome DevTools Performance panel custom tracks — every span becomes a +// `performance.measure` with a `detail.devtools` payload, so Pascal systems +// show up as named lanes in the flame chart while recording. +// 2. Per-window aggregates — `drainPerfCounters()` hands the overlay one +// bucket per track (total/max/count since the last drain), so the panel can +// print "wall-csg 9.8ms" without touching the timeline. +// +// Everything is gated on PERF_OVERLAY_ENABLED: when `?perf` is absent the +// helpers reduce to calling `fn()` directly / returning early, so hot paths pay +// only a boolean check. Measures otherwise accumulate in the browser's +// timeline buffer indefinitely — the overlay is responsible for calling +// `clearPerfMeasures()` on its drain tick. + +import { PERF_OVERLAY_ENABLED } from './gpu-perf' + +export type PerfCounterBucket = { + totalMs: number + maxMs: number + count: number +} + +/** DevTools palette names accepted by the extensibility API. */ +export type PerfTrackColor = + | 'primary' + | 'primary-light' + | 'primary-dark' + | 'secondary' + | 'secondary-light' + | 'secondary-dark' + | 'tertiary' + | 'tertiary-light' + | 'tertiary-dark' + | 'error' + +const counters = new Map() + +// Live tap on every recorded sample, regardless of the panel's drain cadence. +// The action ledger (perf-actions.ts) subscribes for the lifetime of one edit +// action to attribute samples to it. +type PerfSampleListener = (track: string, ms: number) => void +const sampleListeners = new Set() + +export function subscribePerfSamples(listener: PerfSampleListener): () => void { + sampleListeners.add(listener) + return () => sampleListeners.delete(listener) +} + +function record(track: string, ms: number): void { + const bucket = counters.get(track) + if (bucket) { + bucket.totalMs += ms + bucket.count += 1 + if (ms > bucket.maxMs) bucket.maxMs = ms + } else { + counters.set(track, { totalMs: ms, maxMs: ms, count: 1 }) + } + for (const listener of sampleListeners) listener(track, ms) +} + +function emitMeasure( + track: string, + name: string, + start: number, + end: number, + color: PerfTrackColor, + properties?: Array<[string, string]>, +): void { + try { + performance.measure(name, { + start, + end, + detail: { + devtools: { + dataType: 'track-entry', + track, + trackGroup: 'Pascal', + color, + ...(properties ? { properties } : {}), + }, + }, + }) + } catch { + // Older browsers reject the options bag — aggregates still work. + } +} + +/** + * Time a synchronous block and file it under `track`. The label defaults to + * the track name; pass `name` for per-entry granularity (e.g. a node id) — + * it only affects the DevTools lane, not the aggregate bucket. + */ +export function timeSpan( + track: string, + fn: () => T, + opts?: { name?: string; color?: PerfTrackColor; properties?: Array<[string, string]> }, +): T { + if (!PERF_OVERLAY_ENABLED) return fn() + const start = performance.now() + try { + return fn() + } finally { + const end = performance.now() + record(track, end - start) + emitMeasure(track, opts?.name ?? track, start, end, opts?.color ?? 'primary', opts?.properties) + } +} + +/** + * Span for non-callback shapes (spans crossing await points or frames). + * `beginSpan` returns null when perf is off — callers pass the handle back to + * `endSpan`, which no-ops on null. + */ +export type PerfSpanHandle = { track: string; name: string; start: number; color: PerfTrackColor } + +export function beginSpan( + track: string, + opts?: { name?: string; color?: PerfTrackColor }, +): PerfSpanHandle | null { + if (!PERF_OVERLAY_ENABLED) return null + return { + track, + name: opts?.name ?? track, + start: performance.now(), + color: opts?.color ?? 'primary', + } +} + +export function endSpan(handle: PerfSpanHandle | null, properties?: Array<[string, string]>): void { + if (!handle) return + const end = performance.now() + record(handle.track, end - handle.start) + emitMeasure(handle.track, handle.name, handle.start, end, handle.color, properties) +} + +/** Record a duration measured externally (no measure emitted). */ +export function recordPerfSample(track: string, ms: number): void { + if (!PERF_OVERLAY_ENABLED) return + record(track, ms) +} + +/** Hand the current window's buckets to the overlay and start a new window. */ +export function drainPerfCounters(): Map { + const out = new Map(counters) + counters.clear() + return out +} + +/** Drop accumulated timeline entries so long `?perf` sessions don't leak. */ +export function clearPerfMeasures(): void { + try { + performance.clearMeasures() + performance.clearMarks() + } catch { + // ignore + } +} diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 3a2d96cb7e..f91a6dbb73 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -28,6 +28,7 @@ import { type RenderShading, resolveMaterialRef, } from '../../lib/materials' +import { timeSpan } from '../../lib/perf-tracks' import useViewer from '../../store/use-viewer' import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry' @@ -169,7 +170,9 @@ export const DoorSystem = () => { // rebuild reflects the in-flight drag without zustand churn. When // no override is set this returns the scene node unchanged. const effectiveNode = getEffectiveNode(node as DoorNode) - updateDoorMesh(effectiveNode, mesh) + timeSpan('door', () => updateDoorMesh(effectiveNode, mesh), { + properties: [['node', id]], + }) clearDirty(id as AnyNodeId) rebuiltDoorsThisFrame += 1 diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index 94d57a4ff2..4b438dc30a 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -23,6 +23,7 @@ import { createSurfaceRoleMaterial, type RenderShading, } from '../../lib/materials' +import { timeSpan } from '../../lib/perf-tracks' import useViewer from '../../store/use-viewer' /** @@ -163,7 +164,11 @@ export const GeometrySystem = () => { } levelDataByBatch.set( key, - (def.computeLevelData as (s: ReadonlyArray) => unknown)(siblings), + timeSpan( + 'geometry', + () => (def.computeLevelData as (s: ReadonlyArray) => unknown)(siblings), + { name: 'geometry:levelData' }, + ), ) } @@ -210,16 +215,21 @@ export const GeometrySystem = () => { // The builder is typed against the kind's specific node — at the // generic system level we lose that refinement, so the cast lands // here. Builders are responsible for trusting their schema. - const built = ( - builder as ( - n: AnyNode, - c: GeometryContext, - shading: RenderShading, - textures: boolean, - colorPreset: ColorPreset, - sceneTheme: string, - ) => { children: unknown[] } - )(effectiveNode, ctx, shading, textures, colorPreset, sceneTheme) as unknown as Group + const built = timeSpan( + 'geometry', + () => + ( + builder as ( + n: AnyNode, + c: GeometryContext, + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneTheme: string, + ) => { children: unknown[] } + )(effectiveNode, ctx, shading, textures, colorPreset, sceneTheme) as unknown as Group, + { name: `geometry:${node.type}`, properties: [['node', id]] }, + ) if (!textures && def.surfaceRole) { applyDefaultSurfaceRole(built, def.surfaceRole, colorPreset, sceneTheme) diff --git a/packages/viewer/src/systems/perf-action-settle/perf-action-settle-system.tsx b/packages/viewer/src/systems/perf-action-settle/perf-action-settle-system.tsx new file mode 100644 index 0000000000..fe61076299 --- /dev/null +++ b/packages/viewer/src/systems/perf-action-settle/perf-action-settle-system.tsx @@ -0,0 +1,38 @@ +import { useScene } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { PERF_OVERLAY_ENABLED } from '../../lib/gpu-perf' +import { notifyPerfActionFrame } from '../../lib/perf-actions' +import { getPendingWallRebuildCount } from '../wall/wall-system' + +// Later than every other viewer system (the highest in the tree is 10) and +// later than the render call in post-processing (priority 1), so the counts +// reported are what the frame actually left behind. +const SETTLE_PRIORITY = 100 + +const PerfActionSettleFrame = () => { + useFrame(() => { + // Count only dirty marks whose node still exists. A node deleted while + // dirty (undo of a wall split, redo storms) leaves its mark in dirtyNodes + // forever — no system clears marks for missing nodes — and that phantom + // dirt would keep every action from ever settling. Real finding, tracked + // in plans/performance/editor-scalable-scene-runtime.md. + const { dirtyNodes, nodes } = useScene.getState() + let liveDirty = 0 + dirtyNodes.forEach((id) => { + if (nodes[id]) liveDirty++ + }) + notifyPerfActionFrame(liveDirty, getPendingWallRebuildCount()) + }, SETTLE_PRIORITY) + return null +} + +/** + * Feeds the action-cost ledger (lib/perf-actions.ts) the per-frame settle + * state: how much of the scene is still dirty and how many wall neighbour + * rebuilds the wall system still owes. Without `?perf` the inner component + * never mounts, so no useFrame subscriber is registered at all. + */ +export const PerfActionSettleSystem = () => { + if (!PERF_OVERLAY_ENABLED) return null + return +} diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 49ea7a9c94..355a191ea3 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -40,6 +40,7 @@ import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils' import { setGroupsSortedByMaterial } from '../../lib/geometry-groups' +import { timeSpan } from '../../lib/perf-tracks' import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill' import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache' import { @@ -589,7 +590,7 @@ export const WallSystem = () => { } const levelWalls = getLevelWalls(levelId) - const miterData = getCachedLevelMiters(levelId, levelWalls) + const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls)) const rebuiltWallIds = new Set() // Update dirty walls — always, no throttling. The dragged wall must @@ -610,7 +611,9 @@ export const WallSystem = () => { const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh if (mesh) { - updateWallGeometry(wallId, miterData) + timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), { + properties: [['node', wallId]], + }) clearDirty(wallId as AnyNodeId) rebuiltWalls.add(wallId) rebuiltWallIds.add(wallId) @@ -651,7 +654,7 @@ export const WallSystem = () => { for (const [levelId, pendingIds] of pendingAdjacentByLevel) { if (pendingIds.size === 0) continue const levelWalls = getLevelWalls(levelId) - const miterData = getCachedLevelMiters(levelId, levelWalls) + const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls)) for (const wallId of Array.from(pendingIds)) { if (useProgressiveAdjacentRebuilds) { if (rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { @@ -667,7 +670,9 @@ export const WallSystem = () => { const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh if (mesh) { - updateWallGeometry(wallId, miterData) + timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), { + properties: [['node', wallId]], + }) rebuiltWalls.add(wallId) } pendingIds.delete(wallId) @@ -1176,7 +1181,9 @@ export function generateExtrudedWall( let resultBrush = wallBrush for (const cutoutBrush of cutoutBrushes) { prepareBrushForCSG(cutoutBrush) - const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION) + const newResult = timeSpan('wall-csg', () => + csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION), + ) prepareBrushForCSG(newResult) if (resultBrush !== wallBrush) { csgGeometry(resultBrush).dispose() diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 32f8c6760d..ac9024d8e5 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -24,6 +24,7 @@ import { type RenderShading, resolveMaterialRef, } from '../../lib/materials' +import { timeSpan } from '../../lib/perf-tracks' import useViewer from '../../store/use-viewer' import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry' @@ -146,7 +147,9 @@ export const WindowSystem = () => { // Merge any live override (width / height / position) so the mesh // rebuild reflects the in-flight drag without zustand churn. const effectiveNode = getEffectiveNode(node as WindowNode) - updateWindowMesh(effectiveNode, mesh) + timeSpan('window', () => updateWindowMesh(effectiveNode, mesh), { + properties: [['node', id]], + }) clearDirty(id as AnyNodeId) rebuiltWindowsThisFrame += 1