卓琪的开发笔记zhuoqidev.comRSSllms.txt

← 全部灵感~/design/scroll-3d-story

图表与 3D原创

3D 滚动叙事

左边 3D 小场景钉住不动,右边文字一步步滚过去:镜头推到当前这一步,零件弹出来,标签跟着 3D 点走。纯 three.js 代码建模。

html · css · three.jsMIT收录于 #3D#滚动叙事#产品介绍#黏土风

在线演示新窗口打开 ↗

看点

  • 钉住的舞台:场景用 position: sticky 钉在左边,右边每一步是一张卡片,每张至少 62vh 高;手机上舞台钉在顶部,卡片从下面滚过去。
  • 当前步 = 越过阅读线的最后一张卡:宽屏的阅读线在视口 55% 处,窄屏在舞台下方一点;滚动事件合并到 requestAnimationFrame 里,每帧最多算一次。
  • 镜头只改两个数:镜头始终从同一个方向看,每一步只改「看哪里」和「离多远」,用指数插值追过去,切步不晕。第 0 步是全景,场景先「收起来」再一站站搭出来。
  • 零件弹簧出场:每一站是一组零件,到了这一步才从地面弹出来(带一点过冲的弹簧),往回滚就收回去;一下跳过几步也会按顺序搭。路上的点按离上一站的远近依次出现,那封小信沿路飞到当前这一站。
  • HTML 标签挂在 3D 点上:每帧把锚点投影到画布坐标,挂上普通的 HTML 标签;点出了画面就藏起来,上方放不下就挂到下面,左右贴边会往里滑。文字可选、可读屏,也不用在 3D 里排字。
  • 代码建模:桌子、信封、邮筒、邮车、房子都是圆角盒子、圆柱、三角棱柱拼的,没有模型文件要下载;MeshStandardMaterial 粗糙度 0.72 + 柔和阴影,就是黏土感。
  • 省电:只有东西在动时才渲染,停下就停掉循环;离开视口也停。偏好减少动效时镜头和零件直接到位。

怎么用

  1. 拷 .story-body 这段结构和样式:左边 .story-stage > .story-fig(放画布和 .pins 标签层),右边 <ol class="story-steps">,每步一个 data-step。
  2. 在 build() 里换成你自己的场景:每一步做一个 Group 放进 pieces(写上它属于第几步),再给每一站一个锚点 Object3D 挂标签。
  3. 改 FOCUS / DIST 两个数组:每一步镜头看哪里、离多远。第 0 步拉远看全景。
  4. 标签写在 HTML 里:<span class="label" data-pin="锚点名" data-step="步号">,脚本会负责定位和显隐。
  5. 首屏要快的话,可以先放一张同机位的静态图,等用户第一次滚动或点击再加载 three.js,加载完淡入画布(画面不会跳)。

坑

  • 读布局(getBoundingClientRect)只在滚动后的那一帧里做,别在加载时、也别和写样式交错着读,否则会强制重排、滚动发卡。
  • 画布要设 touch-action: pan-y,不然手机上在场景上竖着滑会被拖动吃掉,页面滚不动;拖动只用来左右转一点,松手转回正面。
  • 弹簧积分用小的固定步长(这里 1/120 秒),掉帧的时候才不会弹飞。
  • 不同宽高比下全景会被裁掉:按「参考宽高比 ÷ 实际宽高比」把镜头拉远一些。
  • 没有 WebGL 时舞台显示一句提示,文字步骤照样讲完整个过程;正式用的话最好有一张静态图兜底。
$给 Agent 读这篇:index.md

源码 MIT 开源,拿去改。

<!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>

来加个微信吧

刘卓琪的微信二维码

微信扫码,或在手机上长按识别

加好友请备注来意,不然可能会漏掉哦~