Add timelinerenderer abstraction and implement WebGL renderer
diff --git a/ui/src/base/canvas2d_renderer.ts b/ui/src/base/canvas2d_renderer.ts
new file mode 100644
index 0000000..b118233
--- /dev/null
+++ b/ui/src/base/canvas2d_renderer.ts
@@ -0,0 +1,208 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Canvas 2D fallback implementation of Renderer for when WebGL is unavailable.
+// All transforms are applied via the canvas context's transform matrix
+// (translate/scale), so draw methods use coordinates directly.
+
+import {
+  Renderer,
+  RGBA,
+  Transform2D,
+  RECT_FLAG_HATCHED,
+  RECT_FLAG_FADEOUT,
+  MarkerRenderFunc,
+} from './renderer';
+
+export class Canvas2DRenderer implements Renderer {
+  private readonly ctx: CanvasRenderingContext2D;
+
+  constructor(ctx: CanvasRenderingContext2D) {
+    this.ctx = ctx;
+  }
+
+  pushTransform({
+    offsetX = 0,
+    offsetY = 0,
+    scaleX = 1,
+    scaleY = 1,
+  }: Partial<Transform2D>): Disposable {
+    const ctx = this.ctx;
+
+    ctx.save();
+    ctx.translate(offsetX, offsetY);
+    ctx.scale(scaleX, scaleY);
+
+    return {
+      [Symbol.dispose]: () => {
+        ctx.restore();
+      },
+    };
+  }
+
+  drawMarker(
+    x: number,
+    y: number,
+    w: number,
+    h: number,
+    color: RGBA,
+    render: MarkerRenderFunc,
+  ): void {
+    const ctx = this.ctx;
+    ctx.fillStyle = rgbaToString(color);
+    render(ctx, x - w / 2, y, w, h);
+  }
+
+  drawMarkers(
+    positions: Float32Array,
+    sizes: Float32Array,
+    colors: Uint8Array,
+    count: number,
+    render: MarkerRenderFunc,
+  ): void {
+    const ctx = this.ctx;
+
+    for (let i = 0; i < count; i++) {
+      const x = positions[i * 2];
+      const y = positions[i * 2 + 1];
+      const w = sizes[i * 2];
+      const h = sizes[i * 2 + 1];
+
+      const r = colors[i * 4];
+      const g = colors[i * 4 + 1];
+      const b = colors[i * 4 + 2];
+      const a = colors[i * 4 + 3] / 255;
+
+      ctx.fillStyle = `rgba(${r},${g},${b},${a})`;
+      render(ctx, x - w / 2, y, w, h);
+    }
+  }
+
+  drawRect(
+    left: number,
+    top: number,
+    right: number,
+    bottom: number,
+    color: RGBA,
+    flags = 0,
+  ): void {
+    const ctx = this.ctx;
+    const w = right - left;
+    const h = bottom - top;
+
+    if (flags & RECT_FLAG_FADEOUT) {
+      const gradient = ctx.createLinearGradient(left, 0, right, 0);
+      gradient.addColorStop(0, rgbaToString(color));
+      gradient.addColorStop(1, rgbaToString({...color, a: 0}));
+      ctx.fillStyle = gradient;
+    } else {
+      ctx.fillStyle = rgbaToString(color);
+    }
+    ctx.fillRect(left, top, w, h);
+
+    if (flags & RECT_FLAG_HATCHED && w >= 5) {
+      ctx.fillStyle = getHatchedPattern(ctx);
+      ctx.fillRect(left, top, w, h);
+    }
+  }
+
+  drawRects(
+    topLeft: Float32Array,
+    bottomRight: Float32Array,
+    colors: Uint8Array,
+    count: number,
+    flags?: Uint8Array,
+  ): void {
+    const ctx = this.ctx;
+
+    for (let i = 0; i < count; i++) {
+      const rectFlags = flags?.[i] ?? 0;
+
+      const left = topLeft[i * 2];
+      const top = topLeft[i * 2 + 1];
+      const right = bottomRight[i * 2];
+      const bottom = bottomRight[i * 2 + 1];
+      const w = right - left;
+      const h = bottom - top;
+
+      const r = colors[i * 4];
+      const g = colors[i * 4 + 1];
+      const b = colors[i * 4 + 2];
+      const a = colors[i * 4 + 3] / 255;
+
+      if (rectFlags & RECT_FLAG_FADEOUT) {
+        const gradient = ctx.createLinearGradient(left, 0, right, 0);
+        gradient.addColorStop(0, `rgba(${r},${g},${b},${a})`);
+        gradient.addColorStop(1, `rgba(${r},${g},${b},0)`);
+        ctx.fillStyle = gradient;
+      } else {
+        ctx.fillStyle = `rgba(${r},${g},${b},${a})`;
+      }
+      ctx.fillRect(left, top, w, h);
+
+      if (rectFlags & RECT_FLAG_HATCHED && w >= 5) {
+        ctx.fillStyle = getHatchedPattern(ctx);
+        ctx.fillRect(left, top, w, h);
+      }
+    }
+  }
+
+  raw(fn: (ctx: CanvasRenderingContext2D) => void): void {
+    fn(this.ctx);
+  }
+
+  flush(): void {
+    // No-op for Canvas 2D - drawing is immediate
+  }
+
+  clip(x: number, y: number, w: number, h: number): Disposable {
+    const ctx = this.ctx;
+    ctx.save();
+    ctx.beginPath();
+    ctx.rect(x, y, w, h);
+    ctx.clip();
+    return {
+      [Symbol.dispose]: () => {
+        ctx.restore();
+      },
+    };
+  }
+}
+
+function rgbaToString(color: RGBA): string {
+  return `rgba(${color.r},${color.g},${color.b},${color.a / 255})`;
+}
+
+// Creates a diagonal hatched pattern for distinguishing slices with real-time
+// priorities. The pattern is created once as an offscreen canvas and cached
+// on the main canvas context.
+function getHatchedPattern(ctx: CanvasRenderingContext2D): CanvasPattern {
+  const mctx = ctx as CanvasRenderingContext2D & {
+    sliceHatchedPattern?: CanvasPattern;
+  };
+  if (mctx.sliceHatchedPattern !== undefined) return mctx.sliceHatchedPattern;
+
+  const canvas = document.createElement('canvas');
+  const SIZE = 8;
+  canvas.width = canvas.height = SIZE;
+  const patternCtx = canvas.getContext('2d')!;
+  patternCtx.strokeStyle = 'rgba(255,255,255,0.3)';
+  patternCtx.beginPath();
+  patternCtx.lineWidth = 1;
+  patternCtx.moveTo(0, SIZE);
+  patternCtx.lineTo(SIZE, 0);
+  patternCtx.stroke();
+  mctx.sliceHatchedPattern = mctx.createPattern(canvas, 'repeat')!;
+  return mctx.sliceHatchedPattern;
+}
diff --git a/ui/src/base/color.ts b/ui/src/base/color.ts
index 9fa992f..d13a049 100644
--- a/ui/src/base/color.ts
+++ b/ui/src/base/color.ts
@@ -36,6 +36,14 @@
   readonly l: number; // 0-100
 }
 
+// RGB color with values in the range 0-255 for r, g, b and 0-1 for alpha.
+export interface RGBA {
+  readonly r: number; // 0-255
+  readonly g: number; // 0-255
+  readonly b: number; // 0-255
+  readonly a: number; // 0-1
+}
+
 // Defines an interface to an immutable color object, which can be defined in
 // any arbitrary format or color space and provides function to modify the color
 // and conversions to CSS compatible style strings.
@@ -45,9 +53,13 @@
 // Also, because these objects are immutable, it's expected that readonly
 // properties such as |cssString| are efficient, as they can be computed at
 // creation time, so they may be used in the hot path (render loop).
+
 export interface Color {
   readonly cssString: string;
 
+  // RGB values (0-255 for r, g, b; 0-255 for alpha).
+  readonly rgba: RGBA;
+
   // The perceived brightness of the color using a weighted average of the
   // r, g and b channels based on human perception.
   readonly perceivedBrightness: number;
@@ -134,6 +146,7 @@
 // Describes a color defined in standard HSL color space.
 export class HSLColor extends HSLColorBase<HSLColor> implements Color {
   readonly cssString: string;
+  readonly rgba: RGBA;
   readonly perceivedBrightness: number;
 
   // Values are in the range:
@@ -146,6 +159,7 @@
 
     const [r, g, b] = hslToRgb(...this.hsl);
 
+    this.rgba = {r, g, b, a: this.alpha ?? 1};
     this.perceivedBrightness = perceivedBrightness(r, g, b);
 
     if (this.alpha === undefined) {
@@ -164,6 +178,7 @@
 // See: https://www.hsluv.org/
 export class HSLuvColor extends HSLColorBase<HSLuvColor> implements Color {
   readonly cssString: string;
+  readonly rgba: RGBA;
   readonly perceivedBrightness: number;
 
   constructor(hsl: ColorTuple | HSL, alpha?: number) {
@@ -174,6 +189,7 @@
     const g = Math.floor(rgb[1] * 255);
     const b = Math.floor(rgb[2] * 255);
 
+    this.rgba = {r, g, b, a: this.alpha ?? 1};
     this.perceivedBrightness = perceivedBrightness(r, g, b);
 
     if (this.alpha === undefined) {
diff --git a/ui/src/base/renderer.ts b/ui/src/base/renderer.ts
new file mode 100644
index 0000000..a654f0c
--- /dev/null
+++ b/ui/src/base/renderer.ts
@@ -0,0 +1,115 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Common interfaces for canvas rendering, shared between WebGL and Canvas2D
+// implementations.
+
+// 2D transformation (offset + scale). Transforms compound when pushed:
+// - Offsets add: newOffset = currentOffset + transform.offset
+// - Scales multiply: newScale = currentScale * transform.scale
+// For time-to-pixel conversion, use scaleX as pixels-per-time-unit.
+export interface Transform2D {
+  offsetX: number; // Pixel offset in X
+  offsetY: number; // Pixel offset in Y
+  scaleX: number; // Scale factor for X (use as pxPerTime for time conversion)
+  scaleY: number; // Scale factor for Y
+}
+
+export interface RGBA {
+  r: number; // 0-255
+  g: number; // 0-255
+  b: number; // 0-255
+  a: number; // 0-255
+}
+
+// Flag bits for drawRect options
+export const RECT_FLAG_HATCHED = 1; // Draw diagonal crosshatch pattern
+export const RECT_FLAG_FADEOUT = 2; // Fade alpha from full to 0 across width
+
+export type MarkerRenderFunc = (
+  ctx: CanvasRenderingContext2D,
+  x: number,
+  y: number,
+  w: number,
+  h: number,
+) => void;
+
+// Interface for a general renderer with 2D primitive drawing capabilities which
+// can be implemented with different backends (e.g., WebGL, Canvas2D).
+export interface Renderer {
+  // Push a transform onto the stack. Offsets add, scales multiply.
+  // Returns a disposable that restores the previous transform when disposed.
+  // Use with `using`:
+  //   using _ = renderer.pushTransform({offsetX: 10, offsetY: 20, scaleX: 1, scaleY: 1});
+  pushTransform(transform: Partial<Transform2D>): Disposable;
+
+  // Set a clipping rectangle in pixels. All subsequent draws will be clipped
+  // to this region. Returns a disposable that restores the previous clip.
+  // Use with `using`:
+  //   using _ = renderer.clip(x, y, w, h);
+  clip(x: number, y: number, w: number, h: number): Disposable;
+
+  // Draw a single marker centered horizontally at the given position. A marker
+  // is a sprite/glyph with fixed size in pixels regardless of the current
+  // transform.
+  drawMarker(
+    x: number,
+    y: number,
+    w: number,
+    h: number,
+    color: RGBA,
+    render: MarkerRenderFunc,
+  ): void;
+
+  // Bulk draw markers centered horizontally at given positions.
+  // A billboard is a sprite with fixed pixel dimensions regardless of scale.
+  // x values are in time units, y values are in pixels.
+  // render: Canvas2D fallback function (ignored by WebGL which uses SDF).
+  drawMarkers(
+    positions: Float32Array,
+    sizes: Float32Array,
+    colors: Uint8Array,
+    count: number,
+    render: MarkerRenderFunc,
+  ): void;
+
+  // Draw a single rectangle.
+  // left/right are in time units, top/bottom are in pixels.
+  drawRect(
+    left: number,
+    top: number,
+    right: number,
+    bottom: number,
+    color: RGBA,
+    flags?: number,
+  ): void;
+
+  // Bulk draw rectangles.
+  // x values are in time units, y values are in pixels.
+  drawRects(
+    topLeft: Float32Array,
+    bottomRight: Float32Array,
+    colors: Uint8Array,
+    count: number,
+    flags?: Uint8Array,
+  ): void;
+
+  // Escape hatch to raw CanvasRenderingContext2D for custom drawing. Using this
+  // instead of drawing manually to the canvas makes sure than any pipelining is
+  // correctly flushed.
+  raw(fn: (ctx: CanvasRenderingContext2D) => void): void;
+
+  // Flush all pending draw calls to the GPU.
+  flush(): void;
+}
diff --git a/ui/src/base/sdf.ts b/ui/src/base/sdf.ts
new file mode 100644
index 0000000..e57e265
--- /dev/null
+++ b/ui/src/base/sdf.ts
@@ -0,0 +1,152 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Signed Distance Field (SDF) generation for closed polygons.
+// SDFs enable resolution-independent rendering of shapes with smooth anti-aliasing.
+
+import {Point2D} from './geom';
+
+// Signed distance from point to line segment
+function sdSegment(p: Point2D, a: Point2D, b: Point2D): number {
+  const pax = p.x - a.x,
+    pay = p.y - a.y;
+  const bax = b.x - a.x,
+    bay = b.y - a.y;
+  const h = Math.max(
+    0,
+    Math.min(1, (pax * bax + pay * bay) / (bax * bax + bay * bay)),
+  );
+  const dx = pax - bax * h,
+    dy = pay - bay * h;
+  return Math.sqrt(dx * dx + dy * dy);
+}
+
+// Determine if point is inside a closed polygon using ray casting
+function isInsidePolygon(p: Point2D, vertices: readonly Point2D[]): boolean {
+  let inside = false;
+  const n = vertices.length;
+
+  for (let i = 0, j = n - 1; i < n; j = i++) {
+    const vi = vertices[i];
+    const vj = vertices[j];
+
+    // Ray casting: count edge crossings to the right of the point
+    if (
+      vi.y > p.y !== vj.y > p.y &&
+      p.x < ((vj.x - vi.x) * (p.y - vi.y)) / (vj.y - vi.y) + vi.x
+    ) {
+      inside = !inside;
+    }
+  }
+
+  return inside;
+}
+
+// Signed distance from point to closed polygon boundary
+// Negative inside, positive outside
+function sdPolygon(p: Point2D, vertices: readonly Point2D[]): number {
+  const n = vertices.length;
+  if (n < 3) return Infinity;
+
+  // Find minimum distance to any edge
+  let minDist = Infinity;
+  for (let i = 0; i < n; i++) {
+    const a = vertices[i];
+    const b = vertices[(i + 1) % n];
+    minDist = Math.min(minDist, sdSegment(p, a, b));
+  }
+
+  // Determine sign based on inside/outside
+  const inside = isInsidePolygon(p, vertices);
+  return inside ? -minDist : minDist;
+}
+
+/**
+ * Generate a signed distance field for a closed polygon.
+ *
+ * @param vertices - Array of vertices defining the polygon in normalized 0-1 coordinates
+ * @param size - Size of the output texture (size x size pixels)
+ * @param spread - How much distance (in normalized coords) maps to the 0-1 range.
+ *                 Larger values = more gradual falloff, smaller = sharper edges.
+ *                 Default 0.1 works well for most shapes.
+ * @returns Uint8Array of RGBA data (size * size * 4 bytes) where alpha channel
+ *          contains the SDF: 0.5 = edge, <0.5 = inside, >0.5 = outside
+ */
+export function generatePolygonSDF(
+  vertices: readonly Point2D[],
+  size: number,
+  spread: number = 0.1,
+): Uint8Array {
+  const data = new Uint8Array(size * size * 4);
+
+  for (let y = 0; y < size; y++) {
+    for (let x = 0; x < size; x++) {
+      // Map pixel to normalized coordinates (0-1), sampling at pixel centers
+      const p: Point2D = {
+        x: (x + 0.5) / size,
+        y: (y + 0.5) / size,
+      };
+
+      // Get signed distance (negative inside, positive outside)
+      const dist = sdPolygon(p, vertices);
+
+      // Normalize to 0-1 range: 0.5 = edge, <0.5 = inside, >0.5 = outside
+      const normalized = Math.max(0, Math.min(1, dist / spread + 0.5));
+
+      const idx = (y * size + x) * 4;
+      data[idx + 0] = 255; // R
+      data[idx + 1] = 255; // G
+      data[idx + 2] = 255; // B
+      data[idx + 3] = Math.round(normalized * 255); // A = SDF value
+    }
+  }
+
+  return data;
+}
+
+/**
+ * Create a WebGL texture from SDF data.
+ *
+ * @param gl - WebGL2 rendering context
+ * @param sdfData - RGBA data from generatePolygonSDF
+ * @param size - Size of the texture (must match the size used in generatePolygonSDF)
+ * @returns WebGL texture configured for SDF rendering
+ */
+export function createSDFTexture(
+  gl: WebGL2RenderingContext,
+  sdfData: Uint8Array,
+  size: number,
+): WebGLTexture {
+  const texture = gl.createTexture()!;
+  gl.bindTexture(gl.TEXTURE_2D, texture);
+  gl.texImage2D(
+    gl.TEXTURE_2D,
+    0,
+    gl.RGBA,
+    size,
+    size,
+    0,
+    gl.RGBA,
+    gl.UNSIGNED_BYTE,
+    sdfData,
+  );
+
+  // Linear filtering is essential for SDF - it interpolates distance values smoothly
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+
+  return texture;
+}
diff --git a/ui/src/base/webgl_renderer.ts b/ui/src/base/webgl_renderer.ts
new file mode 100644
index 0000000..f52d04e
--- /dev/null
+++ b/ui/src/base/webgl_renderer.ts
@@ -0,0 +1,955 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Simple WebGL rectangle renderer with an immediate-mode style API using
+// instanced rendering. Uses two separate pipelines:
+// 1. Rects pipeline - plain/hatched rectangles (no UVs, no texture)
+// 2. Sprites pipeline - SDF-based shapes like chevrons (with UVs, texture)
+
+import {createSDFTexture, generatePolygonSDF} from './sdf';
+import {Point2D} from './geom';
+import {
+  Renderer,
+  RGBA,
+  RECT_FLAG_HATCHED,
+  RECT_FLAG_FADEOUT,
+  Transform2D,
+  MarkerRenderFunc,
+} from './renderer';
+import {DisposableStack} from './disposable_stack';
+
+const MAX_RECTS = 10000; // Max rectangles per flush
+const MAX_SPRITES = 10000; // Max sprites per flush
+
+// Cached rect shader program (shared across all WebGLRenderer instances)
+let cachedRectProgram:
+  | {
+      gl: WebGL2RenderingContext;
+      program: WebGLProgram;
+      quadCornerLoc: number;
+      topLeftLoc: number;
+      bottomRightLoc: number;
+      colorLoc: number;
+      flagsLoc: number;
+      resolutionLoc: WebGLUniformLocation;
+      dprLoc: WebGLUniformLocation;
+      offsetLoc: WebGLUniformLocation;
+      scaleLoc: WebGLUniformLocation;
+    }
+  | undefined;
+
+// Cached sprite shader program (shared across all WebGLRenderer instances)
+let cachedSpriteProgram:
+  | {
+      gl: WebGL2RenderingContext;
+      program: WebGLProgram;
+      quadCornerLoc: number;
+      spritePosLoc: number;
+      spriteSizeLoc: number;
+      colorLoc: number;
+      uvLoc: number;
+      offsetLoc: number;
+      resolutionLoc: WebGLUniformLocation;
+      dprLoc: WebGLUniformLocation;
+      transformOffsetLoc: WebGLUniformLocation;
+      transformScaleLoc: WebGLUniformLocation;
+      sdfTexLoc: WebGLUniformLocation;
+    }
+  | undefined;
+
+// SDF texture size - doesn't need to be large since SDF interpolates well
+const SDF_TEX_SIZE = 64;
+const SDF_SPREAD = 0.1;
+
+// Chevron shape vertices in normalized 0-1 coordinates:
+//        A (0.5, 0) - top center
+//       / \
+//      /   \
+//     /     \
+//    /   C   \  - C (0.5, 0.7) inner notch
+//   /   / \   \
+//  D---     ---B - D (0, 1) and B (1, 1) bottom corners
+const CHEVRON_VERTICES: readonly Point2D[] = [
+  {x: 0.5, y: 0}, // A - top
+  {x: 1, y: 1}, // B - bottom right
+  {x: 0.5, y: 0.7}, // C - inner notch
+  {x: 0, y: 1}, // D - bottom left
+];
+
+// Cached SDF texture (shared across all renderers for same GL context)
+let cachedSDFTexture:
+  | {gl: WebGL2RenderingContext; texture: WebGLTexture}
+  | undefined;
+
+function ensureSDFTexture(gl: WebGL2RenderingContext): WebGLTexture {
+  if (cachedSDFTexture?.gl === gl) {
+    return cachedSDFTexture.texture;
+  }
+  const sdfData = generatePolygonSDF(
+    CHEVRON_VERTICES,
+    SDF_TEX_SIZE,
+    SDF_SPREAD,
+  );
+  const texture = createSDFTexture(gl, sdfData, SDF_TEX_SIZE);
+  cachedSDFTexture = {gl, texture};
+  return texture;
+}
+
+function ensureRectProgram(gl: WebGL2RenderingContext) {
+  if (cachedRectProgram?.gl === gl) {
+    return cachedRectProgram;
+  }
+
+  // Vertex shader for rects - no UVs needed
+  // Transform: pixelX = offset.x + x * scale.x, pixelY = offset.y + y * scale.y
+  const vsSource = `#version 300 es
+    // Per-vertex (static quad)
+    in vec2 a_quadCorner;
+
+    // Per-instance
+    in vec2 a_topLeft;      // x = left (time or pixels), y = top pixels
+    in vec2 a_bottomRight;  // x = right (time or pixels), y = bottom pixels
+    in vec4 a_color;
+    in uint a_flags;
+
+    out vec4 v_color;
+    out vec2 v_localPos;
+    flat out uint v_flags;
+    flat out float v_rectWidth;
+
+    uniform vec2 u_resolution;
+    uniform float u_dpr;
+    uniform vec2 u_offset;  // Pixel offset (offsetX, offsetY)
+    uniform vec2 u_scale;   // Scale (scaleX, scaleY)
+
+    void main() {
+      // Transform bounds: pixel = offset + input * scale
+      float pixelX0 = u_offset.x + a_topLeft.x * u_scale.x;
+      float pixelX1 = u_offset.x + a_bottomRight.x * u_scale.x;
+      float pixelY0 = u_offset.y + a_topLeft.y * u_scale.y;
+      float pixelY1 = u_offset.y + a_bottomRight.y * u_scale.y;
+
+      float pixelW = max(1.0, pixelX1 - pixelX0);
+      float pixelH = pixelY1 - pixelY0;
+
+      vec2 localPos = a_quadCorner * vec2(pixelW, pixelH) * u_dpr;
+      vec2 pixelPos = vec2(pixelX0, pixelY0) * u_dpr + localPos;
+      vec2 clipSpace = ((pixelPos / u_resolution) * 2.0) - 1.0;
+      gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
+
+      v_color = a_color;
+      v_localPos = localPos;
+      v_rectWidth = pixelW * u_dpr;
+      v_flags = a_flags;
+    }
+  `;
+
+  // Fragment shader for rects - solid color with optional hatching/fadeout
+  // Inject flag constants from TypeScript to keep them in sync
+  const fsSource = `#version 300 es
+    precision mediump float;
+    in vec4 v_color;
+    in vec2 v_localPos;
+    flat in uint v_flags;
+    flat in float v_rectWidth;
+    out vec4 fragColor;
+
+    const uint FLAG_HATCHED = ${RECT_FLAG_HATCHED}u;
+    const uint FLAG_FADEOUT = ${RECT_FLAG_FADEOUT}u;
+    const float HATCH_SPACING = 8.0;
+    const float HATCH_WIDTH = 1.0;
+    const float HATCH_MIN_WIDTH = 4.0;
+
+    void main() {
+      fragColor = v_color;
+
+      // Apply fadeout: alpha fades from full to 0 across the width
+      if ((v_flags & FLAG_FADEOUT) != 0u) {
+        float fadeProgress = v_localPos.x / v_rectWidth;
+        fragColor.a *= 1.0 - fadeProgress;
+      }
+
+      if ((v_flags & FLAG_HATCHED) != 0u && v_rectWidth >= HATCH_MIN_WIDTH) {
+        float diag = v_localPos.x + v_localPos.y;
+        float stripe = mod(diag, HATCH_SPACING);
+        if (stripe < HATCH_WIDTH) {
+          fragColor.rgb = mix(fragColor.rgb, vec3(1.0), 0.3);
+        }
+      }
+    }
+  `;
+
+  const vertexShader = gl.createShader(gl.VERTEX_SHADER)!;
+  gl.shaderSource(vertexShader, vsSource);
+  gl.compileShader(vertexShader);
+  if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
+    console.error(
+      'Rect vertex shader error:',
+      gl.getShaderInfoLog(vertexShader),
+    );
+  }
+
+  const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)!;
+  gl.shaderSource(fragmentShader, fsSource);
+  gl.compileShader(fragmentShader);
+  if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
+    console.error(
+      'Rect fragment shader error:',
+      gl.getShaderInfoLog(fragmentShader),
+    );
+  }
+
+  const program = gl.createProgram()!;
+  gl.attachShader(program, vertexShader);
+  gl.attachShader(program, fragmentShader);
+  gl.linkProgram(program);
+  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+    console.error('Rect program link error:', gl.getProgramInfoLog(program));
+  }
+
+  const quadCornerLoc = gl.getAttribLocation(program, 'a_quadCorner');
+  const topLeftLoc = gl.getAttribLocation(program, 'a_topLeft');
+  const bottomRightLoc = gl.getAttribLocation(program, 'a_bottomRight');
+  const colorLoc = gl.getAttribLocation(program, 'a_color');
+  const flagsLoc = gl.getAttribLocation(program, 'a_flags');
+  cachedRectProgram = {
+    gl,
+    program,
+    quadCornerLoc,
+    topLeftLoc,
+    bottomRightLoc,
+    colorLoc,
+    flagsLoc,
+    resolutionLoc: gl.getUniformLocation(program, 'u_resolution')!,
+    dprLoc: gl.getUniformLocation(program, 'u_dpr')!,
+    offsetLoc: gl.getUniformLocation(program, 'u_offset')!,
+    scaleLoc: gl.getUniformLocation(program, 'u_scale')!,
+  };
+
+  return cachedRectProgram;
+}
+
+function ensureSpriteProgram(gl: WebGL2RenderingContext) {
+  if (cachedSpriteProgram?.gl === gl) {
+    return cachedSpriteProgram;
+  }
+
+  // Vertex shader for sprites - includes UVs for texture sampling
+  // Transform: pixelX = offset.x + x * scale.x, pixelY = offset.y + y * scale.y
+  // Sprite is centered horizontally on the x position
+  const vsSource = `#version 300 es
+    // Per-vertex (static quad)
+    in vec2 a_quadCorner;
+
+    // Per-instance
+    in vec2 a_spritePos;    // x = position (time or pixels), y = pixels
+    in vec2 a_spriteSize;   // width/height in pixels
+    in vec4 a_color;
+    in vec4 a_uv;  // (u0, v0, u1, v1)
+    in vec2 a_offset;       // Per-instance offset (for batching across transforms)
+
+    out vec4 v_color;
+    out vec2 v_uv;
+
+    uniform vec2 u_resolution;
+    uniform float u_dpr;
+    uniform vec2 u_offset;  // Uniform offset (offsetX, offsetY)
+    uniform vec2 u_scale;   // Uniform scale (scaleX, scaleY)
+
+    void main() {
+      // Transform position: pixel = offset + input * scale
+      float pixelX = u_offset.x + a_spritePos.x * u_scale.x;
+      float pixelY = u_offset.y + a_spritePos.y * u_scale.y;
+
+      // Center horizontally: offset by -width/2
+      float centeredX = pixelX - a_spriteSize.x * 0.5;
+
+      vec2 localPos = a_quadCorner * a_spriteSize * u_dpr;
+      vec2 pixelPos = (vec2(centeredX, pixelY) + a_offset) * u_dpr + localPos;
+      vec2 clipSpace = ((pixelPos / u_resolution) * 2.0) - 1.0;
+      gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
+
+      v_color = a_color;
+      v_uv = mix(a_uv.xy, a_uv.zw, a_quadCorner);
+    }
+  `;
+
+  // Fragment shader for sprites - SDF texture sampling with anti-aliasing
+  const fsSource = `#version 300 es
+    precision mediump float;
+    in vec4 v_color;
+    in vec2 v_uv;
+    out vec4 fragColor;
+
+    uniform sampler2D u_sdfTex;
+
+    const float SDF_SPREAD = 0.1;
+
+    void main() {
+      float sdfValue = texture(u_sdfTex, v_uv).a;
+      float dist = (sdfValue - 0.5) * SDF_SPREAD;
+      float aa = fwidth(dist) * 0.75;
+      float alpha = 1.0 - smoothstep(-aa, aa, dist);
+
+      if (alpha < 0.01) {
+        discard;
+      }
+      fragColor = vec4(v_color.rgb, v_color.a * alpha);
+    }
+  `;
+
+  const vertexShader = gl.createShader(gl.VERTEX_SHADER)!;
+  gl.shaderSource(vertexShader, vsSource);
+  gl.compileShader(vertexShader);
+
+  const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)!;
+  gl.shaderSource(fragmentShader, fsSource);
+  gl.compileShader(fragmentShader);
+
+  const program = gl.createProgram()!;
+  gl.attachShader(program, vertexShader);
+  gl.attachShader(program, fragmentShader);
+  gl.linkProgram(program);
+
+  cachedSpriteProgram = {
+    gl,
+    program,
+    quadCornerLoc: gl.getAttribLocation(program, 'a_quadCorner'),
+    spritePosLoc: gl.getAttribLocation(program, 'a_spritePos'),
+    spriteSizeLoc: gl.getAttribLocation(program, 'a_spriteSize'),
+    colorLoc: gl.getAttribLocation(program, 'a_color'),
+    uvLoc: gl.getAttribLocation(program, 'a_uv'),
+    offsetLoc: gl.getAttribLocation(program, 'a_offset'),
+    resolutionLoc: gl.getUniformLocation(program, 'u_resolution')!,
+    dprLoc: gl.getUniformLocation(program, 'u_dpr')!,
+    transformOffsetLoc: gl.getUniformLocation(program, 'u_offset')!,
+    transformScaleLoc: gl.getUniformLocation(program, 'u_scale')!,
+    sdfTexLoc: gl.getUniformLocation(program, 'u_sdfTex')!,
+  };
+
+  return cachedSpriteProgram;
+}
+
+function composeTransforms(
+  a: Transform2D,
+  b: Partial<Transform2D>,
+): Transform2D {
+  const {offsetX = 0, offsetY = 0, scaleX = 1, scaleY = 1} = b;
+  return {
+    offsetX: a.offsetX + offsetX * a.scaleX,
+    offsetY: a.offsetY + offsetY * a.scaleY,
+    scaleX: a.scaleX * scaleX,
+    scaleY: a.scaleY * scaleY,
+  };
+}
+
+const Identity: Transform2D = {
+  offsetX: 0,
+  offsetY: 0,
+  scaleX: 1,
+  scaleY: 1,
+};
+
+export class WebGLRenderer implements Renderer {
+  private readonly c2d: CanvasRenderingContext2D;
+  private readonly gl: WebGL2RenderingContext;
+
+  // ===== Rects pipeline (no UVs) =====
+  private readonly topLeft: Float32Array;
+  private readonly bottomRight: Float32Array;
+  private readonly rectColors: Uint8Array;
+  private readonly rectFlags: Uint8Array;
+  private rectCount = 0;
+
+  // Rect WebGL buffers
+  private readonly rectQuadCornerBuffer: WebGLBuffer;
+  private readonly rectQuadIndexBuffer: WebGLBuffer;
+  private readonly topLeftBuffer: WebGLBuffer;
+  private readonly bottomRightBuffer: WebGLBuffer;
+  private readonly rectColorBuffer: WebGLBuffer;
+  private readonly rectFlagsBuffer: WebGLBuffer;
+
+  // ===== Sprites pipeline (with UVs for SDF) =====
+  private readonly spritePos: Float32Array;
+  private readonly spriteSize: Float32Array;
+  private readonly spriteColors: Uint8Array;
+  private readonly spriteUvs: Float32Array;
+  private readonly spriteOffsets: Float32Array;
+  private spriteCount = 0;
+
+  // Sprite WebGL buffers
+  private readonly spriteQuadCornerBuffer: WebGLBuffer;
+  private readonly spriteQuadIndexBuffer: WebGLBuffer;
+  private readonly spritePosBuffer: WebGLBuffer;
+  private readonly spriteSizeBuffer: WebGLBuffer;
+  private readonly spriteColorBuffer: WebGLBuffer;
+  private readonly spriteUvBuffer: WebGLBuffer;
+  private readonly spriteOffsetBuffer: WebGLBuffer;
+
+  // The current transformation applied to WebGL draws
+  private transform: Transform2D = Identity;
+
+  // initialOffset is applied to WebGL only (not 2D context) to compensate for
+  // offsets already applied to the 2D context externally.
+  constructor(c2d: CanvasRenderingContext2D, gl: WebGL2RenderingContext) {
+    this.c2d = c2d;
+    this.gl = gl;
+
+    // ===== Initialize Rects pipeline =====
+    this.topLeft = new Float32Array(MAX_RECTS * 2);
+    this.bottomRight = new Float32Array(MAX_RECTS * 2);
+    this.rectColors = new Uint8Array(MAX_RECTS * 4);
+    this.rectFlags = new Uint8Array(MAX_RECTS);
+
+    const quadCorners = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
+    const quadIndices = new Uint16Array([0, 1, 2, 2, 1, 3]);
+
+    this.rectQuadCornerBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.rectQuadCornerBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, quadCorners, gl.STATIC_DRAW);
+
+    this.rectQuadIndexBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.rectQuadIndexBuffer);
+    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, quadIndices, gl.STATIC_DRAW);
+
+    this.topLeftBuffer = gl.createBuffer()!;
+    this.bottomRightBuffer = gl.createBuffer()!;
+    this.rectColorBuffer = gl.createBuffer()!;
+    this.rectFlagsBuffer = gl.createBuffer()!;
+
+    // ===== Initialize Sprites pipeline =====
+    this.spritePos = new Float32Array(MAX_SPRITES * 2);
+    this.spriteSize = new Float32Array(MAX_SPRITES * 2);
+    this.spriteColors = new Uint8Array(MAX_SPRITES * 4);
+    this.spriteUvs = new Float32Array(MAX_SPRITES * 4);
+    this.spriteOffsets = new Float32Array(MAX_SPRITES * 2);
+
+    this.spriteQuadCornerBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.spriteQuadCornerBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, quadCorners, gl.STATIC_DRAW);
+
+    this.spriteQuadIndexBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.spriteQuadIndexBuffer);
+    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, quadIndices, gl.STATIC_DRAW);
+
+    this.spritePosBuffer = gl.createBuffer()!;
+    this.spriteSizeBuffer = gl.createBuffer()!;
+    this.spriteColorBuffer = gl.createBuffer()!;
+    this.spriteUvBuffer = gl.createBuffer()!;
+    this.spriteOffsetBuffer = gl.createBuffer()!;
+  }
+
+  pushTransform(transform: Partial<Transform2D>): Disposable {
+    const trash = new DisposableStack();
+    trash.use(this.pushWebGLTransform(transform));
+    trash.use(this.pushCanvas2DTransform(transform));
+    return trash;
+  }
+
+  // Apply a transform to the WebGL context only (not the Canvas2D context).
+  pushWebGLTransform(transform: Partial<Transform2D>): Disposable {
+    const previousTransform = this.transform;
+    this.transform = composeTransforms(this.transform, transform);
+    return {
+      [Symbol.dispose]: () => {
+        this.transform = previousTransform;
+      },
+    };
+  }
+
+  pushCanvas2DTransform({
+    offsetX = 0,
+    offsetY = 0,
+    scaleX = 1,
+    scaleY = 1,
+  }: Partial<Transform2D>): Disposable {
+    const ctx = this.c2d;
+    ctx.save();
+    ctx.translate(offsetX, offsetY);
+    ctx.scale(scaleX, scaleY);
+    return {
+      [Symbol.dispose]: () => {
+        ctx.restore();
+      },
+    };
+  }
+
+  // Draw a billboard (chevron) centered horizontally at the given position.
+  // x is in time units, y is in pixels.
+  // Uses WebGL sprites pipeline with SDF chevron texture.
+  drawMarker(
+    x: number,
+    y: number,
+    w: number,
+    h: number,
+    color: RGBA,
+    _render: MarkerRenderFunc,
+  ): void {
+    if (this.spriteCount >= MAX_SPRITES) {
+      this.flushSprites();
+    }
+
+    const i = this.spriteCount;
+
+    // Position: x is in time units (transformed by shader), y is in pixels
+    this.spritePos[i * 2] = x;
+    this.spritePos[i * 2 + 1] = y;
+
+    // Size in pixels
+    this.spriteSize[i * 2] = w;
+    this.spriteSize[i * 2 + 1] = h;
+
+    // Color (RGBA 0-255)
+    this.spriteColors[i * 4] = color.r;
+    this.spriteColors[i * 4 + 1] = color.g;
+    this.spriteColors[i * 4 + 2] = color.b;
+    this.spriteColors[i * 4 + 3] = color.a;
+
+    // UVs: full SDF texture (0,0) to (1,1)
+    this.spriteUvs[i * 4] = 0; // u0
+    this.spriteUvs[i * 4 + 1] = 0; // v0
+    this.spriteUvs[i * 4 + 2] = 1; // u1
+    this.spriteUvs[i * 4 + 3] = 1; // v1
+
+    // Per-instance offset (pixel offset is baked into uniform)
+    this.spriteOffsets[i * 2] = 0;
+    this.spriteOffsets[i * 2 + 1] = 0;
+
+    this.spriteCount++;
+  }
+
+  // Bulk draw billboards (chevrons) centered horizontally at given positions.
+  // positions: (x, y) pairs - x in time units, y in pixels
+  // sizes: (w, h) pairs in pixels
+  // colors: RGBA values (4 bytes per billboard)
+  // render: Canvas2D fallback render function (ignored - WebGL uses SDF)
+  drawMarkers(
+    positions: Float32Array,
+    sizes: Float32Array,
+    colors: Uint8Array,
+    count: number,
+    _render: MarkerRenderFunc,
+  ): void {
+    let remaining = count;
+    let srcOffset = 0;
+
+    while (remaining > 0) {
+      const available = MAX_SPRITES - this.spriteCount;
+      if (available === 0) {
+        this.flushSprites();
+        continue;
+      }
+
+      const batch = Math.min(remaining, available);
+      const dstOffset = this.spriteCount;
+
+      // Copy positions
+      this.spritePos.set(
+        positions.subarray(srcOffset * 2, (srcOffset + batch) * 2),
+        dstOffset * 2,
+      );
+
+      // Copy sizes
+      this.spriteSize.set(
+        sizes.subarray(srcOffset * 2, (srcOffset + batch) * 2),
+        dstOffset * 2,
+      );
+
+      // Copy colors
+      this.spriteColors.set(
+        colors.subarray(srcOffset * 4, (srcOffset + batch) * 4),
+        dstOffset * 4,
+      );
+
+      // Fill UVs and offsets for each sprite in this batch
+      for (let i = 0; i < batch; i++) {
+        const idx = dstOffset + i;
+        // UVs: full SDF texture (0,0) to (1,1)
+        this.spriteUvs[idx * 4] = 0;
+        this.spriteUvs[idx * 4 + 1] = 0;
+        this.spriteUvs[idx * 4 + 2] = 1;
+        this.spriteUvs[idx * 4 + 3] = 1;
+        // Per-instance offset (pixel offset is baked into uniform)
+        this.spriteOffsets[idx * 2] = 0;
+        this.spriteOffsets[idx * 2 + 1] = 0;
+      }
+
+      this.spriteCount += batch;
+      srcOffset += batch;
+      remaining -= batch;
+    }
+  }
+
+  // Draw single rectangle.
+  // topLeft/bottomRight x values are in time units (relative to transform origin).
+  // topLeft/bottomRight y values are in pixels.
+  // Use +Infinity for right to extend to the canvas edge (incomplete slices).
+  drawRect(
+    left: number,
+    top: number,
+    right: number,
+    bottom: number,
+    color: RGBA,
+    flags = 0,
+  ): void {
+    if (this.rectCount >= MAX_RECTS) {
+      this.flushRects();
+    }
+
+    // Fill in the buffers (+Infinity is handled by GPU clipping)
+    const i = this.rectCount;
+
+    this.topLeft[i * 2 + 0] = left; // left time
+    this.topLeft[i * 2 + 1] = top; // top pixels
+
+    this.bottomRight[i * 2 + 0] = right; // right time (+Infinity OK)
+    this.bottomRight[i * 2 + 1] = bottom; // bottom pixels
+
+    this.rectColors[i * 4 + 0] = color.r;
+    this.rectColors[i * 4 + 1] = color.g;
+    this.rectColors[i * 4 + 2] = color.b;
+    this.rectColors[i * 4 + 3] = color.a;
+
+    this.rectFlags[i] = flags;
+
+    this.rectCount++;
+  }
+
+  // Bulk draw rectangles.
+  // topLeft/bottomRight x values are in time units (relative to transform origin).
+  // topLeft/bottomRight y values are in pixels.
+  // Use +Infinity for right (x in bottomRight) to extend to the canvas edge.
+  drawRects(
+    topLeft: Float32Array,
+    bottomRight: Float32Array,
+    colors: Uint8Array,
+    count: number,
+    flags?: Uint8Array,
+  ): void {
+    let remaining = count;
+    let srcOffset = 0;
+
+    while (remaining > 0) {
+      const available = MAX_RECTS - this.rectCount;
+      if (available === 0) {
+        this.flushRects();
+        continue;
+      }
+
+      const batch = Math.min(remaining, available);
+      const dstOffset = this.rectCount;
+
+      // +Infinity values are handled by GPU clipping
+      this.topLeft.set(
+        topLeft.subarray(srcOffset * 2, (srcOffset + batch) * 2),
+        dstOffset * 2,
+      );
+
+      this.bottomRight.set(
+        bottomRight.subarray(srcOffset * 2, (srcOffset + batch) * 2),
+        dstOffset * 2,
+      );
+
+      this.rectColors.set(
+        colors.subarray(srcOffset * 4, (srcOffset + batch) * 4),
+        dstOffset * 4,
+      );
+
+      if (flags) {
+        this.rectFlags.set(
+          flags.subarray(srcOffset, srcOffset + batch),
+          dstOffset,
+        );
+      } else {
+        this.rectFlags.fill(0, dstOffset, dstOffset + batch);
+      }
+
+      this.rectCount += batch;
+      srcOffset += batch;
+      remaining -= batch;
+    }
+  }
+
+  private flushRects(): void {
+    if (this.rectCount === 0) return;
+
+    const gl = this.gl;
+    const {
+      program,
+      quadCornerLoc,
+      topLeftLoc,
+      bottomRightLoc,
+      colorLoc,
+      flagsLoc,
+      offsetLoc,
+      scaleLoc,
+      resolutionLoc,
+      dprLoc,
+    } = ensureRectProgram(gl);
+
+    gl.useProgram(program);
+    gl.enable(gl.BLEND);
+    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+
+    const dpr = window.devicePixelRatio;
+    gl.uniform2f(resolutionLoc, gl.canvas.width, gl.canvas.height);
+    gl.uniform1f(dprLoc, dpr);
+
+    // Push transform uniforms
+    gl.uniform2f(offsetLoc, this.transform.offsetX, this.transform.offsetY);
+    gl.uniform2f(scaleLoc, this.transform.scaleX, this.transform.scaleY);
+
+    // Static quad corners
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.rectQuadCornerBuffer);
+    gl.enableVertexAttribArray(quadCornerLoc);
+    gl.vertexAttribPointer(quadCornerLoc, 2, gl.FLOAT, false, 0, 0);
+    gl.vertexAttribDivisor(quadCornerLoc, 0);
+
+    // Per-instance: top-left bounds
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.topLeftBuffer);
+    gl.bufferData(
+      gl.ARRAY_BUFFER,
+      this.topLeft.subarray(0, this.rectCount * 2),
+      gl.DYNAMIC_DRAW,
+    );
+    gl.enableVertexAttribArray(topLeftLoc);
+    gl.vertexAttribPointer(topLeftLoc, 2, gl.FLOAT, false, 0, 0);
+    gl.vertexAttribDivisor(topLeftLoc, 1);
+
+    // Per-instance: bottom-right bounds
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.bottomRightBuffer);
+    gl.bufferData(
+      gl.ARRAY_BUFFER,
+      this.bottomRight.subarray(0, this.rectCount * 2),
+      gl.DYNAMIC_DRAW,
+    );
+    gl.enableVertexAttribArray(bottomRightLoc);
+    gl.vertexAttribPointer(bottomRightLoc, 2, gl.FLOAT, false, 0, 0);
+    gl.vertexAttribDivisor(bottomRightLoc, 1);
+
+    // Per-instance: color
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.rectColorBuffer);
+    gl.bufferData(
+      gl.ARRAY_BUFFER,
+      this.rectColors.subarray(0, this.rectCount * 4),
+      gl.DYNAMIC_DRAW,
+    );
+    gl.enableVertexAttribArray(colorLoc);
+    gl.vertexAttribPointer(colorLoc, 4, gl.UNSIGNED_BYTE, true, 0, 0);
+    gl.vertexAttribDivisor(colorLoc, 1);
+
+    // Per-instance: flags
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.rectFlagsBuffer);
+    gl.bufferData(
+      gl.ARRAY_BUFFER,
+      this.rectFlags.subarray(0, this.rectCount),
+      gl.DYNAMIC_DRAW,
+    );
+    gl.enableVertexAttribArray(flagsLoc);
+    gl.vertexAttribIPointer(flagsLoc, 1, gl.UNSIGNED_BYTE, 0, 0);
+    gl.vertexAttribDivisor(flagsLoc, 1);
+
+    // Draw all rectangles
+    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.rectQuadIndexBuffer);
+    gl.drawElementsInstanced(
+      gl.TRIANGLES,
+      6,
+      gl.UNSIGNED_SHORT,
+      0,
+      this.rectCount,
+    );
+
+    // Reset divisors
+    gl.vertexAttribDivisor(topLeftLoc, 0);
+    gl.vertexAttribDivisor(bottomRightLoc, 0);
+    gl.vertexAttribDivisor(colorLoc, 0);
+    gl.vertexAttribDivisor(flagsLoc, 0);
+
+    this.rectCount = 0;
+  }
+
+  private flushSprites(): void {
+    if (this.spriteCount === 0) return;
+
+    const gl = this.gl;
+    const {
+      program,
+      quadCornerLoc,
+      spritePosLoc,
+      spriteSizeLoc,
+      colorLoc,
+      uvLoc,
+      offsetLoc,
+      resolutionLoc,
+      dprLoc,
+      transformOffsetLoc,
+      transformScaleLoc,
+      sdfTexLoc,
+    } = ensureSpriteProgram(gl);
+
+    gl.useProgram(program);
+    gl.enable(gl.BLEND);
+    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+
+    const dpr = window.devicePixelRatio;
+    gl.uniform2f(resolutionLoc, gl.canvas.width, gl.canvas.height);
+    gl.uniform1f(dprLoc, dpr);
+
+    // Combine transforms: initial offset + pixel offset + time transform
+    // Push transform uniforms
+    gl.uniform2f(
+      transformOffsetLoc,
+      this.transform.offsetX,
+      this.transform.offsetY,
+    );
+    gl.uniform2f(
+      transformScaleLoc,
+      this.transform.scaleX,
+      this.transform.scaleY,
+    );
+
+    // Bind SDF texture
+    gl.activeTexture(gl.TEXTURE0);
+    gl.bindTexture(gl.TEXTURE_2D, ensureSDFTexture(gl));
+    gl.uniform1i(sdfTexLoc, 0);
+
+    // Static quad corners
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.spriteQuadCornerBuffer);
+    gl.enableVertexAttribArray(quadCornerLoc);
+    gl.vertexAttribPointer(quadCornerLoc, 2, gl.FLOAT, false, 0, 0);
+    gl.vertexAttribDivisor(quadCornerLoc, 0);
+
+    // Per-instance: sprite position
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.spritePosBuffer);
+    gl.bufferData(
+      gl.ARRAY_BUFFER,
+      this.spritePos.subarray(0, this.spriteCount * 2),
+      gl.DYNAMIC_DRAW,
+    );
+    gl.enableVertexAttribArray(spritePosLoc);
+    gl.vertexAttribPointer(spritePosLoc, 2, gl.FLOAT, false, 0, 0);
+    gl.vertexAttribDivisor(spritePosLoc, 1);
+
+    // Per-instance: sprite size
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.spriteSizeBuffer);
+    gl.bufferData(
+      gl.ARRAY_BUFFER,
+      this.spriteSize.subarray(0, this.spriteCount * 2),
+      gl.DYNAMIC_DRAW,
+    );
+    gl.enableVertexAttribArray(spriteSizeLoc);
+    gl.vertexAttribPointer(spriteSizeLoc, 2, gl.FLOAT, false, 0, 0);
+    gl.vertexAttribDivisor(spriteSizeLoc, 1);
+
+    // Per-instance: color (Uint8Array normalized to 0-1)
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.spriteColorBuffer);
+    gl.bufferData(
+      gl.ARRAY_BUFFER,
+      this.spriteColors.subarray(0, this.spriteCount * 4),
+      gl.DYNAMIC_DRAW,
+    );
+    gl.enableVertexAttribArray(colorLoc);
+    gl.vertexAttribPointer(colorLoc, 4, gl.UNSIGNED_BYTE, true, 0, 0);
+    gl.vertexAttribDivisor(colorLoc, 1);
+
+    // Per-instance: UVs
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.spriteUvBuffer);
+    gl.bufferData(
+      gl.ARRAY_BUFFER,
+      this.spriteUvs.subarray(0, this.spriteCount * 4),
+      gl.DYNAMIC_DRAW,
+    );
+    gl.enableVertexAttribArray(uvLoc);
+    gl.vertexAttribPointer(uvLoc, 4, gl.FLOAT, false, 0, 0);
+    gl.vertexAttribDivisor(uvLoc, 1);
+
+    // Per-instance: offset
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.spriteOffsetBuffer);
+    gl.bufferData(
+      gl.ARRAY_BUFFER,
+      this.spriteOffsets.subarray(0, this.spriteCount * 2),
+      gl.DYNAMIC_DRAW,
+    );
+    gl.enableVertexAttribArray(offsetLoc);
+    gl.vertexAttribPointer(offsetLoc, 2, gl.FLOAT, false, 0, 0);
+    gl.vertexAttribDivisor(offsetLoc, 1);
+
+    // Draw all sprites
+    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.spriteQuadIndexBuffer);
+    gl.drawElementsInstanced(
+      gl.TRIANGLES,
+      6,
+      gl.UNSIGNED_SHORT,
+      0,
+      this.spriteCount,
+    );
+
+    // Reset divisors
+    gl.vertexAttribDivisor(spritePosLoc, 0);
+    gl.vertexAttribDivisor(spriteSizeLoc, 0);
+    gl.vertexAttribDivisor(colorLoc, 0);
+    gl.vertexAttribDivisor(uvLoc, 0);
+    gl.vertexAttribDivisor(offsetLoc, 0);
+
+    this.spriteCount = 0;
+  }
+
+  raw(fn: (ctx: CanvasRenderingContext2D) => void): void {
+    fn(this.c2d);
+  }
+
+  flush(): void {
+    this.flushRects();
+    this.flushSprites();
+  }
+
+  clip(x: number, y: number, w: number, h: number): Disposable {
+    const gl = this.gl;
+    const ctx = this.c2d;
+    const dpr = window.devicePixelRatio;
+
+    // Flush pending draws before changing scissor
+    this.flush();
+
+    // Transform clip coordinates from virtual canvas space to physical canvas
+    // space using the current transform offset.
+    const physX = x + this.transform.offsetX;
+    const physY = y + this.transform.offsetY;
+
+    // Enable scissor and set new clip region
+    // WebGL scissor uses bottom-left origin, so flip Y
+    gl.enable(gl.SCISSOR_TEST);
+    const canvasHeight = gl.canvas.height;
+    gl.scissor(
+      Math.round(physX * dpr),
+      Math.round(canvasHeight - (physY + h) * dpr),
+      Math.round(w * dpr),
+      Math.round(h * dpr),
+    );
+
+    // Also clip Canvas2D context (already has transform applied via ctx.translate)
+    ctx.save();
+    ctx.beginPath();
+    ctx.rect(x, y, w, h);
+    ctx.clip();
+
+    return {
+      [Symbol.dispose]: () => {
+        this.flush();
+        ctx.restore();
+        gl.disable(gl.SCISSOR_TEST);
+      },
+    };
+  }
+}
diff --git a/ui/src/components/tracks/base_slice_track.ts b/ui/src/components/tracks/base_slice_track.ts
index 9b825a7..6c3aa15 100644
--- a/ui/src/components/tracks/base_slice_track.ts
+++ b/ui/src/components/tracks/base_slice_track.ts
@@ -13,7 +13,6 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {drawIncompleteSlice} from '../../base/canvas_utils';
 import {colorCompare} from '../../base/color';
 import {Monitor} from '../../base/monitor';
 import {AsyncDisposableStack} from '../../base/disposable_stack';
@@ -39,6 +38,7 @@
 import {checkerboardExcept} from '../checkerboard';
 import {UNEXPECTED_PINK} from '../colorizer';
 import {BUCKETS_PER_PIXEL, CacheKey} from './timeline_cache';
+import {RECT_FLAG_FADEOUT} from '../../base/renderer';
 
 // The common class that underpins all tracks drawing slices.
 
@@ -433,6 +433,7 @@
     visibleWindow,
     timescale,
     colors,
+    renderer,
   }: TrackRenderContext): void {
     // TODO(hjd): fonts and colors should come from the CSS and not hardcoded
     // here.
@@ -542,21 +543,37 @@
         lastColor = colorString;
         ctx.fillStyle = colorString;
       }
+
+      const rgba = {
+        r: color.rgba.r,
+        g: color.rgba.g,
+        b: color.rgba.b,
+        a: Math.round(color.rgba.a * 255),
+      };
+
       const y = padding + slice.depth * (sliceHeight + rowSpacing);
       if (slice.flags & SLICE_FLAGS_INSTANT) {
-        this.drawChevron(ctx, slice.x, y, sliceHeight);
+        renderer.drawMarker(
+          slice.x,
+          y,
+          this.instantWidthPx,
+          sliceHeight,
+          rgba,
+          (ctx, x, y, w, h) =>
+            this.drawChevron(ctx, x + (w - CHEVRON_WIDTH_PX) / 2, y, h),
+        );
       } else if (slice.flags & SLICE_FLAGS_INCOMPLETE) {
         const w = CROP_INCOMPLETE_SLICE_FLAG.get()
           ? slice.w
           : Math.max(slice.w - 2, 2);
-        drawIncompleteSlice(
-          ctx,
+
+        renderer.drawRect(
           slice.x,
           y,
-          w,
-          sliceHeight,
-          color,
-          !CROP_INCOMPLETE_SLICE_FLAG.get(),
+          slice.x + w,
+          y + sliceHeight,
+          rgba,
+          RECT_FLAG_FADEOUT,
         );
       } else {
         const w = Math.max(
@@ -565,7 +582,8 @@
             ? SLICE_MIN_WIDTH_FADED_PX
             : SLICE_MIN_WIDTH_PX,
         );
-        ctx.fillRect(slice.x, y, w, sliceHeight);
+
+        renderer.drawRect(slice.x, y, slice.x + w, y + sliceHeight, rgba);
       }
     }
 
diff --git a/ui/src/core/track_manager_unittest.ts b/ui/src/core/track_manager_unittest.ts
deleted file mode 100644
index d063ef7..0000000
--- a/ui/src/core/track_manager_unittest.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {assertExists} from '../base/logging';
-import {Duration} from '../base/time';
-import {TimeScale} from '../base/time_scale';
-import {Track, TrackRenderContext} from '../public/track';
-import {HighPrecisionTime} from '../base/high_precision_time';
-import {HighPrecisionTimeSpan} from '../base/high_precision_time_span';
-import {TrackManagerImpl} from '../core/track_manager';
-import {TrackNode} from '../public/workspace';
-
-function makeMockTrack() {
-  return {
-    onCreate: jest.fn(),
-    onUpdate: jest.fn(),
-    onDestroy: jest.fn(),
-
-    render: jest.fn(),
-    onFullRedraw: jest.fn(),
-    getSliceVerticalBounds: jest.fn(),
-    getHeight: jest.fn(),
-    getTrackShellButtons: jest.fn(),
-    onMouseMove: jest.fn(),
-    onMouseClick: jest.fn(),
-    onMouseOut: jest.fn(),
-  };
-}
-
-async function settle() {
-  await new Promise((r) => setTimeout(r, 0));
-}
-
-let mockTrack: ReturnType<typeof makeMockTrack>;
-let td: Track;
-let trackManager: TrackManagerImpl;
-const visibleWindow = new HighPrecisionTimeSpan(HighPrecisionTime.ZERO, 0);
-const dummyTrackNode = new TrackNode({name: 'test', uri: 'foo'});
-const dummyCtx: TrackRenderContext = {
-  trackUri: 'foo',
-  trackNode: dummyTrackNode,
-  ctx: new CanvasRenderingContext2D(),
-  size: {width: 123, height: 123},
-  visibleWindow,
-  resolution: Duration.ZERO,
-  timescale: new TimeScale(visibleWindow, {left: 0, right: 0}),
-  colors: {
-    COLOR_BORDER: 'hotpink',
-    COLOR_BORDER_SECONDARY: 'hotpink',
-    COLOR_BACKGROUND_SECONDARY: 'hotpink',
-    COLOR_ACCENT: 'hotpink',
-    COLOR_BACKGROUND: 'hotpink',
-    COLOR_TEXT: 'hotpink',
-    COLOR_TEXT_MUTED: 'hotpink',
-    COLOR_NEUTRAL: 'hotpink',
-    COLOR_TIMELINE_OVERLAY: 'hotpink',
-  },
-};
-
-beforeEach(() => {
-  mockTrack = makeMockTrack();
-  td = {
-    uri: 'test',
-    renderer: mockTrack,
-  };
-  trackManager = new TrackManagerImpl();
-  trackManager.registerTrack(td);
-});
-
-describe('TrackManager', () => {
-  it('calls track lifecycle hooks', async () => {
-    const entry = assertExists(trackManager.getTrackFSM(td.uri));
-
-    entry.render(dummyCtx);
-    await settle();
-    expect(mockTrack.onCreate).toHaveBeenCalledTimes(1);
-    expect(mockTrack.onUpdate).toHaveBeenCalledTimes(1);
-
-    // Double flush should destroy all tracks
-    trackManager.flushOldTracks();
-    trackManager.flushOldTracks();
-    await settle();
-    expect(mockTrack.onDestroy).toHaveBeenCalledTimes(1);
-  });
-
-  it('calls onCrate lazily', async () => {
-    // Check we wait until the first call to render before calling onCreate
-    const entry = assertExists(trackManager.getTrackFSM(td.uri));
-    await settle();
-    expect(mockTrack.onCreate).not.toHaveBeenCalled();
-
-    entry.render(dummyCtx);
-    await settle();
-    expect(mockTrack.onCreate).toHaveBeenCalledTimes(1);
-  });
-
-  it('reuses tracks', async () => {
-    const first = assertExists(trackManager.getTrackFSM(td.uri));
-    trackManager.flushOldTracks();
-    first.render(dummyCtx);
-    await settle();
-
-    const second = assertExists(trackManager.getTrackFSM(td.uri));
-    trackManager.flushOldTracks();
-    second.render(dummyCtx);
-    await settle();
-
-    expect(first).toBe(second);
-    // Ensure onCreate called only once
-    expect(mockTrack.onCreate).toHaveBeenCalledTimes(1);
-  });
-
-  it('destroys tracks when they are not resolved for one cycle', async () => {
-    const entry = assertExists(trackManager.getTrackFSM(td.uri));
-    entry.render(dummyCtx);
-
-    // Double flush should destroy all tracks
-    trackManager.flushOldTracks();
-    trackManager.flushOldTracks();
-
-    await settle();
-
-    expect(mockTrack.onDestroy).toHaveBeenCalledTimes(1);
-  });
-
-  it('contains crash inside onCreate()', async () => {
-    const entry = assertExists(trackManager.getTrackFSM(td.uri));
-    const e = new Error();
-
-    // Mock crash inside onCreate
-    mockTrack.onCreate.mockImplementationOnce(() => {
-      throw e;
-    });
-
-    entry.render(dummyCtx);
-    await settle();
-
-    expect(mockTrack.onCreate).toHaveBeenCalledTimes(1);
-    expect(mockTrack.onUpdate).not.toHaveBeenCalled();
-    expect(entry.getError()).toBe(e);
-  });
-
-  it('contains crash inside onUpdate()', async () => {
-    const entry = assertExists(trackManager.getTrackFSM(td.uri));
-    const e = new Error();
-
-    // Mock crash inside onUpdate
-    mockTrack.onUpdate.mockImplementationOnce(() => {
-      throw e;
-    });
-
-    entry.render(dummyCtx);
-    await settle();
-
-    expect(mockTrack.onCreate).toHaveBeenCalledTimes(1);
-    expect(mockTrack.onUpdate).toHaveBeenCalledTimes(1);
-    expect(entry.getError()).toBe(e);
-  });
-
-  it('handles dispose after crash', async () => {
-    const entry = assertExists(trackManager.getTrackFSM(td.uri));
-    const e = new Error();
-
-    // Mock crash inside onUpdate
-    mockTrack.onUpdate.mockImplementationOnce(() => {
-      throw e;
-    });
-
-    entry.render(dummyCtx);
-    await settle();
-
-    // Ensure we don't crash during the next render cycle
-    entry.render(dummyCtx);
-    await settle();
-  });
-});
diff --git a/ui/src/frontend/timeline_page/track_tree_view.ts b/ui/src/frontend/timeline_page/track_tree_view.ts
index 3e48bb6..4fd7737 100644
--- a/ui/src/frontend/timeline_page/track_tree_view.ts
+++ b/ui/src/frontend/timeline_page/track_tree_view.ts
@@ -48,6 +48,7 @@
 import {TrackNode} from '../../public/workspace';
 import {SnapPoint} from '../../public/track';
 import {VirtualOverlayCanvas} from '../../widgets/virtual_overlay_canvas';
+import {WebGLRenderer} from '../../base/webgl_renderer';
 import {
   COLOR_ACCENT,
   COLOR_BACKGROUND,
@@ -75,6 +76,28 @@
 import {CursorTooltip} from '../../widgets/cursor_tooltip';
 import {CanvasColors} from '../../public/canvas_colors';
 import {Icons} from '../../base/semantic_icons';
+import {Canvas2DRenderer} from '../../base/canvas2d_renderer';
+import {Renderer} from '../../base/renderer';
+
+// Creates a CanvasRenderer with the appropriate base offset.
+// WebGL needs the canvas offset applied via transform since it doesn't use
+// ctx.translate like Canvas2D. Canvas2D already has the offset applied.
+function createCanvasRenderer(
+  ctx: CanvasRenderingContext2D,
+  webglCtx: WebGL2RenderingContext | undefined,
+  initialOffsetX: number,
+  initialOffsetY: number,
+): Renderer {
+  if (webglCtx && WEBGL_RENDERING.get()) {
+    const renderer = new WebGLRenderer(ctx, webglCtx);
+    renderer.pushWebGLTransform({
+      offsetX: initialOffsetX,
+      offsetY: initialOffsetY,
+    });
+    return renderer;
+  }
+  return new Canvas2DRenderer(ctx);
+}
 
 const VIRTUAL_TRACK_SCROLLING = featureFlags.register({
   id: 'virtualTrackScrolling',
@@ -84,6 +107,14 @@
   defaultValue: true,
 });
 
+const WEBGL_RENDERING = featureFlags.register({
+  id: 'webglRendering',
+  name: 'WebGL rendering',
+  description: `Use WebGL for rendering track rectangles. Falls back to
+    Canvas 2D when disabled or unavailable.`,
+  defaultValue: false,
+});
+
 // Snap-to-boundaries feature constants
 const SNAP_THRESHOLD_PX = 15;
 const SNAP_ENABLED_DEFAULT = true;
@@ -293,13 +324,15 @@
         className: classNames(className, 'pf-track-tree'),
         overflowY: 'auto',
         overflowX: 'hidden',
-        onCanvasRedraw: ({ctx, virtualCanvasSize, canvasRect}) => {
+        enableWebGL: true,
+        onCanvasRedraw: ({ctx, virtualCanvasSize, canvasRect, webglCtx}) => {
           this.drawCanvas(
             ctx,
             virtualCanvasSize,
             renderedTracks,
             canvasRect,
             rootNode,
+            webglCtx,
           );
 
           if (VIRTUAL_TRACK_SCROLLING.get()) {
@@ -387,6 +420,7 @@
     renderedTracks: ReadonlyArray<TrackView>,
     floatingCanvasRect: Rect2D,
     rootNode: TrackNode,
+    webglCtx?: WebGL2RenderingContext,
   ) {
     const timelineRect = new Rect2D({
       left: TRACK_SHELL_WIDTH,
@@ -420,6 +454,7 @@
       COLOR_TIMELINE_OVERLAY,
     };
 
+    // Render all track content (WebGL rectangles + Canvas 2D text)
     const tracksOnCanvas = this.drawTracks(
       renderedTracks,
       floatingCanvasRect,
@@ -428,6 +463,7 @@
       timelineRect,
       visibleWindow,
       colors,
+      webglCtx,
     );
 
     renderFlows(this.trace, ctx, size, renderedTracks, rootNode, timescale);
@@ -472,6 +508,7 @@
     }
   }
 
+  // Render all tracks - WebGL rectangles and Canvas 2D content in one pass
   private drawTracks(
     renderedTracks: ReadonlyArray<TrackView>,
     floatingCanvasRect: Rect2D,
@@ -480,7 +517,17 @@
     timelineRect: Rect2D,
     visibleWindow: HighPrecisionTimeSpan,
     colors: CanvasColors,
+    webglCtx?: WebGL2RenderingContext,
   ) {
+    // Create renderer with appropriate base offset. WebGL needs the canvas
+    // offset applied since it doesn't use ctx.translate like Canvas2D.
+    const canvasRenderer = createCanvasRenderer(
+      ctx,
+      webglCtx,
+      -floatingCanvasRect.left,
+      -floatingCanvasRect.top,
+    );
+
     let tracksOnCanvas = 0;
     for (const trackView of renderedTracks) {
       const {verticalBounds} = trackView;
@@ -498,6 +545,7 @@
           this.perfStatsEnabled,
           this.trackPerfStats,
           colors,
+          canvasRenderer,
         );
         ++tracksOnCanvas;
       }
diff --git a/ui/src/frontend/timeline_page/track_view.ts b/ui/src/frontend/timeline_page/track_view.ts
index 492e508..c81dafe 100644
--- a/ui/src/frontend/timeline_page/track_view.ts
+++ b/ui/src/frontend/timeline_page/track_view.ts
@@ -23,7 +23,6 @@
  */
 
 import m from 'mithril';
-import {canvasClip, canvasSave} from '../../base/canvas_utils';
 import {classNames} from '../../base/classnames';
 import {Bounds2D, Rect2D, Size2D, VerticalBounds} from '../../base/geom';
 import {HighPrecisionTimeSpan} from '../../base/high_precision_time_span';
@@ -49,6 +48,7 @@
 import {Popup} from '../../widgets/popup';
 import {CanvasColors} from '../../public/canvas_colors';
 import {CodeSnippet} from '../../widgets/code_snippet';
+import {Renderer} from '../../base/renderer';
 
 export const TRACK_MIN_HEIGHT_SETTING = 'dev.perfetto.TrackMinHeightPx';
 export const DEFAULT_TRACK_MIN_HEIGHT_PX = 18;
@@ -261,6 +261,7 @@
     );
   }
 
+  // Render the track - both WebGL rectangles and Canvas 2D content
   drawCanvas(
     ctx: CanvasRenderingContext2D,
     rect: Rect2D,
@@ -268,25 +269,35 @@
     perfStatsEnabled: boolean,
     trackPerfStats: WeakMap<TrackNode, PerfStats>,
     colors: CanvasColors,
+    renderer: Renderer,
   ) {
     // For each track we rendered in view(), render it to the canvas. We know the
     // vertical bounds, so we just need to combine it with the horizontal bounds
     // and we're golden.
-    const {node, renderer, verticalBounds} = this;
+    const {node, renderer: trackRenderer, verticalBounds} = this;
 
     if (node.isSummary && node.expanded) return;
-    if (renderer?.getError()) return;
+    if (trackRenderer?.getError()) return;
 
     const trackRect = new Rect2D({
       ...rect,
       ...verticalBounds,
     });
 
+    // Clip to the track area
+    using _clip = renderer.clip(
+      trackRect.left,
+      trackRect.top,
+      trackRect.width,
+      trackRect.height,
+    );
+
     // Track renderers expect to start rendering at (0, 0), so we need to
     // translate the canvas and create a new timescale.
-    using _ = canvasSave(ctx);
-    canvasClip(ctx, trackRect);
-    ctx.translate(trackRect.left, trackRect.top);
+    using _translate = renderer.pushTransform({
+      offsetX: trackRect.left,
+      offsetY: trackRect.top,
+    });
 
     const timescale = new TimeScale(visibleWindow, {
       left: 0,
@@ -303,7 +314,7 @@
 
     const start = performance.now();
     node.uri &&
-      renderer?.render({
+      trackRenderer?.render({
         trackUri: node.uri,
         trackNode: node,
         visibleWindow,
@@ -312,8 +323,12 @@
         ctx,
         timescale,
         colors,
+        renderer: renderer,
       });
 
+    // Flush after each track
+    renderer.flush();
+
     this.highlightIfTrackInAreaSelection(ctx, timescale, trackRect);
 
     const renderTime = performance.now() - start;
diff --git a/ui/src/plugins/dev.perfetto.ProcessSummary/group_summary_track.ts b/ui/src/plugins/dev.perfetto.ProcessSummary/group_summary_track.ts
index e49ec0d..95877e5 100644
--- a/ui/src/plugins/dev.perfetto.ProcessSummary/group_summary_track.ts
+++ b/ui/src/plugins/dev.perfetto.ProcessSummary/group_summary_track.ts
@@ -422,7 +422,13 @@
     }
   }
 
-  render({ctx, size, timescale, visibleWindow}: TrackRenderContext): void {
+  render({
+    ctx,
+    size,
+    timescale,
+    visibleWindow,
+    renderer,
+  }: TrackRenderContext): void {
     const data = this.fetcher.data;
 
     if (data === undefined) return; // Can't possibly draw anything.
@@ -455,7 +461,6 @@
 
       const rectStart = Math.floor(timescale.timeToPx(tStart));
       const rectEnd = Math.floor(timescale.timeToPx(tEnd));
-      const rectWidth = Math.max(1, rectEnd - rectStart);
 
       let colorScheme;
 
@@ -486,7 +491,13 @@
       }
 
       const y = MARGIN_TOP + laneHeight * lane + lane;
-      ctx.fillRect(rectStart, y, rectWidth, laneHeight);
+      const rgba = colorScheme.base.rgba;
+      renderer.drawRect(rectStart, y, rectEnd, y + laneHeight, {
+        r: rgba.r,
+        g: rgba.g,
+        b: rgba.b,
+        a: Math.round(rgba.a * 255),
+      });
     }
   }
 
diff --git a/ui/src/plugins/dev.perfetto.Sched/cpu_slice_track.ts b/ui/src/plugins/dev.perfetto.Sched/cpu_slice_track.ts
index 772c753..0876c38 100644
--- a/ui/src/plugins/dev.perfetto.Sched/cpu_slice_track.ts
+++ b/ui/src/plugins/dev.perfetto.Sched/cpu_slice_track.ts
@@ -15,9 +15,8 @@
 import {BigintMath as BIMath} from '../../base/bigint_math';
 import {Monitor} from '../../base/monitor';
 import {search, searchEq, searchSegment} from '../../base/binary_search';
-import {assertExists, assertTrue} from '../../base/logging';
+import {assertTrue} from '../../base/logging';
 import {duration, Time, time} from '../../base/time';
-import {drawIncompleteSlice} from '../../base/canvas_utils';
 import {cropText} from '../../base/string_utils';
 import {Color} from '../../base/color';
 import m from 'mithril';
@@ -37,6 +36,7 @@
 import {Trace} from '../../public/trace';
 import {ThreadMap} from '../dev.perfetto.Thread/threads';
 import {SourceDataset} from '../../trace_processor/dataset';
+import {RECT_FLAG_FADEOUT, RECT_FLAG_HATCHED} from '../../base/renderer';
 
 export interface Data extends TrackData {
   // Slices are stored in a columnar fashion. All fields have the same length.
@@ -289,7 +289,13 @@
   }
 
   renderSlices(
-    {ctx, timescale, size, visibleWindow}: TrackRenderContext,
+    {
+      ctx,
+      timescale,
+      size,
+      visibleWindow,
+      renderer,
+    }: TrackRenderContext,
     data: Data,
   ): void {
     assertTrue(data.startQs.length === data.endQs.length);
@@ -352,31 +358,31 @@
         color = colorScheme.base;
         textColor = colorScheme.textBase;
       }
-      ctx.fillStyle = color.cssString;
 
-      if (data.flags[i] & CPU_SLICE_FLAGS_INCOMPLETE) {
-        drawIncompleteSlice(
-          ctx,
-          rectStart,
-          MARGIN_TOP,
-          rectWidth,
-          RECT_HEIGHT,
-          color,
-        );
-      } else {
-        ctx.fillRect(rectStart, MARGIN_TOP, rectWidth, RECT_HEIGHT);
-      }
+      // Build flags
+      const srcFlags = data.flags[i];
+      let flags = 0;
+      if (srcFlags & CPU_SLICE_FLAGS_INCOMPLETE) flags |= RECT_FLAG_FADEOUT;
+      if (srcFlags & CPU_SLICE_FLAGS_REALTIME) flags |= RECT_FLAG_HATCHED;
+
+      const rgba = color.rgba;
+      renderer.drawRect(
+        rectStart,
+        MARGIN_TOP,
+        rectEnd,
+        MARGIN_TOP + RECT_HEIGHT,
+        {
+          r: rgba.r,
+          g: rgba.g,
+          b: rgba.b,
+          a: Math.round(rgba.a * 255),
+        },
+        flags,
+      );
 
       // Don't render text when we have less than 5px to play with.
       if (rectWidth < 5) continue;
 
-      // Stylize real-time threads. We don't do it when zoomed out as the
-      // fillRect is expensive.
-      if (data.flags[i] & CPU_SLICE_FLAGS_REALTIME) {
-        ctx.fillStyle = getHatchedPattern(ctx);
-        ctx.fillRect(rectStart, MARGIN_TOP, rectWidth, RECT_HEIGHT);
-      }
-
       // TODO: consider de-duplicating this code with the copied one from
       // chrome_slices/frontend.ts.
       let title = `[utid:${utid}]`;
@@ -562,26 +568,3 @@
     return new SchedSliceDetailsPanel(this.trace, this.threads);
   }
 }
-
-// Creates a diagonal hatched pattern to be used for distinguishing slices with
-// real-time priorities. The pattern is created once as an offscreen canvas and
-// is kept cached inside the Context2D of the main canvas, without making
-// assumptions on the lifetime of the main canvas.
-function getHatchedPattern(mainCtx: CanvasRenderingContext2D): CanvasPattern {
-  const mctx = mainCtx as CanvasRenderingContext2D & {
-    sliceHatchedPattern?: CanvasPattern;
-  };
-  if (mctx.sliceHatchedPattern !== undefined) return mctx.sliceHatchedPattern;
-  const canvas = document.createElement('canvas');
-  const SIZE = 8;
-  canvas.width = canvas.height = SIZE;
-  const ctx = assertExists(canvas.getContext('2d'));
-  ctx.strokeStyle = 'rgba(255,255,255,0.3)';
-  ctx.beginPath();
-  ctx.lineWidth = 1;
-  ctx.moveTo(0, SIZE);
-  ctx.lineTo(SIZE, 0);
-  ctx.stroke();
-  mctx.sliceHatchedPattern = assertExists(mctx.createPattern(canvas, 'repeat'));
-  return mctx.sliceHatchedPattern;
-}
diff --git a/ui/src/public/track.ts b/ui/src/public/track.ts
index 0f5846a..821e548 100644
--- a/ui/src/public/track.ts
+++ b/ui/src/public/track.ts
@@ -24,6 +24,7 @@
 import {TrackNode} from './workspace';
 import {CanvasColors} from './canvas_colors';
 import {z} from 'zod';
+import {Renderer} from '../base/renderer';
 
 /**
  * Represents a snap point for the snap-to-boundaries feature.
@@ -128,6 +129,12 @@
    * Semantic colors which can vary depending on the current theme.
    */
   readonly colors: CanvasColors;
+
+  /**
+   * A high-performance renderer for drawing rectangles and billboards.
+   * Uses WebGL when available, with Canvas 2D fallback.
+   */
+  readonly renderer: Renderer;
 }
 
 // A definition of a track, including a renderer implementation and metadata.
@@ -286,6 +293,11 @@
   /**
    * Required method used to render the track's content to the canvas, called
    * synchronously on every render cycle.
+   *
+   * Tracks can use ctx (Canvas 2D) for text and shapes, and canvasRenderer
+   * (WebGL) for high-performance rectangle rendering. Both are available
+   * in the same render call, and the WebGL content will appear behind
+   * Canvas 2D content.
    */
   render(ctx: TrackRenderContext): void;
   onFullRedraw?(): void;
diff --git a/ui/src/widgets/virtual_overlay_canvas.ts b/ui/src/widgets/virtual_overlay_canvas.ts
index 1d6c57f..97ea7a5 100644
--- a/ui/src/widgets/virtual_overlay_canvas.ts
+++ b/ui/src/widgets/virtual_overlay_canvas.ts
@@ -55,6 +55,11 @@
   // The rect of the visible viewport W.R.T to the virtual canvas element.
   // Does not include overdraw area.
   readonly viewportRect: Rect2D;
+
+  // Optional WebGL canvas and context for hardware-accelerated rendering.
+  // These are positioned in sync with the 2D canvas.
+  readonly webglCanvas?: HTMLCanvasElement;
+  readonly webglCtx?: WebGL2RenderingContext;
 }
 
 export type Overflow = 'hidden' | 'visible' | 'auto';
@@ -87,6 +92,11 @@
   // Override styles from base interface, only allowing object type styles
   // rather than strings.
   style?: Partial<CSSStyleDeclaration>;
+
+  // Enable a second canvas for WebGL rendering. When enabled, webglCanvas and
+  // webglCtx will be provided in the draw context.
+  // Default: false.
+  readonly enableWebGL?: boolean;
 }
 
 function getScrollAxesFromOverflow(x: Overflow, y: Overflow) {
@@ -112,6 +122,8 @@
   private ctx?: CanvasRenderingContext2D;
   private virtualCanvas?: VirtualCanvas;
   private attrs?: VirtualOverlayCanvasAttrs;
+  private webglCanvas?: HTMLCanvasElement;
+  private webglCtx?: WebGL2RenderingContext;
 
   view({attrs, children}: m.CVnode<VirtualOverlayCanvasAttrs>) {
     this.attrs = attrs;
@@ -166,6 +178,30 @@
     // Create the canvas rendering context
     this.ctx = assertExists(virtualCanvas.canvasElement.getContext('2d'));
 
+    // Create WebGL canvas if enabled
+    if (attrs.enableWebGL) {
+      this.webglCanvas = document.createElement('canvas');
+      this.webglCanvas.style.position = 'absolute';
+      this.webglCanvas.style.pointerEvents = 'none';
+      // Insert before the 2D canvas so WebGL renders underneath
+      canvasContainerElement.insertBefore(
+        this.webglCanvas,
+        virtualCanvas.canvasElement,
+      );
+      this.trash.defer(() => {
+        if (this.webglCanvas?.parentElement) {
+          this.webglCanvas.parentElement.removeChild(this.webglCanvas);
+        }
+      });
+
+      this.webglCtx =
+        this.webglCanvas.getContext('webgl2', {
+          alpha: true,
+          premultipliedAlpha: true,
+          antialias: true,
+        }) ?? undefined;
+    }
+
     // When the container resizes, we might need to resize the canvas. This can
     // be slow so we don't want to do it every render cycle. VirtualCanvas will
     // tell us when we need to do this.
@@ -173,12 +209,38 @@
       const dpr = window.devicePixelRatio;
       canvas.width = width * dpr;
       canvas.height = height * dpr;
+
+      // Resize WebGL canvas to match
+      if (this.webglCanvas && this.webglCtx) {
+        this.webglCanvas.width = width * dpr;
+        this.webglCanvas.height = height * dpr;
+        this.webglCanvas.style.width = `${width}px`;
+        this.webglCanvas.style.height = `${height}px`;
+        this.webglCtx.viewport(0, 0, width * dpr, height * dpr);
+      }
     });
 
+    // Manually sync WebGL canvas size since the initial ResizeObserver callback
+    // fired before the listener was set (during VirtualCanvas construction).
+    if (this.webglCanvas && this.webglCtx) {
+      const canvasRect = virtualCanvas.canvasRect;
+      const dpr = window.devicePixelRatio;
+      this.webglCanvas.width = canvasRect.width * dpr;
+      this.webglCanvas.height = canvasRect.height * dpr;
+      this.webglCanvas.style.width = `${canvasRect.width}px`;
+      this.webglCanvas.style.height = `${canvasRect.height}px`;
+      this.webglCtx.viewport(0, 0, canvasRect.width * dpr, canvasRect.height * dpr);
+    }
+
     // Whenever the canvas changes size or moves around (e.g. when scrolling),
     // we'll need to trigger a re-render to keep canvas content aligned with the
     // DOM elements underneath.
     virtualCanvas.setLayoutShiftListener(() => {
+      // Keep WebGL canvas in sync with 2D canvas position
+      if (this.webglCanvas) {
+        const canvasRect = virtualCanvas.canvasRect;
+        this.webglCanvas.style.transform = `translate(${canvasRect.left}px, ${canvasRect.top}px)`;
+      }
       this.redrawCanvas();
     });
 
@@ -204,6 +266,12 @@
     ctx.resetTransform();
     ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
 
+    // Clear WebGL canvas if present
+    if (this.webglCtx) {
+      this.webglCtx.clearColor(0, 0, 0, 0);
+      this.webglCtx.clear(this.webglCtx.COLOR_BUFFER_BIT);
+    }
+
     // Adjust scaling according pixel ratio. This makes sure the canvas remains
     // sharp on high DPI screens.
     const dpr = window.devicePixelRatio;
@@ -223,6 +291,10 @@
       virtualCanvasSize: virtualCanvas.size,
       canvasRect: virtualCanvas.canvasRect,
       viewportRect: virtualCanvas.viewportRect,
+      webglCanvas: this.webglCanvas,
+      webglCtx: this.webglCtx,
     });
+
+    this.webglCtx?.flush();
   }
 }