diff --git a/index.html b/index.html
index 1d698f0..44d2a2a 100644
--- a/index.html
+++ b/index.html
@@ -401,11 +401,26 @@
+
+
+
@@ -464,6 +479,21 @@
<< >> - Bit shift left/right
+ - * / % - Math operations
+
+
+
Render Modes
+
Classic - Original colorful mode
+
Grayscale - Black and white
+
Red/Green/Blue - Single color channels
+
HSV - Hue-based coloring
+
Rainbow - Spectrum coloring
+
+
+
+
Export
+
Export PNG - Save current frame as image
+
Export GIF - Save 2-second animation (if animated)
+
diff --git a/src/FakeShader.ts b/src/FakeShader.ts
index 89cabc6..a6419f5 100644
--- a/src/FakeShader.ts
+++ b/src/FakeShader.ts
@@ -5,6 +5,7 @@ interface WorkerMessage {
width?: number;
height?: number;
time?: number;
+ renderMode?: string;
}
interface WorkerResponse {
@@ -25,6 +26,7 @@ export class FakeShader {
private isCompiled: boolean = false;
private isRendering: boolean = false;
private pendingRenders: string[] = [];
+ private renderMode: string = 'classic';
// Frame rate limiting
private targetFPS: number = 30;
@@ -111,7 +113,8 @@ export class FakeShader {
type: 'render',
width: this.canvas.width,
height: this.canvas.height,
- time: currentTime
+ time: currentTime,
+ renderMode: this.renderMode
} as WorkerMessage);
}
@@ -179,6 +182,10 @@ export class FakeShader {
this.frameInterval = 1000 / this.targetFPS;
}
+ setRenderMode(mode: string): void {
+ this.renderMode = mode;
+ }
+
destroy(): void {
this.stopAnimation();
this.worker.terminate();
diff --git a/src/ShaderWorker.ts b/src/ShaderWorker.ts
index 17fc44f..0bc34cd 100644
--- a/src/ShaderWorker.ts
+++ b/src/ShaderWorker.ts
@@ -6,6 +6,7 @@ interface WorkerMessage {
width?: number;
height?: number;
time?: number;
+ renderMode?: string;
}
interface WorkerResponse {
@@ -33,7 +34,7 @@ class ShaderWorker {
this.compileShader(message.id, message.code!);
break;
case 'render':
- this.renderShader(message.id, message.width!, message.height!, message.time!);
+ this.renderShader(message.id, message.width!, message.height!, message.time!, message.renderMode || 'classic');
break;
}
} catch (error) {
@@ -75,7 +76,7 @@ class ShaderWorker {
}
}
- private renderShader(id: string, width: number, height: number, time: number): void {
+ private renderShader(id: string, width: number, height: number, time: number, renderMode: string): void {
if (!this.compiledFunction) {
this.postError(id, 'No compiled shader');
return;
@@ -110,12 +111,12 @@ class ShaderWorker {
try {
const value = this.compiledFunction(x, y, time, pixelIndex);
const safeValue = isFinite(value) ? value : 0;
- const color = Math.abs(safeValue) % 256;
+ const [r, g, b] = this.calculateColor(safeValue, renderMode);
- data[i] = color; // R
- data[i + 1] = (color * 2) % 256; // G
- data[i + 2] = (color * 3) % 256; // B
- data[i + 3] = 255; // A
+ 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
@@ -131,6 +132,93 @@ class ShaderWorker {
}
}
+ private calculateColor(value: number, renderMode: string): [number, number, number] {
+ const absValue = Math.abs(value) % 256;
+
+ switch (renderMode) {
+ case 'classic':
+ return [
+ absValue,
+ (absValue * 2) % 256,
+ (absValue * 3) % 256
+ ];
+
+ case 'grayscale':
+ return [absValue, absValue, absValue];
+
+ case 'red':
+ return [absValue, 0, 0];
+
+ case 'green':
+ return [0, absValue, 0];
+
+ case 'blue':
+ return [0, 0, absValue];
+
+ case 'rgb':
+ return [
+ (absValue * 255 / 256) | 0,
+ ((absValue * 2) % 256 * 255 / 256) | 0,
+ ((absValue * 3) % 256 * 255 / 256) | 0
+ ];
+
+ case 'hsv':
+ return this.hsvToRgb(absValue / 255.0, 1.0, 1.0);
+
+ case 'rainbow':
+ return this.rainbowColor(absValue);
+
+ default:
+ return [absValue, absValue, absValue];
+ }
+ }
+
+ private hsvToRgb(h: number, s: number, v: number): [number, number, number] {
+ const c = v * s;
+ const x = c * (1 - Math.abs((h * 6) % 2 - 1));
+ const m = v - c;
+
+ let r = 0, g = 0, b = 0;
+
+ if (h < 1/6) {
+ r = c; g = x; b = 0;
+ } else if (h < 2/6) {
+ r = x; g = c; b = 0;
+ } else if (h < 3/6) {
+ r = 0; g = c; b = x;
+ } else if (h < 4/6) {
+ r = 0; g = x; b = c;
+ } else if (h < 5/6) {
+ r = x; g = 0; b = c;
+ } else {
+ r = c; g = 0; b = x;
+ }
+
+ return [
+ Math.round((r + m) * 255),
+ Math.round((g + m) * 255),
+ Math.round((b + m) * 255)
+ ];
+ }
+
+ private rainbowColor(value: number): [number, number, number] {
+ const phase = (value / 255.0) * 6;
+ const segment = Math.floor(phase);
+ const remainder = phase - segment;
+ const t = remainder;
+ const q = 1 - t;
+
+ switch (segment % 6) {
+ case 0: return [255, Math.round(t * 255), 0];
+ case 1: return [Math.round(q * 255), 255, 0];
+ case 2: return [0, 255, Math.round(t * 255)];
+ case 3: return [0, Math.round(q * 255), 255];
+ case 4: return [Math.round(t * 255), 0, 255];
+ case 5: return [255, 0, Math.round(q * 255)];
+ default: return [255, 255, 255];
+ }
+ }
+
private sanitizeCode(code: string): string {
// Strict whitelist approach
const allowedPattern = /^[0-9a-zA-Z\s\+\-\*\/\%\^\&\|\(\)\<\>\~\?:,\.xyti]+$/;
diff --git a/src/Storage.ts b/src/Storage.ts
index de81bfd..6f9b5f3 100644
--- a/src/Storage.ts
+++ b/src/Storage.ts
@@ -10,6 +10,7 @@ interface AppSettings {
resolution: number;
fps: number;
lastShaderCode: string;
+ renderMode: string;
}
export class Storage {
@@ -95,7 +96,8 @@ export class Storage {
const defaults: AppSettings = {
resolution: 1,
fps: 30,
- lastShaderCode: 'x^y'
+ lastShaderCode: 'x^y',
+ renderMode: 'classic'
};
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
} catch (error) {
@@ -103,7 +105,8 @@ export class Storage {
return {
resolution: 1,
fps: 30,
- lastShaderCode: 'x^y'
+ lastShaderCode: 'x^y',
+ renderMode: 'classic'
};
}
}
diff --git a/src/main.ts b/src/main.ts
index 1047bf3..94b1903 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -52,8 +52,11 @@ class BitfielderApp {
const showUiBtn = document.getElementById('show-ui-btn')!;
const randomBtn = document.getElementById('random-btn')!;
const shareBtn = document.getElementById('share-btn')!;
+ const exportPngBtn = document.getElementById('export-png-btn')!;
+ const exportGifBtn = document.getElementById('export-gif-btn')!;
const resolutionSelect = document.getElementById('resolution-select') as HTMLSelectElement;
const fpsSelect = document.getElementById('fps-select') as HTMLSelectElement;
+ const renderModeSelect = document.getElementById('render-mode-select') as HTMLSelectElement;
const helpPopup = document.getElementById('help-popup')!;
const closeBtn = helpPopup.querySelector('.close-btn')!;
@@ -68,8 +71,11 @@ class BitfielderApp {
showUiBtn.addEventListener('click', () => this.showUI());
randomBtn.addEventListener('click', () => this.generateRandom());
shareBtn.addEventListener('click', () => this.shareURL());
+ exportPngBtn.addEventListener('click', () => this.exportPNG());
+ exportGifBtn.addEventListener('click', () => this.exportGIF());
resolutionSelect.addEventListener('change', () => this.updateResolution());
fpsSelect.addEventListener('change', () => this.updateFPS());
+ renderModeSelect.addEventListener('change', () => this.updateRenderMode());
closeBtn.addEventListener('click', () => this.hideHelp());
// Library events
@@ -251,12 +257,110 @@ class BitfielderApp {
Storage.saveSettings({ fps });
}
+ private updateRenderMode(): void {
+ const renderModeSelect = document.getElementById('render-mode-select') as HTMLSelectElement;
+ const renderMode = renderModeSelect.value;
+ this.shader.setRenderMode(renderMode);
+ this.render();
+ Storage.saveSettings({ renderMode });
+ }
+
+ private exportPNG(): void {
+ const link = document.createElement('a');
+ link.download = `bitfielder-${Date.now()}.png`;
+ link.href = this.canvas.toDataURL('image/png');
+ link.click();
+ }
+
+ private exportGIF(): void {
+ const hasAnimation = this.editor.value.includes('t');
+ if (!hasAnimation) {
+ // Static image - just export as PNG
+ this.exportPNG();
+ return;
+ }
+
+ // For animated GIFs, we need to capture multiple frames
+ this.captureGIF();
+ }
+
+ private async captureGIF(): Promise {
+ const hasAnimation = this.editor.value.includes('t');
+ if (!hasAnimation) {
+ this.exportPNG();
+ return;
+ }
+
+ // Show progress indicator
+ const exportBtn = document.getElementById('export-gif-btn')!;
+ const originalText = exportBtn.textContent;
+ exportBtn.textContent = 'Capturing...';
+ exportBtn.setAttribute('disabled', 'true');
+
+ try {
+ // Import GIF.js dynamically
+ const { default: GIF } = await import('https://cdn.skypack.dev/gif.js@0.2.0');
+
+ const gif = new GIF({
+ workers: 2,
+ quality: 10,
+ width: this.canvas.width,
+ height: this.canvas.height,
+ workerScript: 'https://cdn.skypack.dev/gif.js@0.2.0/dist/gif.worker.js'
+ });
+
+ const frameDuration = 1000 / 30; // 30 FPS
+ const totalFrames = 60; // 2 seconds at 30 FPS
+ const frameInterval = 1000 / 30;
+
+ // Capture frames
+ for (let frame = 0; frame < totalFrames; frame++) {
+ exportBtn.textContent = `Capturing ${frame + 1}/${totalFrames}`;
+
+ // Wait for frame to render
+ await new Promise(resolve => setTimeout(resolve, frameInterval));
+
+ // Create a copy of the canvas for this frame
+ const tempCanvas = document.createElement('canvas');
+ tempCanvas.width = this.canvas.width;
+ tempCanvas.height = this.canvas.height;
+ const tempCtx = tempCanvas.getContext('2d')!;
+ tempCtx.drawImage(this.canvas, 0, 0);
+
+ gif.addFrame(tempCanvas, { delay: frameDuration });
+ }
+
+ exportBtn.textContent = 'Rendering GIF...';
+
+ gif.on('finished', (blob: Blob) => {
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.download = `bitfielder-${Date.now()}.gif`;
+ link.href = url;
+ link.click();
+ URL.revokeObjectURL(url);
+
+ exportBtn.textContent = originalText;
+ exportBtn.removeAttribute('disabled');
+ });
+
+ gif.render();
+ } catch (error) {
+ console.error('GIF export failed:', error);
+ exportBtn.textContent = originalText;
+ exportBtn.removeAttribute('disabled');
+ // Fallback to PNG
+ this.exportPNG();
+ }
+ }
+
private loadSettings(): void {
const settings = Storage.getSettings();
// Apply settings to UI
(document.getElementById('resolution-select') as HTMLSelectElement).value = settings.resolution.toString();
(document.getElementById('fps-select') as HTMLSelectElement).value = settings.fps.toString();
+ (document.getElementById('render-mode-select') as HTMLSelectElement).value = settings.renderMode || 'classic';
// Load last shader code if no URL hash
if (!window.location.hash) {