教程链接:Gears Hammer of Dawn
项目链接:GearsHammerOfDawnTemplate
Pipeline & Shader:Built-in,Standard Surface
模型制作:Blender
本文是对 Gears Hammer of Dawn 学习过程的记录和总结,不是完全的翻译,更多的细节和图文建议跳转原博
…… 为什么不能放 gif 了啊 QWQ!!
重要提示:
在阅读本篇笔记时,如果发现任何关于坐标的问题,如某个地方感觉应该用 y 分量,笔记中使用的是 z 或 - z 分量,请举起双手,分别伸出大拇指(x)、食指(y)、中指(z)并摆成坐标系,默念:Unity 左手系 y - up,Blender 右手系 z - up
效果分析
1. 激光随时间变粗
2. 激光会像周边区域投射光 -> 点光源 + 发光后处理
3. 激光不是完全直的,会高频率的轻微摆动 -> 不稳定感
4. 激光周围有一些环绕的粒子,随着激光变粗,粒子的环绕半径也随之变粗
1. 激光的顶部直径剧烈变粗,激光的底部变化极其微小 -> 上下对比产生了强烈的冲击感
2. 可以更清楚的看到 Part1 提到的激光周围的粒子
1. 地面开始爆裂
2. 直径最大的部分由顶端转移到了底端
3. 光照强度骤增
4. 激光周围的发光粒子:环绕激光的同时,由随机、高频的噪声决定形状
1. 激光的形态不在发生明显变化
2. 可以更清楚的看到激光周围的粒子
3. 离激光越近的地面碎片被击飞的越高,但是击飞高度的衰减并不是线性的
4. 被击飞到空中的碎片随向激光的反方向旋转
5. 空中离得近的碎片旋转的角度并不一样 -> 旋转时被加入了噪声值
重力模拟:碎片上升速度减慢到 0,再慢慢开始下落
模型制作(可跳过)
模型使用 Blender 制作,已经制作完成的模型已经放进了工程文件,如果对模型的制作过程无兴趣可以直接跳过
不过地面模型制作中使用了一个不需要写脚本就可以把顶点 uv 设置为所属片元的中心坐标的方法,建议学习一下,很简单方便
激光
直接创建一个圆柱体,适当增减面数即可
地面
-
创建一个平面,绘制一些碎片
-
使用 EdgeSplit 修改器,角度设置为 0,应用,这样就可以将绘制的碎片从同一平面分开
-
将每个顶点所属片元的中心坐标存储进顶点的 uv
使用 creating UV coordinates from projection 创建出相同形状的 uv
然后选中每一个片元,将它们的 scale 设置为 0,塌缩成黑点,这些黑点就是每一个片元的中心坐标,存储进了相邻顶点的 uv -
挤出平面,获得厚度
场景准备(可跳过)
与 Shader 无关的事情如场景、物体、灯光、相机、粒子等设置已经在工程中设置完毕,不感兴趣可以直接跳过
场景层级介绍
相机设置
CameraParent 是相机的父物体,负责带着相机围绕激光和地面旋转,同时挂载了“SeqenceController.cs” 脚本
相机上挂载了 Post-Process Layer 和 Post-Process Volume(使用了 “ Post-Processing V2 ” 包)
因为在相机组件上已经使用了 MSAA,在 Post-Processing Layer 中关闭 AA
可以看到 Post-Process Volume 是全局的,开启了 Bloom,Vignette,Color Grading 效果
灯光
为了突出激光和 bloom 效果,保证激光和地面轮廓清晰,需要两种光照:
一种较为微弱暗淡,只要体现出激光和地面轮廓的环境光,使用平行光
另一种较强较亮,有光线衰减,要体现出激光的能量,使用点光源
激光 & 地面
因为激光具有很强的发光特性,所以需要关闭激光 MeshRenderer 组件的 CastShadow、receive shadows
虽然地面在被激光击中后会碎成很多独立的碎片进行运动,为了保证运行效果效率,还是要将它们制作成同一个网格,利用 shader 和之前设置好的 uv 制作效果(有效降低DrawCall)
粒子
Dust Particles:用来填补地面裂开后下面的空洞
Dense Dust Particles:设置在激光下方的尽头,用来遮挡激光尾部的穿帮镜头
Lightning Particles:围绕在激光周围,用来增加激光的周边细节
Lightning Particles 需要模拟激光周围的环绕电流效果,需要自上而下蜿蜒流动,发光
想达到这样的效果,不需要渲染粒子个体,而是只渲染粒子的拖尾,并添加一个高频噪声扰动
但是这样看起来不太够,电流的环绕感并不强,所以再增加一个 “Orbit Velocity”,让粒子沿着自己的 z 轴有一个随机速度
SequenceController 脚本
SequenceController 脚本主要有五个作用:
特效周期循环、相机环绕 & 晃动、材质参数设置、粒子开关、灯光强度调节
// 注意,Shader 中的属性名应与此处字符名一致
private static readonly int Sequence = Shader.PropertyToID("_Sequence");
private static readonly int HeightMax = Shader.PropertyToID("_Sequence");
// ...
void Update()
{
// 周期设置:5s一循环
SequenceVal += Time.deltaTime * 0.2f;
if (SequenceVal > 1.0f)
{
SequenceVal = 0.0f;
CameraTransform.localPosition = Vector3.zero;
}
// 相机环绕
Transform localTransform;
// 15° 到 135°
(localTransform = transform).rotation = Quaternion.Euler(45.0f, SequenceVal * 120.0f + 15.0f, 0);
// 相机到旋转中心的距离是 2.5f
localTransform.position = -(localTransform.rotation * (Vector3.forward * 2.5f));
// 第一阶段:激光准备发射
if (SequenceVal < 0.1f)
{
// 材质参数设置
GroundMat.SetFloat(Sequence, 0.01f - SequenceVal * 0.1f);
BeamMat.SetFloat(HeightMax, Mathf.Pow(SequenceVal * 10.0f, 5.0f));
// 激光点光源光强设置
LaserLight.intensity = SequenceVal * 8.0f;
// 关闭粒子系统
foreach (GameObject go in ParticleSystems)
go.SetActive(false);
// 缩小隐藏激光粒子系统
LightningParticles.localScale = new Vector3(0.4f, 0.4f, 1.0f);
}
// 第二阶段
// 激光发射,激光周身环绕电流粒子,点光源强度变高
// 地面被击碎产生尘土粒子,相机震动
else
{
// 材质参数设置
GroundMat.SetFloat(Sequence, SequenceVal * 1.09f - 0.09f);
BeamMat.SetFloat(HeightMax, 1.0f);
// 激光点光源光强设置
LaserLight.intensity = Mathf.Sin(Time.time * 30.0f) * 0.03f +
Mathf.Sin(Time.time * 50.0f) * 0.03f +
Mathf.Sin(Time.time * 7.0f) * 0.01f +
0.8f;
// 粒子系统开启
foreach (GameObject go in ParticleSystems)
{
go.SetActive(true);
}
// 激光粒子系统设置正常大小显示
LightningParticles.localScale = Vector3.one;
// 相机晃动
CameraTransform.localPosition = Random.insideUnitSphere * 0.04f * (1.0f - SequenceVal);
}
}
代码都很好理解,问题是材质参数到底是怎么设置的…
// 第一阶段
if (SequenceVal < 0.1f) // 0 < SequenceVal < 0.1
{
GroundMat.SetFloat(Sequence, 0.01f - SequenceVal * 0.1f); // 0.01 -> 0
BeamMat.SetFloat(HeightMax, Mathf.Pow(SequenceVal * 10.0f, 5.0f)); // 0 -> 1
}
// 第二阶段
else // 0.1 < SequenceVal < 1
{
GroundMat.SetFloat(Sequence, SequenceVal * 1.09f - 0.09f); // 0.1 -> 1
BeamMat.SetFloat(HeightMax, 1.0f); // 1
}
激光的参数比较好理解,但是地面的参数很迷惑,看起来并不连续,是从 0.01 线性到 0,再突变到 0.01,再线性变到 1
…
调参吧TA
Shader 编写
Surface Shader 基础
使用 Surface Shader 的原因:不需要自己处理光照计算,只要专心处理顶点即可
#pragma surface surf Standard vertex:vert
- 定义 surface shader 名称为 “ surf”
- 使用 “ Standard ” 光照模型
- 使用自定义的 vertex shader " vert "
void vert(inout appdata_full v){
}
- Custom vertex shader
- appdata_base = position + normal + uv0
- appdata_tan = appdata_base + tangent
- appdata_full = appdata_tan + uv1 + uv2
struct Input
{
float3 color;
};
Surface Shader 必须,且不可为空
可以使用 float3 color 输出颜色,debug 使用
void surf (Input i, inout SurfaceOutputStandard o){
}
SurfaceOutputStandard:Standard 光照模型的输入输出结构体
具体内容可查看 Writing Surface Shaders 或拉一个 ShaderGraph
基本输出
void surf (Input i, inout SurfaceOutputStandard o)
{
o.Albedo = float3(1, 1, 1);
o.Metallic = 0;
o.Smoothness = 1;
}
地面 Shader
地面运动分析 & 整理思路
地面运动分析:
- 离激光越近,飞起越高,旋转角度越大
- 相邻碎片的飞起高度和旋转角度不大相同
- 与激光的距离和飞起高度、旋转角度都不成线性比例
- 飞起后上升速度慢慢减小,直至静止在空中,再缓缓加速下落
- 碎片向激光外侧翻转
根据上面的分析,影响地面碎片运动的因素是时间、到激光的距离,以及噪声扰动
整理思路如下:
- 移动 uv 原点到激光(本例中为地面中心),方便获得距离
- 为飞起高度和旋转角度添加噪声扰动
- 使用 pow() 函数计算与激光的距离对飞起高度、旋转角度的影响
- 使用三角函数计算碎片的起落运动
- 碎片沿垂直于上方向和指向激光方向的轴旋转
属性添加
Properties
{
_Sequence("Sequence", Range(0,1)) = 0.0
_Noise("Noise Texture", 2D) = "white" {
}
_Exp("Shape Exponent", Range(1.0,10.0)) = 5.0 // Exponential decay
_Rot("Rotation Multiplier", Range(1.0,100.0)) = 50.0
_Height("Height Multiplier", Range(0.1,1.0)) = 0.5
}
_Sequence:使用 C# 控制代码控制特效的运行顺序
_Noise:防止所有相邻像素都拥有相似的位置、颜色等属性
_Exp:碎片击飞高度衰减因子
_Rot:碎片旋转
_Height:碎片击飞高度
影响因素1:噪声采样
void vert(inout appdata_full v, out Input i)
{
float noise = tex2Dlod(_Noise, v.texcoord * 2.0f).r;
i.color = float3(noise, noise, noise);
}
- out Input i
- tex2Dlod():因为是在自定义的 vert shader 中进行采样,不能像 frag shader 那样自动挑选纹理的 Mip Level,所以需要使用 tex2Dlod() 手动指定 Mip Level
- v.texcoord * 2.0f:手动 Tilling,增加噪声频率
影响因素2:距离
本例中激光与地面相交于地面的中心,所以只要将地面的 uv 原点平移到中心,就可以使用 length(uvDir) 作为碎片与激光的距离
void vert(inout appdata_full v, out Input i)
{
float noise = tex2Dlod(_Noise, v.texcoord * 2.0f).r;
float2 uvDir = v.texcoord.xy - 0.5f;
i.color = float3(noise, noise, noise);
}
影响因素3:时间
_Sequence 由外部 C# 脚本控制,可以很好的表示时间,但是仍需要做一些 trick 调整(调参吧TA)
void vert(inout appdata_full v, out Input i)
{
float noise = tex2Dlod(_Noise, v.texcoord * 2.0f).r;
float scaledSequence = _Sequence * 1.52f - 0.02f;
float2 uvDir = v.texcoord.xy - 0.5f;
float seqVal = pow(1.0f - (noise + 1.0f) * length(uvDir), _Exp) * scaledSequence;
i.color = float3(noise, noise, noise);
}
经过 pow()、noise 处理的 seqVal 已经包含了前面分析的所有对地面碎片的影响因子:时间、距离、扰动,并且满足了非线性关系,接下来可以使用它制作碎片的运动了
碎片飞起 & 下落
起落是简单的平移运动,先从这个下手吧
前面分析碎片飞起后上升速度慢慢减小,直至静止在空中,再缓缓加速下落
void vert(inout appdata_full v, out Input i)
{
// ...
// 上下起落
v.vertex.z += sin(seqVal * 2.0f) * _Height;
}
加上噪声扰动:
void vert(inout appdata_full v, out Input i)
{
// ...
// 上下起落
v.vertex.z += sin(seqVal * 2.0f) * (noise + 1.0f) * _Height;
// 水平扰动
v.vertex.xy -= normalize(float2(v.texcoord.x, 1.0f - v.texcoord.y) - 0.5f) * seqVal * noise;
}
模型是用 Blender 制作的,Blender 是右手系,z 轴向上;Unity 是左手系,y 轴向上
Unity - Blender:x — -x,y — z,z — -y
所以这里的上下起落使用的 z 方向分量(Unity 的 y),水平扰动使用了x方向和取反后的y方向(Unity 的 xz)
碎片旋转
旋转需要考虑旋转中心、旋转轴、旋转角度
旋转中心:因为地面模型的大小是 2X2,uv 的范围是(0,1),需要乘 2
旋转轴:同时垂直于上方向和指向激光方向的轴,使用 cross() 获得
旋转角度:由 seqVal 决定
旋转对象:顶点位置、顶点法线(光照计算需要)
旋转辅助函数
void Rotate(inout float4 vertex, inout float3 normal, float3 center, float3 around, float angle)
{
// 平移到旋转中心的矩阵
float4x4 translation = float4x4(
1, 0, 0, center.x,
0, 1, 0, -center.y,
0, 0, 1, -center.z,
0, 0, 0, 1);
// 平移回初始位置的矩阵
float4x4 translationT = float4x4(
1, 0, 0, -center.x,
0, 1, 0, center.y,
0, 0, 1, center.z,
0, 0, 0, 1);
around.x = -around.x; // 翻转 x 轴,右手系 -> 左手系
around = normalize(around);
float s = sin(angle);
float c = cos(angle);
float ic = 1.0 - c;
// 旋转矩阵
// Blender - Unity: x = -x, y = -z, z = y
float4x4 rotation = float4x4(
ic * around.x * around.x + c, ic * around.x * around.y - s * around.z, ic * around.z * around.x + s * around.y, 0.0,
ic * around.x * around.y + s * around.z, ic * around.y * around.y + c, ic * around.y * around.z - s * around.x, 0.0,
ic * around.z * around.x - s * around.y, ic * around.y * around.z + s * around.x, ic * around.z * around.z + c, 0.0,
0.0, 0.0, 0.0, 1.0);
// 平移到旋转中心 -> 旋转 -> 平移回初始位置
vertex = mul(translationT, mul(rotation, mul(translation, vertex)));
normal = mul(translationT, mul(rotation, mul(translation, float4(normal, 0.0f)))).xyz;
}
中间一大坨旋转矩阵看着有点眼生,其实是旋转矩阵的角 — 轴表示,本体见补充
上面两个平移矩阵中,x 轴和 yz 轴符号不同,是因为左右手系转换时需要将 x 轴翻转
最后要注意代码顺序:先旋转后平移
void vert(inout appdata_full v, out Input i)
{
// seqVal计算
// ...
Rotate(v.vertex, v.normal, float3(2.0f * uvDir, 0), cross(float3(uvDir, 0), float3(noise * 0.1f, 0, 1)), seqVal * _Rot);
// 上下起落、水平扰动
// ...
}
激光 Shader
属性添加
Properties
{
_Color("Color", Color) = (1,1,1,1)
_Emission("Emission", Color) = (1,1,1,1)
_Sequence("Sequence Value", Range(0,1)) = 0.1
_Width("Width Multiplier", Range(1,3)) = 2
[Header(Noise)]
_NoiseFrequency("Noise Frequency", Range(1,100)) = 50.0
_NoiseLength("Noise Length", Range(0.01,1.0)) = 0.25
_NoiseIntensity("Noise Intensity", Range(0,0.1)) = 0.02
}
surface shader设置
void surf (Input i, inout SurfaceOutputStandard o)
{
o.Albedo = _Color.rgb;
o.Emission = _Emission;
o.Metallic = 0;
o.Smoothness = 1;
}
形态分析
- 扩张中(扩张部分):最大半径受三角函数影响,迅速沿着激光自上而下移动,受一定噪声影响
- 扩张前(扩张部分下方):半径缓慢变大,受一定噪声影响
- 扩张后(扩张部分上方):半径恒定,受一定噪声影响
根据上面的描述,需要提前获得激光的高度备用
void vert(inout appdata_full v)
{
float beamHeight = 20.0f;
}
扩张部分位置随时间变化分析
- 开始(sequence = 0):最顶端,1
- 中间(sequence = 0.5):最底端,0
- 结束(sequence = 1):视线外,-1(最下方)
根据上面的描述,需要对 sequence 进行重映射
void vert(inout appdata_full v)
{
float beamHeight = 20.0f;
float scaledSeq = (1.0f - _Sequence) * 2.0f - 1.0f;
float scaledSeqHeight = scaledSeq * beamHeight;
}
扩张部分
使用余弦波构造扩张部分的形态
扩张部分的形态应该受顶点在激光上的竖直方向位置,及时间的影响:
void vert(inout appdata_full v)
{
// ...
float cosVal = cos(3.141f * (v.vertex.z / beamHeight - scaledSeq));
v.vertex.xy *= cosVal;
}
扩张前中后形态变化
void vert(inout appdata_full v)
{
// Specific depends on model
float beamHeight = 20.0f;
// Remap: seq-0 -> top-beam-1, seq-0.5 -> bottom-beam-0, seq-1 -> out of view(-1), (0, 1) -> (1, -1)
float scaledSeq = (1.0f - _Sequence) * 2.0f - 1.0f;
float scaledSeqHeight = scaledSeq * beamHeight;
// The Broading part
float cosVal = cos(3.141f * (v.vertex.z / beamHeight - scaledSeq));
// beam radius = lerp(before broading, broading, height-related)
float width = lerp(0.05f * (beamHeight - scaledSeqHeight + 0.5f),
cosVal,
pow(smoothstep(scaledSeqHeight - 8.0f, scaledSeqHeight, v.vertex.z), 0.1f));
// beam radius = lerp((before broading, broading), after broading, height-related)
width = lerp(width,
0.4f,
smoothstep(scaledSeqHeight, scaledSeqHeight + 10.0f, v.vertex.z));
v.vertex.xy *= width * _Width;
}
两个 lerp() 的 value 推荐在 shader 修改数值帮助理解(如整体替换为 0.5),其实完全不是啥难理解的东西就是一个平滑数字…使用时间、z 进行计算,找到一个舒服的值就可以了
添加半径的随机扰动
void vert(inout appdata_full v)
{
// ...
v.vertex.xy += sin(_Time.y * _NoiseFrequency + v.vertex.z * _NoiseLength) * _NoiseIntensity * _Sequence;
}
效果调整
完全按照教程走下来的效果有一个地方不满意:在激光将地面击碎至空中前后两帧地面碎片的状态差异有点大(一个向下凹陷,一个向上凸起)
经过分析,是和 SequenceController 脚本、地面 Shader 中的代码片段有关
SequenceController 脚本片段:
// 第一阶段
if (SequenceVal < 0.1f) // 0 < SequenceVal < 0.1
{
GroundMat.SetFloat(Sequence, 0.01f - SequenceVal * 0.1f); // 0.01 -> 0
}
// 第二阶段
else // 0.1 < SequenceVal < 1
{
GroundMat.SetFloat(Sequence, SequenceVal * 1.09f - 0.09f); // 0.1 -> 1
}
Shader 片段
float scaledSequence = _Sequence * 1.52f - 0.02f;
调整后代码:
SequenceController 脚本片段:
// 第一阶段
if (SequenceVal < 0.1f) // 0 < SequenceVal < 0.1
{
GroundMat.SetFloat(Sequence, 0.1 * SequenceVal); // 0 -> 0.01
}
// 第二阶段
else // 0.1 < SequenceVal < 1
{
GroundMat.SetFloat(Sequence, SequenceVal * 1.1f - 0.1f); // 0.01 -> 1
}
Shader 片段
float scaledSequence = _Sequence * 1.5f;
补充
查看模型信息的方法
可以看到模型顶点含有的信息、网格面数等
旋转矩阵的“角—轴”表示
总结
第一次做地面碎裂效果,发现没有想象中的那么难(嗯一定是原教程太详细了)
也是第一次手撸 Surface Shader,大概明白是怎么个结构和思路了
对着旋转矩阵看了好久,死活和自己学过的矩阵对不上号,以为是作者搞的trick。果然不懂就要问度娘,人家明明就是穿了马甲的旋转矩阵
Shader 的参数设置真是有够看经验啊,相关参数就是那几个,根据各种数学函数的图像特点往里面扔吧,总有合适的式子的…
目前看到的常用的也就这么几个:
pow、sin、cos、length、normalize、abs
lerp、inverseLerp、remap
frac、floor、ceil、round、step、smoothStep
配合各种noise(ASE里还有很神奇的Vornorio节点)
…………………………
这大佬好几个教程都很详细很良心,但一看全都是 Built-in 的…
Built-in 和 Surface Shader 早晚被淘汰,其实一开始是非常不想再来看的
只好安慰自己说对于小白来说更重要的是学习大佬的思路…(难道不是这样吗!)