引言
用 Zig 写游戏的最大理由是迭代速度:增量编译通常在百毫秒级,热重载几乎无感;没有 GC 停顿,帧时间稳定;zig build -Dtarget=... 一条命令就能交叉编译到 Windows、Linux、macOS 甚至 WebAssembly。
本文以 raylib 作为渲染与输入层,重点讲两件事:一是游戏循环的正确写法(固定时间步 + 插值),二是ECS(Entity-Component-System)架构的 Zig 实现。ECS 是数据导向设计的核心,能让缓存命中率与并行度大幅提升。
前置:/zig-language-basics/(结构体、切片、错误处理)、/zig-memory-management/(分配器与生命周期)。
目录
- 1. Zig 游戏开发概览与选型
- 2. raylib 绑定与项目骨架
- 3. 游戏循环与固定时间步
- 4. ECS 架构设计
- 5. 组件存储与查询实现
- 6. 实体生命周期与内存管理
- 7. 输入、碰撞与物理
- 8. 资源加载与音频
- 9. 打包发布与性能优化
- 速查表
- 一句话记忆
- 相关阅读
- 延伸阅读
1. Zig 游戏开发概览与选型
1.1 引擎还是自研
| 方案 | 代表 | 特点 |
|---|---|---|
| 完整引擎 | Godot(GDExtension 支持 Zig) | 编辑器强大,Zig 写逻辑 |
| 轻量框架 | raylib、SDL2、mach | 完全掌控,代码即游戏 |
| 纯自研 | zig-gamedev 生态 | 最大控制权,成本最高 |
独立开发者常见路线是 raylib + 自研 ECS:raylib 提供绘制、输入、音频、字体,ECS 提供架构。
1.2 raylib 的优势
raylib 是纯 C 的单库,约 300 个函数,覆盖 2D/3D 绘制、输入、音频、模型加载。它没有场景树、没有编辑器,因此和 Zig 的「显式优于隐式」哲学很契合。绑定方式依然是 @cImport。
1.3 目标平台矩阵
zig build -Dtarget=x86_64-windows-gnu
zig build -Dtarget=x86_64-linux-gnu
zig build -Dtarget=aarch64-macos
zig build -Dtarget=wasm32-emscripten
交叉编译能力意味着你可以在一台机器上出全部平台产物。
2. raylib 绑定与项目骨架
2.1 导入 raylib
const std = @import("std");
const rl = @cImport({
@cInclude("raylib.h");
});
2.2 build.zig 配置
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "mygame",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe.addIncludePath(b.path("vendor/raylib/src"));
exe.addCSourceFile(.{
.file = b.path("vendor/raylib/src/rcore.c"),
.flags = &.{"-std=c99"},
});
exe.addCSourceFile(.{ .file = b.path("vendor/raylib/src/rshapes.c"), .flags = &.{} });
exe.addCSourceFile(.{ .file = b.path("vendor/raylib/src/rtextures.c"), .flags = &.{} });
exe.addCSourceFile(.{ .file = b.path("vendor/raylib/src/rtext.c"), .flags = &.{} });
exe.linkLibC();
b.installArtifact(exe);
}
2.3 最小可运行骨架
pub fn main() void {
rl.InitWindow(960, 540, "Zig Game");
defer rl.CloseWindow();
rl.SetTargetFPS(60);
while (!rl.WindowShouldClose()) {
rl.BeginDrawing();
rl.ClearBackground(rl.BLACK);
rl.DrawText("Hello Zig", 20, 20, 24, rl.RAYWHITE);
rl.EndDrawing();
}
}
踩坑:raylib 的
InitWindow内部有静态状态,同一进程只能初始化一次;测试时要避免重复调用。
3. 游戏循环与固定时间步
3.1 三种时间模型
| 模型 | 优点 | 缺点 |
|---|---|---|
| 可变步长 | 简单 | 物理不稳定,行为依赖帧率 |
| 固定步长 | 确定性、可回放 | 需要插值消除抖动 |
| 固定步长 + 插值 | 平滑且稳定 | 实现略复杂 |
游戏逻辑几乎都应选第三种。
3.2 累加器实现
const FIXED_DT: f32 = 1.0 / 60.0;
const MAX_STEPS: u32 = 5; // 防止死亡螺旋
pub fn run(game: *Game) void {
var accumulator: f32 = 0;
var previous = rl.GetTime();
var current: f64 = 0;
while (!rl.WindowShouldClose()) {
current = rl.GetTime();
var frame_time: f32 = @floatCast(current - previous);
previous = current;
// 卡顿时钳制,避免一次补太多步
if (frame_time > 0.25) frame_time = 0.25;
accumulator += frame_time;
var steps: u32 = 0;
while (accumulator >= FIXED_DT and steps < MAX_STEPS) : (steps += 1) {
game.update(FIXED_DT);
accumulator -= FIXED_DT;
}
const alpha = accumulator / FIXED_DT;
game.render(alpha);
}
}
3.3 插值渲染
渲染时用 alpha 在上一个位置和当前位置之间插值,视觉上就平滑了:
const render_pos = Position{
.x = prev.x + (curr.x - prev.x) * alpha,
.y = prev.y + (curr.y - prev.y) * alpha,
};
4. ECS 架构设计
4.1 为什么用 ECS
面向对象继承树在游戏里很快失控:GameObject → Movable → Damageable → Player 这样的层级一旦交叉需求出现就无解。ECS 换成组合:
- Entity:只是一个整数 ID。
- Component:纯数据(
Position、Velocity、Sprite)。 - System:纯逻辑,遍历拥有特定组件组合的实体。
4.2 数据导向的内存布局
关键洞察:把同类组件连续存放。Position 数组连续,系统遍历时缓存命中率极高。
const Position = struct { x: f32, y: f32 };
const Velocity = struct { dx: f32, dy: f32 };
const World = struct {
positions: std.ArrayListUnmanaged(Position) = .{},
velocities: std.ArrayListUnmanaged(Velocity) = .{},
alive: std.ArrayListUnmanaged(bool) = .{},
};
对比「每个实体一个结构体,内含所有组件」,这种 SoA(Structure of Arrays)布局在 10 万实体时能快数倍。
4.3 稀疏集合与稠密数组
生产级 ECS 常用「稀疏集合」:sparse[entity_id] = dense_index,让 (entity, component) 查询 O(1),同时保持组件数组稠密。
const SparseSet = struct {
sparse: []u32, // entity id -> dense 下标
dense: []u32, // dense 下标 -> entity id
count: usize,
};
5. 组件存储与查询实现
5.1 简化版组件表
pub fn ComponentTable(comptime T: type) type {
return struct {
const Self = @This();
dense: std.ArrayListUnmanaged(T) = .{},
entities: std.ArrayListUnmanaged(u32) = .{},
lookup: std.AutoHashMapUnmanaged(u32, u32) = .{},
pub fn set(self: *Self, alloc: std.mem.Allocator, e: u32, value: T) !void {
const gop = try self.lookup.getOrPut(alloc, e);
if (gop.found_existing) {
self.dense.items[gop.value_ptr.*] = value;
} else {
gop.value_ptr.* = @intCast(self.dense.items.len);
try self.dense.append(alloc, value);
try self.entities.append(alloc, e);
}
}
pub fn get(self: *Self, e: u32) ?*T {
const idx = self.lookup.get(e) orelse return null;
return &self.dense.items[idx];
}
pub fn remove(self: *Self, e: u32) void {
const idx = self.lookup.get(e) orelse return;
const last = self.dense.items.len - 1;
self.dense.items[idx] = self.dense.items[last];
self.entities.items[idx] = self.entities.items[last];
self.lookup.put(self.entities.items[idx], idx) catch {};
self.dense.items.len -= 1;
self.entities.items.len -= 1;
_ = self.lookup.remove(e);
}
};
}
5.2 移动系统
pub fn movementSystem(
positions: *ComponentTable(Position),
velocities: *ComponentTable(Velocity),
dt: f32,
) void {
for (velocities.entities.items, velocities.dense.items) |e, v| {
if (positions.get(e)) |p| {
p.x += v.dx * dt;
p.y += v.dy * dt;
}
}
}
这是 ECS 的精髓:系统只关心「有 Velocity 的实体」,用 entities 与 dense 平行数组同时遍历,无需任何虚函数调用。
5.3 删除时的 swap-remove
上面的 remove 用了 swap-remove:把最后一个元素挪到被删位置,再缩短数组。O(1) 删除,代价是顺序不稳定——遍历时不能依赖索引顺序。
6. 实体生命周期与内存管理
6.1 实体 ID 与代际
朴素的 u32 实体 ID 会被复用,导致「悬空引用指向新实体」。解决办法是代际索引:
const Entity = packed struct {
index: u32,
generation: u32,
};
删除实体时 generation += 1,旧句柄的 generation 不匹配即判定为失效。
6.2 分配器选择
游戏主循环中避免逐帧分配。推荐:
- 启动期用
std.heap.GeneralPurposeAllocator或 arena 一次性分配资源池。 - 运行期用对象池 + 空闲列表,零分配。
- 帧内临时数据用
std.heap.ArenaAllocator,每帧reset(.retain_capacity)。
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
// 每帧
_ = arena.reset(.retain_capacity);
const scratch = arena.allocator();
6.3 延迟销毁
系统遍历中删除组件会破坏遍历,正确做法是收集待删列表,帧末统一处理:
var pending_destroy: std.ArrayListUnmanaged(u32) = .{};
// 系统中
try pending_destroy.append(alloc, e);
// 帧末
for (pending_destroy.items) |e| world.destroy(e);
pending_destroy.clearRetainingCapacity();
7. 输入、碰撞与物理
7.1 输入映射层
不要在各系统里直接读按键,抽一层「动作映射」:
const Action = enum { left, right, jump, fire };
fn isPressed(action: Action) bool {
return switch (action) {
.left => rl.IsKeyDown(rl.KEY_A) or rl.IsKeyDown(rl.KEY_LEFT),
.right => rl.IsKeyDown(rl.KEY_D) or rl.IsKeyDown(rl.KEY_RIGHT),
.jump => rl.IsKeyPressed(rl.KEY_SPACE),
.fire => rl.IsMouseButtonPressed(rl.MOUSE_BUTTON_LEFT),
};
}
这样改键位、加手柄支持只动一处。
7.2 AABB 碰撞
轴对齐包围盒检测最便宜,适合平台跳跃与射击:
const Rect = struct { x: f32, y: f32, w: f32, h: f32 };
fn overlaps(a: Rect, b: Rect) bool {
return a.x < b.x + b.w and
a.x + a.w > b.x and
a.y < b.y + b.h and
a.y + a.h > b.y;
}
7.3 空间划分加速
实体多时用均匀网格(uniform grid)或四叉树做宽相检测,只对同格实体做精确检测:
const cell_x = @as(usize, @intFromFloat(pos.x / CELL_SIZE));
const cell_y = @as(usize, @intFromFloat(pos.y / CELL_SIZE));
const idx = cell_y * GRID_W + cell_x;
踩坑:负坐标做整数除法会向零取整,
-1 / 32 == 0而不是-1,网格索引会错位。用@divFloor而非/。
8. 资源加载与音频
8.1 纹理与字体
const tex = rl.LoadTexture("assets/player.png");
defer rl.UnloadTexture(tex);
const font = rl.LoadFontEx("assets/font.ttf", 32, null, 0);
defer rl.UnloadFont(font);
rl.DrawTextEx(font, "得分 100", .{ .x = 10, .y = 10 }, 32, 1, rl.WHITE);
LoadFontEx 比默认字体支持中文,但需要指定码点范围,否则中文会显示为方框。
8.2 音频
rl.InitAudioDevice();
defer rl.CloseAudioDevice();
const sfx = rl.LoadSound("assets/jump.wav");
defer rl.UnloadSound(sfx);
rl.PlaySound(sfx);
8.3 资源清单与热重载
开发期监听资源目录变化,重新加载纹理即可实时看到美术改动:
var watcher = try std.fs.cwd().openDir("assets", .{ .iterate = true });
// 比对 mtime,变化则 Unload + Load
9. 打包发布与性能优化
9.1 嵌入资源
发布时把资源用 @embedFile 编进二进制,避免外部文件丢失:
const icon_bytes = @embedFile("assets/icon.png");
const icon = rl.LoadTextureFromImage(rl.LoadImageFromMemory(".png", icon_bytes, icon_bytes.len));
9.2 性能要点
- 开启
-Doptimize=ReleaseFast,并考虑-Dcpu=native(发布时慎用,会限制可移植性)。 - 每帧 draw call 数量比顶点数更重要,尽量用纹理图集合批。
- 用
rl.DrawTextureRec从图集裁剪,减少状态切换。 - 避免在渲染循环里做堆分配与字符串格式化。
9.3 发布检查清单
| 项目 | 检查点 |
|---|---|
| 帧率 | 目标机稳定 60 FPS,帧时间无尖峰 |
| 内存 | 长时间运行无增长(对象池泄漏) |
| 输入 | 键鼠与手柄都能操作 |
| 分辨率 | 窗口缩放不拉伸变形 |
| 打包 | 各平台产物可独立运行 |
速查表
| 需求 | 做法 |
|---|---|
| 初始化窗口 | rl.InitWindow(w, h, title) |
| 主循环条件 | while (!rl.WindowShouldClose()) |
| 帧时间 | rl.GetFrameTime() / rl.GetTime() |
| 固定步长 | 累加器 + while (acc >= FIXED_DT) |
| 组件存储 | 稠密数组 + 稀疏索引 |
| 删除实体 | swap-remove + 延迟销毁 |
| 每帧临时内存 | ArenaAllocator + reset(.retain_capacity) |
| 按键轮询 | rl.IsKeyDown / rl.IsKeyPressed |
| AABB 检测 | 四向比较 |
| 嵌入资源 | @embedFile + LoadImageFromMemory |
一句话记忆
游戏循环 = 累加器固定步长更新 + alpha 插值渲染;架构 = 实体是 ID、组件是纯数据、系统是遍历;内存 = 启动期分配池、运行期零分配、帧内用 arena 且帧末延迟销毁。
相关阅读
- /zig-language-basics/ — 结构体、切片与错误处理基础
- /zig-memory-management/ — 分配器选型与生命周期管理
- /zig-std-data-structures/ — ArrayList、HashMap 与容器选型
延伸阅读
- /zig-performance-optimization/ — 缓存友好布局与热点优化
- /zig-concurrency-atomics/ — 并行系统与任务调度
- /zig-webassembly/ — 编译到浏览器发布网页版
- Zig 专题 — Zig 系统编程专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。