我在技术图形/渲染中的最后一个任务是找到一个好的渲染水的解决方案。 特别是基于粒子的细而快速移动的水流的渲染。 在过去的一周中,我想到了不错的结果,所以我将写一篇有关此的文章。
渲染水时,我真的不喜欢体素化/行进立方体方法(例如,参见在Blender中渲染流体模拟)。 当水的体积与用于渲染的网格的比例相同时,运动明显是离散的。 这个问题可以通过增加网格的分辨率来解决,但是对于实时在相对较长距离上的细喷头来说,这是不切实际的,因为它极大地影响了执行时间和占用的内存。 (使用稀疏体素结构来改善这种情况是有先例的。但是我不确定这对于动态系统的效果如何。而且,这也不是我想要使用的难度级别。)
我探索的第一个选择是穆勒的屏幕空间网格。 他们使用将水颗粒渲染到深度缓冲区中,对其进行平滑处理,识别相似深度的连接片段,并使用行进正方形根据结果构建网格。 如今,这种方法可能比2007年
更加适用(因为现在我们可以在计算着色器中创建网格),但是与我想要的方法相比,它仍然具有更高的复杂性和成本。
最后,我找到了Simon Green在GDC 2010上的演讲,即游戏的屏幕空间流体渲染。 它以与“屏幕空间网格”完全相同的方式开始:将粒子渲染到深度缓冲区中并对其进行平滑。 但是,不是构建网格,而是使用生成的缓冲区在主场景中着色和合成液体(通过明确记录深度)。我决定实现这种系统。
准备工作
先前的几个Unity项目教会我不要处理渲染引擎的限制。 因此,流体缓冲区由具有较浅景深的第二个摄像机渲染,以便在主场景之前渲染。 每个流体系统都存在于单独的渲染层上。 主腔室不放水,第二个腔室仅放水。 两个相机都是空物体的子代,以确保它们的相对方向。
这样的方案意味着我可以在液体层中渲染几乎任何东西,并且看起来像我期望的那样。 在我的演示场景中,这意味着子发射器的一些喷射和飞溅可以合并在一起。 另外,这将允许混合其他水系统,例如,基于高度场的体积,然后可以将其渲染为相同的体积。 (我尚未对此进行测试。)
我的场景中的水源是标准的粒子系统。 实际上,没有执行流体模拟。 反过来,这意味着粒子不会以完全物理的方式相互重叠,但是最终结果在实践中似乎是可以接受的。
流体缓冲渲染
此技术的第一步是渲染基础流体缓冲区。 这是一个屏幕外缓冲区,包含(在我的实现的当前阶段)以下内容:流体宽度,屏幕空间中的运动矢量和噪声值。 另外,我们通过显式记录来自片段着色器的深度来渲染深度缓冲区,以将粒子的每个四边形变成球形(实际上是椭圆形)的“球”。
深度和宽度的计算非常简单:
frag_out o; float3 N; N.xy = i.uv*2.0 - 1.0; float r2 = dot(N.xy, N.xy); if (r2 > 1.0) discard; Nz = sqrt(1.0 - r2); float4 pixel_pos = float4(i.view_pos + N * i.size, 1.0); float4 clip_pos = mul(UNITY_MATRIX_P, pixel_pos); float depth = clip_pos.z / clip_pos.w; o.depth = depth; float thick = Nz * i.size * 2;
(当然,可以简化深度计算;从剪辑位置开始,我们只需要z和w。)
稍后,我们将返回片段着色器以获取运动和噪声矢量。
有趣之处始于顶点着色器,而这正是我偏离绿色技术的地方。 该项目的目标是渲染高速水流; 球形颗粒可以帮助实现这一点,但是要产生连续的射流,将需要大量的球形颗粒。 相反,我将根据粒子的速度拉伸它们的四边形,这又会拉伸深度球,使它们不是球形,而是椭圆形。 (由于深度计算是基于UV的,它不会发生变化,因此一切正常。)
有经验的Unity用户可能想知道为什么我根本不使用Unity粒子系统中可用的内置“拉伸广告牌”模式。 拉伸广告牌沿世界空间中的速度矢量执行无条件拉伸。 在一般情况下,这是非常合适的,但是当速度矢量与前向摄像头矢量(或非常接近)共同指向时,会导致非常明显的问题。 广告牌在屏幕上延伸,这使其二维性质非常引人注目。
取而代之的是,我使用一个瞄准相机的广告牌,并将速度矢量投影到粒子的平面上,用它来拉伸四边形。 如果速度矢量垂直于平面(指向或远离屏幕),则粒子将保持应有的拉伸和球形状态,并且当倾斜时,粒子将沿该方向拉伸,这正是我们所需要的。
让我们详细解释一下,这是一个相当简单的函数:
float3 ComputeStretchedVertex(float3 p_world, float3 c_world, float3 vdir_world, float stretch_amount) { float3 center_offset = p_world - c_world; float3 stretch_offset = dot(center_offset, vdir_world) * vdir_world; return p_world + stretch_offset * lerp(0.25f, 3.0f, stretch_amount); }
为了计算屏幕空间的运动矢量,我们计算了矢量的两组位置:
float3 vp1 = ComputeStretchedVertex( vertex_wp, center_wp, velocity_dir_w, rand); float3 vp0 = ComputeStretchedVertex( vertex_wp - velocity_w * unity_DeltaTime.x, center_wp - velocity_w * unity_DeltaTime.x, velocity_dir_w, rand); o.motion_0 = mul(_LastVP, float4(vp0, 1.0)); o.motion_1 = mul(_CurrVP, float4(vp1, 1.0));
请注意,由于我们是在主通道中而不是在速度矢量的通道中计算运动矢量,因此Unity不会从视图中为我们提供先前的或未变形的当前投影。 为了解决这个问题,我向相应的粒子系统添加了一个简单的脚本:
public class ScreenspaceLiquidRenderer : MonoBehaviour { public Camera LiquidCamera; private ParticleSystemRenderer m_ParticleRenderer; private bool m_First; private Matrix4x4 m_PreviousVP; void Start() { m_ParticleRenderer = GetComponent(); m_First = true; } void OnWillRenderObject() { Matrix4x4 current_vp = LiquidCamera.nonJitteredProjectionMatrix * LiquidCamera.worldToCameraMatrix; if (m_First) { m_PreviousVP = current_vp; m_First = false; } m_ParticleRenderer.material.SetMatrix("_LastVP", GL.GetGPUProjectionMatrix(m_PreviousVP, true)); m_ParticleRenderer.material.SetMatrix("_CurrVP", GL.GetGPUProjectionMatrix(current_vp, true)); m_PreviousVP = current_vp; } }
我之所以手动缓存先前的矩阵,是因为Camera.previousViewProjectionMatrix提供的结果不正确。
¯\ _(ツ)_ /¯
(此外,此方法违反了渲染渲染;在实践中设置全局矩阵常量而不是对每种材质使用它们都是明智的选择。)
让我们回到片段着色器:我们使用投影位置来计算屏幕空间的运动矢量:
float3 hp0 = i.motion_0.xyz / i.motion_0.w; float3 hp1 = i.motion_1.xyz / i.motion_1.w; float2 vp0 = (hp0.xy + 1) / 2; float2 vp1 = (hp1.xy + 1) / 2; #if UNITY_UV_STARTS_AT_TOP vp0.y = 1.0 - vp0.y; vp1.y = 1.0 - vp1.y; #endif float2 vel = vp1 - vp0;
(运动矢量的计算几乎没有变化,取自
https://github.com/keijiro/ParticleMotionVector/blob/master/Assets/ParticleMotionVector/Shaders/Motion.cginc )
最后,流体缓冲器中的最后一个值是噪声。 我为每个粒子使用一个稳定的随机数来选择四种噪声之一(打包成一个纹理)。 然后按速度和单位减去颗粒大小进行缩放(因此,快颗粒和小颗粒会更吵)。 该噪波值用于着色过程中,以扭曲法线并添加一层泡沫。 Green的作品使用了三通道白噪声,但是较新的作品(具有曲率流的屏幕空间流体渲染)建议使用Perlin噪声。 我使用不同比例的Voronoi噪声/单元噪声:
混合问题(和解决方法)
这是我实施的第一个问题。 为了正确计算颗粒的厚度,将其相加混合。 由于混合会影响所有输出,因此这意味着噪声和运动矢量也会进行加法混合。 加性噪声非常适合我们,但不适合加性矢量,如果将它们保持原样,则会出现令人反感的时间抗锯齿(TAA)和运动模糊。 为了解决这个问题,在渲染流体缓冲区时,我只需将运动矢量乘以厚度,然后将其除以阴影遍次中的总厚度即可。 这为我们提供了所有重叠粒子的加权平均运动矢量。 并不是我们所需要的(奇怪的假象是在多个喷头相交时产生的),但是完全可以接受。
更复杂的问题是深度。 为了正确渲染深度缓冲区,我们需要同时激活深度记录和深度检查。 如果未对粒子进行排序,则可能会导致问题(因为渲染顺序的差异可能会导致其他粒子重叠的粒子的输出被剪切)。 因此,我们命令Unity粒子系统按深度对粒子进行排序,然后我们用手指指望。 该系统还将深入渲染。 我们将*有*重叠系统(例如,两个粒子射流的相交)未被正确处理的情况,这将导致较小的厚度。 但这并不经常发生,并且不会极大地影响外观。
正确的方法很可能是将深度缓冲区和颜色缓冲区完全分开。 回报将是两次通过渲染。 设置系统时值得探讨此问题。
深度平滑
最后,绿色技术中最重要的事情。 我们将一堆球形球渲染到深度缓冲区中,但实际上,水并不包含“球”。 因此,现在我们采用这种近似并将其模糊化,使其更像液体的表面。
天真的方法是将高斯噪声深度简单地应用于整个缓冲区。 它会产生奇怪的结果-它使远处的点比近处的点更平滑,并使轮廓的边缘模糊。 相反,我们可以更改深度的模糊半径,并使用双面模糊来保存边缘。
这里只出现一个问题:这种变化使模糊难以区分。 共享模糊可以通过两遍执行:水平模糊,然后垂直模糊。 一遍就完成了难以区分的模糊。 之所以重要,是因为共享模糊线性缩放(O(w)+ O(h)),非共享模糊线性缩放(O(w * h))。 大规模的,非共享的模糊在实践中很快变得不适用。
作为成年人,负责任的开发人员,我们可以采取明显的行动:闭上眼睛,假装双向噪声*是共享的,并且仍然使用单独的水平和垂直过道来实现。
Green在他的演讲中证明,尽管这种方法在结果结果中
会产生伪像(尤其是在重建法线时),但阴影阶段会很好地将其隐藏起来。 当使用我创建的较窄的水流时,这些伪影甚至不那么明显,并且不会特别影响结果。
底纹
我们终于完成了流体缓冲的工作。 现在,让我们继续进行效果的第二部分:对主图像进行着色和合成。
在这里,我们遇到了许多Unity渲染限制。 我决定只用太阳和天窗的光线照亮水。 支持其他光源需要多次通过(这很浪费!),或者需要在GPU端构建光源搜索结构(成本高昂且相当复杂)。 另外,由于Unity不提供对阴影贴图的访问,而定向光使用屏幕空间的阴影(基于不透明几何体渲染的深度缓冲区),因此我们无法访问有关来自太阳光源的阴影的信息。 您可以将命令缓冲区附加到阳光源上,以创建专门用于水的屏幕空间的阴影图,但是到目前为止,我还没有这样做。
着色的最后阶段是通过脚本控制的,并使用命令缓冲区发送绘制调用。 这是
必需的,因为运动矢量纹理(用于临时抗锯齿(TAA)和运动模糊)不能用于使用Graphics.SetRenderTarget()的直接渲染。 在主摄像机附带的脚本中,我们编写以下内容:
void Start() {
颜色缓冲区和运动矢量不能与MRT(多个渲染目标)同时渲染。 我找不到原因。 另外,它们需要绑定到不同的深度缓冲区。 幸运的是,我们将深度
都写入了
这两个深度缓冲区,因此重新投影临时抗锯齿效果很好(哦,使用黑匣子引擎很高兴)。
在每一帧中,我们从OnPostRender()中抛出一个合成渲染:
RenderTexture GenerateRefractionTexture() { RenderTexture result = RenderTexture.GetTemporary(m_MainCamera.activeTexture.descriptor); Graphics.Blit(m_MainCamera.activeTexture, result); return result; } void OnPostRender() { if (ScreenspaceLiquidCamera && ScreenspaceLiquidCamera.IsReady()) { RenderTexture refraction_texture = GenerateRefractionTexture(); m_Mat.SetTexture("_MainTex", ScreenspaceLiquidCamera.GetColorBuffer()); m_Mat.SetVector("_MainTex_TexelSize", ScreenspaceLiquidCamera.GetTexelSize()); m_Mat.SetTexture("_LiquidRefractTexture", refraction_texture); m_Mat.SetTexture("_MainDepth", ScreenspaceLiquidCamera.GetDepthBuffer()); m_Mat.SetMatrix("_DepthViewFromClip", ScreenspaceLiquidCamera.GetProjection().inverse); if (SunLight) { m_Mat.SetVector("_SunDir", transform.InverseTransformVector(-SunLight.transform.forward)); m_Mat.SetColor("_SunColor", SunLight.color * SunLight.intensity); } else { m_Mat.SetVector("_SunDir", transform.InverseTransformVector(new Vector3(0, 1, 0))); m_Mat.SetColor("_SunColor", Color.white); } m_Mat.SetTexture("_ReflectionProbe", ReflectionProbe.defaultTexture); m_Mat.SetVector("_ReflectionProbe_HDR", ReflectionProbe.defaultTextureHDRDecodeValues); Graphics.ExecuteCommandBuffer(m_CommandBuffer); RenderTexture.ReleaseTemporary(refraction_texture); } }
这是CPU参与结束的地方,以后只有着色器可以使用。
让我们从运动矢量的传递开始。 这是整个着色器的外观:
#include "UnityCG.cginc" sampler2D _MainDepth; sampler2D _MainTex; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert(appdata v) { v2f o; o.vertex = mul(UNITY_MATRIX_P, v.vertex); o.uv = v.uv; return o; } struct frag_out { float4 color : SV_Target; float depth : SV_Depth; }; frag_out frag(v2f i) { frag_out o; float4 fluid = tex2D(_MainTex, i.uv); if (fluid.a == 0) discard; o.depth = tex2D(_MainDepth, i.uv).r; float2 vel = fluid.gb / fluid.a; o.color = float4(vel, 0, 1); return o; }
屏幕空间中的速度存储在流体缓冲区的绿色和蓝色通道中。 由于我们在渲染缓冲区时通过厚度来缩放速度,因此我们再次将总厚度(位于alpha通道中)除以得到加权平均速度。
值得注意的是,在处理大量水时,可能需要另一种处理速度缓冲区的方法。 由于我们不进行混合渲染,因此丢失
了水下所有物体的运动矢量,从而破坏了这些对象的TAA和运动模糊。 当使用稀薄的水流时,这不是问题,但是当我们需要TAA或运动模糊对象在整个表面上清晰可见时,这可能会干扰在泳池或湖泊中工作。
更有趣的是主要的着色通道。 在用液体厚度掩盖后,我们的首要任务是重建观察空间(观察空间)的位置和法线。
float3 ViewPosition(float2 uv) { float clip_z = tex2D(_MainDepth, uv).r; float clip_x = uv.x * 2.0 - 1.0; float clip_y = 1.0 - uv.y * 2.0; float4 clip_p = float4(clip_x, clip_y, clip_z, 1.0); float4 view_p = mul(_DepthViewFromClip, clip_p); return (view_p.xyz / view_p.w); } float3 ReconstructNormal(float2 uv, float3 vp11) { float3 vp12 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(0, 1)); float3 vp10 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(0, -1)); float3 vp21 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(1, 0)); float3 vp01 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(-1, 0)); float3 dvpdx0 = vp11 - vp12; float3 dvpdx1 = vp10 - vp11; float3 dvpdy0 = vp11 - vp21; float3 dvpdy1 = vp01 - vp11;
这是重建查看空间位置的昂贵方法:我们在剪辑空间中获取位置,然后执行投影的反向操作。
在找到一种重构位置的方法之后,法线变得更简单:我们计算深度缓冲区中相邻点的位置,并根据它们构建切线基础。 为了处理轮廓的边缘,我们在两个方向上进行采样,然后选择最靠近视图空间的点来重建法线。 这种方法出奇地好,仅在非常薄的物体的情况下才会引起问题。
这意味着我们对每个像素执行五个单独的反向投影操作(针对当前点和四个相邻的像素)。 有一种较便宜的方法,但是这篇文章已经太长了,因此我将其留待以后使用。
结果法线为:
我使用来自流体缓冲器的噪声值的导数对这个计算出的法线进行了扭曲,通过力参数进行缩放,并通过除以射流的厚度进行归一化(与速度相同的原因):
N.xy += NoiseDerivatives(i.uv, fluid.r) * (_NoiseStrength / fluid.a); N = normalize(N);
我们最终可以继续着色本身。 水遮蔽包括三个主要部分:镜面反射,镜面折射和泡沫。
反射是完全取自标准Unity着色器的标准GGX。 (通过一次校正,将正确的F0为2%用于水。)
有了折射,一切都会变得更加有趣。 正确的折射需要光线追踪(或光线渐进以获得近似结果)。 幸运的是,折射对眼睛的影响不如反射直观,因此错误的结果不太明显。 因此,我们通过x和y法线移动用于折射纹理的UV样本,并通过厚度和力参数进行缩放:
float aspect = _MainTex_TexelSize.y * _MainTex_TexelSize.z; float2 refract_uv = (i.grab_pos.xy + N.xy * float2(1, -aspect) * fluid.a * _RefractionMultiplier) / i.grab_pos.w; float4 refract_color = tex2D(_LiquidRefractTexture, refract_uv);
(请注意,使用了相关校正;它是
可选的 -毕竟,它只是一个近似值,但是加起来很简单。)
该折射光穿过液体,因此一部分被吸收:
float3 water_color = _AbsorptionColor.rgb * _AbsorptionIntensity; refract_color.rgb *= exp(-water_color * fluid.a);
请注意,_AbsorptionColor是通过与预期方式完全相反的方式确定的:每个通道的值指示的是
吸收的光量,而不是透射的光量。 因此,值为(1、0、0)的_AbsorptionColor不会给出红色,而是发出蓝绿色(青绿色)。
反射和折射使用菲涅耳系数混合:
float spec_blend = lerp(0.02, 1.0, pow(1.0 - ldoth, 5)); float4 clear_color = lerp(refract_color, spec, spec_blend);
直到那一刻,我们(主要是)遵循规则并使用了物理阴影。
他很好,但是他有水问题。 有点很难看:
要修复它,让我们添加一些泡沫。
当水湍流并且空气与水混合形成气泡时,就会出现泡沫。 这样的气泡会在反射和折射中产生各种变化,从而使所有水都具有漫射照明的感觉。 我将使用包裹的环境光对该行为进行建模:
float3 foam_color = _SunColor * saturate((dot(N, L)*0.25f + 0.25f));
根据流体的噪声和软化的菲涅耳系数,使用特殊系数将其添加到最终颜色中:
float foam_blend = saturate(fluid.r * _NoiseStrength) * lerp(0.05f, 0.5f, pow(1.0f - ndotv, 3)); clear_color.rgb += foam_color * saturate(foam_blend);
包裹的环境照明被标准化以节省能量,因此可以用作漫射的近似值。 泡沫颜色的混合更加明显。 这显然违反了能量守恒定律。
但总的来说,一切看起来都不错,并使流更加引人注目:
进一步的工作和改进
在创建的系统中,可以进行很多改进。
- 使用多种颜色。 目前,仅在着色的最后阶段才计算吸收率,并且对屏幕上的所有液体使用恒定的颜色和亮度。 可以支持不同的颜色,但需要第二种颜色缓冲液,并且在渲染基础流体缓冲液的过程中需要吸收每个粒子的吸收积分。 这可能是一项昂贵的操作。
- 全覆盖。 可以访问GPU端的照明搜索结构(可以手动构建,也可以绑定到新的Unity HD渲染管线),因此我们可以使用任意数量的光源适当地照亮水并创建合适的环境照明。
- 改善折射。 使用背景纹理的模糊Mip纹理,我们可以更好地模拟粗糙表面的折射。 实际上,这对于少量液体喷雾不是很有用,但是对于较大体积的液体可能有用。
如果有机会,我会改进该系统以减少脉冲,但目前可以称其为完整系统。