前言

本科时看到Evan的WebGL_Water就喜欢上并收藏了,现在读研课上偶遇图形学作业,看起来感觉性能挺好,那就Unity复刻改造下扔手机里跑跑。接下来我将分为模拟和渲染两部分简单讲下项目要点。

模拟

模拟部分简单用了局部平均和差分,也能看作浅水波方程的极简版,取四个邻点算高度差再扩散。水波(以及后续焦散)采用纹理表示,其中水波纹理的r通道为高度场,g通道为速度场

1
2
3
4
5
6
7
8
9
float average = (
tex2D(_SelfTexture2D, coord - dx).r +
tex2D(_SelfTexture2D, coord - dy).r +
tex2D(_SelfTexture2D, coord + dx).r +
tex2D(_SelfTexture2D, coord + dy).r
) * 0.25;
info.g += (average - info.r) * 2.0;
info.g *= 0.99; //速度损耗
info.r += info.g;

然后是波纹平滑与体积保持,定积分扔给GPT算的,解放大脑

1
2
3
float drop = max(0, 1.0 - length(float2(0.5, 0.5) - coord) / 0.5);
drop = 0.5 - cos(drop * PI) * 0.5; //平滑
info.r += (drop - 0.25 * (PI / 2 - 2 / PI)) * _Strength; //体积保持

渲染

一般来说焦散是要走路径追踪的,但路径追踪并非特定于焦散,算是模拟光线行为和渲染逼真图像的通用解决方案。有人会问路径追踪确实很强,但还是太吃硬件了,有没有简单而强势的方法推荐下?有的兄弟,有的,这么强的方法当然不止一个。静态的且不论(渲静态水太没意思了),对于动态水来说也有用网格来近似光的波前和基于屏幕空间的方法。两者都很省,但光波前网格更直观也更物理些,所以项目采用的光波前网格。网格的每个顶点代表一束光离开光源并落在场景的某个地方。网格的每个三角形近似于三角形顶点之间的所有可能的光线。三角形面积的增加意味着光线散开,强度变暗。面积减小意味着光线聚焦,应该更亮一些。总结来说就是亮度变化与面积变化成正比,直接表示面积而非采样以避免了需要大量的样本确认焦散形状,因此效率更高。需要注意的是这种方法并非万能,但因为光通过平面水体折射而适用于焦散。

img

然后问题又来了,如何访问三角形中的全部三个顶点,几何着色器?确实是一种解法,但太复杂了懒得写,正好片元着色器为了算lod保留了偏导变化率,这里放段概要:

片元着色器有一个有趣的评估策略:它们总是在2x2组中每次评估4个。由于该组中的所有片段着色器共享相同的指令指针,因此每个片段着色器都可以使用自身和相邻片段着色器之间沿该轴的有限差异计算沿x和y轴的任何值的瞬时屏幕空间偏导数。这通常用于计算纹理mipmap级别。

那直接就是一个一把抓住,顷刻炼化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
v2f vert (appdata v) //顶点着色器传递原现顶点
{
float4 info = SAMPLE_TEXTURE2D_LOD(_WaveMap, sampler_WaveMap, float4(v.vertex.xy * 0.5 + 0.5, 0, 0), 100);
info.ba *= 0.5;
float3 normal = float3(info.b, sqrt(1.0 - dot(info.ba, info.ba)), info.a);
float3 refractedLight = refract(-GetMainLight().direction, float3(0.0, 1.0, 0.0), IOR_AIR / IOR_WATER);
float3 ray = refract(-GetMainLight().direction, normal, IOR_AIR / IOR_WATER);
o.oldPos = Project(v.vertex.xzy, refractedLight, refractedLight);
o.newPos = Project(v.vertex.xzy + float3(0.0, info.r, 0.0), ray, refractedLight);
}

float4 frag (v2f i) : SV_Target //片元着色器用偏导算面积
{
float oldArea = length(ddx(i.oldPos)) * length(ddy(i.oldPos));
float newArea = length(ddx(i.newPos)) * length(ddy(i.newPos));
float4 col = float4(oldArea / newArea * 0.2, 1.0, 0.0, 0.0);
}

成果

最后加上亿点点细节和相应控制脚本就算完工了,相较于原版来说加了天空盒和法线贴图,大概更符合一般场景了些。再进一步可以拓展到水下任意模型,但那样也就是上光追加速结构,感觉没什么意思就算了。搁手机和电脑都跑了下,完结撒花。

img

红米K20 红米K50 Laptap(GTX 1650)
FPS 60 121 810