Musoucrow' BLOG


  • Home

  • Categories

  • Archives

  • Tags

  • Search

在URP实现局部后处理描边

Posted on 2020-07-05 | In Teach | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  最近在Demo开发的过程中,遇到了一个细节问题,场景模型之间的边界感很弱:
0
  这样就会导致玩家难以分辨接下来面对的究竟是可以跳下去的台阶,亦或是要跳过去的台阶了。我们想到的解决方法便是给场景模型加个外描边,以此区分:
1
  整挺好,于是本文就来介绍一下实现思路。首先按照惯性我们直接采用了与人物相同的法线外扩描边,但是效果却不尽人意:
2
  这完全就牛头不对马嘴,既然老办法不好使那就看看后处理描边吧。不过由于Demo使用的渲染管线是URP,在后处理这块与原生完全不同。于是乎再一次踏上了踩坑之旅……
另附源码地址:https://github.com/MusouCrow/TypeOutline

RenderFeature

  经过调查发现,URP除了Post-processing之外,并没有直接提供屏幕后处理的方案。而URP的Post-processing尚不稳定(与原生产生了版本分裂),所以还是去寻找更稳妥的方式。根据官方例程找到了实现屏幕后处理描边的方式,当然它们的描边实现方式很搓,并不适合我们项目。于是取其精华去其糟粕,发现了其实现后处理的关键:RenderFeature。
3
  RenderFeature系属于URP的配置三件套之一的Forward Renderer,你可以在该配置文件里添加想要的RenderFeature,可以将它看做是一种自定义的渲染行为,通过CommandBuffer提交自己的渲染命令到任一渲染时点(如渲染不透明物体后、进行后处理之前)。URP默认只提供了RenderObjects这一RenderFeature,作用是使用特定的材质,在某个渲染时机,对某些Layer的对象进行一遍渲染。这显然不是我们所需要的,所幸官方例程里提供了我们想要的RenderFeature——Blit,它提供了根据材质、且材质可获取屏幕贴图,并渲染到屏幕上的功能:

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
Shader "Custom/Test"
{
Properties
{
[HideInInspector]_MainTex("Base (RGB)", 2D) = "white" {}
}
SubShader
{
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
struct Varyings
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
};
Varyings vert(Attributes input)
{
Varyings output;
VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
output.vertex = vertexInput.positionCS;
output.uv = input.uv;
return output;
}
float4 frag(Varyings input) : SV_Target
{
float4 color = 1 - SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
return color;
}
ENDHLSL
}
}
FallBack "Diffuse"
}

4
  如此这般便实现了经典的反色效果,只要引入Blit的相关代码,然后在Forward Renderer文件进行RenderFeature的相关配置,并实现Shader与材质,即可生效。较之原生在MonoBehaviour做这种事,URP的设计明显更为合理。

Outline

  后处理部署完毕,接下来便是描边的实现了。按照正统的屏幕后处理做法,应该是基于一些屏幕贴图(深度、法线、颜色等),使用Sobel算子之类做边缘检测。然而也有一些杂技做法,如官方例程以及此篇。当然相同的是,它们都需要使用屏幕贴图作为依据来进行处理,不同的屏幕贴图会导致不一样的效果,如上文那篇就使用深度与法线结合的贴图,产生了内描边的效果。然而我们只需要外描边而已,所以使用深度贴图即可。
  深度贴图在URP的获取相当简单,只需要在RenderPipelineAsset文件将Depth Texture勾选,然后便可在后处理Shader通过_CameraDepthTexture变量获取:
5
  有了深度贴图,那么接下来逮着别人的Shader抄就完事了——然而那些杂技做法的效果通通不行:官方的更适合美式风格,上文那篇的做法在某些场合会产生奇怪的斑点。于是只好按照《UnityShader入门精要》的写法来了:

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
Shader "Custom/Outline"
{
Properties
{
[HideInInspector]_MainTex("Base (RGB)", 2D) = "white" {}
_Rate("Rate", Float) = 0.5
_Strength("Strength", Float) = 0.7
}
SubShader
{
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
TEXTURE2D(_CameraDepthTexture);
SAMPLER(sampler_CameraDepthTexture);
float4 _CameraDepthTexture_TexelSize;
float _Rate;
float _Strength;
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
struct Varyings
{
float4 vertex : SV_POSITION;
float2 uv[9] : TEXCOORD0;
};
Varyings vert(Attributes input)
{
Varyings output;
VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
output.vertex = vertexInput.positionCS;
output.uv[0] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(-1, -1) * _Rate;
output.uv[1] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(0, -1) * _Rate;
output.uv[2] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(1, -1) * _Rate;
output.uv[3] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(-1, 0) * _Rate;
output.uv[4] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(0, 0) * _Rate;
output.uv[5] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(1, 0) * _Rate;
output.uv[6] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(-1, 1) * _Rate;
output.uv[7] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(0, 1) * _Rate;
output.uv[8] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(1, 1) * _Rate;
return output;
}
float4 frag(Varyings input) : SV_Target
{
const half Gx[9] = {
-1, 0, 1,
-2, 0, 2,
-1, 0, 1
};
const half Gy[9] = {
-1, -2, -1,
0, 0, 0,
1, 2, 1
};
float edgeY = 0;
float edgeX = 0;
float luminance = 0;
float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv[4]);
for (int i = 0; i < 9; i++) {
float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, input.uv[i]);
luminance = LinearEyeDepth(depth, _ZBufferParams) * 0.1;
edgeX += luminance * Gx[i];
edgeY += luminance * Gy[i];
}
float edge = (1 - abs(edgeX) - abs(edgeY));
edge = saturate(edge);
return lerp(color * _Strength, color, edge);
}
ENDHLSL
}
}
FallBack "Diffuse"
}

6
  很棒,但是可以看到,身为一般物件的方砖也被描边了,可我们想要的只是场景描边而已——于是进入了最后的难题:对特定对象的后处理。

Mask

  首先我们参考原生下的做法,利用模板测试的特性,对特定对象的Shader写入模板值,然后在后处理时根据模板值做判断是否处理,确实是个绝妙的做法——很可惜,在URP下我找不到能够生效的做法。根据上文那篇需要渲染出深度法线结合的屏幕贴图的需要,作者实现了一个新的RenderFeature:根据渲染对象们的某个Pass,渲染成一张新的屏幕贴图(可选择使用特定的材质,若不使用则是Pass的结果)。并可作为全局变量供后续的后处理Shader使用。我将之命名为RenderToTexture,这也是后处理常用的一种技术。
  有了这个便有了新的想法:为所有渲染对象的Shader添加新的Pass(名为Mask),该Pass根据参数配置决定渲染成怎样的颜色(需要描边为白色,不需要为黑色)。如此渲染成屏幕贴图后便可作为描边Shader的参考(下称Mask贴图),决定是否需要描边:
7
  注意要为Mask贴图的底色设置为非黑色,否则与底色接壤的物件会描边失败。那么见证成果吧:

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
Shader "Custom/Outline"
{
Properties
{
[HideInInspector]_MainTex("Base (RGB)", 2D) = "white" {}
_Rate("Rate", Float) = 0.5
_Strength("Strength", Float) = 0.7
}
SubShader
{
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
TEXTURE2D(_CameraDepthTexture);
SAMPLER(sampler_CameraDepthTexture);
float4 _CameraDepthTexture_TexelSize;
TEXTURE2D(_MaskTexture);
SAMPLER(sampler_MaskTexture);
float _Rate;
float _Strength;
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
struct Varyings
{
float4 vertex : SV_POSITION;
float2 uv[9] : TEXCOORD0;
};
Varyings vert(Attributes input)
{
Varyings output;
VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
output.vertex = vertexInput.positionCS;
output.uv[0] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(-1, -1) * _Rate;
output.uv[1] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(0, -1) * _Rate;
output.uv[2] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(1, -1) * _Rate;
output.uv[3] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(-1, 0) * _Rate;
output.uv[4] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(0, 0) * _Rate;
output.uv[5] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(1, 0) * _Rate;
output.uv[6] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(-1, 1) * _Rate;
output.uv[7] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(0, 1) * _Rate;
output.uv[8] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(1, 1) * _Rate;
return output;
}
float4 frag(Varyings input) : SV_Target
{
const half Gx[9] = {
-1, 0, 1,
-2, 0, 2,
-1, 0, 1
};
const half Gy[9] = {
-1, -2, -1,
0, 0, 0,
1, 2, 1
};
float edgeY = 0;
float edgeX = 0;
float luminance = 0;
float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv[4]);
float mask = 1;
for (int i = 0; i < 9; i++) {
mask *= SAMPLE_DEPTH_TEXTURE(_MaskTexture, sampler_MaskTexture, input.uv[i]);
}
if (mask == 0) {
return color;
}
for (int i = 0; i < 9; i++) {
float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, input.uv[i]);
luminance = LinearEyeDepth(depth, _ZBufferParams) * 0.1;
edgeX += luminance * Gx[i];
edgeY += luminance * Gy[i];
}
float edge = (1 - abs(edgeX) - abs(edgeY));
edge = saturate(edge);
return lerp(color * _Strength, color, edge);
}
ENDHLSL
}
}
FallBack "Diffuse"
}

8
  很棒,这下一般物件不会被描边了,局部后处理描边完成!当然随后遇到一个新的问题:
9
  这是因为透明(Transparent)模式下的对象按照通用做法是不会写入深度信息的(为了透明时能看到模型内部),然而我们描边需要的正是深度信息,由于树叶没有写入深度信息,所以在描边时当它不存在了,于是产生了这样的结果。解决方法也好办,在透明模式也写入深度信息(ZWrite)即可,毕竟我们的透明模型不需要看到内部,一举两得。

后记

  其实期间还产生了投机心理,想着把角色自带的描边给废了,统一后处理,岂不美哉?很可惜搞出来的效果始终是不满意,法线外扩 is Good,没办法喽——
  顺带一提,对于后处理的贴图创建记得将msaaSamples属性设为1,否则就会进行抗锯齿处理,那可真的炸裂……

3D瓦片地图采坑录

Posted on 2020-06-07 | In Teach | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  由于Demo的场景风格主打像素风格(这里的像素风格指具备一定精度的风格,而非时下流行的马赛克),故决定使用瓦片(Tile)地图来实现。也就是这种东西:
0
  游戏地图将由一个个规范化的单位图片拼接而成,是为瓦片。在早期的像素风格游戏可谓相当流行,因其构造成本低(无论从技术上还是美术上),而却能灵活拼接出各种各样的地图。于是我们也打算如此,但是在3D游戏下搞瓦片地图确是罕见得很。于是便开始了采坑之旅,遂成本文。

Sprytile

  首先我们想到了在3D游戏下最接近瓦片地图思想的方案:Terrain,一般3D大地图都用类似方案:对一张平面地图进行各种揉捏形成地形,并在此之上涂抹各种图素。但可惜对于瓦片地图而言未免杀鸡用牛刀了,且在各方面都不能做到最适化,遂不考虑。
  其次便是Unity官方自带的2D Tilemap Editor,对瓦片方面的需求倒是满足了,可惜3D瓦片并不只是在一张平面上进行,而通过多张平面旋转组成场景未免勉强,遂放弃。
  既然在Unity这边行不通,那么便考虑生产端的Blender有什么合适的插件了。果不其然找到了Sprytile,一看就明白,就是它了:
1
  使用准备好的tile图集,划分格子,在Blender以格子为单位进行填涂,每个格子将会是一个面,支持XYZ三个平面进行,在填涂完成后可作为正常模型进行各种操作。一切看起来是那么的美好,然后一路到了游戏后……

过滤之殇

  以前篇的截图便可看出问题所在:
2
  可以看到地板之间存在奇怪的黑线:
3
  根据插件文档提供的Unity导出说明来看,必须要将地图贴图的过滤模式设置为Point,即邻近过滤:
4
  嗯,黑线果然消失了。那么问题便出在图片的过滤模式了,回去复习了下纹理过滤,答案昭然若揭:
5
  由于瓦片地图的习惯会将相关图素集成一块,形成连续的图块:
6
  那么在线性过滤下,图块的边缘像素在采样的时候将会混进相邻图块的颜色,于是那些奇怪的黑线便是这么来的。
  也许读者会认为:不用线性过滤不就得了,毕竟插件作者也是这么认为的。可惜我们做的风格并不适合用邻近过滤,那将显得与人物画风差异过大且马赛克:
7
  细心观察上图底部,这是开启抗锯齿(MSAA)导致的。查阅资料后发现这是MSAA的特点导致,如此哪怕我们想靠邻近过滤解决也是没门……当然也考虑过更换为其他抗锯齿的方式,但是效果都不甚喜人,于是开始寻觅解决之道。

无奈的解法

  首先考虑对tile图片进行下手:既然边缘会采样到相邻图块的像素,那么将它们隔开不就得了,如此:
8
  当然这么做的话要让Sprytile有所支持才行,好在它是开源的。Blender的插件改起来也还算容易,毕竟会在界面提示标识好功能函数名。结果还是翻车了:
9
  瓦片之间出现了奇怪的透明点,仔细一想便明白了:透明像素一样会被采样到,所以会影响到透明度。于是迅速想到下个方案:让每个图块外扩边缘1像素,这样边缘的采样只会采样到相同颜色的像素:
10
  很不错,黑线什么的都消失了,哪怕是开着MSAA。同时美术也在Blender琢磨出了一种方法:在不做任何处理的贴图的基础上,将瓦片地图模型的UV全部缩小一点(参考值:0.96),如此便不会直接采样到边缘像素,从而以牺牲了一点点边缘效果解决了问题。
  两者的效果最终是差不多的,但都是不完美的,在编辑器里便一目了然:
11
  可以看得出,格子之间的边界可谓泾渭分明,这是因为每个格子都是独立的mesh,它们并不是作为一个整体去渲染,也就不存在视为一个整体去采样。也就无法达到真正意义上2D游戏里要达到的效果(格子之间彻底融为一体)。要这么做有两种方式:第一种是根据瓦片地图的填涂情况最终生成大贴图与新模型,但这种方式相当不优化,并且会急剧增大包体。其次是仿照以前的2D游戏做法:游戏本身使用邻近过滤渲染,最后将渲染成图进行拉伸放大(放大方式采用线性过滤),但这是牺牲画面分辨率带来的。
  经过以上总结可以看出,基本不存在非常完美的解决方案,只能矮子里拔大个了:UV缩小的方案从工序上最为简单,且显示效果也能接受(没对比过原版基本看不出太多异样)。

后记

  一开始我们觉得这种瓦片地图1个格子就占2个三角形,面数会不会太高了。结果在参考其他游戏的情况时发现《闪之轨迹3》的一组垃圾桶的面数……
12
  嗯,一组垃圾桶的面数都完爆我整个瓦片地图模型了,法老控牛逼!
  顺带一提的是,刚才提到的格子边界问题在Unity官方的2D Tilemap Editor也是存在的,只能说是瓦片地图的局限性了,好在离得远也看不太出。

Demo的卡通渲染方案

Posted on 2020-05-10 | In Teach | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  本篇文章按理来说在三月便该发布了,因为插队原因延宕至今,不过好饭不怕晚,干就完了奥利给!阅读本文最好拥有一定的图形学知识,当然看个热闹也是好的。
  游戏画面的风格是一开始便要定下的大事,这在古法2D主要通过素材本身及后期调色决定,没有太多文章可作。而在现代游戏(尤其是3D)则会通过Shader在原本的元素上进行加料,如通过基于物理的渲染(PBR)将模型凸显出金属、石头、布料等材质倾向。而在早期为了凸显3D模型的立体感,一般会采用经验总结出来的冯氏光照模型(Blinn-Phong),这也是许多3D软件的默认方案,那将会让我们的模型长成这样:
0
  嗯,这有够雕塑风的,让我想起了当初名震一时的猴赛雷,有着异曲同工之妙:
1
  由此可见,对于讲究卡通风格的游戏,这种通用的光照模型肯定得枪毙,于是本文才会诞生。对于这类非写实方向的渲染方案,业界称之为NPR。而往下细分则是日式卡通渲染,其中佼佼者当属《罪恶装备》系列,而《崩坏3rd》也是不少人在这方面的启蒙者。当然美术这一块没有绝对的风格一致,渲染也不例外,所以Demo里的卡通渲染方案乃是个人的方案,不代表业界的标准实现与效果。
  Demo基于Unity2019.3开发,渲染管线为URP7.3.1,采用直接编写Shader的方式(HLSL),将一一介绍其中要点。本文所谓的卡通效果以日式2D赛璐璐风格为准,不论厚涂之类的风格。

着色

  首先我们先抛开一切:冯氏光照不好那咱们就是了。直接把贴图显示了,什么料都不要加。

1
2
3
// 根据uv坐标获取对应贴图上的颜色
half4 color = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);

2
  嗯,虽然很原始,但好歹没那股恶心感了,把投影也加上:

1
2
3
4
// 根据渲染管线提供的shadowCoord获取光照信息,并计算出投影颜色
Light mainLight = GetMainLight(shadowCoord);
color *= mainLight.color * (mainLight.distanceAttenuation * mainLight.shadowAttenuation);

3
  哎呀,有了投影瞬间立体起来了,开始有《塞尔达传说:风之杖》内味了:
4
  要加投影记得添加Pass:ShadowCaster,并且获取光照信息也需要开启一定的宏,这些并非本文重点,详情请查阅URP的Shader实现。
  但只是如此还不够:颜色太鲜艳了,看久了会累。那么有两种方案:调色与着色,调色则是进行总体的颜色调节,使之不要这么鲜艳,着色则是根据模型面对光的吸收度决定明暗。这里还是选择着色:它将会增强模型的立体感。
  这里说的着色其实就是冯氏光照中的漫反射(Diffuse):当光照射到非平面的物体上,将根据与光的夹角决定吸收度(越是与光垂直的面越亮)。而在3D模型中,每个模型面都会往上发射一条射线,也就事实上构成了一条垂直于平面向量,这在数学中称之为法线(Normal)。我们可以使用向量点积(Dot)获取法线与光照方向之间的夹角,以此决定模型面的光亮程度。
5

1
2
3
half NDotL = dot(normal, lightDir); // 计算法线与光照方向的夹角系数
NDotL = saturate(NDotL); // 保证系数在0-1
color *= NDotL;

6
  啊这,这不是跟一开始差不多么?这是当然的,因为一开始便是冯氏光照的方案。其漫反射的思想其实并无问题,但原罪在于过渡太丰富了,每个模型面与光的夹角都不同,导致颜色都不同。整个模型看起来就过于立体,以至于产生了雕塑感。
  而在日式2D卡通的世界里(尤其是赛璐璐),着色并不会有太详细的过渡,只是到了某个角度统一涂暗,反之为亮,最多在两者之间加点过渡而已。那么便基于此思想进行改造即可:

1
2
3
4
5
6
7
8
9
10
11
12
half NDotL = dot(normal, lightDir); // 计算法线与光照方向的夹角系数
// 根据_DiffuseRange约束系数,输出0-1的值
// 由于使用了smoothstep,在接近_DiffuseRange上下限时会做柔滑处理,使之产生过渡感
// _DiffuseRange的参考值为0.5, 0.7
half v = smoothstep(_DiffuseRange[0], _DiffuseRange[1], NDotL);
// 根据根据v的值决定输出_LightRange范围内的值
// _LightRange的参考值为0.9, 1
v = lerp(_LightRange[0], _LightRange[1], v);
color *= v;

7
  还不错,这下便为模型划分了明暗,并在两者之间做了过渡,这种方式称之为二值化。着色并没有采用很明显的暗色,只是想凸显一点立体感,以及让画面更柔和,不那么刺眼罢了。当然目前可以说是非常不明显了,这是有原因的,且待后续调色。

描边

  接下来需要补上日式2D卡通不可或缺的一部分:描边(Outline),描边有助于划分物体,明确空间上的层次,并提供一定的风味。
  关于描边的实现方式,业界主要有模型多画一遍并将边缘外扩以及屏幕后处理的方案。前者方案在日式游戏较为流行,优点在于实现简单,性能也还算过得去,缺点是必须开抗锯齿不然没眼看。后者实现方式多样,并且根据实现方式能达到不一样的效果(如一定程度的内描边),但有些更适合搭配延迟渲染(Deferred Rendering),而这代表着对显卡带宽与光照方案有要求。
  另外在显示方案上也有区别,有追求任何缩放下描边大小不变的,也有自然派的。有让描边纯色的,也有要让描边根据贴图颜色决定的。本人采用的是模型外扩、自然缩放、根据贴图颜色决定的描边方案。
  多显示一遍模型在Unity增加一个Pass即可,并且开启正面剔除(只显示背面,不然会干扰到正常模型)。并且在顶点着色器对模型顶点进行外扩,外扩的方向由所在模型面的法线决定。而颜色方面则在片元着色器根据贴图颜色进行置暗显示即可:

1
2
3
4
5
6
7
8
9
10
// 顶点着色器
half4 viewPos = mul(UNITY_MATRIX_MV, input.positionOS); // 将顶点从模型空间转为观察空间
half3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, input.normalOS); // 同上,将法线转为观察空间
viewPos += float4(normalize(normal), 0) * 0.0075; // 顶点沿法线外扩
output.positionCS = mul(UNITY_MATRIX_P, viewPos); // 将顶点从观察空间转为裁剪空间
output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); // 提取uv
return output;
1
2
3
4
// 片源着色器
color *= SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); // 提取贴图颜色
return color * 0.3; // 压暗

8

  果不其然,没有抗锯齿的话就很搓,跟早期的跑跑卡丁车似的。安排一波MSAA8x:
9
  哎,这就舒服多了,当然实际上由于小泥人的关系,4x和8x实际上看不出区别,而2x也算可以接受的效果,这么看来能耗也还好。当然关于描边实际上还有内描边这个大题,但小泥人不需要这么丰富的细节,这就很舒服。

发光

  目前模型的显示还欠缺一些发光的元素,如一般头发和武器会有一些高光效果。这在冯氏光照称之为镜面光照(Specular):本质上与漫反射一样,只是由视角方向与光照方向相加,并与法线做点积获得两者的夹角系数,如此便可实现根据摄像机与光照运动结合决定模型高光的位置。
  当然仅此而已是不够的,显而易见仅此而已的话将会如漫反射一般范围很大,而高光实际上只需要一点即可。实际上会将之范围缩小:

1
2
3
4
5
6
half3 halfVec = normalize(lightDir + viewDir); // 将摄像头方向与光照方向相加
half NdotH = dot(normal, halfVec); // 与法线点积,获取夹角系数
NdotH = saturate(NdotH); // 保证在0-1
half v = pow(NdotH, _Smoothness); // 缩小夹角系数的值,由于NdotH在0-1,所以pow后会变得更小,_Smoothness参考值为8-64
color += color * v; // 在原有颜色的基础上叠加

10
  与之前一样,这样的高光过渡太强了,不够卡通,将之二值化:

1
2
3
4
5
half v = pow(NdotH, _Smoothness); // 缩小夹角系数的值,由于NdotH在0-1,所以pow后会变得更小,_Smoothness参考值为8-64
v = step(_SpecularRamp, v); // 小于_SpecularRamp的值将为0,反之为1
v = v * _SpecularStrength; // 定义高光强度,参考值为0.2
color += color * v; // 在原有颜色的基础上叠加

11
  这样的高光就更有手绘的感觉了,牛屎一块。但很显然对于头发而言光是一块牛屎高光是不够的,让美术自由的进行创作显然是更好的方案。于是引入了发光贴图(Emission),其本身很简单:就是在最后把发光贴图的内容显示出来即可。而之所以要单独划分贴图而不是画死在原贴图,在于要自由的控制透明度甚至曝光,以及让发光参与单独的光照运算(与高光类似的方法,摄像机视角与光照方向相加后与法线点积)。
12

  到了目前仍缺一个日式2D卡通的一个特性:边缘光(Rim),一般为了表达物体处于光亮的环境下,属于光溢出的一种表达,有助于提升画面的层次感。实现原理也很简单:视角方向与法线点积,根据夹角系数取得当前视角下的模型边缘部分,为之加光即可。

1
2
3
4
5
6
7
8
half VDotN = dot(viewDir, normal); // 视角方向与法线点积,获取夹角系数
VDotN = 1 - saturate(VDotN); // 取反,方便计算
half v = smoothstep(_RimRange[0], _RimRange[1], VDotN); // 与漫反射部分类似,做二值化,参考值为0.4-1
v = step(0.5, v); // 小于0.5的部分都不要了
v = v * _RimStrength; // 设定边缘光强度,参考值为0.1
color += color * v; // 叠加

13
  发光的构成大致如此,目前也许看起来不够明显,实是尚未调色所致,且看下文。

调色

  先来看看目前的效果:
14
  首先是整体颜色风格不符合主题,这个场景属于有着岩浆的密室,应该符合昏暗以及灼热的色调,使用Split Toning进行调色:
15
  嗯,至少色调上像样了,但还是缺乏灼热的感觉,上Bloom看看:
16
  哎呀,看着只是稍微亮了点的样子,那是因为Bloom需要配合HDR使用,将颜色突破0-1的限制下进行运算,才能做到光溢出的效果:
17
  唔……这溢出的实在是有限,因为目前还处于Linear颜色空间,显示器对于颜色会进行处理,使得颜色之间的区间变小(明暗不明显),需要转成Gamma才能抵消之:
18
  成了,如此便得出了昏暗且灼热的场景风格,高对比度(亮者更亮、暗者更暗)的画面。

后记

  这算是本人进入图形渲染的一个里程碑,感觉这的确是个美术活。技术不过是让你能进入赛道罢了,真正决定效果的还得看美术的理念。

移植贪吃蛇——从C#到C++

Posted on 2020-04-07 | In Teach | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  因为某些机缘巧合,引起了我对C++的重视。一时兴起,决定将两年前用Unity写的Snake进行移植。经过两周的抽空,总算是完成了。项目采用现代C++标准编写,采用CMake构建,图形库为SDL。由于本次的重点不在于图形这块,所以没有使用原版的素材,采用矩形代替。
  在工程实现上除了基本的业务外,还实现了C#的event以及的Unity的GameObject与Component。
  本文将从C#开发者的角度出发比较C++的不同点,最后总结其思想。由于本人在此之前从未有C++的工程经验,对于许多特性在此之前也是一知半解,对于一些事物的理解若有误还请指教。

低成本封装

  首先最引我瞩目的便是C++的参数传递,形如这般的函数:

1
void Init(const string& title, int width, int height);

  由于C++的引用参数string&性质,将值传入时不会发生拷贝,而是等于直接使用原变量。可以有效降低封装抽象的成本,加上const字段是为使得形如"123"这样的常量区对象也能传入。
  当然这在C#也并不是没有,ref便是如此。但这在C#并不会下意识去用,毕竟在C++若是不用指针或引用作为参数的话可是会直接拷贝新对象的,而在C#直接使用也不会造成很大的负担(值类型直接拷贝,引用类型用指针)。
  其次便是C++的内联函数了,作为函数宏的替代品之一。可以在编译时将函数展开为具体的内容,节省了一次函数调用的消耗。但内联函数需写在头文件中,若是关联项多,修改后便会增加编译时长。且展开量过大也会增大代码量,增加编译时长。但不失为一个降低封装成本的手段。

明确的内存

  其次与C#最大的不同便是对象的创建了,C++有着以下两种形式:

1
2
A a = A();
A* a = new A();

  了解C++的自然晓得,前者在当前内存域下申请,后者在堆申请。而在C#则隐去了这个细节,而是设立固定的规则:

  • 引用对象使用指针,原则上在堆申请,若对象的生命周期存在于申请的函数里,则在栈申请——是为逃逸分析。
  • 值对象在当前内存域下申请,且由于不是指针,变量传递会产生拷贝。除非使用ref、in、out等参数关键字。

  而C++的内存申请机制则带来了明确感,如在函数里申请生命周期只存在函数里的对象,需要明确的使用A a = A();方式。且在构建类的时候,对于那些不使用A* a = new A();创建方式的成员变量,其内存占用是明确的,在类对象申请内存的时候会一并申请,即这些成员变量在内存布局上可能是连续的。从这点来说可比C#要牛逼多了。

相似的容器

  在容器方面,C++与C#大体看起来是相似的,当然在API的爽度而言还是C#更胜一筹(C++17拉近了不少)。但实际上还是存在一些细节上的不同,就比如我们常用的Key-Value容器:C++的std::map与C#的Dictionary在实现乃至功能上就不一样。实际上std::map对应C#的应该是SortedDictionary:它们都是基于红黑树实现,都是有序存储的表。而Dictionary则是基于哈希实现的,即我们俗称的哈希表,与之对应的是std::unordered_map。
  通过命名能看出两种语言在这方面的倾向性:红黑树占用的内存更小,但查找和删除的时间复杂度都是O(logn),而哈希查找和删除的时间复杂度都是O(1)。实际使用的时候感觉还是得权衡利弊,不能贪图方便就一直用一套。std::set与HashSet这边也是类似的对应,以此类推。
  在序列容器方面的对应倒是工整:std::vector对应List,都是不断扩容的数组容器。链表方面则是std::list对应LinkedList。但std::array却无对应了,硬要说的话就是与C#的原生数组对应,毕竟这个容器出现的意义就是弥补与C语言兼容的原生数组。
  顺带一提,在使用std::vector时由于会出现扩容复制的问题,需要考虑好成员对象的拷贝方案,乃至于内存泄漏的问题。

智能的指针

  内存管理是所有编程语言都无法绕开的点,绝大多数编程语言对于堆内存的管理都是采用垃圾回收的方式。而在C++的鸿蒙时代则与C语言一样,需要手动管理指向堆内存的指针。尽管也有std::auto_ptr这样的东西,但在功能上还不够全面。而手动管理内存将难以解决对象在多处被引用时将如何安全销毁的问题,为了实现这种机制也得做出不少妥协。
  所幸随着时代的发展,现代C++迎来了智能指针,它基于引用计数的规则,将裸指针包装起来,当符合销毁条件后便可自动回收。智能指针有着几种具体的类实现,而其中最常用的是std::share_ptr,当它持有指针时将增加计数,反之同理将减少计数,最终归0销毁。但其较之垃圾回收有个致命的缺陷:相互引用时将一直保持计数,无法销毁。为此C++引入了std::weak_ptr:它不会增加计数,在计数归0时持有指针也随之销毁。如此对于相互引用的情况下,分清主次,合理分配share_ptr与weak_ptr即可解决无法销毁的问题。
  智能指针在使用上总有一种外挂的感觉,需要成体系的去使用。不如内置的垃圾回收式语言来的方便,且写起来还是有一定的心智负担(相互引用),不过在性能而言较之垃圾回收更为优越(回收对象与时机都很明确,且是被动进行的)。

模板与泛型

  C++的模板与C#的泛型表面上用起来很是相似,实则有所不同。以下对比两者的差异:

1
template<class T, int x> // C++支持模板参数,可填写整型或指针

1
GenericList<T> where T : Employee // 使用System.Object不支持的方法时,需进行类型约束指定基类
1
2
3
4
5
6
7
// 这么骚的操作见过么?
void f(int x);
template <class ... Args>
void Do(Args... args) {
f(args ...);
};

  从实际使用体验与两者的命名可以看出,「模板」的本质是参数化代码生成,而「泛型」则是类型参数化。即泛型只是模板功能的一部分而已。模板能实现的其他功能,在C#则以其他方式代替了(如变长参数params)。

后记

  从以上种种便能看出C++与C#在设计哲学上的不同,C#通过约束开发者行为从而达到更稳定健壮的结果,哪怕会失去一定的性能与灵活性,而C++则更依赖开发者自身的素质(如C++支持多重继承而C#仅仅支持单类+多接口继承)。
  从个人的使用体验来看,现代C++并非不能作为业务开发语言。只是对开发者的素质要求较之一般语言更高,从招聘成本与项目稳定性而言是个问题。如此来看,除非有必要的性能敏感且需要一定封装的核心层(如游戏引擎),否则用C + 脚本语言或者C#/Java这类可上可下的语言是个更好的选择。

Shader Graph踩坑实录

Posted on 2020-03-08 | In Teach | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  Demo也到了做渲染的时候了,经过一番鏖战后,算是大体完成了。但随着后续需求的到来,发现这套纯代码的Shader方案对于扩展、复用等方面有着诸多不便。于是便打起了Shader Graph的主意……经过一番纠缠,于是有了本篇踩坑实录。另附源码地址,但本篇并不会对其做讲解。基于Unity2020.1.0a20,渲染管线为URP7.18。

优劣

  首先要明确的是:Shader Graph不支持Builtin渲染管线,且尚未处于彻底成熟的阶段,哪怕是最新版本尚有不少缺陷。但瑕不掩瑜,且说其优劣:

  • 优点
    • 由于节点化与Sub Graph的存在,Shader的组装将变得相当容易,极大提升了模块化水平。
    • 调整Property与Keyword变得相当方便,纯代码下将需要多些工序。
    • 内置各种节点,降低了美术参与创作的门槛。
    • 能够实时预览每个节点造成的变化预览,虽然对我而言没啥用。
  • 缺点
    • 编辑器尚不够稳定,经常会出现整个程序崩溃的情况。
    • 生成的代码不够优化,比较暴力,存在各种分支判断、重复函数(也许最终会优化?)
    • 由于Slot机制的原因,可能会出现很多运算集中在片元着色器中。
    • 对于创建Pass并不友好,需要修改源码。

来龙

  其实一般的浅度Shader Graph使用者并不会如我这般踩这么多坑:直接使用内置的Graph模板进行创作即可。不幸的是,如前文所言:对于创建Pass并不友好,需要修改源码。于是便开始了踩坑之旅……
  在默认情况下,Unity的Package将保存在工程的Library/PackageCache目录下,这样子是不能直接修改源码的(Library目录下的东西属于可再生物,随时会被覆盖),需将之搬迁至工程的Packages目录下。
  对于考虑到日后Shader Graph的版本升级情况,所以尽可能的不要修改原工程的内容,而是尽量新建文件。但考虑到Shader Graph源码下存在不少inner元素,直接在外面写自己的内容也并非彻底可行。只能直接在Shader Graph包下进行添加文件的方式了,这也是要将之移至Packages目录的原因。
  我要做的事情相当明确:新建一个自定义的Graph类型。在URP下已经自带Unlit与PBR两种类型了,于是本人便基于Unlit Graph并结合先前实现的Shader的特性进行新类型的创作。

去脉

  我们能接触到Unlit Graph创建的起点便是Project区下右键菜单的Create->Shader->Unlit Graph了,直接在Shader Graph源码包下全局搜索Create/Shader/Unlit Graph即可找到:
0
  照葫芦画瓢在同目录下弄个新文件实现相同功能即可,这下我们便知道Unlit Graph的正主了:UnlitMasterNode。经过研究发现,它决定了在编辑器下Unlit Master Node的样式:
1
2
  但这只是个壳子罢了,根据代码中的IUnlitSubShader为引,找到了其核心:
3
  这个UniversalUnlitSubShader的作用相当简单:根据编辑器的设置生成Shader代码。如上图便可看出定义Pass数据结构的行为,这也是诱使我来改源码的直接原因。在里面你将见到形如这般的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var unlitMasterNode = masterNode as UnlitMasterNode;
var subShader = new ShaderGenerator();
subShader.AddShaderChunk("SubShader", true);
subShader.AddShaderChunk("{", true);
subShader.Indent();
{
var surfaceTags = ShaderGenerator.BuildMaterialTags(unlitMasterNode.surfaceType);
var tagsBuilder = new ShaderStringBuilder(0);
surfaceTags.GetTags(tagsBuilder, "UniversalPipeline");
subShader.AddShaderChunk(tagsBuilder.ToString());
GenerateShaderPass(unlitMasterNode, m_UnlitPass, mode, subShader, sourceAssetDependencyPaths);
GenerateShaderPass(unlitMasterNode, m_ShadowCasterPass, mode, subShader, sourceAssetDependencyPaths);
GenerateShaderPass(unlitMasterNode, m_DepthOnlyPass, mode, subShader, sourceAssetDependencyPaths);
}
subShader.Deindent();
subShader.AddShaderChunk("}", true);

  如此情况便变得相当清晰了,只要清楚你想生成怎样的Shader代码,在摸熟了生成代码的API,便可自由地进行创作了。通过右键节点可以随时查看生成的代码情况:
4

注意事项

  也许看似还算简单,但其中坑点还是有不少的:

  • 不要尝试采用继承的形式去新建新类型,其本身代码就没打算让你这么做,必然会碰壁,除非改源码(如此便违反原则了)
  • 做出了修改后,要到对应的Graph文件进行Save操作触发检测。
  • 也许是检测的原因,有时候HLSL代码做出了修改后不会被识别到,需要换下行。
  • Shader Graph生成的着色器参数有着自己的一套处理方式,务必参考自带的代码。
  • Unlit Graph的主Pass并没有LightMode,想做背面Pass的时候要注意下。
  • Unlit Graph将渲染模式、混合模式、剔除做成节点设置并不是一个好选择(无法让材质修改),推荐按照URP的方式做成材质属性。5
  • Shader Graph对于生成的Shader代码存在分支数限制,需要到Preference->Shader Graph进行修改上限。值得一提的是,全局Keyword与局部Keyword似乎是分别对待的。
  • Shader Graph并不存在完整的环境,它是无法识别到一些渲染管线里的函数的。所以在编写Custom Shader的时候需要加上#if SHADERGRAPH_PREVIEW分支判定以处理在编辑模式下的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void MainLight_float(float3 WorldPos, out float3 Direction, out float3 Color, out float DistanceAtten, out float ShadowAtten)
{
#if SHADERGRAPH_PREVIEW
Direction = float3(0.5, 0.5, 0);
Color = 1;
DistanceAtten = 1;
ShadowAtten = 1;
#else
#if SHADOWS_SCREEN
float4 clipPos = TransformWorldToHClip(WorldPos);
float4 shadowCoord = ComputeScreenPos(clipPos);
#else
float4 shadowCoord = TransformWorldToShadowCoord(WorldPos);
#endif
Light mainLight = GetMainLight(shadowCoord);
Direction = mainLight.direction;
Color = mainLight.color;
DistanceAtten = mainLight.distanceAttenuation;
ShadowAtten = mainLight.shadowAttenuation;
#endif
}

后记

  现在感觉游戏开发的未来方向就是连连看了,从这点来说UE4的确算是时代前沿。在编辑器里加入逻辑控制元素,让更多人能加入创作,尽可能地解放生产力,的确是游戏开发所需要的。

Unity骨骼动画的总结

Posted on 2020-02-01 | In Teach | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  恰逢假期,在家继续推进Demo,骨骼动画相关的调研算是告一段落了,遂以本文记录相关要点。
  首先要明确一点,本文所说的骨骼动画皆是3D模型的骨骼动画,与2D精灵的骨骼动画无关,虽然原理大致相通。

网格、骨骼、绑定

  了解过3D相关知识的都知道,模型(Model)是由一个个三角形组成的,而这种三角形的学名则是网格(Mesh)。当然在DCC软件里为了方便创作,会用2个三角形组成四边形作为网格:
0
  然后便是骨骼(Skeleton)了,它是驱动模型运动的根本,如下图所示,这是一种彼此之间有父子关系连接在一起的长条状玩意:
1
  最后便是让模型跟着骨骼一起运动了,这个骨肉融合的过程称为绑定,具体要做的事便是将某节骨骼与相关的网格建立关系:
2
  如上图所示,模型上有着不同的颜色,这表示该节骨骼所影响到的网格权重值(蓝色为0,红色为1),所以绑定也俗称“刷权重”。权重值越高,该节骨骼对相应网格的影响便越大(存在多节骨骼对相同网格存在影响,此时便要通过权重值来决定优先级了)。
  随着时代的发展,现代DCC软件基本配备自动刷权重的功能了。做好模型部件的划分(每个部件拥有独立的骨骼,通过各骨骼之间建立关系来联系模型),减少每个模型的权重复杂度,如此通过自动刷权重基本可以应对一般情况了。

模型与动画

  生成给Unity使用的模型与动画我选择FBX格式,毕竟这算是最流行的3D格式了。对于动画,我选择一个动画一个FBX文件的形式(业界也有全部做到一个文件里,在Unity内分割的行为),动画FBX文件里只有骨骼与动画信息,不含模型。
  说到这里,便有一个绕不开的点:多个模型复用相同的动画,这里涉及到Unity里的两种骨骼动画模式:Generic与Humanoid。
  Generic如其名般:一般的动画,在这种动画模式下实现复用的思想很朴素:只要模型的骨骼与动画的骨骼要素相同,那么复用便是水到渠成的事了。这也表示必须同类模型与动画的骨骼结构是一致的。这也表示难以使用外界的第三方资源,对于一些以拼凑、同人、大乱斗为特色的民间项目,或是想直接使用某游戏提取出来的动画,那便捉急了。
  Humanoid则不然,这是一种专为人形设计的动画模式。如下图所示,它定义了人体通用的若干个关节点,将模型对应的骨骼填进去即可:
avatar
  事实上Humanoid便是做了一层中间层转换,让各自的人形模型的骨骼信息统一抽象为上图这套体系,并且还做了关节运动幅度的可控,如此便可实现复用了。且Unity实现了自动识别填充,使用起来还算方便。
  但事实上我放弃了这种做法,选择了Generic模式。原因如下:

  • Humanoid模式为了兼容不同体型下的情况,禁用了具有缩放行为的骨骼动画。
  • Humanoid模式只是为人形考虑,但实际上需要动画复用不只是人形。
  • 由于Demo模型选用的是小泥人,并没多少合适的第三方动画选择(已尝试过)。
  • 哪怕是人形,事实上也会有一些Humanoid无法顾及到的部件(如头发)。
  • 既然不用第三方动画,那么骨骼结构的稳定性自然有保证。

  以上原因不是说用Humanoid模式就完全无法解决,But simple is good,Generic就完事了。

Animator: 切换、分层、混合树

  时值2020年,Animator自然是动画组件的不二之选了。如下图所示般,构建动画状态机,实现一个有机的动画播放环境:
animator
  首先要注意的是,不推荐将动画状态机当成单位业务的状态机使用,虽然动画状态机有提供挂载脚本的形式,但事实上单位的状态并不是与某个动画绑死的,应该由状态去播放动画,而非是动画下绑定专门的业务。让动画的归动画,状态的归状态吧!
  在动画切换控制方面,我使用了Animator自带的变量机制(Parameters),结合条件切换、动画状态脚本、代码控制等方式,实现动画的高可控切换。
  如前文提到的动画复用问题,实际上哪怕骨骼主体相同,但仍会有各自的特殊部件,这时候便要用上Animator的分层机制(Layers)了。通过定义多个不同的层次,在层次中使用Avatar Mask确定影响的骨骼部位,在层次中定义各个动画状态下对应的部件动画。可以为层次定义独立的动画状态机体系,也可以在设置中选中Sync启用以主体层为准的体系。个人更推荐后者,除非与本体动画无关。
  最后是业界不少人士喜欢用的混合树(Blend Trees),分为多种类型(不同的维度乃至于机制),原理为定义若干个动画,确定每个动画在变量组合的特定值下权重最大(播放优先级最高),如此通过操纵变量即可灵活混合相关动画(每个动画的元素都有一定的权重,最终混合成独特的动画)。本人暂时还没用到,主要是追求更明确的动画,而非那种融合的感觉。

补间与帧动画

  骨骼动画的本质,便是在不同的时间点为某节骨骼定义了特定的位置、缩放、旋转。动画的运作便是根据两个时间点之间的骨骼数据做数值变化,这种行为称之为补间(Tweens),同理骨骼动画也就是一种补间动画。与补间动画相对应的概念是帧动画,帧动画只会在特定的时间点发生变化,时间点之间的运动途中是不变的,在许多经典的2D游戏动画便是这种做法。
  为何我会提及到这点呢?一个很明显的区别:较之帧动画,补间动画显得实在是太流畅了,毕竟理论上游戏运作的每一帧它都在改变。但是流畅不是很好么?这一直是电子游戏的追求才对啊!在大多数情况下也许没错,但有时太过流畅,反而会失去「力量感」,说的再通俗点,就是没2D游戏内味了。我想这也是不少一般3D动作游戏做的不好的一点。
  这种现象在日本动画业界运用3D时早有发现:由于2D手绘帧的标准是24帧每秒,而如果3D动画按照视频播放帧进行输出,就会显得两者仿佛根本不在一个世界般。故后来都选择了按照2D手绘帧的帧率进行抽帧,以此达到同步。当然哪怕如此,3D动画也是无法还原出2D手绘帧那股味的,一者在于手绘帧的每帧内容都是人为创作的,具有独特的节奏感。另者在于2D手绘帧的运动帧为了表达动感,往往会画成糊成一团的样子:
3
  这种效果在3D动画基本上是难以实现的,所以往往会盖一层特效解决:
4
  当然《塞尔达传说:荒野之息》对此的处理已经算是上乘了,特意选择了与武器本体颜色相近的特效,在形状上也与2D运动帧接近。个人认为可以在此基础上为武器加入短时间内夸张的形变,使之更有张力。这在《守望先锋》里也有相关运用,留待后日实践验证了。
  言归正传,鉴于补间动画过于流畅的特性,为此我也类似日本动画业界的做法一般,按照24帧每秒的形式对动画播放进行了抽帧。实现思想也很简单:平时将Animator暂停,使用一个定时器,在特定时间点让Animator一次性把暂停的时间差更新补上。如下图对照所示(图1抽帧,图2没有):
5
6
  当然这种方式并不完美:真正的2D帧动画每一帧的持续时间都是人为确定的,而这样只是粗暴的抽帧罢了。当然人为确定帧时间的方式势必带来更高的人力成本,具体如何仍需取舍。

后记

  关于「补间与帧动画」一节纯属个人看法,在正统3D派看来也许属于邪道也说不定(笑。骨骼动画涉及的相关种种实际远不止如此,如换装、部位组合动画等,限于篇幅,就不展开了。

Unity光照模式的总结

Posted on 2020-01-12 | In Teach | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  近日在琢磨Demo应该选择怎样的光照模式,遂做了个试验:对比在同一场合下,各种模式的情况。故以此文记录之(版本为2019.2、平台为Standalone、渲染管线为Builtin)。

环境光

  环境光(Ambient)严格来说并不是一种光照,它只是单纯的为所有显示元素上色罢了。可以理解为2D游戏便是有个(255, 255, 255)环境光。可于(Window → Rendering → Lighting Settings)下的Environment Lighting进行设置。
0
  环境光是无论如何都需要的,一般用于决定画面的底色。下图便是用了白色的效果:
1
  看到上图便能理解我说的「环境光严格来说并不是一种光照」,毕竟连影子都没有,整个画面显得很单薄。但事实上在早期、以及现在一些不依赖光影的游戏是有这种做法的。它们一般会采用类似2D游戏的做法,在素材层面解决各种显示效果问题。对于不依赖光影、强调美术的绝对控制的游戏,使用纯环境光是个方案。
  顺带一提,在Environment Lighting设置下的Gradient与Skybox模式有着不一样的效果,属于更高级的环境光实现。

实时光

  实时光(Realtime)顾名思义,就是每时每刻都在进行的光照。在Light组件的Mode属性设置为Realtime即是。实时光的优缺点很明显,如下:

  • 优点
    • 游戏时可随时改变光照的状态,即刻产生反应
    • 随取随用,无需烘焙
    • 光照效果最好
  • 缺点
    • 在正向渲染(Forward Rendering)下,画面同时出现多个光照时,开销较大
    • 为了节能,某些设备、设置下,光照的数量有限

  实时光一般就是开箱即用到的光照,效果如下:
2
  可以看得出,各项消耗指标都比纯环境光要高,而该场景只有三项光照(平行光1个、聚光灯2个)。故一般游戏都不会如此奢侈,会采用各种手段来达到相同的效果。
  而以上却还不是效果的极致,还差个全局光照(Global Illumination)呢。刚才所见的光照只是「直接的光照」罢了,它只会考虑到照到了谁便处理谁,没有从全局的角度去考虑。在开启全局光照后,除了直接光照之外,还会产生物件之间相互反射的间接光照。效果如下:
3
  从画面效果来看变得更为深邃了,墙壁与地板都有了反射后的光渍,而各项指标实际上与局部并无不同(疑似)。使用它的前提是要在上文的Lighting Settings下开启Realtime Global Illumination,并且为静态物件做好Static标记。具体实现细节请自行查阅官方文档,在此不表。
  当然,这并不代表全局光照优于局部光照,就比如有些游戏的画面风格并不喜欢那些全局光照带来的光渍。还是要看想要怎样的美术效果。

烘焙光

  烘焙光(Baked)可谓实时光的反面:根据光照信息预先渲染成贴图,最后盖到场景上。这个「根据光照信息预先渲染成贴图」的过程,是为烘焙。而烘焙的类型、算法、设置有着多样化的选择,直接影响烘焙的时长、效果、贴图大小与数量。也因烘焙的特性,只适用于静态物件(标记为Static的对象)。优缺点如下:

  • 优点
    • 部分渲染元素(取决于烘焙类型)没有实时运行的开销
    • 属于全局光照,拥有间接光
  • 缺点
    • 光照属性不能运行时修改
    • 动态物件不受影响
    • 烘焙耗时

  烘焙类型主要分三种,效果如下:
4Subtractive: 全烘焙
5Shadowmask: 烘焙阴影与间接光
6Backed Indirect: 只烘焙间接光

  可以看到效果是一个比一个好,但性能却是一个比一个耗。并且可以看出,由于烘焙设置的问题,效果是不如实时光的。而通过设置达到最优的话,烘焙时长则又是个问题了,鱼和熊掌不可兼得啊(砸钱便能我全都要)。
  对于Subtractive,只需把Light组件的Mode属性设置为Baked即可。对于其余两种,实际上是一种实时光+烘焙光的混合方案,则需设置为Mixed。由于动态对象不受烘焙光影响的特性,Subtractive下的胶囊仔直接跟纯环境光时一个样了。解决方案有很多种,如采用Mixed方案(静态物件烘焙光、动态物件实时光)、Light Probes等。
  顺带一提,关于Shadowmask,在阴影设置中可调为Distance Shadowmask。如此将取决于阴影距离的设置,在阴影距离内的阴影,将采用实时阴影,距离之外的则是烘焙阴影。也算是一种提升品质的方式吧。
  烘焙光在业界的应用相当广泛,其中Subtractive式烘焙在早期游戏与现代手游可谓家常便饭,妥善使用Light Probes也能达到不俗的效果。

后记

  以上只是本人粗略的实验与记录,实际上光照的内容浩如烟海,远非本篇所能涵盖。在光照方面本人也只能算是初学者,有所不对还请海涵,并欢迎指教。

2019年度总结

Posted on 2019-12-31 | In Talk | | Visitors

  与前两年不同,今年可没有“不知不觉中,一年又过去了”的感觉。也许是上班了之后,对日子有了概念吧(毕竟元旦放假,笑)。同时与前两年相同,特作总结,以为归纳。
  今年的Blog内容只有寥寥6篇,且全是DFQ相关。盖因10月之前都在紧张的开发中,实是无暇整点别的,希望明年有所不同吧。
  若论今年最大的成就,自然是DFQ的完成了。相关事宜也在后记有所总结。算是这些年来DNF同人的一张答卷吧。
  除此之外,自然是利用这些年的积累,成功的找到了一份符合预期的工作。虽称不上惊艳,但也不差。在找工作期间也受到了不少业界同仁朋友的帮助,在此感谢。工作之后的最大好处便是生活作息规律了不少,至少不存在通宵了。其次是袋里也算有俩破钱,吃点买点都还凑合。在工作上也见识到不少业界才有的东西,并且深刻感受到了屎山、祖传代码、各种对接联调需求扯皮的滋味。也逐渐理解业界不少游戏明明肉眼可见的可调优项,却事实上没去改的现实。
  主要原因在于,团队人数越多,意志的贯彻性就会越差。并且由于人多,野心也就愈大,需要做的杂七杂八也就愈多,不类独立游戏会专注于某项去做好。并且由于团队传承的关系,不免会存在换皮、祖传、屎山代码的现象。亦有可能熟悉这套玩意的核心成员已经不在而导致整体水平下滑,且由于规划、时间问题,没有成本去重构、推翻重做,这也是很无奈的事实。故工作后我最大的两点感受是:

  • 自己能白手写出某套东西并不算什么,能基于别人的东西去改出来那才是本事。
  • 团队人愈多,工作流程(工具链、生产线、规章制度)就愈重要,这决定了团队生产力释放的多寡。并且人类之间的信息交接力其实很差,人多就不免要付出对接成本,这也是要通过工作流程去尽量减少的成本之一。

  说完工作的事,介于「生命不息,整活不止」的原则,新的整活计划自然如火如荼的进行中。如去年所言,需要在3D游戏开发方面发力。结果今年都在忙DFQ,有点惭愧。以上便是本人的2019年度总结,且待明年的Blog吧。
  无双草泥马
  2019.12.31

《DFQ》开发随录——后记

Posted on 2019-10-17 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  DFQ终究是顺利的开发完成,并在发布之后的短短四日内达到接近十万的下载量。赢得广大玩家的好评,甚者认为比之官方的手游品质更佳。然而这一切恍若黄粱一梦,于昨日晚上收到了来自腾讯的律师函,要求删除相关下载渠道与宣传内容。
  取得这等成绩自是喜人,能被腾讯警惕也证明品质确实出色。只可惜游戏尚有一些待完善之处,无法再为玩家提供更新了。在此期间也有不少请求换皮合作的邀请,但本人志不在此。也不想讨论相关太多,就来为这个项目做个总结吧。

程序

  关于制作DFQ的想法,在《阿拉德英雄传》3.0搁置以后便有了模糊的想法。认为要做一款简洁爽快的中小型DNF同人手游,作为我、以及开元的DNF同人最终章。但随着增长了见识以后,便发现自己的程序架构掌控力不足,于是花了不少时间恶补。且前期对于要做一款怎样的游戏并无清晰的概念,遂直到2018年6月才正式开始制作。
  由于DFQ算是弥补3.0搁置的一个执念,于是依旧选择了LÖVE引擎。事实上这是个重大的决策失误。LÖVE关于移动端方面的功能只是刚刚推出,且是冷门引擎,没人帮忙踩坑。于是在制作时便遇到了很多问题,包括相关库的缺少或不合适以至于要造轮子、引擎本身的BUG之类等等。在发布之后更是遇到了诸多问题(NO GAME、全面屏、DPI等),在此奉劝各位,不要用冷门引擎。
  说回程序本身,这次基本上是达到我的要求了,至少工程不再是一塌糊涂、难以协作的状态了。对于各种方面的写法问题也有过深刻的研究(如活动对象采用ECS架构),对于代码格式与写法也有了规范。不足之处在于状态的业务按理来说应该配置化,而非OOP,因其存在大量相似而不同的业务,而这些部分是难以用OOP解决的。以及在OOP的规范上,我引入了private(以_前缀表示),然而会发现很多时候这只会增加不必要的思考负担以及封装成本,这方面该如何处理其实至今我仍未有答案(在业界也是众说纷纭)。
  为了弥补Lua没有强类型与智能提示的缺点,我引入了EmmyLua。所以看过之前的开源工程的会发现代码会有形如---@class XXX的注释,不过这种外挂的形式终究不如原生来的爽快,且缺乏运行前检查的功能。对于代码健壮性的建设尚嫌不足,由此可见TypeScript才是究极的工程化脚本啊。
  在后续测试的图中遇到过一次严重的内存泄漏事故,基本上刷到第三个地下城时便会内存膨胀到难以接受的地步。通过使用LuaMemorySnapshotDump发现,由于我为了减少内存申请,会将一些固定的table类参数写成模块变量,而使用后却没有及时清空,导致游戏对象一直依附其中,无法回收。以及为了优化而做的对象池也是如此,真是汗颜。

美术

  美术方面可以说的不多,首先是由于因需求做了个粒子编辑器,导致对粒子效果的掌控力上升。游戏的粒子特效使用率较之《阿拉德英雄传》有了大幅的提升。
  在人物素材方面,实现了非常强大的纸娃娃,并且为了效率优化,纸娃娃是拼合成一张大图的,缺点在于合成相当耗时,需要在恰当的时机进行(如读图)。并且部分设备支持的图片大小最高为4096,而实际上则出现了超过该大小的图片(此问题至今尚未修复)。
  在优化方面,由于引入了图集与压缩的措施,游戏的加载速度与显存占用是得到了极大的提升。在尚未采取压缩之前,小米9会出现显存带宽爆炸的情况。可以说安卓设备五花八门,令人十分头大。
  UI方面,立绘依旧是小山龙同志的作品,质量较之当年可谓进步明显,原本打算采取live2d的方案,却因工期延宕而搁置了,实是一憾。至于界面本身,可以看出对于手游而言,连及格都称得上是勉强,实是因为一版过兼无经验所致。
  手游UI与端游UI的不同处在于,由于屏幕太小,实际上UI要做的很大才显得正常。并且由于有着触控的需求,按钮也必须大且位置合适。如果为了美术效果而做得小,那也得必须要求附近无其他冲突项,且实际可触控范围要比素材看起来大才行。

游戏性

  DFQ的原初想法实是一款刷刷刷的类暗黑游戏,但本人感觉这年头的游戏都过度强调养成了。遂在砍掉养成的前提下重新审视,定下了三国战纪+Roguelike的主基调。三国战纪的元素在于:角色扮演、街机闯关、拾取道具,Roguelike则是随机性了,可谓独立游戏时下的流行元素,当然大家不约而同选择Roguelike的原因很简单:游戏内容不足,所以要用随机性来增添耐玩性。这么一组合起来,DFQ的样子便很明显了:开局一把刀,一切全靠打,随机地图事件,横版街机闯关。
  但是仅仅如此还是不够的,要是玩家一直用着一成不变的搭配进行游戏,那可就太浪费了。传统游戏对于此的解决方案有:上下级替换、针对性关卡替换,然DFQ没有条件搞上下级替换,针对性替换也未免苛刻。于是抄袭《塞尔达传说:荒野之息》的武器耐久度设定,让武器、道具、技能都具有轮替性。这份轮替性对于玩家而言实是辛辣了,以至于赢得不少吐槽。在后续版本中便为此做出了调整优化,但实是最佳之法。关键在于:玩家需要自己感到可控的设计,而非稀里糊涂的机制。DFQ在不少方面都有类似问题(如最初设想的动态难度),实该检讨。
  在关卡设计上,DFQ是以传统游戏的标准进行设计的,做出了不少较之DNF的突破(如跨房间追击战、迷宫等)。在开发前期犯下一个严重的错误:以端游的标准去做手游,以至于难度过大。手游的操作成本比端游要大,以及更适合轻度的体验。传统的DNF操作方式并不够爽快,遂在此基础上加入了小技能可互相强制、普攻可减少技能冷却的设计,让整个游戏的体验如打了肾上腺素一般。
  除此之外,在做《阿拉德英雄传》多年以来,落下不少错误的思想,如怪物的攻击频率相当鬼畜、没有合适的前后摇等。虽然较之当年,我的ACT设计理解有所提升,引入了反击元素,但仍是不足。在小草泥马的一番军训后,引入了紧张时间与舒适时间的概念,领悟了张弛有度的道理。怪物的设计上会做出提供玩家利用的破绽,让战斗的体验达到了业界平均水平(指国际)。

宣传

  在此感谢轻声低语、瓦伦、夕阳飘羽三位同仁的协助,在DNF百度贴吧、COLG等处得到了极大的曝光,也感谢纷纷去安利、自发宣传的玩家们,以此得到了广泛关注。取得了下载量近十万、B站宣传片十三万播放、贴吧关注量近七千、群人数两千七、COLG帖子47页的成绩。
  另外意想不到的是,以前《阿拉德英雄传》的玩家群体的活跃度也很高,并且开元工作室的名声也比想象中要大(不少人都知道,包括DNF吧的吧务),可见以前做下的成果也是有回报的。
  其实在我看来,比较核心向的游戏,只要你知道受众的聚集地,并且有相关号召力的人士愿意帮忙。在赢得了核心玩家的认可下,宣传效果是非常棒的,哪怕远在韩国的好船同志也收到消息并通关了。这点和业界从广泛人群中捞玩家的买量游戏还是有所不同的。

后记

  其实DFQ的成功,除了本身品质的优秀之外,更在于官方手游迟迟未发,当然最近又有动作了,这也算是撞枪口上了吧。且DFQ属于手游里少有的偏传统的单机类型,在玩腻了抽卡养成流水关卡的玩家看来,自然是十分闪耀的。而我之所以拒绝那些合作换皮的邀请,也在于我并没有想过符合游戏的商业化改造方案,如果按照市面上的那一套进行,结合下一次的天时地利人和,感觉迎来的只会是暴死吧。
  经过这次的验证,可以见到我的ACT理解程度已经进入了新的台阶,然而这还不是我所能做到的极限,若有机会一定会再次整活。当然同人游戏是不会再做了,这次已经是撞倒了天花板,在各方面有其局限性。

《DFQ》开发随录——AI

Posted on 2019-05-31 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  DFQ是PVE(Player VS Environment)游戏,那么自然要有AI了。DFQ的AI实现并不算复杂,并没有用到时下流行的行为树(Behavior Tree)。原因在于不需要做到太精细的操作,且AI部分的业务不需要执行策划参与(不需要编辑器化)。本文将一一叙述其中要点,相信对于同类型的游戏也有一定的参考价值。

宗旨

  AI设计的宗旨只有一个:根据游戏的各种情况,决定进行怎样的操作。这样的思路其实挺仿生的,我们玩游戏的时候本质上也是如此。要注意的是,玩家进行操作的方式是通过外设(键盘鼠标手柄触控等)输入操作信息(按键坐标等)。从这点来说,AI也可以这么做,以进行虚拟的输入操作。这么做的好处是很明显的,如此AI与玩家在功能上达到了一致,AI也不需要与某个具体的功能耦合,只需要关注相应的操作指令,无需关注具体的功能实现。
  而这一切只需要做一套操作模块即可,并且做联机时来自其他玩家的输入亦可如此处理,通过这种方式达到了玩家、AI、联机三者的有机统一。可谓「软件开发中遇到的所有问题,都可以通过增加一层抽象而得以解决」的一次实践。
  当然这也会引入新的问题:某些功能只想AI拥有,那该怎么办?DFQ中的非转向移动便属于这类,解决方法很简单:设计一个玩家无法触发的操作指令即可。

对象

  AI的实体存在就是个类对象(下文称AI对象)而已,它会被外部调用的基础函数只有两个:Update和Tick。Update用于处理持续性的业务,而Tick则是一次性的业务,当然两者可能会有所结合(Tick接收参数,以驱动Update的运作)。
  AI对象主要会存在的场合有:常驻(移动和攻击)、技能(判断是否应发动)、状态(某些状态下需要后续的操作),通过配置化的方式,即可灵活的组合需要的AI了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
return {
script = "base",
state = "attack",
ai = {
script = "battleJudge",
collider = "duelist/goblin/skin/3-attack"
},
attackValues = {
{
damageRate = 1,
isPhysical = true
}
}
}

  如上配置所示,这是个技能的配置。在AI部分选择了battleJudge类,并提供了用于判断范围的collider参数。如此便定下了该技能的AI方针:使用collider判断是否存在敌人,存在则按下技能对应的按键,以发动技能。

实现

  说完基本构成后,再来说说一些具体AI业务的实现吧。

  • 移动:移动AI的核心构成有三
    • 获取目标:遍历符合条件的对象,涉及到阵营等因素。
    • 寻路:以目标为终点展开的寻路,由于DFQ使用的是网格地图,所以使用A星之类的寻路算法即可。
    • 输入操作:获取到移动路径后,通过发出输入指令以驱动角色以之移动。要注意的是,这种方式不可能做到完全贴合路径,所以出现了超过了路径点的情况也不会作处理。
  • 攻击:攻击AI要做的事情很单纯,遍历技能以Tick它们的AI对象进行发动而已。要注意的是,技能的使用顺序要建立优先级进行排序。
  • 判定:这个判定,便是上文的battleJudge了,通过collider以判断目标是否存在。这里的collider便是先前打击感所言的立体矩形,如下图所示:
    collider

  以上便是DFQ里值得一提的AI业务。顺带一提的是,AI是典型的不需要立即生效的业务,所以可以考虑每帧只执行一个单位的AI业务,以此减缓性能压力,并且避免敌人一窝蜂展开攻击的现象。

后记

  在本文开篇时,输入AI二字的我其实有点恍惚。短短数年,AI几乎成了深度学习/机器学习的代名词了。在游戏领域一个理所当然的缩写反倒让我踌躇了一瞬,真是唏嘘啊。也许以后的游戏AI真的都成了基于强化学习的实现也说不定呢(笑。

123…6
Musoucrow

Musoucrow

53 posts
7 categories
37 tags
RSS
Coding Github
© 2022 Musoucrow
Powered by Hexo
Theme - NexT.Mist