溶解的思路

  1. 透明度测试+关闭背面剔除
    1
    2
    3
    4
    "RenderType"="TransparentCutout"
    "Queue"="AlphaTest"

    Cull Off
  2. Frag 阶段,判断是否裁剪像素

裁剪像素的方法

  • (常用)通过 clip 函数
  • 通过 discard
  • 通过设置 alpha 变量为 0

clip 函数

  • clip (x),x 小于 0 就会丢弃该像素。
  • 在片段着色器中,根据当前 uv,采样噪声图,就可以获得一个遮罩值。
  • clip (遮罩值 - 阈值),就可以看到一个动态溶解效果。
    1
    2
    half noise = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, i.uv).r;
    clip(noise - _DissolveThreshold);

discard 函数:

1
2
3
4
if(noise < _DissolveThreshold)
{
discard
}

ASE 实现

Pasted image 20221004114121

Shader 实现

思路一:smoothstep 区分溶解部分和不溶解部分
思路二:step 算出溶解边缘

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
float4 MainTex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
float Noise = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, i.uv).r;
clip(Noise - _DissolveThreshold);

//思路一:smoothstep区分溶解部分和不溶解部分
//Noise- _DissolveThreshold < 0,返回0,t = 1,表示为溶解的部分
//Noise- _DissolveThreshold > _EdgeWidth, 返回1,t = 0,表示为不溶解的部分
//Noise- _DissolveThreshold 在(0, _EdgeWidth),返回(0, 1)的平滑过渡值

//改进:当_DissolveThreshold=0时,由于Noise不一定大于_EdgeWidth,即t不一定为0,导致始终有一小部分被溶解。
//我们需要规定当_DissolveThreshold=0时,t也为0
//在计算finalColor时 t * step(0.0001,_DissolveThreshold)即可
//(t<0.0001时,返回0)
float t = 1.0 - smoothstep(0.0,_EdgeWidth,Noise - _DissolveThreshold);

//使用t值,_EdgeColor1和_EdgeColor2进行插值,得到最终的颜色
float4 dissolveColor = lerp(_EdgeColor1, _EdgeColor2, t);
float4 finalColor = lerp(MainTex, dissolveColor, t * step(0.0001,_DissolveThreshold));
return float4(finalColor.rgb,1);


//思路二:step算出溶解边缘
float internalEdge = step(Noise, _DissolveThreshold);
float externalEdge = step(Noise, _DissolveThreshold + _EdgeWidth);;
float edge = externalEdge - internalEdge;

float4 finalColor = lerp(MainTex, _EdgeColor1, edge * step(0.0001,_DissolveThreshold));
return float4(finalColor.rgb,1);

从特定点开始消融

69a74153809c2d6850cb8a5014db7799_MD5

DissolveFromPoint 场景

为了从特定点开始消融,我们需要把片元到特定点的距离考虑进 clip 中。

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
Varyings vert(Attributes i)
{
Varyings o = (Varyings)0;
o.positionCS = TransformObjectToHClip(i.positionOS.xyz);
o.uv =TRANSFORM_TEX(i.uv, _MainTex);

o.positionOS = i.positionOS.xyz;
//转换到模型空间
o.startPosOS = TransformWorldToObject(_StartPoint.xyz);
return o;
}

float4 frag(Varyings i) : SV_Target
{
float4 MainTex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);

float Noise = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, i.uv).r*_NoiseScale;
float vertexDistance = distance(i.positionOS, i.startPosOS)- _DissolveDiffuse; //片元到开始点的距离
float normalizedDistance = saturate(vertexDistance/_MaxVertexDistance + Noise);//归一化,开始点处值为0,因为距离该处片元距离开始点距离为0

clip(normalizedDistance-_DissolveThreshold);

//step算出溶解边缘
float internalEdge = step(normalizedDistance,_DissolveThreshold);
float externalEdge = step(normalizedDistance, _DissolveThreshold+_EdgeWidth);;
float edge = externalEdge-internalEdge;

float4 finalColor = lerp(MainTex, _EdgeColor, edge * step(0.0001,_DissolveThreshold));

return finalColor;
}

**** C# 脚本求出网格内两点的最大距离,用来对第一步求出的距离进行归一化。** 思路就是遍历任意两点,然后找出最大距离:

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
public class CalcMaxVertexDistance : MonoBehaviour
{
public Material material;
private float m_maxDistance;
private static readonly int s_MaxVertexDistance = Shader.PropertyToID("_MaxVertexDistance");

void Start()
{
m_maxDistance = CalculationMaxDistance();
}
void Update()
{
material.SetFloat(s_MaxVertexDistance, m_maxDistance);
}
float CalculationMaxDistance()
{
float maxDistance = 0;
Vector3[] vertices = GetComponent<MeshFilter>().mesh.vertices;
for (int i = 0; i < vertices.Length; i++)
{
Vector3 v1 = vertices[i];
for (int k = 0; k < vertices.Length; k++)
{
if (i == k) continue;
Vector3 v2 = vertices[k];
float mag = (v1 - v2).magnitude;
if (mag > maxDistance)
{
maxDistance = mag;
}
}
}
return maxDistance;
}
}

这就完成了从特定点开始消融的效果了,不过有一点要注意,消融开始点最好是在网格上面,这样效果会好点。

应用:场景切换

利用这个从特定点消融的原理,我们可以实现场景切换。
假设我们要实现如下效果:
00bfce6e7a584fe3953f5096df7d93c2_MD5

因为我们原来的 Shader 是从中间开始向四周扩散溶解的,和图中从四周开始向中间溶解有点不同,因此我们需要稍微修改一下计算距离的方式:

1
2
//Fragment
float normalizedDistance = 1-saturate(vertexDistance/_MaxVertexDistance + Noise);

第二步就是需要修改计算距离的坐标空间,原来我们是在模型空间下计算的,而现在很明显多个不同的物体会同时受消融值的影响,因此我们改为世界空间下计算距离:

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
Varyings vert(Attributes i)
{
Varyings o = (Varyings)0;

o.positionCS = TransformObjectToHClip(i.positionOS.xyz);
o.uv =TRANSFORM_TEX(i.uv, _MainTex);

//由于影响多个模型,所以在世界空间中计算
o.positionWS = TransformObjectToWorld(i.positionOS.xyz);
return o;
}

float4 frag(Varyings i) : SV_Target
{
float4 MainTex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);

float Noise = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, i.uv).r*_NoiseScale;
float vertexDistance = distance(i.positionWS, _StartPoint)- _DissolveDiffuse; //片元到开始点的距离
float normalizedDistance = 1-saturate(vertexDistance/_MaxVertexDistance + Noise);//归一化,开始点处值为0,因为距离该处片元距离开始点距离为0

clip(normalizedDistance-_DissolveThreshold);


//step算出溶解边缘
float internalEdge = step(normalizedDistance,_DissolveThreshold);
float externalEdge = step(normalizedDistance, _DissolveThreshold+_EdgeWidth);;
float edge = externalEdge-internalEdge;
//return float4(edge.xxx,1);

float4 finalColor = lerp(MainTex, _EdgeColor, edge * step(0.0001,_DissolveThreshold));

return finalColor;
}

第三步为了计算所有场景的物体的顶点到消融开始点的最大距离,我定义了下面这个脚本:

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
public class DissolveEnvironment : MonoBehaviour {
public Vector3 dissolveStartPoint;
[Range(0, 1)]
public float dissolveThreshold = 0;
[Range(0, 1)]
public float distanceEffect = 0.6f;

void Start () {
//计算所有子物体到消融开始点的最大距离
MeshFilter[] meshFilters = GetComponentsInChildren<MeshFilter>();
float maxDistance = 0;
for(int i = 0; i < meshFilters.Length; i++)
{
float distance = CalculateMaxDistance(meshFilters[i].mesh.vertices);
if (distance > maxDistance)
maxDistance = distance;
}
//传值到Shader
MeshRenderer[] meshRenderers = GetComponentsInChildren<MeshRenderer>();
for(int i = 0; i < meshRenderers.Length; i++)
{
meshRenderers[i].material.SetVector("_StartPoint", dissolveStartPoint);
meshRenderers[i].material.SetFloat("_MaxDistance", maxDistance);
}
}

void Update () {
//传值到Shader,为了方便控制所有子物体Material的值
MeshRenderer[] meshRenderers = GetComponentsInChildren<MeshRenderer>();
for (int i = 0; i < meshRenderers.Length; i++)
{
meshRenderers[i].material.SetFloat("_Threshold", dissolveThreshold);
meshRenderers[i].material.SetFloat("_DistanceEffect", distanceEffect);
}
}

//计算给定顶点集到消融开始点的最大距离
float CalculateMaxDistance(Vector3[] vertices)
{
float maxDistance = 0;
for(int i = 0; i < vertices.Length; i++)
{
Vector3 vert = vertices[i];
float distance = (vert - dissolveStartPoint).magnitude;
if (distance > maxDistance)
maxDistance = distance;
}
return maxDistance;
}
}

这个脚本同时还提供了一些值来方便控制所有场景的物体。

ed3d96e893b43d2ec618422f6ceee286_MD5

像这样把场景的物体放到 Environment 物体下面,然后把脚本挂到 Environment,就能实现如下结果了:

dae88d424f6a4eb58634b1e451b549d5_MD5

从特定方向开始消融

af9dc9b6f88651963e54f903681c490b_MD5

DissolveFromDirectionX 场景

理解了上面的从特定点开始消融,那么理解从特定方向开始消融就很简单了。
下面实现 X 方向消融的效果。

  1. 求出 X 方向的边界,然后传给 Shader:
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
public class DissolveDirection : MonoBehaviour
{
public Material material;

private float m_minX;
private float m_maxX;
private static readonly int s_MinBorderX = Shader.PropertyToID("_MinBorderX");
private static readonly int s_MaxBorderX = Shader.PropertyToID("_MaxBorderX");

void Start()
{
CalculationBorderX(out m_minX, out m_maxX);
}
void Update()
{
material.SetFloat(s_MinBorderX, m_minX);
material.SetFloat(s_MaxBorderX, m_maxX);
}

//计算X边界
void CalculationBorderX(out float minX, out float maxX)
{
Vector3[] vertices = GetComponent<MeshFilter>().mesh.vertices;
minX = vertices[0].x;
maxX = vertices[0].x;

for (int i = 1; i < vertices.Length; i++)
{
float x = vertices[i].x;
if(x<minX)
minX = x;
if (x>maxX)
maxX = x;
}
}
}
  1. 求出各个片元在 X 分量上与边界的距离,并除以边界宽度 range
1
2
3
4
5
6
7
8
9
10
11
12
13
float4 MainTex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
float Noise = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, i.uv).r * _NoiseScale;

float range = abs(_MaxBorderX-_MinBorderX);
float borderDistance = saturate(distance(i.positionOS.x,_MinBorderX) / range + Noise);
clip(borderDistance-_DissolveThreshold);

//step算出溶解边缘
float internalEdge = step(borderDistance,_DissolveThreshold);
float externalEdge = step(borderDistance, _DissolveThreshold + _EdgeWidth);;
float edge = externalEdge-internalEdge;

float4 finalColor = lerp(MainTex, _EdgeColor, edge * step(0.0001,_DissolveThreshold));

灰烬飞散效果

57f269bc58db0d7a46c35ba31347b1b7_MD5

DirectionAsh 场景

主要效果就是上面的从特定方向消融加上灰烬向特定方向飞散。
首先我们需要生成灰烬,我们可以延迟 clip 的时机:

1
2
float edgeCutout = NoiseTex - _Threshold;
clip(edgeCutout + _AshWidth); //延至灰烬宽度处才剔除掉

这样可以在消融边缘上面留下一大片的颜色,而我们需要的是细碎的灰烬,因此我们还需要用白噪声图对这片颜色再进行一次 Dissolve:

1
2
3
4
5
6
7
8
float degree = saturate(edgeCutout / _EdgeWidth);
fixed4 edgeColor = tex2D(_RampTex, float2(degree, degree));
fixed4 finalColor = fixed4(lerp(edgeColor, albedo, degree).rgb, 1);
if(degree < 0.001) //粗略表明这是灰烬部分
{
clip(whiteNoise * _AshDensity + normalizedDist * _DistanceEffect - _Threshold); //灰烬处用白噪声来进行碎片化
finalColor = _AshColor;
}

下一步就是让灰烬能够向特定方向飞散,实际上就是操作顶点,让顶点进行偏移,因此这一步在顶点着色器中进行:

1
2
3
4
5
float NoiseTex = GetNormalizedDist(o.worldPos.y);
float3 localFlyDirection = normalize(mul(unity_WorldToObject, _FlyDirection.xyz));
float flyDegree = (_Threshold - cutout)/_EdgeWidth;
float val = max(0, flyDegree * _FlyIntensity);
v.vertex.xyz += localFlyDirection * val;

Trifox 的镜头遮挡消融

本文作者是来自 Glowfish Interactive 的开发者 Brecht Lecluyse,目前正在开发一款独特而多彩的顶视角双摇杆动作冒险游戏《Trifox》,灵感源自经典的跳台游戏,玩家在《Trifox》中扮演一只技艺超群的狐狸去拯救被侵略的家园。今天将由 Brecht Lecluyse 为大家分享《Trifox》游戏项目中遇到的角色与障碍物间的遮挡处理,以及溶解着色器相关的问题。

角色应该如何显示

定义问题

在开发过程中,我们面临的首要挑战,就是在全 3D 的场景中让主角保持在头顶视角的相机之内。也就是说,如果有东西挡住了主角,是选择避免这些遮挡,还是把遮挡物隐藏掉?假使选择隐藏的话,如何采用一个视觉上令人可以愉快接受的方式,来让隐藏的过程符合游戏风格?如何保持不会妨碍游戏体验的空间感?

对于那些镜头设置类似于《Trifox》的游戏而言,这是一个非常常见的问题。所以在开始实现自己的方案之前,我们先参考了一些已有的方案。

鸟瞰视角

第一个方案是禁止关卡中出现任何横亘玩家与摄像机之间的巨大障碍物。这意味着大部分区域必须非常广阔,而墙壁和障碍物需要尽可能保持低矮,或干脆完全避免使用它们,并且摄像机与玩家角色要有一个特定的距离,尽可能垂直向下看。这种形式非常适合于 “街机” 类游戏,或场景中所有物体都面向同一个方向的传统顶视角 RPG。然而在我们的案例中,视角与角色的距离拉远会导致玩家与主角产生疏远感。同时,也会使场景显得更加不自然,从而使玩家难以感知我们的游戏性、沉浸感以及视觉风格。

4481ca398d3b39c8c478e5eb4055858b_MD5

鸟瞰视角例子:Arrow Head 的《Helldivers》

切割及掀盖

下一个方案是对环境进行切割。您可以把场景想象为一个多层蛋糕,基于当前镜头关注的区域,玩家只能看到某一层的内容。

当主角站在建筑外部时,可以看到建筑的屋顶;而当他进入建筑内部时,整个屋顶以及高楼层就被全部隐藏,只显示当前楼层的墙壁和地板。大多数情况下不显示屋顶,这样能避免额外的设置工作。这种方法能够给玩家自然进出建筑物的感觉,让游戏能有更复杂的关卡设计和更强的空间感。虽然我们依旧需要将镜头定位在一个合适的距离并向下看,但由于障碍物可以被隐藏或显示,构造环境的方法可以更加自由。

为了进一步优化这个方法,我们隐藏墙壁和其他各种物体,决定它们是否遮挡我们的视野,是否允许更低的镜头角度及玩家角色视野更近。

32c15e2c5bb6c3dd6b86b243e7dff856_MD5

掀盖式相机示例:Firaxis Games 的《XCom: Enemy Unknown》

这种方法唯一的缺点是会让人感觉不太自然。虽然可以通过淡出和使用透明材质来减轻这种感觉,但总体来说,我们希望尽可能远离这些东西,以避免重绘性能问题和全屏透明覆盖。这种方法确实更加符合需求,但对于我们的游戏而言仍然不是非常理想。

自然演化

在考察了常规的解决方案后,我们仍旧感觉有些问题。这些方法在大多数游戏中都工作得很好,但在这个项目上,我们总感到似乎缺了点什么。

我们具体想要达成以下目标:

  • 障碍物应当能在平滑而自然的过渡之后被隐藏。
  • 关卡设计师应当能控制哪些东西能够被隐藏,于是我们就可以保留一部分依然能够遮挡视野的物体,在环境中增加纵深感。
  • 镜头和角色之间的距离发生变化时,系统应当运作如常。
  • 遮挡处理应当在任何角度下都能工作。
  • 障碍物被隐藏后,玩家需要依然能够感受到它的存在。
  • 设置工作需要尽可能简化。

我们最终得出的方案整合了之前所有的方法,并增加了额外的障碍物隐藏风格。

52de0a5043deab659200f6440a347697_MD5

当玩家向遮盖物移动的时候,遮盖物会逐渐变透明,确保视野不被遮挡,同时清楚地表现出此处确实有物体存在。这样,封闭空间场景依然可以保持封闭的感觉,并且墙面逐渐淡出不至于太显眼。这同时意味着我们可以让多个相交的大小形状不同的对象在一个统一的方式下消失,而不需要额外的设置。

那么我们如何达成这个目标?解决这个问题需要考虑如何来轻松地创建一个很棒的隐藏效果,可应用于各种游戏中的各种物体上。本文将阐述实现最终方案的过程,从深入研究一些常用的溶解着色器技术开始。

基础的溶解着色器与世界空间 UV

基于噪音纹理的裁切

最简单的溶解效果可以使用 2D 噪音纹理和裁剪的着色器技术来实现。这个裁剪函数会将所有大于等于 0 的值绘制到屏幕上,让所有小于 0 的值不可见,可以作为材质的开关。

上述的噪音纹理应用到 Unity 默认的立方体的示例如下。

0d94fbfce1db2a8879898a4e424fc027_MD5

a1f6b14af227baab2d41ff68f699c390_MD5

下一步是通过基于噪音纹理处理让这个立方体逐渐消失。回过头去看看 clip 着色器函数的工作原理和渐变纹理,这就很容易实现了。只需取出纹理中的灰度值,并减去一个 0 到 1 之间的值,这个值可以作为透明度百分比。

85c29fab88bd973d27a58e9e473619a3_MD5

您可能注意到上图的对象会在大概 75% 的透明度下完全消失,而不是 100%。这并不完全是我们期待的结果。纹理像素的值完全处于 0 和 1 之间,为什么会这样呢?

这是因为纹理导入引擎时,会进行一次伽马校正。这种情况下,我们需要将纹理当作线性数据纹理使用,也就是说我们需要将 rgba 的值当作数据而非颜色来使用。所以需要确保信息不被导入过程所改变。这可以通过调整纹理的导入设置来实现。

在 “导入设置” 窗口中,将纹理类型设为 “Advanced” 并启用 “Bypass sRGB Sampling” 标志。下面的动画展示了启用该设置前后的区别。在处理一些用到了纹理数据的更加复杂的着色器效果时,这个设置尤其重要。

780217401e700f4fd12375b9a1e3060d_MD5

b0484fae2a72c7e43fba2b83845716a0_MD5

2537ccabfc7e2ffd18a91115a9e5251d_MD5

这种方式对于一些可展开的网格非常适用,但如果是其它网格呢?如果有一些互相交叉的对象,能否创建一个效果,让它们表现得像是一个整体?

9b279cf06eb298bcec451173007249d7_MD5

除非特地在溶解纹理上展开了每一个对象,否则相邻的对象在溶解时会出现很大的差异。而对于缩放过的对象,将需要针对每一个缩放级别各自应用一个材质来保持噪音细节平滑。很明显,这会导致巨量的设置工作,不符合需求。下一步:用程序化世界空间展开来替代手动展开。

世界空间 UV

通常,纹理是依据网格在创建时定义的 UV 坐标映射到网格表面的。现在我们想要替换这些 UV 坐标,转而使用一个基于表面在场景中位置的坐标系。通过添加 Unity 内置的着色器输入参数 “worldPos”,我们就可以在着色器中访问到这个信息。

这是一个常见的着色器技术,可以用于各种有趣的程序纹理贴图技术。例如,可以用它自动在表面低于某个特定高度的地方创建水浸效果。

1d984316a128f4baf683211a8a091a57_MD5

再次把调整过后的着色器应用于立方体,这里需要一些额外步骤才能使世界空间纹理技术能够正常用于淡出效果。目前这个展开程序仅支持 3D 平面,这里的示例是 XY 平面,因为用到了 x 和 y 坐标来代替 UV 坐标。

10edcf6e51bf5424454698d160cc3caf_MD5

可以通过一些向量运算计算出能够作用于所有表面的 UV 集,无论这些表面在空间中的位置和方向如何。最终实现一个纹理应用于网格后,它能在不同的方向、缩放以及位置下保持连贯。

835d449e7ec0f85798cee172936ae4bb_MD5

最终应用于游戏资源的效果如下:

a08f305358b9c1852c612305afd00fd5_MD5

这足以应付大多数情况,但我们并未止步于此。从上图可以看出,使用这种噪音可能会产生令人不适的边缘溶解效果。此外,由于使用了纹理,在接近对象的时候会观察到明显的像素变化。我们同样也无法保证噪音纹理能够在很大的表面角度差和互相交错的物体之间都保持很好的连续性。

e0e11dc5a8dc415cf287d91a1f7b14bf_MD5

如何在游戏过程中处理遮挡住镜头视野的物体?我们提出了适用于该游戏的解决方案,考察了一些常用的着色器技术,比如: 世界空间贴图和基于纹理的溶解着色器。效果如下:

1318311e85ae76d2c48fb9b1a71143d0_MD5

我们发现了一些基于纹理的着色器技术的瑕疵:

  • 溶解效果在过渡边缘会显得很混乱。
  • 在近距离观察物体的时候会看到很大的像素块。
  • 无法让噪音纹理在很大的表面角度差和交错的物体之间保持很好的连贯性。

本篇内容

由于基于纹理的溶解着色器存在一些瑕疵,让我们不得不考虑完全摈弃纹理,而程序 3D 噪声方法会是一个比较好的选择。换句话说,我们可以使用数学过程由程序来生成 3D 噪音,以世界空间坐标为主要的输入参数,使用一个由包含 0 到 1 之间变化值的渐变体组成的 3D 纹理。

fafca9b5882962af0522a0a8891f49ea_MD5

上图的溶解百分比设为 50% 左右,以便更好的展示 3D 噪音体

下面一起来看看使用纹理和使用程序生成 3D 噪声这两种方案的对比。

1318311e85ae76d2c48fb9b1a71143d0_MD5

基于纹理的溶解着色器的效果

d7f087db6967cbea78701d0d432b4cd6_MD5

7d40ea65cf0c9c6f31b837e32e1fe0f6_MD5

基于程序 3D 噪声的溶解着色器效果

很神奇吧,全程序化技术与纹理技术相比的平滑度差别有多么大,再加上一个自发光边缘,就会看到有趣的魔幻破碎效果。

这是因为使用程序生成的 3D 噪音可以任意缩放而不损失质量。并且由于不需要 UV 坐标,无论物体之间方向如何,溶解过程同样也会无缝衔接。不过,这个问题解决之后,还需要思考一下:如何使用这个技术动态地处理镜头遮挡问题?

引入屏幕空间坐标系 - 动态处理镜头遮挡问题

上篇文章在谈到如何处理遮挡问题时,我们提到过玩家应当能够感受到被隐藏的障碍物的存在。如果可能的话,在不影响视野的前提下,我们仍然希望能够看见隐藏物体的一部分。为了实现这一点,我们在着色器中引入了屏幕空间坐标系。用这种方法,可以在屏幕空间中根据将要渲染的表面离屏幕中心的距离来决定是否移除它。

通常屏幕空间坐标系是用来做后期特效的,但在这里用于影响物体的基本着色。添加下面的着色器输入结构体来访问屏幕空间位置:float4 screenPos; 这样就能够像访问世界坐标一样,访问屏幕坐标。

用这样的方法,我们计算出一个简单的屏幕空间辐射渐变,并应用到场景中的物体上。值得注意的一点是,这并不是覆盖效果,而是屏幕坐标直接影响了渲染的几何体的色彩值。如下图,最终画面结果感觉就像用一个聚光灯照射在屏幕中心一样。

1ea3a06eb569a3d02f3836ed6dd9f5a8_MD5

一个完全未改变的辐射渐变

93f586b752a7af67d3630edb677d8512_MD5

经过了压缩调整的辐射渐变

经过压缩调整的辐射渐变可以符合屏幕的形状,并给出最终需要的遮罩。这个生成的遮罩能够帮我们判断出哪些部分需要溶解。但仍有一个重要的问题需要解决:如果就这样使用该遮罩,可能会为整个场景从相机位置开始打一个无限远的洞,如果玩家站在了一个可被溶解的遮挡物前,会怎样呢?我们希望确保那些物体保持完全可见,或至少把溶解效果控制在最小范围。

这个问题可以通过将玩家和相机的距离纳入计算,并分析渲染物体的深度值,被很好地解决掉。加入噪音遮罩后,最终得到如下结果:

dd8531edfaa5f20a15da048686eb57e7_MD5

这里玩家距离被设为固定的 20 米,以便展示效果更好

802526a107c5d946e4709247d6adb9a6_MD5

最终用于《Trifox》的效果添加了额外的步骤以获取更多的处理选项,比如通过顶点绘制来降低溶解密度,屏幕空间下降的控制,以及各种噪音微调选项。下图展示了完整的遮挡处理系统的效果:

97534239511a5b1988fc1e21cd025bd6_MD5

可以在拱门的下半部分看到基于深度的噪音衰减效果和顶点绘制控制。

在 Unity 5.6 Beta 版中,Vulkan 使图形显示性能更上一层楼,在最终发布的正式版中,Vulkan 将支持 Android,Windows,Linux,以及 Tizen 平台。

最后一步 - 功能检查与验证

在宣布遮挡处理系统完工前,让我们一起回顾最初的需求,并探讨这个方案是否符合。

  • 障碍物:应当以一个平滑而自然的方式移出视野,在任意角度都有效,即使已经被隐藏,玩家应当依然能够感觉到它的存在。
  • 关卡设计师:需要能够控制哪些东西可以被隐藏,以此加强环境的深度感。
  • 系统:无论角色和镜头之间的距离如何变化都能工作。
  • 设置工作:尽可能简化。

恭喜,任务完成!现在拥有了一个能够正常运转的系统,能够实现我们想要的效果,并且风格与游戏相匹配。  

总结

回顾整篇文章,以上是攻克《Trifox》中的障碍物处理问题的过程。从定义问题开始,我们考察了可能的解决方案,然后一步一步实现了最终游戏里的效果。

我们最终使用的是一个可以实现很多视觉效果的技术,《Trifox》中的大量游戏细节都使用该技术,包括让被打败的敌人和被破坏的物体消失,以及改变环境的外观或让整个世界淡入或淡出等等。下面提供了两个原型示例,展示这个技术的不同应用:

95a03e8fad7fba340ee68c5c10f4ae6b_MD5

比利时工作室 Exiin 也在游戏《Ary And The Secret Of Seasons》中运用了同样的技术,如下图所见,用来将季节球淡出。