Files
2026-01-18 00:13:02 +09:00

550 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}