系统扫描


关于本教程

本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用。我希望您会喜欢本教程,并制作出色的游戏!

如果您喜欢本教程并希望我继续写作,请考虑支持我的 Patreon

实践 Rust 编程


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 dispatcherWASM 中的单线程调用器之间切换的设置。在新的 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、动态且 'staticUnifiedDispatcher。(宏在您运行它之前不会定义该函数!)。它首先创建一个新的 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 模块中。你可以在 源代码 中查看实现细节。移动它们实际上非常简单:

  1. 将系统(或系统文件夹)移动到 systems 中。
  2. main.rs 中删除正在查找它的 moduse 语句。
  3. 在系统中,将 use super:: 替换为 use crate::
  4. 调整 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 中的 moduse 语句重复这些标记。因此,如果你运行 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 写入地图,并读取 PositionBlocksTileTileSize
  • VisibilitySystem 写入地图、ViewshedHiddenRandomNumberGenerator。它读取 PositionNameBlocksVisibility
  • EncumbranceSystem 写入 EquipmentChangedPoolsAttributes,并从 ItemInBackpackEquippedEntityAttributeBonusStatusEffectSlow 读取。
  • InitiativeSystem 写入 InitiativeMyTurnRandomNumberGeneratorRunStateDurationEquipmentChanged,并从 PositionAttributesEntityPointPoolsStatusEffectDamageOverTime 读取。
  • TurnStatusSystem 写入 MyTurn,并从 ConfusionRunStateStatusEffect 读取。

我们可以继续枚举所有这些系统,但这已经是一个很好的说明。由此,我们可以确定:

  1. MapIndexingSystem 锁定地图,因此它不能与 VisibilitySystem 并发运行。由于 MapIndexingSystem 首先定义,因此它将首先运行。
  2. VisibilitySystem 锁定地图、视野、隐藏和 RNG。因此它必须等待 Visibility 系统。
  3. EncumbranceSystem 锁定 RNG,因此它必须等待 VisibilitySystem 完成。
  4. InitiativeSystem 也锁定 RNG,因此它必须等待 Encumbrance 完成。
  5. 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.rsmain 函数,并删除以下行:

#![allow(unused)]
fn main() {
gs.ecs.insert(rltk::RandomNumberGenerator::new());
}

现在,每当游戏尝试访问 RNG 资源时都会崩溃!因此,我们需要搜索整个程序,找到我们使用 RNG 的地方 - 并将它们全部替换为 crate::rng::roll_dicecrate::rng::range。否则,语法保持不变。这是一个 很大的 更改,但大部分代码都相同。请参阅 源代码 以获取工作版本。(一个不错的副作用是,我们不再在地图构建器中到处传递 rng;它们变得更简洁了!)

随着该依赖项的解决,我们现在能够以更高的并发性运行。你的 FPS 应该有所提高,如果你在进程监视器中观察,我们会更多地使用线程。

总结

本章极大地清理了我们的系统处理。它更快、更精简、更好看 - 以几个 unsafe 代码块(管理良好)和一个令人讨厌的宏为代价。我们还使 RNG 成为全局的,但安全地将其包装在 mutex 中。结果呢?游戏在我的系统上以发布模式以 1,300 FPS 运行,并且现在受益于 Specs 惊人的线程功能。即使在单线程模式下,它也能以不错的 1,100 FPS 运行(在我的系统上:core i7)。


本章的源代码可以在这里找到

在您的浏览器中使用 web assembly 运行本章的示例(需要 WebGL2)

版权 (C) 2019, Herbert Wolverson.