资讯动态

用C语言和SDL2从零构建2D游戏框架:Caveman项目复盘

发布时间:2026/10/15 2:47:10 来源:尧图企业网站定制
想聊一个我最近整完的小项目名字就叫 Caveman。为什么叫这个一方面是穴居人这个意象特别适合复古像素风另一方面它算是给自己的一场返祖实验——不用任何现成游戏引擎回到最原始的方式用 C 语言和 SDL2 从一行代码开始搭出一个能跑、能玩、能看清每一帧画面的极简 2D 游戏框架。这篇文章不是教科书而是复盘。我会把 Caveman 从立项、技术选型、核心实现到编译运行、踩坑排错的全过程拆开讲明白。适合两类人看一是想搞懂游戏引擎底层原理的老开发二是想找个小而完整的 C 语言项目练手的新手。你不需要有游戏开发经验但最好能看懂一点点 C 代码。项目本身不大几千行而已但麻雀虽小五脏俱全窗口管理、游戏循环、碰撞检测、精灵动画、UI 状态机这些核心模块全都有。你把它啃下来再回头看那些商业引擎视角会完全不一样。1. 项目由来为什么叫 Caveman1.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、SDL2Caveman 的开发环境我前后用了 Linux 和 macOS 两个平台都只依赖 SDL2 一个库。安装方法如下Ubuntu/Debian 系sudo apt install libsdl2-devmacOS需要 Homebrewbrew install sdl2Windows 我建议直接用 vcpkgvcpkg 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 -fsanitizeaddress -g ...报错定位到动画帧索引计算的位置因为current_frame在某个边界条件下会等于frame_count直接访问了数组末尾之外的一个元素。修复非常简单把取模运算改为先判断、后归零。但这件事给我的教训是写 C 语言项目默认就要开启-fsanitizeaddress编译选项跑测试它能查到几乎所有内存类 bug比自己对着屏幕猜快太多了。很多花屏、随机崩溃的问题根子都在内存读写越界。5.3 碰撞判定穿模和丢帧第二个碰撞相关的坑是穿模。落石下落速度较快时AABB 每帧移动距离可能超过障碍物厚度导致上一帧还在左边、下一帧已经在右边碰撞检测完全错过了中间的状态。这和角色在高速移动中穿过薄墙是一个原理。我的处理办法也是最简单的一种把移动拆成多个小步。如果一帧内落石要移动 10 像素就把它拆成 10 次 1 像素的移动每次移动后都检查碰撞。这样虽然在极端情况下会有额外计算量但这个 demo 里落石数量不超过 20 个完全可以接受。如果你想追求更严谨的方案可以去研究连续碰撞检测CCD在移动前后两条线段之间做相交计算。但对于像素风小游戏步进法就是性价比最高的选择。5.4 常见问题速查表把我在 Caveman 开发过程中遇到的高频问题整理成一张表方便你对照排查现象可能原因处理办法窗口黑屏但程序在跑纹理未正确SDL_RenderCopy或渲染窗口尺寸为 0检查渲染器创建和纹理拷贝调用顺序画面发虚模糊HiDPI 未启用或逻辑分辨率与窗口比例不一致加SDL_WINDOW_ALLOW_HIGHDPI统一逻辑绘制尺寸游戏速度时快时慢游戏循环使用了可变时间步长更新逻辑改成固定时间步长 累积器随机闪退/花屏内存越界一般是数组或像素访问越界编译加-fsanitizeaddress定位越界点输入响应延迟事件处理和更新逻辑耦合严重SDL_PollEvent单独处理不在 update 里做事件分发精灵半透明边缘有白边像素格式的 alpha 通道初始化不正确创建纹理时明确SDL_PIXELFORMAT_ARGB8888清空时填0x00000000窗口拖动时 CPU 飙升每帧都全量刷新整个画布背景纹理单独绘制一份静态层动态物体单独合成这张表里的问题基本覆盖了一个 C/ SDL2 新手会遇到的绝大部分编译与运行异常。你可以直接存下来当参考碰到类似的毛病先查表。6. 扩展思路与个人体会Caveman 目前是个 demo但它的扩展方向非常明确。如果你愿意继续往下走可以尝试三条路线第一把软渲染升级成硬件加速。由于renderer.c的接口已经隔离好了你只需要把画矩形、画精灵这些函数内部换成 OpenGL 的三角形游戏逻辑一行不用动。这是学习引擎渲染管线最好的中间步骤。第二加入音频。做一个简单的 PCM 音频播放模块程序启动时播放一段 8-bit 背景音乐再为落石和跳跃加两个音效复古游戏的氛围立刻就有了。音频从零写也不难难点在于混音一次播放两个以上音源时需要做混音器。第三给游戏逻辑加更多玩法。原始人可以丢石头打鸟、可以挖洞躲避、可以收集食物换分数。引擎的骨架已经搭好往上添内容只是工作量的问题。最后说说我的个人体会。这个项目给我最大的收获不是代码量也不是什么高深技术而是建立了一种我能从零做出来的信心。以前用引擎遇到问题总想着搜索插件、查文档很少去想底层发生了什么。做完 Caveman 之后再看任何游戏引擎的渲染管线、场景管理、帧循环设计脑子里都有了一个对应的坐标那些抽象概念一下子就活了。如果你也想试试我的建议是别贪大画面糙一点没关系玩法简单一点也没关系先把窗口能开、画面能动、按键有反应这三件事跑通。只要跑通了第一版后面的路就是顺理成章的。Caveman 这条路真的值得走一遍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑