找回密码
 立即注册
查看: 442|回复: 3

使用 SDF 来绘制物体(1)—简述、着色、阴影以及 Unity 实现

[复制链接]
发表于 2023-3-23 17:22 | 显示全部楼层 |阅读模式
什么是 SDF

SDF 是 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[3];
        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 创作并发布

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2023-3-23 17:30 | 显示全部楼层
光线步进[赞]
发表于 2023-3-23 17:32 | 显示全部楼层
我们都知道,其实非常简单,这里需要一点数学知识,这里再回想一点点数学知识。
发表于 2023-3-23 17:32 | 显示全部楼层
[流泪]
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-1 08:36 , Processed in 0.107006 second(s), 28 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表