当前位置: 首页 > article >正文

cherno引擎课 -

感谢b站星云图形的翻译:【双语】【最佳游戏引擎教程实战】【入门】(1):Introducing the GAME ENGINE series!_哔哩哔哩_bilibili

Introducing the GAMEENGINE series 

希望:它是一个制作(互动)3D实时渲染应用程序的引擎。

本质是一个数据转换机器,基本上就是读取数据,然后引擎对数据进行某些处理。

什么是游戏引擎

我们要构建的是一种,读取文件、转换、然后展现到屏幕上的东西,且有交互能力。程序的具体行为是面向数据的,而非硬编码的。

游戏引擎通常包含一个平台、一套工具,可以创建那些资产。它为我们提供创建资源的方式。

比如用 PS 做纹理、用 3D Studio Max 或者 Maya 或者 Blender 等制作3D模型,但是工作的最后仍然需要将那个3D模型转换为游戏引擎真正接受的格式。因为对于大多数正经的大型游戏引擎,几乎从来不会直接读取 png、jpeg 或者 obj 模型之类的。它们通常会读取游戏引擎特定的内建的自定义的格式。可能是纹理、模型、关卡等等

内容创作者有职责把模型从第三方格式转换为游戏引擎格式,比如要定义所有引擎需要的东西,要对那个模型做一些事情。因为通常来说,引擎可能需要更多从 Maya 导出的模型本身包含的数据。

为了让我们的数据转换更方便,我们需要大量的各种各样的系统,包括平台抽象层,能让我们的代码运行在不同的平台上。

DESIGNING our GAME ENGINE

先聚焦于构建需要的最小的基础框架,能够运行,然后我们不停迭代、增强、增加更多特性、提高稳定性、更好的性能和优化,和其他之类的事情。

一个游戏引擎需要些什么(只是看看要包括些什么,并不是实现的顺序)。

第一点要做的是需要一个入口点:Entry Point

一个入口点本质上就是当我们希望我们的应用程序或者游戏使用这个引擎启动的时候会发生什么。比如是什么控制了 main 函数?什么控制了main函数的代码执行?

之后要有一个 Application Layout (应用层):处理程序的生命周期和事件之类的东西的那部分代码。比如说我们的主循环,也就是保持我们的程序运行和渲染的那个循环,也就是保持时间流动的那个循环。也就是执行(驱动)我们的游戏希望执行的全部代码的那个循环。关于事件比如改变窗口大小或者关闭窗口,或者输入事件,比如鼠标或者键盘,所有这些东西都要在应用层处理。

我们需要一种方式来让我们的游戏或者引擎作为一个实际的应用程序运行在任何可能要运行的平台上,这就是应用层要解决的。

接下来需要一个 Window Layout (窗口层)。然后在这一层里面我们要处理 input (输入)和 event (事件)。由于输入可以放到事件里面(输入事件),所以我们的事件管理系统会处理输入事件,从我们的窗口和应用程序捕获输入事件。事件管理系统也会是非常重要的东西,我们需要构建一个非常基础的消息和广播系统。基础的意思是说只要,我们应用程序内的某些层可以订阅事件。并且当事件发生的时候,可以从应用层的堆栈获得通知。当事件传播到那些层的时候,那些层可以选择是否要把事件传播到哪些东西,就像一个所有者系统。某种程度上它就是一个基础的消息系统。当事件发生的时候,我们可以中断,或者不是真的中断。总之事件发生的时候,然后我们会得到通知,本质上就是因为我们的 onEvent() 函数会被调用。

然后是渲染器 Render ,可能会是我们需要处理的最大的系统之一。渲染器就是实际渲染图形到屏幕上的那个东西。

接下来是 Render API abstraction 。最开始我们只会用 OpenGL ,之后还需要各种 API 。我们要把所有的东西设置成API无关的,这样新增一种 API 就不是完全重写。显然有的东西不得不是API独特的,比如我们可以就写一个叫 “上载纹理” 的函数,然后为四种API各做一个实现不同,因为我们创建渲染器的方式会因为API而不同。因为如果你用Vulkan的方式,相比于使用OpenGL的方式做特定的事情的时候vulkan可能会更高效。因此我们仍然需要写两份代码,而不是把每个 OpenGL 的函数拷贝一份

接着是 Debugging Support 调试支持。就比如日志系统,比如性能,为此我们需要一种分析系统,我们希望应用程序能有一种特殊的运行方式,在 VisualStudio 设置之外的特殊的模式。我们希望应用程序能够自己运行调试,因此可以运行在任何平台上。而不用担心,运行特定平台可用的特定工具。我们希望把工具代码插入到自己的代码中,但是(工具代码)只在调试模式下运行。可以为每个函数计时并且汇总成好看的视图,任何也许用某种工具查看之类的。

然后还希望有一些 Scripting 脚本,比如 Lua 或者 C# 或者 Python 之类的。需要脚本语言,避免完全使用 C++。我们可以像艺术家和内容创造者那样轻松地写高级脚本语言而不用担心内存的问题。

还有 Memory System 内存系统。需要管理好资源。以及调试内存之类的。

还需要实体组件系统(ECS,Entity Component System)。这是一种让我们能在世界创建游戏对象的模块化的方式。比如让世界里的每个独立实体或游戏对象能包含特定的组件或系统,因此我们能定义行为以及动作的具体细节。它就是一种让我们能定义引擎要对实体做的事情的方式。

还需要 Physics Solution 物理解算。

还有 File I/O ,VFS(虚拟文件系统)

还有Build System 构建系统,把3D模型或材质需要能转换为自定义格式,那是为我们的引擎优化的格式。所以不用在运行时浪费时间转换格式,因为我们可以离线处理。热交换资产,希望比如开着 PS 在纹理上画了一些东西,按下 Ctrl + S,我们希望在运行时构建系统捕获然后实时地重新构建导入游戏,所以我们能更新东西,甚至比如3D模型,调整一些顶点或者进行某种修改,然后就热交换到引擎里。所以我们能在游戏运行时修改,这不是一个非常重要的系统,现在可能不值得讨论。

不过暂时我们只支持Windows只支持OpenGL。我们的C++代码文件里不会包含任何Windows代码,比如 Win32 API 代码。因为显然引擎要在未来支持其他的平台。所以会抽象那些东西,保证平台或渲染API独特的代码分散在它自己的文件里。在其他平台或者渲染API的时候它们不会被编译

Project Setup

这一节我们要设置好所有东西:Github仓库、Visual Studio 解决方案和项目、依赖配置,然后我们要链接项目到一起,做一个屏幕上打印 Hello World 的简单程序。但是会为我们的游戏引擎组织好结构和配置。

GitHub上建立好工程,选择 Apache-2.0 License ,具体选什么 License 可以看下面这个图:

这里我们把引擎选择编译成一个 DLL ,然后外部链接上最终的 exe ,选择 DLL 的原因是我们可以自己选择加载或者卸载 DLL ,主要的原因是我们会有很多依赖,我们会需要链接很多库到引擎里。如果用静态库的话,我们的所有的静态库都会被链接到游戏里面,我们的引擎依赖那些库,如果使用静态库的话,那些需要链接到引擎的库文件实际上全部会链接到游戏。用DLL的话基本上就像一个exe文件,所以我们可以把所有东西都链接到那个DLL,然后我们的游戏只会依赖那一个单独的包含了全部需要的内容的DLL文件,而不是无数的其他的库文件。

所以本质上我们要做的,就是把所有的依赖都链接到那个引擎的DLL文件,这意味着它们都需要是静态库。我们需要做的就是,把所有静态库链接到引擎DLL然后把引擎DLL链接到游戏。

(👆这一段没读懂,原视频是这么个意思,但是听不懂)

设置引擎为一个库文件(dll),在外部将库文件链接到外部的应用项目(exe)
      (静态库的形式类似于将一大堆库链接到游戏中)
      (动态库的形式类似于将一大堆外部库先链接到dll文件中,再将这个dll文件链接到游戏中,这样我们的游戏只会依赖于这一个dll文件)

Cherno游戏引擎笔记记录(1~14)_c++cherno游戏引擎-CSDN博客 的解释,这个听懂了

一些vs设置可以参考文章:Cherno_游戏引擎系列教程(1):1~16_game engine series-CSDN博客

Entry Point Game Engine Series

代码的结构:Hazel 是一个dll项目,Sandbox(沙盒) 是一个exe

什么是沙盒(sandbox)

sandbox(沙盒)是一种安全机制,用于限制程序的访问权限和行为范围。它创建了一个受限的执行环境,将程序隔离在其中,以防止恶意代码或不安全的操作对系统造成损害。

引擎入口点

引擎入口点(Engine Entry Point)通常指的是一个程序的起始执行位置,也可以被称为主函数(Main Function)。程序从这里开始执行,并按照预定的流程继续执行。
例如,在C语言中,引擎入口点通常被命名为main函数,它是程序的起始位置。在C++中,引擎入口点可以是全局的main函数,也可以是类的静态成员函数。

上一节我们的 Sandbox 的项目的 Application.cpp是这样写的:

namespace HEngine
{
	__declspec(dllimport) void Print();
}

void main()
{
	HEngine::Print();
}

这意味着 main 函数是由 Application 定义的,但是引擎的入口应该由引擎负责定义,我们希望这些是由引擎端控制的。我们要在 Sandbox 项目里创建一个 Application 类,来定义和启动我们的应用程序。我们还要把 __declspec(dllimport) 和 __declspec(dllexport) 写到宏里,然后我们可以重用头文件。

宏(条件判断的实现逻辑)

#define 宏名称 值或代码

#ifdef 标识符
    // 如果标识符已经被定义,则编译这部分代码
#else
    // 如果标识符没有被定义,则编译这部分代码
#endif

宏不会自动定义。如果在 属性页 -> C++ -> 预处理器 中填入一个宏XXX,这意味着在编译代码时会自动在预处理阶段为XXX这个宏定义一个值。
这样不用手动编写一个宏,可以直接使用#ifdef语句进行条件判断。

日志系统

日志就是我们记录事件的一种方式。这里的事件不只是字面意义,因为事件可以是任何东西。

我们要写一个日志库。日志最大的议题是格式化不同的类型。只是打印一个字符串很简单,但是我们希望能打印的不只是文本,所以需要一种好的格式化方式,不定参的格式化。

总之,我们要使用一个叫 spdlog 的库:https://github.com/gabime/spdlog

C++没有定义导入和使用库的方法。很多时候基本上就是选择一个你想用的构建系统,比如 CMake 或者 Premake 之类的。然后保证你使用的每个库写到构建系统里。像这样你可以更新和维护它。或者用 git-submodule 添加它们,如果你用 GitHub 就可以用 git-submodule ,这可能是最好的方式。

我们要做的就是添加一个 .gitmodules 文件,然后我们实际克隆 HEngine 的时候,也会克隆所有 submodule 。这很有用,因为可以持有一个版本的完整代码。

子模版?

git submodule add 命令会在主仓库中创建一个指向子模块仓库的链接,并将子模块仓库克隆到指定的目录下。
这个链接存储在主仓库的 .gitmodules 文件中,以便记录和管理子模块的相关信息。

通过将外部依赖库作为子模块添加到主仓库中,你可以保持主仓库和子模块仓库的独立性。
这意味着主仓库和子模块仓库可以分别进行版本控制和更改,而不会相互干扰
当你在不同的项目中使用相同的外部依赖库时,你只需要在这些项目中添加子模块的链接,而不必重复复制和维护这些外部依赖库的副本。

我们希望 HEngine::Log 而不是 spdlog::log,于是我们在引擎中创建了一个Log类。我们要做的是创建两个控制台,一个客户的,一个引擎的,一个叫 Core,一个叫 App。

Log.h

#pragma once

#include "Core.h"
#include "memory.h"
#include "spdlog/spdlog.h"

namespace Hazel {
	class HAZEL_API Log
	{
	public:
		static void Init();

		inline static std::shared_ptr<spdlog::logger>& GetCoreLogger() {
			return s_CoreLogger;
		}
		
		inline static std::shared_ptr<spdlog::logger>& GetClientLogger() {
			return s_ClientLogger;
		}
	private:
		static std::shared_ptr<spdlog::logger> s_CoreLogger;
		static std::shared_ptr<spdlog::logger> s_ClientLogger;

	};
}

Log.cpp

#include "Log.h"
#include <spdlog/sinks/stdout_color_sinks.h>
namespace Hazel {
	std::shared_ptr<spdlog::logger> Log::s_CoreLogger;
	std::shared_ptr<spdlog::logger> Log::s_ClientLogger;

	void Log::Init() {
		spdlog::set_pattern("%^[%T] %n: %v%$");
		s_CoreLogger = spdlog::stdout_color_mt("Hazel");
		s_CoreLogger->set_level(spdlog::level::trace);

		s_ClientLogger = spdlog::stdout_color_mt("APP");
		s_ClientLogger->set_level(spdlog::level::trace);
	}
}

 EntryPoint 调用

#pragma once
#ifdef HZ_PLATFORM_WINDOWAS

extern Hazel::Application* Hazel::CreateApplication();

int main(int argc,char** argv) {

	Hazel::Log::Init();
	Hazel::Log::GetClientLogger()->warn("Initialzed Log!");
	Hazel::Log::GetCoreLogger()->info("Hello!");

	printf("Hazel Engine\n");
	auto app = Hazel::CreateApplication();
	app->Run();
	delete app;
	return 0;
}
#endif // HZ_PLATFORM_WINDOWS

可以使用宏来减少使用时写的代码量

#define HZ_CORE_ERROR(...) ::Hazel::Log::GetCoreLogger()->error(__VA_ARGS__)
#define HZ_CORE_WARN(...) ::Hazel::Log::GetCoreLogger()->warn(__VA_ARGS__)
#define HZ_CORE_INFO(...) ::Hazel::Log::GetCoreLogger()->info(__VA_ARGS__)
#define HZ_CORE_TRACE(...) ::Hazel::Log::GetCoreLogger()->trace(__VA_ARGS__)

双下划线   "__"   ---->   预定义的宏

定义:双下划线 "" 表示这是一个预定义的宏,由编译器或标准库定义。
目的:一些预定义的宏都包含双下划线 "__",例如 __cplusplus、LINE、FILE 等等。这样设计的目的是为了避免与用户自定义的标识符冲突,并且提供一些方便的功能。

(...) 和 __VA_ARGS__配对使用

1.(...) 是可变参数模板的语法,表示宏函数可以接受任意数量的参数。
2.VA_ARGS 是一个预定义的宏,在 C++ 中用于表示可变参数列表。它将被展开成实际传入的可变参数列表。

一般情况下,在宏定义中使用 (...) 来接受可变数量的参数,在宏展开时使用 VA_ARGS 来引用这些参数。
下面是一个示例来说明 (...) 和 VA_ARGS 的配对使用:

#define PRINT_VALUES(format, ...) \
    printf(format, __VA_ARGS__);

int main() {
    PRINT_VALUES("%d %s\n", 10, "Hello");  // 输出:10 Hello
    return 0;
}
在这个例子中,PRINT_VALUES 宏使用了可变参数模板 (...) 来接受可变数量的参数,然后使用 VA_ARGS 来引用这些参数。在宏展开时,VA_ARGS 将被实际传入的可变参数替换。

 Premake

本期会讨论CMake之类的构建问题。首先是为什么需要项目生成,而不是直接VS呢?主要就是不同平台的问题了。

我们要用的是 Premake:Releases · premake/premake-core · GitHub

Premake 是用 Lua 来写的

这里release未必是发行版本,Dist才是完全的发行版本;release就是一个更快的Debug比如去掉一些日志啥的来测试

workspace "Hazel"
    architecture "x64"

    configurations {
        "Debug",
        "Release",
        "Dist"
    }

outputdir = "%{cfg.buildcfg}-%{cfg.system}-%{cfg.architecture}"

project "Hazel"
    location "Hazel"
    kind "SharedLib"
    language "C++"

    targetdir ("bin/" .. outputdir .. "/%{prj.name}")
    objdir ("bin-int/" .. outputdir .. "/%{prj.name}")

    files {
        "src/**.h",
        "src/**.cpp"
    }

    includedirs {
        "vendor/spdlog/include"
    }

    filter "system:windows"
        cppdialect "C++17"
        staticruntime "On"
        systemversion "10.0.19041.0"
        
        defines {
            "HZ_PLATFORM_WINDOWAS",
            "HAZEL_BUILD_DLL",
        }

        postbuildcommands {
            ("{COPY} %{cfg.buildtarget.relpath} ../bin/" .. outputdir .. "/Sandbox")
        }

    filter "configurations:Debug" 
        defines "HZ_DEBUG"
        symbols "On"

    filter "configurations:Release" 
        defines "HZ_RELEASE"
        optimize "On"

    filter "configurations:Dist" 
        defines "HZ_DIST"
        optimize "On"

project "Sandbox"
        location "Sandbox"
        kind "ConsoleApp"

        language "C++"

        files {
            "../%{prj.name}/src/**.h",
            "../%{prj.name}/src/**.cpp"
        }
    
        includedirs {
            "vendor/spdlog/include",
            "D:/vs2022/HazelEngine/Hazel/src"
        }

        links {
            "Hazel"
        }

        targetdir ("bin/" .. outputdir .. "/%{prj.name}")
        objdir ("bin-int/" .. outputdir .. "/%{prj.name}")

    filter "system:windows"
        cppdialect "C++17"
        staticruntime "On"
        systemversion "10.0.19041.0"
        
        defines {
            "HZ_PLATFORM_WINDOWAS",
        }

        filter "configurations:Debug" 
            defines "HZ_DEBUG"
            symbols "On"

        filter "configurations:Release" 
            defines "HZ_RELEASE"
            optimize "On"

        filter "configurations:Dist" 
            defines "HZ_DIST"
            optimize "On"

写一个 .bat 文件,一键式执行

Planning the Event System

这一节要写一个事件系统,从而可以处理收到的窗口事件等,比如窗口关闭、改变大小、输入事件等等。

我们不希望 Application 依赖 window,window 类应当完全不知晓 Application,而Application要创建window 。所以我们需要创建一种方法,可以把所有事件发送回到 App,然后 Application 可以处理它们。当窗口中发生了一个事件,window 类会收到一个事件回调,然后它要构造一个 HEngine 事件,然后用某种方法传给 App。

当 App 创建了一个 window 类的时候,同时给 window 类设置一个事件回调,所以每当窗口得到一个事件,它检查回调现在是否为 null ,如果不是 null,就用这些事件数据调用回调。然后 App 会有一个函数叫 onEvent() 接受一个事件的引用,会从 window 调用这个函数。

这些一般被称为阻塞事件,因为当我们处理这些鼠标按下事件的时候,可能直接在栈上构造事件,然后立即调用回调函数。所以当我们处理这个事件的时候,会暂停所有其他事件。因此称为阻塞事件。未来可以创建带缓冲的事件,基本上就是捕获这些信息,在某个地方队列存储,不阻塞其他事件,然后可能每帧遍历事件队列。然后调度和处理它们,而不是在收到事件时立即处理。

cherno字写的和我一样难看

Event System

四个事件文件
       -->AppEvent
Event.h-->KeyEvent
       -->MouseEvent

对于KeyEvent,第一次是按下事件,之后都是重复事件。

这里我们的公共抽象基类 Event 有这些接口:

博文参考:Cherno_游戏引擎系列教程(1):1~16_game engine series-CSDN博客

Cherno游戏引擎笔记记录(1~14)_c++cherno游戏引擎-CSDN博客


http://www.kler.cn/a/390328.html

相关文章:

  • power bi中的related函数解析
  • 算法演练----24点游戏
  • 在Flutter中,禁止侧滑的方法
  • 3.5【数据库系统】ER图
  • 期权懂|期权新手入门教学:期权合约有哪些要素?
  • AI大模型开发架构设计(14)——基于LangChain大模型的案例架构实战
  • 前端开发中ES6的技术细节二
  • 24/11/11 算法笔记 泊松融合
  • 开源项目低代码表单设计器FcDesigner扩展组件分组
  • 基于汇编语言实现的彩色黑白棋游戏
  • gitlab项目如何修改主分支main为master,以及可能遇到的问题
  • Electron 项目中获取 Windows 进程列表的深入剖析
  • Microsoft 365 Exchange如何设置可信发件IP白名单
  • MFC中 error C2440错误分析及解决方法
  • Google Go编程风格指南-介绍
  • 工业通信协议对比:OPC-UA、Modbus、MQTT、HTTP
  • The Input data type is inconsistent with defined schema
  • XHCI 1.2b 规范摘要(15)
  • 刷题统计(C语言)
  • 【Word2Vec】传统词嵌入矩阵训练方法
  • DataX任务:同步mysql数据到Elasticsearch,且Elasticsearch索引带有分词器
  • FPGA学习(10)-数码管
  • 工位管理新策略:Spring Boot企业级应用
  • 4-3-2.C# 数据容器 - Dictionary 扩展(Dictionary 存储对象的特性、Dictionary 与数组的转换)
  • 【爬虫分享】
  • PYTHON常用基础库-写算法