Bevy 游戏引擎开发指南


译者介绍

作为 Rust 与游戏开发爱好者,我从 Bevy 发布的时候就开始关注它的发展,Bevy 作为一个新生的实验性的游戏引擎,很适合追随它的成长脚步来学习游戏开发,一开始我还在犹豫是否要真正地深入了解和学习它。 随着越来越多的人开始使用 Bevy 开发游戏和插件,他们的热情和愿景鼓舞了我,于是我也下定决心投入更多的精力来通过掌握 Bevy 提升自己的游戏开发技巧。

受制于 Bevy 引擎的开发优先级,项目初期与引擎相关的开发文档极其缺乏,官网提供的入门文档,仅能满足基本的环境配置和示例运行,虽然官方的示例以代码的形式给开发者提供了最直接的指引,但对 Bevy 进行系统性介绍的官方文档尚不存在,对于入门开发者来说,丰富的文本介绍同样不可或缺。

幸运的是,社区开发者们对 Bevy 抱以极大期望和热情,由开发者编写的 Unofficial Bevy Cheat Book 是当时能找到的介绍 Bevy 最全面的书籍。

在本书中,除了全面系统地介绍 Bevy 的特性之外,还包括了许多社区插件、开发实践等各方面丰富实用的描述和引用,即使随着以后官方文档不断丰富,这本书仍能帮助入门者如何更快、更好、更全面地掌握 Bevy 生态并开发游戏。

在通读这本书后,我知道自己仍忽视了一些重要的内容,于是我打算精读,与此同时我发现,介绍 Bevy 的中文文本更加缺乏,如果我把本书翻译成中文,不仅能达到精读的目的,还能吸引更多的中文开发者,为社区做一点贡献。

于是促始了本书的中文版翻译工作。

在翻译此书时,Bevy 正处于激进迭代的 0.6 版本,离真正的产品就绪仍有些距离, 我向书籍原作者提出了自己的意向并寻求一些翻译贡献的建议, 原作者表示由于目前本书仍处在持续修订当中,暂时不接受不同语言版本的合并请求,但建议我可以在本书的基础上自由"编写/维护/托管"我自己的书籍。

为了让书名更符合中文阅读者的习惯,所以我将本书中文版命名为《Bevy 游戏引擎开发指南》


简明实用的 Bevy 游戏引擎 (GitHub) 开发指导手册。

欢迎! 愿这本书对你所帮助!

(别忘了 Star 本书的 GitHub 仓库,也考虑一下伸出援助之手 donating 🙂)

其它资源推荐

Bevy 有一个丰富的官方代码实例集 official code examples

也可以查看了解社区制作的资源 bevy-assets

我们的社区是非常友好和富有帮助的,欢迎加入 Bevy Discord 来聊天、提问,或者参与到项目中来。

如果你想看一些用 Bevy 制作的游戏,请访问 itch.ioBevy Assets

维护

本书的这个版本是针对 Bevy 0.6。

我打算让这本书与每一个新的 Bevy 版本保持同步。 我也会尽我所能定期对它进行改进。

支持我

GitHub Sponsors

我想继续改进和维护这本书,为 Bevy 社区提供一个高质量的独立学习资源。

你的捐款可以帮助我可以更好地为这种免费提供的内容上工作。谢谢你! ❤️

支持 Bevy

如果你喜欢 Bevy 游戏引擎,你应该考虑捐赠官方项目。

GitHub Sponsors

License

Copyright © 2021 Ida Iyes.

本书中的所有代码都在 MIT-0 License 许可证下提供。根据你的选择,你也可以在 regular MIT 许可下使用它。

本书的文本内容是在 CC BY-NC-SA 4.0 许可证下提供。

例外:如果用于为 "Official Bevy Project" 做贡献,本书的全部内容可以在 MIT-0 License 许可证下使用。

"Official Bevy Project" 定义如下:

Contributions

本书的开发是在 GitHub 上进行的。

请在 GitHub 提交任何有关错误/混淆/误导内容的 issues,以及你希望添加到本书的新内容的建议。

我们接受贡献,但有一些限制。

所有细节见Contributing部分。

稳定性警告

Bevy 仍然是一个非常新的、实验性的游戏引擎! 它是于 2020 年 8 月才开始公开!

虽然迭代的速度惊人,而且开发也很活跃,但离 Bevy 成熟仍需要一些时间。

所以没有稳定性的保证,而且破坏性的变化经常发生!

通常情况下,适应新版本的变化并不难(甚至可以直接跟踪主 git 开发分支),但在这里合适的警告是必要的。

Bevy 内置模块列表

本页列举的是 Bevy 内置提供的所有重要功能的简略表。

SystemParams

这些都是可以作为 system 参数使用的特殊类型。

(List in API Docs)

你的 system 函数总共最多可以有 16 个参数,如果你需要更多参数,用元组把它们包起来解决这个限制。单个元组最多可以包含 16 个成员,但可以无限地嵌套。

Assets

这些是 Bevy 默认注入的 Assets 类型。

  • Image: Pixel data, used as a texture for 2D and 3D rendering; also contains the SamplerDescriptor for texture filtering settings
  • TextureAtlas: 2D "Sprite Sheet" defining sub-images within a single larger image
  • Mesh: 3D Mesh (geometry data), contains vertex attributes (like position, UVs, normals)
  • Shader: Raw GPU shader source code, in one of the supported languages (WGSL or SPIR-V)
  • ColorMaterial: Basic "2D material": contains color, optionally an image
  • StandardMaterial: "3D material" with support for Physically-Based Rendering
  • Font: Font data used for text rendering
  • Scene: Scene composed of literal ECS entities to instantiate
  • DynamicScene: Scene composed with dynamic typing and reflection
  • Gltf: GLTF Master Asset: index of the entire contents of a GLTF file
  • GltfNode: Logical GLTF object in a scene
  • GltfMesh: Logical GLTF 3D model, consisting of multiple GltfPrimitives
  • GltfPrimitive: Single unit to be rendered, contains the Mesh and Material to use
  • AudioSource: Raw audio data for bevy_audio
  • FontAtlasSet: (internal use for text rendering)

Bundles

Bevy 内置的 bundle 类型,用于 spawning 不同种类的普通实体。

(List in API Docs)

Any tuples of up to 15 Component types are valid bundles.

Bevy 3D:

Bevy 2D:

Bevy UI:

Resources

Configuration Resources

这些设置资源允许你改变 Bevy 各部分的工作方式。

其中一些会影响引擎的低级初始化,所以必须从一开始初始化时就存在才能生效。你需要在你的 app builder 开始时注入这些资源:

这些可以在开始初始化时注入,但在运行时(从 system 中)改变也是可以的。

  • ClearColor: Global renderer background color to clear the window at the start of each frame
  • AmbientLight: Global renderer "fake lighting", so that shadows don't look too dark / black
  • Msaa: Global renderer setting for Multi-Sample Anti-Aliasing (some platforms might only support the values 1 and 4)
  • GamepadSettings: Gamepad input device settings, like joystick deadzones and button sensitivities

Engine Resources

这些资源在游戏运行时提供对游戏引擎不同功能的访问。

如果你需要它们的状态,或者控制 Bevy 的各个部分,就可以从你的 systems 中访问它们。

  • FixedTimesteps: The state of all registered FixedTimestep drivers
  • Time: Global time-related information (current frame delta time, time since startup, etc.)
  • AssetServer: Control the asset system: Load assets, check load status, etc.
  • Gamepads: List of IDs for all currently-detected (connected) gamepad devices
  • Windows: All the open windows (the primary window + any additional windows in a multi-window gui app)
  • WinitWindows: Raw state of the winit backend for each window
  • Audio: Use this to play sounds via bevy_audio
  • AsyncComputeTaskPool: Task pool for running background CPU tasks
  • ComputeTaskPool: Task pool where the main app schedule (all the systems) runs
  • IoTaskPool: Task pool where background i/o tasks run (like asset loading)
  • Diagnostics: Diagnostic data collected by the engine (like frame times)
  • SceneSpawner: Direct control over spawning Scenes into the main app World

Input Handling Resources

这些资源代表了不同输入设备的当前状态。从你的 systems 中读取它们来 [处理用户输入][cb::input]。

Events

Input Events

这些 events 在有设备产生输入时发生。把它们读到 [处理用户输入][cb::input]。

System and Control Events

来自操作系统/窗口系统、或用于控制 Bevy 的事件。

Components

太具体地在此列出各个 Component 类型的完整清单用外有限。

请直接查看:(List in API Docs)

以下列出个人认为最重要的内置 Component 类型:

  • Transform: Local transform (relative to parent, if any)
  • GlobalTransform: Global transform (in the world)
  • Parent: Entity's parent, if in a hierarchy
  • Children: Entity's children, if in a hierarchy
  • Timer: Track if a time interval has elapsed
  • Stopwatch: Track how much time has passed
  • Handle<T>: Reference to an asset of specific type
  • Visibility: Manually control visibility, whether to display the entity (hide/show)
  • RenderLayers: Group entities into "layers" and control which "layers" a camera should display
  • Camera: Camera used for rendering
  • OrthographicProjection: Orthographic projection for a camera
  • PerspectiveProjection: Perspective projection for a camera
  • Sprite: (2D) Properties of a sprite, using a whole image
  • TextureAtlasSprite: (2D) Properties of a sprite, using a sprite sheet
  • PointLight: (3D) Properties of a point light
  • DirectionalLight: (3D) Properties of a directional light
  • NotShadowCaster: (3D) Disable entity from producing dynamic shadows
  • NotShadowReceiver: (3D) Disable entity from having dynamic shadows of other entities
  • Wireframe: (3D) Draw object in wireframe mode
  • Node: (UI) Mark entity as being controlled by the UI layout system
  • Style: (UI) Layout properties of the node
  • Button: (UI) Marker for a pressable button
  • Interaction: (UI) Track interaction/selection state: if the node is clicked or hovered over
  • Text: Text to be displayed

GLTF Asset Labels

Asset path labels to refer to GLTF sub-assets.

支持以下资产标签({}是数字索引)。

  • Scene{}: 获取 GLTF 场景,作为 Bevy 场景
  • Node{}: 获取 GLTF Node,作为 GltfNode
  • Mesh{}: 获取 GLTF Mesh,作为 GltfMesh
  • Mesh{}/Primitive{}: 获取 GLTF Primitive,作为 Bevy Mesh
  • Texture{}: 获取 GLTF 纹理,作为 Bevy Image
  • Material{}: 获取 GLTF 材质,作为 Bevy StandardMaterial
  • DefaultMaterial: 同上一个,如果 GLTF 文件中包含一个没有索引的默认材质,则直接获取该材质。

Stages

在内部,Bevy 至少有这些内置的阶段:

  • 在主应用程序(CoreStage)中:FirstPreUpdateUpdatePostUpdateLast
  • 在渲染子程序(RenderStage)中:ExtractPrepareQueuePhaseSortRenderCleanup

Bevy 配置建议

本章收集的是关于如何配置你的项目或开发工具的额外提示,来自 Bevy 社区,在 Bevy 官方设置文档 中尚未涉及的内容。

欢迎提出建议,在本章下添加更多内容。


也请查看本书的以下其他相关内容。

使用 Bevy main 开发分支

Bevy 的开发速度非常快,经常有一些令人兴奋的新东西还没有发布。本页将介绍关于使用 Bevy 开发版本的的建议。

快速入门

如果你不使用任何第三方插件,只想使用 Bevy 主开发分支:

[dependencies]
bevy = "0.6"

[patch.crates-io]
bevy = { git = "https://github.com/bevyengine/bevy" }

欲了解更多信息,请继续阅读。

你需要使用 Bevy main 开发分支吗?

目前,Bevy 不做补丁版本发布,只做主版本发布。最新的版本往往缺少最新的错误修复、可用性改进和功能。加入到这个行动中来可能会很有说服力!

如果你是 Bevy 的新手,这可能不适合你;你可能更愿意使用已经发布的版本。它将拥有与社区插件和文档的最佳兼容性。

使用未发布版本的 Bevy 的最大缺点是第三方插件的兼容性。Bevy 是不稳定的、破坏性的,变化经常发生。然而,许多积极维护的社区插件都有跟踪最新的 Bevy 主分支,尽管它们可能不是完全最新的。有可能你想使用的插件在 Bevy main 分支的最新变化下不能工作,你可能要自己去修复它。

不过,频繁的破坏性变化对你来说可能不是一个问题。多亏了 Cargo,你可以在你方便的时候更新 Bevy,只要你觉得已经准备好处理任何可能的破坏性变化。

如果你选择使用 Bevy main 分支,我们强烈建议你在 DiscordGitHub 上 与 Bevy 社区互动,这样你就可以跟踪最新发生的事情,获得帮助,或参与讨论。

常见的陷阱:神秘的编译错误

当在不同版本的 Bevy 之间转换时(例如,将现有的项目从发布的版本过渡到 git 版本),你可能会遇到很多奇怪的意外构建错误。

通常你可以通过删除 Cargo.locktarget 目录来解决这些问题。

rm -rf Cargo.lock target

更多信息请看 这一页。关于这个错误,请看这个 cargo issue

如果你仍然出错,可能是因为 cargo 试图在你的依赖树中同时使用多个不同版本的 Bevy。如果你使用的一些插件指定了与你的项目不同的 Bevy版本,就会发生这种情况。这很烦人,但很容易解决。请阅读下面一节,了解如何配置你的项目,以尽量减少这种情况的发生。

如何使用 Bevy main 分支?

推荐的方法是使用 cargo patch

[dependencies]
# keep this as normal
bevy = "0.6"

[patch.crates-io]
# override it with bevy from git
bevy = { git = "https://github.com/bevyengine/bevy" }
# or if you have it cloned locally:
#bevy = { path = "../bevy" }

这样做会告诉 cargo 替换整个依赖树中的 Bevy 版本,包括第三方插件(假设它们也在其依赖中列出了 crates-io 的版本(bevy = "0.6"))。

如果你使用的任何第三方插件指定了不同的版本,这比直接在 [dependencies] 中指定 gitpath更好,因为这避免了在你的依赖树中可能有多个不同 bevy 版本的风险。

不幸的是,有些插件作者直接将 git 版本放在他们的 [dependencies] 中,这就破坏了上述设置。这可以通过添加另一个 cargo patch 来解决,也可以覆盖 git 仓库:

[patch."https://github.com/bevyengine/bevy"]
bevy = { path = "../bevy" }

一些第三方插件依赖于特定的 bevy 子模块,而不是整个 bevy。你可能还需要对这些插件单独打补丁:

[patch.crates-io]
bevy = { path = "../bevy" }
# specific crates as needed by the plugins you use (check their `Cargo.toml`)
bevy_ecs = { path = "../bevy/crates/bevy_ecs" }
bevy_math = { path = "../bevy/crates/bevy_math" }
# ... and so on

更新 Bevy

Cargo.lock 文件记录了你正在使用的确切版本(包括 git 提交)。在你手动更新 bevy 之前,bevy 的最新变化不会在你的项目中生效。

要更新,请运行:

cargo update

如果你删除或丢失了 Cargo.lock 文件,cargo 将不得不重新生成它并在这个过程中更新你的 bevy。为了防止这种情况,你应该把它和你的源代码一起添加到你的 git 仓库。另外,你也可以更明确地要求在 Cargo.toml 中提供准确的 git commit 哈希值。

bevy = { git = "https://github.com/bevyengine/bevy", rev = "7a1bd34e" }

给插件开发者的建议

如果你正在发布一个 bevy 插件,并且你想支持 bevy 主版本,建议你:

  • 在你的仓库中做一个单独的分支,以保持它与你的主版本分开,用于发布 bevy 的版本。
  • 在你的 README 中写上信息,告诉人们如何找到它。
  • 如上所示,配置你的 Cargo.toml。
  • 设置好 CI,当你的插件被 bevy 的新变化破坏时,能及时通知你。

有关 Cargo.toml 和依赖

如本页所示的 Cargo.toml 来发布你的插件。

通过指定 bevy 的发布版本,甚至在你跟踪 bevy 主分支的插件分支中,你可以让你的用户的开发变得更轻松。如果他们想使用本地的克隆版本、特定的提交版本或自己的分叉版本(而不是上游仓库),他们只需要在自己项目中打一个简单的 cargo 补丁就可以轻松做到。

如果你在依赖关系中直接指定 bevy git 仓库,你会使这种工作流程变得更加困难。

你也可以使用安全的方法引入 cargo 补丁。当你在开发你的插件时,这些补丁也能正常适用,你就可以根据想要的 bevy 版本进行构建,而不会影响你的用户,让他们使用他们想要的任何 bevy。

CI 配置

这里有一个 GitHub Actions 的例子。这个例子会在每天上午 8:00(UTC)运行,以验证你的代码是否仍可正常编译。如果失败,GitHub 会通知你。

name: check if code still compiles

on:
  schedule:
    - cron: '0 8 * * *'

env:
  CARGO_TERM_COLOR: always

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2

      - name: Install Dependencies
        run: sudo apt-get update && sudo apt-get install --no-install-recommends pkg-config libx11-dev libasound2-dev libudev-dev

      - uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          override: true

      - name: Check code
        run: cargo update && cargo check --lib --examples

文本编辑器和 IDE

本页包含对不同文本编辑器和 IDE 的配置建议。

在大多数情况下,Bevy 与其他 Rust 项目一样。如果你的编辑器或 IDE 配置环境是为 Rust 准备的,那同样也适用 Bevy 。本页包含了可能对 Bevy 特别有用的额外信息。

请通过提供建议来帮助改进这个页面。

CARGO_MANIFEST_DIR

当运行你的应用程序/游戏时,Bevy 会在 CARGO_MANIFEST_DIR 环境变量指定的路径中搜索 assets 文件夹。这使得 cargo run 可以从终端正确运行。

如果你的编辑器或 IDE 正在以非标准的方式运行你的项目(比如,在调试器里面),你必须确保配置是正确的。

VSCode

下面是一个配置片段,展示了如何为调试 Bevy 创建一个运行配置(使用 lldb)。

(这里的示例是用于 Bevy 本身的开发,使用 breakout 例子作为测试)

(如果用于你自己的项目,请根据你的需要调整)。

{
    "type": "lldb",
    "request": "launch",
    "name": "Debug example 'breakout'",
    "cargo": {
        "args": [
            "build",
            "--example=breakout",
            "--package=bevy"
        ],
        "filter": {
            "name": "breakout",
            "kind": "example"
        }
    },
    "args": [],
    "cwd": "${workspaceFolder}",
    "env": {
        "CARGO_MANIFEST_DIR": "${workspaceFolder}"
    }
}

Bevy 开发工具和游戏编辑器

Bevy 还没有一个官方的游戏编辑器或其他此类工具。官方的编辑器被计划为一个长期的未来目标。就目前,这里有一些社区制作的工具可以给你提供一些帮助。


游戏编辑器

bevy_inspector_egui 在游戏中给你一个简单的类似编辑器的属性检查窗口。它可以让你在游戏运行中实时修改你的组件和资源的值。

bevy_editor_pls 是一个类似游戏编辑器的界面,你可以把它嵌入到你的游戏中。它有更多的功能,比如切换应用状态、飞行摄像头、性能诊断和检查器面板。

游戏诊断

bevy_mod_debugdump 是一个帮助你可视化 App Schedule(所有注册的 systems 及其 ordering dependenciesstages),以及 Bevy Render Graph 的工具。

bevy_lint 是一个 linter(基于 dylint),可以自动检查你 Bevy 代码中的一些常见问题。

如果你遇到了令人困惑的、神秘的编译器错误信息(比如 这些)),而你又无法弄清楚,bevycheck 是一个可以帮忙你用来诊断的工具。它试图提供更多用户友好的 Bevy 专有错误信息。

使用第三方插件

社区制作 Bevy 插件的生态系统正在不断增长,它提供了许多没有正式包含在引擎中的功能。在你的项目中使用其中的一些插件,你会大大受益。

要找到这些插件,你可以在 Bevy 官方网站上的 Bevy Assets 页面搜索。这是 Bevy 社区插件的公示表。如果你自己已经开发并发布了 Bevy 插件,你应该把你的插件链接加入到该页面

请注意,一些第三方插件可能使用不常用的许可证。在你的项目中使用某个插件之前,请务必检查其许可证。


本书中的其他页面也会包含一些在使用第三方插件时的宝贵信息。

插件推荐

这是我个人的、精心筛选的、主观的推荐列表,介绍了 Bevy 生态系统中最重要的插件(在我看来)。

我的目标是帮助指导新的 Bevy 用户找到一些已知的好资源,这样你就可以开始做你想做的游戏了。:)

这里列出的都是与最新 Bevy 版本兼容的插件,并且使用了宽容性的许可证(像 Bevy 一样)。

这个页面内容非常有限,我只能推荐我所了解的插件。也请查看 Bevy Assets 页面,以找到更多的插件。:)

开发工具和编辑器 Development Tools and Editors

这些插件在另一个单独页面列出

编码辅助工具 Code Helpers

bevy_loading 是一个 state 转换的辅助工具。它可以让你注册系统、报告他们的进度、跟踪进度,并在它们都准备好时过渡到下一个状态。最有用处的是 加载屏幕 ,但也可以更普遍地使用,比如跟踪 assets 的加载。

bevy_asset_loader 是一个更灵活和有主见的、用于管理和加载游戏资源的辅助工具。使用自定义注解,可以让你更方便地声明你的资源。

输入映射 Input Mappings

为了帮助解决你的游戏的 [输入处理][chapter::input] 需求,可以试试 Leafwing Studios 的输入管理器插件。它以一种非常灵活的方式来处理你游戏的输入绑定和映射。

音频 Audio

使用 bevy_kira_audio 代替内置的 bevy_audio

内置的音频功能非常有限,你用这个插件可以满足音频游戏的几乎所有功能需求。

关于如何设置它的帮助,请看这个页面

摄像机 Camera

bevy_config_cam 是一个帮忙你很容易地在 Bevy 3D 项目中添加摄像机控制的好插件。它可以让你选择各种常见的摄像机行为(如跟随、俯视、FPS式、自由漫游)。

摄像机是一种与游戏本身特定相关的东西。随着你项目的进展,你可能想为你的游戏实现自定义的摄像机控制逻辑。然而,当你开始做一个新项目时,这个插件是非常棒的。

瓦片地图 Tilemap

如果你正在制作一个基于瓦片地图的 2D 游戏,有一些插件可以帮助你高效地完成它。最好是使用这些类的插件,而不是为每块瓦片生成大量单独的 Bevy 精灵。

  • bevy_ecs_tilemap
    • 每个瓦片使用一个 ECS 实体,并进行有效的自定义渲染。
    • 让你以 ECS 的方式来处理瓦片图。
    • 设置、配置、生成瓦片图可能有点复杂。
    • 功能丰富:方形、六角形、等高线网格,动画、层、块,可选的 ldtk/tiled 支持,...
  • bevy_tilemap
    • 另一个功能丰富的插件,但这个插件不是 ECS 方式的(整个地图是一个实体)。
    • 旨在为无限的、无尽的或动态生成的地图提供良好的支持。
    • API 相当完善和稳定,有很好的文档。
  • bevy_simple_tilemap
    • 功能有限,如果你只需要快速地渲染一个正方形瓦片网格,则很容易使用。

形状图、矢量图、画布 Shapes / Vector Graphics / Canvas

如果你想绘制2D图形,请使用 bevy_prototype_lyon 插件。

游戏 AI Game AI

big-brain 是一个有助于开发游戏 AI 行为(AI 工具)的插件。

用户图形界面 GUI

如果你想要替换 Bevy 的 内置 UI,请看 bevy_egui。它将 egui 工具包 集成到了 Bevy 中。

它是一个即时模式的 GUI 库(就像 Rust 生态中的 Dear Imgui)。

它的功能非常丰富,包含很多小工具。

它并不是为制作华丽的游戏性 UI 而设计的(尽管如此它仍很可能对你的游戏非常有帮忙)。它非常适用于类似编辑器的 UI、调试 UI 或者非游戏应用。

物理 Physics

Bevy 可以与 Rapier 物理引擎 集成。

有两个插件可供你选择:

  • bevy_rapier
    • 由 Rapier 项目的开发者官方维护。
    • 这是一个"原生"的插件,让你直接访问 Rapier。
    • 给你最多的控制权,但也增加了使用的难度,而且不是 Bevy 惯用式。
    • 你可能需要阅读大量的文档,学习难度大。
  • heron
    • 试图做一个对 Bevy 更惯用的插件。更有主见。
    • 可能会比 bevy_rapier 更容易使用,更直观。
    • 可能在功能方面也更有限。

动画 Animation

对于简单的"平滑运动"(缓和/调整/插值),可以试试 [bevy_easings][project::bevy_easings]。这对于移动 2D 物体、移动摄像机或其他类似的过渡效果来说可能已经足够好了。

对于 2D 精灵的动画,可以试试 [benimator][project::benimator]。

对于 3D 骨骼动画,不幸的是,似乎还没有插件。

另外,很久以前,有一个 [PR][bevy::1429] 试图为 Bevy 贡献一个全功能的动画系统。据我所知,它还没有被作为一个独立的插件来使用。

自定义 Bevy 功能和模块

Bevy 是非常模块化和可配置化的。它被实现为由许多独立的 cargo crates 组成,允许你删除你不需要的部分。较高级别的功能建立在较底层的基础库之上,并且可以禁用或进行替换。

底层的核心库(如 Bevy ECS)也可以完全独立使用,或者集成到其他非 Bevy 项目中。

Bevy Cargo Features

在 Bevy 项目中,你可以使用 cargo 的 feature 特性启用或禁用 Bevy 的各个功能。

这里我将解释其中的一部分,以及为什么你可能想要自己设置这些 feature。

许多常见的功能在默认情况下是启用的。如果你想禁用其中一些,请注意,不幸的是,Cargo 不允许你禁用个别的默认功能,所以你需要先禁用全部默认的 Bevy 功能后,再重新启用你想要的功能。

下面是一个你可能会用到的 Bevy 配置:

[dependencies.bevy]
version = "0.6"
# 如果有任何一个默认功能是你不想要,请禁用 default-features
default-features = false
features = [
  # 下面是常见的默认功能:
  # (留下你想要的)
  "render",
  "bevy_winit",
  "bevy_gilrs",
  "bevy_audio",
  "png",
  "hdr",
  "vorbis",
  "x11",
  "filesystem_watcher",
  # 下面是一些你可能不感兴趣的功能:
  # (有需要随意添加)
  "bmp",
  "tga",
  "dds",
  "jpeg",
  "wav"
  "flac",
  "mp3",
  "subpixel_glyph_atlas",
  "dynamic",
  "serialize",
  "trace",
  "trace_tracy",
  "trace_chrome",
  "wgpu_trace",
  "wayland"
]

(请看 这里,了解 Bevy 可通过 cargo 配置的完整功能清单)

图形和渲染 Graphics / Rendering

对于一个图形化的应用程序或游戏(大多数 Bevy 项目),你需要 renderbevy_winit

如果你不需要图形(如专用游戏服务器、科学模拟等),你可以删除这些功能。

音频 Audio

Bevy 目前的音频在功能上非常有限,而且有些残缺。建议你使用 bevy_kira_audio 插件来代替。禁用 bevy_audiovorbis

更多信息请看这一页

文件格式 File Formats

你可以使用相关的 cargo features 来启用或禁用对加载各种不同文件格式资源的支持。

更多信息见[这一页][cb::file-format]。

输入设备 Input Devices

如果你不关心对 游戏控制器或手柄 的支持,你可以禁用 bevy_gilrs

Linux 视窗后端

Linux 上,你可以选择支持 X11 或 Wayland,或两者都支持。只有 x11 是默认启用的,因为它是传统的视窗后端系统,它与大多数或甚至于所有的 Linux 发行版兼容,这可以使你的工程构建文件更小、编译更快。你可能想额外启用 wayland 来完全支持现代 Linux 环境。这将为你的项目增加一些额外的依赖。

开发功能 Development Features

在你的项目开发过程中,使用下面这些功能可能会很有帮助:

动态链接 Dynamic Linking

dynamic 会使 Bevy 作为一个共享的动态库被构建和链接。这将加快增量构建的速度,当你更新代码后试图进行测试时,这可以减少挫败感。在我的机器上,我的项目在没有启用这个功能的情况下重新编译需要2秒,而启用这个选项则仅需要0.5秒。这使得启动游戏的感觉几乎是即时的。

众所周知是的,这在 Linux 上工作得非常好,但你在其他平台上可能会遇到问题。恕我直言,我听说有人在 Windows 有遇到问题。

不要在你打算发布给其他人的版本中启用这个功能,它引入了不必要的复杂性(你需要捆绑额外的文件),并有可能导致程序不能正常运行。尽可能只在开发过程中使用这个功能。

基于以上分析,可把这个功能作为 cargo 的一个命令行选项来指定,而不是把它放在 Cargo.toml 中,可能会更方便。只需像下面这样运行你的项目:

cargo run --features bevy/dynamic

追踪 Tracing

tracewgpu_trace 功能对于分析和诊断性能问题可能会很有用。

trace_chrometrace_tracy 功能用来选择你想用来可视化跟踪的后端。

跨平台编译 Cross-Compilation

本子章介绍如何在你的开发环境操作系统上为其它不同的操作系统构建可执行程序。

也请查看 Platforms 章节,了解在不同操作系统上工作或运行的具体信息。

在 Linux 上构建 Windows 程序

(也可以查看 Windows 平台页面,了解有关为 Windows 开发的一般信息)


Rust 为构建 Windows 程序提供了两种不同的工具链:

  • MSVC:在 Windows 上构建程序时默认的工具链,需要下载微软的 SDK。
  • GNU:基于 MINGW 的替代构建方式,可能设置起来更容易

首次设置 MSVC

Rust MSVC 工具链

实际上,你可以在 Linux 上使用相同的基于 MSVC 的 Rust 工具链,这也是在 Windows 构建程序时使用的方案。

将需要支持的目标平台添加到你的 Rust 安装库中(假设你使用了 rustup):

rustup target add x86_64-pc-windows-msvc

这就安装了 Rust 为 Windows 编译程序所需的文件,包括 Rust 标准库。

Microsoft Windows SDKs

你需要安装微软的 Windows SDK,就像在 Windows 上工作时一样。在 Linux 上,这可以通过一个叫做 xwin 的简单脚本来完成。你需要接受微软的专利许可。

安装 xwin:

cargo install xwin

现在,使用 xwin 接受微软的许可,从微软服务器下载所有文件,并将它们安装到你选择的目录中。

例如,假设要安装到 /opt/xwin/

xwin --accept-license 1 splat --output /opt/xwin

链接 (MSVC)

Rust 需要知道如何链接最终的 EXE 文件。

默认的微软链接器(link.exe)在 Linux 上是不可用的。相反,我们需要使用 LLD 链接器(无论如何,在 Windows 上工作时也推荐使用这个)。只要在你的 Linux 发行版中安装 lld 包就可以了。

我们还需要告诉 Rust 微软 Windows SDK 库的位置(在上一步骤中与 xwin 一起安装时指定的位置)。

把这个添加到 .cargo/config.toml 中(位于你的主文件夹或你的 bevy 项目中)。

[target.x86_64-pc-windows-msvc]
linker = "lld"
rustflags = [
  "-Lnative=/opt/xwin/crt/lib/x86_64",
  "-Lnative=/opt/xwin/sdk/lib/um/x86_64",
  "-Lnative=/opt/xwin/sdk/lib/ucrt/x86_64"
]

首次设置 GNU

Rust GNU 工具链

你也可以使用其他基于 GNU 的 Windows 工具链。

将需要支持的目标平台添加到你的 Rust 安装中(假设你使用 rustup)。

rustup target add x86_64-pc-windows-gnu

这就安装了 Rust 为 Windows 编译程序所需的文件,包括 Rust 标准库。

MINGW

GNU 工具链需要安装 MINGW 环境,你的发行版可能为它提供了一个软件包。在你的发行版中搜索一个交叉编译的 MINGW 软件包。

它可能被称为:cross-x86_64-w64-mingw32,但这在不同的发行版中会有所不同。

你不需要来自微软的任何文件。

构建你的项目

最终,在完成所有的设置后,你就可以为 Windows 构建你的 Rust/Bevy 项目了。

cargo build --target=x86_64-pc-windows-msvc --release
cargo build --target=x86_64-pc-windows-gnu --release

常见的陷阱

本章涵盖了你在使用 Bevy 开发游戏的过程中可能会遇到的一些常见问题或意外,以及如何解决这些问题的具体建议。

奇怪的编译错误

有时,当你试图编译你的项目时,你会遇到奇怪的、令人困惑的编译错误,下面提供一些有用的解决方法。

更新你的 Rust

首先,确保你的 Rust 是最新的。当使用 Bevy 时,你必须至少使用最新的稳定版 Rust(或者 nightly 版本)。

如果你正在使用 rustup 来管理你的 Rust 安装与升级,你可以运行:

rustup update

重置 cargo 状态

许多类型的构建错误通常可以通过强制 cargo 重新生成它的内部状态(重新计算依赖关系等)来解决。你可以通过删除 Cargo.lock 文件和 target 目录来做到这一点。

rm -rf target Cargo.lock

这样做之后,再试着构建你的项目,很可能那些神秘的错误就会消失。

这一招通常能解决构建失败的问题,但你的问题仍未能解决,那你的问题可能需要进一步的调查。可以通过 GitHub 或 Discord,在 Bevy 社区中寻求帮助。

如果你使用的是 Bevy main 开发分支,而上述方法又未能解决你的问题,那你的错误可能是由第三方插件引起的。请看这个页面的解决方案。

新的 Cargo Resolver

Cargo 最近增加了一种新的依赖性解析算法,与旧的算法不兼容。Bevy 需要 新的解析器。

如果你只是创建一个新的空白 Cargo 项目,不用担心,cargo new 已经处理好了相关的配置。

如果你从 Bevy 的依赖关系中得到奇怪的编译器错误,请继续阅读下面的内容,确保你的配置正确,然后 重置 cargo 状态

Single-Crate Projects

在包含单个库的项目中(你的项目中只有一个 Cargo.toml 文件),如果你使用最新的 Rust2021 版本,新的解析器会自动启用。

所以,在下面两种示例配置中,任选一种用来配置你的 Cargo.toml,

使用 Rust2021 版本情况配置如下:

[package]
edition = "2021"

或,使用低于 Rust2021 版本则配置如下:

[package]
resolver = "2"

Multi-Crate Workspaces

在包含多个库的 Workspaces 中,解析器是整个工作区的一个全局设置。默认情况下,它 不会 被启用。

如果你正在将一个单库项目过渡到一个 Workspace,你或许会被坑到。

必须 在你的 Cargo Workspace 的顶级 Cargo.toml 中手动添加解析器的版本配置:

[workspace]
resolver = "2"

什么样的错误?

一个常见的例子是 "failed to select a version" 的错误,它可能看起来像这样:

error: failed to select a version for `web-sys`.
    ... required by package `wgpu v0.9.0`
    ... which is depended on by `bevy_wgpu v0.5.0 (https://github.com/bevyengine/bevy#6a8a8c9d)`
    ... which is depended on by `bevy_internal v0.5.0 (https://github.com/bevyengine/bevy#6a8a8c9d)`
    ... which is depended on by `bevy v0.5.0 (https://github.com/bevyengine/bevy#6a8a8c9d)`
    ... which is depended on by `bevy-scratchpad v0.1.0 (C:\Users\Alice\Documents\bevy-scratchpad)`
versions that meet the requirements `=0.3.50` are: 0.3.50

all possible versions conflict with previously selected packages.

  previously selected package `web-sys v0.3.46`
    ... which is depended on by `bevy_app v0.5.0 (https://github.com/bevyengine/bevy#6a8a8c9d)`
    ... which is depended on by `bevy_asset v0.5.0 (https://github.com/bevyengine/bevy#6a8a8c9d)`
    ... which is depended on by `bevy_audio v0.5.0 (https://github.com/bevyengine/bevy#6a8a8c9d)`
    ... which is depended on by `bevy_internal v0.5.0 (https://github.com/bevyengine/bevy#6a8a8c9d)`
    ... which is depended on by `bevy v0.5.0 (https://github.com/bevyengine/bevy#6a8a8c9d)`
    ... which is depended on by `bevy-scratchpad v0.1.0 (C:\Users\Alice\Documents\bevy-scratchpad)`

failed to select a version for `web-sys` which could resolve this conflict

(依据具体情况,你的可能和上面的例子不完全一样)

相关的另一个看似无意义的编译器错误提示,是与 Bevy 的内部类型产生冲突(如 "expected type Transform, found type Transform")。

为什么会这样?

这类错误通常是由于 cargo 的内部状态被破坏而引起的。因为依赖关系没有被正确解决,导致 cargo 试图将多个版本的 Bevy 链接到你的项目中。这种情况经常发生于你在你的项目中切换使用 Bevy 发布版本和 git 版本,但 Cargo 仍会记住它之前使用的版本,并因此遭遇困惑。

关于这个 bug,请看这个 cargo issue。如果你有任何有趣的信息要补充,你可以在该问题下贡献你的帮助。

性能问题

未经优化的调试编译

你可以在 debug 和 dev 模式下启用编译器的优化功能!

即使是启用 opt-level=1 级别的优化也足以让 Bevy 不至于慢得让人痛苦! 你也可以对依赖项启用更高的优化,但不要包括你自己的代码,以保证你自己代码的编译速度!

# 在 `Cargo.toml` 或 `.cargo/config.toml` 中进行配置

# 在调试模式下只启用少量的优化措施:
[profile.dev]
opt-level = 1

# 像下面这样对依赖的库启用更高级别的优化(包括 Bevy ),但不会包括我们自己的代码:
[profile.dev.package."*"]
opt-level = 3

为什么要这样做?

没有编译器优化的 Rust 是 非常慢 的。特别是对于 Bevy,默认的 cargo 调试编译配置会导致 糟糕 的运行性能。资源加载很慢、FPS 很低。

常见的症状:

  • 从 GLTF 文件加载带有大量大型纹理的高清晰 3D 模型文件,可能需要几分钟的时间! 这可能会使你产生错觉,让你以为你的代码不能正常运行,因为直到它准备好之前你在屏幕上看不到任何东西。
  • 甚至在生成几个 2D 精灵或 3D 模型后,帧率可能会下降到到无法播放的水平。

为什么不使用 --release

你可能听说过这样的建议:只要使用 --release 这种发布模式来运行就没有性能问题了! 然而,这是个不好的建议,请不要这样做。

发布模式禁用了"调试断言":在开发期间有用的额外检查。许多库在这个设置下还包括额外的东西。在 Bevy 和 WGPU 中,这包括对着色器和 GPU API 使用的验证。发布模式禁用了这些检查,导致信息量较小的崩溃、热重载问题,或者潜在的错误或无效逻辑没有被注意到。

发布模式也使增量的重新编译变得缓慢。这违背了 Bevy 实现快速编译的目标,在你开发时你可能会非常恼火。


根据本页顶部的建议,如果只是为了测试你的游戏有足够的性能,你不需要使用 --release 来进行编译。你只需要把它用于你发送给用户的 实际 发布版本。

如果你愿意,你也可以为实际的发行版构建启用 LTO(链接时间优化),以极慢的编译时间为代价,挤出更多的性能。

[profile.release]
lto = "thin"

添加 system 函数时遇到的问题

当你在向你的 Bevy 应用程序添加 system 时,有时你会遇到令人困惑的编译器错误。

这些错误看起来像这样:

the trait bound `for<'r, 's, 't0> fn(bevy::prelude::Query<'r, 's, (&'t0 Param)) {my_system}: IntoSystem<(), (), _>` is not satisfied

这是由于你的 system 函数包含不兼容的参数造成的。Bevy 只能接受特殊类型作为 system 参数。

有可能你的错误也会类似下面这样的错误:

the trait bound `Component: WorldQuery` is not satisfied
the trait `WorldQuery` is not implemented for `Component`
this struct takes at most 2 type arguments but 3 type arguments were supplied

这些错误是由错误的 query 查询引起的。

初学者常见的错误

  • 使用 &mut Commands(bevy 0.4 语法)而不是 Commands
  • 使用 Query<MyStuff> 而不是 Query<&MyStuff>Query<&mut MyStuff>
  • 使用 Query<&ComponentA, &ComponentB> 而不是 Query<(&ComponentA, &ComponentB)>(忘记这是元组)。
  • 直接使用你的资源类型而不使用 ResResMut
  • 直接使用你的组件类型而没把它们放在 Query 中。
  • 在你的函数中使用其他任意的类型。

请注意,Query<Entity> 是正确的用法,因为 Entity ID 很特殊,它不是一个组件。

支持的类型

系统参数只支持以下类型:

(List in API Docs)

你的 system 函数总共最多可以有 16 个参数,如果你需要更多参数,用元组把它们包起来解决这个限制。单个元组最多可以包含 16 个成员,但可以无限地嵌套。

UI 不显示

我看不到我的 UI!

如果你正在开发一个用户界面,但运行后 UI 并没有显示在屏幕上,那你可能忘了创建一个 UI 摄像机。Bevy 需要 UI 摄像机来渲染用户界面。

    commands.spawn_bundle(UiCameraBundle::default());

2D 对象不显示

Bevy 的二维 坐标空间 的设置是这样的:你背景的 Z 坐标 Z=0.0,而其他精灵层 Z 坐标需要为正数,即在背景之上。

如此同时意味着,为了看到你的场景,摄像机需要放在更远的地方,在一个大的 +Z 坐标上,看向 -Z。

如果你要覆盖摄像机的 transform 配置,从而创建你自己的 transform,你必需要这样做, 默认创建的 transform Z 坐标为 0(Z=0.0),它将会把摄像机放置在你的精灵(在 +Z 坐标)后面,从摄相机里就看不到精灵, 你需要设置一个大的 Z 坐标。于是,你要么给新创建的 transform 设置一个大的 Z 坐标值,要么从 Bevy 内置的 Bundle 构造函数(OrthographicCameraBundle::new_2d())生成的 Transform 中复制 Z 坐标值。

默认情况下,当你使用 Bevy 内置的 Bundle 构造函数(OrthographicCameraBundle::new_2d())创建一个 2D 摄像机时,Bevy 将摄像机的 Transform 设置为 Z=999.9。这接近于默认的剪裁平面(Z轴的可见范围),它被设置为1000.0。

3D 对象不显示

如果你试图创建一个 3D 对象,但在屏幕上看不到它,本页将列出你可能遇到的一些常见问题。

GLTF 资源使用不正确

请参考 GLTF page 页面,了解如何在 Bevy 中正确使用 GLTF 3D 资源。

GLTF 文件很复杂。它包含许多子资源,与不同的 Bevy 类型对应。请确保你使用的东西是正确的。

在你正在生成一个 GLTF 场景时,确保使用与 GLTF 基本类型相对应的 MeshStandardMaterial

如果你通过资源路径创建场景,请确保指定一个你想要的子资源标签。

asset_server.load("my.gltf#Scene0");

如果你尝试使用 Gltf 的顶层的 master asset 创建场景,它无法正常工作。

如果你尝试仅生成 GLTF 中一个 Mesh,它也无法正常工作。

如果你通过在自己创建的 Bevy PbrBundle 实体上使用 Gltf 顶层 master asset 或 GltfMesh,它同样无法正常工作。你需要一个特定的 GLTF Primitive、或者直接使用场景。:)

不受支持的 GLTF

Bevy 并不完全支持 GLTF 格式的所有特性,对数据有一些特殊的要求。不是所有的 GLTF 文件都能在 Bevy 中加载和渲染。不幸的是,在许多这样的情况下,你得不到任何的错误提示或诊断信息。

普遍遇到的限制:

  • 纹理通过 base64 编码后以 ascii 字符形式嵌入的 GLTF 文件不能被加载。需要把你的纹理放在外部文件中,或者使用二进制(*.glb)的格式。
  • 不支持 Mipmaps。你的资源应该仍然可以被加载和渲染,但没有 mipmapping,它看起来可能会很嘈杂、有颗粒、有锯齿。
  • Bevy 的渲染器要求所有的 mesh 和 primitive 都有每个顶点的位置、UV 和法线,请确保所有这些数据都包含在内。
  • 没有纹理的 mesh 和 primitive(如果材质只是一种纯色)仍然必须包含 UV。Bevy 将不会渲染没有 UV 的网格。
  • 当在你的材质中使用法线贴图时,切线也必须包含在网格中。有法线贴图但没有切线的网格是有效的,如果缺少切线,其他软件通常会自动生成切线,但 Bevy 还不支持这个。请确保在导出时勾选包括切线的复选框。
  • Bevy 还没有内置的骨架动画支持,渲染时动画是完全被忽略的。

这份清单并不详尽,可能还有我不知道的或忘了包括在这里的其他不支持的情况。:)

未优化的编译或调试编译

或许你的资源只是需要多一点时间来加载?Bevy(和一般的 Rust)在没有编译器优化的情况下是非常慢的。实际上,拥有大纹理的复杂 GLTF 文件可能需要超过一分钟才能加载并显示在屏幕上。在优化后的构建中,这几乎是瞬间完成的。请看这里

顶点顺序和剔除算法

在默认情况下,Bevy 渲染器假设逆时针方向的顶点顺序,并且启用了背面剔除。

如果你是通过代码生成你的 Mesh,请确保你的顶点顺序是正确的。

从结构中借用字段

当你有一个 componentresource,即具有多个字段的大型结构时,有时你想同时借用其中的几个字段,同时可能要求是可变借用。

struct MyThing {
    a: Foo,
    b: Bar,
}

fn my_system(mut q: Query<&mut MyThing>) {
    for thing in q.iter_mut() {
        helper_func(&thing.a, &mut thing.b); // ERROR!
    }
}

fn helper_func(foo: &Foo, bar: &mut Bar) {
    // do something
}

这会导致编译器出现关于借用冲突的错误:

error[E0502]: cannot borrow `thing` as mutable because it is also borrowed as immutable
    |
    |         helper_func(&thing.a, &mut thing.b); // ERROR!
    |         -----------  -----         ^^^^^ mutable borrow occurs here
    |         |            |
    |         |            immutable borrow occurs here
    |         immutable borrow later used by call

解决办法是使用 "reborrow" 方式,这是 Rust 编程中一个通用但并不显而易见的技巧:

        // add this at the start of the for loop, before using `thing`:
        let thing = &mut *thing;

注意,这一行会触发 变更检测。即使你在此后没有修改数据,该组件也会被标记为已更改。

解释

Bevy 通常让你通过特殊的封装类型(如 Res<T>ResMut<T>Mut<T>(当获取可变组件的时候)来访问你的数据。) 这可以让 Bevy 跟踪对数据的访问。

这些是 "智能指针" 类型,使用 Rust 的 Deref 特性来解除对数据的引用。它们通常可以无缝地工作以致于你甚至不会注意到它们。

然而,在某种意义上,它们对编译器是不透明的。当你可以直接访问该结构时,Rust 语言允许单独借用该结构的字段,但当它被包裹在另一种类型中时,字段就无法借用。

上面显示的 "reborrow" 技巧,有效地将包装器转换为一个普通的 Rust 引用。*thing 通过 DerefMut 解除对包装器的引用,然后通过 &mut 以可变方式借用它。现在你可访问的数据是 &mut MyStuff 而不是 Mut<MyStuff>

Bevy Time 和 Rust/OS time

不要 使用 std::time::Instant::now() 来获取当前时间,使用 Bevy 的 Res<Time>.

Rust(同 OS)会给你调用该函数时的精确时间,然而,这并不是你想要的。

你游戏的 system 是由 Bevy 的并行调度器运行的,这意味着他们可能会在每一帧的不同时刻被调用。这将导致不一致的、抖动的时间,使你的游戏表现失常或看起来不流畅。

Bevy 的 Time 给你的计时信息是正确和一致的。它被设计为用于游戏逻辑。

这种用法不是 Bevy 特有的,而是同样适用于其它引擎的游戏开发。总是从你的游戏引擎中获取时间,而不是从你的编程语言或操作系统中获取。

UI 布局是相反的

在 bevy 中,Y 轴总是指向 上方。当开发 UI 时,原点在屏幕的 左下角

这意味着 UI 是从下往上布局的。

这与网页和其他 UI 开发库的典型行为相反,后者的布局是从上到下的。

Bevy 对用户界面使用 Flexbox 布局模型,但与网页和 CSS 不同,垂直轴是倒置的。

不合常理的是,这意味着要构建根据从上到下依次布局的 UI 时,你需要使用 FlexDirection::ColumnReverse

Bevy 中的 UV 坐标

在 Bevy 中,纹理和图片像素的垂直轴,以及在着色器中对纹理进行采样时,都是 下指向 的,即从上到下,原点是在左上角。

这与大多数图像文件格式存储像素数据的方式一致,也与大多数图形 API 的工作方式一致(包括 DirectX、Vulkan、Metal、WebGPU,但 OpenGL 除外 )。

由于与 OpenGL(以及基于它的框架)不同。如果你以前的开发经验是基于 OpenGL,用同样的方式开发 Bevy 游戏时,你可能会发现你的网格上的纹理是垂直翻转的。你必须要以正确的 UV 格式重新导出或重新生成你的网格。

UV 也与 Bevy 中其他地方使用的世界坐标系(Y 轴指向上方)不一致。

如果你的 2D 精灵的图像被翻转了(不管什么原因),你可以用 Bevy 的精灵翻转功能来纠正。

    commands.spawn_bundle(SpriteBundle {
        sprite: Sprite {
            flip_y: true,
            flip_x: false,
            ..Default::default()
        },
        ..Default::default()
    });

Bevy 游戏引擎核心功能

本章内容涵盖了 Bevy 游戏引擎的核心功能。它与 Bevy 编程框架 一章共同完成对 Bevy 基础功能的介绍。

This chapter covers the foundational information about the game engine aspects of Bevy. It serves as an addition to the Bevy Programming Framework chapter.

坐标系统

Bevy 使用右手 Y 轴向上坐标系。

为保持一致性,Bevy 在 3D、2D 和 UI 中使用相同的坐标系。

用 2D 来解释最简单易懂:

  • X 轴从左到右(+X 指向右)。
  • Y 轴从下到上(+Y 指向上)。
  • Z 轴从远到近(+Z 指向你,在屏幕外)。
  • 对于 2D 来说,原点(X=0.0,Y=0.0)默认是在 屏幕的正中心
    • 对于 UI,原点在 左下角

当你在处理 2D 精灵时,你可以把背景放在 Z=0.0 的位置,然后通过调整不同精灵的 Z 坐标(大于 0.0),来把它们相互叠加在一起。

在 3D 中,各轴的方向是一样的。

这是一个右手坐标系统。你可以用右手的手指来想象这3个轴:拇指=X、食指=Y、中间=Z。

这与 Godot、Maya 和 OpenGL 相同。与 Unity 相比,它的 Z 轴是相反的。

注意:在 Bevy 中,Y 轴总是指向 上方

如果你习惯于使用 Y 轴向下的 2D 库,这可能导致在开发 UI 时会感觉不直观(因为它与网页相反)。

还要注意的是在处理 2D 开发时的一个常见的陷阱:摄像机必须放置在一个较远的Z坐标(默认为Z=999.9),否则你屏幕可能无法看到你的精灵。

变换

可参考的官方例子:parenting,关于创建 2D 或 3D 物体的东西。


如果你是游戏开发的新手,首先可以简单定义一下:

通过 Transform 你可在游戏的任意位置放置对象,并对它进行 "平移"(位置/坐标)、"旋转" 和 "缩放"(大小调整)等变换操作。

你通过修改 translation 来移动物体,通过修改 rotation 来旋转物体,通过修改 scale 来使物体变大或变小。

变换组件 Transform Components

在 Bevy 中,变换由 两个 组件表示。TransformGlobalTransform。任何在游戏世界中代表一个对象的实体都需要有这两个组件。

你经常需要用到 Transform,它是一个包含平移、旋转和缩放的 struct。要读取或操作这些值,可以在你的 systems 函数中使用 query 来访问它们。

如果实体有一个 父对象Transform 组件是相对于父对象的,这意味着子对象将与父对象一起移动、旋转和缩放。

GlobalTransform 表示在游戏世界里的绝对全局位置。如果该实体没有一个父对象,那么它的值将与 Transform 相同。GlobalTransform 的值是由 Bevy 在内部进行计算和管理的,你应该把它当作只读的,不要改变它。

请注意:这两个组件的值是由 Bevy 内部的系统("变换传递系统")在 PostUpdate 阶段执行同步的,如果你想做一些依赖于实体的相对局部、绝对全局位置的高级变换操作,这会有些微妙,并可能导致你掉进棘手的陷阱。当你改变 Transform 时,GlobalTransform 不会被立即更新,它们两的值在 "变换传递系统" 执行前都将是不同步的。

父子层级结构

可参考的官方例子: hierarchy, parenting.


从技术上讲,实体/组件 本身不能形成一个层级结构(ECS 是一个一维平面数据结构)。然而,逻辑层级结构是游戏中的一种常见模式。

Bevy 支持在实体之间建立这样的逻辑联系:以形成一个虚拟的 "层级",只需在各自的实体上添加 ParentChildren 组件即可。

当使用 Commands 来生成实体时,Commands 有向实体添加子对象的方法,这个方法同时可以为子对象自动添加正确的组件。

// spawn the parent and get its Entity id
    let parent = commands.spawn_bundle(MyParentBundle::default())
        .id();

// do the same for the child
    let child = commands.spawn_bundle(MyChildBundle::default())
        .id();

// add the child to the parent
    commands.entity(parent).push_children(&[child]);

// you can also use `with_children`:
    commands.spawn_bundle(MyParentBundle::default())
        .with_children(|parent| {
            parent.spawn_bundle(MyChildBundle::default());
        });

你可以通过一条 command 命令将某个层级包含的所有实体全部销毁:

fn close_menu(
    mut commands: Commands,
    query: Query<Entity, With<MainMenuUI>>,
) {
    for entity in query.iter() {
        // despawn the entity and its children
        commands.entity(entity).despawn_recursive();
    }
}

访问父对象或子对象

为了开发一个能与层级结构一起工作的 system 函数,你通常需要两个Query查询参数:

  • 一个用来访问包含你想要组件的子实体
  • 一个用来访问包含你想要组件的父实体

在这两个查询参数中,一个参数应该包含适当的组件(Component),获得实体的 ID,以便与另一个参数查询到的结果一起使用:

  • 如果你想遍历实体并访问它们的父对象,在 child 查询参数类型定义中包含 Parent,或
  • 如果你想遍历实体并访问它们的子对象,在 parent 查询参数类型定义中包含 Children

例如,如果我们想获得有父对象的摄相机(Camera)的 Transform,以及它们父对象的 GlobalTransform

fn camera_with_parent(
    q_child: Query<(&Parent, &Transform), With<Camera>>,
    q_parent: Query<&GlobalTransform>,
) {
    for (parent, child_transform) in q_child.iter() {
        // `parent` contains the Entity ID we can use
        // to query components from the parent:
        let parent_global_transform = q_parent.get(parent.0);

        // do something with the components
    }
}

再举个例子,比如我们在开发一个策略游戏,我们有一群作战单元是隶属于同一个小分队的子对象。假设我们需要编写一个对每个小分队起作用的系统,这个系统需要访问关于子对象的一些信息:

fn process_squad_damage(
    q_parent: Query<(&MySquadDamage, &Children)>,
    q_child: Query<&MyUnitHealth>,
) {
    // get the properties of each squad
    for (squad_dmg, children) in q_parent.iter() {
        // `children` is a collection of Entity IDs
        for &child in children.iter() {
            // get the health of each child unit
            let health = q_child.get(child);

            // do something
        }
    }
}

相对变换

如果你的实体代表某种 "游戏世界中的对象",你可能希望子对象能相对于父对象定位,并随其移动。

Bevy 内置 Bundles 创建的对象,都会自动为它们提供了上面提到的父子相对定位行为。

如果你没有使用这样的 Bundle,你需要确保在父对象和子对象中都添加这些组件:GlobalTransformTransform

Transform 表示相对位置,你可以直接修改它。

GlobalTransform 代表绝对位置,它是由 bevy 内部管理的,不要自己修改和维护它。

更多信息,请访问关于变换的专门页面。

固定时间步长

可参考的官方例子: fixed_timestep.


如果你需要在固定的时间间隔内做一些事情(一个常见的用例是物理状态更新),你可以使用 Bevy 的 FixedTimestep Run Criteria 将相应的 systems 添加到你的应用程序中。

    use bevy::core::FixedTimestep;

// The timestep says how many times to run the SystemSet every second
// For TIMESTEP_1, it's once every second
// For TIMESTEP_2, it's twice every second

    const TIMESTEP_1_PER_SECOND: f64 = 60.0 / 60.0;
    const TIMESTEP_2_PER_SECOND: f64 = 30.0 / 60.0;

    fn main() {
        App::new()
            .add_plugins(DefaultPlugins)
            .add_system_set(
                SystemSet::new()
                    // This prints out "hello world" once every second
                    .with_run_criteria(FixedTimestep::step(TIMESTEP_1_PER_SECOND))
                    .with_system(slow_timestep)
            )
            .add_system_set(
                SystemSet::new()
                    // This prints out "goodbye world" twice every second
                    .with_run_criteria(FixedTimestep::step(TIMESTEP_2_PER_SECOND))
                    .with_system(fast_timestep)
            )
            .run();
    }

    fn slow_timestep() {
        println!("hello world");
    }

    fn fast_timestep() {
        println!("goodbye world");
    }

状态

你可以通过访问 FixedTimesteps resource 来检查固定时间步长跟踪器的当前状态。这可以让你知道离下一次触发还有多少时间,或者它已经越界了多少时长时间。你需要给你的固定时间步长使用 标签

请查看这个官方例子对这一点的体现与说明。

注意事项

由于这个功能是用 Run Criteria 实现的,这些 systems 仍然与所有正常系统一起作为帧刷新生命周期的一部分被调用,时间并不精确。

FixedTimestep Run Criteria 只是检查自上次运行你的系统以来经过了多少时间,并决定是否在当前帧内运行它们,或者根据需要多次运行它们。

危险! 事件丢失!

默认情况下,Bevy 的 事件不可靠的! 事件只持续 2 帧,之后就会丢掉。如果你在你的固定时间步长系统接收事件,请注意,如果帧速率高于固定时间步长的 2 倍,你可能会错过一些事件。

解决这个问题的一个办法是使用 手动清除的事件。这可以让你控制事件的持续时间,但如果你忘记清除它们,将会导致内存泄漏和浪费。

资源

可参考的官方例子: asset_loading.


Bevy 有一个灵活的系统来异步加载和管理你的游戏资源(运行在后台,不会在你的游戏中造成尖峰时延)。

你加载的资源的数据被存储在 Assets<T> 资源 中(资源类型列表)。

资源是通过 句柄 的设计模式来追踪的。句柄仅仅只是特定资源的轻量级 ID。

使用 AssetServer 加载资源

要从文件中加载资源,请使用 AssetServer

struct UiFont(Handle<Font>);

fn load_ui_font(
    mut commands: Commands,
    server: Res<AssetServer>
) {
    let handle: Handle<Font> = server.load("font.ttf");

    // we can store the handle in a resource:
    //  - to prevent the asset from being unloaded
    //  - if we want to use it to access the asset later
    commands.insert_resource(UiFont(handle));
}

这将使资源加载在后台排队进行。被加载的资源需要稍等一些时间才可以被使用。资源就绪之前,在同一系统中,你可以通过资源句柄来使用资源,但实际资源数据仍需等就绪后才能真正变得有效果。

在资产加载之前你就可以使用资源句柄来生成你的 2D 精灵、3D 模型和用户界面,当资源准备就绪时,它们就会 "跳进来"。

需要知道的是,你可以随意多次地调用 asset_server.load 来加载同一资源,即使资源目前正在加载或已经加载,都只会为你返回相同的句柄。如果资源处于不可用或未加载的状态,则它将会被重新开始加载。

创建你自己资源

你也可以 Assets<T> 来手动添加资源。

如果你想用代码来创建它们(比如程序化生成),或者你已经通过其他方式得到了数据,这种方式就会很有用。

fn add_material(
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    let new_mat = StandardMaterial {
        base_color: Color::rgba(0.25, 0.50, 0.75, 1.0),
        unlit: true,
        ..Default::default()
    };

    let handle = materials.add(new_mat);

    // do something with the handle
}

热重载

Bevy 可以监测到你何时修改了你的资源文件,并在游戏运行的过程中重新加载它们。更多信息请见本页面

句柄

句柄(Handle<T>)是引用特定资源的典型方式。当你在游戏中生成一些东西时,比如 2D 精灵、3D 模型或 UI,它们在各自的组件中将需要通过句柄来引用它们使用到的资源。

你可以把你的句柄存放在你认为方便的地方(比如 resources) 中),或者直接存放在实体的组件中。

如果你没有把句柄存储在任何地方,你仍可以通过调用 asset_server.load 从一个路径获得一个句柄。这样你就可以在需要访问资源的时候简单地调用,而不必费力地存储句柄。

访问资源

要从系统中访问实际资源数据,请使用 Assets<T> resource

你可以使用句柄或资源路径来获取你想要的资源:

struct SpriteSheets {
    map_tiles: Handle<TextureAtlas>,
}

fn use_sprites(
    handles: Res<SpriteSheets>,
    atlases: Res<Assets<TextureAtlas>>,
    images: Res<Assets<Image>>,
) {
    // Could be `None` if the asset isn't loaded yet
    if let Some(atlas) = atlases.get(&handles.map_tiles) {
        // do something with the texture atlas
    }

    // Can use a path instead of a handle
    if let Some(map_tex) = images.get("map.png") {
        // if "map.png" was loaded, we can use it!
    }
}

资源路径和标签

来自文件系统的资源可以通过 AssetPath 来识取,它由文件路径外加一个标签组成。标签用于区分同一文件中包含的多个资源。这方面的一个例子是 GLTF files 文件,它可以包含网格、场景、纹理、材质等等。

资源路径类型可以用一个路径字符串来初始化创建,标签(如果有的话)通过 # 号附在路径字符串之后。

fn load_gltf_things(
    mut commands: Commands,
    server: Res<AssetServer>
) {
    // get a specific mesh
    let my_mesh: Handle<Mesh> = server.load("my_scene.gltf#Mesh0/Primitive0");

    // spawn a whole scene
    let my_scene: Handle<Scene> = server.load("my_scene.gltf#Scene0");
    commands.spawn_scene(my_scene);
}

关于使用 3D 模型的更多信息,请参阅 GLTF 页面

句柄和资源生命周期(垃圾回收)

句柄有内置的引用计数(类似于 Rust 中 的 RcArc)。这使得 Bevy 可以跟踪一个资源并得知它是否仍然正在被引用,并在它不再被需要时自动卸载它。

你可以使用 .clone() 来为同一个资源创建多个句柄。句柄克隆是一个低消耗的操作,但它被设计为明确的显性调用,以确保你知道你的代码中哪些地方创建了额外的句柄,并可能影响资源的生命周期。

弱引用句柄

句柄可以是 "强引用"(默认)或 "弱引用"。只有强引用句柄被计算在资源引用计数中,并能维持资源的被加载状态。弱引用句柄可以让你引用一个资源,同时在该资源没有了强引用句柄后仍会被卸载(即使弱引用句柄仍存在)。

你需要使用 .clone_weak() 而不是 .clone() 来创建弱引用句柄。

非类型化的句柄

Bevy 还有一个 HandleUntyped 类型。如果你需要无视资源类型以能够引用任何资源,请使用这种类型的句柄。

这种类型允许你存储一个包含混合类型资源的集合(如 VecHashMap)。

你可以使用 .clone_untyped() 创建一个非类型化的句柄。

非类型化资源加载

方便的是,如果你不知道文件是什么资源类型,AssetServer 支持非类型化加载。你也可以加载整个文件夹:

struct ExtraAssets(Vec<HandleUntyped>);

fn load_extra_assets(
    mut commands: Commands,
    server: Res<AssetServer>,
) {
    if let Ok(handles) = server.load_folder("extra") {
        commands.insert_resource(ExtraAssets(handles));
    }
}

它将尝试根据文件扩展名来检测每个资产的格式。

资源事件

如果你需要在资源被创建、修改或移除时执行特定的业务逻辑,你可以通过关注 AssetEvent 事件 来做出相应的反应。

struct MyMapImage {
    handle: Handle<Image>,
}

fn fixup_images(
    mut ev_asset: EventReader<AssetEvent<Image>>,
    mut assets: ResMut<Assets<Image>>,
    map_img: Res<MyMapImage>,
) {
    for ev in ev_asset.iter() {
        match ev {
            AssetEvent::Created { handle } |
            AssetEvent::Modified { handle } => {
                // a texture was just loaded or changed!

                let texture = assets.get_mut(handle).unwrap();
                // ^ unwrap is OK, because we know it is loaded now

                if *handle == map_img.handle {
                    // it is our special map image!
                } else {
                    // it is some other image
                }
            }
            AssetEvent::Removed { handle } => {
                // an image was unloaded
            }
        }
    }
}

热重载资源

可参考的官方例子: hot_asset_reloading, [hot_shader_reloading][example::hot_shader_reloading].


在运行时,如果你修改了已被加载到游戏中的资源的文件(通过 AssetServer),Bevy 会监测到并自动重新加载该资源。这对于快速迭代非常有用。你可以在游戏运行的时候编辑你的资源,并在游戏中即时看到它的变化。

并非所有的[文件格式][buildins::file-formats]和使用情况都能得到同样的支持。典型的资源类型,如纹理和图片应该没有问题,但复杂的 GLTF 或场景文件,或涉及自定义逻辑的资源则可能不会被即时加载刷新。

如果你需要某个有自定义逻辑的资源也能被热重载,你可以在一个系统中使用 AssetEvent 事件([了解更多][cb::assetevent])来实现它。

热重载是一个可选择项,并且必须被启用才能工作。你可以在一个初始化系统中来启用它:

    asset_server.watch_for_changes().unwrap();

着色器 Shaders

Bevy 也支持着色器的热重载。你可以编辑你的自定义着色器代码并立即看到变化。这只有在你通过 bevy 资源系统(通过 AssetServer)加载你的着色器时才有效。参见[官方示例][example::hot_shader_reloading]。

文件格式支持

默认情况下,只有少数资源文件格式被启用:

  • 图片:PNG 和 HDR
  • 音频:OGG/Vorbis

你可以通过 cargo 功能启用更多格式:

  • 图像:JPEG、TAG、BMP、DDS
  • 音频:FLAC、MP3、WAV
[dependencies.bevy]
version = "0.6"
features = ["jpeg", "tga", "bmp", "dds", "flac", "mp3", "wav"]

3D 模型 (GLTF)

可参考的官方例子: load_gltf, update_gltf_scene.


Bevy 使用 GLTF 2.0 文件格式来加载 3D 资源。

(其他格式如 Wavefront OBJ 可通过第三方插件非官方地提供)

快速入门:将 3D 模型生成到你的世界中

最简单的用例是加载一个 "3D 模型" 并将其生成到游戏世界中。

"三维模型"通常很复杂,由多个部分组成。想想一栋房子:窗户、屋顶、门等等,都是独立的部分,可能由多个网格、材质和纹理组成,从技术上来说,Bevy 需要多个 ECS 实体来表示和渲染整栋房子。

这就是为什么你的 GLTF "模型"被 Bevy 表示为一个场景。这样,你可以很容易地生成它,而 Bevy 将创建所有相关的子实体并正确地配置它们。

这样你就可以在世界中把整个东西当作"一个单一的物体"放置,把它挂在一个父实体下面,就可以使用 Transform 来对其进行变换。

fn spawn_gltf(
    mut commands: Commands,
    ass: Res<AssetServer>,
) {
    // note that we have to include the `Scene0` label
    let my_gltf = ass.load("my.glb#Scene0");

    // to be able to position our 3d model:
    // spawn a parent entity with a Transform and GlobalTransform
    // and spawn our gltf as a scene under it
    commands.spawn_bundle((
        Transform::from_xyz(2.0, 0.0, -5.0),
        GlobalTransform::identity(),
    )).with_children(|parent| {
        parent.spawn_scene(my_gltf);
    });
}

如果你的 GLTF 场景代表了"整个关卡和地图",而不是"一个单独的 3D 模型",并且你不需要移动它,你可以直接生成场景,而不需要为它创建一个父实体。

另外,这个例子假设你有一个简单的 GLTF 文件,只包含一个"默认场景"。GLTF 是一种非常灵活的文件格式,一个文件可以包含许多"模型"或更复杂的"场景"。要想更好地了解 GLTF 和可能的工作流程,请阅读本页面的其他内容。:)

GLTF简介

GLTF 是一种现代开放标准,用于在不同的 3D 软件应用程序之间交换 3D 资源,如游戏引擎和 3D 建模软件。

GLTF 文件格式有两种变体:人类易读的 ascii 文本(*.gltf)和二进制(*.glb)。二进制格式更紧凑,更适合于将资产与你的游戏打包。文本格式对开发有用,因为可以更容易地使用文本编辑器手动查看它。

一个 GLTF 文件可以包含许多对象(子资源):网格、材质、纹理、场景。当加载一个 GLTF 文件时,Bevy 将加载里面包含的所有资源。它们将被映射到适当的 Bevy 内部资源类型

GLTF 子资源

与 Bevy 一起比较时,GLTF 的术语可能令人困惑,因为它有时使用相同的词来指代不同的东西。本节将尝试解释各种 GLTF 术语。

为了帮忙我们理解所有的东西,先来区分这些概念在不同的地方是如何体现的:在你的 3D 建模软件(如 Blender)中、在 GLTF 文件本身,以及在 Bevy 中。

GLTF 场景 是你用来在游戏世界生成内容的原材料。生成后的内容会和你在 3D 软件建模的时候在屏幕上看到的一样。游戏场景整合了游戏引擎所需的所有数据,以创建所有需要的实体来表现你想要的东西。从概念上讲,把一个场景看成是一个 "单元"。根据你的使用情况,这可能是一个"3D模型",甚至是整个地图或游戏关卡。在 Bevy 中,这些被表示为 Bevy 场景和所有的子 ECS 实体。

GLTF 场景是由 GLTF 节点组成的。这些节点描述了游戏场景中的"对象",通常是 GLTF 网格,但也可以是其他东西,如摄相机和灯光。每个 GLTF 节点都有一个用于在场景中定位的变换组件。Bevy 核心功能中没有与 GLTF 节点一一对应的概念。Bevy 只是使用这些数据来创建场景中的 ECS 实体。如果你需要访问这些数据,Bevy 有一个特殊的 GltfNode 资源类型。

GLTF Meshes 代表一个概念性的"3D 对象"。这些对应于你在 3D 建模软件中的"对象"。GLTF 网格有可能会非常复杂,由多个小块组成,称为 GLTF 基元,每个可以使用不同的材质。GLTF 网格没有一个核心的 Bevy 对应物,但有一个特殊的 GltfMesh 资源类型,用来描述基元。

GLTF 基元是单独的 "3D 几何单位",目的是用于渲染。它们包含了实际的几何体和顶点数据,并在绘制时参考需要使用的材质。在 Bevy 中,每个 GLTF 基元都被表示为 Bevy Mesh 资源,并且必须作为一个单独的 ECS 实体被生成以进行渲染。

GLTF 材质描述了你的 3D 模型表面的着色参数,完全支持基于物理的渲染(PBR),还引用了要使用的纹理。在 Bevy 中,GLTF 材质表示为 StandardMaterial 资源,被 Bevy PBR 3D 渲染器所用。

GLTF 纹理(图片)可以嵌入到 GLTF 文件中,也可以存储在外部的独立图像文件中。例如,你可以将你的纹理作为单独的 PNG 或 JPEG 文件,以方便开发,或者将它们全部打包在 GLTF 文件中,以方便分发。在 Bevy 中,GLTF 纹理被作为 Bevy 图片 资源加载。

GLTF 采样器描述了 GPU 应该如何使用一个特定的纹理的设置。Bevy 并没有把这些分开,这些数据被储存在 Bevy 图片资源中(存于 SamplerDescriptor 类型的 sampler 字段)。

GLTF 使用模式

由于 GLTF 非常灵活,如何构建你的资源由你决定。

一个 GLTF 文件可能被用来:

  • 只包含一个带有某模型的 GLTF 场景,表示一个单一的"3D 模型",这样你就可以将其生成到你的游戏中。
  • 作为一个 GLTF 场景,可能还包含摄像机,用来表示整个游戏关卡。这可以让你一次性加载和生成整个关卡和地图。
  • 表示一个关卡或地图的一个部分,比如一个房间。
  • 包含一组不同的 "3D 模型",每个模型都是一个单独的 GLTF 场景。这可以让你一次性加载和管理整个集合,并根据需要单独生成它们。
  • ...其他?

创建 GLTF 资源的工具

如果你使用最新版本的 Blender(2.8以上)进行 3D 建模,GLTF 是开箱即支持的。只要选择 GLTF 作为导出格式。

对于其他工具,你可以试试这些导出插件:

在 Bevy 中使用 GLTF 子资源

包含在 GLTF 文件中的各种子资源可以通过两种方式来处理:

  • 通过索引(整数 id,按照它们在文件中出现的顺序)。
  • 按名称(文本字符串,你在创建资源时在你的 3D 建模软件中设置的名称,可以导出到 GLTF 中)。

为了在 Bevy 中获得相应资源的句柄,你可以使用 Gltf ["master asset"][#gltf-master-asset],或者使用 带标签的资源路径

Gltf 主资源

如果你有一个复杂的 GLTF 文件,这可能是浏览其内容和使用里面不同东西的最灵活和有用的方法。

你必须等待 GLTF 文件加载,然后再使用 Gltf 资源。

use bevy::gltf::Gltf;

/// Helper resource for tracking our asset
struct MyAssetPack(Handle<Gltf>);

fn load_gltf(
    mut commands: Commands,
    ass: Res<AssetServer>,
) {
    let gltf = ass.load("my_asset_pack.glb");
    commands.insert_resource(MyAssetPack(gltf));
}

fn spawn_gltf_objects(
    mut commands: Commands,
    my: Res<MyAssetPack>,
    assets_gltf: Res<Assets<Gltf>>,
) {
    // if the GLTF has loaded, we can navigate its contents
    if let Some(gltf) = assets_gltf.get(&my.0) {
        // spawn the first scene in the file
        commands.spawn_scene(gltf.scenes[0].clone());

        // spawn the scene named "YellowCar"
        // do it under a parent entity, to position it in the world
        commands.spawn_bundle((
            Transform::from_xyz(1.0, 2.0, 3.0),
            GlobalTransform::identity(),
        )).with_children(|parent| {
            parent.spawn_scene(gltf.named_scenes["YellowCar"].clone());
        });

        // PERF: the `.clone()`s are just for asset handles, don't worry :)
    }
}

对于一个更复杂的例子,比如说,不管出于什么原因我们想直接创建一个 3D PBR 实体(不推荐这样做;你应该直接使用场景):

use bevy::gltf::GltfMesh;

fn gltf_manual_entity(
    mut commands: Commands,
    my: Res<MyAssetPack>,
    assets_gltf: Res<Assets<Gltf>>,
    assets_gltfmesh: Res<Assets<GltfMesh>>,
) {
    if let Some(gltf) = assets_gltf.get(&my.0) {
        // Get the GLTF Mesh named "CarWheel"
        // (unwrap safety: we know the GLTF has loaded already)
        let carwheel = assets_gltfmesh.get(&gltf.named_meshes["CarWheel"]).unwrap();

        // Spawn a PBR entity with the mesh and material of the first GLTF Primitive
        commands.spawn_bundle(PbrBundle {
            mesh: carwheel.primitives[0].mesh.clone(),
            // (unwrap: material is optional, we assume this primitive has one)
            material: carwheel.primitives[0].material.clone().unwrap(),
            ..Default::default()
        });
    }
}

带标签的资源路径

这是另一种访问特定子资源的方式。它不太可靠,但在某些情况下可能更容易使用。

使用 AssetServer 将路径字符串转换为 资源句柄

优点是,你可以立即获得子资源的句柄,即使你的 GLTF 文件还没有加载完成。

缺点是,它更容易出错。如果你指定了一个在文件中不存在的子资源、或者输错了标签、或者用了错误的标签,它只会默默地不工作。另外,目前只支持使用数字索引,不能使用名字来定位子资源。

fn use_gltf_things(
    mut commands: Commands,
    ass: Res<AssetServer>,
) {
    // spawn the first scene in the file
    let scene0 = ass.load("my_asset_pack.glb#Scene0");
    commands.spawn_scene(scene0);

    // spawn the second scene under a parent entity
    // (to move it)
    let scene1 = ass.load("my_asset_pack.glb#Scene1");
    commands.spawn_bundle((
        Transform::from_xyz(1.0, 2.0, 3.0),
        GlobalTransform::identity(),
    )).with_children(|parent| {
        parent.spawn_scene(scene1);
    });
}

支持以下资产标签({}是数字索引)。

  • Scene{}: 获取 GLTF 场景,作为 Bevy 场景
  • Node{}: 获取 GLTF Node,作为 GltfNode
  • Mesh{}: 获取 GLTF Mesh,作为 GltfMesh
  • Mesh{}/Primitive{}: 获取 GLTF Primitive,作为 Bevy Mesh
  • Texture{}: 获取 GLTF 纹理,作为 Bevy Image
  • Material{}: 获取 GLTF 材质,作为 Bevy StandardMaterial
  • DefaultMaterial: 同上一个,如果 GLTF 文件中包含一个没有索引的默认材质,则直接获取该材质。

GltfNodeGltfMesh 资源类型只对帮助你浏览 GLTF 文件的内容有用。它们不是 Bevy 渲染器的核心类型,也不会被 Bevy 以任何其他方式使用。Bevy 渲染器希望实体具有 MaterialMeshBundle,为此你需要 MeshStandardMaterial

Bevy Limitations

Bevy 并不完全支持 GLTF 格式的所有特性,对数据有一些特殊的要求。不是所有的 GLTF 文件都能在 Bevy 中加载和渲染。不幸的是,在许多这样的情况下,你得不到任何的错误提示或诊断信息。

普遍遇到的限制:

  • 纹理通过 base64 编码后以 ascii 字符形式嵌入的 GLTF 文件不能被加载。需要把你的纹理放在外部文件中,或者使用二进制(*.glb)的格式。
  • 不支持 Mipmaps。你的资源应该仍然可以被加载和渲染,但没有 mipmapping,它看起来可能会很嘈杂、有颗粒、有锯齿。
  • Bevy 的渲染器要求所有的 mesh 和 primitive 都有每个顶点的位置、UV 和法线,请确保所有这些数据都包含在内。
  • 没有纹理的 mesh 和 primitive(如果材质只是一种纯色)仍然必须包含 UV。Bevy 将不会渲染没有 UV 的网格。
  • 当在你的材质中使用法线贴图时,切线也必须包含在网格中。有法线贴图但没有切线的网格是有效的,如果缺少切线,其他软件通常会自动生成切线,但 Bevy 还不支持这个。请确保在导出时勾选包括切线的复选框。
  • Bevy 还没有内置的骨架动画支持,渲染时动画是完全被忽略的。

这份清单并不详尽,可能还有我不知道的或忘了包括在这里的其他不支持的情况。:)

音频

Bevy 自己的内置音频支持是非常简陋和有限的。它可以播放声音,但仅此而已。它甚至没有音量控制。

相反,我们建议你试试 bevy_kira_audio 社区插件,它将 Kira 声音库与 bevy 整合在一起。Kira 的功能要丰富得多,包括支持管理许多音轨(如背景音乐和声音效果)、有音量控制、立体声平移、播放速率和流媒体。通过 WASM 它也支持 web 平台

社区在很大程度上认为 Bevy 的音频功能已经落后且没有多少用处,它可能会被删除并被其他东西取代(可能是 bevy_kira_audio)。

在你的项目中使用 bevy_kira_audio 需要一些额外的配置,因为你需要禁用 Bevy 自己的音频。Bevy 的音频是一个 cargo 库功能选项,默认是启用的,但必须禁用。Cargo 不允许你禁用库个别的默认功能,所以你需要禁用所有默认的 Bevy 功能,重新启用你需要的功能。

你必须不包括 bevy_audio 功能,或任何音频文件格式支持功能(如默认的 vorbis),你需要在 bevy_kira_audio 上启用你想要的文件格式,而不是通过 Bevy。

[dependencies.bevy]
version = "0.6"
default-features = false
# These are the remaining default features other than `bevy_audio` and `mp3`
features = [
  "render",
  "bevy_winit",
  "bevy_gilrs",
  "png",
  "hdr",
  "filesystem_watcher",
  "x11"
]

[dependencies.bevy_kira_audio]
version = "0.8.0"
# `ogg` format support is enabled by default, disable if you don't want it
default-features = false
# enable the features you care about
features = [
  "wav",
  "flac",
  "mp3",
  "ogg",
]

有关 Bevy 的可选功能的更多信息,请参见此页面

输入处理

点击下载示列代码。

这是一个完整的 Bevy 输入处理例子,你可以运行。它将把所有的输入内容打印到控制台。


Bevy 支持以下输入:

  • 键盘(检测按键被按下或释放)
  • 字符(用于文本输入。键盘布局由操作系统负责)
  • 鼠标(相对移动、按键、滚动)
    • 移动(鼠标移动,不仅仅只是代表操作系统的光标移动)
    • 光标(光标绝对位置)
    • 按键
    • 滚动(鼠标滚轮或触摸板手势)
  • 触摸屏(包含多点触摸)
  • 游戏手柄(通过 gilrs 库实现)

传感器,如加速计和陀螺仪,还不支持。

对于大多数输入类型,Bevy 提供了两种处理方式:

有些输入只能以事件的形式提供。

检查设备状态是通过 resources 来完成的,比如 Input(用于数字输入,如按键或按钮)、Axis (用于模拟输入)、Touches(用于触摸屏上的手指操作)等等。这种处理输入的方式对于实现游戏逻辑非常方便。在这些情况下,你通常只关心游戏中映射到动作的特定输入,你可以检查特定的按钮、按键,看它们何时被按下、释放,或者它们的当前状态是什么。

Events输入事件)是一种较底层的、更全面的方法。如果你想从某类输入设备中获得所有的操作,而不是只检查特定的输入,就使用输入事件。

输入映射

Bevy 还没有提供一个内置的方法来做输入映射(配置按键绑定,等等)。你需要想出自己的方法,将输入转化为游戏或应用中的逻辑动作。

有一些社区制作的插件可以帮助你:见 bevy-assets 的输入部分。我个人推荐:Leafwing Studios 的输入管理器插件

一个好的想法是为你自己的游戏设计特定的映射抽象。例如,如果你需要处理角色的移动,你可能希望有一个系统来读取输入并将其转换为你自己的内部"移动事件、行为事件",然后另一个系统作用于这些内部事件来实际移动角色。确保使用 明确的 system 排序 以避免滞后、帧延迟。

键盘

可参考的官方例子: keyboard_input, keyboard_input_events.


本页展示如何处理键盘按键的按下和释放。

如果你对文本输入感兴趣,请看 字符输入 页面。

注意:Mac 上的 Command 键对应于 PC 上的 Windows 微标键。

检查按键的状态

检查特定按键的状态目前只能通过按键代码完成,使用 Input<KeyCode>Input, KeyCoderesource 来获取状态:

fn keyboard_input(
    keys: Res<Input<KeyCode>>,
) {
    if keys.just_pressed(KeyCode::Space) {
        // Space was pressed
    }
    if keys.just_released(KeyCode::LControl) {
        // Left Ctrl was released
    }
    if keys.pressed(KeyCode::W) {
        // W is being held down
    }
}

键盘事件

为了获得所有的键盘操作,你可以使用 KeyboardInput 事件 来获取。

fn keyboard_events(
    mut key_evr: EventReader<KeyboardInput>,
) {
    use bevy::input::ElementState;

    for ev in key_evr.iter() {
        match ev.state {
            ElementState::Pressed => {
                println!("Key press: {:?} ({})", ev.key_code, ev.scan_code);
            }
            ElementState::Released => {
                println!("Key release: {:?} ({})", ev.key_code, ev.scan_code);
            }
        }
    }
}

这些事件给了你按键码和扫描码。扫描码被表示为一个任意的 u32 整数ID。

按键码和扫描码

键盘按键可以通过按键码或扫描码来识别。

按键码表示每个键上的符号或字母,并取决于用户操作系统中当前使用的键盘布局。Bevy 用 KeyCode 枚举来表示它们。

扫描码代表键盘上的物理键,与操作系统键盘布局无关。不幸的是,它们只是任意的整数 ID,并且与平台有关。没有简单的方法可以从扫描码中获知应该在游戏的用户界面上为用户显示什么。

此外,对在 Bevy 中使用扫描码的支持是有限的。这对于使用多个或非 QWERTY 键盘布局的人来说可能会很烦。

请参阅 Bevy Issue #2052 以找到一些改进的方法。

与键盘布局无关的按键绑定

不管目前的输入处理有哪些限制,你也可以为了给玩家提供更好的体验尝试更多的方法:

  • 在你的系统内部记录键盘按键绑定并存储为扫描码
  • 使用事件处理输入,并使用扫描码来识别按键
  • 仅仅为在用户界面中显示一个键的名称而存储按键码

这样做意味着在操作系统中使用多种键盘布局的用户,如果在游戏过程中不小心切换了他们的布局,或者用错误的布局开始游戏,他们设置的键盘绑定关系不会被破坏。

不幸的是,这也意味着你的游戏用户界面将显示注册键盘绑定时使用的键盘布局上的按键符号。如果用户改变了当前的键盘布局,这可能是错误的或令人困惑的。

鼠标

可参考的官方例子: mouse_input, mouse_input_events.


鼠标按键

键盘输入类似,鼠标按键也可以作为一种输入状态资源,以及事件

你可以使用 Input<MouseButton> 检查特定鼠标按键的状态:

fn mouse_button_input(
    buttons: Res<Input<MouseButton>>,
) {
    if buttons.just_pressed(MouseButton::Left) {
        // Left button was pressed
    }
    if buttons.just_released(MouseButton::Left) {
        // Left Button was released
    }
    if buttons.pressed(MouseButton::Right) {
        // Right Button is being held down
    }
}

要获得所有的按压/释放动作,使用 MouseButtonInput 事件

fn mouse_button_events(
    mut mousebtn_evr: EventReader<MouseButtonInput>,
) {
    use bevy::input::ElementState;

    for ev in mousebtn_evr.iter() {
        match ev.state {
            ElementState::Pressed => {
                println!("Mouse button press: {:?}", ev.button);
            }
            ElementState::Released => {
                println!("Mouse button release: {:?}", ev.button);
            }
        }
    }
}

鼠标滚动和滚轮

为了检测滚动输入,使用 MouseWheel 事件

fn scroll_events(
    mut scroll_evr: EventReader<MouseWheel>,
) {
    use bevy::input::mouse::MouseScrollUnit;
    for ev in scroll_evr.iter() {
        match ev.unit {
            MouseScrollUnit::Line => {
                println!("Scroll (line units): vertical: {}, horizontal: {}", ev.y, ev.x);
            }
            MouseScrollUnit::Pixel => {
                println!("Scroll (pixel units): vertical: {}, horizontal: {}", ev.y, ev.x);
            }
        }
    }
}

MouseScrollUnit 枚举很重要:它告诉你滚动输入的类型。Line 是用于有固定滚动步长的设备,比如桌面电脑鼠标上的滚轮。Pixel 用于具有平滑(细粒度)滚动的硬件,如笔记本的触控板。

你应该以不同的方式来处理这两种类型的硬件(用不同的灵敏度设置),以便为这两种类型的硬件提供良好的体验。

鼠标移动

如果你不关心鼠标光标的确切位置,而只是想看看它从一帧到另一帧的移动量,就使用 MouseMotion 事件 就对了。这对于控制 3D 摄像机等事情很有用。

每当鼠标被移动时,你会收到一个带有 delta 数据的事件。

fn mouse_motion(
    mut motion_evr: EventReader<MouseMotion>,
) {
    for ev in motion_evr.iter() {
        println!("Mouse moved: X: {} px, Y: {} px", ev.delta.x, ev.delta.y);
    }
}

你或许还想在游戏窗口内抓住/锁定鼠标

鼠标光标位置

如果你想准确地跟踪指针/光标的位置,请使用这个功能。这对诸如在你的游戏或 UI 上点击和悬停某个东西的应用场景非常有用。

你可以从相应的窗口中获得鼠标指针的当前坐标(如果鼠标当前正处在该窗口内)。

fn cursor_position(
    windows: Res<Windows>,
) {
    // Games typically only have one window (the primary window).
    // For multi-window applications, you need to use a specific window ID here.
    let window = windows.get_primary().unwrap();

    if let Some(_position) = window.cursor_position() {
        // cursor is inside the window, position given
    } else {
        // cursor is not inside the window
    }
}

为了检测光标何时被移动,使用 CursorMoved events 来获得新的坐标。

fn cursor_events(
    mut cursor_evr: EventReader<CursorMoved>,
) {
    for ev in cursor_evr.iter() {
        println!(
            "New cursor position: X: {}, Y: {}, in Window ID: {:?}",
            ev.position.x, ev.position.y, ev.id
        );
    }
}

注意,你只能获得鼠标在游戏窗口内的位置,你不能获得鼠标在整个操作系统桌面或屏幕上的全局位置。

要跟踪鼠标光标何时进入和离开你的窗口,可以使用 CursorEnteredCursorLeft 事件

文本和字符

可参考的官方例子: char_input_events.


如果你想在 Bevy 应用程序中实现文本输入,请使用本节介绍的内容(而不是键盘输入)。用这种方式,文本和字符的输入都能按照用户对他们的操作系统所期望的那样正常工作,包括 Unicode 支持。

Bevy 将为来自操作系统的每个 Unicode 码产生一个 ReceivedCharacter 事件

这个例子展示了如何让用户将文本输入到一个字符串中(这里存储为一个本地资源):

/// prints every char coming in; press enter to echo the full string
fn text_input(
    mut char_evr: EventReader<ReceivedCharacter>,
    keys: Res<Input<KeyCode>>,
    mut string: Local<String>,
) {
    for ev in char_evr.iter() {
        println!("Got char: '{}'", ev.char);
        string.push(ev.char);
    }

    if keys.just_pressed(KeyCode::Return) {
        println!("Text input: {}", *string);
        string.clear();
    }
}

游戏手柄

可参考的官方例子: gamepad_input, gamepad_input_events.


Bevy 支持游戏手柄输入硬件:游戏机控制器、游戏手柄等。许多不同种类的硬件应该都能工作,但如果你的设备不被支持,你应该向 gilrs 项目提交一个 issue。

手柄 ID

Bevy 为每个连接的游戏手柄分配一个唯一的 ID(Gamepad)。这可以让你把设备与特定的玩家联系起来,并区分你的输入是来自哪一个玩家。

你可以使用 Gamepads 资源 来列出所有当前连接的手柄设备的 ID,或者检查某个特定设备的状态。

为了检测游戏手柄何时连接或断开,你可以使用 GamepadEvent 事件

展示如何记住第一个连接成功的手柄 ID 的例子:

/// Simple resource to store the ID of the connected gamepad.
/// We need to know which gamepad to use for player input.
struct MyGamepad(Gamepad);

fn gamepad_connections(
    mut commands: Commands,
    my_gamepad: Option<Res<MyGamepad>>,
    mut gamepad_evr: EventReader<GamepadEvent>,
) {
    for GamepadEvent(id, kind) in gamepad_evr.iter() {
        match kind {
            GamepadEventType::Connected => {
                println!("New gamepad connected with ID: {:?}", id);

                // if we don't have any gamepad yet, use this one
                if my_gamepad.is_none() {
                    commands.insert_resource(MyGamepad(*id));
                }
            }
            GamepadEventType::Disconnected => {
                println!("Lost gamepad connection with ID: {:?}", id);

                // if it's the one we previously associated with the player,
                // disassociate it:
                if let Some(MyGamepad(old_id)) = my_gamepad.as_deref() {
                    if old_id == id {
                        commands.remove_resource::<MyGamepad>();
                    }
                }
            }
            // other events are irrelevant
            _ => {}
        }
    }
}

处理手柄输入

你可以用 Axis<GamepadAxis> (Axis, GamepadAxis)来处理模拟摇杆和触发开关。按键可以用 Input<GamepadButton> (Input, GamepadButton) 来处理,类似于鼠标按键键盘按键

注意,GamepadButton 中的按键名称是厂商中立的(比如使用 SouthEast,而非 X/OA/B)。

fn gamepad_input(
    axes: Res<Axis<GamepadAxis>>,
    buttons: Res<Input<GamepadButton>>,
    my_gamepad: Option<Res<MyGamepad>>,
) {
    let gamepad = if let Some(gp) = my_gamepad {
        // a gamepad is connected, we have the id
        gp.0
    } else {
        // no gamepad is connected
        return;
    };

    // The joysticks are represented using a separate axis for X and Y

    let axis_lx = GamepadAxis(gamepad, GamepadAxisType::LeftStickX);
    let axis_ly = GamepadAxis(gamepad, GamepadAxisType::LeftStickY);

    if let (Some(x), Some(y)) = (axes.get(axis_lx), axes.get(axis_ly)) {
        // combine X and Y into one vector
        let left_stick_pos = Vec2::new(x, y);

        // Example: check if the stick is pushed up
        if left_stick_pos.length() > 0.9 && left_stick_pos.y > 0.5 {
            // do something
        }
    }

    // In a real game, the buttons would be configurable, but here we hardcode them
    let jump_button = GamepadButton(gamepad, GamepadButtonType::South);
    let heal_button = GamepadButton(gamepad, GamepadButtonType::East);

    if buttons.just_pressed(jump_button) {
        // button just pressed: make the player jump
    }

    if buttons.pressed(heal_button) {
        // button being held down: heal the player
    }
}

你也可以使用 GamepadEvent 事件 来处理游戏手柄的输入:

fn gamepad_input_events(
    my_gamepad: Option<Res<MyGamepad>>,
    mut gamepad_evr: EventReader<GamepadEvent>,
) {
    let gamepad = if let Some(gp) = my_gamepad {
        // a gamepad is connected, we have the id
        gp.0
    } else {
        // no gamepad is connected
        return;
    };

    for GamepadEvent(id, kind) in gamepad_evr.iter() {
        if id.0 != gamepad.0 {
            // event not from our gamepad
            continue;
        }

        use GamepadEventType::{AxisChanged, ButtonChanged};

        match kind {
            AxisChanged(GamepadAxisType::RightStickX, x) => {
                // Right Stick moved (X)
            }
            AxisChanged(GamepadAxisType::RightStickY, y) => {
                // Right Stick moved (Y)
            }
            ButtonChanged(GamepadButtonType::DPadDown, val) => {
                // buttons are also reported as analog, so use a threshold
                if *val > 0.5 {
                    // button pressed
                }
            }
            _ => {} // don't care about other inputs
        }
    }
}

手柄设置

你可以使用 GamepadSettings 资源 来配置各个轴和按键的死区和其他参数。你可以设置全局默认值,也可以单独设置每个轴和按键。

下面是一个例子,展示了如何用自定义设置来配置游戏手柄(不一定是好的设置,请不要盲目地复制这些设置):

// this should be run once, when the game is starting
// (transition entering your in-game state might be a good place to put it)
fn configure_gamepads(
    my_gamepad: Option<Res<MyGamepad>>,
    mut settings: ResMut<GamepadSettings>,
) {
    let gamepad = if let Some(gp) = my_gamepad {
        // a gamepad is connected, we have the id
        gp.0
    } else {
        // no gamepad is connected
        return;
    };

    // add a larger default dead-zone to all axes (ignore small inputs, round to zero)
    settings.default_axis_settings.negative_low = -0.1;
    settings.default_axis_settings.positive_low = 0.1;

    // make the right stick "binary", squash higher values to 1.0 and lower values to 0.0
    let right_stick_settings = AxisSettings {
        positive_high:  0.5, // values  0.5 to  1.0, become  1.0
        positive_low:   0.5, // values  0.0 to  0.5, become  0.0
        negative_low:  -0.5, // values -0.5 to  0.0, become  0.0
        negative_high: -0.5, // values -1.0 to -0.5, become -1.0
        // the raw value should change by at least this much,
        // for Bevy to register an input event:
        threshold: 0.01,
    };

    // make the triggers work in big/coarse steps, to get fewer events
    // reduces noise and precision
    let trigger_settings = AxisSettings {
        threshold: 0.2,
        // also set some conservative deadzones
        positive_high: 0.8,
        positive_low: 0.2,
        negative_high: -0.8,
        negative_low: -0.2,
    };

    // set these settings for the gamepad we use for our player
    settings.axis_settings.insert(
        GamepadAxis(gamepad, GamepadAxisType::RightStickX),
        right_stick_settings.clone()
    );
    settings.axis_settings.insert(
        GamepadAxis(gamepad, GamepadAxisType::RightStickY),
        right_stick_settings.clone()
    );
    settings.axis_settings.insert(
        GamepadAxis(gamepad, GamepadAxisType::LeftZ),
        trigger_settings.clone()
    );
    settings.axis_settings.insert(
        GamepadAxis(gamepad, GamepadAxisType::RightZ),
        trigger_settings.clone()
    );

    // for buttons (or axes treated as buttons), make them less sensitive
    let button_settings = ButtonSettings {
        // require them to be pressed almost all the way, to count
        press: 0.9,
        // require them to be released almost all the way, to count
        release: 0.1,
    };

    settings.default_button_settings = button_settings;
}

触控屏

可参考的官方例子: touch_input, touch_input_events.


支持多点触摸的触控屏。你可以跟踪屏幕上的多个手指,并获得手指的位置、按压力量信息。Bevy 不提供手势识别。

Touches 资源 允许你跟踪当前在屏幕上的任何手指:

fn touches(
    touches: Res<Touches>,
) {
    // There is a lot more information available, see the API docs.
    // This example only shows some very basic things.

    for finger in touches.iter() {
        if touches.just_pressed(finger.id()) {
            println!("A new touch with ID {} just began.", finger.id());
        }
        println!(
            "Finger {} is at position ({},{}), started from ({},{}).",
            finger.id(),
            finger.position().x,
            finger.position().y,
            finger.start_position().x,
            finger.start_position().y,
        );
    }
}

或者,你也可以使用 TouchInput 事件

fn touch_events(
    mut touch_evr: EventReader<TouchInput>,
) {
    use bevy::input::touch::TouchPhase;
    for ev in touch_evr.iter() {
        // in real apps you probably want to store and track touch ids somewhere
        match ev.phase {
            TouchPhase::Started => {
                println!("Touch {} started at: {:?}", ev.id, ev.position);
            }
            TouchPhase::Moved => {
                println!("Touch {} moved to: {:?}", ev.id, ev.position);
            }
            TouchPhase::Ended => {
                println!("Touch {} ended at: {:?}", ev.id, ev.position);
            }
            TouchPhase::Cancelled => {
                println!("Touch {} cancelled at: {:?}", ev.id, ev.position);
            }
        }
    }
}

拖放文件

可参考的官方例子: drag_and_drop.


Bevy 支持大多数桌面操作系统上常见的拖放手势,但只支持文件,不支持任意的数据/对象。

如果你把一个文件(例如,从文件管理器应用程序)拖到 Bevy 的应用程序中,Bevy 将产生一个 FileDragAndDrop 事件,事件中包含被拖入的文件的路径。

通常,在一个图形应用程序中,你可能想根据它被放置的位置来做不同的事情。为此,你可以获取鼠标指针的位置,或者使用 Bevy UI Interaction

例如,这里是如何检测一个文件是否被投放到一个特殊的 UI 部件或元素(我们用一个自定义的标记组件来识别):

#[derive(Component)]
struct MyDropTarget;

fn file_drop(
    mut dnd_evr: EventReader<FileDragAndDrop>,
    query_ui_droptarget: Query<&Interaction, With<MyDropTarget>>,
) {
    for ev in dnd_evr.iter() {
        println!("{:?}", ev);
        if let FileDragAndDrop::DroppedFile { id, path_buf } = ev {
            println!("Dropped file with path: {:?}", path_buf);

            if id.is_primary() {
                // it was dropped over the main window
            }

            for interaction in query_ui_droptarget.iter() {
                if *interaction == Interaction::Hovered {
                    // it was dropped over our UI element
                    // (our UI element is being hovered over)
                }
            }
        }
    }
}

MIDI (乐器)

Bevy 还没有内置这个功能,但有一个第三方插件可用:bevy_midi

Bevy 编程框架

本章介绍了 Bevy 核心编程框架的功能特性。这包括 ECS(Entity Component System)、App(应用程序) 和 Scheduling(调度)。

关于编程模式和习语的例子,请看编程模式一章。

即使你想把 Bevy 作为游戏引擎以外的东西使用,本章的所有知识也是有用的。例如:只使用 ECS 进行科学模拟。

因此,本章不包括 Bevy 的游戏引擎部分。这些功能在本书的其他章节中涉及,如 [Bevy 游戏引擎核心功能] 章节。

本章包括对每个核心概念的简明解释,并附有代码片段,以说明如何在游戏中使用它。本书会刻意指出使用每个功能的任何重要注意事项,并推荐已知的最佳实践。

ECS 简介

可参考的官方例子: ecs_guide.

还可以参考完整的游戏实例: alien_cake_addict, breakout.


ECS 作为数据结构

Bevy 使用 Bevy ECS(Entity-Component System)为你存储和管理所有数据。

从概念上讲,你可以把它与数据库或电子表格中的表格进行类比。你的不同数据类型(组件)就像表格的"列","行"(实体)包含多个组件的值或实例。

例如,你可以为你的游戏创建一个 Health 组件。然后,你可以有许多实体代表你游戏中的不同事物,如玩家、NPC 或怪物,所有这些事物都可以有一个 Health 值(以及其他相关组件)。

这使得编写游戏逻辑(Systems)变得很容易,它可以对任何具有必要组件的实体进行操控(例如健康/伤害系统可以操控任何具有 Health 的事物),不管那是玩家、NPC 还是怪物(或其他什么)。这使得你的游戏逻辑非常灵活和可重复使用。

一个给定的实体所具有的组件的集合或组合,被称为实体的原型。

请注意,实体并不局限于"游戏世界中的对象"。ECS 是一个通用的数据结构。你可以创建实体和组件来存储任何数据。

性能

Bevy 有一个智能的调度算法,可以尽可能地并行运行你的系统。当你的函数不需要对相同的数据进行冲突的访问时,它就会自动这样做。你的游戏将可以"自由"地扩展到在多个 CPU 核心上运行,也就是说,不需要你额外的开发努力。

为了尽可能地提高并行化运行,你可以使你的数据和代码更加细化。将你的数据分割成更小的类型和结构。将你的逻辑分成多个小的系统或函数。让每个系统只访问与它相关的数据。访问冲突越少,游戏的运行速度就越快。

Bevy 性能的一般经验法则是:越细化越好。

给来自面向对象语言程序员的提示

你可能已经习惯了用"对象类"来思考。例如,你可能会想定义一个大的单体结构 Player,包含玩家的所有字段和属性。

在 Bevy 中,这被认为是不好的做法,因为这样做会使你的数据更难处理,并限制性能。

相反,当不同的数据片断可以被独立访问时,你应该让数据变得更细化。

例如,将游戏中的玩家表现为一个实体,由独立的组件类型(独立的 struct)组成,如健康、XP 或任何与你的游戏有关的东西。你也可以将标准的 Bevy 组件如 TransformTransform 解释)附加到它上面。

这将使你更容易开发你的系统(游戏逻辑和行为),以及使你的游戏运行性能更好。

当然,像 Transform 这样的东西,或者一组坐标,不进一步细化,而作为一个单一的结构同样是有意义的,因为它的字段不可能独立发挥作用。

实体和组件

可参考的官方例子: ecs_guide.


实体 Entities

实体只是一个简单的整数 ID,它标识了一组特定的组件值。

要创建("spawn")新的实体,请使用 Commands

组件 Components

组件是实体关联的数据。

要创建一个新的组件类型,只需定义一个 Rust 结构枚举,并派生 Component trait。

#[derive(Component)]
struct Health {
    hp: f32,
    extra: f32,
}

组件类型必须是唯一的,Rust 的每种类型中只能对应一个组件。

使用 newtype 模式来包装数据类型,从基本类型中制作出独特的组件:

#[derive(Component)]
struct PlayerXp(u32);

#[derive(Component)]
struct PlayerName(String);

你可以使用空结构体来帮助你识别特定的实体。这些被称为"标记组件"。对[查询过滤器][cb::queryfilter]非常有用。

/// Add this to all menu ui entities to help identify them
#[derive(Component)]
struct MainMenuUI;

/// Marker for hostile game units
#[derive(Component)]
struct Enemy;

/// This will be used to identify the main player entity
#[derive(Component)]
struct Player;

可以使用 queriessystems 中访问组件。

你可以使用 Commands 在现有实体上添加/删除组件。

组件 Bundle

Bundle 就像"模板"一样,使创建具有共同组件集的实体变得容易。

#[derive(Bundle)]
struct PlayerBundle {
    xp: PlayerXp,
    name: PlayerName,
    health: Health,
    _p: Player,

    // We can nest/include another bundle.
    // Add the components for a standard Bevy Sprite:
    #[bundle]
    sprite: SpriteSheetBundle,
}

Bevy 也将具有任意组件的元组视为 Bundle。

(ComponentA, ComponentB, ComponentC)

请注意,你不能查询整个 Bundle。Bundle 只是作为创建实体时的一种便捷方式,你仍需要在你的系统中单独地查询你想要访问的组件类型。

资源

可参考的官方例子: ecs_guide.


资源允许你独立于实体存储一些数据类型的单一全局实例。

在你的应用程序中仅仅使用资源来存储真正的全局数据,例如配置/设置。

任何 Rust 类型(结构枚举)都可以作为资源使用。目前,不需要满足特殊的 trait 或应用 derive,但在未来的 Bevy 版本中可能会改变(类似于组件的要求)。

资源类型必须是唯一的,一个特定资源类型只能有一个实例。

struct GoalsReached {
    main_goal: bool,
    bonus: bool,
}

系统中可以通过 Res/ResMut 来访问资源。

资源初始化

为简单资源派生用于初始化默认值的 Default 方法:

#[derive(Default)]
struct StartingLevel(usize);

通过实现 FromWorld 方来满足资源更复杂的初始化需求:

struct MyFancyResource { /* stuff */ }

impl FromWorld for MyFancyResource {
    fn from_world(world: &mut World) -> Self {
        // You have full access to anything in the ECS from here.
        // For instance, you can mutate other resources:
        let mut x = world.get_resource_mut::<MyOtherResource>().unwrap();
        x.do_mut_stuff();

        MyFancyResource { /* stuff */ }
    }
}

你可以在 App 创建时初始化你的资源:

    fn main() {
        App::new()
            // ...

            // if it implements `Default` or `FromWorld`
            .init_resource::<MyFancyResource>()
            // if not, or if you want to set a specific value
            .insert_resource(StartingLevel(3))

            // ...
            .run();
    }

Commands 可以用来在从系统中创建/删除资源:

    commands.insert_resource(GoalsReached { main_goal: false, bonus: false });
    commands.remove_resource::<MyResource>();

如果你插入的资源类型已经存在,之前的将被覆盖。

使用建议

何时使用实体/组件、或何时使用资源来存储数据,通常是关于你想如何访问数据的问题:使用从任何地方全局访问的模式(资源),还是使用 ECS 模式(实体/组件)。

即使在你的游戏中某个对象只有一个(比如单人游戏中的玩家),使用实体而使用资源也是很合适的,因为实体是由多个组件组成的,其中一些组件可以与其他实体共用。这可以使你的游戏逻辑更加灵活。例如,你可以用一个"健康/伤害系统",对玩家和敌人都有效。

系统

可参考的官方例子: ecs_guide, startup_system, system_param.


系统是你编写的函数,由 Bevy 运行。

这是你实现所有游戏逻辑的地方。

这些函数只能接受特殊的参数类型,以指定你想要访问的内容。如果你在你的函数中使用不支持的参数类型,你会得到令人疑惑的编译器错误

以下是一些可选项:

fn debug_start(
    // access resource
    start: Res<StartingLevel>
) {
    eprintln!("Starting on level {:?}", *start);
}

System parameters can be grouped into tuples (which can be nested). This is useful for organization.

fn complex_system(
    (a, mut b): (Res<ResourceA>, ResMut<ResourceB>),
    // this resource might not exist, so wrap it in an Option
    mut c: Option<ResMut<ResourceC>>,
) {
    if let Some(mut c) = c {
        // do something
    }
}

你的 system 函数总共最多可以有 16 个参数,如果你需要更多参数,用元组把它们包起来解决这个限制。单个元组最多可以包含 16 个成员,但可以无限地嵌套。

Runtime

To run your systems, you need to add them to Bevy via the app builder:

    fn main() {
        App::new()
            // ...

            // run it only once at launch
            .add_startup_system(init_menu)
            .add_startup_system(debug_start)

            // run it every frame update
            .add_system(move_player)
            .add_system(enemies_ai)

            // ...
            .run();
    }

The above is enough for simple projects.

As your project grows more complex, you might want to enhance your app builder with some of the powerful tools that Bevy offers for managing when/how your systems run, such as: explicit ordering with labels, system sets, states, run criteria, and stages.

查询

可参考的官方例子: ecs_guide.


查询可以让你访问构成实体的组件

fn check_zero_health(
    // access entities that have `Health` and `Transform` components
    // get read-only access to `Health` and mutable access to `Transform`
    // optional component: get access to `Player` if it exists
    mut query: Query<(&Health, &mut Transform, Option<&Player>)>,
) {
    // get all matching entities
    for (health, mut transform, player) in query.iter_mut() {
        eprintln!("Entity at {} has {} HP.", transform.translation, health.hp);

        // center if hp is zero
        if health.hp <= 0.0 {
            transform.translation = Vec3::ZERO;
        }

        if let Some(player) = player {
            // the current entity is the player!
            // do something special!
        }
    }
}

获取与一个特定 实体 相关的 组件

    if let Ok((health, mut transform)) = query.get_mut(entity) {
        // do something with the components
    } else {
        // the entity does not have the components from the query
    }

通过查询获取你所访问的实体的ID(Entity):

// add `Entity` to `Query` to get Entity IDs
fn query_entities(q: Query<(Entity, /* ... */)>) {
    for (e, /* ... */) in q.iter() {
        // `e` is the Entity ID of the entity we are accessing
    }
}

如果你知道你的查询只会匹配一个实体,你可以使用 single/single_mut 直接获取该实体,而不必使用迭代:

fn query_player(mut q: Query<(&Player, &mut Transform)>) {
    let (player, mut transform) = q.single_mut();

    // do something with the player and its transform
}

(如果查询匹配了一个以上的实体,这种访问方式将引起崩溃)

Bundle

查询适用于单个组件。如果你使用一个 bundle 创建了一个实体,你需要从该 bundle 中查询你所关心的特定组件。

一个常见的初学者的错误是查询 bundle 类型!

查询过滤器

添加查询过滤器来缩小你从查询中得到的实体的范围。

使用 With/Without 来只获得具有特定组件的实体。

fn debug_player_hp(
    // access the health, only for friendly players, optionally with name
    query: Query<(&Health, Option<&PlayerName>), (With<Player>, Without<Enemy>)>,
) {
    // get all matching entities
    for (health, name) in query.iter() {
        if let Some(name) = name {
            eprintln!("Player {} has {} HP.", name.0, health.hp);
        } else {
            eprintln!("Unknown player has {} HP.", health.hp);
        }
    }
}

多个过滤器可以合并使用:

  • 在一个元组中应用所有的过滤器(AND 逻辑)。
  • 使用 Or<(...)> 包装器来检测其中任何一个(OR 逻辑)。
    • (注意里面的元组)

命令

可参考的官方例子: ecs_guide.


使用 Commands 来创建/销毁实体、现有实体上添加/删除组件、管理资源。

这些命令行为不会立即生效,它们被排在队列中,以便在接下来安全的时候执行。参见:阶段

(如果你不使用阶段,这意味着你的其他 systems 将在下一帧更新时看到这些操作结果)。

fn spawn_player(
    mut commands: Commands,
) {
    // manage resources
    commands.insert_resource(GoalsReached { main_goal: false, bonus: false });
    commands.remove_resource::<MyResource>();

    // create a new entity using `spawn`
    let entity_id = commands.spawn()
        // add a component
        .insert(ComponentA)
        // add a bundle
        .insert_bundle(MyBundle::default())
        // get the Entity ID
        .id();

    // shorthand for creating an entity with a bundle
    commands.spawn_bundle(PlayerBundle {
        name: PlayerName("Henry".into()),
        xp: PlayerXp(1000),
        health: Health {
            hp: 100.0, extra: 20.0
        },
        _p: Player,
        sprite: Default::default(),
    });

    // spawn another entity
    // NOTE: tuples of arbitrary components are valid bundles
    let other = commands.spawn_bundle((
        ComponentA::default(),
        ComponentB::default(),
        ComponentC::default(),
    )).id();

    // add/remove components of an existing entity
    commands.entity(entity_id)
        .insert(ComponentB)
        .remove::<ComponentA>()
        .remove_bundle::<MyBundle>();

    // despawn an entity
    commands.entity(other).despawn();
}

fn make_all_players_hostile(
    mut commands: Commands,
    query: Query<Entity, With<Player>>,
) {
    for entity in query.iter() {
        // add an `Enemy` component to the entity
        commands.entity(entity).insert(Enemy);
    }
}

事件

可参考的官方例子: [ecs_event][example::ecs_event].


通过事件,可以在系统之间发送数据! 让你的系统之间相互沟通!

要发送事件,使用 EventWriter<T>。要接收事件,使用 EventReader<T>

每个 reader 都独立地跟踪它所读取的事件,所以你可以处理来自多个系统的相同事件。

    struct LevelUpEvent(Entity);

    fn player_level_up(
        mut ev_levelup: EventWriter<LevelUpEvent>,
        query: Query<(Entity, &PlayerXp)>,
    ) {
        for (entity, xp) in query.iter() {
            if xp.0 > 1000 {
                ev_levelup.send(LevelUpEvent(entity));
            }
        }
    }

    fn debug_levelups(
        mut ev_levelup: EventReader<LevelUpEvent>,
    ) {
        for ev in ev_levelup.iter() {
            eprintln!("Entity {:?} leveled up!", ev.0);
        }
    }

你需要通过应用程序生成器添加你的自定义事件类型:

    fn main() {
        App::new()
            // ...
            .add_event::<LevelUpEvent>()
            .add_system(player_level_up)
            .add_system(debug_levelups)
            // ...
            .run();
    }

事件是你处理跳转类数据流的工具。由于事件可以从任何系统发送并被多个系统接收,因此它们的用途非常广泛。

可能的陷阱

小心帧延迟、帧滞后的问题,如果 Bevy 在发送系统之前运行接收系统,这样的问题可能会发生。接收系统只有在下一帧更新时才有机会接收事件。如果你需要确保事件被立即、在同一帧内处理,你可以使用显式系统排序

事件不会持久化。它们被存储直到下一帧的结束,之后就会丢弃。如果你的系统不是每一帧都处理事件,你可能会错过一些事件。

这种设计的好处是,你不必担心未处理的事件会造成过多的内存占用。

如果你不喜欢这样,你可以手动控制事件被清除的时间(风险是如果你忘记清除它们,就有可能会内存泄漏和内存浪费)。

应用构造器

可参考的官方例子:所有例子 :)

特别是可以看看完整的游戏例子: alien_cake_addict, breakout.


要进入 bevy 的 runtime,你需要配置一个 App。通过 App 是你可以定义构成你的项目的所有组成部分:插件 plugins系统 systems事件 event 类型、状态 states阶段 stages......

从技术上讲,App 包含 ECS 世界(所有数据被存储在其中)和调度器(所有要运行的系统被存储在其中)。对于高级的使用情况,子应用程序 是一种实现拥有一个以上 ECS 世界和调度器的方式。

本地资源不需要被注册。它们是它们各自系统的一部分。

组件类型不需要被注册。


调度器(还)不能在运行时修改,你想运行的所有系统必须提前在 App 中添加/配置。

ECS 世界中的数据可以在任何时候被修改,在系统中使用 Commands 创建/销毁你的实体资源,或不使用系统而使用 World 直接访问。

资源也可以提前在应用程序构造器中初始化。


你还需要添加具有 Bevy 内置功能的插件组:如果你正在制作一个完整的游戏或应用程序,可以选择 DefaultPlugins,如果是开发服务器程序之类的,可以选择 MinimalPlugins

注意,有一些特殊的配置资源,如果你想使用它们,必须先添加才能生效。


    fn main() {
        App::new()
            // make sure to add any config resources first, before Bevy:
            .insert_resource(WindowDescriptor {
                // ...
                ..Default::default()
            }) // etc...

            // Bevy itself:
            .add_plugins(DefaultPlugins)

            // resources:
            .insert_resource(StartingLevel(3))
            // if it implements `Default` or `FromWorld`
            .init_resource::<MyFancyResource>()

            // events:
            .add_event::<LevelUpEvent>()

            // systems to run once at startup:
            .add_startup_system(spawn_player)

            // systems to run each frame:
            .add_system(player_level_up)
            .add_system(debug_levelups)
            .add_system(debug_stats_change)
            // ...

            // launch the app!
            .run();
    }

结束应用

为了干净利落地关闭 bevy,可以从任何系统中发送一个 AppExit 事件

use bevy::app::AppExit;

fn exit_system(mut exit: EventWriter<AppExit>) {
    exit.send(AppExit);
}

为原型设计开发过程方便退出应用,bevy 提供了一个系统,你可以添加到你的应用程序中,当你按下 Esc 键时应用直接关闭退出。

For prototyping, bevy provides a system you can add to your App, to exit on pressing the Esc key:

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_system(bevy::input::system::exit_on_esc_system)
        .run();
}

本地资源

可参考的官方例子: ecs_guide.


本地资源允许你拥有属于每个系统自己的数据。

Local<T> 是一个类似于 ResMut<T> 的系统参数,它让你可以完全地读写访问某个数据类型的实例,这实例是独立于实体和组件。

Res<T>/ResMut<T> 引用的是某数据类型的一个全局实例,可以在所有系统之间共享。与之对应,每个 Local<T> 参数引用的都是一个单独的实例,专属于该系统。

#[derive(Default)]
struct MyState;

fn my_system1(mut local: Local<MyState>) {
    // you can do anything you want with the local here
}

fn my_system2(mut local: Local<MyState>) {
    // the local in this system is a different instance
}

作为本地资源的类型必须实现 DefaultFromWorld 方法,它会被自动初始化。

一个系统可以有多个相同类型的 Local 参数。

指定一个初始值

你可以在把你的系统添加到你的 App 中时,使用系统的 .config 方法可以把 Local 初始化为该类型默认值以外的值。

.config 是 Bevy 的 API,用于"配置"特定的系统参数。系统的大多数其他类型的参数不支持配置,但 Local 允许你指定初始值。

    /// Configuration for `my_system`.
    ///
    /// The system will access it using `Local<MyConfig>`.
    /// It will be initialized with the correct value at App build time.
    ///
    /// Must still impl `Default`, because of requirement for `Local`.
    #[derive(Default)]
    struct MyConfig {
        magic: usize,
    }

    fn my_system(
        mut cmd: Commands,
        my_res: Res<MyStuff>,
        config: Local<MyConfig>,
    ) {
        // TODO: do stuff
    }

    fn main() {
        App::new()
            .add_system(my_system.config(|params| {
                // our config is the third parameter in the system fn,
                // hence `.2`
                params.2 = Some(MyConfig {
                    magic: 420,
                });
            }))
            .run();
    }

插件

可参考的官方例子: plugin, plugin_group.


随着你的项目的成长,使其更加模块化会非常有用。你可以把它分成"插件"。

插件只是要添加到 应用创建器 中的东西的集合。

    struct MyPlugin;

    impl Plugin for MyPlugin {
        fn build(&self, app: &mut App) {
            app
                .init_resource::<MyOtherResource>()
                .add_event::<MyEvent>()
                .add_startup_system(plugin_init)
                .add_system(my_system);
        }
    }

    fn main() {
        App::new()
            .add_plugins(DefaultPlugins)
            .add_plugin(MyPlugin)
            .run();
    }

对于你自己项目的内部组织来说,插件的主要价值来自于不必将你所有的 Rust 类型和函数声明为 pub,只是为了让它们能够从 fn main 中访问,从而被添加到应用创建器中。 插件让你从多个不同的地方向你的应用程序添加东西,就像单独的 Rust 文件/模块一样。

你可以决定插件如何融入你的游戏架构。

一些建议:

  • 为不同的游戏状态创建插件。
  • 为各种子系统创建插件,如物理系统或输入处理。

插件组

插件组一次可以注册多个插件。Bevy 的 DefaultPluginsMinimalPlugins 就是这样的例子。

创建你自己的插件组:

    struct MyPluginGroup;

    impl PluginGroup for MyPluginGroup {
        fn build(&mut self, group: &mut PluginGroupBuilder) {
            group
                .add(FooPlugin)
                .add(BarPlugin);
        }
    }

    fn main() {
        App::new()
            .add_plugins(DefaultPlugins)
            .add_plugins(MyPluginGroup)
            .run();
    }

在向应用程序添加插件组时,你可以禁用一些插件,而保留其他插件。

例如,如果你想手动设置日志(用你自己的 tracing 订阅者),你可以禁用 Bevy 的 LogPlugin

        App::new()
            .add_plugins_with(DefaultPlugins, |plugins| {
                plugins.disable::<LogPlugin>()
            })
            .run();

请注意,这只是简单地禁用了功能,但它不是真正删除代码以避免应用程序的二进制文件的膨胀。被禁用的插件仍然会被编译进你的程序中。

如果你想给你的编译产物瘦身,你应该禁用 Bevy 的默认库功能,或者单独使用 Bevy 的各种子库。

发布库

插件给了你一个很好的途径来发布基于 Bevy 的库,让其他人可以很容易地将其应用到他们的项目中。

如果你打算将插件发布为公共使用的库,你应该阅读官方的插件作者指南

不要忘记在官方网站上向 Bevy Assets 提交关于你插件的条目,这样人们可以更容易地找到你的插件。你可以通过在 Github 仓库 中提交一个 PR 来做到这一点。

如果你的插件有兴趣支持 Bevy 开发主分支,请参考这里的建议。

系统执行顺序

Bevy 的调度算法被设计为通过在可用的 CPU 线程上并行执行尽可能多的系统来提供最大的性能。

当各系统在访问需要的数据没有冲突时,这是有可能的。然而,当一个系统需要对某数据进行写(独占)访问时,其他需要访问同一数据的系统则不能同时运行。Bevy 从系统的函数签名(它的参数类型)中来确定所有这些信息。

默认情况下系统的执行顺序是不确定的。Bevy 不考虑每个系统何时执行,每帧的执行顺序甚至都可能在发生变化!

这有什么关系吗?

在许多情况下,你不需要担心这个问题。

然而,有时你需要依靠特定的系统以特定的顺序运行。比如说:

  • 也许你在一个系统中写的逻辑依赖于另一个系统对该数据做的任何修改总是先发生?
  • 一个系统需要接收另一个系统发送的事件。
  • 你正在进行变更检测。

在这种情况下,系统以错误的顺序执行通常会导致其行为被延迟到下一帧。在极少数情况下,根据你的游戏逻辑,这甚至可能导致更严重的逻辑错误。

是否重要取决于你的决定。

对于典型游戏中的许多情况,例如果汁的视觉效果,如果它们被延迟了一帧,可能并不重要,也不值得为它费心。如果在这里忽视执行顺序也可能带来更好的性能。

另一方面,对于像处理玩家输入控制这样的情况,这将导致恼人的滞后体验,所以你需要修复它。

明确的系统排序

解决方案是使用系统标签来明确指定你想要的顺序:

    fn main() {
        App::new()
            .add_plugins(DefaultPlugins)

            // order doesn't matter for these systems:
            .add_system(particle_effects)
            .add_system(npc_behaviors)
            .add_system(enemy_movement)

            // create labels, because we need to order other systems around these:
            .add_system(map_player_input.label("input"))
            .add_system(update_map.label("map"))

            // this will always run before anything labeled "input"
            .add_system(input_parameters.before("input"))

            // this will always run after anything labeled "input" and "map"
            // also label it just in case
            .add_system(
                player_movement
                    .label("player_movement")
                    .after("input")
                    .after("map")
            )
            .run();
    }

每个标签都是一个参考点,用于对系统进行排序。

.label/.before/.after 方法可以根据需要在一个系统上使用多次。

你可以在一个系统上放置多个标签。

你也可以在多个系统上使用同一个标签。

当你有多个系统具有相同标签或执行顺序时,使用系统集可能更方便。

循环依赖关系

如果你有多个系统相互依赖,那么显然即使用标签也不可能完全解决问题。

你应该试着重新设计你的游戏以避免这种情况,或者只能接受现实。即使这样,你仍然可以按想要的方式对系统显式排序,至少确保最终结果是可预测的。

系统集

系统集允许你轻松地将共同的属性应用于多个系统,以达到诸如标签排序执行条件状态等目的。

    fn main() {
        App::new()
            .add_plugins(DefaultPlugins)

            // group our input handling systems into a set
            .add_system_set(
                SystemSet::new()
                    .label("input")
                    .with_system(keyboard_input)
                    .with_system(gamepad_input)
            )

            // our "net" systems should run before "input"
            .add_system_set(
                SystemSet::new()
                    .label("net")
                    .before("input")
                    // individual systems can still have
                    // their own labels (and ordering)
                    .with_system(server_session.label("session"))
                    .with_system(server_updates.after("session"))
            )

            // some ungrouped systems
            .add_system(player_movement.after("input"))
            .add_system(session_ui.after("session"))
            .add_system(smoke_particles)

            .run();
    }

变更检测

可参考的官方例子: component_change_detection.


Bevy 允许你轻松地检测数据何时发生了改变。你可以用它来执行响应变更的行为。

组件

使用查询过滤器

  • Added<T>: 检测新组件实例
    • 如果该组件被添加到一个现有的实体中
    • 如果一个带有该组件的新实体被创建出来
  • Changed<T>: 检测被变更的组件实例
    • 当组件被写访问时触发
    • 如果组件是新添加的,也会触发(同 Added)。

(如果你想对移除组件作出反应,请参阅移除检测页面。它的工作方式不同,使用起来也更麻烦)。

/// Print the stats of friendly players when they change
fn debug_stats_change(
    query: Query<
        // components
        (&Health, &PlayerXp),
        // filters
        (Without<Enemy>, Or<(Changed<Health>, Changed<PlayerXp>)>),
    >,
) {
    for (health, xp) in query.iter() {
        eprintln!(
            "hp: {}+{}, xp: {}",
            health.hp, health.extra, xp.0
        );
    }
}

/// detect new enemies and print their health
fn debug_new_hostiles(
    query: Query<(Entity, &Health), Added<Enemy>>,
) {
    for (entity, health) in query.iter() {
        eprintln!("Entity {:?} is now an enemy! HP: {}", entity, health.hp);
    }
}

Changed 检测是由 DerefMut 触发的。仅仅通过可变查询访问组件,而不实际执行 &mut 访问,将不会触发它。

这使得变更检测相当准确。你可以依靠它来优化你的游戏性能,或者故意来触发变更事件的发生。

还要注意的是,当你变更一个组件时,Bevy 并不跟踪新值是否真的与旧值不同。它总是会触发变更检测。如果你想避免这种情况,只需自己判断数据前后是否一致:

fn update_player_xp(
    mut query: Query<&mut PlayerXp>,
) {
    for mut xp in query.iter_mut() {
        let new_xp = maybe_lvl_up(&xp);

        // avoid triggering change detection if the value is the same
        if new_xp != *xp {
            *xp = new_xp;
        }
    }
}

变更检测是可靠的 -- 它将检测自你的检测系统上一次运行以来发生的所有变更。如果你的系统只是偶尔运行(如状态执行条件),你不必担心错过变化。

资源

对于资源,变更检测是通过 Res/ResMut 系统参数的方法提供的。

fn check_res_changed(
    my_res: Res<MyResource>,
) {
    if my_res.is_changed() {
        // do something
    }
}

fn check_res_added(
    // use Option, not to panic if the resource doesn't exist yet
    my_res: Option<Res<MyResource>>,
) {
    if let Some(my_res) = my_res {
        // the resource exists

        if my_res.is_added() {
            // it was just added
            // do something
        }
    }
}

这与组件的变化检测的工作方式相同。

请注意,变化检测目前不能用于检测状态变化(即通过 State 资源)(bug)。

可能的陷阱

注意帧延迟和滞后一帧的问题。如果 Bevy 在变更系统之前运行了检测系统,就会发生这种情况。检测系统将只能在下次运行时看到变化,通常是在下一帧更新时。

如果你需要确保变化被立即(在同一帧中)处理,你可以使用显式的系统排序。

然而,当用 Added<T> 来检测组件的添加时(通常是用 Commands 来完成),这还不够,你需要使用阶段

状态

可参考的官方例子: state.


状态允许你构造你应用程序的运行时"流程"。

下面是你可以通过状态实现的功能,比如:

  • 一个菜单页或一个加载页
  • 暂停或结束暂停游戏
  • 不同的游戏模式
  • ...

在每个状态下,你可以有不同的系统被执行。你也可以添加一次性的设置和清理系统,以更在进入或退出一个状态时运行。

要使用状态,请定义一个枚举类型,并在你的应用程序构造器中添加系统集

    #[derive(Debug, Clone, Eq, PartialEq, Hash)]
    enum AppState {
        MainMenu,
        InGame,
        Paused,
    }

    fn main() {
        App::new()
            .add_plugins(DefaultPlugins)

            // add the app state type
            .add_state(AppState::MainMenu)

            // add systems to run regardless of state, as usual
            .add_system(play_music)

            // systems to run only in the main menu
            .add_system_set(
                SystemSet::on_update(AppState::MainMenu)
                    .with_system(handle_ui_buttons)
            )

            // setup when entering the state
            .add_system_set(
                SystemSet::on_enter(AppState::MainMenu)
                    .with_system(setup_menu)
            )

            // cleanup when exiting the state
            .add_system_set(
                SystemSet::on_exit(AppState::MainMenu)
                    .with_system(close_menu)
            )
            .run();
    }

对多个系统集使用相同的状态是可以的。

当你想设置标签和使用明确的系统排序时,这非常有用。

这对插件来说也很有用。每个插件都可以为同一个状态添加自己的系统集。

状态在底层是通过执行条件实现的。这些特殊的系统集构造函数实际上只是为自动添加状态管理执行条件的辅助工具。

控制状态

在系统的内部,你可以使用 State<T> 资源检查和控制状态。

    fn play_music(
        app_state: Res<State<AppState>>,
        // ...
    ) {
        match app_state.current() {
            AppState::MainMenu => {
                // TODO: play menu music
            }
            AppState::InGame => {
                // TODO: play game music
            }
            AppState::Paused => {
                // TODO: play pause screen music
            }
        }
    }

要改变到另一个状态:

    fn enter_game(mut app_state: ResMut<State<AppState>>) {
        app_state.set(AppState::InGame).unwrap();
        // ^ this can fail if we are already in the target state
        // or if another state change is already queued
    }

在当前状态的系统执行完成后,Bevy 将会转换到你设定的下一个状态。

你可以在一帧更新中做任意多的状态转换。Bevy 会处理好所有这些状态转换,并执行所有相关的系统(在进入下一阶段之前)。

状态栈

你不需要完全从一个状态转换到另一个状态,你也可以进行叠加状态,形成一个状态堆栈。

这就是你如何实现诸如"游戏暂停"屏幕、或一个菜单叠加屏幕的同时,而游戏世界仍然可见或在后景运行。

你可以有一些系统,即使在所属状态"未激活"时仍然在运行(也就是该状态在后台,其他状态在它上面运行)。你也可以在"暂停"或"恢复"状态时来添加并运行一次性系统。

在你的应用程序构造器中如下:

            // player movement only when actively playing
            .add_system_set(
                SystemSet::on_update(AppState::InGame)
                    .with_system(player_movement)
            )
            // player idle animation while paused
            .add_system_set(
                SystemSet::on_inactive_update(AppState::InGame)
                    .with_system(player_idle)
            )
            // animations both while paused and while active
            .add_system_set(
                SystemSet::on_in_stack_update(AppState::InGame)
                    .with_system(animate_trees)
                    .with_system(animate_water)
            )
            // things to do when becoming inactive
            .add_system_set(
                SystemSet::on_pause(AppState::InGame)
                    .with_system(hide_enemies)
            )
            // things to do when becoming active again
            .add_system_set(
                SystemSet::on_resume(AppState::InGame)
                    .with_system(reset_player)
            )
            // setup when first entering the game
            .add_system_set(
                SystemSet::on_enter(AppState::InGame)
                    .with_system(setup_player)
                    .with_system(setup_map)
            )
            // cleanup when finally exiting the game
            .add_system_set(
                SystemSet::on_exit(AppState::InGame)
                    .with_system(despawn_player)
                    .with_system(despawn_map)
            )

为了管理这样的状态栈,使用 push/pop

        // to go into the pause screen
        app_state.push(AppState::Paused).unwrap();
        // to go back into the game
        app_state.pop().unwrap();

(如前所示,使用 .set 方法来替换堆栈顶部的活动状态)

已知的陷阱和限制

事件

当在不是一直运行的系统中接收事件时,例如在暂停状态下,你会错过任何在接收系统不运行的帧中发送的事件

为了减轻这种情况,你可以实现一个自定义的清理策略,来手动管理相关事件类型的生命周期。

结合其它执行条件

因为状态是用执行条件来实现的,所以把它们和其他使用的执行条件结合起来将会很棘手,比如固定的时间步长。

如果你试图给你的系统集添加另一个执行条件,它将取代 Bevy 的状态管理执行条件!这将使系统集的执行不再受到状态的约束。

使用一些技巧仍然有可能完成这样的目的。

(TODO)展示一个例子,说明如何做到这一点。

With Input

如果你想通过 Input<T> 的按键被按下来触发状态转换的话,随即你需要通过调用 .reset 手动清除刚才的输入:

    fn esc_to_menu(
        mut keys: ResMut<Input<KeyCode>>,
        mut app_state: ResMut<State<AppState>>,
    ) {
        if keys.just_pressed(KeyCode::Escape) {
            app_state.set(AppState::MainMenu).unwrap();
            keys.reset(KeyCode::Escape);
        }
    }

(注意,这需要 ResMut)

不这样做会导致出现这个问题

多阶段

如果你需要在多个阶段中依赖状态系统,这需要一个变通的方法。

你必须只将状态添加到一个阶段,然后调用 .get_driver() 并在这些阶段中的任何依赖状态的系统集之前将其添加到其他阶段。

(TODO)例子

执行条件

执行条件是一种机制,用于控制 Bevy 在运行时是否应该执行特定的系统。这就是你控制如何使功能只在特定条件下被执行。

执行条件是一个较底层的基本功能。Bevy 在上面提供了更高层次的抽象,比如 状态。如果你真的需要更直接的控制,你可以使用执行条件,而不使用这些抽象概念。

执行条件可以应用于单个系统系统集阶段

执行条件也是 Bevy 系统,它返回一个类型为 enum ShouldRun 的值。它们可以接受任何系统参数,就像普通系统一样。

这个例子显示了如何使用执行条件来实现不同的多人游戏模式:

    use bevy::ecs::schedule::ShouldRun;

    #[derive(Debug, PartialEq, Eq)]
    enum MultiplayerKind {
        Client,
        Host,
        Local,
    }

    fn run_if_connected(
        mode: Res<MultiplayerKind>,
        session: Res<MyNetworkSession>,
    ) -> ShouldRun
    {
        if *mode == MultiplayerKind::Client && session.is_connected() {
            ShouldRun::Yes
        } else {
            ShouldRun::No
        }
    }

    fn run_if_host(
        mode: Res<MultiplayerKind>,
    ) -> ShouldRun
    {
        if *mode == MultiplayerKind::Host || *mode == MultiplayerKind::Local {
            ShouldRun::Yes
        } else {
            ShouldRun::No
        }
    }

    fn main() {
        App::new()
            .add_plugins(DefaultPlugins)

            // if we are currently connected to a server,
            // activate our client systems
            .add_system_set(
                SystemSet::new()
                    .with_run_criteria(run_if_connected)
                    .before("input")
                    .with_system(server_session)
                    .with_system(fetch_server_updates)
            )

            // if we are hosting the game,
            // activate our game hosting systems
            .add_system_set(
                SystemSet::new()
                    .with_run_criteria(run_if_host)
                    .before("input")
                    .with_system(host_session)
                    .with_system(host_player_movement)
                    .with_system(host_enemy_ai)
            )

            // other systems in our game
            .add_system(smoke_particles)
            .add_system(water_animation)
            .add_system_set(
                SystemSet::new()
                    .label("input")
                    .with_system(keyboard_input)
                    .with_system(gamepad_input)
            )
            .run();
    }

执行条件标签

如果你有多个系统或系统集,你想共享同一个执行标准,你可以给这个执行条件一个标签

当你使用标签时,Bevy 将只执行一次执行条件系统,记住它的输出结果,并将结果应用于所有带有标签的任务。

        #[derive(Debug, Clone, PartialEq, Eq, Hash)]
        #[derive(RunCriteriaLabel)]
        enum MyRunCriteria {
            Client,
            Host,
        }

        fn main() {
            App::new()
                // ...
                .add_system_set(
                    SystemSet::new()
                        .with_run_criteria(
                            // assign it a label
                            run_if_host
                                .label(MyRunCriteria::Host)
                        )
                        .with_system(host_session)
                        .with_system(host_player_movement)
                        .with_system(host_enemy_ai)
                )

                // extra system for debugging the host
                // it can share our previously-registered run criteria
                .add_system(host_debug
                    .with_run_criteria(MyRunCriteria::Host)
                )
                .run();
        }

如果你有一个复杂的执行条件系统,该系统会进行写操作或其他非幂等性操作,那么一次性执行的属性就特别重要。

已知的陷阱

当在一个不是每帧都被执行的系统中接收事件时,在接收系统不执行的帧中,所有发送的事件都将会错过。

为了应对这种情况,你可以实现一个自定义的事件清理策略,以便手动管理相关事件类型的生命周期。


Bevy 的固定时间步长在底层也是通过执行条件实现的。

标签

标签可以给你应用程序中的各种东西命名,比如系统(用于执行顺序控制)、执行条件阶段和其它集合。

Bevy 使用了一些聪明的 Rust 类型系统魔法,允许你使用字符串和你自定义的类型作为标签,甚至可以将它们混合使用!

使用字符串做标签对于原型设计来说是既快速又简单。然而,它们很容易打错,而且是非结构化的。编译器不能帮助你验证它们以发现错误。

你也可以使用自定义类型(通常是枚举)来定义你的标签。这使编译器可以检查它们,有助于你在较大的项目中保持标签的组织性。

你需要根据它们的用途,派生出适当的 trait:StageLabelSystemLabelRunCriteriaLabel,或者 AmbiguitySetLabel

任何 Rust 类型都是合适的,只要它满足这些标准的 Rust trait:Clone + Eq + Hash + Debug (以及隐含的 Send + Sync + 'static)。

    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
    #[derive(SystemLabel)]
    enum MySystems {
        InputSet,
        Movement,
    }

    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
    #[derive(StageLabel)]
    enum MyStages {
        Prepare,
        Cleanup,
    }

    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
    #[derive(StageLabel)]
    struct DebugStage;

    fn main() {
        App::new()
            .add_plugins(DefaultPlugins)

            // Add our game systems:
            .add_system_set(
                SystemSet::new()
                    .label(MySystems::InputSet)
                    .with_system(keyboard_input)
                    .with_system(gamepad_input)
            )
            .add_system(player_movement.label(MySystems::Movement))

            // temporary debug system, let's just use a string label
            .add_system(debug_movement.label("temp-debug"))

            // Add our custom stages:
            // note that Bevy's `CoreStage` is an enum just like ours!
            .add_stage_before(CoreStage::Update, MyStages::Prepare, SystemStage::parallel())
            .add_stage_after(CoreStage::Update, MyStages::Cleanup, SystemStage::parallel())

            .add_stage_after(CoreStage::Update, DebugStage, SystemStage::parallel())

            // we can just use a string for this one:
            .add_stage_before(CoreStage::PostUpdate, "temp-debug-hack", SystemStage::parallel())

            .run();
    }

对于快速原型设计,只用字符串作为标签是很方便的。

然而,通过将你的标签定义为自定义类型,Rust 编译器可以为你检查它们,你的 IDE 输入时也可以自动补全。这是推荐的方式,因为它可以防止错误,并帮助你在大型项目中保持组织性。

阶段

所有要由 Bevy 运行的系统都包含在阶段中。每一帧更新,Bevy 都按顺序执行每个阶段。在每个阶段中,Bevy 的调度算法通过使用多个 CPU 核,可以并行地运行许多系统,以获得良好的性能。

阶段之间的边界是同一阶段内所有系统的强制同步点。它们确保前一阶段的所有系统在下一阶段的任何系统开始之前全部已经执行结束,所以有这么一个时间点,没有任系统处于执行状态。

这使得安全地使用命令成为可能。任何在系统中使用 命令 进行的操作都会在该阶段结束时应用生效。

在内部,Bevy 至少有这些内置的阶段:

  • 在主应用程序(CoreStage)中:FirstPreUpdateUpdatePostUpdateLast
  • 在渲染子程序(RenderStage)中:ExtractPrepareQueuePhaseSortRenderCleanup

默认情况下,当你添加你的系统时,它们会被添加到 CoreStage::Update

Bevy 的内部系统存在于其他阶段,相对于你的游戏逻辑,以确保它们执行顺序正确。

如果你想在 Bevy 的任何一个内部阶段添加你自己的系统,你需要谨防与 Bevy 自己的内部系统发生潜在的意外干扰。记住:Bevy 的内部系统是用普通的系统和 ECS 实现的,就像你自己的系统一样!

你可以添加你自己的附加阶段。例如,如果我们希望在我们的游戏逻辑之后运行我们的调试系统:

    fn main() {
        // label for our debug stage
        static DEBUG: &str = "debug";

        App::new()
            .add_plugins(DefaultPlugins)

            // add DEBUG stage after Bevy's Update
            // also make it single-threaded
            .add_stage_after(CoreStage::Update, DEBUG, SystemStage::single_threaded())

            // systems are added to the `CoreStage::Update` stage by default
            .add_system(player_gather_xp)
            .add_system(player_take_damage)

            // add our debug systems
            .add_system_to_stage(DEBUG, debug_player_hp)
            .add_system_to_stage(DEBUG, debug_stats_change)
            .add_system_to_stage(DEBUG, debug_new_hostiles)

            .run();
    }

如果你需要管理你的系统之间相对何时运行,通常最好避免使用阶段,而使用明确的系统排序。阶段限制了并行执行和你游戏的性能。

然而,当你真的想确保所有先前的系统执行结束,阶段可以使事情更容易组织。阶段也是应用命令的唯一方法。

如果你有一些系统的执行需要依赖于其他系统通过使用命令操作的结果,并且需要在同一帧内完成,那么将这些系统放入单独的阶段是实现这一目标的唯一途径。

[WIP] Direct World/ECS Access

独占系统

独占系统是指 Bevy 不会与任何其他系统并行运行的 系统]cb::system。他们可以通过采取 &mut World 参数完全不受限制地访问整个 ECS World

在一个独占系统中,你可以完全控制存储在 ECS 中的所有数据。你可以做任何你想做的事情。

请注意,独占系统会限制性能,因为它阻止了多线程(没有其他东西在同一时间运行)。

独占系统的一些有用例子:

  • 将各种实体和组件转储到一个文件中,以实现游戏文件的保存和加载,或从编辑器中导出场景等功能。
  • 直接生成/销毁 实体,或者创建/删除 资源,实现操作没有任何延迟(不像在普通系统中使用命令)。
  • 用你自己的调度算法运行任意的系统
  • ...

请参阅 直接访问 World 页面,以了解如何实现的更多细节。

    fn do_crazy_things(world: &mut World) {
        // we can do anything with any data in the Bevy ECS here!
    }

你需要向 应用 添加你的独占系统,就像普通系统一样,但你必须对它们调用 .exclusive_system()

不能把它们在常规的并行系统之间排序,独占系统总是在以下的一个地方运行:

  • .at_start(): 在一个 阶段 的开始处
  • .at_end(): 在一个 阶段 结束时,在常规系统的 命令 被应用之后
  • .before_commands(): 在一个 阶段 内的所有常规系统之后,但在 命令 被应用之前 (如果你没有指定,默认是 .at_start())
    fn main() {
        App::new()
            .add_plugins(DefaultPlugins)

            // this will run at the start of CoreStage::Update (the default stage)
            .add_system(do_crazy_things.exclusive_system())

            // this will run at the end of CoreStage::PostUpdate
            .add_system_to_stage(
                CoreStage::PostUpdate,
                some_more_things
                    .exclusive_system()
                    .at_end()
            )

            .run();
    }

移除检测

可参考的官方例子: removal_detection.


移除检测很特别,这是因为,与变更检测不同,移除对象的数据不再存在于 ECS 中(很明显),所以 Bevy 无法保持跟踪它的元数据。

尽管如此,能够对移除作出反应对某些应用来说是很重要的,所以 Bevy 提供了一种有限的形式。

组件

你可以检查在当前帧中已经被移除的组件,该数据在每一帧更新结束时被清除。注意,这使得这个功能使用起来很麻烦,需要你使用多个阶段

当你删除一个组件时(使用命令),该操作会在阶段结束时生效,在同一帧更新期间,检查移除的系统必须在移除操作所在阶段的后一个阶段运行。否则,它将无法检测到移除的情况。

使用 RemovedComponents<T> 这个特殊的系统参数类型,可以得到一个迭代器,用来获取所有实体的 ID,这些实体有一个类型为 T 的组件,在这个帧的早的时候被移除。

    /// Some component type for the sake of this example.
    #[derive(Component)]
    struct Seen;

    fn main() {
        App::new()
            .add_plugins(DefaultPlugins)
            // we could add our system to Bevy's `PreUpdate` stage
            // (alternatively, you could create your own stage)
            .add_system_to_stage(CoreStage::PreUpdate, remove_components)
            // our detection system runs in a later stage
            // (in this case: Bevy's default `Update` stage)
            .add_system(detect_removals)
            .run();
    }

    fn remove_components(
        mut commands: Commands,
        q: Query<(Entity, &Transform), With<Seen>>,
    ) {
        for (e, transform) in q.iter() {
            if transform.translation.y < -10.0 {
                // remove the `Seen` component from the entity
                commands.entity(e)
                    .remove::<Seen>();
            }
        }
    }

    fn detect_removals(
        removals: RemovedComponents<Seen>,
        // ... (maybe Commands or a Query ?) ...
    ) {
        for entity in removals.iter() {
            // do something with the entity
        }
    }

(要对这些实体进行处理,你可以直接用 Commands::entity()Query::get() 来使用实体的 ID。)

资源

Bevy 没有提供任何 API 来检测资源何时被移除。

你可以使用 Option 和一个单独的 Local 系统参数来解决这个问题,有效地实现你自己的检测。

fn detect_removed_res(
    my_res: Option<Res<MyResource>>,
    mut my_res_existed: Local<bool>,
) {
    if let Some(my_res) = my_res {
        // the resource exists!

        // remember that!
        *my_res_existed = true;

        // (... you can do something with the resource here if you want ...)
    } else if *my_res_existed {
        // the resource does not exist, but we remember it existed!
        // (it was removed)

        // forget about it!
        *my_res_existed = false;

        // ... do something now that it is gone ...
    }
}

请注意,由于这个检测是在你的系统本地进行的,所以不要求它要在同一帧更新期间发生。

查询集

为了安全起见,一个系统不能在同一个组件上有多个具有可变性冲突的查询

Bevy 提供了一个解决方案:将它们包裹在一个 QuerySet 中:

fn reset_health(
    // access the health of enemies and the health of players
    // (note: some entities could be both!)
    mut q: QuerySet<(
        QueryState<&mut Health, With<Enemy>>,
        QueryState<&mut Health, With<Player>>
    )>,
) {
    // set health of enemies
    for mut health in q.q0().iter_mut() {
        health.hp = 50.0;
    }

    // set health of players
    for mut health in q.q1().iter_mut() {
        health.hp = 100.0;
    }
}

(注意:你必须使用 QueryState 而不是 Query)

这可以确保在同一时间只能使用其中一个有冲突的查询。

一个查询集的最大查询数是 4。

系统链

可参考的官方例子: system_chaining.


你可以用多个 Rust 函数组成一个 Bevy 系统

可以编写一些仅接受一个输入参数并返回一个输出参数的函数,并把它们连接在一起,作为一个更大的系统运行。

这被称为"系统链",但要注意"链"这个术语有些误导性--你不是在创建一个由多个系统组成的链式系统来依次运行,而是在创建一个由多个 Rust 函数组成的单一的大型 Bevy 系统。

请注意,系统链并不是在系统之间进行通信的一种方式。如果你想在系统之间传递数据,你应该使用事件来代替。


一个有用的场景是从系统代码中返回错误(允许使用 Rust 的 ? 操作符),然后由一个单独的函数来处理这些错误。

    fn net_receive(mut netcode: ResMut<MyNetProto>) -> std::io::Result<()> {
        netcode.receive_updates()?;

        Ok(())
    }

    fn handle_io_errors(In(result): In<std::io::Result<()>>) {
        if let Err(e) = result {
            eprintln!("I/O error occurred: {}", e);
        }
    }

这样的函数不能单独地注册为系统(Bevy 不知道该如何处理它的输入/输出)。你必须把它和系统链在一起:

    fn main() {
        App::new()
            // ...
            .add_system(net_receive.chain(handle_io_errors))
            // ...
            .run();
    }

性能警告

请注意,Bevy 把整个系统链当作一个单一的大系统来处理,所有的资源和查询都是合并的。这意味着并行性可能受到限制,影响性能。

避免添加一个需要对任何东西进行可变访问的系统作为多个链的一部分,它将阻止所有受影响的链(以及其它访问相同数据的系统)的并行运行。

[WIP] Sub-Apps

[WIP] Non-Send

为系统编写测试

可参考的官方例子: how_to_test_systems.

你可能想为你的系统编写和运行自动测试。

对 Bevy 你可以使用常规的 Rust 测试功能(cargo test)。

要做到这一点,你可以在你的测试中创建一个空的 ECS 世界,然后,直接访问世界,插入任何你需要测试的实体资源。 为你想运行的系统创建一个独立的阶段,并在这个世界上手动运行它。

Programming Patterns

这一章的内容是关于任何非显而易见的编程技巧、编程技术、编程模式和编程习语,这些在使用 Bevy 编程时可能会很有用。

这些主题是 Bevy 编程框架一章中所涵盖的主题的延伸。请阅读那一章来学习基础概念。

泛型系统

Bevy 系统只是普通的 Rust 函数,这意味着这些函数是可以泛型化的,对不同的 Rust 类型或值进行参数化处理,相同的系统可以多次复用。

组件类型泛型化

你可以使用泛型类型参数来指定你的系统可以对哪些组件类型(以及哪些实体)进行操作。

这在与 Bevy 状态结合使用时会很有用。你可以根据状态对不同的实体集做同样的事情。

例子:清理

一个直观的用例是用于清理。我们可以做一个通用的清理系统,用于将所有具有某种组件类型的实体销毁掉,然后,在退出不同的状态时运行它。

use bevy::ecs::component::Component;

fn cleanup_system<T: Component>(
    mut commands: Commands,
    q: Query<Entity, With<T>>,
) {
    for e in q.iter() {
        commands.entity(e).despawn_recursive();
    }
}

菜单实体可以用 cleanup::MenuExit 来标记,游戏地图的实体可以用 cleanup::LevelUnload 来标记。

我们可以将通用的清理系统添加到我们的状态转换中,来处理各自的实体:

/// Marker components to group entities for cleanup
mod cleanup {
    use bevy::prelude::*;
    #[derive(Component)]
    pub struct LevelUnload;
    #[derive(Component)]
    pub struct MenuClose;
}

#[derive(Debug, Clone, Eq, PartialEq, Hash)]
enum AppState {
    MainMenu,
    InGame,
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_state(AppState::MainMenu)
        // add the cleanup systems
        .add_system_set(SystemSet::on_exit(AppState::MainMenu)
            .with_system(cleanup_system::<cleanup::MenuClose>))
        .add_system_set(SystemSet::on_exit(AppState::InGame)
            .with_system(cleanup_system::<cleanup::LevelUnload>))
        .run();
}

使用 Trait

当你需要为每种类型提供某种不同的方法或功能时,你可以把它和 Trait 结合起来使用。

例子:Bevy 的摄像机投影

(这是 Bevy 本身的一个用例)

Bevy 有一个 CameraProjection trait。不同的投影类型,如 PerspectiveProjectionOrthographicProjection 都实现了这个 trait, 为如何响应调整窗口大小、计算投影矩阵等提供正确的逻辑。

有一个通用的系统 fn camera_system::<T: CameraProjection + Component>,它用于处理所有具有给定投影类型的摄像机,它将在适当的时候调用 trait 方法(比如在窗口调整事件中)。

Bevy 手册中的自定义相机投影实例展示了这个 API 的作用。

使用常量泛型

既然 Rust 支持 Const Generics,那么函数也可以通过值来参数化,而不仅仅是类型。

fn process_layer<const LAYER_ID: usize>(
    // system params
) {
    // do something for this `LAYER_ID`
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_system(process_layer::<1>)
        .add_system(process_layer::<2>)
        .add_system(process_layer::<3>)
        .run();
}

需要注意的是,这些值在编译时是静态/常量。这可能会是一个严重的限制。在某些情况下,当你或在犹疑你可以使用常量泛型时,你可能最终意识到你实际上想要的是一个运行时值。

如果你需要通过传递一些数据来"配置"你的系统,你可以使用一个 ResourceLocal

注意:从 Rust 1.59 开始,对使用枚举值作为常量泛型的支持还不稳定。要使用枚举,你需要 Rust Nightly 版,并启用实验中和不稳定的特性(把这个放在你的 main.rslib.rs 的顶部):

#![feature(adt_const_params)]

手动清除事件

点击这里下载本页的完整例子代码文件。


事件队列需要定期清理,这样它就不会无限制地增长和浪费无限制的内存。

Bevy 的默认清理策略是在每一帧都清除事件,但有双重缓冲,所以前一帧中产生的事件保持可用。这意味着你只能在事件发送后的下一帧结束之前处理这些事件。

这个默认策略对每一帧运行都会检查事件的系统很有效,这也是典型的用法模式。

然而,如果你的系统不是每一帧都读取事件,它们就会错过一些事件。出现这种情况的一些常见情况是:

  • 在运行中途可能提前返回的系统,导致不是每次运行都读取事件
  • 当使用固定的时间步长
  • 只在特定状态下运行的系统,比如你的游戏有一个暂停状态
  • 当使用自定义执行条件来控制你的系统时

为了能够在这类情况下可靠地管理事件,你可能想要手动的控制事件在内存中保留的时间。

你可以用你自己的策略取代 Bevy 的默认清理策略。

要做到这一点,只需使用 .init_resource (而不是 .add_event)方法将你的事件类型(封装为 Events<T>)添加到应用程序构造器中。

(.add_event 实际上只是一个方便的方法,它初始化了资源并为默认的清理策略添加了 Bevy 的内置系统(事件类型泛型化))

然后你必须谨慎地清除事件。如果你不经常这样做,你的事件可能会堆积起来,浪费内存。

示例

我们可以为此创建泛型系统。实现自定义清理策略,然后根据需要多次将该系统添加到你的App中,用于你想使用自定义行为的每个事件类型。

use bevy::app::Events;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)

        // add the `Events<T>` resource manually
        // these events will not have automatic cleanup
        .init_resource::<Events<MySpecialEvent>>()

        // this is a regular event type with automatic cleanup
        .add_event::<MyRegularEvent>()

        // add the cleanup systems
        .add_system(my_event_manager::<MySpecialEvent>)
        .run();
}

/// Custom cleanup strategy for events
///
/// Generic to allow using for any custom event type
fn my_event_manager<T: 'static + Send + Sync>(
    mut events: ResMut<Events<T>>,
) {
    // TODO: implement your custom logic
    // for deciding when to clear the events

    // clear all events like this:
    events.clear();

    // or with double-buffering
    // (this is what Bevy's default strategy does)
    events.update();

    // or drain them, if you want to iterate,
    // to access the values:
    for event in events.drain() {
        // TODO: do something with each event
    }
}

Bevy 手册

本章告诉你在各种实际场景中如何使用 Bevy。

目的是作为 Bevy 的官方例子的补充。

编写的这些例子只关注于目前手头任务的相关信息。

页面中只展示相关部分的代码,完整的可编译的例子文件可在每一页开头给出的链接下载。

我们这里假定你已经熟悉了 Bevy 编程

改变背景颜色

可参考的官方例子: clear_color.


点击这里下载完整示例代码


使用 ClearColor 资源 来改变背景颜色:

fn main() {
    App::new()
        .insert_resource(ClearColor(Color::rgb(0.4, 0.4, 0.4)))
        .add_plugins(DefaultPlugins)
        .run();
}

在控制台中显示帧速率

点击这里下载完整示例代码


你可以使用 bevy 的内置诊断系统,将帧速率(FPS)打印到控制台,以监测性能:

use bevy::diagnostic::{FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin};

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugin(LogDiagnosticsPlugin::default())
        .add_plugin(FrameTimeDiagnosticsPlugin::default())
        .run();
}

获取鼠标

点击这里下载完整示例代码


你可以使用 bevy 的窗口设置 API 锁定/释放鼠标光标。

这里有一个例子,当鼠标点击时锁定和隐藏主窗口的光标,当按下 Esc 键时释放光标。

fn cursor_grab_system(
    mut windows: ResMut<Windows>,
    btn: Res<Input<MouseButton>>,
    key: Res<Input<KeyCode>>,
) {
    let window = windows.get_primary_mut().unwrap();

    if btn.just_pressed(MouseButton::Left) {
        window.set_cursor_lock_mode(true);
        window.set_cursor_visibility(false);
    }

    if key.just_pressed(KeyCode::Escape) {
        window.set_cursor_lock_mode(false);
        window.set_cursor_visibility(true);
    }
}

设置窗口图标

点击这里下载完整示例代码


你可能想设置一个自定义的窗口图标。在 Windows 和 Linux 上,这是显示在窗口标题栏(如果有的话)和任务栏(如果有的话)的图标图像。

不幸的是,Bevy 还没有提供一个简单的、符合人体工程学的内置方法来实现这个功能。然而,它可以通过 winit API 来完成。

这里显示的方法是一个不优雅的变通技巧。为了节省代码的复杂性,我们不使用 Bevy 的资源系统在后台加载图片,而是绕过资源系统,直接使用 image 库加载文件。

在 Bevy 中加入一个适当的 API 来完成设置窗口图标的工作仍处于 WIP:见 PR#2268Issue #1031

这个例子展示了如何从 Bevy 的启动系统中设置主窗口的图标:

use bevy::window::WindowId;
use bevy::winit::WinitWindows;
use winit::window::Icon;

fn set_window_icon(
    windows: Res<WinitWindows>,
) {
    let primary = windows.get_window(WindowId::primary()).unwrap();

    // here we use the `image` crate to load our icon data from a png file
    // this is not a very bevy-native solution, but it will do
    let (icon_rgba, icon_width, icon_height) = {
        let image = image::open("my_icon.png")
            .expect("Failed to open icon path")
            .into_rgba8();
        let (width, height) = image.dimensions();
        let rgba = image.into_raw();
        (rgba, width, height)
    };

    let icon = Icon::from_rgba(icon_rgba, icon_width, icon_height).unwrap();

    primary.set_window_icon(Some(icon));
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_startup_system(set_window_icon)
        .run();
}

注意:你需要把 winit 添加到你的项目的依赖项中,而且它必须和 Bevy 使用的版本相同。你可以用 cargo tree 来显示依赖关系树,看看哪个版本是正确的。从 Bevy 0.6 开始,应该是 winit = "0.26"

追踪资源加载

点击这里下载完整示例代码


你如果想使用一个第三方库来做这个功能,请查看我推荐的可以为你做这件事的辅助库。如果不想用第三方库,本页面将告诉你如何自己实现追踪资源加载。


你或许想知道你的各种资源何时完成加载,以便依此采取一些行动,例如退出加载页面并开始游戏。

要做到这一点,我们可以把我们的各种资源句柄转换成 HandleUntyped,这样我们就可以把它们全部加入到一个集合中。

然后我们可以向 AssetServer 询问该集合的加载状态。

struct AssetsLoading(Vec<HandleUntyped>);

fn setup(server: Res<AssetServer>, mut loading: ResMut<AssetsLoading>) {
    // we can have different asset types
    let font: Handle<Font> = server.load("my_font.ttf");
    let menu_bg: Handle<Image> = server.load("menu.png");
    let scene: Handle<Scene> = server.load("level01.gltf#Scene0");

    // add them all to our collection for tracking
    loading.0.push(font.clone_untyped());
    loading.0.push(menu_bg.clone_untyped());
    loading.0.push(scene.clone_untyped());
}

fn check_assets_ready(
    mut commands: Commands,
    server: Res<AssetServer>,
    loading: Res<AssetsLoading>
) {
    use bevy::asset::LoadState;

    match server.get_group_load_state(loading.0.iter().map(|h| h.id)) {
        LoadState::Failed => {
            // one of our assets had an error
        }
        LoadState::Loaded => {
            // all assets are now ready

            // this might be a good place to transition into your in-game state

            // remove the resource to drop the tracking handles
            commands.remove_resource::<AssetsLoading>();
            // (note: if you don't have any other handles to the assets
            // elsewhere, they will get unloaded after this)
        }
        _ => {
            // NotLoaded/Loading: not fully ready yet
        }
    }
}

也可以通过向 AssetServer.get_load_state() 传递单个句柄来查询单个资源的加载状态。

将光标转换为世界坐标

[点击这里下载完整示例代码][cbexample::cursor2worldy]


Bevy 还没有提供内置的功能来帮助找出光标当前所指向的东西。

3D 游戏

这是一个很好的(非官方)插件: bevy_mod_picking.

2D 游戏

下面是一个简单的取巧解决方案,用于使用默认的 Bevy 2d 摄像机正射投影的游戏:

/// Used to help identify our main camera
#[derive(Component)]
struct MainCamera;

fn setup(mut commands: Commands) {
    commands.spawn()
        .insert_bundle(OrthographicCameraBundle::new_2d())
        .insert(MainCamera);
}

fn my_cursor_system(
    // need to get window dimensions
    wnds: Res<Windows>,
    // query to get camera transform
    q_camera: Query<&Transform, With<MainCamera>>
) {
    // get the primary window
    let wnd = wnds.get_primary().unwrap();

    // check if the cursor is in the primary window
    if let Some(pos) = wnd.cursor_position() {
        // get the size of the window
        let size = Vec2::new(wnd.width() as f32, wnd.height() as f32);

        // the default orthographic projection is in pixels from the center;
        // just undo the translation
        let p = pos - size / 2.0;

        // assuming there is exactly one main camera entity, so this is OK
        let camera_transform = q_camera.single();

        // apply the camera transform
        let pos_wld = camera_transform.compute_matrix() * p.extend(0.0).extend(1.0);
        eprintln!("World coords: {}/{}", pos_wld.x, pos_wld.y);
    }
}

自定义摄像机投影

点击这里下载完整示例代码


使用自定义投影的摄像机(不使用 Bevy 的标准透视或正射投影)。

如果你出于某种原因坚持使用 Bevy 默认的坐标系以外的东西,你也可以用它来改变坐标系。

这里我们实现了一个简单的正射投影,它将 -1.01.0 映射到窗口的垂直轴上,并遵寻窗口的水平轴的长宽比:

use bevy::render::camera::{Camera, CameraProjection, DepthCalculation, CameraPlugin};
use bevy::render::view::VisibleEntities;

#[derive(Component)]
struct SimpleOrthoProjection {
    far: f32,
    aspect: f32,
}

impl CameraProjection for SimpleOrthoProjection {
    fn get_projection_matrix(&self) -> Mat4 {
        Mat4::orthographic_rh(
            -self.aspect, self.aspect, -1.0, 1.0, 0.0, self.far
        )
    }

    // what to do on window resize
    fn update(&mut self, width: f32, height: f32) {
        self.aspect = width / height;
    }

    fn depth_calculation(&self) -> DepthCalculation {
        // for 2D (camera doesn't rotate)
        //DepthCalculation::ZDifference

        // otherwise
        DepthCalculation::Distance
    }

    fn far(&self) -> f32 {
        self.far
    }
}

impl Default for SimpleOrthoProjection {
    fn default() -> Self {
        Self { far: 1000.0, aspect: 1.0 }
    }
}

fn setup(mut commands: Commands) {
    // same components as bevy's Camera2dBundle,
    // but with our custom projection

    let projection = SimpleOrthoProjection::default();

    // Need to set the camera name to one of the bevy-internal magic constants,
    // depending on which camera we are implementing (2D, 3D, or UI).
    // Bevy uses this name to find the camera and configure the rendering.
    // Since this example is a 2d camera:

    let cam_name = CameraPlugin::CAMERA_2D;

    let mut camera = Camera::default();
    camera.name = Some(cam_name.to_string());

    commands.spawn_bundle((
        // position the camera like bevy would do by default for 2D:
        Transform::from_translation(Vec3::new(0.0, 0.0, projection.far - 0.1)),
        GlobalTransform::default(),
        VisibleEntities::default(),
        camera,
        projection,
    ));
}

fn main() {
    // need to add a bevy-internal camera system to update
    // the projection on window resizing

    use bevy::render::camera::camera_system;

    App::new()
        .add_plugins(DefaultPlugins)
        .add_startup_system(setup)
        .add_system_to_stage(
            CoreStage::PostUpdate,
            camera_system::<SimpleOrthoProjection>,
        )
        .run();
}

平移环绕摄像机

点击这里下载完整示例代码


这段代码来自于社区贡献。

当前版本由 @mirenbharta 开发。最初的工作由 @skairunner 启动。


这是一个类似于 Blender 等 3D 编辑器中的摄像机控制器。

使用鼠标右键来旋转、中键用来平移、滚轮用来向内向外移动。

这里主要是为了说明问题,拿一个例子来学习。在你的项目中,你可以试试 bevy_config_cam 插件。

/// Tags an entity as capable of panning and orbiting.
#[derive(Component)]
struct PanOrbitCamera {
    /// The "focus point" to orbit around. It is automatically updated when panning the camera
    pub focus: Vec3,
    pub radius: f32,
    pub upside_down: bool,
}

impl Default for PanOrbitCamera {
    fn default() -> Self {
        PanOrbitCamera {
            focus: Vec3::ZERO,
            radius: 5.0,
            upside_down: false,
        }
    }
}

/// Pan the camera with middle mouse click, zoom with scroll wheel, orbit with right mouse click.
fn pan_orbit_camera(
    windows: Res<Windows>,
    mut ev_motion: EventReader<MouseMotion>,
    mut ev_scroll: EventReader<MouseWheel>,
    input_mouse: Res<Input<MouseButton>>,
    mut query: Query<(&mut PanOrbitCamera, &mut Transform, &PerspectiveProjection)>,
) {
    // change input mapping for orbit and panning here
    let orbit_button = MouseButton::Right;
    let pan_button = MouseButton::Middle;

    let mut pan = Vec2::ZERO;
    let mut rotation_move = Vec2::ZERO;
    let mut scroll = 0.0;
    let mut orbit_button_changed = false;

    if input_mouse.pressed(orbit_button) {
        for ev in ev_motion.iter() {
            rotation_move += ev.delta;
        }
    } else if input_mouse.pressed(pan_button) {
        // Pan only if we're not rotating at the moment
        for ev in ev_motion.iter() {
            pan += ev.delta;
        }
    }
    for ev in ev_scroll.iter() {
        scroll += ev.y;
    }
    if input_mouse.just_released(orbit_button) || input_mouse.just_pressed(orbit_button) {
        orbit_button_changed = true;
    }

    for (mut pan_orbit, mut transform, projection) in query.iter_mut() {
        if orbit_button_changed {
            // only check for upside down when orbiting started or ended this frame
            // if the camera is "upside" down, panning horizontally would be inverted, so invert the input to make it correct
            let up = transform.rotation * Vec3::Y;
            pan_orbit.upside_down = up.y <= 0.0;
        }

        let mut any = false;
        if rotation_move.length_squared() > 0.0 {
            any = true;
            let window = get_primary_window_size(&windows);
            let delta_x = {
                let delta = rotation_move.x / window.x * std::f32::consts::PI * 2.0;
                if pan_orbit.upside_down { -delta } else { delta }
            };
            let delta_y = rotation_move.y / window.y * std::f32::consts::PI;
            let yaw = Quat::from_rotation_y(-delta_x);
            let pitch = Quat::from_rotation_x(-delta_y);
            transform.rotation = yaw * transform.rotation; // rotate around global y axis
            transform.rotation = transform.rotation * pitch; // rotate around local x axis
        } else if pan.length_squared() > 0.0 {
            any = true;
            // make panning distance independent of resolution and FOV,
            let window = get_primary_window_size(&windows);
            pan *= Vec2::new(projection.fov * projection.aspect_ratio, projection.fov) / window;
            // translate by local axes
            let right = transform.rotation * Vec3::X * -pan.x;
            let up = transform.rotation * Vec3::Y * pan.y;
            // make panning proportional to distance away from focus point
            let translation = (right + up) * pan_orbit.radius;
            pan_orbit.focus += translation;
        } else if scroll.abs() > 0.0 {
            any = true;
            pan_orbit.radius -= scroll * pan_orbit.radius * 0.2;
            // dont allow zoom to reach zero or you get stuck
            pan_orbit.radius = f32::max(pan_orbit.radius, 0.05);
        }

        if any {
            // emulating parent/child to make the yaw/y-axis rotation behave like a turntable
            // parent = x and y rotation
            // child = z-offset
            let rot_matrix = Mat3::from_quat(transform.rotation);
            transform.translation = pan_orbit.focus + rot_matrix.mul_vec3(Vec3::new(0.0, 0.0, pan_orbit.radius));
        }
    }
}

fn get_primary_window_size(windows: &Res<Windows>) -> Vec2 {
    let window = windows.get_primary().unwrap();
    let window = Vec2::new(window.width() as f32, window.height() as f32);
    window
}

/// Spawn a camera like this
fn spawn_camera(mut commands: Commands) {
    let translation = Vec3::new(-2.0, 2.5, 5.0);
    let radius = translation.length();

    commands.spawn_bundle(PerspectiveCameraBundle {
        transform: Transform::from_translation(translation)
            .looking_at(Vec3::ZERO, Vec3::Y),
        ..Default::default()
    }).insert(PanOrbitCamera {
        radius,
        ..Default::default()
    });
}

列出所有资源类型

点击这里下载完整示例代码


这个例子显示了如何打印所有被添加为资源的类型的列表。

fn print_resources(archetypes: &Archetypes, components: &Components) {
    let mut r: Vec<String> = archetypes
        .resource()
        .components()
        .map(|id| components.get_info(id).unwrap())
        // get_short_name removes the path information
        // i.e. `bevy_audio::audio::Audio` -> `Audio`
        // if you want to see the path info replace
        // `TypeRegistration::get_short_name` with `String::from`
        .map(|info| TypeRegistration::get_short_name(info.name()))
        .collect();

    // sort list alphebetically
    r.sort();
    r.iter().for_each(|name| println!("{}", name));
}

请注意,这并 没有 给你打印每一个由 Bevy 提供的、作为资源类型的全面列表。它列出的是 当前添加到 应用程序中的所有资源的类型(所有由依赖插件或由你自己注册的,等等)。

请参阅这里查看更多 Bevy 内置类型的列表。

Bevy 跨平台

本章收集了一些特定平台的信息,关于在不同的操作系统或环境中如何使用 Bevy。


Bevy 可以在主流的桌面操作系统上开箱即用:Linux、macOS、Windows,不要求特殊的配置。

关于为桌面平台开发时的具体提示和建议,请参见以下页面:

Bevy 同时也把在其它平台上轻松地使用作为目标,如网络浏览器(通过 WebAssembly)、手机(Android 和 iOS)和游戏机,让你运行在所有平台上的 Bevy 代码都是一样的,区别只在于构建过程和环境设置。

然而,这个愿景还没有完全实现。目前,对非桌面平台的支持是有限的,而且需要相对更复杂的配置:

  • 网络浏览器:Bevy 在网页上运行得相当好,但有一些限制。
  • 手机:支持是最小的,而且仍存在问题。可以正常构建,但可能无法运行,做好解决复杂问题的准备。
  • 游戏机:支持仍然是完全不存在的。

如果你对这些平台感兴趣,并且你愿意帮助改善 Bevy 的跨平台支持,我们将非常欢迎你的贡献!

Linux 桌面

如果你有任何更多的关于 Linux 的知识,请帮助改进这个页面!

GitHub 上创建 Issue 或 PR。


桌面 Linux 是 Bevy 支持得最好的平台之一。

你可能需要设置一些开发依赖,这取决于你的发行版。请看 Bevy 官方软件库中的说明

如果你也想从 Linux 构建 Windows EXE,请看这里

GPU 驱动

运行 Bevy 应用程序需要 Vulkan 图形 API 的支持。你(和你的用户)必须确保安装了兼容的硬件和驱动。

在大多数现 Linux 代发行版和计算机上,这应该是没有问题的。

如果 Bevy 应用程序拒绝运行,并在控制台打印出无法找到可兼容 GPU 的错误,那么问题很可能是你的图形驱动程序的 Vulkan 组件没有正确安装。 你可能需要安装一些额外的软件包或重新安装你的显卡驱动。请根据你的 Linux 发行版查找该怎么做。

X11 and Wayland

截至 2021 年,Linux 的桌面生态系统在传统的 X11 技术栈和现代的 Wayland 技术栈之间是分散的。许多发行版正在默认切换到基于 Wayland 的桌面环境。

Bevy 同时支持两者,但只有 X11 支持是默认启用的。如果你正在运行基于 Wayland 的桌面,这意味着你的 Bevy 应用程序将在 XWayland 兼容层中运行。

要启用 Bevy 原生支持 Wayland,请通过 cargo 库功能开启 wayland

[dependencies]
bevy = { version = "0.6", features = ["wayland"] }

现在你的应用程序将被构建为同时支持 X11 和 Wayland。

如果你出于某种原因想移除对 X11 的支持,请通过 cargo 库功能禁用 x11

你可以使用环境变量来覆盖运行时使用的图形后端协议:

(在支持 Wayland 协议的桌面上使用 X11/XWayland 运行)

export WINIT_UNIX_BACKEND=x11

或者:

(要求使用 Wayland)

export WINIT_UNIX_BACKEND=wayland

macOS 桌面

如果你有任何更多的关于 macOS 的知识,请帮助改进这个页面!

GitHub 上创建 Issue 或 PR。


(此页面目前是空的,请求帮助!)

Windows 桌面

如果你有任何更多的关于 Windows 的知识,请帮助改进这个页面!

GitHub 上创建 Issue 或 PR。


Windows 是 Bevy 支持得最好的平台之一。

MSVC 和 GNU 编译器工具链都可以工作。

你也可以在 Linux 中构建 Windows EXE。

发布你的应用程序

cargo build 构建的 EXE 可以独立运行,不需要依赖任何额外的文件或 DLL。

你的 assets 文件夹需要和它一起发布。Bevy 会在用户电脑上的 EXE 的同一目录下搜索到它。

把你的游戏交付其他人玩,最简单的方法是把它们放在一个 ZIP 文件中。如果你使用其他的安装方式,请将 assets 文件夹和 EXE 安装到同一路径。

为你的应用程序创建一个图标

你可能想让你的应用程序图标出现在这两个地方:

  • EXE 文件(它在文件资源管理器中的样子)
  • 运行时的窗口(它在任务栏和窗口标题栏中的样子)

设置 EXE 图标

(借鉴自这里)

EXE 图标可以用 cargo 构建脚本来设置。

在你的 Cargo.toml 中添加 embed_resources 的构建依赖项,允许将资源嵌入到你编译的可执行文件中。

[build-dependencies]
embed-resource = "1.6.3"

在你的工程目录里创建 build.rs 文件:

extern crate embed_resource;

fn main() {
    let target = std::env::var("TARGET").unwrap();
    if target.contains("windows") {
        embed_resource::compile("icon.rc");
    }
}

在你的工程目录里创建 icon.rc 文件:

app_icon ICON "icon.ico"

在你的工程目录里将你的图标创建保存为 icon.ico

设置 Windows 图标

参阅 Bevy 手册: 设置 Windows 图标

浏览器 (WebAssembly)

简介

你可以用 Bevy 制作网页浏览器游戏。这一章将帮助你了解如何做到这一点所需要知道的细节。本页是对 Bevy 支持浏览器的概述。

你的 Bevy 应用程序将被编译为 WebAssembly(WASM),这使得它可以被嵌入到网页中并在浏览器中运行。

性能将受到限制,因为 WebAssembly 比原生代码慢,目前不支持多线程。

并非所有的第三方插件都兼容浏览器。如果你需要额外的非官方插件,你必须检查它们是否与 WASM 兼容。

项目设置

同样的 Bevy 项目,无需任何特殊的代码修改,就可以为网页平台或桌面原生平台构建程序。

然而,你需要一个带有一些 HTML 和 JavaScript 的"网站"来加载和运行你的游戏。这可能算是为了开发和测试所做的一个最小的改动。它可以很容易地自动生成。

为了部署,需要一个服务器来托管你的网站,供其他人访问。你可以使用 GitHub 的托管服务:GitHub Pages

其他注意事项

当用户加载你的网站来玩你的游戏时,他们的浏览器将需要下载文件。优化大小是很重要的,这样你的游戏可以快速加载,不浪费数据带宽。

还需要一些少量的额外配置才可以正常查看 Rust 崩溃信息

快速开始

首先,在你的 Rust 安装组件中添加对 WASM 的支持。通过 Rustup:

rustup target install wasm32-unknown-unknown

接下来,要在浏览器中运行你的 Bevy 项目。

wasm-server-runner

最简单和最自动的方法是使用 wasm-server-runner 工具。

安装它:

cargo install wasm-server-runner

.cargo/config.toml(在你的项目文件夹中,或在你的用户主文件夹中)中进行设置以使 cargo 可以与它工作:

[target.wasm32-unknown-unknown]
runner = "wasm-server-runner"

现在你可以直接如下运行你的游戏:

cargo run --target wasm32-unknown-unknown

它将自动运行一个最小的本地 Web 服务器并在浏览器中打开你的游戏。

wasm-bindgen

wasm-bindgen 是一个工具,用于生成将游戏放在你的网站上所需的所有文件:

像如下运行:

cargo build --release --target wasm32-unknown-unknown
wasm-bindgen --out-dir ./out/ --target web ./target/

./out/ 目录是它用来放置输出文件的地方。

你需要把这些放在你的 Web 服务器上。

./out/ is the directory where it will place the output files.

You need to put these on your web server.

更高级的工具

这里有一些更高级的候选工具。这些工具可以为你做更多的事情,使你的工作流程更加自动化,但在工作方式上也更有个人主张。

崩溃消息

除非我们采取一些措施,否则在网络浏览器中运行时,你将无法看到 Rust 的崩溃信息。这意味着,如果你的游戏崩溃了,你将不知道原因。

为了解决这个问题,我们可以使用 console_error_panic_hook 库来设置一个崩溃钩子,使信息可以出现在浏览器控制台。

Cargo.toml 中把 console_error_panic_hook 库添加到你的依赖项中:

[dependencies]
console_error_panic_hook = "0.1"

在你的主函数的开头,在做其他事情之前,添加如下代码:

    // When building for WASM, print panics to the browser console
    #[cfg(target_arch = "wasm32")]
    console_error_panic_hook::set_once();

优化大小

当通过服务器提供一个 WASM 二进制文件时,文件越小,浏览器就能越快地下载它。更快的下载意味着更少的页面加载时间和更少的数据带宽使用,这意味着用户会更高兴。

本页给出了如何使你的 WASM 文件更小的一些建议。

不要过早地进行优化! 在开发过程中,你可能不需要小的 WASM 文件,而且这些优化技术中有许多会妨碍你的开发流程,给你造成更长编译时间、更少可调试性的代价。

根据你应用程序的性质,在合适的时间节点,建议对二进制大小和运行速度进行测量。

Twiggy 是一个用于 WASM 二进制文件的代码大小分析器,你可以用它来进行测量。

有关其他信息和更多技术,请参考 Rust and WebAssembly 书中有关缩小 .wasm 代码大小的章节。

大小第一,性能第二

你可以改变编译器的优化配置文件,告诉它优先考虑小的输出文件,而不是性能。

(虽然在一些罕见的情况下,对大小的优化实际上可以提高速度)

Cargo.toml 中,添加以下内容之一:

[profile.release]
opt-level = 's'
[profile.release]
opt-level = 'z'

这是两种不同的文件大小优化配置。通常情况下,zs 产生更小的文件,但有时也可能是相反的。分别测量以确认哪一个对你更有效。

链接时优化 (LTO)

Cargo.toml 中,添加以下内容:

[profile.release]
lto = "thin"

LTO 告诉编译器将所有的代码放在一起优化,将所有的库视为一个整体。它可能能够更积极地内联和裁剪函数。

这通常会导致更小的尺寸和更好的性能,但需进行测量以便确认最终结果。有时,尺寸实际上可能会更大。

这样做的缺点是编译的时间会更长。请尽量只在为其它用户编译发行版本的时候这样做。

使用 wasm-opt 工具

binaryen 工具包是一套用于与 WASM 工作的额外工具。其中有一个工具是 wasm-opt,在优化方面它比编译器走得更远,可以用来进一步优化速度或大小:

# Optimize for size (s profile).
wasm-opt -Os -o output.wasm input.wasm

# Optimize for size (z profile).
wasm-opt -Oz -o output.wasm input.wasm

# Optimize aggressively for speed.
wasm-opt -O3 -o output.wasm input.wasm

# Optimize aggressively for both size and speed.
wasm-opt -O -ol 100 -s 100 -o output.wasm input.wasm

使用 wee-alloc 内存分配器

你可以用 wee-alloc 代替 Rust 的默认内存分配器,它的速度较慢,但其体积小于一千字节。

这可能会导致显著的性能冲击。如果使用它你的游戏仍然运行得足够快,那么较小的下载大小可能更重要。

Cargo.toml 中,添加以下内容:

[dependencies]
wee_alloc = "0.4"

然后在 main.rs 中,添加以下内容:


#![allow(unused)]
fn main() {
#[cfg(target_arch = "wasm32")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
}

你知道更多的 WASM 尺寸优化技术吗?请在 GitHub Issue 中发布相关信息,以便将其添加到本页面中来!

GitHub Pages 托管

GitHub Pages 是一项托管服务,允许你在 GitHub 的服务器上发布你的网站。

更多细节,请访问 GitHub Pages 的官方文档

将网站(比如你的 WASM 游戏)部署到 GitHub Pages,是通过将文件放在 GitHub 仓库的一个特殊分支中来实现的。你可以为此创建一个单独的仓库,但你也可以从与源代码相同的仓库中进行。

你需要准备最终的站点文件以便进行部署。


在你的 git 仓库中创建一个空分支:

git checkout --orphan web
git reset --hard

经过上面的操作,你现在所处的应该在一个空的工作目录。

把所有需要托管的文件,包括你的 HTML、WASM、JavaScript 和 assets 文件,提交到 git:

git add *
git commit

(或者最好在上面的命令中手动罗列添加你的文件,以代替 * 通配符)

把你的新分支 Push 到 GitHub:

git push -u origin web --force

在 GitHub Web UI 中,进入仓库设置,去到 "GitHub Pages" 部分,然后在"源代码"下选择分支 "web" 和 /(根)文件夹。然后点击"保存"。

稍微等一下,你的网站应该可以通过 https://your-name.github.io/your-repo 访问了。

荣誉

虽然这本书的大部分内容是由我 Ida Iyes(@inodentry)撰写的,但许多人都做出了重要的贡献! 非常感谢你们! ❤️


  • Alice I. Cecile @alice-i-cecile: 审阅、建议、反馈了很多好的意见
  • nile @TheRawMeatball: 审查、有用的问题反馈
  • @Zaszi:撰写 WASM 章节的初稿
  • @skairunner@mirenbharta:开发"平移环绕摄像机"示例
  • @billyb2:固定时间步长的例子

感谢所有提交 GitHub issues 并提出建议的人!


衷心感谢所有sponsors! ❤️你们都让这一切得到了回报,真的!

多亏了你们,我才能真正在这本书上继续工作,改进和维护它!


当然,最大的感谢要归功于 Bevy 项目本身及其创始人 @cart,因为他首先创建了这个令人惊叹的社区和游戏引擎。它使这一切成为可能。你真的改变了我的生活! ❤️

Contributing to Bevy

If you want to help out the Bevy Game Engine project, check out Bevy's [official contributing guide][bevy::contributing].

贡献

要文明。如果你需要一个行为准则,可以看看 Bevy 的。

如果你对这本书有任何建议,例如关于新内容的想法,或者你注意到任何不正确或误导性的东西,请在[GitHub仓库][project::cb]中提出问题!

贡献代码

如果你只是想为本书贡献代码实例,请随时发起一个 PR。我可以负责编辑书中的文字和页面,你的代码将被显示在上面。

手册实例

手册实例的代码应该作为一个完整的、可运行的实例文件提供,放在 src/code/examples 下。书的页面将只显示代码的相关部分,而非不必要的模板代码。

始终使用 [mdbook anchor syntax][mdbook::anchor-syntax],而不是行号,来表示页面上要显示的代码部分。

Credits

如果你提供了一个手册实例,我将在书中以你的 github 用户名注明你的名字,并附上 PR 的链接。如果你不希望被引用,或者你希望以其他方式被引用,请告诉我(但内容不允许进行商业性的自我宣传)。

贡献书籍文本

我不直接合并其他人写的书本内容。这是因为我希望这本书能遵循一个统一的编写风格。

如果你想为这本书写新的内容,请随时提出要包括的内容的 PR,但请注意,它很可能不会完全按照你写的内容来保存。

我可能会把它合并到一个临时的分支中,然后按照我认为合适的方式进行编辑或改写,以便发表到书中。

许可证

为了避免版权和许可方面的复杂情况,你同意在 MIT-0 No Attribution License 许可下提供你对项目的任何贡献。

请注意,这允许你的作品在不保留你的版权的情况下被重新授权。

如前所述,书中实际出版的内容将是我在你的贡献基础上的衍生作品。我将把它与书中的其他内容统一授权,见:许可证

Bevy 版本

为当前 Bevy 版本所写的内容,被接受为本书的 main 分支。

为 Bevy 主分支的新开发而写的内容,可接受为本书的 next 分支,为下一个即将发布的 Bevy 版本做准备。

风格指南

力求简单和简约。不要包括与表达与内容无关的东西。

"完美非指无所增加、而是无所减少的时候。"

不要忘记指出潜在的问题和其他相关的实际思法。

尽量使用最常见/标准的术语和关键词,以使事情容易找到。不要想出你自己的新的/额外的术语。

避免重复在书中其他地方能找到的内容,最好使用关联链接。

代码风格

避免单行过长的代码,以保证在小屏幕上的可读性。

使用合理的格式,不要太偏离 Rust 语言社区使用的通用惯例。我并不严格执行,没有必要使用 rustfmt。如果偏离这些标准可以使代码在书中得到更好的呈现,那么这样做是可取的。

文字风格

使之易于阅读。

  • 简要。尽量涵盖所有重要的信息,不做冗长的解释。
  • 倾向于使用简单的词组和短句子。
  • 避免信息过载。
    • 把事情分成简短的段落。
    • 避免同时介绍许多(即使是相关的)主题。
    • 将高级用法与基础知识分开介绍。