Initial claude-generated commit

prompt:

yeah give me a starter project structure with rust + wgpu + trunk, with
a really simple 3d scene - maybe just moving the camera around 3d space
with WSAD, and a couple of flat-shaded colorful spheres and octohedrons
floating in space to give something to look at
This commit is contained in:
Greg Shuflin
2026-02-07 20:53:19 -08:00
commit 24efbeddf2
11 changed files with 1188 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "wgpu-demo"
version = "0.1.0"
edition = "2021"
[dependencies]
wgpu = "23"
winit = { version = "30", features = ["rwh_06"] }
glam = "0.29"
bytemuck = { version = "1", features = ["derive"] }
pollster = "0.4"
log = "0.4"
rand = "0.8"
web-time = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
env_logger = "0.11"
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1"
console_log = "1"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"Document",
"Window",
"Element",
"HtmlCanvasElement",
] }
[profile.release]
opt-level = "s"
lto = true
strip = true
+58
View File
@@ -0,0 +1,58 @@
# wgpu-demo
A minimal 3D tech demo using Rust + wgpu + winit, compiled to WASM via Trunk.
Flat-shaded colorful spheres and octahedra floating in space. WASD + mouse to fly around.
## Prerequisites
```sh
# Install trunk (WASM bundler)
cargo install trunk
# Add the WASM target
rustup target add wasm32-unknown-unknown
```
## Run in browser (WASM)
```sh
trunk serve
```
Then open http://127.0.0.1:8080 — click to capture the mouse, WASD to move, mouse to look, Space/Shift for up/down, Escape to release cursor.
## Run natively (desktop)
```sh
cargo run --release
```
Same controls. Native build uses Vulkan/Metal/DX12 via wgpu.
## Deploy
```sh
trunk build --release
```
This produces a `dist/` folder you can deploy to any static host (Vercel, Netlify, GitHub Pages, etc).
## Project structure
```
src/
main.rs — Entry point, winit event loop, input handling, WASM bootstrap
renderer.rs — wgpu device/surface/pipeline setup, render loop
camera.rs — FPS camera with mouse look
mesh.rs — Procedural icosphere + octahedron generation (flat-shaded)
scene.rs — Scene population: random placement of colored shapes
shader.wgsl — Vertex + fragment shader with directional + specular lighting
```
## Notes
- The renderer writes per-object model transforms via `queue.write_buffer` each draw call. This is fine for ~30 objects but you'd want instanced rendering or a dynamic uniform buffer for hundreds+.
- `web-time` is used instead of `std::time::Instant` because the latter panics on WASM.
- On WASM, wgpu uses WebGPU if available, falling back to WebGL2 via the GL backend.
- Binary size: release builds with `opt-level = "s"` + LTO are typically 1-3 MB gzipped.
+6
View File
@@ -0,0 +1,6 @@
[build]
# Output directory
dist = "dist"
[watch]
watch = ["src", "index.html"]
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>wgpu demo</title>
<link data-trunk rel="rust" data-wasm-opt="z" />
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
}
canvas {
width: 100% !important;
height: 100% !important;
}
</style>
</head>
<body>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
use glam::{Mat4, Vec3};
pub struct Camera {
pub position: Vec3,
/// Yaw in radians (rotation around Y axis)
pub yaw: f32,
/// Pitch in radians (rotation around X axis), clamped
pub pitch: f32,
pub aspect: f32,
pub fov_y: f32,
pub near: f32,
pub far: f32,
}
impl Camera {
pub fn new(position: Vec3, aspect: f32) -> Self {
Self {
position,
yaw: 0.0,
pitch: 0.0,
aspect,
fov_y: 60.0_f32.to_radians(),
near: 0.1,
far: 200.0,
}
}
/// Forward direction on the XZ plane (for WASD movement)
pub fn forward(&self) -> Vec3 {
Vec3::new(self.yaw.sin(), 0.0, -self.yaw.cos()).normalize()
}
/// Right direction on the XZ plane
pub fn right(&self) -> Vec3 {
Vec3::new(self.yaw.cos(), 0.0, self.yaw.sin()).normalize()
}
/// The actual look direction (including pitch) for the view matrix
fn look_dir(&self) -> Vec3 {
Vec3::new(
self.pitch.cos() * self.yaw.sin(),
self.pitch.sin(),
-self.pitch.cos() * self.yaw.cos(),
)
.normalize()
}
pub fn view_matrix(&self) -> Mat4 {
let target = self.position + self.look_dir();
Mat4::look_at_rh(self.position, target, Vec3::Y)
}
pub fn projection_matrix(&self) -> Mat4 {
Mat4::perspective_rh(self.fov_y, self.aspect, self.near, self.far)
}
pub fn view_proj(&self) -> Mat4 {
self.projection_matrix() * self.view_matrix()
}
/// Process mouse delta for look-around
pub fn mouse_look(&mut self, dx: f32, dy: f32) {
let sensitivity = 0.003;
self.yaw += dx * sensitivity;
self.pitch -= dy * sensitivity;
// Clamp pitch to avoid gimbal flip
self.pitch = self.pitch.clamp(
-89.0_f32.to_radians(),
89.0_f32.to_radians(),
);
}
}
/// Uniform data shipped to the GPU
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct CameraUniform {
pub view_proj: [[f32; 4]; 4],
pub eye_pos: [f32; 3],
pub _padding: f32,
}
impl CameraUniform {
pub fn from_camera(cam: &Camera) -> Self {
Self {
view_proj: cam.view_proj().to_cols_array_2d(),
eye_pos: cam.position.into(),
_padding: 0.0,
}
}
}
+20
View File
@@ -0,0 +1,20 @@
use std::collections::HashSet;
use winit::keyboard::KeyCode;
pub struct InputState {
pub keys_held: HashSet<KeyCode>,
pub mouse_locked: bool,
}
impl InputState {
pub fn new() -> Self {
Self {
keys_held: HashSet::new(),
mouse_locked: false,
}
}
pub fn is_pressed(&self, key: KeyCode) -> bool {
self.keys_held.contains(&key)
}
}
+284
View File
@@ -0,0 +1,284 @@
mod camera;
mod input;
mod mesh;
mod renderer;
mod scene;
use std::sync::Arc;
use camera::Camera;
use glam::Vec3;
use input::InputState;
use renderer::Renderer;
use winit::{
application::ApplicationHandler,
dpi::PhysicalSize,
event::{DeviceEvent, ElementState, KeyEvent, WindowEvent},
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
keyboard::{KeyCode, PhysicalKey},
window::{CursorGrabMode, Window, WindowAttributes, WindowId},
};
struct App {
renderer: Option<Renderer>,
window: Option<Arc<Window>>,
camera: Camera,
input: InputState,
scene_meshes_and_objects: Option<(Vec<mesh::Mesh>, Vec<scene::SceneObject>)>,
start_time: Option<web_time::Instant>,
last_frame: Option<web_time::Instant>,
}
impl App {
fn new() -> Self {
let (meshes, objects) = scene::build_scene();
Self {
renderer: None,
window: None,
camera: Camera::new(Vec3::new(0.0, 2.0, 10.0), 1.0),
input: InputState::new(),
scene_meshes_and_objects: Some((meshes, objects)),
start_time: None,
last_frame: None,
}
}
fn update(&mut self, dt: f32) {
let speed = 8.0 * dt;
if self.input.is_pressed(KeyCode::KeyW) {
self.camera.position += self.camera.forward() * speed;
}
if self.input.is_pressed(KeyCode::KeyS) {
self.camera.position -= self.camera.forward() * speed;
}
if self.input.is_pressed(KeyCode::KeyA) {
self.camera.position -= self.camera.right() * speed;
}
if self.input.is_pressed(KeyCode::KeyD) {
self.camera.position += self.camera.right() * speed;
}
if self.input.is_pressed(KeyCode::Space) {
self.camera.position.y += speed;
}
if self.input.is_pressed(KeyCode::ShiftLeft) {
self.camera.position.y -= speed;
}
}
fn try_lock_cursor(&self, window: &Window) {
// Try confined first (works on more platforms), fall back to locked
let _ = window
.set_cursor_grab(CursorGrabMode::Locked)
.or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined));
window.set_cursor_visible(false);
}
fn unlock_cursor(&self, window: &Window) {
let _ = window.set_cursor_grab(CursorGrabMode::None);
window.set_cursor_visible(true);
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return;
}
let attrs = WindowAttributes::default()
.with_title("wgpu demo")
.with_inner_size(PhysicalSize::new(1280u32, 720));
let window = Arc::new(event_loop.create_window(attrs).unwrap());
#[cfg(target_arch = "wasm32")]
{
use winit::platform::web::WindowExtWebSys;
let canvas = window.canvas().expect("Couldn't get canvas");
canvas.style().set_css_text("width: 100%; height: 100%;");
web_sys::window()
.and_then(|win| win.document())
.and_then(|doc| doc.body())
.map(|body| body.append_child(&canvas).unwrap());
// On WASM we need to spawn the async init
let win_clone = window.clone();
let meshes_and_objects = self.scene_meshes_and_objects.take().unwrap();
// We'll store the Arc<Window> now and init the renderer in a spawn
self.window = Some(window);
// This is a bit of a dance because we can't block on WASM
wasm_bindgen_futures::spawn_local(async move {
// We stash renderer init for the resumed handler to pick up
// Actually, let's use a different approach — store via a global
let renderer =
Renderer::new(win_clone, &meshes_and_objects.0).await;
// We need to get this back to the App somehow. For simplicity,
// use a thread_local or similar. But winit 0.30+ on wasm makes
// this tricky. Let's use a simpler approach below.
PENDING_INIT
.with(|cell| {
*cell.borrow_mut() = Some((renderer, meshes_and_objects));
});
});
return;
}
#[cfg(not(target_arch = "wasm32"))]
{
self.window = Some(window.clone());
let (meshes, objects) = self.scene_meshes_and_objects.take().unwrap();
let renderer = pollster::block_on(Renderer::new(window, &meshes));
self.renderer = Some(renderer);
self.scene_meshes_and_objects = Some((meshes, objects));
self.start_time = Some(web_time::Instant::now());
self.last_frame = Some(web_time::Instant::now());
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
// On WASM, check if the async renderer init has completed
#[cfg(target_arch = "wasm32")]
if self.renderer.is_none() {
PENDING_INIT.with(|cell| {
if let Some((renderer, data)) = cell.borrow_mut().take() {
self.renderer = Some(renderer);
self.scene_meshes_and_objects = Some(data);
self.start_time = Some(web_time::Instant::now());
self.last_frame = Some(web_time::Instant::now());
}
});
}
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::KeyboardInput {
event:
KeyEvent {
physical_key: PhysicalKey::Code(key),
state,
..
},
..
} => {
match state {
ElementState::Pressed => {
self.input.keys_held.insert(key);
// Escape to release cursor
if key == KeyCode::Escape && self.input.mouse_locked {
self.input.mouse_locked = false;
if let Some(w) = &self.window {
self.unlock_cursor(w);
}
}
}
ElementState::Released => {
self.input.keys_held.remove(&key);
}
}
}
WindowEvent::MouseInput {
state: ElementState::Pressed,
..
} => {
if !self.input.mouse_locked {
self.input.mouse_locked = true;
if let Some(w) = &self.window {
self.try_lock_cursor(w);
}
}
}
WindowEvent::Resized(size) => {
if let Some(r) = &mut self.renderer {
r.resize(size.width, size.height);
self.camera.aspect = size.width as f32 / size.height.max(1) as f32;
}
}
WindowEvent::RedrawRequested => {
let Some(renderer) = &self.renderer else { return };
let Some(start) = self.start_time else { return };
let Some((_, objects)) = &self.scene_meshes_and_objects else {
return;
};
let now = web_time::Instant::now();
let dt = now
.duration_since(self.last_frame.unwrap_or(now))
.as_secs_f32()
.min(0.1); // cap to avoid spiral of death
self.last_frame = Some(now);
let time = now.duration_since(start).as_secs_f32();
self.update(dt);
match renderer.render(&self.camera, objects, time) {
Ok(_) => {}
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
let size = self.window.as_ref().unwrap().inner_size();
if let Some(r) = &mut self.renderer {
r.resize(size.width, size.height);
}
}
Err(wgpu::SurfaceError::OutOfMemory) => {
log::error!("Out of GPU memory!");
event_loop.exit();
}
Err(e) => {
log::warn!("Surface error: {:?}", e);
}
}
// Request next frame
if let Some(w) = &self.window {
w.request_redraw();
}
}
_ => {}
}
}
fn device_event(
&mut self,
_event_loop: &ActiveEventLoop,
_device_id: winit::event::DeviceId,
event: DeviceEvent,
) {
if let DeviceEvent::MouseMotion { delta: (dx, dy) } = event {
if self.input.mouse_locked {
self.camera.mouse_look(dx as f32, dy as f32);
}
}
}
}
// For WASM: thread-local to shuttle the renderer from the async init back to the event loop
#[cfg(target_arch = "wasm32")]
thread_local! {
static PENDING_INIT: std::cell::RefCell<
Option<(Renderer, (Vec<mesh::Mesh>, Vec<scene::SceneObject>))>,
> = std::cell::RefCell::new(None);
}
fn main() {
// Logging
#[cfg(target_arch = "wasm32")]
{
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
console_log::init_with_level(log::Level::Info).expect("Couldn't init logger");
}
#[cfg(not(target_arch = "wasm32"))]
{
env_logger::init();
}
let event_loop = EventLoop::new().unwrap();
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = App::new();
event_loop.run_app(&mut app).unwrap();
}
+165
View File
@@ -0,0 +1,165 @@
use bytemuck::{Pod, Zeroable};
use glam::Vec3;
/// Interleaved vertex: position + normal + color
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct Vertex {
pub position: [f32; 3],
pub normal: [f32; 3],
pub color: [f32; 3],
}
impl Vertex {
pub fn layout() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
// position
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
},
// normal
wgpu::VertexAttribute {
offset: 12,
shader_location: 1,
format: wgpu::VertexFormat::Float32x3,
},
// color
wgpu::VertexAttribute {
offset: 24,
shader_location: 2,
format: wgpu::VertexFormat::Float32x3,
},
],
}
}
}
/// Generate flat-shaded triangle list (no index buffer — each triangle gets its own
/// vertices so they have per-face normals).
pub struct Mesh {
pub vertices: Vec<Vertex>,
}
impl Mesh {
/// Create an octahedron with the given color.
pub fn octahedron(color: [f32; 3]) -> Self {
let top = Vec3::Y;
let bottom = -Vec3::Y;
let front = Vec3::Z;
let back = -Vec3::Z;
let left = -Vec3::X;
let right = Vec3::X;
let faces: &[[Vec3; 3]] = &[
// Upper 4
[top, front, right],
[top, right, back],
[top, back, left],
[top, left, front],
// Lower 4
[bottom, right, front],
[bottom, back, right],
[bottom, left, back],
[bottom, front, left],
];
let vertices = faces
.iter()
.flat_map(|[a, b, c]| {
let normal = (*b - *a).cross(*c - *a).normalize();
[
Vertex { position: (*a).into(), normal: normal.into(), color },
Vertex { position: (*b).into(), normal: normal.into(), color },
Vertex { position: (*c).into(), normal: normal.into(), color },
]
})
.collect();
Self { vertices }
}
/// Create an icosphere by subdividing an icosahedron, then projecting
/// vertices onto the unit sphere. `subdivisions` of 2-3 looks nice.
pub fn icosphere(subdivisions: u32, color: [f32; 3]) -> Self {
// Golden ratio
let phi = (1.0 + 5.0_f32.sqrt()) / 2.0;
// 12 vertices of an icosahedron (normalized to unit sphere)
let raw_verts: Vec<Vec3> = vec![
Vec3::new(-1.0, phi, 0.0),
Vec3::new(1.0, phi, 0.0),
Vec3::new(-1.0, -phi, 0.0),
Vec3::new(1.0, -phi, 0.0),
Vec3::new(0.0, -1.0, phi),
Vec3::new(0.0, 1.0, phi),
Vec3::new(0.0, -1.0, -phi),
Vec3::new(0.0, 1.0, -phi),
Vec3::new(phi, 0.0, -1.0),
Vec3::new(phi, 0.0, 1.0),
Vec3::new(-phi, 0.0, -1.0),
Vec3::new(-phi, 0.0, 1.0),
]
.into_iter()
.map(|v| v.normalize())
.collect();
// 20 faces of the icosahedron
let mut triangles: Vec<[Vec3; 3]> = vec![
[raw_verts[0], raw_verts[11], raw_verts[5]],
[raw_verts[0], raw_verts[5], raw_verts[1]],
[raw_verts[0], raw_verts[1], raw_verts[7]],
[raw_verts[0], raw_verts[7], raw_verts[10]],
[raw_verts[0], raw_verts[10], raw_verts[11]],
[raw_verts[1], raw_verts[5], raw_verts[9]],
[raw_verts[5], raw_verts[11], raw_verts[4]],
[raw_verts[11], raw_verts[10], raw_verts[2]],
[raw_verts[10], raw_verts[7], raw_verts[6]],
[raw_verts[7], raw_verts[1], raw_verts[8]],
[raw_verts[3], raw_verts[9], raw_verts[4]],
[raw_verts[3], raw_verts[4], raw_verts[2]],
[raw_verts[3], raw_verts[2], raw_verts[6]],
[raw_verts[3], raw_verts[6], raw_verts[8]],
[raw_verts[3], raw_verts[8], raw_verts[9]],
[raw_verts[4], raw_verts[9], raw_verts[5]],
[raw_verts[2], raw_verts[4], raw_verts[11]],
[raw_verts[6], raw_verts[2], raw_verts[10]],
[raw_verts[8], raw_verts[6], raw_verts[7]],
[raw_verts[9], raw_verts[8], raw_verts[1]],
];
// Subdivide
for _ in 0..subdivisions {
let mut new_tris = Vec::with_capacity(triangles.len() * 4);
for [a, b, c] in &triangles {
let ab = ((*a + *b) / 2.0).normalize();
let bc = ((*b + *c) / 2.0).normalize();
let ca = ((*c + *a) / 2.0).normalize();
new_tris.push([*a, ab, ca]);
new_tris.push([*b, bc, ab]);
new_tris.push([*c, ca, bc]);
new_tris.push([ab, bc, ca]);
}
triangles = new_tris;
}
// Flat-shade: each face gets its own normal
let vertices = triangles
.iter()
.flat_map(|[a, b, c]| {
let normal = (*b - *a).cross(*c - *a).normalize();
[
Vertex { position: (*a).into(), normal: normal.into(), color },
Vertex { position: (*b).into(), normal: normal.into(), color },
Vertex { position: (*c).into(), normal: normal.into(), color },
]
})
.collect();
Self { vertices }
}
}
+353
View File
@@ -0,0 +1,353 @@
use std::sync::Arc;
use wgpu::util::DeviceExt;
use crate::camera::{Camera, CameraUniform};
use crate::mesh::{Mesh, Vertex};
use crate::scene::{ModelUniform, SceneObject};
/// GPU-side mesh: vertex buffer + vertex count
pub struct GpuMesh {
pub vertex_buffer: wgpu::Buffer,
pub vertex_count: u32,
}
pub struct Renderer {
pub surface: wgpu::Surface<'static>,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
pub config: wgpu::SurfaceConfiguration,
pub pipeline: wgpu::RenderPipeline,
pub depth_view: wgpu::TextureView,
pub camera_buffer: wgpu::Buffer,
pub camera_bind_group: wgpu::BindGroup,
pub model_buffer: wgpu::Buffer,
pub model_bind_group: wgpu::BindGroup,
pub gpu_meshes: Vec<GpuMesh>,
}
impl Renderer {
pub async fn new(
window: Arc<winit::window::Window>,
meshes: &[Mesh],
) -> Self {
let size = window.inner_size();
// --- wgpu instance + surface ---
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
#[cfg(target_arch = "wasm32")]
backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
#[cfg(not(target_arch = "wasm32"))]
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
let surface = instance.create_surface(window).unwrap();
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.expect("Failed to find a suitable GPU adapter");
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("device"),
required_features: wgpu::Features::empty(),
required_limits: if cfg!(target_arch = "wasm32") {
wgpu::Limits::downlevel_webgl2_defaults()
} else {
wgpu::Limits::default()
},
memory_hints: Default::default(),
},
None,
)
.await
.unwrap();
// --- Surface config ---
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.find(|f| f.is_srgb())
.copied()
.unwrap_or(surface_caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width: size.width.max(1),
height: size.height.max(1),
present_mode: wgpu::PresentMode::AutoVsync,
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &config);
// --- Depth texture ---
let depth_view = create_depth_texture(&device, &config);
// --- Shader ---
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
});
// --- Camera uniform ---
let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("camera_buf"),
contents: bytemuck::bytes_of(&CameraUniform {
view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(),
eye_pos: [0.0; 3],
_padding: 0.0,
}),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let camera_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("camera_bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("camera_bg"),
layout: &camera_bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: camera_buffer.as_entire_binding(),
}],
});
// --- Model uniform (reused per draw) ---
let model_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("model_buf"),
contents: bytemuck::bytes_of(&ModelUniform {
transform: glam::Mat4::IDENTITY.to_cols_array_2d(),
}),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let model_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("model_bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let model_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("model_bg"),
layout: &model_bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: model_buffer.as_entire_binding(),
}],
});
// --- Pipeline ---
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("pipeline_layout"),
bind_group_layouts: &[&camera_bind_group_layout, &model_bind_group_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("render_pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[Vertex::layout()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: surface_format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
// --- Upload meshes ---
let gpu_meshes = meshes
.iter()
.map(|m| {
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh_vb"),
contents: bytemuck::cast_slice(&m.vertices),
usage: wgpu::BufferUsages::VERTEX,
});
GpuMesh {
vertex_buffer,
vertex_count: m.vertices.len() as u32,
}
})
.collect();
Self {
surface,
device,
queue,
config,
pipeline,
depth_view,
camera_buffer,
camera_bind_group,
model_buffer,
model_bind_group,
gpu_meshes,
}
}
pub fn resize(&mut self, width: u32, height: u32) {
if width > 0 && height > 0 {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
self.depth_view = create_depth_texture(&self.device, &self.config);
}
}
pub fn render(
&self,
camera: &Camera,
objects: &[SceneObject],
time: f32,
) -> Result<(), wgpu::SurfaceError> {
// Update camera uniform
let cam_uniform = CameraUniform::from_camera(camera);
self.queue
.write_buffer(&self.camera_buffer, 0, bytemuck::bytes_of(&cam_uniform));
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"),
});
{
let mut 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.02,
g: 0.02,
b: 0.05,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.camera_bind_group, &[]);
for obj in objects {
let transform = obj.transform(time);
let model_uniform = ModelUniform {
transform: transform.to_cols_array_2d(),
};
// Write per-object transform
self.queue.write_buffer(
&self.model_buffer,
0,
bytemuck::bytes_of(&model_uniform),
);
pass.set_bind_group(1, &self.model_bind_group, &[]);
let gpu_mesh = &self.gpu_meshes[obj.mesh_index];
pass.set_vertex_buffer(0, gpu_mesh.vertex_buffer.slice(..));
pass.draw(0..gpu_mesh.vertex_count, 0..1);
}
}
self.queue.submit(std::iter::once(encoder.finish()));
output.present();
Ok(())
}
}
fn create_depth_texture(
device: &wgpu::Device,
config: &wgpu::SurfaceConfiguration,
) -> wgpu::TextureView {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("depth_texture"),
size: wgpu::Extent3d {
width: config.width.max(1),
height: config.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
texture.create_view(&wgpu::TextureViewDescriptor::default())
}
+95
View File
@@ -0,0 +1,95 @@
use glam::{Mat4, Quat, Vec3};
use rand::Rng;
use crate::mesh::Mesh;
/// A placed object in the scene
pub struct SceneObject {
pub mesh_index: usize,
pub position: Vec3,
pub rotation_axis: Vec3,
pub rotation_speed: f32,
pub scale: f32,
pub base_rotation: f32,
}
impl SceneObject {
pub fn transform(&self, time: f32) -> Mat4 {
let angle = self.base_rotation + time * self.rotation_speed;
let rotation = Quat::from_axis_angle(self.rotation_axis, angle);
Mat4::from_translation(self.position)
* Mat4::from_quat(rotation)
* Mat4::from_scale(Vec3::splat(self.scale))
}
}
/// Model uniform for per-object transform
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct ModelUniform {
pub transform: [[f32; 4]; 4],
}
/// Color palette — saturated, cheerful colors
const COLORS: &[[f32; 3]] = &[
[1.0, 0.3, 0.35], // coral red
[0.3, 0.85, 0.5], // mint green
[0.3, 0.5, 1.0], // cornflower blue
[1.0, 0.75, 0.2], // golden yellow
[0.85, 0.35, 0.9], // orchid purple
[0.2, 0.9, 0.9], // cyan
[1.0, 0.55, 0.2], // tangerine
[0.5, 1.0, 0.3], // lime
];
/// Build all the meshes and objects for the scene.
/// Returns (meshes, objects) where each object references a mesh by index.
pub fn build_scene() -> (Vec<Mesh>, Vec<SceneObject>) {
let mut rng = rand::thread_rng();
let mut meshes = Vec::new();
let mut objects = Vec::new();
// Create a few mesh variants
// Icospheres at different subdivision levels
for &color in &COLORS[..4] {
meshes.push(Mesh::icosphere(2, color));
}
// Octahedra
for &color in &COLORS[4..] {
meshes.push(Mesh::octahedron(color));
}
let num_objects = 30;
for i in 0..num_objects {
let mesh_index = i % meshes.len();
let spread = 40.0;
let position = Vec3::new(
rng.gen_range(-spread..spread),
rng.gen_range(-spread * 0.5..spread * 0.5),
rng.gen_range(-spread..spread),
);
let rotation_axis = Vec3::new(
rng.gen_range(-1.0..1.0),
rng.gen_range(-1.0..1.0),
rng.gen_range(-1.0..1.0),
)
.normalize_or_else(Vec3::Y);
let rotation_speed = rng.gen_range(0.2..1.5) * if rng.gen_bool(0.5) { 1.0 } else { -1.0 };
let scale = rng.gen_range(0.5..2.5);
let base_rotation = rng.gen_range(0.0..std::f32::consts::TAU);
objects.push(SceneObject {
mesh_index,
position,
rotation_axis,
rotation_speed,
scale,
base_rotation,
});
}
(meshes, objects)
}
+58
View File
@@ -0,0 +1,58 @@
// Vertex input from the mesh buffers
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) color: vec3<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_normal: vec3<f32>,
@location(1) color: vec3<f32>,
@location(2) world_position: vec3<f32>,
};
struct Camera {
view_proj: mat4x4<f32>,
eye_pos: vec3<f32>,
_padding: f32,
};
struct Model {
transform: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> camera: Camera;
@group(1) @binding(0) var<uniform> model: Model;
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.transform * vec4<f32>(in.position, 1.0);
out.clip_position = camera.view_proj * world_pos;
// Transform normal (assuming uniform scale, so we can skip inverse-transpose)
out.world_normal = normalize((model.transform * vec4<f32>(in.normal, 0.0)).xyz);
out.color = in.color;
out.world_position = world_pos.xyz;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let normal = normalize(in.world_normal);
// Simple directional light + ambient for a flat-shaded look
let light_dir = normalize(vec3<f32>(0.4, 1.0, 0.6));
let ambient = 0.15;
let diffuse = max(dot(normal, light_dir), 0.0);
// Slight specular highlight
let view_dir = normalize(camera.eye_pos - in.world_position);
let half_dir = normalize(light_dir + view_dir);
let specular = pow(max(dot(normal, half_dir), 0.0), 32.0) * 0.3;
let brightness = ambient + diffuse * 0.75 + specular;
let final_color = in.color * brightness;
return vec4<f32>(final_color, 1.0);
}