使用空间哈希模拟Unity物理碰撞

使用空间哈希模拟Unity物理碰撞

在使用Unity进行游戏开发的过程中,如果想要知道哪些实体产生了碰撞,或者是想要模拟物理的碰撞使实体两两不互相穿透,我们首先想到的就是使用Unity的碰撞体和刚体。但是在一些较为简单但数量级较大的需求下(比如需要上千个实体两两互不穿透),盲目使用Unity的物理系统是不划算的,这时候就可以通过自写哈希来得到实体间的距离情况,防止穿透,可以优化很多不必要的性能开销。


哈希碰撞演示

Unity物理

使用Unity物理进行碰撞检测需要碰撞双方都带有Collider组件,且其中一方需要带有Rigidbody。那么在上千实体的集群碰撞环境下,就需要所有的实体都带有Collider和Rigibody,才能保证互有碰撞不穿透。但是Rigidbody在运行时也会计算很多不必要的物理模拟效果(比如:惯性、摩擦力、弹性等),而且Unity的物理基于C++,大量频繁调用物理还会有跨越C#和C++边界这层性能消耗。

因此在需求较为简单,不需要逼真的物理效果的情况下,推荐在C#脚本侧自行模拟物理相关功能。本文记录了使用自写哈希模拟集群碰撞的解决方案。

空间哈希原理

排除掉Unity物理系统,假设场景里有 N 个球,每帧我想知道“哪些球重叠了”,最直接的做法是两两配对比较距离。如果场景中有上千个球,每帧就需要几百万次的距离计算,但是可能与一个球真正发生碰撞的,只有它附近的那几个,剩余几千个球的物理计算都是浪费的。

空间哈希就是为了减少这种浪费,它的原理简单来说就是把空间切成格子,把每个物体登记进它所在的格子;查询“我附近的物体”时,只检测自己周围几个格子。

详细的原理可以看文章:

Spatial Hash Collision理解学习 - 知乎

这篇文章中的解释非常详细,这边我就不过多赘述了。

空间哈希的实现

需要暴露三个API供外部调用:

  1. Clear() : 清空哈希
  2. Add(float x,float z) : 登记一个坐标
  3. Query(Vector3 pos, Entry[] buffer) : 查询附近实体

使用一个结构体Entry来存储实际实体坐标和其它信息。这里主要是3D场景下的前后左右移动,所以记录x轴和z轴坐标.

public struct Entry{
    public float x;
    public float z;
}

构建一个字典来存储哈希表,key由实体坐标转换而来,value代表该坐标所在格内的实体信息

private Dictionary<long, List<Entry>> hashTable=new();

坐标转换合并

着重说一下字典的key转换

由于字典只认一个 key,而格子是二维的。而int 是 32 位、long 是 64 位,两个格子坐标恰好无损装进一个 long:移位拼接是零分配的寄存器运算,key 的匹配就简化为一次 64 位整数比较。更重要的是它不是哈希而是双射——不同格子永远不会因为编码而冲突。

唯一要注意的是符号:int 转 long 时负数高位补 1,负的 z 会把 1 OR 进 x 的位段,让 (0,-1) 和 (-1,-1) 变成同一个 key;解法是拼接前先转 uint 做零扩展,让每个字段严格占用自己的 32 位——这是所有多字段位打包的通用规则。

实现起来也非常简单,输入整数坐标x,z,输出long:

private long PackKey(int x,int z)
{
    return (long)((uint)x << 32 | (uint)z);
}

坐标类型转换

场景中读取的游戏对象是float类型,要将其转换为int类型,这一步其实也就是将坐标统一到本格下标,只记录格子不记录实际坐标。这里有个小细节,把浮点坐标转格子下标,需要向下取整。如果直接强转,-1.2 和 -0.8 两个很近的物体,会被算成 -2 和 0 两个相隔很远的格子,3×3 邻域就漏了。

    private int FloatToInt(float value)
    {
        int i = (int)value;
        return i<value?i:i-1;
    }

邻域查询

Query方法参数中传入需要查询的实体坐标pos和容器buffer。这里传入调用方创建或缓存的容器,避免在Query中每次都创建。

将pos坐标转换为格下标,然后查询以该格为中心3x3范围内的实体,存入buffer

public int Query(Vector3 pos, Entry[] buffer)
    {
        var cx = FloatToInt(pos.x);
        var cz = FloatToInt(pos.z);
        int n = 0;
        int cap = buffer.Length;

        for (int x = cx - 1; x <= cx + 1; x++)
        {
            for (int z = cz - 1; z <= cz + 1; z++)
            {
                var list = GetEntryList(PackKey(x, z));
                for (int i = 0; i < list.Count; i++)
                {
                    if (n < cap)
                    {
                        buffer[n++] = list[i];
                    }
                }
            }
        }

        return n;
    }

这样就将全局遍历简化为了范围查询,大大减少了大数量级下的时间复杂度。

完整实现

public class SpatialHash
{
    public struct Entry
    {
        public float x;
        public float z;
    }

    private Dictionary<long, List<Entry>> hashTable=new();
    private float cellSize;
    
    public SpatialHash(float radius)
    {
        cellSize = radius * 2;
        cellSize = cellSize>0.01f?cellSize:1f;
    }

    public void Clear()
    {
        foreach (var keyValuePair in hashTable)
        {
            keyValuePair.Value.Clear();
        }
        hashTable.Clear();
    }

    public void Add(float x,float z)
    {
        var key = PackKey(FloatToInt(x), FloatToInt(z));
        var list = GetEntryList(key);
        list.Add(new Entry(){x=x,z=z});
    }

    public int Query(Vector3 pos, Entry[] buffer)
    {
        var cx = FloatToInt(pos.x);
        var cz = FloatToInt(pos.z);
        int n = 0;
        int cap = buffer.Length;

        for (int x = cx - 1; x <= cx + 1; x++)
        {
            for (int z = cz - 1; z <= cz + 1; z++)
            {
                var list = GetEntryList(PackKey(x, z));
                for (int i = 0; i < list.Count; i++)
                {
                    if (n < cap)
                    {
                        buffer[n++] = list[i];
                    }
                }
            }
        }

        return n;
    }

    private List<Entry> GetEntryList(long key)
    {
        if (!hashTable.TryGetValue(key, out var list))
        {
            return CreateEntryList(key);
        }
        
        return list;
    }

    private List<Entry> CreateEntryList(long key)
    {
        return hashTable[key] = new List<Entry>();
    }

    private long PackKey(int x,int z)
    {
        return (long)((uint)x << 32 | (uint)z);
    }

    private int FloatToInt(float value)
    {
        int i = (int)value;
        return i<value?i:i-1;
    }
}

模拟碰撞中的应用

空间哈希最经典的场景就是集群:几百个球在一个区域内持续乱飞,两两碰撞、永不重叠、永不停息。

直接看主循环Update:

void Update()
{
    float dt = Mathf.Min(Time.deltaTime, 0.033f);   // 切后台回来防止瞬移

    // ① 移动 + 边界反弹
    for (int i = 0; i < count; i++)
    {
        Vector3 p = balls[i].position + vel[i] * dt;
        if (p.x < -halfSize) { p.x = -halfSize; vel[i].x = Mathf.Abs(vel[i].x); }
        else if (p.x > halfSize) { p.x = halfSize; vel[i].x = -Mathf.Abs(vel[i].x); }
        if (p.z < -halfSize) { p.z = -halfSize; vel[i].z = Mathf.Abs(vel[i].z); }
        else if (p.z > halfSize) { p.z = halfSize; vel[i].z = -Mathf.Abs(vel[i].z); }
        balls[i].position = p;
    }

    // ② 重建哈希:实体都在动,旧桶内容已过期
    hash.Clear();
    for (int i = 0; i < count; i++)
        hash.Add(balls[i].position.x, balls[i].position.z, i);

    // ③ 逐个查询,消掉重叠
    for (int i = 0; i < count; i++)
        Resolve(i);
}

首先执行本帧移动逻辑,然后由于场景中的实体一直在移动,所以我这边每帧都会清空重建哈希。重建完成后对每个实体的3x3哈希区域进行查询重叠(直接调用Query接口),重叠的实体就各退0.5单位。这里的碰撞处理比较粗糙,只是一个示例。实际效果见开头gif。

还可以优化

大多数游戏不会令这么多实体同时移动碰撞,更多的情况是一部分实体原地静止等待指令但保持碰撞,另一部分实体收到指令开始移动。这种情况下每帧清空哈希重建显然是不划算的。我们可以新建两个容器分别记录静止实例和运动实例。在每帧清空哈希时不需要清空静止实例,只需要根据记录重建运动实例的哈希即可。

// 清空所有动态条目。只遍历本帧动态用过的桶,不动静态桶。
//_dynamicKeys记录所有动态实例的key
//IsStatic是静止标记
public void ClearDynamic()
{
    for (int i = 0; i < _dynamicKeys.Count; i++)
    {
        long key = _dynamicKeys[i];
        if (!hashTable.TryGetValue(key, out List<Entry> list))
        {
            continue;
        }

        // 反向遍历移除该桶内所有动态条目(桶内通常 1~3 个,可忽略)
        for (int j = list.Count - 1; j >= 0; j--)
        {
            if (!list[j].IsStatic)
            {
                list.RemoveAt(j);
            }
        }
    }

    _dynamicKeys.Clear();
}

关于Unity物理系统的思考

Unity的物理系统是很强大,它能够很好地模拟真实世界中的物理效果,包括碰撞、弹力、重力、摩擦力、速度等。但是在大多数的游戏中,我们的需求通常很简单,可能仅仅只需要众多物理模拟中的一种或者几种。这时候去使用Unity的物理系统就可能造成一些性能浪费。同时,Unity物理的一些参数可能难以把控,拿2D平台跳跃游戏举例,单纯地使用物理速度来控制角色的移动可能会面临一些常见问题:惯性、摩擦力、角色粘在墙上等等,并且会发现不管参数怎么调整都不能做到商业游戏那样优秀的手感。这时候使用一套自写的角色系统就比较好,为了追求更好的手感甚至可以将跳跃或移动过程分段,每段使用不同的函数控制位移,这也是一些成熟优秀的平台跳跃游戏的做法。

那么在什么情况下需要使用Unity的物理系统呢。我觉得在一些需要真实物理模拟的游戏中使用最佳:比如赛车、球类等一些体育游戏中。

posted @ 2026-09-10 17:24  CloverJoyi  阅读(17)  评论(0)    收藏  举报