本文将基于屏幕后处理技术实现一个无限大的水面渲染,主要内容包括:
- 屏幕世界坐标还原
- 水面坐标计算
- 镜面反射
- 菲涅尔反射与透射
- 水体次表面散射
- 水面波纹实现
- 水面波函数
- 噪声图
所需要的前置知识包括:
- 渲染管线基础
- 着色器编程
- 线性代数和空间变换
- Unity Built-in Render Pipeline 渲染开发(基于 Command Buffer 的渲染管线扩展)
- Compute Shader
代码放在了 GitHub:
https://github.com/SardineFish/Post-processing-Demogithub.com最终实现的效果如图(图中效果还包括雾效、大气散射等其他后处理):
概述
有时候我们希望在游戏中实时渲染一个无限大的平面,例如一望无际的海面,或者是一个无限大的地面。
一个简单的做法是构造一个非常大的 Plane,在对这个 Plane 的渲染基础上做进一步的效果。
这样实现的效果依赖于 Plane 的尺寸,如果尺寸不够大,在渲染画面上就能很看出很明显的平面边界,影响画面观感。尤其以较低的视角进行渲染时,摄像机理论上可以观察到平面的消失点,此时需要平面的尺寸是无限大,这显然难以实现。特别是当场景里有那么一个巨大的物体时,有时会给场景编辑带来一定的麻烦。
实现一个“看上去”无限大的平面,我们可以直接从屏幕图像入手,计算这个无限大平面是否应该显示在屏幕上的某些像素上。这个过程类似于光线追踪的原理,对屏幕上每个像素,计算渲染该像素的光线方向,即从摄像机视锥体顶点出发穿过该像素的射线向量,然后计算该射线是否与该平面相交,计算出世界坐标下的交点坐标后,进行进一步的渲染(例如纹理采样,环境贴图采样等)。
这里我们将基于设置在 CameraEvent.BeforeImageEffects
的 CommandBuffer
上用 CommandBuffer.Blit
实现屏幕后处理。
屏幕世界坐标转换
这里的第一步就是计算渲染屏幕像素的光线向量。
在渲染管线中经过顶点着色器变换,并经过齐次除法之后,顶点坐标最终被表示为归一化的设备坐标(NDC),最终呈现在屏幕上的画面处在摄像机近裁平面和远裁平面之间的视锥体中。我们所看到的屏幕平面在世界坐标下,就是摄像机的近裁平面。
将近裁平面划分为 W*H 的网格(W, H为屏幕的像素宽度和高度),其中每个单元格就对应了屏幕上的一个像素,因此计算从视锥体顶点(通常为摄像机的世界坐标位置)出发,经过近裁平面上对应位置的向量,就是我们所需要的像素渲染射线。
屏幕像素的世界坐标计算
我们首先计算每个屏幕像素在世界空间中的坐标(即近裁平面上像素网格的位置),可以利用 (Camera.projectionMatrix * Camera.worldToCameraMatrix).inverse
获得从当前摄像机裁剪空间到世界空间的变换矩阵。
我们可以在顶点着色器中计算近裁平面顶点在世界空间下的坐标,利用顶点插值在片元着色器中得到屏幕像素的世界坐标。
float4x4 _ViewProjectionInverseMatrix; // Projection to world matrix
float3 _CameraPos; // Camera position in world space
float3 _CameraClipPlane; // (near, far, near - far) in world space
v2f vert (appdata v)
{
// ...
float4 p = float4(o.vertex.x, o.vertex.y, -1, 1);
p = p * _CameraClipPlane.x;
float4 screenWorldPos = mul(_ViewProjectionInverseMatrix, p);
// ...
}
我们知道,经过 MVP 矩阵变换之后,近裁平面左上角顶点的坐标是(-Near, Near, -Near, Near)
,其中Near
是摄像机近裁平面的距离。
我们可以在 C# 中通过CommandBuffer.SetGlobalVector
等 API 设置 Shader 中的全局变量。
在片元着色器中通过screenWorldPos - _CameraPos
即得到了渲染当前屏幕像素的投射光线方向。
我们通过在初始化摄像机时设置摄像机的深度纹理模式,并在 Shader 代码中声明全局变量 _CameraDepthTexture
来获取摄像机深度图。
camera.depthTextureMode = DepthTextureMode.Depth;
由于深度图中的深度信息储存的是视角空间下的深度值,并且由于齐次除法,深度值与实际片元到摄像机的距离是非线性的关系,好在 Unity 提供了两个内置函数Linear01Depth
和LinearEyeDepth
,将深度图中的深度值转换为线性的深度值。
LinearEyeDepth
获得该片元到摄像机近裁平面的距离(世界空间下的单位长度),Linear01Depth
将深度值映射到取值范围[0,1]
的线性深度值中(相对近裁平面的距离)
由于获取到的线性深度值是距离近裁平面的垂直距离,因此我们还需要进一步将线性深度值变换为片元到摄像机的世界空间距离。
float3 ray = screenWorldPos - _CameraPos; // do not normalize it here
ray = normalize(ray) * (length(ray) / _CameraClipPlane.x); // to correct distance
float depth = tex2D(_CameraDepthTexture, i.uv).r;
float3 worldPos = (_CameraPos + LinearEyeDepth(depth) * ray.xyz);
至此我们获得了当前屏幕像素上的图像在世界空间中的位置worldPos
;
水面坐标计算
接下来我们利用上一步计算得到的屏幕像素的投射光线,计算光线与预期的水平面的交点。
令:
其中:p
为射线与平面交点,p0
为平面上任意一点,n
为平面法向量,求解得到t
之后,根据t
是否有(0,+∞)
上的解可以判断当前射线是否与平面相交,并求出交点坐标p
。
得到交点坐标后,我们还要计算交点与摄像机的距离,与此前计算的当前像素到摄像机的距离比较,以此决定当前像素上的水面是否被遮挡。
镜面反射实现
这里我们将使用 Unity 的 Reflection Probe 实现水面倒影的渲染,Reflection Probe 将每帧实时渲染一个360°的环境贴图(分别渲染 Cube Map 6个面),因此带来的性能开销非常大,但是这样可以获得最佳的水面倒影效果。目前主流的实时反射效果利用屏幕空间反射(SSR)技术,将反射源的采样局限在屏幕空间内,因此在当前视野内没有出现的物体,也无法在水面倒影中出现,而实时 Reflection Probe 可以避免这个问题(虽然代价非常大)。
我们知道,从摄像机 V
,观测到镜面 P
点处的反射光线,可以视为从 V
点的镜面对称点 V'
经过 P
点所观测到的光线。
因此我们可以通过将 Reflection Probe 放置到 V'
的位置,渲染得到从 V'
观测到的环境贴图,并使用 $vec{PX}$ 反射向量对 CubeMap 进行采样得到预期的反射图像。
C# Code:
// Do this in Update()
var pos = camera.transform.position;
pos.y = SeaLevelHeight - (target.position.y - SeaLevelHeight);
ReflectionProbe.transform.position = pos;
// Do this in PreRender() and setup command buffer.
ReflectionProbe.RenderProbe(); // Render reflection probe manually
CommandBuffer.SetTexture("_ReflectTex", ReflectionProbe.realtimeTexture);
Shader Code:
float3 reflectDir = reflect(ray, normal);
float3 reflectionColor = texCUBElod(_ReflectTex, float4(reflectDir, 0)).rgb;
实现效果根据 Reflection Probe 的分辨率配置,应该可以得到完美的镜面倒影效果:
菲涅尔反射与透射
菲涅尔反射
至此我们已经完成了镜面反射,而现实中的水面并不是完美的镜面反射,根据菲涅尔反射定律,真实的水面应该包含一部分反射和一部分折射,其中反射率随入射角增大而增大。
在实时渲染中常使用由 Schlick 提出的菲涅尔近似函数[1]:
其中
这里的
抛光导体(金属)的
透射
水体的透射部分可以直接用屏幕图像中的原像素来代替,为了得到更真实的水体效果,我们可以在透射颜色上加上一定的雾效,计算原屏幕和到与水面视点的距离,以此加上距离相关的雾效。
float depth = length(worldPos - waterPos);
float density = 1 / 1 - pow(_RefractFog.a, 0.5f) + 0.00001f;
float f = 1 - exp(-pow(density * abs(depth), 1));
float3 refractColor = tex2D(_MainTex, i.uv);
refractColor = _RefractFog.rgb * f + color * (1 - f);
return fixed4(color, 1);
下图只保留了水体透射部分。
使用菲涅尔方程计算反射率,综合反射和折射:
float fresnel = saturate(fresnelFunc(_F0, dot(normal, -ray)));
float3 color = fresnel * reflectionColor + (1 - fresnel) * refractColor;
水体次表面散射和水面阴影
目前得到的水体完全由反射和透射图像构成,缺少水体与阳光的交互,看上去阴沉沉的。
事实上,在阳光的照射下,由于水中存在的微小悬浮物,光线进入水体后在水体中发生多次散射,最终一部分光线离开水面,被摄像机捕捉,这样的光学现象称为次表面散射。此外由于水面光滑,在朝向阳光的方向能观察到高光反射,形成波光粼粼的效果。我们可以沿用 PBR 的光照模型给水面增加次表面散射和高光反射。
在这里我们可以将水体的次表面散射分解成表面浅层面的次表面散射和整个无限大水体的次表面散射,其中浅层水面的次表面散射我们可以直接使用 PBR 漫反射近似,整个水体的次表面散射用了 Fast Subsurface Scattering in Unity 一文中提到的快速次表面散射近似模型。
其中
增加了次表面散射的水体在朝向阳光的方向可以观察到被正面阳光照亮的水体,增强水体的通透感。
由于浅层水体的次表面散射(表面漫反射)来自附近入射的光线(而无限大水体的次表面散射光来自无限远),因此还应该对屏幕空间阴影贴图进行采样。
为了确保在场景物体的渲染时,阴影能正确的透射在水底的物体上,在渲染时不应该在场景中放置用于接收水面阴影的平面。而现在我们需要额外的一份屏幕空间阴影贴图,用来采样投射在水面上的阴影。
这里我的做法是创建一个只用来渲染屏幕空间阴影贴图的摄像机,在场景中生成一个足够大的平面,利用 LayerMask 让这个平面只对渲染阴影的摄像机可见,用 Camera.RenderWithShader
渲染一个只包括屏幕空间阴影贴图的 RenderTexture,使用这张 RenderTexture 作为水面的屏幕空间阴影贴图。这样对性能有一定的影响,更好的做法应该是扩展渲染管线,手动渲染两种屏幕空间阴影贴图,目前还没有尝试。
综合前面的反射、透射、次表面散射和阴影之后的水面效果如图
水面波纹
水面渲染自然少不了波纹,水面波纹通过对水面法线的扰动产生反射、折射效果的扭曲,制造波动的水面效果。
水面波纹的实现通常有2种方式,模拟水波的解析函数或者使用噪声图实现随机波动。
这里我使用了4个正弦函数叠加作为主要的水波,使用噪声图作为水面上细小的不规则波动。
波函数叠加
我们可以使用正弦函数近似水面的波动模型[3]:
其中
将多个正弦波叠加得到水面t
时刻在位置(x,y)
处的水面高度:
对以上函数求偏导可得t
时刻位置(x,y)
处的水面法向量:
其中:
同理可得
通常我们使用4个不同方向、波长和波速的正弦波叠加作为水波,对每个波的参数调节需要一定的耐心,一个好的参数组合可以很大程度上提升水波的视觉效果,这里可以通过渲染波函数高度图对参数效果进行预览
对于参数的选择,尽可能选择互质的参数以增加叠加波函数的周期,以减少视觉上的重复感。
此外这里我们再引入两个参数,WaveStrength
与波动的水面法线相乘后归一化以调节水面扭曲程度,WaveAttenuation
用于让水面波动扭曲程度随距离衰减,以得到更好的水面观感。
float3 calculateWaveNormal(float2 pos, flaot dist)
{
float omega[4] = {37, 17, 23, 16};
float phi[4] = {7, 5, 3, 11};
float2 dir[4] = {
float2(1, 0),
float2(1, 0.1),
float2(1, 0.3),
float2(1, 0.57)
};
float t = _Time.y * _WaveSpeed;
float3 normal = 0;
for(int i = 0; i < 4; i++)
{
omega[i] *= _WaveScale;
normal += float3(
omega[i] * dir[i].x * 1 * cos(dot(dir[i], pos) * omega[i] + t * phi[i]),
1,
omega[i] * dir[i].y * 1 * cos(dot(dir[i], pos) * omega[i] + t * phi[i])
);
}
normal.xz = normal.xz * _WaveStrength * exp(-1 / _WaveAtten * dist);
return normalize(normal);
}
使用噪声图
除了使用解析函数生成水面波纹,我们还可以利用噪声,给水面增加不规则的微小扭曲,增加水面的细节。
这里我使用了 ComputeShader 实现了一个循环的 Perlin Noise 噪声图,并且确保每帧生成的噪声图在时间上连续。具体的实现方法不在这里展开,可能会单独写一篇关于噪声图生成的文章(开始给自己挖坑)
// ...
pos = pos * _NoiseScale + _WaveSpeed * _Time.y * _NoiseTex_TexelSize.xy;
float centerHeight = tex2D(_NoiseTex, pos).r;
float2 delta = float2(
tex2D(_NoiseTex, pos + float2(_NoiseTex_TexelSize.x, 0)).r - centerHeight,
tex2D(_NoiseTex, pos + float2(0, _NoiseTex_TexelSize.y)).r - centerHeight
);
delta /= _NoiseTex_TexelSize.xy;
delta *= _NoiseStrength * exp(-1 / _WaveAtten * dist);
normal.xz += delta.xy;
return normalize(normal);
我们将解析波函数得到的法线向量加上噪声图采样得到的法线偏量并归一化作为最终的水面法线,应用到水面反射、菲涅尔方程和透射上,最终得到了水面的波纹扭曲效果。
最终的水面效果如图(实际动态视觉效果更佳)
最后
本文介绍了一种基于屏幕后处理的无限大水面渲染方法,虽然在性能上存在一定的缺陷,特别是水面反射的实现上,因此还有很大的优化余地,此外水波的生成采用了在 Shader 代码中实时计算解析函数值的方式,也对性能有较大的影响,更好的做法应当讲水面波纹法线渲染到一张法线贴图上,并在运行时直接采样法线贴图。
文中的代码均节选并简化自我实现的代码,Demo 项目放在了 GitHub:
https://github.com/SardineFish/Post-processing-Demogithub.com个人感觉,画面效果,有一说一,确实,能看。
扩展阅读
GPU Gems Chapter 1. Effective Water Simulation from Physical Models
Fast Subsurface Scattering in Unity
Understanding Perlin Noise
Reference
[1] Schlick, Christophe. "An inexpensive BRDF model for physically‐based rendering." Computer graphics forum. Vol. 13. No. 3. Edinburgh, UK: Blackwell Science Ltd, 1994.
[2] Cyrille, Damez, and Nicolas Wirrmann. "The Comprehensive PBR Guide by Allegorithmic-vol. 1." Light and Matter: The Theory of Physically-Based Rendering and Shading.
[3] Fernando, Randima, ed. GPU gems: programming techniques, tips, and tricks for real-time graphics. Vol. 590. Reading: Addison-Wesley, 2004.