前言

最近打算学习下特效部分,因为现在我的热饭班长血怒特效很挫,长这样

v2-dd4fd510822fe7a0d9a5a429df92cd31_1440w

但是人家应该是这样的

v2-b81ac4a8e034aba4b6e64e2cd3cf9b3e_1440w

虽然说热饭班长没有Shader撑牌面,但就算加上Shader感觉也比不上原版那么霸气,而且有很多奇怪的贴图我都不知道怎么用,比如

v2-5a9a3bb44013d59d926bd3c47afea016_1440w

我直接黑人问号脸,所以来学习一下

本文大部分内容都收集整理自realtimevfx网站,也推荐大家去这个网站学习特效制作,大佬作品和教程非常多!

正文

特效是Textures,Sprites,Meshes,Lights和Shader一起配合而创建的酷炫效果。

在特效制作工作流程中,特效纹理是决定最终特效质量的最重要的其中一环,它的制作通常与游戏的风格有很大的相关性,Jason Keyser’s LoL FX breakdowns 频道有很多非常好的示例

VFX的五大艺术准则

Gameplay

特效的制作要去迎合Gameplay,让Gameplay对于玩家来说更加清晰明确,反馈性更强。这一点在许多文章,视频,PPT都有提及,举个例子,LOL中提莫的蘑菇爆炸的时候要播放特效,老版本的蘑菇爆炸特效和欧米伽小队的爆炸特效完全不一样,一方面是特效的质量,另一方面,也是比较重要的一方面,它使玩家能清晰的看到爆炸的范围,以及特效更加清晰,明确,没有老版本蘑菇杂乱无章的感觉

v2-b0a375f602ab570387cb61ea99160450_1440w

再比如小炮崔斯塔娜的火箭跳跃,火焰拖尾和落地AOE相对于无特效版本,让施法者和敌人都感觉不会太过突兀,当然了,图三远大于真正伤害范围的特效也是不可取的。

image-20210907131700940

形状

避免使用带有过多细节的照片纹理或视觉效果,因为它们可能会产生不必要的干扰因素,取而代之的是,应当使用软硬线条结合的手绘图来获取更好的效果。

v2-9ff3aa80a09e915529723b94a9c3cfd6_1440w

举个比较直观的例子男刀的W技能就因为使用了大量细节的贴图纹理(四角蝴蝶镖)会对其余特效的制作,甚至玩家产生干扰,而潘森的W就因为使用了简单的手绘贴图让其特效更加清晰明确。

v2-0e27c722c2e3df4ad15c7bc323b3becf_1440w

在创建用于移动的特效时(例如一个笔直飞行轨迹的火球术),应当为特效加上模糊效果(再不济也得加个拖尾效果)来制造出移动的感觉,如果不这样做的话会感觉很突兀

v2-c179255cd71439b211530de965ff93c2_1440w

亮度值

搭配/选择良好的亮度值能让VFX的表现力更上一层楼,应当避免使用0%或者100%的亮度,因为其往往会和场景或UI混在一起,让人困惑

v2-87b3f14cb7444ebdb2e74f7819d4639e_1440w

举个例子,炸弹人的Q技能就因为在其特效中心位置增加了亮度值显得炸弹更有破坏力

v2-c270cedcc38ed73b2a4364f158c68393_1440w

搭配使用高对比度的亮度可以做出能量集聚的效果,但是不应滥用,否则团战时将会让人眼花缭乱(实际上我团战打急眼了就是吓急吧乱按)

v2-452a5a8726fdc901e3b92eabd512bf3f_1440w

颜色

颜色的饱和度和上面提到的亮度规则基本上是通用的

v2-87939771894f41224127873d48e03edc_1440w

在使用颜色的时候,应当只有一个主色调,并且不应当使用两个几乎相反(对比度非常高)的颜色,因为他们不但不会相互衬托,反而会产生竞争关系,导致观感不好

v2-e28af2ed87898220039014e0192d63cc_1440w

生命周期

每个特效都应当有其生命周期,另外,在生命周期中配置一些与时间成非线性关系的变量将会很有用

例如下面艾克的大招,左侧幂函数的曲线相对于右侧常规线性曲线造成的效果更有力量感

image-20210907131816623

VFX常用技巧

以下图片皆来自 ShannonBerke

v2-9c3200dca5cc03143b7805bf8ab152cd_1440w

v2-83b45469347425a9689bb081ecbb6b74_1440w

v2-1441a5474489cbbf75c3de74fbccdefb_1440w

v2-f2833705916d084359b0e968ab70f36c_1440w

v2-efc41a51da35f68bd6e70ed352227038_1440w

v2-dcfbf37baa6c48aac31e033152ab3f87_1440w

实战

资源准备

理论部分了解的差不多了,来实战吧,但是我的建模水平仅限于在Blender拉个Box,连UV都展不明白,所以还是得向LOL学习

对于LOL的资源获取,推荐大家关注 LoL-Fantome 这个开源组织以及作为作者的两位大佬,提供了几乎所有用来学习LOL的工具(警告,仅供个人学习使用,严禁用于商业用途)

LOL的特效贴图和Mesh可以用 Wad解析工具 导出,但是Mesh格式是sco和scb这种非主流格式,所以需要转换格式,这里我们选择obj格式,至于工具的制作,引用封装 LeagueToolkit 库中的 解析代码 即可

但是直接使用的话会遇到一个问题,那就是工具会默认将一个sco的多个子mesh分别导出obj,例如下面这个sco文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[ObjectBegin]
Name= blood_drop_01 //sco名称
CentralPoint= 36.5671 -8.7635 -0.0087 //sco中心点
PivotPoint= 0.0000 0.0000 -0.0000 //sco锚点
Verts= 134 //顶点数
29.7181 -1.7972 -0.0328 //顶点0
30.0716 -1.7988 -2.1521 //顶点1

Faces= 220 //面数
....
//第0个数字代表这个面由多少顶点构成,第1,2,3个数字代表顶点索引值,第4个字符串代表材质名称
//其余部分数字两两构成一个UV,代表其对应顶点的UV值
3 56 45 66 pasted__blinn1 0.518236577511 0.939145863056 0.611138164997 0.911541640759 0.517365455627 0.939149141312 //三角面 0
3 66 45 55 pasted__blinn1 0.517365455627 0.939149141312 0.611138164997 0.911541640759 0.605477631092 0.911556601524 //三角面 1
3 78 68 67 pasted__pasted__blinn1 0.495640635490 0.114031434059 0.736957550049 0.605616688728 0.749850034714 0.605569958687 //三角面 2
3 78 69 68 pasted__pasted__blinn1 0.495640635490 0.114031434059 0.700808763504 0.605747699738 0.736957550049 0.605616688728 //三角面 3
...
[ObjectEnd]

就会导出两个obj文件,我将他们两个依次导入Blender,如下图所示

v2-a5a467e6a2ff4876baf2c700640434c1_1440w

这显然不太行,我们要的是一个完整的血滴,当然了,保不齐我们也会有将特效的SubMeshes分开导出obj的需求,所以我准备保留导出多obj的基础上增加SubMesh的合并功能

要修改的话其实也比较简单,修改其源码即可

先来分析下其解析过程,因为涉及多个SubMesh的分离,所以会将顶点索引规范到[0,N]之间

1
2
3
4
5
//Normalize indices
for (int i = 0; i < indices.Count; i++)
{
indices[i] -= minVertex;
}

为了最小化对源码的入侵,选择将这个minVertex(最小的顶点索引值)存到StaticObjectSubmesh类中,然后在进行SubMeshes合并的时候还原一下即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//////////////////////////////////////////////StaticObject.cs CreateSubmeshes Method////////////////////////////////
//Normalize indices
for (int i = 0; i < indices.Count; i++)
{
indices[i] -= minVertex;
}
submeshes.Add(new StaticObjectSubmesh(mappedSubmesh.Key, submeshVertices, indices, minVertex));

///////////////////////////////////////////////StaticObjectSubmesh.cs//////////////////////////////////////////
public uint MinVertexIndex { get; private set; }
public StaticObjectSubmesh(string name, List<StaticObjectVertex> vertices, List<uint> indices, uint minVertexIndex = 0)
{
this.Name = name;
this.Vertices = vertices;
this.Indices = indices;
this.MinVertexIndex = minVertexIndex;
}

导出的完整代码如下

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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
using System;
using System.Collections.Generic;
using LeagueToolkit.IO.StaticObjectFile;
using System.IO;
using System.Numerics;
using LeagueToolkit.IO.OBJ;
using LeagueAnimation = LeagueToolkit.IO.AnimationFile.Animation;

namespace LeagueToolkit.Sandbox
{
/// <summary>
/// 导出sco,scb为obj的程序,默认导出目录为 ${staticObjectsPath}/ObjOutPut
/// </summary>
class Program
{
//sco,scb所在目录
private static string s_StaticObjectsPath =
"E:/LOLExportAll/Darius/assets/characters/darius/skins/base/particles";

//obj导出目录
private static string s_ObjOutPath = $"{s_StaticObjectsPath}/ObjOutPut";

static void Main(string[] args)
{
ExportStatic2Obj();
}

/// <summary>
/// 导出sco,scb为obj文件
/// </summary>
/// <param name="exportSubMesh">是否要单独导出子mesh,默认为false,即会将一个sco的多个子mesh融合在一起导出一个obj,如果为true,多个submesh将会导出多个obj</param>
static void ExportStatic2Obj(bool exportSubMesh = false)
{
if (!Directory.Exists(s_StaticObjectsPath))
{
throw new Exception($"{s_StaticObjectsPath}不存在,请检查路径!");
}

Directory.CreateDirectory(s_ObjOutPath);

var staticFilesPaths = Directory.GetFiles(s_StaticObjectsPath);

foreach (var staticFilePath in staticFilesPaths)
{
StaticObject sco = null;
if (staticFilePath.EndsWith(".sco"))
{
sco = StaticObject.ReadSCO(staticFilePath);
}
else if (staticFilePath.EndsWith(".scb"))
{
sco = StaticObject.ReadSCB(staticFilePath);
}
else
{
continue;
}

var name = Path.GetFileNameWithoutExtension(staticFilePath);
if (exportSubMesh)
{
var resultObjs = sco.ToObj();
foreach (var obj in resultObjs)
{
obj.Obj.Write($"{s_ObjOutPath}/{obj.MaterialName}{name}.obj");
Console.WriteLine($"导出{s_ObjOutPath}/{obj.MaterialName}{name}.obj 完成");
}
}
else
{
(string MaterialName, OBJFile Obj) finalObjTuple = sco.ToObjWithCombineSubMesh();
finalObjTuple.Obj.Write($"{s_ObjOutPath}/{finalObjTuple.MaterialName}{name}.obj");
Console.WriteLine($"导出{s_ObjOutPath}/{finalObjTuple.MaterialName}{name}.obj 完成");
}
}
}
}

public static class ObjFileExtension
{
/// <summary>
/// 自定义的obj导出拓展, 将Obj的子Mesh拼合在一起
/// </summary>
/// <param name="self"></param>
/// <returns></returns>
public static (string MaterialName, OBJFile Obj) ToObjWithCombineSubMesh(this StaticObject self)
{
if (self.Submeshes.Count > 1)
{
List<Vector3> finalVertexs = new List<Vector3>(16);
List<Vector2> finalUVs = new List<Vector2>(16);
List<uint> finalIndexs = new List<uint>(16);

for (int i = 0; i < self.Submeshes.Count; i++)
{
foreach (var staticObjectVertexs in self.Submeshes[i].Vertices)
{
finalVertexs.Add(staticObjectVertexs.Position);
finalUVs.Add(staticObjectVertexs.UV);
}

foreach (var index in self.Submeshes[i].Indices)
{
finalIndexs.Add(index + self.Submeshes[i].MinVertexIndex);
}
}

return (self.Submeshes[0].Name, new OBJFile(finalVertexs, finalIndexs, finalUVs));
}
else
{
return self.ToObj()[0];
}
}
}
}

特效分析

v2-f11b0ff8d6ec767345a2ee1ae25db1f0_b

主要有三块

  • 类似边缘光的淡红色特效
  • 烟状物
  • 圆球轨迹环绕的飞絮(分许中心高亮)

类似边缘光的效果

使用两个Shader,或者两个Pass都可以,思路就是常规的边缘光思路,再加上根据Time的偏移和渐隐即可

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
Shader "NKGMoba/Darius_R_RimLightOffset"
{
Properties
{
_MainTex("MainTex",2D)="white"{}
_OffsetDir("OffsetDir", Vector) = (0,0,0,0)
_OffsetIntensity("OffsetIntensity", Float) = 1
_TimeScale("TimeScale", Float) = 1
}

SubShader
{
Tags {"RenderPipeline" = "UniversalPipeline" "Queue" = "Transparent" "RenderType" = "Transparent"}

Cull Off

Pass
{
Tags {"LightMode" = "UniversalForward"}
//关闭深度写入
ZWrite Off
//将该片元着色器产生的颜色混合因子设置为SrcAlpha
//将已经存在与颜色缓冲中的颜色混合因子设置为OneMinusSrcAlpha
Blend SrcAlpha OneMinusSrcAlpha
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

CBUFFER_START(UnityPerMaterial)

float4 _MainTex_TexelSize;
float3 _OffsetDir;
float _OffsetIntensity;
float _TimeScale;

CBUFFER_END

TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);

struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};

struct Varyings
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldNormal: TEXCOORD1;
float3 worldViewDir: TEXCOORD2;
};

Varyings vert(Attributes input)
{
Varyings output;

input.positionOS.xyz += _OffsetDir * _OffsetIntensity * (_Time.y % _TimeScale);
VertexPositionInputs vertexPositionInput = GetVertexPositionInputs(input.positionOS);

output.vertex = vertexPositionInput.positionCS;
output.uv = input.uv;
//使用顶点变换矩阵的逆转置矩阵对法线进行相同的变换,
//因此我们要先得到模型空间到世界空间的变换矩阵的逆矩阵unity_WorldToObject
//然后通过调换他在mul函数中的位置,得到和转置矩阵相同的矩阵乘法。
//由于法线是一个三维矢量,我们只需要截取unity_WorldToObject前三行的前3列即可
output.worldNormal = mul(input.normal, (float3x3)unity_WorldToObject);
output.worldViewDir = _WorldSpaceCameraPos.xyz - vertexPositionInput.positionWS;

return output;
}

half4 frag(Varyings input) : SV_Target
{
//将法线归一化
float3 worldNormal = normalize(input.worldNormal);
//把视线方向归一化
float3 worldViewDir = normalize(input.worldViewDir);
//计算视线方向与法线方向的夹角,夹角越大,dot值越接近0,说明视线方向越偏离该点,也就是平视,该点越接近边缘
float rim = max(0, dot(worldViewDir, worldNormal));
//计算rimLight
half4 rimColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, float2(clamp(rim, 0.1f, 0.3f), 0.5f));
rimColor.a -= (_Time.y % _TimeScale);
return rimColor;
}
ENDHLSL
}
}
}

烟状物&&絮状物

期间遇到了一个问题:URP管线中因为Shader的keyword定义问题,没有办法完全适配Particles System,例如这个特效的颜色在生命周期中的变化(Color in Lifetime) URP and particles color over lifetime not working

Had to switch to either Universal Render Pipeline/2D/Sprite-Lit-Default or Universal Render Pipeline/Particles/Unlit to get the module to work.

想了想,改是好改,但是这个Particles System未来可能会停止维护,现在主要是Visual Effect Graph使用了Compute Shader做GPU加速运算,但是仍旧有一部分的机型不支持Compute Shader,或者说支持的不是很好,但是不妨碍我们先进行学习,不得不说使用体验极佳,仅用了下图这么点内容就完成了两个特效的制作

v2-5768f548a829ab08c096842803c41ccc_1440w

期间也遇到了一些小问题,Google一下基本上都有答案,推荐体验嗷

结果

(味道差好多,以后慢慢调了)

v2-aeda88edc0660e6e68d44a84c4108905_b

引用

网站分享

Realtimevfx-一个高质量VFX学习交流社区

Sketchfab-一个免费模型交流学习网站

教程/资源分享

VFX入门教程精华版

创造VFX所需要遵守的原则

LOL VFX制作知识分享

LOL 召唤师峡谷,您可以出于学习的目的下载它

LOL VFX制作视频合集

LOL VFX教程与作品合集贴

LoL-Fantome