Zig 游戏开发实战:raylib、ECS 架构与游戏循环

Zig 编译快、无 GC、可交叉编译到全平台,非常适合独立游戏开发。本文以 raylib 为渲染层,讲解游戏循环与固定时间步、数据导向的 ECS 架构设计与实现、组件存储布局、实体生命周期、碰撞检测、资源与音频管理,以及多平台打包发布流程。

引言

用 Zig 写游戏的最大理由是迭代速度:增量编译通常在百毫秒级,热重载几乎无感;没有 GC 停顿,帧时间稳定;zig build -Dtarget=... 一条命令就能交叉编译到 Windows、Linux、macOS 甚至 WebAssembly。

本文以 raylib 作为渲染与输入层,重点讲两件事:一是游戏循环的正确写法(固定时间步 + 插值),二是ECS(Entity-Component-System)架构的 Zig 实现。ECS 是数据导向设计的核心,能让缓存命中率与并行度大幅提升。

前置:/zig-language-basics/(结构体、切片、错误处理)、/zig-memory-management/(分配器与生命周期)。


目录


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 系统编程专题

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「系统编程」更多文章

  1. Zig 解析器与编译器前端:词法分析、递归下降与 AST
  2. Zig 算法与数据结构实战:哈希表、树、图与排序
  3. Zig 文本处理:Unicode、正则与高性能字符串