0%

武器-连锁激光

看了一些星际大战之后,想做一个激光武器,既然地球online里面做有点危险,那还是在unity里面做一个吧,先来看看效果

激光

激光5

激光11

最初呢,链接的激光是瞬间生成的,有一些假,第二个做了一些优化,可以看到链接丝滑了很多,第三个做了顶点动画看起来要更有威力了一些,看看怎么实现的吧,也可以取代码自用。

需求

有几个基本的需求

  1. 当激光路径碰到敌人,激光会锁住敌人,并产生连锁。
  2. 当敌人走出一定范围,链接会断开,重新生成链接。
  3. 当有新的敌人进入范围,而且没达到连锁上限,可以再次重新生成链接。

另外有几个自己可有可无的需求

  1. 用点光源照亮被连锁到的敌人
  2. 光线是有动感的,不能仅仅只一条线,那太low了
  3. 光线打出去是有速度的。

成员变量

首先是一些变量,建议跳过。

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);
}
}

动感光波

让我们先来看看真实的激光大概是什么样子的

img

img

大部分外围有淡淡的光芒(我还是比较喜欢紫色激光),中间有一条白色线条,但就这对我来说还是有点不够有表现力,我希望有动态效果,于是在此基础上添加了纹理动画以及顶点动画,这样看起来更有威力了不是吗?

  1. 我们很容易想到用一个纯白色来根据片元的v坐标的sin函数实现,将0-1的线性变化映射为0-1-0平滑过度的曲线,再搭配Gloss对其次方控制宽度。

  2. 之后是外围我想要实现的纹理动画效果了,用ps做了一张左右都为蓝色,中间为紫色的纹理,为保证平滑过渡,所以左右两边必须是一模一样的颜色,之后在顶点着色器中将顶点的uv坐标进行移动,_Time是unity定义的计时器,为float4类型,t代表自场景加载所经过的时间,4个分量分别为(t/20,t,2t,3t),不论用哪一个,将它与我们的ColorSpeed相乘取小数,就可以得到0-1之间的一个数与转换的顶点uv相加就可以得到不断移动的纹理了,但由于这样的方向是反的,我们还需要用1减去小数仍然为0-1之间。

紫蓝紫

  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中生成随机噪声