<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Realtime Ray-Traced Metallic Sphere over Street (Single HTML)</title>
<style>
html,body { height:100%; margin:0; background:#111; color:#ddd; font-family:Inter,system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;}
#app { display:flex; height:100vh; }
canvas { display:block; width:100%; height:100%; background:#000; flex:1 1 auto;}
#ui {
width:320px; background:rgba(12,12,12,0.9); padding:12px; box-sizing:border-box;
border-left:1px solid rgba(255,255,255,0.03); overflow:auto;
}
h1 { font-size:16px; margin:4px 0 10px; }
label { display:block; font-size:12px; margin-top:8px; color:#bbb; }
input[type=range] { width:100%; }
.row { display:flex; gap:8px; align-items:center; }
.small { font-size:12px; color:#9aa; }
.footer { margin-top:12px; font-size:12px; color:#777; }
button { margin-top:8px; padding:8px 10px; background:#1e1e1e; color:#ddd; border:1px solid #333; cursor:pointer; }
a { color:#9cf; }
</style>
</head>
<body>
<div id="app">
<canvas id="glcanvas"></canvas>
<div id="ui">
<h1>Realtime Ray-Traced Metallic Sphere — Street HDR</h1>
<div class="small">Environment map: <a href="https://polyhaven.com/hdris/urban" target="_blank" rel="noreferrer">Poly Haven — Urban Street (CC0)</a>.</div>
<label>Metallic (0..1) <span id="metalVal" class="small"></span></label>
<input id="metal" type="range" min="0" max="1" step="0.01" value="1">
<label>Roughness (0..1) <span id="roughVal" class="small"></span></label>
<input id="rough" type="range" min="0" max="1" step="0.01" value="0.05">
<label>Reflectivity (0..1) <span id="reflVal" class="small"></span></label>
<input id="refl" type="range" min="0" max="1" step="0.01" value="1">
<label>Base color (hue) <span id="hueVal" class="small"></span></label>
<input id="hue" type="range" min="0" max="360" step="1" value="210">
<label>Sphere height (m) <span id="hgtVal" class="small"></span></label>
<input id="height" type="range" min="0.2" max="4" step="0.01" value="1.0">
<label>Sphere radius (m) <span id="radVal" class="small"></span></label>
<input id="radius" type="range" min="0.1" max="3" step="0.01" value="0.6">
<label>Gloss samples (1..64) <span id="sampVal" class="small"></span></label>
<input id="samples" type="range" min="1" max="64" step="1" value="8">
<label>Exposure <span id="expVal" class="small"></span></label>
<input id="exposure" type="range" min="0.2" max="4" step="0.01" value="1.0">
<div style="margin-top:10px;">
<button id="resetBtn">Reset camera</button>
<button id="lowResBtn">Toggle low-res (perf)</button>
</div>
<div class="footer">
Controls: drag to orbit, scroll to zoom. Shader: ray-sphere + env sampling; glossy = jittered reflection sampling. <br><br>
Note: For fully physical multi-bounce path tracing you'd need many more samples — this is a realtime single-bounce approximation.
</div>
</div>
</div>
<script>
(() => {
// ------------------------------
// Configuration & helpers
// ------------------------------
const canvas = document.getElementById('glcanvas');
const gl = canvas.getContext('webgl2', { antialias: false });
if (!gl) { alert('WebGL2 is required. Use a modern browser.'); return; }
// UI elements
const ui = {
metal: document.getElementById('metal'),
rough: document.getElementById('rough'),
refl: document.getElementById('refl'),
hue: document.getElementById('hue'),
height: document.getElementById('height'),
radius: document.getElementById('radius'),
samples: document.getElementById('samples'),
exposure: document.getElementById('exposure'),
resetBtn: document.getElementById('resetBtn'),
lowResBtn: document.getElementById('lowResBtn'),
metalVal: document.getElementById('metalVal'),
roughVal: document.getElementById('roughVal'),
reflVal: document.getElementById('reflVal'),
hueVal: document.getElementById('hueVal'),
hgtVal: document.getElementById('hgtVal'),
radVal: document.getElementById('radVal'),
sampVal: document.getElementById('sampVal'),
expVal: document.getElementById('expVal'),
};
function updateLabels() {
ui.metalVal.textContent = ui.metal.value;
ui.roughVal.textContent = ui.rough.value;
ui.reflVal.textContent = ui.refl.value;
ui.hueVal.textContent = ui.hue.value;
ui.hgtVal.textContent = ui.height.value;
ui.radVal.textContent = ui.radius.value;
ui.sampVal.textContent = ui.samples.value;
ui.expVal.textContent = ui.exposure.value;
}
updateLabels();
// Shader helpers
function compileShader(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(s));
throw new Error('Shader compile failed');
}
return s;
}
function linkProgram(vs, fs) {
const p = gl.createProgram();
gl.attachShader(p, vs);
gl.attachShader(p, fs);
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(p));
throw new Error('Program link failed');
}
return p;
}
// ------------------------------
// Fullscreen quad
// ------------------------------
const quadVS = `#version 300 es
precision highp float;
in vec2 aPos;
out vec2 vUV;
void main(){
vUV = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
const quadFS = `#version 300 es
precision highp float;
precision highp sampler2D;
in vec2 vUV;
out vec4 outColor;
uniform vec2 uResolution;
uniform float uTime;
uniform sampler2D uEnv; // equirectangular tonemapped JPG/PNG (sRGB)
uniform vec3 uCamPos;
uniform mat3 uCamMat; // camera basis
uniform float uFov;
// sphere props
uniform vec3 uSpherePos;
uniform float uSphereRadius;
uniform vec3 uBaseColor;
uniform float uMetallic;
uniform float uRoughness;
uniform float uReflectivity;
uniform float uExposure;
uniform int uSamples;
uniform vec2 uRandomSeed;
// small random generator per-pixel
highp float rand(in vec2