我自己維護引擎的github地址在這里,里面加了不少注釋,有需要的可以看看
參考視頻鏈接在這里
Scene類重構
參考:《InsideUE4》GamePlay架構(二)Level和World
目前我的Scene類基本只是給entt的封裝,提供了一些GameObject和Component的相關函數,其Update函數沒有被調用,相關API也很缺乏,大概是這樣:
namespace Hazel
{class GameObject;class Scene{public:Scene();~Scene();void Update();void OnViewportResized(uint32_t width, uint32_t height);GameObject& CreateGameObjectInScene(const std::shared_ptr<Scene>& ps, const std::string& name = "Default Name");...}
}
現在的窗口其實只有Viewport,沒有GameView窗口。為了完善Scene的相關功能,可以先來看看別的游戲引擎里的設計,Unity里會有SceneView和GameView兩個窗口,一個負責繪制場景,一個負責代表GamePlay的實際窗口,而UE里的Viewport和GameView則共用一個窗口,按F8 eject可以切換SceneView和GameView
游戲引擎里允許同時加載多個場景(Scene),那么邏輯上講,需要三個內容:
- Scene Manager:應該是作為全局單例存在,負責加載和卸載場景
- 場景文件,作為文本文件(或可以被解析的二進制文件)存放了場景信息
- 程序加載時的Scene,每個Scene由序列化好的場景文件實例化而來
對應到各個引擎里,有不同的叫法,UE里把World認作Scene Manager、場景文件叫xxxx.map,Level就是實例化的Scene;而Unity早期用Application代表Scene Manager(后面改到了Scene Manager里),場景文件叫xxxx.unity,Scene就是實例化的Scene。
比如UE里的代碼:
// UWorld里存了Level數組
class ENGINE_API UWorld final : public UObject, public FNetworkNotify
{/** Array of levels currently in this world. Not serialized to disk to avoid hard references. */UPROPERTY(Transient)TArray<TObjectPtr<class ULevel>> Levels;/** Level collection. ULevels are referenced by FName (Package name) to avoid serialized references. Also contains offsets in world units */UPROPERTY(Transient)TArray<TObjectPtr<ULevelStreaming>> StreamingLevels;...
}// ULevel里存了Actor數組
UCLASS(MinimalAPI)
class ULevel : public UObject, public IInterface_AssetUserData, public ITextureStreamingContainer
{...// Level里的Actor和待GC的Actor數組TArray<AActor*> Actors;TArray<AActor*> ActorsForGC;// 唯一的LevelScriptActor指針UPROPERTY(NonTransactional)TObjectPtr<class ALevelScriptActor> LevelScriptActor;
}
大概邏輯是,Update這些Level(或Scene),從而Tick里面的Actor和ActorComponent。所以這里把Scene類重構成如下所示:
namespace Hazel
{class GameObject;class Scene{public:Scene();~Scene();void Begin();void Pause();void Stop();void Update(const float& deltaTime);void Clear();void OnViewportResized(uint32_t width, uint32_t height);void ClearAllGameObjectsInScene();...}
}
PS:這里我并沒有像Cherno一樣區分了RuntimeUpdate和EditorUpdate,因為感覺別的游戲引擎里都沒這么區分,而且Editor下應該也調用Update函數才對
2D PHYSICS!
主要是實現簡單的物理引擎,做了以下事情:
- Fork GitHub上的比較有名的2D物理引擎(erincatto/box2d),作為submodule加入到引擎里
- 創建2D用的Rigidbody組件,目前只支持Box
- Scene類里添加一個box2d的manager對象的指針:具體的Rigidbody分為Static和Dynamic類型(還有Kinematic?)
- Render模塊、物理模塊與腳本模塊的順序問題
關于Box2D
參考:Box2D —— A 2D physics engine for games
From the game engine’s point of view, a physics engine is just a system for procedural animation.
這是個C++寫的庫,還挺牛的,Unity和Cocos都用到了它,為了避免與其他的庫命名沖突,里面的類型名都以b2
作為前綴,這里舉個簡單的Demo代碼:
// 1. 創建世界, b2World is the physics hub that manages memory, objects, and simulation.
b2Vec2 gravity(0.0f, -10.0f);
b2World world(gravity);// 創建世界時需要設置重力加速度// 2. 創建一個長方形代表地面, 默認為static類型
b2BodyDef groundBodyDef;// 先創建引用
groundBodyDef.position.Set(0.0f, -10.0f);
b2Body* groundBody = world.CreateBody(&groundBodyDef);// 再創建實體// 給地面添加Box Collider, 這里的0.0f指的是?
b2PolygonShape groundBox;
groundBox.SetAsBox(50.0f, 10.0f);
groundBody->CreateFixture(&groundBox, 0.0f);// 3. 創建一個dynamic類型的rigidbody
b2BodyDef bodyDef;
bodyDef.type = b2_dynamicBody;
bodyDef.position.Set(0.0f, 4.0f);
b2Body* body = world.CreateBody(&bodyDef);// 添加Box Collider, 添加一些物理參數
b2PolygonShape dynamicBox;
dynamicBox.SetAsBox(1.0f, 1.0f);b2FixtureDef fixtureDef;
fixtureDef.shape = &dynamicBox;
fixtureDef.density = 1.0f;
fixtureDef.friction = 0.3f;
body->CreateFixture(&fixtureDef);// 5. 開啟世界的更新, 根據前面的設置, 世界開始后動態box會墜落到靜態的地面上, 不斷bounce
for (int32 i = 0; i < 60; ++i)
{// 調用物理世界的更新world.Step(timeStep, velocityIterations, positionIterations);// 打印更新后的Transformb2Vec2 position = body->GetPosition();float angle = body->GetAngle();printf("%4.2f %4.2f %4.2f\n", position.x, position.y, angle);
}// 6. Clean up
// world離開作用域時, 會自動釋放相關的所有memory
順便提一下,這里面有個概念叫fixture,具體有shape、restitution、friction和density四個主要屬性,并不是固定裝置的意思,而是類似于Unity里的Collider的概念。另外注意一點,這里需要先填寫好BodyRef,再調用CreateBody,代碼如下所示:
// 注意, 調用CreateBody之前要把b2BodyDef里的參數都填好// 正確寫法
m_BodyDef.position.Set(x, y);
m_BodyDef.type = Rigidbody2DTypeToB2BodyType(type);
m_Body = world->CreateBody(&m_BodyDef);// 錯誤寫法
m_BodyDef.position.Set(x, y);
m_Body = world->CreateBody(&m_BodyDef);
m_BodyDef.type = Rigidbody2DTypeToB2BodyType(type);
這里做的事情主要是封裝,比較簡單:
- 創建Rigidbody2DComponent類
- 對應類對象的UI繪制:允許在Inspector上Add此Component,可以編輯相關屬性
- Rigidbody2DComponent類相關屬性與場景對應的YAML文件之間的序列化與反序列化
- Scene里利用
b2World
更新物理場景,更新完后,更新帶Rigidbody2DComponent的GameObject的Transform(感覺這個過程應該在渲染Update之前)
Universally Unique Identifiers (UUID/GUID)
思考一個問題,在進入PlayMode再退出之后,怎么回到原本的場景:
- 最暴力的做法,重新load一下場景文件
資產的GUID不能用path,因為它們的路徑是會改變的,而且存字符串性能也不好;也不適合用那種從0開始的,每創建一次就+1的global id,這是因為不同的人可能同時在創建新文件,這樣做在資產合并之后會有GUID沖突;所以這里決定用random hash,就跟git commit hash一樣,杜絕重名的GUID
比如git commit hash為2b621d3e7eee447057bb974fab80aad4193e5389
,這里面有40個16進制的數字,每個數字有4個bit,代表半個字節,一共就是20個字節的hash值,git還有個short hash,也就是這里的前7位2b621d3
,一般16^7這么大范圍的數字作為hash就足夠了,這里的引擎沒有那么大的體量,選擇使用8個字節來存hash,也就是2 ^ 64 = 16 ^ 16
,對應類型為uint64_t
具體步驟為:
- 隨機數生成64位的hash,即HashFunction,雖然各個Platform有提供自己的算法,這里還是統一使用std的random庫
- 創建UUID類,static涉及到thread safe,但GUID的創建應該只會在main thread里進行,設置static set避免沖突
- 把UUID實裝到GameObject(視頻里叫Entity)上
std提供的隨機數生成算法
只是記錄下寫法:
#include <random>
#include <iostream>static std::random_device s_RandomDevice;
static std::mt19937_64 s_Engine(s_RandomDevice());
static std::uniform_int_distribution<uint64_t> s_UniformDistribution;int main()
{for (size_t i = 0; i < 10; i++){uint64_t rab = s_UniformDistribution(s_Engine);std::cout << rab << std::endl;}std::cin.get();
}
print為:
16594018988857477878
3025682643124843386
10762836072128041903
...
Playing and Stopping Scenes (and Resetting)
為了在進入和退出Play Mode后,還原場景本身,這里我認為有兩種比較實用的做法:
- 區分Editor Scene和Runtime Scene(準確的說是PlayMode Scene),Editor下編輯的是Editor Scene,在點擊Play進入Play Mode時,拷貝一份Editor Scene作為Runtime Scene。會有一個Scene的指針代表CurrentActiveScene,PlayMode下指向Runtime Scene,Editor下指向Editor Scene
- 在Editor下編輯Scene時,創建一個Cache,作為保存Scene的文本文件。當點擊Play后,開始執行Runtime的邏輯,Stop Scene回到Editor狀態下后,再Load一次Cache的Scene文件即可,當Save Scene時,該Cache文件可以直接覆蓋原本的Scene文件
第二種方法是我自己想的,感覺寫起來比較方便,但是涉及到了頻繁的IO,如果場景變復雜了,可能會很卡,所以還是選擇第一種做法,也是Cherno的做法,感覺挺復雜的,提供了Scene、Component和GameObject的Copy操作,然后在點擊Play時,復制出Runtime用的Scene
注意在復制Scene時,只復制Scene的初始狀態(即需要被序列化的部分),因為實際游戲里的Scene可能會非常復雜,里面可能會有很多腳本Spawn出來的對象,所以只記錄初始狀態,讓其自己去Tick,是比較合理的
那么怎么復制一個場景呢?這里的需求應該是:
- 復制的Runtime Scene的數據與Editor Scene完全相同,GameObject的GUID也完全相同
- Runtime Scene下,不管怎么鼓搗,都不會影響Editor下的對象,這意味著兩個類的數據成員都是獨有的,不共享
目前Scene的數據成員有:
entt::registry m_Registry
uint32_t m_ViewportWidth = 0, m_ViewportHeight = 0;
b2World* m_PhysicsWorld = nullptr;
要從Editor Scene復制出一個新的Runtime下隨便折騰的Scene,還不能影響Editor Scene的內容,相對于要對原本的Scene做一個Deep Copy的操作。所以肯定是不能直接Copy registry
和b2World
對象的,新的registry會影響Editor Scene,所以這里的Copy Scene的做法跟反序列化一個場景很像,無非反序列化是從文本里讀取信息,而這里是從原場景里讀取信息。
這樣一看思路就比較清晰了:
- 設計
CopyComponent
函數 - 設計
CopyGameObject
函數,里面會調用各個Component的CopyComponent
函數 - 設計
CopyScene
函數,里面調用CopyGameObject
函數
Copy Component
很久沒寫這塊的代碼了,先來回顧下Component
相關代碼:
// 目前的Component基類,基本就是簡單成了這個樣子
class Component
{// 記錄的是此Component對應的Instance在Scene(register)里的Id, 是ECS系統里的唯一標識// 派生類復制的時候應該走的是CopyCtoruint32_t InstanceId = 0;
};class GameObject
{
public:template<class T, class... Args>T& AddComponent(Args&& ...args){auto& com = m_SceneRegistry.emplace<T>(m_InstanceId, std::forward<Args>(args)...);com.InstanceId = (uint32_t)m_InstanceId;return com;}private:entt::entity m_InstanceId;// entt::entity就是std::uint32_tentt::registry m_SceneRegistry;std::shared_ptr<UUID> m_ID;
}
有了這個就好說了,CopyComponent的時候,任務交給Component類的復制構造函數即可,模板函數如下:
// 用于把src里entity的某種Component復制到dst的entity上
template<typename Component>
static void CopyComponent(entt::registry& dst, entt::registry& src)
{// 獲取所有帶有Component的entity數組auto view = src.view<Component>();for (auto e : view){// 保證Component對應的Entity()的ID與原來的相同即可auto& component = src.get<Component>(e);dst.emplace_or_replace<Component>(component.InstanceId, component);}
}
Copy Scene
代碼如下:
// 大體是new一個scene, 基于原本的UUID, new出每個Entity, 再逐一為Component調用Copy操作
// 為復制得到的Entity添加Component
std::shard_ptr<Scene> Scene::Copy(std::shard_ptr<Scene> other)
{std::shard_ptr<Scene> newScene = CreateRef<Scene>();newScene->m_ViewportWidth = other->m_ViewportWidth;newScene->m_ViewportHeight = other->m_ViewportHeight;auto& srcSceneRegistry = other->m_Registry;auto& dstSceneRegistry = newScene->m_Registry;// 創建一個臨時map, 存儲新創建的帶UUID的Entitystd::unordered_map<UUID, entt::entity> enttMap;// Create entities in new sceneauto idView = srcSceneRegistry.view<IDComponent>();for (auto e : idView){UUID uuid = srcSceneRegistry.get<IDComponent>(e).ID;const auto& name = srcSceneRegistry.get<TagComponent>(e).Tag;Entity newEntity = newScene->CreateEntityWithUUID(uuid, name);enttMap[uuid] = (entt::entity)newEntity;}// Copy components (except IDComponent and TagComponent)CopyComponent<TransformComponent>(dstSceneRegistry, srcSceneRegistry, enttMap);CopyComponent<SpriteRendererComponent>(dstSceneRegistry, srcSceneRegistry, enttMap);CopyComponent<CameraComponent>(dstSceneRegistry, srcSceneRegistry, enttMap);CopyComponent<NativeScriptComponent>(dstSceneRegistry, srcSceneRegistry, enttMap);CopyComponent<Rigidbody2DComponent>(dstSceneRegistry, srcSceneRegistry, enttMap);CopyComponent<BoxCollider2DComponent>(dstSceneRegistry, srcSceneRegistry, enttMap);return newScene;
}
去掉Component之間的繼承關系
參考:Iterating through components with common base class or via ducktype
在Copy Scene時遇到了一個操作,我想給所有的Component添加Awake函數(類似于Unity里的Awake
和UE里的BeginPlay
函數),這個函數只在Play Mode下被調用。目前我的類繼承邏輯是這樣的:
class Component
{
public:Component() = default;virtual ~Component() = default;virtual void Awake() {}uint32_t InstanceId = 0;
};class CameraComponent : public Component...
class Rigidbody2D : public Component...
class Transform : public Component...
現在我需要在進入PlayMode時,調用所有Component的Awake函數,我是這么寫的:
auto& view = m_Registry.view<Component>();
for (auto& entity : view)
{Component* com ref = view.get<Component>(entity);com->Awake();
}
實際代碼里,發現返回的view
數組為空,這意味著entt的view函數只能返回派生類類型的對象,這么寫就不會有問題:
auto& view = m_Registry.view<CameraComponent>();
看了下相關論壇的解釋,發現entt是不希望Component之間存在繼承關系的,因為這樣會有悖ECS的設計理念,它對于Component有兩個要求:
- 每個帶有數據的Component都應該有單獨的final type
- 每個Component里的數據應該存的是value,而不是指針或引用
這樣是為了Cache Friendly,每個相同的Component類的Data都放到同一個內存池了,當遍歷場景里的同一種Component時,會只在同一片內存區域上操作,Component不存指針和引用也是為了避免內存訪問的跳轉;同時,這里的虛函數調用也會給CPU造成額外的性能消耗(Another thing your CPU really likes, is predictable code to execute. It tries to predict and prepare upcoming code. It also has some local space for instructions as well
),所以理想的entt代碼應該是這樣的:
Struct TextDrawable {
sf::Text text;
};struct SpriteDrawable {
sf::Sprite sprite;
};//and you simple iterate them separately:
registry.view().each([&renderTarget](const TextDrawable& rDrawable){
renderTarget.draw(rDrawable);
});registry.view().each([&renderTarget](const SpriteDrawable& rDrawable){
renderTarget.draw(rDrawable);
});
看了下Cherno寫的,確實沒用到繼承關系,類聲明都寫到了一個Component.h里,方便后續遍歷
順便看了下倆常用游戲引擎里的Component設計,發現它們還是存在著繼承關系的,比如:
// 雖然UObject里沒有任何數據, 都是接口, 但這里的UActorComponent里面是有數據的
class UMovementComponent : public UActorComponent : (public UObject, public IInterface_AssetUserData)
這也正說明了倆引擎是EC架構,不是嚴格的ECS架構,因為相同的Component的Data不是完全存放到一起去的
暫時還沒想好要不要這么改,后面再說吧
Using C# Scripting with the Entity Component System
前兩課基本都是搭建基礎建設,使得在引擎腳本層的C#和引擎本身的C++之間的api可以相互調用,這節課主要是為了在引擎里的應用,比如這兩個操作:
- C++暴露創建GameObject的接口,用戶可以從C#里調用它來創建GameObject
- 添加ScriptComponent,在C#里使用WASD鍵位來實現場景里Quad的移動
我的理解是,要做的方法跟Unity是類似的。添加按鈕,點擊的時候出現一個TextBox,然后輸入,會創建對應的類在項目里,然后C++里會定義去查詢C#里的特殊名字的函數,為其調用mono的internal call函數,應該會有一個集合把這些函數都存起來,再在C++引擎特定loop的地方,調用這些函數
Finishing The Pull Request
enum class
enum class有個不太好的地方,它不可以直接參與位運算,需要重載它的相關運算符,而enum可以,其實可以用下面這種寫法,用enum模擬enum class:
-
Hazel未來想要完全從C#里調用C++的代碼,而不是像Unity這樣,要一個個接口暴露出來這么麻煩。因為Unity為了閉源,是把C++代碼提供成dll的,而Hazel的C#是直接有C++的源碼工程的
-
往Transform組件里加Cache數據并不好,一方面是加內存占用,更重要的是一堆Transform數組會在Cache里造成Cache miss,因為帶來了很多無效數據
Rendering Circles in a Game Engine
有幾種取巧的辦法:
- 繪制正多邊形近似圓
- 使用圓形貼圖模擬,這種情況下其實繪制的是quad
一般游戲里,如果是Debug用的東西,比如Gizmos,那么可以用多邊形模擬圓,但是Runtime用的東西,比如角色吃的甜甜圈,還是比較適合直接繪制出真正的圓
這里主要講的是渲染Circles,其實不需要具體的geometry,可以通過Shader算法來實現,其實挺簡單的,就是利用UV坐標做文章,繪制一個圓時,需要知道它的圓心對應的UV坐標,然后再給定一個半徑對應的UV坐標長度R即可,那么每次繪制像素時,如果其UV坐標到中心UV坐標的長度小于R,即繪制點,即可
這里可以使用Shader Toy幫助快速看效果,如下圖所示,用左下角的(0,0)點為圓心,繪制的圓,裁剪后就剩四分之一部分:
由于UV坐標都在[0,1]區間,但是這里的屏幕長寬比不同,所以要根據ratio調整一下UV坐標,這里把U坐標按比例增大,再改一下圓心坐標為屏幕中心的(0.5, 0.5),即可:
附錄
raw pointer轉換成smart pointers的問題
參考:Creating shared_ptr from raw pointer
首先回顧一下相關語法,對于raw pointer轉換成shared_ptr
時,寫法為:
classA* raw_ptr = new classA;// 一定要記住, 后續不能再使用raw_ptr// 如果是聲明加定義shared_ptr
shared_ptr<classA> my_ptr(raw_ptr);// 這個過程會析構classA么// 如果只是給shared_ptr賦值
my_ptr.reset(raw_ptr);// 這個過程會析構classA么
順便說一句,這里的三行代碼,只有第三行會調用classA的析構函數,因為reset函數會把原本的對象析構,再重新賦值,不過此時的raw_ptr已經被析構了,再去給my_ptr
賦值的話,此時的my_ptr
里面會存一個野指針,總之,raw pointer轉換成shared_ptr
不涉及對象的析構過程,但是shared_ptr.reset
賦值方法會調用原本存儲對象的析構函數
上面的寫法其實并不好,這樣寫更好:
// 避免創建一個指針變量, 讓其他代碼使用, 這樣從根本上避免了raw pointer的問題
shared_ptr<classA> my_ptr(new classA);
raw pointer轉換成unique_ptr
的情況,也是類似的:
classA* raw_ptr = new classA;
std::unique_ptr<classA> my_ptr(raw_ptr);classA* raw_ptr2 = new classA;
my_ptr.reset(raw_ptr2);
至于weak_ptr
,它不能直接通過raw pointer轉過來,因為weak_ptr
必須基于shared_ptr
或其他的weak_ptr
,參考Creating weak_ptr<> from raw pointer
Intrusive Reference Counting
參考:invasive vs non-invasive ref-counted pointers in C++
Intrusive reference counters are atomic integers embedded inside data object which tell how many times in the program the object is being used. As soon as the reference counter reaches value 0 , the object is deleted
When counter is stored inside body class, it is called intrusive reference counting and when the counter is stored external to the body class it is known as non-intrusive reference counting.
引用計數寫在對象類內的叫做intrusive(或Invasive) reference counting,否則為non intrusive reference counting
關于MSAA(抗鋸齒)
跟Cherno上的課沒有太大關系,純粹是因為我看到屏幕里繪制的圖形存在鋸齒,看上去很不舒服,所以決定開的抗鋸齒。
這個過程比較復雜,踩了很多坑,就不多說了,記錄下幾個重點:
ImGui::Image
需要輸出Textuer2D類型的id,而不支持Texture2DMultiSample,應該單獨寫一個framebuffer,專門用于Resolve MSAA的framebuffer輸出的MSAA的Texture Attachment和Render Object- OpenGL里同一個framebuffer不可以輸出不同類型的Texture Attachment或Render Object,要么都是MSAA,要么都不是MSAA
- 學習使用Render Doc可以很好的排查這些問題
最后的效果差異如下圖所示:
解決Z Depth的Bug
參考:Depth testing
參考:Framebuffers
參考:Advanced GLSL
目前遇到了一個以為比較棘手的Bug,渲染的深度測試功能完全失效了,仔細研究了下,發現它是按照物體的渲染順序來的,即第一個出現在Hierarchy里的永遠最后被畫,永遠不會被其他物體所遮擋。
為了確認我到底是哪里出了問題,我決定先把Depth數據繪制出來,把Shader改成了如下所示:
void main()
{// 原本的不管out_color = texture(u_Texture[v_TexIndex], v_TexCoord * v_TilingFactor) * v_Color;out_InstanceId = v_InstanceId;out_color = vec4(vec3(gl_FragCoord.z,gl_FragCoord.z,gl_FragCoord.z), 1.0);
}
這里的gl_FragCoord
是OPENGL在fs里提供的內置變量,其xy值代表片元的屏幕坐標,z值代表繪制的primitive對應片元的深度值(注意并不是Depth buffer里對應位置的深度值),其值所在的區間為[0, +Infinity)
,由于繪制的時候,z值超過1的都是白色了,所以我拉的比較近,如下圖所示:
可以看到,較近的顏色較暗,然而較遠的顏色是白的,這說明我的Depth值應該是沒問題的
仔細查了下,發現是framebuffer沒有添加depth attachment的緣故,因為我是把場景用fbo渲染出一張貼圖的,對應的depth attachment也應該加上,MSAA和正常的fbo繪制寫法稍有不同,如下所示:
// 正常FBO的寫法
// create a renderbuffer object for depth and stencil attachment (we won't be sampling these)
unsigned int rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, spec.width, spec.height); // use a single renderbuffer object for both a depth AND stencil buffer.
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo); // now actually attach it
m_RboAttachmentIndices.push_back(rbo);// MSAA的FBO的寫法
unsigned int rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, spec.width, spec.height); // use a single renderbuffer object for both a depth AND stencil buffer.
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo); // now actually attach it
關于ImDrawList::AddCallback()
AddCallback() just registers a function and parameter that your imgui renderer loop will call. The order within a same window/drawlist are preserved, so if you add 1000 triangles and then add a callback, then 1000 triangles again, your renderer will see them in that order.
AddCallback其實就是傳入一個DrawCall的function指針,隨后ImGui會在對應的位置調用此函數,
When should I set GL_TEXTURE_MIN_FILTER and GL_TEXTURE_MAG_FILTER?
參考:https://learnopengl.com/Getting-started/Textures
這里回顧一下貼圖在OpenGL里的參數設置,原本繪制的Viewport貼圖的代碼為:
{...GLuint textureId;glGenTextures(1, &textureId);glBindTexture(GL_TEXTURE_2D, textureId);// R32I應該是代表32位interger, 意思是這32位都只存一個integerglTexImage2D(GL_TEXTURE_2D, 0, GL_R32I, spec.width, spec.height, 0, GL_RED_INTEGER, GL_UNSIGNED_BYTE, NULL);glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_NEAREST);glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_NEAREST);// 下面這兩行代碼是必須的, 否則會黑屏glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
}
前面兩行比較簡單,屬于Texture Wrapping,是當UV坐標超過[0, 1]范圍時,應該如何Mapping UV坐標的問題,可以用Map,也可以用Clamp。但是這里的GL_TEXTURE_MIN_FILTER
和GL_TEXTURE_MAG_FILTER
就不太理解了,看了下文檔,這兩行屬于Texture Filtering,處理的是當輸入貼圖大小與輸出的貼圖大小不匹配時的選項,這里分為好幾種情況:
- 輸入貼圖的長和寬均大于輸出貼圖的長和寬
- 輸入貼圖的長和寬均小于輸出貼圖的長和寬
- 輸入貼圖和輸出貼圖各有一個尺寸更長(不太清楚這種情況下的計算)
這里的邏輯是,這里會根據屏幕上要繪制的像素點的中心坐標,得到相對于屏幕的XY值(在[0, 1]區間),即為采樣貼圖的UV坐標,由于貼圖上也是一個個的Texel,所以采樣的時候可以直接選擇Texel點(GL_LINEAR),或者根據周圍的四個Texel點進行雙線性插值一下(GL_NEAREST),如下圖所示:
至于這里的GL_TEXTURE_MIN_FILTER
和GL_TEXTURE_MAG_FILTER
,其實是把這種GL_LINEAR
和GL_NEAREST
的使用情況更細分一下而已:
GL_TEXTURE_MIN_FILTER
意味著輸入貼圖尺寸大于輸出的貼圖尺寸,這意味著貼圖變小GL_TEXTURE_MAX_FILTER
意味著輸入貼圖尺寸小于輸出的貼圖尺寸,這意味著貼圖變大
有比較好的思路是在貼圖變大時使用線性效果,而貼圖變小時使用插值效果:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
回到這個問題本身,為啥沒有設置這兩行會黑屏,我猜是因為framebuffer的貼圖尺寸會改變,必須設置這個的原因吧,查了下,這兩行對于任何Texture2D貼圖來說,應該都是必要的(Texture Wraping不是必要的):
For standard OpenGL textures, the filtering state is part of the texture, and must be defined when the texture is created.
這里還有個問題,就是MultiSample Texture如何設置Texture Wraping和Filtering?參考:Creating Multisample Texture Correctly
得到的結論是,由于MultiSample Texture就是帶了多個Sampler的Texture,比如它會對一個Texel的四個角進行采樣,然后融合,所以它本身就是GL_NEAREST
類型的參數,相當于:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
由于所有的MultiSample Texture都是這么設置的,所以OpenGL就直接省略讓人設置的環節了,如果像下面這么寫:
glGenTextures(1,&_texture_id);
glBindTexture(GL_TEXTURE_2D_MULTISAMPLE,_texture_id);
glTexParameterf(GL_TEXTURE_2D_MULTISAMPLE,GL_TEXTURE_MIN_FILTER,GL_NEAREST);
glTexParameterf(GL_TEXTURE_2D_MULTISAMPLE,GL_TEXTURE_MAG_FILTER,GL_NEAREST);
glTexImage2DMultisample(...);
應該會報錯:
GL_INVALID_ENUM error generated. multisample texture targets doesn’t support sampler state
glDrawBuffers函數復習
之前代碼里調用過這個函數,現在給忘了咋用了,這里復習一下,代碼如下:
OpenGLFramebuffer::OpenGLFramebuffer(const FramebufferSpecification& spec) : Framebuffer(spec.width, spec.height)
{glGenFramebuffers(1, &m_FramebufferId);glBindFramebuffer(GL_FRAMEBUFFER, m_FramebufferId);// 創建倆Color Attachment ...HAZEL_CORE_ASSERT((bool)(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE), "Framebuffer incomplete");// 目前每個Camera只output兩張貼圖, 第一張代表Viewport里的貼圖, 第二張代表InstanceID貼圖const GLenum buffers[]{ GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 };glDrawBuffers(2, buffers);
}
問題是:
- glDrawBuffers是不是DrawCall,如果是的話,為啥會在fbo的ctor里調用,而不是在loop里被調用?
- glDrawBuffers的用法
參考:https://www.reddit.com/r/opengl/comments/11sz3yf/does_gldrawbuffer_permanently_alter_the_bound/
參考:https://stackoverflow.com/questions/51030120/concept-what-is-the-use-of-gldrawbuffer-and-gldrawbuffers
glDrawBuffers specifies a list of color buffers to be drawn into.
看了下,這個函數應該不是DrawCall,只是負責開啟和關閉FBO上的Color Attachment而已,開啟后可供shader寫入
glDrawElementsBaseVertex報Access violation reading location錯
glDrawElementsBaseVertex
與glDrawElements
差不多,可以先回顧下glDrawElements
函數:
// 函數簽名
// indices: Specifies a byte offset (cast to a pointer type) into the buffer bound to GL_ELEMENT_ARRAY_BUFFER? to start reading indices from.
void glDrawElements(GLenum mode, GLsizei count, GLenum type, const void * indices);glClearColor(0.1f, 0.1f, 0.1f, 1);
glClear(GL_COLOR_BUFFER_BIT);
// DrawCall調用, 在調用此函數前, 需要綁定Index Buffer
glDrawElements(GL_TRIANGLES, m_QuadVertexArray->GetIndexBuffer()->GetCount(), GL_UNSIGNED_INT, nullptr);
glDrawElementsBaseVertex
的簽名如下,相較于glDrawElements
,只多了一個int參數:
void glDrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, void *indices, GLint basevertex);
這里都需要傳入的指針,是代表的indices數組里的指針,比如說我有六個頂點,和一個頂點數組:
float positions[] =
{-0.5f, -1.0f, 0.0f,-1.5f, 1.0f, 0.0f,-2.5f, -1.0f, 0.0f,2.5f, -1.0f, 0.0f,1.5f, 1.0f, 0.0f,0.5f, -1.0f, 0.0f
};uint8 indices[] =
{0, 1, 2,3, 4, 5
};
通過傳入合適的指針,可以只繪制部分頂點,比如:
// 直接繪制第二個三角形
glDrawElements(/* mode = */ GL_TRIANGLES,/* count = */ 3,/* type = */ GL_UNSIGNED_BYTE,/* offset = */ (void*)( sizeof( uint8 ) * 3 ) );
PDB文件
參考:https://learn.microsoft.com/en-us/visualstudio/debugger/specify-symbol-dot-pdb-and-source-files-in-the-visual-studio-debugger?view=vs-2022
PDB,全程program database,文件后綴為.pdb
,也叫symbol files,它負責處理下面二者之間的映射:
- 源代碼里的identifier或statement
- 編譯后的APP里的identifiers或instruction
正是因為此特性,有了PDB文件的存在,才能把debugger和源碼link到一起,從而實現基于代碼的調試
查看lib里的object文件依賴的pdb路徑
參考:https://stackoverflow.com/questions/25843883/how-to-remove-warning-lnk4099-pdb-lib-pdb-was-not-found
先把lib文件用7Zip解壓,然后找到對應的object文件,然后打開VS對應的Developer Command Prompt For VS2022,cd到對應目錄,執行以下命令輸出到AAA.txt文件即可
C:\dev\scaler\center\agent\thirdparty\libcurl\win\lib>dumpbin /section:.debug$T /rawdata rc2_cbc.obj > AAA.txt
打開AAA文件即可看到里面依賴的PDB文件的路徑,如下圖所示:
解決所有的Warnings
目前分為以下幾種Warning,會仔細分析后面幾個復雜點的Warning
- 類型轉換提示的loss of data,主要是把uint32_t轉成指針類型,在64位機器上會把32位的整型轉成64位的整型,會給警告
- 返回對象引用的函數返回了臨時變量,比如函數
GameObject& Func(){ return GameObject(); }
- warning LNK4006
second definition ignored
- warning LNK4099: PDB ‘’ was not found with ‘Hazel.lib(sgen-fin-weak-hash.obj)’ or at ‘’; linking object as if no debug info
warning LNK4006
關于Warning LNK4006,詳細信息為:
Ws2_32.lib(WS2_32.dll) : warning LNK4006: __NULL_IMPORT_DESCRIPTOR already defined in opengl32.lib(OPENGL32.dll); second definition ignored
Winmm.lib(WINMM.dll) : warning LNK4006: __NULL_IMPORT_DESCRIPTOR already defined in opengl32.lib(OPENGL32.dll); second definition ignored
這里的Ws2_32.lib
是系統在C:\Program Files (x86)\Windows Kits
下環境自帶的文件,基于如何判斷lib文件是static lib還是import lib,我用解壓軟件打開看了一下,里面一堆dll
文件,說明它是import lib,比如對應的ws2_32.dll
會出現在C:\Windows\System32
文件夾下。關于這個警告,我看了下Linker warnings LNK4006 and LNK4221和Stop Warning: __NULL_IMPORT_DESCRIPTOR這兩篇文章,得到的結論大概是:
由于我現在的HazelEditor依賴于Hazel項目,前者會build出一個exe(HazelEditor.exe),后者會build出一個靜態lib文件(Hazel.lib),HazelEditor.exe只依賴Hazel.lib,而后者又為了支持Mono,依賴了Ws2_32.dll
和Winmm.dll
這些系統的庫。重點就在于,我這個static lib
的Hazel項目是不應該依賴一個dll的,這樣當我最終build出exe時,會有兩份相同的Ws2_32.dll
,一份來自于依賴的Hazel.lib(應該是靜態lib會拷貝所有內容的緣故),另一份來自HazelEditor.exe
,這樣就會重復了(也可能是Hazel.lib里多個靜態lib都依賴于這個Ws2_32.dll的緣故)。
我把Hazel對應Project對這些系統lib的依賴挪到HazelEditor項目就可以了。
warning LNK4099
提示為:
warning LNK4099: PDB '' was not found with 'Hazel.lib(sgen-fin-weak-hash.obj)' or at ''; linking object as if no debug info
意思是找不到對應的PDB文件,如下圖所示
關于PDB(Program database)文件就不多說了,它是在Debug Configuration下build出Binary時會附帶的文件,它包含了源碼的變量和指令與實際執行的APP里變量和指令的mapping。這里的Hazel.lib作為靜態庫文件,里面其實包含了一堆object文件,如下圖所示,是我用解壓文件對它進行操作時的樣子:
解壓后能在對應的文件里看到我很多類編譯出的.obj
文件,如下圖所示:
仔細看了了對應lib里報錯的object文件依賴的pdb路徑,發現本機上確實沒有原本build出來的PDB文件了(因為我改動了盤的位置),要解決這個問題,應該不難,做法感覺比較多:
- 要么考慮直接更改object里依賴的PDB路徑
- 考慮更新lib庫,本機上是OK的
- 把PDB文件也提交上去,然后依賴時使用相對路徑,這樣每臺電腦上都可以
- 寫代碼禁用此類warning,畢竟也不會去debug這些Mono的庫
fatal error LNK1127: library is corrupt
用自己的筆記本電腦打開項目的時候報了這個錯:
1>C:\Program Files (x86)\Windows Kits\10\lib\10.0.22000.0\um\x64\opengl32.lib : fatal error LNK1127: library is corrupt
本來是用VS2022打開的項目,但是我筆記本電腦上下載錯了版本,導致VS2022專業版試用過期了,所以我換成了VS2017,重新Build出來sln后打開編譯,就有這個錯了
看了下這個庫文件,屬于Windows SDK里提供的庫文件,在我對應項目的Properties->Librarian->General的Additional Dependencies里設置了對應的依賴:
看了下本機的同名文件路徑,發現確實有好幾個版本,177開頭的應該是VS2017用的SDK,220開頭的則是VS2022用的SDK:
發現右鍵點擊對應項目,點擊Retarget Projects,降級到VS2017對應的SDK就可以了,之前使用Premake5.exe重新創建sln的過程居然沒改SDK的版本,需要這里手動改下
UE與EnTT的關系
參考:Upcoming ECS in UE5 (Mass)
參考:EnTT and Unreal Engine
其實沒啥關系,UE本身是EC架構的引擎,跟Unity一樣,近些年Unity提出了ECS對應的DOTS系統,旨在把游戲轉成ECS架構,UE5也提出了類似的理念,即Mass系統,這個系統目前不知道有沒有完全成型,但是在此之前,如果想要在UE的項目里自行實現ECS架構,很多人會選擇使用EnTT庫
這里介紹下在UE里使用EnTT的方法,截至UE4.25,其C++版本是C++14,但是EnTT最低需要使用C++17的版本,為了在項目里用它,需要手動升級項目里C++的版本,需要在對應項目的Build.cs里加上:
using UnrealBuildTool;public class MyProject : ModuleRules
{public MyProject(ReadOnlyTargetRules Target) : base(Target){PCHUsage = PCHUsageMode.NoSharedPCHs;PrivatePCHHeaderFile = "<PCH filename>.h";CppStandard = CppStandardVersion.Cpp17;PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore" });PrivateDependencyModuleNames.AddRange(new string[] { });}
}
最后再為EnTT創建單獨的第三方庫Module加進來即可