微信小游戏 + Three.js 环境搭建实战(完整流程与踩坑记录)

0 阅读20分钟

写在前面

在 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.js1. 访问 Node.js 官方网站:nodejs.org/zh-cn/ 2. 点击LTS版本的按钮,下载对应你操作系统的安装包(Windows选择.msi,macOS选择.pkg)
安装Node.js1. 双击下载好的安装包,按照提示一步步安装(基本上一直点"下一步"即可) 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 definedweapp-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 内容生成的实际案例
  • 技术美术视角下的跨平台方案设计

如果你也对这几个方向的交叉感兴趣,欢迎一起交流。技术的魅力,从来不止于掌握它,而在于用它连接不同的领域、不同的思维、不同的可能性。 这条路还在延伸,我们都在路上。