前言
前不久重构草地渲染系统 VegeRenderer 时顺便调研了几种角色压草效果的实现,并且与自己的原版实现方式做了下对比,打算在此做个简单总结,并且介绍下所采用的最终方案。
方案一
也是我之前使用的最简单直接的方案:运行时将所有在热点区域的角色中心坐标点压入一个专门开辟的Compute Buffer
中,然后在Gpu端挨个读取其中坐标数据,结合当前顶点位置和高度等信息,用手K的数学公式的方式来修改顶点位置,模拟压草效果。光说有点抽象,我们上段修改顶点的代码:
float3 CheckBending(float3 PosCenter)
{
if (_BendingCount == 0)
return float3(0,0,0);
float4 cur;
float d;
for (int i = 0; i < _BendingCount; i++)
{
cur = _BendingBuffer[i];
d = distance(PosCenter.xyz, cur.xyz);
if (d < cur.w)
{ //,
return float3(cur.xz, (cur.w - d) / cur.w);
}
}
return float3(0, 0, 0);
}
float3 ApplyBending(float3 aBendingInfo, float3 posWS, float modelY)
{
float2 dir = posWS.xz - aBendingInfo.xy;
dir = normalize(dir);
float piOver2 = 1.5708;
float thetaRange = 0.8;
float theta = piOver2 - aBendingInfo.z * thetaRange;
float s, c;
sincos(theta, s, c);
dir *= modelY * c;
float3 addOn = float3(dir.x, modelY * (s - 1), dir.y);
return posWS + addOn;
}
方法有2个,其中第一个 CheckBending
用于检测当前模型顶点与每一个处于热点区域角色的位置之间的距离,如果满足一定距离,则计算并返回一个BendingInfo
,这个数据对象的xy分量很简单,就是目标角色世界空间中的xz坐标,而z分量则是经过处理的强度因子,遵循离得越近,强度越大的朴实设定。
第二个方法处理具体的下压操作,所以这个方法只有在BendingInfo
返回值的z分量不为0时才会被调用。其中入参1前面已经讲过,不在赘述;入参2是当前顶点在世界空间中的xz分量;入参3是该植被模型顶点在模型空间中的高度。方法的意图也很简单,就是让顶点自身的模型高度、传入的强度因子一起去和三角函数做混淆,最后沿着中心指向顶点的方向,向前和向下压草。
简单说明下问题,首先是移动端GPU对ComputeBuffer
数量是有一个比较苛刻的限制的,比如早期的小米5在渲染顶点阶段就限制绑定到vertex shader上CB不能超过2条,现在压草就占去了50%的资源会极大限制后续的发挥,所以CB是个能少用就少用的选项。然后是CheckBending
这个函数不够Scale,随着热点区域人数增多,很显然方法内那个带着距离计算公式的for循环会消耗更多的资源。最后则是当前压草算法的一点缺陷:眼明的同学可以已经注意到了,我对顶点的处理是相当于是按照模型空间的(0,0,0)
点为基准进行处理的,这就导致一些处于模型边缘成对的顶点在变换后并不能保持之前的对于关系(比如叶子的宽度在下压过程中突然变宽了数倍)。
方案二
这其实也是一类方法,变种相信会很多,我就只介绍当前使用的形式了。简单概括起来,方案二传递数据的载体是RenderTexture
,共分为2个部分,第一部分是在运行时生成带有压草数据的RT,类似于将信息编码到纹理中;第二部分是在处理植被顶点数据时采样RT,将信息读取并解码成可用的数据,直接应用到当前顶点数据的改写当中。我们下面分开讲解。
第一部分(生成RT)
RenderTexture
是 Texture
类的子类,专门用于接收来自FrameBuffer
的数据,数据的生成可以通过Compute Shader专门处理,也可以通过类似Graphic.CopyTexture这样的接口来实现,这里我们采用最简单省力的方式,正交摄像机。
步骤是这样的:先在主角色头顶上方一定距离布置一台正交相机,确保其始终垂直向下观察角色及其周围一定范围的区域(如图1),然后创建一张RT并设置给这台摄像机(确保这台相机的帧缓存目标是你创建的这张RT即可,RT本身也可以由程序在运行时创建,但是我推进直接以资源的形式在Unity工程中创建RT实例,方便后续直观的修改)
给相机设置好输出纹理后,还需要设置一下 Culling Mask,这是为了确保相机只渲染我们感兴趣的东西(数据!)并且足够高效。
接下来是真正重要的部分:我们要这台摄像机拍摄什么感兴趣的数据呢?当然是详细的压草信息啦,比如草的倒伏方向,倒伏角度等,这些数据我们可以通过编写简单的顶点和片元shader来计算获取:
v2f vert (appdata v)
{
v2f o = (v2f)0;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
VertexPositionInputs vertexInput = GetVertexPositionInputs(v.positionOS.xyz);
o.vertex = vertexInput.positionCS;
o.uv = v.uv;
return o;
}
half4 frag (v2f i) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(i);
half4 col = _BaseColor.rgba;
half2 xy = i.uv - 0.5; // -0.5 ~ 0.5
half len = dot(xy, xy);
if (len < 0.25)
{
col.b = clamp(sqrt(len), 0.0001, 0.5);
col.rg = xy / col.b;
col.b = (0.5 - col.b) * 2; //0 ~ 1
}
return col;
}
顶点没什么可说的,可以想象我们将来会把这个shader应用在一张正方形面片(Quad)上,所以只需要传递好坐标和uv即可。片元这里稍微复杂一丢丢,主要的逻辑是以 uv = (0.5, 0.5)
为中心点,画一个半径为0.5的圆,凡是不再圆内的像素点都使用目标缓存中的原有颜色,而在圆内的像素,设置其rg通道为草倒伏的方向(归一化后),而b通道是一个0~1区间的强度因子,确保离圆心越近,强度越大这样的朴素设定。
shader搞定以后可以顺手把Quad模型和材质实例都创建出来,此时别忘记将上图中左侧的模型网格所属的layer设置为正交摄像机所关注的类型。如果一切顺利,把Quad丢到场景中让正交相机能够看到,我们可以得到类似下图中的效果:
注意到上图中彩色轮环左下角有部分黑色,其实这里存储的大多是负值,代表西南方向,由于Unity显示色彩时会自动clamp数值到[0, 1]
区间,所以才看起来是统一的黑色。至此,第一部分数据生成就完成了。
第二部分(解码RT中的数据)
当确定输入数据格式后,这部分就相对很好做了,我们通过分析代码来重现过程。
首先是在渲染植被的顶点shader中,进行的处理
if (IN.positionOS.y > 0)
{
//apply local wind(bending)
if (WithinLocalWindMap(posCenterWS.xz))
{
half3 dirForce = GetLocalWindDirXYForceZ(WorldToLocalWindUV(posCenterWS.xz));
if (dirForce.z > 0)
{
half hRate = saturate(IN.positionOS.y / 1.5); //TODO: use uv1 (NO MAGIC NUMBER!)
half angle = sin(hRate) * dirForce.z * 2.5;
half3 localWindDir = half3(dirForce.x, 0, dirForce.y);
half3 axis = cross(localWindDir, half3(0, -1, 0)); //dir cross product with down vector
posWS = RotateAroundAxis(posCenterWS, posWS, normalize(axis), angle);
}
}
//apply global wind
posWS += GetBackGroundWind(posCenterWS, IN.positionOS.y);
}
代码中3个大大的if条件判断语句分别代表了
- 是否是地表之上的植被顶点
- 是否处于热点区域之内(正交摄像机的视场范围内)
- 是否被角色压着了(强度因子大于0)
额外说明一下,代码中WorldToLocalWindUV
方法是用来将单株草的根节点变换到正交相机的ViewSpace中去,也就是RT所对于的纹理空间,以便能正确的执行GetLocalWindDirXYForceZ
获取采样后的数据 dirForce。而在此之后的处理则可以简单描述为:
-. 获取植被当前顶点的高度比率 hRate
(也就是当前点相对于模型最高点的占比,一般可以由美术同学直接刷入uv中)。
-. 依据hRate
以及读入的该位置的强度因子dirForce.z
进行混淆,计算出一个旋转角度 angle
,显然这个角度正比于刚才提到的2个参数,且高度因子还做了个平滑处理,避免太过线性的结果出现。
-. 接下来一行很简单,重建世界空间中的倒伏方向向量,由于原始xy分量已经做了归一化处理,所以当y设置为0时,无需再做归一。
-. 接下来是计算转轴,可以用刚才的方向向量叉乘向下的单位向量即可。
-. 最后是引用旋转公式,输入选择的基点,待选择顶点,转轴还有选择角度,并将返回的结果赋值给顶点坐标即可。
下面附上一份标准的旋转公式以供参考:
//center = rot pivot; original = target point; u = rot axis; angle = rot angle in radian
float3 RotateAroundAxis(float3 center, float3 original, float3 u, float angle)
{
original -= center;
float C, S;
sincos(angle, S, C);
float t = 1 - C;
float m00 = t * u.x * u.x + C;
float m01 = t * u.x * u.y - S * u.z;
float m02 = t * u.x * u.z + S * u.y;
float m10 = t * u.x * u.y + S * u.z;
float m11 = t * u.y * u.y + C;
float m12 = t * u.y * u.z - S * u.x;
float m20 = t * u.x * u.z - S * u.y;
float m21 = t * u.y * u.z + S * u.x;
float m22 = t * u.z * u.z + C;
float3x3 finalMatrix = float3x3(m00, m01, m02, m10, m11, m12, m20, m21, m22);
return mul(finalMatrix, original) + center;
}
至此,第二部分也讲完了。
效果
目前看性能上和原方案少人状态时相差不大,由于使用了旋转矩阵处理偏移,所以整个下压过程看起来更加自然,也没有了顶点之间的比例失衡等问题。