移动端天气系统--【下雨】效果之【雷电】的实现和分析


文章较长,建议跳到感兴趣的部分阅读,转载请标明出处。

一、环境

Win/Android/iOS,Unity3D


二、方案对比

  Nividia的Sample里,说了4种产生雷电的方法,其实一般用于实时渲染的就这两种。
  基于特效的方法。由特效/美术人员预先制作好雷电的Mesh动画或贴图。运行时,直接播放Mesh动画或采样贴图。这种方法的好处就是定制程度高,想要啥形状都可以。最大的问题是不灵活,只能使用预先制作好的外观形状,也无法批量使用,因为会很容易看出形状的重复。
  基于分型图形学的方法, Fractal L-System。还有一种比较普遍的方法是使用分型图形学里的L-System方法,按一定的分型规则生成形状。这种程序化生成方法最大的好处就是能够随机地,生成大量形状各异的雷电,通过控制参数模拟出理想的形状,也完全免去了特效/美术人员的工作量。缺点也很明显,因为它的生成依赖于随机算法,所以形状不能保证百分之一百理性,并且分型一般是由大量“图元”组合而成,当数量过于庞大时会对性能造成压力。



三、思路概述

整个雷电效果的实现分为三部分。
第一部分是:怎么生成用于雷电渲染的Mesh
第二部分是:怎么让雷电正确地显示出来
第三部分是:效果实现完成后,应该提供怎么样的接口和编辑器功能给外部使用。
我的思路是,在CPU侧按照L-System的方法生成Mesh,在GPU侧计算Billboard和中心扩散效果。最后,除了提供基本的生成控制参数,还提供“落雷区域”的概念,方便美术/策划人员配置使用。



四、关键技术点

1.分型L-System是如何产生曲折(ZigZag)和分支(Fork)的

基于物理渲染出来的雷电
【基于物理渲染的雷电】
  在我们的意识里,雷电就是百步九折,旁逸斜出的(好不容易才挤出这些成语:-) )。L-System通过重复将直线变折线,并延长其中一段,来生成这种树根状结构的Mesh。

步骤如下:
产生曲折和分支的过程
  从①到②:对一线段AB,取【中点】C将线段分为两段,并将C沿【中垂线】偏移AB长度【20%】的距离,便产生了ZigZag的效果。然后从沿AC方向延长AB长度的【30%】得到CD,并将CD绕C电旋转【20°】。到此完成一轮分型生成。(以上带【】的参数均为建议值)
  从②到③:重复上述步骤,并对参数加以控制和约束则可以得到比较理想的雷电雏形

2.随机化,2D到3D的迁移

  从生成的步骤看,所有点都是在同一个平面的,既然要做,为什么不做三维的呢?否则跟直接贴图有啥区别?
  2D到3D的迁移设计两部分工作,一个是随机参数需要推广到三维,一个是Billboard的实现。

2.1.随机参数推广到三维

  需要推广到三维的参数是中点的偏移位置和分支的旋转方法。

2.1.1.中点的偏移位置

  在二维情况下,每一段的中点可以从沿中垂线偏移,推广到以中点为圆心,以一定的偏移量为半径,选定圆上随机一点作为折点,构造折线

线段中心偏移到圆上
【线段中心偏移到圆上】
那么在三维情况下,就可以推广到以中点为球心,以一定的偏移量为球半径,选定球面上随机一点作为折点,构造折线

线段中心偏移到球面上
【线段中心偏移到球面上】

2.1.2.分支的旋转

  这个会稍微复杂一点。
在二维情况下,我们可以将延长线绕折点旋转,以折点建立极坐标系
极坐标下,需要两个随机参数
【极坐标下,需要两个随机参数】

并且限制旋转角度在【0°,180°】。限制在这个区间是为了避免后续分型过程中,分支往上生长,这不符合我们看到的雷电的样子。二维的情况下,需要两个随机参数,一个是极半径,一个是极角。

球坐标下,需要三个随机参数
【球坐标下,需要三个随机参数】

  推广到三维,就是建立球坐标系,并限制天顶角为【0°,90°】。三维的情况下,需要三个随机参数,一个是球半径,一个是方向角,一个是天顶角。

2.2.Constrained Quads

  推广到三维最重要的一步是,我在不同角度观察这个雷电的时候应该是什么样子的。我选择的实现方法是Constrained Quads1,有约束公告板。

  使用无约束公告版的话,雷电的每一段都会平行于视平面
无约束的错误
【无约束的错误】
导致雷电的结构被破坏。而正确的样子应该是,无论从哪个角度看,片段的切线方向都是沿雷电的生长方向的。

2.2.1.建立逐四边形的局部坐标系

  书里介绍的约束四边形的约束轴是世界坐标的上轴,但正如上文所说,我们需要约束轴是片段的切线。约束四边形的实现方法,本质是以四边形原点建立局部坐标系,相机位置投影到在以约束轴为法线,过原点的平面,在平面上作投影向量的垂线,以此构建坐标系,并重新展开四边形的顶点到这组正交基上。
约束四边形的构造
在我的实现中,顶点数量会在2000~20000,如果将billboard计算放在CPU每帧计算会相当耗性能。为了节省每帧计算顶点位置的消耗,我将billboard的朝向计算放在了GPU侧。而这就带来另一个问题,怎么建立逐四边形的局部坐标系。

  我的做法是,一开始假设观察方向是朝正Z方向,以片段的起点到终点为正切线方向,以正Y轴为上轴,通过施密特正交化获得片段的法线和副切线。需要注意的是,所有的四边形都同属于一个Mesh,也就是说,只有一个原点,而我们计算片段的顶点位置时是要通过原点约束的。所以需要通过顶点属性传入每个片段的原点。

var tangent = (seg.end - seg.start).normalized;
var bitangent = tangent.x < 0 ? Vector3.up : Vector3.down;
var normal = Vector3.Cross(tangent, bitangent);
bitangent = Vector3.Cross(normal, tangent);
2.2.2.切线方向约束和朝向相机

  要达到“尽可能面向相机”的效果,且只能绕切线旋转,可以将相机向量投影到副切线和法线组成的平面上,投影向量和法线的夹角,就是片段需要旋转的角度。
在这里插入图片描述
通过上面说的方法获得OA,OB,OC三个正交基后,要令四边形“尽量”朝向相机,就要让它的法线LookAt相机位值向量OP在平面BOC上的投影,OP‘。即整个四边形需要旋转∠BOP’。

  可以看到,这个旋转变换实际上是需要一个绕任意轴旋转的变换。最理想的就是使用四元组进行旋转,而shaderlab里是没有内置四元组运算的,需要我们自己实现一个简单的四元组乘法运算,根据旋转轴和旋转角度构建四元组,旋转后的位置等于四元组,原位置和共轭四元组的连乘。

inline float4 QuatCross(float4 a, float4 b)
{
	return float4(
		a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
		a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
		a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
		a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z
	);
}
2.2.3.多段面2

  其实多段面的方式会更适合这种绕切线旋转,且尽可能面向相机的需求。Unity的LineRender这是这种实现,但它的朝向只能在CPU计算。读者可以权衡两种方法,择优而用。

3.控制生成位置

  怎么控制生成位置也是值得拿出来说一下的。
在这里插入图片描述
  我设计了一个“落雷区域”的概念,在柱坐标系下,标记起点和终点。一道雷电的生成位置有参数决定,落雷中心,起点最高高度,起点最低高度,终点最高高度,终点最低高度,区域最小圆心距,区域最大圆心距,起点和终点跨越的圆心角。这些参数可以灵活控制雷电的生成区域,平时可以将区域半径覆盖整个场景,可以看到在远处落雷的效果。而这些参数都是可以动态修改的,与分型构造无关,如果有特殊需求,譬如剧情里,需要在特定位置落雷,只需要修改中心和半径即可。



五、效果优化

1.控制迭代次数和分支次数

  为了有更好的效果,一般迭代次数越多越好,但那是有很大的性能代价的。所以需要根据观察距离和雷电的长度而定,建议值是10次左右,相当于一根折线由1024个片段组成。限制分支数量也是一样的,分支过多不仅会影响性能,同时也会影响外观,太多分支会让雷电看起来很不自然(变成了树根)。

【控制分支次数,我用这种方法有效地限制了分支数量,同时保证分支的迭代次数】

if ((k < (maxForkGen + seg.generation)) && (seg.generation < maxForkGen))
{
 // generate fork
}

2.闪电的模拟

  行雷闪电,雷雨天的要素除了有落雷,还会有闪电,闪电的模拟挺值得玩味的。先来看看其他作品都是怎么实现的吧。
  落雷的时候,云层会被照亮,在一梦江湖(楚留香)里是直接用一个billboard来模拟被照亮的云层。
  天刀里,闪电时全场景会被照亮,而且阴影角度是会有改变的。在手游上,大部分阴影都是烘焙的,如果强行改变模型阴影(一般是实时阴影)方向,阴影方向会显得不自然。
  动画里的实现其实也可以借鉴以下,如《幻镜诺德琳》第二季第一集12:13~12:15处,还有不良人第三季第32话【05:30 ~05:31,https://www.bilibili.com/bangumi/play/ep280817】里面的闪电,都是通过黑白调色,甚至是瞬间的黑白屏营造出来的,借鉴于此,其实我们还可以通过后处理来模拟闪电瞬间的亮度变化的视觉效果。

  通过给场景物体和角色模型一个全局的色调控制成分,相当于一个全局的亮度控制,可以用来模拟闪电。用一个合适的缓动函数来改变雷电的亮度会十分加分,合适的缓动函数能够形成残留的错觉,easeInOutBack可能是个不错的选择。



六、性能优化

1.渲染性能

  由于雷电出现往往只有一瞬间,而且一般出现在远方所以需要填充的像素还很少。基本不会造成渲染上的压力,在低端机上(Snapdragon430,Adreno505)GPUBound情况下,也只有1ms左右瞬间波峰。

2.CPU性能

  性能压力主要在CPU压力。
  步骤是先生成线段,然后用线段Segment位置生成四边形,一开始是用字典记录这种映射关系的。但已经过Profile就发现这里产生了大量的GC,导致频繁触发了GC.Collect。原因是对于自定义的结构体,而且没有自定义比较函数Equal,Dictionary内部会使用默认的比较器,里面有多个装箱操作。大量的查找会导致大量的装箱操作和堆内存的分配。覆盖默认的Equal函数能比较好的优化这个问题,当然最好的是避免使用Dictionary,这让我的性能提高了数百毫秒。
  一些存储中间过程数据的容器也可以改成静态成员,避免每次计算都发生扩容操作,在关闭天气时释放掉即可
  其余主要的压力来源于向量运算,这部分除非更换图元类型避免计算四边形的顶点位置或者使用SIMD指令,否则很难优化。
  虽然上述方法已经节省了大量的性能,但大量的向量运算和内存拷贝等操作依然十分耗时。我们的雷电不是每一帧都需要生成的,甚至是很长一段时间才需要出现一次,因此我实现了一个摊帧创建的策略,将生成折线和生成Mesh的步骤按需要平摊的帧数来进行,譬如20帧完成一个雷电,那么原本每帧数万次的向量运算就可以大幅降低。可以以通过控制平摊帧数,保证适配不同机型的性能。


七、遇到的问题

1.重叠部分穿帮和过亮

  我们是通过四边形来组成雷电,那么在衔接处如果转角太大,或者四边形太宽就会看到明显的穿帮。
四边形宽度变大时出现棱角
【四边形宽度变大时出现棱角】

  解决方法主要参考了Nvidia的sample和Drillian的文章。
  Nvidia的是在线段两侧增加顶点,配合shader计算形成了两个cap,混合方式是Blend One One。
  Drillian提供的方案是在线段相交处的平分线的垂线方向延长(不知道有没有理解错误),混合方式估计是Blend One One或者SrcAlpha OneMinusSrcAlpha。
  Blend One One的光溢出效果确实比较好,但问题也很明显,就是衔接处会形成较亮的“节点”。对衔接处过亮的问题,最理想的解决方案在混合方式为BlendOp Max的条件下,使用单独的相机渲染再以后处理方式画到主相机的画面上。但这对于移动端太昂贵了。

重叠处过亮的问题
【Blend One One,头尾加cap,重叠处过亮的问题】

  所以,我认为比较合理的混合方式是BlendOp Max,然后配合在线段的末端加cap(注意,是只在末端加,而不是两端都加)。虽然这种混合方法在某些情况下会有问题,但在我们的游戏中还是比较理想且能接受的。
  最后再说一下这个cap的生成问题。Nvidia的sample里是通过新增顶点然后用顶点属性控制插值形成的。我用的是unity2017版本,默认是不支持triangle strip图元类型的,只能用triangle图元类型生成mesh,本来在mesh生成和内存上已经有一定压力了,所以避免增加这个负担,我使用了一个比较trick的方法。大概思路就是我只需要找到半高位置,以这个位置开始径向衰减即可,因为只需要宽高比,直接将他作为切线长度压缩到切线信息里即可。
Shader控制Cap的径向衰减
【Shader控制Cap的径向衰减】

float2 center = float2(0.5, 0.5);
float leftX = 0.5 / (segment_width / segment_height);
float rightX = 1 - leftX;
 //end cap
int isEnd = step(rightX, i.uv.x);
i.uv.x = lerp(center.x, lerp(0.5, 1, (i.uv.x - rightX)/ leftX), isEnd);
float dist = saturate(1 - 2 * length(i.uv - center));
fixed3 tint = lerp(_OutsideColor.rgb, _InsideColor.rgb, dist);
fixed4 col = fixed4(dist, dist, dist, dist);
col.rgb *= tint * clamp(1 - 0.2 * (generation - 1), 0.1, 1) * _Intensity;
return col;

最终效果如下:
最终效果图
【最终效果图】



八、关于后续改良的思考

  这节开始前,各位读者可以看下以下这些视频的雷电:
1)动画《鬼灭之刃》的霹雳一闪00:00~00:15,还有这里01:20~02:04
2)《鬼泣-巅峰之战》的CG00:12~00:14
3)《哪吒之魔童降世》里的雷劫98:14~101:25
  一言蔽之,雷电效果做好的话实在是太帅了!而且这个雷电效果不仅可以用于天气,还可以用于技能。所以在这里也记录一下我对雷电效果改进的思考。

1.顶点动画

  改变亮度能模拟闪烁,一定程度提升了效果。如果能让雷电一定程度的动起来,效果会更霸气。
  在我的电脑(CPU i7-8750H),Unity Editor下Profile,每毫秒大概能处理1000个向量运算。在移动端只会更少。而要让雷电动起来,大量的顶点做变换运算,所以现在这种用多个四边形组成雷电的方法肯定是不明智的。
简单的可以通过简单的平移和缩放模拟单个雷电的运动,并配合这里提到【贴Drillian的话和链接】说的。
  比较理想的实现方式应该是使用多段面(请参见上文),并用Triangle Strip图元来绘制,这样顶点数量和顶点索引数量都可以减少一半,变换的运算量也会减少一半。(最理想的应该是在几何着色器里做,这样图元生成和动画就集于一身了,Nvidia的sample就是这样做,可惜几何着色器在移动端有兼容和性能问题,暂不予考虑)



九、参考

[1] Procyon(你能在Steam上搜到这个游戏: ) )

[2] Nvidia D3D10 Samples, Lightning

[3] 天涯明月刀端游



  1. Mathematics for 3D Game Programming and Computer Graphics,Chapter9.3.2,Constrained Quads ↩︎

  2. Mathematics for 3D Game Programming and Computer Graphics,Chapter9.3.3,Polyboards ↩︎

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值