Round Egg 2: Setup and wgpu

2023-06-01

Pedro Burgos, Dominykas Jogela

Update: The original idea of the project was to make our own custom render loop. At some point later down the line we discarded that idea. This post explores our initial attempt at understanding wgpu's inner workings.


Throughout the project, we have gone back and forth in our implementation decisions, for example, we have later switched to nightly rust. Furthermore we have gone through several major Bevy releases.

We commence by referring to the wgpu documentation. It explains how we can hook into winit's event loop, and use env_logger for the purpose of logging within wgpu.

pub fn run() {
    env_logger::init(); // Necessary for logging within WGPU
    let event_loop = EventLoop::new(); // Loop provided by winit for handling window events
    let window = WindowBuilder::new().build(&event_loop).unwrap();
    // ...
}

Then we need to initialize the wgpu backend, this by doing so we can specify the requirements of the platform.

    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
        backends: wgpu::Backends::all(),
        dx12_shader_compiler: wgpu::Dx12Compiler::Dxc {
            dxc_path: None,
            dxil_path: None,
        },
    });

We also need to create a few other resources, a surface and an adapter (we utilize pollster to block waiting for the adapter)

    let surface = unsafe { instance.create_surface(&window) }.unwrap();
    let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
        power_preference: wgpu::PowerPreference::default(),
        compatible_surface: Some(&surface),
        force_fallback_adapter: false,
    }))
    .unwrap();

request_device grants us a device which will manage the GPU resources such as buffers or textures, and a queue associated with it. The queue will be our way to submit rendering commands to the GPU.

    let (device, queue) = pollster::block_on(adapter.request_device(
        &wgpu::DeviceDescriptor {
            label: None,
            features: wgpu::Features::empty(),
            limits: wgpu::Limits::default(),
        },
        None, // Trace path
    ))
    .unwrap();

Finally we configure our surface. We don't particularly care about the settings right now, we just want to be able to render something (anything).

    let size = window.inner_size();
    surface.configure(
        &device,
        &wgpu::SurfaceConfiguration {
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
            format: wgpu::TextureFormat::Bgra8Unorm, /* BGRA is the most widely supported format */
            width: size.width,
            height: size.height,
            present_mode: wgpu::PresentMode::Fifo,
            alpha_mode: wgpu::CompositeAlphaMode::Opaque,
            view_formats: Vec::<wgpu::TextureFormat>::new(),
        },
    );

That is it for the setup boilerplate.

We still need one more bit, the event loop. As we mentioned we can hook up to winit's event loop.

    event_loop.run(move |event, _, control_flow| {
        *control_flow = ControlFlow::Wait;

        match event {
            Event::WindowEvent {
              // ...
            }
            Event::RedrawRequested(_) => {
              // ...
            }
            _ => (),
        }

Mainly we want to redirect winit's redraw events to wgpu, and trigger a render pass. For now we will simply draw a plain green image.

        match event {
          // ...
            Event::RedrawRequested(_) => {
              // ...
                let output = surface.get_current_texture().unwrap();
                let view = output
                    .texture
                    .create_view(&wgpu::TextureViewDescriptor::default());
                let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
                    label: Some("Render Encoder"),
                });

                // Create and configure the render pass
                {
                    let _render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                        label: Some("Render Pass"),
                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                            view: &view,
                            resolve_target: None,
                            ops: wgpu::Operations {
                                load: wgpu::LoadOp::Clear(wgpu::Color {
                                    r: 0.1, 
                                    g: 0.9,
                                    b: 0.3,
                                    a: 1.0,
                                }),
                                store: true,
                            },
                        })],
                        depth_stencil_attachment: None,
                    });
                }

                // Submit the rendering command 
                queue.submit(std::iter::once(encoder.finish()));
                output.present();
            }
            _ => (),
        }

We can finally run our code and check that everything works. This is one of these cases where rust's type system actually came in very helpful. The whole time it felt like putting pieces of a puzzle together, as opposed to hopping that the order in we are calling functions in the correct one and nothing explodes.

Rendering of our green surface
Rendering of our green surface

Now the only thing left is a big refactor, we can create a State object that will hold the rendering state of our program.

pub struct RenderingState {
    pub(crate) surface: wgpu::Surface,
    pub(crate) device: wgpu::Device,
    pub(crate) queue: wgpu::Queue,
    pub(crate) config: wgpu::SurfaceConfiguration,
    pub(crate) size: winit::dpi::PhysicalSize<u32>,
    pub(crate) window: winit::window::Window,
}

Finally we also chose to replace pollster with tokio. This was always the plan but we were using the former for convenience.

We made some attempts at making the code compile to Web assembly, however we quickly realized that it would add some extra complexity which was out of the scope of the project.

For example, WASM does not fully support async - as the browser is a single threaded environment. Though there exist libraries such as wasm-bindgen-futures which convert Rust's Futures into Promises.

At the end, our main function looks something like this.

#[tokio::main]
async fn main() {
    let (window, event_loop) = init_window();
    let state = State::new(window).await;
    event_loop.run(event_handler(state));
}