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
This commit is contained in:
Danila Poyarkov 2026-03-04 13:03:00 +03:00
parent 72f140700d
commit 2a86ea5ce5
15 changed files with 1477 additions and 11 deletions

View file

@ -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.733.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

View file

@ -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'

View file

@ -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<DrawMethod, (...args: unknown[]) => 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<string, unknown>)[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<string, unknown>)[method] = fn
}
this.originals.clear()
}
}

View file

@ -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<string, number>()
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,
)
}

View file

@ -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
}
}

View file

@ -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 = []
}
}

View file

@ -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<string, number>, 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()
}
}

View file

@ -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'

View file

@ -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<string, number>()
private starts = new Map<string, number>()
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()
}
}

View file

@ -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()
}
}

View file

@ -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<string, SkPicture>()
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()
}
}

View file

@ -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()
}
}
]

View file

@ -118,7 +118,8 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, 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()

View file

@ -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,

View file

@ -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)
})
})