Closed17

Bevy 0.6 を読むメモ (RenderStage など)

自作 2D フレームワークの参考に Bevy Engine (home, GitHub) を読むメモです。

以下常態です。

bevy_ecs

以前、 Unofficial Bevy Cheat Book 経由で bevy_ecs美味しい機能をピックアップして読んだ 。付け加えるとしたら、

SystemStage

Criteria ("実行基準")

StageRunCriteriaShouldRun::YesAndCheckAgain を返す限り、その SystemStage は繰り返し実行される。ループに利用できるのが面白い。

System の実行順

  1. exclusive_at_start実行
  2. parallel 実行 (たぶん apply_buffers は呼ばれない)
  3. exclusive_before_commands
  4. 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

RenderStageRenderWorld に挿入されるステージ:

 #[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 の存在

実は僕らが普段見る Apprender_appSubApp として持っている。

Plugin からは app.sub_app_mut(RenderApp)render_app を見ることができるが、 World (app_world) からは見えない。例外は RenderStage::Extract の間で、このときだけ ResMut<RenderWorld> に触ることができる。

RenderStage::Extract

RenderStage::Extractrender_world に登録されているが、 この Stage::runapp_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> で持てばいい気がする。

MeshRenderAsset:

#[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

RenderPhaseEntity を使って表現する:

#[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>,
}

TODO: render_graph

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>>,
}

Transparent2dEntityPhaseItem かつ 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),
        }
    }
}

RenderPhase<Transparent2d> に書き込まれた Transparent2ddraw_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_renderbevy_core_pipeline の system が以下のようにスケジュールされている:

  1. Extract: 2D カメラの Entity に以下を追加する:
  • ViewTarget (bevy_render::view)
  • RenderPhase<Transparent2d> (bevy_core_pipeline)
  1. Queue: なし
  2. (PhaseSort): RenderPhase<Transparent2d> が複数あればソートし、バッチ処理を実施する (bevy_render::render_phase)
  3. Render: カメラの ViewTarget に対して Transparent2ddraw_function を呼ぶ (bevy_core_pipeline) 。

bevy_sprite の役割

以下の system を RenderStage に追加する:

  1. Extract:
  • extract_sprites: ExtractedSprite を生成する
  • extract_sprite_events: Image アセットの生成に応じて wgpu::BindGroup を生成する (ための準備をする)
  1. `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 軸ソート

Vec::sort_unstable

SpriteVertexColoredSpriteVertex

色なし・色ありの頂点が分かれている (上記 DrawSpriteBatch):

Sprite Batching - bevy#3060

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).

TODO: mesh

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 カメラの EntityTarnsparentUi をアタッチする:

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_textTextPipeline を使って、文字 (glyph) 毎に ExtractedUiNode を作る。

TextPipelinewgpu の pipeline ではない。

Queue フェーズ

ExtractedUiNode を基に GPU バッファを更新する。 Render フェーズで描画できるように UiMeta を作る:

pub struct UiMeta {
    vertices: BufferVec<UiVertex>,
    view_bind_group: Option<BindGroup>,
}

Render フェーズ

全てのカメラ (もとい TransparentUi) を訪れ、全ての UiMeta を描画する。とはいえ UI カメラはデフォルトで 1 個だけのはず。

レンダリング部分だけ見ると bevy_sprite 以上にシンプルだった。レイアウトは見ていない。

SpriteMetaUiMeta もパイプラインを切り替える機能を持っていないので、間に SDF フォントを挟むのは不可能に見える。

Bevy には関連クレートを同じパターンで読み書きできるという旨味があって、これは凄いと思った。もはや読む前から大体分かる。一方で最終形態には遥かに遠くて、パターンの変更・改善を待たないといけない部分も多そう。今後もアイデアをウォッチして自分のフレームワークに取り込みたいけれど、 Bevy を直接使う時が来るかは分からない。

2D フレームワークを作るのに必要な分は読めた。やはり App/Render world の分離に惹かれるけれど、 Extract を並列化しないと意味が無い気はする。

それでは締めます。お疲れ様でしたー

このスクラップは4ヶ月前にクローズされました
ログインするとコメントできます