Hazel引擎学习(十二)

我自己维护引擎的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);
    // 打印更新后的Transform
    b2Vec2 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 registryb2World对象的,新的registry会影响Editor Scene,所以这里的Copy Scene的做法跟反序列化一个场景很像,无非反序列化是从文本里读取信息,而这里是从原场景里读取信息。

这样一看思路就比较清晰了:

  • 设计CopyComponent函数
  • 设计CopyGameObject函数,里面会调用各个Component的CopyComponent函数
  • 设计CopyScene函数,里面调用CopyGameObject函数

Copy Component

很久没写这块的代码了,先来回顾下Component相关代码:

// 目前的Component基类,基本就是简单成了这个样子
class Component
{
	// 记录的是此Component对应的Instance在Scene(register)里的Id, 是ECS系统里的唯一标识
	// 派生类复制的时候应该走的是CopyCtor
	uint32_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_t
	entt::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的Entity
	std::unordered_map<UUID, entt::entity> enttMap;

	// Create entities in new scene
	auto 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位都只存一个integer
	glTexImage2D(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_FILTERGL_TEXTURE_MAG_FILTER就不太理解了,看了下文档,这两行属于Texture Filtering,处理的是当输入贴图大小与输出的贴图大小不匹配时的选项,这里分为好几种情况:

  • 输入贴图的长和宽均大于输出贴图的长和宽
  • 输入贴图的长和宽均小于输出贴图的长和宽
  • 输入贴图和输出贴图各有一个尺寸更长(不太清楚这种情况下的计算)

这里的逻辑是,这里会根据屏幕上要绘制的像素点的中心坐标,得到相对于屏幕的XY值(在[0, 1]区间),即为采样贴图的UV坐标,由于贴图上也是一个个的Texel,所以采样的时候可以直接选择Texel点(GL_LINEAR),或者根据周围的四个Texel点进行双线性插值一下(GL_NEAREST),如下图所示:
在这里插入图片描述
至于这里的GL_TEXTURE_MIN_FILTERGL_TEXTURE_MAG_FILTER,其实是把这种GL_LINEARGL_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错

glDrawElementsBaseVertexglDrawElements差不多,可以先回顾下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.dllWinmm.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加进来即可

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/230174.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

做数据分析为何要学统计学(5)——什么问题适合使用t检验?

t检验&#xff08;Students t test&#xff09;&#xff0c;主要依靠总体正态分布的小样本&#xff08;例如n < 30&#xff09;对总体均值水平进行差异性判断。 t检验要求样本不能超过两组&#xff0c;且每组样本总体服从正态分布&#xff08;对于三组以上样本的&#xff0…

基于深度学习yolov5实现安全帽人体识别工地安全识别系统-反光衣识别系统

欢迎大家点赞、收藏、关注、评论啦 &#xff0c;由于篇幅有限&#xff0c;只展示了部分核心代码。 文章目录 一项目简介 二、功能三、系统四. 总结 一项目简介 实现安全帽人体识别工地安全识别系统需要使用深度学习技术&#xff0c;特别是YOLOv5算法。下面是对基于YOLOv5实现安…

Jenkins部署python接口自动化测试

一、点击新建Item 二、指定源码和分支 私钥位置&#xff1a;C:\Users\Administrator\.ssh 文件下 三、构建脚本编写 四、构建后操作 指定输出的allure 结果目录 总结&#xff1a; 感谢每一个认真阅读我文章的人&#xff01;&#xff01;&#xff01; 作为一位过来人也是希望…

增强现实中的真实人/机/环与虚拟人/机/环

在增强现实中&#xff0c;真实人与虚拟人、真实机器与虚拟机器、真实环境与虚拟环境之间有着密切的关系。增强现实技术通过将真实与虚拟相结合&#xff0c;打破了传统的现实世界与虚拟世界的界限&#xff0c;创造出了一种新的体验方式。真实人、真实机器和真实环境与其对应的虚…

数据分析基础之《matplotlib(5)—直方图》

一、直方图介绍 1、什么是直方图 直方图&#xff0c;形状类似柱状图却有着与柱状图完全不同的含义。直方图牵涉统计学的概念&#xff0c;首先要对数据进行分组&#xff0c;然后统计每个分组内数据元的数量。在坐标系中&#xff0c;横轴标出每个组的端点&#xff0c;纵轴表示频…

SugarCRM 任意文件上传漏洞复现(CVE-2023-22952)

0x01 产品简介 SugarCRM是美国SugarCRM公司的一套开源的客户关系管理系统(CRM)。该系统支持对不同的客户需求进行差异化营销、管理和分配销售线索,实现销售代表的信息共享和追踪。 0x02 漏洞概述 SugarCRM index.php接口存在安全漏洞,该漏洞源于安装组件中存在授权绕过和P…

spark链接hive时踩的坑

使用spark操作hive&#xff0c;使用metastore连接hive&#xff0c;获取hive的数据库时&#xff0c;当我们在spark中创建数据库的时候&#xff0c;创建成功。 同时hive中也可以看到这个数据库&#xff0c;建表插入数据也没有问题&#xff0c;但是当我们去查询数据库中的数据时&a…

Springboot+AOP+注解实现字段AES+Base64加解密

AOP实现AESBASE64加解密 场景如下&#xff1a; 需要对数据库存储的字段&#xff0c;进行加解密的处理。如果都直接写代码的话&#xff0c;那么代码回冗余很多&#xff0c;所以使用AOP注解去实现。让代码简洁&#xff0c;方便 具体实现如下&#xff1a; 1、依赖 <depende…

微信小程序 bindtap 事件多参数传递

在微信小程序中&#xff0c;我们无法直接通过 bindtap"handleClick(1,2,3)" 的方式传递参数&#xff0c;而是需要通过自定义属性 data- 的方式进行传递&#xff0c;并在事件回调函数中通过 event.currentTarget.dataset 来获取这些参数。然而&#xff0c;这种传参方式…

SpringAOP专栏二《原理篇》

上一篇SpringAOP专栏一《使用教程篇》-CSDN博客介绍了SpringAop如何使用&#xff0c;这一篇文章就会介绍Spring AOP 的底层实现原理&#xff0c;并通过源代码解析来详细阐述其实现过程。 前言 Spring AOP 的实现原理是基于动态代理和字节码操作的。不了解动态代理和字节码操作…

1、关于前端js-ajax绕过

1、Ajax知识 、js--Ajax 传统请求跟js--Ajax请求的差别 在实例中用的上js-ajax的有 表单验证&#xff1a; 在用户填写表单时&#xff0c;可以使用 Ajax 在不刷新页面的情况下验证表单字段&#xff0c;并提供即时反馈。 实时搜索&#xff1a; 在搜索框中输入内容时&#xff0…

现代皮质沙发模型材质编辑

在线工具推荐&#xff1a; 3D数字孪生场景编辑器 - GLTF/GLB材质纹理编辑器 - 3D模型在线转换 - Three.js AI自动纹理开发包 - YOLO 虚幻合成数据生成器 - 三维模型预览图生成器 - 3D模型语义搜索引擎 当谈到游戏角色的3D模型风格时&#xff0c;有几种不同的风格&#xf…

元宇宙vr党建云上实景展馆扩大党的影响力

随着科技的飞速发展&#xff0c;VR虚拟现实技术已经逐渐融入我们的日常生活&#xff0c;尤其在党建领域&#xff0c;VR数字党建展馆更是成为引领红色教育新风尚的重要载体。今天&#xff0c;就让我们一起探讨VR数字党建展馆如何提供沉浸式体验&#xff0c;助力党建工作创新升级…

Mybatis XML 多表查询

这篇需结合 <<Mybatis XML 配置文件>>那一篇博客一起看 工作中尽量避免使用多表查询,尤其是对性能要求非常高的项目 我们之前建了个用户表(代码在Mybatis XML配置文件那篇博客里),这次再建一个文章表,代码如下 : -- 创建⽂章表 DROP TABLE IF EXISTS articleinf…

LabVIEW开发远程结构健康监测系统

LabVIEW开发远程结构健康监测系统 工程师依赖于振动监测来评估建筑物、桥梁和其他大型结构的完整性。传统的振动监测工具在数据收集上存在限制&#xff0c;无法长时间收集高保真波形。随着内存存储、处理器速度和宽带无线通信技术的进步&#xff0c;出现了对能够长时间收集并实…

【链表Linked List】力扣-203 移除链表元素

目录 题目描述 解题过程 题目描述 给你一个链表的头节点 head 和一个整数 val &#xff0c;请你删除链表中所有满足 Node.val val 的节点&#xff0c;并返回 新的头节点 。 示例 1&#xff1a; 输入&#xff1a;head [1,2,6,3,4,5,6], val 6 输出&#xff1a;[1,2,3,4,5…

CGAL的3D简单网格数据结构

由具有多个曲面面片的多面体曲面生成的多域四面体网格。将显示完整的三角剖分&#xff0c;包括属于或不属于网格复合体、曲面面片和特征边的单元。 1、网格复合体、 此软件包致力于三维单纯形网格数据结构的表示。 一个3D单纯形复杂体由点、线段、三角形、四面体及其相应的组合…

【c语言指针详解】指针的基本概念和用法

目录 一、指针的基本概念和用法 二、指针运算 2.1 指针的自增和自减运算 2.2 指针的自增和自减运算 三、数组和指针 四、指针和函数 4.1 在函数中使用指针作为参数和返回值 4.1.1 使用指针作为函数参数 4.1.2 使用指针作为函数返回值 4.2 指针参数的传值和传引用特性 4.2.1 指针…

element中el-select多选v-model是对象数组

文章目录 一、问题二、解决三、最后 一、问题 element中的el-select的v-model一般都是字符串或者字符串数组&#xff0c;但是有些时候后端接口要求该字段要传对象或者对象数组&#xff0c;如果再转换一次数据&#xff0c;对于保存配置和回显都是吃力不讨好的事情。如下所示&am…

【投稿】期刊选择

一、期刊影响力评价方法 只要投稿的期刊&#xff0c;被上述三个索引收录&#xff0c;那就说明该期刊的影响力是得到认可的。 二、如何选择合适的期刊 研究工作和目标期刊进行权衡。