DLL热重载

导入

在游戏开发中,如何才能提高开发效率?一个最基本的思路就是,游戏代码的修改可以实时反应在游戏上,你修改一个逻辑,调整一个参数,新增加一个绘制,游戏窗口就能实时生效,这就是我们说的热重载技术。

作者强烈建议所有游戏开发都要用热重载来开发,千万不要改一个参数全部编译一下,然后重新打开游戏查看效果,结果不理想,关闭游戏,继续修改参数,然后再编译运行,尤其是很大部分参数可能并不在游戏开始时生效,你可能还需要等待游戏运行到生效的地方来查看修改效果,游戏逻辑修改和游戏渲染绘制修改也是一样,这样来开发游戏,我觉得是很痛苦的,我见过很多人都是一直这样做的。除非你写的游戏非常小,也就相当于一个demo,那每次修改重新启动游戏肯定是无妨的,不过这种不太称得上游戏开发就是。

对于脚本语言来说,天生是支持热重载的,比如lua , python等,如果你用python开发游戏,只需要把游戏逻辑写到另一个模块中,然后游戏主循环检查时间戳判断是否更新,然后importlib.reload()即可,这也是为什么游戏引擎一般都会结合脚本语言,用脚本语言来写核心游戏逻辑,就是因为可以热重载,提高开发效率,同时游戏更新补丁可以很小,且不用更新二进制可执行文件,还能热更新,游戏过程中直接生效。如果你使用Unity和虚幻等游戏引擎开发游戏的话,都是支持热重载的,因此我这里主要针对不依赖游戏引擎,使用非脚本语言开发游戏,就可以使用标题中所说的DLL热重载技术

使用DLL热重载技术开发游戏一般使用Host-Guest 模式,这种架构将游戏分为两个物理部分,以C++使用openGL开发游戏为例:

  • Host (宿主/平台层):负责操作系统层面的交互。

    • 职责:创建窗口、初始化 OpenGL 上下文、处理系统消息(鼠标/键盘原始输入)、管理主循环、加载/卸载 DLL。
    • 特点:极少修改。一旦写好,可能几个月都不用动。因为它不包含游戏逻辑,只提供“场地”。
    • 内存拥有者:它负责分配 GameState 内存。为什么?因为 DLL 卸载时,DLL 内部的堆栈内存会被操作系统回收,只有存放在 Host 里的数据才能在 DLL 切换间隙存活。
  • Guest (客户/逻辑层 - DLL):负责具体的游戏内容。

    • 职责:计算坐标、更新状态、发出 OpenGL 绘制指令。
    • 特点:高频修改。所有的游戏玩法、渲染效果都在这里。
    • 无状态设计:DLL 自身最好不要有全局变量(或者说全局变量在重载后会重置)。它应该是一个纯粹的“加工厂”,Host 把数据(GameState)传给它,它加工完或绘制完就结束。

演示效果

本篇博客,我会给出一个简单的使用C++和openGL进行游戏开发的例子,展示如何利用Host-Guest 模式实现DLL热重载开发游戏,当然”游戏“只是简单的三角形绘制,只是提供一个DLL热重载开发的基石,我会说明如何在这个基础上进行真正的游戏开发。如果你看懂整个思路的话,其实不用多说,就能明白如何进一步开发。

可以看到,本来三角形只是变色,我取消注释了旋转和大小改变的代码,并且编译,发现左侧游戏窗口中,修改实时生效。如果是中大型游戏开发的话,这种效率提升是非常之明显的,你一定会庆幸刚开始使用了热重载的开发架构。

完整项目代码

下面给出完整的项目代码,导入中的思路已经写的很清楚了,整个项目结构是这样:

HotReload
│  CMakeLists.txt
└─src
   |   common.h # 共享数据结构 (Host和Game共用)
   |   game.cpp # 游戏逻辑 (dll)
   |   main.cpp # 宿主程序 (exe)

common.h

所有的代码我都会写上详细注释,保证清晰明了。

// 共享数据结构 (Host和Game共用)
#pragma once
#include <glad/glad.h> // 为了使用 GLuint

// 定义导出/导入宏
// 如果是宿主程序或使用该库,则是 IMPORT
// 如果是编译 Game DLL,则是 EXPORT
#ifdef GAME_BUILD_DLL
    #define GAME_API extern "C" __declspec(dllexport)
#else
    #define GAME_API extern "C" __declspec(dllimport)
#endif

// 游戏状态结构体
// 这些数据由 Host 分配并持有,DLL 只是操作它们
// 这样当 DLL 卸载时,数据不会丢失
struct GameState {
    bool isInitialized = false; // 是否已经初始化了 OpenGL 资源
    float totalTime = 0.0f;     // 游戏运行总时间

    // OpenGL 资源 ID
    GLuint shaderProgram;
    GLuint VAO;
    GLuint VBO;

    // 三角形的颜色 (可以在运行时修改)
    float triangleColor[3] = { 1.0f, 0.5f, 0.2f }; 
};

// 定义函数指针类型,方便 Host 加载
typedef void (*OnUpdateFunc)(GameState* state, float dt);
typedef void (*OnRenderFunc)(GameState* state);

main.cpp

// 宿主程序 (exe)
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <iostream>
#include <filesystem>
#include <windows.h>  
#include <string>
#include <thread> 
#include <chrono>

#include "common.h"

namespace fs = std::filesystem;

// 获取当前可执行文件所在的目录
std::string GetExecutableDir() {
    char buffer[MAX_PATH];
    GetModuleFileNameA(NULL, buffer, MAX_PATH);
    std::string fullPath(buffer);
    size_t pos = fullPath.find_last_of("\\/");
    return fullPath.substr(0, pos);
}

// ---------------------------------------------------------
// 简单的 DLL 热重载器类
// ---------------------------------------------------------
class GameLibrary {
public:
    HMODULE hModule = NULL;
    OnUpdateFunc updateFunc = nullptr;
    OnRenderFunc renderFunc = nullptr;

    std::string dllPath;      // 原始 DLL 完整路径
    // 临时 DLL 名字,避免锁定原始文件导致编译器无法写入
    std::string tempDllPath;  // 临时 DLL 完整路径
    fs::file_time_type lastWriteTime;

    GameLibrary(const std::string& dllName) {
        std::string exeDir = GetExecutableDir();
        dllPath = exeDir + "/" + dllName;
        tempDllPath = exeDir + "/Game_temp.dll";

        std::cout << "[Host] Target DLL Path: " << dllPath << std::endl;
    }

    // 检查文件是否更新并加载
    bool TryLoadOrReload() {
        // 1. 检查文件是否存在
        if (!fs::exists(dllPath)) {
            // 只有第一次找不到时才报错,避免刷屏
            static bool firstMissingWarn = true;
            if (firstMissingWarn) {
                std::cerr << "[Host] Error: Game.dll not found at " << dllPath << std::endl;
                firstMissingWarn = false;
            }
            return false;
        }

        // 2. 获取最后修改时间
        auto currentWriteTime = fs::last_write_time(dllPath);
        // 如果时间没变,不需要重载
        if (hModule != NULL && currentWriteTime <= lastWriteTime) {
            return false; 
        }

        // 3. 卸载旧的
        if (hModule != NULL) {
            std::cout << "[Host] Detected change. Reloading..." << std::endl;
            Unload();
        }

        // 4. DLL 复制一份到临时文件 (带重试机制)
        // 编译器写入文件需要时间,文件可能会被短暂锁定,导致 CopyFile 失败
        // 尝试 5 次,每次间隔 100ms
        int retries = 0;
        const int maxRetries = 10;

        while (retries < maxRetries) {
            if (CopyFileA(dllPath.c_str(), tempDllPath.c_str(), FALSE)) {
                // 复制成功,跳出循环
                break;
            }
            // 复制失败,等待一下
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
            retries++;
        }

        if (retries == maxRetries) {
            std::cerr << "[Host] Failed to copy DLL after multiple attempts. (Is the compiler still writing?)" << std::endl;
            return false;
        }

        // 5. 加载临时 DLL
        hModule = LoadLibraryA(tempDllPath.c_str());
        if (!hModule) {
            std::cerr << "[Host] Failed to load DLL!" << std::endl;
            return false;
        }
        // 6. 获取函数指针
        updateFunc = (OnUpdateFunc)GetProcAddress(hModule, "GameUpdate");
        renderFunc = (OnRenderFunc)GetProcAddress(hModule, "GameRender");

        if (!updateFunc || !renderFunc) {
            std::cerr << "[Host] Failed to find functions inside DLL!" << std::endl;
            Unload();
            return false;
        }

        lastWriteTime = currentWriteTime;
        std::cout << "[Host] Reloaded Successfully! (Retries: " << retries << ")" << std::endl;
        return true;
    }

    void Unload() {
        if (hModule) {
            FreeLibrary(hModule);
            hModule = NULL;
            updateFunc = nullptr;
            renderFunc = nullptr;
        }
    }
};

//宿主主程序主要负责窗口绘制
int main() {
    // 1. 初始化 GLFW
    if (!glfwInit()) return -1;
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

    GLFWwindow* window = glfwCreateWindow(800, 600, "Hot Reload Final", NULL, NULL);
    if (!window) {
        glfwTerminate();
        return -1;
    }
    glfwMakeContextCurrent(window);


    // 2. 初始化 GLAD
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
        std::cout << "Failed to initialize GLAD" << std::endl;
        return -1;
    }
    // 3. 准备游戏状态
    GameState gameState;

    // 4. 准备热重载器
    GameLibrary gameLib("Game.dll");

    // 初始加载
    gameLib.TryLoadOrReload();
    // 5. 主循环
    float lastTime = 0.0f;
    while (!glfwWindowShouldClose(window)) {
        // --- 热重载检查 ---
        // 这里每帧查其实消耗极低,因为只是读文件属性
        gameLib.TryLoadOrReload();
        // 计算 dt
        float time = (float)glfwGetTime();
        float dt = time - lastTime;
        lastTime = time;
        // 处理输入
        if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
            glfwSetWindowShouldClose(window, true);
        // 清屏
        glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);
        // --- 调用 DLL 中的逻辑 ---
        if (gameLib.updateFunc) gameLib.updateFunc(&gameState, dt);
        if (gameLib.renderFunc) gameLib.renderFunc(&gameState);

        glfwSwapBuffers(window);
        glfwPollEvents();
    }

    gameLib.Unload();
    glfwTerminate();
    return 0;
} 

game.cpp

// 游戏逻辑 (dll)
#include "common.h"
#include <glad/glad.h>
#include <GLFW/glfw3.h> 
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <iostream>
#include <vector>
#include <cmath>

// 静态标记:每次 DLL 被重新加载时,变回 true
static bool isDllLoaded = false;

// 简单的着色器代码
const char* vertexShaderSource = R"(
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 uModel;
void main() {
    gl_Position = uModel * vec4(aPos, 1.0);
}
)";

const char* fragmentShaderSource = R"(
#version 330 core
out vec4 FragColor;
uniform vec3 uColor;
void main() {
    FragColor = vec4(uColor, 1.0);
}
)";

// 辅助函数:检查 Shader 编译错误 
void CheckShaderError(GLuint shader, std::string type) {
    GLint success;
    GLchar infoLog[1024];
    if (type != "PROGRAM") {
        glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
        if (!success) {
            glGetShaderInfoLog(shader, 1024, NULL, infoLog);
            std::cout << "[DLL ERROR] SHADER_COMPILATION_ERROR of type: " << type << "\n" << infoLog << "\n -- --------------------------------------------------- -- " << std::endl;
        }
    } else {
        glGetProgramiv(shader, GL_LINK_STATUS, &success);
        if (!success) {
            glGetProgramInfoLog(shader, 1024, NULL, infoLog);
            std::cout << "[DLL ERROR] PROGRAM_LINKING_ERROR of type: " << type << "\n" << infoLog << "\n -- --------------------------------------------------- -- " << std::endl;
        }
    }
}

// 初始化资源
void InitResources(GameState* state) {
    // 1. 编译 Vertex Shader
    GLuint vs = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vs, 1, &vertexShaderSource, NULL);
    glCompileShader(vs);
    CheckShaderError(vs, "VERTEX");

    // 2. 编译 Fragment Shader
    GLuint fs = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fs, 1, &fragmentShaderSource, NULL);
    glCompileShader(fs);
    CheckShaderError(fs, "FRAGMENT");

    // 3. 链接程序
    state->shaderProgram = glCreateProgram();
    glAttachShader(state->shaderProgram, vs);
    glAttachShader(state->shaderProgram, fs);
    glLinkProgram(state->shaderProgram);
    CheckShaderError(state->shaderProgram, "PROGRAM");

    glDeleteShader(vs);
    glDeleteShader(fs);

    // 4. 定义三角形顶点数据
    float vertices[] = {
         -0.5f, -0.5f, 0.0f, // 左下
          0.5f, -0.5f, 0.0f, // 右下
          0.0f,  0.5f, 0.0f  // 顶部
    };

    // 5. 设置缓冲
    glGenVertexArrays(1, &state->VAO);
    glGenBuffers(1, &state->VBO);

    glBindVertexArray(state->VAO);

    glBindBuffer(GL_ARRAY_BUFFER, state->VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);

    state->isInitialized = true;
    std::cout << "[Game DLL] Resources Initialized (VAO: " << state->VAO << ")" << std::endl;
}

// =========================================================
// 游戏核心逻辑:每一帧更新
// =========================================================
GAME_API void GameUpdate(GameState* state, float dt) {
    state->totalTime += dt;

    // 默认颜色
    state->triangleColor[0] = 1.0f; 
    state->triangleColor[1] = 0.5f; 
    state->triangleColor[2] = 0.2f;

    // 实时修改测试:取消注释查看颜色变化
    // state->triangleColor[0] = (sin(state->totalTime) * 0.5f) + 0.5f;
    // state->triangleColor[1] = (cos(state->totalTime) * 0.5f) + 0.5f;
    // state->triangleColor[2] = 0.0f;

}

// =========================================================
// 核心绘制:每一帧渲染
// =========================================================
GAME_API void GameRender(GameState* state) {
    // 重新加载dll时候必须重新初始化 GLAD,否则 DLL 里的 glUseProgram 等函数全是空指针
    if (!isDllLoaded) {
        if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
            std::cout << "[Game DLL] Failed to initialize GLAD inside DLL!" << std::endl;
        } else {
            std::cout << "[Game DLL] GLAD Initialized inside DLL." << std::endl;
        }
        isDllLoaded = true;
    }

    // 初始化 OpenGL 资源 (VAO, Shader)
    // 只会执行一次,即第一次获取state资源,因为host不用关闭
    // 所以state资源不会消失
    if (!state->isInitialized) {
        InitResources(state);
    }

    // 确保使用 Shader
    glUseProgram(state->shaderProgram);

    // 设置颜色
    GLint colorLoc = glGetUniformLocation(state->shaderProgram, "uColor");
    glUniform3f(colorLoc, state->triangleColor[0], state->triangleColor[1], state->triangleColor[2]);

    glBindVertexArray(state->VAO);

    // 基础 Model 矩阵
    glm::mat4 model = glm::mat4(1.0f);

    // 实时修改测试:取消注释查看旋转  
    // model = glm::rotate(model, state->totalTime, glm::vec3(0.0f, 0.0f, 1.0f));
    // float scale = (sin(state->totalTime * 6.0f) * 0.25f) + 0.75f;
    // model = glm::scale(model, glm::vec3(scale));


    GLint modelLoc = glGetUniformLocation(state->shaderProgram, "uModel");
    glUniformMatrix4fv(modelLoc, 1, GL_FALSE, &model[0][0]);

    // 绘制第一个三角形
    glDrawArrays(GL_TRIANGLES, 0, 3);

    // 实时修改测试:再画一个三角形

    // glm::mat4 model2 = glm::mat4(1.0f);
    // model2 = glm::translate(model2, glm::vec3(0.5f, 0.5f, 0.0f));
    // model2 = glm::scale(model2, glm::vec3(0.5f)); 
    // glUniformMatrix4fv(modelLoc, 1, GL_FALSE, &model2[0][0]);
    // glUniform3f(colorLoc, 0.0f, 1.0f, 1.0f); // 青色
    // glDrawArrays(GL_TRIANGLES, 0, 3);


    // 解绑
    glBindVertexArray(0);
} 

CMakeLists.txt

cmake_minimum_required(VERSION 3.20)
project(CppHotReloadGame)
# 设置 C++ 标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 忽略警告 C4819
add_compile_options(/wd4819)
# 寻找包 (由 vcpkg 提供)
find_package(glfw3 CONFIG REQUIRED)
find_package(glad CONFIG REQUIRED)
find_package(glm CONFIG REQUIRED)

# 1. 游戏逻辑 DLL (Game)
add_library(Game SHARED src/game.cpp)
target_link_libraries(Game PRIVATE glad::glad glm::glm glfw)
# 定义宏,告诉代码这是在编译 DLL
target_compile_definitions(Game PRIVATE GAME_BUILD_DLL)

# 2. 宿主程序 EXE (Host)
add_executable(Host src/main.cpp)
target_link_libraries(Host PRIVATE glad::glad glfw glm::glm) 

执行

cmake -GNinja -B build -DCMAKE_TOOLCHAIN_FILE="D:/vcpkg/vcpkg/scripts/buildsystems/vcpkg.cmake" -DCMAKE_BUILD_TYPE=Debug

修改DCMAKE_TOOLCHAIN_FILE指向你自己的vcpkg包管理路径即可。

然后cmake --build build编译即可生成二进制文件,build\Host.exe执行文件。

然后取消game.cpp中相关注释,重新cmake --build build,就能看到游戏窗口实时修改效果。

说明

  • 使用vcpkg进行包管理即可,这里用的是经典全局模式,如果使用清单模式,就vcpkg new --application初始化之后,使用vcpkg add port添加glfw3,glad,glm`这三个依赖即可,之后cmake构建时候会自动下载依赖库

  • game.cpp中注释了几段实时修改测试,分别是改变颜色,旋转,绘制另一个三角形,方便取消注释查看修改效果实时反应在游戏窗口上。

细节和进一步开发

细节

  • OpenGL Context (上下文): Context 属于创建它的线程(Host 线程)。OpenGL 是一个状态机,Context 存储了“当前绑定了哪个纹理”、“当前用了哪个 Shader”。

    • 关键点:DLL 代码运行在 Host 线程中,所以 DLL 天然可以直接调用 OpenGL 命令,不需要重新创建 Context。
  • GLAD 与 函数指针管理: 现代 OpenGL (3.3+) 的函数(如 glUseProgram, glGenBuffers)实际上是显卡驱动里的函数地址。

    • GLAD 的作用:它去显卡驱动里查询这些函数的地址,并保存在全局函数指针变量里。
    • 动态链接的陷阱
      • Host.exe 链接了 GLAD -> Host 有一份全局变量表(已填充)。
      • Game.dll 也链接了 GLAD -> DLL 也有自己独立的一份全局变量表(初始为 NULL)。
      • 因为 DLL 和 EXE 是两个独立的模块,它们的全局变量是不共享的。
    • 解决方案
      • 简单做法:DLL 加载时,再次调用 gladLoadGLLoader。这会把驱动里的地址填入 DLL 自己的那份表中。
      • 同样因为这个原因,所以不要在game.cpp中使用任何游戏逻辑相关的全局变量或者静态变量,因为重新加载dll后会消失,实际游戏开发中,这些全部交给host分配,可以封装在GameState中存储在common.h这个共同头文件中,然后每次卸载dll时候需要保存,加载dll时候需要读取,这样才能保证游戏整体进程不会重置,是统一的
  • 文件锁定

    • 当 Host 加载 Game.dll 时,Windows 内核会锁定该文件,禁止外部写入。

    • 如果你直接编译,连接器(Linker)试图写入 Game.dll 会失败(Permission Denied)。

    • 因此先 FreeLibrary,解除锁定。同时使用影子加载,把 Game.dll 复制为 Game_temp.dll 加载。这样原版 Game.dll 就不会被锁定,下一次编译可以直接覆盖,无需 Host 先卸载。

进一步

所谓进一步开发,实际上非常清晰,就是把游戏逻辑和渲染全部链接Game.dll中,你可以采用C++的类设计,分成不同的h文件,cpp文件,但是最终都要加到dll的链接列表中。最后只需要暴露updaterender两个接口函数即可,这两个函数内部负责游戏的更新和渲染。同样所有的shader和图片资源也需要资源管理器管理。

同时注意,就像上面提到的那样,所有的游戏核心数据,请用host来分配,dll不应该拥有,而只是每次都重新读取host的这些数据。我的这个例子你如果仔细看效果,你会发现我取消注释缩放旋转代码,重新编译后,三角形是先偏移一下位置,然后再开始执行缩放旋转,这就是因为model矩阵位置是dll确定的,重新加载dll就会重置,而实际开发,游戏物体,比如人物,敌人等的位置是游戏核心逻辑数据,是不会交给dll的,需要由host分配内存,放到common.h的GameState中。

然后这个程序如果你运行后拖拽窗口就会发现,游戏就暂停了,这是因为windows消息机制阻塞了openGL的循环,因此实际游戏开发中,主线程游戏循环需要放到子线程中去异步执行,也就是绑定上下文->初始化GLAD->加载游戏dll->渲染循环->清理资源,这里注意的是主线程需要先放弃上下文glfwMakeContextCurrent(NULL),然后子线程才能绑定,绑定上下文也就是绑定了openGL状态机,才能使用gl函数进行主窗口的设置和渲染。而主线程创建窗口后,循环中只负责处理操作系统消息,也就是输入事件,比如键盘鼠标事件,这些输入事件数据需要同步转发填充到GameState中,这样dll才能获取,这里又要注意竞态问题,填充这些数据的时候需要加锁。

建议读者使用这个初始的架构,开始慢慢开发一个小游戏,慢慢体会。遇到问题一定要先想到dll重新加载,dll内存区域就会刷新的这个事实,考虑是不是指针丢失?不清楚这一点,可能会遇到很多问题。但是只要牢记dll不拥有核心数据,必须每次重新读取,就没有大问题。