前言

本人算是米哈游老玩家了,崩三开服元老,虽然第一部完结就弃坑主崩铁了。去年三月调酒活动的游玩效果很惊艳,加之米哈游去年年底公布了相关技术专利以及最近和ycz同答的问题中她提及了弹簧质点的液面模拟,这不得冲一波。先感谢下伊底1D分层液体瓶,个人方向算是主物理模拟的引擎工具,渲染方面不甚精通,从文章中学到很多。

专利介绍

专利写了一堆废话,有信息的感觉就两点:其一是其弹簧骨骼模型,以质心为起点,液面为终点,再以模拟结果点法式构造液面方程,最后由相机射线求交构出整个虚拟液面;其二为衔接处融合内壁以及液面法线以表现表面张力。看下来还是挺失望的,标题写的“液体的模拟方法”,真模拟就整了一根弹簧,余下全在整渲染。个人感觉挺细枝末节的,就不原教旨主义复刻了,开始自己以模拟为主的复刻。

img

img

实时渲染

网上找了些调酒工具的模型,部分模型则借用自伊底1D,再上张调酒台的图当背景,看起来就比较有感觉了。场景渲染部分为适应模型贴图手搓了PBR以及自定义Phong模型,调酒渲染则参考的伊底1D的扎孔半透以及液面分层线插。

扎孔半透

扎孔半透是为了处理冰块和液体同为通明的渲染顺序问题,两者应该是相互作用,但懒得改SRP管线就给冰块上了个16阶的扎孔半透。比较详尽的解释[在此](Unity Stipple Transparency Shader - Alex Ocias Blogocias.com/blog/unity-stipple-transparency-shader/),大致思想就是以预设的透明度概率裁剪掉部分像素。挺古早的技术,怀念FC时代虽然受限于硬件但当时的人们是真有创造力,所以动物井的出现才那么让人眼前一亮……在之后的GIF中可以看到冰块有明显条带,原因在于像素裁剪基于屏幕空间加之开的多窗导致游戏视窗分辨率不足,全屏后不会出现此问题。

液面线插

液面分层线插的话也只是用min,max函数直接取上下层颜色进行插值,扰动的话个人感觉没什么区别,虽然这话可能会被美术喷,但没关系我又不是TA(

img

1
2
3
f = saturate((frac(h) - 0.5 + _BlendFrac) / (2 * _BlendFrac));
int l = max(0, floor(h)), r = min(_LayerCnt - 1, ceil(h));
albedo = lerp(_LayerCols[l], _LayerCols[r], f * (_NoiseSV.x * SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, i.uv + _Time.x * _NoiseSV.zw).a + 1 - 0.5 * _NoiseSV.x));

物理模拟

然后就是模拟部分的自创了,综合个人在星铁中的游玩体验感觉米哈游重渲染而轻模拟:专利中概括而言有“弹簧质点网格很费,我们采用单根弹簧”,“玩家重视视觉细节,所以我们要做法线混合”;以及新版本中翁法罗斯的浴池,水花部分极其夸张应该并非模拟,水的波动SWE或者均值扩散就行,话说都上电脑端了这不来点物理飞溅效果压力下显卡?

物理交互

重回正题对于调酒的物理模拟,本人采用4x4的弹簧质点,8次迭代。毕竟液面尺寸不大,隔壁水池均值扩散都能拿的64x64的Custom Render Texture(CRT)开糊。初始化状压约束(ACM后遗症),更新使用PBD(写布料写多了顺手的事)。个人感觉弹簧质点模拟液面方程适合固液耦合交互的情况,所以在加冰块以及搅拌时也将其抽象为质点与弹簧质点网格交互。最后以ComputeBuffer将弹簧质点信息传入Shader,C#脚本端的任务就告一段落了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// Mass-Spring
Vector3[] points; // x: height, y: old_height z: velocity
int[] constrains; // bitmask
Vector4[] datas; // x: height, yzw: normal
ComputeBuffer dataBuffer;

void BuildConstrains()
{
for (int i = 0; i < constrains.Length; i++)
{
int x = i / gridSize, y = i % gridSize;
// Build Constrains
if (x < gridSize - 1)
{
constrains[x * gridSize + y] = (constrains[x * gridSize + y] << 3) + 1;
constrains[(x + 1) * gridSize + y] = (constrains[(x + 1) * gridSize + y] << 3) + 2;
}
if (y < gridSize - 1)
{
constrains[x * gridSize + y] = (constrains[x * gridSize + y] << 3) + 3;
constrains[x * gridSize + y + 1] = (constrains[x * gridSize + y + 1] << 3) + 4;
}
}
}

void HandleCollision(Vector3 pos, float radius)
{
for (int i = 0; i < points.Length; i++)
{
int x = i / gridSize, y = i % gridSize;
Vector3 pointPos = new Vector3(x / (gridSize - 1f),
points[i].x, y / (gridSize - 1f));
float d = (pos - pointPos).magnitude, dx = radius + 1f / (2 * gridSize - 2);
if (d < dx) continue;
points[i].x = -Mathf.Sqrt(d * d - dx * dx);
points[i].z = (points[i].x - points[i].y) / dt;
}
}

void UpdateMassSpring(bool updateVel = true)
{
// PBD Iteration
float delta;
if (updateVel)
for (int i = 0; i < points.Length; i++)
{
points[i].z *= damp;
points[i].x += dt * points[i].z;
}
for (int i = 0; i < iteration; i++)
{
for (int j = 0; j < constrains.Length; j++)
{
int num = constrains[j], cnt = 0; delta = 0;
while (num > 0)
{
switch (num & 7)
{
case 1:
delta += 0.5f * (points[j + gridSize].x - points[j].x);
cnt++; break;
case 2:
delta += 0.5f * (points[j - gridSize].x - points[j].x);
cnt++; break;
case 3:
delta += 0.5f * (points[j + 1].x - points[j].x);
cnt++; break;
case 4:
delta += 0.5f * (points[j - 1].x - points[j].x);
cnt++; break;
}
num >>= 3;
}
points[j].x += delta / cnt;
}
}
// Volumn Conservation
delta = 0;
for (int i = 0; i < points.Length; i++)
delta += points[i].x; delta /= points.Length;
for (int i = 0; i < points.Length; i++)
{
points[i].x -= delta;
points[i].z = (points[i].x - points[i].y) / dt;
points[i].y = points[i].x;
}

// Calculate Normal
for (int i = 0; i < points.Length; i++)
{
int x = i / gridSize, y = i % gridSize,
xl = Mathf.Max(x - 1, 0), xr = Mathf.Min(x + 1, gridSize - 1),
yl = Mathf.Max(y - 1, 0), yr = Mathf.Min(y + 1, gridSize - 1);

float dx = (points[y * gridSize + xr].x - points[y * gridSize + xl].x) / (xr - xl),
dz = (points[yr * gridSize + x].x - points[yl * gridSize + x].x) / (yr - yl);

Vector3 normal = new Vector3(dx, 1.0f, dz).normalized;
datas[i] = new Vector4(points[i].x, normal.x, normal.y, normal.z);
}

dataBuffer.SetData(datas);
material.SetBuffer("_DataBuffer", dataBuffer);
}

液面构建

Shader端的任务大概就是根据弹簧质点网格高度以及法线信息,Lerp肯定是不行的,试了下SmoothStep,挺搞的还不如Lerp,最后还得是自己写的CosStep。想来之前均值扩散自己也用的CosStep,建议Unity把三角函数插值也集成下每次真懒得自己写了。就单独展示下高度的余弦插值,法线部分完全一致,也没必要在这里凑字数了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int _GridSize;
StructuredBuffer<float4> _DataBuffer; // x: height yzw: normal

float CosStep(float a, float b, float t)
{
float f = (1 - cos(t * PI)) * 0.5;
return a * (1 - f) + b * f;
}

float4 frag(v2f i) : SV_Target
{
int xl = floor(i.uv.x * _GridSize), xr = xl + 1,
yl = floor(i.uv.y * _GridSize), yr = yl + 1;
float xf = frac(i.uv.x * _GridSize), yf = frac(i.uv.y * _GridSize),
height = CosStep(CosStep(_DataBuffer[yl * _GridSize + xl].x, _DataBuffer[yl * _GridSize + xr].x, xf),
CosStep(_DataBuffer[yr * _GridSize + xl].x, _DataBuffer[yr * _GridSize + xr].x, xf), yf);
}

后记

惯例以成品效果收尾,同样加了亿点点细节,GIF分辨率限制看不出气泡。米哈游用的应该是和伊底1D一样的较为夸张的动画曲线控制,而弹簧质点网格就很物理了。性能方面4x4简易网格外加8次迭代费不了多少,感觉需要固液耦合的小型液面完全可以使用。

img

剩下的就是一些随感了,春招暑期实习开了,再看会UE物理源码就准备投下外企以及国外公司,毕竟此番出国也是为了wlb。米池坑是坑,但米哈游拿钱也是真的办事。崩三的最后一课,天穹流星,薪炎永燃,往世乐土,毕业旅行;崩铁的仙舟罗浮,匹诺康尼,翁法罗斯…… 希望米哈游能继续蒸蒸日上吧,我也尽力争取最后留下属于我的故事。