Files
bitfielder/src/FakeShader.ts

629 lines
17 KiB
TypeScript

interface WorkerMessage {
id: string;
type: 'compile' | 'render';
code?: string;
width?: number;
height?: number;
time?: number;
renderMode?: string;
valueMode?: string;
hueShift?: number;
startY?: number; // Y offset for tile rendering
mouseX?: number;
mouseY?: number;
mousePressed?: boolean;
mouseVX?: number;
mouseVY?: number;
mouseClickTime?: number;
touchCount?: number;
touch0X?: number;
touch0Y?: number;
touch1X?: number;
touch1Y?: number;
pinchScale?: number;
pinchRotation?: number;
accelX?: number;
accelY?: number;
accelZ?: number;
gyroX?: number;
gyroY?: number;
gyroZ?: number;
audioLevel?: number;
bassLevel?: number;
midLevel?: number;
trebleLevel?: number;
bpm?: number;
}
interface WorkerResponse {
id: string;
type: 'compiled' | 'rendered' | 'error';
success: boolean;
imageData?: ImageData;
error?: string;
}
export class FakeShader {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private code: string;
private worker: Worker; // Single worker for backwards compatibility
private workers: Worker[] = [];
private workerCount: number;
private animationId: number | null = null;
private startTime: number = Date.now();
private isCompiled: boolean = false;
private isRendering: boolean = false;
private pendingRenders: string[] = [];
private renderMode: string = 'classic';
private valueMode: string = 'integer';
private hueShift: number = 0;
private timeSpeed: number = 1.0;
private currentBPM: number = 120;
// Multi-worker state
private tileResults: Map<number, ImageData> = new Map();
private tilesCompleted: number = 0;
private totalTiles: number = 0;
private mouseX: number = 0;
private mouseY: number = 0;
private mousePressed: boolean = false;
private mouseVX: number = 0;
private mouseVY: number = 0;
private mouseClickTime: number = 0;
private touchCount: number = 0;
private touch0X: number = 0;
private touch0Y: number = 0;
private touch1X: number = 0;
private touch1Y: number = 0;
private pinchScale: number = 1;
private pinchRotation: number = 0;
private accelX: number = 0;
private accelY: number = 0;
private accelZ: number = 0;
private gyroX: number = 0;
private gyroY: number = 0;
private gyroZ: number = 0;
private audioLevel: number = 0;
private bassLevel: number = 0;
private midLevel: number = 0;
private trebleLevel: number = 0;
// Frame rate limiting
private targetFPS: number = 30;
private frameInterval: number = 1000 / this.targetFPS;
private lastFrameTime: number = 0;
constructor(canvas: HTMLCanvasElement, code: string = 'x^y') {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.code = code;
// Initialize offscreen canvas if supported
this.initializeOffscreenCanvas();
// Always use maximum available cores
this.workerCount = navigator.hardwareConcurrency || 4;
// Some browsers report logical processors (hyperthreading), which is good
// But cap at a reasonable maximum to avoid overhead
this.workerCount = Math.min(this.workerCount, 32);
console.log(
`Auto-detected ${this.workerCount} CPU cores, using all for maximum performance`
);
// Initialize workers
this.initializeWorkers();
// Keep single worker reference for backwards compatibility
this.worker = this.workers[0];
this.compile();
}
private initializeOffscreenCanvas(): void {
if (typeof OffscreenCanvas !== 'undefined') {
try {
// this.offscreenCanvas = new OffscreenCanvas(this.canvas.width, this.canvas.height); // Removed unused
// this._offscreenCtx = this.offscreenCanvas.getContext('2d'); // Removed unused
// this._useOffscreen = this._offscreenCtx !== null; // Removed unused property
} catch (error) {
console.warn('OffscreenCanvas not supported:', error);
// this._useOffscreen = false; // Removed unused property
}
}
}
private initializeWorkers(): void {
// Create worker pool
for (let i = 0; i < this.workerCount; i++) {
const worker = new Worker(new URL('./ShaderWorker.ts', import.meta.url), {
type: 'module',
});
worker.onmessage = (e: MessageEvent<WorkerResponse>) =>
this.handleWorkerMessage(e.data, i);
worker.onerror = (error) => console.error(`Worker ${i} error:`, error);
this.workers.push(worker);
}
}
private handleWorkerMessage(
response: WorkerResponse,
workerIndex: number = 0
): void {
switch (response.type) {
case 'compiled':
this.isCompiled = response.success;
if (!response.success) {
console.error('Compilation failed:', response.error);
this.fillBlack();
}
break;
case 'rendered':
if (this.workerCount > 1) {
this.handleTileResult(response, workerIndex);
} else {
// Single worker mode
this.isRendering = false;
if (response.success && response.imageData) {
// Put ImageData directly on main canvas
this.ctx.putImageData(response.imageData, 0, 0);
} else {
console.error('Render failed:', response.error);
this.fillBlack();
}
}
// Process pending renders
if (this.pendingRenders.length > 0) {
this.pendingRenders.shift(); // Remove completed render
if (this.pendingRenders.length > 0) {
// Skip to latest render request
const latestId =
this.pendingRenders[this.pendingRenders.length - 1];
this.pendingRenders = [latestId];
this.executeRender(latestId);
}
}
break;
case 'error':
this.isRendering = false;
console.error('Worker error:', response.error);
this.fillBlack();
break;
}
}
private fillBlack(): void {
this.ctx.fillStyle = '#000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
private compile(): void {
this.isCompiled = false;
const id = `compile_${Date.now()}`;
// Send compile message to all workers
this.workers.forEach((worker) => {
worker.postMessage({
id,
type: 'compile',
code: this.code,
} as WorkerMessage);
});
}
private executeRender(id: string): void {
if (!this.isCompiled || this.isRendering) {
return;
}
this.isRendering = true;
// this._currentRenderID = id; // Removed unused property
const currentTime = (Date.now() - this.startTime) / 1000 * this.timeSpeed;
// Always use multiple workers if available
if (this.workerCount > 1) {
this.renderWithMultipleWorkers(id, currentTime);
} else {
this.renderWithSingleWorker(id, currentTime);
}
}
private renderWithSingleWorker(id: string, currentTime: number): void {
this.worker.postMessage({
id,
type: 'render',
width: this.canvas.width,
height: this.canvas.height,
fullWidth: this.canvas.width,
fullHeight: this.canvas.height,
time: currentTime,
renderMode: this.renderMode,
valueMode: this.valueMode,
hueShift: this.hueShift,
mouseX: this.mouseX,
mouseY: this.mouseY,
mousePressed: this.mousePressed,
mouseVX: this.mouseVX,
mouseVY: this.mouseVY,
mouseClickTime: this.mouseClickTime,
touchCount: this.touchCount,
touch0X: this.touch0X,
touch0Y: this.touch0Y,
touch1X: this.touch1X,
touch1Y: this.touch1Y,
pinchScale: this.pinchScale,
pinchRotation: this.pinchRotation,
accelX: this.accelX,
accelY: this.accelY,
accelZ: this.accelZ,
gyroX: this.gyroX,
gyroY: this.gyroY,
gyroZ: this.gyroZ,
audioLevel: this.audioLevel,
bassLevel: this.bassLevel,
midLevel: this.midLevel,
trebleLevel: this.trebleLevel,
bpm: this.currentBPM,
} as WorkerMessage);
}
private renderWithMultipleWorkers(id: string, currentTime: number): void {
// Reset tile tracking
this.tileResults.clear();
this.tilesCompleted = 0;
this.totalTiles = this.workerCount;
const width = this.canvas.width;
const height = this.canvas.height;
const tileHeight = Math.ceil(height / this.workerCount);
// Distribute tiles to workers
this.workers.forEach((worker, index) => {
const startY = index * tileHeight;
const endY = Math.min((index + 1) * tileHeight, height);
if (startY >= height) return; // Skip if tile is outside canvas
worker.postMessage({
id: `${id}_tile_${index}`,
type: 'render',
width: width,
height: endY - startY,
// Pass the Y offset for correct coordinate calculation
startY: startY,
// Pass full canvas dimensions for center calculations
fullWidth: width,
fullHeight: height,
time: currentTime,
renderMode: this.renderMode,
valueMode: this.valueMode,
hueShift: this.hueShift,
mouseX: this.mouseX,
mouseY: this.mouseY,
mousePressed: this.mousePressed,
mouseVX: this.mouseVX,
mouseVY: this.mouseVY,
mouseClickTime: this.mouseClickTime,
touchCount: this.touchCount,
touch0X: this.touch0X,
touch0Y: this.touch0Y,
touch1X: this.touch1X,
touch1Y: this.touch1Y,
pinchScale: this.pinchScale,
pinchRotation: this.pinchRotation,
accelX: this.accelX,
accelY: this.accelY,
accelZ: this.accelZ,
gyroX: this.gyroX,
gyroY: this.gyroY,
gyroZ: this.gyroZ,
audioLevel: this.audioLevel,
bassLevel: this.bassLevel,
midLevel: this.midLevel,
trebleLevel: this.trebleLevel,
bpm: this.currentBPM,
} as WorkerMessage);
});
}
setCode(code: string): void {
this.code = code;
this.compile();
}
render(animate: boolean = false): void {
const currentTime = performance.now();
// Frame rate limiting
if (animate && currentTime - this.lastFrameTime < this.frameInterval) {
if (animate) {
this.animationId = requestAnimationFrame(() => this.render(true));
}
return;
}
this.lastFrameTime = currentTime;
if (!this.isCompiled) {
this.fillBlack();
if (animate) {
this.animationId = requestAnimationFrame(() => this.render(true));
}
return;
}
const renderId = `render_${Date.now()}_${Math.random()}`;
// Add to pending renders queue
this.pendingRenders.push(renderId);
// If not currently rendering, start immediately
if (!this.isRendering) {
this.executeRender(renderId);
}
// Continue animation
if (animate) {
this.animationId = requestAnimationFrame(() => this.render(true));
}
}
startAnimation(): void {
this.stopAnimation();
this.startTime = Date.now();
this.lastFrameTime = 0; // Reset frame timing
this.render(true);
}
stopAnimation(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
// Clear pending renders
this.pendingRenders = [];
}
setTargetFPS(fps: number): void {
this.targetFPS = Math.max(1, Math.min(120, fps)); // Clamp between 1-120 FPS
this.frameInterval = 1000 / this.targetFPS;
}
setRenderMode(mode: string): void {
this.renderMode = mode;
}
setValueMode(mode: string): void {
this.valueMode = mode;
}
setHueShift(shift: number): void {
this.hueShift = shift;
}
setTimeSpeed(speed: number): void {
this.timeSpeed = speed;
}
setBPM(bpm: number): void {
this.currentBPM = bpm;
}
setMousePosition(
x: number,
y: number,
pressed: boolean = false,
vx: number = 0,
vy: number = 0,
clickTime: number = 0
): void {
this.mouseX = x;
this.mouseY = y;
this.mousePressed = pressed;
this.mouseVX = vx;
this.mouseVY = vy;
this.mouseClickTime = clickTime;
}
setTouchPosition(
count: number,
x0: number = 0,
y0: number = 0,
x1: number = 0,
y1: number = 0,
scale: number = 1,
rotation: number = 0
): void {
this.touchCount = count;
this.touch0X = x0;
this.touch0Y = y0;
this.touch1X = x1;
this.touch1Y = y1;
this.pinchScale = scale;
this.pinchRotation = rotation;
}
setDeviceMotion(
ax: number,
ay: number,
az: number,
gx: number,
gy: number,
gz: number
): void {
this.accelX = ax;
this.accelY = ay;
this.accelZ = az;
this.gyroX = gx;
this.gyroY = gy;
this.gyroZ = gz;
}
setAudioData(level: number, bass: number, mid: number, treble: number): void {
this.audioLevel = level;
this.bassLevel = bass;
this.midLevel = mid;
this.trebleLevel = treble;
}
destroy(): void {
this.stopAnimation();
this.workers.forEach((worker) => worker.terminate());
}
private handleTileResult(
response: WorkerResponse,
workerIndex: number
): void {
if (!response.success || !response.imageData) {
console.error(
`Tile render failed for worker ${workerIndex}:`,
response.error
);
return;
}
// Store tile result
this.tileResults.set(workerIndex, response.imageData);
this.tilesCompleted++;
// Check if all tiles are complete
if (this.tilesCompleted === this.totalTiles) {
this.compositeTiles();
}
}
private async compositeTiles(): Promise<void> {
const height = this.canvas.height;
const tileHeight = Math.ceil(height / this.workerCount);
// Use ImageBitmap for faster compositing if available
if (typeof createImageBitmap !== 'undefined') {
try {
const bitmapPromises: Promise<ImageBitmap>[] = [];
const positions: number[] = [];
for (let i = 0; i < this.workerCount; i++) {
const tileData = this.tileResults.get(i);
if (tileData) {
bitmapPromises.push(createImageBitmap(tileData));
positions.push(i * tileHeight);
}
}
const bitmaps = await Promise.all(bitmapPromises);
for (let i = 0; i < bitmaps.length; i++) {
this.ctx.drawImage(bitmaps[i], 0, positions[i]);
bitmaps[i].close(); // Free memory
}
} catch (error) {
// Fallback to putImageData if ImageBitmap fails
this.fallbackCompositeTiles();
}
} else {
this.fallbackCompositeTiles();
}
// Clear tile results
this.tileResults.clear();
// Mark rendering as complete
this.isRendering = false;
// Process pending renders
if (this.pendingRenders.length > 0) {
this.pendingRenders.shift();
if (this.pendingRenders.length > 0) {
const latestId = this.pendingRenders[this.pendingRenders.length - 1];
this.pendingRenders = [latestId];
this.executeRender(latestId);
}
}
}
private fallbackCompositeTiles(): void {
const tileHeight = Math.ceil(this.canvas.height / this.workerCount);
for (let i = 0; i < this.workerCount; i++) {
const tileData = this.tileResults.get(i);
if (tileData) {
const startY = i * tileHeight;
this.ctx.putImageData(tileData, 0, startY);
}
}
}
// Simplified method - kept for backward compatibility but always uses all cores
setMultiWorkerMode(_enabled: boolean, _workerCount?: number): void {
// Always use all available cores, ignore the enabled parameter
console.log(
`Multi-worker mode is always enabled, using ${this.workerCount} cores for maximum performance`
);
}
getWorkerCount(): number {
return this.workerCount;
}
static generateRandomCode(): string {
const presets = [
'x^y',
'x&y',
'x|y',
'(x*y)%256',
'(x+y+t*10)%256',
'((x>>4)^(y>>4))<<4',
'(x^y^(x*y))%256',
'((x&y)|(x^y))%256',
'(x+y)&255',
'x%y',
'(x^(y<<2))%256',
'((x*t)^y)%256',
'(x&(y|t*8))%256',
'((x>>2)|(y<<2))%256',
'(x*y*t)%256',
'(x+y*t)%256',
'(x^y^(t*16))%256',
'((x*t)&(y*t))%256',
'(x+(y<<(t%4)))%256',
'((x*t%128)^y)%256',
'(x^(y*t*2))%256',
'((x+t)*(y+t))%256',
'(x&y&(t*8))%256',
'((x|t)^(y|t))%256',
];
const vars = ['x', 'y', 't', 'i'];
const ops = ['^', '&', '|', '+', '-', '*', '%'];
const shifts = ['<<', '>>'];
const numbers = ['2', '4', '8', '16', '32', '64', '128', '256'];
const randomChoice = <T>(arr: T[]): T =>
arr[Math.floor(Math.random() * arr.length)];
const dynamicExpressions = [
() => `${randomChoice(vars)}${randomChoice(ops)}${randomChoice(vars)}`,
() =>
`(${randomChoice(vars)}${randomChoice(ops)}${randomChoice(vars)})%${randomChoice(numbers)}`,
() =>
`${randomChoice(vars)}${randomChoice(shifts)}${Math.floor(Math.random() * 8)}`,
() =>
`(${randomChoice(vars)}*${randomChoice(vars)})%${randomChoice(numbers)}`,
() => `${randomChoice(vars)}^${randomChoice(vars)}^${randomChoice(vars)}`,
];
// 70% chance to pick from presets, 30% chance to generate dynamic
if (Math.random() < 0.7) {
return randomChoice(presets);
} else {
return randomChoice(dynamicExpressions)();
}
}
}