'use strict';
// ── Shared vertex shader ──────────────────────────────────────────────────
const VERT_SRC = `#version 300 es
in vec2 aPosition;
out vec2 vUV;
void main() {
vUV = aPosition * 0.5 + 0.5;
gl_Position = vec4(aPosition, 0.0, 1.0);
}`;
// ── Path-tracing fragment shader ──────────────────────────────────────────
const TRACE_SRC = `#version 300 es
precision highp float;
precision highp int;
in vec2 vUV;
out vec4 fragColor;
uniform vec2 uResolution; // render FBO resolution
uniform sampler2D uPreviousFrame;
uniform int uFrameIndex; // 1-based; used for running-mean weight
#define SAMPLES 2
#define MAXDEPTH 5
#define PI 3.14159265359
#define DIFF 0
#define SPEC 1
#define REFR 2
#define NUM_SPHERES 9
// PCG hash — all integer arithmetic, no float precision loss at large frame counts
uint rng_state;
float rand() {
rng_state = rng_state * 747796405u + 2891336453u;
uint word = ((rng_state >> ((rng_state >> 28u) + 4u)) ^ rng_state) * 277803737u;
word = (word >> 22u) ^ word;
return float(word) / 4294967296.0;
}
struct Ray { vec3 o, d; };
struct Sphere { float r; vec3 p, e, c; int refl; };
Sphere spheres[NUM_SPHERES];
void initSpheres() {
spheres[0] = Sphere(1e5, vec3(-1e5+1., 40.8, 81.6), vec3(0.), vec3(.75,.25,.25), DIFF);
spheres[1] = Sphere(1e5, vec3( 1e5+99., 40.8, 81.6), vec3(0.), vec3(.25,.25,.75), DIFF);
spheres[2] = Sphere(1e5, vec3(50., 40.8, -1e5), vec3(0.), vec3(.75), DIFF);
spheres[3] = Sphere(1e5, vec3(50., 40.8, 1e5+170.),vec3(0.), vec3(0.), DIFF);
spheres[4] = Sphere(1e5, vec3(50., -1e5, 81.6), vec3(0.), vec3(.75), DIFF);
spheres[5] = Sphere(1e5, vec3(50., 1e5+81.6, 81.6), vec3(0.), vec3(.75), DIFF);
spheres[6] = Sphere(16.5, vec3(27., 16.5, 47.), vec3(0.), vec3(1.), SPEC);
spheres[7] = Sphere(16.5, vec3(73., 16.5, 78.), vec3(0.), vec3(.7, 1., .9), REFR);
spheres[8] = Sphere(600., vec3(50., 681.33, 81.6), vec3(12.),vec3(0.), DIFF);
}
float hitSphere(Sphere s, Ray r) {
vec3 op = s.p - r.o;
float b = dot(op, r.d);
float det = b*b - dot(op, op) + s.r*s.r;
if (det < 0.) return 0.;
det = sqrt(det);
float t = b - det;
if (t > 1e-3) return t;
t = b + det;
return t > 1e-3 ? t : 0.;
}
int hitScene(Ray r, out float t, out Sphere s, int avoid) {
int id = -1;
t = 1e5;
s = spheres[0];
for (int i = 0; i < NUM_SPHERES; ++i) {
float d = hitSphere(spheres[i], r);
if (i != avoid && d > 0. && d < t) { t = d; id = i; s = spheres[i]; }
}
return id;
}
// Cosine-weighted hemisphere sample aligned to direction d
vec3 jitter(vec3 d, float phi, float sina, float cosa) {
vec3 w = normalize(d), u = normalize(cross(w.yzx, w)), v = cross(w, u);
return (u*cos(phi) + v*sin(phi)) * sina + w * cosa;
}
vec3 radiance(Ray r) {
vec3 acc = vec3(0.), mask = vec3(1.);
int id = -1;
for (int depth = 0; depth < MAXDEPTH; ++depth) {
float t; Sphere obj;
if ((id = hitScene(r, t, obj, id)) < 0) break;
vec3 x = r.o + r.d * t;
vec3 n = normalize(x - obj.p);
vec3 nl = n * sign(-dot(n, r.d));
acc += mask * obj.e;
if (obj.refl == DIFF) {
float r2 = rand();
r = Ray(x, jitter(nl, 2.*PI*rand(), sqrt(r2), sqrt(1.-r2)));
mask *= obj.c;
} else if (obj.refl == SPEC) {
r = Ray(x, reflect(r.d, n));
mask *= obj.c;
} else {
float a = dot(n, r.d), ddn = abs(a);
float nc = 1., nt = 1.5, nnt = mix(nc/nt, nt/nc, float(a > 0.));
float cos2t = 1. - nnt*nnt*(1.-ddn*ddn);
r = Ray(x, reflect(r.d, n));
if (cos2t > 0.) {
vec3 tdir = normalize(r.d*nnt + sign(a)*n*(ddn*nnt+sqrt(cos2t)));
float R0 = (nt-nc)*(nt-nc)/((nt+nc)*(nt+nc));
float c = 1. - mix(ddn, abs(dot(tdir, n)), float(a > 0.));
float Re = R0+(1.-R0)*c*c*c*c*c, P = .25+.5*Re, RP = Re/P, TP = (1.-Re)/(1.-P);
if (rand() < P) { mask *= RP; }
else { mask *= obj.c * TP; r = Ray(x, tdir); }
}
}
}
return acc;
}
void main() {
initSpheres();
// Integer-based seed — decorrelated per pixel and per frame, no float precision decay
rng_state = uint(gl_FragCoord.x) * 1973u
+ uint(gl_FragCoord.y) * 9277u
+ uint(uFrameIndex) * 26699u;
rng_state ^= rng_state >> 16u;
rand(); // advance once to escape the low-entropy initial state
vec2 uv = (vUV - 0.5) * 2.0;
vec3 camPos = vec3(50., 40.8, 169.);
vec3 cz = normalize(vec3(50., 40., 81.6) - camPos);
vec3 cx = vec3(1., 0., 0.);
vec3 cy = normalize(cross(cx, cz)); cx = cross(cz, cy);
vec3 color = vec3(0.);
for (int i = 0; i < SAMPLES; ++i) {
color += radiance(Ray(camPos,
normalize(0.53135 * (uResolution.x/uResolution.y * uv.x * cx
+ uv.y * cy) + cz)));
}
color /= float(SAMPLES);
// ── Correct progressive accumulation: Welford's incremental mean ─────
// Numerically stable for arbitrarily large frame counts.
// Avoids multiplying the accumulated value by n (which overflows RGBA16F
// once n grows beyond a few thousand).
// n=1 → fragColor = acc + (color - acc) = color (first frame, acc is 0)
float n = float(uFrameIndex);
vec3 acc = texture(uPreviousFrame, vUV).rgb;
fragColor = vec4(acc + (color - acc) / n, 1.0);
}`;
// ── Tone-mapping / gamma post-process ────────────────────────────────────
const POST_SRC = `#version 300 es
precision highp float;
in vec2 vUV;
out vec4 fragColor;
uniform sampler2D uTexture;
vec3 ACESFilm(vec3 x) {
float a=2.51, b=0.03, c=2.43, d=0.59, e=0.14;
return clamp((x*(a*x+b))/(x*(c*x+d)+e), 0.0, 1.0);
}
void main() {
vec3 col = texture(uTexture, vUV).rgb;
col = ACESFilm(col);
col = pow(col, vec3(1.0/2.2));
fragColor = vec4(col, 1.0);
}`;
// ── WebGL 2 renderer ──────────────────────────────────────────────────────
function initRenderer() {
// Canvas setup
const canvas = document.createElement('canvas');
Object.assign(canvas.style, {
display: 'block', position: 'fixed',
top: '0', left: '0', width: '100%', height: '100%'
});
document.body.style.cssText = 'margin:0;overflow:hidden;background:#000';
document.body.appendChild(canvas);
const displayW = window.innerWidth;
const displayH = window.innerHeight;
const renderW = Math.floor(displayW / 1.5);
const renderH = Math.floor(displayH / 1.5);
canvas.width = displayW;
canvas.height = displayH;
const gl = canvas.getContext('webgl2', { alpha: false, antialias: false });
if (!gl) { alert('WebGL 2 is not supported in this browser.'); return; }
if (!gl.getExtension('EXT_color_buffer_float')) {
alert('EXT_color_buffer_float not available — float framebuffers unsupported.'); return;
}
// ── Shader helpers ───────────────────────────────────────────────────
function compileShader(type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS))
throw new Error('Shader compile error:\n' + gl.getShaderInfoLog(sh));
return sh;
}
function createProgram(vSrc, fSrc) {
const prog = gl.createProgram();
gl.attachShader(prog, compileShader(gl.VERTEX_SHADER, vSrc));
gl.attachShader(prog, compileShader(gl.FRAGMENT_SHADER, fSrc));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS))
throw new Error('Program link error:\n' + gl.getProgramInfoLog(prog));
return prog;
}
// ── Geometry: full-screen triangle strip ─────────────────────────────
const quadBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
function createVAO(prog) {
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
const loc = gl.getAttribLocation(prog, 'aPosition');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
return vao;
}
// ── RGBA16F ping-pong FBOs for HDR accumulation ───────────────────────
function createFloatFBO(w, h) {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0,
gl.RGBA, gl.HALF_FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D, tex, 0);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE)
throw new Error('Framebuffer incomplete');
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return { fbo, tex };
}
// ── Build GPU programs ────────────────────────────────────────────────
const traceProg = createProgram(VERT_SRC, TRACE_SRC);
const postProg = createProgram(VERT_SRC, POST_SRC);
const traceVAO = createVAO(traceProg);
const postVAO = createVAO(postProg);
// Cache uniform locations — never call getUniformLocation per-frame
const uTrace = {
uPreviousFrame: gl.getUniformLocation(traceProg, 'uPreviousFrame'),
uResolution: gl.getUniformLocation(traceProg, 'uResolution'),
uFrameIndex: gl.getUniformLocation(traceProg, 'uFrameIndex'),
};
const uPost = {
uTexture: gl.getUniformLocation(postProg, 'uTexture'),
};
// Two RGBA16F FBOs; initialise both to black
const fbos = [createFloatFBO(renderW, renderH), createFloatFBO(renderW, renderH)];
for (const { fbo } of fbos) {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// ── Render loop ───────────────────────────────────────────────────────
let frameIndex = 1;
function frame(timestamp) {
// Ping-pong: frameIndex=1 → write fbos[1], read fbos[0] (black init)
// frameIndex=2 → write fbos[0], read fbos[1], etc.
const readIdx = (frameIndex - 1) & 1;
const writeIdx = frameIndex & 1;
const read = fbos[readIdx];
const write = fbos[writeIdx];
// Pass 1 — path trace into write FBO
gl.bindFramebuffer(gl.FRAMEBUFFER, write.fbo);
gl.viewport(0, 0, renderW, renderH);
gl.useProgram(traceProg);
gl.bindVertexArray(traceVAO);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, read.tex);
gl.uniform1i(uTrace.uPreviousFrame, 0);
gl.uniform2f(uTrace.uResolution, renderW, renderH);
gl.uniform1i(uTrace.uFrameIndex, frameIndex);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
// Pass 2 — tone-map write FBO to screen
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, displayW, displayH);
gl.useProgram(postProg);
gl.bindVertexArray(postVAO);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, write.tex);
gl.uniform1i(uPost.uTexture, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
frameIndex++;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initRenderer);
} else {
initRenderer();
}