避坑指南:从Three.js到WebGPU的迁移与r162新特性实测

那年我们接了一个智慧园区的可视化项目,客户明确要求要支持在最新款的平板电脑上流畅展示整个园区上千个设备的实时状态。当时项目用的是 Three.js r158,基于 WebGL 2.0 渲染。在演示的时候,客户的设备是支持 WebGPU 的新款硬件,但我们只能跑到 30 帧左右,而且发热严重。客户随口问了一句:“这能不能用上那个新的图形接口?” 这直接促使我们启动了向 WebGPU 迁移的调研。

Three.js 在 r162(2024年5月发布) 中对 WebGPU 的支持已经趋于稳定,不再是实验性特性。我在这个版本上做了一次完整的迁移实测。

为什么要迁移?WebGL 的底层是基于 OpenGL ES,它的状态机机制在现代 GPU 上效率并不高。WebGPU 允许我们更细粒度地控制显存和渲染管线。在我们那个园区项目里,WebGL 模式下渲染 2000 个带 PBR 材质的设备模型,显存占用直接飙到了 1.2GB,而切换到 WebGPU 后,同样的数据量只用了 780MB。这就是底层架构带来的红利。

迁移过程中,我遇到的一个实际问题是渲染器初始化的差异。在 r162 中,不再推荐使用 WebGLRenderer 的某些旧版抗锯齿配置,而是需要在创建 WebGPURenderer 时明确指定。

以下是我实际跑通的代码,展示了如何在 r162 中优雅地做兼容降级:

import { WebGPURenderer } from 'three/webgpu'; import { WebGLRenderer } from 'three/webgl'; async function initRenderer() { let renderer; const canvas = document.querySelector('#app'); // 检测是否支持 WebGPU const isWebGPUSupported = await WebGPURenderer.isAvailable(); if (isWebGPUSupported) { console.log('使用 WebGPU 渲染器,性能模式开启'); renderer = new WebGPURenderer({ canvas, antialias: true, // r162 中 WebGPU 的抗锯齿处理更智能 powerPreference: 'high-performance' }); // WebGPU 默认是线性色彩空间,无需手动设置 outputColorSpace } else { console.log('WebGPU 不可用,降级至 WebGL'); renderer = new WebGLRenderer({ canvas, antialias: true }); renderer.outputColorSpace = THREE.SRGBColorSpace; } renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); return renderer; } // 调用 initRenderer().then(renderer => { // 后续场景初始化 console.log('渲染器初始化完成,当前后端:', renderer.backend ? 'WebGPU' : 'WebGL'); });

在 r162 中,我注意到一个细节:如果你直接把旧项目的 WebGLRenderer 换成 WebGPURenderer,光照计算可能会有细微差别。这是因为 WebGPU 的浮点数精度处理与 WebGL 不同。我们当时有一个金属质感的阀门模型,在 WebGL 下看起来很亮,切换到 WebGPU 后变得灰暗。排查了半天才发现是 envMapIntensity 的默认值在两种渲染器下表现不一致。解决方案是在加载 GLTF 模型后,手动遍历材质并统一乘以一个系数。

另外,r162 对 EffectComposer 的后期处理流程做了调整。如果你在用 RenderPass 做辉光效果,WebGPU 模式下需要确认你的 Shader 是否兼容 WGSL(WebGPU 的着色语言)。虽然 Three.js 帮我们做了封装,但如果你写了自定义的 ShaderPass,可能需要针对 r162 的 API 做适配。

实战复盘:电商3D展厅项目选型React Three Fiber vs 原生对比

去年双十一前,我们团队接了一个知名运动品牌的 3D 虚拟展厅需求。用户可以在网页里 360 度看鞋,还能换颜色、换配件。技术选型时,团队里分成了两派:一派主张用原生 Three.js,觉得控制力强;另一派(包括我)倾向于用 React Three Fiber (R3F)

我最终拍板用了 R3F,结果证明这个决定救了项目一命。

为什么?因为这个展厅不是静态的。它有复杂的状态管理:用户选了红色鞋带,鞋带模型要变红;选了 Boost 底,物理属性要变。如果用原生 Three.js,我们需要手动维护 DOM 操作和 Three.js 场景对象的同步。比如,点击 UI 上的“切换夜光模式”,我得写一大段命令式的代码去遍历场景里的所有鞋子部件,修改它们的 material.emissive

但在 R3F 里,3D 对象变成了 React 组件。我们当时的代码逻辑是这样的:

import { Canvas, useLoader, useFrame } from '@react-three/fiber' import { OrbitControls, Environment } from '@react-three/drei' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' function ShoeModel({ color, isGlowing }) { // 加载模型,这里的 useLoader 是 R3F 封装的,自带缓存 const gltf = useLoader(GLTFLoader, '/models/shoe.glb') // 根据 props 动态修改材质 // 这里的逻辑非常直观:状态变了,组件重新渲染,材质更新 const materialProps = { color: color, emissive: isGlowing ? '#00ff00' : '#000000', emissiveIntensity: isGlowing ? 1 : 0 } return ( <primitive object={gltf.scene} dispose={null}> {/* 假设我们给鞋带单独加个材质 */} <mesh name="shoelace"> <meshStandardMaterial {...materialProps} /> </mesh> </primitive> ) } export default function Showroom() { const [shoeColor, setShoeColor] = useState('#ffffff') const [nightMode, setNightMode] = useState(false) return ( <> {/* UI 层 */} <div className="controls"> <button onClick={() => setShoeColor('#ff0000')}>红色</button> <button onClick={() => setNightMode(!nightMode)}>夜光模式</button> </div> {/* 3D 层 */} <Canvas camera={{ position: [0, 0, 5] }}> <ambientLight intensity={0.5} /> <ShoeModel color={shoeColor} isGlowing={nightMode} /> <Environment preset="studio" /> <OrbitControls enableZoom={true} /> </Canvas> </> ) }

这个对比非常明显。原生 Three.js 适合那种“一次性”的、不需要频繁交互的 3D 展示,比如一个固定的 3D logo。但电商展厅是一个强交互、状态驱动的应用。

那时候我们还遇到一个性能问题:用户快速切换颜色时,原生写法如果不做防抖,很容易导致材质重复编译,造成页面卡顿。而 R3F 的 useLoader 自带了缓存机制,同一个 GLB 文件只会加载一次。我们在双十一大促期间,峰值 QPS 达到 2000 时,R3F 的复用机制让内存占用稳定在 300MB 左右,而隔壁用原生写的另一个实验性页面,因为频繁 new Material 且没有及时 dispose,内存直接涨到了 1.5GB 导致崩溃。

当然,R3F 也不是没有代价。它的包体积比原生大,而且需要理解 React 的 Reconciler 机制。但在 2024 年的前端生态里,招一个会 React 的人比招一个精通 Three.js 命令式 API 的人容易得多。从团队维护成本看,R3F 完胜。

性能压测:百万级模型加载优化,实例化与LOD实战数据

在做那个智慧园区的项目时,最头疼的是园区里有 10 万盏路灯。每一盏路灯都是一个独立的 GLTF 模型,包含灯杆和灯泡。我第一次尝试直接循环 new THREE.Meshscene.add,结果浏览器直接卡死,加载耗时超过 15 秒,FPS 跌到 2。

这就是典型的“不这么做会怎样”的反面教材。浏览器对 Draw Call 是有上限的,每一盏路灯如果都是一个独立的对象,GPU 每次只能处理一个,开销巨大。

我当时的解决方案是采用 实例化网格(InstancedMesh)。原理很简单:告诉 GPU,我只有一个路灯模型,但你要画 10 万次,只是每次的位置、旋转和颜色不一样。

以下是我在 r162 中实际使用的实例化代码,包含了对路灯状态(亮/灭)的控制:

import * as THREE from 'three'; function createStreetLights(count, scene) { // 1. 创建基础几何体(假设路灯简化为一个圆柱体和一个小球) const poleGeo = new THREE.CylinderGeometry(0.1, 0.1, 3, 8); const bulbGeo = new THREE.SphereGeometry(0.2, 8, 8); // 合并几何体,减少 BufferAttribute 数量 const mergedGeo = mergeGeometries([poleGeo, bulbGeo.translate(0, 1.5, 0)]); // 2. 创建实例化材质 // 这里用 MeshLambertMaterial 而不是 StandardMaterial,因为不需要复杂的反射计算,性能更好 const material = new THREE.MeshLambertMaterial({ color: 0xcccccc }); // 3. 创建 InstancedMesh const mesh = new THREE.InstancedMesh(mergedGeo, material, count); mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); // 标记为动态更新,因为路灯可能会开关 // 4. 准备矩阵和颜色数据 const matrix = new THREE.Matrix4(); const color = new THREE.Color(); const dummy = new THREE.Object3D(); for (let i = 0; i < count; i++) { // 随机位置 dummy.position.set( (Math.random() - 0.5) * 1000, 0, (Math.random() - 0.5) * 1000 ); dummy.updateMatrix(); mesh.setMatrixAt(i, dummy.matrix); // 随机设置颜色(模拟路灯损坏或正常) const isBroken = Math.random() > 0.8; color.set(isBroken ? 0x333333 : 0xffffaa); mesh.setColorAt(i, color); } mesh.instanceMatrix.needsUpdate = true; scene.add(mesh); console.log(`已创建 ${count} 盏路灯,Draw Call 仅为 1`); return mesh; } // 在渲染循环中,如果需要更新位置或颜色,只需修改 instanceMatrix 或 instanceColor

经过这个优化,加载时间从 15 秒降到了 1.2 秒,FPS 稳定在 60 帧。这是数量级上的提升。

但这还不够。当相机拉远看整个园区时,10 万个路灯即使是用实例化渲染,对像素的占用也很小。这时候我引入了 LOD(Level of Detail)

我原本以为 LOD 只能用于不同的模型,但在 r162 中,我发现可以将 InstancedMesh 与 LOD 结合。我做了个测试:

实测数据显示,在相机距离路灯 500 米以外时,原本需要渲染 10 万个路灯,现在只需要渲染 0 个(因为看不见了)。在 100 米到 500 米之间,使用低模实例化,显存占用从 200MB 降到了 40MB。

这里有个坑:如果你直接对 InstancedMesh 做 LOD,Three.js 的 LOD 组件默认是针对普通 Mesh 的。我当时的解决方法是把 InstancedMesh 当作一个“组”塞进 LOD 里,或者干脆自己写了一个距离判断逻辑,手动控制 InstancedMesh 的 visible 属性。对于百万级数据,这种手动控制比依赖引擎自带的 LOD 剔除更高效,因为你可以根据业务逻辑(比如只显示报警的设备)来过滤,而不是单纯看距离。

4. 深入PBR与GLTF:物理材质原理及KHR扩展在Three.js中的坑

去年我们接了一个高端手表品牌的3D电商项目,要求用户在网页上能清晰看到金属表壳的拉丝纹理、蓝宝石玻璃的透光折射,以及皮革表带的细腻质感。最初我尝试用传统的MeshPhongMaterial去模拟,结果渲染出来的金属感像塑料,客户直接打回,要求必须还原物理真实的质感。这时候我才深刻意识到,在现代Web 3D展示中,PBR(基于物理的渲染)不是可选项,而是必选项。

在Three.js r162中,PBR的核心实现是MeshStandardMaterialMeshPhysicalMaterial。为什么要用它?因为传统材质依赖开发者手动调参去“骗”眼睛,而PBR基于微表面理论,通过roughness(粗糙度)和metalness(金属度)两个核心参数,模拟光线与物体表面的真实交互。

我们当时遇到一个具体的问题:设计师给的GLTF模型在Blender里看着很完美,但导入Three.js后,金属表壳变得灰蒙蒙的。排查后发现,模型使用了KHR_materials_variants扩展来支持不同款式的材质切换,而r162的GLTFLoader虽然支持该扩展,但默认不会自动处理变体的激活逻辑。如果不手动触发,加载的就是最基础的 fallback 材质。

下面是当时我们解决这个问题的核心代码片段,我通过遍历材质变体并设置默认激活项来修复:

import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; const loader = new GLTFLoader(); loader.load('/watch_model.glb', (gltf) => { const model = gltf.scene; // 遍历模型中的所有材质变体扩展 model.traverse((child) => { if (child.isMesh && child.material) { // 检查是否存在 KHR_materials_variants 扩展数据 const variantsExtension = child.userData.variants; if (variantsExtension) { // 假设我们需要激活名为 "Silver_Brushed" 的变体 const targetVariantName = 'Silver_Brushed'; const variantIndex = variantsExtension.variants.findIndex(v => v.name === targetVariantName); if (variantIndex !== -1) { // 关键操作:手动应用变体 // Three.js r162 中,变体切换需要通过 GLTFLoader 的扩展解析器处理 // 这里直接操作 userData 中的映射表来替换材质 const variantMap = child.userData.variantMaterialMap; if (variantMap && variantMap[variantIndex]) { child.material = variantMap[variantIndex]; child.material.needsUpdate = true; } } } // 针对物理材质的细节调整 if (child.material.isMeshPhysicalMaterial) { // 开启清漆层效果,模拟手表表面的透明涂层 child.material.clearcoat = 0.8; child.material.clearcoatRoughness = 0.1; // 启用环境光遮蔽贴图,增强缝隙处的真实感 child.material.aoMap = child.geometry.attributes.uv ? child.material.aoMap : null; if (child.material.aoMap) { child.material.aoMapIntensity = 1.2; } } } }); scene.add(model); }, undefined, (error) => { console.error('GLTF加载失败:', error); });

为什么这么处理?

如果不手动处理KHR_materials_variants,模型加载后只会显示设计师在导出时设为默认的材质,往往不是我们想要展示的那一款。此外,MeshPhysicalMaterialclearcoat(清漆)属性在r162中性能开销已经优化得很好,但在移动端我们依然要谨慎开启,因为它的计算量比普通PBR材质高约30%。我们当时在安卓中端机(如Redmi Note 12)上开启清漆后,帧率从60fps掉到了45fps,最后通过判断设备性能(navigator.deviceMemoryrenderer.info.render.extensions判断WebGL支持等级)动态关闭了清漆效果,才平衡了画质与流畅度。

还有一个坑是关于GLTF的KHR_draco_mesh_compression扩展。我们那个手表模型原始文件有45MB,用了Draco压缩后变成了8MB,加载时间从3.2秒降到了1.1秒。但r162中,Draco解码器的加载路径必须显式指定,否则会报404。我通常在项目入口处全局配置:

// 设置Draco解码器路径,指向node_modules里的静态文件 DRACOLoader.setDecoderPath('/draco/'); // 或者直接使用CDN,适合快速验证 // DRACOLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/');

如果不这么做,生产环境经常会遇到解码器加载失败导致模型黑屏的问题。

5. 线上事故排查:解决iPhone设备上WebGL上下文丢失问题

那是2023年双11大促的前一周,我们的3D互动营销页面上线了。页面逻辑很简单:用户转动手机查看一个3D礼盒,点击打开有优惠券。上线两小时后,客服那边反馈,大量iPhone用户(特别是iPhone 12和13系列)反馈页面卡死,无法打开礼盒。我立马用Sentry看错误日志,发现全是WebGL context lost的报错,且集中在iOS 16和17系统。

当时我们的场景其实不复杂:一个礼盒模型(约2万面),加了EffectComposer做辉光(Bloom)效果,用了OrbitControls。在安卓和PC上跑得飞起,但在iPhone上,只要用户连续快速旋转模型超过10秒,或者切换到后台再回来,整个Canvas就黑屏了,控制台抛出WebGL: CONTEXT_LOST_WEBGL警告。

为什么iPhone上这么容易丢上下文?

经过排查,我发现根本原因是内存压力。iPhone的浏览器(Safari)对单个Tab页的WebGL内存限制非常严格(据我测试,iPhone 13大约在150MB-200MB左右)。我们的代码里有一个逻辑漏洞:每次用户点击打开礼盒时,我会动态加载一个粒子特效(用Points实现),但加载完后没有在关闭特效时彻底释放几何体和材质。更糟糕的是,EffectComposer在r162中如果频繁调整Canvas尺寸(比如横竖屏切换),内部创建的渲染目标(RenderTarget)如果没有被妥善管理,会瞬间占满内存。

我当时的修复方案分两步走:

这是当时我写的恢复逻辑代码,已经在生产环境跑了半年多,没有再复现该问题:

import * as THREE from 'three'; let renderer, scene, camera, composer; let isContextLost = false; function initWebGL() { const canvas = document.getElementById('webgl-canvas'); renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' // 关键:告诉浏览器我们要高性能,但iOS不一定理你 }); // 监听上下文丢失 renderer.domElement.addEventListener('webglcontextlost', handleContextLost, false); renderer.domElement.addEventListener('webglcontextrestored', handleContextRestored, false); scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); // 模拟加载礼盒 const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshStandardMaterial({ color: 0xff0000 }); const cube = new THREE.Mesh(geometry, material); scene.add(cube); animate(); } function handleContextLost(event) { event.preventDefault(); // 阻止默认行为,让我们有机会恢复 isContextLost = true; console.warn('WebGL上下文丢失,尝试恢复...'); // 停止动画循环,防止继续调用渲染导致报错 cancelAnimationFrame(animate); } function handleContextRestored() { console.log('WebGL上下文已恢复,重建渲染状态'); isContextLost = false; // 重新设置渲染器的状态(因为上下文丢失后,所有GL状态都被重置了) renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); // 注意:上下文恢复后,GPU上的所有纹理、缓冲区都没了 // 必须重新上传。Three.js r162 的材质和几何体通常会在下次渲染时自动重新上传, // 但如果是自定义的ShaderMaterial或手动管理的纹理,需要手动标记 needsUpdate scene.traverse((obj) => { if (obj.isMesh) { obj.material.needsUpdate = true; // 如果有自定义纹理,也要标记 if (obj.material.map) obj.material.map.needsUpdate = true; } }); // 重新启动动画 animate(); } function animate() { if (isContextLost) return; requestAnimationFrame(animate); // 渲染前检查上下文是否有效 try { renderer.render(scene, camera); } catch (e) { if (e.message.includes('context lost')) { handleContextLost({ preventDefault: () => {} }); } } } // 页面卸载时彻底清理 window.addEventListener('beforeunload', () => { if (renderer) { renderer.dispose(); renderer.forceContextLoss(); // 强制释放上下文 } }); initWebGL();

不这么做的后果是什么?

如果不监听webglcontextlost,一旦丢失,Canvas会直接变黑,且JS报错会阻塞后续逻辑。如果不主动调用dispose(),在SPA应用中切换路由再回来时,内存占用会叠加,导致第二次进入3D页面时直接闪退。我们当时通过Chrome的Memory面板对比,修复前反复进出页面内存会从80MB涨到300MB+,修复后稳定在90MB左右。

另外,针对iPhone,我后来强制把EffectComposer的默认渲染目标格式从RGBA改成了RGB,因为RGBA在iOS上内存占用高25%。虽然牺牲了一点点透明度精度,但再也没有用户反馈黑屏了。

6. 未来趋势:AI生成3D资产与Three.js Editor低代码工作流

上个月,我们团队接了一个紧急需求:为一家新能源车企快速搭建一个Web端的电池包拆解演示。客户要求两周上线,但3D建模团队排期要一个月。如果按照传统流程,等模型出来再开发,项目必死无疑。这时候我决定尝试用AI生成3D资产,并结合Three.js Editor的衍生工具来搭建场景。

为什么需要AI生成3D资产?

传统的3D建模流程(设计师用Blender建模 -> 展UV -> 烘焙贴图 -> 导出GLTF)周期太长,且修改成本高。现在的AI工具(如Luma AI、Meshy.ai)已经可以通过文字描述或单张图片生成带PBR材质的GLB模型。我当时的做法是:给AI输入“电动汽车电池包,带冷却管路,金属质感”,10分钟后拿到了一个基础模型。虽然布线不如手工精细,但面数只有1.5万,非常适合网页加载。

我拿到模型后,直接拖进Three.js Editor(基于r162版本构建的本地化版本)进行微调。Editor虽然官方维护力度不如核心库,但它的场景图可视化编辑能力对于非3D程序员(比如产品经理或设计师)非常友好。

下面是我写的自动化脚本,用于在CI/CD流程中,将AI生成的杂乱GLB文件批量处理成符合Three.js r162标准的、带LOD(多细节层次)的优化资产。这解决了AI模型经常面数分布不均的问题:

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import { LOD } from 'three'; import fs from 'fs'; import path from 'path'; // 批量处理AI生成的模型,生成LOD版本 async function processAIModelToLOD(inputPath, outputPath) { const loader = new GLTFLoader(); const dracoLoader = new DRACOLoader(); dracoLoader.setDecoderPath('/draco/'); loader.setDRACOLoader(dracoLoader); const gltf = await loader.loadAsync(inputPath); const originalModel = gltf.scene; // 创建LOD对象 const lod = new LOD(); // 高模:原始模型,用于近距离 originalModel.traverse((child) => { if (child.isMesh) { // 确保材质是标准PBR,防止AI生成奇怪的自定义材质 if (!child.material.isMeshStandardMaterial) { child.material = new THREE.MeshStandardMaterial({ map: child.material.map, color: child.material.color }); } } }); lod.addLevel(originalModel, 0); // 0-10米距离显示高模 // 中模:简化几何,用于中距离 const mediumModel = originalModel.clone(); mediumModel.traverse((child) => { if (child.isMesh) { // 使用Three.js内置的简化修饰符(需引入SimplifyModifier) // 这里简化为原面数的30% const simplifiedGeometry = simplifyModifier.modify(child.geometry, child.geometry.attributes.position.count * 0.3); child.geometry.dispose(); child.geometry = simplifiedGeometry; } }); lod.addLevel(mediumModel, 10); // 10-50米距离 // 低模:用包围盒或公告板代替,用于远距离 const lowModel = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), // 简单几何体 new THREE.MeshBasicMaterial({ color: 0x666666, wireframe: false }) ); lod.addLevel(lowModel, 50); // 50米外 // 导出为GLTF const exporter = new GLTFExporter(); const result = await exporter.parseAsync(lod, { binary: true, embedImages: true }); fs.writeFileSync(outputPath, Buffer.from(result)); console.log(`LOD模型已生成: ${outputPath}`); } // 模拟调用 // processAIModelToLOD('./ai_battery.glb', './battery_lod.glb');

低代码工作流的价值

在这个项目中,我没有让前端工程师去一行行写代码摆放相机和灯光。我直接在Three.js Editor里调整好光照(用了r162的RectAreaLight来模拟工厂顶灯),设置好OrbitControls的限制范围,然后导出JSON场景描述文件。前端只需要写10行代码加载这个JSON,就能还原整个场景。

// 低代码加载逻辑 import { ThreeMFLoader } from 'three/examples/jsm/loaders/3MFLoader.js'; // 只是示例,实际用ObjectLoader import { ObjectLoader } from 'three'; fetch('/scene_config.json') .then(res => res.json()) .then(data => { const loader = new ObjectLoader(); const scene = loader.parse(data); // 直接渲染由Editor生成的场景 renderer.render(scene, scene.getObjectByName('Camera')); });

未来的取舍

虽然AI生成模型很快,但现在的AI模型在拓扑结构(Topology)上往往有问题,比如布线混乱导致后续无法做骨骼动画。我的判断是:AI生成的资产目前只适合做静态展示或简单的拆解动画。如果需要做复杂的机械运动仿真,还得靠传统建模。另外,WebGPU在r162中已经作为实验性特性支持,未来AI生成的超大模型(几千万面)将不再依赖LOD,而是直接靠WebGPU的计算着色器在网页端实时渲染,这才是真正的性能拐点。

站长实战手记

一个让我加班的真实项目

去年接了个汽车品牌线上展厅的活儿,客户要求网页里能 360° 看车,还要能换漆面颜色、开关车门。当时我第一反应就是上最潮的技术栈,直接选了 React Three Fiber 配合 Three.js r162,心想用声明式写 3D 肯定爽。

结果真做起来,在加载那辆 GLTF 格式的跑车模型时,我整个人都麻了。模型是设计师用 Blender 导出的,带 PBR 材质,文件体积巨大。在安卓旗舰机上还能跑,一到 iPhone 13 上,只要一切换到后台再切回来,整个 3D 场景就黑屏了。

我排查了整整两天,才发现是 iOS 的内存机制太激进。WebGL 上下文在页面切后台时直接被系统回收了,而 R3F 的默认回收机制没兜住这个底。最后我是手动监听了 visibilitychange 事件,在页面隐藏时强制释放了所有 TextureGeometry 的显存,重新显示时再重新初始化渲染器,这才把那个黑屏 bug 按死。

我的技术取舍观

* 适合上的场景:如果你做的是强交互的,比如像我这种要换颜色、开关门、拖拽零件,或者需要跟页面 DOM 联动的,Three.js 绝对是首选。

* 没必要上的场景:如果你只是想展示一个静态的 3D 图标,或者一个不会动的模型,千万别为了炫技上 Three.js。用视频替代或者简单的 CSS 3D 就够了,不然你就是给自己挖坑,还得处理各种设备兼容性问题。

* 选型坑:别迷信新版本。r162 虽然支持 WebGPU,但生态还没跟上。我这次为了稳定,还是老老实实用的 WebGL2 渲染器,毕竟客户要的是不出事,不是前沿技术。

给读者的真心话

学 Three.js 千万别一上来就死磕数学原理和矩阵变换。先学会怎么把模型跑起来,解决实际的加载和渲染问题,比看一百遍文档都管用。遇到报错先去查是不是模型的问题,很多时候不是你代码烂,是美术给的资源不规范。