1. WebGPU 和 TSL:社区这波热度到底从哪来
1.1 WebGL 时代压了十几年的两个天花板
先交代背景。Three.js 圈子里,WebGL 统治浏览器 3D 十几年,大家早就习惯了"调调材质、摆摆场景、打一个灯光"这套工作流。但这两年我明显感到,越来越多老项目开始卡住:不是美术不够好,而是渲染性能摸到了天花板。这个天花板有两个具体表现。
第一是 Draw Call。WebGL 的 API 继承自 OpenGL ES 2.0,设计年代很早,一个物体从绑定到绘制要做一堆状态切换,三千个独立物体的场景就能把主线程压得很满。所以社区里的高级技巧几乎都在做同一件事:用 InstancedMesh 合并实例、用 BufferGeometry 合并顶点、用纹理图集减少切换,本质都是绕开 API 本身的限制。这些技巧很有效,但写完之后自己看着代码都嫌啰嗦。
第二是"GPU 只能画不能算"。WebGL 没有通用的计算着色器,想做大规模粒子、流体、布料、软体物理,只能把数据塞进纹理或者顶点缓冲,用渲染管线"顺路"算一下。能算,但极绕,很多算法根本没法表达。这就导致"浏览器里做实时物理模拟"长期处于可以演示、很难落地的状态。
WebGPU 正好把这两个问题都接住了。它新的 API 设计把状态切换成本压得很低,同时提供了独立的 Compute Shader 管线,GPU 终于可以"先算后画",而且画的数据可以直接来源于算的结果。社区里很多憋了很久的玩法(百万粒子、流体、PBD 布料)一下就出来了。TSL 在这个时间点出现,又把"怎么写 shader"这件事从字符串时代拉到了函数式时代。两件事撞在一起,这波热度自然起飞。
1.2 TSL 革命的地方不在语法,在写代码的方式
很多朋友第一次看到 TSL(Three.js Shading Language)都会问:这不就是把 GLSL 换成 JavaScript 风格吗?语法不一样而已。我一开始也这么想,实际用了一周之后看法变了:TSL 真正改变的,是"着色器代码的生命周期"。
WebGL 时代最常见的写法是 ShaderMaterial 里贴一段字符串:
const material = new THREE.ShaderMaterial({ vertexShader: ` varying vec3 vColor; void main() { vColor = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` varying vec3 vColor; void main() { gl_FragColor = vec4(vColor, 1.0); } `, });这段代码的问题不在于 GLSL 难学,而在于它是一段"游离在工程结构之外的字符串"。IDE 不会给你提示,变量名拼错了要到运行时才报错,跨版本升级时gl_Position和 Three.js 内部绑定的名字一变,整段就要重写。最痛苦的是,想在两个材质之间抽取公共函数,你只能继续做字符串拼接。字符串拼接 shader,在稍微正规一点的工程里几乎是灾难。
TSL 的逻辑是把着色器程序当成普通 TypeScript 函数写,返回值代表某个输出挂点:
import * as THREE from 'three/webgpu'; import { Fn, positionLocal, sin, uv, vec4 } from 'three/tsl'; const material = new THREE.NodeMaterial(); material.positionNode = Fn(() => { return positionLocal; // 顶点位置 })(); material.colorNode = Fn(() => { return vec4(sin(uv().x * 10.0), uv().y, 0.0, 1.0); // 片元颜色 })();这里的每个数学运算都是一个节点对象,Fn(() => {...})()把函数体转成可复用的着色器图。编译器(three/tsl 内部那套)会把它编译成 WGSL 或者 GLSL,所以同一份代码在 WebGPU 和 WebGL 后端都能跑。对工程来说收益是实打实的:IDE 类型检查、单元测试、函数抽取、按模块拆分,全都恢复了。这在我接手过的几个项目里是革命性的。
注意:TSL 在 Three.js r16x 版本迭代很快,早期叫
tslFn,现在一般统一为Fn;部分挂点名也会变。下面代码以我记录时的常见写法为例,你上手前先看一眼当前版本迁移说明,别直接复制旧教程到新版本。
1.3 已有项目该不该现在换
这个问题我每周都会被问。我的态度很明确:新项目直接考虑 WebGPU + TSL,老项目按模块搬,别搞"周末全部重写"这种事。
WebGL 到现在还是最稳的浏览器 3D 方案,普及率最高。如果你的产品是数据大屏、走版演示、稳定运营的 WebGL 系统,现在强行切 WebGPU 可能给自己找一堆兼容性麻烦。反过来,只要项目满足下面一个条件,就值得开一块儿试验田:
- 需要上万级别的粒子、流体、布料,WebGL 要绕路才能做的;
- 要写多个自定义材质,并且打算把它们做成内部可复用组件;
- 主线程被 Draw Call 卡到没脾气,用 Instance 合并已经救不回来;
- 全新项目、无历史包袱,可以直接把
three/webgpu作为默认入口。
我个人的做法是:先把渲染器换成 WebGPURenderer 跑通原有场景,再挑一个高频页面/模块用 TSL 重写材质,验证性能收益,最后决定是不是全面铺开。这样风险可控,也有一个能拿得出手的对比数据。
2. 社区最火的 10 个 WebGPU + TSL 演示案例逐帧拆解
下面这十个是我在社区里高频见到的项目类型,也都实际拉下来跑过或者改过。每个案例我会说三件事:为什么大家会点开看、核心在炫什么技术、以及你能从中拿走什么。
2.1 百万粒子系统:第一个被 WebGPU 点爆的作品
粒子永远是新技术的最佳广告。原因很朴素:效果好,肉眼可见,工作量不大。WebGL 时代十万粒子就需要各种优化技巧,到 WebGPU 这里思路完全变了——把粒子位置和速度放在 Storage Buffer 里,用 Compute Shader 在 GPU 上做物理积分,然后直接把这块存储区当顶点属性绘制,整个过程 CPU 只参与了最初的数据初始化。
我跑过的一个社区 demo,200 万粒子在空间里做引力汇聚和相互排斥,帧率稳在 60。这里的关键是把积分逻辑搬进了计算着色器,而不是在 JS 里for循环更新位置。复现路径大概是这样的:先创建一个存储缓冲属性,把粒子的位置、速度、质量都注册成 Storage Buffer Attribute,用一条计算节点做积分,每帧调用renderer.compute()。从第二帧开始,所有位置更新都在 GPU 里完成,CPU 每帧只发一次 dispatch,不读回任何数据。这种"算完直接画"的数据流模式,是做 WebGPU 里所有高级效果的基础。
2.2 实时水体:从贴图欺骗到频谱模拟
水体是另一个传播量很高的类型。WebGL 时代海洋效果基本两条路:多层法线贴图滚动,或者 CPU 端做简易 Gerstner 波。WebGPU 社区的经典方案是 FFT 海洋:用计算着色器把海浪频谱做逆傅里叶变换,生成高度图和法线图,网格顶点位移和法线采样都来自这张图。
这个方案在 GPU 里跑的每一步都依赖 compute:频谱求解、IFFT、消散项处理。放到 TSL 里,你写的不是一段完整的 WGSL,而是一个返回"高度偏移"的函数,然后把它挂到material.positionNode上;法线是同一个函数求导之后的副产品,可以挂到material.normalNode。一个很讨喜的细节是,WebGPU 的 compute 和绘制可以共用同一块 Storage Buffer,海浪高度图算完之后,不需要拷贝一份给顶点着色器,直接引用就行。这类跨管线资源复用,在 WebGL 里几乎要写一堆 hack,到 WebGPU 变成了基础语法。
2.3 体积雾与云层:Raymarch 终于可以放手写
云和雾过去在 Web 端常用的做法,是叠加一堆半透明粒子和片层,用"方向 + 透明度"骗过眼睛。预算紧的时候没问题,但相机角度稍微刁钻一点就会穿帮。
WebGPU 的案例用的是 Raymarch 思路:在片元着色器里,从相机位置沿视线发射射线,步进采样一个 3D 噪声纹理,累积密度和颜色。表面看就是个循环,但在 WebGL 里我们不太敢写长循环,因为分支和循环的开销收不住。WebGPU 因为驱动层的整理,分支和循环的相对开销变低了,再加上 compute 可以做噪声纹理的预烘焙,体积云这类效果在浏览器里第一次有了"可实时交互"的质感。
TSL 的Fn非常适合封装 Raymarch 的步进逻辑,你可以把"采样噪声""累积密度""光照散射"拆成三个函数,像调库一样组合。这是社区里体积渲染类 demo 密度特别高的原因——还是那句话,WebGPU 给了表达空间,TSL 给了表达效率。
2.4 实时反射与正方体摄像机效果
正方体摄像机(CubeCamera)在 WebGL 时代就是做反射的标配工具:在物体位置放一个 Camera,向六个面各渲染一次场景,生成立方体纹理,再赋给物体做环境反射。WebGPU 社区里这个效果被反复拿来"复刻",因为大家对反射效果太熟悉了,一眼就能看出渲染品质变化。
核心代码其实是老面孔:
import * as THREE from 'three/webgpu'; // 注意:不同 Three.js 版本对立方体渲染目标命名有差异, // 老版本通常叫 WebGLCubeRenderTarget,新版本留意 CubeRenderTarget,按本地类型定义来选。 const cubeTarget = new THREE.WebGLCubeRenderTarget(256); const cubeCamera = new THREE.CubeCamera(0.1, 100, cubeTarget); scene.add(cubeCamera); // 每帧渲染前,先把镜面物体藏起来,避免反射到自身 reflectiveMesh.visible = false; cubeCamera.update(renderer, scene); reflectiveMesh.visible = true; reflectiveMesh.material.envMap = cubeTarget.texture;在 WebGPU 下跑这个经典流程,我第一次就踩了坑:CubeCamera 的渲染目标大小、纹理 flipY 行为、深度缓冲格式都和 WebGL 时代不完全一致,导致反射画面发暗或者上下颠倒。后面我在第 4 节单独展开,这里先记住一个结论:老 API 的流程能复用,但纹理选项不能照抄,得逐个核。
2.5 PBR 材质天花板:车漆、金属、次表面
车漆、各向异性金属、清漆层(Clearcoat)这类效果,在 WebGL 里要么靠 MeshPhysicalMaterial 现成参数,要么靠某位大神把 BRDF 公式翻译成长字符串 GLSL。到 TSL 时代,自定义 BRDF 变成了改材质图里的某个分支函数,你可以直接改高光项、改清漆层衰减因子、改次表面散射的半径。
社区里最出圈的一类是"虚拟展厅":一辆汽车放在旋转台上,周围是 HDR 环境光,车身反射环境贴图,清漆层负责那层高亮的反光。单独看这种 demo 的技术含量不如粒子系统,但它胜在"用户刚需"。电商、广告、汽车官网都需要这种 Web 端 3D 展示,所以传播量一直很高。这部分最值得抄的不是某个材质参数,而是"材质组件化"的思路:把车身漆面、玻璃、轮胎橡胶分别封装成 TSL 函数,在项目里像积木一样拼。这比我过去维护一大段 GLSL 舒服了不止一个量级。
2.6 程序化地形与 GPU 植被实例化
地形生成在 WebGL 时代也是大热门,但要实时改变地形,CPU 要不停更新顶点缓冲。WebGPU 社区的地形 demo 通常把高度图生成放进 compute,网格顶点采样噪声函数,再叠加侵蚀算法;植被部分则是把草的每一个叶片当成一个实例,位置和朝向全部由 compute 按地形高度生成。
复现的关键点在instanceIndex。在计算着色器里,每个线程负责一个实例,通过索引号加上噪声公式算出它"长在哪里、多高、往哪歪",再配上 LOD(细节层次),远处植被自动降级为简版模型。这样做的好处是植被数量从几千提到几十万,CPU 依然零负担。如果你对"GPU 驱动场景"这个概念一直有点模糊,这个案例是最好的入门教材。
2.7 音频可视化:FFT 数据直通着色器
音频可视化常年是创意编程社区的最爱,WebGPU 版本的玩法也没变:用 Web Audio API 的 AnalyserNode 拿到频域数据,上传给着色器,然后由 TSL 节点把低频、中频、高频映射成几何波动、粒子爆发或颜色渐变。
这个案例的教学价值很高,因为它同时用到了 uniform 上传和 compute 处理两条链路。频域数据从 CPU 传到 GPU,TypedArray 可以直接传给 uniform;如果你要做更复杂的响应,比如按频谱分频段驱动不同粒子群,再用 compute 做一次数据整理。TSL 里定义一个 uniform 数组再配合instanceIndex取值,这段逻辑非常直观。我的体感是,音频可视化特别适合作为 TSL 练手项目,输入数据现成、反馈即时、做出来又很适合拿去"炫"。
2.8 布料与柔体:PBD 约束求解
Position Based Dynamics(PBD)在 WebGL 时代就有 CPU 版本,但仿真规模撑不起来。社区里的 WebGPU 版本把 PBD 的三步——位置预测、碰撞约束、速度更新——全部写进 compute。布料逐顶点做约束求解,一张几千顶点的布料可以实时挂在交互手柄上摆动,这在 WebGL 里做,CPU 会非常吃力。
它让我真正理解了一句话:"compute 不是用来画东西的,是用来算东西的。"算完的结果可以画成布料,也可以拿去做场景物体的位置动画。只要你脑海里把"Draw Call"和"Compute Dispatch"拆成两条独立的调度线,整个 WebGPU 的思维就建立起来了。后面你再去看粒子、水体、地形,会发现全都是一套计算模式在不同业务场景里的重复。
2.9 程序化城市与建筑生长动画
"从一片空地,到一片发光城市"这类视频在社区传播量极高,因为它有很强的叙事性。实现上并不复杂:每栋建筑是一个挤出体,高度由噪声决定,生长动画由时间变量控制挤出顶点的高度。TSL 里这个玩法尤其简单,把positionLocal.y乘以一个由time和instanceIndex共同决定的生长因子即可。
这个案例对我的启示是:WebGPU 不只是堆性能,很多特效在 TSL 里"做起来顺手"会直接改变创意实现的意愿。过去想到一个效果,先要盘算"这段 GLSL 怎么写、跨版本会不会崩",现在写起来像普通业务代码,创意的摩擦成本低了。程序化城市这类项目也特别适合拿来给团队做 TSL 入门培训,代码量不大,效果足够震撼,能快速建立信心。
2.10 后处理全家桶:泛光、景深、动态模糊的 TSL 化
最后一个类型是后处理。WebGL 时代大家习惯用 EffectComposer 加一堆现成 Pass。到 WebGPU 时代,社区开始用 TSL 自己组装后处理节点,原因无非还是复用和类型安全。泛光(Bloom)可以从"亮度提取 + 高斯模糊 + 合成"三个小函数开始,动态模糊用上一帧的深度和投影矩阵反推像素运动。
这类 demo 不一定视觉效果最炸,但它最接近"生产可复用"。我见到的不少正式项目最后都会沉淀出一套自己的后处理管线,TSL 化之后这套管线可以作为 npm 包在多个项目间共享,这是传统字符串 GLSL 办不到的。如果你所在团队做可视化产品,我建议从后处理入手做第一波 TSL 技术储备,性价比很高。
3. 十个案例背后共同的三个技术底座
案例虽然类型不同,但拆到底你会发现它们全都在用同一套底层能力。理解了这三个底座,看任何 WebGPU/TSL 项目的源码都不会再晕。
3.1 Compute Shader:把"循环"搬进 GPU
传统渲染管线的思路是"顶点进、像素出",每次绘制是一个固定的处理流程。Compute Shader 则是完全通用的一组并行计算任务,你规定每个线程做什么,GPU 会开成千上万个线程同时执行。它和 CPU 循环最大的差别,不是"算得快",而是"并发数量大",而且计算过程不需要和绘制绑定。
在 Three.js 里,你甚至不需要手写一行 WGSL。定义一个 TSL 函数作为计算内核,把它传给compute()方法,每帧调用renderer.compute(computeNode)执行。函数里通过instanceIndex获取当前线程编号,读取存储缓冲里的数据、做更新、写回。数据就在原地更新,不需要离开 GPU。这个模式放到业务里,最常见的就是"动画系统数据在 GPU 里流动":位置、速度、生命周期这些状态不再每帧从 GPU 读回 CPU,而是持续留在显存里被 compute 迭代。一旦你想通这一点,WebGPU 项目的架构思路基本就过关了。
3.2 Storage Buffer 与资源复用:共享、序列化到底指什么
社区热搜里有"three.js 共享 序列化",很多人不理解。其实在我们做项目时,绕不开两类问题:资源复用和跨线程/跨页面传输。
先说资源复用。同一场景里,很多物体可以共享同一份几何体(BufferGeometry)和纹理(Texture)。Three.js 的 Material 也可以多个 Mesh 共用。这里有个容易踩的雷:如果你用"复制 geometry.array 再改"的方式给不同物体差异数据,因为共享引用,改一个会全部变化。正确的做法是先geometry.toNonIndexed()或者把 attribute 的 array 再拷贝一份,确保每个物体拥有独立的array引用。
再说序列化。保存场景、跨页面恢复、Web Worker 里加载模型,都需要把数据"打包带走"。Three.js 场景可以直接scene.toJSON(),模型的常用格式是 GLTF/GLB(二进制,体积小,加载快)。但注意:GPU 侧的 GPUBuffer、GPUTexture 这类资源是不能被直接序列化的,它活在显存里,结构上属于 GPU 上下文。你只能把原始数据(ArrayBuffer 或 TypedArray)从 CPU 侧序列化出去,到新环境重新上传。
所以做"多场景共享"时,我更建议共享"数据源"而不是共享"GPU 资源":维护一份 ArrayBuffer 作为顶点数据源头,每个场景各自上传自己的 GPU Buffer。如果用了 Worker + OffscreenCanvas,跨线程传的就是 Transferable 的 ArrayBuffer,浏览器零拷贝转移,不要用结构化克隆去传一个大数组,那是双重拷贝,卡到怀疑人生。
3.3 Node 材质:材质就是一张有向无环图
TSL 表面上是语法糖,底层其实是节点图。节点图做过设计软件的都不陌生:把几个输入节点接进一个数学节点,再接到输出节点,中间每一步都可以被复用和修改。TSL 的函数语法是人类友好的节点图编辑器。
Three.js 的 NodeMaterial 提供了一堆挂点:colorNode(片元颜色)、positionNode(顶点位置)、normalNode(法线)、diffuseNode(漫反射)、emissiveNode(自发光)等。普通 MeshStandardMaterial 本质也是一棵树,只是用参数化界面封装了。所以你在 TSL 里改一个material.colorNode,等价于把标准材质某个环节替换成自己的子图,其余部分还是走引擎默认逻辑。
这也是我坚持推荐 TSL 给业务项目的原因:你不用从零写一个完整材质,只需要在标准材质上"打补丁式"地改一个点。生产系统最怕的就是从 WebGL 字符串蔓延到全项目的"不可控魔法代码",节点图把这种魔法变成了可检查、可测试的工程结构。
4. 我把三个热搜效果搬进 WebGPU 管线的完整记录
光看别人 demo 不过瘾,我自己动手把它们搬到 WebGPU 管线里跑通,过程不算顺利,但踩过的坑都值得说。
4.1 正方体摄像机(CubeCamera)反射效果实战
场景很简单:一个亮面地面 + 一个金属球,球体要反射周围场景。动手前我以为"换皮肤"就行,实际第一步就卡住了。WebGPURenderer 的初始化必须先 await:
import * as THREE from 'three/webgpu'; const renderer = new THREE.WebGPURenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); await renderer.init(); // 关键:初始化是异步的再创建 CubeCamera,这里需要特别检查渲染目标格式:
const cubeTarget = new THREE.WebGLCubeRenderTarget(256, { type: THREE.HalfFloatType, // 用半浮点减小带宽,颜色精度也够 }); const cubeCamera = new THREE.CubeCamera(0.1, 100, cubeTarget); scene.add(cubeCamera);动画循环里注意顺序:
function animate() { requestAnimationFrame(animate); // 先隐藏被反射物体,避免反射到自己 reflectiveMesh.visible = false; cubeCamera.update(renderer, scene); reflectiveMesh.visible = true; renderer.render(scene, camera); }跑起来之后遇到两个很典型的坑:
- 反射图像发暗:原因大概率是渲染目标 colorSpace 或纹理
flipY设置和 WebGL 默认值不同,解决方法是创建 target 时手动检查texture.colorSpace、texture.flipY,和目标纹理的后处理设置对齐。 - 反射画面像"贴纸"一样跟着摄像机动:检查 CubeCamera 的 position 是否每帧都紧跟被反射物体,我一开始只设置了一次,物体一移动反射就穿帮。
提示:CubeCamera 本质是每帧渲染 6 个面,开销等于给场景多渲染几次。场景很重的话,建议降低分辨率(128 或 256)并隔帧更新,视觉上差别不大,性能立刻好一截。
4.2 雨、雪、雾三件套:天气系统的 TSL 写法
"three.js雨雪雾怎么实现"在搜索里一直很热。核心思路其实不复杂,老 WebGL 项目里我用 Particles 加自定义顶点动画搞定,到 TSL 里同样可以做,而且代码更干净。
雾最简单,用现成的 FogExp2 就行:
scene.fog = new THREE.FogExp2(0x9db6c0, 0.018);雨和雪的核心是粒子系统。雨滴用多个细长的线状粒子表达,雪用圆点加飘落偏移。用 TSL 控制顶点动画:
import { Fn, uniform, positionLocal, instanceIndex, time, vec3 } from 'three/tsl'; const material = new THREE.NodeMaterial(); material.positionNode = Fn(() => { // 雨滴的垂直下落与水平风偏移 const offsetY = positionLocal.y.add(time.mul(30.0)); const offsetX = positionLocal.x.add(time.mul(1.5)); return vec3( offsetX.mod(200.0).sub(100.0), offsetY.mod(200.0).sub(100.0), positionLocal.z ); })();雪片则在 y 方向下落的同时,在 x、z 方向加一个正弦摆偏移,每个粒子的相位可以用instanceIndex生成一个伪随机数:
import { sin } from 'three/tsl'; material.positionNode = Fn(() => { const t = time.mul(2.0).add(instanceIndex.toFloat().mul(0.01)); const swayX = sin(t).mul(1.5); const swayZ = sin(t.mul(1.3)).mul(1.0); const fallY = positionLocal.y.sub(time.mul(4.0)); return vec3(positionLocal.x.add(swayX), fallY, positionLocal.z.add(swayZ)); })();这段代码跑起来,一万片雪花也只是一个大 Points 的绘制调用。注意几个细节:粒子最好复用同一缓冲,重生用取模(mod)完成;材质要开 transparent 和 depthWrite: false,否则雨滴会把雪地糊成一片;如果粒子数量超过十万,别再在 CPU 每帧写位置,直接上 compute 方案。
4.3 WebGL 项目迁移 WebGPU 的注意清单
迁移不是换一个渲染器类那么简单,下面是列好的清单,每一条都是我或身边朋友实际踩过的:
| 事项 | WebGL 时代 | WebGPU 时代 | 注意点 |
|---|---|---|---|
| 导入路径 | three | three/webgpu | 混着导入会导致两个渲染器体系并存,内存和类型都会乱 |
| 渲染器 | WebGLRenderer | WebGPURenderer | 初始化是异步的,必须await renderer.init() |
| 自定义材质 | ShaderMaterial | NodeMaterial | GLSL 字符串不再直接用,要转 TSL 节点 |
| 阴影 | PCF 为主 | 支持 PCF/VSM 等 | 阴影参数兼容但表现有差异,需逐个验证 |
| 纹理 | flipY 默认 true | 默认值有差异 | 上传纹理前确认 flipY/colorSpace |
| 抗锯齿 | 看显卡 | 默认 MSAA 可选 | 用antialias参数或手动 setMSAA |
还有一个最容易被忽略的点:兼容性检测。上线前必须写一段兜底逻辑:
async function checkGPU() { if (!('gpu' in navigator)) { return 'fallback'; // 当前浏览器不支持 WebGPU } try { const adapter = await navigator.gpu.requestAdapter(); return adapter ? 'webgpu' : 'fallback'; } catch (e) { return 'fallback'; } }我的经验是把 WebGL 渲染器保留一套,作为 WebGPU 不支持时的保底路径。两套渲染逻辑在业务层尽量抽象成同一调用,比如renderer.render(scene, camera)语义一致,页面在启动时按检测结果选择。虽然维护两套听着麻烦,但实际改动面很小,换来的是产品稳定性。
5. 生产级落地:Vue3 + Three.js + TypeScript 的机房可视化
聊完炫的,聊点更接地气的。很多团队做机房/数据中心可视化,技术栈选 Vue3 + TypeScript 的概率很高,这块也是社区热搜里一直有的话题。我以一个实际做过的项目为例,说说 WebGPU/TSL 到来之前,这套架构该怎么组织,以及它未来可以怎么演化。
5.1 为什么选 Vue3 + TS 而不是纯 JS 页面
机房可视化本质是一个"长期维护"的业务系统,不是一次性演示页,所以工程性比花哨更重要。Vue3 的响应式系统和组件化非常契合"选中设备、弹出面板、更新状态"这类交互,TypeScript 则保住了 Three.js 场景对象(Mesh、Camera、Light)的类型边界。
我的目录结构大致是这样:
src/ views/ // 页面组件,一块一块的场景挂在组件里 components/ // UI 组件(设备面板、告警列表) three/ // Three.js 场景封装 scene.ts // 初始化 renderer、camera、scene models.ts // 模型加载和实例化 interaction.ts// 射线拾取、鼠标交互 data.ts // 接收 WebSocket 数据并驱动场景 store/ // Pinia:保存选中设备、筛选条件、告警状态关键点是"场景不写在组件里"。Three.js 的 renderer 实例、动画循环、事件监听都绑定在组件声明周期上,直接在 Vue 组件里new THREE.Scene()很容易在组件销毁时忘了释放资源。我把它们都封装成类,组件只负责创建实例和调用更新方法。这一点对新手来说特别重要,很多人第一个坑就是页面切换几次后浏览器内存暴涨。
5.2 机房场景的建模、加载与实例化
机房场景我建议分三类资源处理:
- 建筑外壳、装饰物:用 Blender 建模导出 GLB,走 DRACOLoader 压缩,加载一次。
- 机柜、服务器、空调:数量大、重复多,用 InstancedMesh。一个机柜是几百个部件,但整个机房两百个机柜,可以做成"一份几何体 + 两百组实例矩阵",Draw Call 从几百降到一两条。
- 灯带、指示点:用 Points 或 Sprite,一张小贴图反复实例。
实例化机柜的代码骨架:
import { InstancedMesh, Matrix4, Object3D } from 'three'; const rackGroup = new Object3D(); const matrix = new Matrix4(); // 在每个机柜位置摆一个实例 for (let i = 0; i < rackCount; i++) { matrix.makeTranslation(xArr[i], yArr[i], zArr[i]); rackGroup.matrixWorld.copy(matrix); mesh.setMatrixAt(i, rackGroup.matrixWorld); } mesh.instanceMatrix.needsUpdate = true;这段代码很多人第一次看会奇怪:为什么要临时组一个 Object3D 再取 matrixWorld?因为 InstancedMesh 的矩阵是相对世界坐标的,直接传一个临时矩阵容易出偏移。我吃过这个亏,后来养成了"先构造 Matrix4,再显式复制进实例矩阵槽位"的习惯,并确保每个实例的世界矩阵计算一致。
5.3 拾取、联动与实时数据面板
机房可视化的交互核心是:鼠标移到机柜上高亮,点击弹出设备详情,左侧表格点某一行,场景里对应机柜闪烁。这里有一条不能省的流程:raycaster 拾取。
import { Raycaster, Vector2 } from 'three'; const raycaster = new Raycaster(); const pointer = new Vector2(); function onPointerMove(event) { pointer.x = (event.clientX / window.innerWidth) * 2 - 1; pointer.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObject(rackInstances); // 高亮命中的实例,用 instanceColor 做染色 }高亮用 InstancedMesh 的 instanceColor 很顺手:给每个实例一个颜色属性,命中时把颜色改成高亮黄,离开时还原。实时数据(温度、功耗、告警)从 WebSocket 推过来的时候,我只更新一个 Int32Array 或 Float32Array,再把这个数组映射成 instanceColor 或者某个 scale 参数,完全不重建场景。
现场经验是:数据驱动的动画(机柜发热变红、空调功率调大叶片转动)要尽量用"改实例属性"和"改颜色"来解决,不要动辄重建 Mesh 或重新加载模型。机房场景在规模化之后全是性能的持久战,能省一次 draw call 就省一次。
5.4 这套架构到 WebGPU 会怎么演化
我对这套技术栈的判断是:先把"底层的 WebGL 依赖"从业务代码里剥离开,WebGPU 成熟后逐步替换底层渲染器。业务层的组件、Pinia、拾取逻辑基本不用动,因为 Three.js 的语义(Scene、Mesh、Raycaster、InstancedMesh)在 WebGPU 后端保持一致。
能明显受益的是两个模块:
- 大规模状态可视化:几百个柜子的温度场如果用 TSL + compute,每个机柜的温度粒子可以实时演算,散热动画不再依赖 CPU 每帧改几十个关节位置。
- 告警扩散效果:按机柜位置生成脉冲光效果,原来是逐个材质改 emissive,换成 TSL 后可以用一个全局脉冲函数挂到全部实例上,代码量少一半。
6. 现场翻车记录:7 天跑通 TSL 后踩过的坑与排查思路
这一节是实操里最想分享的部分。社区教程永远写得顺,但真实环境里报错才是常态。
6.1 TSL 编译报错的高频场景
第一类:Fn(() => {...})()外层少了括号。TSL 里函数体转节点需要一个"求值"动作,Fn(() => {...})只是定义了函数,()才是把它变成节点。少一个括号,挂到 colorNode 上的就是一个"函数对象"而不是"节点对象"。
第二类:向量和标量混算。GLSL 里vec3 + float有自动转换,TSL 的类型要求严格得多,vec3.add(float)有时需要手动float()包一层,或者用.toVec4(1.0)补一位。报错信息往往很长,但开头几个关键词就是缺少 cast 的地方。
第三类:混着导入three和three/webgpu。一旦项目里同时存在两个 Three.js 体系,单例判断就会出各种诡异现象,比如renderer.isWebGPURenderer是 undefined,排查起来非常痛苦。迁移阶段一定要全项目统一一个入口。
6.2 WebGPU 兼容性陷阱
我在一个客户现场遇到的情况是:开发机 Chrome 跑得好好的,测试机的环境不同,直接白屏。排查下来就是 WebGPU 不可用或者底层图形栈不支持。这类问题的第一道防线就是特性检测,前面给的checkGPU兜底代码一定要在生产环境加上。
浏览器支持矩阵建议翻 caniuse 或浏览器官方说明确认,我的体感是 Chromium 系最稳,其他浏览器的支持节奏不一样。线上项目想稳妥,就做双后端降级,别赌用户环境。另外,await renderer.init()这个异步初始化千万不能漏。漏掉的话,第一帧画面可能是黑屏,而且在低端显卡上会表现为"时好时坏",必须在启动逻辑里同步处理。
6.3 性能排查:瓶颈在 CPU 还是 GPU
WebGPU 项目性能出问题,先别急着开骂。我用一个老土但有效的办法定位:先看renderer.info里的 object 数和 draw call 数量。如果 Draw Call 高、切换频繁,瓶颈在 CPU 调用;如果 Draw Call 不多但帧率低,大概率是 GPU 侧过载,先降pixelRatio、降纹理尺寸、降低后处理步骤。
再打开浏览器 DevTools 的 Performance 面板,看主线程是否有大块 JS 开销。如果 JS 每帧只做几十毫秒的更新,但帧数还是上不去,那多半是 GPU 渲染超时,重点检查着色器复杂度。我见过一个项目,粒子数量只有 20 万,但初始化时忘了给存储缓冲做对齐处理,导致每帧 CPU 都要做一次重排,性能直接减半,这种问题只有看 profile 才能发现。
提示:排查 TSL 相关渲染异常时,先把
material.colorNode换成最简单的常量返回,验证管线是否通。如果常量颜色能显示,再逐步把噪声、采样这些环节加回来,二分定位问题。这一条是拯救我无数次翻车的万能套路。
最后再分享一个我个人的习惯:写 TSL 前先画"数据流图",不用搞得很复杂,纸上写下"谁产生数据、谁消耗数据、数据从哪个 buffer 来、最终挂到哪个 node",比直接写代码容易发现问题得多。WebGPU 项目里,绝大多数 bug 不是语法问题,而是"数据流的持有关系搞错了"。这个习惯从 WebGL 时代带到现在,一直很好用。