
把窗口从 800×600 拖到 1600×900画面里那个本该是正方形的东西被拉成了一张扁饼——这个场景在 OpenGL 入门阶段几乎人人遇到过。问题不在于你写的绘制代码有 bug而在于窗口尺寸变化这件事本身没有被接住。创建可调整大小的窗口看着是最基础的一步实际上它串起了 GLFW 的窗口属性设置、帧缓冲尺寸与逻辑尺寸的换算、回调函数的执行时机、视口更新、投影矩阵随宽高比联动这一整条链路。任何一个环节漏掉表现出来的症状都差不多拉伸、黑边、闪烁、拖动时画面卡死、最小化后恢复崩溃。这篇笔记就围绕OpenGL GLFW 创建可调整大小的窗口这一件事展开把从初始化到回调到投影矩阵的完整链路拆开讲。适合已经能跑通第一个三角形、但一拖窗口就露馅的读者也适合想把自己那套窗口模板整理干净的老手。下面所有代码基于 GLFW 3.3 以上版本 现代 OpenGL3.3 核心模式用 C/C 写其他语言绑定Java、Python、Rust 之类思路完全一致只是 API 名字换了。1. 先把窗口当成一个对象初始化顺序里的隐藏依赖很多教程在创建窗口这一步只给三行代码glfwInit()、glfwCreateWindow()、glfwMakeContextCurrent()。跑起来确实有窗口所以大家默认窗口本来就是能拖的。这个结论对了一半——GLFW 里GLFW_RESIZABLE的默认值就是GLFW_TRUE你不设它窗口依然可调整。但能拖和拖了之后正常是两件完全不同的事前者是窗口管理器的行为后者要靠你自己补代码。我见过不少人卡在为什么我设了GLFW_RESIZABLE没生效这种问题上追下去发现是 hint 的调用位置错了。GLFW 的 hint 机制有个硬性约束大部分 hint 只在glfwCreateWindow()执行的那一刻被读取一次窗口建出来之后再改不会回头影响已经存在的窗口。这就像点外卖时备注不要香菜你得在下单前写餐送到手再改备注没有任何意义。1.1 hint 的生效边界与调用顺序下面这些 hint 必须在glfwCreateWindow()之前设置顺序错了或者放晚了全部静默失效不会有任何报错提示hint默认值作用是否必须建窗前设置GLFW_RESIZABLEGLFW_TRUE窗口能否被用户拉伸是GLFW_CONTEXT_VERSION_MAJOR/MINOR1.0请求的 OpenGL 版本是GLFW_OPENGL_PROFILEGLFW_OPENGL_ANY_PROFILE核心模式或兼容模式是GLFW_OPENGL_FORWARD_COMPATGLFW_FALSE前向兼容macOS 必需是GLFW_SCALE_TO_MONITORGLFW_FALSE是否按显示器缩放窗口内容是GLFW_VISIBLEGLFW_TRUE建窗后是否立即显示是GLFW_DECORATEDGLFW_TRUE是否有标题栏和边框是这里有个容易忽略的细节GLFW_SCALE_TO_MONITOR在 3.3 版本才有如果你用的是更老的 GLFW这个 hint 根本不存在编译会直接报未定义标识符。所以代码里最好包一层版本判断或者干脆在项目里锁定 GLFW 的最低版本。我自己的模板里长期保留这一段glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE); // 显式写出来别指望默认值 #ifdef __APPLE__ glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); #endif显式写GLFW_RESIZABLE的理由不是为了改变行为而是为了让读代码的人知道你考虑过这件事。窗口能不能拉伸是一个产品级决策藏在默认值里迟早被后人改乱。还有一个顺序问题值得强调glfwMakeContextCurrent(window)必须在任何 GL 函数调用之前执行。有些人在glfwCreateWindow()返回后立刻调glViewport忘了先绑定上下文结果在某些驱动上什么都不发生在某些驱动上直接崩。这类问题排查起来极其浪费时间因为错误现场和真正的原因隔了好几层。1.2 一个能编译、能跑、能拖的最小骨架在讨论回调之前先把地基打牢。下面这段是可以直接编译运行的完整骨架功能是开一个可调整大小的窗口、清屏成深灰色、按 ESC 退出。看着简陋但它是后面所有扩展的基准#include glad/glad.h #include GLFW/glfw3.h #include cstdio static void onFramebufferSize(GLFWwindow* window, int width, int height) { std::printf(framebuffer resized: %d x %d\n, width, height); } int main() { if (!glfwInit()) { std::fprintf(stderr, glfwInit failed\n); return -1; } glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE); GLFWwindow* window glfwCreateWindow(800, 600, Resizable Window, nullptr, nullptr); if (!window) { std::fprintf(stderr, create window failed\n); glfwTerminate(); return -1; } glfwMakeContextCurrent(window); glfwSwapInterval(1); if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) { std::fprintf(stderr, glad init failed\n); glfwTerminate(); return -1; } glfwSetFramebufferSizeCallback(window, onFramebufferSize); glfwSetKeyCallback(window, [](GLFWwindow* w, int key, int, int action, int) { if (key GLFW_KEY_ESCAPE action GLFW_PRESS) glfwSetWindowShouldClose(w, GLFW_TRUE); }); int fbW 0, fbH 0; glfwGetFramebufferSize(window, fbW, fbH); glViewport(0, 0, fbW, fbH); while (!glfwWindowShouldClose(window)) { glClearColor(0.10f, 0.12f, 0.15f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); glfwSwapBuffers(window); glfwPollEvents(); } glfwTerminate(); return 0; }编译命令Linux 或 macOS 上手动链接 GLFWg main.cpp glad.c -lglfw -ldl -o appWindows MSVC 下额外链接glfw3.lib、opengl32.lib并把 glad.c 加进工程。这一段不是重点但链接错误是新手最常卡住的地方——undefined reference to glfwInit十有八九是没加-lglfwglfwGetProcAddress找不到多半是没链接-ldl。先跑通这个骨架再去拖窗口。你会发现控制台在疯狂打印尺寸说明回调确实触发了但画面没有任何变化——因为此时你还没画东西也还没更新视口。这就是下一步要解决的问题。2. 拖大之后画面变形窗口尺寸和帧缓冲尺寸不是一回事初学者最容易混淆的一组概念glfwGetWindowSize()和glfwGetFramebufferSize()。这两个函数名字长得像返回的也总是两个 int很容易随手抓一个用然后在高分屏上翻车。打个比方glfwGetWindowSize拿到的是这块玻璃在桌面上占多长多宽逻辑尺寸单位是屏幕坐标glfwGetFramebufferSize拿到的是这块玻璃背后贴了多少个可以点亮的像素格子物理尺寸单位是像素。在普通的 96 DPI 显示器上这两者数值相等所以你随便用哪个都对一旦上了 200% 缩放的高分屏物理尺寸会变成逻辑尺寸的两倍两者就分家了。2.1 两个尺寸函数的分工与换算函数返回单位典型用途高 DPI 下的表现glfwGetWindowSize屏幕坐标窗口位置、UI 布局、保存窗口大小不变glfwGetFramebufferSize像素glViewport、渲染目标尺寸、宽高比成倍放大glfwGetWindowContentScale缩放因子手动做 UI 缩放返回 1.0 / 1.5 / 2.0 等glfwSetWindowSize屏幕坐标程序化改窗口大小影响的是逻辑尺寸结论很直接所有跟 OpenGL 渲染相关的尺寸计算一律用glfwGetFramebufferSize。包括glViewport的四个参数、投影矩阵的宽高比、离屏渲染的视口尺寸全部走它。用glfwGetWindowSize去做这些事在高分屏上的表现就是画面只占据了左下角四分之一——因为你的视口比实际像素小了一半。我在一个跨平台 Demo 上真切踩过这个坑。同一份代码在普通显示器上完美换到笔记本的高分屏上画面缩在左下角一小块。当时第一反应是投影矩阵算错了翻来覆去检查矩阵乘法最后把两个尺寸都打印出来才看到问题窗口 1280×720帧缓冲 2560×1440。glViewport(0, 0, 1280, 720)只覆盖了左下角剩下四分之三区域保持上一帧的内容看起来像黑边。对应地回调也有两个glfwSetWindowSizeCallback逻辑尺寸变化时触发适合更新 UI 布局、保存窗口状态。glfwSetFramebufferSizeCallback物理像素尺寸变化时触发适合更新视口和投影。两个都注册不会冲突各管各的。但如果只想要一个选 framebuffer 版本——它同时包含了 DPI 变化的情况。2.2 glViewport 应该在哪里调用glViewport(x, y, width, height)干的事情是告诉 OpenGL把归一化设备坐标NDC映射到窗口的哪一块像素矩形上。它的参数直接决定你的画面占多大区域所以必须和帧缓冲尺寸保持一致而且必须在尺寸变化时重新调用。最省事的写法是在主循环里每帧调用一次int fbW, fbH; glfwGetFramebufferSize(window, fbW, fbH); glViewport(0, 0, fbW, fbH);好处是绝对不会漏代价是每帧一次glfwGetFramebufferSize的系统调用开销。这个开销小到可以忽略但我个人还是倾向于写在回调里 用一个标记位兜底原因后面会讲。千万要注意的是初始视口。有人以为 OpenGL 初始化之后会自动帮你设置视口其实默认的视口尺寸是窗口创建时的初始大小通常就是你传给glfwCreateWindow的 800×600。如果你一开始就把窗口拖到 1600×1200 再开始渲染画面还是只占左下角的 800×600。所以第一次的glViewport一定要在渲染循环之前显式调用一次。还有一个小坑glViewport的参数是GLintglfwGetFramebufferSize输出的是int两者位数一致直接传没问题。但如果你用unsigned int接尺寸在极端情况下会出问题老老实实用int更稳。3. 回调函数的分寸感到底该写多重才合适回调写轻了不顶事写重了卡帧这个度很难把握。我见过最激进的做法是在 framebuffer 回调里直接重建整个渲染管线——重新编译着色器、重建帧缓冲对象。结果就是拖个窗口程序 CPU 直接飙满画面一卡一卡的用的人以为程序挂了。回调到底能做什么取决于它的执行位置。GLFW 的回调全都在主线程执行而主线程什么时候执行回调在glfwPollEvents()或glfwWaitEvents()被调用的时候。也就是说回调是在你的事件处理阶段同步跑完的跑完之后才轮到你的渲染代码。这个执行模型很关键——它意味着你可以在回调里调 GL 函数上下文是 current 的但也意味着回调里花的每一毫秒都会直接推迟这一帧的渲染。3.1 两类回调的职责划分我习惯把回调的职责分到三个层级第一层是状态记录只做纯粹的数值保存不碰任何 GL 资源。这是绝对安全的也是必须做的static int g_fbWidth 800; static int g_fbHeight 600; static bool g_viewportDirty true; static bool g_projectionDirty true; static void onFramebufferSize(GLFWwindow*, int width, int height) { g_fbWidth width; g_fbHeight height; g_viewportDirty true; g_projectionDirty true; }第二层是轻量 GL 调用比如glViewport。这类调用开销极低放在回调里完全没问题还能省掉主循环里的每帧查询static void onFramebufferSize(GLFWwindow*, int width, int height) { g_fbWidth width; g_fbHeight height; if (width 0 height 0) glViewport(0, 0, width, height); g_projectionDirty true; }第三层是重量级操作比如重建 FBO、重新编译着色器、重新生成纹理。这类绝对不能放回调里必须挪到主循环中用脏标记延迟处理。原因很简单窗口拖动过程中framebuffer 回调可能一秒触发几十次每次都重建一遍资源性能直接崩。3.2 拖动窗口时画面卡死的真相有个现象很多人遇到过但没深究在 Windows 上按住标题栏拖动窗口画面就停住了松手才继续动。用上一节的骨架测试拖动过程中控制台的尺寸日志是停的说明主循环根本没在跑。原因不在 OpenGL也不在 GLFW而在于窗口管理器在拖动时进入了模态循环——系统接管了消息处理你的glfwPollEvents拿不到消息主循环卡在glfwSwapBuffers或者下一次glfwPollEvents上。这是 Windows 平台的既有行为macOS 和大部分 Linux 桌面环境不会这样。绕过它的办法是注册glfwSetWindowRefreshCallback。这个回调在窗口内容需要重绘时触发包括被其他窗口遮挡后重新露出、以及拖动过程中的重绘请求。在回调里补一次渲染 交换缓冲画面就能跟着走static void onWindowRefresh(GLFWwindow* window) { renderScene(); // 你的绘制逻辑 glfwSwapBuffers(window); }写成这个形态的前提是renderScene()本身没有副作用、不依赖主循环里的状态。我一般会把渲染逻辑抽成一个独立函数主循环和 refresh 回调都调它这样两边行为一致。要提醒一句这个回调在主循环之外执行如果你在主循环里每帧更新动画时间refresh 回调里拿到的时间可能是旧的。对静态场景没影响对动画场景可能出现拖动时画面短暂停格。我自己的做法是 refresh 回调里只做最简单的重绘不改任何状态。3.3 脏标记 主循环渲染的标准形态综合下来我模板里的窗口状态管理长这样struct WindowState { int fbWidth 800; int fbHeight 600; bool viewportDirty true; bool projectionDirty true; bool minimized false; };minimized这个字段单独拎出来是有原因的下一节会细说。主循环里根据脏标记决定要不要更新更新完立刻清标记while (!glfwWindowShouldClose(window)) { glfwPollEvents(); if (g_state.minimized) { glfwWaitEventsTimeout(0.1); continue; } if (g_state.viewportDirty) { glViewport(0, 0, g_state.fbWidth, g_state.fbHeight); g_state.viewportDirty false; } if (g_state.projectionDirty) { rebuildProjection(g_state.fbWidth, g_state.fbHeight); g_state.projectionDirty false; } renderScene(); glfwSwapBuffers(window); }这套结构的价值在于渲染频率和事件频率彻底解耦。回调可以疯狂触发主循环每帧只处理一次最终状态中间那些过渡尺寸被自然丢弃。拖窗口时从 800 拖到 1600中间可能经过 500 个尺寸但实际只渲染了 60 次左右GPU 完全扛得住。4. 画面不拉伸的核心宽高比驱动的投影更新视口更新对了画面位置就对了但内容还是会变形。原因在于投影矩阵是用固定宽高比算的。你按 800/600 1.333 算了一个正交或者透视矩阵窗口拖到 1600×600 之后宽高比变成 2.667矩阵没变于是所有东西在水平方向被拉伸了整整一倍。这就是画面变成扁饼的完整解释视口是新的投影是旧的两者不匹配。4.1 正交投影下的宽高比处理正交投影的场景2D 游戏、UI、图表最典型的错误写法是// 错误把投影和窗口尺寸写死绑在一起 glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, -1.0f, 1.0f);窗口变了矩阵还是这个画面自然拉伸。正确的思路是让投影只依赖宽高比不依赖具体像素数float aspect (float)fbWidth / (float)fbHeight; float halfH 1.0f; // 垂直方向固定显示 [-1, 1] float halfW halfH * aspect; // 水平方向随之伸缩 glm::mat4 proj glm::ortho(-halfW, halfW, -halfH, halfH, -1.0f, 1.0f);这个写法的含义是垂直视野固定水平视野随窗口变宽而变宽。窗口变宽你能看到更多内容但圆形依然是圆的。反过来水平固定、垂直伸缩也成立只是窗口变得很扁时垂直方向会看到很少的内容体验不太好。还有第三种做法是在着色器里用一个u_aspectuniform 补偿#version 330 core layout(location 0) in vec2 aPos; uniform float uAspect; // width / height void main() { vec2 p aPos; if (uAspect 1.0) p.x / uAspect; // 窗口更宽压缩 x else p.y * uAspect; // 窗口更高压缩 y gl_Position vec4(p, 0.0, 1.0); }这么写的好处是完全不需要矩阵库适合画个三角形验证链路的小 Demo。缺点是不方便做相机变换正式项目还是乖乖用矩阵。4.2 透视投影里 aspect 的位置透视投影的矩阵里有一项专门吃宽高比float aspect (float)fbWidth / (float)fbHeight; glm::mat4 proj glm::perspective(glm::radians(45.0f), aspect, 0.1f, 100.0f);这个aspect算错或者没更新表现就是画面水平方向压扁或拉长。3D 场景里这个错误比 2D 更明显因为透视的深度感会被破坏远处的物体看起来位置不对。更新时机上有个细节不要在每次glfwPollEvents()之后无脑重算投影矩阵。glm::perspective本身开销不大但如果你在它后面还挂了级联操作比如把投影矩阵传给多个着色器、更新阴影贴图的视锥成本就上来了。用脏标记控制只在真正需要时重算是更务实的做法。还有个坑fbWidth / fbHeight如果用整数除法会得到 0 或 1。务必先转 float。这个错误极其常见而且表现很迷惑——窗口在 600×600 以内时画面正常一超过就变成一条线因为整数除法把 1.5 截成了 1。5. 边界场景最小化、零尺寸与尺寸限制正常拖动之外还有一堆边界场景会把没防住的代码打回原形。这些场景在开发时不容易碰到一到用户手里立刻暴露。5.1 最小化时拿到 0×0 会发生什么点最小化按钮的那一瞬间部分平台会把帧缓冲尺寸上报为 0×0。这时候如果你按照上一节的逻辑走glViewport(0, 0, 0, 0)GL 本身不会崩——零尺寸视口是合法的只是画不出东西。真正的雷在后面float aspect (float)fbWidth / (float)fbHeight; // 0 / 0 NaNNaN一旦进入投影矩阵整个变换就废了。恢复窗口后画面可能全黑、可能闪烁最糟的情况是 NaN 传染到其他矩阵导致整个场景消失而且不报任何错你只能靠打印矩阵值一点点找。我的处理方式是在回调里就把零尺寸挡掉同时维护一个minimized标记static void onFramebufferSize(GLFWwindow*, int width, int height) { g_state.fbWidth width; g_state.fbHeight height; if (width 0 || height 0) { g_state.minimized true; return; } g_state.minimized false; g_state.viewportDirty true; g_state.projectionDirty true; }主循环里看到minimized就跳过渲染用glfwWaitEventsTimeout(0.1)让出 CPU。不要用glfwWaitEvents()无超时阻塞某些平台的窗口恢复事件不一定能可靠唤醒它用带超时的版本更保险代价是一秒十次的空转完全可以接受。顺带提一个容易忽略的点最小化期间不要调glfwSwapBuffers。某些驱动在零尺寸缓冲上交换会返回错误码虽然不致命但会把错误状态残留在 GL 上下文里影响后续的错误检查。既然不渲染就别交换。5.2 最小尺寸、最大尺寸和固定宽高比GLFW_RESIZABLE是二选一的开关但实际需求往往介于两者之间。比如窗口不能被拖到小于 480×320否则 UI 元素全挤在一起或者某类应用要求 16:9 固定比例不想让用户拖成方块。GLFW 提供了三个专门的函数// 最小 480×320最大不限 glfwSetWindowSizeLimits(window, 480, 320, GLFW_DONT_CARE, GLFW_DONT_CARE); // 锁定 16:9 宽高比拖动时高度自动跟随宽度 glfwSetWindowAspectRatio(window, 16, 9);GLFW_DONT_CARE是一个专用宏表示这个方向不限制。注意不要传 -1虽然数值上碰巧一致但语义上不对可读性也差。这两个函数和GLFW_RESIZABLE的关系是叠加的RESIZABLE决定能不能拖size limits 和 aspect ratio 决定能拖成什么样。三个都用上能得到一个边界明确的窗口用户拖到极限就停住不会出现奇怪的尺寸。有个细节值得注意设置了宽高比之后用glfwSetWindowSize程序化改尺寸也会受约束设成不符合比例的值会被自动调整。如果你需要临时绕过先调glfwSetWindowAspectRatio(window, GLFW_DONT_CARE, GLFW_DONT_CARE)解除限制改完再设回去。还有全屏切换。glfwSetWindowMonitor(window, monitor, 0, 0, mode-width, mode-height, mode-refreshRate)可以在窗口化和全屏之间切换切换过程中会触发 framebuffer 回调尺寸直接从窗口尺寸跳到显示器分辨率。如果你的投影矩阵更新依赖脏标记这里会自动跟上如果写死了初始尺寸切全屏就会拉伸。这也是为什么我一直强调投影必须和 framebuffer 尺寸联动。6. 手写一份完整可跑的可调窗口实现前面拆得比较散这里整合成一份完整代码。功能包括可调整大小的窗口、视口同步、投影随宽高比更新、最小化保护、拖动时画面不卡死、按 ESC 退出。画的是一个会随窗口变化的彩色三角形用来直观验证拉伸是否被消除。6.1 完整代码#include glad/glad.h #include GLFW/glfw3.h #include cstdio #include cmath struct WindowState { int fbWidth 800; int fbHeight 600; bool viewportDirty true; bool projectionDirty true; bool minimized false; float aspect 800.0f / 600.0f; }; static WindowState g_state; static GLuint g_vao 0, g_vbo 0, g_program 0; static GLint g_locAspect -1; static void onFramebufferSize(GLFWwindow*, int width, int height) { g_state.fbWidth width; g_state.fbHeight height; if (width 0 || height 0) { g_state.minimized true; return; } g_state.minimized false; g_state.viewportDirty true; g_state.projectionDirty true; } static void renderScene(GLFWwindow* window); static void onWindowRefresh(GLFWwindow* window) { // 拖动/遮挡恢复时补一次绘制避免画面停滞 renderScene(window); glfwSwapBuffers(window); } static GLuint compileShader(GLenum type, const char* src) { GLuint s glCreateShader(type); glShaderSource(s, 1, src, nullptr); glCompileShader(s); GLint ok 0; glGetShaderiv(s, GL_COMPILE_STATUS, ok); if (!ok) { char log[1024]; glGetShaderInfoLog(s, sizeof(log), nullptr, log); std::fprintf(stderr, shader compile error: %s\n, log); } return s; } static void initGL() { const char* vs R( #version 330 core layout(location 0) in vec2 aPos; uniform float uAspect; void main() { vec2 p aPos; if (uAspect 1.0) p.x / uAspect; else p.y * uAspect; gl_Position vec4(p, 0.0, 1.0); } ); const char* fs R( #version 330 core out vec4 FragColor; void main() { FragColor vec4(0.90, 0.55, 0.20, 1.0); } ); GLuint v compileShader(GL_VERTEX_SHADER, vs); GLuint f compileShader(GL_FRAGMENT_SHADER, fs); g_program glCreateProgram(); glAttachShader(g_program, v); glAttachShader(g_program, f); glLinkProgram(g_program); glDeleteShader(v); glDeleteShader(f); g_locAspect glGetUniformLocation(g_program, uAspect); const float verts[] { -0.5f, -0.5f, 0.5f, -0.5f, 0.0f, 0.5f }; glGenVertexArrays(1, g_vao); glGenBuffers(1, g_vbo); glBindVertexArray(g_vao); glBindBuffer(GL_ARRAY_BUFFER, g_vbo); glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW); glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); glBindVertexArray(0); } static void renderScene(GLFWwindow*) { glClearColor(0.10f, 0.12f, 0.15f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); glUseProgram(g_program); glUniform1f(g_locAspect, g_state.aspect); glBindVertexArray(g_vao); glDrawArrays(GL_TRIANGLES, 0, 3); glBindVertexArray(0); } int main() { if (!glfwInit()) return -1; glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE); #ifdef __APPLE__ glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); #endif GLFWwindow* window glfwCreateWindow(800, 600, Resizable Window, nullptr, nullptr); if (!window) { glfwTerminate(); return -1; } glfwMakeContextCurrent(window); glfwSwapInterval(1); if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) return -1; glfwSetFramebufferSizeCallback(window, onFramebufferSize); glfwSetWindowRefreshCallback(window, onWindowRefresh); glfwSetWindowSizeLimits(window, 480, 320, GLFW_DONT_CARE, GLFW_DONT_CARE); glfwGetFramebufferSize(window, g_state.fbWidth, g_state.fbHeight); g_state.aspect (float)g_state.fbWidth / (float)g_state.fbHeight; initGL(); while (!glfwWindowShouldClose(window)) { glfwPollEvents(); if (g_state.minimized) { glfwWaitEventsTimeout(0.1); continue; } if (g_state.viewportDirty) { glViewport(0, 0, g_state.fbWidth, g_state.fbHeight); g_state.viewportDirty false; } if (g_state.projectionDirty) { g_state.aspect (float)g_state.fbWidth / (float)g_state.fbHeight; g_state.projectionDirty false; } renderScene(window); glfwSwapBuffers(window); } glDeleteProgram(g_program); glDeleteBuffers(1, g_vbo); glDeleteVertexArrays(1, g_vao); glfwTerminate(); return 0; }6.2 关键几行的逐条解读glfwSetWindowSizeLimits(window, 480, 320, GLFW_DONT_CARE, GLFW_DONT_CARE)这行放在回调注册之后、主循环之前。窗口一开始就是 800×600比最小值大所以不会触发自动缩小。如果你把初始尺寸设成 300×200会看到窗口一出现就被撑到 480×320这是正常行为。glfwSetWindowRefreshCallback里又调了一次renderSceneglfwSwapBuffers。注意这里的renderScene读的是g_state.aspect而拖动过程中aspect可能还没来得及在主循环里更新。所以严格来说refresh 回调里的画面用的是上一帧的宽高比。实测影响很小因为拖动结束后主循环立刻会补一次正确的。如果追求极致可以在 refresh 回调里也做一次脏标记检查但一般没必要。uAspect的补偿逻辑写的是窗口更宽就压 x更高就压 y。这个做法的效果是三角形的外接框始终保持正方形比例不会因为窗口变宽而变扁。你可以把if/else换成一个统一的p.x / uAspect;效果就变成窗口越宽三角形越扁——这正好是错误写法的表现可以拿来对照感受区别。主循环里glfwPollEvents放在最前面而不是最后是为了让回调先跑完脏标记先就位同一帧内就完成更新。如果放在最后尺寸变化就要延迟一帧生效拖窗口时会看到画面比窗口边界慢半拍。6.3 实测现象与排查清单跑起来之后正常的表现应该是这样的拖动窗口边缘三角形始终是等边三角形只是窗口变宽时它相对变小窗口变窄时相对变大但形状不变。如果窗口已经很宽三角形会显得比较小这是因为垂直视野固定水平方向看到的空间变多了。这是预期行为不是 bug。按最小化控制台不会有任何异常输出CPU 占用掉到接近零。恢复窗口画面立刻回来没有黑屏或闪烁。在 Windows 上按住标题栏拖动画面跟着窗口走不会卡住。如果实测不符合上面任何一条对照下表排查现象最可能的原因排查动作画面缩在左下角视口用了 window size打印两个尺寸对比三角形变扁aspect 没更新或没传给着色器检查g_locAspect是否为 -1拖窗口时画面停住没注册 refresh 回调检查glfwSetWindowRefreshCallback恢复窗口后黑屏零尺寸污染了矩阵检查minimized分支是否生效编译报uAspect未使用uniform 被优化掉确认顶点着色器里真的用到了它高分屏上画面模糊用了 window size 做视口换glfwGetFramebufferSizeglGetUniformLocation返回 -1 是个特别隐蔽的坑。它不报错glUniform1f(-1, ...)也不报错只是静默不生效。原因通常是着色器里那个 uniform 被编译器优化掉了比如你没在main里用它。我现在的习惯是在初始化阶段加一句检查if (g_locAspect 0) { std::fprintf(stderr, warning: uniform uAspect not found\n); }宁可多打一行日志也别让这个问题拖到运行期去猜。最后再分享一个小技巧。调窗口相关的问题时我习惯在回调里临时加一行std::printf把 window size、framebuffer size、content scale 三个值一起打出来。三者一对比90% 的尺寸类问题当场就能定位——是逻辑尺寸变了还是物理尺寸变了是 DPI 缩放导致的还是视口没更新一眼就能分清。这行日志我到现在还留在模板里只是加了编译期的开关发布版本关掉调试版本默认打开。