简介面向微信小程序开发者与三维展示初学者这份源码示例聚焦在轻量小程序环境中接入three.js并完成3D模型展示与基础动画控制适合需要快速验证渲染方案、排查兼容问题或搭建课堂演示的读者参考。压缩包共18个文件整体约258KB以8个js逻辑脚本、5个json配置、2个wxml结构文件、2个wxss样式文件及1个gltf模型为主分别承担交互逻辑、页面配置、视图结构、样式描述与模型资源等职责目录按pages、components、utils、3Dmodels等模块拆分层次清楚便于按功能定位和二次调整。已有2307人学习下载在入门实践场景中具备一定参考热度。源码保留了可自定义的动画入口读者可据此实现旋转、掉落等效果并借助现成虾模型与组件化组织减少模型接入、渲染调试中的常见坑点整体上手门槛较低。1. 用户点开详情页只想把东西转一圈看看微信小程序 3D 展示这件事需求方第一次提的时候往往说得特别朴素用户点进详情页能把这个产品的正面、侧面、背面转着看一遍最好再捏合放大看看接口和纹理细节。听上去就是加载个模型加点手势真动手才发现难点根本不在 three.js 的 API 上而在运行环境的落差——小程序里没有 DOM没有document.getElementByIdcanvas 必须用选择器异步拿节点触摸事件也不是浏览器的 PointerEvent模型从打包到上屏的每一步都得重新安排。这套方案要解决的就是把这个落差填平一份 GLB 模型进到小程序后能稳定加载、稳定出图、手势跟手、退出不泄漏内存并且在低端安卓机上不掉到个位数帧率。适合两类人正在做 3D 展示类毕设或课程设计、需要一套能跑通也能写进文档的完整结构以及手上已经有一个小程序想加一个 3D 看物模块、但不想推倒重来的开发者。下面按选型、架构、实现、避坑、验收的顺序走一遍。2. 技术选型与运行环境三条路线的边界和适配层怎么落动手写第一行代码之前值得先花半小时把路线定死。选错了后面所有代码都是在给错误的地基打补丁。这一章把可选路线、适配层的本质、工程初始化三件事讲清楚后面的架构才有依据。2.1 原生 WebGL、内置 3D 能力与 three.js 的取舍摆在小程序面前的其实只有三条路。第一条是手写 WebGL调gl.drawElements、自己写矩阵变换和着色器好处是包体最小、没有中间层坏处是光照、材质、模型解析全得自己实现一个 GLTF 解析器就能吃掉两周毕设周期根本不够。第二条是用小程序生态里自带的 3D 能力比如某些版本提供的内置 3D 组件。上手快但组件封装程度高能调的参数有限模型格式支持窄遇到这个模型就是加载不出来的时候几乎没有排查手段只能换模型。做演示够用做交付方案不够。第三条是 three.js 加一层作用域适配。生态最成熟GLTF/GLB 是官方主推格式光照、材质、动画混合器、射线拾取全都现成网上能查到的案例最多出问题也最容易搜到同类现象。代价是要多引一个适配包包体大一点某些依赖 DOM 的扩展模块比如后处理 EffectComposer需要改造后才能用。对比项手写 WebGL内置 3D 组件three.js 适配层起步成本极高低中模型格式支持自己实现窄GLTF/GLB/OBJ 等可调参数全部有限基本全部出问题可排查性高自己写的低高社区案例多包体增量最小小约 500KB1MB适合场景单一特效简单展示完整 3D 展示方案对绝大多数毕设和课程设计来说第三条是性价比最高的。方案里我一般直接按 three.js 这条路线写因为它在能交作业和能上线之间不需要重写。2.2 适配层的本质为什么要一个包装过的 THREEthree.js 内部大量假设自己跑在浏览器里创建纹理时会碰document.createElement(canvas)加载资源时会碰XMLHttpRequest判断环境时会读window。小程序里这些全都没有直接import * as THREE from three在真机上第一时间就报window is not defined。适配包做的事说穿了很朴素对外暴露一个工厂函数你把手上的 canvas 节点传进去它在这个作用域内伪造出最小可用的window、document、canvas再返回一个绑定好的 THREE 对象。之后所有new THREE.Scene()、new THREE.WebGLRenderer()用的都是这个作用域里的版本而不是全局的那份。# 在项目根目录初始化并安装适配包 npm init -y npm install threejs-miniprogram --save # 安装完成后必须在开发者工具里执行「工具 - 构建 npm」 # 否则 miniprogram_npm 目录不会生成require 会直接报模块找不到构建这一步是新手上手时最容易卡住的地方装完包不改project.config.json里的packNpmManually或没点构建代码里require一定失败报错信息还不指向真正原因。我习惯在 README 里把装完必须构建写成第一步省得后面反复解释。2.3 工程初始化目录结构、页面节点与基础库版本目录按职责切三块pages/放页面models/放体积小的本地模型utils/放渲染器封装和手势处理。模型如果超过 1MB别塞主包走分包或者远端域名。页面节点只需要一个 canvas但type必须显式写成webgl写成默认的 2d 会拿到一个 2D 上下文后面创建渲染器时直接失败!-- pages/viewer/viewer.wxml -- view classstage !-- type 必须是 webgl且要绑定触摸事件 -- canvas typewebgl idglcanvas classgl-canvas bindtouchstartonTouchStart bindtouchmoveonTouchMove bindtouchendonTouchEnd bindtouchcancelonTouchEnd /canvas !-- 加载进度条覆盖在 canvas 上层模型就绪后隐藏 -- view classloading wx:if{{loading}} text模型加载 {{progress}}%/text /view /view页面 JSON 里disableScroll: true建议打开否则手指在 canvas 上上下滑动时页面会跟着滚手势和页面滚动打架体验非常糟。基础库版本建议不低于 2.9.0canvas的node()取节点能力在这个版本之后才稳定。这些参数不是玄学是踩过一次真机白屏之后才记住的。3. 总体架构设计三层结构与模型从硬盘到屏幕的链路一套 3D 展示方案能不能维护取决于有没有把页面管什么、渲染管什么、资源管什么切干净。切不干净的典型症状是所有代码堆在页面的onReady里两百行下去想加个新模型就得复制一份最后没人敢动。3.1 分层页面层、渲染层、资源层的职责边界我一般切成三层。页面层只负责生命周期和 UI 状态拿到 canvas 节点、显示加载进度、处理页面onHide/onUnload、把触摸事件原样转发出去。它不写任何 three.js 代码这样换框架、换页面结构时渲染层可以整体搬走。渲染层是一个类对外只暴露init(canvas, width, height)、loadModel(url)、rotate(dx, dy)、zoom(scale)、dispose()这几个方法。内部持有 scene、camera、renderer、mixer负责渲染循环和资源回收。外部永远不直接碰scene避免页面代码到处scene.add()导致回收时漏对象。资源层负责模型文件的组织命名规范、压缩参数、存放位置。这一层看起来最不像代码但它决定了首屏时间和内存占用往往比渲染层更影响最终体验。层持有的对象对外方法不该做的事页面层canvas 节点、UI 状态生命周期钩子直接操作 scene渲染层scene / camera / renderer / mixerinit、loadModel、dispose读写页面 data资源层glb 文件、贴图无构建期完成运行时动态拼路径3.2 资源链路GLB 制作规范、压缩与加载策略模型从建模软件导出到最终上屏中间至少要过三道关。第一道是格式。统一用.glb单文件不要用.gltf 外部.bin 一堆贴图。小程序里加载外部贴图要额外发请求路径稍有偏差就是白模而且每个文件都要过域名校验。GLB 把几何、材质、贴图全打进一个二进制文件一次请求搞定出问题的概率低一个数量级。第二道是减面。移动端 GPU 对三角形数量很敏感把 30 万面的模型直接丢进小程序中低端安卓机基本就是幻灯片。通常的做法是在建模软件里减到 3 万面以内保留轮廓法线贴图补细节。纹理尺寸控制在 1024×1024 以内超过就是浪费显存。第三道是压缩。GLB 体积超过 2MB 时考虑用 Draco 或者 meshopt 压缩几何数据。Draco 需要额外的解码器文件在小程序里要手动把解码脚本放进项目并配置路径麻烦但有效meshopt 解码器更小集成相对简单。这属于优化项不是必选项先跑通再压。存放位置按体积分小于 1MB 的放主包14MB 的放分包再大的放已配置好的合法域名下通过wx.downloadFile拿到临时路径再喂给加载器。注意域名必须在后台配好否则真机上一定失败而开发者工具里勾了不校验合法域名是能跑通的——这就是最经典的工具里好好的真机全白。3.3 手势交互层把触摸事件翻译成相机轨道参数three.js 官方的 OrbitControls 是绑在 DOM 元素上的监听pointerdown、wheel这些浏览器事件小程序里直接引进来不会有任何反应。常见的做法有两个改造 OrbitControls 的事件绑定部分或者干脆自己写一套轻量手势。自己写的版本反而更好控制核心就是记录上一次触摸点和当前触摸点算位移和距离差// utils/gesture.js —— 极简手势解析输出旋转量和缩放量 export default class Gesture { constructor() { this.lastX 0; this.lastY 0; this.lastDist 0; this.mode none; // none | rotate | zoom } // 双指距离用来判断捏合缩放 static distance(t1, t2) { const dx t1.x - t2.x; const dy t1.y - t2.y; return Math.sqrt(dx * dx dy * dy); } start(e) { const t e.touches; if (t.length 1) { this.mode rotate; this.lastX t[0].x; this.lastY t[0].y; } else if (t.length 2) { this.mode zoom; this.lastDist Gesture.distance(t[0], t[1]); } } move(e) { const t e.touches; if (this.mode rotate t.length 1) { // 位移量除以画布宽度做归一化保证不同机型手感一致 const dx (t[0].x - this.lastX) / 300; const dy (t[0].y - this.lastY) / 300; this.lastX t[0].x; this.lastY t[0].y; return { type: rotate, dx, dy }; } if (this.mode zoom t.length 2) { const dist Gesture.distance(t[0], t[1]); const delta dist - this.lastDist; this.lastDist dist; return { type: zoom, delta: delta / 200 }; } return null; } end() { this.mode none; } }这里有两个参数值得说明。除以 300 是把像素位移归一化成弧度当量值越大转得越慢调试时按模型复杂度调缩放那边除以 200 同理控制捏合灵敏度。归一化的意义在于不同屏幕宽度下手感一致不做这一步小屏上轻轻一划就转半圈大屏上怎么划都纹丝不动。渲染层拿到{type:rotate, dx, dy}之后用球坐标更新相机位置// 球坐标绕目标点旋转避免直接用欧拉角导致万向锁 rotate(dx, dy) { this.theta - dx; // 水平角 this.phi - dy; // 垂直角 // 限制俯仰角防止相机翻到模型底下或顶上 const limit Math.PI / 2 - 0.05; this.phi Math.max(-limit, Math.min(limit, this.phi)); const r this.radius; this.camera.position.x r * Math.cos(this.phi) * Math.sin(this.theta); this.camera.position.y r * Math.sin(this.phi); this.camera.position.z r * Math.cos(this.phi) * Math.cos(this.theta); this.camera.lookAt(0, 0, 0); }用球坐标而不是直接改rotation是为了避免俯仰角到 ±90° 时画面翻转。这个坑很隐蔽不加限制时用户手指往上滑到底模型会突然上下颠倒看着像渲染错误其实是相机穿过了极点。4. 核心模块实现场景装配、模型加载与渲染循环架构定完落到能跑的代码。这一章三个模块是强耦合的场景装配不对模型加载出来是黑的加载链路没兜底弱网就是永久转圈渲染循环没收尾来回切三次页面就闪退。4.1 场景装配相机、光照与画布尺寸对齐先拿节点。canvas 必须在页面渲染完成后取onLoad里取一定是 nullonReady才是正确时机// 页面层拿节点、算尺寸、交给渲染层 onReady() { wx.createSelectorQuery() .select(#glcanvas) .node() .exec((res) { const canvas res[0] res[0].node; if (!canvas) { // 拿不到节点时给出可见提示不要静默失败 this.setData({ loading: false, error: 画布初始化失败 }); return; } // 关键物理像素尺寸 逻辑尺寸 × 像素比 const info wx.getSystemInfoSync(); const dpr Math.min(info.pixelRatio, 2); // 上限 2高分屏不追 3 canvas.width info.windowWidth * dpr; canvas.height info.windowHeight * dpr; this.viewer new Viewer3D(); this.viewer.init(canvas, canvas.width, canvas.height); this.viewer.loadModel(this.data.modelUrl, (p) { this.setData({ progress: p }); }); }); }canvas.width/height设的是绘制缓冲尺寸跟 CSS 尺寸是两回事。只设 CSS 不设这个画面会糊成一团设了但没乘 dpr高分屏上就是马赛克。dpr 卡在 2 是刻意的3 倍屏渲染像素量是 2 倍的 2.25 倍肉眼几乎看不出差别帧率却掉一截。场景本身反而简单init(canvas, width, height) { this.THREE createScopedThreejs(canvas); // 绑定作用域的 THREE const THREE this.THREE; this.scene new THREE.Scene(); // 背景色跟页面底色保持一致避免出现突兀的色块边界 this.scene.background new THREE.Color(0xf5f6f8); this.camera new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); this.camera.position.set(0, 0, 5); const renderer new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setSize(width, height, false); // false 表示不改写 CSS 尺寸 renderer.setPixelRatio(1); // 像素比已在 canvas.width 里体现 this.renderer renderer; // 环境光打底 主方向光造型纯环境光会让模型像纸片 this.scene.add(new THREE.AmbientLight(0xffffff, 0.75)); const key new THREE.DirectionalLight(0xffffff, 1.2); key.position.set(3, 5, 4); this.scene.add(key); // 补一盏反向弱光避免背面死黑 const fill new THREE.DirectionalLight(0xffffff, 0.35); fill.position.set(-3, -2, -4); this.scene.add(fill); }setSize第三个参数传false很重要。传true时 three.js 会去改 canvas 的style.width小程序里这个操作没意义还可能触发重排。光照配比也可以调环境光 0.75 加主光 1.2 是我在多数产品模型上试出来的中性值金属材质多的模型要把环境光压低、补一盏半球光。4.2 模型加载进度、超时与降级兜底GLTFLoader 在小程序里不能直接 import需要从 three 的 examples 里取源码把内部的 THREE 引用替换成作用域返回的实例再挂到THREE.GLTFLoader上。做完这一步加载逻辑本身和浏览器里差不多区别在于必须加超时和失败兜底loadModel(url, onProgress) { const THREE this.THREE; const loader new THREE.GLTFLoader(); // 15 秒还没加载完就判定失败避免用户对着转圈干等 const timer setTimeout(() { if (!this.loaded) this.onError this.onError(timeout); }, 15000); loader.load( url, (gltf) { clearTimeout(timer); this.loaded true; const model gltf.scene; // 自动居中并缩放保证任意尺寸的模型都能完整入镜 const box new THREE.Box3().setFromObject(model); const size box.getSize(new THREE.Vector3()); const center box.getCenter(new THREE.Vector3()); const maxDim Math.max(size.x, size.y, size.z); const scale 2 / maxDim; model.scale.setScalar(scale); model.position.sub(center.multiplyScalar(scale)); this.scene.add(model); this.model model; // 模型自带骨骼动画时启动混合器 if (gltf.animations gltf.animations.length) { this.mixer new THREE.AnimationMixer(model); this.mixer.clipAction(gltf.animations[0]).play(); } onProgress onProgress(100); this.startLoop(); }, (xhr) { // 部分环境拿不到 total此时退化成不确定进度 if (xhr.total) { onProgress onProgress(Math.floor((xhr.loaded / xhr.total) * 100)); } }, () { clearTimeout(timer); this.onError this.onError(load-failed); } ); }自动居中缩放这几行是方案里最值钱的部分之一。建模同学导出的模型原点可能在世界坐标的任意角落尺寸可能是 0.01 米也可能是 100 米。不做这层归一化每换一个模型就要手动调一次相机位置方案就失去了复用性。用包围盒算出最大边长统一缩放到 2 个单位再把包围盒中心平移到原点相机固定在 z5 就能保证模型完整入镜。超时那条也别省。弱网环境下加载器可能永远不回调用户看到的就是无限转圈最后归因为小程序卡死了。给个 15 秒的兜底超时后展示静态图 重试按钮体验上是降级但不是死局。4.3 渲染循环与生命周期帧率控制、页面隐藏与资源释放渲染循环要解决三个问题用谁的时间戳、什么时候停、停了之后怎么回收。startLoop() { let last Date.now(); const loop () { const now Date.now(); const delta (now - last) / 1000; last now; // 动画混合器按真实时间步进不受帧率波动影响 if (this.mixer) this.mixer.update(delta); // 相机始终看向原点配合手势层的球坐标更新 this.camera.lookAt(0, 0, 0); this.renderer.render(this.scene, this.camera); this.rafId this.canvas.requestAnimationFrame(loop); }; this.rafId this.canvas.requestAnimationFrame(loop); } stopLoop() { if (this.rafId) { this.canvas.cancelAnimationFrame(this.rafId); this.rafId null; } }requestAnimationFrame必须挂在 canvas 上而不是全局小程序里全局没有这个方法。delta用真实时间差而不是固定 1/60能让动画速度在不同帧率的机型上保持一致否则高端机动画飞快、低端机慢动作。页面生命周期要接死// 页面隐藏时停掉循环省电也省 GPU onHide() { this.viewer this.viewer.stopLoop(); }, onShow() { this.viewer this.viewer.startLoop(); }, // 卸载时逐个释放缺一步就是内存泄漏 onUnload() { if (!this.viewer) return; this.viewer.stopLoop(); this.viewer.scene.traverse((obj) { if (obj.geometry) obj.geometry.dispose(); if (obj.material) { const mats Array.isArray(obj.material) ? obj.material : [obj.material]; mats.forEach((m) { // 贴图是最容易漏的必须单独释放 Object.keys(m).forEach((k) { if (m[k] m[k].isTexture) m[k].dispose(); }); m.dispose(); }); } }); this.viewer.renderer.dispose(); this.viewer null; }traverse里遍历材质属性释放贴图这段是血泪经验。只调material.dispose()的话贴图占用的显存不会立刻归还来回进出页面十几次小程序的 WebGL 上下文就会因为显存超限被强制回收表现是画面突然全白然后报一堆上下文丢失的错误。renderer.dispose()之后再置空引用让 GC 能真正收走。5. 避坑与常见问题排查黑屏、白模、卡顿、闪退的定位路径下面五条是按出现频率排的基本覆盖了从开发到真机测试会遇到的绝大多数问题。每条都按现象、原因、解决三段写方便直接对照排查。5.1 黑屏但没有任何报错现象页面打开后 canvas 区域一片纯色或者全黑控制台干净得可疑。原因八成是 canvas 节点没拿到。onLoad里取节点、或者 canvas 被wx:if控制初始不渲染node()返回的都是 null。还有一种隐蔽情况是容器高度为 0canvas 拿到了但尺寸是 0×0渲染器照常工作就是什么都看不见。解决把取节点挪到onReadycanvas 用hidden而不是wx:if控制显隐取到节点后立刻打印canvas.width为 0 就是样式问题去查容器有没有写死高度。5.2 模型加载成功但通体发白或发黑现象能看出轮廓但材质完全没有质感像塑料纸片或者干脆黑成一坨。原因两类。一类是光照缺失——只加了环境光PBR 材质没有方向光就没有高光看着就是纸片。另一类是颜色空间不对贴图按线性空间解读整体会偏暗或偏灰。解决按 4.1 的光照配比补齐主光加补光贴图加载后显式设置颜色空间不同版本 three.js 里是texture.colorSpace或texture.encoding按你锁定的版本写。拿不准时先把材质临时换成MeshBasicMaterial能正常显示说明是光照问题不能就是贴图没加载上。5.3 手指滑动没反应或者转得乱七八糟现象手势完全无响应或者模型转的方向跟手指相反或者一划就飞出去。原因直接引了官方 OrbitControls它监听的是 DOM 事件在小程序里根本收不到。转得乱则是坐标系没对齐触摸事件的x/y是相对 canvas 的逻辑坐标跟相机旋转轴的符号方向不一定一致。解决换成自写手势层3.3 的做法方向反了就把dx、dy前面的符号取反一划飞出去是归一化分母太小把除数调大。调的时候拿个带明显正反面的模型测比如一个带 logo 的盒子正反面一眼就能看出来。5.4 来回进出页面几次后卡顿甚至闪退现象第一次打开很流畅反复切换 tab 或者进出详情页之后帧率一降再降最后白屏。原因requestAnimationFrame没取消多个循环叠着跑几何体和纹理没释放显存持续增长页面onUnload没写回收逻辑。解决onHide停循环、onShow重启onUnload里按 4.3 的流程逐个 dispose。自测方法很简单连续进出页面 20 次看帧率和内存曲线是否平稳不平稳就说明漏了。5.5 开发者工具正常真机加载失败现象工具里模型好好的扫码到手机上一直转圈或者直接报错。原因最常见的是域名没配。工具里可以勾选跳过域名校验真机不行。其次是模型文件太大超过单次请求限制还有一种是 GLB 里引用了外部贴图路径工具里恰好能读到真机上路径无效。解决把下载域名加进合法域名列表大文件走分包或者拆分导出时确认勾选嵌入贴图保证是真正的单文件 GLB。真机调试建议开 vConsole直接看网络面板的失败原因比猜快得多。6. 验收与答辩用一张指标表和一段演示脚本证明方案达标代码跑通只是及格线能不能交付要看有没有可量化的验收标准。答辩现场最容易被问倒的不是你用了什么技术而是你怎么证明它好用。提前准备一张指标表把主观感受变成可测的数字说服力完全不一样。验收项合格标准测量方法首屏可见时间4G 网络下 ≤ 3 秒真机录屏逐帧数从点击进入到模型完整出现交互帧率中端安卓机稳定 ≥ 30fps开发者工具性能面板 真机帧率悬浮窗手势响应延迟拖动后 ≤ 100ms 画面跟手录屏慢放对比手指位移与画面变化的时间差内存稳定性连续进出页面 20 次无增长趋势真机内存面板记录每次退出后的占用弱网可用性3G 模拟下超时后有降级提示与重试开发者工具切换网络为 3G观察兜底逻辑模型兼容性3 个不同来源的 GLB 均能正确居中显示换模型不改代码直接替换文件测试测帧率有个小技巧别只看平均值。手指静止时帧率虚高要测的是持续旋转过程中的最低帧率那个数字才是用户真实感受。我一般让模型匀速自转 30 秒记录期间的最低值。演示脚本也提前写好按四步走控制在三分钟内第一步正常打开展示模型完整入镜和自动居中第二步单指旋转到背面展示双面材质和光照过渡第三步双指放大到接口细节证明纹理清晰度够用第四步故意断网重进展示超时兜底和重试按钮。第四步是加分项绝大多数演示只演顺利路径把异常路径也演出来说明你想过边界情况。答辩提问高频集中在三个方向为什么不用原生方案用 2.1 的对比表回答、模型太大怎么办用 3.2 的三道关回答、多模型怎么管理用 3.1 的分层回答。这三个问题提前把答案压缩成三句话现场就不会卡壳。从那以后我每次做这类展示方案都会在写第一行业务代码之前先把验收指标表填完哪怕只是自己拍脑袋定的数字——没有这张表做到后面很容易陷入感觉还行的自我麻痹等到真机上被低端机教做人返工成本比前期定标准高得多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?