2019 年的收穫與成長

貓冬發表於2019-12-04

今年發生了很多事情,部落格也因此從七月停更到了現在,實在慚愧...現在趁著年終,趕緊抓住 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 上面跑,而不是整個專案。

那麼,古爾丹,代價是什麼呢?
——格羅瑪什·地獄咆哮

代價就是能熱更的東西極其侷限,只能熱更函式、和新增的類等。

在瞭解原因之前,我們先來看看例子,假設我們遊戲就這麼多程式碼:

// 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 後會發現:

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 了?實際上沒那麼簡單,因為編譯器又作妖了。

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

再次檢視反編譯後的 DLL:

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 程式碼時:

.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 分鐘視訊講解除法電路、快取、流水線設計、並行等概念,揭開計算機一層一層的抽象,我認為這是自底向上最好的教材。另外在知乎刷到一篇文章:《帶你深入理解記憶體對齊最底層原理》也是很好的佐料。

瞭解了硬體原理之後,這種優化能帶到實踐中嗎?一維陣列的順序訪問比逆序訪問要快,那麼二維陣列呢?

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,但是兩個小時的通勤仍然讓我措手不及。公司下班吃完飯,回到家九點,隨便看點啥就十一點了,早上還得八點多起…

怎麼能學習!

最後嘗試更換了出行方式,從地鐵改成坐巴士上班,這樣有位置坐,才能靜下心看看書。儘量把閱讀安排在通勤時間上,這樣才能勉強保持學的進度。

工作方面壓力也還行,平常有時間的話能關注下業務邏輯以外的東西。例如有一次運營拿來一個我們遊戲的指令碼樣本,我花了點時間解包,學了學反編譯,再讓負責戰鬥的大佬針對性地做了點預防。這個過程對我而言也是新的經驗,其中應對 iOS 外掛時借鑑了圖靈的《九陰真經:iOS黑客攻防祕籍》的思路。

前不久轉了正,算是給學生時代交了份答卷。隨著見識的增長,自己對博文和程式碼又有了更高的要求。見識了“好”的文章後,自己怎麼寫才能組織好文章,才能更好地講述一些知識點?

我們需要放下書本,去實踐,去體驗,去觀察,去琢磨,去嘗試,去創造,去設計,去stay hungry, stay foolish。
號稱終極快速學習法的費曼技巧,究竟是什麼樣的學習方法?

學習、實踐、總結、再清楚地解說一件事,並將其寫成博文,這是我對我下一階段的要求。其中學習總結的速度也得跟上,我已經看到有讀者抱怨我部落格斷更了……

今年讀過的書:

明年想更深入多執行緒、記憶體佈局、和渲染相關的話題。

加油吧,感謝 2019 年幫助過我的所有人。

相關文章