perf(canvas): cache label hit tests and trace scene renderer

This commit is contained in:
Danila Poyarkov 2026-05-17 03:27:19 +03:00
parent faaa741911
commit c044d26431
6 changed files with 88 additions and 30 deletions

View file

@ -98,6 +98,14 @@ export class LabelCache {
}))
}
getAllSections(): readonly CachedSection[] {
return this.sections
}
getAllComponents(): readonly CachedComponent[] {
return this.components
}
private rebuild(graph: SceneGraph, pageId: string | null): void {
this.sections = []
this.components = []

View file

@ -14,6 +14,8 @@ import {
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import type { Vector } from '#core/types'
import type { LabelCache } from './cache'
function measureGlyphWidth(font: Font, text: string): number {
const glyphIds = font.getGlyphIDs(text)
const widths = font.getGlyphWidths(glyphIds)
@ -76,52 +78,88 @@ function walkLabelTree(
return result
}
export function hitTestSectionTitle(
function hitCachedLabel<T extends { nodeId: string; absX: number; absY: number }>(
graph: SceneGraph,
items: readonly T[],
hit: (node: SceneNode, item: T) => SceneNode | null
): SceneNode | null {
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]
const node = graph.getNode(item.nodeId)
if (!node || !node.visible) continue
const result = hit(node, item)
if (result) return result
}
return null
}
function hitSectionTitle(
child: SceneNode,
ax: number,
ay: number,
insideSection: boolean,
canvasX: number,
canvasY: number,
zoom: number,
pageId: string,
font: Font | null
font: Font
): SceneNode | null {
const textW = measureGlyphWidth(font, child.name)
const pillW = Math.min(textW + SECTION_TITLE_PADDING_X * 2, child.width * zoom) / zoom
const pillH = SECTION_TITLE_HEIGHT / zoom
const gap = SECTION_TITLE_GAP / zoom
const hit = rotatePoint(canvasX - ax, canvasY - ay, child.rotation)
const pillY = insideSection ? gap : -pillH - gap
return hitInRect(hit.x, hit.y, 0, pillY, pillW, pillH) ? child : null
}
export function hitTestSectionTitle(graph: SceneGraph, canvasX: number, canvasY: number, zoom: number, pageId: string, font: Font | null, labelCache?: LabelCache): SceneNode | null {
if (!font) return null
if (labelCache) {
return hitCachedLabel(graph, labelCache.getAllSections(), (child, section) =>
hitSectionTitle(child, section.absX, section.absY, section.nested, canvasX, canvasY, zoom, font)
)
}
return walkLabelTree(graph, pageId, (child, _parent, ax, ay, insideSection) => {
if (child.type !== 'SECTION') return undefined
const textW = measureGlyphWidth(font, child.name)
const pillW = Math.min(textW + SECTION_TITLE_PADDING_X * 2, child.width * zoom) / zoom
const pillH = SECTION_TITLE_HEIGHT / zoom
const gap = SECTION_TITLE_GAP / zoom
const hit = rotatePoint(canvasX - ax, canvasY - ay, child.rotation)
const pillY = insideSection ? gap : -pillH - gap
return hitInRect(hit.x, hit.y, 0, pillY, pillW, pillH) ? child : undefined
return hitSectionTitle(child, ax, ay, insideSection, canvasX, canvasY, zoom, font)
})
}
export function hitTestComponentLabel(
graph: SceneGraph,
function hitComponentLabel(
child: SceneNode,
ax: number,
ay: number,
canvasX: number,
canvasY: number,
zoom: number,
pageId: string,
font: Font | null
font: Font
): SceneNode | null {
const textW = measureGlyphWidth(font, child.name)
const labelW = (COMPONENT_LABEL_ICON_SIZE + COMPONENT_LABEL_ICON_GAP + textW) / zoom
const labelH = COMPONENT_LABEL_FONT_SIZE / zoom
const gap = COMPONENT_LABEL_GAP / zoom
const labelY = ay - labelH - gap
return hitInRect(canvasX, canvasY, ax, labelY, labelW, labelH) ? child : null
}
export function hitTestComponentLabel(graph: SceneGraph, canvasX: number, canvasY: number, zoom: number, pageId: string, font: Font | null, labelCache?: LabelCache): SceneNode | null {
if (!font) return null
if (labelCache) {
return hitCachedLabel(graph, labelCache.getAllComponents(), (child, component) =>
hitComponentLabel(child, component.absX, component.absY, canvasX, canvasY, zoom, font)
)
}
const LABEL_TYPES = new Set(['COMPONENT', 'COMPONENT_SET'])
return walkLabelTree(graph, pageId, (child, _parent, ax, ay) => {
if (!LABEL_TYPES.has(child.type)) return undefined
const textW = measureGlyphWidth(font, child.name)
const labelW = (COMPONENT_LABEL_ICON_SIZE + COMPONENT_LABEL_ICON_GAP + textW) / zoom
const labelH = COMPONENT_LABEL_FONT_SIZE / zoom
const gap = COMPONENT_LABEL_GAP / zoom
const labelY = ay - labelH - gap
return hitInRect(canvasX, canvasY, ax, labelY, labelW, labelH) ? child : undefined
return hitComponentLabel(child, ax, ay, canvasX, canvasY, zoom, font)
})
}

View file

@ -416,7 +416,8 @@ export class SkiaRenderer {
canvasY,
this.zoom,
this.pageId ?? graph.rootId,
this.sectionTitleFont
this.sectionTitleFont,
this.labelCache
)
}
@ -427,7 +428,8 @@ export class SkiaRenderer {
canvasY,
this.zoom,
this.pageId ?? graph.rootId,
this.componentLabelFont
this.componentLabelFont,
this.labelCache
)
}

View file

@ -206,6 +206,9 @@ export function createEditor(options?: EditorOptions) {
get renderer() {
return _renderer
},
get canvasRenderers() {
return [..._renderers]
},
get textEditor() {
return _textEditor
},

View file

@ -33,13 +33,17 @@ export class RenderProfiler {
this.hud = new HudController(ck)
}
toggle(): void {
this.hudVisible = !this.hudVisible
this.enabled = this.hudVisible
setVisible(visible: boolean): void {
this.hudVisible = visible
this.enabled = visible
this.phases.enabled = this.enabled
this.syncInstrumentation()
}
toggle(): void {
this.setVisible(!this.hudVisible)
}
beginFrame(): void {
if (!this.enabled) return
this.renderStartTime = now()

View file

@ -20,7 +20,10 @@ export function createProfilerActions(editor: Editor) {
}
function toggleProfiler() {
editor.renderer?.profiler.toggle()
const visible = !(editor.renderer?.profiler.hudVisible ?? false)
for (const renderer of editor.canvasRenderers) {
renderer.profiler.setVisible(visible)
}
editor.requestRepaint()
}