diff --git a/docs/renderer-profiler-plan.md b/docs/renderer-profiler-plan.md new file mode 100644 index 000000000..a3aaf3219 --- /dev/null +++ b/docs/renderer-profiler-plan.md @@ -0,0 +1,352 @@ +# Renderer Profiler — Design Plan + +Production-grade instrumentation for OpenPencil's CanvasKit/Skia renderer, inspired by game engine profiling tools (Unreal Insights, Unity Frame Timing Manager) and browser render engine internals (Chrome Compositor, WebKit/Skia tracing). + +## What Skia gives us + +### 1. Profiling build of CanvasKit + +`canvaskit-wasm` ships three bundles: +- **default** — stripped WASM names +- **full** — includes Skottie etc. +- **profiling** — same as full but with **full internal function names** preserved in WASM + +```ts +import InitCanvasKit from 'canvaskit-wasm/profiling' +``` + +This means Chrome DevTools Performance panel flame charts will show real Skia C++ function names (`GrDrawingManager::internalFlush`, `SkCanvas::drawRect`, etc.) instead of mangled `$func123`. We should use this build behind a `?profiling` URL param or a dev-mode toggle. + +### 2. SkPicture recording & replay + +Skia's `SkPictureRecorder` records draw commands into an immutable `SkPicture`. We already use this for caching (`recordScenePicture`). Key profiling insight from Skia's own benchmark tool: + +> `CanvasKit.flush()` returns after it has sent all instructions to the GPU, but we don't know the GPU is done until the **next frame is requested**. Thus, we need to keep track of time **between frames** to accurately calculate draw time. + +- `drawPicture + flush` duration tells us CPU-side WASM/JS time +- `total_frame_ms` (time between rAFs) tells us the real GPU-inclusive cost +- If `total_frame_ms ≈ with_flush_ms` → CPU-bound +- If `total_frame_ms >> with_flush_ms` → GPU-bound + +### 3. Skia Debugger (external tool) + +Skia has an online debugger at https://debugger.skia.org that can load `.skp` files and show: +- Draw command playback +- GPU op bounds visualization +- Overdraw visualization +- Clip/matrix state at any step +- GPU operation batching (colored op IDs) + +We could add an "Export .skp" debug feature to save the current `scenePicture` for analysis. + +### 4. EXT_disjoint_timer_query (GPU timing) + +The `EXT_disjoint_timer_query_webgl2` WebGL extension provides **actual GPU-side timing** in nanoseconds. Figma built [figma/webgl-profiler](https://github.com/figma/webgl-profiler) on top of this exact extension. + +**Limitations:** +- Only works in Desktop Chrome ≥ 70 +- `TIMESTAMP_EXT` was removed for security; only `TIME_ELAPSED_EXT` (begin/end query) works +- Results are asynchronous (available next frame or later) +- Non-draw commands often report 0ns (but totals are accurate) +- Inconsistent on non-Apple-Silicon Intel GPUs + +**Usage pattern:** +```ts +const ext = gl.getExtension('EXT_disjoint_timer_query_webgl2') +const query = gl.createQuery() +gl.beginQuery(ext.TIME_ELAPSED_EXT, query) +// ... draw calls ... +gl.endQuery(ext.TIME_ELAPSED_EXT) +// next frame: poll gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE) +``` + +### 5. Chrome Performance Panel extensibility + +Chrome now supports custom tracks via `performance.mark`/`performance.measure` with `detail.devtools` metadata, and the lower-overhead `console.timeStamp` API: + +```ts +performance.measure("Scene Render", { + start: startMark, + detail: { + devtools: { + dataType: "track-entry", + track: "OpenPencil Renderer", + trackGroup: "OpenPencil", + color: "primary", + properties: [["Nodes", "142"], ["Cached", "true"]], + tooltipText: "Full scene render with SkPicture cache hit" + } + } +}) +``` + +This shows up as a dedicated "OpenPencil Renderer" track in the DevTools Performance panel alongside browser internals. Extremely powerful for production debugging. + +--- + +## Architecture + +### Layers of profiling (from light to heavy) + +| Layer | Overhead | Always-on | What it measures | +|-------|----------|-----------|-----------------| +| **Frame Stats HUD** | ~0.1ms | Optional toggle | FPS, frame time, CPU/GPU split, node count, cache hits | +| **User Timing marks** | ~0.01ms each | Dev builds | Phase timings visible in DevTools Performance panel | +| **GPU Timer Queries** | ~0.2ms setup | On demand | Actual GPU execution time per frame (async readback) | +| **Detailed profiler** | ~1-3ms | Manual trigger | Per-node timings, draw call counts, overdraw heatmap | + +### 1. Frame Stats HUD (always-available, minimal overhead) + +An in-canvas overlay rendered by SkiaRenderer itself, togglable via keyboard shortcut (Shift+P or similar). Modeled after game engine stat overlays. + +**Metrics displayed:** +``` + FPS: 60 (16.7ms) ← smoothed rAF delta + CPU: 4.2ms ← JS/WASM time (start of render → after flush) + GPU: 8.1ms ← GPU time (async, from EXT_disjoint_timer_query or inferred) + ───────────────── + Nodes: 342 (47 culled) ← total vs viewport-culled count + Draw calls: 128 ← WebGL draw call count + Cache: SkPicture HIT ← scene picture reuse status + Textures: 12 (48MB) ← image cache stats + WASM heap: 64MB ← CanvasKit memory +``` + +**Frame time graph** — rolling 120-frame history bar chart (like Unreal's `stat unit`), color-coded: +- Green: within budget (16.7ms for 60fps) +- Yellow: over budget but < 2× (16.7–33.3ms) +- Red: severe (> 33.3ms) + +The graph draws CPU bars from bottom and GPU bars from top, making CPU/GPU overlap visible at a glance (exactly how Unity Frame Timing Manager visualizes it). + +### 2. Phase instrumentation (User Timing API) + +Instrument the `render()` method with `performance.mark`/`performance.measure` using the Chrome DevTools extensibility API to create a custom "OpenPencil" track group: + +**Phases to instrument:** + +| Phase | What | Where in code | +|-------|------|---------------| +| `frame` | Total rAF-to-rAF | `useCanvas.ts` rAF callback | +| `render:scene` | Scene drawing (picture replay OR live render) | `render()` | +| `render:recordPicture` | SkPicture recording | `recordScenePicture()` | +| `render:drawPicture` | SkPicture replay | `canvas.drawPicture()` | +| `render:volatile` | Live render (hover/drag/text edit) | volatile path | +| `render:sectionTitles` | Section title drawing | `drawSectionTitles()` | +| `render:componentLabels` | Component labels | `drawComponentLabels()` | +| `render:selection` | Selection borders/handles | `drawSelection()` | +| `render:rulers` | Ruler drawing | `drawRulers()` | +| `render:flush` | GPU command submission | `surface.flush()` | +| `layout:compute` | Yoga layout | `computeAllLayouts()` | + +Replace the existing `console.time`/`console.timeEnd` calls (which already exist in the render method) with the structured User Timing API: + +```ts +// Before (current) +console.time('render:flush') +this.surface.flush() +console.timeEnd('render:flush') + +// After (with DevTools custom tracks) +const start = performance.now() +this.surface.flush() +performance.measure('flush', { + start, + detail: { + devtools: { + dataType: 'track-entry', + track: 'Renderer', + trackGroup: 'OpenPencil', + color: 'tertiary' + } + } +}) +``` + +### 3. GPU Timer Queries + +Wrap the WebGL context to insert `EXT_disjoint_timer_query_webgl2` queries around the flush boundary: + +```ts +class GpuTimer { + private ext: EXT_disjoint_timer_query_webgl2 | null + private pending: WebGLQuery[] + private results: number[] // rolling buffer of GPU times in ms + + beginFrame(gl: WebGL2RenderingContext) { ... } + endFrame(gl: WebGL2RenderingContext) { ... } + pollResults(gl: WebGL2RenderingContext): number | null { ... } +} +``` + +Since results come back asynchronously (typically 1-4 frames later), the HUD shows the most recently completed GPU measurement, not the current frame's. This is the same approach Unity and Unreal use. + +For accurate CPU vs GPU bottleneck detection (Figma's approach): +- `perceived_fps` = 1000 / rAF delta +- `cpu_fps` = 1000 / (JS render end − JS render start) +- `gpu_fps` = 1000 / GPU timer query result + +If `gpu_fps ≈ perceived_fps` and `cpu_fps >> perceived_fps` → **GPU-bound** +If `cpu_fps ≈ perceived_fps` and `gpu_fps >> perceived_fps` → **CPU-bound** + +### 4. Detailed Profiler (on-demand, higher overhead) + +Triggered via debug menu or keyboard shortcut. Captures detailed per-node data for a single frame or short burst: + +**Per-node metrics:** +- Time spent in `renderNode()` (cumulative, including children) +- Self time (excluding children) +- Number of draw calls generated +- Whether the node was culled +- Fill/stroke/effect complexity (number of paints, blur radii, etc.) + +**Implementation:** A profiling wrapper around `renderNode()` that pushes/pops a stack: + +```ts +private renderNodeProfiled(canvas, graph, nodeId, ...) { + const entry = this.profilerStack.push(nodeId) + entry.startTime = performance.now() + this.renderNode(canvas, graph, nodeId, ...) + entry.endTime = performance.now() + this.profilerStack.pop() +} +``` + +**Overdraw visualization:** Skia has a built-in overdraw mode (`SkOverdrawCanvas` / `OverdrawColorFilter`). In CanvasKit, we can achieve this by rendering with `CanvasKit.MakeMatrix` color filter that maps draw count → heat colors. Alternative: render each node's bounds with additive blend to accumulate overdraw count as brightness. + +**Output formats:** +- **In-canvas heatmap** — overlay showing per-pixel render cost (warm = slow) +- **Speedscope-compatible JSON** — export frame profile for viewing in https://www.speedscope.app/ (same format Figma's webgl-profiler uses) +- **Node table** — sorted list of most expensive nodes with self-time + +### 5. Draw Call Counter + +Proxy the WebGL context to count actual GL draw calls per frame: + +```ts +function instrumentGL(gl: WebGL2RenderingContext) { + let drawCalls = 0 + const origDrawArrays = gl.drawArrays.bind(gl) + const origDrawElements = gl.drawElements.bind(gl) + gl.drawArrays = (...args) => { drawCalls++; return origDrawArrays(...args) } + gl.drawElements = (...args) => { drawCalls++; return origDrawElements(...args) } + return { + getAndReset() { const c = drawCalls; drawCalls = 0; return c } + } +} +``` + +This is the standard technique used by Spector.js, WebGL Inspector, and every game engine's stat display. + +--- + +## Implementation plan + +### Phase 1: Foundation (`packages/core/src/profiler.ts`) + +Create a `RenderProfiler` class that all layers use: + +```ts +export class RenderProfiler { + enabled = false + hudVisible = false + + // Frame stats (always tracked when enabled, near-zero cost) + readonly frameStats: FrameStats + + // Phase timing (User Timing API integration) + beginPhase(name: string): void + endPhase(name: string): void + + // GPU timer (optional, requires extension) + readonly gpuTimer: GpuTimer | null + + // Draw call counter + readonly drawCallCounter: DrawCallCounter | null + + // Detailed per-node profiling (on-demand) + beginDetailedCapture(): void + endDetailedCapture(): FrameCapture + + // HUD rendering (called by SkiaRenderer) + drawHUD(canvas: Canvas, ck: CanvasKit): void +} +``` + +### Phase 2: Integrate into renderer + +1. Add `profiler: RenderProfiler` to `SkiaRenderer` +2. Replace all `console.time`/`console.timeEnd` with `profiler.beginPhase`/`endPhase` +3. Add phase markers for every render stage +4. Wrap `surface.flush()` with GPU timing queries +5. Add HUD drawing at end of `render()` (in screen space, after everything else) + +### Phase 3: HUD overlay + +Render directly on the Skia canvas as the very last step (after rulers). Uses small monospace font, semi-transparent dark background. Positioned top-left or bottom-left, not interfering with rulers. + +### Phase 4: GPU timing & bottleneck detection + +1. Implement `GpuTimer` class with `EXT_disjoint_timer_query_webgl2` +2. Add CPU vs GPU bottleneck indicator to HUD +3. Handle graceful degradation when extension unavailable + +### Phase 5: Detailed profiler & export + +1. Per-node timing capture +2. Speedscope JSON export +3. Overdraw visualization mode +4. Node cost table (could display in dev panel or console) + +--- + +## File structure + +``` +packages/core/src/ + profiler/ + index.ts — re-exports + render-profiler.ts — main RenderProfiler class + frame-stats.ts — FPS/frame-time tracking with rolling averages + gpu-timer.ts — EXT_disjoint_timer_query wrapper + draw-call-counter.ts — WebGL proxy for draw call counting + phase-timer.ts — User Timing API / console.timeStamp integration + hud-renderer.ts — in-canvas HUD overlay drawing + frame-capture.ts — detailed per-node capture data structures + speedscope-export.ts — export to speedscope JSON format +``` + +## Activation + +| Mechanism | What activates | +|-----------|---------------| +| `?profiling` URL param | Use profiling CanvasKit build with full WASM names | +| `Shift+P` keyboard shortcut | Toggle HUD overlay | +| `Shift+Alt+P` | Start/stop detailed frame capture | +| Dev menu → "Export frame profile" | Save speedscope JSON | +| Dev menu → "Show overdraw" | Toggle overdraw heatmap | +| `store.debug.profiler` | Programmatic control from Vue devtools | + +## Key design principles + +1. **Zero cost when off** — no allocations, no timing calls, no proxy overhead when profiler is disabled +2. **Minimal cost when HUD-only** — < 0.2ms overhead for the basic stats display +3. **Render on canvas, not DOM** — HUD is drawn by Skia itself, no DOM overlays that could interfere with WebGL +4. **Async GPU results** — never stall the pipeline waiting for GPU query results; show N-2 frame's GPU time +5. **Production-safe** — the basic HUD and User Timing marks can ship in production behind a flag +6. **DevTools-native** — leverage Chrome's Performance panel extensibility for zero-overhead external profiling +7. **Export-friendly** — speedscope JSON format is the standard for flame chart sharing + +## References + +- [Skia Tracing](https://skia.org/docs/dev/tools/tracing) — Skia's internal `TRACE_EVENT` macros, Perfetto integration +- [Skia Debugger](https://debugger.skia.org) — SKP visual debugger with GPU op bounds +- [Skia Perf](https://skia.org/docs/dev/testing/skiaperf) — Skia's continuous perf monitoring with 400k+ metrics per commit +- [CanvasKit profiling build](https://www.npmjs.com/package/canvaskit-wasm) — `canvaskit-wasm/profiling` with full WASM function names +- [Figma webgl-profiler](https://github.com/figma/webgl-profiler) — Figma's GPU profiler using EXT_disjoint_timer_query, speedscope output +- [Skia render-skp.html](https://skia.googlesource.com/skia/+/052566d8ccb7/tools/perf-canvaskit-puppeteer/render-skp.html) — Skia's own CanvasKit benchmark: frame-to-frame timing methodology +- [EXT_disjoint_timer_query_webgl2 spec](https://registry.khronos.org/webgl/extensions/EXT_disjoint_timer_query_webgl2/) — WebGL GPU timing extension +- [Chrome Performance extensibility API](https://developer.chrome.com/docs/devtools/performance/extension) — Custom tracks via `performance.measure` detail.devtools +- [Unity Frame Timing Manager](https://docs.unity3d.com/2022.3/Documentation/Manual/frame-timing-manager.html) — CPU main thread / render thread / GPU split +- [Unreal Insights](https://dev.epicgames.com/documentation/en-us/unreal-engine/introduction-to-performance-profiling-and-configuration-in-unreal-engine) — Per-thread, per-GPU frame profiling +- Skia Milestone 132-133 release notes — `GrGLInterface` timer query support, `GpuStats.elapsedTime` for GPU time reporting diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 27477f142..ee54fa06d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -48,6 +48,8 @@ export { FigmaAPI, FigmaNodeProxy, type FigmaFontName } from './figma-api' export { ALL_TOOLS, defineTool, toolsToAI } from './tools' export type { ToolDef, ParamDef, ParamType } from './tools' export { SkiaRenderer, type RenderOverlays } from './renderer' +export { RenderProfiler } from './profiler' +export type { FrameCapture, NodeProfile } from './profiler' export { computeLayout, computeAllLayouts, setTextMeasurer } from './layout' export type { TextMeasurer } from './layout' export { getCanvasKit, getGpuBackend, type CanvasKitOptions, type GpuBackend } from './canvaskit' diff --git a/packages/core/src/profiler/draw-call-counter.ts b/packages/core/src/profiler/draw-call-counter.ts new file mode 100644 index 000000000..becc5be2e --- /dev/null +++ b/packages/core/src/profiler/draw-call-counter.ts @@ -0,0 +1,45 @@ +const DRAW_METHODS = [ + 'drawArrays', + 'drawElements', + 'drawArraysInstanced', + 'drawElementsInstanced' +] as const + +type DrawMethod = (typeof DRAW_METHODS)[number] + +export class DrawCallCounter { + count = 0 + + private originals = new Map void>() + private gl: WebGL2RenderingContext | null + + constructor(gl: WebGL2RenderingContext | null) { + this.gl = gl + if (!gl) return + + for (const method of DRAW_METHODS) { + const original = gl[method].bind(gl) as (...args: unknown[]) => void + this.originals.set(method, original) + ;(gl as unknown as Record)[method] = (...args: unknown[]) => { + this.count++ + original(...args) + } + } + } + + reset(): number { + const prev = this.count + this.count = 0 + return prev + } + + destroy(): void { + const gl = this.gl + if (!gl) return + + for (const [method, fn] of this.originals) { + ;(gl as unknown as Record)[method] = fn + } + this.originals.clear() + } +} diff --git a/packages/core/src/profiler/frame-capture.ts b/packages/core/src/profiler/frame-capture.ts new file mode 100644 index 000000000..2ee8a6059 --- /dev/null +++ b/packages/core/src/profiler/frame-capture.ts @@ -0,0 +1,138 @@ +const hasPerformance = typeof performance !== "undefined" + +export interface NodeProfile { + nodeId: string + name: string + type: string + depth: number + startTime: number + endTime: number + selfTime: number + drawCalls: number + culled: boolean + children: NodeProfile[] +} + +export interface FrameCapture { + timestamp: number + totalTimeMs: number + cpuTimeMs: number + gpuTimeMs: number + totalNodes: number + culledNodes: number + drawCalls: number + scenePictureCacheHit: boolean + rootProfiles: NodeProfile[] +} + +export class CaptureStack { + private stack: NodeProfile[] = [] + private roots: NodeProfile[] = [] + private frameStart = 0 + + begin(nodeId: string, name: string, type: string, culled: boolean): void { + const profile: NodeProfile = { + nodeId, + name, + type, + depth: this.stack.length, + startTime: hasPerformance ? performance.now() - this.frameStart : 0, + endTime: 0, + selfTime: 0, + drawCalls: 0, + culled, + children: [], + } + this.stack.push(profile) + } + + end(drawCallsDelta: number): void { + const profile = this.stack.pop() + if (!profile) return + + profile.endTime = hasPerformance ? performance.now() - this.frameStart : 0 + profile.drawCalls = drawCallsDelta + + let childrenTime = 0 + for (const child of profile.children) { + childrenTime += child.endTime - child.startTime + } + profile.selfTime = profile.endTime - profile.startTime - childrenTime + + const parent = this.stack[this.stack.length - 1] + if (parent) { + parent.children.push(profile) + } else { + this.roots.push(profile) + } + } + + reset(frameStart: number): void { + this.stack.length = 0 + this.roots.length = 0 + this.frameStart = frameStart + } + + getRootProfiles(): NodeProfile[] { + return this.roots + } +} + +interface SpeedscopeFrame { + name: string +} + +interface SpeedscopeEvent { + type: "O" | "C" + at: number + frame: number +} + +export function toSpeedscopeJSON(capture: FrameCapture): string { + const frames: SpeedscopeFrame[] = [] + const frameIndex = new Map() + const events: SpeedscopeEvent[] = [] + + function getFrameIdx(nodeId: string, name: string): number { + const key = `${nodeId}\0${name}` + let idx = frameIndex.get(key) + if (idx === undefined) { + idx = frames.length + frames.push({ name: `${name} (${nodeId})` }) + frameIndex.set(key, idx) + } + return idx + } + + function walk(profile: NodeProfile): void { + const idx = getFrameIdx(profile.nodeId, profile.name) + events.push({ type: "O", at: profile.startTime, frame: idx }) + for (const child of profile.children) { + walk(child) + } + events.push({ type: "C", at: profile.endTime, frame: idx }) + } + + for (const root of capture.rootProfiles) { + walk(root) + } + + return JSON.stringify( + { + $schema: "https://www.speedscope.app/file-format-schema.json", + shared: { frames }, + profiles: [ + { + type: "evented", + name: "Frame Render", + unit: "milliseconds", + startValue: 0, + endValue: capture.totalTimeMs, + events, + }, + ], + }, + null, + 2, + ) +} diff --git a/packages/core/src/profiler/frame-stats.ts b/packages/core/src/profiler/frame-stats.ts new file mode 100644 index 000000000..bc5bcce5f --- /dev/null +++ b/packages/core/src/profiler/frame-stats.ts @@ -0,0 +1,122 @@ +const BUFFER_SIZE = 120 + +const hasPerformance = typeof performance !== 'undefined' + +export class FrameStats { + frameTime = 0 + cpuTime = 0 + gpuTime = 0 + + minFrameTime = Infinity + maxFrameTime = 0 + avgFrameTime = 0 + + minCpuTime = Infinity + maxCpuTime = 0 + avgCpuTime = 0 + + minGpuTime = Infinity + maxGpuTime = 0 + avgGpuTime = 0 + + smoothedFps = 0 + + totalNodes = 0 + culledNodes = 0 + drawCalls = 0 + scenePictureCacheHit = false + + private frameTimeBuffer = new Float64Array(BUFFER_SIZE) + private cpuTimeBuffer = new Float64Array(BUFFER_SIZE) + private gpuTimeBuffer = new Float64Array(BUFFER_SIZE) + private bufferIndex = 0 + private bufferCount = 0 + private lastTimestamp = 0 + + recordFrame(cpuTimeMs: number): void { + const now = hasPerformance ? performance.now() : 0 + + if (this.lastTimestamp > 0) { + this.frameTime = now - this.lastTimestamp + } + this.lastTimestamp = now + + this.cpuTime = cpuTimeMs + + const i = this.bufferIndex + this.frameTimeBuffer[i] = this.frameTime + this.cpuTimeBuffer[i] = this.cpuTime + this.gpuTimeBuffer[i] = this.gpuTime + + this.bufferIndex = (i + 1) % BUFFER_SIZE + if (this.bufferCount < BUFFER_SIZE) this.bufferCount++ + + this.computeStats() + } + + getFrameTimeHistory(): Float64Array { + return this.frameTimeBuffer + } + + getCpuTimeHistory(): Float64Array { + return this.cpuTimeBuffer + } + + getGpuTimeHistory(): Float64Array { + return this.gpuTimeBuffer + } + + getBufferIndex(): number { + return this.bufferIndex + } + + getBufferCount(): number { + return this.bufferCount + } + + private computeStats(): void { + const n = this.bufferCount + if (n === 0) return + + let ftSum = 0 + let ftMin = Infinity + let ftMax = 0 + let cpuSum = 0 + let cpuMin = Infinity + let cpuMax = 0 + let gpuSum = 0 + let gpuMin = Infinity + let gpuMax = 0 + + for (let j = 0; j < n; j++) { + const ft = this.frameTimeBuffer[j] + ftSum += ft + if (ft < ftMin) ftMin = ft + if (ft > ftMax) ftMax = ft + + const cpu = this.cpuTimeBuffer[j] + cpuSum += cpu + if (cpu < cpuMin) cpuMin = cpu + if (cpu > cpuMax) cpuMax = cpu + + const gpu = this.gpuTimeBuffer[j] + gpuSum += gpu + if (gpu < gpuMin) gpuMin = gpu + if (gpu > gpuMax) gpuMax = gpu + } + + this.minFrameTime = ftMin + this.maxFrameTime = ftMax + this.avgFrameTime = ftSum / n + + this.minCpuTime = cpuMin + this.maxCpuTime = cpuMax + this.avgCpuTime = cpuSum / n + + this.minGpuTime = gpuMin + this.maxGpuTime = gpuMax + this.avgGpuTime = gpuSum / n + + this.smoothedFps = this.avgFrameTime > 0 ? 1000 / this.avgFrameTime : 0 + } +} diff --git a/packages/core/src/profiler/gpu-timer.ts b/packages/core/src/profiler/gpu-timer.ts new file mode 100644 index 000000000..6fb0f4640 --- /dev/null +++ b/packages/core/src/profiler/gpu-timer.ts @@ -0,0 +1,94 @@ +interface EXTDisjointTimerQuery { + TIME_ELAPSED_EXT: 0x88bf + GPU_DISJOINT_EXT: 0x8fbb +} + +const MAX_PENDING_QUERIES = 4 + +export class GPUTimer { + private gl: WebGL2RenderingContext | null + private ext: EXTDisjointTimerQuery | null = null + private pending: WebGLQuery[] = [] + private activeQuery: WebGLQuery | null = null + private _lastGpuTimeMs = NaN + + get available(): boolean { + return this.ext !== null + } + + get lastGpuTimeMs(): number { + return this._lastGpuTimeMs + } + + constructor(gl: WebGL2RenderingContext | null) { + this.gl = gl + if (gl) { + this.ext = + (gl.getExtension('EXT_disjoint_timer_query_webgl2') as EXTDisjointTimerQuery | null) ?? + null + } + } + + beginFrame(): void { + if (!this.gl || !this.ext) return + if (this.pending.length >= MAX_PENDING_QUERIES) return + + const query = this.gl.createQuery() + if (!query) return + + this.gl.beginQuery(this.ext.TIME_ELAPSED_EXT, query) + this.activeQuery = query + } + + endFrame(): void { + if (!this.gl || !this.ext || !this.activeQuery) return + + this.gl.endQuery(this.ext.TIME_ELAPSED_EXT) + this.pending.push(this.activeQuery) + this.activeQuery = null + } + + pollResults(): number | null { + if (!this.gl || !this.ext) return null + + const disjoint = this.gl.getParameter(this.ext.GPU_DISJOINT_EXT) as boolean + + let result: number | null = null + const remaining: WebGLQuery[] = [] + + for (const query of this.pending) { + const ready = this.gl.getQueryParameter(query, this.gl.QUERY_RESULT_AVAILABLE) as boolean + + if (ready) { + if (!disjoint) { + const ns = this.gl.getQueryParameter(query, this.gl.QUERY_RESULT) as number + this._lastGpuTimeMs = ns / 1_000_000 + result = this._lastGpuTimeMs + } + this.gl.deleteQuery(query) + } else { + remaining.push(query) + } + } + + this.pending = remaining + return result + } + + destroy(): void { + if (!this.gl) return + + if (this.activeQuery) { + if (this.ext) { + this.gl.endQuery(this.ext.TIME_ELAPSED_EXT) + } + this.gl.deleteQuery(this.activeQuery) + this.activeQuery = null + } + + for (const query of this.pending) { + this.gl.deleteQuery(query) + } + this.pending = [] + } +} diff --git a/packages/core/src/profiler/hud-renderer.ts b/packages/core/src/profiler/hud-renderer.ts new file mode 100644 index 000000000..355f3ea03 --- /dev/null +++ b/packages/core/src/profiler/hud-renderer.ts @@ -0,0 +1,242 @@ +import type { FrameStats } from './frame-stats' +import type { CanvasKit, Canvas, Paint, Font, Typeface } from 'canvaskit-wasm' + +const BUFFER_SIZE = 120 +const LINE_HEIGHT = 13 +const PADDING = 6 +const BAR_WIDTH = 2 +const BAR_GAP = 0.5 +const GRAPH_HEIGHT = 40 +const MAX_SCALE_MS = 50 +const BUDGET_MS = 16.67 +const FAST_MS = 16.7 +const SLOW_MS = 33.3 +const CORNER_RADIUS = 4 +const RULER_SIZE = 20 +const SWATCH_SIZE = 6 +const SWATCH_GAP = 3 +const LEGEND_ITEM_GAP = 10 +const GRAPH_WIDTH = BUFFER_SIZE * (BAR_WIDTH + BAR_GAP) +const COL_WIDTH = 130 +const PANEL_WIDTH = Math.max(COL_WIDTH * 2, GRAPH_WIDTH) + PADDING * 2 + +export class HudRenderer { + private bgPaint: Paint + private textPaint: Paint + private dimTextPaint: Paint + private greenPaint: Paint + private yellowPaint: Paint + private redPaint: Paint + private gpuPaint: Paint + private budgetLinePaint: Paint + private graphBgPaint: Paint + private hudFont: Font + + constructor(private ck: CanvasKit) { + this.bgPaint = new ck.Paint() + this.bgPaint.setStyle(ck.PaintStyle.Fill) + this.bgPaint.setColor(ck.Color4f(0.1, 0.1, 0.1, 0.85)) + this.bgPaint.setAntiAlias(true) + + this.textPaint = new ck.Paint() + this.textPaint.setStyle(ck.PaintStyle.Fill) + this.textPaint.setColor(ck.Color4f(0.9, 0.9, 0.9, 1)) + this.textPaint.setAntiAlias(true) + + this.dimTextPaint = new ck.Paint() + this.dimTextPaint.setStyle(ck.PaintStyle.Fill) + this.dimTextPaint.setColor(ck.Color4f(0.55, 0.55, 0.55, 1)) + this.dimTextPaint.setAntiAlias(true) + + this.greenPaint = new ck.Paint() + this.greenPaint.setStyle(ck.PaintStyle.Fill) + this.greenPaint.setColor(ck.Color4f(0.3, 0.85, 0.4, 1)) + this.greenPaint.setAntiAlias(true) + + this.yellowPaint = new ck.Paint() + this.yellowPaint.setStyle(ck.PaintStyle.Fill) + this.yellowPaint.setColor(ck.Color4f(1.0, 0.85, 0.2, 1)) + this.yellowPaint.setAntiAlias(true) + + this.redPaint = new ck.Paint() + this.redPaint.setStyle(ck.PaintStyle.Fill) + this.redPaint.setColor(ck.Color4f(1.0, 0.3, 0.3, 1)) + this.redPaint.setAntiAlias(true) + + this.gpuPaint = new ck.Paint() + this.gpuPaint.setStyle(ck.PaintStyle.Fill) + this.gpuPaint.setColor(ck.Color4f(0.4, 0.6, 1.0, 1)) + this.gpuPaint.setAntiAlias(true) + + this.budgetLinePaint = new ck.Paint() + this.budgetLinePaint.setStyle(ck.PaintStyle.Stroke) + this.budgetLinePaint.setStrokeWidth(1) + this.budgetLinePaint.setColor(ck.Color4f(1, 1, 1, 0.3)) + this.budgetLinePaint.setAntiAlias(true) + this.budgetLinePaint.setPathEffect(ck.PathEffect.MakeDash([3, 3], 0)) + + this.graphBgPaint = new ck.Paint() + this.graphBgPaint.setStyle(ck.PaintStyle.Fill) + this.graphBgPaint.setColor(ck.Color4f(0.05, 0.05, 0.05, 0.5)) + this.graphBgPaint.setAntiAlias(true) + + this.hudFont = new ck.Font(null, 10) + } + + setTypeface(typeface: Typeface): void { + this.hudFont.delete() + this.hudFont = new this.ck.Font(typeface, 10) + } + + draw(canvas: Canvas, stats: FrameStats, phases: Map, showRulers: boolean): void { + const rulerOffset = showRulers ? RULER_SIZE : 0 + const hasGraph = stats.getBufferCount() > 0 + + const phaseNames = [ + 'render:scene', + 'render:drawPicture', + 'render:recordPicture', + 'render:volatile', + 'render:sectionTitles', + 'render:componentLabels', + 'render:selection', + 'render:rulers', + 'render:flush' + ] + const visiblePhases = phaseNames.filter((n) => (phases.get(n) ?? 0) > 0.01) + + const statsRows = 3 + const phaseRows = visiblePhases.length > 0 ? 1 + visiblePhases.length : 0 + const statsHeight = (statsRows + phaseRows) * LINE_HEIGHT + const graphSection = hasGraph ? GRAPH_HEIGHT + PADDING + LINE_HEIGHT : 0 + const contentHeight = statsHeight + PADDING * 2 + graphSection + + const bgX = rulerOffset + PADDING + const bgY = rulerOffset + PADDING + + const bgRect = this.ck.LTRBRect(bgX, bgY, bgX + PANEL_WIDTH, bgY + contentHeight) + canvas.drawRRect(this.ck.RRectXY(bgRect, CORNER_RADIUS, CORNER_RADIUS), this.bgPaint) + + const col1 = bgX + PADDING + const col2 = col1 + COL_WIDTH + let y = bgY + PADDING + LINE_HEIGHT + + const fps = Math.round(stats.smoothedFps) + const avgFrame = stats.avgFrameTime.toFixed(1) + const avgCpu = stats.avgCpuTime.toFixed(1) + const gpuAvailable = !Number.isNaN(stats.avgGpuTime) && stats.avgGpuTime > 0 + const avgGpu = gpuAvailable ? stats.avgGpuTime.toFixed(1) : 'n/a' + const cacheStatus = stats.scenePictureCacheHit ? 'HIT' : 'MISS' + + canvas.drawText(`FPS: ${fps} (${avgFrame}ms)`, col1, y, this.textPaint, this.hudFont) + canvas.drawText(`Nodes: ${stats.totalNodes} (${stats.culledNodes} culled)`, col2, y, this.textPaint, this.hudFont) + y += LINE_HEIGHT + + canvas.drawText(`CPU: ${avgCpu}ms`, col1, y, this.textPaint, this.hudFont) + canvas.drawText(`Draws: ${stats.drawCalls}`, col2, y, this.textPaint, this.hudFont) + y += LINE_HEIGHT + + canvas.drawText(`GPU: ${avgGpu}${gpuAvailable ? 'ms' : ''}`, col1, y, this.textPaint, this.hudFont) + canvas.drawText(`Cache: ${cacheStatus}`, col2, y, this.textPaint, this.hudFont) + + if (visiblePhases.length > 0) { + y += LINE_HEIGHT + canvas.drawText('Phases:', col1, y, this.dimTextPaint, this.hudFont) + for (const name of visiblePhases) { + y += LINE_HEIGHT + const ms = (phases.get(name) ?? 0).toFixed(2) + const label = name.replace('render:', '') + canvas.drawText(` ${label}: ${ms}ms`, col1, y, this.dimTextPaint, this.hudFont) + } + } + + if (hasGraph) { + const graphX = bgX + PADDING + const graphY = y + PADDING + this.drawBarGraph(canvas, stats, graphX, graphY) + this.drawLegendRow(canvas, graphX, graphY + GRAPH_HEIGHT + LINE_HEIGHT - 2) + } + } + + private drawLegendRow(canvas: Canvas, x: number, y: number): void { + const items: [Paint, string][] = [ + [this.greenPaint, '60fps'], + [this.yellowPaint, '30fps'], + [this.redPaint, 'slow'], + [this.gpuPaint, 'GPU'] + ] + let cx = x + for (const [paint, label] of items) { + const swatchY = y - SWATCH_SIZE + 1 + canvas.drawRect( + this.ck.LTRBRect(cx, swatchY, cx + SWATCH_SIZE, swatchY + SWATCH_SIZE), + paint + ) + cx += SWATCH_SIZE + SWATCH_GAP + canvas.drawText(label, cx, y, this.dimTextPaint, this.hudFont) + cx += label.length * 5.5 + LEGEND_ITEM_GAP + } + } + + private drawBarGraph(canvas: Canvas, stats: FrameStats, graphX: number, graphY: number): void { + const graphRect = this.ck.LTRBRect(graphX, graphY, graphX + GRAPH_WIDTH, graphY + GRAPH_HEIGHT) + canvas.drawRRect(this.ck.RRectXY(graphRect, 2, 2), this.graphBgPaint) + + const cpuHistory = stats.getCpuTimeHistory() + const gpuHistory = stats.getGpuTimeHistory() + const bufferCount = stats.getBufferCount() + const bufferIndex = stats.getBufferIndex() + + const barOffset = BUFFER_SIZE - bufferCount + + for (let i = 0; i < bufferCount; i++) { + const histIndex = (bufferIndex + i) % BUFFER_SIZE + + const cpuTime = cpuHistory[histIndex] + if (cpuTime <= 0) continue + + const barHeight = Math.min((cpuTime / MAX_SCALE_MS) * GRAPH_HEIGHT, GRAPH_HEIGHT) + const barX = graphX + (i + barOffset) * (BAR_WIDTH + BAR_GAP) + const barY = graphY + GRAPH_HEIGHT - barHeight + + let paint: Paint + if (cpuTime < FAST_MS) { + paint = this.greenPaint + } else if (cpuTime < SLOW_MS) { + paint = this.yellowPaint + } else { + paint = this.redPaint + } + + canvas.drawRect( + this.ck.LTRBRect(barX, barY, barX + BAR_WIDTH, graphY + GRAPH_HEIGHT), + paint + ) + + const gpuTime = gpuHistory[histIndex] + if (!Number.isNaN(gpuTime) && gpuTime > 0) { + const gpuBarHeight = Math.min((gpuTime / MAX_SCALE_MS) * GRAPH_HEIGHT, GRAPH_HEIGHT) + canvas.drawRect( + this.ck.LTRBRect(barX, graphY + GRAPH_HEIGHT - gpuBarHeight, barX + BAR_WIDTH, graphY + GRAPH_HEIGHT), + this.gpuPaint + ) + } + } + + const budgetY = graphY + GRAPH_HEIGHT - (BUDGET_MS / MAX_SCALE_MS) * GRAPH_HEIGHT + canvas.drawLine(graphX, budgetY, graphX + GRAPH_WIDTH, budgetY, this.budgetLinePaint) + } + + destroy(): void { + this.bgPaint.delete() + this.textPaint.delete() + this.dimTextPaint.delete() + this.greenPaint.delete() + this.yellowPaint.delete() + this.redPaint.delete() + this.gpuPaint.delete() + this.budgetLinePaint.delete() + this.graphBgPaint.delete() + this.hudFont.delete() + } +} diff --git a/packages/core/src/profiler/index.ts b/packages/core/src/profiler/index.ts new file mode 100644 index 000000000..f2eb6daa3 --- /dev/null +++ b/packages/core/src/profiler/index.ts @@ -0,0 +1,8 @@ +export { RenderProfiler } from './render-profiler' +export { FrameStats } from './frame-stats' +export { GPUTimer } from './gpu-timer' +export { DrawCallCounter } from './draw-call-counter' +export { PhaseTimer } from './phase-timer' +export { HudRenderer } from './hud-renderer' +export { CaptureStack, toSpeedscopeJSON } from './frame-capture' +export type { NodeProfile, FrameCapture } from './frame-capture' diff --git a/packages/core/src/profiler/phase-timer.ts b/packages/core/src/profiler/phase-timer.ts new file mode 100644 index 000000000..d2cceb700 --- /dev/null +++ b/packages/core/src/profiler/phase-timer.ts @@ -0,0 +1,62 @@ +type DevToolsColor = + | 'primary' + | 'secondary' + | 'tertiary' + | 'tertiary-dark' + | 'secondary-dark' + | 'secondary-light' + +function colorForPhase(name: string): DevToolsColor { + if (name === 'frame') return 'primary' + if (name === 'render:flush') return 'tertiary-dark' + if (name === 'render:recordPicture') return 'tertiary' + if (name.startsWith('render:')) return 'secondary' + if (name.startsWith('layout:')) return 'secondary-dark' + return 'secondary-light' +} + +const SMOOTH = 0.05 + +export class PhaseTimer { + enabled = false + readonly averages = new Map() + + private starts = new Map() + + beginPhase(name: string): void { + if (!this.enabled || typeof performance === 'undefined') return + this.starts.set(name, performance.now()) + } + + endPhase(name: string): void { + if (!this.enabled || typeof performance === 'undefined') return + + const startTime = this.starts.get(name) + if (startTime === undefined) return + this.starts.delete(name) + + const duration = performance.now() - startTime + const prev = this.averages.get(name) + this.averages.set( + name, + prev === undefined ? duration : prev + (duration - prev) * SMOOTH + ) + + performance.measure(name, { + start: startTime, + detail: { + devtools: { + dataType: 'track-entry', + track: 'Renderer', + trackGroup: 'OpenPencil', + color: colorForPhase(name) + } + } + }) + } + + clearPhases(): void { + this.starts.clear() + this.averages.clear() + } +} diff --git a/packages/core/src/profiler/render-profiler.ts b/packages/core/src/profiler/render-profiler.ts new file mode 100644 index 000000000..ea00b3ad3 --- /dev/null +++ b/packages/core/src/profiler/render-profiler.ts @@ -0,0 +1,163 @@ +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() + } +} diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 435f1be54..eeb19b5fb 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -60,6 +60,7 @@ import { } from './constants' import { isFontLoaded } from './fonts' import { vectorNetworkToPath } from './vector' +import { RenderProfiler } from './profiler' import type { SceneNode, SceneGraph, Fill, Stroke } from './scene-graph' import type { SnapGuide } from './snap' @@ -147,6 +148,7 @@ export class SkiaRenderer { private scenePictureVersion = -1 private scenePicturePageId: string | null = null private nodePictureCache = new Map() + readonly profiler: RenderProfiler private rulerBgPaint: Paint private rulerTickPaint: Paint @@ -170,6 +172,8 @@ export class SkiaRenderer { pageId: string | null = null private worldViewport = { x: 0, y: 0, w: 0, h: 0 } + private _nodeCount = 0 + private _culledCount = 0 private color4f(r: number, g: number, b: number, a: number): Float32Array { const c = this._tmpColor @@ -201,9 +205,10 @@ export class SkiaRenderer { return type === 'COMPONENT' || type === 'COMPONENT_SET' || type === 'INSTANCE' } - constructor(ck: CanvasKit, surface: Surface) { + constructor(ck: CanvasKit, surface: Surface, gl?: WebGL2RenderingContext | null) { this.ck = ck this.surface = surface + this.profiler = new RenderProfiler(ck, gl ?? null) this.fillPaint = new ck.Paint() this.fillPaint.setStyle(ck.PaintStyle.Fill) @@ -321,6 +326,7 @@ export class SkiaRenderer { this.sizeFont = new this.ck.Font(typeface, SIZE_FONT_SIZE) this.sectionTitleFont = new this.ck.Font(typeface, SECTION_TITLE_FONT_SIZE) this.componentLabelFont = new this.ck.Font(typeface, COMPONENT_LABEL_FONT_SIZE) + this.profiler.setTypeface(typeface) } this.fontMgr = this.ck.FontMgr.FromData(fontData) ?? null } @@ -491,6 +497,9 @@ export class SkiaRenderer { overlays: RenderOverlays = {}, sceneVersion = -1 ): void { + const p = this.profiler + p.beginFrame() + graph.clearAbsPosCache() const canvas = this.surface.getCanvas() @@ -505,7 +514,6 @@ export class SkiaRenderer { } const hasVolatileOverlays = - overlays.hoveredNodeId != null || overlays.dropTargetId != null || overlays.rotationPreview != null || overlays.editingTextId != null @@ -516,48 +524,80 @@ export class SkiaRenderer { sceneVersion === this.scenePictureVersion && this.pageId === this.scenePicturePageId + p.setCacheHit(!!canUsePicture) + // Scene layer (world coordinates) canvas.save() canvas.scale(this.dpr, this.dpr) canvas.translate(this.panX, this.panY) canvas.scale(this.zoom, this.zoom) + p.beginPhase('render:scene') if (canUsePicture) { + p.beginPhase('render:drawPicture') canvas.drawPicture(this.scenePicture!) + p.endPhase('render:drawPicture') } else if (hasVolatileOverlays) { + this._nodeCount = 0 + this._culledCount = 0 + p.beginPhase('render:volatile') const pageNode = graph.getNode(this.pageId ?? graph.rootId) if (pageNode) { for (const childId of pageNode.childIds) { this.renderNode(canvas, graph, childId, overlays, 0, 0) } } + p.endPhase('render:volatile') } else { + this._nodeCount = 0 + this._culledCount = 0 + p.beginPhase('render:recordPicture') this.recordScenePicture(canvas, graph, sceneVersion) + p.endPhase('render:recordPicture') } + p.endPhase('render:scene') canvas.restore() // Section titles + component labels (screen coordinates, zoom-independent) canvas.save() canvas.scale(this.dpr, this.dpr) + p.beginPhase('render:sectionTitles') this.drawSectionTitles(canvas, graph) + p.endPhase('render:sectionTitles') + p.beginPhase('render:componentLabels') this.drawComponentLabels(canvas, graph) + p.endPhase('render:componentLabels') canvas.restore() // UI overlay layer (screen coordinates, zoom-independent) canvas.save() canvas.scale(this.dpr, this.dpr) + this.drawHoverHighlight(canvas, graph, overlays.hoveredNodeId) + p.beginPhase('render:selection') this.drawSelection(canvas, graph, selectedIds, overlays) + p.endPhase('render:selection') this.drawSnapGuides(canvas, overlays.snapGuides) this.drawMarquee(canvas, overlays.marquee) this.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator) this.drawPenOverlay(canvas, overlays.penState) this.drawRemoteCursors(canvas, graph, overlays.remoteCursors) + p.beginPhase('render:rulers') if (this.showRulers) this.drawRulers(canvas, graph, selectedIds) + p.endPhase('render:rulers') + + // Profiler HUD (drawn last, on top of everything) + p.drawHUD(canvas, this.showRulers) canvas.restore() + + p.beginPhase('render:flush') this.surface.flush() + p.endPhase('render:flush') + + p.setNodeCounts(this._nodeCount, this._culledCount) + p.endFrame() } private recordScenePicture(canvas: Canvas, graph: SceneGraph, sceneVersion: number): void { @@ -583,6 +623,37 @@ export class SkiaRenderer { // --- Selection UI --- + private drawHoverHighlight( + canvas: Canvas, + graph: SceneGraph, + hoveredNodeId?: string | null + ): void { + if (!hoveredNodeId) return + const node = graph.getNode(hoveredNodeId) + if (!node) return + + const abs = graph.getAbsolutePosition(node.id) + const sx = abs.x * this.zoom + this.panX + const sy = abs.y * this.zoom + this.panY + + this.auxStroke.setStrokeWidth(1 / this.zoom) + this.auxStroke.setColor( + this.isComponentType(node.type) ? this.compColor() : this.selColor() + ) + this.auxStroke.setPathEffect(null) + + canvas.save() + canvas.translate(sx, sy) + if (node.rotation !== 0) { + const cx = (node.width / 2) * this.zoom + const cy = (node.height / 2) * this.zoom + canvas.rotate(node.rotation, cx, cy) + } + canvas.scale(this.zoom, this.zoom) + this.strokeNodeShape(canvas, node, this.auxStroke) + canvas.restore() + } + private drawSelection( canvas: Canvas, graph: SceneGraph, @@ -969,6 +1040,8 @@ export class SkiaRenderer { const node = graph.getNode(nodeId) if (!node || !node.visible) return + this._nodeCount++ + const absX = parentAbsX + node.x const absY = parentAbsY + node.y @@ -994,9 +1067,11 @@ export class SkiaRenderer { cx + diag / 2 < vp.x || cy + diag / 2 < vp.y ) { + this._culledCount++ return } } else if (absX > vp.x + vp.w || absY > vp.y + vp.h || absX + bw < vp.x || absY + bh < vp.y) { + this._culledCount++ return } } @@ -1049,14 +1124,6 @@ export class SkiaRenderer { canvas.drawRect(this.ck.LTRBRect(0, 0, node.width, node.height), this.auxStroke) } - // Hover highlight — shape-aware outline - if (overlays.hoveredNodeId === nodeId) { - this.auxStroke.setStrokeWidth(1 / this.zoom) - this.auxStroke.setColor(this.isComponentType(node.type) ? this.compColor() : this.selColor()) - this.auxStroke.setPathEffect(null) - this.strokeNodeShape(canvas, node, this.auxStroke) - } - // Clip + render children for containers const isClippableContainer = node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE' @@ -2942,6 +3009,7 @@ export class SkiaRenderer { for (const pic of this.nodePictureCache.values()) pic?.delete() this.nodePictureCache.clear() this.scenePicture?.delete() + this.profiler.destroy() this.surface.delete() } } diff --git a/src/components/AppMenu.vue b/src/components/AppMenu.vue index 67d71f752..736c816dc 100644 --- a/src/components/AppMenu.vue +++ b/src/components/AppMenu.vue @@ -111,6 +111,16 @@ const viewMenu: MenuItem[] = [ label: 'Zoom out', shortcut: `${mod}-`, action: () => store.applyZoom(100, window.innerWidth / 2, window.innerHeight / 2) + }, + { separator: true }, + { + label: 'Performance profiler', + get checked() { + return store.renderer?.profiler.hudVisible ?? false + }, + onCheckedChange: () => { + store.toggleProfiler() + } } ] diff --git a/src/composables/use-canvas.ts b/src/composables/use-canvas.ts index 56f1a68eb..b7bec0542 100644 --- a/src/composables/use-canvas.ts +++ b/src/composables/use-canvas.ts @@ -118,7 +118,8 @@ export function useCanvas(canvasRef: Ref, store: Edito } } - renderer = new SkiaRenderer(ck, surface) + const glCtx = (canvas.getContext('webgl2') ?? null) as WebGL2RenderingContext | null + renderer = new SkiaRenderer(ck, surface, glCtx) store.setCanvasKit(ck, renderer) renderer.loadFonts().then(() => renderNow()) renderNow() diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 14540c5c2..c22a28ae5 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -1477,6 +1477,11 @@ export function createEditorStore() { requestRender() } + function toggleProfiler() { + _renderer?.profiler.toggle() + requestRepaint() + } + function toggleVisibility() { for (const id of state.selectedIds) { const node = graph.getNode(id) @@ -2059,6 +2064,7 @@ export function createEditorStore() { goToMainComponent, bringToFront, sendToBack, + toggleProfiler, toggleVisibility, toggleLock, moveToPage, diff --git a/tests/engine/profiler.test.ts b/tests/engine/profiler.test.ts new file mode 100644 index 000000000..3c8e54696 --- /dev/null +++ b/tests/engine/profiler.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'bun:test' +import { FrameStats } from '../../packages/core/src/profiler/frame-stats' +import { DrawCallCounter } from '../../packages/core/src/profiler/draw-call-counter' +import { PhaseTimer } from '../../packages/core/src/profiler/phase-timer' +import { GPUTimer } from '../../packages/core/src/profiler/gpu-timer' +import { CaptureStack, toSpeedscopeJSON } from '../../packages/core/src/profiler/frame-capture' + +describe('FrameStats', () => { + it('records frames and computes rolling averages', () => { + const stats = new FrameStats() + + for (let i = 0; i < 10; i++) { + stats.recordFrame(5) + } + + expect(stats.avgCpuTime).toBe(5) + expect(stats.smoothedFps).toBeGreaterThan(0) + expect(stats.getFrameTimeHistory()).toBeInstanceOf(Float64Array) + expect(stats.getBufferCount()).toBe(10) + }) + + it('tracks external fields', () => { + const stats = new FrameStats() + stats.totalNodes = 100 + stats.culledNodes = 20 + stats.drawCalls = 50 + stats.scenePictureCacheHit = true + + expect(stats.totalNodes).toBe(100) + expect(stats.culledNodes).toBe(20) + expect(stats.drawCalls).toBe(50) + expect(stats.scenePictureCacheHit).toBe(true) + }) + + it('handles GPU time as NaN initially', () => { + const stats = new FrameStats() + stats.recordFrame(1) + expect(stats.gpuTime).toBe(0) + }) +}) + +describe('DrawCallCounter', () => { + it('handles null GL context', () => { + const counter = new DrawCallCounter(null) + expect(counter.count).toBe(0) + expect(counter.reset()).toBe(0) + counter.destroy() + }) +}) + +describe('GPUTimer', () => { + it('handles null GL context', () => { + const timer = new GPUTimer(null) + expect(timer.available).toBe(false) + expect(timer.lastGpuTimeMs).toBeNaN() + timer.beginFrame() + timer.endFrame() + expect(timer.pollResults()).toBeNull() + timer.destroy() + }) +}) + +describe('PhaseTimer', () => { + it('is disabled by default', () => { + const timer = new PhaseTimer() + expect(timer.enabled).toBe(false) + }) + + it('records measures when enabled', () => { + const timer = new PhaseTimer() + timer.enabled = true + timer.beginPhase('test') + timer.endPhase('test') + timer.clearPhases() + }) + + it('is a no-op when disabled', () => { + const timer = new PhaseTimer() + timer.beginPhase('test') + timer.endPhase('test') + }) +}) + +describe('CaptureStack', () => { + it('builds a tree of node profiles', () => { + const stack = new CaptureStack() + stack.reset(performance.now()) + + stack.begin('node-1', 'Frame 1', 'FRAME', false) + stack.begin('node-2', 'Rect', 'RECTANGLE', false) + stack.end(2) + stack.end(3) + + const roots = stack.getRootProfiles() + expect(roots).toHaveLength(1) + expect(roots[0].nodeId).toBe('node-1') + expect(roots[0].children).toHaveLength(1) + expect(roots[0].children[0].nodeId).toBe('node-2') + expect(roots[0].children[0].drawCalls).toBe(2) + }) +}) + +describe('toSpeedscopeJSON', () => { + it('produces valid speedscope JSON', () => { + const capture = { + timestamp: 0, + totalTimeMs: 10, + cpuTimeMs: 8, + gpuTimeMs: 6, + totalNodes: 5, + culledNodes: 1, + drawCalls: 10, + scenePictureCacheHit: false, + rootProfiles: [ + { + nodeId: 'n1', + name: 'Frame', + type: 'FRAME', + depth: 0, + startTime: 0, + endTime: 10, + selfTime: 5, + drawCalls: 3, + culled: false, + children: [ + { + nodeId: 'n2', + name: 'Rect', + type: 'RECTANGLE', + depth: 1, + startTime: 2, + endTime: 7, + selfTime: 5, + drawCalls: 2, + culled: false, + children: [] + } + ] + } + ] + } + + const json = toSpeedscopeJSON(capture) + const parsed = JSON.parse(json) + + expect(parsed.$schema).toBe('https://www.speedscope.app/file-format-schema.json') + expect(parsed.profiles).toHaveLength(1) + expect(parsed.profiles[0].type).toBe('evented') + expect(parsed.profiles[0].unit).toBe('milliseconds') + expect(parsed.shared.frames).toHaveLength(2) + expect(parsed.profiles[0].events).toHaveLength(4) + }) +})