序言
撰写本书的原因
关于 Bevy 的系统性中文指南至今仍然欠缺,互联网上的资料零散、碎片化,且大多停留在如何做的层面,很少触及为什么这样设计。想要深入理解 Bevy 的内部机制,往往需要自行翻阅大量源码与文档,这消耗了开发者大量的时间,也严重阻碍了 Bevy 生态在中文社区的成长。
Bevy 经过五年的发展,距离 1.0 完成度已接近八成,其架构设计也趋于稳定。加之作者本人对Bevy比较感兴趣,遂决定撰写一本从源码出发、深入设计哲学的指南(本书撰写始于2025年10月11日)。
本书是什么
这是一本从源码出发、以理解引擎如何运作为目标的 Bevy 深度导读。
本书不教你搓一个简单的游戏,而是带你拆开 Bevy 这个复杂系统,看它的每一块齿轮是怎么咬合的,本书的内容和目的是解释如下问题。
-
ECS 是如何在运行时管理成千上万个实体与组件
-
反射是如何让静态语言获得动态能力的
-
Bevy是如何做到无锁并行的
-
宏是如何在编译期生成一整套 trait 实现的
-
渲染管线是如何把数据从 CPU 搬到 GPU的
-
GPU是如何把数据渲染成图像的
……以及更多底层而复杂的问题。
本书不是什么
- 不是游戏开发教程。作者自己其实很少玩游戏,这本书也不是来教做游戏的。读完本书,你不会得到一个完整的游戏 Demo。(或许以后会有?)
- 不是 API 手册。API 会随版本变化,但设计思想不会。本书关注的是后者。
- 不是面面俱到的百科全书。物理引擎、音频细节、UI 系统等内容,本书要么简略带过,要么留给其他资料。
读完本书,你将能够
- 独立阅读 Bevy 的源码,理解它的模块划分与设计决策
- 理解 ECS、反射、宏、调度图、渲染管线背后的核心机制
- 在 Bevy 版本发生重大变化时,快速迁移自己的代码与理解
- 具备进一步研究引擎、框架、系统级软件的底层能力
- 对复杂系统如何被设计出来这件事,形成自己的判断力
但你不会因此成为游戏开发者。这不是本书的目标。
本书适合谁
本书假设你:
- 已经初步掌握 Rust(trait、泛型、所有权、生命周期)
- 学过基本线性代数(矩阵乘法、向量点积)
- 对游戏开发、图形学、GPU 编程没有先验要求
如果你属于以下任意一种人,本书会非常适合你:
- 学过 Rust,但没见过一个真实的、大型的 Rust 项目是如何组织宏、trait 和模块的
- 学过线性代数,但想知道矩阵变换在 3D 引擎里到底是怎么被用起来的
- 对反射、宏、ECS、渲染管线这些魔法感到好奇,想知道它们是怎么被设计出来的
- 想理解游戏引擎这个复杂系统
- 未来想自己写引擎、写框架,或对系统级软件设计感兴趣
如果你只是想快速做一个游戏出来,本书可能不适合你。但如果你想理解 Bevy 为什么这样设计,并且愿意和我一起从源码出发去追根究底,那么来对地方了~
本书的撰写风格
本书采用个人笔记式的源码阅读风格。先自顶向下帮助读者建立对Bevy的API的初始印象,然后自底向上分析Bevy的底层逻辑与源代码,逐步展开背后的设计动机。
- 我会大量地问为什么,然后去源码里找答案。
- 我会在必要的地方从0给出数学推导,也会在更多地方给出直觉解释。
- 我会承认自己没搞懂的地方,也会在后续版本中回头修正。
- 我不会假装自己什么都知道,但我会尽量把自己走过的弯路讲清楚。
最终目标不是让你记住 API,而是让你理解 Bevy 的设计哲学与内部原理。当你真正理解这套设计之后,API 的变化对你来说只是换了一层皮。即使未来 Bevy 发生了重大变化,你也能轻松上手。
本书结构
本书计划分为上中下三册:
- 上册:自顶向下结构,Bevy 总览、ECS 架构、资产系统、时间、2D / 3D 渲染基础、空间变换、相机、动画、反射基础。上册的目标是让你能够读懂 Bevy examples 中的绝大部分内容。
- 中册:自顶向下 + 自底向上双模式,深入反射系统与有向无环并行图的构建与运作原理,包括
bevy_reflect的 derive 宏实现、调度图的构建与执行、并行调度的冲突检测等。 - 下册:自顶向下 + 自底向上双模式,深入 Bevy 的渲染管线与
wgpu渲染系统,包括渲染图、Extract 阶段、RenderCommand、着色器绑定等。
关于更新
本书目前正在积极撰写中,也是我本人学习 Bevy 过程中的经验总结。由于事务缠身(导师横向、学术研究、其他项目),更新可能较慢,但绝不会弃坑。欢迎合作与贡献。
其他
Github Pages:线上书籍在线观看。https://ailrid.github.io/Bevy-Engine-Guide/
virid:我的另一个项目,Bevy 思想的落地实践——使用类 ECS 实现的一站式重型 Electron 应用开发框架,将 Bevy 的设计哲学和 Rust 理念嵌入 TypeScript 与 Electron。https://github.com/Ailrid/virid
⚖️ 版权与许可
我花了很多个深夜去磨这些章节,每一小节都要写作数个小时甚至半天。如果你觉得有用,那是对我最大的认可。
为了保护这些心血不被滥用,本书文字、图片及示例代码均采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 (CC BY-NC-SA 4.0) 进行许可。
这意味着:
- 署名:您可以自由地转载、引用或基于本书进行二次创作,但必须保留原作者(Ailrid)的署名并提供本书链接。
- 非商业性使用:您不得将本书内容或其衍生作品用于任何商业目的(如付费课程、出版获利、商业 AI 训练)。
- 相同方式共享:如果您对本书进行了修改、转换或二次创作,您必须使用相同的许可协议分发您的贡献。
Caution
本书部分图像来自公开网络搜集,无法联系作者,若侵权请联系进行删除。
第 1 章:Bevy 总览
1.1 章节序言
孔明在荆州,与石广元、徐元直、孟公威俱游学,三人务于精熟,而亮独观其大略。 —— 《魏略》
笔者一直认为,学习一件事要由粗入细,由浅入深,因此本章的目标主要是快速让读者对于Bevy能有一个大概的印象,了解Bevy程序的主要组成部分。大量的细节部分我都将略去,因此读者如现在遇到不理解的地方可以不求甚解,后续的章节将会依次仔细展开各个部分进行详细的介绍。
1.2 Hello Bevy!
将Bevy作为一个依赖项,新建一个Rust项目,输入以下代码让我们开始吧
use bevy::prelude::*;
fn main() {
App::new()
.add_systems(Update, hello_world)
.run();
}
fn hello_world() {
println!("hello world");
}
上面的这些代码的含义不言而喻,我们实例化了一个App,然后在系统更新时调用hello_world函数,最后运行。使用cargo命令运行这些代码,预想中的游戏窗口和界面并未出现,而是在终端上打印了一条“hello_world“后程序就关闭了,这是怎么回事?
1.2.1 窗口与循环
Bevy的设计理念是插件化的,这意味着每一项功能对于Bevy来说都是以插件的形式启用的,因此窗口的显示、游戏的循环逻辑等功能都需要引入插件。在上面的代码中,我们未添加任何插件,只是简单的声明了 App后调用了hello_world函数,因此程序将在终端中打印一条消息,然后立刻退出。
然而,由于窗口和游戏循环的创建和管理几乎是必选项,除非你想让你的应用在无窗口模式下运行。因此Bevy提供了一组默认的插件用来启用这些功能,更改我们的代码如下,再运行一次,窗口就会显示出来了,同时控制台将不断打印“hello_world“。
#![allow(unused)]
fn main() {
App::new()
//加入这行代码
.add_plugins(DefaultPlugins)
.add_systems(Update, hello_world)
.run();
}
这行代码都导入了哪些插件?点开DefaultPlugins的定义,可以看到其中包含如下代码,其中就包含了基本的窗口显示、资源加载、渲染、窗口事件、鼠标键盘输入等。感兴趣的读者可以查看详细的文档,这里不再详细展开。
#![allow(unused)]
fn main() {
#[cfg(feature = "bevy_window")]
bevy_window:::WindowPlugin,
...
bevy_asset:::AssetPlugin,
#[cfg(feature = "bevy_scene")]
bevy_scene:::ScenePlugin,
#[cfg(feature = "bevy_winit")]
bevy_winit:::WinitPlugin,
#[cfg(feature = "bevy_render")]
bevy_render:::RenderPlugin,
......
}
1.2.2 调度系统
前面说到,在系统更新时,调用了我们的hello_world函数,那么什么是更新时呢?这就涉及到了Bevy的调度系统以及Bevy的整个生命周期。
在Bevy中,系统的调度由Schedule执行,Schedule包含了一个函数的集合,将在游戏的不同时间段,利用元数据去执行这些函数。换句话说,Schedule负责执行游戏的开始、循环、结束逻辑,并在合适的时候执行用户或者系统的函数。Bevy应用的调度主要有三部分组成,他们的名称如下表所示。
| 名称 | 作用 |
|---|---|
| Main | 游戏的主世界主要逻辑 |
| Extract | 将游戏数据从主世界转移到渲染世界 |
| Render | 将渲染世界里的数据渲染数据到屏幕上 |
这里提到了主世界与渲染世界,何为主世界?何为渲染世界?
按照传统的渲染流程,系统的工作是顺序的即:更新->渲染->更新->渲染,这样的工作方式导致下一帧的更新需要等待上一帧的渲染结束(比如csgo就是这样的,所以玩csgo帧率越高你赢得机会就越大不是没有道理)。
将渲染步骤剥离出来,划分为主世界与渲染世界,并在其中添加Extract同步系统,即可将其变为并行工作的系统提高效率。这便是主世界与渲染世界的由来,这种分离的方式将游戏划分为三部分并分别独立出来,能够使得系统解偶的同时提高并行化。
在游戏的主要逻辑中,我们主要需要关注的就是Main中的逻辑,如果需要进行高级图形处理定制化渲染效果,则需要对Extract和Render过程进行修改。在Main调度中,又分为以下10个步骤,其含义不言而喻,Startup的三个过程在启动中只会执行一次,然后游戏将在一个Update循环中不断运行,之前我们在运行hello_world时所指定的Update就是这里。
在这其中,除了RunFixedMainLoop外,几乎所有的步骤都是不言而喻的,那么RunFixedMainLoop是什么?
一般而言,在游戏逻辑中,存在着两种更新方式:
- 游戏的画面将以某些帧率,在每帧都进行更新
- 游戏的逻辑应该与帧率无关,应该是实际的物理时间
这样的更新方式确保了即使我们的游戏帧率发生变化,其游戏逻辑,例如攻击,闪避等指令的物理的花费时间仍然相同,这是非常重要的,而这个计时的方式,即是RunFixedMainLoop的存在意义。RunFixedMainLoop中同样也有一个类似的FixedPreUpdate、FixedUpdate等环节,但不同的是这些调度中的逻辑是按照一定的时间间隔而执行的,这意味着虽然在每一次游戏循环中都会经历,但是并不一定代表着其中的逻辑将会执行,只有当前后两次的时间间隔达到了设定,其中的逻辑才会再次运行。
因此,我们只应该将游戏的渲染处理部分放入Update中,而应该将游戏的实际逻辑处理部分放入RunFixedMainLoop中。
1.3 实体-组件-系统(ECS模式)
只是让程序不断的打印“hello_world“显然是一件很无聊的事情,让我们试着将程序变得更有意思些吧!将原来的代码修改为以下的代码。
use bevy::prelude::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Component, Default)]
struct FpsCounter {
frame_count: f64,
current_time: f64,
previous_time: f64,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
//尝试换成add_systems(FixedUpdate, counter_fps)看看显示的结果有什么不同
.add_systems(Update, counter_fps)
.run();
}
fn setup(mut commands: Commands) {
commands.spawn_empty().insert(FpsCounter::default());
}
fn counter_fps(mut counters: Query<&mut FpsCounter>) {
let mut counter = counters
.single_mut()
.expect("Expected exactly one FpsCounter entity");
if counter.previous_time == 0.0 {
counter.previous_time = counter.current_time;
}
let now = SystemTime::now();
let unix_time_f32 = now.duration_since(UNIX_EPOCH).unwrap().as_secs_f64();
counter.current_time = unix_time_f32;
if counter.current_time - counter.previous_time >= 1.0 {
println!(
"FPS: {}",
counter.frame_count / (counter.current_time - counter.previous_time)
);
counter.frame_count = 0.0;
counter.previous_time = counter.current_time;
} else {
counter.frame_count += 1.0;
}
}
乍一看,或许你有些慌乱,Component是什么?Query又是什么?Commands又是什么?但是如果你仔细端详这些代码,你会发现他只是一个简单的手写fps计算器,功能不过是计算帧率并打印到控制台之上。这些简单的代码包含了两个层面,第一个是复习我们的调度系统,第二个则是展示了Entity-Component-System(ECS)模式的使用方式。在我的系统上,当使用Startup时,显示的帧率在128帧左右,而当使用FixedUpdate时,则显示在64帧左右。这说明Bevy的默认游戏帧率要比实际的物理时间快一倍。
1.3.1 Entity、Component、System是什么
在面向对象的程序中,游戏里的实体例如玩家、怪物等,往往被建模为一些类,其中拥有他们的各种成员以及属性,一个面向对象语言中的玩家也许会被建模成如下的代码。
class Player {
public:
Player(...){...}
void move(...) {...}
private:
std::string _name;
float _health;
int _level;
float _position_x = 0;
float _position_y = 0;
};
而在一个ECS系统中,则会将其建模为如下的部分。观察他们,我们可以发现在面向对象中,对象的各种属性对应的就是ECS中的Component,而对象的方法则对应一个有着特殊参数的普通的函数,这个有着特殊参数的函数,就是ECS模式中的System。
#![allow(unused)]
fn main() {
#[derive(Entity)]
struct Entity(u64)
#[derive(Component)]
struct Name(String)
#[derive(Component)]
struct Health(f32)
#[derive(Component)]
struct Level(u32)
#[derive(Component)]
struct Position{
x:f32,
y:f32
}
fn move(mut Players:Query<&mut Player>){
....//some actions
}
}
既然Component和System都能找到对应与对象的部分,那么Entity又是什么呢?**简单来说,Entity只是一个简单的标识符,用于标识唯一的实体。**在大多数时候,不需要关心此值让Bevy为我们自动生成即可。
在大量的参考手册中,都使用数据库的例子与ECS系统进行类比,不过二者在多种层次上的相似性确实令人惊叹。学习过数据库的读者应该知道,在关系数据库的一张表中,每一行是一个记录,而每一列则是一个属性,其中每一行都应当有一个唯一的标识ID。
我们可以将ECS中的Entity想象为一张表的标识ID列,其他的Component作为属性列,二者共同标识了游戏世界中唯一的一个实体和实体的各种数据。不过有些许不同的是,数据库的每张表所拥有的属性列是固定的,而不同实体拥有的属性的数目则不需要一样。
1.3.2 Commands与Query
经过前面的介绍,你现在应该已经大致理解了ECS模式,可是在一开始的的代码中,Commands与Query又是什么呢?
沿用前面数据库的例子,对数据库来说最重要的就是对数据的增删改查询。这些操作从操作的对象层面来说,可以分为表级别和行级别,前者代表我们可以对表进行操作来添加和删除一些数据来改变行的总数,后者代表我们可以对某些行进行属性的修改而不改变行的总数。这些操作对应到ECS系统中,就是Commands与Query。
利用Commands,我们可以在表级别的范围内修改数据,即我们可以在游戏中添加或删除一些实体。在下面的代码中,我们使用spawn_empty()方法创建了一个没有任何属性的实体,然后使用insert()方法往其中添加了一个属性,其值是FpsCounter::default(),由于这些操作过于常用,因此还有一个spawn()的简写方式
#![allow(unused)]
fn main() {
commands.spawn_empty().insert(FpsCounter::default());
//等同于下面这行
commands.spawn((FpsCounter::default(),))
}
利用Query,我们可以在行级别的范围内修改数据,即我们可以得到某些实体然后修改他们相关联的组件。下面的代码中,我们使用Query尝试获取主世界中那些拥有FpsCounter组件的实体的FpsCounter的可变引用作为参数,然后在函数中进行修改。
Query类似于一个包含着查询结果的vec,这是因为所能查询到的组件有可能有很多个,若我们断定只有一个,可以使用single_mut()方法将其转换为单一结果。当然,Bevy中也拥有其他更方便的方式来实现这样的目标,但是作为对于ECS模式的第一次粗略介绍,笔者认为还是尽量不应使用各种方便的技巧,重点是让读者认识到ECS模式的工作流程。此外,Query的第二个泛型可以接受With和Without参数,以实现过滤查询,这类似于数据库中Where子句。
#![allow(unused)]
fn main() {
//查询拥有Player组件标识的实体的Health的内容
fn get_players_health(mut health:Query<&mut Health,With<Player>>){....}
}
1.4 Resource
在编写程序中,我们往往需要一些全局的单例变量,最典型的应用场景就是游戏的设置功能,Resource就是为此而存在的,每种类型的Resource将以单例的方式存在于游戏世界中,在需要时可以在System中进行修改。
1.4.1 创建Resource
要定义一个资产,我们只需要像定义Component一样即可。
#![allow(unused)]
fn main() {
#[derive(Resource)]
struct Setting{
source:f32
};
}
要将此资产加入App所管理的资产中,需要在创建App后调用其insert_resource()方法并将一个单例传递给App。
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(Setting{source:0})
.run();
}
或者,为资产实现Default或者FromWorld,即可让Bevy自动创建默认实例。
#![allow(unused)]
fn main() {
//实现Default
#[derive(Resource,Default)]
//指明泛型参数,Bevy会自动创建实例
App.insert_resource::<Setting>()
}
除了使用App在创建游戏时插入我们的资产,在游戏运行时我们如何动态的决定是否添加某些资产呢?答案就是使用Commands。
#![allow(unused)]
fn main() {
fn add_score(mut commands: Commands) {
commands.init_resource::<Setting>();
//或者我们也可以在这里删除一些资产
commands.remove_resource::<Setting>();
}
}
1.4.2 使用Resource
现在我们插入了自己的资产,之后我们该如何使用呢?只需像使用Query时一样,利用一个特定的类型作为System的参数,Bevy就会为我们进行资产的查询和管理。在这里我们有三种方式获得资产,他们分别获得资产的共享引用Res,可变引用ResMut,还有可选资产Option。
#![allow(unused)]
fn main() {
//获得资产的可变引用以便更改
fn some_system(mut score: ResMut<Score>)
//只获得共享引用
fn some_system(score: Res<Score>)
//如果资产可能尚未创建,那么需要使用Option使之变为可选
fn some_system(mut score: Option<ResMut<Score>>)
}
1.4.3 默认Resource
除了我们自己创建的一些资产外,Bevy内存在着一些非常重要的内置全局资产,他们包括游戏经过的时间、键盘或鼠标的状态、网格、材质等,这些信息在游戏中是如此的常用。我们现在只粗略介绍一些,目的在于让读者明白Bevy中是通过Resource来管理这些重要数据的,后续章节中将会继续详细介绍其中使用方法。
#![allow(unused)]
fn main() {
Res<Time> //自应用启动以来的时间,以及上一帧逝去的时间
Res<Events<E>> //用于访问各种引擎事件
Res<Assets<T>> // 用于加载静态资产
Res<Window> //存储主窗口的属性
Res<ButtonInput<B>> //用于查询键盘或者鼠标的状态
}
1.5 资产Assets
资产是需要加载到游戏中的资源,通常来自于各种硬盘里的文件,例如图像、模型、材质、字体、音频等等等等。由于这些资源的加载往往需要耗费大量时间,因此Bevy里这些资产的加载往往都是以异步的形式以避免阻塞游戏循环。
在Bevy中,我们可以使用AssetServer从硬盘里加载资产,使用Assets<T>来存储已经加载的各类资产。
1.5.1 AssetServer
AssetServer作为一种全局资源,可以使用之前我们加载资源的方式以Res来获取。默认情况下,加载的资产都相对于项目目录下的assets文件夹,要修改这个默认行为,可以修改BEVY_ASSET_ROOT环境变量来指定加载资产的目录。下面展示了一个常用的加载资产并共享的方式。
首先,我们使用AssetServer加载了一个图像并获得其句柄,然后将其句柄储存在一个全局资源ShareImage上,这样,我们之后便可以通过 Res<ShareImage>来获得其句柄以便进行操作。
#![allow(unused)]
fn main() {
#[derive(Resource)]
struct ShareImage {
handle: Option<Handle<Image>>,
}
fn load_image(asset_server: Res<AssetServer>, mut share_image: ResMut<ShareImage>) {
let image_handle = asset_server.load("test.png");
share_image.handle = Some(image_handle);
}
}
上面我们提到了句柄(Handle),那么什么是句柄呢?简单来说,句柄类似于一个对资产的引用计数指针,但能被克隆为强句柄和弱句柄,当不再存在资产的强句柄时,Bevy能够自动将其回收并销毁以释放内存。所以,为了保证资产的持续存在,必须将句柄存储在一个Resource或者Component中。
由于AssetServer返回的是一个句柄并采取异步的方式加载资源,如果你的逻辑中需要判断资源是否加载完成,不能依靠句柄本身存在与否来判断,要实现此功能,可以使用其身上的get_load_state()方法。
#![allow(unused)]
fn main() {
fn on_asset_event(
mut commands: Commands,
asset_server: Res<AssetServer>,
share_image: Res<ShareImage>,
) {
match asset_server.get_load_state(&share_image.handle) {
Some(LoadState::NotLoaded) => {}
Some(LoadState::Loading) => {}
Some(LoadState::Loaded) => {
//在这里使用handle,这时已经加载完成
}
}
}
1.5.2 Assets
前面说到,AssetServer负责加载资源,而Assets<T>负责储存资源,这是什么意思呢?Assets<T> 是一个键值对集合,存储了特定类型 T 的所有实际资产数据。当AssetServer成功加载资源后,将会将真正的数据保存在对应的**Assets<T>** 中,如果你需要获得真正的数据,则需要使用相关的句柄和对应类型的Assets
#![allow(unused)]
fn main() {
fn read_image_data(images: ResMut<Assets<Image>>, share_image: Res<ShareImage>) {
let handle = match &share_image.handle {
None => return,
Some(handle) => handle,
};
if let Some(image) = images.get(handle) {
// 现在你有了image的真正数据,可以读取或者修改
println!("Loaded image size: {:?}", image.size());
}
}
}
1.5.3 自定义资产
Bevy支持常见的资产,这些资产不需要任何操作即可使用AssetServer进行加载,但是如果我们的资产是某种Bevy不支持的格式时我们该怎么办?这时我们必须手动编写代码和Bevy进行交互来定义我们的资产类型、资产的加载方法、资产的设置以及加载时可能的错误。
现在,我们想要声明一个能够加载点云las文件的资产,我们应该怎么做呢?如果你不知道las文件是什么,不用担心,那只是一些用二进制格式存储的点的三维坐标和一些属性而已。
首先,让我们定义我们的资产数据应该长什么样子。本质上,那只是一个点的Vec而已,其中每个点都有自己的位置、点的尺寸、以及颜色信息,看起来可能是下面这个样子,注意到我们使用了#[derive(Asset)]来告诉Bevy这是我们的资产。
#![allow(unused)]
fn main() {
//点云资产
#[derive(Asset)]
pub struct PointCloud {
pub points: Vec<PointCloudData>,
}
//实际的点数据
#[repr(C)]
pub struct PointCloudData {
pub position: Vec3,
pub point_size: f32,
pub color: [f32; 4],
}
}
接着,让我们定义加载时可能出现的一些错误,我们可以使用thiserror来快速声明这些错误类型。
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
pub enum LasLoaderError {
#[error("failed to load file: {0}")]
Io(#[from] std::io::Error),
}
}
之后,让我们定义一些资产的加载设置和加载器,并为我们的加载器实现AssetLoader特型,在之前我们介绍过,Bevy中的资产加载是异步的,因此需要使用async声明load方法。这里的代码没什么神奇的,但值得一提的是这里的 Reader读取的是二进制数据,需要使用一个Vec<u8>来作为缓冲区存储这些字节数据。
#![allow(unused)]
fn main() {
//在加载时我们可以额外传递一个配置以便动态的控制加载过程,但是在这里我们不需要这些
pub struct LasLoaderSettings{}
//我们的加载器
pub struct LasLoader {}
impl AssetLoader for LasLoader {
type Asset = PointCloud;
type Settings = LasLoaderSettings;
type Error = LasLoaderError;
async fn load(
&self,
reader: &mut dyn bevy_asset::io::Reader,
_settings: &Self::Settings,
_load_context: &mut LoadContext<'_>,
) -> Result<PointCloud, Self::Error> {
let mut bin_data = Vec::new();
reader.read_to_end(&mut bin_data).await?;
//在这里编写真正加载数据的逻辑
//let points = .....
//然后返回一个资产
Ok(PointCloud { points })
}
}
最后,让我们在App中注册这些资产和相应的加载器。
fn main() {
App::new()
.add_plugins(DefaultPlugins)
//通过这两个方法注册相应的加载器和资产类型
.init_asset_loader::<LasLoader>()
.init_asset::<PointCloud>()
.add_systems(Startup, load_pointcloud)
.run();
}
//现在,我们应该能够直接使用这些资产类型了
fn load_pointcloud(
mut commands: Commands,
asset_server: Res<AssetServer>,
){
let point_cloud_handler = asset_server.load::<PointCloud>("pointCloud.las");
}
1.6 相机
Camera是一个虚拟的三维场景摄像机,想象一下一个人手持相机在一个三维场景中不断变换位置的过程,在不同的位置和不同的角度,Camera上所呈现的画面也会不同。在游戏中,Camera的各种参数决定了所能够看见的画面是怎样的,但是Camera本身往往是相对于游戏中的各种场景所独立的,Bevy中内置了两种类型的相机:Camera2d和Camera3d,不言而喻,前者用于2D画面的渲染,后者用于3D画面的渲染。
在Bevy中,Camera2d和Camera3d作为两个内置组件直接使用即可,将以下代码替换掉Startup调度中原来的函数,即可在屏幕上绘制一个立方体。虽然我们尚未介绍灯光、变换、材质的具体相关内容,但是你现在也应该能够大致理解这些代码中每一行的作用。
#![allow(unused)]
fn main() {
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// 立方体
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),
MeshMaterial3d(materials.add(Color::srgb_u8(124, 144, 255))),
Transform::from_xyz(0.0, 0.5, 0.0),
));
// 灯光
commands.spawn((
PointLight {
shadows_enabled: true,
..default()
},
Transform::from_xyz(4.0, 8.0, 4.0),
));
// 相机
commands.spawn((
Camera3d::default(),
Transform::from_xyz(-2.5, 4.5, 9.0).looking_at(Vec3::ZERO, Vec3::Y),
));
}
}
本小节不会讲解投影变换所需要的矩阵运算和数学知识,仅仅是面向新手的定性的讲解。
1.6.1 投影与坐标系
在计算机图形学中,存在着两种投影方式,正交投影(Orthographic pro)和透视投影(Perspective projection),他们的示意如下图所示其中Camera2d默认采用正交投影,Camera3d则是透视投影。
正交投影是一种平行投影,读者可以想想将一个三维空间沿着视线轴压扁成二维平面。其重要的特点是投影之后物体的大小能够精确反应在三维空间的大小,但是丧失了物体之间的深度关系,在2D游戏或者工程制图中往往最常用。
透视投影则是根据严格的透视与几何关系来进行投影的,投影后的结果和人眼观察三维空间所得到的结果相同。其重要的特点是符合近大远小的透视特征,图形具备立体感,在3D游戏中是最常用的投影。

说完了投影,我们再来说说Bevy中的坐标系。在Bevy中的世界坐标系是一个空间右手坐标系,且Z轴从屏幕指向外部,Y轴从屏幕底部指向顶部,其原点默认处于屏幕的中心。这说明,对于Camera2d来说,Z轴的大小决定了相机的远近,也决定了画面的大小。而画面则是一副标准的右手平面直角坐标系。
Note
想一想,为何Bevy要采用这样的设计?
1.6.2 渲染
一个相机渲染的目标输出结果在程序中的绝大多数时候是Window或者Image。渲染到Window上即是将渲染结果渲染到实际窗口上,而渲染到Image则一般是为了保存到本地,或者使用UI库(例如egui)时显示3D画面。
默认状态下,相机的渲染目标是Window,如果要渲染到Image需要先进行一定的配置,这部分将在后面的章节中详细介绍。
1.7 输入
Bevy中的输入分为两类:
- Bevy系统对于某些动作自动发出的事件,例如资产加载完成
- 系统接收到的外部输入,例如键盘鼠标等
在本小节中,我们主要简要介绍来自键盘与鼠标的输入及其窗口事件,Bevy中其他的输入方式将留到后续的章节中进行介绍。
键盘和鼠标在Bevy中统一类型为按钮输入(ButtonInput),但是查询系统只负责进行查询,按键的状态则需要我们自己来判断,ButtonInput提供了多重方法来对按键的状态进行判断,以下是常用的三种方法。
| 方法 | 描述 |
|---|---|
pressed | 当按键被按下时一直为true |
just_pressed | 按键按下时返回 true,有效时间仅一帧 |
just_released | 按键释放时返回true,有效时间仅一帧 |
1.7.1 键盘
最简单的方式是像使用Resource一样使用ButtonInput对象。指定ButtonInput的中的泛型类型为KeyCode,将会在每一帧进行查询,利用前面所属的just_pressed()方法,即可在每一帧内判断是否按下了某些按键。
#![allow(unused)]
fn main() {
fn jump_system(input: Res<ButtonInput<KeyCode>>) {
//需要在这里进行判断
if input.just_pressed(KeyCode::Space) {
info!("Jump!");
}
}
}
如果我们的按键是组合按键怎么办?例如我们需要判断是否同时按下ctrl + shift + a时。这里可以利用一个any_pressed()方法,不言而喻只要组合里的任一按键被按下,那么该方法将会返回true,当然,也有一个all_pressed()方法。
#![allow(unused)]
fn main() {
fn combo_key_system(input: Res<ButtonInput<KeyCode>>) {
//注意这里是pressed,而下面是just_pressed,这保证了我们可以一直按着shift和ctrl
let shift = input.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
let ctrl = input.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]);
if ctrl && shift && input.just_pressed(KeyCode::KeyA) {
info!("Special ability activated! (Ctrl + Shift + A)");
}
}
}
1.7.2 鼠标
鼠标的按键使用方式完全与键盘相同。但是其泛型类型由KeyCode变为了MouseButton。
#![allow(unused)]
fn main() {
fn shoot_input_system(mouse: Res<ButtonInput<MouseButton>>) {
if mouse.just_pressed(MouseButton::Left) {
info!("Bang! Weapon fired.");
}
}
}
看到这里读者可能会有疑问,为何对于鼠标只介绍了按键的判断,鼠标的移动、拖动、滑轮滑动等事件如何进行处理呢?答案很明显,既然已经用事件来形容这些输入,那么自然就要在事件系统中进行处理。
1.8 事件
事件用于多个系统之间的通讯,他可以由某些系统或者Bevy发出,在另一些系统中得到处理。Bevy中有两种类型的事件。
Message用于系统之间的通信Event用户触发立即行为EntityEvent的观察者
1.8.1 Message
Bevy的Message系统组成主要分为三部分,他们三者相互配合一起构成了Bevy的Message系统。为了避免消息队列的无限增长,上一帧的Message将会在下一帧结束时被清除,因此如果你不采取一些另外的措施,就不能将消息留到之后进行处理。
| 名称 | 作用 |
|---|---|
Messages<T> | 一个队列,用于容纳事件的信息,本质上是一个带有一些其他方法的Vec<T>。 |
MessageWriter<T> | 将消息写入Messages<T>中。 |
MessageReader<T> | 从队列中读取事件,同时进行一些额外操作保证不会重复读取同一个事件。 |
在使用Message系统前,必须要先定义我们的消息类型,然后进行消息类型的注册。
#![allow(unused)]
fn main() {
//在这里定义消息
#[derive(Message)]
struct CustomMessage {
//发出事件的实体ID
entity: Entity,
//其他信息
some_infos: f32,
}
//在app中注册消息
App::new()
.add_message::<CustomMessage>();
}
之后,便可以在一个System中发出事件,另一个System中处理这些事件。通过这种方式,我们可以将系统之间进行解偶,将功能划分为多个系统并提高系统的复用性。
#![allow(unused)]
fn main() {
fn write_message(
mut messages: MessageWriter<CustomMessage>,
entity_and_transform: Query<Entity, With<SomeCompoents>>,
) {
for entity in entity_and_transform {
// 发送某些信息
//...
messages.write(CustomMessage{
entity,
some_infos,
});
}
}
fn read_message(mut messages: MessageReader<CustomMessage>) {
for message in messages.read() {
//对消息做一些处理
//...
}
}
}
1.8.2 Event
除了通信方式的Message,Bevy还有另一套Event模式的事件监听与分发系统。这套基于Event模式的事件系统有Event与EntityEvent两种方式。前者用于全局事件,后者则作用在某个特定的实体上,所以被称为EntityEvent,二者的定义方式均与Message相似。
#![allow(unused)]
fn main() {
#[derive(Event)]
struct ReturnToTitle;
#[derive(EntityEvent)]
struct PlayerKilled {
entity: Entity
}
}
要触发这些事件,只需在commands中调用其trigger方法并传入事件对象。
#![allow(unused)]
fn main() {
// 触发一个全局的广播事件
commands.trigger(ReturnToTitle)
// 出发某个特定实体上的事件
commands.trigger(PlayerKilled { entity })
}
为了相应这些事件,我们还需要定义一个Observer来监听这些事件。一个Observer只是一个特定的函数,其中需要将On作为第一个参数的类型以表示逻辑当<事件类型>发生时。
对于全局的广播事件, 我们应当在App上进行注册,对于某个特定实体上的事件,则需要在使用commands上调用spawn时注册。
fn on_return_to_title(
event: On<ReturnToTitle>,
) {
//做一些全局的工作
}
fn main() {
//在这里注册全局的观察者
App::new().add_plugins(DefaultPlugins).add_observer(on_respawn);
}
#![allow(unused)]
fn main() {
fn on_player_Killed(
event: On<PlayerKilled>,
query: Query<&Player>,
) {
if let Ok(player) = query.get(event.entity) {
//在这里可以处理一些数据
}
}
fn set_up(mut commands: Commands) {
//在这里注册监听器
commands.spawn(Player::default()).observe(on_player_Killed);
}
}
关于消息与事件的大体介绍就到这里,你可能想问,什么时候我该使用Message,什么时候又该使用Event呢?
简而言之,Message更适合于频繁被触发,需要解耦系统的场景,而Event则适合于处理单个事件且需要将事件限定在某些实体范围内的场景。此外,Event还能够在组件之间进行冒泡,这在某些场景下可能非常有用,具体的使用方式将在后续章节中仔细介绍。
现在,可以回答我们最初的问题了,如何对鼠标的移动、滑轮滚动等做出响应呢?使用EventReader读取相应的事件类型即可。
#![allow(unused)]
fn main() {
fn mouse_event(
mut cursor_events: EventReader<CursorMoved>,
mut wheel_events: EventReader<MouseWheel>,
) {
for event in cursor_events.read() {
info!("Cursor moved: {:?}", event);
}
for event in wheel_events.read() {
info!("Mouse wheel used: {:?}", event);
}
}
}
1.9 UI
GUI的编写是一项繁杂的工作,到目前为止 (0.17.2)利用Bevy自身的UI系统来构建UI都是一件麻烦事。不过,UI系统已经被开发人员们提上日程且重点讨论,相信在不久的将来Bevy将经过一次重大更新并给出一个符合人体工程学的UI系统。
在后面的章节中,我们将使用bevy_egui来构建UI,这是egui的Bevy绑定,egui是一个以简洁易用而出名的立即式UI框架,相信我,不需要太多学习你便能掌握它。
1.10 音频
1.10.1 音频播放
Bevy中播放音频只需要加载资产后使用AudioPlayer和PlaybackSettings即可控制音频的播放。前者用于与系统交互播放音频,后者负责初始化的设置。
#![allow(unused)]
fn main() {
fn play_audio(
asset_server: Res<AssetServer>,
mut commands: Commands,
) {
let audio = asset_server.load("audio.ogg");
//在这里向实体插入AudioPlayer和PlaybackSettings组件
commands.spawn((
AudioPlayer::new(audio),
PlaybackSettings::LOOP,
));
}
}
其中,音频的格式必须是Bevy支持的格式之一,即wav,ogg,flac,mp3其中之一,默认情况下Bevy只支持ogg格式音频,若需要读取其他格式音频,需要在toml中启用其功能。
[dependencies]
bevy = { version = "0.17", features = ["mp3"] }
1.10.2 音频控制
我们当然不能简单的只是音频,在大多数情况下我们都需要对音频进行播放控制和进度控制等等。Bevy在音频开始播放后,会在实体上自动插入一个AudioSink组件,利用该组件上暴露的方法,便可以对正在播放的音频进行控制或查询其音频属性进行显示。
该组件包含常用play()、pause()、is_paused()等方法和position等属性,这些方法和属性的作用不言而喻,详细的其他方法可以查看文档。
#![allow(unused)]
fn main() {
#[derive(Component)]
struct MyMusic;
//在setup中
commands.spawn((
AudioPlayer::new(asset_server.load("sounds.ogg")),
MyMusic,
));
//在一个system中获得AudioSink来操控音频
fn update_progress(
music_controller: Single<&AudioSink, With<MyMusic>>,
) {
println!("Progress: {}s", music_controller.position().as_secs_f32());
}
}
1.11 插件
在最初,我们便讲到“Bevy的设计理念是插件化的”。现在,我们终于来到了这里,什么是插件?怎么构建自己的插件呢?
插件是指任何接受一个App参数,并对其进行修改的函数。这些函数可以对App的行为做出任意更改,甚至可以添加其他插件。通过插件,可以将功能转移到App外的其他部分,使程序的解耦化。
要编写一个自己的插件,我们需要为我们的插件结构体实现 Plugin的build方法,如果我们还需要在build完成后时进行额外的工作,则还可以实现其cleanup方法。
#![allow(unused)]
fn main() {
pub struct CustomPlugin;
impl Plugin for CustomPlugin {
fn cleanup(&self, _app: &App){
//....
}
fn build(&self, app: &mut App) {
//....
}
}
}
就是这样!插件本身没什么奇特的,但是在使用和编写的过程中需要谨慎注意插件的使用顺序和关系,不然会导致层次混乱甚至多次调用同一插件,这可能会导致应用崩溃。
1.12 物理引擎
通过前面的介绍你可能已经注意到了,我们的目光一直放在如何显示和操控上,而并没有关心真正的物理效应例如碰撞,重力等,而这些物理效果和交互,则是由物理引擎来进行处理。
物理引擎是一种用于在虚拟环境中模拟现实世界物理现象的核心软件组件。 它负责处理物体间的碰撞检测、动力学模拟、刚体运动、重力、摩擦力、关节约束等物理效果,使得游戏中的物体能够以真实或符合游戏设定的方式相互作用。
Bevy没有官方的物理引擎,其将物理引擎的选择交给了用户,用户可以自由选择rust生态中的一些物理引擎,不过主流的选择是Avian或Rapier,其前者致力于与Bevy进行集成,后者则是与Bevy分离的单独项目。读者可以自行阅读文档进行学习。
1.13 反射与依赖注入
反射指的是程序在运行时能够访问、检测和修改程序本身状态或行为的一种能力。通过反射,我们可以在程序运行时修改类型的内容并使用字符串进行动态的字段访问。
在Bevy中,广泛使用了一种叫依赖注入的技术,这种技术使我们在编译时能够抹除数据的真实类型而在程序运行时动态的指定数据,其就是利用了反射来实现这样的效果。这也就是为什么虽然我们在编写System时并没有告诉Bevy其参数的个数与类型而仅只是声明在了函数定义中,但是Bevy仍然能够将正确的参数传递给System的原因。
由于Bevy的系统高度依赖反射来实现各种功能,因此当你需要向Bevy中添加自己的类型时候,就必须实现反射才能够让Bevy能够正常工作。
1.14 渲染
Bevy中的渲染是基于wgpu来完成的,wgpu是一个基于WebGPU规范的的Rust实现,其本身是跨平台的,这使得Bevy也能够在不同的平台上进行渲染。对于一般的渲染而言,我们通常不需要接触Bevy的渲染管道,但是当我们需要进行某种高级的图形学渲染效果开发时,则需要在Bevy中编写自己的渲染命令来告诉Bevy如何渲染。
在前面我们说到,Bevy中分为主世界和渲染世界,主世界中的组件通过Extract环节同步到渲染世界进行渲染,如果我们要在Bevy的渲染管线中自定义自己的渲染环节,则必须配置好以下几个部分使Bevy能够使用我们自己的着色器渲染数据。
- 创建一个
ExtractComponent来标识需要渲染的实体并在Extract阶段将其同步到渲染世界,这可以通过一个名为ExtractComponentPlugin的插件来实现自动化同步。 - 在
RenderApp上注册两个Resource作为缓冲区,一个储存了RenderPipeline包含。 - 编写一个或多个渲染逻辑,并实现
RenderCommand特型,获取上一步Resource并结合其他代码决定如何处理数据,将这些逻辑组合成一个元组并在RenderApp上使用add_render_command方法注册。 - 在
RenderApp的Render调度中的Prepare环节调用我们编写的prepare函数来准备渲染所需的数据,在这里定义我们的渲染管线的结构、缓冲区的布局等等。 - 在
RenderApp的Render调度中的Queue环节调用我们编写的queue函数来渲染数据
第 2 章:ECS 架构
2.1 Entity
2.1.1 EntityCommands
在前面我们生成实体时,还记得我们使用了Command.spawn()方法生成了一个没有组件的实体,然后依次往该实体上插入了一些组件,可如何我们想在之后修改实体该怎么办?查看该方法返回的值,可以发现其返回了一个EntityCommands类型的值而不是一个Entity类型的值,如果要获得真正的Entity,我们需要调用EntityCommands上的id方法。
这样设计是因为,单一的Entity几乎没有任何用处,真正有用的是与其相关的EntityCommands,其本质上是一个有关修改实体命令的队列,利用EntityCommands我们可以让我们快速对实体做出一些更改,这些更改大体上分为两类。
- 与实体上的组件相关的方法
- 与其他实体相关的方法
于此同时,Commands上有一个特殊的名为entity的方法,该方法接受一个Entity类型的参数并返回一个EntityCommands类型的值。利用Commands,我们可以通过查询系统获得的Entity来生成EntityCommands。通过这种方式,我们可以实现在游戏运行时对实体而不是组件进行修改,例如添加新的组件或者删除旧的组件等。
#![allow(unused)]
fn main() {
fn change_entity(
mut commands: Commands,
query: Query<Entity, With<Player>>, // 查找所有有 Player 组件的实体
) {
for entity in query.iter() {
let entity_commands = commands.entity(entity);
//在这里可以做实体做出修改
//..
}
}
}
2.1.2 Relationship
之前我们一直侧重于实体与其所含有的组件的关系,但是不同的实体之间也会存在着关系,例如一个玩家可能拥有多个宠物、载具等,当玩家死亡时,这些子实体也应该被重置,这种关系被称为实体之间的Relationship。(有意思的是,Relationship并不定义在Entity上而是在他们的Component上)
Bevy为我们预先内置了两种关系:ChildOf和children。前者用于指定当前实体的父实体,后者用于指定当前实体的子实体。通过这样的父子关系,子实体可以继承父实体的一些组件,例如可见性或者全局变换。
要指定当前实体的父实体,需要我们获取父实体的Entity,这可以通过EntityCommands上的id方法获得。随后,我们使用一个内置的ChildOf组件包裹父实体的Entity,然后将其添加到子实体身上。当Bevy识别到ChildOf组件后,将会自动完成之后的工作,通过其中的Entity追踪父实体的生命周期当父实体销毁时自动销毁子实体。
#![allow(unused)]
fn main() {
let player = commands.spawn((Player).id();
commands.spawn((Car, ChildOf(player)));
}
要指定当前实体的子实体,可以通过通过EntityCommands上的with_children方法或者children!来实现。此外,EntityCommands还包含了大量与父子关系相关的方法,通过这些方法还可以动态的删除、替换实体之间的父子关系。
#![allow(unused)]
fn main() {
commands.spawn((Player)
.with_children(|parent| {
parent.spawn((Car,));
});
//也可以使用宏来完成
commands.spawn((Player),
children![
(Car,),
(Car,),
]);
}
除了父子关系外,实体之间还可能有着其他各种各样的关系,因此Bevy还提供了更高级的 API抽象,让我们能够自定义实体之间的关系并决定如何处理这种关系。要自定义关系,我们需要定义关系的relationship与relationship_target,前者作为关系的“源”,后者作为关系的“目标”。这类似于数据库关系中的One-to-Many关系,前者即是关系中的one,后者是关系中的many。
#![allow(unused)]
fn main() {
// 定义一个关系的“源”,一个“源”只能引用一个实体
#[derive(Component, Debug)]
#[relationship(relationship_target = TargetedBy)]
struct Targeting(Entity);
// 定义一个关系的“目标”,由于一个目标会有多个相关联的实体,因此这里是Vec<Entity>
// 在这里我们启用linked_spawn后,能够让Bevy在target销毁时自动清除其内的关联实体
#[derive(Component, Debug)]
#[relationship_target(relationship = Targeting,linked_spawn)]
struct TargetedBy(Vec<Entity>);
}
有了这些,我们便可以通过将其作为组件来使用
#![allow(unused)]
fn main() {
fn spawn_player(mut commands: Commands) {
let player = commands.spawn((Player, Name::new("player_one"))).id();
//使用Targeting代表关系中的“源”
commands.spawn((Car, Targeting(player), Name::new("Lamborghini")));
commands.spawn((Pet, Targeting(player), Name::new("Black")));
}
commands.spawn((
Player,
Name::new("player_one"),
related!(TargetedBy[
// 使用related!宏和TargetedBy直接从关系目标实体上定义关系
(Car, Name::new("Lamborghini")),
(Pet, Name::new("Black")),
]),
));
}
回想一下数据库中的基础知识,现在我们能够解决One-to-Many的关系情况了,如何解决Many-to-Many关系的呢?在数据库中,这往往通过新增一个关系表来实现,类比这种方法,在Bevy中我们也可以通过新增一个关系实体。我们可以利用最经典的学生与课程的例子来讲解。
我们将Many-to-Many分解为两个One-to-Many关系,并将其中的两个One组件插入到一个关系实体上,这样我们便可以借助连接实体“顺藤摸瓜”得到对应的学生和课程。
#![allow(unused)]
fn main() {
// 实体 Student
#[derive(Component)] struct Student;
// 实体 Course
#[derive(Component)] struct Course;
// 学生实体上的 Relationship
#[derive(Component)]
#[relationship(relationship_target = StudentEnrollments)]
struct JunctionToStudent(Entity);
// 课程实体上的 Relationship
#[derive(Component)]
#[relationship(relationship_target = CourseEnrollments)]
struct JunctionToCourse(Entity);
// 学生实体上的 RelationshipTarget
#[derive(Component)]
#[relationship_target(relationship = JunctionToStudent)]
struct StudentEnrollments(Vec<Entity>);
// 课程实体上的 RelationshipTarget
#[derive(Component)]
#[relationship_target(relationship = JunctionToCourse)]
struct CourseEnrollments(Vec<Entity>);
let student_a = commands.spawn(Student).id();
let course_math = commands.spawn(Course).id();
// 创建连接实体
commands.spawn((
JunctionToStudent(student_a),
JunctionToCourse(course_math),
));
}
2.2 Component
2.2.1 Archetype
实体与组件的关系可以类比成数据库中表,其中每一行代表了world中的某个实体与其相关的组件,每一列代表了其中的组件。然而,这只是我们一厢情愿的类比,这些实体和组件在Bevy中真正的存储方式要比这复杂的多。
Bevy将组件默认存储在Table中,从概念上讲,Table只是一种用于储存数据的数据结构,类似于一个HashMap<ComponentId, Column>,其中每个Column是一个Vec<T:Component>,这意味着其可以方便的查找,但是不方便组件的插入与删除(想象一下,当你向一个Vec中间插入一个新元素的时候,你需要把新位置后的元素全都向后平移一位以腾出位置)。因此,Bevy还提供了一种SparseSet形式的存储数据结构,使用这种稀疏数据结构,可以在需要频繁的插入与删除时提高性能。
#![allow(unused)]
fn main() {
#[derive(Component)]
//如果一个组件可能被频繁插入或者删除,可以标记为稀疏集来优化性能
#[component(storage = "SparseSet")]
struct SomeComponent;
}
想象一下在这样的数据结构中我们如何查找一个满足要求的行?必须首先从通过组件的ComponentId获取这一列,然后获取该列中实体的行。当我们需要查找的实体要满足很多条件时,重复进行这样的查找和修改是十分低效的,这是一个不能并行的操作,因为我们不能确定另外一个列位置上是否拥有同样的属性(为什么?)。
为了解决这个问题,Bevy中引入了Archetype。从技术上讲,Archetype是固定组件的组合,这意味着一个Archetype内的实体,其拥有的组件种类是相同的,这使得的Bevy能够通过矢量化操作提高查找或者修改的效率从而提高性能。Bevy在Archetype存储在了一个Table中的引用,这意味着多个Archetype可以共享一张表,但是每个Archetype都只指向一张Table。
作为一个例子,考虑下面的这张Table,当我们查询Player时如果没有Archetype,那么我们必须遍历每个实体才能找到我们最终的结果。
| Entity | Player | Monster | Health | Attack |
|---|---|---|---|---|
| 1 | ✓ | - | 100 | 10 |
| 2 | - | ✓ | 50 | 15 |
| 3 | - | ✓ | 75 | 20 |
| 4 | ✓ | - | 80 | 15 |
现在,我们可以将其分为两个Archetype并引用上面表中的数据。当我们需要找到拥有Player的实体时,我们可以根据原型直接排除第一个Archetype上的所有实体。完美!现在我们的查询系统可以通过Archetype知道那些实体拥有同样的结构以此来对操作进行并行加速了。
| Entity | Monster | Health | Attack |
|---|---|---|---|
| 1 | ✓ | 50 | 15 |
| 2 | ✓ | 75 | 20 |
| Entity | Player | Health | Attack |
|---|---|---|---|
| 1 | ✓ | 100 | 10 |
| 2 | ✓ | 80 | 15 |
尽管这些工作是引擎的幕后工作,但是了解Bevy是如何组织我们的数据是非常重要的,这使得我们能更好的够优化自己的数据组织方式来帮助Bevy更快的运行我们的程序。
2.2.2 Bundle
很多初次学习Bevy的人往往都弄不清楚Bundle和component的关系,从字面意思上来看,Bundle的含义是“一堆,一批”,从实际功用上来看,Bundle是一个容器,容纳了一组component。
还记得我们是如何往实体上插入属性的吗?通过spawn方法,我们传入了一个元组,如果你细心,你能够发现spawn方法接受的参数类型是一个实现了Bundle特型的泛型。由于Bevy为元组类型实现了该特型,因此在这里你不要自己声明Bundle。我们也可以自己声明需要的Bundle,只需要使用Bundle指令即可。
利用Bundle,最直观的便捷就是我们可以快速插入或者删除一组组件。不过,不能在查询中使用Bundle,这是因为查询系统需要访问其中的各个组件类型才能过滤实体。
#![allow(unused)]
fn main() {
commands.spawn((Player,Health::new(100),Attack::new(10)))
//实际上这些代码等同于下面这些
#[derive(Bundle)]
struct PlayerBundle {
player: Player,
health: Health,
attack: Attack
}
//一次性插入多个组件
let player = commands
.spawn_empty()
.insert(PlayerBundle {
player: Player,
health: Health::new(100),
attack: Attack::new(10)
})
.id();
//一次性删除这些组件
commands.entity(player).remove::<PlayerBundle>();
}
不过,既然Rust能够正确推断出类型,为什么我们还要自己手动实现需要的Bundle结构体呢?答案是Bundle能够帮我们更好的管理组件的结构帮助我们管理代码和实体。
当一个Bundle的字段里含有另一个Bundle时会发生什么(这是常见的,因为我们这可以复用我们的代码并解耦组件之间的以来)?我们知道所有的组件在Bevy中都是扁平化的,一个组件不可能包含另一个组件。因此Bevy会自动帮我们将其展开。但需要注意的是,不能包含一个Bundle多次,否则Bevy将会崩溃。
#![allow(unused)]
fn main() {
#[derive(Bundle)]
struct BiologyBundle {
health: Health,
attack: Attack
}
#[derive(Bundle)]
struct PlayerBundle {
player: Player,
health_and_attack: BiologyBundle,
}
//当我们拥有以上定义时,Bevy会自动帮我们扁平化PlayerBundle,生成下面的结构
//struct PlayerBundle {
// player: Player,
// health: Health,
// attack: Attack
//}
}
2.2.3 require
require属性的组件会使插入一个组件时如果实体身上没有需要的组件时,自动插入其他的组件(必须实现Default或在指令中指定值)。
#![allow(unused)]
fn main() {
#[derive(Component)]
//下面这些方式都可以定义必须组件
#[require(Health, Attack)]
#[require(Health = Health{100},Health = Attack{100}]
struct Player;
//当我们插入Player时,Health和Attack也会被一并插入
let player = commands.spawn(Player).id();
//我们可以获得其身上自动插入的属性
commands.entity(player).get::<Health>().unwrap();
}
不过,这种情况也会导致一些类似于面向对象中的“多重继承”的问题,即一个组件通过多个require链产生了同一组件多次。一般来说,我们要避免这种情况的发生,不过当无法避免时,Bevy将遵循以下的初始化顺序。
- 如果
#[require()]中存在明显的构造函数,则优先选择该构造函数。 - 否则,对
require树执行深度优先搜索并选择找到的第一个。
以上的方式通过在编译时生成必须组件,当我们在运行时需要指定必须组件时,可以调用World上的register_required_components或register_required_components_with方法,具体的使用方式可以查询Bevy文档即可,这里不再赘述。
2.2.4 常用组件
Bevy为我们内置了一些常用的组件,这些组件提供了最基础的功能用于控制一些最基本的实体行为,我们将介绍一些常用的基本组件。
Transform是一个最常用的组件之一,用于控制实体的变换,其定义如下,包含最基本的平移、旋转、缩放。值得一提的是,**Transform是实体相对于其父位置的位置,如果没有ChildOf组件,则为固定的世界坐标系。**这些组件运行在PostUpdate调度中,因此改变后下一帧才会发生变化,不过在多数时候这都是不那么重要的。
#![allow(unused)]
fn main() {
pub struct Transform {
pub translation: Vec3,
pub rotation: Quat,
pub scale: Vec3,
}
}
Transform拥有很多方便的工厂函数和方法,这些函数包括from_xyz,from_matrix, from_rotation,looking_at,with_translation等等等等。其作用是不言而喻的,具体的使用方法读者可以查看文档。
Visibility组件用于告诉相机某实体是否可见,其定义如下,该可见性同样会影响到子实体。
#![allow(unused)]
fn main() {
pub enum Visibility {
Inherited,
Hidden,
Visible,
}
}
剩余的还有一些相机、灯光等组件,我们将在后续的章节中的合适位置再介绍。
2.3 System
2.3.1 System Order
回想一下在我们注册系统时,我们是将其组合在一个元组之中并调用add_systems方法添加的。但是当存在多个系统时,他们的运行顺序是怎样的呢?答案是:Bevy会努力使他们能够并行运行。
“努力并行”是什么意思呢?Bevy会检查不同系统所需要的参数,当两个系统的参数不存在同一对象的可变引用时,Bevy将会并行执行两个系统。例如我们有一个hello_world和一个hello_bevy系统,二者的作用只是打印两条不同的消息。当我们的程序运行时,并行运行意味着你不会看到二者按照顺序依次不断被打印,打印的结果就像下面这样。
Hello, bevy!
Hello, world!
Hello, world!
Hello, bevy!
Hello, bevy!
Hello, world!
Hello, bevy!
Hello, world!
Hello, world
当二者之间存在某一对象的可变引用时,情况就不同了。例如下面的两个系统,当我们注册之后并在其中使用了commands(由于是惰性的,如果不使用则仍然会并行),Bevy将会发现这两个系统不能够并行调用,因此这两个系统将会按照我们添加时的顺序来调用。
#![allow(unused)]
fn main() {
fn first_system(mut commands: Commands) {
//...
}
fn second_system(mut commands: Commands) {
//...
}
}
这很好,因为在这种情况下我们的系统确实不应该被并行。可如果存在两个系统能够并行,但是我们并不希望Bevy这样做时,我们该怎么办呢?Bevy为我们提供了一些便捷的方法来做到这种事,例如before、after、chain,顾名思义,这些方法的作用是指定某些系统和另一些系统的运行先后关系。不过需要注意的是,使用这些方法时只是指定了顺序,你仍然需要把每个系统都注册,程序才能够正常运行。
#![allow(unused)]
fn main() {
//在hello_world运行之前先运行hello_bevy,别忘了注册hello_bevy
add_systems(Update, (hello_world.before(hello_bevy),hello_bevy));
//在hello_bevy之后再运行hello_bevy,别忘了注册hello_world
add_systems(Update, (hello_bevy.after(hello_world),hello_world));
//以hello_bevy,hello_world的方式先后运行,这个方法更方便
add_systems(Update, (hello_bevy,hello_world).chain());
}
这些方法在名为IntoScheduleConfigs的特型上,还有一些其他的方便方法,读者可以自己查看相关文档,这里不再赘述。如果你查看过add_systems的签名,你会发现其第二个参数的类型就是实现了这个特型的泛型参数。Bevy为元组、函数等都实现了这个特型,这使得我们能够在函数上调用这些方法(他们本来不存在于这些类型上)。
解决了相互并行的系统之间的顺序,还剩下一个问题:如何串行各个系统,使得系统的处理结果可以从前往后传递,像管道一样运行呢?
既然提到了管道,Bevy也在系统特型为我们提供了一个pipe方法,通过pipe和特殊的In参数,我们可以做到这些。
#![allow(unused)]
fn main() {
// 从这个系统中我们可以返回一些消息传递给下一个系统
fn parse_message_system(message: Res<Message>) -> Result<usize, ParseIntError> {
message.parse::<usize>()
}
// 特殊的In参数类型用于告诉Bevy该参数是从上个系统接收的返回值
fn handler_system(In(result): In<Result<usize, ParseIntError>>) {
match result {
Ok(value) => println!("parsed message: {value}"),
Err(err) => println!("encountered an error: {err:?}"),
}
}
//使用pipe方法,可以将这些系统组合到的一起
parse_message_system.pipe(handler_system)
//还有一种方法可以像使用迭代器一样组合这些系统
parse_message_system.map(|out|{handler_system(out)})
}
2.3.2 run_if
很多时候,你可能想要按照某些条件来动态的决定系统是否运行,Bevy为我们提供了run_if方法来做到这件事。
run_if需要一个函数,该函数需要返回一个返回bool类型的闭包,并且该闭包也可以像一个system一样接受各种参数,Bevy将会自动注册这些参数,在游戏的每个循环里,这个闭包将会被运行。当闭包返回true时,系统就会运行。
#![allow(unused)]
fn main() {
//run_if里也可以写如条件,使用and或者or的方式来连接
some_system.run_if(
resource_exists::<InputCounter>.and(
|counter: Res<InputCounter>| counter.is_changed() && !counter.is_added()
)
)
}
同时,Bevy也为我们提供了一些常用的判断条件,这些条件函数将在以后的章节中依次介绍,与ECS系统相关的条件函数可以在文档里的Functions部分下找到。
2.3.3 System Set
当我们的系统越来越多时,如何管理和有条件的运行一批系统是至关重要的,例如我们希望用户在游戏中按下某个按键之后只运行系统的UI设置系统来渲染页面,而暂停游戏的逻辑。我们该如何有条件的运行和管理系统呢?Bevy中引入了SystemSet的概念,通过SystemSet我们可以将系统的运行阶段进行划分以更好的分组控制。
要使用SystemSet,首先要定义一个enum类型,派生 SystemSet并继承一系列必需的标准 Rust 特征:
#![allow(unused)]
fn main() {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
enum MySystemSet {
SetOne,
SetTwo,
}
}
之后,我们使用in_set方法来指定系统运行时需要所处的状态,使用App上的configure_sets来设置我们的系统的状态变化顺序,就像下面的代码一样(其中hello_world_from_state_one等系统只是一条打印消息的普通函数)。
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(
Update,
(hello_world_from_set_one, hello_bevy_from_set_one)
.chain()
.in_set(MySystemSet::SetOne),
)
.add_systems(
Update,
(hello_world_from_set_two, hello_bevy_from_set_two)
.chain()
.in_set(MySystemSet::SetTwo),
)
// .add_systems(Update, change_Set)
.configure_sets(Update, (MySystemSet::SetTwo, MySystemSet::SetOne).chain())
.run();
}
运行这些代码,你可以发现我们的系统以下面的方式循环打印,这是因为我们以chain的方式指定了系统之间的运行顺序,因此总是先打印world再打印Bevy。同时,我们的这行代码(MyState::StateTwo, MyState::StateOne).chain()指定了在Update调度中,系统的状态是先处在StateTwo,然后变换到StateOne。因此in_set(MyState::StateTwo)内的两个系统将先运行,然后才是in_set(MyState::StateOne)的两个系统运行。
Hello, world! From set two
Hello, Bevy! From set two
Hello, world! From set one
Hello, Bevy! From set one
2.3.4 State
有了SystemSet我们可以对系统的运行阶段进行划分和分组,但是如何才能真正做到对系统运行阶段的控制呢?例如我们想要按下ESC键后能够暂停游戏逻辑的系统,而打开UI绘制和设置的系统,我们应该怎么做呢?这就要使用State来控制系统的状态。
在计算机科学中,State是一个非常通用的概念,用于描述系统、物体或者实体在特定时间点或特定情况下的情况、性质或特征。在Bevy中,我们通过切换App的State,再利用State与SystemSet,就能实现我们的需求——动态的控制系统的运行与关闭。
定义状态的步骤没什么特殊的,让编译器为我们实现States与一系列必需的标准 Rust 特征即可。然后,我们需要在App上使用init_state或者insert_state方法注册我们的状态。
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, States)]
enum MyState {
#[default]
StateOne,
StateTwo,
}
App::new()
// 添加我们的状态
.init_state::<MyState>()
}
之后,我们可以通过创建一个系统来根据需要动态的改变系统的状态,在此系统中,我们可以获得两个特殊的参数:Res<State<MyState>>和 mut next_state: ResMut<NextState<AppState>>,利用前者,我们可以获得当前所处的状态,后者则可以将状态转换为下一状态。
#![allow(unused)]
fn main() {
fn toggle_state(
mut next_state: ResMut<NextState<AppState>>,
current_state: Res<State<AppState>>,
input: Res<ButtonInput<KeyCode>>,
) {
if input.just_pressed(KeyCode::Escape) {
//按键按下时,设置新状态
next_state.set(AppState::MainMenu);
}
}
}
现在,我们可以更改一下我们的代码,写出一个简单的按键控制状态系统。注意到我们使用了run_if和in_state来动态的判断并运行不同系统。现在当你按下空格前,程序将只会打印From set one的两条消息,当你按下空格后,程序则只会打印From set two的两条消息。
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.init_state::<MyState>()
.add_systems(
Update,
(hello_world_from_set_one, hello_bevy_from_set_one)
.chain()
.in_set(MySystemSet::SetOne)
//利用run_if方法和state动态判断是否应该执行这些systems
.run_if(in_state(MyState::StateOne)),
)
.add_systems(
Update,
(hello_world_from_set_two, hello_bevy_from_set_two)
.chain()
.in_set(MySystemSet::SetTwo)
//利用run_if方法和state动态判断是否应该执行这些systems
.run_if(in_state(MyState::StateTwo)),
)
.add_systems(Update, change_state)
.configure_sets(Update, (MySystemSet::SetTwo, MySystemSet::SetOne).chain())
.run();
}
fn change_state(
input: Res<ButtonInput<KeyCode>>,
state: Res<State<MyState>>,
mut next_set: ResMut<NextState<MyState>>,
) {
if input.just_pressed(KeyCode::Space) {
//按键按下时检测当前的状态,并更改为另一状态
match state.get() {
MyState::StateOne => next_set.set(MyState::StateTwo),
MyState::StateTwo => next_set.set(MyState::StateOne),
}
}
}
除了使用run_if和in_state的方式,实际上Bevy还提供了OnEnter和OnExit两个特殊的调度器,这种方式类似于守卫模式,在进入和离开某个状态时将会各进入该调度一次。利用这两个调度,我们可以在状态转换时执行一些特定的系统,但是需要注意的是,这个调度只会在转换时调用其中的函数一次,而run_if和in_state不会这样。例如在App上使用下面这些代码,这些函数只会在按下空格键时执行一次。
#![allow(unused)]
fn main() {
add_systems(OnEnter(MyState::StateOne), || {
println!("Entered State One");
})
add_systems(OnEnter(MyState::StateOne), || {
println!("Entered State Two");
})
}
2.3.5 SystemParam
在前面定义系统时,我们直接将参数作为系统函数的参数,这样做固然方便,但当系统的参数越来越多时会导致我们的参数越来越多也越来越复杂,如果我们能够将其参数单独定义成一个结构体,那么就能将其分离。
利用指令SystemParam来让Bevy为我们自动实现结构体的SystemParam特型,这样我们就可以将原来的多个参数转移到结构体中,并使用结构体作为我们的参数。不过,当我们这样做时必须指定正确的生命周期,具体的生命周期类型,可以查看文档。
#![allow(unused)]
fn main() {
// 使用指令SystemParam来自动实现SystemParam trait
#[derive(SystemParam)]
struct PlayerCounter<'w, 's> {
players: Query<'w, 's, &'static Player>,
count: ResMut<'w, PlayerCount>,
}
impl<'w, 's> PlayerCounter<'w, 's> {
fn count(&mut self) {
self.count.0 = self.players.iter().len();
}
}
/// 在系统中我们可以直接使用该结构体作为查询参数
fn count_players(mut counter: PlayerCounter) {
counter.count();
println!("{} players in the game", counter.count.0);
}
}
有时候,我们在system中不想修改原来的SystemParam,我们只是需要一份副本来执行某些操作,我们该怎么办呢?这时我们可以利用Local来修饰查询,这样bevy会为我们提供一个完整的副本,在副本上进行所有的操作都不会影响查询系统中的SystemParam。
#![allow(unused)]
fn main() {
fn count_players(mut counter: Local<PlayerCounter>) {
//现在counter是一份完整的部分,我们修改这里的counter不会影响其他系统得到的counter
}
}
2.4 Query
2.4.1 QueryData
查看Query的定义,可以发现其有两个参数,QueryData和QueryFilter。
QueryData是查询获取的数据类型,将作为查询项返回。只有与请求数据匹配的实体才会生成查询项。
QueryFilter是一组可选条件,用于确定查询项应保留还是丢弃。默认值为unit,表示不会应用其他过滤器。
#![allow(unused)]
fn main() {
pub struct Query<'world, 'state, D, F = ()>where
D: QueryData,
F: QueryFilter,
{ /* private fields */ }
}
对于可变和不可变引用的获取,必须在QueryData中指定类型,这是为了使Bevy能够在查询不可变组件时尽可能的并行。另外,这两个泛型参数既可以是单个结构,也可以是一个元组,这意味着我们可以写出这样的代码来一次性查询实体上的多个组件。
#![allow(unused)]
fn main() {
// 获取一个组件的共享引用
fn immutable_query(query: Query<&ComponentA>) {
// ...
}
// 获取一个组件的可变引用
fn mutable_query(query: Query<&mut ComponentA>) {
// ...
}
// 获取同时拥有组件ComponentA和Player的实体上的这两个组件的引用
fn multiple_query(query: Query<(&mut ComponentA,Player)>) {
// ...
}
}
Query返回的类型是一个迭代器,如果想要真正修改这些组件,我们就必须遍历其中的内容。除了使用常见的for循环,Query上还提供了很多便利的方法来获取其内容。
#![allow(unused)]
fn main() {
fn multiple_query(query: Query<(&mut ComponentA,Player)>) {
for a,player in &query{
....
}
}
}
在很多情况下,我们还需要进行更细力度的查询,类似“最少有一个”、“仅一个”、“0或1个”这样的数量判断,如果不满足这些约束,则跳过我们的系统逻辑。Query类型上有一些方法,能够方便我们判断这些情况。这些方法如下,具体的参数和使用方法读者可查阅文档
| 方法 | 描述 |
|---|---|
iter | 返回所有项目的迭代器 |
for_each | 为每个项目并行运行给定的函数 |
iter_many | 对与实体列表匹配的每个项目运行给定函数 |
iter_combinations | 返回指定数量项目的所有组合的迭代器 |
par_iter | 返回并行迭代器 |
get | 返回给定实体的查询项 |
get_component<T> | 返回给定实体的组件 |
many | 返回给定实体列表的查询项 |
get_single | 安全版本single返回Result<T> |
single | 返回查询项,如果还有其他则会导致panic |
is_empty | 如果查询为空,则返回 true |
contains | 如果查询包含给定实体,则返回 true |
不过,Bevy直接为我们提供了一些Query的变体,能为我们方便的进行这样的查询。这些变体包括:
Single: 恰好有一个匹配的查询项。Option<Single>: 零个或一个匹配的查询项。Populated:至少有一个匹配的查询项。
#![allow(unused)]
fn main() {
// 使用Single时不再需要便利查询
fn hurt_boss(mut boss: Single<&mut Boss>) {
boss.health -= 4.0;
}
// 使用Option时返回的是一个Option
fn hurt_boss(boss: Option<Single<&mut Boss>>) {
match boss{
Some(boss)=>{//...},
None=>{//...}
}
}
// Populated则需要迭代处理
fn hurt_boss(boss: Populated<&mut Boss>) {
for boss in &boss{
//...
}
}
}
当简单的组合不能描述我们想要的查询时,Bevy还提供了一些便捷的类型能够使得我们的查询更容易编写。这些类型包括:
| 类型 | 作用 |
|---|---|
Entity | 获得查询得到的实体,实体只是一个数字,不需要引用。 |
Option<F> | 查询可能为None。 |
AnyOf<T> | 指定多个组件,只需要满足这些组件里的任一即可。相当于Option的简便方法。 |
Ref<T> | 获得共享引用,与直接使用&不同的是,这个类型还拥有一些特殊的方法用于检测组件的内容是否发生变化。 |
#![allow(unused)]
fn main() {
// 获得Entity后,我们可以利用command来更改实体上的组件
fn change_entity(mut command:Command,query:Query<(Entity,Player)>){
let entity_commands = commands.entity(entity);
//...
}
// Option相当于查询的“或”运算
fn query_a_or_b(
query: Query<(Option<&A>, Option<&B>)>,
) {
for (a, b) in &query {
if let Some(a) = a {
//...
}
if let Some(b) = b {
//...
}
}
}
// 使用Ref获得组件后,其上拥有一些特殊的方法(is_added、is_changed、changed_by)可以用来在组件改变时执行额外的逻辑
fn change_detect(query: Query<Ref<Player>>) {
for player in &query {
if player.is_added() {
// ...
}
if player.is_changed(){
//...
}
// changed_by仅用于调试,将打印一些有用的信息帮助调试
println(component.changed_by())
}
}
}
2.4.2 QueryFilter
在前面,我们只使用了QueryData进行查询,如果你细心,你可以发现在查询时虽然我们指定了一些组件,但是这些组件仅用于标识一些实体,而在实际获得后我们并不需要这些组件,也就是说,这些组件仅用于查询过滤条件。
利用Query的第二个泛型参数QueryFilter,我们可以将其分离开来,其同样支持以元组的形式指定多个条件,并且具有很多的便捷类型能够使得我们的查询更容易编写,这些类型包括:
| 类型 | 含义 |
|---|---|
With<T> | 查询的实体上应该具有组件T |
Without<T> | 查询的实体上不应具有组件T |
Or<T> | 相当于或运算,指定多组过滤条件,满足其中一个即可 |
Changed<T> | 实体必须有该组件,且该组件在这帧中被更改 |
Added<T> | 实体在这帧中添加了该组件 |
我们仅介绍一下Changed类型,此过滤器的常见用途是避免值未改变时的冗余工作。就性能和效果而言,以下的两个系统是大致等价的。
#![allow(unused)]
fn main() {
fn system1(q: Query<&MyComponent, Changed<Transform>>) {
for item in &q { /* component changed */ }
}
fn system2(q: Query<(&MyComponent, Ref<Transform>)>) {
for item in &q {
if item.1.is_changed() { /* component changed */ }
}
}
}
2.4.3 自定义查询参数
Bevy虽然有强大的查询系统,不过当查询需要的条件越来越多时就会出现一些不可避免的问题。由于Rust的限制,最多只能存在15个参数,虽然我们可以对元组进行嵌套来解决这个问题,不过如果我们能够定义自己的查询类型,那么我们的代码就能够漂亮的多。该部分内容读者可查看文档,Bevy对此已有详细的说明。
2.5 Resource
在第一章中写到Resource是一个全局单例。用于保存一些在游戏的整个生命周期里都存在的数据,例如游戏设置等。
要创建一个Resource,只需要使用Resource指令即可,然后我们便可以声明一个单例并初始化。
#![allow(unused)]
fn main() {
#[derive(Resource,Default)]
struct Setting{
source:f32
};
//在App中直接初始化,在这里我们实现了Default,因此可以只指定类型
App.insert_resource::<Setting>()
//或者使用commands动态的添加和删除
fn add_score(mut commands: Commands) {
commands.init_resource::<Setting>();
//或者我们也可以在这里删除一些资产
commands.remove_resource::<Setting>();
}
}
在使用时,只需要在需要使用的系统上使用Res或者ResMut来指定资源的类型即可。
#![allow(unused)]
fn main() {
//获得资产的可变引用以便更改
fn some_system(mut score: ResMut<Score>)
//只获得共享引用
fn some_system(score: Res<Score>)
//如果资产可能尚未创建,那么需要使用Option使之变为可选
fn some_system(mut score: Option<ResMut<Score>>)
}
除了作为一个可以在系统中共享的数据单例,Bevy中许多功能的实现也都是基于Resource来实现的,在前面我们能已经介绍了一部分,这些内容如下。
#![allow(unused)]
fn main() {
Res<Time> //自应用启动以来的时间,以及上一帧逝去的时间
Res<Events<E>> //用于访问各种引擎事件
Res<Assets<T>> // 用于加载静态资产
Res<Window> //存储主窗口的属性
Res<ButtonInput<B>> //用于查询键盘或者鼠标的状态
}
run_if可以利用查询系统来结合Resource进行判断,就像下面这样。通过这种方法,可以结合各种Resource来动态的决定系统的运行状态。
#![allow(unused)]
fn main() {
some_system
.run_if(|counter: Res<InputCounter>| counter.is_changed() && !counter.is_added())
}
Bevy里还为我们提供了一组与Resource相关的conditions,这些可以在文档里的Functions部分下找到,这些条件包括:resource_added、resource_changed、resource_exists等等等等
2.6 Message
2.6.1 用法回顾
在前面,我们曾简单的介绍过如何使用Message来在多个系统之间进行消息的传递,其最简单的使用方式如下。我们首先使用Message宏定义了消息,然后在App中注册了消息,最后我们使用MessageWriter和MessageReader来进行消息的发送和读取。
#![allow(unused)]
fn main() {
//在这里定义消息
#[derive(Message)]
struct CustomMessage {
//发出事件的实体ID
entity: Entity,
//其他信息
some_infos: f32,
}
//在app中注册消息
App::new()
.add_message::<CustomMessage>();
fn write_message(
mut messages: MessageWriter<CustomMessage>,
entity_and_transform: Query<Entity, With<SomeCompoents>>,
) {
for entity in entity_and_transform {
// 发送某些信息
//...
messages.write(CustomMessage{
entity,
some_infos,
});
}
}
fn read_message(mut messages: MessageReader<CustomMessage>) {
for message in messages.read() {
//对消息做一些处理
//...
}
}
}
2.6.2 Message
使用Message宏会自动为结构体实现Message trait。实际的消息是存储在一个Messages资源中,在bevy_ecs中其实定义如下,其包含两个队列用来存储消息,我们写入的消息就是存储在了这两个队列中。
messages_a中存储了上一帧中的消息,messages_b中存储了当前帧插入的消息。
#![allow(unused)]
fn main() {
#[derive(Debug, Resource)]
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Resource, Default))]
pub struct Messages<E: Message> {
/// Holds the oldest still active messages.
/// Note that `a.start_message_count + a.len()` should always be equal to `messages_b.start_message_count`.
pub(crate) messages_a: MessageSequence<E>,
/// Holds the newer messages.
pub(crate) messages_b: MessageSequence<E>,
pub(crate) message_count: usize,
}
}
2.6.3 MessageWriter
MessageWriter的实现没有什么神奇的,只是一个包含了Messages的SystemParam薄薄的包装。在我们使用的时候,消息会被写入其中的messages内。
#![allow(unused)]
fn main() {
#[derive(SystemParam)]
pub struct MessageWriter<'w, E: Message> {
#[system_param(validation_message = "Message not initialized")]
messages: ResMut<'w, Messages<E>>,
}
}
当我们使用MessageWriter写入消息时,会调用messages上的write方法,该方法会将消息写入messages_b这个队列中。MessageWriter还有一些很有用的方法,这些方法包括write_default和write_batch,前者可以写入一个空消息,后者可以批量写入消息。
2.6.4 MessageReader
MessageReader的实现也没有什么神奇的,几乎和MessageWriter相同,消息会在messages内读取。
#![allow(unused)]
fn main() {
#[derive(SystemParam, Debug)]
pub struct MessageReader<'w, 's, E: Message> {
pub(super) reader: Local<'s, MessageCursor<E>>,
#[system_param(validation_message = "Message not initialized")]
messages: Res<'w, Messages<E>>,
}
}
读取消息的实现则相对麻烦,这要借助另外几个结构:MessageCursor 、MessageIterator、MessageMutator等等。简而言之,这些结构帮助我们跟踪记录了每一种消息在队列messages_a和messages_b中的位置,当我们读取时将会按照顺序依次读取。
下一帧的时候将messages_a中的消息将被清空,messages_b中的消息将会转移到messages_a中。这也就是为什么如果消息如果在下一帧不读取将会被丢弃的原因。
MessageReader上也有一些很有用的方法,例如is_empty、len等方法可以帮助我们再不读取消息的情况下做出一些决定。
2.7 Event
2.7.1 用法回顾
基于Event模式的事件系统有Event与EntityEvent两种方式,前者用于全局事件,后者则作用在某个特定的实体上,所以被称为EntityEvent。
对于前者,其使用方式如下。首先使用Event宏注册一个事件,然后在App上注册我们的Observer,一个Observer只是一个特定的函数,其中需要将On作为第一个参数的类型以表示逻辑当<事件类型>发生时。当我们需要时可以使用command触发一个全局事件来调用处理函数响应。
#[derive(Event)]
struct ReturnToTitle;
// 触发一个全局的广播事件
commands.trigger(ReturnToTitle)
fn on_return_to_title(
event: On<ReturnToTitle>,
) {
//做一些全局的工作
}
fn main() {
//在这里注册全局的观察者
App::new().add_plugins(DefaultPlugins).add_observer(on_return_to_title);
}
对于后者,其基本步骤是相同的,不过我们的Observer这时需要直接绑定到实体上,而且PlayerKilled中要有一个Entity类型的entity字段,用来指定触发的事件是哪个实体。或者,由我们利用#[event_target]手动指定。
值得一提的是,如果我们在App上通过add_observer也注册了一个处理PlayerKilled事件的函数,那么即使我们指定了触发的实体,这个函数也会运行,这是因为其底层使用的是和Event相同的触发器。
#![allow(unused)]
fn main() {
#[derive(EntityEvent)]
struct PlayerKilled {
//一个Entity类型的entity字段作为目标实体
entity: Entity
//手动指定
//#[event_target]
//exploded_entity: Entity,
}
// 触发某个特定实体上的事件
commands.trigger(PlayerKilled { entity })
fn on_player_Killed(
event: On<PlayerKilled>,
query: Query<&Player>,
) {
if let Ok(player) = query.get(event.entity) {
//在这里可以处理一些数据
}
}
fn set_up(mut commands: Commands) {
//在这里注册监听器
commands.spawn(Player::default()).observe(on_player_Killed);
}
}
2.7.2 lifecycle
在很多时候,Bevy会自动触发一些事件,这些事件被称为生命周期事件,包括实体上的组件被添加、删除、修改等,如果需要对某些特定的内置事件进行响应,那么可以使用Event,具体的示例如下,其中On的第一个参数是事件的类型,二个参数是具体的Bundle。如果要查看更详细的信息可以查看文档。
#![allow(unused)]
fn main() {
use bevy::prelude::*;
App::new()
// 添加观察器
.add_observer(react_on_removal)
fn react_on_removal(remove: On<Remove, MyComponent>) {
//....
}
}
除了上面这种方法,还可以直接在World上对某个组件注册相应的处理函数,这些函数称为生命周期钩子(Hook),就像下面这样。这些钩子可以接受一个HookContext类型的参数,其中包含了发出这个事件的实体,组件的ID等。
#![allow(unused)]
fn main() {
fn setup(world: &mut World) {
world
.register_component_hooks::<MyComponent>()
.on_add(
|mut world,
HookContext {
entity,
component_id,
caller,
..
}| {
//..
},
)
//同样,我们也可以注册on_insert或on_remove等更多的钩子
//.on_insert()
//.on_remove();
}
2.7.3 propagate
在介绍Relationship的时候,我们曾讲过,子实体会继承父实体的一些组件。而当子实体和父实体都对同样EntityEvent注册了observer的时候,事件将会以冒泡的形式,先在子实体上触发,然后再交给父实体,而且子实体对事件的信息做出更改后,父实体将得到被修改的事件结构体。
#![allow(unused)]
fn main() {
//假设我们注册了一个父实体和三个子实体
commands
.spawn((Name::new("Goblin"), HitPoints(50)))
.observe(take_damage)
.with_children(|parent| {
parent
.spawn((Name::new("Helmet"), Armor(5)))
.observe(block_attack);
parent
.spawn((Name::new("Socks"), Armor(10)))
.observe(block_attack);
parent
.spawn((Name::new("Shirt"), Armor(15)))
.observe(block_attack);
});
//为子实体注册observer
fn block_attack(mut attack: On<Attack>) {
//对attack可以做出一些更改,例如被成功防御时设置attack.damage = 0;
//或者,我们可以阻止冒泡,调用attack.propagate(false);
}
//为父实体注册observer
fn take_damage(
attack: On<Attack>,
) {
//读取attack做一些处理,这时的attack中的内容是block_attack处理之后的
}
}
2.7.4 Event还是Message
之前我们对选择Event还是Message进行过一些简短的讨论,现在我们可以好好的说一下这件事了。
Event的消息处理是即时的、广播的、无序的、冒泡的。这意味着你的Event可以被多个observer同时响应,而且你无法决定他们的响应顺序,而且可以在子实体和父实体之间进行冒泡,这是很有用的。
Message的消息处理是最多延迟一帧的、专一的、有序的、不能冒泡的、有缓冲的。这意味着一般Message的消息只能被读取一次,然后下一帧就会被丢弃,而且多个系统读取到的Message是不一样的,读取的顺序是发出者发出的顺序。
明白了二者的区别,那么当你需要选择的时候就很明显了。如果你的需求和实体关系密切,需要精确的定位到某个特定的实体上并且不关心顺序,需要冒泡处理,那么就是Event,否则就是Message。
2.8 World
World本身其实没有什么要介绍的,但是World的概念却是无处不在的。简而言之,World是一个“舞台”,是一个容纳了所有实体、组件与系统的地方。要能使用ecs系统的内容,必须在World进行操作。因此我们其实可以把代码写成下面这样。
fn main() {
let mut world = World::new();
//world.insert_resource(...)
//world.spwan(...)
//.....
}
现在我们可以说,一个App就是对World做了一层包装,我们在App上调用的很多方法,其实是调用的World上的方法。但是,World只提供了这些与ecs相关的方法,没有提供游戏循环、时间管理、插件等等,这些其实是在App上提供的。
2.9 Schedule
一个schedule是一个包含了如何对World进行调度的结构,简而言之,我们的各个游戏阶段,例如Update等,都是一个schedule,因此我们可以把代码写成这样。
fn hello_world() { println!("Hello world!") }
fn main() {
let mut world = World::new();
let mut schedule = Schedule::default();
//我们会将system注册在schedule上
schedule.add_systems(hello_world);
//调用一次run会运行一次schedule上注册过的系统,因此hello_world只会运行一次
schedule.run(&mut world);
}
实际上,这些所有的调度,包括下面这张图内的所有阶段,都是在App内注册的,因此我们说,App才是提供游戏循环、时间管理、插件的真正实现之处,单纯的World能实现的内容是相当有限的。
2.10 章节回顾
在这一章里,介绍了整个bevy_ecs crate中的主要内容,该部分是bevy能够运行的基石,同时也提供了强大的功能。利用依赖注入和ecs模式,bevy为我们搭建好了整个游戏的基础框架,使得我们不必再花费精力在状态管理和游戏循环以及并发之中。
阅读完这些内容,你现在应该已经能够看懂bevy储存库下example中的相当多内容了。虽然我们没有对ecs进行全面的细节介绍,但是通过这些介绍,你也应该能够自己自主探索剩下的内容了。仔细阅读example下的ecs示例,你会有新的收获。
Note
其实利用bevy_app、bevy_ecs、bevy_time三个crate,就能实现一个最基本的应用程序框架,有时候这是非常有用的。
例如你想编写一个没有窗口但是又不停运行的系统,但是又不想使用while和状态机来进行麻烦的状态管理,那么使用这三个crate就能解决你的问题。
第 3 章:资产系统
3.1 回顾Asset
资产是需要加载到游戏中的资源,通常来自于各种硬盘里的文件,例如图像、模型、材质、字体、音频等等等等。由于这些资源的加载往往需要耗费大量时间,因此Bevy里这些资产的加载往往都是以异步的形式以避免阻塞游戏循环。在Bevy中,我们可以使用AssetServer从硬盘里加载资产,使用Assets<T>来存储已经加载的各类资产。
与资产相关的内容主要在bevy_asset这个crate中,要使用这些内容,必须在App上调用其中的AssetPlugin这个插件才能访问AssetServer、Assets等类型,这个插件还提供了一些额外的设置,例如指定模式和路径等,该插件已经在DefaultPlugin内,不需要我们再手动安装。
与Asset相关的类型和结构很多,不过大多数时候我们都不需要和他们打交道,如果仅仅是使用,我们只需要和AssetServer还有Assets<T>接触就足够了。
3.1.1 AssetServer
AssetServer作为一种全局资源,可以使用之前我们加载资源的方式以Res来获取。默认情况下,加载的资产都相对于项目目录下的assets文件夹,要修改这个默认行为,可以修改BEVY_ASSET_ROOT环境变量来指定加载资产的目录。
首先,我们使用AssetServer加载了一个图像并获得其句柄,句柄类似于一个对资产的引用计数指针,但能被克隆为强句柄和弱句柄,当不再存在资产的强句柄时,Bevy能够自动将其回收并销毁以释放内存。所以,为了保证资产的持续存在,必须将句柄存储在一个Resource或者Component中。
#![allow(unused)]
fn main() {
#[derive(Resource)]
struct ShareImage {
handle: Option<Handle<Image>>,
}
fn load_image(asset_server: Res<AssetServer>, mut share_image: ResMut<ShareImage>) {
let image_handle = asset_server.load("test.png");
share_image.handle = Some(image_handle);
}
}
由于AssetServer返回的是一个句柄并采取异步的方式加载资源,如果你的逻辑中需要判断资源是否加载完成,不能依靠句柄本身存在与否来判断,要实现此功能,可以使用其身上的get_load_state方法或is_loaded_with_dependencies方法,该方法会返回一个LoadState类型的枚举,用来标识加载的阶段。他们的区别将在之后介绍,现在重要的是理解资产的加载是异步的,需要判断是否加载完成才能够使用。
#![allow(unused)]
fn main() {
fn on_asset_event(
mut commands: Commands,
asset_server: Res<AssetServer>,
share_image: Res<ShareImage>,
) {
match asset_server.get_load_state(&share_image.handle) {
Some(LoadState::NotLoaded) => {}
Some(LoadState::Loading) => {}
Some(LoadState::Loaded) => {
//在这里使用handle,这时已经加载完成
}
}
}
3.1.2 Assets
Assets<T> 是一个键值对集合,存储了特定类型 T 的所有实际资产数据。当AssetServer成功加载资源后,将会将真正的数据保存在对应的**Assets<T>** 中,如果需要获得真正的数据,则需要使用相关的句柄和对应类型的Assets
#![allow(unused)]
fn main() {
fn read_image_data(images: ResMut<Assets<Image>>, share_image: Res<ShareImage>) {
let handle = match &share_image.handle {
None => return,
Some(handle) => handle,
};
if let Some(image) = images.get(handle) {
// 现在你有了image的真正数据,可以读取或者修改
println!("Loaded image size: {:?}", image.size());
}
}
}
3.1.3 自定义资产
如果我们的资产是某种Bevy不支持的格式时,必须手动编写代码和Bevy进行交互来定义我们的资产类型、资产的加载方法、资产的设置以及加载时可能的错误。
假如,我们想要声明一个能够加载点云las文件的资产,那么首先需要定义我们的资产数据应该长什么样子。本质上那只是一个点的Vec而已,其中每个点都有自己的位置、点的尺寸、以及颜色信息,看起来可能是下面这个样子。
注意到我们使用了#[derive(Asset)]来告诉Bevy这是我们的资产。
#![allow(unused)]
fn main() {
//点云资产
#[derive(Asset)]
pub struct PointCloud {
pub points: Vec<PointCloudData>,
}
//实际的点数据
#[repr(C)]
pub struct PointCloudData {
pub position: Vec3,
pub point_size: f32,
pub color: [f32; 4],
}
}
接着,让我们定义加载时可能出现的一些错误,我们可以使用thiserror来快速声明这些错误类型。
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
pub enum LasLoaderError {
#[error("failed to load file: {0}")]
Io(#[from] std::io::Error),
}
}
之后,让我们定义一些资产的加载设置和加载器,并为我们的加载器实现AssetLoader特型,在之前我们介绍过,Bevy中的资产加载是异步的,因此需要使用async声明load方法。这里的代码没什么神奇的,但值得一提的是这里的 Reader读取的是二进制数据,需要使用一个Vec<u8>来作为缓冲区存储这些字节数据。
#![allow(unused)]
fn main() {
//在加载时我们可以额外传递一个配置以便动态的控制加载过程,但是在这里我们不需要这些
pub struct LasLoaderSettings{}
//我们的加载器
pub struct LasLoader {}
impl AssetLoader for LasLoader {
type Asset = PointCloud;
type Settings = LasLoaderSettings;
type Error = LasLoaderError;
async fn load(
&self,
reader: &mut dyn bevy_asset::io::Reader,
_settings: &Self::Settings,
_load_context: &mut LoadContext<'_>,
) -> Result<PointCloud, Self::Error> {
let mut bin_data = Vec::new();
reader.read_to_end(&mut bin_data).await?;
//在这里编写真正加载数据的逻辑
//let points = .....
//然后返回一个资产
Ok(PointCloud { points })
}
}
最后,让我们在App中注册这些资产和相应的加载器。
fn main() {
App::new()
.add_plugins(DefaultPlugins)
//通过这两个方法注册相应的加载器和资产类型
.init_asset_loader::<LasLoader>()
.init_asset::<PointCloud>()
.add_systems(Startup, load_pointcloud)
.run();
}
//现在,我们应该能够直接使用这些资产类型了
fn load_pointcloud(
mut commands: Commands,
asset_server: Res<AssetServer>,
){
let point_cloud_handler = asset_server.load::<PointCloud>("pointCloud.las");
}
3.2 资产加载流程
在 Bevy 引擎中,AssetReader、AssetLoader、AssetSaver 和 AssetWriter 都是资产系统的重要组成部分,它们各自承担着不同的职责。
3.2.1 AssetLoader
AssetLoader是我们主要用于加载资产的核心类型,在第一章的自定义资产中,我们做了如下几件事情:
- 使用
Asset指令定义一个PointCloud资产 - 定义对应的
LasLoader、LasLoaderError、LasLoaderSettings - 为
LasLoader实现异步的load方法,并从中返回一个Asset
通过这些操作,我们便可以在系统中这样使用我们自定义的加载器以方便的来实现自定义类型的文件加载,并且,我们还可以通过LasLoaderSettings来控制加载的行为。
fn main() {
App::new()
.add_plugins(DefaultPlugins)
//通过这两个方法注册相应的加载器和资产类型
.init_asset_loader::<LasLoader>()
.init_asset::<PointCloud>()
.add_systems(Startup, load_pointcloud)
.run();
}
//现在,我们应该能够直接使用这些资产类型了
fn load_pointcloud(
mut commands: Commands,
asset_server: Res<AssetServer>,
){
//我们不再简单的使用load方法,而是使用load_with_settings方法来指定setting参数
let point_cloud_handler = asset_server.load_with_settings::<PointCloud>(
"pointCloud.las",
|settings: &mut LasLoaderSettings| {
//我们可以对这个默认对象做一些更改
});
}
3.2.2 AssetReader
在为LasLoader实现异步的load方法时,你可能已经注意到了有一个Reader参数,他是一个类型擦除的bevy_asset::io::Reader特型对象,用于异步地将字节数据读取到缓冲区中。而这个特型对象,正是AssetReader的read方法返回的。
#![allow(unused)]
fn main() {
impl AssetLoader for LasLoader {
type Asset = PointCloud;
type Settings = LasLoaderSettings;
type Error = LasLoaderError;
async fn load(
&self,
reader: &mut dyn bevy_asset::io::Reader,
_settings: &Self::Settings,
_load_context: &mut LoadContext<'_>,
) -> Result<PointCloud, Self::Error> {
let mut bin_data = Vec::new();
reader.read_to_end(&mut bin_data).await?;
//在这里编写真正加载数据的逻辑
//let points = .....
//然后返回一个资产
Ok(PointCloud { points })
}
}
AssetReader和AssetLoader都用于资产的加载,但是他们负责的层级是完全不同的。AssetReader为不同的平台提供了一个统一的、异步的加载方式,使得我们的游戏能够在每个平台上以相同的方式读取资产;而AssetLoader负责将数据解析成正确的格式,以供程序的正确使用。
大多数情况下,我们可以直接使用内置的AssetReader不需要对其进行自定义,但如果你需要在这个环节进行一些操作,那么Bevy也提供了相应的方法让我们能够重写我们自己的Reader。详细的细节可以查看文档和示例。
3.2.3 AssetSaver与AssetWriter
理解了AssetReader与AssetLoader的关系,我们可以猜到应该还有两个用于保存资产到本地的类型,这些类型就是AssetWriter与AssetSaver,他们的关系是类似的:AssetWriter提供了一个统一的、异步的写入方式,将我们的资产写入到文件系统中,而AssetSaver则负责将数据转换成需要保存的格式。二者的具体使用方法可以查看文档,需要为AssetSaver实现一个save方法,该方法和load非常相似,这里不再赘述。
3.3 资产加载
3.3.1 内嵌资产
有些时候,我们希望将资产打包进入二进制程序中,然后在程序中直接读取这些资产而不是从硬盘里加载。例如,我们可能编写了一些wgsl着色器而又不想将这些代码作为文件存储在磁盘里,这时就需要将其直接内嵌在二进制的程序中,不过我们会需要一些额外的手段来告诉Bevy如何读取这些内嵌的资源。
Bevy中使用宏来嵌入资产到程序中并读取,他们是embedded_asset!宏和load_embedded_asset!宏。
embedded_asset!宏接受两个或三个参数,当接受两个参数的时候,第一个是当前app的可变引用,第二个是需要内嵌的资源的相对于当前目录的路径。假设我们的项目目录如下,我们现在想要将rock.wgsl内嵌到程序中。
bevy_rock
├── src
│ ├── render
│ │ ├── rock.wgsl
│ │ └── mod.rs
│ └── lib.rs
└── Cargo.toml
在render目录下的mod.rs中,我们可以编写一个插件,然后这样使用embedded_asset!宏和load_embedded_asset!宏。当使用宏加载后,文件将会位于一个虚拟的目录下,在这里是embedded://bevy_rock/render/rock.wgsl(注意到src路径已经被删除)。其中前面的embedded被称为AssetSourceId,每一类AssetSourceId都映射着对应的AssetReader和AssetWriter(从程序中之间内嵌的数据读取方式和从磁盘的读取方式不同,所以需要保留这个信息告诉Bevy正确的读取方式)。
我们还可以利用app上的register_asset_source方法注册自己的AssetSourceId,将其绑定到某些文件夹以便在加载资产时通过前戳来快速访问资产,详细的方法可以参考文档。
#![allow(unused)]
fn main() {
//使用两个参数的版本时,第一个参数是app的可变引用,第二个是资产的路径(相对当前目录)
embedded_asset!(app, "rock.wgsl")
//使用三个参数的版本时,第三个参数是资产的路径,第二个参数是需要移除的路径部分
//因此embedded_asset!(app, "rock.wgsl")和embedded_asset!(app, "/src/", "rock.wgsl")是等效的
embedded_asset!(app, "/examples/rock_stuff/", "rock.wgsl")
}
然后,我们有两种方式可以加载这个资源。
#![allow(unused)]
fn main() {
//如果在当前的模块中加载,我们可以直接使用load_embedded_asset
let shader = load_embedded_asset!(&asset_server, "rock.wgsl");
//如果在其他模块中,我们可以使用asset_server和路径全称
let shader = asset_server.load::<Shader>("embedded://bevy_rock/render/rock.wgsl");
}
在Bevy 0.12之前,你可能会看到名为load_internal_asset!的宏,该宏的作用和上面是一样的,不过目前已经被embedded_asset!取代,因此不建议继续使用。
3.3.2 web资产
另一类比较特殊的资产就是从web上加载的资产,在网络上加载一些内如,我们需要一点额外的支持:引入WebAssetPlugin插件并开启http特征。
use bevy::{asset::io::web::WebAssetPlugin, prelude::*};
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WebAssetPlugin {
silence_startup_warning: true,
}))
.add_systems(Startup, setup)
.run();
}
然后我们就可以像使用普通的文件一样,从web的url里加载这些资产了。
#![allow(unused)]
fn main() {
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn(Camera2d);
let url = "https://raw.githubusercontent.com/bevyengine/bevy/refs/heads/main/assets/branding/bevy_bird_dark.png";
commands.spawn(Sprite::from_image(asset_server.load(url)));
}
}
3.4 资产事件
资产会在加载的过程中发出一系列Message,如果你需要对这些事件做出一些响应,在bevy中这些事件是一个AssetEvent的枚举类型,可以看到当资产被添加、更改、移除、加载完成时都会发出一些事件,并且在其中保存了对应的资产的ID。
#![allow(unused)]
fn main() {
pub enum AssetEvent<A: Asset> {
Added {
id: AssetId<A>,
},
Modified {
id: AssetId<A>,
},
Removed {
id: AssetId<A>,
},
Unused {
id: AssetId<A>,
},
LoadedWithDependencies {
id: AssetId<A>,
},
}
}
要响应这些事件,只需要使用特定的MessageReader即可。
#![allow(unused)]
fn main() {
fn read_message(mut messages: MessageReader<AssetEvent>) {
for message in messages.read() {
//对消息做一些处理
//...
}
}
}
第 4 章:时间与定时器
4.1 Timer
一个Timer是一个bevy_time子crate中的类型,代表了bevy中的定时器,通过该类型,我们可以实现计时功能,其主要的两个构造方式如下:
#![allow(unused)]
fn main() {
pub fn new (duration: Duration , mode: TimerMode ) -> Self
pub fn from_seconds (duration: f32 , mode: TimerMode ) -> Self
//其中TimerMode的定义如下
pub enum TimerMode {
Once,
Repeating,
}
}
Timer并不关心时间的起点,而更关心时间差,其TimerMode指定了Timer的模式,这个模式的含义如下:
- Once:在经过
Duration时间后,计时器将停止跟踪并保持在完成状态,直到重置为止。 - Repeating:在经过
Duration时间后,计时器不会保持状态,而是可以继续计时,再经过Duration时间后还会触发,并且仍然可以在任何给定点重置。
Timer的用法非常简单,读者可以查看文档,这里只介绍以下几个方法。
#![allow(unused)]
fn main() {
//将计时器的时间往前拨动
//如果拨动的时间大于Timer内的Duration,Once类型的计时器会卡最大的Duration,Repeating类型的计时器不会被影响
pub fn tick (&mut self, delta: Duration ) -> &Self
//计时器从创建,到上一次tick为止,是否已经到达持续时间
pub fn is_finished (&self) -> bool
//仅在上次调用tick方法后,计时器到达Duration的情况下,才返回true
pub fn just_finished (&self) -> bool
}
is_finished与just_finished的区别可能有些微妙,这可以通过以下代码来演示。一般而言我们会在Repeating模式下使用just_finished,在Once模式下使用is_finished。
#![allow(unused)]
fn main() {
//Repeating模式下使用just_finished
let mut timer = Timer::from_seconds(1.0, TimerMode::Repeating);
//我们在这里拨动了1.1s的时间,大于1.0秒,因此在这个tick里满足了条件
timer.tick(Duration::from_secs_f32(1.1));
assert_eq!(timer.just_finished(), true);
//我们又在一个新的tick里拨动了0.5s,现在一共是1.6s,还没达到第二个时间点(2s)
//所以下面会返回false
timer.tick(Duration::from_secs_f32(0.5));
assert_eq!(timer.just_finished(), false);
//Once模式下使用is_finished
//在第一次就已经拨到了1.1s,大于1.0s,因此该始时钟停留在1.0s
//所以is_finished的返回值一直都是true
let mut timer = Timer::from_seconds(1.0, TimerMode::Once);
timer.tick(Duration::from_secs_f32(1.1));
assert_eq!(timer.is_finished(), true);
timer.tick(Duration::from_secs_f32(0.5));
assert_eq!(timer.is_finished(), true);
}
4.2 Time
Bevy中内置了一些时间信息,其定义如下,这些Time以全局资源的形式存在,通过这些我们可以获得Bevy的时间信息,从而指定时间相关的任务,例如定时运行某些系统等。
#![allow(unused)]
fn main() {
pub struct Time<T: Default = ()> {
context: T,
wrap_period: Duration,
delta: Duration,
delta_secs: f32,
delta_secs_f64: f64,
elapsed: Duration,
elapsed_secs: f32,
elapsed_secs_f64: f64,
elapsed_wrapped: Duration,
elapsed_secs_wrapped: f32,
elapsed_secs_wrapped_f64: f64,
}
}
Bevy中存在四种Time,这些时钟是我们在调用DefaultPlugins时内部的 TimePlugin为我们插入的,并且还为我们创建好了更新这些信息的,他们分别是:
- Time
:记录实际经过的时间 - Time
:记录虚拟游戏时间,该时间可以暂停或调整 - Time
:根据虚拟时间跟踪固定时间步长 - Time:一个通用时钟,对应于系统的“当前”或“默认”时间
Bevy是如何做到这些的?简而言之,TimePlugin插件在First调度中(还记得First吗?那是游戏循环的第一个阶段)更新了Time<Real>(使用渲染app传递的时间或者直接调用Instant::now()),然后使用了这个时间来更新了Time和Time<Virtual>。
bevy_time的源码里是这么写的,利用Time<Real>两次更新的时间差来更新Time<Virtual>,然后直接拷贝了一份给Time。这说明其实Time和Time<Virtual>里的时间其实是一样的(除非在FixedMain调度里)。
#![allow(unused)]
fn main() {
pub fn update_virtual_time(current: &mut Time, virt: &mut Time<Virtual>, real: &Time<Real>) {
let raw_delta = real.delta();
virt.advance_with_raw_delta(raw_delta);
*current = virt.as_generic();
}
}
在FixedMain调度里,Bevy会更改Time,这是通过调用下面这个特殊的系统来实现的。这个系统利用Time<Virtual>更新了Time<Fixed>,然后在FixedMain阶段里直接修改了Time的时间与Time<Fixed>相同,来让我们在运行FixedMain中的系统时,调用Time看到的时间是Time<Fixed>。最后,当离开这个调度后,我们又将其更正为Time<Virtual>,一切就这样恢复原样,剩下的系统看到的Time不会发生任何改变。
#![allow(unused)]
fn main() {
pub fn run_fixed_main_schedule(world: &mut World) {
let delta = world.resource::<Time<Virtual>>().delta();
world.resource_mut::<Time<Fixed>>().accumulate(delta);
// Run the schedule until we run out of accumulated time
let _ = world.try_schedule_scope(FixedMain, |world, schedule| {
while world.resource_mut::<Time<Fixed>>().expend() {
*world.resource_mut::<Time>() = world.resource::<Time<Fixed>>().as_generic();
schedule.run(world);
}
});
*world.resource_mut::<Time>() = world.resource::<Time<Virtual>>().as_generic();
}
}
说了这么多,其实只告诉了我们下面三件事:
在RunFixedMainLoop阶段执行的系统,我们看到的Time是Time<Fixed>。
在Update阶段执行的系统,我们看到的Time是Time<Virtual>。
如果需要获得现实世界的时间,我们则需要使用Time<Real>。
4.3 Time与Timer的配合使用
4.3.1 定时执行系统
很多时候,我们想要创建一个定时任务,利用Timer和Time,我们可以轻轻松松完成这件事,例如下面这样。
#![allow(unused)]
fn main() {
//返回一个闭包,这个闭包会在每次游戏循环里被执行,当返回true时系统将会执行
pub fn on_real_timer(duration: Duration) -> impl FnMut(Res<Time<Real>>) -> bool + Clone {
//创建一个定时器
let mut timer = Timer::new(duration, TimerMode::Repeating);
//闭包获取Time<Real>,并拨动始终,判断是否经过了一段时间
move |time: Res<Time<Real>>| {
timer.tick(time.delta());
timer.just_finished()
}
}
//利用run_if,可以使用这个方法
app.add_system(Update,some_system.run_if(on_real_timer(Durarion::from_sec_32(1.0))))
}
我们不需要重复编写这些功能,Bevy在bevy_time中已经为我们提供了一套常用的conditions,读者可以查看文档。
4.3.2 定时执行系统(进阶)
上面的方式只适用于简单的情况,更多时候我们还需要进行一定的控制,这时我们也可以使用上面的方式,但是我们这时候需要利用一个Component来存储Timer并手动利用tick更新时间,就像下面这样。
#![allow(unused)]
fn main() {
#[derive(Component, Deref, DerefMut)]
struct AnimationTimer(Timer);
fn animate_sprite(
time: Res<Time>,
mut query: Query<&mut AnimationTimer>,
) {
for mut timer in &mut query {
timer.tick(time.delta());
if timer.just_finished(){
//执行一些操作
}
}
}
}
4.3.3 时间相关的变量
Bevy的示例中充满了这种用法,我们可以获得Time,然后利用Time来更新某些变量。例如我们可以通过delta_secs方法获得每帧相隔的时间,然后乘以系数并不断累加到某个位置,这可以做到让该变量随着时间不断更新的效果。
#![allow(unused)]
fn main() {
fn animate(mut state: ResMut<AnimationState>, time: Res<Time>) {
if state.current >= state.max || state.current <= state.min {
state.speed = -state.speed;
};
state.current += state.speed * time.delta_secs();
}
}
第 5 章:2D 渲染
5.1 Mesh2d
在Bevy中,有 Mesh2d与Mesh3d两种,前者是三角形、矩形、圆形等各种二维形状,后者代表立方体、网格模型等三维物体。Mesh2d与Mesh3d可以像一个普通的组件一样使用,就像下面一样。在这些代码里,我们生成了一个实体,并在其之上添加了一个Mesh2d与对应的MeshMaterial2d。同时,别忘了我们还需要指定一个Camera2d来渲染这些内容。
#![allow(unused)]
fn main() {
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
commands.spawn(Camera2d);
commands.spawn((
Mesh2d(meshes.add(Rectangle::default())),
MeshMaterial2d(materials.add(Color::from(PURPLE))),
Transform::default().with_scale(Vec3::splat(128.)),
));
}
}
Mesh代表由点组成的集合,他们可能是一个模型、一条线段、或者只是一些点,Bevy,而ColorMaterial则代表代表这些形状的颜色、材质等。在Bevy中,二者以Assets的形式存在,当使用时,我们应当使用ResMut获得该资源并向其中添加真正的数据类型。在Assets中我们说过,Bevy加载Assets时将返回一个对应的句柄并在后台加载这些数据,因此,创建Mesh2d与MeshMaterial2d组件时,我们传递的也是一个对应的Handler。
Bevy在bevy_math::primitives中为我们定义好了一组基本的几何形状,这些形状同样在preload中不需要我们再从bevy_math中导入,如上面的Rectangle就是其中的一个形状,这里不再一一赘述,读者可以查看文档详细了解每种形状的创建方式。
5.2 MeshMaterial2d
MeshMaterial2d描述了Bevy应该如何渲染这些形状的具体信息,如果没有MeshMaterial2d只有Mesh2d,那么你会发现窗口上不会渲染任何东西。因此,不详细了解MeshMaterial2d,那么我们就无法掌握如何渲染这些形状。
5.2.1 ColorMaterial
ColorMaterial是一种二维的材质,其结构体的内容如下。可见,通过ColorMaterial我们能够控制的是一个mesh2d的颜色、透明度、uv变换、还有材质,下面我们来详细介绍一些其使用方式。
#![allow(unused)]
fn main() {
pub struct ColorMaterial {
pub color: Color,
pub alpha_mode: AlphaMode2d,
pub uv_transform: Affine2,
#[texture(1)]
#[sampler(2)]
pub texture: Option<Handle<Image>>,
}
}
在创建ColorMaterial时,我们可以直接向其传入上面的四个参数。
#![allow(unused)]
fn main() {
let mesh_handle = meshes.add(Rectangle::from_size(Vec2::splat(256.0)));
commands.spawn((
//给Mesh2d传入一个形状的句柄
Mesh2d(mesh_handle),
//给MeshMaterial2d传入一个ColorMaterial的句柄
MeshMaterial2d(materials.add(ColorMaterial {
color: BLUE.into(),
alpha_mode: AlphaMode2d::Opaque,
texture: Some(texture_handle.clone()),
..default()
})),
Transform::from_xyz(-300.0, 0.0, 1.0),
));
}
对于第一个参数color而言,Bevy给了我们很多的构造方式,这些方式如下。
#![allow(unused)]
fn main() {
//bevy里有一组内置的常用颜色,可以直接引入他们
use bevy::color::palettes::css::{BLUE, GREEN, WHITE};
//或者,我们自己创建颜色结构体Color并传入
use bevy::prelude::*;
let color = Color::hsl(360. * i as f32 / num_shapes as f32, 0.95, 0.7);
}
枚举alpha_mode是一个AlphaMode2d类型,当我们使用图像做为材质时,该值设置对于图像的透明区域该如何处理。其包含三个值Opaque,Mask(f32),Blend,他们的含义如下。
Opaque:忽略图像的透明通道,使用底色填充(一般为黑色)Mask(f32):设置一个阈值,类型为f32,当透明度低于这个值时将被渲染为透明,否则被认为完全不透明Blend:透明通道不为1时则为半透明颜色,并且会与下面的其他颜色相混合
#![allow(unused)]
fn main() {
use bevy::sprite_render::AlphaMode2d;
MeshMaterial2d(materials.add(ColorMaterial {
alpha_mode: AlphaMode2d::Opaque,
..default()
})),
}
uv_transform要与texture一起使用时才能发挥作用,当我们加载了一个图像并传入时,我们可以通过指定uv_transform来决定图像应该如何与形状对其,这是一个二维的仿射变换。
在加载图像时,若图像大小小于Mesh2d,那么默认情况下,图像将会在左上角开始被放大到能填满区域为止,但是当你指定了uv_transform时,若还不能覆盖整个区域,那么边缘将会被拉伸到填满区域为止;若图像大小大于Mesh2d,那么将只会从图像的左上角开始显示。或者,我们可以指定将图像进行重复显示,这是通过加载图像时指定settings做到的。
ImageSamplerDescriptor里包含了大量的设置,读者可以查看文档详细了解,这里我们只使用其uv参数。
#![allow(unused)]
fn main() {
let image_with_repeated_sampler = asset_server.load_with_settings(
"textures/fantasy_ui_borders/panel-border-010-repeated.png",
|s: &mut _| {
*s = ImageLoaderSettings {
sampler: ImageSampler::Descriptor(ImageSamplerDescriptor {
// 以重复的模式来加载图像
address_mode_u: ImageAddressMode::Repeat,
address_mode_v: ImageAddressMode::Repeat,
..default()
}),
..default()
}
},
);
//使用时,我们需要指定Affine2来决定重复的次数,如果不指定,那么将默认为1,因此区域将铺满一个图像,而且使用临近插值
//因此,这样不会出现线性插值时导致的边缘模糊问题
MeshMaterial2d(materials.add(ColorMaterial {
texture: Some(image_with_repeated_sampler),
// uv_transform used here for proportions only, but it is full Affine2
// that's why you can use rotation and shift also
uv_transform: Affine2::from_scale(Vec2::new(2., 3.)),
..default()
})),
}
现在,我们介绍了ColorMaterial的四个重要的参数的使用方法,但是我们仍然缺少一种重要的材质渲染方式,这种方式是GPU最基础的也是功能最强大的,那就是利用顶点属性来渲染颜色。
Mesh结构体上有一个特殊的方法,名叫insert_attribute,利用这个方法,我们可以对Mesh的顶点处插入一些属性,并指定这些属性的类型,当拥有这些属性时,wgpu将会自动工作来将其显示在屏幕上。同样,我们也可以移除这些属性,关于其他更多的方法可以查看相关的文档。
#![allow(unused)]
fn main() {
let mut mesh = Mesh::from(Rectangle::default());
//顶点属性是一组数组
let vertex_colors: Vec<[f32; 4]> = vec![
LinearRgba::RED.to_f32_array(),
LinearRgba::GREEN.to_f32_array(),
LinearRgba::BLUE.to_f32_array(),
LinearRgba::WHITE.to_f32_array(),
];
//利用insert_attribute可以插入这些数组,并指定其类型为Mesh::ATTRIBUTE_COLOR
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, vertex_colors);
//现在,我们可以使用这个新的mesh作为Mesh2d
let mesh_handle = meshes.add(mesh);
}
5.2.2 自定义材质
与2d相关的内容主要储存在bevy_sprite_render这个crate中,其中包括渲染管道的组成、材质的定义的代码等。我们可以通过实现一个 Material2d特型来创建自己的材质。这涉及到了一些底层的wgpu与图形学内容,因此这一节应该属于渲染管线的内容,但是在这里我们将会简要提一下,感兴趣的读者可以查看文档,或者等到学习自定义着色器时在反过来看这些内容。
5.3 Font
字体是2D显示的另一个方面,在Bevy中,我们可以加载自己的字体并显示。
字体是后戳为ttf的一些文件,包含了文字应该如何在屏幕上绘制的信息,要加载这些字体,就如同加载普通的Aeest一样。
#![allow(unused)]
fn main() {
let font = asset_server.load("fonts/FiraSans-Bold.ttf");
//加载字体之后,我们使用TextFont组件使用它,并指定一些字体的样式,就像使用图片一样
let text_font = TextFont {
font: font.clone(),
font_size: 50.0,
..default()
};
//将这个组件绑定到某个实体上,并指定一些其他的字体组件,便能显示在屏幕上
commands.spawn((
Text2d::new(" translation "),
text_font.clone(),
TextLayout::new_with_justify(text_justification),
TextBackgroundColor(Color::BLACK.with_alpha(0.5)),
Text2dShadow::default(),
));
}
在这其中涉及到了大量的字体相关的组件,例如TextLayout,TextBackgroundColor,Text2dShadow,他们的作用不言而喻,通过修改这些组件便可以影响字体的显示效果,读者可以查阅文档来获得详细的信息,这里不再赘述。
5.4 Image
在前面,我们只是简单的使用了一下 asset_server来加载图像,但是我们还没有详细的介绍图像,在介绍Sprite相关的知识之前,有必要在介绍一下与图像相关的加载方式。在之前,我们知道了如何使用asset_server来加载图像,然后利用返回的handle,我们可以进行查询。
#![allow(unused)]
fn main() {
fn read_image_data(images: ResMut<Assets<Image>>, share_image: Res<ShareImage>) {
let handle = match &share_image.handle {
None => return,
Some(handle) => handle,
};
if let Some(image) = images.get(handle) {
//在这里可以使用
}
}
}
以下是Image结构体的字段,顾名思义,我们可以通过data来访问原始的图像数据,另外我们还有一些配置项能够修改图像的渲染行为。这些参数的具体含义,读者可以查看文档。这里不再赘述。
#![allow(unused)]
fn main() {
pub struct Image {
pub data: Option<Vec<u8>>,
pub data_order: TextureDataOrder,
pub texture_descriptor: TextureDescriptor<Option<&'static str>, &'static [TextureFormat]>,
pub sampler: ImageSampler,
pub texture_view_descriptor: Option<TextureViewDescriptor<Option<&'static str>>>,
pub asset_usage: RenderAssetUsages,
pub copy_on_resize: bool,
}
}
在这里,我们需要重点关注其sampler字段,其表示了当图像被缩放时应该怎么被渲染,默认情况下Bevy将会采取线性插值的方式渲染,这可能会导致我们的图像经过放大之后出现模糊。这时,我们可以更改其为最临近插值,即可解决这个问题。
#![allow(unused)]
fn main() {
image.sampler = ImageSampler::nearest();
}
更进一步,不知道读者是否还记得,每一种资产加载时,我们可以传递一个setting选项,Bevy为我们提供了ImageLoaderSettings选项,其中就可以直接指定我们的采样方式。
#![allow(unused)]
fn main() {
//加载图像时的一些可选配置
pub struct ImageLoaderSettings {
pub format: ImageFormatSetting,
pub texture_format: Option<TextureFormat>,
pub is_srgb: bool,
pub sampler: ImageSampler,
pub asset_usage: RenderAssetUsages,
}
let image = asset_server.load_with_settings(
"textures/fantasy_ui_borders/numbered_slices.png",
|settings: &mut ImageLoaderSettings| {
settings.sampler = ImageSampler::nearest();
},
);
}
5.5 Sprite
Sprite通常翻译为“精灵”,但其实只是另一种形式的图像,通常用于2D游戏的贴图。由于Sprite只是一种图像,因此最简单的方式就是直接加载一个图像然后生成,这可以通过以下方式来做到,通过这种方式,生成的贴图将会保持图像的原始尺寸,默认情况下,其中心将位于坐标(0,0)处。
#![allow(unused)]
fn main() {
//Sprite包含了非常多的参数,这些参数我们将在后面一一介绍
pub struct Sprite {
//使用的图像的句柄
pub image: Handle<Image>,
//用于渲染的图像集合(多用于精灵的动画)
pub texture_atlas: Option<TextureAtlas>,
//设置颜色,可以用来控制透明度
pub color: Color,
//是否沿着x轴反转精灵
pub flip_x: bool,
//同上,不过是y轴
pub flip_y: bool,
//渲染时选定的尺寸,用于限制精灵的大小
pub custom_size: Option<Vec2>,
//
pub rect: Option<Rect>,
//控制精灵的加载方式,缩放、切片、瓦片重叠等
pub image_mode: SpriteImageMode,
}
//可以直接使用Sprite结构体来加载
commands.spawn(Sprite {
image: asset_server.load("sprites/ball.png"),
..default()
});
//或者,可以使用一个工厂函数
let image_handle = asset_server.load("sprites/ball.png");
let sprite = Sprite::from_image(image_handle);
commands.spawn(sprite);
}
让我们先来介绍最重要的image_mode,很多时候我们并不是只想将图像加载,而是以重复的方式或者切片、缩放之后加载,这种方式可以节省我们的内存,通过只保存一份最小纹理,然后在加载时进行处理,我们就可以节省空间。为了做到这件事,我们需要使用image_mode参数,该参数是一个名为SpriteImageMode 的枚举类型,其包含以下四个值。
#![allow(unused)]
fn main() {
pub enum SpriteImageMode {
//默认拉伸图像到能够填满整个区域
Auto,
//将纹理缩放
Scale(SpriteScalingMode),
//将纹理切片
Sliced(TextureSlicer),
//将纹理重复堆叠,stretch_value用于指定每一片相对于图像大小的缩放比
Tiled {
tile_x: bool,
tile_y: bool,
stretch_value: f32,
},
}
}
5.5.1 sprite scale
要在加载时对图像进行缩放,我们可以在加载时将其指定为SpriteImageMode::Scale,其类型如下。其中包含了六个类型,这些值都不会改变图像的宽高比,前三个会尽可能放大图像导致图像只有一部分被渲染,后三个保证图像都会被渲染。
#![allow(unused)]
fn main() {
pub enum ScalingMode {
FillCenter,
FillStart,
FillEnd,
FitCenter,
FitStart,
FitEnd,
}
}
要使用这些值时,还必须指定Sprite的custom_size字段,因此我们现在可以这样去设定每个精灵的大小了。
#![allow(unused)]
fn main() {
commands.spawn((
Sprite {
image: asset_server.load("textures/slice_square_2.png"),
custom_size: Some(Vec2::new(100., 225.)),
image_mode: SpriteImageMode::Scale(SpriteScalingMode::FillCenter),
..default()
}
));
}
下面是bevy示例sprite_scale中,每种缩放模式的效果。

5.5.2 sprite slice
要在加载时对图像进行切片,我们可以在加载时将其指定为SpriteImageMode::Sliced,其中的TextureSlicer类型如下。sliced是一种九宫格切片技术,关于该技术的详细介绍读者可以查看百科。简而言之,这是一直特殊的缩放技术,能够在缩放时尽可能合理保持图像的纹理。
#![allow(unused)]
fn main() {
pub struct TextureSlicer {
//进行切片时的四条切割线位置
pub border: BorderRect,
//九宫格中心的缩放方式
pub center_scale_mode: SliceScaleMode,
//九宫格四个侧面的缩放方式
pub sides_scale_mode: SliceScaleMode,
//九宫格四个角的最大缩放比例
pub max_corner_scale: f32,
}
pub enum SliceScaleMode {
//以拉伸的方式拉伸图像
Stretch,
//如果要重复绘制,每一小块纹理的缩放值
Tile {
stretch_value: f32,
},
}
//这里的四个值是相对于图像四个边缘的偏移量(分成九宫格需要四条线),而不是绝对坐标
//left指的是距离图像左侧边缘的距离
//right指的是距离图像右侧边缘的距离
pub struct BorderRect {
pub left: f32,
pub right: f32,
pub top: f32,
pub bottom: f32,
}
}
同样,我们在使用这种缩放模式的时候,也必须指定尺寸的大小。然后,就可以这样创建不同大小的切片。下图中的效果来自bevy的sprite_slice示例。
#![allow(unused)]
fn main() {
commands.spawn((
Sprite {
image: asset_server.load("textures/slice_square.png"),
custom_size: Some(Vec2::new(300.0, 200.0)),
image_mode: SpriteImageMode::Sliced(TextureSlicer {
border: BorderRect::all(200.0),
center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.1 },
sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 },
max_corner_scale: 0.2,
}),,
..default()
},
));
}

5.5.3 sprite tile
要在加载时对图像进行重复,例如我们可以利用小块纹理进行重复来得到整块区域的北京,我们可以在加载时将其指定为SpriteImageMode::Tiled,相比前两种,这种方式使用起来则轻松得多。
#![allow(unused)]
fn main() {
Tiled {
tile_x: bool,
tile_y: bool,
stretch_value: f32,
},
}
我们有三个参数,分别指定了在两个方向上是否要进行重复,最后一个参数和slice中的一样,指定重复时的比例。例如,我们可以这样设定。
#![allow(unused)]
fn main() {
commands.spawn(Sprite {
image: asset_server.load("branding/icon.png"),
image_mode: SpriteImageMode::Tiled {
tile_x: true,
tile_y: true,
stretch_value: 0.5, // The image will tile every 128px
},
..default()
});
}

5.5.4 sprite animations
加载完了图像之后,我们还需要给每个角色设定动画,如果只是看到人物单纯的平移那可不是我们想要的。对于2D图像来说,我们最常用的就是下面这种方式,其中每一副图像指定了角色动画的一帧,我们要做的就是不断重复这些图像来产生动画。

要加载这样的精灵,我们需要结合texture_atlas参数来指定动画每一帧的读取方式,该参数的定义如下。
#![allow(unused)]
fn main() {
pub struct TextureAtlas {
//纹理的布局的描述
pub layout: Handle<TextureAtlasLayout>,
//纹理图的索引
pub index: usize,
}
pub struct TextureAtlasLayout {
//整个图像大小
pub size: UVec2,
//图像里每一帧的大小
pub textures: Vec<URect>,
}
}
由于TextureAtlasLayout是一种Assets,因此我们还需要将其添加到bevy内置的资产中。
#![allow(unused)]
fn main() {
//这个组件包含一个计时器,用于控制我们的动画播放速度
#[derive(Component, Deref, DerefMut)]
struct AnimationTimer(Timer);
//这个组件用来记录帧数的起始和结束编号
#[derive(Component)]
struct AnimationIndices {
first: usize,
last: usize,
}
fn setup(
mut commands: Commands,
asset_server: Res<AssetServer>,
// 获取TextureAtlasLayout资产
mut texture_atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
//加载这幅由多帧拼成的图像
let texture = asset_server.load("textures/rpg/chars/gabe/gabe-idle-run.png");
//指定图像的布局,这里指定其为1行7列,每一帧大小为24px,后两个参数是padding和offset,我们不需要指定
let layout = TextureAtlasLayout::from_grid(UVec2::splat(24), 7, 1, None, None);
//将其添加到全局的资产中
let texture_atlas_layout = texture_atlas_layouts.add(layout);
let animation_indices = AnimationIndices { first: 1, last: 6 };
//加载相机
commands.spawn(Camera2d);
//指定精灵和动画
commands.spawn((
Sprite::from_atlas_image(
texture,
TextureAtlas {
layout: texture_atlas_layout,
//指定当前的帧编号
index: 1,
},
),
animation_indices,
Transform::from_scale(Vec3::splat(6.0)),
AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
));
}
}
现在,我们便可以在一个系统中通过更改组件的texture_atlas上的index,就可以实现帧之间的切换。不过在这里,我们得关心一下帧切换的速度,由于bevy的渲染系统非常快,所以我们必须控制一下切换的速度符合实际,这就要利用我们之前添加的AnimationTimer,其中定义了一个Timer。
#![allow(unused)]
fn main() {
fn animate_sprite(
time: Res<Time>,
mut query: Query<(&AnimationIndices, &mut AnimationTimer, &mut Sprite)>,
) {
for (indices, mut timer, mut sprite) in &mut query {
timer.tick(time.delta());
if timer.just_finished()
&& let Some(atlas) = &mut sprite.texture_atlas
{
atlas.index = if atlas.index == indices.last {
indices.first
} else {
atlas.index + 1
};
}
}
}
}
5.5.5 批量加载sprite
想象一下,在一个游戏里我们有巨量的图像都需要加载,在这种情况下这件事很快就会变得很棘手,由于精灵的不同动作的动画可能不在一幅图像里,因此我们必须手动的将这些多副图像组合为一幅。幸运的是,Bevy为我们提供了一种更好的方式来加载这些图像。
首先,我们不再使用asset_server来加载单幅图像,而是使用其load_folder方法来递归地加载整个文件夹下的所有可加载文件,并判断其是否是图像。
#![allow(unused)]
fn main() {
//用于保存句柄的全局资源
#[derive(Resource, Default)]
struct RpgSpriteFolder(Handle<LoadedFolder>);
fn load_textures(mut commands: Commands, asset_server: Res<AssetServer>) {
//在这里我们加载整个文件夹,并保存其句柄
commands.insert_resource(RpgSpriteFolder(asset_server.load_folder("textures/rpg")));
}
}
然后,我们还需要某种方式,来观察加载是否已经完成然后调用我们的某个系统来处理,这可以通过系统的state来实现(还记得吗?)
//首先定义我们的state,并指定默认的状态
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, States)]
enum AppState {
#[default]
Setup,
Finished,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
//注册我们的state
.init_state::<AppState>()
//当进入Setup状态时,我们调用load_textures加载整个文件夹下的内容
.add_systems(OnEnter(AppState::Setup), load_textures)
//如果在Setup状态,我们就不停的检查加载是否已经完成
.add_systems(Update, check_textures.run_if(in_state(AppState::Setup)))
//当进入Finished状态时,资源加载完成,我们调用setup函数使用这些资源
.add_systems(OnEnter(AppState::Finished), setup)
.run();
}
fn check_textures(
mut next_state: ResMut<NextState<AppState>>,
rpg_sprite_folder: Res<RpgSpriteFolder>,
mut events: MessageReader<AssetEvent<LoadedFolder>>,
) {
//我们可以通过事件的方式来检查加载是否完成
for event in events.read() {
if event.is_loaded_with_dependencies(&rpg_sprite_folder.0) {
next_state.set(AppState::Finished);
}
}
}
一旦这些步骤完成,我们就可以使用这些资源来加载我们的sprite。
#![allow(unused)]
fn main() {
fn setup(
mut commands: Commands,
rpg_sprite_handles: Res<RpgSpriteFolder>,
asset_server: Res<AssetServer>,
mut texture_atlases: ResMut<Assets<TextureAtlasLayout>>,
loaded_folders: Res<Assets<LoadedFolder>>,
mut textures: ResMut<Assets<Image>>,
) {
//首先获取我们已经加载完成的loaded_folder
let loaded_folder = loaded_folders.get(&rpg_sprite_handles.0).unwrap();
//然后创建sprite
let (texture_atlas_linear, linear_sources, linear_texture) = create_texture_atlas(
loaded_folder,
None,
Some(ImageSampler::linear()),
&mut textures,
);
}
}
如何合并多副图像为一个并从中创建单个的TextureAtlas呢?这可以通过TextureAtlasBuilder来实现,TextureAtlasBuilder能够自动将多副图像合并为一副,并返回三个参数:texture_atlas_layout, texture_atlas_sources, texture,他们的类型分别是TextureAtlas,TextureAtlasSources,Handle<Image>。
#![allow(unused)]
fn main() {
fn create_texture_atlas(
folder: &LoadedFolder,
padding: Option<UVec2>,
sampling: Option<ImageSampler>,
textures: &mut ResMut<Assets<Image>>,
) -> (TextureAtlasLayout, TextureAtlasSources, Handle<Image>) {
//创建一个Builder
let mut texture_atlas_builder = TextureAtlasBuilder::default();
//我们可以指定padding来在合并时将图像相隔一段像素
texture_atlas_builder.padding(padding.unwrap_or_default());
//对文件夹里的每一副图像,检查是否为图像并其添加到builder中
for handle in folder.handles.iter() {
let id = handle.id().typed_unchecked::<Image>();
let Some(texture) = textures.get(id) else {
warn!(
"{} did not resolve to an `Image` asset.",
handle.path().unwrap()
);
continue;
};
texture_atlas_builder.add_texture(Some(id), texture);
}
//现在,合并这些图像并生成一整副
let (texture_atlas_layout, texture_atlas_sources, texture) =
texture_atlas_builder.build().unwrap();
//将合并之后的图像添加到全局资产中
let texture = textures.add(texture);
//在这里修改我们的图像采样方式
let image = textures.get_mut(&texture).unwrap();
image.sampler = sampling.unwrap_or_default();
(texture_atlas_layout, texture_atlas_sources, texture)
}
}
根据前面的介绍,texture_atlas_layout中记录了合并后整副图像的大小和每一副子图像的矩形范围。但是我们还缺少一个关键信息,我们只知道这幅子图的范围,而不知道这部分子图到底是那副图像。这些信息,就记录在了TextureAtlasSources类型的texture_atlas_sources中,其定义如下,只是一个简单的HashMap而已。如果我们需要使用这些信息的话,便可以通过这两个返回值来获得正确的对应关系。
#![allow(unused)]
fn main() {
pub struct TextureAtlasSources {
pub texture_ids: HashMap<AssetId<Image>, usize>,
}
}
第 6 章:空间变换
6.1 向量与变换
6.1.1 向量
在线性代数中,我们学习过,如果我们确定了一组线性无关的基,那么三维空间中的某个点坐标,可以使用一个向量来表示。一般而言,我们经常取一组空间相互正交的基,并令他们的模长为1,于是,我们的xyz三个轴的基向量分别表示为:、、。且相互之间两两做点积为0。
对于空间中的一个向量,利用我们的正交基,可以将其表示为即:
一般情况下,我们将其基的三个系数,成为a在该基下的坐标。即的坐标为
6.1.2 旋转
现在,假设我们有两组正交基,它们分别为 (世界空间基)和 (局部空间基)。同一个空间向量 在这两组基下的表示分别为:
于是,我们得到等式:
现在,我们假设第二组正交基(局部基)中的每一个向量,都可以通过第一组基(世界基)的线性组合来表示。即:
将其写成矩阵形式,我们得到了两组正交基的变换公式:
我们将这个中间的 矩阵记为 。将其带入之前的等式:
由于基向量是线性无关的,我们便得到了两组坐标的转换公式:
既然我们已经建立了等式 ,那么反过来,如果我们已知旋转后的向量在世界空间中的表示 ,想要推导它在局部空间(即相对于那组新基 )的坐标 ,该怎么办?
利用正交基的一个核心性质:向量与基向量的点积即为该向量在该方向上的分量投影。我们将等式两边同时与局部基向量做点积:
将 代入:
同理可得 和 。写成矩阵形式:
观察这个矩阵中的每一个元素。由于我们的基向量均为单位向量(模长为 1),根据点积的定义 ,每一个元素 实际上就是两组基向量之间夹角的余弦值。
因此,这个矩阵被称为方向余弦矩阵。并且,他就是的逆矩阵
现在,我们将这个发现与之前的旋转矩阵 进行对比。你会发现一个极其优美的数学对称性:
- 从局部到世界:矩阵 的列是新基在旧基下的投影。
- 从世界到局部:上面这个投影矩阵的行是新基在旧基下的投影。
这意味着,对于正交旋转矩阵,其逆矩阵等于其转置矩阵(,这是一个很重要的性质!)。
小结:不管是如何进行旋转变换,我们总要找出一个旋转矩阵,然后用旋转矩阵和原来的坐标做左乘,即可得到新的旋转之后的坐标。在正交基的情况下,这个矩阵就是方向余弦阵,因此我们有
6.1.3 平移
相对于旋转,平移在直觉上要简单得多。在三维空间中,平移仅仅是将物体从一个点 移动到另一个点 ,其变换过程可以简单地表示为向量加法:
其中 是我们的平移向量。
6.1.4 World-to-Local
到目前为止,我们已经用 矩阵 解决了旋转和缩放。如果我们想对坐标系同时进行旋转和平移,那么我们的坐标变换公式会变成: 其中是原来坐标系下的坐标,是变换之后的坐标。
这个公式在数学上被称为仿射变换。然而,从计算机图形学和引擎设计的角度来看,这个公式不适合硬件加速运算:
- 无法合并:如果你有一连串的变换(比如父实体转了 30 度并移动了 5 米,子实体又转了 20 度),必须交替进行矩阵乘法和向量加法,这使得变换的复合变得极其复杂。
- 原点的“诅咒”:对于任何 矩阵 ,都有 。这意味着在线性变换的视角下,原点是被钉死的,你永远无法通过“乘法”把一个处于原点的物体挪走。
为了将平移这个“加法”操作统一进矩阵的“乘法”大门,我们需要引入齐次坐标。我们将三维向量 提升到四维,增加一个分量 。
通过构建一个 矩阵,可以将旋转 和平移 完美地融合在一起: 对于这个式子,我们把中间的矩阵记作如下,这个变换叫做 World-to-Local(世界坐标系到局部坐标系) 变换,其中局部坐标系的原点,在世界坐标系下的坐标。
Important
很多教程和库(如 OpenCV/SLAM 等)直接将变换矩阵的平移项记作 。此时的 并不是局部坐标系原点 本身,而是 在局部坐标系下经过旋转补偿后的投影,即 。从几何含义上看,这实际上就是世界坐标系原点,在局部坐标系下的坐标。
当展开这个矩阵乘法时,你会发现:
- 前三列与坐标相乘,完成了旋转。
- 第四列与 相乘,恰好将平移分量 加到了结果中。
这说明,无论多么复杂的变换序列(移动、旋转、再移动、再缩放),在底层都可以坍缩为一连串 4x4 矩阵的连乘。 这也是为什么 GPU 专门针对 4x4 矩阵运算进行了硬件优化。
小结:如果假设点不动,而坐标系发生了旋转和平移,那么在新的坐标系下的坐标x’与x的关系为:
6.1.5 Local-to-World
在前面的讨论中,我们研究的是如何将世界坐标映射到新的局部坐标系中。但在构建场景时,我们更常见的操作是:定义一个物体在自己的局部空间中的样子,然后将其“放置”到世界空间中。
如果我们已知点在局部坐标系下的坐标 ,想要反求它在世界坐标系下的坐标 ,只需要对之前的等式 进行求逆操作:
我们将这个逆矩阵记为 。对于齐次变换矩阵 ,其逆矩阵具有非常特殊的结构。利用旋转矩阵的正交性(),我们可以直接写出这个矩阵:
注意:这里的 不再带转置,因为它代表的是局部基向量在世界空间下的方向。不仅仅是平移的负,而是旋转和平移整体作用后的负
在 Bevy 中,当你创建一个实体并设置它的 Transform 时,你本质上就是在定义这个 Local-to-World 矩阵:
- 旋转(Rotation):填充矩阵左上角的 部分。
- 平移(Translation):填充矩阵第四列的前三个元素。
6.1.7 点旋转时的变换
在上面,我们讨论的都是坐标系动、点不动的情况。现在,我们切换到另一个视角:坐标系 始终固定不变,而空间中的点(或向量)从 位置旋转到了 位置。
显而易见的,点相当于坐标系转换一个角度,等于坐标系**反向旋转(因此这里是R的逆的逆,即R本身)**一个相同的角度,因此我们可以直接得到。 对于齐次变换,同样可以容易得到: 除此之外,要注意!这里的是在原来的坐标系下的坐标,也就是说他和p是同一个坐标系下面的坐标。
上一小节的中的是坐标系在旋转后的,新的坐标系下面的,坐标。这很重要!很多教程往往不能正确区分他们。
6.1.8 几种情况下的计算方法
情况1:假设某点坐标为,其按照如下顺序进行了变换:首先相对于原始坐标系旋转了一个角度,变换矩阵为得到,然后又相对于原始坐标系再次旋转了一个角度,变换矩阵为,那么最终坐标和的关系是什么?
注意⚠️!这里是点绕着坐标系旋转,而不是坐标系本身在变化
根据上一小节中的内容,经过第一次旋转后,点的位置变为: 此时, 是一个在原始坐标系下表达的新坐标值。
因为第二次旋转 依然是相对于原始坐标系定义的,它直接作用于当前空间中的任何向量。由于 此时已经在原始坐标系中就位,我们直接对其应用 : 将步骤 1 的等式代入步骤 2,利用矩阵乘法的结合律我们得到: 用处:这个公式解释了一个物体的中心点坐标应该如何在固定的世界坐标系中连续变换,只需要不断的左乘在原来的结果上即可。
情况2:假设某点坐标为,其坐标系按照如下顺序进行了变换:首先相对于原始坐标系旋转了一个角度,坐标系的变换矩阵为,然后又相对于原始坐标系再次旋转了一个角度,坐标系变换矩阵为,那么在最终的坐标系下的坐标和的关系是什么?
注意⚠️!这里是坐标本身在旋转,而点没有发生变化
这个问题要稍微难一些。重新回顾我们前面的定义,如果有局部坐标下的坐标 ,想要反求它在世界坐标系下的坐标 ,我们有: 现在,我们来考察这两次变换后,最终的坐标系的基,在世界坐标系下的是什么。由于我们仅考虑旋转,因此平移向量是零向量。因此我们得到,每一次变换后,基的变换矩阵为: 根据情况1的结论,当一个点(向量)绕着定轴连续绝对变换时,基的最终坐标为 由于点没有发生变化,因此我们有: 因此我们得到(只有在是单位阵下才成立): 用处:这个公式解释了多个旋转如何被合并为一个旋转。
情况3:假设某点坐标为,其坐标系按照如下顺序进行了变换:首先相对于原始坐标系旋转了一个角度,坐标系的变换矩阵为,然后又相对已经旋转后的新的坐标系再次旋转了一个角度,坐标系变换矩阵为,那么最终坐标和的关系是什么?
在第一次旋转时,对于新坐标系下的坐标: 在第二次旋转时,对于最终的坐标系系下的坐标: 我们可以得到 用处:这个公式解释了如何相对自己目前的坐标来进行连续旋转,只需要不断的右乘在原来的变换矩阵上即可。
6.1.9 总结
上面各个例子也有对应的World To Local变换下的结果,但是最结论里的顺序都是相同的。
这部分相对来说比较绕,记住一个口诀“外左内右”:
- 外生变换(绕固定世界轴):新矩阵往左边乘。
- 内生变换(绕自身局部新轴):新矩阵往右边乘。
6.2 四元数
6.2.1 万向锁问题
在上面的情况二和情况三中,我们介绍了将多个旋转合并为一个旋转的方法。然而在一般的实践中,我们常常用到的是情况三。因为我们人类习惯性的都会把旋转相对于“当前的坐标系”,而不是最初的那个固定的原始坐标系。
通过这种方式,我们可以将一次旋转分别分别成先后绕着的三次旋转,这样就可以很直观的方式来描述旋转。注意!绕着XYZ和YXZ的顺序并不一样,这是因为矩阵乘法不是可交换的。再例如,如果你做过类似航空、GIS、卫星相关的领域,那么你一定也知道“偏航角,俯仰角,滚动角”,这是一组经典的按照的顺序旋转。其中,轴朝正上方,轴朝物体前方,轴则和平面垂直(你能想象得到吗?,这种的习惯在游戏中也经常使用,在游戏中轴朝正上方,轴朝物体前方,轴则和平面垂直)。因此我们的旋转是:
- 绕物体自身的Z轴旋转,旋转角度
- 绕旋转之后的Y轴旋转,旋转角度
- 绕旋转之后的X轴旋转。旋转角度
然而,如果你平时经常研究物理,那你一定知道采用这种方式来表达旋转会导致著名的万向锁问题。让我们来回忆一下,什么是万象锁吧。以旋转为例,当我们的俯仰角,也就是Y旋转了90度时,第一次旋转与第三次旋转将使用同一个轴。这是什么意思呢?
让我们来分析一下。现在我们就是游戏中的角色:
- 我们首先往左看,“偏航”了一个角度,假设我们这次的旋转矩阵是,下标为是因为现在我们假设一开始是世界坐标系中,通过旋转我们进入了新的坐标系中。这时候我们得到了新的X和Y轴,而Z轴不变。
- 然后我们直接抬头正视上方盯着天空。假设我们这时得到的旋转矩阵是,这时候我们的新的X轴和Z轴,Y轴不变,但是,这时候可以发现,我们现在的新的X周和第一步的Z轴重合了。
- 最后,我们绕着第二步得到的新X轴旋转,记作旋转矩阵得到了最终的结果。
- 我们最终的变换矩阵则为(因为我们每次都是绕着上次旋转得到的新坐标系,遵从外左内右的规则,我们往右乘)
这里,我们就会发现,在第二步的时候我们注意到了一件非常重要的事情,我们最新的X轴和一开始的Z轴重合了。这有什么含义?这意味着,我们可以将3中的旋转,等同于一个“相对于最初的Z”轴的旋转,然而,我们1中的旋转,也是“相对于最初的Z”轴的旋转。
这在数学上意味着什么?按照外左内右的结论,如果我们把最后一次的旋转看作“相对于最初的Z”轴的旋转,这意味着我们好像可以把整个矩阵重写为这样,然后我们可以发现似乎前两个式子由于都在同一个坐标系下,他们是可以被合并为同一个旋转的,于是我们在3中的旋转,可以直接被1中的旋转“一块”完成了。这次旋转失去了他的意义。
当最后一次旋转失去了意义,这就意味着我们的公式是有死角的,下面给出一个具体的例子来说明,什么情况下我们会遇到这在问题。
在游戏中,相机的朝向也可以用一个相对于世界坐标系的三次旋转来表达。在一些古早的游戏中,当玩家在游戏中将视角完全抬起,直视天空()时,然后进行如下操作:
- 动作: 玩家试图轻轻地水平移动鼠标(想要控制角色“偏航”)。
- 现象: 游戏画面并没有平滑地水平旋转,而是发生极为剧烈的翻转、跳跃,甚至整个画面倒置。
这是因为,当时当时的许多游戏底层使用欧拉角来存储鏡头的姿态,并通过插值来实现平滑的视角转换。当玩家在直视天空时输入了一个微小的水平移动 。由于角度处于 这个奇异点时,雅可比矩阵的秩亏损(这时我们几乎相当于少了一个自由度,算出的分母非常小以至于近乎为0),如果不再代码中专门处理好这种情况。微小的 输入,在求解公式中就会导致分母为0时算出的解非常巨大。结果就是,在游戏引擎在短短的一帧内,计算出了角度的剧烈跳变。反映在画面上,就是镜头在这一帧内疯狂旋转了近 180 度,造成了“视角错乱”。
6.2.2 四元数定义
为了彻底解决欧拉角带来的万象锁与计算奇异性,哈密顿(Hamilton)提出了著名的四元数(Quaternion)。要理解四元数,我们必须先跳出‘分步拆解’的思维,认识旋转的另一种形式:轴角表示法。
前面我们一直在尝试分解旋转,实际上,我们也可以找出一个特殊的旋转向量,把旋转描述为:“绕着这个旋转向量旋转一个角度”。
这是什么意思呢?想象你在空间中插了一根柱子(旋转轴 ),然后让物体绕着这根柱子拧动一个角度 。这个描述只需要一个单位向量和一个角度,完全不涉及“先后顺序”,因此从定义上就消灭了万象锁。然而,这个方法的缺点是数学处理非常繁琐,甚至还专门产生了著名的罗德里格旋转公式(感兴趣的读者可以去看一下)。因此,有什么更好的方法,既能通过这样的旋转来避开万象锁的问题,又能便于计算机和人来计算吗?答案就是,四元数。
对于四元数,我们不再详细的复杂证明,而是直接给出结论。
在数学上,四元数可以看作是复数的扩充。一个四元数 由一个实部和三个虚部组成: 其中 是实数,而 是三个相互正交的虚数单位,满足哈密顿著名的公式: 为了方便工程计算,我们常将其写成标量-向量的形式: 四元数与旋转向量和旋转角有非常直接的对应关系,要描述“绕单位向量 旋转 角度”这一动作,对应的单位四元数定义为: 即:
注意到这里是,好像给了我们一种“只转了一半的感觉”。对于开始位置,即不旋转的时候,我们有
6.2.3 四元数的运算
四元数有一些定义的运算,这些常见的运算如下
1. 加法与减法
这是最直观的运算,对应分量直接相加减。
2. 四元数乘法(格拉斯曼积)
两个四元数 的乘积并不是分量相乘,而是遵循类似多项式展开的规则:
这里有一点极其重要:
- 不可交换性:由于公式中包含向量叉乘(),所以 。这与旋转矩阵乘法的性质完全一致。
3. 共轭与求逆
- 共轭:。即实部不变,虚部取反。
- 模长:。
- 逆:。
在描述旋转时,我们只使用单位四元数(模长为 1)。此时 。这对应了旋转矩阵中“逆等于转置”的特点。
6.2.4 四元数与旋转
1. 从旋转中构造四元数
要描述“绕单位轴 旋转 角度”,四元数 的构造公式是: 其中, 记录了旋转角度的信息,而虚部向量 则记录了旋转轴的方向及其缩放后的分量。用于旋转的四元数必须是单位四元数,即 。这能保证旋转不会改变物体的缩放比例。
要构造单位四元数,只需要保证n是一个单位旋转轴即可:
在实际的物理引擎或游戏引擎中,经过成千上万次的四元数乘法运算后,由于浮点数的精度误差,四元数的模长可能会略微偏离 1。如果 ,物体会随着旋转不断变大;反之则会萎缩。因此,引擎通常会在每一帧对四元数进行归一化处理,确保旋转的“纯净性”:
2. 利用四元数计算坐标
给定一个空间点(或向量),使用四元数 对其进行旋转的计算公式为: 其中 是 的共轭(对于单位四元数,共轭即为其逆 )。这个公式将三维点投影到四维空间进行旋转运算,最后再投影回三维空间。它在数学上完全等价于 ,但计算更平滑且抗干扰。
前面说到, 给人一种“只转了一半”的感觉。而在这里,我们可以发现另一半去哪儿了:四元数 与其共轭 各自贡献了一次旋转效果,从而在两次“合成”后得到正确的角度。
3. 旋转的复合
对于连续的旋转,四元数同样遵循我们总结的“外左内右”的规则,即:
假设先进行旋转 ,再进行旋转 。
- 外生变换(绕固定世界轴):总旋转 。
- 内生变换(绕自身局部轴):总旋转 。
四元数乘法本质上是在执行旋转轴的复合。由于不涉及中间轴的 90° 锁死,无论 或 是什么角度,乘法结果永远是一个有效的、无畸变的旋转姿态。
如果想把一个旋转“撤销”回来,只需要把四元数的虚部(旋转轴)取反即可。这对应了旋转矩阵的转置 。
与欧拉角对三个角度分别进行线性插值不同,四元数使用 球面线性插值(Slerp)。它能保证物体沿着四维超球面上最短的弧线进行平滑转动。无论起始姿态和目标姿态相差多大,球面线性插值都能保证旋转过程是匀速的。因为不涉及角度分解,即便经过 这样的位置,插值过程依然极其丝滑。
6.3 Transform示例
Bevy中默认的数学运算库叫做glam,Bevy将其重新导出为了bevy_math这个crate。基本上来说,这个crate中包含了一些基本的数学类型,例如我们上面讲过的向量、矩阵、四元数、还有一些额外的辅助函数和数学曲线生成等,其命名方式非常直观,感兴趣的读者可以查看相关的文档即可,这里我们不再赘述,只是简单的列出一些常用的类型。
| 含义 | 类型 |
|---|---|
| 二维向量 | Vec2 |
| 三维向量 | Vec3 |
| 4x4矩阵 | Mat4 |
| 四元数 | Quat |
让我们来看看,Transfrom里到底有什么。翻开源代码,可以看到一个Transform包含了三个部分:平移、旋转、缩放。而且旋转是使用四元数来表达的。
Important
如果实体有父实体,那么这些变换是相对于父实体的位置而言的。(详情见第2章中的Relationship)
如果实体没有父实体,那么是相对于世界坐标原点而言的
#![allow(unused)]
fn main() {
pub struct Transform {
pub translation: Vec3,
pub rotation: Quat,
pub scale: Vec3,
}
}
那么?我们怎么知道实体在世界坐标系中的坐标呢?bevy为我们提供了一个GlobalTransform组件,当你在实体上插入Transfrom时,这个组件也会被同时插入。不过一般而言,你不能直接修改实体的GlobalTransform,这是由bevy维护的一个内部的坐标状态,通过更改组件的Transfrom,bevy会自动更新GlobalTransform。
终于说完了这些枯燥的理论知识。还是让我们来看看我们的代码到底应该是怎么工作的吧!这里我们以bevy的最复杂的Transfrom例子为例来讲解,至于其他的平移和缩放,相信聪明的你能从其他简单的bevy官方仓库的例子里一下子就看懂。
这个比较复杂的例子的名字叫做align,运行cargo run --example align,可以发现例子做了一件很简单的事情:当我们按下R随机生成一个旋转轴,按下T时将飞船对齐到旋转轴。

让我们来看看代码都写了些什么?首先,例子里定义了这些组件,这些组件都非常的简单,只有一些需要额外的特殊说明。
#![allow(unused)]
fn main() {
// 这是我们的飞船上的一些组件
#[derive(Component, Default)]
struct Ship {
/// 用以存储按下T时应该旋转到的角度
target_transform: Transform,
/// 一个用来标识飞船当前是否正在旋转的变量,如果飞船还没旋转完成,那么要阻止后续的操作
in_motion: bool,
}
// Dir3只是一个简单的单位向量,用来代表方向,就是一个归一化的Vec3
// 这对应了图中的两个白色轴
#[derive(Component)]
struct RandomAxes(Dir3, Dir3);
// 一个简单的标识符,用来标识上面的那组文字
#[derive(Component)]
struct Instructions;
// 当前鼠标是否按下,我们需要这个状态因为我们需要按下之后才能拖动视角
#[derive(Resource)]
struct MousePressed(bool);
// 一个随机数生成器而已,不太需要关心
#[derive(Resource)]
struct SeededRng(ChaCha8Rng);
}
第二步,让我们来看看 setup逻辑里都做了一些什么事?
Important
注意!bevy中的相机,其局部坐标系**-Z轴是视野正前方,向上是Y轴,X和YZ组成右手直角坐标系**。
世界坐标系中,向上是Y轴,从屏幕内只指向屏幕前的你是Z轴,X和YZ组成右手直角坐标系
#![allow(unused)]
fn main() {
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
asset_server: Res<AssetServer>,
) {
// 创建了一个随机数生成器
let mut seeded_rng = ChaCha8Rng::seed_from_u64(19878367467712);
// 创建了我们的相机
// 重点来了!这里终于见到了我们的Transform,前面我们说的相机本身就是一个带有Transfrom的实体
// 其中视角的朝向就又旋转矩阵所决定,这里的意思是:请给我一个这样的Transform
// 这个Transform的位置在坐标(3,2.5,4)处,并且他的旋转四元数刚好让视线(z轴)朝着坐标系原点
// 并且,请保证我的相机自己的局部Y轴和世界坐标系的Y轴“一致”。
commands.spawn((
Camera3d::default(),
Transform::from_xyz(3., 2.5, 4.).looking_at(Vec3::ZERO, Vec3::Y),
));
// 没什么用的东西
commands.spawn((
Mesh3d(meshes.add(Plane3d::default().mesh().size(100.0, 100.0))),
MeshMaterial3d(materials.add(Color::srgb(0.3, 0.5, 0.3))),
Transform::from_xyz(0., -2., 0.),
));
// 一盏灯光,位置在(4.0, 7.0, -4.0)
commands.spawn((
PointLight {
shadow_maps_enabled: true,
..default()
},
Transform::from_xyz(4.0, 7.0, -4.0),
));
// 随机生成两个轴
let first = seeded_rng.random();
let second = seeded_rng.random();
commands.spawn(RandomAxes(first, second));
// 加载我glft模型(还记得怎么加载模型吗?这是一个实体树)
// 然后让初始化时让飞船的目标角度对准上面两个轴(这里的关键是,什么才算对准?稍后我们揭晓)
commands.spawn((
SceneRoot(
asset_server
.load(GltfAssetLabel::Scene(0).from_asset("models/ship/craft_speederD.gltf")),
),
Ship {
target_transform: random_axes_target_alignment(&RandomAxes(first, second)),
..default()
},
));
// 显示文字
commands.spawn((
Text::new(
"The bright red axis is the primary alignment axis, and it will always be\n\
made to coincide with the primary target direction (white) exactly.\n\
The fainter red axis is the secondary alignment axis, and it is made to\n\
line up with the secondary target direction (gray) as closely as possible.\n\
Press 'R' to generate random target directions.\n\
Press 'T' to align the ship to those directions.\n\
Click and drag the mouse to rotate the camera.\n\
Press 'H' to hide/show these instructions.",
),
Node {
position_type: PositionType::Absolute,
top: px(12),
left: px(12),
..default()
},
Instructions,
));
commands.insert_resource(MousePressed(false));
commands.insert_resource(SeededRng(seeded_rng));
}
}
好吧,上面的代码其实看起来简单,但是蕴含的信息其实很多。让我们首先来看看这一行 Transform::from_xyz(3., 2.5, 4.).looking_at(Vec3::ZERO, Vec3::Y)。这行代码非常有意思,后面的looking_at(Vec3::ZERO, Vec3::Y)的第二个参数是做什么的?
想象一下。当你的把视线中心对准这一行文字,你是不能确定自己的位置的。为什么?因为你可以始终对准这个点,然后以视线轴为中心,左右旋转自己的头。为了确定到底盯着原点看的时候头顶应该朝哪儿,因此我们才需要一个参考轴,这就是世界坐标系的Y轴(朝正上方)。
因此这段代码的后半段的意思是:我要看着这个点,而且我的头顶要朝着天。有意思的是,如果你把Vec3::Y改成-Vue3::Y,你会发现你得把屏幕倒过来看画面才正常。
在让我们看看random_axes_target_alignment,这个函数到底做了什么?
#![allow(unused)]
fn main() {
fn random_axes_target_alignment(random_axes: &RandomAxes) -> Transform {
let RandomAxes(first, second) = random_axes;
Transform::IDENTITY.aligned_by(Vec3::NEG_Z, *first, Vec3::X, *second)
}
}
这个函数一共其实就两行(其实有用的就一行)。不过这一行代码,也有很丰富的信息量。首先Transform::IDENTITY构造了一个单位变换。什么是单位变换?其实等同于坐标为(0,0,0),旋转为单位四元数(不旋转),缩放为1的一个Transform。然后,调用了aligned_by使其**“对齐”**随机生成的两个轴first和second。
如果你观察力足够强,你肯定能发现很多问题。由于我们前两个轴是随机生成的,他们可不一定是正交的。因此我们要旋转的话是根本不可能完整对齐的。所以,这里才需要四个参数,而且这四个参数的名称很有意思。分别是主轴、主轴方向、副轴、副轴方向。
#![allow(unused)]
fn main() {
pub fn aligned_by(
mut self,
main_axis: impl TryInto<Dir3>,
main_direction: impl TryInto<Dir3>,
secondary_axis: impl TryInto<Dir3>,
secondary_direction: impl TryInto<Dir3>,
) -> Self {
self.align(
main_axis,
main_direction,
secondary_axis,
secondary_direction,
);
self
}
}
这里的对齐,和我们上面讲的looking_at其实如出一辙。上面的代码翻译成人话,可以这样来理解:请给我这样一个四元数,让我旋转后我的正前方(Vec3::NEG_Z)朝着first,同时,我希望我的右侧(Vec3::X)尽可能的和second重合。如果实在重合不了,请给我找一个最小的差。
至于其余的代码,相信聪明的你一眼就能看明白了,这些代码不过是每当我们按下R时,重新生成一对轴然后保存。按下T时解开锁。
#![allow(unused)]
fn main() {
if keyboard.just_pressed(KeyCode::KeyR) {
// Randomize the target axes
let first = seeded_rng.0.random();
let second = seeded_rng.0.random();
**random_axes = RandomAxes(first, second);
// Stop the ship and set it up to transform from its present orientation to the new one
ship.in_motion = false;
ship.target_transform = random_axes_target_alignment(&random_axes);
}
if keyboard.just_pressed(KeyCode::KeyT) {
ship.in_motion ^= true;
}
}
然后,利用几个简单的api,就可以控制飞船的Transform旋转到我们生成的角度。
#![allow(unused)]
fn main() {
fn rotate_ship(ship: Single<(&mut Ship, &mut Transform)>, time: Res<Time>) {
let (mut ship, mut ship_transform) = ship.into_inner();
if !ship.in_motion {
return;
}
let target_rotation = ship.target_transform.rotation;
ship_transform
.rotation
.smooth_nudge(&target_rotation, 3.0, time.delta_secs());
if ship_transform.rotation.angle_between(target_rotation) <= f32::EPSILON {
ship.in_motion = false;
}
}
}
至于剩下的示例,相信你现在已经有足够的水平去看得懂了,这里就不再赘述。
第 7 章:成像原理与相机
7.1 针孔相机模型
要介绍相机的成像原理,必须先讲清楚相机的成像模型。根据第六章中的内容,我们已经知道了在Bevy中坐标系的组成和变换公式,现在让我们做如下推理来看看相机到底是怎么成像的。
假设我们的世界坐标是,,,根据我们前面的知识,由屏幕内指向我们,指向正上方,与前二者组成右手坐标系(即朝右侧),如下图所示。
假设我们的相机,在世界坐标系中的姿态(以方向余弦阵表示)和坐标表示为:和。根据我们前面的知识,对于空间中的一个在世界坐标系下的点,在相机坐标系中的坐标为,那么我们有:
根据相似三角形原理,通过透镜成像的几何关系,我们可以推导相机投影模型。
假设相机光心位于坐标原点,光轴与 轴重合。一个位于相机前方距离为 的点 ,经过焦距为 的透镜,在成像平面上形成点 。
根据几何相似关系,我们将成像平面放在光心前方(焦距 处),此时推导出的投影关系为:
这里的负号揭示了针孔相机的特性之一:倒立成像。根据几何光学的相似三角形,物体在成像平面上的投影相对于原物体是上下左右颠倒的。为了在数值处理上更符合直觉,我们通常会对投影结果再取一次负号(或者将成像平面定义在光心前方),从而将模型修正为正立的投影: 观察上面的式子,由于右侧存在除以 的操作,这在齐次坐标的线性矩阵乘法中是无法直接表示的。为了将其转化为矩阵运算,我们将方程两侧同时乘以 :
现在,我们将这三个线性方程组合起来,就可以构建成一个矩阵乘法运算: 最后,我们将最开始的世界坐标系到相机坐标系的变换关系带入其中,并添加一个维度,可以得到世界坐标到成像平面的直接变换:
上述公式完整描述了 3D 世界点到 2D 成像平面的投影过程,其中包含两个核心矩阵和一个深度因子:
-
(内参矩阵):
这是一个 的矩阵(实际上这个矩阵里还应该有两个 代表uv变换的参数,不过这个变换在图形学中用的极少,一般只用在计算机视觉中),它负责将 3D 相机坐标系下的点,按照焦距 投影到 2D 平面上。
-
(外参矩阵):
这是一个 的矩阵,代表了相机在世界坐标系中的位姿。它通过旋转矩阵 和平移向量 ,将任何世界坐标系下的点转换到相机坐标系中。这是一个典型的World-To-Local变换。
-
(深度/归一化因子):
这是公式中最特殊的部分。观察矩阵乘法的结果,你会发现左侧是一个三维向量,其中最后一个分量正好是 。在齐次坐标体系中,该分量代表了点在相机坐标系下的垂直深度。
这也是为什么我们需要在矩阵运算后执行**“透视除法”**:因为矩阵运算的结果左侧不是直接的 ,而是 。要得到最终的成像坐标 ,我们必须将前两个分量除以第三个分量 。
记得我们之前说过,Bevy中相机的-z轴是视野正前方,现在你能解释为什么了吗?
Note
观察第一幅图,如果你想要观察成像平面,你应该怎么看?这时候朝什么方向?
还是让我们来简单的再讲一下uv变换吧(不然总感觉不够完整)。
前面我们得到了成像平面内的坐标,但是在最后,我们还需要将成像平面的坐标 映射到图像的 像素坐标 。
在成像平面上,假设我们的成像平面坐标系的原点位于,通常这个点被称为像主点,而像素坐标系的原点位于图像的左上角这之间很明显差了一个平移和翻转。此外,还需要考虑物理单位与像素之间的缩放比例()。
实际上在大多数的计算机视觉领域中,由于一开始采用的y并不是竖直朝上,而是竖直朝下的右手坐标系,因此不存在这个翻转y轴的问题。
这里我们也以不需要翻转y轴的情况为例。设像素坐标为 ,则uv坐标系与成像平面坐标系的坐标关系为:
其中 和 是将物理长度转换为像素单位的尺度因子(包含焦距 的影响)。为了简化,常将 和 合并入内参矩阵。最后得到我们的完整的内参矩阵K与投影公式。 通常情况下,在计算机视觉中,我们才需要通过标定精确测出内参矩阵 以描述真实的物理相机。而在图形学引擎中,我们往往直接通过投影矩阵来定义视锥体。这是因为,图形学引擎隐含地将像主点 预设为图像中心,并将坐标范围通过 NDC(归一化设备坐标)统一映射到了 之间,从而避免了手动处理像素坐标 。
7.2 Camera2d
讲解完了枯燥的数学知识,现在来看看Bevy中的相机是如何工作的吧。在Bevy中,有2d和3d两种基本相机类型。他们的具体代码在bevy_camera这个crate中。这个crate以一个Plugin的形式注册了相机相关的插件。
#![allow(unused)]
fn main() {
#[derive(Default)]
pub struct CameraPlugin;
impl Plugin for CameraPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ClearColor>().add_plugins((
CameraProjectionPlugin,
visibility::VisibilityPlugin,
visibility::VisibilityRangePlugin,
));
}
}
}
先让我们来看看bevy中的2d相机是如何工作的吧。找到该crate中的Camera2d定义,可以发现如下定义:
#![allow(unused)]
fn main() {
/// A 2D camera component. Enables the 2D render graph for a [`Camera`].
#[derive(Component, Default, Reflect, Clone)]
#[reflect(Component, Default, Clone)]
#[require(
Camera,
Projection::Orthographic(OrthographicProjection::default_2d()),
Frustum = OrthographicProjection::default_2d().compute_frustum(&GlobalTransform::from(Transform::default())),
)]
pub struct Camera2d;
}
看起来Camera2d只是一个空壳子而已,该组件的全部功能都由require中的component来实现。(还记得第2章中的require吗?)
观察这些component,可以发现其总共由三个部分组成:Camera(相机)、Projection(投影方式)、Frustum(视锥)。这些组成了一个2d相机的基本要素。
7.2.1 Camera
Camera组件是一个相机的基本组成部分之一,该component的定义如下:
#![allow(unused)]
fn main() {
#[derive(Component, Debug, Reflect, Clone)]
#[reflect(Component, Default, Debug, Clone)]
#[require(
Frustum,
CameraMainTextureUsages,
VisibleEntities,
Transform,
Visibility,
RenderTarget
)]
pub struct Camera {
// 定义在渲染目标的哪个矩形区域内绘图,决定了“归一化设备坐标”到“屏幕空间像素坐标(uv)”的最终变换。
// 这个参数可以实现分屏游戏、左下角的小地图、或者后视镜效果。如果不设置,默认撑满整个渲染目标。
pub viewport: Option<Viewport>,
// 决定多个相机之间的渲染先后。order 越大,越晚渲染。
// 例如UI 相机的 order 通常比 3D 场景相机高,确保 UI 始终盖在场景上方
pub order: isize,
// 如果为 false,渲染世界在Extract阶段就会忽略这个相机,从而节省所有的 CPU 剔除和 GPU 渲染开销
pub is_active: bool,
// 一个只读/自动更新的字段,存储了相机的最终数学状态,根据Projection来计算4x4的投影矩阵
pub computed: ComputedCameraValues,
// 决定渲染结果如何处理
pub output_mode: CameraOutputMode,
// 控制多重采样抗锯齿(MSAA)的数据同步
pub msaa_writeback: MsaaWriteback,
// 在相机开始画第一笔之前,要把画布刷成什么样。如果设置为None意味着你会直接在“上一个相机”画好的结果上继续画
pub clear_color: ClearColorConfig,
// 把背面剔除变成正面剔除,制作平面反射镜或水面反射
pub invert_culling: bool,
// 用于超大分辨率渲染或多屏拼接,允许你定义当前相机只负责完整投影矩阵中的“一小块”
pub sub_camera_view: Option<SubCameraView>,
}
}
这里面最值得一提的是require中的RenderTarget组件,这个组件决定了该相机渲染出的图像应该如何显示,他是一个简单的枚举。前两者不言而喻,根据窗口或者一个图像的句柄,渲染到窗口或者一个图像中。
值得一提的是后两个,TextureView允许相机渲染到一个由外部创建或手动管理的纹理视图上,这个选项是因为有些特殊场景(如 OpenXR 或与外部图形 API 交互时),纹理是由外部系统分配的,Bevy 只需要一个句柄来向其写入数据。
None { size: UVec2 }表示相机不渲染任何颜色信息,但它依然具有物理尺寸,这个选项可以用来在Prepass阶段生成深度图、法线图等。
#![allow(unused)]
fn main() {
#[derive(Component, Debug, Clone, Reflect, From)]
#[reflect(Clone, Component)]
pub enum RenderTarget {
Window(WindowRef),
Image(ImageRenderTarget),
TextureView(ManualTextureViewHandle),
None {
size: UVec2,
},
}
}
等等!为什么ComputedCameraValues是4x4的矩阵?而我们前面推导出来的K,是一个3x3或者3x4的矩阵呢?
回顾我们之前的投影公式,我们直接一步到位把一个世界坐标系里的坐标转换成了归一化设备坐标系里了。这很方便也很直白,但是在真正的GPU运算上稍微有点不一样。 不一样在哪儿呢?由于GPU被设计用来只能进行4x4矩阵的运算(因为T是4x4的,K如果也是4x4的就能省事很多),而且我们需要一种更方便方式保留Z轴的信息来进行深度测试(否则出现遮挡的时候我们将不能知道到底应该怎么画)。因此我们需要对这个公式进行一些改造。我们的新的投影不再是丢弃轴,而是将一个不规则的梯形视锥体挤压成一个标准单位立方体。
在 GPU 中,我们不再使用 ,而是使用 投影矩阵,记作 。它的形式通常如下: 因此我们的新公式可以写成:
这里有几个极其关键的数学技巧:
-
第一行的 :
我们的在x和y方向的焦距不再是相同的,根据一个长宽比来计算。通过这种方式我们可以控制相机的投影长宽比,而不再只能是一个简单的正方形。注意到,同时我们也去除了uv变换参数 ,正如之前所说,这一步在图形学里往往由底层自动完成。
-
第四行的 :
用这个矩阵乘以相机坐标系下的点 时,结果向量的 分量会变成 。还记得公式左边的那个 吗?现在它没有消失,它被藏进了齐次坐标的 里。
-
第三行的 :
这是 CV 与 CG 的最大区别。CV 只需要知道 ,但 GPU 必须知道这个像素点到底有多深。 和 是常数,它们负责将 映射到 范围(在 Bevy 中通常是 1 到 0 的反转深度)。
在图形学中,为了节省计算资源,我们需要一个视锥体。这个由 (近平面)和 (远平面)定义的梯形空间,必须通过 和 映射到这个范围。只有当坐标在这个由近平面+远平面+长宽比组成的是视椎内,才会被相机渲染并显示。
一般情况下,深度的范围是 。这两个常数的推导结果通常如下。当点在近平面时(),计算出的 恰好为 ;当点在远平面时,结果为 。 在进行完这一步后,硬件电路将会自动执行剩下的归一化过程。利用我们保留的,我们可以将整个坐标都映射到一个立方体空间里。这一步往往被称为透视除法。此时,所有的坐标都被塞进了一个坐标范围在 ( 和 )以及 ()的立方体中。这个立方体空间就是 NDC(归一化设备坐标系)。 有了 ,GPU 可以在不进行任何颜色计算前,先对比当前像素的深度。如果新来的像素比缓存里的更远,直接丢弃就能避免大量的无效计算。
7.2.2 Projection
Camera组件决定了相机的通用配置,但是并没有说明相机的投影到底是采用的何种方式。这个配置使用何种方式来进行投影的选项被单独声明为一个组件,其定义如下:
#![allow(unused)]
fn main() {
#[derive(Component, Debug, Clone, Reflect, From)]
#[reflect(Component, Default, Debug, Clone)]
pub enum Projection {
Perspective(PerspectiveProjection),
Orthographic(OrthographicProjection),
Custom(CustomProjection),
}
}
可以看到前两个正是我们提到过的透视投影与正交投影。其定义如下:
#![allow(unused)]
fn main() {
pub struct PerspectiveProjection {
// fov是相机广角,一个弧度,用来计算fy = cot(fov/2)
pub fov: f32,
// fx = fy/aspect_ratio
pub aspect_ratio: f32,
pub near: f32,
pub far: f32,
pub near_clip_plane: Vec4,
}
pub struct OrthographicProjection {
pub near: f32,
pub far: f32,
// 投影矩阵中的平移分量,默认是 (0.5, 0.5),意味着相机的坐标 (0,0) 映射到屏幕中心。
// 如果你改为 (0,0),相机的坐标就会映射到屏幕左下角
pub viewport_origin: Vec2,
// 下面两个均用于指定世界单位与像素的线性比例
// ScalingMode 决定了基准。例如 WindowSize 模式下,1个单位可能对应1个像素
// scale 则是叠加在基准上的缩放
// 在 2D 游戏中,这就是缩放倍率
pub scaling_mode: ScalingMode,
pub scale: f32,
pub area: Rect,
}
}
在Camera2d中,默认的值为Projection::Orthographic(OrthographicProjection::default_2d()),因此对于2d相机,Bevy采用的是正交投影的形式。
从数学本质上而言,正交投影只是透视投影的一种特殊形式而已。假设你的正交相机视口宽度为 ,高度为 ,近平面为 ,远平面为 (在 Bevy 中这些由 scale 和 scaling_mode 算出),其矩阵形式如下:
7.2.3 Frustum
Frustum是一个非常特殊的组件,这个组件需要配合Aabb组件一起使用来完成视锥剔除功能。当将该组件与Camera组件一同使用时,Bevy会计算每个具有Aabb组件的实体与Frustum的交叉关系,所有未包含在视锥内的物体都将被剔除不会参加渲染。因此,最简单的渲染优化方案就是为你的实体添加一个Aabb组件。
Aabb的定义如下,可见该组件非常的简单,只有一个中心坐标与三个轴半长。二者共同组成了一个立方体。
#![allow(unused)]
fn main() {
pub struct Aabb {
pub center: Vec3A,
pub half_extents: Vec3A,
}
}
除了Aabb,Bevy还提供了Sphere,这是一个三维球体,也可用来进行视锥剔除,但优先级要比Aabb低。
#![allow(unused)]
fn main() {
pub struct Sphere {
pub center: Vec3A,
pub radius: f32,
}
}
7.2.4 渲染流程
前面我们详细讲解了一个Camera2d相机的各种配置,但是我们没有涉及任何如何利用这些配置将其绘制到窗口上的内容。那么这些流程在哪儿呢?bevy到底是在哪儿完成的视锥剔除和渲染呢?
好吧,这些所有的GPU渲染、视锥剔除,根本就不在bevy_camera这个crate里,这些相关的代码在bevy_render中。这一部分的内容涉及到详细的渲染管线处理,因此我们在目前暂不详细介绍。
总之,我们上面配置的这些每个component,是在bevy_render的system中进行了相关的查询和处理,并最终渲染到RenderTarget上的。你会看到类似下面这样的一些system,来对拥有上面那些组件的实体进行查询和处理。
#![allow(unused)]
fn main() {
pub fn camera_system(
mut window_resized_reader: MessageReader<WindowResized>,
mut window_created_reader: MessageReader<WindowCreated>,
mut window_scale_factor_changed_reader: MessageReader<WindowScaleFactorChanged>,
mut image_asset_event_reader: MessageReader<AssetEvent<Image>>,
primary_window: Query<Entity, With<PrimaryWindow>>,
windows: Query<(Entity, &Window)>,
images: Res<Assets<Image>>,
manual_texture_views: Res<ManualTextureViews>,
mut cameras: Query<(&mut Camera, &RenderTarget, &mut Projection)>,
) -> Result<(), BevyError> {
//....
}
}
7.3 Camera3d
找到Camerad3d的源代码,可以发现他和2d相机非常相似,唯一不同的就是多出了两个参数。
还记得我们前面一直所说的坐标系问题吗?源代码里的注释完美的印证了我们的结论。
“The camera coordinate space is right-handed X-right, Y-up, Z-back.
This means “forward” is -Z.”
#![allow(unused)]
fn main() {
/// A 3D camera component. Enables the main 3D render graph for a [`Camera`].
///
/// The camera coordinate space is right-handed X-right, Y-up, Z-back.
/// This means "forward" is -Z.
#[derive(Component, Reflect, Clone)]
#[reflect(Component, Default, Clone)]
#[require(Camera, Projection)]
pub struct Camera3d {
/// The depth clear operation to perform for the main 3d pass.
pub depth_load_op: Camera3dDepthLoadOp,
/// The texture usages for the depth texture created for the main 3d pass.
pub depth_texture_usages: Camera3dDepthTextureUsage,
}
}
这里再提一下我们在讲解require时候提到的,当我们只制定类型的时候,默认会使用default来实例化一个组件并插入到实体上。而默认情况下,就是Projection::Perspective(Default::default())。因此默认情况下,bevy采用的就是透视投影。
那么这两个参数是什么呢?这就是 3d 与 2d 的关键不同不同之处。
在 2D 中,Bevy 处理遮挡使用的是最经典的画家算法,就像画家画画一样,先画背景,再画中景,最后画前景,2d中不可能存在3d的遮挡情况,后面画的物体会直接覆盖前面画的物体,不需要去解决“这条视线上哪个点离相机更近”这种问题。这通常由物体的 Transform.translation.z 轴大小,或者排序索引来决定,是很简单的。
但当问题一转换到3d空间中,问题就变得非常棘手了,当多个 3d 空间中的点被投影到同一个像素位置,我们就必须知道这些点之间的遮挡关系。幸好我们在前面我们利用透视除法解决了这个问题,像我们之前讲的一样,我们在这个过程中保留了透视变换得到的 ,GPU 先对比当前像素的深度就可以决定到底渲染成什么样子。
这两个比camera2d多出来的字段,正是用于控制深度计算的额外配置。
Camera3dDepthLoadOp
depth_load_op是一个Camera3dDepthLoadOp类型的值,决定了在渲染新的一帧之前,是把深度缓冲区里的所有数值全部重置还是保留。
#![allow(unused)]
fn main() {
/// The depth clear operation to perform for the main 3d pass.
#[derive(Reflect, Serialize, Deserialize, Clone, Debug)]
#[reflect(Serialize, Deserialize, Clone, Default)]
pub enum Camera3dDepthLoadOp {
/// Clear with a specified value.
/// Note that 0.0 is the far plane due to bevy's use of reverse-z projections.
Clear(f32),
/// Load from memory.
Load,
}
}
为什么默认情况下清空的深度是 0.0 呢?按照一般情况下,当点在近平面时计算出的 应该恰好为 ;当点在远平面时,结果为 。如果把值设置为 0.0 ,那么以后的所有点岂不是都被遮住不显示了?一般情况下应该是这样的。但是由于bevy使用的是-Z轴作为视线防线,因此在这个过程里离得相机越远的物体,其反而Z坐标越小了,所以,这里的深度完全是倒过来的。
这也说明了这里为什么是 0.0 ,因为按照公式 ,当然越小的值计算之后越靠近 0 了。
于是我们就可以得到结论:如果把该值设置为 0.0 ,当世界里有物体前方存在遮挡的物体时,那么该物体就不应该被继续显示了,这是最符合我们生活常识的。
那么Load模式有什么用?不清空,直接保留并加载上一帧留下来的深度数据有什么用呢?这常被用在多相机系统上,或者当前一个渲染通道已经计算好了深度信息,下一个通道需要直接在这个深度基础上继续画东西(比如分层渲染、后处理特效等)的场景,这时候你就不需要重新擦除了。
Camera3dDepthTextureUsage
depth_texture_usages是一个Camera3dDepthTextureUsage类型的值,这个值的作用那就更高深一些了。其结构如下,只是一个简单的u32类型数字。
#![allow(unused)]
fn main() {
pub struct Camera3dDepthTextureUsage(pub u32);
}
但是我们在代码中给予的默认值是TextureUsages::RENDER_ATTACHMENT.into(),这是一种在C语言时候就经常使用的方法了,通过把一个字节给掰开变成几份bit来使用,来用位运算组合设置。我们来看看里面都有什么有意思的设置。
#![allow(unused)]
fn main() {
pub struct TextureUsages: u32 {
// 允许把这张纹理的数据读出来,复制到别的纹理或内存 buffer 里。
// 比如你要做游戏截图,就把当前画面的纹理当成 Source 复制出来保存。
const COPY_SRC = 1 << 0;
// 允许把外部的数据写入到这张纹理里。
// 比如你从硬盘加载了一张角色皮肤图片,通过 CPU 把数据塞进 GPU 纹理时,这张纹理必须有COPY_DST
const COPY_DST = 1 << 1;
/// 允许 Shader 去采样(Sample)这张纹理。也就是 Shader 只能读取它的颜色,不能修改它
const TEXTURE_BINDING = 1 << 2;
/// 允许一些特殊的 Shader(比如 Compute Shader 计算着色器)对这张纹理进行任意位置的随机读写
const STORAGE_BINDING = 1 << 3;
/// 只要你的相机要往这张贴图上画任何东西,这张贴图就必须拥有这个权限。
const RENDER_ATTACHMENT = 1 << 4;
// ....等等还有一些我们就不介绍了
}
}
这里的RENDER_ATTACHMENT正是这里的关键,告诉bevy_render,这个相机需要创建3d深度纹理,并且赋予写入权限。
RenderLayers
真正的游戏里,你会需要多个相机协同工作,比如一个3d相机渲染人物模型,一个3d主相机渲染世界,一个2d相机渲染屏幕上的各种UI。这时候你就必须控制这些东西之间的可见关系了。Bevy提供了一个RenderLayers组件。简单来说,它是用来决定“哪部相机能看到哪个物体”的筛选开关。
你可以给某个 3D 模型挂上组件 RenderLayers::layer(1),同时给 3D 相机也挂上 RenderLayers::layer(1)。当且仅当相机的图层和物体的图层有交集(intersects)时,这个相机才会去渲染这个物体。
默认情况下,所有没有显式指定图层的物体和相机,都属于 Layer 0。所以如果你不配置它,大家都在默认图层,互相都能看见。
Frustum去哪儿了?
如果你足够细心,你会发现,camera3d的require里好像少了点东西,让我们重新对比看看。
#![allow(unused)]
fn main() {
#[require(
Camera,
Projection::Orthographic(OrthographicProjection::default_2d()),
Frustum = OrthographicProjection::default_2d().compute_frustum(&GlobalTransform::from(Transform::default())),
)]
pub struct Camera2d;
#[require(Camera, Projection)]
pub struct Camera3d {
// ...
}
}
为什么Camera3d没有Frustum参数?他去哪里了?是3d相机不需要视锥剔除吗?当然不是。这是因为Camera已经包含了Frustum了。
#![allow(unused)]
fn main() {
#[require(
Frustum,
CameraMainTextureUsages,
VisibleEntities,
Transform,
Visibility,
RenderTarget
)]
pub struct Camera {
//...
}
}
还记得我们在之前讲的require是如何处理组件冲突的吗?require会按照深度遍历的方式,以第一个找到的该类型的组件为准,所以其实Camera3d早就通过Camera创建了默认的Frustum。
7.4 Hdr
这里顺便提一下Hdr,其全称叫做高动态范围技术。
很多人对Hdr的印象就是让视频饱和度变得超高的那个东西(其实我也总这么认为)。
Bevy支持Hdr,只需要Camera上挂一个Hdr组件,相机就会使用一个中间的“高动态范围(HDR)”渲染贴图。这允许游戏使用更广泛的光照数值进行渲染。但是,这并不会影响相机是否会向你的显示器输出真正硬件级别的 HDR 信号(Bevy 目前还不支持直接输出 HDR 画面给显示器),它仅仅影响渲染过程中的那张中间贴图。
开启之后,显卡会使用精度和范围更大的类型来进行绘图,如果你做的是有炫酷魔法特效、3D 火焰爆破,就要在相机上加上 Hdr,并将 CompositingSpace 设为 Linear 或 Oklab,这样半透明的特效叠加起来才会既通透又高级。
#![allow(unused)]
fn main() {
#[derive(Component, Default, Copy, Clone, Reflect, PartialEq, Eq, Hash, Debug)]
#[reflect(Component, Default, PartialEq, Hash, Debug)]
pub struct Hdr;
/// Color space for alpha compositing. Affects how overlapping semi-transparent layers blend.
#[derive(Component, Copy, Clone, Reflect, PartialEq, Eq, Hash, Debug, Default)]
#[reflect(Component, PartialEq, Hash, Debug, Default)]
pub enum CompositingSpace {
// 直接拿屏幕上看到的非线性颜色数值进行加减乘除
#[default]
Srgb,
// 需要Hdr, 显卡会先把颜色还原成真实世界里物理光子的数量(线性光),然后再进行混合,最后再转回屏幕颜色。
// 半透明的粒子、火焰叠加在一起时,会像现实中的光线叠加一样自然。
Linear,
// 需要Hdr, 知觉均匀混合, 在Oklab色彩空间里混合
Oklab,
}
}
7.5 CameraController
看起来Bevy只为我们提供了最基础的相机显示功能,那我们该怎么控制相机呢?
当然,可以直接如同操纵一个实体一样去操控相机的位置和角度,但是,非必要千万别这么做。
Rust程序员应该有一条准则:Do Not Repeat Yourself。
面对相机这样高度固定化的配置,Bevy有很多开箱即用的crate,甚至Bevy自己也内置了两个通用的可控制的相机,分别为我们处理3D与2D下的各种通用功能。他们就是FreeCamera与PanCamera。
这个功能在bevy_camera_controller这个crate里,源代码也非常简单,就是添加了一些系统和组件,来实现我们说的那些基本的对相机实体操控功能,这里就不再赘述了。
7.5.1 PanCamera
PanCamera是一个支持平移、缩放和旋转的 2D 摄像机控制器,包含了基础的平移、旋转、缩放等功能。
要使用这个相机,需要在配置里启用features。
[dependencies]
bevy = { version = "0.X", features = ["pan_camera"] }
Bevy还提供了一套默认的控制方法,这还需要安装插件,注意到这个插件不在DefaultPlugins里。
use bevy::camera_controller::pan_camera::{PanCamera, PanCameraPlugin};
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
// 使用Bevy提供的默认的操控方式
.add_plugins(PanCameraPlugin)
.add_systems(Startup, (setup, spawn_text).chain())
.run();
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
// 把PanCamera安装到和Camera2d相同的组件上
commands.spawn((Camera2d, PanCamera::default()));
commands.spawn(Sprite::from_image(
asset_server.load("branding/bevy_bird_dark.png"),
));
}
一旦启用,PanCamera会自动安装到程序里,要操控只需要直接查询并使用即可。这里我们与其讲解怎么使用,不如直接来看看源代码,其实很简单。PanCamera就是带了一堆设置的一个普通的组件而已。
#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct PanCamera {
// 以下这些控制字段不言而喻
pub enabled: bool,
pub zoom_factor: f32,
pub min_zoom: f32,
pub max_zoom: f32,
pub zoom_speed: f32,
pub key_zoom_in: Option<KeyCode>,
pub key_zoom_out: Option<KeyCode>,
pub pan_speed: f32,
pub key_up: Option<KeyCode>,
pub key_down: Option<KeyCode>,
pub key_left: Option<KeyCode>,
pub key_right: Option<KeyCode>,
pub rotation_speed: f32,
pub key_rotate_ccw: Option<KeyCode>,
pub key_rotate_cw: Option<KeyCode>,
}
}
而那个插件,本质上只是安装了这样一个控制的System而已。通过实例化PanCamera时传入的KeyCode,你就可以将这些功能绑定到自己的按键上。
#![allow(unused)]
fn main() {
fn run_pancamera_controller(
time: Res<Time<Real>>,
key_input: Res<ButtonInput<KeyCode>>,
accumulated_mouse_scroll: Res<AccumulatedMouseScroll>,
mut query: Query<(&mut Transform, &mut PanCamera), With<Camera>>,
) {
// 一些无关代码...
// 控制移动
let mut movement = Vec2::ZERO;
if let Some(key) = controller.key_left {
if key_input.pressed(key) {
movement.x -= 1.0;
}
}
// 其他的还有各种方向...
if movement != Vec2::ZERO {
let right = transform.right();
let up = transform.up();
let delta = (right * movement.x + up * movement.y).normalize() * controller.pan_speed * dt;
transform.translation.x += delta.x;
transform.translation.y += delta.y;
}
// 控制旋转
if let Some(key) = controller.key_rotate_ccw {
if key_input.pressed(key) {
transform.rotate_z(controller.rotation_speed * dt);
}
}
if let Some(key) = controller.key_rotate_cw {
if key_input.pressed(key) {
transform.rotate_z(-controller.rotation_speed * dt);
}
}
// 控制缩放
let mut zoom_amount = 0.0;
// (with keys)
if let Some(key) = controller.key_zoom_in {
if key_input.pressed(key) {
zoom_amount -= controller.zoom_speed;
}
}
if let Some(key) = controller.key_zoom_out {
if key_input.pressed(key) {
zoom_amount += controller.zoom_speed;
}
}
// 其他还有鼠标滚轮操作等等等等...
}
}
7.5.2 FreeCamera
FreeCamera是一个允许用户在场景中自由移动的3d相机控制器。它实现的是一个3d自由视角相机,类似于《我的世界》的创造模式或者场景编辑器,按 W S A D 前后左右飞,按键盘或鼠标控制视线,镜头可以在 3d 空间里任意穿梭。
其代码里的实现本质上和使用方法上其实也都和PanCamera差不多,也需要启动features和插件。但是要比PanCamera复杂很多。
#![allow(unused)]
fn main() {
#[derive(Component)]
#[require(FreeCameraState)]
pub struct FreeCamera {
// 游戏里也叫鼠标灵敏度
pub sensitivity: f32,
// 前后左右上下控制
pub key_forward: KeyCode,
pub key_back: KeyCode,
pub key_left: KeyCode,
pub key_right: KeyCode,
pub key_up: KeyCode,
pub key_down: KeyCode,
// 切换加速跑
pub key_run: KeyCode,
// 按住这个鼠标按键时,隐藏鼠标,镜头跟随鼠标直接旋转
pub mouse_key_cursor_grab: MouseButton,
// 上面那个要一直按着鼠标,这个是直接按键切换
pub keyboard_key_toggle_cursor_grab: KeyCode,
// 基础行走速度
pub walk_speed: f32,
// 加速跑速度
pub run_speed: f32,
// 这是很好玩的东西,当你滚动鼠标滚轮时,它改变的不是当前的焦距,而是相机的移动速度
// 只需要向上滚动几下轮滑,相机的基础速度就会指数级
pub scroll_factor: f32,
// 摩擦力,停止按键时停下需要的时间与摩擦力有关,越大停的越快,越小越像脚下踩了肥皂
pub friction: f32,
}
}
具体的其他我们就不再赘述了,对更多相机的细节感兴趣的读者可以查看Bevy下的examples/camera下的各种示例,特别是其中有一个很好玩的first_person_view_model,利用了相机和RenderLayers实现了类似于我的世界一样的第一人称视角相机。
第 8 章:3D 基础
本章节的主要内容在于对3D世界有一个一般的了解,由于3D部分过于复杂,大量的细节都将在后续章节中进行更为详细的讲解。
8.1 Light
“上帝说:要有光,就有了光。”我们的三维世界也需要有光,否则渲染出来的屏幕将会漆黑一片。有关灯光的介绍是一个大主题,甚至能够写好几本书,在这里我们只会简要的介绍一下几种常见的灯光,以便我们能够首先看到点什么,剩下的内容将会在下一章内更仔细的介绍。
8.1.1 PointLight
PointLight是一种从中心点开始向各个方向发光的光源,其结构体定义如下。我们现在主要关心的是color、intensity、range、radius这几个参数。他们分别设定了光源的颜色、强度、范围、光点半径。
#![allow(unused)]
fn main() {
pub struct PointLight {
pub color: Color,
pub intensity: f32,
pub range: f32,
pub radius: f32,
pub shadows_enabled: bool,
pub soft_shadows_enabled: bool,
pub affects_lightmapped_mesh_diffuse: bool,
pub shadow_depth_bias: f32,
pub shadow_normal_bias: f32,
pub shadow_map_near_z: f32,
}
}
8.1.2 SpotLight
SpotLight是一种从某个点朝着某个方向发射的光源,一般也叫聚光灯,形状是一个从源点沿着方向为轴线的锥形,其结构体定义如下。除了color、intensity、range、radius等参数,我们还需要关心inner_angle和outer_angle这两个参数,
#![allow(unused)]
fn main() {
pub struct SpotLight {
pub color: Color,
pub intensity: f32,
pub range: f32,
pub radius: f32,
pub shadows_enabled: bool,
pub soft_shadows_enabled: bool,
pub affects_lightmapped_mesh_diffuse: bool,
pub shadow_depth_bias: f32,
pub shadow_normal_bias: f32,
pub shadow_map_near_z: f32,
pub outer_angle: f32,
pub inner_angle: f32,
}
}
这两个参数是两个角度,范围应该在0~90度之间,且inner_angle应当小于outer_angle,这两个参数看起来就像下面这样。outer_angle指定了聚光灯的范围,而位于inner_angle和outer_angle之间的光强度将会逐渐减小来呈现一种边缘的光更弱的效果。

要使用SpotLight光指定SpotLight是不够的,我们还必须指定他的Position和Direction才行,但是定义中并没有提供我们相关的设置,我们要怎么办呢?当然,使用Transform即可。因此想要使用聚光灯,就要像下面这样,利用Transform组件来指定原点和方向。
#![allow(unused)]
fn main() {
commands.spawn((
SpotLight {
intensity: 100_000.0,
color: LIME.into(),
shadows_enabled: true,
inner_angle: 0.6,
outer_angle: 0.8,
..default()
},
Transform::from_xyz(-1.0, 2.0, 0.0).looking_at(Vec3::new(-1.0, 0.0, 0.0), Vec3::Z),
));
}
8.1.3 DirectionalLight
DirectionalLight意为平行光,是一种理想的现实里不存在的光源,我们的太阳光也可以视为这种光源。这种光源的光线不像SpotLight或者PointLight那样由一个点发出,而是由一个平面发出,就好像由一组光源列阵一样。
illuminance参数指定了照明的强度,但是这个单位与intensity不同。intensity以lumens(流明)为单位,而illuminance以lux(每平方米的流明)为单位。
#![allow(unused)]
fn main() {
pub struct DirectionalLight {
pub color: Color,
pub illuminance: f32,
pub shadows_enabled: bool,
pub soft_shadow_size: Option<f32>,
pub affects_lightmapped_mesh_diffuse: bool,
pub shadow_depth_bias: f32,
pub shadow_normal_bias: f32,
}
}
8.2 Mesh3d
同Mesh2d一样,Mesh3d是我们用来表示一个三维网格模型的组件,其用法与Mesh2d也相同:传入一个Mesh的Handler。因此我们可以像下面的代码一样,创建一些内置的Mesh并添加到Mesh资产中,然后使用Mesh3d和MeshMaterial3d来显示这些物体。
同时,别忘了指定灯光和相机,否则我们的屏幕上将会一片漆黑。
#![allow(unused)]
fn main() {
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
//创建一些基础的材质,不然无法显示我们的形状
let debug_material = materials.add(StandardMaterial {
base_color_texture: Some(images.add(uv_debug_texture())),
..default()
});
let shapes = [
meshes.add(Cuboid::default()),
meshes.add(Tetrahedron::default()),
meshes.add(Capsule3d::default()),
meshes.add(Torus::default()),
meshes.add(Cylinder::default()),
meshes.add(Cone::default()),
meshes.add(ConicalFrustum::default()),
meshes.add(Sphere::default().mesh().ico(5).unwrap()),
meshes.add(Sphere::default().mesh().uv(32, 18)),
meshes.add(Segment3d::default()),
meshes.add(Polyline3d::new(vec![
Vec3::new(-0.5, 0.0, 0.0),
Vec3::new(0.5, 0.0, 0.0),
Vec3::new(0.0, 0.5, 0.0),
])),
];
let num_shapes = shapes.len();
for (i, shape) in shapes.into_iter().enumerate() {
commands.spawn((
Mesh3d(shape),
MeshMaterial3d(debug_material.clone()),
Transform::from_xyz(
-SHAPES_X_EXTENT / 2. + i as f32 / (num_shapes - 1) as f32 * SHAPES_X_EXTENT,
2.0,
Z_EXTENT / 2.,
)
.with_rotation(Quat::from_rotation_x(-PI / 4.)),
Shape,
));
}
commands.spawn((
PointLight {
shadows_enabled: true,
intensity: 10_000_000.,
range: 100.0,
shadow_depth_bias: 0.2,
..default()
},
Transform::from_xyz(8.0, 16.0, 8.0),
));
commands.spawn((
Camera3d::default(),
Transform::from_xyz(0.0, 7., 14.0).looking_at(Vec3::new(0., 1., 0.), Vec3::Y),
));
}
}
8.2.1 加载glb/glTF
既然这一章讲得是3D,那么我们必须先好好讲明白如何把我们在blender中建模好的模型加载进bevy里并显示。bevy中的模型加载非常简单,但是也有些许的独特。
在bevy中, glb和glTF文件是一等公民,虽然bevy也支持加载obj或者其他格式的模型,但是bevy对于glb和glTF的支持是最好的。这其中涉及到很多复杂的原因,但是,如果你的模型不是glb格式的,你可以现在blender里将其重新导出为glb模型,或者你也可以使用bevy_obj等crate直接加载obj模型。
简单来说,GLB 是 GLTF 的二进制(Binary)版本,他们都通过bevy_gltf这个crate来进行加载。
glTF通常是一个 .gltf 文件(一般是JSON 格式),旁边带着一堆些.bin 文件(存储顶点、几何数据的二进制包)和一堆 .png/.jpg 图片。其存储方式是按照类别来分类存储的(如下方表格所示)。
glb只有一个单独的 .glb 文件。它把 JSON 说明书、二进制数据、贴图全部塞进了一个大包里,因为是全二进制存储,且省去了 Base64 编码的开销,通常比散装的 glTF 更小,而且加载更快,因此更推荐使用glb格式。
模型一般约定放在asset/models文件夹下。因此,我们可以这样来加载模型。那么,下面的SceneRoot和GltfAssetLabel又是什么呢?简单来说,SceneRoot只是一个特殊的组件,代表了我们在使用 blender建模时的场景根,GltfAssetLabel则代表了各种可能的glb规范里的类型。
#![allow(unused)]
fn main() {
fn spawn_gltf(mut commands: Commands, asset_server: Res<AssetServer>) {
let gltf_scene: Handle<Scene> = asset_server.load(GltfAssetLabel::Scene(0).from_asset("models/FlightHelmet/FlightHelmet.gltf"));
//或者,我们可以使用这种带后戳的格式化字符串
//let gltf_scene: Handle<Scene> = asset_server.load(format!("models/FlightHelmet/FlightHelmet.gltf#{}", GltfAssetLabel::Scene(0)));
commands.spawn((
SceneRoot(gltf_scene),
Transform::from_xyz(2.0, 0.0, -5.0),
));
}
}
查看定义,可以看到GltfAssetLabel是一个枚举类型,其定义如下。这个枚举 GltfAssetLabel 实际上是 Bevy 为 glb/gltf 文件定义的**“内部地址薄”**。
#![allow(unused)]
fn main() {
pub enum GltfAssetLabel {
Scene(usize),
Node(usize),
Mesh(usize),
Primitive {
mesh: usize,
primitive: usize,
},
MorphTarget {
mesh: usize,
primitive: usize,
},
Texture(usize),
Material {
index: usize,
is_scale_inverted: bool,
},
DefaultMaterial,
Animation(usize),
Skin(usize),
InverseBindMatrices(usize),
}
}
在这里,我们给出每一种类型在glTF规范中对应的部分,可以看到这个枚举里的每一个字段,都对应了glTF其中的一个标准。当我们加载后,我们便可以更改这些配置,来达到在游戏中变化的效果。
| 名称 | 对应 |
|---|---|
| Scene | GLTF 里的场景 ,一个 GLTF 可以包含多个场景(例如一个文件里存了“白天”和“黑夜”两个版本的布局)。通常我们用 Scene(0),它会包含模型的所有层级、灯光和初始位置还有所有的模型 |
| Node | 场景树的骨架。每个节点都有自己的 Transform(平移、旋转、缩放)。你可以通过索引单独加载某个节点 |
| Mesh | 对应网格,GLTF 里的 Mesh 并不直接包含顶点,它是一个容器 |
| Primitive | 一个 Mesh 可以由多个 Primitive 组成(例如一个角色的 Mesh,手是一个 Primitive,身体是另一个,因为它们可能使用不同的材质) |
| MorphTarget | 对应形态键,用于做面部表情或简单的形变动画(比如捏脸系统) |
| Texture | 对应贴图,和uv变换有关 |
| Material | 对应材质,就是7.3节中的材质 |
| DefaultMaterial | 如果 GLTF 里有的物体没赋材质,Bevy 会分配一个默认材质。 |
| Animation | 文件里的动画,如 Animation(0) 可能是“走”,Animation(1) 可能是“跑” |
| Skin | 对应骨架,存储了骨骼层级关系 |
| InverseBindMatrices | 对应逆绑定矩阵,简单说,它是为了让 GPU 知道当骨骼旋转时,皮肤应该怎么跟着动 |
为什么上面的枚举中的数据全都是usize类型?这是因为glTF 在底层确实几乎全部使用数组下标来引用资源,因此这也说明如果不把glTF放到blender里看看到底那个索引对应的是什么,那加载的时候就只能祈求上帝知道这是什么了。
在上面加载的时候,我们使用的是GltfAssetLabel::Scene(0)这意味着整个场景都被加载进来了。有趣的是,你也可以只使用GltfAssetLabel::Mesh(n)来加载一个 Mesh。但是这个Mesh指向的是 glTF 文件内部那个 Meshes 数组中的第 个元素。因此他到底是什么,除了在glTF中看一看,谁也不得而知。
8.2.2 glTF元数据
如果只有下标,那么谁也不知道我们加载出来的一个Scene下面的每个Mesh到底是什么了。因此必然存在一些元数据(描述数据的数据)来描述这些Mesh。这些元数据一般分为两类,一类是glTF自带元数据,一类是建模师在建模时插入的元数据。
对于前者,我们经常会用到的是GltfMeshName和GltfMaterialName。在Blender里这些就是Data 属性面板里的名字和 Material属性面板里的名字。顾名思义,这两个元数据组件会被 Bevy自动插入到读取的Mesh3d和MeshMaterial3d<StandardMaterial>对应的组件上,不需要你手动操作。

例如,一个Cube的glTF描述可能如下:
{
"asset":{
"generator":"Khronos glTF Blender I/O v5.1.18",
"version":"2.0"
},
"scene":0,
"scenes":[
{
"name":"Scene",
"nodes":[
0
]
}
],
"nodes":[
{
"mesh":0,
"name":"Cube",
"scale":[
0.33985769748687744,
0.33985769748687744,
0.33985769748687744
]
}
],
"materials":[
{
"doubleSided":true,
"name":"Material",
"pbrMetallicRoughness":{
"baseColorFactor":[
0.800000011920929,
0.800000011920929,
0.800000011920929,
1
],
"metallicFactor":0,
"roughnessFactor":0.5
}
}
],
"meshes":[
{
"name":"Cube_geometry",
"primitives":[
{
"attributes":{
"POSITION":0,
"NORMAL":1,
"TEXCOORD_0":2
},
"indices":3,
"material":0
}
]
}
],
//...
}
利用Qurey系统和自动插入的GltfMaterialName,你可以直接这样来查询。并且根据名字来判断到底是不是你需要的那个Mesh3d。
#![allow(unused)]
fn main() {
//在setup中
//commands.spawn(SceneRoot(asset_server.load(
// GltfAssetLabel::Scene(0).from_asset("models/GltfPrimitives/gltf_primitives.glb"),
//)));
fn find_top_material_and_mesh(
mut materials: ResMut<Assets<StandardMaterial>>,
mut meshes: ResMut<Assets<Mesh>>,
time: Res<Time>,
mat_query: Query<(
&MeshMaterial3d<StandardMaterial>,
&Mesh3d,
&GltfMaterialName,
)>,
) {
for (mat_handle, mesh_handle, name) in mat_query.iter() {
//利用name,我们可以找到正确的子实体
if name.0 == "Top" {
if let Some(material) = materials.get_mut(mat_handle) {
if let Color::Hsla(ref mut hsla) = material.base_color {
*hsla = hsla.rotate_hue(time.delta_secs() * 100.0);
} else {
material.base_color = Color::from(Hsla::hsl(0.0, 0.9, 0.7));
}
}
//利用mesh_handle,从meshes中我们可以获取相应的mesh
if let Some(mesh) = meshes.get_mut(mesh_handle)
&& let Some(VertexAttributeValues::Float32x3(positions)) =
mesh.attribute_mut(Mesh::ATTRIBUTE_POSITION)
{
for position in positions {
*position = (
position[0],
1.5 + 0.5 * ops::sin(time.elapsed_secs() / 2.0),
position[2],
)
.into();
}
}
}
}
}
}
对于后者,他们都是可选的。当你使用 SceneRoot 加载场景时,Bevy 会自动将以下 extras 挂载到生成的 Entity 上。这些extras基本是是一些用户自定义的元数据或者简单的名称等
| **对应的组件 ** | 说明 |
|---|---|
GltfExtras | 整个Glft的自定义属性 |
GltfSceneExtras | 场景级别的自定义数据 |
GltfMeshExtras | 网格自定义的属性 |
GltfMaterialExtras | 材质的自定义属性 |
在blender里,你可以这样去插入这些extras,比如材质的自定义属性如下。

在使用这自定义属性的时候,要特别注意应当用Option来进行Query,因为这些数据很有可能并不存在。
#![allow(unused)]
fn main() {
fn check_for_gltf_extras(
gltf_extras_per_entity: Query<(
Entity,
Option<&Name>,
Option<&GltfSceneExtras>,
Option<&GltfExtras>,
Option<&GltfMeshExtras>,
Option<&GltfMaterialExtras>,
)>,
mut display: Single<&mut Text, With<ExampleDisplay>>,
) {
let mut gltf_extra_infos_lines: Vec<String> = vec![];
for (id, name, scene_extras, extras, mesh_extras, material_extras) in
gltf_extras_per_entity.iter()
{
if scene_extras.is_some()
|| extras.is_some()
|| mesh_extras.is_some()
|| material_extras.is_some()
{
let formatted_extras = format!(
"Extras per entity {} ('Name: {}'):
- scene extras: {:?}
- primitive extras: {:?}
- mesh extras: {:?}
- material extras: {:?}
",
id,
name.unwrap_or(&Name::default()),
scene_extras,
extras,
mesh_extras,
material_extras
);
gltf_extra_infos_lines.push(formatted_extras);
}
display.0 = gltf_extra_infos_lines.join("\n");
}
}
}
8.2.3 遍历glb/gltf
在上面一节里,我们已经介绍了如何加载glb文件,并理解了加载后glb文件的构成,现在,我们需要来看看如何在ecs系统中使用这些加载好的glb文件。
先给出一个例子,这个系统会查询一个有MovedScene组件的glb实体,然后对他的每一个子实体都进行修改。这个例子里有着很多有趣的细节。
首先,这个例子告诉我们,如果我们的glb中没有把模型合并,那么我们加载的模型并不是一个单一的实体。通过使用Children的方式可以发现,Scene是一簇有着父子关系的实体,整个场景通过children和ChildOf来组织(详情见第2章)。
#![allow(unused)]
fn main() {
//在setup中
//commands.spawn((
// Transform::from_xyz(-1.0, 0.0, 0.0),
// SceneRoot(
// asset_server
// .load
// (GltfAssetLabel::Scene(0).from_asset("models/FlightHelmet/FlightHelmet.gltf")),
// ),
//));
//commands.spawn((
// Transform::from_xyz(-1.0, 0.0, 0.0),
// SceneRoot(
// asset_server
// .load
// (GltfAssetLabel::Scene(0).from_asset("models/FlightHelmet/FlightHelmet.gltf")),
// ),
// MovedScene,
//));
fn move_scene_entities(
time: Res<Time>,
moved_scene: Query<Entity, With<MovedScene>>,
children: Query<&Children>,
mut transforms: Query<&mut Transform>,
) {
for moved_scene_entity in &moved_scene {
let mut offset = 0.;
//使用iter_descendants方法,递归遍历该实体的所有子实体
for entity in children.iter_descendants(moved_scene_entity) {
//获得每个子实体的transform,然后更改
if let Ok(mut transform) = transforms.get_mut(entity) {
transform.translation = Vec3::new(
offset * ops::sin(time.elapsed_secs()) / 20.,
0.,
ops::cos(time.elapsed_secs()) / 20.,
);
offset += 0.5;
}
}
}
}
}
运行这些代码,可以发现他们的效果如下所示。因为每个子实体都有自己独立的transform,所以最终的效果会让模型的每个部分错位平移。这正是我们在第六章里多次提到的!子实体的transform是相对于父实体的。

现在,让我们再重新回顾一下本小节。本小节的内容相当重要,当我们从glb文件中加载一个Scene的时候,我们获得的是一簇实体,而不是一个单一的实体,因此我们只使用了SceneRoot组件来挂载根,像这样加载glb模型。
#![allow(unused)]
fn main() {
let flight_helmet = asset_server
.load(
GltfAssetLabel::Scene(0).from_asset("models/FlightHelmet/FlightHelmet.gltf"));
commands.spacwn((
SceneRoot(flight_helmet.clone()),
Transform::from_xyz(-1.25, 0., 0.),
));
}
从glb文件内加载的模型上,带有很多的组件,这些组件包括:MeshMaterial3d、GltfMeshName、GltfMaterialName、各种Extras等等。其中MeshMaterial3d描述了实体的材质,后两者描述了实体的网格和材质的名字,Extras则是一些附加的文本信息。这其中,最重要的是MeshMaterial3d,在下一章中,我们将会使用MeshMaterial3d来动态的修改一个已经加载好的模型或者shape的材质。
8.3 MeshMaterial3d
与Mesh2d对应的MeshMaterial2d相同,Mesh3d也对应了MeshMaterial3d。如果没有MeshMaterial3d只有Mesh3d,窗口上也不会渲染任何东西。
8.3.1 StandardMaterial
好吧,为什么在渲染2d时我们使用的是ColorMaterial,但是到了3d我们却使用StandardMaterial呢?总之,别搞混了,ColorMaterial是一种2d材质,不要把他用在3d上,否则后果你懂的。
StandardMaterial这个名字其实是来自于PBR光照模型的一种约定,PBR光照模型是一种广泛被使用的,用来模拟各种材质的三维物体在渲染时的效果的算法,负责执行这部分的内容通常也被成为渲染引擎。StandardMaterial的结构体参数多达60个,其结构体定义如下(如果你用过blender,那么你肯定对材质十分头疼,但是我们现在学的是Bevy而不是怎么选择参数来控制渲染效果,只需要掌握几种创建StandardMaterial的方式就足够了。)
这些字段的详细信息可以查看文档。
#![allow(unused)]
fn main() {
pub struct StandardMaterial {Show 60 fields
pub base_color: Color,
pub base_color_channel: UvChannel,
pub base_color_texture: Option<Handle<Image>>,
pub emissive: LinearRgba,
pub emissive_exposure_weight: f32,
pub emissive_channel: UvChannel,
pub emissive_texture: Option<Handle<Image>>,
pub perceptual_roughness: f32,
pub metallic: f32,
pub metallic_roughness_channel: UvChannel,
pub metallic_roughness_texture: Option<Handle<Image>>,
pub reflectance: f32,
pub specular_tint: Color,
pub diffuse_transmission: f32,
pub diffuse_transmission_channel: UvChannel,
pub diffuse_transmission_texture: Option<Handle<Image>>,
pub specular_transmission: f32,
pub specular_transmission_channel: UvChannel,
pub specular_transmission_texture: Option<Handle<Image>>,
pub thickness: f32,
pub thickness_channel: UvChannel,
pub thickness_texture: Option<Handle<Image>>,
pub ior: f32,
pub attenuation_distance: f32,
pub attenuation_color: Color,
pub normal_map_channel: UvChannel,
pub normal_map_texture: Option<Handle<Image>>,
pub flip_normal_map_y: bool,
pub occlusion_channel: UvChannel,
pub occlusion_texture: Option<Handle<Image>>,
pub specular_channel: UvChannel,
pub specular_texture: Option<Handle<Image>>,
pub specular_tint_channel: UvChannel,
pub specular_tint_texture: Option<Handle<Image>>,
pub clearcoat: f32,
pub clearcoat_channel: UvChannel,
pub clearcoat_texture: Option<Handle<Image>>,
pub clearcoat_perceptual_roughness: f32,
pub clearcoat_roughness_channel: UvChannel,
pub clearcoat_roughness_texture: Option<Handle<Image>>,
pub clearcoat_normal_channel: UvChannel,
pub clearcoat_normal_texture: Option<Handle<Image>>,
pub anisotropy_strength: f32,
pub anisotropy_rotation: f32,
pub anisotropy_channel: UvChannel,
pub anisotropy_texture: Option<Handle<Image>>,
pub double_sided: bool,
pub cull_mode: Option<Face>,
pub unlit: bool,
pub fog_enabled: bool,
pub alpha_mode: AlphaMode,
pub depth_bias: f32,
pub depth_map: Option<Handle<Image>>,
pub parallax_depth_scale: f32,
pub parallax_mapping_method: ParallaxMappingMethod,
pub max_parallax_layer_count: f32,
pub lightmap_exposure: f32,
pub opaque_render_method: OpaqueRendererMethod,
pub deferred_lighting_pass_id: u8,
pub uv_transform: Affine2,
}
}
这些字段多到有点让人望而生畏,不过仔细看看,其中有一些我们确实在ColorMaterial见过功能类似的。仔细查看文档,可以发现base_color、alpha_mode、base_color_texture、uv_transform这四个参数分别对应了ColorMaterial中的四个参数,他们的说明几乎是相同的,因此接下来我们也会主要围绕这几个参数来讲。
#![allow(unused)]
fn main() {
pub struct ColorMaterial {
pub color: Color,
pub alpha_mode: AlphaMode2d,
pub uv_transform: Affine2,
#[texture(1)]
#[sampler(2)]
pub texture: Option<Handle<Image>>,
}
}
8.3.2 base_color
从纯色创建一个StandardMaterial最简单的,也是最常用的方法,就是直接创建一个Color然后添加到StandardMaterial的资产中,剩下的事情Bevy会为我们自己完成。
#![allow(unused)]
fn main() {
fn setup(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
//....
commands.spawn((
Mesh3d(cube.clone()),
MeshMaterial3d(materials.add(Color::from(hsla))),
Transform::from_translation(Vec3::new(x as f32, 0.0, z as f32)),
));
//...
}
}
或者,我们可以直接创建一个StandardMaterial并指定base_color。
#![allow(unused)]
fn main() {
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgba(0.5, 1.0, 0.5, 0.0),
..default()
})),
}
8.3.3 alpha_mode
让我们回顾一下,ColorMaterial中的alpha_mode参数是一个AlphaMode2d类型的枚举,包含了三个值,分别表示 不透明、阈值、混合模式。
#![allow(unused)]
fn main() {
pub enum AlphaMode2d {
Opaque,
Mask(f32),
Blend,
}
}
在StandardMaterial中,alpha_mode参数变成了AlphaMode类型(令人疑惑的是,为什么不叫AlphaMode3d呢?)的枚举,在AlphaMode2d的基础上增加了四个值,一共包含了七个值。
#![allow(unused)]
fn main() {
pub enum AlphaMode {
Opaque,
Mask(f32),
Blend,
Premultiplied,
AlphaToCoverage,
Add,
Multiply,
}
}
看起来后五个值的作用都很相似?那么他们有什么区别呢?
在Blend模式下,计算颜色公式是利用源图像和目标图像进行加权得到的,而这里的源图像的权值,就是我们透明通道的值。用公式表示为:
在Premultiplied模式下,将会假设你输入的图像已经与透明通道做了乘法,因此公式变为:
Premultiplied可以消除 Blend 模式中常见的黑色光环问题。
在AlphaToCoverage模式下的工作原理比较复杂,简而言之,它主要用于提高 多重采样抗锯齿(MSAA) 环境下,半透明或带遮罩边缘的渲染质量。因为在 MSAA 环境中,如果Blend模式导致一个像素被丢弃,它的所有 子样本也会被同时丢弃。这导致锯齿状的边缘非常明显,缺乏平滑过渡。
在Add模式下,前一种颜色的值会和后一种颜色的值直接相加,由于颜色分量相加,最终的颜色值会大于或等于原始的背景颜色,从而产生发光、发亮的效果,因此在火焰、烟雾、光晕和粒子特效中经常使用这种模式。
在Multiply模式下,源颜色和目标颜色的 RGB 分量被直接相乘。相乘的结果总是会比两个原始颜色中的任何一个都要暗。这可以产生一种褪色或者污渍的效果。
8.3.4 base_color_texture与uv_transform
之前我们在讲2D的时候,曾说过“uv_transform要与texture一起使用时才能发挥作用”。而在这里的3D的情况,其实也是相同的,不过这两个参数稍有不同,变成了base_color_texture与uv_transform。
如果你学习过一点blender,那你对uv肯定是再熟悉不过了,在这里我们不会去详细讨论这些概念,关于3维物体的图像材质与uv投影是一个很大的领域,这部分工作往往都是在建模软件里直接完成的,而不需要我们在bevy里做,我们也不应该在bevy里去做。
这里的uv_transform与blender中的uv略有不同。细心的读者可以发现,这里的uv_transform居然是一个Affine2类型,查看文档,可以发现这样的描述:“The transform applied to the UVs corresponding to ATTRIBUTE_UV_0 on the mesh before sampling. Default is identity.”
这说明,这里的uv_transform是对uv图像本身给进行变换。在99%的情况下,我们都不需要修改这些设置,因为在建模时已经对齐了。
8.4 Wireframe
最后,让我们来介绍一下如何对3D模型进行简单的调试。当你兴致冲冲地加载了一个复杂的 glTF 模型,却发现屏幕上什么都没有,或者模型看起来扭曲得像个克苏鲁怪物时,你可能需要一种手段来观察它的骨架——也就是它的三角面片到底是怎么分布的。这就是**线框模式(Wireframe)**的用武之地。
在 Bevy 中开启线框模式稍微有一点点门槛,因为它涉及到一些底层图形 API 的特性。
首先,线框渲染并不是默认开启的。由于它依赖于 GPU 的特殊功能(POLYGON_MODE_LINE),你需要在初始化 DefaultPlugins 时手动添加 WireframePlugin。
use bevy::pbr::wireframe::{WireframePlugin, WireframeConfig};
use bevy::render::{render_resource::WgpuFeatures, settings::WgpuSettings, RenderPlugin};
fn main() {
App::new()
.add_plugins((
DefaultPlugins.set(RenderPlugin {
render_creation: WgpuSettings {
// 开启底层线框模式支持
features: WgpuFeatures::POLYGON_MODE_LINE,
..default()
}
.into(),
..default()
}),
// 启用线框插件
WireframePlugin::default(),
))
// WireframeConfig是一个内置的很方便的控制resource,你可以通过他来切换所有物体是否显示线框
.insert_resource(WireframeConfig {
global: true,
default_color: WHITE.into(),
..default()
})
.run();
}
如果你想看场景里所有物体的线框,你可以修改 WireframeConfig 资源。这通常用于调试整个场景的性能。
#![allow(unused)]
fn main() {
fn toggle_global_wireframe(mut config: ResMut<WireframeConfig>) {
config.global = true; // 开启后,全场所有 Mesh 都会显示线框
config.default_color = Color::WHITE; // 你甚至能改线框的颜色
}
}
如果只想看某个特定模型的结构。这时你只需要给对应的 Entity 挂上一个 Wireframe 组件即可,这时WireframeConfig将不会影响该实体的设置。
#![allow(unused)]
fn main() {
commands.spawn((
Mesh3d(mesh_handle),
MeshMaterial3d(material_handle),
Wireframe, // 只有这个物体会显示线框
WireframeColor { // 控制颜色
color: YELLOW.into(),
},
WireframeLineWidth { width: 3.0 }, // 粗细
WireframeTopology::Quads,// 线框形状
));
}

除了看起来很酷(很有黑客帝国的科技感)之外,线框模式在 3D 开发中有两个核心作用:
- 检查拓扑结构:你可以直观地看到模型是否过于精细(面数爆炸),或者是否存在法线反转导致的破面。
- 确认层级关系:在处理像我们在 8.2.2 节中提到的那种复杂的 glTF 场景时,开启线框能帮你快速定位每一个子 Mesh 的具体边界。
第 9 章:3D动画
9.1 动画总览
要播放3D模型动画,不管怎样,我们都要先拿到模型,Gltf模型其实是一种Asset,因此我们在第三章中讨论的如何加载资产的那一整套,都可以完美的平移过来。
首先我们需要一个Resource来存储句柄,以便在不同的系统之间传递。当然也可以传递一些别的东西,不过目前而言我们主要关心的是其中的Handle。
#![allow(unused)]
fn main() {
#[derive(Resource)]
struct Fox(Handle<Gltf>);
}
然后,在startup阶段我们的setup系统中,只需要直接加载即可。
#![allow(unused)]
fn main() {
fn setup(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
commands.insert_resource(Fox(asset_server.load(FOX_PATH)));
//....
}
}
在这里,我们将用上我们在第2章中介绍的条件运行(参见2.3.2 run_if)。我们希望在资源加载完成并且配置完成之前,不要运行我们的动画系统。(或者,我们也可以采用state的方法来切换系统的运行,你能想到怎么做吗?)
#![allow(unused)]
fn main() {
// 存储动画相关的资源
#[derive(Resource)]
struct Animations {
// 至于AnimationNodeIndex和AnimationGraph是什么,等待下文我们揭晓
animations: Vec<AnimationNodeIndex>,
graph_handle: Handle<AnimationGraph>,
}
// 这个系统在资产加载完成之前将会一直尝试运行
app.add_systems(
Update,
spawn_fox_asset_when_ready.run_if(not(resource_exists::<Animations>)),
)
// 这个系统只有在加载完成后运行
.add_systems(
Update,
keyboard_control.run_if(resource_exists::<Animations>),
)
}
在spawn_fox_asset_when_ready系统中,我们依然采用我们的三板斧。使用is_loaded_with_dependencies来判断是否已经加载完成(详见3.1.1节,要时常复习才能温故知新!)
#![allow(unused)]
fn main() {
fn spawn_fox_asset_when_ready(
mut commands: Commands,
fox_handle: Res<Fox>,
asset_server: Res<AssetServer>,
gltfs: Res<Assets<Gltf>>,
mut graphs: ResMut<Assets<AnimationGraph>>,
) {
// 如果尚未加载完成,那么先不执行
if !asset_server.is_loaded_with_dependencies(&fox_handle.0) {
return;
}
// 然后后利用句柄来获得我们的资产,你还记得吗?
let fox = gltfs
.get(&fox_handle.0)
.expect("a loaded asset should exist in the glTF assets collection");
// 很快你就会知道这里的三个动画对应了什么了
// 现在,只需要知道我们有了一个AnimationGraph类型的graph
// 还有了一个Vec<NodeIndex>类型的node_indices
let (graph, node_indices) = AnimationGraph::from_clips([
fox.named_animations["Run"].clone(),
fox.named_animations["Walk"].clone(),
fox.named_animations["Survey"].clone(),
]);
// 保存这些动画信息,以便我们后续使用
let graph_handle = graphs.add(graph);
commands.insert_resource(Animations {
animations: node_indices,
graph_handle,
});
// 现在,我们才向bevy中添加真正的模型
commands
.spawn(SceneRoot(
fox.default_scene
.clone()
.expect("a default scene exists in this file"),
))
// 这里用到了observe,还记得observe有什么用吗?(详见2.7.1节,再次强调温故知新的重要性~)
.observe(setup_scene);
}
// 现在,每当SceneInstanceReady触发时,该函数将会被执行
// 可是SceneInstanceReady是什么?他的完整路径是bevy::scene::SceneInstanceReady
// 简而言之,这是bevy的一个内置事件,其定义如下,每当整个场景加载完成之后会被触发
// pub struct SceneInstanceReady {
// pub entity: Entity,
// pub instance_id: InstanceId,
// }
// 而AnimationPlayer组件,是跟随scene自动添加的
// 所以当SceneInstanceReady触发的时候,AnimationPlayer也已经存在
fn setup_scene(
_ready: On<SceneInstanceReady>,
mut commands: Commands,
animations: Res<Animations>,
player: Single<(Entity, &mut AnimationPlayer)>,
) {
// 直接将内部的两个组件的所有权拿出来
let (entity, mut player) = player.into_inner();
// 然后我们需要new一个AnimationTransitions
// 如果你用过css,那么你一定也知道transition有什么用,这里的AnimationTransitions
// 也是一样的,当动画切换的时候,将会在动画之间插入平滑的过渡
let mut transitions = AnimationTransitions::new();
// 立刻开始重复播放第一个动画
transitions
.play(&mut player, animations.animations[0], Duration::ZERO)
.repeat();
// 这里的commands.entity对应了我们在第2章介绍的EntityComands,还记得吗?
// 因此我们向scene根实体上,插入了这两个新的组件
commands
.entity(entity)
.insert(AnimationGraphHandle(animations.graph_handle.clone()))
.insert(transitions);
}
}
这些代码看下来真是让人大汗淋漓。你可能心里会想,怎么那么多没见过的东西?AnimationNodeIndex是什么?AnimationGraph是什么?AnimationTransitions又是什么?AnimationGraphHandle又是什么??
让我们来一个个翻一翻源代码看看吧。AnimationNodeIndex其实只是一个别名,其本质上不过是一个含有u32类型的枚举罢了,这个数字标识了一个动画在gltf中的索引。这解释了为什么我们使用transitions.play方法时需要他。
#![allow(unused)]
fn main() {
/// The index of either an animation or blend node in the animation graph.
///
/// These indices are the way that [animation players] identify each animation.
///
/// [animation players]: crate::AnimationPlayer
pub type AnimationNodeIndex = NodeIndex<u32>;
}
AnimationGraph包含了一个有向无环图,感兴趣的读者可以查看文档,他其实相当复杂。描述了多个动画应该如何混合在一起。这是什么意思呢?我们在设计动画时,比如行走和攻击,一般都会设计成为两个单独的动画。如果不能将其混合起来一起播放,那么你行走的时候攻击时角色就会发生诡异的漂移(即脚不动但是角色还在跑,这在很多粗制滥造的游戏中很常见)。
#![allow(unused)]
fn main() {
#[derive(Asset, Reflect, Clone, Debug)]
#[reflect(Debug, Clone)]
pub struct AnimationGraph {
/// The `petgraph` data structure that defines the animation graph.
pub graph: AnimationDiGraph,
/// The index of the root node in the animation graph.
pub root: NodeIndex,
/// The mask groups that each animation target (bone) belongs to.
///
/// Each value in this map is a bitfield, in which 0 in bit position N
/// indicates that the animation target doesn't belong to mask group N, and
/// a 1 in position N indicates that the animation target does belong to
/// mask group N.
///
/// Animation targets not in this collection are treated as though they
/// don't belong to any mask groups.
pub mask_groups: HashMap<AnimationTargetId, AnimationMask>,
}
// 其中
/// A type alias for the `petgraph` data structure that defines the animation
/// graph.
pub type AnimationDiGraph = DiGraph<AnimationGraphNode, (), u32>;
pub type AnimationMask = u64;
pub struct AnimationTargetId(pub Uuid);
}
观察其代码,可以发现其由graph、root、mask_groups构成。AnimationGraphNode描述了如何对动画进行混合。HashMap<AnimationTargetId, u64>则描述了每块动画骨骼的uuid和掩码组之间的关系。掩码组是什么意思?
为什么这里是一个HashMap<AnimationTargetId, AnimationMask>类型呢?其实这是一种典型的位图。u64类型含有64个bit位。因此每个骨骼,最多支持定义 64 个不同的掩码组。
举个例子,一个复杂的模型可能有几百根骨骼,但你可能只想给其中的一部分(比如只有右手、或者只有上半身)分配掩码组。HashMap 只存储那些被分配了组的骨骼。你在 mask_groups 里把所有属于“上半身”的骨骼 ID 都标记为 1 << 0(第 0 组)。节点 A 播放“奔跑”动画(不带掩码,作用于全身)。节点 B 播放“挥剑”或“换弹”动画,但你给这个节点设置一个掩码,指定它只作用于第 0 组。角色就可以一边跑(下半身由节点 A 控制),一边做攻击动作(上半身被节点 B 覆盖)。
这方面的混合其实非常复杂,三言两语是搞不定的。如果对动画混合更感兴趣的读者,则需要查阅更详细的资料,这里就不再赘述。至于AnimationGraphHandle,这名字显而易见,由于AnimationGraph实际上是一种asset,因此当然也有对应的handle。
AnimationTransitions是一个用于控制动画之间过度的“增强型”AnimationPlayer。前面我们说当场景生成时,AnimationPlayer会自动添加到场景的任何根动画中。要使用AnimationTransitions你还必须把AnimationGraphHandle也同时放在一个实体上。仔细一想这也是正常的。因为动画过渡,本身就是利用动画图来实现的。
#![allow(unused)]
fn main() {
// 第一次动画必须使用transitions.play而不能使用AnimationPlayer上的play方法
// 因为AnimationTransitions需要记录动画的顺序,如果直接操作AnimationPlayer开始
// 会导致AnimationTransitions 无法确定动画的切换关系,从而导致过渡效果通常不正确。
let mut transitions = AnimationTransitions::new();
transitions
.play(&mut player, animations.animations[0], Duration::ZERO)
.repeat();
// 并且我们把这两个组件都插入到了根上
commands
.entity(entity)
.insert(AnimationGraphHandle(animations.graph_handle.clone()))
.insert(transitions);
}
最后,一切大功告成,只需要在按下不同的按键时切换不同动画即可。
#![allow(unused)]
fn main() {
fn keyboard_control(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut animation_players: Query<(&mut AnimationPlayer, &mut AnimationTransitions)>,
animations: Res<Animations>,
mut current_animation: Local<usize>,
) {
// 这里的player是一个AnimationPlayer
// 而transitions则是我们插入的AnimationTransitions
for (mut player, mut transitions) in &mut animation_players {
// 我们可以获取下一个动画的NodeIndex
let Some((&playing_animation_index, _)) = player.playing_animations().next() else {
continue;
};
// 当按下特定的按键时,可以控制播放器的行为,比如播放暂停
if keyboard_input.just_pressed(KeyCode::Space) {
let playing_animation = player.animation_mut(playing_animation_index).unwrap();
if playing_animation.is_paused() {
playing_animation.resume();
} else {
playing_animation.pause();
}
}
// 还可以控制播放速度
if keyboard_input.just_pressed(KeyCode::ArrowUp) {
let playing_animation = player.animation_mut(playing_animation_index).unwrap();
let speed = playing_animation.speed();
playing_animation.set_speed(speed * 1.2);
}
// 对于动画的切换,我们需要计算下一个索引,并重新使用transitions来切换
// 必须使用transitions.play()!否则过渡会出问题
if keyboard_input.just_pressed(KeyCode::Enter) {
*current_animation = (*current_animation + 1) % animations.animations.len();
transitions
.play(
&mut player,
animations.animations[*current_animation],
Duration::from_millis(250),
)
.repeat();
}
// ..... 等等的类似逻辑
}
}
}
9.2 动画图与控制
上面那些乱七八糟的东西实在是看的让人头大,让我们首先来更仔细的看看所谓AnimationGraph的概念到底是什么吧。
用更规范一些的说法,动画图(Animation Graph)是游戏引擎中用来管理、混合、切换和控制角色复杂动画的数据结构或可视化节点图。在不同的游戏引擎中有不同的名字(UE 中叫做 AnimGraph、Unity 中叫做 Animator Controlle),但是他们的核心作用是一样的,都负责在不同的模型动画之间进行过渡、平滑、混合。
9.2.1 骨骼与动画
要理解为什么需要动画图,就必须理解,模型在建模软件中的骨骼动画是如何存在的。
简而言之,如下图所示,骨架被外部模型表面包围,而模型的表面由顶点与三角形或四边形构成。骨架中的每一根骨骼都会影响皮肤的形状和位置。

骨架本身就是一个树状层级结构,每根骨骼都有一个相对父节点的位置、旋转和缩放(第六章中我们详细讨论过这些变换)。要给模型加入动画,就需要在时间轴的关键节点打上关键帧。Blender 会使用函数曲线在关键帧之间进行自动插值。这些针对同一套骨骼的所有的关键帧和时间曲线组合在一起,在 Blender 中被称为一个 Action(动作)。
除此之外,为了让外部的模型顶点和三角形能够跟随骨架的变换而变化,网格的每个顶点都会绑定到一到多根骨骼上,并赋予 0 到 1 之间的权重。当骨骼移动时,顶点会根据权重按比例跟随移动。这些骨架顶点和周围网格顶点组成的关系叫做蒙皮(Skin)。

对于 gltf 格式来说,骨骼本质上就是一个普通的 Node,Node 是一个纯粹的空间节点,它有层级关系(父子关系),并记录了自己的平移, 旋转, 缩放。例如他看起来可能是这样。
"nodes": [
{ "name": "Hips", "children": [ 1 ], "translation": [0, 1, 0] }, // Index 0: 髋关节(根节点)
{ "name": "Thigh_L", "children": [ 2 ], "rotation": [0, 0, 0, 1] }, // Index 1: 左大腿
{ "name": "Shin_L", "rotation": [0, 0, 0, 1] } // Index 2: 左小腿
]
其中的 children 指明了个节点之间的父子关系,当 Hips 移动时,Thigh_L和 Shin_L 作为子节点,在空间中会自动跟着一起移动。
而 gltf 的 Mesh 字段里,存的是几何体的顶点坐标。为了支持蒙皮,Mesh 的每个顶点都会额外附带两组数据:
- JOINTS_0(关联骨骼):这个顶点受哪几根骨头影响?(通常最多支持 4 根,用 Node 的索引表示)。
- WEIGHTS_0(权重比例):受这 4 根骨头影响的程度是多少?(4 个浮点数,加起来等于 1.0)。
现在有了 Node 和 Mesh,但 GPU 还是不知道怎么算。这是因为Node和Mesh的坐标系之间是独立的,二者之间缺少一个变换矩阵。
这个问题值得我们好好再聊一聊。举个例子,假如Mesh是手掌表面,而Node是手掌骨骼,在没有蒙皮的情况下,他们的坐标是相对于世界原点或者自己的父节点的。现在假如手掌骨骼相对手腕骨骼旋转了90度,这个旋转矩阵,是在骨骼自己的坐标系下旋转的,如果直接将其应用到手掌表面,那么骨骼会直接从手掌里甩出去了。
因此,我们在做这个变换之前,需要先把Mesh顶点的坐标系和Node的坐标系对齐,这是一个4*4的变换矩阵(如果对应了四根骨头,那么就有4个变换矩阵)。这个矩阵就就叫做 inverseBindMatrices。
Skin 就要负责提供这些信息。他看起来可能是这样的。joints字段告诉了引擎“这个 Skin 包含了哪些 Node 作为关节”,inverseBindMatrices 里是一个索引,意思是去寻找索引为 5 的 Accessor 里的数据作为逆变换矩阵。
"skins": [
{ "name": "HumanoidSkin", "joints": [ 0, 1, 2 ], "inverseBindMatrices": 5 }
]
假如 Mesh 的每个顶点都存着:
-
JOINTS_0: [1, 2, 0, 0] (这个顶点关联了 1 号骨骼小臂、2 号骨骼手腕)
-
WEIGHTS_0: [0.7, 0.3, 0, 0] (小臂占 70% 权重,手腕占 30% 权重)
在这种情况下,这个变换就可以表示为。
说到这里我们终于说完了最基本的骨骼知识,那么让我们回到正题,为什么我们需要AnimationGraph?
因为 gltf 里,一个动画就是一段固定的、写死的数据采样流(例如从 0 秒到 1 秒,骨骼 A 旋转 45 度)。动画师不可能穷尽所有的动画可能,必须由游戏引擎在运行时利用动画师的这些已经写好的动画,拼接出新的动画。
9.2.2 AnimationGraph
一个AnimationGraph是由AnimationGraphNode组成的一个有向无环图,其中后者分为三种类型。
pub enum AnimationNodeType {
/// 动画图的数据源头。它负责从内存中加载并播放一个具体的动画资源
/// 它永远是动画图的叶子节点,没有子节点,只负责向上传递数据。
Clip(Handle<AnimationClip>),
/// 将多个子节点(可以是 Clip 或其他 Blend 节点)的动画效果按比例归一化后线性混合。
#[default]
Blend,
/// 将子节点的动画叠加到动画上。
Add,
}
一个典型的游戏角色动画图在内存中可以是这样组合的。底层的两个 Clip 负责提供 Walk 和 Run 的原始骨骼矩阵。中间的 Blend 按照当前移动速度,把两者插值混合成一个自然快走姿势。顶层的 Add 节点直接把 Attack 的右手挥剑差值数据“加”在刚刚混合好的姿势上,最终算出送往顶点着色器(Vertex Shader)的骨骼矩阵。
[ 最终输出到骨骼 (Final Output) ]
│
[ Add 叠加节点 ]
/ \
/ \ (加性叠加右手动作)
[ Blend 混合节点 ] [ Clip: 挥剑攻击 (Attack) ]
/ \
/ \ (按速度 40%/60% 归一化混合)
[ Clip: 走路 (Walk) ] [ Clip: 跑步 (Run) ]
用代码来写这些流程的话,大概长这样。除了最通用的add_edge、add_clip之外,读者可以查看文档,还有很多例如 add_clips,add_blend from_clips(我们在之前使用过这个方法)之类的便捷方法。
#![allow(unused)]
fn main() {
fn setup_animation_graph(
mut commands: Commands,
mut animation_graphs: ResMut<Assets<AnimationGraph>>,
mut players: Query<(Entity, &mut AnimationPlayer), Added<AnimationPlayer>>,
asset_server: Res<AssetServer>,
) {
for (entity, mut player) in &mut players {
// 构建动画图节点
let mut graph = AnimationGraph::new();
// 创建 Clip 节点
let clip_walk = asset_server.load(GltfAssetLabel::Animation(0).from_asset("models/character.gltf"));
let clip_run = asset_server.load(GltfAssetLabel::Animation(1).from_asset("models/character.gltf"));
let clip_attack = asset_server.load(GltfAssetLabel::Animation(2).from_asset("models/character.gltf"));
let node_walk = graph.add_clip(clip_walk, 1.0, graph.root);
let node_run = graph.add_clip(clip_run, 1.0, graph.root);
let node_attack = graph.add_clip(clip_attack, 1.0, graph.root);
// 将 Walk 和 Run 按 50%/50% 混合成移动动画
let node_blend = graph.add_node(AnimationNodeType::Blend, graph.root);
graph.add_edge(node_walk, node_blend);
graph.add_edge(node_run, node_blend);
// 将 Blend 产生的移动动画 作为基础,叠加 Attack
let node_add = graph.add_node(AnimationNodeType::Add, graph.root);
graph.add_edge(node_blend, node_add);
graph.add_edge(node_attack, node_add);
// 将根节点设为最终输出的 Add 节点
graph.root = node_add;
// 注册并播放动画图
let graph_handle = animation_graphs.add(graph);
// 在 AnimationPlayer 上开始播放该动画图
player.play(node_add).repeat();
commands.entity(entity).insert(CharacterAnimation {
graph_handle,
node_walk,
node_run,
node_blend,
node_attack,
node_add,
});
}
}
}
9.2.3 AnimationPlayer
仅仅能够混合不同的动画是远远不够的,我们还需要能够在运行时动态的改变动画的混合逻辑与速度等控制要素。
在前面我们说到,AnimationPlayer 会自动添加到场景的任何根动画中,而控制动画,就要用到 AnimationPlayer 身上的方法。AnimationPlayer 上有着 start、play、stop、is_playing_animation、animation_mut 等等非常有用的方法,这些方法都需要一个 AnimationNodeIndex 类型的参数,即动画的 index ,读者可以查看文档。
这里我们主要介绍 is_playing_animation 与 animation_mut 两个方法。前者主要用来查询当前播放的动画的index是否为该index,后者则用于获取id对应的 ActiveAnimation 。
#![allow(unused)]
fn main() {
static CLIP_NODE_INDICES: [u32; 3] = [2, 3, 4];
fn sync_weights(mut query: Query<(&mut AnimationPlayer, &ExampleAnimationWeights)>) {
for (mut animation_player, animation_weights) in query.iter_mut() {
for (&animation_node_index, &animation_weight) in CLIP_NODE_INDICES
.iter()
.zip(animation_weights.weights.iter())
{
// 查询当前动画的index是否为animation_node_index,如果不是那么则播放该动画
if !animation_player.is_playing_animation(animation_node_index.into()) {
animation_player.play(animation_node_index.into());
}
// 获得当前动画的ActiveAnimation并设置其权重
// 这里的active_animation是一个ActiveAnimation类型的结构体
if let Some(active_animation) =
animation_player.animation_mut(animation_node_index.into())
{
active_animation.set_weight(animation_weight);
}
}
}
}
}
ActiveAnimation 是 bevy 用来实时追踪和控制单个正在运行(或暂停)的动画实例的底层状态。里面存储了非常多有用的信息,并且暴露了很多有用的调整方法。读者可以查看文档。
#![allow(unused)]
fn main() {
pub struct ActiveAnimation {
/// 该动画当前生效的权重系数。动画图中的节点权重会乘以这个值,用来在运行时做平滑淡入淡出或调节混合比例。
weight: f32,
repeat: RepeatAnimation,
/// 播放倍速
speed: f32,
/// 该动画累计播放的总时间
elapsed: f32,
/// 动画片段内部的当前时间戳。永远限定在 [0.0, 动画时长] 范围内。
seek_time: f32,
/// 上一帧的 seek_time。
/// 引擎用它和当前的 seek_time 对比,来判断这一帧动画是否跨过了某个关键帧或动画事件
last_seek_time: Option<f32>,
/// 动画已经播放完成的次数
completions: u32,
/// 这一帧是否刚刚播放完一次
just_completed: bool,
paused: bool,
}
}
第 10 章:反射-P1
首先,谢谢你能陪我一直读到这里,。
现在,你已经对bevy有了更详细的认知,凭借之前的知识,现在你应该可以自己写出一些简单的游戏了。不过如果你细心,那你可能察觉到我漏掉了关于物理引擎的这一部分,我们在本书中不会详细介绍,因为这不属于bevy的组成部分(或许以后我可能会专门再写一本书研究如何从0写一个物理引擎>-<),读者可以查看别的相关资料,我们这里就不再赘述了。
在这里我们正式进入第二部分,即bevy最复杂最核心的反射与ecs系统,我将会花几个章节的篇幅先把反射讲清楚,然后揭开bevy_reflect与bevy_ecs的核心秘密。
让我们把话题拉到更宏大历史进程中,来进一步认识我们平时的工作吧!Bevy 的反射与 ECS 极其复杂,是出了名的难懂,这是因为它在试图解决『图灵机的并发安全困境』与『静态语言缺乏运行时灵活度』这两个大难题。
而解答这两个难题的钥匙,就是函数式编程与元编程。(以下话题大多数为闲聊话题,非代码部分,如果不想看也可以不看,不过看了之后会帮助你对于编程有一个全新的理解)。
10.1 函数式编程
如果你仔细审视当今技术栈的顶峰,你会发现函数式编程无处不在,它已经成为了诸多框架和语言刻在基因里的概念——不可变数据与闭包。
你可能把这句话都已经听烂了,可变数据会导致状态污染、控制流混乱、导致发生“我这个数据怎么莫名其妙变了”这种问题,听烂了闭包的n种好处。但是抛开这种教条,到底为什么我们需要函数式编程?这中间到底发生过什么?让我们从古到今来复盘一下。
顺着族谱往上找,函数式编程其实根本不是什么现代前端或者Rust的新发明,它的理论根基在1930年代就被阿隆佐·邱奇(Alonzo Church)在普林斯顿大学的黑板上写下来了。那时候连真正意义上的电子计算机都还没诞生。
10.1.1 Lambda 演算
要聊 λ 演算的诞生,我们得把时钟拨回1920-1930年代。那时,整个数学界正处于一场数学危机中,数学家们正在疯狂地寻找数学的终极根基。
起因在于,大数学家大卫·希尔伯特(David Hilbert)在1900年的国际数学家大会上,一口气提出了 23 个著名的数学问题。这 23个问题直接指引了整个20世纪数学家们的奋斗方向,诞生了无数的数学定理,甚至连编程本身,也是从中诞生。
希尔伯特是一个极端的数学理性主义者,他有一句名言刻在了他的墓碑上:“我们必须知道,我们必将知道”。他无法容忍数学体系里存在任何模糊、二义性或者无法证明的东西。于是,1920年代,他进一步提出的一个宏大宏愿——“希尔伯特计划”。提出了一个宏大的计划,想要一劳永逸地为整个数学建立一座坚不可摧的理论体系。他要求这个数学体系必须满足三个条件:
- 完备性: 任何一个数学命题,要么能证明它是真的,要么能证明它是假的。绝对不存在无法确定的中间地带。
- 一致性: 数学体系必须是自洽的,绝对不能推导自相矛盾的结论(比如不能推导出 )。
- 可判定性: 存在一个纯粹机械化的步骤(算法),只要把任何数学命题丢进去,这个机器就能在有限时间内死板地计算出它是真还是假。这就是大名鼎鼎的判定问题。
如果这个计划成功,数学将变成一门完美的艺术:所有的定理都不再需要天才的灵光一现,只需要交给一个机械工人去推导符号就可以了。
这三个条件,直接导致了三个伟大的发现。
希尔伯特正带着全世界的数学家试图证明这个体系时,1931年,25岁的库尔特·哥德尔(Kurt Gödel)发表了著名的哥德尔不完备定理。
简单来说,哥德尔不完备定理直接证明了完备性的破产,哥德尔证明了。
任何一个自洽的数学体系,一定存在着一些绝对正确,但永远无法被这个体系证明的数学命题。
这给希尔伯特的梦想狠狠地砸了第一锤:数学是不可能完备的。
虽然完备性碎了,但希尔伯特和数学家们想:好吧,就算有些问题无法证明,那我们至少能不能发明一个“算法”,来判定某一个特定命题到底是不是“无法证明”的呢?这对应了第三个条件——可判定性。
为了解决这个判定问题,数学家们必须先搞懂一件事:到底什么叫“算法”? 当时根本没有计算机,大家对计算的理解都是模糊的。
于是,为了彻底打趴下希尔伯特的幻想,有两个天才在1936年不约而同地登场了,他们各自为计算下了一个严格的数学定义:
- 阿隆佐·邱奇 发明了** 演算**,认为计算就是函数的代入与展开。
- 艾伦·图灵 发明了图灵机,认为计算就是读写头在纸带上的擦擦写写。
后者,是今天一切计算机的老祖宗,而前者,则是函数式编程的老祖宗。
那么,什么是 演算?要一句话说清 演算,就是:“世界上没有实体,只有动作,万物皆是函数。”
在传统的物理世界或图灵机世界里,我们习惯了先有“东西(数据)”,再有“对东西的操作(算法)”。但邱奇的 演算做出了一个极其反直觉但是很数学的假设:我们不需要数据,只需要映射关系本身,就足以表达宇宙间的一切规律。
- 没有数字: “2” 不是一个实体,而是“把某个动作连续做两次”的抽象概念。
- 没有状态: 函数是一个绝对纯净的映射通道。你把东西丢进去,吐出新的东西。在这个过程中,输入的参数绝对不会在原位被污染或篡改。不可变性不是它的特性,而是它的核心假设。
邱奇用他的基础假设和Lambda演算,证明了只要有以下三种语法组件,就能组合出宇宙间所有的计算逻辑:
- 变量: (一个代号,用于指代输入)。
- 函数抽象: (定义一个匿名函数,输入是 ,逻辑体是 。现代前端管它叫箭头函数:x => M)。
- 函数应用: (把函数 作用在参数 上。也就是函数调用:M(N))。
它的计算机制只有一条:-归约(-reduction)。
说白了就是九个字——变量代入与函数展开。把输入参数代入到函数体里,再像弹簧一样把函数表达式展开、化简,直到不能再简为止。这就是整个 演算的全部运转方式。
邱奇在这个连数字、if-else、循环语句都没有的荒凉世界里,推导出了两个终极结论:
- 结论一:它和图灵机完全等价(邱奇-图灵论题)
通过纯粹的函数组合(比如用函数表达布尔值、用著名的“Y组合子”在没有循环语句的情况下强行实现递归),邱奇证明了:这个只用符号代入的逻辑系统,其计算能力与那个拥有无限纸带、读写头的、精密的物理图灵机完全等价。 这就为后来的函数式编程(Lisp、Haskell)奠定了绝对合法的正统地位——图灵机能干的, 演算全都能干。
- 结论二:机械化的终极判定是不可能的
邱奇利用 演算证明了,你无法写出一个通用的 表达式,去判定任意另一个 表达式是否能被化简到终点。这和图灵的停机问题殊途同归,彻底干掉了希尔伯特想用机械化判定一切数学命题的宏愿(希尔伯特又被无情鞭打)。
10.1.2 图灵机
说到图灵机,每个计算机人都很熟悉,在这里我们再重新复习一下图灵机的三个物理组件。
-
无限长的纸带: 这就是内存。纸带被划分为一个个格子,每个格子存储一个符号(数据)。
-
读写头: 这就是 CPU 的指针和寄存器。它可以在纸带有左右移动,更重要的是,它能擦除当前格子的符号,并写入一个新符号。
-
状态寄存器: 记录机器当前处于什么状态。
在图灵的假设里,计算就是读写头在纸带上的擦擦写写。想进行计算,必须去内存的某个地址上,把原来的值擦掉,改写成新的值。这就是地地道道的“可变变量”。我们今天在面向对象或命令式编程里写的 x = x + 1、obj.age = 18,全都是图灵机在纸带上擦擦写写的现代翻版。
OOP的出现,其实也绝对不是偶然,它就是图灵机模型在工程复杂度爆炸时的必然产物。 甚至可以说,OOP是图灵机在软件工程世界里演进出来的终极完全体。图灵机的核心就是那条纸带和那个擦擦写写的读写头,就是命令式编程的根源。世界上最早的高级语言(如 Fortran、C 语言)全都是地地道道的图灵机翻版。
为了拯救图灵机模型,不让它在复杂的现实业务里崩溃,人类必须想个办法限制读写头的瞎擦乱写。于是,阿兰·凯(Alan Kay,OOP之父)、芭芭拉·利斯科夫(Barbara Liskov)这帮天才提出了面向对象的思想。仔细审视OOP的核心三大特性(封装、继承、多态),它们其实全都是为了给图灵机的可变纸带打补丁。
10.1.3 为什么计算机使用图灵机
历史务实的地方就在于,冯·诺依曼在造物理计算机时,由于当年的硬件极其昂贵,物理学家们用屁股决定也会毫不犹豫背叛邱奇,选择更贴合硬件物理结构的图灵机模型(内存就是纸带,CPU就是读写头)。
于是,原位修改、命令式代码,这些东西统治了工业界半个世纪。而演算则带着它天生不可变,被软件工程打入冷宫,只能在Lisp语言里自娱自乐,甚至被工业界嘲笑是学术玩具。
这主要是因为,在那个世纪的硬件条件下,如果你非要用图灵机的硬件去跑纯演算,因为不能原地改数据,你每次改个属性都得在内存里重新开辟空间、复制一遍数据,当年只有几KB内存的机器根本无法运行任何复杂程序。
10.1.4 函数式编程复兴原因
函数式编程的复兴主要是由于两个变化导致的。
第一个变化是半导体技术达到了上限。在21世纪初人类终于撞上了物理极限,摩尔定律锁死了,单核 CPU 的主频撞到了物理极限。为了让计算机更快,物理学家和工程师们只能开始往一个芯片里塞更多的核心——多核并发时代来临。
这时候,图灵机模型的致命缺陷暴露了。多个核心同时在同一块内存上疯狂擦擦写写,世界瞬间乱套了。为了防止数据竞争,人类不得不发明了锁、互斥量、死锁检测。为了保护那块可变内存,程序员的心智负担直接爆炸。
人类在图灵机的沙盘里修修补补快崩溃时,一回头,突然发现了在函数式编程里躺了半个世纪的 演算。因为 演算天然不可变,数据只能读取不能修改,所以一千个核心同时去读它也是绝对安全的,天然对多核并发免疫。
另一个变化是系统的复杂度变了。早期的程序很简单,内存很贵,原位修改能榨干每一比特的硬件性能。但现代的系统太庞大了。数据在无数个组件、中间件、异步回调里流转。这时候,可变数据就变成了:“我这个状态怎么莫名其妙变了?是谁在哪个旮旯角里悄悄改了我的属性?”debug 变成了玄学。
兜兜转转大半个世纪,软件工程发现,解决复杂度的终极武器,居然是90年前数学家在黑板上写下的、那个连变量和数字都没有的纯粹世界。不管是现代前端的函数式编程、不可变数据、Lambda函数,其归根到底,老祖宗都是90年前的 演算。说到这里顺便插一嘴,其实纯粹的Lambda演算的函数,不能包含内部状态和触发副作用的,在现在叫做“纯函数”。而现在编程语言里的更务实的,能够捕获外部变量和存有自己状态的,有个更响亮的名字——闭包。现在你知道为什么叫Lambda函数而不是gamma或者什么omega函数了吧。
10.1.5 现代函数式编程
现代硬件底层依然是硬件高效的图灵机,但在软件的上层,现代编程语言都正在用演算的核心概念,让程序员能够编写出确定与优雅的程序。由于根上还是图灵机,实现最纯粹的其演算实是很困难的,而且我们也没有必要完全按照最初的演算来编写程序,不然那就太痛苦了。
现代函数式编程语言,从演算里偷来了最核心的几个武器——不可变数据、闭包、代数数据类型、高阶函数复合。变成了你天天看到的这些概念。
- **闭包:**本质上是演算在图灵机上的妥协产物。演算没有状态,而闭包通过捕获自由变量,把环境和动作打包在了一起。
- **不可变数据类型:**本质上是演算的核心假设。在现代主要通过两种方式实现,一种是编译器强制不可变关键字(例如
const)甚至在Rust里,只要不声明可变,就默认不可变;一种是通过结构共享,在可变数据类型上强行实现不可变数据,这就是React的核心,没变的部分用指针指向老数据,变了的部分创建新节点。 - **代数数据类型:**这是现代函数式编程极其重要、甚至可以说是
Rust、TypeScript等语言灵魂的一点,其标志性的体现就是enum。传统OOP依赖开放多态(通过子类继承虚表来分发行为),而函数式编程带来了闭合多态,结合模式匹配,编译器能强迫你必须处理完所有情况才能编译通过。这种闭合带来了类型安全和运行效率。 - **高阶函数复合:**传统OOP要实现功能的组合通常要让对象持有另一个对象的引用。而函数式编程的组合,是数学意义上的函数复合()。一个函数能够接收函数作为参数,或者能够实现一个,返回函数的函数
因此,你在设计模式中看到的,很多由于现代语言的改进而消灭的设计模式,其实更本质上,是现代函数式编程和演算对于图灵机的胜利。
10.1.6 不存在银弹
软件开发中没有银弹。说完了函数式编程与OOP的爱恨情仇。赞扬了函数编程的优点,那么,函数式编程的缺点是什么?
前面我们说到,演算的核心就是变量代入与函数展开,而一个事物的核心假设与方法,是优点来源的同时,往往也是最大的缺点来源。
缺点一:闭包地狱
在React里有一个被无数人喷过的被叫做“闭包地狱”的大雷,由于闭包的特性,某个异步回调函数或副作用捕获了“过去”的状态,导致它在执行时拿到的依然是旧数据,而不是最新的值。为了引入函数式组件的副作用与缓存,React推出了 useEffect、useCallback 等等一堆API。然而搞笑的是,它们反而转接了代价,变成了闭包地狱最频繁的爆发区。
其本质,是闭包的状态捕获与异步并发之间的冲突。
而在更底层的语言里,这个问题变得更加致命。单单是所有权机制和异步就已经开始产生了排异反应。
由于Rust 经常会发生所有权转移,把一个Future传给另一个线程、或者放进一个Box里。为了解决异步并发导致的内存移动产生空指针,Rust 引入了 Pin。Pin<P> 的本质是在内存中把这个对象“钉住”。一旦一个 Future 被 Pin 住了,Rust 就保证它再也不能在内存中被移动。这样,它内部的自引用指针就永远是安全的。
Rust闭包与其他语言也不同,在Rust中闭包也有一个move关键字。在没有所有权的语言中,闭包对外部变量的捕获一般是全自动且悄无声息的,一律通过引用计数挂在堆上。但Rust的闭包非常节约,能用借用就绝不用所有权。但如果你的闭包生命周期比它捕获的变量还要长,借用就会失效,导致编译报错,这时候你就必须在闭包前加上 move。
而当Rust里的异步和闭包撞上,就会发生巨大的核反应。
如果捕获的变量已经在外面的函数里被销毁(Drop)了,但异步任务还在另一个线程里哼哧哼哧地跑,这时候去访问那个变量,不就成了野指针吗?而Rust设计的目标就是根除空指针的存在,这可怎么办?
为了让异步任务能安全运行,你必须使用move关键字。
#![allow(unused)]
fn main() {
let local_data = String::from("重要数据");
// 加上 move!把 local_data 的所有权彻底剥夺,转移进异步任务内部
tokio::spawn(async move {
// 此时这个数据完全属于这个异步任务了,它爱活多久活多久
println!("{}", local_data);
});
}
回到React闭包地狱的本质,其根源是同一个变量,外面在变,闭包里拿的是旧的,从而产生了冲突,React创造了useEffect。而Rust的move机制直接从物理上消灭了这种冲突的可能性。
但代价就是,一旦被捕获,外面的世界就彻底失去它了。因此如果你既想把数据传给异步任务,又想让外面的代码继续用,该怎么办?只能拷贝。
这就是第一条代价:拷贝地狱。
更进一步,如果数据不仅要被多个异步任务共享,而且这些任务都想修改它,那你必须保证线程安全。
这就是第二条代价:Arc<Mutex<T>>地狱。
缺点二:丧失的多态拓展性
代数数据类型与模式匹配很好的解决了绝大部分OOP继承需要的场景。唯独只有一条没有解决——扩展性。这在计算机科学中被称为“表达式问题”。
以最典型的UI树为例。利用开放多态(继承),可以很好的描述一个UI的树结构,这已经在OOP中得到了大量的应用,我们不再赘述。
在现代的闭合多态语言中,利用代数数据类型与接口,我们也可以实现同样的功能,因此可以写出这样的代码来描述一个树形结构。
#![allow(unused)]
fn main() {
// 用代数类型穷尽节点的所有可能性(闭合多态)
#[derive(Debug)]
pub enum UIWidget {
Button { text: String },
Text { content: String },
Panel { children: Vec<UIWidget> },
}
impl UIWidget {
// 统一的行为方法,内部通过模式匹配(Pattern Matching)分发逻辑
pub fn draw(&self) {
match self {
UIWidget::Button { text } => {
println!(" [渲染按钮]: {}", text);
}
UIWidget::Text { content } => {
println!(" [渲染文本]: {}", content);
}
UIWidget::Panel { children } => {
println!("[开始渲染面板] ->");
// 一视同仁地向下递归遍历
for child in children {
child.draw();
}
println!("<- [面板渲染结束]");
}
}
}
}
}
但是——这里的关键是代数类型是一种封闭的多态,如果要添加新的UI组件,必须更改整个代数类型,这意味着,如果你在写一个通用 UI 框架,麻烦就大了。如果用户想自定义一个自己的组件并塞进框架的容器里,他们根本无法做到!因为他们没有权限去修改框架源码的enum类型,而且增加一个新的类型,意味着要大动干戈地修改原有的枚举定义,并且所有 match 它的地方全都要改一遍。这对框架设计来说是毁灭性的。
有很多框架采用了一种讨巧的方式,比如SwiftUI或者Slint等,将修改结构后需要更改源代码的问题,交给了编译器来自动化工作,让你写起来感觉有开放多态的好处,但是实际上运行时却没有OOP的开销和问题。但这往往导致编译器报错出来的类型会嵌套几十层如同天书,同时也无法良好的支持热重载。
而OOP是开放多态,从不假设数量。这就导致了一个天然的结果——OOP天然就是适合用来组建真正的UI树。很多UI框架早以证实了这一点,Flutter、早期的React(实际上React现在也是,虽然代码里不是,但是JS天生就是弱类型多态的,这只是一种不需要开放多态的幻觉,如果用纯粹、严格的函数式静态语言去写,就会立刻撞上这堵南墙),源代码里都是OOP。
或许你有很多的方法仍然可以绕着来实现这样的开放多态,但是绝对不会优雅,最终你会发现自己绝对又结合类型擦除,重新实现了OOP中的虚表。
或许有朝一日,如果我们能够用另一种继承之外的,更好的方式来实现开放多态,这个问题就能够解决了。
缺点三:复合地狱
最后一点则是函数式编程里臭名昭著的套娃地狱。例如Rust的迭代器不仅链条长,最致命的是Rust编译器的类型推导。这一长串下来,中间任何一步的借用或所有权搞错,编译器就会抛出一个长达两页纸的、夹杂着类似 Filter<Map<Filter<Iter<...>>>> 的超长泛型报错,直接把人看瞎。
#![allow(unused)]
fn main() {
let top_users: Vec<String> = users
.into_iter()
.filter(|u| u.age >= 18)
.map(|u| u.name)
.filter(|name| !name.is_empty())
// 为了去重,得先收集到 HashSet,再转回迭代器(因为迭代器本身没有 dedup)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.take(3)
.collect();
// 哦等等,还要排序?迭代器没有 sort(),又得 collect 出来排,再处理……
}
正如人月神话里说的,软件开发中没有银弹。其实这句话放到编程语言里也是相同的。没有一种编程风格能够解决所有的设计问题。很多人误以为函数式编程比OOP更加优美,是因为他们只看到了高阶函数在字面上的简练。真正的函数式编程之所以敢如此自由传递和嵌套函数,是因为它强制要求函数是“纯”的。但我们在前端、在Rust、在Go里用的,大多是多范式语言而不是真正的纯粹的函数式语言。因此,硬币的另一面就是:自由的代价往往就是混乱。
10.1.7 补充
Lisp是第一门函数式编程语言。Lisp是世界上第二古老的高级语言(仅次于 Fortran),由人工智能之父约翰·麦卡锡(John McCarthy在1958年发明。他的特点是万物皆是列表,万物皆是函数。Lisp是现代所有动态语言(包括 JavaScript)的老祖宗。它是第一个引入闭包(Closure)、高阶函数、垃圾回收(GC)机制的语言。
Haskhell是一门把函数式编程发展到极致的编程语言。是为了实现“绝对纯粹的邱奇理想国”而打造的语言。它是以逻辑学家哈斯凯尔·柯里(Haskell Curry,柯里化Curry思想的提出者)的名字命名的。他的特点是,极端的纯粹与不可变,在 Haskell 里,“可变变量”这个概念是被彻底物理消灭的,它把 = 理解为数学上的等价定义,而不是图灵机的赋值。Haskell 引入了极其强大的静态强类型系统。之前在设计模式里感受到的代数数据类型和模式匹配,其现代工业界的终极源头就是Haskell。
10.2 元编程
要讲元编程,就必须先弄明白什么不是元编程,“元”到底指的是什么。
让我们把思维从日常的写代码里抽离出来,站到一个更高的抽象维度去重新审视,那个你一直在写但从未考虑过的问题:“代码本身到底是什么?”
10.2.1 代码的本质
每天,程序员坐在电脑前敲下 if (user.isAdmin)、list.map(x => x * 2),或者封装一个用户类、写一个数据库连接池,这些通通不是元编程,这些应该叫做,业务编程或者一阶编程。
在这种模式下,代码本身决定的是具体的数据结构与控制流程,而用户输入的数据是被推上流水线的原材料。代码像一个加工厂,日复一日地吞进去数据,吐出来新的数据。
在计算机科学的终极圣殿里,有一条定理,叫做“柯里-霍华德同构”。
它用严密的数学逻辑证明了一件事**:代码的本质,就是数学证明。**这个定理表明,类型本质上就是数学里的命题,函数本质上就是对这个命题的数学证明。
当你写下一个函数 function add(a: number, b: number): number 并让它顺利通过编译时,其实不是在写一段机械的指令,而是在向计算机推导一个数学定理:“已知存在两个数和,我可以通过以下步骤,证明必然能通过函数体内的流程来构造出第三个数。”只要代码能跑通,就意味着你的证明成立,命题就为真。
10.2.2 元编程的本质
还记得我们之前说,希尔伯特的判定问题指的是:存在一个纯粹机械化的算法,只要把任何数学命题丢进去,这个机器就能在有限时间内死板地计算出它是真还是假。而这个机器,其实就是我们天天在用的计算机。
“元”一词在希腊语里有“超越”、“在……之后”的意思,这个词还广泛用在其他领域,例如心理学上有“元认知”,是一个人对自己的认知能力;数据科学里有“元数据”,是用来描述数据的数据,比如描述一副图像的各种参数等等。
现在我们可以指出,我们之前写的业务代码,就是在编写一个命题,写代码的过程就是去证明我们的逻辑和结论为真的过程。而元编程,是去写出一个“能够证明一个证明为真”的程序。在这种情况下,一个证明本身就成为了一个命题。
这么说可能有些拗口,“证明一个证明”是什么意思呢?既然我们说写代码是证明,那么其实简单替换一下就可以得到更容易理解的概念:元编程,是写一段程序,让这个程序输出另一个可运行的、正确的程序的过程。
希尔伯特的判定问题,以及图灵的图灵机停机问题,如果用现代软件工程的语言来翻译,就是“试图寻找一个万能的、全知全能的元编程程序”的问题。
图灵提出,是否存在一个万能的判定程序 ,当你把任意一个程序 和它的输入 传给 时, 能够百分之百准确地告诉你,程序 是会运行结束(停机),还是会陷入死循环?
这个 ,在定义上就是一个地地道道的、最顶级的“元编程程序”,。如果它存在,它就拥有了超越一阶代码的“上帝视角”,能够看穿所有世间代码的宿命。它要吃进去的原材料不是别的,正是另一段程序的源代码。它的加工逻辑是去分析这段源代码的控制流,它的输出是一个布尔值(true 或 false)。
元编程是极其强大的武器,能够轻而易举做到普通编程根本无法做到的事情。在现代语言里,语言本身主要使用反射与宏,来实现元编程。
10.2.3 运行时元编程—反射
反射是很多程序员进阶路上最大的一只拦路虎,甚至是编程生涯的终点。
很多人天天都在用它( JSON 序列化工具、用 Spring 的注解、或者在前端用Vue3/MobX),但如果你问:“到底什么是反射?它反射了什么?”,绝大多数人都会卡壳,往往只觉得它很高级、很神秘,能搞出一些魔法,但底层的逻辑却是一片浆糊。
要弄懂反射,必须先明白这个词是怎么来的。它其实是一个非常精妙的物理隐喻。
在物理世界里,反射是一束光打在镜子上,折射回你的眼睛,让你看清了你自己的模样。
在编程语言里,反射是代码在运行的时候,突然停下来,转过头去对着镜子晃一下,审视自己、观察自己、看清自己的代码结构。这就叫做运行时的“自省”。
所以,反射,就是程序在运行期“照镜子”自审的能力。 它反射的不是别的东西,正是一个程序自身。
说到这里可能还是很抽象,一个程序看自身是什么意思?让我们看的更本质一些。
为了明白为什么需要反射,我们必须回头看看你写下的代码,是怎么编译的。
当你写下这些定义:
class User {
public name: string;
private age: number;
public deleteDatabase() { ... }
}
对于人类来说,这个类结构清晰,有属性、有方法、有私有共有权限。这些定义一目了然。
但是,当编译器把这段代码编译成机器码或者字节码、放到内存里去运行的时候,计算机只认识内存地址和偏移量。在物理内存里,这个 User 对象最后可能被简化成了仅仅是一块 16个字节的连续内存空间。前8个字节存 name 的指针。后8个字节存 age 的数字,你在代码里所有的访问和调用,全都变成了指针和地址。
这时候,如果你突然产生了一个需求:“我想写一个万能的工具,用户传进来任意一个对象,我都能自动把它变成JSON字符串吐出来。”如果没有反射你根本无法做到。因为程序在运行的时候,根本不知道前8个字节的名字叫做什么,后8个字节的名字叫做什么。
为了解决这个痛点,现代支持反射的语言(Java、Go、C#、JavaScript)的编译器,做了一件额外的事情。它们在编译和加载代码时,没有把这些类型和字段数据删除,而是偷偷藏了一份每个函数和对象的字段和参数记录。
以TS为例,当你启用编译器的反射功能,并且引入反射库。
{
"compilerOptions": {
"experimentalDecorators": true, // 开启装饰器
"emitDecoratorMetadata": true // 核心:允许编译器在运行时吐出类型元数据
}
}
import "reflect-metadata";
假如你拥有一个巨大的Map,其中key是类型,而value是对应的数据。
// 假设这是我们的大 Map,里面存着系统运行所需的各种基础服务
const container = new Map<any, any>();
// 往大Map里塞点原材料
class ApiService {
fetchData() { return "从服务器捞出的核心数据"; }
}
class LoggerService {
log(msg: string) { console.log(`[日志]: ${msg}`); }
}
container.set(ApiService, new ApiService());
container.set(LoggerService, new LoggerService());
TS有个设定,只有被装饰器修饰的类或方法,编译器才会配合地保存这个类或方法的参数和类型信息。所以我们先写一个最简单的装饰器,哪怕它什么都不干。
// 这是一个空壳装饰器,单纯为了告诉 TS 编译器
function Injectable(): ClassDecorator {
return (target) => {
};
}
现在,我们要写一个万能的自动实例化函数 resolve。它会去照镜子(反射),看看目标类需要什么参数,然后从大 Map 里捞出来塞进去。这里的关键是,由于在之前引入了import "reflect-metadata";,这个库会在全局的Reflect对象上添加一个getMetadata方法,其本质其实是一个巨大的WeakMap。利用这个方法,你可以获得一个含有当前对象元数据的数组。
function resolve<T>(target: new (...args: any[]) => T): T {
// 核心反射:向 Reflect 索要这个类的构造函数参数类型图纸
// "design:paramtypes" 是TS内置的key,用来获取参数的类型数组
const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
console.log(`[反射自审]: 发现类 ${target.name} 的构造函数需要 ${paramTypes.length} 个参数。`);
// 打印出来的 paramTypes 看起来像这样:[ [class ApiService], [class LoggerService] ]
paramTypes.forEach((type, index) => {
console.log(` 参数 ${index + 1} 的类型是: ${type.name}`);
});
// 现在你可以根据类型参数,从刚才的大Map里捞出对应的依赖
const dependencies = paramTypes.map(type => {
const dependency = container.get(type);
if (!dependency) {
throw new Error(`找不到依赖的类型: ${type.name},请先塞进大 Map 中!`);
}
return dependency;
});
// 把捞出来的参数作为构造函数参数塞进去,返回新实例
return new target(...dependencies);
}
现在我们写一个业务类 UserController,它假装自己不知道参数怎么来,只管伸手要:
@Injectable() // 打上标记,让TS编译器保存参数
class UserController {
// 声明我需要 ApiService 和 LoggerService,参数数量:2
constructor(
private api: ApiService,
private logger: LoggerService
) {}
public handleRequest() {
this.logger.log("开始处理用户请求...");
const data = this.api.fetchData();
console.log(`[控制器响应]: ${data}`);
}
}
// 我们不需要手动 new UserController(new ApiService(), new LoggerService()) 了
// 直接交给 resolve 函数,它会通过反射自动组装
const controller = resolve(UserController);
controller.handleRequest();
这个例子,本质上就是我们在创建型模式里讨论的依赖注入的最简单实现。在其他语言里,比如Java,Go,同样也需要用到反射,才能够实现依赖注入。这是因为依赖注入必须有一个前提,必须能够让程序知道“这个类或者函数接受几个参数,每个参数的类型是什么”。要知道这些事情,就必须使用反射。
但是,你发现他的代价了吗?
第一,它是不纯粹的。JS语言本身根本不支持这种类型的反射,我们必须在语言外层加一套特殊的编译器开关(emitDecoratorMetadata)并引入一个reflect-metadata库。
第二,它必须依赖装饰器(@Injectable)这个语法。如果程序员忘记在类名上加这行注解,反射机制当场就会变成瞎子,什么参数也捞不出来,在运行时直接导致程序崩溃。
这种在运行时“编译时留额外信息、运行时再翻来翻去查找”的方案,正是元编程在运行时流派的工程妥协。这也正是为什么,更硬核的现代语言,会抛弃运行时的反射,直接转入编译期的阵营。
10.2.4 编译时元编程—宏
宏的发展史是一个极其精彩的屌丝逆袭故事,它一开始根本不是什么高大上的元编程神器,而是一个纯粹的打字机语法糖。
宏最早可以追溯到汇编语言时代和C语言时代(#define)。那时候,由于硬件内存极小,连函数的调用开销(压栈、出栈、保护现场)程序员都斤斤计较。于是,大家想:能不能发明一个东西,让我少写点重复代码,但在编译前自动帮我把代码复制粘贴过去。
这就是文本替换宏。在这个阶段,宏根本没有智商,它不认识什么是类型,不认识什么是语法,它就是一个纯粹的字符串替换工具。因为这种呆板设计,早期的宏制造了无数难以调试的Bug,被现代面向对象语言(Java、C#)视作毒瘤,直接在语言设计中被彻底开除了。
然而,当程序员陷入了“运行时的无限套娃”和“反射的沉重代价”中感到毫无办法时,宏最终又被拾起来重新发现,并且在系统级语言里完成了屌丝逆袭。
现代的宏(以 Rust 的过程宏 为代表)彻底进行了全面升级,它不再是盲目的文本替换了,而是直接跟编译器深度合体,直接获得了接触抽象语法树 的最高权限,变成了指挥编译器的工具。
在动态类型和Java或者C#这种字节码语言里主要依靠反射,而宏主要在真正的硬核编译型语言里大行其道,并且分为好几个流派。
流派 A:Rust的“编译魔法”——过程宏
这个流派的观点非常直白粗暴简单有效,既然元编程是改写代码,那就干脆在编译器内部开一个后门。
当你写下#[derive(Serialize)] 时,编译器在编译你的核心代码之前,会先去跑一段由你编写的元编程程序。这段程序吃进去的是你当前结构体的Token流,在内存里重组语法树,然后吐出全新的Rust代码。
这种流派是纯粹的语言内解法,非常安全,类型系统和生命周期检查全部无缝覆盖。但代价是编译速度极慢,因为你编译一个程序,实际上等于先编译并运行了十几个“生成代码的程序”。
流派 B:UE5(虚幻引擎)的“重工业魔改”——外部编译器
C++天生是没有现代反射和元数据的,但在游戏开发中,对象的序列化、GC、蓝图与C++的动态绑定又必须依赖反射。Epic官方一看,指望C++标准委员会扯皮完(C++标准委员会是这样的),黄花菜都凉了,于是他们采取了最暴力的重工业手段:不和委员会玩了,自己给 C++ 编译器写个外挂壳子。
这就是UHT(虚幻请求头工具)。当你在UE5里写下 UCLASS()、UPROPERTY() 时,原生的C++编译器根本不认识这些宏。在真正的C++编译器启动之前,UE的UHT会先跑一遍,把你的整个 C++ 源码文件肉眼扫描一遍,强行解析出你的类结构。UHT会在幕后自动生成一堆巨庞大的、极其恶心的 .generated.h 和 .gen.cpp 文件,帮你在C++里强行手写出了一套元数据。最后,再把这些生成的代码和你的源代码一起喂给标准的C++编译器。
这种流派直接降维打击,在不支持反射的语言里,硬生生用重型工程手段造出了全套反射基础设施。但是极度依赖虚幻自己的构建系统,编译链条极长,一旦代码写错,引发的报错信息简直是天书。
流派 C:Zig语言的“超级杀招”——comptime关键字
Zig 语言走了一条让所有人惊掉下巴的纯粹路线。不发明宏也不发明外部工具,而是在编译期直接运行正儿八经的Zig代码。这就是Zig被无数人赞扬的超级大招——comptime 关键字。
在Zig里,你想写一个万能的、支持任何类型的泛型容器,不需要写复杂的泛型语法或宏,你直接在编译期写常规代码。直接把类型当作变量给当作函数的输出给返回出去。
// 这是一个函数,输入是一个类型,输出是一个结构体新类型!
fn List(comptime T: type) type {
return struct {
items: []T,
len: usize,
};
}
// 使用时:
const IntList = List(i32); // 在编译期把 i32 类型传进去,直接生成一个新的结构体!
在Zig语言里,类型天生就是编译期的一等公民。你可以像操作普通的数字、字符串一样,在编译期用 if-else、for 循环去组装这些类型。它不需要任何特殊的宏语法,日常怎么写业务逻辑,就怎么写元编程。编译器在编译时,会把标有 comptime 的代码直接在内嵌的解释器里跑完,把结果固化成极其干净、零开销的机器码吐出来。
流派 D:现代语言的“隐藏元编程”——泛型
泛型在计算机科学的严谨定义下,其实也是“元编程”最基础、最优雅的一种形态。
当你编写fn swap<T>(a: &mut T, b: &mut T)这样的函数时,你并没有编写一段具体的机器码。你是在编写“编写代码的逻辑”。所以,泛型本质上就是一个“代码生成器”。编译器替你完成了繁琐的“代码复制粘贴”,这就是最高效、最安全、最易用的元编程。Zig语言其实就是把这种“泛型即元编程”的本质给彻底释放出来,并且把泛型的地位进一步提升了。
10.2.5 成也萧何,败也萧何
在前面我们说到了zig语言最神奇的地方,把类型当作可以运算的变量来在编译器期间运行生成结果,看似一切都很美,那么代价是什么?
让我们来抛开现象看本质,zig语言的comptime到底是什么?
在话题函数式编程中我们长篇铺叙了图灵机与 演算的历史,不知道你是否能发现一些端倪。
当我们写下泛型HsahMap<String,Vec<Vec<usize>>>时,其实这就是最纯正的 演算,类型就是我们的输入,泛型的嵌套就是函数的嵌套。
因此Rust社区才能利用泛型构造出泛型常量,当你在nalgebra库中使用U2,U3这样的泛型常量时,其实就是在用 演算去模拟真正的次数计算。
那么,zig语言的comptime是什么?
显然,他就是一个完整的用来计算类型的图灵机,具备了图灵机便于使用简单直观的优点的同时,也背负了图灵机所有的缺点。
- 图灵停机问题:图灵证明了,对于一个程序,不存在任何一个程序只需要看源代码,就能够推导出这个程序是否会在有限时间内终止。这里的程序,就是Zig编译器本身。换句话说,对于
comptime中的内容,Zig编译器永远也不能希望靠纯粹的代码检查就能判断comptime中的内容会不会卡死编译器,这对Zig语言的语言服务器提出了巨大的挑战。 - 复杂度爆炸:自由是混乱的源泉。相比 演算,图灵机的灵活性实在是太大了,一旦不加任何对于
comptime的限制,comptime中的内容最终必将会成长为谁也看不懂的混沌巨兽。 - 并行:当
comptime碰上并行,所有之前在图灵机中的出现的竟态问题,都会重新以更复杂的状态出现在comptime中。
解决方法也很简单,必须对自由的comptime加上严格的函数式限制,例如不可变量、纯函数式编程….
comptime的特性使得Zig的编译器和语言服务器复杂度暴增,Zig必须在正式编译之前,把所有的comptime运行一遍才能知道真正的结果,这就意味着Zig编译器必须内置一个微型解释器来运行这些代码。同时,这也意味着这对于类型检查、代码补全、代码跳转等功能是毁灭性打击。
成也萧何,败也萧何。Zig语言的未来还长路漫漫。
10.2.6 最大的元编程程序—编译器
其实某种意义上来说,编译器,就是最大的元编程程序。
在前面我们说,元编程,是写一段程序,让这个程序输出另一个可运行的、正确的程序的过程。那么按照这个标准来看,我们前面讨论的方法,都局限在这两个程序都是一种语言上。但是只要我们允许这两个程序可以不是一个语言,那么我们实际上就可以顺理成章得到一个结论——编译器,就是最大的元编程程序。
我们在前端创造了各种DSL语言,JSX也好Vue也罢, JSX和Svelte的编译器,本质上就是一个“语法翻译层面的元编程”。当你写下 <div/> 时,你不是在写JS,你是在写一种元编程语言。编译器这个最大的元编程程序,在构建阶段接管了你的代码,它把你的语义描述通过AST重写,转换成了指令式的、低层级的DOM操作,让你能够一行代码顶原来的十行代码。
一个编译器的里程碑事件,就是让这个语言的编译器,也是用这个编程语言写出来的。在计算机里这叫做自举。这就是元编程的闭环。在这个闭环里,编程语言变成了一种能够自我复制、自我进化、自我增殖的生命体。
但是,这种进化是永无休止的吗?
显然,图灵和邱奇已经证明了,编译器不是万能的,不管如何进化,也不可能进化出理想的终点——能够解释万物终极真理判别器。那么,如果我们彻底超越编程与代码的边界,去看看那个真正孕育了计算机的母体呢?
10.3 篇外:超越编程
让我们把话题推向逻辑和理性终点,在编程之外,超越编程之外的世界是什么样的?
让我们把视野拉的更高一些,当程序消失,当计算机消失,甚至连物理世界也消失,我们还剩下什么?
10.3.1 符号主义
早在 17 世纪,戈特弗里德·威廉·莱布尼茨(Gottfried Wilhelm Leibniz)就提出了一个想法:能不能发明一种通用特性语言,把人类世界所有的思想、争论、逻辑,全部简化为纯粹的符号和算术?这样未来如果两个人发生争论,他们不需要面红耳赤地吵架,只需要拿出纸和笔,说一句:“让我们来算一算!”,通过符号推导就能得出谁是对的。
这就是符号主义创世起点。莱布尼茨在三百年前,就已经在脑海中构思了“逻辑代数”的蓝图。在他眼里,思考就是计算,理性就是符号的重组。
顺着莱布尼茨的火种,历史走到了 20 世纪初。逻辑学家们不满足于仅仅把符号当成工具,他们发起了一场更加激进的圣战:证明整个数学,本质上都只是逻辑学的一个分支。
罗素和怀特海为了实现莱布尼茨的梦想,硬生生写出了三大卷、重达数公斤的宏篇巨著——《数学原理》。这本书枯燥、严密、纯粹到了非人的地步。在这套完全由符号构筑的冰冷世界里,他们花了整整 362页的符号推导,才终于在数学上严格地证明了一个在我们看来是废话的命题:。罗素定义了什么是0,什么是1,什么是加法,然后用极其严密的逻辑符号去证明和定义了2。
为什么当年那些大哲学家要搞这么复杂来证明看似这么简单的问题?
因为越是简单,反而越是困难。
罗素搞得这么复杂,就是为了向全世界证明:不需要依赖任何关于数字的直觉,我只用纯粹的逻辑概念(集合、属于、并集),就能凭空推导整个数学。
那么,到底什么是符号?什么又是符号主义?人类为什么非要用符号不可?
在普通人眼里,符号只是键盘上的 + - * / 或者代码里的字母。但在哲学和数学的底层,符号是人类用来驯服无限宇宙的唯一武器。
在逻辑哲学里,符号的本质,是人类理性对现实世界进行的一种“极高层次的抽象”。
现实世界是极其沉重、复杂且充满杂质的。比如,你眼前有“两头健壮的牛”,有“两个红苹果”,还有“两颗正在坍缩的恒星”。物理世界里的牛会生病,苹果会腐烂,恒星会熄灭,它们太重了,人类的大脑根本无法直接把它们塞进思维里去运算。于是,人类发明了符号。 人类把牛、苹果、恒星里那个共同的、抽象的本质抽出来,脱掉所有的水分和实体,变成了一个毫无具体意义的符号——2。
有了符号,就诞生了符号主义,它的底层核心假设只有一句话:“人类的认知本质上就是计算,而计算本质上就是符号的操纵。”
符号主义坚信,宇宙的底层逻辑是一套巨大的、由公理驱动的形式系统。在这个系统里,不管是数学(如皮亚诺的后继符号)、逻辑(如真与假),还是人类的思想、语言,都可以被彻底拆解为离散的符号。你不需要去物理世界里看牛和苹果。你只需要制定一套严格的符号规则。只要你按照规则,把符号 变成符号 ,再变成符号 ,只要推导过程不违背规则,你在纸面上演算出来的结论,就必定是物理世界的终极真理。
我们为什么要用符号?因为符号是人类跨越心智带宽限制来认识和改造世界的工程手段。
自然语言是充满二义性和模糊性的。如果用自然语言去推导真理,逻辑链条稍微拉长,就会因为语义的妥协而彻底崩塌。罗素的著作《逻辑与知识》,就是在研究如何用一套严密的符号去创造一门精确的语言。
一旦现实世界被符号化,真理的推导就变成了一套刻板的、机械的符号替换游戏。这时候,思考这个动作,就被彻底降维降格成了体力劳动。 既然是体力劳动,人类就可以把这个开销转嫁出去——在三百年前转嫁给纸笔,在今天转嫁给图灵机,也就是计算机。
人类的寿命和肉眼是有限的,但数学和宇宙是无限的。你怎么去证明“所有自然数都满足某个规律”?你不可能把无限个数挨个试一遍。 但符号主义可以通过数学归纳法(皮亚诺第五公理),用区区几行符号的推导,把横跨到宇宙终点的无限个数字,一网打尽。
可以说,不管是数学家写下的每一行公式和证明,还是程序员写下的每一行代码,每天面对的,就是这样的一个由各种符号组成的世界。
10.3.2 逻辑哲学
既然有了符号,我们开始玩起了符号的游戏,那么在这场游戏里,我们天天挂在嘴边的逻辑,本质到底是什么?
在这里,我们直接请出 20 世纪最伟大的天才哲学家——路德维希·维特根斯坦(Ludwig Wittgenstein)。
很多人(包括很多程序员)觉得逻辑是人类大脑里“正确思考的指南手册”。
但维特根斯坦在全书的开篇,就直接颠覆了这种唯心主义的幻觉。
-
世界是所有发生的事情。
-
世界是事实的总和,而不是事物的总和。
什么叫“世界不是事物的总和,而是事实的总和”?在计算机和现实世界里,根本没有孤立存在的“苹果”、“内存”或“用户”(这些只是事物)。世界是由“苹果在桌子上”、“指针指向了0x001”、“变量 x 等于 1”这些事实构成的。
那么,逻辑是什么?维特根斯坦给出了如同神谕一般的定义:
- 我们在语言中不能表达任何违反逻辑的东西,就像在几何学中不能用坐标来表现一个违反空间定律的图形,或者给出一个不可能的点的坐标一样。
- 逻辑充满了世界:世界的边界也就是逻辑的边界。
逻辑,本质上根本不是什么高深的思想工具。
逻辑就是允许事实能够发生、能够被组合、被陈列的“底层网格”,是世界的“脚手架”。如果没有逻辑这个底层脚手架,世界上的事实根本无处挂载,就像没有内存的网格和语法规则,代码里的变量和数据流根本无法存在一样。
既然逻辑是世界的脚手架,那么逻辑命题的底层真相是什么?
-
逻辑的命题都是同义反复。
-
因此逻辑的命题什么也没有说。
什么叫“同义反复”、“什么也没有说”?
举个例子:
- 命题 A:“外面正在下雨。”(这句话传达了新信息,它可能是真,可能是假。)
- 命题 B:“外面要么在下雨,要么没在下雨。”(这句话在逻辑上永远正确,是个真理。)
但维特根斯坦说:命题 B 作为一句绝对正确的逻辑真理,它告诉你任何关于天气的新知识了吗?没有。它在物理上一文不值。
这就是我们写代码的真相。
我们在代码的世界里写下了成千上万行严密的逻辑推导,我们用泛型和控制流卡死了每一种可能。编译通过的那一刻,我们欢呼雀跃,觉得自己创造了真理。但维特根斯坦告诉我们:**你的代码什么也没有说,它只是一场宏大的、在编译期完成的“同义反复”。**喂给编译器的输入,和编译器吐出来的输出,在逻辑的物理天平上,重量是完全相等的。逻辑没有创造任何新东西,它只是用一种绝对严密的符号游戏,保证了“输入”到“输出”的等价交换。逻辑的本质,就是一场绝对不会出错的、巨大的符号变换。
程序员们每天打交道的代码(if-else、变量名、类名),本质上就是这套玩了三百年的“符号脱水游戏”。用符号把沉重的现实业务脱水成轻量级的代码,在计算机里疯狂运算。
10.3.4 超越编程
从莱布尼茨的通用语言,到罗素362页的符号圣战,再到维特根斯坦给世界布下的逻辑网格,人类用三百年的时间,把现实世界脱水成了纯粹的符号与逻辑。
现代程序员,其实正是这门最古老学派的事实缔造者。我们用代码在物理的硅片上,用符号和逻辑,筑起了人类历史上最庞大、最精密的逻辑巴别塔。
但维特根斯坦在理性的终点拉住了我们。他告诉我们:
-
对语言的边界,就是世界的边界。
-
对于不可言说之物,必须保持沉默
符号和逻辑能捕获的,仅仅是那个被“脱水”的事实世界。而超越编程之外的——那些关于生命的痛苦、美、爱、以及存在本身的意义,它们无法被写进 if-else,无法被封装进类与函数,它们属于那片“不可言说的领域”。
沃尔玛你程序员们用尽一生的逻辑去编写程序,去穷尽这个符号世界的所有可能。而当我们终于合上电脑、抬起头,望向窗外真实、充满不确定性的复杂世界时,才会发现超越编程之外的,是那些代码无法触及的生命色彩,对于这些我们能做的,只有带着最深的敬畏,保持沉默。
第 11 章:反射-P2
11.1 Bevy中的反射
在前面我们说到,元编程的实现类型多种多样,有运行时反射、编译期宏、还有UE5那样的外挂反射和Zig这种独树一帜的comptime关键字等等。那么Bevy里主要利用了什么?
答案是前两种。Bevy即为我们提供了运行时反射的能力,又利用宏和Rust强大的泛型能力实现了编译期单态化与静态分发。其中前者由bevy_reflect这个crate提供,后者则主要在bevy_ecs中实现。
Note
想一想,为什么bevy要把他们分开,分别使用运行时反射和编译期宏?(提示:运行时反射的优点和代价是什么)
首先让我们从比较容易理解的运行时反射来研究一下, bevy_reflect该如何使用?他的内部工作原理又是什么?
11.2 基本概念
11.2.1 Reflect与PartialReflect
bevy_reflect的功能建立在两个trait上,即Reflect和PartialReflect,并且利用类型擦除来存储实现了这些trait并且需要反射的数据,要认识反射就必须先搞懂这两个trait到底是用来做什么的。
看到这两个名字你可能会想到Rust标准库中 Eq 与 PartialEq(以及 Ord 与 PartialOrd),Partial 这个前缀代表着“弱化版的约束”或“不完全的语义”。显然在这里Bevy借鉴了Rust标准库中的这些命名,仅仅是通过这些名字和Rust的传统我们就能意识到这样一件事:任何一个实现了Reflect的类型肯定也实现了PartialReflect
为什么要区分二者呢?这是因为在编写游戏时,经常会遇到这样的一个需求:需要根据配置文件(可能是json或者任何序列化的文本)在游戏中生成一个合适的实体或者结构体以供后面的代码使用。然而,Rust是一门编译语言,所有类型的结构体已经在编译期完全确定了,所以你不可能在不重新编译的前提下,真的生成一个全新的结构体定义并实例化。
为了能够用代码动态捏造出一个原本在Rust代码里根本不存在的结构体,这时候你就必须拥有一个能够动态创建并任意添加和修改的某种东西,而PartialReflect的Partial含义即在此,一个能够动态捏造的假类型。
Reflect与PartialReflect的区别关键就在于此,Reflect代表的是真实Rust类型,你可以把一个dyn Reflect 转回(Downcast)原始的真实的结构体,而PartialReflect对应的结构并不一定在Rust中拥有真实的定义。
Note
在以前的早期版本中,其实只有
Reflect并没有PartialReflect,当时遇到的问题是:当你从JSON反序列化或者用代码动态创建一个结构体时,它本质上只是个HashMap。如果强行让它实现Reflect,当你尝试把这个HashMap转换成真正的struct时就会崩溃。
顺便一提,你还可以在二者之间进行来回的转换,二者的转换关系如下,不言而喻,把一个Reflect转换成PartialReflect是没问题的,但是反过来就有可能失败。
-
PartialReflect::as_partial_reflectfor&dyn PartialReflect -
PartialReflect::as_partial_reflect_mutfor&mut dyn PartialReflect -
PartialReflect::into_partial_reflectforBox<dyn PartialReflect> -
PartialReflect::try_as_reflectfor&dyn Reflect -
PartialReflect::try_as_reflect_mutfor&mut dyn Reflect -
PartialReflect::try_into_reflectforBox<dyn Reflect>
11.2.2 #[derive(Reflect)]
为一个结构体或枚举等实现 Reflect 非常简单,直接使用派生宏即可。
#![allow(unused)]
fn main() {
use bevy::reflect::Reflect;
#[derive(Reflect)]
struct MyStruct {
foo: i32
}
}
当你给一个 struct 或 enum 打上 #[derive(Reflect)] 时,它不仅会自动实现 Reflect 和 PartialReflect,还会暗中为你自动实现另外几个底层反射trait。
-
GetTypeRegistration:允许类型将自己注册到 Bevy 的类型注册表(TypeRegistry)中。 -
Typed:提供该类型的编译期与运行时元数据(如类型名称、路径、包含哪些字段等)。 -
根据你的类型形态,自动实现
Struct、TupleStruct或Enum。
并不是所有Rust类型都能直接 #[derive(Reflect)],必须满足以下条件。
-
类型必须同时实现
Any + Send + Sync。类型本身不能包含非静态的引用(例如不能包含&'a str,必须是String)。这是因为反射系统需要在运行时动态识别类型,不能有随时会失效的临时引用。 -
结构体或枚举里的每一个字段(和子元素),本身也必须实现了
Reflect。如果某个字段无法或不需要反射(比如第三方库的复杂类型、指针等),可以在字段上加#[reflect(ignore)]忽略该字段。 -
如果是在枚举(
enum)上使用#[derive(Reflect)],所有字段和子元素必须实现FromReflect。这是因为枚举在 Rust中占用的内存大小取决于最大变体,且带有辨识 Tag。动态构建或还原一个枚举变体时,必须能够把反射数据完全还原为真实的Rust值,因此强制要求FromReflect。
在第二条前面我们提了一嘴#[reflect(ignore)],#[reflect(...)]可以作用整个结构体或枚举的顶部,也可以只作用于某些特殊的字段上,在这里我们主要只列举出几个用法并在后文详细给出用法,至于全部的用法读者可以查看文档。
#[reflect(TypeData)]。例如#[reflect(Debug, PartialEq)],会把该类型的ReflectDebug和ReflectPartialEq注册到元数据中。这常配合#[reflect_trait]宏使用,让系统可以在只有dyn Reflect的情况下调用具体trait的方法。(在后面讲到反射如何配合trait使用时我们将再次回顾这件事)#[reflect(@expr)]。注入自定义元数据。接受@符号后的任何表达式,该表达式解析为实现该表达式的值Reflect。如果注册了两个相同类型的属性,则后注册的属性会覆盖前者。
11.3 反射用例
现在,让我们来真正看看到底该如何真正的使用bevy_reflect吧。
11.3.1 结构体/枚举/元组反射
首先,让我们来看看如何利用反射来给结构体和枚举等添加一些自定义属性,利用这个功能我们可以方便的添加一些附加元数据在结构体上,观看以下示例:
#![allow(unused)]
fn main() {
#[derive(Reflect)]
struct Slider {
#[reflect(@RangeInclusive::<f32>::new(0.0, 1.0))]
// 使用#[reflect(@expr)]属性注入元数据
#[reflect(@0.0..=1.0_f32)]
value: f32,
}
// 获得反射的编译时类型信息
let TypeInfo::Struct(type_info) = Slider::type_info() else {
panic!("expected struct");
};
// 获得对应的字段
let field = type_info.field("value").unwrap();
// 将我们前面存进去的元数据信息重新拿出来
let range = field.get_attribute::<RangeInclusive<f32>>().unwrap();
assert_eq!(*range, 0.0..=1.0);
}
对于枚举,我们可以这样使用#[reflect(@expr)],将一些额外的结构体元数据添加到对应的枚举上,不过要记住,这些结构体元数据也必须是#[derive(Reflect)]的。
#![allow(unused)]
fn main() {
// 可以是实现Reflect的任何类型:
#[derive(Reflect)]
struct Required;
#[derive(Reflect, PartialEq, Debug)]
struct Tooltip(String);
impl Tooltip {
fn new(text: &str) -> Self {
Self(text.to_string())
}
}
#[derive(Reflect)]
#[reflect(@Required, @Tooltip::new("An ID is required!"))]
struct Id(u8);
let TypeInfo::TupleStruct(type_info) = Id::type_info() else {
panic!("expected struct");
};
// 可以检查该结构体上是否拥有该类型的元数据属性信息
assert!(type_info.has_attribute::<Required>());
// 动态的获得相应的id
let some_type_id = TypeId::of::<Tooltip>();
let tooltip: &dyn Reflect = type_info.get_attribute_by_id(some_type_id).unwrap();
assert_eq!(
// 这里我们尝试直接下转为真正的类型
tooltip.downcast_ref::<Tooltip>(),
Some(&Tooltip::new("An ID is required!"))
);
}
另外,让我们再补充一下,所谓的TypeInfo和TypeId到底是什么?
对于TypeInfo,这是bevy_reflect里用于标识元数据属性信息的一个枚举,就类似于之前我们看到的ReflectRef那样,让我们能够将一个类型的元数据属性信息转换成正确的形式。
#![allow(unused)]
fn main() {
pub enum TypeInfo {
Struct(StructInfo),
TupleStruct(TupleStructInfo),
Tuple(TupleInfo),
List(ListInfo),
Array(ArrayInfo),
Map(MapInfo),
Set(SetInfo),
Enum(EnumInfo),
Opaque(OpaqueInfo),
}
}
对于任何给定的类型,我们可以通过以下四种方式之一检索此值。在前面我们正是使用了第一种方式来获得相应的TypeInfo,同样如果我们拥有TypeRegistry或对应实例,也可以获得相应的元数据属性。
而TypeId 是Rust标准库提供的一个极其底层的机制,大多数时候你都不用接触他,但一旦要用到它时就变得非常重要。简而言之,TypeId 就是Rust编译器在编译期为每一个类型生成的唯一身份证号(u128类型)。比如在Bevy中,所有的Component都存放在World里。World 底层的哈希表就是以TypeId作为Key的。
而上面的 tooltip.downcast_ref::<Tooltip>()这一行代码,实际上就是在比较泛型参数 Tooltip 的 TypeId::of::<Tooltip>()和内部的Id是否一致。
Caution
如果你有 C++ Qt 的开发经验,看到这里一定会有强烈的既视感。Qt 为了在静态的 C++ 中实现 UI 编辑器绑定和 QML 交互,打造了 MOC 与
Q_PROPERTY宏,如果你仔细看看,你会发现就前面我们讨论的内容而言,两者的核心逻辑完全一致:
- 代码生成:利用外部工具(Qt 的
moc)或编译器宏(Bevy 的#[derive(Reflect)])在编译期提取类型元信息。- 标签注入:通过属性注解(Qt 的
Q_PROPERTY/ Bevy 的#[reflect(@expr)])为字段挂载额外的运行时属性。- 动态驱动 UI:编辑器在运行时通过查询元数据中心(
QMetaObject/TypeRegistry),动态决定如何渲染控件(例如自动将带有范围元数据的f32渲染为 UI 滑动条)。这种跨越语言和年代的设计巧合,恰恰证明了在强类型静态语言中构建现代游戏引擎/GUI 框架时,元数据反射机制是通往动态编辑能力的必然选择。
前面我们一直在研究的事情是如何给一个 struct贴上元数据并获取。现在,我们来研究反射最核心、最基本的功能:如何在运行时通过字符串或索引,动态地访问、修改甚至凭空捏造数据的属性。
在正常的Rust代码中,当你实例化一个结构体let player: Player = Player { id: 123 };时,编译器在编译期就已经明确知道它的内存布局和类型大小。我们称之为具体类型。
但在反射系统中,为了能把不同的类型统一存放在同一个容器或函数参数里,我们需要通过trait对象擦除类型信息,因此首先,我们必须将其转换为一个Box。
#![allow(unused)]
fn main() {
let reflected: Box<dyn Reflect> = Box::new(player);
assert!(reflected.downcast_ref::<Player>().is_some());
// 利用Reflect上的方法,你甚至可以直接clone而不需要转为具体的类型
let cloned: Box<dyn Reflect> = reflected.reflect_clone().unwrap();
assert_eq!(cloned.downcast_ref::<Player>(), Some(&Player { id: 123 }));
}
在前面我们说,Reflect宏会根据你的类型形态,自动实现Struct、TupleStruct或 Enum。
这里说的Struct和其他的到底是什么呢?这是bevy_reflect中的一个用来代表数据类型的Trait(“继承”自PartialReflect)。通过将某些类型的特定操作包装成一个Trait,这样就可以将一个PartialReflect当作相应的Rust中的原生类型并提供一些特定的方法,这样就不用在通用的PartialReflect进行很多危险的操作了。
具体而言,我们拥有如下的这些trait,他们均“继承”自PartialReflect。
例如Struct这个trait看起来就是这样,可以看到这里拥有很多struct上才有意义的方法,比如使用字符串访问字段等等,这些在元组或枚举上是无效的操作。
#![allow(unused)]
fn main() {
pub trait Struct: PartialReflect {
// Required methods
fn field(&self, name: &str) -> Option<&(dyn PartialReflect + 'static)>;
fn field_mut(
&mut self,
name: &str,
) -> Option<&mut (dyn PartialReflect + 'static)>;
fn field_at(&self, index: usize) -> Option<&(dyn PartialReflect + 'static)>;
fn field_at_mut(
&mut self,
index: usize,
) -> Option<&mut (dyn PartialReflect + 'static)>;
fn name_at(&self, index: usize) -> Option<&str>;
fn index_of_name(&self, name: &str) -> Option<usize>;
fn field_len(&self) -> usize;
fn iter_fields(&self) -> FieldIter<'_> ⓘ;
// Provided methods
fn to_dynamic_struct(&self) -> DynamicStruct { ... }
fn get_represented_struct_info(&self) -> Option<&'static StructInfo> { ... }
}
}
通过dyn PartialReflect对象上的reflect_ref或者reflect_mut方法,我们就可以得到这些对应的值,这些方法返回一个ReflectRef类型的枚举,包含了一个对应的Trait对象。
#![allow(unused)]
fn main() {
// 每个变体都包含一个trait对象,该特性对象具有特定于某种类型的方法。
// 通过以下方式获得PartialReflect::reflect_ref(返回不可变引用)/PartialReflect::reflect_mut(返回可变引用)。
pub enum ReflectRef<'a> {
Struct(&'a dyn Struct),
TupleStruct(&'a dyn TupleStruct),
Tuple(&'a dyn Tuple),
List(&'a dyn List),
Array(&'a dyn Array),
Map(&'a dyn Map),
Set(&'a dyn Set),
Enum(&'a dyn Enum),
#[cfg(feature = "functions")]
Function(&'a dyn Function),
Opaque(&'a dyn PartialReflect),
}
}
根据我们上面的讨论,为了访问这个Box<dyn Reflect>中的字段,我们可以将其转换为一个dyn Struct。(按照我们前面介绍的,不可变引用是reflect_ref,可变引用时调用reflect_mut),这些代码看起来就像下面这样。
#![allow(unused)]
fn main() {
let value = reflected.reflect_ref(); // value这时是一个ReflectRef类型
let value = value.as_struct().unwrap(); // value这时是 dyn Struct
let id = value.field("id").unwrap().try_downcast_ref::<u32>();
assert_eq!(id, Some(&123));
}
但是,我们目前仍面临一个难题,dyn Struct上并没有类似的任何insert之类的方法,那么我们该如何添加自己的字段呢?
首先回忆一下,还记得我们前面讨论的关于Reflect和PartialReflect的关系吗?为了可以在其之上添加本来并不存在的字段,我们必须以某种方式把一个 dyn Reflect 转换成dyn PartialReflect,这时我们就创造了一个动态类型。
通过这里是to_dynamic方法,我们可以把一个Box<dyn Reflect>转换为一个 Box<dyn PartialReflect>,但是,这远不是简单的转换,他不同于as_partial_reflect,这时我们的这个对象现在变成了“动态的”。
#![allow(unused)]
fn main() {
// 注意⚠️,这里不是PartialReflect::as_partial_reflect,这里是to_dynamic方法
let mut dynamic: Box<dyn PartialReflect> = reflected.to_dynamic().unwrap();
assert!(dynamic.is_dynamic());
// 一旦转换为了动态类型,就无法在变回reflect了
assert!(dynamic.try_as_reflect().is_none());
}
这里的关键是,为什么需要动态类型?他内部又是怎样工作的?
前面我们在pub trait Struct: PartialReflect中并没有看到任何能够添加或者删除字段的方法,但有一个方法格外的显眼:fn to_dynamic_struct(&self) -> DynamicStruct { ... }。
这说明,bevy_reflect其实为前面我们说的每一种类型的trait都实现了对应的动态类型,所以自然而然的,我们拥有下面这些:
通过获得这些动态类型,我们就可以真正的进行 insert 、remove_at、 remove_by_name等操纵结构体的方法了。
#![allow(unused)]
fn main() {
// 通过这个动态类型代理,我们可以动态地读取字段
// dyn Struct上有一个fn to_dynamic_struct(&self) -> DynamicStruct { ... }
let mut dynamic_mut = dynamic
.reflect_mut()
.as_struct()
.unwrap()
.to_dynamic_struct();
dynamic_mut.insert("x", 1u32);
dynamic_mut.insert("y", 2u32);
dynamic_mut.insert("z", 3u32);
}
或者,我们可以干脆直接创建一个DynamicStruct并使用,
#![allow(unused)]
fn main() {
#[derive(Reflect, Default, Debug, PartialEq)]
struct MyStruct {
x: u32,
y: u32,
z: u32,
}
let mut dynamic_struct = DynamicStruct::default();
dynamic_struct.insert("x", 1u32);
dynamic_struct.insert("y", 2u32);
dynamic_struct.insert("z", 3u32);
let mut my_struct = MyStruct::default();
// apply方法可以把一个 &dyn PartialReflect类型其中的数据插入到一个实现了Reflet特性的对象上
my_struct.apply(&dynamic_struct);
assert_eq!(my_struct, MyStruct { x: 1, y: 2, z: 3 });
}
对于元组、枚举等数据,和结构体的使用方法是类似的,这里就不再赘述了。
最后,这里不得不提到的一件事就是,当我们使用#[derive(Reflect)]来给结构体/枚举添加反射功能时,所有的字段都的生命周期必须都是’static,因此,非静态借用数据无法实现反射。
#![allow(unused)]
fn main() {
#[derive(Reflect, Default, Debug, PartialEq)]
struct Pos {
x: u32,
y: u32,
z: u32,
}
#[derive(Reflect, Default, Debug, PartialEq)]
struct MyStruct<'a> {
pos: &'a Pos,// 不能这样写❌
}
}
11.3.2 函数反射
前面我们讲了这么几件事:如何使用反射给一个结构体/枚举本身/某个字段添加属性、如何在运行时通过字符串或索引,动态地访问、修改甚至凭空捏造数据。
现在我们来看看反射在函数上有什么用。要启用函数反射,首先要在cargo.toml中启用该功能。
bevy_reflect = {version = "1.95.0",features=["functions"]}
在开始之前,先让我们自己想一想,对一个函数而言,反射应该具备什么功能呢?很显然,对于一个函数来说,我们最关心的无非是下面几件东西:**函数标识、每个参数的类型、每个参数的位置、返回值类型。**稍后我们就会看到,完全如我们所料,这些在bevy_reflect里都有正确的对应对象或者获取方法。而且这也是在函数反射中,最为常用的功能之一。
在前面我们说到,创建一个不存在的结构体,我们需要一个动态类型,而对于函数,我们也需要一个DynamicFunction,不过好消息是,大多数时候我们只需要对一个函数简单的调用into_function,在背后,这是通过一个IntoFunction trait来实现的。
#![allow(unused)]
fn main() {
fn add(a: i32, b: i32) -> i32 {
a + b
}
// bevy_reflect为0-15个参数的函数、闭包均实现了 IntoFunction trait
// 在这里必须强调的一点是,函数的所有参数的生命周期必须和返回值相同
let mut func: DynamicFunction = add.into_function();
}
这个DynamicFunction类型上有一个重要的方法pub fn info(&self) -> &FunctionInfo,其返回一个对于FunctionInfo类型的引用,而这个FunctionInfo类型上,就拥有描述一个函数所需要的全部信息。前面我们说到的四种信息,可以通过FunctionInfo类型上的这几个方法来获得。
-
pub fn name(&self) -> Option<&Cow<'static, str>> -
pub fn arg_count(&self) -> ArgCount -
pub fn signatures(&self) -> &[SignatureInfo]
#![allow(unused)]
fn main() {
// 实际上DynamicFunction上也有name方法,因此你可以直接这样func.name()
let name: Option<&Cow<'static, str>> = func.info().name() ;
let arg_count:ArgCount = func.info().arg_count();
let signatures:&[SignatureInfo] = func.info().signatures();
}
让我们一个个来看看这些东西,首先是name,为何返回的是一个Option呢?这是因为,当add是闭包函数、匿名函数或函数指针是,name将会是None,对于重载函数,这是基础签名的名称(对于函数重载的讨论我们下面再进行)。
arg_count的类型是ArgCount,ArgCount又是一个什么样的类型呢?为什么不直接返回一个usize类型呢?答案是,这还是和函数重载有关。由于一个函数可能有多个实现,这时他们的参数个数也会发生变化,因此你应该可以猜到,ArgCount的用法和一个二维数组非常相似,类似于这样。
#![allow(unused)]
fn main() {
for arg_len in arg_count.iter(){
// 这时,arg_len才是一个usize类型
}
}
signatures的类型是&[SignatureInfo],你可能也已经猜到了为何这是一个数组切片了,原因同样是因为重载。不过,有意思的是,对比二者,我也想问,为何上面的args不是一个&[usize]类型呢?
SignatureInfo是一个非常重要的数据类型,其中含有了对于我们最重要的参数和返回值相关的信息。于是我们为了获取这些信息,还需要再往内部嵌套一层,因此同样我们就有了下面的这些方法。
-
pub fn args(&self) -> &[ArgInfo] -
pub fn return_info(&self) -> &ReturnInfo
#![allow(unused)]
fn main() {
let args:&[ArgInfo] =signatures.args();
let return_info:&ReturnInfo = signatures.return_info();
// 实际上 SignatureInfo 也有 pub fn arg_count(&self) -> usize 方法
// 而且,他还有pub fn name(&self) -> Option<&Cow<'static, str>>方法
// 于是我们可以发现一件事,DynamicFunction上的这两个方法实际上都只是简单的调用了 SignatureInfo 而已
}
ArgInfo类型代表了一个确切的函数参数的信息,通过他,我们终于可以获得一个参数的类型信息、名称、在参数表中的位置了。bevy_reflect为我们提供了非常多有用的方法,包括下面这些。
#![allow(unused)]
fn main() {
for arg in args.iter(){
// pub fn index(&self) -> usize 返回参数在参数列表中的位置索引
arg.index();
// fn ty(&self) -> &Type 返回参数的Rust底层类型
arg.ty();
// fn type_id(&self) -> TypeId 返回参数类型的TypeId
arg.type_id();
// 检查给出的类型是否匹配,这只比较了类型之间的 TypeId,并未验证它们是否共享相同的类型路径
arg.is::<T>();
// 检查参数是引用/可变引用还是所有权转移
args.ownership();
}
// pub enum Ownership {
// Ref,
// Mut,
// Owned,
// }
}
对于ReturnInfo,和ArgInfo的区别主要在于,其没有index()方法,因为函数的返回值一般只能有一个。
在rust中,对于闭包的区分我们有Fn和 FnMut和FnOnce,你可能已经猜到了,我们前面的DynamicFunction对应的其实就是Fn,而FnMut,当然也有一个DynamicFunctionMut!
他们的区别和Fn和 FnMut的区别几乎是一样的,当我们的闭包需要修改外部的变量时,我们就需要使用into_function_mut()方法生成一个DynamicFunctionMut。
#![allow(unused)]
fn main() {
let mut count = 0;
let increment = |amount: i32| count += amount;
let closure: DynamicFunctionMut = dbg!(increment.into_function_mut());
}
对于其余的获取参数和各种信息的方法,二者基本上是没有区别的,这里就不再赘述。
现在,我们还要做另一件事。既然我们已经知道了怎么通过反射查询一个函数的参数信息,那我们便可以通过这些信息组装成一个合适的参数列表,然后动态调用函数。可是,我们该怎么组装这样一个列表然后调用这个反射的函数对象呢?
bevy_reflect为我们提供了一个ArgList类型,这就是我们需要的,一份可以传递给 DynamicFunction或DynamicFunctionMut的参数列表。我们可以像这样,创建一个参数列表。
#![allow(unused)]
fn main() {
let foo = 123;
let bar = 456;
let mut baz = 789;
let args = ArgList::new()
// 移动所有权
.with_owned(foo)
// 移动所有权并创建一个堆指针
.with_boxed(Box::new(foo))
// 不可变引用
.with_ref(&bar)
// 可变引用参数
.with_mut(&mut baz)
// 一个ArgValue参数
.with_arg(ArgValue::Ref(&3.14));
// ArgValue参数是一个枚举类型,包含下列一些变体
// pub enum ArgValue<'a> {
// Owned(Box<dyn PartialReflect>),
// Ref(&'a (dyn PartialReflect + 'static)),
// Mut(&'a mut (dyn PartialReflect + 'static)),
// }
}
在DynamicFunction和DynamicFunctionMut上,我们有一个call方法,其正好接受一个ArgList类型的引用。
pub fn call<'a>(&self, args: ArgList<'a>) -> Result<Return<'a>, FunctionError>
而在DynamicFunctionMut上,我们还有一个额外的call_once方法。
pub fn call_once<'a>(&self, args: ArgList<'a>) -> Result<Return<'a>, FunctionError>
二者的区别在于,DynamicFunctionMut的call,会可变借用被捕获的变量,所以在手动drop掉DynamicFunctionMut之前,这些被闭包捕获的变量就无法再次使用了。如果你需要在调用函数之后继续使用被捕获的变量,那么就需要call_once来自动drop掉该对象。
#![allow(unused)]
fn main() {
let mut count = 0;
let increment = |amount: i32| count += amount;
let closure: DynamicFunctionMut = dbg!(increment.into_function_mut());
let args = dbg!(ArgList::new().with_owned(5_i32));
dbg!(closure.call_once(args).unwrap());
}
现在,让我们补充介绍一下,对于函数重载和泛型函数这些情况下该如何正确使用反射呢?
其实按照正确的说法,rust本身是不支持函数重载的。但是在bevy_reflect中,通过一些魔法,我们可以做到函数重载的功能。即一个DynamicFunction对象,能够拥有多个SignatureInfo和相应的实现,这和C++中的函数重载是相同的。
DynamicFunction和DynamicFunctionMut上有一个构造函数方法,其签名如下。这里的F是一个实现了IntoFunction的类型。
#![allow(unused)]
fn main() {
pub fn with_overload<'a, F, Marker>(self, function: F) -> DynamicFunction<'a>
where
'env: 'a,
F: IntoFunction<'a, Marker>
pub fn with_overload<'a, F, Marker>(self, function: F) -> DynamicFunctionMut<'a>
where
'env: 'a,
F: IntoFunctionMut<'a, Marker>
}
前面我们说到,bevy_reflect为0-15个参数的函数均实现了IntoFunction,也就是说,我们完全可以把任何一个函数拿过来,将两个函数完全合二为一,哪怕他们在rust里根本不是一个函数!于是我们就可以写出下面这样魔鬼般的代码。
#![allow(unused)]
fn main() {
fn add<T: Add<Output = T>>(a: T, b: T) -> T {
a + b
}
let mut func = add::<i32>.into_function();
// 直接把泛型化的两个函数单子合并塞到同一个DynamicFunction里!
func = func.with_overload(add::<f32>);
// 在调用call时候我们直接传入ArgList即可,bevy_reflect会为我们决定使用哪个变体
let args = ArgList::default().with_owned(25_i32).with_owned(75_i32);
let result = func.call(args).unwrap().unwrap_owned();
assert_eq!(result.try_take::<i32>().unwrap(), 100);
let args = ArgList::default().with_owned(25.0_f32).with_owned(75.0_f32);
let result = func.call(args).unwrap().unwrap_owned();
assert_eq!(result.try_take::<f32>().unwrap(), 100.0);
fn add_2(a: i32, b: i32) -> i32 {
a + b
}
fn add_3(a: i32, b: i32, c: i32) -> i32 {
a + b + c
}
let mut func = add_2.into_function();
// 或者直接合并两个完全不同的函数!
func = func.with_overload(add_3);
let args = ArgList::default().with_owned(25_i32).with_owned(75_i32);
let result = func.call(args).unwrap().unwrap_owned();
assert_eq!(result.try_take::<i32>().unwrap(), 100);
let args = ArgList::default()
.with_owned(25_i32)
.with_owned(75_i32)
.with_owned(100_i32);
let result = func.call(args).unwrap().unwrap_owned();
assert_eq!(result.try_take::<i32>().unwrap(), 200);
}
而对于泛型函数,遗憾的是bevy_reflect无法直接对泛型函数进行反射,泛型函数在 Rust 里不是一个函数,而是一个函数模板。它没有独立的 TypeId,也没有唯一的签名。不过这并不是完全无法做到的,实际上正如我们能前面的代码中写的那样,通过手动单态化,仍然可以对单态化之后的泛型函数进行反射。
11.3.3 trait反射
trait在编译后有两种形态,这两种形态决定了对于trait的反射是更加棘手的。
在静态分发时,trait的约束在编译期用掉了,对于编译之后的代码而言,所有的调用都直接内联成了具体的函数地址,在这一点上他和普通的函数是一致的,我们在这里丢掉了trait本身的信息。
在动态分发时,编译器会生成一张vtable表,里面保存了trait所有方法的函数指针,但是这个vtable只包含自己以及父trait的方法,并没有别的trait相关的信息。这就意味着你根本无法从一个dyn trait对象上得到任何其他trait的信息。
为了把这些信息留到运行时,我们必须要采取一些额外的措施。
假设我们有如下的一个trait,并为Zombie结构体实现了这个trait。
#![allow(unused)]
fn main() {
trait Damageable {
type Health: Sized + core::ops::Mul<i32, Output = Self::Health>;
fn damage(&mut self, damage: Self::Health);
}
#[derive(Reflect, PartialEq, Debug)]
struct Zombie {
health: i32,
}
impl Damageable for Zombie {
type Health = i32;
fn damage(&mut self, damage: Self::Health) {
self.health -= damage;
}
}
}
一旦我们想要将其变为dyn Reflect来使用反射的功能,我们就会丢失掉该trait相关的信息。
#![allow(unused)]
fn main() {
let mut value: Box<dyn Reflect> = Box::new(Zombie { health: 100 });
// 你根本写不出来这些代码,编译器会直接阻止你,因为这是一个dyn Reflect对象,而不是一个dyn Damageable对象
// value.damage(10);
}
如何才能曲线救国呢?不管怎么样,我们必须通过某种方式把这个trait相关的信息保存到和类型Zombie相关的某种数据结构上。bevy_reflect为我们提供了这样一种数据结构,即TypeRegistry。然而我们在前面说到,trait并不是一个具体的结构,甚至在静态分发下编译后直接消失了,我们该怎么保存这些数据呢?
聪明的你肯定能想到,如果我们能够编写一个结构体,并为结构体实现一些方法,然后在结构体的这些方法内把调用转发给trait,我们就可以做到把无形变有形,这样以来就能够保存这些trait数据了。而这个用曲线保存trait信息的结构体,在这里就叫做TypeData。顾名思义,这是一个用来保存类型的数据结构,虽然其是一个结构体。
bevy_reflect为我们提供了这样一个泛型,只要我们实现他,bevy_reflect就可以为我们自动从一个struct里提取出一个
#![allow(unused)]
fn main() {
pub trait CreateTypeData<T, Input = ()>: TypeData {
/// 创建这个类型的TypeData
fn create_type_data(input: Input) -> Self;
}
// TypeData只是一个能够下转为其他类型的记号,并没有什么很奇异的方法
pub trait TypeData: Downcast + Send + Sync {
fn clone_type_data(&self) -> Box<dyn TypeData>;
}
}
那么,让我们来尝试一下,应该如何来写这些代码呢?
我们可不想为每个用到Damageable的struct都编写一个相应的适配,所以该怎么让才能让rust自己为我们来编写呢?在前面我们说到,一个dyn Reflect可以被downcast_mut为一个具体的类型,而这个方法正好是一个泛型,或许我们能够利用泛型参数来解决这个问题。
让我们先拼凑出这些代码。当使用时,我们让rust编译器为我们根据泛型参数自动推断出应该转换为什么类型。可是这里的T应该是个什么类型才能让我们调用trait上的方法呢?嗯,没错,这里的T应该是我们上面说到的TypeData,我们先把这件事记下。
#![allow(unused)]
fn main() {
// 这里的T应该是什么类型呢?他应该是我们的TypeData,这个TypeData应该能够转发damage方法调用给真正的trait
let damageable = reflect.downcast_mut::<T>().unwrap();
damageable.damage(damage);
}
由于我们需要转发这个调用,那我们就必须得把这些代码封装成一个闭包,考虑到闭包的参数,我们至少要有两个类型参数:dyn Reflect对象和真正的trait方法的参数,到这里我们发现我们要面临一个非常棘手的问题,这里的T应该怎么限定呢?
#![allow(unused)]
fn main() {
#[derive(Clone)]
struct ReflectDamageable {
damage: fn(&mut dyn Reflect, damage: Box<dyn Reflect>),
}
impl<T> CreateTypeData<T> for ReflectDamageable
where
T: ?,// 这里的T应该是什么约束?
{
fn create_type_data(_input: ()) -> Self {
Self {
damage: |reflect, damage| {
let damageable = reflect.downcast_mut::<T>().unwrap();
let damage = damage.take::<T::Health>().unwrap();
damageable.damage(damage);
},
}
}
}
impl ReflectDamageable {
pub fn damage(&self, reflect: &mut dyn Reflect, damage: Box<dyn Reflect>) {
(self.damage)(reflect, damage);
}
}
}
让我们来细细思索一下,假如我们已经能够通过一个TypeRegistry和Zombie拿到了ReflectDamageable这个结构体,这个T应该是一个什么呢?
正确的答案是,这是一个实现了Reflect和Damageable两个trait的泛型。因为如果我们想在一个Reflect对象上调用Damageable的方法,这就是满足约束的条件。
Caution
这里的T必须是一个真实存在的结构体,不能是一个动态类型(如我们之前所说)
#![allow(unused)]
fn main() {
impl<T> CreateTypeData<T> for ReflectDamageable
where
T: Reflect + Damageable<Health: Reflect>,
{
//...
}
}
一旦我们完成了这些,我们就可以写出下面这些代码来。注意这里的约束是如何传递的。
impl ReflectDamageable中damage →struct ReflectDamageable中的damage
其中后者是bevy_reflect自动调用我们编写的create_type_data生成的。这时我们通过泛型紧箍咒强行把Damageable添加到了其中,这时rust编译器将从这里把这个约束传播并求解,于是编译器将会反推出 Zombie: Reflect + Damageable 必须成立。
#![allow(unused)]
fn main() {
// 把这些信息注册到一个TypeRegistry里
let mut registry = TypeRegistry::default();
registry.register::<Zombie>();
registry.register_type_data::<Zombie, ReflectDamageable>();
// 通过反射对象身上的typeid拿到注册表中的type data
let type_id = value.reflect_type_info().type_id();
let reflect_damageable = registry
.get_type_data::<ReflectDamageable>(type_id)
.unwrap();
// 直接通过转发操作,我们就能调用到真正的trait方法
reflect_damageable.damage(value.as_reflect_mut(), Box::new(25i32));
assert_eq!(value.take::<Zombie>().unwrap(), Zombie { health: 75 });
}
每次都要编写上面那些胶水代码来转发这些实现实在是太麻烦了,而且还极其容易出错,因此bevy_reflect为我们提供了一个 **#[reflect_trait]**指令,可以为我们自动实现这些功能,我们可以这样写。
#![allow(unused)]
fn main() {
#[derive(Reflect)]
struct Skeleton {
health: i32,
}
// 这一条指令自动为我们生成了ReflectHealth这个struct和上面的转发方法
#[reflect_trait]
trait Health {
fn health(&self) -> i32;
}
impl Health for Skeleton {
fn health(&self) -> i32 {
self.health
}
}
// 然后我们可以直接使用这个ReflectHealth对象
// 注意⚠️我们这里看不到ReflectHealth的定义,他是隐含在#[reflect_trait]中的
// 一般而言对于任何一个trait XXX,这个指令生成的TypeData是形如ReflectXXX的TypeData
registry.register_type_data::<Skeleton, ReflectHealth>();
let type_id = value.reflect_type_info().type_id();
let reflect_health = registry.get_type_data::<ReflectHealth>(type_id).unwrap();
let value: &dyn Health = reflect_health.get(value.as_reflect()).unwrap();
assert_eq!(value.health(), 50);
}
解决!
11.3.4 序列化
//TODO
11.4 补充
//TODO