From fdfe4aaf5f526cec6392be2af95449e0033cf5a1 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Mar 2026 20:55:24 +0900 Subject: [PATCH] feat: add multi-light demo with orbiting point lights and spot light Fix pbr_demo to use LightsUniform/LightData instead of old LightUniform. Create multi_light_demo with 5 PBR spheres (varying metallic), a ground plane, 4 colored orbiting point lights, a directional fill light, and a spot light from above. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 1 + examples/multi_light_demo/Cargo.toml | 15 + examples/multi_light_demo/src/main.rs | 562 ++++++++++++++++++++++++++ examples/pbr_demo/src/main.rs | 24 +- 4 files changed, 586 insertions(+), 16 deletions(-) create mode 100644 examples/multi_light_demo/Cargo.toml create mode 100644 examples/multi_light_demo/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 77afb24..ecfa46b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "examples/hierarchy_demo", "examples/asset_demo", "examples/pbr_demo", + "examples/multi_light_demo", ] [workspace.dependencies] diff --git a/examples/multi_light_demo/Cargo.toml b/examples/multi_light_demo/Cargo.toml new file mode 100644 index 0000000..8a57040 --- /dev/null +++ b/examples/multi_light_demo/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "multi_light_demo" +version = "0.1.0" +edition = "2021" + +[dependencies] +voltex_math.workspace = true +voltex_platform.workspace = true +voltex_renderer.workspace = true +wgpu.workspace = true +winit.workspace = true +bytemuck.workspace = true +pollster.workspace = true +env_logger.workspace = true +log.workspace = true diff --git a/examples/multi_light_demo/src/main.rs b/examples/multi_light_demo/src/main.rs new file mode 100644 index 0000000..7d2e180 --- /dev/null +++ b/examples/multi_light_demo/src/main.rs @@ -0,0 +1,562 @@ +use winit::{ + application::ApplicationHandler, + event::WindowEvent, + event_loop::{ActiveEventLoop, EventLoop}, + keyboard::{KeyCode, PhysicalKey}, + window::WindowId, +}; +use voltex_math::{Vec3, Mat4}; +use voltex_platform::{VoltexWindow, WindowConfig, InputState, GameTimer}; +use voltex_renderer::{ + GpuContext, Camera, FpsController, CameraUniform, LightsUniform, LightData, + Mesh, GpuTexture, MaterialUniform, generate_sphere, create_pbr_pipeline, obj, +}; +use wgpu::util::DeviceExt; + +const NUM_OBJECTS: usize = 6; // 5 spheres + 1 ground plane + +struct MultiLightApp { + state: Option, +} + +struct AppState { + window: VoltexWindow, + gpu: GpuContext, + pipeline: wgpu::RenderPipeline, + sphere_mesh: Mesh, + ground_mesh: Mesh, + camera: Camera, + fps_controller: FpsController, + camera_buffer: wgpu::Buffer, + light_buffer: wgpu::Buffer, + material_buffer: wgpu::Buffer, + camera_light_bind_group: wgpu::BindGroup, + _texture: GpuTexture, + material_bind_group: wgpu::BindGroup, + input: InputState, + timer: GameTimer, + cam_aligned_size: u32, + mat_aligned_size: u32, + time: f32, +} + +fn camera_light_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Camera+Light Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: wgpu::BufferSize::new( + std::mem::size_of::() as u64, + ), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }) +} + +fn align_up(size: u32, alignment: u32) -> u32 { + ((size + alignment - 1) / alignment) * alignment +} + +impl ApplicationHandler for MultiLightApp { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + let config = WindowConfig { + title: "Voltex - Multi-Light Demo".to_string(), + width: 1280, + height: 720, + ..Default::default() + }; + let window = VoltexWindow::new(event_loop, &config); + let gpu = GpuContext::new(window.handle.clone()); + + // Dynamic uniform buffer alignment + let alignment = gpu.device.limits().min_uniform_buffer_offset_alignment; + let cam_aligned_size = align_up(std::mem::size_of::() as u32, alignment); + let mat_aligned_size = align_up(std::mem::size_of::() as u32, alignment); + + // Generate sphere mesh (shared by all 5 spheres) + let (vertices, indices) = generate_sphere(0.5, 32, 16); + let sphere_mesh = Mesh::new(&gpu.device, &vertices, &indices); + + // Ground plane: cube.obj scaled to (10, 0.1, 10) + let obj_src = include_str!("../../../assets/cube.obj"); + let obj_data = obj::parse_obj(obj_src); + let ground_mesh = Mesh::new(&gpu.device, &obj_data.vertices, &obj_data.indices); + + // Camera at (0, 5, 12), looking down slightly + let aspect = gpu.config.width as f32 / gpu.config.height as f32; + let mut camera = Camera::new(Vec3::new(0.0, 5.0, 12.0), aspect); + camera.pitch = -0.3; + let fps_controller = FpsController::new(); + + // Initial lights uniform + let mut lights_uniform = LightsUniform::new(); + lights_uniform.add_light(LightData::directional([0.0, -1.0, -0.5], [1.0, 1.0, 1.0], 0.3)); + // Point lights at initial positions (will be updated per frame) + lights_uniform.add_light(LightData::point([5.0, 2.0, 0.0], [1.0, 0.0, 0.0], 15.0, 15.0)); + lights_uniform.add_light(LightData::point([0.0, 2.0, 5.0], [0.0, 1.0, 0.0], 15.0, 15.0)); + lights_uniform.add_light(LightData::point([-5.0, 2.0, 0.0], [0.0, 0.0, 1.0], 15.0, 15.0)); + lights_uniform.add_light(LightData::point([0.0, 2.0, -5.0], [1.0, 1.0, 0.0], 15.0, 15.0)); + lights_uniform.add_light(LightData::spot( + [0.0, 5.0, 0.0], [0.0, -1.0, 0.0], [1.0, 1.0, 1.0], + 20.0, 10.0, 20.0, 35.0, + )); + + // Camera dynamic uniform buffer (one CameraUniform per object) + let camera_buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Camera Dynamic Uniform Buffer"), + size: (cam_aligned_size as usize * NUM_OBJECTS) as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let light_buffer = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Light Uniform Buffer"), + contents: bytemuck::cast_slice(&[lights_uniform]), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + // Material dynamic uniform buffer (one MaterialUniform per object) + let material_buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Material Dynamic Uniform Buffer"), + size: (mat_aligned_size as usize * NUM_OBJECTS) as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + // Bind group layouts + let cl_layout = camera_light_bind_group_layout(&gpu.device); + let tex_layout = GpuTexture::bind_group_layout(&gpu.device); + let mat_layout = MaterialUniform::bind_group_layout(&gpu.device); + + // Camera+Light bind group + let camera_light_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Camera+Light Bind Group"), + layout: &cl_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &camera_buffer, + offset: 0, + size: wgpu::BufferSize::new( + std::mem::size_of::() as u64, + ), + }), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: light_buffer.as_entire_binding(), + }, + ], + }); + + // Texture bind group (white 1x1) + let texture = GpuTexture::white_1x1(&gpu.device, &gpu.queue, &tex_layout); + + // Material bind group + let material_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Material Bind Group"), + layout: &mat_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &material_buffer, + offset: 0, + size: wgpu::BufferSize::new( + std::mem::size_of::() as u64, + ), + }), + }], + }); + + // PBR pipeline + let pipeline = create_pbr_pipeline( + &gpu.device, + gpu.surface_format, + &cl_layout, + &tex_layout, + &mat_layout, + ); + + self.state = Some(AppState { + window, + gpu, + pipeline, + sphere_mesh, + ground_mesh, + camera, + fps_controller, + camera_buffer, + light_buffer, + material_buffer, + camera_light_bind_group, + _texture: texture, + material_bind_group, + input: InputState::new(), + timer: GameTimer::new(60), + cam_aligned_size, + mat_aligned_size, + time: 0.0, + }); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _window_id: WindowId, + event: WindowEvent, + ) { + let state = match &mut self.state { + Some(s) => s, + None => return, + }; + + match event { + WindowEvent::CloseRequested => event_loop.exit(), + + WindowEvent::KeyboardInput { + event: + winit::event::KeyEvent { + physical_key: PhysicalKey::Code(key_code), + state: key_state, + .. + }, + .. + } => { + let pressed = key_state == winit::event::ElementState::Pressed; + state.input.process_key(key_code, pressed); + if key_code == KeyCode::Escape && pressed { + event_loop.exit(); + } + } + + WindowEvent::Resized(size) => { + state.gpu.resize(size.width, size.height); + if size.width > 0 && size.height > 0 { + state.camera.aspect = size.width as f32 / size.height as f32; + } + } + + WindowEvent::CursorMoved { position, .. } => { + state.input.process_mouse_move(position.x, position.y); + } + + WindowEvent::MouseInput { + state: btn_state, + button, + .. + } => { + let pressed = btn_state == winit::event::ElementState::Pressed; + state.input.process_mouse_button(button, pressed); + } + + WindowEvent::MouseWheel { delta, .. } => { + let y = match delta { + winit::event::MouseScrollDelta::LineDelta(_, y) => y, + winit::event::MouseScrollDelta::PixelDelta(pos) => pos.y as f32, + }; + state.input.process_scroll(y); + } + + WindowEvent::RedrawRequested => { + state.timer.tick(); + let dt = state.timer.frame_dt(); + + // Camera input + if state + .input + .is_mouse_button_pressed(winit::event::MouseButton::Right) + { + let (dx, dy) = state.input.mouse_delta(); + state + .fps_controller + .process_mouse(&mut state.camera, dx, dy); + } + let mut forward = 0.0f32; + let mut right = 0.0f32; + let mut up = 0.0f32; + if state.input.is_key_pressed(KeyCode::KeyW) { + forward += 1.0; + } + if state.input.is_key_pressed(KeyCode::KeyS) { + forward -= 1.0; + } + if state.input.is_key_pressed(KeyCode::KeyD) { + right += 1.0; + } + if state.input.is_key_pressed(KeyCode::KeyA) { + right -= 1.0; + } + if state.input.is_key_pressed(KeyCode::Space) { + up += 1.0; + } + if state.input.is_key_pressed(KeyCode::ShiftLeft) { + up -= 1.0; + } + state + .fps_controller + .process_movement(&mut state.camera, forward, right, up, dt); + state.input.begin_frame(); + + state.time += dt; + + // Compute view-projection + let view_proj = state.camera.view_projection(); + let cam_pos = [ + state.camera.position.x, + state.camera.position.y, + state.camera.position.z, + ]; + + let cam_aligned = state.cam_aligned_size as usize; + let mat_aligned = state.mat_aligned_size as usize; + + // Build staging data for camera and material uniforms + let cam_total = NUM_OBJECTS * cam_aligned; + let mat_total = NUM_OBJECTS * mat_aligned; + let mut cam_staging = vec![0u8; cam_total]; + let mut mat_staging = vec![0u8; mat_total]; + + // Object layout: indices 0..4 = spheres, index 5 = ground plane + // Spheres at y=0, x = [-4, -2, 0, 2, 4], metallic varies 0.0..1.0 + for i in 0..5usize { + let x = -4.0 + i as f32 * 2.0; + let model = Mat4::translation(x, 0.0, 0.0); + let cam_uniform = CameraUniform { + view_proj: view_proj.cols, + model: model.cols, + camera_pos: cam_pos, + _padding: 0.0, + }; + let bytes = bytemuck::bytes_of(&cam_uniform); + let offset = i * cam_aligned; + cam_staging[offset..offset + bytes.len()].copy_from_slice(bytes); + + let metallic = i as f32 / 4.0; + let mat_uniform = MaterialUniform::with_params( + [0.8, 0.2, 0.2, 1.0], + metallic, + 0.3, + ); + let bytes = bytemuck::bytes_of(&mat_uniform); + let offset = i * mat_aligned; + mat_staging[offset..offset + bytes.len()].copy_from_slice(bytes); + } + + // Ground plane at y=-0.5, scale (10, 0.1, 10) + { + let i = 5; + let model = Mat4::translation(0.0, -0.5, 0.0) + .mul_mat4(&Mat4::scale(10.0, 0.1, 10.0)); + let cam_uniform = CameraUniform { + view_proj: view_proj.cols, + model: model.cols, + camera_pos: cam_pos, + _padding: 0.0, + }; + let bytes = bytemuck::bytes_of(&cam_uniform); + let offset = i * cam_aligned; + cam_staging[offset..offset + bytes.len()].copy_from_slice(bytes); + + let mat_uniform = MaterialUniform::with_params( + [0.5, 0.5, 0.5, 1.0], + 0.0, + 0.8, + ); + let bytes = bytemuck::bytes_of(&mat_uniform); + let offset = i * mat_aligned; + mat_staging[offset..offset + bytes.len()].copy_from_slice(bytes); + } + + state + .gpu + .queue + .write_buffer(&state.camera_buffer, 0, &cam_staging); + state + .gpu + .queue + .write_buffer(&state.material_buffer, 0, &mat_staging); + + // Update lights with orbiting point lights + let radius = 5.0f32; + let time = state.time; + let mut lights_uniform = LightsUniform::new(); + + // Directional fill light + lights_uniform.add_light(LightData::directional( + [0.0, -1.0, -0.5], [1.0, 1.0, 1.0], 0.3, + )); + + // 4 orbiting point lights + let offsets = [0.0f32, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, 3.0 * std::f32::consts::FRAC_PI_2]; + let colors = [ + [1.0, 0.0, 0.0], // Red + [0.0, 1.0, 0.0], // Green + [0.0, 0.0, 1.0], // Blue + [1.0, 1.0, 0.0], // Yellow + ]; + for j in 0..4 { + let angle = time + offsets[j]; + let px = radius * angle.cos(); + let pz = radius * angle.sin(); + lights_uniform.add_light(LightData::point( + [px, 2.0, pz], colors[j], 15.0, 15.0, + )); + } + + // Spot light from above + lights_uniform.add_light(LightData::spot( + [0.0, 5.0, 0.0], [0.0, -1.0, 0.0], [1.0, 1.0, 1.0], + 20.0, 10.0, 20.0, 35.0, + )); + + state.gpu.queue.write_buffer( + &state.light_buffer, + 0, + bytemuck::cast_slice(&[lights_uniform]), + ); + + // Render + let output = match state.gpu.surface.get_current_texture() { + Ok(t) => t, + Err(wgpu::SurfaceError::Lost) => { + let (w, h) = state.window.inner_size(); + state.gpu.resize(w, h); + return; + } + Err(wgpu::SurfaceError::OutOfMemory) => { + event_loop.exit(); + return; + } + Err(_) => return, + }; + + let view = + output + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = state.gpu.device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { + label: Some("Render Encoder"), + }, + ); + + { + let mut render_pass = + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Multi-Light Render Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.05, + g: 0.05, + b: 0.08, + a: 1.0, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some( + wgpu::RenderPassDepthStencilAttachment { + view: &state.gpu.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }, + ), + occlusion_query_set: None, + timestamp_writes: None, + multiview_mask: None, + }); + + render_pass.set_pipeline(&state.pipeline); + render_pass.set_bind_group(1, &state._texture.bind_group, &[]); + + // Draw 5 spheres (objects 0..4) + render_pass.set_vertex_buffer(0, state.sphere_mesh.vertex_buffer.slice(..)); + render_pass.set_index_buffer( + state.sphere_mesh.index_buffer.slice(..), + wgpu::IndexFormat::Uint32, + ); + for i in 0..5u32 { + let cam_offset = i * state.cam_aligned_size; + let mat_offset = i * state.mat_aligned_size; + render_pass.set_bind_group( + 0, + &state.camera_light_bind_group, + &[cam_offset], + ); + render_pass.set_bind_group( + 2, + &state.material_bind_group, + &[mat_offset], + ); + render_pass.draw_indexed(0..state.sphere_mesh.num_indices, 0, 0..1); + } + + // Draw ground plane (object 5) + render_pass.set_vertex_buffer(0, state.ground_mesh.vertex_buffer.slice(..)); + render_pass.set_index_buffer( + state.ground_mesh.index_buffer.slice(..), + wgpu::IndexFormat::Uint32, + ); + { + let cam_offset = 5u32 * state.cam_aligned_size; + let mat_offset = 5u32 * state.mat_aligned_size; + render_pass.set_bind_group( + 0, + &state.camera_light_bind_group, + &[cam_offset], + ); + render_pass.set_bind_group( + 2, + &state.material_bind_group, + &[mat_offset], + ); + render_pass.draw_indexed(0..state.ground_mesh.num_indices, 0, 0..1); + } + } + + state.gpu.queue.submit(std::iter::once(encoder.finish())); + output.present(); + } + + _ => {} + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + if let Some(state) = &self.state { + state.window.request_redraw(); + } + } +} + +fn main() { + env_logger::init(); + let event_loop = EventLoop::new().unwrap(); + let mut app = MultiLightApp { state: None }; + event_loop.run_app(&mut app).unwrap(); +} diff --git a/examples/pbr_demo/src/main.rs b/examples/pbr_demo/src/main.rs index 1bc5fa1..17699d9 100644 --- a/examples/pbr_demo/src/main.rs +++ b/examples/pbr_demo/src/main.rs @@ -8,7 +8,7 @@ use winit::{ use voltex_math::{Vec3, Mat4}; use voltex_platform::{VoltexWindow, WindowConfig, InputState, GameTimer}; use voltex_renderer::{ - GpuContext, Camera, FpsController, CameraUniform, LightUniform, + GpuContext, Camera, FpsController, CameraUniform, LightsUniform, LightData, Mesh, GpuTexture, MaterialUniform, generate_sphere, create_pbr_pipeline, }; use wgpu::util::DeviceExt; @@ -99,13 +99,9 @@ impl ApplicationHandler for PbrDemoApp { let camera = Camera::new(Vec3::new(0.0, 0.0, 12.0), aspect); let fps_controller = FpsController::new(); - // Light: direction [-1, -1, -1], color white, ambient 0.1 - let light_uniform = LightUniform { - direction: [-1.0, -1.0, -1.0], - _padding1: 0.0, - color: [1.0, 1.0, 1.0], - ambient_strength: 0.1, - }; + // Light: direction [-1, -1, -1], color white, intensity 1.0 + let mut lights_uniform = LightsUniform::new(); + lights_uniform.add_light(LightData::directional([-1.0, -1.0, -1.0], [1.0, 1.0, 1.0], 1.0)); // Camera dynamic uniform buffer (one CameraUniform per sphere) let camera_buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor { @@ -117,7 +113,7 @@ impl ApplicationHandler for PbrDemoApp { let light_buffer = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Light Uniform Buffer"), - contents: bytemuck::cast_slice(&[light_uniform]), + contents: bytemuck::cast_slice(&[lights_uniform]), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); @@ -365,16 +361,12 @@ impl ApplicationHandler for PbrDemoApp { .write_buffer(&state.material_buffer, 0, &mat_staging); // Write light uniform - let light_uniform = LightUniform { - direction: [-1.0, -1.0, -1.0], - _padding1: 0.0, - color: [1.0, 1.0, 1.0], - ambient_strength: 0.1, - }; + let mut lights_uniform = LightsUniform::new(); + lights_uniform.add_light(LightData::directional([-1.0, -1.0, -1.0], [1.0, 1.0, 1.0], 1.0)); state.gpu.queue.write_buffer( &state.light_buffer, 0, - bytemuck::cast_slice(&[light_uniform]), + bytemuck::cast_slice(&[lights_uniform]), ); // Render