From f7054d8300a0bc238476af1fa587587913ad4fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sat, 5 Jul 2025 23:02:15 +0200 Subject: [PATCH] some easy wins --- src/FakeShader.ts | 165 +++++++++++++++-- src/ShaderWorker.ts | 422 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 508 insertions(+), 79 deletions(-) diff --git a/src/FakeShader.ts b/src/FakeShader.ts index 61bb69d..8e2ae47 100644 --- a/src/FakeShader.ts +++ b/src/FakeShader.ts @@ -50,6 +50,21 @@ export class FakeShader { private isRendering: boolean = false; private pendingRenders: string[] = []; private renderMode: string = 'classic'; + private offscreenCanvas: OffscreenCanvas | null = null; + private offscreenCtx: OffscreenCanvasRenderingContext2D | null = null; + private useOffscreen: boolean = false; + + // Adaptive resolution scaling + private adaptiveCanvas: HTMLCanvasElement; + private adaptiveCtx: CanvasRenderingContext2D; + private currentScale: number = 1.0; + private targetRenderTime: number = 16; // Target 60 FPS + private performanceHistory: number[] = []; + private lastScaleAdjustment: number = 0; + private minScale: number = 0.25; + private maxScale: number = 1.0; + private renderStartTime: number = 0; + private mouseX: number = 0; private mouseY: number = 0; private mousePressed: boolean = false; @@ -84,6 +99,12 @@ export class FakeShader { this.ctx = canvas.getContext('2d')!; this.code = code; + // Initialize adaptive resolution canvas + this.initializeAdaptiveCanvas(); + + // Initialize offscreen canvas if supported + this.initializeOffscreenCanvas(); + // Initialize worker this.worker = new Worker(new URL('./ShaderWorker.ts', import.meta.url), { type: 'module' }); this.worker.onmessage = (e: MessageEvent) => this.handleWorkerMessage(e.data); @@ -92,6 +113,35 @@ export class FakeShader { this.compile(); } + private initializeAdaptiveCanvas(): void { + this.adaptiveCanvas = document.createElement('canvas'); + this.adaptiveCtx = this.adaptiveCanvas.getContext('2d')!; + this.updateAdaptiveCanvasSize(); + } + + private updateAdaptiveCanvasSize(): void { + const scaledWidth = Math.floor(this.canvas.width * this.currentScale); + const scaledHeight = Math.floor(this.canvas.height * this.currentScale); + + if (this.adaptiveCanvas.width !== scaledWidth || this.adaptiveCanvas.height !== scaledHeight) { + this.adaptiveCanvas.width = scaledWidth; + this.adaptiveCanvas.height = scaledHeight; + } + } + + private initializeOffscreenCanvas(): void { + if (typeof OffscreenCanvas !== 'undefined') { + try { + this.offscreenCanvas = new OffscreenCanvas(this.canvas.width, this.canvas.height); + this.offscreenCtx = this.offscreenCanvas.getContext('2d'); + this.useOffscreen = this.offscreenCtx !== null; + } catch (error) { + console.warn('OffscreenCanvas not supported:', error); + this.useOffscreen = false; + } + } + } + private handleWorkerMessage(response: WorkerResponse): void { switch (response.type) { case 'compiled': @@ -105,7 +155,14 @@ export class FakeShader { case 'rendered': this.isRendering = false; if (response.success && response.imageData) { - this.ctx.putImageData(response.imageData, 0, 0); + // Put ImageData on adaptive resolution canvas + this.adaptiveCtx.putImageData(response.imageData, 0, 0); + + // Upscale to main canvas with proper interpolation + this.upscaleToMainCanvas(); + + // Monitor performance and adjust scale + this.updatePerformanceMetrics(); } else { console.error('Render failed:', response.error); this.fillBlack(); @@ -151,27 +208,37 @@ export class FakeShader { return; } + // Update adaptive canvas size based on current scale + this.updateAdaptiveCanvasSize(); + + // Start performance timing + this.renderStartTime = performance.now(); + this.isRendering = true; const currentTime = (Date.now() - this.startTime) / 1000; + // Scale mouse coordinates to match render resolution + const scaledMouseX = this.mouseX * this.currentScale; + const scaledMouseY = this.mouseY * this.currentScale; + this.worker.postMessage({ id, type: 'render', - width: this.canvas.width, - height: this.canvas.height, + width: this.adaptiveCanvas.width, + height: this.adaptiveCanvas.height, time: currentTime, renderMode: this.renderMode, - mouseX: this.mouseX, - mouseY: this.mouseY, + mouseX: scaledMouseX, + mouseY: scaledMouseY, mousePressed: this.mousePressed, - mouseVX: this.mouseVX, - mouseVY: this.mouseVY, + mouseVX: this.mouseVX * this.currentScale, + mouseVY: this.mouseVY * this.currentScale, mouseClickTime: this.mouseClickTime, touchCount: this.touchCount, - touch0X: this.touch0X, - touch0Y: this.touch0Y, - touch1X: this.touch1X, - touch1Y: this.touch1Y, + touch0X: this.touch0X * this.currentScale, + touch0Y: this.touch0Y * this.currentScale, + touch1X: this.touch1X * this.currentScale, + touch1Y: this.touch1Y * this.currentScale, pinchScale: this.pinchScale, pinchRotation: this.pinchRotation, accelX: this.accelX, @@ -295,6 +362,82 @@ export class FakeShader { this.worker.terminate(); } + private upscaleToMainCanvas(): void { + // Clear main canvas + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + // Set interpolation based on scale + if (this.currentScale < 0.5) { + // Use smooth interpolation for heavily downscaled content + this.ctx.imageSmoothingEnabled = true; + this.ctx.imageSmoothingQuality = 'high'; + } else { + // Use pixel-perfect scaling for minimal downscaling + this.ctx.imageSmoothingEnabled = false; + } + + // Draw scaled content to main canvas + this.ctx.drawImage( + this.adaptiveCanvas, + 0, 0, this.adaptiveCanvas.width, this.adaptiveCanvas.height, + 0, 0, this.canvas.width, this.canvas.height + ); + } + + private updatePerformanceMetrics(): void { + const renderTime = performance.now() - this.renderStartTime; + + // Add to performance history + this.performanceHistory.push(renderTime); + if (this.performanceHistory.length > 10) { + this.performanceHistory.shift(); // Keep only last 10 measurements + } + + // Adjust scale if we have enough data and enough time has passed + const now = performance.now(); + if (this.performanceHistory.length >= 3 && now - this.lastScaleAdjustment > 500) { + this.adjustRenderScale(); + this.lastScaleAdjustment = now; + } + } + + private adjustRenderScale(): void { + // Calculate average render time from recent history + const avgRenderTime = this.performanceHistory.reduce((a, b) => a + b, 0) / this.performanceHistory.length; + + const tolerance = 2; // 2ms tolerance + + if (avgRenderTime > this.targetRenderTime + tolerance) { + // Too slow - scale down + const newScale = Math.max(this.minScale, this.currentScale * 0.85); + if (newScale !== this.currentScale) { + this.currentScale = newScale; + console.log(`Scaling down to ${(this.currentScale * 100).toFixed(0)}% (${avgRenderTime.toFixed(1)}ms avg)`); + } + } else if (avgRenderTime < this.targetRenderTime - tolerance && this.currentScale < this.maxScale) { + // Fast enough - try scaling up + const newScale = Math.min(this.maxScale, this.currentScale * 1.1); + if (newScale !== this.currentScale) { + this.currentScale = newScale; + console.log(`Scaling up to ${(this.currentScale * 100).toFixed(0)}% (${avgRenderTime.toFixed(1)}ms avg)`); + } + } + } + + setAdaptiveQuality(enabled: boolean, targetFPS: number = 60): void { + if (enabled) { + this.targetRenderTime = 1000 / targetFPS; + this.currentScale = 1.0; + this.performanceHistory = []; + } else { + this.currentScale = 1.0; + } + } + + getCurrentScale(): number { + return this.currentScale; + } + static generateRandomCode(): string { const presets = [ 'x^y', diff --git a/src/ShaderWorker.ts b/src/ShaderWorker.ts index c17a90e..d895b24 100644 --- a/src/ShaderWorker.ts +++ b/src/ShaderWorker.ts @@ -43,11 +43,68 @@ interface WorkerResponse { class ShaderWorker { private compiledFunction: Function | null = null; private lastCode: string = ''; + private mathCache: Map = new Map(); + private sinTable: Float32Array; + private cosTable: Float32Array; + private expTable: Float32Array; + private logTable: Float32Array; + private imageDataCache: Map = new Map(); + private compilationCache: Map = new Map(); + private colorTables: Map = new Map(); constructor() { self.onmessage = (e: MessageEvent) => { this.handleMessage(e.data); }; + + this.initializeLookupTables(); + this.initializeColorTables(); + } + + private initializeLookupTables(): void { + const tableSize = 4096; + this.sinTable = new Float32Array(tableSize); + this.cosTable = new Float32Array(tableSize); + this.expTable = new Float32Array(tableSize); + this.logTable = new Float32Array(tableSize); + + for (let i = 0; i < tableSize; i++) { + const x = (i / tableSize) * 2 * Math.PI; + this.sinTable[i] = Math.sin(x); + this.cosTable[i] = Math.cos(x); + this.expTable[i] = Math.exp(x / tableSize); + this.logTable[i] = Math.log(1 + x / tableSize); + } + } + + private initializeColorTables(): void { + const tableSize = 256; + + // Pre-compute color tables for each render mode + const modes = ['classic', 'grayscale', 'red', 'green', 'blue', 'rgb', 'hsv', 'rainbow']; + + for (const mode of modes) { + const colorTable = new Uint8Array(tableSize * 3); // RGB triplets + + for (let i = 0; i < tableSize; i++) { + const [r, g, b] = this.calculateColorDirect(i, mode); + colorTable[i * 3] = r; + colorTable[i * 3 + 1] = g; + colorTable[i * 3 + 2] = b; + } + + this.colorTables.set(mode, colorTable); + } + } + + private fastSin(x: number): number { + const index = Math.floor(Math.abs(x * this.sinTable.length / (2 * Math.PI)) % this.sinTable.length); + return this.sinTable[index]; + } + + private fastCos(x: number): number { + const index = Math.floor(Math.abs(x * this.cosTable.length / (2 * Math.PI)) % this.cosTable.length); + return this.cosTable[index]; } private handleMessage(message: WorkerMessage): void { @@ -66,30 +123,60 @@ class ShaderWorker { } private compileShader(id: string, code: string): void { + const codeHash = this.hashCode(code); + if (code === this.lastCode && this.compiledFunction) { this.postMessage({ id, type: 'compiled', success: true }); return; } + // Check compilation cache + const cachedFunction = this.compilationCache.get(codeHash); + if (cachedFunction) { + this.compiledFunction = cachedFunction; + this.lastCode = code; + this.postMessage({ id, type: 'compiled', success: true }); + return; + } + try { const safeCode = this.sanitizeCode(code); - this.compiledFunction = new Function('x', 'y', 't', 'i', 'mouseX', 'mouseY', 'mousePressed', 'mouseVX', 'mouseVY', 'mouseClickTime', 'touchCount', 'touch0X', 'touch0Y', 'touch1X', 'touch1Y', 'pinchScale', 'pinchRotation', 'accelX', 'accelY', 'accelZ', 'gyroX', 'gyroY', 'gyroZ', 'audioLevel', 'bassLevel', 'midLevel', 'trebleLevel', ` - // Timeout protection - const startTime = performance.now(); - let iterations = 0; - - function checkTimeout() { - iterations++; - if (iterations % 1000 === 0 && performance.now() - startTime > 5) { - throw new Error('Shader timeout'); + + // Check if expression is static (contains no variables) + const isStatic = this.isStaticExpression(safeCode); + + if (isStatic) { + // Pre-compute static value + const staticValue = this.evaluateStaticExpression(safeCode); + this.compiledFunction = () => staticValue; + } else { + this.compiledFunction = new Function('x', 'y', 't', 'i', 'mouseX', 'mouseY', 'mousePressed', 'mouseVX', 'mouseVY', 'mouseClickTime', 'touchCount', 'touch0X', 'touch0Y', 'touch1X', 'touch1Y', 'pinchScale', 'pinchRotation', 'accelX', 'accelY', 'accelZ', 'gyroX', 'gyroY', 'gyroZ', 'audioLevel', 'bassLevel', 'midLevel', 'trebleLevel', ` + // Timeout protection + const startTime = performance.now(); + let iterations = 0; + + function checkTimeout() { + iterations++; + if (iterations % 1000 === 0 && performance.now() - startTime > 5) { + throw new Error('Shader timeout'); + } } - } - - return (function() { - checkTimeout(); - return ${safeCode}; - })(); - `); + + return (function() { + checkTimeout(); + return ${safeCode}; + })(); + `); + } + + // Cache the compiled function + this.compilationCache.set(codeHash, this.compiledFunction); + + // Limit cache size to prevent memory bloat + if (this.compilationCache.size > 20) { + const firstKey = this.compilationCache.keys().next().value; + this.compilationCache.delete(firstKey); + } this.lastCode = code; this.postMessage({ id, type: 'compiled', success: true }); @@ -99,78 +186,277 @@ class ShaderWorker { } } + private isStaticExpression(code: string): boolean { + // Check if code contains any variables + const variables = ['x', 'y', 't', 'i', 'mouseX', 'mouseY', 'mousePressed', 'mouseVX', 'mouseVY', 'mouseClickTime', 'touchCount', 'touch0X', 'touch0Y', 'touch1X', 'touch1Y', 'pinchScale', 'pinchRotation', 'accelX', 'accelY', 'accelZ', 'gyroX', 'gyroY', 'gyroZ', 'audioLevel', 'bassLevel', 'midLevel', 'trebleLevel']; + + for (const variable of variables) { + if (code.includes(variable)) { + return false; + } + } + + return true; + } + + private evaluateStaticExpression(code: string): number { + try { + // Safely evaluate numeric expression + const result = new Function(`return ${code}`)(); + return isFinite(result) ? result : 0; + } catch (error) { + return 0; + } + } + + private hashCode(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return hash.toString(36); + } + private renderShader(id: string, width: number, height: number, time: number, renderMode: string, message: WorkerMessage): void { if (!this.compiledFunction) { this.postError(id, 'No compiled shader'); return; } - const imageData = new ImageData(width, height); + const imageData = this.getOrCreateImageData(width, height); const data = imageData.data; const startTime = performance.now(); const maxRenderTime = 50; // 50ms max render time try { - for (let y = 0; y < height; y++) { - // Check timeout every row - if (performance.now() - startTime > maxRenderTime) { - // Fill remaining pixels with black and break - for (let remainingY = y; remainingY < height; remainingY++) { - for (let remainingX = 0; remainingX < width; remainingX++) { - const i = (remainingY * width + remainingX) * 4; - data[i] = 0; // R - data[i + 1] = 0; // G - data[i + 2] = 0; // B - data[i + 3] = 255; // A - } - } - break; - } - - for (let x = 0; x < width; x++) { - const i = (y * width + x) * 4; - const pixelIndex = y * width + x; - - try { - const value = this.compiledFunction( - x, y, time, pixelIndex, - message.mouseX || 0, message.mouseY || 0, - message.mousePressed || false, - message.mouseVX || 0, message.mouseVY || 0, - message.mouseClickTime || 0, - message.touchCount || 0, - message.touch0X || 0, message.touch0Y || 0, - message.touch1X || 0, message.touch1Y || 0, - message.pinchScale || 1, message.pinchRotation || 0, - message.accelX || 0, message.accelY || 0, message.accelZ || 0, - message.gyroX || 0, message.gyroY || 0, message.gyroZ || 0, - message.audioLevel || 0, message.bassLevel || 0, message.midLevel || 0, message.trebleLevel || 0 - ); - const safeValue = isFinite(value) ? value : 0; - const [r, g, b] = this.calculateColor(safeValue, renderMode); - - data[i] = r; // R - data[i + 1] = g; // G - data[i + 2] = b; // B - data[i + 3] = 255; // A - } catch (error) { - data[i] = 0; // R - data[i + 1] = 0; // G - data[i + 2] = 0; // B - data[i + 3] = 255; // A - } - } - } - + // Use tiled rendering for better timeout handling + this.renderTiled(data, width, height, time, renderMode, message, startTime, maxRenderTime); this.postMessage({ id, type: 'rendered', success: true, imageData }); } catch (error) { this.postError(id, error instanceof Error ? error.message : 'Render failed'); } } + private renderTiled(data: Uint8ClampedArray, width: number, height: number, time: number, renderMode: string, message: WorkerMessage, startTime: number, maxRenderTime: number): void { + const tileSize = 64; // 64x64 tiles for better granularity + const tilesX = Math.ceil(width / tileSize); + const tilesY = Math.ceil(height / tileSize); + + for (let tileY = 0; tileY < tilesY; tileY++) { + for (let tileX = 0; tileX < tilesX; tileX++) { + // Check timeout before each tile + if (performance.now() - startTime > maxRenderTime) { + const startX = tileX * tileSize; + const startY = tileY * tileSize; + this.fillRemainingPixels(data, width, height, startY, startX); + return; + } + + const tileStartX = tileX * tileSize; + const tileStartY = tileY * tileSize; + const tileEndX = Math.min(tileStartX + tileSize, width); + const tileEndY = Math.min(tileStartY + tileSize, height); + + this.renderTile(data, width, tileStartX, tileStartY, tileEndX, tileEndY, time, renderMode, message); + } + } + } + + private renderTile(data: Uint8ClampedArray, width: number, startX: number, startY: number, endX: number, endY: number, time: number, renderMode: string, message: WorkerMessage): void { + for (let y = startY; y < endY; y++) { + for (let x = startX; x < endX; x++) { + const i = (y * width + x) * 4; + const pixelIndex = y * width + x; + + try { + const value = this.compiledFunction!( + x, y, time, pixelIndex, + message.mouseX || 0, message.mouseY || 0, + message.mousePressed || false, + message.mouseVX || 0, message.mouseVY || 0, + message.mouseClickTime || 0, + message.touchCount || 0, + message.touch0X || 0, message.touch0Y || 0, + message.touch1X || 0, message.touch1Y || 0, + message.pinchScale || 1, message.pinchRotation || 0, + message.accelX || 0, message.accelY || 0, message.accelZ || 0, + message.gyroX || 0, message.gyroY || 0, message.gyroZ || 0, + message.audioLevel || 0, message.bassLevel || 0, message.midLevel || 0, message.trebleLevel || 0 + ); + const safeValue = isFinite(value) ? value : 0; + const [r, g, b] = this.calculateColor(safeValue, renderMode); + + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + data[i + 3] = 255; + } catch (error) { + data[i] = 0; + data[i + 1] = 0; + data[i + 2] = 0; + data[i + 3] = 255; + } + } + } + } + + private canUseSIMD(): boolean { + return typeof WebAssembly !== 'undefined' && WebAssembly.validate && + new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]).every((byte, i) => byte === [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00][i]); + } + + private renderWithSIMD(data: Uint8ClampedArray, width: number, height: number, time: number, renderMode: string, message: WorkerMessage, startTime: number, maxRenderTime: number): void { + const chunkSize = 4; // Process 4 pixels at once + + for (let y = 0; y < height; y++) { + if (performance.now() - startTime > maxRenderTime) { + this.fillRemainingPixels(data, width, height, y, 0); + break; + } + + for (let x = 0; x < width; x += chunkSize) { + const endX = Math.min(x + chunkSize, width); + const xValues = new Float32Array(chunkSize); + const yValues = new Float32Array(chunkSize); + const results = new Float32Array(chunkSize); + + for (let i = 0; i < endX - x; i++) { + xValues[i] = x + i; + yValues[i] = y; + } + + this.computeChunkSIMD(xValues, yValues, results, endX - x, time, message); + + for (let i = 0; i < endX - x; i++) { + const pixelX = x + i; + const pixelI = (y * width + pixelX) * 4; + const safeValue = isFinite(results[i]) ? results[i] : 0; + const [r, g, b] = this.calculateColor(safeValue, renderMode); + + data[pixelI] = r; + data[pixelI + 1] = g; + data[pixelI + 2] = b; + data[pixelI + 3] = 255; + } + } + } + } + + private computeChunkSIMD(xValues: Float32Array, yValues: Float32Array, results: Float32Array, count: number, time: number, message: WorkerMessage): void { + for (let i = 0; i < count; i++) { + try { + const pixelIndex = yValues[i] * (xValues.length / count) + xValues[i]; + results[i] = this.compiledFunction!( + xValues[i], yValues[i], time, pixelIndex, + message.mouseX || 0, message.mouseY || 0, + message.mousePressed || false, + message.mouseVX || 0, message.mouseVY || 0, + message.mouseClickTime || 0, + message.touchCount || 0, + message.touch0X || 0, message.touch0Y || 0, + message.touch1X || 0, message.touch1Y || 0, + message.pinchScale || 1, message.pinchRotation || 0, + message.accelX || 0, message.accelY || 0, message.accelZ || 0, + message.gyroX || 0, message.gyroY || 0, message.gyroZ || 0, + message.audioLevel || 0, message.bassLevel || 0, message.midLevel || 0, message.trebleLevel || 0 + ); + } catch (error) { + results[i] = 0; + } + } + } + + private renderSerial(data: Uint8ClampedArray, width: number, height: number, time: number, renderMode: string, message: WorkerMessage, startTime: number, maxRenderTime: number): void { + for (let y = 0; y < height; y++) { + if (performance.now() - startTime > maxRenderTime) { + this.fillRemainingPixels(data, width, height, y, 0); + break; + } + + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + const pixelIndex = y * width + x; + + try { + const value = this.compiledFunction!( + x, y, time, pixelIndex, + message.mouseX || 0, message.mouseY || 0, + message.mousePressed || false, + message.mouseVX || 0, message.mouseVY || 0, + message.mouseClickTime || 0, + message.touchCount || 0, + message.touch0X || 0, message.touch0Y || 0, + message.touch1X || 0, message.touch1Y || 0, + message.pinchScale || 1, message.pinchRotation || 0, + message.accelX || 0, message.accelY || 0, message.accelZ || 0, + message.gyroX || 0, message.gyroY || 0, message.gyroZ || 0, + message.audioLevel || 0, message.bassLevel || 0, message.midLevel || 0, message.trebleLevel || 0 + ); + const safeValue = isFinite(value) ? value : 0; + const [r, g, b] = this.calculateColor(safeValue, renderMode); + + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + data[i + 3] = 255; + } catch (error) { + data[i] = 0; + data[i + 1] = 0; + data[i + 2] = 0; + data[i + 3] = 255; + } + } + } + } + + private fillRemainingPixels(data: Uint8ClampedArray, width: number, height: number, startY: number, startX: number): void { + for (let remainingY = startY; remainingY < height; remainingY++) { + const xStart = remainingY === startY ? startX : 0; + for (let remainingX = xStart; remainingX < width; remainingX++) { + const i = (remainingY * width + remainingX) * 4; + data[i] = 0; + data[i + 1] = 0; + data[i + 2] = 0; + data[i + 3] = 255; + } + } + } + + private getOrCreateImageData(width: number, height: number): ImageData { + const key = `${width}x${height}`; + let imageData = this.imageDataCache.get(key); + + if (!imageData) { + imageData = new ImageData(width, height); + this.imageDataCache.set(key, imageData); + + // Limit cache size to prevent memory bloat + if (this.imageDataCache.size > 5) { + const firstKey = this.imageDataCache.keys().next().value; + this.imageDataCache.delete(firstKey); + } + } + + return imageData; + } + private calculateColor(value: number, renderMode: string): [number, number, number] { const absValue = Math.abs(value) % 256; + // Use pre-computed color table if available + const colorTable = this.colorTables.get(renderMode); + if (colorTable) { + const index = Math.floor(absValue) * 3; + return [colorTable[index], colorTable[index + 1], colorTable[index + 2]]; + } + + // Fallback to direct calculation + return this.calculateColorDirect(absValue, renderMode); + } + + private calculateColorDirect(absValue: number, renderMode: string): [number, number, number] { switch (renderMode) { case 'classic': return [