个人网站中的 3D 人物实现记录

记录我在 Astro 个人网站中接入 GLB 模型时用到的 Three.js 渲染、眼球跟随、滚动与拖动交互,以及缓存和静态降级处理。

  • 编程

这篇是我给个人网站加入 3D 肖像时留下的一份实现记录。模型先按图片生成,后来又在 Blender 里补了左右眼的独立结构。页面里的节点名称和角度参数都来自当前这份 GLB,并不是一套可以直接套用到所有模型上的配置。换一份模型后,方向、尺寸、眼球轴向和交互范围都可能需要重新观察。

模型和页面

页面使用的是我为个人网站准备的一份肖像 GLB。网格、材质、纹理和眼球节点都已经放在文件里,网页端没有重新建模,主要负责加载、摆放和交互。

GLB 是 glTF 的二进制封装,可以把场景层级、网格、材质和纹理放进同一个文件。浏览器本身不会像显示图片那样显示 GLB,所以我用 Three.js 的 GLTFLoader 把文件解析成场景对象,再交给 WebGL 绘制。

我提供的原始 GLB 可以直接解析。网站上线时使用的版本又做了 Meshopt 压缩,文件里的顶点和索引数据体积更小,因此解析时多接了一个 MeshoptDecoder

const gltf = await new GLTFLoader()
  .setMeshoptDecoder(MeshoptDecoder)
  .parseAsync(modelBytes, "/");

// 解析后得到可以放入 Three.js 场景的对象
const model = gltf.scene;

我的模型压缩比大概是3:1,算是大幅提高了网页加载速度。

这里所说的“解码”,只是把压缩后的几何数据还原成 Three.js 能使用的数据,不是把 GLB 转成另一种模型格式。如果文件没有使用 Meshopt、Draco 或 KTX2 之类的扩展,对应的额外解码器也用不到。

我把模型下载和解析分成了两步:先用 fetch 取得 ArrayBuffer,再交给 parseAsync。这样比较方便控制缓存、预取、请求取消,以及海报切换到 3D 画面的时机。换成 GLTFLoader.load() 也能完成同一件事。

渲染环境

页面本身由 Astro 生成。组件先输出一张静态海报和一个透明的 <canvas>,浏览器确认支持 WebGL2 后,再加载 Three.js 和模型代码。模型准备好以前,访问者看到的仍然是海报,不会留下一块空白区域。

场景里使用了透视相机、环境光和几盏方向光。渲染器开启透明背景与抗锯齿,颜色输出使用 sRGB,并配合 ACES 色调映射。灯光颜色和强度对人物肤色影响很明显,我这次只按当前模型的材质做了调整,没有把它们当作通用参数。

相机没有跟随鼠标移动。页面里的变化主要发生在模型自身的几个容器层上,这样相机和网页构图可以保持稳定。

模型的方向、居中和大小

这份 GLB 放进场景后,原始朝向和页面需要的方向不一致。我先用一个外层 Group 将它绕 Y 轴转了 -90°,再用 Box3 读取模型的包围盒:

const bounds = new THREE.Box3().setFromObject(modelFrame);
const size = bounds.getSize(new THREE.Vector3());
const center = bounds.getCenter(new THREE.Vector3());

// 1.9 是当前肖像在页面中的目标尺寸
const scale = 1.9 / Math.max(size.x, size.y);
modelFrame.scale.setScalar(scale);
modelFrame.position.set(
  -center.x * scale,
  -center.y * scale + 0.01,
  -center.z * scale,
);

这段处理让我不用反复手调模型中心点。1.90.01 只是当前模型与当前页面构图下的结果;模型比例、原点或姿势变化后,这两个值也会跟着变。

为了避免几种姿态互相覆盖,我把对象分成了几层:最外层负责页面位置和整体缩放,下一层负责滚动时的阅读姿态,再下一层保存用户拖动后的角度,最里面才是 GLB 的方向校正和尺寸适配。

眼球跟随鼠标

当前模型的左右眼节点在运行时名为 EyeAimLEyeAimR。每个节点下面包含眼白、虹膜、瞳孔和高光,所以我旋转的是整个眼球节点,不是单独移动瞳孔。

模型加载后,我先保存两只眼睛原有的四元数。每一帧交互更新时,再计算两只眼睛在三维世界里的中点,并通过相机投影到屏幕。鼠标相对这个屏幕坐标的偏移会被归一化到 -11,然后换算成水平和垂直旋转。

当前模型使用的水平范围是 ±22°,垂直范围是 ±13°。水平旋转沿眼球局部 -Z 轴,垂直旋转沿局部 +X 轴。这组方向和数值只对应这次模型的眼球朝向。

目标姿态由“原始四元数 × 注视旋转”得到,再用 slerp 慢慢靠近:

targetQuaternion
  .copy(baseQuaternion)
  .multiply(gazeRotation);

// 13 是当前页面使用的跟随速度
const factor = 1 - Math.exp(-13 * delta);
eye.quaternion.slerp(targetQuaternion, factor);

我用四元数主要是为了保留模型原有眼球姿态,也让多个方向的旋转组合得比较平稳。鼠标离开页面后,目标值回到中心,两只眼睛会逐渐恢复初始方向。触摸设备不启用持续眼球跟随,避免手指遮挡画面时产生多余变化。

滚动和拖动

滚动页面时,模型会从首屏展示状态过渡到正文旁边的阅读状态。桌面端会同时改变位置、缩放和少量 Y 轴角度;当前设置大约从 转到 。移动端空间较窄,我保留了位置和大小变化,没有加入这段转身。

拖动交互使用 Pointer Events。按下时先用 Raycaster 检查指针有没有碰到模型网格,只有从人物上开始的拖动才会改变角度。横向移动超过 6px 后进入旋转,当前范围限制在 ±45°,松开后保留最后的位置。

滚动姿态和拖动姿态分别放在两个嵌套的 Group 中。用户拖动时,内层会把外层已有的阅读角度计算进去,所以两种变化不会简单重复叠加。

按需渲染和静态降级

这张肖像大部分时间是静止的,我没有让渲染循环一直运行。滚动、拖动、眼球跟随或窗口尺寸变化时,页面才继续请求新帧;姿态稳定后便停下来。页面离开视口时,IntersectionObserver 也会暂停相关更新。

如果浏览器不支持 WebGL2、模型加载失败,或访问者开启了“减少动态效果”,页面会继续显示静态海报。桌面和移动端各有一张适合当前构图的图片,所以 3D 并不是页面内容出现的前提。

回看这段实现,它更像是围绕一份具体模型做的几层小配合:Three.js 负责把 GLB 放进网页,Meshopt 缩小传输体积,Box3 帮我整理构图,四元数处理眼球方向,分层的 Group 保存滚动和拖动姿态。这里记录的数值只是这次调试留下的结果,后面更换模型时,我还会从节点结构、局部轴向和页面构图重新确认。