系统扫描
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢本教程并希望我继续写作,请考虑支持我的 Patreon。
Specs 提供了一个非常棒的 dispatcher(调度器)系统:它可以自动利用并发,让你的游戏运行如飞。那么为什么我们没有使用它呢?Web Assembly!WASM 不支持像其他平台那样的线程,而且一个使用 dispatcher 编译到 WASM 的 Specs 应用程序会在第一次尝试调度系统时就崩溃。让桌面应用程序也因此受罪是不公平的。此外,当前的 run_systems
函数看起来一点都不友好:
#![allow(unused)] fn main() { fn run_systems(&mut self) { let mut mapindex = MapIndexingSystem{}; mapindex.run_now(&self.ecs); let mut vis = VisibilitySystem{}; vis.run_now(&self.ecs); ... // 还有很多系统 }
所以本章的目标是构建一个接口,它可以检测 WASM,并回退到单线程 dispatcher。如果不是 WASM 环境,我们希望使用 Specs 的 dispatcher。我们也希望系统接口更友好一些 - 并且不必多次指定系统。
启动 Systems 模块
首先,我们将创建一个新目录:src/systems
。这将用于存放独立的 系统 设置,但现在我们将使用它来开始构建一个可以处理在原生使用的 Specs dispatcher 和 WASM 中的单线程调用器之间切换的设置。在新的 src/systems
目录中,创建一个文件:mod.rs
。现在你可以让它保持为空。
警告:这里有一些稍微高级的宏和配置。你可以随意使用它,如果需要,稍后再学习它是如何工作的。我们已经进行了 73 章,所以我希望我们已经准备好了!
再创建一个新目录:src/systems/dispatcher
。在该文件夹中,放置另一个空的 mod.rs
文件。
现在转到 main.rs
并添加一行 mod systems;
:这是为了将其包含在编译中(我们稍后会处理整洁的用法)。修改 src/systems/mod.rs
以包含行 mod dispatcher
- 同样,这只是为了确保它被编译。
通用化 Dispatch
根据我们的规范/想法,我们知道我们需要一种通用的方式来运行系统 - 并且不必关心哪个底层设置处于活动状态(从编程的角度来看)。这听起来像是 trait(特征)的工作 - trait 在 Rust 中是(在其他方面)对多态、继承(有点像)和接口的答案。将以下内容添加到 src/systems/dispatcher/mod.rs
:
#![allow(unused)] fn main() { use specs::prelude::World; use super::*; pub trait UnifiedDispatcher { fn run_now(&mut self, ecs : *mut World); } }
这指定了我们的 UnifiedDispatcher
trait 将提供一个名为 run_now
的方法,该方法接受自身(用于状态)和 ECS 作为可变参数。
单线程 Dispatch
我们将从简单的例子开始。添加一个新文件,src/systems/dispatcher/single_thread.rs
。在 dispatcher/mod.rs
中,添加行 mod single_thread; pub use single_thread::*;
。
在 single_thread.rs
内部,我们将需要一些库的支持 - 所以首先导入以下内容:
#![allow(unused)] fn main() { use super::super::*; use super::UnifiedDispatcher; use specs::prelude::*; }
接下来,我们需要一个地方来存储我们的系统。我们将以 Specs 风格传递可运行的目标(见下文),但对于单线程执行,我们的目标是按照传递的顺序运行它们。与之前的 run_now
不同,我们将提前创建系统,然后迭代/执行它们 - 而不是每次都重新创建它们。让我们从结构定义开始:
#![allow(unused)] fn main() { pub struct SingleThreadedDispatcher<'a> { pub systems : Vec<Box<dyn RunNow<'a>>> } }
这里为生命周期增加了一些复杂性(我们将来自 Specs 的 RunNow
trait 与结构体放在同一个生命周期中),但这很简单:每个使用 Specs 系统功能的系统都实现了 RunNow
trait。因此,我们只是存储一个 boxed(装箱,因为它们的大小各不相同,我们必须使用指针间接寻址)RunNow
trait 的 vector(向量)。
实际执行它们有点困难。以下方法有效:
#![allow(unused)] fn main() { impl<'a> UnifiedDispatcher for SingleThreadedDispatcher<'a> { fn run_now(&mut self, ecs : *mut World) { unsafe { for sys in self.systems.iter_mut() { sys.run_now(&*ecs); } crate::effects::run_effects_queue(&mut *ecs); } } } }
可能有更好的方法来编写这个,但我一直遇到生命周期问题。World
和系统都倾向于有效地 'static
- 它们与程序的生命周期一样长。说服 Rust 相信这一点让我头疼了一整天,直到我最终决定只使用 unsafe
并相信自己会做正确的事情!
请注意,我们将 World
作为 可变指针,而不是常规的可变引用。解引用可变指针本质上是不安全的:Rust 不能确定你不会破坏生命周期保证。因此,unsafe
代码块在那里允许我们这样做。由于我们添加系统并且从不删除它们,并且在没有工作的 World
的情况下调用系统无论如何都会崩溃 - 我们可以侥幸逃脱。(如果有人想给我一个安全的实现,我将很乐意使用它!)。该函数只是迭代所有系统,并执行它们 - 并在最后运行效果队列。
所以那是相对容易的部分。困难 的部分是我们想要获取 Specs 风格的 dispatcher 调用 - 并将其转换为有用的系统数据。我们还希望以一种适用于 任何一种 dispatching 类型的方式来完成它,并且我们不想多次声明我们的系统。在挠头思考了一段时间后,我想出了一个 宏,它可以生成一个函数:
#![allow(unused)] fn main() { macro_rules! construct_dispatcher { ( $( ( $type:ident, $name:expr, $deps:expr ) ),* ) => { fn new_dispatch() -> Box<dyn UnifiedDispatcher + 'static> { let mut dispatch = SingleThreadedDispatcher{ systems : Vec::new() }; $( dispatch.systems.push( Box::new( $type {} )); )* return Box::new(dispatch); } }; } }
宏总是很难教授的;如果你不小心,它们就会开始看起来像 Perl。它们与其说是生成 代码,不如说是生成 语法 - 然后在编译期间“烹饪”成代码。看看 Specs 如何构建系统,每个系统都有一行这样的代码:
#![allow(unused)] fn main() { .with(HelloWorld, "hello_world", &[]) }
所以我们为每个系统指定了三个数据片段:系统 类型、名称和一个字符串数组,用于指定依赖项。对于单线程使用,我们实际上会忽略最后两个(并相信用户会按正确的顺序输入系统)。将此映射到宏的参数部分,我们有:
#![allow(unused)] fn main() { macro_rules! construct_dispatcher { ( $( ( $type:ident, $name:expr, $deps:expr ) ),* ) => { }
$(...),*
表示“重复此代码块的内容,0..n 次。然后这三个参数在括号内 - 使它们成为一个 元组。$type
是系统的类型 - 并且是一个 标识符(而不是纯粹的类型)。$name
和 $deps
只是表达式。
在宏的主体中:
#![allow(unused)] fn main() { ) => { fn new_dispatch() -> Box<dyn UnifiedDispatcher + 'static> { let mut dispatch = SingleThreadedDispatcher{ systems : Vec::new() }; $( dispatch.systems.push( Box::new( $type {} )); )* return Box::new(dispatch); } }; }
我们定义了一个新函数,名为 new_dispatch
。它返回一个 boxed、动态且 'static
的 UnifiedDispatcher
。(宏在您运行它之前不会定义该函数!)。它首先创建一个新的 SingleThreadedDispatcher
实例,其中包含一个空的系统 vector。然后它迭代每个 元组,将一个空系统推入 vector。最后,它返回该结构 - 并在其周围放置一个 box。
我们实际上还没有 创建 函数 - 我们只是教会了 Rust 如何做到这一点。所以在 src/systems/dispatch/mod.rs
中,我们需要定义它,以及它需要使用的系统:
#![allow(unused)] fn main() { #[macro_use] mod single_thread; pub use single_thread::*; construct_dispatcher!( (MapIndexingSystem, "map_index", &[]), (VisibilitySystem, "visibility", &[]), (EncumbranceSystem, "encumbrance", &[]), (InitiativeSystem, "initiative", &[]), (TurnStatusSystem, "turnstatus", &[]), (QuipSystem, "quips", &[]), (AdjacentAI, "adjacent", &[]), (VisibleAI, "visible", &[]), (ApproachAI, "approach", &[]), (FleeAI, "flee", &[]), (ChaseAI, "chase", &[]), (DefaultMoveAI, "default_move", &[]), (MovementSystem, "movement", &[]), (TriggerSystem, "triggers", &[]), (MeleeCombatSystem, "melee", &[]), (RangedCombatSystem, "ranged", &[]), (ItemCollectionSystem, "pickup", &[]), (ItemEquipOnUse, "equip", &[]), (ItemUseSystem, "use", &[]), (SpellUseSystem, "spells", &[]), (ItemIdentificationSystem, "itemid", &[]), (ItemDropSystem, "drop", &[]), (ItemRemoveSystem, "remove", &[]), (HungerSystem, "hunger", &[]), (ParticleSpawnSystem, "particle_spawn", &[]), (LightingSystem, "lighting", &[]) ); pub fn new() -> Box<dyn UnifiedDispatcher + 'static> { new_dispatch() } }
这定义了一个 new()
函数,它只是传递调用 new_dispatch
的结果。对 construct_dispatcher!
的宏调用 创建 了这个函数 - 包含了所有系统定义(我包含了所有系统)。
移动我们的系统
为了方便访问,我已经将 所有 我们的系统(仅限 Specs 系统)移动到了新的 systems
模块中。你可以在 源代码 中查看实现细节。移动它们实际上非常简单:
- 将系统(或系统文件夹)移动到
systems
中。 - 从
main.rs
中删除正在查找它的mod
和use
语句。 - 在系统中,将
use super::
替换为use crate::
。 - 调整
src/systems/mod.rs
以编译 (mod
) 和use
系统。
完成后的 src/systems/mod.rs
看起来像这样。请注意,我们添加了一个易于访问的 new
函数来获取新的系统 dispatcher:
#![allow(unused)] fn main() { mod dispatcher; pub use dispatcher::UnifiedDispatcher; // 系统导入 mod map_indexing_system; use map_indexing_system::MapIndexingSystem; mod visibility_system; use visibility_system::VisibilitySystem; mod ai; use ai::*; mod movement_system; use movement_system::MovementSystem; mod trigger_system; use trigger_system::TriggerSystem; mod melee_combat_system; use melee_combat_system::MeleeCombatSystem; mod ranged_combat_system; use ranged_combat_system::RangedCombatSystem; mod inventory_system; use inventory_system::*; mod hunger_system; use hunger_system::HungerSystem; pub mod particle_system; use particle_system::ParticleSpawnSystem; mod lighting_system; use lighting_system::LightingSystem; pub fn build() -> Box<dyn UnifiedDispatcher + 'static> { dispatcher::new() } }
particle_system
有点不同,因为它有 其他 函数在其他地方使用。你需要找到这些函数,并将它们的路径调整为 crate::systems::particle_system::
。
现在打开 main.rs
,并将新的 dispatcher 添加到 State
:
#![allow(unused)] fn main() { pub struct State { pub ecs: World, mapgen_next_state : Option<RunState>, mapgen_history : Vec<Map>, mapgen_index : usize, mapgen_timer : f32, dispatcher : Box<dyn systems::UnifiedDispatcher + 'static> } }
State 的初始化器更改为(在 fn main()
中):
#![allow(unused)] fn main() { let mut gs = State { ecs: World::new(), mapgen_next_state : Some(RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame }), mapgen_index : 0, mapgen_history: Vec::new(), mapgen_timer: 0.0, dispatcher: systems::build() }; }
我们现在可以 大幅度 简化我们的 run_systems
函数:
#![allow(unused)] fn main() { impl State { fn run_systems(&mut self) { self.dispatcher.run_now(&mut self.ecs); self.ecs.maintain(); } } }
如果你现在 cargo run
项目,它将像以前一样运行:但系统执行现在更直接了。它甚至可能更快一点,因为我们没有在每次执行时都重新创建系统。
多线程 Dispatch
我们通过单线程 dispatcher 获得了一些清晰度和组织性,但我们尚未释放 Specs 的力量!创建一个新文件,src/systems/dispatcher/multi_thread.rs
。
我们将首先创建一个新结构来保存 Specs dispatcher,并包含一些引用:
#![allow(unused)] fn main() { use super::UnifiedDispatcher; use specs::prelude::*; pub struct MultiThreadedDispatcher { pub dispatcher: specs::Dispatcher<'static, 'static> } }
我们还需要实现 run_now
(使用我们创建的 UnifiedDispatcher
trait):
#![allow(unused)] fn main() { impl<'a> UnifiedDispatcher for MultiThreadedDispatcher { fn run_now(&mut self, ecs : *mut World) { unsafe { self.dispatcher.dispatch(&mut *ecs); crate::effects::run_effects_queue(&mut *ecs); } } } }
这非常简单:它只是告诉 Specs “dispatch” 我们正在存储的 dispatcher,然后执行效果队列。
再一次,我们需要一个宏来处理输入:
#![allow(unused)] fn main() { macro_rules! construct_dispatcher { ( $( ( $type:ident, $name:expr, $deps:expr ) ),* ) => { fn new_dispatch() -> Box<dyn UnifiedDispatcher + 'static> { use specs::DispatcherBuilder; let dispatcher = DispatcherBuilder::new() $( .with($type{}, $name, $deps) )* .build(); let dispatch = MultiThreadedDispatcher{ dispatcher : dispatcher }; return Box::new(dispatch); } }; } }
这与单线程版本采用 完全相同 的输入。这是有意的:它们被设计为可互换的。它还创建了一个 new_dispatch
函数,具有相同的返回类型。它从 Specs 调用 DispatcherBuilder::new
,然后迭代宏参数,为每组系统数据添加一个 .with(...)
行。最后,它调用 .build
并将其存储在 MultiThreadedDispatcher
结构中 - 并在一个 box 中返回自身。
这实际上非常简单,但留下了一个大问题:我们如何知道我们将使用哪个 dispatcher 呢?我们几乎只希望在 WASM32 中使用单线程版本,否则我们希望利用 Specs 的线程和效率。因此,我们修改 src/systems/dispatcher/mod.rs
以包含 条件编译:
#![allow(unused)] fn main() { #[cfg(target_arch = "wasm32")] #[macro_use] mod single_thread; #[cfg(not(target_arch = "wasm32"))] #[macro_use] mod multi_thread; #[cfg(target_arch = "wasm32")] pub use single_thread::*; #[cfg(not(target_arch = "wasm32"))] pub use multi_thread::*; use specs::prelude::World; use super::*; pub trait UnifiedDispatcher { fn run_now(&mut self, ecs : *mut World); } construct_dispatcher!( (MapIndexingSystem, "map_index", &[]), (VisibilitySystem, "visibility", &[]), (EncumbranceSystem, "encumbrance", &[]), (InitiativeSystem, "initiative", &[]), (TurnStatusSystem, "turnstatus", &[]), (QuipSystem, "quips", &[]), (AdjacentAI, "adjacent", &[]), (VisibleAI, "visible", &[]), (ApproachAI, "approach", &[]), (FleeAI, "flee", &[]), (ChaseAI, "chase", &[]), (DefaultMoveAI, "default_move", &[]), (MovementSystem, "movement", &[]), (TriggerSystem, "triggers", &[]), (MeleeCombatSystem, "melee", &[]), (RangedCombatSystem, "ranged", &[]), (ItemCollectionSystem, "pickup", &[]), (ItemEquipOnUse, "equip", &[]), (ItemUseSystem, "use", &[]), (SpellUseSystem, "spells", &[]), (ItemIdentificationSystem, "itemid", &[]), (ItemDropSystem, "drop", &[]), (ItemRemoveSystem, "remove", &[]), (HungerSystem, "hunger", &[]), (ParticleSpawnSystem, "particle_spawn", &[]), (LightingSystem, "lighting", &[]) ); pub fn new() -> Box<dyn UnifiedDispatcher + 'static> { new_dispatch() } }
单线程导入以条件编译标记为前缀:
#![allow(unused)] fn main() { #[cfg(target_arch = "wasm32")] }
这表示“仅当目标架构为 wasm32
时才编译随附的行”。
同样,多线程版本具有相反的标记:
#![allow(unused)] fn main() { #[cfg(not(target_arch = "wasm32"))] }
我们对 dispatcher 中的 mod
和 use
语句重复这些标记。因此,如果你运行 WASM,你将获得 #[macro_use] mod single_thread; use single_thread::*;
。如果你在本地运行,你将获得 #[macro_use] mod multi_thread; use multi_thread::*;
。Rust 不会编译未包含 mod
语句的模块:因此我们只构建 一种 dispatcher 策略。由于我们随后使用了它,宏 construct_dispatcher!
被放置到我们的本地 (systems::dispatcher
) 命名空间中 - 因此我们对宏的调用运行了我们连接到的任何版本。
这是 编译时 dispatch,并且是一个非常强大的设置。RLTK 在内部大量使用它来自定义自身以适应各种硬件后端。
所以,如果你现在 cargo run
你的项目 - 游戏将使用 Specs dispatcher 运行。如果你启动系统监视器,你可以看到它正在使用多个线程!
为什么当我们添加线程时它没有崩溃?
一个常见的说法是“我有一个 bug。我添加了 8 个线程,现在我有 8 个 bug。” 这可能是非常真实的,但 Rust 非常努力地推广“无畏并发”。Rust 本身可以防止 数据竞争 - 不允许两个系统同时访问/更改相同的数据,这是某些其他语言中 bug 的常见来源。但是,它不能防止逻辑问题,例如系统需要来自先前系统的信息,但该系统尚未运行。
Specs 代表你将安全性更进一步。这是我们地图索引系统的 SystemData
定义:
#![allow(unused)] fn main() { WriteExpect<'a, Map>, ReadStorage<'a, Position>, ReadStorage<'a, BlocksTile>, ReadStorage<'a, TileSize>, Entities<'a> }
还记得我们如何煞费苦心地指定我们是想要对资源和组件的 Write
(写入)访问还是 Read
(读取)访问吗?Specs 实际上使用此信息进行调度。当它构建 dispatcher 时,它会查找 Write
访问 - 并确保没有两个系统可以同时对相同的数据具有写入访问权限。它还确保在数据被锁定以进行写入时不会发生读取数据。但是,系统可以并发地 读取 数据。因此,在这种情况下,Specs 保证任何需要 读取 地图的内容都将等到 MapIndexingSystem
完成对其的 写入 操作。
这具有构建依赖链的效果 - 并按逻辑顺序排列系统。作为一个简短的示例:
MapIndexingSystem
写入地图,并读取Position
、BlocksTile
和TileSize
。VisibilitySystem
写入地图、Viewshed
、Hidden
和RandomNumberGenerator
。它读取Position
、Name
和BlocksVisibility
。EncumbranceSystem
写入EquipmentChanged
、Pools
、Attributes
,并从Item
、InBackpack
、Equipped
、Entity
、AttributeBonus
、StatusEffect
和Slow
读取。InitiativeSystem
写入Initiative
、MyTurn
、RandomNumberGenerator
、RunState
、Duration
、EquipmentChanged
,并从Position
、Attributes
、Entity
、Point
、Pools
、StatusEffect
、DamageOverTime
读取。TurnStatusSystem
写入MyTurn
,并从Confusion
、RunState
、StatusEffect
读取。
我们可以继续枚举所有这些系统,但这已经是一个很好的说明。由此,我们可以确定:
MapIndexingSystem
锁定地图,因此它不能与VisibilitySystem
并发运行。由于MapIndexingSystem
首先定义,因此它将首先运行。VisibilitySystem
锁定地图、视野、隐藏和 RNG。因此它必须等待 Visibility 系统。EncumbranceSystem
锁定 RNG,因此它必须等待VisibilitySystem
完成。InitiativeSystem
也锁定 RNG,因此它必须等待 Encumbrance 完成。TurnStatusSystem
锁定MyTurn
-InitiativeSystem
也是如此。因此它必须等待该系统完成。
换句话说:我们还没有真正进行那么多多线程处理!我们正在通过使用 Specs 的 dispatcher 来获得效率提升 - 因此我们获得了一些好处(在我的本地计算机上,在调试模式下感觉肯定更快了!)。
量化“感觉更快”
让我们在屏幕上添加一个帧率指示器,以便我们 知道 我们所做的事情是否有帮助。我们将使其成为可选的,作为编译时标志(很像地图调试显示)。在 main.rs
中的地图标志旁边,添加:
#![allow(unused)] fn main() { const SHOW_FPS : bool = true; }
在 tick
的末尾,当你提交渲染批次时 - 添加以下行:
#![allow(unused)] fn main() { if SHOW_FPS { ctx.print(1, 59, &format!("FPS: {}", ctx.fps)); } }
如果你现在 cargo run
,你将在屏幕底部看到一个 FPS 计数器。在我的系统上,它几乎总是显示 60
。如果你想看看它 能 有多快,我们需要关闭 vsync
(垂直同步)。这很容易在 main
函数中更改 RLTK 初始化:
#![allow(unused)] fn main() { let mut context = RltkBuilder::simple(80, 60) .with_title("Roguelike Tutorial") .with_font("vga8x16.png", 8, 16) .with_sparse_console(80, 30, "vga8x16.png") .with_vsync(false) .build(); }
现在它显示我的帧率在 200 区域!
线程化 RNG
RandomNumberGenerator
作为一个可写资源是我们系统中无法实现并发的最大原因。它在各处都被使用,系统必须相互等待才能生成随机数。我们 可以 在需要随机数时简单地使用本地 RNG - 但这样我们就会失去设置 随机种子 的能力(更多内容将在以后的章节中介绍!)。相反,我们将创建一个 全局 随机数生成器,并使用 mutex(互斥锁)保护它 - 这样程序就可以从同一个来源获取随机数。
让我们创建一个新文件,src/rng.rs
。在 main.rs
中添加 pub mod rng;
,我们将把随机数包装器作为 lazy_static
来充实。我们将使用 Mutex
保护它,以便可以从多个线程安全地使用它:
#![allow(unused)] fn main() { use std::sync::Mutex; use rltk::prelude::*; lazy_static! { static ref RNG: Mutex<RandomNumberGenerator> = Mutex::new(RandomNumberGenerator::new()); } pub fn reseed(seed: u64) { *RNG.lock().unwrap() = RandomNumberGenerator::seeded(seed); } pub fn roll_dice(n:i32, die_type: i32) -> i32 { RNG.lock().unwrap().roll_dice(n, die_type) } pub fn range(min: i32, max: i32) -> i32 { RNG.lock().unwrap().range(min, max) } }
现在转到 main.rs
的 main
函数,并删除以下行:
#![allow(unused)] fn main() { gs.ecs.insert(rltk::RandomNumberGenerator::new()); }
现在,每当游戏尝试访问 RNG 资源时都会崩溃!因此,我们需要搜索整个程序,找到我们使用 RNG 的地方 - 并将它们全部替换为 crate::rng::roll_dice
或 crate::rng::range
。否则,语法保持不变。这是一个 很大的 更改,但大部分代码都相同。请参阅 源代码 以获取工作版本。(一个不错的副作用是,我们不再在地图构建器中到处传递 rng
;它们变得更简洁了!)
随着该依赖项的解决,我们现在能够以更高的并发性运行。你的 FPS 应该有所提高,如果你在进程监视器中观察,我们会更多地使用线程。
总结
本章极大地清理了我们的系统处理。它更快、更精简、更好看 - 以几个 unsafe
代码块(管理良好)和一个令人讨厌的宏为代价。我们还使 RNG
成为全局的,但安全地将其包装在 mutex 中。结果呢?游戏在我的系统上以发布模式以 1,300 FPS 运行,并且现在受益于 Specs 惊人的线程功能。即使在单线程模式下,它也能以不错的 1,100 FPS 运行(在我的系统上:core i7)。
本章的源代码可以在这里找到
在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)
版权 (C) 2019, Herbert Wolverson.