——因为个人之前活动的平台缺乏有营养的可以促进双方思考与进步的评论(毕竟平台的主打内容和用户群体不同),所以思考(与自己的懒惰做斗争)过后,技术相关的文章以后还是来知乎或CSDN之类专业性比较强的平台发吧- -
——之前个人主要在B站和半次元活动,名字统一【伊底_1D】,如果对我的黑历史感兴趣的可以移步(虽然也没多长的历史),而这边之后视情况可能也会把已有的一些技术方面的文慢慢搬过来。
——下面的正文其实是之前在B站写好的一版直接粘过来后微调的,有些排版或者个人的习惯措辞可能会有些诡异- -大可不必在意。
目录
前言
——这次应该是开备忘录系列以来综合性最强的一次效果开发了- -因为是“踩坑记录”,所以在专业人士来看废话可能会比较多?然后因为代码量较大,且涉及多个shader和C#脚本联动的情况,所以在结尾就不贴完整代码了,而是会提供完整的U3D工程链接。
——当时须弥版本刚开就看到一些肝帝已经跑到了沙漠(而自己连化城郭都还没出去···),然后就发现,欸,这个轨迹的效果有点意思,之前也在怪猎冰原里见过相似的效果。再加上如今的自己大致能想到实现方法,于是就做了做试试。经过小半个月的各种踩坑后可谓收获颇丰,并有了现在这篇文章。(下面是提瓦特车神早柚的表演时间)
——然后,下面是本文实现的效果。沙漠材质是用的这个。
——稍微换波贴图调调参就是原神6.0至冬预定(乐)
——可以看出,在一些像是轨迹的形状以及淡化消失的方式上还是和原神中的效果存在差别的,这是高度混合的算法不同导致的(后面会讲)。至于原神中的高度混合算法,以后有机会再单独开一篇文来分析。
——因为用到了诸如细分曲面shader,Graphics.Blit等个人虽知道但一直没有机会实际使用的功能模块或API,自己也是磕磕绊绊边查资料边做的效果,所以难免会有些理解不到位甚至错误的地方。也因此,这次除了记录知识点外,格外欢(期)迎(待)专业人士进行讨论或指正(如果有专业人士会看的话- -···虽然我好像每次都会说类似的话)。当然,单纯只是感兴趣的圈外人士也欢迎提问- -哪怕不是和本文的内容直接相关。
——我的一贯态度就是,比起流量和点赞、浮于表面的夸奖之类,含有分析的建设性批评、表扬或知识分享,对我而言更令人愉悦(虽说流量可能也有增加获得这些的数学期望的作用就是了┑( ̄Д  ̄)┍)。
——自我抒情到此为止_(:з)∠)_那么下面正式开始。
一、流程分析
——事先说明,本文的效果只在PC端才有,在移动端(非云原神)则是只有脚印而没有拖拽的轨迹,并且脚印也没有体积(个人猜测,移动端可能的实现方案有动态创建销毁透贴片,基于Graphic.Blit的绘制等。相对来说前者的性能更优且效果更加可控,所以可能性更大些。但这些不是本文的重点,不过多讨论)。
——先直接说结论。这个效果的实现可以分成两个大步骤:轨迹体积的实现,以及轨迹的动态绘制。
1.1、轨迹体积的分析
——从上图中角色的脚被挡住,地面与岩石等其他场景模型的交界线形状发生变化等现象可以证明,PC端的效果并不是法线或视差贴图等障眼法(关于法线与视差是什么,不了解的可以点这里和这里),而是真的去做了体积。
——个人在当时看到游戏中的效果时,脑子里主要浮现出了两个方案:
——1、模仿U3D的拖尾渲染器,写一个根据物体的运动动态生成横截面是U型的条状网格的脚本,同时想办法让轨迹模型外轮廓的顶点贴在地上(下面是U3D自带的拖尾渲染器的效果)。
——但简单想想,用这个方法的话,处理轨迹模型和地面的接缝以及轨迹自身的交叉过渡可能会比较麻烦。并且中间凹陷的地方可能是在地面以下的,这还会有一个渲染顺序的问题,可能需要用模板测试等方法做个剔除。总之,感觉前途多舛,不太行,pass。
—— 2、利用置换贴图+曲面细分的方式直接在地形上动手脚,分别都是什么意思会在下面细说。建模的盆友可能知道,其实就类似于zbrush里的细分级别,而这个效果本身总体来说也就是个低配版的zbrush。
——需要说明的是,曲面细分shader除了相对较大的性能开销外还存在兼容性问题。据我所知,尤其对于移动端显卡来说,较老的型号好像并不支持这个运算模块,个人认为这也是mhy没在移动端做这个效果的主要原因。
1.2、轨迹的动态绘制分析
——“动态变化可交互”才是这个效果的灵魂所在。如果只是个静态的效果,直接在模型上把轨迹做出来再上个法线不比上面那些省事又效果好?
——基于上面体积实现的思路,我们动态绘制的轨迹图至少得是一张类似于下面这样的灰度图。
——其中越黑的地方代表高度越低。而上面这张图,实际上是用PS画出来的。用过PS的盆友可能知道,PS的笔刷有个“绘制间距”的参数。这个参数d的含义就是,你画出来的那些看似连贯的线或色块,实际上是很多个中心间距为d的圆点(或者其他笔刷)叠在一起形成的。
—— 本人在较早的时候做过一个水波涟漪交互的效果。当时虽然想到了这层原理,但奈何知识有限,那时采用的方法十分简单粗暴,就是用一个1000长度的数组去记录每个“圆点”的相关参数(位置,大小,频率,衰减之类的),然后传到shader里做循环。每个像素1000次循环,再加上之前脚本的计算也是实时的,所以性能消耗可想而知。
——虽然这种方法对每个点都有着很强的可控性,但单就这个效果来说没必要,而且当绘制次数达到几万甚至几十万时,总不能还用数组去解决。考虑到一个点在画完后就不会再有什么变化,即便有,它的变化也与其他的点相同,而这种“大量单元相互独立且处理相同”就意味着该效果存在利用GPU的并行性解决的潜力。
——对于绘制,我们可以模仿PS的绘制过程来解决这个问题。
——这个绘制(添加新的“圆点”)的过程可以描述为一个迭代、或者说递归的过程,即用原图经过“加点”方法f(x)处理输出新图,而新图作为下次“加点”处理的原图输入,并重复这个过程。
——如此一来,在shader中只需计算和新加的那一个“圆点”相关的数据即可,而之前的结果都保存在了一张图像中。这个过程的实现方法也不唯一,可能用另一个我没怎么用过的叫ComputeShader的东西处理要更NB一些。但ComputeShader在兼容性上好像还不如曲面细分shader,总之这次姑且用U3D的内置函数——Graphics.Blit来解决。
二、轨迹体积的实现
2.1、置换贴图
——所谓置换贴图(或者叫位移贴图、高度贴图、凹凸贴图等。可能陈述有误,但不重要- -),一般是一张记录了表面凹凸程度(高度)的灰度图(比如1.2里的那张五角星),可以用它在顶点shader中改变模型的顶点位置(实际上模型本身没变,只是最终看上去的效果变了。最常见的套路就是下面这样沿法线方向膨胀)。
——但像上面这样的做法存在两个问题:
——1、轨迹的形状可能存在外围比地表高,内部比地表低的情况,即大概下面这样的形状。
——但示例的高度挤出只是单向的。
——2、仔细观察示例可以发现,形状虽然变了,但是光影分布是错误的。
——对于问题1,可以仿照法线贴图的编码方式对置换贴图的灰度做一个[-1,1]->[0,1]的重映射,即实际的高度变化=2*灰度-1。(也就是说,原本的【灰度0对应高度0】变成了【灰度0.5对应高度0】,这使得图像中间接存储了负值信息。更进一步,如果有需要还可以自定义高度0对应的灰度,甚至非线性映射)确定编码规则后在shader中按对应规则解码即可。
——对于问题2,可以在置换贴图的基础上使用对应的法线贴图来矫正光影。虽然法线可以基于高度图在shader中实时计算,但考虑到性能开销与效果,这里还是用现成的法线贴图来做。这里可以做一个小优化,充分利用颜色通道,将法线信息存到图像的RGB通道,高度存到A通道中。
——综合以上两点可以得到类似于下面的笔刷贴图。
——需要注意的是,因为这里的法线是我们自定义的编码方式,所以U3D的图像导入设置中不能选NormalMap,shader中也不能用UnpackNormal解码。并且因为存储的是数据不是颜色,所以不能勾选sRGB。(原因可以参考我的这篇文章中末尾关于线性和Gamma空间的陈述)
——但实际上,仅仅是做了上面这些,效果也远远达不到预期。
——原因便是模型本身的精度太低,与图像精度之间差距太大,这反而加剧了违和感。这就需要接下来的曲面细分来解决。
2.2、曲面细分
——为了节省性能及资源量,游戏中的模型,尤其是地形模型往往精度不高(顶点密度小)。但像是本文的这种效果,一条轨迹的宽度可能还没有地形中的一个三角面大,所以还需要在有轨迹的地方动态添加一定数量的顶点。这件事可以通过shader中的“细分曲面阶段”或“几何阶段”来实现。本文则是采用自动化程度较高的细分曲面shader来实现。
——曲面细分和几何shader是渲染管线中的两个可选阶段。一般我们写的shader,尤其是移动端shader只会用到顶点和片段shader,而完整的流程则是顶点->曲面细分->几何->片段。
——按个人的理解和概括(只是个人为了方便使用做的总结- -看完了能用来做东西,但理论层面上不一定对),曲面细分阶段又可以进一步分成【顶点阶段数据接收】、【细分规则配置】、【计算插值权重】和【细分后的顶点属性计算】四部分。若要使用曲面细分shader,首先需要在Pass内的开头补上
#pragma hull hs
#pragma domain ds
——两句命令。(hs和ds可以按自己的喜好改名,但后面的方法名也要对应修改)
——提前说明,下面对各阶段的解析只讨论图元是三角形时的情况。
2.2.1、顶点阶段数据接收
——曲面细分的位置在顶点阶段后。参照顶点与片段之间利用结构体及对应的方法传递数据的方式(后面几个阶段的数据交换也都类似),曲面细分也需要定义一个结构体来接收顶点阶段的数据,比如下面这个样子。
//顶点结构体
struct a2v
{
float4 posOS : POSITION;
float3 nDirOS : NORMAL;
float2 uv0 : TEXCOORD0;
//其他数据
};
//曲面细分结构体
struct v2t
{
float3 posWS : TEXCOORD0;
float3 nDirWS : TEXCOORD1;
float2 uv0 : TEXCOORD2;
//其他数据
};
//顶点shader
v2t vert(a2v i)
{
v2t o;
//坐标
o.posWS = TransformObjectToWorld(i.posOS.xyz);
//向量
o.nDirWS = TransformObjectToWorldNormal(i.nDirOS);
//UV
o.uv0 = i.uv0;
//其他
return o;
}
————(至于什么a2v,v2t的,其实就是xxx to xxx的意思- -xxx是shader各阶段的缩写,反正我是这么理解的)
2.2.2、细分规则配置
——即如何对一个三角面进行细分。在曲面细分的流程中,我们需要设置三角形各边要分成几段,以及三角形内部有几个新加的点(实际上不是点的数量。内部点的细分数并不直观)。首先,我们需要声明下面这么个结构体用来存储细分配置。
struct TessParam
{
float EdgeTess[3] : SV_TessFactor;//各边细分数
float InsideTess : SV_InsideTessFactor;//内部细分点系数
};
————然后紧接着这个结构体下面写这么个方法来算具体怎么分,后面几个阶段也是类似的格式。
TessParam ConstantHS(InputPatch<v2t, 3> i, uint id : SV_PrimitiveID)
{
TessParam o;
o.EdgeTess[0] = 第1条边的细分段数;
o.EdgeTess[1] = 第2条边的细分段数;
o.EdgeTess[2] = 第3条边的细分段数;
o.InsideTess = 内部细分点系数;
return o;
}
——上面这个方法在运行时会在原模型的每个三角面上跑一遍。参数列表中的i可以获得之前在顶点阶段传到曲面细分结构体里的数据,并且是三份(一个三角面上的三个顶点)。如果需要做一些局部细分的优化,可能就需要用到它。
——然后,我们先来看一下这两种参数对模型的实际影响。
——右边参数的xyz分量对应边的细分段数,w对应内部细分点系数。需要特别说明的是这里有个坑,如果把三条边的细分段数公开成三个不同的参数来控制会出问题,网格会完全消失(或闪烁),但个人不知道原因。如果有带佬清楚,愿意分享的话欢迎讨论一下- -
——对于这几个参数,其中的EdgeTess很直观,给多少,对应的边就会被分成几段;而对于InsideTess,经观察可以发现它只会影响内层三角形的细分点排列方式,也就是说EdgeTess与InsideTess是互相独立的。
——以一些简单的等差数列知识可以算出来,InsideTess系数与内部的细分点个数间的关系如下:
——其中N是三角形内部增加的点的个数;x是InsideTess给的数;k在x是奇数时为0,偶数时为1。
2.2.3、计算插值权重
——这个阶段是不可编程的。他会根据我们之前和下面的细分配置在硬件内部进行计算,并得到每个新顶点的插值权重,或者说所谓的“重心空间坐标”(其实某种层面上类似于片段shader前的光栅化线性插值阶段,只不过这里插值计算的是顶点而不是像素。并且这一步只是得到每个顶点的插值权重,真正的混合需要我们在下一个阶段手动计算)。但是在此之前,还需要写一些配置方面的东西。
struct TessOut
{
float3 posWS : TEXCOORD0;
float3 nDirWS : TEXCOORD1;
float2 uv0 : TEXCOORD2;
//其他数据
};
[domain("tri")]//图元类型
[partitioning("integer")]//曲面细分的过渡方式是整数还是小数
[outputtopology("triangle_cw")]//三角面正方向是顺时针还是逆时针
[outputcontrolpoints(3)]//输出的控制点数
[patchconstantfunc("ConstantHS")]//对应之前的细分规则配置阶段的方法名
[maxtessfactor(64.0)]//最大可能的细分段数
TessOut hs(InputPatch<v2t, 3> i, uint idx : SV_OutputControlPointID)//在此处进行的操作是对原模型的操作,而非细分后
{
TessOut o;
o.posWS = i[idx].posWS;
o.nDirWS = i[idx].nDirWS;
o.uv0 = i[idx].uv0;
//其他
return o;
}
——还是经典的一个结构体一个方法的组合。但说实话,目前我不知道这个方法的存在意义是什么···本次开发中也没有在TessOut方法中做什么计算,就是原封不动传递数据。所以,还是欢迎知道的带佬答疑解惑。
——这段中间的那几行中括号括起来的配置还是有一定影响的,但一般需要动的可能也就第二行。除了integer,可选的字段还有fractional_odd和fractional_even两种。后两种的效果类似,都是有个平滑过渡的过程。
2.2.4、细分后的顶点属性计算
——到这里就是细分着色器的最后一步了。这一步的方法其实相当于一般顶点-片段shader中的顶点阶段,像是2.1中的置换贴图法线膨胀算法或是其他常见的写在顶点阶段的算法就可以写在这里。只不过经过了前面的细分处理,这里所处理的顶点不是原模型的顶点,而是细分后的所有顶点。
struct t2f
{
float4 posCS : SV_POSITION;
float3 posWS : TEXCOORD0;
float3 nDirWS : TEXCOORD1;
float2 uv0 : TEXCOORD2;
};
[domain("tri")]//图元类型
t2f ds(TessParam tessParam, float3 bary : SV_DomainLocation, const OutputPatch<TessOut, 3> i)
{
t2f o;
//权重混合
o.posWS = i[0].posWS * bary.x + i[1].posWS * bary.y + i[2].posWS * bary.z;
o.nDirWS = i[0].nDirWS * bary.x + i[1].nDirWS * bary.y + i[2].nDirWS * bary.z;
o.uv0 = i[0].uv0 * bary.x + i[1].uv0 * bary.y + i[2].uv0 * bary.z;
//坐标
o.posCS = TransformWorldToHClip(o.posWS);
return o;
}
——相较于顶点shader,细分shader的这一步往往会多一步“权重混合”,其中的bary就是在上一个阶段得到的当前顶点的重心空间坐标。而通过参数列表里的i,我们就可以拿到当前顶点所在的原模型三角面上的三个顶点相关的数据,并作加权平均。
——最后将结构体传给片段shader进行相关计算即可。
float4 frag(t2f i) : SV_Target
{
//像素处理
}
2.2.5、一点补充
——如果对曲面细分和几何shader更多更专业的技术细节感兴趣,这里给出三个我在查资料时觉得不错的文章,可以参考一下。
2.3、置换贴图+曲面细分
——基于之前做的沙漠材质球,将上述两种技术加进去,便可得到以下效果。
——虽然效果很不错,但这加了细分之后密密麻麻的网格看着就吓人。而实际情况中,地形往往很大,玩家的有效视野可能只占其中一小小部分,总不能一开细分就把全世界所有地方无差别的上段数。所以可以尝试一些相关的优化,放在后面讲解。
三、轨迹的动态绘制
3.1、Graphics.Blit
——之前的体积实现都是在shader中做的,而从这里开始需要先暂时切换到C#脚本中。
——承接1.2最后的分析,我们先来了解一下U3D内置的方法Graphics.Blit是干什么的。这个方法有多种重载,本文主要用到其中两种来模拟1.2中提到的迭代过程。先看一下下面的关键代码。
RenderTexture paintRT;//记录轨迹的渲染纹理
Material paintMat;//绘制处理用的材质
//···
void Paint(Texture2D brushTex/*其他参数*/)
{
//其他计算
//绘制材质参数配置
paintMat.SetTexture("_BrushTex", brushTex);//笔刷贴图
/*其他参数设置···*/
//纹理交换
RenderTexture tempRT = RenderTexture.GetTemporary(paintRT.descriptor);
Graphics.Blit(paintRT, tempRT, paintMat, 0);
Graphics.Blit(tempRT, paintRT);
RenderTexture.ReleaseTemporary(tempRT);
}
——定义一个Paint方法。其中,paintRT是旧的轨迹图,同时也是地形使用的置换贴图; paintMat是我们一会要写的绘制用的shader实例化出来的一个材质;rutTex是在本次绘制中使用的笔刷贴图(即类似于2.1里的那个法线高度混合图)。
——完成材质的参数传递后,我们先用RenderTexture.GetTemporary方法创建一个交换用的临时RT(说是用这个方法的性能比较好);
——然后Graphics.Blit(paintRT, tempRT, paintMat, 0)这句的意思是:【把paintRT用paintMat处理一次后的图像传给tempRT】(0的意思是使用shader中的第几个pass,一般就用第一个,尤其本文的URP管线)。需要注意的是,Blit会默认把paintRT传给shader中名为_MainTex的变量,所以写shader时名字要对应;
——然后Graphics.Blit(tempRT, paintRT)是将tempRT的结果传回paintRT,完成一次迭代过程;最后一定要用ReleaseTemporary手动释放临时RT的内存!(否则就等着内存爆炸吧,不要问我怎么知道的)
3.2、笔刷绘制shader
——上面的Blit方法需要以一个paintMat作为处理材质,其对应的便是接下来的绘制shader。 结合1.2中的分析与上述过程,我们绘制用的shader其实只需要关注“单步操作”即可,即“如何把一张笔刷图贴到旧的轨迹图上去”。
——先考虑一下,这个shader可能需要用到什么参数。首先,旧的轨迹图和笔刷图是必须的;然后是“在哪画”,即笔刷的UV坐标(这里需要补充一下,Graphics.Blit方法实际上是在一个UV范围是0~1的方形网格上进行相关计算的,所以UV坐标也就相当于贴图归一化的像素坐标);还有“画多大”,即笔刷在UV空间中的半径;最后还可以加个强度,或者说透明度。
——法线采用之前一篇文中说过的偏导数的混合方式,高度直接在解码后相加,最后编码(特别说明,原神中的高度混合显然不是这种方式。经过我尝试,它应该是一种比较后取最大/最小的算法,这个以后有机会再讲)。拿2.1中的五角星为旧轨迹纹理,用SD简单做一个轨迹笔刷。
——输入后可以得到下面的效果(为方便演示,左半边显示法线,右半边显示高度)。
——关于笔刷局部绘制的实现,可以参考以下伪代码(即在原本的UV0中挖出一块方形来做局部UV),并且相同的思路在之后的地形绘制范围跟随中也会再次用到。
float Remap(float min, float max, float input)
{
float k = 1.0 / (max - min);
float b = -min * k;
return k * input + b;
}
float2 _BrushPosTS;//笔刷中心UV坐标
float _BrushRadius;//笔刷归一化半径
float2 uv00 = _BrushPosTS - _BrushRadius;
float2 uv11 = _BrushPosTS + _BrushRadius;
float2 uv_Brush = float2(Remap(uv00.x, uv11.x, i.uv0.x), Remap(uv00.y, uv11.y, i.uv0.y));
3.3、与地形的联动
——如上图,实际的游戏过程中,可能产生轨迹的物体可能不止一个。他们的位置会实时发生变化(即对应shader的位置参数),并且轨迹的深浅和大小也可能产生变化(即对应shader的强度与半径参数)。
——所以Paint方法中除了原本的笔刷贴图,还至少应当包含【当前的物体位置】、【当前的绘制强度】与【当前的绘制半径】这三个参数。倘若其中的位置和半径参数还是世界空间下的尺度,在方法中还需要进行世界空间到UV空间的转化。
3.3.1、局部绘制轨迹
——说到这里就连带着引出一个问题:如何进行世界空间到UV空间的转化?考虑到像是沙漠之类的地形多是近似于z=F(x,y)形式的高度场(即没有洞穴结构那样的纵向层次),所以可以参考3.2末尾构造局部UV的思路,在世界空间的俯视投影视角(即XZ平面)下规定一个方形区域,然后将这个区域内世界坐标的xz分量归一化后当作UV(这也是shader中一种常用套路),以此对轨迹图进行采样即可。(但这也就意味着轨迹雕刻仅局限在XZ平面上,而不像zbrush中那样是任意角度的。对于任意角度的情况我在中途也曾思考过,且已经有一定的实现思路,但在本文中暂不讨论,之后若是有成果再单独发文_(:з)∠)_)
——可以看到,即便是原神内的效果也是在靠近到一定程度后,野怪的移动才会产生轨迹。这样做既可以将曲面细分控制在一个小范围内降低性能消耗,又可以让单位长度的轨迹图像素密度提高,优化效果精度,所以这步操作还是很有必要的。
——本文定义方形区域的方式是取其左下与右上点的世界坐标构成一个四维向量,效果如下。
3.3.2、绘制区域动态跟随
——因为绘制轨迹的方形范围只是地形中的一个局部,并且玩家控制的角色是运动的,所以这个区域的位置也应当是动态变化的。
——这里在当时我想到了两种实现思路:一是绘制范围相对于地形不动,把地图按一定大小的方格分配,当玩家到达一个新地块时,启动玩家所在地块及周围8个地块的绘制方法,离开时再关闭并卸载区域外的RenderTexture等动态资源(有点像是大地图分区块动态加载的概念?);二是绘制范围的中心实时跟随玩家。显然第二个看上去就简单不少,所以懒狗如我果断选择方法2_(:з)∠)_
——采用上述方法就意味着,每当玩家发生移动时,还需要额外向shader传一个UV偏移向量来使整张轨迹图发生偏移。本文将该偏移量与之前的笔刷位置参数_BrushPosTS合并为_BrushPosTS_Offset四维向量。汇总上面的思路,完善后的Paint方法如下。
public void Paint(Transform tfIN, Texture2D brushTex, float brushRadius, float brushInt)
{
Vector4 pos_Offset;
//如果输入对象是玩家,则额外计算纹理偏移与地形材质的范围参数
if (tfIN == playerTf)
{
//位移向量计算
Vector3 deltaDir01 = (playerTf.position - playerOldPos) / paintSize;
pos_Offset = new Vector4(0.5f, 0.5f, deltaDir01.x, deltaDir01.z);
//地形材质范围更新
playerOldPos = playerTf.position;
float halfSize = paintSize / 2;
Vector3 pos00 = playerOldPos - new Vector3(halfSize, 0, halfSize);
Vector3 pos11 = playerOldPos + new Vector3(halfSize, 0, halfSize);
groundMat.SetVector("_PaintRect", new Vector4(pos00.x, pos00.z, pos11.x, pos11.z));
}
//非玩家,计算当前对象相对玩家的归一化位置
else
{
Vector3 deltaDir01 = (tfIN.position - playerTf.position) / paintSize;
pos_Offset = new Vector4(0.5f + deltaDir01.x, 0.5f + deltaDir01.z, 0, 0);
}
//绘制材质参数配置
paintMat.SetTexture("_BrushTex", brushTex);
paintMat.SetVector("_BrushPosTS_Offset", pos_Offset);
paintMat.SetFloat("_BrushRadius", brushRadius / paintSize);
paintMat.SetFloat("_BrushInt", brushInt);
//刷新渲染纹理
RenderTexture tempRT = RenderTexture.GetTemporary(paintRT.descriptor);
Graphics.Blit(paintRT, tempRT, paintMat, 0);
Graphics.Blit(tempRT, paintRT);
RenderTexture.ReleaseTemporary(tempRT);
}
——仿照之前1.2说到的PS中的”绘制间距“参数,我们也可以给每个物体附加一个类似的属性stepLength,即当物体的位移(本文中使用XZ面的投影距离)超过stepLength时才调用一次Paint方法,大致是如下过程。
public Texture2D brushTex;//笔刷法线高度纹理
public float brushRadius;//笔刷半径
public float brushInt;//笔刷强度
public float stepLength;//绘制间隔
private Vector2 oldPosXZ;//上次绘制的XZ面投影位置
void Start()
{
oldPosXZ = this.transform.position;
}
public void Update()
{
Vector2 newPosXZ = new Vector2(transform.position.x, transform.position.z);
if (transform.hasChanged && (newPosXZ - oldPosXZ).sqrMagnitude >= stepLength * stepLength)
{
Paint(this.transform, brushTex, brushRadius, brushInt);
oldPosXZ = newPosXZ;
transform.hasChanged = false;
}
}
3.3.3、混乱的开端
——核心代码已全部编写完成,万事俱备只欠东风。正确配置脚本参数与挂载后,可以得到如下结果。
——其中,方块代表玩家,其余的两个球是野怪。
——暂且不论一些悬空留痕的bug还有原神中随时间淡化消失等细节还没做,单纯就轨迹的表现来看,大方向是对的,但显然有问题
四、混乱的平息
——其实对于专业人士来说,前面那些曲面细分或是blit之类的可能只是看都不用看的玩意。但从本节开始的内容才是本文的精髓,这些可能是只有实际做过类似效果的人才会知道的其中的一些坑点。但也并不是说下面的东西有多难,而只是一些细节的配置罢了。
——分析上一节末尾的效果,我们可以总结出现有的问题有以下两点:
——1、当且仅当玩家移动时,所有的轨迹都会被逐渐模糊抹平
——2、超出红色框限制的绘制范围时,不论是法线还是高度都会无限拉长
4.1、位移离散化
——因为实现用到的Blit本质上是个迭代过程,所以任何微小的误差都会在多次迭代后被无限放大。既然问题1的现象是逐渐模糊,那么说明图像本身可能在每一次计算时都经历了某种微小的模糊,但之前的shader中又并没有涉及模糊的算法。
——经排查后,最终先是将可能的原因锁定在了RenderTexture的滤波模式(Filter Mode)配置上。
——上面是RenderTexture的默认初始化配置,Filter Mode默认情况下是Bilinear模式。查阅官方文档后可知,这个模式会在采样时有个取邻域四像素进行均值模糊的内部操作。
——那么可以尝试改成无滤波处理的Point模式看看效果。
——虽然变得锐利了是个新问题,但它确实不会模糊了。不过却又犯了癫痫?
——检查一下之前C#脚本中关于玩家发生位移时的计算,逻辑上确实是没有问题的,视觉效果上大体的位置也没问题,只是有抖动,可能是存在什么微小的误差。难不成是Vector类型的数据精度不够?
——之后,我尝试将RT的分辨率改成2K甚至更高,或是将绘制范围缩小,会发现抖动的幅度都会减小。这时我灵光乍现,并顺势算了一下,原本的1K分辨率RT在64单位长度的绘制范围下,一像素对应多少单位长度。0.0625,在视觉上并不是一个可忽视的长度。
——于是,”将位移向量的各分量按RT分辨率对应的最小单位长度进行离散化截断“的想法浮现在脑中。
//位移向量计算
//···
deltaDir01 = deltaDir01 * RtSize;
deltaDir01.x = Mathf.Floor(deltaDir01.x) / RtSize;
deltaDir01.z = Mathf.Floor(deltaDir01.z) / RtSize;
//···
//地形材质范围更新
//playerOldPos = playerTf.position;
playerOldPos += deltaDir01 * paintSize;
playerOldPos.y = playerTf.position.y;
//···
——在3.3.2Paint方法的代码中做出上述微调,运行,Perfect!
——甚至开了Bilinear也没问题,顺带解决了过渡锐利的问题,三倍Icecream!!!
——总结一下这个坑点就是,即便原本在逻辑上(或者说数学上)可能没问题的代码,因为图像存在分辨率的量子化概念(遇事不决量子力学。其实计算机里的数据类型本质上都是离散的- -),所以若是原本的数学模型是连续的,就有可能导致问题。
4.2、边缘遮罩
——并不像问题1一上来没啥思路,针对问题2,当时的我大致知道,是RT的WrapMode设置为Clamp,而在shader里我又没有做范围截断导致的。
——当图像的平铺模式设为Clamp,所有在采样时UV<0的分量会按0进行采样;UV>1的分量会按1进行采样,视觉上就是在边缘拉长的效果。
——可以在笔刷绘制shader最后加一个圆形的淡化遮罩乘上去,或者不是圆形,保证UV在0和1的像素值都是”0“值且视觉上过渡自然不会穿帮即可。
float4 _Zero = float4(0.5, 0.5, 1.0, 0.5);
//边缘遮罩
float edgeMask = saturate(Remap(0.5, 0.4, length(i.uv0 - float2(0.5, 0.5))));
//混合
float4 finalRGBA = edgeMask * float4(nDirTS_NEW, h_NEW) + (1.0-edgeMask) * _Zero;
——需要注意的是,因为轨迹图的色值经过编码,所以其原本在物理含义上对应的0值(法线(0,0,1)高度0)经编码后是(0.5, 0.5, 1.0, 0.5)。
4.3、小误差截断
——俗话说“n个XXX实际上有n+1个不是常识吗?”,其实在研发过程中我还遇到了很多因为小误差导致的bug,但在非极端调参的情况下一般不会很明显。
——当把轨迹的高度缩放或法线强度参数拉的特别高时就会出现上面的情况,这是由0值不完全是0导致的。可以用类似下面的方法来抹平微小误差。
float minError = 1.5 / 255;
//···
height = abs(height - 0.5) < minError ? 0.5 : height;
//···
float4 var_RutTex = tex2D(_RutRTTex, i.uv_Rut);
var_RutTex.xyw = abs(var_RutTex.xyw - 0.5) < minError ? 0.5 : var_RutTex.xyw;
五、时间淡化
——到上面为止,该效果的主体功能已经全部完成,接下来就剩一些细节上的调整与优化,比如本节这个看上去只是个“饭后甜品”的淡化效果。然而,这个效果却是在实现时坑害我最深的一个技术点。
5.1、全局匀速衰减
——原神中的轨迹会随时间逐渐消失。淡化衰减这个效果本身不难,只不过需要另外写个衰减处理的shader,然后用Blit方法在Update里迭代,但前提是没有上图中这种“从外到内范围逐渐缩小”的效果,并且本文使用的“高度直接相加”的混合算法更是与这个效果矛盾重重。
——我在踩坑过程中也曾尝试过用一些属性遮罩纹理结合Blit来模拟这种效果,像是下面这样。
——其实当轨迹不发生交叉时,上面的效果还是看得过去的。但存在这种瑕疵还是决定它无法实际使用。
——所以,本文先给出一种最简单的淡化实现方案。概括来说,就是不管一个点当前的高度或是其他属性如何,只要不是0(编码后0.5),就让它匀速向着0变化,在shader中的写法如下:
height_NEW = height_OLD - sign(height_OLD) * unity_DeltaTime.x / _AttenTime;
——其中,sign方法返回输入值的符号(大于0返回1;小于0返回-1,等于0返回0);unity_DeltaTime.x是渲染当前帧所消耗的时间;_AttenTime是高度为1的点完全衰减到0所需的时间。
——而对于法线衰减,其实在任意点的高度都按上述规则变化时,相当于整个曲面都在匀速下降,所以只要没有降到0,法线在数学上是不会变的。但若是直接用
if (height == 0) nDirTS = float3(0, 0, 1);
————的方式将法线截断就会有下面十分锐利的淡化效果。
——所以本文让法线也按如下方式同步淡化。
nDirTS_NEW.xy = nDirTS_OLD.xy - normalize(nDirTS_OLD.xy) * unity_DeltaTime.x / _AttenTime;
nDirTS_NEW = normalize(nDirTS_NEW);
5.2、ARGB64
——上面的算法逻辑看上去很完美,但运行就会发现没啥变化,导致这个bug的原因还是精度。更进一步说,是RenderTexture的颜色格式。
——参考4.1中RenderTexture的默认设置,其颜色格式默认是R8G8B8A8_UNORM,即对应C#代码中的RenderTextureFormat.ARGB32,但对于衰减的高度计算来说,8位浮点数(即对应2^8=256阶灰度,最低的有效小数大概0.0039)的精度是不够用的。
——参考上面的高度衰减算法,假如_AttenTime给到10,而unity_DeltaTime.x一般可能也就十几甚至几毫秒,一除直接小于0.0039,在程序中可能直接自动截断为0,所以导致没有衰减。
——解决方案也很简单,采用RenderTextureFormat.ARGB64或更高精度的颜色格式即可。
六、细节优化
6.1、基于距离的局部曲面细分
——讲到这里,本文总算也是接近尾声了。这里先把2.3提到的曲面细分优化做一下。相关的局部细分策略其实有很多种,这里只给出我在踩坑过程中尝试的一种基于归一化XZ面投影距离(即绘制框内构造的局部UV空间中的距离)的优化方案。
——该优化发生在2.2.2的细分规则配置阶段。实现上大致分为两步:判断当前三角形的XZ面投影是否存在某一顶点在绘制矩形的范围内;满足上一条件时,根据边的中点到玩家的距离做lerp计算该边的细分段数,根据三角形重心到玩家的距离做lerp计算内部的细分系数。
——需要注意的是顶点索引和边索引的对应方式。在细分配置按2.2.3进行配置时,边0两端的顶点索引是1,2;边1两端的顶点索引是2,0;边2两端的顶点索引是0,1。该对应方式可能会因部分配置更改而变化(如[outputtopology("triangle_cw")]),但我没实际测试过- -
6.2、基于视差算法的轨迹实现
——考虑到曲面细分这个东西在移动端上用不了(好奇用视差是什么效果- -),所以我即兴之下试了试用视差算法的方式实现这个效果。
——对于Blit方法的应用还是采用本文中的方式没变,只不过在地形shader中利用其中动态绘制的高度图进行视差采样而不是曲面细分,效果如下。
——以上是迭代10层的效果。可以发现,除了像是闪烁效果像是飘在轨迹上还需要额外做些处理、以及物体与地面的交界线是平的(毕竟视差不改变网格结构),如果不仔细观察其实效果尚可。
——下面还有个只使用法线贴图的效果作为对比。
七、项目工程链接
——个人不会用github,而且好像要挂梯子,所以退而求其次用百度盘- -