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 (1)

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)