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