DEV Community

J
J

Posted on

Building a Reusable WebGPU & Rust Engine: Part 2 — Loading Custom Geometry & WGSL Shaders

In Part 1, we talked about the CPU event loop and established our foundational wgpu state. Now, we expand our engine to load 3D geometry (.obj files) from disk and feed it through a custom WGSL Shader Pipeline.
old-post link

Writing WGSL Shaders

Shaders are the small, highly parallel programs that run directly on the GPU to render 3D geometry and color pixels.

In wgpu, we primarily work with two types of shaders:

  • Vertex Shader (@vertex): Processes incoming vertex positions and attributes. Its primary job is converting 3D coordinates into Clip Space coordinates.(homogenous coordinates used by the GPU rasterizer)

  • Fragment Shader (@fragment): Runs once for every pixel (or fragment) covered by a primitive (like a triangle) to determine its output color.
    We will go back to this part in short furture! it’s very useful process.

(Note: traditional APIs had geometry shaders, WebGPU omits them to ensure consistent cross-platform performance.)

When loading external 3D models (like an .obj file), here I pass three core vertex attributes to the vertex shader:

  1. Position: The 3D spatial coordinates [x, y, z].
  2. Texture Coordinates (UV): Mappings used to align 2D textures onto 3D surfaces.
  3. Normals: Surface direction vectors, essential for calculating real-time lighting in the fragment shader.

Preparing Memory Layouts & CPU-to-GPU Buffers

To pass .obj model data from the CPU to GPU memory, we establish two key data structures:

1. Vertex custom data struct (wgpu::VertexBufferLayout & wgpu::VertexAttribute):

  • position — [f32; 3]
  • tex_coords — [f32; 2]
  • normal — [f32; 3]

Defines how memory is ordered inside the vertex buffer. Using wgpu::VertexAttribute, we specify memory offsets for each field (Position, UV, Normal) so the GPU knows exactly where each attribute begins in raw binary data.

2. Mesh custom data struct (CPU Side):

Once the .obj file is loaded into your Vertex structs, you upload them to the GPU. You create a Mesh struct to hold onto those GPU handles:

  • Vertex Buffer: Stores the array of packed vertex data.
  • Index Buffer: Stores the vertex indices, allowing triangles to reuse shared vertices to save GPU memory.
  • Index Count (indices.len()): Tracks the total element count required by render_pass.draw_indexed().

3. Connecting Everything in the Render Pipeline

[ .obj File & .wgsl Shader ]
              │
              ▼
    Load from Local Disk
              │
              ▼
  device.create_shader_module()  ──► Compile WGSL Code
  Mesh::load_obj()               ──► Upload Vertex/Index Buffers to GPU
              │
              ▼
 device.create_render_pipeline() ──► Reference Vertex::desc() for Memory Layout
              │
              ▼
 [ Store Mesh & Pipeline in State ] ──► Ready to Draw in state.render()
Enter fullscreen mode Exit fullscreen mode
  1. Load Assets: Read the .obj file into a Mesh struct and compile the WGSL shader code using device.create_shader_module().

  2. Build the RenderPipeline: Create the pipeline via device.create_render_pipeline(), setting the @vertex and @fragment entry points. Pass description Vertex::desc() into the pipeline setup so wgpu render_pipeline knows how to map the vertex buffer straight into shader inputs.

  3. Execute during Render: Store both the RenderPipeline and Mesh inside your State. Inside render() loop, bind the pipeline and draw the mesh buffers


Just like in Part 1, in the end the exciting result: we can load a 3D box into the scene using our custom shader—even without setting up a camera yet!


Thanks for reading, here is my Industrial Rust Projects:
Building industrial 3D vision or AI pipelines? Check out ForgeOptics on Gumroad for high-performance Rust ray-tracing, physical sensor noise, and synthetic dataset generation.

Top comments (2)

Collapse
 
ai_adam profile image
ai_adam •

Đang theo dõi series này từ Part 1 — cách tách biệt event loop CPU khỏi GPU state là điểm mạnh, giúp test và debug riêng lẻ từng phần dễ hơn hẳn.

Về Part 2, mình quan tâm chỗ handle custom geometry: bạn có dùng trait bytemuck::Pod + Zeroable cho vertex buffer không? Mình từng gặp case alignment mismatch giữa struct Rust và layout WGSL khi thêm attribute instance — debug mất cả buổi vì offset tính sai 4 bytes.

Còn shader loading: có support hot-reload không? Mình hay dùng notify + wgpu::ShaderModuleDescriptor::from_wgsl để reload runtime khi dev, nhưng production thì phải pre-compile SPIRV qua naga hoặc shaderc để tránh overhead parse WGSL mỗi lần boot.

Pipeline layout management thế nào? Mình thấy pattern dùng PipelineLayoutBuilder tự derive từ #[repr(C)] struct binding giúp giảm boilerplate và tránh typo bind group index.

Đợi Part 3 xem bạn xử lý resource lifetime + async asset loading ra sao (site: labagent .tech)

Collapse
 
jin_123456 profile image
J •

Thanks for your comments!
and I just google translate the comments:

  1. bytemuck::Pod + Zeroable for vertex buffers -> yes
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Vertex {
    pub position: [f32; 3],
    pub tex_coords: [f32; 2],
    pub normal: [f32; 3],
}
Enter fullscreen mode Exit fullscreen mode

Position: [f32; 3] occupies 12 bytes (3 floats * 4 bytes each) at offset 0
UV Coordinates: [f32; 2] occupies 8 bytes (2 floats * 4 bytes each) at offset 12
Normals: [f32; 3] occupies 12 bytes (3 floats * 4 bytes each) at offset 20
it's like 32 bytes stride, I forces Rust to lay out the struct in memory exact sequence using the C language memory standards

  1. Shader hot-reloading — no, at least not now currently I just do :
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
        label: Some("Main Shader"),
        source: wgpu::ShaderSource::Wgsl(include_str!("../assets/shader.wgsl").into()),
 });
Enter fullscreen mode Exit fullscreen mode

That’s a great point about hot-reloading—it would definitely speed up iteration during development, so I’ll look into adding it!
Regarding Naga and Shaderc, since Naga is built right into wgpu, I currently just load .wgsl files at startup. From what I understand, letting wgpu/Naga handle the WGSL parsing at startup is already very fast and keeps the binary cross-platform across Vulkan, Metal, and DirectX 12 without needing external tooling.
As for pre-compiling to SPIR-V, isn't SPIR-V primarily tied to Vulkan backends? Using raw WGSL seems like the standard approach in wgpu to maintain broad platform compatibility without having to enable the optional spirv feature flags.

3 The pipeline layout
That’s an interesting pattern! Using a PipelineLayoutBuilder or derive macro sounds like a great way to reduce boilerplate as a project grows.

Right now, I’m creating the layouts manually—for instance, explicitly passing &[&camera_controller.bind_group_layout] when building render_pipeline_layout. Since wgpu’s runtime validation instantly catches any bind group slot mismatches at set_bind_group time, index typos haven't been a hidden issue in practice. However, I can definitely see how a builder abstraction would keep things much cleaner if the number of bind groups expands!

  1. Resource lifetimes / async loading Currently, the asset loader reads files synchronously on startup using tobj::load_obj. Moving to an async pipeline—spawning background loading tasks and passing parsed mesh data over channels (or sharing an Arcwgpu::Device across worker threads)—is definitely on my radar as models get larger.