Unity射线检测GC问题深度解析:从根源到优化
引言
射线检测是Unity游戏开发中最常用的技术之一,广泛应用于点击检测、视线判断、射击系统等场景。然而,不正确的射线检测用法可能导致严重的GC(垃圾回收)问题,进而影响游戏性能。本文将深入分析射线检测GC问题的根源,并提供完整的优化方案。
一、射线检测为什么会产生GC?
核心结论:射线检测本身不会产生GC,产生GC的是用于存储检测结果的数组。
在Unity中,以下射线检测API会产生GC开销:
1
2
3
4
5
// 这些API都会在每次调用时创建新的RaycastHit数组
RaycastHit[] hits1 = Physics.RaycastAll(origin, direction, distance);
RaycastHit[] hits2 = Physics.SphereCastAll(origin, radius, direction, distance);
RaycastHit[] hits3 = Physics.BoxCastAll(origin, halfExtents, direction, orientation, distance);
RaycastHit[] hits4 = Physics.CapsuleCastAll(point1, point2, radius, direction, distance);
GC产生的原理
- 数组是引用类型:RaycastHit数组在堆内存中分配
- 每次调用创建新数组:无论检测到0个还是多个物体,都会创建新数组
- 数组成为垃圾:当数组不再被使用时,等待GC回收
- GC压力累积:频繁调用导致大量垃圾,引发频繁GC
二、无GC的射线检测API
Unity提供了NonAlloc版本的射线检测API,这些API使用预分配的数组,完全避免GC开销:
1
2
3
4
5
// 预分配数组(只需创建一次)
private RaycastHit[] hitResults = new RaycastHit[50];
// 使用NonAlloc版本,复用预分配数组
int hitCount = Physics.RaycastNonAlloc(origin, direction, hitResults, distance);
NonAlloc API的工作原理
- 结果直接写入预分配数组:从数组第0个元素开始覆盖旧数据
- 返回实际检测数量:hitCount表示有效结果的数量
- 忽略旧数据:只有前hitCount个元素是有效的,后面的元素是旧数据
常用NonAlloc API列表
Physics.RaycastNonAlloc()Physics.SphereCastNonAlloc()Physics.BoxCastNonAlloc()Physics.CapsuleCastNonAlloc()
三、代码示例:错误 vs 正确用法
错误用法(产生GC)
1
2
3
4
5
6
7
8
9
10
void Update()
{
// 每帧创建新数组,产生GC
RaycastHit[] hits = Physics.RaycastAll(transform.position, transform.forward, 100f);
foreach (RaycastHit hit in hits)
{
Debug.Log(hit.collider.name);
}
}
正确用法(无GC)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 预分配数组
private RaycastHit[] hitResults = new RaycastHit[50];
void Update()
{
// 复用预分配数组,无GC
int hitCount = Physics.RaycastNonAlloc(transform.position, transform.forward, hitResults, 100f);
// 只处理前hitCount个有效结果
for (int i = 0; i < hitCount; i++)
{
RaycastHit hit = hitResults[i];
Debug.Log(hit.collider.name);
}
}
四、扇形检测的GC优化案例
扇形检测是射线检测GC问题的重灾区,因为需要发射多条射线。以下是优化前后的对比:
优化前(产生大量GC)
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
public List<GameObject> DetectEnemiesInSector()
{
List<GameObject> enemies = new List<GameObject>(); // 创建列表,产生GC
int rayCount = 15; // 15条射线覆盖扇形
float halfAngle = 30f;
for (int i = 0; i < rayCount; i++)
{
float angle = -halfAngle + (i * sectorAngle / (rayCount - 1));
Vector3 direction = Quaternion.Euler(0, angle, 0) * transform.forward;
// 每条射线都产生GC
RaycastHit[] hits = Physics.RaycastAll(transform.position, direction, 100f);
foreach (RaycastHit hit in hits)
{
GameObject enemy = hit.collider.gameObject;
if (!enemies.Contains(enemy))
{
enemies.Add(enemy); // 列表扩容可能产生GC
}
}
}
return enemies; // 返回新列表,产生GC
}
优化后(无GC)
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
// 预分配数组和列表
private RaycastHit[] hitResults = new RaycastHit[50];
private List<GameObject> detectedEnemies = new List<GameObject>(50);
private HashSet<GameObject> uniqueEnemies = new HashSet<GameObject>();
public List<GameObject> DetectEnemiesInSectorOptimized()
{
detectedEnemies.Clear();
uniqueEnemies.Clear();
int rayCount = 15;
float halfAngle = 30f;
for (int i = 0; i < rayCount; i++)
{
float angle = -halfAngle + (i * sectorAngle / (rayCount - 1));
Vector3 direction = Quaternion.Euler(0, angle, 0) * transform.forward;
// 复用数组,无GC
int hitCount = Physics.RaycastNonAlloc(transform.position, direction, hitResults, 100f);
for (int j = 0; j < hitCount; j++)
{
GameObject enemy = hitResults[j].collider.gameObject;
uniqueEnemies.Add(enemy);
}
}
// 使用HashSet去重,效率更高
detectedEnemies.AddRange(uniqueEnemies);
return detectedEnemies;
}
五、最佳实践总结
1. 根据需求选择合适的射线检测API
检测单个物体(无GC):如果只需要检测射线碰到的第一个物体,使用Physics.Raycast():
1
2
3
4
if (Physics.Raycast(origin, direction, out RaycastHit hit, distance))
{
// 处理检测结果
}
检测多个物体(有GC):如果需要检测射线碰到的所有物体,使用Physics.RaycastAll():
1
2
// 每次调用都会产生GC
RaycastHit[] hits = Physics.RaycastAll(origin, direction, distance);
检测多个物体(无GC):如果需要检测多个物体且避免GC,使用Physics.RaycastNonAlloc():
1
2
RaycastHit[] hitResults = ArrayPool<RaycastHit>.Shared.Rent(50);
int hitCount = Physics.RaycastNonAlloc(origin, direction, hitResults, distance);
核心原则:GC问题与射线数量无关,只与是否使用返回数组的API有关。即使是单条射线,使用RaycastAll()也会产生GC。
2. 多射线检测使用NonAlloc版本
1
2
3
4
5
// 预分配数组
private RaycastHit[] hitResults = new RaycastHit[100];
// 使用NonAlloc版本
int hitCount = Physics.RaycastNonAlloc(origin, direction, hitResults, distance);
3. 合理设置数组大小
- 数组大小应大于等于预期的最大检测数量
- 避免数组过大造成内存浪费
- 避免数组过小导致检测结果被截断
4. 使用对象池管理数组和列表
使用.NET ArrayPool(推荐)
对于数组对象池,推荐使用.NET内置的ArrayPool<T>,它是经过高度优化的数组对象池实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System.Buffers;
// 从ArrayPool获取预分配数组
RaycastHit[] hitResults = ArrayPool<RaycastHit>.Shared.Rent(50);
// 使用数组
int hitCount = Physics.RaycastNonAlloc(origin, direction, hitResults, distance);
// 处理检测结果
for (int i = 0; i < hitCount; i++)
{
RaycastHit hit = hitResults[i];
// 处理结果
}
// 归还数组到池中
ArrayPool<RaycastHit>.Shared.Return(hitResults);
优势:
- 线程安全,适合多线程环境
- 自动管理数组大小,避免过度分配
- 减少内存碎片,提高内存使用效率
- 无需手动实现对象池逻辑
使用自定义列表对象池
对检测结果列表使用对象池,避免频繁创建销毁:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 列表对象池
private List<GameObject> enemyListPool = new List<GameObject>(100);
// 从池中获取列表
List<GameObject> GetEnemyListFromPool()
{
if (enemyListPool.Count > 0)
{
List<GameObject> list = enemyListPool[enemyListPool.Count - 1];
enemyListPool.RemoveAt(enemyListPool.Count - 1);
list.Clear();
return list;
}
return new List<GameObject>(50);
}
// 归还列表到池中
void ReturnEnemyListToPool(List<GameObject> list)
{
list.Clear();
enemyListPool.Add(list);
}
六、总结
射线检测的GC问题完全可以避免,关键在于:
- 理解GC问题的根源:数组创建而非射线检测本身
- 选择正确的API:根据需求选择Raycast、RaycastAll或RaycastNonAlloc
- 合理管理内存:使用预分配数组或ArrayPool避免频繁创建
- 遵循最佳实践:根据检测需求选择合适的API
通过正确的优化措施,您可以在享受射线检测便利性的同时,完全消除其GC开销,为游戏性能保驾护航。
延伸阅读
- Unity官方文档:Physics.RaycastNonAlloc
- Unity性能优化指南:减少垃圾回收
- .NET ArrayPool文档:[ArrayPool
Class](https://learn.microsoft.com/en-us/dotnet/api/system.buffers.arraypool-1?view=net-7.0)