Skip to content

Explore WebGL for indicator and overlay rendering #7437

@steveruizok

Description

@steveruizok

Problem

The current indicator and overlay rendering system has significant performance issues:

  1. Complete remount on page changes - When switching pages, all indicator DOM nodes are destroyed and recreated because React keys are id + '_indicator' and shape IDs differ between pages. For pages with 100+ shapes, this causes noticeable lag.

  2. All indicators render when any changes - When the hovered shape changes, or selection changes, the entire renderingShapes array is mapped and all ShapeIndicator components render (even though most are memoized, they still go through memo comparison).

  3. DOM overhead - Each indicator requires its own SVG element with nested groups, meaning hundreds of DOM nodes for a moderately complex canvas. The overlays layer contains many SVG elements even when most indicators are hidden via display: none.

  4. Multiple memo layers - The current implementation uses three layers of memo (DefaultShapeIndicatorInnerIndicatorEvenInnererIndicator) to allow hooks at each level, but this adds prop drilling and comparison overhead.

Current architecture

Component hierarchy

.tl-overlays (div, contain: strict)
└── .tl-html-layer (div, scaled/translated)
    └── DefaultShapeIndicators (memo)
        └── [for each rendering shape]
            └── DefaultShapeIndicator (memo)
                └── svg.tl-overlays__item
                    └── g.tl-shape-indicator
                        └── InnerIndicator (memo)
                            └── EvenInnererIndicator (memo, custom comparison)
                                └── ShapeUtil.indicator() → SVG path/rect

Key files

  • packages/editor/src/lib/components/default-components/DefaultShapeIndicators.tsx
  • packages/editor/src/lib/components/default-components/DefaultShapeIndicator.tsx
  • packages/editor/src/lib/components/default-components/DefaultCanvas.tsx

Current optimizations

  • CSS contain: strict on .tl-overlays prevents layout thrashing
  • Transform updates via direct DOM manipulation (useQuickReactor) bypass React
  • Custom memo comparison only checks shape.props and shape.meta
  • Visibility culling filters off-screen shapes via notVisibleShapes derivation (recently optimized in perf: optimize notVisibleShapes derivation with inlined bounds checks #7429)
  • idsToDisplay diffing prevents unnecessary re-renders when selection hasn't changed

Proposed alternatives

Option 1: WebGL overlay canvas

Replace the entire indicator/overlay layer with a WebGL canvas that renders indicators directly to GPU.

Pros:

  • Single canvas element instead of hundreds of DOM nodes
  • GPU-accelerated rendering - batch all indicators in one draw call
  • Near-instant "page switch" - just clear and redraw geometry
  • Already have WebGLManager pattern in templates/shader/
  • Smooth zoom/pan - matrix transforms on GPU

Cons:

  • Need to reimplement indicator geometry rendering in WebGL
  • Lose React component model for indicators
  • More complex debugging
  • Custom shapes need WebGL indicator implementation
  • Need fallback for WebGL unavailability

Approach:

  1. Create IndicatorWebGLManager extending existing WebGLManager pattern
  2. Collect all indicator geometry (paths, rects, etc.) each frame
  3. Batch render as stroked paths using a line shader
  4. Use instanced rendering for common shapes

Option 2: Canvas 2D overlay

Use HTML5 Canvas 2D context instead of WebGL.

Pros:

  • Simpler than WebGL - no shaders needed
  • Good for moderate shape counts
  • Easy to render SVG paths via Path2D

Cons:

  • CPU-bound, less scalable than WebGL
  • Still need to reimplement indicator rendering
  • Performance varies significantly by browser/OS

Option 3: Single SVG with reused elements

Keep SVG but consolidate all indicators into a single <svg> with reusable <use> elements.

Pros:

  • Smaller change from current approach
  • Native SVG features (styling, accessibility)
  • Browser handles rendering optimization

Cons:

  • Still DOM-based with associated overhead
  • <use> has browser-specific quirks
  • May not solve fundamental remounting issue

Option 4: Hybrid approach

Use WebGL for common indicators (rectangles, simple paths) and fall back to SVG for complex custom indicators.

Pros:

  • Best of both worlds
  • Graceful degradation for custom shapes
  • Incremental migration path

Cons:

  • More complex architecture
  • Two rendering paths to maintain

Option 5: Virtual list / windowing

Only mount indicator components for shapes near viewport center, lazy-mount others.

Pros:

  • Minimal change to existing architecture
  • Scales with viewport, not total shapes
  • Works with existing custom indicators

Cons:

  • Edge cases around viewport boundaries
  • Doesn't solve hover/selection re-render issue
  • Still many DOM nodes for dense canvases

Performance benchmarks needed

To properly evaluate these options, we should benchmark:

  1. Page switch latency with varying shape counts (50, 100, 500, 1000 shapes)
  2. Hover indicator change latency
  3. Selection change latency
  4. Memory usage for indicator DOM nodes
  5. Frame time during pan/zoom with all indicators visible

Recommendation

I'd recommend exploring Option 1 (WebGL) as the primary investigation, with Option 4 (Hybrid) as a fallback strategy. The existing WebGLManager in templates/shader/ provides a solid foundation for integration with tldraw's reactive system.

Key questions to answer:

  1. Can we efficiently batch heterogeneous indicator geometries?
  2. What's the fallback story for custom ShapeUtil indicators?
  3. How do we handle indicator interactions (if any)?
  4. Performance target: <16ms for page switch regardless of shape count

References

Metadata

Metadata

Assignees

Labels

performanceImprove performance of an existing feature

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions