卡车寻钻实战教程

  目录
  1. 1. 目录
  2. 2. 1. 环境搭建与第一个场景
    1. 2.1. 1.1 HTML 骨架
    2. 2.2. 1.2 CSS 全屏画布
    3. 2.3. 1.3 Three.js 三大核心:Scene、Camera、Renderer
  3. 3. 2. 几何体与材质
    1. 3.1. 2.1 核心公式:Mesh = Geometry + Material
    2. 3.2. 2.2 常用几何体一览
    3. 3.3. 2.3 材质类型对比
    4. 3.4. 2.4 位置、旋转、缩放
  4. 4. 3. 光照系统
    1. 4.1. 3.1 四种基础光源
    2. 4.2. 3.2 光照策略
  5. 5. 4. 阴影
    1. 5.1. 4.1 阴影三步走
    2. 5.2. 4.2 阴影贴图分辨率
    3. 5.3. 4.3 阴影相机范围
  6. 6. 5. 组合与层级
    1. 6.1. 5.1 Group:逻辑容器
    2. 6.2. 5.2 为什么需要容器组?
    3. 6.3. 5.3 坐标系继承
  7. 7. 6. 构建复杂模型:半挂卡车
    1. 7.1. 6.1 设计思路
    2. 7.2. 6.2 材质管理
    3. 7.3. 6.3 车轮创建函数
    4. 7.4. 6.4 双轮轴函数
    5. 7.5. 6.5 模块化导出
  8. 8. 7. 动画循环
    1. 8.1. 7.1 requestAnimationFrame 基础
    2. 8.2. 7.2 deltaTime 的重要性
    3. 8.3. 7.3 平滑旋转(角度插值)
    4. 8.4. 7.4 相机平滑跟随(Lerp)
  9. 9. 8. 相机与轨道控制器
    1. 9.1. 8.1 两种相机
    2. 9.2. 8.2 OrbitControls 轨道控制器
    3. 9.3. 8.3 区分拖拽和点击
  10. 10. 9. 射线检测与点击交互
    1. 10.1. 9.1 Raycaster 原理
    2. 10.2. 9.2 完整点击流程
    3. 10.3. 9.3 目标点标记
    4. 10.4. 9.4 脉冲动画
  11. 11. 10. 雾效与大气
    1. 11.1. 10.1 雾的基本用法
    2. 11.2. 10.2 雾的策略
    3. 11.3. 10.3 雾与材质的交互
  12. 12. 11. 碰撞检测与避障
    1. 12.1. 11.1 圆形碰撞检测
    2. 12.2. 11.2 方向避让算法
    3. 12.3. 11.3 卡住检测
  13. 13. 12. 粒子特效:爆炸
    1. 13.1. 12.1 粒子生成
    2. 13.2. 12.2 粒子更新与清理
  14. 14. 13. 多渲染器:小地图
    1. 14.1. 13.1 独立渲染器
    2. 14.2. 13.2 独立场景
    3. 14.3. 13.3 正交相机
    4. 14.4. 13.4 同步标记
    5. 14.5. 13.5 自定义形状标记
  15. 15. 14. UI 叠加层:计时器
    1. 15.1. 14.1 动态创建 DOM 元素
    2. 15.2. 14.2 CSS 定位
    3. 15.3. 14.3 时间格式化
  16. 16. 15. 游戏状态管理
    1. 16.1. 15.1 状态变量
    2. 16.2. 15.2 状态转换
    3. 16.3. 15.3 暂停逻辑
  17. 17. 16. 代码组织与模块化
    1. 17.1. 16.1 项目结构
    2. 17.2. 16.2 模块导出/导入
    3. 17.3. 16.3 代码分区原则
  18. 18. 附录:常见问题与调试
    1. 18.1. A.1 物体不显示?
    2. 18.2. A.2 阴影不显示?
    3. 18.3. A.3 性能优化建议
    4. 18.4. A.4 调试技巧

基于项目 卡车寻钻 的完整代码,循序渐进掌握 Three.js 核心概念。


目录

  1. 环境搭建与第一个场景
  2. 几何体与材质
  3. 光照系统
  4. 阴影
  5. 组合与层级
  6. 构建复杂模型:半挂卡车
  7. 动画循环
  8. 相机与轨道控制器
  9. 射线检测与点击交互
  10. 雾效与大气
  11. 碰撞检测与避障
  12. 粒子特效:爆炸
  13. 多渲染器:小地图
  14. UI 叠加层:计时器
  15. 游戏状态管理
  16. 代码组织与模块化

1. 环境搭建与第一个场景

1.1 HTML 骨架

Three.js 需要一个 <canvas> 元素作为渲染目标:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Scene</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<canvas class="webgl"></canvas>
<script type="module" src="./script.js"></script>
</body>
</html>

关键点

  • type="module" 启用 ES Module,支持 import 语法
  • canvas 使用 class 选择器而非 id,便于 CSS 控制

1.2 CSS 全屏画布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
* {
margin: 0;
padding: 0;
}

html, body {
overflow: hidden; /* 禁止滚动条 */
}

.webgl {
position: fixed;
top: 0;
left: 0;
outline: none; /* 去除聚焦边框 */
}

1.3 Three.js 三大核心:Scene、Camera、Renderer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import * as THREE from 'three'

// 1. 场景 —— 容纳所有 3D 对象的容器
const scene = new THREE.Scene()
scene.background = new THREE.Color('#C8C8C8')

// 2. 相机 —— 决定"观众"看到什么
const camera = new THREE.PerspectiveCamera(
50, // FOV:视野角度(度)
window.innerWidth / window.innerHeight, // 宽高比
0.1, // near:近裁剪面
100 // far:远裁剪面
)
camera.position.set(10, 7, 10) // 相机位置 (x, y, z)
camera.lookAt(0, 0, 0) // 看向原点

// 3. 渲染器 —— 把场景画到 canvas 上
const canvas = document.querySelector('canvas.webgl')
const renderer = new THREE.WebGLRenderer({ canvas })
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

// 4. 渲染一帧
renderer.render(scene, camera)

概念解释

概念 类比 说明
Scene 舞台 所有演员(物体)、灯光都在舞台上
Camera 摄像机 决定观众从哪个角度、多大范围看舞台
Renderer 转播车 把舞台画面转换成屏幕上的像素

PerspectiveCamera 参数详解

  • FOV (50):视野角度,越大看到的范围越广,但边缘畸变越明显。50° 接近人眼自然视角
  • Aspect:宽/高比,必须匹配 canvas 尺寸,否则物体会被拉伸
  • Near (0.1):比这个距离更近的物体不渲染
  • Far (100):比这个距离更远的物体不渲染

2. 几何体与材质

2.1 核心公式:Mesh = Geometry + Material

1
2
3
4
5
6
7
8
9
10
11
12
13
// Geometry:定义形状(顶点位置)
const geometry = new THREE.BoxGeometry(1, 1, 1) // 宽、高、深

// Material:定义外观(颜色、光泽、纹理)
const material = new THREE.MeshStandardMaterial({
color: '#c41e3a',
roughness: 0.2, // 粗糙度 0=镜面 1=磨砂
metalness: 0.7 // 金属度 0=塑料 1=纯金属
})

// Mesh:几何体 + 材质 = 可渲染的物体
const cube = new THREE.Mesh(geometry, material)
scene.add(cube)

2.2 常用几何体一览

几何体 构造函数 本项目用途
BoxGeometry BoxGeometry(w, h, d) 车身、货箱、障碍物
CylinderGeometry CylinderGeometry(rTop, rBot, h, seg) 车轮、排气管
PlaneGeometry PlaneGeometry(w, h) 地面
SphereGeometry SphereGeometry(r, wSeg, hSeg) 爆炸粒子
OctahedronGeometry OctahedronGeometry(r, detail) 钻石
ConeGeometry ConeGeometry(r, h, seg) 小地图箭头
RingGeometry RingGeometry(innerR, outerR, seg) 目标点标记
CircleGeometry CircleGeometry(r, seg) 小地图标记点
ShapeGeometry ShapeGeometry(shape) 小地图菱形标记

2.3 材质类型对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// MeshStandardMaterial —— PBR 物理渲染(最常用)
// 需要灯光才能看到,效果最真实
new THREE.MeshStandardMaterial({
color: '#c41e3a',
roughness: 0.2,
metalness: 0.7
})

// MeshBasicMaterial —— 不受灯光影响,始终明亮
// 适合 UI 标记、小地图、粒子
new THREE.MeshBasicMaterial({
color: '#ffff00',
side: THREE.DoubleSide, // 双面渲染
transparent: true,
opacity: 0.8
})

// 发光材质 —— 自发光效果
new THREE.MeshStandardMaterial({
color: '#fffacd',
emissive: '#fffacd', // 自发光颜色
emissiveIntensity: 0.6 // 自发光强度
})

材质选择决策树

  • 需要灯光影响?→ MeshStandardMaterial(99% 的场景物体)
  • 始终可见、不受光?→ MeshBasicMaterial(UI、标记、粒子)
  • 需要自发光?→ 给 MeshStandardMaterialemissive 属性

2.4 位置、旋转、缩放

1
2
3
4
5
6
7
8
9
10
// 位置 —— 物体在 3D 空间中的坐标
mesh.position.set(x, y, z) // 绝对设置
mesh.position.x = 5 // 单轴修改

// 旋转 —— 绕各轴旋转(弧度制)
mesh.rotation.x = -Math.PI / 2 // 绕 X 轴旋转 -90°
mesh.rotation.y = Math.PI / 4 // 绕 Y 轴旋转 45°

// 缩放
mesh.scale.set(1, 1.6, 1) // Y 轴拉长 1.6 倍

Three.js 坐标系

  • X 轴:右为正
  • Y 轴:上为正
  • Z 轴:屏幕外(朝向观众)为正

3. 光照系统

3.1 四种基础光源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. 环境光 —— 均匀照亮所有物体,消除纯黑阴影
const ambientLight = new THREE.AmbientLight('#ffffff', 0.4)
scene.add(ambientLight)

// 2. 半球光 —— 模拟天空(上)和地面(下)的漫反射
const hemisphereLight = new THREE.HemisphereLight(
'#C8C8C8', // 天空颜色
'#8B7355', // 地面颜色
0.6 // 强度
)
scene.add(hemisphereLight)

// 3. 方向光 —— 模拟太阳,平行光线,可产生阴影
const directionalLight = new THREE.DirectionalLight('#ffffff', 2.5)
directionalLight.position.set(10, 15, 5)
scene.add(directionalLight)

// 4. 点光源 —— 从一点向四周发光,有衰减范围
const pointLight = new THREE.PointLight('#ccddff', 2.5, 6)
// 颜色 强度 照射距离
pointLight.position.set(sx, 1.5, sz)
scene.add(pointLight)

3.2 光照策略

光源 强度 用途
AmbientLight 0.4 基础照明,防止暗面全黑
HemisphereLight 0.6 模拟天空/地面环境光
DirectionalLight 2.5 主光源,产生阴影
PointLight 2.5 钻石发光效果

经验法则

  • AmbientLight 强度通常 0.3~0.5,太高会让场景”平”
  • DirectionalLight 作为主光源,强度 1~3
  • PointLight 的第三个参数(距离)要合理设置,太小照不到,太大浪费性能

4. 阴影

4.1 阴影三步走

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 第一步:渲染器开启阴影
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap // 柔和阴影

// 第二步:光源投射阴影
directionalLight.castShadow = true
directionalLight.shadow.mapSize.width = 1024 // 阴影贴图分辨率
directionalLight.shadow.mapSize.height = 1024
directionalLight.shadow.camera.near = 0.5
directionalLight.shadow.camera.far = 50
directionalLight.shadow.camera.left = -15 // 阴影相机范围
directionalLight.shadow.camera.right = 15
directionalLight.shadow.camera.top = 15
directionalLight.shadow.camera.bottom = -15
directionalLight.shadow.bias = -0.0001 // 防止阴影条纹

// 第三步:物体设置阴影属性
mesh.castShadow = true // 该物体会投射阴影
mesh.receiveShadow = true // 该物体表面接收阴影

4.2 阴影贴图分辨率

shadow.mapSize 决定阴影清晰度:

  • 512 — 模糊,性能好
  • 1024 — 适中(本项目使用)
  • 2048 — 清晰,性能开销大
  • 4096 — 非常清晰,仅高端设备

4.3 阴影相机范围

shadow.camera 的 left/right/top/bottom 决定了阴影覆盖范围。范围太小会导致阴影被裁切,太大会浪费分辨率。


5. 组合与层级

5.1 Group:逻辑容器

1
2
3
4
5
6
7
8
9
10
11
12
// Group 本身不可见,但可以包含子物体
const carContainer = new THREE.Group()

// 子物体相对于 Group 定位
const car = createTruck()
carContainer.add(car)

// 移动 Group 会带动所有子物体
carContainer.position.set(10, 0, 5)
carContainer.rotation.y = Math.PI / 4

scene.add(carContainer)

5.2 为什么需要容器组?

本项目中的关键设计:

1
2
3
4
5
6
7
8
// car 的视觉朝向是固定的(车头朝 +Z)
// carContainer 负责旋转,car 的视觉不受影响
carContainer.add(car)

// 移动时只旋转容器
carContainer.rotation.y = targetAngle

// car 内部的视觉结构保持不变

核心思想:分离”逻辑朝向”和”视觉朝向”。容器负责移动和旋转,内部模型保持自己的坐标系。

5.3 坐标系继承

1
2
3
4
5
6
7
World (scene)
└── carContainer (position, rotation)
└── car (local position, local rotation)
├── cabBody
├── wheel1
├── wheel2
└── ...
  • 子物体的 position 是相对于父物体的
  • 父物体移动/旋转,子物体自动跟随
  • 子物体可以有自己的局部变换

6. 构建复杂模型:半挂卡车

6.1 设计思路

卡车由多个基础几何体组合而成,分为两大区域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
卡车结构:
├── 车头 (Cab)
│ ├── 底盘
│ ├── 车身主体
│ ├── 前格栅 + 镀铬横条
│ ├── 前保险杠
│ ├── 大灯(左右)
│ ├── 挡风玻璃
│ ├── 侧窗(左右)
│ ├── 后视镜(左右)
│ ├── 车顶导流罩
│ ├── 车顶灯排
│ └── 排气管(左右)
├── 货箱 (Trailer)
│ ├── 货箱主体
│ ├── 前围
│ ├── 底护板
│ ├── 银色腰线
│ ├── 尾部装饰条纹
│ ├── 尾灯(左右)
│ └── 尾部防撞梁
└── 车轮
├── 前转向桥 ×2(单轮)
├── 驱动桥 ×4(双轮)
├── 挂车桥1 ×4(双轮)
├── 挂车桥2 ×4(双轮)
├── 挂车桥2.5 ×4(双轮)
├── 挂车桥3 ×4(双轮)
└── 轮眉 ×7

6.2 材质管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 集中定义材质,便于统一调整
const bodyMaterial = new THREE.MeshStandardMaterial({
color: '#c41e3a', // 法拉利红
roughness: 0.2, // 光滑漆面
metalness: 0.7 // 金属质感
})

const rimMaterial = new THREE.MeshStandardMaterial({
color: '#e0e8f0', // 亮银色
roughness: 0.1, // 镜面
metalness: 0.98 // 接近纯金属
})

const glassMaterial = new THREE.MeshStandardMaterial({
color: '#a8dadc', // 淡蓝玻璃
roughness: 0.05,
metalness: 0.15,
opacity: 0.6, // 半透明
transparent: true
})

6.3 车轮创建函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function createWheel(x, z) {
const wg = new THREE.Group()

// 轮胎(黑色圆柱)
const tireGeo = new THREE.CylinderGeometry(0.26, 0.26, 0.22, 28)
const tire = new THREE.Mesh(tireGeo, wheelMaterial)
tire.rotation.x = Math.PI / 2 // 圆柱默认朝 Y,旋转到朝 Z
wg.add(tire)

// 轮毂(银色圆柱)
const rimGeo = new THREE.CylinderGeometry(0.145, 0.145, 0.224, 20)
const rim = new THREE.Mesh(rimGeo, rimMaterial)
rim.rotation.x = Math.PI / 2
wg.add(rim)

// 辐条(6 根均匀分布)
for (let i = 0; i < 6; i++) {
const spokeGeo = new THREE.BoxGeometry(0.226, 0.114, 0.014)
const spoke = new THREE.Mesh(spokeGeo, rimMaterial)
spoke.rotation.y = (Math.PI / 3) * i // 每根旋转 60°
spoke.position.y = 0.07
wg.add(spoke)
}

// 轴心盖
const hubCapGeo = new THREE.CylinderGeometry(0.052, 0.052, 0.228, 16)
const hubCap = new THREE.Mesh(hubCapGeo, chromeMaterial)
hubCap.rotation.x = Math.PI / 2
wg.add(hubCap)

wg.position.set(x, 0.26, z) // wheelY = wheelRadius
return wg
}

6.4 双轮轴函数

1
2
3
4
5
6
7
8
9
10
11
function createDualWheelAxle(axleX) {
const dualGap = 0.253 // 双轮间距
const outerZ = 0.5 + 0.077 + dualGap * 0.5
const innerZ = 0.5 + 0.077 - dualGap * 0.5

// 每侧两个轮子(共 4 个)
car.add(createWheel(axleX, outerZ))
car.add(createWheel(axleX, innerZ))
car.add(createWheel(axleX, -outerZ))
car.add(createWheel(axleX, -innerZ))
}

6.5 模块化导出

1
2
3
4
5
6
7
8
9
10
// truck.js
export function createTruck({ bodyColor = '#c41e3a', trailerColor = '#d62828' } = {}) {
const car = new THREE.Group()
// ... 构建所有部件 ...
return car
}

// script.js
import { createTruck } from './truck.js'
const car = createTruck({ bodyColor: '#c41e3a', trailerColor: '#d62828' })

模块化好处

  • 主文件保持简洁
  • 卡车模型可复用
  • 修改外观只需改 truck.js
  • 支持参数化配置颜色

7. 动画循环

7.1 requestAnimationFrame 基础

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const clock = new THREE.Clock()

const tick = () => {
// 获取上一帧到现在的秒数
const deltaTime = Math.min(clock.getDelta(), 0.1)
// ^^^^^^^^^^^^^^^^^^^
// 防止切标签页回来时 deltaTime 过大

// 在这里更新所有动画...

// 渲染
renderer.render(scene, camera)

// 请求下一帧
window.requestAnimationFrame(tick)
}

tick() // 启动循环

7.2 deltaTime 的重要性

1
2
3
4
5
6
// ❌ 错误:帧率不同时移动速度不同
carContainer.position.x += 0.05

// ✅ 正确:使用 deltaTime 保证速度恒定
const speed = 1.8 // 单位/秒
carContainer.position.x += speed * deltaTime

deltaTime 是帧间隔时间(秒)。60fps 时约为 0.016,30fps 时约为 0.033。乘以 deltaTime 后,无论帧率如何,每秒移动距离相同。

7.3 平滑旋转(角度插值)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 计算目标角度
const targetAngle = Math.atan2(moveDir.x, moveDir.z)

// 当前角度
const currentAngle = carContainer.rotation.y

// 计算最短旋转路径
let angleDiff = targetAngle - currentAngle
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2

// 平滑旋转(每帧旋转差值的一部分)
carContainer.rotation.y += angleDiff * Math.min(deltaTime * 8, 1)

关键技巧

  • Math.atan2(x, z) 计算从 +Z 轴到目标方向的角度
  • while 循环确保走最短弧(不绕远路)
  • deltaTime * 8 控制旋转速度,Math.min(..., 1) 防止过冲

7.4 相机平滑跟随(Lerp)

1
2
3
4
5
6
7
8
9
10
11
12
// 目标相机位置(卡车右后方上方)
const targetCamPos = new THREE.Vector3(
carContainer.position.x + 8,
carContainer.position.y + 6,
carContainer.position.z + 8
)

// 线性插值:每帧移动剩余距离的 5%
camera.position.lerp(targetCamPos, 0.05)

// 轨道控制器目标也跟随
controls.target.lerp(carContainer.position, 0.08)

Lerp 原理A.lerp(B, t) 计算 A + (B - A) * t

  • t = 0:保持在 A
  • t = 1:瞬间到达 B
  • t = 0.05:每帧移动剩余距离的 5%,产生缓动效果

8. 相机与轨道控制器

8.1 两种相机

1
2
3
4
5
6
7
8
// 透视相机 —— 近大远小(本项目使用)
const camera = new THREE.PerspectiveCamera(50, aspect, 0.1, 100)

// 正交相机 —— 无透视,适合小地图
const minimapCamera = new THREE.OrthographicCamera(-60, 60, 60, -60, 0.1, 200)
// left right top bottom near far
minimapCamera.position.set(0, 80, 0)
minimapCamera.lookAt(0, 0, 0)

8.2 OrbitControls 轨道控制器

1
2
3
4
5
6
7
8
9
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'

const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true // 惯性阻尼
controls.dampingFactor = 0.08 // 阻尼系数
controls.target.set(0, 0.8, 0) // 注视目标点
controls.maxPolarAngle = Math.PI / 2.2 // 限制俯角(防止看到地底)
controls.enableZoom = false // 禁用缩放
controls.update()

常用配置

属性 说明
enableDamping 启用惯性,松手后平滑减速
dampingFactor 阻尼系数,越小惯性越大
maxPolarAngle 最大极角,限制相机不能翻到底部
minPolarAngle 最小极角,限制相机不能到顶部
enableZoom 是否允许滚轮缩放
enableRotate 是否允许旋转
enablePan 是否允许平移

8.3 区分拖拽和点击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let mouseDownPos = new THREE.Vector2()

canvas.addEventListener('pointerdown', (event) => {
mouseDownPos.set(event.clientX, event.clientY)
})

canvas.addEventListener('pointerup', (event) => {
const dx = event.clientX - mouseDownPos.x
const dy = event.clientY - mouseDownPos.y

// 移动距离 > 3px 视为拖拽(轨道控制),不触发点击
if (Math.sqrt(dx * dx + dy * dy) > 3) return

// 否则是点击,执行移动逻辑...
})

9. 射线检测与点击交互

9.1 Raycaster 原理

射线检测是从相机位置发出一条射线,穿过鼠标在屏幕上的位置,检测射线与哪些 3D 物体相交。

1
2
3
4
5
6
屏幕 (2D)          3D 世界
┌─────────┐ Camera
│ ● │ │
│ (x,y) │ │ 射线
│ │ ▼
└─────────┘ ════════ 地面

9.2 完整点击流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const raycaster = new THREE.Raycaster()
const mouse = new THREE.Vector2()

canvas.addEventListener('pointerup', (event) => {
// 1. 屏幕坐标 → 归一化设备坐标 (NDC)
// NDC 范围:x∈[-1,1], y∈[-1,1]
mouse.x = (event.clientX / sizes.width) * 2 - 1
mouse.y = -(event.clientY / sizes.height) * 2 + 1
// ^ 注意:屏幕 Y 轴向下,NDC Y 轴向上,需要取反

// 2. 设置射线
raycaster.setFromCamera(mouse, camera)

// 3. 检测与地面的交点
const intersects = raycaster.intersectObject(ground)

if (intersects.length > 0) {
const point = intersects[0].point // 交点 3D 坐标
targetPosition.set(point.x, 0, point.z)
isMoving = true
}
})

9.3 目标点标记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 创建半透明圆环标记
const markerGeometry = new THREE.RingGeometry(0.15, 0.25, 32)
markerGeometry.rotateX(-Math.PI / 2) // 平放在地面上

const markerMaterial = new THREE.MeshBasicMaterial({
color: '#ffff00',
side: THREE.DoubleSide,
opacity: 0.8,
transparent: true
})

const marker = new THREE.Mesh(markerGeometry, markerMaterial)
marker.visible = false
marker.renderOrder = 1 // 渲染顺序(后渲染)
marker.material.depthTest = false // 禁用深度测试(始终可见)
marker.frustumCulled = false // 禁用视锥体裁剪
scene.add(marker)

// 点击时显示
marker.position.copy(targetPosition).setY(0.02)
marker.visible = true

// 到达时隐藏
marker.visible = false

三个关键属性

  • renderOrder:值越大越后渲染,显示在最前面
  • depthTest: false:不参与深度比较,即使被遮挡也可见
  • frustumCulled: false:即使超出相机视锥体也渲染

9.4 脉冲动画

1
2
3
4
5
6
7
// 在 tick 中更新
if (marker.visible) {
const elapsedTime = clock.elapsedTime
const scale = 1 + Math.sin(elapsedTime * 6) * 0.2
marker.scale.setScalar(scale)
marker.material.opacity = 0.3 + Math.sin(elapsedTime * 6) * 0.3
}

10. 雾效与大气

10.1 雾的基本用法

1
2
3
// 线性雾:near 开始,far 完全遮挡
scene.fog = new THREE.Fog('#C8C8C8', 8, 30)
// 颜色 near far

参数说明

  • color:雾的颜色,应与场景背景色一致
  • near (8):距离相机 8 单位内无雾
  • far (30):距离相机 30 单位外完全被雾遮挡

10.2 雾的策略

1
2
3
// 地面 120×120,雾 far=30 时边缘会露馅
// 扩大地面后需要同步调整雾参数
scene.fog = new THREE.Fog('#C8C8C8', 12, 60)

经验法则far 约为地面半宽的 1/2 到 2/3,确保边缘自然消失在雾中。

10.3 雾与材质的交互

只有 MeshStandardMaterialMeshPhongMaterial 受雾影响。MeshBasicMaterial 不受雾影响,这正是小地图使用 MeshBasicMaterial 的原因之一。


11. 碰撞检测与避障

11.1 圆形碰撞检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const carRadius = 1.4  // 卡车碰撞半径

function checkCollision(x, z) {
for (const obs of obstacles) {
const dx = x - obs.mesh.position.x
const dz = z - obs.mesh.position.z
const dist = Math.sqrt(dx * dx + dz * dz)

// 两圆相交 = 碰撞
if (dist < carRadius + obs.radius) {
return true
}
}
return false
}

原理:将卡车和障碍物都简化为圆形(2D 俯视),判断圆心距离是否小于半径之和。

11.2 方向避让算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function getAvoidanceDirection(fromX, fromZ, desiredDir) {
const step = 0.3 // 探测步长

// 1. 直接方向不碰撞就直接用
const testX = fromX + desiredDir.x * step
const testZ = fromZ + desiredDir.z * step
if (!checkCollision(testX, testZ)) {
return desiredDir.clone()
}

// 2. 尝试左右偏转,从小到大找可行方向
const angles = [0.4, -0.4, 0.8, -0.8, 1.2, -1.2, 1.6, -1.6]
for (const angle of angles) {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
const newDir = new THREE.Vector3(
desiredDir.x * cos - desiredDir.z * sin,
0,
desiredDir.x * sin + desiredDir.z * cos
).normalize()

const tx = fromX + newDir.x * step
const tz = fromZ + newDir.z * step
if (!checkCollision(tx, tz)) {
return newDir
}
}

// 3. 所有方向都碰撞,原地停止
return new THREE.Vector3(0, 0, 0)
}

算法流程

  1. 先尝试直接朝目标走
  2. 被挡住则尝试 ±23°、±46°、±69°、±92° 偏转
  3. 全部被挡则返回零向量(停止)

11.3 卡住检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let stuckTimer = 0
let lastClosestDistance = Infinity

// 在移动循环中:
if (distance < lastClosestDistance - 0.01) {
// 在靠近目标,重置计时器
lastClosestDistance = distance
stuckTimer = 0
} else {
// 没有靠近,累加计时
stuckTimer += deltaTime
}

// 超过 0.8 秒无法靠近,放弃移动
if (stuckTimer > 0.8) {
isMoving = false
// 清理标记...
}

解决问题:当卡车被障碍物包围无法到达目标时,不会无限抖动,而是在 0.8 秒后自动停止。


12. 粒子特效:爆炸

12.1 粒子生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const explosionParticles = []

function triggerExplosion(position) {
const particleCount = 60

for (let i = 0; i < particleCount; i++) {
// 随机大小的小球
const pGeo = new THREE.SphereGeometry(0.06 + Math.random() * 0.1, 6, 6)

// 蓝白色调随机颜色
const hue = 0.55 + Math.random() * 0.15
const pMat = new THREE.MeshBasicMaterial({
color: new THREE.Color().setHSL(hue, 1, 0.5 + Math.random() * 0.5),
transparent: true,
opacity: 1
})

const particle = new THREE.Mesh(pGeo, pMat)
particle.position.copy(position)

// 随机方向的速度向量(球面分布)
const angle = Math.random() * Math.PI * 2
const phi = Math.random() * Math.PI * 0.5
const speed = 2 + Math.random() * 5

particle.userData = {
velocity: new THREE.Vector3(
Math.cos(phi) * Math.cos(angle) * speed,
Math.sin(phi) * speed + 1, // 额外向上速度
Math.cos(phi) * Math.sin(angle) * speed
),
life: 0.6 + Math.random() * 0.8, // 生命周期
age: 0
}

scene.add(particle)
explosionParticles.push(particle)
}
}

12.2 粒子更新与清理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 在 tick 中:
for (let i = explosionParticles.length - 1; i >= 0; i--) {
const p = explosionParticles[i]
p.userData.age += deltaTime

if (p.userData.age >= p.userData.life) {
// 生命周期结束,清理
scene.remove(p)
p.geometry.dispose() // 释放 GPU 内存
p.material.dispose()
explosionParticles.splice(i, 1)
} else {
const progress = p.userData.age / p.userData.life

// 更新位置(速度 + 重力)
p.position.x += p.userData.velocity.x * deltaTime
p.position.y += p.userData.velocity.y * deltaTime
p.position.z += p.userData.velocity.z * deltaTime
p.userData.velocity.y -= 9.8 * deltaTime // 重力加速度

// 淡出 + 缩小
p.material.opacity = 1 - progress
p.scale.setScalar(1 - progress * 0.6)
}
}

关键点

  • 倒序遍历数组,安全地在循环中删除元素
  • dispose() 释放 GPU 资源,防止内存泄漏
  • userData 存储每个粒子的自定义数据

13. 多渲染器:小地图

13.1 独立渲染器

1
2
3
4
5
6
7
8
9
10
11
// 获取小地图 canvas
const minimapCanvas = document.getElementById('minimap')

// 创建独立渲染器(透明背景)
const minimapRenderer = new THREE.WebGLRenderer({
canvas: minimapCanvas,
alpha: true, // 透明背景
antialias: true // 抗锯齿
})
minimapRenderer.setSize(200, 200)
minimapRenderer.setClearColor(0x000000, 0) // 完全透明

13.2 独立场景

1
2
3
4
5
6
7
8
9
10
// 小地图使用独立场景,避免主场景的光照、阴影、雾效干扰
const minimapScene = new THREE.Scene()

// 所有小地图物体使用 MeshBasicMaterial(不受光影响)
const minimapGround = new THREE.Mesh(
new THREE.PlaneGeometry(120, 120),
new THREE.MeshBasicMaterial({ color: '#C8C8C8' })
)
minimapGround.rotation.x = -Math.PI / 2
minimapScene.add(minimapGround)

13.3 正交相机

1
2
3
const minimapCamera = new THREE.OrthographicCamera(-60, 60, 60, -60, 0.1, 200)
minimapCamera.position.set(0, 80, 0) // 正上方俯视
minimapCamera.lookAt(0, 0, 0)

正交相机没有透视效果,适合小地图的俯视视角。

13.4 同步标记

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在 tick 中同步卡车标记
truckMarker.position.set(carContainer.position.x, 0.5, carContainer.position.z)
truckMarker.rotation.y = carContainer.rotation.y

// 钻石标记脉冲动画
if (glowingSphere) {
const pulse = 1 + Math.sin(clock.elapsedTime * 4) * 0.25
minimapSphereDot.scale.setScalar(pulse)
minimapSphereDotMat.opacity = 0.6 + Math.sin(clock.elapsedTime * 4) * 0.3
}

// 渲染小地图
minimapRenderer.render(minimapScene, minimapCamera)

13.5 自定义形状标记

1
2
3
4
5
6
7
8
9
10
11
// 使用 Shape 绘制菱形
const minimapDiamondShape = new THREE.Shape()
const dSize = 1.5
minimapDiamondShape.moveTo(0, dSize)
minimapDiamondShape.lineTo(dSize, 0)
minimapDiamondShape.lineTo(0, -dSize)
minimapDiamondShape.lineTo(-dSize, 0)
minimapDiamondShape.closePath()

const diamondGeo = new THREE.ShapeGeometry(minimapDiamondShape)
diamondGeo.rotateX(-Math.PI / 2) // 平放在地面

14. UI 叠加层:计时器

14.1 动态创建 DOM 元素

1
2
3
4
const timerEl = document.createElement('div')
timerEl.id = 'timer'
timerEl.textContent = '00:00:000'
document.body.appendChild(timerEl)

14.2 CSS 定位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#timer {
position: fixed;
top: 16px;
right: 16px;
padding: 8px 18px;
font-family: 'Courier New', monospace; /* 等宽字体,数字不跳动 */
font-size: 24px;
font-weight: bold;
color: #ffffff;
background: rgba(0, 0, 0, 0.55); /* 半透明黑底 */
border-radius: 10px;
border: 2px solid rgba(255, 255, 255, 0.3);
pointer-events: none; /* 不阻挡鼠标事件 */
user-select: none; /* 不可选中 */
}

14.3 时间格式化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 在 tick 中更新
if (!gamePaused) {
elapsedGameTime += deltaTime

const totalMs = Math.floor(elapsedGameTime * 1000)
const minutes = Math.floor(totalMs / 60000)
const seconds = Math.floor((totalMs % 60000) / 1000)
const ms = totalMs % 1000

timerEl.textContent =
`${String(minutes).padStart(2, '0')}:` +
`${String(seconds).padStart(2, '0')}:` +
`${String(ms).padStart(3, '0')}`
}

15. 游戏状态管理

15.1 状态变量

1
2
3
let gamePaused = false       // 游戏是否暂停
let isMoving = false // 卡车是否在移动
let elapsedGameTime = 0 // 游戏经过时间

15.2 状态转换

1
2
3
4
5
6
7
8
9
初始状态 ──点击地面──▶ 移动中 ──到达目标──▶ 空闲

├──被挡住──▶ 空闲

└──碰到钻石──▶ 暂停(游戏结束)

计时器停止
点击无效
卡车不可移动

15.3 暂停逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 点击事件守卫
canvas.addEventListener('pointerup', (event) => {
if (gamePaused) return // 暂停时忽略点击
// ...
})

// 移动逻辑守卫
if (isMoving && !gamePaused) {
// 只有未暂停时才移动
}

// 碰撞触发暂停
if (dist < carRadius + 0.8) {
gamePaused = true
isMoving = false
// 清理标记...
triggerExplosion(glowingSphere.position.clone())
scene.remove(glowingSphere)
scene.remove(sphereLight)
glowingSphere = null
}

16. 代码组织与模块化

16.1 项目结构

1
2
3
4
5
src/
├── index.html # HTML 骨架
├── style.css # 全局样式 + UI 组件样式
├── script.js # 主逻辑:场景、交互、动画
└── truck.js # 卡车模型模块

16.2 模块导出/导入

1
2
3
4
5
6
7
8
9
10
// truck.js —— 导出
export function createTruck({ bodyColor, trailerColor } = {}) {
const car = new THREE.Group()
// ... 构建卡车 ...
return car
}

// script.js —— 导入
import { createTruck } from './truck.js'
const car = createTruck({ bodyColor: '#c41e3a', trailerColor: '#d62828' })

16.3 代码分区原则

script.js 中,用注释分隔不同功能区域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* Base —— 场景、雾
* Lights —— 光照
* Ground —— 地面、网格
* 障碍物 —— 方盒子生成
* 发光钻石 —— 收集物
* Truck —— 卡车创建
* 目标点标记 —— UI 标记
* Sizes —— 窗口尺寸
* Camera —— 相机、轨道控制
* Renderer —— 主渲染器
* Minimap —— 小地图
* 点击移动逻辑 —— 交互
* 计时器 DOM —— UI
* 爆炸粒子效果 —— 特效
* 游戏状态 —— 状态管理
* Animate —— 动画循环
*/

附录:常见问题与调试

A.1 物体不显示?

检查清单:

  1. ✅ 是否 scene.add(mesh)
  2. ✅ 相机是否朝向物体?camera.lookAt(mesh.position)
  3. ✅ 物体是否在相机 near/far 范围内?
  4. ✅ 使用 MeshStandardMaterial 时是否有灯光?
  5. ✅ 物体是否被其他物体遮挡?

A.2 阴影不显示?

检查清单:

  1. renderer.shadowMap.enabled = true
  2. ✅ 光源 castShadow = true
  3. ✅ 投射物 castShadow = true
  4. ✅ 接收面 receiveShadow = true
  5. ✅ 阴影相机范围是否覆盖物体?

A.3 性能优化建议

优化项 方法
几何体复用 相同形状共享 Geometry 实例
材质复用 相同外观共享 Material 实例
阴影分辨率 不超过 2048
粒子数量 控制在 100 以内
像素比 Math.min(devicePixelRatio, 2)
视锥体裁剪 默认开启,标记类物体可关闭

A.4 调试技巧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 显示物体包围盒
const box = new THREE.BoxHelper(mesh, 0xff0000)
scene.add(box)

// 显示坐标轴
const axes = new THREE.AxesHelper(5)
scene.add(axes)

// 打印物体世界坐标
console.log(mesh.getWorldPosition(new THREE.Vector3()))

// 使用 dat.GUI 实时调参
import GUI from 'lil-gui'
const gui = new GUI()
gui.add(directionalLight, 'intensity', 0, 5, 0.1)

本教程基于 卡车寻钻 项目完整代码编写,涵盖了从环境搭建到复杂交互的完整 Three.js 开发流程。建议配合源码阅读,动手修改参数观察效果变化。