返回博客

移动游戏 Scaling 优化:为百万级 CCU 玩家构建大规模城市架构

发布于 2026年5月21日
移动游戏 Scaling 优化:为百万级 CCU 玩家构建大规模城市架构

概要

本文深入探讨了在移动端构建支持百万级 CCU 的大规模城市架构的优化策略,涵盖了服务器端空间分割、网络兴趣管理以及客户端资源流式加载等核心支柱。作者详细介绍了如何通过 Spatial Hashing 解决 O(N²) 性能瓶颈,并利用动态网络更新频率和 HLODs 降低带宽与显存压力。最后,文章强调了分布式 Backend 架构的重要性,并推荐使用 horizOn 等集成方案来简化高并发服务的开发与部署。

每一位 Multiplayer 开发者都经历过移动游戏架构崩溃的瞬间。你设计了一个庞大且精美的城市环境,在本地使用 10 个模拟客户端进行测试,运行效率是完美的 60 FPS。然而,当你将其推向生产环境,面对 1,000 名并发玩家涌入中央广场时,情况瞬间失控:低端 Android 设备因 Out-Of-Memory (OOM) 异常而直接崩溃,iOS Jetsam 机制强制杀掉应用,而你的 Dedicated Server CPU 占用率飙升至 100%,因为它正试图为数千个相互重叠的实体计算网络同步。

在构建旨在支持数百万活跃用户的移动端 MMO 或大规模开放世界时,你不能依赖引擎默认的开箱即用设置。移动端硬件有着严格的功耗墙(Thermal Throttling)和硬性的内存上限(中端设备通常限制游戏可用 RAM 不足 2GB)。与此同时,你的服务器必须在不崩溃的情况下处理密集的玩家集群。

实现真正的移动游戏 Scaling 优化需要三管齐下的方案:服务器端激进的空间分割(Spatial Partitioning)、客户端严苛的内存管理,以及能够处理海量连接的分布式 Backend 架构。在这篇分步教程中,我们将详细拆解如何为移动平台构建大规模城市架构。

第 1 步:服务器端空间分割 (Server-Side Spatial Partitioning)

在大规模 Multiplayer 游戏中,服务器性能的头号敌人是 O(N²) 问题。如果你的服务器通过遍历每个玩家来检查他们与其他所有玩家的距离,从而确定谁需要网络更新,那么计算量将呈灾难性增长。100 名玩家每 tick 需要 10,000 次距离检查;1,000 名玩家则需要 1,000,000 次。在 30Hz 的服务器 tick rate 下,每秒需要进行 3,000 万次检查。

为了解决这个问题,我们必须实现空间哈希 (Spatial Hashing) 或 Grid/Quadtree 系统。通过将城市划分为逻辑网格,玩家只需针对其当前单元格及相邻单元格内的实体检查网络相关性。这能将 O(N²) 的噩梦转化为 O(1) 的网格查询,随后配合高度受限的局部检查。

实现空间哈希网格 (C# 示例)

这是一个高效的 C# 2D 空间哈希网格实现,你可以将其适配到 Unity、Godot (通过 C#) 或自定义 Backend 服务器,在无需遍历全球状态的情况下管理实体邻近性。

using System.Collections.Generic;
using UnityEngine;

public class SpatialHashGrid
{
    private readonly float _cellSize;
    private readonly Dictionary<Vector2Int, HashSet<uint>> _grid;

    public SpatialHashGrid(float cellSize = 50f)
    {
        _cellSize = cellSize;
        _grid = new Dictionary<Vector2Int, HashSet<uint>>();
    }

    // 将世界坐标转换为网格坐标
    private Vector2Int GetCellCoordinate(Vector3 position)
    {
        return new Vector2Int(
            Mathf.FloorToInt(position.x / _cellSize),
            Mathf.FloorToInt(position.z / _cellSize)
        );
    }

    // 在网格中添加或更新玩家位置
    public void UpdateEntityPosition(uint entityId, Vector3 oldPosition, Vector3 newPosition)
    {
        Vector2Int oldCell = GetCellCoordinate(oldPosition);
        Vector2Int newCell = GetCellCoordinate(newPosition);

        if (oldCell != newCell)
        {
            if (_grid.ContainsKey(oldCell))
            {
                _grid[oldCell].Remove(entityId);
            }
            
            if (!_grid.ContainsKey(newCell))
            {
                _grid[newCell] = new HashSet<uint>();
            }
            _grid[newCell].Add(entityId);
        }
    }

    // 获取即时邻近区域(9 个单元格)内的所有实体
    public List<uint> GetEntitiesInProximity(Vector3 position)
    {
        List<uint> nearbyEntities = new List<uint>();
        Vector2Int centerCell = GetCellCoordinate(position);

        // 遍历玩家周围的 3x3 网格
        for (int x = -1; x <= 1; x++)
        {
            for (int y = -1; y <= 1; y++)
            {
                Vector2Int cellToCheck = new Vector2Int(centerCell.x + x, centerCell.y + y);
                if (_grid.TryGetValue(cellToCheck, out HashSet<uint> entitiesInCell))
                {
                    nearbyEntities.AddRange(entitiesInCell);
                }
            }
        }

        return nearbyEntities;
    }
}

通过将网络同步逻辑接入 GetEntitiesInProximity,你的服务器只需为少数几个彼此靠近的玩家计算精确距离,从而大幅降低 CPU 负载,让服务器在同一个实例中从容处理数千名并发用户。

第 2 步:网络兴趣管理 (Network Interest Management)

即便通过空间哈希解决了服务器的 CPU 瓶颈,你依然面临带宽问题。移动网络(4G/5G)天生不稳定,容易出现高抖动(Jitter),且有严格的带宽限制。每 tick 发送附近 50 名玩家的数据会撑爆移动客户端的套接字缓冲区,导致严重的同步漂移。

兴趣管理 (或 Network Relevancy) 是优先考虑“发送什么”的技术。一个 2 米外正在交火的玩家需要每秒 30 次更新;而一个 40 米外在另一条街行走的玩家每秒仅需 2 次更新。

覆盖网络相关性 (Unreal Engine C++ 示例)

在 Unreal Engine 中,你可以通过重写 IsNetRelevantFor 函数来控制这一点。这允许你基于视线和距离阶梯激进地剔除网络流量。

bool ACityPlayerCharacter::IsNetRelevantFor(const AActor* RealViewer, const AActor* ViewTarget, const FVector& SrcLocation) const
{
    // 1. 始终对自己相关
    if (RealViewer == this || ViewTarget == this)
    {
        return true;
    }

    // 2. 计算距离平方(比精确距离更快)
    const float DistanceSquared = FVector::DistSquared(SrcLocation, GetActorLocation());

    // 3. 绝对剔除距离(例如:10,000 units = 100 米)
    const float MaxRelevancyDistSq = 100000000.0f; 
    if (DistanceSquared > MaxRelevancyDistSq)
    {
        return false;
    }

    // 4. 基于距离的动态网络更新频率
    // 如果距离较远,我们降低发送数据的频率
    if (DistanceSquared > 25000000.0f) // 50 米
    {
        NetUpdateFrequency = 2.0f; // 每秒 2 次更新
    }
    else
    {
        NetUpdateFrequency = 30.0f; // 每秒 30 次更新
    }

    return Super::IsNetRelevantFor(RealViewer, ViewTarget, SrcLocation);
}

通过基于距离动态调整 NetUpdateFrequency,你可以将服务器下行带宽降低 70% 以上,节省玩家的移动流量并防止延迟峰值。

第 3 步:客户端内存限制与资源流式加载 (Streaming)

服务器拥有充足的 RAM,但移动电话没有。iPhone 13 拥有 4GB 的统一内存,而 iOS 操作系统通常会保留其中的 1.5GB 到 2GB。你的游戏必须完全运行在剩余的 2GB 空间内。如果你一次性将整个大规模城市加载进内存,操作系统会立即终止应用。

为了在这种环境下生存,你的城市必须被切分成块并进行异步 Streaming。

  • 分层细节级别 (HLODs): 不要在一个远处的街区渲染 50 个独立的建筑(这会导致 3,000 个 Draw calls),而是将整个街区烘焙成一个带有统一纹理图集的静态网格体。这能将远处几何体的 Draw calls 从数千个降低到恰好一个。
  • Addressable 资源系统: 永远不要在主要数据资源中使用硬引用。如果玩家在 A 区生成,客户端应使用异步加载(例如 Unity 的 Addressables 或 Unreal 的 PrimaryAssetLabels)仅下载或加载 A 区所需的纹理和网格体。B 区的数据必须从 RAM 中严格清除。
  • 纹理压缩: 移动端应完全依赖 ASTC (Adaptive Scalable Texture Compression)。它支持高度可变的块占用空间,让你能针对每个纹理对内存与视觉质量进行细粒度控制。

第 4 步:分布式 Backend 架构与服务器分片 (Sharding)

一座巨大的都市无法在单台物理机上运行。在设计 MMO 级别的城市时,世界必须物理性地划分到多个服务器实例(分片或节点)上。当玩家过桥从市中心节点进入贫民窟节点时,他们的客户端连接和世界状态必须在两个完全不同的服务器进程之间无缝移交。

自行构建这套系统需要搭建由 Agones 等系统编排的 Kubernetes 集群、使用 Redis 进行数据库分片以在服务器节点间传递玩家状态,以及自定义 UDP Load Balancers 以实现无缝连接移交。确保玩家在切换期间不丢失物品是一项巨大的工程——对于资深工程团队来说,这轻松需要 4-6 个月的专属 DevOps 工作。

如果在这些移交过程中没有正确处理 RPC 队列和数据库写入,不可避免地会出现状态损坏。我们之前讨论过修复破坏状态的 Unreal Engine RPC 同步问题的机制,这些原则同样适用于跨服务器节点的空间移交。

这就是平台方案的优势所在。通过 horizOn,这些高并发 Backend 服务、实时数据库同步和 Dedicated Server 编排都已经预先配置。你可以专注于构建城市的玩法循环和客户端优化,而不是把资金花在架构和调试 Kubernetes 网络规则上。

移动端城市世界构建最佳实践

为了确保你的城市在低端设备上保持高帧率的同时,完美扩展至数百万总用户,请严格遵守这些架构规则:

  1. 激进的 Instance Pooling: 在游戏过程中,严禁对车辆、行人或弹药等瞬时对象使用 Instantiate()SpawnActor。移动端 CPU 在内存分配和 Garbage Collection 方面表现吃力。应在加载界面预热对象池并循环使用。
  2. 街区的纹理图集化 (Texture Atlasing): Draw calls 是移动端 GPU 的主要杀手(移动端依赖 Tile-Based Deferred Rendering)。将所有通用街道道具(垃圾桶、长椅、路灯)的纹理合并到一个大型纹理图集中。这允许引擎将数百个道具的渲染批处理为一个 Draw call。
  3. 单块区域的严格面数预算: 强制执行硬性限制。单个移动端城市区块(例如 100x100 米区域)理想情况下应保持在 300,000 个可见三角形以下。应大量依赖法线贴图 (Normal maps) 而非原始几何体来模拟建筑细节。
  4. 实现服务器端休眠 (Server-Side Hibernation): 如果地图 80% 的区域目前是空的,却仍为整座城市运行 Dedicated Server,这会迅速耗尽你的工作室资金。你需要激进的实例管理,从 Fortnite 服务器优化休眠方案中汲取灵感,关闭闲置的网格坐标,并在玩家接近时立即唤醒它们。
  5. 碰撞体与视觉网格分离: 永远不要使用复杂的视觉网格进行服务器端碰撞计算。服务器应仅将城市理解为一系列低多边形基础形状(盒子、胶囊体、球体)。这能保持极小的服务器内存占用,并将物理计算控制在亚毫秒级。

应避免的常见陷阱

  • RPC 泛滥陷阱: 开发者经常为视觉效果(如车祸产生的火花)触发服务器到客户端的 RPC。千万不要这样做。服务器应仅同步车辆状态(例如 bIsCrashed = true)。客户端应通过 OnRep/属性钩子独立观察此状态变化,并在本地触发火花特效。这能节省海量网络带宽。
  • 区域转换时的内存泄漏: 在移动端卸载城市区块时,确保主动强制进行 Garbage Collection 或手动卸载 Asset Bundles。如果每次玩家在区域间移动时都在内存中留下几兆字节的孤儿纹理,运行 20 分钟后必然崩溃。

结论

实现真正的移动游戏 Scaling 优化是一种平衡的艺术。它需要为客户端 RAM 的每一兆字节而战,严格管控网络相关性,并将服务器负载分布在可扩展的 Backend 节点上。通过实现空间哈希、动态更新频率和异步资源流式加载,你可以构建出即使在数年前的移动硬件上也能流畅运行的大规模、鲜活的城市。

然而,构建能路由数千个并发连接并管理无缝服务器移交的可扩展基础设施,往往比开发游戏本身更难。准备好在没有 DevOps 噩梦的情况下扩展你的 Multiplayer Backend 了吗?免费试用 horizOn 或查看 API 文档,了解我们如何提供开箱即用的高并发架构。


来源:Designing Large-Scale Mobile Game Cities: Production, Optimization, & Worldbuilding Expertise