A Pen by Dan Brickley on CodePen.
Created
November 25, 2025 14:48
-
-
Save danbri/61bd82f82283728382410455bb1dc8c3 to your computer and use it in GitHub Desktop.
Lucid6
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>SDF Playground – DSL, Layers, Compositing</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| background: #1e1e1e; | |
| color: #fff; | |
| } | |
| raymarcher-app { | |
| display: block; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <raymarcher-app></raymarcher-app> | |
| <script type="module"> | |
| // ============================================================ | |
| // Global app context + debug logger | |
| // ============================================================ | |
| class GlobalDebugLogger { | |
| constructor() { | |
| this.startTime = performance.now(); | |
| } | |
| logEvent(message, type = "info") { | |
| const time = ((performance.now() - this.startTime) / 1000).toFixed(2); | |
| const event = new CustomEvent("debug-log", { | |
| detail: { message, type, time } | |
| }); | |
| window.dispatchEvent(event); | |
| console.log("[" + time + "s]", message); | |
| } | |
| clear() { | |
| this.startTime = performance.now(); | |
| window.dispatchEvent(new CustomEvent("debug-clear")); | |
| } | |
| } | |
| class AppContext { | |
| constructor() { | |
| this.debugLogger = new GlobalDebugLogger(); | |
| this.sceneGraph = []; | |
| this.sceneObjects = []; | |
| this.instances = { | |
| nodeEditor: null, | |
| sdfRenderer: null, | |
| glslPreview: null, | |
| compositeEditor: null | |
| }; | |
| } | |
| } | |
| const appContext = new AppContext(); | |
| window.appContext = appContext; | |
| const log = (msg, type = "info") => appContext.debugLogger.logEvent(msg, type); | |
| // ============================================================ | |
| // Debug console element | |
| // ============================================================ | |
| class DebugConsoleApp extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| height: 100%; | |
| background: #111827; | |
| color: #e5e7eb; | |
| box-sizing: border-box; | |
| padding: 8px; | |
| font-family: system-ui, -apple-system, sans-serif; | |
| } | |
| .header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| background: #1f2937; | |
| padding: 6px 8px; | |
| border-radius: 4px; | |
| font-size: 13px; | |
| } | |
| .content { | |
| margin-top: 8px; | |
| height: calc(100% - 40px); | |
| overflow-y: auto; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; | |
| font-size: 12px; | |
| } | |
| .entry { margin-bottom: 4px; } | |
| .entry.info { color: #93c5fd; } | |
| .entry.success { color: #86efac; } | |
| .entry.error { color: #fca5a5; } | |
| button { | |
| background: #374151; | |
| border: none; | |
| border-radius: 4px; | |
| color: #e5e7eb; | |
| padding: 4px 8px; | |
| font-size: 11px; | |
| cursor: pointer; | |
| } | |
| button:hover { background: #4b5563; } | |
| </style> | |
| <div class="header"> | |
| <span>Debug</span> | |
| <button id="clearBtn">Clear</button> | |
| </div> | |
| <div id="log" class="content"></div> | |
| `; | |
| } | |
| connectedCallback() { | |
| this.shadowRoot.getElementById("clearBtn") | |
| .addEventListener("click", () => { | |
| this.shadowRoot.getElementById("log").innerHTML = ""; | |
| appContext.debugLogger.clear(); | |
| }); | |
| this._onLog = this.onLog.bind(this); | |
| this._onClear = this.onClear.bind(this); | |
| window.addEventListener("debug-log", this._onLog); | |
| window.addEventListener("debug-clear", this._onClear); | |
| } | |
| disconnectedCallback() { | |
| window.removeEventListener("debug-log", this._onLog); | |
| window.removeEventListener("debug-clear", this._onClear); | |
| } | |
| onLog(e) { | |
| const { message, type, time } = e.detail; | |
| const div = document.createElement("div"); | |
| div.className = "entry " + type; | |
| div.textContent = "[" + time + "s] " + message; | |
| const logEl = this.shadowRoot.getElementById("log"); | |
| logEl.appendChild(div); | |
| logEl.scrollTop = logEl.scrollHeight; | |
| } | |
| onClear() { | |
| this.shadowRoot.getElementById("log").innerHTML = ""; | |
| } | |
| } | |
| customElements.define("debug-console-app", DebugConsoleApp); | |
| // ============================================================ | |
| // Node editor stub | |
| // ============================================================ | |
| class NodeEditorApp extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| height: 100%; | |
| background: #111827; | |
| color: #e5e7eb; | |
| box-sizing: border-box; | |
| padding: 12px; | |
| font-family: system-ui, -apple-system, sans-serif; | |
| font-size: 14px; | |
| } | |
| h2 { margin: 0 0 8px 0; font-size: 16px; } | |
| p { margin: 4px 0; line-height: 1.4; } | |
| .tag { | |
| display: inline-block; | |
| padding: 2px 6px; | |
| border-radius: 999px; | |
| background: #4b5563; | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| margin-right: 6px; | |
| } | |
| .box { | |
| margin-top: 8px; | |
| padding: 8px; | |
| border-radius: 6px; | |
| background: #020617; | |
| border: 1px dashed #374151; | |
| } | |
| ul { | |
| padding-left: 18px; | |
| margin: 4px 0; | |
| } | |
| li { | |
| margin-bottom: 2px; | |
| } | |
| </style> | |
| <h2>Node Editor (experimental)</h2> | |
| <p> | |
| <span class="tag">Design</span> | |
| Placeholder for a future SDF node graph editor that round-trips with the text DSL. | |
| </p> | |
| <div class="box"> | |
| <p>MVP direction:</p> | |
| <ul> | |
| <li>Text DSL is the source of truth.</li> | |
| <li>Node graph will be a structured view over the same IR.</li> | |
| <li>Later: DSL ⇄ graph round-tripping and per-node parameters.</li> | |
| </ul> | |
| </div> | |
| `; | |
| } | |
| connectedCallback() { | |
| appContext.instances.nodeEditor = this; | |
| log("Node editor stub initialized", "info"); | |
| } | |
| disconnectedCallback() { | |
| if (appContext.instances.nodeEditor === this) { | |
| appContext.instances.nodeEditor = null; | |
| } | |
| } | |
| } | |
| customElements.define("node-editor-app", NodeEditorApp); | |
| // ============================================================ | |
| // DSL normalization + legacy parser (IR) + SHADOW parser | |
| // ============================================================ | |
| function normalizeDslText(text) { | |
| const rawLines = text.split(/\r?\n/); | |
| const normalized = []; | |
| let buffer = ""; | |
| let depth = 0; | |
| for (let i = 0; i < rawLines.length; i++) { | |
| let line = rawLines[i]; | |
| // strip JS-style comments | |
| line = line.replace(/\/\/.*$/, ""); | |
| // full-line DSL comment | |
| if (line.trim().startsWith("#")) { | |
| if (!buffer && depth === 0) continue; | |
| else continue; | |
| } | |
| // trailing # comment | |
| line = line.replace(/#.*$/, ""); | |
| line = line.trim(); | |
| if (!line && depth === 0 && !buffer) continue; | |
| if (!buffer) buffer = line; | |
| else buffer += " " + line; | |
| for (let j = 0; j < line.length; j++) { | |
| const c = line[j]; | |
| if (c === "(") depth++; | |
| else if (c === ")") depth = Math.max(0, depth - 1); | |
| } | |
| if (depth === 0 && buffer) { | |
| normalized.push(buffer.trim()); | |
| buffer = ""; | |
| } | |
| } | |
| if (buffer) normalized.push(buffer.trim()); | |
| return normalized; | |
| } | |
| // ---------- Legacy IR parser (current source of truth) ---------- | |
| function splitArgs(argsStr) { | |
| const result = []; | |
| let current = ""; | |
| let depth = 0; | |
| for (let i = 0; i < argsStr.length; i++) { | |
| const c = argsStr[i]; | |
| if (c === "[" || c === "(") { | |
| depth++; | |
| current += c; | |
| } else if (c === "]" || c === ")") { | |
| depth = Math.max(0, depth - 1); | |
| current += c; | |
| } else if (c === "," && depth === 0) { | |
| if (current.trim()) result.push(current.trim()); | |
| current = ""; | |
| } else { | |
| current += c; | |
| } | |
| } | |
| if (current.trim()) result.push(current.trim()); | |
| return result; | |
| } | |
| function parseDslToSceneGraph(text) { | |
| const lines = normalizeDslText(text); | |
| const nodes = []; | |
| const ids = new Set(); | |
| const errors = []; | |
| function addError(lineNo, msg) { | |
| errors.push("Line " + lineNo + ": " + msg); | |
| } | |
| function parseArray(raw) { | |
| const trimmed = raw.trim(); | |
| const inner = trimmed.replace(/^\[/, "").replace(/\]$/, ""); | |
| if (!inner.trim()) return []; | |
| return inner.split(",").map(s => s.trim()).filter(Boolean); | |
| } | |
| function parseValue(raw) { | |
| const v = raw.trim(); | |
| if (v.startsWith("[") && v.endsWith("]")) { | |
| return parseArray(v); | |
| } | |
| return v; | |
| } | |
| function parseParams(argsStr, node) { | |
| if (!argsStr) return; | |
| node.paramOrder = node.paramOrder || []; | |
| const parts = splitArgs(argsStr); | |
| parts.forEach(part => { | |
| const eqIdx = part.indexOf("="); | |
| if (eqIdx === -1) return; | |
| const key = part.slice(0, eqIdx).trim(); | |
| const valRaw = part.slice(eqIdx + 1).trim(); | |
| node.params[key] = parseValue(valRaw); | |
| node.paramOrder.push(key); | |
| }); | |
| (node.paramOrder || []).forEach((key, idx) => { | |
| const alias = "arg_" + (idx + 1); | |
| if (!(alias in node.params)) { | |
| node.params[alias] = node.params[key]; | |
| } | |
| }); | |
| } | |
| function parseInputsAndParams(argsStr, node) { | |
| if (!argsStr) return; | |
| node.paramOrder = node.paramOrder || []; | |
| const parts = splitArgs(argsStr); | |
| parts.forEach(part => { | |
| const eqIdx = part.indexOf("="); | |
| if (eqIdx !== -1) { | |
| const key = part.slice(0, eqIdx).trim(); | |
| const valRaw = part.slice(eqIdx + 1).trim(); | |
| node.params[key] = parseValue(valRaw); | |
| node.paramOrder.push(key); | |
| } else { | |
| const id = parseValue(part); | |
| if (!id) return; | |
| node.inputOrder.push(id); | |
| node.inputs["in" + node.inputOrder.length] = id; | |
| } | |
| }); | |
| (node.paramOrder || []).forEach((key, idx) => { | |
| const alias = "arg_" + (idx + 1); | |
| if (!(alias in node.params)) { | |
| node.params[alias] = node.params[key]; | |
| } | |
| }); | |
| } | |
| lines.forEach((line, index) => { | |
| if (!line) return; | |
| const lineNo = index + 1; | |
| let id; | |
| let expr; | |
| const assignMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$/); | |
| if (assignMatch) { | |
| id = assignMatch[1]; | |
| expr = assignMatch[2].trim(); | |
| // Special case: alias like `out = s0` | |
| const bareIdMatch = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)$/); | |
| if (bareIdMatch) { | |
| const target = bareIdMatch[1]; | |
| if (ids.has(id)) { | |
| addError(lineNo, "Duplicate id '" + id + "'"); | |
| return; | |
| } | |
| ids.add(id); | |
| const node = { | |
| id, | |
| type: "alias", | |
| outputType: "DistanceField", | |
| inputs: { in1: target }, | |
| inputOrder: [target], | |
| params: {}, | |
| paramOrder: [] | |
| }; | |
| nodes.push(node); | |
| return; | |
| } | |
| } else { | |
| id = "n" + index; | |
| expr = line.trim(); | |
| } | |
| if (ids.has(id)) { | |
| addError(lineNo, "Duplicate id '" + id + "'"); | |
| return; | |
| } | |
| ids.add(id); | |
| const callMatch = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)$/); | |
| if (!callMatch) { | |
| addError(lineNo, "Expected function call like: sphere(r=1.0)"); | |
| return; | |
| } | |
| const func = callMatch[1]; | |
| const args = callMatch[2].trim(); | |
| const node = { | |
| id, | |
| type: func, | |
| outputType: "DistanceField", | |
| inputs: {}, | |
| inputOrder: [], | |
| params: {}, | |
| paramOrder: [] | |
| }; | |
| if (func === "sphere") { | |
| parseParams(args, node); | |
| if (!node.params.radius && node.params.r) { | |
| node.params.radius = node.params.r; | |
| } | |
| if (!node.params.radius) node.params.radius = "1.0"; | |
| } else if (func === "box") { | |
| parseParams(args, node); | |
| if (!node.params.size && node.params.s) { | |
| node.params.size = node.params.s; | |
| } | |
| if (!node.params.size) node.params.size = "1.0"; | |
| } else if (func === "capsule") { | |
| parseParams(args, node); | |
| if (!node.params.r && node.params.radius) { | |
| node.params.r = node.params.radius; | |
| } | |
| if (!node.params.r) node.params.r = "0.5"; | |
| if (!node.params.a) node.params.a = ["0.0", "0.0", "0.0"]; | |
| if (!node.params.b) node.params.b = ["0.0", "1.0", "0.0"]; | |
| } else if (func === "ellipsoid") { | |
| parseParams(args, node); | |
| if (!node.params.r && node.params.radius) { | |
| node.params.r = node.params.radius; | |
| } | |
| if (!node.params.r) node.params.r = ["1.0", "1.0", "1.0"]; | |
| } else if (func === "plane") { | |
| parseParams(args, node); | |
| if (!node.params.n) node.params.n = ["0.0", "1.0", "0.0"]; | |
| if (!node.params.d) node.params.d = "0.0"; | |
| } else if (func === "union" || func === "subtract" || func === "smoothUnion") { | |
| parseInputsAndParams(args, node); | |
| if (node.inputOrder.length < 1) { | |
| addError(lineNo, func + " requires at least one input"); | |
| } | |
| if (func === "smoothUnion" && !node.params.k) { | |
| node.params.k = "0.2"; | |
| } | |
| } else { | |
| parseParams(args, node); | |
| addError(lineNo, "Unknown function '" + func + "' (kept as placeholder node)"); | |
| } | |
| nodes.push(node); | |
| }); | |
| return { nodes, errors }; | |
| } | |
| // ---------- Shadow Mode Parser (AST-only, no effect on IR/GLSL) ---------- | |
| function shadowParseDsl(text) { | |
| const lines = normalizeDslText(text); | |
| const ast = []; | |
| const errors = []; | |
| lines.forEach((line, index) => { | |
| if (!line) return; | |
| const lineNo = index + 1; | |
| let node = { line: lineNo, raw: line, kind: "unknown" }; | |
| // def name(...) | |
| const defMatch = line.match(/^def\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)\s*:?/); | |
| if (defMatch) { | |
| node.kind = "def"; | |
| node.name = defMatch[1]; | |
| node.params = (defMatch[2] || "").split(",") | |
| .map(s => s.trim()) | |
| .filter(Boolean); | |
| ast.push(node); | |
| return; | |
| } | |
| // assignment | |
| const assignMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$/); | |
| if (assignMatch) { | |
| const id = assignMatch[1]; | |
| const expr = assignMatch[2].trim(); | |
| node.kind = "assign"; | |
| node.name = id; | |
| node.expr = expr; | |
| const callMatch = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)$/); | |
| if (callMatch) { | |
| node.exprKind = "call"; | |
| node.callee = callMatch[1]; | |
| node.argsRaw = callMatch[2]; | |
| } else { | |
| node.exprKind = "expr"; | |
| } | |
| ast.push(node); | |
| return; | |
| } | |
| // bare function call? | |
| const callOnlyMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)$/); | |
| if (callOnlyMatch) { | |
| node.kind = "call"; | |
| node.callee = callOnlyMatch[1]; | |
| node.argsRaw = callOnlyMatch[2]; | |
| ast.push(node); | |
| return; | |
| } | |
| // bare identifier or unknown expression | |
| const identMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)$/); | |
| if (identMatch) { | |
| node.kind = "ident"; | |
| node.name = identMatch[1]; | |
| ast.push(node); | |
| return; | |
| } | |
| node.kind = "unknown"; | |
| errors.push("Line " + lineNo + ": shadow parser couldn't classify '" + line + "'"); | |
| ast.push(node); | |
| }); | |
| return { ast, errors }; | |
| } | |
| // ============================================================ | |
| // Default DSL | |
| // ============================================================ | |
| const DEFAULT_DSL = [ | |
| "# Default DSL: animated blob with wobble", | |
| "", | |
| "s0 = sphere(", | |
| " r = 1.0 + 0.3*sin(time),", | |
| " color = [0.4 + 0.4*sin(time), 0.6, 1.0],", | |
| " offset = [0.0, 0.3*sin(time*0.7), 0.0]", | |
| ")", | |
| "", | |
| "out = s0" | |
| ].join("\n"); | |
| (function initDefaultSceneGraph() { | |
| const parsed = parseDslToSceneGraph(DEFAULT_DSL); | |
| if (parsed.errors.length) { | |
| log("Default DSL parse errors: " + parsed.errors.join("; "), "error"); | |
| appContext.sceneGraph = []; | |
| } else { | |
| appContext.sceneGraph = parsed.nodes; | |
| log("Default DSL sceneGraph initialized with " + | |
| parsed.nodes.length + " node(s)", "success"); | |
| } | |
| })(); | |
| // ============================================================ | |
| // IR → GLSL codegen | |
| // ============================================================ | |
| function generateGlslFromSceneGraph(sceneGraph) { | |
| const sg = Array.isArray(sceneGraph) ? sceneGraph : []; | |
| const lines = []; | |
| const byId = new Map(); | |
| sg.forEach(node => { | |
| if (node && node.id) byId.set(node.id, node); | |
| }); | |
| const visited = new Set(); | |
| const temp = new Set(); | |
| const sorted = []; | |
| let hasCycle = false; | |
| function visit(node) { | |
| if (!node || !node.id) return; | |
| if (temp.has(node.id)) { | |
| hasCycle = true; | |
| log("Cycle detected involving node '" + node.id + "'", "error"); | |
| return; | |
| } | |
| if (visited.has(node.id)) return; | |
| temp.add(node.id); | |
| const ins = node.inputs || {}; | |
| Object.values(ins).forEach(depId => { | |
| const dep = byId.get(depId); | |
| if (dep) visit(dep); | |
| }); | |
| temp.delete(node.id); | |
| visited.add(node.id); | |
| sorted.push(node); | |
| } | |
| sg.forEach(n => visit(n)); | |
| const ordered = sorted.length ? sorted : sg; | |
| const funcNameById = new Map(); | |
| ordered.forEach((node, idx) => { | |
| const safe = (node.id || ("node" + idx)).replace(/[^a-zA-Z0-9_]/g, "_"); | |
| let fn = "g_sdfn_" + safe; | |
| if (funcNameById.has(node.id)) fn += "_" + idx; | |
| funcNameById.set(node.id, fn); | |
| }); | |
| function exprToGLSL(v, fallback) { | |
| if (v == null) return fallback; | |
| if (Array.isArray(v)) return exprToGLSL(v[0], fallback); | |
| if (typeof v === "number") return String(v); | |
| if (typeof v === "string") { | |
| return v.replace(/\btime\b/g, "u_time"); | |
| } | |
| return fallback; | |
| } | |
| function vec3Expr(arr, def, label) { | |
| if (!Array.isArray(arr) || arr.length === 0) { | |
| const c = exprToGLSL(arr, def); | |
| return "vec3(" + c + ", " + c + ", " + c + ")"; | |
| } | |
| let a = arr.slice(); | |
| if (a.length === 1) { | |
| a = [a[0], a[0], a[0]]; | |
| } else if (a.length === 2) { | |
| log("DSL: " + label + " array has 2 components; repeating last", "info"); | |
| a = [a[0], a[1], a[1]]; | |
| } else if (a.length > 3) { | |
| log("DSL: " + label + " array has >3 components; extra ignored", "info"); | |
| a = a.slice(0, 3); | |
| } | |
| const x = exprToGLSL(a[0], def); | |
| const y = exprToGLSL(a[1], def); | |
| const z = exprToGLSL(a[2], def); | |
| return "vec3(" + x + ", " + y + ", " + z + ")"; | |
| } | |
| function getInputCalls(node) { | |
| const inputs = node.inputs || {}; | |
| let ids = node.inputOrder && node.inputOrder.length | |
| ? node.inputOrder.slice() | |
| : Object.values(inputs); | |
| ids = ids.filter(Boolean); | |
| const calls = []; | |
| ids.forEach(inpId => { | |
| const fn = funcNameById.get(inpId); | |
| if (fn) calls.push(fn + "(p)"); | |
| else calls.push("vec4(9999.0, 1.0, 1.0, 1.0)"); | |
| }); | |
| return calls; | |
| } | |
| lines.push("// Autogenerated GLSL from SDF scene graph"); | |
| lines.push("// Nodes: " + sg.length + " (ordered: " + ordered.length + ")"); | |
| if (hasCycle) lines.push("// WARNING: graph contains cycles"); | |
| lines.push(""); | |
| lines.push("float g_sdSphere(vec3 p, float r) { return length(p) - r; }"); | |
| lines.push("float g_sdBox(vec3 p, vec3 b) {"); | |
| lines.push(" vec3 d = abs(p) - b;"); | |
| lines.push(" return min(max(d.x, max(d.y, d.z)), 0.0) + length(max(d, 0.0));"); | |
| lines.push("}"); | |
| lines.push("float g_opSmoothUnion(float d1, float d2, float k) {"); | |
| lines.push(" float h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0);"); | |
| lines.push(" return mix(d2, d1, h) - k * h * (1.0 - h);"); | |
| lines.push("}"); | |
| lines.push("float g_opSubtract(float d1, float d2) {"); | |
| lines.push(" return max(d1, -d2);"); | |
| lines.push("}"); | |
| lines.push("vec3 g_rotateXYZ(vec3 p, vec3 r) {"); | |
| lines.push(" float cx = cos(r.x), sx = sin(r.x);"); | |
| lines.push(" float cy = cos(r.y), sy = sin(r.y);"); | |
| lines.push(" float cz = cos(r.z), sz = sin(r.z);"); | |
| lines.push(" mat3 rx = mat3(1.0,0.0,0.0, 0.0,cx,-sx, 0.0,sx,cx);"); | |
| lines.push(" mat3 ry = mat3(cy,0.0,sy, 0.0,1.0,0.0, -sy,0.0,cy);"); | |
| lines.push(" mat3 rz = mat3(cz,-sz,0.0, sz,cz,0.0, 0.0,0.0,1.0);"); | |
| lines.push(" return rz * ry * rx * p;"); | |
| lines.push("}"); | |
| lines.push("float g_sdCapsule(vec3 p, vec3 a, vec3 b, float r) {"); | |
| lines.push(" vec3 pa = p - a;"); | |
| lines.push(" vec3 ba = b - a;"); | |
| lines.push(" float h = clamp(dot(pa, ba)/dot(ba, ba), 0.0, 1.0);"); | |
| lines.push(" return length(pa - ba * h) - r;"); | |
| lines.push("}"); | |
| lines.push("float g_sdEllipsoid(vec3 p, vec3 r) {"); | |
| lines.push(" float k0 = length(p / r);"); | |
| lines.push(" float k1 = length(p / (r * r));"); | |
| lines.push(" return k0 * (k0 - 1.0) / k1;"); | |
| lines.push("}"); | |
| lines.push("float g_sdPlane(vec3 p, vec3 n, float d) {"); | |
| lines.push(" return dot(p, normalize(n)) + d;"); | |
| lines.push("}"); | |
| lines.push(""); | |
| // quaternion helpers | |
| lines.push("vec4 g_quat(float w, float x, float y, float z) {"); | |
| lines.push(" return normalize(vec4(x, y, z, w));"); | |
| lines.push("}"); | |
| lines.push("vec4 g_quatMul(vec4 a, vec4 b) {"); | |
| lines.push(" return vec4("); | |
| lines.push(" a.w*b.x + a.x*b.w + a.y*b.z - a.z*b.y,"); | |
| lines.push(" a.w*b.y - a.x*b.z + a.y*b.w + a.z*b.x,"); | |
| lines.push(" a.w*b.z + a.x*b.y - a.y*b.x + a.z*b.w,"); | |
| lines.push(" a.w*b.w - dot(a.xyz, b.xyz)"); | |
| lines.push(" );"); | |
| lines.push("}"); | |
| lines.push("vec4 g_quatEuler(float rx, float ry, float rz) {"); | |
| lines.push(" float hx = rx * 0.5;"); | |
| lines.push(" float hy = ry * 0.5;"); | |
| lines.push(" float hz = rz * 0.5;"); | |
| lines.push(" vec4 qx = vec4(sin(hx), 0.0, 0.0, cos(hx));"); | |
| lines.push(" vec4 qy = vec4(0.0, sin(hy), 0.0, cos(hy));"); | |
| lines.push(" vec4 qz = vec4(0.0, 0.0, sin(hz), cos(hz));"); | |
| lines.push(" vec4 q = g_quatMul(g_quatMul(qz, qy), qx);"); | |
| lines.push(" return normalize(q);"); | |
| lines.push("}"); | |
| lines.push("vec4 g_quatAxisAngle(vec3 axis, float angle) {"); | |
| lines.push(" float h = angle * 0.5;"); | |
| lines.push(" float s = sin(h);"); | |
| lines.push(" return normalize(vec4(axis * s, cos(h)));"); | |
| lines.push("}"); | |
| lines.push("vec3 g_qrotate(vec3 p, vec4 q) {"); | |
| lines.push(" vec3 t = 2.0 * cross(q.xyz, p);"); | |
| lines.push(" return p + q.w * t + cross(q.xyz, t);"); | |
| lines.push("}"); | |
| lines.push("// DSL quaternion helpers"); | |
| lines.push("vec4 quat(float w, float x, float y, float z) { return g_quat(w,x,y,z); }"); | |
| lines.push("vec4 quatEuler(float rx, float ry, float rz) { return g_quatEuler(rx,ry,rz); }"); | |
| lines.push("vec4 quatAxisAngle(vec3 axis, float angle) { return g_quatAxisAngle(axis,angle); }"); | |
| lines.push(""); | |
| if (!sg.length) { | |
| lines.push("// No nodes; sampleGraphScene returns empty density."); | |
| lines.push("vec4 sampleGraphScene(vec3 p, out float sdf, out vec3 surfCol) {"); | |
| lines.push(" sdf = 9999.0;"); | |
| lines.push(" surfCol = vec3(1.0);"); | |
| lines.push(" float den = smoothstep(0.1, -0.1, sdf);"); | |
| lines.push(" return vec4(vec3(den), den);"); | |
| lines.push("}"); | |
| return lines.join("\n"); | |
| } | |
| ordered.forEach((node, idx) => { | |
| if (!node || !node.id) return; | |
| const fn = funcNameById.get(node.id); | |
| const type = node.type || "unknown"; | |
| const params = node.params || {}; | |
| const col = vec3Expr(params.color || ["1.0", "1.0", "1.0"], "1.0", "color"); | |
| const off = vec3Expr(params.offset || ["0.0", "0.0", "0.0"], "0.0", "offset"); | |
| const rot = vec3Expr(params.rot || ["0.0", "0.0", "0.0"], "0.0", "rot"); | |
| const hasQuat = params.rotq != null; | |
| lines.push("// node " + idx + ": type=" + type + " id=" + node.id); | |
| lines.push("vec4 " + fn + "(vec3 p) {"); | |
| lines.push(" vec3 q = p;"); | |
| lines.push(" q -= " + off + ";"); | |
| if (hasQuat) { | |
| const rotqExpr = exprToGLSL(params.rotq, "vec4(0.0,0.0,0.0,1.0)"); | |
| lines.push(" vec4 qrot = " + rotqExpr + ";"); | |
| lines.push(" qrot = normalize(qrot);"); | |
| lines.push(" q = g_qrotate(q, qrot);"); | |
| } else { | |
| lines.push(" q = g_rotateXYZ(q, " + rot + ");"); | |
| } | |
| if (type === "sphere") { | |
| const rExpr = exprToGLSL(params.radius, "1.0"); | |
| lines.push(" float d = g_sdSphere(q, " + rExpr + ");"); | |
| lines.push(" vec3 color = clamp(" + col + ", 0.0, 1.0);"); | |
| lines.push(" return vec4(d, color);"); | |
| } else if (type === "box") { | |
| const sExpr = params.size; | |
| let sizeExpr; | |
| if (Array.isArray(sExpr)) { | |
| sizeExpr = vec3Expr(sExpr, "1.0", "size"); | |
| } else { | |
| const v = exprToGLSL(sExpr, "1.0"); | |
| sizeExpr = "vec3(" + v + ")"; | |
| } | |
| lines.push(" float d = g_sdBox(q, " + sizeExpr + ");"); | |
| lines.push(" vec3 color = clamp(" + col + ", 0.0, 1.0);"); | |
| lines.push(" return vec4(d, color);"); | |
| } else if (type === "capsule") { | |
| const aExpr = vec3Expr(params.a, "0.0", "a"); | |
| const bExpr = vec3Expr(params.b, "1.0", "b"); | |
| const rExpr = exprToGLSL(params.r, "0.5"); | |
| lines.push(" float d = g_sdCapsule(q, " + aExpr + ", " + bExpr + ", " + rExpr + ");"); | |
| lines.push(" vec3 color = clamp(" + col + ", 0.0, 1.0);"); | |
| lines.push(" return vec4(d, color);"); | |
| } else if (type === "ellipsoid") { | |
| const rVec = Array.isArray(params.r) | |
| ? vec3Expr(params.r, "1.0", "r") | |
| : "vec3(" + exprToGLSL(params.r, "1.0") + ")"; | |
| lines.push(" float d = g_sdEllipsoid(q, " + rVec + ");"); | |
| lines.push(" vec3 color = clamp(" + col + ", 0.0, 1.0);"); | |
| lines.push(" return vec4(d, color);"); | |
| } else if (type === "plane") { | |
| const nExpr = vec3Expr(params.n, "0.0", "n"); | |
| const dExpr = exprToGLSL(params.d, "0.0"); | |
| lines.push(" float d = g_sdPlane(q, " + nExpr + ", " + dExpr + ");"); | |
| lines.push(" vec3 color = clamp(" + col + ", 0.0, 1.0);"); | |
| lines.push(" return vec4(d, color);"); | |
| } else if (type === "union") { | |
| const calls = getInputCalls(node); | |
| if (!calls.length) { | |
| lines.push(" return vec4(9999.0, " + col + ");"); | |
| } else { | |
| calls.forEach((c, i) => lines.push(" vec4 v" + i + " = " + c + ";")); | |
| lines.push(" vec4 best = v0;"); | |
| for (let i = 1; i < calls.length; i++) { | |
| lines.push(" if (v" + i + ".x < best.x) best = v" + i + ";"); | |
| } | |
| lines.push(" return best;"); | |
| } | |
| } else if (type === "subtract") { | |
| const calls = getInputCalls(node); | |
| if (!calls.length) { | |
| lines.push(" return vec4(9999.0, " + col + ");"); | |
| } else { | |
| calls.forEach((c, i) => lines.push(" vec4 v" + i + " = " + c + ";")); | |
| lines.push(" float d = v0.x;"); | |
| for (let i = 1; i < calls.length; i++) { | |
| lines.push(" d = g_opSubtract(d, v" + i + ".x);"); | |
| } | |
| lines.push(" return vec4(d, v0.yzw);"); | |
| } | |
| } else if (type === "smoothUnion") { | |
| const calls = getInputCalls(node); | |
| const kExpr = exprToGLSL(params.k, "0.2"); | |
| if (!calls.length) { | |
| lines.push(" return vec4(9999.0, " + col + ");"); | |
| } else { | |
| calls.forEach((c, i) => lines.push(" vec4 v" + i + " = " + c + ";")); | |
| lines.push(" float d = v0.x;"); | |
| for (let i = 1; i < calls.length; i++) { | |
| lines.push(" d = g_opSmoothUnion(d, v" + i + ".x, " + kExpr + ");"); | |
| } | |
| lines.push(" vec4 best = v0;"); | |
| for (let i = 1; i < calls.length; i++) { | |
| lines.push(" if (v" + i + ".x < best.x) best = v" + i + ";"); | |
| } | |
| lines.push(" return vec4(d, best.yzw);"); | |
| } | |
| } else if (type === "alias") { | |
| const calls = getInputCalls(node); | |
| if (!calls.length) { | |
| lines.push(" return vec4(9999.0, " + col + ");"); | |
| } else { | |
| lines.push(" vec4 v0 = " + calls[0] + ";"); | |
| lines.push(" return v0;"); | |
| } | |
| } else { | |
| lines.push(" float d = 9999.0;"); | |
| lines.push(" vec3 color = clamp(" + col + ", 0.0, 1.0);"); | |
| lines.push(" return vec4(d, color);"); | |
| } | |
| lines.push("}"); | |
| lines.push(""); | |
| }); | |
| let root = ordered.find(n => n.id === "out"); | |
| if (!root && ordered.length) root = ordered[ordered.length - 1]; | |
| const rootFn = root ? funcNameById.get(root.id) : null; | |
| lines.push("vec4 g_df_scene(vec3 p) {"); | |
| if (!rootFn) { | |
| lines.push(" return vec4(9999.0, 1.0, 1.0, 1.0);"); | |
| } else { | |
| lines.push(" return " + rootFn + "(p);"); | |
| } | |
| lines.push("}"); | |
| lines.push(""); | |
| lines.push("vec4 sampleGraphScene(vec3 p, out float sdf, out vec3 surfCol) {"); | |
| lines.push(" vec4 v = g_df_scene(p);"); | |
| lines.push(" float d = v.x;"); | |
| lines.push(" vec3 col = v.yzw;"); | |
| lines.push(" float den = smoothstep(0.1, -0.1, d);"); | |
| lines.push(" sdf = d;"); | |
| lines.push(" surfCol = col;"); | |
| lines.push(" return vec4(col, den);"); | |
| lines.push("}"); | |
| return lines.join("\n"); | |
| } | |
| // ============================================================ | |
| // DSL / GLSL preview component (uses legacy IR; runs shadow parser) | |
| // ============================================================ | |
| class GlslPreviewApp extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| height: 100%; | |
| background: #020617; | |
| color: #e5e7eb; | |
| box-sizing: border-box; | |
| padding: 8px; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; | |
| font-size: 12px; | |
| } | |
| .header { | |
| display: flex; | |
| flex-wrap: wrap; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 6px 8px; | |
| background: #111827; | |
| border-radius: 6px; | |
| margin-bottom: 6px; | |
| gap: 6px; | |
| } | |
| .header-title { | |
| font-size: 13px; | |
| font-weight: 600; | |
| } | |
| .header-buttons { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| align-items: center; | |
| justify-content: flex-end; | |
| } | |
| .btn-group { | |
| display: flex; | |
| gap: 4px; | |
| flex-wrap: wrap; | |
| } | |
| button { | |
| border: none; | |
| cursor: pointer; | |
| font-size: 11px; | |
| white-space: nowrap; | |
| } | |
| .btn-ex { | |
| background: #374151; | |
| color: #e5e7eb; | |
| border-radius: 999px; | |
| padding: 3px 10px; | |
| } | |
| .btn-ex:hover { | |
| background: #4b5563; | |
| } | |
| .btn-action { | |
| background: #2563eb; | |
| color: #e5e7eb; | |
| border-radius: 4px; | |
| padding: 4px 10px; | |
| font-weight: 600; | |
| } | |
| .btn-action:hover { | |
| background: #1d4ed8; | |
| } | |
| .content { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| height: calc(100% - 40px); | |
| margin-top: 4px; | |
| } | |
| .pane { | |
| flex: 1; | |
| min-height: 0; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .pane-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| color: #9ca3af; | |
| margin-bottom: 2px; | |
| } | |
| textarea { | |
| flex: 1; | |
| width: 100%; | |
| border-radius: 6px; | |
| border: 1px solid #1f2937; | |
| background: #020617; | |
| color: #e5e7eb; | |
| padding: 8px; | |
| resize: none; | |
| box-sizing: border-box; | |
| white-space: pre; | |
| -webkit-user-select: text; | |
| user-select: text; | |
| touch-action: auto; | |
| } | |
| </style> | |
| <div class="header"> | |
| <div class="header-title">SDF DSL → GLSL</div> | |
| <div class="header-buttons"> | |
| <div class="btn-group"> | |
| <button class="btn-ex" id="ex1">Two Spheres</button> | |
| <button class="btn-ex" id="ex2">Box Minus Sphere</button> | |
| <button class="btn-ex" id="ex3">Animated Blob</button> | |
| <button class="btn-ex" id="ex4">Wandering Box</button> | |
| </div> | |
| <div class="btn-group"> | |
| <button class="btn-action" id="render">Render</button> | |
| <button class="btn-action" id="reset">Reset</button> | |
| <button class="btn-action" id="clear">Clear</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="content"> | |
| <div class="pane"> | |
| <div class="pane-header"> | |
| <span>DSL (editable)</span> | |
| <button class="btn-ex" id="selectDsl">Select</button> | |
| </div> | |
| <textarea id="dsl" spellcheck="false"></textarea> | |
| </div> | |
| <div class="pane"> | |
| <div class="pane-header"> | |
| <span>Generated GLSL</span> | |
| <button class="btn-ex" id="selectGlsl">Select</button> | |
| </div> | |
| <textarea id="glsl" spellcheck="false" readonly></textarea> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| connectedCallback() { | |
| appContext.instances.glslPreview = this; | |
| const root = this.shadowRoot; | |
| root.getElementById("render").addEventListener("click", () => this.applyDsl()); | |
| root.getElementById("reset").addEventListener("click", () => this.resetDsl()); | |
| root.getElementById("clear").addEventListener("click", () => this.clearDsl()); | |
| root.getElementById("ex1").addEventListener("click", () => this.loadExample(1)); | |
| root.getElementById("ex2").addEventListener("click", () => this.loadExample(2)); | |
| root.getElementById("ex3").addEventListener("click", () => this.loadExample(3)); | |
| root.getElementById("ex4").addEventListener("click", () => this.loadExample(4)); | |
| root.getElementById("selectDsl").addEventListener("click", () => this.selectTextarea("dsl")); | |
| root.getElementById("selectGlsl").addEventListener("click", () => this.selectTextarea("glsl")); | |
| const dslArea = root.getElementById("dsl"); | |
| if (dslArea.value.trim() === "") { | |
| dslArea.value = DEFAULT_DSL; | |
| } | |
| this.applyDsl(); | |
| } | |
| disconnectedCallback() { | |
| if (appContext.instances.glslPreview === this) { | |
| appContext.instances.glslPreview = null; | |
| } | |
| } | |
| selectTextarea(id) { | |
| const ta = this.shadowRoot.getElementById(id); | |
| if (!ta) return; | |
| ta.focus(); | |
| ta.select(); | |
| } | |
| clearDsl() { | |
| const dslArea = this.shadowRoot.getElementById("dsl"); | |
| dslArea.value = ""; | |
| appContext.sceneGraph = []; | |
| this.refreshGlsl(); | |
| log("DSL cleared; sceneGraph reset", "info"); | |
| window.dispatchEvent(new CustomEvent("dsl-args-usage", { | |
| detail: { arg_1: false, arg_2: false, arg_3: false, arg_4: false } | |
| })); | |
| if (appContext.instances.sdfRenderer) { | |
| appContext.instances.sdfRenderer.rebuildProgramFromSceneGraph(); | |
| } | |
| } | |
| resetDsl() { | |
| const dslArea = this.shadowRoot.getElementById("dsl"); | |
| dslArea.value = DEFAULT_DSL; | |
| this.applyDsl(); | |
| } | |
| loadExample(which) { | |
| let text = ""; | |
| if (which === 1) { | |
| text = [ | |
| "# Example 1: two colored spheres (uses arg_1 for radius tweak)", | |
| "", | |
| "n0 = sphere(", | |
| " r = 1.0 + arg_1,", | |
| " color = [1.0, 0.2, 0.2],", | |
| " offset = [-0.8, 0.0, 0.0]", | |
| ")", | |
| "n1 = sphere(", | |
| " r = 0.8,", | |
| " color = [0.2, 0.8, 1.0],", | |
| " offset = [0.8, 0.0, 0.0]", | |
| ")", | |
| "out = union(n0, n1)" | |
| ].join("\n"); | |
| } else if (which === 2) { | |
| text = [ | |
| "# Example 2: box with carved-out sphere", | |
| "", | |
| "b = box(", | |
| " s = [1.3, 0.9, 0.7],", | |
| " color = [0.2, 0.9, 0.4],", | |
| " rot = [0.0, 0.4, 0.0]", | |
| ")", | |
| "s = sphere(", | |
| " r = 0.9,", | |
| " color = [1.0, 0.9, 0.5],", | |
| " offset = [0.0, 0.0, 0.0]", | |
| ")", | |
| "out = subtract(b, s)" | |
| ].join("\n"); | |
| } else if (which === 3) { | |
| text = DEFAULT_DSL; | |
| } else { | |
| text = [ | |
| "# Example 4: wandering, tumbling box (uses quatEuler)", | |
| "", | |
| "s0 = box(", | |
| " s = [1.4, 1.0, 0.6],", | |
| " color = [0.4 + sin(time), 0.0, 1.0],", | |
| " offset = [0.4 + sin(time), 1.0, 0.0],", | |
| " rotq = quatEuler(1.2*time, 0.9*time, 0.7*time)", | |
| ")", | |
| "out = s0" | |
| ].join("\n"); | |
| } | |
| const dslArea = this.shadowRoot.getElementById("dsl"); | |
| dslArea.value = text; | |
| this.applyDsl(); | |
| } | |
| applyDsl() { | |
| const dslArea = this.shadowRoot.getElementById("dsl"); | |
| const codeArea = this.shadowRoot.getElementById("glsl"); | |
| const text = dslArea.value || ""; | |
| // 1) Legacy parser (source of truth) | |
| const { nodes, errors } = parseDslToSceneGraph(text); | |
| // 2) Shadow parser (AST-only, defensively wrapped) | |
| try { | |
| const shadow = shadowParseDsl(text); | |
| const ast = shadow.ast || []; | |
| const errs = shadow.errors || []; | |
| const counts = { | |
| total: ast.length, | |
| def: ast.filter(n => n.kind === "def").length, | |
| assign: ast.filter(n => n.kind === "assign").length, | |
| call: ast.filter(n => n.kind === "call").length, | |
| ident: ast.filter(n => n.kind === "ident").length, | |
| unknown: ast.filter(n => n.kind === "unknown").length | |
| }; | |
| log( | |
| "Shadow parser: " + | |
| counts.total + " stmt(s), " + | |
| counts.def + " def, " + | |
| counts.assign + " assign, " + | |
| counts.call + " call, " + | |
| counts.ident + " ident, " + | |
| counts.unknown + " unknown", | |
| errs.length ? "error" : "info" | |
| ); | |
| if (errs.length) { | |
| log("Shadow parser notes: " + errs.slice(0, 3).join(" | "), "error"); | |
| } | |
| } catch (e) { | |
| log("Shadow parser exception: " + (e && e.message ? e.message : e), "error"); | |
| } | |
| // args usage for UI sliders | |
| const used = { | |
| arg_1: /\barg_1\b/.test(text), | |
| arg_2: /\barg_2\b/.test(text), | |
| arg_3: /\barg_3\b/.test(text), | |
| arg_4: /\barg_4\b/.test(text) | |
| }; | |
| window.dispatchEvent(new CustomEvent("dsl-args-usage", { detail: used })); | |
| if (errors.length) { | |
| const header = "// DSL parse errors (legacy parser):\n// " + errors.join("\n// ") + "\n\n"; | |
| const oldCode = generateGlslFromSceneGraph(appContext.sceneGraph || []); | |
| codeArea.value = header + oldCode; | |
| log("DSL parse (legacy) failed with " + errors.length + " error(s)", "error"); | |
| return; | |
| } | |
| appContext.sceneGraph = nodes; | |
| const glslCode = generateGlslFromSceneGraph(nodes); | |
| codeArea.value = glslCode; | |
| log("DSL applied via legacy parser: " + nodes.length + " node(s)", "success"); | |
| if (appContext.instances.sdfRenderer) { | |
| const r = appContext.instances.sdfRenderer; | |
| r.params.sceneMode = 3; | |
| r.updateUniforms(); | |
| r.rebuildProgramFromSceneGraph(); | |
| } | |
| } | |
| refreshGlsl() { | |
| const codeArea = this.shadowRoot.getElementById("glsl"); | |
| const code = generateGlslFromSceneGraph(appContext.sceneGraph || []); | |
| codeArea.value = code; | |
| log("GLSL preview refreshed from sceneGraph", "info"); | |
| } | |
| } | |
| customElements.define("glsl-preview-app", GlslPreviewApp); | |
| // ============================================================ | |
| // Compositing editor: one-line GLSL combining layers | |
| // ============================================================ | |
| const DEFAULT_COMPOSITE = [ | |
| "// Available: colSurf, colVol, colUtil, depthSurf, depthVol", | |
| "// depthSurf/depthVol in [0,1]; 1.0 ~ no hit", | |
| "finalColor = mix(colSurf, colVol, 0.4) + 0.2 * colUtil;" | |
| ].join("\n"); | |
| class CompositeEditorApp extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| height: 100%; | |
| background: #020617; | |
| color: #e5e7eb; | |
| box-sizing: border-box; | |
| padding: 8px; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; | |
| font-size: 12px; | |
| } | |
| .header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 6px 8px; | |
| background: #111827; | |
| border-radius: 6px; | |
| margin-bottom: 6px; | |
| } | |
| button { | |
| background: #2563eb; | |
| border: none; | |
| border-radius: 4px; | |
| color: #e5e7eb; | |
| padding: 4px 10px; | |
| font-size: 11px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| } | |
| button:hover { background: #1d4ed8; } | |
| textarea { | |
| width: 100%; | |
| height: calc(100% - 40px); | |
| border-radius: 6px; | |
| border: 1px solid #1f2937; | |
| background: #020617; | |
| color: #e5e7eb; | |
| padding: 8px; | |
| box-sizing: border-box; | |
| resize: none; | |
| white-space: pre; | |
| } | |
| </style> | |
| <div class="header"> | |
| <span>Compositing (GLSL one-liner)</span> | |
| <button id="apply">Apply Composite</button> | |
| </div> | |
| <textarea id="expr" spellcheck="false"></textarea> | |
| `; | |
| } | |
| connectedCallback() { | |
| appContext.instances.compositeEditor = this; | |
| const ta = this.shadowRoot.getElementById("expr"); | |
| if (!ta.value.trim()) ta.value = DEFAULT_COMPOSITE; | |
| this.shadowRoot.getElementById("apply") | |
| .addEventListener("click", () => this.apply()); | |
| } | |
| disconnectedCallback() { | |
| if (appContext.instances.compositeEditor === this) { | |
| appContext.instances.compositeEditor = null; | |
| } | |
| } | |
| apply() { | |
| const expr = this.shadowRoot.getElementById("expr").value || DEFAULT_COMPOSITE; | |
| window.dispatchEvent(new CustomEvent("composite-updated", { | |
| detail: { expr } | |
| })); | |
| log("Composite expression updated", "info"); | |
| } | |
| } | |
| customElements.define("composite-editor-app", CompositeEditorApp); | |
| // ============================================================ | |
| // SDF Renderer (three-layer, composited, edges, pause) | |
| // ============================================================ | |
| class SdfRendererApp extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| height: 100%; | |
| background: #111827; | |
| color: #e5e7eb; | |
| box-sizing: border-box; | |
| padding: 8px; | |
| } | |
| .container { | |
| display: flex; | |
| flex-direction: row; | |
| height: 100%; | |
| gap: 8px; | |
| } | |
| .canvas-wrap { | |
| flex: 1; | |
| min-width: 0; | |
| display: flex; | |
| align-items: stretch; | |
| justify-content: center; | |
| } | |
| canvas { | |
| width: 100%; | |
| height: 100%; | |
| border-radius: 8px; | |
| background: #000; | |
| } | |
| .controls { | |
| width: 260px; | |
| max-width: 40%; | |
| background: #020617; | |
| border-radius: 8px; | |
| padding: 8px; | |
| box-sizing: border-box; | |
| font-size: 13px; | |
| overflow-y: auto; | |
| } | |
| .controls h3 { | |
| margin: 4px 0 6px 0; | |
| font-size: 14px; | |
| } | |
| .row { | |
| display: grid; | |
| grid-template-columns: 90px 1fr 46px; | |
| gap: 6px; | |
| align-items: center; | |
| margin-bottom: 6px; | |
| } | |
| .row label { font-size: 12px; } | |
| .row span.value { | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; | |
| font-size: 11px; | |
| text-align: right; | |
| } | |
| .row-check { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 6px; | |
| font-size: 12px; | |
| } | |
| input[type="range"] { | |
| width: 100%; | |
| -webkit-appearance: none; | |
| background: #1f2937; | |
| height: 6px; | |
| border-radius: 3px; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 14px; | |
| height: 14px; | |
| border-radius: 50%; | |
| background: #2563eb; | |
| } | |
| select { | |
| width: 100%; | |
| padding: 6px; | |
| border-radius: 4px; | |
| border: 1px solid #374151; | |
| background: #111827; | |
| color: #e5e7eb; | |
| font-size: 13px; | |
| margin-bottom: 6px; | |
| } | |
| input[disabled]::-webkit-slider-thumb { | |
| background: #6b7280; | |
| } | |
| @media (max-width: 768px) { | |
| .container { flex-direction: column; } | |
| .controls { | |
| width: 100%; | |
| max-width: 100%; | |
| max-height: 40vh; | |
| } | |
| .canvas-wrap { flex: 0 0 60vh; } | |
| } | |
| </style> | |
| <div class="container"> | |
| <div class="canvas-wrap"> | |
| <canvas id="canvas"></canvas> | |
| </div> | |
| <div class="controls"> | |
| <h3>Scene</h3> | |
| <select id="preset"> | |
| <option value="dsl" selected>DSL Graph Render</option> | |
| <option value="jellies">Jellies (sphere + box)</option> | |
| <option value="vol-cubes">Volume Cubes</option> | |
| <option value="vol-spheres">Volume Spheres</option> | |
| <option value="vol-capsules">Volume Capsules</option> | |
| <option value="ball">Bouncing Ball</option> | |
| </select> | |
| <h3>Camera & Shading</h3> | |
| <div class="row-check"> | |
| <input id="orbitCamera" type="checkbox" checked /> | |
| <label for="orbitCamera">Orbit camera</label> | |
| </div> | |
| <div class="row-check"> | |
| <input id="edgeRender" type="checkbox" checked /> | |
| <label for="edgeRender">Edge render (DSL)</label> | |
| </div> | |
| <div class="row-check"> | |
| <input id="pauseRender" type="checkbox" /> | |
| <label for="pauseRender">Pause rendering</label> | |
| </div> | |
| <h3>Volume / DSL Raymarch</h3> | |
| <div class="row"> | |
| <label>Steps</label> | |
| <input id="steps" type="range" min="16" max="256" step="16" value="64" /> | |
| <span id="stepsVal" class="value">64</span> | |
| </div> | |
| <div class="row"> | |
| <label>Step Size</label> | |
| <input id="stepSize" type="range" min="0.01" max="0.2" step="0.01" value="0.1" /> | |
| <span id="stepSizeVal" class="value">0.10</span> | |
| </div> | |
| <div class="row"> | |
| <label>Max Dist</label> | |
| <input id="maxDist" type="range" min="5" max="20" step="1" value="10" /> | |
| <span id="maxDistVal" class="value">10</span> | |
| </div> | |
| <h3>DSL Args</h3> | |
| <div class="row"> | |
| <label>arg_1</label> | |
| <input id="arg1" type="range" min="-2.0" max="2.0" step="0.05" value="0.0" /> | |
| <span id="arg1Val" class="value">0.00</span> | |
| </div> | |
| <div class="row"> | |
| <label>arg_2</label> | |
| <input id="arg2" type="range" min="-2.0" max="2.0" step="0.05" value="0.0" /> | |
| <span id="arg2Val" class="value">0.00</span> | |
| </div> | |
| <div class="row"> | |
| <label>arg_3</label> | |
| <input id="arg3" type="range" min="-2.0" max="2.0" step="0.05" value="0.0" /> | |
| <span id="arg3Val" class="value">0.00</span> | |
| </div> | |
| <div class="row"> | |
| <label>arg_4</label> | |
| <input id="arg4" type="range" min="-2.0" max="2.0" step="0.05" value="0.0" /> | |
| <span id="arg4Val" class="value">0.00</span> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| this.params = { | |
| sceneMode: 3, // 0=jellies, 1=volume shapes, 2=ball, 3=DSL | |
| maxSteps: 64.0, | |
| stepSize: 0.1, | |
| maxDist: 10.0, | |
| shape: 0, | |
| ballRadius: 0.5, | |
| cameraOrbit: 1, | |
| enableEdges: 1, | |
| arg1: 0.0, | |
| arg2: 0.0, | |
| arg3: 0.0, | |
| arg4: 0.0 | |
| }; | |
| this.gl = null; | |
| this.program = null; | |
| this.uniforms = null; | |
| this._rafId = 0; | |
| this.compositeExpr = DEFAULT_COMPOSITE; | |
| this._running = false; | |
| this._userPaused = false; | |
| this._visibilityHandler = null; | |
| } | |
| connectedCallback() { | |
| appContext.instances.sdfRenderer = this; | |
| this.initGL(); | |
| this.setupControls(); | |
| this.resizeCanvas(); | |
| window.addEventListener("resize", this._onResize = () => this.resizeCanvas()); | |
| window.addEventListener("dsl-args-usage", | |
| this._onArgsUsage = (e) => this.updateArgUsage(e.detail)); | |
| window.addEventListener("composite-updated", | |
| this._onComposite = (e) => this.updateComposite(e.detail.expr)); | |
| this._visibilityHandler = () => { | |
| if (document.hidden) { | |
| this.stopAnimation(); | |
| } else { | |
| if (!this._userPaused) this.startAnimation(); | |
| } | |
| }; | |
| document.addEventListener("visibilitychange", this._visibilityHandler); | |
| this.startAnimation(); | |
| log("SDF renderer initialized", "success"); | |
| } | |
| disconnectedCallback() { | |
| window.removeEventListener("resize", this._onResize); | |
| window.removeEventListener("dsl-args-usage", this._onArgsUsage); | |
| window.removeEventListener("composite-updated", this._onComposite); | |
| document.removeEventListener("visibilitychange", this._visibilityHandler); | |
| this.stopAnimation(); | |
| if (appContext.instances.sdfRenderer === this) { | |
| appContext.instances.sdfRenderer = null; | |
| } | |
| } | |
| startAnimation() { | |
| if (this._running || !this.gl) return; | |
| this._running = true; | |
| this._rafId = requestAnimationFrame((t) => this.animate(t)); | |
| } | |
| stopAnimation() { | |
| if (!this._running) return; | |
| this._running = false; | |
| if (this._rafId) cancelAnimationFrame(this._rafId); | |
| this._rafId = 0; | |
| } | |
| resizeCanvas() { | |
| const canvas = this.shadowRoot.getElementById("canvas"); | |
| const rect = canvas.getBoundingClientRect(); | |
| canvas.width = rect.width || 300; | |
| canvas.height = rect.height || 300; | |
| } | |
| updateArgUsage(detail) { | |
| const root = this.shadowRoot; | |
| const pairs = [ | |
| ["arg1", "arg_1"], | |
| ["arg2", "arg_2"], | |
| ["arg3", "arg_3"], | |
| ["arg4", "arg_4"] | |
| ]; | |
| pairs.forEach(([id, key]) => { | |
| const input = root.getElementById(id); | |
| input.disabled = !detail[key]; | |
| }); | |
| } | |
| updateComposite(expr) { | |
| this.compositeExpr = expr || DEFAULT_COMPOSITE; | |
| this.buildProgramFromSceneGraph(); | |
| } | |
| getFragmentSource(extraGraphCode, compositeExpr) { | |
| const exprLines = (compositeExpr || DEFAULT_COMPOSITE).split(/\r?\n/); | |
| const sanitized = exprLines.map((l) => l).join("\n"); | |
| return ` | |
| precision highp float; | |
| uniform float u_time; | |
| uniform float u_maxSteps; | |
| uniform float u_stepSize; | |
| uniform float u_maxDist; | |
| uniform int u_sceneMode; | |
| uniform int u_shape; | |
| uniform float u_ballRadius; | |
| uniform int u_cameraOrbit; | |
| uniform int u_enableEdges; | |
| uniform float u_arg1; | |
| uniform float u_arg2; | |
| uniform float u_arg3; | |
| uniform float u_arg4; | |
| // DSL arg aliases | |
| #define arg_1 u_arg1 | |
| #define arg_2 u_arg2 | |
| #define arg_3 u_arg3 | |
| #define arg_4 u_arg4 | |
| 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 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; | |
| } | |
| ${extraGraphCode} | |
| // jellies: sphere + box smooth union | |
| vec4 sampleJellies(vec3 p, out float sdf, out vec3 surfCol) { | |
| vec3 p1 = p - vec3(0.8, 0.0, 0.0); | |
| vec3 p2 = p + vec3(0.8, 0.0, 0.0); | |
| float dSphere = sdSphere(p1, 1.0); | |
| float dBox = sdBox(p2, vec3(1.0)); | |
| float d = dSphere; | |
| float d2 = dBox; | |
| float k = 0.2; | |
| float h = clamp(0.5 + 0.5 * (d2 - d) / k, 0.0, 1.0); | |
| float dBlend = mix(d2, d, h) - k * h * (1.0 - h); | |
| vec3 color = 0.5 + 0.5 * cos(vec3(1.0, 2.0, 3.0) + dBlend * 2.0 + u_time); | |
| float density = smoothstep(0.1, -0.1, dBlend) * 0.5; | |
| sdf = dBlend; | |
| surfCol = color; | |
| return vec4(color, density); | |
| } | |
| // tiled volume shapes | |
| vec4 sampleVolumeShapes(vec3 p, out float sdf, out vec3 surfCol) { | |
| vec3 q = mod(p + 2.0, 4.0) - 2.0; | |
| float d; | |
| if (u_shape == 0) { | |
| d = sdBox(q, vec3(0.5)); | |
| } else if (u_shape == 1) { | |
| d = sdSphere(q, 0.7); | |
| } else { | |
| d = sdCapsule(q, vec3(-0.3), vec3(0.3), 0.2); | |
| } | |
| 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; | |
| sdf = d; | |
| surfCol = color; | |
| return vec4(color, density + gridDensity); | |
| } | |
| // bouncing ball with squash & stretch | |
| vec4 sampleBallScene(vec3 p, out float sdf, out vec3 surfCol) { | |
| float dGround = p.y; | |
| float omega = 2.5; | |
| float s = abs(sin(u_time * omega)); | |
| float bounceHeight = 2.0; | |
| float radius = u_ballRadius; | |
| float ballY = radius + s * bounceHeight; | |
| vec3 ballCenter = vec3(0.0, ballY, 0.0); | |
| vec3 q = p - ballCenter; | |
| float contact = 1.0 - s; | |
| float squash = contact * 0.4; | |
| float sy = 1.0 - squash; | |
| float sxz = 1.0 + squash; | |
| vec3 qScaled = vec3(q.x / sxz, q.y / sy, q.z / sxz); | |
| float dBall = length(qScaled) - radius; | |
| float d = min(dBall, dGround); | |
| vec3 ballColor = vec3(0.9, 0.5, 0.2); | |
| vec3 groundColor = vec3(0.2, 0.7, 0.3); | |
| vec3 color = mix(groundColor, ballColor, smoothstep(dGround, dBall, dBall)); | |
| float density = smoothstep(0.1, -0.1, d) * 0.7; | |
| float groundBand = smoothstep(0.0, 0.3, -p.y) * 0.2; | |
| density += groundBand; | |
| sdf = d; | |
| surfCol = color; | |
| return vec4(color, density); | |
| } | |
| // estimate normal for DSL SDF via g_df_scene | |
| vec3 estimateNormal(vec3 p) { | |
| float eps = 0.001; | |
| float dx = g_df_scene(p + vec3(eps,0.0,0.0)).x - g_df_scene(p - vec3(eps,0.0,0.0)).x; | |
| float dy = g_df_scene(p + vec3(0.0,eps,0.0)).x - g_df_scene(p - vec3(0.0,eps,0.0)).x; | |
| float dz = g_df_scene(p + vec3(0.0,0.0,eps)).x - g_df_scene(p - vec3(0.0,0.0,eps)).x; | |
| return normalize(vec3(dx, dy, dz)); | |
| } | |
| void main() { | |
| vec2 resolution = vec2(300.0, 300.0); | |
| vec2 uv = (gl_FragCoord.xy / resolution) * 2.0 - 1.0; | |
| vec3 ro; | |
| vec3 upHint; | |
| if (u_cameraOrbit == 1) { | |
| float theta = u_time * 0.5; | |
| if (u_sceneMode == 2) { | |
| ro = vec3(5.0 * sin(theta), 3.0, 5.0 * cos(theta)); | |
| } else { | |
| ro = vec3(4.0 * sin(theta), 2.0, 4.0 * cos(theta)); | |
| } | |
| upHint = normalize(vec3(0.05 * sin(u_time * 0.4), 1.0, 0.05 * cos(u_time * 0.6))); | |
| } else { | |
| ro = vec3(0.0, 2.5, 5.0); | |
| upHint = vec3(0.0, 1.0, 0.0); | |
| } | |
| vec3 forward = normalize(-ro); | |
| vec3 right = normalize(cross(upHint, forward)); | |
| vec3 up = cross(forward, right); | |
| vec3 rd = normalize(uv.x * right + uv.y * up + 1.5 * forward); | |
| vec3 colVol = vec3(0.0); | |
| vec3 colSurf = vec3(0.0); | |
| vec3 colUtil = vec3(0.0); | |
| float trans = 1.0; | |
| float t = 0.0; | |
| bool hitSurf = false; | |
| float depthSurf = 1.0; | |
| for (float i = 0.0; i < 256.0; i += 1.0) { | |
| if (i >= u_maxSteps || t > u_maxDist || trans < 0.01) break; | |
| vec3 p = ro + rd * t; | |
| float sdf = 9999.0; | |
| vec3 surfCol = vec3(1.0); | |
| vec4 sample; | |
| if (u_sceneMode == 0) { | |
| sample = sampleJellies(p, sdf, surfCol); | |
| } else if (u_sceneMode == 1) { | |
| sample = sampleVolumeShapes(p, sdf, surfCol); | |
| } else if (u_sceneMode == 2) { | |
| sample = sampleBallScene(p, sdf, surfCol); | |
| } else { | |
| sample = sampleGraphScene(p, sdf, surfCol); | |
| } | |
| if (!hitSurf && sdf < 0.001) { | |
| hitSurf = true; | |
| depthSurf = clamp(t / u_maxDist, 0.0, 1.0); | |
| colSurf = surfCol; | |
| } | |
| colVol += trans * sample.rgb * sample.a * u_stepSize; | |
| trans *= exp(-sample.a * u_stepSize * 2.0); | |
| float adv = max(u_stepSize * 0.3, abs(sdf)); | |
| t += adv; | |
| } | |
| float depthVol = clamp(t / u_maxDist, 0.0, 1.0); | |
| if (!hitSurf) colSurf = colVol; | |
| colUtil = vec3(depthSurf); | |
| vec3 colSurfLit = colSurf; | |
| if (hitSurf && u_sceneMode == 3) { | |
| float tHit = depthSurf * u_maxDist; | |
| vec3 pSurf = ro + rd * tHit; | |
| vec3 n = estimateNormal(pSurf); | |
| float diff = clamp(dot(n, normalize(vec3(0.3, 0.7, 0.5))), 0.0, 1.0); | |
| colSurfLit *= 0.5 + 0.5 * diff; | |
| } | |
| vec3 colSurfFinal = colSurfLit; | |
| vec3 finalColor; | |
| ${sanitized} | |
| vec3 col = finalColor; | |
| if (u_enableEdges == 1 && hitSurf && u_sceneMode == 3) { | |
| float tHit = depthSurf * u_maxDist; | |
| vec3 pSurf = ro + rd * tHit; | |
| vec3 n = estimateNormal(pSurf); | |
| float edge = pow(clamp(1.0 - abs(dot(n, rd)), 0.0, 1.0), 2.0); | |
| col = mix(col, vec3(0.0), edge); | |
| } | |
| col += vec3(0.05); | |
| col = col / (1.0 + col); | |
| col = pow(col, vec3(0.4545)); | |
| gl_FragColor = vec4(col, 1.0); | |
| } | |
| `; | |
| } | |
| initGL() { | |
| const canvas = this.shadowRoot.getElementById("canvas"); | |
| const gl = canvas.getContext("webgl"); | |
| if (!gl) { | |
| log("WebGL not available", "error"); | |
| return; | |
| } | |
| this.gl = gl; | |
| this.buildProgramFromSceneGraph(); | |
| } | |
| buildProgramFromSceneGraph() { | |
| if (!this.gl) return; | |
| const gl = this.gl; | |
| const extra = generateGlslFromSceneGraph(appContext.sceneGraph || []); | |
| const vsSource = ` | |
| attribute vec4 a_position; | |
| void main() { gl_Position = a_position; } | |
| `; | |
| const fsSource = this.getFragmentSource(extra, this.compositeExpr); | |
| const vs = gl.createShader(gl.VERTEX_SHADER); | |
| gl.shaderSource(vs, vsSource); | |
| gl.compileShader(vs); | |
| if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) { | |
| console.error("VS error:", gl.getShaderInfoLog(vs)); | |
| } | |
| const fs = gl.createShader(gl.FRAGMENT_SHADER); | |
| gl.shaderSource(fs, fsSource); | |
| gl.compileShader(fs); | |
| if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) { | |
| const logMsg = gl.getShaderInfoLog(fs); | |
| console.error("FS error:", logMsg); | |
| log("Fragment shader compile failed; DSL/composite may not work.", "error"); | |
| } | |
| const prog = gl.createProgram(); | |
| gl.attachShader(prog, vs); | |
| gl.attachShader(prog, fs); | |
| gl.linkProgram(prog); | |
| if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { | |
| console.error("Program link error:", gl.getProgramInfoLog(prog)); | |
| log("Program link error; renderer may be broken.", "error"); | |
| } | |
| gl.useProgram(prog); | |
| this.program = prog; | |
| 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 posLoc = gl.getAttribLocation(prog, "a_position"); | |
| gl.enableVertexAttribArray(posLoc); | |
| gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); | |
| this.uniforms = { | |
| time: gl.getUniformLocation(prog, "u_time"), | |
| maxSteps: gl.getUniformLocation(prog, "u_maxSteps"), | |
| stepSize: gl.getUniformLocation(prog, "u_stepSize"), | |
| maxDist: gl.getUniformLocation(prog, "u_maxDist"), | |
| sceneMode: gl.getUniformLocation(prog, "u_sceneMode"), | |
| shape: gl.getUniformLocation(prog, "u_shape"), | |
| ballRadius: gl.getUniformLocation(prog, "u_ballRadius"), | |
| cameraOrbit: gl.getUniformLocation(prog, "u_cameraOrbit"), | |
| enableEdges: gl.getUniformLocation(prog, "u_enableEdges"), | |
| arg1: gl.getUniformLocation(prog, "u_arg1"), | |
| arg2: gl.getUniformLocation(prog, "u_arg2"), | |
| arg3: gl.getUniformLocation(prog, "u_arg3"), | |
| arg4: gl.getUniformLocation(prog, "u_arg4") | |
| }; | |
| this.updateUniforms(); | |
| log("WebGL program rebuilt from sceneGraph + composite", "info"); | |
| } | |
| rebuildProgramFromSceneGraph() { | |
| this.buildProgramFromSceneGraph(); | |
| } | |
| updateUniforms() { | |
| if (!this.gl || !this.uniforms) return; | |
| const gl = this.gl; | |
| gl.uniform1f(this.uniforms.maxSteps, this.params.maxSteps); | |
| gl.uniform1f(this.uniforms.stepSize, this.params.stepSize); | |
| gl.uniform1f(this.uniforms.maxDist, this.params.maxDist); | |
| gl.uniform1i(this.uniforms.sceneMode, this.params.sceneMode); | |
| gl.uniform1i(this.uniforms.shape, this.params.shape); | |
| gl.uniform1f(this.uniforms.ballRadius, this.params.ballRadius); | |
| gl.uniform1i(this.uniforms.cameraOrbit, this.params.cameraOrbit); | |
| gl.uniform1i(this.uniforms.enableEdges, this.params.enableEdges); | |
| gl.uniform1f(this.uniforms.arg1, this.params.arg1); | |
| gl.uniform1f(this.uniforms.arg2, this.params.arg2); | |
| gl.uniform1f(this.uniforms.arg3, this.params.arg3); | |
| gl.uniform1f(this.uniforms.arg4, this.params.arg4); | |
| } | |
| animate(timeMs) { | |
| if (!this._running || !this.gl || !this.uniforms) return; | |
| const time = timeMs / 1000; | |
| this.render(time); | |
| this._rafId = requestAnimationFrame((t) => this.animate(t)); | |
| } | |
| render(time) { | |
| if (!this.gl || !this.uniforms) return; | |
| 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); | |
| } | |
| setupControls() { | |
| const root = this.shadowRoot; | |
| const steps = root.getElementById("steps"); | |
| const stepsVal = root.getElementById("stepsVal"); | |
| const stepSize = root.getElementById("stepSize"); | |
| const stepSizeVal = root.getElementById("stepSizeVal"); | |
| const maxDist = root.getElementById("maxDist"); | |
| const maxDistVal = root.getElementById("maxDistVal"); | |
| const preset = root.getElementById("preset"); | |
| const orbitCamera = root.getElementById("orbitCamera"); | |
| const edgeRender = root.getElementById("edgeRender"); | |
| const pauseRender = root.getElementById("pauseRender"); | |
| const arg1 = root.getElementById("arg1"); | |
| const arg1Val = root.getElementById("arg1Val"); | |
| const arg2 = root.getElementById("arg2"); | |
| const arg2Val = root.getElementById("arg2Val"); | |
| const arg3 = root.getElementById("arg3"); | |
| const arg3Val = root.getElementById("arg3Val"); | |
| const arg4 = root.getElementById("arg4"); | |
| const arg4Val = root.getElementById("arg4Val"); | |
| const setText = (el, v, decimals) => { | |
| el.textContent = v.toFixed(decimals); | |
| }; | |
| steps.addEventListener("input", e => { | |
| const v = parseFloat(e.target.value); | |
| this.params.maxSteps = v; | |
| stepsVal.textContent = v.toFixed(0); | |
| this.updateUniforms(); | |
| }); | |
| stepSize.addEventListener("input", e => { | |
| const v = parseFloat(e.target.value); | |
| this.params.stepSize = v; | |
| setText(stepSizeVal, v, 2); | |
| this.updateUniforms(); | |
| }); | |
| maxDist.addEventListener("input", e => { | |
| const v = parseFloat(e.target.value); | |
| this.params.maxDist = v; | |
| maxDistVal.textContent = v.toFixed(0); | |
| this.updateUniforms(); | |
| }); | |
| orbitCamera.addEventListener("change", e => { | |
| this.params.cameraOrbit = e.target.checked ? 1 : 0; | |
| this.updateUniforms(); | |
| }); | |
| edgeRender.addEventListener("change", e => { | |
| this.params.enableEdges = e.target.checked ? 1 : 0; | |
| this.updateUniforms(); | |
| }); | |
| pauseRender.addEventListener("change", e => { | |
| this._userPaused = e.target.checked; | |
| if (this._userPaused) { | |
| this.stopAnimation(); | |
| } else { | |
| if (!document.hidden) this.startAnimation(); | |
| } | |
| }); | |
| preset.addEventListener("change", e => { | |
| const v = e.target.value; | |
| if (v === "jellies") { | |
| this.params.sceneMode = 0; | |
| } else if (v === "vol-cubes" || v === "vol-spheres" || v === "vol-capsules") { | |
| this.params.sceneMode = 1; | |
| this.params.shape = (v === "vol-cubes") ? 0 : | |
| (v === "vol-spheres") ? 1 : 2; | |
| } else if (v === "ball") { | |
| this.params.sceneMode = 2; | |
| this.params.maxSteps = 128.0; | |
| this.params.stepSize = 0.05; | |
| this.params.maxDist = 15.0; | |
| steps.value = this.params.maxSteps; | |
| stepsVal.textContent = this.params.maxSteps.toFixed(0); | |
| stepSize.value = this.params.stepSize; | |
| setText(stepSizeVal, this.params.stepSize, 2); | |
| maxDist.value = this.params.maxDist; | |
| maxDistVal.textContent = this.params.maxDist.toFixed(0); | |
| } else { | |
| this.params.sceneMode = 3; | |
| } | |
| this.updateUniforms(); | |
| }); | |
| const argHandler = (valEl, key) => e => { | |
| const v = parseFloat(e.target.value); | |
| this.params[key] = v; | |
| setText(valEl, v, 2); | |
| this.updateUniforms(); | |
| }; | |
| arg1.addEventListener("input", argHandler(arg1Val, "arg1")); | |
| arg2.addEventListener("input", argHandler(arg2Val, "arg2")); | |
| arg3.addEventListener("input", argHandler(arg3Val, "arg3")); | |
| arg4.addEventListener("input", argHandler(arg4Val, "arg4")); | |
| stepsVal.textContent = this.params.maxSteps.toFixed(0); | |
| setText(stepSizeVal, this.params.stepSize, 2); | |
| maxDistVal.textContent = this.params.maxDist.toFixed(0); | |
| setText(arg1Val, this.params.arg1, 2); | |
| setText(arg2Val, this.params.arg2, 2); | |
| setText(arg3Val, this.params.arg3, 2); | |
| setText(arg4Val, this.params.arg4, 2); | |
| } | |
| } | |
| customElements.define("sdf-renderer-app", SdfRendererApp); | |
| // ============================================================ | |
| // Main app container 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: 44px; | |
| display: flex; | |
| background: #020617; | |
| z-index: 10; | |
| border-bottom: 1px solid #111827; | |
| } | |
| .tab-btn { | |
| flex: 1; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 13px; | |
| user-select: none; | |
| cursor: pointer; | |
| color: #9ca3af; | |
| background: #020617; | |
| border-bottom: 2px solid transparent; | |
| font-weight: 500; | |
| } | |
| .tab-btn.active { | |
| border-bottom-color: #22d3ee; | |
| background: #1f2937; | |
| color: #22d3ee; | |
| } | |
| .content { | |
| height: calc(100% - 44px); | |
| margin-top: 44px; | |
| } | |
| .tab { | |
| display: none; | |
| height: 100%; | |
| } | |
| .tab.active { | |
| display: block; | |
| } | |
| </style> | |
| <div class="tab-bar"> | |
| <div class="tab-btn active" data-tab="render">Render</div> | |
| <div class="tab-btn" data-tab="glsl">DSL / GLSL</div> | |
| <div class="tab-btn" data-tab="composite">Composite</div> | |
| <div class="tab-btn" data-tab="debug">Debug</div> | |
| <div class="tab-btn" data-tab="editor">Editor (exp)</div> | |
| </div> | |
| <div class="content"> | |
| <div id="tab-render" class="tab active"> | |
| <sdf-renderer-app style="width:100%; height:100%;"></sdf-renderer-app> | |
| </div> | |
| <div id="tab-glsl" class="tab"> | |
| <glsl-preview-app style="width:100%; height:100%;"></glsl-preview-app> | |
| </div> | |
| <div id="tab-composite" class="tab"> | |
| <composite-editor-app style="width:100%; height:100%;"></composite-editor-app> | |
| </div> | |
| <div id="tab-debug" class="tab"> | |
| <debug-console-app style="width:100%; height:100%;"></debug-console-app> | |
| </div> | |
| <div id="tab-editor" class="tab"> | |
| <node-editor-app style="width:100%; height:100%;"></node-editor-app> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| connectedCallback() { | |
| const bar = this.shadowRoot.querySelector(".tab-bar"); | |
| const buttons = bar.querySelectorAll(".tab-btn"); | |
| buttons.forEach(btn => { | |
| btn.addEventListener("click", () => this.switchTab(btn.dataset.tab)); | |
| }); | |
| } | |
| switchTab(name) { | |
| const buttons = this.shadowRoot.querySelectorAll(".tab-btn"); | |
| const tabs = this.shadowRoot.querySelectorAll(".tab"); | |
| buttons.forEach(b => b.classList.toggle("active", b.dataset.tab === name)); | |
| tabs.forEach(t => t.classList.toggle("active", t.id === "tab-" + name)); | |
| log("Switched to " + name + " tab", "info"); | |
| if (name === "glsl" && appContext.instances.glslPreview) { | |
| appContext.instances.glslPreview.refreshGlsl(); | |
| } | |
| } | |
| } | |
| customElements.define("raymarcher-app", RaymarcherApp); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment