three.js r187 之后:运行时追上了,工具链还差一截
2026-10-31 有一篇文章拿 three.js 的 r187 当证据,说"浏览器现在是 3D 图形的正经家园"。这话一半是对的,而错的那一半很贵。
对的一半:运行时确实追上了。WebGPU 是真的,three.js 在一个场景图后面挂着两套后端,一个线上产品配置器今天就住在一个 URL 里。没对的那一半:工具链没跟上,而这两件事很容易混淆,因为它们看起来都是"demo 在我笔记本上跑得很顺"。
一个团队如果把版本号当绿灯,跳过性能剖析、资产管线和资源回收,最后交付的东西会是这样:画面漂亮,但在显存预算 400MB 的中端安卓平板上崩掉,首帧卡两秒。
本文完整编译英文源文 HackyJS / pr0h0,2026-11-01《Three.js r187 and the Tooling Gaps Between Browser 3D and Native Rendering》 的全部要点(原文自述 95% 由 AI 撰写),保留了它的代码、实测数字与自认的边界。我们没有在这些设备上复测,数字均为源文单机结果。
一、r187 这轮报道究竟证明了什么
确认的部分很小:一个版本号存在、发布方、日期、以及对它信号的编辑判断。这些都没错——three.js 的 rN 大约每月一发,每次都有渲染器、加载器、材质系统的实质工作。
没被证明的部分:源报道里没有 diff、没有 changelog 链接、没有基准、没有特性清单。它无法告诉你 r187 到底动了 WebGPU 后端、TSL 节点库、KTX2 转码接线,还是都没动。
要坐实这件事其实很无聊也很具体:拉 r187 的 tag 和 release notes,看 REVISION 常量在前后两个 tag 之间的 diff,读 src/renderers/、src/nodes/、examples/jsm/loaders/ 下的提交——十分钟就知道它对你的负载有没有意义。
版本号框架本身就是误导:three.js 没有 major/minor 与破坏性变更契约,只有递增的 rN。版本号是一个多绕了几步的时间戳。 有用的问题不是"r187 加了什么",而是"哪些差距在它之前就窄了,哪些还宽着"——这个问题不用 r187 也能回答。
二、一个库里两套运行时
WebGLRenderer 和 WebGPURenderer 挂在大致共享的场景图下面,但它们不是可互换的:
| WebGLRenderer | WebGPURenderer(three/webgpu) |
|
|---|---|---|
| 目标 | WebGL2(能力天花板由扩展决定) | WebGPU 规范 |
| 能力获取 | 逐个探测扩展:EXT_texture_compression_bptc、WEBGL_compressed_texture_astc/ etc、KHR_parallel_shader_compile |
请求 adapter,读 limits / features 再规划 |
| 模型 | 全局状态机 | pipeline + bind group |
| 计算着色器 | 没有 | 有 |
| 编译 | compileShader + linkProgram,无并行扩展时阻塞主线程 |
pipeline 创建 |
后果很实际:凡是伸进 renderer.getContext() 发原始 WebGL 调用的代码、依赖某个压缩纹理扩展存在的代码、假设 uniform 更新顺序的代码,换后端就会变形或直接坏。WebGPURenderer 甚至接受 forceWebGL: true 走 WebGL2 后端(做 A/B 对比很好用)——但也意味着 "我们换了 WebGPURenderer" ≠ "我们跑在 WebGPU 上"。
可移植层只有一条:节点材质 + TSL
设计意图是节点材质加 Three Shading Language:不写 GLSL 字符串,拼类型化节点图,后端把它降级成 GLSL 或 WGSL。降级这一步就是全部的可移植性故事。
原始 GLSL 是这条故事的终点——ShaderMaterial 里的字符串着色器在 WebGPU 后端无法被任意降级:
// 只在 WebGL 有效,换到 WebGPU 后端就是死路
const glslOnly = new THREE.ShaderMaterial({
vertexShader: `
uniform float uTime;
void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `void main(){ gl_FragColor = vec4(1.0); }`
});
同样的效果写成节点图,两套后端都能降:
const nodeMat = new MeshStandardNodeMaterial();
nodeMat.positionNode = positionLocal.add(
vec3(0, sin(time.add(positionLocal.x)).mul(0.1), 0)
);
代价也要说清楚:节点系统对冷门内置特性的覆盖历史上落后于 ShaderMaterial;大节点图的调试体验比读四十行 GLSL 差;TSL 语法在版本间挪过位置。但它是唯一能扛过后端切换而不重写的路径,所以它该在第一天就写进架构,而不是当后期迁移工程。
能真跑的检测
别用 UA 猜,也别在渲染时 catch 崩溃。提前检测、明确回退、并把原因打进日志:
export async function createRenderer(canvas) {
if (!navigator.gpu) {
console.warn('[renderer] navigator.gpu 缺失 —— 没有 WebGPU');
return null;
}
try {
const renderer = new WebGPURenderer({ canvas, antialias: true });
await renderer.init(); // device 就绪才 resolve
console.info('[renderer] WebGPU 后端生效');
return renderer;
} catch (err) {
console.warn('[renderer] WebGPU 初始化失败,回退:', err.message);
return null;
}
}
这个检查不告诉你 adapter 是否满足你的限额。依赖大 storage buffer 或特定纹理格式时,要拿 adapter.limits / adapter.features 与一份显式需求表逐项比。"有 WebGPU"和"WebGPU 够用"是两个问题。
三、先钉死基线,再谈基准
后面每个数字都来自一个钉死的版本。不确定版本的 CDN 引用,是基准变成传说的标准路径。
mkdir three-baseline && cd three-baseline
npm init -y && npm i three@0.187.0
node -e "console.log(require('three').REVISION)" # 187
npm 版本号直接跟 rN 对齐。不用打包器就用 import map——注意 three/webgpu 与 three/tsl 入口必须显式列出,直接 import three 拿到的是 WebGL 构建,里面没有 WebGPURenderer。
然后搭一个确定性场景:固定相机、固定灯光、一个资产、无动画、无 resize 抖动;如果摆放用了 Math.random 就把它冻住。场景不需要漂亮,需要每次一模一样。
renderer.info 是这个生态里最便宜的跨机指标——渲染器本来就在维护这些 CPU 侧计数,读它零成本:
export function sampleAfter(renderer, scene, camera, frames = 60) {
return new Promise((resolve) => {
let n = 0;
renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
if (++n === frames) {
renderer.setAnimationLoop(null);
resolve({
calls: renderer.info.render.calls,
triangles: renderer.info.render.triangles,
geometries: renderer.info.memory.geometries,
textures: renderer.info.memory.textures,
});
}
});
});
}
前 5 帧通常含着色器编译与上传尖峰,必须跳过预热再采样。200 个网格的基线输出是这样:
{ calls: 214, triangles: 1843200, geometries: 203, textures: 11 }
calls 是 214 而不是 200,因为阴影贴图与背景各算一次。这不是 bug,这正是"读计数器而不是在心里数网格"的理由。
四、资产管线:原生仍赢的地方
glTF 2.0 是唯一的交付格式,没有像样的竞争者:Khronos 开放规范、干净映射到 GPU 图元、three.js 的 GLTFLoader 是生态里维护最好的加载器。
glTF 不携带的东西才是有意思的部分:核心规范描述的是材质模型(metallic-roughness 加一组固定贴图槽),不是着色器图。Blender / Maya / Houdini 里复杂的材质图导出时不完整保留——被烘成少量贴图并丢掉参数化;程序化节点设置完全没有表示;DCC 专有形制(修改器栈、实例化设置、约束)被压平或丢弃。
这是从原生移植过来最常见的意外:场景"过得很顺",直到有人发现织物着色器现在是一张带接缝的 PNG。
贴图:KTX2 / Basis 是转码,不是解压
你发的是 Basis Universal 超压缩源,加载时转码成设备真正支持的压缩格式(移动 ASTC、桌面 BC7、兜底 ETC2)。下载体积小,因为源格式紧凑;运行时挑原生格式,所以 GPU 看不到未压缩位图。
取舍和直觉相反:省下载字节,花上传时间。转码要占 CPU(主线程或 worker),上传也不免费。桌面快网 + 温缓存时,KTX2 首绘可能比压得好的 PNG 更慢;冷启动的移动端它决定性地赢——而后者通常才是要紧的场景。
const ktx2 = new KTX2Loader()
.setTranscoderPath('/basis/') // basis_transcoder.js + .wasm
.detectSupport(renderer); // 由 renderer 决定目标格式
const loader = new GLTFLoader().setKTX2Loader(ktx2);
detectSupport(renderer) 是最常被忘的一行:没有它,加载器不知道该转成哪种格式。
几何:Draco 与 meshoptimizer
Draco(KHR_draco_mesh_compression) |
meshoptimizer(EXT_meshopt_compression) |
|
|---|---|---|
| 解码成本 | 高(WASM 解码器逐图元做真活) | 低(设计成快速滤波器) |
| 属性支持 | 广,含自定义属性 | 广,对动画与实例化数据支持强 |
| 体积收益 | 密集静态网格最大 | 略小,但加载明显更快 |
| 接线 | setDRACOLoader(new DRACOLoader().setDecoderPath('/draco/')) |
setMeshoptDecoder(MeshoptDecoder) |
经验法则(源文明确标注为经验而非实测):默认用 meshopt;下载体积是硬约束且付得起解码成本时再上 Draco。动画数据或实例化多的场景,meshopt 几乎总是更好的交易;单块密集主资产 + 慢速网络,Draco 多出来的压缩比值得。
两者都在关键路径上加一次 WASM 拉取。都不免费,也都不替代你在限速网络下实测冷加载。
浏览器没有答案的四件事
- 离线烘焙光照(原生农场跑几小时,出贴图)——浏览器要么也离线烘(等于回到原生),要么用实时 GI 近似吃帧预算。
- 虚拟纹理(16K 贴图集 + 常驻管理器是原生引擎标配)——网页等价物是研究项目,不是库调用。
- 离线着色器变体编译(原生在构建期编全部排列组合)——浏览器在首次绘制时、在用户设备上、用那块设备的驱动编。
- 带增量重建的资产依赖图(改一张贴图知道哪份材质、哪个场景失效)。
项目依赖前三条中的任何一条,就为它单列预算,别指望未来的版本把它吸收掉。
五、首帧卡顿
新材料第一次绘制触发的工作藏不住:WebGL 上是 compileShader + linkProgram(没有 KHR_parallel_shader_compile 就阻塞);WebGPU 上是 pipeline 创建,含着色器模块编译与驱动内部缓存。
放大器是变体爆炸:一个"标准"材质 × 3 张可选贴图 × 2 种阴影模式 × 实例化开关,不是一个程序,是一张排列组合表。30 秒里引入 12 个变体就是 12 次卡顿,且落点不可预测——这比一次长卡更糟,因为用户感知到的是"卡"而不是"加载"。
三种确实有效的缓解:
- 预热渲染:用户看到任何东西之前,把场景渲到 1×1 视口一次,再揭示。WebGL 后端用
renderer.compile(scene, camera)做完一半。WebGPU 后端近期版本有异步编译路径——按你钉死的那版查,这块接口动过。 - 渐进引入材质:在前 N 秒按固定顺序引入变体,摊薄成本,让不可见对象的变体延后。
- 程序缓存:平台允许时后端级缓存跨加载存活,投入产出比最高,但你能控制的最少。
三者减的不是同一件事:预热减"可见卡顿的频率",渐进减"单次卡的幅度",缓存减"总工作量"。分开量,否则你不知道哪个起作用。
从 JS 里修不了的:pipeline 缓存能否跨刷新存活、能否跨浏览器更新存活,都在应用控制之外。变体预编译同理——你能提前触发编译,但不能为没见过的设备离线编译。这是原生保留的结构性优势,也是最容易被用户看见的那个:在别的方面都顺的体验里,突然卡两秒。
六、性能剖析:好工具,但不是原生的等价物
浏览器侧真实可用的:renderer.info(粗粒度计数,免费连续)、Spector.js(WebGL 单帧捕获:每命令状态、贴图内容、程序源码)、Chrome Performance / tracing(CPU 侧归因:长任务、布局、GC)。
一个五分钟就能做的捕获流程:
- 开着 DevTools 复现慢交互;
- 只录坏状态下三帧的 Performance trace;
- 读最大的长任务——在渲染调用里就是 GPU 提交或编译,在你的代码里就是场景图/矩阵更新;
- 换 Spector.js 抓同一帧,看绘制调用清单;
- 拿 Spector 的绘制调用数与
renderer.info.render.calls对比。两者不一致,说明场景图有陈旧节点。
第 5 步是最能捞回浪费掉的那几周的地方:绘制调用数随导航上升、可见网格数没变,那是泄漏,不是性能问题。
拿不到的:GPU 计时、厂商计数器、占用率、着色器指令级成本、可靠的显存驻留数字。WebGPU 的 timestamp query 藏在开关后面,且按厂商而异。对比原生捕获(RenderDoc / PIX)第一次就能拿到每通道 GPU 时间与资源驻留——差距很具体:CPU 侧错误你能在浏览器里找完并修好,GPU 受限的优化闭环在浏览器里合不上。 你只能靠二分经验(关后处理、砍一半分辨率、减少 overdraw)从差值里推断,那是几天对几小时。
一个今天就能跑的对照
2,000 个静态盒子、三种材质、一盏带阴影的方向光、无后处理;three r187 / macOS / M2 Pro / Chrome stable:
| 做法 | calls | triangles | 中位帧时 | p95 |
|---|---|---|---|---|
每个盒子一个 Mesh |
4,012 | 24,000 | 14.6 ms | 19.1 ms |
三个 InstancedMesh(按材质) |
14 | 24,000 | 3.2 ms | 4.0 ms |
三角形数必须一样——实例化不减几何,只减提交。两个提醒:calls: 14 里含阴影与背景通道,不是"场景 14 次绘制";以及这是最容易的一类收益,因为批处理用 renderer.info 就能量,不需要任何 GPU 计数器——恰好是浏览器工具擅长的题型。
七、内存与回收:JS 堆边界
BufferGeometry 持有的 typed array 归 JS 引擎回收;它上传到的 GPU 缓冲不归任何 JS 能控制的东西管。贴图、render target、编译好的程序同理。
契约是 dispose():调用时 three.js 发 dispose 事件,后端释放对应 GPU 资源;只丢引用时,JS 侧最终会回收对象,显存则挂在那里,直到上下文丢失。
让东西活着的引用都是熟面孔:材质持有贴图引用而 material.dispose() 不会释放那些贴图;渲染器按材质与 defines 缓存已编译程序,改 material.defines 就新建一个并让旧的成孤儿;scene.remove(mesh) 只摘节点;你为拾取、动画、撤销历史留的任何数组/Map/闭包都会把整棵子树拖住。
靠代码评审的自律撑不过 deadline,用一个跟踪器:
export function createResourceTracker() {
const tracked = new Set();
return {
track(r) { tracked.add(r); return r; },
disposeAll() { for (const r of tracked) r?.dispose?.(); tracked.clear(); },
};
}
每个构造点都包一层,卸载时整组释放;别忘了 renderer.renderLists.dispose() 丢掉已移除对象的渲染列表缓存。
跨页面导航该盯的泄漏症状极其具体:renderer.info.memory.geometries 与 .textures 随导航单调上升、从不回落。把这两个数字放到 FPS 旁边的开发浮层里,稳态应该是平的,不是锯齿向上的。
(performance.measureUserAgentSpecificMemory() 能给真实堆归因,但只在 cross-origin isolated 上下文里可用——那是部署约束,不是调试便利。)
八、浏览器真赢的地方
- 零安装触达:一个 URL 在会议室的手机上就能打开,安装包不能。
- 可寻址的场景状态:相机、选中、变体住在 query string 里,于是共享评审链接和可用的后退按钮是免费的——原生要花几周去逼近。
- CDN 缓存:最大的资产在同一区域内是同一份字节,边缘缓存、按文件名失效。
- 组织内当天更新:修完所有人都有,没有更新提示、没有版本错位、没有 IT 工单。
对产品配置器、数据可视化讲解、内部设计评审工具来说,这些优势就是产品本身。在那里做原生不是慢一点的同一产品,是更差的产品。
模型崩塌的具体形态
别说"大场景慢",要具体:
- 大场景先死在显存驻留,不是绘制调用。 移动浏览器在显存压力下会丢上下文,恢复路径(重新上传所有东西)表现为几秒白屏。这在资产规模上暴露,不在复杂度上。
- 长会话先死在泄漏。 一个在仓库里开八小时的配置器,会攒下用户试过的每个变体的贴图句柄,标签页变成吃内存的怪物,然后被系统杀掉。
- 重后处理先死在填充率,而这恰好是浏览器剖析帮不上忙的场景——你 GPU 受限却没有 GPU 计数器。
三种都有网页侧的缓解,没有网页侧的根治。知道你的项目会先撞上哪一个,比一张功能清单值钱。
九、移植前的判据表
| 特征 | 建议 | 为什么 |
|---|---|---|
| 场景复杂度 | 批处理提交下约 1–2M 三角形以内可行;超过先量再定 | 绘制调用成本可控,原始几何吞吐受设备限制,移动 GPU 差一个数量级 |
| 会话时长 | 分钟级用浏览器;小时级用原生 | 长会话被回收 bug 与上下文丢失恢复支配,两条在浏览器里都更严 |
| 资产迭代速度 | 浏览器完胜 | CDN 失效 + 当天部署,安装包比不了 |
| 目标设备 | 未知/很广用浏览器;固定 kiosk 或工作站用原生 | 你控制不了浏览器里的 GPU,固定硬件才吃到原生的强项 |
| 保真度上限 | 需要虚拟纹理、离线烘焙、重后处理就选原生 | 今天没有生产级网页等价物 |
| 分发触达 | 浏览器完胜 | 评审、演示、现场使用,一个链接胜过安装包 |
十、什么情况下这个结论会翻转
以下全部是前瞻推断,不是观察:
- WebGPU 可用性广泛且一致,包括今天回退到 WebGL2 的中端安卓。可用性在改善,跨厂商 limits/features 的一致性是更难的那半。
- 可移植的 pipeline 缓存落地——能跨会话、跨浏览器更新存活,首帧卡顿就从"缓解练习"变成已解决问题。这是用户可见影响最大的一项。
- 浏览器内真正的 GPU 剖析——厂商稳定支持的 timestamp query 加上读它的 DevTools 面,能合上今天要花几天的优化闭环。
- 网页原生资产管线追平 DCC 导出保真度——材质图与创作形制能在往返中存活。目前没有接近的。
至少落地两条之前,把工具链差距当成平台固定成本来预算。
边界
- 原文自述 95% 由 AI 撰写,且作者把自己的若干建议明确标为"经验法则而非实测结果"(Draco vs meshopt 的选择就是)。
- 全部实测数字来自单机(three r187、M2 Pro、Chrome stable),绝对值会变,看的是比值。
- 文中"r187 新增了什么"作者刻意不写——因为源材料没有,他也不替它编。
- 本文为编译稿,我们未复测任何基准。
Sources
- 主源文:pr0h0《Three.js r187 and the Tooling Gaps Between Browser 3D and Native Rendering》,HackyJS,2026-11-01 https://hackyjs.com/posts/threejs-r187-and-the-tooling-gaps-between-browser-3d-and-native-rendering
- 起因报道:Startup Fortune,2026-10-31(源文称其为"种子",也是它能验证的全部范围)
- three.js 仓库与 tag:https://github.com/mrdoob/three.js
- glTF 2.0 规范(Khronos)、KTX 2.0 容器规范、
KHR_texture_basisu、EXT_meshopt_compression、KHR_draco_mesh_compression - WebGPU 规范(W3C);
KHR_parallel_shader_compile扩展 - Spector.js:https://spector.babylonjs.com/
同主题阅读
本文属于「Web3D / 游戏引擎 / 元宇宙」专题。BimAnt 编译整理,转载请注明出处。文中基准为源文单机数据,我们未复测;WebGPU/three.js 接口在版本间有变动,以你钉死的版本为准。
本文属于「Web3D / 游戏」专题。查看专题全部文章 →