目录
前言
——本文基于之前的痕迹效果分析文,针对轨迹的形状与淡化方式做进一步的探讨。(光是这个效果就又踩了半个月坑···)如果本文遇到些不太理解的操作,可以尝试先简单了解上一篇。
——其实倒也不至于说完全解析- -毕竟我目前并不会解包拆源代码这种操作-_-只是效果的大方向上,个人觉得做到了与游戏内基本一致。那么先上一波个人还原的效果。
一、波形构造
——为了实现上面的效果,相对于上次的“笔刷法线高度图”的绘制方式,本文在笔刷的实现上改为了定制化能力偏弱但可控性较强的程序纹理方式(不是我不想用旧方法- -···而是按旧方法走做不来原神里那种向中间聚拢的效果)。更具体的说,是利用函数曲线去构造轨迹的横截面波形。
——不论起伏多复杂的笔刷图,只要它是旋转对称的,那么其高度在数学上就总能表示为
z=f(r)r=x2+y2——的形式。所以我们一上来只需要关注二维情况下的高度函数f(r)的构造即可。而考虑到程序上的性能与便捷性,我们应当尽量用幂函数去构造f(r)。
——先来仔细看看原神里的轨迹横截面形状的变化。
1.1、高次幂函数尝试
——若是考虑衔接的顺滑(即导数连续),这个波形应当有坑底、波峰、外围边界这三个关键的控制点,并且这三个点导数为0。在幂函数的前提下,这至少是个4次函数。若是将坑底的深度、波峰的高度、外围的远近也公开为可独立调整的参数,幂函数的次数更是能达到5次。而求解这个5次函数的6个系数,考虑一般方法则需要一个6x6的系数矩阵求逆并做矩阵乘法。
——且不论矩阵乘法的计算量,单就形状的可控性而言,因为5次函数最多会有4个极值点,所以如上图所示,有一个游离的极值点可能会出现在给定的三个控制点中间,这是我们不希望看到的。
——所以这里我们退而求其次,考虑用分段函数的方法去构造波形。
1.2、导数连续的分段幂函数尝试
——在本人的初次尝试中,我将波形分成了如下三段。
——其中1是二次函数,2、3是三次函数,并且通过计算保证分段点的导数连续。
——虽然形状的控制上很像这么回事,但为了计算第二段的三次函数的系数,还是用到了一个4x4的矩阵。
——虽然在shader中算几个矩阵和行列式之类的也完全没问题(而不像我用来画函数的这个geogebra,开的久点函数多点就巨卡···),shader没有矩阵求逆也可以用克莱姆法则求5个行列式来解出4个系数(数学上是等价的),但经个人之前实际踩坑,其实分段点导数连续与否,对于模型的顶点密度来说,哪怕开了曲面细分也基本看不出来,是否连续的视觉感受主要还是靠后面的光照模型计算来体现(关于法线的计算后面会说,这里提前告知并不是用本节的高度函数直接求导算法线)。
——所以,既是为了数学模型尽可能简洁也是为了性能,这里我们再退一步- -不管分段点的导数连续了,直接躺平_(:з)∠)_
1.3、导数不连续的分段幂函数尝试
——最终,我直接用两个开口方向相反的二次函数来拼成我们需要的高度形状。
——其中,T是时间,Hm是最大波峰高度,Dm是最大波谷深度,W是波峰宽度,且这些参数,包括函数的定义域都限制到了[0,1]。同时,波峰高度和波谷深度如图所示还加了一个随时间T的线性衰减。如果有需要,其实也可以自定义衰减函数,或是给W也加个衰减,本文就先采用上述模型来模拟效果,写成shader方法大概就下面这样(我就不管什么shader的if不if的性能开销玄学了- -)。
float GetHeight(float x, float t)
{
float cut = t - _W;//分段点
float H = _Hmax * t;
float D = _Dmax * t;
if (x < cut)
{
return D * (1 - x * x / (cut * cut));
}
else if (x < t)
{
return -4 * H / (_W * _W) * (x - t) * (x - cut);
}
else
{
return 0;
}
}
——将这个函数绕着竖轴转一圈,实际上就是3D的情况(Geogebra已经卡废···)。
——可以看到,向中间聚拢的效果已经出来了。可以想象一下,我们最终要实现的效果,实际上就是很多个这种坑连贯起来,然后做这种随时间的衰减。
二、属性遮罩
2.1、归一化半径与时间
——高度函数已经编出来了,接下来我们需要“x轴”才能把高度算出来。这里利用一张RT(其实就是上篇文章里的轨迹渲染纹理)来记录每一点相对于它自己的圆心(笔刷中心)的归一化半径(也就是x轴,或者说上节开头公式中的r)。这个过程则是在上篇文章的3.2节笔刷绘制shader中完成。
——因为上面的高度函数还有个时间变量,所以这张RT里还需要有个通道记录归一化时间。
2.2、布尔高度混合
——知道RT里存什么东西后,我们还需要知道每画一笔新的需要以什么样的方式叠到旧的RT上。上一篇文章我就不止一次说过,之前的“高度直接相加”的高度混合算法在数学层面上是和原神那种向中间聚拢的效果互相矛盾的。如果要问具体是为什么,大致就是“轨迹聚拢的方向和轨迹高度的梯度方向不平行”这样的原因。
——关于两种高度混合算法的不同,我们可以从轨迹交叉的效果上看出一些端倪。
——其中,左侧为上次我实现的效果,右侧是原神中的效果。若是以我的那种方式进行衰减,则必然会出现下面这种隆起的边缘横跨轨迹的诡异效果。
——原神的效果中,在交叉情况下,新的轨迹会有一部分完全覆盖掉旧轨迹(白色),而这部分的高度普遍要更低。
——所以我们可以合理猜测,这个高度混合算法大致是下面这样的形式。
h = h_OLD < h_NEW ? h_OLD : h_NEW;//取较小者作为新的高度
——而这个交叉区域的交界线像是双曲线又说明,这个轨迹构成的高度场应该近似于抛物面,所以我们在做布尔运算时也应将比较项平方再进行比较。
——这种if else的混合方式,实际上就是所谓的布尔算法(即很多3D建模软件里都有的布尔运算)。
2.3、各shader之间的关联
——需要注意的是,我们不能直接对1.3中的高度函数使用布尔运算,而是在笔刷绘制shader中基于上面的归一化半径使用布尔运算。
float hOLD = saturate(r01_OLD - (1 - time01_OLD));
hOLD = saturate(hOLD * hOLD + (1 - time01_OLD));//转换为抛物面
if (hOLD <= r01_Brush * r01_Brush)
{
r01_NEW = r01_OLD;
time01_NEW = time01_OLD
}
else
{
r01_NEW = r01_Brush;
time01_NEW = 1;
}
——然后在地形shader中基于归一化半径使用高度函数。(因为高度函数计算出的高度有正值有负值,如果对此使用布尔……总之会很混乱,不要问我怎么知道的)
——也因此,在衰减shader中,除了对时间进行线性衰减外,我们也需要对半径进行相似的操作,这样对半径的布尔算法才有意义。
float dt = unity_DeltaTime.x / _AttenTime;
float r01 = var_MainTex.r + dt;//因为半径是0到1变化,所以做加法
float time01 = r01 >= 1 ? _ZeroGLB.g : var_MainTex.g - dt;//截断范围外的属性
——而在地形shader中,只需要以
r = saturate(var_RutRTTex.r - (1 - var_RutRTTex.g));
——的方式得到正确的归一化半径即可。
——关于上面各shader之间的关系可以再消化消化。若是各方面的关联配置正确会得到以下效果(从左到右分别是校正后的半径通道,时间通道,高度函数)。
——至此,高度变化基本已经搞定了,然后就是解析法求法线了。
三、法线计算
——因为1.3的高度函数很清晰,所以我们能轻易求出它的导数。
f′(r)=−2Dcut2xx∈[0,cut)f′(r)=−4HW2(2x−cut−t)x∈[cut,t)f′(r)=0x∈[t,1]
——而拓展到3D的情况,实际就是求第一节开头那个函数的法线,即下面这个结果(没有单位化)。
n=(kx,ky,1)k=−f′(r)r
——也就是说,我们现在还需要知道笔刷中的一点相对于其圆心的归一化坐标,也就是单位极向量。正好上面的轨迹RT还剩下两个通道没用,我们在2.3的布尔算法里加入单位向量的编码即可。
if (r01_OLD <= r01_Brush)
{
r01_NEW = r01_OLD;
time01_NEW = time01_OLD;
dirR = var_MainTex.zw;
}
else
{
r01_NEW = r01_Brush;
time01_NEW = 1;
dirR = 0.5 * dirR / dir_Len + 0.5;
}
——然后在地形shader里去解码算一波法线。需要注意的是,用上面的公式算出来的法线还是切空间下的法线,还需要经过混合与空间转换才是最终的法线。然后就能得到下面的效果。
——嗯,问题很大很严重,但概括下来主要就三个问题。
1、因为高度函数采用二次函数,所以求导后的结果是线性变化的,进而导致轨迹的明暗过渡太强烈
2、因为布尔算法的非黑即白,导致笔刷之间的交界非常锐利
3、因为绘制间隔的存在,导致在衰减的最后会出现明显的点状分离效果
——接下来,我们逐个解决这些问题。
四、“故障”排查
4.1、自定义导数
——其实在1.2中就说过,我们最后使用的法线并不是在数学上和高度函数完全对应的,为的就是解决上面这个问题。
——我们完全可以把形状和光影分开看,形状用一套解决方案,光影用另一套,只要看上去符合大方向并且好看即可。
——根据1.2,我们理想中的波形应当是在波谷,波峰,边界3个点的导数为0,并且中间也是导数连续的。在最简单的情况下,其导函数形如下图中的黄色分段函数。
——其中,第一段函数对应的就是凹坑中的导数部分。我们只需要对其进行一个次幂运算,便能改变其明暗的色阶分布。
if (x < cut1)
{
dybdx = Remap(0, cut1, x);
dybdx = sign(dybdx) * pow(abs(dybdx), _DPow);
}
4.2、模糊处理
——这里直接祭出不久前写的模糊算法,在C#的Update与Paint方法中都在原本的Blit流水线基础上加一发模糊即可。
private void Update()
{
//创建临时RT
RenderTexture tempRT = RenderTexture.GetTemporary(paintRT.descriptor);
//轨迹淡化
Graphics.Blit(paintRT, tempRT, fadeMat, 0);
Graphics.Blit(tempRT, paintRT);
//模糊
BlurManager.Blur(ref paintRT_Blur, paintRT, blurMat, blurStep, blurRadius);
//释放临时RT
RenderTexture.ReleaseTemporary(tempRT);
}
public void Paint(Transform tfIN, Texture2D brushTex, float brushRadius, float brushInt)
{
···
//轨迹RT
RenderTexture tempRT = RenderTexture.GetTemporary(paintRT.descriptor);
Graphics.Blit(paintRT, tempRT, paintMat, 0);
Graphics.Blit(tempRT, paintRT);
BlurManager.Blur(ref paintRT_Blur, paintRT, blurMat, blurStep, blurRadius);
RenderTexture.ReleaseTemporary(tempRT);
}
——因为在过程中我又发现衰减的结尾法线还是保持了一个比较强的状态,所以在法线强度的系数中又加了一个随时间衰减的次幂运算。
nDirTS_Rut.xy *= _RutHeight * _RutNormalInt * pow(t, _NormalIntTimePow);
4.3、胶囊形区域求最短距离
——其实经历了上面两波操作矫正,效果以及基本上大差不差了。如果绘制间距调成0,模糊半径开到3以上(下面是2的效果),其实结尾的点状分离现象也基本会消失。
——如果硬是纠结结尾那一点点瑕疵,这里给出一个我在做效果的过程中突发奇想产生的副产物,其可以保证哪怕绘制间距再大,其间的轨迹也是连贯的。比如采用这种算法时,下面绘制间距拉到1的效果。
——其基本思想就是,用数学的方式表示下面这个胶囊形区域,并求区域内所有点到两圆心间线段的最短距离。
——其实这种问题扔给高中生也会做,区域表示直接看成两个圆和一个矩形,然后中间的矩形用线性规划那套东西拆成四个一次函数比大小,最后和圆取并集;最短距离则是根据所在的区域分类讨论,当在矩形里直接列个点到直线距离公式,圆就直接取半径。我一开始也是这么想的,但···我忘了点到直线距离公式是啥了_(:з)∠)_
——于是,这里直接采用一种空间转换的方法,把原本空间里的点转到下图这个空间里去,然后不论矩形和圆的表示还是点到线段的最短距离都将会变得很简单,直接就都是平行于XY轴的,做个分支判断即可。
——类比到这个轨迹效果中,这两个圆其实就相当于笔刷shader中当前画的那一笔以及上一笔,而红色的向量就是位移向量,这些东西在shader中都是已知条件,直接拿来算即可。
——最后,像是第三节中的极向量,求完后再把它转回到原本的空间即可。而算出来的这个胶囊形区域的归一化最短距离,再和原图做布尔运算,完美。
——当然,这样做目前还是有些问题的。比如跳跃再落地,它会直接将起跳点和落地点连出一条轨迹出来。这个可以通过C#端加判断来避免,但是文章末尾的资源里我没做。
五、展望与资源链接
——下面列出一些个人曾思考过但未实际去做的点- -
——1、现在这套方案将波形的参数放到了地形里统一控制。实际上,若是要那种每个物体轨迹都可能不同的情况,可以考虑再开一个RT专门存波形的参数。
——2、可以考虑用一张图去存轨迹的高度和导数(法线),而不是现在的数学方法。这张图可以是一维的(用归一化半径去采样),也可以是二维的(用极向量去采样)。但这样做的话,便意味着上面的每个物体定制化轨迹成为不可能。
——3、现在的轨迹可能像是磨了皮的网红显得过于"光滑圆润",可以考虑加一张噪波来扰动采样。
——4、原神中的绘制效果有些地方有“笔触感”,比如蘑菇精拿头扫地的轨迹会有粗细变化,就像拿板子画画一样。这个应该可以放到C#里用协程之类的方法做。
——那么这次的效果就到此为止- -吭哧了一个月,总算是心中一块石头落了地- -舒服了_(:з)∠)_最后放上资源链接。
——提取码: v3i3