Rendering Pipeline, Depth, Camera, Light, Transform, and bevy_ecs


2024-11-14

Auto translated into English by AI.

Nov-13-2024 19-17-11.gif

Wgpu PBR is a practice project aimed at building a PBR renderer with wgpu and Rust. I may implement various other graphics effects in the future.

It currently uses Egui for the UI and the bevy_ecs library to manage logic.

The code shown here is from an early development version and will most likely be refactored in the future.

Mesh

Loaded models have the following containment hierarchy:

Model → Mesh → Primitive.

Although a Primitive is the smallest unit of a model, it can't be rendered on its own, only when a Mesh is rendered. A Primitive is also the unit that carries a material.

Model data structure

  • Model

    • UploadedMesh (Vec<_>)

      • Vertex buffer

      • Index buffer

      • UploadedPrimitive (Vec<_>)

        • indices_start

        • indices_num

        • material_instance

Currently, loading models in glb format is supported.

Material

The Material programming pattern is essentially about organizing and managing Pipeline and BindGroup (while also storing the associated Layout). The project's material BindGroupLayout currently looks like this:

  1. Transform

  2. Camera

  3. Light

  4. [Material‘s]

    1. Texture

The Material pattern in this project is currently implemented using two structs: Material and Material Instance.

A Material provides the Pipeline and shared BindGroup values (such as Camera and Light), and defines and stores the associated Layout. An Instance provides the BindGroup values that vary between instances (such as Texture).

  • The Transform BindGroup is provided by MeshRenderer.

Texture

See https://sotrh.github.io/learn-wgpu/beginner/tutorial5-textures/

rust
pub struct UploadedImage {
    pub size: wgpu::Extent3d,
    pub texture: Texture,
    pub view: TextureView,
    pub sampler: Sampler,
}

Implementing Camera

I'm using cgmath as the math library. Camera's View and Projection matrices can be calculated quickly with cgmath.

rust
impl Camera{
    ...
    pub fn build_view_projection_matrix(&self) -> Matrix4<f32> {
        let view = Matrix4::look_at_rh(self.eye, self.target, self.up);
        let proj = perspective(cgmath::Deg(self.fovy), self.aspect, self.znear, self.zfar);
        return OPENGL_TO_WGPU_MATRIX * proj * view;
    }
}
#[rustfmt::skip]
pub const OPENGL_TO_WGPU_MATRIX: cgmath::Matrix4<f32> = cgmath::Matrix4::new(
    1.0, 0.0, 0.0, 0.0,
    0.0, 1.0, 0.0, 0.0,
    0.0, 0.0, 0.5, 0.5,
    0.0, 0.0, 0.0, 1.0,
);

OpenGL's normalized device coordinate (NDC) depth range (z) is [-1, 1], while the normalized device coordinate depth range in wpgu/Vulkan/DX/Mental is [0, 1]. The projection matrix generated by cgmath follows the OpenGL specification, so a conversion matrix is needed to map depth to [0, 1].

RenderCamera

The camera-related Buffer, BindGroupLayout, and BindGroup are stored and managed in RenderCamera, a Bevy Resource. They are created together when RenderCamera is initialized.

rust
#[derive(Resource)]
pub struct RenderCamera {
    pub camera: Camera,
    pub buffer: Arc<wgpu::Buffer>,
    pub bind_group_layout: Arc<BindGroupLayout>,
    pub bind_group: Arc<BindGroup>,
}
rust
impl RenderCamera {
    pub fn new(device: &wgpu::Device, aspect: f32) -> RenderCamera {
        let camera = Camera::new(aspect);
        let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
          ...
        });
        let camera_bind_group_layout =
            Arc::new(device.create_bind_group_layout(&CameraUniform::layout_desc()));
        let camera_bind_group = Arc::new(device.create_bind_group(&wgpu::BindGroupDescriptor {
          ...
        }));
        RenderCamera {
            camera,
            buffer: Arc::new(camera_buffer),
            bind_group_layout: camera_bind_group_layout,
            bind_group: camera_bind_group,
        }
    }
}

Implementing Transform

This implementation is only for convenience and isn't worth using as a reference. For a reference, see Bevy's Transform logic implementation.

The logical Transform looks like this:

rust
#[derive(Component)]
pub struct Transform {
    pub parent: Option<Entity>,
    pub children: Vec<Entity>,
    pub position: Point3<f32>,
    pub rotation: Quaternion<f32>,
    pub scale: Vector3<f32>,
}

For rendering, Transform is currently implemented using a uniform. The Uniform Buffer passed to the GPU contains a model matrix (to transform vertex positions) and the model's rotation matrix (to transform vertex normals).

Data size alignment

Data passed to the GPU needs size alignment according to standard layout rules (std140 or std430). In wgsl, its sizes are treated as follows; cases where the type size differs from Rust are marked in brown:

Data type <f32> f32 vec2 vec3 vec4 mat3x3 mat4x4
Size / bytes 4 8 16 16 48 (3 * vec3) 64 (4 * vec3)

Also, the size passed to the Buffer must be a multiple of 16. So padding variables are declared to align the data.

rust
pub struct TransformUniform {
    pub model: [[f32; 4]; 4],
    pub rotation: [[f32; 3]; 3],
    pub padding: [f32; 3],
}

Encapsulation

Each model with a Transform that is to be rendered has its own Transform Buffer and BindGroup, so they are encapsulated together in a component called MeshRenderer.

rust
#[derive(Component, Default)]
pub struct MeshRenderer {
    pub mesh: Option<Arc<UploadedMesh>>,
    pub transform_bind_group: Option<Arc<BindGroup>>,
    pub transform_buffer: Option<Arc<Buffer>>,
}

Implementing a directional light

Similar to Camera, this is managed by a RenderLight. It currently contains only one directional light.

The LightUniform structure passed to the GPU is shown below. Whether intensity needs to be passed still needs investigation.

rust
#[repr(C, align(16))]
#[derive(Debug, Clone, Copy)]
pub struct LightUniform {
    pub direction: [f32; 3],
    pub padding1: f32,
    pub color: [f32; 4],
    pub intensity: f32,
    pub padding2: [f32; 3],
}

Engine Lifetime

The game currently uses a temporary lifecycle.

rust
input()
handle_redraw()
  pre_update() // 更新 Time
  update() //游戏逻辑
  post_update() // 更新 Unifoms to GPU
  render() // 渲染

Other notes

  • The camera's depth map is written to depth_texture for future use.

  • A simple Input records input.

  • A simple Time records delta time.

Related links

【Wgpu introductory material】https://sotrh.github.io/learn-wgpu/

【Vulkan introductory material】https://vulkan-tutorial.com/

【Learning Vulkan by building a game engine】https://vkguide.dev/

【PBR theory】https://learnopengl.com/PBR/Theory

【Bevy Engine】https://bevyengine.org/

【Egui】https://www.egui.rs/

【Model used】https://sketchfab.com/3d-models/mkr-lykken-91b274a40ebe46cd931235ac32ae0492