把一座 Three.js 浮岛搬进静态站
浮岛页只下载自己那一份脚本。场景在运行时现算,Three.js 与整个三维世界都留在这一页的 chunk 里,首屏 HTML 里只有一个空舞台。
结论先给:三维场景可以放进纯静态站,前提是它按页加载,而不是全站共享。浮岛页的 HTML 里没有一行 Three.js,只有一个固定高度的空舞台;文档解析完,浏览器才开始下载那一份场景脚本。
一个空舞台,和它下面的一切
页面骨架是三层的:最外层是带墨线边框与硬偏移阴影的舞台,里面一个绝对定位的挂载点,再外面是加载 / 就绪 / 失败三态。三态用 data-state 属性切换,骨架、错误提示与重试按钮从一开始就在 HTML 里,不靠脚本生成。
这样做有两个直接好处。第一,首屏不闪:舞台的尺寸由 CSS 的 clamp() 定死,脚本到位之前页面高度就是最终高度,不会在场景出现的一瞬间把下面的内容顶下去。第二,失败可解释:浏览器不支持 WebGL 时,用户看到的是一句人话加一个重试按钮,而不是一片空白。
挂载方式和站上的游戏、工具完全一致 —— 静态空壳 + 动态 import() + createApp:
const loaders = import.meta.glob<{ default: Parameters<typeof createApp>[0] }>('./*/Scene.vue');
const loader = loaders['./' + slug + '/Scene.vue'];
if (!loader) throw new Error('未找到浮岛组件');
const mod = await loader();
createApp(mod.default, { locale }).mount(root);
import.meta.glob 在这里不是装饰。Vite 会在构建期把它展开成一条独立 chunk 的映射,于是 Three.js、五个后处理模块与整个场景代码只会被访问过这一页的人下载。全站其它 400 多页的产物里没有它。
场景本身:全部现算
岛上没有一张贴图、没有一个模型文件。岩层是手工三角化的多层岩环,顶点色区分明暗;水面与瀑布是两个自定义 ShaderMaterial,靠 time 与 night 两个 uniform 驱动;草叶、花朵、苔石走 InstancedMesh,约一千二百个实例;柔光贴图在内存里用 canvas 画一张径向渐变,连它都不发请求。
代价是首帧要算的东西不少。所以主岛的阴影贴图算一次就锁住:
renderer.shadowMap.autoUpdate = false;
renderer.shadowMap.needsUpdate = true;
场景里所有会动的物体 —— 卫星碎岛、萤火、花瓣、云 —— 都不投影。一帧大约三百多次绘制调用、五万个三角面,这个量级在笔记本和手机上都能跑。
与孔版套印对齐
原版是一个独立的深色页面,直接搬过来会和站上的纸色墨色打架。所以只搬了场景,观景台重画:直角按钮、等宽大写小标签、0 模糊硬偏移阴影,全部走 --c-* 令牌。深色模式跟着站点主题走,与场景自己的「暮光 / 月夜」是两回事 —— 前者是纸的颜色,后者是岛上的天色。
左上角那行小字是实测值:绘制调用数、三角面数与就绪状态,每 900 毫秒从渲染器读一次。它既是调试信息,也是这一页的说明书。
代价
说清楚代价:Three.js 让这一页的脚本从几十 KB 涨到约六百 KB(gzip 后约 150 KB)。这笔钱只有打开浮岛页的人付,但他确实要付。
所以这一页不适合放进首页首屏,也不适合塞进文章正文当插图。它是目的地,不是路过的地方。
收尾
静态站和三维场景不矛盾。把重的东西关进它自己的路由,让三态由 HTML 而不是脚本兜底,剩下的就是设计问题了。

评论
…