Hazel引擎学习(五)

我自己维护引擎的github地址在这里,里面加了不少注释,有需要的可以看看

Render Flow And Submission

背景

在Hazel引擎学习(四),从无到有,绘制出了三角形,然后把相关的VerterBuffer、VertexArray、IndexBuffer进行了抽象化,也就是说目前Application里不会有具体的OpenGL这种平台相关的代码,还剩下一个DrawCall没有进行抽象化,也就是里面的glDrawElements函数,还有相关的glClear和glClearColor没有抽象化。

Renderer Architecture

前面做的抽象化,比如VertexBuffer、VertexArray,这些都是渲染要用到的相关概念的类抽象,真正的跨平台 用于渲染的Renderer类还没有创建起来。

思考一下,一个Renderer需要干什么 它需要Render一个Geometry。Render一个Geometry需要以下内容:

  • 一个Vertex Array,包含了VertexBuffers和一个IndexBuffer
  • 一个Shader
  • 人物的视角,即Camera系统,本质上就是一个Projection和View矩阵
  • 绘制物体的所在的世界坐标,前面的VertexBuffer里记录的是局部坐标,也就是Model(World)矩阵
  • Cube表面的材质属性,wooden或者plastic,金属度等相关属性,这个也可以属于Shader的范畴
  • 环境信息:比如环境光照、比如Environment Map、Radiance Map

这些信息可以分为两类:

  • 环境相关的信息:渲染不同的物体时,环境信息也一般是相同的,比如环境光照、人物的视角等
  • 被渲染的物体相关的信息:不同物体的相关信息很多是不同的,比如VertexArray,也可能部分属性相同(比如材质),这些相同的内容可以在批处理里进行处理,从而优化性能

总结得到,一个Renderer应该具有以下功能

  • 设置环境相关的信息
  • 接受被渲染的物体,传入它对应的数据,比如Vertex Array、引用的Material和Shader
  • 渲染物体,调用DrawCall
  • 批处理,为了优化性能,把相同材质的物体一起渲染等

可以把Renderer每帧执行的任务分为四个步骤:

  • BeginScene: 负责每帧渲染前的环境设置
  • Submit:收集场景数据,同时收集渲染命令,提交渲染命令到队列里
  • EndScene:对收集到的场景数据进行优化
  • Render:按照渲染队列,进行渲染

具体步骤如下:
1. BeginScene
由于环境相关的信息是相同的,所以在Renderer开始渲染的阶段,需要先搭建相关环境,为此设计了一个Begin Scene函数。Begin Scene阶段,基本就是告诉Renderer,我要开始渲染一个场景,然后会设置其周围的环境(比如环境光照)、Camera。

2. Submit
这个阶段,就可以渲染每一个Mesh了,他们的Transform矩阵一般是不同的,依次传给Renderer就可以了,这里会把所有的渲染命令都commit到RenderCommandQueue里。

3. End Scene
应该是在这个阶段,在收集完场景数据后,做一些优化的操作,比如

  • 把使用相同的材质的物体合并到一起(Batch)
  • 把在Frustum外部的物体Cull掉
  • 根据位置进行排序

4. Render
在把所有的东西都commit到RenderCommandQueue里后,所有的Scene相关的东西,现在Renderer都处理好了,也都拥有了该数据,就可以开始渲染了。

整体四个过程的代码大体如下:

// 在Render Loop里
while (m_Running)
{
	// 这个ClearColor是游戏最底层的颜色,一般不会出现在用户界面里,可能用得比较少
	RenderCommand::SetClearColor();// 参数省略
	RenderCommand::Clear();
	RenderCommand::DrawIndexed();
	
	Renderer::BeginScene();// 用于设置Camera、Environment和lighting等
	Renderer::Submit();// 提交Mesh给Renderer
    Renderer::EndScene();
    
	// 在多线程渲染里,可能会在这个阶段用一个另外的线程执行Render::Flush操作,需要结合Render Command Queue
	Renderer::Flush();
	...
}


本课要做的实际内容

上面虽然介绍完了渲染架构,大体上是统一处理物体,然后统一渲染,但是由于目前相关的架构还没搭起来,所以这节课仍然是Bind一个VAO,然后调用一次DrawCall,以后会改进的。这节课的目的还是抽象Application.cpp里的OpenGL相关部分的代码。

目前就剩glClear、glClearColor和DrawCall的代码需要抽象化了,也就是这三句:

glClearColor(0.1f, 0.1f, 0.1f, 1);
glClear(GL_COLOR_BUFFER_BIT);
glDrawElements(GL_TRIANGLES, m_QuadVertexArray->GetIndexBuffer()->GetCount(), GL_UNSIGNED_INT, nullptr);

这些代码,打算把它抽象为:

// 这个ClearColor是游戏最底层的颜色,一般不会出现在用户界面里,用洋红色这种offensive的颜色比较好
RenderCommand::SetClearColor(glm::vec4(1.0, 0.0, 1.0, 1.0));// 直接用glm里的vec4好了
RenderCommand::Clear();
RenderCommand::DrawIndexed();

然后看一下现在之前写好的Renderer和RendererAPI类,非常简陋,只有一个static函数用于表示当前使用的渲染API类型:
在这里插入图片描述

RendererAPI类,除了要有标识当前使用的API类型的函数外,还需要有很多与平台无关的渲染的API,比如清空Buffer、根据Vertex Array进行调用DrawCall等函数,所以这里先把RendererAPI类丰富一下,创建一个RendererAPI.h文件:

class RendererAPI
{
public:
	// 渲染API的类型, 这一块应该由RendererAPI负责, 而不是Renderer负责
	enum class APIType// 把原来的内容移到这里
	{
		None = 0, OpenGL
	};
public:
	// 把相关代码抽象成以下三个接口,放在RenderAPI类里
	virtual void Clear() const = 0;
	virtual void SetClearColor(const glm::vec4&) const = 0;
	virtual void DrawIndexed(const std::shared_ptr<VertexArray>&) const = 0;
};

对于原本的Renderer里的GetAPI函数,它应该由RendererAPI负责, 而不是Renderer负责,这里把它移到RendererAPI里,现在的RendererAPI类就变为了:

class RendererAPI
{
	...
public:
	inline static GetAPIType() const { return s_APIType; }
private:
	static APIType s_APIType;
}

//在RendererAPI的cpp文件里进行初始化:
RendererAPI::APIType RendererAPI::s_APIType = RendererAPI::APIType::OpenGL;

为了方便,也可以在Renderer里提供一个GetAPIType函数,只是底层换成返回RendererAPI类下的GetAPIType函数就行了。

RendererAPI是一个接口类,与平台无关,现在就可以实现OpenGL平台的OpenGLRendererAPI了,Platform文件夹下创建对应的cpp和h文件,跟之前的做法类似,不多说。

接下来,就是实现Renderer类了,目前这个类只实现了一个GetAPIType函数,类的声明如下:

class Renderer
{
public:
	// TODO: 未来会接受Scene场景的相关参数,比如Camera、lighting, 保证shaders能够得到正确的环境相关的uniforms
	static void BeginScene();
	// TODO:
	static void EndScene();
	// TODO: 会把VAO通过RenderCommand下的指令,传递给RenderCommandQueue
	// 目前偷个懒,直接调用RenderCommand::DrawIndexed()函数
	static void Submit(const std::shared_ptr<VertexArray>&);

	inline static RendererAPI::API GetAPIType()	{ return RendererAPI::GetAPIType(); }
};

后面会再去实现成员函数,现在先把类都声明好,还剩一个RenderCommand类了,同样创建一个RenderCommand.h头文件

class RenderCommand
{
public:
	// 注意RenderCommand里的函数都应该是单一功能的函数,不应该有其他耦合的任何功能
	inline static void DrawIndexed(const std::shared_ptr<VertexArray>& vertexArray)
	{
		// 比如这里不可以调用vertexArray->Bind()函数
		s_RenderAPI->DrawIndexed(vertexArray);
	}
	// 同上,再实现Clear和ClearColor的static函数 
	...
private:
	static RendererAPI* s_RenderAPI;
};

可以看到,RenderCommand类只是把RendererAPI的内容,做了一个静态的封装,这样做是为了以后把函数加入到RenderCommandQueue里做的架构设计,也是为了后面的多线程渲染做铺垫。

然后在Renderer的submit函数里实现下面的内容即可:

void Renderer::submit(const std::shared_ptr<VertexArray>& vertexArray)
{
	vertexArray->Bind();
	RenderCommand::DrawIndexed(vertexArray);
}

RenderAPI、Renderer和RenderCommand类的对比

  • Renderer的概念明晰一点,目前主要是准备渲染数据相关的接口。
  • RenderAPI和RenderCommand类有点难区分,RenderCommand类里面应该都是static函数,而且RendererAPI是一个虚基类,具体每个平台都有各自的PlatformRendererAPI类,而RenderCommand类里的static函数会直接调用RendererAPI类,也就是说,RenderCommand和Renderer类都是与Platform无关的,没有子类需要继承他们。

最后的Loop代码结构
这一节课完成后,代码结构是这样的:

void Application::Run()
{
	std::cout << "Run Application" << std::endl;
	while (m_Running)
	{
		// 每帧开始Clear
		RenderCommand::Clear();
		RenderCommand::ClearColor(glm::vec4(1, 0, 1.0, 1));

		Renderer::BeginScene();
		m_BlueShader->Bind();
		Renderer::Submit(m_QuadVertexArray);			// 调用vertexArray->Bind函数
		RenderCommand::DrawIndexed(m_QuadVertexArray);

		m_Shader->Bind();
		Renderer::Submit(m_VertexArray);
		RenderCommand::DrawIndexed(m_VertexArray);

		Renderer::EndScene();

		// Application并不应该知道调用的是哪个平台的window,Window的init操作放在Window::Create里面
		// 所以创建完window后,可以直接调用其loop开始渲染
		for (Layer* layer : m_LayerStack)
			layer->OnUpdate();

		m_ImGuiLayer->Begin();
		for (Layer* layer : m_LayerStack)
			// 每一个Layer都在调用ImGuiRender函数
			// 目前有两个Layer, Sandbox定义的ExampleLayer和构造函数添加的ImGuiLayer
			layer->OnImGuiRender();

		m_ImGuiLayer->End();
		
		// 每帧结束调用glSwapBuffer
		m_Window->OnUpdate();
	}
}


Camera

相机系统的代码框架(architecture)很重要,它决定了游戏引擎能否将更多的时间花在渲染上,从而提高帧数。Camera除了与渲染相关,还与玩家有着交互, 比如User Input、比如玩家移动的时候,Camera往往也需要移动,所以说,Camera既受GamePlay影响,也会被Submit到Renderer做渲染工作,这节课的主要目的是Planning。

Camera本身是一个虚拟的概念,它的本质其实就是View和Projection矩阵的设置,其属性有:

  • 相机的位置
  • 相机的相关属性,比如FOV,比如Aspect Ratio
  • MVP三个矩阵里,M是与模型密切相关的,但是不同模型在同一个相机下,V和P矩阵是相同的,所以说,VP矩阵属于相机的属性

实际渲染时,默认相机都是在世界坐标系原点,朝向-z方向看的,当调整相机属性时,比如说Zoom In的时候,相机的位置并没有变,实际上是整个世界的物体在靠近相机,即往Camera这边平移;当我们向左移动相机的时候,其实没有Camera这个概念,实际上我们是把所有世界的物体向右移,所以,相机的transform变化矩阵与物体的transform变化矩阵正好是互逆的。也就是说,我们可以通过记录相机的transformation矩阵,然后取逆矩阵,就可以得到对应的View矩阵了,这里只需要Position和Rotation,因为相机是没有缩放的。

顶点坐标计算时的归属分配问题
如下图所示,是一个顶点进行计算到屏幕坐标系的过程:

gl_Position = project * view * model * vertPos;

其中,project*view,合称vp矩阵,它应该属于Camera,因为同一个相机里的所有的物体的vp矩阵都是一样的,而model矩阵应该属于物体对应的Object(比如Unity的GameObject),vertPos则属于Mesh上的点。


Camera作为参数传给Renderer的BeginScene函数
具体在代码里的思路是,在游戏的Game Loop里有一个BeginScene函数,这个函数是Renderer的静态函数,会去更新相机、灯光等设置,所以这里的BeginScene函数里需要接受Camera类的对象作为参数,不过这里的作为参数,会有两种做法:

  1. Camera对象作为引用传入BeginScene函数,传入的是引用
  2. Camera对象作值传入BeginScene函数,传入的是值

正常情况下,我思考的肯定是第一种传Camera的方法,但是这里却需要选择第二种,原因就在于,这里是多线程渲染,因为在多线程渲染里,会把函数都放在RenderCommandQueue里执行,这个BeginScene也会放进去,而在RenderCommandQueue里的函数,如果存了相机的引用,是一件比较危险的事情,因为多线程代码里有了camera的引用,camera在多线程渲染的时候就不保证是不变的,如果渲染时主线程更改了Camera的相关信息,比如Camera的Pos, 就会乱套(注意,传入const& 也不行,因为这只能保证不在RenderCommandQueue里去改Camera的信息,并不代表主线程里不可以改变Camera的信息)

代码如下:

// 这个BeginScene函数,它会把Camera的相关信息,比如vp矩阵,fov等信息拷贝进Renderer
BeginScene(m_Camera, ...);// 对m_Camera进行值传递

留下的Camera的作业

Cherno让我们自己实现一个Camera系统,下面是我设计的Camera接口和思路,感觉有很多还不确定的地方:

// 这应该是一个与渲染平台无关的类, 后续会派生为不同的平台对应的Camera?
class Camera
{
public:
	Camera(float fov) : m_Fov(fov) {}

	//这个矩阵怎么做到全平台无关, 可能要自己设计一个数学类, 代表mat4
	mat4 GetCameraMatrix();
	void SetCameraMatrix();
	void GetFov() { return m_Fov; }
	void SetFov(float fov) {  m_Fov = fov; }
private:
	float m_Fov;
	// 透视矩阵还是正交矩阵, 用enum吗
};

Camera的具体实现

看了下视频,这一章并没有实现一个通用的Camera类,而是根据投影方式的不同,分为了透视投影的Camera和正交投影的Camera,这一集先实现了更简单的正交投影的Camera。

首先从设计上,纠正一下上面的问题:

  1. 不应该是返回GetCameraMatrix,Camera本质就是两个Matrix,也就是View矩阵和投影矩阵,所以应该设计返回V、P和VP矩阵的三个接口
  2. 返回矩阵的类型这里并没有自定义,还是用的glm::mat4,好像glm::mat4就是跨平台的C++代码
  3. 在Camera的构造函数里,输入构建Project矩阵的数据,以及构建View矩阵的数据

这里的接口设计如下:

// 目前把它认为是一个2D的Camera, 因为目前相机的旋转只有一个维度
class OrthographicCamera 
{
public:
	// 构造函数, 由于正交投影下, 需要Frustum, 默认near为-1, far为1, 就不写了
	// 不过这个构造函数没有指定Camera的位置, 所以应该是默认位置
	OrthographicCamera(float left, float right, float bottom, float top);

	// 读写Camera的位置和朝向, 这些数据是用于设置View矩阵的
	const glm::vec3& GetPosition() const { return m_Position; }
	void SetPosition(const glm::vec3& position) { m_Position = position; RecalculateViewMatrix(); }
	float GetRotation()const { return m_Rotation; }
	void SetRotation(float rotation) { m_Rotation = rotation; RecalculateViewMatrix(); }

	// 返回三个矩阵的接口, 这些数据用于设置Projection矩阵
	const glm::mat4& GetProjectionMatrix() const { return m_ProjectionMatrix; }
	const glm::mat4& GetViewMatrix() const { return m_ViewMatrix; }
	const glm::mat4& GetViewProjectionMatrix() const { return m_ViewProjectionMatrix; }
private:
	void RecalculateViewMatrix();
private:
	glm::mat4 m_ProjectionMatrix;
	glm::mat4 m_ViewMatrix;
	glm::mat4 m_ViewProjectionMatrix;// 作为计算时的Cache
	
	glm::vec3 m_Position;	// 正交投影的相机位置好像也不重要
	float m_Rotation = 0.0f;// 正交投影下的相机只会有绕Z轴的旋转
};

类实现代码如下,没啥难的:

#include "hzpch.h"
#include "OrthographicCamera.h"
#include <glm/gtc/matrix_transform.hpp>

namespace Hazel 
{
	OrthographicCamera::OrthographicCamera(float left, float right, float bottom, float top)
		: m_ProjectionMatrix(glm::ortho(left, right, bottom, top, -1.0f, 1.0f)), m_ViewMatrix(1.0f)
	{
		m_ViewProjectionMatrix = m_ProjectionMatrix * m_ViewMatrix;
	}

	void OrthographicCamera::RecalculateViewMatrix()
	{
		glm::mat4 transform = glm::translate(glm::mat4(1.0f), m_Position) *
			glm::rotate(glm::mat4(1.0f), glm::radians(m_Rotation), glm::vec3(0, 0, 1));

		m_ViewMatrix = glm::inverse(transform);
		m_ViewProjectionMatrix = m_ProjectionMatrix * m_ViewMatrix;
	}
} 

应用OrthographicCamera

OrthographicCamera本质就是VP矩阵,这里在创建Application时,写死了一个正交投影的Camera,然后在每帧Render的过程中,从该Camera里取VP矩阵存在SceneData里,代码如下:

void Application::Run() 
{
	std::cout << "Run Application" << std::endl;

	while (m_Running)
	{
		// 每帧开始Clear
		RenderCommand::Clear();
		RenderCommand::ClearColor(glm::vec4(1.0f, 0.0f, 1.0f, 1.0f));

		// 把Camera里的VP矩阵信息传到Renderer的SceneData里
		Renderer::BeginScene(*m_Camera);
		{
			// todo: 后续操作应该有Batch
			// bind shader, 上传VP矩阵, 然后调用DrawCall
			Renderer::Submit(m_BlueShader, m_VertexArray);
			// bind, 然后调用DrawCall
			Renderer::Submit(m_Shader, m_QuadVertexArray);
		}
		Renderer::EndScene();

		// 更新其他的Layer和ImGUILayer
		...
	}
}

// Submit函数如下
void Renderer::Submit(const std::shared_ptr<Shader>& shader, const std::shared_ptr<VertexArray>& va)
{
	shader->Bind();
	shader->UploadUniformMat4("u_ViewProjection", m_SceneData->ViewProjectionMatrix);

	RenderCommand::DrawIndexed(va);
}


TimeStep系统

现在的Hazel游戏引擎里,一秒内调用多少次OnUpdate函数,完全是取决于CPU的(当开启VSync则取决于显示器的频率)。假如我设计一个用代码控制相机移动的功能,代码如下:

void Application::Run() 
{
		std::cout << "Run Application" << std::endl;

		while (m_Running)
		{
			auto m_CameraPosition = m_Camera->GetPosition();
			float m_CameraMoveSpeed = 0.01f;

			if (Hazel::Input::IsKeyPressed(HZ_KEY_LEFT))
				m_CameraPosition.x -= m_CameraMoveSpeed;

			m_Camera->SetPosition(m_CameraPosition);
			...
		}
}

此时会出现一个问题:如果同时在不同的机器上执行这段代码,性能更好的CPU,1s内循环跑的此时越多,相机会移动的更快。不同的机器的执行效果不一样,这肯定是不行的,所以要设计TimeStep系统。


三种不同的Timestep系统

一般来说,有三种Timestep系统,它们都用来帮助解决不同机器上循环执行速度不同的问题:

  • 固定delta time的Timestep系统
  • 灵活delta time的Timestep系统,delta time取决于此帧用时
  • 半固定delta time的Timestep系统(Semi-fixed timestep)

详情可以看附录。


Hazel引擎里的Timestep系统

由于目前没有物理引擎部分,所以这里选择了上面说的第二种Timestep系统

第二种Timestep系统的设计原理是:虽然不同机器执行一次Loop函数的用时不同,但只要把每一帧里的运动,跟该帧所经历的时间相乘,就能抵消因为帧率导致的数据不一致的问题。因为函数执行的速度是与执行函数的时间成反比的,这样就能去除调用函数过快带来的影响,毕竟函数每秒调用次数越多,其每帧所用时间就越少。比如说函数里物体的速度是X m/s,那么X* DeltaTime,即使机器A渲染1秒渲染了5帧,机器B1秒渲染了8帧,它们在1s的时间点的总DeltaTime还是不变的,位移也都是X

所以只需要记录每帧的DeltaTime,然后在Movement里乘以它即可,具体其实就是把之前在循环里调用的函数,比如OnUpdate函数,从无参函数变成带一个TimeStep参数的函数而已,代码如下:

// ============== Timestep.cpp =============
// Timestep 实际就是一个float值的wrapper
class Timestep
{
public:
	Timestep(float time = 0.0f)
		: m_Time(time)
	{
	}
	
	operator float() const { return m_Time; }
	// 给float添加wrapper是方便进行秒和毫秒的转换
	float GetSeconds() const { return m_Time; }
	float GetMilliseconds() const { return m_Time * 1000.0f; }
private:
	float m_Time;
};


// ============== Application.cpp =============
void Application::Run()
{
	while(m_Running)
	{
		float time = (float)GetTime();// 全平台通用的封装的API, OpenGL上就是glfwGetTime();
		// 注意, 这里time - m_LastFrameTIme, 正好算的应该是当前帧所经历的时间, 而不是上一帧经历的时间
		Timestep timestep = time - m_LastFrameTIme;
		m_LastFrameTime = time; 
		
		...// 调用引擎LayerStack里每个layer的OnUpdate函数
		...// 调用引擎的ImGUILayer的OnUpdate函数		
		...// 调用m_Window的OnUpdate函数
	}
}

其他的游戏引擎也大多是这么做的,比如Unity,其Update函数里虽然没有传DeltaTime,但是可以通过Time.DeltaTime来获取它,如下图所示:
在这里插入图片描述


把Application.cpp里的内容移到Sandbox对应的Project

Application类应该主要负责进行While循环,在里面调用各个Layer的Update函数,而SandboxApp类虽然继承于Application类,但是也只是个大致的空壳而已,它的存在是为了把new出来的ExampleLayer加入到继承来的m_LayerStack里,具体的绘制Quad和Triangle的的操作应该放到Sandbox对应Project的Layer里,大概是这样:

#pragma once
#include "Hazel/Layer.h"
#include "Hazel/Renderer/Shader.h"
#include "Hazel/Renderer/VertexArray.h"
#include "Hazel/Renderer/OrthographicCamera.h"


class ExampleLayer : public Hazel::Layer
{
public:
	ExampleLayer();

private:
	void OnAttach() override;
	void OnDettach() override;
	void OnEvent(Hazel::Event& e) override;
	void OnUpdate(const Timestep& step) override;
	void OnImGuiRender() override;

// 相机、绘制的VertexArray、对应的Shader和Camera都属于单独的一个Layer
// 这里的Camera是属于Layer的, 没有存在Application或Sandbox类里
private:
	Hazel::OrthographicCamera m_Camera;
	std::shared_ptr<Hazel::Shader> m_Shader;
	std::shared_ptr<Hazel::Shader> m_BlueShader;

	std::shared_ptr<Hazel::VertexArray> m_VertexArray;
	std::shared_ptr<Hazel::VertexArray> m_QuadVertexArray;
};

// 注意, 这里的SandboxApp只可以创建对应的Layer, 然后加入到Application的
// m_LayerStack里, 具体调用的函数在Application.cpp的Run函数里
// Run函数是不会暴露给子类去override的
class SandboxApp : public Hazel::Application
{
public:
	// 其实构造函数的函数定义是放在cpp文件里的, 这里方便显示放到了类声明里
	SandboxApp()
	{
		HAZEL_ASSERT(!s_Instance, "Already Exists an application instance");

		m_Window = std::unique_ptr<Hazel::Window>(Hazel::Window::Create());
		m_Window->SetEventCallback(std::bind(&Application::OnEvent, this, std::placeholders::_1));

		m_ImGuiLayer = new Hazel::ImGuiLayer();
		m_LayerStack.PushOverlay(m_ImGuiLayer);
	
		m_LayerStack.PushLayer(new ExampleLayer());

		//m_Window->SetVSync(true);
	}
	
	~SandboxApp() {};
};


Transforms

目前的Transform都是World坐标系的Transform,没有层级父子关系,本质就是globalPosition,globalRotation和globalScale,可以组成一个矩阵来表示。这里做的很简单,甚至都没有单独创建一个Transform类,就是用矩阵代表Model矩阵,作为uniform传给Shader而已,很简单,这里重点修改的函数是Submit函数,原本的函数如下:

void Renderer::Submit(const std::shared_ptr<Shader>& shader, const std::shared_ptr<VertexArray>& va)

现在要修改成:

// 在渲染Vertex Array的时候, 添加对应model的transform对应的矩阵信息
void Renderer::Submit(const std::shared_ptr<Shader>& shader, const std::shared_ptr<VertexArray>& vertexArray, const glm::mat4& transform)
{
	shader->Bind();           
	shader->UploadUniformMat4("u_ViewProjection", s_SceneData->ViewProjectionMatrix);
	shader->UploadUniformMat4("u_Transform", transform);

    RenderCommand::DrawIndexed(va);
}



添加Hazel::Scope and Hazel::Ref

代码如下:

// Core.h文件里
namespace Hazel
{
	// 感觉没有太大必要, 我就先不写这个了, 有需要的时候再写
	template<typename T>
	using Scope = std::unique_ptr<T>;

	template<typename T>
	using Ref = std::shared_ptr<T>;

}

这样写虽然看上去有点多此一举,但是它可以区分Hazel内部与Hazel外部的智能指针,比如这两句代码,就不会用Hazel::Ref来表示,用于表示它是使用的外部的内容,甚至引擎内部不同的模块也可以用不同的Ref,用于方便区分:

std::shared_ptr<spdlog::logger> Log::s_CoreLogger;
std::shared_ptr<spdlog::logger> Log::s_ClientLogger;

另外,课里提到了几个知识点:

  • shared_ptr是线程安全的,它的引用计数的加和减操作都是原子级别的,为了保证多线程,会造成额外的消耗,所以如果不是在多线程下使用的,为了更高效,未来还可能需要实现自己引擎的shared_ptr类,无非不是线程安全的
  • 根据编程经验,绝大多数情况下,可以使用shared_ptr,而不是unique_ptr,二者性能开销其实不大,如果二者性能开销较大,可能还不如用raw pointers
  • 这里的Hazel::Ref,也就是shared_ptr引用计数的部分,可以视作一个非常粗略的AssetManager,一旦资源的引用计数为0,则自动销毁该资源


Textures

这一章的内容不难,目的是创建一个Texture类,这里的Texture类跟之前的VertexArray,Buffer类是类似的。先创建一个抽象基类,这个基类代表各个平台通用的Texture,然后创建对应的Create Texture函数,然后根据RenderAPI的Platform类型,在Create函数里返回不同平台下的Texture对象,代码如下:

namespace Hazel 
{
	// Texture可以分为多种类型, 比如CubeTexture, Texture2D
	class Texture
	{
	public:
		virtual ~Texture() = default;

		virtual uint32_t GetWidth() const = 0;
		virtual uint32_t GetHeight() const = 0;

		virtual void Bind(uint32_t slot = 0) const = 0;
	};

	// 注意, 这里额外包了个Texture2D类, 继承于Texture类
	class Texture2D : public Texture
	{
	public:
		static Ref<Texture2D> Create(const std::string& path);
	};

    std::shared_ptr<Texture2D> Texture2D::Create(const std::string& path)
	{
		switch (Renderer::GetAPI())
		{
			case RendererAPI::API::None:    HZ_CORE_ASSERT(false, "RendererAPI::None is currently not supported!"); return nullptr;
			case RendererAPI::API::OpenGL:  return std::make_shared<OpenGLTexture2D>(path);
		}

		HZ_CORE_ASSERT(false, "Unknown RendererAPI!");
		return nullptr;
	}
} 

视频里额外提到的内容:

  • Textures并不只是单纯的颜色组合出来的一张图而已,它还可以存储一些离线计算的结果,还有法线贴图等,比如动画里,甚至可以用其存储skin矩阵
  • 当加载Texture失败时,可以返回一张洋红色的贴图,或者直接绘制洋红色,表示这个材质丢了
  • 目前的Texture2D的实例类,比如OpenGLTexture2D,是从构造函数里读取文件的,所以目前不支持资源的热更,后续需要通过AssetManager实现,Texture2D的热更,这样我在编辑的时候,替换文件,能马上显示最新的贴图效果
  • 这里通过stb_image库来加载贴图,所有需要的内容都放在stb_image.h的头文件里,支持JPG, PNG, TGA, BMP, PSD, GIF, HDR, PIC格式
  • 这里读取贴图时,会返回一个整型,叫做channels,它代表了贴图里面的通道个数,比如RGB格式的图片,通道个数为3,RGBA的则为4,对于灰度图这种贴图,通道个数甚至可能为1。


Blend

之前学过OpenGL,这一章也很简单,无非是在OpenGLRenderer里添加一个Init函数,然后开启Blend,然后多绘制一个图片就行了,代码如下:

glEnable(GL_BLEND);
// 这个函数用于决定, pixel绘制时, 如果已经有绘制的pixel了, 那么新pixel的权重是其alpha值, 原本的pixel的权重值是1-alpha值
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);  //source权重值用其alpha值,destination权重值为1-source权重值

之前写过一篇OpenGL笔记(五),可以看看



Shader Asset Files

目前的Shader是在代码里写死的,这样写的代码会作为static常量存在内存里。但是游戏引擎里有一个常见的需求,就是对Shader的热更,比如说我更改一个Shader,我想立马在游戏里看到更改之后的Shader的效果。如果把Shader写在单独的文件里,就可以重新单独Reload和编译这个新文件。还有个问题,游戏引擎,比如Unity,里面支持在编辑器里写Shader,目前的这种写法不满足这种用户需求。

之前学习OpenGL时,一个ShaderProgram是有多个文件的,分别存放vert shader、fragment shader等,而DX是都放在一个文件里的。感觉都放一个文件里更科学一点,所以这边创建文件是下面这样的格式:

#type vertex				// 注意,这玩意儿是自己定义的字符串里分隔Shader的方法
...//写原本的vertex shader

#type fragment
...//写原本的fragment shader

然后利用ifstream来读取文件,得到string,再寻找上面的#type ...这种东西,把一个大的string,分为多个string,每个细分后的string对应一种shader。

视频里额外提到的内容:

  • 一般来说,游戏引擎里的Shader,都是在Editor下预先编译好的二进制文件,然后再在Runtime对其进行组合和应用


ShaderLibrary

这章也很简单,其实就是把Shader的读取和存储位置都分配到ShaderLibrary类里,ShaderLibrary本质就是个哈希map,key是shader的名字,value是shader的内容,代码如下:

class ShaderLibrary
{
public:
	void Add(const std::string& name, const Ref<Shader>& shader);
	void Add(const Ref<Shader>& shader);
	Ref<Shader> Load(const std::string& filepath);
	Ref<Shader> Load(const std::string& name, const std::string& filepath);

	Ref<Shader> Get(const std::string& name);
	bool Exists(const std::string& name) const;
private:
	std::unordered_map<std::string, Ref<Shader>> m_Shaders;
};

然后把原本的ExampleLayer里的shader变量,改成一个ShaderLibrary对象即可,需要什么就找它要。



Appendix

一个警告:warning C4227: anachronism used : qualifiers on reference are ignored

anachronism翻译过来说不合时宜的东西,具体警告的代码出现在这一行:

virtual void DrawIndexed(std::shared_ptr<VertexArray>& const) = 0;

如果把&符号或者const关键字去掉,警告就消失了,也就是说,std::shared_ptr<T>& const类型是不存在的,编译器会自动忽略掉&符号。
还是自己犯蠢了,应该写const &,reference本身就是const的,只有const &的说法,没有& const的说法。而指针就不一样,既有const*,也有*const,下面这句话也会引发同样的警告:

const int j = 0;
int &const i = j;   // C4227

顺便可以判断一下下面哪些会编译失败:

const int j = 0;
int const& i = j;
int const* p1 = j;
int const* p2 = &j;
int const* const p3 = j;
int *const p4 = j;
int c = 5;                                                                                                                                              
int const* p5 = &c;

答案是:

const int j = 0;				
int const& i = j;					
int const* p1 = j;					// 能通过编译, 但有问题, p1是个指针, 这里p1的值为0x00000000
int const* p2 = &j;					// p2是个指针, 记录的是j的地址
int const* const p3 = &j;				
int *const p4 = j;					// 编译报错, 因为p4指向的是普通int, 不是const int
int c = 5;				
int const* p5 = &c;					// 编译正确


传入shared_ptr的讲究

参考:https://stackoverflow.com/questions/37610494/passing-const-shared-ptrt-versus-just-shared-ptrt-as-parameter
看看下面两个函数签名,有啥区别

void doSomething(std::shared_ptr<T> o) {}
// this signature seems to defeat the purpose of a shared pointer
void doSomething(const std::shared_ptr<T> &o) {} 

第二种方式,传入的是const &,它没有复制shared_ptr对象,那么对应引用计数不会改变。而shared_ptr是线程安全的,它的引用计数的增减是原子操作,这需要消耗不少性能,因此,第二种函数签名会比第一种函数签名的效率更高。

再往深了想一想,如果传入的是const &,意味着这个指针是以引用的方式传入的,也就是说,如果层层调用的函数里,每个函数都传入这个指针的引用作为参数,那么在最后的函数里,让该指针变为nullptr,那么整个一层层调用都会失败。而Shared_ptr的理念本来就是一个对象共享多个指针,如果传入const &,那么似乎与shared_ptr的设计理念相悖

正确的做法是,When calling down to functions pass the raw pointer or a reference to the object the smart pointer is managing.根据CppCoreGuidelines里的F7,有这么一条建议:

For general use, take T* or T& arguments rather than smart pointers

因为传入智能指针会转交对象的控制权,只有当确确实实需要转交控制权的时候,才应该传入智能指针。Passing by smart pointer restricts the use of a function to callers that use smart pointers,参数如果是智能指针,会限制参数的范围。举个例子,一个接受widget作为参数的函数,其参数应该可以是任何widget类型的对象,而不只是那些由智能指针控制生命期的对象。可以看下面的代码:

// 不同的函数签名对应的方式
// accepts any int*
void f(int*);

// can only accept ints for which you want to transfer ownership
void g(unique_ptr<int>);

// can only accept ints for which you are willing to share ownership
void g(shared_ptr<int>);

// doesn't change ownership, but requires a particular ownership of the caller
void h(const unique_ptr<int>&);

// accepts any int
void h(int&);

这是一个坏例子:

// callee,不好的签名设计方式
void f(shared_ptr<widget>& w)
{
    // ...
	// 没有利用到智能指针涉及到的lifetime相关的东西,还不如直接传入widget&
    use(*w); // only use of w -- the lifetime is not used at all    
    // ...
};

// caller
// 创建shared_ptr然后传入,成功
shared_ptr<widget> my_widget = /* ... */;
f(my_widget);

// 创建正常对象然后传入,失败
widget stack_widget;
f(stack_widget); // error

这是一个好例子:

// callee
void f(widget& w)
{
    // ...
    use(w);
    // ...
};

// caller
shared_ptr<widget> my_widget = /* ... */;
f(*my_widget);// 指针解引用

widget stack_widget;
f(stack_widget); // ok -- now this works

最后总结和强调一点:

  • 如果一个函数的传参里,使用到了smart pointer type(不一定非得是std的智能指针,也可以是自己实现了operator->和operator*的类),而且对应的智能指针是copyable的,如果只用到了operator*, operator->get()操作,那么函数参数建议为T*T&


抽象类的对象不可以被创建,但是指针可以

这么写是没问题的

// header里
class RenderCommand 
{
	static RendererAPI* s_RendererAPI;
};
// cpp里
RendererAPI* RenderCommand::s_RendererAPI = new OpenGLRendererAPI();

这么写就有问题了:

class RenderCommand 
{
	static RendererAPI s_RendererAPI;// 错误,抽象类不可以有对象
};


公有继承与私有继承

参考:https://www.bogotobogo.com/cplusplus/private_inheritance.php#:~:text=Private%20Inheritance%20is%20one%20of,interface%20of%20the%20derived%20object.

之前写的类继承的时候,public写掉了,变成了默认的私有继承,导致这行代码出错:

std::shared_ptr<Texture2D> Texture2D::Create(const std::string& path)
{
	...
	return  std::make_shared<OpenGLTexture2D>(path);// 编译错误, OpenGLTexture2D无法转型为Texture2D
}

公有继承是is-a的关系,私有继承是has-a的关系,代码如下:

// 例一: 说明private继承不是is-a关系
class Person {};
class Student:private Person {};	// private
void eat(const Person& p){}		// anyone can eat
void study(const Student& s){}		// only students study

int main() 
{
	Person p;	// p is a Person
	Student s;	// s is a Student
	eat(p);		// fine, p is a Person
	eat(s);		// error! s isn't a Person
	return 0;
}

// 例二, 介绍了private继承的用法
#include <iostream>

using namespace std;

class Engine 
{
 public:
	Engine(int nc){
		 cylinder = nc;
	}

	void start() {
		cout << getCylinder() <<" cylinder engine started" << endl;
	};

	int getCylinder() {
		return cylinder;
	}
	
private:
	int cylinder;
 };
 
 class Car : private Engine
{    // Car has-a Engine
 public:
   Car(int nc = 4) : Engine(nc) { }        
   void start() {
   // 这种写法很奇怪, 这里的getCylinder又不是静态函数, 为啥这么写
	cout << "car with " << Engine::getCylinder() <<
		   " cylinder engine started" << endl;
	Engine:: start();
   }
 }; 

int main( ) 
{ 
	Car c(8);
	c.start();
	return 0; 
}

// output为
// car with 8 cylinder engine started
// 8 cylinder engine started

三种Timestep系统

参考:https://gafferongames.com/post/fix_your_timestep/
参考:https://johnaustin.io/articles/2019/fix-your-unity-timestep
参考:https://www.youtube.com/watch?v=ReFmKHfSOg0&ab_channel=InfallibleCode


Fixed delta time

这也是最简单的方法,代码如下:

double t = 0.0;
double dt = 1.0 / 60.0;

while (!quit)
{
    integrate(state, t, dt);
    render(state);
    t += dt;
}

这种代码一般是启用VSync时调用的,因为此时已经知道调用渲染Loop的频率,比如这里显示器是60的帧率,就可以直接这么写。但这样写也不太好,因为它存在一个问题:如果CPU渲染的频率跟不上显示器的频率,那么这里的函数一秒就不能跑到60次,游戏里的逻辑就会变慢。而且如果没开VSync,也会有问题。


Variable delta time

下面的代码类似于Unity里的Update函数的执行逻辑,其实这里delta time记录的是上一帧跑到当前帧所用的时间。

double t = 0.0;

double currentTime = hires_time_in_seconds();

while (!quit )
{
    double newTime = hires_time_in_seconds();
    double frameTime = newTime - currentTime;// 相当于Unity的Time.deltaTime
    currentTime = newTime;

    integrate(state, t, frameTime);
    t += frameTime;

    render(state);
}

不过这种方法,如果机器很差的话,帧与帧之间的delta time不仅可能会很大,而且每帧的delta time都是不同的。而对于物理模拟的系统来说,需要的是稳定不变,且较小的delta time,只有这样,才能平滑的计算物理之间的变化。


Semi-fixed timestep

其实类似于Unity的FixedUpdate函数,Unity里的Update函数是每帧执行一次的函数,而FixedUpdate每帧可以执行0、1任意多的次数,这取决于Unity的time相关的设置与游戏里实际的framerate,如下图所示:
在这里插入图片描述


FixedUpdate is used when you need to have something persistently cleanly applying at the same rate, and that’s usually physics, because physics needs to calculate stuff using time as an actual parameter.

初步思路是,当一帧所用时长,即DeltaTime大于规定的每帧的最大值时,在该帧多次处理DeltaTime,类似于补帧操作,把DeltaTime细化为多个更小的DeltaTime,相关代码如下:

double t = 0.0;
double dt = 1 / 60.0;// 这是一个常量, 代表delta time的最大值

double currentTime = hires_time_in_seconds();

while (!quit)
{
	// 获取上一帧到这里的delta time
    double newTime = hires_time_in_seconds();
    double frameTime = newTime - currentTime;
    currentTime = newTime;
    
    // 注意这里的循环, 当frameTime大于1/60时, 这里会额外跑几次这个循环
    // 它相当于把frameTime细分为了多个Timestep, 但都在这一帧内执行
    while (frameTime > 0.0)
    {
        float deltaTime = min(frameTime, dt);// delta time不允许超过1/60
        // 
        integrate(state, t, deltaTime);
        frameTime -= deltaTime;
        // t应该是真实的当前经历的时间
        t += deltaTime;
    }

    render(state);
}

这样写也会有一个缺点,如果这里的Integrate里的内容CPU消耗很高的话,容易陷入Spiral Of Death,翻译过来就是循环死亡。举个例子,当前面代码的integrate函数,无法在给它分配的frameTime里完成时,就会陷入无尽的循环


关于Spiral of Death

What is the spiral of death? It’s what happens when your physics simulation can’t keep up with the steps it’s asked to take. For example, if your simulation is told: “OK, please simulate X seconds worth of physics” and if it takes Y seconds of real time to do so where Y > X, then it doesn’t take Einstein to realize that over time your simulation falls behind. It’s called the spiral of death because being behind causes your update to simulate more steps to catch up, which causes you to fall further behind, which causes you to simulate more steps…

新的代码如下:

double t = 0.0;
const double dt = 0.01;

double currentTime = hires_time_in_seconds();
double accumulator = 0.0;

while (!quit)
{
	double newTime = hires_time_in_seconds();
	double frameTime = newTime - currentTime;
	currentTime = newTime;

	accumulator += frameTime;

	while (accumulator >= dt)
	{
		integrate(state, t, dt);
		accumulator -= dt;
		t += dt;
	}

	render(state);
}

感觉有点复杂了,后面写物理引擎部分的时候再研究这个问题吧,应该就在这篇文章的后面部分,Remain。


C++查找换行符

这里一定要注意斜杠的写法

// 注意, 转义字符是\r\n, windows上代表换行, \r是光标移到行首, \n是光标跳到下一行
sizeof("\r\n");// 3
sizeof("/r/n");// 5

不过我看字符串里实际上是\n\n,好像也是换行符


std::string.find_first_of函数的误用

size_t newP2 = (string("fdasftvv")).find_first_of("#type ", 0);// 居然newP2为5

我以为是查询这个字符串,然后仔细想了想,它其实是查询的输入的字符串的任意一个字符,比如这里输入的"type ",它就会从"fdasftvv"里,寻找type五种字符(const char*末尾的结束符应该不算)

应该用的函数是string.find():

string str = "geeksforgeeks a computer science";
string str1 = "geeks";
  
// Find first occurrence of "geeks"
size_t found = str.find(str1);
if (found != string::npos)
    cout << "First occurrence is " << found << endl;  

std::make_shared的用法

之前是这么写的,然后编译报错:

{
	std::shared_ptr<Shader> Shader::Create(const std::string& path)
	{
		RendererAPI::APIType type = Renderer::GetAPI();
		switch (type)
		{
		case RendererAPI::APIType::OpenGL:
			return std::make_shared<OpenGLShader>(new OpenGLShader(path));
		...
}		

其实是写法问题,需要改成:

return std::make_shared<OpenGLShader>(path);
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值