UE 手游在 iOS 平台运行时内存占用太高?试试这样着手优化
性能優化,對游戲開發來說是一個需要不斷鉆研的課題,性能越好,游戲才會運行的更加順暢,玩家的體驗感才會更好。騰訊游戲學院專家、游戲客戶端開發 Leonn,將和大家分享 UE 手游在 iOS 平臺上的內存分布和優化。(本文首發于騰訊游戲學院專家團月刊《EXP 手冊》)
文 | Leonn
騰訊游戲學院專家 游戲客戶端開發
對于在 iOS 平臺上運行的 UE 程序,經常會出現內存占用較高,xcode 的內存統計和 UE 的統計有偏差的問題。本文將討論下 iOS 上內存的管理機制,內存的組成,iOS 特有的一些資源管理特性,以及 UE 程序針對 iOS 常見的內存瓶頸和優化。
iOS 程序內存分配原理
1.1 iOS 系統內存分配原理
iOS 也是一個類 linux 的系統,所以基本的內存分配還是走的虛擬內存系統中 page mapping 和 swap out 那套,即虛擬內存訪問缺頁產生對物理內存的實際占用,以及對物理內存的喚入喚出(詳細可以看前面的《Android 內存分布和優化》的文章)。iOS 上的 page size 為 16k,
Swap out 和 compressed memory
關于 swap out 機制,由于移動端內存的壽命問題,沒有實現傳統虛擬內存那樣的 swap in/out 機制,只有一些 read-only 的數據(例如代碼)在被 swap out 的時候,會被直接從物理內存移除,而不會 back up 到磁盤文件上,下次 swap in 就直接重新加載,也就是非 read only 的 page 只要被訪問就永遠不可能喚出物理內存。
?
此外 iOS 還有額外的 memory compress 機制,即將一些不常用的內存壓縮存儲在內存中。
?
所以 iOS 中有個特有的表示內存占用的說法 memory footprint 就是值得壓縮前的總大小,而不是當前的實際物理內存大小。
當系統內存吃緊的時候,系統會通過 didrecievememorywarning 通知程序,讓程序自愿的釋放一些內存,這時候如果程序仍然不能有效降低內存,進程就會被殺掉。
VM Object
在 iOS 內核中,使用一個叫做 VM Object 的對象表示在虛擬內存空間的一塊被映射的內存區域(region),一個 region 是由幾個連續的 page 組成,所以一個 vm object region 的起始地址必定是某個虛擬空間上 page 的起始地址。VM Object 還記錄了其他的一些信息,包括繼承關系,讀寫權限,是否是 wired(能不能被 swap-out)。此外它還關聯了一個 pager,用來做內存映射,這個 pager 是 default pager 或者 vnode pager 的一種, default pager 負責將虛地址 VA 跟物理地址 PA 做映射(即訪問 va 缺頁后將開辟物理內存),vnode pager 直接將文件映射到虛地址空間(這樣不經過內存直接讀寫文件)。
VM_Copy
Vm object 的 pager 除了可以是 default pager 或者 vnode pager 之外,還可以直接映射另外一個 vm object,這時是為了做 copy-on-write 優化。這允許不同的 vm object 映射同一段 page 區域,直到其中一個 vm object 需要發生寫入它才會 copy 出一個新的。在 iOS 系統下我們可以直接調用 vm_copy 代替 memcpy 來執行這種 copy-on-write 的 copy,只要你 copy 后面不寫入,就一直沒有實際的 copy 開銷。Vm_copy 的唯一缺點是如果發生寫入那么 copy 會存在延時,所以對于頻繁的小內存的 copy 還是推薦直接 memcpy。
?
1.2 iOS 系統內存的分配方式
在《Android 內存分布和優化》中我們講了使用 malloc 和 mmap 分配系統內存的原理和區別。這里我們詳細討論下他們在 iOS 上的特點。
vm_allocate
首先 iOS 上做 mmap 的函數是 vm_allocate,它同 Android 上的 mmap 用法相當,即分配一個虛存,做物理內存或者文件映射。
Malloc
直接從堆上分配內存,并且這些內存會立即從虛擬內存映射物理內存,并且不會初始化內存內容。在 iOS 上 malloc 的底層實現細節如下:
小內存:小于幾個 pagesize 的內存,malloc 會從一個 pool 上分配,這個 pool 本身是由 vm_allocate 分配的虛存,這些虛存可能都是已經存在物理內存映射的了,這個池分配的粒度都是按照 16 字節對齊,所以我們用 malloc 也盡量 16 字節對齊,否則就存在了浪費。這個小內存池的預分配的大小有多大要取決于系統策略。
大內存:對于大于幾個 pagesize 的內存,Malloc 自動使用 vm_allocate,它只分配虛存,不立即映射物理內存,分配粒度為 1 個 page 大小(即 16k),因為不同的 vmobject 是由不同的獨立的 page 組成,這時如果 malloc 的大小沒有 16k 對齊,也會產生較多的內存浪費。在這種情況,使用 malloc 和直接使用 vm_allocate 是相當的。
Calloc
calloc 同 malloc 不同的是,它在分配內存后,在使用前會保證將內存初始化為 0。他比用 malloc+memset 要優化,因為 memset 發生時會立即產生缺頁造成物理內存占用,然后初始化 0,而 calloc 則是延遲的,它不會馬上產生物理內存占用,而是要等得到真正這塊內存被使用之前。我們應該完全使用 calloc 代替 malloc+memset。
Malloc zone
iOS 上所有的內存分配都是來自于某個 malloc zone 的,每個 zone 有獨立的內存池,默認的分配都是在 default malloc zone 上的。使用 malloc zone 有個好處是減少小內存池的浪費。我們知道內存池的浪費主要有兩種來源,一種是對齊浪費,即為了匹配內存池的分配粒度,沒有對齊的內存產生的浪費,如 malloc 一個 17 byte 的內存其實需要 malloc 32byte,另一種則來源于對頁的空白浪費,例如在頻繁的分配內存后,會開辟大量的新 page,這樣在后面即使先后發生了一些釋放,但是因為釋放不集中在一個 page 上,也導致了很多 page 上只被少量的 block 占據,導致大量的空白部分的浪費。如果我們可以知道某些內存的生命周期是相同的,那么我們可以把它們在同樣的一個 zone 上分配,這樣我們在確定他們的生命周期全都到期后,可以對整個 zone 執行釋放的操作,這樣就杜絕了這兩種浪費。在 iOS 下使用 malloc zone 的相關接口是:
?
- malloc_create_zone 創建一個 zone
- malloc_zone_malloc 再某個 zone 上分配
- malloc_destroy_zone 釋放整個 zone
UE 程序在 iOS 上的內存組成清單
了解了 iOS 上的基本內存分配原理后,我們來統計我們 iOS 上的 UE 程序的內存組成。在對 UE 程序進行內存分析和優化過程中,我們要做的的第一件事就是獲取一個完整的關于你程序的內存組成清單。UE 的引擎內部提供了 LLM,memreport 等內存統計工具,但是這些只是 UE 能感知到的內存,我們需要能明確整個程序的內存被花到哪里了,以及為什么程序會因內存過高而產生問題。
2.1 iOS 內存組成統計口徑
Memory footprint
Android 上我們一般使用 PSS,即程序(按分攤統計共享庫)分配的實際物理內存大小來定義內存開銷。iOS 有所不同,iOS 上通常使用 memory footprint(下面簡寫為 mem foot)這一個概念來定義內存開銷,mem foot 同實際占用的物理內存之間有一定差別。Mem foot 在 iOS 上的定義是進程實際占用的物理內存+進程被壓縮了的內存在壓縮前的大小,即 mem foot = resident + swapped (這里的 swapped 不是指 swap out 的意思,是前文說到的 iOS 的內存壓縮機制)。所以從定義上看,所謂 mem foot 是指你的進程所可能觸碰到的所有物理內存大小(盡管部分已經被壓縮),這就是腳印的意思。
在 xcode 的 allocater 中,我們可以計算 vm tracker 中 all 中的 resident+swapped 的大小來得到 mem foot 值。如果是在代碼中,則可以通過 darwin 內核的接口 task_info 獲取 TASK_VM_INFO 來獲取其中的 phys_footprint 來獲得,darwin 源碼中關于 phys_footprint 的定義是
?
其中 internal 即除了顯存外的 resident 內存,internal_compressed 即指除了顯存外的 swapped 部分,iokit_mapped 一般就是(其實是不能使用 purgeable memory 的)顯存,后面的 purgeable 是指使用 purgeable memory 中屬性為 nontvolatile 的。關于 purgeable memory 后文再說。
可以說 memery footprint 是 iOS 上統計內存占用的金標準。
XCode Gauge
當我們使用 xcode 運行游戲,會看到一個實時的顯示內存的儀表盤,如下圖
?
這個叫做 xcode memory gauge,它統計的又是什么內存呢,其實它嚴格來說統計的不是 memory footprint,它統計的是 vmtracker 里面 dirty+swapped 的值,那么什么是 dirty 內存呢,dirty 是指實際占用的物理內存(resident)中那些一定不能被 swap out 出去的內存,前面提到 iOS swap out 機制時說,iOS 上只有那些可讀的文件等才能被 swap out,這些能 swap out 的內存通常危害不大,在內存吃緊的時候可以部分被系統調度出物理內存,他們一般是各種文件映射,代碼庫,符號文件等,所以 dirty 才是程序動態分配的需要考慮的內存,xcode gause 統計的是真正用戶能夠決定的內存腳印大小。他要比 mem foot 小一些,小了那些代碼和文件的內存占用。
2.2 iOS 程序主要內存構成
我們以 memory footprint 為統計標準來得到 iOS 的完整內存構成,最正確方便的方法是使用 xcode 的 allocations 工具,里面有個 vm tracker,vm tracker 就是用來跟蹤程序的每個 vmobject 的,即每個虛擬內存區域的分配情況。在 vm tracker 里面做一個 snapshots,就可以得到當前內存分配的一個快照。
?
其中 All 是指總的分類,all 下面是各種細類,右面的 resident dirty swapped 分別指實際物理內存,不能被 swap out 出去的物理內存,以及被壓縮的內存的壓縮前大小,最后面的 virtual size 是虛存大小。我們把 all 中的 resident+swapped 就是總 memory foot print。
下面是占據大頭的幾個細類:
?
- IOKIt 和 IOSurface:通常就是指我們 GPU 需要訪問的內存,即顯存
- Performance tool data:是實際運行沒有的,Profile 工具本身內存。
- Mappedfile:文件映射,用于讀寫的文件,一般不占用很多 dirty
- __LINKEDIT 和 __TEXT:代碼段部分,即代碼段內存,只讀,他們一般不占 dirty
- __DATA:代碼的數據段,包括可讀寫的全局變量等。
Malloc_NANO/TINY/SMALL/LARGE :這就是前面提到的 iOS 的 malloc 小內存池,nano/TINY 指的就是文章最前提到的 malloc zone。雖然默認的 malloc 是在 default zone 上分配的,但是系統還是會根據不同的大小再選擇不同的 zone。對于 0-256B 的 malloc,系統會使用 nano zone,nano zone 比較特殊,它專門為小內存而優化,并且預先就 vm_allocate 了一塊 512M 的虛擬內存空間做這個 nano zone 的 pool,這塊空間處于堆底。對于更大一些的小內存分配,則會根據情況使用到 tiny small large 這三個 zone 的 pool。所以我們推薦大家在 iOS 上對于 256b 以內的內存分配直接走 malloc,而不是 UE 的 malloc,可能會得到更多的收益。
VM_ALLOCATE:這個就是通過 vm_allocate 方法申請虛存后缺頁觸發的內存占用,QQ號碼轉讓平臺在 UE 里面一定是大頭,因為 UE 自己的 Fmalloc 在 iOS 上就是走的 vm_allocate。又因為 UE 的 fmalloc 在做 vm_allocate 的時候傳遞的 tag 是 255,所以在 vmtracker 中,所有體現為 memory tag 255 的 vm 就是 UE 的 fmalloc。
2.3 UE 程序內存組成清單
從 vm tracker 出發,在配合我們在《Android 內存分布和優化》中提到的 UE 的自帶的 LLM 機制,我們就可以構建 UE 程序在 iOS 平臺上的內存完整清單了。它至少應該被分割成以下幾個大部分:
這是我們對于任何一個 UE 程序,可以得到的在 iOS 上的詳細的內存分布情況,這里面有幾個問題需要注意:
實際的總 mem foot 和下面各自項加起來可能是存在一定偏差的。因為 LLm 中各個從 UE Fmalloc 出來的子項的總和其實也只是個 vm_allocate 的虛存大小,它實際上占用的物理內存腳印是要小一些的,另外 LLM 里面對 metal texture 和 buffer 的內存計算也是估計的,但是一般情況不會差別過大,我們只要了解這其中存在差值即可。
2.4 顯存大小的統計
Llm 統計的 metal tex 和 metal buffer 是 UE 統計的 gpu 訪問的資源量,它同實際值是有偏差的,比如 UE 未考慮到使用 purgeable memory,memryless 等資源的內存減少,此外顯存上還有除了 tex 和 buffer 之外的其他資源。所以如果想確定真實的 gpu 資源使用還是要看 IOKIT 的值,只是我們可以用 metal tex 和 buffer 估計下大致的比例。另外在最新版本 xcode 的截幀工具中我們也可以看到一個細節的 tex 和 buffer 的顯存,如下:、
?
它可以顯示詳細的 tex 和 buffer 使用情況,但是內存值是明顯偏大的,因為這里顯示的是虛存值,不是物理內存,所以也只能參考。
UE 程序在 iOS 上的主要瓶頸和優化
我們從上面的清單上找到一些內存的大頭。在一個大型 3D 項目中,內存較大的塊一般集中在在代碼段部分,GPU 訪問內存,Uobject,和 Fmalloc 內存池浪費上。本章節也著重講這幾塊的針對 iOS 的常用優化方法。很多平臺通用的優化方法在文章《Android 內存分布和優化》中已經說到了,這里就不重復,主要將針對 iOS 平臺的優化手段。
3.1 GPU 訪問內存
也可以稱為顯存,顯存的主要組成部分包括 buffer, texture 和 shader。顯存的資源維護在 iOS 上就有一些特有的優化手段。
Purgeable memory
iOS 上的顯存資源 MTLResource(mtlbuffer,mtltexture)使用的都是 purgeable memory。所謂 purgeable memory 是指這種內存有三種 purgeable state,分別為 volatile,none-volatile 和 empty。
Volatile:該內存資源是暫時不被使用的,系統將在內存吃緊的時候回收掉它,使用這種類型資源前要查詢該資源是否已經無效了(變成 empty 狀態)。
Non_volatile:該內存資源一直有用,不能被回收。
Empty:該內存資源明確不用了,需要立即釋放。
重要的一點是 volatile 和 empty 狀態的資源不計入程序自己的 mem footprint,它算系統的 cache 內存。
通過 purgeable state iOS 系統等于為我們提供了一層 pool 或 cache 機制,我們應該盡量利用它。事實上理想情況我們應該把大部分程序用到的可反復創建的顯存資源用 purgeable state 來管理。就像一個緩存池一樣,我們不用這個資源就把他標記為 volatile 的,我們想用就從池拿出來,判斷它是否為 empty,被釋放了就重新創建否則直接用。iOS 也開辟了大片的內存為這個 purgeable 的資源池,除非我們需要考慮重新創建的成本,否則你的顯存資源都應該是在不用后做成 volatile 的。
在 UE 程序中,我們基本會用到 texure streaming pool 去做 texure 的 streaming,用 mesh streaming pool 去做 mesh 的 streaming,還有各種 rt pool 等等,事實上這些 pool 里所有的資源都應該走 volatile 的機制。這對顯存總量的節省是巨大的,并且更加科學,iOS 系統會自動在內存壓力下幫我釋放 cache。
?
Memoryless Resource
除了 purgeable state 之外,metal 的 resource 還可以指定它的 storage mode。Storage mode 用于指定 mtl 資源的被 cpu 和 gpu 訪問的途徑和存儲優化。對于用于做 rt 的 texture,有一個特殊的存儲模式叫做 memoryless。
我們知道對于 tbdr 的設備,我們在創建一個 rt 之后,rt 的真正訪問是在 gpu 的 cache 上的,除非我們顯示的需要讀取它才需要把整個資源從 gpu cache resolve 到 memory 上的,所以在很多情況,我們是根本不需要存在一個 memory 上的那一份 rt 的。例如你的只用作深度測試的深度圖。這樣的資源在 iOS 上可以聲明為 memoryless 的 storage mode,這樣整個 mtltexture 對象在創建后其實并不會產生一個 memory 上的內存占用,只會在 gpu 的 cache 上產生臨時的對象,并且用后也不會 resolve 回內存,相當于我們節省了整個 rt 的內存開銷。
?
在 iOS 上對于所有的不需要 resolve 的 rt(或 storeaction 設置為 don’t care)都應該設置為 memoryless。注意如果聲明了 meoryless 但是實際又去讀取了它則會產生 crash。
Memoryless 的資源同樣不計入 mem foot。
Metal resource heap
iOS 中 xture 和 buffer 等資源通常可以直接從 mtldevice 上創建。但是能帶來更多內存優化的方法是從 mtlheap 上創建。
Metal Resouce Heap 是一個抽象的用于創建 GPU 資源的 heap,它其實是維護了一個內存池。我們可以從 1 個 MTLHeap 上 subllocate 多個 texure 或者 buffer,這樣做有很多好處:
首先它減輕了資源創建的時間開銷,因為 heap 的后面是一個可復用的內存池。
然后因為 mtlheap 的內存可能被系統動態的壓縮不常用的區域,所以基于 mtlheap 可以減少內存占用。
一個 mtlheap 上的 subllocate 資源擁有相同的 storage mode 和 cpu cache mode。另外 mtlheap 需要整體設置 purgeable state,而不能每個資源單獨設置。所以實際使用中我們也要有很多的 mtlheap 組成的池,那些 storage mode 相同,purgeable state 相同的資源從一個 heap 上分配,另外 metal 文檔提到單個 heap 也不能過大,因為對 heap 的壓縮將影響其性能。
另外 mtlheap 上分配的資源支持 alias 機制,下面一段會講。
Resource Alias
從 mtlheap 上創建的資源支持 alias 機制,alias 也是 iOS 上對 gpu 資源的一個獨有優化機制,它是指一個被標記為 alias 的資源 A 可以被 mtlheap 重用,只要重用的資源 B 同 A 有相同的資源格式,只是內容不一致,并且邏輯上要保證 A 和 B 不會被 GPU 同時使用。
一個典型的使用場合是后處理鏈,在這里面要涉及很多后處理階段,每個后處理階段用到不同的 rt,但是這些 rt 不會被同時使用,我們就可以把這 rt 做成 alias 的,然后在后面階段不斷被重用,但是分配的內存一直都是那一個。這個過程要注意使用 fence 或 event 來保證共享這塊內存的 rt 不會被同時使用到,這里我們的做法應該是從 mtlheap 創建一個 rt,然后執行第一步后處理,然后插入一個 fence,調用它的 makealiasable,然后再創建第二個 rt,執行第二步后處理…依次下去。
iOS 通過 alias 為我們在保持邏輯層有多個資源的同時,做到了一個底層的內存共享。
關于 shader
Shader 也會占用較多的顯存。除了常規的減少 shader 變體之外,我們還應該利用 metal 的 shaderlibrary 的預編譯,預先將 metal shader 編譯成 native 的 mtllibrary,運行時從 library 中加載 shader function,而不是動態從源碼編譯 shader 。
首先從 mtllibrary 加載要更快,另外 mtllibrary 本身不占用物理內存,只占用虛存,只會在我們用到哪些 shader 的時候才產生內存占用,且由于 native code 本身體積也很小,占用內存少。而動態編譯 shader 會將源碼載入內存進行便于,源碼體積大本身就會產生更大的內存腳印。
3.2 UE 的 fmalloc
對于 fmalloc 的分配,除了常規的減少內存分配次數,盡量對齊內存,減少 traray 的 resize,用 inline allocater 等棧模擬等方法之外,在 iOS 上還有一些額外可以嘗試的操作。
UE 使用一個自帶的內存池(Fmalloc)去進行內存的分配釋放管理,預先分配整段內存,避免 malloc 產生磁盤碎片,這個過程會產生前面提到的所有內存池都會有的對齊浪費和頁空白浪費。這部分浪費的內存顯示在了 LLm 的 malloc unused 項目里。
其實對于 iOS 來說,iOS 底層已經實現了類似的 malloc 小內存池,所以在項目里可以實驗一下在 iOS 上不采用 UE 的 fmalloc 而直接用 malloc 交給 iOS 的內存池管理,對比下內存用量的區別,對于不同的項目這個哪個更好不好說,但是可以試一下。即使最終發現使用 UE 的 fmalloc 還是更優的話,還是可以試一下對于 256B 以內的小內存直接使用 iOS 的 malloc 對比測試一下,因為 iOS 的 nano malloc 對于小內存還做了額外的優化。
Vm_copy
前面提到了 iOS 上使用 vm_copy 來明確的使用 copy on write 優化,所以我們應該在代碼里大量的對于大塊內存的 memcpy 換成 vm_copy,除非你發現這里的 copy 時間是個瓶頸。這種優化對于圖形程序的收益是很大的,一個典型的場景,我們從文件加載模型數據,然后將其 copy 到申請的一個 mtlbuffer 上,在 copy 之后我們幾乎不會對這個內存做更改,如果使用 vm_copy,這個 copy 的操作就剩下了,也減少了內存腳印。
3.3 其他
代碼段內存
另外對于 iOS 程序來說,代碼段本身也有可能是個大頭,這部分可以被 swap out,所以當內存真正吃緊的時候危害相對沒這么大,但還是可以想辦法減小。包括減少代碼體積,減少模板的使用,strip 掉調試符號,將一些 iOS 上不會用到的 UE 的 plugin 去掉等。
對 iOS 系統 lowmemorywarning 的響應
iOS 系統會根據不同的機型制定該機型內存告警的級別,如 xcode memory gauge 上面顯示的一樣,到達紅色區域(如 iphonexr 到達 2.1G)就會觸發內存告警,你的程序不能無視內存告警,如果在內存告警到達時不能有效的盡快減輕內存負擔,系統將會很快結束該進程以回收內存。我們需要在收到 didreceivememorywarning 的時候額外釋放大量任何可以釋放但不會導致程序異常的內存,例如你的各種 cache,這也可以大大減少系統異常退出的幾率。
iOS 內存問題排查常用工具
UE 自帶的 memoryreport
這是最簡單方便的估計 UE 主要資源的方法,里面集成了一些指令,可以看到常用資源如貼圖,uobject 等的詳細清單,內存,但是這里面的內存值都是估計的,只供參考。
UE 自帶的 obj list 指令
用于列出任意 uobject 類型的實例清單,對于內存泄露和因 uobject 產生的內存優化很重要。
UE 自帶的 obj refs 指令
用于列出任何 uobject 實例的引用鏈條,可以在我們通過 obj list 找到泄露的對象后繼續追查它未被 GC 的原因。
UE 自帶的 LLM 工具
在《Android 內存分析和優化》中詳細講了 UE 這個工具的實現,它可以將 UE 范疇內分配的內存按 tag 列出,對顯存資源也能做出較準確的估計。
Xcode 的 allocation
這個就是平臺層面的工具,但是也是最全面的,它獲取整個 iOS 程序的內存情況,進行內存分布分析,泄露查找。這里面可以按照各種 tag 列出整個虛存空間的分配情況,還可以看到如下圖整個程序的虛存空間每個地址上的分配情況,tag,大小,映射的物理內存大小,類似于 Android 平臺上的 pmap。
?
還可以插入 generation,來定位在某個時間段之內增長的內存。
注意這個工具里面的幾個分類表述,all heap 是指從 malloc 途徑的分配,anonymous VM 是指所有不帶 tag 的 vm_allocate,而 UE 的 fmalloc 因為帶了 255 的 tag,所以不在 anonymous VM 分類下,而是在 all vm region 下面的 memory tag 255 下。
?
此外 Xcode 中的 leaker,graph capture 中的 gpu memory,以及 memorygraph 都是比較有用的排查 iOS 內存問題和做優化的工具。Memory graph 里面就列出了所有程序范疇內的 vmobject 分布及之間的關系。
總的來說,相比較與其他平臺,iOS 是一個從操作系統層面就極度追求優化的一個平臺,提供了大量平臺特有的內存優化手段,這使得同樣的程序可以在 iOS 上比其他平臺都有少的多的內存占用,使及時對于大量只有 2G 內存的 iOS 設備仍然能夠良好的體驗游戲,而我們不能無視這些手段,需要利用好 iOS 提供給我們的武器去優化程序的內存使用。
總結
以上是生活随笔為你收集整理的UE 手游在 iOS 平台运行时内存占用太高?试试这样着手优化的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 在游戏里模拟天空的颜色,太迷人了!
- 下一篇: 《幽灵行动·荒野》中的程序化技术:道路、