//! [`ForwardRenderer`]: a single-pass forward renderer with a depth buffer and //! one directional light. //! //! Stage 4 scope: draw a list of [`RenderObject`]s (each a [`GpuMesh`] + //! [`Material`] + [`Transform`]) through the lit shader, into a caller-provided //! color target, using an owned depth texture. Shadows, multiple lights, and //! post-processing arrive in later stages. use std::num::NonZeroU64; use bytemuck::{Pod, Zeroable}; use glam::Mat3; use serde::{Deserialize, Serialize}; use super::mesh::{GpuMesh, Vertex}; use super::{Camera, Material}; use crate::math::{Color, Transform, Vec3, Vec4}; /// Depth buffer format used by the forward pass. pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; /// A directional light: parallel rays with a travel `direction`. /// /// Also a **reflected, addable component**: drop one on an entity to author a /// sun/key light in the scene, dual-editable from the editor and scripts. /// (Gathering light entities into the forward pass is a later-stage wiring; the /// renderer currently takes its [`Lighting`] directly.) #[derive(Debug, Clone, Copy, Serialize, Deserialize, crate::reflect::Reflect)] pub struct DirectionalLight { /// The direction the light travels (does not need to be normalized). pub direction: Vec3, /// Light color. pub color: Color, /// Scalar intensity multiplier. pub intensity: f32, } impl Default for DirectionalLight { fn default() -> Self { Self { direction: Vec3::new(-0.5, -1.0, -0.35), color: Color::WHITE, intensity: 1.0, } } } /// Scene lighting for a forward pass: one directional light plus an ambient term. #[derive(Debug, Clone, Copy)] pub struct Lighting { /// The single directional (sun) light. pub light: DirectionalLight, /// Flat ambient color added everywhere (cheap fill light). pub ambient: Color, } impl Default for Lighting { fn default() -> Self { Self { light: DirectionalLight::default(), ambient: Color::rgb(0.08, 0.08, 0.10), } } } /// One drawable: a GPU mesh placed by `transform` and shaded with `material`. pub struct RenderObject<'a> { /// The mesh to draw. pub mesh: &'a GpuMesh, /// Its surface material. pub material: Material, /// World placement. pub transform: Transform, } #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct GlobalsUniform { view_proj: [[f32; 4]; 4], camera_pos: [f32; 4], light_dir: [f32; 4], light_color: [f32; 4], ambient: [f32; 4], } #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct ObjectUniform { model: [[f32; 4]; 4], normal_mtx: [[f32; 4]; 4], albedo: [f32; 4], mr: [f32; 4], } /// A forward renderer owning its pipeline, depth buffer, and uniform storage. pub struct ForwardRenderer { pipeline: wgpu::RenderPipeline, globals_buffer: wgpu::Buffer, globals_bind_group: wgpu::BindGroup, object_layout: wgpu::BindGroupLayout, object_buffer: wgpu::Buffer, object_bind_group: wgpu::BindGroup, /// Per-object stride: `size_of::` rounded up to the device's /// minimum dynamic-uniform-buffer offset alignment. object_stride: u64, object_capacity: u32, depth: Option, color_format: wgpu::TextureFormat, } struct DepthTarget { view: wgpu::TextureView, width: u32, height: u32, } impl ForwardRenderer { /// Builds the renderer for a given color target format (e.g. the surface /// format for a window, or `Rgba8Unorm` for offscreen rendering). pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("oxide.forward.lit"), source: wgpu::ShaderSource::Wgsl(include_str!("shaders/lit.wgsl").into()), }); let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("oxide.forward.globals_layout"), entries: &[wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: NonZeroU64::new(std::mem::size_of::() as u64), }, count: None, }], }); let object_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("oxide.forward.object_layout"), entries: &[wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: true, min_binding_size: NonZeroU64::new(std::mem::size_of::() as u64), }, count: None, }], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("oxide.forward.pipeline_layout"), bind_group_layouts: &[Some(&globals_layout), Some(&object_layout)], immediate_size: 0, }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("oxide.forward.pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_main"), compilation_options: Default::default(), buffers: &[Vertex::LAYOUT], }, primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, cull_mode: Some(wgpu::Face::Back), unclipped_depth: false, polygon_mode: wgpu::PolygonMode::Fill, conservative: false, }, depth_stencil: Some(wgpu::DepthStencilState { format: DEPTH_FORMAT, depth_write_enabled: Some(true), depth_compare: Some(wgpu::CompareFunction::Less), stencil: wgpu::StencilState::default(), bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState::default(), fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs_main"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: color_format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], }), multiview_mask: None, cache: None, }); let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("oxide.forward.globals"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("oxide.forward.globals_bg"), layout: &globals_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: globals_buffer.as_entire_binding(), }], }); let object_stride = align_up( std::mem::size_of::() as u64, device.limits().min_uniform_buffer_offset_alignment as u64, ); let object_capacity = 16; let (object_buffer, object_bind_group) = create_object_storage(device, &object_layout, object_stride, object_capacity); Self { pipeline, globals_buffer, globals_bind_group, object_layout, object_buffer, object_bind_group, object_stride, object_capacity, depth: None, color_format, } } /// The color target format this renderer was built for. pub fn color_format(&self) -> wgpu::TextureFormat { self.color_format } /// Renders `objects` into `target` (whose full physical size is /// `width`×`height`) as seen by `camera` placed at `view_transform`, lit /// by `lighting`. Drawing is restricted to `viewport_rect` (a sub- /// rectangle of the target), and the projection uses that rect's aspect /// ratio. /// /// The color target is *loaded* (not cleared) so a clear pass run before /// this — e.g. the window's clear color — shows through as the background; /// the depth buffer is cleared to 1.0 each call. #[allow(clippy::too_many_arguments)] pub fn render( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, target: &wgpu::TextureView, (width, height): (u32, u32), viewport_rect: crate::math::Rect, camera: &Camera, view_transform: &Transform, lighting: &Lighting, objects: &[RenderObject<'_>], ) { let (width, height) = (width.max(1), height.max(1)); // Clamp the viewport rect to the target so wgpu doesn't complain. let vp_w = viewport_rect.width().max(1.0).min(width as f32); let vp_h = viewport_rect.height().max(1.0).min(height as f32); let vp_x = viewport_rect.min.x.max(0.0).min(width as f32 - vp_w); let vp_y = viewport_rect.min.y.max(0.0).min(height as f32 - vp_h); // Depth must match the full color target's dimensions (the // attachment binding requires that). Pixels outside `set_viewport` // are never written, so the extra depth is wasted memory but never // incorrect. self.ensure_depth(device, width, height); self.ensure_object_capacity(device, objects.len() as u32); // Globals — aspect comes from the viewport rect, not the target. let aspect = vp_w / vp_h; let view_proj = camera.view_projection(aspect, view_transform); let to_light = (-lighting.light.direction).normalize_or_zero(); let lc = lighting.light.color; let amb = lighting.ambient; let globals = GlobalsUniform { view_proj: view_proj.to_cols_array_2d(), camera_pos: view_transform.translation.extend(1.0).to_array(), light_dir: to_light.extend(0.0).to_array(), light_color: (Vec4::new(lc.r, lc.g, lc.b, 1.0) * lighting.light.intensity).to_array(), ambient: Vec4::new(amb.r, amb.g, amb.b, 1.0).to_array(), }; queue.write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals)); // Per-object uniforms. for (i, obj) in objects.iter().enumerate() { let model = obj.transform.to_matrix(); let normal_mtx = Mat3::from_mat4(model).inverse().transpose(); let normal_mtx4 = [ normal_mtx.x_axis.extend(0.0).to_array(), normal_mtx.y_axis.extend(0.0).to_array(), normal_mtx.z_axis.extend(0.0).to_array(), [0.0, 0.0, 0.0, 1.0], ]; let a = obj.material.albedo; let uniform = ObjectUniform { model: model.to_cols_array_2d(), normal_mtx: normal_mtx4, albedo: [a.r, a.g, a.b, a.a], mr: [obj.material.metallic, obj.material.roughness, 0.0, 0.0], }; queue.write_buffer( &self.object_buffer, i as u64 * self.object_stride, bytemuck::bytes_of(&uniform), ); } let depth_view = &self.depth.as_ref().expect("depth ensured above").view; let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("oxide.forward.encoder"), }); { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("oxide.forward.pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: depth_view, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, }), stencil_ops: None, }), timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); // Restrict drawing to the host's viewport sub-rect. Pixels // outside this rectangle keep whatever the prior pass (e.g. // ClearPass or the window clear) wrote there. pass.set_viewport(vp_x, vp_y, vp_w, vp_h, 0.0, 1.0); pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &self.globals_bind_group, &[]); for (i, obj) in objects.iter().enumerate() { let offset = (i as u64 * self.object_stride) as u32; pass.set_bind_group(1, &self.object_bind_group, &[offset]); pass.set_vertex_buffer(0, obj.mesh.vertex_buffer.slice(..)); pass.set_index_buffer(obj.mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); pass.draw_indexed(0..obj.mesh.index_count, 0, 0..1); } } queue.submit([encoder.finish()]); } fn ensure_depth(&mut self, device: &wgpu::Device, width: u32, height: u32) { let stale = match &self.depth { Some(d) => d.width != width || d.height != height, None => true, }; if stale { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("oxide.forward.depth"), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: DEPTH_FORMAT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); self.depth = Some(DepthTarget { view: texture.create_view(&wgpu::TextureViewDescriptor::default()), width, height, }); } } fn ensure_object_capacity(&mut self, device: &wgpu::Device, needed: u32) { if needed > self.object_capacity { let capacity = needed.next_power_of_two(); let (buffer, bind_group) = create_object_storage(device, &self.object_layout, self.object_stride, capacity); self.object_buffer = buffer; self.object_bind_group = bind_group; self.object_capacity = capacity; } } } /// Allocates the per-object uniform buffer (`capacity` slots of `stride` bytes) /// and a dynamic-offset bind group over it. fn create_object_storage( device: &wgpu::Device, layout: &wgpu::BindGroupLayout, stride: u64, capacity: u32, ) -> (wgpu::Buffer, wgpu::BindGroup) { let buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("oxide.forward.objects"), size: stride * capacity as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("oxide.forward.object_bg"), layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &buffer, offset: 0, size: NonZeroU64::new(std::mem::size_of::() as u64), }), }], }); (buffer, bind_group) } /// Rounds `value` up to the next multiple of `align` (a power of two). fn align_up(value: u64, align: u64) -> u64 { let align = align.max(1); value.div_ceil(align) * align }