写在前面
在 UE 和 Three.js 之间反复横跳,是我这几年的工作常态。
一边是影视级渲染的数字孪生城市,一边是轻巧快捷的 Web 可视化看板。两个世界各有各的哲学,也各有各的舒适区。但时间久了,我越来越意识到:真正的挑战不是掌握更多工具,而是让同一个美术思维,在不同平台上找到最合适的表达方式。
选择微信小游戏作为起点,不是因为它本身有多重要,而是它像一个天然的“压力测试场”——环境受限,能力有限,但用户触达极广。在这里,很多常规方法会失效,迫使你去理解 Three.js 更深层的机制。而这些理解,最终会反过来让常规项目做得更好。
更有意思的是,沿着这条与日常工作关系不大的路径走下去,反而常常收获意料之外的思路。那些在常规项目中习以为常的假设,在陌生环境里被打破后,往往能长出新的解决方案。这大概就是跨界探索的魅力——它不直接解决眼下的问题,却能让你换一种方式思考问题本身。
AI 时代,技术迭代的速度比以往任何时候都快。昨天还流行的框架,今天可能就有了更优的替代方案。但无论工具如何更迭,连接不同领域、探索未知路径的价值,始终不会褪色。反而,在快速变化的环境中,这种跨界整合的能力,正变得越来越重要。
这个系列我会把一路上的思考、方案和踩坑记录都摊开。当然,小游戏只是一个开始,后续还会有更多关于 UE、Three.js 以及其他方向的探索——希望能和大家一起,在技术的交叉地带找到更多有趣的东西。
如果你是从前端转向 3D 开发的伙伴,希望这些内容能帮你少走弯路;如果你来自 CG 美术行业、想了解程序思维,希望能为你打开一扇门。
1. 概述
微信小游戏环境与浏览器不同,缺少 window、document 等对象,因此无法直接运行标准的 Three.js。本方案通过引入社区适配器 weapp-adapter 和专为小程序优化的 threejs-miniprogram 库,让 Three.js 能够在小游戏环境中流畅运行。
核心组件:
- weapp-adapter:模拟浏览器基础 API(如 document.createElement)。
- threejs-miniprogram:基于 Three.js 核心的适配版本,提供 createScopedThreejs 方法绑定 canvas。
- 微信开发者工具:用于预览、调试和上传。
2. 环境准备
| 工具 | 说明 | 下载链接 |
|---|---|---|
| 微信开发者工具 | 官方 IDE,必须安装 | developers.weixin.qq.com/miniprogram… |
| VSCode | 代码编辑器(可选,但推荐) | code.visualstudio.com/ |
| Node.js | 用于安装 npm 包 | nodejs.org/zh-cn |
3. 创建小游戏项目
| 步骤 | 操作说明 |
|---|---|
| 1 | 打开微信开发者工具,点击 “新建项目”。 |
| 2 | 填写项目信息: - 项目名称:例如 MyThreeGame - 目录:选择一个空文件夹(例如 D:/MyThreeGame) - AppID:可使用测试号(点击“测试号”自动生成),也可填入正式 AppID - 后端服务:选择 “不使用云服务”(简化项目) - 模板选择:选择 “小游戏” → “小游戏”(默认的纯 JavaScript 模板) |
| 3 | 点击 “创建”,工具会自动生成一个飞行射击游戏示例。 |
4. 清理默认模板
我们只需要保留项目的基本配置文件,删除示例代码和资源。
| 保留的文件/文件夹 | 说明 |
|---|---|
| game.js | 入口文件 |
| game.json | 配置文件 |
| project.config.json | 项目配置 |
| project.private.config.json | (如有)私有配置 |
| js/ 文件夹 | 保留,但清空内容 |
| 删除的内容 | 说明 |
|---|---|
| audio/ | 整个文件夹(示例音频) |
| images/ | 整个文件夹(示例图片) |
| js/ 下除了 libs/ 以外的所有文件和子文件夹 | 如 base/, npc/, player/, runtime/, databus.js, main.js, render.js 等 |
| .eslintrc.js | 可选删除 |
| README.md | 可选删除 |
清理后的目录结构应如下:
5. 引入 weapp-adapter(社区版)
微信小游戏环境需要适配层来模拟浏览器 API,这里使用社区维护的 finscn/weapp-adapter,它比官方版对 Three.js 更友好。
| 步骤 | 操作说明 |
|---|---|
| 1 | 访问 finscn/weapp-adapter 仓库,下载 ZIP 或使用 git clone。 |
| 2 | 将仓库中的 src 文件夹内的 所有文件 复制到你的项目 js/weapp-adapter/ 目录下。 即在 js/ 下新建 weapp-adapter 文件夹,把 index.js 和其他辅助文件放进去。 |
| 3 | 确保 js/weapp-adapter/index.js 存在。 |
此时目录结构:
6. 安装 threejs-miniprogram 并构建 npm
这一节我们会用到 npm——它是Node.js的包管理工具,可以理解成"应用的商店",用来下载和管理项目所需的第三方代码库。npm会随着Node.js一起安装,所以我们先要安装Node.js。
6.1 如果你还没有安装Node.js和npm
| 步骤 | 操作说明 |
|---|---|
| 下载Node.js | 1. 访问 Node.js 官方网站:nodejs.org/zh-cn/ 2. 点击LTS版本的按钮,下载对应你操作系统的安装包(Windows选择.msi,macOS选择.pkg) |
| 安装Node.js | 1. 双击下载好的安装包,按照提示一步步安装(基本上一直点"下一步"即可) 2. 安装路径建议使用默认的英文路径,不要包含中文或空格 3. 安装完成后,npm就已经自动装好了 |
| 验证安装 | 1. 打开命令行工具: - Windows用户:按 Win + R,输入 cmd,回车 - macOS用户:打开"终端"(Terminal) 2. 输入以下命令查看版本: bash node -v npm -v 3. 如果能看到版本号(例如 v18.17.0 和 9.6.7),说明安装成功 |
💡 小提示:如果提示"node不是内部或外部命令",说明安装过程中没有自动配置环境变量,可以重启命令行试试,或者重新安装Node.js并确保勾选"Add to PATH"选项。
6.2 在项目中使用npm
安装好Node.js和npm后,我们就可以用npm来安装threejs-miniprogram了。
| 步骤 | 操作说明 | |
|---|---|---|
| 1. 打开命令行 | 有多种方式: - 在项目文件夹中,按住 Shift 键点击鼠标右键,选择 "在此处打开 PowerShell 窗口"(Windows)或 "新建终端"(macOS) - 使用 VSCode:在项目文件夹上右键 -> "通过 Code 打开",然后在 VSCode 中打开集成终端(快捷键 Ctrl+`) - 使用微信开发者工具:工具自带的 "终端" 面板也可以执行 npm 命令(位于底部工具栏) | |
| 2. 初始化npm项目 | 执行:npm init -y 这个命令会创建一个 package.json 文件,用来记录项目依赖的信息。 | |
| 3. 安装 threejs-miniprogram | 执行:npm install --save threejs-miniprogram npm会从远程仓库下载这个包到项目中的 node_modules 文件夹,并自动记录依赖关系。 | |
| 4. 在微信开发者工具中构建 npm | 打开微信开发者工具,点击顶部菜单 【工具】 → 【构建 npm】。 构建成功后,项目根目录会出现 miniprogram_npm/threejs-miniprogram/ 文件夹,里面包含 index.js 等文件。 |
⚠️ 注意:必须执行"构建 npm"这一步,否则运行时无法识别 import from 'threejs-miniprogram',会报模块未定义的错误。如果构建失败,可以尝试先点击"清除缓存"再重新构建。
!
6.3 补充说明
| 问题 | 说明 |
|---|---|
| 为什么要安装这个特定的库? | 标准的Three.js在微信小游戏环境会报错,因为小游戏没有window、document等浏览器对象。threejs-miniprogram是专门适配过的版本,解决了这些问题。 |
| 如果安装过程中遇到网络问题 | npm默认从国外服务器下载,有时可能较慢。可以配置淘宝镜像加速: npm config set registry registry.npmmirror.com |
| node_modules文件夹很大,需要提交到代码仓库吗? | 不需要。通常我们只提交package.json和package-lock.json,其他人在拉取代码后执行npm install即可自行安装依赖。 |
7. 编写入口文件 game.js
game.js是小游戏的唯一入口文件。我们只需引入适配器和主逻辑类。
// game.js
import './js/weapp-adapter/index.js';
import Main from './js/main-three.js';
new Main();
8. 编写主逻辑 main-three.js(最终成功版)
在 js/ 下创建 main-three.js,写入以下代码(直接复制即可)。这是经过实际验证、成功运行的版本。
// js/main-three.js
import { createScopedThreejs } from 'threejs-miniprogram';
export default class Main {
constructor() {
// 1. 获取 Canvas(微信小游戏环境专用)
// 优先使用 weapp-adapter 注入的 window.canvas,否则回退到 GameGlobal.screencanvas
const canvas = (typeof window !== 'undefined' && window.canvas) ? window.canvas : GameGlobal.screencanvas;
// 2. 创建 THREE 作用域(传入 canvas,适配器会自动处理上下文)
const THREE = createScopedThreejs(canvas);
// 3. 获取系统信息,用于适配屏幕尺寸
const systemInfo = wx.getSystemInfoSync();
const windowWidth = systemInfo.windowWidth;
const windowHeight = systemInfo.windowHeight;
const pixelRatio = Math.min(systemInfo.pixelRatio, 2); // 限制像素比,优化性能
// 4. 创建渲染器
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: false,
powerPreference: 'low-power'
});
renderer.setSize(windowWidth, windowHeight);
renderer.setPixelRatio(pixelRatio);
// 5. 创建场景(设置红色背景方便调试)
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xE22900); // 红色背景,确认渲染成功
// 6. 创建相机
const camera = new THREE.PerspectiveCamera(75, windowWidth / windowHeight, 0.1, 1000);
camera.position.set(2, 2, 5);
camera.lookAt(0, 0, 0);
// 7. 创建立方体
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x44aa88 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
// 8. 添加灯光
const ambientLight = new THREE.AmbientLight(0xFFFFFF);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(1, 2, 3);
scene.add(directionalLight);
// 9. 动画循环(使用 canvas.requestAnimationFrame 更稳定,并提供降级方案)
const animate = () => {
cube.rotation.x += 0.01;
cube.rotation.y += 0.02;
renderer.render(scene, camera);
if (canvas && canvas.requestAnimationFrame) {
canvas.requestAnimationFrame(animate);
} else {
requestAnimationFrame(animate);
}
};
animate();
}
}
代码逐块详解
为了让刚接触 Three.js 或微信小游戏开发的朋友(尤其是从 CG 美术背景转过来的)能清楚每一行在做什么,下面我们逐段解释。这部分内容为基础的环境搭建和测试立方体示例,如果对此已经熟悉或不关心具体细节,可以直接跳过看后续章节。
第1块:导入适配库
import { createScopedThreejs } from 'threejs-miniprogram';
- 作用:导入 threejs-miniprogram 提供的核心函数 createScopedThreejs。这个函数的作用是创建一个“作用域”,将 Three.js 的所有 API 绑定到我们传入的 canvas 上。在小游戏环境中,这是必须的,因为标准的 Three.js 会尝试使用浏览器全局对象,而这里没有。
第2块:获取 Canvas
const canvas = (typeof window !== 'undefined' && window.canvas) ? window.canvas : GameGlobal.screencanvas;
- 为什么这么写:在微信小游戏中,直接调用 wx.createCanvas() 会创建一个新的离屏 canvas,它可能不会显示在屏幕上,导致黑屏。而 weapp-adapter 已经在全局模拟了一个 window 对象,并在其上挂载了真正的屏幕 canvas(window.canvas)。所以我们优先取它;如果 window 不存在(极少数情况),则回退到微信底层提供的 GameGlobal.screencanvas。
- 一句话:拿到真正能显示到屏幕上的画布。
第3块:创建 THREE 作用域
const THREE = createScopedThreejs(canvas);
- 作用:调用导入的函数,传入 canvas,得到绑定好上下文的 THREE 对象。之后所有 Three.js 的 API(如 THREE.Scene)都从这个对象上获取,而不是从全局的 THREE(全局的本来也不存在)。这保证了渲染器使用的是我们传入的 canvas 的 WebGL 上下文。
第4块:获取系统信息并适配屏幕
const systemInfo = wx.getSystemInfoSync();
const windowWidth = systemInfo.windowWidth;
const windowHeight = systemInfo.windowHeight;
const pixelRatio = Math.min(systemInfo.pixelRatio, 2); // 限制像素比,优化性能
- wx.getSystemInfoSync():微信小游戏提供的同步 API,获取设备信息,包括屏幕宽高、像素比等。
- pixelRatio 限制为 2:手机屏幕像素比可能很高(例如 3 或 4),如果完全按照设备像素比渲染,GPU 压力会很大,导致卡顿或发热。限制最大为 2 可以在画质和性能间取得平衡(对于大多数游戏已足够)。
第5块:创建渲染器
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: false,
powerPreference: 'low-power'
});
renderer.setSize(windowWidth, windowHeight);
renderer.setPixelRatio(pixelRatio);
- canvas: canvas:显式指定使用我们刚才获取的画布,这样 Three.js 就不会自己创建新画布。
- antialias: false:移动端关闭抗锯齿以提升性能(如果开启可能导致某些设备上渲染模糊或变慢)。
- powerPreference: 'low-power':告诉浏览器(这里是适配层)优先使用低功耗 GPU,有利于省电和散热。
- setSize 和 setPixelRatio:设置渲染输出的尺寸和像素比,与屏幕匹配。
第6块:创建场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xE22900); // 红色背景
- 创建一个空场景,并将背景设为醒目的红色。这样如果立方体没显示,至少能看到红色,方便判断渲染循环是否在工作。
第7块:创建相机
const camera = new THREE.PerspectiveCamera(75, windowWidth / windowHeight, 0.1, 1000);
camera.position.set(2, 2, 5);
camera.lookAt(0, 0, 0);
- 透视相机:视野角 75 度,宽高比根据屏幕计算,近裁面 0.1,远裁面 1000。
- 将相机放在 (2,2,5) 位置,并看向原点 (0,0,0),这样立方体正好在视野中央。
第8块:创建立方体与材质
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x44aa88 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
- 创建一个边长为 1 的立方体几何体,使用标准材质,颜色为蓝绿色(0x44aa88)。将立方体添加到场景中。
第9块:添加灯光
const ambientLight = new THREE.AmbientLight(0xFFFFFF);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(1, 2, 3);
scene.add(directionalLight);
- 环境光:提供基础照明,避免背光面完全黑暗。
- 方向光:模拟太阳光,从 (1,2,3) 方向照射,产生立体感。
- 如果没有灯光,标准材质会是全黑的(看不见物体)。
第10块:动画循环
const animate = () => {
cube.rotation.x += 0.01;
cube.rotation.y += 0.02;
renderer.render(scene, camera);
if (canvas && canvas.requestAnimationFrame) {
canvas.requestAnimationFrame(animate);
} else {
requestAnimationFrame(animate);
}
};
animate();
- animate 函数:每一帧执行的操作——旋转立方体,渲染场景。
- canvas.requestAnimationFrame:微信小游戏环境建议使用 canvas 对象上的 requestAnimationFrame 方法(由适配器提供),它和渲染上下文绑定更紧密,能保证在正确的时机刷新。如果 canvas 上没有这个方法(极少数情况),则回退到全局的 requestAnimationFrame。
- 调用 animate() 启动循环。
9. 运行与调试
| 步骤 | 操作说明 |
|---|---|
| 1 | 在 VSCode 中保存所有文件。 |
| 2 | 切换到微信开发者工具并刷新。 |
| 3 | 如果一切正常,你将看到红色背景 + 一个旋转的立方体。 |
| 4 | 打开调试器(Console),确认没有报错,且能看到相关日志(如有添加)。 |
💡 如果仍黑屏,请打开 Console 查看错误信息,并参考第 11 节“常见问题排查”。
10. 真机预览
| 步骤 | 操作说明 |
|---|---|
| 1 | 在开发者工具中点击 预览,生成二维码。 |
| 2 | 用手机微信扫码,即可在真机上运行测试。 |
| 3 | 真机上打开 vConsole 查看日志,确认性能表现。 |
11. 调试与兼容性修复:一次借助AI的底层问题解决
环境搭建完成后,我在预览测试时遇到了两个棘手的报错:
- iOS真机崩溃:报错 TypeError: Cannot redefine property: style,发生在 createScopedThreejs 初始化阶段。
- 生命周期加载失败:报错 LifeCycle.load fail: Can't find variable: setTimeout,出现在部分iPhone上。
作为技术美术出身的我,我并不想钻到JavaScript引擎和WebGL底层的细节里,至少目前阶段还是希望将精力聚焦于视效和功能的实现上。但问题又必须解决。于是我借助AI辅助工具,快速分析报错堆栈、搜索社区类似案例,并最终形成了几个针对性的补丁。这里把改动思路整理出来,既是对自己工作的记录,也希望能帮到遇到类似问题的朋友——毕竟,我们不需要成为底层专家,但可以学会利用工具帮我们跨越这些坑。
11.1 Canvas获取的兜底逻辑
问题:小游戏里存在多个“canvas”概念。直接调用 wx.createCanvas() 可能创建一个离屏canvas,渲染上去的内容不会显示到屏幕上,导致黑屏。
解决:优先使用 weapp-adapter 注入的屏幕canvas(window.canvas 或 GameGlobal.screencanvas),如果都不存在,再回退到 wx.createCanvas()。这样确保我们始终操作的是真正显示在屏幕上的画布。
const canvas =
(typeof window !== 'undefined' && window.canvas) ? window.canvas :
(typeof GameGlobal !== 'undefined' && GameGlobal.screencanvas) ? GameGlobal.screencanvas :
(typeof wx !== 'undefined' && wx.createCanvas) ? wx.createCanvas() : null;
11.2 iOS上 Cannot redefine property: style 的规避
问题:threejs-miniprogram 在初始化时会对某些DOM-like对象调用 Object.defineProperty,而iOS上部分对象(如某些内置元素)的 style 属性已被定义为不可配置(configurable: false),再次定义就会导致崩溃。
解决:在调用 createScopedThreejs 之前,临时替换 Object.defineProperty 方法,加入兼容逻辑——如果目标对象不可扩展,或者要定义的属性是 style 且已存在且不可配置,则直接返回原对象,跳过定义。执行完初始化后立即恢复原生的 Object.defineProperty。这样补丁只作用于核心初始化阶段,不影响后续代码。
const __defineProperty = Object.defineProperty;
Object.defineProperty = function(obj, prop, descriptor) {
try {
if (obj && Object.isExtensible && !Object.isExtensible(obj)) return obj;
if (prop === 'style') {
const existed = Object.getOwnPropertyDescriptor(obj, prop);
if (existed && existed.configurable === false) return obj;
}
} catch (e) {}
return __defineProperty(obj, prop, descriptor);
};
let THREE;
try {
THREE = createScopedThreejs(canvas);
} finally {
Object.defineProperty = __defineProperty; // 立即恢复
}
11.3 setTimeout 未定义的兼容处理
问题:部分iPhone环境下,全局的 setTimeout / setInterval 在生命周期早期可能不可用,导致脚本加载失败。
解决:在 weapp-adapter 的 window.js 中加入兜底实现——如果原生timer存在,就用原生;否则用 requestAnimationFrame 模拟一个简易的定时器,并将这些兼容timer挂载到 GameGlobal 上,确保任何地方都能访问到。
// 优先使用原生,不存在则用 RAF 模拟
const _setTimeout = (typeof setTimeout === 'function') ? setTimeout : function(fn, delay) {
// ... 用 requestAnimationFrame 轮询实现
};
const _clearTimeout = /* 对应清理函数 */;
同时在index.js中将这些timer赋值给GameGlobal,补齐全局环境:
if (typeof global.setTimeout !== 'function') {
global.setTimeout = _window.setTimeout;
}
// 同理 setInterval, clearTimeout, clearInterval
11.4 附录:完整代码文件
以下是本文涉及的主要文件的完整代码文件,可直接复制到项目中使用。
js/main-three.js
import { createScopedThreejs } from 'threejs-miniprogram';
export default class Main {
constructor() {
// 1. 获取 Canvas(微信小游戏环境专用)
// 优先使用 weapp-adapter 注入的 window.canvas,否则回退到 GameGlobal.screencanvas
const canvas =
(typeof window !== 'undefined' && window.canvas)
? window.canvas
: (typeof GameGlobal !== 'undefined' && GameGlobal.screencanvas)
? GameGlobal.screencanvas
: (typeof wx !== 'undefined' && wx && wx.createCanvas)
? wx.createCanvas()
: null;
// 2. 创建 THREE 作用域(传入 canvas,适配器会自动处理上下文)
const __defineProperty = Object.defineProperty;
Object.defineProperty = function(obj, prop, descriptor) {
try {
if (obj && Object.isExtensible && Object.isExtensible(obj) === false) {
return obj;
}
if (prop === 'style') {
const existed = Object.getOwnPropertyDescriptor(obj, prop);
if (existed && existed.configurable === false) {
return obj;
}
}
} catch (e) {}
return __defineProperty(obj, prop, descriptor);
};
let THREE;
try {
THREE = createScopedThreejs(canvas);
} finally {
Object.defineProperty = __defineProperty;
}
// 3. 获取系统信息,用于适配屏幕尺寸
const systemInfo = wx.getSystemInfoSync();
const windowWidth = systemInfo.windowWidth;
const windowHeight = systemInfo.windowHeight;
const pixelRatio = Math.min(systemInfo.pixelRatio, 2); // 限制像素比,优化性能
// 4. 创建渲染器
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: false,
powerPreference: 'low-power'
});
renderer.setSize(windowWidth, windowHeight);
renderer.setPixelRatio(pixelRatio);
// 5. 创建场景(设置红色背景方便调试)
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xE22900); // 红色背景,确认渲染成功
// 6. 创建相机
const camera = new THREE.PerspectiveCamera(75, windowWidth / windowHeight, 0.1, 1000);
camera.position.set(2, 2, 5);
camera.lookAt(0, 0, 0);
// 7. 创建立方体
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x44aa88 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
// 8. 添加灯光
const ambientLight = new THREE.AmbientLight(0xFFFFFF);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(1, 2, 3);
scene.add(directionalLight);
// 9. 动画循环(使用 canvas.requestAnimationFrame 更稳定,并提供降级方案)
const animate = () => {
//先添加立方体动画
cube.rotation.x += 0.01;
cube.rotation.y += 0.02;
// 再更新渲染
renderer.render(scene, camera);
// 最后请求下一帧
if (canvas && canvas.requestAnimationFrame) {
canvas.requestAnimationFrame(animate);
} else {
requestAnimationFrame(animate);
}
}
animate();
}
}
js/weapp-adapter/window.js
import Canvas from './Canvas'
import CommonComputedStyle from './style/CommonComputedStyle'
import getImageComputedStyle from './style/ImageComputedStyle'
import getCanvasComputedStyle from './style/CanvasComputedStyle'
import Event from './Event'
export { default as navigator } from './navigator'
export { default as XMLHttpRequest } from './XMLHttpRequest'
export { default as WebSocket } from './WebSocket'
export { default as Worker } from './Worker'
export { default as Image } from './Image'
export { default as ImageBitmap } from './ImageBitmap'
export { default as Audio } from './Audio'
export { default as FileReader } from './FileReader'
export { default as Element } from './Element'
export { default as HTMLElement } from './HTMLElement'
export { default as HTMLImageElement } from './HTMLImageElement'
export { default as HTMLCanvasElement } from './HTMLCanvasElement'
export { default as HTMLMediaElement } from './HTMLMediaElement'
export { default as HTMLAudioElement } from './HTMLAudioElement'
export { default as HTMLVideoElement } from './HTMLVideoElement'
export { default as WebGLRenderingContext } from './WebGLRenderingContext'
export { TouchEvent, PointerEvent, MouseEvent } from './EventIniter/index.js'
export { default as localStorage } from './localStorage'
export { default as location } from './location'
export { btoa, atob } from './Base64.js'
export { default as Symbol } from './Symbol'
export * from './WindowProperties'
const { platform } = wx.getSystemInfoSync()
// 暴露全局的 canvas
GameGlobal.screencanvas = GameGlobal.screencanvas || new Canvas()
const canvas = GameGlobal.screencanvas;
function getComputedStyle(dom) {
const tagName = dom.tagName;
if (tagName === "CANVAS") {
return getCanvasComputedStyle(dom);
} else if (tagName === "IMG") {
return getImageComputedStyle(dom);
}
return CommonComputedStyle;
}
function scrollTo(x, y) {
// x = Math.min(window.innerWidth, Math.max(0, x));
// y = Math.min(window.innerHeight, Math.max(0, y));
// We can't scroll the page of WeChatTinyGame, so it'll always be 0.
// window.scrollX = 0;
// window.scrollY = 0;
}
function scrollBy(dx, dy) {
window.scrollTo(window.scrollX + dx, window.scrollY + dy);
}
function alert(msg) {
console.log(msg);
}
function focus() {}
function blur() {}
if (platform !== 'devtools') {
const wxPerf = wx.getPerformance ? wx.getPerformance() : Date;
const consoleTimers = {};
console.time = function(name) {
consoleTimers[name] = wxPerf.now();
};
console.timeEnd = function(name) {
const timeStart = consoleTimers[name];
if(!timeStart) {
return;
}
const timeElapsed = wxPerf.now() - timeStart;
console.log(name + ": " + timeElapsed / 1000 + "ms");
delete consoleTimers[name];
};
}
function eventHandlerFactory() {
return (res) => {
const event = new Event('resize')
event.target = window;
event.timeStamp = Date.now();
event.res = res;
event.windowWidth = res.windowWidth;
event.windowHeight = res.windowHeight;
document.dispatchEvent(event);
}
}
if (wx.onWindowResize) {
wx.onWindowResize(eventHandlerFactory())
}
const _requestAnimationFrame = requestAnimationFrame;
const _cancelAnimationFrame = cancelAnimationFrame;
// 兼容:某些运行容器下 setTimeout/setInterval 可能不可用。
// 这里用(canvas)requestAnimationFrame 做一个最小兜底,让依赖定时器的逻辑不至于直接崩溃。
const __raf = (cb) => {
if (canvas && canvas.requestAnimationFrame) {
return canvas.requestAnimationFrame(cb)
}
if (typeof requestAnimationFrame === 'function') {
return requestAnimationFrame(cb)
}
return cb()
}
// 内部 timer 状态,挂在 GameGlobal 上,避免多份 adapter 重复加载时丢状态。
GameGlobal.__weappAdapterTimerId = GameGlobal.__weappAdapterTimerId || 0
GameGlobal.__weappAdapterTimerMap = GameGlobal.__weappAdapterTimerMap || {}
// 优先使用原生 setTimeout;缺失时使用 RAF 轮询模拟。
const _setTimeout = (typeof setTimeout === 'function') ? setTimeout : function(fn, delay = 0) {
const id = ++GameGlobal.__weappAdapterTimerId
const start = Date.now()
let cancelled = false
GameGlobal.__weappAdapterTimerMap[id] = () => {
cancelled = true
}
const loop = () => {
if (cancelled) return
if (Date.now() - start >= delay) {
fn()
delete GameGlobal.__weappAdapterTimerMap[id]
return
}
__raf(loop)
}
__raf(loop)
return id
};
// 与 _setTimeout 配套的清理方法。
const _clearTimeout = (typeof clearTimeout === 'function') ? clearTimeout : function(id) {
const cancel = GameGlobal.__weappAdapterTimerMap[id]
if (cancel) {
cancel()
delete GameGlobal.__weappAdapterTimerMap[id]
}
};
// 优先使用原生 setInterval;缺失时基于 _setTimeout 递归模拟。
const _setInterval = (typeof setInterval === 'function') ? setInterval : function(fn, interval = 0) {
const id = ++GameGlobal.__weappAdapterTimerId
let cancelled = false
GameGlobal.__weappAdapterTimerMap[id] = () => {
cancelled = true
}
const tick = () => {
if (cancelled) return
fn()
_setTimeout(tick, interval)
}
_setTimeout(tick, interval)
return id
};
// 与 _setInterval 配套的清理方法。
const _clearInterval = (typeof clearInterval === 'function') ? clearInterval : function(id) {
_clearTimeout(id)
};
export {
canvas,
alert,
focus,
blur,
getComputedStyle,
scrollTo,
scrollBy,
_setTimeout as setTimeout,
_clearTimeout as clearTimeout,
_setInterval as setInterval,
_clearInterval as clearInterval,
_requestAnimationFrame as requestAnimationFrame,
_cancelAnimationFrame as cancelAnimationFrame
}
js/weapp-adapter/index.js
import * as _window from './window'
import document from './document'
const global = GameGlobal
GameGlobal.global = GameGlobal.global || global
// 兼容:部分真机环境会出现全局 setTimeout 不存在(或生命周期阶段访问不到)的报错。
// 这里将 window.js 里导出的定时器方法挂到 GameGlobal 上,供外部/引擎侧使用。
if (typeof global.setTimeout !== 'function' && typeof _window.setTimeout === 'function') {
global.setTimeout = _window.setTimeout
}
if (typeof global.clearTimeout !== 'function' && typeof _window.clearTimeout === 'function') {
global.clearTimeout = _window.clearTimeout
}
if (typeof global.setInterval !== 'function' && typeof _window.setInterval === 'function') {
global.setInterval = _window.setInterval
}
if (typeof global.clearInterval !== 'function' && typeof _window.clearInterval === 'function') {
global.clearInterval = _window.clearInterval
}
function inject() {
_window.document = document;
_window.addEventListener = (type, listener) => {
_window.document.addEventListener(type, listener)
}
_window.removeEventListener = (type, listener) => {
_window.document.removeEventListener(type, listener)
}
_window.dispatchEvent = function(event = {}) {
// nothing to do
}
const { platform } = wx.getSystemInfoSync()
// 开发者工具无法重定义 window
if (typeof __devtoolssubcontext === 'undefined' && platform === 'devtools') {
for (const key in _window) {
const descriptor = Object.getOwnPropertyDescriptor(global, key)
if (!descriptor || descriptor.configurable === true) {
Object.defineProperty(window, key, {
value: _window[key]
})
}
}
for (const key in _window.document) {
const descriptor = Object.getOwnPropertyDescriptor(global.document, key)
if (!descriptor || descriptor.configurable === true) {
Object.defineProperty(global.document, key, {
value: _window.document[key]
})
}
}
window.parent = window
window.wx = wx
} else {
_window.wx = wx;
for (const key in _window) {
global[key] = _window[key]
}
global.window = global
global.top = global.parent = global
}
}
if (!GameGlobal.__isAdapterInjected) {
GameGlobal.__isAdapterInjected = true
inject()
}
11.5 关于AI辅助的思考
这次问题的定位和修复,很大程度上依赖了AI工具的帮助。我把报错信息粘贴进去,经过多轮反馈和调试,AI帮我逐步定位问题根源,并给出了社区验证过的解决方案。我不需要深究iOS上 Object.defineProperty 的内部机制,也不需要记住 setTimeout 在所有环境下的行为——工具帮我做了这些。
对于技术美术而言,这种工作方式很舒服:我们专注于美术效果的实现和跨平台方案的整合,底层兼容性问题交给AI和社区经验去解决。当然,理解问题的大致方向和修复思路是必要的,但不必陷入每一行代码的细节。
如果你在开发中也遇到类似报错,不妨试试用AI辅助分析,往往能快速找到答案。这并非投机取巧,而是现代开发中合理利用工具的方式。
12. 常见问题排查
| 问题 | 可能原因 | 解决方法 |
|---|---|---|
| 编译时报错 module 'miniprogram_npm/...' is not defined | 没有执行“构建 npm”或构建失败 | 重新执行 【工具】→【构建 npm】,确保 miniprogram_npm 目录生成。 |
| 报错 wx is not defined | weapp-adapter 未正确引入或路径错误 | 检查 game.js 第一行 import './js/weapp-adapter/index.js' 路径是否正确,确保文件存在。 |
| 黑屏,但无报错 | Canvas 获取错误,或 requestAnimationFrame 未绑定到正确 Canvas | 使用最终版代码中获取 Canvas 的方式(优先 window.canvas),并改用 canvas.requestAnimationFrame。 |
| 真机上性能卡顿 | 像素比过高或抗锯齿开启 | 降低 pixelRatio 限制(如设为 2),关闭 antialias,减少几何体面数。 |
| 开发者工具模拟器正常,真机黑屏 | 真机 WebGL 版本差异或 Canvas 上下文创建失败 | 在真机调试模式下打开 vConsole,查看是否有 WebGL 相关错误日志。 |
13. 结语与展望
至此,你已经成功在微信小游戏中跑通了 Three.js 3D 场景。这只是一个开始——小游戏作为“压力测试场”,帮助我们理解了 Three.js 在受限环境下的底层机制,而这些经验会反哺到更广泛的 Web 3D 项目中。 在后续的分享中,我会继续探讨:
- UE 与 Three.js 的资产管线整合
- 数据可视化在不同终端的适配策略
- AI 辅助 3D 内容生成的实际案例
- 技术美术视角下的跨平台方案设计
如果你也对这几个方向的交叉感兴趣,欢迎一起交流。技术的魅力,从来不止于掌握它,而在于用它连接不同的领域、不同的思维、不同的可能性。 这条路还在延伸,我们都在路上。