notetoself -

2026-09-20
Mirror Coins
▾Config
View

        
      
Page source <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Mirror Coins</title> <style> html, body { margin: 0; height: 100%; background: #000; overflow: hidden; } body { display: flex; flex-direction: column; } /* Canvas takes the space ABOVE the controls (flex:1) so it is never overlapped. */ canvas { display: block; width: 100%; flex: 1 1 auto; min-height: 0; } #error { position: static; margin: 0; padding: 8px 12px; width: 100%; box-sizing: border-box; color: #ffb3b3; background: rgba(120,0,0,.85); font: 13px/1.4 monospace; white-space: pre-wrap; display: none; } #hud { width: 100%; height: 66px; resize: both; box-sizing: border-box; color: #000; background: rgba(255,255,255,.85); border: 1px solid #999; font: 12px/1.3 monospace; padding: 4px; } #webcam { display: none; } /* Bottom control area (static flow): the collapsible config box holds EVERYTHING. The whole bar is capped at half the viewport and scrolls as one unit. */ #bar { flex: 0 1 auto; max-height: 50vh; margin: 6px; display: flex; flex-direction: column; gap: 6px; min-height: 0; } #file-input, #webcam-btn { cursor: pointer; text-align: left; background: rgba(255,255,255,.85); font: 12px monospace; padding: 4px 8px; } #webcam-btn.active { background: #8fd08f; color: #06320b; font-weight: bold; } #panel { flex: 1; min-height: 0; min-width: 0; display: flex; flex-direction: column; gap: 3px; overflow-y: auto; background: rgba(20,20,25,.82); border: 1px solid #555; border-radius: 4px; padding: 6px 10px; color: #eee; font: 12px/1.2 monospace; box-sizing: border-box; } #panel-header { cursor: pointer; user-select: none; color: #8fd08f; font-weight: bold; display: flex; align-items: center; gap: 6px; padding: 2px 0; } /* Collapsible body: source controls, fader grid, and the message/debug stack */ #panel-body { display: flex; flex-direction: column; gap: 8px; } #panel.collapsed #panel-body { display: none; } #source { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } #source .src-row { display: inline-flex; align-items: center; gap: 4px; } #source .src-row > span { color: #8fd08f; white-space: nowrap; } #source select, #grad-angle { font: 12px monospace; background: rgba(255,255,255,.85); border: 1px solid #777; border-radius: 3px; padding: 3px 4px; } #grad-angle { width: 90px; } #source .src-chk { display: inline-flex; align-items: center; gap: 3px; cursor: pointer; } #gradient-row.hidden { display: none; } #grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3px 14px; align-content: start; } #panel .ctl { display: grid; grid-template-columns: 120px minmax(0, 1fr) 46px; align-items: center; gap: 8px; font-size: 15px; } #panel .ctl > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* Taller, chunkier faders: transparent native track + custom ~18px round thumb. */ #panel input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; min-width: 0; margin: 6px 0; height: 20px; background: transparent; } #panel input[type="range"]::-webkit-slider-runnable-track { height: 8px; background: #555; border-radius: 4px; } #panel input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 18px; height: 18px; margin-top: -5px; border-radius: 50%; background: #8fd08f; } #panel input[type="range"]::-moz-range-track { height: 8px; background: #555; border-radius: 4px; } #panel input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; border: none; border-radius: 50%; background: #8fd08f; } #panel .val { text-align: right; color: #8fd08f; font-size: 15px; } /* Themed fieldset groups (Layout, Camera/tilt, View, Shader). */ #panel fieldset.grp { border: 1px solid #4a4a55; border-radius: 5px; margin: 2px 0 4px; padding: 4px 8px 8px; min-width: 0; } #panel fieldset.grp legend { color: #8fd08f; font-weight: bold; padding: 0 6px; font-size: 13px; } #panel .grp-head { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; } #panel .grp-head input { width: auto; height: auto; margin: 0; } /* Shader group is inert (dimmed) until its "Live shader" checkbox is ticked. */ #panel #shader-grp.disabled { opacity: 0.45; } #panel #shader-grp.disabled #editor, #panel #shader-grp.disabled .ctl { pointer-events: none; } /* Error + debug HUD now live INSIDE the config box (collapse with it) */ #stack { display: flex; flex-direction: column; gap: 4px; } /* Shadertoy-style live fragment-shader editor (inside the collapsible body) */ #editor { display: flex; flex-direction: column; gap: 4px; } #editor .ed-head { display: flex; align-items: center; gap: 6px; color: #8fd08f; } #editor .ed-head label { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; } #shader-editor { width: 100%; height: 150px; box-sizing: border-box; resize: vertical; color: #d6e6ff; background: #0b0f18; border: 1px solid #444; font: 12px/1.3 monospace; padding: 6px; tab-size: 2; white-space: pre; overflow: auto; } /* Smartphones (narrow viewports): scale controls ~2.5x for touch. Scoped so desktop is untouched. #panel font = 20px (~1.67x the 12px base); buttons and slider thumbs stack on top of that to reach a real ~2.5x hit-target size. */ @media (max-width: 600px) { #panel { font-size: 20px; gap: 10px; padding: 12px 14px; } /* base for all em math */ #panel-header { font-size: 1.3em; padding: 6px 0; } /* ~26px */ #panel .ctl { grid-template-columns: 190px minmax(0, 1fr) 74px; gap: 12px; font-size: 1em; } #panel fieldset.grp legend { font-size: 0.85em; } #panel .val { font-size: 1em; } /* Range sliders ~2.5x taller with a fat, tappable custom thumb (~34-40px). */ #panel input[type="range"] { -webkit-appearance: none; appearance: none; background: transparent; height: 2.2em; margin: 12px 0; } #panel input[type="range"]::-webkit-slider-runnable-track { height: 0.6em; background: #555; border-radius: 0.3em; } #panel input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 2em; height: 2em; margin-top: -0.7em; border-radius: 50%; background: #8fd08f; } #panel input[type="range"]::-moz-range-track { height: 0.6em; background: #555; border-radius: 0.3em; } #panel input[type="range"]::-moz-range-thumb { width: 2em; height: 2em; border: none; border-radius: 50%; background: #8fd08f; } /* Buttons + camera picker + file input ~2.5x (font + generous padding). */ #file-input, #webcam-btn, #camera-select, #shader-apply, #shader-reset { font-size: 1.4em; padding: 0.7em 1em; } #editor .ed-head { gap: 12px; flex-wrap: wrap; align-items: center; } #editor .ed-head label { font-size: 1.15em; } #shader-status { font-size: 1em; } #hud { font-size: 16px; height: 110px; } #error { font-size: 16px; } } </style> <!-- ===================== SHADERS (GLSL) ===================== --> <!-- Vertex shader: used for BOTH the mirror coins and the background quad. aPosition is model-space geometry, aNormal the surface normal. The coin program also uses uModelView / uNormalMatrix; the background program ignores them (it supplies clip-space positions directly). --> <script id="quad-vs" type="x-shader/x-vertex"> attribute vec3 aPosition; attribute vec3 aNormal; uniform mat4 uProjection; uniform mat4 uModelView; uniform mat3 uNormalMatrix; uniform mat4 uNormalModel; // model matrix used to rotate normals into world varying vec3 vNormal; varying vec3 vWorldPos; varying vec2 vCoinXY; // local (pre-swing) mirror XY, used as the bump coordinate void main() { vec4 worldPos = uModelView * vec4(aPosition, 1.0); vWorldPos = worldPos.xyz; vNormal = normalize(mat3(uNormalModel) * aNormal); vCoinXY = aPosition.xy; // local space: bumps ride along as the mirror swings gl_Position = uProjection * worldPos; } </script> <!-- Fragment shader for the coins: perfect mirror reflection. We reflect the view direction about the surface normal, intersect that ray with a virtual image plane located BEHIND the viewer (off-screen), and sample the uploaded texture at the hit point (falling back to a procedural checker until an image is loaded). --> <script id="coin-fs" type="x-shader/x-fragment"> precision highp float; varying vec3 vNormal; varying vec3 vWorldPos; varying vec2 vCoinXY; // local mirror-space XY (pre swing) -> bump coordinates uniform vec3 uCameraPos; uniform vec3 uPlanePoint; // a point on the virtual image plane (behind viewer) uniform vec3 uPlaneNormal; // unit normal of that plane (faces the viewer/-Z side) uniform vec2 uPlaneHalfSize; // half-extent of the image in world units (x, y) uniform vec4 uUvBox; // content rect within the (letterboxed) texture: x0,y0,x1,y1 uniform sampler2D uImage; // uploaded image texture uniform int uHasImage; // 1 = sample uImage, 0 = procedural fallback (see uBgMode) uniform float uBgBrightness; // background grey level -> mirrors reflect it where there is no image uniform vec3 uGoldColor; // golden surface tint (tints the reflection) uniform float uGoldStrength; // 0 = neutral mirror, 1 = fully golden // --- Mirror CONTENT fallback when a ray misses the image plane (or no image): // uBgMode selects what the mirrors show there: 0 grey, 1 checker, 2 gradient. // The gradient is evaluated procedurally so it needs no extra texture. --- uniform int uBgMode; // 0 grey | 1 checker | 2 gradient uniform int uGradRadial; // 1 = radial (center->edge), 0 = linear uniform float uGradAngle; // linear ramp direction (radians) when uGradRadial==0 uniform float uGradInvert; // 1 = swap black/white ends float gradValue(vec2 uv) { // black(0)->white(1) ramp across the mirror plane float v; if (uGradRadial == 1) { v = length(uv - 0.5) * 2.0; // 0 at center -> ~1 at corners } else { vec2 d = vec2(cos(uGradAngle), sin(uGradAngle)); v = dot(uv - 0.5, d) + 0.5; // project onto the direction axis } v = clamp(v, 0.0, 1.0); return mix(v, 1.0 - v, uGradInvert); } // --- Live "bump" (Shadertoy-style): perturb the surface normal so the mirror is // no longer perfectly flat. Edit bumpHeight() in the Config editor and hit // Apply to recompile live. It returns a pseudo-height field over the mirror's // local (x,y); its finite-difference gradient tilts the reflection normal. --- uniform float uBumpStrength; // 0 = perfectly flat mirror, larger = wavier uniform float uBumpScale; // spatial frequency of the bumps across a mirror uniform float uTime; // seconds, for animating the bump field uniform mat4 uNormalModel; // model rotation basis (also declared in the VS; stages share uniforms by name) float bumpHeight(vec2 p) { float t = uTime * 1.5; // animation speed // Two crossed travelling ripples + a finer diagonal wave -> organic "rippled // water / funhouse mirror" surface. Dial uBumpStrength down for a flat mirror. float h = sin(p.x * uBumpScale + t) + cos(p.y * uBumpScale - t * 0.9) + 0.5 * sin((p.x + p.y) * uBumpScale * 1.7 + t * 1.3); return h; } void main() { vec3 N = normalize(vNormal); // true world-space surface normal vec3 V = normalize(uCameraPos - vWorldPos); // surface -> camera // Perturb N by the bump field's gradient, expressed in the mirror's tangent // frame (T,B = local X/Y pushed through the model rotation). Because T/B ride // along with the swing, the bumps stick to the mirror instead of swimming. if (uBumpStrength > 0.0) { // uNormalModel carries a per-coin TRANSLATION in column 3, so zero it before // taking the 3x3 — we only want its rotation to map local X/Y into world T/B. mat3 rot = mat3(uNormalModel[0].xyz, uNormalModel[1].xyz, uNormalModel[2].xyz); vec3 T = normalize(rot * vec3(1.0, 0.0, 0.0)); vec3 B = normalize(rot * vec3(0.0, 1.0, 0.0)); float e = 0.02; // finite-difference step (mirror units) float hx = bumpHeight(vCoinXY + vec2(e, 0.0)) - bumpHeight(vCoinXY - vec2(e, 0.0)); float hy = bumpHeight(vCoinXY + vec2(0.0, e)) - bumpHeight(vCoinXY - vec2(0.0, e)); N = normalize(N - uBumpStrength * (hx * T + hy * B) / (2.0 * e)); } vec3 R = reflect(-V, N); // mirror reflection ray (from surface) // Intersect R with the virtual image plane behind the viewer: t such that // dot(RayPoint - PlanePoint, PlaneNormal) == 0. Negative dot => hit "behind", // which is exactly where the off-screen content sits for a mirror facing us. float denom = dot(R, uPlaneNormal); vec2 uv = vec2(0.5); // plane-hit coordinate in [0,1] (used below) if (abs(denom) > 1e-4) { float t = dot(uPlanePoint - vWorldPos, uPlaneNormal) / denom; vec3 hit = vWorldPos + R * t; // world-space point on the image plane uv = hit.xy / (2.0 * uPlaneHalfSize) + 0.5; // SAME uv drives plane color AND image lookup } // Off-screen plane color: what the mirrors show when they DON'T hit a loaded image. // uBgMode 1 -> checker (default, as before), 2 -> gradient, else flat grey. // Sampled from the ray's plane-hit uv so it reflects COHERENTLY across the field // (one continuous checker/gradient seen by all mirrors) — sampling vWorldPos instead // is what made the reflection break and blend with the gradient. vec3 planeCol; if (uBgMode == 1) { vec2 cc = floor(uv * 8.0); // continuous checker across the virtual plane planeCol = mix(vec3(0.05), vec3(0.95), mod(cc.x + cc.y, 2.0)); } else if (uBgMode == 2) { planeCol = vec3(gradValue(uv)); // black->white gradient across the whole plane } else { planeCol = vec3(uBgBrightness); // flat grey fallback when the ray misses entirely } // Final color: the loaded CONTENT image wins where present; otherwise the procedural // plane (checker / gradient / grey). Outside [0,1] the image is skipped -> plane shows. vec3 col = planeCol; if (uHasImage == 1 && uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0) { vec2 tuv = mix(uUvBox.xy, uUvBox.zw, uv); // into the centered content region col = texture2D(uImage, tuv).rgb; // uploaded image (correct aspect) } // Golden surface: tint the reflection toward uGoldColor by uGoldStrength. col = mix(col, col * uGoldColor, uGoldStrength); gl_FragColor = vec4(col, 1.0); } </script> <!-- Vertex shader for the full-screen background triangle. Deliberately has NO aNormal attribute: the previous shared-vs approach disabled aNormal and fed a constant via vertexAttrib3f, which raised GL_INVALID_OPERATION (0x502) on some WebGL impls and broke all later draws. Positions are already clip-space. --> <script id="bg-vs" type="x-shader/x-vertex"> attribute vec3 aPosition; varying vec3 vWorldPos; void main() { // With projection & modelView set to identity, clip-space coords ARE the // view-space ray direction; the fs subtracts uCameraPos (origin) and normalizes. vWorldPos = aPosition; gl_Position = vec4(aPosition.xy, 0.999, 1.0); // near far plane -> always behind } </script> <!-- Fragment shader for the background: a FLAT neutral backdrop ONLY. The content (checker / gradient / image / camera) is intentionally NOT drawn here — it lives off-screen behind the viewer and is seen ONLY via the mirrors' reflection, exactly as before. Drawing it here was the bug that made "Content" define the background. --> <script id="bg-fs" type="x-shader/x-fragment"> precision highp float; varying vec3 vWorldPos; uniform float uBrightness; // neutral grey level, driven by the "Bg brightness" fader void main() { gl_FragColor = vec4(vec3(uBrightness), 1.0); } </script> </head> <body> <canvas id="gl-canvas"></canvas> <div id="bar"> <div id="panel"> <div id="panel-header"><span id="panel-arrow">▾</span><span>Config</span></div> <div id="panel-body"> <div id="source"> <label class="src-row"><span>Reflect</span> <select id="bg-select" title="What the mirrors reflect"> <option value="checker">Checker</option> <option value="gradient">Gradient</option> <option value="image">Image</option> <option value="camera">Camera</option> </select> </label> <label class="src-row"><span>Tilt by</span> <select id="tilt-select" title="What tilts the mirrors"> <option value="function">Function</option> <option value="gradient">Gradient</option> <option value="image">Image</option> <option value="camera">Camera</option> </select> </label> <div id="gradient-row" class="src-row"> <select id="grad-dir" title="Gradient direction"> <option value="linear">Linear</option> <option value="radial">Radial</option> </select> <input id="grad-angle" type="range" min="0" max="360" step="1" title="Gradient angle"> <label class="src-chk"><input id="grad-invert" type="checkbox"> inv</label> </div> <input id="file-input" type="file" accept="image/*"> <select id="camera-select" title="Camera"></select> <button id="webcam-btn" type="button">Start webcam</button> <label class="src-chk"><input id="mirror-tilt-cam" type="checkbox" title="Flip the camera horizontally when it drives tilt"> Mirror tilt cam</label> <button id="reset-btn" type="button" title="Restore all defaults">Reset to defaults</button> </div> <fieldset class="grp"> <legend>View</legend> <label class="src-chk"><input id="fullscreen-chk" type="checkbox"> Fullscreen by default</label> <label class="src-chk"><input id="awake-chk" type="checkbox"> Keep screen awake</label> </fieldset> <div id="grid"></div> <fieldset class="grp" id="shader-grp"> <legend><label class="grp-head"><input id="shader-enable" type="checkbox"> Shader</label></legend> <div id="editor"> <div class="ed-head"> <button id="shader-apply" type="button">Apply shader</button> <label><input id="shader-auto" type="checkbox"> auto</label> <button id="shader-reset" type="button">Reset</button> <span id="shader-status"></span> </div> <textarea id="shader-editor" spellcheck="false"></textarea> </div> </fieldset> <div id="stack"> <pre id="error"></pre> <textarea id="hud" readonly=""></textarea> </div> </div> </div> </div> <video id="webcam" playsinline="" muted=""></video> <script> "use strict"; // Surface any runtime/shader error directly on the page (easier to read than // the console when debugging a black canvas). Safe to remove once working. function showError(msg) { const el = document.getElementById("error"); el.style.display = "block"; el.textContent += msg + "\n"; } window.addEventListener("error", (e) => showError(e.message)); // Debug HUD + GL error trap (temporary; remove once the discs render). const hudEl = document.getElementById("hud"); let glErrAfterBg = "none"; function glErrText(gl) { let code = gl.getError(), first = null, n = 0; while (code !== gl.NO_ERROR) { if (first === null) first = code; n++; code = gl.getError(); } return first === null ? "none" : `0x${first.toString(16)} x${n}`; } /* ============================================================ 1. MINIMAL MATRIX HELPERS (column-major, as WebGL expects) ============================================================ */ function mat4Identity() { return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); } function mat4Perspective(fovYRad, aspect, near, far) { const f = 1.0 / Math.tan(fovYRad / 2); const nf = 1 / (near - far); return new Float32Array([ f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0, ]); } function mat4Multiply(a, b) { const out = new Float32Array(16); for (let c = 0; c < 4; c++) { for (let r = 0; r < 4; r++) { let sum = 0; for (let k = 0; k < 4; k++) sum += a[k * 4 + r] * b[c * 4 + k]; out[c * 4 + r] = sum; } } return out; } function mat4Translate(tx, ty, tz) { const m = mat4Identity(); m[12] = tx; m[13] = ty; m[14] = tz; return m; } // Scale XY by `s` (on-screen mirror size) but Z by `sz` (mirror thickness), so // slab depth is decoupled from the mirror's diameter. Applied to coin geometry so // its size follows the "Mirror diameter" fader independent of world units / depth. function mat4ScaleZ(s, sz) { const m = mat4Identity(); m[0] = s; m[5] = s; m[10] = sz; return m; } // Rotation about Y axis (the swing axis). function mat4RotateY(rad) { const c = Math.cos(rad), s = Math.sin(rad); return new Float32Array([ c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1, ]); } // Rotation about X axis (used for each coin's fixed "mirror" tilt). function mat4RotateX(rad) { const c = Math.cos(rad), s = Math.sin(rad); return new Float32Array([ 1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, ]); } /* ============================================================ 2. SHADER COMPILATION HELPERS ============================================================ */ function compileShader(gl, type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error("Shader compile error: " + gl.getShaderInfoLog(shader)); } return shader; } function createProgram(gl, vsSource, fsSource) { const vs = compileShader(gl, gl.VERTEX_SHADER, vsSource); const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSource); const program = gl.createProgram(); gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error("Program link error: " + gl.getProgramInfoLog(program)); } return program; } /* ============================================================ 3. GEOMETRY 4a. A thin rounded-rectangle mirror slab: front cap + back cap (triangle fans, normal +/-Z) and a rim band for thickness. The outline is a SUPERELLIPSE |x|^n + |y|^n = r^n, so the corner exponent n morphs the shape continuously: n=2 -> circle, n->large -> square. `corner` in [0,1] maps to n = 2..8 (1.0 = circle, 0.0 = square). Full perimeter normals are used for both caps and the rim, so rounded corners shade correctly. 4b. A full-screen triangle in clip space for the background sky. ============================================================ */ // Outward normal of the superellipse |x|^n + |y|^n = R^n at (px,py). The gradient // degenerates on the axes (0 * Infinity = NaN), so fall back to the radial dir. function superNormal(px, py, n) { const en = Math.max(1, n - 1); let nx = n * Math.pow(Math.abs(px), en) * Math.sign(px); let ny = n * Math.pow(Math.abs(py), en) * Math.sign(py); const L = Math.hypot(nx, ny); if (!(L > 1e-6)) return [px, py, (Math.hypot(px, py) || 1)]; // caller normalizes; radial fallback return [nx / L, ny / L]; } // Upright rounded-square outline: walk the perimeter clockwise from top-left so a // square sits with edges along the axes and corners at tl/tr/bl/br (NOT a diamond). function superOutline(R, n, seg) { const P = R * Math.pow(2, 1 / n); // half-side == circumradius of the square limit const pAt = (t) => { // t in [0,4): corner0=TL .. corner3=BL const side = Math.floor(t), f = t - side; if (side === 0) return [-P + 2 * P * f, P]; // top edge TL -> TR if (side === 1) return [ P, P - 2 * P * f]; // right edge TR -> BR if (side === 2) return [ P - 2 * P * f, -P]; // bottom BR -> BL return [-P, -P + 2 * P * f]; // left edge BL -> TL }; const pts = []; for (let i = 0; i < seg; i++) { const t = (i / seg) * 4, [ax, ay] = pAt(t); const s = Math.pow(Math.pow(Math.abs(ax), n) + Math.pow(Math.abs(ay), n), 1 / n); pts.push([ax / s, ay / s]); // project the square perimeter onto the superellipse } return pts; } function buildCoin(size, halfThickness, segments, corner) { const positions = [], normals = [], indices = []; let v = 0; const R = size / 2; // inradius: circle radius when corner=1 (matches old sizing) // Exponent morphs the shape: corner=1 -> n=2 (circle), corner=0 -> n=64 (square). const n = 2 * Math.pow(32, 1 - corner); // 2 * 2^(5*(1-corner)) => n in [2, 64] // Upright outline + outward normals (perimeter-sampled so corners sit tl/tr/bl/br). const pts = superOutline(R, n, segments); const nor = pts.map(([px, py]) => { const nn = superNormal(px, py, n); return nn.length === 3 ? [nn[0] / nn[2], nn[1] / nn[2]] : nn; // radial fallback normalized }); // Front cap (normal +Z): center fan. positions.push(0, 0, halfThickness); normals.push(0, 0, 1); v++; for (let i = 0; i < segments; i++) { positions.push(pts[i][0], pts[i][1], halfThickness); normals.push(0, 0, 1); v++; } for (let i = 0; i < segments; i++) indices.push(0, 1 + i, 1 + ((i + 1) % segments)); // Back cap (normal -Z): reversed winding. const bC = v; positions.push(0, 0, -halfThickness); normals.push(0, 0, -1); v++; for (let i = 0; i < segments; i++) { positions.push(pts[i][0], pts[i][1], -halfThickness); normals.push(0, 0, -1); v++; } for (let i = 0; i < segments; i++) indices.push(bC, 1 + bC + ((i + 1) % segments), 1 + bC + i); // Rim band connecting front and back outlines (perimeter normals). const rimStart = v; for (let i = 0; i < segments; i++) { positions.push(pts[i][0], pts[i][1], halfThickness); normals.push(nor[i][0], nor[i][1], 0); v++; positions.push(pts[i][0], pts[i][1], -halfThickness); normals.push(nor[i][0], nor[i][1], 0); v++; } for (let i = 0; i < segments; i++) { const a = rimStart + i * 2, b = rimStart + ((i + 1) % segments) * 2; indices.push(a, a + 1, b, b, a + 1, b + 1); } return { positions, normals, indices }; } const COIN_SIZE = 2.0; // unit base (diameter=2) so mat4Scale maps size 1:1 to world units let coin = buildCoin(COIN_SIZE, 0.06, 96, 1); // start as a circle; rebuilt on corner/thickness change // Full-screen triangle: aPosition is already in clip space, so the vertex // shader's (projection * modelView) transform must be identity for this draw. const bgPositions = [-1, -1, 0, 3, -1, 0, -1, 3, 0]; /* ============================================================ 5. COIN GRID + SWING PARAMETERS 4x4 grid in the XY plane. Each coin swings about the Y axis by +/-2 degrees at a random frequency in [0.5, 2] Hz with a random phase. ============================================================ */ const canvas = document.getElementById("gl-canvas"); // needed early: buildCoins/worldUnitsPerPixel read its size // Live-tunable parameters, driven by the control-panel faders (see section 9). // Defaults here are the single source of truth; the sliders are initialized from them. const params = { swingAmpDeg: 2, // swing amplitude about Y, in degrees baseFreq: 1, // Hz; each coin uses baseFreq * its own freqScale tiltSpread: 0.35, // random fixed mirror tilt spread (radians) planeDistance: 50, // image-plane distance behind the camera imageHalfHeight: 40, // world half-height of the image plane camZ: 15, // camera distance from the coin grid gapPx: 8, // center-to-center spacing = diameter + gap (px); 0 => touching diameterPx: 90, // mirror diameter in SCREEN pixels (min 10) areaW: 70, // width of the mirror field as % of viewport width areaH: 70, // height of the mirror field as % of viewport height bgBrightness: 0.06, // background grey level (0 = black .. 1 = white) goldStrength: 0.85, // golden tint on the reflection (0 = neutral, 1 = full gold) mirrorThickness: 0.08, // slab depth as a FRACTION of the mirror diameter (0 .. 0.5) cornerRadius: 1.0, // outline roundness: 1.0 = circle, 0.0 = square (superellipse) bumpStrength: 0.0, // normal-perturbation bumpiness (0 = flat mirror; shader group) bumpScale: 3.0, // spatial frequency of the bump field across a mirror (shader group) shaderCode: "", // live-edited coin-fs source ("" = use the built-in default) shaderEnabled: false, // master toggle for the Shader group (source editor + bump faders) cameraId: "", // deviceId of the chosen camera ("" = prefer front via facingMode) bendAmount: 18, // max mirror "bend" (degrees) when you drag on a mirror savedImage: "", // dataURL of the last uploaded image (restored after refresh) fullscreen: true, // enter fullscreen by default (config box collapse triggers it) keepAwake: true, // request a screen wake lock so the display stays on // --- DLP mirror: independent control over what the mirrors reflect vs. what tilts them --- bgSource: "checker", // mirror CONTENT: "checker" | "gradient" | "image" | "camera" tiltSource: "function", // mirror TILT driver: "function" | "gradient" | "image" | "camera" mirrorTiltCam: true, // horizontally flip (selfie-mirror) the camera when it drives TILT gradientDir: "linear", // gradient shape: "linear" (directional) | "radial" (center->edge) gradientAngle: 0, // linear ramp direction in degrees (0 = left->right) gradientInvert: false, // swap black/white ends of the gradient tiltThreshold: 0.5, // luminance level (0..1) at which a DMD mirror flips ON dlpBinary: 1.0, // tilt response: 0 = smooth/proportional .. 1 = hard binary snap }; /* ---- Persistence: faders + chosen source survive a page refresh ---- */ // Bump the key whenever DEFAULTS change so stale saved values don't mask new defaults. const STORE_KEY = "gl-mirror-params-v3"; const DEFAULTS = JSON.parse(JSON.stringify(params)); // pristine snapshot for "reset to defaults" function saveParams() { try { localStorage.setItem(STORE_KEY, JSON.stringify(params)); } catch (e) {} } (function restoreParams() { try { const s = localStorage.getItem(STORE_KEY); if (!s) return; Object.assign(params, JSON.parse(s)); // overlay saved values onto the defaults } catch (e) {} })(); // Golden surface color that tints reflections (warm gold). Configurable via the // "Gold strength" fader, which blends between a neutral mirror and this tint. const GOLD_COLOR = [1.0, 0.843, 0.0]; // ~#FFD700 // Build a grid of coins that FILLS the configured screen area (areaW x areaH %) // with mirrors of the given diameter + gap. The mirror count is DERIVED from // how many fit — there is no explicit "mirrors per side" any more. Sizes are in // world units via `upp` (world units per screen pixel at the grid plane, z=0). function buildCoins(upp) { const arr = []; const diamWorld = params.diameterPx * upp; // mirror diameter (world) const gapWorld = Math.max(0, params.gapPx) * upp; // edge-to-edge gap (>= 0) const step = diamWorld + gapWorld; // center-to-center pitch // Area in DEVICE pixels (matches worldUnitsPerPixel's device-pixel basis) so the // field fills areaW x areaH % of the viewport regardless of DPR / fullscreen. const dpr = window.devicePixelRatio || 1; const w = (canvas.clientWidth || window.innerWidth) * dpr; const h = (canvas.clientHeight || window.innerHeight) * dpr; const areaW = (params.areaW / 100) * w * upp; // field width (world) const areaH = (params.areaH / 100) * h * upp; // field height (world) const cols = Math.max(1, Math.floor((areaW + gapWorld) / step)); const rows = Math.max(1, Math.floor((areaH + gapWorld) / step)); for (let gy = 0; gy < rows; gy++) { for (let gx = 0; gx < cols; gx++) { const x = (gx - (cols - 1) / 2) * step; const y = (gy - (rows - 1) / 2) * step; arr.push({ offset: [x, y, 0], phase: Math.random() * Math.PI * 2, freqScale: (0.5 + Math.random() * 1.5) / params.baseFreq, // per-coin factor [0.5..2]/base // Small fixed tilt so coins mostly face the camera and reflect a clear // image; slight per-coin variation in what part of the checker they show. tiltX: (Math.random() - 0.5) * 0.35, // +/- ~10 deg about X tiltY: (Math.random() - 0.5) * 0.2, // +/- ~6 deg base yaw bendX: 0, bendY: 0, // live touch-bend tilt (radians), relaxes to 0 }); } } return arr; } // World units covered by ONE DEVICE PIXEL at the coin grid plane (z=0). Using the // drawing-buffer height (canvas.height = CSS px * devicePixelRatio) rather than the // CSS height makes a mirror's on-screen size depend only on its diameterPx relative to // the framebuffer — so it stays identical across fullscreen/DPR/zoom changes. function worldUnitsPerPixel() { const devH = canvas.height || (window.innerHeight * (window.devicePixelRatio || 1)); return (2 * Math.tan(Math.PI / 8) * params.camZ) / devH; // FOV is PI/4 about Y } let coins = buildCoins(worldUnitsPerPixel()); /* ============================================================ 6. WEBGL SETUP ============================================================ */ const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); if (!gl) { showError("WebGL not supported by this browser."); throw new Error("no webgl"); } // Program A: mirror coins (reflect the uploaded image on a virtual plane). // The fragment source is captured as DEFAULT_FS so the live editor can restore it; // coinProgram is mutable because "Apply shader" hot-swaps a freshly compiled one. const QUAD_VS_SOURCE = document.getElementById("quad-vs").textContent; const DEFAULT_FS = document.getElementById("coin-fs").textContent; let coinProgram = createProgram(gl, QUAD_VS_SOURCE, (params.shaderEnabled && params.shaderCode) ? params.shaderCode : DEFAULT_FS); // Program B: dark background (no reflection content drawn here). const bgProgram = createProgram( gl, document.getElementById("bg-vs").textContent, document.getElementById("bg-fs").textContent ); // Upload geometry. NOTE: typed-array type depends on target — the index buffer // MUST be Uint16Array, not Float32Array (the latter raised GL_INVALID_OPERATION). function makeBuffer(target, data) { const buf = gl.createBuffer(); gl.bindBuffer(target, buf); const arr = (target === gl.ELEMENT_ARRAY_BUFFER) ? new Uint16Array(data) : new Float32Array(data); gl.bufferData(target, arr, gl.STATIC_DRAW); return buf; } let coinPosBuf = makeBuffer(gl.ARRAY_BUFFER, coin.positions); let coinNormBuf = makeBuffer(gl.ARRAY_BUFFER, coin.normals); let coinIdxBuf = makeBuffer(gl.ELEMENT_ARRAY_BUFFER, coin.indices); const bgPosBuf = makeBuffer(gl.ARRAY_BUFFER, bgPositions); // Rebuild the mirror mesh when the outline (corner radius) or thickness changes. function rebuildCoinGeometry() { coin = buildCoin(COIN_SIZE, params.mirrorThickness * COIN_SIZE / 2, 96, params.cornerRadius); gl.deleteBuffer(coinPosBuf); gl.deleteBuffer(coinNormBuf); gl.deleteBuffer(coinIdxBuf); coinPosBuf = makeBuffer(gl.ARRAY_BUFFER, coin.positions); coinNormBuf = makeBuffer(gl.ARRAY_BUFFER, coin.normals); coinIdxBuf = makeBuffer(gl.ELEMENT_ARRAY_BUFFER, coin.indices); } // Cache attribute + uniform locations for the coin program. function getLocations(program) { return { aPosition: gl.getAttribLocation(program, "aPosition"), aNormal: gl.getAttribLocation(program, "aNormal"), projection: gl.getUniformLocation(program, "uProjection"), modelView: gl.getUniformLocation(program, "uModelView"), normalModel: gl.getUniformLocation(program, "uNormalModel"), cameraPos: gl.getUniformLocation(program, "uCameraPos"), planePoint: gl.getUniformLocation(program, "uPlanePoint"), planeNormal: gl.getUniformLocation(program, "uPlaneNormal"), planeHalfSize: gl.getUniformLocation(program, "uPlaneHalfSize"), uvBox: gl.getUniformLocation(program, "uUvBox"), image: gl.getUniformLocation(program, "uImage"), hasImage: gl.getUniformLocation(program, "uHasImage"), bgBrightness: gl.getUniformLocation(program, "uBgBrightness"), goldColor: gl.getUniformLocation(program, "uGoldColor"), goldStrength: gl.getUniformLocation(program, "uGoldStrength"), bgMode: gl.getUniformLocation(program, "uBgMode"), gradRadial: gl.getUniformLocation(program, "uGradRadial"), gradAngle: gl.getUniformLocation(program, "uGradAngle"), gradInvert: gl.getUniformLocation(program, "uGradInvert"), bumpStrength: gl.getUniformLocation(program, "uBumpStrength"), bumpScale: gl.getUniformLocation(program, "uBumpScale"), time: gl.getUniformLocation(program, "uTime"), }; } let coinLoc = getLocations(coinProgram); // reassigned when a shader is hot-applied const bgLoc = { aPosition: gl.getAttribLocation(bgProgram, "aPosition"), brightness: gl.getUniformLocation(bgProgram, "uBrightness"), }; gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.cullFace(gl.BACK); /* ============================================================ 7. RESIZE HANDLING ============================================================ */ function resize() { const dpr = window.devicePixelRatio || 1; const w = Math.floor(canvas.clientWidth * dpr); const h = Math.floor(canvas.clientHeight * dpr); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); coins = buildCoins(worldUnitsPerPixel()); // counts depend on viewport size -> reflow the grid } } window.addEventListener("resize", resize); /* ============================================================ 8. RENDER LOOP ============================================================ */ // Mutable scene state, recomputed each frame from `params` (fader-driven). const cameraPos = [0, 0, params.camZ]; // live camera position const imagePlanePoint = [0, 0, 65]; // set below from planeDistance const IMAGE_PLANE_NORMAL = [0, 0, -1]; // faces the coins/camera (toward -Z) // Reflected-image texture (CONTENT only). Starts empty (uHasImage = 0 -> procedural // fallback). Loading an image/camera that is used as CONTENT uploads it here. const imageTexture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, imageTexture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); 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); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); // SEPARATE tilt-control texture. When the camera/image drives TILT (not content) it is // uploaded here instead of imageTexture, so the picture NEVER appears in the mirrors — // it only sets mirror angles. Content and tilt are fully decoupled by using two textures. const tiltTexture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tiltTexture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); 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); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); let contentLoaded = false; // imageTexture holds a live CONTENT image let tiltLoaded = false; // tiltTexture holds a live TILT signal (image/camera) let tiltTexW = 1, tiltTexH = 1; // tilt texture size (for readback mapping) let tiltUvBox = [0, 0, 1, 1]; // content rect within the letterboxed tilt texture let planeHalfWidth = params.imageHalfHeight; // updated from the image aspect ratio let texCenter = [0, 0, 0, 0]; // center-texel readback (debug HUD only) let imageAspect = 1; // uploaded image width/height (for undistorted UVs) let uvBox = [0, 0, 1, 1]; // content rect in the letterboxed POT texture let videoSource = null; // active <video> stream, or null when off-screen const imgEl = new Image(); // persistent image element for restoring savedImage const MAX_TEX_DIM = 1024; // cap so we never upload a huge texture per frame const letterCanvas = document.createElement("canvas"); // reusable letterbox staging canvas const letterCtx = letterCanvas.getContext("2d"); // WebGL1 requires power-of-two dimensions for mipmaps / linear-filtered mips, // otherwise the texture is incomplete and samples as black. Since most sources // (photos, webcams) are non-POT, we letterbox them onto a POT canvas at their // TRUE aspect ratio (no stretching) before upload, and remap UVs into the bar- // free content rect via uvBox so the picture stays undistorted on screen. function nextPow2(n) { return Math.max(1, Math.pow(2, Math.ceil(Math.log2(n)))); } // Letterbox `source` (Image or Video, size srcW x srcH) onto a POT canvas at its true // aspect ratio and upload it to `targetTex`. When target is the CONTENT texture we also // refresh planeHalfWidth/uvBox/contentLoaded (the reflection geometry); when it's the TILT // texture we only record tiltTexW/H/tiltLoaded — nothing about it reaches the mirrors' look. function uploadLetterboxed(source, srcW, srcH, withMipmaps, targetTex) { const isContent = (targetTex === imageTexture); const cap = Math.min(1, MAX_TEX_DIM / Math.max(srcW, srcH)); // shrink big sources const potW = nextPow2(Math.floor(srcW * cap)), potH = nextPow2(Math.floor(srcH * cap)); const scale = Math.min(potW / srcW, potH / srcH) ; // fit within POT bounds const drawW = srcW * scale, drawH = srcH * scale; const offX = (potW - drawW) / 2, offY = (potH - drawH) / 2; letterCanvas.width = potW; letterCanvas.height = potH; letterCtx.clearRect(0, 0, potW, potH); letterCtx.drawImage(source, offX, offY, drawW, drawH); // centered, undistorted gl.bindTexture(gl.TEXTURE_2D, targetTex); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); // texture v=0 is the bottom gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, letterCanvas); if (withMipmaps) { gl.generateMipmap(gl.TEXTURE_2D); // legal (POT) for static images gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); } else { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // video: no mip-chain } if (isContent) { texCenter = Array.from(letterCtx.getImageData(potW >> 1, potH >> 1, 1, 1).data); imageAspect = srcW / srcH; // true ratio for undistorted UVs planeHalfWidth = params.imageHalfHeight * imageAspect; // world half-width keeps the ratio // Content rect in texture space. UNPACK_FLIP_Y flips rows, so the vertical bar // offset (offY from the top) maps to the bottom: y0 = offY/h, y1 = 1 - offY/h. uvBox = [offX / potW, offY / potH, 1 - offX / potW, 1 - offY / potH]; contentLoaded = true; } else { tiltTexW = potW; tiltTexH = potH; // readback maps into this rect tiltUvBox = [offX / potW, offY / potH, 1 - offX / potW, 1 - offY / potH]; tiltLoaded = true; } } /* ---- DLP tilt sampling ----------------------------------------------- Each mirror reads the luminance of its chosen TILT source at its own grid UV and tilts ON/OFF. The gradient is analytic (no readback); image/camera use a cached one-texel gl.readPixels against the SEPARATE tiltTexture, so the control signal is never what the mirrors reflect. ----- */ // Map params.bgSource -> the coin-fs / bg-fs procedural fallback mode (0 grey|1 checker|2 gradient). function bgMode() { return params.bgSource === "gradient" ? 2 : (params.bgSource === "checker" ? 1 : 0); } // Analytic black->white ramp value in [0,1] at uv, matching gradValue() in the shaders. function gradientLum(u, v) { let val; if (params.gradientDir === "radial") { val = Math.hypot(u - 0.5, v - 0.5) * 2.0; } else { const a = params.gradientAngle * Math.PI / 180; val = (u - 0.5) * Math.cos(a) + (v - 0.5) * Math.sin(a) + 0.5; } val = Math.min(1, Math.max(0, val)); return params.gradientInvert ? 1 - val : val; } // Cached readback target bound to the TILT texture (rebuilt whenever it is re-uploaded). let lumFBO = null; function ensureLumFBO() { if (!lumFBO) lumFBO = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, lumFBO); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tiltTexture, 0); gl.bindFramebuffer(gl.FRAMEBUFFER, null); } // CPU luminance cache for the tilt texture. The OLD path did one gl.readPixels PER MIRROR // PER FRAME (~900 synchronous GPU->CPU stalls at small diameters) which tanked the frame // rate. Instead we read the whole (small, <=1024px POT) tilt texture into this array ONCE // per frame, and every mirror reads from memory — O(1) readPixels instead of O(mirrors). let lumCache = null, lumCacheW = 0, lumCacheH = 0; function refreshLumCache() { if (!tiltLoaded || !lumFBO) return; const w = tiltTexW, h = tiltTexH; if (!lumCache || lumCacheW !== w || lumCacheH !== h) { lumCache = new Uint8Array(w * h * 4); lumCacheW = w; lumCacheH = h; } gl.bindFramebuffer(gl.FRAMEBUFFER, lumFBO); gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, lumCache); // ONE readback for the whole frame gl.bindFramebuffer(gl.FRAMEBUFFER, null); } // Luminance in [0,1] of the active tilt source at grid-normalized (u,v), sampled from the // per-frame CPU cache (no GPU round-trip). Gradient is analytic; camera optionally mirrored. function sampleLuminance(source, u, v) { if (source === "gradient") return gradientLum(u, v); if (!lumCache) return 0.5; // no tilt signal this frame -> neutral let tu = u; // Selfie-mirror the camera tilt signal: moving right flips the RIGHT side of the field // (natural mirror feel) unless "Mirror tilt cam" is off. Flip the sample coord, not pixels. if (source === "camera" && params.mirrorTiltCam) tu = 1 - u; tu = tiltUvBox[0] + (tiltUvBox[2] - tiltUvBox[0]) * tu; const tv = tiltUvBox[1] + (tiltUvBox[3] - tiltUvBox[1]) * v; const x = Math.min(lumCacheW - 1, Math.max(0, Math.round(tu * (lumCacheW - 1)))); const y = Math.min(lumCacheH - 1, Math.max(0, Math.round(tv * (lumCacheH - 1)))); const i = (y * lumCacheW + x) * 4; return (0.2126 * lumCache[i] + 0.7152 * lumCache[i + 1] + 0.0722 * lumCache[i + 2]) / 255; } document.getElementById("file-input").addEventListener("change", (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { params.savedImage = String(reader.result); // persist so the image survives a refresh loadImgFromDataURL(params.savedImage); }; reader.onerror = () => showError("Could not read image: " + file.name); reader.readAsDataURL(file); }); // Load an image from a dataURL and route it by ROLE: to the CONTENT texture if either // picker is set to "image" as content, AND/OR to the TILT texture if tilt is driven by // "image". The two are independent — an image used only for tilt never shows up reflected. function loadImgFromDataURL(dataUrl) { imgEl.onload = () => { if (contentNeedsImage()) uploadLetterboxed(imgEl, imgEl.width, imgEl.height, true, imageTexture); if (tiltNeedsImage()) { uploadLetterboxed(imgEl, imgEl.width, imgEl.height, false, tiltTexture); ensureLumFBO(); } }; imgEl.onerror = () => showError("Could not load image data."); imgEl.src = dataUrl; } /* ---- Webcam: live getUserMedia stream -> per-frame letterbox upload ---- */ const webcamEl = document.getElementById("webcam"); const webcamBtn = document.getElementById("webcam-btn"); const cameraSel = document.getElementById("camera-select"); function stopWebcam() { if (videoSource) { const s = videoSource.srcObject; if (s) for (const t of s.getTracks()) t.stop(); // release the camera videoSource.srcObject = null; videoSource = null; } webcamBtn.textContent = "Start webcam"; webcamBtn.classList.remove("active"); } // Build the camera picker from enumerateDevices(). Labels + real deviceIds are only // exposed AFTER permission is granted, so request a throwaway stream first (then stop // it) purely to unlock the list. Default selection: params.cameraId if present, else // the front-facing camera (the user asked for selfie-cam), else the first device. async function populateCameras() { if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return; try { // permission unlocks labels/ids const tmp = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); for (const t of tmp.getTracks()) t.stop(); } catch (e) { /* denied/blocked -> list below may be label-less; still show options */ } let devices = []; try { devices = await navigator.mediaDevices.enumerateDevices(); } catch (e) { showError("Could not enumerate cameras: " + e.message); return; } const cams = devices.filter((d) => d.kind === "videoinput"); cameraSel.innerHTML = ""; cams.forEach((cam, i) => { const opt = document.createElement("option"); opt.value = cam.deviceId; const front = /front|user|face|selfie/i.test(cam.label); opt.textContent = cam.label || `${front ? "Front" : "Camera"} ${i + 1}`; cameraSel.appendChild(opt); }); if (!cams.length) return; const match = cams.find((c) => c.deviceId === params.cameraId); const front = cams.find((c) => /front|user|face|selfie/i.test(c.label)); cameraSel.value = (match || front || cams[0]).deviceId; } async function startWebcam() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { showError("Webcam needs a secure context (https or localhost). From file://, Chrome blocks " + "getUserMedia — serve with e.g. `python3 -m http.server` and open http://localhost."); return; } // Prefer the explicitly chosen device; fall back to a facingMode hint when none is set. const video = cameraSel.value ? { deviceId: { exact: cameraSel.value } } : { facingMode: "user" }; // default to front camera try { const stream = await navigator.mediaDevices.getUserMedia({ video, audio: false }); webcamEl.srcObject = stream; await webcamEl.play(); // begin decoding frames await new Promise((res) => { // wait for a real frame size if (webcamEl.videoWidth) return res(); webcamEl.addEventListener("loadedmetadata", res, { once: true }); }); videoSource = webcamEl; if (tiltNeedsCamera()) ensureLumFBO(); // bind readback to the tilt texture webcamBtn.textContent = "Stop webcam"; webcamBtn.classList.add("active"); } catch (err) { showError("Webcam unavailable: " + err.name + " — " + err.message); } } // Route a fresh webcam frame by ROLE. Camera-as-CONTENT -> imageTexture (reflected); // camera-as-TILT -> tiltTexture (angles only, never shown). A single stream can feed both. function uploadWebcamFrame() { if (!videoSource || videoSource.readyState < 2) return; const w = videoSource.videoWidth, h = videoSource.videoHeight; if (contentNeedsCamera()) uploadLetterboxed(videoSource, w, h, false, imageTexture); if (tiltNeedsCamera()) uploadLetterboxed(videoSource, w, h, false, tiltTexture); } // Manual webcam toggle only drives the CAMERA source for content and/or tilt; picking a // non-camera source in either picker turns the stream off automatically. webcamBtn.addEventListener("click", () => { if (videoSource) stopWebcam(); else { startWebcam(); params.bgSource = "camera"; params.tiltSource = "camera"; syncSourceUI(); saveParams(); } }); // Live camera switching: restart the stream with the newly selected device. cameraSel.addEventListener("change", () => { params.cameraId = cameraSel.value; saveParams(); if (videoSource) { stopWebcam(); startWebcam(); } }); /* ============================================================ 9. CONTROL PANEL (faders) -> drives `params` live Each slider updates params[key] and its readout on input, so every change is reflected immediately in the next rendered frame. ============================================================ */ // Faders, SORTED BY TOPIC via `group`. Each group is rendered into its own fieldset. const controls = [ // `exp:true` maps the slider exponentially for fine control near the low end, and // a raw slider value of 0 pins the parameter to exactly 0 (e.g. freq/amp stopped). { key: "diameterPx", label: "Mirror diam (px)", min: 5, max: 4000, step: 1, exp: true, floor: 5, group: "Layout" }, { key: "gapPx", label: "Mirror gap (px)", min: 0, max: 160, step: 1, group: "Layout" }, { key: "areaW", label: "Area width (%)", min: 10, max: 100, step: 1, group: "Layout" }, { key: "areaH", label: "Area height (%)", min: 10, max: 100, step: 1, group: "Layout" }, { key: "mirrorThickness",label: "Thickness (frac)", min: 0, max: 0.5, step: 0.01, group: "Layout" }, { key: "cornerRadius", label: "Corner radius", min: 0, max: 1, step: 0.01, group: "Layout" }, { key: "tiltSpread", label: "Tilt spread", min: 0, max: 1, step: 0.05, group: "Camera & tilt" }, { key: "tiltThreshold", label: "Tilt threshold", min: 0, max: 1, step: 0.01, group: "Camera & tilt" }, { key: "dlpBinary", label: "DLP binary-ness", min: 0, max: 1, step: 0.01, group: "Camera & tilt" }, { key: "swingAmpDeg", label: "Swing amp (deg)", min: 0, max: 60, step: 0.01, exp: true, group: "Camera & tilt" }, { key: "baseFreq", label: "Swing freq (Hz)", min: 0, max: 2, step: 0.01, exp: true, group: "Camera & tilt" }, { key: "bendAmount", label: "Touch bend (deg)", min: 0, max: 60, step: 0.5, group: "Camera & tilt" }, { key: "camZ", label: "Camera Z", min: 6, max: 40, step: 1, group: "Reflection" }, { key: "planeDistance", label: "Plane distance", min: 20, max: 90, step: 1, group: "Reflection" }, { key: "imageHalfHeight",label: "Image size (halfH)", min: 5, max: 120, step: 1, group: "Reflection" }, { key: "bgBrightness", label: "Bg brightness", min: 0, max: 1, step: 0.01, group: "Reflection" }, { key: "goldStrength", label: "Gold strength", min: 0, max: 1, step: 0.01, group: "Reflection" }, // Shader group lives with the source editor; its faders are disabled until the // "Live shader" checkbox is ticked (see syncShaderGroupEnabled). { key: "bumpStrength", label: "Bump strength", min: 0, max: 2, step: 0.01, group: "Shader" }, { key: "bumpScale", label: "Bump scale", min: 0.5, max: 20, step: 0.1, group: "Shader" }, ]; // Faders that change the mirror layout -> rebuild the coin grid on input. const REBUILD_KEYS = new Set(["diameterPx", "gapPx", "areaW", "areaH"]); // Faders that change the mirror MESH -> rebuild its geometry on input. const GEOM_KEYS = new Set(["cornerRadius", "mirrorThickness"]); // Exponential <-> linear slider mapping for `exp` controls (raw s in 0..1). A raw // value of 0 maps to exactly 0; otherwise a log curve from floor..max gives fine control // over small values. `floor` defaults to EXP_FLOOR (so swing amp/freq reach a true 0.0); // controls like diameterPx pass their own min as the floor so the whole slider range is // usable instead of wasting its lower half on sub-minimum values. const EXP_FLOOR = 0.01; // default smallest non-zero exponential value function expFromSlider(ctl, s) { const f = ctl.floor != null ? ctl.floor : EXP_FLOOR; return s <= 0 ? 0 : f * Math.pow(ctl.max / f, s); } function sliderFromExp(ctl, v) { const f = ctl.floor != null ? ctl.floor : EXP_FLOOR; return v <= 0 ? 0 : Math.log(v / f) / Math.log(ctl.max / f); } const panelEl = document.getElementById("panel"); const gridEl = document.getElementById("grid"); // fader fieldsets live here; header toggles #panel.collapsed // One fieldset per topic group, in declaration order of first appearance. The "Shader" // group is special: its faders live inside the existing #editor (inside #shader-grp). const groups = new Map(); function groupFor(name) { if (!groups.has(name)) { let fs, body; if (name === "Shader") { fs = document.getElementById("shader-grp"); body = document.getElementById("editor"); // bump faders appended after the textarea } else { fs = document.createElement("fieldset"); fs.className = "grp"; const lg = document.createElement("legend"); lg.textContent = name; body = document.createElement("div"); body.className = "grid"; // reuse the two-column fader layout (class form) fs.append(lg, body); gridEl.appendChild(fs); } groups.set(name, { body, controls: [] }); } return groups.get(name); } const sliderRegistry = []; // every fader, so "Reset to defaults" can refresh them all for (const ctl of controls) { const row = document.createElement("label"); row.className = "ctl"; const name = document.createElement("span"); name.textContent = ctl.label; const input = document.createElement("input"); input.type = "range"; const val = document.createElement("span"); val.className = "val"; if (ctl.exp) { input.min = 0; input.max = 1; input.step = 0.001; } else { input.min = ctl.min; input.max = ctl.max; input.step = ctl.step; } const showVal = (v) => { val.textContent = (ctl.step < 1 ? v.toFixed(2) : String(Math.round(v))); }; const apply = (v) => { params[ctl.key] = v; if (REBUILD_KEYS.has(ctl.key)) coins = buildCoins(worldUnitsPerPixel()); // relayout mirrors live if (GEOM_KEYS.has(ctl.key)) rebuildCoinGeometry(); // reshape mirror mesh live showVal(v); saveParams(); }; input.addEventListener("input", () => { const raw = parseFloat(input.value); apply(ctl.exp ? expFromSlider(ctl, raw) : raw); }); row.append(name, input, val); groupFor(ctl.group).body.appendChild(row); sliderRegistry.push({ ctl, input, showVal }); } // Paint every fader's slider + readout from the current `params` (used at init and after reset). function refreshSliders() { for (const s of sliderRegistry) { const v = params[s.ctl.key]; s.input.value = s.ctl.exp ? sliderFromExp(s.ctl, v || 0) : v; s.showVal(v); } } refreshSliders(); // baseFreq may have been restored != default, so rebuild once to realign per-coin freqScale. coins = buildCoins(worldUnitsPerPixel()); rebuildCoinGeometry(); // apply restored corner radius + thickness /* ---- DLP source pickers + gradient controls ---------------------------- Two INDEPENDENT selects: what the mirrors REFLECT (bgSource) and what TILTS them (tiltSource). A source chosen for TILT is a control signal only — it never appears as reflected content (separate tiltTexture). Camera is live-only, so switching either picker to/from "camera" starts/stops the stream; "image" reloads the saved image. */ const bgSelect = document.getElementById("bg-select"); const tiltSelect = document.getElementById("tilt-select"); const gradRow = document.getElementById("gradient-row"); const gradDir = document.getElementById("grad-dir"); const gradAngle = document.getElementById("grad-angle"); const gradInvert = document.getElementById("grad-invert"); function needsCamera() { return params.bgSource === "camera" || params.tiltSource === "camera"; } function contentNeedsCamera(){ return params.bgSource === "camera"; } function tiltNeedsCamera() { return params.tiltSource === "camera"; } function contentNeedsImage(){ return params.bgSource === "image"; } function tiltNeedsImage() { return params.tiltSource === "image"; } // Drop any texture whose role is no longer active, so switching Reflect/Tilt away from the // camera (or image) stops showing/sampling the stale upload. The render loop re-uploads a // fresh webcam frame each tick when a role IS active; images reload via ensureImageLoaded(). function syncTextureRoles() { if (!(contentNeedsCamera() || contentNeedsImage())) contentLoaded = false; // uHasImage -> 0, checker/gradient shows if (!(tiltNeedsCamera() || tiltNeedsImage())) tiltLoaded = false; // no stale tilt signal } // Bring the DOM controls in line with `params` and reconcile the live camera stream. function syncSourceUI() { bgSelect.value = params.bgSource; tiltSelect.value = params.tiltSource; gradDir.value = params.gradientDir; gradAngle.value = params.gradientAngle; gradInvert.checked = !!params.gradientInvert; // Reveal gradient knobs whenever either picker actually uses the gradient. gradRow.classList.toggle("hidden", params.bgSource !== "gradient" && params.tiltSource !== "gradient"); syncTextureRoles(); // clear textures whose role just went inactive if (needsCamera()) { if (!videoSource) startWebcam(); } else if (videoSource) stopWebcam(); } // Reload the saved image into whichever texture its role demands. function ensureImageLoaded() { const needC = contentNeedsImage(), needT = tiltNeedsImage(); if ((needC && !contentLoaded) || (needT && !tiltLoaded)) { if (params.savedImage) loadImgFromDataURL(params.savedImage); } } bgSelect.addEventListener("change", () => { params.bgSource = bgSelect.value; ensureImageLoaded(); syncSourceUI(); saveParams(); }); tiltSelect.addEventListener("change", () => { params.tiltSource = tiltSelect.value; ensureImageLoaded(); syncSourceUI(); saveParams(); }); gradDir.addEventListener("change", () => { params.gradientDir = gradDir.value; saveParams(); }); gradAngle.addEventListener("input", () => { params.gradientAngle = +gradAngle.value; saveParams(); }); gradInvert.addEventListener("change", () => { params.gradientInvert = gradInvert.checked; saveParams(); }); const mirrorTiltCamChk = document.getElementById("mirror-tilt-cam"); mirrorTiltCamChk.checked = !!params.mirrorTiltCam; mirrorTiltCamChk.addEventListener("change", () => { params.mirrorTiltCam = mirrorTiltCamChk.checked; saveParams(); }); /* ---- View: fullscreen-by-default + keep-awake -------------------------- Fullscreen can only be entered from a user gesture, so the initial "fullscreen by default" request is armed to fire on the page's first interaction. The wake lock is requested when permitted and re-acquired whenever the tab becomes visible again. --- */ const fullscreenChk = document.getElementById("fullscreen-chk"); const awakeChk = document.getElementById("awake-chk"); let wakeLock = null; async function requestWakeLock() { if (!params.keepAwake || !('wakeLock' in navigator)) return; try { wakeLock = await navigator.wakeLock.request('screen'); } catch (e) { /* denied/unsupported -> silently stay off */ } } function releaseWakeLock() { if (wakeLock) { wakeLock.release().catch(() => {}); wakeLock = null; } } fullscreenChk.checked = !!params.fullscreen; awakeChk.checked = !!params.keepAwake; requestWakeLock(); // attempt immediately (needs permission) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') requestWakeLock(); }); fullscreenChk.addEventListener("change", () => { params.fullscreen = fullscreenChk.checked; saveParams(); }); awakeChk.addEventListener("change", () => { params.keepAwake = awakeChk.checked; saveParams(); if (params.keepAwake) requestWakeLock(); else releaseWakeLock(); }); // Arm "fullscreen by default": fire once on the first user gesture, then disarm. function armDefaultFullscreen() { if (!params.fullscreen || document.fullscreenElement) return; const go = () => { document.removeEventListener('pointerdown', go); if (params.fullscreen && !document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(() => {}); }; document.addEventListener('pointerdown', go, { once: true }); } // Reset every parameter to its pristine default and repaint all controls. document.getElementById("reset-btn").addEventListener("click", () => { Object.assign(params, JSON.parse(JSON.stringify(DEFAULTS))); saveParams(); refreshSliders(); coins = buildCoins(worldUnitsPerPixel()); rebuildCoinGeometry(); syncSourceUI(); syncShaderGroupEnabled(); fullscreenChk.checked = params.fullscreen; awakeChk.checked = params.keepAwake; mirrorTiltCamChk.checked = !!params.mirrorTiltCam; if (params.keepAwake) requestWakeLock(); else releaseWakeLock(); }); // Collapsible config box: click the header to hide/show the faders. const panelHeader = document.getElementById("panel-header"); const panelArrow = document.getElementById("panel-arrow"); function setCollapsed(c) { panelEl.classList.toggle("collapsed", c); panelArrow.textContent = c ? "▸" : "▾"; params.panelCollapsed = c; saveParams(); // Collapsing the config box goes fullscreen (immersive mirror view) ONLY when the // "Fullscreen by default" option is on; expanding exits. Header click is a user gesture, // so requestFullscreen() is allowed here. It can still be rejected (iframe policy / // non-secure ctx), which must never break toggling. if (c && params.fullscreen && !document.fullscreenElement) { document.documentElement.requestFullscreen?.().catch(() => {}); } else if (!c && document.fullscreenElement) { document.exitFullscreen?.().catch(() => {}); } } setCollapsed(!!params.panelCollapsed); // restore collapsed state panelHeader.addEventListener("click", () => setCollapsed(!panelEl.classList.contains("collapsed"))); // If the user leaves fullscreen by other means (Esc / browser chrome), re-expand the // config box so it can't get stuck hidden with no visible way back. document.addEventListener("fullscreenchange", () => { if (!document.fullscreenElement && panelEl.classList.contains("collapsed")) setCollapsed(false); }); /* ---- Shadertoy-style live fragment-shader editor ------------------------ Edit the coin fragment shader in the textarea and hit "Apply shader" (or tick "auto") to recompile + hot-swap the program. A failed compile keeps the last working shader on screen; the error text lands in both #error and the inline status line so a black canvas is never silent. ------------------- */ const editorEl = document.getElementById("shader-editor"); const applyBtn = document.getElementById("shader-apply"); const resetBtn = document.getElementById("shader-reset"); const autoChk = document.getElementById("shader-auto"); const statusEl = document.getElementById("shader-status"); const shaderEnChk = document.getElementById("shader-enable"); // master: enable the whole Shader group editorEl.value = params.shaderCode || DEFAULT_FS; // The Shader fieldset (source editor + bump faders) is inert until "Live shader" is ticked. function syncShaderGroupEnabled() { const on = !!params.shaderEnabled; shaderEnChk.checked = on; const grp = document.getElementById("shader-grp"); if (grp) grp.classList.toggle("disabled", !on); editorEl.disabled = applyBtn.disabled = resetBtn.disabled = autoChk.disabled = !on; for (const s of sliderRegistry) if (s.ctl.group === "Shader") s.input.disabled = !on; } function setShaderStatus(ok, msg) { // transient inline feedback statusEl.textContent = ok ? "OK" : "compile error"; statusEl.style.color = ok ? "#8fd08f" : "#ff9d9d"; } // Apply `src` as the coin fragment program; when reverting to default, params.shaderCode="". function applyShaderSource(src) { try { const p = createProgram(gl, QUAD_VS_SOURCE, src); // throws on compile/link error gl.deleteProgram(coinProgram); // swap in the new program coinProgram = p; coinLoc = getLocations(p); // rebind cached uniform locations params.shaderCode = (src === DEFAULT_FS) ? "" : src; saveParams(); setShaderStatus(true, ""); } catch (err) { showError("Shader not applied: " + err.message); // keep the previous program setShaderStatus(false, err.message); } } // Toggling the group on recompiles the saved source; off restores the built-in default. shaderEnChk.addEventListener("change", () => { params.shaderEnabled = shaderEnChk.checked; saveParams(); applyShaderSource(params.shaderEnabled ? (params.shaderCode || DEFAULT_FS) : DEFAULT_FS); syncShaderGroupEnabled(); }); applyBtn.addEventListener("click", () => applyShaderSource(editorEl.value)); resetBtn.addEventListener("click", () => { editorEl.value = DEFAULT_FS; applyShaderSource(DEFAULT_FS); }); editorEl.addEventListener("input", () => { if (params.shaderEnabled && autoChk.checked) applyShaderSource(editorEl.value); }); /* ---- Touch/drag "bend": swipe on a mirror to tilt just THAT mirror, like lightly pushing one leaf of a membrane. The grab point is projected onto the z=0 grid plane and the nearest mirror (within one diameter) is picked; it tilts about the axis perpendicular to the push. Response saturates smoothly at bendAmount so it feels gentle, and relaxes back to 0 when released. ---- */ let lastFrameT = 0; const bendDrag = { active: false, ox: 0, oy: 0, coin: null }; // Screen (CSS px) -> world point on the z=0 grid plane. The camera is axis-aligned // and looks straight down -Z (no roll/pitch), so this is plain similar-triangles // unprojection — no general mat4 inverse needed. worldUnitsPerPixel() gives the world // size of one CSS pixel at z=0; NDC X is right, screen Y is top-down (so Y flips). function screenToGridWorld(cssX, cssY) { const rect = canvas.getBoundingClientRect(); const upp = worldUnitsPerPixel(); // world units per CSS px at z=0 const wx = ((cssX - rect.left) / rect.width - 0.5) * rect.width * upp; const wy = (0.5 - (cssY - rect.top) / rect.height) * rect.height * upp; return [wx, wy]; // on the z=0 grid plane } // Nearest mirror to a world point, or null if the tap is further than one diameter away. function pickCoin(wx, wy) { const maxDist = (params.diameterPx * worldUnitsPerPixel()) / 2; // mirror radius (world) let best = null, bestD = Infinity; for (const c of coins) { const d = Math.hypot(c.offset[0] - wx, c.offset[1] - wy); if (d < bestD) { bestD = d; best = c; } } return bestD <= maxDist ? best : null; } canvas.addEventListener("pointerdown", (e) => { const g = screenToGridWorld(e.clientX, e.clientY); bendDrag.active = true; bendDrag.coin = pickCoin(g[0], g[1]); // only the touched mirror bends bendDrag.ox = e.clientX; bendDrag.oy = e.clientY; canvas.setPointerCapture(e.pointerId); // keep receiving moves off-canvas }); canvas.addEventListener("pointermove", (e) => { if (!bendDrag.active || !bendDrag.coin) return; const dx = e.clientX - bendDrag.ox, dy = e.clientY - bendDrag.oy; // CSS px drag vector const len = Math.hypot(dx, dy); if (len < 0.5) return; const maxRad = params.bendAmount * Math.PI / 180; // gentle saturation cap // Push direction in world space: +X right, +Y up (screen Y is inverted). A rightward // push leans the mirror's near edge away (+Y rot); a downward push tips its top back // (+X rot). tanh gives a soft "lightly touching" ramp that eases into maxRad. const bend = maxRad * Math.tanh(len / 150); bendDrag.coin.bendY = (dx / len) * bend; bendDrag.coin.bendX = (-dy / len) * bend; // set live; relaxes on release }); function endBend() { bendDrag.active = false; bendDrag.coin = null; } canvas.addEventListener("pointerup", endBend); canvas.addEventListener("pointercancel", endBend); function render(nowMs) { resize(); const time = nowMs * 0.001; // seconds const dt = Math.min(0.05, (nowMs - lastFrameT) * 0.001 || 0); // clamp big gaps (tab switches) lastFrameT = nowMs; // Recompute mutable scene state from the live fader params each frame. cameraPos[2] = params.camZ; imagePlanePoint[2] = params.camZ + params.planeDistance; // plane stays behind the camera const swingAmp = params.swingAmpDeg * Math.PI / 180; // radians // On-screen mirror size: scale the unit (diameter=2) coin so its diameter matches // diameterPx pixels (world diameter = coinScale*COIN_SIZE = diameterPx*upp). const coinScale = (params.diameterPx * worldUnitsPerPixel()) / COIN_SIZE; // Grid extents (world) so a mirror's offset maps to normalized uv in [0,1] for tilt sampling. let gxMin = 0, gxMax = 1, gyMin = 0, gyMax = 1; if (coins.length) { gxMin = gxMax = coins[0].offset[0]; gyMin = gyMax = coins[0].offset[1]; for (const c of coins) { if (c.offset[0] < gxMin) gxMin = c.offset[0]; if (c.offset[0] > gxMax) gxMax = c.offset[0]; if (c.offset[1] < gyMin) gyMin = c.offset[1]; if (c.offset[1] > gyMax) gyMax = c.offset[1]; } } const spanX = (gxMax - gxMin) || 1, spanY = (gyMax - gyMin) || 1; const tiltBySource = params.tiltSource !== "function"; // else keep the classic swing/random tilt const aspect = canvas.width / canvas.height; const projection = mat4Perspective(Math.PI / 4, aspect, 0.1, 100.0); const view = mat4Translate(-cameraPos[0], -cameraPos[1], -cameraPos[2]); // --- Background sky: FLAT grey only (content is seen via reflection, not here) --- gl.disable(gl.DEPTH_TEST); // always behind everything gl.depthMask(false); gl.useProgram(bgProgram); gl.uniform1f(bgLoc.brightness, params.bgBrightness); const bgModeVal = bgMode(); // mirror CONTENT mode (grey|checker|gradient) for coin-fs gl.bindBuffer(gl.ARRAY_BUFFER, bgPosBuf); gl.enableVertexAttribArray(bgLoc.aPosition); gl.vertexAttribPointer(bgLoc.aPosition, 3, gl.FLOAT, false, 0, 0); gl.drawArrays(gl.TRIANGLES, 0, 3); glErrAfterBg = glErrText(gl); // isolate: is the error from bg or coins? // --- Mirror coins --- gl.enable(gl.DEPTH_TEST); gl.depthMask(true); gl.useProgram(coinProgram); // Push the newest webcam frame to whichever texture(s) the camera role demands. uploadWebcamFrame(); // ONE GPU->CPU readback of the tilt texture per frame (mirrors then sample from RAM). if (params.tiltSource === "image" || params.tiltSource === "camera") refreshLumCache(); gl.uniformMatrix4fv(coinLoc.projection, false, projection); gl.uniform3fv(coinLoc.cameraPos, cameraPos); gl.uniform3fv(coinLoc.planePoint, imagePlanePoint); gl.uniform3fv(coinLoc.planeNormal, IMAGE_PLANE_NORMAL); gl.uniform2f (coinLoc.planeHalfSize, planeHalfWidth, params.imageHalfHeight); gl.uniform4fv(coinLoc.uvBox, uvBox); // uHasImage is the CONTENT texture only; a camera/image used purely for tilt never sets it. gl.uniform1i (coinLoc.hasImage, contentLoaded ? 1 : 0); gl.uniform1f (coinLoc.bgBrightness, params.bgBrightness); // mirrors reflect bg where no image gl.uniform1i (coinLoc.bgMode, bgModeVal); // content fallback: grey|checker|gradient gl.uniform1i (coinLoc.gradRadial, params.gradientDir === "radial" ? 1 : 0); gl.uniform1f (coinLoc.gradAngle, params.gradientAngle * Math.PI / 180); gl.uniform1f (coinLoc.gradInvert, params.gradientInvert ? 1 : 0); gl.uniform3fv(coinLoc.goldColor, GOLD_COLOR); gl.uniform1f (coinLoc.goldStrength, params.goldStrength); gl.uniform1f (coinLoc.bumpStrength, params.bumpStrength); gl.uniform1f (coinLoc.bumpScale, params.bumpScale); gl.uniform1f (coinLoc.time, time); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, imageTexture); gl.uniform1i (coinLoc.image, 0); // uImage samples texture unit 0 // Bind coin geometry once; only the model matrix changes per coin. gl.bindBuffer(gl.ARRAY_BUFFER, coinPosBuf); gl.enableVertexAttribArray(coinLoc.aPosition); gl.vertexAttribPointer(coinLoc.aPosition, 3, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, coinNormBuf); gl.enableVertexAttribArray(coinLoc.aNormal); gl.vertexAttribPointer(coinLoc.aNormal, 3, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, coinIdxBuf); for (const c of coins) { // Touch bend relaxes exponentially toward 0 once the drag is released. if (!bendDrag.active && (c.bendX || c.bendY)) { const k = Math.exp(-dt / 0.15); // ~0.15s settle time constant c.bendX *= k; c.bendY *= k; if (Math.abs(c.bendX) < 1e-4) c.bendX = 0; if (Math.abs(c.bendY) < 1e-4) c.bendY = 0; } // Tilt: either the classic time-function (per-coin random base + swing), or a DLP // response driven by the sampled luminance of the chosen tilt source at this cell. let tiltX, tiltY; if (tiltBySource) { const u = spanX ? (c.offset[0] - gxMin) / spanX : 0.5; const v = spanY ? (c.offset[1] - gyMin) / spanY : 0.5; const lum = sampleLuminance(params.tiltSource, u, v); // Response between smooth (proportional) and binary (hard ON/OFF at threshold): // r=0 -> continuous ramp about the threshold; r=1 -> hard step. const x = lum - params.tiltThreshold; const soft = Math.max(-1, Math.min(1, x / 0.5)); // linear across +/-0.5 luminance const sharp = x > 0 ? 1 : (x < 0 ? -1 : 0); // binary DMD flip const resp = soft + params.dlpBinary * (sharp - soft); // mix(soft, sharp, dlpBinary) tiltY = params.tiltSpread * resp; // yaw about Y: +/- tiltSpread tiltX = 0; // source-driven tilt is yaw-only } else { const swing = swingAmp * Math.sin(2 * Math.PI * params.baseFreq * c.freqScale * time + c.phase); tiltY = c.tiltY * (params.tiltSpread / 0.35) + swing; tiltX = c.tiltX * (params.tiltSpread / 0.35); } // Bend is applied in LOCAL axes (added inside the rotation), so a rightward push // always leans that mirror's near edge away regardless of its base yaw. const rotation = mat4Multiply( mat4RotateY(tiltY + (c.bendY || 0)), mat4RotateX(tiltX + (c.bendX || 0)) ); // XY scaled by the on-screen size; Z scaled only by thickness so the slab depth // is independent of mirror diameter (geometry already stores half-thickness). const model = mat4Multiply( mat4Translate(c.offset[0], c.offset[1], c.offset[2]), mat4Multiply(rotation, mat4ScaleZ(coinScale, coinScale)) ); const modelView = mat4Multiply(view, model); gl.uniformMatrix4fv(coinLoc.modelView, false, modelView); // Normal basis = the coin's pure ROTATION (no translation). This is what // makes vNormal actually tilt with the mirror; using identity here made the // reflection direction independent of orientation -> "painted-on" look. gl.uniformMatrix4fv(coinLoc.normalModel, false, rotation); gl.drawElements(gl.TRIANGLES, coin.indices.length, gl.UNSIGNED_SHORT, 0); } // --- Debug HUD: are the coins actually being drawn? What does GL say? --- const texInfo = `content=${contentLoaded ? "img" : "-"} tiltSig=${tiltLoaded ? "yes" : "-"}`; hudEl.value = `coins=${coins.length} idx=${coin.indices.length} posVerts=${coin.positions.length / 3}\n` + `glErr(after bg)=${glErrAfterBg} glErr(after coins)=${glErrText(gl)}\n` + `${texInfo} content=${params.bgSource} tilt=${params.tiltSource} thr=${params.tiltThreshold.toFixed(2)} bin=${params.dlpBinary.toFixed(2)}\n` + `diam=${params.diameterPx}px gap=${params.gapPx}px area=${params.areaW}x${params.areaH}% bg=${params.bgBrightness.toFixed(2)} gold=${params.goldStrength.toFixed(2)} thick=${params.mirrorThickness.toFixed(2)} corner=${params.cornerRadius.toFixed(2)} upp=${worldUnitsPerPixel().toFixed(3)}\n` + `bump=${params.bumpStrength.toFixed(2)}@${params.bumpScale.toFixed(1)} shader=${params.shaderCode ? "edited" : "default"}\n` + `planeZ=${imagePlanePoint[2].toFixed(0)} halfH=${params.imageHalfHeight.toFixed(0)} camZ=${params.camZ.toFixed(0)}`; requestAnimationFrame(render); } requestAnimationFrame(render); /* ---- Restore the last source(s) after a page refresh --------------------- syncSourceUI() reconciles the pickers + starts/stops the camera per params; for an "image" role we (re)load the saved dataURL into the content and/or tilt texture. */ ensureImageLoaded(); syncSourceUI(); syncShaderGroupEnabled(); // reflect the (default off) shader group armDefaultFullscreen(); // enter fullscreen on first gesture if enabled // Best-effort camera list. Labels/deviceIds need permission, so this prompts once on a // secure context; on file:// or when denied it fails silently and the picker stays empty // (startWebcam then falls back to facingMode:"user", i.e. the front camera). populateCameras(); </script> </body> </html>