2024年1月

在网页里实现文字聊天是比较容易的,但若要实现视频聊天,就比较麻烦了。本文将实现一个纯网页版的视频聊天和桌面分享的Demo,可直接在浏览器中运行,不需要安装任何插件。

一. 主要功能及支持平台

1.本Demo的主要功能有

(1)一对一语音视频聊天。

(2)远程桌面观看。

(3)当客户端掉线时,会进行自动重连,当网络恢复后,重连成功。

2.支持的平台

(1)支持的操作系统包括:Windows、信创国产Linux(银河麒麟、统信UOS)、Android、iOS、Mac、鸿蒙OS。

(2)支持的CPU架构:X86/X64、ARM、MIPS、Loongarch。

(3)支持几乎所有的主流浏览器:Chrome、Edge、Firefox、Safari、360浏览器、QQ浏览器等。

(4)另外,我们测试过,使用APP套壳,在WebView控件中加载Demo页面,也是可以正常视频聊天的。

如此,可以在C/S架构的客户端或手机APP中嵌入WebView控件来引入视频聊天或桌面分享功能的。

二. 开发环境

1. 服务端:

服务端开发环境是 Visual Sudio 2022 ,开发语音是 C# 。

2. Web端:

PC版Web开发环境是 VS Code 1.85 ,使用 vue 3。

手机版Web开发环境是 HBuilder 3.8.12,uni-app(导出H5)。

三. 运行效果

此Demo的源码分为三个部分,分别是服务端,PC端Web(横版)和手机端Web(竖版)。接下来首先来看移动端Web的运行效果。

(1)首先是登录界面,在登录界面有三个输入框,依次为服务器IP、用户账号和用户密码,在本Demo中,用户账号和用户密码均可随便填写。

(2)接下来是首页界面,首页界面有一个已连接的提示框,代表的意思是目前与服务端是连接状态,当因为网络原因或者其他原因断开时,则会提示已断开连接。

(3)发起视频聊天,输入对方的账号,然后点击请求视频会话按钮即可向对方发起视频聊天请求,对方接受请求和即可聊天了。

下图是手机端与PC端的视频聊天效果:

注意:手机端是不支持分享自己的桌面的,但是移动端可以观看PC端桌面。

(4)接下来看看一下PC端的运行效果。

登录之后主页界面,左上角是关于自己的一些信息,右边窗口则是显示连接对方的摄像头或者桌面。

(4)下图是在PC端观看他人桌面。

输入对方的账号,然后点击请求远程桌面,在对方同意后便可以观看别人的屏幕了。

四. 服务端源码说明

注意,由于浏览器的限制,如果你要将Web端部署到公网上,需要使用HTTPS协议,否则无法访问摄像头。

与之对应的,服务端也需要使用到WSS协议,因此需要准备一份SSL证书用于部署。如果你仅仅只是在本地运行看一下效果,则无需准备。

上图为服务端初始化代码,若不打算部署只是在浏览器中加载本地Demo页面,则应将上图中的第六行注释掉,并将第七行中MultimediaServerFactory.CreateMultimediaServer方法中的wssOption用null替换掉。

若打算将网站部署在服务器上,则需要将第五行X509Certificate2中的两个参数分别修改为你证书的路径和密码。

五. Web端源码说明

本Demo的中的Web端包含两套代码,其中移动端Web采用Uniapp进行开发,PC端Web采用Vue框架进行开发。为了便于理解,下面对源码中的关键点进行讲解说明,两套代码逻辑基本相同,因此这里不作区分。

1. 消息定义

在本Demo中,我们定义了10个消息类型,用于Web端之间进行通信,其定义如下:

const informationTypes ={//视频请求
  VideoRequest: 0,//回复视频请求的结果
  VideoResult: 1,//通知对方 挂断 视频连接
  CloseVideo: 2,//通知好友 网络原因,导致 视频中断
  NetReasonCloseVideo: 3,//通知对方(忙线中) 挂断 视频连接
  BusyLine: 4,//远程桌面请求
  DesktopRequest: 5,//回复远程桌面请求的结果
  DesktopResult: 6,//主动取消远程桌面请求
  CancelDesktop: 7,//对方(主人端)主动断开远程桌面
  OwnerCloseDesktop: 8,//客人端断开远程桌面连接
  GuestCloseDesktop: 9};

由于这些消息类型经常会使用到,因此需要将其放到一个全局都能访问到的地方,在移动端Web源码中,它被放在了Vuex中。而在PC端Web源码中,它放在src目录下的omcs目录下。

2. 自定义消息处理器

在登录成功后的这个时机,通过调用多媒体管理器上的
SetCustomMessageReceivedCallback
方法,我们向
multimediaManager
(多媒体管理器)注册一个回调函数,这个回调函数会在接收到其他用户或服务端的消息时被触发。

这个回调函数会接收一个对象类型的参数,其中包含了消息的类型和消息发起者的用户名数据,然后就可以根据消息的类型来完成自己的业务操作了。下图是本Demo中定义的消息处理器:

3. 一对一语音视频

在本Demo中,一对一语音视频聊天功能的实现逻辑简而言之就是:例如用户A想要与用户B视频聊天,那么用户A向用户B发送VideoRequest消息,在用户B收到来自用户A的VideoRequest消息时选择同意与否,并将携带用户B意愿数据的VideoResult消息发送用户A。

//请求视频会话
const videoRequest = async () =>{//...
  multimediaManager.sendCustomMessage(targetUsername.value, InformationTypes.VideoRequest, null, null);//...
};//响应视频会话
const videoResult = (flag) =>{
  
//...   multimediaManager.sendCustomMessage(targetUsername.value, InformationTypes.VideoResult, [flag ? 1 : 0], "");
  
//... };

4. 桌面分享

与一对一语音视频聊天功能类似,实现桌面分享也是一方发起请求,一方进行回应。与语音视频对应的,桌面分享的请求的消息类型为DesktopRequest,响应的消息类型为DesktopResult。

5. 断网重连

在网络断开时,用户进入掉线状态(与服务器断开),每5秒会进行与服务器的重新连接。提前向多媒体管理器注入ConnectionInterrupted和ConnectionRebuildSucceed回调,能够在与媒体服务器断开和重新连接成功时做一些事情。

六. 如何在本地部署运行Web端

Web端包含两套代码,其中移动端Web的目录是H5MediaDemo_WebH5,PC端Web的目录是H5MediaDemo_WebPC。

1. 移动端web:

由于移动端web是采用uniapp开发的,而uniapp项目需要通过HBuilder X来运行,因此,你需要在电脑上安装一个
HBuilder X
,然后在HBuilderX中打开运行——>运行到浏览器,然后选择一个浏览器就可以运行起来了,如下图:

2. PC端web:

PC端采用Vue3开发的,需要依赖NodeJS环境,因此,你需要在电脑上安装一个
NodeJS
(建议安装长期维护版)。在安装完后,通过在命令行窗口输入node -v和npm - v来检查是否安装成功:

确定安装成功后,通过命令行进入到H5MediaDemo_WebPC的项目根目录,然后输入npm run dev即可将项目运行起来。

七. 源码下载

(1)
PC版源码

(2)
手机版源码

另外,我们已经部署好了测试服务器,以方便测试。

(1)
PC  Web 测试网址

(2)
手机 Web 测试网址

网页版视频聊天Demo实现的介绍就到这里了,谢谢!

之前已经分享过多篇关于Spring Boot中使用
Java 21新特性虚拟线程
的性能测试案例:

早上看到群友问到一个关于虚拟线程遇到MySQL连接不兼容导致的性能问题:

这个问题确实之前就有看到过相关的评测,顺着个这个问题,重新把相关评测找出来,给大家分享一下。

以下内容主要参考文章:
https://medium.com/deno-the-complete-reference/springboot-physical-vs-virtual-threads-vs-webflux-performance-comparison-for-jwt-verify-and-mysql-23d773b41ffd

评测案例

评测采用现实场景中的处理流程,具体如下:

  1. 从HTTP授权标头(authorization header)中提取 JWT
  2. 验证 JWT 并从中提取用户的电子邮件
  3. 使用提取到的电子邮件执行 MySQL 查询用户
  4. 返回用户记录

这个场景其实是
Spring Boot 虚拟线程与Webflux在JWT验证和MySQL查询上的性能比较
测试的后续。前文主要对比了虚拟线程和WebFlux的,但没有对比虚拟线程与物理线程的区别。所以,接下来的内容就是本文关心的重点:在物理线程和虚拟线程下,MySQL驱动是否有性能优化。

测试环境

  • Java 20(使用预览模式,开启虚拟线程)
  • Spring Boot 3.1.3
  • 依赖的第三方库:jjwt、mysql-connector-java

测试工具:Bombardier

采用了开源负载测试工具:Bombardier。在测试场景中预先创建 100,000 个 JWT 列表。

在测试期间,Bombardier 从该池中随机选择了JWT,并将它们包含在HTTP请求的Authorization标头中。

MySQL表结构与数据准备

User表结构如下:

mysql> desc users;
+--------+--------------+------+-----+---------+-------+
| Field  | Type         | Null | Key | Default | Extra |
+--------+--------------+------+-----+---------+-------+
| email  | varchar(255) | NO   | PRI | NULL    |       |
| first  | varchar(255) | YES  |     | NULL    |       |
| last   | varchar(255) | YES  |     | NULL    |       |
| city   | varchar(255) | YES  |     | NULL    |       |
| county | varchar(255) | YES  |     | NULL    |       |
| age    | int          | YES  |     | NULL    |       |
+--------+--------------+------+-----+---------+-------+
6 rows in set (0.00 sec)

准备大约10w条数据:

mysql> select count(*) from users;
+----------+
| count(*) |
+----------+
|    99999 |
+----------+
1 row in set (0.01 sec)

测试代码:使用物理线程

配置文件:

server.port=3000
spring.datasource.url= jdbc:mysql://localhost:3306/testdb?useSSL=false&allowPublicKeyRetrieval=true
spring.datasource.username= dbuser
spring.datasource.password= dbpwd
spring.jpa.hibernate.ddl-auto= update
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect

User实体定义:

@Entity
@Table(name = "users")
public class User {
  @Id
  private String email;

  private String first;

  private String last;

  private String city;

  private String county;

  private int age;

  // 省略了getter和setter
}

数据访问实现:

public interface UserRepository extends CrudRepository<User, String> {

}

API实现:

@RestController
public class UserController {

    @Autowired
    UserRepository userRepository;

    private SignatureAlgorithm sa = SignatureAlgorithm.HS256;
    private String jwtSecret = System.getenv("JWT_SECRET");

    @GetMapping("/")
    public User handleRequest(@RequestHeader(HttpHeaders.AUTHORIZATION) String authHdr) {
        String jwtString = authHdr.replace("Bearer","");
        Claims claims = Jwts.parser()
            .setSigningKey(jwtSecret.getBytes())
            .parseClaimsJws(jwtString).getBody();

        Optional<User> user = userRepository.findById((String)claims.get("email"));
        return user.get();
    }
}

应用主类:

@SpringBootApplication
public class UserApplication {

    public static void main(String[] args) {
        SpringApplication.run(UserApplication.class, args);
    }
}

测试代码:使用虚拟线程

主要调整应用主类,其他一样,具体修改如下:

@SpringBootApplication
public class UserApplication {

    public static void main(String[] args) {
        SpringApplication.run(UserApplication.class, args);
    }

    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }
}

测试代码:使用WebFlux

server.port=3000
spring.r2dbc.url=r2dbc:mysql://localhost:3306/testdb?allowPublicKeyRetrieval=true&ssl=false
spring.r2dbc.username=dbuser
spring.r2dbc.password=dbpwd
spring.r2dbc.pool.initial-size=10
spring.r2dbc.pool.max-size=10
@Table(name = "users")
public class User {
  @Id
  private String email;

  private String first;

  private String last;

  private String city;

  private String county;

  private int age;

  // 省略getter、setter和构造函数
}

数据访问实现:

public interface UserRepository extends R2dbcRepository<User, String> {

}

业务逻辑实现:

@Service
public class UserService {

  @Autowired
  UserRepository userRepository;

  public Mono<User> findById(String id) {
    return userRepository.findById(id);
  }
}

API实现:

@RestController
@RequestMapping("/")
public class UserController {
  @Autowired
  UserService userService;

  private SignatureAlgorithm sa = SignatureAlgorithm.HS256;
  private String jwtSecret = System.getenv("JWT_SECRET");

  @GetMapping("/")
  @ResponseStatus(HttpStatus.OK)
  public Mono<User> getUserById(@RequestHeader(HttpHeaders.AUTHORIZATION) String authHdr) {
    String jwtString = authHdr.replace("Bearer","");
    Claims claims = Jwts.parser()
        .setSigningKey(jwtSecret.getBytes())
        .parseClaimsJws(jwtString).getBody();
    return userService.findById((String)claims.get("email"));
  }
}

应用主类:

@EnableWebFlux
@SpringBootApplication
public class UserApplication {

  public static void main(String[] args) {
    SpringApplication.run(UserApplication.class, args);
  }

}

测试结果

每次测试都包含 100 万个请求,分别评估了它们在不同并发(50、100、300)水平下的性能。下面是结果展示:

分析总结

在这个测试案例中使用了MySQL驱动,虚拟线程的实现方式性能最差,WebFlux依然保持领先。所以,主要原因在于这个MySQL的驱动对虚拟线程不友好。如果涉及到数据库访问的情况下,需要寻找对虚拟线程支持最佳的驱动程序。另外,该测试使用的是Java 20和Spring Boot 3.1。对于Java 21和Spring Boot 3.2建议读者在使用的时候自行评估。

最后,对于MySQL驱动对虚拟线程支持好的,欢迎留言区推荐一下。如果您学习过程中如遇困难?可以加入我们超高质量的
Spring技术交流群
,参与交流与讨论,更好的学习与进步!更多
Spring Boot教程可以点击直达!
,欢迎收藏与转发支持!

欢迎关注我的公众号:程序猿DD。第一时间了解前沿行业消息、分享深度技术干货、获取优质学习资源

大家好,本文提出了ECS模式。ECS模式是游戏引擎中常用的模式,通常用来组织游戏场景。本文出自我写的开源书《3D编程模式》,该书的更多内容请详见:
Github
在线阅读

目录

普通英雄和超级英雄

需求

我们需要开发一个游戏,游戏中有两种人物:普通英雄和超级英雄,他们具有下面的行为:

  • 普通英雄只能移动
  • 超级英雄不仅能够移动,还能飞行

我们使用下面的方法来渲染:

  • 使用Instance技术来一次性批量渲染所有的普通英雄
  • 一个一个地渲染每个超级英雄

实现思路

应该有一个游戏世界,它由多个普通英雄和多个超级英雄组成

一个模块对应一个普通英雄,一个模块对应一个超级英雄。模块应该维护该英雄的数据和实现该英雄的行为

给出UML

领域模型

image

总体来看,领域模型分为用户、游戏世界、英雄这三个部分

我们看下用户、游戏世界这两个部分:

Client是用户

World是游戏世界,由多个普通英雄和多个超级英雄组成。World负责管理所有的英雄,并且实现了初始化和主循环的逻辑

我们看下英雄这个部分:

一个NormalHero对应一个普通英雄,维护了该英雄的数据,实现了移动的行为

一个SuperHero对应一个超级英雄, 维护了该英雄的数据,实现了移动、飞行的行为

给出代码

首先,我们看下Client的代码;
然后,我们依次看下Client代码中前两个步骤的代码,它们包括:

  • 创建WorldState的代码
  • 创建场景的代码

然后,因为创建场景时操作了普通英雄和超级英雄,所以我们看下它们的代码,它们包括:

  • 普通英雄移动的代码
  • 超级英雄移动和飞行的代码

然后,我们依次看下Client代码中剩余的两个步骤的代码,它们包括:

  • 初始化的代码
  • 主循环的代码

然后,我们看下主循环的一帧中每个步骤的代码,它们包括:

  • 主循环中更新的代码
  • 主循环中渲染的代码

最后,我们运行Client的代码

Client的代码

Client

let worldState = World.createState()

worldState = _createScene(worldState)

worldState = WorldUtils.init(worldState)

WorldUtils.loop(worldState, [World.update, World.renderOneByOne, World.renderInstances])

Client首先创建了WorldState,用来保存游戏世界中所有的数据;然后创建了场景;然后进行了初始化;最后开始了主循环

创建WorldState的代码

World

export let createState = (): worldState => {
    return {
        normalHeroes: Map(),
        superHeroes: Map()
    }
}

createState函数创建了WorldState,它包括两个分别用来保存所有的普通英雄和所有的超级英雄的容器

创建场景的代码

Client

let _createScene = (worldState: worldState): worldState => {
    创建和加入normalHero1到worldState.normalHeroes
    创建和加入normalHero2到worldState.normalHeroes

    normalHero1移动

    创建和加入superHero1到worldState.superHeroes
    创建和加入superHero2到worldState.superHeroes

    superHero1移动
    superHero1飞行

    return worldState
}

_createScene函数创建了场景,创建和加入了两个普通英雄和两个超级英雄到游戏世界中。其中第一个普通英雄进行了移动,第一个超级英雄进行了移动和飞行

NormalHero

//创建一个普通英雄
export let create = (): [normalHeroState, normalHero] => {
    创建它的state数据:
        position设置为[0,0,0]
        velocity设置为1.0

        其中:position为位置,velocity为速度

    返回该英雄
}

NormalHero的create函数创建了一个普通英雄,初始化了它的数据

SuperHero

//创建一个超级英雄
export let create = (): [superHeroState, superHero] => {
    创建它的state数据:
        position设置为[0,0,0]
        velocity设置为1.0
        maxVelocity设置为1.0

        其中:position为位置,velocity为速度,maxVelocity为最大速度

    返回该英雄
}

SuperHero的create函数创建了一个超级英雄,初始化了它的数据

普通英雄移动的代码

NormalHero

//一个普通英雄的移动
export let move = (worldState: worldState, normalHero: normalHero): worldState => {
    从worldState中获得该英雄的position和velocity

    根据velocity,更新position

    更新worldState中该英雄的数据
}

move函数实现了移动的行为逻辑,更新了位置

超级英雄移动和飞行的代码

SuperHero

//一个超级英雄的移动
export let move = (worldState: worldState, superHero: superHero): worldState => {
    从worldState中获得该英雄的position和velocity

    根据velocity,更新position

    更新worldState中该英雄的数据
}

//一个超级英雄的飞行
export let fly = (worldState: worldState, superHero: superHero): worldState => {
    从worldState中获得该英雄的position和velocity、maxVelocity

    根据maxVelocity、velocity,更新position

    更新worldState中该英雄的数据
}

SuperHero的move函数的逻辑跟NormalHero的move函数的逻辑是一样的

fly函数实现了飞行的行为逻辑。它跟move函数一样,也是更新英雄的position。只是因为两者在计算时使用的速度的算法不一样,所以更新position的幅度不同

初始化的代码

WorldUtils

export let init = (worldState) => {
    console.log("初始化...")

    return worldState
}

init函数实现了初始化。这里没有任何逻辑,只是进行了打印

主循环的代码

WorldUtils

export let loop = (worldState, [update, renderOneByOne, renderInstances]) => {
    worldState = update(worldState)
    renderOneByOne(worldState)
    renderInstances(worldState)

    ...

    requestAnimationFrame(
        (time) => {
            loop(worldState, [update, renderOneByOne, renderInstances])
        }
    )
}

loop函数实现了主循环。在主循环的一帧中,首先进行了更新;然后一个一个地渲染了所有的超级英雄;然后一次性批量渲染了所有的普通英雄;最后执行下一帧

主循环中更新的代码

World

export let update = (worldState: worldState): worldState => {
    遍历worldState.normalHeroes:
        更新每个normalHero
    遍历worldState.superHeroes:
        更新每个superHero
}

update函数实现了更新,它会遍历所有的normalHero和superHero,调用它们的update函数来更新自己

我们看下NormalHero的update函数的代码:

//更新一个普通英雄
export let update = (normalHeroState: normalHeroState): normalHeroState => {
    更新该英雄的position
}

它更新了自己的position

我们看下SuperHero的update函数的代码:

//更新一个超级英雄
export let update = (superHeroState: superHeroState): superHeroState => {
    更新该英雄的position
}

它的逻辑跟NormalHero的update是一样的,这是因为两者都使用同样的算法来更新自己的position

主循环中渲染的代码

World

export let renderOneByOne = (worldState: worldState): void => {
    worldState.superHeroes.forEach(superHeroState => {
        console.log("OneByOne渲染 SuperHero...")
    })
}

export let renderInstances = (worldState: worldState): void => {
    let normalHeroStates = worldState.normalHeroes

    console.log("批量Instance渲染 NormalHeroes...")
}

renderOneByOne函数实现了超级英雄的渲染,它遍历每个超级英雄,一个一个地渲染
renderInstances函数实现了普通英雄的渲染,它一次性获得所有的普通英雄,批量渲染

运行Client的代码

下面,我们运行Client的代码,打印的结果如下:

初始化...
更新NormalHero
更新NormalHero
更新SuperHero
更新SuperHero
OneByOne渲染 SuperHero...
OneByOne渲染 SuperHero...
批量Instance渲染 NormalHeroes...
{"normalHeroes":{"144891":{"position":[0,0,0],"velocity":1},"648575":{"position":[2,2,2],"velocity":1}},"superHeroes":{"497069":{"position":[6,6,6],"velocity":1,"maxFlyVelocity":10},"783438":{"position":[0,0,0],"velocity":1,"maxFlyVelocity":10}}}

通过打印的数据,可以看到运行的步骤如下:
1.进行了初始化
2.更新了所有的人物,包括两个普通英雄和两个超级英雄
3.渲染了2个超级英雄
4.一次性批量渲染了所有的普通英雄
5.打印了WorldState

我们看下打印的WorldState:

  • WorldState的normalHeroes中一共有两个普通英雄的数据,其中有一个普通英雄数据的position为[2,2,2]而不是初始的[0,0,0],说明该普通英雄进行了移动操作;
  • WorldState的superHeroes中一共有两个超级英雄的数据,其中有一个超级英雄数据的position为[6,6,6],说明该超级英雄进行了移动和飞行操作

值得注意的是:
因为WorldState的normalHeroes和superHeroes中的Key是随机生成的id值,所以每次打印时Key都不一样

提出问题

  • NormalHero和SuperHero中的update、move函数的逻辑是重复的

  • 如果英雄增加更多的行为,NormalHero和SuperHero模块会越来越复杂,不容易维护

虽然这两个问题都可以通过继承来解决,即最上面是Hero基类,然后不同种类的Hero层层继承,但是继承的方式很死板,不够灵活

基于组件化的思想改进

概述解决方案

  • 基于组件化的思想,用组合代替继承。具体修改如下:


    • 将人物抽象为GameObject;
    • 将人物的行为抽象为组件,并把人物的相关数据也移到组件中;
    • GameObject通过挂载不同的组件,来实现不同的行为

这样就通过GameObject组合不同的组件来代替人物层层继承,从而更加灵活

给出UML

领域模型

image

总体来看,领域模型分为用户、游戏世界、GameObject、组件这四个部分

我们看下用户、游戏世界这两个部分:

Client是用户

World是游戏世界,由多个GameObject组成。World负责管理所有的GameObject,并且实现了初始化和主循环的逻辑

我们看下GameObject这个部分:

一个GameObject对应一个人物。GameObject负责管理挂载的组件,它可以挂载PositionComponent、VelocityComponent、FlyComponent、InstanceComponent这四种组件,每种组件最多挂载一个

我们看下组件这个部分:

组件负责维护自己的数据,实现自己的行为逻辑。具体来说,是将NormalHero、SuperHero的position数据和move函数、update函数移到了PositionComponent中;将NormalHero、SuperHero的velocity数据移到了VelocityComponent中;将SuperHero的maxVelocity数据和fly函数移到了FlyComponent中

InstanceComponent没有数据和逻辑,它只是一个标记,用来表示挂载该组件的GameObject使用一次性批量渲染的算法来渲染

结合UML图,描述如何具体地解决问题

  • 现在只需要实现一次Position组件中的update、move函数,然后将它挂载到不同的GameObject中,就可以实现普通英雄和超级英雄的更新、移动的逻辑,从而消除了之前在NormalHero、SuperHero中因共实现了两次的update、move函数而造成的重复代码

  • 因为NormalHero、SuperHero都是GameObject,而GameObject本身只负责管理组件,没有行为逻辑,所以随着人物的行为的增加,GameObject并不会增加逻辑,而只需要增加对应行为的组件,让GameObject挂载该组件即可
    通过这样的设计,将行为的逻辑和数据从人物移到了组件中,从而可以通过组合的方式使人物具有多个行为,避免了庞大的人物模块的出现

给出代码

首先,我们看下Client的代码;
然后,我们依次看下Client代码中前两个步骤的代码,它们包括:

  • 创建WorldState的代码
  • 创建场景的代码

然后,因为创建场景时操作了普通英雄和超级英雄,所以我们看下它们的代码,它们包括:

  • 移动的相关代码
  • 飞行的相关代码

然后,我们依次看下Client代码中剩余的两个步骤的代码,它们包括:

  • 初始化和主循环的代码

然后,我们看下主循环的一帧中每个步骤的代码,它们包括:

  • 主循环中更新的代码
  • 主循环中渲染的代码

最后,我们运行Client的代码

Client的代码

Client的代码跟之前的Client的代码基本上一样,故省略。不一样的地方是_createScene函数中创建场景的方式不一样,这个等会再讨论

创建WorldState的代码

World

export let createState = (): worldState => {
    return {
        gameObjects: Map()
    }
}

createState函数创建了WorldState,它保存了一个用来保存所有的gameObject的容器

创建场景的代码

Client

let _createScene = (worldState: worldState): worldState => {
    创建和加入normalHero1到worldState.gameObjects:
        创建gameObject
        创建positionComponent
        创建velocityComponent
        创建instanceComponent
        挂载positionComponent、velocityComponent、instanceComponent到gameObject
        加入gameObject到worldState.gameObjects

    创建和加入normalHero2到worldState.gameObjects

    normalHero1移动:
        调用normalHero1挂载的positionComponent的move函数

    创建和加入superHero1到worldState.gameObjects:
        创建gameObject
        创建positionComponent
        创建velocityComponent
        创建flyComponent
        挂载positionComponent、velocityComponent、flyComponent到gameObject
        加入gameObject到worldState.gameObjects

    创建和加入superHero2到worldState.gameObjects

    superHero1移动:
        调用superHero1挂载的positionComponent的move函数
    superHero1飞行:
        调用superHero1挂载的flyComponent的fly函数

    return worldState
}

_createScene函数创建了场景,场景的内容跟之前一样,都包括了2个普通英雄和2个超级英雄,只是现在创建一个英雄的方式改变了,具体变为:首先创建一个GameObject和相关的组件;然后挂载组件到GameObject;最后加入该GameObject到World中

普通英雄对应的GameObject挂载的组件跟超级英雄对应的GameObject挂载的组件也不一样,其中前者挂载了InstanceComponent(因为普通英雄需要一次性批量渲染),后者则挂载了FlyComponent(因为超级英雄多出了飞行的行为)

另外,现在改为通过调用对应组件的函数而不是直接操作英雄模块,从而实现英雄的“移动”、“飞行”

GameObject

//创建一个gameObject
export let create = (): [gameObjectState, gameObject] => {
    创建它的state数据:
        没有挂载任何的组件

    返回该gameObject
}

GameObject的create函数创建了一个gameObject,初始化了它的数据

PositionComponent

//创建一个positionComponent
export let create = (): positionComponentState => {
    创建它的state数据:
        gameObject设置为null
        position设置为[0,0,0]

        其中:position为位置,gameObject为挂载到的gameObject

    返回该组件
}

PositionComponent的create函数创建了一个positionComponent,初始化了它的数据

VelocityComponent

//创建一个velocityComponent
export let create = (): velocityComponentState => {
    创建它的state数据:
        gameObject设置为null
        velocity设置为1.0

        其中:velocity为速度,gameObject为挂载到的gameObject

    返回该组件
}

FlyComponent

//创建一个flyComponent
export let create = (): flyComponentState => {
    创建它的state数据:
        gameObject设置为null
        maxVelocity设置为1.0

        其中:maxVelocity为最大速度,gameObject为挂载到的gameObject

    返回该组件
}

InstanceComponent

//创建一个instanceComponent
export let create = (): instanceComponentState => {
    创建它的state数据:
        gameObject设置为null

        其中:gameObject为挂载到的gameObject

    返回该组件
}

这三种组件的create函数的职责跟PositionComponent的create函数的职责一样,不一样的是InstanceComponent的state数据中只有挂载到的gameObject,没有自己的数据

我们可以看到,组件的state数据中都保存了挂载到的gameObject,这样做的目的是可以通过它来获得挂载到它上的其它组件,从而一个组件可以操作其它挂载的组件

移动的相关代码

PositionComponent

...

//获得一个组件的position
export let getPosition = (positionComponentState: positionComponentState) => {
    return positionComponentState.position
}

//设置一个组件的position
export let setPosition = (positionComponentState: positionComponentState, position) => {
    return {
        ...positionComponentState,
        position: position
    }
}

...

//一个gameObject的移动
export let move = (worldState: worldState, positionComponentState: positionComponentState): worldState => {
    //获得该组件的position、gameObject
    let [x, y, z] = getPosition(positionComponentState)

    //通过该组件的gameObject,获得挂载到该gameObject的velocityComponent组件
    //获得它的velocity
    let gameObject = getExnFromStrictNull(positionComponentState.gameObject)
    let velocity = VelocityComponent.getVelocity(GameObject.getVelocityComponentExn(getGameObjectStateExn(worldState, gameObject)))

    //根据velocity,更新该组件的position
    positionComponentState = setPosition(positionComponentState, [x + velocity, y + velocity, z + velocity])

    更新worldState中该组件挂载的gameObject中的该组件的数据
}

VelocityComponent

//获得一个组件的velocity
export let getVelocity = (velocityComponentState: velocityComponentState) => {
    return velocityComponentState.velocity
}

PositionComponent维护了position数据,提供了它的get、set函数。VelocityComponent维护了velocity数据,,提供了它的get函数

另外,PositionComponent的move函数实现了移动的行为逻辑

飞行的相关代码

FlyComponent

//获得一个组件的maxVelocity
export let getMaxVelocity = (flyComponentState: flyComponentState) => {
    return flyComponentState.maxVelocity
}

//设置一个组件的maxVelocity
export let setMaxVelocity = (flyComponentState: flyComponentState, maxVelocity) => {
    return {
        ...flyComponentState,
        maxVelocity: maxVelocity
    }
}

//一个gameObject的飞行
export let fly = (worldState: worldState, flyComponentState: flyComponentState): worldState => {
    //获得该组件的maxVelocity、gameObject
    let maxVelocity = getMaxVelocity(flyComponentState)
    let gameObject = getExnFromStrictNull(flyComponentState.gameObject)

    //通过该组件的gameObject,获得挂载到该gameObject的positionComponent组件
    //获得它的position
    let [x, y, z] = PositionComponent.getPosition(GameObject.getPositionComponentExn(getGameObjectStateExn(worldState, gameObject)))

    //通过该组件的gameObject,获得挂载到该gameObject的velocityComponent组件
    //获得它的velocity
    let velocity = VelocityComponent.getVelocity(GameObject.getVelocityComponentExn(getGameObjectStateExn(worldState, gameObject)))

    //根据maxVelocity、velocity,更新positionComponent组件的position
    velocity = velocity < maxVelocity ? (velocity * 2.0) : maxVelocity
    let positionComponentState = PositionComponent.setPosition(GameObject.getPositionComponentExn(getGameObjectStateExn(worldState, gameObject)), [x + velocity, y + velocity, z + velocity])

    更新worldState中该组件挂载的gameObject中的该组件的数据
}

FlyComponent维护了maxVelocity数据,,提供了它的get、set函数。另外,FlyComponent的fly函数实现了飞行的行为逻辑

初始化和主循环的代码

初始化和主循环的逻辑跟之前一样,故省略代码

主循环中更新的代码

World

export let update = (worldState: worldState): worldState => {
    遍历worldState.gameObjects:
        if(gameObject挂载了positionComponent){
            更新positionComponent
        }
}

update函数实现了更新,它会遍历所有的gameObject,调用它挂载的PositionComponent组件的update函数来更新该组件。我们看下PositionComponent的update函数的代码:

//更新一个组件
export let update = (positionComponentState: positionComponentState): positionComponentState => {
    更新该组件的position
}

它的逻辑跟之前的NormalHero和SuperHero中的update函数的逻辑是一样的

主循环中渲染的代码

World

export let renderOneByOne = (worldState: worldState): void => {
    let superHeroGameObjects = worldState.gameObjects.filter(gameObjectState => {
        //判断gameObject是不是没有挂载InstanceComponent
        return !GameObject.hasInstanceComponent(gameObjectState)
    })

    superHeroGameObjects.forEach(gameObjectState => {
        console.log("OneByOne渲染 SuperHero...")
    })
}

export let renderInstances = (worldState: worldState): void => {
    let normalHeroGameObejcts = worldState.gameObjects.filter(gameObjectState => {
        //判断gameObject是不是挂载了InstanceComponent
        return GameObject.hasInstanceComponent(gameObjectState)
    })

    console.log("批量Instance渲染 NormalHeroes...")
}

renderOneByOne函数实现了超级英雄的渲染,它首先得到了所有没有挂载InstanceComponent组件的gameObject;最后遍历它们,一个一个地渲染

renderInstances函数实现了普通英雄的渲染,它首先得到了所有挂载了InstanceComponent组件的gameObject;最后批量渲染

运行Client的代码

下面,我们运行Client的代码,打印的结果如下:

初始化...
更新PositionComponent
更新PositionComponent
更新PositionComponent
更新PositionComponent
OneByOne渲染 SuperHero...
OneByOne渲染 SuperHero...
批量Instance渲染 NormalHeroes...
{"gameObjects":{"304480":{"positionComponent":{"gameObject":304480,"position":[0,0,0]},"velocityComponent":{"gameObject":304480,"velocity":1},"flyComponent":{"gameObject":304480,"maxVelocity":10},"instanceComponent":null},"666533":{"positionComponent":{"gameObject":666533,"position":[2,2,2]},"velocityComponent":{"gameObject":666533,"velocity":1},"flyComponent":null,"instanceComponent":{"gameObject":666533}},"838392":{"positionComponent":{"gameObject":838392,"position":[0,0,0]},"velocityComponent":{"gameObject":838392,"velocity":1},"flyComponent":null,"instanceComponent":{"gameObject":838392}},"936933":{"positionComponent":{"gameObject":936933,"position":[6,6,6]},"velocityComponent":{"gameObject":936933,"velocity":1},"flyComponent":{"gameObject":936933,"maxVelocity":10},"instanceComponent":null}}}

通过打印的数据,可以看到运行的步骤与之前一样
不同之处在于:

  • 更新4个英雄现在变为更新4个positionComponent
  • 打印的WorldState不一样

我们看下打印的WorldState:

  • WorldState的gameObjects包括了4个gameObject的数据,其中有一个gameObject数据的positionComponent的position为[2,2,2],说明它进行了移动操作;
  • 有一个gameObject数据的positionComponent的position为[6,6,6],说明它进行了移动和飞行操作

值得注意的是:
因为WorldState的gameObjects中的Key是随机生成的id值,所以每次打印时Key都不一样

提出问题

  • 组件的数据分散在各个组件中,性能不好
    如position数据现在是一对一地分散保存在各个positionComponent组件中(即一个positionComponent组件保存自己的position),那么如果需要遍历所有组件的position数据,则需要遍历所有的positionComponent组件,分别获得它们的position。因为每个positionComponent组件的数据并没有连续地保存在内存中,所以会造成缓存命中丢失,带来性能损失
  • 涉及多种组件的行为不知道放在哪里
    如果超级英雄增加一个“跳”的行为,该行为不仅需要修改position数据,还需要修改velocity数据,那么实现该行为的jump函数应该放在哪个组件中呢?
    因为jump函数需要同时修改PositionComponent组件的position数据和VelocityComponent组件的velocity数据,所以将它放在两者中任何一种组件中都不合适。因此需要增加一种新的组件-JumpComponent,对应“跳”这个行为,并实现jump函数。该函数会通过JumpComponent挂载到的gameObject来获得挂载到它上的PositionComponent和VelocityComponent组件,从而修改它们的数据。
    如果增加更多的这种涉及多种组件的行为,就需要为每个这样的行为增加一种组件。因为组件比较重,既有数据又有逻辑,所以增加组件的成本较高;另外,因为组件与GameObject是聚合关系,而GameObject和World也是聚合关系,它们都属于强关联关系,所以增加组件会较强地影响GameObject和World,这也增加了成本

使用ECS模式来改进

概述解决方案

  • 基于Data Oriented的思想进行改进
    组件可以按角色分为Data Oriented组件和其它组件,其中前者的特点是属于该角色的每个组件都有数据,且组件的数量较多;后者的特点是属于该角色的每个组件都没有数据,或者组件的数量很少。这里具体说明一下各种组件的角色:目前一共有四种组件,它们是PositionComponent、VelocityComponent、FlyComponent、InstanceComponent。其中,InstanceComponent组件因为没有组件数据,所以属于“其它组件”;另外三种组件则都属于“Data Oriented组件”。
    属于Data Oriented组件的三种组件的所有组件数据将会分别集中起来,保存在各自的一块连续的地址空间中,具体就是分别保存在三个ArrayBuffer中

  • 将GameObject和各个组件扁平化
    GameObject不再有数据和逻辑了,而只是一个全局唯一的id。组件也不再有数据和逻辑了,其中属于“Data Oriented组件”的组件只是一个ArrayBuffer上的索引;属于“其它组件”的组件只是一个全局唯一的id

  • 增加Component+GameObject这一层,将扁平的GameObject和组件放在该层中

  • 增加Manager这一层,来管理GameObject和组件的数据
    这一层有GameObjectManager和四种组件的Manager,其中GameObjectManager负责管理所有的gameObject;四种组件的Manager负责管理自己的ArrayBuffer,操作属于该种类的所有组件

  • 增加System这一层,来实现行为的逻辑
    一个System实现一个行为,比如这一层中的MoveSystem、FlySystem分别实现了移动和飞行的行为逻辑

值得注意的是:

  • GameObject和组件的数据被移到了Manager中,逻辑则被移到了Manager和System中。其中只操作自己数据的逻辑(如getPosition、setPosition)被移到了Manager中,其它逻辑(通常为行为逻辑,需要操作多种组件)被移到了System中
  • 一种组件的Manager只对该种组件进行操作,而一个System可以对多种组件进行操作

给出UML

领域模型

image

总体来看,领域模型分为五个部分:用户、World、System层、Manager层、Component+GameObject层,它们的依赖关系是前者依赖后者

我们看下用户、World这两个部分:
Client是用户

World是游戏世界,虽然仍然实现了初始化和主循环的逻辑,不过不再管理所有的GameObject了

我们看下System这一层:

有多个System,每个System实现一个行为逻辑。每个System的职责如下:

  • CreateStateSystem实现创建WorldState的逻辑,创建的WorldState包括了所有的Manager的state数据;
  • UpdateSystem实现更新所有人物的position的逻辑,具体是更新所有PositionComponent的position;
  • MoveSystem实现一个人物的移动的逻辑,具体是根据挂载到该人物gameObject上的一个positionComponent和一个velocityComponent,更新该positionComponent的position;
  • FlySystem实现一个人物的飞行的逻辑,具体是根据挂载到该人物gameObject上的一个positionComponent、一个velocityComponent、一个flyComponent,更新该positionComponent的position;
  • RenderOneByOneSystem实现渲染所有超级英雄的逻辑;
  • RenderInstancesSystem实现渲染所有普通英雄的逻辑

我们看下Manager这一层:

每个Manager都有一个state数据

GameObjectManager负责管理所有的gameObject

PositionComponentManager、VelocityComponentManager、FlyComponentManager、InstanceComponentManager负责管理属于各自种类的所有的组件

PositionComponentManager的state数据包括一个buffer字段和一个positions字段,其中前者是一个ArrayBuffer,保存了该种组件的所有组件数据;后者是buffer的视图,用于读写buffer中的position数据

PositionComponentManager的batchUpdate函数负责批量更新所有的positionComponent组件的position

因为VelocityComponentManager、FlyComponentManager与PositionComponentManager一样,都属于Data Oriented组件的Manager,所以它们的数据和函数类似(只是没有batchUpdate函数),故在图中省略它们的数据和函数

我们看下Component+GameObject这一层:

因为PositionComponent、VelocityComponent、FlyComponent属于Data Oriented组件,所以它们是一个index,也就是各自Manager的state的buffer中的索引值

因为InstanceComponent属于其它组件,所以它是一个全局唯一的id

GameObject是一个全局唯一的id

我们来看下依赖关系:

System层:

因为CreateSystem需要调用各个Manager的createState函数来创建它们的state,所以依赖了整个Manager层

因为UpdateSystem需要调用PositionComponentManager的batchUpdate函数来更新,所以依赖了PositionComponentManager

因为MoveSystem需要调用PositionComponentManager来获得和设置position,并且调用VelocityComponentManager来获得velocity,所以依赖了PositionComponentManager、VelocityComponentManager

因为FlySystem需要调用PositionComponentManager来获得和设置position,并且调用VelocityComponentManager、FlyComponentManager来分别获得velocity和maxVelocity,所以依赖了PositionComponentManager、VelocityComponentManager、FlyComponentManager

因为RenderOneByOneSystem和RenderInstancesSystem需要调用GameObjectManager来获得所有的gameObject,并调用各种组件的Manager来获得组件数据,所以依赖了整个Manager层

Manager层:

因为GameObjectManager需要操作GameObject,所以依赖了GameObject

因为各种组件的Manager需要操作所有的该种组件,所以依赖了对应的组件

结合UML图,描述如何具体地解决问题

  • 现在各种组件的数据都集中保存在各自Manager的state的buffer(ArrayBuffer)中,遍历同一种组件的所有组件数据即是遍历一个ArrayBuffer。因为ArrayBuffer的数据是连续地保存在内存中的,所以缓存命中不会丢失,从而提高了性能

  • 现在将涉及多种组件的行为放在对应的System中。因为System很轻,没有数据,只有逻辑,所以增加和维护System的成本较低;另外,因为System位于最上层,所以修改System也不会影响Manager层和Component+GameObject层

给出代码

首先,我们看下Client的代码;
然后,我们看下Client代码中第一步的代码:

  • 创建WorldState的代码

然后,因为创建WorldState时会创建Data Oriented组件的Manager的state,其中的关健是创建各自的ArrayBuffer,所以我们看下创建它的代码;
然后,我们看下Client代码中第二步的代码:

  • 创建场景的代码

然后,因为创建场景时操作了普通英雄和超级英雄,所以我们看下它们的代码,它们包括:

  • 移动的相关代码
  • 飞行的相关代码

然后,我们依次看下Client代码中剩余的两个步骤的代码,它们包括:

  • 初始化和主循环的代码

然后,我们看下主循环的一帧中每个步骤的代码,它们包括:

  • 主循环中更新的代码
  • 主循环中渲染的代码

最后,我们运行Client的代码

Client的代码

Client

let worldState = World.createState({ positionComponentCount: 10, velocityComponentCount: 10, flyComponentCount: 10 })

跟之前一样...

Client的代码跟之前的Client的代码基本一样,除了createState函数的参数和_createScene函数中创建场景的方式不一样,这个等会再讨论

创建WorldState的代码

World

export let createState = CreateStateSystem.createState

CreateStateSystem

export let createState = ({ positionComponentCount, velocityComponentCount, flyComponentCount }): worldState => {
    return {
        gameObjectManagerState: GameObjectManager.createState(),
        positionComponentManagerState: PositionComponentManager.createState(positionComponentCount),
        velocityComponentManagerState: VelocityComponentManager.createState(velocityComponentCount),
        flyComponentManagerState: FlyComponentManager.createState(flyComponentCount),
        instanceComponentManagerState: InstanceComponentManager.createState()
    }
}

CreateStateSystem的createState函数创建了WorldState,它保存了各个Manager的state

因为Data Oriented组件的Manager的state在创建时要创建包括该种组件的所有组件数据的ArrayBuffer,需要知道该种组件的最大个数,所以这里的createState函数接收了三种Data Oriented组件的最大个数

创建ArrayBuffer的代码

我们以PositionComponentManager为例,来看下它的createState函数的相关代码:
position_component/ManagerStateType

export type state = {
    maxIndex: number,
    buffer: ArrayBuffer,
    positions: Float32Array,
    ...
}

这是PositionComponentManager的state的类型定义,它的字段解释如下:

  • buffer字段保存了一个ArrayBuffer,它用来保存所有的positionComponent的数据。目前每个positionComponent的数据只有position,它的类型是三个float
  • positions字段保存了ArrayBuffer的一个视图,通过它可以读写所有的positionComponent的position
  • maxIndex字段是ArrayBuffer上最大的索引值,用于在创建一个positionComponent时生成它的index值

position_component/Manager

let _setAllTypeArrDataToDefault = ([positions]: Array<Float32Array>, count, [defaultPosition]) => {
    range(0, count - 1).forEach(index => {
        OperateTypeArrayUtils.setPosition(index, defaultPosition, positions)
    })

    return [positions]
}

let _initBufferData = (count, defaultDataTuple): [ArrayBuffer, Array<Float32Array>] => {
    let buffer = BufferUtils.createBuffer(count)

    let typeArrData = _setAllTypeArrDataToDefault(CreateTypeArrayUtils.createTypeArrays(buffer, count), count, defaultDataTuple)

    return [buffer, typeArrData]
}

export let createState = (positionComponentCount: number): state => {
    let defaultPosition = [0, 0, 0]

    let [buffer, [positions]] = _initBufferData(positionComponentCount, [defaultPosition])

    return {
        maxIndex: 0,
        buffer,
        positions,
        ...
    }
}

这是PositionComponentManager的createState函数的代码,其中调用的_initBufferData函数创建了buffer和positions,它的步骤如下:
1.调用BufferUtils的createBuffer函数来创建包括最大组件个数的数据的ArrayBuffer
2.调用CreateTypeArrayUtils的createTypeArrays函数来创建所有的TypeArray,它们是操作ArrayBuffer的视图。这里具体是只创建了一个视图:positions
3.调用_setAllTypeArrDataToDefault函数来将positions的所有的值写为默认值:[0,0,0]

下面是BufferUtils的createBuffer函数和CreateTypeArrayUtils的createTypeArrays函数的相关代码:
position_component/BufferUtils

let _getPositionSize = () => 3

export let getPositionOffset = (count) => 0

export let getPositionLength = (count) => count * _getPositionSize()

export let getPositionIndex = index => index * _getPositionSize()

let _getTotalByteLength = (count) => {
    return count * Float32Array.BYTES_PER_ELEMENT * _getPositionSize()
}

export let createBuffer = (count) => {
    return new ArrayBuffer(_getTotalByteLength(count))
}

position_component/CreateTypeArrayUtils

export let createTypeArrays = (buffer, count) => {
    return [
        new Float32Array(buffer, BufferUtils.getPositionOffset(count), BufferUtils.getPositionLength(count))
    ]
}

另外两种Data Oriented组件的Manager(VelocityComponentManager、FlyComponentManager)的createState函数的逻辑跟PositionComponentManager的createState函数的逻辑一样,故省略相关代码

创建场景的代码

Client

let _createScene = (worldState: worldState): worldState => {
    创建normalHero1:
        创建gameObject
        创建positionComponent
        创建velocityComponent
        创建instanceComponent
        挂载positionComponent、velocityComponent、instanceComponent到gameObject

    创建normalHero2

    normalHero1移动:
        调用MoveSystem的move函数,传入normalHero1的positionComponent、velocityComponent

    创建superHero1
        创建gameObject
        创建positionComponent
        创建velocityComponent
        创建flyComponent
        挂载positionComponent、velocityComponent、flyComponent到gameObject

    创建superHero2

    superHero1移动:
        调用MoveSystem的move函数,传入superHero1的positionComponent、velocityComponent
    superHero1飞行:
        调用FlySystem的fly函数,传入superHero1的positionComponent、velocityComponent、flyComponent

    return worldState
}

_createScene函数创建了场景,场景的内容跟之前一样,都包括了2个普通英雄和2个超级英雄,只是现在创建一个英雄的方式又改变了,具体变为:现在不需要加入GameObject到World中

另外,现在改为通过调用MoveSystem和FlySystem的函数来操作对应的组件,从而实现英雄的“移动”、“飞行”

gameObject/Manager

export let createState = (): state => {
    return {
        maxUID: 0
    }
}

//创建一个gameObject
//一个gameObject就是一个uid
export let createGameObject = (state: state): [state, gameObject] => {
    let uid = state.maxUID

    //生成一个uid
    //uid的意思是unique id,即全局唯一的id
    let newUID = uid + 1

    state = {
        ...state,
        maxUID: newUID
    }

    return [state, uid]
}

GameObjectManager的createGameObject函数创建了一个gameObject,它就是一个全局唯一的id

position_component/Manager

//创建一个positionComponent
//一个positionComponent就是一个index
export let createComponent = (state: state): [state, component] => {
    let index = state.maxIndex
        
    //生成一个index
    let newIndex = index + 1

    state = {
        ...state,
        maxIndex: newIndex
    }

    return [state, index]
}

PositionComponentManager的createComponent函数创建了一个positionComponent,它就是一个PositionComponentManager的state的buffer上的索引

VelocityComponentManager、FlyComponentManager的相关代码跟PositionComponentManager类似,故省略相关代码

instance_component/Manager

//创建一个instanceComponent
//一个instanceComponent就是一个uid
export let createComponent = (state: state): [state, component] => {
    let uid = state.maxUID

    //生成一个id
    let newUID = uid + 1

    state = {
        ...state,
        maxUID: newUID
    }

    return [state, uid]
}

InstanceComponentManager的createComponent函数创建了一个instanceComponent,因为InstanceComponent组件属于“其它组件”,所以它跟GameObject一样都是一个全局唯一的id而不是一个index

移动的相关代码

MoveSystem

//一个gameObject的移动
export let move = (worldState: worldState, positionComponent, velocityComponent): worldState => {
    let [x, y, z] = PositionComponentManager.getPosition(worldState.positionComponentManagerState, positionComponent)

    let velocity = VelocityComponentManager.getVelocity(worldState.velocityComponentManagerState, velocityComponent)

    //根据velocity,更新positionComponent的position
    let positionComponentManagerState = PositionComponentManager.setPosition(worldState.positionComponentManagerState, positionComponent, [x + velocity, y + velocity, z + velocity])

    return {
        ...worldState,
        positionComponentManagerState: positionComponentManagerState
    }
}

MoveSystem的move函数实现移动的行为逻辑。这里涉及到读写Data Oriented组件的ArrayBuffer上的数据。我们来看下读写PositionComponentManager的positions的相关代码:
position_component/Manager

export let getPosition = (state: state, component: component) => {
    return OperateTypeArrayUtils.getPosition(component, state.positions)
}

export let setPosition = (state: state, component: component, position) => {
    OperateTypeArrayUtils.setPosition(component, position, state.positions)

    return state
}

position_component/OperateTypeArrayUtils

export let getPosition = (index, typeArr) => {
    return TypeArrayUtils.getFloat3Tuple(BufferUtils.getPositionIndex(index), typeArr)
}

export let setPosition = (index, data, typeArr) => {
    TypeArrayUtils.setFloat3(BufferUtils.getPositionIndex(index), data, typeArr)
}

TypeArrayUtils

export let getFloat3Tuple = (index, typeArray) => {
    return [
        typeArray[index],
        typeArray[index + 1],
        typeArray[index + 2]
    ]
}

export let setFloat3 = (index, param, typeArray) => {
    typeArray[index] = param[0]
    typeArray[index + 1] = param[1]
    typeArray[index + 2] = param[2]
}

position_component/BufferUtils

let _getPositionSize = () => 3

...

export let getPositionIndex = index => index * _getPositionSize()

通过代码可知,实现“读写PositionComponentManager的ArrayBuffer上的数据”的思路是:
因为一个positionComponent的值是ArrayBuffer的索引,所以使用它来读写ArrayBuffer的视图positions中的对应数据

飞行的相关代码

FlySystem

//一个gameObject的飞行
export let fly = (worldState: worldState, positionComponent, velocityComponent, flyComponent): worldState => {
    let [x, y, z] = PositionComponentManager.getPosition(worldState.positionComponentManagerState, positionComponent)

    let velocity = VelocityComponentManager.getVelocity(worldState.velocityComponentManagerState, velocityComponent)

    let maxVelocity = FlyComponentManager.getMaxVelocity(worldState.flyComponentManagerState, flyComponent)

    //根据maxVelociy、velocity,更新positionComponent的position
    velocity = velocity < maxVelocity ? (velocity * 2.0) : maxVelocity
    let positionComponentManagerState = PositionComponentManager.setPosition(worldState.positionComponentManagerState, positionComponent, [x + velocity, y + velocity, z + velocity])

    return {
        ...worldState,
        positionComponentManagerState: positionComponentManagerState
    }

}

FlySystem的fly函数实现了飞行的行为逻辑

初始化和主循环的代码

初始化和主循环的逻辑跟之前一样,故省略代码

主循环中更新的代码

World

export let update = UpdateSystem.update

UpdateSystem

export let update = (worldState: worldState): worldState => {
    let positionComponentManagerState = PositionComponentManager.batchUpdate(worldState.positionComponentManagerState)

    return {
        ...worldState,
        positionComponentManagerState: positionComponentManagerState
    }
}

UpdateSystem的update函数实现了更新,它调用了PositionComponentManager的batchUpdate函数来批量更新所有的positionComponent组件。我们看下PositionComponentManager的相关代码:
position_component/Manager

export let getAllComponents = (state: state): Array<component> => {
    从state中获得所有的positionComponents
}

export let batchUpdate = (state: state) => {
    return getAllComponents(state).reduce((state, component) => {
        更新position
    }, state)
}

batchUpdate函数遍历所有的positionComponent,更新它们的position。更新的逻辑跟之前一样

主循环中渲染的代码

World

export let renderOneByOne = RenderOneByOneSystem.render

RenderOneByOneSystem

export let render = (worldState: worldState): void => {
    let superHeroGameObjects = GameObjectManager.getAllGameObjects(worldState.gameObjectManagerState).filter(gameObject => {
        //判断gameObject是不是没有挂载InstanceComponent
        return !InstanceComponentManager.hasComponent(worldState.instanceComponentManagerState, gameObject)
    })

    superHeroGameObjects.forEach(gameObjectState => {
        console.log("OneByOne渲染 SuperHero...")
    })
}

gameObject/Manager

export let getAllGameObjects = (state: state): Array<gameObject> => {
    let { maxUID } = state

    //返回[0, 1, ...,  maxUID-1]的数组
    return range(0, maxUID - 1)
}

RenderOneByOneSystem的render函数实现了超级英雄的渲染,它首先得到了所有没有挂载InstanceComponent组件的gameObject;最后一个一个地渲染

World

export let renderInstances = RenderInstancesSystem.render

RenderInstancesSystem

export let render = (worldState: worldState): void => {
    let normalHeroGameObejcts = GameObjectManager.getAllGameObjects(worldState.gameObjectManagerState).filter(gameObject => {
        //判断gameObject是不是挂载了InstanceComponent
        return InstanceComponentManager.hasComponent(worldState.instanceComponentManagerState, gameObject)
    })

    console.log("批量Instance渲染 NormalHeroes...")
}

RenderInstancesSystem的render函数实现了普通英雄的渲染,它首先得到了所有挂载了InstanceComponent组件的gameObject;最后一次性批量渲染

运行Client的代码

下面,我们运行Client的代码,打印的结果如下:

初始化...
更新PositionComponent: 0
更新PositionComponent: 1
更新PositionComponent: 2
更新PositionComponent: 3
OneByOne渲染 SuperHero...
OneByOne渲染 SuperHero...
批量Instance渲染 NormalHeroes...
{"gameObjectManagerState":{"maxUID":4},"positionComponentManagerState":{"maxIndex":4,"buffer":{},"positions":{"0":2,"1":2,"2":2,"3":0,"4":0,"5":0,"6":6,"7":6,"8":6,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0},"gameObjectMap":{"0":0,"1":1,"2":2,"3":3},"gameObjectPositionMap":{"0":0,"1":1,"2":2,"3":3}},"velocityComponentManagerState":{"maxIndex":4,"buffer":{},"velocitys":{"0":1,"1":1,"2":1,"3":1,"4":1,"5":1,"6":1,"7":1,"8":1,"9":1},"gameObjectMap":{"0":0,"1":1,"2":2,"3":3},"gameObjectVelocityMap":{"0":0,"1":1,"2":2,"3":3}},"flyComponentManagerState":{"maxIndex":1,"buffer":{},"maxVelocitys":{"0":10,"1":10,"2":10,"3":10,"4":10,"5":10,"6":10,"7":10,"8":10,"9":10},"gameObjectMap":{"0":2,"1":3},"gameObjectFlyMap":{"2":0,"3":1}},"instanceComponentManagerState":{"maxUID":1,"gameObjectMap":{"0":0,"1":1},"gameObjectInstanceMap":{"0":0,"1":1}}}

通过打印的数据,可以看到运行的步骤与之前一样
不同之处在于:

  • 打印的WorldState不一样

我们看下打印的WorldState:

  • WorldState的gameObjectManagetState的maxUID为4,说明创建了4个gameObject;
  • WorldState的positionComponentManagerState的maxIndex为4,说明创建了4个positionComponent;
  • WorldState的positionComponentManagerState的positions有3个连续的值是2、2、2,说明有一个positionComponent组件进行了移动操作;有另外3个连续的值是6、6、6,说明有另外一个positionComponent组件进行了移动操作和飞行操作;

定义

一句话定义

组合代替继承;连续地保存组件数据;分离逻辑和数据

补充说明

“组合代替继承”是指基于组件化思想,通过GameObject组合不同的组件代替GameObject层层继承

“连续地保存组件数据”是指基于Data Oriented思想,将Data Oriented组件的组件数据集中起来,保存在内存中连续的地址空间

“分离逻辑和数据”是指将GameObject和组件扁平化,将它们的数据放到Manager层,将它们的逻辑放到System层和Manager层。其中,只操作自己数据的逻辑(如getPosition、setPosition)被移到了Manager中,其它逻辑(通常为行为逻辑,需要操作多种组件)被移到了System中

通用UML

领域模型

image

总体来看,领域模型分为用户、World、System层、Manager层、Component+GameObject层五个部分,它们的依赖关系是前者依赖后者。其中,System层负责实现行为的逻辑;Manager层负责管理场景数据,即管理GameObject和组件的数据;Component+GameObject层为组件和GameObject,它们现在只是有一个number类型的数据的值对象

我们看下用户这个部分:

  • Client
    该角色是用户

我们看下World这个部分:

  • World
    该角色是门户,提供了API,实现了初始化和主循环的逻辑

我们看下System这一层:

一个System实现一个行为的逻辑

  • CreateStateSystem
    该角色负责创建WorldState

  • OtherSystem
    该角色是除了CreateStateSystem以外的所有System,它们各自有一个函数action,用于实现某个行为

我们看下Manager这一层:

每个Manager都有一个state数据

  • GameObjectManager
    该角色负责管理所有的gameObject

  • DataOrientedComponentManager
    该角色是一种Data Oriented组件的Manager,负责维护和管理该种组件的所有组件数据,将其集中地保存在各自的state的buffer中
    DataOrientedComponentManager的state数据包括一个buffer字段和多个“typeArray of buffer”字段,其中前者是一个ArrayBuffer,保存了该种组件的所有组件数据;后者是buffer的多个视图,用于读写buffer中对应的数据

  • OtherComponentManager
    该角色是一种其它组件的Manager,负责维护和管理该种组件的所有组件数据
    OtherComponentManager的state数据包括多个“value map”字段,它们是多个Hash Map,每个Hash Map保存一类组件数据

我们看下Component+GameObject这一层:

  • DataOrientedComponent
    该角色是一种Data Oriented组件中的一个组件,它是一个ArrayBuffer上的索引

  • OtherComponent
    该角色是一种其它组件中的一个组件,它是一个全局唯一的id

  • GameObject
    该角色是一个gameObject,它是一个全局唯一的id

角色之间的关系

  • 只有一个CreateStateSystem

  • 可以有多个OtherSystem,每个OtherSystem实现一个行为

  • 只有一个GameObjectManager

  • 可以有多个DataOrientedComponentManager,每个对应一种Data Oriented组件

  • 可以有多个DataOrientedComponent,每个对应一种Data Oriented组件

  • 可以有多个OtherComponentManager,每个对应一种其它组件

  • 可以有多个OtherComponent,每个对应一种其它组件

System层:

  • 因为CreateSystem需要调用各个Manager的createState函数来创建它们的state,所以依赖了整个Manager层
  • 因为OtherSystem可能需要调用1个GameObjectManager来处理gameObject、调用多个DataOrientedComponentManager和OtherComponentManager来处理各自种类的组件,所以它与GameObjectManager是一对一的依赖关系,与DataOrientedComponentManager和OtherComponentManager都是一对多的依赖关系

Manager层:

  • 因为GameObjectManager需要操作多个GameObject,所以它与GameObject是一对多的依赖关系
  • 因为DataOrientedComponentManager和DataOrientedComponent对应同一种Data Oriented组件,且前者管理所有的后者,所以前者和后者是一对多的依赖关系

  • 因为OtherComponentManager和OtherComponent对应同一种其它组件,且前者管理所有的后者,所以前者和后者是一对多的依赖关系

Component+GameObject层:

  • 因为一个GameObject可以挂载各种组件,其中每种组件只能挂载一个,所以GameObject与DataOrientedComponent、OtherComponent都是一对一的组合关系

角色的抽象代码

下面我们来看看各个角色的抽象代码:

我们按照依赖关系,从上往下依次看下领域模型中用户、World、System层、Manager层、Component+GameObject层这五个部分的抽象代码:

首先,我们看下属于用户的抽象代码
然后,我们看下World的抽象代码
然后,我们看下System层的抽象代码,它们包括:

  • CreateStateSystem的抽象代码
  • OtherSystem的抽象代码

然后,我们看下Manager层的抽象代码,它们包括:

  • GameObjectManager的抽象代码
  • DataOrientedComponentManager的抽象代码
  • OtherComponentManager的抽象代码

最后,我们看下Component+GameObject层的抽象代码,它们包括:

  • GameObject的抽象代码
  • DataOrientedComponent的抽象代码
  • OtherComponent的抽象代码

用户的抽象代码

Client

let _createScene = (worldState: worldState): worldState => {
    创建gameObject1
    创建组件
    挂载组件

    触发gameObject1的行为

    创建更多的gameObjects...

    return worldState
}

let worldState = World.createState({ dataOrientedComponent1Count: xx })

worldState = _createScene(worldState)

worldState = World.init(worldState)

World.loop(worldState)

World的抽象代码

World

export let createState = CreateStateSystem.createState

export let action1 = OtherSystem1.action

export let init = (worldState: worldState): worldState => {
    初始化...

    return worldState
}


//假实现
let requestAnimationFrame = (func) => {
}


export let loop = (worldState: worldState) => {
    调用OtherSystem来更新

    调用OtherSystem来渲染

    requestAnimationFrame(
        (time) => {
            loop(worldState)
        }
    )
}

CreateStateSystem的抽象代码

CreateStateSystem

export let createState = ({ dataOrientedComponent1Count }): worldState => {
    return {
        gameObjectManagerState: GameObjectManager.createState(),
        dataOrientedComponent1ManagerState: DataOrientedComponent1Manager.createState(dataOrientedComponent1Count),
        otherComponent1ManagerState: OtherComponent1Manager.createState(),

        创建更多的DataOrientedManagerState和OtherComponentManagerState...
    }
}

OtherSystem的抽象代码

OtherSystem1

export let action = (worldState: worldState, gameObject?: gameObject, dataOrientedComponentX?: dataOrientedComponentX, otherComponentX?: otherComponentX) => {
    行为的逻辑...

    return worldState
}

有多个OtherSystem,这里只给出一个OtherSystem的抽象代码

GameObjectManager的抽象代码

gameObject/ManagerStateType

export type state = {
    maxUID: number
}

gameObject/Manager

export let createState = (): state => {
    return {
        maxUID: 0
    }
}

export let createGameObject = (state: state): [state, gameObject] => {
    let uid = state.maxUID

    let newUID = uid + 1

    state = {
        ...state,
        maxUID: newUID
    }

    return [state, uid]
}

export let getAllGameObjects = (state: state): Array<gameObject> => {
    let { maxUID } = state

    return range(0, maxUID - 1)
}

DataOrientedComponentManager的抽象代码

dataoriented_component1/ManagerStateType

export type TypeArrayType = Float32Array | Uint8Array | Uint16Array | Uint32Array

export type state = {
    maxIndex: number,
    //buffer保存了该种组件所有的value1、value2、...、valueX数据
    buffer: ArrayBuffer,
    //该种组件所有的value1数据的视图
    value1s: TypeArrayType,
    //该种组件所有的value2数据的视图
    value2s: TypeArrayType,
    更多valueXs...,

    ...
}

dataoriented_component1/Manager

let _setAllTypeArrDataToDefault = ([value1s, value2s]: Array<Float32Array>, count, [defaultValue1, defaultValue2]) => {
    range(0, count - 1).forEach(index => {
        OperateTypeArrayUtils.setValue1(index, defaultValue1, value1s)
        OperateTypeArrayUtils.setValue2(index, defaultValue2, value2s)
    })

    return [value1s, value2s]
}

let _initBufferData = (count, defaultDataTuple): [ArrayBuffer, Array<TypeArrayType>] => {
    let buffer = BufferUtils.createBuffer(count)

    let typeArrData = _setAllTypeArrDataToDefault(CreateTypeArrayUtils.createTypeArrays(buffer, count), count, defaultDataTuple)

    return [buffer, typeArrData]
}

export let createState = (dataorientedComponentCount: number): state => {
    let defaultValue1 = default value1
    let defaultValue2 = default value2

    let [buffer, [value1s, value2s]] = _initBufferData(dataorientedComponentCount, [defaultValue1, defaultValue2])

    return {
        maxIndex: 0,
        buffer,
        value1s,
        value2s,
        ...
    }
}

export let createComponent = (state: state): [state, component] => {
    let index = state.maxIndex

    let newIndex = index + 1

    state = {
        ...state,
        maxIndex: newIndex
    }

    return [state, index]
}

...

export let getAllComponents = (state: state): Array<component> => {
    从state中获得所有的dataorientedComponent1s
}

export let getValue1 = (state: state, component: component) => {
    return OperateTypeArrayUtils.getValue1(component, state.value1s)
}

export let setValue1 = (state: state, component: component, position) => {
    OperateTypeArrayUtils.setValue1(component, position, state.value1s)

    return state
}

get/set value2...

export let batchOperate = (state: state) => {
    let allComponents = getAllComponents(state)

    console.log("批量操作")

    return state
}

dataoriented_component1/BufferUtils

// 这里只给出了两个value的情况
// 更多的value也以此类推...

let _getValue1Size = () => value1 size

let _getValue2Size = () => value2 size

export let getValue1Offset = () => 0

export let getValue2Offset = (count) => getValue1Offset() + getValue1Length(count) * TypeArray2.BYTES_PER_ELEMENT

export let getValue1Length = (count) => count * _getValue1Size()

export let getValue2Length = (count) => count * _getValue2Size()

export let getValue1Index = index => index * _getValue1Size()

export let getValue2Index = index => index * _getValue2Size()

let _getTotalByteLength = (count) => {
    return count * (TypeArray1.BYTES_PER_ELEMENT * (_getValue1Size() + TypeArray2.BYTES_PER_ELEMENT * (_getValue2Size())))
}

export let createBuffer = (count) => {
    return new ArrayBuffer(_getTotalByteLength(count))
}

dataoriented_component1/CreateTypeArrayUtils

export let createTypeArrays = (buffer, count) => {
    return [
        new Float32Array(buffer, BufferUtils.getValue1Offset(), BufferUtils.getValue1Length(count)),
        new Float32Array(buffer, BufferUtils.getValue2Offset(count), BufferUtils.getValue2Length(count)),
    ]
}

有多个DataOrientedComponentManager,这里只给出一个DataOrientedComponentManager的抽象代码

OtherComponentManager的抽象代码

other_component1/ManagerStateType

export type state = {
    maxUID: number,
    //value1Map用来保存该种组件所有的value1数据
    value1Map: Map<component, value1 type>,
    更多valueXMap...,

    ...
}

other_component1/Manager

export let createState = (): state => {
    return {
        maxUID: 0,
        value1Map: Map(),
        ...
    }
}

export let createComponent = (state: state): [state, component] => {
    let uid = state.maxUID

    let newUID = uid + 1

    state = {
        ...state,
        maxUID: newUID
    }

    return [state, uid]
}

...

export let getAllComponents = (state: state): Array<component> => {
    从state中获得所有的otherComponent1s
}

export let getValue1 = (state: state, component: component) => {
    return getExnFromStrictUndefined(state.value1Map.get(component))
}

export let setValue1 = (state: state, component: component, value1) => {
    return {
        ...state,
        value1Map: state.value1Map.set(component, value1)
    }
}

export let batchOperate = (state: state) => {
    let allComponents = getAllComponents(state)

    console.log("批量操作")

    return state
}

有多个OtherComponentManager,这里只给出一个OtherComponentManager的抽象代码

GameObject的抽象代码

GameObjectType

type id = number

export type gameObject = id

DataOrientedComponent的抽象代码

DataOrientedComponent1Type

type index = number

export type component = index

有多个DataOrientedComponent,这里只给出一个DataOrientedComponent的抽象代码

OtherComponent的抽象代码

OtherComponent1Type

type id = number

export type component = id

有多个OtherComponent,这里只给出一个OtherComponent的抽象代码

遵循的设计原则在UML中的体现

ECS模式主要遵循下面的设计原则:

  • 单一职责原则
    每个System只实现一个行为;每个组件的Manager只管理一种组件
  • 合成复用原则
    GameObject组合了多个组件
  • 接口隔离原则
    GameObject和组件经过了扁平化处理,移除了数据和逻辑,改为只是有一个number类型数据的值对象
  • 最少知识原则
    World、System、Manager、Component+GameObject这几个层只能上层依赖下层,不能跨层依赖
  • 开闭原则
    要增加一种行为,只需要增加一个System,不会影响Manager

应用

优点

  • 组件的数据集中连续地保存在ArrayBuffer中,增加了缓存命中,提高了读写的性能

  • 创建和删除组件的性能也很好,因为在这个过程中不会分配或者销毁内存,所以没有垃圾回收的开销
    这是因为在创建ArrayBuffer时就预先按照最大组件个数分配了一块连续的内存,所以在创建组件时,只是返回一个当前最大索引(maxIndex)的值而已;在删除组件时,只是将ArrayBuffer中该组件对应的数据还原为默认值而已

  • 职责划分明确,行为的逻辑应该放在哪里很清楚
    对于只涉及到操作一种组件的行为逻辑,则将其放在该组件对应的Manager(如将batchUpdate position的逻辑放到PositionComponentManager的batchUpdate函数中);涉及到多种组件的行为逻辑则放在对应的System中(如将飞行行为放到FlySystem中);

  • 增加行为很容易
    因为一个行为对应一个System,所以要增加一个行为,则只需增加一个对应的System即可,这不会影响到Manager。另外,因为System只有逻辑没有数据,所以增加和维护System很容易

缺点

  • 需要转换为函数式编程的思维
    习惯面向对象编程的同学倾向于设计一个包括数据和逻辑的组件类,而ECS模式则将其扁平化为一个值对象,这符合函数式编程中一切都是数据的思维模式。
    另外,ECS中的System其实就只是一个函数而已,本身没有数据,这也符合函数式编程中函数是第一公民的思维模式。
    终上所述,如果使用函数式编程范式的同学能够更容易地使用ECS模式

使用场景

场景描述

游戏的场景中有很多种类的人物,人物的行为很多或者很复杂

具体案例

  • 有很多种类的游戏人物
    通过挂载不同的组件到GameObject,来实现不同种类的游戏人物,代替继承

  • 游戏人物有很多的行为,而且还经常会增加新的行为
    因为每个行为对应一个System,所以增加一个新的行为就是增加一个System。不管行为如何变化,只影响System层,不会影响作为下层的Manager层和GameObject、Component层

  • 对于引擎而言,ECS模式主要用在场景管理这块

注意事项

  • 因为组件的ArrayBuffer一旦在创建后,它的大小就不会改动,所以最好在创建时指定足够大的最大组件个数

结合其它模式

结合多线程模式

如果引擎开了多个线程,那么可以将组件的ArrayBuffer改为SharedArrayBuffer。这样的话就可以将其直接共享到各个线程中而不需要拷贝,从而提高了性能

结合管道模式

如果引擎使用了管道模式,那么可以去掉System,而使用管道的Job来代替。其中一个Job就是一个System

另外,可以去掉WorldState,而使用PipelineManagerState来代替

最佳实践

哪些场景不需要使用模式

如果游戏的人物种类很少,行为简单,那么就可以使用最开始给出的人物模块的方案,即使用一个人物模块对应一种人物,并通过继承实现多种人物,这样最容易实现

更多资料推荐

ECS的概念最先是由“守望先锋”游戏的开发者提出的,详细资料可以在网上搜索“《守望先锋》架构设计和网络同步”

ECS模式是在“组件化”、“Data Oriented”基础上发展而来,可以在网上搜索更多关于“组件化”、“Data Oriented”、“ECS”的资料

欢迎阅读本系列文章!我将带你一起探索如何利用OpenAI API开发GPT应用。无论你是编程新手还是资深开发者,都能在这里获得灵感和收获。

本文,我们将继续展示聊天API中插件的使用方法,让你能够轻松驾驭这个强大的工具。

插件运行效果

首先给大家展示下插件的运行效果,如下图所示:

可以看到,每次询问GPT,它都会返回指定城市的实时天气信息,这个天气是真实的,不是GPT瞎编的,是GPT通过一个实时天气插件查询到的。

插件运行原理

知己知彼,百战不殆!首先让我们来了解下插件的运行原理。如下图所示:

首先我们在客户端发起一个聊天会话,比如询问GPT:“今天天气怎么样?”

为了使用我们自己的插件,我们还需要告诉GPT有哪些插件可用,目前这需要我们在发起聊天时传递一个支持的插件列表给GPT。

然后GPT收到我们的聊天后,它会根据用户的聊天内容去匹配插件,并在返回的消息中指示命中了哪些插件,这个匹配是根据GPT的语言理解能力做出的。

然后客户端就可以检查命中了哪些插件,并调用执行本地相应的插件方法。插件方法是在本地执行的,这也比较合理,如果放到GPT服务端,GPT不仅要适配各种计算能力,还可能面临巨大的安全风险。

然后客户端将插件的执行结果附加到本次聊天会话中,再次发起聊天请求,GPT收到后,会根据首次聊天请求和插件生成的内容组织本次聊天响应结果,再返回给用户。

这样就完成了一次基于插件的GPT会话。

插件使用示例

基于上面的运行原理,我们来编写一个GPT插件的示例程序。

在这个示例程序中,我将提供一个天气查询的插件,当用户询问GPT今天的天气时,GPT就会命中这个插件,然后插件会调用外部API获取实时的天气情况,最后GPT会使用插件生成的结果组织一段文字回复返回给用户。

编写天气插件

这里我们将使用“心知天气”提供的免费天气查询服务,大家感兴趣的可以去这里注册个账号:
https://www.seniverse.com/
,注册成功后,需要复制账号的私钥,调用天气接口时会用到。

然后我们就可以编写天气查询插件了,这里直接给出我的代码:

def get_city_weather(param):
    city = json.loads(param)["city"]
    params = {
        "key": "这里换成你的天气产品私钥",
        "location": city,
        "language": "zh-Hans",
        "unit": "c",
    }
    url = "https://api.seniverse.com/v3/weather/now.json"
    r = requests.get(url, params=params)
   
    data = r.json()["results"]
    address = data[0]["location"]['path']
    temperature = data[0]['now']["temperature"]
    text = data[0]['now']["text"]
    return address+",当前天气:"+text+",温度:"+temperature+ "℃"

可以看到就是一个Python函数,接收json格式的参数,返回天气描述信息。

注意这里的参数格式(包括有哪些参数)是和GPT大模型匹配过的,下文会讲到怎么定义参数。

接口的主要逻辑就是使用城市名称,调用实时天气接口获取天气信息,然后再拼接成一段话并返回。

我这里只使用了天气的部分指标,详细指标大家可以看接口文档:

发起带插件的聊天

话不多说,看代码:

client = OpenAI(api_key='sk-xxx')

# 聊天消息上下文
messages=[{
    "role": "user",
    "content": "请问现在天气怎么样?",
}]

# 天气插件
weather_tool = {
        "type": "function",
        "function": {
            "name": "get_city_weather",
            "description": "获取某个城市的天气",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "城市名称",
                    },
                },
                "required": ["city"],
            },
        }
    }

# 发起聊天请求
response = client.chat.completions.create(
    messages=messages,
    model='gpt-3.5-turbo-1106',
    stream=False,
    # 插件相关
    tool_choice="auto",
    tools=[weather_tool]
)

在上面这段代码中,我们首先声明了一个OpenAI客户端,没有API Key的同学可以看文章最后。

然后我们创建了一个很普通的聊天会话,就是以普通用户的身份询问GPT今天的天气情况。

然后我们定义了一个天气插件,其实就是一个Json对象。

  • type:目前只能传 fucntion,也就是说目前插件就是外置函数。
  • function:函数的定义。
    • name:函数的名称,这里就是我们上边定义的 get_city_weather。
    • description:函数的描述,GPT将使用这个描述来决定什么时候以及如何调用函数。
    • parameters:函数的参数。
      • type:固定object
      • properties:定义函数的各个参数,每个参数包含两个属性:type和description,description也很重要,让GPT模型知道怎么来提供这个参数。
      • required:数组,定义必填的参数。

最后我们向GPT发起本次聊天请求,其中增加了关于插件的两个参数:

  • tool_choice:开启插件,固定值 auto,设置为none则不使用插件。
  • tools:插件列表,包含我们上边定义的 weather_tool 插件。

处理插件命中

如果GPT大模型命中了插件,它会在返回值中携带一些信息。根据这些信息,我们可以知道要调用哪个插件的函数,然后再把函数的执行结果附加到消息上下文中,再请求GPT大模型,GPT大模型会使用函数返回值组织文本内容,最终返回给用户。

相关代码如下:

response_message = response.choices[0].message
if response_message.tool_calls is not None:
    tool_call = response_message.tool_calls[0]
    messages.append(response_message)
    messages.append({
        "role": "tool",
        "content": get_city_weather(tool_call.function.arguments),
        "tool_call_id": tool_call.id
    })

    response = client.chat.completions.create(
        messages=messages,
        model='gpt-3.5-turbo-1106',
        stream=False,
        tool_choice="auto",
        tools=[weather_tool]
	)

    print(response_message.choices[0].message.content)

判断是否命中插件使用的是 response_message.tool_calls is not None,也就是返回值中的 tool_calls 不为空,因为这里只有一个插件,所以我们没有做进一步的判断;如果有多个插件,可以遍历tool_calls,根据插件关联函数的 name,选择执行不同的方法。

注意这里我们把本次响应的消息又追加到了上下文中:messages.append(response_message)。

然后我们又追加了插件生成的消息,就是下面这段:

messages.append({
        "role": "tool",
        "content": get_city_weather(tool_call.function.arguments),
        "tool_call_id": tool_call.id
    })

介绍下这几个字段:

  • role:指定这个消息来自插件。
  • content:指定消息的内容。get_city_weather 就是我们上边定义的插件方法,而它的参数 tool_call.function.arguments 则是大模型生成的 ,这个方法会在在本地执行,并生成一段天气信息描述。
  • tool_call_id:这段消息关联的插件id,需要让大模型了解这个数据关系。

然后我们又通过 client.chat.completions.create 向GPT大模型发起请求 ,并拿到最终的返回结果。

完整的代码示例

因为上文中两次请求GPT大模型的方法都是一样的,所以我们这里把它抽象为一个方法。

另外为了充分展现插件的使用方法,这里会向GPT询问三个城市的天气信息,通过循环发起。

from openai import OpenAI
import json
import requests
import time

# 获取天气的方法
def get_city_weather(param):
    city = json.loads(param)["city"]
    params = {
        "key": "这里换成你的天气产品私钥",
        "location": city,
        "language": "zh-Hans",
        "unit": "c",
    }
    url = "https://api.seniverse.com/v3/weather/now.json"
    r = requests.get(url, params=params)
    
    data = r.json()["results"]
    #print(json.dumps(data))
    address = data[0]["location"]['path']
    temperature = data[0]['now']["temperature"]
    text = data[0]['now']["text"]
    return address+",当前天气:"+text+",温度:"+temperature+ "℃"
          
# 天气插件的定义
weather_tool = {
        "type": "function",
        "function": {
            "name": "get_city_weather",
            "description": "获取某个城市的天气",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "城市名称",
                    },
                },
                "required": ["city"],
            },
        }
    }

# 创建OpenAI客户端,获取API Key请看文章最后
client = OpenAI(api_key='sk-xxx')

# 定义请求GPT的通用方法
def create_completion():
    return client.chat.completions.create(
        messages=messages,
        model='gpt-3.5-turbo-1106',
        stream=False,
        tool_choice="auto",
        tools=[weather_tool]
    )


# 我的三个问题
questions = ["请问现在天气怎么样?","请问上海天气怎么样?","请问广州天气怎么样?"]

# 聊天上下文,初始为空
messages=[]

print("---GPT天气插件演示--- ")

# 遍历询问我的问题
for question in questions:  
   
    # 将问题添加到上下文中
    messages.append({
        "role": "user",
        "content": question,
    })
    print("路人甲: ",question)
    
    # 请求GPT,并拿到响应
    response_message = create_completion().choices[0].message
    # 把响应添加到聊天上下文中
    messages.append(response_message)
    #print(response_message)
    # 根据插件命中情况,执行插件逻辑
    if response_message.tool_calls is not None:
        tool_call = response_message.tool_calls[0]
        #print("tool_call: ",tool_call.id)
        # 追加插件生成的天气内容到聊天上下文
        weather_info = get_city_weather(tool_call.function.arguments)
        #print(weather_info)
        messages.append({
            "role": "tool",
            "content": weather_info,
            "tool_call_id": tool_call.id
        })
        # 再次发起聊天
        second_chat_completion = create_completion()
        gpt_output = second_chat_completion.choices[0].message.content
        # 打印GPT合成的天气内容
        print("GPT: ",gpt_output)
        time.sleep(0.2)
        # 将GPT的回答也追加到上下文中
        messages.append({
            "role": "assistant",
            "content": gpt_output,
        })


以上就是本文的主要内容,有没有感受到插件的强大能力!

后续我还会继续分享图片、语音、文档助手等API的使用方法。

如需GPT账号、学习陪伴群、AI编程训练营,推荐关注小册:
大模型应用开发 | API 实操

关注萤火架构,加速技术提升!