openpencil/packages/core/src/profiler/render-profiler.ts
Danila Poyarkov 2a86ea5ce5 Add renderer profiler with HUD overlay, GPU timing, and phase instrumentation
Profiler modules in packages/core/src/profiler/:
- FrameStats: rolling 120-frame buffer tracking FPS, CPU/GPU times, node counts
- GpuTimer: EXT_disjoint_timer_query_webgl2 wrapper for GPU-side timing
- DrawCallCounter: WebGL context proxy counting draw calls per frame
- PhaseTimer: Chrome DevTools Performance panel custom tracks via User Timing API
- HudRenderer: in-canvas overlay with stats text and frame time bar graph
- CaptureStack: per-node profiling with speedscope JSON export
- RenderProfiler: orchestrates all layers with zero cost when disabled

Integration:
- SkiaRenderer.render() instrumented with phase markers for scene, picture
  recording/replay, section titles, component labels, selection, rulers, flush
- Node counting and viewport culling tracking in renderNode()
- WebGL2 context passed through for GPU timer and draw call counter
- Shift+P keyboard shortcut to toggle HUD overlay
- toggleProfiler() added to editor store
2026-03-04 14:13:17 +03:00

164 lines
4.4 KiB
TypeScript

import { FrameStats } from './frame-stats'
import { GPUTimer } from './gpu-timer'
import { DrawCallCounter } from './draw-call-counter'
import { PhaseTimer } from './phase-timer'
import { HudRenderer } from './hud-renderer'
import { CaptureStack, toSpeedscopeJSON } from './frame-capture'
import type { FrameCapture } from './frame-capture'
import type { CanvasKit, Canvas, Typeface } from 'canvaskit-wasm'
const now = typeof performance !== 'undefined' ? () => performance.now() : () => 0
export class RenderProfiler {
enabled = false
hudVisible = false
capturing = false
readonly stats = new FrameStats()
readonly phases = new PhaseTimer()
readonly gpuTimer: GPUTimer
readonly drawCallCounter: DrawCallCounter
private hud: HudRenderer | null = null
private typeface: Typeface | null = null
private captureStack: CaptureStack | null = null
private captureFrameStart = 0
private lastCapture: FrameCapture | null = null
private renderStartTime = 0
constructor(
private ck: CanvasKit,
gl: WebGL2RenderingContext | null
) {
this.gpuTimer = new GPUTimer(gl)
this.drawCallCounter = new DrawCallCounter(gl)
}
toggle(): void {
this.hudVisible = !this.hudVisible
this.enabled = this.hudVisible
this.phases.enabled = this.enabled
}
beginFrame(): void {
if (!this.enabled) return
this.renderStartTime = now()
this.phases.beginPhase('frame')
this.gpuTimer.beginFrame()
this.drawCallCounter.reset()
}
endFrame(): void {
if (!this.enabled) return
this.gpuTimer.endFrame()
this.gpuTimer.pollResults()
const cpuTime = now() - this.renderStartTime
this.stats.gpuTime = this.gpuTimer.lastGpuTimeMs
this.stats.drawCalls = this.drawCallCounter.count
this.stats.recordFrame(cpuTime)
this.phases.endPhase('frame')
}
beginPhase(name: string): void {
if (!this.enabled) return
this.phases.beginPhase(name)
}
endPhase(name: string): void {
if (!this.enabled) return
this.phases.endPhase(name)
}
setNodeCounts(total: number, culled: number): void {
this.stats.totalNodes = total
this.stats.culledNodes = culled
}
setCacheHit(hit: boolean): void {
this.stats.scenePictureCacheHit = hit
}
beginCapture(): void {
this.capturing = true
this.captureStack = new CaptureStack()
this.captureFrameStart = now()
this.captureStack.reset(this.captureFrameStart)
}
endCapture(): FrameCapture | null {
if (!this.capturing || !this.captureStack) return null
this.capturing = false
const capture: FrameCapture = {
timestamp: this.captureFrameStart,
totalTimeMs: now() - this.captureFrameStart,
cpuTimeMs: this.stats.cpuTime,
gpuTimeMs: this.gpuTimer.lastGpuTimeMs,
totalNodes: this.stats.totalNodes,
culledNodes: this.stats.culledNodes,
drawCalls: this.stats.drawCalls,
scenePictureCacheHit: this.stats.scenePictureCacheHit,
rootProfiles: this.captureStack.getRootProfiles()
}
this.lastCapture = capture
this.captureStack = null
return capture
}
beginNode(nodeId: string, name: string, type: string, culled: boolean): void {
this.captureStack?.begin(nodeId, name, type, culled)
}
endNode(drawCallsBefore: number): void {
this.captureStack?.end(this.drawCallCounter.count - drawCallsBefore)
}
getLastCapture(): FrameCapture | null {
return this.lastCapture
}
exportSpeedscope(): string | null {
if (!this.lastCapture) return null
return toSpeedscopeJSON(this.lastCapture)
}
downloadSpeedscope(): void {
const json = this.exportSpeedscope()
if (!json || typeof document === 'undefined') return
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `openpencil-frame-${Date.now()}.speedscope.json`
a.click()
URL.revokeObjectURL(url)
}
setTypeface(typeface: Typeface): void {
this.typeface = typeface
this.hud?.setTypeface(typeface)
}
drawHUD(canvas: Canvas, showRulers: boolean): void {
if (!this.hudVisible) return
if (!this.hud) {
this.hud = new HudRenderer(this.ck)
if (this.typeface) this.hud.setTypeface(this.typeface)
}
this.hud.draw(canvas, this.stats, this.phases.averages, showRulers)
}
destroy(): void {
this.gpuTimer.destroy()
this.drawCallCounter.destroy()
this.hud?.destroy()
this.hud = null
this.phases.clearPhases()
}
}