diff --git a/effectsim/.gitignore b/effectsim/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/effectsim/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/effectsim/README.md b/effectsim/README.md new file mode 100644 index 0000000..8a68b74 --- /dev/null +++ b/effectsim/README.md @@ -0,0 +1,116 @@ +# LED Matrix Simulator + +High-performance LED matrix simulator Web Component targeting 120+ FPS at 150×200 resolution. + +## Quick Start + +1. **Install dependencies:** + ```bash + pnpm install + ``` + +2. **Build the project:** + ```bash + pnpm run build + ``` + +3. **Start the WebSocket test server:** + ```bash + pnpm run server + ``` + +4. **Start the HTTP server:** + ```bash + pnpm run serve + ``` + +5. **Open in browser:** + - Navigate to http://localhost:8080 + - The demo should automatically connect to the WebSocket server and display a moving rainbow pattern + +## Usage + +### Basic HTML Integration + +```html + + +``` + +### Configuration Attributes + +- `panels-x`, `panels-y`: Panel grid dimensions +- `panel-cols`, `panel-rows`: Resolution per panel +- `pixel-size`: Size in CSS pixels or "auto" +- `gap`: Gap between LEDs in pixels +- `fps-cap`: FPS limit (0 = uncapped) +- `ws-url`: WebSocket server URL + +### JavaScript API + +```javascript +const matrix = document.querySelector('led-matrix'); + +// Push a frame manually +const rgbData = new Uint8Array(cols * rows * 3); +// ... fill with RGB888 data +matrix.pushFrame(rgbData); + +// Listen to events +matrix.addEventListener('ready', (e) => { + console.log(`Matrix ready: ${e.detail.cols}×${e.detail.rows}`); +}); + +matrix.addEventListener('stats', (e) => { + console.log(`FPS: ${e.detail.fps}, Render: ${e.detail.renderMs}ms`); +}); +``` + +## Architecture + +- **Web Component**: Framework-agnostic custom element +- **Canvas 2D**: High-performance rendering with offscreen buffer +- **WebSocket Client**: Real-time frame streaming with drop-frame backpressure +- **Coordinate Mapping**: Pre-computed LUT with serpentine wiring (rows alternate L→R, R→L) +- **Performance Monitoring**: Built-in FPS counter and render time tracking + +## WebSocket Protocol + +Send binary messages containing exactly `cols × rows × 3` bytes of RGB888 data: + +```javascript +// Example: 84×112 matrix = 28,224 bytes +const frame = new Uint8Array(84 * 112 * 3); +// Fill with RGB data... +websocket.send(frame); +``` + +## Performance + +- **Target**: 120+ FPS at 200×150 (30k pixels) +- **Optimizations**: Pre-computed coordinate mapping, buffer reuse, atomic frame swapping +- **Monitoring**: Real-time FPS, render time, and dropped frame statistics + +## Files + +``` +├── src/ +│ ├── led-matrix.ts # Main Web Component +│ ├── util/ +│ │ ├── lut.ts # Coordinate mapping +│ │ └── fps.ts # Performance monitoring +│ └── types.d.ts # Type definitions +├── server/ +│ └── test-server.js # WebSocket test server +├── index.html # Demo page +└── dist/ # Compiled JavaScript +``` + +## License + +MIT \ No newline at end of file diff --git a/effectsim/index.html b/effectsim/index.html new file mode 100644 index 0000000..eeb0904 --- /dev/null +++ b/effectsim/index.html @@ -0,0 +1,310 @@ + + + + + + LED Matrix Simulator + + + + +
+

Hyperion Simulator

+
+ +
+
+ + +
+
+ + +
+
+ + + 0.5 +
+
+ + +
+
+ +
+
+ + +
+ +
+

Performance

+
+ FPS: + 0.0 +
+
+ Render: + 0.00ms +
+
+ Dropped: + 0 +
+
+ Resolution: + 0×0 +
+
+ Connection: +
+
+ Disconnected +
+
+
+
+ + + + \ No newline at end of file diff --git a/effectsim/package.json b/effectsim/package.json new file mode 100644 index 0000000..5af2fd3 --- /dev/null +++ b/effectsim/package.json @@ -0,0 +1,20 @@ +{ + "name": "led-matrix-simulator", + "version": "1.0.0", + "description": "High-performance LED matrix simulator Web Component", + "type": "module", + "main": "dist/led-matrix.js", + "scripts": { + "build": "tsc -p .", + "dev": "tsc -w", + "serve": "pnpx http-server -c-1 -p 8080", + "server": "node server/test-server.js" + }, + "devDependencies": { + "typescript": "^5.9.0", + "@types/node": "^24.0.0" + }, + "dependencies": { + "ws": "^8.18.3" + } +} \ No newline at end of file diff --git a/effectsim/pnpm-lock.yaml b/effectsim/pnpm-lock.yaml new file mode 100644 index 0000000..1e196ec --- /dev/null +++ b/effectsim/pnpm-lock.yaml @@ -0,0 +1,57 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + ws: + specifier: ^8.18.3 + version: 8.18.3 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.3.1 + typescript: + specifier: ^5.9.0 + version: 5.9.2 + +packages: + + '@types/node@24.3.1': + resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==} + + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.10.0: + resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@types/node@24.3.1': + dependencies: + undici-types: 7.10.0 + + typescript@5.9.2: {} + + undici-types@7.10.0: {} + + ws@8.18.3: {} diff --git a/effectsim/server/test-server.js b/effectsim/server/test-server.js new file mode 100644 index 0000000..a25c133 --- /dev/null +++ b/effectsim/server/test-server.js @@ -0,0 +1,238 @@ +#!/usr/bin/env node + +import { WebSocketServer } from 'ws'; + +// Parse CLI arguments +const args = process.argv.slice(2); +let panelsX = 3; // Default 3 panels wide +let panelsY = 4; // Default 4 panels tall + +for (let i = 0; i < args.length; i++) { + if (args[i] === '--panels-x' && i + 1 < args.length) { + panelsX = parseInt(args[i + 1]); + i++; // Skip next argument + } else if (args[i] === '--panels-y' && i + 1 < args.length) { + panelsY = parseInt(args[i + 1]); + i++; // Skip next argument + } else if (args[i] === '--help' || args[i] === '-h') { + console.log('Usage: node test-server.js [--panels-x ] [--panels-y ]'); + console.log(' --panels-x Number of panels horizontally (default: 3)'); + console.log(' --panels-y Number of panels vertically (default: 4)'); + console.log(' --help, -h Show this help message'); + process.exit(0); + } +} + +// Validate arguments +if (isNaN(panelsX) || panelsX < 1 || panelsX > 20) { + console.error('Error: --panels-x must be a number between 1 and 20'); + process.exit(1); +} +if (isNaN(panelsY) || panelsY < 1 || panelsY > 20) { + console.error('Error: --panels-y must be a number between 1 and 20'); + process.exit(1); +} + +// Server configuration +const PORT = 9002; +const PANEL_SIZE = 28; // Fixed 28×28 panels +const PANELS_X = panelsX; +const PANELS_Y = panelsY; +const DEFAULT_COLS = PANELS_X * PANEL_SIZE; +const DEFAULT_ROWS = PANELS_Y * PANEL_SIZE; + +// Frame protocol constants +const FRAME_MAGIC = 0x4D44454C; // "LEDM" in little-endian +const FRAME_HEADER_SIZE = 8; // bytes + +class TestServer { + constructor(port = PORT) { + this.port = port; + this.wss = null; + this.clients = new Set(); + this.animationId = null; + this.frameCount = 0; + this.startTime = Date.now(); + } + + start() { + this.wss = new WebSocketServer({ + port: this.port, + perMessageDeflate: false // Disable compression for better performance + }); + + this.wss.on('connection', (ws, req) => { + console.log(`Client connected from ${req.socket.remoteAddress}`); + this.clients.add(ws); + + ws.on('close', () => { + console.log('Client disconnected'); + this.clients.delete(ws); + + // Stop animation if no clients + if (this.clients.size === 0 && this.animationId) { + clearInterval(this.animationId); + this.animationId = null; + console.log('Animation stopped - no clients'); + } + }); + + ws.on('error', (error) => { + console.error('WebSocket error:', error); + this.clients.delete(ws); + }); + + // Start animation if first client + if (this.clients.size === 1 && !this.animationId) { + this.startAnimation(); + } + }); + + console.log(`LED Matrix Test Server running on ws://localhost:${this.port}`); + console.log(`Matrix: ${PANELS_X}×${PANELS_Y} panels of ${PANEL_SIZE}×${PANEL_SIZE} = ${DEFAULT_COLS}×${DEFAULT_ROWS} pixels`); + } + + startAnimation() { + console.log('Starting animation...'); + this.frameCount = 0; + this.startTime = Date.now(); + + // Target ~60 FPS for test data + this.animationId = setInterval(() => { + this.broadcastFrame(); + }, 1000 / 60); + } + + broadcastFrame() { + if (this.clients.size === 0) return; + + const cols = DEFAULT_COLS; + const rows = DEFAULT_ROWS; + const rgbData = this.generateTestPattern(cols, rows, this.frameCount); + const frame = this.createFrameWithHeader(PANELS_X, PANELS_Y, rgbData); + + this.clients.forEach(client => { + if (client.readyState === 1) { // WebSocket.OPEN + client.send(frame); + } + }); + + this.frameCount++; + + // Log stats every 5 seconds + if (this.frameCount % 300 === 0) { + const elapsed = (Date.now() - this.startTime) / 1000; + const fps = this.frameCount / elapsed; + console.log(`Sent ${this.frameCount} frames (${fps.toFixed(1)} FPS avg) to ${this.clients.size} client(s)`); + } + } + + createFrameWithHeader(panelsX, panelsY, rgbData) { + // Frame format: + // Header (FRAME_HEADER_SIZE bytes): + // - Magic: "LEDM" (4 bytes) + // - Panels X: uint16 little-endian (2 bytes) + // - Panels Y: uint16 little-endian (2 bytes) + // Data: RGB888 column-major (panelsX * panelsY * PANEL_SIZE * PANEL_SIZE * 3 bytes) + + const dataSize = rgbData.length; + const frame = new ArrayBuffer(FRAME_HEADER_SIZE + dataSize); + const headerView = new DataView(frame, 0, FRAME_HEADER_SIZE); + const dataView = new Uint8Array(frame, FRAME_HEADER_SIZE); + + // Write header + headerView.setUint32(0, FRAME_MAGIC, true); + headerView.setUint16(4, panelsX, true); + headerView.setUint16(6, panelsY, true); + + // Copy RGB data + dataView.set(rgbData); + + return frame; + } + + generateTestPattern(cols, rows, frameNum) { + const buffer = new Uint8Array(cols * rows * 3); + + // Rainbow spiral parameters + const centerX = cols / 2; + const centerY = rows / 2; + const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY); + + // Rotate at 1/10 Hz = 0.1 rotations per second + // At 60 FPS, each frame is 1/60 second + const rotationSpeed = 0.1; // Hz + const timeSeconds = frameNum / 60; // Convert frame to seconds + const rotationOffset = timeSeconds * rotationSpeed * 2 * Math.PI; + + // Generate in row-major order (to match Canvas ImageData) + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + const idx = (y * cols + x) * 3; // Row-major indexing + + // Calculate distance and angle from center + const dx = x - centerX; + const dy = y - centerY; + const distance = Math.sqrt(dx * dx + dy * dy); + const angle = Math.atan2(dy, dx); + + // Create spiral: combine angle and distance for hue + // Add rotation offset for animation + const spiralTurns = 3; // Number of complete color cycles in the spiral + const hue = ((angle + rotationOffset) / (2 * Math.PI) + + (distance / maxRadius) * spiralTurns) % 1; + + // Fade out at edges for better visual effect + const brightness = Math.max(0, 1 - (distance / maxRadius) * 0.3); + + const [r, g, b] = this.hsvToRgb(hue, 1, brightness); + + buffer[idx] = Math.round(r * 255); + buffer[idx + 1] = Math.round(g * 255); + buffer[idx + 2] = Math.round(b * 255); + } + } + + return buffer; + } + + // HSV to RGB conversion for rainbow effects + hsvToRgb(h, s, v) { + const c = v * s; + const x = c * (1 - Math.abs((h * 6) % 2 - 1)); + const m = v - c; + + let r, g, b; + if (h < 1/6) [r, g, b] = [c, x, 0]; + else if (h < 2/6) [r, g, b] = [x, c, 0]; + else if (h < 3/6) [r, g, b] = [0, c, x]; + else if (h < 4/6) [r, g, b] = [0, x, c]; + else if (h < 5/6) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + + return [r + m, g + m, b + m]; + } + + stop() { + if (this.animationId) { + clearInterval(this.animationId); + this.animationId = null; + } + + if (this.wss) { + this.wss.close(); + } + + console.log('Test server stopped'); + } +} + +// Handle graceful shutdown +const server = new TestServer(); +process.on('SIGINT', () => { + console.log('\nShutting down gracefully...'); + server.stop(); + process.exit(0); +}); + +server.start(); \ No newline at end of file diff --git a/effectsim/src/led-matrix.ts b/effectsim/src/led-matrix.ts new file mode 100644 index 0000000..a02d0e6 --- /dev/null +++ b/effectsim/src/led-matrix.ts @@ -0,0 +1,576 @@ +// LED Matrix Simulator Web Component + +import { CoordinateLUT } from './util/lut.js'; +import { FPSCounter, FPSLimiter } from './util/fps.js'; +import { WebGLLEDRenderer } from './util/webgl-renderer.js'; +import type { + MatrixConfig, + MatrixDimensions, + FrameBuffer, + ReadyEventDetail, + StatsEventDetail, + ComponentState +} from './types.d.ts'; + +// Frame protocol constants +const FRAME_MAGIC = 0x4D44454C; // "LEDM" in little-endian +const FRAME_HEADER_SIZE = 8; // bytes +const PANEL_SIZE = 28; // 28×28 panels + +export class LEDMatrix extends HTMLElement { + // Configuration + private config: MatrixConfig; + private lut: CoordinateLUT; + + // Canvas and rendering + private canvas!: HTMLCanvasElement; + private webglRenderer!: WebGLLEDRenderer; + + // Frame handling + private currentFrame: FrameBuffer | null = null; + private pendingFrame: FrameBuffer | null = null; + + // Performance monitoring + private fpsCounter: FPSCounter; + private fpsLimiter: FPSLimiter; + private animationId: number = 0; + + // WebSocket + private ws: WebSocket | null = null; + private wsUrl: string = ''; + private reconnectTimer: number = 0; + private reconnectDelay: number = 2000; + private maxReconnectDelay: number = 10000; + + // Component state + private state: ComponentState; + private lastCanvasSize: { width: number; height: number } = { width: 0, height: 0 }; + private cachedContainerSize: { width: number; height: number } = { width: 0, height: 0 }; + + // Observed attributes (geometry comes from frame headers) + static get observedAttributes() { + return [ + 'pixel-size', 'gap', + 'fps-cap', 'ws-url', 'lens-flare-intensity' + ]; + } + + constructor() { + super(); + + // Initialize state + this.state = { + initialized: false, + connected: false, + rendering: false + }; + + // No default configuration - will be set from frame headers + this.config = { + panelsX: 0, + panelsY: 0, + panelCols: 0, + panelRows: 0, + pixelSize: 'auto', + gap: 1, + fpsCap: 0, + lensFlareIntensity: 0.5 + }; + + // Initialize utilities + this.lut = new CoordinateLUT(this.config); + this.fpsCounter = new FPSCounter(); + this.fpsLimiter = new FPSLimiter(this.config.fpsCap); + + // Create shadow DOM + this.attachShadow({ mode: 'open' }); + + + this.initializeDOM(); + } + + connectedCallback() { + console.log('LED Matrix component connected'); + + this.updateConfigFromAttributes(); + + // Start basic render loop for LED pattern display + // This will show LED pattern when disconnected if we have geometry from previous connection + if (!this.state.rendering) { + this.startRenderLoop(); + } + + // Don't initialize until we get geometry from first frame + + // Connect WebSocket if URL provided (only if not already connected) + if (this.wsUrl && !this.ws) { + this.connectWebSocket(); + } + } + + disconnectedCallback() { + console.log('LED Matrix component disconnected'); + + this.cleanup(); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + if (oldValue === newValue) return; + + console.log(`Attribute ${name} changed: ${oldValue} → ${newValue}`); + + // Update configuration + this.updateConfigFromAttributes(); + + // Handle specific changes + if (name === 'ws-url') { + if (this.ws) { + this.disconnectWebSocket(); + } + if (newValue && !this.ws) { + this.connectWebSocket(); + } + } else if (name === 'pixel-size') { + if (newValue === 'auto') { + this.cachedContainerSize = { width: 0, height: 0 }; // Reset cache to recalculate + } + if (this.state.initialized) { + this.resize(); + } + } else if (name === 'fps-cap') { + this.fpsLimiter.setTargetFPS(this.config.fpsCap); + } + } + + // Public API methods + + /** + * Push a frame for rendering + */ + pushFrame(rgb: Uint8Array): void { + const dims = this.lut.getDimensions(); + const expectedLength = dims.totalPixels * 3; + + if (rgb.length !== expectedLength) { + console.error(`Frame size mismatch: expected ${expectedLength}, got ${rgb.length}`); + this.fpsCounter.dropFrame(); + return; + } + + // Create frame buffer + const frame: FrameBuffer = { + data: new Uint8ClampedArray(rgb.buffer.slice(rgb.byteOffset, rgb.byteOffset + rgb.byteLength)), + cols: dims.cols, + rows: dims.rows, + timestamp: performance.now() + }; + + // Atomic swap - replace any pending frame + this.pendingFrame = frame; + } + + /** + * Manually trigger resize recalculation + */ + resize(): void { + if (!this.canvas) { + return; + } + + // Allow resize for LED pattern display even if not fully initialized + const dims = this.lut.getDimensions(); + if (dims.cols === 0 || dims.rows === 0) { + return; + } + + // Calculate CSS pixel size based on pixelSize config + let cssPixelSize: number; + if (this.config.pixelSize === 'auto') { + // Use cached container size for auto-sizing to prevent constant changes + // Only update cache if we don't have valid dimensions yet + if (this.cachedContainerSize.width === 0 || this.cachedContainerSize.height === 0) { + // Get parent container dimensions, not our own element dimensions + const parent = this.parentElement; + if (!parent) { + return; + } + const containerRect = parent.getBoundingClientRect(); + if (containerRect.width === 0 || containerRect.height === 0) { + return; + } + this.cachedContainerSize = { width: containerRect.width, height: containerRect.height }; + console.log(`📦 Cached parent container size: ${this.cachedContainerSize.width}×${this.cachedContainerSize.height}`); + } + + const scaleX = this.cachedContainerSize.width / dims.cols; + const scaleY = this.cachedContainerSize.height / dims.rows; + cssPixelSize = Math.min(scaleX, scaleY); + } else { + cssPixelSize = this.config.pixelSize; + } + + // Calculate logical canvas size (in CSS pixels) + const canvasWidth = dims.cols * cssPixelSize; + const canvasHeight = dims.rows * cssPixelSize; + + // Only update canvas if dimensions actually changed (with tolerance for floating point precision) + const tolerance = 0.1; // 0.1px tolerance + const widthChanged = Math.abs(this.lastCanvasSize.width - canvasWidth) > tolerance; + const heightChanged = Math.abs(this.lastCanvasSize.height - canvasHeight) > tolerance; + const sizeChanged = widthChanged || heightChanged; + if (!sizeChanged) { + return; // No changes needed, avoid triggering ResizeObserver loop + } + + this.lastCanvasSize = { width: canvasWidth, height: canvasHeight }; + + // Set canvas CSS size + this.canvas.style.width = `${canvasWidth}px`; + this.canvas.style.height = `${canvasHeight}px`; + + // Set canvas resolution (accounting for device pixel ratio) + const devicePixelRatio = window.devicePixelRatio || 1; + const resolutionWidth = canvasWidth * devicePixelRatio; + const resolutionHeight = canvasHeight * devicePixelRatio; + this.canvas.width = resolutionWidth; + this.canvas.height = resolutionHeight; + + // Update WebGL renderer + if (this.webglRenderer) { + this.webglRenderer.resize(resolutionWidth, resolutionHeight); + } + } + + // Private methods + + + private initializeDOM(): void { + if (!this.shadowRoot) return; + + // Create canvas + this.canvas = document.createElement('canvas'); + this.canvas.style.display = 'block'; + this.canvas.style.imageRendering = 'pixelated'; + + // Initialize WebGL renderer + try { + this.webglRenderer = new WebGLLEDRenderer(this.canvas); + if (!this.webglRenderer.initialize()) { + throw new Error('WebGL renderer initialization failed'); + } + } catch (error) { + console.error('Failed to initialize WebGL renderer:', error); + throw new Error('WebGL not supported or failed to initialize'); + } + + // Add to shadow DOM + this.shadowRoot.appendChild(this.canvas); + + // Load CSS + const style = document.createElement('style'); + style.textContent = ` + :host { + display: inline-block; + background: var(--led-off-bg, #111); + } + canvas { + display: block; + } + `; + this.shadowRoot.appendChild(style); + } + + private updateConfigFromAttributes(): void { + + const pixelSize = this.getAttribute('pixel-size'); + this.config.pixelSize = pixelSize === 'auto' ? 'auto' : parseFloat(pixelSize || 'auto') || 'auto'; + + this.config.gap = parseFloat(this.getAttribute('gap') || '1'); + this.config.fpsCap = parseInt(this.getAttribute('fps-cap') || '0'); + this.config.lensFlareIntensity = parseFloat(this.getAttribute('lens-flare-intensity') || '0.5'); + this.wsUrl = this.getAttribute('ws-url') || ''; + } + + private initialize(): void { + console.log('🔄 Initializing LED Matrix...', this.config); + + // Update coordinate mapping + this.lut.updateConfig(this.config); + const dims = this.lut.getDimensions(); + console.log(`📐 Matrix dimensions calculated: ${dims.cols}×${dims.rows} (${dims.totalPixels} pixels)`); + + // Update WebGL renderer with new dimensions + if (this.webglRenderer) { + this.webglRenderer.updateDimensions(dims); + console.log(`🎨 Updated WebGL renderer: ${dims.cols}×${dims.rows}`); + } + + // Reset performance counters + this.fpsCounter.reset(); + this.fpsLimiter.setTargetFPS(this.config.fpsCap); + + // Start render loop + this.startRenderLoop(); + + // Update state + this.state.initialized = true; + console.log(`✅ LED Matrix initialization complete: ${dims.cols}×${dims.rows}`); + + // Trigger immediate resize to set up canvas display + // Use requestAnimationFrame to ensure DOM is ready + requestAnimationFrame(() => { + this.resize(); + + // Fallback: retry resize after a short delay in case container isn't ready + setTimeout(() => { + this.resize(); + }, 50); + }); + + // Dispatch ready event + const readyEvent = new CustomEvent('ready', { + detail: { cols: dims.cols, rows: dims.rows } + }); + this.dispatchEvent(readyEvent); + } + + private startRenderLoop(): void { + if (this.animationId) { + cancelAnimationFrame(this.animationId); + } + + this.state.rendering = true; + + const render = (timestamp: number) => { + if (!this.state.rendering) return; + + // Check FPS limiting + if (this.fpsLimiter.shouldRender(timestamp)) { + this.renderFrame(); + } + + // Update stats periodically + if (this.fpsCounter.shouldUpdateStats()) { + const stats = this.fpsCounter.getStats(); + const statsEvent = new CustomEvent('stats', { + detail: stats + }); + this.dispatchEvent(statsEvent); + } + + this.animationId = requestAnimationFrame(render); + }; + + this.animationId = requestAnimationFrame(render); + } + + private renderFrame(): void { + const renderStart = this.fpsCounter.startFrame(); + + try { + // Swap frame buffers atomically + if (this.pendingFrame) { + this.currentFrame = this.pendingFrame; + this.pendingFrame = null; + } + + const dims = this.lut.getDimensions(); + if (dims.cols === 0 || dims.rows === 0) { + // No geometry yet - wait for WebSocket frame headers + return; + } + + if (!this.webglRenderer) { + console.error('WebGL renderer not initialized'); + this.fpsCounter.dropFrame(); + return; + } + + const canvasWidth = this.canvas.width; + const canvasHeight = this.canvas.height; + + if (canvasWidth === 0 || canvasHeight === 0) { + return; + } + + if (this.state.connected && this.currentFrame) { + this.webglRenderer.updateFrame(this.currentFrame); + this.webglRenderer.render(canvasWidth, canvasHeight, { + gap: this.config.gap, + lensFlareIntensity: this.config.lensFlareIntensity + }, true); + } else { + this.webglRenderer.render(canvasWidth, canvasHeight, { + gap: this.config.gap, + lensFlareIntensity: this.config.lensFlareIntensity + }, false); + } + + this.fpsCounter.endFrame(renderStart); + + } catch (error) { + console.error('❌ Render error:', error); + this.fpsCounter.dropFrame(); + } + } + + + + + + + + // Frame message handling + + private handleFrameMessage(buffer: ArrayBuffer): void { + // Check minimum header size + if (buffer.byteLength < FRAME_HEADER_SIZE) { + console.error('❌ Frame too small for header'); + this.fpsCounter.dropFrame(); + return; + } + + const headerView = new DataView(buffer, 0, FRAME_HEADER_SIZE); + + // Check magic bytes + const magic = headerView.getUint32(0, true); + if (magic !== FRAME_MAGIC) { + console.error('❌ Invalid frame magic'); + this.fpsCounter.dropFrame(); + return; + } + + // Parse header + const panelsX = headerView.getUint16(4, true); + const panelsY = headerView.getUint16(6, true); + + // Calculate expected dimensions + const expectedCols = panelsX * PANEL_SIZE; + const expectedRows = panelsY * PANEL_SIZE; + const expectedDataSize = expectedCols * expectedRows * 3; + + // Validate frame size + if (buffer.byteLength !== FRAME_HEADER_SIZE + expectedDataSize) { + console.error(`❌ Frame size mismatch: expected ${FRAME_HEADER_SIZE + expectedDataSize}, got ${buffer.byteLength}`); + this.fpsCounter.dropFrame(); + return; + } + + // Auto-configure if geometry changed or first time + if (this.config.panelsX !== panelsX || this.config.panelsY !== panelsY || !this.state.initialized) { + console.log(`🔧 Auto-configuring: ${panelsX}×${panelsY} panels (${expectedCols}×${expectedRows} pixels)`); + this.config.panelsX = panelsX; + this.config.panelsY = panelsY; + this.config.panelCols = PANEL_SIZE; + this.config.panelRows = PANEL_SIZE; + + // Update coordinate mapping so geometry is available even when disconnected + this.lut.updateConfig(this.config); + + this.initialize(); + } + + // Extract RGB data and push frame + const rgbData = new Uint8Array(buffer, FRAME_HEADER_SIZE); + this.pushFrame(rgbData); + } + + // WebSocket management + + private connectWebSocket(): void { + if (!this.wsUrl) return; + + console.log(`Connecting to WebSocket: ${this.wsUrl}`); + + try { + this.ws = new WebSocket(this.wsUrl); + this.ws.binaryType = 'arraybuffer'; + + this.ws.onopen = () => { + console.log('WebSocket connected'); + this.state.connected = true; + this.reconnectDelay = 2000; // Reset delay + + const event = new CustomEvent('socketopen'); + this.dispatchEvent(event); + }; + + this.ws.onmessage = (event) => { + if (event.data instanceof ArrayBuffer) { + this.handleFrameMessage(event.data); + } + }; + + this.ws.onclose = () => { + console.log('WebSocket disconnected'); + this.state.connected = false; + this.ws = null; + + const event = new CustomEvent('socketclose'); + this.dispatchEvent(event); + + // Auto-reconnect + this.scheduleReconnect(); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + const event = new CustomEvent('socketerror', { detail: error }); + this.dispatchEvent(event); + }; + + } catch (error) { + console.error('Failed to create WebSocket:', error); + } + } + + private disconnectWebSocket(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = 0; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.state.connected = false; + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) return; + + console.log(`Reconnecting in ${this.reconnectDelay}ms...`); + + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = 0; + this.connectWebSocket(); + + // Exponential backoff + this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); + }, this.reconnectDelay); + } + + private cleanup(): void { + // Stop render loop + this.state.rendering = false; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = 0; + } + + // Cleanup WebGL resources + if (this.webglRenderer) { + this.webglRenderer.cleanup(); + } + + // Disconnect WebSocket + this.disconnectWebSocket(); + + console.log('LED Matrix component cleaned up'); + } +} + +// Register the custom element +customElements.define('led-matrix', LEDMatrix); diff --git a/effectsim/src/types.d.ts b/effectsim/src/types.d.ts new file mode 100644 index 0000000..d4c1cb3 --- /dev/null +++ b/effectsim/src/types.d.ts @@ -0,0 +1,59 @@ +// Core type definitions for LED Matrix Simulator + +export interface MatrixConfig { + panelsX: number; + panelsY: number; + panelCols: number; + panelRows: number; + pixelSize: number | 'auto'; + gap: number; + fpsCap: number; + lensFlareIntensity: number; +} + +export interface MatrixDimensions { + cols: number; + rows: number; + totalPixels: number; +} + +export interface PerformanceStats { + fps: number; + dropped: number; + renderMs: number; +} + +export interface CoordinateMapping { + logicalIndex: number; + bufferOffset: number; + panelX: number; + panelY: number; + inPanelX: number; + inPanelY: number; +} + +export interface FrameBuffer { + data: Uint8ClampedArray; + cols: number; + rows: number; + timestamp: number; +} + +// Custom events +export interface ReadyEventDetail { + cols: number; + rows: number; +} + +export interface StatsEventDetail { + fps: number; + dropped: number; + renderMs: number; +} + +// Component lifecycle +export interface ComponentState { + initialized: boolean; + connected: boolean; + rendering: boolean; +} \ No newline at end of file diff --git a/effectsim/src/util/fps.ts b/effectsim/src/util/fps.ts new file mode 100644 index 0000000..698a65f --- /dev/null +++ b/effectsim/src/util/fps.ts @@ -0,0 +1,142 @@ +// Performance monitoring utilities + +import type { PerformanceStats } from '../types.d.ts'; + +export class FPSCounter { + private frameCount: number = 0; + private lastTime: number = 0; + private startTime: number = 0; + private renderTimes: number[] = []; + private droppedFrames: number = 0; + private maxRenderSamples: number = 60; // Keep last 60 render times + + constructor() { + this.reset(); + } + + /** + * Reset all counters + */ + reset(): void { + this.frameCount = 0; + this.lastTime = performance.now(); + this.startTime = this.lastTime; + this.renderTimes = []; + this.droppedFrames = 0; + } + + /** + * Record the start of a frame render + */ + startFrame(): number { + return performance.now(); + } + + /** + * Record the end of a frame render + */ + endFrame(startTime: number): void { + const renderTime = performance.now() - startTime; + + // Store render time (keep only recent samples) + this.renderTimes.push(renderTime); + if (this.renderTimes.length > this.maxRenderSamples) { + this.renderTimes.shift(); + } + + this.frameCount++; + } + + /** + * Record a dropped frame + */ + dropFrame(): void { + this.droppedFrames++; + } + + /** + * Get current performance statistics + */ + getStats(): PerformanceStats { + const now = performance.now(); + const elapsed = (now - this.startTime) / 1000; // seconds + + // Calculate FPS over total elapsed time + const fps = elapsed > 0 ? this.frameCount / elapsed : 0; + + // Calculate average render time + const renderMs = this.renderTimes.length > 0 + ? this.renderTimes.reduce((a, b) => a + b, 0) / this.renderTimes.length + : 0; + + return { + fps: Math.round(fps * 10) / 10, // Round to 1 decimal + dropped: this.droppedFrames, + renderMs: Math.round(renderMs * 100) / 100 // Round to 2 decimals + }; + } + + /** + * Check if enough time has passed for stats update (typically ~1 second) + */ + shouldUpdateStats(intervalMs: number = 1000): boolean { + const now = performance.now(); + const elapsed = now - this.lastTime; + + if (elapsed >= intervalMs) { + this.lastTime = now; + return true; + } + + return false; + } +} + +/** + * Frame rate limiter utility + */ +export class FPSLimiter { + private targetFPS!: number; + private targetInterval!: number; + private lastFrameTime: number = 0; + + constructor(targetFPS: number = 0) { + this.setTargetFPS(targetFPS); + } + + /** + * Set target FPS (0 = uncapped) + */ + setTargetFPS(fps: number): void { + this.targetFPS = fps; + this.targetInterval = fps > 0 ? 1000 / fps : 0; + } + + /** + * Check if enough time has passed to render next frame + */ + shouldRender(currentTime: number): boolean { + if (this.targetFPS <= 0) { + // Uncapped - always render + return true; + } + + const elapsed = currentTime - this.lastFrameTime; + if (elapsed >= this.targetInterval) { + this.lastFrameTime = currentTime; + return true; + } + + return false; + } + + /** + * Get time until next frame should be rendered (in ms) + */ + getTimeToNextFrame(currentTime: number): number { + if (this.targetFPS <= 0) return 0; + + const elapsed = currentTime - this.lastFrameTime; + return Math.max(0, this.targetInterval - elapsed); + } +} \ No newline at end of file diff --git a/effectsim/src/util/lut.ts b/effectsim/src/util/lut.ts new file mode 100644 index 0000000..161d2f1 --- /dev/null +++ b/effectsim/src/util/lut.ts @@ -0,0 +1,64 @@ +// Simple coordinate mapping utilities + +import type { MatrixConfig, MatrixDimensions } from '../types.d.ts'; + +export class CoordinateLUT { + private dimensions: MatrixDimensions; + + constructor(config: MatrixConfig) { + this.dimensions = this.calculateDimensions(config); + } + + /** + * Update configuration and recalculate dimensions + */ + updateConfig(config: MatrixConfig): void { + const newDimensions = this.calculateDimensions(config); + this.dimensions = newDimensions; + console.log(`Matrix dimensions: ${this.dimensions.cols}×${this.dimensions.rows} (${this.dimensions.totalPixels} pixels)`); + } + + /** + * Get buffer offset for coordinate (row, col) - row-major layout for ImageData + * Returns -1 if coordinates are out of bounds + */ + getBufferOffset(row: number, col: number): number { + if (row < 0 || row >= this.dimensions.rows || + col < 0 || col >= this.dimensions.cols) { + return -1; + } + + // Row-major layout for ImageData: row * cols + col + const index = row * this.dimensions.cols + col; + + // Convert to ImageData buffer offset (RGBA = 4 bytes per pixel) + return index * 4; + } + + /** + * Get matrix dimensions + */ + getDimensions(): MatrixDimensions { + return { ...this.dimensions }; + } + + /** + * Calculate total matrix dimensions from panel configuration + */ + private calculateDimensions(config: MatrixConfig): MatrixDimensions { + const cols = config.panelsX * config.panelCols; + const rows = config.panelsY * config.panelRows; + return { + cols, + rows, + totalPixels: cols * rows + }; + } +} + +/** + * Utility function to create a LUT from configuration + */ +export function createCoordinateLUT(config: MatrixConfig): CoordinateLUT { + return new CoordinateLUT(config); +} \ No newline at end of file diff --git a/effectsim/src/util/webgl-renderer.ts b/effectsim/src/util/webgl-renderer.ts new file mode 100644 index 0000000..7bbc814 --- /dev/null +++ b/effectsim/src/util/webgl-renderer.ts @@ -0,0 +1,241 @@ +// WebGL LED Matrix Renderer + +import type { MatrixDimensions, FrameBuffer } from '../types.d.ts'; +import { + WebGLShaderProgram, + createProgram, + getUniformLocation, + getAttribLocation, + createFrameTexture, + createQuadBuffer, + VERTEX_SHADER_SOURCE, + FRAGMENT_SHADER_SOURCE +} from './webgl.js'; + +export class WebGLLEDRenderer { + private gl: WebGLRenderingContext; + private program: WebGLShaderProgram | null = null; + private frameTexture: WebGLTexture | null = null; + private quadBuffer: WebGLBuffer | null = null; + + // Cached uniform locations + private uniforms = { + frameTexture: null as WebGLUniformLocation | null, + resolution: null as WebGLUniformLocation | null, + matrixSize: null as WebGLUniformLocation | null, + ledRadius: null as WebGLUniformLocation | null, + ledSpacing: null as WebGLUniformLocation | null, + flareIntensity: null as WebGLUniformLocation | null, + hasFrameData: null as WebGLUniformLocation | null + }; + + // Current state + private currentDimensions: MatrixDimensions | null = null; + private isInitialized = false; + + constructor(canvas: HTMLCanvasElement) { + const gl = canvas.getContext('webgl'); + if (!gl) { + throw new Error('WebGL not supported'); + } + this.gl = gl; + + // Enable blending for lens flare effects + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + } + + /** + * Initialize WebGL resources + */ + initialize(): boolean { + try { + // Create shader program + this.program = createProgram(this.gl, VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE); + if (!this.program) { + console.error('Failed to create shader program'); + return false; + } + + // Get uniform locations + this.uniforms.frameTexture = getUniformLocation(this.gl, this.program, 'u_frameTexture'); + this.uniforms.resolution = getUniformLocation(this.gl, this.program, 'u_resolution'); + this.uniforms.matrixSize = getUniformLocation(this.gl, this.program, 'u_matrixSize'); + this.uniforms.ledRadius = getUniformLocation(this.gl, this.program, 'u_ledRadius'); + this.uniforms.ledSpacing = getUniformLocation(this.gl, this.program, 'u_ledSpacing'); + this.uniforms.flareIntensity = getUniformLocation(this.gl, this.program, 'u_flareIntensity'); + this.uniforms.hasFrameData = getUniformLocation(this.gl, this.program, 'u_hasFrameData'); + + // Create fullscreen quad buffer + this.quadBuffer = createQuadBuffer(this.gl); + if (!this.quadBuffer) { + console.error('Failed to create quad buffer'); + return false; + } + + // Create frame texture + this.frameTexture = createFrameTexture(this.gl); + if (!this.frameTexture) { + console.error('Failed to create frame texture'); + return false; + } + + this.isInitialized = true; + console.log('WebGL LED renderer initialized successfully'); + return true; + + } catch (error) { + console.error('WebGL initialization failed:', error); + return false; + } + } + + /** + * Update matrix dimensions and reallocate resources if needed + */ + updateDimensions(dimensions: MatrixDimensions): void { + if (!this.currentDimensions || + this.currentDimensions.cols !== dimensions.cols || + this.currentDimensions.rows !== dimensions.rows) { + + this.currentDimensions = { ...dimensions }; + console.log(`WebGL renderer updated to ${dimensions.cols}×${dimensions.rows}`); + } + } + + /** + * Upload frame data to texture + */ + updateFrame(frame: FrameBuffer): void { + if (!this.isInitialized || !this.frameTexture || !this.currentDimensions) { + return; + } + + this.gl.bindTexture(this.gl.TEXTURE_2D, this.frameTexture); + this.gl.texImage2D( + this.gl.TEXTURE_2D, + 0, // level + this.gl.RGB, // internal format + this.currentDimensions.cols, // width + this.currentDimensions.rows, // height + 0, // border + this.gl.RGB, // format + this.gl.UNSIGNED_BYTE, // type + frame.data // data + ); + } + + /** + * Render the current frame with LED effects + */ + render(canvasWidth: number, canvasHeight: number, config: { + gap: number; + lensFlareIntensity: number; + }, hasFrameData: boolean = true): void { + + if (!this.isInitialized || !this.program || !this.quadBuffer || !this.currentDimensions) { + return; + } + + const gl = this.gl; + + // Set viewport + gl.viewport(0, 0, canvasWidth, canvasHeight); + + // Clear with dark background + gl.clearColor(0.067, 0.067, 0.067, 1.0); // #111111 + gl.clear(gl.COLOR_BUFFER_BIT); + + // Use shader program + gl.useProgram(this.program.program); + + // Bind fullscreen quad + gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer); + + const positionAttrib = getAttribLocation(gl, this.program, 'a_position'); + if (positionAttrib !== -1) { + gl.enableVertexAttribArray(positionAttrib); + gl.vertexAttribPointer(positionAttrib, 2, gl.FLOAT, false, 0, 0); + } + + // Set uniforms + if (this.uniforms.resolution) { + gl.uniform2f(this.uniforms.resolution, canvasWidth, canvasHeight); + } + + if (this.uniforms.matrixSize) { + gl.uniform2f(this.uniforms.matrixSize, this.currentDimensions.cols, this.currentDimensions.rows); + } + + // LED parameters (matching current Canvas 2D implementation) + if (this.uniforms.ledRadius) { + gl.uniform1f(this.uniforms.ledRadius, 0.15); // 15% of pixel radius + } + + if (this.uniforms.ledSpacing) { + gl.uniform1f(this.uniforms.ledSpacing, 0.25); // 25% spacing between LED centers + } + + if (this.uniforms.flareIntensity) { + gl.uniform1f(this.uniforms.flareIntensity, config.lensFlareIntensity); + } + + if (this.uniforms.hasFrameData) { + gl.uniform1i(this.uniforms.hasFrameData, hasFrameData ? 1 : 0); + } + + // Bind frame texture + if (hasFrameData && this.frameTexture) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.frameTexture); + + if (this.uniforms.frameTexture) { + gl.uniform1i(this.uniforms.frameTexture, 0); + } + } + + // Draw fullscreen quad + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + + // Clean up + if (positionAttrib !== -1) { + gl.disableVertexAttribArray(positionAttrib); + } + } + + /** + * Handle canvas resize + */ + resize(canvasWidth: number, canvasHeight: number): void { + if (!this.isInitialized) return; + + // Update canvas size + this.gl.canvas.width = canvasWidth; + this.gl.canvas.height = canvasHeight; + } + + /** + * Clean up WebGL resources + */ + cleanup(): void { + if (!this.gl) return; + + if (this.frameTexture) { + this.gl.deleteTexture(this.frameTexture); + this.frameTexture = null; + } + + if (this.quadBuffer) { + this.gl.deleteBuffer(this.quadBuffer); + this.quadBuffer = null; + } + + if (this.program) { + this.gl.deleteProgram(this.program.program); + this.program = null; + } + + this.isInitialized = false; + console.log('WebGL renderer cleaned up'); + } +} \ No newline at end of file diff --git a/effectsim/src/util/webgl.ts b/effectsim/src/util/webgl.ts new file mode 100644 index 0000000..11dd5af --- /dev/null +++ b/effectsim/src/util/webgl.ts @@ -0,0 +1,274 @@ +// WebGL utilities for LED Matrix rendering + +export interface WebGLShaderProgram { + program: WebGLProgram; + uniforms: { [key: string]: WebGLUniformLocation }; + attributes: { [key: string]: number }; +} + +/** + * Compile a WebGL shader + */ +export function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null { + const shader = gl.createShader(type); + if (!shader) { + console.error('Failed to create shader'); + return null; + } + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error('Shader compilation error:', gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + + return shader; +} + +/** + * Create and link a WebGL program + */ +export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string): WebGLShaderProgram | null { + const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource); + const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource); + + if (!vertexShader || !fragmentShader) { + return null; + } + + const program = gl.createProgram(); + if (!program) { + console.error('Failed to create program'); + return null; + } + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error('Program linking error:', gl.getProgramInfoLog(program)); + gl.deleteProgram(program); + return null; + } + + // Clean up shaders + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + + return { + program, + uniforms: {}, + attributes: {} + }; +} + +/** + * Get and cache uniform locations + */ +export function getUniformLocation(gl: WebGLRenderingContext, shaderProgram: WebGLShaderProgram, name: string): WebGLUniformLocation | null { + if (shaderProgram.uniforms[name] !== undefined) { + return shaderProgram.uniforms[name]; + } + + const location = gl.getUniformLocation(shaderProgram.program, name); + if (location === null) { + console.warn(`Uniform '${name}' not found in shader program`); + return null; + } + + shaderProgram.uniforms[name] = location; + return location; +} + +/** + * Get and cache attribute locations + */ +export function getAttribLocation(gl: WebGLRenderingContext, shaderProgram: WebGLShaderProgram, name: string): number { + if (shaderProgram.attributes[name] !== undefined) { + return shaderProgram.attributes[name]; + } + + const location = gl.getAttribLocation(shaderProgram.program, name); + if (location === -1) { + console.warn(`Attribute '${name}' not found in shader program`); + } + + shaderProgram.attributes[name] = location; + return location; +} + +/** + * Create a texture for RGB frame data + */ +export function createFrameTexture(gl: WebGLRenderingContext): WebGLTexture | null { + const texture = gl.createTexture(); + if (!texture) { + console.error('Failed to create texture'); + return null; + } + + gl.bindTexture(gl.TEXTURE_2D, texture); + + // Set texture parameters for pixel-perfect rendering + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + + return texture; +} + +/** + * Create a buffer for fullscreen quad vertices + */ +export function createQuadBuffer(gl: WebGLRenderingContext): WebGLBuffer | null { + const buffer = gl.createBuffer(); + if (!buffer) { + console.error('Failed to create buffer'); + return null; + } + + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + + // Fullscreen quad vertices (two triangles) + const vertices = new Float32Array([ + -1.0, -1.0, // Bottom left + 1.0, -1.0, // Bottom right + -1.0, 1.0, // Top left + 1.0, 1.0 // Top right + ]); + + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + return buffer; +} + +// Vertex shader source - simple fullscreen quad +export const VERTEX_SHADER_SOURCE = ` +attribute vec2 a_position; +varying vec2 v_texCoord; + +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = (a_position + 1.0) / 2.0; +} +`; + +// Fragment shader source - LED cluster rendering +export const FRAGMENT_SHADER_SOURCE = ` +precision mediump float; + +uniform sampler2D u_frameTexture; +uniform vec2 u_resolution; +uniform vec2 u_matrixSize; +uniform float u_ledRadius; +uniform float u_ledSpacing; +uniform float u_flareIntensity; +uniform bool u_hasFrameData; + +varying vec2 v_texCoord; + +// Rotate a 2D point by angle (in radians) +vec2 rotate(vec2 point, float angle) { + float c = cos(angle); + float s = sin(angle); + return vec2(point.x * c - point.y * s, point.x * s + point.y * c); +} + +void main() { + // Convert screen coords to logical pixel coords + vec2 logicalPixel = v_texCoord * u_matrixSize; + vec2 pixelCenter = floor(logicalPixel) + 0.5; + + // Sample RGB data for this logical pixel (or use pattern color) + vec3 pixelColor; + if (u_hasFrameData) { + pixelColor = texture2D(u_frameTexture, pixelCenter / u_matrixSize).rgb; + } else { + // LED pattern mode - dim LEDs for visibility + pixelColor = vec3(0.5, 0.5, 0.5); + } + + // Calculate position within the logical pixel + vec2 localPos = logicalPixel - floor(logicalPixel) - 0.5; + + // LED cluster positions (2x2 grid pattern) + vec2 ledPositions[4]; + ledPositions[0] = vec2(-u_ledSpacing, -u_ledSpacing); // Top-left + ledPositions[1] = vec2(u_ledSpacing, -u_ledSpacing); // Top-right + ledPositions[2] = vec2(-u_ledSpacing, u_ledSpacing); // Bottom-left + ledPositions[3] = vec2(u_ledSpacing, u_ledSpacing); // Bottom-right + + vec4 finalColor = vec4(0.067, 0.067, 0.067, 1.0); // #111111 background // Black background (#111) + + // First pass: render LED packages and LEDs + for (int i = 0; i < 4; i++) { + vec2 ledCenter = ledPositions[i]; + vec2 ledLocalPos = localPos - ledCenter; + + // Calculate LED package size (diamond when rotated 45 degrees) + float packageSize = u_ledSpacing * sqrt(2.0); + + // Check if we're inside the diamond package (rotated square) + vec2 rotatedPos = rotate(ledLocalPos, -0.785398); // -45 degrees + bool insidePackage = abs(rotatedPos.x) <= packageSize / 2.0 && abs(rotatedPos.y) <= packageSize / 2.0; + + if (insidePackage) { + // Inside package - dark grey background + finalColor.rgb = vec3(0.2, 0.2, 0.2); // Dark grey package (#333) + + // Check if we're inside the circular LED + // LED diameter should equal package side length + float distToLED = length(ledLocalPos); + float circleRadius = packageSize / 2.0; // Circle diameter = package side length + if (distToLED <= circleRadius) { + // Inside circular LED - apply pixel color + finalColor.rgb = pixelColor; + } + break; + } + } + + // Second pass: apply lens flare effects from current and neighboring pixels + if (u_hasFrameData) { + // Check flare from neighboring pixels to allow bleeding across boundaries + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + // Sample neighboring pixel color + vec2 neighborPixelCenter = pixelCenter + vec2(float(dx), float(dy)); + vec3 neighborPixelColor = texture2D(u_frameTexture, neighborPixelCenter / u_matrixSize).rgb; + float neighborBrightness = max(max(neighborPixelColor.r, neighborPixelColor.g), neighborPixelColor.b); + + if (neighborBrightness > 0.1) { + // Calculate offset for neighbor pixel's LED positions + vec2 neighborOffset = vec2(float(dx), float(dy)); + + // Check all 4 LEDs in this neighboring pixel + for (int i = 0; i < 4; i++) { + vec2 neighborLEDCenter = ledPositions[i] + neighborOffset; + vec2 ledLocalPos = localPos - neighborLEDCenter; + float distToLED = length(ledLocalPos); + + float packageSize = u_ledSpacing * sqrt(2.0); + float circleRadius = packageSize / 2.0; + float flareRadius = circleRadius * (1.0 + neighborBrightness * u_flareIntensity * 2.0); + + if (distToLED <= flareRadius && distToLED > circleRadius) { + // Outside LED circle but within flare radius + float flareIntensity = max(0.0, (flareRadius - distToLED) / (flareRadius - circleRadius)); + flareIntensity *= neighborBrightness * u_flareIntensity * 0.3; + // Additive blend for lens flare from neighboring LEDs + finalColor.rgb += neighborPixelColor * flareIntensity; + } + } + } + } + } + } + + gl_FragColor = finalColor; +} +`; \ No newline at end of file diff --git a/effectsim/styles/component.css b/effectsim/styles/component.css new file mode 100644 index 0000000..fd2abb7 --- /dev/null +++ b/effectsim/styles/component.css @@ -0,0 +1,26 @@ +/* LED Matrix component styles */ + +:host { + display: block; + width: 100%; + height: 100%; + background: var(--led-off-bg, #111111); + border-radius: var(--led-border-radius, 4px); +} + +canvas { + width: 100%; + height: 100%; + display: block; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + border-radius: var(--led-radius, 0); +} + +/* Default CSS custom properties */ +:host { + --led-radius: 0px; + --led-off-bg: #0a0a0a; +} \ No newline at end of file diff --git a/effectsim/tsconfig.json b/effectsim/tsconfig.json new file mode 100644 index 0000000..2705786 --- /dev/null +++ b/effectsim/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ES2022", + "moduleResolution": "node", + "lib": ["DOM", "ES2024"], + "outDir": "dist", + "strict": true, + "skipLibCheck": true, + "sourceMap": true, + "declaration": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "server"] +} \ No newline at end of file