使用 SDF 来绘制物体(1)—简述、着色、阴影以及 Unity 实现
什么是 SDFSDF 是 Signed Distance Field(或 Signed Distance Function)的简称,它表示了某点距离其最近的物体的距离,这个距离是 360° 四面八方的。在实际使用时,这个值可以是实时计算出来的,也可以提前计算并记录在 3D 纹理中。
用 SDF 表示简单的物体
举个简单的例子。如果我们要使用 SDF 表示一个球体(圆心处于原点),那么可以定义一个函数float sdfSphere(float3 samplePos),它接受一个空间中的采样点,返回这个采样点与这个球体的位置关系——> 0则在球体外;= 0则在球体表面,< 0则在球体中——并且这个值的绝对值就是这个点距球体表面的距离。
float sdfSphere(float3 samplePos, float radius) {
return length(samplePos) - radius;
}
上方的代码就实现了该函数,我们可以将空间的任意一点作为参数传进去。同理,平面可以如下表示——> 0则在平面上方;= 0则在平面表面,< 0则在平面下方。
float sdfPlane(float3 samplePos, float height) {
return samplePos.y - height;
}
接下来尝试把它们组合在一个场景中。其中,球体的半径为 4,原点位于 (0, 0, 15),平面则在 y = -5 处。
float sdfScene(float3 samplePos) {
float result = 0;
result = sdfSphere(samplePos + float3(0, 0, -15), 5);
result = min(result, sdfPlane(samplePos, -5));
return result;
}
可以看到,这里组合物体的时候,使用了min函数。这是因为 SDF 的定义就是该点距离其最近的物体的距离,那么自然要记录的就是更小的那一个距离。
接下来我们尝试将这个场景绘制出来。
尝试在 Unity 中进行绘制
不同于传统的传入点的坐标,然后进行顶点到片元着色器的那一套方式,这里我们只需要关心片元着色器的这一阶段,然后使用 RayMarching 的方式来进行绘制。
更具体来说,这里需要创建一个和屏幕一样大小的平面,然后遍历每一个像素,向世界中发射射线,射线以特定步长一步一步前进,若撞到物体,则返回撞到的那个点的颜色,这样就可以将整个场景绘制出来。
文中的环境为 Windows 10,DirectX 11。注意 DirectX 11 的 NDC 中近平面的 Z 值是 1 而不是 -1 或 0。
这里一步步来,首先是创建屏幕大小的平面。根据光栅化的矩阵变换规则可知,只需在顶点着色器的返回阶段,返回 4 个近平面上四个角的 HClip 坐标即可。然后这 4 个坐标组成两个三角形,即组成了屏幕大小的矩形平面。
这里还可以优化一下。参考 SRP 和 PostProcessing 的源代码,其实只需创建一个大的三角形让其覆盖整个屏幕范围即可。
上图中,矩形为实际的视口。
public class MiscUtil {
private static Mesh fullScreenTraingleMesh;
public static Mesh FullScreenTraingleMesh {
get {
if (fullScreenTraingleMesh == null) {
fullScreenTraingleMesh = new() {
vertices = GetFullScreenTriangleVertexPosition(),
triangles = new int[] { 0, 1, 2 },
};
}
return fullScreenTraingleMesh;
}
}
public static Vector3[] GetFullScreenTriangleVertexPosition() {
var z = SystemInfo.usesReversedZBuffer ? 1 : -1;
var r = new Vector3;
for (int i = 0; i < 3; i++) {
var uv = new Vector2((i << 1) & 2, i & 2);
r = new Vector3(uv.x * 2.0f - 1.0f, uv.y * 2.0f - 1.0f, z);
}
return r;
}
}
然后是这个自定义 Mesh 对应的 Shader。在这个 Shader 中,我们只需要给片元增加一个自定义参数——该片元对应的世界坐标。
struct Attributes {
float3 positionOS : POSITION;
};
struct Varyings {
float4 positionHCS : SV_POSITION;
float3 positionWS : TEXCOORD0;
};
Varyings vert(Attributes input) {
Varyings output;
output.positionHCS = float4(input.positionOS.xy, 1.0f, 1.0f);
float4 worldPos = mul(unity_MatrixInvVP, output.positionHCS);
output.positionWS = worldPos.xyz / worldPos.w;
return output;
}
half4 frag(Varyings input) : SV_Target {
//Custom code here
}
接下来将这个自定义 Mesh 绘制出来即可。因为这里使用的是 URP,所以增加了一个 RenderFeature,然后在自定义的 Pass 中使用 CommandBuffer 进行绘制。
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) {
var cmd = CommandBufferPool.Get(Name);
try {
cmd.Clear();
cmd.DrawMesh(MiscUtil.FullScreenTraingleMesh, Matrix4x4.identity, material);
context.ExecuteCommandBuffer(cmd);
} finally {
cmd.Release();
}
}
这样准备工作就结束了,下面就是将 SDF 以及 RayMarching 相关的代码放进去。
float sdfSphere(float3 samplePos, float radius) {
return length(samplePos) - radius;
}
float sdfPlane(float3 samplePos, float height) {
return samplePos.y - height;
}
float sdfScene(float3 samplePos) {
float result = 0;
result = sdfSphere(samplePos + float3(0, 0, -15), 5);
result = min(result, sdfPlane(samplePos, -5));
return result;
}
float4 rayMarching(float3 pos, float3 dir) {
for (int step = 0; step < 512; step++) {
float d = sdfScene(pos);
if (d < 0.001f) {
return float4(1.0f, 1.0f, 1.0f, 1.0f);
}
pos += dir * d;
}
return float4(0.5f, 0.0f, 0.0f, 1.0f);
}
//...省略部分代码...//
half4 frag(Varyings input) : SV_Target {
float3 start = GetCameraPositionWS();
float3 target = input.positionWS;
float3 dir = normalize(target - start);
return rayMarching(start, dir);
}
将摄像机放到原点处,运行代码,我们就可以看到效果。
加上光照着色
接下来尝试进行光照着色,我们来为整个场景加上漫反射光以及环境光。
我们都知道,漫反射光照的公式为min(0, dot(L, N)),其中,L是着色点到光源的方向,N是着色点的法向量,并且这两个向量都是单位向量。那怎么在 SDF 中计算它们呢?
其实非常简单,在上述代码中,rayMarching方法在射线碰撞到物体的时候,我们就已经得知了这个点的世界坐标,所以到光源的L向量就可以计算出来,问题是N法向量怎么求。
这里需要一点数学知识,在 SDF 中,某一点的法向量其实就是这一点的梯度。梯度又是什么呢?只从表达式上看的话,其实它就是三个轴的偏导数组合而成的。
\left ( \frac{\partial f}{\partial x}, \frac{\partial f}{\partial y}, \frac{\partial f}{\partial z} \right )
这里再回想一点点数学知识,计算导数/偏导数,我们可以直接用以下式子:
\frac{\partial f}{\partial x} = \frac{f(x + \Delta x) - f(x)}{\Delta x}
于是代码就可以写出来了,下面就是计算某采样点法线的方法。
float3 getNormal(float3 surfacePos) {
float df = sdfScene(surfacePos);
float2 dt = float2(0.001f, 0.0f);
return normalize(float3(
sdfScene(surfacePos + dt.xyy) - df,
sdfScene(surfacePos + dt.yxy) - df,
sdfScene(surfacePos + dt.yyx) - df
));
}
于是就可以写出计算漫反射的代码。
float getLight(float3 surfacePos) {
float3 normal = getNormal(surfacePos);
return max(0.0f, dot(normal, lightDir));
}
最后修改一下rayMarching方法,返回该点的颜色即可。
float4 rayMarching(float3 pos, float3 dir) {
float3 baseColor = float3(1.0f, 1.0f, 1.0f);
float3 ambient = float3(0.05f, 0.05f, 0.05f);
for (int step = 0; step < 512; step++) {
float d = sdfScene(pos);
if (d < 0.001f) {
return float4(baseColor * getLight(pos) + ambient, 1.0f);
}
pos += dir * d;
}
return float4(0.5f, 0.0f, 0.0f, 1.0f);
}
运行,得到的结果是这样的。
加上简单的硬阴影
为了进一步增加真实性,接下来尝试加入阴影。不同于传统光栅化的方法,这里不需要 ShadowMap 之类的东西,SDF + RayMarching 在计算阴影方面有着天然的优势。我们只需从着色点向光源发射射线,若射线没有被阻挡物遮住,那它就可以接收到光,没有阴影;反之,若射线半路上碰到了遮挡物,则表示该点接收不到光,有阴影。
于是计算某点阴影的代码如下。
float calHardShadow(float3 surfacePos) {
float t = 0.5f;
for (int i = 0; i < 512; i++) {
float h = sdfScene(surfacePos + lightDir * t);
if (h < 0.001f) {
return 0.02f;
}
t += h;
}
return 1.0f;
}
然后修改一下rayMarching方法,着色时将阴影系数考虑进去即可。
float4 rayMarching(float3 pos, float3 dir) {
float3 baseColor = float3(1.0f, 1.0f, 1.0f);
float3 ambient = float3(0.05f, 0.05f, 0.05f);
for (int step = 0; step < 512; step++) {
float d = sdfScene(pos);
if (d < 0.001f) {
return float4(baseColor * getLight(pos) * calHardShadow(pos) + ambient, 1.0f);
}
pos += dir * d;
}
return float4(0.5f, 0.0f, 0.0f, 1.0f);
}
运行,得到的结果是这样的。
加上效果更柔和的软阴影
在现实生活中,软阴影/半影的出现是因为很多光源是面光源,有一些点收到了不完全的光照,从而产生了比较“稀”的阴影。
仔细观察一下可以发现,阻挡物离阴影接受物越远,半影范围越大(记为条件 1);阻挡物会造成半影的那一部分的边缘空间内,离阻挡物距离越近,阴影越浓(记为条件 2)。
所以可以增加一个累计变量,用于调整 RayMarching 过程中没有阴影的部分的着色,从而达成近似的半影效果。
float calSoftShadow(float3 surfacePos, float k) {
float res = 1.0f;
float t = 0.5f;
for (int i = 0; i < 512; i++) {
float h = sdfScene(surfacePos + lightDir * t);
if (h < 0.001f) {
return 0.02f;
}
res = min(res, k * h / t );
t += h;
}
return res;
}
上述代码中,k 是自定义系数,用于手动调整;h 是步进过程中的 SDF 值,可以用来表示上述的条件 2,h 越小阴影越浓;t 是已经步进的距离,可以用于表示上述的条件 1,t 越小半影越小。
当 k = 16.0f 时,阴影效果如下。
当 k = 8.0f 时,阴影效果如下。
虽然这只是一种 Trick,但可以看出效果还是不错的。
下一篇文章,会尝试讲述一些 SDF 进阶的使用方法。
参考资料
[*]https://iquilezles.org/articles/distfunctions/
[*]https://iquilezles.org/articles/rmshadows/
[*]Real-Time Rendering, 4th Edition, Tomas Akenine-M ̈oller etc.
本文使用 Zhihu On VSCode 创作并发布
光线步进[赞] 我们都知道,其实非常简单,这里需要一点数学知识,这里再回想一点点数学知识。 [流泪]
页:
[1]