/** * Render Optimizer Module * * Provides performance optimizations for large drawings: * 1. RAF batching - coalesces multiple render requests per frame * 2. Stroke caching - pre-renders completed strokes to an offscreen canvas * 3. Dirty region tracking - only redraws what changed * 4. Path2D caching - reuses Path2D objects for strokes * * ## Performance Characteristics * * Without optimization: * - Every pointer move triggers a full redraw * - All strokes are re-rendered each frame * - CPU bound on stroke count × points per stroke * * With optimization: * - Pointer moves batched to RAF (max 60fps render) * - Completed strokes cached to offscreen canvas * - Only active stroke + cached layer composited * - Path2D objects reused (avoids path reconstruction) * * ## Benchmarks (target) * * - 1000 strokes × 100 points each = 100k points * - Should maintain 60fps during drawing * - Should maintain 60fps during pan/zoom */ // ============================================================================ // RAF Batching // ============================================================================ let pendingRender = false; let renderCallback = null; let rafId = null; /** * Schedules a render on the next animation frame. * Multiple calls before the frame executes are coalesced into one render. * * @param {() => void} callback - The render function to call */ export function scheduleRender(callback) { renderCallback = callback; if (!pendingRender) { pendingRender = true; rafId = requestAnimationFrame(() => { pendingRender = false; if (renderCallback) { renderCallback(); } }); } } /** * Cancels any pending render. */ export function cancelPendingRender() { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } pendingRender = false; } /** * Forces an immediate render, bypassing RAF batching. * Use sparingly - mainly for critical updates like tool changes. * * @param {() => void} callback */ export function forceRender(callback) { cancelPendingRender(); callback(); } // ============================================================================ // Stroke Cache (Offscreen Canvas) // ============================================================================ let cacheCanvas = null; let cacheContext = null; let cacheValid = false; let cachedStrokeCount = 0; let cachedTransform = null; let cacheWidth = 0; let cacheHeight = 0; /** * Initializes the stroke cache with given dimensions. * * @param {number} width - Width in physical pixels * @param {number} height - Height in physical pixels */ export function initStrokeCache(width, height) { if (!cacheCanvas) { cacheCanvas = document.createElement('canvas'); // Use OffscreenCanvas if available for better performance if (typeof OffscreenCanvas !== 'undefined') { try { cacheCanvas = new OffscreenCanvas(width, height); } catch (e) { // Fallback to regular canvas cacheCanvas = document.createElement('canvas'); } } } // Resize if needed if (cacheCanvas.width !== width || cacheCanvas.height !== height) { cacheCanvas.width = width; cacheCanvas.height = height; cacheWidth = width; cacheHeight = height; cacheContext = cacheCanvas.getContext('2d', { alpha: true, willReadFrequently: false }); invalidateCache(); } } /** * Marks the cache as invalid (needs redraw). */ export function invalidateCache() { cacheValid = false; } /** * Checks if cache needs to be rebuilt. * * @param {number} strokeCount - Current number of strokes * @param {Object} transform - Current transform { scale, offsetX, offsetY } * @returns {boolean} */ export function isCacheValid(strokeCount, transform) { if (!cacheValid) return false; if (strokeCount !== cachedStrokeCount) return false; if (!cachedTransform) return false; // Check if transform changed if (transform.scale !== cachedTransform.scale || transform.offsetX !== cachedTransform.offsetX || transform.offsetY !== cachedTransform.offsetY) { return false; } return true; } /** * Gets the cache canvas for drawing completed strokes. * * @returns {{ canvas: HTMLCanvasElement | OffscreenCanvas, context: CanvasRenderingContext2D }} */ export function getCacheCanvas() { return { canvas: cacheCanvas, context: cacheContext }; } /** * Marks the cache as valid after a full redraw. * * @param {number} strokeCount * @param {Object} transform */ export function validateCache(strokeCount, transform) { cacheValid = true; cachedStrokeCount = strokeCount; cachedTransform = { ...transform }; } /** * Clears the cache canvas. */ export function clearCache() { if (cacheContext && cacheCanvas) { cacheContext.setTransform(1, 0, 0, 1, 0, 0); cacheContext.clearRect(0, 0, cacheCanvas.width, cacheCanvas.height); } invalidateCache(); } // ============================================================================ // Path2D Caching // ============================================================================ // WeakMap to store Path2D objects keyed by stroke const pathCache = new WeakMap(); /** * Gets or creates a cached Path2D for a stroke. * Path2D objects can be reused for faster rendering. * * @param {Object} stroke - Stroke object with points array * @param {boolean} smooth - Use quadratic curves for smoothing * @returns {Path2D | null} */ export function getCachedPath(stroke, smooth = false) { if (!stroke.points || stroke.points.length < 2) { return null; } // Check cache let cached = pathCache.get(stroke); if (cached && cached.pointCount === stroke.points.length && cached.smooth === smooth) { return cached.path; } // Create new Path2D const path = new Path2D(); const points = stroke.points; path.moveTo(points[0].x, points[0].y); if (smooth && points.length > 2) { // Quadratic curve smoothing for (let i = 1; i < points.length - 1; i++) { const current = points[i]; const next = points[i + 1]; const midX = (current.x + next.x) / 2; const midY = (current.y + next.y) / 2; path.quadraticCurveTo(current.x, current.y, midX, midY); } // Final segment const last = points[points.length - 1]; const secondLast = points[points.length - 2]; path.quadraticCurveTo(secondLast.x, secondLast.y, last.x, last.y); } else { // Straight line segments for (let i = 1; i < points.length; i++) { path.lineTo(points[i].x, points[i].y); } } // Cache it pathCache.set(stroke, { path, pointCount: points.length, smooth }); return path; } /** * Clears a stroke's cached path (call when stroke is modified). * * @param {Object} stroke */ export function invalidateStrokePath(stroke) { pathCache.delete(stroke); } /** * Clears all cached paths. */ export function clearPathCache() { // WeakMap doesn't have clear(), but entries will be GC'd // when strokes are no longer referenced } // ============================================================================ // Rendering Helpers // ============================================================================ /** * Draws a stroke using cached Path2D. * * @param {CanvasRenderingContext2D} ctx * @param {Object} stroke * @param {boolean} smooth */ export function drawStrokeCached(ctx, stroke, smooth = false) { const path = getCachedPath(stroke, smooth); if (!path) return; ctx.strokeStyle = stroke.color; ctx.lineWidth = stroke.size; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.globalAlpha = stroke.opacity; ctx.stroke(path); ctx.globalAlpha = 1; } /** * Draws multiple strokes efficiently using the cache layer. * * @param {CanvasRenderingContext2D} mainCtx - Main canvas context * @param {Array} strokes - Array of stroke objects * @param {Object} transform - Current transform * @param {number} dpr - Device pixel ratio */ export function drawStrokesWithCache(mainCtx, strokes, transform, dpr) { if (!cacheCanvas || !cacheContext) { // Fallback: draw directly for (const stroke of strokes) { drawStrokeCached(mainCtx, stroke, false); } return; } // Check if cache is valid if (!isCacheValid(strokes.length, transform)) { // Rebuild cache clearCache(); // Apply transform to cache context cacheContext.setTransform( transform.scale * dpr, 0, 0, transform.scale * dpr, transform.offsetX * transform.scale * dpr, transform.offsetY * transform.scale * dpr ); // Draw all strokes to cache for (const stroke of strokes) { drawStrokeCached(cacheContext, stroke, false); } validateCache(strokes.length, transform); } // Composite cache onto main canvas mainCtx.setTransform(1, 0, 0, 1, 0, 0); mainCtx.drawImage(cacheCanvas, 0, 0); } // ============================================================================ // Performance Metrics // ============================================================================ let frameCount = 0; let lastFpsTime = 0; let currentFps = 0; let renderTimes = []; const MAX_RENDER_SAMPLES = 60; /** * Records the start of a render for timing. * * @returns {number} Start timestamp */ export function startRenderTiming() { return performance.now(); } /** * Records the end of a render and updates metrics. * * @param {number} startTime */ export function endRenderTiming(startTime) { const duration = performance.now() - startTime; renderTimes.push(duration); if (renderTimes.length > MAX_RENDER_SAMPLES) { renderTimes.shift(); } frameCount++; const now = performance.now(); if (now - lastFpsTime >= 1000) { currentFps = frameCount; frameCount = 0; lastFpsTime = now; } } /** * Gets current performance metrics. * * @returns {{ fps: number, avgRenderMs: number, maxRenderMs: number }} */ export function getPerformanceMetrics() { const avg = renderTimes.length > 0 ? renderTimes.reduce((a, b) => a + b, 0) / renderTimes.length : 0; const max = renderTimes.length > 0 ? Math.max(...renderTimes) : 0; return { fps: currentFps, avgRenderMs: avg, maxRenderMs: max, sampleCount: renderTimes.length }; } /** * Resets performance metrics. */ export function resetPerformanceMetrics() { frameCount = 0; lastFpsTime = performance.now(); currentFps = 0; renderTimes = []; } // ============================================================================ // Benchmarking // ============================================================================ /** * Runs a performance benchmark with synthetic strokes. * * @param {CanvasRenderingContext2D} ctx * @param {Object} transform * @param {number} dpr * @param {Object} options * @returns {Object} Benchmark results */ export function runBenchmark(ctx, transform, dpr, options = {}) { const { strokeCount = 100, pointsPerStroke = 50, iterations = 10 } = options; console.log(`[Benchmark] Starting: ${strokeCount} strokes × ${pointsPerStroke} points = ${strokeCount * pointsPerStroke} total points`); // Generate synthetic strokes const strokes = []; for (let i = 0; i < strokeCount; i++) { const points = []; const startX = Math.random() * 2000 - 500; const startY = Math.random() * 2000 - 500; for (let j = 0; j < pointsPerStroke; j++) { points.push({ x: startX + j * 5 + Math.random() * 10, y: startY + Math.sin(j * 0.2) * 50 + Math.random() * 10 }); } strokes.push({ id: `benchmark_${i}`, points, color: `hsl(${(i * 37) % 360}, 70%, 50%)`, size: 2 + (i % 4), opacity: 0.8 }); } // Benchmark direct rendering const directTimes = []; for (let i = 0; i < iterations; i++) { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.setTransform( transform.scale * dpr, 0, 0, transform.scale * dpr, transform.offsetX * transform.scale * dpr, transform.offsetY * transform.scale * dpr ); const start = performance.now(); for (const stroke of strokes) { if (stroke.points.length < 2) continue; ctx.beginPath(); ctx.strokeStyle = stroke.color; ctx.lineWidth = stroke.size; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.globalAlpha = stroke.opacity; ctx.moveTo(stroke.points[0].x, stroke.points[0].y); for (let k = 1; k < stroke.points.length; k++) { ctx.lineTo(stroke.points[k].x, stroke.points[k].y); } ctx.stroke(); ctx.globalAlpha = 1; } directTimes.push(performance.now() - start); } // Benchmark Path2D cached rendering const cachedTimes = []; for (let i = 0; i < iterations; i++) { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.setTransform( transform.scale * dpr, 0, 0, transform.scale * dpr, transform.offsetX * transform.scale * dpr, transform.offsetY * transform.scale * dpr ); const start = performance.now(); for (const stroke of strokes) { drawStrokeCached(ctx, stroke, false); } cachedTimes.push(performance.now() - start); } // Benchmark with stroke cache layer const { width, height } = { width: ctx.canvas.width, height: ctx.canvas.height }; initStrokeCache(width, height); invalidateCache(); const layerTimes = []; for (let i = 0; i < iterations; i++) { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); // First iteration rebuilds cache, rest should use cache const start = performance.now(); drawStrokesWithCache(ctx, strokes, transform, dpr); layerTimes.push(performance.now() - start); } const avg = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length; const results = { config: { strokeCount, pointsPerStroke, totalPoints: strokeCount * pointsPerStroke, iterations }, direct: { avg: avg(directTimes), min: Math.min(...directTimes), max: Math.max(...directTimes) }, path2d: { avg: avg(cachedTimes), min: Math.min(...cachedTimes), max: Math.max(...cachedTimes) }, layerCache: { avg: avg(layerTimes), min: Math.min(...layerTimes), max: Math.max(...layerTimes) }, layerCacheFirstFrame: layerTimes[0], layerCacheSubsequent: avg(layerTimes.slice(1)) }; console.log('[Benchmark] Results:'); console.log(` Direct rendering: ${results.direct.avg.toFixed(2)}ms avg`); console.log(` Path2D cached: ${results.path2d.avg.toFixed(2)}ms avg (${((1 - results.path2d.avg / results.direct.avg) * 100).toFixed(1)}% faster)`); console.log(` Layer cache first: ${results.layerCacheFirstFrame.toFixed(2)}ms`); console.log(` Layer cache subsequent: ${results.layerCacheSubsequent.toFixed(2)}ms avg (${((1 - results.layerCacheSubsequent / results.direct.avg) * 100).toFixed(1)}% faster)`); // Clear benchmark strokes from path cache for (const stroke of strokes) { invalidateStrokePath(stroke); } return results; }