2023年从零构建游戏引擎:核心模块、ECS架构与实战代码解析
如果你是一名开发者最近在思考“要不要自己写一个游戏引擎”那么这篇文章就是为你准备的。你可能已经看过很多关于游戏引擎的讨论有人说“不要重复造轮子”有人说“自己写引擎才能学到真东西”还有人说“现代游戏引擎已经足够强大没必要从头开始”。这些观点都有道理但都太笼统了。真正的问题是在2023年一个开发者或一个小团队从零开始制作一个游戏引擎到底意味着什么它还是一个可行的学习路径或创业选择吗答案是它依然是一个极具价值的深度技术实践但其目标、路径和预期必须被彻底重构。它不再是为了替代 Unity 或 Unreal而是为了理解现代游戏开发的“黑盒”解决特定领域的性能瓶颈或是构建一个高度定制化的工具链。本文将带你从“为什么”到“怎么做”拆解一个现代游戏引擎的核心模块提供可落地的代码示例和架构思路并告诉你哪些坑可以避开哪些幻想必须放弃。1. 为什么在2023年你还需要了解如何制作游戏引擎在 Unity 和 Unreal Engine 5 功能如此强大的今天“造轮子”似乎是一种时间上的奢侈甚至浪费。但理解引擎的构造对开发者有三大不可替代的价值深度掌控与性能优化当你使用现成引擎遇到无法解决的性能瓶颈如特定类型的渲染、物理或网络同步时理解底层原理是唯一出路。你无法优化一个你不理解的黑盒。特定领域的技术壁垒并非所有“游戏”都适合通用引擎。超大规模策略游戏、特定风格的2D像素游戏、Web端轻量级游戏、或与硬件深度绑定的应用都可能需要高度定制化的引擎解决方案。顶尖技术能力的试金石构建一个引擎是对计算机图形学、数据结构、算法、软件架构和跨平台开发能力的终极综合考验。这个过程所培养的系统性思维和解决问题的能力远超单纯使用引擎。所以我们的目标不是做出一个“Unity Killer”而是构建一个“教学引擎”或“专用引擎”。前者用于学习和验证概念后者用于解决特定问题。明确了这一点我们才能开始。2. 现代游戏引擎的核心模块拆解一个可运行的最小化游戏引擎远不止是“渲染一个三角形”。它是一个复杂的软件系统各模块间高度解耦又紧密协作。我们可以将其核心抽象为以下几个层次模块层级核心组件关键职责技术选型举例应用层入口点、窗口管理、事件循环创建窗口处理系统消息输入、重绘驱动主循环GLFW, SDL, Win32 API渲染层图形API抽象、渲染器、材质系统将场景数据转换为屏幕像素管理GPU资源OpenGL, Vulkan, DirectX 11/12, Metal资源层资源管理器、加载器、序列化加载和管理纹理、模型、音频、配置文件等资产自定义格式 JSON/二进制序列化场景层场景图、实体组件系统(ECS)、空间数据结构组织游戏对象处理对象间关系与空间查询自定义ECS 四叉树/八叉树BVH逻辑层脚本系统、行为树、状态机定义游戏对象的行为和游戏规则Lua/Python绑定 可视化脚本 纯代码驱动工具层编辑器、调试器、性能分析器提供可视化创作和实时调试能力ImGui, 自定义编辑器对于个人或小团队项目建议采用“核心优先工具后置”的策略。即先实现一个无编辑器的、纯代码驱动的可运行核心验证核心架构的可行性再考虑工具链。3. 环境准备选择你的技术栈在开始写第一行代码前必须做出几个关键选择。这些选择将决定你未来数月的开发体验。3.1 编程语言C行业标准无争议的选择。提供最高的性能和硬件控制能力但学习曲线陡峭需要手动管理内存。推荐使用现代CC17/20。Rust新兴的强力竞争者。内存安全性与高性能兼得但生态特别是在图形学领域仍在成长学习曲线同样不低。C#如果你专注于工具开发或希望快速原型验证且不追求极限性能C# .NET是一个舒适的选择。但作为引擎核心性能和控制力不如C。本文将以 C 作为示例语言因为它最通用资料最丰富。3.2 图形APIOpenGL最适合入门。跨平台高级别API文档和教程海量。虽然被认为是“旧时代”的API但用它学习图形管线概念事半功倍。Vulkan/DirectX 12/Metal现代低级API。提供极致性能和控制但复杂度极高需要编写大量样板代码管理GPU资源。不建议初学者直接挑战。抽象层成熟的引擎会封装一个“渲染抽象层”RHI底层对接不同的图形API。这是我们架构的终极目标但初期可以只实现OpenGL后端。3.3 窗口与输入管理GLFW轻量级API简洁完美配合OpenGL。强烈推荐用于起步。SDL功能更全面包含音频、线程等但稍重一些。同样是优秀选择。3.4 构建系统与依赖管理CMakeC项目的事实标准。学习它尽管初期可能令人困惑。vcpkg/Conan用于管理第三方库如GLFW、GLM、spdlog。避免手动下载和配置库文件。环境搭建示例Windows VS Code CMake vcpkg安装基础工具确保已安装 Visual Studio包含MSVC编译器或 MinGW以及 CMake 和 Git。安装 vcpkggit clone https://github.com/Microsoft/vcpkg.git cd vcpkg .\bootstrap-vcpkg.bat # Windows # ./bootstrap-vcpkg.sh # Linux/macOS安装所需库.\vcpkg install glfw3 gl3w glm spdlog fmt集成到CMake在你的CMakeLists.txt中通过find_package使用这些库。4. 第一步创建窗口与OpenGL上下文这是引擎的“心跳”。我们使用 GLFW 创建窗口并用 GL3W 或 Glad 加载 OpenGL 函数指针。项目结构建议MyGameEngine/ ├── CMakeLists.txt ├── src/ │ ├── core/ │ │ ├── Application.cpp/.hpp # 应用主循环 │ │ └── Window.cpp/.hpp # 窗口封装 │ ├── graphics/ # 渲染相关 │ └── main.cpp # 程序入口 └── external/ # 第三方库可选代码实现src/core/Window.hpp#pragma once #include string #include GLFW/glfw3.h namespace MyEngine { struct WindowProps { std::string Title; unsigned int Width; unsigned int Height; WindowProps(const std::string title My Game Engine, unsigned int width 1280, unsigned int height 720) : Title(title), Width(width), Height(height) {} }; class Window { public: Window(const WindowProps props); ~Window(); void OnUpdate(); // 交换缓冲区处理事件 inline unsigned int GetWidth() const { return m_Data.Width; } inline unsigned int GetHeight() const { return m_Data.Height; } inline GLFWwindow* GetNativeWindow() const { return m_Window; } void SetVSync(bool enabled); bool IsVSync() const; private: void Init(const WindowProps props); void Shutdown(); GLFWwindow* m_Window; struct WindowData { std::string Title; unsigned int Width, Height; bool VSync; }; WindowData m_Data; }; }代码实现src/core/Window.cpp#include Window.hpp #include core/Log.hpp // 假设我们有一个日志库 #include glad/glad.h // 使用Glad加载OpenGL函数 static void GLFWErrorCallback(int error, const char* description) { LOG_ERROR(GLFW Error ({0}): {1}, error, description); } Window::Window(const WindowProps props) { Init(props); } Window::~Window() { Shutdown(); } void Window::Init(const WindowProps props) { m_Data.Title props.Title; m_Data.Width props.Width; m_Data.Height props.Height; LOG_INFO(Creating window {0} ({1}, {2}), props.Title, props.Width, props.Height); // 初始化GLFW int success glfwInit(); if (!success) { LOG_CRITICAL(Could not initialize GLFW!); } glfwSetErrorCallback(GLFWErrorCallback); // 配置OpenGL上下文 (使用OpenGL 4.5 Core Profile) glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 5); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); #ifdef __APPLE__ glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); #endif // 创建窗口 m_Window glfwCreateWindow((int)props.Width, (int)props.Height, m_Data.Title.c_str(), nullptr, nullptr); if (m_Window nullptr) { LOG_CRITICAL(Failed to create GLFW window!); glfwTerminate(); } // 设置当前上下文 glfwMakeContextCurrent(m_Window); // 使用Glad加载OpenGL函数指针 int version gladLoadGLLoader((GLADloadproc)glfwGetProcAddress); if (!version) { LOG_CRITICAL(Failed to initialize Glad!); glfwTerminate(); } LOG_INFO(OpenGL Info:); LOG_INFO( Vendor: {0}, glGetString(GL_VENDOR)); LOG_INFO( Renderer: {0}, glGetString(GL_RENDERER)); LOG_INFO( Version: {0}, glGetString(GL_VERSION)); // 设置VSync默认开启 SetVSync(true); } void Window::Shutdown() { glfwDestroyWindow(m_Window); glfwTerminate(); } void Window::OnUpdate() { glfwPollEvents(); // 处理输入等事件 glfwSwapBuffers(m_Window); // 交换前后缓冲区 } void Window::SetVSync(bool enabled) { glfwSwapInterval(enabled ? 1 : 0); m_Data.VSync enabled; } bool Window::IsVSync() const { return m_Data.VSync; }代码实现src/core/Application.hpp/cpp(主循环封装) 和src/main.cpp// Application.hpp (简化版) #pragma once #include Window.hpp namespace MyEngine { class Application { public: Application(); virtual ~Application(); void Run(); protected: std::unique_ptrWindow m_Window; bool m_Running true; }; } // main.cpp #include core/Application.hpp #include iostream class SandboxApp : public MyEngine::Application { public: SandboxApp() { // 初始化游戏特定内容 std::cout Sandbox App Started! std::endl; } ~SandboxApp() override default; }; // 由客户端定义入口 MyEngine::Application* CreateApplication(); int main(int argc, char** argv) { auto app CreateApplication(); app-Run(); delete app; return 0; } // 客户端必须实现此函数 MyEngine::Application* CreateApplication() { return new SandboxApp(); }此时运行程序你应该能看到一个空白窗口。这标志着你的引擎拥有了最基础的生命周期。5. 渲染系统的基石着色器与网格有了窗口下一步是画点东西。现代图形渲染的核心是着色器(Shader)和顶点数据。5.1 实现一个简单的着色器类目标是加载并编译GLSL着色器代码链接成着色器程序(Shader Program)。// src/graphics/Shader.hpp #pragma once #include string #include unordered_map #include glm/glm.hpp namespace MyEngine { class Shader { public: Shader(const std::string vertexSrc, const std::string fragmentSrc); ~Shader(); void Bind() const; void Unbind() const; // 上传Uniform变量 void UploadUniformMat4(const std::string name, const glm::mat4 matrix); void UploadUniformFloat4(const std::string name, const glm::vec4 values); void UploadUniformInt(const std::string name, int value); private: uint32_t m_RendererID; std::unordered_mapstd::string, int m_UniformLocationCache; int GetUniformLocation(const std::string name); }; }// src/graphics/Shader.cpp (关键部分) Shader::Shader(const std::string vertexSrc, const std::string fragmentSrc) { // 创建顶点着色器 GLuint vertexShader glCreateShader(GL_VERTEX_SHADER); const GLchar* source vertexSrc.c_str(); glShaderSource(vertexShader, 1, source, 0); glCompileShader(vertexShader); // ... 检查编译错误 (glGetShaderiv, glGetShaderInfoLog) // 创建片段着色器 GLuint fragmentShader glCreateShader(GL_FRAGMENT_SHADER); source fragmentSrc.c_str(); glShaderSource(fragmentShader, 1, source, 0); glCompileShader(fragmentShader); // ... 检查编译错误 // 链接着色器程序 m_RendererID glCreateProgram(); glAttachShader(m_RendererID, vertexShader); glAttachShader(m_RendererID, fragmentShader); glLinkProgram(m_RendererID); // ... 检查链接错误 // 删除着色器对象 glDeleteShader(vertexShader); glDeleteShader(fragmentShader); }5.2 实现一个简单的网格(Mesh)与顶点缓冲区(Vertex Buffer)// src/graphics/VertexArray.hpp #pragma once #include cstdint #include memory #include vector namespace MyEngine { // 定义顶点缓冲区的布局 struct BufferElement { std::string Name; uint32_t Type; // GL_FLOAT, GL_UNSIGNED_INT等 uint32_t Count; uint32_t Size; bool Normalized; // ... 构造函数和辅助方法 }; class VertexBuffer { public: VertexBuffer(float* vertices, uint32_t size); ~VertexBuffer(); void Bind() const; void Unbind() const; private: uint32_t m_RendererID; }; class IndexBuffer { public: IndexBuffer(uint32_t* indices, uint32_t count); ~IndexBuffer(); void Bind() const; void Unbind() const; inline uint32_t GetCount() const { return m_Count; } private: uint32_t m_RendererID; uint32_t m_Count; }; class VertexArray { public: VertexArray(); ~VertexArray(); void Bind() const; void Unbind() const; void AddVertexBuffer(const std::shared_ptrVertexBuffer vertexBuffer); void SetIndexBuffer(const std::shared_ptrIndexBuffer indexBuffer); const std::shared_ptrIndexBuffer GetIndexBuffer() const { return m_IndexBuffer; } private: uint32_t m_RendererID; std::vectorstd::shared_ptrVertexBuffer m_VertexBuffers; std::shared_ptrIndexBuffer m_IndexBuffer; }; }5.3 绘制一个彩色三角形在SandboxApp的初始化中添加以下代码// 在SandboxApp构造函数中 // 1. 定义顶点数据 (位置 颜色) float vertices[] { // 位置 // 颜色 (RGB) -0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下红色 0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 右下绿色 0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 顶部蓝色 }; // 2. 定义索引数据 uint32_t indices[] { 0, 1, 2 }; // 3. 创建并绑定顶点数组对象(VAO) m_VertexArray std::make_uniqueVertexArray(); // 4. 创建顶点缓冲区(VBO)并设置布局 auto vertexBuffer std::make_sharedVertexBuffer(vertices, sizeof(vertices)); BufferLayout layout { { a_Position, ShaderDataType::Float3 }, { a_Color, ShaderDataType::Float3 } }; vertexBuffer-SetLayout(layout); m_VertexArray-AddVertexBuffer(vertexBuffer); // 5. 创建索引缓冲区(IBO/EBO) auto indexBuffer std::make_sharedIndexBuffer(indices, 3); m_VertexArray-SetIndexBuffer(indexBuffer); // 6. 创建着色器 std::string vertexSrc R( #version 450 core layout(location 0) in vec3 a_Position; layout(location 1) in vec3 a_Color; out vec3 v_Color; void main() { gl_Position vec4(a_Position, 1.0); v_Color a_Color; } ); std::string fragmentSrc R( #version 450 core in vec3 v_Color; out vec4 FragColor; void main() { FragColor vec4(v_Color, 1.0); } ); m_Shader std::make_uniqueShader(vertexSrc, fragmentSrc);在主循环的渲染部分// 在Application::Run的循环中每次更新后 glClear(GL_COLOR_BUFFER_BIT); m_Shader-Bind(); m_VertexArray-Bind(); glDrawElements(GL_TRIANGLES, m_VertexArray-GetIndexBuffer()-GetCount(), GL_UNSIGNED_INT, nullptr);运行程序你应该能看到一个彩色的三角形。恭喜你的渲染管线打通了这是从零到一最关键的一步。6. 引入实体组件系统(ECS)架构当三角形能渲染后你会立刻面临一个问题如何管理成百上千个具有不同属性位置、模型、脚本的游戏对象传统的面向对象继承GameObject-Player-Enemy会很快导致“钻石继承”和代码僵化。现代游戏引擎的核心架构趋势是ECS。6.1 ECS核心概念实体(Entity)一个唯一的ID代表游戏世界中的一个“事物”。它本身没有任何数据或行为。组件(Component)纯粹的数据结构。例如TransformComponent(位置、旋转、缩放)、MeshComponent(渲染网格)、HealthComponent(生命值)。系统(System)处理具有特定组件组合的实体的逻辑。例如RenderSystem遍历所有拥有TransformComponent和MeshComponent的实体并渲染它们。6.2 一个极简的ECS实现示例// src/ecs/Entity.hpp #pragma once #include cstdint using Entity uint32_t; const Entity MAX_ENTITIES 5000; // src/ecs/Component.hpp #pragma once #include bitset #include array using ComponentType uint8_t; const ComponentType MAX_COMPONENTS 32; using Signature std::bitsetMAX_COMPONENTS; // src/ecs/Registry.hpp (简化版) #pragma once #include Entity.hpp #include Component.hpp #include unordered_map #include memory class Registry { std::arraySignature, MAX_ENTITIES m_Signatures; // 存储组件池的映射ComponentType - 组件池指针 std::unordered_mapComponentType, std::shared_ptrIComponentPool m_ComponentPools; std::queueEntity m_AvailableEntities; public: Entity CreateEntity() { // 从队列中获取或生成新ID // ... } void DestroyEntity(Entity entity) { // 清理该实体的所有组件ID回收到队列 // ... } templatetypename T void AddComponent(Entity entity, T component) { // 获取T对应的ComponentType ID // 获取或创建T类型的组件池 // 将组件数据插入池中并设置实体的签名位 // ... } templatetypename T T GetComponent(Entity entity) { // 根据实体ID和组件类型从池中取出数据 // ... } templatetypename T bool HasComponent(Entity entity) { // 检查签名位 // ... } }; // 使用示例 auto registry GetGlobalRegistry(); // 假设有一个全局注册表 Entity player registry.CreateEntity(); registry.AddComponentTransformComponent(player, TransformComponent{glm::vec3(0.0f)}); registry.AddComponentMeshComponent(player, MeshComponent{triangleMesh});6.3 渲染系统示例// src/systems/RenderSystem.hpp #pragma once #include ecs/Registry.hpp #include graphics/Shader.hpp #include components/TransformComponent.hpp #include components/MeshComponent.hpp class RenderSystem { public: void Update(Registry registry, Shader shader) { auto view registry.ViewTransformComponent, MeshComponent(); for (auto entity : view) { auto transform registry.GetComponentTransformComponent(entity); auto mesh registry.GetComponentMeshComponent(entity); // 上传变换矩阵到着色器 shader.UploadUniformMat4(u_Model, transform.GetModelMatrix()); // 绑定并绘制网格 mesh.vertexArray-Bind(); glDrawElements(GL_TRIANGLES, mesh.vertexArray-GetIndexBuffer()-GetCount(), GL_UNSIGNED_INT, nullptr); } } };ECS将数据与逻辑分离使引擎更高效缓存友好、更灵活动态组合组件、更易测试。这是构建可扩展引擎的关键决策点。7. 资源管理与序列化引擎不能把所有模型、纹理的二进制数据硬编码在代码里。我们需要一个系统来加载、缓存和管理这些资产。7.1 资源管理器基础模式// src/assets/ResourceManager.hpp #pragma once #include string #include unordered_map #include memory templatetypename T class ResourceManager { public: std::shared_ptrT Load(const std::string filepath) { auto it m_Resources.find(filepath); if (it ! m_Resources.end()) { return it-second; // 返回缓存 } // 加载资源 std::shared_ptrT resource std::make_sharedT(); if (!resource-LoadFromFile(filepath)) { LOG_ERROR(Failed to load resource: {0}, filepath); return nullptr; } m_Resources[filepath] resource; return resource; } void Unload(const std::string filepath) { m_Resources.erase(filepath); } void Clear() { m_Resources.clear(); } private: std::unordered_mapstd::string, std::shared_ptrT m_Resources; }; // 特化用于纹理 class Texture { public: bool LoadFromFile(const std::string path) { // 使用stb_image等库加载图片生成OpenGL纹理 // ... return true; } void Bind(uint32_t slot 0) const { glActiveTexture(GL_TEXTURE0 slot); glBindTexture(GL_TEXTURE_2D, m_RendererID); } private: uint32_t m_RendererID; }; // 全局资源管理器 extern ResourceManagerTexture g_TextureManager;7.2 场景序列化使用JSON为了保存和加载游戏场景你需要将ECS中的实体和组件状态保存到文件。JSON是一个不错的起点。// 序列化一个实体 nlohmann::json SerializeEntity(Registry registry, Entity entity) { nlohmann::json j; j[id] entity; if (registry.HasComponentTransformComponent(entity)) { auto transform registry.GetComponentTransformComponent(entity); j[components][transform] { {position, {transform.position.x, transform.position.y, transform.position.z}}, {rotation, {transform.rotation.x, transform.rotation.y, transform.rotation.z}}, {scale, {transform.scale.x, transform.scale.y, transform.scale.z}} }; } if (registry.HasComponentMeshComponent(entity)) { auto mesh registry.GetComponentMeshComponent(entity); j[components][mesh] { {filepath, mesh.filepath} }; } return j; } // 反序列化过程则相反根据JSON创建实体并添加组件。8. 常见问题与排查思路在引擎开发过程中你会频繁遇到以下问题问题现象可能原因排查方式解决方案窗口创建失败黑屏或无响应GLFW初始化失败图形驱动问题多线程冲突。检查glfwInit()返回值查看GLFW错误回调。更新显卡驱动。确保在主线程序初始化GLFW并正确设置OpenGL上下文提示。编译着色器时崩溃或日志报错GLSL语法错误版本声明不匹配变量未使用。使用glGetShaderInfoLog和glGetProgramInfoLog获取详细错误信息。将错误信息输出到控制台或日志文件。使用简单的测试着色器逐步排查。三角形不显示或显示为纯色顶点数据格式错误着色器链接失败VAO未绑定深度测试未开启/关闭。使用图形调试器如RenderDoc捕获一帧检查管线状态和顶点数据。确保VAO在绑定VBO和EBO之后绑定。检查顶点着色器输出位置是否为裁剪空间坐标。内存泄漏程序运行后内存持续增长OpenGL对象纹理、缓冲区未删除STL容器未清理智能指针循环引用。使用Valgrind或Visual Studio诊断工具。在析构函数中打印日志。遵循RAII原则为每个GL对象创建封装类在析构函数中调用glDeleteXXX。ECS系统更新顺序导致逻辑错误系统间存在依赖如物理系统必须在动画系统之后但执行顺序未定义。观察帧间状态不一致。引入显式的系统执行顺序定义如枚举或优先级数字在注册表中排序。加载资源时程序卡死或崩溃文件路径错误磁盘IO阻塞主线程资源格式解析错误。检查文件路径是否存在和可读。将加载过程日志化。使用绝对路径或相对于可执行文件的路径。对于大资源实现异步加载。9. 最佳实践与工程建议日志系统是生命线在项目第一天就集成一个日志库如spdlog。在每一个关键步骤初始化、加载、渲染、销毁输出信息。这是调试复杂图形和系统问题的唯一可靠手段。抽象但不要过度抽象早期就定义好渲染抽象层RenderCommand、RendererAPI但初期只实现一个后端如OpenGL。避免在第一个三角形渲染之前就设计支持Vulkan和Metal的完美抽象。使用现代C特性智能指针std::unique_ptr,std::shared_ptr管理资源避免裸new/delete。使用移动语义减少拷贝。利用STL容器和算法。性能分析要早集成简单的CPU性能分析使用std::chrono和GPU查询如glQuery。知道每一帧时间花在哪里是优化决策的基础。版本控制与依赖管理使用Git并合理使用子模块或包管理器vcpkg/Conan管理第三方库。确保项目在任何一台新机器上都能通过几条命令构建。测试驱动核心模块为数学库向量、矩阵、序列化、ECS的核心数据结构编写单元测试。图形相关部分难以单元测试但逻辑部分可以。保持可运行状态每实现一个功能如纹理加载就做一个小的测试程序来验证它。永远不要让引擎处于“完全无法编译运行”的状态超过一天。阅读其他开源引擎不要闭门造车。学习像raylib简单直接、bgfx渲染抽象优秀、EnttECS库这样的优秀开源项目。理解他们的设计选择。构建一个游戏引擎是一场马拉松而不是百米冲刺。它的价值不在于最终产出一个能与商业引擎媲美的产品而在于这个过程中你被迫去深入理解计算机图形学、内存管理、系统架构和软件工程的核心原理。从创建一个窗口到渲染一个三角形再到组织起一个由组件驱动的动态世界每一步都是对综合能力的锤炼。当你完成一个最小可用的引擎核心后你可以选择多个方向深入实现一个简单的2D物理系统集成Lua脚本支持构建一个基于ImGui的实时编辑器或者尝试将渲染后端从OpenGL切换到Vulkan。每一个方向都足以展开为一个全新的、深邃的技术领域。所以如果你决定开始请记住从具体而微的目标开始“今天让窗口显示出来”拥抱迭代和重构并永远保持让代码运行起来的能力。最终你收获的将不仅仅是一个引擎而是一套解决复杂软件问题的思维框架和实战能力。这才是“造轮子”在2023年依然闪耀的意义。