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:
parent
72f140700d
commit
2a86ea5ce5
352
docs/renderer-profiler-plan.md
Normal file
352
docs/renderer-profiler-plan.md
Normal 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.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
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
45
packages/core/src/profiler/draw-call-counter.ts
Normal file
45
packages/core/src/profiler/draw-call-counter.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
138
packages/core/src/profiler/frame-capture.ts
Normal file
138
packages/core/src/profiler/frame-capture.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
122
packages/core/src/profiler/frame-stats.ts
Normal file
122
packages/core/src/profiler/frame-stats.ts
Normal 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
|
||||
}
|
||||
}
|
||||
94
packages/core/src/profiler/gpu-timer.ts
Normal file
94
packages/core/src/profiler/gpu-timer.ts
Normal 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 = []
|
||||
}
|
||||
}
|
||||
242
packages/core/src/profiler/hud-renderer.ts
Normal file
242
packages/core/src/profiler/hud-renderer.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
8
packages/core/src/profiler/index.ts
Normal file
8
packages/core/src/profiler/index.ts
Normal 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'
|
||||
62
packages/core/src/profiler/phase-timer.ts
Normal file
62
packages/core/src/profiler/phase-timer.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
163
packages/core/src/profiler/render-profiler.ts
Normal file
163
packages/core/src/profiler/render-profiler.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
153
tests/engine/profiler.test.ts
Normal file
153
tests/engine/profiler.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue