回顧并為今天的內容定下基調
我們目前正在進入實體系統的一個新階段,之前我們已經讓實體的移動系統變得更加靈活,現在我們想把這個思路繼續延伸到實體系統的更深層次。今天的重點,是重新審視我們處理實體類型(entity type)的方式,并開始思考這一機制將如何影響后續開發。
目前,我們已經實現了動態生成多個小人(entity)的能力,這些小人擁有與主角相同的運動控制邏輯。通過這個例子,可以看到引擎和游戲開發是一個非常非線性的過程。我們之前可能花了兩到三整天的時間,只為了決定如何放置點位,以及實體如何占據空間。而實體存儲和世界存儲的設計更是占據了五到六整天的編碼時間。將這些時間換算成常規工作周,目前為止也就六到七周的完整編碼時間,可見前期這些系統性構建工作的比重非常大。
但當這些系統逐漸成型之后,事情就會開始加速發展。一旦這些基礎設施搭建完畢,新的功能就可以以更快的速度搭建起來。比如現在,我們可以輕松生成多個可控制的實體,并將它們投入世界中。這正是實體系統成熟的一個表現。接下來的約一百天的編碼時間里,預計我們將看到更多“指數級”的開發進展,也就是說,投入的工作量和產出的效果不再是線性關系,而是會獲得更大的回報。
當然,我們還沒完全解決所有問題。比如現在這些小人會重疊在一起,這是因為當前的碰撞系統還沿用了舊的邏輯,無法正確處理現在這種實體分布和交互方式。另外,我們在 Z 軸處理上也還有一些待解決的問題。也就是說,盡管我們已經在正確的軌道上了,但仍需進一步完善這些關鍵系統。
接下來,我們希望開始將實體的行為邏輯逐步從“類型驅動”方式中解耦。目前,我們的做法是根據實體類型進行大量的特殊處理,例如繪制主角,是通過創建一個 HeroHead
類型的實體和一個 HeroBody
類型的實體,然后在多個邏輯點中使用這兩個類型來控制繪制、運動等行為。
這種做法并不錯誤,很多游戲確實采用類似方式來處理實體。如果游戲中每種實體都非常獨特、沒有太多相似之處,這樣的類型驅動設計完全沒問題,而且你依然可以通過函數復用來共享一些通用邏輯。
但我們構建的游戲并不是那種“每個實體都很獨特”的類型,而是更希望整個實體系統具有連續性。也就是說,我們不僅希望存在“主角”和“Boss”這樣兩個離散的實體類型,我們更希望能存在一個中間狀態:比如介于主角與Boss之間的某種實體。這要求我們的系統能支持高度可組合、可擴展的設計,以便形成豐富的組合、變化和行為模式。
我們希望從設計上模仿 Roguelike 游戲的傳統,而非像《塞爾達傳說》那樣的類型結構。我們追求的是模塊化構建:將實體拆分成若干獨立的功能模塊,然后通過組合這些模塊來生成各種實體類型。甚至在運行時,我們可以通過添加或移除模塊來動態改變實體的行為能力。
因此,現在就是一個不錯的時機,我們可以開始嘗試構建這樣一個更通用、更加動態的實體系統,朝著組合式、組件化的方向前進。這將為后續的游戲機制擴展奠定良好的基礎。
在 game_entity.h
中思考如何從實體結構體中去除 Type
字段
我們開始著手思考是否可以逐步消除實體結構中的 type
字段的實際用途,并設想如果要徹底擺脫 type
字段,我們該如何做。
舉個例子:假設我們希望在游戲中加入一種敵人,這種敵人從外觀和運動方式上與主角完全相同,就像是主角的邪惡鏡像版(可能是幽靈或者其它黑暗形態)。它不會響應玩家的控制輸入,而是使用某種 AI 或其他非玩家輸入方式進行移動與行為控制。我們希望它能共享主角的所有邏輯代碼,僅僅是輸入方式不同。
另一個例子可能是“只有頭部”的漂浮敵人,它使用的仍是主角頭部的代碼邏輯,只是沒有身體。再比如“只有身體”的實體,它可以像青蛙一樣蹦跳移動,但沒有頭部控制方向。盡管我們現在主角的邏輯代碼非常少(可能只有幾十到上百行),但僅憑這點代碼,我們理論上就可以組合出很多種不同的敵人類型,只要改變控制邏輯(AI、玩家輸入等),這些組合方式就能產生幾十種不同行為的實體。
然而,現在我們的實體系統無法很容易實現這種靈活組合。原因在于當前系統仍然強烈依賴 type
字段來決定行為。這會使我們不得不對每種類型做硬編碼,顯得僵化、不可組合,未來要擴展就變得困難。
我們希望推動實體系統朝更加解耦、組合化的方向發展。目標是能夠自由拼接功能模塊來構建新實體,而不是為每個實體類型寫一整套獨立邏輯。幸運的是,我們目前的實體結構是“平坦的”,沒有使用復雜的類層次結構或繼承體系,因此不會遇到典型 C++ 繼承體系中那種類型轉換、抽象接口的問題。這一點讓我們得以自由組合不同的數據片段。
問題的關鍵是:type
字段目前仍承擔著類似“繼承類名”的角色,它限制了實體之間的組合可能性,就像 C++ 的類層次會限制對象的可重用性一樣。這種設計思路顯然不適合我們當前追求的靈活組合目標。
我們接下來的思路,是從實體已有的運動狀態入手:現在的實體已經具備某種“有限狀態機”的行為,例如 Planted
(固定不動)和 Hopping
(跳躍移動)這兩種模式。這說明:運動模式的狀態變量,原本就可以作為行為的判斷標準,而不需要依賴于 type
字段。任何實體只要設置了這個運動狀態,就可以具備相應的行為。
因此,我們第一步要做的事情,是把那些原本可以通過更小粒度變量來控制的邏輯,從 type
字段中解耦出來。我們不再用 type == HERO_HEAD
這樣的判斷來決定行為,而是用實際控制行為的數據來決定邏輯分支。
總結來說,我們正在采取以下策略來推動實體系統去類型化、組件化:
- 把行為判斷的依據從
type
字段轉移到具體的數據字段上,例如運動狀態、控制來源等; - 將實體視為屬性和行為的集合,而不是一個靜態類型;
- 使實體具備可以自由組合的能力,以支持豐富的變化和演化;
- 通過數據驅動的方式,為系統后續支持復雜的 AI、角色變異、怪物組合等提供可擴展基礎。
這一過程的目標,是實現一個高度模塊化、靈活、可組合的實體系統,讓我們能以最少的代碼,通過不同模塊的拼接方式,快速構建出多樣且具行為差異的游戲實體。
在 game_entity.h
中將不需要依賴 Type
的部分移除
我們開始嘗試將代碼中與實體移動模式相關的處理邏輯,從依賴 type
字段的實現方式中解耦出來,看看這個過程會帶來什么變化。
首先,在“世界模式”中檢查了當前的移動模式 movement_mode
的使用位置。原本的代碼邏輯是將不同的移動行為封裝在一個 switch
語句中,根據實體的 type
來決定具體行為。我們嘗試最簡單的方式:將這段與移動行為相關的邏輯從 switch
語句中剝離出來,放到外部獨立處理,避免再依賴 type
字段。
我們從主角身體部分的處理邏輯入手。邏輯中,如果移動模式是 Planted
(靜止狀態),那么實體會被固定在其所占據的方格中,并讀取 head_delta
來計算上下浮動(bob)效果。這個 head_delta
是針對“成對實體”(如頭和身體)特有的處理。除此之外,其它邏輯是通用的,不依賴實體結構是否是“成對”的。Planted
狀態下其實唯一特有的是上下浮動(bob)計算,換句話說,這部分邏輯是可以獨立出來并通用化的。
相比之下,Hopping
(跳躍)模式的邏輯本身就不依賴頭部,處理邏輯可以獨立存在,不需要關心實體是不是一對“頭-身體”的組合。因此,我們開始把 Planted
模式下的 bob 運算邏輯抽取出來,標記為一個處理移動模式的邏輯塊。
雖然這種抽取操作最終很適合重構成一個函數,但考慮到當前時機尚早,我們暫時不將其封裝為函數,而是內聯保留在當前位置。同時加了一個注釋,明確表示這是用于處理實體移動模式的邏輯部分,后續可以再提取出去使其更清晰。
接著,繼續編譯以確認當前更改沒有引發錯誤。在處理 T_bob
和 dT_bob
相關運算時,我們設想每個實體未來都會用到這種“上下浮動”的 bob 動畫。如果有一些實體如樹木(tree)是完全靜止不動的,理論上它們可以擁有一種新的默認移動模式,如“靜態模式”(immobile),其特征是不做任何位移,也不計算任何浮動效果。這一模式在邏輯上什么也不做,是最簡單的狀態。對于樹這種實體來說,這種處理方式是最合適的。
但考慮到當前的實際代碼結構,暫時決定不新增這個模式,而是直接使用已有的 Planted
狀態,因為 Planted
狀態本身只在“存在頭部”時才計算浮動,沒有頭部時不會觸發相關計算。因此對于像樹這樣的靜態實體,不會有任何負面影響。
然后我們重新編譯,發現主要問題還是出在某些沒有“頭部”的實體身上,因為浮動邏輯依賴 head_delta
的存在。為避免當前階段陷入對這種情況的復雜處理,我們選擇臨時注釋掉相關代碼,跳過這部分邏輯,讓整體流程能夠順利運行。
通過這一步,我們已經成功地將一部分原本綁定在 type
上的行為邏輯,從類型結構中解耦出來,向著行為由“具體狀態變量”驅動的方向邁出第一步。這種方式為日后實現更加靈活、模塊化的實體行為系統打下了基礎。我們后續還可以繼續將其它與 type
綁定的行為進一步抽象、重構,以達成徹底擺脫類型標識符的目標。
運行游戲,確認除了“暫時漂浮的東西”(FloatyThingForNow)以外沒有丟失任何內容
我們現在已經基本上實現了與原來相同的邏輯,只是移除了之前的上下浮動(bobbing)動畫效果。除了這一點,其他行為都保持不變。原來當實體處于“Planted”(靜止)狀態,并進行拉伸動作時,它會有一個向下的動態效果,現在這個效果沒有了——也就是說,它不會像以前那樣“低頭”或“下蹲”,但這不是重大損失。
重要的是,這種調整可以廣泛應用于所有實體,對系統沒有明顯的不良影響,除了一個特殊的例外:一個浮空的實體開始出現異常行為,它漂浮到了屏幕中央之外。這種行為出錯的原因在于,我們之前用于控制浮動動畫的變量 T_bob
和相關邏輯,實際上已經隱含地承擔了控制浮空實體位置的功能。它通過某種彈性變化的計算,與實體位置相加,導致實體隨時間偏移。這和我們剛才移除浮動動畫的調整產生了沖突。
目前來說,我們尚不清楚這個“浮空實體”應該具有什么樣的行為定義。它是個特例,不適用于我們當前對實體運動和浮動的統一邏輯。基于此,我們決定暫時不去處理它,僅將其標記為一個待處理的內容,后續可以具體設計這種特殊實體的邏輯需求。
更進一步地,我們意識到,變量命名必須有明確的語義。例如 T_bob
這個名稱就很模糊。它似乎指的是某種彈性、浮動、時間相關的值,但具體語義不清晰,導致不同實體對其的使用不一致,甚至產生邏輯沖突。為了解決這個問題,我們需要為實體中的每個變量賦予明確的定義。每一個變量都應該對應一個特定的、單一語義的作用點,這樣所有實體都可以共享并圍繞這些明確的變量構建更復雜、協調的行為邏輯。
這其實不是系統的缺陷,而是一種優勢。我們正在朝著更好的設計邁進,即讓每個變量在系統中都擁有清晰、統一的含義,從而促成實體之間的交互更豐富,系統行為更靈活、可組合。例如:如果系統中有一個明確表示“彈跳值”的變量,那其他邏輯也可以根據它來決定動畫、聲音、位置、攻擊行為等各種互動邏輯,這正是我們期望的那種“豐富交互”的體現。
我們接下來做了一個 TODO
標記,提醒未來要認真思考如何支持這些浮空平臺或特殊實體,它們的運動機制應當有更加清晰的邏輯定義,而不是依賴隱式變量。
隨后,我們將注意力轉移到另一個關鍵問題:實體之間的“配對”關系。當前代碼中,存在“頭”和“身體”的關聯,例如一個實體被稱為“頭”,并通過一個 head
變量指向另一個實體(通常是身體)。我們認為這種“配對實體”關系應該被抽象成更通用的概念——即一個實體可能與另一個實體存在某種關聯關系,并基于此產生特定行為。
這種關系不僅限于“頭-身體”的組合,未來可能存在如“手-武器”、“主機-附屬模塊”、“角色-寵物”等多種形式。因此我們需要將目前對“head”這種具體命名方式的處理抽象為更中性、結構化的關聯變量,并在系統中形成一種明確的“配對實體”語義模型,以支持未來更復雜的行為組合與模塊復用。我們將著手對此進行定義和重構。
在 game_entity.h
中對實體結構進行劃分,分開有意義的內容和試驗性內容
現在我們開始進入實體系統設計的真正階段,是時候對已有的內容進行一次清晰的劃分和整理。我們將把原本在原型開發階段臨時添加的各種屬性、變量,與我們經過思考、設計清晰、有明確語義的內容進行區分。這種區分并不是意味著前者就是錯誤的,而是它們當初的目的更多是為了快速驗證、測試或驅動引擎跑起來,并不具備長期使用的嚴謹性。
我們打算在實體結構中建立一種“分區”概念,一部分是正式的、被確認具有特定語義的字段;另一部分是臨時使用的、尚未經過徹底思考的字段。隨著系統的發展,我們將逐步將原型階段的臨時內容遷移到正式結構中——只有當我們明確某個變量的實際意義、用途和通用性之后,它才會被提取出來,納入正式實體結構的一部分。
這種做法的核心目的是讓系統在可維護性和可擴展性方面更加強健。一個字段一旦被納入正式結構,它就不再只是某個特定實體為了臨時功能而生的“定制數據”,而是被賦予了通用的角色,未來可能會被多個實體復用,或者作為系統邏輯交互的基礎要素。
雖然將某些字段上升為正式字段不代表它們絕不可變,但我們確實要意識到其中的區別:臨時字段是為眼前所用;正式字段則是為整個系統結構服務的,是基礎的一部分。我們接下來的工作將集中于這類字段的梳理、命名、語義明確以及結構重構,確保每一個進入實體結構“核心區”的字段都具有明確職責、清晰用途,并為未來系統復雜行為的搭建打好地基。
在有意義的結構區添加 entity_reference *PairedEntities
和 uint32 PairedEntityCount
,引入實體配對的概念
我們現在開始思考實體系統中一個更通用和可擴展的結構——“配對實體”(paired entities)。這是一個對現有“head-body”這類結構的抽象化思考。我們不再僅僅將它看作是“頭”和“身體”的關系,而是把它歸納為一種“實體之間的配對關系”,并嘗試建立一個可以廣泛適用于類似結構的機制。
我們首先確立了這樣一個概念:一個實體可能會與其他一個或多個實體存在“配對關系”。比如一個復雜的 boss 怪物,它的多個部分(如八條腿)可能是獨立的實體,并和主干實體之間存在某種父子級別的關系。這種設計不僅僅局限于頭和身體的組合,而是能支持更加復雜的結構形式。
因此,我們計劃將“配對實體”作為一個正式的結構引入實體系統。這個結構應當具備以下幾個特征:
-
具備一組引用:不再是單一的引用,而是一個可以容納多個實體引用的集合。意味著一個實體可以擁有多個“配對對象”。
-
引用數量可變:系統應允許這些配對關系的數量是動態的,例如一個實體可以配對 2 個、5 個甚至 23 個其他實體。
-
引用關系具備語義:每一個配對關系不僅僅是一個 ID,還應當能攜帶關系的語義類型,例如“父子關系”、“部件關系”、“控制關系”等。我們設想使用一個枚舉或標簽字段來表示這種關系的類型。
-
未來可能擴展結構細節:當前雖然暫時不打算完全實現這套系統,但已經開始考慮后續可能涉及的內容,比如配對的實體之間是否還應該包含空間結構或層級結構等更多信息。
在實際使用上,我們希望看到這樣一個配對結構如何工作。例如我們在處理“planted”運動模式時,原本是通過頭部與身體之間的距離來判斷角色是否應該“下蹲”。如果我們引入了多個頭部或多個配對實體,我們就必須考慮——應該如何決定“蹲下的程度”:
- 取所有配對實體與本體的平均距離?
- 選擇最遠的距離(最大值)?
- 使用最近的一個(最小值)?
- 或者進行某種加權平均?
這些都屬于向“配對系統”引入更強行為語義的一部分,可能需要以后進一步設計。
總之,我們的目標是:為實體系統引入一個通用、動態、結構化的“配對機制”,使得不同實體之間可以建立具備多樣語義的關聯,并以此為基礎支持更復雜的邏輯行為和動畫系統。這一結構不僅提升系統通用性和可維護性,也為未來添加新型實體或擴展游戲交互能力提供了基礎支撐。
黑板時間:范數(Norm)與畢達哥拉斯定理
我們在這里引入“范數(norm)”的概念,是為了更好地理解在多維空間中如何對多個數值進行度量。范數是一種“度量方式”,它幫助我們在處理多個值(如向量)時得出一個統一的量化結果。
什么是范數(Norm)
范數是數學中用于衡量“向量大小”或“距離”的一種方式,在編程和圖形系統中也非常常見。我們平時常用的向量長度計算,其實就是范數的一種具體形式——2范數(Euclidean Norm)。
向量長度其實是2范數
我們平時處理二維向量 (x, y)
時,會通過勾股定理計算其長度:
length = sqrt(x2 + y2)
這正是2范數的定義。在三維中就是:
length = sqrt(x2 + y2 + z2)
所以,2范數實際上就是將各個分量平方相加后開根號。
各種常見范數及其意義
我們可以將不同的“范數”看作是對一組數值的不同“測量方式”,它們的通用形式是:
n范數 = (|x?|? + |x?|? + ... + |x?|?)^(1/n)
以下是常見的幾種范數:
1范數(Manhattan Norm / Taxicab Norm)
||v||? = |x| + |y| + |z| ...
這是將各分量直接相加的結果,不涉及平方或開根號。常用于需要對權重歸一化的地方,例如在圖形渲染中處理權重時:
所有權重加起來應該是1,于是就除以總和,也就是除以1范數。
2范數(Euclidean Norm)
||v||? = sqrt(x2 + y2 + z2)
這就是我們平時說的“向量長度”,最常用于描述實際的空間距離。
3范數(不常用)
||v||? = (x3 + y3 + z3)^(1/3)
實際工程中很少使用,因為沒有特別明確的意義或優勢。
∞范數(Infinity Norm)
||v||∞ = max(|x|, |y|, |z| ...)
這是取所有分量中的最大值。其數學定義形式是:
(x^∞ + y^∞ + z^∞)^(1/∞)
由于當指數趨近無窮時,最大的那一項會“壓倒性地占據主導”,所以最終的結果等于最大分量值。
這個范數在實際應用中比想象中常見,比如在進行某些最壞情況分析、約束判定等場景中非常有用。
總結
- 范數是度量一組數值整體大小的方式。
- 不同范數根據實際需求使用,其中 1 范數和 2 范數最為常用。
- ∞ 范數提供的是最大單個分量的值,適用于某些特殊場景。
- 了解這些范數的定義和幾何含義,有助于我們在設計系統時選擇合適的度量方式。
我們未來在構建實體之間行為權重、配對機制等系統時,可以借助這些范數來計算距離、差異或相似度等,用于實現更復雜的邏輯判斷和物理表現。
在 game_world_mode.cpp
中編寫多個實體配對的使用代碼
我們剛才講到范數是為了引出一個更實用的場景:當我們面對一個本應處理單一輸入的操作,而現在卻希望它能處理多個輸入,但最終仍然只輸出一個值的情況時,范數就成了一個非常合適的工具。
多個輸入,單個輸出的處理邏輯
比如說我們原來有一個系統,它只處理一個“頭部”實體的位置,用來決定一個生物在起跳前需要下蹲的程度。但現在我們希望這個系統可以支持多個“頭部”或其他相關實體的組合,那么我們該如何繼續只輸出一個單一的“下蹲值”呢?
我們就可以用范數來歸約多個值為一個。
使用范數總結多個實體的數據
舉個例子,我們遍歷所有關聯的實體(比如多個頭部、或附加部分),然后對它們的位置偏差進行累加。具體來說:
- 遍歷所有“配對的實體”。
- 獲取每個配對實體的位置信息。
- 計算它和主實體之間的距離差。
- 將這些差值平方后累加。
- 遍歷結束后,對總和開根號,得到類似原來那種“長度”的結果(也就是2范數)。
- 最終這個值就作為唯一的輸出。
這種方式和原來只對一個實體做操作時保持了相同的輸出邏輯結構,只是現在我們把多個輸入歸約成了一個總量。
優勢與可擴展性
這種做法的優勢非常明顯:
- 通用性強:不再依賴特定命名(如“頭部”),而是通過“配對關系”處理任意多個子實體。
- 行為一致性:只需要改變配對關系,不需要改變邏輯結構即可獲得新的交互方式。
- 未來可擴展:后續可以靈活擴展,比如允許多個部分影響跳躍力、移動速度、碰撞行為等。
我們現在其實已經可以不再關心一個實體是否擁有某個具體配件(比如頭部)了,因為“是否有配件”這個判斷已經隱含在遍歷過程中:沒有配件,循環體不會執行,結果自然為零。
更進一步的泛化目標
我們的目標,是盡量將所有邏輯從特殊命名邏輯轉化為通用結構驅動邏輯。也就是說,我們希望未來的系統能自動適應各種組合,不需要為每種情況寫特定代碼。
通過范數和配對系統,我們就能夠:
- 用統一方式處理多個輸入;
- 提供靈活的實體組合機制;
- 實現更復雜、動態的行為關系;
- 為后續加入新機制打下結構基礎。
這就是我們為何要引入范數作為過渡工具的根本原因。它不僅是數學技巧,更是構建通用系統邏輯的一塊基石。
在 game_world_mode.cpp
中思考如何讓 PackEntityIntoChunk
了解配對關系,并讓 PairedEntities
動態調整大小,以及如何追蹤 entity_id
我們現在面臨的核心問題是實體打包(packing)系統在處理“配對實體”這種動態數組數據結構時遇到的一些復雜性,尤其是在缺乏固定數量的“頭部”實體之后,原有的簡化結構不再適用。為了解決這個問題,我們必須改進實體的打包與解包邏輯,使其支持可變數量的數據項,并處理動態增長的數組。
實體打包時的內存管理問題
在原有的打包流程中,我們只是將整個實體結構以塊拷貝的形式復制到內存區域中,但這種方式無法很好地處理如“配對實體數組”這種可變長度數據結構。比如:
pair_entity_count
是一個明確數量,可以正常打包;- 但
pair_entity_ptr
是一個指針,指向一段動態分配的數組,需要我們根據數組實際長度來打包,而不是直接復制指針值。
因此,我們需要明確策略:
- 只打包實際存在的配對實體數據;
- 避免為未使用的空間浪費內存或帶寬;
- 確保結構在被打包后能夠正確重建原有信息。
數組的動態增長策略
在實際運行中,一個實體在創建時可能沒有任何配對對象,但運行中可能會陸續添加。這就引出了“動態數組增長”的需求。目前尚未實現這個機制,因此我們需要決定一套策略。
我們的選擇:
- 每次增長時以固定塊大小進行(如每次增加 4 個 slot);
- 避免一次性預分配過大內存;
- 根據常見情況進行優化:大多數實體配對數為 0~2,極少超過 10,因此無需支持非常大的配對數。
這種方式既節省內存,又保證了運行時效率。
將打包與解包邏輯集中管理
我們希望將打包與解包的邏輯從模擬系統中移出,集中放到 handmade_world
這樣的模塊中:
- 模擬模塊只關注實體行為;
handmade_world
負責打包與解包;- 讓數據流動變得清晰、可追蹤,減少重復錯誤。
重新思考實體引用 EntityReference 的結構
目前的 EntityReference
通常包含一個指針和一個索引(ID),這在系統設計中帶來了一些限制:
- 如果只存指針,那么一旦實體不在當前模擬范圍內,引用會丟失;
- 如果只存索引,那么在使用時每次都要查找實體表,效率不高;
- 如果同時保留指針和 ID,則必須同步更新這兩個值,否則可能造成狀態錯亂。
我們有兩種基本策略:
-
只使用實體 ID
所有引用都通過 ID 來維護,查找通過哈希表或索引表完成;這種方式更穩定,適合長期引用,但訪問開銷稍大。 -
保留 EntityReference(指針 + ID)
- 如果指針有效,就直接使用;
- 如果指針無效(例如目標不在模擬范圍),則依然保留 ID,支持延遲匹配或遠程感知;
- 打包時依據當前狀態判斷是使用指針還是 ID;
這種混合策略兼顧效率與功能,適合需要動態識別關系、記憶狀態的復雜交互,比如:
- 實體記住一個遙遠的目標(如寶石);
- 召喚物記得主人,即使雙方暫時不在同一模擬區域;
- 任務系統中的目標跟蹤;
對于 ID 持久化的進一步思考
我們希望讓實體能長期記住另一個實體的 ID,而不依賴其是否在當前的模擬范圍中。為了實現這個目標:
- 需要允許某些
EntityReference
的 ID 獨立于其指針存在; - 在打包與解包過程中確保 ID 被保留;
- 即使該實體不在當前的 active chunk 中,也能通過 ID 比對識別其身份;
- 使用者在邏輯上要意識到:指針為 null 不代表引用無效,只是代表引用對象不在當前范圍。
例如:
if (ref.pointer == 0 && ref.id != 0) {// 實體不在當前區域,但我們仍然“認識”這個目標
}
總結目標
- 支持動態數量的配對實體;
- 動態數組可增長,按需分配;
- 優化打包流程,按實際數據量處理;
- 保留實體 ID,支持長時間引用;
- 將打包邏輯集中管理,簡化系統架構;
- 構建一個更靈活、可擴展的數據管理體系,為更復雜的游戲行為做準備。
在 game_entity.h
中考慮引入枚舉 entity_relationship
來表示實體之間的配對關系
我們正在探討如何更合理地設計和使用 EntityReference
(實體引用)結構,以支持模擬范圍內外的實體關系。這一問題看似簡單,實則牽涉到數據結構的健壯性、可維護性以及未來的功能擴展。
基礎設計建議:固定16字節結構
我們提出了一種設計方式,將 EntityReference
明確定義為16字節的結構,包含以下三個部分:
- 關系標志位(例如用于后續可能的用途);
- 索引(ID):用于持久標識實體;
- 指針:用于高效訪問當前在模擬范圍內的實體對象。
這種結構讓我們在編碼時總是使用指針進行實體訪問操作,但在需要驗證引用是否合法或需要進行邏輯判斷時使用索引。
我們還提出了一種方式使指針和索引“更難直接訪問”,以提示調用者不應隨意使用,而應該通過專門的 helper 函數 進行訪問和處理,從而確保使用行為的一致性和正確性。
三態狀態設計
核心難點在于實體引用實際上是一個“三態狀態”,而不是傳統意義上的“引用/未引用”兩態:
- 空引用:既沒有指針也沒有索引(表示沒有任何引用關系);
- 就地引用:指針有效,指向當前模擬范圍內的實體;
- 遠程引用:指針為空,但索引有效,表示引用了一個不在模擬區域內的實體。
我們必須在系統中處理這三種狀態,不能假設引用要么存在(有效指針),要么不存在(空指針)。尤其是在支持大規模世界、跨區域實體追蹤的設計中,遠程引用(指針為空,ID有效)是非常常見且必要的場景,例如:
- 玩家離開某區域,但某個敵人依然記得其 ID;
- 寵物記住主人的 ID,即使主人暫時不在視野內;
- 任務系統追蹤特定實體的狀態,不依賴是否在當前加載的區域。
是否引入“空代理實體”
我們考慮過另一種方案,即當引用指針為空時,讓它總是指向一個特殊的“空代理實體”,以避免空指針判斷。但我們認為這方案不夠優雅:
- 增加了概念復雜度;
- 需要維護一個虛擬實體池;
- 程序員很可能誤將代理實體當成真實實體使用,反而增加混淆。
因此,我們傾向于不采用這種方式。
實際代碼中的應用
通過分析已有的代碼,我們發現,在大多數情況下,使用實體引用并不需要特別判斷三種狀態:
- 如果我們只是訪問指針,訪問失敗就意味著該實體不在模擬范圍內;
- 如果我們只是收集數據或做簡單遍歷,大多數邏輯在“有指針時處理,無指針時忽略”的方式下就能運作良好;
- 唯獨在需要判斷“是否引用了某個對象”時,才需要判斷索引是否有效(即使指針為空)。
因此,推薦的使用方式是:
if (EntityRef.pointer) {// 模擬范圍內,可直接訪問實體
} else if (EntityRef.index != 0) {// 模擬范圍外,但引用仍然有效// 可延遲加載或標記為遠程交互
} else {// 完全沒有引用
}
這種方式在邏輯上清晰,避免了直接依賴單一指針的脆弱性,同時允許我們在需要時支持更復雜的行為。
最佳實踐與未來展望
為了減少后續維護成本和誤用風險,我們計劃:
- 強制所有對
EntityReference
的操作必須通過封裝函數; - 這些函數將根據引用狀態自動選擇是使用指針還是使用索引;
- 系統所有模塊都需要遵守“引用可能為空,但 ID 可能有效”的基本原則;
- 設計輔助邏輯來優雅地處理“遠程引用”的情況,比如:自動預加載、延遲綁定、弱引用行為等。
通過這樣的結構,我們不僅提高了系統的穩健性,還為大規模、動態世界中的復雜交互打下了良好基礎。
在 game_entity.h
中引入 ReferenceIsValid
、entity_stored_reference
和 ReferencesAreEqual
我們進一步完善了實體引用(Entity Reference)的設計方案,特別是關于如何判斷引用是否有效以及如何比較兩個引用是否相等的問題。這些都是在模擬范圍內外處理實體引用時非常關鍵的邏輯環節。
引入判斷引用有效性的輔助函數
我們意識到,單純通過指針來判斷一個引用是否有效是不可靠的,因為指針為 0
并不意味著引用無效。引用仍可能攜帶一個合法的索引(ID),只是當前模擬區域內沒有該實體對象而已。
為了解決這個問題,我們引入了一個輔助函數,比如:
b32 ReferenceIsValid(EntityReference ref);
這個函數僅檢查 index
字段,而不會檢查指針。這樣即使指針為零,只要索引有效,函數依然會返回引用有效。
此舉大大簡化了使用方的判斷邏輯。調用者只需要問:“這個引用是有效的嗎?”,而不需要關心它是模擬內實體還是遠程引用。
引入獨立結構 EntityStoredReference
為了更好地區分“引用實體”和“存儲引用”,我們新建了一個 EntityStoredReference
結構,它專門用于持久保存實體引用信息,內容可能包括:
- 實體 ID(index);
- 關系類型(例如敵人、同伴等);
- 可能保留指針(但主要用于臨時加速訪問)。
這一結構的作用是把引用看作一個完整的、可序列化的數據,而不僅僅是指針 + ID 的臨時拼裝。
引入輔助函數判斷兩個引用是否相等
我們還設計了另一個輔助函數,例如:
b32 ReferencesAreEqual(EntityStoredReference a, EntityStoredReference b);
這個函數用于判斷兩個引用是否指向同一個實體(以及是否屬于相同關系)。比較邏輯主要基于:
StoredIndex
(即實體 ID);StoredRelationship
(可選,取決于是否參與判斷);
通過這個函數,我們可以優雅地處理實體之間的各種關系判斷,而不必暴露低層字段,也避免了判斷指針時可能出現的歧義。
保留指針的討論與處理策略
我們考慮是否在引用結構中繼續保留實體指針。盡管指針值并不能反映引用的持久性,但它在模擬區域內時可以加快訪問速度。
我們傾向于 保留指針字段,原因包括:
- 某些代碼可能只設置了指針,但還未設定索引;
- 模擬區域內操作頻繁,保留指針可以避免重復查找;
- 通過封裝訪問和判斷邏輯,可以安全使用指針,同時避免錯誤用法。
設計思路總結
我們目前建立的引用系統具有以下特點:
- 三態引用模型:無引用、本地引用、遠程引用;
- 封裝判斷函數:使用
ReferenceIsValid
等接口統一判斷有效性; - 引用比較邏輯清晰:通過
ReferencesAreEqual
等接口明確引用是否等價; - 結構分離:運行時引用與持久引用分離,便于序列化與存儲;
- 封裝訪問機制:強調不直接訪問指針和 ID 字段,強制使用輔助函數。
這種設計不僅提高了系統健壯性,還使得引用機制具備良好的擴展性,能夠應對復雜的世界構建和長時實體追蹤需求。我們可以安全處理離線實體、跨區交互等功能,而不會破壞局部模擬的高效性。
在 game_world_mode.cpp
中使 PackEntityReferenceArray
能正確打包變長的實體配對信息
我們正在實現實體引用的打包機制,核心目標是確保實體在序列化/打包時能夠正確保留它們與其他實體的關系,尤其是在存在多個配對實體引用的情況下。以下是我們所做工作的詳細分解:
實體引用打包的整體流程設計
我們在打包實體數據(PackEntityIntoChunk
)時,不僅要打包實體本身的數據,還必須打包它所持有的實體引用數組(即與其他實體的關系)。因此,計算打包大小時,不能只考慮實體本體的大小,還需要加上:
配對實體數量 × 單個存儲引用的大小
這樣可以保證目標內存塊分配足夠空間來容納這些引用。
動態處理引用數組打包
我們定義了 PackEntityReferenceArray
的邏輯,對引用數組進行遍歷與打包處理。每一個引用項將被轉換為 StoredEntityReference
結構,存儲到目標內存中。這一結構保存以下信息:
index
:實體 ID;relationship
:關系類型;pointer
:可以選擇保留指針;- 其他必要字段。
這個過程類似于序列化:將運行時狀態(可能包含指針)轉換為持久化狀態(以索引為主),便于保存與傳輸。
條件復制引用索引邏輯
在處理每個引用時,我們判斷:
- 如果源引用的
pointer
非空,說明它指向當前模擬區域的實體。 - 如果是這樣,我們嘗試保留它的
ID
; - 但如果指針為空,我們可以選擇清空索引,或判斷它是否仍然在哈希表中(即仍處于模擬區域中),從而決定是否保留索引。
這一判斷依賴于模擬區域的哈希表(用于實體查找),這使我們思考是否應該將打包邏輯遷移到 SimRegion
內部而不是 World
層面。因為當前 World
對象缺乏這部分上下文信息。
關于引用數組的結構選擇
我們注意到一個設計點:是否使用數組或命名槽(named slots)來存儲配對引用。目前我們采用數組,這樣可以支持任意數量的關系(可擴展性強),但也缺乏語義清晰度(例如不知道第一個引用指向“目標”,第二個指向“父節點”)。
我們思考后決定保持數組結構,因為:
- 我們可能有多個類型的引用,使用數組結構可支持動態數量;
- 如果需要多個語義分明的引用,也可以使用多個數組;
- 同時每個引用項中可攜帶“關系類型”字段,這彌補了語義缺失的問題。
正確計算偏移并執行打包
在打包時,我們嚴格記錄:
source.paired_entities
是源實體引用數組;source.paired_entity_count
是數組長度;dest
是目標位置(實體本體之后的存儲引用區域);
我們遍歷源數組,依次將每個 EntityReference
轉換為 StoredEntityReference
并寫入目標內存,過程中保持初始化安全性,避免數據未定義。
進一步思考:引用是否應在模擬區域關閉時統一重新打包?
我們還討論了另一個問題:是否應該將引用的重新打包操作(尤其是判斷引用是否仍存在于哈希表)放在模擬區域(SimRegion
)關閉時統一執行。這種做法雖然可能帶來一定的冗余計算,但也可能更清晰地統一引用狀態管理。
總結
當前的實體引用打包系統已經具備以下核心特性:
- 支持引用數組的序列化,考慮了配對引用數量與結構;
- 判斷引用有效性,基于是否存在于模擬區域哈希表中;
- 動態擴展能力強,通過數組結構支持任意數量引用;
- 字段初始化安全,打包過程中明確初始化所有字段;
- 可能需結構調整,未來可考慮將打包邏輯移動至
SimRegion
; - 處理關系語義,通過存儲引用的“關系”字段保留引用類型信息。
整個系統保持了良好的清晰度、擴展性與運行時安全性,為大型動態實體世界的序列化、網絡傳輸與遠程引用提供了扎實基礎。
在 game_world.cpp
、game_sim_region.cpp
和 game_world_mode.cpp
中修復編譯錯誤
我們正在完善“遍歷引用”(Traversal Reference)與“打包引用”系統的實現,目標是實現一個結構良好、可反序列化并能正確描述實體間關系的系統,即通過存儲引用(StoredEntityReference
)來表示實體間的配對關系。
引用結構的最終形式確定
我們決定使用 StoredEntityReference
來統一表示配對引用。這種引用不僅包含實體指針(pointer
),還包含與其關系(relationship
)和實體索引(index
)等字段,便于在運行時和序列化之間轉換。
保持系統在可編譯狀態
盡管時間緊迫,但我們選擇將系統最小化保持在可編譯狀態,以便下次可以繼續工作。這意味著我們先實現基本框架,保證編譯通過,并為后續打包、解包和邏輯處理留出結構。
頭部和身體的實體配對初始化
在添加玩家的過程中,我們需要初始化兩個實體的引用關系,例如一個“頭部”和一個“身體”:
-
分別定義
head_refs
和body_refs
數組,每個只包含一個EntityReference
; -
設置它們之間的互相引用,即:
body_refs[0].pointer = head
head_refs[0].pointer = body
-
設置引用數量為
1
(paired_entity_count = 1
); -
將數組賦值給實體的
paired_entities
字段。
這種互相引用關系定義了兩實體之間的邏輯連接(例如身體和頭部屬于同一個單位)。
暫時禁用引用數組(用于空實體)
在有些情況下,例如某個配對實體目前尚不存在,我們仍保留結構,但將指針設為 nullptr
或暫不填充。這種情況下:
- 仍設置
paired_entity_count = 1
,表明結構預留了引用; - 但指針為空,實際在運行時可以通過其他機制(如哈希表)重新建立引用。
引用數組在“打包”中的處理邏輯回顧
在之前的打包過程中,我們已經準備好以下機制:
- 實體引用數組會被打包到內存塊中;
- 每個引用會轉換為
StoredEntityReference
; - 指針信息在某些條件下保留(如仍在模擬區域中);
- 如果不再有效,則只保留
index
或清空; - 打包大小會考慮引用數組大小;
- 解包邏輯尚未完全實現,待后續補全。
對后續問題的預期
我們已經認識到目前的代碼中仍然存在一些尚未實現或可能存在 Bug 的部分,包括:
- 引用數組的完整遍歷與打包校驗;
- 反序列化/解包時如何恢復引用(根據 index 或重新查找實體);
- 多個引用數組或多重關系時如何高效表達;
- 如何在實體失效或刪除時維護這些引用的正確性;
- 模擬區域(SimRegion)結束時是否重新打包引用,保證跨幀引用正確。
結語
目前系統已進入一個清晰可擴展的階段:
- 我們實現了基本的數據結構和打包處理框架;
- 引用初始化邏輯已經清楚,尤其是互相引用的實體配對;
- 保持系統可編譯狀態,為下次迭代提供良好基礎;
- 后續將完善引用打包、恢復、驗證和失效處理等功能。
盡管還有未完成的部分,但整體架構穩定,邏輯清晰,支持靈活擴展和更復雜的引用管理需求。
問答時間
一個簡單理解無窮范數的方法是畫出各種常見范數的單位超球:1-范數是菱形,2-范數是圓或球體,3-范數開始變得更方……你會發現這一系列趨近于一個方形/超立方體
我們正在討論如何形象地理解“無窮范數”(Infinity Norm,也稱為 ∞ 范數)。我們提出了一種相對簡單但具有直觀可視化效果的方法:通過繪制不同范數的單位超球面(unit hypersphere)來進行比較和理解。
范數的幾何解釋方式
我們考慮用以下方式直觀理解常見范數:
-
一范數(L1 Norm)
對應的單位“超球面”在二維中是一個菱形(diamond shape),其邊界是所有絕對值和為 1 的點構成的集合。 -
二范數(L2 Norm)
即我們最熟悉的歐幾里得范數,對應的單位超球面就是普通的圓形(或在更高維中是超球體)。 -
三范數(L3 Norm)及更高
形狀會越來越接近于方形(或在高維中為超立方體),即邊界逐漸變得更“平”。 -
無窮范數(L∞ Norm)
對應單位超球面是完美的正方形或超立方體,因為它表示所有坐標的最大絕對值不超過 1 的集合。
趨勢與極限的可視化
隨著范數階數 p → ∞ p \to \infty p→∞,其單位超球面的形狀會越來越“扁平”,邊界會越來越靠近正方形/立方體的邊緣,最后趨于一個完美的正方形/超立方體邊界。這種從圓形逐漸向正方形演化的過程可以有效幫助我們理解 ∞ 范數的本質:它不再考慮所有坐標的合成量,而是只關注最大分量的值。
理解難點與保留意見
盡管這種幾何形象的解釋在我們內部看來“相對簡單”,但從嚴格教學角度來看,它可能仍然不適合初學者直接使用:
- 前提是聽眾或讀者要能夠理解什么是“單位超球面”,這本身就涉及抽象的高維幾何;
- 同時需要對不同范數的定義有清晰的代數理解,才能映射到圖形感知上。
因此我們雖然認可這種方法在可視化角度是有效的,但要作為“簡單描述方式”可能仍需結合上下文或逐步引導才更合適。
總結
我們通過繪制不同范數對應的單位超球面形狀(從菱形到圓形再到方形)來直觀地理解 ∞ 范數的本質——其最終單位超球面是一個超立方體。這是一種有效的趨勢分析方式,能夠輔助理解高維范數的行為及其極限。但作為解釋手段仍需考慮聽眾的數學背景,適當引導。
你看過 Scott Meyer 關于 CPU 緩存的演講嗎?他提到將相同類型的數據存儲在一起有助于它們出現在同一緩存行上,從而提升性能。這是你在游戲項目中會考慮或涉及的內容嗎?
我們討論了關于 CPU緩存優化與數據布局 的問題,特別是在程序設計中關于內存局部性(locality)、數據排列、以及對性能的影響。
關于“相同類型數據應該排在一起”的說法
這個說法是錯誤的。正確的說法應該是:
應該把那些“會被一起訪問的數據”放在一起,而不是“類型相同的數據”。
也就是說,優化CPU緩存并不是看數據類型,而是看訪問模式。如果我們在程序中經常同時訪問變量A、B、C,那么即使它們的類型不同,也應該把它們放在一起,保證它們能被加載進同一個緩存行(cache line)。反過來,如果我們只是有很多x坐標值(例如一個結構體數組中只關心x
字段),但處理它們時是分散訪問的,那么僅僅因為它們是相同類型的數據放在一起是沒有意義的,甚至可能會浪費緩存空間,降低性能。
空間局部性(Spatial Locality)是關鍵
優化緩存命中率的關鍵是空間局部性:
把程序中會“在時間上接近地被訪問”的數據放在內存上接近的位置。
這才是我們進行數據組織時需要關注的原則,而不是盲目根據類型分類。
SIMD對數據布局的影響
有些情況下,SIMD(Single Instruction Multiple Data,單指令多數據)指令集會強制我們批量處理一類數據。例如:
- SIMD要求將一組浮點數據(如4個或8個浮點數)連續排列,才能高效處理。
- 這種情況下我們可能需要使用“結構體數組”(SoA, Structure of Arrays)而不是“數組結構體”(AoS, Array of Structures),以便每種字段連續排列。
但即使如此,這也是因為算法或硬件處理方式強制要求我們一起訪問這些數據,因此仍然符合“將一起訪問的數據排在一起”的原則。
數據組織通常不是程序初期關注的重點
我們通常不會在架構初期就過度優化緩存,因為這樣做會造成極大的復雜性成本,而大部分代碼并不需要這類優化。應當在瓶頸分析之后有針對性地優化:
- 編寫合理、清晰的邏輯;
- 用性能分析工具識別熱點;
- 對熱點代碼進行局部的內存優化。
C++在數據組織方面的局限
我們指出 C++ 在數據組織能力上表現很差:
- 沒有良好的原生工具來支持數據重排;
- 封裝結構強,但對數據導向設計(Data-Oriented Design)支持差;
- 若希望改變數據布局,經常需要手動重寫大量代碼,維護成本高。
相比之下,有些語言或引擎支持靈活的數據布局描述,例如有能力讓程序員指定如何存儲結構體內部的數據,便于根據使用場景進行切換(如 SoA 和 AoS 之間的切換),這對優化緩存一致性和并行化極為重要。
總結
我們在內存優化方面的核心觀點是:
- 并非“相同類型的數據放一起”能提高性能;
- 真正重要的是訪問模式:一起訪問的數據應該在內存中靠得更近;
- 在需要做SIMD處理等特殊場景時,我們也會依賴特定布局;
- 優化緩存應當在確定存在性能瓶頸之后再進行,避免過早優化;
- C++在數據組織上的能力薄弱,更現代的語言和系統可以提供更好的支持。
這類緩存優化和內存組織的內容我們在實踐中已多次應用,并會繼續在架構中考慮其影響。
你能用實體配對的概念實現例如“飛船-乘客”這種關系嗎?如果可以,那乘客在飛船上的移動會如何實現?
關于“車輛乘員關系”的概念,比如飛艇和其乘客,目前系統中可能沒有專門的父級概念來表示這種關系。現有系統里已經有“站在(standing on)”這個概念,因此通常會用“站在”來表示乘客處于飛艇上的狀態。飛艇上的可通行點(traversable points)會用來描述乘客在飛艇上的具體位置和行動路徑。
關于移動的實現,乘客的移動會基于他們“站在”飛艇上的狀態來處理,意味著他們的動作和位置變化是依賴于飛艇本身的運動和飛艇上的可通行點,而不是單獨維護一個車輛與乘員的特殊父子關系結構。因此,乘員的移動是通過已有的“站在”關系和飛艇的運動同步實現的。
我覺得這個實體引用系統現在用可能還太早了,因為我們目前還沒有太多它的使用場景示例。你現在做這個是有什么特定原因嗎?
目前來看還為時過早,因為還沒有太多實際用例來說明這個設計具體怎么用。之所以現在開始做,是因為需要在系統里試驗“實體引用數組”的概念,而不是之前已有的單個實體引用。之前的設計更多依賴于實體類型,比如某個實體類型知道自己會用“頭部指針”來定位,但現在想寫一些代碼,不預設具體有多少輸入,舉個例子,比如不知道一共有多少條腿,可能只是有幾條腿附著在實體上,腿在走路時會有搖晃效果,但腿的數量是不確定的。
因此想先做一個基礎的結構,能支持實體引用不止一個,而是一組引用。現在先用一個只包含一個引用的簡單示例來測試,看看這種設計感覺如何,是一種“探索”的過程。接下來會嘗試支持更多引用,看看實際效果怎么樣。猜測最終可能需要用多個數組來存儲不同種類的引用,比如“關系”部分可能不是放在單個實體內部的關系字段,而是拆分出來單獨存儲。總之先做一個數組的示例,之后再慢慢調整優化。
現在還沒確定最終方案,主要是想通過實際寫代碼去“試水”,看看不同方案的利弊,再決定下一步怎么走。總之目前先做一個支持數組的基礎示例是必要的,方便后續的實驗和迭代。