2024年11月

Taro 4.0 已经推出一段时间了,4.0 版本主要是支持了鸿蒙端的开发以及 Vite 编译工具的支持。duxapp 在这段时间也跟随 Taro 的脚步,实现的对鸿蒙端的支持,并且也将之前的 duxui 这个多端的 UI 库,对鸿蒙端实现了兼容。

duxui 组件库提供了 60+ 的组件支持,能快速帮助你完成业务。

现在使用这个 UI 库,不仅能开发鸿蒙,还能实现同时开发 React Native、小程序和 H5,也是目前唯一一个能兼容这么多端的 UI 库。

组件展示

效果图

如何使用

使用下面的命令,可以快速初始化一个 UI 库的示例项目,你可以通过这个示例项目快速的查看到 UI 库在鸿蒙端以及其他端的效果

npx duxapp-cli create projectExample duxuiExample
  • 提示:在这之前需要安装好基本的环境
    nodejs 20+
    yarn
    git

创建项目后,进入项目目录
projectExample
运行以下命令

yarn dev:harmony --app=duxuiExample

编译完成后,使用 DevEco Studio 打开
dist/harmony
目录,这个目录就是一个原生鸿蒙项目

  • 如果你还不了解鸿蒙开发基础知识,或者想继续深入了解如何开发,可以查看这篇
    入门教程

duxapp 做了些什么

自动化

在 duxapp 中,我们把鸿蒙工程模板做了封装,就像
React Native
端那样,你不需要自行创建项目工程文件,在你运行鸿蒙相关的命令的时候,会自动创建鸿蒙工程项目,你需要做的就是使用 DevEco Studio 来继续编译 duxapp 生成的鸿蒙工程文件

配置化

包名、版本等信息就和
React Native
端那样,通过配置文件来实现

模块化

我们将鸿蒙端的支持封装到了
duxapp
的一个模块
duxappHarmony
中,只要你依赖了这个模块,就能实现对鸿蒙端的兼容

如果你还不是很了解 duxapp 的模块化,可以查看
模块化介绍

UI组件库

在过去这一段时间的兼容过程中,主要做的工作就是对 UI 库的兼容,因为鸿蒙的
arkui
和 Web 标准对比,存在不小的差异,每个组件都需要进行适配,并且还需要对一些底层代码进行修改。总得来说,现阶段虽然还存在一些小问题,但是95%的功能已经实现兼容

继续

如果你对这个项目有兴趣,可以查看文档,继续了解详情

开发文档:
http://duxapp.cn

GitHub:
https://github.com/duxapp

来源:晓飞的算法工程笔记 公众号,转载请注明出处

论文: VL4AD: Vision-Language Models Improve Pixel-wise Anomaly Detection

创新性


  1. 提出
    VL4AD
    模型用于解决语义分割网络难以检测来自未知语义类别的异常的问题,避免额外的数据收集和模型训练。
  2. VL4AD
    将视觉-语言(
    VL
    )编码器纳入现有的异常检测器,利用语义广泛的
    VL
    预训练来增强对离群样本的感知,还加入
    max-logit
    提示集成和类别合并策略用于丰富类别描述。
  3. 提出了一种新的评分函数,可通过文本提示实现无数据和无训练的离群样本监督。

VL4AD


视觉文本编码器

视觉编码器
\(\mathcal{E}_\text{vision, vis-lang}\)
是与文本编码器
\(\mathcal{E}_\text{text}\)
共同预训练,解码器
\(\mathcal{D}_\text{vis-lang}\)
处理多尺度的视觉和文本嵌入,生成两种类型的输出:掩码预测分数
\(\mathbf{s} \in [0, 1]^{N\times H\times W}\)
和掩码分类分数
\(\mathbf{c} \in [0, 1]^{N\times K}\)
,其中
\(N\)
表示对象查询的数量。

对象查询是可学习的嵌入,类似于目标检测网络中的先验框。掩码预测分数以类别无关的方式识别物体,而掩码分类分数计算掩码属于特定语义类别的概率。

基于编码后的视觉嵌入
\(\mathbf{v}_i\)

\(i=1, \dots, N\)
)和
ID
类别文本嵌入
\(\mathbf{t}_j\)

\(j=1, \dots, K\)
)之间的余弦相似性计算掩码分类分数:

\[\begin{equation}
\mathbf{c}_{i} = \text{softmax}\Big(1/T
\begin{bmatrix}
\text{cos}(\mathbf{v}_i, \mathbf{t}_1), &
\text{cos}(\mathbf{v}_i, \mathbf{t}_2), &
\ldots, &
\text{cos}(\mathbf{v}_i, \mathbf{t}_{K})
\end{bmatrix}
\Big)
\end{equation}
\]

在架构上,
\(\mathcal{E}_\text{vision, vis-only}\)

\(\mathcal{E}_\text{vision, vis-lang}\)
,以及
\(\mathcal{D}_\text{vis-only}\)

\(\mathcal{D}_\text{vis-lang}\)
是相当相似的,区别在于
\(\mathcal{E}_\text{vision, vis-lang}\)
在预训练后保持不变,仅对视觉-语言解码器
\(\mathcal{D}_\text{vis-lang}\)
进行微调。通过这种方式,将零样本
CLIP
在图像级别的竞争性
OOD
检测性能转移到像素级任务中。

Max-Logit
提示集成于类合并

优化
ID
类文本嵌入可以使其更好地与相应的
ID
视觉嵌入对齐,提高
ID

OOD
类别之间的可分离性,但盲目地微调文本编码器可能导致灾难性遗忘。

为此,论文通过
max-logit
提示集成在文本提示中引入概念词汇多样性和具体化,显著提高模型对
OOD
输入的敏感性。词汇多样性包括同义词和复数形式,而具体化涉及更好地与
CLIP
预训练对齐的分解概念。例如,使用概念{
vegetation
,
tree
,
trees
,
palm tree
,
bushes
}来表示类
vegetation

max-logit
集成考虑给定类
\(k\)
的所有替代概念,替换内容为视觉嵌入
\(\mathbf{v}_i\)
与所有
\(l\)
个替代文本嵌入
\([\mathbf{t}_{k}^{1}, \ldots, \mathbf{t}_{k}^{l}]\)
的最大余弦相似度:

\[\begin{equation}
\max\Big(
\begin{bmatrix}
\text{cos}(\mathbf{v}_i, \mathbf{t}_{k}^{1}), &
\text{cos}(\mathbf{v}_i, \mathbf{t}_{k}^{2}), &
\ldots, &
\text{cos}(\mathbf{v}_i, \mathbf{t}_{k}^{l})
\end{bmatrix}\Big).
\end{equation}
\]

此外,单靠在
\(K\)
类维度上的最大像素级得分可能导致次优性能,因为在两个
ID
类之间的边缘像素的不确定性较高,尤其是当类别数量增加时。

为了解决这个问题,将相关的
ID
类合并为超类。通过在测试期间将各个语义类的文本提示作为不同的替代概念连接到超类中来实现,而无需重新训练。然后,可以使用
max-logit
方法获得超类的不确定性。

通过
OOD
提示实现无数据、无训练异常监督

通过视觉-语言预训练,通常能够很好地检测到与
ID
类不同的语义
OOD
类(远
OOD
类)。但当
OOD
类与
ID
类非常相似的情况(近
OOD
类),则更具挑战性。例如,在
CityScapes
类别中,
OOD
类大篷车在城市驾驶场景中可能在视觉上与
ID
类卡车相似。

利用视觉-语言模型的开放词汇能力,论文引入了一种新的评分函数,旨在更好地检测这些近
OOD
类,而不需要额外的训练或数据准备。

为了在测试时整合
\(Q\)
个新的
OOD
概念,需要通过
\(Q\)
个额外的项
\(\text{cos}(\mathbf{v}_i, \mathbf{t}_{K+1}), \ldots, \text{cos}(\mathbf{v}_i, \mathbf{t}_{K+Q})\)
扩展公式
1
中的掩码分类得分
\(\mathbf{c}_i\)
。遵循公式
2
,即通过将
\(\mathbf{c} \in \left[0, 1\right]^{N\times (K+Q)}\)
的前
\(K\)
个通道与掩码预测得分
\(\mathbf{s} \in \left[0, 1\right]^{N\times H\times W}\)
进行组合,获得最终的不确定性得分
\(\mathbf{u} \in \mathbb{R}^{H\times W}\)

\[\begin{equation}
\mathbf{u}_{h,w} = -\max_{k}\sum_{i=1}^{N} \mathbf{s}_{i, h, w} \cdot \mathbf{c}_{i, k}\ \ .
\end{equation}
\]

通过这一整合,
\(Q\)
类中的
OOD
对象将(在大多数情况下)正确分配到其相应的类别。如果没有这一整合,它们可能会被错误地分配到与其实际
OOD
类别相似的
ID
类。相反,如果输入中不存在
OOD
对象,额外的
\(Q\)
类的影响将保持微不足道。

主要实验




如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.

前言

Redis在我们的日常开发工作中,使用频率非常高,已经变成了必不可少的技术之一。

Redis的使用场景也很多。

比如:保存用户登录态,做限流,做分布式锁,做缓存提升数据访问速度等等。

那么问题来了,Redis的性能要如何优化?

为了充分发挥Redis的性能,这篇文章跟大家一起聊聊Redis性能优化的18招,希望对你会有所帮助。

1. 选择合适的数据结构

Redis支持多种数据结构,如字符串、哈希、列表、集合和有序集合。根据实际需求选择合适的数据结构可以提高性能。

如果要存储用户信息,考虑使用哈希而不是多个字符串:

jedis.hset("user:1001", "name", "Alice");
jedis.hset("user:1001", "age", "30");

这样可以高效地存储和访问多个属性。

2. 避免使用过大的key和value

较长的key和value会占用更多内存,还可能影响性能。

保持key简短,并使用简洁的命名约定。

比如:
将“user:1001:profile”简化为“u:1001:p”。

还可以做压缩等其他优化。

如果对大key问题,比较感兴趣可以看看我的另一篇文章《
从2s优化到0.1s,我用了这5步
》,里面有非常详细的介绍。

3. 使用Redis Pipeline

对多个命令的批量操作,使用Pipeline可以显著降低网络延迟,提升性能。

比如,批量设置key可以这样做:

Pipeline p = jedis.pipelined();
for (int i = 0; i < 1000; i++) {
    p.set("key:" + i, "value:" + i);
}
p.sync();

这样一次性可以发送多个命令,减少了网络往返时间,能够提升性能。

4. 控制连接数量

过多的连接会造成资源浪费,使用
连接池
可以有效管理连接数量。

比如,使用JedisPool:

JedisPool pool = new JedisPool("localhost");
try (Jedis jedis = pool.getResource()) {
    jedis.set("key", "value");
}

有了连接池,这样连接就会被复用,而不是每次都创建新连接,使用完之后,又放回连接池。

能有效的节省连接的创建和销毁时间。

5. 合理使用过期策略

设置合理的过期策略,能防止内存被不再使用的数据占满。

例如,缓存热点数据可以设置过期时间。

比如,对会话数据设置过期时间:

jedis.setex("session:12345", 3600, "data");

Redis内部会定期清理过期的缓存。

6. 使用Redis集群

数据量增大时,使用Redis集群可以将数据分散到多个节点,提升并发性能。

可以将数据哈希分片到多个Redis实例。

这样可以避免单个Redis实例,数据太多,占用内存过多的问题。

7. 充分利用内存优化

选择合适的内存管理策略,Redis支持LRU(Least Recently Used)策略,可以自动删除不常用的数据。

比如,配置Redis的maxmemory:

maxmemory 256mb
maxmemory-policy allkeys-lru

8. 使用Lua脚本

Lua脚本让多条命令在Redis中原子性执行,减少网络延迟。

比如,使用Lua防止多个命令的网络延迟:

EVAL "redis.call('set', KEYS[1], ARGV[1]) return redis.call('get', KEYS[1])" 1 "key" "value"

使用Lua脚本,可以保证Redis的多个命令是原子性操作。

9. 监控与调优

使用INFO命令监控Redis性能数据,如命令支持、内存使用等,及时调优。

比如,使用命令获取监控信息:

INFO memory
INFO clients

10. 避免热点key

热点key会造成单一节点的压力,通过随机化访问来避免。

比如,可以为热点key加随机后缀:

String key = "hotkey:" + (System.currentTimeMillis() % 10);
jedis.incr(key);

11. 使用压缩

存储大对象时,考虑使用压缩技术来节省内存。

比如,可以使用
GZIP
压缩JSON数据:

byte[] compressed = gzipCompress(jsonString);
jedis.set("data", compressed);

12. 使用Geo位置功能

Redis支持地理位置存储和查询,使用
GEOADD
可以高效管理地理数据。

比如,存储地点信息:

jedis.geoadd("locations", longitude, latitude, "LocationName");

13. 控制数据的持久化

合理设置
RDB

AOF
的持久化策略,避免频繁写盘造成性能下降。

示例:
设置持久化的时间间隔:

save 900 1
appendonly yes

14. 尽量减少事务使用

在高并发场景下,避免过度使用MULTI/EXEC,因为事务会锁住key。

可以直接使用单条命令替代事务。

15. 合理配置客户端

调整客户端的连接超时和重连策略,以适应高负载场景,确保连接稳定。

例如:

JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(128); // 最大连接数
poolConfig.setMaxIdle(64); // 最大空闲连接
poolConfig.setMinIdle(16); // 最小空闲连接
poolConfig.setTestOnBorrow(true);
poolConfig.setTestOnReturn(true);
poolConfig.setTestWhileIdle(true);

JedisPool jedisPool = new JedisPool(poolConfig, "localhost", 6379, 2000); // 连接超时2000ms

16. 使用Redis Sentinel

使用
Sentinel
进行监控,实现高可用性,确保系统在故障时能够快速切换。

配置Sentinel进行主从复制。

17. 优化网络配置

保证Redis服务器有良好的网络带宽,避免网络瓶颈。

使用服务器内部专线,减少延迟。

18. 定期清理不必要的数据

生命周期管理很关键,定期删除过期或不必要的数据,保持内存高效利用。

可以设置
Cron
任务定期清理。

虽说Redis内部会清理过期的数据,但有些长期存在的垃圾数据,也建议及时清理。

总结

以上就是Redis性能优化的18条军规,灵活应用这些策略能够为你的项目带来显著的性能提升。希望能帮助到你,欢迎分享你的优化经验!

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。

技术背景

在前面的一篇
博客
中我们介绍了MindSpore-2.4-gpu的安装和其中可能出现的一些问题。这里我们在安装完成之后,可以尝试使用一些MindSpore新版本的特性。那么在安装之后,如果是使用VSCode作为IDE,可以使用ctrl+shift+P快捷键,然后搜索
python:sele
将Python解释器切换到我们所需要的最新MindSpore环境下。

设备管理和资源监测

在mindspore-2.4版本中增加了
mindspore.hal
接口,可以用于管理设备、监测设备以及执行流的处理等等。例如,常用的获取设备的数量:

import mindspore as ms
ms.set_context(device_target="GPU")
device_target = ms.context.get_context("device_target")
print(ms.hal.device_count(device_target))
# 2

这个输出表明我们的环境下有两个GPU卡。也可以打印这两块显卡的名称:

import mindspore as ms
ms.set_context(device_target="GPU")
device_target = ms.context.get_context("device_target")
print(ms.hal.get_device_name(0, device_target))
print(ms.hal.get_device_name(1, device_target))
# Quadro RTX 4000
# Quadro RTX 4000

以及设备的可用状态:

import mindspore as ms
ms.set_context(device_target="GPU")
device_target = ms.context.get_context("device_target")
print(ms.hal.is_available(device_target))
# True

查询设备是否被初始化:

import mindspore as ms
ms.set_context(device_target="GPU")
device_target = ms.context.get_context("device_target")
print(ms.hal.is_initialized(device_target))
A = ms.Tensor([0.], ms.float32)
A2 = (A+A).asnumpy()
print(ms.hal.is_initialized(device_target))
# False
# True

这也说明,只有在计算的过程中,MindSpore才会将Tensor的数据传输到计算后端。除了设备管理之外,新版本的MindSpore还支持了一些内存监测的功能,对于性能管理非常的实用:

import mindspore as ms
import numpy as np
ms.set_context(device_target="GPU")
A = ms.Tensor(np.random.random(1000), ms.float32)
A2 = (A+A).asnumpy()
print(ms.hal.max_memory_allocated())
# 8192

这里输出的占用最大显存的Tensor的大小。需要说明的是,这里不能直接按照浮点数占用空间来进行计算,应该说MindSpore在构建图的过程中会产生一些额外的数据结构,这些数据结构也会占用一定的显存,但是显存增长的趋势是准确的。除了单个的打印,还可以整个的输出一个summary:

import mindspore as ms
import numpy as np
ms.set_context(device_target="GPU")
A = ms.Tensor(np.random.random(1000), ms.float32)
A2 = (A+A).asnumpy()
print(ms.hal.memory_summary())

输出的结果为:

|=============================================|
|               Memory summary                |
|=============================================|
| Metric               | Data                 |
|---------------------------------------------|
| Reserved memory      |   1024 MB            |
|---------------------------------------------|
| Allocated memory     |   4096 B             |
|---------------------------------------------|
| Idle memory          |   1023 MB            |
|---------------------------------------------|
| Eager free memory    |      0 B             |
|---------------------------------------------|
| Max reserved memory  |   1024 MB            |
|---------------------------------------------|
| Max allocated memory |   8192 B             |
|=============================================|

ForiLoop

其实简单来说就是一个内置的for循环的操作,类似于Jax中的fori_loop:

import mindspore as ms
import numpy as np
from mindspore import ops
ms.set_context(device_target="GPU")

@ms.jit
def f(_, x):
    return x + x

A = ms.Tensor(np.ones(10), ms.float32)
N = 3
AN = ops.ForiLoop()(0, N, f, A).asnumpy()
print (AN)
# [8. 8. 8. 8. 8. 8. 8. 8. 8. 8.]

有了这个新的for循环体,我们可以对整个循环体做端到端自动微分:

import mindspore as ms
import numpy as np
from mindspore import ops, grad
ms.set_context(device_target="GPU", mode=ms.GRAPH_MODE)

@ms.jit
def f(_, x):
    return x + x

@ms.jit
def s(x, N):
    return ops.ForiLoop()(0, N, f, x)

A = ms.Tensor(np.ones(10), ms.float32)
N = 3
AN = grad(s, grad_position=(0, ))(A, N).asnumpy()
print (AN)
# [8. 8. 8. 8. 8. 8. 8. 8. 8. 8.]

流计算

CUDA Stream流计算是CUDA高性能编程中必然会用到的一个特性,其性能优化点来自于数据传输和浮点数运算的分离,可以做到在不同的Stream中传输数据,这样就达到了一边传输数据一边计算的效果。相比于单个Stream的
传输-计算-等待-传输-计算
这样的模式肯定是要更快一些,而有些深度学习框架其实很早就已经支持了Stream的调度,MindSpore目前也是跟上了节奏。关于Stream计算适用的一些场景,首先我们来看这样一个例子:

import mindspore as ms
import numpy as np
np.random.seed(0)
from mindspore import numpy as msnp
ms.set_context(device_target="GPU", mode=ms.GRAPH_MODE)

@ms.jit
def U(x, mu=1.0, k=1.0):
    return msnp.sum(0.5 * k * (x-mu) ** 2)

x = ms.Tensor(np.ones(1000000000), ms.float32)
energy = U(x)
print (energy)

在本地环境下执行就会报错:

Traceback (most recent call last):
  File "/home/dechin/projects/gitee/dechin/tests/test_ms.py", line 13, in <module>
    energy = U(x)
  File "/home/dechin/anaconda3/envs/mindspore-master/lib/python3.9/site-packages/mindspore/common/api.py", line 960, in staging_specialize
    out = _MindsporeFunctionExecutor(func, hash_obj, dyn_args, process_obj, jit_config)(*args, **kwargs)
  File "/home/dechin/anaconda3/envs/mindspore-master/lib/python3.9/site-packages/mindspore/common/api.py", line 188, in wrapper
    results = fn(*arg, **kwargs)
  File "/home/dechin/anaconda3/envs/mindspore-master/lib/python3.9/site-packages/mindspore/common/api.py", line 588, in __call__
    output = self._graph_executor(tuple(new_inputs), phase)
RuntimeError: 
----------------------------------------------------
- Memory not enough:
----------------------------------------------------
Device(id:0) memory isn't enough and alloc failed, kernel name: 0_Default/Sub-op0, alloc size: 4000000000B.

----------------------------------------------------
- C++ Call Stack: (For framework developers)
----------------------------------------------------
mindspore/ccsrc/runtime/graph_scheduler/graph_scheduler.cc:1066 Run

说明出现了内存不足的情况。通常情况下,可能需要手动做一个拆分,然后使用循环体遍历:

import time
import mindspore as ms
import numpy as np
from mindspore import numpy as msnp
ms.set_context(device_target="GPU", mode=ms.GRAPH_MODE)

@ms.jit
def U(x, mu=1.0, k=1.0):
    return msnp.sum(0.5 * k * (x-mu) ** 2)

def f(x, N=1000, size=1000000):
    ene = 0.
    start_time = time.time()
    for i in range(N):
        x_tensor = ms.Tensor(x[i*size:(i+1)*size], ms.float32)
        ene += U(x_tensor)
    end_time = time.time()
    print ("The calculation time cost is: {:.3f} s".format(end_time - start_time))
    return ene.asnumpy()

x = np.ones(1000000000)
energy = f(x)
print (energy)
# The calculation time cost is: 11.732 s
# 0.0

这里至少没有报内存错误了,因为每次只有在计算的时候我们才把相应的部分拷贝到显存中。接下来使用流计算,也就是边拷贝边计算的功能:

def f_stream(x, N=1000, size=1000000):
    ene = 0.
    s1 = ms.hal.Stream()
    s2 = ms.hal.Stream()
    start_time = time.time()
    for i in range(N):
        if i % 2 == 0:
            with ms.hal.StreamCtx(s1):
                x_tensor = ms.Tensor(x[i*size:(i+1)*size], ms.float32)
                ene += U(x_tensor)
        else:
            with ms.hal.StreamCtx(s2):
                x_tensor = ms.Tensor(x[i*size:(i+1)*size], ms.float32)
                ene += U(x_tensor)
    ms.hal.synchronize()
    end_time = time.time()
    print ("The calculation with stream time cost is: {:.3f} s".format(end_time - start_time))
    return ene.asnumpy()

因为要考虑到程序编译对性能带来的影响,所以这里使用与不使用Stream的对比需要分开执行。经过多次测试之后,不使用Stream的运行时长大约为:

The calculation time cost is: 10.925 s
41666410.0

而使用Stream的运行时长大约为:

The calculation with stream time cost is: 9.929 s
41666410.0

就直观而言,Stream计算在MindSpore中有可能带来一定的加速效果,但其实这种加速效果相比于直接写CUDA Stream带来的效果增益其实要弱一些,可能跟编译的逻辑有关系。但至少现在有了Stream这样的一个工具可以在MindSpore中直接调用,就可以跟很多同类型的框架同步竞争了。

总结概要

接上一篇对于MindSpore-2.4-gpu版本的安装介绍,本文主要介绍一些MindSpore-2.4版本中的新特性,例如使用hal对设备和流进行管理,进而支持Stream流计算。另外还有类似于Jax中的fori_loop方法,MindSpore最新版本中也支持了ForiLoop循环体,使得循环的执行更加高效,也是端到端自动微分的强大利器之一。

版权声明

本文首发链接为:
https://www.cnblogs.com/dechinphy/p/ms24.html

作者ID:DechinPhy

更多原著文章:
https://www.cnblogs.com/dechinphy/

请博主喝咖啡:
https://www.cnblogs.com/dechinphy/gallery/image/379634.html

探讨 Unity 实时多人游戏的现状,不同游戏类型中的不同网络架构。

网络架构模式

游戏开发者使用各种网络架构模式来确保多人游戏中玩家之间可靠且快速的互动。每种模式都有其自身的优缺点,选择合适的模式取决于您正在使用的特定游戏类型和互动场景。

在本节中,我们将讨论以下模式:锁步、回滚、快照插值和延迟补偿。此外,我们将讨论最适合不同类型或游戏的模式。

锁帧(LockStep)

锁帧(LockStep)
是最古老的网络游戏同步方法之一,如今仍然经常使用。虽然这种架构可以采用多种形式,但我们将重点介绍最常见的实现,然后讨论必要的条件、限制和可能的配置。

在锁步架构中,每个玩家将其输入发送到其他所有玩家,然后在收到所有玩家的输入后立即推进其模拟——就是这样!游戏在互联网上完全同步,使用非常少的带宽,每个玩家始终看到事件的展开方式与其他所有人完全相同。

为了使游戏与锁步架构兼容,必须满足几个条件。让我们讨论一下。

确定性

锁步架构的主要要求是游戏模拟必须支持严格的位级[确定性](https://www.whatgamesare.com/determinism.html)。由于网络代码只同步输入,因此游戏模拟必须在每个帧中使用相同的输入数据在每台机器上计算出相同的结果。否则,游戏模拟将不同步、发散并偏离彼此——最终导致游戏看起来完全不同。

这通常是一个难以满足的条件,游戏必须经过精心设计才能保持确定性。开发人员通常在每个帧或帧中的多个点检查游戏状态校验和,并将这些校验和在参与者之间进行比较,以帮助跟踪和修复在游戏测试期间发生不同步的非确定性来源。

此外,任何使用浮点运算的游戏(即大多数现代游戏)都需要额外的考虑。如果您的游戏在多个平台上运行,由于平台和编译器之间的差异,浮点确定性可能特别难以实现。每个编译器可以使用不同的指令集、重新排序指令或自动矢量化。每个系统都可以以不同的方式实现超越函数,例如余弦、正弦和正切。所有这些都可能导致平台之间甚至构建之间出现不同步。一些开发人员完全使用定点运算或软件模拟浮点运算来实现其游戏模拟,以绕过使用浮点数产生的非确定性。

其他非确定性来源包括使用不同的种子生成随机数或以不同的顺序处理对象,例如物理中的接触。所有这些考虑因素也极大地限制了可以用作游戏模拟一部分的第三方库,例如碰撞检测库、物理引擎等。因此,这对于许多类型的游戏和引擎来说可能不切实际。

固定帧率

锁步还要求所有玩家同步每个输入和滴答所代表的时间单位。换句话说,游戏将以固定的帧率进行。一些游戏将渲染帧率锁定为与固定模拟帧率匹配。其他游戏允许以任意帧率进行渲染,并在固定滴答结果之间显示插值。

问题和限制

虽然锁步架构是最容易实现的架构之一,并且没有视觉伪影,但它存在一些问题和限制。在某些情况下,可以通过对游戏实现做出一些妥协来克服这些限制。在其他情况下,这些限制可能只是限制了这种架构对特定游戏的适用性。

输入延迟

锁步通过等待直到收到所有相关信息才推进,从而防止任何延迟引起的视觉伪影。这种等待有一个缺点:输入延迟。

“输入延迟”通常是指用户按下按钮到在屏幕上看到响应之间的时间。从用户的角度来看,当他们在屏幕上看到该输入的结果时,他们会感知到对他们输入的响应。如果用户必须等到所有其他玩家收到并处理他们的输入才能看到此响应,这会导致明显的输入延迟。

在对反应性要求很高的游戏中,例如射击游戏,输入延迟可能是关键因素,因为它会使游戏响应速度变慢,更难玩。

回滚

接下来,
回滚
是一种流行的网络代码架构,广泛应用于现代竞技游戏,尤其是在格斗游戏中。

回滚可以看作是经典锁步架构的扩展。在回滚架构中,玩家在每一帧发送他们的命令,并在不等待其他玩家的命令的情况下继续他们的游戏。游戏在不等待来自远程玩家的数据的情况下进行——这被称为“客户端预测”,因为远程输入数据尚不可知,客户端必须对其他玩家的未来行动做出假设。

与锁步架构(它提供帧到帧的完美顺序,但存在输入延迟)相比,回滚架构提供即时命令响应,但以顺序为代价。

在回滚过程中,游戏会进行,并且一旦本地玩家的命令发送出去,就会立即显示出来。但是,由于尚未收到来自远程玩家的命令,因此显示的信息是预测。一旦收到来自远程玩家的数据,就必须将此新信息与预测进行协调。如果信息与预测不匹配,则游戏将回滚以“纠正”错误。

什么是“回滚”?

假设在客户端准备渲染第 7 帧时,收到了来自远程玩家的第 5 帧的新数据。在这种情况下,客户端需要执行以下步骤:

  1. 加载/恢复整个游戏的状态,使其处于第 4 帧,即所有命令都已知的上一个不可预测帧。

  2. 使用第 5 帧的原始本地命令以及从远程玩家新收到的命令,将游戏移动到第 5 帧。

  3. 通过对每一帧应用本地命令继续向前推进,直到我们到达当前帧(在我们的示例中,这是第 7 帧)。

图片

完成这些步骤后,我们可能会注意到,远程玩家在第 5 帧中的操作可能导致的结果与预测的结果不同;现在将在游戏中显示这些更正后的结果。

因此,在这种情况下,“回滚”意味着游戏“回滚”到所有内容都已知的最后一帧,然后使用新信息“重播”向前。这提供了纠正预测错误的能力。

需要注意的是,为了使此方法有效,游戏必须能够快速保存和恢复其完整状态,以及使用任意命令向前移动任意数量的帧。根据游戏的复杂性,这可能需要大量的计算资源。

快照插值

快照插值
是一种由游戏 Quake 推广的技术,此后已广泛应用于源自 Quake 的游戏和游戏引擎。事实上,这种模型特别适合射击游戏。

快照插值方法基于游戏对象两个独立的时间流的概念:一个反映过去对象的状态,另一个反映未来对象预期状态。

客户端(玩家)将其命令发送到服务器,服务器处理这些命令,更改游戏状态,然后将游戏当前状态的“快照”发送回客户端。此快照包含有关游戏中的所有对象在创建快照时状态的信息。

为了保持游戏的响应速度,客户端会立即将部分命令应用于某些对象,预测其行为。这会导致对象在玩家的屏幕上同时以不同的状态存在:

  • 插值对象
    以它们在过去某个时间点的状态表示。

  • 预测对象
    以它们在未来某个时间点预期存在的状态表示。

这会产生一种有趣的动态,例如,当您预测的角色试图躲避一个正在插值的传入弹丸时。这可能比看起来更难,因为您的角色正在未来移动,而弹丸则在过去。

尽管如此,这种模型还是有一些优点:

  • 游戏会立即响应玩家命令;与锁步模型不同,不需要输入延迟。

  • 与完整的回滚架构相比,客户端需要更少的处理时间。

  • 对象在从服务器获得的已知状态之间进行插值,因此对象只通过它们已经处于的状态。

需要注意的是,为了有效地使用这种模型,游戏必须能够快速处理和传输游戏状态的快照,以及立即响应玩家命令。

不同游戏类型的最佳架构

每种游戏类型都会对网络延迟、稳定性和吞吐量施加其自身的限制。例如,MMO 游戏需要高吞吐量、低延迟和高稳定性。同时,第一人称射击 (FPS) 游戏需要低延迟和高吞吐量。在这两种类型中,建议使用具有权威游戏服务器和基于状态和输入的数据交换的服务器-客户端拓扑。

下表提供了不同视频游戏类型的网络需求、数据交换格式、推荐的网络拓扑和网络模式的比较概述。

图片

通过分析此表,我们可以得出一些结论:

  1. 网络需求在很大程度上取决于类型:例如,RTS 和 FPS 游戏需要低延迟才能确保流畅且逼真的游戏体验。

  2. 数据交换格式也因类型而异:大多数游戏使用状态和输入交换,但有些游戏,例如 RTS 和动作游戏,只使用输入。

  3. 网络模式和拓扑是根据每种类型的特定需求选择的:动作和体育游戏通常使用预测和回滚,而 MMO 和 RPG 游戏通常使用快照预测和插值。

继续前进

每款游戏都有其自身的一套需求和类型交叉,这会对其网络互动施加其自身的限制。在许多情况下,选择经过验证的解决方案将比独立开发更好。

在这篇文章中,我们回顾了实时多人游戏中网络架构的主要模式。我们还汇总了一个针对不同类型推荐解决方案的通用表格。当然,这些建议并非最终的和通用的,但它们可以作为您选择架构解决方案时的起点。