做OpenGL练手项目的时候,俄罗斯方块几乎是被推荐最多的选题之一。很多人对它的第一印象是"这不就是个二维网格游戏吗,拿OpenGL写纯属大炮打蚊子"。我一开始也这么想,直到真的动手写第一版,才发现自己被打脸得相当彻底:从窗口创建到第一块方块落在正确的位置上,中间隔着一整套坐标系、着色器编译、顶点缓冲更新和状态管理的门槛。更麻烦的是,网上大量教程停在"画一个三角形"就结束了,从三角形到能玩能消行的完整游戏,中间那段路几乎没人细讲。
这篇内容想补的就是这段路。它面向的是已经会一点点OpenGL基础语法、想找一个完整2D项目来打通渲染管线的朋友,也适合完全零基础但愿意照着敲的人。我会从渲染架构的设计选择讲起,把坐标系映射、顶点批处理、旋转合法性判定、固定时间步长和输入手感这几件事拆开揉碎,附带完整的代码思路和我在实际调试里踩过的坑。俄罗斯方块看着简单,但它是少数能同时覆盖"渲染、状态机、时间控制、输入缓冲"四个核心问题的2D项目,用OpenGL实现俄罗斯方块的过程,本质上是把图形管线和游戏循环彻底搞明白的过程。
1. 为什么拿俄罗斯方块练OpenGL渲染管线
1.1 2D项目反而更容易暴露管线理解上的漏洞
图形编程里有个很常见的错觉:二维的东西比三维简单,所以应该先做二维。这个判断对结果是对的,但对过程不太对。三维场景有一堆现成的封装库帮你处理投影、视图、相机,你可能写了几百行还在云里雾里,但至少画面能出来。二维反而没有任何东西可以帮你兜底,每一个像素落在哪里,完全取决于你对顶点变换的理解。棋盘左上角那一格和窗口边缘之间差了几个像素,没人替你算。
俄罗斯方块的网格结构把这个问题放到了显微镜下。整个游戏就是 20 行 10 列,共 200 个格子,每个格子有固定尺寸,方块由 4 个格子组成,所有视觉元素都是轴对齐矩形。这意味着只要你把坐标映射写对了,画面就是严丝合缝的;写错一点点,方块就会和网格线错位、压线或者留缝,肉眼立刻能看出来。这种"错误立即可见"的反馈机制,比画三角形时那种"偏一点也看不出来"的状态高效得多。
我在第一版里就吃过这个亏。当时为了省事,直接把每个格子的尺寸写成 0.1 的浮点数,然后累加十次得到棋盘宽度,结果浮点误差累积到第十列的时候,方块和边框差了将近半个像素,正好在视觉上形成一条明显的缝隙。这类问题在三维项目里永远发现不了,在俄罗斯方块里天天可见。
1.2 最小依赖组合与工程结构
不需要任何引擎,只靠两个库就能跑起来:一个负责窗口和输入,一个负责加载图形接口的函数指针。我在项目里用的是 GLFW 加 GLAD 的组合,前者处理创建窗口、接收键盘事件、管理上下文,后者把驱动里的函数地址抓出来,避免在每个调用前面写一长串平台相关的加载代码。这个组合的好处是极其轻量,编译出来的可执行文件不到 1MB,启动瞬间完成,没有任何运行时依赖。
目录结构我建议在动手写第一行代码之前就定下来,因为俄罗斯方块的代码膨胀速度比想象中快得多:
project/ include/ src/ main.cpp 主循环与窗口 renderer.cpp 顶点批处理与绘制 shader.cpp 着色器编译与封装 board.cpp 棋盘状态与消行逻辑 piece.cpp 方块定义、旋转与碰撞 input.cpp 按键状态与重复逻辑 assets/ shaders/ basic.vert basic.frag这样拆分的理由很实在:渲染和游戏逻辑必须解耦,否则调试的时候你永远分不清"方块没动"到底是逻辑没更新还是画面没重绘。我在第二版重构时把棋盘状态全部收进board,渲染层只接收"给我画一个 20 乘 10 的数组"这个指令,问题定位速度立刻上了一个台阶。渲染层不关心什么是方块、什么是消行,它只知道"某个格子该涂什么颜色"。
1.3 在写代码之前先想清楚的三件事
动手前有三个决策会深刻影响后面的代码结构,值得花十分钟想明白。
第一是坐标系的基准点。窗口的左上角还是左下角?OpenGL 的裁剪空间原点在中心,Y 轴向上,而屏幕坐标习惯是左上角原点、Y 轴向下。这两个约定之间的翻转必须在一处统一处理,不能半个项目用一套,否则后面每一处计算都要在脑子里做一次心算转换。
第二是游戏逻辑的时间粒度。方块下落该由帧数驱动还是时间驱动?如果写成"每 30 帧掉一格",那在 144Hz 显示器上游戏速度会快将近五倍,这个坑我后面会专门讲。
第三是方块的数据表示。用二维 int 数组存整个棋盘是必然的,但单个方块怎么存,决定了旋转和三消的实现难度。这个选择我会在第 4 节展开,先记住一点:不要用运行时矩阵旋转来计算方块的形状变化。
2. 从NDC到棋盘格:坐标系映射是第一道坎
2.1 为什么这个项目必须用正交投影
OpenGL 的顶点着色器最终要输出一个位于 [-1, 1] 范围内的三维坐标,这个范围叫裁剪空间,对应的可见体积叫 NDC。你送进去的任何坐标,最终都会被映射到这个立方体里,超出范围的部分直接被裁掉。对于三维游戏,通常用一个透视投影矩阵来模拟近大远小;对于俄罗斯方块,透视投影是完全错误的——它会让你棋盘两端的格子看起来大小不一。
正确的做法是用正交投影矩阵,把"我关心的那块平面区域"线性映射到 NDC。所谓正交,就是没有透视除法带来的尺度变化,远处的物体和近处的物体在屏幕上一样大。这个性质对二维游戏是刚需。
具体到实现,我定义的可视区域是这样的:宽 0 到 500,高 0 到 700,原点在左上角,Y 轴向下。这个约定非常关键,因为它让"第 0 行第 0 列在左上角"这件事在代码里直观成立。代价是需要一个专门的矩阵把这个坐标系翻到 NDC。
正交投影矩阵的构造公式(列主序,直接可以喂给 uniform):
2/(r-l) 0 0 -(r+l)/(r-l) 0 2/(t-b) 0 -(t+b)/(t-b) 0 0 -2/(f-n) -(f+n)/(f-n) 0 0 0 1这里的参数取l=0, r=500, t=0, b=700, n=-1, f=1。注意t比b小,这是故意的,它会让 Y 方向的映射产生翻转,最终结果就是屏幕上方对应较小的 Y 值。我第一次写的时候没有注意这个顺序,结果整个棋盘是上下颠倒的,方块从底部往上涨,花了半小时才反应过来是投影矩阵的参数顺序问题。
2.2 窗口尺寸变化时的处理策略
窗口可以被拖动缩放,这里有两个截然不同的思路。
第一种是让棋盘尺寸跟着窗口走,你拉大窗口,格子也跟着变大。这种做法需要在窗口回调里重新计算格子尺寸,然后每隔格子重算所有顶点位置。看起来灵活,实际很麻烦:方块的像素尺寸变成运行时变量,所有和尺寸相关的地方都要重新取一次值,非常容易漏。
第二种是固定虚拟分辨率,让画面等比缩放并居中,多出来的区域留黑边。我选的是第二种,理由是整个游戏逻辑可以完全建立在一套固定的整数格子上,坐标计算永远是确定性的。实现上只需要在窗口尺寸变化时重新计算一次投影矩阵,把可视区域的宽高按比例调整,让虚拟画布居中显示。
具体来说,我用一个固定高度的基准(比如 700)作为参照,窗口变宽时左右加黑边,窗口变高时上下加黑边。计算逻辑是取窗口宽高比和基准宽高比,哪个方向更宽松就往哪个方向扩展可视区。这样画面永远不变形,棋盘的格子永远是正方形。这一点很重要:很多人在缩放窗口后发现方块变成了长方形,原因就是直接用了窗口的宽高来当可视区范围,没有保持比例。
2.3 像素坐标到格子坐标的换算
有了正交投影,所有顶点都用"虚拟像素坐标"给出,写法上非常直观:想画一个从 (20, 30) 到 (70, 80) 的矩形,直接把四个角点坐标填进顶点缓冲就行。但棋盘是格子结构的,中间必然要有一层换算。
我在这层换算上坚持用整数运算:每个格子固定 30 像素,棋盘左上角固定在 (20, 20),那么第 row 行第 col 列的格子,左上角像素坐标就是(20 + col * 30, 20 + row * 30),右下角就是加 30。全部是整数,没有任何浮点误差。渲染的时候才转成 float 送进显卡。
提示:格子尺寸一旦确定就写成常量,不要用浮点数做累加。我见过有人为了做格子间的间隙而把尺寸设成 29.7 这种值,十几次累加之后误差肉眼可见。
绘制顺序上也有讲究。我一开始是先画边框再画方块,结果方块盖住了边框的右半边,整个棋盘看起来在右边胖了一圈。改成先画背景格子再画方块、边框最后画,层次就正常了。这类层级问题在有深度测试的三维场景里靠 Z 值解决,但在关闭深度测试的二维场景里,纯粹取决于绘制调用的先后顺序。
3. 一个着色器撑起全场景:顶点批处理与颜色属性
3.1 顶点格式的设计取舍
既然整个游戏的所有视觉元素都是纯色矩形,顶点格式就可以做到极简:两个 float 表示位置,四个 float 表示颜色。总共 24 字节一个顶点,一个矩形 6 个顶点(两个三角形),144 字节。最复杂的场景下,200 个背景格子加最多 4 个活动方块加 4 个方块各 4 格,算下来也就几百个矩形,顶点数据总量不到 100KB,完全可以每帧从头构造。
这里有一个反直觉的结论值得强调:这个项目不需要实例化渲染,也不需要纹理,甚至不需要考虑性能优化。我见过不少人在俄罗斯方块里上实例化、上间接绘制,花了两天时间优化,最后发现瓶颈在窗口垂直同步。真实情况是,几千个顶点的批处理对任何一张显卡来说都是空转,你每帧重新上传整个顶点缓冲,帧时间也不会有明显变化。
代价是你必须保证每帧上传的数据是对的。这个"全量重建"的模型反而让代码更简单:任何状态变化都直接反映在下一帧构造的数据里,不需要关心增量更新、脏标记这些机制。
3.2 着色器代码与属性绑定
两个着色器都很短。顶点着色器只做一件事:把位置乘以投影矩阵,把颜色透传下去。
#version 330 core layout(location = 0) in vec2 aPos; layout(location = 1) in vec4 aColor; uniform mat4 uProjection; out vec4 vColor; void main() { gl_Position = uProjection * vec4(aPos, 0.0, 1.0); vColor = aColor; }片段着色器更短,直接输出插值后的颜色。因为我们的矩形都是纯色,四个角的颜色完全一致,插值等于没有,结果就是干净的实心色块。
#version 330 core in vec4 vColor; out vec4 FragColor; void main() { FragColor = vColor; }需要提醒的是,属性位置用layout(location=)在着色器里写死,比运行期用glGetAttribLocation查询要稳妥得多。用查询的方式,一旦着色器里有变量被优化掉,返回值可能是 -1,然后你会在一个莫名其妙的地方看到全黑画面。我吃过这个亏:某次改动着色器时删掉了一个没用的中间变量,编译器顺手把它的 location 回收了,导致后面的属性绑定全部错位,排查了两个小时。
3.3 每帧的缓冲更新策略
顶点缓冲的更新我试过三种方式,最后稳定在"预分配 + 子更新"。
第一种是每帧glBufferData重新分配,简单粗暴。问题在于驱动可能会在每次调用时重新分配显存,虽然现代驱动对这种模式做了优化,但在某些环境下会带来不必要的气泡。
第二种是glBufferSubData覆盖已分配的区间。前提是缓冲容量足够,所以我在初始化时就按最大可能顶点数预分配,比如 4096 个顶点。每帧计算实际需要的顶点数,然后只更新前一部分。这种做法稳定、无闪烁。
第三种是缓冲孤儿化,先调用一次glBufferData传入空指针表示"我要重新用这块缓冲",再glBufferSubData。它在某些驱动上能避免同步等待,但对这个项目的规模来说完全没必要。
我最终用的是第二种。每帧的流程固定为:清空 CPU 端的顶点数组,按层级依次追加矩形数据,上传到 GPU,然后一次glDrawArrays画完所有东西。整个过程只有一个绘制调用,代码短,调试也方便——想确认某个格子有没有被加进去,只要在构造完数组后打印一下顶点数量。
3.4 把绘制抽象成"画矩形"这一个接口
整个渲染层对外只暴露一个函数:给定左上角坐标、宽高、颜色,追加一个矩形。听起来简陋,但覆盖了这个游戏 100% 的绘制需求。
void Renderer::pushRect(float x, float y, float w, float h, const Color& c) { Vertex v0{{x, y }, c}; Vertex v1{{x + w, y }, c}; Vertex v2{{x + w, y + h}, c}; Vertex v3{{x, y + h}, c}; // 两个三角形:0-1-2, 0-2-3 append(v0); append(v1); append(v2); append(v0); append(v2); append(v3); }顶点的绕序这里其实无关紧要,因为我没有开启面剔除。开着剔除的话,就得保证所有三角形的顶点顺序��致,否则会出现某些矩形"消失"的诡异现象。我在早期版本里因为两个三角形的绕序写反了,导致方块的右半部分不显示,画面看起来像是被切掉了一角,找了好久才定位到是绕序问题。二维项目里最简单的做法就是直接关掉剔除。
4. 方块旋转的数学、踢墙与状态回滚
4.1 用预存旋转态代替运行时矩阵运算
新手最容易掉进的坑,是用一个旋转矩阵在运行时去变换方块的四个格子坐标。理论上没错,实际上一堆麻烦:旋转之后坐标可能变成非整数,你得四舍五入;旋转中心取决于方块的包围盒,不同的方块形状需要不同的中心;连续旋转四次之后,因为浮点误差,格子位置可能和初始状态对不上。
正确做法是预计算。每个方块(I、O、T、S、Z、J、L 七种)都有四个旋转态,每个态下四个格子的相对坐标是固定的。这些坐标可以直接查表,把七种方块乘以四个态乘以四个格子,一共 112 组坐标,全部写成常量数组。运行时"旋转"只是把状态索引加一取模。
struct PieceDef { // states[4][4]:4 个旋转态,每态 4 个格子,存 (x, y) 偏移 int states[4][4][2]; }; static const PieceDef PIECES[7] = { // I {{{{0,1},{1,1},{2,1},{3,1}}, {{2,0},{2,1},{2,2},{2,3}}, {{0,2},{1,2},{2,2},{3,2}}, {{1,0},{1,1},{1,2},{1,3}}}}, // O {{{{1,0},{2,0},{1,1},{2,1}}, {{1,0},{2,0},{1,1},{2,1}}, {{1,0},{2,0},{1,1},{2,1}}, {{1,0},{2,0},{1,1},{2,1}}}}, // ... T, S, Z, J, L };这个结构的好处非常直接:旋转是 O(1) 的查表,不会产生任何数值误差,也不会出现"连续旋转四次回不到原位"的问题。所有看起来复杂的形状变化,在代码里只是一次数组索引切换。
这里有个细节:注意 I 方块的第 0 态和第 2 态的 Y 偏移不一样。这是为了模拟标准旋转系统中 I 方块的特殊行为——它在横躺和竖立时占据的包围盒位置不同。如果你追求手感接近经典版本,这 112 组坐标就值得老老实实查表核对,不要自己凭感觉编。
4.2 旋转合法性判定与"踢墙"的简化实现
旋转之后不一定要成功。如果旋转后的位置有格子越界或者和已固定的方块重叠,这次旋转就必须被拒绝,或者尝试平移后再旋转。
bool tryRotate(Board& b, Piece& p, int dir) { int next = (p.state + dir + 4) % 4; for (int dx : {0, -1, 1, -2, 2}) { for (int dy : {0, -1, 1}) { if (isValid(b, p, p.x + dx, p.y + dy, next)) { p.x += dx; p.y += dy; p.state = next; return true; } } } return false; }上面这段是一个极简版的"踢墙"逻辑:旋转失败时依次尝试向左、向右、向上、向下平移一格再试,只要有一次能放下就接受这次旋转并保留平移量。真正的标准系统有详细的偏移表,对不同方块、不同旋转方向给出不同的候选偏移,手感会更贴近原版。但对大多数自用项目来说,这个七组候选的简化版本已经足够顺滑,而且代码量少到可以一次读懂。
注意:偏移候选的顺序是有性能直觉的——先试位移小的,一旦成功立刻返回。如果顺序反了,方块在墙边会做出违反玩家预期的"跳出"行为。
我在这里踩过的坑是忘了检查next状态下的重叠,只检查了边界。结果方块在紧贴已有方块旋转时,会直接"穿"进去一格,视觉上像是把别人吃掉了一块。后来把isValid写成同时检查"是否越界"和"是否与固定格重叠"才解决。
4.3 旋转中心与锚点的一致性
还有一个隐蔽问题:方块的位置(p.x, p.y)到底指的是什么?是包围盒左上角,还是某个特定格子?这个约定必须和预存坐标表的坐标系完全对齐,否则不同方块会以不同的方式偏移。
我的做法是统一约定(p.x, p.y)为包围盒左上角,预存坐标表里的所有偏移都相对于这个左上角。这样所有方块的位置语义完全一致,绘制时把格子坐标加上位置偏移就行了。
曾经有一版我在处理 I 方块时,因为它的包围盒是 4×4 而其他方块是 3×3,位置计算用了不同的基数,结果 I 方块每次生成都比别人往右偏一格。这种 bug 很烦人,因为它只在一种方块身上出现,测试的时候不小心就会漏掉。解决方式就是所有方块统一用 4×4 的包围盒,虽然 O 和 T 这类形状用不到全部空间,但换来的是位置计算的绝对一致。
5. 下落节奏与输入手感:固定时间步长
5.1 帧驱驱动为什么是个陷阱
最朴素的下落逻辑是"每 N 帧往下掉一格"。在写第一章的时候,这个方案看起来完全够用,因为你在自己机器上测的时候感觉很正常。问题在于,帧率不是恒定的。
一份代码在 60Hz 屏幕上跑,每 30 帧掉一格,玩家感受到的是每秒掉两格。换到 144Hz 屏幕上,同样的代码每秒掉将近五格。如果玩家的显卡性能波动,帧率在 60 和 90 之间浮动,方块下落的速度也会跟着抖,手感非常糟糕。更极端的情况是窗口失焦后恢复,某些系统会把挂起的帧数一次性补上,方块瞬间掉到底部。
所以时间必须由真实时钟驱动,而不是帧计数。
5.2 累加器模式:把逻辑更新和渲染解耦
标准的做法是固定时间步长配累加器。每帧先算出真实流逝的时间,累加到一个变量里,然后以固定的逻辑步长(比如 1/60 秒)去消耗这个累加值,直到不够一步为止。
const double FIXED_DT = 1.0 / 60.0; double accumulator = 0.0; double lastTime = glfwGetTime(); while (!glfwWindowShouldClose(window)) { double now = glfwGetTime(); double frameTime = now - lastTime; lastTime = now; if (frameTime > 0.25) frameTime = 0.25; // 防止挂起后补帧炸裂 accumulator += frameTime; while (accumulator >= FIXED_DT) { update(FIXED_DT); accumulator -= FIXED_DT; } render(); glfwSwapBuffers(window); glfwPollEvents(); }这段代码有两个关键点。第一,frameTime要设上限,否则系统休眠后恢复时,累加器会一下子值很大,while循环跑几千次,游戏状态瞬间推进几十秒。第二,逻辑更新和渲染是分开的,render每帧都执行,update可能执行零次或多次,这保证了不同帧率的机器上游戏速度一致。
我自己测试时用过一个粗暴的验证方法:把FIXED_DT改小,看方块下落速度是否真的变快,同时把窗口拖到副屏(不同刷新率)再拖回来,观察速度有没有跳变。这个方法能快速发现是不是有地方偷偷用了帧计数。
5.3 下落计时器和重力累积的分离
固定步长解决了逻辑更新的稳定性,但方块下落还需要一个独立的计时器,因为下落速度会随等级变化。
我的做法是在update里维护两个变量:一个表示当前等级下的每格下落间隔(秒),一个表示自上格落下以来累积的时间。每 tick 累加固定步长,超过间隔就让方块下移一格并清零。
void update(double dt) { dropTimer += dt; if (dropTimer >= dropInterval()) { dropTimer = 0.0; if (!tryMove(0, 1)) { // 下移失败,说明落地了 startLockDelay(); } } }dropInterval()按软降状态和等级返回不同的值。软降时给一个更小的间隔,比如基础间隔的十分之一;等级提升时基础间隔递减,但有下限,否则高等级会快到让人无法操作。
这里要注意:硬降不能用这个计时器。按下硬降键应该是立即把方块直接放到最低合法位置并锁定,不需要等待。我一开始把硬降也接进计时器逻辑,结果按下去之后方块要"滑"到底部,手感非常怪。
5.4 按键重复、软降与锁定延迟
键盘事件有两个层次需要处理。第一层是"按下/松开"的状态记录,第二层是"按住不放时的重复触发"。
用回调记录状态是最稳妥的:
bool keyLeft = false, keyRight = false; void keyCallback(GLFWwindow*, int key, int, int action, int) { if (key == GLFW_KEY_LEFT) keyLeft = (action != GLFW_RELEASE); if (key == GLFW_KEY_RIGHT) keyRight = (action != GLFW_RELEASE); // ... }然后在update里根据状态决定移动。但这里有一个体验问题:如果每 tick 都移动一格,按住方向键时方块会以每秒 60 格的速度横飞,玩家根本控制不住。
需要实现延迟自动重复,也就是按下后先等一个初始延迟(比如 0.15 秒),之后以固定频率(比如每秒 6 次)重复触发。这需要在按键状态之外再维护一个"按住时长"和"上次触发时间"。
if (keyLeft) { leftHoldTime += dt; if (leftHoldTime >= DAS_DELAY) { leftRepeatTimer += dt; if (leftRepeatTimer >= DAS_REPEAT) { leftRepeatTimer = 0.0; tryMove(-1, 0); } } } else { leftHoldTime = 0.0; leftRepeatTimer = DAS_DELAY; // 下次按下立即触发一次 }这段逻辑里有个小技巧:初始按下时应该立即触发一次移动,让玩家感觉按键有即时反馈。所以松开时把leftRepeatTimer重置成DAS_DELAY,这样下次按下会在第一 tick 就触发。
锁定延迟是另一个提升手感的关键机制。方块落地后不应该立刻固化,而是给玩家一小段时间(常见 0.4 到 0.5 秒)做最后的移动或旋转调整。这段时间内如果玩家操作了,计时器重置,可以继续调整;如果超时没操作,方块固化并生成新方块。这个机制对消行的爽快感影响极大——没有它,很多时候你明明看到可以插进一个缝里,手速跟不上就结束了。
6. 那些让你怀疑自己的排查记录
6.1 着色器编译成功但画面全黑
第一次跑起来的时候,窗口出来了,背景色也对,但一个方块都没有。我第一反应是顶点数据没上传,于是打印了顶点数量,发现是对的;又检查了绘制调用,也确实执行了。
真正的原因在 uniform 上。投影矩阵的 uniform 名字是uProjection,但我在设置矩阵之前忘了调用glUseProgram。OpenGL 的状态机设计意味着大部分 uniform 设置、属性绑定、绘制调用都作用于"当前激活的程序",而激活状态是全局的。没有激活程序的时候,glUniformMatrix4fv的调用要么静默失败,要么写入了一个空程序的槽位,你完全得不到任何报错。
这类问题的排查方法是:在所有 OpenGL 调用之后挂一个错误检查,至少在主循环里检查一次。写一个简单的宏,把当前错误码打出来,能省掉大量猜测时间。
#define GL_CHECK() do { \ GLenum e = glGetError(); \ if (e != GL_NO_ERROR) \ printf("[GL ERROR] 0x%x at %s:%d\n", e, __FILE__, __LINE__); \ } while(0)顺便说一句,初始化的顺序也很容易错。创建窗口、加载函数指针、创建顶点数组对象、创建缓冲、编译着色器、链接程序,这几步的依赖关系不能乱。尤其是上下文没有创建成功的时候调用任何图形函数,行为是未定义的,可能直接崩溃,也可能什么都不做。
6.2 画面闪烁与垂直同步
方块在移动时偶尔闪一下,看起来像是某一帧没画出来。这种问题的第一嫌疑人是双缓冲的交换时机,第二嫌疑人是清屏。
检查后发现是交换间隔没设,显卡以极快的速度刷帧,而我的逻辑更新和渲染在同一个循环里,导致某些帧的绘制内容被下一帧覆盖前就被显示了。加上垂直同步的调用之后闪烁消失。
这里有个取舍。开垂直同步的好处是画面稳定、无撕裂、CPU 占用低;坏处是把帧率锁在显示器刷新率上,如果我在 30Hz 的设备上跑,画面就会很卡。对俄罗斯方块这种节奏的游戏,垂直同步是必开的。
还有一点容易被忽略:清屏必须在每帧的最开始,而且清的是颜色缓冲。如果忘了清,你会看到上一帧的方块残影叠在当前帧上,画面越来越花。这个问题在最初几帧看不出来,跑几秒之后才会明显,所以很容易被晚发现。
6.3 窗口缩放后方块变形成长方形
这个问题我在第 2 节提过,但值得单独说一次排查过程。现象是拉宽窗口后,方块明显变宽,格子不再是正方形。
原因是窗口尺寸回调里只更新了视口,没有重新计算投影矩阵。视口决定了画面映射到窗口的哪个区域,投影矩阵决定了几何形状如何映射到视口。只改一个,相当于把同样的几何内容强行拉伸到不同宽高比的区域里。
正确做法是回调里同时更新视口和投影矩阵,并且投影矩阵要按比例计算,保持虚拟画布的长宽比不变。我在回调里加了一句日志,打印当前的窗口尺寸和计算出的可视区域尺寸,一眼就看出可视区域的宽高比和基准值对不上。
6.4 高刷新率屏幕下方块下落异常
这个坑比较隐蔽。现象是同一份代码,在笔记本上正常,接到一台高刷新率显示器上之后下落速度快了很多。
排查下来,问题出在我最开始把下落计时写在了渲染循环里,每渲染一帧就累加一次固定的1/60秒,而不是累加真实流逝的时间。在 60Hz 显示器上,帧率和假设值恰好相等,所以看起来正常;在更高刷新率的屏幕上前者比后者快,累加速度就快了。
还有一种变体是用了glfwGetTime但没做帧时间上限,导致窗口最小化后恢复时,累加器一次性吃掉了几秒钟的时间,方块瞬间掉到底部并锁定。两个问题都指向同一个原则:游戏逻辑的时间只认真实时钟,且必须做合理的钳制。
7. 从能玩到耐玩:把视觉效果做顺的几个点
7.1 用简单的形状变化制造层次感
方块全是纯色矩形也能做得好看,关键是层次要分清楚。我的配色策略是:背景网格用极暗的底色,格线用比底色亮一档的细线,已固定的方块用饱和度高但明度中等的实色,正在下落的方块用同样的色调但略微提亮,并额外描一圈更亮的边。
描边的做法很土但很有效:画两遍,先画一个比目标矩形大两像素的亮色矩形,再在上面画正常尺寸的目标色矩形。因为绘制顺序靠后覆盖先画的,视觉上就形成了一个边框。多画一个矩形完全不心疼,代价可以忽略。
阴影位置的提示也能加。在方块预计落点的位置画上半透明的淡色矩形,玩家能预判落点,操作精度明显提升。这个功能的实现只需要在生成活动方块时同步计算一次最低合法位置,跟硬降用的是同一套逻辑,复用成本极低。
7.2 下一个方块预览和消行动画
预览区放在棋盘右侧,画一个 4×4 的小格子区域,把下一个方块的四个格子按相对坐标缩放后画进去。缩放比例用一个常量控制,和棋盘格子的换算完全独立,互不影响。
消行动画是这个项目里最容易出效果的地方。最省事的做法是检测到消行后,把这一行标记为"消行中",然后用一个几十毫秒的计时器让这一行逐渐变亮、变白,结束时整行移除,上方的行整体下移。实现上只需要在渲染这一行时根据计时器插值颜色,不需要任何额外的绘制逻辑。
真正麻烦的是消除多行时的连锁处理。要先把所有满行找出来一次性删掉,而不是删一行之后再去找下一行,否则行索引会在删除过程中错位。我的做法是先把所有满行记成一个列表,然后从下往上依次删除,最后统一把上方的行下移对应的次数。这个顺序错了就会出现"消了两行但只下移了一行"的现象,恢复起来很隐蔽。
7.3 值得继续加的东西
状态机是最先该补的。当前版本缺一个明确的"暂停"和"游戏结束"状态,所有逻辑混在一个循环里。加上状态机之后,暂停时不推进下落计时、不接收移动输入,游戏结束时显示最终分数并等待重开,整个流程会清晰很多。
分数与等级系统也很容易接。每消一行加分,分数达到阈值后等级加一,等级影响下落间隔。这三者是链式依赖,接的时候注意别把等级提升写在下落计时器里面,否则会出现等级越高掉得越快、越快又跳级的正反馈。我见过一个实现就是这样,玩到十几级之后方块几乎瞬间落地。
音效和触感反馈的话,简单播放几个短音效就够了,注意音频播放不要阻塞主循环,用独立的线程或者异步接口。这个项目里所有功能都围绕一个核心:让玩家的每一次操作都有明确、即时、可预期的反馈。做到这一点,哪怕全是纯色方块,玩起来也很舒服。