凌晨把最后一个方块正确落到堆顶、消行时整行闪一下再塌下去,那种感觉比跑通一个三角形要实在得多。用OpenGL实现俄罗斯方块这个题目,看起来像入门练手,实际上是把渲染管线从"能画"逼到"画得准、画得稳、画得跟手"的一条捷径。它没有光照、没有骨骼动画、没有场景图,可它要求像素级对齐、要求每一帧的绘制顺序是确定的、要求按键响应稳定在几十毫秒内。任何一个环节含糊,屏幕上就会冒出那几种经典病症:方块边缘糊成一团、格子之间露出一条黑缝、按下方向键半秒后才动。这篇内容写给已经能画出三角形、想把管线真正用起来的人,也写给长期在引擎里拖控件、想回头看一眼底层到底发生了什么的人。下面我把窗口、投影、批处理渲染、旋转与踢墙、固定时间步这一整套完整摊开,顺带把参数怎么算、坑怎么填讲清楚。
1. 为什么选OpenGL做俄罗斯方块不是杀鸡用牛刀
1.1 这个组合真正在练的是"确定性"
俄罗斯方块的画面元素极其简单,简单到可以用一堆矩形糊出来,所以很多人第一反应是"用一个2D绘图库几行就完事了"。这个判断没错,但它把练习价值也一起扔掉了。这个项目真正值钱的地方,是它逼你在一个元素数量少、状态完全可控的环境里,把渲染管线里那些平时被引擎遮住的环节全部走一遍:顶点数据怎么组织、坐标系怎么映射、批次怎么合、采样方式怎么选、垂直同步开不开。
举个具体的例子。一个 10 宽 20 高的棋盘,加上预览区、分数区、边框,屏幕上同时存在的四边形大概两三百个。这个数量小到你怎么写都不会卡,但也小到任何一点瑕疵都藏不住——一个格子偏了半个像素,肉眼立刻能看出来。这就是它作为练手项目的价值:反馈极快,问题极清晰。你不需要调半天才看到一个模糊的性能曲线,改一行代码,下一帧就知道对不对。
我自己的体会是,做完这个项目之后再去看引擎里的 2D 渲染层,很多以前觉得"玄学"的默认设置突然就有了解释——为什么默认纹理过滤是某种模式、为什么精灵要按顺序提交、为什么坐标要加 0.5 的偏移。这些东西在文档里只有一句话,在这类小项目里你能亲手把它们复现一遍。
1.2 三条技术路线的取舍
动手之前先把这个选择想明白,后面能省很多返工。业界常见的做法大致三条,各自的取舍差别很大。
| 路线 | 上手成本 | 能学到的东西 | 适合什么阶段 |
|---|---|---|---|
| 直接调固定管线的 glBegin/glEnd | 极低 | 几乎为零,且现代驱动基本不支持 | 不建议,除非只是验证环境 |
| 现代核心管线 + 手写矩阵与着色器 | 中等 | 顶点布局、投影、批处理、采样全链路 | 想真正搞懂渲染的推荐路线 |
| 套一个2D封装库再画方块 | 低 | 主要是业务逻辑,渲染是黑盒 | 只想要个能玩的成品 |
我推荐中间那条。理由是它把"必须自己决定"的部分留给你了:投影矩阵要自己算,所以你知道棋盘为什么正好铺满窗口;顶点格式要自己定,所以你知道一次 draw call 里塞了多少信息;采样方式要自己选,所以你知道格子边缘为什么会糊。第三条路线做出来的东西当然也能跑,但你对它的控制力约等于零,出了诡异问题只能去翻别人的 issue。
还有一个隐蔽的取舍:要不要引入数学库。很多人第一反应是拉一个矩阵库进来。对这个项目我的建议是别拉,直接手写一个 4x4 矩阵乘法加一个正交投影函数,拢共不到四十行。原因是这个项目的矩阵运算只有一种——正交投影,而且一旦设置好就再也不变。为这一个矩阵引入一整个依赖,等你以后想换构建方式的时候会变成负担。
注意:固定管线那套写法在新版本的核心配置里已经不可用,如果你照着一份很老的教程写,编译能过但运行会直接报错或者黑屏,别怀疑是显卡问题,先看上下文配置。
2. 整体架构:把游戏拆成四层再动手
2.1 分层的边界划在哪里
我见过太多这类项目最后写成一个几千行的主文件,逻辑和绘制混在一起,加一个功能就要改五个地方。开写之前先把层分清楚,后面加东西会轻松很多。我的分法是四层,边界很硬:
平台层负责窗口、输入回调、计时。它只跟操作系统打交道,把"某个键按下去了""这一帧过了多少毫秒"这两件事翻译成内部事件,往上抛。
游戏逻辑层是纯数据。它维护棋盘数组、当前方块、下一个队列、分数等级,暴露的是"向左移动一格能不能成""转一下会不会撞墙"这类查询和"执行移动""锁定方块""消行"这类命令。这一层里绝不允许出现任何图形接口的调用。
渲染层只读不写。它拿到逻辑层的棋盘快照,把它翻译成一批四边形,提交给显卡。它不知道什么是"消行",只知道"这一格有颜色,那一格没有"。
状态机在最外面,管标题、暂停、游戏中、结束这几个状态之间的切换,以及每个状态下输入怎么分发。
这个划分最实在的好处是测试成本。逻辑层是纯数据,你可以写个几十行的测试程序,不创建窗口,直接把方块转一圈看坐标对不对、把棋盘填满看消行对不对。如果没有这层隔离,验证一个旋转逻辑要在窗口里点半天,效率差十倍不止。
2.2 状态机与输入分发
状态机这部分没什么花哨的,但有个细节值得单独说:输入事件的语义。同一个按键在不同状态下的含义完全不同,方向键在游戏里是移动,在暂停菜单里是选项切换。所以输入回调里不应该直接改游戏数据,它只负责往一个事件队列里塞东西,等到这一帧的更新阶段,由状态机决定怎么解释这些事件。
这个做法多了一层搬运,但解决了两个实际问题。一是输入与帧率解耦,按键可能在两帧之间被按下又抬起,如果直接在回调里改数据,一次会漏掉。二是可回放,事件队列记录下来就是一份完整的操作序列,做回放或者做自动化测试都很方便。我自己在调手感的时候把这个队列存到文件里,改完参数重放一遍,比手动操作去比对靠谱得多。
2.3 棋盘的数据布局
棋盘本身用一个一维数组存,宽度固定为 10,高度留出顶部缓冲给方块生成,实际上存 20 到 24 行。每个元素存一个整数,0 表示空,非 0 表示某种颜色索引。用一维数组而不是二维数组,原因是内存连续、缓存友好,而且索引计算就是一个乘法加一个加法,y * WIDTH + x,出错概率比嵌套数组低。
这里有个容易踩的坑:坐标原点放在左下角还是左上角。图形接口的惯例是左下角为原点,y 向上;而很多资料里的棋盘描述是左上角为原点,y 向下。你只要在两处混用,就会出现"方块从底部往上掉"或者"消行消掉了最上面那行"这种一眼看上去很诡异的 bug。我的做法是全程统一用左下角原点、y 向上,只在需要跟外部资料对照的时候临时转换。
提示:把坐标约定写在代码文件开头的一行注释里,每次写新函数前扫一眼。这个习惯能省掉大量符号错误。
3. 渲染管线里那几个必须想清楚的点
3.1 正交投影矩阵手算与单元尺寸
这个项目不需要透视,用正交投影把一块世界坐标区域映射到屏幕上。假设棋盘宽 10 格、高 20 格,我想让棋盘周围留一点空间放边框和预览区,那就把可视区域定为宽 16、高 22,棋盘居中。
正交投影矩阵的形式是固定的,动手算一遍就记住了:
m[0][0] = 2 / (right - left) m[1][1] = 2 / (top - bottom) m[2][2] = -2 / (far - near) m[3][0] = -(right + left) / (right - left) m[3][1] = -(top + bottom) / (top - bottom) m[3][2] = -(far + near) / (far - near) m[3][3] = 1丢进代码里大概是这样一个函数,用列主序存储,直接喂给着色器:
void makeOrtho(float* m, float l, float r, float b, float t, float n, float f) { for (int i = 0; i < 16; ++i) m[i] = 0.0f; m[0] = 2.0f / (r - l); m[5] = 2.0f / (t - b); m[10] = -2.0f / (f - n); m[12] = -(r + l) / (r - l); m[13] = -(t + b) / (t - b); m[14] = -(f + n) / (f - n); m[15] = 1.0f; }这里的关键决策是世界坐标的单位取什么。我取的是"一格 = 1.0"。这样棋盘左下角就是 (3, 1),右上角就是 (13, 21),方块位置直接就是格坐标,不用做任何换算。如果反过来取像素为单位,那么窗口一拉伸,整个棋盘的位置就全错了,你要么重算所有坐标,要么在着色器里再乘一次缩放,纯属给自己找事。
窗口纵横比的处理是另一个必须提前想的问题。直接按窗口像素做视口,窗口一拉宽棋盘就变形,方块从正方形变成扁的。两种解法:一是把视口限制成固定纵横比,多出来的部分留黑边;二是根据窗口大小反算可视区域,保证 y 方向永远是 22 个单位,x 方向按比例扩展。我倾向第二种,窗口拉宽的时候左右会多露出一些背景,视觉上更舒服,而且棋盘永远不变形。
3.2 顶点数据组织与一次提交
最省事的画法是每个方块单独一次绘制调用。两三百个方块就是两三百次调用,虽然这个量级不会卡,但属于明显的浪费,而且每帧都要重新上传数据,驱动那边会很难受。
我用的方案是实例化渲染。整个场景只准备四个顶点的单位四边形(两个三角形拼的三角形带或者六个顶点),每个方块作为一条"实例数据"传进去,包含它的位置偏移和颜色。这样一个 draw call 就能画完整个棋盘。
顶点属性布局这么设计:
| 位置 | 属性 | 类型 | 说明 |
|---|---|---|---|
| 0 | aPos | vec2 | 单位四边形角点,取值 0 或 1 |
| 1 | aUV | vec2 | 纹理图集里的采样坐标 |
| 2 | iOffset | vec2 | 实例属性,方块所在的格坐标 |
| 3 | iColor | vec4 | 实例属性,着色用 |
前两个属性属于顶点缓冲,后两个属于实例缓冲,更新频率完全不同。这一点很重要:顶点缓冲只在初始化时传一次,实例缓冲每帧更新一次。如果把它们塞进同一个缓冲,你就得每帧把不变的顶点数据也重传一遍。
实例缓冲的更新用glBufferSubData配一个预分配的缓冲,容量按最大方块数算好,比如 512 个实例。每帧只需要把实际用到的部分填进去,然后按实际数量提交。这种"一次分配、每帧覆盖"的模式比每帧重新创建缓冲要稳得多,也避免了驱动反复分配内存。
顶点着色器大致是这样:
#version 330 core layout(location = 0) in vec2 aPos; layout(location = 1) in vec2 aUV; layout(location = 2) in vec2 iOffset; layout(location = 3) in vec4 iColor; uniform mat4 uProj; uniform vec2 uCellSize; out vec2 vUV; out vec4 vColor; void main() { vec2 world = aPos * uCellSize + iOffset * uCellSize; gl_Position = uProj * vec4(world, 0.0, 1.0); vUV = aUV; vColor = iColor; }uCellSize固定为 (1,1),写出来是为了以后想加方块间隙或者做缩放动画时不用改结构。iColor跟纹理颜色相乘,这样一张灰度纹理能通过传不同颜色复用成七种方块,图集能省一半空间。
3.3 纹理图集与采样方式的选择
方块表面要不要贴图,取决于你想要的风格。纯色块最简单,直接传颜色不采样就行。但只要你想加一点细节——比如方块表面有内凹的斜面、有高光边、有细微的噪点——就需要一张图集。
图集的做法是把七种方块的图案排在一张图里,比如 4 列 2 行共 8 格,每格 32x32 像素。每个方块的 UV 就是那一格的矩形范围。这里有一个必然会踩到的坑:如果格子之间紧挨着,采样的时候双线性过滤会把相邻格子的颜色混进来,方块边缘出现一条奇怪的彩边或者灰边。
解决办法有两个,都很实用:
- 用最近邻采样(
GL_NEAREST)。这在你追求像素风的时候正好,方块边缘锐利,也不会有渗色。缺点是非整数缩放的时候会有明显的锯齿抖动。 - 在图集里给每格留 1 到 2 像素的边距,UV 只取内部区域。这样双线性过滤的采样点永远落在同一格内部,渗色消失,同时保留平滑边缘。
我一般选第二种,因为窗口尺寸经常不是 32 的整数倍,最近邻在缩放时那种"边缘跳动"看着很难受。留边距的成本也就是图集大一点,无所谓的。
还有个细节:UV 的 y 方向。图像文件的存储习惯是左上角起,而纹理采样的习惯是左下角起,两边一撞,画面就上下颠倒了。要么在加载时翻转图像的像素行,要么在 UV 计算时把 y 反过来。我选在加载时翻转,因为这样着色器里写的就是直觉坐标,以后查代码不容易犯迷糊。
3.4 像素对齐与那条烦人的黑缝
如果方块之间出现了细如发丝的黑线,说明你的方块边缘没有落在像素中心或者整数边界上。这个问题在浮点计算里几乎无法完全避免:世界坐标 1.0 经过投影矩阵和视口变换之后,落在屏幕上的位置是一个浮点数,相邻两个方块共享的那条边,一个算出来是 31.9998,另一个是 32.0003,光栅化的时候中间就可能漏掉一列像素。
有三种压制的办法,按效果从高到低排:
一是让方块的四边形稍微放大一点点,比如每边各扩 0.001 个单位。这点重叠在视觉上看不出来,但能彻底堵住缝隙。代价是层叠顺序会变得重要,需要保证绘制顺序稳定。
二是在片元着色器里做边缘淡出,把纹理边缘的 alpha 稍微提一点。这个需要纹理本身带 alpha 通道,稍麻烦。
三是确保视口尺寸与世界尺寸成整数倍关系,让每个格子在屏幕上正好占整数个像素。这个最干净,但要求窗口尺寸可控,全屏或者自由拉伸的时候就做不到。
我实际用的是第一种加第三种混合:默认窗口开一个格数乘 32 的大小,同时四边形留一点微小的重叠。这样正常使用时完全看不到缝,窗口随便拉也只在极端比例下有一点点。
提示:这类缝隙问题不要指望"调一调就好",它跟硬件的光栅化规则、驱动的浮点精度都有关系。用重叠的方式从几何上解决,比在采样上打补丁稳得多。
4. 方块逻辑:旋转、踢墙和随机序列
4.1 数据表示:坐标表还是旋转矩阵
七种方块,每种四个格子。描述它们的位置有两条路:一是给每种方块一个 4x4 的二进制矩阵,旋转就是把矩阵转置再翻转;二是直接存四个格子的相对坐标,旋转时对每个坐标做一次变换。
我选第一种,理由是踢墙检测和碰撞检测都天然按矩阵展开,边界情况少。以 T 型为例,0 度状态在 4x4 矩阵里大概是这样(1 表示填充):
0 1 0 0 1 1 1 0 0 0 0 0 0 0 0 0旋转 90 度就是把这个矩阵绕中心转,标准做法是先转置再水平翻转。四行四列的矩阵操作起来很快,也不容易出错。注意四个旋转状态必须全部预计算好存起来,不要在运行时逐帧旋转,虽然性能上无所谓,但预计算能让"旋转到某个状态之后坐标该是什么"变成一个确定性的查表,调试起来清晰得多。
有一个经典陷阱:矩阵旋转的中心在哪。4x4 矩阵的几何中心在 (2,2) 这个角点上,不是某个格子的中心。如果旋转中心选错,方块会绕着奇怪的位置打转,转四次回不到原位。验证方法很简单:写个测试循环,把每种方块连转四次,看坐标是不是回到初始状态。回不去就是中心选错了。
4.2 墙踢表:为什么必须抄标准
方块贴着墙或者贴着堆顶旋转时,如果只做纯旋转,就会被判定为碰撞而失败,玩家会觉得"贴着墙转不了,手感很怪"。主流解法是踢墙:旋转失败时,按一张预设的位移表依次尝试几个候选位置,第一个不碰撞的就采用。
这套表在业界有相对统一的标准,分两组,一组给 I 型,一组给其余六种。以其余六种为例,从初始状态转到右侧状态的五个候选位移是:
| 候选顺序 | x 偏移 | y 偏移 |
|---|---|---|
| 1 | 0 | 0 |
| 2 | -1 | 0 |
| 3 | -1 | +1 |
| 4 | 0 | -2 |
| 5 | -1 | -2 |
这里有个特别需要注意的符号问题。上面这张表采用的是y 向上为正的约定,跟本文前面定的坐标系一致。但网上流传的很多资料是按屏幕坐标写的,y 向下为正,数值符号是反的。你要是照着那份表抄进一个 y 向上的系统里,症状是"方块贴着地面旋转时会莫名其妙往上跳两格",看着非常魔幻。
八种旋转方向转移各有五个候选,加上 I 型单独一套,总共是八十个偏移量。手抄容易错,我的建议是抄完之后立刻用肉眼可验证的场景测试:贴着左墙转、贴着右墙转、贴着地面转、贴着已经堆好的方块的侧面转。四个场景全过,这张表基本就是对的。
注意:踢墙失败之后必须完全不改变方块状态。有些人写完发现旋转失败时方块还是动了一格,通常是循环里改了临时坐标却没在失败分支里回滚,这种 bug 靠肉眼极难发现,一定要写断言校验。
4.3 随机序列:为什么不能用纯随机
最朴素的实现是每次从七种里随机抽一个,用取模或者随机数生成器。这个做法的问题在于连续出现同一形状的概率太高。七个里随机抽,连续来五个 S 型是完全可能发生的,玩家的体验就是"这游戏在搞我"。
实际产品里通用的做法是7-bag:把七种方块装进一个袋子,洗牌,然后依次取出,取完了再装一袋重新洗。这样保证每七个方块里每种恰好出现一次,最长的不出现间隔也被限制在十二个以内。实现起来就一个数组加一个索引,洗牌用标准的随机交换。
更进一步还有"首袋固定"和"避免连续同型"的补丁,不过那就属于口味问题了。我的建议是先上 7-bag,玩几十局感受一下,如果觉得还是不顺,再考虑加约束。但要注意,任何额外约束都会让序列变得可预测,高手是能从预览队列里读出后面几个是什么的,约束太强反而降低深度。
预览队列的长度一般给 3 到 5 个。给 5 个的好处是玩家可以提前规划,坏处是界面占地方。我一般给 3 个,视觉上清爽,规划空间也够。
5. 手感:比渲染更容易被忽略的那一半
5.1 固定时间步与帧率无关
游戏循环用固定步长,这是必须的。如果每帧直接把"距离上一帧过了多少秒"乘进重力里,那么帧率一波动,下落速度就跟着变,在 144Hz 显示器上和在 60Hz 上玩起来像两个游戏。
标准写法是累加器模式:
const double STEP = 1.0 / 60.0; double accumulator = 0.0; while (running) { double frameTime = now() - lastFrame; lastFrame = now(); if (frameTime > 0.25) frameTime = 0.25; // 防止卡顿后疯狂追帧 accumulator += frameTime; while (accumulator >= STEP) { update(STEP); accumulator -= STEP; } render(accumulator / STEP); // 插值因子,暂时可以不用 }这里有两个细节。第一是那行上限截断,如果窗口被拖动或者系统卡了一下,累积的时间可能变成好几秒,没有截断的话内层循环会一次跑几百次,游戏瞬间快进,方块直接穿到底。第二是渲染帧率和逻辑帧率分离,逻辑固定 60Hz,渲染跑显示器刷新率,看起来更顺滑。
重力速度用一张按等级查的表,比用公式算更可控。常见的节奏是第一级每秒下落一格,之后逐级加快,到十几级之后基本是瞬落。这张表你可以自己调,但要保证每级之间的差异是递减的,也就是低级差距大、高级差距小,这样升级的感知才明显。
5.2 锁定延迟与重置次数
方块落地之后立刻锁死,玩起来会非常难受——你想做最后一次微调,手刚按下去方块已经定住了。所以所有成熟实现都有锁定延迟:方块触底后开始计时,比如 500 毫秒,时间内还可以左右移动或者旋转;一旦移动成功,计时器重置。
但重置不能无限次,否则玩家可以在底部无限磨蹭。所以要加一个重置次数上限,常见是 15 次,用完之后计时器不再重置,必须锁定。
这两个参数直接决定手感。延迟太短,微调窗口不够;延迟太长,节奏拖沓。500 毫秒配 15 次重置是我试下来比较舒服的组合,想要更竞技一点可以缩到 300 毫秒。还有一个变体是"移动重置、旋转不重置",这个更严格,适合追求精确的玩法。
提示:锁定延迟的计时器一定要用逻辑帧的时间累加,不要用系统时间。用系统时间的话,你在暂停菜单里待着,回来的时候方块已经锁死了。
5.3 左右移动的重复速率
按住方向键连续移动,这块参数在格斗游戏里叫 DAS 和 ARR,俄罗斯方块里同样适用。
DAS(首次延迟)是按住之后多久开始连续移动,常见值 130 到 170 毫秒。太短,轻点一下就会多移一格;太长,想快速横移时感觉迟钝。
ARR(重复间隔)是连续移动时每格之间的间隔,常见值 30 到 50 毫秒。这个是手感差异最大的参数,调到 0 就是"一按到底瞬间贴墙",很多人喜欢这个爽感,但也有人觉得失去了控制感。
实现上要区分"按下瞬间"和"按住"两个阶段:按下那一下必须立刻移动一格,不能等 DAS 计时结束,否则轻点会失灵。这个细节如果漏了,症状是"快速点按方向键,方块不动或者只偶尔动一下",非常影响体感。
6. 从空窗口到能玩:实操路径与关键代码
6.1 环境准备与最小可运行骨架
依赖只需要三样:一个创建窗口和处理输入的库、一个加载图形接口函数指针的加载器、一个可选的数学和图像加载辅助。我用的是最常见的组合,这些在各大平台都能装到,不涉及任何特殊配置。
流程按这个顺序走,每一步都能单独验证,出问题好定位:
- 初始化窗口库,创建窗口和上下文,配置成核心模式,版本选 3.3。
- 初始化函数加载器,加载需要的函数指针。这一步失败会直接崩,所以加载完要检查。
- 开启垂直同步。这个对画面稳定性影响很大,不开的话容易出现画面撕裂,方块高速下落时尤其明显。
- 设置视口和混合模式。方块用纹理的话需要开
GL_BLEND,混合函数用标准的源 alpha 加一减源 alpha。 - 编译着色器,链接程序,检查编译日志。日志一定要打印出来,不要只判断返回值。
- 创建顶点缓冲、实例缓冲、顶点数组对象,配置属性指针。
- 加载纹理,设置过滤和环绕方式。
- 进入主循环。
上下文版本这一步经常出问题。如果你要求 3.3 核心模式,就必须保证所有调用的函数都是核心版本里存在的。用固定管线时代的函数在核心模式下会静默失败,表现出来的就是黑屏,什么错误都没有,非常难受。
6.2 一帧的渲染顺序
渲染顺序直接影响层叠效果。我的顺序是这样的:
- 清屏,用背景色填充。
- 计算投影矩阵,如果是自适应窗口的话每帧重算。
- 把背景板、边框、网格线塞进实例缓冲。
- 把棋盘里已有的方块塞进去。
- 把当前活动方块塞进去。
- 如果有幽灵方块(显示落点位置的虚影),用半透明颜色塞进去。
- 一次性提交绘制。
- 交换缓冲。
把幽灵方块放在活动方块之前提交,靠混合模式让它看起来在底下,比调整深度测试简单得多。这个项目完全不需要深度测试,关掉它,所有东西按提交顺序覆盖,逻辑最直白。
界面上的文字(分数、等级)是最麻烦的部分。最省事的做法是提前把 0 到 9 和几个字母做成图集,每个字符一个四边形,按位拆数字逐个画。不要在这个阶段引入字体渲染库,那是个独立的大坑,会把一个两天能做完的项目拖成两周。
6.3 消行动画与状态切换
消行最简单的方式是瞬间删除,但瞬间删除在视觉上很突兀,玩家可能没看清消了哪几行。加一个短动画:命中的行闪白大约 100 毫秒,然后上方整体下移。
实现上不要把动画塞进逻辑层。逻辑层该做的是立刻把行删掉、把分数加上去,同时发一个"这几行被消掉了"的事件给渲染层。渲染层拿到事件之后自己播一个短动画,播完就恢复正常渲染。这样逻辑和表现彻底分离,你以后想换成"整行炸成碎片"也不用改一个字的逻辑代码。
分数计算要按一次消行数给不同权重,一次消四行的收益要显著高于两次消两行之和,否则玩家没有动机去攒。具体倍率可以自己定,但记得加上连续消行的连击加成,这个机制能显著提升爽感。
7. 常见问题排查实录
7.1 黑屏的排查顺序
黑屏是最常见的症状,也是最难猜的,因为它可能是十几个原因里的任何一个。我总结了一个从便宜到贵的检查顺序,基本能在几分钟内定位:
先看着色器编译日志。这一步最容易被跳过,很多人只看链接是否成功。实际上编译警告里经常藏着你没注意到的类型不匹配。
再看属性位置。如果你在着色器里用了布局限定符,代码里又用查询接口拿位置,两边可能对不上。统一用限定符写死位置,两边都用常量,能省掉一整类问题。
然后看投影矩阵的方向。y 轴搞反了的话,画面会整个跑到可见范围外面去,表现跟黑屏一模一样。临时把投影换成单位矩阵画一个全屏三角形,能快速判断问题出在矩阵还是别处。
最后看视口和裁剪。窗口大小变了但视口没更新,是个老生常谈的问题。还有面剔除,如果你开了背面剔除但顶点顺序是顺时针,整个画面就没了。
7.2 症状对照速查表
| 症状 | 最可能的原因 | 处理方式 |
|---|---|---|
| 方块被拉成扁的 | 视口纵横比没跟着窗口更新 | 每帧按窗口尺寸重设视口,或反算投影区域 |
| 格子边缘有细黑缝 | 浮点误差导致相邻四边形没接上 | 四边形每边扩 0.001 个单位重叠 |
| 纹理上下颠倒 | 图像存储方向与采样方向相反 | 加载时翻转像素行,或在 UV 里反 y |
| 图集边缘出现彩边 | 双线性过滤跨格采样 | 图集留 1 到 2 像素边距,UV 取内部 |
| 方块落地后立刻锁死 | 缺少锁定延迟 | 加 300 到 500 毫秒计时,移动时重置 |
| 轻点方向键没反应 | DAS 计时器吃掉了首次移动 | 按下瞬间立刻移动一格,不走计时 |
| 帧率越高下落越快 | 逻辑步长用了帧时间 | 改成固定步长加累加器 |
| 拖动窗口后画面错位 | 没有监听窗口尺寸变化 | 注册尺寸回调,更新视口和投影 |
| 高速下落时画面撕裂 | 垂直同步没开 | 开启垂直同步,或在交换前加同步 |
| 旋转时方块轻微位移 | 旋转中心不在格点中心 | 检查 4x4 矩阵的旋转中心 |
7.3 几个不容易想到的坑
高分辨率屏幕的缩放。在缩放比例为 1.5 或者 2 的屏幕上,你拿到的窗口尺寸可能是逻辑尺寸,但实际的帧缓冲尺寸是它的 1.5 倍或 2 倍。如果只按窗口尺寸设视口,画面会只占左下角的一小块。必须查询帧缓冲的实际尺寸来设视口。这个坑在没有缩放屏的机器上完全测不出来,换台机器就傻眼。
实例缓冲的容量。一开始可以按棋盘格数乘二来估,但幽灵方块、边框、网格线都算实例,很容易超。超了之后的行为是画到一半就没了,或者直接报错。稳妥的做法是提交前统计实际数量,超过容量的部分直接跳过并打一条日志,而不是让程序崩掉。
窗口最小化时的循环。窗口最小化之后,某些环境里渲染调用会阻塞或者返回错误,帧时间会变得极大。前面提到的时间截断就是防这个的,同时可以考虑在窗口不可见时跳过渲染只跑逻辑,能省不少电。
输入焦点丢失。切出去的时候按键抬起事件可能收不到,回来之后游戏以为你还按着左键,方块一路往左飘。解决办法是在焦点丢失的回调里清空所有按键状态。
提示:这些坑的共同点是"在开发机上不容易复现"。养成在两种刷新率、两种缩放比例的机器上各跑一遍的习惯,能提前抓出大半。
8. 一些让我少走弯路的做法
做完几轮之后回过头看,有几个当初觉得"多余"、后来发现非常值的决定。
一个是把逻辑层做成可以脱离窗口运行。我写了一个简单的命令行程序,加载一份操作序列,然后打印每一步之后的棋盘状态。调旋转、调踢墙、调消行的时候全靠它,比在窗口里肉眼比对快太多了。而且它不依赖任何图形环境,在无图形界面的机器上也能跑。
另一个是参数全部外置成配置文件。锁定延迟、DAS、ARR、重力表、预览数量这些全部从文件读,改完不用重新编译。调手感的时候这一点尤其重要,因为你经常需要反复微调同一个数值,编译一次十几秒,调十次就是几分钟,全浪费了。
还有一个是别在第一步就追求视觉效果。我一开始就想做带高光、带投影、带粒子效果的方块,结果卡在渲染管线上一周没进展。后来退回去先用纯色四边形把整个游戏跑通,从上到下能玩、能消行、能计分,然后再一层层往上加视觉。这个顺序上的差别是:先跑通的话,你每次加效果都是在稳定的基础上增量改,出问题一定是新加的那部分;反过来先做效果,出问题的时候你根本不知道是效果写错了还是底层就没对。
真要往深了做,这个项目能延展的方向其实不少——把渲染层改成基于命令缓冲的批处理架构、给每个方块加独立的动画、把逻辑层改成可以录制和回放、甚至接一个简单的机器学习模型让它自己玩。但每条路的前提都是先把那两百个四边形稳稳当当地画在正确的位置上。