WGPU Renderer
This is the largest and most critical part of the application. Create src/core/renderer.rs. This file handles:
- Initializing the WGPU Device, Queue, and Surface.
- Loading the shaders.
- Creating the Compute and Render pipelines.
- Managing GPU buffers (particles, uniforms).
- The main render loop.
This file is long (over 1000 lines). We will break it down into logical sections. Please ensure you copy all parts into the single renderer.rs file in the order presented.
1. Imports and Data Structures
First, we define the structs that map to our GPU buffers. Note the #[repr(C)] and bytemuck derives—these are essential for memory compatibility between Rust and the GPU.
#[cfg(not(target_arch = "wasm32"))]
use std::process::exit;
use std::sync::Arc;
use winit::{event_loop::ActiveEventLoop, keyboard::KeyCode, window::Window};
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Mat4f {
data: [[f32; 4]; 4],
}
impl std::ops::Mul for Mat4f {
type Output = Self;
fn mul(self, rhs: Self) -> Self {
let mut data = [[0.0; 4]; 4];
for c in 0..4 {
for r in 0..4 {
data[c][r] = self.data[0][r] * rhs.data[c][0]
+ self.data[1][r] * rhs.data[c][1]
+ self.data[2][r] * rhs.data[c][2]
+ self.data[3][r] * rhs.data[c][3];
}
}
Mat4f { data }
}
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct Dimensions {
width: u32,
height: u32,
generation_offset: u32,
num_of_particles: u32,
frame_time: f32,
is_gravity_on: u32,
time_to_die: f32,
num_of_particles_to_generate_per_second: u32,
target_pos: [f32; 4],
proj_view: Mat4f,
init_type: u32,
_pad: [u32; 3], // Explicit padding to align to 16 bytes
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct NumOfAliveParticles {
pub count: u32,
}
#[allow(dead_code)]
#[derive(Default)]
pub struct InputState {
pub forward: bool,
pub backward: bool,
pub left: bool,
pub right: bool,
pub up: bool,
pub down: bool,
}
#[allow(dead_code)]
pub struct Camera {
pub position: [f32; 3],
pub yaw: f32,
pub pitch: f32,
}
#[allow(dead_code)]
impl Camera {
pub fn new() -> Self {
Self {
position: [0.0, 0.0, -200.0],
yaw: 90.0f32.to_radians(),
pitch: 0.0,
}
}
pub fn get_view_matrix(&self) -> Mat4f {
let (sin_y, cos_y) = self.yaw.sin_cos();
let (sin_p, cos_p) = self.pitch.sin_cos();
let forward = [cos_p * cos_y, sin_p, cos_p * sin_y];
let forward = vec3_normalize(forward);
let target = vec3_add(&self.position, &forward);
look_at(&self.position, &target, &[0.0, 1.0, 0.0])
}
pub fn get_forward(&self) -> [f32; 3] {
let (sin_y, cos_y) = self.yaw.sin_cos();
let (sin_p, cos_p) = self.pitch.sin_cos();
let f = [cos_p * cos_y, sin_p, cos_p * sin_y];
vec3_normalize(f)
}
pub fn get_right(&self) -> [f32; 3] {
let f = self.get_forward();
let up = [0.0, 1.0, 0.0];
vec3_normalize(cross(&f, &up))
}
}
// Math Helpers
fn vec3_add(a: &[f32; 3], b: &[f32; 3]) -> [f32; 3] { [a[0] + b[0], a[1] + b[1], a[2] + b[2]] }
fn vec3_sub(a: &[f32; 3], b: &[f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }
fn vec3_scale(a: &[f32; 3], s: f32) -> [f32; 3] { [a[0] * s, a[1] * s, a[2] * s] }
fn dot(a: &[f32; 3], b: &[f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }
fn cross(a: &[f32; 3], b: &[f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
pub fn vec3_normalize(v: [f32; 3]) -> [f32; 3] {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if len > 0.0 { [v[0] / len, v[1] / len, v[2] / len] } else { v }
}
fn look_at(&position: &[f32; 3], &lookat: &[f32; 3], &up: &[f32; 3]) -> Mat4f {
let mut look_at = lookat.clone();
look_at[0] -= position[0];
look_at[1] -= position[1];
look_at[2] -= position[2];
let f: &[f32; 3] = &vec3_normalize(look_at);
let s: &[f32; 3] = &vec3_normalize(cross(f, &up));
let u: &[f32; 3] = &cross(s, f);
let mut result: Mat4f = Mat4f {
data: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]],
};
result.data[0][0] = s[0]; result.data[0][1] = u[0]; result.data[0][2] = -f[0];
result.data[1][0] = s[1]; result.data[1][1] = u[1]; result.data[1][2] = -f[1];
result.data[2][0] = s[2]; result.data[2][1] = u[2]; result.data[2][2] = -f[2];
result.data[3][0] = -dot(s, &position);
result.data[3][1] = -dot(u, &position);
result.data[3][2] = dot(f, &position);
return result;
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct Particle {
pos: [f32; 4],
speed: [f32; 4],
accel: [f32; 4],
}
fn perspective(fov_y: f32, aspect: f32, near: f32, far: f32) -> Mat4f {
let f = 1.0 / (fov_y * 0.5).tan();
let mut m = Mat4f { data: [[0.0; 4]; 4] };
m.data[0][0] = f / aspect;
m.data[1][1] = f;
m.data[2][2] = far / (near - far);
m.data[2][3] = -1.0;
m.data[3][2] = (far * near) / (near - far);
m
}
2. The Renderer Struct
This struct holds everything wgpu related.
#[allow(dead_code)]
pub struct Ren {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
is_surface_configured: bool,
pub window: Arc<Window>,
compute_pipeline: wgpu::ComputePipeline,
rendering_pipeline: wgpu::RenderPipeline,
bind_group: Option<wgpu::BindGroup>,
output_buffer: Option<wgpu::Buffer>,
particles: wgpu::Buffer,
num_of_particles: u32,
dimensions_buffer: wgpu::Buffer,
frame_time: f32,
size: (u32, u32),
padded_bytes_per_row: u32,
pointer_pos: [u32; 2],
projection: Mat4f,
pub camera: Camera,
pub input_state: InputState,
compute_shader: wgpu::ShaderModule,
is_gravity_on: bool,
gravity_follow_mouse: bool,
target_pos: [f32; 4],
is_particles_generated: bool,
time_to_die: f32,
num_of_particles_to_generate_per_second: u32,
generation_offset: u32,
pub num_of_alive_particles: NumOfAliveParticles,
num_of_alive_particles_buffer: wgpu::Buffer,
num_of_alive_particles_staging_buffer: wgpu::Buffer,
init_type: u32,
}
3. Implementation
Now we implement the Ren struct. This includes the initialization logic (`new`), resizing, input handling, and the render loop.
The new function is async because creating a WebGPU adapter and device is an asynchronous operation. We use pollster in window.rs to block on this.
#[allow(dead_code)]
impl Ren {
// Helper to switch initialization types (Sphere vs Cube)
pub fn run_init_sphere_compute_shader(&self) {
self.run_compute_shader("init_sphere");
}
fn run_init_cube_compute_shader(&self) {
self.run_compute_shader("init_cube");
}
// Generalized compute dispatch helper
fn run_compute_shader(&self, entry_point: &str) {
let pipeline = self.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(&format!("Compute Pipeline {}", entry_point)),
layout: None,
module: &self.compute_shader,
entry_point: Some(entry_point),
compilation_options: Default::default(),
cache: Default::default(),
});
let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("compute Encoder init"),
});
let bind_group_layout = pipeline.get_bind_group_layout(0);
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Compute Bind Group init"),
layout: &bind_group_layout,
entries: &[
wgpu::BindGroupEntry { binding: 1, resource: self.particles.as_entire_binding() },
wgpu::BindGroupEntry { binding: 2, resource: self.dimensions_buffer.as_entire_binding() },
],
});
{
let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Compute Pass init"),
timestamp_writes: None,
});
compute_pass.set_pipeline(&pipeline);
compute_pass.set_bind_group(0, &bind_group, &[]);
let x_groups = (self.num_of_particles + 255) / 256;
compute_pass.dispatch_workgroups(x_groups, 1, 1);
}
self.queue.submit(std::iter::once(encoder.finish()));
}
pub async fn new(window: Arc<Window>) -> anyhow::Result<Self> {
let size = window.inner_size();
// 1. Instance
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
#[cfg(not(target_arch = "wasm32"))]
backends: wgpu::Backends::PRIMARY,
#[cfg(target_arch = "wasm32")]
backends: wgpu::Backends::BROWSER_WEBGPU,
..Default::default()
});
// 2. Surface
let surface = instance.create_surface(window.clone()).unwrap();
// 3. Adapter
let adapter = instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
}).await.unwrap();
// 4. Device & Queue
let (device, queue) = adapter.request_device(&wgpu::DeviceDescriptor {
label: None,
required_features: wgpu::Features::VERTEX_WRITABLE_STORAGE,
required_limits: if cfg!(target_arch = "wasm32") {
wgpu::Limits::downlevel_webgl2_defaults()
} else {
wgpu::Limits {
max_storage_buffer_binding_size: 1024 * 1024 * 1024 - 4, // 1GB
..wgpu::Limits::defaults()
}
},
..Default::default()
}, None).await.unwrap();
// 5. Config
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps.formats.iter()
.find(|f| **f == wgpu::TextureFormat::Rgba8Unorm)
.copied()
.unwrap_or(surface_caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
format: surface_format,
width: size.width,
height: size.height,
present_mode: wgpu::PresentMode::AutoNoVsync,
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
// 6. Shaders & Pipelines
let shader = device.create_shader_module(wgpu::include_wgsl!("../../compute_shader.wgsl"));
let draw_shader = device.create_shader_module(wgpu::include_wgsl!("../../draw_shader.wgsl"));
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Compute Pipeline"),
layout: None,
module: &shader,
entry_point: Some("init_sphere"),
compilation_options: Default::default(),
cache: Default::default(),
});
let ren_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("rendering pipeline"),
layout: None,
vertex: wgpu::VertexState {
module: &draw_shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::PointList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &draw_shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
// 7. Buffers
let mut _num_of_particles = 1000000u32;
let mut _time_to_die = 0.0;
let mut _number_of_particles_to_generate_per_second = 0u32;
// Simple CLI args parsing (Native only)
#[cfg(not(target_arch = "wasm32"))]
{
let args: Vec<String> = std::env::args().collect();
if args.len() >= 2 {
if let Ok(num) = args[1].parse::<u32>() { _num_of_particles = num; }
}
}
let particles_buffer_size = _num_of_particles as u64 * std::mem::size_of::<Particle>() as u64;
let particles = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("particles buffer"),
size: particles_buffer_size,
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
});
let dimensions_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Dimensions Buffer"),
size: std::mem::size_of::<Dimensions>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let num_of_alive_particles_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("num_of_alive_particles_buffer"),
size: std::mem::size_of::<NumOfAliveParticles>() as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let num_of_alive_particles_staging_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("num_of_alive_particles_staging_buffer"),
size: std::mem::size_of::<NumOfAliveParticles>() as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
// Initial Data Write
let projection = perspective(90.0f32.to_radians(), size.width as f32 / size.height as f32, 0.01, 10000.0);
queue.write_buffer(&dimensions_buffer, 0, bytemuck::cast_slice(&[Dimensions {
width: size.width, height: size.height, generation_offset: 0,
num_of_particles: _num_of_particles, frame_time: 0.0, is_gravity_on: 1,
time_to_die: _time_to_die, num_of_particles_to_generate_per_second: 1000,
target_pos: [0.0; 4], proj_view: projection * look_at(&[0.0, 0.0, -200.0], &[0.0, 0.0, 0.0], &[0.0, 1.0, 0.0]),
init_type: 0, _pad: [0; 3],
}]));
// Re-create the main compute pipeline (pointing to main)
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Compute Pipeline Main"),
layout: None,
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: Default::default(),
});
Ok(Self {
surface, device, queue, config, is_surface_configured: false,
window, compute_pipeline: pipeline, rendering_pipeline: ren_pipeline,
bind_group: None, output_buffer: None, particles,
num_of_particles: _num_of_particles, dimensions_buffer,
frame_time: 0.0, size: (size.width, size.height), padded_bytes_per_row: 0,
pointer_pos: [0, 0], projection, camera: Camera::new(),
input_state: InputState::default(), compute_shader: shader,
is_gravity_on: true, gravity_follow_mouse: true, target_pos: [0.0; 4],
is_particles_generated: _time_to_die > 0.0, time_to_die: _time_to_die,
num_of_particles_to_generate_per_second: _number_of_particles_to_generate_per_second,
generation_offset: 0,
num_of_alive_particles: NumOfAliveParticles { count: 0 },
num_of_alive_particles_buffer, num_of_alive_particles_staging_buffer,
init_type: 0,
})
}
pub fn resize(&mut self, width: u32, height: u32) {
self.window.request_redraw();
if width > 0 && height > 0 {
self.config.width = width;
self.config.height = height;
self.size = (width, height);
self.surface.configure(&self.device, &self.config);
self.is_surface_configured = true;
self.projection = perspective(90.0f32.to_radians(), width as f32 / height as f32, 0.01, 10000.0);
}
}
pub fn handle_key(&mut self, event_loop: &ActiveEventLoop, code: KeyCode, is_pressed: bool) {
match code {
KeyCode::KeyW => self.input_state.forward = is_pressed,
KeyCode::KeyS => self.input_state.backward = is_pressed,
KeyCode::KeyA => self.input_state.left = is_pressed,
KeyCode::KeyD => self.input_state.right = is_pressed,
KeyCode::KeyQ | KeyCode::Space => self.input_state.up = is_pressed,
KeyCode::KeyE | KeyCode::ShiftLeft => self.input_state.down = is_pressed,
KeyCode::Escape if is_pressed => event_loop.exit(),
KeyCode::KeyR if is_pressed => { self.run_init_sphere_compute_shader(); self.init_type = 1; },
KeyCode::KeyT if is_pressed => { self.run_init_cube_compute_shader(); self.init_type = 0; },
KeyCode::KeyG if is_pressed => self.is_gravity_on = !self.is_gravity_on,
KeyCode::KeyF if is_pressed => self.gravity_follow_mouse = !self.gravity_follow_mouse,
_ => {}
}
}
pub fn handle_mouse_motion(&mut self, delta_x: f64, delta_y: f64) {
let sensitivity = 0.002;
self.camera.yaw += delta_x as f32 * sensitivity;
self.camera.pitch -= delta_y as f32 * sensitivity;
self.camera.pitch = self.camera.pitch.clamp(-1.56, 1.56);
}
pub fn update(&mut self) {
let speed = 200.0 * self.frame_time;
let f = self.camera.get_forward();
let r = self.camera.get_right();
let u = [0.0, 1.0, 0.0];
if self.input_state.forward { self.camera.position = vec3_add(&self.camera.position, &vec3_scale(&f, speed)); }
if self.input_state.backward { self.camera.position = vec3_sub(&self.camera.position, &vec3_scale(&f, speed)); }
if self.input_state.right { self.camera.position = vec3_add(&self.camera.position, &vec3_scale(&r, speed)); }
if self.input_state.left { self.camera.position = vec3_sub(&self.camera.position, &vec3_scale(&r, speed)); }
if self.input_state.up { self.camera.position = vec3_add(&self.camera.position, &vec3_scale(&u, speed)); }
if self.input_state.down { self.camera.position = vec3_sub(&self.camera.position, &vec3_scale(&u, speed)); }
}
pub fn render(&mut self, frame_time: f32, pointer_pos: [u32; 2]) -> Result<(), wgpu::SurfaceError> {
self.window.request_redraw();
self.frame_time = frame_time;
self.pointer_pos = pointer_pos;
// Raycast logic for mouse attraction
if self.gravity_follow_mouse {
// (Simplified raycast logic here for brevity - matches code in repo)
let width = self.size.0 as f32;
let height = self.size.1 as f32;
let ndc_x = (pointer_pos[0] as f32 / width) * 2.0 - 1.0;
let ndc_y = 1.0 - (pointer_pos[1] as f32 / height) * 2.0;
// ... raycast math ...
// (See full source for detail)
}
// Write Uniforms
self.queue.write_buffer(&self.dimensions_buffer, 0, bytemuck::cast_slice(&[Dimensions {
width: self.size.0,
height: self.size.1,
generation_offset: self.generation_offset,
num_of_particles: self.num_of_particles,
frame_time: self.frame_time,
is_gravity_on: if self.is_gravity_on { 1 } else { 0 },
time_to_die: self.time_to_die,
num_of_particles_to_generate_per_second: ((self.num_of_particles_to_generate_per_second as f32) * self.frame_time) as u32,
target_pos: self.target_pos,
proj_view: self.projection * self.camera.get_view_matrix(),
init_type: self.init_type,
_pad: [0; 3],
}]));
if !self.is_surface_configured { return Ok(()); }
let output = self.surface.get_current_texture()?;
let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Render Encoder") });
// COMPUTE PASS
let bind_group_layout = self.compute_pipeline.get_bind_group_layout(0);
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Compute Bind Group"),
layout: &bind_group_layout,
entries: &[
wgpu::BindGroupEntry { binding: 1, resource: self.particles.as_entire_binding() },
wgpu::BindGroupEntry { binding: 2, resource: self.dimensions_buffer.as_entire_binding() },
wgpu::BindGroupEntry { binding: 3, resource: self.num_of_alive_particles_buffer.as_entire_binding() },
],
});
{
let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("Compute Pass"), timestamp_writes: None });
compute_pass.set_pipeline(&self.compute_pipeline);
compute_pass.set_bind_group(0, &bind_group, &[]);
let x_groups = (self.num_of_particles + 255) / 256;
compute_pass.dispatch_workgroups(x_groups, 1, 1);
}
// RENDER PASS
let render_bind_group_layout = self.rendering_pipeline.get_bind_group_layout(0);
let render_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Render Bind Group"),
layout: &render_bind_group_layout,
entries: &[
wgpu::BindGroupEntry { binding: 1, resource: self.particles.as_entire_binding() },
wgpu::BindGroupEntry { binding: 2, resource: self.dimensions_buffer.as_entire_binding() },
],
});
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Render Pass"),
timestamp_writes: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
});
render_pass.set_pipeline(&self.rendering_pipeline);
render_pass.set_bind_group(0, &render_bind_group, &[]);
render_pass.draw(0..self.num_of_particles, 0..1);
}
self.queue.submit(std::iter::once(encoder.finish()));
output.present();
Ok(())
}
}