☰
用C语言和SDL2从零构建2D游戏框架:Caveman项目复盘
2026/10/15 2:47:10 网站建设 项目流程

想聊一个我最近整完的小项目,名字就叫 Caveman。为什么叫这个?一方面是"穴居人"这个意象特别适合复古像素风,另一方面,它算是给自己的一场"返祖实验"——不用任何现成游戏引擎,回到最原始的方式,用 C 语言和 SDL2 从一行代码开始,搭出一个能跑、能玩、能看清每一帧画面的极简 2D 游戏框架。

这篇文章不是教科书,而是复盘。我会把 Caveman 从立项、技术选型、核心实现,到编译运行、踩坑排错的全过程拆开讲明白。适合两类人看:一是想搞懂游戏引擎底层原理的老开发,二是想找个小而完整的 C 语言项目练手的新手。你不需要有游戏开发经验,但最好能看懂一点点 C 代码。项目本身不大,几千行而已,但麻雀虽小五脏俱全,窗口管理、游戏循环、碰撞检测、精灵动画、UI 状态机这些核心模块全都有。你把它啃下来,再回头看那些商业引擎,视角会完全不一样。

1. 项目由来:为什么叫 Caveman

1.1 一次"从零写游戏"的冲动

起因挺简单,有天一个刚入行的朋友问我:"Unity 里那个 GameObject 感觉好神奇,它到底是怎么出现在屏幕上的?"我发现自己居然一时答不上来。用引擎太多年了,习惯了拖拖拽拽,反而说不清最底层那些事:窗口是怎么创建的?画面是怎么刷新的?一个角色的位置信息是怎么变成屏幕上那一串像素的?

为了把这个问题彻底搞清楚,我决定自己写一个游戏,不碰 Unity、不碰 Godot、不碰任何引擎。当时定的调子就是:做一个尽量小的项目,最终能跑一个像样的小 gameplay 就行。于是 Caveman 就诞生了。整个项目从零开始,我自己管窗口、自己管输入、自己管渲染、自己管游戏状态,最后做出来一个"原始人躲避落石"的小 demo,屏幕分辨率 320x240,窗口放大四倍到 1280x960 显示,满屏幕的像素颗粒感,效果出奇地好。

这个项目的意义不在于游戏本身有多好玩,而在于它把"游戏是怎么跑起来的"这条链路完整打通了。等你自己亲手把这套东西写一遍,以后再看到引擎里的场景树、物理系统、渲染管线,你就知道那些都是在这条链路上做的封装,心里会有底得多。

1.2 极简主义是核心设计原则

做这个项目时我一直提醒自己:克制。不要一开始就想做复杂的系统。Caveman 的设计原则就三条:

  • 不引入场景树、组件系统这些引擎概念,能用结构体和函数解决的,绝不上抽象层。
  • 渲染走软件渲染,直接在内存里操作像素,不碰 OpenGL 这些 GPU API。
  • 每个模块的代码量都要小,小到可以一次性看完整个文件而不犯困。

有人会问:用 SDL2 算不算作弊?我觉得不算。SDL2 做的事情只是创建窗口、处理事件、把纹理提交给显卡显示,这些都是操作系统的常规能力,它并没有替你实现任何"游戏逻辑"。一个 2D 游戏的本质还是那一套循环:读输入、更新状态、渲染画面。这套东西必须你自己写,才是真的懂。

比较反直觉的是,恰恰因为不用引擎,项目反而更省时间。没有那些"系统该放哪"的纠结,不用学一堆抽象接口,你想画一个矩形,几十行代码就出来了。对一个想理解原理的人来说,这种"寒酸"是最佳路径。

2. 技术选型与实践方案

2.1 为什么选 C 语言 + SDL2(不用现成引擎)

语言层面,我用的是 C11。选 C 不是因为别的,是因为这个项目需要精确控制内存布局,需要直接操作像素数组,还需要保持编译产物足够小。C 语言没有隐藏的运行时开销,一个struct Player在内存里长什么样,你自己完全清楚。这对于调试软渲染来说太舒服了,开着调试器看内存,一清二楚。

图形库方面,其实可选的不多。我当时在三个方案里徘徊过:

方案跨平台性抽象层级上手难度
X11 / Win32 原生 API差,每平台一套最低高
SDL2好低低
raylib好中更低但不贴近底层

最终选 SDL2,理由很直接:它跨平台且只做最薄的一层封装。SDL_CreateWindow帮你创建窗口,SDL_PollEvent帮你读输入,SDL_CreateTexture给你一块可控像素的缓冲区。除此之外它什么都不管,不会帮你算物理、不会帮你管场景、不会给你内置精灵组件。它就像是你和操作系统之间的一层"翻译官",既不抢戏,也不碍事。

编译产物也让我惊喜。一个最小的窗口程序,用-Os优化参数编译出来才几十 KB,跑起来没有任何外部运行时依赖(除了系统自带的动态库)。放在十年前,这就是一个完整独立游戏的体积。今天大家做游戏动不动几百 MB,其中大部分都是引擎和美术资源的开销,Caveman 让我重新体会到了早期独立游戏那种轻巧。

2.2 软渲染 vs GPU 渲染:Caveman 走哪条路

渲染方案是 Caveman 最核心的决策。当时考虑了两条路:

  • GPU 渲染:用 OpenGL 把三角形顶点数据交给显卡,由 GPU 光栅化填充像素。
  • 软渲染:自己在 CPU 内存里计算每个像素的颜色,然后整块交给 SDL2 显示。

我选了后者。原因有两个:

第一,软渲染的每一条像素信息都是可控的。出现问题你可以加断点,可以打印某一帧的某个坐标值,可以一行行追踪。而 GPU 渲染一旦出问题,你只能对着一个黑屏猜原因,调试工具链复杂得多。

第二,这个项目的规模用软渲染完全扛得住。320x240 分辨率一共 76800 个像素,就算每个像素做复杂的 alpha 混合计算,现代 CPU 每帧也能轻松算完。只有到 1080p 全屏特效那个量级,软渲染才会明显吃力。做像素风小游戏,这个性能是绰绰有余的。

实际实现时,Caveman 的做法是:先用SDL_CreateTexture创建一个流式纹理,设置像素格式为SDL_PIXELFORMAT_ARGB8888,然后在游戏循环里用SDL_LockTexture锁定这块内存,直接往pixels数组里写颜色,写完SDL_UnlockTexture解锁,最后SDL_RenderCopy把纹理绘到窗口上。整个过程就像一个画板,你往画板上涂色,SDL2 帮你把画板端到屏幕上。

2.3 工程结构:从单文件到三文件

一开始,Caveman 所有代码都堆在一个main.c里,两千多行,写到后面我自己的脑子都转不动了。后来痛定思痛,重构了一个非常朴素的分层:

caveman/ ├── main.c # 入口、游戏循环、初始化/销毁 ├── game.c/h # 游戏逻辑:玩家、落石、得分、碰撞 ├── renderer.c/h # 软渲染封装:画点、画矩形、画精灵 ├── input.c/h # 输入封装:键盘状态、按跳检测 └── assets/ └── player.bmp

这个划分纯粹按职责来,不玩花活。renderer.c里没有游戏逻辑,game.c里不会直接调用 SDL 的绘图函数,所有像素写入都通过 renderer 的接口。这样做的最大好处是,之后我想把软渲染换成 OpenGL,只需要改renderer.c,游戏逻辑一行都不用动。

如果你自己写类似项目,我强烈建议哪怕只写几百行,也先分好文件。这不是教条,是真的省时间。我见过太多人"之后再重构"然后就再也没重构过,代码越写越乱,最后弃坑。起步分好层,后面会顺很多。

3. 核心实现细节逐个拆

3.1 游戏循环:固定时间步长与可变时间步长

游戏循环是整个项目的心脏。Caveman 的第一版循环非常简单,就是一个while循环里不断刷新画面:

while (running) { handle_input(); update(); render(); SDL_Delay(16); }

这个写法有几个隐患。最典型的:如果电脑性能不稳,某些帧耗时 5ms,某些帧耗时 30ms,直接导致游戏忽快忽慢。移动速度按每帧固定距离算的话,玩家就会感觉角色"瞬移"。这个问题的根源是把"帧"当成了时间的单位,而帧本身是不稳定的。

Caveman 后来改成了固定时间步长 + 累积器的模式。核心思路是:游戏世界的更新时间步长固定为 1/60 秒,每一帧先算出真正经过了多少时间,把这个时间一点点喂给游戏逻辑:

const double dt = 1.0 / 60.0; double accumulator = 0.0; Uint64 last = SDL_GetPerformanceCounter(); while (running) { Uint64 now = SDL_GetPerformanceCounter(); double frame_time = (double)(now - last) / SDL_GetPerformanceFrequency(); last = now; if (frame_time > 0.25) { frame_time = 0.25; // 防止窗口拖动时追帧爆炸 } accumulator += frame_time; while (accumulator >= dt) { update(dt); accumulator -= dt; } render(); }

解释一下这个代码的意图:假设你屏幕刷新率是 120Hz,那么每帧实际经过约 8.3ms,不到 16.7ms,所以accumulator会攒起来,攒满一个dt就执行一次update。这样不管屏幕是多少刷新率,游戏逻辑永远跑在 60Hz,人物的跳跃高度、落石的速度就不受显示器影响。

有朋友可能问:那accumulator里剩下的零头怎么办?在渲染时可以用插值让画面更平滑,但对 Caveman 这种像素风游戏,直接把零头丢掉完全看不出来。我加了frame_time > 0.25这个保护,是为了防止窗口被拖动时帧时间突增,导致一次循环里执行几百次update,游戏时间瞬间跳走一大截。

3.2 像素级碰撞检测

碰撞检测是游戏逻辑里最容易被低估的部分。Caveman 里主要用的是两种方式,先粗后细。

第一种是 AABB 矩形碰撞。两个物体都有自己的包围盒(位置 + 宽高),判断相交只需要四组比较:

int aabb(float ax, float ay, float aw, float ah, float bx, float by, float bw, float bh) { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; }

就这一句判断,涵盖了落石和玩家角色的主碰撞。但有个问题:像素风游戏的美术素材里往往有大量透明区域,如果角色是一块弧形岩石,直接用矩形碰撞,玩家明显感觉"明明没碰到也扣血了"。这种不精确在某些玩法里很致命。

这时候就要上第二种:像素级碰撞。思路是先用 AABB 粗筛,如果两个矩形都不相交,那肯定没撞,没必要做精细检测;只有矩形相交了,才进入第二个阶段,把两个精灵的像素数据取出来,逐个扫描重叠区域内的像素点,如果找到一个位置两边都是非透明像素,就判定真正碰撞了。

int pixel_collide(Sprite* a, int ax, int ay, Sprite* b, int bx, int by) { // 先 AABB 粗筛 if (!aabb(ax, ay, a->w, a->h, bx, by, b->w, b->h)) return 0; // 计算重叠区域(略,取决于具体坐标对齐方式) // 在重叠区域内逐像素检查 alpha 通道 for (int y = overlap_y; y < overlap_y + overlap_h; y++) { for (int x = overlap_x; x < overlap_x + overlap_w; x++) { if (pixel_a(x, y).a > 0 && pixel_b(x, y).a > 0) { return 1; } } } return 0; }

像素级碰撞的代价是性能,但在这个分辨率下完全不是问题。两个 32x32 精灵的重叠区域最多 1024 个像素,一次检查只需要几微秒。如果你直接拿大纹理做,性能会很难看,所以务必保留 AABB 粗筛这一步。我见过有人直接对整张 512x512 美术图逐像素遍历,每秒只能跑 30 帧,加了粗筛之后立刻满血。

3.3 精灵与动画:从 BMP 到内存像素数组

Caveman 的精灵加载用的是 SDL2 自带的SDL_LoadBMP,它是所有图像格式里最简单的一种,没有压缩、没有解码坑,非常适合学习场景。加载之后通过SDL_CreateTextureFromSurface转成纹理,最终在渲染时整块复制进像素缓冲区。

动画的本质更简单,就是"按时换图"。Caveman 里有一个定时器,每到 100ms 就把当前帧加一,超过帧总数就归零,形成循环播放:

void anim_update(Anim* anim, double dt) { anim->timer += dt; if (anim->timer >= anim->frame_duration) { anim->timer -= anim->frame_duration; anim->current_frame++; if (anim->current_frame >= anim->frame_count) { anim->current_frame = 0; } } }

不过在这里我要说一个我自己找的麻烦:在项目最初期,美术资源还没有时,我直接在代码里用数组定义了像素画。比如一个 8x8 的"原始人",在代码里长这样:

unsigned char player_sprite[8][8] = { {0,0,1,1,1,1,0,0}, {0,1,1,1,1,1,1,0}, // ... 一共 8 行 };

1代表不透明色,0代表透明。这种方式看起来原始,但好处是你可以完全掌控数据,不需要读文件、不需要解析图片格式,一个数组就能试验整条渲染链路。等确认流程没问题了,再换上 BMP 资源。我给的建议是:第一版直接硬编码像素数组,这样能快速绕过资源加载这个无关紧要的环节,把注意力集中在渲染和逻辑上。

3.4 状态机:菜单、游戏、暂停

Caveman 虽然小,但至少要有菜单界面、游戏画面、死亡界面这几个状态。如果不用状态机,代码会写成一坨散落的if分支:if (in_menu && ...)、if (dead && ...),维护起来特别痛苦。

我的方案是定义一个枚举类型和三个函数指针,按当前状态派发:

typedef enum { STATE_MENU, STATE_PLAYING, STATE_PAUSED, STATE_GAMEOVER } GameState; static GameState state = STATE_MENU; void game_update(double dt) { switch (state) { case STATE_MENU: update_menu(dt); break; case STATE_PLAYING: update_playing(dt); break; case STATE_PAUSED: update_paused(dt); break; case STATE_GAMEOVER: update_gameover(dt); break; } }

状态机的价值在于,每个状态的生命周期非常清晰。进入STATE_PLAYING时初始化玩家位置、重置分数;退出时清理子弹列表。你永远不会出现"菜单里的游戏还在继续跑"这种逻辑漏洞。后来我还给状态机加了简单的状态转移表,用一个二维数组定义"哪个状态在什么条件下切到哪个状态",不过对于 Caveman 这个体量,直接switch就够了,过度设计反而增加维护成本。

4. 实操记录:编译运行与效果调优

4.1 环境准备(gcc、SDL2)

Caveman 的开发环境我前后用了 Linux 和 macOS 两个平台,都只依赖 SDL2 一个库。安装方法如下:

Ubuntu/Debian 系:

sudo apt install libsdl2-dev

macOS(需要 Homebrew):

brew install sdl2

Windows 我建议直接用 vcpkg:

vcpkg install sdl2:x64-mingw-dynamic

装好之后,编译命令非常简单:

cc -O2 $(sdl2-config --cflags) main.c game.c renderer.c input.c $(sdl2-config --libs) -o caveman

$(sdl2-config --cflags)和$(sdl2-config --libs)会自动帮你填上头文件路径和链接库的路径。如果你是新手,我建议写一个三行Makefile,把源文件列表、编译参数和输出文件名管起来,不用每次都敲整条命令。

4.2 关键代码:一个最小可玩的 demo

如果你想把 Caveman 跑起来,整个逻辑可以从一个 80 行的窗口程序开始。核心代码长这样:

#include <SDL2/SDL.h> int main(int argc, char** argv) { SDL_Init(SDL_INIT_VIDEO); SDL_Window* win = SDL_CreateWindow( "caveman", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 1280, 960, 0); SDL_Renderer* ren = SDL_CreateRenderer(win, -1, 0); SDL_Texture* canvas = SDL_CreateTexture( ren, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, 320, 240); int running = 1; SDL_Event event; while (running) { while (SDL_PollEvent(&event)) { if (event.type == SDL_QUIT) running = 0; } // 锁定 canvas 像素内存 void* pixels; int pitch; SDL_LockTexture(canvas, NULL, &pixels, &pitch); // 逐像素填充为深灰色背景 Uint32* px = (Uint32*)pixels; for (int i = 0; i < 320 * 240; i++) { px[i] = 0xFF202020; // ARGB: 不透明 + 深灰 } SDL_UnlockTexture(canvas); SDL_RenderClear(ren); SDL_RenderCopy(ren, canvas, NULL, NULL); SDL_RenderPresent(ren); } SDL_DestroyTexture(canvas); SDL_DestroyRenderer(ren); SDL_DestroyWindow(win); SDL_Quit(); return 0; }

注意几个要点:SDL_CreateTexture里的SDL_TEXTUREACCESS_STREAMING表示这块纹理要频繁通过 CPU 写入像素,这是软渲染的前提。pitch是每行像素的字节数,可能不等于宽 * 4,因为 SDL 内部可能做了对齐,所以写像素时千万别直接用行号乘 320 算坐标,要拿pitch / 4当每行像素数。还有,不要把SDL_LockTexture和SDL_UnlockTexture放进循环里太多次,一次锁定、写完、解锁,效率最高。

4.3 帧数优化与性能观察

Caveman 的软渲染跑起来之后,我做了个简单的性能观察实验,就是测试不同逻辑分辨率下 CPU 占用率。结果是:

逻辑分辨率窗口放大倍数每帧像素数我观察到的 CPU 占用
160x1208192002%~4%
320x2404768006%~10%
640x480230720014%~22%

可以看到,像素数增长 4 倍,CPU 占用增长约 3 倍,基本符合预期。在 320x240 下面跑到 60fps 毫无压力,甚至可以在 update 里做大量碰撞计算。如果你发现帧率不稳,优先检查这几件事:

  • 渲染循环里有没有反复分配内存。malloc在游戏循环里非常致命,一帧几十次分配会导致堆碎片化,帧率显著下降。
  • 有没有做没必要的全屏清除。如果背景是静态的,可以在初始化时把背景直接画好,每帧只画动态物体。
  • 有没有频繁锁定解锁纹理。每次SDL_LockTexture都可能涉及内存同步,同一个纹理锁一次、写完、解锁,是最优解。

5. 实际踩过的坑与排错经验

5.1 SDL2 在 HiDPI 下的缩放问题

第一次在 MacBook 上跑 Caveman 时,画面整体模糊,像是蒙了一层纱。查了半天发现是 Retina 屏的物理像素和逻辑像素不一致:SDL2 默认窗口坐标用的是逻辑点,而实际像素数是逻辑点的两倍。如果没有启动 HiDPI 支持,SDL2 内部会先渲染到一张低分辨率缓冲区再拉伸,效果就是糊。

解决办法是创建窗口时加上SDL_WINDOW_ALLOW_HIGHDPI标志,并获取实际绘制区域的大小来设置渲染逻辑分辨率。如果你也遇到画面发虚,先检查窗口是否用了这个标志。这就是那种"不说永远想不通"的坑。

5.2 内存越界导致花屏

Caveman 开发到一半时,屏幕上时不时出现一条斜向的彩色噪点,非常诡异。当时我怀疑是纹理格式问题,折腾了一晚上。后来用 AddressSanitizer 编译才发现,是精灵动画切帧时数组越界了:

cc -fsanitize=address -g ...

报错定位到动画帧索引计算的位置,因为current_frame在某个边界条件下会等于frame_count,直接访问了数组末尾之外的一个元素。修复非常简单,把取模运算改为先判断、后归零。但这件事给我的教训是:写 C 语言项目,默认就要开启-fsanitize=address编译选项跑测试,它能查到几乎所有内存类 bug,比自己对着屏幕猜快太多了。很多花屏、随机崩溃的问题,根子都在内存读写越界。

5.3 碰撞判定"穿模"和丢帧

第二个碰撞相关的坑是"穿模"。落石下落速度较快时,AABB 每帧移动距离可能超过障碍物厚度,导致上一帧还在左边、下一帧已经在右边,碰撞检测完全错过了中间的状态。这和角色在高速移动中穿过薄墙是一个原理。

我的处理办法,也是最简单的一种:把移动拆成多个小步。如果一帧内落石要移动 10 像素,就把它拆成 10 次 1 像素的移动,每次移动后都检查碰撞。这样虽然在极端情况下会有额外计算量,但这个 demo 里落石数量不超过 20 个,完全可以接受。

如果你想追求更严谨的方案,可以去研究连续碰撞检测(CCD),在移动前后两条线段之间做相交计算。但对于像素风小游戏,步进法就是性价比最高的选择。

5.4 常见问题速查表

把我在 Caveman 开发过程中遇到的高频问题整理成一张表,方便你对照排查:

现象可能原因处理办法
窗口黑屏但程序在跑纹理未正确SDL_RenderCopy或渲染窗口尺寸为 0检查渲染器创建和纹理拷贝调用顺序
画面发虚模糊HiDPI 未启用或逻辑分辨率与窗口比例不一致加SDL_WINDOW_ALLOW_HIGHDPI,统一逻辑绘制尺寸
游戏速度时快时慢游戏循环使用了可变时间步长更新逻辑改成固定时间步长 + 累积器
随机闪退/花屏内存越界,一般是数组或像素访问越界编译加-fsanitize=address,定位越界点
输入响应延迟事件处理和更新逻辑耦合严重SDL_PollEvent单独处理,不在 update 里做事件分发
精灵半透明边缘有白边像素格式的 alpha 通道初始化不正确创建纹理时明确SDL_PIXELFORMAT_ARGB8888,清空时填0x00000000
窗口拖动时 CPU 飙升每帧都全量刷新整个画布背景纹理单独绘制一份静态层,动态物体单独合成

这张表里的问题,基本覆盖了一个 C/ SDL2 新手会遇到的绝大部分编译与运行异常。你可以直接存下来当参考,碰到类似的毛病先查表。

6. 扩展思路与个人体会

Caveman 目前是个 demo,但它的扩展方向非常明确。如果你愿意继续往下走,可以尝试三条路线:

第一,把软渲染升级成硬件加速。由于renderer.c的接口已经隔离好了,你只需要把画矩形、画精灵这些函数内部换成 OpenGL 的三角形,游戏逻辑一行不用动。这是学习引擎渲染管线最好的中间步骤。

第二,加入音频。做一个简单的 PCM 音频播放模块,程序启动时播放一段 8-bit 背景音乐,再为落石和跳跃加两个音效,复古游戏的氛围立刻就有了。音频从零写也不难,难点在于混音,一次播放两个以上音源时需要做混音器。

第三,给游戏逻辑加更多玩法。原始人可以丢石头打鸟、可以挖洞躲避、可以收集食物换分数。引擎的骨架已经搭好,往上添内容只是工作量的问题。

最后说说我的个人体会。这个项目给我最大的收获不是代码量,也不是什么高深技术,而是建立了一种"我能从零做出来"的信心。以前用引擎,遇到问题总想着搜索插件、查文档,很少去想底层发生了什么。做完 Caveman 之后,再看任何游戏引擎的渲染管线、场景管理、帧循环设计,脑子里都有了一个对应的坐标,那些抽象概念一下子就活了。

如果你也想试试,我的建议是别贪大,画面糙一点没关系,玩法简单一点也没关系,先把"窗口能开、画面能动、按键有反应"这三件事跑通。只要跑通了第一版,后面的路就是顺理成章的。Caveman 这条路,真的值得走一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询