550 lines
16 KiB
JavaScript
550 lines
16 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|