前端ECS简介

news2025/7/14 8:51:16

ECS概念

ECS是一种软件架构模式,常见于游戏业务场景,其主要对象分类为

• Entity 实体,ECS架构中所有的业务对象都必须拥有一个唯一的Entity实体

• Component 组件,存储着数据结构,对应着某一种业务属性,一个Entity上可以动态挂载多个Component

• System 系统,负责主要的逻辑处理,每个System负责处理特定的Component的逻辑

快速对比下OOP

ECSOOP
数据和逻辑分离数据和逻辑耦合
Entity 可动态挂载多个ComponentObject Instance 一般是固定的某个Class的示例
Entity 做唯一性区分通过指针/引用/id/uuid 做唯一区分
函数通过Entity是否挂载指定的Component 做非法检验编译阶段的静态类型校验通过instanceof 或 type/sort 相关字段区分
World 存储所有的Entity, 并提供 Entity/Component 检索的方法未做明确要求,由开发者根据业务特性自行设计管理存储Object Instatnce的结构。理论上也可以仿照World的概念设计
System 不建议直接相互调用,通过修改/添加/删除Entity的Component,或者利用单例组件,来延迟处理未做明确要求,业务逻辑可能会相互调用,或者通过事件等机制触发

一个ECS架构实现的粒子碰撞动画

下图是一个基于ECS风格的项目demo效果,在一个画布中存在若干个运动的方块,这些方块会在触碰到墙块以及相互碰撞的时候,改变运动方向,不停循环

动图封面

这个demo的ECS框架图如下

从0实现demo

ECS 是一种通过将数据和逻辑分离,以达到提高代码复用性的设计方法。其设计理念中存在3个主要的概念

• entity,唯一性标识

• component, 某种结构的纯数据

• system, 处理挂载了指定种类的 component 的 entity

这里我们利用一个ECS框架快速实现demo,主要用到下面几个API

type EntityId = number;
interface World {
    // 创建 Entity 
    createEntity():EntityId;
    // 给某个 Entity 动态挂载Component
    addEntityComponents(entity: EntityId, ...components: Component[]): World;
    // 添加某个System
    addSystem(system: System): void;
    // 查询指定Component 的 所有Entity
    view<CC extends ComponentConstructor[]>(...componentCtors: CC): Array<[EntityId, Map<ComponentConstructor, Component>]>;
}

下面我们一步步实现demo

绘制静止方块

首先我们定义3个组件和1个系统

• Position 位置组件

• Rect 方块大小组件

• Color 颜色组件

• RenderSystem 绘制系统

export class Position extends Component {
  constructor(public x = 0, public y = 0) {
    super();
  }
}

export class Rectangle extends Component {
  constructor(public readonly width: number, public readonly height: number) {
    super();
  }
}

export class Color extends Component {
  constructor(public color: string) {
    super();
  }
}

export class RenderingSystem extends System {
  constructor(private readonly context: CanvasRenderingContext2D) {
    super();
  }

  public update(world: World, _dt: number): void {
    this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);

    for (const [entity, componentMap] of world.view(Position, Color, Rectangle)) {
      const { color } = componentMap.get(Color);
      const { width, height } = componentMap.get(Rectangle);
      const { x, y } = componentMap.get(Position);

      this.context.fillStyle = color;
      this.context.fillRect(x, y, width, height);
    }
  }
}

再执行主逻辑,创建若干Entity,并挂载Component

const world = new World();
world.addSystem(new RenderingSystem(canvas.getContext('2d')));
// 添加若干方块
for (let i = 0; i < 100; ++i) {
  world.addEntityComponents(
    world.createEntity(),
    new Position(
      getRandom(canvas.width - padding * 2, padding * 2),
      getRandom(canvas.height - padding * 2, padding * 2),
    ),
    new Color(
      `rgba(${getRandom(255, 0)}, ${getRandom(255, 0)}, ${getRandom(
        255,
        0,
      )}, 1)`,
    ),
    new Rectangle(getRandom(20, 10), getRandom(20, 10)),
  );
}

给方块增加速度

动图封面

让方块动起来,这里我们新增1个组件和1个系统

• Velocity 速度组件

• PhysicsSystem 位置更新系统

export class Velocity extends Component {
  constructor(public x = 0, public y = 0) {
    super();
  }
}

export class PhysicsSystem extends System {
  constructor() {
    super();
  }

  update(world: World, dt: number) {
    for (const [entity, componentMap] of world.view(Position, Velocity, Rectangle)) {
      // Move the position by some velocity
      const position = componentMap.get(Position);
      const velocity = componentMap.get(Velocity);

      position.x += velocity.x * dt;
      position.y += velocity.y * dt;
    }
  }
}

增加碰撞包围盒

动图封面

为了不让方块跑出画布,我们新增如下组件和系统

• Collision 碰撞组件

• CollisionSystem 碰撞系统

export class CollisionSystem extends System {
    update(world: World): void {
      for (const [entity1, components1] of world.view(
        Position,
        Velocity,
        Rectangle,
      )) {
        for (const [entity2, components2] of world.view(
          Collision,
          Rectangle,
          Position,
        )) {
          // 1. 检测每个方块与碰撞方块是否碰撞
          // 2. 判断碰撞方向
          // 3. 根据碰撞方向修改方块速度
        }
      }
    }
  }

在主逻辑中,我们增加4个方块围着画布,并给予红色,形成一个包围盒

const padding = 10;
// left
world.addEntityComponents(
  world.createEntity(),
  new Position(0, 0),
  new Velocity(0, 0),
  new Color(`rgba(${255}, ${0}, ${0}, 1)`),
  new Rectangle(padding, canvas.height),
  new Collision(),
);
// top xx
// right xx
// bottom xx

增加更多的碰撞

在之前的代码基础我们再略微修改,就可实现最终效果

1. 增加若干碰撞方块

动图封面

2. 再给每个运动方块也增加碰撞属性,相互碰撞

动图封面

当前目前这里碰撞系统代码比较简陋,后续还有利用四叉树提高碰撞效率,根据方块大小考虑动量守恒等等改进

以一个简单例子对比OOP和ECS

解题思路千千万,这里仅仅是做简单的演示,表达一下感觉,不必过分纠结

题目

假设现在要做一个动物竞技会的游戏,开发要求是有形形色色种类的动物,和形形色色的竞技项目,要求每种动物都要去参加它能参加的竞技项目并得到成绩

以OOP的类和继承的思路,ECS组件的思路对比下架构设计

OOP 两种思路

如图所示,常规的思路是定义好动物和项目的基类及其子类,在子类中描述了动物拥有的能力和项目要求的能力,那么下一步就是处理业务的主逻辑,这里展示两个常规思路

运动员注册制

创建一个动物的时候,将其适合的运动注册一个运动员身份,并保存运动员索引列表,比赛的时候根据运动员索引表将对应项目的运动员索引出来

// 创建随机的动物
function createAnimals(): Animal[] {
  // xxxx
}
class CompetionList {
// 某个动物注册运动员身份
 registerSporter(animal: Animal) {}
// 注册运动项目
 registerCompetion(competion: Competion){}
 // 获取所有竞技的成绩
 run(){
   this.competions.forEach(competion=>{
       const scores = competion.sporters.map(xxx);
   })
 }
}

const competionList = new CompetionList();
// 注册竞技项目
competionList.registerCompetion(xxx)
// 注册运动员
const animals = createAnimals();
animals.forEach((a) => {
  competionList.registerSporter(a);
});
// 比赛结果
competionList.run()

现场报名制

将所有的动物混编,举行项目比赛的时候,按照要求实时查询,而这一思路便和ECS比较像

  function createAnimals(): Animal[] {
    //xxx;
  }
  function canItRun() {}
  function canItSwim() {}
  function canItClimb() {}

  class AnimalsList {
      find(canItXX:(animal:Animal)=>boolean):Animal[]{}
  }
  class CompetionList {
    // 动物报名
     registerAnimal(animal: Animal) {}
    // 注册运动项目
     registerCompetion(competion: Competion){}
     // 获取所有竞技的成绩
     run(animals: AnimalsList){
         this.competions.forEach(competion=>{
             animals.find(competion.canItXX).map(xxx)
         })
     }
  }

这里对比下两者的优劣势

运动员注册现场报名
选手固定时1. 存在缓存,运动员不发生改变的情况下不会产生新的运算1. 运动员不改变时,也需要每次查询消耗性能
选手变化时1. 运动员/项目发生任何变化的时候,都需要更新运动员注册表1. 不影响主逻辑2. 用运行时的一些性能损耗,提高主逻辑的兼容性,减少后续的开发成本

ECS

相关示例代码(以某个ECS框架为例)

import {
  System,
  World,
  Component,
  ComponentConstructor,
} from '@jakeklassen/ecs';

// 设计组件
class Animal extends Component {}
class RunAbility extends Component {
  constructor(public speed: number) {
    super();
  }
}
class SwimAbility extends Component {
  constructor(public speed: number) {
    super();
  }
}

class CompetionComponent extends Component {
  constructor(
    public name: string,
    public canItXX: ComponentConstructor[],
    public getScoreFunc: any,
    public scoreMap: Map<number, number>,
  ) {
    super();
  }
}
// 设计比赛的系统
class CompetionSystem extends System {
  update(world: World) {
    for (const [entityCompetion, componentsCompetion] of world.view(
      CompetionComponent,
    )) {
      const competion = componentsCompetion.get(CompetionComponent);
      for (const [entityAnimal, componentsAnimal] of world.view(
        ...competion.canItXX,
        Animal,
      )) {
        // 计算分数
        const score = competion.getScoreFunc(componentsAnimal);
        competion.scoreMap.set(entityAnimal, score);
      }
    }
  }
}

// 创建world
const world = new World();
// 添加比赛系统
world.addSystem(new CompetionSystem());
// 添加项目组件
world.addEntityComponents(
  world.createEntity(),
  new CompetionComponent(
    '百米跑',
    [RunAbility],
    (animalComponents: any) => {
      return 100 / animalComponents.get(RunAbility).speed;
    },
    new Map<number, number>(),
  ),
);
world.addEntityComponents(
  world.createEntity(),
  new CompetionComponent(
    '百米游泳',
    [SwimAbility],
    (animalComponents: any) => {
      return 100 / animalComponents.get(SwimAbility).speed;
    },
    new Map<number, number>(),
  ),
);
// 随机添加动物组件
for (let i = 0, len = 15; i < len; i++) {
  const entity = world.createEntity();
  if (Math.random() >= 0.5) {
    world.addEntityComponents(
      entity,
      new Animal(),
      new RunAbility(Math.random() * 10),
    );
  }
  if (Math.random() >= 0.5) {
    world.addEntityComponents(
      entity,
      new Animal(),
      new SwimAbility(Math.random() * 10),
    );
  }
}

// 运行跑分
world.update();
for (const [entityCompetion, componentsCompetion] of world.view(
  CompetionComponent,
)) {
  const component = componentsCompetion.get(CompetionComponent);
  console.log('%s 比赛分数如下', component.name);
  console.group();
  for (const [id, score] of component.scoreMap.entries()) {
    console.log('动物id:%d  分数:%d', id, score);
  }
  console.groupEnd();
}

实际运行Demo结果

总结

• ECS 追求数据和逻辑的分离可以降低代码间的耦合,并且因为较为严格的 Entity/Component/System设计,有利于原生端优化性能和实现多线程的特性

• 其缺点是有时显得不那么灵活,System间禁止相互调用,有些OOP容易实现代码思路,在ECS下要进行更多的代码设计

• 没有OOP灵活,灵活的OOP也可以实现得相似ECS

参考资料

Unity ECS 文档: https://docs.unity3d.com/Packages/com.unity.entities@0.1/manual/index.html

发布于 2022-10-24 12:32

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2376927.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Dify与n8n全面对比指南:AI应用开发与工作流自动化平台选择【2025最新】

Dify与n8n全面对比指南&#xff1a;AI应用开发与工作流自动化平台选择【2025最新】 随着AI技术与自动化工具的迅速发展&#xff0c;开发者和企业面临着多种平台选择。Dify和n8n作为两个备受关注的自动化平台&#xff0c;分别专注于不同领域&#xff1a;Dify主要面向AI应用开发&…

【深度学习之四】知识蒸馏综述提炼

知识蒸馏综述提炼 目录 知识蒸馏综述提炼 前言 参考文献 一、什么是知识蒸馏&#xff1f; 二、为什么要知识蒸馏&#xff1f; 三、一点点理论 四、知识蒸馏代码 总结 前言 知识蒸馏作为一种新兴的、通用的模型压缩和迁移学习架构&#xff0c;在最近几年展现出蓬勃的活力…

redis解决常见的秒杀问题

title: redis解决常见的秒杀问题 date: 2025-03-07 14:24:13 tags: redis categories: redis的应用 秒杀问题 每个店铺都可以发布优惠券&#xff0c;保存到 tb_voucher 表中&#xff1b;当用户抢购时&#xff0c;生成订单并保存到 tb_voucher_order 表中。 订单表如果使用数据…

TypeScript中文文档

最近一直想学习TypeScript&#xff0c;一直找不到一个全面的完整的TypeScript 中文文档。在网直上找了了久&#xff0c;终于找到一个全面的中文的typescript中文学习站&#xff0c;有学习ts的朋友可以年。 文档地址&#xff1a;https://typescript.uihtm.com 该TypeScript 官…

Function Calling

在介绍Function Calling之前我们先了解一个概念,接口。 接口 两种常见接口: 人机交互接口,User Interface,简称 UI应用程序编程接口,Application Programming Interface,简称 API接口能「通」的关键,是两边都要遵守约定。 人要按照 UI 的设计来操作。UI 的设计要符合人…

面试--HTML

1.src和href的区别 总结来说&#xff1a; <font style"color:rgb(238, 39, 70);background-color:rgb(249, 241, 219);">src</font>用于替换当前元素&#xff0c;指向的资源会嵌入到文档中&#xff0c;例如脚本、图像、框架等。<font style"co…

SparkSQL操作Mysql-准备mysql环境

我们计划在hadoop001这台设备上安装mysql服务器&#xff0c;&#xff08;当然也可以重新使用一台全新的虚拟机&#xff09;。 以下是具体步骤&#xff1a; 使用finalshell连接hadoop001.查看是否已安装MySQL。命令是: rpm -qa|grep mariadb若已安装&#xff0c;需要先做卸载MyS…

DeepBook 与 CEX 的不同

如果你曾经使用过像币安或 Coinbase 这样的中心化交易所&#xff08;CEX&#xff09;&#xff0c;你可能已经熟悉了订单簿系统 — — 这是一种撮合买卖双方进行交易的机制。而 DeepBook 是 Sui 上首个完全链上的中央限价订单簿。 那么&#xff0c;是什么让 DeepBook 如此独特&…

Scrapy框架下地图爬虫的进度监控与优化策略

1. 引言 在互联网数据采集领域&#xff0c;地图数据爬取是一项常见但具有挑战性的任务。由于地图数据通常具有复杂的结构&#xff08;如POI点、路径信息、动态加载等&#xff09;&#xff0c;使用传统的爬虫技术可能会遇到效率低下、反爬策略限制、任务进度难以监控等问题。 …

城市扫街人文街头纪实胶片电影感Lr调色预设,DNG/手机适配滤镜!

调色详情 城市扫街人文街头纪实胶片电影感 Lr 调色是通过 Lightroom&#xff08;Lr&#xff09;软件&#xff0c;对城市街头抓拍的人文纪实照片进行后期调色处理。旨在赋予照片如同胶片拍摄的质感以及电影般浓厚的叙事氛围&#xff0c;不放过每一个日常又珍贵的瞬间&#xff0c…

让AI帮我写一个word转pdf的工具

需求分析 前几天&#xff0c;一个美女找我&#xff1a; 阿瑞啊&#xff0c;能不能帮我写个工具&#xff0c;我想把word文件转为pdf格式的 我说&#xff1a;“你直接网上搜啊&#xff0c;网上工具多了去了” 美女说&#xff1a; 网上的要么是需要登录注册会员的&#xff0c;要…

OrangePi Zero 3学习笔记(Android篇)10 - SPI和从设备

目录 1. 配置内核 2. 修改设备数 3. 修改权限 4. 验证 Zero 3的板子有2个SPI Master接口&#xff0c;其中SPI0接的是板载16MB大小的SPI Nor Flash&#xff0c;SPI1则是导出到26pin的接口上。 spi和i2c有点不同&#xff0c;spi是直接生成spi虚拟设备&#xff0c;所以在dev里…

基于策略的强化学习方法之近端策略优化(PPO)深度解析

PPO&#xff08;Proximal Policy Optimization&#xff09;是一种基于策略梯度的强化学习算法&#xff0c;旨在通过限制策略更新幅度来提升训练稳定性。传统策略梯度方法&#xff08;如REINFORCE&#xff09;直接优化策略参数&#xff0c;但易因更新步长过大导致性能震荡或崩溃…

文章复现|(1)整合scRNA-seq 和空间转录组学揭示了子宫内膜癌中 MDK-NCL 依赖性免疫抑制环境

https://www.frontiersin.org/journals/immunology/articles/10.3389/fimmu.2023.1145300/full 目标&#xff1a;肿瘤微环境(TME)在子宫内膜癌(EC)的进展中起着重要作用。我们旨在评估EC的TME中的细胞群体。 方法&#xff1a;我们从GEO下载了EC的单细胞RNA测序(scRNA-seq)和空…

HTML-3.4 表单form

本系列可作为前端学习系列的笔记&#xff0c;代码的运行环境是在HBuilder中&#xff0c;小编会将代码复制下来&#xff0c;大家复制下来就可以练习了&#xff0c;方便大家学习。 系列文章目录 HTML-1.1 文本字体样式-字体设置、分割线、段落标签、段内回车以及特殊符号 HTML…

【MySQL】服务器配置与管理(相关日志)

&#x1f525;个人主页&#xff1a; 中草药 &#x1f525;专栏&#xff1a;【MySQL】探秘&#xff1a;数据库世界的瑞士军刀 一、系统变量和选项 当通过mysqld启动数据库服务器时&#xff0c;可以通过选项文件或命令行中提供选项。一般&#xff0c;为了确保服务器在每次运行时…

【问题】Watt加速github访问速度:好用[特殊字符]

前言 GitHub 是全球知名的代码托管平台&#xff0c;主要用于软件开发&#xff0c;提供 Git 仓库托管、协作工具等功能&#xff0c;经常要用到&#xff0c;但是国内用户常因网络问题难以稳定访问 。 Watt Toolkit&#xff08;原名 Steam&#xff09;是由江苏蒸汽凡星科技有限公…

vue3:十三、分类管理-表格--行内按钮---行删除、批量删除实现功能实现

一、实现效果 增加行内按钮的样式效果,并且可以根绝父组件决定是否显示 增加行内删除功能、批量删除功能 二、增加行内按钮样式 1、增加视图层按钮 由于多个表格都含有按钮功能,所以这里直接在子组件中加入插槽按钮 首先增加表格行<el-table-column></el-table-…

浏览器设置代理ip后不能上网?浏览器如何改ip地址教程

使用代理IP已成为许多用户保护隐私、绕过地域限制或进行网络测试的常见做法。当浏览器设置代理IP后无法上网时&#xff0c;通常是由于代理配置问题或代理服务器本身不可用。以下是排查和解决问题的详细步骤&#xff0c;以及更改浏览器IP的方法&#xff1a; 一、代理设置后无法上…

R语言的专业网站top5推荐

李升伟 以下是学习R语言的五个顶级专业网站推荐&#xff0c;涵盖教程、社区、资源库和最新动态&#xff1a; 1.R项目官网 (r-project.org) R语言的官方网站&#xff0c;提供软件下载、文档、手册和常见问题解答。特别适合初学者和高级用户&#xff0c;是获取R语言核心资源的…