diff options
Diffstat (limited to 'tools/shader_editor/index.html')
| -rw-r--r-- | tools/shader_editor/index.html | 767 |
1 files changed, 767 insertions, 0 deletions
diff --git a/tools/shader_editor/index.html b/tools/shader_editor/index.html new file mode 100644 index 0000000..d74b9ff --- /dev/null +++ b/tools/shader_editor/index.html @@ -0,0 +1,767 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>WGSL Shader Editor</title> + <style> +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", monospace; + background: #1e1e1e; + color: #d4d4d4; + overflow: hidden; + height: 100vh; +} + +.container { + display: flex; + height: 100vh; +} + +.preview-pane { + flex: 0 0 70%; + background: #252526; + position: relative; + display: flex; + flex-direction: column; +} + +#preview-canvas { + flex: 1; + width: 100%; + height: auto; + background: #000; +} + +.fps-counter { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.7); + padding: 5px 10px; + border-radius: 3px; + font-size: 12px; + color: #0f0; + font-family: monospace; +} + +.error-overlay { + position: absolute; + top: 50px; + left: 10px; + right: 10px; + background: rgba(200, 0, 0, 0.9); + color: #fff; + padding: 15px; + border-radius: 5px; + font-family: monospace; + font-size: 12px; + white-space: pre-wrap; + max-height: 50%; + overflow-y: auto; +} + +.error-overlay.hidden { + display: none; +} + +.controls { + background: #2d2d30; + padding: 15px; + border-top: 1px solid #3e3e42; +} + +.control-group { + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 10px; +} + +.control-group label { + font-size: 13px; +} + +.control-group button { + background: #0e639c; + color: #fff; + border: none; + padding: 6px 12px; + border-radius: 3px; + cursor: pointer; + font-size: 13px; +} + +.control-group button:hover { + background: #1177bb; +} + +.control-group input[type="number"], +.control-group select { + background: #3c3c3c; + color: #d4d4d4; + border: 1px solid #3e3e42; + padding: 4px 8px; + border-radius: 3px; + font-size: 13px; +} + +.control-group input[type="range"] { + flex: 1; + max-width: 200px; +} + +.control-group input[type="checkbox"] { + margin: 0 5px; +} + +.progress-bar { + flex: 1; + height: 8px; + background: #3c3c3c; + border-radius: 4px; + overflow: hidden; + max-width: 300px; +} + +.progress-fill { + height: 100%; + background: #0e639c; + transition: width 0.1s linear; +} + +.editor-pane { + flex: 0 0 30%; + background: #1e1e1e; + display: flex; + flex-direction: column; + border-left: 1px solid #3e3e42; +} + +.editor-header { + background: #2d2d30; + padding: 10px; + border-bottom: 1px solid #3e3e42; + display: flex; + gap: 10px; +} + +.editor-header button { + background: #0e639c; + color: #fff; + border: none; + padding: 6px 12px; + border-radius: 3px; + cursor: pointer; + font-size: 13px; +} + +.editor-header button:hover { + background: #1177bb; +} + +#code-editor { + flex: 1; + width: 100%; + background: #1e1e1e; + color: #d4d4d4; + border: none; + padding: 15px; + font-family: 'Courier New', monospace; + font-size: 13px; + line-height: 1.5; + resize: none; + outline: none; +} + +.snippets-panel { + background: #252526; + border-top: 1px solid #3e3e42; + padding: 15px; + max-height: 40%; + overflow-y: auto; +} + +.snippets-panel.hidden { + display: none; +} + +.snippets-panel h3 { + font-size: 14px; + margin-bottom: 10px; + color: #4ec9b0; +} + +.snippet-item { + padding: 5px; + cursor: pointer; + border-radius: 3px; + font-family: monospace; + font-size: 12px; +} + +.snippet-item:hover { + background: #2a2d2e; +} + </style> +</head> +<body> + <div class="container"> + <div class="preview-pane"> + <canvas id="preview-canvas"></canvas> + <div id="error-overlay" class="error-overlay hidden"></div> + <div class="fps-counter" id="fps-counter">FPS: 0</div> + + <div class="controls"> + <div class="control-group"> + <button id="play-pause-btn">▶ Play</button> + <button id="reset-time-btn">Reset Time</button> + </div> + + <div class="control-group"> + <label>Loop Time: <span id="loop-time-value">0.00</span></label> + <div class="progress-bar"> + <div id="loop-time-bar" class="progress-fill"></div> + </div> + </div> + + <div class="control-group"> + <label>Loop Period: <input type="number" id="loop-period" min="0.1" max="10" step="0.1" value="2.0">s</label> + </div> + + <div class="control-group"> + <label>Time: <span id="time-value">0.00</span>s</label> + </div> + + <div class="control-group"> + <label>Audio Peak: <input type="range" id="audio-peak" min="0" max="1" step="0.01" value="0.5"></label> + <span id="audio-peak-value">0.50</span> + <label><input type="checkbox" id="audio-pulse"> Auto Pulse</label> + </div> + + <div class="control-group"> + <label>Resolution: + <select id="resolution"> + <option value="512x512">512×512</option> + <option value="1024x1024" selected>1024×1024</option> + <option value="1920x1080">1920×1080</option> + <option value="1280x720">1280×720</option> + </select> + </label> + </div> + </div> + </div> + + <div class="editor-pane"> + <div class="editor-header"> + <input type="file" id="file-input" accept=".wgsl" style="display: none;"> + <button id="load-btn">Load .wgsl</button> + <button id="save-btn">Save .wgsl</button> + <button id="snippets-btn">📚 Snippets</button> + </div> + + <textarea id="code-editor" spellcheck="false"></textarea> + + <div id="snippets-panel" class="snippets-panel hidden"> + <h3>Available Snippets</h3> + <div id="snippets-list"></div> + </div> + </div> + </div> + + <script> +// Shader Composer +class ShaderComposer { + constructor() { + this.snippets = new Map(); + } + + registerSnippet(name, code) { + this.snippets.set(name, code); + } + + resolveRecursive(source, included, substitutions) { + const lines = source.split('\n'); + const result = []; + + for (const line of lines) { + if (line.trim().startsWith('#include ')) { + const match = line.match(/#include\s+"([^"]+)"/); + if (match) { + let name = match[1]; + + if (substitutions && substitutions.has(name)) { + name = substitutions.get(name); + } + + if (!included.has(name)) { + included.add(name); + const snippet = this.snippets.get(name); + if (snippet) { + result.push(`// --- Included: ${name} ---`); + const resolved = this.resolveRecursive(snippet, included, substitutions); + result.push(resolved); + result.push(`// --- End Include: ${name} ---`); + } else { + result.push(`// ERROR: Snippet not found: ${name}`); + } + } + } + } else { + result.push(line); + } + } + + return result.join('\n'); + } + + compose(mainCode, dependencies = [], substitutions = null) { + const result = ['// Generated by ShaderComposer', '']; + const included = new Set(); + + for (let dep of dependencies) { + let name = dep; + if (substitutions && substitutions.has(name)) { + name = substitutions.get(name); + } + + if (!included.has(name)) { + included.add(name); + const snippet = this.snippets.get(name); + if (snippet) { + result.push(`// --- Dependency: ${name} ---`); + result.push(this.resolveRecursive(snippet, included, substitutions)); + result.push(''); + } + } + } + + result.push('// --- Main Code ---'); + result.push(this.resolveRecursive(mainCode, included, substitutions)); + + return result.join('\n'); + } +} + +// WebGPU Preview +class WebGPUPreview { + constructor(canvas) { + this.canvas = canvas; + this.device = null; + this.context = null; + this.pipeline = null; + this.uniformBuffer = null; + this.bindGroup = null; + this.vertexBuffer = null; + this.sampler = null; + this.texture = null; + this.textureView = null; + this.isPlaying = false; + this.startTime = performance.now(); + this.pauseTime = 0; + this.currentTime = 0; + this.loopPeriod = 2.0; + this.audioPeak = 0.5; + this.autoPulse = false; + this.lastFrameTime = 0; + this.frameCount = 0; + this.fps = 0; + this.errorCallback = null; + } + + async init() { + if (!navigator.gpu) { + throw new Error('WebGPU not supported'); + } + + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) { + throw new Error('No GPU adapter found'); + } + + this.device = await adapter.requestDevice(); + this.context = this.canvas.getContext('webgpu'); + + const format = navigator.gpu.getPreferredCanvasFormat(); + this.context.configure({ + device: this.device, + format: format, + alphaMode: 'opaque', + }); + + const vertices = new Float32Array([-1, -1, 3, -1, -1, 3]); + this.vertexBuffer = this.device.createBuffer({ + size: vertices.byteLength, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + mappedAtCreation: true, + }); + new Float32Array(this.vertexBuffer.getMappedRange()).set(vertices); + this.vertexBuffer.unmap(); + + this.uniformBuffer = this.device.createBuffer({ + size: 32, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + this.sampler = this.device.createSampler({ + magFilter: 'linear', + minFilter: 'linear', + }); + + this.texture = this.device.createTexture({ + size: [1, 1], + format: 'rgba8unorm', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + this.textureView = this.texture.createView(); + + const blackPixel = new Uint8Array([0, 0, 0, 255]); + this.device.queue.writeTexture( + { texture: this.texture }, + blackPixel, + { bytesPerRow: 4 }, + [1, 1] + ); + + await this.loadShader(this.getDefaultShader()); + } + + getDefaultShader() { + return `@group(0) @binding(0) var smplr: sampler; +@group(0) @binding(1) var txt: texture_2d<f32>; + +struct CommonUniforms { + resolution: vec2<f32>, + _pad0: f32, + _pad1: f32, + aspect_ratio: f32, + time: f32, + beat: f32, + audio_intensity: f32, +}; +@group(0) @binding(2) var<uniform> uniforms: CommonUniforms; + +@vertex fn vs_main(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> { + var pos = array<vec2<f32>, 3>( + vec2<f32>(-1, -1), + vec2<f32>(3, -1), + vec2<f32>(-1, 3) + ); + return vec4<f32>(pos[i], 0.0, 1.0); +} + +@fragment fn fs_main(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> { + let uv = p.xy / uniforms.resolution; + let c = vec3<f32>( + 0.5 + 0.5 * sin(uniforms.time + uv.x * 3.0), + 0.5 + 0.5 * cos(uniforms.time + uv.y * 3.0), + 0.5 + 0.5 * sin(uniforms.beat * 6.28) + ); + return vec4<f32>(c * uniforms.audio_intensity, 1.0); +}`; + } + + async loadShader(shaderCode) { + try { + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + const info = await shaderModule.getCompilationInfo(); + if (info.messages.length > 0) { + const errors = info.messages.filter(m => m.type === 'error'); + if (errors.length > 0) { + const errorText = errors.map(e => `Line ${e.lineNum}: ${e.message}`).join('\n'); + throw new Error(errorText); + } + } + + this.pipeline = this.device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: shaderModule, + entryPoint: 'vs_main', + buffers: [{ + arrayStride: 8, + attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }], + }], + }, + fragment: { + module: shaderModule, + entryPoint: 'fs_main', + targets: [{ format: navigator.gpu.getPreferredCanvasFormat() }], + }, + primitive: { topology: 'triangle-list' }, + }); + + this.bindGroup = this.device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: this.sampler }, + { binding: 1, resource: this.textureView }, + { binding: 2, resource: { buffer: this.uniformBuffer } }, + ], + }); + + if (this.errorCallback) this.errorCallback(null); + } catch (error) { + if (this.errorCallback) this.errorCallback(error.message); + throw error; + } + } + + updateUniforms() { + const now = performance.now(); + if (!this.isPlaying) { + this.currentTime = this.pauseTime; + } else { + this.currentTime = (now - this.startTime) / 1000; + } + + const loopTime = (this.currentTime % this.loopPeriod) / this.loopPeriod; + const audioPeak = this.autoPulse ? 0.5 + 0.5 * Math.sin(this.currentTime * Math.PI) : this.audioPeak; + + const uniformData = new Float32Array([ + this.canvas.width, this.canvas.height, 0, 0, + this.canvas.width / this.canvas.height, + this.currentTime, loopTime, audioPeak, + ]); + + this.device.queue.writeBuffer(this.uniformBuffer, 0, uniformData); + + this.frameCount++; + if (now - this.lastFrameTime >= 1000) { + this.fps = this.frameCount; + this.frameCount = 0; + this.lastFrameTime = now; + } + + return { time: this.currentTime, loopTime, audioPeak }; + } + + render() { + if (!this.pipeline) return; + + const stats = this.updateUniforms(); + + const commandEncoder = this.device.createCommandEncoder(); + const textureView = this.context.getCurrentTexture().createView(); + + const renderPass = commandEncoder.beginRenderPass({ + colorAttachments: [{ + view: textureView, + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + loadOp: 'clear', + storeOp: 'store', + }], + }); + + renderPass.setPipeline(this.pipeline); + renderPass.setBindGroup(0, this.bindGroup); + renderPass.setVertexBuffer(0, this.vertexBuffer); + renderPass.draw(3); + renderPass.end(); + + this.device.queue.submit([commandEncoder.finish()]); + + return stats; + } + + play() { + this.isPlaying = true; + this.startTime = this.pauseTime > 0 ? performance.now() - this.pauseTime * 1000 : performance.now(); + } + + pause() { + this.isPlaying = false; + this.pauseTime = this.currentTime; + } + + resetTime() { + this.startTime = performance.now(); + this.pauseTime = 0; + this.currentTime = 0; + } + + setResolution(width, height) { + this.canvas.width = width; + this.canvas.height = height; + } + + setLoopPeriod(period) { this.loopPeriod = period; } + setAudioPeak(peak) { this.audioPeak = peak; } + setAutoPulse(enabled) { this.autoPulse = enabled; } + setErrorCallback(callback) { this.errorCallback = callback; } + getFPS() { return this.fps; } +} + +// Main App +const composer = new ShaderComposer(); +composer.registerSnippet('common_uniforms', `struct CommonUniforms { + resolution: vec2<f32>, + _pad0: f32, + _pad1: f32, + aspect_ratio: f32, + time: f32, + beat: f32, + audio_intensity: f32, +};`); + +const canvas = document.getElementById('preview-canvas'); +const preview = new WebGPUPreview(canvas); +const editor = document.getElementById('code-editor'); +const playPauseBtn = document.getElementById('play-pause-btn'); +const resetTimeBtn = document.getElementById('reset-time-btn'); +const loopPeriodInput = document.getElementById('loop-period'); +const audioPeakInput = document.getElementById('audio-peak'); +const audioPeakValue = document.getElementById('audio-peak-value'); +const autoPulseCheckbox = document.getElementById('audio-pulse'); +const resolutionSelect = document.getElementById('resolution'); +const loadBtn = document.getElementById('load-btn'); +const saveBtn = document.getElementById('save-btn'); +const snippetsBtn = document.getElementById('snippets-btn'); +const fileInput = document.getElementById('file-input'); +const errorOverlay = document.getElementById('error-overlay'); +const fpsCounter = document.getElementById('fps-counter'); +const timeValue = document.getElementById('time-value'); +const loopTimeValue = document.getElementById('loop-time-value'); +const loopTimeBar = document.getElementById('loop-time-bar'); +const snippetsPanel = document.getElementById('snippets-panel'); +const snippetsList = document.getElementById('snippets-list'); + +let composeTimeout = null; + +async function init() { + try { + await preview.init(); + + preview.setErrorCallback((error) => { + if (error) { + errorOverlay.textContent = error; + errorOverlay.classList.remove('hidden'); + } else { + errorOverlay.classList.add('hidden'); + } + }); + + updateResolution(); + editor.value = preview.getDefaultShader(); + renderLoop(); + } catch (error) { + errorOverlay.textContent = `Init failed: ${error.message}`; + errorOverlay.classList.remove('hidden'); + } +} + +function renderLoop() { + const stats = preview.render(); + if (stats) { + fpsCounter.textContent = `FPS: ${preview.getFPS()}`; + timeValue.textContent = stats.time.toFixed(2); + loopTimeValue.textContent = stats.loopTime.toFixed(2); + loopTimeBar.style.width = `${stats.loopTime * 100}%`; + audioPeakValue.textContent = stats.audioPeak.toFixed(2); + } + requestAnimationFrame(renderLoop); +} + +playPauseBtn.addEventListener('click', () => { + if (preview.isPlaying) { + preview.pause(); + playPauseBtn.textContent = '▶ Play'; + } else { + preview.play(); + playPauseBtn.textContent = '⏸ Pause'; + } +}); + +resetTimeBtn.addEventListener('click', () => preview.resetTime()); +loopPeriodInput.addEventListener('input', (e) => preview.setLoopPeriod(parseFloat(e.target.value))); +audioPeakInput.addEventListener('input', (e) => preview.setAudioPeak(parseFloat(e.target.value))); +autoPulseCheckbox.addEventListener('change', (e) => { + preview.setAutoPulse(e.target.checked); + audioPeakInput.disabled = e.target.checked; +}); +resolutionSelect.addEventListener('change', updateResolution); + +function updateResolution() { + const [width, height] = resolutionSelect.value.split('x').map(Number); + preview.setResolution(width, height); +} + +editor.addEventListener('input', () => { + clearTimeout(composeTimeout); + composeTimeout = setTimeout(composeAndLoad, 300); +}); + +editor.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + const start = editor.selectionStart; + editor.value = editor.value.substring(0, start) + ' ' + editor.value.substring(editor.selectionEnd); + editor.selectionStart = editor.selectionEnd = start + 4; + } +}); + +async function composeAndLoad() { + try { + const composed = composer.compose(editor.value); + await preview.loadShader(composed); + } catch (error) { + console.error('Compose failed:', error); + } +} + +loadBtn.addEventListener('click', () => { + fileInput.value = ''; + fileInput.click(); +}); + +fileInput.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (event) => { + editor.value = event.target.result; + composeAndLoad(); + }; + reader.readAsText(file); + } +}); + +saveBtn.addEventListener('click', () => { + const blob = new Blob([editor.value], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + a.download = `shader_${timestamp}.wgsl`; + a.click(); + URL.revokeObjectURL(url); +}); + +snippetsBtn.addEventListener('click', () => snippetsPanel.classList.toggle('hidden')); + +document.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + saveBtn.click(); + } + if ((e.ctrlKey || e.metaKey) && e.key === 'o') { + e.preventDefault(); + loadBtn.click(); + } + if (e.key === ' ' && !editor.matches(':focus')) { + e.preventDefault(); + playPauseBtn.click(); + } +}); + +init(); + </script> +</body> +</html> |
