简介:这份源码包面向希望在微信小程序中实现3D模型展示效果的开发者,以three.js为核心,着重解决小程序环境下加载gltf模型、渲染与交互的常见问题,适合已具备小程序基础、想快速验证3D方案的中级开发者参考。压缩包共18个文件、约258KB,以8个js脚本承载渲染逻辑与工具函数,5个json负责页面与项目配置,2个wxml与2个wxss构成页面结构与样式,另含1个gltf格式的虾模型,体积轻量、结构紧凑,可直接导入开发者工具运行。目前已有2309人学习下载,具备一定的可行性验证基础。读者可借此跑通从模型上传服务器到在wxml中按url引用的完整链路,并通过调整js中的函数实现旋转、掉落等动画效果;目录划分清晰,也便于排查模型加载失败、路径配置错误等典型问题,作为小程序3D入门的练手样例较为合适。
1. 小程序里做 3D 展示:先认清它并非“把网页搬过来”
微信小程序 3D 展示这个需求,十个里有八个最后会落到 Three.js 上:模型由美术提供 glb,交互是能转、能放大看细节,最好点一下还能换个颜色。真动手才发现,网页里那套new THREE.WebGLRenderer()在小程序里第一行就跑不通:没有 document,没有 window,canvas 不是 DOM 节点,requestAnimationFrame也不在全局对象上挂着。
这份资源解决的正是这段“从网页到小程序”的落差:一套把 Three.js 接进微信小程序、把 3D 模型稳定显示出来的可运行工程,包含 canvas 节点获取、渲染器初始化、GLTFLoader 适配、触摸交互和资源释放这几块拼图。适合两类人:手上已有 glb 模型、要在小程序里做产品展示的前端;以及做过网页版 Three.js、第一次往小程序迁移、被黑屏和贴图丢失折腾过的人。下面按“先跑通管线,再上模型,最后抠性能和坑”的顺序拆。
2. 适配层怎么搭:canvas 节点、像素比与触摸坐标的三个改造点
小程序里的 Three.js 并非“装个依赖就完事”,它有一层必须自己写的适配代码。这层代码大概一百多行,但每一行都对应一个具体的失败场景。我一般把这段单独放在lib/render/下,和业务页面隔开,后面换页面、换模型都不用重写。
2.1 用 createScopedThreejs 拿到“这块 canvas 专属”的 THREE
直接require('three')在小程序里会在模块加载阶段就失败,因为 three 的构建产物顶层会去访问self、window、document这些在小程序环境里不存在的全局对象。常见做法是用小程序官方那套作用域化的构建,把 canvas 注入进去,生成一个只在当前 canvas 上生效的 THREE 命名空间。
// pages/viewer/viewer.js const { createScopedThreejs } = require('threejs-miniprogram') Page({ onReady() { wx.createSelectorQuery() .select('#webgl') // 必须和 wxml 里 canvas 的 id 一致 .node() // 取真实节点,不是节点快照 .exec((res) => { const canvas = res[0] && res[0].node if (!canvas) { // 低版本基础库拿不到节点,这里要能报出来,不要静默失败 console.error('canvas node 获取失败,检查基础库版本与 type="webgl"') return } // 关键一步:canvas 注入后,返回的 THREE 只服务于这块 canvas const THREE = createScopedThreejs(canvas) this.canvas = canvas this.THREE = THREE this.bootstrap(THREE, canvas) }) } })这段代码里三个点值得说清楚。.node()返回的是真实的 canvas 节点,只有这种节点才有getContext、requestAnimationFrame和createImage,用旧的wx.createCanvasContext拿到的绘图上下文调用不了 Three.js。createScopedThreejs(canvas)返回的 THREE 和 npm 上那份不是同一个实例,里面的Vector3、Mesh、Matrix4都带着这块 canvas 的上下文,所以一个页面创建一个,不要跨页面复用,也不要和自己require进来的 three 混着用。混用的后果很隐蔽:你自己 new 的Vector3传给 scoped 的Mesh,isVector3判断不过,位置静默变成 NaN,模型就是不出现,控制台干干净净——这种问题得靠经验才能发现。
对应的 wxml 长这样,type="webgl"是必须的,canvas-id保留一份是为了兼容旧接口兜底:
<canvas id="webgl" type="webgl" canvas-id="webgl" class="webgl" disable-scroll="{{true}}" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd"> </canvas>disable-scroll和后面事件里的catchtouchmove是两回事,前者阻止 canvas 区域内的页面滚动,后者阻止事件冒泡到页面。做 3D 旋转的时候两个都要,否则用户一拖,页面跟着上下滑,模型只转了一半,体验非常糟糕。
2.2 渲染器三行参数,决定真机上是不是一片模糊
渲染器初始化的代码很短,但参数选错在真机上就是灾难。我吃过的最典型的一次亏,是在 3 倍像素比的安卓机上直接setPixelRatio(systemInfo.pixelRatio),画面确实清晰了,帧率从 50 掉到 18,拖动像幻灯片。
const sys = wx.getSystemInfoSync() const dpr = Math.min(sys.pixelRatio || 2, 2) // 上限锁死 2,别信 3 倍屏 const width = sys.windowWidth // 单位 px,和 canvas 的 CSS 尺寸一致 const height = Math.round(width * 0.75) // 按设计稿比例算,避免拉伸 const renderer = new THREE.WebGLRenderer({ antialias: dpr <= 2, // 低端机建议直接关掉,抗锯齿走 FXAA 更划算 alpha: true, // 需要透明背景时开,不要用 setClearColor 黑色顶 preserveDrawingBuffer: false // 除非要截图,否则关掉能省一次拷贝 }) renderer.setPixelRatio(dpr) renderer.setSize(width, height, false) // 第三个参数 false:不写 canvas.style renderer.setClearColor(0x000000, 0)setSize的第三个参数在小程序里必须传false。小程序 canvas 节点没有可供设置的 style,传true会把宽高写到并不存在的地方,表现是画面被拉伸或者只渲染左下角一小块。width、height一定要用 canvas 在页面上的实际 px 尺寸,设计稿给的是 rpx,得先rpx * windowWidth / 750换算,这一步忘了,iPad 和折叠屏上必然错位。
相机用最普通的透视相机就够,视场角 45、近裁面 0.1、远裁面 1000 是最通用的组合:
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000) camera.position.set(0, 1.2, 3.5) camera.lookAt(0, 0, 0)注意:在接真模型之前,先用一个
BoxGeometry加MeshNormalMaterial把管线跑通。管线不通的时候你看不出是加载问题还是渲染问题,先有个能转的盒子,后面所有排查都有参照物。
2.3 触摸事件与 requestAnimationFrame:这两处必须自己接
网页里我们用OrbitControls,一行代码搞定旋转缩放。小程序里这套控件用不了,因为它绑的是 DOM 的addEventListener和pointerdown,小程序没有这些。硬要改也不是不行,但OrbitControls里有大量event.preventDefault()和domElement.getBoundingClientRect(),改到最后不如自己写三十行。
自己写的版本核心在于坐标来源。小程序 canvas 的触摸事件里,e.touches[0]带的是x、y,单位是相对 canvas 左上角的 px;它没有clientX、clientY,也没有pageX。用错字段的表现是:手指在屏幕左边划,模型往反方向转,越靠边偏得越离谱。
onTouchStart(e) { const t = e.touches[0] this.lastPoint = { x: t.x, y: t.y } this.rotating = true }, onTouchMove(e) { if (!this.rotating) return const t = e.touches[0] const dx = t.x - this.lastPoint.x const dy = t.y - this.lastPoint.y this.rotY += dx * 0.008 // 横向拖动 → 绕 Y 轴 this.rotX = Math.max( // 纵向俯仰夹住,避免翻过头 -Math.PI / 3, Math.min(Math.PI / 3, this.rotX + dy * 0.008) ) this.lastPoint = { x: t.x, y: t.y } this.applyRotation() // 写进 group.rotation this.requestRender() }, onTouchEnd() { this.rotating = false }0.008这个系数和 canvas 的物理宽度相关,宽屏上拖同样的像素转得更少,想让手感统一就改成dx / this.canvasWidth * 2。俯仰角夹在 ±60 度是有意的,产品展示类的模型翻到背面除了看到一堆破面没有任何价值,限制住反而显得专业。
帧循环这里也有个坑:scoped 出来的 THREE 里renderer.setAnimationLoop不一定可用,稳妥写法是走 canvas 节点自己的requestAnimationFrame:
startLoop() { const canvas = this.canvas const tick = () => { this.rafId = canvas.requestAnimationFrame(tick) this.renderer.render(this.scene, this.camera) } this.rafId = canvas.requestAnimationFrame(tick) }, onHide() { // 页面切后台必须停,不停就是持续耗电 + 后台被回收后回来黑屏 if (this.rafId) this.canvas.cancelAnimationFrame(this.rafId) this.rafId = null }onHide里停循环这一条,写 demo 的时候几乎所有人都会漏,上线后被用户投诉“看个模型手机发烫”才回头补。停的时候记得把rafId置空,否则onShow里判断重复启动会失效,出现两个循环同时跑,帧率直接翻倍消耗。
3. 模型加载链路:GLTFLoader 改造与两种取文件方式
管线跑通、盒子能转了,接下来才是真正的硬骨头:把美术给的模型加载进来。这一段的失败率远高于前面,原因是 Three.js 的加载器默认按浏览器环境写,XMLHttpRequest、fetch、Blob、URL.createObjectURL在小程序里一个都没有。适配并非改一行,而是要把整条链路拆开重接。
3.1 FileLoader 必须换掉:wx.request 与文件系统管理器二选一
GLTFLoader内部靠FileLoader拿二进制,FileLoader内部靠fetch。小程序里没有 fetch,所以不管加载远程模型还是包内模型,第一步都是把FileLoader.prototype.load整个替换掉。我一般的写法是先判断协议,远程走wx.request,本地走文件系统管理器。
function patchFileLoader(THREE) { const { FileLoader, Cache } = THREE FileLoader.prototype.load = function (url, onLoad, onProgress, onError) { const scope = this // 命中缓存直接回调,避免同一张贴图/同一个 bin 被重复请求 const cached = Cache.get(url) if (cached) { onLoad && onLoad(cached) return cached } scope.manager.itemStart(url) // 与 itemEnd 必须成对,否则 LoadingManager 永远不触发 const done = (buffer) => { Cache.add(url, buffer) onLoad && onLoad(buffer) scope.manager.itemEnd(url) } const fail = (err) => { onError && onError(err) scope.manager.itemError(url) scope.manager.itemEnd(url) } if (/^https?:\/\//.test(url)) { wx.request({ url, method: 'GET', responseType: 'arraybuffer', // 不写这行,拿到的是被转成字符串的乱码 success: (res) => { if (res.statusCode === 200) done(res.data) else fail(new Error('HTTP ' + res.statusCode)) }, fail }) } else { wx.getFileSystemManager().readFile({ filePath: url, success: (res) => done(res.data), fail }) } } }几个参数值得单独强调。responseType: 'arraybuffer'是最容易漏的一行,漏了以后res.data是字符串,GLTFLoader.parse拿到它会解析失败但只报一句很笼统的错,你会以为是模型文件坏了,来回折腾半天。Cache.add必须配上Cache.get,因为 gltf 里的.bin和外置贴图会被多次请求,缓存能省掉一半流量。manager.itemStart/itemEnd/itemError三个调用要严格配对,配不上时LoadingManager.onLoad永远不触发,你写的“加载完成隐藏 loading”永远等不到。
包内文件读取这里有个版本差异要注意:getFileSystemManager().readFile读代码包内的文件在较新的基础库上没问题,老版本上会报路径不存在。稳妥的做法是在app.js启动时把模型从包内拷到用户目录,之后统一从wx.env.USER_DATA_PATH读,路径变了但接口稳定。
// 把包内模型拷到用户目录,后续统一用这个路径 const fs = wx.getFileSystemManager() const target = `${wx.env.USER_DATA_PATH}/scene.gltf` try { fs.accessSync(target) } catch (e) { fs.copyFileSync('models/scene.gltf', target) // 第一次进来才拷 }3.2 贴图走 canvas.createImage,内嵌贴图的 glb 会静默失败
模型能加载了,第二个问题紧接着来:模型是灰的、纯白或者纯黑,材质完全没生效。八成是贴图没加载上。
GLTFLoader加载外置贴图时走的是ImageLoader,而ImageLoader内部是document.createElementNS('img')。小程序里能当texture.image用的只有canvas.createImage()返回的对象,所以这一步也得替换:
function patchImageLoader(THREE, canvas) { THREE.ImageLoader.prototype.load = function (url, onLoad, onProgress, onError) { const img = canvas.createImage() // 唯一能被 Texture 接受的图像对象 img.onload = () => onLoad && onLoad(img) img.onerror = (e) => { console.error('贴图加载失败', url) onError && onError(e) } img.src = url return img } }比这更隐蔽的是 glb 内嵌贴图。glb 把纹理塞在 bufferView 里,GLTFLoader解析到这种纹理时会new Blob(...)再URL.createObjectURL,而小程序两个都没有。结果是贴图加载直接抛异常,但这个异常在 loader 内部被吞掉一部分,你在控制台只看到“模型加载完成”却是一片白。
绕开这个问题有两个办法。省事的办法是导出时就别内嵌:在建模软件里导出 glTF 时选“分离(.gltf + .bin + textures)”格式,贴图变成独立文件,ImageLoader那条替换路径就能正常工作。想继续用单文件 glb,就得在GLTFLoader里把 bufferView 到 base64 data URI 的那段改掉,把二进制转成data:image/png;base64,...再喂给canvas.createImage(),这条路能走通但内存占用会涨一截,大贴图慎用。
注意:贴图的尺寸也有限制。1024 边长以内最稳,2048 在部分中低端安卓上会创建失败且不报错,表现就是材质变成纯色。
3.3 parse 之后的居中与缩放:不做这一步,模型会绕着空气旋转
加载成功、贴图正常,第三个问题往往是“模型在画面外”或者“转起来像在绕空气旋转”。原因是模型的原点由美术在建模软件里定的,可能在脚底、可能在场景某个角落,和你的相机、你的旋转中心都没关系。
拿到 gltf 之后不要直接scene.add,先算包围盒再做归一:
loader.load(modelPath, (gltf) => { const root = gltf.scene const box = new THREE.Box3().setFromObject(root) const size = box.getSize(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3()) // 1) 把几何中心平移到原点,旋转才是“自转” root.position.sub(center) // 2) 统一缩放到包围球直径 2,相机距离就能固定,不用一个模型调一次 const maxSide = Math.max(size.x, size.y, size.z) || 1 root.scale.setScalar(2 / maxSide) // 3) 有动画的话在这里起 mixer,没有就直接挂上去 if (gltf.animations && gltf.animations.length) { this.mixer = new THREE.AnimationMixer(root) this.mixer.clipAction(gltf.animations[0]).play() } this.group.add(root) this.requestRender() }, undefined, (err) => { console.error('模型加载失败', err) })2 / maxSide这个归一化是我最推荐的一个习惯。不做归一,每个模型的尺寸都不一样,相机z得 하나一个调,产品列表页换一个模型就崩一次。做了归一之后,相机固定z = 3.5,模型永远占画面 60% 左右,视觉统一,配置也不用改。
root.position.sub(center)有个前提:center是相对root父坐标系算出来的,如果模型自身position不为零,直接减会有偏差。稳妥写法是先root.position.set(0,0,0)再算 box,算完再平移。这个细节在小模型上看不出来,在有嵌套层级的模型上会让模型偏出去半个屏幕。
4. 包体与帧率:小程序硬约束下的取舍
小程序对包体和内存的限制比网页严得多,主包 2MB、总包 20MB 这两条线卡在那里,一个没优化的 glb 轻松上 30MB。而 Three.js 本身加上去也有三百多 KB,剩下的空间要留给业务代码。这一章讲的都是“砍”的艺术:砍面数、砍贴图、砍不必要的渲染。
4.1 面数、贴图、材质:先卡死三个数字
我一般在美术出模型之前就把预算表丢过去,而不是等出完再让他们改,后者沟通成本更高。
| 项目 | 建议上限 | 超了会怎样 |
|---|---|---|
| 三角面数 | 3 万以内 | 中低端安卓首帧超过 1.5 秒,拖动掉到 20 帧以下 |
| 贴图总大小 | 1MB 以内 | 包体超限,且显存吃紧容易闪退 |
| 单张贴图边长 | 1024 | 2048 在部分机型创建失败且无报错 |
| 材质数量 | 3 个以内 | 每个材质一次 draw call,数量翻倍帧率腰斩 |
| 骨骼数量 | 30 以内 | 骨骼动画的矩阵计算是 CPU 侧开销,数量线性增长 |
超了就用 gltf-transform 做一轮离线优化,这是目前最省事的一条路:
# 减面 + 限制贴图尺寸 + 清理无用节点,一条命令搞定 npx @gltf-transform/cli optimize raw.glb web.glb \ --texture-size 1024 \ --simplify-ratio 0.5 \ --simplify-error 0.001--texture-size 1024把所有贴图等比压到最长边 1024,通常能省 60% 以上体积;--simplify-ratio 0.5是减面比例,0.5 表示保留一半三角面,配合--simplify-error 0.001控制误差,后者太小减不下来、太大会出现明显破面,0.001 到 0.01 之间试。这个命令会顺手做 prune(删无用节点)和 weld(合并重复顶点),体积还能再降一点。
至于 Draco 这类几何压缩扩展,我的建议是能不碰就不碰。它需要额外的 WASM 或 JS 解码器,而解码器本身在小程序里还要再适配一轮,收益是体积减半,成本是一到两天的调试和一个不确定的兼容性风险。除非包体真的顶到 20MB 红线、减面和压贴图都用完了,否则不值得。
4.2 按需渲染:静止时把帧循环停掉
一个产品展示页,用户有 70% 的时间是在看,不是在拖。持续 60 帧渲染纯属浪费,而且在低端机上会让整个页面都变卡——WebGL 渲染线程占满,setData 都变慢。
改成“脏标记 + 需要时才渲染”的模型,逻辑很简单:所有会改变画面的操作(旋转、缩放、动画帧、材质切换)调用requestRender(),渲染完就停;有持续动画的时候才开连续循环。
requestRender() { if (this.rafId) return // 已经有一帧在排队,不重复排 const step = () => { this.rafId = null this.renderer.render(this.scene, this.camera) if (this.mixer) { // 有动画就必须连续跑,这时退化成常驻循环 this.requestRender() } } this.rafId = this.canvas.requestAnimationFrame(step) }这个写法有个前提:mixer.update(delta)得在 render 之前调用,并且 delta 要自己算。用Date.now()差值除以 1000,不要用clock.getDelta(),scoped 版本的Clock依赖performance.now,在没有 polyfill 的环境里会拿到 0,动画就会原地冻住——这也是个常见问题,表现是模型动了一下就不动了,看起来像加载没完成。
配合按需渲染,还有两个开关收益很明显。关阴影:renderer.shadowMap.enabled = false,实时阴影在移动端是纯 CPU/GPU 大户,产品展示用烘焙到贴图里的假阴影效果不差。降材质:MeshStandardMaterial换成MeshLambertMaterial甚至MeshBasicMaterial(贴图里已经烘好光照的情况下),片元着色器复杂度直接砍一半。
4.3 内存释放:dispose 的顺序和 forceContextLoss
小程序里 WebGL 上下文的数量是有上限的,同层渲染的 canvas 更是占资源。用户在产品页和详情页之间来回切三五次,第六次进来黑屏,控制台什么都不报——这个现象我见过太多次,根因都是页面卸载时没释放。
释放不是一句renderer.dispose()就完事,得按几何体、材质、贴图、渲染器的顺序逐个清:
onUnload() { if (this.rafId) { this.canvas.cancelAnimationFrame(this.rafId) this.rafId = null } if (this.mixer) { this.mixer.stopAllAction() this.mixer = null } this.scene.traverse((obj) => { if (obj.geometry) obj.geometry.dispose() // 顶点数据在显存里 if (!obj.material) return const mats = Array.isArray(obj.material) ? obj.material : [obj.material] mats.forEach((m) => { // 材质上挂的贴图要单独释放,material.dispose() 不管贴图 Object.keys(m).forEach((key) => { const val = m[key] if (val && val.isTexture) val.dispose() }) m.dispose() }) }) this.renderer.dispose() this.renderer.forceContextLoss() // 主动交还 WebGL 上下文,关键的一步 this.renderer = null this.scene = null }forceContextLoss()是最容易被忽略的一行,它主动触发WEBGL_lose_context扩展把上下文还给系统。少了这一步,dispose()只是释放了 Three.js 内部持有的一层引用,底层上下文要等 GC,而 GC 在小程序里的时机完全不可控。
5. 排查清单:黑屏、模型不显示、触摸错位这些事
下面这五条是我在真机上实际遇到、并且在别人的项目里也反复见到的。每条按现象、原因、解决写,遇到问题照着对。
5.1 开发者工具里正常,真机一片黑
现象:模拟器上模型清清楚楚,扫码到手机上打开,canvas 区域纯黑,没有任何报错。
原因通常有三个层次。最外层是像素比,工具里pixelRatio是 1 或 2,真机 3 倍屏,渲染的帧缓冲是 1170×2532,中低端 GPU 直接分配失败。中间层是纹理,用了非 2 的幂尺寸的贴图又开了默认的 mipmap,WebGL1 下会创建失败。最里层是抗锯齿,MSAA 的显存在部分机型上会直接超限。
解决顺序:先把setPixelRatio锁到 2 以下再试;不行就把antialias关掉;还不行就给所有贴图设generateMipmaps = false和minFilter = THREE.LinearFilter。三步下来九成的真机黑屏能解决。这个排查顺序值得记住,从“开销最大的开关”往“开销最小的开关”试。
5.2 模型不显示,控制台一点错都没有
现象:加载回调触发了,gltf.scene也有内容,但画面里只有背景色。
原因可能有三:相机在模型内部(模型尺寸几十米,相机在原点,等于站在模型肚子里);材质是 PBR 但没有环境贴图,metalness默认 1,结果全是黑的;FileLoader拿到的是文本不是二进制,parse出来的场景是空的。
解决:先加一行console.log(new THREE.Box3().setFromObject(root))看包围盒是不是离谱,是就做归一化;再把所有材质临时换成MeshNormalMaterial,能看到彩色模型说明是材质/光照问题,看不到就是几何/相机问题;最后检查responseType有没有写。这个三分法比漫无目的地改代码快十倍。
5.3 模型是白模,贴图完全没有
现象:形状对,光照对,就是纯白或纯灰,没有花纹。
原因:ImageLoader没换,或者换了但模型用的是内嵌贴图的 glb。前者会在控制台报document is not defined,后者什么都不报,因为异常在 loader 内部被捕获了。
解决:先确认导出格式。让美术重新导一份.gltf + 外置贴图,如果换格式就正常了,那就别在 glb 内嵌上继续挣扎。必须用单文件 glb 的话,在GLTFLoader里找URL.createObjectURL那段,改成把 bufferView 转 base64 塞进data:URI。
5.4 手指往左划,模型往右转
现象:交互能用,但方向是反的,或者转动的幅度和手指位移不成比例,屏幕边角处偏得特别厉害。
原因:用了clientX或者pageX而不是 canvas 触摸事件里的x/y。前两者在小程序 canvas 上是undefined,拿到的dx是 NaN,某些机型上 NaN 参与运算会被截断成 0 或者异常值,表现就是方向反了或者乱转。另一种情况是 canvas 的 CSS 尺寸和绘制缓冲尺寸不一致(rpx 没换算),坐标比例对不上。
解决:统一用e.touches[0].x和e.touches[0].y,并且在onTouchMove里加一行断言if (typeof t.x !== 'number') return,出问题能立刻定位。旋转系数不要写死,改成按 canvas 宽度归一:dx / canvasWidth * 2,各种屏幕上手感才一致。
5.5 来回切页面几次后进来黑屏或直接闪退
现象:第一次进正常,退出再进也正常,切到第五六次开始黑屏,或者微信直接闪退到聊天列表。
原因:WebGL 上下文和显存没释放。小程序单页面的上下文数量有配额,页面onUnload之后如果不主动交还,配额被占满,新的 canvas 创建出来就是个空壳,能拿到节点但getContext返回 null。
解决:onUnload里按上一章的顺序做完整释放,特别是forceContextLoss()。验证办法很土但有效:在onUnload里打一行日志,连续进出页面十次,看日志是不是每次都打、内存曲线(开发者工具的性能面板)是不是回到基线。回不去就说明还有引用没断。
6. 进阶:Raycaster 拾取与上线前的自检
模型能转能看之后,产品经理多半会提下一个需求:“点一下模型的某个部位,弹个说明”。这就是拾取,用Raycaster做,但小程序里坐标转换这步要特别小心。
onTap(e) { const t = e.changedTouches[0] const rect = this.canvasSize // 初始化时存下来的 { width, height }(px) // canvas 坐标 → 归一化设备坐标,这一步错了就永远点不中 const ndc = new this.THREE.Vector2( (t.x / rect.width) * 2 - 1, -(t.y / rect.height) * 2 + 1 ) const raycaster = new this.THREE.Raycaster() raycaster.setFromCamera(ndc, this.camera) const hits = raycaster.intersectObjects(this.pickables, true) if (!hits.length) return // 拿到的第一个交点,object.userData 里放业务 id const id = hits[0].object.userData.partId this.setData({ activePart: id }) }两个参数要盯住。rect.width、rect.height必须是 canvas 的实际 px 尺寸,不是 rpx、不是pixelRatio乘过的绘制缓冲尺寸,用错会整体偏移,越靠边越明显。intersectObjects的第二个参数true表示递归子节点,模型拆件之后目标物往往是深层子 mesh,不传true永远拿不到。另外pickables只放需要响应的对象,别把整个场景丢进去,三角形数量大的时候射线检测是 CPU 侧开销,能省则省。
上线前我会固定跑一遍这套自检:连续进出页面十次看内存是否回落;把手机调成省电模式看帧率;用 3 倍像素比的旧安卓机跑一遍拖动;断网进页面看降级提示是否出现;模型换成一个 5 万面的“超标样本”看会不会闪退。这五项花不到二十分钟,能挡掉绝大部分线上事故。
血泪经验是有一年吃过一次亏:本地调试全用模拟器,上线当天安卓端大面积黑屏,回过头才发现整个链路里pixelRatio和贴图 mipmap 两处都没做限制。从那以后我每次接 3D 需求,第一步就是在真机上先跑一个立方体,把渲染管线确认过再谈模型。这套东西拆开看每一步都不复杂,难的是每一步都有一个容易遗漏的适配点,希望帮到你。
本文还有配套的精品资源,点击获取