如果想直接制作,请看【第二篇】内容
这次做一个这样的东西,通过在2DRT上实时绘制,生成动态的体积纹理,也就是可以runtime的VDB设想的文章流程:
- 对原理进行学习
- 制作体积渲染
- 制作实时绘制
第一篇(本篇)是对"Creating a Volumetric Ray Marcher-Shader Bits"的学习这篇文章时间很早,因此这里在学习过程中按照UE5对原文做出兼容性修正
(为避免累赘不做出注明。链接如上,有需要自行学习)
- 本篇中出现的实现只是对原理的展示,开始制作会在下一篇
体积纹理简介
体积纹理在3D空间中存储信息,而不像我们习惯的纹理那样在2D空间中存储。虽然图形API已经支持体积纹理一段时间了,但并不是所有的游戏引擎都原生支持它们。因此,我将展示如何使用常规的2D纹理在任何引擎中创建和使用"伪体积纹理",并在UE4中演示。虽然它们不会像真正的硬件支持的体积纹理那样便宜,但它们可以帮助你进行原型设计和理解体积处理。
大多数艺术家最熟悉的与体积纹理最接近的东西是翻书或subUV动画纹理。翻书实际上是以系列形式排列的一组2D帧。翻书实际上与体积纹理非常相似:唯一的"区别"在于,对于翻书,我们将时间视为第三维度,而对于体积纹理,我们将另一个空间维度视为第三维度,通常是Z轴。这种区别是主观的,因为它们都是数据的另一个维度。
这意味着我们可以通过将时间替换为空间轴来像处理翻书一样处理体积纹理。为了演示这一点,这里是一个16x16x16体积纹理的可视化。它存储为一系列2D帧,就像翻书一样排列。每个帧存储关于空间的2D切片的信息。所有帧存储相同的X、Y位置,只有每个帧的Z位置发生变化。它总共有16帧,这与体积纹理中Z轴的大小匹配。理想情况下,这意味着每个帧的分辨率也应为16x16,以便各轴之间的分辨率相等,但在这个例子中我没有担心这个问题。
在左侧,我们可以看到所有帧垂直堆叠,表示每个帧的编码位置。为了帮助可视化,仅显示单帧的纹理内容。在右侧,你可以看到2D帧的排列方式。右侧高亮显示的帧与左侧3D切片中显示的帧相同,以展示立方体的顶部和底部如何形成体积纹理的范围(以及"翻书"的第一帧和最后一帧)。
请注意,在上述示例中,我们只是恰好使用与Z轴对齐的平面来采样体积纹理,这是它在翻书中排列的方式。我们不必以这种方式采样,实际上我们可以以任何方式采样。在下面的例子中,我将采样平面旋转了90度,但纹理的编码方式保持不变。注意如何从每个帧中读取一些像素来构造每个切片,而不是像subUV查找那样读取整个帧:
这展示了伪体积纹理如何存储3D空间的信息。只需要以正确的方式采样即可实现。关于采样的方法将在下面进一步讨论。
编码伪体积纹理
为了演示3D编码,我们将在UE的材质编辑器中使用噪声节点。首先,我们将XY坐标定义在0-1范围内,并根据每边指定的帧数重复。所有帧共享相同的XY坐标。这样实现:
c
Position.xy = frac(UV*FramesPerSide)
帧内平铺是通过在frac之后乘以坐标来完成的。
Z帧通过将每个帧的2D索引转换为1D索引,然后除以总帧数并乘以用户指定的平铺因子来改变。然后将这个Z值附加到已建立的XY坐标上,形成完整的3D位置。
采样伪体积纹理
采样相对来说比较简单,但是需要注意的是,我们实际上需要对纹理进行两次采样,以便在Z帧之间进行混合。这就是为什么我提到这些伪体积纹理会比真正的硬件体积纹理稍慢的原因。真正的体积纹理使用硬件的mip映射来"自动"进行混合,而不是我们即将进行的手动混合。
实际上,你可以使用函数"SubUV_Function"来采样这些纹理,通过指定Frame输入为首先在0-1范围内归一化的Z位置,然后乘以帧数。这个函数所做的帧混合与上面描述的双线性过滤完全相同。以下是一个自定义函数,它更易于使用,因为它接受一个3D位置输入:
手动进行这种操作的一个副作用是,我们实际上仍然可以使用mip贴图,至少在XY轴上。我们可以通过另一个混合采样将双线性mip贴图添加回Z轴,但我这里不会详细讨论这个问题。也许在未来的帖子中会涉及到这个话题。
无缝平铺
在之前的帖子中,我们讨论了如何使伪体积或翻页纹理无缝平铺。将其应用到这里应该就像在编码和采样方面应用所描述的方法一样简单。它只需要影响XY坐标。
http://shaderbits.com/blog/tiling-within-subuv-or-volume-textures
要使平铺在噪声中工作,你需要做的唯一额外的事情是检查噪声节点上的平铺选项。然后确保将缩放保持在1,并且只通过使用我在上面的编码材料示例中包含的每单元平铺参数来修改平铺。
关于内存和使用的注意事项
体积纹理通常需要高分辨率才能看起来不错。例如,一个256^3的体积纹理需要一个4096x4096的纹理。这将是一个16x16的帧数组,每个帧为256x256。一些体积纹理可以很好地处理DXT压缩,而某些类型的噪声(如Voronoi噪声)会明显受损,最好以未压缩的形式存储。目前,UE只支持带有RGBA通道的未压缩格式,因此如果你打算使用未压缩的格式,你最好将4种不同的变体打包到通道中,否则会浪费内存。
另一个注意事项:由于真正的体积纹理没有实际的mip贴图,因此没有使用二次幂的限制。这很方便,因为只有少数组合实际上可以均匀地适合二次幂纹理内,例如上面提到的256^3分辨率示例。如果你想使用二次幂,你可能需要接受XY和Z之间略微不均衡的分辨率。例如,我使用过的一种布局是在2048x2048纹理内的12x12布局。这产生了170x170x144的分辨率,虽然不是理想的,但相当均匀。
关于何时烘焙体积纹理是合理的,可以与UE4噪声节点中的FastGradient - 3D纹理选项进行比较。该选项为每个八度使用一个体积纹理,这意味着在使用默认的6个八度时,它将对体积纹理进行6次采样。这里描述的方法允许你将所有6个八度烘焙到一个纹理中,但会损失一些细节质量,因为它们会以体积纹理的整体分辨率进行采样,而不是像八度那样缩小并保持完整。这是需要记住的一点。
烘焙到纹理
要将这些烘焙到静态纹理中,你可以简单地使用这里概述的步骤:
https://www.unrealengine.com/blog/getting-the-most-out-of-noise-in-ue4
创建一个体积光线步进
生成体积纹理:在这篇文章中,我们探讨了一种通过将纹理切片存储为帧的二维数组(类似翻书)的方式来生成伪体积纹理的方法。我们将在此使用相同的方法来编码和解码我们的纹理。http://shaderbits.com/blog/authoring-pseudo-volume-textures
光线行进高度图:这篇文章描述了一种在二维平面内渲染体积阴影的方法。平面本身仍然只是一个二维表面,没有基于视角的视差效果。我们现在将在密度追踪中进行阴影追踪,以实现完整的体积效果。http://shaderbits.com/blog/ray-marched-heightmaps
自定义每个对象的阴影:这篇文章展示了如何通过从光源的视角存储深度图生成每个对象的阴影。我们将在此应用相同的技术来添加环境阴影选项。http://shaderbits.com/blog/custom-per-object-shadowmaps-using-blueprints
体积光线步进
体积渲染背后的基本概念是评估光线穿过体积时的情况。这通常意味着返回每个与体积相交的像素的不透明度和颜色。如果你的体积是一个解析函数,你可能可以直接计算结果,但如果你的体积存储在纹理中,你需要在体积中进行多次步骤,在每一步查找纹理。这可以分为两个部分:
1)不透明度(光吸收)
2)颜色(照明,散射)
不透明度采样
为了生成体积的不透明度,必须知道每个可见点的密度或厚度。如果假设体积具有恒定的密度和颜色,那么所需的只是每条光线在碰到不透明遮挡物之前的总长度。对于简单的无纹理雾,这只是场景深度,并使用标准函数 D3DFOG_EXP 重新映射。该函数定义如下:
F = 1 e t ⋅ d F = \frac{1}{e^{t \cdot d}} F=et⋅d1
其中 t 是光线在某种介质中穿过的距离,d 是介质的密度。这是游戏中计算廉价非光照雾的方法已经有一段时间了。这来自于比尔-朗伯定律(Beer-Lambert law),该定律定义了通过粒子体积的透射率:
Transmittance = e − t ⋅ d \text{Transmittance} = e^{-t \cdot d} Transmittance=e−t⋅d
这些看起来可能很相似,因为它们实际上是同一件事情。请注意, x − y x^{-y} x−y 与 1 x y \frac{1}{x^y} xy1 是相同的,所以指数雾函数实际上只是比尔-朗伯定律的一个应用版本。要理解这些函数如何应用于体积渲染,我们可以指出 Drebin的一篇旧论文中的一个方程。它描述了光线穿过体素时沿光线方向离开体素的光量。它设计用于返回在每个体素具有唯一颜色的体积的准确颜色:
C out ( v ) = C in ( v ) ⋅ ( 1 − Opacity ( x ) ) + Color ( x ) ⋅ Opacity ( x ) C_{\text{out}}(v) = C_{\text{in}}(v) \cdot (1 - \text{Opacity}(x)) + \text{Color}(x) \cdot \text{Opacity}(x) Cout(v)=Cin(v)⋅(1−Opacity(x))+Color(x)⋅Opacity(x)
C in ( v ) C_{\text{in}}(v) Cin(v) 是光线进入体素前的颜色, C out ( v ) C_{\text{out}}(v) Cout(v) 是光线通过体素后的颜色。这说明,当一束光线穿过一个体积时,在每个体素处,光的颜色将乘以当前体素的逆不透明度以模拟吸收,然后将当前体素的颜色乘以当前体素的不透明度以模拟散射。只要体积从后向前追踪,这段代码就可以正常工作。如果我们跟踪一个初始值为 1 的透射率变量,体积可以从任意方向追踪。透射率可以被视为不透明度的逆。
这就是指数函数 ( e x ) \left(e^x\right) (ex) 发挥作用的地方。类似于银行账户利息的问题,利息应用得越频繁,赚到的钱就越多,但仅限于一定的极限。这个极限由 e 定义。同样的效果在将密度积分到一个体积时也可以看到。步骤越多,最终结果越接近由指数函数或 e 的某个幂定义的解。这就是比尔-朗伯定律和 D3DFOG_EXP 函数的来源。
我们迄今为止探讨的数学为我们如何构建自定义体积渲染器提供了一些线索。我们知道需要计算每个点的体积厚度。然后,这个厚度值可以与指数密度函数一起使用,以近似计算体积会阻挡多少光。
为了采样体积的密度,沿着穿过体积的每条光线需要进行多个步骤,并在每个点读取体积纹理的值。这个例子展示了一个想象中的球体体积纹理。摄像机光线展示了在规则间隔处采样体积以测量在介质内行进距离的结果:
如果光线在某个步骤中位于介质内部,则将该步骤的长度添加到累积变量中。如果光线在某个步骤中位于介质外部,则在该步骤中不进行累积。最终,对于每个像素,我们得到了一个值,描述了摄像机光线在体积纹理中的介质内部行进的距离。由于距离还与每个点的不透明度相乘,因此返回的最终距离表示线性密度。
在上面的例子中,该距离用黄色点之间的黄色线表示。请注意,当使用较少的步骤计数时,如上例所示,距离可能与实际内容不太匹配,并且会出现切片伪影。这种伪影及其解决方案将在后文中更详细地描述。
此时,我们只是在累积线性值,并在最后返回一个线性距离。为了使其看起来具有体积感,我们使用指数函数重新映射最终值。上述提到的标准 Direct3D 指数雾函数 D3DFOG_EXP 对此效果很好。
示例仅不透明度的光线行进
Custom:
cpp
float numFrames = XYFrames * XYFrames;
float accumdist = 0;
float StepSize = 1 / MaxSteps;
for (int i = 0; i < MaxSteps; i++)
{
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, numFrames).r;
accumdist += cursample * StepSize;
CurPos += -LocalCamVec * StepSize;
}
return accumdist;
这段简单的代码使光线在指定的体积纹理中前进,覆盖纹理空间中的0-1距离,并返回穿过的颗粒物的线性密度。它绝不是完整的,并且缺少关键细节。一些部分将在稍后添加到代码中,一些细节将以材质节点的形式提供。
目前的custom代码,在最后会被尽可能地替换为原生蓝图节点以优化性能,目前以直观演示原理为主要目的
这允许你控制所需的步骤数量和帧布局。
在这个简化的示例中,使用了节点 BoundingBoxBased_0-1_UVW,因为它是获取本地 0-1 起始位置的一种简单方法。它适用于盒子或球体网格,但最终我们不会使用它,原因很快就会显而易见。
如果将其放置在 StaticMesh /Engine/EditorMeshes/EditorCube
上,并设置为 64 步,这应该是这样的:
一个随机的体积毛球,看起来很整洁!但先别急着兴奋。使用上述64步时,结果看起来相当平滑。而使用32步时,会出现奇怪的切片伪影:
这些伪影暴露了用于渲染材质的盒子几何体。它们是一种莫尔纹,源自从盒子交界面的表面精确开始追踪体积纹理。这样做会导致采样模式继续保持盒子的形状,从而产生这种图案。通过将起始位置对齐到视图对齐的平面,可以减少这些伪影。
这是一个仅使用像素着色器模拟几何切片方法的示例。虽然在运动中仍然存在切片伪影,但它们不那么明显,并且不会暴露盒子几何体,这一点非常关键。通过引入时间抖动,可以在低步数时进一步改善采样效果。以下是对齐采样的附加代码。
更多细节代码将在后面讨论完善。
cpp
// 平面对齐
// 获取对象缩放因子
// 注意:这假设体积将仅被均匀缩放。非均匀缩放将需要大量的小改动。
float scale = length( TransformLocalVectorToWorld(Parameters, float3(1.00000000,0.00000000,0.00000000)).xyz);
float worldstepsize = scale * LocalObjectBoundsMax.x*2 / MaxSteps;
float camdist = length( ResolvedView.WorldCameraOrigin - GetObjectWorldPosition(Parameters) );
float planeoffset = GetScreenPosition(Parameters).w / worldstepsize;
float actoroffset = camdist / worldstepsize;
planeoffset = frac( planeoffset - actoroffset);
float3 localcamvec = normalize( mul(Parameters.CameraVector, Primitive.WorldToLocal) );
float3 offsetvec = localcamvec * StepSize * planeoffset;
return float4(offsetvec, planeoffset * worldstepsize);
请注意,深度和ActorPosition都被考虑在内。这使得切片相对于actor稳定,因此当摄像机向前或向后移动时,不会产生移动。我暂时将其放入另一个自定义节点中。将设置部分代码与核心光线行进代码分开,有助于更容易地添加其他原始体,如球体。这不是一个嵌套的自定义节点,因为该值直接使用且仅使用一次。它从未被其他自定义节点具体调用。
下一个任务是更仔细地控制步数。你可能已经注意到,代码到目前为止是饱和射线位置以保持其在0-1空间内。这意味着每当追踪器碰到盒子的边缘时,它会继续浪费时间检查体积。此外,由于追踪距离限制为1,它也永远不会追踪到体积的角到角的完整距离,而体积的角到角距离是1.732。在目前的示例体积中,这恰好不是问题,因为内容是圆形的。解决这个问题的一种方法是在循环中检查射线是否退出体积,但这样的解决方案并不理想,因为它增加了循环的开销,而循环应该尽可能简单。更好的解决方案是预先计算适合的步数。
使用简单的原始体(如盒子或球体)有助于使用简单的数学方法来确定厚度。虽然球体可能是更高效的形状,因为它覆盖的屏幕像素较少,但盒子允许我们显示体积纹理的全部内容,并且在扭曲体积时更灵活。目前,我们将只处理使用盒子。以下是如何预先计算盒子的步数。世界->本地变换允许网格移动。请注意,这实际上改变了我们计算上述平面对齐方式的一些内容,所以我将上述代码合并到了这一部分。现在该函数直接返回本地射线进入位置和厚度:
cpp
//将向量引入局部空间以支持对象变换
//LocalCamPos
//LocalCamVec
//使摄像机位置 0-1
LocalCamPos = (LocalCamPos / (LocalObjectBoundsMax.x * 2)) + 0.5;
float3 invraydir = 1 / LocalCamVec;
float3 firstintersections = (0 - LocalCamPos) * invraydir;
float3 secondintersections = (1 - LocalCamPos) * invraydir;
float3 closest = min(firstintersections, secondintersections);
float3 furthest = max(firstintersections, secondintersections);
float t0 = max(closest.x, max(closest.y, closest.z));
float t1 = min(furthest.x, min(furthest.y, furthest.z));
float planeoffset = 1-frac( ( t0 - length(LocalCamPos-0.5) ) * MaxSteps );
t0 += (planeoffset / MaxSteps) * PlaneAlignment;
t0 = max(0, t0);
float boxthickness = max(0, t1 - t0);
float3 entrypos = LocalCamPos + (max(0,t0) * LocalCamVec);
return float4( entrypos, boxthickness );
标记为Ray Entry
的节点连接到主光线步进节点的CurPos
输入。参数Plane Alignment
允许切换对齐的开启和关闭。
请注意,部分代码现在假设您使用的是一个静态盒子网格,其枢轴位于盒子的中心,而不是盒子的底部。
排序
到目前为止,我们一直在使用几何体的局部位置来轻松从外部开始跟踪,但这不允许摄像机进入体积内部。为了支持进入内部,我们可以使用上面已经解决的盒子相交处的Ray Entry Position输出,然后翻转盒子几何体上的多边形面,使它们朝内。这是可行的,因为我们知道光线会在哪个位置与体积外部相交,也知道光线会在体积内行进多长时间。
翻转面并使用相交点将允许摄像机进入体积内部,但不会使对象正确排序。任何位于立方体内部的对象都会完全绘制在体积之上。为了解决这个问题,我们只需在计算盒子内的光线距离时考虑局部场景深度。这需要在设置函数中添加几行新代码:
cpp
//----
//这行代码放在这行之前:t0 = max(0, t0);
float scale = length( TransformLocalVectorToWorld(Parameters, float3(1.00000000,0.00000000,0.00000000)).xyz);
float localscenedepth = CalcSceneDepth(ScreenAlignedPosition(GetScreenPosition(Parameters)));
float3 camerafwd = mul(float3(0.00000000,0.00000000,1.00000000),ResolvedView.ViewToTranslatedWorld);
localscenedepth /= (LocalObjectBoundsMax.x * 2 * scale);
localscenedepth /= abs( dot( camerafwd, Parameters.CameraVector ) );
t1 = min(t1, localscenedepth);
//----
现在,在材质设置中,应将禁用深度测试
设置为true
,以便控制材质与场景的混合方式。与其他半透明对象的排序将按每个对象的基础进行,我们对此没有太多控制,但至少我们可以解决与不透明对象的排序问题。在材质设置中,还要将混合模式更改为透明度合成
,以避免半透明效果导致的边缘混合伪影。同时确保材质设置为无光照
。
现在,我们可以通过添加一个场景深度查找来生成与不透明几何体的准确排序。这会自动导致光线行进器返回正确的不透明度,因为我们停止了光线在超过场景深度后累积。然而,还有一个伪影需要修复。由于我们使用整个步长来停止光线行进,我们会看到不透明几何体与体积相交处出现阶梯状伪影。
要修复这些切片伪影,只需采取一个额外的步骤。我们跟踪到场景深度为止可以容纳的步数,然后采取最后一步,以适应剩余的部分。这确保我们最终在深度位置采取一个最终样本,从而平滑这些接缝。为了使主追踪循环尽可能简单,我们在主循环之外进行这一操作,作为附加的密度/阴影通道。
到目前为止,我们已经有了一个功能相当完善的仅密度光线行进器。正如你所看到的,着色器的核心光线行进部分可能是最简单的部分。处理不同基元的追踪行为、采样和排序问题才是棘手的部分。
光采样
为了渲染出具有说服力的光照体积,必须对光传输的行为进行建模。当光线穿过一个体积时,体积中的微粒会吸收和散射一定量的光。吸收是指有多少光能被体积损失,而散射是指有多少光被反射出去。吸收 (A) 与散射 (S) 的比率决定了微粒的漫反射亮度。
在这种情况下,为了简化和提升性能,我们只会关注一种散射:向外散射。这基本上是指有多少光线击中体积后会各向同性或漫反射地反射出去。向内散射指的是光线在体积内部的反弹,这在实时处理中通常过于昂贵,但可以通过模糊向外散射的结果来进行合理的近似。要知道给定点的向外散射,需要知道光子从光源到达该点过程中由于吸收损失了多少光能,以及光能在向眼睛方向返回体积外时会损失多少能量。
有许多技术可以计算这些值,但这篇文章主要讨论通过从每个密度样本向光源执行嵌套光线行进的暴力方法。这种方法相当昂贵,因为这意味着着色器的成本将是 DensitySteps * ShadowSteps,或 N*M。这也是目前最简单且最灵活的实现方法。
上面的例子显示了从单个摄像机光线发出的每个密度样本追踪嵌套的阴影样本。请注意,只有在体积介质内部的密度样本才需要执行阴影采样,并且如果光线到达体积边界,或者阴影密度超过了接近完全吸收的阈值,阴影循环可以提前退出。这些措施可以稍微减少剧烈的 N * M 情况。
在每个样本处,获取密度并用来确定该样本能向外散射多少光。这也会影响下一次迭代中透射率的减少量。然后,着色器向光源方向发射光线,查看有多少潜在的光能量到达该点。因此,从该点传输到摄像机的可见光受总光子路径长度和该点自身的散射系数控制。这个过程仍然可以用 Drebin 在 1988 年提出的公式来描述:
C out ( v ) = C in ( v ) ⋅ ( 1 − Opacity ( x ) ) + Color ( x ) ⋅ Opacity ( x ) C_{\text{out}}(v) = C_{\text{in}}(v) \cdot (1 - \text{Opacity}(x)) + \text{Color}(x) \cdot \text{Opacity}(x) Cout(v)=Cin(v)⋅(1−Opacity(x))+Color(x)⋅Opacity(x)
但是上述公式仅描述了一个从光源到摄像机的光路径。为了能够传播向外散射的光并计算体积的不透明度,我们需要在每个样本位置重新创建那个迭代的光线样本,朝向光源。让我们定义一些描述光照计算的基本函数。
线性密度在光线沿 x 点处定义为简单的不透明度乘以密度参数。该参数允许用户调整密度,但为了简化,从现在起将从公式中省略,因为它也可以预先乘以体积不透明度。
线性密度沿光线从点 x 到点 x' 的累积如下:
L i n e a r D e n s i t y ( x ′ , x ) = ∫ x x ′ O p a c i t y ( s ) d ( s ) LinearDensity(x',x)=\int_{x}^{x'}Opacity(s) d(s) LinearDensity(x′,x)=∫xx′Opacity(s)d(s)
因此,从点 x 到点 x' 的光线长度上的透射率定义为:
T r a n s m i t t a n c e ( x ′ , x ) = e − L i n e a r D e n s i t y ( x ′ , x ) Transmittance(x^{\prime},x)=e^{-LinearDensity(x^{\prime},x)} Transmittance(x′,x)=e−LinearDensity(x′,x)
这就是我们在上文中开始进行仅密度光线行进时计算密度的方法。为了添加光照,我们现在需要考虑沿光线的每个点的光散射和吸收。这涉及嵌套一系列这些项。在体积内的点 x 处,从方向 w 的光源到达该点的向外散射量等于:
O u t S c a t t e r i n g ( x , w → ) = e − L i n e a r D e n s i t y ( x , l ) OutScattering(x, \overrightarrow{w})=e^{-LinearDensity(x,l)} OutScattering(x,w )=e−LinearDensity(x,l)
这里 w 是光的方向,l 是体积外朝向负光方向的一个点。术语 -LinearDensity(x, l) 表示从点 x 朝向光源直到体积边界的线性密度累积,代表能吸收光的颗粒物的数量。请注意,这仍然仅是该点可见光的数量值,尚未考虑基于样本不透明度吸收的那部分光。为此,OutScattering 项需要乘以 Opacity(x)。它也未考虑到光线从体积内再次射出时的进一步传输损失。为了考虑这种损失,必须确定从摄像机到点 x 的透射率。
我们可以创建一个修改后的函数 TotalOutScattering(x', w),该函数描述了从点 x 到点 x' 沿光线 w 可见的向外散射量,而不仅仅是描述单个点:
T o t a l O u t S c a t t e r i n g ( x ′ , w → ) = ∫ x x ′ O S ( s , w → ) ∗ T ( x ′ , s ) d ( s ) TotalOutScattering(x',\overrightarrow{w})=\int_{x}^{x'}OS(s,\overrightarrow{w})*T(x',s) d(s) TotalOutScattering(x′,w )=∫xx′OS(s,w )∗T(x′,s)d(s)
请注意,OS 和 T 分别是上述 OutScattering 和 Transmission 项的缩写。OS 还应该乘以 Opacity(s),这是我之前忘记添加的,但以后可能会重新创建表达式。这个函数将返回沿视线穿过体积的所有点的总散射量。实际上,这是几个嵌套积分,展开形式太复杂了,不值得费心写出来,因此我们不妨直接处理代码本身。像 OutScattering 这样的术语通常在开始时隐含地乘以光的颜色和漫反射颜色。
传统上,你可能会在其他论文中看到这个方程写成 Radiance (L),但我没有包括在内,因为辐射度还需要考虑传输到体积中的背景颜色量,这基本上只是 SceneColor * FinalOpacity。由于一些相对随意的决定,我们不会在这里添加这些内容:
我们不会像那样混合背景颜色。相反,我们将使用 AlphaComposite 混合模式并插入我们的不透明度。
我们实际上不会模糊或散射背景颜色,所以我不会过多讨论这个术语。有关完整数学的更多详细信息,请参见 Shopf [2]。本页上的许多数学内容基于该页的方程式,但我试图通过使用真实的词语而不是希腊符号,并以更简化的方式解释关系,使它们对艺术家更友好。
阴影体积示例代码
cpp
float numFrames = XYFrames * XYFrames;
float curdensity = 0;
float transmittance = 1;
float3 LocalCamVec = normalize( mul(Parameters.CameraVector, Primitive.WorldToLocal) ) * StepSize;
float shadowstepsize = 1 / ShadowSteps;
LightVector *= shadowstepsize;
ShadowDensity *= shadowstepsize;
Density *= StepSize;
float3 lightenergy = 0;
for (int i = 0; i < MaxSteps; i++)
{
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, numFrames).r;
// 采样光吸收和散射
if( cursample > 0.001)
{
float3 lpos = CurPos;
float shadowdist = 0;
for (int s = 0; s < ShadowSteps; s++)
{
lpos += LightVector;
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
}
curdensity = saturate(cursample * Density);
float shadowterm = exp(-shadowdist * ShadowDensity);
float3 absorbedlight = shadowterm * curdensity;
lightenergy += absorbedlight * transmittance;
transmittance *= 1-curdensity;
}
CurPos -= LocalCamVec;
}
return float4( lightenergy, transmittance);
正如你所见,仅仅添加基本的阴影处理就给我们开始时的简单密度追踪器增加了相当大的复杂性。
请注意,在这个版本中,摄像机向量和光向量在循环外部一开始就被分别乘以它们各自的步长。这是因为阴影追踪使着色器计算更加昂贵,因此我们希望尽可能将操作移到循环外部(尤其是内部循环)。
在当前形式下,上述着色器代码仍然非常慢。我们确实添加了一项优化:着色器仅在体素的不透明度 > 0.001 时才进行评估。如果我们的体积纹理有很多空白区域,这可能会节省大量时间,但如果整个体积都被写入,则完全没有帮助。我们需要更多的优化来使这个着色器变得实用。
上述版本最大的问题是它将在所有密度采样点运行所有阴影步骤。因此,如果我们使用类似于64个密度步骤和64个阴影步骤,那将是4096个采样。因为我们的伪体积函数需要2次查找,这意味着我们的着色器将每像素进行8192次纹理查找!这非常糟糕,但我们可以通过在光线离开体积或达到完全吸收时尽早退出来显著优化它。
第一部分可以通过在每个阴影迭代中检查光线是否已经离开体积来处理。这将类似于:
cpp
if(lpos.x > 1 || lpos.x < 0 || lpos.y > 1 || lpos.y < 0 || lpos.z > 1 || lpos.z < 0) break;
虽然这样的检查有效,但由于阴影循环运行了很多次,结果它相当慢。我还尝试过在每个阴影循环之前预先计算阴影步骤的数量,这与我为盒形预先计算密度迭代次数的方法非常相似。令人惊讶的是,事实证明那是最慢的方法。我目前发现的最快的提前终止阴影循环的方法是使用这个简单的盒测试数学:
cpp
float3 shadowboxtest = floor( 0.5 + ( abs( 0.5 - lpos ) ) );
float exitshadowbox = shadowboxtest .x + shadowboxtest .y + shadowboxtest .z;
if(exitshadowbox >= 1) break;
接下来我们需要添加的是基于吸收阈值的提前终止。通常这意味着一旦透射率低于某个小数值(例如0.001),你就可以退出阴影循环。这个阈值越大,出现的伪影就越多,因此应该将这个值调整到在视觉上可以接受的最大值。
如果我们通过在每个点将光透射率乘以不透明度的倒数来编写阴影行进循环,那么我们将在每次迭代中隐式地知道透射率,检查阈值将像这样的简单检查一样简单:
cpp
if( transmittance < threshold) break;
为此,我们只需对最终的透射率项进行取反,该项计算为 e ^ (-t * d)。因此,我们想确定在什么 t 值下透射率会低于我们的阈值。幸运的是,这正是 log(x) 函数的作用。log 的默认底数是 e。它回答了"e 的多少次幂等于 x"这个问题。因此,如果我们想知道在什么 t 值下透射率会低于 0.001,我们可以计算:
cpp
DistanceThreshold = -log(0.001) / d;
假设用户定义的密度 d = 1,这将给我们一个线性累积值 6.907755,需要达到 0.001 的透射率。我们在着色器代码中添加这一行:
cpp
float shadowthresh = -log(ShadowThreshold) / ShadowDensity;
其中,ShadowThreshold 是用户定义的透射率阈值,ShadowDensity 是用户定义的阴影密度乘数。这一行需要放在将 ShadowDensity 乘以 shadowstepsize 的那行之后,循环的上方。
更新后的阴影代码
添加阴影退出和透射率阈值,以及循环外的最终部分步骤评估(这也必须执行相同的阴影步骤)产生了以下代码:
cpp
float numFrames = XYFrames * XYFrames;
float accumdist = 0;
float curdensity = 0;
float transmittance = 1;
//float3 localcamvec = normalize( mul(Parameters.CameraVector, Primitive.WorldToLocal) ) * StepSize;
float shadowstepsize = 1 / ShadowSteps;
LightVector *= shadowstepsize;
ShadowDensity *= shadowstepsize;
Density *= StepSize;
float3 lightenergy = 0;
float shadowthresh = -log(ShadowThreshold) / ShadowDensity;
for (int i = 0; i < MaxSteps; i++)
{
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, numFrames).r;
//Sample Light Absorption and Scattering
if( cursample > 0.001)
{
float3 lpos = CurPos;
float shadowdist = 0;
for (int s = 0; s < ShadowSteps; s++)
{
lpos += LightVector;
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
float3 shadowboxtest = floor( 0.5 + ( abs( 0.5 - lpos ) ) );
float exitshadowbox = shadowboxtest .x + shadowboxtest .y + shadowboxtest .z;
shadowdist += lsample;
if(shadowdist > shadowthresh || exitshadowbox >= 1) break;
}
curdensity = saturate(cursample * Density);
float shadowterm = exp(-shadowdist * ShadowDensity);
float3 absorbedlight = shadowterm * curdensity;
lightenergy += absorbedlight * transmittance;
transmittance *= 1-curdensity;
}
CurPos -= localcamvec;
}
CurPos += localcamvec * (1 - FinalStep);
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, numFrames).r;
//Sample Light Absorption and Scattering
if( cursample > 0.001)
{
float3 lpos = CurPos;
float shadowdist = 0;
for (int s = 0; s < ShadowSteps; s++)
{
lpos += LightVector;
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
float3 shadowboxtest = floor( 0.5 + ( abs( 0.5 - lpos ) ) );
float exitshadowbox = shadowboxtest .x + shadowboxtest .y + shadowboxtest .z;
shadowdist += lsample;
if(shadowdist > shadowthresh || exitshadowbox >= 1) break;
}
curdensity = saturate(cursample) * Density;
float shadowterm = exp(-shadowdist * ShadowDensity);
float3 absorbedlight = shadowterm * curdensity;
lightenergy += absorbedlight * transmittance;
transmittance *= 1-curdensity;
}
return float4( lightenergy, transmittance);
现在我们有了一个功能齐全的半透明光线体积光线行进器,它可以从一个方向光自我投影。上述阴影步骤需要对每个支持的额外光源重复。通过计算每个阴影项的反平方衰减,该代码可以轻松支持点光源和方向光,但是必须在每个密度样本处计算从 CurPos 到光源的向量。
环境光
到目前为止,我们只处理了单个光源贡献的散射光。这通常不会看起来很好,因为如果光源完全被遮蔽,体积在阴影中会显得平淡。通常会添加某种环境光项来解决这个问题。有很多方法可以处理环境光。一种方法是预先计算体积纹理内的环境光,就像深度阴影图那样。该方法的缺点是你将无法旋转和实例化体积,因为环境光会保持固定。一个实时的方法是从每个体素向上投射一些稀疏的光线来估算头顶的阴影。这可以通过一个额外的偏移样本来完成,但每增加一个平均样本,结果都会更好。
另一个倾向于使用动态环境光项而不是预计算项的原因是,如果你计划程序化叠加多个体积纹理。例如,在《地平线 零之曙光》的云层论文中描述了一种方法 [3]。在这篇论文中,一个体积纹理描述了整个区域的独特细节的宏观形状,而第二个平铺的体积纹理用于调节基础体积的密度。这种方法非常强大,因为当前的体积渲染技术受限于分辨率。应用混合调制是一种创建更详细外观的好方法,但这意味着预计算照明的方法将无法匹配从体积纹理组合中产生的新细节。
这里是我们如何通过三个额外的偏移样本来估算头顶环境遮蔽的示例。这可以在主循环中透射率乘积之后进行:
cpp
//Sky Lighting
shadowdist = 0;
lpos = CurPos + float3(0,0,0.05);
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
lpos = CurPos + float3(0,0,0.1);
lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
lpos = CurPos + float3(0,0,0.2);
lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
//shadowterm = exp(-shadowdist * AmbientDensity);
//absorbedlight = exp(-shadowdist * AmbientDensity) * curdensity;
lightenergy += exp(-shadowdist * AmbientDensity) * curdensity * SkyColor * transmittance;
这两个被注释掉的项只是尝试减少使用的临时变量数。相同的操作可以应用于所有代码。
光消光颜色
请注意,我们仅在每个密度样本上应用了一次光颜色到阴影项。以这种方式进行操作并不允许散射随深度而改变颜色。现实生活中云的散射主要来自米氏散射,这种散射对所有光波长的散射是均匀的,所以单色散射对于云来说并不算差。尽管如此,有色消光可以模拟液体中的消光光谱、日落的图像基底光照响应或艺术效果,只需将ShadowDensity参数替换为一个V3。你可以通过你想要显示的颜色来除以阴影密度:
初步成果
我们梳理一下,到目前进度,以下是整个材质现在应当呈现的样子:
1. RayMarchCube
RayMarchCube Custom
输出为V4(RGBA)
代码:
cpp
//将向量引入局部空间以支持对象变换
//LocalCamPos
//LocalCamVec
//使摄像机位置 0-1
LocalCamPos = (LocalCamPos / (LocalObjectBoundsMax.x * 2)) + 0.5;
float3 invraydir = 1 / LocalCamVec;
float3 firstintersections = (0 - LocalCamPos) * invraydir;
float3 secondintersections = (1 - LocalCamPos) * invraydir;
float3 closest = min(firstintersections, secondintersections);
float3 furthest = max(firstintersections, secondintersections);
float t0 = max(closest.x, max(closest.y, closest.z));
float t1 = min(furthest.x, min(furthest.y, furthest.z));
float planeoffset = 1-frac( ( t0 - length(LocalCamPos-0.5) ) * MaxSteps );
t0 += (planeoffset / MaxSteps) * PlaneAlignment;
//----
//这行代码放在这行之前:t0 = max(0, t0);
float scale = length( TransformLocalVectorToWorld(Parameters, float3(1.00000000,0.00000000,0.00000000)).xyz);
float localscenedepth = CalcSceneDepth(ScreenAlignedPosition(GetScreenPosition(Parameters)));
float3 camerafwd = mul(float3(0.00000000,0.00000000,1.00000000),ResolvedView.ViewToTranslatedWorld);
localscenedepth /= (LocalObjectBoundsMax.x * 2 * scale);
localscenedepth /= abs( dot( camerafwd, Parameters.CameraVector ) );
t1 = min(t1, localscenedepth);
//----t0 = max(0, t0);
float boxthickness = max(0, t1 - t0);
float3 entrypos = LocalCamPos + (max(0,t0) * LocalCamVec);
return float4( entrypos, boxthickness );
2. Directional Light
UE5去掉了一些节点,这里是代码,这部分可使用自定义的输入。
右侧函数内,是左侧两个custom的封装,随着版本不同可能发生变化
它们分别是:
cpp
ResolvedView.DirectionalLightDirection
cpp
ResolvedView.DirectionalLightColor
3. DensityRayMarch
DensityRayMarch Custom
输出为V4(RGBA)
代码:
cpp
float numFrames = XYFrames * XYFrames;
float accumdist = 0;
float curdensity = 0;
float transmittance = 1;
//float3 LocalCamVec = normalize( mul(Parameters.CameraVector, Primitive.WorldToLocal) ) * StepSize;
float shadowstepsize = 1 / ShadowSteps;
LightVector *= shadowstepsize;
ShadowDensity *= shadowstepsize;
Density *= StepSize;
float3 lightenergy = 0;
float shadowthresh = -log(ShadowThreshold) / ShadowDensity;
for (int i = 0; i < MaxSteps; i++)
{
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, numFrames).r;
//Sample Light Absorption and Scattering
if( cursample > 0.001)
{
float3 lpos = CurPos;
float shadowdist = 0;
for (int s = 0; s < ShadowSteps; s++)
{
lpos += LightVector;
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
float3 shadowboxtest = floor( 0.5 + ( abs( 0.5 - lpos ) ) );
float exitshadowbox = shadowboxtest .x + shadowboxtest .y + shadowboxtest .z;
shadowdist += lsample;
if(shadowdist > shadowthresh || exitshadowbox >= 1) break;
}
curdensity = saturate(cursample * Density);
float shadowterm = exp(-shadowdist * ShadowDensity);
float3 absorbedlight = shadowterm * curdensity;
lightenergy += absorbedlight * transmittance;
transmittance *= 1-curdensity;
//Sky Lighting+++
shadowdist = 0;
lpos = CurPos + float3(0,0,0.05);
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
lpos = CurPos + float3(0,0,0.1);
lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
lpos = CurPos + float3(0,0,0.2);
lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
//shadowterm = exp(-shadowdist * AmbientDensity);
//absorbedlight = exp(-shadowdist * AmbientDensity) * curdensity;
lightenergy += exp(-shadowdist * AmbientDensity) * curdensity * SkyColor * transmittance;
//Sky Lighting+++
}
CurPos -= LocalCamVec;
}
CurPos += LocalCamVec * (1 - FinalStep);
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, numFrames).r;
//Sample Light Absorption and Scattering
if( cursample > 0.001)
{
float3 lpos = CurPos;
float shadowdist = 0;
for (int s = 0; s < ShadowSteps; s++)
{
lpos += LightVector;
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
float3 shadowboxtest = floor( 0.5 + ( abs( 0.5 - lpos ) ) );
float exitshadowbox = shadowboxtest .x + shadowboxtest .y + shadowboxtest .z;
shadowdist += lsample;
if(shadowdist > shadowthresh || exitshadowbox >= 1) break;
}
curdensity = saturate(cursample) * Density;
float shadowterm = exp(-shadowdist * ShadowDensity);
float3 absorbedlight = shadowterm * curdensity;
lightenergy += absorbedlight * transmittance;
transmittance *= 1-curdensity;
//Sky Lighting+++
shadowdist = 0;
lpos = CurPos + float3(0,0,0.05);
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
lpos = CurPos + float3(0,0,0.1);
lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
lpos = CurPos + float3(0,0,0.2);
lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
//shadowterm = exp(-shadowdist * AmbientDensity);
//absorbedlight = exp(-shadowdist * AmbientDensity) * curdensity;
lightenergy += exp(-shadowdist * AmbientDensity) * curdensity * SkyColor * transmittance;
//Sky Lighting+++
}
return float4( lightenergy, transmittance);
进阶
如果追求更多效果,也可以进一步增加复杂度
附加阴影选项
可以添加支持各种阴影方法的功能,比如前面帖子中提到的基于对象自定义深度的阴影图。
这些解决方案在这里都能运行,但基于深度的阴影图在体积效果中表现不佳。因为阴影没有执行高成本的自定义模糊处理,所以看起来会很生硬。(请记住,你已经处于非常高成本的嵌套循环中)
目前,我尝试启用了距离场阴影。
距离场阴影可以在不增加额外成本的情况下生成柔和的影子,非常适合体积效果。
缺点是,为了进行体积测量,必须多次引用全局距离场,这极其昂贵,而且这些距离场的分辨率不太高。
仅在拥有980及以上GPU的情况下尝试。
要添加距离场阴影,需要在循环外传递或重新计算世界空间的光向量。
cpp
//Shadow+++
//float3 dfpos = 2 * (CurPos - 0.5) * Primitive.LocalObjectBoundsMax.x;
//dfpos = TransformLocalPositionToWorld(Parameters, dfpos).xyz;
float3 dfpos=DFPosition;
float dftracedist = 1;
float dfshadow = 1;
float curdist = 0;
float DistanceAlongCone = 0;
for (int d = 0; d < DFSteps; d++)
{
DistanceAlongCone += curdist;
curdist = GetDistanceToNearestSurfaceGlobal(dfpos.xyz);
float SphereSize = DistanceAlongCone * LightTangent;
dfshadow = min( saturate(curdist / SphereSize) , dfshadow);
dfpos.xyz += LightVectorWS * dftracedist * curdist;
dftracedist *= 1.0001;
}
//Shadow+++
这可以在循环中Lighting之后进行
待续待续,放假啦
将透明改成Mask也是非常不错