实体 组件 系统(Entity-Component-System)
A-Frame基于three.js框架,并且使用了实体-组件-系统(entity-component-system)(ECS)架构。ECS架构是三维游戏中常见且理想的设计模式, 遵循组合模式要好于继承和层次结构的设计原则。
ECS的好处包括:
- 通过混合和匹配可重用部件来获得定义对象时更大的灵活性。
- 消除了具有复杂交织功能的长继承链的问题。
- 通过解耦、封装、模块化、重用性来促进简洁设计。
- 从复杂度角度而言,提供构建VR应用程序可伸缩性的最佳方式。
- 久经考验的3D和VR开发架构。
- 很好的新功能可扩展性(可能将它们作为社区组件共享)。
在2D Web上,我们按照层次结构列出具有固定行为的元素。但不同的是,3D/VR上有着无数种类行为不受限制的对象。ECS提供了一种易于管理的模式来构造 各种类型的对象。
下面是ECS架构很好的介绍材料,我们建议您快速阅读下,以便有个整体性的理解。ECS非常适合VR开发,因此A-Frame是完全基于这一范式之上的:
著名的Unity就是一个实现了ECS架构的游戏引擎。尽管在跨实体通信时有点痛苦,但A-Frame、DOM以及声明性HTML将显现出ECS的强大之处。
Concept
ECS的基本定义包括:
- 实体(Entities)是容器对象,用来包含组件。实体是场景中所有对象的基础。没有附加组件的实体不会渲染任何东西,类似于空的
<div>
。 - 组件(Components) 是可重用的模块或数据容器,可以依附于实体以提供外观、行为和/或 功能。组件就像即插即用的对象。所有的逻辑都是通过组件实现,并通过混合、匹配和配置组件来定义不同类型的对象。像炼金术那样!
- 系统(Systems) 为组件类提供全局范围、管理和服务。系统通常是可选的,但我们可以使用它们 来分离逻辑和数据;系统处理逻辑(有点类似控制器或者服务层的角色),组件充当数据容器。
实例
一些将不同组件组合成不同类型的实体的抽象示例:
Box = Position + Geometry + Material
Light Bulb = Position + Light + Geometry + Material + Shadow
Sign = Position + Geometry + Material + Text
VR Controller = Position + Rotation + Input + Model + Grab + Gestures
Ball = Position + Velocity + Physics + Geometry + Material
Player = Position + Camera + Input + Avatar + Identity
作为另一个抽象的例子,想象一下我们想通过组装部件来制造汽车:
- 我们可以给汽车(car)实体附加一个
材料(material)
组件,该组件拥有属性如“color”或“shininess”来设置汽车的外观。 - 我们可以给汽车(car)实体附加一个
引擎(engine)
组件,该组件拥有属性如“马力(horsepower)”或“重量(weight)”来确定汽车的功能。 - 我们可以给汽车(car)实体附加一个
轮胎(tire)
组件,该组件拥有属性如“数量(number of tires)” 或 “方向(steering angle)”来确定汽车的行驶。
因此我们可以通过改变 material
, engine
, 和 tire
组件的属性来制造不同的汽车。material
, engine
, 和 tire
组件可以彼此独立,我们甚至可以混合并匹配它们来创建其他类型的交通工具。
- 创建一个船(boat) 实体:删除
tire
组件。 - 创建一个摩托车(motorcycle) 实体:改变
tire
组件的数量为2, 配置一个较小的engine
组件。 - 创建一个 飞机(airplane) 实体:添加一个
机翼(wing)
和喷气式引擎(jet)
组件。
对比下传统继承模式中,如果我们想扩展一个对象,我们得处理一个复杂的继承链。
注解:类继承和对象组合是代码复用的两种最常用的设计模式。
一:继承
继承是Is a 的关系,比如说Student继承Person,则说明Student is a Person。继承的优点是子类可以重写父类的方法来方便地实现对父类的扩展。
继承的缺点有以下几点:
①:父类的内部细节对子类是可见的。
②:子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法的行为。
③:如果对父类的方法做了修改的话(比如增加了一个参数),则子类的方法必须做出相应的修改。所以说子类与父类是一种高耦合,违背了面向对象思想。
二:组合
组合也就是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。
组合的优点:
①:当前对象只能通过所包含的那个对象去调用其方法,所以所包含的对象的内部细节对当前对象时不可见的。
②:当前对象与包含的对象是一个低耦合关系,如果修改包含对象的类中代码不需要修改当前对象类的代码。
③:当前对象可以在运行时动态的绑定所包含的对象。可以通过set方法给所包含对象赋值。
组合的缺点:①:容易产生过多的对象。②:为了能组合多个对象,必须仔细对接口进行定义。
由此可见,组合比继承更具灵活性和稳定性,所以在设计的时候优先使用组合。只有当下列条件满足时才考虑使用继承:
- 子类是一种特殊的类型,而不只是父类的一个角色
- 子类的实例不需要变成另一个类的对象
- 子类扩展,而不是覆盖或者使父类的功能失效
A-Frame中的ECS
A-Frame拥有代表ECS每个方面的API:
- 实体(Entities) 对应的是
<a-entity>
元素和原型。 - 组件(Components) 通过
<a-entity>
的HTML属性来表示。底层实现上, 组件是包含模式(schema)、生命周期处理器和方法的对象。组件通过AFRAME.registerComponent (name, definition)
API来注册。 - 系统(Systems) 通过
<a-scene>
的HTML属性来表示。系统在定义上和组件类似,系统通过AFRAME.registerSystem (name, definition)
API来注册。
语法(Syntax)
我们创建 <a-entity>
并将组件附加为其HTML属性。大多数组件具有多个属性,这些属性用类似于 HTMLElement.style
CSS的语法来表示。此语法采用分号形式 (:
) 来隔离属性及其属性值,并使用一个分号 (;
) 来分隔不同的属性定义:
<a-entity ${componentName}="${propertyName1}: ${propertyValue1}; ${propertyName2:}: ${propertyValue2}">
举例如下,我们有一个 <a-entity>
,添加了 geometry,material,light, and position 组件,使用了各种属性和属性值:
<a-entity geometry="primitive: sphere; radius: 1.5" light="type: point; color: white; intensity: 2" material="color: white; shader: flat; src: glow.jpg" position="0 0 -5"></a-entity> |
组合(Composition)
通过组合模式,我们可以附加更多的组件来添加额外的外观,行为或功能(如物理特性)。或者我们可以更新组件值来配置实体(无论是通过声明或使用方法 .setAttribute
)。
由多个组件组成的一种常见实体是虚拟现实中玩家的手。玩家的手包含很多组件:外观、手势,行为、与其他对象的交互。
下面的每一个组件互不相关,但可以结合起来定义一个复杂实体:
<a-entity tracked-controls <!-- Hook into the Gamepad API for pose. --> vive-controls <!-- Vive button mappings. --> oculus-touch-controls <!-- Oculus button mappings. --> hand-controls <!-- Appearance (model), gestures, and events. --> controller-cursor <!-- Laser to interact with menus and UI. --> sphere-collider <!-- Listen when hand is in contact with an object. --> grab <!-- Provide ability to grab objects. --> throw <!-- Provide ability to throw objects. --> event-set="_event: grabstart; visible: false" <!-- Hide hand when grabbing object. --> event-set="_event: grabend; visible: true" <!-- Show hand when no longer grabbing object. -->> |
声明式基于DOM的ECS
通过声明式和基于DOM,A-Frame把ECS提高到另外一个层次。传统上,基于ECS的引擎全部是通过代码来创建实体,附加组件,更新组件以及删除组件。但A-Frame利用HTML和 DOM使得ECS更加符合工程学,解决了它的许多弱点。下面是DOM为ECS提供的功能:
- 使用查询选择器来引用其他实体: DOM提供了强大的查询选择器系统,让我们可以很方便的根据条件匹配来查询场景并选择一个实体 或一组实体。我们可以通过id、class或者数据属性来获得对实体的引用。因为A-Frame框架是基于HTML的,我们可以使用现成的查询选择器功能。
document.querySelector('#player')
. - 与事件(Events)解耦的跨实体通信:DOM提供监听和发出事件的能力。这在实体间提供了发布订阅的通信系统。组件之间不必相互了解,它们可以发出一个事件(可能会冒泡),其他组件则可以在不互相调用的情况下监听这些事件:
ball.emit('collided')
. - 元素生命周期管理的API: DOM提供了一组增删改查HTML元素的API:
.setAttribute
,.removeAttribute
,.createElement
, 和.removeChild
。我们可以像普通Web开发一样来使用这些API。 - 具有属性选择器的实体筛选: DOM提供属性选择器,它允许我们查询具有或不具有某些HTML属性的实体。这意味着我们可以查找具有或 不具有特定组件的实体。
document.querySelector('[enemy]:not([alive]')
. - 声明式(Declarativeness): 最后,A-Frame桥接了ECS和HTML,使一个成熟设计模式融合了声明式语法风格、可读性和简易的代码拷贝复制性。
可扩展性(Extensibility)
A-Frame允许开发者创建新的自定义组件来扩展任何已有的功能特性。组件能使用JavaScript、three.js以及Web API(如WebRTC, 语音识别等)。
在后续章节:开发一个 A-Frame 组件中,我们将讨论更多技术细节。作为预览,基本的组件结构大概看起来像下面这样:
AFRAME.registerComponent('foo', { schema: { bar: {type: 'number'}, baz: {type: 'string'} }, init: function () { // Do something when component first attached. }, update: function () { // Do something when component's data is updated. }, remove: function () { // Do something the component or its entity is detached. }, tick: function (time, timeDelta) { // Do something on every scene tick or frame. }}); |
声明性ECS允许我们编写JavaScript模块并通过HTML来抽象它。一旦组件被注册,我们可以通过HTML属性以声明的方式将这个模块的代码插入一个实体。这种代码到HTML的抽象使ECS功能更加强大和易于理解。foo
是刚刚注册过的组件的名称,数据包含 bar
和 baz
属性:
<a-entity foo="bar: 5; baz: bazValue"></a-entity> |
基于组件的开发
为了构建VR应用程序,我们建议放置所有应用程序代码在组件(和系统)内。 一个理想的A-Frame项目代码应该由纯粹模块化、封装和解耦的组件所构成。这些组件可以被单独执行单元测试,也可以与其他组件一起测试。
当一个应用程序仅由组件所创建时,它的所有部分都可以成为可重用的代码。组件可以共享给其他开发人员使用,或者可以在我们其他项目中重用。组件也可以创建分支版本,修改来适应其他用例。
一个简单的ECS项目代码结构可能像下面这样:
index.htmlcomponents/ ball.js collidable.js grabbable.js enemy.js scoreboard.js throwable.js |
高级组件
组件可以在实体上设置其他组件,使其成为高等或更高级别的组件。
比如,游标(cursor)组件 构建于 光线投射(raycaster)组件 之上。 或者 手控(hand-controls)组件 构建于 vive-controls 组件 和 oculus-touch-controls 组件 之上,而后者构建于 跟踪控制(tracked-controls)组件 之上。
社区组件
组件可以共享到A-Frame生态社区。A-Frame是有很好的可扩展性。一个有经验的开发人员可以开发一个物理系统或图形着色器组件,以便新手开发人员可以使用这些组件来加速应用程序的开发。我们只需要在HTML中通过<script>
标签来引用这些组件,而无需了解其内部代码。
怎么查找组件
为了更好的被发现,如果您开发了一个组件,请尽量通过如下共享平台来分享:
Registry
组件被精选和收集到 A-Frame注册表(Registry)中,这类似于Unity资产商店上的组件和模块集合,但这是免费和开放源码的。一旦组件被收录在注册表中,组件可以通过多种渠道很容易地被搜索和安装(比如,angle)。
npm
大多数的框架组件都会发布在npm和GitHub上。我们可以使用 npm的搜索功能来查找 aframe-components
。npm允许我们按质量、受欢迎程度和维护活跃度来排序。这是查找更加完整的组件列表的好地方。
awesome-aframe
仓库
awesome-aframe
仓库 是一个对于A-Frame开发生态系统而言很棒的资源列表。组件是该列表的一部分,该列表不是精选的,也不包含任何图像。每个条目只包含名称、链接和简短描述。
A-Frame每周资讯
A-Frame博客, 我们在博客上会发布A-Frame开发生态系统中的所有事件。这当然包括了新发布的组件或更新。A-Frame官网首页 将总是维护一个最新A-Frame每周资讯的入口链接。
GitHub项目
很多开源的A-Frame应用程序被放在 GitHub 上。它们的代码库中通常会包含一些可以被重用的组件,比如:
使用社区组件
一旦找到了我们想要的组件,我们可以通过 <script>
标签来在HTML中包含和使用它。
比如,我们要使用IdeaSpaceVR的 粒子系统组件,步骤如下:
使用unpkg
首先,我们必须找到该组件js文件的CDN链接。组件的说明文档中通常会包含CDN链接或如何使用的信息。不过要获得最新的CDN链接的一种途径是使用 unpkg.com。
unpkg是一个能够自动host所有npm发布资源的CDN。unpkg提供多版本管理功能,能解析版本语义并提供我们所想要的版本。其URL形式如下:
https://unpkg.com/<npm package name>@<version>/<path to file> |
如果我们想要组件脚本的最新版本, 我们可以去除 version
参数:
https://unpkg.com/<npm package name>/<path to file> |
该JS文件通常会在一个名为 dist/
或者 build/
的目录下,并已经最小化,文件后缀名为.min.js
。
对于粒子系统组件,我们将指向:
https://unpkg.com/aframe-particle-system-component/ |
注意结束处的斜线符号 (/
)。找到所需文件后,右键点击,并选择 Copy Link to Address 来把CDN拷贝到剪切板中。
包含该组件JS文件
接下来,我们把该JS文件通过 <script>
标签添加到HTML <head>
部分。位置需要放在A-Frame JS <script>
标签之后,同时在 <a-scene>
标签之前。
对于粒子系统组件,当前(写本文档时)找到的JS脚本的CDN链接如下:
https://unpkg.com/aframe-particle-system-component@1.0.9/dist/aframe-particle-system-component.min.js |
现在HTML代码看起来如下:
<html> <head> <script src="https://aframe.io/releases/1.1.0/aframe.min.js"></script> <script src="https://unpkg.com/aframe-particle-system-component@1.0.9/dist/aframe-particle-system-component.min.js"></script> </head> <body> <a-scene> </a-scene> </body></html> |
使用组件
阅读并遵循组件说明文档,将组件附加到实体并配置它。
HTML代码如下:
<html> <head> <script src="https://aframe.io/releases/1.1.0/aframe.min.js"></script> <script src="https://unpkg.com/aframe-particle-system-component@1.0.9/dist/aframe-particle-system-component.min.js"></script> </head> <body> <a-scene> <a-entity particle-system="preset: snow" position="0 0 -10"></a-entity> </a-scene> </body></html> |
实例
下面是一个完整的例子,使用了A-Frame注册表中多个社区组件并且使用了unpkg CDN。
<html> <head> <script src="https://aframe.io/releases/1.1.0/aframe.min.js"></script> <script src="https://unpkg.com/aframe-animation-component@3.2.1/dist/aframe-animation-component.min.js"></script> <script src="https://unpkg.com/aframe-particle-system-component@1.0.x/dist/aframe-particle-system-component.min.js"></script> <script src="https://unpkg.com/aframe-extras.ocean@%5E3.5.x/dist/aframe-extras.ocean.min.js"></script> <script src="https://unpkg.com/aframe-gradient-sky@1.0.4/dist/gradientsky.min.js"></script> </head> <body> <a-scene> <a-entity particle-system="preset: rain; color: #24CAFF; particleCount: 5000"></a-entity> <a-entity geometry="primitive: sphere" material="color: #EFEFEF; shader: flat" position="0 0.15 -5" light="type: point; intensity: 5" animation="property: position; easing: easeInOutQuad; dir: alternate; dur: 1000; to: 0 -0.10 -5; loop: true"></a-entity> <a-entity ocean="density: 20; width: 50; depth: 50; speed: 4" material="color: #9CE3F9; opacity: 0.75; metalness: 0; roughness: 1" rotation="-90 0 0"></a-entity> <a-entity geometry="primitive: sphere; radius: 5000" material="shader: gradient; topColor: 235 235 245; bottomColor: 185 185 210" scale="-1 1 1"></a-entity> <a-entity light="type: ambient; color: #888"></a-entity> </a-scene> </body></html> |