我自己维护引擎的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里面自然会去读取对应的shadertex->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 stuffImFont* 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为1Level();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: valuefloat 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, 点为1glm::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, 点为1glm::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, 点为1glm::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 trifor (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]区间产生随机velocityglm::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为1000uint32_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 corelayout(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 corelayout(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 Layoutstd::shared_ptr<VertexArray> QuadVertexArray;        // 代表Quad的VertexArraystd::shared_ptr<Shader> Shader;                      // 目前的2DRenderer只需要一个Shaderstd::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只需要一个Shaderstd::shared_ptr<Texture2D> WhiteTexture;
};// 为了方便更改QuadVertex的数据, 直接设计一个Struct来代表QuadVertex的数据:struct QuadVertex
{glm::vec3 Position;glm::vec4 Color;            // 加了个Colorglm::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 corelayout(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 corelayout(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 Debugingstruct 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 corein 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才能保留成整型

Hazel引擎学习(七)相关推荐

  1. Hazel引擎学习(五)

    我自己维护引擎的github地址在这里,里面加了不少注释,有需要的可以看看 Render Flow And Submission 背景 在Hazel引擎学习(四),从无到有,绘制出了三角形,然后把相关 ...

  2. Hazel引擎学习(四)

    我自己维护引擎的github地址在这里,里面加了不少注释,有需要的可以看看 Hazel引擎是dll还是lib? 引擎作为dll的优点: hotswapping code Easy to Link 引擎 ...

  3. Hazel引擎学习(三)

    我自己维护引擎的github地址在这里,里面加了不少注释,有需要的可以看看 一. Layer 设计完Window和Event之后,需要创建Layer类.Layer这个概念比较抽象,具体在游戏里,比如游 ...

  4. Hazel引擎学习(八)

    我自己维护引擎的github地址在这里,里面加了不少注释,有需要的可以看看 参考视频链接在这里 Testing Hazel's Performance 这节课主要是对当前的Hazel游戏引擎进行性能测 ...

  5. Hazel引擎学习(十一)

    我自己维护引擎的github地址在这里,里面加了不少注释,有需要的可以看看 参考视频链接在这里 很高兴的是,引擎的开发终于慢慢开始往深了走了,前几章的引擎UI搭建着实是有点折磨人,根据课程,接下来的引 ...

  6. Hazel引擎学习(一)

    我自己维护引擎的github地址在这里,里面加了不少注释,有需要的可以看看 Project Setup 1.SetUp项目,生成的文件在bin目录下,生成的intermediate文件在bin-int ...

  7. Hazel引擎学习(六)

    我自己维护引擎的github地址在这里,里面加了不少注释,有需要的可以看看 How to Build a 2D Renderer 不管是3D还是2D的游戏引擎,都需要渲染2D的东西,因为一个游戏里是必 ...

  8. velocity(vm)模板引擎学习介绍及语法

    velocity模板引擎学习 velocity与freemaker.jstl并称为java web开发三大标签技术,而且velocity在codeplex上还有.net的移植版本NVelocity,( ...

  9. STL源码剖析学习七:stack和queue

    STL源码剖析学习七:stack和queue stack是一种先进后出的数据结构,只有一个出口. 允许新增.删除.获取最顶端的元素,没有任何办法可以存取其他元素,不允许有遍历行为. 缺省情况下用deq ...

最新文章

  1. 自动驾驶中的9种传感器融合算法
  2. 在Mac上写汇编!(一)helloworld nasm on macos
  3. 【Linux】一步一步学Linux——unset命令(202)
  4. AF_UNIX和AF_INET
  5. 重磅!微软发布新一代 Teams 开发工具 —— Teams Toolkit!不止VS Code extension!
  6. JoyOI(TYVJ)1071-LCIS【线性dp,LIS,LCS】
  7. IBM研究院计画5年改变人类生活创新预测
  8. Visual C++ 时尚编程百例016(字体)
  9. JupyterHub与OpenLDAP集成
  10. java多线程-基础知识
  11. C语言实现原码一位乘法
  12. 研华工控台式计算机选型,工控机选型手册.pdf
  13. rpm -ivh安装mysql_RPM 命令详细介绍
  14. 通用 DAO 接口设计
  15. 让AI能懂得人类的社交讯号 使AI分辨人类的个性特质
  16. Cesium开发:简单箭头画法
  17. [转贴]周星驰经典对白
  18. 企业青睐什么样的产品经理
  19. DEDE自动调用轮播图/幻灯片
  20. Qt的QImage类

热门文章

  1. Centos杀死进程kill方法大全
  2. 判断键盘输入的数是几位数且是否是回文数
  3. 黑客可入侵自动洗车系统暴力攻击驾驶人
  4. 【论文笔记】MLFF-GAN:A Multilevel Feature Fusion WithGAN for Spatiotemporal Remote Sensing Images
  5. 人体骨骼关键点检测的算法
  6. Python3中省略号(...)用法介绍
  7. 若查找课程表中课程名称是计算机或英语,若查找“课程表”中课程名称是“计算机”或“英语”的记录,应在查询设计视图的“课程名称”列条件行中输入()。...
  8. 使用接口根据关键词取视频列表详情
  9. 炒鸡简单的javaScript的call和apply方法
  10. 彻底清除已删除的文件