Hazel引擎学习(七)

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

Making a GAME in ONE HOUR using MY ENGINE

参考:https://www.youtube.com/watch?v=qITIvVV6BHk&ab_channel=TheCherno

这个视频是看了看Unity里别人做类似Flappy Bird的视频,不过这里的Bird换成了Rocket,来看看用Hazel来实现对应的游戏,需要什么额外的功能:

  • 需要Particle System来代表火箭后面的喷射装置
  • 需要碰撞检测,判断Rocket是否撞到了墙上
  • 重力模拟
  • 一些后处理效果,让画面变得更好看,比如变色发光的墙(glow triangles),这里会使用PS过的贴图代替gloom的效果
  • camera following the player
  • UI for displaying the score
  • Renderer 2D绘制Rotated Quad(目前的DrawQuad函数里只能输入position)
  • Random类,提供随机数

还有两个功能,这里不会实现:

  • 音频系统
  • 打包到安卓上游玩,毕竟引擎目前不像Unity,目前只支持Windows

具体步骤如下:


绘制代表Rocket的像素图

下载了个Photoshop,学习了一下怎么画像素图,参考这里

这个Character,Cherno视频里绘制如下,他在16*16像素的Canvas上画的:
在这里插入图片描述

为了绘制这个Rocket,作为Character,我也画了个,把这第一个Layer的图像导出来图片文件即可,TB是我的英文名Toby的缩写:
在这里插入图片描述


创建基本代码

我直接把当前开发的Hazel的代码Copy了一份,放在新创建的Git仓库里了,并且把SandBox相关的名字改成了FlappyRocket,而且把Hazel.sln改成了FlappyRocket.sln。


添加2D Renderer渲染旋转Quad

这个很简单,直接改原本引擎的DrawQuad里的参数列表就行,除了position,再加一个rotatedAngle:

void Renderer2D::DrawQuad(const glm::vec3 & position, float rotatedAngle, const glm::vec2 & size, std::shared_ptr<Texture> tex)
{
	//Texture绑定到0号槽位即可, shader里面自然会去读取对应的shader
	tex->Bind(0);
	s_Data->Shader->UploadUniformVec4("u_Color", { 1.0f, 1.0f, 1.0f, 1.0f });
	glm::mat4 transform = glm::scale(glm::mat4(1.0f), glm::vec3(size.x, size.y, 1.0f));
	transform = glm::rotate(transform, glm::radians(rotatedAngle), { 0, 0, 1 });
	transform = glm::translate(transform, position);
		
	s_Data->Shader->UploadUniformMat4("u_Transform", transform);
	RenderCommand::DrawIndexed(s_Data->QuadVertexArray);
}

游戏框架

分为这么几个类:

  • 创建Game Layer
  • 设计Level类,类似Unity的Scene类
  • 设计Player类
  • 设计Random类,用于生成随机关卡数据
  • 设计Particle System类,作为火箭的喷射效果

可以创建一个基本的Layer,然后把我之前画的像素贴图渲染到屏幕中间。


各个类的接口

Random和Particle System类没那么重要,就先不介绍了

Game Layer
其实就是基础的Layer

class GameLayer : public Hazel::Layer
{
public:
	GameLayer(const std::string& name = "Layer");
	~GameLayer();
	void OnAttach() override;  //当layer添加到layer stack的时候会调用此函数,相当于Init函数
	void OnDettach() override; //当layer从layer stack移除的时候会调用此函数,相当于Shutdown函数
	void OnEvent(Hazel::Event&) override;
	bool OnMouseButtonPressed(Hazel::MouseButtonPressedEvent & e);
	void OnUpdate(const Hazel::Timestep&) override;
	void OnImGuiRender() override;

private:
	std::shared_ptr<Level> m_Level;

	glm::vec4 m_FlatColor = glm::vec4(0.2, 0.3, 0.8, 1.0);

	// UI stuff
	ImFont* m_Font;
	bool m_Blink = false;
	float m_Time = 0.0f;

	enum class GameState
	{
		Play = 0, MainMenu = 1, GameOver = 2
	};

	GameState m_State = GameState::MainMenu;
};

Level类
主要的数据全部存在Level类里了,有:

  • Player引用,Player里记录了其使用的贴图
  • 关卡数据,以及关卡使用到的贴图
  • 游戏逻辑数据,比如当前得分和游戏是否结束的状态参数
#include "Player.h"

// 整个游戏区间的y坐标在[-10, 10]区间内, Player从(0,0,0)开始自动向右移动
struct Column
{
	glm::vec3 topPos;		
	glm::vec3 bottomPos;	

	glm::vec2 scale = {1.5f, 2.0f};		// the Column can be expanded in x and y axis
};

// 对于整个关卡区间的y值: 
// [-1, 1]为玩家的竖直活动区间
// [-Infinity, -1]和[1, Infinity]区间为关卡的上下边界
// 但是由于正交Camera是紧跟Player的, Camera的显示范围为横轴长度为4, 纵轴长度为2.25(16:9)
class Level
{
public:
	// 默认正交相机的radio为16:9, zoom为1
	Level();

	static glm::vec4 HSVtoRGB(const glm::vec3 & hsv);

	void Init();
	void Reset();

	void OnUpdate(Hazel::Timestep ts);
	void OnRender();
	void OnImGuiRender();

	bool IsGameOver() const { return m_GameOver; }

	Hazel::OrthographicCameraController& GetCameraController() { return m_OrthoCameraController; }
	glm::vec4 GetDynamicCollor() { return m_DynamicColor; }

	void SetPlayer(const Player&p) { m_Player = p; }
	Player& GetPlayer() { return m_Player; }

	void SetSpacePressed(bool pressed) { m_SpacePressed = pressed; }
	std::vector<Column>& GetColumns() { return m_Collumns; }

	std::shared_ptr<Hazel::Texture2D> GetTriangleTex() { return m_TriangleTexture; }
private:
	bool CollisionTest();
	void CreateInitialColumns();
	void UpdateColumns();

	void UpdateColumnBounds();

	void GameOver();
private:
	bool m_GameOver = false;

	float m_LastPlayerPosX = 0.0f;
	// 色调盘和半径都是确定的, 只有色调H会改变
	glm::vec3 m_ColumnHSV = { 0.0f, 0.8f, 0.8f };// H: Hue, S: Saturation,  V: value
	float m_Gravity = 38.0f;
	float m_UpAcceleration = 100.0f;

	Player m_Player;
	std::vector<Column> m_Collumns;									// 关卡信息数组
	std::shared_ptr<Hazel::Texture2D> m_TriangleTexture;			// 关卡对应的Texture2D数组
	bool m_SpacePressed = false;
	Hazel::OrthographicCameraController m_OrthoCameraController;
	glm::vec4 m_DynamicColor = { 1.0f, 0.3f, 0.3f, 1.0f };

public:
	std::vector<glm::vec2> m_DebugCollisions;// 存储Player发生碰撞时的Player的位置

	// 向量的齐次坐标为0, 点为1
	glm::vec4 m_TriVertices[3]{
		{ 0.4f,  -0.4f, 0.0f, 1.0f },	// 注意, 最后一列必须都是1, 因为他们代表点而不是向量
		{ 0.0f,   0.4f, 0.0f, 1.0f },
		{ -0.4f, -0.4f, 0.0f, 1.0f },
	};

	std::vector<glm::vec4> m_ColumnBounds;
};

Player类

class Player
{
public:
	Player(const char* name = "Default Player");

	void OnUpdate(Hazel::Timestep ts);
	void Render();
	void Reset();

	void SetTexture(std::shared_ptr <Hazel::Texture2D> tex) { m_RocketTexture = tex; }
	std::shared_ptr<Hazel::Texture2D> GetTexture() { return m_RocketTexture; }

	float GetRotation() 
	{
		if (m_Velocity.y * 3.0f - 90.0f < -180)
			m_Velocity.y = -90.0f / 3.0f;

		if (m_Velocity.y * 3.0f - 90.0f > 0)
			m_Velocity.y = 90.0f / 3.0f;

		return m_Velocity.y * 3.0f - 90.0f; 
	}
	const glm::vec2& GetPosition() const { return m_Position; } 
	void SetPosition(const glm::vec2& pos);

	glm::vec4 GetForward() { return glm::rotate(glm::mat4(1.0f), glm::radians(m_Velocity.y * 3.0f), { 0, 0, 1 }) * glm::vec4(1, 0, 0, 0); }
	glm::vec2 GetVelocity() { return m_Velocity; }
	void SetVelocity(const glm::vec2& p) { m_Velocity = p; }
	uint32_t GetScore() const { return (uint32_t)((m_Position.x ) / (4.0f / 3.0f)); }
	float GetSpeed() { return m_PlayerSpeed; }

	void Emit();
private:
	glm::vec2 m_Position = { 0.0f, 0.0f };
	glm::vec2 m_Velocity = { 10.0f, 0.0f };

	float m_EnginePower = 0.5f;

	float m_Time = 0.0f;
	float m_SmokeEmitInterval = 0.4f;

	ParticleProperties m_SmokeParticleProps, m_EngineParticleProps;
	ParticleSystem m_ParticleSystem;
	
	std::shared_ptr<Hazel::Texture2D> m_RocketTexture;
	std::string m_Name;
	
public:
	// 向量的齐次坐标为0, 点为1
	glm::vec4 m_MeshVertices[4]{
		{ -0.4f, -0.20f, 0.0f , 1.0f },	// 注意, 最后一列必须都是1, 因为他们代表点而不是向量
		{  0.4f, -0.20f, 0.0f , 1.0f },
		{  0.4f,  0.20f, 0.0f , 1.0f },
		{ -0.4f,  0.20f, 0.0f , 1.0f }
	};

	glm::vec4 m_CurVertices[4];
	float m_PlayerSpeed = 0.075f;
};


我把我写代码的过程列在这里,后面补充一些相关知识,更多的细节都记录在FlappyRocketMadeByHazel了:

  • 绘制出Character
  • Character初始向右水平移动,Character会基于其Forward向量移动
  • 添加Gravity对速度的影响,其实就是角色的速度往下的分量不断增加
  • GameLayer接受按空格键和松开空格键的Event
  • 按下空格键时,Character速度添加向上的分量,其实是类似添加Gravity的操作,只不过速度是反的
  • Camera跟随Character,这个很简单,把Player的offset加到Camera上即可
  • Camera锁定在X轴的[-2 + movement, 2 + movement],和Y轴的[-1.225, 1.225]之间
  • 实现对Background和上下Border的绘制函数
  • 读取三角形贴图,绘制静态关卡(看了下,屏幕横排等于三个Columns的间距和)
  • 加入Random类,绘制动态Column
  • 绘制Runtime下随机颜色的三角形
  • Level类里添加碰撞检测,在其Update函数里不断调用,碰撞则结束游戏
  • 添加粒子系统

下面介绍一些,完成这个小游戏需要补充的知识


Orthographic Camera显示任意的Zone

对于2D的正交相机,比如说,我想把横轴长度2,纵轴长度4的移动区间显示到屏幕上,初始区间即为横轴[-1, 1]、纵轴[-2, 2],那么应该怎么写Camera的矩阵?

// 这是我的构造函数
// 构造函数, 由于正交投影下, 需要Frustum, 默认near为-1, far为1, 就不写了
// 不过这个构造函数没有指定Camera的位置, 所以应该是默认位置
OrthographicCamera(float left, float right, float bottom, float top);

// 所以这么写应该就行了
Hazel::OrthographicCamera(-1.0f, 1.0f, -2.0f, 2.0f)

但是这样画出来画面是变形的,因为我们的屏幕一般是16:9,或者16:10的,所以这里的横向区间比纵向区间一般要是这个比例,所以我现在把横轴长度改成4,纵轴长度改成了4/16 * 9 = 2.25,代码如下:

// 映射区间在横轴[-2, 2]、纵轴[-1.225, 1.225]内
m_OrthoCameraController.GetCamera() = Hazel::OrthographicCamera(-2.0f, 2.0f, -1.225f, 1.225f);

C++写随机数

Random类如下:

// Random.h
#include <random>

class Random
{
public:
	static void Init()
	{
		s_RandomEngine.seed(std::random_device()());
	}

	// 返回[0, 1]范围内的随机浮点数
	static float Float()
	{
		return (float)s_Distribution(s_RandomEngine) / (float)std::numeric_limits<uint32_t>::max();
	}
private:
	static std::mt19937 s_RandomEngine;
	static std::uniform_int_distribution<std::mt19937::result_type> s_Distribution;
};

// Random.cpp
#include "Random.h"

// 初始化静态对象
std::mt19937 Random::s_RandomEngine;
std::uniform_int_distribution<std::mt19937::result_type> Random::s_Distribution;

// 实际使用时
// 获取[-17.5, 17.5]区间的随机数
float center = Random::Float() * 35.0f - 17.5f;


HSL and HSV

参考:https://www.youtube.com/watch?v=Ceur-ARJ4Wc&t=48s&ab_channel=KhanAcademyLabs

HSL (for hue, saturation, lightness) and HSV (for hue, saturation, value; also known as HSB, for hue, saturation, brightness) are alternative representations of the RGB color model

HSL其实是三个参数的首字母大写,hue代表H,意思是色调;S是saturation,即溶解度;L则是亮度。也可以叫HSV,是一样的。

这是另外一种表示颜色的方法,RGB虽然数字上很精确,但是很难直接根据自己想要的颜色,得到对应的RGB的值,比如说我要一个淡紫色,我无法直接给出RGB的大致值,因为RGB表示颜色,并不够直观。所以人们想出了新的颜色模型,即HSV颜色模型。

H翻译为色调,也可以叫颜色,如下图所示,是一个用于参考的色调盘,色盘上的任意一个颜色,会由H和S两个值决定,H的值在[0, 360]之间,S对应着半径,但是这里的色盘并不代表所有的颜色,显然这里没有黑色:在这里插入图片描述
改变亮度,可以获得不同的色调盘,如下图所示,通过HSL三个元素,就可以获取所有的颜色了:
在这里插入图片描述

这里有一份HSV转RGB的代码,从Cherno代码里扒出来的:

static glm::vec4 HSVtoRGB(const glm::vec3& hsv) 
{
	int H = (int)(hsv.x * 360.0f);
	double S = hsv.y;
	double V = hsv.z;

	double C = S * V;
	double X = C * (1 - abs(fmod(H / 60.0, 2) - 1));
	double m = V - C;
	double Rs, Gs, Bs;

	if (H >= 0 && H < 60) 
	{
		Rs = C;
		Gs = X;
		Bs = 0;
	}
	else if (H >= 60 && H < 120)
	 {
		Rs = X;
		Gs = C;
		Bs = 0;
	}
	else if (H >= 120 && H < 180) 
	{
		Rs = 0;
		Gs = C;
		Bs = X;
	}
	else if (H >= 180 && H < 240) 
	{
		Rs = 0;
		Gs = X;
		Bs = C;
	}
	else if (H >= 240 && H < 300) 
	{
		Rs = X;
		Gs = 0;
		Bs = C;
	}
	else 
	{
		Rs = C;
		Gs = 0;
		Bs = X;
	}

	return { (Rs + m), (Gs + m), (Bs + m), 1.0f };
}


碰撞检测

这一块和后面会介绍的粒子系统,是这个2D游戏的两个重点,这里的碰撞检测代码分为两个步骤:

  • 表示出Player周围Collider的Runtime坐标,以及不同位置的Column的三角形的三个点坐标
  • Runtime每帧判断Player的Collider坐标是否在任意Column的三角形内,或者超出上下边界

第一步
核心思路是,写出静态的物体顶点的Mesh坐标,然后根据其Transform,得到新的Runtime下的坐标,代码如下所示:

// Level.h
class Level
{
	...
	// 向量的齐次坐标为0, 点为1
	glm::vec4 m_TriVertices[3]{
		{ 0.4f,  -0.4f, 0.0f, 1.0f },	// 注意, 最后一列必须都是1, 因为他们代表点而不是向量
		{ 0.0f,   0.4f, 0.0f, 1.0f },
		{ -0.4f, -0.4f, 0.0f, 1.0f },
	};

	std::vector<glm::vec4> m_ColumnBounds;
}

// Level.cpp
void Level::UpdateColumnBounds()
{
	for (size_t i = 0; i < m_Collumns.size(); i++)
	{
		auto col = m_Collumns[i];

		// Upper tri
		for (size_t i = 0; i < 3; i++)
		{
			auto trans = glm::scale(glm::mat4(1.0f), { 1.5f, 2.0f, 1.0f });
			trans = glm::rotate(trans, glm::radians(180.0f), { 0,0,1 });// 上排的三角形是倒着向下的, 要旋转180°

			glm::mat4 globalTrans = glm::translate(glm::mat4(1.0f), col.topPos);
			trans = globalTrans * trans;

			glm::vec4 pos = trans * m_TriVertices[i];
			m_ColumnBounds.push_back(pos);
		}


		// Bottom tri
		...
	}
}

第二步
Runtime判断是否碰撞的方法比较简陋,没有什么BVH之类的空间划分算法,它是暴力的每帧遍历所有Column里的三角形,这里用一个Quad的四个点代表Player的Collider,看Player的Collider的周围四个点是否有点在这些三角形里。

核心代码其实就是判断点是否在三角形内,可以用叉乘来判断,如下图所示,其实就是判断只要P点都在AB、BC、CA的同一侧即可,或者AC、CB、BA的同一侧也行:
在这里插入图片描述

代码如下,其他的不多说:

static bool PointInTri(const glm::vec2& p, glm::vec2& p0, const glm::vec2& p1, const glm::vec2& p2)
{
	float s = p0.y * p2.x - p0.x * p2.y + (p2.y - p0.y) * p.x + (p0.x - p2.x) * p.y;
	float t = p0.x * p1.y - p0.y * p1.x + (p0.y - p1.y) * p.x + (p1.x - p0.x) * p.y;

	if ((s < 0) != (t < 0))
		return false;

	float A = -p1.y * p2.x + p0.y * (p2.x - p1.x) + p0.x * (p1.y - p2.y) + p1.x * p2.y;

	return A < 0 ?
		(s <= 0 && s + t >= A) :
		(s >= 0 && s + t <= A);
}



Particle System

这里的粒子系统很简陋,没有涉及到批处理,其实只是绘制了一堆不断移动、不断缩小的Quad而言,然后用了一个vector作为pool,当每个粒子到了它的LifeTime时,不再绘制它们而已,代码如下,不多说了:

#pragma once

#include <Hazel.h>

// 代表粒子系统释放粒子时的统一粒子参数, 参数有:
// 初始大小, 最终大小, lifeTime, 速度, 位置, 起始颜色, 最终颜色等
struct ParticleProperties
{
	glm::vec2 Position;
	// 由于粒子各不相同, 这里的VelocityVariation代表最大的速度变化量, 会在
	// [Velocity - VelocityVariation * 0.5f, Velocity + VelocityVariation * 0.5f]区间产生随机velocity
	glm::vec2 Velocity, VelocityVariation;
	glm::vec4 ColorBegin, ColorEnd;
	float SizeBegin, SizeEnd, SizeVariation;
	float LifeTime = 1.0f;
};

// 由于每个Particle的参数会不同, 所以需要单独设计一个类
struct Particle
{
	glm::vec2 Position;
	glm::vec2 Velocity;
	glm::vec4 ColorBegin, ColorEnd;
	float Rotation = 0.0f;
	float SizeBegin, SizeEnd;

	float LifeTime = 1.0f;
	float LifeRemaining = 0.0f;

	bool Active = false;
};


// Player对象里会存一个ParticleSystem对象
class ParticleSystem
{
public:
	ParticleSystem();

	// 释放粒子, 当按住空格键时, 每帧都会调用此函数, 它们的参数由particleProps统一指定
	// 但是绝大部分参数会基于Random系统, 在原本particleProps给的基础上微变
	void Emit(const ParticleProperties& particleProps);

	void OnUpdate(Hazel::Timestep ts, float playerSpeed);
	void OnRender();
private:
	
	std::vector<Particle> m_ParticlePool;// 会在此类的构造函数里resize到1000, 即Pool的size为1000
	uint32_t m_PoolIndex = 999;
};


Improving our 2D Rendering API

这节课不难,主要是为了丰富Renderer2D::DrawQuad函数,给它加了:

  • Tiling功能
  • Tint功能:Tint是着色、染色的东西,其实就是给Texture的Color加上一个颜色的滤镜而已
  • 渲染旋转后的Quad

Tiling

Tiling的本质其实就是基于Texture的Repeat Mode,让它变成这样:
在这里插入图片描述
然后把原本[0, 1]范围内的UV坐标,各自乘以对应的Tiling倍数即可,像上图这种情况Tiling为3×3


Tint

Tint翻译为染色,着色,其实就是这个代码:

out vec4 color;
uniform sampler2D u_Texture;
// when draw Texture, it is TintColor, but when draw color, this is the output color
uniform vec4 u_Color;
uniform float u_TilingFactor;

void main()
{
	color = texture(u_Texture, TexCoord * u_TilingFactor) * u_Color;
} 


How I Made a Game in an Hour Using Hazel

基本就是分析了一下Cherno自己写的FlappyRocket的代码,看了下,有两个值得注意的地方:

自带glow效果的贴图
正常游戏引擎都是通过后处理实现glow效果的,不过他这里用的Trick,在于他制作的三角形贴图是自带Bloom效果的,在绘制的,要注意,因为代表关卡的三角形贴图很容易有重叠部分,所以从左到右,Column的Z值需要不断增大,左边的图片不能遮挡右边。

如下图所示,是三角形贴图Z值都相同时会出现的情况,左边贴图的方框遮住了右边的三角形贴图:
在这里插入图片描述


一种Shader的Trick
如下图所示,越在屏幕中心的点,亮度越高,这营造了一种幽暗的环境:
在这里插入图片描述
其实是一种Shader的小技巧,就是在output color里,根据离屏幕边缘的距离,改变整体颜色的四个通道的值,Shader如下所示:

// Basic Texture Shader: Texture.glsl

#type vertex
#version 330 core

layout(location = 0) in vec3 a_Position;
layout(location = 1) in vec2 a_TexCoord;

uniform mat4 u_ViewProjection;
uniform mat4 u_Transform;

out vec2 v_TexCoord;
out vec2 v_ScreenPos;

void main()
{
	v_TexCoord = a_TexCoord;
	gl_Position = u_ViewProjection * u_Transform * vec4(a_Position, 1.0);
	v_ScreenPos = gl_Position.xy;
}

#type fragment
#version 330 core

layout(location = 0) out vec4 color;

in vec2 v_TexCoord;
in vec2 v_ScreenPos;

uniform vec4 u_Color;
uniform sampler2D u_Texture;

void main()
{
	float dist = 1.0f - distance(v_ScreenPos * 0.8f, vec2(0.0f));
	dist = clamp(dist, 0.0f, 1.0f);
	dist = sqrt(dist);
	color = texture(u_Texture, v_TexCoord) * u_Color * dist;
}


Hazel 2020

这一章主要是闲聊,然后聊了聊未来的Scripting Language选择,Hazel决定使用lua作为脚本语言,lua非常简单,其实就是相当于几个C++文件、5000多行代码而已,这里没有选择C#作为脚本语言,然后用Mono来跨平台,是因为这样做工作量太大了。尽管C#是很好用的语言,但基于跨平台的原因,还是不选择它。不过如果只想在Win平台上发布游戏,那么游戏引擎是可以考虑用C#的,此时可以用C++/CLI来负责C++与C#的交互。

顺便看了一下后面的课程,大概路线就是:

  • Renderer2D的批处理
  • SpriteSheet
  • ECS
  • Camera系统完善
  • Native Scripting
  • Game Editor相关的UI

关于C++/CLI

参考:https://stackoverflow.com/questions/1933210/c-cli-why-should-i-use-it
参考:https://stackoverflow.com/questions/1969085/what-is-the-difference-between-ansi-iso-c-and-c-cli

C++/CLI is variant of the C++ programming language, modified for Common Language Infrastructure

C++/CLI是C++语言的一个变体,用于支持CLI标准,它主要是作为一种中间语言(intermediate language),用于在C++里调用.NET的dll。

C++/CLI has a very specific target usage, the language (and its compiler, most of all) makes it very easy to write code that needs to interop with unmanaged code. It has built-in support for marshaling between managed and unmanaged types. It used to be called IJW (It Just Works), nowadays called C++ Interop. Other languages need to use the P/Invoke marshaller which can be inefficient and has limited capabilities compared to what C++/CLI can do.

C++/CLI其实是类似于C#或者VB.NET的编程语言,runs on top of Microsoft’s Common Language Interface。跟C#一样,它也是不直接运行在Machine上的,运行它需要安装.NET Framework,而.NET Framework的一部分职责就是负责把C++/CLI的programs翻译成native programs。

不多说了,以后要用到再了解吧



BATCH RENDERING

这里直接把BeginScene和EndScene之间的绘制代码进行批处理,但是具体说多少个DrawCall进行合批,性能最好,这个还不清楚,可能要具体做实验才可以知道哪一个性能最好。目前是用1万个DrawQuad函数进行一次合批,如果数量在1万以内,那我合成一个DrawCall就行了,如果大于1万,那每多1万,每超过1万的个数就会多一个DrawCall。

不过这节课写的代码,还没做到上面这个程度,仅仅是一帧最多绘制1W个Quad,然后会把这些Quad合并到一个DrawCall上,用到的核心API就是OpenGL的glBufferSubData函数,用于在Vertex Buffer里动态填充数据,写法如下:

// 第一种API, 会返回一个指针, 这个指针指向一块内存,这个内存可以直接Write
// glMapBuffer, glMapNamedBuffer — map all of a buffer object's data store into the client's address space
void *glMapBuffer(GLenum target, GLenum access);
void *glMapNamedBuffer(GLuint buffer, GLenum access);

... // 写入Buffer

// 在完成对Buffer的写入之后, 调用Unmap函数, 把这块内存上传到GPU
GLboolean glUnmapBuffer(GLenum target);
GLboolean glUnmapNamedBuffer(GLuint buffer);

// 第二种API, 这种写法更快, 而且适用的OpenGL的版本越广
// glBufferSubData, glNamedBufferSubData — updates a subset of a buffer object's data store
void glBufferSubData(GLenum target, GLintptr offset, GLsizeiptr size, const void * data);
void glNamedBufferSubData(GLuint buffer, GLintptr offset, GLsizeiptr size, const void *data);

// glBufferSubData的写法与glBufferData的写法很像,但是它不分配内存,只是把data发送给buffer
// 实际使用的时候, 要先绑定到对应的dynamic_draw的buffer
glBindBuffer(GL_ARRAY_BUFFER, m_QuadVertexBuffer);
// 把这一块内存的数据,移到绑定的Array Buffer里, 具体应该是做的Deep Copy吧
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(vertices), vertices);

具体步骤如下:

  • 在Renderer2D的Init函数里,创建动态可更新的VertexBuffer
  • 在Renderer2D的Init函数里,创建静态的IndexBuffer
  • 修改Renderer2D的static SceneData数据,把里面的VertexArray里的Vertex Buffer调整为1W个Quad大小的动态Buffer,Index Buffer调整为1W个Quad大小的静态Buffer,创建时,俩Buffer里的数据都是uninitialized data
  • 修改DrawQuad函数,让其绘制时动态往Vertex Buffer里填充要绘制的顶点属性数据,同时记录绘制Quad的个数,目前只支持绘制FlatColor,DrawQuad对应的FlatColor颜色会作为颜色的顶点属性存在Vertex Buffer里
  • EndScene里,根据记录绘制Quad的个数,填充IndexBuffer里的数据,然后调用DrawCall绘制这些Quads

创建动态VertexBuffer

之前的构造函数是这样的,是根据静态数据创建静态的Vertex Buffer:

class VertexBuffer
{
public:
	...
	// 注意这个static函数是在基类声明的, 会根据当前Renderer::GetAPI()返回VertexBuffer的派生类对象
	static VertexBuffer* Create(float* vertices, uint32_t size);
protected:
	uint32_t m_VertexBuffer;
};

添加一个新的VertexBuffwr的构造函数 无非传输的数据为空指针,类型从GL_STATIC_DRAW改成GL_DYNAMIC_DRAW:

OpenGLVertexBuffer::OpenGLVertexBuffer(float* vertices, uint32_t size)
{
	glGenBuffers(1, &m_VertexBuffer);
	glBindBuffer(GL_ARRAY_BUFFER, m_VertexBuffer);
	glBufferData(GL_ARRAY_BUFFER, size, vertices, GL_STATIC_DRAW);	//从CPU传入了GPU
}

OpenGLVertexBuffer::OpenGLVertexBuffer(uint32_t size)
{
	glGenBuffers(1, &m_VertexBuffer);
	glBindBuffer(GL_ARRAY_BUFFER, m_VertexBuffer);
	glBufferData(GL_ARRAY_BUFFER, size, nullptr, GL_DYNAMIC_DRAW);	//从CPU传入了GPU
}

目前的IndexBuffer,暂时就不用动态的了,因为批处理都是固定绘制1W个Quad,目前也只会绘制Quad


创建静态的IndexBuffer

IndexBuffer依旧是静态的,只不过是里面的容量变大了而已:

// 3. 创建Index Buffer
std::unique_ptr<uint32_t[]> indices = std::make_unique<uint32_t[]>(s_Data.MaxIndices);
uint32_t curVertexIndex = 0;
for (size_t i = 0; i < s_Data.MaxIndices; i += 6)
{
	indices[i] = curVertexIndex;
	indices[i + 1] = curVertexIndex + 1;
	indices[i + 2] = curVertexIndex + 2;

	indices[i + 3] = curVertexIndex + 1;
	indices[i + 4] = curVertexIndex + 3;
	indices[i + 5] = curVertexIndex + 2;

	curVertexIndex += 4;// 每经过6个index, 完成一个Quad的绘制, 也就是4个顶点
}

// TODO: 如果改成多线程渲染或者单纯放到CommandQueue里, 可能会有问题, 可能出现实际创建Buffer时, indices的内存被释放的情况
auto quadIndexBuffer = std::shared_ptr<IndexBuffer>(IndexBuffer::Create(&indices[0], sizeof(uint32_t) * s_Data.MaxIndices));

修改Renderer2D的static SceneData数据

原本的Renderer2D一次只会渲染一个Quad,所以它的数据比较简单,如下所示:

// Renderer2D.cpp
struct Renderer2DStorage
{
	// VertexArray里存了vertex buffer、index buffer和对应的vertex Layout
	std::shared_ptr<VertexArray> QuadVertexArray;		// 代表Quad的VertexArray
	std::shared_ptr<Shader> Shader;						// 目前的2DRenderer只需要一个Shader
	std::shared_ptr<Texture2D> WhiteTexture;		
};

已有的创建QuadVertexArray的流程为先创建Vertex Buffer、再设置顶点Buffer的Layout、再创建Index Buffer、最后创建Vertex Array并填充相关数据,代码如下:

// 1.创建Vertex Buffer
float quadVertices[] =
{
	-0.5f, -0.5f, 0, 0.0f, 0.0f,
	 0.5f, -0.5f, 0, 1.0f, 0.0f,
	-0.5f,  0.5f, 0, 0.0f, 1.0f,
	 0.5f,  0.5f, 0, 1.0f, 1.0f
};

// CPU数据传给Vertex Buffer
auto quadVertexBuffer = std::shared_ptr<VertexBuffer>(VertexBuffer::Create(quadVertices, sizeof(quadVertices)));
quadVertexBuffer->Bind();

// 2.创建Layout,会计算好Stride和Offset
BufferLayout layout =
{
	{ ShaderDataType::FLOAT3, "a_Pos" },
	{ ShaderDataType::FLOAT2, "a_Tex" }
};
quadVertexBuffer->SetBufferLayout(layout);

// 3.创建Index Buffer
int quadIndices[] = { 0,1,2,2,1,3 };
auto quadIndexBuffer = std::shared_ptr<IndexBuffer>(IndexBuffer::Create(quadIndices, sizeof(quadIndices)));

// 4.创建Vertex Array, 填充数据
s_Data->QuadVertexArray.reset(VertexArray::Create());
s_Data->QuadVertexArray->Bind();
quadIndexBuffer->Bind();
s_Data->QuadVertexArray->AddVertexBuffer(quadVertexBuffer);
s_Data->QuadVertexArray->SetIndexBuffer(quadIndexBuffer);

修改后的s_Data也大致差不多,无非是IndexBuffer的Size变成了1W,还有就是Vertex Buffer从原本的StaticDraw变成了DynamicDraw:

struct Renderer2DData
{
	const uint32_t MaxQuads = 10000;
	const uint32_t MaxVertices = MaxQuads * 4;
	const uint32_t MaxIndices = MaxQuads * 6;

	std::shared_ptr<VertexArray> QuadVertexArray;		// 这三个还是不变
	std::shared_ptr<Shader> Shader;						// 目前的2DRenderer只需要一个Shader
	std::shared_ptr<Texture2D> WhiteTexture;		
};


// 为了方便更改QuadVertex的数据, 直接设计一个Struct来代表QuadVertex的数据:

struct QuadVertex
{
	glm::vec3 Position;
	glm::vec4 Color;			// 加了个Color
	glm::vec2 TexCoord;
	// TODO: texid, normal,.etc
};

// 创建动态的Vertex Buffer时就这么写,分配1W个Quad个的顶点缓存, 也就是4W个顶点
VertexBuffer::Create(s_Data.MaxVertices * sizeof(QuadVertex));// s_Data.MaxVertices = 40000

注意,这里的QuadVertex里加了个Color的顶点属性,这其实也是一种变相的批处理,因为之前,在Shader里,有一个u_Color的uniform:

out vec4 color;
uniform sampler2D u_Texture;
// when draw Texture, it is TintColor, but when draw color, this is the output color
uniform vec4 u_Color;
uniform float u_TilingFactor;

void main()
{
	color = texture(u_Texture, TexCoord * u_TilingFactor) * u_Color;
} 

这里绘制不同的颜色的Quad时,会调用不同的DrawCall,为了把它合并从一个DrawCall,可以把颜色信息放到Vertex Atrribute里



修改DrawQuad函数

Renderer2D的DrawQuad函数位于BeginScene和EndScene之间,原本的DrawQuad函数只是单纯的调用一次DrawCall,但是批处理后的DrawQuad函数的做法是,每次调用DrawQuad函数,就去填充动态Vertex Buffer里对应位置的顶点的顶点属性数据。同时,这里会去检查调用DrawQuad函数的累计次数,如果正好到了1W次,那么就绘制这个超大的Vertex Buffer,然后Reset其内部数据。

代码如下:

struct Renderer2DData
{
	...
	/// 添加这三个数据, 用于动态更改Vertex Buffer和记录绘制的三角形个数
	uint32_t QuadIndexCount = 0;
	QuadVertex* QuadVertexBufferBase = nullptr;
	QuadVertex* QuadVertexBufferPtr = nullptr;
}

void Renderer2D::DrawQuad(const glm::vec2& position, const glm::vec2& size, const glm::vec4& color)
{
	// 在Vertex Buffer里填入四个顶点的Vertex Attributes数据
	s_Data.QuadVertexBufferPtr->Position = position;
	s_Data.QuadVertexBufferPtr->Color = color;
	s_Data.QuadVertexBufferPtr->TexCoord = { 0.0f, 0.0f };

	s_Data.QuadVertexBufferPtr->Position = { position.x + size.x, position.y, 0.0f };
	s_Data.QuadVertexBufferPtr->Color = color;
	s_Data.QuadVertexBufferPtr->TexCoord = { 1.0f, 0.0f };

	s_Data.QuadVertexBufferPtr->Position = { position.x + size.x, position.y + size.y, 0.0f };
	s_Data.QuadVertexBufferPtr->Color = color;
	s_Data.QuadVertexBufferPtr->TexCoord = { 1.0f, 1.0f };

	s_Data.QuadVertexBufferPtr->Position = { position.x, position.y + size.y, 0.0f };
	s_Data.QuadVertexBufferPtr->Color = color;
	s_Data.QuadVertexBufferPtr->TexCoord = { 0.0f, 1.0f };

	s_Data.DrawedVerticesSize += sizeof(QuadVertex) * 4;
	s_Data.DrawedTrianglesCnt += 2;
}

Batching Rendering Textures

基本思路是在提供的GPU槽位上绑定尽可能多的贴图,然后让Vertex Attribute里包含使用的Texture的id。这个贴图槽位数,即Texture slot limit,取决于GPU。A desktop GPU至少会有32个贴图槽位,而手机则至少有8个,技术层面上,向GPU驱动去查询GPU的最多贴图槽位,这样是比较合理的。但是目前还是就写成最多32个槽位,因为查询GPU相关参数这个功能还比较麻烦。

另外,为了让使用相同的贴图的DrawQuad函数能合并使用同一张贴图,需要设置一个数据结构,用于记录已经用于绘制的贴图,类似于map,key为贴图资源的引用,value为贴图绑定的槽位,这样,当绘制一个带Texture的Quad时,它会去检查map,如果有key,就取得对应的贴图槽位,存到顶点属性里,合并到一个DrawCall内。当然,这个map可能还不止32个key,所以最多一次DrawCall是绘制32种贴图的Quad,但对于2D的Renderer来说,由于Texture Atlas存在,这种超过32个贴图的情况很少见,就先不考虑了。感觉用array代替map也行,无非是把数组的id作为槽位就可以了。

注意:这里的Texture的Key需要是一个unique identifier,这里可以临时使用OpenGL的TextureID,但是对于游戏引擎而言,贴图是一种资源,游戏引擎应该有自己的资源系统,对于任何一种资源,引擎都应该为其生成一个资源的Unique ID,作为Asset Handle,比如Unity把资源的ID存到了其.meta文件里。而且为资源生成的Asset Handle不应该存在资源文件里,正常情况下,即使资源文件被美术家修改了,其Asset Handle也不应该变,因为很可能其他很多地方都记录了这个文件的引用,就像Unity里更新资源文件,不更新其.meta文件一样

具体思路如下:

  • 创建Texture数组,数组大小为32,数组id对应的是贴图槽位,数组元素是Texture的ID,会在BeginScene里被重置,即数组元素全部为0,然后记录一个s_Data.CurrentTextureSlotID,在BeginScene被初始化为1(因为0号槽位预定给了WhiteTexture使用,用于绘制FlatColor)
  • 修改Shader文件,用来同时适配32个Texture Uniform槽位

修改Shader文件

其实就是写法需要熟悉一下而已:

// Basic Texture Shader

#type vertex
#version 330 core

layout(location = 0) in vec3 a_Position;
layout(location = 1) in vec4 a_Color;
layout(location = 2) in vec2 a_TexCoord;
layout(location = 3) in float a_TexIndex;			// 多了俩顶点属性, 这里为啥不传int?
layout(location = 4) in float a_TilingFactor;

uniform mat4 u_ViewProjection;

out vec4 v_Color;
out vec2 v_TexCoord;
out float v_TexIndex;
out float v_TilingFactor;

void main()
{
	v_Color = a_Color;
	v_TexCoord = a_TexCoord;
	v_TexIndex = a_TexIndex;
	v_TilingFactor = a_TilingFactor;
	gl_Position = u_ViewProjection * vec4(a_Position, 1.0);
}

#type fragment
#version 330 core

layout(location = 0) out vec4 color;

in vec4 v_Color;
in vec2 v_TexCoord;
in float v_TexIndex;
in float v_TilingFactor;

uniform sampler2D u_Textures[32];// 改成了数组, 写法跟C++数组相同

void main()
{
	color = texture(u_Textures[int(v_TexIndex)], v_TexCoord * v_TilingFactor) * v_Color;
}

另外,之前是用glUniform1i()来上传Texture的id的,现在需要改成新的API,来上传int数组的uniform:

void OpenGLShader::UploadUniformIntArr(const std::string & uniformName, int * number, size_t count)
{
	glUniform1iv(glGetUniformLocation(m_RendererID, uniformName.c_str()), count, number);
}

写这节课的代码时,因为vertex shader向fragment shader传了个int,还学了点额外的内容,都放在附录里了


Drawing Rotated Quads

这节课就比较简单了,无非是把Rotation也在CPU里算出来,然后传到顶点属性的Position里,这里顺便优化了一下代码,就不多说了。



Renderer Stats and Batch Improvements

这节课主要有这么几个目的:

  • 添加Renderer的相关Statistics信息,比如当前帧调用了几个DrawCall?绘制了多少个Quad
  • 改进Batch系统,因为目前一帧最多只能绘制1W个Quads
  • 用ImGui把Stats绘制出来

添加Statistics类

很简单其实:

// Renderer2D里
class Renderer2D
{
public:

	...

	// For Debuging
	struct Statistics
	{
		uint32_t DrawCallCnt;
		uint32_t DrawQuadCnt;
		
		uint32_t DrawVerticesCnt() { return DrawQuadCnt * 4; }
		uint32_t DrawTrianglesCnt() { return DrawQuadCnt * 2; }
	};

	static Statistics GetStatistics();// 会在2DRendererData里存一个Statistics对象

private:
	static void Flush();
	static void ResetBatchParams();

}

多的就不说了,都在代码里,不难



附录

因为glDrawElements引起的Bug

出这种OpenGL的bug是真的不好查。。。。我本来想绘制一个Quad,DrawCall是这么写的:

// count我以为是2, 因为画俩三角形
glDrawElements(GL_TRIANGLES, 2, GL_UNSIGNED_INT, nullptr);

其实这里的count指的是index buffer里的个数,一个quad是四个顶点,记了6个index,所以这么写才对:

// count我以为是2, 因为画俩三角形
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);

glUniform后面字母的命名规则

参考:https://blog.600mb.com/a?ID=00500-ac818c14-ec0a-4385-870e-4fc601f2bbbf

glUniform function Function name:
Specify the value of the Uniform variable for the current program object. (Translator’s Note: Note that since OpenGL ES is written in C language, but C language does not support function overloading, there will be many function versions with the same name and different suffixes. The function names contain numbers (1, 2, 3 , 4) It means accepting this number to change the value of the uniform variable, i means 32-bit integer, f means 32-bit floating point, ub means 8-bit unsigned byte, ui means 32-bit unsigned integer, v means accept corresponding Pointer type.)

由于C语言不允许函数重载,所以这里用后面加字母的方式来定义不同函数签名的函数。if分别代表32位的有符号整型和浮点数,ub代表8为的unsinged byte(即unsigned char),ui代表无符号整型,v代表对应的指针类型(我之前一直以为v是vector向量的意思)



从Vertex Shader给Fragment Shader传int数据产生的报错

写glsl的Shader时出现了Link编译报错:

[17:23:46] Hazel: Link Shaders Failed!:Fragment info
-------------
0(5) : error C5215: Integer varying v_TexIndex must be flat

[17:23:46] Console: Assertion Failed At: Link  Shaders Error Stopped Debugging!

我的俩Shader是这么写的:

//  vertex shader

#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec2 aTex;
layout(location = 2) in vec4 aCol;
layout(location = 3) in int aTexIndex;

out vec2 v_TexCoord;
out vec4 v_Color;
out int v_TexIndex;

uniform mat4 u_ViewProjection;

void main()
{
	gl_Position = u_ViewProjection * vec4(aPos, 1.0);
	v_TexCoord = aTex;
	v_Color = aCol;
	v_TexIndex = aTexIndex;
}

// fragment shader

#version 330 core

in vec2 v_TexCoord;
in vec4 v_Color;
in int v_TexIndex;

out vec4 color;
uniform sampler2D u_Texture[32];
uniform float u_TilingFactor;

void main()
{
	color = texture(u_Texture[v_TexIndex], v_TexCoord * u_TilingFactor) * v_Color;
}

参考:https://stackoverflow.com/questions/27581271/flat-qualifier-in-glsl
参考:https://stackoverflow.com/questions/28514892/why-cant-i-add-an-int-member-to-my-glsl-shader-input-output-block

原因是,着色器之间不允许直接传入int,因为整型数字是不支持插值的。至于为什么要支持插值,这是因为,绘制的时候是用点来表示Primitive的,而几个点构成的Primitive里的每个像素点的值都是根据周围几个点,通过三角形的重心坐标插值得到的,而int是不允许插值的,所以这里会报错。

如果非要加的,那么需要加上flat关键字:

Fragment shader inputs that are signed or unsigned integers, integer vectors, or any double-precision floating-point type must be qualified with the interpolation qualifier flat.

flat意味着没有插值,至于为什么叫flat,可以参考Flat Shading和Smooth Shading,正常的插值操作发生在Smooth Shading里,而Flat Shading,往往是一个面就只有一个颜色。

修改后的Shader代码如下:

// vertex shader
...
layout(location = 3) in int aTexIndex;// 前面的不变
...
flat out int v_TexIndex;// 输出时的值不插值
...
void main()
{
	gl_Position = u_ViewProjection * vec4(aPos, 1.0);
	v_TexCoord = aTex;
	v_Color = aCol;
	v_TexIndex = aTexIndex;
}

// fragment shader
...
flat in int v_TexIndex;// 接受不插值的值
... // 其他的不变

因为传入GL_INT引发的惨案

参考:https://www.khronos.org/registry/OpenGL-Refpages/es3.0/html/glVertexAttribPointer.xhtml
参考:https://stackoverflow.com/questions/34442754/can-i-use-glvertexattribpointer-instead-of-glvertexattribipointer

基于上面写的从Vertex Shader给Fragment Shader传int数据的方法,我直接把int传给了Vertex Array,但是绘制结果怎么都不对。查了一晚上bug,终于发现:GL_INT可以用于glVertexAttribPointer函数和glVertexAttribIPointer函数,但是用法是不一样的。

Data for an array specified by VertexAttribPointer will be converted to floating-point by normalizing if normalized is TRUE, and converted directly to floating-point otherwise. Data for an array specified by VertexAttribIPointer will always be left as integer values; such data are referred to as pure integers.

GL_INT用于前者时,会被转换为浮点数,只有使用VertexAttribIPointer才能保留成整型

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值