Round Egg 6: Compute shaders in Bevy

2023-10-01

Pedro Burgos, Dominykas Jogela

Note: During this project we have gone through several Bevy releases. At this time we have updated the version to 0.11, we recommend reading through the wonderful release announcements. The 0.11 release, in particular changed shader imports.

The next step is to displace the vertices of our sphere to deform it. Our initial idea is to offload the computation to a compute shader. This is definitely unnecessary (and introduces a lot of problems, as we will see later), however this project is for exploration purposes so we will just go with it.

# Compute shaders (the hard way)

The first step is to setup our pipeline (similarly to how we did at the beginning of the series).

A useful debug tool is bevy_mod_debugdump. We can dump our render graph into a .dot file to later visualize it with graphviz.

let settings = bevy_mod_debugdump::render_graph::Settings::default();
let dot = bevy_mod_debugdump::render_graph_dot(&mut app, &settings);
std::fs::write("render-graph.dot", dot).expect("Failed to write render-graph.dot");

# Bevy's main world vs render world

Our first idea was to define a Compute Buffer, that we can access both from the shader and from our bevy code. This compute buffer will be used to calculate the displaced positions of the vertices. Those positions will be later introduced to the Sphere mesh.

Turns out this is not so simple, and it makes sense, transferring data from the CPU memory to the GPU requires copying the data. If we look at bevy_render's definition of Buffer we can see that it is just a handle to a buffer, not the buffer itself.

// bevy_render::render_resource::Buffer,
#[derive(Clone, Debug)]
pub struct Buffer {
    id: BufferId,
    value: ErasedBuffer,
}

We decided to simply duplicate or data structure, and have two structs, one for the CPU and one for the GPU (with a reference to the aforementioned Buffer).

#[derive(Debug, Deserialize, TypeUuid, TypePath, Clone, Resource, Deref)]
#[uuid = "3ecbac0f-f545-4473-ad43-e1f4243af51e"] // Be careful with the first digit DO NOT use "8.."
struct ComputeUsableBuffer {
    buffer: Vec<u8>,
}

struct GPUComputeUsableBuffer {
    buffer: bevy_render::render_resource::Buffer,
}

To translate between main (CPU) and render (GPU) world, there exists the trait RenderAsset, which "prepares" a resource for rendering. We simply implement it for our buffer types and the conversion should be handled by Bevy automatically.

impl RenderAsset for ComputeUsableBuffer {
    type ExtractedAsset = ComputeUsableBuffer;
    type PreparedAsset = GPUComputeUsableBuffer;
    type Param = SRes<RenderDevice>;

    // Extracts the asset into the render world
    fn extract_asset(&self) -> Self::ExtractedAsset {
        self.clone()
    }
  
    // Prepares the asset for rendering
    fn prepare_asset(
        buffer: Self::ExtractedAsset,
        render_device: &mut bevy::ecs::system::SystemParamItem<Self::Param>,
    ) -> Result<
        Self::PreparedAsset,
        bevy_render::render_asset::PrepareAssetError<Self::ExtractedAsset>,
    > {
        let vertex_buffer_data = buffer.buffer;
        let vertex_buffer = render_device.create_buffer_with_data(&BufferInitDescriptor {
          // 
            usage: BufferUsages::VERTEX
                | BufferUsages::COPY_DST // Destination for copy operations
                | BufferUsages::UNIFORM  // The data contained is uniform
                | BufferUsages::STORAGE, 
            label: Some("Mesh Vertex Buffer"), 
            contents: &vertex_buffer_data,
        });
        Ok(GPUComputeUsableBuffer {
            buffer: vertex_buffer,
        })
    }
}

There is one more thing left to implement: A render pipeline for compute shaders (which is detached from the rendering pipeline) and a compute shader. Doing so is relatively similar to what was covered in the second post. Our rough prototype looks like this

Note: The code shown here is barely optimized, and it is not recommended for use. This is just a description of our process when trying to implement compute shaders.

A better example is surely: https://github.com/Kjolnyr/bevy_app_compute, to which we ended up moving later on.

#[derive(Resource)]
struct SphereDeformationPipeline {
    texture_bind_group_layout: BindGroupLayout,
    init_pipeline: CachedComputePipelineId,
    update_pipeline: CachedComputePipelineId,
}

// FromWorld is the trait that we need for initialization - to gain 
// access to `&World`.
impl FromWorld for SphereDeformationPipeline {
    fn from_world(world: &mut World) -> Self {
        let texture_bind_group_layout =
            world
                .resource::<RenderDevice>()
                .create_bind_group_layout(&BindGroupLayoutDescriptor {
                    label: None,
                    entries: &[BindGroupLayoutEntry {
                        binding: 0,
                        visibility: ShaderStages::COMPUTE,
                        ty: BindingType::Buffer {
                            ty: BufferBindingType::Storage { read_only: false },
                            has_dynamic_offset: false,
                            min_binding_size: None, 
                        },
                        count: None,
                    }],
                });
        let shader = world
            .resource::<AssetServer>()
            .load("shaders/sphere_deformation.wgsl");
        let pipeline_cache = world.resource::<PipelineCache>();
        let init_pipeline = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {
            label: None,
            layout: vec![texture_bind_group_layout.clone()],
            push_constant_ranges: Vec::new(),
            shader: shader.clone(),
            shader_defs: vec![],
            entry_point: Cow::from("init"),
        });
        let update_pipeline = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {
            label: None,
            layout: vec![texture_bind_group_layout.clone()],
            push_constant_ranges: Vec::new(),
            shader,
            shader_defs: vec![],
            entry_point: Cow::from("update"),
        });

        SphereDeformationPipeline {
            texture_bind_group_layout,
            init_pipeline,
            update_pipeline,
        }
    }
}

And we need a function to take care of initialization before we run the pipeline:

fn queue_bind_group(
    mut commands: Commands,
    pipeline: Res<SphereDeformationPipeline>,
    render_device: Res<RenderDevice>,
    buffer: Res<ComputeUsableBuffer>,
) {
    // There is definitely a much better way to do this
    let vertex_buffer_data = buffer.clone().buffer;
    let vertex_buffer = render_device.create_buffer_with_data(&BufferInitDescriptor {
        usage: BufferUsages::VERTEX
            | BufferUsages::COPY_DST
            | BufferUsages::UNIFORM
            | BufferUsages::STORAGE,
        label: Some("Mesh Vertex Buffer"),
        contents: &vertex_buffer_data,
    });

    let buffer = GPUComputeUsableBuffer {
        buffer: vertex_buffer,
    };

    let buffer_binding = BufferBinding {
        buffer: &buffer.buffer,
        offset: 0,
        size: None,
    };

    let bind_group = render_device.create_bind_group(&BindGroupDescriptor {
        label: None,
        layout: &pipeline.texture_bind_group_layout,
        entries: &[BindGroupEntry {
            binding: 0,
            resource: BindingResource::Buffer(buffer_binding),
        }],
    });
    commands.insert_resource(SphereDeformationBindGroup(bind_group));
}

# Packing everything into a Bevy plugin

Bevy has a system of plugins with which users can modularize and distribute functionality. We created a very simple plugin that encapsulates our pipeline by implementing the Plugin trait.

pub struct SphereDeformationPlugin;

impl Plugin for SphereDeformationPlugin {
    fn build(&self, app: &mut App) {
        app.add_plugins(ExtractResourcePlugin::<SphereDeformationHandles>::default());
        app.add_plugins(SphereDeformationPlugin);
        let render_app = app.sub_app_mut(RenderApp);
        render_app.add_systems(Render, queue_bind_group.in_set(RenderSet::Queue));
    }

    fn finish(&self, app: &mut App) {
        let render_app = app.sub_app_mut(RenderApp);
        render_app.init_resource::<SphereDeformationPipeline>();
    }
}