首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >从零构建游戏:主循环、架构与网络同步的工程决策

从零构建游戏:主循环、架构与网络同步的工程决策

原创
作者头像
用户12339161
发布于 2026-10-30 16:56:24
发布于 2026-10-30 16:56:24
1740
举报

从零开发一款游戏,最容易被低估的不是渲染或玩法,而是时间、内存与一致性的管理。引擎替你隐藏了大部分复杂度,但一旦需要定制,底层决策就无法回避。本文沿“主循环 → 架构 → 网络 → 发布”四条线,给出可直接嵌入生产代码的骨架。

一、主循环:固定更新,可变渲染

游戏循环的第一条铁律:更新频率必须与渲染频率解耦。如果物理和玩法逻辑随帧率波动,60Hz 与 30Hz 下的跳跃高度、碰撞结果会截然不同。现代标准是 Glenn Fiedler 2004 年提出的固定时间步长加累加器。

代码语言:javascript
复制
#include <chrono>
class GameLoop {
    using Clock = std::chrono::high_resolution_clock;
    using Duration = std::chrono::duration<double, std::milli>;
    static constexpr Duration FIXED_DT{16.6667};   // 60Hz 逻辑步长
    static constexpr int MAX_CATCHUP = 5;
    Duration lag{0.0};
    Clock::time_point prev;
public:
    void Run() {
        prev = Clock::now();
        while (running) {
            auto now = Clock::now();
            Duration elapsed = now - prev;
            prev = now;
            if (elapsed > Duration{250.0}) elapsed = Duration{250.0}; // 防螺旋死亡
            lag += elapsed;
            int steps = 0;
            while (lag >= FIXED_DT && steps < MAX_CATCHUP) {
                ProcessInput(FIXED_DT);
                FixedUpdate(FIXED_DT);
                lag -= FIXED_DT; ++steps;
            }
            Render(lag / FIXED_DT);   // alpha 用于插值渲染
        }
    }
};

钳制 elapsed 防止“死亡螺旋”:系统卡顿 1 秒后试图补算 60 步,反而更卡;钳制到 250ms 意味着宁可让游戏进入短暂慢动作,也不让模拟崩溃。FixedUpdate 中不得访问渲染,Render 中不得修改状态,这条纪律是后续一切确定性和网络同步的基础。Godot 等引擎的 _physics_process 与 _process 分离,本质上是同一原则的封装。

二、架构:ECS 不是“更快的 OOP”,是内存布局的翻转

传统 GameObject + MonoBehaviour 在实体数量上万时,瓶颈往往不在逻辑复杂度,而在缓存未命中:每个组件是堆上的独立引用,遍历时 CPU 在内存中跳跃。ECS 把这一模型翻转过来:组件是纯数据的 struct,系统按需遍历组件数组。

代码语言:javascript
复制
// 纯数据,无方法
public struct Position { public float X, Y; }
public struct Velocity { public float Dx, Dy; }

// 系统:只关心“同时拥有 Position 和 Velocity”的实体
public void MoveSystem(Span<Position> pos, Span<Velocity> vel, float dt) {
    for (int i = 0; i < pos.Length; i++) {
        pos[i].X += vel[i].Dx * dt;
        pos[i].Y += vel[i].Dy * dt;
    }
}

Unity DOTS 进一步将相同组件组合的实体分组到连续内存块(Archetype Chunk)中,配合 [BurstCompile] 和 Job System,使万级实体更新成为可承受的负载。ECS 的代价是更高的设计门槛:组件粒度切分、系统执行顺序、数据在系统间的流转,都需要提前规划。中小规模项目不必强行 ECS,但当弹幕、粒子、NPC 密度逼近传统架构的帧预算上限时,数据导向是唯一出路。

三、网络同步:先选“谁拥有逻辑”,再谈优化

网络同步的根问题只有一个:战斗逻辑在客户端还是服务器。帧同步(Lockstep)将逻辑放在客户端,服务器只负责收集、排序、广播玩家操作指令;各端输入一致,逻辑一致,结果自然一致。它的致命弱点是确定性和反作弊:浮点数精度、容器遍历顺序、随机数种子,任何一处不一致都会导致表现分叉,而客户端拥有完整逻辑意味着外挂几乎无法根除。

状态同步则把逻辑放在服务器:客户端上传操作,服务器计算权威状态后下发快照,客户端只是表现层。代价是服务器计算压力和带宽成本,以及为掩盖延迟必须引入客户端预测、插值和延迟补偿。

代码语言:javascript
复制
// 帧同步乐观帧锁定:服务器定时广播所有玩家的输入位掩码,不等待慢客户端
// update = (FrameID, player_keyboards[8])
// 客户端按帧号顺序播放,缺少则等待,积压则快进

工程选型上,RTS、格斗、MOBA 适合帧同步(单位多、状态同步带宽爆炸),FPS、MMO、开放世界适合状态同步(权威服务器和反作弊优先)。确定性不是“尽量做到”,而是帧同步的准入门槛:定点数、/fp:strict、禁用非确定性容器遍历,必须在项目第一天就写进编码规范。

四、发布前:对象池与帧预算

性能问题很少来自算法,而来自分配。每帧 Instantiate/Destroy 子弹或粒子会产生 GC 压力,造成帧时间尖峰。对象池用空间换时间:预分配、循环复用、重置状态而非销毁。

代码语言:javascript
复制
public class ObjectPool<T> where T : class, new() {
    private readonly Stack<T> pool = new();
    private readonly Func<T> create; private readonly Action<T> reset;
    private readonly int maxSize;
    public ObjectPool(Func<T> create, Action<T> reset, int initial = 50, int max = 200) {
        this.create = create; this.reset = reset; this.maxSize = max;
        for (int i = 0; i < initial; i++) pool.Push(create());
    }
    public T Get() => pool.Count > 0 ? pool.Pop() : create();
    public void Return(T obj) { if (pool.Count < maxSize) { reset(obj); pool.Push(obj); } }
}

实测中对象池可比反复实例化快一个数量级,且将 GC 分配压到零。发布阶段的性能工作应当先测再改:以 60FPS 为目标时,整帧预算只有 16.6ms,用 Profiler 看 CPU 主线程与 GPU 各自耗时,区分“持续偏高”和“单帧尖峰”,再决定是优化 Draw Call、降低物理频率,还是引入池化与流式加载。

从零开发游戏的工程本质,是把时间确定性(固定步长)、内存局部性(ECS/池化)、状态一致性(同步范式选型)三件事在代码架构中提前锁定。引擎可以换,语言可以换,这三条不会变。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、主循环:固定更新,可变渲染
  • 二、架构:ECS 不是“更快的 OOP”,是内存布局的翻转
  • 三、网络同步:先选“谁拥有逻辑”,再谈优化
  • 四、发布前:对象池与帧预算
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档