A Pen by Dan Brickley on CodePen.
Created
November 23, 2025 20:58
-
-
Save danbri/11d169b3b09f37a3fbb8f2390b8b5b02 to your computer and use it in GitHub Desktop.
sdf scene graph v2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" | |
| content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> | |
| <title>Integrated Raymarcher App</title> | |
| <style> | |
| /* Global styles */ | |
| body { | |
| margin: 0; | |
| font-family: system-ui, -apple-system, sans-serif; | |
| background: #1e1e1e; | |
| color: #fff; | |
| } | |
| /* Make sure the main app fills the viewport */ | |
| raymarcher-app { | |
| display: block; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <raymarcher-app></raymarcher-app> | |
| <!-- Module script defines global logger, sceneGraph, components, and tests --> | |
| <script type="module"> | |
| /********************** | |
| * Global Logger & Scene Graph | |
| **********************/ | |
| class GlobalDebugLogger { | |
| constructor() { | |
| this.startTime = performance.now(); | |
| } | |
| logEvent(message, type = 'info') { | |
| const event = new CustomEvent('debug-log', { | |
| detail: { | |
| message, | |
| type, | |
| time: ((performance.now() - this.startTime) / 1000).toFixed(2) | |
| } | |
| }); | |
| window.dispatchEvent(event); | |
| console.log(`[${((performance.now()-this.startTime)/1000).toFixed(2)}s] ${message}`); | |
| } | |
| clear() { | |
| this.startTime = performance.now(); | |
| window.dispatchEvent(new CustomEvent('debug-clear')); | |
| } | |
| } | |
| window.debugLogger = new GlobalDebugLogger(); | |
| // Our (very simple) scene graph: an array of nodes. | |
| window.sceneGraph = []; | |
| /********************** | |
| * Debug Console Component | |
| **********************/ | |
| class DebugConsoleApp extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { display: block; height: 100%; background: #1a1a1a; padding: 10px; box-sizing: border-box; } | |
| .header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| background: #333; | |
| padding: 8px; | |
| border-radius: 4px; | |
| } | |
| .content { | |
| margin-top: 10px; | |
| height: calc(100% - 40px); | |
| overflow-y: auto; | |
| font-family: monospace; | |
| font-size: 12px; | |
| } | |
| .log-entry { margin-bottom: 4px; } | |
| .log-entry.info { color: #93c5fd; } | |
| .log-entry.success { color: #86efac; } | |
| .log-entry.error { color: #fca5a5; } | |
| </style> | |
| <div class="header"> | |
| <span>Debug Console</span> | |
| <button id="clearBtn">Clear</button> | |
| </div> | |
| <div class="content" id="logContent"></div> | |
| `; | |
| } | |
| connectedCallback() { | |
| this.shadowRoot.getElementById("clearBtn") | |
| .addEventListener("click", () => { | |
| this.shadowRoot.getElementById("logContent").innerHTML = ""; | |
| window.debugLogger.clear(); | |
| }); | |
| window.addEventListener("debug-log", this.handleLog.bind(this)); | |
| window.addEventListener("debug-clear", this.handleClear.bind(this)); | |
| } | |
| handleLog(e) { | |
| const { message, type, time } = e.detail; | |
| const entry = document.createElement("div"); | |
| entry.className = `log-entry ${type}`; | |
| entry.textContent = `[${time}s] ${message}`; | |
| this.shadowRoot.getElementById("logContent").appendChild(entry); | |
| this.shadowRoot.getElementById("logContent").scrollTop = | |
| this.shadowRoot.getElementById("logContent").scrollHeight; | |
| } | |
| handleClear() { | |
| this.shadowRoot.getElementById("logContent").innerHTML = ""; | |
| } | |
| } | |
| customElements.define("debug-console-app", DebugConsoleApp); | |
| /********************** | |
| * Node Editor Component | |
| **********************/ | |
| class NodeEditorApp extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; margin: 0; padding: 0; } | |
| :host { display: block; width: 100%; height: 100%; position: relative; background: #1e1e1e; overflow: hidden; } | |
| .toolbar { | |
| position: fixed; | |
| top: 0; left: 0; right: 0; | |
| height: 48px; | |
| background: #1a1a1a; | |
| color: white; | |
| display: flex; | |
| align-items: center; | |
| padding: 0 16px; | |
| z-index: 2; | |
| } | |
| .palette { | |
| position: fixed; | |
| top: 48px; left: 0; bottom: 0; | |
| width: 80px; | |
| background: white; | |
| padding: 8px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| z-index: 2; | |
| } | |
| .palette-button { | |
| width: 100%; | |
| aspect-ratio: 1; | |
| padding: 8px; | |
| border: none; | |
| border-radius: 8px; | |
| background: white; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.1); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 4px; | |
| color: #2563eb; | |
| } | |
| .palette-button:active { background: #f3f4f6; } | |
| .canvas { | |
| position: absolute; | |
| top: 48px; left: 80px; right: 0; bottom: 0; | |
| background: #1e1e1e; | |
| overflow: hidden; | |
| } | |
| .connections { | |
| position: absolute; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| pointer-events: none; | |
| } | |
| .node { | |
| position: absolute; | |
| background: white; | |
| border-radius: 8px; | |
| padding: 12px; | |
| min-width: 140px; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| } | |
| .node-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } | |
| .node-body { display: flex; flex-direction: column; gap: 8px; } | |
| .param-row { display: flex; align-items: center; gap: 8px; } | |
| .param-label { font-size: 14px; min-width: 50px; } | |
| .param-input { | |
| width: 60px; | |
| padding: 4px; | |
| border: 1px solid #e5e7eb; | |
| border-radius: 4px; | |
| font-size: 14px; | |
| } | |
| .port { | |
| position: absolute; | |
| width: 16px; | |
| height: 16px; | |
| background: white; | |
| border: 2px solid #2563eb; | |
| border-radius: 50%; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| } | |
| .port-in { left: -8px; } | |
| .port-out { right: -8px; } | |
| @keyframes rainbow { | |
| 0% { border-color: red; } | |
| 20% { border-color: orange; } | |
| 40% { border-color: yellow; } | |
| 60% { border-color: green; } | |
| 80% { border-color: blue; } | |
| 100% { border-color: violet; } | |
| } | |
| .port-rainbow { animation: rainbow 1s linear infinite; } | |
| </style> | |
| <div class="toolbar"> | |
| <h2>SDF Node Editor</h2> | |
| </div> | |
| <div class="palette"> | |
| <button class="palette-button" data-type="sphere"> | |
| <svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <circle cx="12" cy="12" r="10"/> | |
| </svg> | |
| <span>Sphere</span> | |
| </button> | |
| <button class="palette-button" data-type="box"> | |
| <svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <rect x="4" y="4" width="16" height="16"/> | |
| </svg> | |
| <span>Box</span> | |
| </button> | |
| <button class="palette-button" data-type="union"> | |
| <svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M8 12h8M12 8v8"/> | |
| </svg> | |
| <span>Union</span> | |
| </button> | |
| <button class="palette-button" data-type="subtract"> | |
| <svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M8 12h8"/> | |
| </svg> | |
| <span>Subtract</span> | |
| </button> | |
| </div> | |
| <div class="canvas" id="canvas"> | |
| <svg class="connections" id="connections"></svg> | |
| </div> | |
| `; | |
| this.nodes = []; | |
| this.connections = []; | |
| this.draggingNode = null; | |
| this.offset = { x: 0, y: 0 }; | |
| this.tempConnection = null; | |
| } | |
| connectedCallback() { | |
| const shadow = this.shadowRoot; | |
| shadow.querySelectorAll(".palette-button").forEach(button => { | |
| button.addEventListener("click", e => { | |
| e.preventDefault(); | |
| this.createNode(button.dataset.type); | |
| }); | |
| }); | |
| shadow.getElementById("canvas") | |
| .addEventListener("pointerdown", this.onPointerDown.bind(this)); | |
| document.addEventListener("pointermove", this.onPointerMove.bind(this)); | |
| document.addEventListener("pointerup", this.onPointerUp.bind(this)); | |
| document.addEventListener("pointercancel", this.onPointerUp.bind(this)); | |
| window.debugLogger.logEvent("Node Editor initialized", "success"); | |
| } | |
| createNode(type) { | |
| const shadow = this.shadowRoot; | |
| const node = document.createElement("div"); | |
| node.className = "node"; | |
| const canvasRect = shadow.getElementById("canvas").getBoundingClientRect(); | |
| const x = Math.random() * (canvasRect.width - 200) + 50; | |
| const y = Math.random() * (canvasRect.height - 200) + 50; | |
| node.style.left = x + "px"; | |
| node.style.top = y + "px"; | |
| node.innerHTML = ` | |
| <div class="node-header"> | |
| <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"> | |
| ${this.getNodeIcon(type)} | |
| </svg> | |
| <span>${type}</span> | |
| </div> | |
| ${this.getNodeParams(type)} | |
| `; | |
| // For "subtract", add up to 3 input ports; otherwise one input port. | |
| if (type === "subtract") { | |
| const maxInputs = 3; | |
| for (let i = 0; i < maxInputs; i++) { | |
| const inPort = document.createElement("div"); | |
| inPort.className = "port port-in"; | |
| node.appendChild(inPort); | |
| inPort.style.top = (50 + (i * 20)) + "%"; | |
| inPort.addEventListener("pointerdown", e => this.onPortPointerDown(e, "in")); | |
| } | |
| } else { | |
| const inPort = document.createElement("div"); | |
| inPort.className = "port port-in"; | |
| node.appendChild(inPort); | |
| inPort.style.top = "50%"; | |
| inPort.addEventListener("pointerdown", e => this.onPortPointerDown(e, "in")); | |
| } | |
| // Always add an output port | |
| const outPort = document.createElement("div"); | |
| outPort.className = "port port-out"; | |
| node.appendChild(outPort); | |
| outPort.style.top = "50%"; | |
| outPort.addEventListener("pointerdown", e => this.onPortPointerDown(e, "out")); | |
| shadow.getElementById("canvas").appendChild(node); | |
| this.nodes.push(node); | |
| window.debugLogger.logEvent(`Created ${type} node at (${Math.round(x)}, ${Math.round(y)})`, "success"); | |
| // --- Simple integration: update the global sceneGraph and renderer parameters. | |
| let nodeData = { type, params: {} }; | |
| if (type === "sphere") { | |
| // Find the numeric input for "radius" | |
| const input = node.querySelector("input"); | |
| const val = parseFloat(input.value); | |
| nodeData.params.radius = val; | |
| // Update global scene graph | |
| window.sceneGraph.push(nodeData); | |
| // Also, update raymarcher-basic if available | |
| if (window.raymarcherBasicInstance) { | |
| window.raymarcherBasicInstance.params.sphereRadius = val; | |
| window.raymarcherBasicInstance.updateUniforms(); | |
| } | |
| } else if (type === "box") { | |
| const input = node.querySelector("input"); | |
| const val = parseFloat(input.value); | |
| nodeData.params.size = val; | |
| window.sceneGraph.push(nodeData); | |
| if (window.raymarcherBasicInstance) { | |
| window.raymarcherBasicInstance.params.boxSize = val; | |
| window.raymarcherBasicInstance.updateUniforms(); | |
| } | |
| } else { | |
| window.sceneGraph.push(nodeData); | |
| } | |
| } | |
| getNodeIcon(type) { | |
| switch (type) { | |
| case "sphere": return '<circle cx="8" cy="8" r="7"/>'; | |
| case "box": return '<rect x="2" y="2" width="12" height="12"/>'; | |
| case "union": return '<path d="M4 8h8M8 4v8"/>'; | |
| case "subtract": return '<path d="M4 8h8"/>'; | |
| default: return ""; | |
| } | |
| } | |
| getNodeParams(type) { | |
| switch (type) { | |
| case "sphere": | |
| return ` | |
| <div class="node-body"> | |
| <div class="param-row"> | |
| <span class="param-label">Radius</span> | |
| <input type="number" class="param-input" value="1.0" step="0.1"> | |
| </div> | |
| </div> | |
| `; | |
| case "box": | |
| return ` | |
| <div class="node-body"> | |
| <div class="param-row"> | |
| <span class="param-label">Size</span> | |
| <input type="number" class="param-input" value="1.0" step="0.1"> | |
| </div> | |
| </div> | |
| `; | |
| case "subtract": | |
| return ` | |
| <div class="node-body"> | |
| <p>Accepts up to 3 inputs in order.</p> | |
| </div> | |
| `; | |
| default: | |
| return `<div class="node-body"></div>`; | |
| } | |
| } | |
| onPortPointerDown(e, portType) { | |
| e.stopPropagation(); | |
| const port = e.target; | |
| if (this.tempConnection && this.tempConnection.port) { | |
| this.tempConnection.port.classList.remove("port-rainbow"); | |
| } | |
| if (this.tempConnection && this.tempConnection.portType !== portType) { | |
| this.connections.push({ | |
| outPort: this.tempConnection.portType === "out" ? this.tempConnection.port : port, | |
| inPort: this.tempConnection.portType === "in" ? this.tempConnection.port : port | |
| }); | |
| port.classList.remove("port-rainbow"); | |
| this.tempConnection = null; | |
| window.debugLogger.logEvent("Connection made!", "success"); | |
| this.renderConnections(); | |
| } else { | |
| this.tempConnection = { port, portType }; | |
| port.classList.add("port-rainbow"); | |
| window.debugLogger.logEvent(`Connection start from ${portType} port`, "info"); | |
| } | |
| } | |
| renderConnections() { | |
| const shadow = this.shadowRoot; | |
| const connectionsLayer = shadow.getElementById("connections"); | |
| connectionsLayer.innerHTML = ""; | |
| this.connections.forEach(conn => { | |
| const outPos = this.getPortCenter(conn.outPort); | |
| const inPos = this.getPortCenter(conn.inPort); | |
| const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); | |
| line.setAttribute("x1", outPos.x); | |
| line.setAttribute("y1", outPos.y); | |
| line.setAttribute("x2", inPos.x); | |
| line.setAttribute("y2", inPos.y); | |
| line.setAttribute("stroke", "#00d4ff"); | |
| line.setAttribute("stroke-width", "2"); | |
| connectionsLayer.appendChild(line); | |
| }); | |
| } | |
| getPortCenter(portElem) { | |
| const canvasRect = this.shadowRoot.getElementById("canvas").getBoundingClientRect(); | |
| const portRect = portElem.getBoundingClientRect(); | |
| return { | |
| x: portRect.left + portRect.width / 2 - canvasRect.left, | |
| y: portRect.top + portRect.height / 2 - canvasRect.top | |
| }; | |
| } | |
| onPointerDown(e) { | |
| const node = e.target.closest(".node"); | |
| if (!node) return; | |
| this.draggingNode = node; | |
| const rect = node.getBoundingClientRect(); | |
| this.offset.x = e.clientX - rect.left; | |
| this.offset.y = e.clientY - rect.top; | |
| window.debugLogger.logEvent("Started dragging node", "info"); | |
| } | |
| onPointerMove(e) { | |
| if (!this.draggingNode) return; | |
| const x = e.clientX - this.offset.x; | |
| const y = e.clientY - this.offset.y; | |
| this.draggingNode.style.left = x + "px"; | |
| this.draggingNode.style.top = y + "px"; | |
| this.renderConnections(); | |
| } | |
| onPointerUp() { | |
| if (this.draggingNode) { | |
| window.debugLogger.logEvent("Finished dragging node", "info"); | |
| this.draggingNode = null; | |
| } | |
| } | |
| } | |
| customElements.define("node-editor-app", NodeEditorApp); | |
| /********************** | |
| * Raymarcher Basic Component | |
| **********************/ | |
| class RaymarcherBasicApp extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| // Note: we use a container so that the controls and canvas are laid out side-by-side. | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { display: block; width: 100%; height: 100%; background: #1e1e1e; color: #fff; } | |
| .container { | |
| display: grid; | |
| grid-template-columns: 300px 1fr; | |
| gap: 20px; | |
| height: 100%; | |
| padding: 20px; | |
| box-sizing: border-box; | |
| } | |
| .controls { | |
| background: #2a2a2a; | |
| padding: 15px; | |
| border-radius: 8px; | |
| overflow-y: auto; | |
| } | |
| details.node { | |
| background: #333; | |
| border-radius: 6px; | |
| margin-bottom: 10px; | |
| } | |
| summary { | |
| padding: 10px; | |
| cursor: pointer; | |
| display: flex; | |
| justify-content: space-between; | |
| } | |
| .node-name { color: #4299e1; } | |
| .node-type { color: #718096; } | |
| .node-content { padding: 10px; } | |
| .control-row { | |
| display: grid; | |
| grid-template-columns: 80px 1fr 40px; | |
| gap: 8px; | |
| align-items: center; | |
| margin-bottom: 8px; | |
| } | |
| input[type="range"] { width: 100%; } | |
| canvas { width: 100%; height: 100%; background: #000; border-radius: 8px; } | |
| select { | |
| width: 100%; | |
| margin-bottom: 15px; | |
| padding: 8px; | |
| background: #333; | |
| color: white; | |
| border: 1px solid #444; | |
| border-radius: 4px; | |
| } | |
| </style> | |
| <div class="container"> | |
| <div class="controls"> | |
| <select id="preset"> | |
| <option value="basic">Two Jellies</option> | |
| <option value="spaced">Spaced Apart</option> | |
| </select> | |
| <details class="node" open> | |
| <summary> | |
| <span class="node-name">Sphere_1</span> | |
| <span class="node-type">Primitive</span> | |
| </summary> | |
| <div class="node-content"> | |
| <div class="control-row"> | |
| <label>Radius</label> | |
| <input type="range" id="sphereRadius" min="0.1" max="2.0" step="0.1" value="1.0"> | |
| <span>1.00</span> | |
| </div> | |
| </div> | |
| </details> | |
| <details class="node"> | |
| <summary> | |
| <span class="node-name">Box_1</span> | |
| <span class="node-type">Primitive</span> | |
| </summary> | |
| <div class="node-content"> | |
| <div class="control-row"> | |
| <label>Size</label> | |
| <input type="range" id="boxSize" min="0.1" max="2.0" step="0.1" value="1.0"> | |
| <span>1.00</span> | |
| </div> | |
| </div> | |
| </details> | |
| <details class="node"> | |
| <summary> | |
| <span class="node-name">Blend_1</span> | |
| <span class="node-type">Operation</span> | |
| </summary> | |
| <div class="node-content"> | |
| <div class="control-row"> | |
| <label>Amount</label> | |
| <input type="range" id="blendAmount" min="0.0" max="0.5" step="0.01" value="0.1"> | |
| <span>0.10</span> | |
| </div> | |
| </div> | |
| </details> | |
| </div> | |
| <canvas id="render"></canvas> | |
| </div> | |
| `; | |
| // Default parameters (will be overridden by sceneGraph if available) | |
| this.params = { sphereRadius: 1.0, boxSize: 1.0, blendAmount: 0.1 }; | |
| } | |
| connectedCallback() { | |
| window.raymarcherBasicInstance = this; // global reference for integration | |
| this.initGL(); | |
| this.setupControls(); | |
| this.resizeCanvas(); | |
| window.addEventListener("resize", () => this.resizeCanvas()); | |
| this.animate(); | |
| window.debugLogger.logEvent("Raymarcher Basic initialized", "success"); | |
| } | |
| resizeCanvas() { | |
| const canvas = this.shadowRoot.getElementById("render"); | |
| let rect = canvas.getBoundingClientRect(); | |
| // Fallback to default sizes if rect dimensions are zero. | |
| canvas.width = rect.width || 300; | |
| canvas.height = rect.height || 300; | |
| } | |
| initGL() { | |
| const canvas = this.shadowRoot.getElementById("render"); | |
| const gl = canvas.getContext("webgl"); | |
| if (!gl) throw new Error("WebGL not available"); | |
| this.gl = gl; | |
| const program = gl.createProgram(); | |
| // Vertex shader: pass-through | |
| const vs = gl.createShader(gl.VERTEX_SHADER); | |
| gl.shaderSource(vs, ` | |
| attribute vec4 position; | |
| void main() { gl_Position = position; } | |
| `); | |
| gl.compileShader(vs); | |
| gl.attachShader(program, vs); | |
| // Fragment shader: basic raymarching SDF demo | |
| const fs = gl.createShader(gl.FRAGMENT_SHADER); | |
| const cw = canvas.width, ch = canvas.height; | |
| gl.shaderSource(fs, ` | |
| precision highp float; | |
| uniform float time; | |
| uniform float sphereRadius; | |
| uniform float boxSize; | |
| uniform float blendAmount; | |
| float sdSphere(vec3 p, float r) { return length(p) - r; } | |
| float sdBox(vec3 p, vec3 b) { | |
| vec3 d = abs(p) - b; | |
| return min(max(d.x, max(d.y, d.z)), 0.0) + length(max(d, 0.0)); | |
| } | |
| float opSmoothUnion(float d1, float d2, float k) { | |
| float h = clamp(0.5 + 0.5*(d2 - d1)/k, 0.0, 1.0); | |
| return mix(d2, d1, h) - k * h * (1.0 - h); | |
| } | |
| vec4 sampleVolume(vec3 p) { | |
| vec3 p1 = p - vec3(0.8, 0.0, 0.0); | |
| vec3 p2 = p + vec3(0.8, 0.0, 0.0); | |
| float sphere = sdSphere(p1, sphereRadius); | |
| float box = sdBox(p2, vec3(boxSize)); | |
| float d = opSmoothUnion(sphere, box, blendAmount); | |
| vec3 color = 0.5 + 0.5 * cos(vec3(1,2,3) + d*2.0 + time); | |
| float density = smoothstep(0.1, -0.1, d) * 0.5; | |
| return vec4(color, density); | |
| } | |
| void main() { | |
| vec2 uv = gl_FragCoord.xy / vec2(${cw}.0, ${ch}.0); | |
| uv = uv * 2.0 - 1.0; | |
| float theta = time * 0.5; | |
| vec3 ro = vec3(4.0*sin(theta), 2.0, 4.0*cos(theta)); | |
| vec3 forward = normalize(-ro); | |
| vec3 right = normalize(cross(vec3(0.0,1.0,0.0), forward)); | |
| vec3 up = cross(forward, right); | |
| vec3 rd = normalize(uv.x * right + uv.y * up + 1.5 * forward); | |
| vec3 col = vec3(0.0); | |
| float transmittance = 1.0; | |
| float t = 0.0; | |
| for(int i = 0; i < 100; i++) { | |
| if(t > 10.0 || transmittance < 0.01) break; | |
| vec3 p = ro + rd * t; | |
| vec4 sample = sampleVolume(p); | |
| col += sample.rgb * sample.a * transmittance * 0.1; | |
| transmittance *= 0.95; | |
| t += 0.1; | |
| } | |
| col = col / (1.0 + col); | |
| col = pow(col, vec3(0.4545)); | |
| gl_FragColor = vec4(col, 1.0); | |
| } | |
| `); | |
| gl.compileShader(fs); | |
| gl.attachShader(program, fs); | |
| gl.linkProgram(program); | |
| gl.useProgram(program); | |
| const buffer = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, buffer); | |
| gl.bufferData(gl.ARRAY_BUFFER, | |
| new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), | |
| gl.STATIC_DRAW); | |
| const position = gl.getAttribLocation(program, "position"); | |
| gl.enableVertexAttribArray(position); | |
| gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); | |
| this.uniforms = { | |
| time: gl.getUniformLocation(program, "time"), | |
| sphereRadius: gl.getUniformLocation(program, "sphereRadius"), | |
| boxSize: gl.getUniformLocation(program, "boxSize"), | |
| blendAmount: gl.getUniformLocation(program, "blendAmount") | |
| }; | |
| this.updateUniforms(); | |
| } | |
| updateUniforms() { | |
| const gl = this.gl; | |
| gl.uniform1f(this.uniforms.sphereRadius, this.params.sphereRadius); | |
| gl.uniform1f(this.uniforms.boxSize, this.params.boxSize); | |
| gl.uniform1f(this.uniforms.blendAmount, this.params.blendAmount); | |
| } | |
| animate() { | |
| this.render(performance.now() / 1000); | |
| requestAnimationFrame(() => this.animate()); | |
| } | |
| render(time) { | |
| const gl = this.gl; | |
| gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); | |
| gl.uniform1f(this.uniforms.time, time); | |
| gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); | |
| } | |
| setupControl(id, param) { | |
| const input = this.shadowRoot.getElementById(id); | |
| const valueSpan = input.nextElementSibling; | |
| input.addEventListener("input", e => { | |
| const val = parseFloat(e.target.value); | |
| valueSpan.textContent = val.toFixed(2); | |
| this.params[param] = val; | |
| this.updateUniforms(); | |
| // Also update sceneGraph if applicable | |
| window.sceneGraph.forEach(node => { | |
| if(node.type === "sphere" && param === "sphereRadius") { | |
| node.params.radius = val; | |
| } else if(node.type === "box" && param === "boxSize") { | |
| node.params.size = val; | |
| } | |
| }); | |
| }); | |
| } | |
| setupControls() { | |
| this.setupControl("sphereRadius", "sphereRadius"); | |
| this.setupControl("boxSize", "boxSize"); | |
| this.setupControl("blendAmount", "blendAmount"); | |
| this.shadowRoot.getElementById("preset") | |
| .addEventListener("change", e => { | |
| const PRESETS = { | |
| basic: { sphereRadius: 1.0, boxSize: 1.0, blendAmount: 0.1 }, | |
| spaced: { sphereRadius: 1.2, boxSize: 0.8, blendAmount: 0.2 } | |
| }; | |
| const preset = PRESETS[e.target.value]; | |
| Object.entries(preset).forEach(([param, value]) => { | |
| this.params[param] = value; | |
| const control = this.shadowRoot.getElementById( | |
| param === "sphereRadius" ? "sphereRadius" : | |
| param === "boxSize" ? "boxSize" : "blendAmount" | |
| ); | |
| if (control) { | |
| control.value = value; | |
| control.nextElementSibling.textContent = value.toFixed(2); | |
| } | |
| }); | |
| this.updateUniforms(); | |
| }); | |
| } | |
| } | |
| customElements.define("raymarcher-basic-app", RaymarcherBasicApp); | |
| /********************** | |
| * Volume Renderer Component | |
| **********************/ | |
| class VolumeRendererApp extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| // For simplicity, layout similar to RaymarcherBasic. | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { display: block; padding: 20px; background: #1a1a1a; color: #fff; box-sizing: border-box; height: 100%; } | |
| .container { max-width: 600px; margin: 0 auto; } | |
| canvas { width: 300px; height: 300px; background: #000; margin-bottom: 15px; border-radius: 4px; } | |
| .control-panel { | |
| background: #2a2a2a; | |
| padding: 15px; | |
| border-radius: 8px; | |
| margin-bottom: 15px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| .slider-container { | |
| display: grid; | |
| grid-template-columns: 120px 1fr 50px; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| select { | |
| background: #333; | |
| color: #fff; | |
| padding: 8px; | |
| border: 1px solid #444; | |
| border-radius: 4px; | |
| font-size: 14px; | |
| width: 100%; | |
| } | |
| .slider-value { font-family: monospace; text-align: right; } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| background: #444; | |
| height: 6px; | |
| border-radius: 3px; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: #4299e1; | |
| cursor: pointer; | |
| } | |
| </style> | |
| <div class="container"> | |
| <canvas id="glCanvas"></canvas> | |
| <div class="control-panel"> | |
| <div class="slider-container" style="grid-template-columns: 120px 1fr;"> | |
| <label>Shape:</label> | |
| <select id="shapeSelect"> | |
| <option value="0">Cubes</option> | |
| <option value="1">Spheres</option> | |
| <option value="2">Tori</option> | |
| <option value="3">Octahedra</option> | |
| <option value="4">Capsules</option> | |
| <option value="5">Plane (R2 in R3)</option> | |
| <option value="6">Line (R1 in R3)</option> | |
| </select> | |
| </div> | |
| <div class="slider-container"> | |
| <label>Ray Steps:</label> | |
| <input type="range" id="stepsSlider" min="16" max="256" value="64" step="16"> | |
| <span class="slider-value" id="stepsValue">64</span> | |
| </div> | |
| <div class="slider-container"> | |
| <label>Step Size:</label> | |
| <input type="range" id="sizeSlider" min="0.01" max="0.2" value="0.1" step="0.01"> | |
| <span class="slider-value" id="sizeValue">0.10</span> | |
| </div> | |
| <div class="slider-container"> | |
| <label>Max Dist:</label> | |
| <input type="range" id="distSlider" min="5" max="20" value="10" step="1"> | |
| <span class="slider-value" id="distValue">10</span> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| this.params = { | |
| maxSteps: 64, | |
| stepSize: 0.1, | |
| maxDist: 10.0, | |
| shape: 0 | |
| }; | |
| } | |
| connectedCallback() { | |
| window.volumeRendererInstance = this; // global integration reference | |
| this.initGL(); | |
| this.setupControls(); | |
| this.resizeCanvas(); | |
| window.addEventListener("resize", () => this.resizeCanvas()); | |
| this.render(); | |
| window.debugLogger.logEvent("Volume Renderer initialized", "success"); | |
| } | |
| resizeCanvas() { | |
| const canvas = this.shadowRoot.getElementById("glCanvas"); | |
| let rect = canvas.getBoundingClientRect(); | |
| canvas.width = rect.width || 300; | |
| canvas.height = rect.height || 300; | |
| } | |
| initGL() { | |
| const canvas = this.shadowRoot.getElementById("glCanvas"); | |
| const gl = canvas.getContext("webgl"); | |
| if (!gl) { | |
| window.debugLogger.logEvent("WebGL not available in Volume Renderer", "error"); | |
| return; | |
| } | |
| this.gl = gl; | |
| const vsSource = ` | |
| attribute vec4 a_position; | |
| void main() { gl_Position = a_position; } | |
| `; | |
| const fsSource = ` | |
| precision highp float; | |
| uniform float u_time; | |
| uniform float u_maxSteps; | |
| uniform float u_stepSize; | |
| uniform float u_maxDist; | |
| uniform int u_shape; | |
| float sdBox(vec3 p, vec3 b) { | |
| vec3 d = abs(p) - b; | |
| return min(max(d.x, max(d.y, d.z)), 0.0) + length(max(d, 0.0)); | |
| } | |
| float sdSphere(vec3 p, float r) { return length(p) - r; } | |
| float sdTorus(vec3 p, vec2 t) { | |
| vec2 q = vec2(length(p.xz) - t.x, p.y); | |
| return length(q) - t.y; | |
| } | |
| float sdOctahedron(vec3 p, float s) { | |
| p = abs(p); | |
| return (p.x + p.y + p.z - s) * 0.57735027; | |
| } | |
| float sdCapsule(vec3 p, vec3 a, vec3 b, float r) { | |
| vec3 pa = p - a; | |
| vec3 ba = b - a; | |
| float h = clamp(dot(pa,ba)/dot(ba,ba), 0.0, 1.0); | |
| return length(pa - ba * h) - r; | |
| } | |
| float sdPlane(vec3 p) { return p.z; } | |
| float sdLineZ(vec3 p) { return length(p.xy); } | |
| vec4 sampleVolume(vec3 p) { | |
| vec3 repeat = mod(p + 2.0, 4.0) - 2.0; | |
| float d = sdBox(repeat, vec3(0.5)); | |
| if(u_shape == 1) { d = sdSphere(repeat, 0.7); } | |
| else if(u_shape == 2) { d = sdTorus(repeat, vec2(0.6, 0.2)); } | |
| else if(u_shape == 3) { d = sdOctahedron(repeat, 0.7); } | |
| else if(u_shape == 4) { d = sdCapsule(repeat, vec3(-0.3), vec3(0.3), 0.2); } | |
| else if(u_shape == 5) { d = sdPlane(p); } | |
| else if(u_shape == 6) { d = sdLineZ(p); } | |
| vec3 color = 0.5 + 0.5 * cos(vec3(1.0,2.0,3.0) + p.z + u_time); | |
| float density = smoothstep(0.1, -0.1, d) * 0.5; | |
| vec3 grid = abs(mod(p + 0.5, 1.0) - 0.5); | |
| float gridLines = min(min(grid.x, grid.y), grid.z); | |
| float gridDensity = (1.0 - smoothstep(0.0, 0.02, gridLines)) * 0.1; | |
| return vec4(color, density + gridDensity); | |
| } | |
| void main() { | |
| vec2 uv = (gl_FragCoord.xy / 300.0)*2.0 - 1.0; | |
| float theta = u_time * 0.5; | |
| vec3 ro = vec3(4.0*sin(theta), 3.0, 4.0*cos(theta)); | |
| vec3 forward = normalize(-ro); | |
| vec3 right = normalize(cross(vec3(0.0,1.0,0.0), forward)); | |
| vec3 up = cross(forward, right); | |
| vec3 rd = normalize(uv.x * right + uv.y * up + 1.5 * forward); | |
| vec3 col = vec3(0.0); | |
| float transmittance = 1.0; | |
| float t = 0.0; | |
| for(float i = 0.0; i < 256.0; i++) { | |
| if(i >= u_maxSteps || t > u_maxDist || transmittance < 0.01) break; | |
| vec3 p = ro + rd * t; | |
| vec4 sample = sampleVolume(p); | |
| col += transmittance * sample.rgb * sample.a * u_stepSize; | |
| transmittance *= exp(-sample.a * u_stepSize * 2.0); | |
| t += u_stepSize; | |
| } | |
| col += transmittance * vec3(0.1); | |
| col = col / (1.0 + col); | |
| col = pow(col, vec3(0.4545)); | |
| gl_FragColor = vec4(col, 1.0); | |
| } | |
| `; | |
| const vertexShader = gl.createShader(gl.VERTEX_SHADER); | |
| gl.shaderSource(vertexShader, vsSource); | |
| gl.compileShader(vertexShader); | |
| if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) { | |
| console.error("Vertex shader error:", gl.getShaderInfoLog(vertexShader)); | |
| } | |
| const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); | |
| gl.shaderSource(fragmentShader, fsSource); | |
| gl.compileShader(fragmentShader); | |
| if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) { | |
| console.error("Fragment shader error:", gl.getShaderInfoLog(fragmentShader)); | |
| } | |
| const program = gl.createProgram(); | |
| gl.attachShader(program, vertexShader); | |
| gl.attachShader(program, fragmentShader); | |
| gl.linkProgram(program); | |
| if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { | |
| console.error("Program link error:", gl.getProgramInfoLog(program)); | |
| } | |
| gl.useProgram(program); | |
| const buffer = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, buffer); | |
| gl.bufferData(gl.ARRAY_BUFFER, | |
| new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), | |
| gl.STATIC_DRAW); | |
| const position = gl.getAttribLocation(program, "a_position"); | |
| gl.enableVertexAttribArray(position); | |
| gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); | |
| this.timeLocation = gl.getUniformLocation(program, "u_time"); | |
| this.maxStepsLocation = gl.getUniformLocation(program, "u_maxSteps"); | |
| this.stepSizeLocation = gl.getUniformLocation(program, "u_stepSize"); | |
| this.maxDistLocation = gl.getUniformLocation(program, "u_maxDist"); | |
| this.shapeLocation = gl.getUniformLocation(program, "u_shape"); | |
| } | |
| render() { | |
| const gl = this.gl; | |
| gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); | |
| gl.uniform1f(this.timeLocation, performance.now() / 1000); | |
| gl.uniform1f(this.maxStepsLocation, this.params.maxSteps); | |
| gl.uniform1f(this.stepSizeLocation, this.params.stepSize); | |
| gl.uniform1f(this.maxDistLocation, this.params.maxDist); | |
| gl.uniform1i(this.shapeLocation, this.params.shape); | |
| gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); | |
| requestAnimationFrame(() => this.render()); | |
| } | |
| setupControls() { | |
| const updateValue = (id, value, format = x => x) => { | |
| this.shadowRoot.getElementById(id + "Value").textContent = format(value); | |
| }; | |
| this.shadowRoot.getElementById("shapeSelect") | |
| .addEventListener("change", e => { this.params.shape = parseInt(e.target.value); }); | |
| this.shadowRoot.getElementById("stepsSlider") | |
| .addEventListener("input", e => { this.params.maxSteps = parseInt(e.target.value); updateValue("steps", this.params.maxSteps); }); | |
| this.shadowRoot.getElementById("sizeSlider") | |
| .addEventListener("input", e => { this.params.stepSize = parseFloat(e.target.value); updateValue("size", this.params.stepSize, x => x.toFixed(2)); }); | |
| this.shadowRoot.getElementById("distSlider") | |
| .addEventListener("input", e => { this.params.maxDist = parseFloat(e.target.value); updateValue("dist", this.params.maxDist); }); | |
| } | |
| } | |
| customElements.define("volume-renderer-app", VolumeRendererApp); | |
| /********************** | |
| * Main App with Tabs | |
| **********************/ | |
| class RaymarcherApp extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { display: block; height: 100%; overflow: hidden; } | |
| .tab-bar { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| height: 48px; | |
| display: flex; | |
| background: #333; | |
| z-index: 100; | |
| } | |
| .tab-button { | |
| flex: 1; | |
| padding: 12px; | |
| text-align: center; | |
| cursor: pointer; | |
| border-bottom: 2px solid transparent; | |
| } | |
| .tab-button.active { | |
| border-bottom: 2px solid #00d4ff; | |
| background: #444; | |
| } | |
| .tab-content { | |
| display: none; | |
| height: calc(100% - 48px); | |
| margin-top: 48px; | |
| } | |
| .tab-content.active { display: block; } | |
| </style> | |
| <div class="tab-bar"> | |
| <div class="tab-button active" data-tab="editor">Editor</div> | |
| <div class="tab-button" data-tab="basic">Raymarcher Basic</div> | |
| <div class="tab-button" data-tab="volume">Volume Renderer</div> | |
| <div class="tab-button" data-tab="debug">Debug</div> | |
| </div> | |
| <div class="tab-content active" id="tab-editor"> | |
| <node-editor-app style="width:100%; height:100%;"></node-editor-app> | |
| </div> | |
| <div class="tab-content" id="tab-basic"> | |
| <raymarcher-basic-app style="width:100%; height:100%;"></raymarcher-basic-app> | |
| </div> | |
| <div class="tab-content" id="tab-volume"> | |
| <volume-renderer-app style="width:100%; height:100%;"></volume-renderer-app> | |
| </div> | |
| <div class="tab-content" id="tab-debug"> | |
| <debug-console-app style="width:100%; height:100%;"></debug-console-app> | |
| </div> | |
| `; | |
| } | |
| connectedCallback() { | |
| this.shadowRoot.querySelectorAll(".tab-button").forEach(button => { | |
| button.addEventListener("click", () => { | |
| this.switchTab(button.dataset.tab); | |
| }); | |
| }); | |
| } | |
| switchTab(tabName) { | |
| this.shadowRoot.querySelectorAll(".tab-button").forEach(btn => { | |
| btn.classList.toggle("active", btn.dataset.tab === tabName); | |
| }); | |
| this.shadowRoot.querySelectorAll(".tab-content").forEach(content => { | |
| content.classList.toggle("active", content.id === "tab-" + tabName); | |
| }); | |
| window.debugLogger.logEvent(`Switched to ${tabName} tab`, "info"); | |
| } | |
| } | |
| customElements.define("raymarcher-app", RaymarcherApp); | |
| /********************** | |
| * Automated Test Runner | |
| **********************/ | |
| function runTests() { | |
| const results = []; | |
| try { | |
| // Test NodeEditor: create sphere and subtract nodes | |
| const nodeEditor = document.createElement("node-editor-app"); | |
| document.body.appendChild(nodeEditor); | |
| nodeEditor.createNode("sphere"); | |
| if (nodeEditor.nodes.length < 1) { | |
| results.push({passed:false, message:"NodeEditor: sphere node not created"}); | |
| } else { | |
| results.push({passed:true, message:"NodeEditor: sphere node created successfully"}); | |
| } | |
| nodeEditor.createNode("subtract"); | |
| const subtractNode = nodeEditor.nodes[nodeEditor.nodes.length - 1]; | |
| const ports = subtractNode.querySelectorAll(".port-in"); | |
| if (ports.length !== 3) { | |
| results.push({passed:false, message:"NodeEditor: subtract node should have 3 input ports, found " + ports.length}); | |
| } else { | |
| results.push({passed:true, message:"NodeEditor: subtract node has 3 input ports"}); | |
| } | |
| nodeEditor.remove(); | |
| // Test RaymarcherBasic: check if uniforms are set | |
| const raymarcherBasic = document.createElement("raymarcher-basic-app"); | |
| document.body.appendChild(raymarcherBasic); | |
| if (!raymarcherBasic.uniforms || | |
| !raymarcherBasic.uniforms.sphereRadius || | |
| !raymarcherBasic.uniforms.boxSize || | |
| !raymarcherBasic.uniforms.blendAmount) { | |
| results.push({passed:false, message:"RaymarcherBasic: Uniforms not set correctly"}); | |
| } else { | |
| results.push({passed:true, message:"RaymarcherBasic: Uniforms are valid"}); | |
| } | |
| raymarcherBasic.remove(); | |
| // Test VolumeRenderer: check if WebGL context is available | |
| const volumeRenderer = document.createElement("volume-renderer-app"); | |
| document.body.appendChild(volumeRenderer); | |
| if (!volumeRenderer.gl) { | |
| results.push({passed:false, message:"VolumeRenderer: WebGL context not available"}); | |
| } else { | |
| results.push({passed:true, message:"VolumeRenderer: WebGL context available"}); | |
| } | |
| volumeRenderer.remove(); | |
| // Test Tab Switching in Main App | |
| const mainApp = document.querySelector("raymarcher-app"); | |
| if (mainApp) { | |
| mainApp.switchTab("debug"); | |
| const activeTab = mainApp.shadowRoot.querySelector(".tab-content.active").id; | |
| if (activeTab !== "tab-debug") { | |
| results.push({passed:false, message:"MainApp: Failed to switch to debug tab"}); | |
| } else { | |
| results.push({passed:true, message:"MainApp: Debug tab switched successfully"}); | |
| } | |
| } else { | |
| results.push({passed:false, message:"MainApp: raymarcher-app not found"}); | |
| } | |
| } catch (err) { | |
| results.push({passed:false, message:"Test exception: " + err}); | |
| } | |
| results.forEach(test => { | |
| if (test.passed) { | |
| window.debugLogger.logEvent("[TEST PASSED] " + test.message, "success"); | |
| } else { | |
| window.debugLogger.logEvent("[TEST FAILED] " + test.message, "error"); | |
| } | |
| }); | |
| } | |
| window.addEventListener("load", () => { | |
| setTimeout(runTests, 1000); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment