feat: strengthen design skill and refresh all showcases
Some checks failed
ci / build (push) Has been cancelled

This commit is contained in:
Yun Chan 2026-09-12 18:16:33 +09:00
parent 69e5cc1efd
commit e09d4efb1c
257 changed files with 8196 additions and 2374 deletions

View file

@ -3,15 +3,15 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lumina Optics · Refraction Bench</title>
<title>Lumina Optics · Interactive Optical Study</title>
<meta
name="description"
content="A WebGL bench where a single beam meets a prism: refraction and dispersion computed per frame. Drag the prism, change the index."
content="An interactive optical study of refraction and dispersion. The lens profile, curves, and specifications are illustrative."
/>
<meta property="og:title" content="Lumina Optics · Refraction Bench" />
<meta property="og:title" content="Lumina Optics · Interactive Optical Study" />
<meta
property="og:description"
content="One beam, one prism, per-frame refraction."
content="An illustrative light path you can inspect and adjust."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#101214" />
@ -22,7 +22,7 @@
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Sans+KR:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
/>
<link rel="stylesheet" href="./styles/main.css?v=2" />
<link rel="stylesheet" href="./styles/main.css?v=20260912all" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23101214'/%3E%3Cpath d='M6 16h8m8 0h4M14 16l10-7v14z' fill='none' stroke='%23BFE8EE' stroke-width='1.4'/%3E%3C/svg%3E"
@ -45,40 +45,54 @@
</header>
<main id="main">
<!--
The bench. The product is light behaviour, so the hero IS the instrument:
one beam, one prism, per-frame refraction. Drag rotates; sliders change
the index and the dispersion — they are the two honest knobs a bench
has. Everything degrades to a static photograph.
-->
<section id="bench" class="hero" aria-labelledby="hero-h">
<div class="bench-stage reveal">
<canvas
id="gl"
aria-label="Interactive prism: a white beam enters from the left and exits as a spectrum. Drag to rotate the prism."
></canvas>
<img
class="bench-fallback"
src="./assets/prism-bench.webp"
alt="A glass prism on a dark optical breadboard splitting one collimated beam into a spectrum"
width="1440"
height="810"
/>
</div>
<div class="wrap hero-inner">
<div class="wrap hero-summary">
<div class="hero-text">
<p class="hero-kicker reveal">Refraction bench · WebGL</p>
<p class="hero-kicker reveal">Interactive optical study</p>
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
One beam,<br />bent honestly
</h1>
<p class="lead reveal" style="--delay: 120ms">
No photograph on this screen is doing the work — the prism is
being refracted right now, per frame, on your GPU. Drag it. Change
the index. The spectrum you see is the same arithmetic lens
designers use, minus the glasses.
Move the prism to compare how refractive index and dispersion
redirect one beam. This view illustrates an optical model; it is
not a measured photograph, a product specification, or a lab result.
</p>
</div>
<div class="hud reveal" style="--delay: 160ms">
</div>
<div class="wrap bench-layout">
<div class="bench-shell">
<div class="bench-stage reveal">
<canvas
id="gl"
aria-label="Illustrative interactive prism: a white beam enters from the left and exits as a spectrum. Drag to rotate the concept model."
></canvas>
<img
class="bench-fallback"
src="./assets/prism-bench.webp"
alt="Illustrative glass prism on a dark optical surface splitting one collimated beam into a spectrum"
width="1440"
height="810"
/>
</div>
</div>
<aside class="bench-controls">
<div class="bench-ledger" role="group" aria-label="Bench configuration">
<dl>
<div>
<dt>illustrative beam reference</dt>
<dd>632.8 nm · collimated</dd>
</div>
<div>
<dt>variable</dt>
<dd>index + dispersion</dd>
</div>
<div>
<dt>view</dt>
<dd>interactive light study · static reference</dd>
</div>
</dl>
</div>
<div class="hud reveal" style="--delay: 160ms">
<div class="hud-row">
<div class="hud-row-head"><label for="ior">Index of refraction</label><output id="ior-out" for="ior">1.52</output></div>
<input type="range" id="ior" min="1.45" max="2.20" step="0.01" value="1.52" />
@ -87,56 +101,48 @@
<div class="hud-row-head"><label for="disp">Dispersion (Abbe ↓)</label><output id="disp-out" for="disp">0.18</output></div>
<input type="range" id="disp" min="0" max="0.6" step="0.01" value="0.18" />
</div>
<p class="hud-note" id="hud-note">Drag the prism to rotate · beam is collimated at 632.8 nm reference</p>
</div>
<p class="hud-note" id="hud-note">Drag the prism to rotate · illustrative light path at a 632.8 nm reference</p>
</div>
</aside>
</div>
</section>
<!--
Anatomy — cut lens photography. Real glass, not renders.
-->
<section id="anatomy" class="section" aria-labelledby="anatomy-h">
<div class="wrap two">
<figure class="two-photo reveal">
<img
src="./assets/lens-section.webp"
alt="Cutaway of a precision lens: a stack of glass elements in a metal barrel, edge-lit so each surface shows a refraction line"
alt="Illustrative lens cutaway: a stack of glass elements in a metal barrel, edge-lit to show refraction paths"
width="900"
height="1200"
loading="lazy"
/>
<figcaption>L-85 cutaway · 7 elements in 6 groups</figcaption>
<figcaption>L-85 concept cutaway · illustrative element layout</figcaption>
</figure>
<div class="two-text">
<h2 id="anatomy-h">Glass decides<br />before software does</h2>
<h2 id="anatomy-h">Glass sets<br />the light path</h2>
<p>
A lens is a chain of refractions, and every surface answers before
any firmware runs. Our L-85 uses two low-dispersion elements to
keep the spectrum you just dragged from fanning out across the
frame — dispersion corrected in glass, not in a profile.
A lens is a chain of refractions: every surface bends the path
before an image is made. The L-85 concept uses low-dispersion
elements to explain why a design can keep colours from spreading
across the frame.
</p>
<p class="muted">
The cutaway above is a physical section, milled for measurement.
Element count and spacing are measured from it, not from the
drawing.
The cutaway is a constructed reference image. Its element count
and spacing are illustrative, not a measurement of a commercial lens.
</p>
</div>
</div>
</section>
<!--
MTF — the only curve that argues. Static, thin, honest:
measured at infinity, full aperture, both field directions.
-->
<section id="mtf" class="section band" aria-labelledby="mtf-h">
<div class="wrap mtf-grid">
<div class="mtf-text">
<h2 id="mtf-h">MTF, not adjectives</h2>
<h2 id="mtf-h">MTF, read with conditions</h2>
<p>
“Tack sharp” is not a specification. Contrast transfer at given
line pairs per millimetre is. These curves are measured on an
optical bench at full aperture — sagittal and meridional, out to
the corner.
Contrast transfer at stated line pairs per millimetre gives a
clearer comparison than a slogan. These curves illustrate the
labels a measured chart needs; they are not optical-bench results.
</p>
<ul class="mtf-legend">
<li><span class="key key-solid" aria-hidden="true"></span>10 lp/mm · sagittal</li>
@ -146,7 +152,7 @@
</ul>
</div>
<figure class="mtf-plot reveal">
<svg viewBox="0 0 560 320" role="img" aria-label="MTF curves: contrast stays above 0.85 at 10 lp/mm across the field; at 30 lp/mm it falls from 0.75 at center to 0.42 at the corner.">
<svg viewBox="0 0 560 320" role="img" aria-label="Illustrative MTF curves showing how contrast can be compared from the centre to the corner; this is not a measured result.">
<g class="chart-grid">
<line x1="48" y1="16" x2="48" y2="272" />
<line x1="48" y1="272" x2="536" y2="272" />
@ -166,76 +172,62 @@
<text x="292" y="292" text-anchor="middle">image field (mm)</text>
<text x="536" y="292" text-anchor="end">corner 21.6</text>
</g>
<!-- 10 lp/mm sagittal: high, gentle fall -->
<polyline class="mtf mtf-10s" points="48,32 172,36 296,44 420,60 536,76" />
<!-- 10 lp/mm meridional -->
<polyline class="mtf mtf-10m" points="48,34 172,40 296,52 420,72 536,96" />
<!-- 30 lp/mm sagittal -->
<polyline class="mtf mtf-30s" points="48,84 172,96 296,120 420,160 536,196" />
<!-- 30 lp/mm meridional -->
<polyline class="mtf mtf-30m" points="48,90 172,108 296,140 420,186 536,232" />
</svg>
<figcaption>L-85 · f/1.8 · infinity focus · bench-measured 2026-07</figcaption>
<figcaption>L-85 concept · f/1.8 · infinity focus · illustrative curve</figcaption>
</figure>
</div>
</section>
<!--
Spec — with conditions.
-->
<section class="section" aria-labelledby="spec-h">
<div class="wrap">
<h2 id="spec-h" class="reveal">Specification</h2>
<h2 id="spec-h" class="reveal">Reference specification</h2>
<div class="table-wrap reveal" style="--delay: 80ms">
<table>
<caption class="sr-only">L-85 specification — item, value, condition</caption>
<caption class="sr-only">L-85 concept specification — illustrative item, value, and condition</caption>
<thead>
<tr>
<th scope="col">Item</th>
<th scope="col" class="num">Value</th>
<th scope="col" class="num">Illustrative value</th>
<th scope="col">Condition</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">Focal length</th><td class="num">85 mm</td><td>nominal · ±1.5% at infinity</td></tr>
<tr><th scope="row">Aperture</th><td class="num">f/1.8 – f/16</td><td>9-blade · near-circular</td></tr>
<tr><th scope="row">Elements</th><td class="num">7 in 6</td><td>2 LD · measured from cutaway</td></tr>
<tr><th scope="row">Close focus</th><td class="num">0.62 m</td><td>1:7.2 magnification</td></tr>
<tr><th scope="row">Filter</th><td class="num">Ø 62 mm</td><td>brass, front-threaded</td></tr>
<tr><th scope="row">Weight</th><td class="num">438 g</td><td>without caps, with hood</td></tr>
<tr><th scope="row">Focal length</th><td class="num">85 mm</td><td>concept reference</td></tr>
<tr><th scope="row">Aperture</th><td class="num">f/1.8 – f/16</td><td>concept reference</td></tr>
<tr><th scope="row">Elements</th><td class="num">7 in 6</td><td>illustrative layout</td></tr>
<tr><th scope="row">Close focus</th><td class="num">0.62 m</td><td>illustrative configuration</td></tr>
<tr><th scope="row">Filter</th><td class="num">Ø 62 mm</td><td>illustrative configuration</td></tr>
<tr><th scope="row">Weight</th><td class="num">438 g</td><td>illustrative configuration</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Contact -->
<section id="contact" class="section band contact" aria-labelledby="contact-h">
<div class="wrap contact-grid">
<h2 id="contact-h">Bench visits</h2>
<h2 id="contact-h">About this study</h2>
<p>
We measure loaner samples for free and publish the curves —
manufacturers welcome or not. Bench time and MTF requests:
This is a concept page for reading an optical model. It does not
schedule sample loans, measurements, or MTF requests.
</p>
<a class="btn" href="mailto:bench@lumina.optics">bench@lumina.optics</a>
<a class="btn" href="#bench">Return to the light study</a>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>lumina optics</p>
<p class="foot-meta">measurements reproducible on request · bench cal. 2026-06</p>
<p>lumina optics · optical study</p>
<p class="foot-meta">illustrative model · no commercial measurement service</p>
</div>
</footer>
<script type="module">
/*
Refraction bench — three.js, one prism, two honest uniforms.
Budget note: three.module.js ≈ 165KB gzip. 예산 상향 — 이 페이지의
제품이 WebGL 자체다(데모 브리프 예산 상향 허용).
폴백: WebGL 실패 시 사진이 보이고 캔버스는 뜨지 않는다(이미 마크업에 있음).
*/
const canvas = document.getElementById("gl");
const fallback = document.querySelector(".bench-fallback");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
@ -243,32 +235,30 @@
let gl = null;
try {
gl = canvas.getContext("webgl2", { antialias: true, alpha: true });
} catch (e) { /* fall through */ }
} catch {}
if (!gl) {
if (!gl || reduced) {
canvas.remove();
document.getElementById("hud-note").textContent =
"WebGL unavailable — showing the bench photograph instead.";
reduced
? "Motion reduced — showing a static reference image instead."
: "Interactive view unavailable — showing a static reference image instead.";
} else {
fallback.remove();
const THREE = await import("https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js");
const renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: true, alpha: true });
renderer.setClearColor(0x000000, 0);
const DPR = Math.min(devicePixelRatio || 1, 1.5);
renderer.setPixelRatio(DPR);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 50);
camera.position.set(0, 0, 9);
// ----- prism: triangular cross-section (CylinderGeometry, 3 segments)
const prismGeo = new THREE.CylinderGeometry(2.1, 2.1, 2.6, 3, 1, false);
prismGeo.rotateX(Math.PI / 2); // axis toward camera
prismGeo.rotateZ(Math.PI / 2); // flat edge up to the beam
prismGeo.rotateX(Math.PI / 2);
prismGeo.rotateZ(Math.PI / 2);
const uniforms = {
uTime: { value: 0 },
uIor: { value: 1.52 },
uDisp: { value: 0.18 },
};
@ -276,7 +266,7 @@
const prismMat = new THREE.ShaderMaterial({
uniforms,
transparent: true,
vertexShader: /* glsl */`
vertexShader: `
varying vec3 vN;
varying vec3 vView;
void main() {
@ -285,44 +275,31 @@
vView = normalize(-mv.xyz);
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: /* glsl */`
fragmentShader: `
precision highp float;
varying vec3 vN;
varying vec3 vView;
uniform float uIor;
uniform float uDisp;
// wavelength ramp: violet → cyan → yellow → red
vec3 spectrum(float t) {
t = clamp(t, 0.0, 1.0);
return clamp(
vec3(
smoothstep(0.30, 0.00, t) + smoothstep(0.70, 1.00, t),
smoothstep(0.00, 0.35, t) - smoothstep(0.60, 1.00, t),
smoothstep(0.00, 0.30, t) - smoothstep(0.45, 0.75, t)
), 0.0, 1.0);
}
void main() {
float ndv = abs(dot(normalize(vN), normalize(vView)));
// fresnel edge (schlick): rim is where refraction lives
float f = pow(1.0 - ndv, 3.0);
// refraction deflection grows with index
float bend = (uIor - 1.0) * ndv;
// dispersion: split position across the rim into wavelengths
float d = uDisp * bend;
vec3 col = spectrum(0.5 + (1.0 - ndv) * (0.6 + d * 4.0));
// glass body: near-black with faint interior glow
vec3 body = vec3(0.02, 0.025, 0.03) * (0.4 + ndv);
vec3 rim = col * f * 1.35;
float alpha = clamp(f * 0.95 + 0.06, 0.0, 1.0);
vec3 body = vec3(0.035, 0.082, 0.094) * (0.7 + ndv * 0.65);
vec3 rim = vec3(0.22, 0.70, 0.75) * (0.24 + f * 0.92);
float alpha = clamp(0.18 + f * 0.62, 0.0, 0.82);
gl_FragColor = vec4(body + rim, alpha);
}`,
side: THREE.DoubleSide,
});
const prism = new THREE.Mesh(prismGeo, prismMat);
scene.add(prism);
const prismEdges = new THREE.LineSegments(
new THREE.EdgesGeometry(prismGeo, 8),
new THREE.LineBasicMaterial({ color: 0xbfe8ee, transparent: true, opacity: 0.78 }),
);
prismEdges.renderOrder = 2;
const prismGroup = new THREE.Group();
prismGroup.add(prism, prismEdges);
scene.add(prismGroup);
// ----- beam: incoming line + dispersed fan (additive planes)
const beamGroup = new THREE.Group();
const mkBeam = (w, h, color1, color2, op) => {
const m = new THREE.Mesh(
@ -345,24 +322,35 @@
);
return m;
};
const beamIn = mkBeam(9, 0.10, 0xdfe8ec, 0xffffff, 0.55);
beamIn.position.set(0, 1.45, -0.2);
const beamIn = mkBeam(4.1, 0.12, 0xdfe8ec, 0xffffff, 0.92);
beamIn.position.set(-3.05, 0, -0.34);
beamGroup.add(beamIn);
const fan = mkBeam(9, 1.7, 0x8fd8e8, 0xf2b060, 0.30);
fan.position.set(-0.4, 0.35, -0.25);
fan.rotation.z = 0.085;
beamGroup.add(fan);
const spectrumRays = [0x7379e8, 0x62c9e8, 0x89dbaa, 0xf0da72, 0xee9866].map((color) => {
const ray = mkBeam(3.9, 0.075, color, color, 0.92);
beamGroup.add(ray);
return ray;
});
scene.add(beamGroup);
// ----- interaction
let dragging = false, lastX = 0, vel = 0.0016, rot = 0;
function updateSpectrum() {
const spread = 0.11 + uniforms.uDisp.value * 0.56;
const bend = (uniforms.uIor.value - 1.45) * 0.26;
spectrumRays.forEach((ray, i) => {
const offset = i / (spectrumRays.length - 1) - 0.5;
ray.position.set(2.75, offset * spread * 2.4 + bend, -0.38);
ray.rotation.z = offset * spread * 0.76;
});
}
updateSpectrum();
let dragging = false, lastX = 0, rot = -0.08;
canvas.addEventListener("pointerdown", (e) => { dragging = true; lastX = e.clientX; canvas.setPointerCapture(e.pointerId); });
canvas.addEventListener("pointermove", (e) => {
if (!dragging) return;
const dx = e.clientX - lastX;
lastX = e.clientX;
rot += dx * 0.006;
vel = dx * 0.0006;
renderFrame();
});
const end = () => (dragging = false);
canvas.addEventListener("pointerup", end);
@ -377,38 +365,32 @@
el.addEventListener("input", () => {
uni.value = parseFloat(el.value);
out.textContent = fmt(uni.value);
updateSpectrum();
renderFrame();
});
bind(iorEl, iorOut, uniforms.uIor, (v) => v.toFixed(2));
bind(dispEl, dispOut, uniforms.uDisp, (v) => v.toFixed(2));
// ----- sizing
const stage = canvas.parentElement;
function fit() {
const r = stage.getBoundingClientRect();
const h = Math.max(320, Math.min(r.height, 760));
const aspect = r.width / h;
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, aspect < 1.25 ? 1.25 : 1.5));
renderer.setSize(r.width, h, false);
camera.aspect = r.width / h;
camera.aspect = aspect;
camera.position.z = aspect < 1.25 ? 15.5 : 9;
camera.updateProjectionMatrix();
renderFrame();
}
fit();
new ResizeObserver(fit).observe(stage);
// ----- loop: paused offscreen, static under reduced motion
let visible = true, t0 = performance.now();
const io = new IntersectionObserver((en) => { visible = en[0].isIntersecting; });
io.observe(canvas);
function frame() {
if (!visible) { requestAnimationFrame(frame); return; }
const t = (performance.now() - t0) / 1000;
uniforms.uTime.value = t;
if (!dragging) { vel *= 0.98; rot += Math.max(0.0016, Math.abs(vel)) * Math.sign(vel || 1); }
prism.rotation.z = rot;
beamGroup.rotation.z = Math.sin(t * 0.4) * 0.01;
function renderFrame() {
prismGroup.rotation.z = rot;
renderer.render(scene, camera);
if (!reduced) requestAnimationFrame(frame);
}
frame(); // reduced-motion: renders exactly one static frame
renderFrame();
}
const io2 = new IntersectionObserver(