一场关于Web 3D游戏开发的完整旅程,从构思到实现,探索空间计算时代的游戏开发新范式
引言:为什么选择Web 3D开发?
在XR(扩展现实)技术蓬勃发展的今天,空间计算正在改变我们与数字世界的交互方式。作为一名开发者,我一直在思考:如何以最低的门槛进入这个激动人心的领域?答案是------从Web 3D开发开始。
Web技术栈具有天然的优势:无需下载安装跨平台运行、快速迭代、易于分享。更重要的是,掌握了Web 3D开发的核心技能后,迁移到专业XR平台(Rokid空间计算平台)只需要了解平台特定的API即可。这就像学会了做菜的基本功,换个厨房依然能烹饪美味。
本文将带你完整经历《光之岛》这款3D收集游戏的开发过程,从技术选型到架构设计,从基础搭建到核心玩法实现,每一步都会详细剖析背后的原理和决策依据。
下面为演示视频:
一、项目构思:从想法到设计
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();
}
}
关键设计点解析:
- 相机参数的选择:FOV(视野角度)75度是个甜蜜点,太小会产生望远镜效果,太大会畸变。近裁剪面0.1,远裁剪面1000,这个范围足够覆盖我们的场景。
- 渲染器配置 :
antialias: true开启抗锯齿,虽然消耗性能,但画面质量提升明显。shadowMap.enabled启用阴影系统,让场景更有立体感。 - 时钟系统: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;
}
}
物理参数深度解析:
- 重力值9.82:标准地球重力,如果做月球游戏可以调整为1.62,创造不同的跳跃手感
- Broadphase选择 :
- NaiveBroadphase:O(n²)复杂度,物体少时效率高
- SAPBroadphase:扫描排序,物体多时更优
- 我们的游戏物体少,选择Naive即可
- Solver迭代次数 :
- 越高越精确,但计算量越大
- 10次是经验值,平衡了精度和性能
- 快速运动的物体可能需要提高这个值
1.
- Sleep机制 :
- 静止物体不参与物理计算
- 大幅降低CPU占用
- 玩家触碰时会自动唤醒
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);
}
执行顺序的重要性:
这个顺序是经过深思熟虑的:
- 先更新时间,确保计时准确
- 物理模拟必须在输入处理后,才能响应玩家操作
- 视觉同步在物理计算后,保证看到的就是真实的
- 摄像机更新在物体更新后,才能正确跟随
- 状态检查在所有更新后,判断准确
- UI更新在状态检查后,显示最新信息
- 渲染必须在最后,呈现完整的一帧
固定时间步长的意义:
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);
}
}
设计细节:
- 为什么是50x50:足够大的平面让玩家不会轻易掉出边界,又不会太大导致迷失方向
- 材质参数调优 :
- 天蓝色(0x4a9eff)与"光之岛"的主题契合
- roughness 0.8让表面有质感,不是镜面反射
- metalness 0.2保持一点反光,增加视觉趣味
- 物理平面特性 :
- Cannon.Plane是无限大的,不用担心边界
- mass=0的物体完全静止,碰撞时只影响动态物体
- 四元数旋转比欧拉角更稳定,避免万向锁问题
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.
- 质量设为5 :
- 太轻(<1):容易被碰撞弹飞
- 太重(>10):跳跃需要很大的力
- 5是经过测试的最佳值
1.
- 线性阻尼0.9 :
- 0表示无阻力,在真空中永动
- 1表示瞬间停止
- 0.9让角色有惯性又能快速停下
1.
- 初始位置(0,3,0) :
- Y=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.
- 金黄色+自发光 :
- 黄色代表价值和奖励(游戏设计通用规律)
- 自发光让水晶在远处也清晰可见
- emissiveIntensity 0.5不会太刺眼
1.
- 材质参数组合 :
- 低粗糙度(0.3) + 高金属度(0.8) = 宝石质感
- 反射环境光,产生璀璨效果
- 与场景中其他物体形成鲜明对比
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;
}
}
}
操作手感优化技巧:
- 移动速度调校(5) :
- 速度1-3:太慢,玩家急躁
- 速度5-7:舒适区间
- 速度10+:太快,失控感
- 最终选5,经过多次试玩调整
1.
- 跳跃力度设计(8) :
- 与重力9.82配合
- 跳跃高度约3.2单位(v²/2g = 64/19.64)
- 滞空时间约1.6秒(2v/g)
- 足够跨越障碍,又不会太飘
1.
- 地面检测的智慧 :
- if (contact.ni.y > 0.5 || contact.nj.y > 0.5)
- ni和nj是碰撞法线的两个方向
- Y > 0.5意味着法线向上(cos45° ≈ 0.7)
- 允许在斜坡上跳跃(不超过45度)
- 防止在墙壁上跳跃
1.
- 直接设置速度 vs 施加力 :
- 施加力:更真实,但响应慢,有惯性
- 直接设速度:立即响应,操作精确
- 游戏偏向后者,牺牲真实换操作性
- 保留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);
}
摄像机设计的艺术:
- 偏移向量(0, 5, 10) :
- Y=5:俯视角度,观察地面和障碍
- Z=10:距离适中,不太近不太远
- X=0:居中,左右视野对称
1.
- Lerp插值系数0.1 :
- 0.01:太慢,摄像机落后明显
- 0.1:恰好,轻微延迟感
- 0.5:太快,几乎刚性跟随
- 插值产生自然的"弹性"效果
1.
- lookAt的作用 :
- 自动计算摄像机旋转
- 无论玩家移动到哪里,始终在视野中心
- 比手动计算欧拉角简单可靠
1.
- 高级技巧(本项目未实现) :
- 碰撞检测:防止摄像机穿墙
- 动态距离:根据速度调整远近
- 视角平滑:鼠标控制环视
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();
}
}
}
实现细节剖析:
- 身份识别机制 :
- (this.body as any).isCrystal = true;
- 利用JavaScript的动态特性
- 给物理体添加自定义属性
- 简单有效,避免复杂的类型判断
1.
- 为何需要findIndex :
- 物理引擎返回的是Body对象
- 需要找到对应的Crystal实例
- 确保移除正确的对象
1.
- 三重移除的必要性:
scene.remove:视觉层,不再渲染world.removeBody:物理层,释放计算资源array.splice:数据层,防止内存泄漏- 缺少任何一步都会导致问题
1.
- 事件驱动的优势 :
- 不需要每帧遍历检查距离
- 物理引擎高效处理碰撞检测
- 代码解耦,逻辑清晰
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);
}
设计考量:
- 60秒的选择 :
- 30秒:太紧张,容易挫败
- 60秒:适中,有探索空间
- 120秒:太长,失去紧迫感
- 可根据水晶数量调整
1.
- deltaTime的重要性 :
- 不同设备帧率不同
- 基于帧数计时不准确
- deltaTime保证时间流逝真实
1.
- 游戏结束后继续渲染 :
- 玩家能看到失败瞬间
- 场景不会突然冻结
- 提供视觉连续性
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';
}
}
逻辑设计原则:
- 互斥性:胜利和失败不会同时发生
- 明确性:条件简单清晰,无歧义
- 即时性:满足条件立即触发
- 可扩展性:易于添加新的结束条件
6.3 UI系统:信息传达的桥梁
UI需要传达关键信息而不干扰游戏:
plain
private updateUI(): void {
this.scoreElement.innerText = 分数: ${this.score};
this.timeElement.innerText = 时间: ${Math.ceil(this.timeLeft)};
}
UI设计原则:
- 位置固定:左上角是信息显示的黄金位置
- 对比强烈:白色文字+黑色阴影,任何背景都清晰
- 实时更新:每帧刷新,信息永远最新
- 简洁明了:只显示必要信息
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;
}
样式设计亮点:
- 文字阴影 :
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8)- 2px右偏,2px下偏
- 4px模糊半径
- 80%透明黑色
- 在任何背景上都可读
1.
- 居中技巧 :
- top: 50%; left: 50%; transform: translate(-50%, -50%);
- 经典的CSS居中方法
- 适用于任何尺寸的元素
- 比flex更稳定
1.
- 半透明背景 :
rgba(0, 0, 0, 0.8)- 黑色80%不透明度
- 既能突出文字,又不完全遮挡场景
- 营造叠加效果
1.
七、性能优化与最佳实践
7.1 渲染优化
- 阴影优化:
plain
// 只为必要的对象启用阴影
this.mesh.castShadow = true; // 投射阴影
this.mesh.receiveShadow = true; // 接收阴影
// 限制阴影范围
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 50;
- 材质复用:
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);
- 几何体优化:
plain
// 降低细分数(在视觉可接受范围内)
new THREE.SphereGeometry(1, 32, 32); // 高质量
new THREE.SphereGeometry(1, 16, 16); // 中等质量,性能更好
7.2 物理优化
- 简化碰撞体:
plain
// 视觉模型可以复杂
const visualMesh = new THREE.CapsuleGeometry(0.5, 1, 4, 8);
// 物理碰撞用简单形状
const physicsShape = new CANNON.Sphere(0.5);
- 休眠机制:
plain
this.world.allowSleep = true;
// 静态物体立即休眠
this.body.sleepState = CANNON.Body.SLEEPING;
- 碰撞过滤:
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 代码优化
- 对象池(未在本项目实现,但值得学习):
plain
class ObjectPool<T> {
private pool: T[] = [];
get(factory: () => T): T {
return this.pool.pop() || factory();
}
release(obj: T): void {
this.pool.push(obj);
}
}
- 事件监听器清理:
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);
// 清理其他资源...
}
}
- 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眼镜。
三个让我眼前一亮的点:
- 49克的重量 第一次戴上Rokid Glasses时我惊了------比我的太阳镜还轻!玩半小时游戏完全无压力,不像某些头显戴10分钟就头晕。
- Web开发者友好 Rokid的JSAR平台基于Web标准,我写了两年的Three.js经验不是白费。看到
spaceDocument.scene那一刻,我知道这就是我要的。 - 调试体验不反人类 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,我的建议是:
- 先在Web上把逻辑做扎实
70%的代码可以复用。在PC上调试物理引擎、游戏逻辑、碰撞检测,这些在Rokid上一样能用。
只有交互部分(键盘→手势)和UI部分(DOM→Babylon GUI)需要重写。
- 多用Rokid的独特能力
别只是"把屏幕游戏搬到空间"。利用Rokid的优势:
- 空间定位: 水晶固定在真实空间的某个位置
- 手势识别: 用捏合、挥手等自然手势代替按钮
- 头部追踪: 看哪走哪,比摇杆自然100倍
- 性能优化别走极端
Rokid是移动设备,但YodaOS优化得很好。我测试下来:
- 8个发光水晶 + 物理引擎 + 粒子效果 = 68 FPS
- 不需要像优化手游那样抠到每一帧
保持代码清晰比挤性能更重要。
- 调试技巧
戴着眼镜调试确实不方便,我的办法:
- 在Web版完成80%开发
- 移植到Rokid后,用
console.log定位问题 - JSAR Devtools能看到日志,比想象中好用
- 关键参数(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 架构设计的重要性
回顾整个开发过程,早期的架构设计决策至关重要:
- 模块化设计:每个类职责单一,便于测试和维护
- 物理视觉分离:降低耦合,各自优化
- 事件驱动:解耦逻辑,代码更清晰
10.2 调试技巧
开发3D应用时的调试方法:
- 可视化调试:
plain
// 显示物理碰撞体
import CannonDebugger from 'cannon-es-debugger';
const cannonDebugger = new CannonDebugger(scene, world);
// 在update中调用
cannonDebugger.update();
- 性能监控:
plain
import Stats from 'stats.js';
const stats = new Stats();
document.body.appendChild(stats.dom);
// 在游戏循环中
stats.begin();
// 游戏逻辑
stats.end();
- 控制台输出:
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,从屏幕到空间,这是技术演进的必然趋势。