Skip to content

Instantly share code, notes, and snippets.

@danbri
Created November 23, 2025 20:58
Show Gist options
  • Select an option

  • Save danbri/11d169b3b09f37a3fbb8f2390b8b5b02 to your computer and use it in GitHub Desktop.

Select an option

Save danbri/11d169b3b09f37a3fbb8f2390b8b5b02 to your computer and use it in GitHub Desktop.
sdf scene graph v2
<!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