迈智数创建筑 AI · Coding

试试搜索

↑↓ 选择Enter 打开Esc 关闭

Lesson03:程序入口与主循环

源码 https://github.com/CHMOSE023/MiniCAD

下载 .md
系列:MiniCAD第 5 / 17 篇
  1. MiniCAD 初级教程课程规划
  2. C++ 基础课:读懂 MiniCAD 所需的语言知识
  3. Lesson01:项目能跑起来
  4. Lesson02:整体架构地图
  5. Lesson03:程序入口与主循环
  6. Lesson04:输入系统与消息映射
  7. Lesson05:绘图工具如何工作
  8. Lesson06:Core 核心层:数学、几何、对象与实体
  9. Lesson07:命令模式与撤销重做
  10. Lesson08:选择、拾取与夹点编辑
  11. Lesson09:视口、相机与坐标转换
  12. Lesson10:渲染层入门
  13. Lesson11:文字与字体系统
  14. Lesson12:吸附与约束系统
  15. Lesson13:命令行系统
  16. Lesson14:多文档管理
  17. Lesson15:综合实战:扩展一个 MiniCAD 功能
目录
  1. 学习目标
  2. Windows 入口:WinMain / main()
  3. Windows 初始化顺序
  4. Windows 消息循环(Run)
  5. Windows 消息路由:WndProc → EventProc
  6. Windows 每帧执行顺序(RenderFrame)
  7. Windows 输入转发路径(DocumentInput)
  8. Web 入口:main()
  9. Web 主循环(MainLoop)
  10. Web 输入转发路径
  11. Web JS 工具栏:EMSCRIPTEN_KEEPALIVE 导出
  12. 两套入口对比
  13. 课堂演示
  14. 拓展练习

学习目标

读懂 MiniCAD 两套入口的完整启动流程,以及消息(输入事件)如何从操作系统/浏览器一路转发到 Editor::OnInput()。完成后应理解:Windows 与 Web 的主循环模型差异、每帧的执行顺序、输入事件的两条转发路径。


Windows 入口:WinMain / main()

src/App/Win/Main.cpp 只有几行,职责是创建 MainWindow 并调用 Run():

// USE_WIN32 宏控制用哪个入口签名
#ifdef USE_WIN32
int APIENTRY WinMain(HINSTANCE hInst, HINSTANCE, PSTR, int)
#else
int main()   // 控制台模式,方便 printf 调试
#endif
{
    MainWindow mainWindow;
    mainWindow.Initialize(L"MiniCAD", 600, 400);
    mainWindow.Run();
    return 0;
}

USE_WIN32 宏决定入口函数签名:WinMain 不显示控制台窗口,main 版本会弹出控制台(便于调试日志)。Release 构建用 WinMain;开发期间用 main 方便 printf。


Windows 初始化顺序

MainWindow::Initialize() 的四步必须严格按顺序:

1. InitWindow()         → 注册窗口类,CreateWindowEx,设置 DWM 阴影,居中显示
2. InitD3D11()          → 创建 Device + SwapChain + D3D11Renderer
3. m_fontSystem.Initialize() + PreloadDefaultFonts()
                        → 必须在 InitDocument 之前,因为 Document 注册字体样式需要 FontSystem
4. InitDocument()       → DocumentManager.InitViewport + 注册 FontStyle + Create() 创建首个文档
5. m_uiManager.Init()   → ImGui Win32/D3D11 后端初始化

顺序约束说明:

  • 字体先于文档:InitDocument 内部调用 RegisterFontStyle,这需要 FontSystem 已就绪。
  • D3D11 先于视口:InitViewport 内部创建 D3D11RenderTarget,需要 IRenderer 已存在。
  • ImGui 最后:m_uiManager.Init 把 ImGui 的 Win32 窗口句柄和 D3D11 设备注入后端,必须两者都已创建。

Windows 消息循环(Run)

void MainWindow::Run()
{
    MSG msg = {};
    bool needsRedraw = true;

    while (msg.message != WM_QUIT)
    {
        if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE))
        {
            TranslateMessage(&msg);  // 将 WM_KEYDOWN 转换为 WM_CHAR
            DispatchMessage(&msg);   // 路由到 WndProc
            needsRedraw = true;      // 收到任何消息都标记需要重绘
        }
        else
        {
            if (needsRedraw)
            {
                RenderFrame();       // 消息队列空了才渲染
                needsRedraw = false;
            }
            else
            {
                WaitMessage();       // 无消息、无需渲染:让出 CPU
            }
        }
    }
}

关键设计:这是一个"事件驱动"的主循环,而不是游戏常见的"无限渲染"循环。

  • PeekMessage(非阻塞):有消息就取走并分发,但不在此处渲染。
  • 消息队列清空后,才调用 RenderFrame(),渲染一帧。
  • 渲染后若没有新消息,WaitMessage() 让线程睡眠,完全不占 CPU。
  • CAD 软件大部分时间静止,这个设计比游戏引擎的忙等循环节省大量 CPU。

Windows 消息路由:WndProc → EventProc

OS 产生消息(WM_MOUSEMOVE、WM_LBUTTONDOWN、WM_KEYDOWN …)
  ↓
DispatchMessage → WndProc(静态函数)
  ↓
通过 GWLP_USERDATA 取回 this 指针
  ↓
pThis->EventProc(hwnd, msg, wParam, lParam)
  ↓
① ImGui_ImplWin32_WndProcHandler(hwnd, msg, ...)  ← ImGui 先吃一遍
  如果 ImGui 消耗(返回 true),直接 return,不往下走
  ↓
② switch(msg) 处理窗口级消息:
   WM_SIZE      → SwapChain::Resize
   WM_NCHITTEST → 自定义标题栏/边框拖拽区域
   WM_DESTROY   → PostQuitMessage(0)
   其余         → DefWindowProc

注意:鼠标移动、点击、按键这些输入事件不在 WndProc 里转发给 Editor。ImGui 的 Win32 后端把它们存进 ImGuiIO,在下一帧 RenderFrame() 中读取。


Windows 每帧执行顺序(RenderFrame)

void MainWindow::RenderFrame()
{
    // 1. 清空后备缓冲区
    ClearRenderTargetView(rtv, { 0.1, 0.1, 0.1, 1 });
    OMSetRenderTargets(1, &rtv, nullptr);

    // 2. ImGui 帧开始(必须在 Editor::Render 之前)
    m_uiManager.BeginFrame();
    //   └─ ImGui_ImplDX11_NewFrame + ImGui_ImplWin32_NewFrame + ImGui::NewFrame

    // 3. 同步字体纹理(BeginFrame 之后、Render 之前)
    m_uiManager.SyncFonts(m_docManager);
    //   └─ 把 ImGui 字体图集 SRV 注入 Editor,供 DrawContext::EmitText 使用

    // 4. 从 ImGui IO 读取本帧视口输入并转发给 Editor
    DocumentInput();

    // 5. Editor 收集顶点并提交给 Viewport 渲染
    m_docManager.GetEditor().Render();

    // 6. 重新绑定 RTV(Editor::Render 内部可能改变渲染目标)
    OMSetRenderTargets(1, &rtv, nullptr);

    // 7. 渲染 ImGui UI(菜单、工具栏、状态栏)
    m_uiManager.Render(m_docManager);

    // 8. ImGui 帧结束,提交 ImGui 绘制数据到 D3D11
    m_uiManager.EndFrame();

    // 9. 呈现(交换前后缓冲区)
    m_swapChain->Present();
}

顺序约束:

  • BeginFrame 必须在 Editor::Render 之前,EmitText 内部调用 ImGui 字形接口需要帧已开始。
  • SyncFonts 必须在 BeginFrame 之后(字体 atlas SRV 在 NewFrame 时才确定)。
  • Editor::Render 必须在 UIManager::Render 之前,否则视口内容会被 ImGui 的绘制覆盖。

Windows 输入转发路径(DocumentInput)

消息循环只负责把 OS 事件喂给 ImGui;每帧由 DocumentInput() 把 ImGui 状态转换成 InputEvent 列表:

void MainWindow::DocumentInput()
{
    const auto& uiInput = m_uiManager.GetViewportInput();
    // ViewportInput 是 UIManager 每帧从 ImGui::IsItemHovered/Active 等状态构建的快照

    if (!uiInput.Valid) return;  // 视口区域未就绪

    // 视口尺寸变化时同步 Viewport
    auto& viewport = m_docManager.GetViewport();
    if (viewport.GetWidth() != uiInput.Size.x || ...)
        viewport.Resize(uiInput.Size.x, uiInput.Size.y);

    // ViewportInputAdapter 把快照差分成事件列表
    for (const auto& e : m_viewportInputAdapter.BuildEvents(uiInput))
    {
        m_docManager.GetEditor().OnInput(e);
    }
}

完整的 Windows 输入路径:

OS 鼠标/键盘事件
  → WndProc → ImGui_ImplWin32_WndProcHandler → 存入 ImGuiIO
  → 下一帧 UIManager::BeginFrame (ImGui::NewFrame)
  → UIManager 从 ImGui::IsItemHovered/Active/IO 构建 ViewportInput 快照
  → ViewportInputAdapter::BuildEvents(ViewportInput) → InputEvent 列表
  → Editor::OnInput(e)

Web 入口:main()

Web 版没有 MainWindow,全局变量就是运行环境:

// 匿名 namespace 内的全局对象
std::unique_ptr<IRenderer> g_renderer;
std::unique_ptr<Document>  g_document;
std::unique_ptr<Viewport>  g_viewport;
Editor                     g_editor;       // 值类型,与 DocumentManager 里相同
FontSystem                 g_fontSystem;

// 手动维护的状态
int g_lastX, g_lastY;       // 上一帧鼠标位置(用于 MouseMove 的 LastMouseX/Y)
int g_pressX, g_pressY;     // 最近一次按下时的位置(用于拖拽判定)
unsigned g_mouseButtons;    // 当前按下的按键掩码(Emscripten 不维护,需要手动)

main() 的初始化顺序:

1. emscripten_webgl_create_context("#minicad-canvas", attrs)
   → 创建 WebGL2 上下文并激活

2. ResizeIfNeeded()
   → 同步 canvas 物理像素尺寸(CSS 尺寸 × devicePixelRatio)

3. CreateRenderer(info)       → WebGLRenderer
4. new Viewport(*g_renderer, w, h)
5. new Document()
6. g_editor.Bind(*g_document, *g_viewport)

7. g_fontSystem.Initialize() + PreloadDefaultFonts()
   → SHX 字体从 --preload-file 嵌入的虚拟 FS /fonts/ 读取

8. 注册 Emscripten 事件回调:
   emscripten_set_mousedown_callback (kCanvas, ..., OnMouse)
   emscripten_set_mouseup_callback   (kCanvas, ..., OnMouse)
   emscripten_set_mousemove_callback (kCanvas, ..., OnMouse)
   emscripten_set_wheel_callback     (kCanvas, ..., OnWheel)
   emscripten_set_keydown_callback   (EMSCRIPTEN_EVENT_TARGET_WINDOW, ..., OnKey)
   emscripten_set_keyup_callback     (EMSCRIPTEN_EVENT_TARGET_WINDOW, ..., OnKey)

9. emscripten_set_main_loop(MainLoop, 0, EM_TRUE)
   → 注册主循环,接管控制流(EM_TRUE = 阻塞,main 后面的代码不会执行)

Web 主循环(MainLoop)

void MainLoop()
{
    ResizeIfNeeded();     // 检查 canvas 是否因浏览器窗口变化而需要缩放
    g_editor.Render();    // 收集顶点 + 提交渲染
}

emscripten_set_main_loop(MainLoop, 0, EM_TRUE) 三个参数含义:

  • MainLoop:每帧调用的函数指针
  • 0:帧率 0 = 跟随浏览器 requestAnimationFrame,通常 60fps
  • EM_TRUE:接管控制流,main() 的剩余代码不执行(类似 while(true) 替代)

Web 主循环远比 Windows 简单:没有消息泵,没有 ImGui,没有 SwapChain——浏览器负责帧节奏,WebGL 上下文始终有效。


Web 输入转发路径

Web 的输入路径比 Windows 直接得多——回调里直接构建 InputEvent 然后调用 g_editor.OnInput():

浏览器原生事件(mousedown、mousemove、wheel、keydown …)
  → Emscripten 回调(OnMouse / OnWheel / OnKey)
  → 构建 InputEvent(含 DPI 修正、按键映射)
  → Dispatch(input) → g_editor.OnInput(e)

中间没有 ImGui,没有快照,没有 BuildEvents,每个事件同步触发。

DPI 修正:浏览器回调给的是 CSS 像素坐标,Editor 和 Viewport 需要物理像素:

int CanvasX(double cssX) {
    return static_cast<int>(cssX * g_width / g_cssWidth);
    // g_width = CSS宽 × devicePixelRatio(物理像素)
    // g_cssWidth = CSS宽
}

手动维护按键掩码:Windows 可以通过 GetAsyncKeyState 查询,Web 没有等价 API,所以手动维护 g_mouseButtons:

// mousedown 时置位
g_mouseButtons |= ButtonMask(button);
// mouseup 时清位
g_mouseButtons &= ~ButtonMask(button);
// 每个事件都把当前掩码写入 InputEvent.MouseButtons
input.MouseButtons = static_cast<uint8_t>(g_mouseButtons);

保留键过滤:浏览器默认拦截 F5(刷新)、F12(DevTools)等,IsBrowserReservedKey() 返回 true 的键直接 return EM_FALSE(告诉浏览器我没消费,它继续默认行为):

bool IsBrowserReservedKey(const EmscriptenKeyboardEvent* e) {
    if (e->keyCode == 116) return true;              // F5
    if (e->keyCode == 122) return true;              // F11
    if (e->ctrlKey && e->keyCode == 82) return true; // Ctrl+R
    // ...
}

Web JS 工具栏:EMSCRIPTEN_KEEPALIVE 导出

Web 版没有 ImGui 工具栏,工具按钮是 HTML + JS,通过 C 导出函数驱动:

extern "C" {
    EMSCRIPTEN_KEEPALIVE void MiniCAD_StartLine()    { g_editor.StartLineTool(); }
    EMSCRIPTEN_KEEPALIVE void MiniCAD_StartCircle()  { g_editor.StartCircleTool(); }
    EMSCRIPTEN_KEEPALIVE void MiniCAD_Undo()         { if (g_document) g_document->Undo(); }
    // ...
}

JS 侧调用:

Module.ccall('MiniCAD_StartLine', null, [], []);

EMSCRIPTEN_KEEPALIVE = 防止链接器优化删除该函数(相当于 __attribute__((used)))。extern "C" = 关闭 C++ 名称修饰,让 JS 能按字面名称找到函数。

这条路径完全绕过 InputEvent 系统——工具激活不经过键盘事件,直接调用 Editor 的方法。


两套入口对比

对比项WindowsWeb
入口函数WinMain / mainmain
主循环驱动PeekMessage + WaitMessagerequestAnimationFrame(由 Emscripten 托管)
CPU 占用(静止时)接近 0(WaitMessage 睡眠)约 1 帧 / 60fps(浏览器唤醒)
输入路径OS→WndProc→ImGuiIO→ViewportInput→InputAdapter→InputEvent浏览器→Emscripten回调→InputEvent
中间层ViewportInput 快照 + ViewportInputAdapter 差分无,直接构建
工具栏ImGui(C++ 内渲染)HTML+JS 按钮 + ccall 导出函数
字体图集ImGui 字体纹理(D3D11 SRV 注入 Editor)Web 字体(无 ImGui,另行处理)
DPI 处理Windows DPI API / DPI 感知声明CSS 尺寸 × devicePixelRatio 手动计算
Resize 触发WM_SIZE → SwapChain::ResizeMainLoop 每帧 ResizeIfNeeded() 轮询

课堂演示

  1. 在 MainWindow::RenderFrame() 里每个步骤加 OutputDebugString,观察每帧的执行顺序。
  2. 在 DocumentInput() 的 for 循环里打断点,观察 ViewportInputAdapter::BuildEvents 一帧生成多少个事件。
  3. 在 WebMain.cpp::OnMouse 里打 printf,在浏览器控制台观察鼠标事件到达的时机(每帧可能有多次,发生在 MainLoop 之外)。

拓展练习

  1. Windows 版中,如果把 WaitMessage() 去掉,会有什么现象?为什么 CAD 软件适合这种"有事才渲染"的循环而游戏不适合?
  2. Web 版的 emscripten_set_main_loop 第二个参数改成 30,会发生什么?改成 -1 呢?
  3. 找到 MainWindow::EventProc 中的 WM_NCHITTEST 处理,这段代码实现了什么功能?为什么 CAD 软件需要自定义标题栏?
  4. Web 版的 MiniCAD_Cancel 导出函数是如何把"取消"操作注入 Editor 的?这和直接调用 g_editor.Cancel() 有什么区别(假设有这个方法)?