如何查询网站的建设商,备案期间网站如何访问,北京官网建设公司,国外活动策划网站这篇文章将带着你从设计出发重新发现ECS。注意:此篇为泛泛之谈#xff0c;不涉及具体实现。从Abstract说起从”是”到”能”再到”有”对对象的抽象是整理代码的要点#xff0c;继承是一种比较古老并常见的抽象#xff0c;其描述了一个对象是什么#xff0c;其…这篇文章将带着你从设计出发重新发现ECS。注意:此篇为泛泛之谈不涉及具体实现。从Abstract说起从”是”到”能”再到”有” 对对象的抽象是整理代码的要点继承是一种比较古老并常见的抽象其描述了一个对象是什么其中包含了对象拥有的属性和对象拥有的方法在简单情况下继承是一种非常易用易懂的抽象然而在更复杂的情况下继承引入的的问题渐渐浮现出来使得它不再那么易用。以下列举几个例子: 深层次继承树要理解一个类需要往上翻看非常多的类。强耦合修改基类会影响到整棵子继承树。菱形继承祖父的数据重复方法产生二义性。繁重的父类子类的方法被不断提取到父类导致父类过度膨胀某 UE4。而这些问题又相互影响产生恶性循环使得项目的后期开发和优化变得无比困难。万恶之源 于是大家便尝试简化模型并描述了一种叫做接口的抽象其描述了一个对象能干什么其中包含了对象拥有的方法不再包含数据接口隐藏了对象的大部分细节使得对象变成一个黑箱且展平了类结构不再是树状然而接口这里指运行时接口而非泛型作为一种非常高层次的抽象这种抽象层次似乎有时会过高导致CPU更难以理解代码这点在稍后会讨论到。 类似的在游戏开发中面对大量的对象种类大家又描述了一种组件的抽象如 UE4 中的 Actor Component 模型和 Unity 中的 Entity Component 模型其描述了一个对象有什么部分其中对象本身不再拥有代码或数据但其实 Unity 和 UE4 之类的并没有做到这么纯粹对象本身依然带有大量基础功能这导致了代码量和内存占用的双重膨胀。组件的方式带来了优越的动态性对象的状态完全由其拥有的组件决定同样一般没这么纯粹甚至可以动态的改变。并且这让我们可以排列组合以少量的组件组合出巨量的对象当然有效组合往往没那么多。有趣的是从展平对象结构的角度看起来和组件和接口有着微妙的相似性。不过这种抽象也带来出了一些歧义性接下来将讨论这一点。组合2. “有”和”能”和实现 在组件模型中对象由组件组成所以其行为也由组件主导例如一个对象拥有[Movement] 和 [Location]则我们可以认为它能够移动这在整体上是十分和谐自然的但当我们仔细考量这个能是由于什么呢是因为 [Movement]吗是因为[Location]吗还是同时因为 [Movement] 和 [Location]当然是同时这里便揭示出了组件和接口的展平对象方式是正交的那移动的逻辑放到哪呢答案是放在这个“切片“上。但在实际项目中会看到把逻辑放在 [Movement] 上的做法这两种方式都是可取的后一种拥有较为简单的实现并被广泛采用而前一种拥有更精准的语义更好的抽象后一种种方式中 [Movement] 去访问并修改了 [Location] 的数据这破坏了一定的封闭性且形成了耦合当然这种耦合也有一定的好处如避免只添加了 [Movement] 这种无意义的情况发生。从Cache说起Cache Miss CacheCache Memory作为储存器子系统的组成部分存放着程序经常使用的指令和数据是为了缓解访问慢设备的延时而预读的 Buffer例如 CPU L1/L2/L3 Cache 作为 DDR 内存 IO 的 Cache而 DDR 内存作为磁盘 IO 的 Cache。当计算需要读取数据的时候通常从最快得缓存开始依次向下查找并递归的读取。预读就是用来减少下一次读取的查找层数每一层的延迟有数量级的差距的技术。相应的预读的预测失败的时候将会有非常高的代价这种情况被称为 Cache Miss。在大部分的情况下在现代 CPU 的频率带来的运算力下 Cache Miss 比数学运算更容易成为程序的性能瓶颈且在代码中的表现比较隐晦。这使得一味的讨论复杂度O(n)不再适用因为现在效率数据代码最常见的例子就是在数据量小的情况下遍历数组会比 HashMap 快上很多这也是Java或C#这类语言的效率陷阱.。从上到下进行查找2. Avoid Cache Miss 避免 Cache Miss 的方案当然就是去讨好预读。而一般预读的策略为线性预读即我们应该尽量的保证数据读写的连续性从逆向思维出发则需了解会打断数据连续性的情形。简单的列举几个:遍历大结构体的数组却只访问少数成员操作对象引用OOP操作数组的顺序不够连续比如实现得不好的 hash 表etc。综上所述避免Cache Miss的主要考量就是尽量使用数组尽量分割属性SOA尽量连续的进行处理。在 GPU 编程中存在大量实例此时达到理论最高效率3. More than Data 前面提到过 Cache 存放着程序经常使用的指令和数据现代 CPU 在数据 IO 的时候并不会完全的挂起而是会利用空闲的运算力继续执行后续的指令且指令也是一种数据这意味着我们不光要照顾数据的连续性还需要考虑到指令的连续性那么什么情况会破坏指令的连续性呢可能是函数指针虚函数的调用回调等循环超长代码块等。特别是函数指针在 IO 期间CPU 无事可做于是在需要高性能的情形下应该尽量避免虚函数。4. Allocation 对于数据而言还有一个重要的问题就是分配内存。在应用中不管是分配还是释放都是十分消耗性能的操作前者可能产生碎片而后者考虑 GC可能带来停顿考虑 RC带来析构血崩考虑手动也可能带来危险和脑力负担所以一般对于高频分配的部分会预先分配大块内存用来管理一般称作池化。从 Thread 说起Multithread 随着处理器核心的发展速度减缓为了进一步提升处理器的性能堆叠核心成为了新的出路甚至现在的处理器没个四核都不好意思见人其中堆叠核心的巅峰就是 GPU上千个核心带来了疯狂的数字处理能力被广泛运用于 AI 和图形领域。而这在游戏之类的高性能软件中为了充分利用 CPU 的算力程序设计成多线程运行也是非常必要的。2. Race Condition 和 Data Race 不幸的是多线程很多时候不是免费的性能并不是所有情况都像异步读文件那么简单在开发过程中很多地方都可能会有 Race 的发生。同步性问题非常的恶心因为通常其不会即时造成崩溃之类的错误而是会积累错误等到错误爆发缘由已经很难查询。所以编码的时候就必须要小心翼翼其中 Race Condition 主要需要我们保证整体操作的原子性一般的解决方案是一把大锁。Data Race 则更加复杂触发Data Race的条件可以归纳为1,同一个位置的对象。2,被两个并行的线程操作。3,两个线程并非都是读。4,不是原子操作。 只有当这四个条件同时成立的时候Data Race 才会发生所以为了避免它的发生我们需要破坏掉其中的一个或多个条件。对于条件4可以使用原子操作破坏然而原子操作的复杂性颇高实际应用中常用于实现底层库无锁队列线程池之类的。而要破坏条件1、3则是避免可变共享完全进行拷贝如erlang。剩下条件2就是避免硬碰硬在可能发生 Data Race 的时候直接放弃并行。但总得来说最重要的还是要避免它的发生一定要对这些条件足够敏感以预防遗漏在这里通常封装就起了反作用因为黑箱之内我们无法知道会发生什么。而此时相对于 OOP 的黑箱函数式的纯粹纯原子性便能体现出它在并行上天生的优势所以卡神推荐在 C 里也尽量使用函数式的思想来进行编码。交汇之地 - 三相之力Component 与 System 之前说到组件模式的时候我们列举了两种方式来存放实现组件功能的代码而使用“管理器”实现的方式拥有更精准的语义和更好的抽象组件之间被彻底解耦而这个“管理器”我们称之为系统System。即系统负责管理特定的组件的组合而组件则不再负责逻辑。接下来分别讨论这两个部分。筛选对应的实体2. System 对象耦合于接口而这里系统则耦合于对象。这意味着组件不变的情况下系统的任何修改都不会对程序的其余部分造成影响。这给代码带来了出色的内聚性让 culling 和 plugin 都变得更轻松并且系统本身拥有很好的纯度我们完全可以把系统看做是”输入上一帧的数据输出下一帧的数据“。也就是系统本身贴合了函数式的思想根据前面的叙述函数式在并行上有天生的优势这在系统上也体现了出来系统负责管理组件的信息是透明的于是我们对系统对组件的读写便一目了然 - 注意结构体之间没有任何依赖系统与系统之间的冲突也一目了然。更进一步在通常情况下系统是一个白箱运作系统的代码将不会经过虚函数不管是效率还是可测试性都是极好的。甚至对于系统的执行调度也完全暴露了出来这在实现网络同步之类的框架的时候能提供很大的便捷性。3. Component 与 Entity 对于对象本身其实已经不必要承载多少信息了激进一点说对象甚至只是一个唯一的ID用于和其他对象区分而已这让我们有机会去除那些基础功能的依赖例如 Transform使得内存和代码进一步压缩。而组件不包含逻辑就只有数据作为一个大的对象的分割的属性通常为小结构体。对于每一种组件我们可以使用紧密的数组来储存它而这也意味着我们可以轻松的池化这个数组。在系统管理组件的时候并不关心特定 Entity而是在组件数据的切片上批量的连续的进行处理这在理想情况下能大大的减少 Cache Miss 的情况。作为额外的好处纯数据的组件对序列化表格化有着极强的适应性毕竟对象天生就是一个填着组件的表格对网络、编辑、存档等都十分的友好。这里也可以引入很多数据库相关的知识4. ECS 至此我们重新发现了 ECS并详细阐述了它的好处。