import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { MeshSurfaceSampler } from "three/addons/math/MeshSurfaceSampler.js";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
const MODEL = "https://cdn.jsdelivr.net/gh/KhronosGroup/glTF-Sample-Assets@2bac6f8c57bf471df0d2a1e8a8ec023c7801dddf/Models/Lantern/glTF-Binary/Lantern.glb";
{ id: "lantern", label: "Lantern", sf: null },
{ id: "orb", label: "Orb", sf: { m1: 6, n11: 2.4, n12: 1.1, n13: 1.1, m2: 6, n21: 2.4, n22: 1.1, n23: 1.1 } },
{ id: "bloom", label: "Bloom", sf: { m1: 8, n11: 0.5, n12: 1.7, n13: 1.7, m2: 8, n21: 0.5, n22: 1.7, n23: 1.7 } },
{ id: "shell", label: "Shell", sf: { m1: 3, n11: 0.7, n12: 0.6, n13: 3.2, m2: 6, n21: 1.6, n22: 0.8, n23: 0.8 } },
{ id: "spike", label: "Spike", sf: { m1: 12, n11: 0.3, n12: 1, n13: 1, m2: 12, n21: 0.3, n22: 1, n23: 1 } }
const sfRadius = (angle, m, n1, n2, n3) => {
const r = Math.abs(Math.cos(t)) ** n2 + Math.abs(Math.sin(t)) ** n3;
const out = r === 0 ? 0 : r ** (-1 / n1);
return Number.isFinite(out) ? Math.min(out, 6) : 0;
function sfPositions(sf, out) {
for (let v = 0; v <= SEG_V; v++) {
const lat = -Math.PI / 2 + Math.PI * v / SEG_V;
const r2 = sfRadius(lat, sf.m2, sf.n21, sf.n22, sf.n23);
const cy = Math.cos(lat);
for (let u = 0; u <= SEG_U; u++) {
const lon = -Math.PI + 2 * Math.PI * u / SEG_U;
const r1 = sfRadius(lon, sf.m1, sf.n11, sf.n12, sf.n13);
const x = r1 * Math.cos(lon) * r2 * cy;
const y = r2 * Math.sin(lat);
const z = r1 * Math.sin(lon) * r2 * cy;
max = Math.max(max, Math.abs(x), Math.abs(y), Math.abs(z));
const row = (SEG_U + 1) * 3;
for (let v = 0; v <= SEG_V; v++) {
const k = max > 0 ? FIT / 2.9 / max : 1;
for (let j = 0; j < out.length; j++) out[j] *= k;
const idx = new Uint32Array(SEG_U * SEG_V * 6);
for (let v = 0; v < SEG_V; v++) {
for (let u = 0; u < SEG_U; u++) {
const a = v * (SEG_U + 1) + u;
const expoOut = (t) => t >= 1 ? 1 : 1 - 2 ** (-10 * t);
{ id: "shaded", label: "Shaded" },
{ id: "wire", label: "Wire" },
{ id: "points", label: "Points" }
{ id: "studio", label: "Studio", mat: { color: 15921906, roughness: 0.52, metalness: 0.05 } },
{ id: "chrome", label: "Chrome", mat: { color: 16777215, roughness: 0.06, metalness: 1 } },
{ id: "wax", label: "Wax", mat: { color: 15263976, roughness: 0.95, metalness: 0, sheen: 1 } },
{ id: "ink", label: "Ink", mat: { color: 1842204, roughness: 0.22, metalness: 0.55 } }
const STYLE_ID = "exp-three-style";
font-family: var(--font-sans);
--three-accent: var(--ink);
gap: var(--s-12,0.75rem);
font-family: var(--font-mono);
font-size: var(--fs-mono-meta,0.75rem);
text-transform: uppercase;
transition: opacity 0.4s ease;
.tj[data-ready] .tj__loading {
inset-block-start: var(--s-16,1rem);
max-inline-size: calc(100% - var(--s-32,2rem));
height: var(--s-40,2.5rem);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md,6px);
box-shadow: var(--shadow-control,0 1px 3px rgba(30,30,30,0.06));
inset-block: var(--s-2,2px);
inset-inline-start: var(--s-2,2px);
border-radius: var(--r-sm,4px);
box-shadow: var(--shadow-seg,0 1px 2px rgba(30,30,30,0.05));
transition: transform 0.4s var(--ease-out-expo,cubic-bezier(0.16,1,0.3,1)), width 0.4s var(--ease-out-expo,cubic-bezier(0.16,1,0.3,1));
padding-inline: var(--s-12,0.75rem);
font-family: var(--font-mono);
font-size: var(--fs-mono-meta,0.75rem);
text-transform: uppercase;
transition: color 0.3s var(--ease-out-quad,cubic-bezier(0.5,1,0.89,1)),scale 0.15s ease;
.tj__seg[aria-pressed="true"] {
outline: 2px solid var(--ink);
border-radius: var(--r-sm,4px);
@media (max-width: 40rem) {
padding-inline: var(--s-8,0.5rem);
@media (prefers-reduced-motion: reduce) {
if (document.getElementById(STYLE_ID)) return;
const el = document.createElement("style");
document.head.appendChild(el);
function makeBaker(renderer) {
const pmrem = new THREE.PMREMGenerator(renderer);
const envRT = pmrem.fromScene(new RoomEnvironment(), 0.04);
const scene = new THREE.Scene();
scene.environment = envRT.texture;
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 10);
const geo = new THREE.SphereGeometry(0.995, 128, 96);
const mat = new THREE.MeshPhysicalMaterial({ envMapIntensity: 1.1 });
scene.add(new THREE.Mesh(geo, mat));
const key = new THREE.DirectionalLight(16777215, 2.4);
key.position.set(-0.7, 0.85, 1);
const fill = new THREE.DirectionalLight(16777215, 0.45);
fill.position.set(0.9, -0.6, 0.7);
const target = new THREE.WebGLRenderTarget(BAKE, BAKE, {
target.texture.colorSpace = THREE.SRGBColorSpace;
target.texture.generateMipmaps = false;
target.texture.minFilter = THREE.LinearFilter;
const bake = (finish, polish = 0) => {
mat.color.set(finish.color);
mat.roughness = THREE.MathUtils.clamp(finish.roughness - polish * 0.45, 0.02, 1);
mat.metalness = THREE.MathUtils.clamp(finish.metalness + Math.max(0, polish) * 0.6, 0, 1);
mat.sheen = finish.sheen ?? 0;
const prev = renderer.getRenderTarget();
renderer.setRenderTarget(target);
renderer.render(scene, camera);
renderer.setRenderTarget(prev);
return { bake, texture: target.texture, dispose };
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const P = { spin: 0.9, pointSize: 0.02 };
const ac = new AbortController();
const wrap = document.createElement("div");
wrap.innerHTML = `<p class="tj__loading"><span class="spin"></span>Loading model</p>
<div class="tj__modes" role="group" aria-label="Subject" style="--segs:${SUBJECTS.length}">
<span class="tj__pill" data-subject-pill aria-hidden="true"></span>
(x, i) => `<button class="tj__seg" type="button" data-subject="${x.id}" aria-pressed="${i === 0}">${x.label}</button>`
<div class="tj__modes" role="group" aria-label="Render mode" style="--segs:${MODES.length}">
<span class="tj__pill" data-mode-pill aria-hidden="true"></span>
(m, i) => `<button class="tj__seg" type="button" data-mode="${m.id}" aria-pressed="${i === 0}">${m.label}</button>`
const pill = wrap.querySelector("[data-mode-pill]");
const subjectPill = wrap.querySelector("[data-subject-pill]");
const segs = [...wrap.querySelectorAll("[data-mode]")];
const subjectSegs = [...wrap.querySelectorAll("[data-subject]")];
const resolveInk = () => {
const probe = document.createElement("span");
probe.style.cssText = "color:var(--ink);position:absolute;visibility:hidden";
const m = getComputedStyle(probe).color.match(/\d+/g);
if (m) ink = Number(m[0]) << 16 | Number(m[1]) << 8 | Number(m[2]);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setClearColor(0, 0);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
wrap.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100);
camera.position.set(0, 0.15, FIT * 2.4);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enablePan = false;
controls.minDistance = FIT * 1.4;
controls.maxDistance = FIT * 4.5;
controls.minPolarAngle = 0.15;
controls.maxPolarAngle = Math.PI * 0.9;
controls.enableDamping = !reduced;
controls.dampingFactor = 0.08;
controls.autoRotate = !reduced;
controls.autoRotateSpeed = P.spin;
const baker = makeBaker(renderer);
const bakeFinish = () => baker.bake(FINISHES.find((f) => f.id === finish).mat, polish);
const shadedMat = new THREE.MeshMatcapMaterial({ matcap: baker.texture });
const wireMat = new THREE.MeshBasicMaterial({
const pointsMat = new THREE.PointsMaterial({ color: ink, size: P.pointSize });
const lanternRoot = new THREE.Group();
const sfRoot = new THREE.Group();
sfRoot.scale.setScalar(SWAP_MIN);
scene.add(lanternRoot, sfRoot);
const sampledGeoms = new Set();
const reps = { shaded: null, wire: null, points: null };
let subject = SUBJECTS[0].id;
const render = () => renderer.render(scene, camera);
const sfCount = (SEG_U + 1) * (SEG_V + 1);
const sfPos = new Float32Array(sfCount * 3);
const sfFrom = new Float32Array(sfCount * 3);
const sfTo = new Float32Array(sfCount * 3);
const sfGeo = new THREE.BufferGeometry();
sfGeo.setIndex(new THREE.BufferAttribute(sfIndex(), 1));
sfGeo.setAttribute("position", new THREE.BufferAttribute(sfPos, 3));
const dials = { lobes: 0, sharp: 1 };
const resolve = (sf) => ({
m1: Math.max(1, sf.m1 + dials.lobes),
m2: Math.max(1, sf.m2 + dials.lobes),
n12: sf.n12 * dials.sharp,
n13: sf.n13 * dials.sharp,
n22: sf.n22 * dials.sharp,
n23: sf.n23 * dials.sharp
let sfActive = SUBJECTS.find((x) => x.sf).sf;
sfPositions(sfActive, sfPos);
const sfMesh = new THREE.Mesh(sfGeo, shadedMat);
const sfWire = new THREE.Mesh(sfGeo, wireMat);
const sfPoints = new THREE.Points(sfGeo, pointsMat);
sfRoot.add(sfMesh, sfWire, sfPoints);
const sfReps = { shaded: sfMesh, wire: sfWire, points: sfPoints };
sfGeo.getAttribute("position").needsUpdate = true;
sfGeo.computeVertexNormals();
sfGeo.computeBoundingSphere();
const morphTo = (sf, dur = 0.9) => {
const swapNow = (out, into) => {
const place = (pill2, btn) => {
if (!btn || !btn.offsetWidth) return;
pill2.style.width = `${btn.offsetWidth}px`;
pill2.style.transform = `translateX(${btn.offsetLeft - 2}px)`;
Object.keys(sfReps).forEach((k) => {
sfReps[k].visible = k === mode;
if (reps[k]) reps[k].visible = k === mode;
segs.forEach((s) => s.setAttribute("aria-pressed", String(s.dataset.mode === mode)));
(s) => s.setAttribute("aria-pressed", String(s.dataset.subject === subject))
place(pill, segs.find((s) => s.dataset.mode === mode));
place(subjectPill, subjectSegs.find((s) => s.dataset.subject === subject));
const applyMode = (next) => {
const applySubject = (id) => {
const next = SUBJECTS.find((x) => x.id === id);
if (!next || id === subject) return;
const leavingModel = !SUBJECTS.find((x) => x.id === subject)?.sf;
if (next.sf && !leavingModel) {
morphTo(resolve(next.sf));
sfPositions(resolve(next.sf), sfPos);
sfActive = resolve(next.sf);
if (reduced) swapNow(lanternRoot, sfRoot);
else swap = { out: lanternRoot, in: sfRoot, t: 0 };
} else if (reduced) swapNow(sfRoot, lanternRoot);
else swap = { out: sfRoot, in: lanternRoot, t: 0 };
morphT = Math.min(1, morphT + dt / morphDur);
const e = expoOut(morphT);
for (let i = 0; i < sfPos.length; i++) sfPos[i] = sfFrom[i] + (sfTo[i] - sfFrom[i]) * e;
swap.t = Math.min(1, swap.t + dt / SWAP_DUR);
const e = expoOut(swap.t * 2);
swap.out.scale.setScalar(1 + (SWAP_MIN - 1) * e);
swap.out.visible = false;
const e = expoOut((swap.t - 0.5) * 2);
swap.in.scale.setScalar(SWAP_MIN + (1 - SWAP_MIN) * e);
swap.in.scale.setScalar(1);
const loader = new GLTFLoader();
const model = gltf.scene;
mesh.material = shadedMat;
geoms.add(mesh.geometry);
const box = new THREE.Box3().setFromObject(model);
const size2 = box.getSize(new THREE.Vector3());
model.scale.multiplyScalar(FIT / Math.max(size2.x, size2.y, size2.z));
box.setFromObject(model);
model.position.sub(box.getCenter(new THREE.Vector3()));
const wire = model.clone();
if (o.isMesh) o.material = wireMat;
const points = model.clone();
if (o.isMesh) pMeshes.push(o);
const areas = pMeshes.map((m) => {
m.geometry.computeBoundingBox();
const s = m.geometry.boundingBox.getSize(new THREE.Vector3());
return 2 * (s.x * s.y + s.y * s.z + s.z * s.x) || 1;
const areaSum = areas.reduce((a, b) => a + b, 0) || 1;
const tmp = new THREE.Vector3();
pMeshes.forEach((mesh, i) => {
const n = Math.max(400, Math.round(BUDGET * areas[i] / areaSum));
const sampler = new MeshSurfaceSampler(mesh).build();
const pos = new Float32Array(n * 3);
for (let k = 0; k < n; k++) {
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
const p = new THREE.Points(geo, pointsMat);
p.position.copy(mesh.position);
p.quaternion.copy(mesh.quaternion);
p.scale.copy(mesh.scale);
mesh.parent?.remove(mesh);
lanternRoot.add(model, wire, points);
(err) => console.error("[lab] threejs-test model failed", err)
const w = wrap.clientWidth;
const h = wrap.clientHeight;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(w, h, false);
camera.updateProjectionMatrix();
const ro = new ResizeObserver(size);
const c = accentHex ?? ink;
shadedMat.color.set(accentHex ?? 16777215);
const themeObs = new MutationObserver(() => {
themeObs.observe(document.documentElement, {
attributeFilter: ["data-theme", "class"]
(s) => s.addEventListener("click", () => applyMode(s.dataset.mode), { signal })
(s) => s.addEventListener("click", () => applySubject(s.dataset.subject), { signal })
controls.addEventListener("change", render);
let last = performance.now();
raf = requestAnimationFrame(loop);
const dt = Math.min(0.1, (now - last) / 1e3);
raf = requestAnimationFrame(loop);
const set = (key, value) => {
controls.autoRotateSpeed = P.spin;
} else if (key === "pointSize") {
P.pointSize = Number(value);
pointsMat.size = P.pointSize;
} else if (key === "finish") {
} else if (key === "polish") {
} else if (key === "lobes" || key === "sharp") {
dials[key] = Number(value);
const sf = SUBJECTS.find((x) => x.id === subject)?.sf;
if (sf) morphTo(resolve(sf), 0.35);
} else if (key === "accent") {
accentHex = String(value).startsWith("var(") ? null : String(value);
cancelAnimationFrame(raf);
geoms.forEach((g) => g.dispose());
sampledGeoms.forEach((g) => g.dispose());
renderer.forceContextLoss();
renderer.domElement.remove();
experiment.mount(document.querySelector(".stage"));