/** * Pixel Art Library - Core Renderer * * Main rendering engine for pixel-perfect canvas drawing * Optimized for performance with batch operations and efficient algorithms */ import type { PixelArtConfig, Point, Rectangle, Circle, Triangle, LinearGradient, RadialGradient, PixelArtLayer, ColorPalette, } from '@/lib/pixel-art/types'; /** * Color cache for performance optimization */ interface ColorCache { [key: string]: string; } /** * RGBA color representation for efficient pixel operations */ interface RGBAColor { r: number; g: number; b: number; a: number; } export class PixelArtRenderer { private ctx: CanvasRenderingContext2D; private config: PixelArtConfig; private palette?: ColorPalette; private colorCache: ColorCache = {}; private rgbaCache: Map = new Map(); private imageDataCache: ImageData | null = null; constructor(ctx: CanvasRenderingContext2D, config: PixelArtConfig, palette?: ColorPalette) { this.ctx = ctx; this.config = config; this.palette = palette; // Set up canvas for pixel-perfect rendering this.setupCanvas(); } private setupCanvas(): void { const { ctx, config } = this; const pixelSize = config.pixelSize || 1; const scale = config.scale || 1; // Disable image smoothing for crisp pixels - CRITICAL for pixel art ctx.imageSmoothingEnabled = false; ctx.imageSmoothingQuality = 'low'; // Set canvas size to exact pixel dimensions const displayWidth = Math.floor(config.width * pixelSize * scale); const displayHeight = Math.floor(config.height * pixelSize * scale); // Set actual canvas resolution (important for pixel-perfect rendering) ctx.canvas.width = displayWidth; ctx.canvas.height = displayHeight; // Set CSS size to match display size for pixel-perfect rendering ctx.canvas.style.width = `${displayWidth}px`; ctx.canvas.style.height = `${displayHeight}px`; // Enhanced browser compatibility for pixel-perfect rendering const canvas = ctx.canvas; canvas.style.imageRendering = 'pixelated'; canvas.style.imageRendering = '-moz-crisp-edges'; canvas.style.imageRendering = 'crisp-edges'; canvas.style.imageRendering = '-webkit-optimize-contrast'; // Fallback for older browsers if (!('imageRendering' in canvas.style)) { canvas.style.msInterpolationMode = 'nearest-neighbor'; } // Scale context - use integer scaling for pixel-perfect rendering const scaleFactor = pixelSize * scale; ctx.scale(scaleFactor, scaleFactor); // Invalidate cache when canvas is resized this.imageDataCache = null; } /** * Clear the canvas */ clear(): void { this.ctx.clearRect(0, 0, this.config.width, this.config.height); } /** * Parse color string to RGBA for efficient operations */ private parseColor(color: string, alpha: number = 1): RGBAColor { const cacheKey = `${color}:${alpha}`; if (this.rgbaCache.has(cacheKey)) { return this.rgbaCache.get(cacheKey)!; } let rgba: RGBAColor; if (color.startsWith('rgba')) { const matches = color.match(/\d+/g); if (matches && matches.length >= 3) { rgba = { r: parseInt(matches[0], 10), g: parseInt(matches[1], 10), b: parseInt(matches[2], 10), a: matches[3] ? parseFloat(matches[3]) : alpha, }; } else { rgba = { r: 0, g: 0, b: 0, a: alpha }; } } else if (color.startsWith('rgb')) { const matches = color.match(/\d+/g); if (matches && matches.length >= 3) { rgba = { r: parseInt(matches[0], 10), g: parseInt(matches[1], 10), b: parseInt(matches[2], 10), a: alpha, }; } else { rgba = { r: 0, g: 0, b: 0, a: alpha }; } } else if (color.startsWith('#')) { const hex = color.slice(1); const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); rgba = { r, g, b, a: alpha }; } else { // Fallback for named colors - try to parse via canvas const tempCtx = document.createElement('canvas').getContext('2d'); if (tempCtx) { tempCtx.fillStyle = color; const computed = tempCtx.fillStyle; if (computed.startsWith('#')) { return this.parseColor(computed, alpha); } } rgba = { r: 0, g: 0, b: 0, a: alpha }; } this.rgbaCache.set(cacheKey, rgba); return rgba; } /** * Draw a single pixel (pixel-perfect) * Optimized with direct pixel manipulation for better performance */ pixel(x: number, y: number, color: string, alpha: number = 1): void { const { ctx } = this; // Round to integer for pixel-perfect rendering const px = Math.floor(x); const py = Math.floor(y); // Bounds check if (px < 0 || px >= this.config.width || py < 0 || py >= this.config.height) { return; } ctx.globalAlpha = alpha; ctx.fillStyle = color; ctx.fillRect(px, py, 1, 1); ctx.globalAlpha = 1; } /** * Draw multiple pixels efficiently using ImageData * Use this for batch pixel operations */ pixels(points: Array<{ x: number; y: number; color: string; alpha?: number }>): void { if (points.length === 0) return; const { ctx } = this; const width = this.config.width; const height = this.config.height; // Get or create ImageData if ( !this.imageDataCache || this.imageDataCache.width !== width || this.imageDataCache.height !== height ) { this.imageDataCache = ctx.createImageData(width, height); } const data = this.imageDataCache.data; // Draw all pixels for (const point of points) { const px = Math.floor(point.x); const py = Math.floor(point.y); if (px < 0 || px >= width || py < 0 || py >= height) continue; const rgba = this.parseColor(point.color, point.alpha ?? 1); const index = (py * width + px) * 4; // Blend with existing pixel if alpha < 1 if (rgba.a < 1 && data[index + 3] > 0) { const existingAlpha = data[index + 3] / 255; const newAlpha = rgba.a + existingAlpha * (1 - rgba.a); const blend = (newValue: number, oldValue: number) => Math.round((rgba.a * newValue + existingAlpha * (1 - rgba.a) * oldValue) / newAlpha); data[index] = blend(rgba.r, data[index]); data[index + 1] = blend(rgba.g, data[index + 1]); data[index + 2] = blend(rgba.b, data[index + 2]); data[index + 3] = Math.round(newAlpha * 255); } else { data[index] = rgba.r; data[index + 1] = rgba.g; data[index + 2] = rgba.b; data[index + 3] = Math.round(rgba.a * 255); } } // Put ImageData back to canvas ctx.putImageData(this.imageDataCache, 0, 0); } /** * Draw a rectangle (pixel-perfect) */ rect(rect: Rectangle, color: string, alpha: number = 1): void { const { ctx } = this; // Round to integers for pixel-perfect rendering const x = Math.floor(rect.x); const y = Math.floor(rect.y); const w = Math.floor(rect.width); const h = Math.floor(rect.height); ctx.globalAlpha = alpha; ctx.fillStyle = color; ctx.fillRect(x, y, w, h); ctx.globalAlpha = 1; } /** * Draw a rectangle outline */ rectOutline(rect: Rectangle, color: string, lineWidth: number = 1, alpha: number = 1): void { const { ctx } = this; ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); ctx.globalAlpha = 1; } /** * Draw a circle using midpoint circle algorithm (more efficient) * Optimized for pixel art with perfect pixel placement */ circle(circle: Circle, color: string, alpha: number = 1): void { const cx = Math.floor(circle.x); const cy = Math.floor(circle.y); const radius = Math.floor(circle.radius); // Use midpoint circle algorithm for better performance and accuracy this.midpointCircle(cx, cy, radius, color, alpha); } /** * Midpoint circle algorithm - more efficient than pixel-by-pixel */ private midpointCircle( cx: number, cy: number, radius: number, color: string, alpha: number ): void { const { ctx } = this; ctx.globalAlpha = alpha; ctx.fillStyle = color; let x = radius; let y = 0; let err = 0; // Draw 8 octants simultaneously const drawOctants = (x: number, y: number) => { if (cx + x >= 0 && cx + x < this.config.width && cy + y >= 0 && cy + y < this.config.height) { ctx.fillRect(cx + x, cy + y, 1, 1); } if (cx - x >= 0 && cx - x < this.config.width && cy + y >= 0 && cy + y < this.config.height) { ctx.fillRect(cx - x, cy + y, 1, 1); } if (cx + x >= 0 && cx + x < this.config.width && cy - y >= 0 && cy - y < this.config.height) { ctx.fillRect(cx + x, cy - y, 1, 1); } if (cx - x >= 0 && cx - x < this.config.width && cy - y >= 0 && cy - y < this.config.height) { ctx.fillRect(cx - x, cy - y, 1, 1); } if (cx + y >= 0 && cx + y < this.config.width && cy + x >= 0 && cy + x < this.config.height) { ctx.fillRect(cx + y, cy + x, 1, 1); } if (cx - y >= 0 && cx - y < this.config.width && cy + x >= 0 && cy + x < this.config.height) { ctx.fillRect(cx - y, cy + x, 1, 1); } if (cx + y >= 0 && cx + y < this.config.width && cy - x >= 0 && cy - x < this.config.height) { ctx.fillRect(cx + y, cy - x, 1, 1); } if (cx - y >= 0 && cx - y < this.config.width && cy - x >= 0 && cy - x < this.config.height) { ctx.fillRect(cx - y, cy - x, 1, 1); } }; // Fill circle by drawing horizontal lines const fillCircle = (x: number, y: number) => { if (y === 0) { // Draw horizontal line through center const startX = Math.max(0, cx - x); const endX = Math.min(this.config.width, cx + x + 1); const width = endX - startX; if (width > 0 && cy >= 0 && cy < this.config.height) { ctx.fillRect(startX, cy, width, 1); } } else { // Draw two horizontal lines (top and bottom) const startX1 = Math.max(0, cx - x); const endX1 = Math.min(this.config.width, cx + x + 1); const width1 = endX1 - startX1; if (width1 > 0) { if (cy + y >= 0 && cy + y < this.config.height) { ctx.fillRect(startX1, cy + y, width1, 1); } if (cy - y >= 0 && cy - y < this.config.height) { ctx.fillRect(startX1, cy - y, width1, 1); } } const startX2 = Math.max(0, cx - y); const endX2 = Math.min(this.config.width, cx + y + 1); const width2 = endX2 - startX2; if (width2 > 0) { if (cy + x >= 0 && cy + x < this.config.height) { ctx.fillRect(startX2, cy + x, width2, 1); } if (cy - x >= 0 && cy - x < this.config.height) { ctx.fillRect(startX2, cy - x, width2, 1); } } } }; while (x >= y) { fillCircle(x, y); drawOctants(x, y); if (err <= 0) { y += 1; err += 2 * y + 1; } if (err > 0) { x -= 1; err -= 2 * x + 1; } } ctx.globalAlpha = 1; } /** * Draw a circle outline */ circleOutline(circle: Circle, color: string, lineWidth: number = 1, alpha: number = 1): void { const { ctx } = this; ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.beginPath(); ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1; } /** * Draw a triangle (pixelated version for pixel art) */ triangle(triangle: Triangle, color: string, alpha: number = 1): void { const { ctx } = this; // Round coordinates for pixel-perfect rendering const p1 = { x: Math.floor(triangle.p1.x), y: Math.floor(triangle.p1.y) }; const p2 = { x: Math.floor(triangle.p2.x), y: Math.floor(triangle.p2.y) }; const p3 = { x: Math.floor(triangle.p3.x), y: Math.floor(triangle.p3.y) }; // Find bounding box const minX = Math.max(0, Math.min(p1.x, p2.x, p3.x)); const maxX = Math.min(this.config.width, Math.max(p1.x, p2.x, p3.x)); const minY = Math.max(0, Math.min(p1.y, p2.y, p3.y)); const maxY = Math.min(this.config.height, Math.max(p1.y, p2.y, p3.y)); // Helper function to check if point is inside triangle const pointInTriangle = (px: number, py: number): boolean => { const v0x = p3.x - p1.x; const v0y = p3.y - p1.y; const v1x = p2.x - p1.x; const v1y = p2.y - p1.y; const v2x = px - p1.x; const v2y = py - p1.y; const dot00 = v0x * v0x + v0y * v0y; const dot01 = v0x * v1x + v0y * v1y; const dot02 = v0x * v2x + v0y * v2y; const dot11 = v1x * v1x + v1y * v1y; const dot12 = v1x * v2x + v1y * v2y; const invDenom = 1 / (dot00 * dot11 - dot01 * dot01); const u = (dot11 * dot02 - dot01 * dot12) * invDenom; const v = (dot00 * dot12 - dot01 * dot02) * invDenom; return u >= 0 && v >= 0 && u + v <= 1; }; ctx.globalAlpha = alpha; ctx.fillStyle = color; // Fill triangle pixel by pixel for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { if (pointInTriangle(x, y)) { ctx.fillRect(x, y, 1, 1); } } } ctx.globalAlpha = 1; } /** * Draw a triangle outline */ triangleOutline( triangle: Triangle, color: string, lineWidth: number = 1, alpha: number = 1 ): void { const { ctx } = this; ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.beginPath(); ctx.moveTo(triangle.p1.x, triangle.p1.y); ctx.lineTo(triangle.p2.x, triangle.p2.y); ctx.lineTo(triangle.p3.x, triangle.p3.y); ctx.closePath(); ctx.stroke(); ctx.globalAlpha = 1; } /** * Draw a line using Bresenham's algorithm for pixel-perfect rendering * Much more accurate for pixel art than canvas stroke */ line(from: Point, to: Point, color: string, lineWidth: number = 1, alpha: number = 1): void { // For pixel art, use Bresenham's algorithm for perfect pixel lines if (lineWidth === 1) { this.bresenhamLine(from, to, color, alpha); } else { // For thicker lines, use canvas stroke (less optimal but handles width) const { ctx } = this; ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.beginPath(); ctx.moveTo(Math.floor(from.x) + 0.5, Math.floor(from.y) + 0.5); ctx.lineTo(Math.floor(to.x) + 0.5, Math.floor(to.y) + 0.5); ctx.stroke(); ctx.globalAlpha = 1; } } /** * Bresenham's line algorithm for pixel-perfect lines */ private bresenhamLine(from: Point, to: Point, color: string, alpha: number = 1): void { let x0 = Math.floor(from.x); let y0 = Math.floor(from.y); const x1 = Math.floor(to.x); const y1 = Math.floor(to.y); const dx = Math.abs(x1 - x0); const dy = Math.abs(y1 - y0); const sx = x0 < x1 ? 1 : -1; const sy = y0 < y1 ? 1 : -1; let err = dx - dy; while (true) { this.pixel(x0, y0, color, alpha); if (x0 === x1 && y0 === y1) break; const e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } } /** * Draw a linear gradient */ linearGradient( gradient: LinearGradient, shape: Rectangle | Circle | Triangle, alpha: number = 1 ): void { const { ctx } = this; const grad = ctx.createLinearGradient(gradient.x1, gradient.y1, gradient.x2, gradient.y2); gradient.stops.forEach((stop) => { const color = stop.alpha !== undefined ? this.colorWithAlpha(stop.color, stop.alpha) : stop.color; grad.addColorStop(stop.offset, color); }); ctx.globalAlpha = alpha; ctx.fillStyle = grad; if ('width' in shape) { // Rectangle ctx.fillRect(shape.x, shape.y, shape.width, shape.height); } else if ('radius' in shape) { // Circle ctx.beginPath(); ctx.arc(shape.x, shape.y, shape.radius, 0, Math.PI * 2); ctx.fill(); } else { // Triangle ctx.beginPath(); ctx.moveTo(shape.p1.x, shape.p1.y); ctx.lineTo(shape.p2.x, shape.p2.y); ctx.lineTo(shape.p3.x, shape.p3.y); ctx.closePath(); ctx.fill(); } ctx.globalAlpha = 1; } /** * Draw a radial gradient */ radialGradient(gradient: RadialGradient, shape: Circle, alpha: number = 1): void { const { ctx } = this; const grad = ctx.createRadialGradient( gradient.cx, gradient.cy, 0, gradient.cx, gradient.cy, gradient.r ); gradient.stops.forEach((stop) => { const color = stop.alpha !== undefined ? this.colorWithAlpha(stop.color, stop.alpha) : stop.color; grad.addColorStop(stop.offset, color); }); ctx.globalAlpha = alpha; ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(shape.x, shape.y, shape.radius, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; } /** * Helper to add alpha to color string */ private colorWithAlpha(color: string, alpha: number): string { // If color is already rgba/rgb, convert it if (color.startsWith('rgba')) { return color.replace( /rgba?\([^)]+\)/, `rgba(${color.match(/\d+/g)?.slice(0, 3).join(',')},${alpha})` ); } if (color.startsWith('#')) { const r = parseInt(color.slice(1, 3), 16); const g = parseInt(color.slice(3, 5), 16); const b = parseInt(color.slice(5, 7), 16); return `rgba(${r},${g},${b},${alpha})`; } return color; } /** * Draw multiple layers */ drawLayers(layers: PixelArtLayer[]): void { layers.forEach((layer) => { if (layer.visible !== false) { this.ctx.save(); if (layer.opacity !== undefined) { this.ctx.globalAlpha = layer.opacity; } if (layer.blendMode) { this.ctx.globalCompositeOperation = layer.blendMode; } layer.draw(this.ctx); this.ctx.restore(); } }); } /** * Get color from palette with caching for performance */ color(colorName: string): string { // Check cache first if (this.colorCache[colorName]) { return this.colorCache[colorName]; } let result: string; if (this.palette) { result = this.palette.colors[colorName] || colorName; } else { result = colorName; } // Cache the result this.colorCache[colorName] = result; return result; } /** * Clear color cache (useful when palette changes) */ clearColorCache(): void { this.colorCache = {}; this.rgbaCache.clear(); } /** * Update palette and clear cache */ setPalette(palette: ColorPalette): void { this.palette = palette; this.clearColorCache(); } /** * Draw a sprite from 2D array data */ sprite(data: number[][], palette: string[]): void { data.forEach((row, y) => { row.forEach((pixel, x) => { if (pixel > 0 && pixel <= palette.length) { this.pixel(x, y, palette[pixel - 1]); } }); }); } /** * Draw a pattern * Optimized to batch pixel operations when possible */ pattern(pattern: (x: number, y: number) => string | null, bounds: Rectangle): void { const pixels: Array<{ x: number; y: number; color: string }> = []; const minX = Math.max(0, Math.floor(bounds.x)); const maxX = Math.min(this.config.width, Math.floor(bounds.x + bounds.width)); const minY = Math.max(0, Math.floor(bounds.y)); const maxY = Math.min(this.config.height, Math.floor(bounds.y + bounds.height)); for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { const color = pattern(x, y); if (color) { pixels.push({ x, y, color }); } } } // Batch draw pixels for better performance if (pixels.length > 100) { this.pixels(pixels); } else { // For small patterns, use individual pixel calls for (const pixel of pixels) { this.pixel(pixel.x, pixel.y, pixel.color); } } } /** * Draw a filled polygon using scanline algorithm * More efficient than triangle-by-triangle for complex shapes */ polygon(points: Point[], color: string, alpha: number = 1): void { if (points.length < 3) return; const { ctx } = this; ctx.globalAlpha = alpha; ctx.fillStyle = color; // Find bounding box let minX = points[0].x; let maxX = points[0].x; let minY = points[0].y; let maxY = points[0].y; for (const point of points) { minX = Math.min(minX, point.x); maxX = Math.max(maxX, point.x); minY = Math.min(minY, point.y); maxY = Math.max(maxY, point.y); } minX = Math.max(0, Math.floor(minX)); maxX = Math.min(this.config.width, Math.floor(maxX) + 1); minY = Math.max(0, Math.floor(minY)); maxY = Math.min(this.config.height, Math.floor(maxY) + 1); // Point-in-polygon test using ray casting const pointInPolygon = (x: number, y: number): boolean => { let inside = false; for (let i = 0, j = points.length - 1; i < points.length; j = i++) { const xi = points[i].x; const yi = points[i].y; const xj = points[j].x; const yj = points[j].y; const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; if (intersect) inside = !inside; } return inside; }; // Fill polygon pixel by pixel for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { if (pointInPolygon(x + 0.5, y + 0.5)) { ctx.fillRect(x, y, 1, 1); } } } ctx.globalAlpha = 1; } /** * Get current canvas as ImageData for further processing */ getImageData(): ImageData { return this.ctx.getImageData(0, 0, this.config.width, this.config.height); } /** * Draw ImageData to canvas (useful for effects and filters) */ putImageData(imageData: ImageData, dx: number = 0, dy: number = 0): void { this.ctx.putImageData(imageData, dx, dy); } }