我自己维护引擎的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语言不允许函数重载,所以这里用后面加字母的方式来定义不同函数签名的函数。i
和f
分别代表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
才能保留成整型