从 WebGL 到 WebGPU:给小朋友写一个 three.js 太阳系

本文是给家里小朋友做「太阳系漫游」网页(直达)过程中的技术调研与实践记录:先简单梳理浏览器 3D 技术的发展脉络,然后看两份有代表性的资料——three.js 官方的 llms.txt 和一个用 WebGPU 手写引擎的钓鱼游戏 Tidewater,最后拆解我们自己的太阳系架构与踩坑实录。全文无框架、零构建依赖,代码都在 GitHub。

一、浏览器 3D 的十五年

把时间线拉长看,网页 3D 大致经历了四个阶段:

  • 插件时代(2006–2011):3D 内容靠 Flash(Away3D)、Unity Web Player、Java Applet 这类插件承载,浏览器本身对 3D 一无所知。
  • WebGL 时代(2011–2022):2011 年 3 月 Khronos 发布 WebGL 1.0(基于 OpenGL ES 2.0),浏览器第一次原生拥有 GPU 渲染能力;同年 mrdoob(Ricardo Cabello)开源了 three.js。2017 年 WebGL 2.0 与 glTF 2.0 格式先后落地,前者带来实例化渲染等能力,后者统一了 3D 模型的「JPEG」标准。
  • WebGPU 时代(2023– ):2023 年 4 月 Chrome 113 默认启用 WebGPU——对标 Vulkan/Metal/DirectX 12 的现代图形 API,支持计算着色器(Compute Shader)和间接绘制,WGSL 取代 GLSL。three.js 随之推出 WebGPURenderer 和 TSL 着色语言。
  • AI 与 3D 汇合(进行中):一方面官方文档开始为 LLM 优化(下文的 llms.txt),另一方面 Text-to-3D / 图生 3D 模型工具开始进入生产流程。

工具链层面同样在演进:从「全局脚本 + 多个 script 标签」到 ES Module + Import Map,从手动管理矩阵到场景图/材质系统,从 GLSL 手写着色器到节点式着色语言。这些变化决定了今天写一个网页 3D 项目的「正确姿势」。

二、three.js 的 llms.txt:给 AI 看的官方文档

2.1 llms.txt 是什么

llms.txt 是 2024 年由 Answer.AI 提出的社区约定:网站在根路径放一个 Markdown 索引,供大语言模型快速定位权威内容——因为直接把整站 HTML 塞给模型,噪音太大。

three.js 官方文档现在提供了 threejs.org/docs/llms.txt,开头第一句就是「生成 three.js 代码时请遵循以下指南」。它同时承担两个角色:

  1. 防坑指南:用 WRONG/CORRECT 正反代码对照,纠正 LLM 最常犯的过时写法;
  2. 文档导航:按板块列出官方手册与 API 的链接,让模型(和人)按需深入。

2.2 结构概要

整份文件的组织如下:

板块 内容
Instructions for LLMs 四条强制规范:Import Map 写入方式、WebGLRenderer vs WebGPURenderer 的选择标准、TSL 着色语言、NodeMaterial 材质族
Getting Started Installation / Creating a Scene / Fundamentals / Responsive Design
Renderer Guides WebGPURenderer 指南
Core Concepts TSL 指南与参考、动画系统、模型加载、场景图、材质、纹理、灯光、相机、阴影
Essential API 按 Core / Scenes / Cameras / Renderers / Objects / Materials / Geometries / Lights / Loaders / Controls / Math 分类的 API 链接

其中最有时代感的两条规范:

① 引入方式——明确禁止 LLM 最爱生成的旧式写法:

1
2
3
4
5
6
7
8
<!-- WRONG:LLM 十有八九会生成这个 -->
<script src="https://unpkg.com/three@0.128.0/build/three.min.js"></script>

<!-- CORRECT:ESM + Import Map -->
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.module.min.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.0/examples/jsm/" } }
</script>

② 渲染器选择——默认用 WebGLRenderer 保证兼容性;只有需要 TSL 自定义着色器、Compute Shader 或节点材质时才上 WebGPURenderer(且必须 await renderer.init())。TSL 的目标之一是让同一份着色逻辑在两个渲染器间迁移。

给 AI 看的文档,本质上是把「这个生态认为的正确姿势」固化成机器可读的约束——对我们人类的价值是:它是这个生态当前最佳实践的一份浓缩清单。

三、Tidewater:看看 WebGPU 能把网页做到什么程度

如果说 llms.txt 是「怎么正确写」,那 Tidewater(作者 Daniel Greenheck,MIT 协议)就是「能写到什么程度」的样本:一个直接跑在 WebGPU + WGSL 上、自研渲染引擎、零框架的岛屿钓鱼游戏——驾船出海、抛竿、搏鱼、天黑前把渔获卖给码头边的 Joe。

几个让我印象深刻的技术点:

  • 水体:Tessendorf 谱方法的四级联 FFT 海洋(含泡沫、白帽、风条纹、涌浪),加上深度感知的破碎浪、浅水上冲流、船尾迹和海床焦散——基本是主机游戏的配置;
  • 大气与光照:基于物理的 Hillaire 2020 大气模型(太阳/月亮/星空)、体积云及其投影、级联阴影 + 屏幕空间接触阴影、GTAO 环境光遮蔽;
  • 后处理链:TAAU 时序超分 + 锐化、bloom、自动曝光、运动模糊、水下合成;
  • 工程化:Vite 构建、src/engine|ocean|sky|world|post|game 分层清晰、无头引擎冒烟测试、大量 ?noClouds/?noSim 类降级开关、动态分辨率。

页面有一条很诚实的提示:首次启动要编译数百个着色器,「可能需要一两分钟」,之后靠浏览器缓存加速。这其实是 WebGPU 应用当前普遍的代价——着色器编译与管线缓存策略,会直接影响用户的第一印象。

它和 llms.txt 一起构成了一个有意思的对照:three.js 在把「正确姿势」标准化以便普及,而 Tidewater 们在探索标准化之外的极限。两者都值得写业务代码时对照着看。

四、本项目:给小朋友的太阳系

有了上面的坐标系,再回头看这个项目要做什么:给 5~10 岁的小朋友做一个「太阳系漫游」——可以选星球抵近观看、切换视角看整个太阳系旋转、抵近看地月旋转、每个星球有中英双语说明。成品在这里(入口在博客导航)。

太阳系全景:太阳、轨道线、小行星带与八大行星

4.1 约束决定架构

三条约束几乎决定了所有技术选择:

  1. 观众在国内、以平板和手机为主 → Three.js 必须本地 vendored(不走 CDN),页面不能有国外依赖;
  2. 面向 5 岁小朋友 → 没有文字化 UI(零件/信息全是画出来的或图标化)、没有失败状态、暗面不能黑到看不见;
  3. 和游戏合集同一个部署体系(Hexo 产物仓库 + rsync)→ 页面放 source/games/solar-system/,用 skip_render: games/** 原样拷贝。

最终结构非常克制:

1
2
3
4
5
6
source/games/solar-system/
├── index.html 页面外壳(深空主题 + UI 面板 + 51LA 统计)
├── main.js 场景 / 交互 / 双语数据(约 500 行)
├── lib/three.module.min.js vendored three@0.170(约 660 KB)
├── tex/earth.jpg moon.jpg NASA 公有领域贴图(地球/月球)
└── tools/build-solar.mjs 打包自包含单文件 dist/solar-system.html

4.2 几个核心设计

数据驱动的天体表。太阳、行星、月球都是同一份 BODIES 数据表的实例:半径、轨道、自转、轴倾角、贴图类型,以及中英双语的说明文案。信息卡、底部胶囊按钮、射线拾取全部由这张表驱动——加一个天体就是加一行数据。公转周期按开普勒第三定律压缩(T ∝ a^1.5),水星飞快、海王星缓慢,比例关系是真的:

1
2
3
// 开普勒第三定律的玩具版:轨道半径决定角速度
const a = (simT * KEPLER) / Math.pow(body.orbit, 1.5);
sys.position.set(Math.cos(a) * body.orbit, 0, Math.sin(a) * body.orbit);

程序化贴图 + 真实贴图的混合。木星条纹、火星锈斑与极冠、水星陨石坑全部用 canvas 逐行生成(512×256 一次性生成,零外部资源);只有地球和月球用 NASA 公有领域贴图。小朋友看到的土星不是「贴图球」,而是条纹 + 大红斑 + 冰环的组合。

木星特写:程序化条纹与大红斑(NASA 贴图的地球/月球在文中另有展示)

自写轨道控制。拖拽旋转、滚轮缩放、双指捏合,加起来六十行——不引入 OrbitControls 附件,vendored 目录里只有一个 three 模块文件。聚焦某个天体时,相机会被摆到「太阳 → 天体」的延长线上(略抬高),保证看到的是被照亮的一面,地月同框时月球轨道环也一目了然。

抵近地球:地月同框,月球轨道环清晰可见

面向 5 岁的视觉兜底。环境光调到 1.0,让行星暗面呈深蓝而不是纯黑;行星名用 sprite 标签(中文 + 英文)随场景旋转,可一键关闭;所有交互目标都是大按钮。

4.3 单文件打包:Import Map 的另一种用法

和游戏合集一样,这个页面也要有「双击就能玩、微信转发就能开」的版本。做法是把 three.js 整个模块文件变成 import map 里的 data URL:

1
2
3
4
5
<script type="importmap">
{ "imports": { "three": "data:text/javascript;base64,..." } }
</script>
<script>window.TEX_URLS = { earth: 'data:image/jpeg;base64,...', moon: '...' };</script>
<script type="module">/* main.js 原样内联 */</script>

main.js 里的 import * as THREE from 'three' 一个字不用改,贴图路径在打包时替换为 data URL。最终 dist/solar-system.html 约 1.9MB,无任何网络依赖。

4.4 踩坑实录(保存时间最长的三条)

坑一:白球事件——「赋值写在了错误的重构里」。上线后实测地球是纯白球体。排查链路:屏幕中心射线拾取确认「白球确实是 Earth 网格」→ 浏览器内采样贴图文件确认「文件和解码都正常(2048×1024,均值 RGB 84,92,105)」→ 材质探针确认 map 已绑定——最后发现是一次重构把 earthMesh.material = realEarth 这行赋值弄丢了:带贴图的材质创建出来了,但从未挂上网格,场景里一直渲染的是占位用的白球。教训:「材质已创建」和「材质已挂上网格」是两件事,肉眼断言不可靠,要用射线拾取+像素采样这种可复现的证据链。

坑二:three r155+ 的物理光照单位。PointLight 的 intensity 语义在 r155 之后变成物理单位,旧教程里的数值直接照抄会过曝——地球被冲成白球有一半功劳在它。最终方案:点光 1.15 + 环境光 1.0 + ACES 色调映射(exposure 0.9),过曝和死黑两头都压住。

坑三:无头测试里 rAF 是冻结的。自动化截图时 requestAnimationFrame 驱动的动画可能一帧都不跑。解法和官方测试套件思路一致:暴露一个手动推帧的钩子,测试里循环调它——这比在测试环境里折腾虚拟时间的 rAF 可靠得多。

五、结语

回到开头的时间线:从插件时代的 Flash 到今天一个 5 岁小朋友可以在 iPad 上拖动旋转的太阳系,浏览器 3D 用十五年走完了「能力补齐 → 生态标准化 → 极限探索」的三段路。three.js 的 llms.txt 代表前两段的沉淀——把正确姿势文档化;Tidewater 代表第三段——在标准之上把实时渲染推到主机游戏的配置。

对我们这种「给小朋友做一个页面」的项目来说,结论很简单:用 three.js + 本地 vendored + 数据驱动的架构,就在「正确姿势」的安全区里;而 Tidewater 们展示了这条路的天花板有多高。

参考