看了一些星际大战之后,想做一个激光武器,既然地球online里面做有点危险,那还是在unity里面做一个吧,先来看看效果
最初呢,链接的激光是瞬间生成的,有一些假,第二个做了一些优化,可以看到链接丝滑了很多,第三个做了顶点动画看起来要更有威力了一些,看看怎么实现的吧,也可以取代码自用。
需求 有几个基本的需求
当激光路径碰到敌人,激光会锁住敌人,并产生连锁。
当敌人走出一定范围,链接会断开,重新生成链接。
当有新的敌人进入范围,而且没达到连锁上限,可以再次重新生成链接。
另外有几个自己可有可无的需求
用点光源照亮被连锁到的敌人
光线是有动感的,不能仅仅只一条线,那太low了
光线打出去是有速度的。
成员变量 首先是一些变量,建议跳过。
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 #region 共有变量 //可供调节激光参数相关 public int maxAtkNum = 5; public int atkLinkRange = 7; public float maxDistance = 20; public float pulseSpeed = 0.7f; public float minWidth = 0.5f; public float maxWidth = 0.6f; public float laserSpeed = 8; #endregion #region 私有变量 private LineRenderer lineRenderer; //控制状态切换 private Ray ray; private RaycastHit hit; private bool isAtking = false; //转折点相关 private GameObject[] lights; private Collider[] colliders; private HashSet<Collider> enemyIsAtking = new HashSet<Collider>(); private int pointSize = 1; private Vector3[] tempPoints; private Vector3 lastPos; private float startTime; private int curPointSize = 2; #endregion
update 我们可以想到使用射线来检测我们的激光是否碰到了第一个敌人,这很重要,因为当激光是否连锁敌人对line组件的选点完全不同,所以,我们需要一个保存一个RaycastHit来判断状态,只有打到第一个敌人才开始连锁。击中敌人之后该怎么做呢?我将它分为四个状态,分别是NotAtking、Atking、NotAtk2Atking、Atk2NotAtking,控制四个状态进行转换的变量为前面我们保存的RaycastHit和一个表示是否开启攻击状态的bool值。先来看update代码吧:
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 void Update() { //让控件依附在父物体身上 transform.localPosition = Vector3.zero; transform.localRotation = Quaternion.identity; //控制射线检测方向 ray.origin = transform.position; ray.direction = transform.forward; Physics.Raycast(ray, out hit, maxDistance); //控制四种状态的切换 if (!isAtking && hit.transform) { NotAkt2Atking(); } else if (isAtking && !hit.transform) { Atk2NoAkting(); } else if(isAtking && hit.transform) { Atking(); } else { NotAtking(); } }
C++ Primer告诉我们最好先实现宏观调用,再去写细节方法,这里通过射线检测返回的hit和bool值在四种状态进行切换,有人可能会问:你这个怎么怎么不传参数啊,当然可以,但我想在这里尽量不在Atking和NotAtking中使用参数传递来调用函数,因为这个函数在update中调用频率非常之高,倒不如全部使用成员变量,这点空间换取的时间是相当值得的。
四种状态 当然我们接下来要去实现这四种状态,从最简单的开始吧,notAtking()函数,它只需要绘制一条慢慢变长的直线就可以了:
1 2 3 4 5 6 7 8 9 10 11 12 void NotAtking() { //用插值取得终点位置。 Vector3 endPos = Vector3.Lerp(lastPos, transform.position + transform.forward * maxDistance, (Time.time - startTime) * laserSpeed); lineRenderer.SetPositions(new Vector3[] { transform.position, endPos }); //之后会提到 curPointSize = 2; }
没什么好说的,非常简单,第二个自然就是NotAtk2Atking(),这里需要弄清楚链接逻辑,这个切换函数所要做的就是把一切数据都准备好,它主要是为Atking()函数做准备,先看代码:
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 //这里的形参后面会提到 void NotAkt2Atking() { //还记得控制状态的切换的两个关键吗?这是其中之一,这里新建局部对象用来作为循环条件 Collider col = hit.collider; //用来收集范围检测到的敌人 Collider[] tempCol; //循环寻找下一个单位 while (col && pointSize <= maxAtkNum) { //加入hashset中 enemyIsAtkingSet.Add(col); colliders[pointSize - 1] = col; //范围检测,这里检测层级可以自己定义 tempCol = Physics.OverlapSphere(col.transform.position, atkLinkRange, 1 << LayerMask.NameToLayer("Corpse")); if (tempCol.Length == 0) col = null; //依次遍历数组,若没有在hashSet中,就可以继续循环 else { col = null; foreach (Collider collider in tempCol) { if (!enemyIsAtking.Contains(collider)) { col = collider; break; } } } //用于记录总共需要多少个点,默认值为1个,每多一个敌人就增加一个 pointSize++; } //设置状态准备进入Atking()函数 isAtking = true; //此变量是为了控制激光进行位移 startTime = Time.time; }
这个函数中使用一个Colloder类型的col作为循环条件,依次寻找到所有单位,当然还可以设置一个上限值,其中范围检测因为仍然会找到已经被链接的单位,所以这里选择使用hashSet来将它们排除。剩下一个变量curPointSize,它为当前已链接点数量,在Atking中就可以明白它的作用。Atking太长了,先看第一部分吧:
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 void Atking() { //为Atking2NotAtk做准备,我不希望看到断开之后是重新从自身位置慢慢出来,而是从敌人这个位置继续向前 lastPos = hit.point; //这里需要判断的是当敌人之间距离过大,我们需要重新进行状态的转换 for (int i = 1; i < pointSize - 1; i++) { if ((colliders[i].transform.position - colliders[i - 1].transform.position).magnitude > atkLinkRange * 1.2f) { //为了让画面更连贯,我们直接跳过NotAtk阶段 Atk2NoAkting(); curPointSize = i + 1; NotAkt2Atking(); return; } } //防止有敌人挡在了第一个敌人和自身之间 if(hit.collider != colliders[0]) { Atk2NoAkting(); curPointSize = 2; NotAkt2Atking(); return; } ... }
curPointSize = i + 1;
该参数的意义是下一次进入Atking函数时,从哪个点开始渐进光线,总不能状态一变就从头开始吧。后面的逻辑就是激光的渐进逻辑,具体分为两个不同的状态,一个是渐进状态,一个是光线已经全部链接完毕状态,具体看代码:
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 void Atking() { ... //渐进状态 if(curPointSize < pointSize) { //创建临时数组,该数组之后直接传到lineRenderer组件中 tempPoints = new Vector3[curPointSize + 1]; //第一个点是自身,中间点为已经链接到的敌人位置,最后一个点是从倒数第二个敌人向最后敌人渐进 tempPoints[0] = transform.position; for (int j = 1; j < curPointSize; ++j) { tempPoints[j] = colliders[j - 1].transform.position; } tempPoints[curPointSize] = Vector3.Lerp(tempPoints[curPointSize - 1], colliders[curPointSize - 1].transform.position, (Time.time - startTime) * laserSpeed); //设置点数量并传入数组 lineRenderer.positionCount = curPointSize + 1; lineRenderer.SetPositions(tempPoints); //当渐进点和渐进位置差的不多就可以认为已经到达 if (Vector3.Distance(colliders[curPointSize - 1].transform.position, tempPoints[curPointSize]) < 0.2f) { ++curPointSize; startTime = Time.time; } //别忘了使激光看起来更有威力的灯光效果,将它们点亮并移动位置 for (int i = 0; i < curPointSize - 2; i++) { lights[i].SetActive(true); lights[i].transform.position = tempPoints[i + 1]; } } //链接完毕状态 else { //老五样了,最后一个点不需要渐进了。 tempPoints = new Vector3[pointSize]; tempPoints[0] = transform.position; for (int i = 1; i < pointSize; i++) { tempPoints[i] = colliders[i - 1].transform.position; } lineRenderer.positionCount = curPointSize; lineRenderer.SetPositions(tempPoints); //开灯,比上面多一盏 for (int i = 0; i < pointSize - 1; i++) { lights[i].SetActive(true); lights[i].transform.position = tempPoints[i + 1]; } //还需要在最后一个位置继续判断有没有新加进来的敌人 Collider[] tempCol = Physics.OverlapSphere(tempPoints[pointSize - 1], atkLinkRange * 0.8f, 1 << LayerMask.NameToLayer("Corpse")); //同样的逻辑 foreach (Collider collider in tempCol) { if (!enemyIsAtking.Contains(collider)) { Atk2NoAkting(); NotAkt2Atking(); return; } } } }
这里需要提到之前NotAtking中的CurPointSize = 2
了,因为我们中Atking中的转换为了画面流畅都跳过了NotAtking这一步,所以需要补上,不然就会出现下次Atking时很多多余的奇怪激光。
其他看起来没有什么困难吧,但其实这里各种各样的边界条件,数组下标的选取让你各种越界,还好都是小问题,最麻烦的Atking结束,那有请最后的状态切换函数登场:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 void Atk2NoAkting() { for (int i = 0; i < maxAtkNum; i++) { lights[i].SetActive(false); } pointSize = 1; enemyIsAtking.Clear(); isAtking = false; lineRenderer.positionCount = 2; Array.Clear(colliders, 0, colliders.Length); Array.Clear(tempPoints, 0, curPointSize.Length); startTime = Time.time; }
非常朴实,这里都是处理我们Atking留下来的烂摊子,把它们收拾好之后,准备下次的Atking或者是NotAtking函数。
生命周期函数 还有一些初始化相关,基本搞定!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private void Awake() { lineRenderer = GetComponent<LineRenderer>(); } // Start is called before the first frame update void Start (){ //把各种各样的东西先初始化一下 lastPos = transform .position; colliders = new Collider[maxAtkNum]; //点光源池 lights = new GameObject[maxAtkNum]; for (int i = 0 ; i < maxAtkNum; i++) { lights[i] = GameObject.Instantiate(Resources.Load <GameObject>("redPoint"), transform .position, Quaternion.identity ); lights[i].SetActive(false ); } }
动感光波 让我们先来看看真实的激光大概是什么样子的
大部分外围有淡淡的光芒(我还是比较喜欢紫色激光),中间有一条白色线条,但就这对我来说还是有点不够有表现力,我希望有动态效果,于是在此基础上添加了纹理动画以及顶点动画,这样看起来更有威力了不是吗?
我们很容易想到用一个纯白色来根据片元的v坐标的sin函数实现,将0-1的线性变化映射为0-1-0平滑过度的曲线,再搭配Gloss对其次方控制宽度。
之后是外围我想要实现的纹理动画效果了,用ps做了一张左右都为蓝色,中间为紫色的纹理,为保证平滑过渡,所以左右两边必须是一模一样的颜色,之后在顶点着色器中将顶点的uv坐标进行移动,_Time
是unity定义的计时器,为float4类型,t代表自场景加载所经过的时间,4个分量分别为(t/20,t,2t,3t),不论用哪一个,将它与我们的ColorSpeed相乘取小数,就可以得到0-1之间的一个数与转换的顶点uv相加就可以得到不断移动的纹理了,但由于这样的方向是反的,我们还需要用1减去小数仍然为0-1之间。
别忘了还有抖抖的效果,也就是我们的顶点动画,这里首先我们一定会想到用随机数,那怎么取得随机数呢?这里可不能像C#脚本一个Ramdon.Range()就得到随机数,所以这里需要想新的方法,在图形学中这种随机也称为噪声,它通常用于实现完全不规则的一些效果,如岩浆、电波等等,具体见参考文献第一篇。这里用三个魔法数字可以得到近乎完全的随机数,第一个问题解决了,还有一个问题是我们不希望整条线都在抖动,那样也会让人感动非常不真实,所以这里我同样让它乘以一个sin函数,映射为0-1,抖动幅度随距离增加,另外通过Magnitude来调节整体抖动幅度。
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 // Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)' Shader "Particles/Additive" { Properties{ _TintColor("Tint Color", Color) = (1,1,1,1) _MainTex("Particle Texture", 2D) = "white" {} _InvFade("Soft Particles Factor", Range(0.01,3.0)) = 1.0 _ColorGloss("Gloss", Float) = 10 //颜色渐变 _ColorSpeed("Color Speed", Range(0.01, 10)) = 1 //顶点动画 _Magnitude("Distortion Magnitude", Float) = 1 _Frequency("Distortion Frequency", Float) = 1 } Category{ //分别为开启透明通道,开启渲染类别,以及禁用批处理 Tags { "Queue" = "Transparent" "RenderType" = "Transparent" "DisableBatching" = "True"} //颜色混合,会和组件上的颜色进行混合处理 Blend SrcAlpha One SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_particles #pragma multi_compile_fog #include "UnityCG.cginc" sampler2D _MainTex; fixed4 _TintColor; float _ColorSpeed; float4 _MainTex_ST; float _ColorGloss; //顶点动画 float _Magnitude; float _Frequency; struct appdata_t { float4 vertex : POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float4 texcoord : TEXCOORD0; UNITY_FOG_COORDS(1) }; //取随机数函数,shader中没有直接可用的函数,返回-1到1(不包含边界)的随机数 float InterleavedGradientNoise(float2 pos) { float3 magic = float3(12.9898, 78.233, 43758.5453123); return frac(magic.z * frac(dot(pos, magic.xy))); } v2f vert(appdata_t v) { float halfPi = 1.57079632675f; v2f o; //随机偏移值 float4 offset = float4(0.0, 0.0, 0.0, 0.0); //调用该随机函数,传入的参数也很重要,我选择使用顶点位置当作参数 //后面用sin调控光线越远抖的越厉害,Magnitude调控整体幅度 offset.xyz = _Magnitude * InterleavedGradientNoise(float2(v.vertex.x, v.vertex.y)) *sin(v.texcoord.x * halfPi); o.vertex = UnityObjectToClipPos(v.vertex + offset); o.color = v.color; //用zw保存uv,并加上一个时间函数,让uv动起来! o.texcoord.zw = TRANSFORM_TEX(v.texcoord,_MainTex) + (1 - frac(float2(_ColorSpeed, 0.0) * _Time.y)); o.texcoord.xy = v.texcoord.xy //用UnityCG.cginc头文件中内置定义的宏处理雾效,从顶点着色器中输出雾效数据 UNITY_TRANSFER_FOG(o,o.vertex); return o; } sampler2D_float _CameraDepthTexture; float _InvFade; fixed4 frag(v2f i) : SV_Target { float Pi = 3.1415926535f; //控制光线内部的白色,Gloass调节大小 fixed ratio = pow(sin(i.texcoord.y * pi), _ColorGloss); //将纹理与主颜色(我这里就用白色)混合 fixed4 col = tex2D(_MainTex, i.texcoord.zw) * (1 - ratio) + _TintColor * ratio; //中间向外围慢慢变淡 col.a = sin(i.texcoord.y * pi) //用UnityCG.cginc头文件中内置定义的宏启用雾效 UNITY_APPLY_FOG_COLOR(i.fogCoord, col, fixed4(0,0,0,0)); return col; } ENDCG } } } }
可以看出初中学的三角函数是多么的好用啊,它让我们实现平滑过渡是如此简单,目前为止大部分的需求都满足了,但还是又一些美中不足,就是强力的激光一定会在周围的地面上产生光影,目前这一点还没能实现,如果大伙们有兴趣,可以自己动手去实现它。
资源地址 链接:https://pan.baidu.com/s/1ew1lCZNnSHyS4A4UC1PAYg?pwd=naqo 提取码:naqo
参考文献 实验通过使命召唤所使用的 Interleaved Gradient Noise 在Shader中生成随机噪声