Bevy 0.1 简介
由 Carter Anderson 于 2020 年 8 月 10 日发布 ( @cart @cart_cart cartdev )
经过几个月的努力,我非常高兴地宣布 Bevy Engine 的诞生!
Bevy 是一个用 Rust 构建的新鲜简洁、数据驱动的游戏引擎和应用程序框架。它 永远免费且开源!
它拥有以下设计目标
- 功能强大:提供完整的 2D 和 3D 功能集
- 简单:初学者易于上手,但对高级用户来说具有无限的灵活性
- 数据为中心:使用实体组件系统范式的数据导向架构
- 模块化:只使用你需要的部分。替换你不喜欢的部分
- 快速:应用程序逻辑应该快速运行,并在可能的情况下并行运行
- 高效:更改应该快速编译...等待不好玩
Bevy 有许多我认为让它区别于其他引擎的功能
- Bevy ECS:一个自定义的实体组件系统,具有无与伦比的可用性和极快的性能
- 渲染图:使用渲染图节点轻松构建自己的多线程渲染管道
- Bevy UI:一个专门为 Bevy 构建的自定义 ECS 驱动的 UI 框架
- 高效编译时间:使用 "快速编译" 配置,预计更改会在约 0.8-3.0 秒内编译完成
它还拥有大多数人对现代通用引擎的期望功能
- 跨平台:Windows、MacOS 和 Linux(计划支持移动和 Web)
- 3D:灯光、网格、纹理、MSAA 和 GLTF 加载
- 精灵:将单个图像渲染为精灵,从精灵图渲染,并动态生成新的精灵图
- 资产:一个可扩展的、事件驱动的资产系统,可以在后台线程中异步加载资产
- 场景:将 ECS 世界保存到人类可读的场景文件,并将场景文件加载到 ECS 世界中
- 插件:所有引擎和应用程序功能都是作为模块化插件实现的
- 声音:将音频文件加载为资产,并从系统中播放
- 多个渲染后端:Vulkan、DirectX 12 和 Metal(感谢 wgpu,更多后端正在路上)
- 数据驱动着色器:轻松将 ECS 组件直接绑定到着色器制服
- 热资产重新加载:在运行时自动重新加载对资产的更改,无需重新编译或重启
- 事件:在 ECS 系统中高效地消费和生成事件
- 属性:使用其名称的字符串版本动态获取和设置组件字段
- 分层变换:在实体之间创建父子关系,将变换传播到层次结构中
话虽如此,Bevy 仍处于非常早期的阶段。我认为它处于 "原型" 阶段:功能缺失,API 会发生变化,文档也很少。 我不建议将 Bevy 用于严肃的项目,除非你愿意处理漏洞和不稳定性。
希望在这一点上你要么 (1) 对 Bevy 感到兴奋,要么 (2) 不再阅读。如果你现在想深入了解,快速入门指南 是最好的起点。你也可以继续阅读,了解 Bevy 的当前状态以及我们希望把它带到哪里。
给读者的快速说明:在本文中,你会发现类似于此格式的文本:Texture
这种格式表示该文本是一个 Rust 类型,它链接到 API 文档。我鼓励你点击任何让你感兴趣的东西!
Bevy 应用 #
首先,让我们看看 Bevy 应用实际上是什么样子。最简单的应用看起来像这样
use bevy::prelude::*;
fn main() {
App::build().run();
}
就是这样!这个应用没有引入任何功能,实际上什么也不做。运行程序会立即终止。我们可以通过这样做让它变得更有趣
fn main() {
App::build()
.add_default_plugins()
.run();
}
AddDefaultPlugins::add_default_plugins
添加了所有你可能期望从游戏引擎中获得的功能:一个 2D/3D 渲染器、资产加载、UI 系统、窗口、输入等
你也可以像这样手动注册默认的 Plugins
fn main() {
App::build()
.add_plugin(CorePlugin::default())
.add_plugin(InputPlugin::default())
.add_plugin(WindowPlugin::default())
.add_plugin(RenderPlugin::default())
.add_plugin(UiPlugin::default())
/* more plugins here ... omitted for brevity */
.run();
}
当然,你也可以创建自己的插件。实际上,所有引擎和游戏逻辑都是使用插件构建的。希望你现在明白我们所说的模块化是什么意思:你可以根据项目的独特需求自由添加/删除插件。但是,我预计大多数人至少在最初会出于简单性而坚持使用 AddDefaultPlugins::add_default_plugins
。
Bevy ECS #
所有 Bevy 引擎和游戏逻辑都是构建在自定义的 实体组件系统(简称 ECS)之上的。实体组件系统是一种软件范式,它涉及将数据分解为组件。实体是分配给组件组的唯一 ID。例如,一个实体可能具有 Position
和 Velocity
组件,而另一个实体可能具有 Position
和 UI
组件。系统是在特定组件集上运行的逻辑。你可能有一个 movement
系统,它在所有具有 Position
和 Velocity
组件的实体上运行。
ECS 模式通过迫使你将应用程序数据和逻辑分解成其核心组件来鼓励干净、解耦的设计。
与其他 Rust ECS 实现不同,其他实现需要复杂的生命周期、特征、构建器模式或宏,Bevy ECS 对所有这些概念使用正常的 Rust 数据类型
- 组件:正常的 Rust 结构体
- 系统:正常的 Rust 函数
- 实体:包含唯一整数的类型
已经有很多关于 ECS 范式的 精彩介绍,因此我将把 "了解 ECS" 作为读者的练习,并直接跳到 Bevy ECS 的特别之处
人体工程学 #
我在这里要大胆地(不可证伪地)声称:Bevy ECS 是现存最符合人体工程学的 ECS
use bevy::prelude::*;
struct Velocity(f32);
struct Position(f32);
// this system spawns entities with the Position and Velocity components
fn setup(mut commands: Commands) {
commands
.spawn((Position(0.0), Velocity(1.0),))
.spawn((Position(1.0), Velocity(2.0),));
}
// this system runs on each entity with a Position and Velocity component
fn movement(mut position: Mut<Position>, velocity: &Velocity) {
position.0 += velocity.0;
}
// the app entry point. hopefully you recognize it from the examples above!
fn main() {
App::build()
.add_default_plugins()
.add_startup_system(setup.system())
.add_system(movement.system())
.run();
}
这是一个完整的自包含 Bevy 应用,具有自动并行系统调度和全局更改检测。在我看来,你 不会 找到 任何 ECS 具有 更好 清晰度 或人体工程学的 ECS。构建游戏(和引擎)涉及编写大量的系统,所以我投入了大量精力,使 ECS 代码易于编写且易于阅读。
性能 #
ECS 范式如此受欢迎的原因之一是它有可能使游戏逻辑变得超级快,主要原因有两个
- 迭代速度:组件紧密地打包在一起,以优化缓存局部性,这使得对它们的迭代速度飞快
- 并行性:系统声明读/写依赖关系,这使得能够实现自动且高效的无锁并行调度
Bevy ECS 在这两方面都做得尽可能好。根据流行的 ecs_bench
基准测试,Bevy ECS 是最快的 Rust ECS,而且优势相当大
系统迭代(以纳秒为单位,越少越好) #
世界设置(以纳秒为单位,越少越好) #
请注意,ecs_bench
是一个单线程基准测试,因此它没有说明这些框架的多线程能力。与往常一样,请注意,ecs_bench
是一个微基准测试,它没有说明复杂游戏的性能。ECS 性能领域存在很多细微差别,上面提到的每个 ECS 实现都在不同的工作负载下会有不同的表现。
我已经将我的 ecs_bench
版本 发布在这里,如果有人想仔细检查我的方法。在一段合理的时间内,如果有人报告问题或我的结果无法(平均)重现,我将在这里发布更新。
功能 #
现在你可能在想 "好吧,@cart,Bevy ECS 具有出色的性能和人体工程学,但当然这意味着你不得不牺牲功能!"
... 不,Bevy 已经为你准备好了
ForEach 系统 #
// "for each systems" run once on each entity containing the given components
fn system(position: Mut<Position>, velocity: &Velocity) {
// do something
}
查询系统 #
// this "query system" is the same as the system above, but gives you control over iteration
fn system(mut query: Query<(&Position, &mut Velocity)>) {
for (position, mut velocity) in &mut query.iter() {
// do something
}
}
更改检测 #
// Added<T> queries only run when the given component has been added
fn system(mut query: Query<Added<Position>>) {
for position in &mut query.iter() {
// do something
}
}
// Mutated<T> queries only run when the given component has been mutated
fn system(mut query: Query<Mutated<Position>>) {
for position in &mut query.iter() {
// do something
}
}
// Changed<T> queries only run when the given component has been added or mutated
fn system(mut query: Query<Changed<Position>>) {
for position in &mut query.iter() {
// do something
}
}
// query.removed<T>() will iterate over every entity where the component T was removed this update
fn system(mut query: Query<&Position>>) {
for entity in query.removed::<Velocity>() {
// do something
}
}
多个查询 #
fn system(mut wall_query: Query<&Wall>, mut player_query: Query<&Player>) {
for player in &mut player_query.iter() {
for wall in &mut wall_query.iter() {
if player.collides_with(wall) {
println!("ouch");
}
}
}
}
实体查询和直接组件访问 #
fn system(mut entity_query: Query<Entity>, mut player_query: Query<&Player>) {
for entity in &mut entity_query.iter() {
if let Some(player) = player_query.get::<Player>(entity) {
// the current entity has a player component
}
}
}
资源 #
// Res and ResMut access global resources
fn system(time: Res<Time>, score: ResMut<Score>) {
// do something
}
// you can use Resources in any system type
fn system(time: Res<Time>, mut query: Query<&Position>) {
// do something
}
fn system(time: Res<Time>, &Position) {
// do something
}
"本地" 系统资源 #
// Local<T> resources are unique per-system. Two instances of the same system will each have their own resource. Local resources are automatically initialized to their default value.
fn system(state: Local<State>, &Position) {
// do something
}
空系统 #
// for the hyper-minimalists
fn system() {
// do something
}
带/不带过滤器 #
// only runs on entities With or Without a given component
fn system(mut query: Query<Without<Parent, &Position>>) {
for position in &mut query.iter() {
// do something
}
}
线程本地系统 #
// systems that must run on the main thread with exclusive access to World and Resources
fn system(world: &mut World, resources: &mut Resources) {
// do something
}
阶段 #
// the scheduler provides Stages as a way to run sets of systems in order
fn main() {
App::build()
// adds a system to the default stage: "update"
.add_system(movement.system())
// creates a new stage after "update"
.add_stage_after("update", "do_things")
.add_system_to_stage("do_things", something.system())
}
命令 #
// use Commands to queue up World and Resource changes, which will be applied at the end of the current Stage
fn system(mut commands: Commands) {
commands.spawn((Position(0.0), Velocity(1.0)));
}
// Commands can also be used alongside other types
fn system(mut commands: Commands, time: Res<Time>, mut query: Query<&Position>) {
// do something
}
函数系统是如何工作的? #
能够直接使用 Rust 函数作为系统可能会让人感觉像是魔法,但我保证它不是!你可能已经注意到,我们在应用中注册系统时就是这么做的
fn some_system() { }
fn main() {
App::build()
.add_system(some_system.system())
.run();
}
.system()
调用接受 some_system
函数指针并将其转换为 Box<dyn System>
。这是因为我们为所有匹配特定函数签名集的函数实现了 IntoQuerySystem
特性。
良好基础 #
Bevy ECS 实际上使用的是极简主义的 Hecs ECS 的一个高度修改版本。Hecs 是一种高效的单线程原型 ECS。它提供了核心 World
、Archetype
和内部 Query
数据结构。Bevy ECS 在其之上增加了以下内容
- 函数系统:Hecs 实际上根本没有“系统”的概念。你只需要直接在 World 上运行查询。Bevy 添加了使用普通 Rust 函数定义可移植、可调度系统的能力。
- 资源:Hecs 没有唯一/全局数据概念。在构建游戏时,这通常是必需的。Bevy 添加了一个
Resource
集合和资源查询。 - 并行调度器:Hecs 是单线程的,但它被设计成允许在它之上构建并行调度器。Bevy ECS 添加了一个自定义的依赖感知调度器,它建立在上面提到的“函数系统”的基础上。
- 优化:Hecs 已经足够快,但通过修改它的一些内部数据访问模式,我们能够显著提高性能。这使其从“足够快”提升到“最快”(参见上面的基准测试,将 Bevy ECS 与普通 Hecs 进行比较)。
- 查询包装器:Bevy ECS 导出的
Query
实际上是 Hecs 查询的包装器。它在多线程环境中为World
提供安全、范围内的访问,并提高了迭代的人体工程学。 - 变更检测:自动(且高效地)跟踪组件添加/删除/更新操作并在 Query 接口中公开它们。
- 稳定实体 ID:几乎每个 ECS(包括 Hecs)都使用不稳定的实体 ID,这些 ID 无法用于序列化(场景/保存文件)或网络。在 Bevy ECS 中,实体 ID 是全局唯一的且稳定的。你可以在任何环境中使用它们!
在不久的将来,我将在 Hecs 的 git 仓库中提交一个问题,提议将他们想要的任何 Bevy ECS 的更改合并到上游。我有一种感觉,他们可能不想要“高级”功能,比如函数系统和并行调度,但我想我们拭目以待吧!
Bevy UI #
Bevy 有一个自定义但熟悉的 UI 系统,基于“弹性盒子”模型。好吧,说是半自定义,但稍后会详细说明。在一开始,我强烈考虑使用 一个 Rust 生态系统中许多很棒的 预制 UI 解决方案。但是,这些框架中的每一个都感觉在某种程度上与 Bevy 核心的数据驱动 ECS 方法“分离”。如果我们采用像 Druid 这样的框架(在设计方面是顶级框架),然后把它塞进 Bevy 数据/事件模型中,那么这将损害 Druid 的设计,Bevy+Druid 最终会比仅仅将 Druid 用作独立框架更不具吸引力。
我决定,Bevy 唯一能够希望在桌面上带来一些引人入胜的东西的方式就是完全拥抱 Bevy 的做事方式。
Bevy UI 直接使用 Bevy 核心的现有 ECS、层次结构、变换、事件、资产和场景系统。正因为如此,Bevy UI 自动获得了诸如 UI 场景文件的热重载、异步纹理加载和变更检测等功能。共享架构意味着对任何这些系统的改进都会直接反馈到 Bevy UI 中。我还没有确信这种方法会产生最好的 UI 框架,但我确信它会在 Bevy 应用程序的背景下产生最好的 UI 框架。
我们仍在实验阶段,我预计一些东西会改变,但到目前为止我们发现的模式非常有前景。另外请记住,目前构建 Bevy UI 的最佳方式是使用代码,但我们正在设计一种新的场景文件格式,它应该能让声明式、基于文件的 UI 组成比目前更出色。
构建块 #
在 Bevy 中,UI 元素只是一个带有 Node
组件的 ECS 实体。节点是具有宽度和高度的矩形,并且使用 Bevy 中其他地方使用的相同 Transform
组件进行定位。 Style
组件用于确定如何渲染、大小和定位节点。
添加新节点(以及所有必需组件)的最简单方法如下
commands.spawn(NodeComponents::default())
NodeComponents
是一个“组件捆绑包”,Bevy 使用它来更轻松地生成各种“类型”的实体。
布局 #
对于布局,Bevy 使用一个名为 Stretch 的 100% Rust 弹性盒子实现。Stretch 提供了根据弹性盒子规范在 2D 空间中定位矩形的算法。Bevy 在上面提到的 Style
组件中公开了弹性属性,并使用 Stretch 输出的定位和大小渲染矩形。Bevy 使用自己的 Z 轴分层算法来将元素“叠加”在一起,但这基本上与 HTML/CSS 使用的算法相同。
相对定位 #
默认情况下,节点相对于彼此定位。
commands
.spawn(NodeComponents {
style: Style {
size: Size::new(Val::Px(100.0), Val::Px(100.0)),
..Default::default()
},
material: materials.add(Color::rgb(0.08, 0.08, 1.0).into()),
..Default::default()
})
.spawn(NodeComponents {
style: Style {
size: Size::new(Val::Percent(40.0), Val::Percent(40.0)),
..Default::default()
},
material: materials.add(Color::rgb(1.0, 0.08, 0.08).into()),
..Default::default()
});
绝对定位 #
你可以像这样将节点“绝对”定位到其父级的角点
commands
.spawn(NodeComponents {
style: Style {
size: Size::new(Val::Percent(40.0), Val::Percent(40.0)),
position_type: PositionType::Absolute,
position: Rect {
top: Val::Px(10.0),
right: Val::Px(10.0),
..Default::default()
},
..Default::default()
},
material: materials.add(Color::rgb(0.08, 0.08, 1.0).into()),
..Default::default()
});
父级关系 #
就像任何其他实体一样,节点可以有子节点。子节点相对于其父节点进行定位和缩放。默认情况下,子节点将始终显示在其父节点的前面。
commands
.spawn(NodeComponents {
style: Style {
size: Size::new(Val::Percent(60.0), Val::Percent(60.0)),
position_type: PositionType::Absolute,
position: Rect {
top: Val::Px(10.0),
right: Val::Px(10.0),
..Default::default()
},
..Default::default()
},
material: materials.add(Color::rgb(0.08, 0.08, 1.0).into()),
..Default::default()
})
.with_children(|parent| {
parent
.spawn(NodeComponents {
style: Style {
size: Size::new(Val::Px(50.0), Val::Px(50.0)),
..Default::default()
},
material: materials.add(Color::rgb(0.5, 0.5, 1.0).into()),
..Default::default()
});
});
弹性盒子 #
我在这里不会讲解弹性盒子是如何工作的,但你可以使用与在 Web 环境中使用相同的“弹性”属性。以下是如何在父级中垂直和水平居中两个节点的示例
commands
.spawn(NodeComponents {
style: Style {
size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..Default::default()
},
material: materials.add(Color::rgb(0.04, 0.04, 0.04).into()),
..Default::default()
})
.with_children(|parent| {
parent
.spawn(NodeComponents {
style: Style {
size: Size::new(Val::Px(80.0), Val::Px(80.0)),
..Default::default()
},
material: materials.add(Color::rgb(0.08, 0.08, 1.0).into()),
..Default::default()
})
.spawn(NodeComponents {
style: Style {
size: Size::new(Val::Px(80.0), Val::Px(80.0)),
..Default::default()
},
material: materials.add(Color::rgb(1.0, 0.08, 0.08).into()),
..Default::default()
});
});
文本和图像 #
节点也可以具有文本和图像组件,这些组件会影响节点的推断大小。
commands
.spawn(TextComponents {
text: Text {
value: "Hello from Bevy UI!".to_string(),
font: asset_server.load("FiraSans-Bold.ttf").unwrap(),
style: TextStyle {
font_size: 25.0,
color: Color::WHITE,
},
},
..Default::default()
})
.spawn(ImageComponents {
style: Style {
size: Size::new(Val::Px(200.0), Val::Auto),
position_type: PositionType::Absolute,
position: Rect {
top: Val::Px(10.0),
right: Val::Px(10.0),
..Default::default()
},
..Default::default()
},
material: materials.add(asset_server.load("bevy_logo.png").unwrap().into()),
..Default::default()
});
交互事件 #
具有 Interaction
组件的节点将跟踪交互状态。你可以用这种方式轻松构建按钮等小部件
例如,这里是一个只在交互状态发生变化的按钮上运行的系统
fn system(_button: &Button, interaction: Mutated<Interaction>) {
match *interaction {
Interaction::Clicked => println!("clicked"),
Interaction::Hovered => println!("hovered"),
Interaction::None => {},
}
}
2D 特性 #
精灵 #
你可以直接使用任何 Texture
资产作为精灵。
let texture = asset_server.load("icon.png").unwrap();
commands.spawn(SpriteComponents {
material: materials.add(texture.into()),
..Default::default()
});
精灵表 #
精灵表(也称为纹理图集)可用于动画、瓦片集,或仅用于优化精灵渲染。
let texture_atlas = TextureAtlas::from_grid(texture_handle, texture.size, 7, 1);
let texture_atlas_handle = texture_atlases.add(texture_atlas);
commands
.spawn(SpriteSheetComponents {
texture_atlas: texture_atlas_handle,
sprite: TextureAtlasSprite::new(0),
..Default::default()
});
动态纹理图集生成 #
精灵通常以单个文件的形式生成。Bevy 可以动态地将它们合并到一个精灵表中!
for sprite_handle in sprite_handles.iter() {
let texture = textures.get(&handle).unwrap();
texture_atlas_builder.add_texture(handle, &texture);
}
let texture_atlas = texture_atlas_builder.finish(&mut textures).unwrap();
3D 特性 #
GLTF 模型加载 #
将 GLTF 文件加载为网格资产
commands
.spawn(PbrComponents {
// load the model
mesh: asset_server.load("boat.gltf").unwrap(),
// create a material for the model
material: materials.add(asset_server.load("boat.png").into()),
..Default::default()
})
注意:在不久的将来,我们将添加对将 GLTF 文件加载为场景而不是网格的支持。
基于深度的绘制顺序 #
为不透明材质进行从前到后的绘制,以便快速“早期片段丢弃”,并为透明材质进行从后到前的绘制,以便正确显示。
父级关系 #
父级变换会传播到其后代。
commands
.spawn(PbrComponents {
mesh: cube_handle,
..Default::default()
}).with_children(|parent| {
parent.spawn(PbrComponents {
mesh: cube_handle,
translation: Translation::new(0.0, 2.0, 0.0),
..Default::default()
});
})
MSAA #
通过使用多重采样抗锯齿来获得平滑的边缘。
app.add_resource(Msaa { samples: 8 })
场景 #
场景是在提前时间内组合游戏/应用程序片段的一种方式。在 Bevy 中,场景仅仅是实体和组件的集合。场景可以被“生成”到 World
中任意次数。“生成”会将场景的实体和组件复制到给定的 World
中。
场景也可以保存到“场景文件”中,并从“场景文件”中加载。未来“Bevy 编辑器”的主要目标之一是让以视觉方式组成场景文件变得容易。
文件格式 #
场景文件以实体和组件的扁平列表形式保存和加载
[
(
entity: 328997855,
components: [
{
"type": "Position",
"map": { "x": 3.0, "y": 4.0 },
},
],
),
(
entity: 404566393,
components: [
{
"type": "Position",
"map": { "x": 1.0, "y": 2.0 },
},
{
"type": "Name",
"map": { "value": "Carter" },
},
],
),
]
分配给 entity
字段的数字是实体的 ID,这些 ID 是完全可选的。如果没有提供实体 ID,则在加载场景时会随机生成一个。我们有 计划在未来改进这种格式,使其更符合人体工程学,缩进实体层次结构,并支持嵌套场景。
加载和实例化 #
可以使用 SceneSpawner
资源将场景添加到 World
中。可以使用 SceneSpawner::load
或 SceneSpawner::instance
进行生成。“加载”场景会保留其中的实体 ID。这对于保存文件之类的情况很有用,在这种情况下,你希望实体 ID 保持不变,并且希望在世界中已有的实体之上应用更改。“实例化”会将实体添加到 World
中,并使用全新的 ID,这允许在同一个 World
中存在多个场景的“实例”。
fn load_scene_system(asset_server: Res<AssetServer>, mut scene_spawner: ResMut<SceneSpawner>) {
// Scenes are loaded just like any other asset.
let scene: Handle<Scene> = asset_server.load("my_scene.scn").unwrap();
// Spawns the scene with entity ids preserved
scene_spawner.load(scene);
// Spawns the scene with new entity ids
scene_spawner.instance(scene);
}
将 ECS 世界保存到场景 #
任何 ECS World
都可以像这样转换为场景
let scene = Scene::from_world(&world, &component_type_registry);
然后,你可以像这样将场景转换为 RON 格式的字符串
let ron_string = scene.serialize_ron(&property_type_registry)?;
热场景重载 #
对场景文件的更改可以自动应用于运行时生成的场景。这允许立即获得反馈,而无需重新启动或重新编译。
请注意,上面的视频没有加速。场景更改实际上是即时应用的。
这是如何实现的? #
场景建立在 Bevy 的属性和资产系统之上。只要组件派生了 Properties
特性,它们就可以在场景中使用。属性使场景序列化、反序列化和在运行时修补更改成为可能。请查看下一节以获取更多详细信息。
属性 #
简而言之,Bevy 属性为 Rust 这种以静态著称的语言添加了一些动态性。通常情况下,使用字段名称的字符串版本获取或设置结构体的字段,或在没有静态类型引用时与结构体交互非常有用。语言通常会使用“反射”功能来处理这些情况,但不幸的是,Rust 目前还没有这种反射功能。我创建了 bevy_property
库,为 Rust 提供一些有用的“类似反射”功能。以下是快速表面层级的介绍。
#[derive(Properties)]
pub struct Counter {
count: u32,
}
let mut counter = Counter { count: 1 };
// You can set a property value like this. The type must match exactly or this will fail.
counter.set_prop_val::<u32>("count", 2);
assert_eq!(counter.count, 2);
assert_eq!(counter.prop_val::<u32>("count").unwrap(), 2);
// You can also set properties dynamically. set_prop accepts any type that implements the Property trait, but the property type must match the field type or this operation will fail.
let new_count: u32 = 3;
counter.set_prop("count", &new_count);
assert_eq!(counter.count, 3);
// DynamicProperties also implements the Properties trait, but it has no restrictions on field names or types
let mut patch = DynamicProperties::map();
patch.set_prop_val::<usize>("count", 4);
// You can "apply" Properties on top of other Properties. This will only set properties with the same name and type. You can use this to "patch" your properties with new values.
counter.apply(&patch);
assert_eq!(counter.count, 4);
// Types that implement Properties can be converted to DynamicProperties
let dynamic_thing: DynamicProperties = counter.to_dynamic();
属性使 Bevy 的场景系统易于使用。我还计划在即将推出的 Bevy 编辑器中使用它们,例如撤消/重做、在运行时查看和编辑组件属性,以及属性动画工具。
实现 Properties 的类型可以使用 serde 序列化,DynamicProperties
可以使用 serde 反序列化。当与 Properties
修补功能结合使用时,这意味着任何派生 Properties
的类型都可以进行往返序列化和反序列化。
要派生 Properties
,结构体中的每个字段都必须实现 Property
特性。这已在大多数核心 Rust 和 Bevy 类型中实现,因此您只需为自定义类型实现 Property
(您也可以派生 Property
)。
我感觉 bevy_property
库在非 Bevy 上下文中也会很有用,因此我将在不久的将来将其发布到 crates.io。
事件 #
Bevy 使用双缓冲事件系统,该系统能够高效地生成和消费事件,并且零分配事件消费者。以下是一个完整的使用 Bevy 应用,它生成并消费自定义事件。
fn main() {
App::build()
.add_event::<MyEvent>()
.add_system(event_producer.system())
.add_system(event_consumer.system())
.run();
}
struct MyEvent {
message: String,
}
fn event_producer(mut my_events: ResMut<Events<MyEvent>>) {
my_events.send(MyEvent { message: "Hello".to_string() });
}
#[derive(Default)]
struct State {
reader: EventReader<MyEvent>,
}
fn event_consumer(mut state: Local<State>, my_events: Res<Events<MyEvent>>) {
for event in state.reader.iter(&my_events) {
println!("received message: {}", event.message);
}
}
app.add_event::<MyEvent>()
为 MyEvent 添加一个新的 Events
资源,以及一个在每次更新时交换 Events<MyEvent>
缓冲区的系统。 EventReaders
创建起来非常便宜。它们本质上只是一个数组索引,用于跟踪已读取的最后一个事件。
事件在 Bevy 中用于诸如窗口调整大小、资产和输入之类的功能。对于分配和 CPU 效率的权衡是,每个系统只有一次机会接收事件,否则它将在下次更新时丢失。我认为这是在循环中运行的应用程序(例如游戏)的正确权衡。
资产 #
Bevy Assets
只是可以使用资产 Handles
引用的类型化数据。例如,3D 网格、纹理、字体、材质、场景和声音都是资产。 Assets<T>
是类型为 T
的资产的泛型集合。通常情况下,资产使用看起来像这样。
资产创建 #
fn create_texture_system(mut textures: ResMut<Assets<Texture>>) {
// creates a new Texture asset and returns a handle, which can then be used to retrieve the actual asset
let texture_handle: Handle<Texture> = textures.add(Texture::default());
}
资产访问 #
fn read_texture_system(textures: Res<Assets<Texture>>, texture_handle: &Handle<Texture>) {
// retrieves a Texture using the current entity's Handle<Texture> component
let texture: &Texture = textures.get(texture_handle).unwrap();
}
资产事件 #
Assets<T>
集合基本上只是从 Handle<T>
到 T
的映射,它记录已创建、修改和删除的 Events
。这些事件也可以作为系统资源消费,就像任何其他 Events
一样。
fn system(mut state: Local<State>, texture_events: Res<Events<AssetEvent>>) {
for event in state.reader.iter(&texture_events) {
if let AssetEvent::Created { handle } = event {
/* do something with created asset */
}
}
}
资产服务器 #
Assets<T>
集合不了解文件系统或多线程。这是 AssetServer
资源的职责。
fn system(mut commands: Commands, asset_server: Res<AssetServer>, mut textures: ResMut<Assets<Texture>>) {
// this will begin asynchronously loading "texture.png" in parallel
let texture_handle: Handle<Texture> = asset_server.load("texture.png").unwrap();
// the texture may not be loaded yet, but you can still add the handle as a component immediately.
// whenever possible, internal Bevy systems will wait for assets to be ready before using them:
let entity = commands.spawn((texture_handle,));
// you can also asynchronously load entire folders (recursively) by adding them as an "asset folder"
asset_server.load_asset_folder("assets").unwrap();
// you can get the handle of any asset (either currently loading or loaded) like this:
let music_handle: Handle<AudioSource> = asset_server.get_handle("assets/music.mp3").unwrap();
// when assets have finished loading, they are automatically added to the appropriate Assets<T> collection
// you can check if an asset is ready like this:
if let Some(texture) = textures.get(&texture_handle) {
// do something with texture
}
// sometimes you want access to an asset immediately. you can block the current system until an asset has
// finished loading and immediately update Assets<T> using the "load_sync" method
let cool_sprite: &Texture = asset_server.load_sync(&mut textures, "assets/cool_sprite.png").unwrap();
}
热重载 #
您可以通过调用以下内容来启用资产更改检测
asset_server.watch_for_changes().unwrap();
这将在其文件发生更改时加载新版本的资产。
添加新的资产类型 #
要添加新的资产类型,请实现 AssetLoader
特性。这会告诉 Bevy 要查找哪些文件格式以及如何将文件字节转换为给定的资产类型。
一旦为 MyAssetLoader
实现 AssetLoader<MyAsset>
,您就可以像这样注册您的新加载器。
app.add_asset_loader::<MyAsset, MyAssetLoader>();
然后您可以访问 Assets<MyAsset>
资源,侦听更改事件,并调用 asset_server.load("something.my_asset")
声音 #
您目前可以像这样加载和播放声音
fn system(asset_server: Res<AssetServer>, audio_output: Res<AudioOutput>) {
let music: Handle<AudioSource> = asset_server.load("music.mp3").unwrap();
// this will play the music asynchronously as soon as it has loaded
audio_output.play(music);
// if you already have an AudioSource reference you can play it immediately like this:
audio_output.play_source(audio_source);
}
我们计划在将来使用更多控制和功能来扩展音频系统。
渲染图 #
所有渲染逻辑都建立在 Bevy 的 RenderGraph
之上。渲染图是一种对渲染逻辑的原子单元进行编码的方式。例如,您可以为 2D 通道、UI 通道、相机、纹理复制、交换链等创建图形节点。将一个节点连接到另一个节点表示它们之间存在某种依赖关系。通过以这种方式对渲染逻辑进行编码,Bevy 渲染器能够分析依赖关系并并行渲染图形。它还有助于鼓励开发人员编写模块化渲染逻辑。
Bevy 默认包含许多节点:CameraNode
、PassNode
、RenderResourcesNode
、SharedBuffersNode
、TextureCopyNode
、WindowSwapChainNode
和 WindowTextureNode
。它还提供用于 2D 渲染、3D 渲染和 UI 渲染的子图。但欢迎您创建自己的节点、自己的图形或扩展包含的图形!
数据驱动着色器 #
组件和资产可以派生 RenderResources
特性,这使它们能够直接复制到 GPU 资源并用作着色器制服。
将制服绑定到自定义着色器就像在您的组件或资产上派生 RenderResources
一样简单
#[derive(RenderResources, Default)]
struct MyMaterial {
pub color: Color,
}
然后将新的 RenderResourceNode 添加到渲染图中
// create the new node
render_graph.add_system_node("my_material", RenderResourcesNode::<MyMaterial>::new(true));
// connect the new node to the "main pass node"
render_graph.add_node_edge("my_material", base::node::MAIN_PASS).unwrap();
从那里开始,MyMaterial 组件将自动复制到 GPU 缓冲区。着色器随后可以像这样引用实体的 MyMaterial
layout(set = 1, binding = 1) uniform MyMaterial_color {
vec4 color;
};
我认为 完全独立的自定义着色器示例 的简单性不言自明。
着色器定义 #
组件和资产也可以添加“着色器定义”,以便在每个实体的基础上选择性地启用着色器代码。
#[derive(RenderResources, ShaderDefs, Default)]
struct MyMaterial {
pub color: Color,
#[render_resource(ignore)]
#[shader_def]
pub always_blue: bool,
}
然后在您的片段着色器中,您可以执行以下操作
void main() {
o_Target = color;
# ifdef MYMATERIAL_ALWAYS_BLUE
o_Target = vec4(0.0, 0.0, 1.0, 1.0);
# endif
}
任何具有 MyMaterial
组件和 always_blue: true
的实体将以蓝色渲染。如果 always_blue
为 false,则将使用 color
渲染。
我们目前使用此功能来切换“无阴影”渲染和可选纹理,但我预计它在各种情况下都会很有用。
着色器布局反射 #
Bevy 可以从 SpirV 着色器(以及通过将 GLSL 着色器编译为 SpirV 从 GLSL 着色器)自动反射着色器数据布局。这意味着创建自定义着色器就像这样简单
let shader_stages = ShaderStages {
vertex: shaders.add(Shader::from_glsl(ShaderStage::Vertex, VERTEX_SHADER)),
fragment: Some(shaders.add(Shader::from_glsl(ShaderStage::Fragment, FRAGMENT_SHADER))),
};
let pipeline_handle = pipelines.add(PipelineDescriptor::default_config(shader_stages));
高效的编译时间 #
我对 Bevy 的主要设计目标之一是“生产力”。游戏开发是一个极其迭代和实验性的过程,充满了微小的变化。如果每次更改都需要大量时间来测试,那么开发就会变得枯燥乏味。以下是我个人对迭代更改的“可接受性”规模。
- 0-1 秒:理想
- 1-3 秒:还可以
- 3-5 秒:令人讨厌
- 5-10 秒:很痛苦,但如果您已经投入了,仍然可以使用
- 10 秒以上:完全不可用
请注意,这些是“迭代编译时间”,而不是“干净编译时间”。干净编译只需要发生一次,而迭代编译则会不断发生。在生产力方面,我不太关心“干净编译”指标,尽管出于其他原因,保持干净编译时间仍然很重要。
如今,最流行的 Rust 引擎之一需要超过 30 秒才能编译插入到简单示例中的单个换行符。这绝对是不可接受的,并且使得真正进行游戏开发几乎不可能。
目前,使用“快速编译”配置,对 Bevy 示例的更改可以在大约 0.8-3 秒内编译完成,具体取决于您的计算机规格、配置和操作系统选择(稍后会详细介绍)。当然,这里总有改进的空间,但 Bevy 目前处于我的“可用性最佳点”。
“Rust 编译速度慢”的梗主要是因为许多 Rust 项目对某些代码模式的编译时间性能影响考虑得不够。Rust 代码通常会缓慢编译,原因有三个。
- 泛型单态化:编译步骤,在此步骤中泛型代码将被转换为非泛型副本。随着单态化代码量的增加,编译时间也会增加。为了降低成本,您应该完全避免泛型,或者使泛型代码“较小”和“较浅”。
- 链接时间:链接代码需要多长时间。这里重要的是要保持代码量和依赖关系数量较低。
- LLVM:Rust 向 LLVM 投掷大量 IR 代码,并希望 LLVM 对其进行优化。这需要时间。此外,LLVM 针对“运行时快速代码”进行了优化,而不是“快速代码生成”。
LLVM 部分我们无法控制(至少现在是这样)。保持泛型使用较低和较浅并不是一个特别困难的问题,只要您从一开始就采用这种思维方式。另一方面,链接时间是迭代编译时间的持续且非常现实的“敌人”。每次迭代编译都会进行链接。向您的项目添加任何代码都会增加链接时间。向您的项目添加任何依赖项都会增加链接时间。
由于各种原因,我们处于劣势。
- 游戏引擎领域
- 游戏引擎本质上会涉及大量领域(因此会涉及大量依赖关系)。
- 游戏引擎“很大”……它们需要大量代码。
- Rust 的设计选择
- 依赖项默认情况下是静态链接的,这意味着每个新的依赖项都会增加链接时间。
- Rust 的默认链接器速度很慢。
- Cargo 使得获取依赖项变得非常容易。看似很小、简单的库实际上可能具有庞大的依赖项树。
解决此问题的一种方法是尽一切可能避免依赖项,并编写尽可能少的代码。 Macroquad 项目就是一个很好的例子。他们采用极简主义的代码方法,并避免任何不符合其严格编译时间要求的依赖项。因此,我认为可以公平地说,对于干净编译和迭代编译,它们都是编译速度最快(同时仍然可用)的 Rust 游戏引擎。但是,他们的方法是以依赖项厌恶为代价的。
Bevy 采用了一种更加务实的方法。首先,愿意获取依赖项对 Rust 生态系统来说是好事。我不想忽视已经完成的所有出色工作,尤其是在 winit 和 wgpu 这样的项目方面。但我们仍然努力使我们的依赖项树尽可能小。任何将 Bevy 从“理想到良好”的迭代编译时间范围内剔除的依赖项都必须进行简化或删除。当与“快速编译”配置结合使用时,这将带来良好的编译时间。
“快速编译”配置 #
“快速编译”配置是我们如何在仍然获取依赖项的情况下实现可用的迭代编译时间的方式。它包含三个部分。
- LLD 链接器:LLD 在链接方面比默认的 Rust 链接器快得多。这是最大的优势。
- Nightly Rust 编译器:提供对最新性能改进和“不稳定”优化的访问。请注意,如果需要,Bevy 仍然可以在稳定的 Rust 上编译。
- 通用共享: 允许箱子共享单态化泛型代码,而不是重复代码。在某些情况下,这允许我们“预编译”泛型代码,使其不会影响迭代编译。
为了启用快速编译,请安装 nightly rust 编译器和 LLD。然后将 此文件 复制到 YOUR_WORKSPACE/.cargo/config
当前限制和未来改进 #
虽然 Bevy 目前按我的标准来说是“高效的”,但它还没有完全完美无缺。首先,MacOS 没有最新版本的 LLD 链接器,因此该平台上的迭代编译速度要慢得多。此外,LLD 在 Windows 上的速度比在 Linux 上稍慢。在我的机器上,我在 Windows 上获得的编译时间约为 1.5-3.0 秒,而在 Linux 上约为 0.8-3.0 秒。
动态链接来救援 #
减少链接时间的简单方法是直接进行动态链接。在我的 2013 款 MacBook Pro 上运行 MacOS(没有 LLD),我通过动态链接应用程序插件,将 Bevy 的迭代编译时间从约 6 秒减少到约 0.6 秒。Bevy 实际上已经支持动态应用程序插件,但新的 Bevy ECS 目前不支持动态链接,因为它依赖于 TypeId(与动态链接不兼容)。幸运的是,我已经在其他项目中解决了 TypeId 问题,因此我们应该很快就能将它加回来。
Cranelift Rustc 后端 #
Cranelift 是一种替代编译器后端,针对快速编译进行了优化。 rustc cranelift 后端 正在快速接近可用状态。我希望它最终能给我们带来不错的提升。
示例游戏:Breakout #
如果您对实际的 Bevy 游戏代码的样子感到好奇,请查看 Breakout 示例。请原谅我有点蹩脚的碰撞代码 :)
为什么要构建 Bevy? #
市面上有很多很棒的引擎……为什么要再建一个?尤其是在 Rust 生态系统中已经存在这么多引擎的情况下?
首先介绍一下我:我是在多年为其他引擎贡献代码(例如 Godot)后决定构建 Bevy 的。我花了几年时间 用 Godot 制作游戏,而且我对 Unity、Unreal 和一些其他框架(如 SDL 和 Three.js)也有经验。我过去已经使用 Rust、Go、HTML5 和 Java 构建了多个定制引擎。我还使用过或密切关注过 Rust 游戏开发生态系统中的大多数当前参与者。我最近辞去了微软高级软件工程师的职位,我在那里的经历极大地影响了我对软件及其应有状态的看法。
这些经历让我想要从游戏引擎中获得以下内容
- 免费和开源: 它需要是免费和开源的,并且没有任何附加条件。游戏是我们文化的重要组成部分,人类正在将数百万小时投入到游戏开发中。为什么我们(作为游戏开发者/引擎开发者)要继续构建封闭源代码垄断企业的生态系统,这些企业会从我们的销售中抽取利润,并拒绝我们了解我们每天使用的技术的可见性?作为一个社区,我相信我们可以做得更好。这个标准排除了 Unreal 和 Unity,尽管它们拥有庞大的功能集。
- 高效: 它需要具有快速构建/运行/测试循环,这意味着使用脚本语言或原生语言的快速编译时间。但脚本语言会引入运行时开销、认知负荷以及我和实际引擎之间的障碍,因此我的偏好是使用具有快速编译时间的原生语言。遗憾的是,编译时间是 Rust 生态系统中一个巨大的问题,许多 Rust 引擎的迭代编译时间过长。幸运的是,像 Macroquad 和 coffee 这样的 Rust 游戏引擎证明了高效的迭代编译时间是可能的。
- 层层嵌套: 理想情况下,引擎是用与游戏相同的语言编写的。能够在您的游戏中运行一个 IDE“转到定义”命令,并直接跳入引擎源代码是一个非常强大的概念。您也不需要担心繁重的语言转换层或有损抽象。如果一个引擎的社区用与引擎相同的语言构建游戏,那么他们更有可能(并且能够)回馈引擎。
- 简单: 它需要易于用于常见任务,但也不能向你隐藏细节。许多引擎要么“易于使用但过于高级”,要么“非常底层但难以进行常见任务”。此外,许多 Rust 中的引擎都充斥着生命周期和泛型。这两种都是强大的工具,但它们也会引入认知负荷并降低人体工程学。如果您不谨慎,泛型还会对编译时间产生重大影响。
- 编辑器: 它需要一个(可选的)图形编辑器。场景创建是游戏开发的重要组成部分,在许多情况下,可视化编辑器胜过代码。作为奖励,编辑器应该在引擎内部构建。Godot 使用这种方法,这非常聪明。这样做可以 自我测试 引擎的 UI 系统,并创建正反馈循环。对编辑器的改进通常也是对核心引擎的改进。它还确保您的引擎足够灵活,可以构建工具(而不仅仅是游戏)。我个人认为,在另一个堆栈中构建一个引擎的编辑器是一个错失的机会(例如,Web、QT、原生小部件)。
- 数据驱动: 它需要是数据驱动的/数据导向的/数据优先的。ECS 是实现此目的的一种常见方法,但它绝对不是唯一的方法。这些范式可以使您的游戏更快(缓存友好,更容易并行化),但它们也使常见任务(如游戏状态序列化和同步)变得非常简单明了。
市面上还没有完全符合我要求的引擎。而要使它们满足我的要求所需的变化范围要么巨大,要么不可能(封闭源代码),要么不受欢迎(我想要的东西不是开发人员或客户想要的)。除此之外,制作新的游戏引擎也很有趣!
Bevy 并没有试图超越其他开源游戏引擎。我们应该尽可能地进行合作,建立共同的基础。如果您是开源游戏引擎开发者,并且您认为 Bevy 组件可以使您的引擎更强大,或者您的引擎的某个组件可以使 Bevy 更强大,或者两者兼而有之,请与我们联系!Bevy 已经从 Rust 游戏开发生态系统的努力中获益匪浅,我们很乐意以任何方式回馈它。
下一步是什么? #
我对 Bevy 在相对较短的时间内取得的进步感到自豪,但还有很多工作要做。这些将是我们未来几个月关注的重点领域
基于物理的渲染 (PBR) #
Bevy 当前的 3D 渲染器非常简陋。由于我主要制作 3D 游戏,因此改进 3D 渲染器是我的优先事项。我们将添加 PBR 着色器、阴影、更多照明选项、骨骼动画、改进的 GLTF 导入、环境光遮蔽(实现待定),以及可能的其他一些东西。
编辑器 #
Bevy 是针对可视化编辑器设计的。场景和属性系统是专门为使游戏<->编辑器数据流更友好而构建的。编辑器将作为 Bevy 应用程序构建,并将利用现有的 Bevy UI、Bevy ECS、场景和属性功能。我喜欢“在引擎中构建编辑器”的方法,因为对编辑器的改进通常也是对引擎的改进,反之亦然。此外,它确保 Bevy 能够构建非游戏应用程序和工具。
平台支持:Android、iOS、Web #
在幕后,Bevy 使用 winit(用于多平台窗口和输入)和 wgpu(用于多平台渲染)。这些项目中的每一个都对上述平台提供不同程度的支持。总的来说,Bevy 的设计目标是平台无关性,因此,通过少量工作,支持上述平台应该是可行的。
渲染批处理和实例化 #
目前,Bevy 的渲染速度足以满足大多数用例,但当涉及到渲染大量对象(数万个)时,它还不够快。为了实现这一目标,我们需要实现批处理/实例化。这些概念可以用多种方式定义,但总的来说,我们将尽可能将尽可能多的几何图形和数据分组到最少的绘制调用中,同时尽可能减少 GPU 状态更改。我希望 Bevy 的数据驱动着色器方法将使实例化实现变得简单且可扩展。
画布 #
目前,绘制 UI 和 2D 场景的唯一方法是通过精灵和矩形。Bevy 需要一个能够绘制抗锯齿曲线和形状的立即模式绘制 API。然后,可以使用它来代码驱动地绘制诸如 Bevy UI 中的圆角、编辑器中的性能图表等内容。我们很有可能将 pathfinder 或 lyon 这样的项目集成到其中。
动画 #
动画几乎贯穿游戏开发的方方面面。首先,我想添加一个通用的代码优先动画系统。在此基础上,我们将添加一个基于属性的时间线系统,该系统可以保存到配置文件中,并在 Bevy 编辑器中可视化/编辑。
更友好的场景格式 #
当前的场景格式可以正常工作,但它还不适合手动场景组合,因为它是一个无序实体的扁平列表。我还想添加嵌套场景。最终,我希望场景格式 看起来像这样。
动态插件加载 #
为了减轻编译和链接插件的成本,并使热代码重载成为可能,我们将提供动态加载应用程序插件的选项。Bevy 实际上已经支持此功能,但存在一个障碍:Rust 的 TypeId
。TypeId 在不同二进制文件之间是不稳定的,这意味着主机二进制文件中的 TypeId::of::
与动态加载的二进制文件中的 TypeId::of::
不匹配。Bevy ECS 使用 TypeId,这意味着动态加载的 ECS 类型将无法正常工作。在过去,Bevy 使用了 Legion ECS 的自定义分支(我们修复了 TypeId 问题)。但是,自从迁移到 Bevy ECS 以来,这个问题再次出现。解决方法是将我们在 Legion 中使用的方法应用于 Bevy ECS。
物理 #
许多游戏需要碰撞检测和物理。我计划构建一个可插入的物理接口,以 nphysics / ncollide 作为第一个后端。
润色 #
有很多领域需要更多的设计工作或功能。例如,我认为核心渲染图处于一个相当不错状态,但中级和高级渲染 API 需要更多的时间和实验。我还想重新思考材料的组成方式。如果您对我们关注的所有改进感到好奇,请查看 GitHub 上的 Bevy 问题跟踪器。
文档 #
Bevy 的 API 仍然处于不稳定状态,因此文档比较零散。 Bevy 手册 仅涵盖引擎中相对成熟的领域,而 Rust API 文档 存在很多缺漏。 总体而言,我认同 “文档稳定性比例” 的理念。 随着功能稳定和设计模式的出现,我们将在这两个领域加大投入。
加入 Bevy! #
如果这些内容对你来说很有吸引力,我建议你查看 Bevy 的 GitHub 仓库,阅读 快速入门指南,并 加入 Bevy 社区。 目前 Bevy 完全由志愿者开发,因此如果你想帮助我们构建下一个伟大的游戏引擎,请与我们联系! 我们需要所有人的帮助,尤其是:
我希望 Bevy 成为一个充满活力的开发者社区……实际上这也是我选择这个名字的原因! Bevy 指一群鸟类,就像我们是一群游戏开发者一样。 加入 Bevy 吧!