Bevy 0.6 を読むメモ (→ pipelined rendering など)
bevy_ecs
以前、 Unofficial Bevy Cheat Book 経由で bevy_ecs
の 美味しい機能をピックアップして読んだ 。付け加えるとしたら、
SystemStage
Criteria ("実行基準")
StageRunCriteria
が ShouldRun::YesAndCheckAgain
を返す限り、その SystemStage
は繰り返し実行される。ループに利用できるのが面白い。
System の実行順
-
exclusive_at_start
実行 -
parallel
実行 (たぶんapply_buffers
は呼ばれない) exclusive_before_commands
- Parallel システムの
apply_buffers
を呼ぶ
System::apply_buffers
は System の self.state.apply
に繋がる。ここでのバッファとはたとえば CommandQueue
のこと。
SystemParam
とは別に引数を受け取る関数
アイデアとしては、以下の形で定義できる (例: bevy_render::render_phase::RenderCommand
):
pub fn system(a: u32, b: u32, (x, y): (Res<T>, Res<U>)) { /* ~~ */ }
system(0, 1, world.borrow());
これ以上の深入りは止めておこう……
返信の形で感想を書きます。
関数の表現力を拡張する bevy_ecs の徹底ぶりが凄く、 DSL を作っているような印象を受ける。 Rust ECS の最先端を行っており、これからも見習うべき点は増えそう。
bevy_render
(WIP)
wgpu
に入門したおかげで bevy_render
を読めるようになったのが嬉しい。
背景
wgpu
を使って 2D フレームワークを作りたい。伝統的な sprite batcher は CPU バッファの更新と描画コマンドの発行を交互に行うが、 wgpu::RenderPass<'a>
で 同じことをするのは難しく 、コストの高い RenderPass
の生成回数を減らすこともできない (はず) 。
そこで bevy_render
を読んで典型的な wgpu
の使い方を理解することにした。調べていくと Extract / Prepare / Render を基にした pipelined rendering に行き着いた。多数ウィンドウの扱いなども楽になりそう。
情報収集
Destiniy のレンダラ
Bevy 0.6 レンダラの主な元ネタ (GDC トークの PDF) 。大規模な設計にも関わらず分かりやすく書いてあって非常に良かった。
Bevy よりも進んだ話題としては、視野内の静的背景を先に pipeline レンダリングするというのがあった。また Extract のステップで毎フレーム 1MB のコピーが行われるのを fairly lightweight footprint のように表現されていたのが印象的だった。
Bevy - Bevy 0.6
最初にこれを読むべきだった。 Extract / Prepare / Queue / Render (/ Submit) のステップに別れた "mid-level rendering API" についての解説が載っている。 Distiny のレンダラとの最大の違いは ECS ベースであることに思えるので調べたい。
Renderer Rework: Initial Merge Tracking Issue
ここに載っている PR に飛ぶと、大抵詳しい解説が載っていて 新規機能の暫定ドキュメント として働いていることが分かった。
Bevy Renderer Rework
まだドキュメントが薄い RenderPhase
などにも詳しい解説が載っている。 RenderStage
についての要約もあった:
Render App Stages
- Extract: Extracts "app world" data and writes to "render world" using Commands
- Prepare: Prepares "render world" data by writing it to the gpu
- Queue: Queue up draw calls and create bind groups for things being drawn
- Render: Execute the render graph (which reads data produced in the previous stages)
Bevy Renderer Rework Round2
WIP Bevy ならではの "mid-level rendering API" についての話が広がっている気がする。
- Render world の
Entity
は app world のEntity
と同じ ID を共有しており、毎フレーム消去される。 Destiny におけるフレームパケットに相当する動的なデータを E/C として保管する。 - その他改善中の話も載っている。
Extract / Prepare / Render の設計
それでは bevy_render
を読んでいく!
RenderStage
RenderStage
は RenderWorld
に挿入されるステージ:
#[derive(Debug, Hash, PartialEq, Eq, Clone, StageLabel)]
pub enum RenderStage {
Extract,
Prepare,
Queue,
// TODO: This could probably be moved in favor of a system ordering abstraction in Render or Queue
PhaseSort,
Render,
Cleanup,
}
RenderApp
の存在
実は僕らが普段見る App
は render_app
を SubApp
として持っている。
Plugin
からは app.sub_app_mut(RenderApp)
と render_app
を見ることができるが、 World
(app_world
) からは見えない。例外は RenderStage::Extract
の間で、このときだけ ResMut<RenderWorld>
に触ることができる。
RenderStage::Extract
RenderStage::Extract
は render_world
に登録されているが、 この Stage::run
は app_world
に対して実行され、その際に起こした変更 (Command
など) は render_world
に対して System::apply_buffer
される (つまり CommandQueue
などが適用される) 。
App / SubApp の仕組みは暫定のもので、将来的には多数の World
を管理する Universe
的存在が登場するかも。
TODO: fn extract_*
は ResMut<RenderWorld>
を引数に取るが、並列実行できるのか?
RederStage::Prepare
WIP
RederStage::Queue
WIP
RederStage::Render
WIP
その他サブモジュール
render_resources
wgpu
のデータ型の wrapper (Arc<T>
+ Uuid
), あと BufferVec<T>
(wgpu::Buffer
+ Vec<T>
) や UniformVec<T>
- なぜ
Uuid
か? たぶん単調増加するAtomicU64
とかだとセーブ・ロードが面倒そう - と思いきや
AtomicU64
に切り替える PR が
render_asset
Asset<T>
から GPU 表現への変換 (Extract, Preprare) を定義する。ここでも ECS の API を使っているのが面白い:
pub struct Image {
pub data: Vec<u8>,
// TODO: this nesting makes accessing Image metadata verbose. Either flatten out descriptor or add accessors
pub texture_descriptor: wgpu::TextureDescriptor<'static>,
pub sampler_descriptor: wgpu::SamplerDescriptor<'static>,
}
impl RenderAsset for Image {
type ExtractedAsset = Image;
type PreparedAsset = GpuImage;
type Param = (SRes<RenderDevice>, SRes<RenderQueue>); // `SRes<T> = Res<'statuc, T>`
/// Clones the Image.
fn extract_asset(&self) -> Self::ExtractedAsset {
self.clone()
}
/// Converts the extracted image into a [`GpuImage`].
fn prepare_asset(
image: Self::ExtractedAsset,
(render_device, render_queue): &mut SystemParamItem<Self::Param>,
) -> Result<Self::PreparedAsset, PrepareAssetError<Self::ExtractedAsset>> {
/* ~~~~ */
}
]
レンダーアセットは AssetEvent<T>
に従って Extract され、順次 (必要ならば複数フレームをまたいで) Prepare される。また削除も AssetEvent<T>
に基づいて自動的に行われる。
TODO: たとえば画像のバイトデータを drop するとレンダーアセットも消える気がする?
texture
Bevy では GPU のテクスチャを自動的にリサイクルするための仕組みがある (3 フレームを超えたら破棄):
pub struct CachedTexture {
pub texture: Texture,
pub default_view: TextureView,
}
pub struct TextureCache {
textures: HashMap<wgpu::TextureDescriptor<'static>, Vec<CachedTextureMeta>>,
}
struct CachedTextureMeta {
texture: Texture,
default_view: TextureView,
taken: bool,
frames_since_last_use: usize,
}
あとは CPU Image
構造体と image
クレートのサポートがある。
mesh
抽象的……じゃなくて具体的な形で汎用的に作られていた。 Optional なスロット毎に Vec<T>
をもてる:
#[derive(Debug, TypeUuid, Clone)]
#[uuid = "8ecbac0f-f545-4473-ad43-e1f4243af51e"]
pub struct Mesh {
primitive_topology: PrimitiveTopology,
attributes: BTreeMap<Cow<'static, str>, VertexAttributeValues>,
indices: Option<Indices>,
}
#[derive(Clone, Debug, EnumVariantMeta)]
pub enum VertexAttributeValues {
Float32(Vec<f32>),
Sint32(Vec<i32>),
/* ~~~~ */
}
#[derive(Debug, Clone)]
pub enum Indices {
U16(Vec<u16>),
U32(Vec<u32>),
}
Mesh
は頂点属性の SoA (頂点の要素毎の Vec<T>
) で、 optional なスロットを文字列で指定できる:
impl Mesh {
pub const ATTRIBUTE_COLOR: &'static str = "Vertex_Color";
pub const ATTRIBUTE_NORMAL: &'static str = "Vertex_Normal";
pub const ATTRIBUTE_TANGENT: &'static str = "Vertex_Tangent";
/* ~~~~ */
}
fn create_triangle() -> Mesh {
let mut mesh = Mesh::new(PrimitiveTopology::TriangleList);
mesh.set_attribute(Mesh::ATTRIBUTE_POSITION, vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.0]]);
mesh.set_indices(Some(Indices::U32(vec![0,1,2])));
mesh
}
なお [A], [B], [C]
の形で持った頂点も、結局は [(A, B, C), (A, B, C)]
の形 (interleaved) で一括送信する。汎用メッシュじゃなければ Vec<Vertex>
で持てばいい気がする。
Mesh
は RenderAsset
:
#[derive(Debug, Clone)]
pub struct GpuMesh {
/// Contains all attribute data for each vertex.
pub vertex_buffer: Buffer,
pub buffer_info: GpuBufferInfo,
pub has_tangents: bool,
pub primitive_topology: PrimitiveTopology,
}
#[derive(Debug, Clone)]
pub enum GpuBufferInfo {
Indexed {
buffer: Buffer, // bevy 版、参照カウント版であることに注意
count: u32,
index_format: IndexFormat,
},
NonIndexed {
vertex_count: u32,
},
}
形 (shape
) は変換 (移動・回転・拡大) を持たない:
#[derive(Debug, Copy, Clone)]
pub struct Quad {
pub size: Vec2,
pub flip: bool,
}
render_phase
RenderPhase
は Entity
を使って表現する:
#[derive(Component)]
pub struct RenderPhase<I: PhaseItem> {
pub items: Vec<I>,
}
PhaseItem
はソート可能で描画関数と紐付け可能:
pub trait PhaseItem: Send + Sync + 'static {
type SortKey: Ord;
fn sort_key(&self) -> Self::SortKey;
fn draw_function(&self) -> DrawFunctionId;
}
その他のデータとも紐付けできる:
pub trait CachedPipelinePhaseItem: PhaseItem {
fn cached_pipeline(&self) -> CachedPipelineId;
}
バッチ処理はテンプレートメソッド経由で行われる (この処理のオーバーライドは bevy
内には見つからず):
pub trait EntityPhaseItem: PhaseItem {
fn entity(&self) -> Entity;
}
/// A [`PhaseItem`] that can be batched dynamically.
///
/// Batching is an optimization that regroups multiple items in the same vertex buffer
/// to render them in a single draw call.
pub trait BatchedPhaseItem: EntityPhaseItem {
fn batch_range(&self) -> &Option<Range<u32>>;
fn batch_range_mut(&mut self) -> &mut Option<Range<u32>>;
// バッチ処理のテンプレートメソッド
#[inline]
fn add_to_batch(&mut self, other: &Self) -> BatchResult {
let self_entity = self.entity();
if let (Some(self_batch_range), Some(other_batch_range)) = (
self.batch_range_mut().as_mut(),
other.batch_range().as_ref(),
) {
// If the items are compatible, join their range into `self`
if self_entity == other.entity() {
if self_batch_range.end == other_batch_range.start {
self_batch_range.end = other_batch_range.end;
return BatchResult::Success;
} else if self_batch_range.start == other_batch_range.end {
self_batch_range.start = other_batch_range.start;
return BatchResult::Success;
}
}
}
BatchResult::IncompatibleItems
}
}
render_pipeline
RenderPipelineDescriptor
とその要素は、主に wgpu の型 の改編版で、シェーダを Handle<T>
で置き換えてライフタイムが消えたもの。
TODO: パイプラインの特殊化の使い道を知る:
pub struct SpecializedPipelines<S: SpecializedPipeline> {
cache: HashMap<S::Key, CachedPipelineId>,
}
pub trait SpecializedPipeline {
type Key: Clone + Hash + PartialEq + Eq;
fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor;
}
キャッシュもあり。 TODO: 詳しく:
pub struct RenderPipelineCache {
layout_cache: LayoutCache,
shader_cache: ShaderCache,
device: RenderDevice,
pipelines: Vec<CachedPipeline>,
waiting_pipelines: HashSet<CachedPipelineId>,
}
render_graph
TODO:
color
- linear RGBA: 物理的な光の値
- sRGBA: 視覚的な光の値
普段の計算は sRGBA で行い、 GPU に送るときは linear RGBA に変換する。 bevy_sprite
の場合は頂点の色データを linear RGBA に事前に変換しており、 imgui_wgpu
の場合はフラグメントシェーダで sRGBA を linear RGBA に変換している。
pub enum Color {
/// sRGBA color
Rgba { ref: f32, green: f2, blue: f32, alpha: f32 },
/// RGBA color in the Linear sRGB colorspace (often colloquially referred to as "linear", "RGB", or "linear RGB").
RgbaLinear { /* ~~~~ */ },
/// HSL (hue, saturation, lightness) color with an alpha channel
Hsla { /* ~~~~ */ },
}
なお 2D 描画では、シェーダに送る色は [u8; 4]
に変換している。元から [u8; 4]
で持てばいい気もする……。
view
Entity 毎にカメラを割り当てる。 See also: https://github.com/bevyengine/bevy/pull/3412
射影行列な ViewUniform
:
#[derive(Clone, AsStd140)]
pub struct ViewUniform {
view_proj: Mat4,
inverse_view: Mat4,
projection: Mat4,
world_position: Vec3,
near: f32,
far: f32,
width: f32,
height: f32,
}
ViewUniform
は 1 つの DynamicUniformVec
に入れる:
#[derive(Default)]
pub struct ViewUniforms {
pub uniforms: DynamicUniformVec<ViewUniform>,
}
#[derive(Component)]
pub struct ViewUniformOffset {
pub offset: u32,
}
fn prepare_view_uniforms(
mut commands: Commands,
render_device: Res<RenderDevice>,
render_queue: Res<RenderQueue>,
mut view_uniforms: ResMut<ViewUniforms>,
views: Query<(Entity, &ExtractedView)>,
) {
view_uniforms.uniforms.clear();
for (entity, camera) in views.iter() {
let projection = camera.projection;
let inverse_view = camera.transform.compute_matrix().inverse();
let view_uniforms = ViewUniformOffset {
offset: view_uniforms.uniforms.push(ViewUniform {
view_proj: projection * inverse_view,
inverse_view,
projection,
world_position: camera.transform.translation,
near: camera.near,
far: camera.far,
width: camera.width as f32,
height: camera.height as f32,
}),
};
commands.entity(entity).insert(view_uniforms);
}
view_uniforms
.uniforms
.write_buffer(&render_device, &render_queue);
}
ちなみに sokol
の Metal backend は 全ユニフォームを単一つの (ダブル) バッファ (4MB) に入れている: sokol_gfx.h Backend Tour: Metal | The Brain Dump 。
真似るべき点としては
-
wgpu
のリソース型の参照カウント版を作る
あるいはアセットの一種にもできるけれど、独立した参照カウント版を作るとレンダリングのコードが分離できて良さそうな気がする -
Queue / Render のステージを分ける
wgpu
を使うなら必然的に必要な措置 -
Render / app world を分離する
これは必要な変更が多いので、基本的な仕組みを整えながらインクリメンタルに目指していくべきかと
bevy_core_pipeline
(WIP)
一番重要なクレートだった 。
Extract
アクティブなカメラに対して RenderPhase
を追加するとある:
pub fn extract_core_pipeline_camera_phases(
mut commands: Commands,
active_cameras: Res<ActiveCameras>,
) {
if let Some(camera_2d) = active_cameras.get(CameraPlugin::CAMERA_2D) {
if let Some(entity) = camera_2d.entity {
commands
.get_or_spawn(entity)
.insert(RenderPhase::<Transparent2d>::default());
}
}
// 3D カメラも扱う
}
またカメラに対しては自動的に
ViewTarget
がアタッチされる (bevy_render::view
) 。
しかし Add capability to render to a texture 以後はカメラ毎に RenderTarget
を指定できるようになるはず:
#[derive(Debug, Clone, Reflect, PartialEq, Eq, Hash)]
pub enum RenderTarget {
/// Window to which the camera's view is rendered.
Window(WindowId),
/// Image to which the camera's view is rendered.
Image(Handle<Image>),
}
Queue
RenderPhase<T>
に Queue フェーズで T: RenderItem
を書き込む。 bevy_sprite
では以下を使う:
pub struct Transparent2d {
pub sort_key: FloatOrd,
pub entity: Entity,
pub pipeline: CachedPipelineId,
pub draw_function: DrawFunctionId,
/// Range in the vertex buffer of this item
pub batch_range: Option<Range<u32>>,
}
Transparent2d
は EntityPhaseItem
かつ CachedPipelineItem
かつ BatchdPhaseItem
。
Node と RenderPhase
これが 2D 用の RenderGraph ノード:
pub struct MainPass2dNode {
query:
QueryState<(&'static RenderPhase<Transparent2d>, &'static ViewTarget), With<ExtractedView>>,
}
impl MainPass2dNode {
pub const IN_VIEW: &'static str = "view";
pub fn new(world: &mut World) -> Self {
Self {
query: QueryState::new(world),
}
}
}
-
IN_VIEW
は use marker components for cameras instead of name strings #3635 が来るまでの代理
RenderPhase<Transparent2d>
に書き込まれた Transparent2d
の draw_function
を走らせる:
impl Node for MainPass2dNode {
fn input(&self) -> Vec<SlotInfo> {
vec![SlotInfo::new(MainPass2dNode::IN_VIEW, SlotType::Entity)]
}
fn update(&mut self, world: &mut World) {
self.query.update_archetypes(world);
}
fn run(
&self,
graph: &mut RenderGraphContext,
render_context: &mut RenderContext,
world: &World,
) -> Result<(), NodeRunError> {
let view_entity = graph.get_input_entity(Self::IN_VIEW)?;
let (transparent_phase, target) = self
.query
.get_manual(world, view_entity)
.expect("view entity should exist");
let pass_descriptor = RenderPassDescriptor {
label: Some("main_pass_2d"),
color_attachments: &[target.get_color_attachment(Operations {
load: LoadOp::Load,
store: true,
})],
depth_stencil_attachment: None,
};
let draw_functions = world
.get_resource::<DrawFunctions<Transparent2d>>()
.unwrap();
let render_pass = render_context
.command_encoder
.begin_render_pass(&pass_descriptor);
let mut draw_functions = draw_functions.write();
let mut tracked_pass = TrackedRenderPass::new(render_pass);
for item in transparent_phase.items.iter() {
let draw_function = draw_functions.get_mut(item.draw_function).unwrap();
draw_function.draw(world, &mut tracked_pass, view_entity, item);
}
Ok(())
}
}
bevy_asset
列挙
pub trait AssetLoader: Send + Sync + 'static {
fn load<'a>(
&'a self,
bytes: &'a [u8],
load_context: &'a mut LoadContext,
) -> BoxedFuture<'a, Result<(), anyhow::Error>>;
fn extensions(&self) -> &[&str];
}
pub trait Asset: TypeUuid + AssetDynamic {}
pub trait AssetDynamic: Downcast + TypeUuidDynamic + Send + Sync + 'static {}
pub struct LoadedAsset<T: Asset> {
pub(crate) value: Option<T>,
pub(crate) dependencies: Vec<AssetPath<'static>>,
}
Handle<T>
pub struct Handle<T>
where
T: Asset,
{
pub id: HandleId,
handle_type: HandleType,
marker: PhantomData<fn() -> T>,
}
enum HandleType {
Weak,
Strong(Sender<RefChange>),
}
pub struct HandleUntyped {
pub id: HandleId,
handle_type: HandleType,
}
イベント
pub enum AssetEvent<T: Asset> {
Created { handle: Handle<T> },
Modified { handle: Handle<T> },
Removed { handle: Handle<T> },
}
App
への拡張メソッド:
pub trait AddAsset {
fn add_asset<T>(&mut self) -> &mut Self where T: Asset;
fn init_asset_loader<T>(&mut self) -> &mut Self where T: AssetLoader + FromWorld;
fn add_asset_loader<T>(&mut self, loader: T) -> &mut Self where T: AssetLoader;
}
読み込み
ファイル内容をバイト列として受け取って読み込む:
pub trait AssetLoader: Send + Sync + 'static {
fn load<'a>(
// 可変参照ではない!
&'a self,
bytes: &'a [u8],
load_context: &'a mut LoadContext,
) -> BoxedFuture<'a, Result<(), anyhow::Error>>;
fn extensions(&self) -> &[&str];
}
pub struct LoadContext<'a> {
pub(crate) ref_change_channel: &'a RefChangeChannel,
pub(crate) asset_io: &'a dyn AssetIo,
// `AssetLoader` はここに出力を書き込む
pub(crate) labeled_assets: HashMap<Option<String>, BoxedLoadedAsset>,
pub(crate) path: &'a Path,
pub(crate) version: usize,
pub(crate) task_pool: &'a TaskPool,
}
他アセットに依存したアセットを読み込む
実はアセットパスから Handle<T>
を作ることができる:
impl<'a> LoadContext<'a> {
pub fn get_handle<I: Into<HandleId>, T: Asset>(&self, id: I) -> Handle<T> {
Handle::strong(id.into(), self.ref_change_channel.sender.clone())
}
}
そのため他のアセットは load_context.loaded_assets.dependencies
経由でロードするのが定石みたい。
参考: How do you load multiple assets from one AssetLoader? #3242
依存アセットを即読み込む場合は、他のアセットローダー (AssetServer::loaders
) に触れない気がする 。
IO
プラットフォーム非依存の API (desktop / Android / wasm) に:
pub trait AssetIo: Downcast + Send + Sync + 'static {
fn load_path<'a>(&'a self, path: &'a Path) -> BoxedFuture<'a, Result<Vec<u8>, AssetIoError>>;
fn read_directory(
&self,
path: &Path,
) -> Result<Box<dyn Iterator<Item = PathBuf>>, AssetIoError>;
fn is_directory(&self, path: &Path) -> bool;
fn watch_path_for_changes(&self, path: &Path) -> Result<(), AssetIoError>;
fn watch_for_changes(&self) -> Result<(), AssetIoError>;
}
WASM を考えてアセットの読み込みは常に非同期だとか。””
TODO: 非同期と内部可変性?
TODO: データ加工の流れ
bevy_ecs_tilemap
タイルマップにも Entity
を使っていく力作 (サードパーティー) 。普通は配列風のデータを用意するので珍しい。
-
タイルマップをメッシュとして描画する
サイズ = sqrt(マップサイズ) なサブメッシュに分ける -
アニメーションの実装
パタンの範囲をシェーダに送り、経過時間に応じてシェーダ側で保管する。柔軟性に欠けるように見えるがどうなのだろう。
#cfg[(not(feature = "atlas"))]
Textura array
それぞれのタクスチャーアトラス (タイルの集まり) を大きさ (tile_width, tile_height, n_tiles)
のテクスチャに変換している。
Texture array というのを知らなかったが、 WGSL (シェーダ言語) の texture_2d_array<f32>
として伝わっている模様:
struct VertexOutput {
[[builtin(position)]] position: vec4<f32>;
[[location(0)]] uv: vec3<f32>;
[[location(1)]] color: vec4<f32>;
};
[[group(3), binding(0)]]
var sprite_texture: texture_2d_array<f32>;
[[group(3), binding(1)]]
var sprite_sampler: sampler;
[[stage(fragment)]]
fn fragment(in: VertexOutput) -> [[location(0)]] vec4<f32> {
var color = textureSample(sprite_texture, sprite_sampler, in.uv.xy, i32(in.uv.z)) * in.color;
if (color.a < 0.001) {
discard;
}
return color;
}
https://www.w3.org/TR/WGSL/#texturesample
textureSample(t: texture_2d_array<f32>, s: sampler, coords: vec2<f32>, array_index: i32) -> vec4<f32>
UV は固定で texture index を切り替える形式の模様:
var start_u: f32 = 0.0;
var end_u: f32 = 1.0;
var start_v: f32 = 0.0;
var end_v: f32 = 1.0;
TODO: UV 座標の Z 軸を使ったメリットは何だろう。分かりやすさ? GPU におけるキャッシュ効率??
RenderStage
前述の通り、テクスチャーアトラスを変換し、タイルサイズのテクスチャを z 軸方向に重ねる。
#[derive(Default, Debug, Clone)]
pub struct TextureArrayCache {
// `.add` されたテクスチャーアトラスのサイズ情報。 `Prepare` ステージへの入力
sizes: HashMap<Handle<Image>, (TileSize, TextureSize, Vec2, FilterMode)>,
// `.add` されたテクスチャーアトラス。 `Prepare` ステージへの入力
prepare_queue: HashSet<Handle<Image>>,
// `Prepare` で作られる `Queue` への入力
queue_queue: HashSet<Handle<Image>>,
// `Queue` ステージの出力
// `.add` された texture atlas を Z 軸方向に展開したテクスチャ
// (x, y, z) = (tile_size_w, tile_size_h, (x + w * i)
textures: HashMap<Handle<Image>, GpuImage>,
}
#[cfg(feature = "atlas")]
Texture array への加工を挟まない。元のテクスチャがタイル間にスペースを持つ場合無駄スペースが残ることになるが、これは副次的な効果であって texture array と texture array の違いではない。
シェーダも切り替わる:
31c31
< [[location(0)]] uv: vec3<f32>;
---
> [[location(0)]] uv: vec2<f32>;
60,63c60,63
< var start_u: f32 = 0.0; //sprite_sheet_x / tilemap_data.texture_size.x;
< var end_u: f32 = 1.0; //(sprite_sheet_x + tilemap_data.tile_size.x) / tilemap_data.texture_size.x;
< var start_v: f32 = 0.0; //sprite_sheet_y / tilemap_data.texture_size.y;
< var end_v: f32 = 1.0; //(sprite_sheet_y + tilemap_data.tile_size.y) / tilemap_data.texture_size.y;
---
> var start_u: f32 = sprite_sheet_x / tilemap_data.texture_size.x;
> var end_u: f32 = (sprite_sheet_x + tilemap_data.tile_size.x) / tilemap_data.texture_size.x;
> var start_v: f32 = sprite_sheet_y / tilemap_data.texture_size.y;
> var end_v: f32 = (sprite_sheet_y + tilemap_data.tile_size.y) / tilemap_data.texture_size.y;
118,119c118,119
< out.uv = vec3<f32>(atlas_uvs[v_index % 4u], f32(texture_index));
< // out.uv = out.uv + 1e-5;
---
> out.uv = atlas_uvs[v_index % 4u];
> out.uv = out.uv + 1e-5;
126c126
< var sprite_texture: texture_2d_array<f32>;
---
> var sprite_texture: texture_2d<f32>;
132c132
< var color = textureSample(sprite_texture, sprite_sampler, in.uv.xy, i32(in.uv.z)) * in.color;
---
> var color = textureSample(sprite_texture, sprite_sampler, in.uv.xy) * in.color;
頂点データなどは変わらない。
TODO: Texture array vs texture atlas
記事か動画を探したい。こうした基礎知識を蓄えるための本を探し、無ければ数年後にノートを作っても良さそう。
共通
Chunk
チャンクサイズ = sqrt(マップサイズ). たとえばマップサイズが (100, 100)
ならばチャンクのサイズは (10, 10)
.
メッシュは Chunk
単位で作る。
ChunkMesher
Bevy の汎用メッシュを作る。 bevy_render
の時に触れた通り、このメッシュは属性毎に Vec<T>
を持つ (が、 GPU に送るときは interleaved になる) もの。
texture: Vec<[i32; 4]>
に注目:
let (animation_start, animation_end, animation_speed) =
if let Some(ani) = gpu_animated {
(ani.start as i32, ani.end as i32, ani.speed)
} else {
(tile.texture_index as i32, tile.texture_index as i32, 0.0)
};
/* ~~~~ */
textures.extend(IntoIter::new([
// `vec<i32. 4>` として頂点シェーダに送られる
[
tile.texture_index as i32,
tile_flip_bits,
animation_start,
animation_end,
],
実は x 成分は使っていないので (issue に上げてみよう 上げるほどでもないか) 、頂点シェーダに送られるのは
- 使われるタイルの添字の範囲 (経過時間で補完すれば対応のタイルが分かる)
- これは柔軟性に欠くなぁ
- X/Y のフリップ情報
アニメーションの扱い
シェーダでは、 (オプショナルな) 複数のアニメフレームを時間で補完している:
struct VertexOutput {
[[builtin(position)]] position: vec4<f32>;
[[location(0)]] uv: vec3<f32>;
[[location(1)]] color: vec4<f32>;
};
[[stage(vertex)]]
fn vertex(
[[builtin(vertex_index)]] v_index: u32,
[[location(0)]] vertex_position: vec3<f32>,
[[location(1)]] vertex_uv: vec4<i32>,
[[location(2)]] color: vec4<f32>,
) -> VertexOutput {
var out: VertexOutput;
var animation_speed = vertex_position.z;
var mesh_data: Output = get_mesh(v_index, vertex_position);
var frames: f32 = f32(vertex_uv.w - vertex_uv.z);
var current_animation_frame = fract(tilemap_data.time * animation_speed) * frames;
current_animation_frame = clamp(current_animation_frame, f32(vertex_uv.z), f32(vertex_uv.w));
TODO: GPU リソースのキャッシュの使い方を見る
TiledLoader
AssetLoader
が自分で他アセットを読み込まなくていいという仕組みが面白い (bevy_asset
) 。タイルマップの描画用画像データがは dependencies
として LoadContext
に仕込むことで AssetServer
に通知され、 Prepare ステージで使えるように自動的に読み込まれる
まあメッシュを作らなくても毎フレーム描画すればいいかなと思った……
bevy_crevice
crevice
関連のリンク:
グラフィクスの知識の不足を感じたので読みたい:
Uniform buffer
/// Wrapper type that aligns the inner type to at least 256 bytes.
///
/// This type is useful for ensuring correct alignment when creating dynamic
/// uniform buffers in APIs like WebGPU.
pub struct DynamicUniform<T>(pub T);
bevy_render
から:
pub struct DynamicUniformVec<T: AsStd140> {
uniform_vec: UniformVec<DynamicUniform<T>>,
}
bevy_sprite
2D のパイプライン
bevy_render
と bevy_core_pipeline
の system が以下のようにスケジュールされている:
- Extract: 2D カメラの
Entity
に以下を追加する:
-
ViewTarget
(bevy_render::view
) -
RenderPhase<Transparent2d>
(bevy_core_pipeline
)
- Queue: なし
- (PhaseSort):
RenderPhase<Transparent2d>
が複数あればソートし、バッチ処理を実施する (bevy_render::render_phase
) - Render: カメラの
ViewTarget
に対してTransparent2d
のdraw_function
を呼ぶ (bevy_core_pipeline
) 。
bevy_sprite
の役割
以下の system を RenderStage
に追加する:
- Extract:
-
extract_sprites
:ExtractedSprite
を生成する -
extract_sprite_events
:Image
アセットの生成に応じてwgpu::BindGroup
を生成する (ための準備をする)
- `Queue:
-
queue_sprite_events
:SpriteMeta
(GPU バッファ) を生成する。またRenderPhase<Transparent2d>
にTransparent2d
を追加する。
Extract
pub struct SpriteAssetEvents {
pub images: Vec<AssetEvent<Image>>,
}
#[derive(Component, Clone, Copy)]
pub struct ExtractedSprite {
pub transform: GlobalTransform,
pub color: Color,
/// Select an area of the texture
pub rect: Option<Rect>,
/// Change the on-screen size of the sprite
pub custom_size: Option<Vec2>,
/// Handle to the `Image` of this sprite
/// PERF: storing a `HandleId` instead of `Handle<Image>` enables some optimizations (`ExtractedSprite` becomes `Copy` and doesn't need to be dropped)
pub image_handle_id: HandleId,
pub flip_x: bool,
pub flip_y: bool,
}
空のドロップが本当にパフォーマンスに影響するのだろうか?
Queue
出力
SpriteMeta
2D 描画用のバッファで、 Transparent2d
にアタッチした draw_function
が使用する:
pub struct SpriteMeta {
vertices: BufferVec<SpriteVertex>,
colored_vertices: BufferVec<ColoredSpriteVertex>,
// uniform の設定用。ハックじみて見えるので忘れて良さそう
view_bind_group: Option<BindGroup>,
}
Transparent2d
bevy_core_pipeline
より再掲。 SpriteMeta
が持つバッファの内 batch_range
の範囲を描画する:
pub struct Transparent2d {
pub sort_key: FloatOrd,
pub entity: Entity,
pub pipeline: CachedPipelineId,
pub draw_function: DrawFunctionId,
/// Range in the vertex buffer of this item
pub batch_range: Option<Range<u32>>,
}
Render
Transparent2d::draw_function
に設定した以下の RenderCommand
が Render フェーズに呼ばれる:
pub type DrawSprite = (
SetItemPipeline, // CachedPipelineId を RenderPass に適用する
SetSpriteViewBindGroup<0>, // view ユニフォームの bind group を設定する RenderCommand
SetSpriteTextureBindGroup<1>, // Entity 毎に呼ばれる (カメラ毎に共通)
DrawSpriteBatch, // `Transparent2d::batch_range` を使って `SpriteMeta` が持つバッファを描画する
);
RenderCommand
のタプルも RenderCommand
であり、 chain-of-responsibility パタンで実装されている。
DrawSpriteBatch
この RenderCommand
だけ丸ごとコピーしておくと:
pub struct DrawSpriteBatch;
impl<P: BatchedPhaseItem> RenderCommand<P> for DrawSpriteBatch {
type Param = (SRes<SpriteMeta>, SQuery<Read<SpriteBatch>>);
fn render<'w>(
_view: Entity,
item: &P,
(sprite_meta, query_batch): SystemParamItem<'w, '_, Self::Param>,
pass: &mut TrackedRenderPass<'w>,
) -> RenderCommandResult {
let sprite_batch = query_batch.get(item.entity()).unwrap();
let sprite_meta = sprite_meta.into_inner();
if sprite_batch.colored {
pass.set_vertex_buffer(0, sprite_meta.colored_vertices.buffer().unwrap().slice(..));
} else {
pass.set_vertex_buffer(0, sprite_meta.vertices.buffer().unwrap().slice(..));
}
pass.draw(item.batch_range().as_ref().unwrap().clone(), 0..1);
RenderCommandResult::Success
}
}
リソース
パイプラインはキャッシュから取得する
bevy_core_pipeline
から:
pub struct SpecializedPipelines<S: SpecializedPipeline> {
cache: HashMap<S::Key, CachedPipelineId>,
}
impl<S: SpecializedPipeline> SpecializedPipelines<S> {
pub fn specialize(
&mut self,
cache: &mut RenderPipelineCache,
specialize_pipeline: &S,
key: S::Key,
) -> CachedPipelineId {
*self.cache.entry(key.clone()).or_insert_with(|| {
let descriptor = specialize_pipeline.specialize(key);
cache.queue(descriptor)
})
}
}
BindGroup
もキャッシュから取得 (もしくはきゃs挿入) する
キャッシュは (当然ながら) 画像ハンドル毎に作る:
pub struct ImageBindGroups {
values: HashMap<Handle<Image>, BindGroup>,
}
Extract された AssetEvent
は一度 Vec
に入れ、 Queue フェーズで更新される:
for event in &events.images {
match event {
AssetEvent::Created { .. } => None,
AssetEvent::Modified { handle } => image_bind_groups.values.remove(handle),
AssetEvent::Removed { handle } => image_bind_groups.values.remove(handle),
};
}
しかし Extract のタイミングで即更新した方がパフォーマンス面が良い気がする。 Extract はあくまでコピーという精神なのだろうか。
パフォーマンス
Z 軸ソート
SpriteVertex
と ColoredSpriteVertex
色なし・色ありの頂点が分かれている (上記 DrawSpriteBatch
):
The
Sprite
type now has a color field. Non-white color tints result in a specialized render pipeline that passes the color in as a vertex attribute. I chose to specialize this because passing vertex colors has a measurable price (without colors I get ~130,000 sprites on bevymark, with colors I get ~100,000 sprites).
mesh
TODO: Extract でコピーするデータ量が気になるけれど、 Destiny レンダラの感じだと全然軽いのかな。
- Queueフェーズでテクスチャの
BindGroup
を作成する - Render フェーズではユニフォーム (トランスフォーム・マトリクス) は更新しない。
RenderItem
の行列は quad に対して適用する。
bevy_ui
主な興味:
- スクリーン座標系をいかに担保しているのか
-
Drawable
的な抽象をしているかどうか
UiCameraBundle
#[derive(Bundle, Debug)]
pub struct UiCameraBundle {
pub camera: Camera,
pub orthographic_projection: OrthographicProjection,
pub transform: Transform,
pub global_transform: GlobalTransform,
// FIXME there is no frustrum culling for UI
pub visible_entities: VisibleEntities,
}
render_pass
PhaseItem
Entity (+ カメラ) にアタッチされる
PhaseItem` はこちら:
pub struct TransparentUi {
pub sort_key: FloatOrd,
pub entity: Entity,
pub pipeline: CachedPipelineId,
pub draw_function: DrawFunctionId,
}
render
main_2d_pass
から独立した ui_pass
を定義している。必ずゲーム画面を描いた後に UI (HUD など) を表示するという順番になるはず。
Extract フェーズ
UI カメラの Entity
に TarnsparentUi
をアタッチする:
pub fn extract_ui_camera_phases(mut commands: Commands, active_cameras: Res<ActiveCameras>) {
if let Some(camera_ui) = active_cameras.get(CAMERA_UI) {
if let Some(entity) = camera_ui.entity {
commands
.get_or_spawn(entity)
.insert(RenderPhase::<super::TransparentUi>::default());
}
}
}
また、このフェーズで文字とそれ以外の区別が消える。 SDF フォントを導入したら話は変わると思う:
-
extract_uinodes
ExtractedUiNode
を作る。 -
extract_text_uinodes
bevy_text
のTextPipeline
を使って、文字 (glyph) 毎にExtractedUiNode
を作る。
TextPipeline
はwgpu
の pipeline ではない。
Queue フェーズ
ExtractedUiNode
を基に GPU バッファを更新する。 Render フェーズで描画できるように UiMeta
を作る:
pub struct UiMeta {
vertices: BufferVec<UiVertex>,
view_bind_group: Option<BindGroup>,
}
Render フェーズ
全てのカメラ (もとい TransparentUi
) を訪れ、全ての UiMeta
を描画する。とはいえ UI カメラはデフォルトで 1 個だけのはず。
レンダリング部分だけ見ると bevy_sprite
以上にシンプルだった。レイアウトは見ていない。
SpriteMeta
も UiMeta
もパイプラインを切り替える機能を持っていないので、間に SDF フォントを挟むのは不可能に見える。
bevy_text
TODO: Bevy には関連クレートを同じパターンで読み書きできるという旨味があって、これは凄いと思った。もはや読む前から大体分かる。一方で最終形態には遥かに遠くて、パターンの変更・改善を待たないといけない部分も多そう。今後もアイデアをウォッチして自分のフレームワークに取り込みたいけれど、 Bevy を直接使う時が来るかは分からない。
2D フレームワークを作るのに必要な分は読めた。やはり App/Render world の分離に惹かれるけれど、 Extract を並列化しないと意味が無い気はする。
それでは締めます。お疲れ様でしたー