multiple interaction points

This commit is contained in:
2025-07-05 19:15:13 +02:00
parent 7df2b49c26
commit d5c5b32bd7
5 changed files with 428 additions and 9 deletions

3
coolshaders.md Normal file
View File

@ -0,0 +1,3 @@
x<<127*y*t
x<<20*t*80*y^8
x**10*y^200*t+20

View File

@ -772,6 +772,7 @@
<button id="fullscreen-btn">Fullscreen</button>
<button id="hide-ui-btn">Hide UI</button>
<button id="random-btn">Random</button>
<button id="audio-btn">Enable Audio</button>
<button id="share-btn">Share</button>
<button id="export-png-btn">Export PNG</button>
</div>
@ -827,6 +828,7 @@
<div class="mobile-menu-buttons">
<button id="help-btn-mobile"><span class="icon"></span> Help</button>
<button id="fullscreen-btn-mobile"><span class="icon"></span> Fullscreen</button>
<button id="audio-btn-mobile"><span class="icon"></span> Enable Audio</button>
<button id="share-btn-mobile"><span class="icon"></span> Share</button>
<button id="export-png-btn-mobile"><span class="icon"></span> Export PNG</button>
</div>
@ -834,7 +836,7 @@
</div>
<div id="editor-panel">
<textarea id="editor" placeholder="Enter shader code... (x, y, t, i, mouseX, mouseY)" spellcheck="false">x^y</textarea>
<textarea id="editor" placeholder="Enter shader code... (x, y, t, i, mouseX, mouseY, mousePressed, touchCount, accelX, audioLevel, bassLevel...)" spellcheck="false">x^y</textarea>
<button id="eval-btn">Eval</button>
</div>
@ -876,6 +878,33 @@
<p><strong>t</strong> - Time (enables animation)</p>
<p><strong>i</strong> - Pixel index</p>
<p><strong>mouseX, mouseY</strong> - Mouse position (0.0 to 1.0)</p>
<p><strong>mousePressed</strong> - Mouse button down (true/false)</p>
<p><strong>mouseVX, mouseVY</strong> - Mouse velocity</p>
<p><strong>mouseClickTime</strong> - Time since last click (ms)</p>
</div>
<div class="help-section">
<h4>Touch & Gestures</h4>
<p><strong>touchCount</strong> - Number of active touches</p>
<p><strong>touch0X, touch0Y</strong> - Primary touch position</p>
<p><strong>touch1X, touch1Y</strong> - Secondary touch position</p>
<p><strong>pinchScale</strong> - Pinch zoom scale factor</p>
<p><strong>pinchRotation</strong> - Pinch rotation angle</p>
</div>
<div class="help-section">
<h4>Device Motion</h4>
<p><strong>accelX, accelY, accelZ</strong> - Accelerometer data</p>
<p><strong>gyroX, gyroY, gyroZ</strong> - Gyroscope rotation rates</p>
</div>
<div class="help-section">
<h4>Audio Reactive</h4>
<p><strong>audioLevel</strong> - Overall audio volume (0.0-1.0)</p>
<p><strong>bassLevel</strong> - Low frequencies (0.0-1.0)</p>
<p><strong>midLevel</strong> - Mid frequencies (0.0-1.0)</p>
<p><strong>trebleLevel</strong> - High frequencies (0.0-1.0)</p>
<p>Click "Enable Audio" to activate microphone</p>
</div>
<div class="help-section">
@ -906,6 +935,15 @@
<p><strong>Export PNG</strong> - Save current frame as image</p>
</div>
</div>
<div class="help-section" style="grid-column: 1 / -1; margin-top: 20px; text-align: center; padding-top: 20px; border-bottom: none;">
<h4>About</h4>
<p><strong>Bitfielder</strong> - Interactive bitfield shader editor</p>
<p>Created by <strong>BuboBubo</strong> (Raphaël Forment)</p>
<p>Website: <a href="https://raphaelforment.fr" target="_blank" style="color: #4A9EFF;">raphaelforment.fr</a></p>
<p>Source: <a href="https://git.raphaelforment.fr" target="_blank" style="color: #4A9EFF;">git.raphaelforment.fr</a></p>
<p>License: <strong>AGPL 3.0</strong></p>
</div>
</div>
<div id="performance-warning">

View File

@ -8,6 +8,27 @@ interface WorkerMessage {
renderMode?: string;
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;
}
interface WorkerResponse {
@ -31,6 +52,27 @@ export class FakeShader {
private renderMode: string = 'classic';
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;
@ -120,7 +162,28 @@ export class FakeShader {
time: currentTime,
renderMode: this.renderMode,
mouseX: this.mouseX,
mouseY: this.mouseY
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
} as WorkerMessage);
}
@ -192,9 +255,39 @@ export class FakeShader {
this.renderMode = mode;
}
setMousePosition(x: number, y: number): void {
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 {

View File

@ -9,6 +9,27 @@ interface WorkerMessage {
renderMode?: string;
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;
}
interface WorkerResponse {
@ -36,7 +57,7 @@ class ShaderWorker {
this.compileShader(message.id, message.code!);
break;
case 'render':
this.renderShader(message.id, message.width!, message.height!, message.time!, message.renderMode || 'classic', message.mouseX || 0, message.mouseY || 0);
this.renderShader(message.id, message.width!, message.height!, message.time!, message.renderMode || 'classic', message);
break;
}
} catch (error) {
@ -52,7 +73,7 @@ class ShaderWorker {
try {
const safeCode = this.sanitizeCode(code);
this.compiledFunction = new Function('x', 'y', 't', 'i', 'mouseX', 'mouseY', `
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;
@ -78,7 +99,7 @@ class ShaderWorker {
}
}
private renderShader(id: string, width: number, height: number, time: number, renderMode: string, mouseX: number, mouseY: number): void {
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;
@ -111,7 +132,20 @@ class ShaderWorker {
const pixelIndex = y * width + x;
try {
const value = this.compiledFunction(x, y, time, pixelIndex, mouseX, mouseY);
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);
@ -222,7 +256,8 @@ class ShaderWorker {
}
private sanitizeCode(code: string): string {
// Strict whitelist approach
// Strict whitelist approach - extended to include new interaction variables
// Variables: x, y, t, i, mouseX, mouseY, mousePressed, mouseVX, mouseVY, mouseClickTime, touchCount, touch0X, touch0Y, touch1X, touch1Y, pinchScale, pinchRotation, accelX, accelY, accelZ, gyroX, gyroY, gyroZ
const allowedPattern = /^[0-9a-zA-Z\s\+\-\*\/\%\^\&\|\(\)\<\>\~\?:,\.xyti]+$/;
if (!allowedPattern.test(code)) {

View File

@ -11,6 +11,35 @@ class BitfielderApp {
private performanceWarning: HTMLElement;
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 lastMouseX: number = 0;
private lastMouseY: 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 initialPinchDistance: number = 0;
private initialPinchAngle: 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;
private audioContext: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private microphone: MediaStreamAudioSourceNode | null = null;
private audioEnabled: boolean = false;
constructor() {
this.canvas = document.getElementById('canvas') as HTMLCanvasElement;
@ -55,6 +84,7 @@ class BitfielderApp {
const hideUiBtn = document.getElementById('hide-ui-btn')!;
const showUiBtn = document.getElementById('show-ui-btn')!;
const randomBtn = document.getElementById('random-btn')!;
const audioBtn = document.getElementById('audio-btn')!;
const shareBtn = document.getElementById('share-btn')!;
const exportPngBtn = document.getElementById('export-png-btn')!;
const resolutionSelect = document.getElementById('resolution-select') as HTMLSelectElement;
@ -81,6 +111,7 @@ class BitfielderApp {
// Mobile menu buttons
const helpBtnMobile = document.getElementById('help-btn-mobile')!;
const fullscreenBtnMobile = document.getElementById('fullscreen-btn-mobile')!;
const audioBtnMobile = document.getElementById('audio-btn-mobile')!;
const shareBtnMobile = document.getElementById('share-btn-mobile')!;
const exportPngBtnMobile = document.getElementById('export-png-btn-mobile')!;
@ -94,6 +125,7 @@ class BitfielderApp {
hideUiBtn.addEventListener('click', () => this.toggleUI());
showUiBtn.addEventListener('click', () => this.showUI());
randomBtn.addEventListener('click', () => this.generateRandom());
audioBtn.addEventListener('click', () => this.toggleAudio());
shareBtn.addEventListener('click', () => this.shareURL());
exportPngBtn.addEventListener('click', () => this.exportPNG());
resolutionSelect.addEventListener('change', () => this.updateResolution());
@ -146,6 +178,10 @@ class BitfielderApp {
this.closeMobileMenu();
this.toggleFullscreen();
});
audioBtnMobile.addEventListener('click', () => {
this.closeMobileMenu();
this.toggleAudio();
});
shareBtnMobile.addEventListener('click', () => {
this.closeMobileMenu();
this.shareURL();
@ -208,10 +244,69 @@ class BitfielderApp {
// Mouse tracking
window.addEventListener('mousemove', (e) => {
this.lastMouseX = this.mouseX;
this.lastMouseY = this.mouseY;
this.mouseX = e.clientX / window.innerWidth;
this.mouseY = 1.0 - (e.clientY / window.innerHeight); // Invert Y to match shader coordinates
this.shader.setMousePosition(this.mouseX, this.mouseY);
this.mouseVX = this.mouseX - this.lastMouseX;
this.mouseVY = this.mouseY - this.lastMouseY;
this.shader.setMousePosition(this.mouseX, this.mouseY, this.mousePressed, this.mouseVX, this.mouseVY, this.mouseClickTime);
});
window.addEventListener('mousedown', () => {
this.mousePressed = true;
this.mouseClickTime = Date.now();
this.shader.setMousePosition(this.mouseX, this.mouseY, this.mousePressed, this.mouseVX, this.mouseVY, this.mouseClickTime);
});
window.addEventListener('mouseup', () => {
this.mousePressed = false;
this.shader.setMousePosition(this.mouseX, this.mouseY, this.mousePressed, this.mouseVX, this.mouseVY, this.mouseClickTime);
});
// Touch tracking
window.addEventListener('touchstart', (e) => {
e.preventDefault();
this.touchCount = e.touches.length;
this.updateTouchPositions(e.touches);
this.initializePinchGesture(e.touches);
});
window.addEventListener('touchmove', (e) => {
e.preventDefault();
this.touchCount = e.touches.length;
this.updateTouchPositions(e.touches);
this.updatePinchGesture(e.touches);
});
window.addEventListener('touchend', (e) => {
e.preventDefault();
this.touchCount = e.touches.length;
if (this.touchCount === 0) {
this.touch0X = this.touch0Y = this.touch1X = this.touch1Y = 0;
this.pinchScale = 1;
this.pinchRotation = 0;
} else {
this.updateTouchPositions(e.touches);
}
});
// Device motion tracking
if (window.DeviceMotionEvent) {
window.addEventListener('devicemotion', (e) => {
if (e.acceleration) {
this.accelX = e.acceleration.x || 0;
this.accelY = e.acceleration.y || 0;
this.accelZ = e.acceleration.z || 0;
}
if (e.rotationRate) {
this.gyroX = e.rotationRate.alpha || 0;
this.gyroY = e.rotationRate.beta || 0;
this.gyroZ = e.rotationRate.gamma || 0;
}
this.shader.setDeviceMotion(this.accelX, this.accelY, this.accelZ, this.gyroX, this.gyroY, this.gyroZ);
});
}
}
private render(): void {
@ -600,6 +695,161 @@ class BitfielderApp {
this.shader.setCode(randomCode);
this.render();
}
private async toggleAudio(): Promise<void> {
const audioBtn = document.getElementById('audio-btn')!;
const audioBtnMobile = document.getElementById('audio-btn-mobile')!;
if (!this.audioEnabled) {
try {
await this.setupAudio();
audioBtn.textContent = 'Disable Audio';
audioBtnMobile.innerHTML = '<span class="icon"></span> Disable Audio';
} catch (error) {
console.error('Failed to enable audio:', error);
audioBtn.textContent = 'Audio Failed';
audioBtnMobile.innerHTML = '<span class="icon"></span> Audio Failed';
}
} else {
this.disableAudio();
audioBtn.textContent = 'Enable Audio';
audioBtnMobile.innerHTML = '<span class="icon"></span> Enable Audio';
}
}
private disableAudio(): void {
this.audioEnabled = false;
if (this.microphone) {
this.microphone.disconnect();
}
if (this.audioContext) {
this.audioContext.close();
}
this.audioLevel = 0;
this.bassLevel = 0;
this.midLevel = 0;
this.trebleLevel = 0;
this.shader.setAudioData(0, 0, 0, 0);
}
private updateTouchPositions(touches: TouchList): void {
if (touches.length > 0) {
this.touch0X = touches[0].clientX / window.innerWidth;
this.touch0Y = 1.0 - (touches[0].clientY / window.innerHeight);
}
if (touches.length > 1) {
this.touch1X = touches[1].clientX / window.innerWidth;
this.touch1Y = 1.0 - (touches[1].clientY / window.innerHeight);
}
this.shader.setTouchPosition(this.touchCount, this.touch0X, this.touch0Y, this.touch1X, this.touch1Y, this.pinchScale, this.pinchRotation);
}
private initializePinchGesture(touches: TouchList): void {
if (touches.length === 2) {
const dx = touches[1].clientX - touches[0].clientX;
const dy = touches[1].clientY - touches[0].clientY;
this.initialPinchDistance = Math.sqrt(dx * dx + dy * dy);
this.initialPinchAngle = Math.atan2(dy, dx);
this.pinchScale = 1;
this.pinchRotation = 0;
}
}
private updatePinchGesture(touches: TouchList): void {
if (touches.length === 2 && this.initialPinchDistance > 0) {
const dx = touches[1].clientX - touches[0].clientX;
const dy = touches[1].clientY - touches[0].clientY;
const distance = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx);
this.pinchScale = distance / this.initialPinchDistance;
this.pinchRotation = angle - this.initialPinchAngle;
this.shader.setTouchPosition(this.touchCount, this.touch0X, this.touch0Y, this.touch1X, this.touch1Y, this.pinchScale, this.pinchRotation);
}
}
private async setupAudio(): Promise<void> {
try {
// Check if Web Audio API is supported
if (!window.AudioContext && !(window as any).webkitAudioContext) {
console.warn('Web Audio API not supported');
return;
}
// Request microphone access
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Create audio context
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
// Create analyser node
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 256; // 128 frequency bins
this.analyser.smoothingTimeConstant = 0.8;
// Create microphone source
this.microphone = this.audioContext.createMediaStreamSource(stream);
this.microphone.connect(this.analyser);
this.audioEnabled = true;
console.log('Audio analysis enabled');
// Start audio analysis loop
this.updateAudioData();
} catch (error) {
console.warn('Failed to setup audio:', error);
this.audioEnabled = false;
}
}
private updateAudioData(): void {
if (!this.analyser || !this.audioEnabled) return;
const bufferLength = this.analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
this.analyser.getByteFrequencyData(dataArray);
// Calculate overall audio level (RMS)
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
sum += dataArray[i] * dataArray[i];
}
this.audioLevel = Math.sqrt(sum / bufferLength) / 255;
// Calculate frequency bands
// Low: 0-85Hz (bins 0-10 at 44.1kHz sample rate)
// Mid: 85-5500Hz (bins 11-85)
// High: 5500Hz+ (bins 86-127)
const lowEnd = Math.floor(bufferLength * 0.08); // ~10 bins
const midEnd = Math.floor(bufferLength * 0.67); // ~85 bins
// Bass (low frequencies)
let bassSum = 0;
for (let i = 0; i < lowEnd; i++) {
bassSum += dataArray[i];
}
this.bassLevel = (bassSum / lowEnd) / 255;
// Mid frequencies
let midSum = 0;
for (let i = lowEnd; i < midEnd; i++) {
midSum += dataArray[i];
}
this.midLevel = (midSum / (midEnd - lowEnd)) / 255;
// Treble (high frequencies)
let trebleSum = 0;
for (let i = midEnd; i < bufferLength; i++) {
trebleSum += dataArray[i];
}
this.trebleLevel = (trebleSum / (bufferLength - midEnd)) / 255;
// Update shader with audio data
this.shader.setAudioData(this.audioLevel, this.bassLevel, this.midLevel, this.trebleLevel);
// Continue the analysis loop
requestAnimationFrame(() => this.updateAudioData());
}
}
const app = new BitfielderApp();