2025-11-30 · game_math
【游戏中的数学】游戏中的数学 (14) - 随机与噪声
详解 Perlin、Simplex、Worley 三大噪声算法原理,分形噪声的倍频程叠加,以及种子管理、无缝平铺、域扭曲等工程实践。
游戏 AI 的核心任务通常是:我知道我在哪,我要去哪,怎么去? 这涉及两个层面: 1. 宏观寻路: 规划一条从 A 到 B 的路径(A* 算法)。 2. 微观移动: 如何控制速度和方向来沿着路径移动,并避开障碍(操纵行为)。 前半部分解决“走哪条路”,后半部分解决“怎么走得自然、不撞人”。
A* (A-Star) 是最著名的寻路算法。它结合了 Dijkstra 的广度优先搜索和贪婪最佳优先搜索。 核心公式: \[ F = G + H \]
启发函数 \(H\) 的选择直接影响 A* 的效率和路径质量:
| 启发函数 | 公式 | 适用场景 |
|---|---|---|
| 曼哈顿 | \(|dx| + |dy|\) | 只能四方向移动的网格 |
| 欧几里得 | \(\sqrt{dx^2 + dy^2}\) | 任意方向移动 |
| 对角线 | \(\max(|dx|, |dy|) + (\sqrt{2}-1) \min(|dx|, |dy|)\) | 允许八方向移动的网格 |
准则:启发函数必须不高估实际代价(即 Admissible),否则 A* 不保证最短路径。
A* 通常在网格 (Grid) 上运行,但现代 3D 游戏更常用 导航网格 (Navigation Mesh / NavMesh):
Unity 和 Unreal 都内置了 NavMesh 烘焙工具,实际项目中很少需要自己实现 A*,但理解原理有助于调参和排错。
A* 每一步要从 Open List 取出 F 值最小的节点,数据结构选择对性能影响巨大:
| 数据结构 | 插入 | 取最小值 | 实际表现 |
|---|---|---|---|
| 无序数组 | O(1) | O(N) | 节点多时极慢 |
| 有序数组 | O(N) | O(1) | 插入代价高 |
| 二叉堆 (Binary Heap) | O(log N) | O(log N) | ✅ 推荐,实现简单且高效 |
| Fibonacci 堆 | O(1) 摊销 | O(log N) 摊销 | 理论最优,但常数大、实现复杂,游戏中几乎不用 |
二叉堆是最佳平衡点:插入和弹出都是 O(log N),且对 CPU 缓存友好。
var openList = new PriorityQueue<Node, float>(); // 二叉堆
openList.Enqueue(startNode, startNode.F);
while (openList.Count > 0) {
Node current = openList.Dequeue();
if (current == goal) return ReconstructPath(current);
foreach (Node neighbor in GetNeighbors(current)) {
float tentativeG = current.G + Cost(current, neighbor);
if (tentativeG < neighbor.G) {
neighbor.G = tentativeG;
neighbor.F = tentativeG + Heuristic(neighbor, goal);
neighbor.Parent = current;
openList.Enqueue(neighbor, neighbor.F);
}
}
}进阶:在均匀网格上,Jump Point Search (JPS) 跳过对称路径来减少展开节点数,速度可达普通 A* 的 10 倍以上,且路径完全等价。
一旦有了路径,或者在开放空间中移动,我们需要计算每一帧的力。 Craig Reynolds 在 1986 年提出了著名的 Boids 模型来模拟鸟群。
基本公式: \[ \vec{Steering} = \vec{Desired} - \vec{Velocity} \] \[ \vec{Force} = \text{clamp}(\vec{Steering}, \text{maxForce}) \]
“别挤我!” 检查周围邻居,计算一个背离它们的向量平均值。距离越近,斥力越大。
“随大流。” 计算周围邻居的平均速度方向(Heading),并尝试转向该方向。
“别掉队。” 计算周围邻居的平均位置(Center of Mass),并尝试向该点移动。
代码实现 (Seek 行为) — 所有操纵行为的基础:
Vector3 Seek(Vector3 target) {
Vector3 desired = (target - position).normalized * maxSpeed;
Vector3 steering = desired - velocity;
return Vector3.ClampMagnitude(steering, maxForce);
}
void Update() {
Vector3 force = Seek(targetPosition);
velocity += force * Time.deltaTime;
velocity = Vector3.ClampMagnitude(velocity, maxSpeed);
position += velocity * Time.deltaTime;
if (velocity != Vector3.zero)
transform.forward = velocity;
}将上述力加权求和: \[ \vec{F}_{total} = w_1 \vec{F}_{sep} + w_2 \vec{F}_{align} + w_3 \vec{F}_{coh} + w_4 \vec{F}_{path} \] 通过调整权重,可以模拟出鱼群、鸟群甚至僵尸潮的运动模式。
加权求和后需要归一化吗?通常不需要——用
ClampMagnitude 限制最大力即可。即使权重之和大于
1,clamp 也会自然处理。
但简单 clamp 有个缺点:分离力和路径跟随力方向相反时可能互相抵消,导致 boid 卡住。更好的方案是优先级截断 (Priority-Based Truncation)——按优先级依次”消费”力预算,预算耗尽则忽略低优先级的力:
Vector3 PriorityTruncate(float maxForce) {
float budget = maxForce;
Vector3 total = Vector3.zero;
// 优先级从高到低
Vector3[] forces = { ObstacleAvoidance(), Separation(),
PathFollow(), Alignment(), Cohesion() };
foreach (var f in forces) {
if (budget <= 0f) break;
float mag = Mathf.Min(f.magnitude, budget);
total += f.normalized * mag;
budget -= mag;
}
return total;
}高优先级的力(如避障)总能获得完整预算,低优先级的力在”有余力”时才生效。
Boids 的三个行为都需要查找”感知半径内的邻居”。每帧对 N 个 boid 做两两距离检查是 O(N²)——1000 个 boid 就不可接受了。
将世界划分为均匀网格,每个 boid 映射到一个格子。查找邻居时只检查当前格子及相邻格子:
int CellSize = perceptionRadius; // 格子大小 = 感知半径
Dictionary<int, List<Boid>> grid = new();
int Hash(Vector3 pos) {
int x = Mathf.FloorToInt(pos.x / CellSize);
int z = Mathf.FloorToInt(pos.z / CellSize);
return x * 73856093 ^ z * 19349663;
}
void Rebuild(List<Boid> allBoids) {
grid.Clear();
foreach (var b in allBoids) {
int key = Hash(b.position);
if (!grid.ContainsKey(key)) grid[key] = new List<Boid>();
grid[key].Add(b);
}
}
List<Boid> GetNeighbors(Boid self) {
var result = new List<Boid>();
for (int dx = -1; dx <= 1; dx++)
for (int dz = -1; dz <= 1; dz++) {
int key = Hash(self.position + new Vector3(dx, 0, dz) * CellSize);
if (grid.TryGetValue(key, out var cell))
foreach (var b in cell)
if (b != self && Vector3.Distance(b.position, self.position) < CellSize)
result.Add(b);
}
return result;
}格子大小设为感知半径是最优选择——太小需检查更多格子,太大则每格 boid 过多。
对于 boid 分布不均匀的场景(一半密集、一半空旷),四叉树 / 八叉树 (Quadtree / Octree) 会自适应细分,效果更好。但大多数游戏中空间哈希因实现简单、缓存友好而成为首选。
在实际游戏 AI 架构中,操纵行为通常嵌入到行为树 (Behavior Tree) 中作为叶节点。行为树的选择节点 (Selector) 和序列节点 (Sequence) 决定何时激活哪些力,操纵行为负责计算具体的力向量。
例如:敌人行为树可能是”巡逻 → 发现玩家 → 追击(Seek)→ 进入攻击范围 → 攻击”,每个状态对应不同的 steering force 组合。
行为树是一个独立的大话题,这里只需记住:Steering Behaviors 是”肌肉”,Behavior Tree 是”大脑”。推荐阅读 AI for Games (Ian Millington) 了解完整架构。
把当前热点继续串成多页阅读,而不是停在单篇消费。
2025-11-30 · game_math
详解 Perlin、Simplex、Worley 三大噪声算法原理,分形噪声的倍频程叠加,以及种子管理、无缝平铺、域扭曲等工程实践。
2025-11-30 · game_math
详解游戏引擎中常用的标量数学函数:Lerp, InverseLerp, Remap, Clamp, SmoothStep 等。不仅有公式,更有实际应用场景。
2025-11-30 · game_math
深入浅出地讲解游戏开发中的三角函数:Sin, Cos, Atan2。从单位圆原理到圆周运动、波浪动画和朝向计算的实际应用。
2025-11-30 · game_math
深入理解游戏开发中的矩阵:TRS 变换与代码实践、矩阵乘法与组合变换、MVP 管线详解、逆矩阵、法线变换,以及旋转表示方式对比。
游戏 AI 的核心任务通常是:我知道我在哪,我要去哪,怎么去? 这涉及两个层面: 1. 宏观寻路: 规划一条从 A 到 B 的路径(A* 算法)。 2. 微观移动: 如何控制速度和方向来沿着路径移动,并避开障碍(操纵行为)。 前半部分解决“走哪条路”,后半部分解决“怎么走得自然、不撞人”。
A* (A-Star) 是最著名的寻路算法。它结合了 Dijkstra 的广度优先搜索和贪婪最佳优先搜索。 核心公式: \[ F = G + H \]
启发函数 \(H\) 的选择直接影响 A* 的效率和路径质量:
| 启发函数 | 公式 | 适用场景 |
|---|---|---|
| 曼哈顿 | \(|dx| + |dy|\) | 只能四方向移动的网格 |
| 欧几里得 | \(\sqrt{dx^2 + dy^2}\) | 任意方向移动 |
| 对角线 | \(\max(|dx|, |dy|) + (\sqrt{2}-1) \min(|dx|, |dy|)\) | 允许八方向移动的网格 |
准则:启发函数必须不高估实际代价(即 Admissible),否则 A* 不保证最短路径。
A* 通常在网格 (Grid) 上运行,但现代 3D 游戏更常用 导航网格 (Navigation Mesh / NavMesh):
Unity 和 Unreal 都内置了 NavMesh 烘焙工具,实际项目中很少需要自己实现 A*,但理解原理有助于调参和排错。
A* 每一步要从 Open List 取出 F 值最小的节点,数据结构选择对性能影响巨大:
| 数据结构 | 插入 | 取最小值 | 实际表现 |
|---|---|---|---|
| 无序数组 | O(1) | O(N) | 节点多时极慢 |
| 有序数组 | O(N) | O(1) | 插入代价高 |
| 二叉堆 (Binary Heap) | O(log N) | O(log N) | ✅ 推荐,实现简单且高效 |
| Fibonacci 堆 | O(1) 摊销 | O(log N) 摊销 | 理论最优,但常数大、实现复杂,游戏中几乎不用 |
二叉堆是最佳平衡点:插入和弹出都是 O(log N),且对 CPU 缓存友好。
var openList = new PriorityQueue<Node, float>(); // 二叉堆
openList.Enqueue(startNode, startNode.F);
while (openList.Count > 0) {
Node current = openList.Dequeue();
if (current == goal) return ReconstructPath(current);
foreach (Node neighbor in GetNeighbors(current)) {
float tentativeG = current.G + Cost(current, neighbor);
if (tentativeG < neighbor.G) {
neighbor.G = tentativeG;
neighbor.F = tentativeG + Heuristic(neighbor, goal);
neighbor.Parent = current;
openList.Enqueue(neighbor, neighbor.F);
}
}
}进阶:在均匀网格上,Jump Point Search (JPS) 跳过对称路径来减少展开节点数,速度可达普通 A* 的 10 倍以上,且路径完全等价。
一旦有了路径,或者在开放空间中移动,我们需要计算每一帧的力。 Craig Reynolds 在 1986 年提出了著名的 Boids 模型来模拟鸟群。
基本公式: \[ \vec{Steering} = \vec{Desired} - \vec{Velocity} \] \[ \vec{Force} = \text{clamp}(\vec{Steering}, \text{maxForce}) \]
“别挤我!” 检查周围邻居,计算一个背离它们的向量平均值。距离越近,斥力越大。
“随大流。” 计算周围邻居的平均速度方向(Heading),并尝试转向该方向。
“别掉队。” 计算周围邻居的平均位置(Center of Mass),并尝试向该点移动。
代码实现 (Seek 行为) — 所有操纵行为的基础:
Vector3 Seek(Vector3 target) {
Vector3 desired = (target - position).normalized * maxSpeed;
Vector3 steering = desired - velocity;
return Vector3.ClampMagnitude(steering, maxForce);
}
void Update() {
Vector3 force = Seek(targetPosition);
velocity += force * Time.deltaTime;
velocity = Vector3.ClampMagnitude(velocity, maxSpeed);
position += velocity * Time.deltaTime;
if (velocity != Vector3.zero)
transform.forward = velocity;
}将上述力加权求和: \[ \vec{F}_{total} = w_1 \vec{F}_{sep} + w_2 \vec{F}_{align} + w_3 \vec{F}_{coh} + w_4 \vec{F}_{path} \] 通过调整权重,可以模拟出鱼群、鸟群甚至僵尸潮的运动模式。
加权求和后需要归一化吗?通常不需要——用
ClampMagnitude 限制最大力即可。即使权重之和大于
1,clamp 也会自然处理。
但简单 clamp 有个缺点:分离力和路径跟随力方向相反时可能互相抵消,导致 boid 卡住。更好的方案是优先级截断 (Priority-Based Truncation)——按优先级依次”消费”力预算,预算耗尽则忽略低优先级的力:
Vector3 PriorityTruncate(float maxForce) {
float budget = maxForce;
Vector3 total = Vector3.zero;
// 优先级从高到低
Vector3[] forces = { ObstacleAvoidance(), Separation(),
PathFollow(), Alignment(), Cohesion() };
foreach (var f in forces) {
if (budget <= 0f) break;
float mag = Mathf.Min(f.magnitude, budget);
total += f.normalized * mag;
budget -= mag;
}
return total;
}高优先级的力(如避障)总能获得完整预算,低优先级的力在”有余力”时才生效。
Boids 的三个行为都需要查找”感知半径内的邻居”。每帧对 N 个 boid 做两两距离检查是 O(N²)——1000 个 boid 就不可接受了。
将世界划分为均匀网格,每个 boid 映射到一个格子。查找邻居时只检查当前格子及相邻格子:
int CellSize = perceptionRadius; // 格子大小 = 感知半径
Dictionary<int, List<Boid>> grid = new();
int Hash(Vector3 pos) {
int x = Mathf.FloorToInt(pos.x / CellSize);
int z = Mathf.FloorToInt(pos.z / CellSize);
return x * 73856093 ^ z * 19349663;
}
void Rebuild(List<Boid> allBoids) {
grid.Clear();
foreach (var b in allBoids) {
int key = Hash(b.position);
if (!grid.ContainsKey(key)) grid[key] = new List<Boid>();
grid[key].Add(b);
}
}
List<Boid> GetNeighbors(Boid self) {
var result = new List<Boid>();
for (int dx = -1; dx <= 1; dx++)
for (int dz = -1; dz <= 1; dz++) {
int key = Hash(self.position + new Vector3(dx, 0, dz) * CellSize);
if (grid.TryGetValue(key, out var cell))
foreach (var b in cell)
if (b != self && Vector3.Distance(b.position, self.position) < CellSize)
result.Add(b);
}
return result;
}格子大小设为感知半径是最优选择——太小需检查更多格子,太大则每格 boid 过多。
对于 boid 分布不均匀的场景(一半密集、一半空旷),四叉树 / 八叉树 (Quadtree / Octree) 会自适应细分,效果更好。但大多数游戏中空间哈希因实现简单、缓存友好而成为首选。
在实际游戏 AI 架构中,操纵行为通常嵌入到行为树 (Behavior Tree) 中作为叶节点。行为树的选择节点 (Selector) 和序列节点 (Sequence) 决定何时激活哪些力,操纵行为负责计算具体的力向量。
例如:敌人行为树可能是”巡逻 → 发现玩家 → 追击(Seek)→ 进入攻击范围 → 攻击”,每个状态对应不同的 steering force 组合。
行为树是一个独立的大话题,这里只需记住:Steering Behaviors 是”肌肉”,Behavior Tree 是”大脑”。推荐阅读 AI for Games (Ian Millington) 了解完整架构。
把当前热点继续串成多页阅读,而不是停在单篇消费。
2025-11-30 · game_math
详解 Perlin、Simplex、Worley 三大噪声算法原理,分形噪声的倍频程叠加,以及种子管理、无缝平铺、域扭曲等工程实践。
2025-11-30 · game_math
详解游戏引擎中常用的标量数学函数:Lerp, InverseLerp, Remap, Clamp, SmoothStep 等。不仅有公式,更有实际应用场景。
2025-11-30 · game_math
深入浅出地讲解游戏开发中的三角函数:Sin, Cos, Atan2。从单位圆原理到圆周运动、波浪动画和朝向计算的实际应用。
2025-11-30 · game_math
深入理解游戏开发中的矩阵:TRS 变换与代码实践、矩阵乘法与组合变换、MVP 管线详解、逆矩阵、法线变换,以及旋转表示方式对比。