什么是 SDF
SDF ,即有号距离场 Signed Distance Function,简单介绍如下:
有号距离场是一种用于表示三维空间中每个点与一个几何形状之间的相对位置关系的函数。对于空间中的任意点,有号距离场返回一个实数,这个实数表示该点与几何形状的相对位置:正数表示点在几何形状的外部,负数表示点在内部,零表示点恰好在几何形状上。
有号距离场图则是将这种函数的值以图形的形式表示出来。在图中,每个点的位置表示它对应的有号距离场的值。通常,这些图会使用颜色或灰度来表示距离场的值,例如,深色可能表示远离形状的点,浅色表示靠近形状的点。

有号距离场图在计算机图形学中有很多应用,例如:
-
图形渲染:用于定义光照效果,通过计算光线与物体表面的相对位置来模拟阴影和光照效果。
-
碰撞检测:用于检测物体之间的相对位置,以确定是否发生碰撞。
-
形态建模:用于表示和操作物体的形状,例如膨胀、收缩、扭曲等。
-
动画:用于模拟物体的运动和变形,例如皮肤拉伸和压缩。
-
寻路:利用 SDF 图为 A* 寻路的启发函数提供代价参考,极易打破对称性。
有号距离场图是一种强大的工具,它提供了一种直观的方式来理解和操作三维空间中的几何形状。
SDF 图的生成
由于 SDF 生成非常耗时,所以一般是离线生成 SDF 图。生成的关键是如何计算图中任意一点到最近多边形(障碍物)边界的距离。这里我采用循环迭代的方式来生成(这种方式较为直观,好理解):
-
遍历地图上所有格子,判定这个格子是否被障碍物(几何体)占据,将所有阻挡的格子打上阻挡标记。同时,把所有格子的 SignedDistance 设置为 MaxValue 。
-
遍历所有标记为障碍物的格子,对其八方向的格子进行判定,如果有任意一点没有阻挡标记,则视为阻挡边界。同时,将阻挡边界的 SignedDistance 设置为 0 ,且将边界格子加入循环队列。
-
从循环队列中取出一个格子,同样遍历八方向的邻居格子,计算邻居格子的 SignedDistance 。如果新的 SignedDistance 小于邻居格子已有的 SignedDistance,则将值设置到邻居格子中,并将邻居格子加入循环队列。简易伪代码如下:
private void BakeSDFPerPixel()//逐像素地烘培 SDF 图; { if (!m_SDFBakeQueue.TryDequeue(out int mapIndex))//从循环队列中获取当前格子 return; //当前SDF数据; var curSDF = SDFArray[mapIndex]; //获取当前的 SignedDistance; var curValue = curSDF.GetSignedDistance(); //将周边8个点加入队列即可; foreach (var nSdfData in 周围八方向的邻居格子) { //计算自身格子和邻居格子的距离; float distance = distance(curSDF , nSdfData); //计算其他格子应该设置的值; float nextValue = curValue + distance; var nSdfData = SDFArray[nIndex]; if (nextValue > nSdfData.GetSignedDistance()) continue;//其他格子有更小的障碍物距离 //此时邻居节点有更近的障碍物距离,将此值设置到邻居节点 nSdfData.SetSignedDistance(nextValue); SDFArray[nIndex] = nSdfData; //将邻居格子加入循环队列; m_SDFBakeQueue.Enqueue(nIndex); }
-
循环执行第 3 步,直到循环队列为空。
-
遍历所有格子,如果被打上过阻挡标记,将其 SignedDistance 取反。
碰撞与滑行
SDF 图表示空间中任意一点到其最近障碍物的距离,这里我们简单地做一个示例:

这里们可以取到 O 的坐标(x,y)。之后针对这个坐标进行二次线性采样,得到当前的 SignedDistance 。如果 SignedDistance 大于 R 则表示没有碰撞,反之则发生了碰撞。
一般在发生碰撞后,可能需要障碍物对物体施加一个作用力,一方面阻止穿模,一方面使得物体沿障碍物滑行(图中绿色箭头):
计算这个推力的时候,需要计算圆形 O 处的梯度 。梯度,简单地理解就是图像颜色变化最陡峭的方向,其模长就是变化的速率。
由于图形学的概念比较复杂, 这里就不深入研究了,这里直接给出线性插值和梯度计算的代码:
/// <summary>
/// 通过线性采样获得的颜色值;
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Color32 GetBilinearPixel(NativeArray<Color32> pixels, float x, float y, int width, int height)
{
// 计算四个最近邻点的整数坐标
int x1 = (int)math.floor(x);
int x2 = math.min((int)math.ceil(x), width - 1);
int y1 = (int)math.floor(y);
int y2 = math.min((int)math.ceil(y), height - 1);
// 计算权重
float tx = x - x1;
float ty = y - y1;
// 获取四个最近邻点的颜色值
var q11 = pixels[(y1 * width) + x1];
var q12 = pixels[(y2 * width) + x1];
var q21 = pixels[(y1 * width) + x2];
var q22 = pixels[(y2 * width) + x2];
// 进行双线性插值
var r1 = Color32.LerpUnclamped(q11, q21, tx);
var r2 = Color32.LerpUnclamped(q12, q22, tx);
var p = Color32.LerpUnclamped(r1, r2, ty);
return p;
}
/// <summary>
/// Sobel 算子用于计算水平和垂直梯度
/// </summary>
public static readonly float[,] SobelX = new float[,] {
{ -1, 0, 1 },
{ -2, 0, 2 },
{ -1, 0, 1 }
};
/// <summary>
/// Sobel 算子用于计算水平和垂直梯度
/// </summary>
public static readonly float[,] SobelY = new float[,] {
{ -1, -2, -1 },
{ 0, 0, 0 },
{ 1, 2, 1 }
};
/// <summary>
/// 计算指定浮点坐标处的梯度向量
/// </summary>
/// <param name="pixels"></param>
/// <param name="x"></param>
/// <param name="y"></param>
/// <param name="width"></param>
/// <param name="height"></param>
/// <returns></returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float2 GetGradientAtPixel(NativeArray<Color32> pixels, float x, float y, int width, int height)
{
float gx = 0;
float gy = 0;
// 使用 Sobel 算子计算梯度
for (int i = -1; i <= 1; i++)
{
for (int j = -1; j <= 1; j++)
{
float sampleX = x + i;
float sampleY = y + j;
// 在指定位置进行双线性采样
var sampledColor = GetBilinearPixel(pixels, sampleX, sampleY, width, height);
float pixelValue = sampledColor.ToInt();
gx += pixelValue * SobelX[i + 1, j + 1];
gy += pixelValue * SobelY[i + 1, j + 1];
}
}
// 归一化梯度向量
float magnitude = math.sqrt((gx * gx) + (gy * gy));
if (magnitude > 0)
{
gx /= magnitude;
gy /= magnitude;
}
return new float2(gx, gy);
}
/// <summary>
/// 将 Color32 转换为一个表示 RGBA 值的 int
/// </summary>
/// <param name="color"></param>
/// <returns></returns>
public static int ToInt(this Color32 color) => (color.r << 24) | (color.g << 16) | (color.b << 8) | color.a;
这里认为所有障碍物都可以直接拦截物体,使其朝向梯度垂直的方向移动,所以只需要求得梯度的方向就可以了。
public static float2 GetPerpendicularVector(float2 v) => new float2(-v.y, v.x);
计算梯度之后方向之后,然后将物体移动方向在梯度的垂直方向进行投影(点乘),从而获得滑行方向。这里用伪代码做个示例:
//计算梯度
var gVec = GetGradientAtPixel(SDF图, 当前位置.x, 当前位置.y, SDF图width, SDF图height);
//计算梯度垂直的方向
var sVec = GetPerpendicularVector(gVec);
//投影获取最终物体的移动方向;
float v = math.dot(当前移动方向, sVec);
最终移动方向 = sVec * v;
A*寻路的启发函数
在 A* 寻路中,会有一个 G 值计算,一般就是起点到当前位置的代价。一般游戏中,这个代价就是地块自身的属性,例如平原就比丘陵代价高些。但在这里,可以将 SDF 图的数据也写入到这个代价中。即:越靠近障碍物代价越高,越远离障碍物代价越低。
一般具体实操的时候,设置寻路的具体参数时,可以秉持这样一个原则:在寻路初期,尽量远离障碍物,走到空旷的地方;在寻路快到终点时,远离障碍物就不难么重要了,距离终点更近的权重应当更高。
如图的示例中,当从起点开始搜索后,明没有直接开始王终点更近的距离走,而是向下:向远离障碍物的方向移动。
当然,图中的例子是没有做路径优化的,所以实际距离并不是最短路径。这个是这个方案带来的副作用(就像 JPS 寻路后也可能不是最优路径一样),但这个弊端可以通过简单地路径规划来避免。