☰
Three.js移动端适配实战:分辨率、性能与上下文恢复
2026/10/6 4:19:26 网站建设 项目流程

上个月我帮朋友把一个产品展示页从桌面端搬到手机端,桌面端 60fps 稳稳当当,结果手机上一打开,模型拖拽卡成 PPT 不说,部分贴图还黑了一块,切到后台再回来整个场景直接没了。排查了一下午,问题都不在模型本身,而是三个平时在 PC 上根本注意不到的环节:渲染分辨率、纹理规格、上下文恢复。

这篇就是来填这些坑的。主打 three.js 移动端适配,适合已经能跑通基础 demo、正准备把场景搬到手机 H5、微信 WebView 或者 App 内嵌页的开发者。我会把从像素账、性能预算、贴图排查到 contextlost 恢复的完整链路都过一遍,每一条都是实际设备上踩出来的。

1. 移动端“卡”的第一步:渲染分辨率的像素账

1.1 你画的不是 CSS 像素,而是物理像素

先说一个最容易被忽略的事实:three.js 渲染的尺寸并不是你在 CSS 里写的那几个宽高值,而是 canvas 的 buffer 尺寸。移动端屏幕的 devicePixelRatio(简称 DPR)普遍是 2 或 3,iPhone 13 系列小屏机的 DPR 是 3,部分安卓旗舰甚至到 3.5。

这时候你用renderer.setSize(width, height)只设置 CSS 尺寸,或者偷懒不设置像素比,渲染器默认按 DPR = 1 去渲染,画面会发虚。但反过来,如果你老老实实传了window.devicePixelRatio,算一下就知道了:一块 390 x 844 逻辑像素的屏幕,DPR 为 3 时,canvas 实际 buffer 是 1170 x 2532,接近 296 万像素;而 DPR 为 1 时只有 33 万像素。GPU 要处理的片元数量差了大约 9 倍,这还是在屏幕全屏显示、没加后处理的前提下。

这就是移动端“怎么一上手机就卡”的元凶之一。不是你代码写得烂,而是像素账没算清。

1.2 setPixelRatio 的正确姿势:限制上限而不是照单全收

我的建议很简单:永远不要直接传window.devicePixelRatio,而是用一个上限值包一层。

const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: 'high-performance', }); const DPR_LIMIT = 2; renderer.setPixelRatio(Math.min(window.devicePixelRatio, DPR_LIMIT));

为什么上限取 2 而不是 3?因为手机上 DPR=3 带来的清晰度提升,人眼在 30cm 外的观看距离下基本感知不到,但 GPU 的填充率压力却成倍增加。尤其在复杂场景里,3 倍 DPR 会让帧率立刻掉一截。我在真机上测过一个带反射材质的产品模型,DPR=2 时稳定 60fps,改成 3 直接掉到 38fps,画面上却看不出任何区别。

这个DPR_LIMIT不是死的,如果你的场景特别简单,比如只是几个纯色盒子,那可以放开到 2.5 甚至 3。但大多数业务场景用 2 就是性价比最高的点。

1.3 resize 处理的两个隐蔽细节

移动端和桌面端还有一个明显差异:浏览器地址栏会随滚动而隐藏或出现,导致window.innerHeight不断变化。如果只在页面加载时执行一次 resize,那用户滚动一下,画面比例可能就变形了。

更麻烦的是部分安卓 WebView 对orientationchange事件的兼容性很古怪,横竖屏切换时不一定会触发 resize。稳妥做法是同时监听两个事件,并加上防抖:

window.addEventListener('resize', onResize); window.addEventListener('orientationchange', () => { setTimeout(onResize, 100); }); function onResize() { const width = window.innerWidth; const height = window.innerHeight; camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height); }

这里有个经验:renderer.setSize(width, height)会自动重设 canvas 的 buffer 尺寸,但在部分 iOS Safari 上,canvas 的 CSS 尺寸会被浏览器强制拉满,导致渲染区域小于显示区域,画面出现奇怪的裁切。我习惯在 setSize 之后显式设置一次 canvas 样式宽高:

renderer.domElement.style.width = width + 'px'; renderer.domElement.style.height = height + 'px';

这在普通浏览器里是多余的,但在 WebView 场景里能省下一整类“画面比例不对”的诡异 bug。

2. 性能预算:图形复杂度才是手机 GPU 的命门

2.1 手机 GPU 的钱只有桌面的十分之一,你花的却是全价

桌面显卡和手机 GPU 的差距不用看跑分,光看功耗和散热就明白:台式机显卡功耗动辄几百瓦,手机整机功耗才几瓦。同样的面数、同样的纹理精度、同样的光照计算公式,手机上要花的时间大致是桌面的 10 倍以上。

所以做移动端 three.js 项目,第一件事就是明确性能预算。我的经验值是:

  • 场景 draw call 尽量控制在 100 以内,简单场景 50 以内;
  • 总三角形面数控制在 20 万到 50 万之间,动态物体控制在 10 万以下;
  • 场景内纹理总数不超过 20 张,且每张尺寸尽量不超过 1024 x 1024;
  • 同时可见的动态光源不超过 2 盏。

这些数字看起来保守,但实际效果很稳。有一次我接手一个桌面端的机械模型,单个模型 80 万面,手机上转一下视角都要一秒钟,最后用 buffergeometry 合并、去重、重拓扑,压到 25 万面,画面观感和原来几乎一样,帧率从 18fps 提到 50fps。

2.2 灯光和阴影:动态方案的隐藏成本

很多人以为加了盏灯、开个阴影没什么大不了的,但在移动端,这可能是最贵的一笔开销。three.js 的每盏动态光在顶点着色器和片元着色器里都要增加计算量,开了阴影的灯光还会额外触发一次 shadow map 渲染,等于把整个场景再画一遍。

如果非得用阴影,切记:阴影贴图分辨率 1024 是移动端上限,512 才是安全值。PC 上常用的 PCFSoftShadowMap 因为算法复杂,在手机上帧率下降尤其明显,我一般直接用 PCFShadowMap,个别极简场景甚至用BasicShadowMap。

真正值得推荐的方案是预制。既然模型是固定场景,为什么不在 Blender 里烘焙好光照贴图,直接输出成 texture?运行时就加载一张贴图替掉所有动态光,帧率立刻起飞。实在要动态效果,用环境贴图或者假光源(比如只影响颜色的 emissive 动画)也比真光源省得多。

2.3 Draw Call 和几何体:用合并换性能

draw call 在桌面端有 300 的情况下能承受,但移动端驱动和浏览器打包提交的开销更大,draw call 数量直接影响 CPU 占用。最简单的优化手段就是把多个小网格合并成一个大网格,用BufferGeometryUtils.mergeGeometries可以把几十个部件合成一个。代价是要动态控制每个部件的显隐、换材质时麻烦,需要提前规划好。

另一种做法是 InstancedMesh,适合摆放大量相同物体的情况,比如树木、路灯、重复的装饰件。一万个盒子用 merge 是 1 个 draw call,用 InstancedMesh 也是 1 个 draw call,但占用显存更小,而且可以逐实例调整位置和旋转,比 merge 灵活得多。

还要注意材质数量。每个材质都代表一次着色器编译,手机上 GPU 驱动编译着色器的时间比桌面长好几倍,打开场景瞬间的白屏甚至卡顿往往就是这部分导致的。能用共享颜色参数解决的,就不要单独 new 材质。

2.4 实测帧率与定位瓶颈的土办法

性能优化不能靠猜。three.js 项目里加一行import Stats from 'three/examples/jsm/libs/stats.module.js',创建 Stats 实例挂在右上角,真机上就能看到实时帧率。秒级判断方法:如果旋转相机时光照位置不变但帧率暴跌,多半是 pixel fillrate 问题(分辨率过高或透明叠加过多);如果场景静止时帧率正常、一有交互就掉帧,瓶颈在 CPU 侧的 draw call 或对象更新;如果加载完成后立刻卡,最可能是着色器编译或者纹理上传。

还有一个我自己常用的土办法:在安卓开发者选项里打开“GPU 呈现模式分析”柱状图,看每帧时间是不是经常超过 16 毫秒。如果绿柱普遍超标,就把像素比降到 1.5 再测一次,如果帧率明显恢复,说明填充率是瓶颈,优先处理分辨率。如果没变化,再回头看 draw call。

3. 贴图不显示的排查链路:从黑块到加载时序

3.1 热搜词里的“贴图开始不显示”到底是怎么回事

这个热搜词几乎每个月都有人搜,太真实了。新人在 PC 上加载贴图一切正常,一放到手机上,某些机型贴图直接消失、黑一块,或者从灰色慢慢“侵蚀”出来。移动端贴图问题不是单一原因,而是一整条链路,我按概率排序逐个排查。

3.2 逐个排查:尺寸、色彩空间、翻转、异步、跨域

第一步查尺寸。虽然 WebGL2 已经放宽了非 2 次幂纹理(NPOT)的限制,但安卓 WebView 和部分 iOS 机型对 NPOT 纹理的 mipmap 生成仍然有兼容性问题,最常见的表现就是低 mipmap 层级黑屏或贴图闪烁。我现在的原则是:所有纹理素材在出包前统一用脚本裁成 2 的幂,512 或 1024,不要指望运行时引擎帮你 resize,那既吃内存又容易出问题。

第二步查色彩空间。three.js 从 r152 开始默认输出色彩空间变成了SRGBColorSpace,旧教程里常见的renderer.outputEncoding = THREE.sRGBEncoding已经被移除。如果你按老代码写,页面上颜色会整体发灰或者过曝。正确做法:

renderer.outputColorSpace = THREE.SRGBColorSpace; texture.colorSpace = THREE.SRGBColorSpace;

map这类颜色贴图一定要设colorSpace = THREE.SRGBColorSpace,而 roughness、metalness、AO 这类数据贴图保持NoColorSpace即可。这个疏漏在 PC 上只是颜色偏差,在手机上某些 WebView 里会直接让整张贴图看起来像被漂白了。

第三步查flipY。three.js 纹理默认flipY = true,这是因为图片像素从左上角开始,而 WebGL 纹理坐标从右下角开始的传统习惯。大部分时候这个默认值是对的,但如果你用 canvas 生成的纹理、视频帧、或者某些 Android 机型的相机照片,像素数据的 Y 轴方向不一致,就会看到贴图上下颠倒或者呈镜像错乱。遇到“这个安卓机贴图花纹全反了”的报告,先把flipY = false试一遍。

第四步查加载时序。TextureLoader.load()是异步的,如果你在回调里才开始渲染,中间会有一段没有贴图的灰白时期;如果模型已经渲染了,而贴图迟迟没回来,就需要手动标记:

const loader = new THREE.TextureLoader(); loader.load(url, (texture) => { texture.colorSpace = THREE.SRGBColorSpace; material.map = texture; material.needsUpdate = true; });

不然可能出现“模型都出来了,贴图过两秒才突然怼上”或者干脆不更新的情况。

第五步查跨域。如果在移动端 WebView 里加载外域 CDN 图片贴图,某些浏览器安全策略更严格,纹理数据会被污染,最终上屏是黑色或整个 canvas 被安全机制锁掉。解决办法是设置 loader 的跨域属性:

loader.setCrossOrigin('anonymous');

但这要求服务器返回正确的 CORS 头,否则等于白设。最省心的方案:贴图永远和页面同域,或者走公司自己的 CDN 并确认好 Access-Control-Allow-Origin。

3.3 压缩纹理和显存占用:手机上没有“免费午餐”

jpg 和 png 在 CPU 侧解码后直接上传 GPU,显存占用是像素尺寸乘 4 字节。一张 1024x1024 的 RGBA 贴图就是 4MB 显存,二十张贴图就是 80MB,这在手机上已经是很大的开销了,容易触发低端机纹理驱逐,表现就是滚一滚场景,某张贴图突然变成低清模糊然后恢复——因为 GPU 显存不够,纹理被挤出去又被重新换回来。

想要根本解决,用压缩纹理。KTX2 是 three.js 官方支持得最好的格式,KTX2Loader+BasisUniversal转码器能把纹理体积压到原来的 1/4 以下,而且支持 GPU 直接采样,不用在运行时解码。缺点是美术流程要多走一步转换,不适合刚上手的人,但项目到了需要抠性能的时候,这一步是必须补的。

4. webglcontextlost:切后台回来,场景为什么空了

4.1 移动端浏览器“杀后台”的逻辑,直接影响 WebGL

这是我在移动端遇到的最诡异的 bug:用户把 App 切到后台,过一会儿切回来,页面还在,但 three.js 场景全没了,canvas 一片空白,控制台却没有任何报错。

原因是移动端浏览器为了省电和内存,会在页面不可见时回收 GPU 资源,包括 WebGL 上下文。iOS Safari 对“内存压力”的处理尤其激进,安卓的 WebView 也经常这么干。一旦上下文丢失,所有着色器、纹理、缓冲区全部失效,但你的 JavaScript 对象还在,页面看起来正常,实际上渲染已经中断了。

4.2 监听事件和恢复重建的最小实现

解决方案是监听webglcontextlost和webglcontextrestored事件。

const canvas = renderer.domElement; canvas.addEventListener('webglcontextlost', (event) => { event.preventDefault(); // 阻止默认的自动恢复流程 // 暂停动画循环、取消相机的交互 }); canvas.addEventListener('webglcontextrestored', () => { // 重建所有纹理、几何体、材质 // 或者在场景简单时直接 location.reload() });

event.preventDefault()很关键。不调它,浏览器会按自己的节奏尝试恢复上下文,而恢复后 three.js 内部的资源却没有重新绑定,就会出现“上下文已经在跑,但场景还是黑的”的假恢复状态。调用了它,你才有机会在状态恢复后手动重建。

对于复杂场景,手工重建所有纹理和 buffer 对象非常痛苦,我的做法是:在 contextlost 触发时记录当前场景的 JSON 快照(three.js 的scene.toJSON()可以帮忙),恢复时直接从快照重新加载;如果项目允许,更粗暴的做法是直接location.reload(),整个页面重启,从入口重新初始化。后者虽然体验一般,但代码量最少、出错面最小,对于展示型页面完全够用。

4.3 低端安卓纹理被回收的额外考验

除了整个上下文丢失,低端安卓机上还会出现更细的问题:GPU 显存不足时,驱动会选一些纹理回收,但不重建上下文。现象是场景还在转动,但部分贴图变成紫色或黑色,过一会又自己恢复。这种情况没有规范的事件可以监听,能做的只有:严格控制显存占用,避免大纹理一次性塞入,以及及时处理不再使用的贴图,调用texture.dispose()。

顺便提一句,别忘了监听页面的visibilitychange。页面从后台回前台时,移动端浏览器经常暂停 requestAnimationFrame,恢复后时间戳差异巨大,如果你的动画逻辑依赖deltaTime,可能会看到相机瞬移一下。稳妥做法是在visibilitychange回到可见状态时获取一次新的时间基准。

5. 版本坑与触摸交互:移动端特有的 API 变化

5.1 照着老教程写,为什么手机上灯光失效一片黑

three.js 版本迭代带来的坑,在移动端上表现得特别明显,因为手机浏览器的缓存策略更激进,你调试用的可能一直是旧版本代码和旧缓存。

最大的一个版本断层发生在 r155 前后:光照单位从“任意值”变成了物理单位,以前写new THREE.DirectionalLight(0xffffff, 1)在旧版本里是正常亮度,到新版几乎全黑,需要把强度调到 3 到 5 才行。如果你项目里用了physicallyCorrectLights或者旧版的useLegacyLights,排查的方向就完全不一样了。

另一个断层是 r152 的outputEncoding换成outputColorSpace,旧代码直接不生效,颜色发灰。这类问题在 PC 上遇到时还能搜到文章,但放到手机 WebView 上很多人第一时间想不到是版本问题,会去调材质参数调半天。

我的建议:项目初始阶段就锁定 three.js 版本,并写进 README;升级版本时搜一下官方迁移指南,别用“最新版试试看”的思路。这套流程能省掉大量形形色色的移动端暗场景 bug。

5.2 移动 GPU 的浮点精度问题

很多 iOS 和安卓的设备,片元着色器的浮点精度只有 mediump。如果你的着色器代码里使用highp声明,部分兼容性差的驱动会直接编译失败,或者在特定角度出现大面积拉链状条纹,像水面波纹一样闪。

three.js 内置材质一般会自动处理,但自己写 ShaderMaterial 就必须留意。最省事的做法是在创建材质时指定精度:

const material = new THREE.ShaderMaterial({ precision: 'mediump', vertexShader: vs, fragmentShader: fs, });

注意这事是双向的:中等精度在某些大数值范围计算场景(比如视差映射)下会产生明显锯齿,如果你做得是高精度视觉项目,可以尝试在片元着色器关键计算中手动float转成vec2拆高低位,或者干脆换用RawShaderMaterial自己掌控整个流程。但对绝大多数业务场景,mediump换来的稳定性和性能提升是划算的。

5.3 触摸交互与页面滚动冲突

移动端交互第一个要注意的:canvas 上默认的触摸事件会被浏览器当成页面滚动和缩放手势处理。这就是为什么你用手指拖动 three.js 场景里的模型,页面却跟着上下滚动。

给 canvas 设一行 CSS 就能解决大部分冲突:

canvas { touch-action: none; }

这一句的意思是“这个元素上的触摸手势由我接管,浏览器别插手”。设置之后,three.js 的 OrbitControls 就能正常处理单指旋转、双指缩放,手势事件不再触发页面滚动。

但如果你希望页面在 canvas 区域外正常滚动,比如产品列表页滑到某个位置才进入 3D 场景,你就得做一个动态开关:根据滚动位置判断是否给touch-action: none。我实际项目里的做法是:目标 canvas 在视口内时才启用三维交互,离开视口就恢复touch-action: auto让页面滚动。初期觉得麻烦,但测试下来用户操作符合直觉,不会有“卡在 3D 区域滚不动页面”的抱怨。

6. 我的移动端固定配置基线

最后把我近乎固定的移动端初始化模板贴出来,所有参数都是真机实测过、踩过坑之后收敛出来的:

function createMobileRenderer(canvasContainer) { const renderer = new THREE.WebGLRenderer({ canvas: canvasContainer, antialias: false, // 手机上 MSAA 开销大,配合 DPR=2 观感足够 powerPreference: 'high-performance', }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.shadowMap.enabled = false; // 默认关闭,个别场景按需开且用 PCFShadowMap renderer.toneMapping = THREE.ACESFilmicToneMapping; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); const onResize = () => { const w = window.innerWidth; const h = window.innerHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); renderer.domElement.style.width = w + 'px'; renderer.domElement.style.height = h + 'px'; }; window.addEventListener('resize', onResize); window.addEventListener('orientationchange', () => setTimeout(onResize, 100)); onResize(); return { renderer, scene, camera }; }

配套一份上线前真机检查清单:

  • 像素比已限到 2,没有直接传devicePixelRatio;
  • 所有纹理均为 2 次幂尺寸,颜色贴图已设SRGBColorSpace;
  • draw call 小于 100,动态光源不超过 2 盏;
  • 贴图与页面同域,或确认 CORS 头配置完成;
  • webglcontextlost/webglcontextrestored事件监听就位;
  • canvas 已设置touch-action: none,页面滚动冲突已处理;
  • 用真机过一遍切后台、锁屏、横竖屏切换的场景。

我自己的经验是:移动端 three.js 的大多数问题不是“特效不够炫”,而是“资源在意料之外的地方超了预算”。把像素账算清楚,把材质贴图规格控住,把上下文丢失和版本变化两个暗礁避开,你的场景在手机上稳定跑 60fps 并不是什么难事。多拿几台不同价位的安卓真机测一测,很多问题会比 PC 上更早暴露出来——这是坏事,也是好事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询