100 人同时操作一座三维城市,为什么不需要服务器渲染 6000 帧?Rust + wgpu 多端并发架构实战

00 |概述

资料核对: 2026 年 11 月 2 日。本文示例中的 120/60/30 FPS、20 Hz 和消息大小都是设计假设,不是生产性能承诺。附带的带宽模型经过算术核对,但未进行真实 GPU 并发压测,也未在本文制作环境编译 Rust 工程。

想象你在做一个智慧城市平台:A 在 144 Hz 显示器上快速旋转园区模型,B 在普通笔记本上每秒绘制 60 帧,C 使用低功耗设备只想要 30 帧。与此同时,100 个人都在查看监测点、切换图层、修改标注,部分人还在以 2 倍速回放昨天的历史轨迹。

一个常见但昂贵的设计错误是:让服务器按每个人的 FPS 推送状态,或者让所有人等待同一个渲染时钟。

如果采用 Rust + wgpu 本地渲染 ,服务器根本不需要替这些用户绘制每一帧。用户自己的 GPU 负责画面;服务器只需要解决谁有权修改世界、修改如何排序、哪些数据该分发给谁、网络变慢怎么办。真正困难的不是"如何让 100 个人使用同一个 Shader",而是如何让 100 个不同速度的客户端共享一份可信的世界状态 。

01|先分清两种「多人三维」:各端绘制,还是服务端推流?

这两种架构容易被混为一谈,但并发成本截然不同。

维度 Rust/wgpu 本地渲染 GPU 服务器 + WebRTC 视频流
渲染发生在哪里 每个人自己的浏览器/原生客户端 GPU 服务器
每个人是否能独立旋转相机 可以 可以,但需独立渲染视图
用户数增长的主要成本 状态分发、静态资源和业务服务 渲染、硬件编码、显存、视频出口
共用一份渲染代码 Rust/Wasm + 原生 Rust 的主要价值 仍需部署远端渲染实例
适合的场景 普通数字孪生、协同标注、三维 GIS 查看器 弱终端访问超大型场景、集中 GPU 算力

本文讨论的是第一种:Rust 渲染核心同时服务浏览器与原生客户端,所有用户拥有独立 GPU 视图。如果 100 人在观看完全相同的镜头,服务端推流可通过媒体分发复用画面;但如果 100 人各自控制不同镜头,SFU 无法替你省掉 100 份不同视图的计算。这两个场景的并发数字不能直接混用。

02|技术选型:共享 Rust 逻辑,保留不同宿主

截至文章资料核对日,wgpu 30.0.1 支持原生 Vulkan、Metal、D3D12 等后端,浏览器端可通过 WebAssembly 使用 WebGPU;WebGL2 可作为受限回退路径。winit 0.30.13 适合原生窗口与事件接入。Axum 0.8.x 和 Tokio 1.54.x 可以承担 Rust 服务端的 WebSocket、异步任务和限流。wgpu 官方说明 · Web 端限制 · winit 文档 · Axum WebSocket

我推荐把代码仓库拆成清晰的四层,而不是把整个应用编译成同一个巨大 Wasm:

text 复制代码
workspace/
├── crates/
│   ├── scene-core/        # 实体、空间索引、相机数学、协议模型
│   ├── renderer-wgpu/     # GPU资源、RenderPipeline、WGSL、绘制命令
│   ├── sync-protocol/     # 快照/增量/命令与版本校验;不依赖GPU
│   └── client-runtime/    # 帧调度、插值、设备能力与场景同步
├── apps/
│   ├── web/               # wasm-bindgen、Canvas、浏览器输入
│   ├── desktop/           # winit、系统窗口、文件与原生打包
│   └── server/            # Axum + Tokio、Room Actor、鉴权、持久化
└── shaders/               # WGSL单一来源

注意这里的"共享"指共享源码和
语义
,不是共享同一个 GPU 上下文,也不是把桌面程序直接复制为 .wasm。WebGPU 并非在所有浏览器和设备上都可用,WebGL2 回退也不具备完全一致的计算/存储纹理能力,必须为需要的 GPU 特性设置能力分级。MDN WebGPU

03|三个时钟必须解耦:渲染、权威更新、网络发送

先定义三个独立时钟:

渲染时钟(Render Clock属于各个客户端,取决于显示器刷新率、GPU 预算、前后台状态与用户选择。A 的目标可以是 120 FPS,B 是 60 FPS,C 是 30 FPS。

权威更新时钟(Simulation / State Clock属于需要统一推进的业务模型。动态监测或简单仿真可以采用固定 20 Hz 的例子,即每 50 ms 更新一次权威状态。但静态 GIS 标注不需要无意义地每秒写 20 次状态:业务命令应是事件驱动。

网络分发时钟(Network Publish Clock 属于订阅与传输策略。状态可以按需合批、按区域分发,关键编辑成功后及时推送;不必等同于渲染时钟或仿真时钟。

在 250 ms 的时间窗里,理论上服务端 20 Hz 可产生 5 个完整间隔;目标 120/60/30 FPS 的客户端分别经历约 30、15、7~8 个绘制间隔。**从同样 5 次状态更新插值生成不同数量的画面,是正常设计,不是数据丢失。**图上的端点同时计入时会多显示一次采样刻度。

浏览器的 requestAnimationFrame 回调频率通常跟随显示器刷新率,并会在后台标签页受到暂停或节流。因此不能把 rAF 的次数当作服务器时间,也不能假设请求 120 FPS 就一定达到 120 FPS 。MDN requestAnimationFrame

一个更容易理解的例子

同一辆车在服务端每 50 ms 发布一次位置:

text 复制代码
t=1000ms  x=10.0
 t=1050ms x=10.5
 t=1100ms x=11.0

A 的 120 FPS 客户端可在两个状态之间绘制约 6 次插值画面;B 在同一期间约 3 次;C 约 1~2 次。这些画面来自本地数学插值,不是服务器发送了 6 份相同的数据。

例外是需要精确一致的物理判定、碰撞或财务状态:权威结果必须由服务器产生,客户端插值只负责视觉效果,不改变业务真实性。

04|四种状态不可混为一个全局对象

多人并发是否好做,首先取决于状态分类是否正确。

状态类别 实例 归属与同步策略
静态资源 地形、建筑、glTF、3D Tiles、纹理 内容寻址/CDN、版本化、按视野加载
权威共享状态 设备位置、告警、协同标注、任务状态 服务端校验、按区域版本化,增量同步
个人视图状态 相机、图层可见性、筛选条件、画质 默认在本地,必要时主动共享
个人播放状态 暂停/快进、历史游标、回放速率 会话私有;除非进入明确的协同回放模式

如果 A 把相机移到大楼北侧,服务器不应该把 B 的相机也移过去;如果 B 修改共享标注并成功保存,A、C 在权限和订阅范围内应该看到变化。

一句话:共享世界,不共享每个人的眼睛。

进一步考虑多人频率不同:A 每秒发送 100 次鼠标移动,B 每秒发送 2 次编辑指令。A 的鼠标数据允许覆盖过期状态;B 的编辑操作不能被覆盖掉。这里真正的分界线是消息语义和可靠性,不是用户快慢。

05|数据协议:快照、增量、事件、确认各司其职

一个实用的协议不需要一开始就追求极致二进制压缩,但必须具有版本信息。

json 复制代码
{
  "type": "delta",
  "room": "park-east",
  "from": 41,
  "to": 42,
  "server_tick": 2036,
  "changes": [
    {"entity_id": 9021, "x": 12.2, "y": 6.4, "status": "warning"}
  ]
}

客户端只能在自己的 room_version == from 时应用此增量。版本不连续,必须请求缺失的事件或重新获取快照。不能看到更大的版本号就直接覆盖现有内存,因为中间可能有删除、权限变化或关联更新。

订阅建立时,应先建立该房间的事件游标/订阅,再读取带版本的快照,随后按版本去重或补齐,避免经典的"先读取快照,后开始监听,中间漏掉一次编辑"。

对于动态状态,可以按 10~30 Hz 合并同一实体在时间窗内的变化;对于可靠编辑,事件应当附带 operation_id、expected_version、操作者与服务端时间。具体频率由画质、网络成本和业务精度决定,不能把这一区间当作通用标准。

Rust 版本一致性代码

下面是一段不依赖网络或 GPU 的纯 Rust 模型,专门演示不连续增量拒绝应用 。它可以放入 sync-protocol crate,并在浏览器和桌面客户端共用:

rust 复制代码
use std::collections::BTreeMap;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EntityState {
    pub id: u64,
    pub status: u8,
}

#[derive(Debug, Clone)]
pub struct Delta {
    pub from: u64,
    pub to: u64,
    pub upserts: Vec<EntityState>,
    pub deletes: Vec<u64>,
}

#[derive(Debug, Default)]
pub struct SceneReplica {
    pub version: u64,
    pub entities: BTreeMap<u64, EntityState>,
}

#[derive(Debug, PartialEq, Eq)]
pub enum ApplyError {
    NeedResync { local: u64, incoming_from: u64 },
    InvalidStep,
}

impl SceneReplica {
    pub fn apply(&mut self, d: Delta) -> Result<(), ApplyError> {
        if d.from != self.version {
            return Err(ApplyError::NeedResync {
                local: self.version,
                incoming_from: d.from,
            });
        }
        if d.to != d.from.checked_add(1).ok_or(ApplyError::InvalidStep)? {
            return Err(ApplyError::InvalidStep);
        }
        for id in d.deletes { self.entities.remove(&id); }
        for entity in d.upserts { self.entities.insert(entity.id, entity); }
        self.version = d.to;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_missing_delta_without_mutating_scene() {
        let mut replica = SceneReplica::default();
        let result = replica.apply(Delta {
            from: 1,
            to: 2,
            upserts: vec![EntityState { id: 7, status: 1 }],
            deletes: vec![],
        });
        assert_eq!(result, Err(ApplyError::NeedResync {
            local: 0, incoming_from: 1,
        }));
        assert_eq!(replica.version, 0);
        assert!(replica.entities.is_empty());
    }

    #[test]
    fn applies_contiguous_delta() {
        let mut replica = SceneReplica::default();
        replica.apply(Delta {
            from: 0,
            to: 1,
            upserts: vec![EntityState { id: 7, status: 2 }],
            deletes: vec![],
        }).unwrap();
        assert_eq!(replica.version, 1);
        assert_eq!(replica.entities.get(&7).unwrap().status, 2);
    }
}

这个例子刻意采用"一次事件推进一个版本"。如果生产系统把一批操作压缩成一个版本,需在协议中明确批次边界。上述代码未实现重复业务命令去重、鉴权、数据库事务和跨房间原子性,不能直接当作完整协同服务。

06|多人同时修改同一实体:为什么不能只用「最后写入获胜」?

A 和 B 同时打开版本 41 的同一设备:A 把颜色改成橙色,B 删除设备。如果两个人的请求都带 expected_version=41:

  • A 先经服务端验证成功,持久化事件后产生版本 42。
  • B 的请求到达时,版本已经是 42。服务端返回冲突和当前版本。
  • B 的客户端重新显示当前实体状态,再由用户明确是否继续删除。

建议采用 Room Actor / 单写者 作为每个共享区域的权威修改入口,命令串行执行,外部服务用持久化事件日志或数据库事务保存成功结果。这样比所有 WebSocket 会话直接竞争同一把全局锁更易于推理。

不要误以为「使用 Tokio 的 mpsc 就拥有 exactly-once」。内存通道不提供跨崩溃持久化;生产中还需要幂等键、数据库约束、事务、事件重放和客户端确认。对实时位置等可覆盖状态,可使用最新值策略;对审核、删除、标注和支付等操作不能这样做。

07|Tokio 实现 Room Actor:一个写入口,两个分发通道

Rust 服务端可以这样组织两类信息:

  • mpsc:向 Room Actor 投递命令,采用有界队列施加背压。
  • watch:发布最新的整体动态状态,慢订阅者允许跳过中间版本。
  • broadcast:分发短期事件提醒;落后的订阅者可能收到 Lagged,不是可靠事件存储。

这些是 Tokio 官方定义的行为:watch 只保留最后一个值,而 broadcast 的缓冲被覆盖时接收端会发现丢失。后者必须接上"事件补齐或快照重建"。Tokio watch · Tokio broadcast · Tokio mpsc

rust 复制代码
use std::{sync::Arc, time::Duration};
use tokio::sync::{mpsc, watch};
use tokio::time::{self, MissedTickBehavior};

#[derive(Debug, Clone, Default)]
struct Snapshot { tick: u64, vehicle_x: f32 }

enum Command {
    SetVelocity(f32),
}

async fn run_room(
    mut commands: mpsc::Receiver<Command>,
    snapshots: watch::Sender<Arc<Snapshot>>,
) {
    let period = Duration::from_millis(50);
    let mut ticker = time::interval_at(time::Instant::now() + period, period);
    ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
    let mut tick = 0_u64;
    let mut vehicle_x = 0.0_f32;
    let mut velocity = 0.0_f32;

    loop {
        tokio::select! {
            command = commands.recv() => match command {
                Some(Command::SetVelocity(v)) if v.is_finite() => velocity = v,
                Some(_) => {},
                None => break,
            },
            _ = ticker.tick() => {
                tick += 1;
                vehicle_x += velocity * 0.05;
                // 这个 watch 只用于可覆盖的"最新动态状态"。
                snapshots.send_replace(Arc::new(Snapshot { tick, vehicle_x }));
            }
        }
    }
}

MissedTickBehavior::Skip 可以避免 Tokio 定时器在长时间阻塞后突然连续补发一大批过期 Tick。但这不是严格仿真时钟的万能设置 :如果业务要求物理时间精度,应根据单调时钟、固定步进与最大追赶次数明确定义补偿策略;严重超时时可暂停、降级或重新同步。Tokio Interval

这里也不要每个 Tick 直接克隆数十万实体。生产中通常按区域生成更小的增量,Arc 复用不可变片段;当区域很大时可继续拆分 Room 或分片。与 watch 相连的客户端读取时使用 borrow_and_update(),避免反复处理同一个新值。

08|慢客户端、网络抖动和背压:允许跳过什么?

100 个客户端不可能始终处于相同网络状态。假设 A 处理消息很快,C 的设备卡顿两秒。如果你让服务器为 C 无限缓存每一条位置更新,两个问题会同时出现:内存不断上涨,C 恢复后还会看到两秒前的无意义运动轨迹。

我建议至少划分三个优先级:

消息 可否合并 / 丢弃中间态 慢客户端处理
相机指针、光标预览、最新监测值 通常可以,但要确保语义不依赖累计量 最新状态覆盖旧状态
位置快照、连续传感器帧 视业务精度允许跳过;历史值另存储 从最新快照恢复,必要时降低更新率
编辑、删除、保存、审核 不能未经确认直接丢弃 有界队列、明确拒绝/重试、持久化补齐

网络层还应限制每个连接的积压字节。WebSocket 浏览器 API 的 bufferedAmount 能表示已交给 send() 却尚未发出的字节,但它不包括整个网络栈的全部缓冲,因此只能作为一个背压信号。MDN bufferedAmount

javascript 复制代码
// 浏览器端示意:只发送"最新的可覆盖状态"。
function sendPresence(ws, newest) {
  if (ws.readyState !== WebSocket.OPEN) return false;
  if (ws.bufferedAmount > 64 * 1024) return false;
  ws.send(JSON.stringify({ type: 'presence', ...newest }));
  return true;
}

不能把这个判断套到可靠编辑命令上 :失败后的编辑操作必须通知用户并按幂等规则重试,绝不能静默丢弃。对必须顺序到达的增量,若 broadcast 报 Lagged,客户端应补齐缺失版本或主动请求新快照。

09|不同帧率如何保持画面顺滑?快照插值与延迟缓冲

服务器以 20 Hz 发布动态状态,客户端可以选择在稍微落后的时间轴上绘制,比如把展示时间放在"预计权威时间 − 100 ms"。这样相邻两帧快照已经到达时,可以在它们之间插值。

text 复制代码
Server:       S100 ----- S101 ----- S102 ----- S103
               |         |         |         |
Client A 120:  a a a a a a a a a a a a a a a a
Client B  60:  b   b   b   b   b   b   b   b
Client C  30:  c       c       c       c

对于两个位置 p0、p1,绘制时间处于 t0、t1 之间:

text 复制代码
alpha = clamp((render_time - t0) / (t1 - t0), 0, 1)
position = p0 * (1 - alpha) + p1 * alpha

经典的 Snapshot Interpolation 技术就是用少量延迟换取对网络抖动的容忍。100 ms 在本文只用于举例 ,真实缓冲应根据快照间隔、RTT 变化、抖动与丢包来测定。对于简单线性移动,短期外推可能可用;碰撞和非线性行为要依赖权威纠正。Gaffer on Games:Snapshot Interpolation

需要区别两个对象:自己正在操控的相机 应该本地即时响应;其他人/设备的远端状态可以使用插值缓冲。不能让本地鼠标每动一下都等待服务器状态回包。

此外,如果客户端后台暂停了 10 秒,恢复时不要疯狂补绘 600 帧。应重置或限制本地时间增量,拉取必要的最新世界状态,再恢复绘制。

10|wgpu 渲染循环:每个客户端都有自己的帧预算

wgpu 只负责图形设备、缓冲、着色器和 GPU 提交,不会替多人系统选择 FPS。浏览器应以 requestAnimationFrame 作为绘制机会,桌面可由 winit 事件循环决定何时请求重绘;两边共用 FramePacer 和场景绘制逻辑。requestAnimationFrame 本身不保证指定帧率,GPU 提交、垂直同步和显示器都会施加限制。MDN rAF

下面是一个与 wgpu 版本无关的纯 Rust 节流组件,可在浏览器与桌面共用:

rust 复制代码
pub struct FramePacer {
    min_gap_ms: f64,
    last_ms: Option<f64>,
}

impl FramePacer {
    pub fn new(target_fps: f64) -> Self {
        assert!(target_fps.is_finite() && target_fps > 0.0);
        Self { min_gap_ms: 1000.0 / target_fps, last_ms: None }
    }

    // now_ms 来自单调时钟;不要用本地墙上时钟来算 dt。
    pub fn should_render(&mut self, now_ms: f64, visible: bool) -> bool {
        if !visible || !now_ms.is_finite() {
            self.last_ms = None;
            return false;
        }
        match self.last_ms {
            None => { self.last_ms = Some(now_ms); true }
            Some(last) if now_ms < last => {
                self.last_ms = Some(now_ms); true
            }
            Some(last) if now_ms - last >= self.min_gap_ms => {
                self.last_ms = Some(now_ms);
                true
            }
            _ => false,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn caps_frames_without_blocking_other_clients() {
        let mut pacer = FramePacer::new(30.0);
        assert!(pacer.should_render(0.0, true));
        assert!(!pacer.should_render(16.7, true));
        assert!(pacer.should_render(34.0, true));
    }
}

这是限制最大绘制频率的最小示例 ,不是精确帧时钟。实际项目需要追踪平滑帧时间、动态画质、设备降级与调度漂移;不能认为 should_render() 返回 true 就能在这一瞬间提交 GPU 帧。浏览器建议使用 rAF 回调时间戳,桌面用单调时钟。

共享 WGSL 时还应明确一个成本:修改 renderer-wgpu 的 Shader 逻辑后,必须在至少一套浏览器 WebGPU、一套桌面原生后端上分别进行画面一致性验证;同一份 Rust 源码不保证不同驱动输出逐像素一致。

10.1|共享 wgpu 代码,到底共享什么?

跨端的关键不在于让浏览器和桌面使用同一个 Window,而是让它们使用同一个实例数据布局、同一个 WGSL 着色器和相同的 GPU 绘制逻辑 。平台层分别创建适配器、设备、交换链/Surface 和输入事件;renderer-wgpu 只接收 Device、Queue 和本帧目标 TextureView 等图形资源。

在上面的监测点案例里,最值得共享的是"把已订阅、已筛选的实体转换成 GPU 实例数组"。下面是实例化绘制时的最小数据提交示例(示意代码,前提是调用方已经创建对应的 GPU Buffer 和渲染管线):

rust 复制代码
use bytemuck::{Pod, Zeroable};

#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct InstanceGpu {
    pub position_scale: [f32; 4], // xyz + scale
    pub color: [f32; 4],
}

pub fn upload_instances(
    queue: &wgpu::Queue,
    instance_buffer: &wgpu::Buffer,
    instances: &[InstanceGpu],
) {
    // 确保调用前已验证 Buffer 容量足够。
    queue.write_buffer(instance_buffer, 0, bytemuck::cast_slice(instances));
}

// 创建 Buffer 时至少包含以下用途:
// wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST
// 绘制时,先绑定一次实例数据,再绘制多个实例:
// pass.set_vertex_buffer(1, instance_buffer.slice(..));
// pass.draw_indexed(0..index_count, 0, 0..instances.len() as u32);

对应 WGSL 的输入部分可以长这样:

wgsl 复制代码
struct VertexInput {
    @location(0) local_position: vec3<f32>,
    @location(1) instance_pos_scale: vec4<f32>,
    @location(2) instance_color: vec4<f32>,
};

struct VertexOutput {
    @builtin(position) position: vec4<f32>,
    @location(0) color: vec4<f32>,
};

@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
    var out: VertexOutput;
    let world = input.local_position * input.instance_pos_scale.w
              + input.instance_pos_scale.xyz;
    // 实际项目要乘当前用户相机的 view_proj 矩阵。
    out.position = vec4<f32>(world, 1.0);
    out.color = input.instance_color;
    return out;
}

这不是完整可渲染的 WGSL 程序为突出实例输入,代码省略了真实的视图投影、片元着色器和管线创建。不同用户拥有不同的相机 Uniform;相同的权威实体状态可以生成不同的可见实例集合。

千万不要把"共享 renderer-wgpu 源码"误解为"各终端复用 GPU 显存"。浏览器中的 GPU 对象与桌面程序的 GPU 对象完全独立,设备丢失、Surface 重建、DPI/分辨率等也由各宿主分别处理。只有真正需要变化的实例 Buffer 才应上传;大规模场景要合并相邻更新范围,避免大量极小的 write_buffer() 调用。

10.2|WebAssembly 与桌面的构建边界

浏览器端通常先安装 wasm32-unknown-unknown,由 wasm-bindgen/wasm-pack 输出可在网页中初始化的模块;桌面端直接打包原生程序。具体命令取决于实际 workspace 的 feature、入口和构建工具,不能直接使用桌面的 main() 作为 Web 入口:

bash 复制代码
rustup target add wasm32-unknown-unknown
# 示例:浏览器宿主 crate 中配置好 wasm-bindgen 后
wasm-pack build apps/web --target web --release
# 示例:桌面宿主 crate 在对应系统编译
cargo run -p desktop --release

wgpu 的浏览器 WebGPU 依赖安全上下文(通常是 HTTPS 或受信任的 localhost),并需要检测 GPU 适配器和功能限制。不要只检测 navigator.gpu 存在就判定可用;WebGL2 回退路径需要独立的特性/画质验证。wgpu Web 端说明 · MDN WebGPU

11|独立回放:A 实时、B 两倍速、C 暂停,怎么做?

"每个人不同频"还可能指不同的业务时间推进速度,不仅是 FPS。A 查看实时数据,B 以两倍速回放昨天,C 停留在某个事故发生时刻。

【配图 06|三名用户拥有各自独立的回放时钟】(请在 CSDN 编辑器上传素材包中的 06-replay.png,在此处插入图片)

每个客户端维护自己的:

text 复制代码
PlaybackSession {
  source: Live | History(branch_id),
  cursor_time,
  playback_speed,        // 0.0、1.0、2.0......
  view_filter,
  camera
}

服务端实时权威世界 仍按自己的规则更新;历史读取服务根据个人 cursor_time 返回对应的数据范围。B 的两倍速不能推动 A 的实时相机或全局仿真。

如果多人要协同修改历史分支,必须额外定义分支版本、时间语义、事件归属和合并规则。不要默认把协同回放当作普通播放速度参数。

12|并发与带宽:到底省了多少?

以下数据是按明确条件推导的模型,不是服务器压力测试:100 名用户各自查看不同视图;假设平均每人每次只接收 2,000 字节的合批动态更新,服务器每秒给每人发送 20 次。则:

text 复制代码
100 用户 × 20 次/秒 × 2,000 字节 = 4,000,000 字节/秒
= 4 MB/s(十进制) ≈ 32 Mbps

相同 100 人如果改为远程视频,每路平均 8 Mbps,仅视频下行就是 100 × 8 = 800 Mbps。这个比较只说明量级:本地模式还需下载模型、纹理、地形等资源,远程模式还需要 GPU 编码和上行输入;两者不是完全相同的负载。

【配图 07|不同同步频率与远程视频的下行带宽量级】(请在 CSDN 编辑器上传素材包中的 05-bandwidth-model.png,在此处插入图片)

如果简单按照 100 人全部 60 FPS 估计,终端侧的理论绘制总和是每秒 6,000 帧;而不同人拥有不同帧率,也不等于每个人都占用一份服务器 120 FPS 计算预算。例如 20 人目标 120 FPS、50 人目标 60 FPS、30 人目标 30 FPS,它们在各自终端上的理论绘制总和是:

text 复制代码
20 × 120 + 50 × 60 + 30 × 30 = 6,300 帧/秒

**这 6,300 帧是分散在不同终端上的,不能拿来要求服务端每秒绘制 6,300 帧。**服务端真正要处理的是订阅查询、鉴权、业务命令、动态状态生成和下行发送。

另外,网络总量也不是固定的 32 Mbps:热点区域拥挤、单次包变大、多人跨区域订阅、历史回放、二次聚合、重传与断线恢复,都可能使出口与 CPU 成本上升。容量规划需使用 P95 消息大小与区域热度分布,而非只看均值。

13|百万实体、千人同时在线,如何避免热点房间?

一个简单 Room Actor 足以解释一致性边界,但不意味着整个城市只能部署一个 Actor。生产中建议沿以下方向演进:

  1. 静态资源走 CDN模型、图片、3D Tiles 与纹理由边缘缓存承担,不经过房间实时广播。
  2. 按区域或实体组分片采用可稳定计算的 cell / tile / topic ID,每个分片拥有明确的权威写入者。
  3. **兴趣管理(AOI)**只分发视野、业务筛选及权限范围内的动态数据;高速跨区时预取相邻 cell。
  4. 分片版本和事件日志每个逻辑房间独立排序,跨分片业务事务单独设计,不假设所有分片拥有一个简单的总序号。
  5. 分层消息 QoS编辑与审计可靠交付,连续遥测允许压缩和最新值,临时光标可降采样。
  6. 连接/CPU/内存限额限制每个租户、会话、订阅区域、入站命令速率和出站队列大小。

例如,若同一热门 cell 有 1,000 个订阅者,千万不要每次变更时为每个用户独立扫描整份城市实体表。可以一次构建不可变的区域更新,再在权限过滤后共享编码结果;同时保证不同租户的资源和权限不会错误复用。

多节点时不要把普通 Redis Pub/Sub 当作可靠事件日志。可以使用数据库的事务消息、Kafka/NATS JetStream 等持久化通道存储关键事件;Redis 适合缓存在线状态与部分短期路由信息。如何选取取决于业务顺序、重放与运维要求,而不是简单追求更多基础设施。

14|上线必须观察的指标,不能只看平均 FPS

维度 指标 需要回答的问题
客户端渲染 帧时间 P50/P95/P99、GPU 耗时、设备内存 低端机是否真的保持可用?
权威状态 每分片 Tick 延迟、命令队列深度、编辑冲突率 是否有热点房间或慢命令?
网络同步 下行字节、发布率、RTT、队列积压、重连次数 用户是不是一直在追旧消息?
一致性 版本缺口率、补日志成功率、快照重建耗时 断线后能否回到正确世界?
协同安全 无权限操作拒绝率、幂等冲突、审计完整性 是否存在越权或重复写入?
用户体验 输入到本地画面、远端状态显示延迟、冻结次数 "同步正确"之外是否足够顺滑?

不要把网络 RTT 当成远端状态最终显示延迟,也不要把某个用户的目标 120 FPS 当作已经测得的显示帧率。需要在同一批测试场景中,独立记录每个端的实际 FPS 与订阅频率。

推荐的负载测试:1、10、100、500、1,000 会话,分别模拟 30/60/120 FPS 用户比例、不同操作频率、5% 慢客户端、热点集中与跨区域订阅。测试 1%、3%、5% 丢包的场景需要注意:WebSocket/TCP 的行为不是直接丢弃协议消息,而表现为重传、排队和延迟波动,应使用受控的网络仿真来复现。针对不同 QoS 分别测试丢包、时延和拥塞。

四项必须验证的失败场景

场景 A:10 秒断网后恢复客户端不重放早已过期的热状态,而是根据游标补齐编辑事件或拿最新快照。

场景 B:两人并发删除、修改恰好只有满足版本条件的一条成功;另一条得到明确冲突,绝不静默覆盖。

场景 C:低端机掉到 15 FPS它只是画得慢,不应该让其他人的服务器更新频率变慢。

场景 D:同一用户重复提交保存 服务端以 operation_id 幂等处理,避免网络重试导致重复业务记录。

文章素材包含一个浏览器离线交互模拟器,它模拟三种独立画面频率和统一快照速率,方便理解"不同 FPS 不要求更高同步频率"。模拟器不运行 Rust/wgpu,也不是生产压测工具;真实基准请在目标 GPU、浏览器和网络中执行。

15|哪些现有 Three.js / Cesium 项目值得迁移?

如果你已经上线了 Three.js 和 Cesium,我不建议为了多人并发立刻重写整套渲染器。可以采用混合落地:

先迁移可独立的部分 ,例如三维设备检查器、轨迹回放、局部园区视图、点云编辑和通用监测点实例化。让这些模块在浏览器和桌面版共用 scene-core + renderer-wgpu,协同服务再提供分区订阅、版本号和冲突处理。

保留成熟的 Cesium 地理能力,例如 3D Tiles 的数据选择、地形和大范围坐标精度处理;如要替换 Cesium,必须重新评估瓦片调度、LOD、地理坐标转换、拾取和动态加载,而不只是重写绘制 API。

可以设定一条现实的验收线:同一份动态状态在两端都正确显示,关键编辑都能重放,慢客户端不拖累其他人;之后再比较新增代码维护成本、性能与场景兼容性。单纯"把 TypeScript 换成 Rust"不能算迁移成功。

结语|跨平台共享渲染代码,不等于所有人共享渲染频率

Rust + wgpu 的价值,是让浏览器与原生客户端复用同一套场景语义、渲染管线和着色器,同时让每个用户继续使用自己的 GPU、自己的相机和自己的帧率。

服务端真正值得集中建设的是另一套基础设施:权威状态、版本控制、区域订阅、背压、可靠业务事件和断线恢复。

对 100 个同时操作的用户,不必追求一个庞大的全局 120 FPS 世界循环。让高速客户端足够快,让慢客户端不拖累别人,让每一次重要修改都可验证、可恢复、可追溯,才是一套能长期维护的多人三维系统。

官方资料与进一步阅读

  1. wgpu 30.0.1 --- docs.rs:原生与 Web 后端,以及跨平台边界。
  2. wgpu 在浏览器上运行:WebGPU/WebGL2 能力差异与安全上下文。
  3. winit 0.30:原生窗口与事件循环。
  4. Axum WebSocket:服务器 WebSocket 升级与会话处理。
  5. Tokio watch:最新值语义。
  6. Tokio broadcast:慢消费者与 Lagged。
  7. Tokio mpsc:有界队列和背压。
  8. Tokio MissedTickBehavior:漏 Tick 后的补偿策略。
  9. MDN requestAnimationFrame:刷新率和后台暂停。
  10. MDN WebGPU API:浏览器支持、能力与 HTTPS。
  11. MDN WebSocket bufferedAmount:浏览器发送队列。
  12. Gaffer On Games --- Snapshot Interpolation:插值缓冲与延迟权衡。
相关推荐
R²AIN SUITE1 小时前
多云或私有云环境怎么选项目管理工具?RainSuite等5种产品的8项部署验证
服务器·网络·数据库
当下新鲜事10 小时前
面向印刷包装设备的运动控制方案架构:E530标准型伺服系统与AC500 PLC的分层设计与选型实施
架构
海宇AI10 小时前
零信任架构实战:基于海宇个人风险报告构建自动化分期商城信用额度初审网关
运维·人工智能·架构·自动化
IMPYLH10 小时前
Linux 的时间为什么会被拨回去
linux·运维·服务器
AUTOSAR组织11 小时前
AUTOSAR架构下信息安全的实现方案
架构·汽车·autosar·软件架构·培训
2601_9632827711 小时前
寒地窄带通信工程实践:黑龙江哈尔滨对讲机选型、组网与合规运维总结
运维·服务器·网络
涛声依旧-底层原理研究所12 小时前
拆解 Cordis 底座:Agent 是如何被「组合」出来的
人工智能·架构
xbzb12 小时前
OpenStack 网络管理(理论):Linux 网络虚拟化是 Neutron 的地基
linux·服务器·网络·openstack·network·neutron
Android系统攻城狮14 小时前
Linux FFmpeg深度解析之avcodec_flush_buffers调用流程与实战(十九)
linux·运维·服务器·ffmpeg·车载音视频·ffmpeg音视频进阶