<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>3D 滚动叙事</title>
<style>
:root {
--paper: #fbf8f1;
--paper-2: #f3eee2;
--card: #fffdf8;
--ink: #1c2230;
--ink-2: #4a5163;
--ink-3: #636877;
--line: #e6dfd0;
--dot: #d8d0bf;
--blue: #2f5bd3;
--blue-line: rgba(47, 91, 211, 0.35);
--seal: #c0391f;
--stamp: #c0391f; /* seal background behind cream text, the same in both themes */
--ground: #efe8d8; /* the clay board the scene stands on */
--shadow: 0 1px 0 rgba(28, 34, 48, 0.04), 0 10px 28px -14px rgba(28, 34, 48, 0.28);
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--spring: linear(
0,
0.062,
0.215,
0.411,
0.615,
0.799,
0.95,
1.059,
1.126,
1.158,
1.161,
1.145,
1.116,
1.083,
1.051,
1.023,
1.001,
0.986,
0.977,
0.974,
0.974,
0.978,
0.983,
0.988,
0.994,
0.998,
1.001,
1.003,
1.004,
1.004,
1
);
--f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
--f-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #14161b;
--paper-2: #1c1f26;
--card: #1e2129;
--ink: #ece7dc;
--ink-2: #bab6ac;
--ink-3: #959aa8;
--line: #31353f;
--dot: #2c3039;
--blue: #8eaaff;
--blue-line: rgba(142, 170, 255, 0.45);
--seal: #f0694e;
--ground: #8d8778;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.25), 0 10px 28px -14px rgba(0, 0, 0, 0.8);
color-scheme: dark;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
color: var(--ink);
font: 16px/1.7 var(--f-sans);
}
.story {
max-width: 1240px;
margin: 0 auto;
padding: 24px 24px 0;
}
.story-head {
max-width: 760px;
margin: 0 auto 18px;
text-align: center;
}
.kicker {
margin: 0;
font: 500 12px/1.6 var(--f-mono);
letter-spacing: 0.08em;
color: var(--seal);
}
.story-head h1 {
margin: 4px 0 4px;
font-size: 28px;
line-height: 1.3;
}
.story-sub {
margin: 0;
font-size: 14.5px;
color: var(--ink-2);
}
/* ---------- the pinned stage and the steps beside it ---------- */
.story-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 36px;
}
.story-stage {
position: sticky;
top: 16px;
align-self: start;
}
.story-fig {
position: relative;
margin: 0 auto;
overflow: hidden;
width: min(100%, calc((100svh - 40px) * 1.6));
aspect-ratio: 16 / 10;
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: var(--shadow);
}
.story-fig canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
touch-action: pan-y; /* a vertical swipe still scrolls the page */
cursor: grab;
}
.story-fig canvas:active {
cursor: grabbing;
}
.nogl {
display: grid;
place-items: center;
height: 100%;
margin: 0;
padding: 24px;
text-align: center;
color: var(--ink-3);
}
.pins {
position: absolute;
inset: 0;
pointer-events: none;
}
/* Labels hang from a point projected out of the 3D scene; the script moves left/top every frame. */
.label,
.num {
position: absolute;
visibility: hidden;
opacity: 0;
transition:
opacity 0.25s,
visibility 0.25s;
}
.label.on,
.num.on {
visibility: visible;
opacity: 1;
}
.label.out,
.num.out {
visibility: hidden;
opacity: 0;
}
.num {
translate: -50% -50%;
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--stamp);
color: #fff8ef;
font: 700 13px/1 var(--f-mono);
box-shadow: 0 0 0 3px var(--card);
}
.label {
width: 0;
height: 0;
}
.label::before {
content: "";
position: absolute;
left: -1px;
bottom: 0;
width: 2px;
height: 20px;
background: var(--ink);
}
.label::after {
content: "";
position: absolute;
left: -5px;
top: -5px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--seal);
box-shadow: 0 0 0 2px var(--card);
}
.label > span {
position: absolute;
bottom: 20px;
left: 0;
translate: calc(-50% + var(--dx, 0px)) 0;
padding: 4px 10px;
white-space: nowrap;
border: 1.5px solid var(--ink);
border-radius: 10px;
background: var(--card);
color: var(--ink);
font: 500 13px/1.45 var(--f-sans);
box-shadow: 2px 3px 0 rgba(28, 34, 48, 0.18);
}
.label.below::before {
bottom: auto;
top: 0;
}
.label.below > span {
bottom: auto;
top: 20px;
}
.story-steps {
list-style: none;
margin: 0;
padding: 0 0 30vh;
}
.story-step {
min-height: 62vh;
}
.story-card {
position: relative;
padding: 18px 20px 18px 56px;
background: var(--card);
border: 2px dashed var(--blue-line);
border-radius: 14px;
box-shadow: var(--shadow);
transition:
border-color 0.3s,
translate 0.5s var(--spring);
}
.story-card h2 {
margin: 0 0 6px;
font-size: 18px;
line-height: 1.45;
}
.story-card p {
margin: 0;
font-size: 14.5px;
line-height: 1.8;
color: var(--ink-2);
}
.story-n {
position: absolute;
left: 16px;
top: 18px;
display: grid;
place-items: center;
width: 27px;
height: 27px;
border-radius: 50%;
border: 1.5px solid var(--ink-3);
font: 600 13px/1 var(--f-mono);
color: var(--ink-2);
transition:
background-color 0.3s,
color 0.3s,
border-color 0.3s;
}
.story-step.on .story-card {
border-style: solid;
border-color: var(--seal);
translate: -6px 0;
}
.story-step.on .story-n {
background: var(--stamp);
border-color: var(--stamp);
color: #fff8ef;
}
@media (max-width: 820px) {
.story {
padding: 20px 16px 0;
}
.story-head h1 {
font-size: 24px;
}
.story-body {
display: block;
}
/* On a phone the stage rides along at the top and the steps scroll beneath it. */
.story-stage {
top: 0;
z-index: 3;
margin: 0 -16px;
padding: 8px 16px 10px;
background: var(--paper);
}
.story-fig {
width: 100%;
}
.story-step {
min-height: 52svh;
}
.story-step:first-child {
padding-top: 14px;
}
.label > span {
font-size: 12.5px;
padding: 3px 8px;
}
.story-step.on .story-card {
translate: none;
}
}
@media (prefers-reduced-motion: reduce) {
.story-card {
transition: border-color 0.3s;
}
}
</style>
</head>
<body>
<section class="story" data-story>
<header class="story-head">
<p class="kicker">SCROLL STORY · 示例</p>
<h1>一封信是怎么寄到的</h1>
<p class="story-sub">往下滚,场景跟着一步步搭起来;拖动场景可以左右转着看。</p>
</header>
<div class="story-body">
<div class="story-stage">
<figure class="story-fig" data-fig>
<div class="pins" aria-hidden="true">
<span class="num" data-pin="s1" data-step="0">1</span>
<span class="num" data-pin="s2" data-step="0">2</span>
<span class="num" data-pin="s3" data-step="0">3</span>
<span class="num" data-pin="s4" data-step="0">4</span>
<span class="num" data-pin="s5" data-step="0">5</span>
<span class="label" data-pin="s1" data-step="1"><span>写好的信</span></span>
<span class="label" data-pin="s2" data-step="2"><span>邮票 = 邮资已付</span></span>
<span class="label" data-pin="s3" data-step="3"><span>邮筒</span></span>
<span class="label" data-pin="s4" data-step="4"><span>按地址分拣、装车</span></span>
<span class="label" data-pin="s5" data-step="5"><span>收件人的信箱</span></span>
</div>
</figure>
</div>
<ol class="story-steps">
<li class="story-step" data-step="0">
<div class="story-card">
<span class="story-n" aria-hidden="true">↓</span>
<h2>从桌上到门口</h2>
<p>图里是一封信要走的全程:五站,一条路。往下滚,场景会先收起来,再一站一站重新搭出来。</p>
</div>
</li>
<li class="story-step" data-step="1">
<div class="story-card">
<span class="story-n" aria-hidden="true">1</span>
<h2>写好一封信</h2>
<p>一张纸、一支笔。写完折好,这时它还只是桌上的一张纸。</p>
</div>
</li>
<li class="story-step" data-step="2">
<div class="story-card">
<span class="story-n" aria-hidden="true">2</span>
<h2>装进信封,贴上邮票</h2>
<p>正面写清收件地址,右上角贴邮票:地址决定它去哪,邮票说明路费已经付过。</p>
</div>
</li>
<li class="story-step" data-step="3">
<div class="story-card">
<span class="story-n" aria-hidden="true">3</span>
<h2>投进邮筒</h2>
<p>邮筒每天定时开箱。信被收走之前,就在里面和别的信一起等着。</p>
</div>
</li>
<li class="story-step" data-step="4">
<div class="story-card">
<span class="story-n" aria-hidden="true">4</span>
<h2>分拣、装车</h2>
<p>邮局按地址把信分好,去同一个方向的装上同一辆车,一路转运到收件人所在的地方。</p>
</div>
</li>
<li class="story-step" data-step="5">
<div class="story-card">
<span class="story-n" aria-hidden="true">5</span>
<h2>送到门口</h2>
<p>投递员把信放进收件人门口的信箱,这一程结束。往回滚,可以再看一遍。</p>
</div>
</li>
</ol>
</div>
</section>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"
}
}
</script>
<script type="module">
import {
BoxGeometry,
CatmullRomCurve3,
Color,
CylinderGeometry,
DirectionalLight,
ExtrudeGeometry,
Group,
HemisphereLight,
Mesh,
MeshStandardMaterial,
NeutralToneMapping,
Object3D,
PCFShadowMap,
PerspectiveCamera,
PMREMGenerator,
Scene,
Shape,
SphereGeometry,
SRGBColorSpace,
Vector3,
WebGLRenderer,
} from "three";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
const root = document.querySelector("[data-story]");
const fig = root.querySelector("[data-fig]");
const steps = [...root.querySelectorAll(".story-step")];
const pinned = [...fig.querySelectorAll("[data-pin]")];
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const wide = matchMedia("(min-width: 821px)");
const LAST = steps.length - 1;
// =====================================================================================
// The model: five stations on a clay board, built from primitives (no model file).
// Step 0 is the overview (everything shown); step k (1..5) shows stations 1..k.
// =====================================================================================
const HEX = {
ink: "#1c2230",
seal: "#d24a30",
blue: "#4f74d9",
green: "#4f9a6c",
yellow: "#f6d453",
cream: "#fbf6ea",
white: "#ffffff",
wood: "#c99a6a",
grey: "#a3a8b4",
};
const mats = new Map();
const mat = (key) => {
if (!mats.has(key))
mats.set(key, new MeshStandardMaterial({ color: HEX[key] ?? key, roughness: 0.72, metalness: 0 }));
return mats.get(key);
};
const box = (w, h, d, r = 0.04) => new RoundedBoxGeometry(w, h, d, 2, Math.min(r, w / 2, h / 2, d / 2));
const cyl = (r, h, n = 24) => new CylinderGeometry(r, r, h, n);
/** Adds a mesh to `parent` at [x, y, z] (y up) with an optional rotation. */
const put = (parent, geo, color, [x, y, z], rot = [0, 0, 0]) => {
const m = new Mesh(geo, mat(color));
m.position.set(x, y, z);
m.rotation.set(...rot);
m.castShadow = m.receiveShadow = true;
parent.add(m);
return m;
};
const prism = (w, h, depth) => {
const s = new Shape();
s.moveTo(-w / 2, 0);
s.lineTo(w / 2, 0);
s.lineTo(0, h);
s.closePath();
return new ExtrudeGeometry(s, { depth, bevelEnabled: false }).translate(0, 0, -depth / 2);
};
// Where each station stands, and the point its label hangs from (relative to the station).
const STATIONS = [
{ at: new Vector3(-5.0, 0, 0.6), pin: new Vector3(-0.15, 0.9, 0) },
{ at: new Vector3(-2.5, 0, -0.9), pin: new Vector3(0.6, 1.25, 0.3) },
{ at: new Vector3(0.1, 0, 0.7), pin: new Vector3(0, 1.8, 0) },
{ at: new Vector3(2.8, 0, -0.8), pin: new Vector3(-0.2, 1.3, 0) },
{ at: new Vector3(5.3, 0, 0.5), pin: new Vector3(0.95, 1.0, 0.55) },
];
function station1(g) {
// a desk with a letter and a pen on it
put(g, box(1.8, 0.1, 1.1), "wood", [0, 0.8, 0]);
for (const [x, z] of [
[-0.8, -0.45],
[0.8, -0.45],
[-0.8, 0.45],
[0.8, 0.45],
])
put(g, cyl(0.05, 0.8, 12), "wood", [x, 0.4, z]);
put(g, box(0.75, 0.02, 0.95, 0.01), "white", [-0.15, 0.86, 0.02], [0, 0.12, 0]);
for (let i = 0; i < 5; i++)
put(
g,
box(i === 4 ? 0.3 : 0.52, 0.012, 0.035, 0.005),
"grey",
[-0.17 - (i === 4 ? 0.11 : 0), 0.875, -0.3 + i * 0.13],
[0, 0.12, 0],
);
put(g, cyl(0.03, 0.62, 12), "blue", [0.55, 0.88, 0.05], [Math.PI / 2, 0, 0.5]);
}
function station2(g) {
// an envelope on a little stand, tilted towards the reader: flap, stamp and address lines
put(g, box(0.9, 0.12, 0.4), "wood", [0, 0.06, 0]);
const env = new Group();
env.position.set(0, 0.12, 0);
env.rotation.x = -0.28;
g.add(env);
put(env, box(1.7, 1.08, 0.06, 0.03), "cream", [0, 0.56, 0]);
put(env, prism(1.62, 0.5, 0.02), "white", [0, 1.07, 0.035], [0, 0, Math.PI]);
put(env, box(0.26, 0.32, 0.03, 0.01), "seal", [0.6, 0.86, 0.045]);
for (let i = 0; i < 3; i++)
put(env, box(0.7 - i * 0.12, 0.04, 0.02, 0.01), "ink", [-0.18 - i * 0.06, 0.42 - i * 0.12, 0.04]);
}
function station3(g) {
// a mailbox: body, domed top, slot, base
put(g, cyl(0.5, 0.12), "ink", [0, 0.06, 0]);
put(g, cyl(0.42, 1.25), "green", [0, 0.74, 0]);
put(g, new SphereGeometry(0.42, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2), "green", [0, 1.36, 0]);
put(g, box(0.5, 0.07, 0.1, 0.02), "ink", [0, 1.12, 0.38]);
put(g, box(0.3, 0.2, 0.04, 0.02), "cream", [0, 0.72, 0.42]);
}
function station4(g) {
// a mail truck with a few parcels waiting beside it
const t = new Group();
t.rotation.y = 0.35;
g.add(t);
put(t, box(1.5, 0.95, 0.95, 0.08), "cream", [-0.25, 0.75, 0]);
put(t, box(1.52, 0.1, 0.97, 0.03), "blue", [-0.25, 0.62, 0]);
put(t, box(0.6, 0.72, 0.9, 0.1), "blue", [0.85, 0.63, 0]);
put(t, box(0.05, 0.3, 0.7, 0.02), "ink", [1.15, 0.78, 0]);
for (const [x, z] of [
[-0.6, 0.48],
[0.7, 0.48],
[-0.6, -0.48],
[0.7, -0.48],
])
put(t, cyl(0.2, 0.14), "ink", [x, 0.2, z], [Math.PI / 2, 0, 0]);
put(g, box(0.36, 0.3, 0.36), "wood", [-0.6, 0.15, 1.05]);
put(g, box(0.3, 0.24, 0.3), "wood", [-0.2, 0.12, 1.15]);
put(g, box(0.26, 0.22, 0.26), "wood", [-0.55, 0.41, 1.05]);
}
function station5(g) {
// a house, and its letter box on a post
put(g, box(1.4, 1.05, 1.15, 0.05), "cream", [0, 0.53, -0.2]);
put(g, prism(1.65, 0.75, 1.35), "seal", [0, 1.05, -0.2]);
put(g, box(0.36, 0.62, 0.05, 0.03), "blue", [-0.2, 0.31, 0.38]);
put(g, box(0.3, 0.26, 0.05, 0.03), "white", [0.35, 0.62, 0.38]);
put(g, cyl(0.04, 0.75, 12), "wood", [0.95, 0.38, 0.55]);
put(g, box(0.36, 0.26, 0.5, 0.06), "yellow", [0.95, 0.86, 0.55]);
put(g, box(0.04, 0.2, 0.04, 0.01), "seal", [1.15, 0.98, 0.5]);
}
function build() {
const model = new Group();
const board = put(model, box(13.6, 0.3, 5.0, 0.12), "#efe8d8", [0, -0.15, 0]);
board.castShadow = false;
// The route: a dotted path from station to station, in front of them.
const route = new CatmullRomCurve3([
new Vector3(-6.3, 0.01, 1.6),
...STATIONS.map((s) => s.at.clone().add(new Vector3(0, 0.01, 1.05))),
new Vector3(6.4, 0.01, 1.7),
]);
const pieces = [];
const groups = [station1, station2, station3, station4, station5].map((make, i) => {
const g = new Group();
g.position.copy(STATIONS[i].at);
make(g);
model.add(g);
pieces.push({ g, step: i + 1, delay: 0.15 });
return g;
});
// A station's u along the route, found by sampling (the route bends, so u is not linear in x).
const uOf = (p) => {
let best = 0;
let bestD = Infinity;
for (let k = 0; k <= 400; k++) {
const d = route.getPointAt(k / 400).distanceToSquared(p);
if (d < bestD) [best, bestD] = [k / 400, d];
}
return best;
};
const U = STATIONS.map((s) => uOf(s.at.clone().add(new Vector3(0, 0.01, 1.05))));
const dotGeo = cyl(0.07, 0.03, 12);
const N = 70;
for (let k = 0; k <= N; k++) {
const u = k / N;
const step = Math.max(1, U.findIndex((x) => x >= u) + 1 || 5); // dots up to station k come with step k
const d = new Group();
d.position.copy(route.getPointAt(u));
put(d, dotGeo, step === 1 ? "grey" : "seal", [0, 0.015, 0]);
model.add(d);
const from = step === 1 ? 0 : U[step - 2];
pieces.push({ g: d, step, delay: 0.35 + Math.max(0, u - from) * 2.2 });
}
// The letter that travels: a small envelope with a stamp, riding above the route.
const letter = new Group();
put(letter, box(0.5, 0.32, 0.04, 0.02), "white", [0, 0, 0]);
put(letter, box(0.1, 0.12, 0.02, 0.01), "seal", [0.15, 0.06, 0.03]);
model.add(letter);
// Pins for the HTML labels.
const pins = STATIONS.map((s, i) => {
const o = new Object3D();
o.position.copy(s.at).add(s.pin);
o.name = `s${i + 1}`;
model.add(o);
return o;
});
return { model, board, route, U, pieces, letter, pins, groups };
}
// =====================================================================================
// The scene: camera per step, pieces that pop in on springs, the letter on the route.
// =====================================================================================
function mount() {
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
// Throws without WebGL; the caller then keeps the text steps only.
const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "low-power" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = SRGBColorSpace;
renderer.toneMapping = NeutralToneMapping;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = PCFShadowMap;
const scene = new Scene();
const pmrem = new PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environmentIntensity = 0.3;
pmrem.dispose();
scene.add(new HemisphereLight(0xffffff, 0x8a8478, 0.35));
const key = new DirectionalLight(0xffffff, 1.6);
key.position.set(-5, 12, 8);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
Object.assign(key.shadow.camera, { left: -9, right: 9, top: 9, bottom: -9, near: 1, far: 40 });
key.shadow.radius = 6;
key.shadow.intensity = 0.55;
key.shadow.bias = -0.0005;
key.shadow.normalBias = 0.03;
scene.add(key, key.target);
const { model, board, route, U, pieces, letter, pins } = build();
scene.add(model);
for (const p of pieces) Object.assign(p, { s: 1, v: 0, to: 1, wait: 0 });
// Camera: always looks from the same direction; each step sets where it looks and how far
// away it stands. Step 0 frames the whole board.
const DIR = new Vector3(0.32, 1.0, 1.45).normalize();
const REF_ASPECT = 1.6;
const FOCUS = [
new Vector3(0.2, 0.3, -0.4),
...STATIONS.map((s) => s.at.clone().add(new Vector3(0, 0.75, 0.3))),
];
const DIST = [18.6, 7.2, 7.2, 7.4, 7.6, 7.8];
const camera = new PerspectiveCamera(30, REF_ASPECT, 0.5, 100);
const goalDist = (k) => DIST[k] * Math.max(1, REF_ASPECT / camera.aspect) ** 0.8;
const look = FOCUS[0].clone();
let dist = DIST[0];
let step = 0;
let yaw = 0;
// The letter rides to the current station (in the overview it rests between 3 and 4).
const letterU = (k) => (k === 0 ? (U[2] + U[3]) / 2 : U[k - 1]);
let lu = letterU(0);
let luFrom = lu;
const UPV = new Vector3(0, 1, 0);
let drag = null;
canvas.addEventListener("pointerdown", (e) => {
drag = { x: e.clientX, yaw };
canvas.setPointerCapture(e.pointerId);
kick();
});
canvas.addEventListener("pointermove", (e) => {
if (!drag) return;
yaw = Math.max(-1.1, Math.min(1.1, drag.yaw - (e.clientX - drag.x) * 0.008));
kick();
});
const release = () => {
drag = null;
kick();
};
canvas.addEventListener("pointerup", release);
canvas.addEventListener("pointercancel", release);
const advance = (dt) => {
let busy = drag !== null;
const t = reduced ? 1 : 1 - Math.exp(-dt * 3.2);
look.lerp(FOCUS[step], t);
dist += (goalDist(step) - dist) * t;
if (look.distanceToSquared(FOCUS[step]) > 1e-5 || Math.abs(goalDist(step) - dist) > 1e-3) busy = true;
if (!drag) {
// let go and the view swings back to the front
yaw *= reduced ? 0 : Math.exp(-dt * 4);
if (Math.abs(yaw) > 1e-4) busy = true;
else yaw = 0;
}
for (const p of pieces) {
if (p.s === p.to && p.v === 0) continue;
busy = true;
if (reduced) {
p.s = p.to;
p.v = 0;
} else if (p.wait > 0) {
p.wait -= dt;
continue;
} else {
// A spring: a small overshoot on the way in, none on the way out.
const damping = p.to ? 15 : 28;
p.v += (170 * (p.to - p.s) - damping * p.v) * dt;
p.s += p.v * dt;
if (Math.abs(p.to - p.s) < 0.002 && Math.abs(p.v) < 0.02) {
p.s = p.to;
p.v = 0;
}
}
p.g.visible = p.s > 0.002;
p.g.scale.setScalar(Math.max(p.s, 0.002));
}
const goal = letterU(step);
const lt = reduced ? 1 : 1 - Math.exp(-dt * 2.4);
lu += (goal - lu) * lt;
if (Math.abs(goal - lu) > 1e-4) busy = true;
else lu = goal;
return busy;
};
const v = new Vector3();
const sizes = new Map(); // label element -> [width, height], measured once
let figW = 0;
let figH = 0;
const draw = () => {
camera.position.copy(look).addScaledVector(v.copy(DIR).applyAxisAngle(UPV, yaw), dist);
camera.lookAt(look);
// the letter hops while it travels, then rests a little above the route
const span = Math.abs(letterU(step) - luFrom) || 1;
const along = 1 - Math.abs(letterU(step) - lu) / span;
route.getPointAt(Math.min(1, Math.max(0, lu)), letter.position);
letter.position.y = 0.45 + Math.sin(Math.PI * along) * 0.6;
letter.visible = step !== 1; // in step 1 it is still the sheet on the desk
letter.lookAt(v.copy(camera.position).setY(letter.position.y));
renderer.render(scene, camera);
// Put each label on its pin's projected point, or hide it when the point is off screen.
// All reads (label sizes, once per label) come before any write, so this never forces a layout.
for (const el of pinned)
if (el.classList.contains("label") && el.classList.contains("on") && !sizes.has(el))
sizes.set(el, [el.firstElementChild.offsetWidth, el.firstElementChild.offsetHeight]);
for (const el of pinned) {
pins[+el.dataset.pin.slice(1) - 1].getWorldPosition(v).project(camera);
const inside = v.z < 1 && Math.abs(v.x) < 0.98 && Math.abs(v.y) < 0.98;
el.classList.toggle("out", !inside);
if (!inside) continue;
const x = ((v.x + 1) / 2) * figW;
const y = ((1 - v.y) / 2) * figH;
el.style.left = `${x}px`;
el.style.top = `${y}px`;
const size = el.classList.contains("on") && sizes.get(el);
if (!size) continue;
// hang above the point; with no room above, below it; slide sideways to stay inside
const [bw, bh] = size;
el.classList.toggle("below", y < bh + 30);
const dx = Math.min(Math.max(0, bw / 2 + 8 - x), figW - bw / 2 - 8 - x);
el.style.setProperty("--dx", `${dx}px`);
}
};
// Render only while something moves; small fixed steps keep the springs in real time.
let raf = 0;
let then = 0;
const tick = (now) => {
raf = 0;
let dt = Math.min((now - then) / 1000, 0.25);
then = now;
let busy = false;
while (dt > 0) {
const h = Math.min(dt, 1 / 120);
busy = advance(h);
dt -= h;
}
draw();
if (busy) raf = requestAnimationFrame(tick);
};
function kick() {
if (raf || !seen) return;
then = performance.now();
raf = requestAnimationFrame(tick);
}
let seen = true;
new IntersectionObserver(([e]) => {
seen = e.isIntersecting;
if (!seen && raf) {
cancelAnimationFrame(raf);
raf = 0;
}
kick();
}).observe(fig);
const theme = () => {
board.material = new MeshStandardMaterial({
color: new Color(getComputedStyle(document.documentElement).getPropertyValue("--ground").trim()),
roughness: 0.8,
});
kick();
};
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", theme);
theme();
new ResizeObserver(([entry]) => {
figW = entry.contentRect.width;
figH = entry.contentRect.height;
sizes.clear(); // fonts may have changed size too
if (!figW || !figH) return;
renderer.setSize(figW, figH, false);
camera.aspect = figW / figH;
camera.updateProjectionMatrix();
dist = goalDist(step);
draw();
kick();
}).observe(fig);
fig.prepend(canvas);
return {
setStep(k) {
if (k === step) return;
const from = step;
luFrom = lu;
step = k;
for (const p of pieces) {
const to = k === 0 || p.step <= k ? 1 : 0;
if (to === p.to) continue;
p.to = to;
// Jumping several steps at once still builds them in order.
p.wait = to ? p.delay + Math.max(0, p.step - Math.max(from, 1) - 1) * 0.3 : 0;
}
kick();
},
};
}
// =====================================================================================
// Scroll: the current step is the last card that has crossed the reading line.
// =====================================================================================
let scene = null;
try {
scene = mount();
} catch {
const p = document.createElement("p");
p.className = "nogl";
p.textContent = "这个浏览器没开 WebGL,3D 场景画不出来;右边的文字照样讲完整个过程。";
fig.replaceChildren(p);
}
let active = -1;
const show = (next) => {
if (next === active) return;
active = next;
steps.forEach((s, i) => s.classList.toggle("on", i === active));
for (const el of pinned) el.classList.toggle("on", Number(el.dataset.step) === active);
scene?.setStep(active);
};
const update = () => {
const figBottom = fig.getBoundingClientRect().bottom;
// mid-screen on wide screens; a little below the pinned stage on narrow ones
const line = wide.matches ? innerHeight * 0.55 : figBottom + (innerHeight - figBottom) * 0.4;
let next = 0;
steps.forEach((s, i) => {
if (s.firstElementChild.getBoundingClientRect().top < line) next = i;
});
show(next);
};
let queued = false;
const onScroll = () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
update();
});
};
addEventListener("scroll", onScroll, { passive: true });
addEventListener("resize", onScroll);
update();
</script>
</body>
</html>