11

2019 年的收获与成长

 3 years ago
source link: http://frankorz.com/2019/12/04/2019-year-end-summary
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

2019 年的收获与成长

2019年12月4日Unity5770 字约 38 分钟

今年发生了很多事情,博客也因此从七月停更到了现在,实在惭愧…现在趁着年终,赶紧抓住 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 年帮助过我的所有人。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK