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:
+34
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
[build]
|
||||
# Output directory
|
||||
dist = "dist"
|
||||
|
||||
[watch]
|
||||
watch = ["src", "index.html"]
|
||||
+24
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user