【ShaderLab实例笔记】Gears Hammer of Dawn - 激光 & 碎石特效制作笔记

教程链接: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,再慢慢开始下落

扫描二维码关注公众号,回复: 14769430 查看本文章

模型制作(可跳过)

模型使用 Blender 制作,已经制作完成的模型已经放进了工程文件,如果对模型的制作过程无兴趣可以直接跳过

不过地面模型制作中使用了一个不需要写脚本就可以把顶点 uv 设置为所属片元的中心坐标的方法,建议学习一下,很简单方便

激光

直接创建一个圆柱体,适当增减面数即可
在这里插入图片描述

地面

  1. 创建一个平面,绘制一些碎片
    在这里插入图片描述

  2. 使用 EdgeSplit 修改器,角度设置为 0,应用,这样就可以将绘制的碎片从同一平面分开

  3. 将每个顶点所属片元的中心坐标存储进顶点的 uv
    使用 creating UV coordinates from projection 创建出相同形状的 uv在这里插入图片描述
    然后选中每一个片元,将它们的 scale 设置为 0,塌缩成黑点,这些黑点就是每一个片元的中心坐标,存储进了相邻顶点的 uv在这里插入图片描述

  4. 挤出平面,获得厚度

场景准备(可跳过)

与 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
  1. 定义 surface shader 名称为 “ surf”
  2. 使用 “ Standard ” 光照模型
  3. 使用自定义的 vertex shader " vert "
void vert(inout appdata_full v){
    
    }
  1. Custom vertex shader
  2. appdata_base = position + normal + uv0
  3. appdata_tan = appdata_base + tangent
  4. 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

地面运动分析 & 整理思路

地面运动分析:

  1. 离激光越近,飞起越高,旋转角度越大
  2. 相邻碎片的飞起高度和旋转角度不大相同
  3. 与激光的距离和飞起高度、旋转角度都不成线性比例
  4. 飞起后上升速度慢慢减小,直至静止在空中,再缓缓加速下落
  5. 碎片向激光外侧翻转

根据上面的分析,影响地面碎片运动的因素是时间、到激光的距离,以及噪声扰动

整理思路如下:

  1. 移动 uv 原点到激光(本例中为地面中心),方便获得距离
  2. 为飞起高度和旋转角度添加噪声扰动
  3. 使用 pow() 函数计算与激光的距离对飞起高度、旋转角度的影响
  4. 使用三角函数计算碎片的起落运动
  5. 碎片沿垂直于上方向和指向激光方向的轴旋转

属性添加

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);
}
  1. out Input i
  2. tex2Dlod():因为是在自定义的 vert shader 中进行采样,不能像 frag shader 那样自动挑选纹理的 Mip Level,所以需要使用 tex2Dlod() 手动指定 Mip Level
  3. 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;
}

形态分析

  1. 扩张中(扩张部分):最大半径受三角函数影响,迅速沿着激光自上而下移动,受一定噪声影响
  2. 扩张前(扩张部分下方):半径缓慢变大,受一定噪声影响
  3. 扩张后(扩张部分上方):半径恒定,受一定噪声影响

根据上面的描述,需要提前获得激光的高度备用

void vert(inout appdata_full v)
{
    
    
    float beamHeight = 20.0f;
}

扩张部分位置随时间变化分析

  1. 开始(sequence = 0):最顶端,1
  2. 中间(sequence = 0.5):最底端,0
  3. 结束(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 早晚被淘汰,其实一开始是非常不想再来看的
只好安慰自己说对于小白来说更重要的是学习大佬的思路…(难道不是这样吗!)

猜你喜欢

转载自blog.csdn.net/weixin_44045614/article/details/116449678