$sloprun.dev

Tiny Raytracer

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 6 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

Every pixel here is one ray of simulated light, fired out of the camera and followed backwards until it hits a sphere, bounces up to three more times, and finds either the lamp or the sky — about a page of arithmetic, no 3D library, no shaders, no WebGL. One ray per pixel is a noisy guess, so the renderer keeps going: each pass jitters the ray inside its pixel and aims the shadow ray at a different spot on the lamp, and the running average is what turns speckle into soft shadow edges, which makes the sample counter a literal measure of how believable the picture has become. It traces in chunks between animation frames, so nothing ever freezes — drag the scene and it drops to a coarse buffer, then sharpens again the moment you let go. Push "Mirror finish" to 100% to watch the floor turn to glass, then down to 0%: the polished ball goes chalky silver rather than black, which is what a rough metal actually looks like.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/raytracer" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Tiny Raytracer — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/raytracer

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 959 lines 37.2 KB index.html
demos/raytracer/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Tiny Raytracer — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */

/* demo-local additions (scrims need alpha; tokens are opaque hex) */
:root { --scrim: rgba(246,246,243,.9); --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.4); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.4); }

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }

.wrap {
  max-width: 1140px; margin: 0 auto;
  padding: 22px 16px 26px;
  display: flex; flex-direction: column; gap: 16px;
}

h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 68ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }

.stage { display: grid; grid-template-columns: minmax(0,1fr) 300px; gap: 16px; align-items: start; }
@media (max-width: 920px) { .stage { grid-template-columns: minmax(0,1fr); } }

.frame {
  position: relative; overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); aspect-ratio: 16 / 10; box-shadow: var(--shadow);
}
@media (max-width: 920px) { .frame { aspect-ratio: 3 / 2; } }
@media (max-width: 520px) { .frame { aspect-ratio: 4 / 3; } }
canvas {
  display: block; width: 100%; height: 100%;
  touch-action: pan-y; cursor: grab; background: var(--surface);
}
canvas.grabbing { cursor: grabbing; }
canvas:focus { outline: none; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }

.hint {
  position: absolute; left: 10px; bottom: 10px; pointer-events: none;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
  color: var(--muted); background: var(--scrim);
  border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
  transition: opacity .5s ease; max-width: calc(100% - 20px);
}
.hint.gone { opacity: 0; }

.tag {
  position: absolute; right: 10px; top: 10px; pointer-events: none;
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
  color: var(--muted); background: var(--scrim);
  border: 1px solid var(--line); border-radius: 999px; padding: 4px 10px;
  font-variant-numeric: tabular-nums;
}
.tag .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; }
.tag.done { color: var(--run); background: var(--run-soft); border-color: transparent; }
.tag.done .dot { background: var(--run); }

.panel { display: flex; flex-direction: column; gap: 12px; }
.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow);
}
.card h2 {
  margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600;
}

.stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v {
  font-family: var(--font-mono); font-size: 19px; line-height: 1.05;
  font-variant-numeric: tabular-nums; letter-spacing: -.02em;
}
.stat .v .u { font-size: 11px; color: var(--muted); margin-left: 2px; letter-spacing: 0; }
.stat .k {
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted);
}

.prog { height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; margin-top: 12px; }
.prog i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .2s linear; }
.prog.done i { background: var(--run); }

.meta {
  margin-top: 9px; font-family: var(--font-mono); font-size: 11px;
  color: var(--muted); letter-spacing: .01em; font-variant-numeric: tabular-nums;
}

.ctl { display: block; margin-bottom: 12px; }
.ctl:last-child { margin-bottom: 0; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.ctl-top label { font-size: 13px; }
.ctl-top .val {
  font-family: var(--font-mono); font-size: 12px; color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.desc { display: block; font-size: 11.5px; color: var(--muted); line-height: 1.4; margin-top: 4px; }

input[type=range] {
  -webkit-appearance: none; appearance: none; display: block;
  width: 100%; height: 20px; margin: 0; background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; width: 15px; height: 15px; margin-top: -5.5px;
  border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
  box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb {
  width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
  border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }

button {
  font: inherit; font-size: 13px; font-family: var(--font-sans);
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 11px; cursor: pointer; line-height: 1.2;
  transition: border-color .12s ease, color .12s ease, background .12s ease;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }

.row { display: flex; flex-wrap: wrap; gap: 7px; }
.row > button { flex: 1 1 auto; }
.seg { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 7px; }
.seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }

footer {
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  letter-spacing: .05em; padding-top: 2px;
}

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>Tiny Raytracer</h1>
    <p class="lede">
      <b>Every pixel you see is one ray of simulated light</b>, followed backwards from the camera
      until it hits something, bounces, and finds the lamp — or the sky. No 3D library and no
      shaders, just arithmetic on a canvas, refining the picture one sample at a time.
      <b>Drag the scene to orbit</b> and watch it re-focus.
    </p>
  </header>

  <main class="stage">

    <div class="frame">
      <canvas id="c" tabindex="0" role="img"
        aria-label="A ray-traced scene: five spheres of different colours and finishes, including a mirrored one, resting on a checkered floor under a single warm lamp with soft shadows. Drag, or use the arrow keys, to orbit the camera; plus and minus move it closer or further away.">
        A ray-traced scene of five spheres on a checkered floor. Your browser does not support canvas.
      </canvas>
      <div class="tag" id="tag"><span class="dot"></span><span id="tagText">tracing</span></div>
      <div class="hint" id="hint">drag to orbit · arrow keys work too</div>
    </div>

    <div class="panel">

      <section class="card" aria-label="Render readout">
        <h2>Readout</h2>
        <div class="stats">
          <div class="stat"><span class="v" id="sSpp">0</span><span class="k">samples / px</span></div>
          <div class="stat"><span class="v" id="sRays">—</span><span class="k">rays / sec</span></div>
          <div class="stat"><span class="v" id="sTime">0.0<span class="u">s</span></span><span class="k">elapsed</span></div>
        </div>
        <div class="prog" id="prog" aria-hidden="true"><i id="progFill"></i></div>
        <p class="meta" id="meta">— × — px · 3 bounces · 1 lamp</p>
        <span class="desc" style="margin-top:8px">The lamp is a small glowing sphere, not a pinprick. Every pass aims its shadow ray at a different spot on it, so shadow edges soften as the samples pile up.</span>
      </section>

      <section class="card" aria-label="Surface finish">
        <h2>Surface</h2>
        <div class="ctl">
          <div class="ctl-top">
            <label for="refl">Mirror finish</label><span class="val" id="reflV" aria-hidden="true">50%</span>
          </div>
          <input type="range" id="refl" min="0" max="100" step="1" value="50"
                 aria-describedby="reflDesc">
          <span class="desc" id="reflDesc">0% is chalky paint, 100% is a polished mirror. Shiny surfaces reflect more at glancing angles — that is why the floor shines near the horizon.</span>
        </div>
      </section>

      <section class="card" aria-label="Quality and controls">
        <h2>Quality</h2>
        <div class="seg" id="seg" role="group" aria-label="Render resolution">
          <button type="button" data-r="0" aria-pressed="false">Draft</button>
          <button type="button" data-r="1" aria-pressed="true">Fine</button>
          <button type="button" data-r="2" aria-pressed="false">Sharp</button>
        </div>
        <span class="desc">Fewer pixels means more samples per second, so noise clears sooner.</span>
        <div class="row" style="margin-top:11px">
          <button type="button" class="primary" id="rerender">Re-render</button>
          <button type="button" id="reset">Reset view</button>
        </div>
      </section>

    </div>
  </main>

  <footer>demo · sloprun.dev</footer>
</div>

<div class="sr-only" role="status" aria-live="polite" id="live"></div>

<script>
(function () {
  "use strict";

  /* ── dom ────────────────────────────────────────────────────────────────── */
  var root    = document.documentElement;
  var canvas  = document.getElementById("c");
  var ctx     = canvas && canvas.getContext ? canvas.getContext("2d", { alpha: false }) : null;
  if (!ctx) return;   /* no canvas: the element's fallback text stands in, no console noise */
  var tag     = document.getElementById("tag");
  var tagText = document.getElementById("tagText");
  var hint    = document.getElementById("hint");
  var sSpp    = document.getElementById("sSpp");
  var sRays   = document.getElementById("sRays");
  var sTime   = document.getElementById("sTime");
  var prog    = document.getElementById("prog");
  var progFill= document.getElementById("progFill");
  var meta    = document.getElementById("meta");
  var reflEl  = document.getElementById("refl");
  var reflV   = document.getElementById("reflV");
  var seg     = document.getElementById("seg");
  var live    = document.getElementById("live");

  var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
  var mqRM   = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
  var reduceMotion = mqRM ? mqRM.matches : false;

  var now = (window.performance && performance.now)
    ? function () { return performance.now(); }
    : function () { return Date.now(); };

  /* ── scene ──────────────────────────────────────────────────────────────── */
  /* sphere record: cx cy cz r r2 albedoR albedoG albedoB shininess metal */
  var NS = 5, ST = 10;
  var SPH = new Float64Array(NS * ST);
  (function () {
    var src = [
      [ 0.00, 1.00,  0.00, 1.00, 0.86, 0.42, 0.10, 0.45, 0],
      [-2.15, 0.72, -0.55, 0.72, 0.09, 0.40, 0.42, 0.30, 0],
      [ 1.95, 0.90, -1.00, 0.90, 0.94, 0.94, 0.96, 1.00, 1],
      [-0.95, 0.42,  1.75, 0.42, 0.72, 0.18, 0.16, 0.25, 0],
      [ 1.15, 0.50,  1.60, 0.50, 0.28, 0.48, 0.86, 0.70, 0]
    ];
    for (var i = 0; i < NS; i++) {
      var o = i * ST, a = src[i];
      SPH[o] = a[0]; SPH[o+1] = a[1]; SPH[o+2] = a[2];
      SPH[o+3] = a[3]; SPH[o+4] = a[3] * a[3];
      SPH[o+5] = a[4]; SPH[o+6] = a[5]; SPH[o+7] = a[6];
      SPH[o+8] = a[7]; SPH[o+9] = a[8];
    }
  })();

  var PLANE_SHIN = 0.42;
  var MAXDEPTH = 3;          /* reflection bounces after the primary hit */
  var MAXS = 240;            /* samples per pixel at convergence */

  /* lamp: a small sphere of light, sampled at a random point each pass.
     Close enough that inverse-square falloff shapes a pool of light. */
  var LX = -3.6, LY = 4.8, LZ = 2.4, LR = 0.65, LI = 40;
  var LDX = 0, LDY = 0, LDZ = 0;
  (function () {
    var m = Math.sqrt(LX*LX + LY*LY + LZ*LZ);
    LDX = LX/m; LDY = LY/m; LDZ = LZ/m;
  })();

  /* ── theme-driven palette (linear, pre-tonemap) ─────────────────────────── */
  var ZR, ZG, ZB, HR, HG, HB, LCR, LCG, LCB, AMBK, EXPO;
  var AFR, AFG, AFB;   /* constant fill, keeps night shadows from crushing */
  var CKA_R, CKA_G, CKA_B, CKB_R, CKB_G, CKB_B, FLR, FLG, FLB;

  function isDark() {
    var attr = root.getAttribute("data-theme");
    if (attr === "dark") return true;
    if (attr === "light") return false;
    return mqDark ? mqDark.matches : false;
  }

  function readTheme() {
    if (isDark()) {
      ZR = 0.010; ZG = 0.014; ZB = 0.032;
      HR = 0.034; HG = 0.028; HB = 0.026;
      LCR = 1.00; LCG = 0.76; LCB = 0.46;
      AMBK = 0.09; EXPO = 0.95; LI = 26;
      AFR = 0.019; AFG = 0.021; AFB = 0.027;
      CKA_R = 0.26; CKA_G = 0.26; CKA_B = 0.28;
      CKB_R = 0.065; CKB_G = 0.065; CKB_B = 0.075;
    } else {
      ZR = 0.230; ZG = 0.390; ZB = 0.730;
      HR = 0.845; HG = 0.880; HB = 0.925;
      LCR = 1.00; LCG = 0.955; LCB = 0.880;
      AMBK = 0.24; EXPO = 1.00; LI = 40;
      AFR = 0.000; AFG = 0.000; AFB = 0.000;
      CKA_R = 0.72; CKA_G = 0.71; CKA_B = 0.68;
      CKB_R = 0.17; CKB_G = 0.17; CKB_B = 0.165;
    }
    FLR = (CKA_R + CKB_R) * 0.5;
    FLG = (CKA_G + CKB_G) * 0.5;
    FLB = (CKA_B + CKB_B) * 0.5;
    buildLUT();
  }

  /* tonemap + gamma via lookup: index by x/(x+1), a compressive 0..1 map */
  var LUT = new Uint8Array(2048);
  function buildLUT() {
    for (var i = 0; i < 2048; i++) {
      var u = i / 2047;
      if (u > 0.9995) u = 0.9995;
      var x = u / (1 - u);
      var y = 1 - Math.exp(-x * EXPO);
      if (y < 0) y = 0;
      LUT[i] = Math.round(255 * Math.pow(y, 1 / 2.2));
    }
  }

  /* ── camera ─────────────────────────────────────────────────────────────── */
  var DEF_YAW = 0.55, DEF_PITCH = 0.235, DEF_DIST = 7.3;
  var yaw = DEF_YAW, pitch = DEF_PITCH, dist = DEF_DIST;
  var TX = 0, TY = 0.95, TZ = 0;
  var TANF = Math.tan(20 * Math.PI / 180);
  var EX, EY, EZ, FX, FY, FZ, RX, RY, RZ, UX, UY, UZ, ASPECT = 1.6;

  function updateCamera() {
    if (pitch < 0.035) pitch = 0.035;
    if (pitch > 1.12) pitch = 1.12;
    var cp = Math.cos(pitch), sp = Math.sin(pitch);
    EX = TX + dist * cp * Math.sin(yaw);
    EY = TY + dist * sp;
    EZ = TZ + dist * cp * Math.cos(yaw);
    var fx = TX - EX, fy = TY - EY, fz = TZ - EZ;
    var m = Math.sqrt(fx*fx + fy*fy + fz*fz);
    FX = fx/m; FY = fy/m; FZ = fz/m;
    /* right = forward x worldUp(0,1,0) = (-Fz, 0, Fx) */
    var rx = -FZ, ry = 0, rz = FX;
    m = Math.sqrt(rx*rx + rz*rz) || 1;
    RX = rx/m; RY = 0; RZ = rz/m;
    /* up = right x forward */
    UX = RY*FZ - RZ*FY; UY = RZ*FX - RX*FZ; UZ = RX*FY - RY*FX;
  }

  /* ── prng (xorshift32) ──────────────────────────────────────────────────── */
  var rs = 2463534242;
  function rnd() {
    rs ^= rs << 13; rs |= 0;
    rs ^= rs >>> 17;
    rs ^= rs << 5;  rs |= 0;
    return (rs >>> 0) / 4294967296;
  }

  /* ── intersection ───────────────────────────────────────────────────────── */
  var hitT = 0, rays = 0;

  function intersect(ox, oy, oz, dx, dy, dz) {
    rays++;
    var best = 1e30, id = -2, i, o, ex, ey, ez, b, c, disc, sq, t;
    for (i = 0; i < NS; i++) {
      o = i * ST;
      ex = ox - SPH[o]; ey = oy - SPH[o+1]; ez = oz - SPH[o+2];
      b = ex*dx + ey*dy + ez*dz;
      c = ex*ex + ey*ey + ez*ez - SPH[o+4];
      if (c > 0 && b > 0) continue;
      disc = b*b - c;
      if (disc <= 0) continue;
      sq = Math.sqrt(disc);
      t = -b - sq;
      if (t < 1e-4) t = -b + sq;
      if (t > 1e-4 && t < best) { best = t; id = i; }
    }
    if (dy < -1e-6 && oy > 0) {
      t = -oy / dy;
      if (t > 1e-4 && t < best) { best = t; id = -1; }
    }
    hitT = best;
    return id;
  }

  /* spheres only: the floor is below everything, so it never casts a shadow */
  function occluded(ox, oy, oz, dx, dy, dz, maxt) {
    rays++;
    var i, o, ex, ey, ez, b, c, disc, sq, t;
    for (i = 0; i < NS; i++) {
      o = i * ST;
      ex = ox - SPH[o]; ey = oy - SPH[o+1]; ez = oz - SPH[o+2];
      b = ex*dx + ey*dy + ez*dz;
      c = ex*ex + ey*ey + ez*ez - SPH[o+4];
      if (c > 0 && b > 0) continue;
      disc = b*b - c;
      if (disc <= 0) continue;
      sq = Math.sqrt(disc);
      t = -b - sq;
      if (t < 1e-3) t = -b + sq;
      if (t > 1e-3 && t < maxt) return true;
    }
    return false;
  }

  /* ── sky ────────────────────────────────────────────────────────────────── */
  var skyR = 0, skyG = 0, skyB = 0;
  function sky(dx, dy, dz) {
    var t = dy * 0.5 + 0.5;
    if (t < 0) t = 0; else if (t > 1) t = 1;
    t = t * t * (3 - 2 * t);
    var r = HR + (ZR - HR) * t;
    var g = HG + (ZG - HG) * t;
    var b = HB + (ZB - HB) * t;
    var s = dx*LDX + dy*LDY + dz*LDZ;
    if (s > 0) {
      var s2 = s*s, s4 = s2*s2, s8 = s4*s4;
      var s16 = s8*s8, s32 = s16*s16, s64 = s32*s32;
      var disc = s64 * s64 * s64 * s64;         /* s^256: a small, bright lamp */
      var halo = s8 * 0.10;
      var e = disc * 7 + halo;
      r += e * LCR; g += e * LCG; b += e * LCB;
    }
    skyR = r; skyG = g; skyB = b;
  }

  /* ── the tracer ─────────────────────────────────────────────────────────── */
  var outR = 0, outG = 0, outB = 0;
  var reflK = 0.50, useJitter = false, metalKd = 0.10;
  /* a conductor reflects instead of scattering, so a polished one has almost no
     diffuse term. Rough it up and the lobe spreads until it reads as dull metal —
     which is why the mirror ball goes chalky silver, never black, at 0%. */
  function setRefl(v) { reflK = v; metalKd = 0.10 + 0.80 * (1 - Math.min(reflK * 2, 1)); }

  function tracePixel(ox, oy, oz, dx, dy, dz) {
    var accR = 0, accG = 0, accB = 0;
    var tR = 1, tG = 1, tB = 1;
    var addEnv = true;
    var depth, id, o, px, py, pz, nx, ny, nz, ar, ag, ab, shin, metal;

    for (depth = 0; depth <= MAXDEPTH; depth++) {
      id = intersect(ox, oy, oz, dx, dy, dz);

      if (id === -2) {
        sky(dx, dy, dz);
        accR += tR * skyR; accG += tG * skyG; accB += tB * skyB;
        addEnv = false;
        break;
      }

      px = ox + dx * hitT; py = oy + dy * hitT; pz = oz + dz * hitT;
      var fog = 0;

      if (id === -1) {
        nx = 0; ny = 1; nz = 0;
        var chk = ((Math.floor(px) + Math.floor(pz)) & 1) === 0;
        /* blur the checker with distance so it never shimmers into moire */
        var mip = hitT * 0.022; if (mip > 1) mip = 1;
        var caR = chk ? CKA_R : CKB_R, caG = chk ? CKA_G : CKB_G, caB = chk ? CKA_B : CKB_B;
        ar = caR + (FLR - caR) * mip;
        ag = caG + (FLG - caG) * mip;
        ab = caB + (FLB - caB) * mip;
        shin = PLANE_SHIN;
        metal = 0;
        fog = 1 - Math.exp(-hitT * 0.018);
      } else {
        o = id * ST;
        var ir = 1 / SPH[o+3];
        nx = (px - SPH[o]) * ir; ny = (py - SPH[o+1]) * ir; nz = (pz - SPH[o+2]) * ir;
        ar = SPH[o+5]; ag = SPH[o+6]; ab = SPH[o+7];
        shin = SPH[o+8]; metal = SPH[o+9];
      }

      /* fresnel: reflectance rises at glancing angles (Schlick) */
      var cosI = -(dx*nx + dy*ny + dz*nz);
      if (cosI < 0) cosI = 0;
      var R0 = shin * reflK;
      var gm = R0 * 2; if (gm > 1) gm = 1;
      var m1 = 1 - cosI, m2 = m1*m1;
      var F = R0 + (1 - R0) * (m2*m2*m1) * gm;
      if (fog > 0) F *= (1 - fog);
      if (F > 0.97) F = 0.97;

      /* hemisphere ambient: sky above, floor bounce below */
      var at = ny * 0.5 + 0.5;
      var ambR = (FLR + (ZR - FLR) * at) * AMBK + AFR;
      var ambG = (FLG + (ZG - FLG) * at) * AMBK + AFG;
      var ambB = (FLB + (ZB - FLB) * at) * AMBK + AFB;
      var kd = metal ? metalKd : 1;
      var lr = ar * ambR * kd, lg = ag * ambG * kd, lb = ab * ambB * kd;

      /* one jittered shadow ray toward a random point on the lamp */
      var jx = 0, jy = 0, jz = 0;
      if (useJitter) {
        var uz = rnd() * 2 - 1, ua = rnd() * 6.283185307179586;
        var us = Math.sqrt(1 - uz*uz);
        jx = us * Math.cos(ua) * LR; jy = us * Math.sin(ua) * LR; jz = uz * LR;
      }
      var sx = LX + jx - px, sy = LY + jy - py, sz = LZ + jz - pz;
      var d2 = sx*sx + sy*sy + sz*sz;
      var sd = Math.sqrt(d2);
      sx /= sd; sy /= sd; sz /= sd;
      var ndl = nx*sx + ny*sy + nz*sz;

      if (ndl > 0) {
        var e = 1e-3;
        if (!occluded(px + nx*e, py + ny*e, pz + nz*e, sx, sy, sz, sd - 1e-2)) {
          var att = LI / d2;
          var df = ndl * att * kd;
          lr += ar * df * LCR; lg += ag * df * LCG; lb += ab * df * LCB;
          /* blinn-phong glint from the lamp itself */
          var hx = sx - dx, hy = sy - dy, hz = sz - dz;
          var hm = Math.sqrt(hx*hx + hy*hy + hz*hz) || 1;
          var ndh = (nx*hx + ny*hy + nz*hz) / hm;
          if (ndh > 0) {
            var h2 = ndh*ndh, h4 = h2*h2, h8 = h4*h4, h16 = h8*h8, h32 = h16*h16;
            var sp = h32 * h32 * att * (0.18 + R0 * 0.9);
            lr += sp * LCR * (metal ? ar : 1);
            lg += sp * LCG * (metal ? ag : 1);
            lb += sp * LCB * (metal ? ab : 1);
          }
        }
      }

      if (fog > 0) {
        sky(dx, dy, dz);
        lr += (skyR - lr) * fog; lg += (skyG - lg) * fog; lb += (skyB - lb) * fog;
      }

      accR += tR * (1 - F) * lr;
      accG += tG * (1 - F) * lg;
      accB += tB * (1 - F) * lb;

      /* metal tints its reflection; everything else reflects white */
      if (metal) { tR *= F * ar; tG *= F * ag; tB *= F * ab; }
      else       { tR *= F; tG *= F; tB *= F; }

      if (tR + tG + tB < 0.004) { addEnv = false; break; }

      var dn = dx*nx + dy*ny + dz*nz;
      dx = dx - 2*dn*nx; dy = dy - 2*dn*ny; dz = dz - 2*dn*nz;
      ox = px + nx * 1e-3; oy = py + ny * 1e-3; oz = pz + nz * 1e-3;
    }

    /* out of bounces: let the last ray see the sky rather than go black */
    if (addEnv) {
      sky(dx, dy, dz);
      accR += tR * skyR; accG += tG * skyG; accB += tB * skyB;
    }

    outR = accR; outG = accG; outB = accB;
  }

  /* ── framebuffer ────────────────────────────────────────────────────────── */
  /* a pixel budget per tier, not a fraction of the box: the same amount of tracing
     happens on any screen, so a narrow phone gets a sharper picture instead of the
     same picture more slowly. */
  var BUDGETS = [30000, 68000, 150000];
  var resIdx = 1, interacting = false;
  var W = 0, H = 0, acc = null, img = null;

  function buildBuffer() {
    var r = canvas.getBoundingClientRect();
    var cw = r.width || 640, ch = r.height || 400;
    var sc = Math.sqrt(BUDGETS[resIdx] / (cw * ch));
    if (sc > 1) sc = 1;              /* never trace more pixels than the screen shows */
    if (interacting) sc *= 0.55;     /* shed detail while the camera is moving */
    if (sc < 0.10) sc = 0.10;
    var w = Math.max(64, Math.round(cw * sc));
    var h = Math.max(48, Math.round(ch * sc));
    if (w === W && h === H) return false;
    var oldImg = img, oldW = W, oldH = H;
    W = w; H = h;
    canvas.width = W; canvas.height = H;
    ASPECT = W / H;
    acc = new Float32Array(W * H * 3);
    img = ctx.createImageData(W, H);
    var d = img.data;
    for (var i = 3; i < d.length; i += 4) d[i] = 255;
    if (oldImg && oldW > 0 && oldH > 0) rescale(oldImg, oldW, oldH);
    else prefill();
    updateMeta();
    return true;
  }

  /* dropping to the coarse drag buffer changes the pixel grid; carry the picture
     across it so grabbing the scene never flashes the frame back to a flat sky. */
  function rescale(src, sw, sh) {
    var s = src.data, d = img.data, x, y, sy, so, o = 0;
    for (y = 0; y < H; y++) {
      sy = (y * sh / H) | 0;
      for (x = 0; x < W; x++) {
        so = (sy * sw + ((x * sw / W) | 0)) * 4;
        d[o] = s[so]; d[o+1] = s[so+1]; d[o+2] = s[so+2];
        o += 4;
      }
    }
    ctx.putImageData(img, 0, 0);
  }

  /* paint a sky/floor gradient so nothing is ever blank before the first pass */
  function prefill() {
    var d = img.data, y, x, o = 0;
    for (y = 0; y < H; y++) {
      var v = (1 - 2 * (y + 0.5) / H) * TANF;
      var dx = FX + v * UX, dy = FY + v * UY, dz = FZ + v * UZ;
      var m = Math.sqrt(dx*dx + dy*dy + dz*dz) || 1;
      sky(dx/m, dy/m, dz/m);
      var pr = LUT[(skyR / (skyR + 1) * 2047) | 0];
      var pg = LUT[(skyG / (skyG + 1) * 2047) | 0];
      var pb = LUT[(skyB / (skyB + 1) * 2047) | 0];
      if (dy / m < -0.02) { pr = (pr * 0.55) | 0; pg = (pg * 0.55) | 0; pb = (pb * 0.55) | 0; }
      for (x = 0; x < W; x++) { d[o] = pr; d[o+1] = pg; d[o+2] = pb; o += 4; }
    }
    ctx.putImageData(img, 0, 0);
  }

  /* ── render loop ────────────────────────────────────────────────────────── */
  var samples = 0, curRow = 0, running = false, rafId = 0;
  var startT = 0, elapsed = 0, computeMs = 0;
  var winRays = 0, winMs = 0, rate = 0;
  var lastStat = 0, lastBlit = 0;

  function restart() {
    if (!acc) return;
    acc.fill(0);
    samples = 0; curRow = 0; rays = 0;
    computeMs = 0; winRays = 0; winMs = 0;
    startT = now(); elapsed = 0;
    running = true;
    setTag(false);
    if (!rafId) rafId = requestAnimationFrame(frame);
  }

  function renderRow(y) {
    var base = y * W * 3;
    var jit = useJitter;
    var invW = 1 / W, invH = 1 / H;
    for (var x = 0; x < W; x++) {
      var jx = jit ? rnd() : 0.5, jy = jit ? rnd() : 0.5;
      var u = (2 * (x + jx) * invW - 1) * ASPECT * TANF;
      var v = (1 - 2 * (y + jy) * invH) * TANF;
      var dx = FX + u * RX + v * UX;
      var dy = FY + u * RY + v * UY;
      var dz = FZ + u * RZ + v * UZ;
      var m = 1 / Math.sqrt(dx*dx + dy*dy + dz*dz);
      tracePixel(EX, EY, EZ, dx*m, dy*m, dz*m);
      acc[base] += outR; acc[base+1] += outG; acc[base+2] += outB;
      base += 3;
    }
  }

  function blit() {
    var rowsValid = samples > 0 ? H : curRow;
    if (rowsValid <= 0) return;
    var d = img.data, y, x, p, o, s, inv, r, g, b;
    for (y = 0; y < rowsValid; y++) {
      s = (y < curRow ? samples + 1 : samples);
      if (s < 1) s = 1;
      inv = 1 / s;
      p = y * W * 3; o = y * W * 4;
      for (x = 0; x < W; x++) {
        r = acc[p] * inv; g = acc[p+1] * inv; b = acc[p+2] * inv;
        d[o]   = LUT[(r / (r + 1) * 2047) | 0];
        d[o+1] = LUT[(g / (g + 1) * 2047) | 0];
        d[o+2] = LUT[(b / (b + 1) * 2047) | 0];
        p += 3; o += 4;
      }
    }
    ctx.putImageData(img, 0, 0);
  }

  function frame() {
    rafId = requestAnimationFrame(frame);
    if (!running || document.hidden) return;

    var budget = interacting ? 9 : 12;
    var t0 = now(), deadline = t0 + budget;
    var r0 = rays, finished = false;

    do {
      useJitter = samples > 0;
      renderRow(curRow);
      curRow++;
      if (curRow >= H) {
        curRow = 0;
        samples++;
        if (samples >= MAXS) { finished = true; break; }
      }
    } while (now() < deadline);

    var dt = now() - t0;
    computeMs += dt;
    winMs += dt; winRays += rays - r0;
    if (winMs > 320) { rate = winRays / (winMs / 1000); winMs = 0; winRays = 0; }

    /* the first two passes are the visible wipe-in, so paint those every frame;
       after that the picture changes by a hair per pass, so a slower repaint keeps
       the rest of the page responsive on a big buffer. */
    var tNow = now();
    if (finished || samples < 2 || tNow - lastBlit >= (reduceMotion ? 240 : 55)) {
      lastBlit = tNow; blit();
    }

    elapsed = (now() - startT) / 1000;

    if (finished) {
      running = false;
      cancelAnimationFrame(rafId); rafId = 0;
      setTag(true);
      updateStats(true);
      say("Render converged at " + MAXS + " samples per pixel.");
      return;
    }
    if (now() - lastStat > 110) { lastStat = now(); updateStats(false); }
  }

  /* ── readout ────────────────────────────────────────────────────────────── */
  function fmtRate(v) {
    if (!v) return "—";
    if (v >= 1e6) return (v / 1e6).toFixed(1) + "M";
    if (v >= 1e3) return Math.round(v / 1e3) + "k";
    return Math.round(v) + "";
  }
  function updateStats(done) {
    sSpp.textContent = samples;
    sRays.textContent = fmtRate(rate);
    sTime.innerHTML = elapsed.toFixed(1) + '<span class="u">s</span>';
    var pct = Math.min(100, samples / MAXS * 100);
    progFill.style.width = pct.toFixed(1) + "%";
    if (done) { prog.classList.add("done"); } else { prog.classList.remove("done"); }
  }
  function setTag(done) {
    if (done) { tag.classList.add("done"); tagText.textContent = "converged · " + MAXS + " spp"; }
    else { tag.classList.remove("done"); tagText.textContent = "tracing"; }
  }
  function updateMeta() {
    meta.textContent = W + " × " + H + " px · " + MAXDEPTH + " bounces · 1 lamp";
  }
  function say(msg) {
    if (live.textContent === msg) live.textContent = "";
    live.textContent = msg;
  }

  /* ── interaction ────────────────────────────────────────────────────────── */
  var hintGone = false;
  function dismissHint() {
    if (hintGone) return;
    hintGone = true;
    hint.classList.add("gone");
    setTimeout(function () { hint.style.display = "none"; }, reduceMotion ? 0 : 520);
  }

  function beginInteract() {
    if (interacting) return;
    interacting = true;
    canvas.classList.add("grabbing");
    buildBuffer();
  }
  function endInteract() {
    if (!interacting) return;
    interacting = false;
    canvas.classList.remove("grabbing");
    buildBuffer();
    updateCamera();
    restart();
  }

  var dragId = null, lastX = 0, lastY = 0;

  canvas.addEventListener("pointerdown", function (e) {
    if (dragId !== null) return;
    dragId = e.pointerId;
    lastX = e.clientX; lastY = e.clientY;
    if (canvas.setPointerCapture) { try { canvas.setPointerCapture(dragId); } catch (err) {} }
    dismissHint();
    /* no re-render yet: a bare click should not throw away a converged image */
  });

  canvas.addEventListener("pointermove", function (e) {
    if (e.pointerId !== dragId) return;
    var dx = e.clientX - lastX, dy = e.clientY - lastY;
    if (dx === 0 && dy === 0) return;
    lastX = e.clientX; lastY = e.clientY;
    beginInteract();
    yaw -= dx * 0.0062;
    pitch += dy * 0.0045;
    updateCamera();
    restart();
    e.preventDefault();
  });

  function releasePointer(e) {
    if (e.pointerId !== dragId) return;
    if (canvas.releasePointerCapture) { try { canvas.releasePointerCapture(dragId); } catch (err) {} }
    dragId = null;
    endInteract();
  }
  canvas.addEventListener("pointerup", releasePointer);
  canvas.addEventListener("pointercancel", releasePointer);

  canvas.addEventListener("keydown", function (e) {
    var k = e.key, step = e.shiftKey ? 0.16 : 0.055;
    var moved = true;
    if (k === "ArrowLeft") yaw += step;
    else if (k === "ArrowRight") yaw -= step;
    else if (k === "ArrowUp") pitch += step * 0.7;
    else if (k === "ArrowDown") pitch -= step * 0.7;
    else if (k === "+" || k === "=") dist = Math.max(4.2, dist - 0.35);
    else if (k === "-" || k === "_") dist = Math.min(13, dist + 0.35);
    else moved = false;
    if (!moved) return;
    e.preventDefault();
    dismissHint();
    updateCamera();
    restart();
  });

  reflEl.addEventListener("input", function () {
    var v = +reflEl.value;
    setRefl(v / 100);
    reflV.textContent = v + "%";
    restart();
  });
  reflEl.addEventListener("change", function () {
    say("Mirror finish " + reflEl.value + " percent.");
  });

  var segBtns = seg.querySelectorAll("button");
  seg.addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-r]") : null;
    if (!b) return;
    resIdx = +b.getAttribute("data-r");
    for (var i = 0; i < segBtns.length; i++) {
      segBtns[i].setAttribute("aria-pressed", segBtns[i] === b ? "true" : "false");
    }
    buildBuffer();
    restart();
    say(b.textContent + " resolution, " + W + " by " + H + " pixels.");
  });

  document.getElementById("rerender").addEventListener("click", function () {
    rs = (rs ^ (now() * 1000 | 0)) | 0 || 2463534242;
    restart();
    say("Re-rendering from scratch.");
  });

  document.getElementById("reset").addEventListener("click", function () {
    yaw = DEF_YAW; pitch = DEF_PITCH; dist = DEF_DIST;
    updateCamera();
    restart();
    say("View reset.");
  });

  /* ── theme + resize plumbing ────────────────────────────────────────────── */
  function onThemeChange() {
    readTheme();
    prefill();
    restart();
  }
  window.addEventListener("message", function (e) {
    var d = e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      root.setAttribute("data-theme", d.theme);
      onThemeChange();
    }
  });
  if (mqDark) {
    if (mqDark.addEventListener) mqDark.addEventListener("change", onThemeChange);
    else if (mqDark.addListener) mqDark.addListener(onThemeChange);
  }
  if (mqRM) {
    var onRM = function () { reduceMotion = mqRM.matches; };
    if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
    else if (mqRM.addListener) mqRM.addListener(onRM);
  }

  var rzT = 0;
  function onResize() {
    clearTimeout(rzT);
    rzT = setTimeout(function () {
      if (buildBuffer()) restart();
    }, 140);
  }
  if (window.ResizeObserver) {
    try { new ResizeObserver(onResize).observe(canvas); } catch (err) { window.addEventListener("resize", onResize); }
  } else {
    window.addEventListener("resize", onResize);
  }

  document.addEventListener("visibilitychange", function () {
    if (!document.hidden && running && !rafId) rafId = requestAnimationFrame(frame);
  });

  /* ── go ─────────────────────────────────────────────────────────────────── */
  readTheme();
  updateCamera();
  setRefl(+reflEl.value / 100);
  reflV.textContent = reflEl.value + "%";
  buildBuffer();
  updateStats(false);
  restart();

})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong