虚幻4渲染编程(RayMarching篇)【第六卷:RayMarchingAdventure】
MY BLOG DIRECTORY:
INTRODUCTION:
总得来说RayMarching算是一个渲染的思路或者是一个渲染框架方法。RayMarching和光线追踪非常类似,但是又有一些差别,总得来看光栅渲染,Ray Marching,光线追踪三者的最大不同在于描述场景物体的方式或是渲染这些数据的方法上有很大差别。光栅渲染是一堆几何数据,Ray Marching则是距离场,光线追踪也是一堆几何图元数据不过和光栅不同的是光线追踪对几何图元渲染的方法有巨大差别。
MAIN CONTENT:
下面我使用的环境是Unity2020,UnrealEngine4.25,UnityShaderlab,HLSL。相对Unreal来说Unity的兼容性做得还是很不错的,基本上上层的一些shader语法或者是接口都能保持兼容。我想还是先从距离场开始说起。整个效果主要是Ray Marching渲染的东西。里面有很多细节的东西值得仔细思考。
这篇文章刚开始的时候过于基础了,有些配不上我新开专栏的目的,不过后面会逐渐深入,如果不像看前面这些基础的可以直接跳过到后面的部分。
(1)Signed Distance Functions
在一切开始之前我们需要有场景信息,如果是软光栅,场景信息就是那堆几何体,但是如果是RayMarching,这里使用了一个距离场(DistanceField)的概念。距离场充满了整个我们渲染的空间,当我们从视口发射一条管线时,每一步进我们会得到一个采样点,把这个采样点传入距离场,距离场会返回一个离该采样点最近表面的距离。
当这个距离足够小,我们就可以认为这条光线撞击到了物体表面。这样我们就找到了物体表面的位置。具体的渲染过程可以看我之前文章里的一个视频:视频链接
Signed Distance Functions缩写为SDF,是一个函数,当我们给这个函数传入一个空间中的一点时,这个函数会给我们返回距离这个点最近表面的距离。距离场由至少一个SDF组成。SDF里面需要实现具体的方法来完成返回“最近表面距离”这件事情,实现“这件事情”的方法目前主要有两种,一种是数学描述,一种是把距离信息储存在体纹理里直接sample。下面先介绍数学描述的办法。
以一个半径为 1 的球为例
f_{SDF}(x,y,z)=\sqrt{x^2+y^2+z^2}-1
把下面一系列点带入SDF方程可以得到如下一些结果:
f(1,0,0)=0\\ f(0,0,0.5)=-0.5\\ f(0,3,0)=2
如果把上述距离场函数写成代码如下
float SphereSDF(float3 SamplePos)
{
return length(SamplePos) - 1;
}
这里其实很容易误解,会把代码写成如下形式:
float SphereSDF_Error(float3 SamplePos, float3 SphereCenter)
{
return distance(SamplePos, SphereCenter) - 1;
}
这里其实有一种错觉,会觉得第二种才是对的,这里最好从数学公式开始推就不容易出错了,因为我们的SDF函数为:
f_{SDF}(x,y,z)=\sqrt{x^2+y^2+z^2}-1
所以按照这个写就是第一种方式。
一开始我有时候会把距离场返回的值和深度居然搞混了。每次Sample返回的就是采样点到最近表面的距离,而当这个距离很小时我们认为我们的光线撞击到了表面,返回这个撞击的点,摄像机到这个撞击点的距离才是深度。
上面介绍了半径为1的球体的SDF公式,我们把它写得更通用一点,把半径暴露出来成为变量:
float SphereSDF(float3 SamplePos, float Radius)
{
return length(SamplePos) - Radius;
}
下面来尝试构建一个无限远的平面的SDF,为了简单起见,假设这个平面的法线是水平向上的,且平面的高度为0
由此可以看到,平面距离采样点的距离就是采样点的Y值(Y轴朝上),所以我们的SDF为
f_{SDF}(x,y,z)= y
写成代码为
float PlaneSDF(float3 SamplePos)
{
return SamplePos.y;
}
如果没有理解SDF函数的定义的化看到这个写法就会很迷,我一开始的时候也是,因为在我印象里,不是应该平面要和光线求交吗!然后杂七杂八算一大堆,我事后总结可能还是之前写光线追踪的思维还残留着,导致思路没转化过来。
下面把上述平面方程写得更通用一点,设平面高度为 h
f_{SDF}(x,y,z)= y - h
float sdPlane(float3 Pos, float Height)
{
return Pos.y - Height;
}
下面继续,来推导一下Box的SDF公式
因为盒子是完全空间对称的,所以可以把四个象限的情况转化为第一象限的情况。或者是,我们构造盒子先构造第一象限的那部分,然后再以Y轴左右对称,以X轴上下对称即可构造出整个盒子。所以只需要考虑采样点P和第一象限内盒子的表面的距离关系即可。
假设盒子第一象限的长宽高为 Extend(L,W,H) 即整个盒子的长宽高其实是2L,2W,2H
第一步先来考虑一个特殊的情况,当采样点P处于盒子对角线上时,即如上图所示的P1点,那么采样点到盒子的距离为 EP_1
f_{SDF}(x,y,z)=\sqrt{(x-W)^2+(y-L)^2+(z-H)^2}
当把采样点移动到P2位置时,此时 EP_2 其实并不是我们想要的距离,正真的距离其实是 EP_2 的X轴分量,而不需要它的负的Y轴分量。所以我们的SDF公式可以改进为如下:
f_{SDF}(x,y,z)=\sqrt{(x-W)^2+(max(y-L), 0)^2+(z-H)^2}
当把采样点移动到P3位置时, EP_3 其实不是我们想要的距离,正真的距离其实时 EP_3 的Y轴分量,而不需要它的负X轴分量,所以我们的SDF公式可以改进为如下:
f_{SDF}(x,y,z)=\sqrt{max((x-W),0)^2+(max(y-L), 0)^2+(z-H)^2}
最后Z轴方向也同理所以最后我们的SDF方程为
f_{SDF}(x,y,z)=\sqrt{max((x-W),0)^2+(max(y-L), 0)^2+max((z-H), 0)^2}
写成HLSL代码:
float sdBox(float3 Pos, float3 BoxExtent)
{
float3 d = abs(Pos) - BoxExtent;
return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}
还有很多图形其实也可以用这种方法推导出它的SDF,这部分可以取看国外大佬的博客(见文末链接)
(2)Begin RayMarching
在Unity里实现就比较轻松了,建一个材质和shader
首先要构建光线,我这里直接用非常简单的方式构建,直接在场景里放一个模型,然后在像素着色器里,那块模型像素覆盖的地方的WorldPosition减去CameraPosition就是光线方向
现在有了光线下一步就是要构建距离场,距离场由SDF组成,之前已经准备好了很多SDF公式
float Scene(float3 p)
{
float d = 0;
float Sphere = sdSphere(p - float3(0, 0, 0), 4);
float Plane = sdPlane(p, -5);
float Box = sdBox(p - float3(0, 0, 10), 3);
d = Sphere;
return d;
}
准备好距离场以后就可以开始Ray Marching了。按照之前的思路,首先先在摄像机位置为起始点,查询一次距离场找到摄像机位置距离最近的表面如下图所示,查询完以后我们判断一下,可以看到这个d值很大,所以我们认为此时的采样点不是物体表面。下面沿着光线的方向向前步进,步进的长度就是刚才查询到的d值
往前步进来到了O1点
此时查询距离场,距离场函数返回一个离采样点最近表面的距离d2,此时判断一下,法线d2值还是很大,所以我们认为光线没有撞击到物体表面,所以继续沿着光线方向步进,步进长度为d2
下面继续步进来到了O2位置
查询一下距离场函数,得到了采样点O2离距离场最近表面的距离d3,此时再判断一下,d3的值已经很小了,所以此时我们认为光线击中了物体表面,所以把O2的位置返回,认为O2就是物体表面的一个点。把上述过程写成代码
//Basic Shape Functions
float sdSphere(float3 Pos, float SphereRadius)
{
return length(Pos) - SphereRadius;
}
float sdPlane(float3 Pos, float Height)
{
return Pos.y - Height;
}
float sdBox(float3 Pos, float3 BoxExtent)
{
float3 d = abs(Pos) - BoxExtent;
return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}
float Scene(float3 p)
{
float d = 0;
float Sphere = sdSphere(p - float3(0, 0, 0), 4);
float Plane = sdPlane(p, -5);
float Box = sdBox(p - float3(0, 0, 10), 3);
d = Sphere;
return d;
}
float4 Lighting(float3 ro, float3 rd)
{
float4 RetColor = { 1, 0, 0, 0 };
return RetColor;
}
float4 RayMarching(float3 ro, float3 rd)
{
for (int step = 0; step < 512; step++)
{
float d = Scene(ro);
if (d < 0.001)
{
return Lighting(ro, rd);
}
ro += rd * d;
}
return 0;
}
float4 PixelMain(VertexToPixle Input) : SV_Target
{
float4 Ret = 0;
float3 RayOrig = _WorldSpaceCameraPos;
float3 RayEnd = Input.WorldPos;
float3 RayDir = normalize(RayEnd - RayOrig);
Ret.rgb = RayMarching(RayOrig, RayDir).rgb;
return Ret;
}
最后可以得到如下结果:
现在距离场里只有一个球,如何把距离场的多个SDF合并到一起呢,很简单判断一下就好了
//Distance Feild Operation
float opU(float d1, float d2)
{
return (d1 < d2) ? d1 : d2;
}
采样点先计算第一个SDF的值,然后再计算第二个SDF的值,然后比较他们两个SDF返回最近的值就是整个距离场应该返回的最小值
效果如下:
可以多加入一些图形:
现在图形有了,我们需要对这些图形做shading,现在就只是返回了一个红色而已。要做shading现在还有很多信息不足,最重要的法线信息应该如何获取呢,只需要对距离场求梯度就可以得到法线,这里我有一篇数学推导:https://zhuanlan.zhihu.com/p/93221341
float3 CalcNormal(float3 ro)
{
float2 eps = float2(0.002, 0.0);
return normalize(float3(
Scene(ro + eps.xyy) - Scene(ro - eps.xyy),
Scene(ro + eps.yxy) - Scene(ro - eps.yxy),
Scene(ro + eps.yyx) - Scene(ro - eps.yyx)
));
}
我们的Lighting函数修改如下:
最后可得如下结果:
因为有了距离场对整个场景进行表达,所以渲染影子就变得轻而易举,只需要在表面位置重新向光源方向发射光线即可,如果向光源方向发射的光线击中了什么东西,那么就表示此位置在阴影里。
float calcSoftshadow(in float3 ro, in float3 rd, in float mint, in float tmax)
{
float res = 1.0;
float t = mint;
for (int i = 0; i < 16; i++)
{
float h = Scene(ro + rd * t);
res = min(res, 8.0 * h / t);
t += clamp(h, 0.1, 0.8);
if (h < 0.001 || t > tmax) break;
}
return clamp(res, 0.0, 1.0);
}
float calcshadow(in float3 ro, in float3 rd, in float mint, in float tmax)
{
float res = 1.0;
float t = mint;
for (int i = 0; i < 16; i++)
{
float h = Scene(ro + rd * t);
t += clamp(h, 0.5, 1);
if (h < 0.01 || t > tmax)
{
res = 0.3;
return clamp(res, 0.0, 1.0);
}
}
return clamp(res, 0.0, 1.0);
}
把阴影加上就可以得到如下效果:
同理,RayMarching凭借着有完整场景描述信息在GPU上的优势,计算反射这些也是易如反掌的事情,只需要用Reflect向量再次Marching整个场景,把Hit到的点的信息拿到就可以了
我的代码如下:
float4 ReflectLighting(float3 ro, float3 rd)
{
float3 N = CalcNormal(ro);
float3 L = _WorldSpaceLightPos0;
float3 V = rd;
float NoL = saturate(dot(N, L));
float ShadowMask = calcSoftshadow(ro, L, 0.01, 19.5);
//float ShadowMask = calcshadow(ro, L, 0.1, 100);
return NoL * ShadowMask;
}
float4 ReflectRayMarching(float3 ro, float3 rd)
{
ro += rd * 2;
for (int step = 0; step < 128; step++)
{
float d = Scene(ro);
if (d < 0.01)
{
return ReflectLighting(ro, rd);
}
ro += rd * d;
}
return float4(0.2, 0.3, 0.2, 0);
}
float4 CalcReflection(float3 ro, float3 N, float3 V)
{
float3 R = -reflect(N, V);
return ReflectRayMarching(ro, R);
}
效果如下:
https://www.zhihu.com/video/1232679945283334144因为是在像素着色器里,所以没办法跳转,如果想加入二次反弹三次反弹只能自己手写。大概思路方法就是这样了,其实Raymarching做GI这些也非常方便,非常适合用来快速研究一些图形效果,不用自己取搭建DX环境,各种架摄像机十分麻烦。
FullCode:
Shader "RayMarching/S_RayMarching"
{
Properties
{
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Cull Off
Pass
{
CGPROGRAM
#pragma vertex VertexMain
#pragma fragment PixelMain
#include "UnityCG.cginc"
#include "Lighting.cginc"
//#include "RayMarchingMaster.cginc"
struct AppData
{
float4 VertexPos : POSITION;
float2 uv : TEXCOORD0;
};
struct VertexToPixle
{
float2 uv : TEXCOORD0;
float4 SVPos : SV_POSITION;
float3 WorldPos : TEXCOORD1;
};
float3 RotateX(float3 p, float3 c, float t)
{
p -= c;
float co = cos(t);
float si = sin(t);
float2x2 mat = { co, -si, si, co };
p.yz = mul(mat, p.yz);
return p;
}
float3 RotateY(float3 p, float3 c, float t)
{
p -= c;
float co = cos(t);
float si = sin(t);
float2x2 mat = { co, -si, si, co };
p.xz = mul(mat, p.xz);
return p;
}
float3 rotateZ(in float3 p, float3 c, float t)
{
p -= c;
float co = cos(t);
float si = sin(t);
float2x2 mat = { co, -si, si, co };
p.xy = mul(mat, p.xy);
return p;
}
VertexToPixle VertexMain (AppData Input)
{
VertexToPixle Out;
Out.SVPos = UnityObjectToClipPos(Input.VertexPos);
Out.WorldPos = mul(UNITY_MATRIX_M, Input.VertexPos);
Out.uv = Input.uv;
return Out;
}
//Basic Shape Functions
float sdSphere(float3 Pos, float SphereRadius)
{
return length(Pos) - SphereRadius;
}
float sdPlane(float3 Pos, float Height)
{
return Pos.y - Height;
}
float sdBox(float3 Pos, float3 BoxExtent)
{
float3 d = abs(Pos) - BoxExtent;
return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}
float sdRoundBox(float3 Pos, float3 BoxExtent, float R)
{
float3 q = abs(Pos) - BoxExtent;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - R;
}
float sdCapsule(float3 p, float3 a, float3 b, float r)
{
float3 pa = p - a;
float3 ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h) - r;
}
float sdTorus(float3 p, float2 t)
{
return length(float2(length(p.xz) - t.x, p.y)) - t.y;
}
float dot2(float3 v) { return dot(v, v); }
float udTriangle(float3 p, float3 a, float3 b, float3 c)
{
float3 ba = b - a; float3 pa = p - a;
float3 cb = c - b; float3 pb = p - b;
float3 ac = a - c; float3 pc = p - c;
float3 nor = cross(ba, ac);
return sqrt(
(sign(dot(cross(ba, nor), pa)) +
sign(dot(cross(cb, nor), pb)) +
sign(dot(cross(ac, nor), pc)) < 2.0)
?
min(min(
dot2(ba * clamp(dot(ba, pa) / dot2(ba), 0.0, 1.0) - pa),
dot2(cb * clamp(dot(cb, pb) / dot2(cb), 0.0, 1.0) - pb)),
dot2(ac * clamp(dot(ac, pc) / dot2(ac), 0.0, 1.0) - pc))
:
dot(nor, pa) * dot(nor, pa) / dot2(nor));
}
//Distance Feild Operation
float opU(float d1, float d2)
{
return (d1 < d2) ? d1 : d2;
}
float Scene(float3 p)
{
float d = 0;
float Sphere = sdSphere(p - float3(0, 0, 0), 4);
float Plane = sdPlane(p, -5);
float Box = sdBox(RotateY(RotateX(p, float3(0, 0, 12), 0.5), 0, 0.5), 3);
float RBox = sdRoundBox(p -float3(0, 0, 25), 3, 0.5);
float RBox2 = sdRoundBox(p -float3(10, -7, 14), float3(20, 0.5, 20), 0.5);
float Capsule = sdCapsule(p - float3(15, 0, 0), float3(0, -2.5, 0), float3(0, 2.5, 0), 3);
float Torus = sdTorus(p - float3(15, 0, 12), float2(4, 1.5));
float Triangle = udTriangle(p - float3(15, 0, 25), float3(-3, 0, 0), float3(3, 0, 0), float3(0, 6, 0));
d = Sphere;
//d = opU(Plane, d);
d = opU(RBox2, d);
d = opU(Box, d);
d = opU(RBox, d);
d = opU(Capsule, d);
d = opU(Torus, d);
d = opU(Triangle, d);
return d;
}
float3 CalcNormal(float3 ro)
{
float2 eps = float2(0.002, 0.0);
return normalize(float3(
Scene(ro + eps.xyy) - Scene(ro - eps.xyy),
Scene(ro + eps.yxy) - Scene(ro - eps.yxy),
Scene(ro + eps.yyx) - Scene(ro - eps.yyx)
));
}
float calcSoftshadow(in float3 ro, in float3 rd, in float mint, in float tmax)
{
float res = 1.0;
float t = mint;
for (int i = 0; i < 16; i++)
{
float h = Scene(ro + rd * t);
res = min(res, 8.0 * h / t);
t += clamp(h, 0.1, 0.8);
if (h < 0.001 || t > tmax) break;
}
return clamp(res, 0.0, 1.0);
}
float calcshadow(in float3 ro, in float3 rd, in float mint, in float tmax)
{
float res = 1.0;
float t = mint;
for (int i = 0; i < 16; i++)
{
float h = Scene(ro + rd * t);
t += clamp(h, 0.5, 1);
if (h < 0.01 || t > tmax)
{
res = 0.3;
return clamp(res, 0.0, 1.0);
}
}
return clamp(res, 0.0, 1.0);
}
float4 ReflectLighting(float3 ro, float3 rd)
{
float3 N = CalcNormal(ro);
float3 L = _WorldSpaceLightPos0;
float3 V = rd;
float NoL = saturate(dot(N, L));
float ShadowMask = calcSoftshadow(ro, L, 0.01, 19.5);
//float ShadowMask = calcshadow(ro, L, 0.1, 100);
return NoL * ShadowMask;
}
float4 ReflectRayMarching(float3 ro, float3 rd)
{
ro += rd * 2;
for (int step = 0; step < 128; step++)
{
float d = Scene(ro);
if (d < 0.01)
{
return ReflectLighting(ro, rd);
}
ro += rd * d;
}
return float4(0.2, 0.3, 0.2, 0);
}
float4 CalcReflection(float3 ro, float3 N, float3 V)
{
float3 R = -reflect(N, V);
return ReflectRayMarching(ro, R);
}
float4 Lighting(float3 ro, float3 rd)
{
float3 N = CalcNormal(ro);
float3 L = _WorldSpaceLightPos0;
float3 V = rd;
float NoL = saturate(dot(N, L));
float ShadowMask = calcSoftshadow(ro, L, 0.01, 19.5);
float4 Reflection = CalcReflection(ro, N, V);
return (NoL + Reflection * 0.1) * ShadowMask;
}
float4 RayMarching(float3 ro, float3 rd)
{
for (int step = 0; step < 512; step++)
{
float d = Scene(ro);
if (d < 0.001)
{
return Lighting(ro, rd);
}
ro += rd * d;
}
return float4(0.2, 0.3, 0.2, 0);
}
float4 PixelMain(VertexToPixle Input) : SV_Target
{
float4 Ret = 0;
float3 RayOrig = _WorldSpaceCameraPos;
float3 RayEnd = Input.WorldPos;
float3 RayDir = normalize(RayEnd - RayOrig);
Ret.rgb = RayMarching(RayOrig, RayDir).rgb;
return Ret;
}
ENDCG
}
}
}
(3)More RayMarching Tips
RayMarching还有很多分支算法小的优化总结,其中比较有代表性的就是MetaBall的渲染和云层渲染。云层渲染可以看本篇文章末尾的Next,本篇文章的下一篇就详细讲述了云层的渲染方法,这里主要是说MetaBall。
如果用之前普通的距离场合并公式opU或者什么其它布尔运算的方式会出现如下效果:
而MetaBall则是需要两个球之间平滑过渡,这部分的数学推导可以看我之前的文章
https://zhuanlan.zhihu.com/p/37055827
这里直接上代码
// Polynomial smooth minimum by iq
float smin(float a, float b, float k)
{
float h = clamp(0.5 + 0.5 * (a - b) / k, 0.0f, 1.0f);
return lerp(a, b, h) - k * h * (1.0 - h);
}
只需要给两个球的SDF合并的时候用smin即可
效果如下:
可以再多做几个球让它们动起来,效果如下:
https://www.zhihu.com/video/1233023378706944000(4)Production RayMarching Solution
可以看到Ray Marching虽然优势明显,但是上述的Ray Marching方案却无法用于产品,最多是一些特殊的效果上可以用。其中最主要的两个原因就是效率和数据生产方式。效率的确太差,因为是纯数学Runtime计算。其次上面的那些SDF全部都是数学建模,比如我想渲染一个人头或者是别的什么模型就是十分困难,无法让团队里的艺术家加入。所以为了解决这个问题,业界采用了体纹理的方式。
把模型外面罩一层体纹理,体纹理罩模型的范围要比模型的AABBBox稍微大一点点。然后把像素距离物体的最近距离全部烘焙存到对应像素里,如果像素在物体内部那存的就是负值。这样其实就相当于SDF了。只不过把之前
d = SDFFunction(p);
变成了
d = SampleTexture3D(p);
这样做的好处就同时解决了一开始的两个痛点,第一个是物体的形状建模问题,第二个是效率问题。SampleTexture3D的速度还是很快的,其次3D纹理里的距离数据是从模型烘焙而来,所以模型艺术家可以先制作模型然后引擎可以烘焙出3D体纹理。这部分如果不是很理解还可以看鸭神的文章:https://zhuanlan.zhihu.com/p/89701518
首先我们会先将不需要渲染的物体剔除掉
剔除掉的物体的VolumeTexture数据就不会被上传。然后光线再和物体的AABBBox求交,因为距离场数据只存在于物体AABBBox所笼罩的空间里,且VolumeTexture的距离场数据只大AABBBox一点点。
如上图所示,光线和AABBBox求交以后得到P点,以P点为起始点开始距离场数据的查询。
打开UE的DistanceVisualization就可以预览到距离场数据产生的结果
但实际上我们看到的不是距离场!UE只是用距离场渲染了个能给你看的场景而已,我们目前看到的根本就不是距离场。我们打开DistanceFieldVisualization.usf和DistanceFieldVisualization.cpp。先看到DistanceFieldVisualization.usf的部分。这里可以看到UE的距离场示图返回的是 saturate(TotalStepsTaken / 200.0f)
下面我们开始改造这个DistanceFieldVisualization.usf,让它渲染出我们之前在Unity里渲染的东西。
UE的这个DistanceFieldVisualization使用的是一个ComputeShader,用深度和屏幕坐标即可还原WorldPosition从而提供光线的RayDirection
这里需要注意的是如果深度信息没有的话,这些工作将会白做,也就是说深度Buffer必须要有值才行。这里和之前我们在Unity里的做法目的是一致的。只是这里是类似全屏PostPass的处理方式。
下面是我的代码:
float Scene(float3 SampleVolumePosition, float3 LocalPositionExtent, float4 UVScaleAndVolumeScale, float3 UVAdd, float2 DistanceFieldMAD)
{
float3 ClampedSamplePosition = clamp(SampleVolumePosition, -LocalPositionExtent, LocalPositionExtent);
float3 VolumeUV = DistanceFieldVolumePositionToUV(ClampedSamplePosition, UVScaleAndVolumeScale.xyz, UVAdd);
float DistanceField = SampleMeshDistanceField(VolumeUV, DistanceFieldMAD).x;
return DistanceField;
}
float3 RayMarching(float3 WorldRayStart, float3 WorldRayEnd)
{
float3 Ret = float3(0, 0, 0);
LOOP
for (uint ListObjectIndex = 0; ListObjectIndex < min(NumIntersectingObjects, (uint) MAX_INTERSECTING_OBJECTS); ListObjectIndex++)
{
uint ObjectIndex = IntersectingObjectIndices[ListObjectIndex];
float4 SphereCenterAndRadius = LoadObjectPositionAndRadius(ObjectIndex);
float3 LocalPositionExtent = LoadObjectLocalPositionExtent(ObjectIndex);
float4x4 WorldToVolume = LoadObjectWorldToVolume(ObjectIndex);
float4 UVScaleAndVolumeScale = LoadObjectUVScale(ObjectIndex);
float3 UVAdd = LoadObjectUVAddAndSelfShadowBias(ObjectIndex).xyz;
float2 DistanceFieldMAD = LoadObjectDistanceFieldMAD(ObjectIndex);
float3 VolumeRayStart = mul(float4(WorldRayStart, 1), WorldToVolume).xyz;
float3 VolumeRayEnd = mul(float4(WorldRayEnd, 1), WorldToVolume).xyz;
float3 VolumeRayDirection = VolumeRayEnd - VolumeRayStart;
float VolumeRayLength = length(VolumeRayDirection);
VolumeRayDirection /= VolumeRayLength;
float2 IntersectionTimes = LineBoxIntersect(VolumeRayStart, VolumeRayEnd, -LocalPositionExtent, LocalPositionExtent);
if (IntersectionTimes.x < IntersectionTimes.y && IntersectionTimes.x < 1)
{
float SampleRayTime = IntersectionTimes.x * VolumeRayLength;
uint StepIndex = 0;
uint MaxSteps = 256;
LOOP
for (; StepIndex < MaxSteps; StepIndex++)
{
float3 SampleVolumePosition = VolumeRayStart + VolumeRayDirection * SampleRayTime;
float DistanceField = Scene(SampleVolumePosition, LocalPositionExtent, UVScaleAndVolumeScale, UVAdd, DistanceFieldMAD);
// Terminate the trace if we reached a negative area or went past the end of the ray
if (DistanceField < 0.001)
{
return float3(1, 0, 0);
}
float MinStepSize = 1.0f / (4 * MaxSteps);
float StepDistance = max(DistanceField, MinStepSize);
SampleRayTime += StepDistance;
}
}
}
return Ret;
}
效果如下:
首先看RayMarching函数,它是整个RayMarching过程的入口,第一个for循环是遍历需要渲染的物体的,第二个For循环才是正真开始marching。这里和之前Unity里做的最大区别就在这里,物体的距离场的合并不是按照之前的方式,而是一个一个往上画的办法。
像之前一样把CalcNormal加上
float3 CalcNormal(float3 SamplePos, float3 LocalPositionExtent, float4 UVScaleAndVolumeScale, float3 UVAdd, float2 DistanceFieldMAD)
{
float2 eps = float2(0.002, 0.0);
float SamplePosDistance = Scene(SamplePos, LocalPositionExtent, UVScaleAndVolumeScale, UVAdd, DistanceFieldMAD);
return normalize(float3(
SamplePosDistance
- Scene(SamplePos - eps.xyy, LocalPositionExtent, UVScaleAndVolumeScale, UVAdd, DistanceFieldMAD),
SamplePosDistance
- Scene(SamplePos - eps.yxy, LocalPositionExtent, UVScaleAndVolumeScale, UVAdd, DistanceFieldMAD),
SamplePosDistance
- Scene(SamplePos - eps.yyx, LocalPositionExtent, UVScaleAndVolumeScale, UVAdd, DistanceFieldMAD)
));
}
这里为了省一点,我把场景查询次数从六次减少到了四次。Normal精度其实会损失一点
效果如下:
有了法线以后把光源方向传进去
可以看到其实这种体纹理的方式来表达距离场主要是精度问题。如果精度太高,体纹理所占的空间也会很大,带宽和显存消耗也十分巨大,所以UE不会拿距离场来计算物体表面着色。第二个原因是距离场无法表达物体除距离以外的更多信息,比如UV表达起来就很费劲,更别说顶点色等信息了。所以这种方式只适合渲染一些低频信息,比如距离场软阴影,AO等。
FullCode
float Scene(float3 SampleVolumePosition, float3 LocalPositionExtent, float4 UVScaleAndVolumeScale, float3 UVAdd, float2 DistanceFieldMAD)
{
float3 ClampedSamplePosition = clamp(SampleVolumePosition, -LocalPositionExtent, LocalPositionExtent);
float3 VolumeUV = DistanceFieldVolumePositionToUV(ClampedSamplePosition, UVScaleAndVolumeScale.xyz, UVAdd);
float DistanceField = SampleMeshDistanceField(VolumeUV, DistanceFieldMAD).x;
return DistanceField;
}
float3 CalcNormal(float3 SamplePos, float3 LocalPositionExtent, float4 UVScaleAndVolumeScale, float3 UVAdd, float2 DistanceFieldMAD)
{
float2 eps = float2(0.002, 0.0);
float SamplePosDistance = Scene(SamplePos, LocalPositionExtent, UVScaleAndVolumeScale, UVAdd, DistanceFieldMAD);
return normalize(float3(
SamplePosDistance
- Scene(SamplePos - eps.xyy, LocalPositionExtent, UVScaleAndVolumeScale, UVAdd, DistanceFieldMAD),
SamplePosDistance
- Scene(SamplePos - eps.yxy, LocalPositionExtent, UVScaleAndVolumeScale, UVAdd, DistanceFieldMAD),
SamplePosDistance
- Scene(SamplePos - eps.yyx, LocalPositionExtent, UVScaleAndVolumeScale, UVAdd, DistanceFieldMAD)
));
}
float3 RayMarching(float3 WorldRayStart, float3 WorldRayEnd)
{
float3 Ret = float3(0, 0, 0);
LOOP
for (uint ListObjectIndex = 0; ListObjectIndex < min(NumIntersectingObjects, (uint) MAX_INTERSECTING_OBJECTS); ListObjectIndex++)
{
uint ObjectIndex = IntersectingObjectIndices[ListObjectIndex];
float4 SphereCenterAndRadius = LoadObjectPositionAndRadius(ObjectIndex);
float3 LocalPositionExtent = LoadObjectLocalPositionExtent(ObjectIndex);
float4x4 WorldToVolume = LoadObjectWorldToVolume(ObjectIndex);
float4 UVScaleAndVolumeScale = LoadObjectUVScale(ObjectIndex);
float3 UVAdd = LoadObjectUVAddAndSelfShadowBias(ObjectIndex).xyz;
float2 DistanceFieldMAD = LoadObjectDistanceFieldMAD(ObjectIndex);
float3 VolumeRayStart = mul(float4(WorldRayStart, 1), WorldToVolume).xyz;
float3 VolumeRayEnd = mul(float4(WorldRayEnd, 1), WorldToVolume).xyz;
float3 VolumeRayDirection = VolumeRayEnd - VolumeRayStart;
float VolumeRayLength = length(VolumeRayDirection);
VolumeRayDirection /= VolumeRayLength;
float2 IntersectionTimes = LineBoxIntersect(VolumeRayStart, VolumeRayEnd, -LocalPositionExtent, LocalPositionExtent);
if (IntersectionTimes.x < IntersectionTimes.y && IntersectionTimes.x < 1)
{
float SampleRayTime = IntersectionTimes.x * VolumeRayLength;
uint StepIndex = 0;
uint MaxSteps = 256;
LOOP
for (; StepIndex < MaxSteps; StepIndex++)
{
float3 SampleVolumePosition = VolumeRayStart + VolumeRayDirection * SampleRayTime;
float DistanceField = Scene(SampleVolumePosition, LocalPositionExtent, UVScaleAndVolumeScale, UVAdd, DistanceFieldMAD);
// Terminate the trace if we reached a negative area or went past the end of the ray
if (DistanceField < 0.001)
{
float3 N = CalcNormal(SampleVolumePosition, LocalPositionExtent, UVScaleAndVolumeScale, UVAdd, DistanceFieldMAD);
float3 L = normalize(float3(1, 1, 0.5));
return saturate(dot(N, L));
}
float MinStepSize = 1.0f / (4 * MaxSteps);
float StepDistance = max(DistanceField, MinStepSize);
SampleRayTime += StepDistance;
}
}
}
return Ret;
}
(5)Global Distance Field(Cascade Volume texture)
像上面那样挨个渲染物体其实很有问题,第一个问题就是效率,如果在小空间内,摆放了很多物体,那么势必会导致for循环执行次数过多。其次根据物体排列顺序可能会出现有部分像素被重写了。为了解决这个问题,可以使用联级的思路来解决。
开启Unreal的GlobalDistanceField便可以看到这种方式
体素纹理会从下往上开始切片,体素中心会根据摄像机位置和物体重新计算,体素的Extend尺寸是引擎写死的,第一级是2500,第二级是5000,第三级是10000,第四级是20000
Unreal的GlobalDistanceField分了四层,同样的上面的预览图其实只是GlobalDistanceField产生的可以看的一张图而已,它上面表现的数值并不是距离场数据本身!
只需要根据距离分别采集四层的联级距离场数据即可
就和之前MeshDistanceField一样,下面我们开始把上述的预览代码进行改造。
效果如下:
这里我只采了0级所以只能看到近处的物体,远处的都没有渲染
下面我把他们采进来
这里就很明显能看到联级交界产生的重叠了
像之前一样把Normal加上效果如下:
FullCode
float GlobalScene(float3 SampleVolumePosition, float3 GlobalVolumeCenter, uint ClipmapIndex)
{
float3 VolumeUV = ComputeGlobalUV(SampleVolumePosition + GlobalVolumeCenter, ClipmapIndex);
float DistanceField = SampleGlobalDistanceField(ClipmapIndex, VolumeUV).x;
return DistanceField;
}
float3 CalcGlobalNormal(float3 SampleVolumePosition, float3 GlobalVolumeCenter, uint ClipmapIndex)
{
float2 eps = float2(0.3, 0.0);
float SamplePosDistance = GlobalScene(SampleVolumePosition, GlobalVolumeCenter, ClipmapIndex);
return normalize(float3(
GlobalScene(SampleVolumePosition + eps.xyy, GlobalVolumeCenter, ClipmapIndex)
- GlobalScene(SampleVolumePosition - eps.xyy, GlobalVolumeCenter, ClipmapIndex),
GlobalScene(SampleVolumePosition + eps.yxy, GlobalVolumeCenter, ClipmapIndex)
- GlobalScene(SampleVolumePosition - eps.yxy, GlobalVolumeCenter, ClipmapIndex),
GlobalScene(SampleVolumePosition + eps.yyx, GlobalVolumeCenter, ClipmapIndex)
- GlobalScene(SampleVolumePosition - eps.yyx, GlobalVolumeCenter, ClipmapIndex)
));
}
float3 RayMarchingGlobalDistanceField(
uniform uint ClipmapIndex,
float3 WorldRayStart,
float3 WorldRayEnd,
float RayLength,
float MinRayTime,
out float OutMaxRayTime,
out float OutIntersectRayTime)
{
float3 Ret = 0;
OutIntersectRayTime = RayLength;
OutMaxRayTime = 1;
float SurfaceBias = 0.5f * GlobalVolumeCenterAndExtent[ClipmapIndex].w * GlobalVolumeTexelSize;
float3 GlobalVolumeCenter = GlobalVolumeCenterAndExtent[ClipmapIndex].xyz;
// Subtract one texel from the extent to avoid filtering from invalid texels
float GlobalVolumeExtent = GlobalVolumeCenterAndExtent[ClipmapIndex].w - GlobalVolumeCenterAndExtent[ClipmapIndex].w * GlobalVolumeTexelSize;
float3 VolumeRayStart = WorldRayStart - GlobalVolumeCenter;
float3 VolumeRayEnd = WorldRayEnd - GlobalVolumeCenter;
float3 VolumeRayDirection = VolumeRayEnd - VolumeRayStart;
float VolumeRayLength = length(VolumeRayDirection);
VolumeRayDirection /= VolumeRayLength;
float2 IntersectionTimes = LineBoxIntersect(VolumeRayStart, VolumeRayEnd, -GlobalVolumeExtent.xxx, GlobalVolumeExtent.xxx);
if (IntersectionTimes.x < IntersectionTimes.y && IntersectionTimes.x < 1)
{
OutMaxRayTime = IntersectionTimes.y;
float SampleRayTime = max(MinRayTime, IntersectionTimes.x) * VolumeRayLength;
float MinDistance = 1000000;
uint StepIndex = 0;
uint MaxSteps = 512;
LOOP
for (; StepIndex < MaxSteps; StepIndex++)
{
float3 SampleVolumePosition = VolumeRayStart + VolumeRayDirection * SampleRayTime;
float DistanceField = GlobalScene(SampleVolumePosition, GlobalVolumeCenter, ClipmapIndex);
MinDistance = min(MinDistance, DistanceField);
float MinStepSize = GlobalVolumeExtent * 2 / 8000.0f;
float StepDistance = max(DistanceField, MinStepSize);
SampleRayTime += StepDistance;
// Terminate the trace if we reached a negative area or went past the end of the ray
if (SampleRayTime > IntersectionTimes.y * VolumeRayLength)
break;
if (DistanceField < SurfaceBias)
{
Ret = CalcGlobalNormal(SampleVolumePosition, GlobalVolumeCenter, ClipmapIndex);
return Ret;
}
}
if (MinDistance < SurfaceBias || StepIndex == MaxSteps)
{
OutIntersectRayTime = min(OutIntersectRayTime, SampleRayTime);
}
}
return Ret;
}
float3 RayMarchingGlobal(float3 WorldRayStart, float3 WorldRayEnd, float TraceDistance)
{
float MaxRayTime0;
float IntersectRayTime;
float StepsTaken;
float3 Ret = 1;
Ret = RayMarchingGlobalDistanceField((uint) 0, WorldRayStart, WorldRayEnd, TraceDistance, 0, MaxRayTime0, IntersectRayTime);
//if (IntersectRayTime >= TraceDistance)
//{
// float MaxRayTime1;
// Ret += RayMarchingGlobalDistanceField((uint) 1, WorldRayStart, WorldRayEnd, TraceDistance, MaxRayTime0, MaxRayTime1, IntersectRayTime);
// if (IntersectRayTime >= TraceDistance)
// {
// float MaxRayTime2;
// Ret += RayMarchingGlobalDistanceField((uint) 2, WorldRayStart, WorldRayEnd, TraceDistance, MaxRayTime1, MaxRayTime2, IntersectRayTime);
// if (IntersectRayTime >= TraceDistance)
// {
// float MaxRayTime3;
// Ret += RayMarchingGlobalDistanceField((uint) 3, WorldRayStart, WorldRayEnd, TraceDistance, MaxRayTime2, MaxRayTime3, IntersectRayTime);
// }
// }
//}
return Ret;
}
给GlobalDistanceField一个兰伯特光照效果如下:
在找到最近点以后还可以在最近点的位置开始marching来求得整个场景的厚度,用来做一些全场景透射的效果
SUMMARY AND OUTLOOK:
目前游戏界常用的三种主要的渲染思路各有优缺点。光栅渲染的问题就是缺乏场景描述,但是速度很快,而且各种物体表面的信息齐全,不知道未来的MeshShader是否会改善这个问题。RayMarching用距离场描述整个场景,但是距离场对物体本身数据的描述就很乏力。光线追踪倒是兼具光栅和Ray marching的优势,但是劣势众所周知就是太费了,计算量太大,虽然最近的RTX各种加速优化,但是也是没有完全铺开用全光线追踪,目前光线追踪只渲染管线中的一部分,比如反射,AO。现在的游戏管线更像是各集合体,用它们的优势做它们优势擅长的那部分工作,然后把这些工作的成果拼合成最后的画面。
enjoy it。
NEXT:
By YivanLee 2020/4/12
Reference
[1]http://iquilezles.org/www/articles/distfunctions/distfunctions.htm
[2]http://jamie-wong.com/2016/07/15/ray-marching-signed-distance-functions/