629 lines
17 KiB
TypeScript
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)();
|
|
}
|
|
}
|
|
}
|