简介这份资源面向希望在微信小程序中实现3D展示效果的开发者尤其适合已掌握JavaScript基础、想进一步探索three.js与WebGL结合应用的中级前端人员。它围绕小程序加载外部3D模型这一技术难点提供可运行的工程示例帮助读者理解场景初始化、模型加载与渲染循环等关键环节。压缩包共11个文件约167KB以4个js脚本和4个json配置为主辅以wxml模板、wxss样式与一份说明文档分别承担逻辑实现、页面结构与项目配置等职责目录按小程序标准结构组织便于直接导入开发者工具对照学习。目前已有3207人学习下载说明该主题在小程序开发群体中具有较高关注度。读者可从中获得three.js在小程序环境下的接入思路、外部模型加载的完整代码骨架以及动画循环与资源释放的实践参考适合作为动手实验的起点。1. 从一张“模型加载不出来”的截图说起去年帮一个做家居展示的朋友看小程序他发来一张截图页面白屏控制台一行红字模型文件 404。他以为是小程序不支持 3D准备推翻重做。我让他把miniprogramThree这个工程跑起来十分钟后模型转起来了——问题不在框架在于他把模型文件放错了目录且没走小程序的本地路径规则。这个项目干的事很具体在微信小程序里加载外部 3D 模型并渲染交互技术栈是 JavaScript Three.js 的小程序适配层。适合两类人一是想在电商、展陈、教育类小程序里塞进一个可旋转查看的 3D 物件的前端二是手里已有 Three.js 经验、想把它搬进小程序环境却卡在 canvas 和文件系统上的开发者。下面按“它怎么组织、怎么跑、怎么改”拆开讲。2. 工程结构与渲染链路一个模型从文件到屏幕要过几道关2.1 目录分层与各层职责这个工程的目录不复杂但分层意图很清楚照着这个骨架去读代码不会迷路。常见做法是下面这种结构具体文件名可能略有出入职责划分是一致的miniprogramThree/ ├── app.js # 小程序入口全局初始化 ├── app.json # 页面注册、窗口配置 ├── project.config.json # 开发者工具项目配置 ├── pages/ │ └── index/ │ ├── index.js # 页面逻辑创建 canvas、驱动渲染循环 │ ├── index.wxml # 只放一个 canvas 节点 │ └── index.wxss # canvas 尺寸与容器样式 ├── libs/ │ ├── three.weapp.js # Three.js 的小程序适配版本 │ └── OrbitControls.js # 触摸旋转/缩放控制器 ├── models/ │ └── demo.glb # 外部 3D 模型文件 └── utils/ └── loader.js # 模型加载与路径处理封装libs放的是运行时依赖models放的是资源pages只负责生命周期和渲染驱动utils把“读文件 解析模型”这类脏活包起来。这种分法最大的好处是换模型只动models换交互只动libs里的控制器页面逻辑基本不用碰。新手最容易犯的错是把 Three.js 的初始化代码全堆在index.js里几百行下去改一个光照参数都要翻半天。2.2 小程序 canvas 与 WebGL 上下文的差异这是整个项目最核心的一道坎。浏览器里new THREE.WebGLRenderer()直接拿document.createElement(canvas)小程序没有 DOM必须用wx.createSelectorQuery()拿到 canvas 节点再通过canvas.getContext(webgl)把上下文喂给渲染器。适配层three.weapp.js做的就是这件事它把 Three.js 内部对document、window的引用替换成小程序能提供的对象。// pages/index/index.js —— 创建 canvas 并绑定 WebGL 上下文 Page({ onReady() { wx.createSelectorQuery() .select(#webgl) // 对应 wxml 里 canvas 的 id .node() .exec((res) { const canvas res[0].node; // 关键小程序 canvas 需要手动设置像素比否则模型发虚 const dpr wx.getSystemInfoSync().pixelRatio; canvas.width res[0].width * dpr; canvas.height res[0].height * dpr; const renderer new THREE.WebGLRenderer({ canvas, // 直接传入节点不走 createElement antialias: true, alpha: true }); renderer.setPixelRatio(dpr); renderer.setSize(res[0].width, res[0].height); this.initScene(renderer); // 进入场景搭建 }); } });这段代码里三个参数值得盯住canvas必须传节点实例而不是选择器字符串setPixelRatio要和canvas.width的放大倍数一致否则会出现“画面清晰但模型被裁切”的怪现象alpha: true决定背景是否透明做商品悬浮展示时通常要开。res[0].width是 CSS 像素尺寸乘dpr才是物理像素这一步漏了在高分屏上模型边缘会有明显锯齿。2.3 场景、相机、光照的最小闭环拿到 renderer 之后场景三件套缺一不可。相机决定你从哪个角度看模型光照决定模型有没有立体感场景是容器。这个项目里相机一般用透视相机视场角 45 到 60 度之间位置根据模型包围盒动态算避免模型太大或太小。// 场景初始化相机位置按模型尺寸自适应 initScene(renderer) { const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera( 45, // 视场角越大越广角 canvasWidth / canvasHeight, // 宽高比必须和 canvas 一致 0.1, // 近裁剪面 1000 // 远裁剪面 ); camera.position.set(0, 0, 5); // 环境光打底 平行光塑造明暗纯环境光会让模型像纸片 scene.add(new THREE.AmbientLight(0xffffff, 0.6)); const dirLight new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(5, 10, 7); scene.add(dirLight); this.scene scene; this.camera camera; this.renderer renderer; }AmbientLight的强度 0.6 加DirectionalLight的 0.8 是常见配比前者保证暗部不死黑后者给出方向感。如果模型是金属材质还得补一个HemisphereLight或者环境贴图否则反射一片灰。相机位置(0, 0, 5)只是占位真正加载模型后要按包围盒重新计算这一点在 2.4 里说。2.4 模型加载与自适应缩放外部模型通过GLTFLoader读进来小程序里读文件走wx.getFileSystemManager()把 ArrayBuffer 交给 loader 的parse方法而不是浏览器的load(url)。这是适配层和原生 Three.js 用法差别最大的地方。// utils/loader.js —— 从小程序文件系统读取 glb 并解析 const fs wx.getFileSystemManager(); function loadModel(path, onLoad) { fs.readFile({ filePath: path, // 例如 models/demo.glb success(res) { const loader new THREE.GLTFLoader(); // 小程序没有 URL 加载能力用 parse 直接吃 ArrayBuffer loader.parse(res.data, , (gltf) { const model gltf.scene; // 按包围盒把模型缩放到统一视觉尺寸 const box new THREE.Box3().setFromObject(model); const size box.getSize(new THREE.Vector3()); const maxDim Math.max(size.x, size.y, size.z); const scale 2 / maxDim; // 目标尺寸 2 个单位 model.scale.setScalar(scale); // 居中把模型几何中心挪到原点 const center box.getCenter(new THREE.Vector3()); model.position.sub(center.multiplyScalar(scale)); onLoad(model); }); }, fail(err) { console.error(模型读取失败, err); } }); }parse的第二个参数是资源根路径glb 内嵌纹理时传空字符串即可如果是 gltf 外挂贴图这里要传贴图所在目录。缩放逻辑用2 / maxDim把最长边压到 2 个单位这样不同来源的模型在同一个相机下视觉大小一致省去每个模型单独调相机的麻烦。居中那一步很多人会漏结果模型偏在画面一角还以为是相机问题。3. 跑起来从开发者工具到真机预览的完整步骤3.1 环境准备与项目导入先把基础环境对齐不然报错会把你带偏。需要微信开发者工具稳定版基础库版本建议 2.16 以上低版本对 canvas 2d 和 WebGL 支持不全。导入项目时选择工程根目录project.config.json里的appid填自己的测试号即可没有 AppID 也能用测试模式跑只是真机预览受限。导入后先别急着点编译检查两处app.json里pages是否注册了pages/index/indexproject.config.json里setting.urlCheck是否关掉本地模型不走网络请求但适配层可能触发校验。这两处不对编译能过但页面空白排查起来很费时间。3.2 模型文件的放置与路径规则模型必须放在小程序包内路径用相对根目录的写法比如models/demo.glb。这里有个血泪经验wx.getFileSystemManager().readFile读的是代码包内文件路径不能带前导斜杠也不能用../跳出包外。很多人从浏览器项目迁过来习惯写./models/demo.glb或者绝对路径直接 404。另外注意包体积限制主包 2MB、总包 20MB不同时期规则略有调整以工具提示为准。glb 模型动辄几 MB超了要么压缩模型要么放分包。常见做法是用 Draco 压缩几何体但小程序端解压需要额外 wasm配置麻烦简单项目更推荐直接减面、减贴图分辨率。3.3 渲染循环与触摸交互接入模型加载完要持续渲染小程序里不能用requestAnimationFrame的浏览器版本得用 canvas 节点自带的requestAnimationFrame。交互则靠OrbitControls接管触摸事件但它的默认事件绑定是 DOM 的适配层里已经改成监听 canvas 的 touch 事件。// 启动渲染循环 绑定控制器 startRender(model) { this.scene.add(model); const controls new OrbitControls(this.camera, this.renderer.domElement); controls.enableDamping true; // 惯性阻尼转动更跟手 controls.dampingFactor 0.08; controls.enablePan false; // 商品展示通常禁掉平移 controls.minDistance 2; controls.maxDistance 10; const canvas this.renderer.domElement; const loop () { controls.update(); // 阻尼开启后必须每帧 update this.renderer.render(this.scene, this.camera); canvas.requestAnimationFrame(loop); }; loop(); }enableDamping打开后如果忘了每帧controls.update()模型会转一下就僵住这是最高频的翻车点。minDistance和maxDistance限制缩放范围防止用户把相机怼进模型内部看到穿模。enablePan false是产品层面的取舍展示类场景平移只会让用户把模型拖出视野。3.4 真机预览与性能观察开发者工具里跑通不代表真机没问题。点真机预览后重点看三件事首帧时间模型多大、解析多久、滑动帧率复杂模型在低端机上会掉到 20 帧以下、内存占用连续切换模型可能触发回收。工具的性能面板能看 JS 耗时但 GPU 层面得靠真机手感判断。如果真机白屏而工具正常八成是模型路径在真机上大小写敏感或者基础库版本差异导致canvas.getContext(webgl)返回 null。前者改文件名后者在app.json里锁定lazyCodeLoading: requiredComponents并确认基础库下限。4. 避坑与排查五个真实踩过的坑4.1 模型加载成功但画面全黑现象控制台无报错onLoad回调也进了但 canvas 一片黑。原因通常是相机位置和模型尺寸不匹配——模型被缩放到 2 个单位相机却在z 100的位置模型小成一个点或者光照全在模型背面。解决加载后打印box.getSize()确认相机z值在模型尺寸的 2 到 3 倍之间把AmbientLight强度临时调到 1.0 排除光照因素。4.2 触摸旋转无响应现象模型能显示手指滑动没反应。原因多为OrbitControls绑定的事件目标不对——适配层如果绑到了document而小程序没有事件永远不触发。解决确认控制器构造时第二个参数传的是renderer.domElement即 canvas 节点并在 wxml 的 canvas 上加上disable-scrolltrue避免页面滚动抢走触摸事件。4.3 高分屏模型边缘锯齿严重现象工具里看着还行真机上模型轮廓毛糙。原因是canvas.width没乘pixelRatio或者renderer.setPixelRatio和 canvas 实际像素不一致。解决两处都用同一个dpr值且setSize传 CSS 尺寸、setPixelRatio传 dpr让 Three.js 自己算物理像素不要手动改canvas.width后又调setSize覆盖掉。4.4 切换页面后返回模型消失或报上下文丢失现象从详情页返回首页canvas 空白控制台提示 WebGL context lost。原因是页面onHide时渲染循环没停或者 canvas 节点被回收后旧引用还在。解决在onHide里置一个running标志位让循环退出onShow时重新走一遍 canvas 查询和 renderer 创建不要复用已销毁的 renderer 实例。4.5 模型文件超包体积上传失败现象开发者工具能跑上传代码包时报体积超限。原因是 glb 里塞了 4K 贴图或高面数网格。解决先用工具查看模型面数和贴图尺寸贴图压到 1024 以内、面数控制在 5 万以下仍超限就把模型放分包页面里用wx.loadSubpackage预加载后再读文件。别指望运行时从网络拉模型小程序对动态资源加载限制多本地包内读取最稳。5. 二次开发换模型、加交互、做验证的实操思路拿到这个工程后最常见的改造是“换成自己的模型并加上点击选中”。换模型本身简单把 glb 丢进models改loader.js里的路径即可但要注意模型坐标系——有些建模软件导出时 Y 轴朝上有些 Z 轴朝上加载后模型可能是躺着的。我一般会在加载回调里加一个朝向校正参数按需绕 X 轴转 -90 度而不是去改模型源文件这样换模型时只调一个开关。加点击选中需要射线检测。小程序 canvas 的触摸坐标是相对页面的要先换算成 canvas 内的归一化坐标再交给Raycaster// 点击拾取把触摸点换算成 NDC 坐标后做射线检测 onCanvasTap(e) { const touch e.touches[0]; const rect this.canvasRect; // 提前用 boundingClientRect 缓存 const x ((touch.clientX - rect.left) / rect.width) * 2 - 1; const y -((touch.clientY - rect.top) / rect.height) * 2 1; const raycaster new THREE.Raycaster(); raycaster.setFromCamera(new THREE.Vector2(x, y), this.camera); const hits raycaster.intersectObjects(this.scene.children, true); if (hits.length 0) { // 命中的第一个物体向上找到带业务标记的父节点 let obj hits[0].object; while (obj !obj.userData.partId) obj obj.parent; if (obj) this.highlightPart(obj); } }intersectObjects第二个参数true表示递归子节点模型拆件展示必须开否则只能点到最外层空组。userData.partId是我习惯的标记方式建模时给每个部件挂业务 ID点击后直接拿到语义信息比按 mesh 名字匹配可靠。高亮实现用替换材质或加描边注意别直接改原材质否则取消选中时恢复不回去。验证改造是否成功我有一套固定动作先在工具里用不同尺寸的模型各跑一遍看自适应缩放是否都居中再真机滑动十秒看帧率是否稳定最后连续进出页面五次确认没有上下文丢失。这套流程走下来基本能挡住八成回归问题。从那以后我每次接 3D 小程序的需求都强制先把模型面数和贴图尺寸过一遍再动手写代码省下的排查时间远比压缩模型花的多。希望帮到你。本文还有配套的精品资源点击获取