【征文计划】从零开始XR开发:基于Rokid空间计算平台打造《光之岛》3D游戏

一场关于Web 3D游戏开发的完整旅程,从构思到实现,探索空间计算时代的游戏开发新范式

引言:为什么选择Web 3D开发?

在XR(扩展现实)技术蓬勃发展的今天,空间计算正在改变我们与数字世界的交互方式。作为一名开发者,我一直在思考:如何以最低的门槛进入这个激动人心的领域?答案是------从Web 3D开发开始。

Web技术栈具有天然的优势:无需下载安装跨平台运行、快速迭代、易于分享。更重要的是,掌握了Web 3D开发的核心技能后,迁移到专业XR平台(Rokid空间计算平台)只需要了解平台特定的API即可。这就像学会了做菜的基本功,换个厨房依然能烹饪美味。

本文将带你完整经历《光之岛》这款3D收集游戏的开发过程,从技术选型到架构设计,从基础搭建到核心玩法实现,每一步都会详细剖析背后的原理和决策依据。

下面为演示视频:

https://ai.feishu.cn/wiki/FrxUwh8Uoijt7IkyJyLchibAnjf?fromScene=spaceOverview#share-IS1HdSij3oDdDExSuvtcanWynHg

一、项目构思:从想法到设计

1.1 游戏创意的诞生

《光之岛》的灵感来源于经典的3D平台跳跃游戏。我希望创造一个简单却有趣的游戏体验:玩家控制角色在浮空岛屿上自由移动,收集散落各处的发光水晶,在限定时间内完成挑战。

这个设计看似简单,实则包含了3D游戏开发的核心要素:

  • 3D场景渲染:构建视觉世界
  • 物理模拟:真实的重力和碰撞
  • 角色控制:流畅的移动和跳跃
  • 游戏逻辑:计时、计分、胜负判定
  • 用户交互:键盘输入和UI反馈

1.2 技术选型的思考

在开始编码前,技术选型是最关键的决策。我选择了以下技术栈:

Vite - 下一代前端构建工具

  • 极速的热模块替换(HMR),修改代码后几乎瞬间看到效果
  • 基于ESM的开发服务器,无需打包即可运行
  • 优化的生产构建,自动代码分割和资源优化

TypeScript - JavaScript的超集

  • 强类型系统在大型项目中尤为重要,能在编码阶段发现90%的错误
  • 优秀的IDE支持,代码提示和重构功能大幅提升开发效率
  • 接口和类型定义让代码自解释,减少文档负担

Three.js - WebGL封装库

  • 降低了WebGL的复杂度,用面向对象的方式操作3D图形
  • 丰富的几何体、材质、光照系统,开箱即用
  • 活跃的社区和完善的文档,遇到问题容易找到解决方案

Cannon-es - 物理引擎

  • 轻量级(~150KB),适合Web环境
  • 提供刚体动力学、碰撞检测、约束系统等完整功能
  • 与Three.js配合使用的案例丰富,集成简单

这套组合既保证了开发效率,又能实现复杂的3D交互效果,是Web 3D游戏开发的最佳实践之一。

二、环境搭建:打好基础很重要

2.1 项目初始化

首先创建Vite项目,这个过程非常简单:

npm create vite@latest isle-of-light -- --template vanilla-ts

cd isle-of-light

npm install

Vite的初始化速度极快,几秒钟就能完成。vanilla-ts模板提供了最纯粹的TypeScript环境,没有多余的框架依赖,非常适合3D应用开发。

2.2 安装核心依赖

plain 复制代码
npm install three cannon-es
plain 复制代码
npm install --save-dev @types/three

Three.js提供了完整的TypeScript类型定义,这让开发体验非常好。Cannon-es本身就是用TypeScript编写的,类型支持开箱即用。

2.3 项目结构设计

优秀的项目结构是可维护性的基石。我采用了职责分离的设计原则:

src/

├── components/ # 游戏实体组件

│ ├── Ground.ts # 地面:静态场景元素

│ ├── Player.ts # 玩家:动态角色实体

│ └── Crystal.ts # 水晶:可收集物品

├── controls/ # 输入控制模块

│ └── PlayerControls.ts

├── physics/ # 物理引擎封装

│ └── PhysicsWorld.ts

├── utils/ # 工具函数(预留)

├── main.ts # 游戏主入口

└── style.css # 样式文件

这种结构的优势在于:

  • 高内聚:相关功能聚合在一起
  • 低耦合:模块间依赖清晰,易于修改
  • 可扩展:新增功能只需添加新模块
  • 易测试:每个模块都可以独立测试

三、核心系统开发

3.1 渲染系统:构建视觉基础

3D渲染的核心是场景(Scene)、相机(Camera)、渲染器(Renderer)这三大件。我将它们封装在Game类的构造函数中:

plain 复制代码
class Game {
  private scene: THREE.Scene;
  private camera: THREE.PerspectiveCamera;
  private renderer: THREE.WebGLRenderer;
  private clock: THREE.Clock;

  constructor() {
    // 场景是所有3D对象的容器
    this.scene = new THREE.Scene();

    // 透视相机模拟人眼视角,FOV设为75度是常用值
    this.camera = new THREE.PerspectiveCamera(
      75,                                     // 视野角度
      window.innerWidth / window.innerHeight, // 宽高比
      0.1,                                    // 近裁剪面
      1000                                    // 远裁剪面
    );
    this.camera.position.set(0, 5, 10);

    // WebGL渲染器,开启抗锯齿提升画质
    this.renderer = new THREE.WebGLRenderer({ antialias: true });
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.shadowMap.enabled = true;  // 启用阴影
    document.body.appendChild(this.renderer.domElement);

    // 时钟用于计算帧间隔
    this.clock = new THREE.Clock();
  }
}

关键设计点解析:

  1. 相机参数的选择:FOV(视野角度)75度是个甜蜜点,太小会产生望远镜效果,太大会畸变。近裁剪面0.1,远裁剪面1000,这个范围足够覆盖我们的场景。
  2. 渲染器配置antialias: true开启抗锯齿,虽然消耗性能,但画面质量提升明显。shadowMap.enabled启用阴影系统,让场景更有立体感。
  3. 时钟系统:Three.js的Clock用于精确计算时间差,这对物理模拟和动画至关重要。

3.2 光照系统:营造氛围的魔法

光照决定了场景的氛围。我采用了环境光+平行光的经典组合:

plain 复制代码
// 环境光提供无方向的基础照明
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
this.scene.add(ambientLight);

// 平行光模拟太阳光,产生方向性阴影
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(10, 20, 10);
directionalLight.castShadow = true;

// 配置阴影质量
directionalLight.shadow.camera.top = 20;
directionalLight.shadow.camera.bottom = -20;
directionalLight.shadow.camera.left = -20;
directionalLight.shadow.camera.right = 20;

this.scene.add(directionalLight);

光照设计原则:

  • 环境光强度0.5:提供基础照明,防止场景过暗,但不能太亮否则会失去立体感
  • 平行光强度0.8:主光源,产生明确的阴影方向
  • 光源位置(10,20,10):从右上方照射,符合自然光习惯
  • 阴影相机范围:覆盖整个游戏场景,确保所有物体都能投射阴影

3.3 物理系统:赋予世界真实感

物理引擎是游戏真实感的来源。Cannon-es的配置需要仔细调整:

plain 复制代码
export class PhysicsWorld {
  public world: CANNON.World;

  constructor() {
    this.world = new CANNON.World();

    // 地球重力加速度,负值表示向下
    this.world.gravity.set(0, -9.82, 0);

    // 碰撞检测算法:NaiveBroadphase适合物体不多的场景
    this.world.broadphase = new CANNON.NaiveBroadphase();

    // 求解器迭代次数,影响碰撞精度和性能
    this.world.solver.iterations = 10;

    // 允许静止物体休眠,优化性能
    this.world.allowSleep = true;
  }
}

物理参数深度解析:

  1. 重力值9.82:标准地球重力,如果做月球游戏可以调整为1.62,创造不同的跳跃手感
  2. Broadphase选择
    1. NaiveBroadphase:O(n²)复杂度,物体少时效率高
    2. SAPBroadphase:扫描排序,物体多时更优
    3. 我们的游戏物体少,选择Naive即可
  3. Solver迭代次数
    1. 越高越精确,但计算量越大
    2. 10次是经验值,平衡了精度和性能
    3. 快速运动的物体可能需要提高这个值
      1.
  4. Sleep机制
    1. 静止物体不参与物理计算
    2. 大幅降低CPU占用
    3. 玩家触碰时会自动唤醒
      1.

3.4 游戏循环:驱动一切的引擎

游戏循环是整个系统的心脏,每秒跳动60次:

plain 复制代码
private animate(): void {
  requestAnimationFrame(() => this.animate());

  const deltaTime = this.clock.getDelta();

  if (this.gameActive) {
    // 1. 更新倒计时
    this.timeLeft -= deltaTime;

    // 2. 更新物理世界(固定时间步长)
    this.physicsWorld.world.step(1/60, deltaTime, 3);

    // 3. 更新玩家输入
    this.playerControls.update();

    // 4. 同步物理到视觉
    this.player.update();
    this.crystals.forEach(crystal => crystal.update());

    // 5. 更新摄像机
    this.updateCamera();

    // 6. 检查游戏状态
    this.checkGameState();

    // 7. 更新UI
    this.updateUI();
  }

  // 8. 渲染画面
  this.renderer.render(this.scene, this.camera);
}

执行顺序的重要性:

这个顺序是经过深思熟虑的:

  1. 先更新时间,确保计时准确
  2. 物理模拟必须在输入处理后,才能响应玩家操作
  3. 视觉同步在物理计算后,保证看到的就是真实的
  4. 摄像机更新在物体更新后,才能正确跟随
  5. 状态检查在所有更新后,判断准确
  6. UI更新在状态检查后,显示最新信息
  7. 渲染必须在最后,呈现完整的一帧

固定时间步长的意义:

world.step(1/60, deltaTime, 3)这行代码很关键:

  • 第一参数1/60:固定时间步长,确保物理模拟稳定
  • 第二参数deltaTime:实际经过的时间
  • 第三参数3:最大子步数,处理帧率波动

即使帧率不稳定,物理模拟依然准确。

四、游戏对象设计

4.1 地面系统:

地面是玩家活动的舞台,需要同时处理视觉和物理两个层面:

plain 复制代码
export class Ground {
  public mesh: THREE.Mesh;
  public body: CANNON.Body;

  constructor(scene: THREE.Scene, world: CANNON.World) {
    // 视觉层:50x50的巨大平面
    const geometry = new THREE.PlaneGeometry(50, 50);
    const material = new THREE.MeshStandardMaterial({
      color: 0x4a9eff,      // 天蓝色,营造浮空岛氛围
      roughness: 0.8,       // 较高粗糙度,非光滑表面
      metalness: 0.2        // 低金属度,更像岩石
    });

    this.mesh = new THREE.Mesh(geometry, material);
    this.mesh.rotation.x = -Math.PI / 2;  // 旋转90度平躺
    this.mesh.receiveShadow = true;       // 接收其他物体的阴影
    scene.add(this.mesh);

    // 物理层:无限平面
    const shape = new CANNON.Plane();
    this.body = new CANNON.Body({
      mass: 0,  // 质量为0 = 静态物体,不受力影响
      shape: shape
    });
    this.body.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
    world.addBody(this.body);
  }
}

设计细节:

  1. 为什么是50x50:足够大的平面让玩家不会轻易掉出边界,又不会太大导致迷失方向
  2. 材质参数调优
    1. 天蓝色(0x4a9eff)与"光之岛"的主题契合
    2. roughness 0.8让表面有质感,不是镜面反射
    3. metalness 0.2保持一点反光,增加视觉趣味
  3. 物理平面特性
    1. Cannon.Plane是无限大的,不用担心边界
    2. mass=0的物体完全静止,碰撞时只影响动态物体
    3. 四元数旋转比欧拉角更稳定,避免万向锁问题

4.2 玩家角色:游戏的主角

玩家角色的设计需要兼顾视觉美观和物理合理性:

plain 复制代码
export class Player {
  public mesh: THREE.Mesh;
  public body: CANNON.Body;

  constructor(scene: THREE.Scene, world: CANNON.World) {
    // 视觉:胶囊体模拟人形
    const geometry = new THREE.CapsuleGeometry(0.5, 1, 4, 8);
    const material = new THREE.MeshStandardMaterial({
      color: 0xff6b35,    // 活力橙色,醒目
      roughness: 0.7,
      metalness: 0.3
    });

    this.mesh = new THREE.Mesh(geometry, material);
    this.mesh.castShadow = true;  // 投射阴影
    scene.add(this.mesh);

    // 物理:球体简化碰撞
    const shape = new CANNON.Sphere(0.5);
    this.body = new CANNON.Body({
      mass: 5,                            // 质量影响惯性
      position: new CANNON.Vec3(0, 3, 0), // 出生在空中
      linearDamping: 0.9                  // 阻尼模拟空气阻力
    });
    this.body.addShape(shape);
    world.addBody(this.body);
  }

  update(): void {
    // 核心:物理驱动视觉
    this.mesh.position.copy(this.body.position as any);
    this.mesh.quaternion.copy(this.body.quaternion as any);
  }
}

关键决策解释:

  1. 为何视觉用胶囊,物理用球
    1. 胶囊体外形更接近人形,视觉效果好
    2. 球体碰撞计算最简单,性能最优
    3. 球体在斜坡上自然滚动,符合物理直觉
      1.
  2. 质量设为5
    1. 太轻(<1):容易被碰撞弹飞
    2. 太重(>10):跳跃需要很大的力
    3. 5是经过测试的最佳值
      1.
  3. 线性阻尼0.9
    1. 0表示无阻力,在真空中永动
    2. 1表示瞬间停止
    3. 0.9让角色有惯性又能快速停下
      1.
  4. 初始位置(0,3,0)
    1. Y=3确保在地面上方
    2. 开局掉落增加动态感
    3. 测试物理引擎是否正常工作
      1.

4.3 水晶系统:目标与奖励

水晶是游戏的核心目标,需要吸引玩家注意:

plain 复制代码
export class Crystal {
  public mesh: THREE.Mesh;
  public body: CANNON.Body;

  constructor(scene: THREE.Scene, world: CANNON.World, position: THREE.Vector3) {
    // 视觉:发光的宝石
    const geometry = new THREE.IcosahedronGeometry(0.5, 0);
    const material = new THREE.MeshStandardMaterial({
      color: 0xffeb3b,          // 金黄色
      emissive: 0xffeb3b,       // 自发光
      emissiveIntensity: 0.5,   // 发光强度
      roughness: 0.3,           // 低粗糙度,晶莹剔透
      metalness: 0.8            // 高金属度,反光强烈
    });

    this.mesh = new THREE.Mesh(geometry, material);
    this.mesh.position.copy(position);
    this.mesh.castShadow = true;
    scene.add(this.mesh);

    // 物理:触发器而非碰撞体
    const shape = new CANNON.Sphere(0.5);
    this.body = new CANNON.Body({
      mass: 0,
      position: new CANNON.Vec3(position.x, position.y, position.z),
      collisionResponse: false,  // 关键:不产生碰撞响应
      isTrigger: true
    });
    this.body.addShape(shape);
    (this.body as any).isCrystal = true;  // 标记身份
    world.addBody(this.body);
  }

  update(): void {
    // 持续旋转,吸引注意
    this.mesh.rotation.y += 0.02;
  }
}

视觉设计的心理学:

  1. 二十面体几何
    1. 接近球形但有棱角
    2. 多个面产生丰富的光影变化
    3. 符合"宝石/水晶"的心理预期
      1.
  2. 金黄色+自发光
    1. 黄色代表价值和奖励(游戏设计通用规律)
    2. 自发光让水晶在远处也清晰可见
    3. emissiveIntensity 0.5不会太刺眼
      1.
  3. 材质参数组合
    1. 低粗糙度(0.3) + 高金属度(0.8) = 宝石质感
    2. 反射环境光,产生璀璨效果
    3. 与场景中其他物体形成鲜明对比
      1.

触发器机制:

collisionResponse: false是关键设置:

  • 玩家可以"穿过"水晶(不会被弹开)
  • 但仍然触发碰撞事件
  • 实现了"触碰即收集"的流畅体验
  • 避免了物理碰撞导致的异常行为

旋转动画的讲究:

每帧旋转0.02弧度(约1.15度):

  • 一圈需要约5秒(2π/0.02/60 ≈ 5.2秒)
  • 速度适中,既有动感又不眼花
  • 匀速旋转比变速更稳定,减少渲染负担

五、交互系统设计

5.1 玩家控制:打造流畅操作

操作手感是游戏体验的核心,需要精心调校:

plain 复制代码
export class PlayerControls {
  private body: CANNON.Body;
  private keysPressed: { [key: string]: boolean } = {};
  private moveSpeed: number = 5;
  private jumpForce: number = 8;
  private isGrounded: boolean = false;

  constructor(body: CANNON.Body) {
    this.body = body;

    // 键盘事件监听
    document.addEventListener('keydown', (e) => {
      this.keysPressed[e.key.toLowerCase()] = true;
    });

    document.addEventListener('keyup', (e) => {
      this.keysPressed[e.key.toLowerCase()] = false;
    });

    // 地面检测:监听物理碰撞
    this.body.addEventListener('collide', (e: any) => {
      const contact = e.contact;
      // 检查碰撞法线方向
      if (contact.ni.y > 0.5 || contact.nj.y > 0.5) {
        this.isGrounded = true;
      }
    });
  }

  update(): void {
    const velocity = new CANNON.Vec3();

    // WASD移动
    if (this.keysPressed['w']) velocity.z -= this.moveSpeed;
    if (this.keysPressed['s']) velocity.z += this.moveSpeed;
    if (this.keysPressed['a']) velocity.x -= this.moveSpeed;
    if (this.keysPressed['d']) velocity.x += this.moveSpeed;

    // 直接设置水平速度,保留垂直速度
    this.body.velocity.x = velocity.x;
    this.body.velocity.z = velocity.z;

    // 跳跃:只在地面时生效
    if (this.keysPressed[' '] && this.isGrounded) {
      this.body.velocity.y = this.jumpForce;
      this.isGrounded = false;
    }
  }
}

操作手感优化技巧:

  1. 移动速度调校(5)
    1. 速度1-3:太慢,玩家急躁
    2. 速度5-7:舒适区间
    3. 速度10+:太快,失控感
    4. 最终选5,经过多次试玩调整
      1.
  2. 跳跃力度设计(8)
    1. 与重力9.82配合
    2. 跳跃高度约3.2单位(v²/2g = 64/19.64)
    3. 滞空时间约1.6秒(2v/g)
    4. 足够跨越障碍,又不会太飘
      1.
  3. 地面检测的智慧
    1. if (contact.ni.y > 0.5 || contact.nj.y > 0.5)
    2. ni和nj是碰撞法线的两个方向
    3. Y > 0.5意味着法线向上(cos45° ≈ 0.7)
    4. 允许在斜坡上跳跃(不超过45度)
    5. 防止在墙壁上跳跃
      1.
  4. 直接设置速度 vs 施加力
    1. 施加力:更真实,但响应慢,有惯性
    2. 直接设速度:立即响应,操作精确
    3. 游戏偏向后者,牺牲真实换操作性
    4. 保留Y轴速度,重力效果不受影响
      1.

5.2 摄像机系统:玩家的眼睛

第三人称视角需要平滑的摄像机跟随:

plain 复制代码
private cameraOffset: THREE.Vector3 = new THREE.Vector3(0, 5, 10);

private updateCamera(): void {
  // 目标位置 = 玩家位置 + 固定偏移
  const targetPosition = new THREE.Vector3()
    .copy(this.player.mesh.position)
    .add(this.cameraOffset);

  // 线性插值实现平滑移动
  this.camera.position.lerp(targetPosition, 0.1);

  // 始终注视玩家
  this.camera.lookAt(this.player.mesh.position);
}

摄像机设计的艺术:

  1. 偏移向量(0, 5, 10)
    1. Y=5:俯视角度,观察地面和障碍
    2. Z=10:距离适中,不太近不太远
    3. X=0:居中,左右视野对称
      1.
  2. Lerp插值系数0.1
    1. 0.01:太慢,摄像机落后明显
    2. 0.1:恰好,轻微延迟感
    3. 0.5:太快,几乎刚性跟随
    4. 插值产生自然的"弹性"效果
      1.
  3. lookAt的作用
    1. 自动计算摄像机旋转
    2. 无论玩家移动到哪里,始终在视野中心
    3. 比手动计算欧拉角简单可靠
      1.
  4. 高级技巧(本项目未实现)
    1. 碰撞检测:防止摄像机穿墙
    2. 动态距离:根据速度调整远近
    3. 视角平滑:鼠标控制环视
      1.

5.3 碰撞检测:收集的实现

水晶收集是游戏的核心互动:

plain 复制代码
constructor() {
  // ... 其他初始化代码

  // 监听玩家的碰撞事件
  this.player.body.addEventListener('collide', (e: any) => {
    this.onPlayerCollide(e);
  });
}

private onPlayerCollide(e: any): void {
  const otherBody = e.body || e.target;

  // 检查碰撞对象是否为水晶
  if (otherBody.isCrystal) {
    const crystalIndex = this.crystals.findIndex(
      c => c.body === otherBody
    );

    if (crystalIndex !== -1) {
      const crystal = this.crystals[crystalIndex];

      // 三重移除:视觉、物理、数据
      this.scene.remove(crystal.mesh);
      this.physicsWorld.world.removeBody(crystal.body);
      this.crystals.splice(crystalIndex, 1);

      // 更新分数
      this.score++;
      this.updateUI();
    }
  }
}

实现细节剖析:

  1. 身份识别机制
    1. (this.body as any).isCrystal = true;
    2. 利用JavaScript的动态特性
    3. 给物理体添加自定义属性
    4. 简单有效,避免复杂的类型判断
      1.
  2. 为何需要findIndex
    1. 物理引擎返回的是Body对象
    2. 需要找到对应的Crystal实例
    3. 确保移除正确的对象
      1.
  3. 三重移除的必要性:
    1. scene.remove:视觉层,不再渲染
    2. world.removeBody:物理层,释放计算资源
    3. array.splice:数据层,防止内存泄漏
    4. 缺少任何一步都会导致问题
      1.
  4. 事件驱动的优势
    1. 不需要每帧遍历检查距离
    2. 物理引擎高效处理碰撞检测
    3. 代码解耦,逻辑清晰
      1.

六、游戏逻辑与状态管理

6.1 计时系统:营造紧迫感

倒计时为游戏增加了挑战性:

plain 复制代码
private timeLeft: number = 60;
private gameActive: boolean = true;

private animate(): void {
  requestAnimationFrame(() => this.animate());

  const deltaTime = this.clock.getDelta();

  if (this.gameActive) {
    // 每帧减少时间
    this.timeLeft -= deltaTime;

    // 时间耗尽检查
    if (this.timeLeft <= 0) {
      this.gameActive = false;
      this.gameOverElement.style.display = 'block';
    }

    // 更新UI显示
    this.updateUI();
  }

  // 渲染继续(即使游戏结束)
  this.renderer.render(this.scene, this.camera);
}

设计考量:

  1. 60秒的选择
    1. 30秒:太紧张,容易挫败
    2. 60秒:适中,有探索空间
    3. 120秒:太长,失去紧迫感
    4. 可根据水晶数量调整
      1.
  2. deltaTime的重要性
    1. 不同设备帧率不同
    2. 基于帧数计时不准确
    3. deltaTime保证时间流逝真实
      1.
  3. 游戏结束后继续渲染
    1. 玩家能看到失败瞬间
    2. 场景不会突然冻结
    3. 提供视觉连续性
      1.

6.2 胜负判定:游戏的终点

清晰的胜负条件是游戏闭环的关键:

plain 复制代码
private score: number = 0;
private totalCrystals: number = 8;

private checkGameState(): void {
  // 胜利条件:收集所有水晶
  if (this.score >= this.totalCrystals) {
    this.gameActive = false;
    this.victoryElement.style.display = 'block';
  }

  // 失败条件:时间耗尽
  if (this.timeLeft <= 0) {
    this.gameActive = false;
    this.gameOverElement.style.display = 'block';
  }
}

逻辑设计原则:

  1. 互斥性:胜利和失败不会同时发生
  2. 明确性:条件简单清晰,无歧义
  3. 即时性:满足条件立即触发
  4. 可扩展性:易于添加新的结束条件

6.3 UI系统:信息传达的桥梁

UI需要传达关键信息而不干扰游戏:

plain 复制代码
private updateUI(): void {
  this.scoreElement.innerText = 分数: ${this.score};
  this.timeElement.innerText = 时间: ${Math.ceil(this.timeLeft)};
}

UI设计原则:

  1. 位置固定:左上角是信息显示的黄金位置
  2. 对比强烈:白色文字+黑色阴影,任何背景都清晰
  3. 实时更新:每帧刷新,信息永远最新
  4. 简洁明了:只显示必要信息
plain 复制代码
HTML结构:
<div id="ui">
  <div id="score">分数: 0</div>
  <div id="time">时间: 60</div>
</div>

<div id="gameOver" style="display: none;">
  <h1>游戏结束!</h1>
  <p>按 R 重新开始</p>
</div>

<div id="victory" style="display: none;">
  <h1>胜利!</h1>
  <p>你收集了所有水晶!</p>
  <p>按 R 重新开始</p>
</div>
CSS样式:
#ui {
  position: absolute;
  top: 20px;
  left: 20px;
  color: white;
  font-size: 20px;
  font-weight: bold;
  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
  z-index: 100;
}

#gameOver, #victory {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
  color: white;
  background-color: rgba(0, 0, 0, 0.8);
  padding: 40px;
  border-radius: 10px;
  z-index: 200;
}

样式设计亮点:

  1. 文字阴影text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8)
    1. 2px右偏,2px下偏
    2. 4px模糊半径
    3. 80%透明黑色
    4. 在任何背景上都可读
      1.
  2. 居中技巧
    1. top: 50%; left: 50%; transform: translate(-50%, -50%);
    2. 经典的CSS居中方法
    3. 适用于任何尺寸的元素
    4. 比flex更稳定
      1.
  3. 半透明背景rgba(0, 0, 0, 0.8)
    1. 黑色80%不透明度
    2. 既能突出文字,又不完全遮挡场景
    3. 营造叠加效果
      1.

七、性能优化与最佳实践

7.1 渲染优化

  1. 阴影优化
plain 复制代码
// 只为必要的对象启用阴影
this.mesh.castShadow = true;    // 投射阴影
this.mesh.receiveShadow = true;  // 接收阴影

// 限制阴影范围
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 50;
  1. 材质复用
plain 复制代码
// 不要这样(创建多个相同材质)
const material1 = new THREE.MeshStandardMaterial({ color: 0xff0000 });
const material2 = new THREE.MeshStandardMaterial({ color: 0xff0000 });

// 应该这样(复用材质)
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 });
const mesh1 = new THREE.Mesh(geometry1, material);
const mesh2 = new THREE.Mesh(geometry2, material);
  1. 几何体优化
plain 复制代码
// 降低细分数(在视觉可接受范围内)
new THREE.SphereGeometry(1, 32, 32);  // 高质量
new THREE.SphereGeometry(1, 16, 16);  // 中等质量,性能更好

7.2 物理优化

  1. 简化碰撞体
plain 复制代码
// 视觉模型可以复杂
const visualMesh = new THREE.CapsuleGeometry(0.5, 1, 4, 8);

// 物理碰撞用简单形状
const physicsShape = new CANNON.Sphere(0.5);
  1. 休眠机制
plain 复制代码
this.world.allowSleep = true;

// 静态物体立即休眠
this.body.sleepState = CANNON.Body.SLEEPING;
  1. 碰撞过滤
plain 复制代码
// 为不同对象分组
const GROUND_GROUP = 1;
const PLAYER_GROUP = 2;
const CRYSTAL_GROUP = 4;

// 设置碰撞过滤
body.collisionFilterGroup = PLAYER_GROUP;
body.collisionFilterMask = GROUND_GROUP | CRYSTAL_GROUP;

7.3 代码优化

  1. 对象池(未在本项目实现,但值得学习):
plain 复制代码
class ObjectPool<T> {
  private pool: T[] = [];

  get(factory: () => T): T {
    return this.pool.pop() || factory();
  }

  release(obj: T): void {
    this.pool.push(obj);
  }
}
  1. 事件监听器清理
plain 复制代码
class Game {
  private handleResize = () => {
    this.camera.aspect = window.innerWidth / window.innerHeight;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(window.innerWidth, window.innerHeight);
  };

  constructor() {
    window.addEventListener('resize', this.handleResize);
  }

  dispose() {
    window.removeEventListener('resize', this.handleResize);
    // 清理其他资源...
  }
}
  1. TypeScript优化
plain 复制代码
// 使用类型守卫
function isCrystalBody(body: any): body is CANNON.Body & { isCrystal: true } {
  return body.isCrystal === true;
}

// 代替 any 类型
if (isCrystalBody(otherBody)) {
  // TypeScript知道otherBody有isCrystal属性
}

八、从Web到Rokid AR:一次意外的惊喜之旅

8.1 为什么我最终选择了Rokid?

完成Web版《光之岛》后,我在想:如果这个游戏能在真实空间中玩,该有多酷?

我试过几个XR平台,但都有各种问题------有的太重戴不住,有的开发工具反人类,有的性能惨不忍睹。直到我接触到Rokid AR眼镜

三个让我眼前一亮的点:

  1. 49克的重量 第一次戴上Rokid Glasses时我惊了------比我的太阳镜还轻!玩半小时游戏完全无压力,不像某些头显戴10分钟就头晕。
  2. Web开发者友好 Rokid的JSAR平台基于Web标准,我写了两年的Three.js经验不是白费。看到spaceDocument.scene那一刻,我知道这就是我要的。
  3. 调试体验不反人类 JSAR Devtools直接集成VS Code,改代码-刷新-看效果,和Web开发一样丝滑。不用折腾Unity那套重型IDE。

最关键的一点:Rokid的手势识别准确率真的高。99%不是吹的,我测试时用"捏合"手势跳跃,几乎没有误触发。


8.2 移植过程:比想象中简单太多

我原本预计移植要花一周,结果一天就搞定了。这得益于Rokid JSAR的设计理念------让Web开发者无缝过渡。

关键改动点1:场景初始化方式变了

Web版我要自己创建Scene、Camera、Renderer三大件:

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(...);

const renderer = new THREE.WebGLRenderer();

Rokid版直接用全局对象,一行搞定:

const scene = spaceDocument.scene as BABYLON.Scene;

const camera = scene.activeCamera; // 系统已经创建好了

为什么这样设计?因为Rokid要控制相机做头部追踪(6DoF),开发者不能乱动相机,否则会破坏空间定位。

关键改动点2:从键盘到手势

这是最大的改动。Web版用WASD控制移动:

if (keysPressed['w']) playerBody.velocity.z -= 5;

Rokid版改用头部朝向+手势:

plain 复制代码
// 获取头部朝向(自动的,相机跟着头转)
const forward = camera.getDirection(BABYLON.Axis.Z);

// 检测"向前挥手"手势
if (手势识别API返回向前) {
  playerBody.velocity.x = forward.x * 5;
  playerBody.velocity.z = forward.z * 5; // 朝看的方向移动
}

这个改动带来了魔法般的体验:你看向哪里,角色就往哪走。比键盘自然太多!

关键改动点3:UI从屏幕到空间

Web版的UI是这样的:

<div id="score">分数: 0</div>

CSS定位,永远钉在屏幕左上角。

Rokid版的UI是3D空间中的一块"浮空面板":

plain 复制代码
const uiPanel = BABYLON.MeshBuilder.CreatePlane('ui', {
  width: 2, height: 1
}, scene);

uiPanel.position = new BABYLON.Vector3(-3, 2, 0); // 浮在左上方
uiPanel.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL; // 始终面向你

这个面板真的在空间里!你走近它会变大,走远会变小,转头它会自动旋转面向你。科幻感拉满。


8.3 Rokid独有的"哇时刻"

移植完成后,我戴着Rokid眼镜试玩,有几个瞬间真的让我"哇"出声。

时刻1:水晶真的"浮"在我客厅里

Web版玩游戏,水晶在屏幕里。Rokid版,水晶就在我茶几旁边,发着金光缓缓旋转。

我走近它,它变大;走远,它变小。绕着它转一圈,能看到每个角度的反光。这种"它真的在这里"的感觉,是屏幕永远给不了的。

时刻2:用"眼神"控制移动方向

我看向水晶,然后做"向前挥手"的手势------角色就朝水晶走过去。

不需要方向键找角度,看哪走哪。这个交互逻辑在Web上根本实现不了,只有AR眼镜能做到。

时刻3:收集水晶的瞬间

角色碰到水晶,水晶消失的同时,我真的感觉像自己碰到了它

因为水晶在我前方2米的空间位置,角色走到那,就是我"虚拟地"走到了那。这种沉浸感,PC游戏再怎么调镜头都模拟不出来。

时刻4:UI面板跟着我转

我转头看向窗外,余光看到左上角的UI面板也跟着转,始终保持"在我视野左上方"。

这是Rokid的billboardMode功能,让3D物体始终面向相机。简单一行代码:

uiPanel.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;

但效果震撼------UI就像悬浮在空中的全息投影,科幻电影里的画面变成了现实。


8.4 性能表现:超出预期

我担心移动设备跑不动,结果Rokid给了我惊喜。

表格 还在加载中,请等待加载完成后再尝试复制

惊喜点:

  • Rokid跑到68帧,比我手机还流畅
  • 手势延迟只有18ms,感觉不到卡顿
  • 电池续航2.8小时,玩完3局没问题

Rokid团队做了很多底层优化,YodaOS-Master系统专门针对AR场景调优,单摄像头SLAM的性能开销远低于我预期。


8.5 开发建议:给想尝试Rokid的你

如果你也想把Web 3D游戏移植到Rokid,我的建议是:

  1. 先在Web上把逻辑做扎实

70%的代码可以复用。在PC上调试物理引擎、游戏逻辑、碰撞检测,这些在Rokid上一样能用。

只有交互部分(键盘→手势)和UI部分(DOM→Babylon GUI)需要重写。

  1. 多用Rokid的独特能力

别只是"把屏幕游戏搬到空间"。利用Rokid的优势:

  • 空间定位: 水晶固定在真实空间的某个位置
  • 手势识别: 用捏合、挥手等自然手势代替按钮
  • 头部追踪: 看哪走哪,比摇杆自然100倍
  1. 性能优化别走极端

Rokid是移动设备,但YodaOS优化得很好。我测试下来:

  • 8个发光水晶 + 物理引擎 + 粒子效果 = 68 FPS
  • 不需要像优化手游那样抠到每一帧

保持代码清晰比挤性能更重要。

  1. 调试技巧

戴着眼镜调试确实不方便,我的办法:

  1. 在Web版完成80%开发
  2. 移植到Rokid后,用console.log定位问题
  3. JSAR Devtools能看到日志,比想象中好用
  4. 关键参数(FPS、位置坐标)直接渲染在空间UI上

8.6 写在最后:空间计算的未来已来

从键盘WASD到手势控制,从屏幕UI到空间UI,从"看游戏"到"身在游戏中"------这不是技术的炫技,而是交互范式的革命。

Rokid给了Web开发者一个低门槛进入XR世界的机会。你不需要学Unity,不需要买Meta Quest,一副49克的眼镜+熟悉的TypeScript,就能开发空间计算应用。

《光之岛》只是个开始。我后续计划做:

  • AR塔防游戏(敌人从真实墙壁爬出来)
  • 空间密室逃脱(线索藏在你房间的角落)
  • 多人AR竞技(和朋友在同一空间PK)

Rokid的生态正在爆发,开发者社区活跃,官方持续投入。2024年的Spatial Joy大赛有200+团队参赛,足以证明这个平台的潜力。

如果你也想尝试,现在就是最好的时机。从一个小游戏开始,你会发现------未来比想象中更近


九、项目扩展方向

9.1 关卡系统

plain 复制代码
interface Level {
  name: string;
  crystalCount: number;
  timeLimit: number;
  obstacles: Obstacle[];
  playerStart: THREE.Vector3;
}

class LevelManager {
  private levels: Level[] = [];
  private currentLevel: number = 0;

  loadLevel(index: number): void {
    const level = this.levels[index];
    // 加载关卡数据
    this.spawnCrystals(level.crystalCount);
    this.createObstacles(level.obstacles);
    this.player.position.copy(level.playerStart);
  }
}

9.2 道具系统

plain 复制代码
interface PowerUp {
  type: 'speed' | 'time' | 'jump';
  duration: number;
  value: number;
}

class PowerUpSystem {
  apply(powerUp: PowerUp): void {
    switch(powerUp.type) {
      case 'speed':
        this.playerControls.moveSpeed *= powerUp.value;
        setTimeout(() => this.reset(), powerUp.duration * 1000);
        break;
      case 'time':
        this.game.timeLeft += powerUp.value;
        break;
      case 'jump':
        this.playerControls.jumpForce *= powerUp.value;
        setTimeout(() => this.reset(), powerUp.duration * 1000);
        break;
    }
  }
}

9.3 粒子效果

plain 复制代码
class ParticleSystem {
  createCollectionEffect(position: THREE.Vector3): void {
    const particles = new THREE.Points(
      new THREE.BufferGeometry(),
      new THREE.PointsMaterial({
        color: 0xffeb3b,
        size: 0.1,
        transparent: true,
        opacity: 1
      })
    );

    // 动画:向上扩散并淡出
    gsap.to(particles.position, {
      y: position.y + 2,
      duration: 1,
      ease: 'power2.out'
    });

    gsap.to(particles.material, {
      opacity: 0,
      duration: 1,
      onComplete: () => this.scene.remove(particles)
    });
  }
}

9.4 音效系统

plain 复制代码
class AudioManager {
  private sounds: Map<string, HTMLAudioElement> = new Map();

  load(name: string, url: string): void {
    const audio = new Audio(url);
    audio.preload = 'auto';
    this.sounds.set(name, audio);
  }

  play(name: string, volume: number = 1): void {
    const sound = this.sounds.get(name);
    if (sound) {
      sound.volume = volume;
      sound.currentTime = 0;
      sound.play();
    }
  }
}

// 使用
audioManager.load('collect', '/sounds/collect.mp3');
audioManager.load('jump', '/sounds/jump.mp3');
audioManager.load('victory', '/sounds/victory.mp3');

// 收集水晶时
audioManager.play('collect', 0.5);

十、开发心得与经验总结

10.1 架构设计的重要性

回顾整个开发过程,早期的架构设计决策至关重要:

  1. 模块化设计:每个类职责单一,便于测试和维护
  2. 物理视觉分离:降低耦合,各自优化
  3. 事件驱动:解耦逻辑,代码更清晰

10.2 调试技巧

开发3D应用时的调试方法:

  1. 可视化调试
plain 复制代码
// 显示物理碰撞体
import CannonDebugger from 'cannon-es-debugger';
const cannonDebugger = new CannonDebugger(scene, world);

// 在update中调用
cannonDebugger.update();
  1. 性能监控
plain 复制代码
import Stats from 'stats.js';
const stats = new Stats();
document.body.appendChild(stats.dom);

// 在游戏循环中
stats.begin();
// 游戏逻辑
stats.end();
  1. 控制台输出
plain 复制代码
// 有节制地使用console
if (this.debug) {
  console.log('Player position:', this.player.body.position);
}

10.3 常见问题与解决

问题1:物理体和网格不同步

plain 复制代码
// 错误:忘记调用update
// 正确:每帧同步
update() {
  this.mesh.position.copy(this.body.position);
  this.mesh.quaternion.copy(this.body.quaternion);
}

问题2:碰撞检测不灵敏

plain 复制代码
// 增加物理迭代次数
world.solver.iterations = 20; // 从10增到20

// 或降低时间步长
world.step(1/120, deltaTime, 5); // 从1/60到1/120

问题3:性能下降

plain 复制代码
// 使用性能分析
console.time('physics');
world.step(1/60, deltaTime);
console.timeEnd('physics');

// 查找瓶颈并优化

结语:开启你的XR开发之旅

《光之岛》从构思到实现,完整呈现了Web 3D游戏开发的全过程。这个项目虽然简单,却包含了3D游戏开发的核心要素:渲染、物理、交互、逻辑。

掌握这些基础后,通往XR开发的大门已经打开。Rokid 空间计算平台提供了强大的硬件能力和SDK支持,让我们能够创造真正沉浸式的体验。从2D到3D,从屏幕到空间,这是技术演进的必然趋势。

相关推荐
PHOSKEY5 小时前
3D工业相机量化管控耳机充电弹针关键尺寸
数码相机·3d
专业开发者6 小时前
Wi-Fi® 赋能沉浸式扩展现实(XR)体验落地
json·xr
二狗哈7 小时前
Cesium快速入门26:加载渲染GeoJson数据
3d·webgl·cesium·地图可视化
北京阿法龙科技有限公司8 小时前
智能赋能高效执法|AR警务智能眼镜核心应用详解|阿法龙XR云平台
ar·xr
zlycheng8 小时前
桌面五轴加工:如何从3D打印升级到精细制造
3d·制造·小五轴
Coovally AI模型快速验证8 小时前
复杂工业场景如何实现3D实例与部件一体化分割?多视角贝叶斯融合的分层图像引导框
人工智能·深度学习·计算机视觉·3d·语言模型·机器人
成都渲染101云渲染66668 小时前
三维制图软件哪个最好用?主流 3D 建模软件深度对比(2025)
3d·ue5·图形渲染·blender·maya·houdini
大写-凌祁9 小时前
Change3D:从视频建模视角重新审视变化检测与描述
3d·音视频
陶甜也21 小时前
使用Blender进行现代建筑3D建模:前端开发者的跨界探索
前端·3d·blender