今年发生了很多事情,博客也因此从七月停更到了现在,实在惭愧…现在趁着年终,赶紧抓住 2019 年的尾巴了,来总结下我的这一年。

本文真的会很啰嗦,但是希望能帮到希望用 Unity 恰饭或者其他技术恰饭的同学。

毕业

今年的上半年完成了研究生的学业,结束了留学生涯。

我的专业课程比较松,总共两年要修 16 节课的学分,但是必修课只有四节,因此我可以尽可能的选择有实战的课程。这边课程的大作业大多需要团队协作,但是有些 IT 研究生同学是跨专业过来的,不是很能写代码,所以有时候挺考验自身能力的(笑。 我组完队一般都希望能把大作业的规模做大些,一方面是作业能拿比较好的分数,另一方面是求职的时候可以拿出能往简历上放的项目经验。

毕业后我发现这种选择是对的,我的队友在毕业后还问我们的线上项目怎么开不起来了,他们也到了找工作的时间,还让我帮忙看了看简历。澳洲工作是很休闲的,大部分下午五六点就能下班。身边同学也在努力地留下来,考 PTE、CCL 考试凑分拿 PR。自己因为还是想做游戏,澳洲环境不太好,就回了国。

从面试到工作

由于课程结束三个月后才能参加毕业典礼拿到毕业证,当时我对求职还不太上心,还想等着春招。但是又不想在家里混吃混喝,就开始每天刷刷面试题,学学感兴趣的,同时也开始在某直聘找工作,打算每周面试一次,接触下当前的就业形势,同时查漏补缺。

第一周

第一周面试了家一百多人的游戏公司,一上来要求三十分钟解一道 Leetcode hard 的题…好不容易解出来了,又要求递归改迭代,又问有没有能优化的点。之后还问了些逻辑题,这时候挺庆幸自己复习了《程序员面试经典(第5版)》第六版刚出噢),基本还能 hold 住场面,但是后来分析算法的时间复杂度分析的十分糟糕,于是就没有然后了…

当天十分沮丧,恰巧面试的地方和一个主美朋友工作的地方很接近,就约了个饭。他开导我说:”拿美术来说,不同公司也会需要不同的美术:古风游戏自然需求专精画古风的,科幻游戏需求的美术风格很明显也不一样。再面试几家就好,今天面试只代表公司不适合你。“我听了很有道理!于是继续不务正业学了喜欢的东西,简单复习复习算法,刷了刷题。

第二周

第二周又接到另一个游戏公司 HR 的面试邀请,面试时直接来了三个面试官,两个程序大佬一个制作人。很明显的,面试风格都不一样。他们事先看了我的简历,看了我的博客。刚好第一周的时候更新了一篇 DOTS 的博文,于是他们一开始就让我介绍下 Unity 的 DOTS 技术栈是什么,还有一些概念细节。后来的其他问题很明显能感觉他们在考察我知识的广度,例如图形学,我简历上提到的 C# 热更新等。

刚好那段时间”不务正业“地跟着《自己动手实现Lua》写了一半的 Lua 虚拟机,于是问到对 Lua 是否熟悉的时候,我就提了一嘴最近在学的东西,接着又展开新的问答。整个过程中,我觉得面试官的风格和第一周公司的面试风格完全不一样,但是有些地方还是答得不够好,于是又在家瞎学。

一周后,我拿到第二家公司的 Offer,成为了公司工具人。

我觉得从面试就能看出公司关注的是开发人员的哪些方面,如主美朋友所说,如果不愿意改变自己学习的风格,那就找到需求这种风格的公司,接下来的工作也印证了这一点。

上面提到的内容

了解代码的另一面

入职后,才发现公司写了一套自己的 C# 热更新,这种热更新是和 xLua 一样的注入式热更,跟 ET 框架分两个项目跑的还不一样(下文会解释)。有意思的是,在我入职过了几个月后,xLua 作者也开源了C# 注入式的热更新项目:InjectFix,作者还配套写了一套 IL 的运行时,听说性能还比 ILRuntime 更好些。

感兴趣的可以先看看 xLua 作者的讲解:

先前基于 ILRuntime 的热更新,例如 ET 框架,大多是分两个项目,主项目和热更项目,热更项目放一些需要经常修改的战斗逻辑、UI 界面等。这样可以直接把热更项目都放在 ILRuntime 上跑,整个项目都能热更,十分方便,但是这样十分依赖 ILRuntime 的性能。

那么注入式的热更有什么区别呢?我们给每个函数前加 if 判断,如果 ILRuntime 上跑的(能热更的)DLL里面有对应的方法,就执行热更的方法,这样 ILRuntime 的性能问题也能避免开来,因为我们可能只有需要热更的函数在 ILRuntime 上面跑,而不是整个项目。

那么,古尔丹,代价是什么呢?
——格罗玛什·地狱咆哮

代价就是能热更的东西极其局限,只能热更函数、和新增的类等。

在了解原因之前,我们先来看看例子,假设我们游戏就这么多代码:

1
2
3
4
5
6
7
8
9
10
// Unity 2019.2 之前,Scripting Runtime Version: .Net 3.5 Equivalent(Deprecated)
public class TestIL : MonoBehaviour
{
void Start()
{
int[] arr = {1, 2, 3, 4};
Action action = () => Debug.Log("Hello IL");
action();
}
}

上面是看上去如古天乐平平无奇的代码,当我们用 dnSpy 反编译 Library\ScriptAssemblies\Assembly-CSharp.dll 后会发现:

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
public class TestIL : MonoBehaviour
{
...
private void Start()
{
int[] array = new int[]
{
1, 2, 3, 4
};
Action action = delegate()
{
Debug.Log("Hello IL");
};
action();
}

// 编译器生成的匿名函数
[CompilerGenerated]
private static void <Start>m__0()
{
Debug.Log("Hello IL");
}

[CompilerGenerated]
private static Action <>f__am$cache0;
}

编译器为我们的 Action 生成了匿名函数,那也就是说如果我需要更改 Debug.Log 中打印的字符串,我只需在热更 DLL 中提供:修改后的函数 + 编译器生成的匿名函数就 okay 了?实际上没那么简单,因为编译器又作妖了。

1
2
3
4
5
6
void Start()
{
int[] arr = {1, 2, 3, 4};
Action action = () => Debug.Log("Hello " + arr[0]); // 修改打印
action();
}

再次查看反编译后的 DLL:

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
private void Start()
{
int[] arr = new int[]
{
1, 2, 3, 4
};
Action action = delegate()
{
Debug.Log("Hello " + arr[0]);
};
action();
}

[CompilerGenerated]
private sealed class <Start>c__AnonStorey0
{
public <Start>c__AnonStorey0()
{
}

internal void <>m__0()
{
Debug.Log("Hello " + this.arr[0]);
}

internal int[] arr;
}

由于 action 中引用了局部变量,mono 编译器将本该生成的匿名方法生成了匿名类,并在调用的时候传入 arr int 数组。

现在我们调整下我们的热更新策略:如果我们检测到编译器生成的匿名函数,将其转换成匿名类,再把这个新增的类复制到热更 DLL 中。

还是有问题!

这时候就需要认识 C# 的中间语言—— MSIL(又称 IL),每句 C# 代码都可以转换成可读性较好类似于机器代码的 IL 代码。

当我们查看 Start 函数的 IL 代码时:

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
.method private hidebysig 
instance void Start () cil managed
{
.maxstack 4
.locals init (
[0] class TestIL/'<Start>c__AnonStorey0',
[1] class [System.Core]System.Action
)

IL_0000: newobj instance void TestIL/'<Start>c__AnonStorey0'::.ctor()
IL_0005: stloc.0
IL_0006: nop
IL_0007: ldloc.0
IL_0008: ldc.i4.4
IL_0009: newarr [mscorlib]System.Int32
IL_000E: dup // 下面这串是什么?怎么又引用了另外一个类?
IL_000F: ldtoken field valuetype '<PrivateImplementationDetails>'/'$ArrayType=16' '<PrivateImplementationDetails>'::'$field-1456763F890A84558F99AFA687C36B9037697848'
IL_0014: call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
IL_0019: stfld int32[] TestIL/'<Start>c__AnonStorey0'::arr
IL_001E: ldloc.0
IL_001F: ldftn instance void TestIL/'<Start>c__AnonStorey0'::'<>m__0'()
IL_0025: newobj instance void [System.Core]System.Action::.ctor(object, native int)
IL_002A: stloc.1
IL_002B: ldloc.1
IL_002C: callvirt instance void [System.Core]System.Action::Invoke()
IL_0031: ret
} // end of method TestIL::Start

在 dnSpy 中找了找,发现了 PrivateImplementationDetails 类:

dnspy

这看起来应该是这个数组被存到了某个地方,这个类只是提供了 id 告诉 IL 这个数组应该在哪找?通过查询 Roslyn 编译器的文档,发现了这个类的注释:”The main purpose of this class so far is to contain mapped fields and their types.“ 所以我们要热更的话,还需要将 PrivateImplementationDetails 类复制到热更 DLL 中。

我们怎么分析代码是否是匿名函数呢?Mono.Cecil 就是一套基于 IL 分析程序集的库,我们可以通过这个库来判断哪些方法不能热更等,这又是另外一个话题,略过不提。

以上只是注入热更的一个小插曲,但是涉及的东西就已经与一开始 Start 方法中的三行代码相去甚远了。如果我们还要支持重载函数的热更,泛型类中函数的热更,就更是让人掉头发的话题,涉及的 IL 十分复杂。

现代的高级语言为我们封装了太多东西,提供了方便编程的语法糖,但也为我们知根知底的学习方式设立了门槛。

但是当我们了解了 IL 中间语言的话,我们以后面对”在匿名函数引用 for 循环中变量的行为会诡异“等问题的时候,我们可以直接反编译 DLL 来看代码真正的面目是怎么样的。

不小心写了足以充当一篇文章的内容,但是我想表达的是:

  • 对于游戏开发者,我们有必要对自己的代码做了什么有充分的了解,谨慎运用语法糖,这样才能充分掌握游戏的性能。
  • 虽说我们远远没达到造 InjectFix 轮子的程度,但是了解该技术的根基——IL,再尝试根据其他文档来分析,能让我们更好的了解这个框架的背后,了解这种热更新的优缺点。

上面提到的内容

  • InjectFix C# 热更新
  • dnSpy .Net 反编译编辑器
  • Mono.Cecil 基于 IL 分析程序集的库,dnSpy 也是基于这个库来分析的。

自顶向下,再自底向上

游戏开发很多技术都倚重于硬件的能力,因此我们有必要对这些硬件的实现和原理有所了解。但这方面也是我的弱点,我个人喜欢按照兴趣来学,因此我的总结的方法是:自顶向下,再自底向上

就如上面热更新的例子一般,自顶向下就是揭开抽象的面纱,从感兴趣的框架或库的应用入手,逐步通过各种方式来了解底层的原理。

拿 DOTS 技术栈做例子,ECS 的编程模式保证数据线性地排列在内存中,恰好能内存 cache 缓存命中率,从而得到更高效的数据读取。更多细枝末节可以先放一旁,例如 Shared components 这种与理念不相符的共享组件是怎么实现的。

知道了内存 cache 缓存命中率在其中发挥巨大作用后,我刷知乎还发现,Disruptor 库为了对齐 128 个字节方便 CPU cache line 用,选择浪费空间提高缓冲命中率。

到底内存的结构是怎么样的?缓存命中率又是什么?字节对齐又是什么?为什么这样的做法能提高性能?带着种种疑问,我们开始自底向上地了解内存结构。

从感兴趣的应用入手,了解硬件底层,这样不至于太枯燥。学到一定程度,当我们把知识网中重要的几个概念都了解之后,我们可以再阅读相关操作系统的书,将周边的知识点与之前比较大的概念相连接,互相印证,从而结成结实的知识图谱。

一开始我想重新捡起《编码:隐匿在计算机软硬件背后的语言》这本书,这本书从手电筒聊天开始,依次讲了摩斯电码、二进制、再到继电器电路、组合成 CPU 等等,能解答我的疑惑,但是后来发现节奏偏慢。于是又看了 Crash Course Computer Science(计算机科学速成课) 中讲 CPU 的一集,觉得节奏非常好,通过 12 分钟视频讲解除法电路、缓存、流水线设计、并行等概念,揭开计算机一层一层的抽象,我认为这是自底向上最好的教材。另外在知乎刷到一篇文章:《带你深入理解内存对齐最底层原理》也是很好的佐料。

了解了硬件原理之后,这种优化能带到实践中吗?一维数组的顺序访问比逆序访问要快,那么二维数组呢?

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
public class TestCache : MonoBehaviour
{
private const int LINE = 10240;
private const int COLUMN = 10240;

void Start()
{
int[,] array = new int[LINE, COLUMN];
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < LINE; i++) // 344 ms
{
for (int j = 0; j < COLUMN; j++)
{
array[i, j] = i + j; // 一行一行访问
}
}

stopwatch.Stop();
Debug.Log("用时1:" + stopwatch.ElapsedMilliseconds + " ms");
stopwatch.Restart();
for (int i = 0; i < LINE; i++) // 1274 ms
{
for (int j = 0; j < COLUMN; j++)
{
array[j, i] = i + j; // 一列一列访问
}
}

stopwatch.Stop();
Debug.Log("用时2:" + stopwatch.ElapsedMilliseconds + " ms");
}
}

一行一行访问和一列一列访问的效率很明显不一样,自此,我们自底向上地把对硬件的了解反映到了实际编码中。

由于自己就是兴趣导向的学习,所以学东西难免有所偏科,但是我认为只要感兴趣的够多,就不怕偏科(笑。每个人有自己学习的节奏,在这里只是提供一种思路。

上面提到的内容

找到适合自己的学习方式

说起来容易,但是要运用之前,我首先需要知道学习这种知识的途径有哪些,才能从中选择最适合自己的学习方式。

拿 Unity 中的渲染来说,我能通过相关官方文档来学,能通过看博文来了解,也能通过 Direct3D、OpenGL 相关教程来学,或者重新拿起图形学的书本,因为都会涉及到渲染流水线的知识。

那么怎么样的才算是适合自己的学习方式呢?有的同学可能喜欢先把理论吃一遍再来实战,我喜欢通过动手来了解。我尝试跟着 LearnOpenGL 教程和一些 Shader 教程来学习渲染管线的知识,也初识 Batch(批次)、GPU Instancing 等名词,但是不同教程都有不同侧重:Shader 教程可能着重教你如何实现各种效果,而图形学课程可能对这些名词的背后实现语焉不详。

之后一直压着疑惑使用着这些技术,我后来又发现了一些选择:要不自己写一套软渲染器?又或者我可以通过 Unity 新出的 Scriptable Render Pipeline(可编程渲染管线)来自定义一套自己的渲染管线?这似乎已经很靠近我想学的东西了,再考虑自己的时间和兴趣,我决定跟着 catlikecoding 的 SRP 教程写一个可编程渲染管线,尝试以一种较底层的角度来了解渲染管线相关的实现。

上面提到的内容

啰嗦了这么多,其实只是想分享下我学习的经验:正所谓曲高和寡,越是进阶的知识点,教程风格越五花八门,当然也会越少人写。每个人有自己的学习风格,尽量多拓展自己的知识来源,不要将自己限制在国内教程和书籍中。

如何扩展知识来源呢?

有了选择,就可以根据自己兴趣爱好来跟着学习,想办法把他们学到脑子里!

先写,再优化

工作后的这段时间我一直在思考,我距离独立造轮子还差多少能力。先写框架划分模块?还是先实现功能?如何写出高内聚低耦合的代码?抛去代码可读性和模块的划分不谈,写出一个轮子最基本的功能对于我可能都是一个难题。

我的工位在写出热更新的大佬的旁边,一开始大佬说招我的原因,是希望我能接受他的热更新框架,继续维护。可惜自己能力不够,看懂了实现,但是却无从下手,之后也就不了了之。不过有段时间,我问大佬问题问到什么程度呢?我一转头瞄下他,他就会划着电脑椅过来等我提问…

大佬在工作室中负责战斗以外的技术攻坚,有需求的话就会主动去学,去实现,C# 热更新框架就是他的作品。后来团队觉得可能要通过帧同步来防外挂,他又开始看相关的论文,文章来从头写,虽然到现在团队还没用上。耳濡目染之下,我也会跟着大佬去看帧同步相关的内容,有时候当当小黄鸭,帮他查漏补缺。

对于造轮子,有需求,有思路,就先开始写!这是我这段时间从大佬身上学到的一点。例如他认为我们 UI 的数据不好管理,于是参考了《Flux 架构》和其他文章,准备将这种理念与 Unity 结合,于是他又开新坑了。

他给我的评价是:知识面较广,好学,就是不肯开始写

说的没错,太多考虑反而会束手束脚,适得其反。先把功能写出来,设计模式按照经验来划分,能用了再优化,这是功利的做法,但也是能让人顾虑少地造轮子的做法。

新的开始

工作的节奏和上学完全不一样,虽说是 9.5 6.5 5,但是两个小时的通勤仍然让我措手不及。公司下班吃完饭,回到家九点,随便看点啥就十一点了,早上还得八点多起…

daxiong

最后尝试更换了出行方式,从地铁改成坐巴士上班,这样有位置坐,才能静下心看看书。尽量把阅读安排在通勤时间上,这样才能勉强保持学的进度。

工作方面压力也还行,平常有时间的话能关注下业务逻辑以外的东西。例如有一次运营拿来一个我们游戏的脚本样本,我花了点时间解包,学了学反编译,再让负责战斗的大佬针对性地做了点预防。这个过程对我而言也是新的经验,其中应对 iOS 外挂时借鉴了图灵的《九阴真经:iOS黑客攻防秘籍》的思路。

前不久转了正,算是给学生时代交了份答卷。随着见识的增长,自己对博文和代码又有了更高的要求。见识了“好”的文章后,自己怎么写才能组织好文章,才能更好地讲述一些知识点?

我们需要放下书本,去实践,去体验,去观察,去琢磨,去尝试,去创造,去设计,去stay hungry, stay foolish。
号称终极快速学习法的费曼技巧,究竟是什么样的学习方法?

学习、实践、总结、再清楚地解说一件事,并将其写成博文,这是我对我下一阶段的要求。其中学习总结的速度也得跟上,我已经看到有读者抱怨我博客断更了……

今年读过的书:

明年想更深入多线程、内存布局、和渲染相关的话题。

加油吧,感谢 2019 年帮助过我的所有人。