From 3fbadc83cb59e52d71300ea05c9ab1f338378b43 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 10 May 2026 18:11:19 +0200 Subject: [PATCH 01/31] feat: renderer # Conflicts: # engine/Cargo.toml # engine/pill_core/Cargo.toml # engine/pill_core/src/utils.rs # engine/pill_engine/Cargo.toml # engine/pill_engine/res/shaders/default_lit_fragment.wgsl # engine/pill_engine/res/shaders/default_unlit_fragment.wgsl # engine/pill_engine/src/ecs/systems/rendering_system.rs # engine/pill_engine/src/engine.rs # engine/pill_engine/src/graphics/dummy_renderer.rs # engine/pill_engine/src/graphics/render_queue.rs # engine/pill_engine/src/graphics/renderer.rs # engine/pill_engine/src/lib.rs # engine/pill_engine/src/renderer/drawers/mesh_drawer.rs # engine/pill_engine/src/renderer/resources/engine_parameters.rs # engine/pill_engine/src/renderer/resources/renderer_mesh.rs # engine/pill_engine/src/renderer/resources/renderer_shader.rs # engine/pill_engine/src/renderer/resources/renderer_texture.rs # engine/pill_engine/src/resources/material.rs # engine/pill_engine/src/resources/shader.rs # engine/pill_engine/src/resources/texture.rs # engine/pill_renderer/Cargo.toml # engine/pill_renderer/src/renderer.rs # engine/pill_renderer/src/resources/renderer_material.rs # engine/pill_renderer/src/resources/renderer_resource_storage.rs --- engine/.cargo/config.toml | 18 + engine/Cargo.toml | 1 - engine/pill_core/Cargo.toml | 2 +- engine/pill_core/src/handle.rs | 150 +++ engine/pill_core/src/lib.rs | 9 + engine/pill_core/src/tags.rs | 9 + engine/pill_core/src/timer.rs | 19 + engine/pill_engine/Cargo.toml | 13 +- .../res/shaders/include/common.hlsl | 3 +- .../ecs/components/egui_manager_component.rs | 4 +- .../components/mesh_rendering_component.rs | 61 +- engine/pill_engine/src/ecs/components/mod.rs | 1 + .../ecs/components/render_state_component.rs | 27 + .../src/ecs/components/transform_component.rs | 56 +- engine/pill_engine/src/ecs/egui_client.rs | 33 + engine/pill_engine/src/ecs/mod.rs | 5 + .../src/ecs/systems/rendering_system.rs | 66 +- engine/pill_engine/src/engine.rs | 12 +- .../src/graphics/dummy_renderer.rs | 134 +-- engine/pill_engine/src/graphics/mod.rs | 15 +- engine/pill_engine/src/graphics/pass_egui.rs | 78 ++ .../src/graphics/pass_pbr_static.rs | 938 ++++++++++++++++++ .../pill_engine/src/graphics/render_queue.rs | 90 +- engine/pill_engine/src/graphics/renderer.rs | 174 ++-- engine/pill_engine/src/lib.rs | 53 +- engine/pill_engine/src/renderer/config.rs | 7 + .../src/renderer}/drawers/egui_drawer.rs | 7 - .../src/renderer}/drawers/mesh_drawer.rs | 124 +-- .../src/renderer}/drawers/mod.rs | 0 engine/pill_engine/src/renderer/instance.rs | 49 + engine/pill_engine/src/renderer/mod.rs | 7 + .../renderer}/resources/engine_parameters.rs | 49 +- .../src/renderer}/resources/mod.rs | 18 +- .../renderer}/resources/renderer_camera.rs | 28 +- .../renderer/resources/renderer_material.rs | 213 ++++ .../src/renderer}/resources/renderer_mesh.rs | 35 +- .../renderer}/resources/renderer_shader.rs | 159 ++- .../renderer}/resources/renderer_texture.rs | 91 +- engine/pill_engine/src/renderer/state.rs | 524 ++++++++++ engine/pill_engine/src/resources/material.rs | 367 ++++--- engine/pill_engine/src/resources/mesh.rs | 30 +- engine/pill_engine/src/resources/mod.rs | 3 +- engine/pill_engine/src/resources/shader.rs | 67 +- engine/pill_engine/src/resources/texture.rs | 114 ++- engine/pill_launcher/src/main.rs | 3 +- engine/pill_native/src/main.rs | 8 +- engine/pill_renderer/Cargo.toml | 34 - engine/pill_renderer/src/config.rs | 8 - engine/pill_renderer/src/instance.rs | 96 -- engine/pill_renderer/src/lib.rs | 12 - engine/pill_renderer/src/profiler.rs | 492 --------- engine/pill_renderer/src/renderer.rs | 736 -------------- .../src/resources/renderer_material.rs | 332 ------- .../resources/renderer_resource_storage.rs | 61 -- engine/pill_runtime/Cargo.toml | 1 - engine/pill_runtime/src/lib.rs | 2 +- engine/pill_web/Cargo.toml | 1 - engine/pill_web/src/lib.rs | 2 +- examples/cube/src/game.rs | 7 +- examples/pill_tunel/src/game.rs | 19 +- 60 files changed, 3100 insertions(+), 2577 deletions(-) create mode 100644 engine/.cargo/config.toml create mode 100644 engine/pill_core/src/handle.rs create mode 100644 engine/pill_core/src/tags.rs create mode 100644 engine/pill_engine/src/ecs/components/render_state_component.rs create mode 100644 engine/pill_engine/src/ecs/egui_client.rs create mode 100644 engine/pill_engine/src/graphics/pass_egui.rs create mode 100644 engine/pill_engine/src/graphics/pass_pbr_static.rs create mode 100644 engine/pill_engine/src/renderer/config.rs rename engine/{pill_renderer/src => pill_engine/src/renderer}/drawers/egui_drawer.rs (94%) rename engine/{pill_renderer/src => pill_engine/src/renderer}/drawers/mesh_drawer.rs (70%) rename engine/{pill_renderer/src => pill_engine/src/renderer}/drawers/mod.rs (100%) create mode 100644 engine/pill_engine/src/renderer/instance.rs create mode 100644 engine/pill_engine/src/renderer/mod.rs rename engine/{pill_renderer/src => pill_engine/src/renderer}/resources/engine_parameters.rs (59%) rename engine/{pill_renderer/src => pill_engine/src/renderer}/resources/mod.rs (77%) rename engine/{pill_renderer/src => pill_engine/src/renderer}/resources/renderer_camera.rs (87%) create mode 100644 engine/pill_engine/src/renderer/resources/renderer_material.rs rename engine/{pill_renderer/src => pill_engine/src/renderer}/resources/renderer_mesh.rs (77%) rename engine/{pill_renderer/src => pill_engine/src/renderer}/resources/renderer_shader.rs (56%) rename engine/{pill_renderer/src => pill_engine/src/renderer}/resources/renderer_texture.rs (65%) create mode 100644 engine/pill_engine/src/renderer/state.rs delete mode 100644 engine/pill_renderer/Cargo.toml delete mode 100644 engine/pill_renderer/src/config.rs delete mode 100644 engine/pill_renderer/src/instance.rs delete mode 100644 engine/pill_renderer/src/lib.rs delete mode 100644 engine/pill_renderer/src/profiler.rs delete mode 100644 engine/pill_renderer/src/renderer.rs delete mode 100644 engine/pill_renderer/src/resources/renderer_material.rs delete mode 100644 engine/pill_renderer/src/resources/renderer_resource_storage.rs diff --git a/engine/.cargo/config.toml b/engine/.cargo/config.toml new file mode 100644 index 00000000..067b86ce --- /dev/null +++ b/engine/.cargo/config.toml @@ -0,0 +1,18 @@ +# Enable native CPU SIMD (AVX2/AVX512 on x86_64, NEON on ARM) for non-WASM builds. +# Without this the compiler defaults to SSE2 baseline on x86_64. +# wasm32 intentionally absent — target-cpu=native breaks WASM builds. + +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "target-cpu=native"] + +[target.x86_64-apple-darwin] +rustflags = ["-C", "target-cpu=native"] + +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-cpu=native"] + +[target.aarch64-apple-darwin] +rustflags = ["-C", "target-cpu=native"] + +[target.aarch64-unknown-linux-gnu] +rustflags = ["-C", "target-cpu=native"] diff --git a/engine/Cargo.toml b/engine/Cargo.toml index adcc520c..31944895 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -5,7 +5,6 @@ members = [ "pill_assets", "pill_core", "pill_engine", - "pill_renderer", "pill_native", "pill_web", "pill_runtime", diff --git a/engine/pill_core/Cargo.toml b/engine/pill_core/Cargo.toml index 184154e8..dba87686 100644 --- a/engine/pill_core/Cargo.toml +++ b/engine/pill_core/Cargo.toml @@ -9,7 +9,7 @@ authors = [ edition = "2021" [dependencies] -glam = { version = "0.30.8", features = ["serde"] } +glam = { version = "0.30.8", features = ["serde", "bytemuck"] } thiserror = "1.0.30" boolinator = "2.4.0" log = { version = "0.4", features = ["release_max_level_off"] } diff --git a/engine/pill_core/src/handle.rs b/engine/pill_core/src/handle.rs new file mode 100644 index 00000000..796f5fc7 --- /dev/null +++ b/engine/pill_core/src/handle.rs @@ -0,0 +1,150 @@ +// WHY over PillSlotMapKey: the renderer has 7 resource types — Handle gives all 7 for free; PillSlotMapKey needs a macro block + unsafe impl written out per type. + +use core::fmt; +use core::hash::{Hash, Hasher}; +use core::marker::PhantomData; + +#[repr(transparent)] +pub struct Handle { + raw: u64, + _marker: PhantomData, +} + +impl Handle { + #[inline] + pub const fn from_parts(index: u32, generation: u32) -> Self { + Self { + raw: ((generation as u64) << 32) | (index as u64), + _marker: PhantomData, + } + } + + #[inline] + pub const fn index(&self) -> u32 { + self.raw as u32 + } + + #[inline] + pub const fn generation(&self) -> u32 { + (self.raw >> 32) as u32 + } + + pub const INVALID: Self = Self { + raw: u64::MAX, + _marker: PhantomData, + }; + + #[inline] + pub const fn is_valid(&self) -> bool { + self.raw != u64::MAX + } +} + +impl Copy for Handle {} +impl Clone for Handle { + fn clone(&self) -> Self { + *self + } +} +impl PartialEq for Handle { + fn eq(&self, other: &Self) -> bool { + self.raw == other.raw + } +} +impl Eq for Handle {} +impl Hash for Handle { + fn hash(&self, state: &mut H) { + self.raw.hash(state) + } +} +impl fmt::Debug for Handle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Handle({:#x})", self.raw) + } +} + +struct Entry { + generation: u32, + resource: Option, +} + +pub struct ResourcePool { + entries: Vec>, + free_list: Vec, + _marker: PhantomData, +} + +impl Default for ResourcePool { + fn default() -> Self { + Self::new() + } +} + +impl ResourcePool { + pub fn new() -> Self { + Self { + entries: Vec::new(), + free_list: Vec::new(), + _marker: PhantomData, + } + } + + #[inline] + pub fn insert(&mut self, resource: V) -> Handle { + if let Some(idx) = self.free_list.pop() { + let e = &mut self.entries[idx as usize]; + e.generation = e.generation.wrapping_add(1); + e.resource = Some(resource); + Handle::from_parts(idx, e.generation) + } else { + let idx = self.entries.len() as u32; + self.entries.push(Entry { + generation: 0, + resource: Some(resource), + }); + Handle::from_parts(idx, 0) + } + } + + #[inline] + pub fn remove(&mut self, h: Handle) -> Option { + let idx = h.index() as usize; + if idx >= self.entries.len() { + return None; + } + let e = &mut self.entries[idx]; + if e.generation != h.generation() { + return None; + } + let res = e.resource.take(); + e.generation = e.generation.wrapping_add(1); + self.free_list.push(h.index()); + res + } + + #[inline] + pub fn get(&self, h: Handle) -> Option<&V> { + let idx = h.index() as usize; + if idx >= self.entries.len() { + return None; + } + let e = &self.entries[idx]; + if e.generation != h.generation() { + return None; + } + e.resource.as_ref() + } + + #[inline] + pub fn get_mut(&mut self, h: Handle) -> Option<&mut V> { + let idx = h.index() as usize; + if idx >= self.entries.len() { + return None; + } + let e = &mut self.entries[idx]; + if e.generation != h.generation() { + return None; + } + e.resource.as_mut() + } +} diff --git a/engine/pill_core/src/lib.rs b/engine/pill_core/src/lib.rs index be203ebb..126effde 100644 --- a/engine/pill_core/src/lib.rs +++ b/engine/pill_core/src/lib.rs @@ -4,6 +4,7 @@ mod bitmask_utils; mod color; mod error; +mod handle; mod log; mod math; #[cfg(not(target_arch = "wasm32"))] @@ -12,6 +13,7 @@ mod pill_slotmap; mod pill_twinmap; mod pill_typemap; mod style; +mod tags; mod timer; mod utils; @@ -63,6 +65,13 @@ pub use utils::{ pub use color::{generate_color_palette, hsl_to_rgb, DISTINCT_COLOR_PALETTE}; +pub use handle::{Handle, ResourcePool}; + +pub use tags::{ + RendererBufferTag, RendererCameraTag, RendererMaterialTag, RendererMeshTag, + RendererPipelineTag, RendererPipelineV2Tag, RendererTextureTag, +}; + pub use timer::{Timer, TimerRecord}; #[cfg(not(target_arch = "wasm32"))] diff --git a/engine/pill_core/src/tags.rs b/engine/pill_core/src/tags.rs new file mode 100644 index 00000000..87ed54ff --- /dev/null +++ b/engine/pill_core/src/tags.rs @@ -0,0 +1,9 @@ +// WHY: zero-size marker types so Handle and Handle are different types — passing the wrong one is a compile error, not a runtime bug. + +pub struct RendererBufferTag; +pub struct RendererCameraTag; +pub struct RendererMaterialTag; +pub struct RendererMeshTag; +pub struct RendererPipelineTag; +pub struct RendererPipelineV2Tag; +pub struct RendererTextureTag; diff --git a/engine/pill_core/src/timer.rs b/engine/pill_core/src/timer.rs index 18f3ac7b..43c347fa 100644 --- a/engine/pill_core/src/timer.rs +++ b/engine/pill_core/src/timer.rs @@ -22,6 +22,7 @@ pub struct Timer { pub records: Vec, current_label: Option, current_label_start: Option, + counters: Vec<(String, u64)>, } impl Default for Timer { @@ -37,6 +38,7 @@ impl Timer { records: Vec::new(), current_label: None, current_label_start: None, + counters: Vec::new(), } } @@ -102,6 +104,23 @@ impl Timer { self.records.iter().map(|r| r.duration).sum() } + pub fn set_counter(&mut self, label: impl Into, value: u64) { + let label = label.into(); + if let Some(entry) = self.counters.iter_mut().find(|(k, _)| k == &label) { + entry.1 = value; + } else { + self.counters.push((label, value)); + } + } + + pub fn get_counter(&self, label: &str) -> Option { + self.counters.iter().find(|(k, _)| k == label).map(|(_, v)| *v) + } + + pub fn counters(&self) -> &Vec<(String, u64)> { + &self.counters + } + pub fn print(&self, indent: usize) { for record in &self.records { Self::print_record(record, indent); diff --git a/engine/pill_engine/Cargo.toml b/engine/pill_engine/Cargo.toml index be7dd641..e0867c9e 100644 --- a/engine/pill_engine/Cargo.toml +++ b/engine/pill_engine/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] pill_core = {path = "../pill_core"} +glam = { version = "0.30.8", features = ["serde", "bytemuck"] } # Errors thiserror = "1.0.30" @@ -18,11 +19,12 @@ tobj = { version = "3.0", optional = true } # Other readonly = "0.2" winit = "0.30.12" -boolinator = "2.4.0" bytemuck = { version = "1.4", features = [ "derive" ] } lazy_static = "1.4.0" dyn-clone = "1.0.4" -egui = { version = "0.32.1", optional = true } +egui = "0.32.1" +egui-wgpu = { version = "0.32.1", features = ["winit"] } +wgpu = "25.0.0" serde = { version = "1.0", features = ["derive"] } bincode = "1.0" once_cell = "1.21.3" @@ -35,6 +37,11 @@ web-time = "1.1" png = "0.17" rodio = { version = "0.20", features = ["wav", "mp3"] } gilrs = "0.11.0" +pollster = "0.2" +egui-winit = { version = "0.32.1", features = ["clipboard"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +egui-winit = { version = "0.32.1", default-features = false } [build-dependencies] fs_extra = "1.2" @@ -45,6 +52,6 @@ game = [] internal = [] headless = [] networking = [] -debug_ui = ["dep:egui"] +debug_ui = [] obj_loading = ["dep:tobj"] all = ["game", "internal", "networking"] diff --git a/engine/pill_engine/res/shaders/include/common.hlsl b/engine/pill_engine/res/shaders/include/common.hlsl index 9bc9ca59..c8bf6aac 100644 --- a/engine/pill_engine/res/shaders/include/common.hlsl +++ b/engine/pill_engine/res/shaders/include/common.hlsl @@ -5,8 +5,9 @@ // any field change must be mirrored on both sides. struct EngineParams { - float3 fog_color; + float delta_time; float fog_density; + float3 fog_color; }; [[vk::binding(0, 0)]] ConstantBuffer engine; diff --git a/engine/pill_engine/src/ecs/components/egui_manager_component.rs b/engine/pill_engine/src/ecs/components/egui_manager_component.rs index 5030c3a2..6aba683d 100644 --- a/engine/pill_engine/src/ecs/components/egui_manager_component.rs +++ b/engine/pill_engine/src/ecs/components/egui_manager_component.rs @@ -32,7 +32,7 @@ impl EguiManagerComponent { } } - pub fn get_ui(engine: &mut Engine) -> Box { + pub fn get_ui(engine: &mut Engine) -> Box { let entity_count = engine .scene_manager .get_active_scene() @@ -75,7 +75,7 @@ impl EguiManagerComponent { .sum::(); let frame_delta_time = engine.frame_delta_time; - let ui = Box::new(move |ui: &egui::Context| { + let ui: Box = Box::new(move |ui: &egui::Context| { egui::Window::new("Pill Engine") .default_open(false) .resizable(true) diff --git a/engine/pill_engine/src/ecs/components/mesh_rendering_component.rs b/engine/pill_engine/src/ecs/components/mesh_rendering_component.rs index eb2a167f..d4f58e1f 100644 --- a/engine/pill_engine/src/ecs/components/mesh_rendering_component.rs +++ b/engine/pill_engine/src/ecs/components/mesh_rendering_component.rs @@ -5,8 +5,10 @@ use crate::{ DeferredUpdateManagerPointer, EntityHandle, SceneHandle, }, engine::Engine, - graphics::{compose_render_queue_key, RenderQueueKey}, - resources::{Material, MaterialHandle, Mesh, MeshHandle, ResourceManager}, + graphics::{compose_pbr_render_queue_key, compose_render_queue_key, RenderQueueKey}, + resources::{ + Material, MaterialHandle, Mesh, MeshHandle, PBRMaterial, PBRMaterialHandle, ResourceManager, + }, }; use pill_core::{get_type_name, PillStyle, PillTypeMapKey}; @@ -16,6 +18,7 @@ use pill_core::{ErrorContext, Result}; const DEFERRED_REQUEST_VARIANT_UPDATE_RENDER_QUEUE: usize = 0; const DEFERRED_REQUEST_VARIANT_SET_MATERIAL: usize = 1; const DEFERRED_REQUEST_VARIANT_SET_MESH: usize = 2; +const DEFERRED_REQUEST_VARIANT_SET_PBR_MATERIAL: usize = 3; // --- Builder --- @@ -40,6 +43,11 @@ impl MeshRenderingComponentBuilder { self } + pub fn pbr_material(mut self, material_handle: &PBRMaterialHandle) -> Self { + self.component.pbr_material_handle = Some(*material_handle); + self + } + pub fn build(self) -> MeshRenderingComponent { self.component } @@ -53,6 +61,8 @@ pub struct MeshRenderingComponent { pub mesh_handle: Option, #[readonly] pub material_handle: Option, + #[readonly] + pub pbr_material_handle: Option, pub(crate) render_queue_key: Option, entity_handle: Option, @@ -75,6 +85,7 @@ impl MeshRenderingComponent { Self { mesh_handle: None, material_handle: None, + pbr_material_handle: None, render_queue_key: None, entity_handle: None, scene_handle: None, @@ -87,6 +98,11 @@ impl MeshRenderingComponent { self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_SET_MATERIAL); } + pub fn set_pbr_material(&mut self, material_handle: &PBRMaterialHandle) { + self.pbr_material_handle = Some(*material_handle); + self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_SET_PBR_MATERIAL); + } + pub fn set_mesh(&mut self, mesh_handle: &MeshHandle) { self.mesh_handle = Some(*mesh_handle); self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_SET_MESH); @@ -94,6 +110,7 @@ impl MeshRenderingComponent { pub fn remove_material(&mut self) { self.material_handle = None; + self.pbr_material_handle = None; self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_UPDATE_RENDER_QUEUE); } @@ -114,17 +131,18 @@ impl MeshRenderingComponent { &mut self, resource_manager: &ResourceManager, ) -> Result<()> { - if let Some(handle) = &self.mesh_handle { - // Use default material if no material is set - let material_handle = match self.material_handle { - Some(v) => v, - None => DEFAULT_MATERIAL_HANDLE, + if let Some(mesh_handle) = &self.mesh_handle { + // PBR path takes priority; legacy Material path is the fallback + let result = if let Some(pbr_handle) = self.pbr_material_handle { + compose_pbr_render_queue_key(resource_manager, pbr_handle, mesh_handle) + } else { + // Use default material if no material is set + let material_handle = self.material_handle.unwrap_or(DEFAULT_MATERIAL_HANDLE); + compose_render_queue_key(resource_manager, &material_handle, mesh_handle) }; // Compose render queue key and set it - if let Ok(render_queue_key) = - compose_render_queue_key(resource_manager, &material_handle, handle) - { + if let Ok(render_queue_key) = result { self.render_queue_key = Some(render_queue_key); } else { self.render_queue_key = None; @@ -176,6 +194,15 @@ impl Component for MeshRenderingComponent { ))?; } + // Check if PBR material handle is valid + if let Some(handle) = &self.pbr_material_handle { + engine.get_resource::(handle).context(format!( + "Creating {} {} failed", + "Component".general_object_style(), + get_type_name::().specific_object_style() + ))?; + } + // Check if mesh handle is valid if let Some(handle) = &self.mesh_handle { engine.get_resource::(handle).context(format!( @@ -208,6 +235,18 @@ impl Component for MeshRenderingComponent { "Material".specific_object_style() ))?; + // Update mesh rendering queue + self.update_render_queue_key(&engine.resource_manager)?; + } + DEFERRED_REQUEST_VARIANT_SET_PBR_MATERIAL => { + engine + .get_resource::(&self.pbr_material_handle.unwrap()) + .context(format!( + "Setting {} {} failed", + "Resource".general_object_style(), + "PBRMaterial".specific_object_style() + ))?; + self.update_render_queue_key(&engine.resource_manager)?; } DEFERRED_REQUEST_VARIANT_SET_MESH => { @@ -220,10 +259,10 @@ impl Component for MeshRenderingComponent { "Mesh".specific_object_style() ))?; + // Update mesh rendering queue self.update_render_queue_key(&engine.resource_manager)?; } DEFERRED_REQUEST_VARIANT_UPDATE_RENDER_QUEUE => { - // Update mesh rendering queue self.update_render_queue_key(&engine.resource_manager)?; } _ => { diff --git a/engine/pill_engine/src/ecs/components/mod.rs b/engine/pill_engine/src/ecs/components/mod.rs index 210a45d3..d0a3e95b 100644 --- a/engine/pill_engine/src/ecs/components/mod.rs +++ b/engine/pill_engine/src/ecs/components/mod.rs @@ -18,6 +18,7 @@ pub(crate) mod mesh_rendering_component; pub(crate) mod network_manager_component; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod network_state_component; +pub(crate) mod render_state_component; pub(crate) mod time_component; pub(crate) mod transform_component; diff --git a/engine/pill_engine/src/ecs/components/render_state_component.rs b/engine/pill_engine/src/ecs/components/render_state_component.rs new file mode 100644 index 00000000..fe35a599 --- /dev/null +++ b/engine/pill_engine/src/ecs/components/render_state_component.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use crate::ecs::egui_client::EguiClient; + +use crate::ecs::components::{GlobalComponent, GlobalComponentStorage}; + +use pill_core::PillTypeMapKey; + +pub struct RenderStateComponent { + pub boot_done: bool, + pub egui_client: Arc, +} + +impl RenderStateComponent { + pub fn new(egui_client: Arc) -> Self { + Self { + boot_done: false, + egui_client, + } + } +} + +impl PillTypeMapKey for RenderStateComponent { + type Storage = GlobalComponentStorage; +} + +impl GlobalComponent for RenderStateComponent {} diff --git a/engine/pill_engine/src/ecs/components/transform_component.rs b/engine/pill_engine/src/ecs/components/transform_component.rs index f93a6f1d..1c7bb4b1 100644 --- a/engine/pill_engine/src/ecs/components/transform_component.rs +++ b/engine/pill_engine/src/ecs/components/transform_component.rs @@ -1,4 +1,5 @@ use crate::ecs::{Component, ComponentStorage}; +use glam::Quat; use pill_core::{Direction, Matrix3f, Matrix3fA, Matrix4f, PillTypeMapKey, Vector3f}; use serde::{Deserialize, Serialize}; @@ -176,15 +177,15 @@ impl TransformComponent { } pub fn update_transform_matrices(transform_component: &mut TransformComponent) { - let model = Matrix4f::model( - transform_component.position, - transform_component.rotation, + let q = Quat::from_rotation_y(transform_component.rotation.y.to_radians()) + * Quat::from_rotation_x(transform_component.rotation.x.to_radians()) + * Quat::from_rotation_z(transform_component.rotation.z.to_radians()); + transform_component.model_matrix = Matrix4f::from_scale_rotation_translation( transform_component.scale, + q, + transform_component.position, ); - let normal = Matrix3f::from_euler_angles(transform_component.rotation); - - transform_component.model_matrix = model; - transform_component.normal_matrix = normal.into(); + transform_component.normal_matrix = Matrix3fA::from_quat(q); } pub fn get_model_matrix(transform_component: &TransformComponent) -> Matrix4f { @@ -206,44 +207,3 @@ impl Default for TransformComponent { Self::new() } } - -pub trait Matrix3fAngleExt { - fn from_euler_angles(rotation_deg: Vector3f) -> Matrix3f; -} - -pub trait Matrix4fModelExt { - fn model(position: Vector3f, rotation_deg: Vector3f, scale: Vector3f) -> Matrix4f; - fn from_euler_angles(rotation_deg: Vector3f) -> Matrix4f; -} - -impl Matrix3fAngleExt for Matrix3f { - fn from_euler_angles(rotation_deg: Vector3f) -> Matrix3f { - let rz = Matrix3f::from_angle(rotation_deg.z.to_radians()); - let ry = Matrix3f::from_angle(rotation_deg.y.to_radians()); - let rx = Matrix3f::from_angle(rotation_deg.x.to_radians()); - rz * ry * rx - } -} - -impl Matrix4fModelExt for Matrix4f { - fn model(position: Vector3f, rotation_deg: Vector3f, scale: Vector3f) -> Matrix4f { - let rz = Matrix3f::from_angle(rotation_deg.z.to_radians()); - let ry = Matrix3f::from_angle(rotation_deg.y.to_radians()); - let rx = Matrix3f::from_angle(rotation_deg.x.to_radians()); - let rot3 = rz * ry * rx; - - let t = Matrix4f::from_translation(position); - let r = Matrix4f::from_mat3(rot3); - let s = Matrix4f::from_scale(scale); - - t * r * s - } - - fn from_euler_angles(rotation_deg: Vector3f) -> Matrix4f { - let rz = Matrix3f::from_angle(rotation_deg.z.to_radians()); - let ry = Matrix3f::from_angle(rotation_deg.y.to_radians()); - let rx = Matrix3f::from_angle(rotation_deg.x.to_radians()); - let rot3 = rz * ry * rx; - Matrix4f::from_mat3(rot3) - } -} diff --git a/engine/pill_engine/src/ecs/egui_client.rs b/engine/pill_engine/src/ecs/egui_client.rs new file mode 100644 index 00000000..43ed9029 --- /dev/null +++ b/engine/pill_engine/src/ecs/egui_client.rs @@ -0,0 +1,33 @@ +use std::sync::{Arc, Mutex}; + +type UiFn = Box; + +pub struct EguiClient { + events: Mutex>, + ui: Mutex>, +} + +impl EguiClient { + pub fn new() -> Arc { + Arc::new(Self { + events: Mutex::new(Vec::new()), + ui: Mutex::new(None), + }) + } + + pub fn handle_input(&self, event: winit::event::WindowEvent) { + self.events.lock().unwrap().push(event); + } + + pub fn take_events(&self) -> Vec { + std::mem::take(&mut self.events.lock().unwrap()) + } + + pub fn set_ui(&self, f: impl Fn(&egui::Context) + Send + 'static) { + *self.ui.lock().unwrap() = Some(Box::new(f)); + } + + pub fn take_ui(&self) -> Option { + self.ui.lock().unwrap().take() + } +} diff --git a/engine/pill_engine/src/ecs/mod.rs b/engine/pill_engine/src/ecs/mod.rs index 248c7e8e..dacbef0c 100644 --- a/engine/pill_engine/src/ecs/mod.rs +++ b/engine/pill_engine/src/ecs/mod.rs @@ -1,6 +1,7 @@ #![cfg_attr(debug_assertions, allow(dead_code, unused_variables))] mod components; +mod egui_client; mod entity; mod scene; mod scene_manager; @@ -31,6 +32,10 @@ pub use components::audio_source_component::AudioSourceComponent; #[cfg(feature = "debug_ui")] pub use components::egui_manager_component::EguiManagerComponent; +pub use components::render_state_component::RenderStateComponent; + +pub use egui_client::EguiClient; + pub use components::deferred_update_component::{ DeferredUpdateComponent, DeferredUpdateComponentRequest, DeferredUpdateManagerPointer, DeferredUpdateRequest, DeferredUpdateResourceRequest, diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index f55a9891..667907dd 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -1,8 +1,8 @@ use crate::{ config::RENDERING_SYSTEM, ecs::{ - CameraAspectRatio, CameraComponent, EntityHandle, MeshRenderingComponent, - TransformComponent, + CameraAspectRatio, CameraComponent, EntityHandle, + MeshRenderingComponent, RenderStateComponent, TransformComponent, }, engine::Engine, graphics::RenderQueueItem, @@ -16,6 +16,24 @@ use web_time::Instant; pub fn rendering_system(engine: &mut Engine) -> Result<()> { let mut timer = Timer::new(); timer.begin_context("rendering_system update"); + + // First-frame bootstrap: install default pass chain + let boot_done = engine + .get_global_component::()? + .boot_done; + + if !boot_done { + let egui_client = engine + .get_global_component::()? + .egui_client + .clone(); + engine.renderer.init_default_passes(egui_client)?; + engine + .get_global_component_mut::()? + .boot_done = true; + return Ok(()); + } + timer.record("Get active camera"); let active_scene_handle = engine.scene_manager.get_active_scene_handle()?; @@ -24,14 +42,10 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { { let active_scene = engine.scene_manager.get_active_scene_mut()?; - // - Find active camera and update its aspect ratio if needed - - // Find first enabled camera and use it as active for (entity_handle, camera_component) in active_scene.get_one_component_iterator_mut::()? { if camera_component.enabled { - // Update active camera aspect ratio if it is set to automatic if let CameraAspectRatio::Automatic(_) = camera_component.aspect { let aspect_ratio = engine.window_size.width as f32 / engine.window_size.height as f32; @@ -44,29 +58,23 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { } let active_camera_entity_handle = active_camera_entity_handle_result - .ok_or_else(|| -> pill_core::PillError { EngineError::NoActiveCamera.into() })?; + .ok_or_else(|| -> pill_core::PillError { pill_core::PillError::from(EngineError::NoActiveCamera) })?; - // - Prepare rendering data timer.record("Clear render queue"); - // Clear the render queue engine.render_queue.clear(); - engine.render_queue.reserve(200000); // Reserve space for 1000 items + engine.render_queue.reserve(200000); timer.record("Prepare render queue"); let mut _matrix_calculation_duration: f32 = 0.0; let mut add_to_render_queue_duration: f32 = 0.0; - // Iterate mesh rendering components for (entity_handle, _transform_component, mesh_rendering_component) in engine .scene_manager .get_two_component_iterator_mut::( active_scene_handle, )? { - // Update transform matrices if required - - // Add valid mesh rendering components to render queue let add_to_render_queue_start_time = Instant::now(); if let Some(render_queue_key) = mesh_rendering_component.render_queue_key { let render_queue_item = RenderQueueItem { @@ -93,16 +101,23 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { timer.record("Sort render queue"); - // Sort render queue engine.render_queue.sort(); timer.record("Get component storages"); + // Build egui UI and push to egui_client #[cfg(feature = "debug_ui")] - let egui_ui = crate::ecs::EguiManagerComponent::get_ui(engine); + { + use crate::ecs::EguiManagerComponent; + let egui_ui = EguiManagerComponent::get_ui(engine); + let egui_client = engine + .get_global_component::()? + .egui_client + .clone(); + egui_client.set_ui(egui_ui); + } let active_scene = engine.scene_manager.get_active_scene_mut()?; - // Get storages let camera_component_storage = active_scene .get_component_storage::() .context(format!( @@ -124,17 +139,6 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { // Render let delta_time = engine.frame_delta_time; - #[cfg(feature = "debug_ui")] - let render_result = engine.renderer.render( - active_camera_entity_handle, - &engine.render_queue, - camera_component_storage, - transform_component_storage, - egui_ui, - delta_time, - &mut timer, - ); - #[cfg(not(feature = "debug_ui"))] let render_result = engine.renderer.render( active_camera_entity_handle, &engine.render_queue, @@ -142,10 +146,11 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { transform_component_storage, delta_time, &mut timer, + &engine.resource_manager, ); match render_result { Ok(_) => { - timer.end_context()?; // End "Render" context + timer.end_context()?; engine.system_manager.update_system_timer( RENDERING_SYSTEM.name, RENDERING_SYSTEM.update_phase, @@ -156,8 +161,7 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { Err(e) => { match e.downcast_ref::() { Some(RendererError::SurfaceLost) => { - // Recreate lost surface - timer.end_context()?; // End "Render" context + timer.end_context()?; engine.system_manager.update_system_timer( RENDERING_SYSTEM.name, RENDERING_SYSTEM.update_phase, diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index 427f3bef..2ae9795f 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -1,4 +1,4 @@ -use crate::{app_config::EngineConfig, config::*, ecs::*, graphics::*, resources::*}; +use crate::{app_config::EngineConfig, config::*, ecs::*, graphics::*, renderer::resources::{RendererMaterial, RendererMesh, RendererShader, RendererTexture}, resources::*}; use pill_core::{ debug, error, get_game_error_message, get_type_name, info, EngineError, LogContext, @@ -119,8 +119,17 @@ impl Engine { .get_int("MAX_SOUNDS") .unwrap_or(MAX_SOUNDS as i64) as usize; + #[cfg(not(feature = "headless"))] + { + self.register_resource_type::(max_shader_count)?; + self.register_resource_type::(max_material_count)?; + self.register_resource_type::(max_texture_count)?; + self.register_resource_type::(max_mesh_count)?; + } + self.register_resource_type::(max_shader_count)?; self.register_resource_type::(max_material_count)?; + self.register_resource_type::(max_material_count)?; self.register_resource_type::(max_texture_count)?; self.register_resource_type::(max_mesh_count)?; #[cfg(not(target_arch = "wasm32"))] @@ -308,6 +317,7 @@ impl Engine { #[cfg(not(feature = "headless"))] { self.add_global_component(InputComponent::new())?; + self.add_global_component(RenderStateComponent::new(EguiClient::new()))?; } #[cfg(all(not(feature = "headless"), not(target_arch = "wasm32")))] diff --git a/engine/pill_engine/src/graphics/dummy_renderer.rs b/engine/pill_engine/src/graphics/dummy_renderer.rs index 2298b531..7ac141fb 100644 --- a/engine/pill_engine/src/graphics/dummy_renderer.rs +++ b/engine/pill_engine/src/graphics/dummy_renderer.rs @@ -1,12 +1,11 @@ use crate::{ app_config::EngineConfig, - ecs::{CameraComponent, ComponentStorage, EntityHandle, TransformComponent}, + ecs::{CameraComponent, ComponentStorage, EguiClient, EntityHandle, TransformComponent}, graphics::{ - PillRenderer, RenderQueueItem, RendererCameraHandle, RendererMaterialHandle, - RendererMeshHandle, RendererShaderHandle, RendererTextureHandle, + BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, RendererTargetDesc, + RenderQueueItem, RendererCameraHandle, RendererTextureHandle, WorldQuery, }, - internal::{MaterialParameter, MaterialTexture}, - resources::{MeshData, ShaderParameterSlot, ShaderTextureSlot, TextureType}, + resources::{ResourceManager, ShaderParameterSlot, ShaderTextureSlot}, }; use pill_core::Result; @@ -23,7 +22,7 @@ impl PillRenderer for DummyRenderer { // --- Create --- - fn create_shader( + fn create_shader_struct( &mut self, _name: &str, _vertex_wgsl: &str, @@ -32,117 +31,90 @@ impl PillRenderer for DummyRenderer { _parameter_slots: &[(String, ShaderParameterSlot)], _pass_engine_parameters: bool, _pass_camera_parameters: bool, - ) -> Result { - Ok(RendererShaderHandle::default()) + ) -> Result { + unimplemented!("DummyRenderer has no GPU shader creation") } - fn create_material( - &mut self, - _name: &str, - _renderer_shader_handle: RendererShaderHandle, - _textures: &[(String, MaterialTexture)], - _parameters: &HashMap, - ) -> Result { - Ok(RendererMaterialHandle::default()) + fn create_camera(&mut self) -> Result { + Ok(RendererCameraHandle::default()) } - fn create_texture( - &mut self, - _name: &str, - _rgba: &[u8], - _width: u32, - _height: u32, - _texture_type: TextureType, - ) -> Result { - Ok(RendererTextureHandle::default()) - } - fn create_mesh(&mut self, _name: &str, _mesh_data: &MeshData) -> Result { - Ok(RendererMeshHandle::default()) - } - fn create_camera(&mut self) -> Result { - Ok(RendererCameraHandle::default()) + // --- Destroy --- + + fn destroy_camera(&mut self, _renderer_camera_handle: RendererCameraHandle) -> Result<()> { + Ok(()) } - // --- Update --- + // --- Other --- + + fn resize(&mut self, _new_window_size: PhysicalSize) {} - fn update_material_textures( - &mut self, - _renderer_material_handle: RendererMaterialHandle, - _textures: &[(String, MaterialTexture)], - ) -> Result<()> { + #[cfg(feature = "debug_ui")] + fn pass_input_to_egui(&mut self, _event: &WindowEvent) -> Result<()> { Ok(()) } - fn update_material_parameters( + fn render( &mut self, - _renderer_material_handle: RendererMaterialHandle, - _parameters: &HashMap, + _active_camera_entity_handle: EntityHandle, + _render_queue: &[RenderQueueItem], + _camera_component_storage: &ComponentStorage, + _transform_component_storage: &ComponentStorage, + _delta_time: f32, + _timer: &mut Timer, + _resource_manager: &ResourceManager, ) -> Result<()> { Ok(()) } - // --- Destroy --- + // --- Pass API --- - fn destroy_shader(&mut self, _renderer_shader_handle: RendererShaderHandle) -> Result<()> { + fn set_passes(&mut self, _passes: Vec>) -> Result<()> { Ok(()) } - fn destroy_material( - &mut self, - _renderer_material_handle: RendererMaterialHandle, - ) -> Result<()> { + fn init_default_passes(&mut self, _egui_client: Arc) -> Result<()> { Ok(()) } - fn destroy_texture(&mut self, _renderer_texture_handle: RendererTextureHandle) -> Result<()> { - Ok(()) + fn get_device(&self) -> &wgpu::Device { + unimplemented!("DummyRenderer has no wgpu Device") } - fn destroy_mesh(&mut self, _renderer_mesh_handle: RendererMeshHandle) -> Result<()> { - Ok(()) + fn get_queue(&self) -> &wgpu::Queue { + unimplemented!("DummyRenderer has no wgpu Queue") } - fn destroy_camera(&mut self, _renderer_camera_handle: RendererCameraHandle) -> Result<()> { - Ok(()) + fn get_surface_format(&self) -> wgpu::TextureFormat { + wgpu::TextureFormat::Rgba8UnormSrgb } - // --- Other --- - - fn resize(&mut self, _new_window_size: PhysicalSize) { - // no-op for dummy + fn create_buffer(&mut self, _desc: BufferDesc) -> Result { + unimplemented!("DummyRenderer has no wgpu Device") } - #[cfg(feature = "debug_ui")] - fn pass_input_to_egui(&mut self, _event: &WindowEvent) -> Result<()> { - Ok(()) + fn create_pipeline_v2(&mut self, _desc: PipelineV2Desc) -> Result { + unimplemented!("DummyRenderer has no wgpu Device") } - #[cfg(feature = "debug_ui")] - fn render( + fn create_render_target( &mut self, - _active_camera_entity_handle: EntityHandle, - _render_queue: &[RenderQueueItem], - _camera_component_storage: &ComponentStorage, - _transform_component_storage: &ComponentStorage, - _egui_ui: Box, - _delta_time: f32, - _timer: &mut Timer, - ) -> Result<()> { - Ok(()) + _desc: RendererTargetDesc, + ) -> Result { + Ok(RendererTextureHandle::default()) } - #[cfg(not(feature = "debug_ui"))] - fn render( - &mut self, - _active_camera_entity_handle: EntityHandle, - _render_queue: &[RenderQueueItem], - _camera_component_storage: &ComponentStorage, - _transform_component_storage: &ComponentStorage, - _delta_time: f32, - _timer: &mut Timer, - ) -> Result<()> { - Ok(()) + fn create_depth_texture(&mut self, _label: &str) -> Result { + Ok(RendererTextureHandle::default()) + } + + fn get_render_target_view( + &self, + _handle: RendererTextureHandle, + ) -> Option<&wgpu::TextureView> { + None } + } diff --git a/engine/pill_engine/src/graphics/mod.rs b/engine/pill_engine/src/graphics/mod.rs index 1e0ad585..cb7cff9f 100644 --- a/engine/pill_engine/src/graphics/mod.rs +++ b/engine/pill_engine/src/graphics/mod.rs @@ -4,22 +4,29 @@ mod dummy_renderer; #[cfg(feature = "debug_ui")] mod egui; +#[cfg(feature = "debug_ui")] +mod pass_egui; +mod pass_pbr_static; mod render_queue; mod renderer; // --- Use --- pub use renderer::{ - PillRenderer, Renderer, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, - RendererShaderHandle, RendererTextureHandle, + BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, Renderer, RendererCameraHandle, + RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle, RendererTargetDesc, + RendererTextureHandle, ShaderDesc, WorldQuery, }; #[cfg(feature = "headless")] pub use self::dummy_renderer::DummyRenderer; #[cfg(feature = "debug_ui")] pub use egui::EguiUI; +#[cfg(feature = "debug_ui")] +pub use pass_egui::PassEgui; +pub use pass_pbr_static::PassPBRStatic; pub use render_queue::{ - compose_render_queue_key, decompose_render_queue_key, RenderQueueItem, RenderQueueKey, - RenderQueueKeyFields, RENDER_QUEUE_KEY_ORDER, + compose_pbr_render_queue_key, compose_render_queue_key, decompose_render_queue_key, + RenderQueueItem, RenderQueueKey, RenderQueueKeyFields, RENDER_QUEUE_KEY_ORDER, }; diff --git a/engine/pill_engine/src/graphics/pass_egui.rs b/engine/pill_engine/src/graphics/pass_egui.rs new file mode 100644 index 00000000..b7789766 --- /dev/null +++ b/engine/pill_engine/src/graphics/pass_egui.rs @@ -0,0 +1,78 @@ +use crate::{ + ecs::EguiClient, + graphics::{Pass, PillRenderer, WorldQuery}, + renderer::drawers::egui_drawer::EguiDrawer, +}; +use pill_core::Result; +use std::sync::Arc; + +pub struct PassEgui { + window: Arc, + client: Arc, + drawer: Option, +} + +impl PassEgui { + /// Creates the egui pass; the `EguiDrawer` is initialised lazily in `Pass::init`. + pub fn new(window: Arc, client: Arc) -> Self { + Self { + window, + client, + drawer: None, + } + } +} + +impl Pass for PassEgui { + fn get_label(&self) -> &str { + "pass_egui" + } + + fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { + self.drawer = Some(EguiDrawer::new( + renderer.get_device(), + renderer.get_surface_format(), + None, + 1, + self.window.clone(), + )); + Ok(()) + } + + fn draw( + &mut self, + encoder: &mut wgpu::CommandEncoder, + renderer: &mut dyn PillRenderer, + _frame: &wgpu::SurfaceTexture, + view: &wgpu::TextureView, + _world: &WorldQuery<'_>, + ) -> Result<()> { + let drawer = self.drawer.as_mut().expect("PassEgui not initialized"); + + for ev in self.client.take_events() { + drawer.handle_input(&ev); + } + + let size = self.window.inner_size(); + let screen_desc = egui_wgpu::ScreenDescriptor { + size_in_pixels: [size.width, size.height], + pixels_per_point: drawer.window_scale_factor, + }; + + let ui_fn = self.client.take_ui(); + let run_ui: Box = match ui_fn { + Some(ui_function) => Box::new(move |egui_context: &egui::Context| ui_function(egui_context)), + None => Box::new(|_egui_context: &egui::Context| {}), + }; + + drawer.record_draw_commands( + renderer.get_device(), + renderer.get_queue(), + encoder, + view, + screen_desc, + run_ui, + &mut pill_core::Timer::new(), + ) + } +} diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_static.rs new file mode 100644 index 00000000..04dc7603 --- /dev/null +++ b/engine/pill_engine/src/graphics/pass_pbr_static.rs @@ -0,0 +1,938 @@ +use crate::{ + ecs::{CameraComponent, ComponentStorage, TransformComponent}, + graphics::{ + decompose_render_queue_key, BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, + RendererMaterialHandle, RendererMeshHandle, RendererTextureHandle, ShaderDesc, WorldQuery, + }, + renderer::resources::{RendererMaterial, RendererMesh}, +}; +use glam::{Mat3, Mat4, Quat, Vec3}; +use pill_core::{PillSlotMapKey, Result}; +use std::num::NonZeroU32; + + +/// Camera uniform layout: position (vec4) + view-projection matrix (mat4x4). +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct CameraUniform { + position: [f32; 4], + view_projection_matrix: [[f32; 4]; 4], +} + +impl CameraUniform { + fn new() -> Self { + Self { + position: [0.0; 4], + view_projection_matrix: Mat4::IDENTITY.to_cols_array_2d(), + } + } + + /// Recomputes view and projection from ECS components; called once per frame before GPU upload. + fn update_data( + &mut self, + camera_component: &CameraComponent, + transform_component: &TransformComponent, + ) { + self.position = [ + transform_component.position.x, + transform_component.position.y, + transform_component.position.z, + 0.0, + ]; + + // View matrix: engine uses +Z forward convention; look_to_lh preserves right-handed winding + // by computing right = up × (-dir) = +X, avoiding the NDC x-flip that look_to_rh produces. + let roll_matrix = Mat3::from_rotation_z(transform_component.rotation.z.to_radians()); + let yaw_matrix = Mat3::from_rotation_y(transform_component.rotation.y.to_radians()); + let pitch_matrix = Mat3::from_rotation_x(transform_component.rotation.x.to_radians()); + let rotation_matrix = yaw_matrix * pitch_matrix * roll_matrix; + let direction = rotation_matrix * Vec3::Z; + let eye = Vec3::new( + transform_component.position.x, + transform_component.position.y, + transform_component.position.z, + ); + let view = Mat4::look_to_lh(eye, direction, Vec3::Y); + + // Projection: perspective_lh maps view-z ∈ [z_near, z_far] to NDC depth [0,1] for +Z forward. + let proj = Mat4::perspective_lh( + camera_component.fov.to_radians(), + camera_component.aspect.get_value(), + camera_component.range.start, + camera_component.range.end, + ); + + self.view_projection_matrix = (proj * view).to_cols_array_2d(); + } +} + +/// Per-draw uniform: MVP and model matrix packed into a std140-aligned dynamic UBO. +#[repr(C)] +#[derive(Copy, Clone)] +pub(crate) struct PerDrawStd140 { + mvp: [[f32; 4]; 4], + model: [[f32; 4]; 4], +} +unsafe impl bytemuck::Zeroable for PerDrawStd140 {} +unsafe impl bytemuck::Pod for PerDrawStd140 {} + +// Preallocated dynamic-UBO ring: 256-byte stride to satisfy min UBO offset alignment. +const MAX_EXPECTED_PER_DRAW_INSTANCES: usize = 100_000; +const UNIFORM_OFFSET_ALIGNMENT: usize = 256; +const PER_DRAW_STRIDE_BYTES: usize = ((std::mem::size_of::() + + (UNIFORM_OFFSET_ALIGNMENT - 1)) + / UNIFORM_OFFSET_ALIGNMENT) + * UNIFORM_OFFSET_ALIGNMENT; + +pub const MATERIAL_BIND_GROUP_GLOBALS: usize = 0; +pub const MATERIAL_BIND_GROUP_TEXTURES: usize = 1; +pub const MATERIAL_BIND_GROUP_PARAMS: usize = 2; +pub const MATERIAL_BIND_GROUP_PERDRAW: usize = 3; + +/// Visible entity ready for batching into a draw call. +pub(crate) struct VisiblePreDraw { + pub(crate) material_handle: RendererMaterialHandle, + pub(crate) mesh_handle: RendererMeshHandle, + pub(crate) entity_index: u32, + pub(crate) mvp: [[f32; 4]; 4], + pub(crate) model: [[f32; 4]; 4], +} + +/// Mesh batch within a material group: same mesh handle, consecutive per-draw entries in the ring. +pub(crate) struct MeshBatch { + pub(crate) mesh_handle: RendererMeshHandle, + pub(crate) instances: Vec, + pub(crate) base_offset_u32: u32, +} + +/// Draw group: one pipeline + one material, containing one or more mesh batches. +pub(crate) struct GroupCmd { + pub(crate) pipeline: *const wgpu::RenderPipeline, + pub(crate) material_handle: RendererMaterialHandle, + pub(crate) batches: Vec, +} + +/// GPU-side pass state initialized in `Pass::init`, read every `Pass::draw`. +struct PassPBRStaticState { + camera_uniform: CameraUniform, + camera_buffer: wgpu::Buffer, + globals_bind_group: wgpu::BindGroup, + pipeline: PipelineV2, + ibl_sampler: wgpu::Sampler, + // 1×1 white fallback used when no IBL textures are provided. + ibl_fallback_texture: wgpu::Texture, + ibl_fallback_view: wgpu::TextureView, + per_draw_buffer: wgpu::Buffer, + per_draw_bind_group: wgpu::BindGroup, +} + +/// Default PBR pass: full GGX shading with optional IBL, per-draw dynamic UBO ring. +/// No MeshDrawer — each entity is drawn with a direct indexed draw call. +pub struct PassPBRStatic { + ibl_irradiance_tex: Option, + ibl_prefilter_tex: Option, + ibl_brdf_lut_tex: Option, + depth_texture: Option, + per_draw_stride: u64, + per_draw_capacity: u64, + visible_pre_draw_buffer: Vec, + groups_buffer: Vec, + staging_buffer: Vec, + state: Option, +} + +impl PassPBRStatic { + /// Creates the pass with optional IBL textures; `Pass::init` must run before the first frame. + pub fn new( + ibl_irradiance_tex: Option, + ibl_prefilter_tex: Option, + ibl_brdf_lut_tex: Option, + ) -> Self { + Self { + ibl_irradiance_tex, + ibl_prefilter_tex, + ibl_brdf_lut_tex, + depth_texture: None, + per_draw_stride: PER_DRAW_STRIDE_BYTES as u64, + per_draw_capacity: 0, + visible_pre_draw_buffer: Vec::with_capacity(MAX_EXPECTED_PER_DRAW_INSTANCES), + groups_buffer: Vec::with_capacity(2000), + staging_buffer: Vec::with_capacity( + MAX_EXPECTED_PER_DRAW_INSTANCES * PER_DRAW_STRIDE_BYTES, + ), + state: None, + } + } +} + +/// Returns the initialized pass state; panics in debug if `init` was not called. +fn get_state(pass: &mut PassPBRStatic) -> &mut PassPBRStaticState { + debug_assert!(pass.state.is_some()); + // SAFETY: initialized once in init(), read every draw + unsafe { pass.state.as_mut().unwrap_unchecked() } +} + +impl Pass for PassPBRStatic { + fn get_label(&self) -> &str { + "pass_pbr_static" + } + + fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { + let vertex_wgsl = r#" + struct Camera { + position: vec4, + viewProjection: mat4x4, + }; + @group(0) @binding(0) var UCamera: Camera; + struct PerDraw { mvp: mat4x4, model: mat4x4, }; + @group(3) @binding(0) var UPerDraw: PerDraw; + struct VSIn { + @location(0) pos: vec3, + @location(4) uv: vec2, + @location(5) normal: vec3, + }; + struct VSOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, + @location(1) worldPos: vec3, + @location(2) worldNormal: vec3, + }; + @vertex fn main(input: VSIn) -> VSOut { + var out: VSOut; + let worldPos4 = UPerDraw.model * vec4(input.pos, 1.0); + // TODO: Use a proper normal matrix (inverse-transpose of model) if non-uniform scaling is used. + let n = normalize((UPerDraw.model * vec4(input.normal, 0.0)).xyz); + out.pos = UPerDraw.mvp * vec4(input.pos, 1.0); + out.uv = input.uv; + out.worldPos = worldPos4.xyz; + out.worldNormal = n; + return out; + } + "#; + + let fragment_wgsl = r#" + // PBR material textures (set 1) — bindings match DEFAULT_LIT_SHADER layout (0-3). + @group(1) @binding(0) var texBaseColor: texture_2d; + @group(1) @binding(1) var smpBaseColor: sampler; + @group(1) @binding(2) var texNormal: texture_2d; + @group(1) @binding(3) var smpNormal: sampler; + // PBR params UBO (set 2) — 32 bytes matching default lit shader parameter layout: + // offset 0: baseColorFactor (vec3 + pad), offset 16: roughnessFactor (f32 + pad). + struct MaterialParams { + baseColorFactor: vec3, _pad0: f32, + roughnessFactor: f32, _pad1: f32, _pad2: vec2, + } + @group(2) @binding(0) var UMaterial: MaterialParams; + struct Camera { + position: vec4, + viewProjection: mat4x4, + }; + @group(0) @binding(0) var UCamera: Camera; + // IBL resources in globals (set 0) + @group(0) @binding(1) var texIrradiance: texture_2d; + @group(0) @binding(2) var smpIrradiance: sampler; + @group(0) @binding(3) var texPrefilter: texture_2d; + @group(0) @binding(4) var smpPrefilter: sampler; + @group(0) @binding(5) var texBrdfLut: texture_2d; + @group(0) @binding(6) var smpBrdfLut: sampler; + + const PI: f32 = 3.14159265359; + + // Old renderer used a point light at (-10,10,-10); the resulting direction from light + // to scene (z≈12) is approximately (0.38,-0.38,0.84) — behind and above the camera. + // This strongly illuminates the camera-facing surface (normal≈(0,0,-1), NdotL≈0.84). + const LIGHT_DIR0: vec3 = vec3( 0.38, -0.38, 0.84); // key: behind-camera, upper-left + const LIGHT_DIR1: vec3 = vec3(-0.50, 0.50, -0.71); // rim: front upper-right + const LIGHT_DIR2: vec3 = vec3( 0.00, -1.00, 0.00); // bounce: from below + const LIGHT_COL0: vec4 = vec4(1.0, 0.90, 0.80, 8.0); // warm key + const LIGHT_COL1: vec4 = vec4(0.5, 0.55, 1.00, 2.5); // cool rim + const LIGHT_COL2: vec4 = vec4(0.8, 0.80, 0.80, 0.8); // neutral bounce + + fn DistributionGGX(N: vec3, H: vec3, roughness: f32) -> f32 { + // Add epsilon to avoid singularities at very low roughness. + let a = max(roughness * roughness, 0.0025); + let a2 = a * a; + let NdotH = max(dot(N, H), 0.0); + let NdotH2 = NdotH * NdotH; + let denom = (NdotH2 * (a2 - 1.0) + 1.0); + return a2 / (PI * denom * denom + 1e-7); + } + + fn GeometrySchlickGGX(NdotV: f32, roughness: f32) -> f32 { + // Heitz's k for direct lighting approximation. + let r = roughness + 1.0; + let k = (r * r) / 8.0; + let denom = NdotV * (1.0 - k) + k; + return NdotV / denom; + } + + fn GeometrySmith(N: vec3, V: vec3, L: vec3, roughness: f32) -> f32 { + let NdotV = max(dot(N, V), 0.0); + let NdotL = max(dot(N, L), 0.0); + let ggx2 = GeometrySchlickGGX(NdotV, roughness); + let ggx1 = GeometrySchlickGGX(NdotL, roughness); + return ggx1 * ggx2; + } + + fn fresnelSchlick(cosTheta: f32, F0: vec3) -> vec3 { + return F0 + (vec3(1.0, 1.0, 1.0) - F0) * pow(1.0 - cosTheta, 5.0); + } + + fn fresnelSchlickRoughness(cosTheta: f32, F0: vec3, roughness: f32) -> vec3 { + return F0 + (max(vec3(1.0, 1.0, 1.0) * (1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0); + } + + fn dir_to_equirect_uv(dir: vec3) -> vec2 { + let d = normalize(dir); + let u = 0.5 + atan2(d.x, -d.z) / (2.0 * PI); + let v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) / PI; + return vec2(fract(u), clamp(v, 0.0, 1.0)); + } + + fn accumulateDirLight( + N: vec3, V: vec3, F0: vec3, + albedo: vec3, roughness: f32, metallic: f32, + lightDir: vec3, lightColor: vec4 + ) -> vec3 { + // lightDir is direction from light to surface; incoming L is opposite. + let L = normalize(-lightDir.xyz); + let H = normalize(V + L); + let radiance = lightColor.w * lightColor.xyz; + let NDF = DistributionGGX(N, H, roughness); + let G = GeometrySmith(N, V, L, roughness); + let F = fresnelSchlick(max(dot(H, V), 0.0), F0); + let kS = F; + var kD = vec3(1.0, 1.0, 1.0) - kS; + kD = kD * (1.0 - metallic); + let numerator = NDF * G * F; + let denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001; + let specular = numerator / vec3(denominator, denominator, denominator); + let NdotL = max(dot(N, L), 0.0); + return (kD * (albedo / PI) + specular) * radiance * NdotL; + } + + struct FSOut { + @location(0) color: vec4, + }; + + @fragment fn main( + @location(0) uv: vec2, + @location(1) WorldPos: vec3, + @location(2) NormalIn: vec3, + ) -> FSOut { + var albedo = textureSample(texBaseColor, smpBaseColor, uv).rgb * UMaterial.baseColorFactor; + // roughnessFactor maps to specularity slot (offset 16 in current material layout). + // metallic is not stored yet — default to 0.0 (dielectric). + var roughness = clamp(UMaterial.roughnessFactor, 0.045, 0.99); + let metallic = 0.0; + // TODO: Support normal mapping (tangent space) and AO texture. + let N = normalize(NormalIn); + let V = normalize(UCamera.position.xyz - WorldPos); + var F0 = vec3(0.04, 0.04, 0.04); + F0 = mix(F0, albedo, vec3(metallic, metallic, metallic)); + var Lo = vec3(0.0, 0.0, 0.0); + Lo = Lo + accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR0, LIGHT_COL0); + Lo = Lo + accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR1, LIGHT_COL1); + Lo = Lo + accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR2, LIGHT_COL2); + var color = Lo; + // Diffuse IBL ambient. + let kS = fresnelSchlick(max(dot(N, V), 0.0), F0); + let kD = (vec3(1.0, 1.0, 1.0) - kS) * (1.0 - metallic); + let uvIrr = dir_to_equirect_uv(N); + let irradiance = textureSample(texIrradiance, smpIrradiance, uvIrr).rgb; + let ambient_diffuse = kD * irradiance * albedo; + // Specular IBL. + let R = reflect(-V, N); + let MAX_REFLECTION_LOD: f32 = 4.0; + let prefilteredColor = textureSampleLevel( + texPrefilter, smpPrefilter, dir_to_equirect_uv(R), roughness * MAX_REFLECTION_LOD + ).rgb; + let envBRDF = textureSample(texBrdfLut, smpBrdfLut, vec2(max(dot(N, V), 0.0), roughness)).rg; + let F = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness); + let specular = prefilteredColor * (F * envBRDF.x + envBRDF.y); + let ambient = vec3(0.04, 0.04, 0.04) * albedo; + color = color + ambient_diffuse + specular + ambient; + var out: FSOut; + out.color = vec4(color, 1.0); + return out; + } + "#; + + let surface_format = renderer.get_surface_format(); + let bind_groups: Vec> = vec![ + // 0: globals (camera + IBL irradiance + prefilter + BRDF LUT) + vec![ + 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, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + // 1: material textures (base_color, normal, metallic_roughness, emissive) + vec![ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + // 2: material params + vec![wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + // 3: per-draw dynamic UBO (MVP + model) + vec![wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: Some( + std::num::NonZeroU64::new(std::mem::size_of::() as u64) + .unwrap(), + ), + }, + count: None, + }], + ]; + + let desc = PipelineV2Desc { + label: Some("pass_pbr_static_pipeline"), + vs: ShaderDesc { + source: vertex_wgsl, + entry_func: "main", + }, + ps: ShaderDesc { + source: fragment_wgsl, + entry_func: "main", + }, + vertex_buffers: &[ + ::data_layout_descriptor(), + ], + bind_groups, + targets: &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + 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(), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + // Pill mesh uses CW exterior winding; disable culling to match old renderer behavior. + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: false, + }, + }; + + let pipeline = renderer.create_pipeline_v2(desc)?; + + let camera_buffer = { + let device = renderer.get_device(); + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pass_pbr_static_camera_buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) + }; + + self.per_draw_capacity = MAX_EXPECTED_PER_DRAW_INSTANCES as u64; + let per_draw_buffer = renderer.create_buffer(BufferDesc { + label: Some("pass_pbr_static_per_draw_ring"), + byte_size: self.per_draw_stride * self.per_draw_capacity, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + })?; + let per_draw_bind_group = { + let layout_ptr: *const wgpu::BindGroupLayout = + &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_PERDRAW] as *const _; + let device = renderer.get_device(); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_pbr_static_per_draw_bind_group"), + layout: unsafe { &*layout_ptr }, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &per_draw_buffer, + offset: 0, + size: Some( + std::num::NonZeroU64::new( + std::mem::size_of::() as u64, + ) + .unwrap(), + ), + }), + }], + }) + }; + + // 1×1 black fallback texture used when IBL handles are None — zero IBL contribution. + let ibl_fallback_texture = { + let device = renderer.get_device(); + device.create_texture(&wgpu::TextureDescriptor { + label: Some("ibl_fallback"), + size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }) + }; + renderer.get_queue().write_texture( + wgpu::TexelCopyTextureInfo { + texture: &ibl_fallback_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &[0u8, 0, 0, 0], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + ); + let ibl_fallback_view = + ibl_fallback_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let ibl_sampler = { + let device = renderer.get_device(); + device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }) + }; + + // Resolve IBL texture views; fall back to the 1×1 white texture when not provided. + let irradiance_view = self + .ibl_irradiance_tex + .and_then(|handle| renderer.get_render_target_view(handle)) + .unwrap_or(&ibl_fallback_view); + let prefilter_view = self + .ibl_prefilter_tex + .and_then(|handle| renderer.get_render_target_view(handle)) + .unwrap_or(&ibl_fallback_view); + let brdf_lut_view = self + .ibl_brdf_lut_tex + .and_then(|handle| renderer.get_render_target_view(handle)) + .unwrap_or(&ibl_fallback_view); + + let prefilter_sampler = { + let device = renderer.get_device(); + device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Linear, + lod_min_clamp: 0.0, + lod_max_clamp: 16.0, + ..Default::default() + }) + }; + + let globals_bind_group = { + let layout_ptr: *const wgpu::BindGroupLayout = + &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_GLOBALS] as *const _; + let device = renderer.get_device(); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_pbr_static_globals_bind_group"), + layout: unsafe { &*layout_ptr }, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(irradiance_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&ibl_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(prefilter_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&prefilter_sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(brdf_lut_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(&ibl_sampler), + }, + ], + }) + }; + + self.depth_texture = Some(renderer.create_depth_texture("pass_pbr_static_depth")?); + + self.state = Some(PassPBRStaticState { + camera_uniform: CameraUniform::new(), + camera_buffer, + globals_bind_group, + pipeline, + ibl_sampler, + ibl_fallback_texture, + ibl_fallback_view, + per_draw_buffer, + per_draw_bind_group, + }); + + Ok(()) + } + + fn draw( + &mut self, + encoder: &mut wgpu::CommandEncoder, + renderer: &mut dyn PillRenderer, + _frame: &wgpu::SurfaceTexture, + view: &wgpu::TextureView, + world: &WorldQuery<'_>, + ) -> Result<()> { + // Read active camera and transform. + let active_camera_index = world.active_camera.data().index as usize; + let active_camera_component = world + .camera_components + .data + .get(active_camera_index) + .unwrap() + .as_ref() + .unwrap(); + let active_camera_transform = world + .transform_components + .data + .get(active_camera_index) + .unwrap() + .as_ref() + .unwrap(); + + // Update camera uniform and write to GPU buffer. + let vp_mat: Mat4 = { + let state = get_state(self); + state + .camera_uniform + .update_data(active_camera_component, active_camera_transform); + renderer.get_queue().write_buffer( + &state.camera_buffer, + 0, + bytemuck::bytes_of(&state.camera_uniform), + ); + Mat4::from_cols_array_2d(&state.camera_uniform.view_projection_matrix) + }; + + let clear_color = active_camera_component.clear_color; + + // Build visible set from the render queue. + self.visible_pre_draw_buffer.clear(); + for render_queue_item in world.render_queue.iter() { + let key_fields = decompose_render_queue_key(render_queue_item.key); + let mesh_handle = RendererMeshHandle::new( + key_fields.mesh_index.into(), + NonZeroU32::new(key_fields.mesh_version.into()).unwrap(), + ); + let material_handle = RendererMaterialHandle::new( + key_fields.material_index.into(), + NonZeroU32::new(key_fields.material_version.into()).unwrap(), + ); + + let entity_index = render_queue_item.entity_index as usize; + let transform = world + .transform_components + .data + .get(entity_index) + .unwrap() + .as_ref() + .unwrap(); + // Rotation stored in radians (game convention); match old shader's X*Y*Z quaternion order. + let q = Quat::from_rotation_x(transform.rotation.x) + * Quat::from_rotation_y(transform.rotation.y) + * Quat::from_rotation_z(transform.rotation.z); + let model_mat: Mat4 = + Mat4::from_scale_rotation_translation(transform.scale, q, transform.position); + let mvp: [[f32; 4]; 4] = (vp_mat * model_mat).to_cols_array_2d(); + let model: [[f32; 4]; 4] = model_mat.to_cols_array_2d(); + + self.visible_pre_draw_buffer.push(VisiblePreDraw { + material_handle, + mesh_handle, + entity_index: render_queue_item.entity_index, + mvp, + model, + }); + } + + // Sort visible set by material (Pipeline → Material → Mesh). + self.visible_pre_draw_buffer.sort_by_key(|visible| { + ((visible.material_handle.data().version.get() as u64) << 32) + | (visible.material_handle.data().index as u64) + }); + + // Build group/batch command list. + self.groups_buffer.clear(); + let pipeline_ptr: *const wgpu::RenderPipeline = { + let state = get_state(self); + &state.pipeline.pipeline as *const _ + }; + for visible in &self.visible_pre_draw_buffer { + let need_new_group = self + .groups_buffer + .last() + .map(|group| group.material_handle != visible.material_handle) + .unwrap_or(true); + if need_new_group { + self.groups_buffer.push(GroupCmd { + pipeline: pipeline_ptr, + material_handle: visible.material_handle, + batches: Vec::new(), + }); + } + let group = self.groups_buffer.last_mut().unwrap(); + if let Some(batch) = group + .batches + .iter_mut() + .find(|batch| batch.mesh_handle == visible.mesh_handle) + { + batch.instances.push(PerDrawStd140 { mvp: visible.mvp, model: visible.model }); + } else { + group.batches.push(MeshBatch { + mesh_handle: visible.mesh_handle, + instances: vec![PerDrawStd140 { mvp: visible.mvp, model: visible.model }], + base_offset_u32: 0, + }); + } + } + + // Upload per-draw data to the ring buffer. + let needed: u64 = self + .groups_buffer + .iter() + .map(|group| { + group.batches.iter().map(|batch| batch.instances.len() as u64).sum::() + }) + .sum(); + if self.per_draw_capacity < needed { + log::error!( + "PassPBRStatic: per-draw capacity exceeded (needed={}, capacity={})", + needed, + self.per_draw_capacity + ); + } + self.staging_buffer.clear(); + let mut next_offset_u32: u32 = 0; + for group in self.groups_buffer.iter_mut() { + for batch in group.batches.iter_mut() { + batch.base_offset_u32 = next_offset_u32; + for per_draw in &batch.instances { + self.staging_buffer.extend_from_slice(bytemuck::bytes_of(per_draw)); + let pad = + (self.per_draw_stride as usize) - std::mem::size_of::(); + self.staging_buffer.extend(std::iter::repeat(0u8).take(pad)); + next_offset_u32 = + next_offset_u32.wrapping_add(self.per_draw_stride as u32); + } + } + } + { + let state_ref: &PassPBRStaticState = + unsafe { self.state.as_ref().unwrap_unchecked() }; + renderer + .get_queue() + .write_buffer(&state_ref.per_draw_buffer, 0, &self.staging_buffer); + } + + let depth_view = renderer + .get_render_target_view(self.depth_texture.unwrap()) + .unwrap(); + + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("pass_pbr_static_render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: clear_color.x as f64, + g: clear_color.y as f64, + b: clear_color.z as f64, + a: 1.0, + }), + 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, + }); + + // Record draw commands: bind pipeline → globals → material → per-draw per instance. + let state_ref: &PassPBRStaticState = + unsafe { self.state.as_ref().unwrap_unchecked() }; + for group in &self.groups_buffer { + render_pass.set_pipeline(unsafe { &*group.pipeline }); + render_pass.set_bind_group( + MATERIAL_BIND_GROUP_GLOBALS as u32, + &state_ref.globals_bind_group, + &[], + ); + + let mat = world + .resources + .get_resource::(&group.material_handle) + .unwrap(); + + // Skip materials that don't have PBR-compatible bind groups. + let (Some(textures_bg), Some(params_bg)) = + (mat.textures_bind_group.as_ref(), mat.parameters_bind_group.as_ref()) + else { + continue; + }; + render_pass.set_bind_group(MATERIAL_BIND_GROUP_TEXTURES as u32, textures_bg, &[]); + render_pass.set_bind_group(MATERIAL_BIND_GROUP_PARAMS as u32, params_bg, &[]); + + for batch in &group.batches { + let mesh = world + .resources + .get_resource::(&batch.mesh_handle) + .unwrap(); + render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); + render_pass.set_index_buffer( + mesh.index_buffer.slice(..), + wgpu::IndexFormat::Uint32, + ); + for instance_index in 0..batch.instances.len() { + let offset = batch + .base_offset_u32 + .wrapping_add((instance_index as u32) * (self.per_draw_stride as u32)); + render_pass.set_bind_group( + MATERIAL_BIND_GROUP_PERDRAW as u32, + &state_ref.per_draw_bind_group, + &[offset], + ); + render_pass.draw_indexed(0..mesh.index_count, 0, 0..1); + } + } + } + + Ok(()) + } +} diff --git a/engine/pill_engine/src/graphics/render_queue.rs b/engine/pill_engine/src/graphics/render_queue.rs index 41167a79..a49d8bbc 100644 --- a/engine/pill_engine/src/graphics/render_queue.rs +++ b/engine/pill_engine/src/graphics/render_queue.rs @@ -1,6 +1,10 @@ use crate::{ config::*, - resources::{Material, MaterialHandle, Mesh, MeshHandle, ResourceManager, Shader}, + renderer::resources::{RendererMaterial, RendererMesh, RendererShader}, + resources::{ + Material, MaterialHandle, Mesh, MeshHandle, PBRMaterial, PBRMaterialHandle, + ResourceManager, Shader, + }, }; use pill_core::PillSlotMapKey; @@ -19,7 +23,7 @@ use std::{ pub struct RenderQueue { pub items: Vec, - pub context_change_indices: Vec, // Indices of items after which context changes (e.g. material or mesh change) + pub context_change_indices: Vec, } // --- Render queue item @@ -89,10 +93,9 @@ where + Not, { pub fn new(mask_range: core::ops::Range) -> Self { - // Compile-time evaluable function let one: T = T::from(1_u8); let two: T = T::from(2_u8); - let mask_range_length = mask_range.end - mask_range.start + one; //if mask_range.start == zero { mask_range.end + one } else { mask_range.end - mask_range.start }; + let mask_range_length = mask_range.end - mask_range.start + one; let mask_size: T = T::from(std::mem::size_of::() as u8 * 8); let mask_shift: T = mask_size - mask_range.end - one; let mask: T = pill_core::create_bitmask_from_range::(&mask_range); @@ -113,25 +116,65 @@ pub fn compose_render_queue_key( material_handle: &MaterialHandle, mesh_handle: &MeshHandle, ) -> Result { - let material = resource_manager.get_resource::(material_handle)?; - let shader = resource_manager.get_resource::(&material.shader_handle)?; - let mesh = resource_manager.get_resource::(mesh_handle)?; - - let render_queue_key: RenderQueueKey = ((RENDER_QUEUE_KEY_ORDER.max - material.rendering_order as RenderQueueKey) << RENDER_QUEUE_KEY_ORDER.mask_shift) | // Order has to be inverted for proper sorting - - // 1. Shader - defining rendering pipeline - ((shader.renderer_resource_handle.unwrap().data().index as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) | - ((shader.renderer_resource_handle.unwrap().data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) | - - // 2. Material - defining material properties, same rendering pipeline - ((material.renderer_resource_handle.unwrap().data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) | - ((material.renderer_resource_handle.unwrap().data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) | - - // 3. Mesh - defining mesh properties, same material thus same rendering pipeline - ((mesh.renderer_resource_handle.unwrap().data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift ) | - ((mesh.renderer_resource_handle.unwrap().data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift); + #[cfg(feature = "headless")] + return Ok(0); + + #[cfg(not(feature = "headless"))] + { + let material = resource_manager.get_resource::(material_handle)?; + let shader = resource_manager.get_resource::(&material.shader_handle)?; + let mesh = resource_manager.get_resource::(mesh_handle)?; + + let renderer_shader_handle = + resource_manager.get_resource_handle::(&shader.name)?; + let renderer_material_handle = + resource_manager.get_resource_handle::(&material.name)?; + let renderer_mesh_handle = + resource_manager.get_resource_handle::(&mesh.name)?; + + let render_queue_key: RenderQueueKey = ((RENDER_QUEUE_KEY_ORDER.max - material.rendering_order as RenderQueueKey) << RENDER_QUEUE_KEY_ORDER.mask_shift) + | ((renderer_shader_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) + | ((renderer_shader_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) + | ((renderer_material_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) + | ((renderer_material_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) + | ((renderer_mesh_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) + | ((renderer_mesh_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift); + + Ok(render_queue_key) + } +} - Ok(render_queue_key) +// PBR path: PBRMaterial always uses default lit shader +pub fn compose_pbr_render_queue_key( + resource_manager: &ResourceManager, + material_handle: PBRMaterialHandle, + mesh_handle: &MeshHandle, +) -> Result { + #[cfg(feature = "headless")] + return Ok(0); + + #[cfg(not(feature = "headless"))] + { + let material = resource_manager.get_resource::(&material_handle)?; + let mesh = resource_manager.get_resource::(mesh_handle)?; + + let renderer_shader_handle = + resource_manager.get_resource_handle::(DEFAULT_LIT_SHADER_NAME)?; + let renderer_material_handle = + resource_manager.get_resource_handle::(&material.name)?; + let renderer_mesh_handle = + resource_manager.get_resource_handle::(&mesh.name)?; + + let render_queue_key: RenderQueueKey = + ((renderer_shader_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) + | ((renderer_shader_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) + | ((renderer_material_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) + | ((renderer_material_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) + | ((renderer_mesh_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) + | ((renderer_mesh_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift); + + Ok(render_queue_key) + } } pub struct RenderQueueKeyFields { @@ -146,7 +189,6 @@ pub struct RenderQueueKeyFields { // Decomposes pill engine render queue key into separate fields pub fn decompose_render_queue_key(render_queue_key: RenderQueueKey) -> RenderQueueKeyFields { - // [TODO] What if render queue key is not valid let order: u8 = ((render_queue_key & RENDER_QUEUE_KEY_ORDER.mask as RenderQueueKey) >> RENDER_QUEUE_KEY_ORDER.mask_shift as RenderQueueKey) as u8; let shader_index: u8 = ((render_queue_key & RENDER_QUEUE_KEY_SHADER_INDEX.mask) @@ -197,7 +239,7 @@ fn get_render_queue_key_item_range(render_queue_item_index: u8) -> Range = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_RENDERING_ORDER_IDX)); pub static ref RENDER_QUEUE_KEY_SHADER_INDEX: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_SHADER_INDEX_IDX)); pub static ref RENDER_QUEUE_KEY_SHADER_VERSION: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_SHADER_VERSION_IDX)); diff --git a/engine/pill_engine/src/graphics/renderer.rs b/engine/pill_engine/src/graphics/renderer.rs index cf754ec5..0638674f 100644 --- a/engine/pill_engine/src/graphics/renderer.rs +++ b/engine/pill_engine/src/graphics/renderer.rs @@ -1,10 +1,11 @@ #![allow(clippy::too_many_arguments)] use crate::{ app_config::EngineConfig, - ecs::{CameraComponent, ComponentStorage, EntityHandle, TransformComponent}, + ecs::{CameraComponent, ComponentStorage, EguiClient, EntityHandle, TransformComponent}, graphics::RenderQueueItem, - internal::{MaterialParameter, MaterialTexture, MeshData}, - resources::{ShaderParameterSlot, ShaderTextureSlot, TextureType}, + resources::{ + ResourceManager, ShaderParameterSlot, ShaderTextureSlot, TextureType, + }, }; use pill_core::Timer; @@ -34,16 +35,85 @@ pill_core::define_new_pill_slotmap_key! { pub struct RendererShaderHandle; } +// --- Pass API types --- + +pub struct WorldQuery<'a> { + pub active_camera: EntityHandle, + pub render_queue: &'a [RenderQueueItem], + pub camera_components: &'a ComponentStorage, + pub transform_components: &'a ComponentStorage, + pub delta_time: f32, + pub resources: &'a ResourceManager, +} + +#[derive(Clone, Copy)] +pub struct BufferDesc<'a> { + pub label: Option<&'a str>, + pub byte_size: u64, + pub usage: wgpu::BufferUsages, +} + +#[derive(Clone, Copy, Debug)] +pub struct ShaderDesc<'a> { + pub source: &'a str, + pub entry_func: &'a str, +} + +#[derive(Clone, Debug)] +pub struct PipelineV2Desc<'a> { + pub label: Option<&'a str>, + pub vs: ShaderDesc<'a>, + pub ps: ShaderDesc<'a>, + pub vertex_buffers: &'a [wgpu::VertexBufferLayout<'a>], + pub bind_groups: Vec>, + pub targets: &'a [Option], + pub depth_stencil: Option, + pub multisample: wgpu::MultisampleState, + pub primitive: wgpu::PrimitiveState, +} + +pub struct RendererTargetDesc { + pub name: String, + pub format: wgpu::TextureFormat, + pub width: u32, + pub height: u32, +} + +pub struct PipelineV2 { + pub pipeline: wgpu::RenderPipeline, + pub bind_group_layouts: Vec, +} + +// --- Pass trait --- + +pub trait Pass { + /// Returns a short human-readable identifier used in profiling labels. + fn get_label(&self) -> &str; + /// Allocates all GPU resources the pass needs; called once before the first frame. + fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()>; + /// Records this pass's GPU commands into `encoder` for the current frame. + fn draw( + &mut self, + encoder: &mut wgpu::CommandEncoder, + renderer: &mut dyn PillRenderer, + frame: &wgpu::SurfaceTexture, + view: &wgpu::TextureView, + world: &WorldQuery<'_>, + ) -> Result<()>; +} + // --- Renderer trait definition --- pub trait PillRenderer { + /// Creates the renderer synchronously; panics on WASM where async init is required. fn new(window: Arc, config: EngineConfig) -> Result where Self: Sized; // --- Create --- - fn create_shader( + /// Compiles vertex and fragment WGSL into a `RendererShader` with slot and bind-group metadata. + fn create_shader_struct( &mut self, name: &str, vertex_wgsl: &str, @@ -52,84 +122,72 @@ pub trait PillRenderer { parameter_slots: &[(String, ShaderParameterSlot)], pass_engine_parameters: bool, pass_camera_parameters: bool, - ) -> Result; - - fn create_material( - &mut self, - name: &str, - renderer_shader_handle: RendererShaderHandle, - textures: &[(String, MaterialTexture)], - parameters: &HashMap, - ) -> Result; - - fn create_texture( - &mut self, - name: &str, - rgba: &[u8], - width: u32, - height: u32, - texture_type: TextureType, - ) -> Result; - - fn create_mesh(&mut self, name: &str, mesh_data: &MeshData) -> Result; + ) -> Result; + /// Allocates a GPU camera uniform buffer and returns a handle to it. fn create_camera(&mut self) -> Result; - // --- Update --- - - fn update_material_textures( - &mut self, - renderer_material_handle: RendererMaterialHandle, - textures: &[(String, MaterialTexture)], - ) -> Result<()>; - - fn update_material_parameters( - &mut self, - renderer_material_handle: RendererMaterialHandle, - parameters: &HashMap, - ) -> Result<()>; - // --- Destroy --- - fn destroy_shader(&mut self, renderer_shader_handle: RendererShaderHandle) -> Result<()>; - - fn destroy_material(&mut self, renderer_material_handle: RendererMaterialHandle) -> Result<()>; - - fn destroy_texture(&mut self, renderer_texture_handle: RendererTextureHandle) -> Result<()>; - - fn destroy_mesh(&mut self, renderer_mesh_handle: RendererMeshHandle) -> Result<()>; - + /// Releases the GPU resources associated with the given camera handle. fn destroy_camera(&mut self, renderer_camera_handle: RendererCameraHandle) -> Result<()>; // --- Other --- + /// Reconfigures the swap chain and depth texture after a window resize. fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize); #[cfg(feature = "debug_ui")] + /// Forwards a winit window event to the egui input handler. fn pass_input_to_egui(&mut self, event: &winit::event::WindowEvent) -> Result<()>; - #[cfg(feature = "debug_ui")] + /// Drives the full frame: acquires the surface, runs all passes, and presents. fn render( &mut self, active_camera_entity_handle: EntityHandle, render_queue: &[RenderQueueItem], camera_component_storage: &ComponentStorage, transform_component_storage: &ComponentStorage, - egui_ui: Box, delta_time: f32, timer: &mut Timer, + resource_manager: &ResourceManager, ) -> Result<()>; - #[cfg(not(feature = "debug_ui"))] - fn render( - &mut self, - active_camera_entity_handle: EntityHandle, - render_queue: &[RenderQueueItem], - camera_component_storage: &ComponentStorage, - transform_component_storage: &ComponentStorage, - delta_time: f32, - timer: &mut Timer, - ) -> Result<()>; + // --- Pass API --- + + /// Replaces the current pass chain; calls `Pass::init` on each new pass before storing it. + fn set_passes(&mut self, passes: Vec>) -> Result<()>; + + /// Installs the default pass chain (scene + optional egui) on first frame bootstrap. + fn init_default_passes(&mut self, egui_client: Arc) -> Result<()>; + + /// Returns the wgpu `Device`; required by passes that allocate their own GPU resources. + fn get_device(&self) -> &wgpu::Device; + + /// Returns the wgpu `Queue`; required by passes that upload data each frame. + fn get_queue(&self) -> &wgpu::Queue; + + /// Returns the surface texture format; used when creating pass render pipelines. + fn get_surface_format(&self) -> wgpu::TextureFormat; + + /// Allocates a GPU buffer with the given descriptor. + fn create_buffer(&mut self, desc: BufferDesc) -> Result; + + /// Compiles a render pipeline from WGSL sources and bind group layout descriptors. + fn create_pipeline_v2(&mut self, desc: PipelineV2Desc) -> Result; + + /// Creates an off-screen render target texture and returns its handle. + fn create_render_target(&mut self, desc: RendererTargetDesc) -> Result; + + /// Creates a depth texture sized to the current surface and returns its handle. + fn create_depth_texture(&mut self, label: &str) -> Result; + + /// Returns the texture view for a previously created render target, or `None` if not found. + fn get_render_target_view( + &self, + handle: RendererTextureHandle, + ) -> Option<&wgpu::TextureView>; + } pub type Renderer = Box; diff --git a/engine/pill_engine/src/lib.rs b/engine/pill_engine/src/lib.rs index 46f92b24..e7da909a 100644 --- a/engine/pill_engine/src/lib.rs +++ b/engine/pill_engine/src/lib.rs @@ -7,6 +7,7 @@ mod config; mod ecs; mod engine; mod graphics; +pub mod renderer; mod resources; // --- Macros --- @@ -68,9 +69,9 @@ pub mod game { }, engine::{Engine, KeyboardKey, MouseButton, PillGame}, resources::{ - Material, MaterialHandle, Mesh, MeshHandle, Resource, ResourceLoader, ResourceStorage, - Shader, ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, Texture, - TextureHandle, TextureType, + Material, MaterialHandle, Mesh, MeshHandle, PBRMaterial, PBRMaterialHandle, Resource, + ResourceLoader, ResourceStorage, Shader, ShaderParameterSlot, ShaderParameterType, + ShaderTextureSlot, Texture, TextureHandle, TextureType, }, }; @@ -99,23 +100,27 @@ mod internal_mod { get_renderer_resource_handle_from_camera_component, networking_system_client, networking_system_server, update_transform_matrices, AudioListenerComponent, AudioManagerComponent, AudioSourceComponent, CameraAspectRatio, CameraComponent, - ComponentStorage, EntityHandle, EntityUpdate, InputComponent, MeshRenderingComponent, - NetworkEntityAction, NetworkEntityState, NetworkManagerComponent, NetworkSide, - NetworkStateComponent, NetworkUpdatePayload, Scene, TimeComponent, TransformComponent, + ComponentStorage, EguiClient, EntityHandle, EntityUpdate, + InputComponent, MeshRenderingComponent, NetworkEntityAction, NetworkEntityState, + NetworkManagerComponent, NetworkSide, NetworkStateComponent, NetworkUpdatePayload, + RenderStateComponent, Scene, TimeComponent, TransformComponent, }, engine::{Engine, PillGame}, graphics::{ - decompose_render_queue_key, PillRenderer, RenderQueueItem, RenderQueueKey, - RenderQueueKeyFields, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, - RendererShaderHandle, RendererTextureHandle, RENDER_QUEUE_KEY_ORDER, + decompose_render_queue_key, BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, + RenderQueueItem, RenderQueueKey, RenderQueueKeyFields, RendererCameraHandle, + RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle, RendererTargetDesc, + RendererTextureHandle, ShaderDesc, WorldQuery, RENDER_QUEUE_KEY_ORDER, }, resources::{ - get_renderer_texture_handle_from_material_texture, Material, MaterialHandle, - MaterialParameter, MaterialTexture, Mesh, MeshData, MeshHandle, MeshVertex, - ResourceLoader, ResourceManager, ShaderParameterSlot, ShaderParameterType, - ShaderTextureSlot, Texture, TextureHandle, TextureType, + Material, MaterialHandle, MaterialParameter, MaterialTexture, Mesh, MeshData, + MeshHandle, MeshVertex, PBRMaterial, PBRMaterialHandle, ResourceLoader, ResourceManager, + ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, Texture, TextureHandle, + TextureType, }, }; + #[cfg(feature = "debug_ui")] + pub use crate::ecs::EguiManagerComponent; } #[cfg(target_arch = "wasm32")] @@ -126,22 +131,26 @@ mod internal_mod { ecs::{ get_model_matrix, get_normal_matrix, get_renderer_resource_handle_from_camera_component, update_transform_matrices, - CameraAspectRatio, CameraComponent, ComponentStorage, EntityHandle, InputComponent, - MeshRenderingComponent, Scene, TimeComponent, TransformComponent, + CameraAspectRatio, CameraComponent, ComponentStorage, EguiClient, + EntityHandle, InputComponent, MeshRenderingComponent, RenderStateComponent, Scene, + TimeComponent, TransformComponent, }, engine::{Engine, PillGame}, graphics::{ - decompose_render_queue_key, PillRenderer, RenderQueueItem, RenderQueueKey, - RenderQueueKeyFields, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, - RendererShaderHandle, RendererTextureHandle, RENDER_QUEUE_KEY_ORDER, + decompose_render_queue_key, BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, + RenderQueueItem, RenderQueueKey, RenderQueueKeyFields, RendererCameraHandle, + RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle, RendererTargetDesc, + RendererTextureHandle, ShaderDesc, WorldQuery, RENDER_QUEUE_KEY_ORDER, }, resources::{ - get_renderer_texture_handle_from_material_texture, Material, MaterialHandle, - MaterialParameter, MaterialTexture, Mesh, MeshData, MeshHandle, MeshVertex, - ResourceLoader, ResourceManager, ShaderParameterSlot, ShaderParameterType, - ShaderTextureSlot, Texture, TextureHandle, TextureType, + Material, MaterialHandle, MaterialParameter, MaterialTexture, Mesh, MeshData, + MeshHandle, MeshVertex, PBRMaterial, PBRMaterialHandle, ResourceLoader, ResourceManager, + ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, Texture, TextureHandle, + TextureType, }, }; + #[cfg(feature = "debug_ui")] + pub use crate::ecs::EguiManagerComponent; } #[cfg(feature = "internal")] diff --git a/engine/pill_engine/src/renderer/config.rs b/engine/pill_engine/src/renderer/config.rs new file mode 100644 index 00000000..a490a60a --- /dev/null +++ b/engine/pill_engine/src/renderer/config.rs @@ -0,0 +1,7 @@ +pub const MAX_INSTANCE_PER_DRAWCALL_COUNT: usize = 10000; +pub const INITIAL_INSTANCE_VECTOR_CAPACITY: usize = 10000; + +pub const ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX: u32 = 0; +pub const CAMERA_PARAMETERS_BIND_GROUP_LAYOUT_INDEX: u32 = 1; +pub const MATERIAL_PARAMETERS_BIND_GROUP_LAYOUT_INDEX: u32 = 2; +pub const MATERIAL_TEXTURES_BIND_GROUP_LAYOUT_INDEX: u32 = 3; diff --git a/engine/pill_renderer/src/drawers/egui_drawer.rs b/engine/pill_engine/src/renderer/drawers/egui_drawer.rs similarity index 94% rename from engine/pill_renderer/src/drawers/egui_drawer.rs rename to engine/pill_engine/src/renderer/drawers/egui_drawer.rs index b45b5b3c..9ccebacc 100644 --- a/engine/pill_renderer/src/drawers/egui_drawer.rs +++ b/engine/pill_engine/src/renderer/drawers/egui_drawer.rs @@ -63,7 +63,6 @@ impl EguiDrawer { encoder: &mut wgpu::CommandEncoder, window_surface_view: &wgpu::TextureView, screen_descriptor: egui_wgpu::ScreenDescriptor, - // run_ui: impl FnOnce(&egui::Context), mut run_ui: Box, timer: &mut Timer, ) -> Result<()> { @@ -72,9 +71,6 @@ impl EguiDrawer { let window = &self.window; let raw_input = self.state.take_egui_input(window); - // let full_output = self.context.run(raw_input, |ctx| { - // (&mut run_ui)(ctx); - // }); let full_output = self.context.run(raw_input, |_| { run_ui(&self.context); }); @@ -107,7 +103,6 @@ impl EguiDrawer { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, - //depth_slice: None, })], depth_stencil_attachment: None, label: Some("egui main render pass"), @@ -123,8 +118,6 @@ impl EguiDrawer { self.renderer .render(&mut *render_pass, &tris, &screen_descriptor); - // let _ = drop(render_pass); - for texture_id in &full_output.textures_delta.free { self.renderer.free_texture(texture_id) } diff --git a/engine/pill_renderer/src/drawers/mesh_drawer.rs b/engine/pill_engine/src/renderer/drawers/mesh_drawer.rs similarity index 70% rename from engine/pill_renderer/src/drawers/mesh_drawer.rs rename to engine/pill_engine/src/renderer/drawers/mesh_drawer.rs index f2099971..016da7ff 100644 --- a/engine/pill_renderer/src/drawers/mesh_drawer.rs +++ b/engine/pill_engine/src/renderer/drawers/mesh_drawer.rs @@ -1,22 +1,21 @@ use std::{num::NonZeroU32, ops::Range}; -use crate::{ +use crate::renderer::{ config::{ CAMERA_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, INITIAL_INSTANCE_VECTOR_CAPACITY, MATERIAL_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, MATERIAL_TEXTURES_BIND_GROUP_LAYOUT_INDEX, }, - resources::{RendererCamera, RendererResourceStorage, RendererShader}, - Instance, + instance::Instance, + resources::{EngineParameters, RendererCamera, RendererMaterial, RendererMesh, RendererShader}, }; -use pill_core::{debug, LogContext, PillStyle, RendererError, Timer}; -use pill_engine::{ - internal::{ - RenderQueueItem, RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle, - TransformComponent, - }, - ComponentStorage, +use crate::{ + ecs::ComponentStorage, + graphics::{RenderQueueItem, RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle}, + internal::{TransformComponent, decompose_render_queue_key}, + resources::ResourceManager, }; +use pill_core::{debug, LogContext, PillStyle, RendererError, Timer}; use pill_core::Result; @@ -29,7 +28,7 @@ pub struct DrawingContext { material_name: String, mesh_handle: Option, mesh_name: String, - mesh_index_count: u32, // Number of indices in the current mesh + mesh_index_count: u32, accumulated_instance_range: Range, accumulated_instance_count: u32, @@ -80,23 +79,22 @@ impl DrawingContext { pub fn change_rendering_order(&mut self, new_order: u8) { self.rendering_order = new_order; - self.rendering_context_change_number += 1; debug!(LogContext::Frame => "Rendering order changed to: {}", self.rendering_order); } pub fn change_shader( &mut self, - renderer_resource_storage: &RendererResourceStorage, + resource_manager: &ResourceManager, + engine_parameters: &EngineParameters, shader_handle: RendererShaderHandle, render_pass: &mut wgpu::RenderPass, camera: &RendererCamera, ) { self.shader_handle = Some(shader_handle); - let shader: &RendererShader = renderer_resource_storage - .shaders - .get(shader_handle) - .unwrap(); + let shader: &RendererShader = resource_manager + .get_resource::(&shader_handle) + .expect("RendererShader not found"); self.shader_name = shader.name.clone(); debug!(LogContext::Frame => "Changing shader to: {}", self.shader_name.name_style()); @@ -106,10 +104,9 @@ impl DrawingContext { if shader.pass_engine_parameters { render_pass.set_bind_group( ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, - &renderer_resource_storage.engine_parameters.bind_group, + &engine_parameters.bind_group, &[], ); - debug!(LogContext::Frame => "Engine parameters bound"); } if shader.pass_camera_parameters { @@ -118,35 +115,29 @@ impl DrawingContext { &camera.bind_group, &[], ); - debug!(LogContext::Frame => "Camera parameters bound"); } self.rendering_context_change_number += 1; - debug!(LogContext::Frame => "Renderer pipeline shader changed to: {}", self.shader_name.name_style()); } pub fn change_material( &mut self, - renderer_resource_storage: &RendererResourceStorage, + resource_manager: &ResourceManager, material_handle: RendererMaterialHandle, render_pass: &mut wgpu::RenderPass, ) { self.material_handle = Some(material_handle); - let material = renderer_resource_storage - .materials - .get(material_handle) - .unwrap(); + let material = resource_manager + .get_resource::(&material_handle) + .expect("RendererMaterial not found"); self.material_name = material.name.clone(); - debug!(LogContext::Frame => "Changing material to: {}", self.material_name.name_style()); - if let Some(ref parameters_bind_group) = material.parameters_bind_group { render_pass.set_bind_group( MATERIAL_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, parameters_bind_group, &[], ); - debug!(LogContext::Frame => "Material parameters bound"); } if let Some(ref texture_bind_group) = material.textures_bind_group { @@ -155,21 +146,21 @@ impl DrawingContext { texture_bind_group, &[], ); - debug!(LogContext::Frame => "Material textures bound"); } self.rendering_context_change_number += 1; - debug!(LogContext::Frame => "Renderer pipeline material changed to: {}", self.material_name.name_style()); } pub fn change_mesh( &mut self, - renderer_resource_storage: &RendererResourceStorage, + resource_manager: &ResourceManager, mesh_handle: RendererMeshHandle, render_pass: &mut wgpu::RenderPass, ) { self.mesh_handle = Some(mesh_handle); - let mesh = renderer_resource_storage.meshes.get(mesh_handle).unwrap(); + let mesh = resource_manager + .get_resource::(&mesh_handle) + .expect("RendererMesh not found"); self.mesh_name = mesh.name.clone(); self.mesh_index_count = mesh.index_count; @@ -177,7 +168,6 @@ impl DrawingContext { render_pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); self.rendering_context_change_number += 1; - debug!(LogContext::Frame => "Renderer pipeline mesh changed to: {}", self.mesh_name.name_style()); } } @@ -189,7 +179,6 @@ pub struct MeshDrawer { impl MeshDrawer { pub fn new(device: &wgpu::Device, max_instance_batch_size: u32) -> Self { - // Create instance buffer let buffer_size = (size_of::() * max_instance_batch_size as usize) as u64; let instance_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("instance_buffer"), @@ -208,52 +197,28 @@ impl MeshDrawer { #[allow(clippy::too_many_arguments)] pub fn record_draw_commands( &mut self, - // Resources queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, - renderer_resource_storage: &RendererResourceStorage, + resource_manager: &ResourceManager, + engine_parameters: &EngineParameters, color_attachment: wgpu::RenderPassColorAttachment, depth_stencil_attachment: wgpu::RenderPassDepthStencilAttachment, - // Rendring data camera: &RendererCamera, render_queue: &[RenderQueueItem], transform_component_storage: &ComponentStorage, timer: &mut Timer, - // profiler: &mut Profiler, ) -> Result<()> { timer.record("Prepare render pass"); debug!(LogContext::Frame => "Recording mesh draw commands"); - //let _timestamp_query_start = profiler.write_timestamp(encoder, "xx"); - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("main pass"), color_attachments: &[Some(color_attachment.clone())], depth_stencil_attachment: Some(depth_stencil_attachment.clone()), timestamp_writes: None, - occlusion_query_set: None, // profiler.get_occlusion_query_set(), // immut borrow ends after this stmt - //timestamp_writes: None, - // occlusion_query_set: profiler.get_occlusion_query_set(), // immut borrow ends after this stmt - // timestamp_writes: Some(wgpu::RenderPassTimestampWrites { - // query_set: profiler.get_timestamp_query_set().unwrap(), - // beginning_of_pass_write_index: Some(0), - // end_of_pass_write_index: Some(1), - // }), + occlusion_query_set: None, }); - // let _pipeline_statistics_query_start = profiler.begin_pipeline_statistics_query(&mut render_pass); - //let _occlusion_query_start = profiler.begin_occlusion_query(&mut render_pass); - - // let mut current_rendering_order: u8 = 0; - - // let mut current_shader_handle: Option = None; - // let mut current_shader_name = ""; - // let mut current_material_handle: Option = None; - // let mut current_material_name = ""; - // let mut current_mesh_handle: Option = None; - // let mut current_mesh_name = ""; - // let mut current_mesh_index_count: u32 = 0; // Number of indices in the current mesh - let mut current_drawing_context = DrawingContext::default(); for (i, instance_batch) in render_queue @@ -265,12 +230,10 @@ impl MeshDrawer { current_drawing_context.instance_batch_size = batch_size as u32; timer.begin_context(format!("Prepare draw instance batch {}", i)); - timer.record("Write instance buffer".to_string()); - // Prepare instance data and load it to buffer self.instances.clear(); - self.instances.reserve(instance_batch.len()); // Pre-allocate exact capacity + self.instances.reserve(instance_batch.len()); for render_queue_item in instance_batch { let transform_slot = transform_component_storage @@ -288,21 +251,19 @@ impl MeshDrawer { &self.instance_buffer, 0, bytemuck::cast_slice(&self.instances), - ); // Update instance buffer + ); - render_pass.set_vertex_buffer(1, self.instance_buffer.slice(..)); // Set instance buffer + render_pass.set_vertex_buffer(1, self.instance_buffer.slice(..)); timer.record("Record draw instances commands"); - // Reset instance range for each batch current_drawing_context.accumulated_instance_range = 0..0; current_drawing_context.accumulated_instance_count = 0; for (j, render_queue_item) in instance_batch.iter().enumerate() { let render_queue_key_fields = - pill_engine::internal::decompose_render_queue_key(render_queue_item.key); + decompose_render_queue_key(render_queue_item.key); - // Recreate resource handles let renderer_shader_handle = RendererShaderHandle::new( render_queue_key_fields.shader_index.into(), NonZeroU32::new(render_queue_key_fields.shader_version.into()).unwrap(), @@ -316,64 +277,51 @@ impl MeshDrawer { NonZeroU32::new(render_queue_key_fields.mesh_version.into()).unwrap(), ); - // Check for rendering order change if current_drawing_context.rendering_order > render_queue_key_fields.order { current_drawing_context.record_draw_accumulated_instances(&mut render_pass); current_drawing_context.change_rendering_order(render_queue_key_fields.order); } - // Check for shader change if current_drawing_context.shader_handle != Some(renderer_shader_handle) { current_drawing_context.record_draw_accumulated_instances(&mut render_pass); current_drawing_context.change_shader( - renderer_resource_storage, + resource_manager, + engine_parameters, renderer_shader_handle, &mut render_pass, camera, ); } - // Check for material change if current_drawing_context.material_handle != Some(renderer_material_handle) { current_drawing_context.record_draw_accumulated_instances(&mut render_pass); current_drawing_context.change_material( - renderer_resource_storage, + resource_manager, renderer_material_handle, &mut render_pass, ); } - // Check for mesh change if current_drawing_context.mesh_handle != Some(renderer_mesh_handle) { current_drawing_context.record_draw_accumulated_instances(&mut render_pass); current_drawing_context.change_mesh( - renderer_resource_storage, + resource_manager, renderer_mesh_handle, &mut render_pass, ); } - // Add new instance current_drawing_context.accumulate_instance(); - // If last in batch, draw accumulated instances if j == batch_size - 1 { current_drawing_context.record_draw_accumulated_instances(&mut render_pass); } } - timer.end_context()?; // End "Draw instance batch" context + timer.end_context()?; } - // Drop render_pass before finishing encoder - //let _occlusion_query_end = profiler.end_occlusion_query(&mut render_pass); - //let _pipeline_statistics_query_end = profiler.end_pipeline_statistics_query(&mut render_pass); - drop(render_pass); - - // let _timestamp_query_end = profiler.write_timestamp(encoder, "xx12"); - - //queue.submit(std::iter::once(encoder.finish())); Ok(()) } } diff --git a/engine/pill_renderer/src/drawers/mod.rs b/engine/pill_engine/src/renderer/drawers/mod.rs similarity index 100% rename from engine/pill_renderer/src/drawers/mod.rs rename to engine/pill_engine/src/renderer/drawers/mod.rs diff --git a/engine/pill_engine/src/renderer/instance.rs b/engine/pill_engine/src/renderer/instance.rs new file mode 100644 index 00000000..37081931 --- /dev/null +++ b/engine/pill_engine/src/renderer/instance.rs @@ -0,0 +1,49 @@ +use crate::renderer::resources::Vertex; + +use pill_core::Matrix3f; +use crate::ecs::TransformComponent; + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct Instance { + pub(crate) transform: Matrix3f, +} + +impl Instance { + pub fn new(transform_component: &TransformComponent) -> Instance { + Instance { + transform: Matrix3f::from_cols( + transform_component.position, + transform_component.rotation, + transform_component.scale, + ), + } + } +} + +impl Vertex for Instance { + fn data_layout_descriptor<'a>() -> wgpu::VertexBufferLayout<'a> { + use std::mem; + wgpu::VertexBufferLayout { + array_stride: mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 1, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: mem::size_of::<[f32; 3]>() as wgpu::BufferAddress, + shader_location: 2, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: mem::size_of::<[f32; 6]>() as wgpu::BufferAddress, + shader_location: 3, + format: wgpu::VertexFormat::Float32x3, + }, + ], + } + } +} diff --git a/engine/pill_engine/src/renderer/mod.rs b/engine/pill_engine/src/renderer/mod.rs new file mode 100644 index 00000000..17f614f3 --- /dev/null +++ b/engine/pill_engine/src/renderer/mod.rs @@ -0,0 +1,7 @@ +pub mod config; +pub mod drawers; +pub mod instance; +pub mod resources; +pub mod state; + +pub use state::Renderer; diff --git a/engine/pill_renderer/src/resources/engine_parameters.rs b/engine/pill_engine/src/renderer/resources/engine_parameters.rs similarity index 59% rename from engine/pill_renderer/src/resources/engine_parameters.rs rename to engine/pill_engine/src/renderer/resources/engine_parameters.rs index a76b7f21..392730b5 100644 --- a/engine/pill_renderer/src/resources/engine_parameters.rs +++ b/engine/pill_engine/src/renderer/resources/engine_parameters.rs @@ -1,15 +1,21 @@ -use pill_core::Result; +use pill_core::{Result, Vector3f}; use wgpu::util::DeviceExt; -// Layout must match the HLSL `EngineParams` in `include/common.hlsl` (std140): -// vec3 fog_color; // offset 0 (12 bytes) -// float fog_density; // offset 12 (4 bytes) -// // total: 16 bytes +// Layout must match the WGSL `EngineParams_std140_0` compiled from `include/common.hlsl`: +// float delta_time; // offset 0 (4 bytes, @align(16) so 12 bytes padding follow) +// float fog_density; // offset 4 (4 bytes, @align(4), 8 bytes padding to reach @align(16) vec3) +// [padding] // offset 8 (8 bytes) +// vec3 fog_color; // offset 16 (12 bytes, @align(16)) +// [padding] // offset 28 (4 bytes, struct size rounded to 16) +// // total: 32 bytes #[repr(C)] #[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct EngineParametersData { - pub fog_color: [f32; 3], + pub delta_time: f32, pub fog_density: f32, + _pad0: [f32; 2], + pub fog_color: Vector3f, + _pad1: f32, } impl Default for EngineParametersData { @@ -19,21 +25,25 @@ impl Default for EngineParametersData { } impl EngineParametersData { + /// Returns a zeroed instance; all fields are written before the first GPU upload. pub fn new() -> Self { Self { - fog_color: [0.0; 3], + delta_time: 0.0, fog_density: 0.0, + _pad0: [0.0; 2], + fog_color: Vector3f::ZERO, + _pad1: 0.0, } } - pub fn update_data(&mut self, fog_density: f32, fog_color: [f32; 3]) { + /// Writes the latest per-frame values into the CPU-side struct before the GPU upload. + pub fn update_data(&mut self, delta_time: f32, fog_density: f32, fog_color: Vector3f) { + self.delta_time = delta_time; self.fog_density = fog_density; self.fog_color = fog_color; } } -// --- Camera --- - #[derive(Debug)] pub struct EngineParameters { pub parameters_data: EngineParametersData, @@ -43,6 +53,7 @@ pub struct EngineParameters { } impl EngineParameters { + /// Allocates the GPU uniform buffer and bind group; called once during renderer init. pub fn new(device: &wgpu::Device) -> Result { let parameters_data = EngineParametersData::new(); @@ -53,15 +64,14 @@ impl EngineParameters { usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); - // Define engine bind group layout let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("engine_parameters_bind_group_layout"), entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, // (set = X, binding = 0) + binding: 0, visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, // Specifies if this buffer will be changing size or not + has_dynamic_offset: false, min_binding_size: None, }, count: None, @@ -71,24 +81,23 @@ impl EngineParameters { let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { layout: &bind_group_layout, entries: &[wgpu::BindGroupEntry { - binding: 0, // (set = X, binding = 0) + binding: 0, resource: parameters_uniform_buffer.as_entire_binding(), }], label: Some("engine_parameters_bind_group"), }); - let camera = Self { + Ok(Self { parameters_data, parameters_uniform_buffer, bind_group_layout, bind_group, - }; - - Ok(camera) + }) } - pub fn update(&mut self, queue: &wgpu::Queue, fog_density: f32, fog_color: [f32; 3]) { - self.parameters_data.update_data(fog_density, fog_color); + /// Updates the CPU-side data and writes it to the GPU buffer; called once per frame before draw. + pub fn update(&mut self, queue: &wgpu::Queue, delta_time: f32, fog_density: f32, fog_color: Vector3f) { + self.parameters_data.update_data(delta_time, fog_density, fog_color); queue.write_buffer( &self.parameters_uniform_buffer, 0, diff --git a/engine/pill_renderer/src/resources/mod.rs b/engine/pill_engine/src/renderer/resources/mod.rs similarity index 77% rename from engine/pill_renderer/src/resources/mod.rs rename to engine/pill_engine/src/renderer/resources/mod.rs index 96db9239..e7c6ebbf 100644 --- a/engine/pill_renderer/src/resources/mod.rs +++ b/engine/pill_engine/src/renderer/resources/mod.rs @@ -2,22 +2,12 @@ mod engine_parameters; mod renderer_camera; mod renderer_material; mod renderer_mesh; -mod renderer_resource_storage; mod renderer_shader; mod renderer_texture; -// --- Use --- - -pub use renderer_shader::RendererShader; - +pub use engine_parameters::EngineParameters; +pub use renderer_camera::RendererCamera; pub use renderer_material::RendererMaterial; - -pub use renderer_texture::RendererTexture; - pub use renderer_mesh::{RendererMesh, Vertex}; - -pub use renderer_camera::RendererCamera; - -pub use engine_parameters::EngineParameters; - -pub use renderer_resource_storage::RendererResourceStorage; +pub use renderer_shader::RendererShader; +pub use renderer_texture::RendererTexture; diff --git a/engine/pill_renderer/src/resources/renderer_camera.rs b/engine/pill_engine/src/renderer/resources/renderer_camera.rs similarity index 87% rename from engine/pill_renderer/src/resources/renderer_camera.rs rename to engine/pill_engine/src/renderer/resources/renderer_camera.rs index 8d590854..718c942d 100644 --- a/engine/pill_renderer/src/resources/renderer_camera.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_camera.rs @@ -1,4 +1,4 @@ -use pill_engine::internal::{CameraComponent, TransformComponent}; +use crate::ecs::{CameraComponent, TransformComponent}; use pill_core::Result; use pill_core::{Matrix3f, Matrix4f, Vector3f, Vector4f}; @@ -6,19 +6,17 @@ use wgpu::util::DeviceExt; #[rustfmt::skip] pub const OPENGL_TO_WGPU_MATRIX: Matrix4f = Matrix4f::from_cols_array(&[ - 1.0, 0.0, 0.0, 0.0, // column 1 - 0.0, 1.0, 0.0, 0.0, // column 2 - 0.0, 0.0, 0.5, 0.5, // column 3 - 0.0, 0.0, 0.0, 1.0, // column 4 + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 0.5, 0.5, + 0.0, 0.0, 0.0, 1.0, ]); -// --- Camera Uniform --- - #[repr(C)] #[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct CameraParametersData { - pub position: Vector4f, // Camera position - pub view_projection_matrix: Matrix4f, // Perspective manipulation + pub position: Vector4f, + pub view_projection_matrix: Matrix4f, } impl Default for CameraParametersData { @@ -40,7 +38,6 @@ impl CameraParametersData { camera_component: &CameraComponent, transform_component: &TransformComponent, ) { - // Update position self.position = Vector4f::new( transform_component.position.x, transform_component.position.y, @@ -48,7 +45,6 @@ impl CameraParametersData { 0.0, ); - // Update view-projection self.view_projection_matrix = CameraParametersData::calculate_projection_matrix(camera_component) * CameraParametersData::calculate_view_matrix(transform_component); @@ -75,8 +71,6 @@ impl CameraParametersData { } } -// --- Camera --- - #[derive(Debug)] pub struct RendererCamera { pub parameters_data: CameraParametersData, @@ -102,20 +96,18 @@ impl RendererCamera { let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { layout: &camera_bind_group_layout, entries: &[wgpu::BindGroupEntry { - binding: 0, // (set = X, binding = 0) + binding: 0, resource: parameters_uniform_buffer.as_entire_binding(), }], label: Some("camera_parameters_bind_group"), }); - let camera = Self { + Ok(Self { parameters_data, parameters_uniform_buffer, bind_group_layout: camera_bind_group_layout, bind_group, - }; - - Ok(camera) + }) } pub fn update( diff --git a/engine/pill_engine/src/renderer/resources/renderer_material.rs b/engine/pill_engine/src/renderer/resources/renderer_material.rs new file mode 100644 index 00000000..5887b2c9 --- /dev/null +++ b/engine/pill_engine/src/renderer/resources/renderer_material.rs @@ -0,0 +1,213 @@ +#![cfg_attr(debug_assertions, allow(dead_code, unused_imports, unused_variables))] + +use pill_core::{debug, LogContext, PillStyle, PillTypeMapKey, RendererError, Result}; +use crate::{ + config::{DEFAULT_COLOR_TEXTURE_NAME, DEFAULT_NORMAL_TEXTURE_NAME}, + graphics::RendererMaterialHandle, + renderer::resources::{RendererTexture, RendererShader}, + resources::{ + MaterialParameter, Resource, ResourceManager, ResourceStorage, + ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, + }, +}; +use crate::graphics::RendererShaderHandle; +use crate::graphics::RendererTextureHandle; + +use std::collections::HashMap; + +pub struct RendererMaterial { + pub name: String, + pub shader_handle: RendererShaderHandle, + pub parameters_bind_group: Option, + pub textures_bind_group: Option, + pub(crate) parameters_uniform_buffer: Option, +} + +impl RendererMaterial { + /// Creates GPU buffers and bind groups for a material's parameters and textures. + pub fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + resource_manager: &ResourceManager, + name: &str, + shader_handle: RendererShaderHandle, + resolved_textures: &HashMap, + parameters: &HashMap, + ) -> Result { + debug!(LogContext::Rendering => "Creating material {}", name.name_style()); + + let shader = resource_manager + .get_resource::(&shader_handle) + .map_err(|_| -> pill_core::PillError { pill_core::PillError::from(RendererError::RendererResourceNotFound) })?; + + let parameter_slots = &shader.parameter_slots; + let texture_slots = &shader.texture_slots; + + let (parameters_bind_group, parameters_uniform_buffer) = { + if !parameter_slots.is_empty() { + let parameters_uniform_buffer_size = Self::calculate_uniform_size(parameter_slots); + + let parameters_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some(&format!("{}_material_parameters_buffer", name)), + size: parameters_uniform_buffer_size as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + Self::write_parameters_to_buffer( + queue, + ¶meters_uniform_buffer, + parameter_slots, + parameters, + )?; + + let parameters_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(&format!("{}_material_parameters_bind_group", name)), + layout: shader.parameters_bind_group_layout.as_ref().unwrap(), + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: parameters_uniform_buffer.as_entire_binding(), + }], + }); + + (Some(parameters_bind_group), Some(parameters_uniform_buffer)) + } else { + (None, None) + } + }; + + let textures_bind_group = if !texture_slots.is_empty() { + Some(Self::create_textures_bind_group( + device, + resource_manager, + shader.textures_bind_group_layout.as_ref().unwrap(), + &format!("{}_textures", name), + texture_slots, + resolved_textures, + )?) + } else { + None + }; + + Ok(Self { + name: name.to_string(), + shader_handle, + parameters_bind_group, + textures_bind_group, + parameters_uniform_buffer, + }) + } + + fn calculate_uniform_size(parameter_slots: &[(String, ShaderParameterSlot)]) -> usize { + parameter_slots.len() * 16 // each slot is padded to 16 bytes per std140 alignment rules + } + + /// Packs parameter values into a std140-aligned byte buffer and uploads it to the GPU. + pub(crate) fn write_parameters_to_buffer( + queue: &wgpu::Queue, + buffer: &wgpu::Buffer, + parameter_slots: &[(String, ShaderParameterSlot)], + parameters: &HashMap, + ) -> Result<()> { + let mut data = Vec::new(); + + for (slot_name, slot) in parameter_slots { + match slot.parameter_type { + ShaderParameterType::Color => { + if let Some(MaterialParameter::Color(value)) = parameters.get(slot_name) { + data.extend_from_slice(&value.x.to_le_bytes()); + data.extend_from_slice(&value.y.to_le_bytes()); + data.extend_from_slice(&value.z.to_le_bytes()); + data.extend_from_slice(&0.0f32.to_le_bytes()); + } else { + data.extend_from_slice(&[0u8; 16]); + } + } + ShaderParameterType::Scalar => { + if let Some(MaterialParameter::Scalar(value)) = parameters.get(slot_name) { + data.extend_from_slice(&value.to_le_bytes()); + data.extend_from_slice(&[0u8; 12]); + } else { + data.extend_from_slice(&[0u8; 16]); + } + } + ShaderParameterType::Bool => { + if let Some(MaterialParameter::Bool(value)) = parameters.get(slot_name) { + let value: u32 = if *value { 1 } else { 0 }; + data.extend_from_slice(&value.to_le_bytes()); + data.extend_from_slice(&[0u8; 12]); + } else { + data.extend_from_slice(&[0u8; 16]); + } + } + } + } + + if !data.is_empty() { + queue.write_buffer(buffer, 0, &data); + } + + Ok(()) + } + + /// Builds a wgpu bind group mapping each texture slot to its resolved renderer texture handle. + pub(crate) fn create_textures_bind_group( + device: &wgpu::Device, + resource_manager: &ResourceManager, + texture_bind_group_layout: &wgpu::BindGroupLayout, + name: &str, + texture_slots: &HashMap, + resolved_textures: &HashMap, + ) -> Result { + // Collect handles as owned values first so their borrows outlive the entries vec. + let mut slot_handles: Vec<(u32, u32, RendererTextureHandle)> = Vec::new(); + for (slot_name, slot) in texture_slots { + let handle = match resolved_textures.get(slot_name) { + Some(h) => *h, + None => { + let default_name = match slot.texture_type { + crate::resources::TextureType::Color => DEFAULT_COLOR_TEXTURE_NAME, + crate::resources::TextureType::Normal => DEFAULT_NORMAL_TEXTURE_NAME, + }; + resource_manager + .get_resource_handle::(default_name) + .map_err(|_| -> pill_core::PillError { pill_core::PillError::from(RendererError::RendererResourceNotFound) })? + } + }; + slot_handles.push((slot.texture_binding, slot.sampler_binding, handle)); + } + + let mut entries = Vec::new(); + for (texture_binding, sampler_binding, handle) in &slot_handles { + let texture = resource_manager + .get_resource::(handle) + .map_err(|_| -> pill_core::PillError { pill_core::PillError::from(RendererError::RendererResourceNotFound) })?; + entries.push(wgpu::BindGroupEntry { + binding: *texture_binding, + resource: wgpu::BindingResource::TextureView(&texture.texture_view), + }); + entries.push(wgpu::BindGroupEntry { + binding: *sampler_binding, + resource: wgpu::BindingResource::Sampler(&texture.sampler), + }); + } + + Ok(device.create_bind_group(&wgpu::BindGroupDescriptor { + layout: texture_bind_group_layout, + entries: &entries, + label: Some(name), + })) + } +} + +impl PillTypeMapKey for RendererMaterial { + type Storage = ResourceStorage; +} + +impl Resource for RendererMaterial { + type Handle = RendererMaterialHandle; + + fn get_name(&self) -> String { + self.name.clone() + } +} diff --git a/engine/pill_renderer/src/resources/renderer_mesh.rs b/engine/pill_engine/src/renderer/resources/renderer_mesh.rs similarity index 77% rename from engine/pill_renderer/src/resources/renderer_mesh.rs rename to engine/pill_engine/src/renderer/resources/renderer_mesh.rs index 2416687b..5c9481b1 100644 --- a/engine/pill_renderer/src/resources/renderer_mesh.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_mesh.rs @@ -1,17 +1,13 @@ -use pill_engine::internal::{MeshData, MeshVertex}; +use crate::graphics::RendererMeshHandle; +use crate::resources::{MeshData, MeshVertex, Resource, ResourceStorage}; -use pill_core::Result; +use pill_core::{PillTypeMapKey, Result}; use wgpu::util::DeviceExt; -// --- Vertex --- - pub trait Vertex { - // Defines how a data is layed out in memory (To specify how RenderPipeline needs to map the buffer in the shader) fn data_layout_descriptor<'a>() -> wgpu::VertexBufferLayout<'a>; } -// --- Mesh --- - pub struct RendererMesh { pub name: String, pub vertex_buffer: wgpu::Buffer, @@ -33,14 +29,24 @@ impl RendererMesh { usage: wgpu::BufferUsages::INDEX, }); - let renderer_mesh = Self { + Ok(Self { name: name.to_string(), vertex_buffer, index_buffer, index_count: mesh_data.indices.len() as u32, - }; + }) + } +} + +impl PillTypeMapKey for RendererMesh { + type Storage = ResourceStorage; +} + +impl Resource for RendererMesh { + type Handle = RendererMeshHandle; - Ok(renderer_mesh) + fn get_name(&self) -> String { + self.name.clone() } } @@ -52,35 +58,26 @@ impl Vertex for RendererMesh { step_mode: wgpu::VertexStepMode::Vertex, attributes: &[ wgpu::VertexAttribute { - // Vertex position offset: 0, shader_location: 0, format: wgpu::VertexFormat::Float32x3, }, wgpu::VertexAttribute { - // Vertex texture coordinates - // slangc maps TEXCOORD0 → @location(4), not 1 offset: mem::size_of::<[f32; 3]>() as wgpu::BufferAddress, shader_location: 4, format: wgpu::VertexFormat::Float32x2, }, wgpu::VertexAttribute { - // Vertex normal - // slangc maps NORMAL → @location(5) offset: mem::size_of::<[f32; 5]>() as wgpu::BufferAddress, shader_location: 5, format: wgpu::VertexFormat::Float32x3, }, wgpu::VertexAttribute { - // Vertex tangent - // slangc maps TANGENT → @location(6) offset: mem::size_of::<[f32; 8]>() as wgpu::BufferAddress, shader_location: 6, format: wgpu::VertexFormat::Float32x3, }, wgpu::VertexAttribute { - // Vertex bitangent - // slangc maps BINORMAL → @location(7) offset: mem::size_of::<[f32; 11]>() as wgpu::BufferAddress, shader_location: 7, format: wgpu::VertexFormat::Float32x3, diff --git a/engine/pill_renderer/src/resources/renderer_shader.rs b/engine/pill_engine/src/renderer/resources/renderer_shader.rs similarity index 56% rename from engine/pill_renderer/src/resources/renderer_shader.rs rename to engine/pill_engine/src/renderer/resources/renderer_shader.rs index 51610e98..11208683 100644 --- a/engine/pill_renderer/src/resources/renderer_shader.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_shader.rs @@ -1,6 +1,6 @@ -use pill_core::Result; -use pill_core::{debug, LogContext, PillStyle}; -use pill_engine::internal::{ShaderParameterSlot, ShaderTextureSlot}; +use crate::graphics::RendererShaderHandle; +use crate::resources::{Resource, ResourceStorage, ShaderParameterSlot, ShaderTextureSlot}; +use pill_core::{debug, LogContext, PillStyle, PillTypeMapKey, Result}; use std::collections::HashMap; pub struct RendererShader { @@ -34,7 +34,6 @@ impl RendererShader { pass_engine_parameters: bool, pass_camera_parameters: bool, ) -> Result { - // Print shader information { let mut shader_info = format!( "Creating shader {}:\n - Settings:\n - Pass engine parameters: {}\n - Pass camera parameters: {}", @@ -56,14 +55,9 @@ impl RendererShader { )); } - // Log with context debug!(LogContext::Rendering => "{}", shader_info); } - debug!(LogContext::Rendering => "Shader modules created"); - - // Create shader modules from WGSL strings — wgpu consumes WGSL natively; - // no compile step at runtime, no naga linked into the binary. let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("master_vertex_shader"), source: wgpu::ShaderSource::Wgsl(vertex_wgsl.into()), @@ -73,75 +67,60 @@ impl RendererShader { source: wgpu::ShaderSource::Wgsl(fragment_wgsl.into()), }); - let parameters_bind_group_layout = { - if !parameter_slots.is_empty() { - let bind_group_layout_entry = wgpu::BindGroupLayoutEntry { - binding: 0, // (set = 2, binding = 0) - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }; - - Some( - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some(&format!("{}_parameters_bind_group_layout", name)), - entries: &[bind_group_layout_entry], - }), - ) - } else { - None - } - }; - - debug!(LogContext::Rendering => "Parameters bind group layout created"); - - // Create bind group layout entries for textures - Bind group slot 1 - let textures_bind_group_layout = { - if !texture_slots.is_empty() { - let mut entries = Vec::new(); - - for texture_slot in texture_slots.values() { - // Texture binding - entries.push(wgpu::BindGroupLayoutEntry { - binding: texture_slot.texture_binding, + let parameters_bind_group_layout = if !parameter_slots.is_empty() { + Some( + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(&format!("{}_parameters_bind_group_layout", name)), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - multisampled: false, - view_dimension: wgpu::TextureViewDimension::D2, - sample_type: wgpu::TextureSampleType::Float { filterable: true }, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, }, count: None, - }); + }], + }), + ) + } else { + None + }; - // Sampler binding - entries.push(wgpu::BindGroupLayoutEntry { - binding: texture_slot.sampler_binding, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }); - } - - Some( - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some(&format!("{}_textures_bind_group_layout", name)), - entries: &entries, - }), - ) - } else { - None + let textures_bind_group_layout = if !texture_slots.is_empty() { + let mut entries = Vec::new(); + + for texture_slot in texture_slots.values() { + entries.push(wgpu::BindGroupLayoutEntry { + binding: texture_slot.texture_binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }); + + entries.push(wgpu::BindGroupLayoutEntry { + binding: texture_slot.sampler_binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }); } - }; - debug!(LogContext::Rendering => "Textures bind group layout created"); + Some( + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(&format!("{}_textures_bind_group_layout", name)), + entries: &entries, + }), + ) + } else { + None + }; - // Create pipeline layout let pipeline_layout = { - // Collect bind group layouts only if they exist let mut bind_group_layouts = Vec::new(); if pass_engine_parameters { @@ -164,7 +143,6 @@ impl RendererShader { }) }; - // Create color target states that specifies what what color outputs wgpu should set up let color_target_states = &[Some(wgpu::ColorTargetState { format: color_format, blend: Some(wgpu::BlendState { @@ -180,7 +158,7 @@ impl RendererShader { vertex: wgpu::VertexState { module: &vertex_shader, entry_point: Some("vs_main"), - buffers: vertex_layouts, // Specifies structure of vertices that will be passed to the vertex shader + buffers: vertex_layouts, compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { @@ -190,25 +168,24 @@ impl RendererShader { compilation_options: Default::default(), }), primitive: wgpu::PrimitiveState { - // Specifies how to interpret vertices when converting them into triangles - topology: wgpu::PrimitiveTopology::TriangleList, // Each three vertices will correspond to one triangle + topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, // Specifies how to determine whether a given triangle is facing forward or not (FrontFace::Ccw means that a triangle is facing forward if the vertices are arranged in a counter clockwise direction) - cull_mode: Some(wgpu::Face::Back), // Triangles that are not considered facing forward are culled (not included in the render) as specified by CullMode::Back - polygon_mode: wgpu::PolygonMode::Fill, // Setting this to anything other than Fill requires Features::NON_FILL_POLYGON_MODE - conservative: false, // Requires Features::CONSERVATIVE_RASTERIZATION - unclipped_depth: true, // Requires Features::DEPTH_CLAMPING + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: true, }, depth_stencil: depth_format.map(|format| wgpu::DepthStencilState { format, depth_write_enabled: true, - depth_compare: wgpu::CompareFunction::Less, // Specifies when to discard a new pixel. Using LESS means pixels will be drawn front to back + depth_compare: wgpu::CompareFunction::Less, stencil: wgpu::StencilState::default(), bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState { - count: 1, // Determines how many samples pipeline will use (Multisampling) - mask: !0, // Specifies which samples should be active + count: 1, + mask: !0, alpha_to_coverage_enabled: false, }, multiview: None, @@ -217,7 +194,9 @@ impl RendererShader { let render_pipeline = device.create_render_pipeline(&render_pipeline_descriptor); - let pipeline = Self { + debug!(LogContext::Rendering => "Render pipeline created"); + + Ok(Self { name: name.to_string(), render_pipeline, parameter_slots: parameter_slots.to_vec(), @@ -226,10 +205,18 @@ impl RendererShader { parameters_bind_group_layout, pass_engine_parameters, pass_camera_parameters, - }; + }) + } +} - debug!(LogContext::Rendering => "Render pipeline created"); +impl PillTypeMapKey for RendererShader { + type Storage = ResourceStorage; +} + +impl Resource for RendererShader { + type Handle = RendererShaderHandle; - Ok(pipeline) + fn get_name(&self) -> String { + self.name.clone() } } diff --git a/engine/pill_renderer/src/resources/renderer_texture.rs b/engine/pill_engine/src/renderer/resources/renderer_texture.rs similarity index 65% rename from engine/pill_renderer/src/resources/renderer_texture.rs rename to engine/pill_engine/src/renderer/resources/renderer_texture.rs index 4ebcde7c..92aa98be 100644 --- a/engine/pill_renderer/src/resources/renderer_texture.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_texture.rs @@ -1,14 +1,10 @@ -use pill_engine::internal::TextureType; +use crate::graphics::RendererTextureHandle; +use crate::resources::{Resource, ResourceStorage, TextureType}; -use pill_core::Result; - -// --- Texture --- - -pill_core::define_new_pill_slotmap_key! { - pub struct RendererTextureHandle; -} +use pill_core::{PillTypeMapKey, Result}; pub struct RendererTexture { + pub name: String, pub texture: wgpu::Texture, pub texture_view: wgpu::TextureView, pub sampler: wgpu::Sampler, @@ -20,28 +16,26 @@ impl RendererTexture { pub fn new_texture( device: &wgpu::Device, queue: &wgpu::Queue, - name: Option<&str>, + name: &str, rgba: &[u8], width: u32, height: u32, texture_type: TextureType, ) -> Result { - // Get size + let size = wgpu::Extent3d { width, height, depth_or_array_layers: 1, }; - // Specify texture format let format = match texture_type { TextureType::Color => wgpu::TextureFormat::Rgba8UnormSrgb, TextureType::Normal => wgpu::TextureFormat::Rgba8Unorm, }; - // Create texture let texture = device.create_texture(&wgpu::TextureDescriptor { - label: name, + label: Some(name), size, mip_level_count: 1, sample_count: 1, @@ -68,10 +62,8 @@ impl RendererTexture { size, ); - // Create texture view let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - // Create sampler let sampler = device.create_sampler(&wgpu::SamplerDescriptor { address_mode_u: wgpu::AddressMode::Repeat, address_mode_v: wgpu::AddressMode::Repeat, @@ -79,12 +71,13 @@ impl RendererTexture { mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Nearest, mipmap_filter: wgpu::FilterMode::Nearest, - lod_min_clamp: 0.0, // Fix: Must be 0.0 or greater - lod_max_clamp: 100.0, // You can set this based on your texture's mipmap levels + lod_min_clamp: 0.0, + lod_max_clamp: 100.0, ..Default::default() }); Ok(Self { + name: name.to_string(), texture, texture_view, sampler, @@ -96,15 +89,12 @@ impl RendererTexture { surface_configuration: &wgpu::SurfaceConfiguration, label: &str, ) -> Result { - // Get size let size = wgpu::Extent3d { - // Depth texture needs to be the same size as window width: surface_configuration.width, height: surface_configuration.height, depth_or_array_layers: 1, }; - // Create texture let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some(label), size, @@ -112,14 +102,12 @@ impl RendererTexture { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: Self::DEPTH_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, // Rendering to this texture so RENDER_ATTACHMENT flag is needed + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); - // Create texture view let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - // Create sampler let sampler = device.create_sampler(&wgpu::SamplerDescriptor { address_mode_u: wgpu::AddressMode::Repeat, address_mode_v: wgpu::AddressMode::Repeat, @@ -134,9 +122,66 @@ impl RendererTexture { }); Ok(Self { + name: label.to_string(), texture, texture_view, sampler, }) } + + pub fn new_render_target( + device: &wgpu::Device, + label: &str, + width: u32, + height: u32, + format: wgpu::TextureFormat, + ) -> Result { + let size = wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + Ok(Self { + name: label.to_string(), + texture, + texture_view, + sampler, + }) + } +} + +impl PillTypeMapKey for RendererTexture { + type Storage = ResourceStorage; +} + +impl Resource for RendererTexture { + type Handle = RendererTextureHandle; + + fn get_name(&self) -> String { + self.name.clone() + } } diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs new file mode 100644 index 00000000..4bcea0a5 --- /dev/null +++ b/engine/pill_engine/src/renderer/state.rs @@ -0,0 +1,524 @@ +#![allow(clippy::too_many_arguments)] +use crate::{ + app_config::EngineConfig, + ecs::EguiClient, + graphics::{ + BufferDesc, Pass, PassPBRStatic, PillRenderer, PipelineV2, PipelineV2Desc, + RendererCameraHandle, RendererTargetDesc, RendererTextureHandle, WorldQuery, + }, + internal::{ + get_renderer_resource_handle_from_camera_component, CameraComponent, ComponentStorage, + EntityHandle, RenderQueueItem, TransformComponent, + }, + renderer::{ + instance::Instance, + resources::{ + EngineParameters, RendererCamera, RendererMesh, RendererShader, RendererTexture, Vertex, + }, + }, + resources::ResourceManager, +}; + +use pill_core::{debug, info, LogContext, PillSlotMap, PillSlotMapKey, PillStyle, RendererError, Timer}; +use pill_core::Result; +use std::{collections::HashMap, sync::Arc}; + +pub struct Renderer { + pub state: State, +} + +impl Renderer { + /// Creates the renderer asynchronously; required on WASM where `block_on` is unavailable. + pub async fn new_async( + window: Arc, + config: EngineConfig, + ) -> Result { + info!(LogContext::Rendering => "Initializing {}", "Renderer".module_object_style()); + let state = State::new(window, config).await?; + Ok(Self { state }) + } +} + +impl PillRenderer for Renderer { + #[cfg(not(target_arch = "wasm32"))] + fn new(window: Arc, config: EngineConfig) -> Result { + info!(LogContext::Rendering => "Initializing {}", "Renderer".module_object_style()); + let state = pollster::block_on(State::new(window, config))?; + Ok(Self { state }) + } + + #[cfg(target_arch = "wasm32")] + fn new(_window: Arc, _config: EngineConfig) -> Result { + panic!("Use Renderer::new_async on WASM") + } + + // --- Create --- + + fn create_shader_struct( + &mut self, + name: &str, + vertex_wgsl: &str, + fragment_wgsl: &str, + texture_slots: &HashMap, + parameter_slots: &[(String, crate::resources::ShaderParameterSlot)], + pass_engine_parameters: bool, + pass_camera_parameters: bool, + ) -> Result { + RendererShader::new( + name, + &self.state.device, + self.state.color_format, + Some(self.state.depth_format), + &[ + RendererMesh::data_layout_descriptor(), + Instance::data_layout_descriptor(), + ], + vertex_wgsl, + fragment_wgsl, + parameter_slots, + texture_slots, + &self.state.engine_parameters.bind_group_layout, + &self.state.camera_bind_group_layout, + pass_engine_parameters, + pass_camera_parameters, + ) + } + + fn create_camera(&mut self) -> Result { + let camera = RendererCamera::new( + &self.state.device, + self.state.camera_bind_group_layout.clone(), + )?; + let handle = self.state.cameras.insert(camera); + Ok(handle) + } + + // --- Destroy --- + + fn destroy_camera(&mut self, renderer_camera_handle: RendererCameraHandle) -> Result<()> { + self.state.cameras.remove(renderer_camera_handle).unwrap(); + Ok(()) + } + + // --- Other --- + + fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize) { + info!(LogContext::Rendering => "Resizing {} resources", "Renderer".module_object_style()); + self.state.resize(new_window_size) + } + + #[cfg(feature = "debug_ui")] + fn pass_input_to_egui(&mut self, event: &winit::event::WindowEvent) -> Result<()> { + if let Some(c) = &self.state.egui_client { + c.handle_input(event.clone()); + } + Ok(()) + } + + fn render( + &mut self, + active_camera_entity_handle: EntityHandle, + render_queue: &[RenderQueueItem], + camera_component_storage: &ComponentStorage, + transform_component_storage: &ComponentStorage, + delta_time: f32, + timer: &mut Timer, + resource_manager: &ResourceManager, + ) -> Result<()> { + debug!(LogContext::Frame => "Starting frame render"); + + timer.record("Get frame"); + + let frame = self.state.surface.get_current_texture(); + let frame = match frame { + std::result::Result::Ok(frame) => frame, + std::result::Result::Err(error) => match error { + wgpu::SurfaceError::Lost => return Err(RendererError::SurfaceLost.into()), + wgpu::SurfaceError::OutOfMemory => { + return Err(RendererError::SurfaceOutOfMemory.into()) + } + _ => return Err(RendererError::SurfaceOther.into()), + }, + }; + + let view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + let mut encoder = self + .state + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("render_encoder"), + }); + + let world = WorldQuery { + active_camera: active_camera_entity_handle, + render_queue, + camera_components: camera_component_storage, + transform_components: transform_component_storage, + delta_time, + resources: resource_manager, + }; + + timer.begin_context("Scene Passes"); + + let mut passes = std::mem::take(&mut self.state.passes); + for pass in &mut passes { + pass.draw(&mut encoder, self, &frame, &view, &world)?; + } + self.state.passes = passes; + + timer.end_context()?; + + timer.record("Submit commands and present frame"); + + self.state.queue.submit(std::iter::once(encoder.finish())); + frame.present(); + + Ok(()) + } + + // --- Pass API --- + + fn set_passes(&mut self, mut passes: Vec>) -> Result<()> { + for pass in &mut passes { + pass.init(self)?; + } + self.state.passes = passes; + Ok(()) + } + + /// Installs the default pass chain (scene + optional egui) on first frame bootstrap. + fn init_default_passes(&mut self, egui_client: Arc) -> Result<()> { + self.state.egui_client = Some(egui_client.clone()); + #[cfg(feature = "debug_ui")] + { + use crate::graphics::PassEgui; + self.set_passes(vec![ + Box::new(PassPBRStatic::new(None, None, None)), + Box::new(PassEgui::new(self.state.window.clone(), egui_client)), + ]) + } + #[cfg(not(feature = "debug_ui"))] + self.set_passes(vec![Box::new(PassPBRStatic::new(None, None, None))]) + } + + fn get_device(&self) -> &wgpu::Device { + &self.state.device + } + + fn get_queue(&self) -> &wgpu::Queue { + &self.state.queue + } + + fn get_surface_format(&self) -> wgpu::TextureFormat { + self.state.color_format + } + + fn create_buffer(&mut self, desc: BufferDesc) -> Result { + let buffer = self.state.device.create_buffer(&wgpu::BufferDescriptor { + label: desc.label, + size: desc.byte_size, + usage: desc.usage, + mapped_at_creation: false, + }); + Ok(buffer) + } + + fn create_pipeline_v2(&mut self, desc: PipelineV2Desc) -> Result { + let bind_group_layouts: Vec = desc + .bind_groups + .iter() + .map(|entries| { + self.state + .device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: None, + entries, + }) + }) + .collect(); + + let bind_group_layout_refs: Vec<&wgpu::BindGroupLayout> = + bind_group_layouts.iter().collect(); + + let pipeline_layout = + self.state + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: desc.label, + bind_group_layouts: &bind_group_layout_refs, + push_constant_ranges: &[], + }); + + let vs_module = self + .state + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(desc.vs.entry_func), + source: wgpu::ShaderSource::Wgsl(desc.vs.source.into()), + }); + let fs_module = self + .state + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(desc.ps.entry_func), + source: wgpu::ShaderSource::Wgsl(desc.ps.source.into()), + }); + + let pipeline = + self.state + .device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: desc.label, + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &vs_module, + entry_point: Some(desc.vs.entry_func), + buffers: desc.vertex_buffers, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &fs_module, + entry_point: Some(desc.ps.entry_func), + targets: desc.targets, + compilation_options: Default::default(), + }), + primitive: desc.primitive, + depth_stencil: desc.depth_stencil, + multisample: desc.multisample, + multiview: None, + cache: None, + }); + + Ok(PipelineV2 { + pipeline, + bind_group_layouts, + }) + } + + fn create_render_target(&mut self, desc: RendererTargetDesc) -> Result { + let texture = RendererTexture::new_render_target( + &self.state.device, + &desc.name, + desc.width, + desc.height, + desc.format, + )?; + let handle = self.state.pass_textures.insert(texture); + Ok(handle) + } + + fn create_depth_texture(&mut self, label: &str) -> Result { + let texture = RendererTexture::new_depth_texture( + &self.state.device, + &self.state.surface_configuration, + label, + )?; + let handle = self.state.pass_textures.insert(texture); + Ok(handle) + } + + fn get_render_target_view( + &self, + handle: RendererTextureHandle, + ) -> Option<&wgpu::TextureView> { + self.state + .pass_textures + .get(handle) + .map(|t| &t.texture_view) + } + +} + +pub struct State { + pub(crate) cameras: PillSlotMap, + pub(crate) engine_parameters: EngineParameters, + pass_textures: PillSlotMap, + surface: wgpu::Surface<'static>, + device: wgpu::Device, + queue: wgpu::Queue, + surface_configuration: wgpu::SurfaceConfiguration, + #[allow(dead_code)] + window_size: winit::dpi::PhysicalSize, + color_format: wgpu::TextureFormat, + depth_format: wgpu::TextureFormat, + depth_texture: RendererTexture, + passes: Vec>, + egui_client: Option>, + camera_bind_group_layout: wgpu::BindGroupLayout, + window: Arc, +} + +impl State { + async fn new(window: Arc, config: EngineConfig) -> Result { + let window_width = config.get_int("WINDOW_WIDTH")? as u32; + let window_height = config.get_int("WINDOW_HEIGHT")? as u32; + let window_size = winit::dpi::PhysicalSize::new(window_width, window_height); + + // Create wgpu instance and window surface + let (instance, surface) = { + let backends = match std::env::var("WGPU_BACKENDS").as_deref() { + std::result::Result::Ok("VULKAN") => wgpu::Backends::VULKAN, + std::result::Result::Ok("DX12") => wgpu::Backends::DX12, + std::result::Result::Ok("METAL") => wgpu::Backends::METAL, + std::result::Result::Ok("GL") => wgpu::Backends::GL, + std::result::Result::Ok("BROWSER_WEBGPU") => wgpu::Backends::BROWSER_WEBGPU, + _ => wgpu::Backends::all(), + }; + + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends, + flags: wgpu::InstanceFlags::from_build_config().with_env(), + backend_options: wgpu::BackendOptions::default(), + }); + let surface = instance + .create_surface(window.clone()) + .map_err(|error| -> pill_core::PillError { pill_core::PillError::from(format!("Failed to create surface: {}", error)) })?; + (instance, surface) + }; + + // Select GPU adapter and request logical device + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::default(), + compatible_surface: Some(&surface), + force_fallback_adapter: false, + }) + .await + .map_err(|error| -> pill_core::PillError { pill_core::PillError::from(format!("Failed to request adapter: {}", error)) })?; + + let info = adapter.get_info(); + info!(LogContext::Rendering => "Using GPU: {} ({:?})", info.name, info.backend); + + let (device, queue) = { + let wanted = wgpu::Features::DEPTH_CLIP_CONTROL + | wgpu::Features::TIMESTAMP_QUERY + | wgpu::Features::PIPELINE_STATISTICS_QUERY; + let features = wanted & adapter.features(); + + adapter + .request_device(&wgpu::DeviceDescriptor { + label: None, + required_features: features, + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), + trace: wgpu::Trace::default(), + }) + .await + .map_err(|error| -> pill_core::PillError { pill_core::PillError::from(format!("Failed to request device: {}", error)) })? + }; + + // Configure swap chain format, present mode, and depth texture + let (surface_configuration, color_format, depth_format) = { + let preferred_format = wgpu::TextureFormat::Rgba8UnormSrgb; + let surface_capabilities = surface.get_capabilities(&adapter); + + #[cfg(target_arch = "wasm32")] + let present_mode = wgpu::PresentMode::Fifo; + #[cfg(not(target_arch = "wasm32"))] + let present_mode = if surface_capabilities + .present_modes + .contains(&wgpu::PresentMode::Mailbox) + { + wgpu::PresentMode::Mailbox + } else if surface_capabilities + .present_modes + .contains(&wgpu::PresentMode::Immediate) + { + wgpu::PresentMode::Immediate + } else { + wgpu::PresentMode::Fifo + }; + + let format = if surface_capabilities.formats.contains(&preferred_format) { + preferred_format + } else if surface_capabilities + .formats + .contains(&wgpu::TextureFormat::Bgra8UnormSrgb) + { + wgpu::TextureFormat::Bgra8UnormSrgb + } else if surface_capabilities + .formats + .contains(&wgpu::TextureFormat::Bgra8Unorm) + { + wgpu::TextureFormat::Bgra8Unorm + } else { + surface_capabilities.formats[0] + }; + + let surface_configuration = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: window_size.width, + height: window_size.height, + desired_maximum_frame_latency: 2, + present_mode, + alpha_mode: wgpu::CompositeAlphaMode::Auto, + view_formats: vec![format], + }; + surface.configure(&device, &surface_configuration); + let color_format = surface_configuration.format; + let depth_format = wgpu::TextureFormat::Depth32Float; + (surface_configuration, color_format, depth_format) + }; + + let depth_texture = + RendererTexture::new_depth_texture(&device, &surface_configuration, "depth_texture") + .map_err(|error| -> pill_core::PillError { pill_core::PillError::from(format!("Failed to create depth texture: {}", error)) })?; + + let camera_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("camera_parameters_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: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let engine_parameters = EngineParameters::new(&device)?; + let cameras = PillSlotMap::with_capacity_and_key(10); + let pass_textures = PillSlotMap::with_capacity_and_key(10); + + Ok(Self { + cameras, + engine_parameters, + pass_textures, + surface, + device, + queue, + surface_configuration, + window_size, + color_format, + depth_format, + depth_texture, + passes: Vec::new(), + egui_client: None, + camera_bind_group_layout, + window, + }) + } + + fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize) { + if new_window_size.width > 0 && new_window_size.height > 0 { + self.window_size = new_window_size; + self.surface_configuration.width = new_window_size.width; + self.surface_configuration.height = new_window_size.height; + self.surface + .configure(&self.device, &self.surface_configuration); + self.depth_texture = RendererTexture::new_depth_texture( + &self.device, + &self.surface_configuration, + "depth_texture", + ) + .unwrap(); + } + } +} diff --git a/engine/pill_engine/src/resources/material.rs b/engine/pill_engine/src/resources/material.rs index 2d0b7287..1d072dae 100644 --- a/engine/pill_engine/src/resources/material.rs +++ b/engine/pill_engine/src/resources/material.rs @@ -5,7 +5,8 @@ use crate::{ MeshRenderingComponent, }, engine::Engine, - graphics::{RendererMaterialHandle, RendererTextureHandle, RENDER_QUEUE_KEY_ORDER}, + graphics::RENDER_QUEUE_KEY_ORDER, + renderer::resources::{RendererMaterial, RendererShader, RendererTexture}, resources::{Resource, ResourceStorage, Shader, ShaderHandle, Texture, TextureHandle}, }; @@ -23,6 +24,148 @@ const DEFERRED_REQUEST_VARIANT_PARAMETER: usize = 1; const DEFERRED_REQUEST_VARIANT_TEXTURE_START: usize = 2; const DEFERRED_REQUEST_VARIANT_TEXTURE_END: usize = 10; +// --- PBRMaterial --- + +pill_core::define_new_pill_slotmap_key! { + pub struct PBRMaterialHandle; +} + +#[readonly::make] +pub struct PBRMaterial { + #[readonly] + pub name: String, + pub albedo: Color, + pub metallic: f32, + pub roughness: f32, + pub emissive: Color, + pub albedo_texture: Option, + pub normal_texture: Option, + pub metallic_roughness_texture: Option, + pub emissive_texture: Option, + handle: Option, +} + +impl PBRMaterial { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + albedo: Color::new(1.0, 1.0, 1.0), + metallic: 0.0, + roughness: 0.5, + emissive: Color::new(0.0, 0.0, 0.0), + albedo_texture: None, + normal_texture: None, + metallic_roughness_texture: None, + emissive_texture: None, + handle: None, + } + } + + pub fn albedo(mut self, color: Color) -> Self { + self.albedo = color; + self + } + + pub fn roughness(mut self, roughness: f32) -> Self { + self.roughness = roughness.clamp(0.0, 1.0); + self + } + + pub fn albedo_texture(mut self, handle: TextureHandle) -> Self { + self.albedo_texture = Some(handle); + self + } + + pub fn normal_texture(mut self, handle: TextureHandle) -> Self { + self.normal_texture = Some(handle); + self + } +} + +impl PillTypeMapKey for PBRMaterial { + type Storage = ResourceStorage; +} + +impl Resource for PBRMaterial { + type Handle = PBRMaterialHandle; + + fn get_name(&self) -> String { + self.name.clone() + } + + fn initialize(&mut self, engine: &mut Engine) -> Result<()> { + #[cfg(not(feature = "headless"))] + { + let mut parameters: HashMap = HashMap::new(); + parameters.insert( + DEFAULT_LIT_SHADER_TINT_PARAMETER_SLOT_NAME.to_string(), + MaterialParameter::Color(self.albedo), + ); + parameters.insert( + DEFAULT_LIT_SHADER_SPECULARITY_PARAMETER_SLOT_NAME.to_string(), + MaterialParameter::Scalar(1.0 - self.roughness), + ); + + let mut resolved_textures: HashMap = + HashMap::new(); + + if let Some(texture_handle) = self.albedo_texture { + let tex_name = engine + .resource_manager + .get_resource::(&texture_handle)? + .name + .clone(); + let h = engine + .resource_manager + .get_resource_handle::(&tex_name)?; + resolved_textures + .insert(DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_NAME.to_string(), h); + } + + if let Some(texture_handle) = self.normal_texture { + let tex_name = engine + .resource_manager + .get_resource::(&texture_handle)? + .name + .clone(); + let h = engine + .resource_manager + .get_resource_handle::(&tex_name)?; + resolved_textures + .insert(DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_NAME.to_string(), h); + } + + let renderer_shader_handle = engine + .resource_manager + .get_resource_handle::(DEFAULT_LIT_SHADER_NAME)?; + + let renderer_material = RendererMaterial::new( + engine.renderer.get_device(), + engine.renderer.get_queue(), + &engine.resource_manager, + &self.name, + renderer_shader_handle, + &resolved_textures, + ¶meters, + )?; + engine.resource_manager.add_resource(renderer_material)?; + } + Ok(()) + } + + fn pass_handle(&mut self, self_handle: H) { + self.handle = Some(PBRMaterialHandle::from(self_handle.data())); + } + + fn destroy(&mut self, engine: &mut Engine, _self_handle: H) -> Result<()> { + #[cfg(not(feature = "headless"))] + engine + .resource_manager + .remove_resource_by_name::(&self.name)?; + Ok(()) + } +} + // --- Material parameters --- #[derive(Debug)] @@ -36,28 +179,15 @@ pub enum MaterialParameter { #[derive(Clone)] pub struct MaterialTexture { - //pub texture_type: TextureType, pub texture_handle: TextureHandle, - pub(crate) renderer_resource_handle: Option, } impl MaterialTexture { pub fn new(texture_handle: TextureHandle) -> Self { - Self { - //texture_type, - texture_handle, - renderer_resource_handle: None, - } + Self { texture_handle } } } -// This needed so that renderer can get renderer texture handle from material texture while it is still hidden in game API -pub fn get_renderer_texture_handle_from_material_texture( - material_texture: &MaterialTexture, -) -> &Option { - &material_texture.renderer_resource_handle -} - // --- Builder --- pub struct MaterialBuilder { @@ -133,7 +263,6 @@ pub struct Material { #[readonly] pub rendering_order: u8, - pub(crate) renderer_resource_handle: Option, shader_name: Option, handle: Option, deferred_update_manager: Option, @@ -144,7 +273,6 @@ impl Material { MaterialBuilder::new(name) } - // Creates default lit material with default shader and textures pub fn new(name: &str) -> Self { Self { name: name.to_string(), @@ -153,7 +281,6 @@ impl Material { //textures_mapping: Vec::new(), parameters: HashMap::new(), rendering_order: RENDER_QUEUE_KEY_ORDER.max as u8, - renderer_resource_handle: None, shader_name: None, handle: None, deferred_update_manager: None, @@ -181,34 +308,15 @@ impl Material { // Set new handle but not renderer resource handle (it will be set by deferred update system) // l//et _ = texture_slot.texture_handle = texture_handle; - // Post deferred update request (only if renderer resource handle is set (it means that material is initialized)) - if self.renderer_resource_handle.is_some() { + // Post deferred update request (only if deferred_update_manager is set (it means that material is initialized)) + if self.deferred_update_manager.is_some() { self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_TEXTURE_START + slot_index); - // texture_slot_index); } Ok(()) } - // pub fn remove_texture(&mut self, slot_name: &str) -> Result<()> { - // // Get texture slot - // let texture_slot = self.textures.data.get_mut(slot_name) - // .ok_or( EngineError::MaterialTextureSlotNotFound(slot_name.to_string(), self.name.to_string()).into())?; - - // // Get texture slot index - // let texture_slot_index = self.textures.mapping.iter().position(|v| v == slot_name).expect("Critical: No mapping"); - // // Set new handle and renderer resource handle - // texture_slot.texture_handle = None; - // texture_slot.renderer_texture_handle = None; - - // // Post deferred update request (only if renderer resource handle is set (it means that material is initialized)) - // if self.renderer_resource_handle.is_some() { - // self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_TEXTURE_START + texture_slot_index); - // } - - // Ok(()) - // } pub fn set_rendering_order(&mut self, order: u8) -> Result<()> { let error = EngineError::WrongRenderingOrder( @@ -217,14 +325,12 @@ impl Material { ); if order < RENDER_QUEUE_KEY_ORDER.max as u8 { self.rendering_order = order; - // Post deferred update request (only if renderer resource handle is set (it means that material is initialized)) - if self.renderer_resource_handle.is_some() { + if self.deferred_update_manager.is_some() { self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_RENDERING_ORDER); } } else { return Err(error.into()); } - Ok(()) } @@ -281,8 +387,7 @@ impl Material { MaterialParameter::Scalar(v) => { if *v != value { *v = value; - // Post deferred update request (only if renderer resource handle is set (it means that material is initialized)) - if self.renderer_resource_handle.is_some() { + if self.deferred_update_manager.is_some() { self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_PARAMETER); } } @@ -306,8 +411,7 @@ impl Material { MaterialParameter::Bool(v) => { if *v != value { *v = value; - // Post deferred update request (only if renderer resource handle is set (it means that material is initialized)) - if self.renderer_resource_handle.is_some() { + if self.deferred_update_manager.is_some() { self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_PARAMETER); } } @@ -329,7 +433,6 @@ impl Material { .context(error.clone())?; match parameter { MaterialParameter::Color(v) => { - // Clamp color channel values between 0.0 and 1.0 let valid_color = Color::new( value.x.clamp(0.0, 1.0), value.y.clamp(0.0, 1.0), @@ -337,8 +440,7 @@ impl Material { ); if *v != valid_color { *v = valid_color; - // Post deferred update request (only if renderer resource handle is set (it means that material is initialized)) - if self.renderer_resource_handle.is_some() { + if self.deferred_update_manager.is_some() { self.post_deferred_update_request(DEFERRED_REQUEST_VARIANT_PARAMETER); } } @@ -356,19 +458,16 @@ impl Material { ) -> Result<()> { let shader = engine.get_resource::(&self.shader_handle)?; - // Get texture to be set let texture = engine .get_resource::(&texture_slot.texture_handle) .context(EngineError::InvalidTextureHandleForSlot( texture_slot_name.to_string(), ))?; - // Get texture slot from shader let shader_texture_slot = shader.texture_slots.get(texture_slot_name).context( EngineError::MaterialTextureSlotNotFound(texture_slot_name.to_string()), )?; - // Check if slots are of the same type if !enum_variant_eq(&texture.texture_type, &shader_texture_slot.texture_type) { return Err(EngineError::WrongTextureType( get_enum_variant_type_name(&texture.texture_type), @@ -405,39 +504,52 @@ impl Resource for Material { } fn initialize(&mut self, engine: &mut Engine) -> Result<()> { - // This resource is using DeferredUpdateSystem so keep DeferredUpdateManager let deferred_update_component = engine .get_global_component_mut::() .expect("Critical: No DeferredUpdateComponent"); self.deferred_update_manager = Some(deferred_update_component.borrow_deferred_update_manager()); - // Check if assigned textures are of correct type declared in shader for (texture_slot_name, texture_slot) in self.textures.iter() { self.validate_texture(engine, texture_slot_name, texture_slot)?; } - // Assign renderer resource handle to texture slot - for (texture_slot_name, texture_slot) in self.textures.iter_mut() { - let texture = engine - .get_resource::(&texture_slot.texture_handle) - .context(EngineError::InvalidTextureHandleForSlot( - texture_slot_name.to_string(), - ))?; - texture_slot.renderer_resource_handle = Some(texture.renderer_resource_handle.unwrap()); - } + #[cfg(not(feature = "headless"))] + { + let shader = engine.get_resource::(&self.shader_handle)?; + self.shader_name = Some(shader.name.clone()); + let shader_name = shader.name.clone(); + + let mut resolved_textures: HashMap = + HashMap::new(); + for (slot_name, tex_slot) in self.textures.iter() { + let tex_name = engine + .resource_manager + .get_resource::(&tex_slot.texture_handle) + .context(EngineError::InvalidTextureHandleForSlot(slot_name.clone()))? + .name + .clone(); + let renderer_tex_handle = engine + .resource_manager + .get_resource_handle::(&tex_name)?; + resolved_textures.insert(slot_name.clone(), renderer_tex_handle); + } - // Create new renderer material resource - let shader = engine.get_resource::(&self.shader_handle)?; - self.shader_name = Some(shader.get_name()); - let shader_renderer_resource_handle = shader.renderer_resource_handle.unwrap(); - let renderer_resource_handle = engine.renderer.create_material( - &self.name, - shader_renderer_resource_handle, - &self.textures, - &self.parameters, - )?; - self.renderer_resource_handle = Some(renderer_resource_handle); + let renderer_shader_handle = engine + .resource_manager + .get_resource_handle::(&shader_name)?; + + let renderer_material = RendererMaterial::new( + engine.renderer.get_device(), + engine.renderer.get_queue(), + &engine.resource_manager, + &self.name, + renderer_shader_handle, + &resolved_textures, + &self.parameters, + )?; + engine.resource_manager.add_resource(renderer_material)?; + } Ok(()) } @@ -449,13 +561,11 @@ impl Resource for Material { fn deferred_update(&mut self, engine: &mut Engine, request: usize) -> Result<()> { match request { DEFERRED_REQUEST_VARIANT_RENDERING_ORDER => { - // Find mesh rendering components that use this material and update them for (_scene_handle, scene) in engine.scene_manager.scenes.iter_mut() { for (_entity_handle, mesh_rendering_component) in scene.get_one_component_iterator_mut::()? { if let Some(material_handle) = mesh_rendering_component.material_handle { - // If mesh rendering component has handle to this material if material_handle.data() == self.handle.unwrap().data() { mesh_rendering_component .update_render_queue_key(&engine.resource_manager) @@ -466,11 +576,32 @@ impl Resource for Material { } } DEFERRED_REQUEST_VARIANT_PARAMETER => { - // Update renderer counterpart - engine.renderer.update_material_parameters( - self.renderer_resource_handle.unwrap(), - &self.parameters, - )?; + #[cfg(not(feature = "headless"))] + { + let mat_name = self.name.clone(); + let parameter_slots = { + let renderer_mat = engine + .resource_manager + .get_resource_by_name::(&mat_name)?; + let shader = engine + .resource_manager + .get_resource::(&renderer_mat.shader_handle)?; + shader.parameter_slots.clone() + }; + + let queue = engine.renderer.get_queue(); + let renderer_mat = engine + .resource_manager + .get_resource_by_name_mut::(&mat_name)?; + if let Some(ref buffer) = renderer_mat.parameters_uniform_buffer { + RendererMaterial::write_parameters_to_buffer( + queue, + buffer, + ¶meter_slots, + &self.parameters, + )?; + } + } } DEFERRED_REQUEST_VARIANT_TEXTURE_START..=DEFERRED_REQUEST_VARIANT_TEXTURE_END => { // Check if assigned texture is of correct type @@ -478,21 +609,40 @@ impl Resource for Material { let (texture_slot_name, texture_slot) = &self.textures[idx]; self.validate_texture(engine, texture_slot_name, texture_slot)?; - // Assign renderer resource handle to texture slot - let (texture_slot_name, texture_slot) = &mut self.textures[idx]; - let texture = engine - .get_resource::(&texture_slot.texture_handle) - .context(EngineError::InvalidTextureHandleForSlot( - texture_slot_name.to_string(), - ))?; - texture_slot.renderer_resource_handle = - Some(texture.renderer_resource_handle.unwrap()); - - // Update renderer counterpart - engine.renderer.update_material_textures( - self.renderer_resource_handle.unwrap(), - &self.textures, - )?; + #[cfg(not(feature = "headless"))] + { + let mat_name = self.name.clone(); + let new_bind_group = { + let renderer_mat = engine + .resource_manager + .get_resource_by_name::(&mat_name)?; + let renderer_shader = engine + .resource_manager + .get_resource::(&renderer_mat.shader_handle)?; + let mut resolved: HashMap = HashMap::new(); + for (slot_name, mat_tex) in &self.textures { + let tex = engine + .resource_manager + .get_resource::(&mat_tex.texture_handle)?; + let h = engine + .resource_manager + .get_resource_handle::(&tex.name)?; + resolved.insert(slot_name.clone(), h); + } + RendererMaterial::create_textures_bind_group( + engine.renderer.get_device(), + &engine.resource_manager, + renderer_shader.textures_bind_group_layout.as_ref().unwrap(), + &format!("{}_textures", mat_name), + &renderer_shader.texture_slots, + &resolved, + )? + }; + engine + .resource_manager + .get_resource_by_name_mut::(&mat_name)? + .textures_bind_group = Some(new_bind_group); + } } _ => { panic!("Critical: Processing deferred update request with value {} in {} failed. Handling is not implemented", request, get_type_name::().specific_object_style()); @@ -503,27 +653,10 @@ impl Resource for Material { } fn destroy(&mut self, engine: &mut Engine, _self_handle: H) -> Result<()> { - // Destroy renderer resource - if let Some(v) = self.renderer_resource_handle { - engine.renderer.destroy_material(v).unwrap(); - } - - // Find mesh rendering components that use this material and update them - - //for (_scene_handle, _scene) in engine.scene_manager.scenes.iter_mut() { - // let x = &engine.resource_manager; - - // for (entity_handle, mesh_rendering_component) in engine.iterate_one_component::()? { - // if let Some(material_handle) = mesh_rendering_component.material_handle { - // // If mesh rendering component has handle to this material - // if material_handle.data() == self_handle.data() { - // mesh_rendering_component.set_material_handle(Option::::None); - // mesh_rendering_component.update_render_queue_key(&engine.resource_manager).unwrap(); - // } - // } - // } - //} - + #[cfg(not(feature = "headless"))] + engine + .resource_manager + .remove_resource_by_name::(&self.name)?; Ok(()) } } diff --git a/engine/pill_engine/src/resources/mesh.rs b/engine/pill_engine/src/resources/mesh.rs index ae6daf0d..21a844ac 100644 --- a/engine/pill_engine/src/resources/mesh.rs +++ b/engine/pill_engine/src/resources/mesh.rs @@ -1,7 +1,7 @@ use crate::{ ecs::MeshRenderingComponent, engine::Engine, - graphics::RendererMeshHandle, + renderer::resources::RendererMesh, resources::{Resource, ResourceStorage}, }; @@ -31,7 +31,6 @@ pub struct Mesh { pub name: String, #[readonly] pub path: PathBuf, - pub(crate) renderer_resource_handle: Option, mesh_data: Option, // When exporting from Blender, V coordinate is flipped, so we need to flip it back @@ -45,7 +44,6 @@ impl Mesh { Self { name: name.to_string(), path, - renderer_resource_handle: None, mesh_data: None, flip_uv_y: false, } @@ -60,7 +58,6 @@ impl Mesh { Self { name: name.to_string(), path: PathBuf::new(), - renderer_resource_handle: None, mesh_data: Some(mesh_data), flip_uv_y: false, } @@ -170,21 +167,26 @@ impl Resource for Mesh { } } - // Create new renderer mesh resource - let renderer_resource_handle = engine - .renderer - .create_mesh(&self.name, self.mesh_data.as_ref().unwrap()) - .context(error_message.clone())?; - self.renderer_resource_handle = Some(renderer_resource_handle); + #[cfg(not(feature = "headless"))] + { + let renderer_mesh = RendererMesh::new( + engine.renderer.get_device(), + &self.name, + self.mesh_data.as_ref().unwrap(), + ) + .context(error_message)?; + engine.resource_manager.add_resource(renderer_mesh)?; + } Ok(()) } fn destroy(&mut self, engine: &mut Engine, self_handle: H) -> Result<()> { - // Destroy renderer resource - if let Some(v) = self.renderer_resource_handle { - engine.renderer.destroy_mesh(v).unwrap(); - } + // Destroy renderer counterpart + #[cfg(not(feature = "headless"))] + engine + .resource_manager + .remove_resource_by_name::(&self.name)?; // Find mesh rendering components that use this mesh and update them for (_scene_handle, scene) in engine.scene_manager.scenes.iter_mut() { diff --git a/engine/pill_engine/src/resources/mod.rs b/engine/pill_engine/src/resources/mod.rs index 3b2d8750..366d866b 100644 --- a/engine/pill_engine/src/resources/mod.rs +++ b/engine/pill_engine/src/resources/mod.rs @@ -26,8 +26,7 @@ pub use mesh::{Mesh, MeshData, MeshHandle, MeshVertex}; pub use texture::{Texture, TextureHandle, TextureType}; pub use material::{ - get_renderer_texture_handle_from_material_texture, Material, MaterialHandle, MaterialParameter, - MaterialTexture, + Material, MaterialHandle, MaterialParameter, MaterialTexture, PBRMaterial, PBRMaterialHandle, }; pub use shader::{ diff --git a/engine/pill_engine/src/resources/shader.rs b/engine/pill_engine/src/resources/shader.rs index 21f1fddb..685046c6 100644 --- a/engine/pill_engine/src/resources/shader.rs +++ b/engine/pill_engine/src/resources/shader.rs @@ -1,11 +1,10 @@ use crate::{ ecs::DeferredUpdateManagerPointer, engine::Engine, + renderer::resources::RendererShader, resources::{Resource, ResourceLoader, ResourceStorage, TextureType}, }; -use crate::internal::RendererShaderHandle; - use pill_core::{get_type_name, PillSlotMapKey, PillStyle, PillTypeMapKey}; use std::{collections::HashMap, path::Path}; @@ -96,7 +95,6 @@ pub struct Shader { #[readonly] pub enable_camera_binding: bool, - pub(crate) renderer_resource_handle: Option, handle: Option, deferred_update_manager: Option, } @@ -122,7 +120,6 @@ impl Shader { texture_slots, enable_engine_binding, enable_camera_binding, - renderer_resource_handle: None, handle: None, deferred_update_manager: None, } @@ -151,12 +148,6 @@ impl Resource for Shader { get_type_name::().specific_object_style() ); - // This resource is using DeferredUpdateSystem so keep DeferredUpdateManager - //let deferred_update_component = engine.get_global_component_mut::().expect("Critical: No DeferredUpdateComponent"); - //self.deferred_update_manager = Some(deferred_update_component.borrow_deferred_update_manager()); - - // WGSL is the only accepted source format; wgpu consumes WGSL natively - // so there is no runtime compile step. let vertex_bytes = read_wgsl_bytes( &self.vertex_shader_resource_loader, &engine.game_resources_directory_path, @@ -176,22 +167,22 @@ impl Resource for Shader { format!("Fragment shader for {} is not valid UTF-8 WGSL", &self.name).into() })?; - // TODO: Parse shader files and validate texture and parameter slots, or create them automatically here, so the user does not have to do it manually - - // Load data - let renderer_resource_handle = engine - .renderer - .create_shader( - &self.name, - vertex_wgsl, - fragment_wgsl, - &self.texture_slots, - &self.parameter_slots, - self.enable_engine_binding, - self.enable_camera_binding, - ) - .context(error_message)?; - self.renderer_resource_handle = Some(renderer_resource_handle); + #[cfg(not(feature = "headless"))] + { + let renderer_shader = engine + .renderer + .create_shader_struct( + &self.name, + vertex_wgsl, + fragment_wgsl, + &self.texture_slots, + &self.parameter_slots, + self.enable_engine_binding, + self.enable_camera_binding, + ) + .context(error_message)?; + engine.resource_manager.add_resource(renderer_shader)?; + } Ok(()) } @@ -201,26 +192,10 @@ impl Resource for Shader { } fn destroy(&mut self, engine: &mut Engine, _self_handle: H) -> Result<()> { - // Destroy renderer resource - if let Some(v) = self.renderer_resource_handle { - engine.renderer.destroy_shader(v).unwrap(); - } - - // Find materials that use this shader and update them - // for (scene_handle, scene) in engine.scene_manager.scenes.iter_mut() { - // let x = &engine.resource_manager; - - // // for (entity_handle, mesh_rendering_component) in engine.iterate_one_component::()? { - // // if let Some(material_handle) = mesh_rendering_component.material_handle { - // // // If mesh rendering component has handle to this material - // // if material_handle.data() == self_handle.data() { - // // mesh_rendering_component.set_material_handle(Option::::None); - // // mesh_rendering_component.update_render_queue_key(&engine.resource_manager).unwrap(); - // // } - // // } - // // } - // } - + #[cfg(not(feature = "headless"))] + engine + .resource_manager + .remove_resource_by_name::(&self.name)?; Ok(()) } } diff --git a/engine/pill_engine/src/resources/texture.rs b/engine/pill_engine/src/resources/texture.rs index f322e11e..36df54b9 100644 --- a/engine/pill_engine/src/resources/texture.rs +++ b/engine/pill_engine/src/resources/texture.rs @@ -1,8 +1,9 @@ use crate::{ engine::Engine, - graphics::RendererTextureHandle, + renderer::resources::{RendererMaterial, RendererTexture}, resources::{Material, Resource, ResourceLoader, ResourceStorage}, }; +use std::collections::HashMap; use pill_core::{get_type_name, ErrorContext, PillSlotMapKey, PillStyle, PillTypeMapKey, Result}; pill_core::define_new_pill_slotmap_key! { @@ -23,7 +24,6 @@ pub struct Texture { pub resource_loader: ResourceLoader, #[readonly] pub texture_type: TextureType, - pub(crate) renderer_resource_handle: Option, } impl Texture { @@ -32,11 +32,9 @@ impl Texture { name: name.to_string(), resource_loader, texture_type, - renderer_resource_handle: None, } } - /// Decode image bytes (format auto-detected) into a texture. pub fn from_bytes(name: &str, texture_type: TextureType, bytes: &[u8]) -> Self { Self::new( name, @@ -161,49 +159,87 @@ impl Resource for Texture { }; // Create renderer texture resource - let renderer_resource_handle = engine - .renderer - .create_texture(&self.name, &rgba, width, height, self.texture_type) + #[cfg(not(feature = "headless"))] + { + let renderer_texture = RendererTexture::new_texture( + engine.renderer.get_device(), + engine.renderer.get_queue(), + &self.name, + &rgba, + width, + height, + self.texture_type, + ) .context(error_message)?; - self.renderer_resource_handle = Some(renderer_resource_handle); + engine.resource_manager.add_resource(renderer_texture)?; + } Ok(()) } fn destroy(&mut self, engine: &mut Engine, self_handle: H) -> Result<()> { - // Destroy renderer resource - if let Some(v) = self.renderer_resource_handle { - engine.renderer.destroy_texture(v).unwrap(); - } - - // Take resource storage from engine - let resource_storage = engine + #[cfg(not(feature = "headless"))] + engine .resource_manager - .get_resource_storage_mut::()?; - let materials = &mut resource_storage.data; - - // Find materials that use this texture and update them - for material_slot in materials.iter_mut() { - let material: &mut Material = material_slot - .1 - .as_mut() - .expect("Critical: Resource is None"); - - // Update texture slots - let before = material.textures.len(); - material - .textures - .retain(|(_, slot)| slot.texture_handle.data() != self_handle.data()); - - if material.textures.len() < before { - engine - .renderer - .update_material_textures( - material.renderer_resource_handle.unwrap(), - &material.textures, - ) - .unwrap(); + .remove_resource_by_name::(&self.name)?; + + // Phase 1: find and update affected materials (CPU side only) + let affected_material_names: Vec = { + let resource_storage = engine + .resource_manager + .get_resource_storage_mut::()?; + let mut affected = Vec::new(); + for (_, slot) in resource_storage.data.iter_mut() { + let material = slot.as_mut().expect("Critical: Resource is None"); + let before = material.textures.len(); + material + .textures + .retain(|(_, slot)| slot.texture_handle.data() != self_handle.data()); + if material.textures.len() < before { + affected.push(material.name.clone()); + } } + affected + }; + + // Phase 2: rebuild RendererMaterial textures bind group for affected materials + #[cfg(not(feature = "headless"))] + for mat_name in &affected_material_names { + let new_bind_group = { + let renderer_mat = engine + .resource_manager + .get_resource_by_name::(mat_name)?; + let renderer_shader = engine + .resource_manager + .get_resource::( + &renderer_mat.shader_handle, + )?; + let material = engine + .resource_manager + .get_resource_by_name::(mat_name)?; + let mut resolved: HashMap = HashMap::new(); + for (slot_name, mat_tex) in &material.textures { + let tex = engine + .resource_manager + .get_resource::(&mat_tex.texture_handle)?; + let h = engine + .resource_manager + .get_resource_handle::(&tex.name)?; + resolved.insert(slot_name.clone(), h); + } + RendererMaterial::create_textures_bind_group( + engine.renderer.get_device(), + &engine.resource_manager, + renderer_shader.textures_bind_group_layout.as_ref().unwrap(), + &format!("{}_textures", mat_name), + &renderer_shader.texture_slots, + &resolved, + )? + }; + engine + .resource_manager + .get_resource_by_name_mut::(mat_name)? + .textures_bind_group = Some(new_bind_group); } Ok(()) diff --git a/engine/pill_launcher/src/main.rs b/engine/pill_launcher/src/main.rs index 955505f2..b2b1dd50 100644 --- a/engine/pill_launcher/src/main.rs +++ b/engine/pill_launcher/src/main.rs @@ -461,7 +461,8 @@ fn render_puml_for_crate(crate_dir: &Path) -> Result<()> { // Prefer "plantuml" CLI tool if available if !have_cli { - bail!("Please install plantuml!"); + println!("plantuml not found, skipping diagram rendering"); + return Ok(()); } for puml in &inputs { diff --git a/engine/pill_native/src/main.rs b/engine/pill_native/src/main.rs index 534b6c5c..cb974edf 100644 --- a/engine/pill_native/src/main.rs +++ b/engine/pill_native/src/main.rs @@ -140,9 +140,7 @@ fn workspace_includes_game( } fn looks_like_engine_workspace(path: &Path) -> bool { - path.join("pill_core").exists() - && path.join("pill_engine").exists() - && path.join("pill_renderer").exists() + path.join("pill_core").exists() && path.join("pill_engine").exists() } fn engine_workspace_from_game_manifest(game_project_directory_path: &Path) -> Option { @@ -905,7 +903,7 @@ fn create_file_watchers(project_paths: &ProjectPaths) -> FileWatchers { let engine_engine_source_files_watcher = FileWatcher::new(engine_source_path).set_recursive(true); - let renderer_source_path = engine_workspace_directory_path.join("pill_renderer/src"); + let renderer_source_path = engine_workspace_directory_path.join("pill_engine/src/renderer"); let engine_renderer_source_files_watcher = FileWatcher::new(renderer_source_path).set_recursive(true); @@ -1375,10 +1373,8 @@ mod tests { let engine_b = root.join("Pill-Engine-Upstream").join("engine"); fs::create_dir_all(engine_a.join("pill_core")).unwrap(); fs::create_dir_all(engine_a.join("pill_engine")).unwrap(); - fs::create_dir_all(engine_a.join("pill_renderer")).unwrap(); fs::create_dir_all(engine_b.join("pill_core")).unwrap(); fs::create_dir_all(engine_b.join("pill_engine")).unwrap(); - fs::create_dir_all(engine_b.join("pill_renderer")).unwrap(); fs::write( engine_a.join("Cargo.toml"), diff --git a/engine/pill_renderer/Cargo.toml b/engine/pill_renderer/Cargo.toml deleted file mode 100644 index 36252329..00000000 --- a/engine/pill_renderer/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "pill_renderer" -version = "0.1.0" -edition = "2021" - -[dependencies] -pill_core = {path = "../pill_core"} -pill_engine = {path = "../pill_engine", features = ["internal"]} - -# Logging -log = { version = "0.4", features = ["release_max_level_off"] } - -# Other -glam = { version = "0.30.8", features = ["serde", "bytemuck"] } -winit = "0.30.12" -cgmath = "0.18" -bytemuck = { version = "1.4", features = [ "derive" ] } -# egui (optional — enabled by debug_ui feature) -egui = { version = "0.32.1", optional = true } -egui-wgpu = { version = "0.32.1", features = ["winit"], optional = true } - -# Native-only dependencies -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -wgpu = "25.0.0" -pollster = "0.2" -egui-winit = { version = "0.32.1", features = ["clipboard"], optional = true } - -# WASM dependencies -[target.'cfg(target_arch = "wasm32")'.dependencies] -wgpu = { version = "25.0.0", default-features = false, features = ["webgpu", "wgsl"] } -egui-winit = { version = "0.32.1", default-features = false, optional = true } - -[features] -debug_ui = ["dep:egui", "dep:egui-wgpu", "dep:egui-winit", "pill_engine/debug_ui"] diff --git a/engine/pill_renderer/src/config.rs b/engine/pill_renderer/src/config.rs deleted file mode 100644 index efe594bc..00000000 --- a/engine/pill_renderer/src/config.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub const MAX_INSTANCE_PER_DRAWCALL_COUNT: usize = 10000; -pub const INITIAL_INSTANCE_VECTOR_CAPACITY: usize = 10000; - -// Order of bind group layouts in all shaders -pub const ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX: u32 = 0; // (set = 0, binding = X) -pub const CAMERA_PARAMETERS_BIND_GROUP_LAYOUT_INDEX: u32 = 1; // (set = 1, binding = X) -pub const MATERIAL_PARAMETERS_BIND_GROUP_LAYOUT_INDEX: u32 = 2; // (set = 2, binding = X) -pub const MATERIAL_TEXTURES_BIND_GROUP_LAYOUT_INDEX: u32 = 3; // (set = 3, binding = X) diff --git a/engine/pill_renderer/src/instance.rs b/engine/pill_renderer/src/instance.rs deleted file mode 100644 index a177f1ad..00000000 --- a/engine/pill_renderer/src/instance.rs +++ /dev/null @@ -1,96 +0,0 @@ -use crate::resources::Vertex; - -use pill_core::Matrix3f; -use pill_engine::internal::TransformComponent; - -// --- Instance --- - -#[repr(C)] -#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -pub struct Instance { - pub(crate) transform: Matrix3f, // It is matrix3 because we only need the rotation componen -} - -impl Instance { - pub fn new(transform_component: &TransformComponent) -> Instance { - Instance { - transform: Matrix3f::from_cols( - transform_component.position, - transform_component.rotation, - transform_component.scale, - ), - } - } -} - -impl Vertex for Instance { - fn data_layout_descriptor<'a>() -> wgpu::VertexBufferLayout<'a> { - use std::mem; - wgpu::VertexBufferLayout { - array_stride: mem::size_of::() as wgpu::BufferAddress, - // We need to switch from using a step mode of Vertex to Instance - // This means that shaders will only change to use the next instance when the shader starts processing a new instance - step_mode: wgpu::VertexStepMode::Instance, - attributes: &[ - wgpu::VertexAttribute { - // Instance transform position - // slangc maps TEXCOORD1 → @location(1) - offset: 0, - shader_location: 1, - format: wgpu::VertexFormat::Float32x3, - }, - wgpu::VertexAttribute { - // Instance transform rotation - // slangc maps TEXCOORD2 → @location(2) - offset: mem::size_of::<[f32; 3]>() as wgpu::BufferAddress, - shader_location: 2, - format: wgpu::VertexFormat::Float32x3, - }, - wgpu::VertexAttribute { - // Instance transform scale - // slangc maps TEXCOORD3 → @location(3) - offset: mem::size_of::<[f32; 6]>() as wgpu::BufferAddress, - shader_location: 3, - format: wgpu::VertexFormat::Float32x3, - }, // Model matrix (mat4 takes up 4 vertex slots as it is technically 4 vec4s. We need to define a slot for each vec4) - // wgpu::VertexAttribute { - // offset: 0, - // shader_location: 5, - // format: wgpu::VertexFormat::Float32x4, - // }, - // wgpu::VertexAttribute { - // offset: mem::size_of::<[f32; 4]>() as wgpu::BufferAddress, - // shader_location: 6, - // format: wgpu::VertexFormat::Float32x4, - // }, - // wgpu::VertexAttribute { - // offset: mem::size_of::<[f32; 8]>() as wgpu::BufferAddress, - // shader_location: 7, - // format: wgpu::VertexFormat::Float32x4, - // }, - // wgpu::VertexAttribute { - // offset: mem::size_of::<[f32; 12]>() as wgpu::BufferAddress, - // shader_location: 8, - // format: wgpu::VertexFormat::Float32x4, - // }, - - // // Normal matrix - // wgpu::VertexAttribute { - // offset: mem::size_of::<[f32; 16]>() as wgpu::BufferAddress, - // shader_location: 9, - // format: wgpu::VertexFormat::Float32x3, - // }, - // wgpu::VertexAttribute { - // offset: mem::size_of::<[f32; 19]>() as wgpu::BufferAddress, - // shader_location: 10, - // format: wgpu::VertexFormat::Float32x3, - // }, - // wgpu::VertexAttribute { - // offset: mem::size_of::<[f32; 22]>() as wgpu::BufferAddress, - // shader_location: 11, - // format: wgpu::VertexFormat::Float32x3, - // }, - ], - } - } -} diff --git a/engine/pill_renderer/src/lib.rs b/engine/pill_renderer/src/lib.rs deleted file mode 100644 index 374b5814..00000000 --- a/engine/pill_renderer/src/lib.rs +++ /dev/null @@ -1,12 +0,0 @@ -pub mod config; -pub mod drawers; -pub mod instance; -pub mod renderer; -pub mod resources; -//pub mod profiler; - -// --- Use --- - -pub use renderer::*; - -pub use instance::Instance; diff --git a/engine/pill_renderer/src/profiler.rs b/engine/pill_renderer/src/profiler.rs deleted file mode 100644 index 7e033eba..00000000 --- a/engine/pill_renderer/src/profiler.rs +++ /dev/null @@ -1,492 +0,0 @@ -use std::{cell::Cell, num::NonZeroU32}; -use wgpu::{ - core::device::queue, - Adapter, - Buffer, - BufferDescriptor, - BufferUsages, - CommandEncoder, - Device, - Features, - Maintain, - PipelineStatisticsTypes, - QuerySet, - QuerySetDescriptor, - QueryType, - Queue -}; - -/* -This module provides GPU-side profiling capabilities using wgpu. - -- Timestamp queries: - Measures: Time taken by sections of code (eg. shadow pass, main pass, post-processing, etc). - This is true GPU time, showing stalls and bottlenecks on GPU side - Result: Vector of u64 ticks, which can be converted to milliseconds using the provided conversion function. - Not available on the web (needs TIMESTAMP_QUERY feature) - Learnings: - - GPU bottlenecks: If a section takes too long, it indicates a GPU-side bottleneck. - - Frame time analysis: Helps understand how much time each rendering stage takes. - -- Occlusion queries: - Measures: How many fragments(pixels) passed depth/stencil during the draw region maked with begin_occlusion_tracking/end_occlusion_tracking. - Result: Vector of u64 counts, one per query. Will be 0 if fully occluded. - Learnings: - - Overdraw insights: If you see high occlusion counts, it means many fragments were discarded by depth test. - - Visibility culling: Can be used to skip rendering objects that are fully occluded. - - Bound analysis: Can help understand how many pixels are actually visible in the scene. - -- Pipeline statistics: - Collects various pipeline statistics like: - - VERTEX_SHADER_INVOCATIONS — how many times the vertex shader ran (accounts for the vertex cache with indexed draws). - - CLIPPER_INVOCATIONS — number of times the clipper stage was invoked (equals triangles output by the vertex stage). - - CLIPPER_PRIMITIVES_OUT — primitives that survived clipping (triangles that actually proceed to rasterization). - - FRAGMENT_SHADER_INVOCATIONS — how many fragments executed the fragment shader (per-sample with MSAA; GPUs often execute in 2×2 quads for derivatives). - - COMPUTE_SHADER_INVOCATIONS — total compute shader invocations (dispatch count × workgroup size). - Result: Vector of u64 values, one per statistic type requested. - Not available on the web (needs PIPELINE_STATISTICS_QUERY feature) -*/ - -/// How many frames we pipeline readbacks to avoid stalls. -/// You can set this to 2–4 depending on your swapchain latency. -const FRAMES_IN_FLIGHT: usize = 3; - -/// GPU-side profiling using wgpu -pub struct Profiler { - // Capabilities - has_timestamps: bool, - has_pipeline_stats: bool, - - // Query sets (optional if feature unsupported) - timestamp_query_set: Option, - timestamp_query_names: Vec, // To store names associated with each timestamp - occlusion_query_set: Option, - pipeline_statistics_query_set: Option, - pipeline_statistics_types: PipelineStatisticsTypes, - - // Maximum queries we allow per frame for each kind - max_timestamp_queries: u32, - max_occlusion_queries: u32, - max_pipeline_statistics_queries: u32, - - // Rolling per-frame indices - current_timestamp_query: Cell, - current_occlusion_query: Cell, - current_pipeline_statistics_query: Cell, - - // Resolve buffers (ring) for readback, one per in-flight frame - timestamp_buffers: Vec>, - occlusion_buffers: Vec>, - pipeline_buffers: Vec>, - - // Bytes per query result set - timestamp_queries_result_bytes: u64, - occlusion_queries_result_bytes: u64, - pipeline_statistics_queries_result_bytes: u64, - - // Frame index for the ring - frame_index: usize, - - // Conversion period (nanoseconds per timestamp tick) - timestamp_period_ns: f32, -} - -impl Profiler { - pub fn new( - device: &Device, - queue: &Queue, - adapter: &Adapter, - max_timestamp_queries: u32, // Number of timestamp writes planned to record per frame (start/end of sections) - max_occlusion_queries: u32, // Number of occlusion queries per frame - max_pipeline_statistics_queries: u32, // Number of pipeline statistics queries per frame - pipeline_statistics_types: PipelineStatisticsTypes, // Type of pipeline statistics to collect - ) -> Self { - let features = adapter.features(); - let has_timestamps = features.contains(Features::TIMESTAMP_QUERY); - let has_pipeline_statistics = features.contains(Features::PIPELINE_STATISTICS_QUERY); - - // Timestamp query set - let timestamp_query_set = if has_timestamps && max_timestamp_queries > 0 { - Some(device.create_query_set(&QuerySetDescriptor { - label: Some("gpu_profiler.timestamp_query_set"), - ty: QueryType::Timestamp, - count: max_timestamp_queries, - })) - } else { - None - }; - - // Occlusion query set - let occlusion_query_set = if max_occlusion_queries > 0 { - Some(device.create_query_set(&QuerySetDescriptor { - label: Some("gpu_profiler.occlusion_query_set"), - ty: QueryType::Occlusion, - count: max_occlusion_queries, - })) - } else { - None - }; - - // Pipeline statistics query set - let pipeline_statistics_query_set = if has_pipeline_statistics && !pipeline_statistics_types.is_empty() { - Some(device.create_query_set(&QuerySetDescriptor { - label: Some("gpu_profiler.pipeline_statistics_query_set"), - ty: QueryType::PipelineStatistics(pipeline_statistics_types), - count: max_pipeline_statistics_queries, - })) - } else { - None - }; - - // Bytes per query result entry - let timestamp_queries_result_bytes = std::mem::size_of::() as u64; - let occlusion_queries_result_bytes = std::mem::size_of::() as u64; - let pipeline_statistics_fields = pipeline_statistics_types.bits().count_ones() as u64; - let pipeline_statistics_queries_result_bytes = if pipeline_statistics_fields == 0 { 0 } else { pipeline_statistics_fields * std::mem::size_of::() as u64 }; - - // Resolve buffers ring (created lazily on first use) - let timestamp_buffers = (0..FRAMES_IN_FLIGHT).map(|_| None).collect(); - let occlusion_buffers = (0..FRAMES_IN_FLIGHT).map(|_| None).collect(); - let pipeline_buffers = (0..FRAMES_IN_FLIGHT).map(|_| None).collect(); - - // Timestamp period - let timestamp_period_ns = if has_timestamps { queue.get_timestamp_period() } else { 0.0 }; - - Self { - has_timestamps, - has_pipeline_stats: has_pipeline_statistics, - - timestamp_query_set, - timestamp_query_names: Vec::new(), - occlusion_query_set, - pipeline_statistics_query_set, - pipeline_statistics_types, - - max_timestamp_queries, - max_occlusion_queries, - max_pipeline_statistics_queries: max_occlusion_queries.max(1), // we bound ps queries with the same cap for simplicity - - current_timestamp_query: Cell::new(0), - current_occlusion_query: Cell::new(0), - current_pipeline_statistics_query: Cell::new(0), - - timestamp_buffers, - occlusion_buffers, - pipeline_buffers, - - timestamp_queries_result_bytes, - occlusion_queries_result_bytes, - pipeline_statistics_queries_result_bytes, - - frame_index: 0, - timestamp_period_ns, - } - } - - /// Call once at the start of frame. - pub fn begin_frame(&mut self) { - self.current_timestamp_query.set(0); - self.current_occlusion_query.set(0); - self.current_pipeline_statistics_query.set(0); - self.timestamp_query_names.clear(); - } - - /// Call once at the end of frame, after all resolves were scheduled into encoder - /// This advances the ring index so next frame resolves into a different buffer - pub fn end_frame(&mut self) { - self.frame_index = (self.frame_index + 1) % FRAMES_IN_FLIGHT; - } - - // --- Timestamps --- - - pub fn get_timestamp_query_set(&self) -> Option<&QuerySet> { - self.timestamp_query_set.as_ref() - } - - /// Write a timestamp (returns its query index) - /// Called before and after a region to time - pub fn write_timestamp(&mut self, encoder: &mut CommandEncoder, name: &str) -> Option { - if let Some(query_set) = &self.timestamp_query_set { - // Check if there is space for another timestamp - if self.current_timestamp_query.get() >= self.max_timestamp_queries { - println!("Profiler: Max timestamps reached for this frame"); - return None; - } - - let index = self.current_timestamp_query.get(); - self.current_timestamp_query.set(index + 1); - encoder.write_timestamp(query_set, index); - - // Store the name in parallel with the timestamp index - self.timestamp_query_names.push(name.to_string()); - - Some(index) - } else { - None - } - } - - /// Resolve all timestamps recorded so far this frame into the ring buffer - pub fn resolve_timestamp_queries(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder) { - if let Some(query_set) = &self.timestamp_query_set { - let count = self.current_timestamp_query.get(); - if count == 0 { return; } - let byte_len = self.timestamp_queries_result_bytes * count as u64; - - let index = self.frame_index; - let slot = &mut self.timestamp_buffers[index]; - let buffer = ensure_buffer_slot(device, slot, byte_len, "gpu_profiler.timestamp_queries.resolve"); - - encoder.resolve_query_set(query_set, 0..count, buffer, 0); - } - } - - /// Blocking readback of all timestamps for the frame that was resolved into the previous ring slot - /// Returns the raw u64 ticks and also a helper to convert ticks to ms - pub fn read_timestamp_queries_blocking(&self, device: &Device) -> Option> { - let index = (self.frame_index + FRAMES_IN_FLIGHT - 1) % FRAMES_IN_FLIGHT; - let buffer = self.timestamp_buffers[index].as_ref()?; - let slice = buffer.slice(..); - - // Map and wait - slice.map_async(wgpu::MapMode::Read, |_| {}); - device.poll(Maintain::Wait); - let data = slice.get_mapped_range(); - let values: Vec = bytemuck::cast_slice(&data).to_vec(); - - drop(data); - buffer.unmap(); - Some(values) - } - - /// Convert a delta of timestamp ticks to milliseconds. - pub fn timestamp_ticks_to_ms(&self, delta_ticks: u64) -> f32 { - (delta_ticks as f32 * self.timestamp_period_ns) / 1_000_000.0 - } - - pub fn summarize_timestamp_queries(&self, ticks: &[u64]) { - if ticks.len() < 2 { - println!("[GPU] no timestamp sections recorded"); - return; - } - let want = ticks.len() - 1; - let use_default = self.timestamp_query_names.len() != want; - - for (i, window) in ticks.windows(2).enumerate() { - let delta_ticks = window[1] - window[0]; - let ms = self.timestamp_ticks_to_ms(delta_ticks); - let label = if use_default { - format!("Section {}", i) - } else { - self.timestamp_query_names[i].to_string() - }; - println!("[GPU] {:<24}: {:6.3} ms", label, ms); - } - } - - // --- Occlusion --- - - /// Expose the occlusion query set for putting into `RenderPassDescriptor.occlusion_query_set`. - pub fn get_occlusion_query_set(&self) -> Option<&QuerySet> { - self.occlusion_query_set.as_ref() - } - - /// Begin an occlusion query within a render pass. Returns query index. - pub fn begin_occlusion_query(&self, render_pass: &mut wgpu::RenderPass<'_>) -> Option { - if let Some(_query_set) = &self.occlusion_query_set { - // Check if there is space for another occlusion query - if self.current_occlusion_query.get() >= self.max_occlusion_queries { - println!("Profiler: Max occlusion queries reached for this frame"); - return None; - } - - let index = self.current_occlusion_query.get(); - self.current_occlusion_query.set(index + 1); - render_pass.begin_occlusion_query(index); - Some(index) - } else { - None - } - } - - pub fn end_occlusion_query(&self, render_pass: &mut wgpu::RenderPass<'_>) { - render_pass.end_occlusion_query(); - } - - /// Resolve occlusion queries recorded this frame. - pub fn resolve_occlusion_queries(&mut self, device: &Device, encoder: &mut CommandEncoder) { - if let Some(query_set) = &self.occlusion_query_set { - let count = self.current_occlusion_query.get(); - if count == 0 { return; } - let byte_len = self.occlusion_queries_result_bytes * count as u64; - - let index = self.frame_index; - let slot = &mut self.occlusion_buffers[index]; - let buffer = ensure_buffer_slot(device, slot, byte_len, "gpu_profiler.occlusion_queries.resolve"); - - encoder.resolve_query_set(query_set, 0..count, buffer, 0); - } - } - - pub fn read_occlusion_queries_blocking(&self, device: &Device) -> Option> { - let index = (self.frame_index + FRAMES_IN_FLIGHT - 1) % FRAMES_IN_FLIGHT; - let buffer = self.occlusion_buffers[index].as_ref()?; - let slice = buffer.slice(..); - - // Map and wait - slice.map_async(wgpu::MapMode::Read, |_| {}); - device.poll(Maintain::Wait); - let data = slice.get_mapped_range(); - let values: Vec = bytemuck::cast_slice(&data).to_vec(); - - drop(data); - buffer.unmap(); - Some(values) - } - - pub fn summarize_occlusion_queries(&self, samples: &[u64]) { - if samples.is_empty() { - println!("[GPU] no occlusion queries recorded"); - return; - } - let mut visible = 0usize; - for (i, &sample) in samples.iter().enumerate() { - let is_visible = sample > 0; - if is_visible { visible += 1; } - println!("[GPU] occlusion[{:02}] = {:>12} {}", i, sample, if is_visible { "(visible)" } else { "(occluded)" }); - } - println!("[GPU] occlusion visible: {}/{}", visible, samples.len()); - } - - // --- Pipeline statistics --- - - /// Expose the pipeline stats query set (and mask) so you can begin/end in passes - pub fn pipeline_statistics_query_set(&self) -> Option<(&QuerySet, PipelineStatisticsTypes)> { - self.pipeline_statistics_query_set - .as_ref() - .map(|query_set| (query_set, self.pipeline_statistics_types)) - } - - /// Begin a pipeline statistics query in the pass; returns query index - pub fn begin_pipeline_statistics_query(&self, render_pass: &mut wgpu::RenderPass<'_>) -> Option { - if let Some(query_set) = &self.pipeline_statistics_query_set { - // Check if there is space for another pipeline statistics query - if self.current_pipeline_statistics_query.get() >= self.max_pipeline_statistics_queries { - println!("Profiler: Max pipeline statistics queries reached for this frame"); - return None; - } - - let index = self.current_pipeline_statistics_query.get(); - self.current_pipeline_statistics_query.set(index + 1); - render_pass.begin_pipeline_statistics_query(query_set, index); - Some(index) - } else { - None - } - } - - pub fn summarize_pipeline_statistics_queries(&self, raw: &[u64]) { - use wgpu::PipelineStatisticsTypes as PST; - - let mask = self.pipeline_statistics_types; - if raw.is_empty() || mask.is_empty() { - println!("[GPU] no pipeline statistics recorded"); - return; - } - - let mut layout: Vec<(&'static str, wgpu::PipelineStatisticsTypes)> = Vec::new(); - let push = |v: &mut Vec<_>, name, flag, mask: wgpu::PipelineStatisticsTypes| { if mask.contains(flag) { v.push((name, flag)); } }; - push(&mut layout, "VS invocations", wgpu::PipelineStatisticsTypes::VERTEX_SHADER_INVOCATIONS, mask); - push(&mut layout, "Clipper invocations", wgpu::PipelineStatisticsTypes::CLIPPER_INVOCATIONS, mask); - push(&mut layout, "Clipper primitives out", wgpu::PipelineStatisticsTypes::CLIPPER_PRIMITIVES_OUT, mask); - push(&mut layout, "FS invocations", wgpu::PipelineStatisticsTypes::FRAGMENT_SHADER_INVOCATIONS, mask); - push(&mut layout, "CS invocations", wgpu::PipelineStatisticsTypes::COMPUTE_SHADER_INVOCATIONS, mask); - - let stride = layout.len(); - if stride == 0 { - println!("[GPU] pipeline statistics mask is empty"); - return; - } - - for (query, chunk) in raw.chunks(stride).enumerate() { - if chunk.len() < stride { break; } - println!("[GPU] pipeline stats query {}:", query); - for ((name, _flag), &value) in layout.iter().zip(chunk.iter()) { - println!(" {:>24}: {}", name, value); - } - } - } - - pub fn end_pipeline_statistics_query(&self, render_pass: &mut wgpu::RenderPass<'_>) { - render_pass.end_pipeline_statistics_query(); - } - - pub fn resolve_pipeline_statistics_queries(&mut self, device: &Device, encoder: &mut CommandEncoder) { - if let Some(query_set) = &self.pipeline_statistics_query_set { - let count = self.current_pipeline_statistics_query.get(); - if count == 0 { return; } - let byte_len = self.pipeline_statistics_queries_result_bytes * count as u64; - - let index = self.frame_index; - let slot = &mut self.pipeline_buffers[index]; - let buffer = ensure_buffer_slot(device, slot, byte_len, "gpu_profiler.pipeline_statistics_queries.resolve"); - - encoder.resolve_query_set(query_set, 0..count, buffer, 0); - } - } - - pub fn read_pipeline_statistics_queries_blocking(&self, device: &Device) -> Option> { - let index = (self.frame_index + FRAMES_IN_FLIGHT - 1) % FRAMES_IN_FLIGHT; - let buffer = self.pipeline_buffers[index].as_ref()?; - let slice = buffer.slice(..); - - // Map and wait - slice.map_async(wgpu::MapMode::Read, |_| {}); - device.poll(Maintain::Wait); - let data = slice.get_mapped_range(); - let values: Vec = bytemuck::cast_slice(&data).to_vec(); - - drop(data); - buffer.unmap(); - Some(values) - } - - // --- Misc --- - - pub fn summarize_all_blocking(&self, device: &wgpu::Device) { - if let Some(timestamp_queries) = self.read_timestamp_queries_blocking(device) { - self.summarize_timestamp_queries(×tamp_queries); - } - if let Some(occlusion_queries) = self.read_occlusion_queries_blocking(device) { - self.summarize_occlusion_queries(&occlusion_queries); - } - if let Some(pipeline_statistics_queries) = self.read_pipeline_statistics_queries_blocking(device) { - self.summarize_pipeline_statistics_queries(&pipeline_statistics_queries); - } - } -} - -#[inline] -fn ensure_buffer_slot<'a>( - device: &wgpu::Device, - slot: &'a mut Option, - size: u64, - label: &str, -) -> &'a wgpu::Buffer { - let need_new = slot.as_ref().map(|b| b.size() < size).unwrap_or(true); - if need_new { - *slot = Some(device.create_buffer(&BufferDescriptor { - label: Some(label), - size, - usage: BufferUsages::COPY_SRC | wgpu::BufferUsages::MAP_READ | BufferUsages::QUERY_RESOLVE, - mapped_at_creation: false, - })); - // *slot = Some(device.create_buffer(&wgpu::BufferDescriptor { - // label: Some(label), - // size, - // usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::QUERY_RESOLVE, - // mapped_at_creation: false, - // })); - } - slot.as_ref().unwrap() -} \ No newline at end of file diff --git a/engine/pill_renderer/src/renderer.rs b/engine/pill_renderer/src/renderer.rs deleted file mode 100644 index 6d34ad57..00000000 --- a/engine/pill_renderer/src/renderer.rs +++ /dev/null @@ -1,736 +0,0 @@ -#![allow(clippy::too_many_arguments)] -use crate::{ - config::MAX_INSTANCE_PER_DRAWCALL_COUNT, - drawers::mesh_drawer::MeshDrawer, - instance::Instance, - resources::{ - RendererCamera, RendererMaterial, RendererMesh, RendererResourceStorage, RendererShader, - RendererTexture, Vertex, - }, -}; - -use pill_engine::internal::{ - get_renderer_resource_handle_from_camera_component, CameraComponent, ComponentStorage, - EngineConfig, EntityHandle, MaterialParameter, MaterialTexture, MeshData, PillRenderer, - RenderQueueItem, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, - RendererShaderHandle, RendererTextureHandle, ShaderParameterSlot, ShaderTextureSlot, - TextureType, TransformComponent, -}; - -use pill_core::{debug, info, LogContext, PillSlotMapKey, PillStyle, RendererError, Timer}; - -use std::{collections::HashMap, sync::Arc}; - -use pill_core::{ErrorContext, Result}; - -pub struct Renderer { - pub state: State, -} - -impl Renderer { - /// Async constructor for WASM - call this instead of PillRenderer::new on web - pub async fn new_async( - window: Arc, - config: EngineConfig, - ) -> Result { - info!(LogContext::Rendering => "Initializing {}", "Renderer".module_object_style()); - let state: State = State::new(window, config).await?; - Ok(Self { state }) - } -} - -impl PillRenderer for Renderer { - #[cfg(not(target_arch = "wasm32"))] - fn new(window: Arc, config: EngineConfig) -> Result { - info!(LogContext::Rendering => "Initializing {}", "Renderer".module_object_style()); - let state: State = pollster::block_on(State::new(window, config))?; - - Ok(Self { state }) - } - - #[cfg(target_arch = "wasm32")] - fn new(_window: Arc, _config: EngineConfig) -> Result { - panic!("Use Renderer::new_async on WASM") - } - - // --- Create --- - - fn create_shader( - &mut self, - name: &str, - vertex_wgsl: &str, - fragment_wgsl: &str, - texture_slots: &HashMap, - parameter_slots: &[(String, ShaderParameterSlot)], - pass_engine_parameters: bool, - pass_camera_parameters: bool, - ) -> Result { - let shader = RendererShader::new( - name, - &self.state.device, - self.state.color_format, - Some(self.state.depth_format), - &[ - RendererMesh::data_layout_descriptor(), - Instance::data_layout_descriptor(), - ], - vertex_wgsl, - fragment_wgsl, - parameter_slots, - texture_slots, - &self - .state - .renderer_resource_storage - .engine_parameters - .bind_group_layout, - &self.state.camera_bind_group_layout, - pass_engine_parameters, - pass_camera_parameters, - )?; - let handle = self.state.renderer_resource_storage.shaders.insert(shader); - - Ok(handle) - } - - fn create_material( - &mut self, - name: &str, - renderer_shader_handle: RendererShaderHandle, - textures: &[(String, MaterialTexture)], - parameters: &HashMap, - ) -> Result { - let material = RendererMaterial::new( - &self.state.device, - &self.state.queue, - &self.state.renderer_resource_storage, - name, - renderer_shader_handle, - textures, - parameters, - )?; - let handle = self - .state - .renderer_resource_storage - .materials - .insert(material); - Ok(handle) - } - - fn create_texture( - &mut self, - name: &str, - rgba: &[u8], - width: u32, - height: u32, - texture_type: TextureType, - ) -> Result { - let texture = RendererTexture::new_texture( - &self.state.device, - &self.state.queue, - Some(name), - rgba, - width, - height, - texture_type, - )?; - let handle = self - .state - .renderer_resource_storage - .textures - .insert(texture); - Ok(handle) - } - - fn create_mesh(&mut self, name: &str, mesh_data: &MeshData) -> Result { - let mesh = RendererMesh::new(&self.state.device, name, mesh_data)?; - let handle = self.state.renderer_resource_storage.meshes.insert(mesh); - Ok(handle) - } - - fn create_camera(&mut self) -> Result { - let camera = RendererCamera::new( - &self.state.device, - self.state.camera_bind_group_layout.clone(), - )?; - let handle = self.state.renderer_resource_storage.cameras.insert(camera); - Ok(handle) - } - - // --- Update --- - - fn update_material_textures( - &mut self, - renderer_material_handle: RendererMaterialHandle, - textures: &[(String, MaterialTexture)], - ) -> Result<()> { - RendererMaterial::update_textures( - &self.state.device, - renderer_material_handle, - &mut self.state.renderer_resource_storage, - textures, - ) - } - - fn update_material_parameters( - &mut self, - renderer_material_handle: RendererMaterialHandle, - parameters: &HashMap, - ) -> Result<()> { - RendererMaterial::update_parameters( - &self.state.device, - &self.state.queue, - renderer_material_handle, - &mut self.state.renderer_resource_storage, - parameters, - ) - } - - // --- Destroy --- - - fn destroy_shader(&mut self, renderer_shader_handle: RendererShaderHandle) -> Result<()> { - self.state - .renderer_resource_storage - .shaders - .remove(renderer_shader_handle) - .unwrap(); - - // TODO: Check if there are no materials using this shader (engine should replace them with default shader), if there are prevent shader destruction - Ok(()) - } - - fn destroy_material(&mut self, renderer_material_handle: RendererMaterialHandle) -> Result<()> { - self.state - .renderer_resource_storage - .materials - .remove(renderer_material_handle) - .unwrap(); - Ok(()) - } - - fn destroy_texture(&mut self, renderer_texture_handle: RendererTextureHandle) -> Result<()> { - self.state - .renderer_resource_storage - .textures - .remove(renderer_texture_handle) - .unwrap(); - Ok(()) - } - - fn destroy_mesh(&mut self, renderer_mesh_handle: RendererMeshHandle) -> Result<()> { - self.state - .renderer_resource_storage - .meshes - .remove(renderer_mesh_handle) - .unwrap(); - Ok(()) - } - - fn destroy_camera(&mut self, renderer_camera_handle: RendererCameraHandle) -> Result<()> { - self.state - .renderer_resource_storage - .cameras - .remove(renderer_camera_handle) - .unwrap(); - Ok(()) - } - - // --- Other --- - - fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize) { - info!(LogContext::Rendering => "Resizing {} resources", "Renderer".module_object_style()); - self.state.resize(new_window_size) - } - - #[cfg(feature = "debug_ui")] - fn pass_input_to_egui(&mut self, event: &winit::event::WindowEvent) -> Result<()> { - self.state.egui_drawer.handle_input(event); - Ok(()) - } - - #[cfg(feature = "debug_ui")] - fn render( - &mut self, - active_camera_entity_handle: EntityHandle, - render_queue: &[RenderQueueItem], - camera_component_storage: &ComponentStorage, - transform_component_storage: &ComponentStorage, - egui_ui: Box, - delta_time: f32, - timer: &mut Timer, - ) -> Result<()> { - self.state.pending_egui_ui = Some(egui_ui); - self.state.render( - active_camera_entity_handle, - render_queue, - camera_component_storage, - transform_component_storage, - delta_time, - timer, - ) - } - - #[cfg(not(feature = "debug_ui"))] - fn render( - &mut self, - active_camera_entity_handle: EntityHandle, - render_queue: &[RenderQueueItem], - camera_component_storage: &ComponentStorage, - transform_component_storage: &ComponentStorage, - delta_time: f32, - timer: &mut Timer, - ) -> Result<()> { - self.state.render( - active_camera_entity_handle, - render_queue, - camera_component_storage, - transform_component_storage, - delta_time, - timer, - ) - } -} - -pub struct State { - // Resources - renderer_resource_storage: RendererResourceStorage, - // Renderer variables - surface: wgpu::Surface<'static>, - device: wgpu::Device, - queue: wgpu::Queue, - surface_configuration: wgpu::SurfaceConfiguration, - window_size: winit::dpi::PhysicalSize, - color_format: wgpu::TextureFormat, - depth_format: wgpu::TextureFormat, - depth_texture: RendererTexture, - // Drawers - mesh_drawer: MeshDrawer, - #[cfg(feature = "debug_ui")] - egui_drawer: crate::drawers::egui_drawer::EguiDrawer, - #[cfg(feature = "debug_ui")] - pending_egui_ui: Option>, - // Other - camera_bind_group_layout: wgpu::BindGroupLayout, - //profiler: Profiler, -} - -impl State { - // Creating some of the wgpu types requires async code - async fn new(window: Arc, config: EngineConfig) -> Result { - let window_width = config - .get_int("WINDOW_WIDTH") - .context("WINDOW_WIDTH is missing from config")? as u32; - let window_height = config - .get_int("WINDOW_HEIGHT") - .context("WINDOW_HEIGHT is missing from config")? as u32; - let window_size = winit::dpi::PhysicalSize::new(window_width, window_height); - #[cfg(feature = "debug_ui")] - let window_ref = window.clone(); - - // 1. Create instance and surface - let (instance, surface) = { - let backends = match std::env::var("WGPU_BACKENDS").as_deref() { - std::result::Result::Ok("VULKAN") => wgpu::Backends::VULKAN, - std::result::Result::Ok("DX12") => wgpu::Backends::DX12, - std::result::Result::Ok("METAL") => wgpu::Backends::METAL, - std::result::Result::Ok("GL") => wgpu::Backends::GL, - std::result::Result::Ok("BROWSER_WEBGPU") => wgpu::Backends::BROWSER_WEBGPU, - _ => wgpu::Backends::all(), - }; - - let instance_descriptor = wgpu::InstanceDescriptor { - backends, - flags: wgpu::InstanceFlags::from_build_config().with_env(), - backend_options: wgpu::BackendOptions::default(), - }; - - let instance = wgpu::Instance::new(&instance_descriptor); - let surface = instance - .create_surface(window) - .context("Failed to create surface")?; - (instance, surface) - }; - - // 2. Adapter - let adapter = { - let request_adapter_options = wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::default(), - compatible_surface: Some(&surface), - force_fallback_adapter: false, - }; - instance - .request_adapter(&request_adapter_options) - .await - .context("Failed to request adapter")? - }; - - let info = adapter.get_info(); - info!(LogContext::Rendering => "Using GPU: {} ({:?})", info.name, info.backend); - - // 3. Device and queue - let (device, queue) = { - // Ask only for features the adapter actually supports. On Metal - // (macOS) PIPELINE_STATISTICS_QUERY isn't available; on some - // WebGPU adapters TIMESTAMP_QUERY isn't either. Downstream code - // (profiler.rs) checks `device.features().contains(...)` before - // using them, so narrowing here is safe. - let wanted = wgpu::Features::DEPTH_CLIP_CONTROL - | wgpu::Features::TIMESTAMP_QUERY - | wgpu::Features::PIPELINE_STATISTICS_QUERY; - let features = wanted & adapter.features(); - - let device_descriptor = wgpu::DeviceDescriptor { - label: None, - required_features: features, - required_limits: wgpu::Limits::default(), - memory_hints: wgpu::MemoryHints::default(), - trace: wgpu::Trace::default(), - }; - - adapter - .request_device(&device_descriptor) - .await - .context("Failed to request device")? - }; - - // 4. Surface configuration - let (surface_configuration, color_format, depth_format) = { - let preferred_format = wgpu::TextureFormat::Rgba8UnormSrgb; - - // Get supported present modes and choose the best one - let surface_capabilities = surface.get_capabilities(&adapter); - - // Present mode: on wasm, Mailbox/Immediate aren't universally - // supported — stick to Fifo. On native, prefer Mailbox → Immediate - // → Fifo based on what the surface advertises. - #[cfg(target_arch = "wasm32")] - let present_mode = wgpu::PresentMode::Fifo; - #[cfg(not(target_arch = "wasm32"))] - let present_mode = if surface_capabilities - .present_modes - .contains(&wgpu::PresentMode::Mailbox) - { - wgpu::PresentMode::Mailbox - } else if surface_capabilities - .present_modes - .contains(&wgpu::PresentMode::Immediate) - { - wgpu::PresentMode::Immediate - } else { - wgpu::PresentMode::Fifo - }; - - // Choose the best supported format - let format = if surface_capabilities.formats.contains(&preferred_format) { - preferred_format - } else if surface_capabilities - .formats - .contains(&wgpu::TextureFormat::Bgra8UnormSrgb) - { - wgpu::TextureFormat::Bgra8UnormSrgb - } else if surface_capabilities - .formats - .contains(&wgpu::TextureFormat::Bgra8Unorm) - { - wgpu::TextureFormat::Bgra8Unorm - } else { - surface_capabilities.formats[0] // Use first available format - }; - - let surface_configuration = wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - format, - width: window_size.width, - height: window_size.height, - desired_maximum_frame_latency: 2, - present_mode, - alpha_mode: wgpu::CompositeAlphaMode::Auto, - view_formats: vec![format], - }; - surface.configure(&device, &surface_configuration); - let color_format = surface_configuration.format; - let depth_format = wgpu::TextureFormat::Depth32Float; - (surface_configuration, color_format, depth_format) - }; - - // 5. Depth texture - let depth_texture = - RendererTexture::new_depth_texture(&device, &surface_configuration, "depth_texture") - .context("Failed to create depth texture")?; - - // 6. Define camera bind group layout - // Each camera instance has the same bind group layout is we define it here once - let camera_bind_group_layout = - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("camera_parameters_bind_group_layout"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, // (set = X, binding = 0) - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, // Specifies if this buffer will be changing size or not - min_binding_size: None, - }, - count: None, - }], - }); - - // 7. Resource storage - let renderer_resource_storage = RendererResourceStorage::new(&device, &config)?; - - // 8. Drawers - let mesh_drawer = MeshDrawer::new(&device, MAX_INSTANCE_PER_DRAWCALL_COUNT as u32); - #[cfg(feature = "debug_ui")] - let egui_drawer = crate::drawers::egui_drawer::EguiDrawer::new( - &device, - surface_configuration.format, - None, - 1, - window_ref, - ); - - // 9. Profiler - // let profiler = { - // Profiler::new( - // &device, - // &queue, - // &adapter, - // 16, // up to 16 timestamp marks per frame - // 64, // up to 64 occlusion queries per frame - // 64, // up to 64 pipeline statistics queries per frame - // wgpu::PipelineStatisticsTypes::VERTEX_SHADER_INVOCATIONS - // | wgpu::PipelineStatisticsTypes::FRAGMENT_SHADER_INVOCATIONS, - // ) - // }; - - // Create state - let renderer = Self { - // Resources - renderer_resource_storage, - // Renderer variables - surface, - device, - queue, - surface_configuration, - window_size, - color_format, - depth_format, - depth_texture, - // Drawers - mesh_drawer, - #[cfg(feature = "debug_ui")] - egui_drawer, - #[cfg(feature = "debug_ui")] - pending_egui_ui: None, - // Other - camera_bind_group_layout, - // profiler - }; - - Ok(renderer) - } - - fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize) { - if new_window_size.width > 0 && new_window_size.height > 0 { - self.window_size = new_window_size; - self.surface_configuration.width = new_window_size.width; - self.surface_configuration.height = new_window_size.height; - self.surface - .configure(&self.device, &self.surface_configuration); - self.depth_texture = RendererTexture::new_depth_texture( - &self.device, - &self.surface_configuration, - "depth_texture", - ) - .unwrap(); - } - } - - fn render( - &mut self, - active_camera_entity_handle: EntityHandle, - render_queue: &[RenderQueueItem], - camera_component_storage: &ComponentStorage, - transform_component_storage: &ComponentStorage, - _delta_time: f32, - timer: &mut Timer, - ) -> Result<()> { - debug!(LogContext::Frame => "Starting frame render"); - - timer.record("Get frame"); - // self.profiler.begin_frame(); - - // Get frame or return mapped error if failed - let frame = self.surface.get_current_texture(); - let frame = match frame { - std::result::Result::Ok(frame) => frame, - std::result::Result::Err(error) => match error { - wgpu::SurfaceError::Lost => return Err(RendererError::SurfaceLost.into()), - wgpu::SurfaceError::OutOfMemory => { - return Err(RendererError::SurfaceOutOfMemory.into()) - } - _ => return Err(RendererError::SurfaceOther.into()), - }, - }; - - let view = frame - .texture - .create_view(&wgpu::TextureViewDescriptor::default()); - - timer.record("Update camera parameters"); - - // Get active camera and update it - let camera_storage = camera_component_storage - .data - .get(active_camera_entity_handle.data().index as usize) - .unwrap(); - let active_camera_component = camera_storage.as_ref().unwrap(); - - timer.record("Update engine parameters"); - - // Engine uniform is updated AFTER the camera lookup so fog (carried on - // CameraComponent) can be forwarded into the `engine` UBO alongside delta_time. - self.renderer_resource_storage.engine_parameters.update( - &self.queue, - active_camera_component.fog_density, - [ - active_camera_component.fog_color.x, - active_camera_component.fog_color.y, - active_camera_component.fog_color.z, - ], - ); - let renderer_camera = self - .renderer_resource_storage - .cameras - .get_mut(get_renderer_resource_handle_from_camera_component( - active_camera_component, - )) - .ok_or_else(|| -> pill_core::PillError { - RendererError::RendererResourceNotFound.into() - })?; - let camera_transform_storage = transform_component_storage - .data - .get(active_camera_entity_handle.data().index as usize) - .unwrap(); - let active_camera_transform_component = camera_transform_storage.as_ref().unwrap(); - renderer_camera.update( - &self.queue, - active_camera_component, - active_camera_transform_component, - ); - let renderer_camera = self - .renderer_resource_storage - .cameras - .get(get_renderer_resource_handle_from_camera_component( - active_camera_component, - )) - .unwrap(); - let clear_color = active_camera_component.clear_color; - - // Build a command buffer that can be sent to the GPU - let mut encoder = self - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("render_encoder"), - }); - - // let _timestamp_query_start = self.profiler.write_timestamp(&mut encoder, "Start Render Pass"); - - // Render meshes - { - timer.record("Create render pass attachments"); - - // Create color attachment - let color_attachment = wgpu::RenderPassColorAttachment { - view: &view, // Specifies what texture to save the colors to - resolve_target: None, // Specifies what texture will receive the resolved output - ops: wgpu::Operations { - // Specifies what to do with the colors on the screen - load: wgpu::LoadOp::Clear(wgpu::Color { - r: clear_color.x as f64, - g: clear_color.y as f64, - b: clear_color.z as f64, - a: 1.0, - }), // Specifies how to handle colors stored from the previous frame - store: wgpu::StoreOp::Store, - }, - //depth_slice: None, - }; - - // Create depth attachment - let depth_stencil_attachment = wgpu::RenderPassDepthStencilAttachment { - view: &self.depth_texture.texture_view, - depth_ops: Some(wgpu::Operations { - load: wgpu::LoadOp::Clear(1.0), - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }; - - debug!(LogContext::Frame => "Start recording mesh draw commands"); - timer.begin_context("Mesh Drawer"); - - self.mesh_drawer.record_draw_commands( - &self.queue, - &mut encoder, - &self.renderer_resource_storage, - color_attachment, - depth_stencil_attachment, - renderer_camera, - render_queue, - transform_component_storage, - timer, - //&mut self.profiler - )?; - - timer.end_context()?; - } - - // Render egui UI - #[cfg(feature = "debug_ui")] - if let Some(egui_ui) = self.pending_egui_ui.take() { - timer.begin_context("Egui Draw"); - debug!(LogContext::Frame => "Start recording egui draw commands"); - - self.egui_drawer.record_draw_commands( - &self.device, - &self.queue, - &mut encoder, - &view, - egui_wgpu::ScreenDescriptor { - size_in_pixels: [ - self.surface_configuration.width, - self.surface_configuration.height, - ], - pixels_per_point: self.egui_drawer.window_scale_factor, - }, - egui_ui, - timer, - )?; - - timer.end_context()?; // End Egui Draw context - } - // let _timestamp_query_end = self.profiler.write_timestamp(&mut encoder, "End Render Pass"); - - // Resolve queries recorded this frame - // self.profiler.resolve_timestamp_queries(&self.device, &mut encoder); - // self.profiler.resolve_occlusion_queries(&self.device, &mut encoder); - // self.profiler.resolve_pipeline_statistics_queries(&self.device, &mut encoder); - - timer.record("Submit commands and present frame"); - - // Submit the command buffer to the GPU - self.queue.submit(std::iter::once(encoder.finish())); - - timer.record("Read profiling data"); - - // self.profiler.end_frame(); - - // Read profiling data - //self.profiler.summarize_all_blocking(&self.device); - - // Present the frame - frame.present(); - - Ok(()) - } -} diff --git a/engine/pill_renderer/src/resources/renderer_material.rs b/engine/pill_renderer/src/resources/renderer_material.rs deleted file mode 100644 index ed69cf21..00000000 --- a/engine/pill_renderer/src/resources/renderer_material.rs +++ /dev/null @@ -1,332 +0,0 @@ -#![cfg_attr(debug_assertions, allow(dead_code, unused_imports, unused_variables))] - -use pill_core::{debug, LogContext, PillStyle, RendererError}; -use pill_engine::internal::{ - get_default_texture_handles, get_renderer_texture_handle_from_material_texture, - MaterialParameter, MaterialTexture, RendererMaterialHandle, RendererShaderHandle, - ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, -}; - -use crate::resources::RendererResourceStorage; -use pill_core::Result; -use std::collections::HashMap; - -// --- Material --- - -pub struct RendererMaterial { - pub name: String, - pub shader_handle: RendererShaderHandle, - pub parameters_bind_group: Option, - pub textures_bind_group: Option, - pub(crate) parameters_uniform_buffer: Option, -} - -impl RendererMaterial { - pub fn new( - device: &wgpu::Device, - queue: &wgpu::Queue, - rendering_resource_storage: &RendererResourceStorage, - name: &str, - shader_handle: RendererShaderHandle, - textures: &[(String, MaterialTexture)], - parameters: &HashMap, - ) -> Result { - debug!(LogContext::Rendering => "Creating material {}", name.name_style()); - - let shader = rendering_resource_storage - .shaders - .get(shader_handle) - .ok_or_else(|| -> pill_core::PillError { - RendererError::RendererResourceNotFound.into() - })?; - - let parameter_slots = &shader.parameter_slots; - let texture_slots = &shader.texture_slots; - - // Create parameters uniform buffer and bind group if there are parameter slots - let (parameters_bind_group, parameters_uniform_buffer) = { - if !parameter_slots.is_empty() { - // Calculate uniform buffer size, create buffer if needed and write data to it - let parameters_uniform_buffer_size = Self::calculate_uniform_size(parameter_slots); - - let parameters_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some(&format!("{}_material_parameters_buffer", name)), - size: parameters_uniform_buffer_size as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - // Write parameter data to buffer - Self::write_parameters_to_buffer( - queue, - ¶meters_uniform_buffer, - parameter_slots, - parameters, - )?; - - debug!(LogContext::Rendering => "Uniform buffer of size {} bytes created", parameters_uniform_buffer_size); - - // Create parameters uniform buffer bind group - let parameters_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some(&format!("{}_material_parameters_bind_group", name)), - layout: shader.parameters_bind_group_layout.as_ref().unwrap(), - entries: &[wgpu::BindGroupEntry { - binding: 0, // (set = 2, binding = 0) - resource: parameters_uniform_buffer.as_entire_binding(), - }], - }); - - debug!(LogContext::Rendering => "Parameters bind group created"); - - (Some(parameters_bind_group), Some(parameters_uniform_buffer)) - } else { - debug!(LogContext::Rendering => "No parameter slots found, skipping uniform buffer and bind group creation"); - (None, None) - } - }; - - // Create texture bind group - let textures_bind_group = if !texture_slots.is_empty() { - Some(Self::create_textures_bind_group( - device, - rendering_resource_storage, - shader.textures_bind_group_layout.as_ref().unwrap(), - &format!("{}_textures", name), - texture_slots, - textures, - )?) - } else { - None - }; - - debug!(LogContext::Rendering => "Textures bind group created"); - - let renderer_material = Self { - name: name.to_string(), - shader_handle, - parameters_bind_group, - textures_bind_group, - parameters_uniform_buffer, - }; - - debug!(LogContext::Rendering => "Material creation successful"); - - Ok(renderer_material) - } - - pub fn update_textures( - _device: &wgpu::Device, - _material_renderer_handle: RendererMaterialHandle, - _rendering_resource_storage: &mut RendererResourceStorage, - _textures: &[(String, MaterialTexture)], - ) -> Result<()> { - // let material = rendering_resource_storage.materials.get(material_renderer_handle) - // .ok_or(RendererError::RendererResourceNotFound.into())?; - // let shader_handle = material.shader_handle; - // let shader = rendering_resource_storage.shaders.get(shader_handle) - // .ok_or(RendererError::RendererResourceNotFound.into())?; - - //let texture_slots = &shader.texture_slots; - - // TODO: Implement - // Recreate texture bind group - // if !texture_slots.is_empty() && !shader.bind_group_layouts.is_empty() { - // let texture_bind_group = Self::create_texture_bind_group( - // device, - // rendering_resource_storage, - // &shader.bind_group_layouts[0], - // &format!("{}_textures", material.name), - // texture_slots, - // textures - // )?; - - // let material = rendering_resource_storage.materials.get_mut(material_renderer_handle) - // .ok_or(RendererError::RendererResourceNotFound.into())?; - - // // if !material.bind_groups.is_empty() { - // // material.bind_groups[0] = texture_bind_group; - // // } - // } - - Ok(()) - } - - pub fn update_parameters( - _device: &wgpu::Device, - queue: &wgpu::Queue, - material_renderer_handle: RendererMaterialHandle, - rendering_resource_storage: &mut RendererResourceStorage, - parameters: &HashMap, - ) -> Result<()> { - let material = rendering_resource_storage - .materials - .get(material_renderer_handle) - .ok_or_else(|| -> pill_core::PillError { - RendererError::RendererResourceNotFound.into() - })?; - let shader_handle = material.shader_handle; - let shader = rendering_resource_storage - .shaders - .get(shader_handle) - .ok_or_else(|| -> pill_core::PillError { - RendererError::RendererResourceNotFound.into() - })?; - - let parameter_slots = &shader.parameter_slots; - - let material = rendering_resource_storage - .materials - .get_mut(material_renderer_handle) - .ok_or_else(|| -> pill_core::PillError { - RendererError::RendererResourceNotFound.into() - })?; - - // Update uniform buffer if it exists - if let Some(ref buffer) = material.parameters_uniform_buffer { - Self::write_parameters_to_buffer(queue, buffer, parameter_slots, parameters)?; - } - - Ok(()) - } - - fn calculate_uniform_size(parameter_slots: &[(String, ShaderParameterSlot)]) -> usize { - // Calculate total size needed for all parameters - // Each parameter slot gets 16 bytes (vec4 alignment in WGSL) - parameter_slots.len() * 16 - } - - fn write_parameters_to_buffer( - queue: &wgpu::Queue, - buffer: &wgpu::Buffer, - parameter_slots: &[(String, ShaderParameterSlot)], - parameters: &HashMap, - ) -> Result<()> { - // Create a temporary buffer to hold all parameter data - let mut data = Vec::new(); - - // NOTE: Each parameter is 16 bytes (vec4 alignment in WGSL) - // Padding is added to ensure each parameter takes 16 bytes - // This is not ideal because we could make it more efficient by packing parameters more tightly - // But for simplicity, we will keep it this way for now - for (slot_name, slot) in parameter_slots { - match slot.parameter_type { - ShaderParameterType::Color => { - // Color parameter (3 floats + padding) - if let Some(MaterialParameter::Color(value)) = parameters.get(slot_name) { - data.extend_from_slice(&value.x.to_le_bytes()); - data.extend_from_slice(&value.y.to_le_bytes()); - data.extend_from_slice(&value.z.to_le_bytes()); - data.extend_from_slice(&0.0f32.to_le_bytes()); // Padding - } else { - data.extend_from_slice(&[0u8; 16]); - } - } - ShaderParameterType::Scalar => { - // Scalar parameter (1 float + padding) - if let Some(MaterialParameter::Scalar(value)) = parameters.get(slot_name) { - data.extend_from_slice(&value.to_le_bytes()); - data.extend_from_slice(&[0u8; 12]); // Padding to 16 bytes - } else { - data.extend_from_slice(&[0u8; 16]); - } - } - ShaderParameterType::Bool => { - // Bool parameter (1 u32 + padding) - if let Some(MaterialParameter::Bool(value)) = parameters.get(slot_name) { - let value: u32 = if *value { 1 } else { 0 }; - data.extend_from_slice(&value.to_le_bytes()); - data.extend_from_slice(&[0u8; 12]); // Padding to 16 bytes - } else { - data.extend_from_slice(&[0u8; 16]); - } - } - } - } - - if !data.is_empty() { - queue.write_buffer(buffer, 0, &data); - } - - Ok(()) - } - - fn create_textures_bind_group( - device: &wgpu::Device, - rendering_resource_storage: &RendererResourceStorage, - texture_bind_group_layout: &wgpu::BindGroupLayout, - name: &str, - texture_slots: &HashMap, - textures: &[(String, MaterialTexture)], - ) -> Result { - let mut entries = Vec::new(); - - for (slot_name, slot) in texture_slots { - // Get texture from material texture map or use default - let renderer_texture_handle = match textures - .iter() - .find(|(k, _)| k == slot_name) - .map(|(_, v)| v) - { - Some(material_texture) => { - debug!(LogContext::Rendering => "Material texture slot {} found in material textures", slot_name.name_style()); - get_renderer_texture_handle_from_material_texture(material_texture).unwrap() - } - None => { - debug!(LogContext::Rendering => "Material texture slot {} not found in material textures, using default texture", slot_name.name_style()); - get_default_texture_handles(slot.texture_type).1 - } - }; - - let texture = rendering_resource_storage - .textures - .get(renderer_texture_handle) - .unwrap(); - - // Add texture view entry - entries.push(wgpu::BindGroupEntry { - binding: slot.texture_binding, - resource: wgpu::BindingResource::TextureView(&texture.texture_view), - }); - - // Add sampler entry - entries.push(wgpu::BindGroupEntry { - binding: slot.sampler_binding, - resource: wgpu::BindingResource::Sampler(&texture.sampler), - }); - } - - // Set texture resources to the bind group - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - layout: texture_bind_group_layout, - entries: &entries, - label: Some(name), - }); - - Ok(bind_group) - } - - // fn create_parameters_bind_group( - // device: &wgpu::Device, - // parameter_bind_group_layout: &wgpu::BindGroupLayout, - // name: &str, - // buffer: &wgpu::Buffer, - // ) -> Result { - // let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - // layout: parameter_bind_group_layout, - // entries: &[ - // wgpu::BindGroupEntry { - // binding: MATERIAL_PARAMETERS_BINDING_INDEX as u32, - // resource: buffer.as_entire_binding(), - // }, - // ], - // label: Some(name), - // }); - - // Ok(bind_group) - // } - - // Helper method to get bind group by index - // pub fn get_bind_group(&self, index: usize) -> Option<&wgpu::BindGroup> { - // self.bind_groups.get(index) - // } -} diff --git a/engine/pill_renderer/src/resources/renderer_resource_storage.rs b/engine/pill_renderer/src/resources/renderer_resource_storage.rs deleted file mode 100644 index 0dc8eba3..00000000 --- a/engine/pill_renderer/src/resources/renderer_resource_storage.rs +++ /dev/null @@ -1,61 +0,0 @@ -use crate::resources::{ - EngineParameters, RendererCamera, RendererMaterial, RendererMesh, RendererShader, - RendererTexture, -}; - -use pill_engine::internal::{ - EngineConfig, RendererCameraHandle, RendererMaterialHandle, RendererMeshHandle, - RendererShaderHandle, RendererTextureHandle, -}; - -use pill_core::PillSlotMap; -use pill_core::Result; - -pub const MAX_SHADERS: usize = 10; -pub const MAX_TEXTURES: usize = 10; -pub const MAX_MATERIALS: usize = 10; -pub const MAX_MESHES: usize = 10; -pub const MAX_CAMERAS: usize = 10; - -pub struct RendererResourceStorage { - pub(crate) shaders: PillSlotMap, - pub(crate) materials: PillSlotMap, - pub(crate) textures: PillSlotMap, - pub(crate) meshes: PillSlotMap, - pub(crate) cameras: PillSlotMap, - pub(crate) engine_parameters: EngineParameters, -} - -impl RendererResourceStorage { - pub fn new(device: &wgpu::Device, config: &EngineConfig) -> Result { - let max_shader_count = config.get_int("MAX_SHADERS").unwrap_or(MAX_SHADERS as i64) as usize; - let max_texture_count = config - .get_int("MAX_TEXTURES") - .unwrap_or(MAX_TEXTURES as i64) as usize; - let max_material_count = config - .get_int("MAX_MATERIALS") - .unwrap_or(MAX_MATERIALS as i64) as usize; - let max_mesh_count = config.get_int("MAX_MESHES").unwrap_or(MAX_MESHES as i64) as usize; - let max_camera_count = config.get_int("MAX_CAMERAS").unwrap_or(MAX_CAMERAS as i64) as usize; - - Ok(RendererResourceStorage { - shaders: PillSlotMap::::with_capacity_and_key( - max_shader_count, - ), - textures: PillSlotMap::::with_capacity_and_key( - max_texture_count, - ), - materials: - PillSlotMap::::with_capacity_and_key( - max_material_count, - ), - meshes: PillSlotMap::::with_capacity_and_key( - max_mesh_count, - ), - cameras: PillSlotMap::::with_capacity_and_key( - max_camera_count, - ), - engine_parameters: EngineParameters::new(device)?, - }) - } -} diff --git a/engine/pill_runtime/Cargo.toml b/engine/pill_runtime/Cargo.toml index 22b4d6a0..2f2aea86 100644 --- a/engine/pill_runtime/Cargo.toml +++ b/engine/pill_runtime/Cargo.toml @@ -10,7 +10,6 @@ crate-type = ["cdylib", "rlib"] pill_abi = { path = "../pill_abi" } pill_core = { path = "../pill_core" } pill_engine = { path = "../pill_engine", features = ["internal"] } -pill_renderer = { path = "../pill_renderer" } libloading = "0.8.8" winit = "0.30.12" diff --git a/engine/pill_runtime/src/lib.rs b/engine/pill_runtime/src/lib.rs index 978894de..30de9d39 100644 --- a/engine/pill_runtime/src/lib.rs +++ b/engine/pill_runtime/src/lib.rs @@ -10,7 +10,7 @@ use libloading::{Library, Symbol}; use pill_abi::*; use pill_core::{PillError, Result}; use pill_engine::internal::*; -use pill_renderer::Renderer; +use pill_engine::renderer::Renderer; use winit::{ dpi::{PhysicalPosition, PhysicalSize}, window::Window, diff --git a/engine/pill_web/Cargo.toml b/engine/pill_web/Cargo.toml index 04ba55ef..4ecc682c 100644 --- a/engine/pill_web/Cargo.toml +++ b/engine/pill_web/Cargo.toml @@ -17,7 +17,6 @@ crate-type = ["rlib"] [target.'cfg(target_arch = "wasm32")'.dependencies] pill_engine = { path = "../pill_engine", features = ["game", "internal"] } -pill_renderer = { path = "../pill_renderer" } pill_core = { path = "../pill_core" } wasm-bindgen = "0.2" diff --git a/engine/pill_web/src/lib.rs b/engine/pill_web/src/lib.rs index 53c59464..a1b57b83 100644 --- a/engine/pill_web/src/lib.rs +++ b/engine/pill_web/src/lib.rs @@ -16,7 +16,7 @@ use winit::{ }; use pill_engine::internal::*; -use pill_renderer::Renderer; +use pill_engine::renderer::Renderer; // In release, every fallible call uses unwrap_unchecked so LLVM sees the Err // branch as UB and DCEs the whole branch — including format strings, Display diff --git a/examples/cube/src/game.rs b/examples/cube/src/game.rs index 08b240e6..773196b4 100644 --- a/examples/cube/src/game.rs +++ b/examples/cube/src/game.rs @@ -30,9 +30,8 @@ impl PillGame for WebGame { let cube_mesh_handle = engine.add_resource(Mesh::cube("cube", 2.0))?; let material_handle = engine.add_resource( - Material::builder("cube_material") - .color_parameter("tint", Color::new(0.80, 0.80, 0.82))? - .build(), + PBRMaterial::new("cube_material") + .albedo(Color::new(0.80, 0.80, 0.82)), )?; engine @@ -57,7 +56,7 @@ impl PillGame for WebGame { .with_component( MeshRenderingComponent::builder() .mesh(&cube_mesh_handle) - .material(&material_handle) + .pbr_material(&material_handle) .build(), ) .build(); diff --git a/examples/pill_tunel/src/game.rs b/examples/pill_tunel/src/game.rs index c2606853..accdc097 100644 --- a/examples/pill_tunel/src/game.rs +++ b/examples/pill_tunel/src/game.rs @@ -107,13 +107,12 @@ fn tinted_pill_material( tint: (f32, f32, f32), color_tex: TextureHandle, normal_tex: TextureHandle, -) -> Result { +) -> Result { engine.add_resource( - Material::builder(name) - .texture("color", color_tex)? - .texture("normal", normal_tex)? - .color_parameter("tint", Color::new(tint.0, tint.1, tint.2))? - .build(), + PBRMaterial::new(name) + .albedo(Color::new(tint.0, tint.1, tint.2)) + .albedo_texture(color_tex) + .normal_texture(normal_tex), ) } @@ -213,7 +212,7 @@ impl PillGame for WebGame { include_bytes!("../res/textures/pill_normal.cooked_tex"), ))?; - let tunnel_materials: Vec = PALETTE + let tunnel_materials: Vec = PALETTE .iter() .enumerate() .map(|(i, tint)| { @@ -226,7 +225,7 @@ impl PillGame for WebGame { ) }) .collect::>()?; - let hero_material = + let hero_material: PBRMaterialHandle = tinted_pill_material(engine, "hero_material", HERO_TINT, color_tex, normal_tex)?; // Camera @@ -261,7 +260,7 @@ impl PillGame for WebGame { .with_component( MeshRenderingComponent::builder() .mesh(&pill_mesh) - .material(&hero_material) + .pbr_material(&hero_material) .build(), ) .with_component(HeroPillComponent {}) @@ -299,7 +298,7 @@ impl PillGame for WebGame { .with_component( MeshRenderingComponent::builder() .mesh(&pill_mesh) - .material(&material) + .pbr_material(&material) .build(), ) .with_component(PillParticleComponent { From ca7267d5aadcc1e88dff40cd33c1692d4c24cb4c Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Thu, 28 May 2026 18:11:45 +0200 Subject: [PATCH 02/31] feat: renderer # Conflicts: # engine/Cargo.toml # engine/pill_core/Cargo.toml # engine/pill_core/src/utils.rs # engine/pill_engine/Cargo.toml # engine/pill_engine/res/shaders/default_lit_fragment.wgsl # engine/pill_engine/res/shaders/default_unlit_fragment.wgsl # engine/pill_engine/src/ecs/systems/rendering_system.rs # engine/pill_engine/src/engine.rs # engine/pill_engine/src/graphics/dummy_renderer.rs # engine/pill_engine/src/graphics/render_queue.rs # engine/pill_engine/src/graphics/renderer.rs # engine/pill_engine/src/lib.rs # engine/pill_engine/src/renderer/drawers/mesh_drawer.rs # engine/pill_engine/src/renderer/resources/engine_parameters.rs # engine/pill_engine/src/renderer/resources/renderer_mesh.rs # engine/pill_engine/src/renderer/resources/renderer_shader.rs # engine/pill_engine/src/renderer/resources/renderer_texture.rs # engine/pill_engine/src/resources/material.rs # engine/pill_engine/src/resources/shader.rs # engine/pill_engine/src/resources/texture.rs # engine/pill_renderer/Cargo.toml # engine/pill_renderer/src/renderer.rs # engine/pill_renderer/src/resources/renderer_material.rs # engine/pill_renderer/src/resources/renderer_resource_storage.rs --- engine/pill_core/src/timer.rs | 5 +- .../src/ecs/systems/rendering_system.rs | 42 ++++----- engine/pill_engine/src/engine.rs | 9 +- .../src/graphics/dummy_renderer.rs | 17 +--- engine/pill_engine/src/graphics/pass_egui.rs | 4 +- .../src/graphics/pass_pbr_static.rs | 60 ++++++++----- .../pill_engine/src/graphics/render_queue.rs | 77 +++++++++++----- engine/pill_engine/src/graphics/renderer.rs | 10 +-- engine/pill_engine/src/lib.rs | 30 +++---- .../src/renderer/drawers/mesh_drawer.rs | 5 +- engine/pill_engine/src/renderer/instance.rs | 2 +- .../renderer/resources/engine_parameters.rs | 11 ++- .../renderer/resources/renderer_material.rs | 24 +++-- .../renderer/resources/renderer_texture.rs | 1 - engine/pill_engine/src/renderer/state.rs | 90 ++++++++++--------- engine/pill_engine/src/resources/material.rs | 8 +- engine/pill_engine/src/resources/texture.rs | 7 +- 17 files changed, 231 insertions(+), 171 deletions(-) diff --git a/engine/pill_core/src/timer.rs b/engine/pill_core/src/timer.rs index 43c347fa..8b5337c7 100644 --- a/engine/pill_core/src/timer.rs +++ b/engine/pill_core/src/timer.rs @@ -114,7 +114,10 @@ impl Timer { } pub fn get_counter(&self, label: &str) -> Option { - self.counters.iter().find(|(k, _)| k == label).map(|(_, v)| *v) + self.counters + .iter() + .find(|(k, _)| k == label) + .map(|(_, v)| *v) } pub fn counters(&self) -> &Vec<(String, u64)> { diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index 667907dd..84e0917d 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -1,8 +1,8 @@ use crate::{ config::RENDERING_SYSTEM, ecs::{ - CameraAspectRatio, CameraComponent, EntityHandle, - MeshRenderingComponent, RenderStateComponent, TransformComponent, + CameraAspectRatio, CameraComponent, EntityHandle, MeshRenderingComponent, + RenderStateComponent, TransformComponent, }, engine::Engine, graphics::RenderQueueItem, @@ -57,8 +57,10 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { } } - let active_camera_entity_handle = active_camera_entity_handle_result - .ok_or_else(|| -> pill_core::PillError { pill_core::PillError::from(EngineError::NoActiveCamera) })?; + let active_camera_entity_handle = + active_camera_entity_handle_result.ok_or_else(|| -> pill_core::PillError { + pill_core::PillError::from(EngineError::NoActiveCamera) + })?; timer.record("Clear render queue"); @@ -158,23 +160,21 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { )?; Ok(()) } - Err(e) => { - match e.downcast_ref::() { - Some(RendererError::SurfaceLost) => { - timer.end_context()?; - engine.system_manager.update_system_timer( - RENDERING_SYSTEM.name, - RENDERING_SYSTEM.update_phase, - timer, - )?; - engine.renderer.resize(engine.window_size); - Ok(()) - } - Some(RendererError::SurfaceOutOfMemory) => { - panic!("Critical: Renderer error, system out of memory"); - } - _ => Err(e), + Err(e) => match e.downcast_ref::() { + Some(RendererError::SurfaceLost) => { + timer.end_context()?; + engine.system_manager.update_system_timer( + RENDERING_SYSTEM.name, + RENDERING_SYSTEM.update_phase, + timer, + )?; + engine.renderer.resize(engine.window_size); + Ok(()) } - } + Some(RendererError::SurfaceOutOfMemory) => { + panic!("Critical: Renderer error, system out of memory"); + } + _ => Err(e), + }, } } diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index 2ae9795f..8f31b2a5 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -1,4 +1,11 @@ -use crate::{app_config::EngineConfig, config::*, ecs::*, graphics::*, renderer::resources::{RendererMaterial, RendererMesh, RendererShader, RendererTexture}, resources::*}; +use crate::{ + app_config::EngineConfig, + config::*, + ecs::*, + graphics::*, + renderer::resources::{RendererMaterial, RendererMesh, RendererShader, RendererTexture}, + resources::*, +}; use pill_core::{ debug, error, get_game_error_message, get_type_name, info, EngineError, LogContext, diff --git a/engine/pill_engine/src/graphics/dummy_renderer.rs b/engine/pill_engine/src/graphics/dummy_renderer.rs index 7ac141fb..1bd27f50 100644 --- a/engine/pill_engine/src/graphics/dummy_renderer.rs +++ b/engine/pill_engine/src/graphics/dummy_renderer.rs @@ -2,8 +2,8 @@ use crate::{ app_config::EngineConfig, ecs::{CameraComponent, ComponentStorage, EguiClient, EntityHandle, TransformComponent}, graphics::{ - BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, RendererTargetDesc, - RenderQueueItem, RendererCameraHandle, RendererTextureHandle, WorldQuery, + BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, RenderQueueItem, + RendererCameraHandle, RendererTargetDesc, RendererTextureHandle, WorldQuery, }, resources::{ResourceManager, ShaderParameterSlot, ShaderTextureSlot}, }; @@ -39,8 +39,6 @@ impl PillRenderer for DummyRenderer { Ok(RendererCameraHandle::default()) } - - // --- Destroy --- fn destroy_camera(&mut self, _renderer_camera_handle: RendererCameraHandle) -> Result<()> { @@ -99,10 +97,7 @@ impl PillRenderer for DummyRenderer { unimplemented!("DummyRenderer has no wgpu Device") } - fn create_render_target( - &mut self, - _desc: RendererTargetDesc, - ) -> Result { + fn create_render_target(&mut self, _desc: RendererTargetDesc) -> Result { Ok(RendererTextureHandle::default()) } @@ -110,11 +105,7 @@ impl PillRenderer for DummyRenderer { Ok(RendererTextureHandle::default()) } - fn get_render_target_view( - &self, - _handle: RendererTextureHandle, - ) -> Option<&wgpu::TextureView> { + fn get_render_target_view(&self, _handle: RendererTextureHandle) -> Option<&wgpu::TextureView> { None } - } diff --git a/engine/pill_engine/src/graphics/pass_egui.rs b/engine/pill_engine/src/graphics/pass_egui.rs index b7789766..ef54cc87 100644 --- a/engine/pill_engine/src/graphics/pass_egui.rs +++ b/engine/pill_engine/src/graphics/pass_egui.rs @@ -61,7 +61,9 @@ impl Pass for PassEgui { let ui_fn = self.client.take_ui(); let run_ui: Box = match ui_fn { - Some(ui_function) => Box::new(move |egui_context: &egui::Context| ui_function(egui_context)), + Some(ui_function) => { + Box::new(move |egui_context: &egui::Context| ui_function(egui_context)) + } None => Box::new(|_egui_context: &egui::Context| {}), }; diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_static.rs index 04dc7603..e8c9ed14 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_static.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_static.rs @@ -10,7 +10,6 @@ use glam::{Mat3, Mat4, Quat, Vec3}; use pill_core::{PillSlotMapKey, Result}; use std::num::NonZeroU32; - /// Camera uniform layout: position (vec4) + view-projection matrix (mat4x4). #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] @@ -553,10 +552,8 @@ impl Pass for PassPBRStatic { buffer: &per_draw_buffer, offset: 0, size: Some( - std::num::NonZeroU64::new( - std::mem::size_of::() as u64, - ) - .unwrap(), + std::num::NonZeroU64::new(std::mem::size_of::() as u64) + .unwrap(), ), }), }], @@ -568,7 +565,11 @@ impl Pass for PassPBRStatic { let device = renderer.get_device(); device.create_texture(&wgpu::TextureDescriptor { label: Some("ibl_fallback"), - size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -590,7 +591,11 @@ impl Pass for PassPBRStatic { bytes_per_row: Some(4), rows_per_image: Some(1), }, - wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, ); let ibl_fallback_view = ibl_fallback_texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -805,11 +810,17 @@ impl Pass for PassPBRStatic { .iter_mut() .find(|batch| batch.mesh_handle == visible.mesh_handle) { - batch.instances.push(PerDrawStd140 { mvp: visible.mvp, model: visible.model }); + batch.instances.push(PerDrawStd140 { + mvp: visible.mvp, + model: visible.model, + }); } else { group.batches.push(MeshBatch { mesh_handle: visible.mesh_handle, - instances: vec![PerDrawStd140 { mvp: visible.mvp, model: visible.model }], + instances: vec![PerDrawStd140 { + mvp: visible.mvp, + model: visible.model, + }], base_offset_u32: 0, }); } @@ -820,7 +831,11 @@ impl Pass for PassPBRStatic { .groups_buffer .iter() .map(|group| { - group.batches.iter().map(|batch| batch.instances.len() as u64).sum::() + group + .batches + .iter() + .map(|batch| batch.instances.len() as u64) + .sum::() }) .sum(); if self.per_draw_capacity < needed { @@ -836,18 +851,17 @@ impl Pass for PassPBRStatic { for batch in group.batches.iter_mut() { batch.base_offset_u32 = next_offset_u32; for per_draw in &batch.instances { - self.staging_buffer.extend_from_slice(bytemuck::bytes_of(per_draw)); + self.staging_buffer + .extend_from_slice(bytemuck::bytes_of(per_draw)); let pad = (self.per_draw_stride as usize) - std::mem::size_of::(); self.staging_buffer.extend(std::iter::repeat(0u8).take(pad)); - next_offset_u32 = - next_offset_u32.wrapping_add(self.per_draw_stride as u32); + next_offset_u32 = next_offset_u32.wrapping_add(self.per_draw_stride as u32); } } } { - let state_ref: &PassPBRStaticState = - unsafe { self.state.as_ref().unwrap_unchecked() }; + let state_ref: &PassPBRStaticState = unsafe { self.state.as_ref().unwrap_unchecked() }; renderer .get_queue() .write_buffer(&state_ref.per_draw_buffer, 0, &self.staging_buffer); @@ -885,8 +899,7 @@ impl Pass for PassPBRStatic { }); // Record draw commands: bind pipeline → globals → material → per-draw per instance. - let state_ref: &PassPBRStaticState = - unsafe { self.state.as_ref().unwrap_unchecked() }; + let state_ref: &PassPBRStaticState = unsafe { self.state.as_ref().unwrap_unchecked() }; for group in &self.groups_buffer { render_pass.set_pipeline(unsafe { &*group.pipeline }); render_pass.set_bind_group( @@ -901,9 +914,10 @@ impl Pass for PassPBRStatic { .unwrap(); // Skip materials that don't have PBR-compatible bind groups. - let (Some(textures_bg), Some(params_bg)) = - (mat.textures_bind_group.as_ref(), mat.parameters_bind_group.as_ref()) - else { + let (Some(textures_bg), Some(params_bg)) = ( + mat.textures_bind_group.as_ref(), + mat.parameters_bind_group.as_ref(), + ) else { continue; }; render_pass.set_bind_group(MATERIAL_BIND_GROUP_TEXTURES as u32, textures_bg, &[]); @@ -915,10 +929,8 @@ impl Pass for PassPBRStatic { .get_resource::(&batch.mesh_handle) .unwrap(); render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); - render_pass.set_index_buffer( - mesh.index_buffer.slice(..), - wgpu::IndexFormat::Uint32, - ); + render_pass + .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); for instance_index in 0..batch.instances.len() { let offset = batch .base_offset_u32 diff --git a/engine/pill_engine/src/graphics/render_queue.rs b/engine/pill_engine/src/graphics/render_queue.rs index a49d8bbc..fa7ebac5 100644 --- a/engine/pill_engine/src/graphics/render_queue.rs +++ b/engine/pill_engine/src/graphics/render_queue.rs @@ -132,13 +132,21 @@ pub fn compose_render_queue_key( let renderer_mesh_handle = resource_manager.get_resource_handle::(&mesh.name)?; - let render_queue_key: RenderQueueKey = ((RENDER_QUEUE_KEY_ORDER.max - material.rendering_order as RenderQueueKey) << RENDER_QUEUE_KEY_ORDER.mask_shift) - | ((renderer_shader_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) - | ((renderer_shader_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) - | ((renderer_material_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) - | ((renderer_material_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) - | ((renderer_mesh_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) - | ((renderer_mesh_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift); + let render_queue_key: RenderQueueKey = ((RENDER_QUEUE_KEY_ORDER.max + - material.rendering_order as RenderQueueKey) + << RENDER_QUEUE_KEY_ORDER.mask_shift) + | ((renderer_shader_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) + | ((renderer_shader_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) + | ((renderer_material_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) + | ((renderer_material_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) + | ((renderer_mesh_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) + | ((renderer_mesh_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift); Ok(render_queue_key) } @@ -165,13 +173,19 @@ pub fn compose_pbr_render_queue_key( let renderer_mesh_handle = resource_manager.get_resource_handle::(&mesh.name)?; - let render_queue_key: RenderQueueKey = - ((renderer_shader_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) - | ((renderer_shader_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) - | ((renderer_material_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) - | ((renderer_material_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) - | ((renderer_mesh_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) - | ((renderer_mesh_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift); + let render_queue_key: RenderQueueKey = ((renderer_shader_handle.data().index + as RenderQueueKey) + << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) + | ((renderer_shader_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) + | ((renderer_material_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) + | ((renderer_material_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) + | ((renderer_mesh_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) + | ((renderer_mesh_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift); Ok(render_queue_key) } @@ -240,11 +254,32 @@ fn get_render_queue_key_item_range(render_queue_item_index: u8) -> Range = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_RENDERING_ORDER_IDX)); - pub static ref RENDER_QUEUE_KEY_SHADER_INDEX: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_SHADER_INDEX_IDX)); - pub static ref RENDER_QUEUE_KEY_SHADER_VERSION: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_SHADER_VERSION_IDX)); - pub static ref RENDER_QUEUE_KEY_MATERIAL_INDEX: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_MATERIAL_INDEX_IDX)); - pub static ref RENDER_QUEUE_KEY_MATERIAL_VERSION: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_MATERIAL_VERSION_IDX)); - pub static ref RENDER_QUEUE_KEY_MESH_INDEX: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_MESH_INDEX_IDX)); - pub static ref RENDER_QUEUE_KEY_MESH_VERSION: RenderQueueField = RenderQueueField::::new(get_render_queue_key_item_range(RENDER_QUEUE_KEY_MESH_VERSION_IDX)); + pub static ref RENDER_QUEUE_KEY_ORDER: RenderQueueField = + RenderQueueField::::new(get_render_queue_key_item_range( + RENDER_QUEUE_KEY_RENDERING_ORDER_IDX + )); + pub static ref RENDER_QUEUE_KEY_SHADER_INDEX: RenderQueueField = + RenderQueueField::::new(get_render_queue_key_item_range( + RENDER_QUEUE_KEY_SHADER_INDEX_IDX + )); + pub static ref RENDER_QUEUE_KEY_SHADER_VERSION: RenderQueueField = + RenderQueueField::::new(get_render_queue_key_item_range( + RENDER_QUEUE_KEY_SHADER_VERSION_IDX + )); + pub static ref RENDER_QUEUE_KEY_MATERIAL_INDEX: RenderQueueField = + RenderQueueField::::new(get_render_queue_key_item_range( + RENDER_QUEUE_KEY_MATERIAL_INDEX_IDX + )); + pub static ref RENDER_QUEUE_KEY_MATERIAL_VERSION: RenderQueueField = + RenderQueueField::::new(get_render_queue_key_item_range( + RENDER_QUEUE_KEY_MATERIAL_VERSION_IDX + )); + pub static ref RENDER_QUEUE_KEY_MESH_INDEX: RenderQueueField = + RenderQueueField::::new(get_render_queue_key_item_range( + RENDER_QUEUE_KEY_MESH_INDEX_IDX + )); + pub static ref RENDER_QUEUE_KEY_MESH_VERSION: RenderQueueField = + RenderQueueField::::new(get_render_queue_key_item_range( + RENDER_QUEUE_KEY_MESH_VERSION_IDX + )); } diff --git a/engine/pill_engine/src/graphics/renderer.rs b/engine/pill_engine/src/graphics/renderer.rs index 0638674f..1600c700 100644 --- a/engine/pill_engine/src/graphics/renderer.rs +++ b/engine/pill_engine/src/graphics/renderer.rs @@ -3,9 +3,7 @@ use crate::{ app_config::EngineConfig, ecs::{CameraComponent, ComponentStorage, EguiClient, EntityHandle, TransformComponent}, graphics::RenderQueueItem, - resources::{ - ResourceManager, ShaderParameterSlot, ShaderTextureSlot, TextureType, - }, + resources::{ResourceManager, ShaderParameterSlot, ShaderTextureSlot, TextureType}, }; use pill_core::Timer; @@ -183,11 +181,7 @@ pub trait PillRenderer { fn create_depth_texture(&mut self, label: &str) -> Result; /// Returns the texture view for a previously created render target, or `None` if not found. - fn get_render_target_view( - &self, - handle: RendererTextureHandle, - ) -> Option<&wgpu::TextureView>; - + fn get_render_target_view(&self, handle: RendererTextureHandle) -> Option<&wgpu::TextureView>; } pub type Renderer = Box; diff --git a/engine/pill_engine/src/lib.rs b/engine/pill_engine/src/lib.rs index e7da909a..a5429721 100644 --- a/engine/pill_engine/src/lib.rs +++ b/engine/pill_engine/src/lib.rs @@ -93,6 +93,8 @@ pub mod game { #[cfg(not(target_arch = "wasm32"))] mod internal_mod { pub use crate::app_config::EngineConfig; + #[cfg(feature = "debug_ui")] + pub use crate::ecs::EguiManagerComponent; pub use crate::{ config::*, ecs::{ @@ -100,8 +102,8 @@ mod internal_mod { get_renderer_resource_handle_from_camera_component, networking_system_client, networking_system_server, update_transform_matrices, AudioListenerComponent, AudioManagerComponent, AudioSourceComponent, CameraAspectRatio, CameraComponent, - ComponentStorage, EguiClient, EntityHandle, EntityUpdate, - InputComponent, MeshRenderingComponent, NetworkEntityAction, NetworkEntityState, + ComponentStorage, EguiClient, EntityHandle, EntityUpdate, InputComponent, + MeshRenderingComponent, NetworkEntityAction, NetworkEntityState, NetworkManagerComponent, NetworkSide, NetworkStateComponent, NetworkUpdatePayload, RenderStateComponent, Scene, TimeComponent, TransformComponent, }, @@ -114,26 +116,26 @@ mod internal_mod { }, resources::{ Material, MaterialHandle, MaterialParameter, MaterialTexture, Mesh, MeshData, - MeshHandle, MeshVertex, PBRMaterial, PBRMaterialHandle, ResourceLoader, ResourceManager, - ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, Texture, TextureHandle, - TextureType, + MeshHandle, MeshVertex, PBRMaterial, PBRMaterialHandle, ResourceLoader, + ResourceManager, ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, Texture, + TextureHandle, TextureType, }, }; - #[cfg(feature = "debug_ui")] - pub use crate::ecs::EguiManagerComponent; } #[cfg(target_arch = "wasm32")] mod internal_mod { pub use crate::app_config::EngineConfig; + #[cfg(feature = "debug_ui")] + pub use crate::ecs::EguiManagerComponent; pub use crate::{ config::*, ecs::{ get_model_matrix, get_normal_matrix, get_renderer_resource_handle_from_camera_component, update_transform_matrices, - CameraAspectRatio, CameraComponent, ComponentStorage, EguiClient, - EntityHandle, InputComponent, MeshRenderingComponent, RenderStateComponent, Scene, - TimeComponent, TransformComponent, + CameraAspectRatio, CameraComponent, ComponentStorage, EguiClient, EntityHandle, + InputComponent, MeshRenderingComponent, RenderStateComponent, Scene, TimeComponent, + TransformComponent, }, engine::{Engine, PillGame}, graphics::{ @@ -144,13 +146,11 @@ mod internal_mod { }, resources::{ Material, MaterialHandle, MaterialParameter, MaterialTexture, Mesh, MeshData, - MeshHandle, MeshVertex, PBRMaterial, PBRMaterialHandle, ResourceLoader, ResourceManager, - ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, Texture, TextureHandle, - TextureType, + MeshHandle, MeshVertex, PBRMaterial, PBRMaterialHandle, ResourceLoader, + ResourceManager, ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, Texture, + TextureHandle, TextureType, }, }; - #[cfg(feature = "debug_ui")] - pub use crate::ecs::EguiManagerComponent; } #[cfg(feature = "internal")] diff --git a/engine/pill_engine/src/renderer/drawers/mesh_drawer.rs b/engine/pill_engine/src/renderer/drawers/mesh_drawer.rs index 016da7ff..839a6af2 100644 --- a/engine/pill_engine/src/renderer/drawers/mesh_drawer.rs +++ b/engine/pill_engine/src/renderer/drawers/mesh_drawer.rs @@ -12,7 +12,7 @@ use crate::renderer::{ use crate::{ ecs::ComponentStorage, graphics::{RenderQueueItem, RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle}, - internal::{TransformComponent, decompose_render_queue_key}, + internal::{decompose_render_queue_key, TransformComponent}, resources::ResourceManager, }; use pill_core::{debug, LogContext, PillStyle, RendererError, Timer}; @@ -261,8 +261,7 @@ impl MeshDrawer { current_drawing_context.accumulated_instance_count = 0; for (j, render_queue_item) in instance_batch.iter().enumerate() { - let render_queue_key_fields = - decompose_render_queue_key(render_queue_item.key); + let render_queue_key_fields = decompose_render_queue_key(render_queue_item.key); let renderer_shader_handle = RendererShaderHandle::new( render_queue_key_fields.shader_index.into(), diff --git a/engine/pill_engine/src/renderer/instance.rs b/engine/pill_engine/src/renderer/instance.rs index 37081931..9d68ae15 100644 --- a/engine/pill_engine/src/renderer/instance.rs +++ b/engine/pill_engine/src/renderer/instance.rs @@ -1,7 +1,7 @@ use crate::renderer::resources::Vertex; -use pill_core::Matrix3f; use crate::ecs::TransformComponent; +use pill_core::Matrix3f; #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] diff --git a/engine/pill_engine/src/renderer/resources/engine_parameters.rs b/engine/pill_engine/src/renderer/resources/engine_parameters.rs index 392730b5..03343ea7 100644 --- a/engine/pill_engine/src/renderer/resources/engine_parameters.rs +++ b/engine/pill_engine/src/renderer/resources/engine_parameters.rs @@ -96,8 +96,15 @@ impl EngineParameters { } /// Updates the CPU-side data and writes it to the GPU buffer; called once per frame before draw. - pub fn update(&mut self, queue: &wgpu::Queue, delta_time: f32, fog_density: f32, fog_color: Vector3f) { - self.parameters_data.update_data(delta_time, fog_density, fog_color); + pub fn update( + &mut self, + queue: &wgpu::Queue, + delta_time: f32, + fog_density: f32, + fog_color: Vector3f, + ) { + self.parameters_data + .update_data(delta_time, fog_density, fog_color); queue.write_buffer( &self.parameters_uniform_buffer, 0, diff --git a/engine/pill_engine/src/renderer/resources/renderer_material.rs b/engine/pill_engine/src/renderer/resources/renderer_material.rs index 5887b2c9..05c8c60f 100644 --- a/engine/pill_engine/src/renderer/resources/renderer_material.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_material.rs @@ -1,17 +1,17 @@ #![cfg_attr(debug_assertions, allow(dead_code, unused_imports, unused_variables))] -use pill_core::{debug, LogContext, PillStyle, PillTypeMapKey, RendererError, Result}; +use crate::graphics::RendererShaderHandle; +use crate::graphics::RendererTextureHandle; use crate::{ config::{DEFAULT_COLOR_TEXTURE_NAME, DEFAULT_NORMAL_TEXTURE_NAME}, graphics::RendererMaterialHandle, - renderer::resources::{RendererTexture, RendererShader}, + renderer::resources::{RendererShader, RendererTexture}, resources::{ - MaterialParameter, Resource, ResourceManager, ResourceStorage, - ShaderParameterSlot, ShaderParameterType, ShaderTextureSlot, + MaterialParameter, Resource, ResourceManager, ResourceStorage, ShaderParameterSlot, + ShaderParameterType, ShaderTextureSlot, }, }; -use crate::graphics::RendererShaderHandle; -use crate::graphics::RendererTextureHandle; +use pill_core::{debug, LogContext, PillStyle, PillTypeMapKey, RendererError, Result}; use std::collections::HashMap; @@ -38,7 +38,9 @@ impl RendererMaterial { let shader = resource_manager .get_resource::(&shader_handle) - .map_err(|_| -> pill_core::PillError { pill_core::PillError::from(RendererError::RendererResourceNotFound) })?; + .map_err(|_| -> pill_core::PillError { + pill_core::PillError::from(RendererError::RendererResourceNotFound) + })?; let parameter_slots = &shader.parameter_slots; let texture_slots = &shader.texture_slots; @@ -171,7 +173,9 @@ impl RendererMaterial { }; resource_manager .get_resource_handle::(default_name) - .map_err(|_| -> pill_core::PillError { pill_core::PillError::from(RendererError::RendererResourceNotFound) })? + .map_err(|_| -> pill_core::PillError { + pill_core::PillError::from(RendererError::RendererResourceNotFound) + })? } }; slot_handles.push((slot.texture_binding, slot.sampler_binding, handle)); @@ -181,7 +185,9 @@ impl RendererMaterial { for (texture_binding, sampler_binding, handle) in &slot_handles { let texture = resource_manager .get_resource::(handle) - .map_err(|_| -> pill_core::PillError { pill_core::PillError::from(RendererError::RendererResourceNotFound) })?; + .map_err(|_| -> pill_core::PillError { + pill_core::PillError::from(RendererError::RendererResourceNotFound) + })?; entries.push(wgpu::BindGroupEntry { binding: *texture_binding, resource: wgpu::BindingResource::TextureView(&texture.texture_view), diff --git a/engine/pill_engine/src/renderer/resources/renderer_texture.rs b/engine/pill_engine/src/renderer/resources/renderer_texture.rs index 92aa98be..af7416a1 100644 --- a/engine/pill_engine/src/renderer/resources/renderer_texture.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_texture.rs @@ -22,7 +22,6 @@ impl RendererTexture { height: u32, texture_type: TextureType, ) -> Result { - let size = wgpu::Extent3d { width, height, diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs index 4bcea0a5..538818ec 100644 --- a/engine/pill_engine/src/renderer/state.rs +++ b/engine/pill_engine/src/renderer/state.rs @@ -19,8 +19,10 @@ use crate::{ resources::ResourceManager, }; -use pill_core::{debug, info, LogContext, PillSlotMap, PillSlotMapKey, PillStyle, RendererError, Timer}; use pill_core::Result; +use pill_core::{ + debug, info, LogContext, PillSlotMap, PillSlotMapKey, PillStyle, RendererError, Timer, +}; use std::{collections::HashMap, sync::Arc}; pub struct Renderer { @@ -145,12 +147,12 @@ impl PillRenderer for Renderer { .texture .create_view(&wgpu::TextureViewDescriptor::default()); - let mut encoder = self - .state - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("render_encoder"), - }); + let mut encoder = + self.state + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("render_encoder"), + }); let world = WorldQuery { active_camera: active_camera_entity_handle, @@ -267,30 +269,30 @@ impl PillRenderer for Renderer { source: wgpu::ShaderSource::Wgsl(desc.ps.source.into()), }); - let pipeline = - self.state - .device - .create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: desc.label, - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &vs_module, - entry_point: Some(desc.vs.entry_func), - buffers: desc.vertex_buffers, - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &fs_module, - entry_point: Some(desc.ps.entry_func), - targets: desc.targets, - compilation_options: Default::default(), - }), - primitive: desc.primitive, - depth_stencil: desc.depth_stencil, - multisample: desc.multisample, - multiview: None, - cache: None, - }); + let pipeline = self + .state + .device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: desc.label, + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &vs_module, + entry_point: Some(desc.vs.entry_func), + buffers: desc.vertex_buffers, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &fs_module, + entry_point: Some(desc.ps.entry_func), + targets: desc.targets, + compilation_options: Default::default(), + }), + primitive: desc.primitive, + depth_stencil: desc.depth_stencil, + multisample: desc.multisample, + multiview: None, + cache: None, + }); Ok(PipelineV2 { pipeline, @@ -320,16 +322,12 @@ impl PillRenderer for Renderer { Ok(handle) } - fn get_render_target_view( - &self, - handle: RendererTextureHandle, - ) -> Option<&wgpu::TextureView> { + fn get_render_target_view(&self, handle: RendererTextureHandle) -> Option<&wgpu::TextureView> { self.state .pass_textures .get(handle) .map(|t| &t.texture_view) } - } pub struct State { @@ -373,9 +371,11 @@ impl State { flags: wgpu::InstanceFlags::from_build_config().with_env(), backend_options: wgpu::BackendOptions::default(), }); - let surface = instance - .create_surface(window.clone()) - .map_err(|error| -> pill_core::PillError { pill_core::PillError::from(format!("Failed to create surface: {}", error)) })?; + let surface = instance.create_surface(window.clone()).map_err( + |error| -> pill_core::PillError { + pill_core::PillError::from(format!("Failed to create surface: {}", error)) + }, + )?; (instance, surface) }; @@ -387,7 +387,9 @@ impl State { force_fallback_adapter: false, }) .await - .map_err(|error| -> pill_core::PillError { pill_core::PillError::from(format!("Failed to request adapter: {}", error)) })?; + .map_err(|error| -> pill_core::PillError { + pill_core::PillError::from(format!("Failed to request adapter: {}", error)) + })?; let info = adapter.get_info(); info!(LogContext::Rendering => "Using GPU: {} ({:?})", info.name, info.backend); @@ -407,7 +409,9 @@ impl State { trace: wgpu::Trace::default(), }) .await - .map_err(|error| -> pill_core::PillError { pill_core::PillError::from(format!("Failed to request device: {}", error)) })? + .map_err(|error| -> pill_core::PillError { + pill_core::PillError::from(format!("Failed to request device: {}", error)) + })? }; // Configure swap chain format, present mode, and depth texture @@ -466,7 +470,9 @@ impl State { let depth_texture = RendererTexture::new_depth_texture(&device, &surface_configuration, "depth_texture") - .map_err(|error| -> pill_core::PillError { pill_core::PillError::from(format!("Failed to create depth texture: {}", error)) })?; + .map_err(|error| -> pill_core::PillError { + pill_core::PillError::from(format!("Failed to create depth texture: {}", error)) + })?; let camera_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { diff --git a/engine/pill_engine/src/resources/material.rs b/engine/pill_engine/src/resources/material.rs index 1d072dae..2ab8d17c 100644 --- a/engine/pill_engine/src/resources/material.rs +++ b/engine/pill_engine/src/resources/material.rs @@ -118,8 +118,7 @@ impl Resource for PBRMaterial { let h = engine .resource_manager .get_resource_handle::(&tex_name)?; - resolved_textures - .insert(DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_NAME.to_string(), h); + resolved_textures.insert(DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_NAME.to_string(), h); } if let Some(texture_handle) = self.normal_texture { @@ -316,8 +315,6 @@ impl Material { Ok(()) } - - pub fn set_rendering_order(&mut self, order: u8) -> Result<()> { let error = EngineError::WrongRenderingOrder( order.to_string(), @@ -619,7 +616,8 @@ impl Resource for Material { let renderer_shader = engine .resource_manager .get_resource::(&renderer_mat.shader_handle)?; - let mut resolved: HashMap = HashMap::new(); + let mut resolved: HashMap = + HashMap::new(); for (slot_name, mat_tex) in &self.textures { let tex = engine .resource_manager diff --git a/engine/pill_engine/src/resources/texture.rs b/engine/pill_engine/src/resources/texture.rs index 36df54b9..f3afaeaf 100644 --- a/engine/pill_engine/src/resources/texture.rs +++ b/engine/pill_engine/src/resources/texture.rs @@ -212,12 +212,13 @@ impl Resource for Texture { let renderer_shader = engine .resource_manager .get_resource::( - &renderer_mat.shader_handle, - )?; + &renderer_mat.shader_handle, + )?; let material = engine .resource_manager .get_resource_by_name::(mat_name)?; - let mut resolved: HashMap = HashMap::new(); + let mut resolved: HashMap = + HashMap::new(); for (slot_name, mat_tex) in &material.textures { let tex = engine .resource_manager From 724f9493f21407bd340c1009bda0b7dcbf36578e Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Thu, 28 May 2026 19:04:01 +0200 Subject: [PATCH 03/31] feat: extract shaders to files --- engine/Cargo.toml | 2 +- .../res/shaders/pbr_static_fragment.hlsl | 134 +++++++++++++ .../res/shaders/pbr_static_vertex.hlsl | 41 ++++ .../src/graphics/pass_pbr_static.rs | 184 +----------------- examples/pill_tunel/Cargo.toml | 2 +- 5 files changed, 181 insertions(+), 182 deletions(-) create mode 100644 engine/pill_engine/res/shaders/pbr_static_fragment.hlsl create mode 100644 engine/pill_engine/res/shaders/pbr_static_vertex.hlsl diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 31944895..99836cbc 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,7 +8,7 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "NO_PATH", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) + "/Users/mk/dev/demo/Pill-Engine/examples/pill_tunel", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at diff --git a/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl b/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl new file mode 100644 index 00000000..b449740e --- /dev/null +++ b/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl @@ -0,0 +1,134 @@ +// PBR static fragment shader. Edit here — pill_assets regenerates the .wgsl. +// Full GGX microfacet BRDF with 3 directional lights and optional IBL fallback. + +struct Camera { + float4 position; + column_major float4x4 viewProjection; +}; +[[vk::binding(0, 0)]] ConstantBuffer UCamera; + +// IBL resources in globals bind group (set 0, bindings 1-6). +[[vk::binding(1, 0)]] Texture2D texIrradiance; +[[vk::binding(2, 0)]] SamplerState smpIrradiance; +[[vk::binding(3, 0)]] Texture2D texPrefilter; +[[vk::binding(4, 0)]] SamplerState smpPrefilter; +[[vk::binding(5, 0)]] Texture2D texBrdfLut; +[[vk::binding(6, 0)]] SamplerState smpBrdfLut; + +// PBR material textures (set 1) — bindings match DEFAULT_LIT_SHADER layout (0-3). +[[vk::binding(0, 1)]] Texture2D texBaseColor; +[[vk::binding(1, 1)]] SamplerState smpBaseColor; +[[vk::binding(2, 1)]] Texture2D texNormal; +[[vk::binding(3, 1)]] SamplerState smpNormal; + +// PBR params UBO (set 2) — 32 bytes: baseColorFactor (vec3 + pad), roughnessFactor (f32 + pad + vec2 pad). +struct MaterialParams { + float3 baseColorFactor; + float _pad0; + float roughnessFactor; + float _pad1; + float2 _pad2; +}; +[[vk::binding(0, 2)]] ConstantBuffer UMaterial; + +static const float PI = 3.14159265359; + +// Old renderer used a point light at (-10,10,-10); the resulting direction from light +// to scene (z≈12) is approximately (0.38,-0.38,0.84) — behind and above the camera. +static const float3 LIGHT_DIR0 = float3( 0.38, -0.38, 0.84); // key: behind-camera, upper-left +static const float3 LIGHT_DIR1 = float3(-0.50, 0.50, -0.71); // rim: front upper-right +static const float3 LIGHT_DIR2 = float3( 0.00, -1.00, 0.00); // bounce: from below +static const float4 LIGHT_COL0 = float4(1.0, 0.90, 0.80, 8.0); // warm key +static const float4 LIGHT_COL1 = float4(0.5, 0.55, 1.00, 2.5); // cool rim +static const float4 LIGHT_COL2 = float4(0.8, 0.80, 0.80, 0.8); // neutral bounce + +float DistributionGGX(float3 N, float3 H, float roughness) { + // Add epsilon to avoid singularities at very low roughness. + float a = max(roughness * roughness, 0.0025); + float a2 = a * a; + float NdotH = max(dot(N, H), 0.0); + float denom = (NdotH * NdotH * (a2 - 1.0) + 1.0); + return a2 / (PI * denom * denom + 1e-7); +} + +float GeometrySchlickGGX(float NdotV, float roughness) { + // Heitz's k for direct lighting approximation. + float r = roughness + 1.0; + float k = (r * r) / 8.0; + return NdotV / (NdotV * (1.0 - k) + k); +} + +float GeometrySmith(float3 N, float3 V, float3 L, float roughness) { + float NdotV = max(dot(N, V), 0.0); + float NdotL = max(dot(N, L), 0.0); + return GeometrySchlickGGX(NdotL, roughness) * GeometrySchlickGGX(NdotV, roughness); +} + +float3 fresnelSchlick(float cosTheta, float3 F0) { + return F0 + (float3(1.0, 1.0, 1.0) - F0) * pow(1.0 - cosTheta, 5.0); +} + +float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness) { + return F0 + (max(float3(1.0 - roughness, 1.0 - roughness, 1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0); +} + +float2 dir_to_equirect_uv(float3 dir) { + float3 d = normalize(dir); + float u = 0.5 + atan2(d.x, -d.z) / (2.0 * PI); + float v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) / PI; + return float2(frac(u), clamp(v, 0.0, 1.0)); +} + +float3 accumulateDirLight( + float3 N, float3 V, float3 F0, + float3 albedo, float roughness, float metallic, + float3 lightDir, float4 lightColor +) { + // lightDir is direction from light to surface; incoming L is opposite. + float3 L = normalize(-lightDir); + float3 H = normalize(V + L); + float3 radiance = lightColor.w * lightColor.xyz; + float NDF = DistributionGGX(N, H, roughness); + float G = GeometrySmith(N, V, L, roughness); + float3 F = fresnelSchlick(max(dot(H, V), 0.0), F0); + float3 kD = (float3(1.0, 1.0, 1.0) - F) * (1.0 - metallic); + float denom = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001; + float3 specular = (NDF * G * F) / float3(denom, denom, denom); + return (kD * (albedo / PI) + specular) * radiance * max(dot(N, L), 0.0); +} + +[shader("fragment")] +float4 fs_main( + [[vk::location(0)]] float2 uv : TEXCOORD0, + [[vk::location(1)]] float3 WorldPos: TEXCOORD1, + [[vk::location(2)]] float3 NormalIn: TEXCOORD2 +) : SV_TARGET { + float3 albedo = texBaseColor.Sample(smpBaseColor, uv).rgb * UMaterial.baseColorFactor; + // roughnessFactor maps to specularity slot (offset 16 in current material layout). + // metallic is not stored yet — default to 0.0 (dielectric). + float roughness = clamp(UMaterial.roughnessFactor, 0.045, 0.99); + float metallic = 0.0; + // TODO: Support normal mapping (tangent space) and AO texture. + float3 N = normalize(NormalIn); + float3 V = normalize(UCamera.position.xyz - WorldPos); + float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, float3(metallic, metallic, metallic)); + float3 Lo = float3(0.0, 0.0, 0.0); + Lo += accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR0, LIGHT_COL0); + Lo += accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR1, LIGHT_COL1); + Lo += accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR2, LIGHT_COL2); + // Diffuse IBL ambient. + float3 kS = fresnelSchlick(max(dot(N, V), 0.0), F0); + float3 kD = (float3(1.0, 1.0, 1.0) - kS) * (1.0 - metallic); + float3 irradiance = texIrradiance.Sample(smpIrradiance, dir_to_equirect_uv(N)).rgb; + float3 ambientDiffuse = kD * irradiance * albedo; + // Specular IBL. + float3 R = reflect(-V, N); + float MAX_REFLECTION_LOD = 4.0; + float3 prefilteredColor = texPrefilter.SampleLevel(smpPrefilter, dir_to_equirect_uv(R), roughness * MAX_REFLECTION_LOD).rgb; + float2 envBRDF = texBrdfLut.Sample(smpBrdfLut, float2(max(dot(N, V), 0.0), roughness)).rg; + float3 F = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness); + float3 specularIBL = prefilteredColor * (F * envBRDF.x + envBRDF.y); + float3 ambient = float3(0.04, 0.04, 0.04) * albedo; + float3 color = Lo + ambientDiffuse + specularIBL + ambient; + return float4(color, 1.0); +} diff --git a/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl b/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl new file mode 100644 index 00000000..a9a7fa29 --- /dev/null +++ b/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl @@ -0,0 +1,41 @@ +// PBR static vertex shader. Edit here — pill_assets regenerates the .wgsl. +// Reads pos/uv/normal from vertex buffer (locations 0, 4, 5); MVP and model +// matrix come from the per-draw dynamic UBO at group 3 binding 0. + +struct Camera { + float4 position; + column_major float4x4 viewProjection; +}; +[[vk::binding(0, 0)]] ConstantBuffer UCamera; + +struct PerDraw { + column_major float4x4 mvp; + column_major float4x4 model; +}; +[[vk::binding(0, 3)]] ConstantBuffer UPerDraw; + +struct VSOut { + [[vk::location(0)]] float2 uv : TEXCOORD0; + [[vk::location(1)]] float3 worldPos : TEXCOORD1; + [[vk::location(2)]] float3 worldNormal: TEXCOORD2; + float4 sv_position: SV_POSITION; +}; + +struct VSIn { + [[vk::location(0)]] float3 pos; + [[vk::location(4)]] float2 uv; + [[vk::location(5)]] float3 normal; +}; + +[shader("vertex")] +VSOut vs_main(VSIn input) { + float4 worldPos4 = mul(UPerDraw.model, float4(input.pos, 1.0)); + // TODO: Use a proper normal matrix (inverse-transpose of model) if non-uniform scaling is used. + float3 n = normalize(mul(UPerDraw.model, float4(input.normal, 0.0)).xyz); + VSOut output; + output.sv_position = mul(UPerDraw.mvp, float4(input.pos, 1.0)); + output.uv = input.uv; + output.worldPos = worldPos4.xyz; + output.worldNormal = n; + return output; +} diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_static.rs index e8c9ed14..c66ae56d 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_static.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_static.rs @@ -177,185 +177,9 @@ impl Pass for PassPBRStatic { } fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { - let vertex_wgsl = r#" - struct Camera { - position: vec4, - viewProjection: mat4x4, - }; - @group(0) @binding(0) var UCamera: Camera; - struct PerDraw { mvp: mat4x4, model: mat4x4, }; - @group(3) @binding(0) var UPerDraw: PerDraw; - struct VSIn { - @location(0) pos: vec3, - @location(4) uv: vec2, - @location(5) normal: vec3, - }; - struct VSOut { - @builtin(position) pos: vec4, - @location(0) uv: vec2, - @location(1) worldPos: vec3, - @location(2) worldNormal: vec3, - }; - @vertex fn main(input: VSIn) -> VSOut { - var out: VSOut; - let worldPos4 = UPerDraw.model * vec4(input.pos, 1.0); - // TODO: Use a proper normal matrix (inverse-transpose of model) if non-uniform scaling is used. - let n = normalize((UPerDraw.model * vec4(input.normal, 0.0)).xyz); - out.pos = UPerDraw.mvp * vec4(input.pos, 1.0); - out.uv = input.uv; - out.worldPos = worldPos4.xyz; - out.worldNormal = n; - return out; - } - "#; - - let fragment_wgsl = r#" - // PBR material textures (set 1) — bindings match DEFAULT_LIT_SHADER layout (0-3). - @group(1) @binding(0) var texBaseColor: texture_2d; - @group(1) @binding(1) var smpBaseColor: sampler; - @group(1) @binding(2) var texNormal: texture_2d; - @group(1) @binding(3) var smpNormal: sampler; - // PBR params UBO (set 2) — 32 bytes matching default lit shader parameter layout: - // offset 0: baseColorFactor (vec3 + pad), offset 16: roughnessFactor (f32 + pad). - struct MaterialParams { - baseColorFactor: vec3, _pad0: f32, - roughnessFactor: f32, _pad1: f32, _pad2: vec2, - } - @group(2) @binding(0) var UMaterial: MaterialParams; - struct Camera { - position: vec4, - viewProjection: mat4x4, - }; - @group(0) @binding(0) var UCamera: Camera; - // IBL resources in globals (set 0) - @group(0) @binding(1) var texIrradiance: texture_2d; - @group(0) @binding(2) var smpIrradiance: sampler; - @group(0) @binding(3) var texPrefilter: texture_2d; - @group(0) @binding(4) var smpPrefilter: sampler; - @group(0) @binding(5) var texBrdfLut: texture_2d; - @group(0) @binding(6) var smpBrdfLut: sampler; - - const PI: f32 = 3.14159265359; - - // Old renderer used a point light at (-10,10,-10); the resulting direction from light - // to scene (z≈12) is approximately (0.38,-0.38,0.84) — behind and above the camera. - // This strongly illuminates the camera-facing surface (normal≈(0,0,-1), NdotL≈0.84). - const LIGHT_DIR0: vec3 = vec3( 0.38, -0.38, 0.84); // key: behind-camera, upper-left - const LIGHT_DIR1: vec3 = vec3(-0.50, 0.50, -0.71); // rim: front upper-right - const LIGHT_DIR2: vec3 = vec3( 0.00, -1.00, 0.00); // bounce: from below - const LIGHT_COL0: vec4 = vec4(1.0, 0.90, 0.80, 8.0); // warm key - const LIGHT_COL1: vec4 = vec4(0.5, 0.55, 1.00, 2.5); // cool rim - const LIGHT_COL2: vec4 = vec4(0.8, 0.80, 0.80, 0.8); // neutral bounce - - fn DistributionGGX(N: vec3, H: vec3, roughness: f32) -> f32 { - // Add epsilon to avoid singularities at very low roughness. - let a = max(roughness * roughness, 0.0025); - let a2 = a * a; - let NdotH = max(dot(N, H), 0.0); - let NdotH2 = NdotH * NdotH; - let denom = (NdotH2 * (a2 - 1.0) + 1.0); - return a2 / (PI * denom * denom + 1e-7); - } - - fn GeometrySchlickGGX(NdotV: f32, roughness: f32) -> f32 { - // Heitz's k for direct lighting approximation. - let r = roughness + 1.0; - let k = (r * r) / 8.0; - let denom = NdotV * (1.0 - k) + k; - return NdotV / denom; - } - - fn GeometrySmith(N: vec3, V: vec3, L: vec3, roughness: f32) -> f32 { - let NdotV = max(dot(N, V), 0.0); - let NdotL = max(dot(N, L), 0.0); - let ggx2 = GeometrySchlickGGX(NdotV, roughness); - let ggx1 = GeometrySchlickGGX(NdotL, roughness); - return ggx1 * ggx2; - } + let vertex_wgsl = include_str!("../../res/shaders/pbr_static_vertex.wgsl"); - fn fresnelSchlick(cosTheta: f32, F0: vec3) -> vec3 { - return F0 + (vec3(1.0, 1.0, 1.0) - F0) * pow(1.0 - cosTheta, 5.0); - } - - fn fresnelSchlickRoughness(cosTheta: f32, F0: vec3, roughness: f32) -> vec3 { - return F0 + (max(vec3(1.0, 1.0, 1.0) * (1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0); - } - - fn dir_to_equirect_uv(dir: vec3) -> vec2 { - let d = normalize(dir); - let u = 0.5 + atan2(d.x, -d.z) / (2.0 * PI); - let v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) / PI; - return vec2(fract(u), clamp(v, 0.0, 1.0)); - } - - fn accumulateDirLight( - N: vec3, V: vec3, F0: vec3, - albedo: vec3, roughness: f32, metallic: f32, - lightDir: vec3, lightColor: vec4 - ) -> vec3 { - // lightDir is direction from light to surface; incoming L is opposite. - let L = normalize(-lightDir.xyz); - let H = normalize(V + L); - let radiance = lightColor.w * lightColor.xyz; - let NDF = DistributionGGX(N, H, roughness); - let G = GeometrySmith(N, V, L, roughness); - let F = fresnelSchlick(max(dot(H, V), 0.0), F0); - let kS = F; - var kD = vec3(1.0, 1.0, 1.0) - kS; - kD = kD * (1.0 - metallic); - let numerator = NDF * G * F; - let denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001; - let specular = numerator / vec3(denominator, denominator, denominator); - let NdotL = max(dot(N, L), 0.0); - return (kD * (albedo / PI) + specular) * radiance * NdotL; - } - - struct FSOut { - @location(0) color: vec4, - }; - - @fragment fn main( - @location(0) uv: vec2, - @location(1) WorldPos: vec3, - @location(2) NormalIn: vec3, - ) -> FSOut { - var albedo = textureSample(texBaseColor, smpBaseColor, uv).rgb * UMaterial.baseColorFactor; - // roughnessFactor maps to specularity slot (offset 16 in current material layout). - // metallic is not stored yet — default to 0.0 (dielectric). - var roughness = clamp(UMaterial.roughnessFactor, 0.045, 0.99); - let metallic = 0.0; - // TODO: Support normal mapping (tangent space) and AO texture. - let N = normalize(NormalIn); - let V = normalize(UCamera.position.xyz - WorldPos); - var F0 = vec3(0.04, 0.04, 0.04); - F0 = mix(F0, albedo, vec3(metallic, metallic, metallic)); - var Lo = vec3(0.0, 0.0, 0.0); - Lo = Lo + accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR0, LIGHT_COL0); - Lo = Lo + accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR1, LIGHT_COL1); - Lo = Lo + accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR2, LIGHT_COL2); - var color = Lo; - // Diffuse IBL ambient. - let kS = fresnelSchlick(max(dot(N, V), 0.0), F0); - let kD = (vec3(1.0, 1.0, 1.0) - kS) * (1.0 - metallic); - let uvIrr = dir_to_equirect_uv(N); - let irradiance = textureSample(texIrradiance, smpIrradiance, uvIrr).rgb; - let ambient_diffuse = kD * irradiance * albedo; - // Specular IBL. - let R = reflect(-V, N); - let MAX_REFLECTION_LOD: f32 = 4.0; - let prefilteredColor = textureSampleLevel( - texPrefilter, smpPrefilter, dir_to_equirect_uv(R), roughness * MAX_REFLECTION_LOD - ).rgb; - let envBRDF = textureSample(texBrdfLut, smpBrdfLut, vec2(max(dot(N, V), 0.0), roughness)).rg; - let F = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness); - let specular = prefilteredColor * (F * envBRDF.x + envBRDF.y); - let ambient = vec3(0.04, 0.04, 0.04) * albedo; - color = color + ambient_diffuse + specular + ambient; - var out: FSOut; - out.color = vec4(color, 1.0); - return out; - } - "#; + let fragment_wgsl = include_str!("../../res/shaders/pbr_static_fragment.wgsl"); let surface_format = renderer.get_surface_format(); let bind_groups: Vec> = vec![ @@ -486,11 +310,11 @@ impl Pass for PassPBRStatic { label: Some("pass_pbr_static_pipeline"), vs: ShaderDesc { source: vertex_wgsl, - entry_func: "main", + entry_func: "vs_main", }, ps: ShaderDesc { source: fragment_wgsl, - entry_func: "main", + entry_func: "fs_main", }, vertex_buffers: &[ ::data_layout_descriptor(), diff --git a/examples/pill_tunel/Cargo.toml b/examples/pill_tunel/Cargo.toml index 241c1f4b..3433b742 100644 --- a/examples/pill_tunel/Cargo.toml +++ b/examples/pill_tunel/Cargo.toml @@ -2,7 +2,7 @@ name = "pill_game" version = "0.1.0" edition = "2021" -workspace = "NO_PATH" +workspace = "/Users/mk/dev/demo/Pill-Engine/engine" [lib] crate-type = ["cdylib", "rlib"] From d51c2620d98539eff6639b9489cca1153620bce5 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Thu, 28 May 2026 23:36:58 +0200 Subject: [PATCH 04/31] review --- engine/pill_core/src/handle.rs | 37 ++- engine/pill_core/src/timer.rs | 17 +- .../ecs/components/render_state_component.rs | 1 + engine/pill_engine/src/ecs/egui_client.rs | 15 +- .../src/ecs/systems/rendering_system.rs | 4 +- engine/pill_engine/src/engine.rs | 4 +- .../src/graphics/dummy_renderer.rs | 8 + engine/pill_engine/src/graphics/mod.rs | 2 + engine/pill_engine/src/graphics/pass_egui.rs | 136 ++++++++- .../mesh_drawer.rs => graphics/pass_mesh.rs} | 259 ++++++++++++------ .../src/graphics/pass_pbr_static.rs | 9 +- .../pill_engine/src/graphics/render_queue.rs | 7 +- engine/pill_engine/src/graphics/renderer.rs | 6 + .../src/renderer/drawers/egui_drawer.rs | 127 --------- .../pill_engine/src/renderer/drawers/mod.rs | 4 +- engine/pill_engine/src/renderer/instance.rs | 1 + engine/pill_engine/src/renderer/state.rs | 8 + 17 files changed, 387 insertions(+), 258 deletions(-) rename engine/pill_engine/src/{renderer/drawers/mesh_drawer.rs => graphics/pass_mesh.rs} (55%) delete mode 100644 engine/pill_engine/src/renderer/drawers/egui_drawer.rs diff --git a/engine/pill_core/src/handle.rs b/engine/pill_core/src/handle.rs index 796f5fc7..2b8167f7 100644 --- a/engine/pill_core/src/handle.rs +++ b/engine/pill_core/src/handle.rs @@ -11,6 +11,7 @@ pub struct Handle { } impl Handle { + /// Constructs a handle from a raw slot index and generation counter. #[inline] pub const fn from_parts(index: u32, generation: u32) -> Self { Self { @@ -19,11 +20,13 @@ impl Handle { } } + /// Returns the slot index encoded in this handle. #[inline] pub const fn index(&self) -> u32 { self.raw as u32 } + /// Returns the generation counter encoded in this handle. #[inline] pub const fn generation(&self) -> u32 { (self.raw >> 32) as u32 @@ -34,6 +37,7 @@ impl Handle { _marker: PhantomData, }; + /// Returns `true` if this handle has not been set to the sentinel invalid value. #[inline] pub const fn is_valid(&self) -> bool { self.raw != u64::MAX @@ -81,6 +85,7 @@ impl Default for ResourcePool { } impl ResourcePool { + /// Creates an empty pool with no allocated slots. pub fn new() -> Self { Self { entries: Vec::new(), @@ -89,13 +94,14 @@ impl ResourcePool { } } + /// Inserts a resource and returns a handle that uniquely identifies this slot generation. #[inline] pub fn insert(&mut self, resource: V) -> Handle { if let Some(idx) = self.free_list.pop() { - let e = &mut self.entries[idx as usize]; - e.generation = e.generation.wrapping_add(1); - e.resource = Some(resource); - Handle::from_parts(idx, e.generation) + let entry = &mut self.entries[idx as usize]; + entry.generation = entry.generation.wrapping_add(1); + entry.resource = Some(resource); + Handle::from_parts(idx, entry.generation) } else { let idx = self.entries.len() as u32; self.entries.push(Entry { @@ -106,45 +112,48 @@ impl ResourcePool { } } + /// Removes the resource at the given handle; returns `None` if the handle is stale or out of bounds. #[inline] pub fn remove(&mut self, h: Handle) -> Option { let idx = h.index() as usize; if idx >= self.entries.len() { return None; } - let e = &mut self.entries[idx]; - if e.generation != h.generation() { + let entry = &mut self.entries[idx]; + if entry.generation != h.generation() { return None; } - let res = e.resource.take(); - e.generation = e.generation.wrapping_add(1); + let res = entry.resource.take(); + entry.generation = entry.generation.wrapping_add(1); self.free_list.push(h.index()); res } + /// Returns a shared reference to the resource at the given handle, or `None` if stale or absent. #[inline] pub fn get(&self, h: Handle) -> Option<&V> { let idx = h.index() as usize; if idx >= self.entries.len() { return None; } - let e = &self.entries[idx]; - if e.generation != h.generation() { + let entry = &self.entries[idx]; + if entry.generation != h.generation() { return None; } - e.resource.as_ref() + entry.resource.as_ref() } + /// Returns an exclusive reference to the resource at the given handle, or `None` if stale or absent. #[inline] pub fn get_mut(&mut self, h: Handle) -> Option<&mut V> { let idx = h.index() as usize; if idx >= self.entries.len() { return None; } - let e = &mut self.entries[idx]; - if e.generation != h.generation() { + let entry = &mut self.entries[idx]; + if entry.generation != h.generation() { return None; } - e.resource.as_mut() + entry.resource.as_mut() } } diff --git a/engine/pill_core/src/timer.rs b/engine/pill_core/src/timer.rs index 8b5337c7..e62383af 100644 --- a/engine/pill_core/src/timer.rs +++ b/engine/pill_core/src/timer.rs @@ -32,6 +32,7 @@ impl Default for Timer { } impl Timer { + /// Creates an empty timer with no records, contexts, or counters. pub fn new() -> Self { Self { stack: Vec::new(), @@ -42,6 +43,7 @@ impl Timer { } } + /// Opens a new named timing context; any previously open context becomes a parent. pub fn begin_context(&mut self, label: impl Into) { // Close current segment if needed self.flush_record(); @@ -56,6 +58,7 @@ impl Timer { }); } + /// Closes the innermost context and records its elapsed duration. pub fn end_context(&mut self) -> Result<()> { self.flush_record(); // End any remaining record @@ -76,6 +79,7 @@ impl Timer { Ok(()) } + /// Records a named checkpoint; its duration is measured until the next `record` or `end_context` call. pub fn record(&mut self, label: impl Into) { self.flush_record(); // End any previous one self.current_label = Some(label.into()); @@ -100,30 +104,35 @@ impl Timer { self.current_label_start = None; } + /// Returns the sum of all top-level record durations in milliseconds. pub fn total_duration(&self) -> f32 { - self.records.iter().map(|r| r.duration).sum() + self.records.iter().map(|record| record.duration).sum() } + /// Sets the named counter to `value`, inserting it if it does not exist yet. pub fn set_counter(&mut self, label: impl Into, value: u64) { let label = label.into(); - if let Some(entry) = self.counters.iter_mut().find(|(k, _)| k == &label) { + if let Some(entry) = self.counters.iter_mut().find(|(key, _)| key == &label) { entry.1 = value; } else { self.counters.push((label, value)); } } + /// Returns the current value of the named counter, or `None` if it has not been set. pub fn get_counter(&self, label: &str) -> Option { self.counters .iter() - .find(|(k, _)| k == label) - .map(|(_, v)| *v) + .find(|(key, _)| key == label) + .map(|(_, value)| *value) } + /// Returns all counters as a slice of (label, value) pairs. pub fn counters(&self) -> &Vec<(String, u64)> { &self.counters } + /// Prints all records to stdout at the given indentation level. pub fn print(&self, indent: usize) { for record in &self.records { Self::print_record(record, indent); diff --git a/engine/pill_engine/src/ecs/components/render_state_component.rs b/engine/pill_engine/src/ecs/components/render_state_component.rs index fe35a599..da74f28c 100644 --- a/engine/pill_engine/src/ecs/components/render_state_component.rs +++ b/engine/pill_engine/src/ecs/components/render_state_component.rs @@ -12,6 +12,7 @@ pub struct RenderStateComponent { } impl RenderStateComponent { + /// Creates the component in its pre-boot state with the provided egui client. pub fn new(egui_client: Arc) -> Self { Self { boot_done: false, diff --git a/engine/pill_engine/src/ecs/egui_client.rs b/engine/pill_engine/src/ecs/egui_client.rs index 43ed9029..c06431dd 100644 --- a/engine/pill_engine/src/ecs/egui_client.rs +++ b/engine/pill_engine/src/ecs/egui_client.rs @@ -1,13 +1,14 @@ use std::sync::{Arc, Mutex}; -type UiFn = Box; +type UiFunction = Box; pub struct EguiClient { events: Mutex>, - ui: Mutex>, + ui: Mutex>, } impl EguiClient { + /// Creates a new shared egui client with empty event and UI queues. pub fn new() -> Arc { Arc::new(Self { events: Mutex::new(Vec::new()), @@ -15,19 +16,23 @@ impl EguiClient { }) } + /// Enqueues a window event for delivery to the egui input handler on the next frame. pub fn handle_input(&self, event: winit::event::WindowEvent) { self.events.lock().unwrap().push(event); } + /// Drains and returns all queued window events, leaving the queue empty. pub fn take_events(&self) -> Vec { std::mem::take(&mut self.events.lock().unwrap()) } - pub fn set_ui(&self, f: impl Fn(&egui::Context) + Send + 'static) { - *self.ui.lock().unwrap() = Some(Box::new(f)); + /// Replaces the current UI function with `ui_fn`; called once per frame before rendering. + pub fn set_ui(&self, ui_fn: impl Fn(&egui::Context) + Send + 'static) { + *self.ui.lock().unwrap() = Some(Box::new(ui_fn)); } - pub fn take_ui(&self) -> Option { + /// Takes ownership of the pending UI function, leaving `None` in its place. + pub fn take_ui(&self) -> Option { self.ui.lock().unwrap().take() } } diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index 84e0917d..728267c4 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -160,7 +160,7 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { )?; Ok(()) } - Err(e) => match e.downcast_ref::() { + Err(error) => match error.downcast_ref::() { Some(RendererError::SurfaceLost) => { timer.end_context()?; engine.system_manager.update_system_timer( @@ -174,7 +174,7 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { Some(RendererError::SurfaceOutOfMemory) => { panic!("Critical: Renderer error, system out of memory"); } - _ => Err(e), + _ => Err(error), }, } } diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index 8f31b2a5..b6eda158 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -451,8 +451,8 @@ impl Engine { EngineError::NonReturnedSystemTimer(system_name.clone()) ); } - Err(e) => { - panic!("{}", EngineError::Other(e.to_string())); + Err(error) => { + panic!("{}", EngineError::Other(error.to_string())); } }; diff --git a/engine/pill_engine/src/graphics/dummy_renderer.rs b/engine/pill_engine/src/graphics/dummy_renderer.rs index 1bd27f50..1c9e989a 100644 --- a/engine/pill_engine/src/graphics/dummy_renderer.rs +++ b/engine/pill_engine/src/graphics/dummy_renderer.rs @@ -89,6 +89,14 @@ impl PillRenderer for DummyRenderer { wgpu::TextureFormat::Rgba8UnormSrgb } + fn get_engine_parameters(&self) -> &crate::renderer::resources::EngineParameters { + unimplemented!("DummyRenderer has no EngineParameters") + } + + fn get_camera_bind_group_layout(&self) -> wgpu::BindGroupLayout { + unimplemented!("DummyRenderer has no wgpu Device") + } + fn create_buffer(&mut self, _desc: BufferDesc) -> Result { unimplemented!("DummyRenderer has no wgpu Device") } diff --git a/engine/pill_engine/src/graphics/mod.rs b/engine/pill_engine/src/graphics/mod.rs index cb7cff9f..6c9320e2 100644 --- a/engine/pill_engine/src/graphics/mod.rs +++ b/engine/pill_engine/src/graphics/mod.rs @@ -6,6 +6,7 @@ mod dummy_renderer; mod egui; #[cfg(feature = "debug_ui")] mod pass_egui; +mod pass_mesh; mod pass_pbr_static; mod render_queue; mod renderer; @@ -24,6 +25,7 @@ pub use self::dummy_renderer::DummyRenderer; pub use egui::EguiUI; #[cfg(feature = "debug_ui")] pub use pass_egui::PassEgui; +pub use pass_mesh::PassMesh; pub use pass_pbr_static::PassPBRStatic; pub use render_queue::{ diff --git a/engine/pill_engine/src/graphics/pass_egui.rs b/engine/pill_engine/src/graphics/pass_egui.rs index ef54cc87..fadb835d 100644 --- a/engine/pill_engine/src/graphics/pass_egui.rs +++ b/engine/pill_engine/src/graphics/pass_egui.rs @@ -1,10 +1,133 @@ use crate::{ ecs::EguiClient, graphics::{Pass, PillRenderer, WorldQuery}, - renderer::drawers::egui_drawer::EguiDrawer, }; -use pill_core::Result; +use pill_core::{RendererError, Result, Timer}; use std::sync::Arc; +use winit::event::WindowEvent; + +const BORDER_RADIUS: f32 = 2.0; + +struct EguiDrawer { + context: egui::Context, + state: egui_winit::State, + renderer: egui_wgpu::Renderer, + window_scale_factor: f32, + window: Arc, +} + +impl EguiDrawer { + fn new( + device: &wgpu::Device, + output_color_format: wgpu::TextureFormat, + output_depth_format: Option, + msaa_samples: u32, + window: Arc, + ) -> EguiDrawer { + let window_scale_factor = window.scale_factor() as f32; + let context = egui::Context::default(); + let id = context.viewport_id(); + + let visuals = egui::Visuals { + window_corner_radius: egui::CornerRadius::from(BORDER_RADIUS), + window_shadow: egui::Shadow::NONE, + ..Default::default() + }; + context.set_visuals(visuals); + + let state = egui_winit::State::new(context.clone(), id, &window, None, None, None); + + let renderer = egui_wgpu::Renderer::new( + device, + output_color_format, + output_depth_format, + msaa_samples, + false, + ); + + EguiDrawer { + context, + state, + renderer, + window_scale_factor, + window, + } + } + + fn handle_input(&mut self, event: &WindowEvent) { + let _ = self.state.on_window_event(&self.window, event); + } + + #[allow(clippy::too_many_arguments)] + fn record_draw_commands( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + window_surface_view: &wgpu::TextureView, + screen_descriptor: egui_wgpu::ScreenDescriptor, + mut run_ui: Box, + timer: &mut Timer, + ) -> Result<()> { + timer.record("Prepare window and input"); + + let window = &self.window; + let raw_input = self.state.take_egui_input(window); + + let full_output = self.context.run(raw_input, |_| { + run_ui(&self.context); + }); + + timer.record("Handle platform output"); + + self.state + .handle_platform_output(window, full_output.platform_output); + + timer.record("Tesselate and update textures"); + + let tris = self + .context + .tessellate(full_output.shapes, full_output.pixels_per_point); + for (id, image_delta) in &full_output.textures_delta.set { + self.renderer + .update_texture(device, queue, *id, image_delta); + } + + timer.record("Update buffers and record render pass"); + + self.renderer + .update_buffers(device, queue, encoder, &tris, &screen_descriptor); + + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: window_surface_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + label: Some("egui main render pass"), + timestamp_writes: None, + occlusion_query_set: None, + }); + + timer.record("Render"); + + let render_pass: &mut wgpu::RenderPass<'static> = + unsafe { std::mem::transmute(&mut render_pass) }; + + self.renderer + .render(&mut *render_pass, &tris, &screen_descriptor); + + for texture_id in &full_output.textures_delta.free { + self.renderer.free_texture(texture_id) + } + + Ok(()) + } +} pub struct PassEgui { window: Arc, @@ -13,7 +136,7 @@ pub struct PassEgui { } impl PassEgui { - /// Creates the egui pass; the `EguiDrawer` is initialised lazily in `Pass::init`. + /// Creates the egui pass; `EguiDrawer` is initialised lazily in `Pass::init`. pub fn new(window: Arc, client: Arc) -> Self { Self { window, @@ -47,7 +170,10 @@ impl Pass for PassEgui { view: &wgpu::TextureView, _world: &WorldQuery<'_>, ) -> Result<()> { - let drawer = self.drawer.as_mut().expect("PassEgui not initialized"); + let drawer = self + .drawer + .as_mut() + .ok_or_else(|| -> pill_core::PillError { RendererError::Other.into() })?; for ev in self.client.take_events() { drawer.handle_input(&ev); @@ -74,7 +200,7 @@ impl Pass for PassEgui { view, screen_desc, run_ui, - &mut pill_core::Timer::new(), + &mut Timer::new(), ) } } diff --git a/engine/pill_engine/src/renderer/drawers/mesh_drawer.rs b/engine/pill_engine/src/graphics/pass_mesh.rs similarity index 55% rename from engine/pill_engine/src/renderer/drawers/mesh_drawer.rs rename to engine/pill_engine/src/graphics/pass_mesh.rs index 839a6af2..12bcf46c 100644 --- a/engine/pill_engine/src/renderer/drawers/mesh_drawer.rs +++ b/engine/pill_engine/src/graphics/pass_mesh.rs @@ -1,26 +1,32 @@ use std::{num::NonZeroU32, ops::Range}; -use crate::renderer::{ - config::{ - CAMERA_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, - INITIAL_INSTANCE_VECTOR_CAPACITY, MATERIAL_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, - MATERIAL_TEXTURES_BIND_GROUP_LAYOUT_INDEX, - }, - instance::Instance, - resources::{EngineParameters, RendererCamera, RendererMaterial, RendererMesh, RendererShader}, +use crate::graphics::{ + RenderQueueItem, RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle, }; +use crate::internal::decompose_render_queue_key; use crate::{ - ecs::ComponentStorage, - graphics::{RenderQueueItem, RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle}, - internal::{decompose_render_queue_key, TransformComponent}, + ecs::{CameraComponent, ComponentStorage}, + graphics::{Pass, PillRenderer, RendererTextureHandle, WorldQuery}, + internal::TransformComponent, + renderer::{ + config::{ + CAMERA_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, + INITIAL_INSTANCE_VECTOR_CAPACITY, MATERIAL_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, + MATERIAL_TEXTURES_BIND_GROUP_LAYOUT_INDEX, MAX_INSTANCE_PER_DRAWCALL_COUNT, + }, + instance::Instance, + resources::{ + EngineParameters, RendererCamera, RendererMaterial, RendererMesh, RendererShader, + }, + }, resources::ResourceManager, }; -use pill_core::{debug, LogContext, PillStyle, RendererError, Timer}; +use pill_core::{debug, LogContext, PillSlotMapKey, PillStyle, RendererError, Result, Timer}; -use pill_core::Result; +// --- DrawingContext --- #[derive(Debug, Clone, Default)] -pub struct DrawingContext { +struct DrawingContext { rendering_order: u8, shader_handle: Option, shader_name: String, @@ -40,7 +46,7 @@ pub struct DrawingContext { } impl DrawingContext { - pub fn log(&self) { + fn log(&self) { debug!( LogContext::Frame => "Draw {} instance(s) {}->{}/{} command recorded [Batch: {}, Rendering order: {}, Shader: {}, Material: {}, Mesh: {}]", @@ -56,7 +62,7 @@ impl DrawingContext { ); } - pub fn record_draw_accumulated_instances(&mut self, render_pass: &mut wgpu::RenderPass) { + fn record_draw_accumulated_instances(&mut self, render_pass: &mut wgpu::RenderPass) { if self.accumulated_instance_count > 0 { render_pass.draw_indexed( 0..self.mesh_index_count, @@ -70,20 +76,20 @@ impl DrawingContext { } } - pub fn accumulate_instance(&mut self) { + fn accumulate_instance(&mut self) { self.accumulated_instance_range = self.accumulated_instance_range.start..self.accumulated_instance_range.end + 1; self.accumulated_instance_count = self.accumulated_instance_range.end - self.accumulated_instance_range.start; } - pub fn change_rendering_order(&mut self, new_order: u8) { + fn change_rendering_order(&mut self, new_order: u8) { self.rendering_order = new_order; self.rendering_context_change_number += 1; debug!(LogContext::Frame => "Rendering order changed to: {}", self.rendering_order); } - pub fn change_shader( + fn change_shader( &mut self, resource_manager: &ResourceManager, engine_parameters: &EngineParameters, @@ -120,7 +126,7 @@ impl DrawingContext { self.rendering_context_change_number += 1; } - pub fn change_material( + fn change_material( &mut self, resource_manager: &ResourceManager, material_handle: RendererMaterialHandle, @@ -151,7 +157,7 @@ impl DrawingContext { self.rendering_context_change_number += 1; } - pub fn change_mesh( + fn change_mesh( &mut self, resource_manager: &ResourceManager, mesh_handle: RendererMeshHandle, @@ -171,156 +177,229 @@ impl DrawingContext { } } -pub struct MeshDrawer { - max_instance_batch_size: u32, +// --- PassMesh --- + +/// Instance-batched rendering pass using `RendererShader` pipelines. +/// Install via `renderer.set_passes(vec![Box::new(PassMesh::new())])`. +pub struct PassMesh { + depth_handle: Option, + camera: Option, + instance_buffer: Option, instances: Vec, - instance_buffer: wgpu::Buffer, } -impl MeshDrawer { - pub fn new(device: &wgpu::Device, max_instance_batch_size: u32) -> Self { - let buffer_size = (size_of::() * max_instance_batch_size as usize) as u64; - let instance_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("instance_buffer"), - size: buffer_size, - usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - MeshDrawer { - max_instance_batch_size, - instances: Vec::::with_capacity(INITIAL_INSTANCE_VECTOR_CAPACITY), - instance_buffer, +impl PassMesh { + /// Creates a `PassMesh`; GPU resources are allocated lazily in `Pass::init`. + pub fn new() -> Self { + Self { + depth_handle: None, + camera: None, + instance_buffer: None, + instances: Vec::with_capacity(INITIAL_INSTANCE_VECTOR_CAPACITY), } } +} + +impl Default for PassMesh { + fn default() -> Self { + Self::new() + } +} + +impl Pass for PassMesh { + fn get_label(&self) -> &str { + "pass_mesh" + } - #[allow(clippy::too_many_arguments)] - pub fn record_draw_commands( + /// Allocates the depth texture, camera UBO, and instance buffer. + fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { + self.depth_handle = Some(renderer.create_depth_texture("pass_mesh_depth")?); + + let layout = renderer.get_camera_bind_group_layout(); + self.camera = Some(RendererCamera::new(renderer.get_device(), layout)?); + + let buffer_size = + (std::mem::size_of::() * MAX_INSTANCE_PER_DRAWCALL_COUNT) as u64; + self.instance_buffer = Some( + renderer + .get_device() + .create_buffer(&wgpu::BufferDescriptor { + label: Some("pass_mesh_instance_buffer"), + size: buffer_size, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + ); + + Ok(()) + } + + /// Records instance-batched draw commands for the current frame. + fn draw( &mut self, - queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, - resource_manager: &ResourceManager, - engine_parameters: &EngineParameters, - color_attachment: wgpu::RenderPassColorAttachment, - depth_stencil_attachment: wgpu::RenderPassDepthStencilAttachment, - camera: &RendererCamera, - render_queue: &[RenderQueueItem], - transform_component_storage: &ComponentStorage, - timer: &mut Timer, + renderer: &mut dyn PillRenderer, + _frame: &wgpu::SurfaceTexture, + view: &wgpu::TextureView, + world: &WorldQuery<'_>, ) -> Result<()> { - timer.record("Prepare render pass"); + // Resolve active camera. + let active_camera_index = world.active_camera.data().index as usize; + let active_camera_component = world + .camera_components + .data + .get(active_camera_index) + .unwrap() + .as_ref() + .unwrap(); + let active_camera_transform = world + .transform_components + .data + .get(active_camera_index) + .unwrap() + .as_ref() + .unwrap(); + + // Update camera UBO. + self.camera.as_mut().unwrap().update( + renderer.get_queue(), + active_camera_component, + active_camera_transform, + ); + + let clear_color = active_camera_component.clear_color; + + let depth_view = renderer + .get_render_target_view(self.depth_handle.unwrap()) + .ok_or_else(|| -> pill_core::PillError { RendererError::Other.into() })?; + debug!(LogContext::Frame => "Recording mesh draw commands"); let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("main pass"), - color_attachments: &[Some(color_attachment.clone())], - depth_stencil_attachment: Some(depth_stencil_attachment.clone()), + label: Some("pass_mesh"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: clear_color.x as f64, + g: clear_color.y as f64, + b: clear_color.z as f64, + a: 1.0, + }), + 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, }); let mut current_drawing_context = DrawingContext::default(); - for (i, instance_batch) in render_queue - .chunks(self.max_instance_batch_size as usize) + let render_pass: &mut wgpu::RenderPass<'static> = + unsafe { std::mem::transmute(&mut render_pass) }; + + for (i, instance_batch) in world + .render_queue + .chunks(MAX_INSTANCE_PER_DRAWCALL_COUNT) .enumerate() { let batch_size = instance_batch.len(); current_drawing_context.instance_batch_number = i as u32; current_drawing_context.instance_batch_size = batch_size as u32; - timer.begin_context(format!("Prepare draw instance batch {}", i)); - timer.record("Write instance buffer".to_string()); - self.instances.clear(); self.instances.reserve(instance_batch.len()); for render_queue_item in instance_batch { - let transform_slot = transform_component_storage + let transform_slot = world + .transform_components .data .get(render_queue_item.entity_index as usize) .ok_or_else(|| -> pill_core::PillError { RendererError::Other.into() })?; let transform_component = transform_slot .as_ref() .ok_or_else(|| -> pill_core::PillError { RendererError::Other.into() })?; - //println!("Creating new instance with transform component: {:?} {:?}", i, transform_component.position); self.instances.push(Instance::new(transform_component)); } - queue.write_buffer( - &self.instance_buffer, + renderer.get_queue().write_buffer( + self.instance_buffer.as_ref().unwrap(), 0, bytemuck::cast_slice(&self.instances), ); - render_pass.set_vertex_buffer(1, self.instance_buffer.slice(..)); - - timer.record("Record draw instances commands"); + render_pass.set_vertex_buffer(1, self.instance_buffer.as_ref().unwrap().slice(..)); current_drawing_context.accumulated_instance_range = 0..0; current_drawing_context.accumulated_instance_count = 0; for (j, render_queue_item) in instance_batch.iter().enumerate() { - let render_queue_key_fields = decompose_render_queue_key(render_queue_item.key); + let key_fields = decompose_render_queue_key(render_queue_item.key); let renderer_shader_handle = RendererShaderHandle::new( - render_queue_key_fields.shader_index.into(), - NonZeroU32::new(render_queue_key_fields.shader_version.into()).unwrap(), + key_fields.shader_index.into(), + NonZeroU32::new(key_fields.shader_version.into()).unwrap(), ); let renderer_material_handle = RendererMaterialHandle::new( - render_queue_key_fields.material_index.into(), - NonZeroU32::new(render_queue_key_fields.material_version.into()).unwrap(), + key_fields.material_index.into(), + NonZeroU32::new(key_fields.material_version.into()).unwrap(), ); let renderer_mesh_handle = RendererMeshHandle::new( - render_queue_key_fields.mesh_index.into(), - NonZeroU32::new(render_queue_key_fields.mesh_version.into()).unwrap(), + key_fields.mesh_index.into(), + NonZeroU32::new(key_fields.mesh_version.into()).unwrap(), ); - if current_drawing_context.rendering_order > render_queue_key_fields.order { - current_drawing_context.record_draw_accumulated_instances(&mut render_pass); - current_drawing_context.change_rendering_order(render_queue_key_fields.order); + if current_drawing_context.rendering_order > key_fields.order { + current_drawing_context.record_draw_accumulated_instances(render_pass); + current_drawing_context.change_rendering_order(key_fields.order); } if current_drawing_context.shader_handle != Some(renderer_shader_handle) { - current_drawing_context.record_draw_accumulated_instances(&mut render_pass); + current_drawing_context.record_draw_accumulated_instances(render_pass); current_drawing_context.change_shader( - resource_manager, - engine_parameters, + world.resources, + renderer.get_engine_parameters(), renderer_shader_handle, - &mut render_pass, - camera, + render_pass, + self.camera.as_ref().unwrap(), ); } if current_drawing_context.material_handle != Some(renderer_material_handle) { - current_drawing_context.record_draw_accumulated_instances(&mut render_pass); + current_drawing_context.record_draw_accumulated_instances(render_pass); current_drawing_context.change_material( - resource_manager, + world.resources, renderer_material_handle, - &mut render_pass, + render_pass, ); } if current_drawing_context.mesh_handle != Some(renderer_mesh_handle) { - current_drawing_context.record_draw_accumulated_instances(&mut render_pass); + current_drawing_context.record_draw_accumulated_instances(render_pass); current_drawing_context.change_mesh( - resource_manager, + world.resources, renderer_mesh_handle, - &mut render_pass, + render_pass, ); } current_drawing_context.accumulate_instance(); if j == batch_size - 1 { - current_drawing_context.record_draw_accumulated_instances(&mut render_pass); + current_drawing_context.record_draw_accumulated_instances(render_pass); } } - - timer.end_context()?; } - drop(render_pass); Ok(()) } } diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_static.rs index c66ae56d..eee83d7a 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_static.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_static.rs @@ -586,11 +586,14 @@ impl Pass for PassPBRStatic { .as_ref() .unwrap(); // Rotation stored in radians (game convention); match old shader's X*Y*Z quaternion order. - let q = Quat::from_rotation_x(transform.rotation.x) + let rotation = Quat::from_rotation_x(transform.rotation.x) * Quat::from_rotation_y(transform.rotation.y) * Quat::from_rotation_z(transform.rotation.z); - let model_mat: Mat4 = - Mat4::from_scale_rotation_translation(transform.scale, q, transform.position); + let model_mat: Mat4 = Mat4::from_scale_rotation_translation( + transform.scale, + rotation, + transform.position, + ); let mvp: [[f32; 4]; 4] = (vp_mat * model_mat).to_cols_array_2d(); let model: [[f32; 4]; 4] = model_mat.to_cols_array_2d(); diff --git a/engine/pill_engine/src/graphics/render_queue.rs b/engine/pill_engine/src/graphics/render_queue.rs index fa7ebac5..11585c4b 100644 --- a/engine/pill_engine/src/graphics/render_queue.rs +++ b/engine/pill_engine/src/graphics/render_queue.rs @@ -92,6 +92,7 @@ where + Add + Not, { + /// Computes mask, shift, and maximum value for a bit field at the given bit range. pub fn new(mask_range: core::ops::Range) -> Self { let one: T = T::from(1_u8); let two: T = T::from(2_u8); @@ -110,7 +111,7 @@ where } } -// Creates pill engine render queue composed from order, material index, material version, mesh index, mesh version +/// Creates pill engine render queue composed from order, material index, material version, mesh index, mesh version pub fn compose_render_queue_key( resource_manager: &ResourceManager, material_handle: &MaterialHandle, @@ -152,7 +153,7 @@ pub fn compose_render_queue_key( } } -// PBR path: PBRMaterial always uses default lit shader +/// PBR path: PBRMaterial always uses default lit shader pub fn compose_pbr_render_queue_key( resource_manager: &ResourceManager, material_handle: PBRMaterialHandle, @@ -201,7 +202,7 @@ pub struct RenderQueueKeyFields { pub mesh_version: u8, } -// Decomposes pill engine render queue key into separate fields +/// Decomposes pill engine render queue key into separate fields pub fn decompose_render_queue_key(render_queue_key: RenderQueueKey) -> RenderQueueKeyFields { let order: u8 = ((render_queue_key & RENDER_QUEUE_KEY_ORDER.mask as RenderQueueKey) >> RENDER_QUEUE_KEY_ORDER.mask_shift as RenderQueueKey) as u8; diff --git a/engine/pill_engine/src/graphics/renderer.rs b/engine/pill_engine/src/graphics/renderer.rs index 1600c700..17cb68f3 100644 --- a/engine/pill_engine/src/graphics/renderer.rs +++ b/engine/pill_engine/src/graphics/renderer.rs @@ -168,6 +168,12 @@ pub trait PillRenderer { /// Returns the surface texture format; used when creating pass render pipelines. fn get_surface_format(&self) -> wgpu::TextureFormat; + /// Returns the shared engine-parameters UBO; required by passes that use legacy RendererShader pipelines. + fn get_engine_parameters(&self) -> &crate::renderer::resources::EngineParameters; + + /// Returns a clone of the camera bind-group layout; required to create a RendererCamera in pass init. + fn get_camera_bind_group_layout(&self) -> wgpu::BindGroupLayout; + /// Allocates a GPU buffer with the given descriptor. fn create_buffer(&mut self, desc: BufferDesc) -> Result; diff --git a/engine/pill_engine/src/renderer/drawers/egui_drawer.rs b/engine/pill_engine/src/renderer/drawers/egui_drawer.rs deleted file mode 100644 index 9ccebacc..00000000 --- a/engine/pill_engine/src/renderer/drawers/egui_drawer.rs +++ /dev/null @@ -1,127 +0,0 @@ -use pill_core::Result; -use pill_core::Timer; -use std::sync::Arc; -use winit::event::WindowEvent; - -const BORDER_RADIUS: f32 = 2.0; - -pub struct EguiDrawer { - pub context: egui::Context, - state: egui_winit::State, - renderer: egui_wgpu::Renderer, - pub window_scale_factor: f32, - pub window: Arc, -} - -impl EguiDrawer { - pub fn new( - device: &wgpu::Device, - output_color_format: wgpu::TextureFormat, - output_depth_format: Option, - msaa_samples: u32, - window: Arc, - ) -> EguiDrawer { - let window_scale_factor = window.scale_factor() as f32; - let context = egui::Context::default(); - let id = context.viewport_id(); - - let visuals = egui::Visuals { - window_corner_radius: egui::CornerRadius::from(BORDER_RADIUS), - window_shadow: egui::Shadow::NONE, - ..Default::default() - }; - context.set_visuals(visuals); - - let state = egui_winit::State::new(context.clone(), id, &window, None, None, None); - - let renderer = egui_wgpu::Renderer::new( - device, - output_color_format, - output_depth_format, - msaa_samples, - false, - ); - - EguiDrawer { - context, - state, - renderer, - window_scale_factor, - window, - } - } - - pub fn handle_input(&mut self, event: &WindowEvent) { - let _ = self.state.on_window_event(&self.window, event); - } - - #[allow(clippy::too_many_arguments)] - pub fn record_draw_commands( - &mut self, - device: &wgpu::Device, - queue: &wgpu::Queue, - encoder: &mut wgpu::CommandEncoder, - window_surface_view: &wgpu::TextureView, - screen_descriptor: egui_wgpu::ScreenDescriptor, - mut run_ui: Box, - timer: &mut Timer, - ) -> Result<()> { - timer.record("Prepare window and input"); - - let window = &self.window; - let raw_input = self.state.take_egui_input(window); - - let full_output = self.context.run(raw_input, |_| { - run_ui(&self.context); - }); - - timer.record("Handle platform output"); - - self.state - .handle_platform_output(window, full_output.platform_output); - - timer.record("Tesselate and update textures"); - - let tris = self - .context - .tessellate(full_output.shapes, full_output.pixels_per_point); - for (id, image_delta) in &full_output.textures_delta.set { - self.renderer - .update_texture(device, queue, *id, image_delta); - } - - timer.record("Update buffers and record render pass"); - - self.renderer - .update_buffers(device, queue, encoder, &tris, &screen_descriptor); - - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: window_surface_view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - label: Some("egui main render pass"), - timestamp_writes: None, - occlusion_query_set: None, - }); - - timer.record("Render"); - - let render_pass: &mut wgpu::RenderPass<'static> = - unsafe { std::mem::transmute(&mut render_pass) }; - - self.renderer - .render(&mut *render_pass, &tris, &screen_descriptor); - - for texture_id in &full_output.textures_delta.free { - self.renderer.free_texture(texture_id) - } - - Ok(()) - } -} diff --git a/engine/pill_engine/src/renderer/drawers/mod.rs b/engine/pill_engine/src/renderer/drawers/mod.rs index 2029e0fb..8b137891 100644 --- a/engine/pill_engine/src/renderer/drawers/mod.rs +++ b/engine/pill_engine/src/renderer/drawers/mod.rs @@ -1,3 +1 @@ -#[cfg(feature = "debug_ui")] -pub mod egui_drawer; -pub mod mesh_drawer; + diff --git a/engine/pill_engine/src/renderer/instance.rs b/engine/pill_engine/src/renderer/instance.rs index 9d68ae15..164a82c4 100644 --- a/engine/pill_engine/src/renderer/instance.rs +++ b/engine/pill_engine/src/renderer/instance.rs @@ -10,6 +10,7 @@ pub struct Instance { } impl Instance { + /// Creates an instance from a transform component's position, rotation, and scale. pub fn new(transform_component: &TransformComponent) -> Instance { Instance { transform: Matrix3f::from_cols( diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs index 538818ec..7190dcaa 100644 --- a/engine/pill_engine/src/renderer/state.rs +++ b/engine/pill_engine/src/renderer/state.rs @@ -218,6 +218,14 @@ impl PillRenderer for Renderer { self.state.color_format } + fn get_engine_parameters(&self) -> &crate::renderer::resources::EngineParameters { + &self.state.engine_parameters + } + + fn get_camera_bind_group_layout(&self) -> wgpu::BindGroupLayout { + self.state.camera_bind_group_layout.clone() + } + fn create_buffer(&mut self, desc: BufferDesc) -> Result { let buffer = self.state.device.create_buffer(&wgpu::BufferDescriptor { label: desc.label, From 95d4adab6142226bbfc3ec5dd56d5ff3357bc033 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Fri, 29 May 2026 00:06:49 +0200 Subject: [PATCH 05/31] review --- engine/pill_core/src/timer.rs | 2 +- .../src/ecs/components/transform_component.rs | 6 ++-- engine/pill_engine/src/graphics/pass_mesh.rs | 32 ++++--------------- .../pill_engine/src/renderer/drawers/mod.rs | 1 - engine/pill_engine/src/renderer/mod.rs | 1 - .../src/renderer/resources/renderer_camera.rs | 3 ++ .../renderer/resources/renderer_material.rs | 3 +- 7 files changed, 16 insertions(+), 32 deletions(-) delete mode 100644 engine/pill_engine/src/renderer/drawers/mod.rs diff --git a/engine/pill_core/src/timer.rs b/engine/pill_core/src/timer.rs index e62383af..fa9dd853 100644 --- a/engine/pill_core/src/timer.rs +++ b/engine/pill_core/src/timer.rs @@ -128,7 +128,7 @@ impl Timer { } /// Returns all counters as a slice of (label, value) pairs. - pub fn counters(&self) -> &Vec<(String, u64)> { + pub fn counters(&self) -> &[(String, u64)] { &self.counters } diff --git a/engine/pill_engine/src/ecs/components/transform_component.rs b/engine/pill_engine/src/ecs/components/transform_component.rs index 1c7bb4b1..20381387 100644 --- a/engine/pill_engine/src/ecs/components/transform_component.rs +++ b/engine/pill_engine/src/ecs/components/transform_component.rs @@ -177,15 +177,15 @@ impl TransformComponent { } pub fn update_transform_matrices(transform_component: &mut TransformComponent) { - let q = Quat::from_rotation_y(transform_component.rotation.y.to_radians()) + let rotation = Quat::from_rotation_y(transform_component.rotation.y.to_radians()) * Quat::from_rotation_x(transform_component.rotation.x.to_radians()) * Quat::from_rotation_z(transform_component.rotation.z.to_radians()); transform_component.model_matrix = Matrix4f::from_scale_rotation_translation( transform_component.scale, - q, + rotation, transform_component.position, ); - transform_component.normal_matrix = Matrix3fA::from_quat(q); + transform_component.normal_matrix = Matrix3fA::from_quat(rotation); } pub fn get_model_matrix(transform_component: &TransformComponent) -> Matrix4f { diff --git a/engine/pill_engine/src/graphics/pass_mesh.rs b/engine/pill_engine/src/graphics/pass_mesh.rs index 12bcf46c..48bb5b25 100644 --- a/engine/pill_engine/src/graphics/pass_mesh.rs +++ b/engine/pill_engine/src/graphics/pass_mesh.rs @@ -5,9 +5,7 @@ use crate::graphics::{ }; use crate::internal::decompose_render_queue_key; use crate::{ - ecs::{CameraComponent, ComponentStorage}, graphics::{Pass, PillRenderer, RendererTextureHandle, WorldQuery}, - internal::TransformComponent, renderer::{ config::{ CAMERA_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, ENGINE_PARAMETERS_BIND_GROUP_LAYOUT_INDEX, @@ -21,7 +19,7 @@ use crate::{ }, resources::ResourceManager, }; -use pill_core::{debug, LogContext, PillSlotMapKey, PillStyle, RendererError, Result, Timer}; +use pill_core::{debug, LogContext, PillSlotMapKey, PillStyle, RendererError, Result}; // --- DrawingContext --- @@ -37,9 +35,6 @@ struct DrawingContext { mesh_index_count: u32, accumulated_instance_range: Range, - accumulated_instance_count: u32, - - rendering_context_change_number: u32, instance_batch_number: u32, instance_batch_size: u32, @@ -50,7 +45,7 @@ impl DrawingContext { debug!( LogContext::Frame => "Draw {} instance(s) {}->{}/{} command recorded [Batch: {}, Rendering order: {}, Shader: {}, Material: {}, Mesh: {}]", - self.accumulated_instance_count, + self.accumulated_instance_range.end - self.accumulated_instance_range.start, self.accumulated_instance_range.start, self.accumulated_instance_range.end - 1, self.instance_batch_size, @@ -63,7 +58,7 @@ impl DrawingContext { } fn record_draw_accumulated_instances(&mut self, render_pass: &mut wgpu::RenderPass) { - if self.accumulated_instance_count > 0 { + if self.accumulated_instance_range.end > self.accumulated_instance_range.start { render_pass.draw_indexed( 0..self.mesh_index_count, 0, @@ -72,20 +67,15 @@ impl DrawingContext { self.log(); self.accumulated_instance_range = self.accumulated_instance_range.end..self.accumulated_instance_range.end; - self.accumulated_instance_count = 0; } } fn accumulate_instance(&mut self) { - self.accumulated_instance_range = - self.accumulated_instance_range.start..self.accumulated_instance_range.end + 1; - self.accumulated_instance_count = - self.accumulated_instance_range.end - self.accumulated_instance_range.start; + self.accumulated_instance_range.end += 1; } fn change_rendering_order(&mut self, new_order: u8) { self.rendering_order = new_order; - self.rendering_context_change_number += 1; debug!(LogContext::Frame => "Rendering order changed to: {}", self.rendering_order); } @@ -122,8 +112,6 @@ impl DrawingContext { &[], ); } - - self.rendering_context_change_number += 1; } fn change_material( @@ -153,8 +141,6 @@ impl DrawingContext { &[], ); } - - self.rendering_context_change_number += 1; } fn change_mesh( @@ -172,8 +158,6 @@ impl DrawingContext { self.mesh_index_count = mesh.index_count; render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); render_pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); - - self.rendering_context_change_number += 1; } } @@ -317,7 +301,6 @@ impl Pass for PassMesh { current_drawing_context.instance_batch_size = batch_size as u32; self.instances.clear(); - self.instances.reserve(instance_batch.len()); for render_queue_item in instance_batch { let transform_slot = world @@ -331,16 +314,15 @@ impl Pass for PassMesh { self.instances.push(Instance::new(transform_component)); } + let instance_buffer = self.instance_buffer.as_ref().unwrap(); renderer.get_queue().write_buffer( - self.instance_buffer.as_ref().unwrap(), + instance_buffer, 0, bytemuck::cast_slice(&self.instances), ); - - render_pass.set_vertex_buffer(1, self.instance_buffer.as_ref().unwrap().slice(..)); + render_pass.set_vertex_buffer(1, instance_buffer.slice(..)); current_drawing_context.accumulated_instance_range = 0..0; - current_drawing_context.accumulated_instance_count = 0; for (j, render_queue_item) in instance_batch.iter().enumerate() { let key_fields = decompose_render_queue_key(render_queue_item.key); diff --git a/engine/pill_engine/src/renderer/drawers/mod.rs b/engine/pill_engine/src/renderer/drawers/mod.rs deleted file mode 100644 index 8b137891..00000000 --- a/engine/pill_engine/src/renderer/drawers/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/engine/pill_engine/src/renderer/mod.rs b/engine/pill_engine/src/renderer/mod.rs index 17f614f3..72fa7b05 100644 --- a/engine/pill_engine/src/renderer/mod.rs +++ b/engine/pill_engine/src/renderer/mod.rs @@ -1,5 +1,4 @@ pub mod config; -pub mod drawers; pub mod instance; pub mod resources; pub mod state; diff --git a/engine/pill_engine/src/renderer/resources/renderer_camera.rs b/engine/pill_engine/src/renderer/resources/renderer_camera.rs index 718c942d..02c8c2a2 100644 --- a/engine/pill_engine/src/renderer/resources/renderer_camera.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_camera.rs @@ -33,6 +33,7 @@ impl CameraParametersData { } } + /// Writes the latest camera position and view-projection matrix from ECS components into the CPU-side struct. pub fn update_data( &mut self, camera_component: &CameraComponent, @@ -80,6 +81,7 @@ pub struct RendererCamera { } impl RendererCamera { + /// Allocates the camera uniform buffer and bind group using the provided layout. pub fn new( device: &wgpu::Device, camera_bind_group_layout: wgpu::BindGroupLayout, @@ -110,6 +112,7 @@ impl RendererCamera { }) } + /// Recomputes view-projection from ECS components and uploads the result to the GPU buffer. pub fn update( &mut self, queue: &wgpu::Queue, diff --git a/engine/pill_engine/src/renderer/resources/renderer_material.rs b/engine/pill_engine/src/renderer/resources/renderer_material.rs index 05c8c60f..c9852fe9 100644 --- a/engine/pill_engine/src/renderer/resources/renderer_material.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_material.rs @@ -101,7 +101,8 @@ impl RendererMaterial { } fn calculate_uniform_size(parameter_slots: &[(String, ShaderParameterSlot)]) -> usize { - parameter_slots.len() * 16 // each slot is padded to 16 bytes per std140 alignment rules + const STD140_SLOT_BYTES: usize = 16; // each slot is padded to 16 bytes per std140 alignment rules + parameter_slots.len() * STD140_SLOT_BYTES } /// Packs parameter values into a std140-aligned byte buffer and uploads it to the GPU. From cdcb249b77de9a1aac530b1f0057a22758e9dcca Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Fri, 29 May 2026 00:44:05 +0200 Subject: [PATCH 06/31] review --- .../src/ecs/components/mesh_component.rs | 83 +++++++++++++++++++ engine/pill_engine/src/ecs/components/mod.rs | 3 +- ...mponent.rs => pbr_renderable_component.rs} | 35 ++++---- engine/pill_engine/src/ecs/mod.rs | 3 +- .../src/ecs/systems/rendering_system.rs | 8 +- engine/pill_engine/src/lib.rs | 12 +-- engine/pill_engine/src/resources/material.rs | 10 +-- engine/pill_engine/src/resources/mesh.rs | 32 ++++--- examples/cube/src/game.rs | 6 +- examples/floating_pills/src/game.rs | 8 +- examples/italian_brainrot/src/game.rs | 6 +- examples/net_minimal/client/src/game.rs | 6 +- examples/pill_tunel/src/game.rs | 6 +- 13 files changed, 153 insertions(+), 65 deletions(-) create mode 100644 engine/pill_engine/src/ecs/components/mesh_component.rs rename engine/pill_engine/src/ecs/components/{mesh_rendering_component.rs => pbr_renderable_component.rs} (90%) diff --git a/engine/pill_engine/src/ecs/components/mesh_component.rs b/engine/pill_engine/src/ecs/components/mesh_component.rs new file mode 100644 index 00000000..23d02660 --- /dev/null +++ b/engine/pill_engine/src/ecs/components/mesh_component.rs @@ -0,0 +1,83 @@ +use crate::{ + ecs::{Component, ComponentStorage}, + engine::Engine, + resources::{Mesh, MeshHandle}, +}; +use pill_core::{get_type_name, PillStyle, PillTypeMapKey}; +use pill_core::{ErrorContext, Result}; + +// --- Builder --- + +pub struct MeshComponentBuilder { + component: MeshComponent, +} + +impl MeshComponentBuilder { + pub fn default() -> Self { + Self { + component: MeshComponent::new(), + } + } + + pub fn mesh(mut self, mesh_handle: &MeshHandle) -> Self { + self.component.mesh_handle = Some(*mesh_handle); + self + } + + pub fn build(self) -> MeshComponent { + self.component + } +} + +// --- Mesh Component --- + +#[readonly::make] +pub struct MeshComponent { + #[readonly] + pub mesh_handle: Option, +} + +impl Default for MeshComponent { + fn default() -> Self { + Self::new() + } +} + +impl MeshComponent { + pub fn builder() -> MeshComponentBuilder { + MeshComponentBuilder::default() + } + + pub fn new() -> Self { + Self { mesh_handle: None } + } + + pub fn set_mesh(&mut self, mesh_handle: &MeshHandle) { + self.mesh_handle = Some(*mesh_handle); + } + + pub fn remove_mesh(&mut self) { + self.mesh_handle = None; + } + + pub(crate) fn set_mesh_handle(&mut self, mesh_handle: Option) { + self.mesh_handle = mesh_handle; + } +} + +impl PillTypeMapKey for MeshComponent { + type Storage = ComponentStorage; +} + +impl Component for MeshComponent { + fn initialize(&mut self, engine: &mut Engine) -> Result<()> { + if let Some(handle) = &self.mesh_handle { + engine.get_resource::(handle).context(format!( + "Creating {} {} failed", + "Component".general_object_style(), + get_type_name::().specific_object_style() + ))?; + } + Ok(()) + } +} diff --git a/engine/pill_engine/src/ecs/components/mod.rs b/engine/pill_engine/src/ecs/components/mod.rs index d0a3e95b..1baa1dab 100644 --- a/engine/pill_engine/src/ecs/components/mod.rs +++ b/engine/pill_engine/src/ecs/components/mod.rs @@ -13,11 +13,12 @@ pub(crate) mod deferred_update_component; #[cfg(feature = "debug_ui")] pub(crate) mod egui_manager_component; pub(crate) mod input_component; -pub(crate) mod mesh_rendering_component; +pub(crate) mod mesh_component; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod network_manager_component; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod network_state_component; +pub(crate) mod pbr_renderable_component; pub(crate) mod render_state_component; pub(crate) mod time_component; pub(crate) mod transform_component; diff --git a/engine/pill_engine/src/ecs/components/mesh_rendering_component.rs b/engine/pill_engine/src/ecs/components/pbr_renderable_component.rs similarity index 90% rename from engine/pill_engine/src/ecs/components/mesh_rendering_component.rs rename to engine/pill_engine/src/ecs/components/pbr_renderable_component.rs index d4f58e1f..b45f7b15 100644 --- a/engine/pill_engine/src/ecs/components/mesh_rendering_component.rs +++ b/engine/pill_engine/src/ecs/components/pbr_renderable_component.rs @@ -22,14 +22,14 @@ const DEFERRED_REQUEST_VARIANT_SET_PBR_MATERIAL: usize = 3; // --- Builder --- -pub struct MeshRenderingComponentBuilder { - component: MeshRenderingComponent, +pub struct PbrRenderableComponentBuilder { + component: PbrRenderableComponent, } -impl MeshRenderingComponentBuilder { +impl PbrRenderableComponentBuilder { pub fn default() -> Self { Self { - component: MeshRenderingComponent::new(), + component: PbrRenderableComponent::new(), } } @@ -48,15 +48,15 @@ impl MeshRenderingComponentBuilder { self } - pub fn build(self) -> MeshRenderingComponent { + pub fn build(self) -> PbrRenderableComponent { self.component } } -// --- Mesh Rendering Component --- +// --- PBR Renderable Component --- #[readonly::make] -pub struct MeshRenderingComponent { +pub struct PbrRenderableComponent { #[readonly] pub mesh_handle: Option, #[readonly] @@ -70,15 +70,15 @@ pub struct MeshRenderingComponent { deferred_update_manager: Option, } -impl Default for MeshRenderingComponent { +impl Default for PbrRenderableComponent { fn default() -> Self { Self::new() } } -impl MeshRenderingComponent { - pub fn builder() -> MeshRenderingComponentBuilder { - MeshRenderingComponentBuilder::default() +impl PbrRenderableComponent { + pub fn builder() -> PbrRenderableComponentBuilder { + PbrRenderableComponentBuilder::default() } pub fn new() -> Self { @@ -141,7 +141,6 @@ impl MeshRenderingComponent { compose_render_queue_key(resource_manager, &material_handle, mesh_handle) }; - // Compose render queue key and set it if let Ok(render_queue_key) = result { self.render_queue_key = Some(render_queue_key); } else { @@ -162,7 +161,7 @@ impl MeshRenderingComponent { let scene_handle = self.scene_handle.expect( "Critical: Cannot post deferred update request. No SceneHandle set in Component", ); - let request = DeferredUpdateComponentRequest::::new( + let request = DeferredUpdateComponentRequest::::new( entity_handle, scene_handle, request_variant, @@ -172,11 +171,11 @@ impl MeshRenderingComponent { } } -impl PillTypeMapKey for MeshRenderingComponent { - type Storage = ComponentStorage; +impl PillTypeMapKey for PbrRenderableComponent { + type Storage = ComponentStorage; } -impl Component for MeshRenderingComponent { +impl Component for PbrRenderableComponent { fn initialize(&mut self, engine: &mut Engine) -> Result<()> { // This component is using DeferredUpdateSystem so keep DeferredUpdateManager let deferred_update_component = engine @@ -226,7 +225,6 @@ impl Component for MeshRenderingComponent { fn deferred_update(&mut self, engine: &mut Engine, request: usize) -> Result<()> { match request { DEFERRED_REQUEST_VARIANT_SET_MATERIAL => { - // Check if material handle is valid engine .get_resource::(&self.material_handle.unwrap()) .context(format!( @@ -235,7 +233,6 @@ impl Component for MeshRenderingComponent { "Material".specific_object_style() ))?; - // Update mesh rendering queue self.update_render_queue_key(&engine.resource_manager)?; } DEFERRED_REQUEST_VARIANT_SET_PBR_MATERIAL => { @@ -250,7 +247,6 @@ impl Component for MeshRenderingComponent { self.update_render_queue_key(&engine.resource_manager)?; } DEFERRED_REQUEST_VARIANT_SET_MESH => { - // Check if mesh handle is valid engine .get_resource::(&self.mesh_handle.unwrap()) .context(format!( @@ -259,7 +255,6 @@ impl Component for MeshRenderingComponent { "Mesh".specific_object_style() ))?; - // Update mesh rendering queue self.update_render_queue_key(&engine.resource_manager)?; } DEFERRED_REQUEST_VARIANT_UPDATE_RENDER_QUEUE => { diff --git a/engine/pill_engine/src/ecs/mod.rs b/engine/pill_engine/src/ecs/mod.rs index dacbef0c..8e987d89 100644 --- a/engine/pill_engine/src/ecs/mod.rs +++ b/engine/pill_engine/src/ecs/mod.rs @@ -50,7 +50,8 @@ pub use components::transform_component::{ get_model_matrix, get_normal_matrix, update_transform_matrices, TransformComponent, }; -pub use components::mesh_rendering_component::MeshRenderingComponent; +pub use components::mesh_component::MeshComponent; +pub use components::pbr_renderable_component::PbrRenderableComponent; pub use components::time_component::TimeComponent; diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index 728267c4..746a5679 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -1,7 +1,7 @@ use crate::{ config::RENDERING_SYSTEM, ecs::{ - CameraAspectRatio, CameraComponent, EntityHandle, MeshRenderingComponent, + CameraAspectRatio, CameraComponent, EntityHandle, PbrRenderableComponent, RenderStateComponent, TransformComponent, }, engine::Engine, @@ -72,13 +72,13 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { let mut _matrix_calculation_duration: f32 = 0.0; let mut add_to_render_queue_duration: f32 = 0.0; - for (entity_handle, _transform_component, mesh_rendering_component) in engine + for (entity_handle, _transform_component, pbr_renderable_component) in engine .scene_manager - .get_two_component_iterator_mut::( + .get_two_component_iterator_mut::( active_scene_handle, )? { let add_to_render_queue_start_time = Instant::now(); - if let Some(render_queue_key) = mesh_rendering_component.render_queue_key { + if let Some(render_queue_key) = pbr_renderable_component.render_queue_key { let render_queue_item = RenderQueueItem { key: render_queue_key, entity_index: entity_handle.data().index, diff --git a/engine/pill_engine/src/lib.rs b/engine/pill_engine/src/lib.rs index a5429721..402b76e0 100644 --- a/engine/pill_engine/src/lib.rs +++ b/engine/pill_engine/src/lib.rs @@ -64,8 +64,8 @@ pub mod game { ecs::{ CameraAspectRatio, CameraComponent, Component, ComponentStorage, EntityHandle, GamepadAxis, GamepadButton, GlobalComponent, GlobalComponentStorage, InputComponent, - MeshRenderingComponent, PlayerId, SceneHandle, TimeComponent, TransformComponent, - UpdatePhase, + MeshComponent, PbrRenderableComponent, PlayerId, SceneHandle, TimeComponent, + TransformComponent, UpdatePhase, }, engine::{Engine, KeyboardKey, MouseButton, PillGame}, resources::{ @@ -103,8 +103,8 @@ mod internal_mod { networking_system_server, update_transform_matrices, AudioListenerComponent, AudioManagerComponent, AudioSourceComponent, CameraAspectRatio, CameraComponent, ComponentStorage, EguiClient, EntityHandle, EntityUpdate, InputComponent, - MeshRenderingComponent, NetworkEntityAction, NetworkEntityState, - NetworkManagerComponent, NetworkSide, NetworkStateComponent, NetworkUpdatePayload, + MeshComponent, NetworkEntityAction, NetworkEntityState, NetworkManagerComponent, + NetworkSide, NetworkStateComponent, NetworkUpdatePayload, PbrRenderableComponent, RenderStateComponent, Scene, TimeComponent, TransformComponent, }, engine::{Engine, PillGame}, @@ -134,8 +134,8 @@ mod internal_mod { get_model_matrix, get_normal_matrix, get_renderer_resource_handle_from_camera_component, update_transform_matrices, CameraAspectRatio, CameraComponent, ComponentStorage, EguiClient, EntityHandle, - InputComponent, MeshRenderingComponent, RenderStateComponent, Scene, TimeComponent, - TransformComponent, + InputComponent, MeshComponent, PbrRenderableComponent, RenderStateComponent, Scene, + TimeComponent, TransformComponent, }, engine::{Engine, PillGame}, graphics::{ diff --git a/engine/pill_engine/src/resources/material.rs b/engine/pill_engine/src/resources/material.rs index 2ab8d17c..7e59dbe3 100644 --- a/engine/pill_engine/src/resources/material.rs +++ b/engine/pill_engine/src/resources/material.rs @@ -2,7 +2,7 @@ use crate::{ config::*, ecs::{ DeferredUpdateComponent, DeferredUpdateManagerPointer, DeferredUpdateResourceRequest, - MeshRenderingComponent, + PbrRenderableComponent, }, engine::Engine, graphics::RENDER_QUEUE_KEY_ORDER, @@ -559,12 +559,12 @@ impl Resource for Material { match request { DEFERRED_REQUEST_VARIANT_RENDERING_ORDER => { for (_scene_handle, scene) in engine.scene_manager.scenes.iter_mut() { - for (_entity_handle, mesh_rendering_component) in - scene.get_one_component_iterator_mut::()? + for (_entity_handle, pbr_renderable_component) in + scene.get_one_component_iterator_mut::()? { - if let Some(material_handle) = mesh_rendering_component.material_handle { + if let Some(material_handle) = pbr_renderable_component.material_handle { if material_handle.data() == self.handle.unwrap().data() { - mesh_rendering_component + pbr_renderable_component .update_render_queue_key(&engine.resource_manager) .unwrap(); } diff --git a/engine/pill_engine/src/resources/mesh.rs b/engine/pill_engine/src/resources/mesh.rs index 21a844ac..c2fed192 100644 --- a/engine/pill_engine/src/resources/mesh.rs +++ b/engine/pill_engine/src/resources/mesh.rs @@ -1,5 +1,5 @@ use crate::{ - ecs::MeshRenderingComponent, + ecs::{MeshComponent, PbrRenderableComponent}, engine::Engine, renderer::resources::RendererMesh, resources::{Resource, ResourceStorage}, @@ -188,18 +188,26 @@ impl Resource for Mesh { .resource_manager .remove_resource_by_name::(&self.name)?; - // Find mesh rendering components that use this mesh and update them + // Null out any MeshComponent or PbrRenderableComponent that references this mesh for (_scene_handle, scene) in engine.scene_manager.scenes.iter_mut() { - for (_entity_handle, mesh_rendering_component) in - scene.get_one_component_iterator_mut::()? - { - if let Some(mesh_handle) = mesh_rendering_component.mesh_handle { - // If mesh rendering component has handle to this mesh - if mesh_handle.data() == self_handle.data() { - mesh_rendering_component.set_mesh_handle(Option::::None); - mesh_rendering_component - .update_render_queue_key(&engine.resource_manager) - .unwrap(); + if let Ok(iter) = scene.get_one_component_iterator_mut::() { + for (_entity_handle, mesh_component) in iter { + if let Some(mesh_handle) = mesh_component.mesh_handle { + if mesh_handle.data() == self_handle.data() { + mesh_component.set_mesh_handle(Option::::None); + } + } + } + } + if let Ok(iter) = scene.get_one_component_iterator_mut::() { + for (_entity_handle, pbr_renderable_component) in iter { + if let Some(mesh_handle) = pbr_renderable_component.mesh_handle { + if mesh_handle.data() == self_handle.data() { + pbr_renderable_component.set_mesh_handle(Option::::None); + pbr_renderable_component + .update_render_queue_key(&engine.resource_manager) + .unwrap(); + } } } } diff --git a/examples/cube/src/game.rs b/examples/cube/src/game.rs index 773196b4..c321e28c 100644 --- a/examples/cube/src/game.rs +++ b/examples/cube/src/game.rs @@ -6,7 +6,7 @@ fn rotate_cubes_system(engine: &mut Engine) -> Result<()> { let dt = engine.get_global_component::()?.delta_time; for (_entity, transform, _mesh) in - engine.iterate_two_components_mut::()? + engine.iterate_two_components_mut::()? { let rot = transform.rotation; transform.set_rotation(Vector3f::new( @@ -26,7 +26,7 @@ impl PillGame for WebGame { engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; let cube_mesh_handle = engine.add_resource(Mesh::cube("cube", 2.0))?; let material_handle = engine.add_resource( @@ -54,7 +54,7 @@ impl PillGame for WebGame { .build_entity(active_scene) .with_component(TransformComponent::new()) .with_component( - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .mesh(&cube_mesh_handle) .pbr_material(&material_handle) .build(), diff --git a/examples/floating_pills/src/game.rs b/examples/floating_pills/src/game.rs index f45d9231..779d97d4 100644 --- a/examples/floating_pills/src/game.rs +++ b/examples/floating_pills/src/game.rs @@ -72,7 +72,7 @@ impl PillGame for Game { // Register components engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; @@ -390,7 +390,7 @@ fn object_appearance_changing_system(engine: &mut Engine) -> Result<()> { .get(demo_state.current_mesh) .unwrap(); for (_, mesh_rendering_component) in - engine.iterate_one_component_mut::()? + engine.iterate_one_component_mut::()? { mesh_rendering_component.set_mesh(&mesh_handle); } @@ -407,7 +407,7 @@ fn object_appearance_changing_system(engine: &mut Engine) -> Result<()> { }; for (_, mesh_rendering_component) in - engine.iterate_one_component_mut::()? + engine.iterate_one_component_mut::()? { let material_handle = current_material_set[rng.gen_range(0..=2)]; mesh_rendering_component.set_material(&material_handle); @@ -618,7 +618,7 @@ fn spawn_floating_objects(engine: &mut Engine, object_count: usize) -> Result<() }) .with_component(TransformComponent::new()) .with_component( - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .material(&material_handle) .mesh(&mesh_handle) .build(), diff --git a/examples/italian_brainrot/src/game.rs b/examples/italian_brainrot/src/game.rs index 5a632aba..b1ba8957 100644 --- a/examples/italian_brainrot/src/game.rs +++ b/examples/italian_brainrot/src/game.rs @@ -13,7 +13,7 @@ impl PillGame for Game { // Register components engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; @@ -140,7 +140,7 @@ impl PillGame for Game { .build(), ) .with_component( - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .material(&chimpanzini_bananini_material_handle_lit) .mesh(&chimpanzini_bananini_mesh_handle) .build(), @@ -157,7 +157,7 @@ impl PillGame for Game { .build(), ) .with_component( - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .material(&chimpanzini_bananini_material_handle_unlit) .mesh(&chimpanzini_bananini_mesh_handle) .build(), diff --git a/examples/net_minimal/client/src/game.rs b/examples/net_minimal/client/src/game.rs index fe34d76f..4faaeb83 100644 --- a/examples/net_minimal/client/src/game.rs +++ b/examples/net_minimal/client/src/game.rs @@ -48,7 +48,7 @@ impl PillGame for Game { // Register components engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; @@ -108,7 +108,7 @@ impl PillGame for Game { .rotation(Vector3f::new(-210.0, 0.0, 0.0)) .build(); engine.add_component_to_entity(active_scene, pill, transform_component.clone())?; - let mesh_rendering_component = MeshRenderingComponent::builder() + let mesh_rendering_component = PbrRenderableComponent::builder() .mesh(&pill_mesh_handle) .material(&pill_material_handle) .build(); @@ -310,7 +310,7 @@ fn spawn_player( engine.add_component_to_entity( scene, ent, - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .mesh(&mesh) .material(&mat) .build(), diff --git a/examples/pill_tunel/src/game.rs b/examples/pill_tunel/src/game.rs index accdc097..32836ca2 100644 --- a/examples/pill_tunel/src/game.rs +++ b/examples/pill_tunel/src/game.rs @@ -192,7 +192,7 @@ impl PillGame for WebGame { engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; - engine.register_component::(active_scene)?; + engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; @@ -258,7 +258,7 @@ impl PillGame for WebGame { .build(), ) .with_component( - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .mesh(&pill_mesh) .pbr_material(&hero_material) .build(), @@ -296,7 +296,7 @@ impl PillGame for WebGame { .build(), ) .with_component( - MeshRenderingComponent::builder() + PbrRenderableComponent::builder() .mesh(&pill_mesh) .pbr_material(&material) .build(), From 285f712898834ba766ef2ec857dbaa3dd6dc2129 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Fri, 29 May 2026 00:51:31 +0200 Subject: [PATCH 07/31] review --- engine/pill_engine/src/graphics/egui.rs | 20 -------------------- engine/pill_engine/src/graphics/mod.rs | 4 ---- 2 files changed, 24 deletions(-) delete mode 100644 engine/pill_engine/src/graphics/egui.rs diff --git a/engine/pill_engine/src/graphics/egui.rs b/engine/pill_engine/src/graphics/egui.rs deleted file mode 100644 index 47bf9ec6..00000000 --- a/engine/pill_engine/src/graphics/egui.rs +++ /dev/null @@ -1,20 +0,0 @@ -#![cfg(feature = "debug_ui")] - -pub struct EguiUI<'a> { - frame_delta_time: &'a f32, // Use a reference to frame_delta_time -} - -impl<'a> EguiUI<'a> { - fn render(&self, ui: &egui::Context) { - egui::Window::new("PillEngine") - .default_open(true) - .resizable(true) - .anchor(egui::Align2::LEFT_TOP, [0.0, 0.0]) - .show(ui, |ui| { - if ui.add(egui::Button::new("Click me")).clicked() { - println!("PRESSED"); - println!("{}", self.frame_delta_time); - } - }); - } -} diff --git a/engine/pill_engine/src/graphics/mod.rs b/engine/pill_engine/src/graphics/mod.rs index 6c9320e2..6617844c 100644 --- a/engine/pill_engine/src/graphics/mod.rs +++ b/engine/pill_engine/src/graphics/mod.rs @@ -3,8 +3,6 @@ #[cfg(feature = "headless")] mod dummy_renderer; #[cfg(feature = "debug_ui")] -mod egui; -#[cfg(feature = "debug_ui")] mod pass_egui; mod pass_mesh; mod pass_pbr_static; @@ -22,8 +20,6 @@ pub use renderer::{ #[cfg(feature = "headless")] pub use self::dummy_renderer::DummyRenderer; #[cfg(feature = "debug_ui")] -pub use egui::EguiUI; -#[cfg(feature = "debug_ui")] pub use pass_egui::PassEgui; pub use pass_mesh::PassMesh; pub use pass_pbr_static::PassPBRStatic; From 6e64c8307dd1aae8562a958e11d4662b277e2e00 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Fri, 29 May 2026 01:20:52 +0200 Subject: [PATCH 08/31] review --- engine/pill_engine/src/config.rs | 32 +-- .../pill_engine/src/graphics/render_queue.rs | 228 ++++++------------ 2 files changed, 74 insertions(+), 186 deletions(-) diff --git a/engine/pill_engine/src/config.rs b/engine/pill_engine/src/config.rs index b40602cc..a21db6f7 100644 --- a/engine/pill_engine/src/config.rs +++ b/engine/pill_engine/src/config.rs @@ -114,32 +114,12 @@ pub const DEFAULT_UNLIT_SHADER_TINT_PARAMETER_SLOT_NAME: &str = "tint"; pub const DEFAULT_UNLIT_MATERIAL_NAME: &str = "pill_engine_default_unlit_material"; // Render queue key -pub type RenderQueueKeyType = u64; // Defines size of renderer queue key (Should be u8, u16, u32, or u64) - -pub const RENDER_QUEUE_KEY_ITEMS_LENGTH: [RenderQueueKeyType; 7] = [5, 8, 8, 8, 8, 8, 8]; // Defines size of next render queue key parts (bits from left to right) - -// 64-bit render sort key layout (u64) -// +---------+--------------+---------------------------------------------------------+ -// | Bits | Field | Description | -// +---------+--------------+---------------------------------------------------------+ -// | 0..4 | Order | 5 bits: render order (max 32) | -// | 5..12 | Shader Idx | 8 bits: shader index (max 256) | -// | 13..20 | Shader Ver | 8 bits: shader version (max 256) | -// | 21..28 | Material Idx | 8 bits: material index (max 256) | -// | 29..36 | Material Ver | 8 bits: material version (max 256) | -// | 37..44 | Mesh Idx | 8 bits: mesh index (max 256) | -// | 45..52 | Mesh Ver | 8 bits: mesh version (max 256) | -// | 53..63 | Free | 11 bits: free for future use | -// +---------+--------------+---------------------------------------------------------+ - -// Indices of render queue key parts (maps RENDER_QUEUE_KEY_ITEMS_LENGTH) -pub const RENDER_QUEUE_KEY_RENDERING_ORDER_IDX: u8 = 0; -pub const RENDER_QUEUE_KEY_SHADER_INDEX_IDX: u8 = 1; -pub const RENDER_QUEUE_KEY_SHADER_VERSION_IDX: u8 = 2; -pub const RENDER_QUEUE_KEY_MATERIAL_INDEX_IDX: u8 = 3; -pub const RENDER_QUEUE_KEY_MATERIAL_VERSION_IDX: u8 = 4; -pub const RENDER_QUEUE_KEY_MESH_INDEX_IDX: u8 = 5; -pub const RENDER_QUEUE_KEY_MESH_VERSION_IDX: u8 = 6; +pub type RenderQueueKeyType = u64; + +// 64-bit render sort key layout (MSB → LSB): +// bit: 63 59 58 51 50 43 42 35 34 27 26 19 18 11 10 0 +// [ order ] [shader_idx] [shader_ver] [ mat_idx ] [ mat_ver ] [mesh_idx ] [mesh_ver ] [ unused ] +// [ 5 bits ] [ 8 bits ] [ 8 bits ] [ 8 bits ] [ 8 bits ] [ 8 bits ] [ 8 bits ] [ 11 bits ] // Default resource handle - Color texture pub const DEFAULT_COLOR_TEXTURE_HANDLE: TextureHandle = TextureHandle(PillSlotMapKeyData { diff --git a/engine/pill_engine/src/graphics/render_queue.rs b/engine/pill_engine/src/graphics/render_queue.rs index 11585c4b..649a0f8c 100644 --- a/engine/pill_engine/src/graphics/render_queue.rs +++ b/engine/pill_engine/src/graphics/render_queue.rs @@ -9,24 +9,12 @@ use crate::{ use pill_core::PillSlotMapKey; -use core::fmt::{self, Debug}; -use lazy_static::lazy_static; +use core::fmt; use pill_core::Result; -use std::{ - cmp::Ordering, - convert::TryInto, - fmt::{Binary, Display}, - ops::{Add, Not, Range, Shl, Sub}, -}; - -// --- Render queue +use std::cmp::Ordering; -pub struct RenderQueue { - pub items: Vec, - pub context_change_indices: Vec, -} +// --- Render queue item --- -// --- Render queue item pub struct RenderQueueItem { pub key: RenderQueueKey, pub entity_index: u32, @@ -52,66 +40,53 @@ impl PartialEq for RenderQueueItem { impl Eq for RenderQueueItem {} -impl Display for RenderQueueItem { +impl fmt::Display for RenderQueueItem { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "({} {})", self.key, self.entity_index) } } -impl Debug for RenderQueueItem { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { +impl fmt::Debug for RenderQueueItem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "({} {})", self.key, self.entity_index) } } -// --- Render queue field --- +// --- Render queue key type --- -pub struct RenderQueueField { - pub mask_range: core::ops::Range, - pub mask_shift: T, - pub mask: T, - pub max: T, -} +pub type RenderQueueKey = crate::config::RenderQueueKeyType; -pub trait Pow { - fn pow(self, exp: Self) -> Self; -} +// --- Bit-field descriptors --- -impl RenderQueueField -where - T: Copy - + Default - + Pow - + Binary - + Debug - + From - + From - + Ord - + Shl - + Sub - + Add - + Not, -{ - /// Computes mask, shift, and maximum value for a bit field at the given bit range. - pub fn new(mask_range: core::ops::Range) -> Self { - let one: T = T::from(1_u8); - let two: T = T::from(2_u8); - let mask_range_length = mask_range.end - mask_range.start + one; - let mask_size: T = T::from(std::mem::size_of::() as u8 * 8); - let mask_shift: T = mask_size - mask_range.end - one; - let mask: T = pill_core::create_bitmask_from_range::(&mask_range); - let max: T = two.pow(mask_range_length) - one; +pub struct RenderQueueFieldDesc { + pub mask: RenderQueueKey, + pub mask_shift: u32, + pub max: RenderQueueKey, +} - RenderQueueField { - mask_range, - mask_shift, - mask, - max, - } +const fn field(mask: u64, shift: u32, max: u64) -> RenderQueueFieldDesc { + RenderQueueFieldDesc { + mask, + mask_shift: shift, + max, } } -/// Creates pill engine render queue composed from order, material index, material version, mesh index, mesh version +pub const RENDER_QUEUE_KEY_ORDER: RenderQueueFieldDesc = field(0xF800_0000_0000_0000, 59, 31); +pub const RENDER_QUEUE_KEY_SHADER_INDEX: RenderQueueFieldDesc = + field(0x07F8_0000_0000_0000, 51, 255); +pub const RENDER_QUEUE_KEY_SHADER_VERSION: RenderQueueFieldDesc = + field(0x0007_F800_0000_0000, 43, 255); +pub const RENDER_QUEUE_KEY_MATERIAL_INDEX: RenderQueueFieldDesc = + field(0x0000_07F8_0000_0000, 35, 255); +pub const RENDER_QUEUE_KEY_MATERIAL_VERSION: RenderQueueFieldDesc = + field(0x0000_0007_F800_0000, 27, 255); +pub const RENDER_QUEUE_KEY_MESH_INDEX: RenderQueueFieldDesc = field(0x0000_0000_07F8_0000, 19, 255); +pub const RENDER_QUEUE_KEY_MESH_VERSION: RenderQueueFieldDesc = + field(0x0000_0000_0007_F800, 11, 255); + +// --- Compose --- + pub fn compose_render_queue_key( resource_manager: &ResourceManager, material_handle: &MaterialHandle, @@ -133,23 +108,22 @@ pub fn compose_render_queue_key( let renderer_mesh_handle = resource_manager.get_resource_handle::(&mesh.name)?; - let render_queue_key: RenderQueueKey = ((RENDER_QUEUE_KEY_ORDER.max - - material.rendering_order as RenderQueueKey) - << RENDER_QUEUE_KEY_ORDER.mask_shift) - | ((renderer_shader_handle.data().index as RenderQueueKey) - << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) - | ((renderer_shader_handle.data().version.get() as RenderQueueKey) - << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) - | ((renderer_material_handle.data().index as RenderQueueKey) - << RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) - | ((renderer_material_handle.data().version.get() as RenderQueueKey) - << RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) - | ((renderer_mesh_handle.data().index as RenderQueueKey) - << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) - | ((renderer_mesh_handle.data().version.get() as RenderQueueKey) - << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift); - - Ok(render_queue_key) + Ok( + ((RENDER_QUEUE_KEY_ORDER.max - material.rendering_order as RenderQueueKey) + << RENDER_QUEUE_KEY_ORDER.mask_shift) + | ((renderer_shader_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) + | ((renderer_shader_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) + | ((renderer_material_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) + | ((renderer_material_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) + | ((renderer_mesh_handle.data().index as RenderQueueKey) + << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) + | ((renderer_mesh_handle.data().version.get() as RenderQueueKey) + << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift), + ) } } @@ -174,8 +148,7 @@ pub fn compose_pbr_render_queue_key( let renderer_mesh_handle = resource_manager.get_resource_handle::(&mesh.name)?; - let render_queue_key: RenderQueueKey = ((renderer_shader_handle.data().index - as RenderQueueKey) + Ok(((renderer_shader_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) | ((renderer_shader_handle.data().version.get() as RenderQueueKey) << RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) @@ -186,12 +159,12 @@ pub fn compose_pbr_render_queue_key( | ((renderer_mesh_handle.data().index as RenderQueueKey) << RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) | ((renderer_mesh_handle.data().version.get() as RenderQueueKey) - << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift); - - Ok(render_queue_key) + << RENDER_QUEUE_KEY_MESH_VERSION.mask_shift)) } } +// --- Decompose --- + pub struct RenderQueueKeyFields { pub order: u8, pub shader_index: u8, @@ -202,85 +175,20 @@ pub struct RenderQueueKeyFields { pub mesh_version: u8, } -/// Decomposes pill engine render queue key into separate fields -pub fn decompose_render_queue_key(render_queue_key: RenderQueueKey) -> RenderQueueKeyFields { - let order: u8 = ((render_queue_key & RENDER_QUEUE_KEY_ORDER.mask as RenderQueueKey) - >> RENDER_QUEUE_KEY_ORDER.mask_shift as RenderQueueKey) as u8; - let shader_index: u8 = ((render_queue_key & RENDER_QUEUE_KEY_SHADER_INDEX.mask) - >> RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) as u8; - let shader_version: u8 = ((render_queue_key & RENDER_QUEUE_KEY_SHADER_VERSION.mask) - >> RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) as u8; - let material_index: u8 = ((render_queue_key & RENDER_QUEUE_KEY_MATERIAL_INDEX.mask) - >> RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) as u8; - let material_version: u8 = ((render_queue_key & RENDER_QUEUE_KEY_MATERIAL_VERSION.mask) - >> RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) as u8; - let mesh_index: u8 = ((render_queue_key & RENDER_QUEUE_KEY_MESH_INDEX.mask) - >> RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) as u8; - let mesh_version: u8 = ((render_queue_key & RENDER_QUEUE_KEY_MESH_VERSION.mask) - >> RENDER_QUEUE_KEY_MESH_VERSION.mask_shift) as u8; - +pub fn decompose_render_queue_key(key: RenderQueueKey) -> RenderQueueKeyFields { RenderQueueKeyFields { - order, - shader_index, - shader_version, - material_index, - material_version, - mesh_index, - mesh_version, + order: ((key & RENDER_QUEUE_KEY_ORDER.mask) >> RENDER_QUEUE_KEY_ORDER.mask_shift) as u8, + shader_index: ((key & RENDER_QUEUE_KEY_SHADER_INDEX.mask) + >> RENDER_QUEUE_KEY_SHADER_INDEX.mask_shift) as u8, + shader_version: ((key & RENDER_QUEUE_KEY_SHADER_VERSION.mask) + >> RENDER_QUEUE_KEY_SHADER_VERSION.mask_shift) as u8, + material_index: ((key & RENDER_QUEUE_KEY_MATERIAL_INDEX.mask) + >> RENDER_QUEUE_KEY_MATERIAL_INDEX.mask_shift) as u8, + material_version: ((key & RENDER_QUEUE_KEY_MATERIAL_VERSION.mask) + >> RENDER_QUEUE_KEY_MATERIAL_VERSION.mask_shift) as u8, + mesh_index: ((key & RENDER_QUEUE_KEY_MESH_INDEX.mask) + >> RENDER_QUEUE_KEY_MESH_INDEX.mask_shift) as u8, + mesh_version: ((key & RENDER_QUEUE_KEY_MESH_VERSION.mask) + >> RENDER_QUEUE_KEY_MESH_VERSION.mask_shift) as u8, } } - -// --- Render queue fields config --- - -pub type RenderQueueKey = crate::config::RenderQueueKeyType; - -impl Pow for RenderQueueKey { - fn pow(self, exp: Self) -> Self { - RenderQueueKey::pow(self, exp.try_into().unwrap()) - } -} - -fn get_render_queue_key_item_range(render_queue_item_index: u8) -> Range { - let mut start: RenderQueueKey = 0; - let mut end: RenderQueueKey = 0; - for i in 0..render_queue_item_index + 1 { - start += if i.ne(&0) { - RENDER_QUEUE_KEY_ITEMS_LENGTH[i as usize - 1] - } else { - 0 - }; - end += RENDER_QUEUE_KEY_ITEMS_LENGTH[i as usize]; - } - start..(end - 1) -} - -lazy_static! { - pub static ref RENDER_QUEUE_KEY_ORDER: RenderQueueField = - RenderQueueField::::new(get_render_queue_key_item_range( - RENDER_QUEUE_KEY_RENDERING_ORDER_IDX - )); - pub static ref RENDER_QUEUE_KEY_SHADER_INDEX: RenderQueueField = - RenderQueueField::::new(get_render_queue_key_item_range( - RENDER_QUEUE_KEY_SHADER_INDEX_IDX - )); - pub static ref RENDER_QUEUE_KEY_SHADER_VERSION: RenderQueueField = - RenderQueueField::::new(get_render_queue_key_item_range( - RENDER_QUEUE_KEY_SHADER_VERSION_IDX - )); - pub static ref RENDER_QUEUE_KEY_MATERIAL_INDEX: RenderQueueField = - RenderQueueField::::new(get_render_queue_key_item_range( - RENDER_QUEUE_KEY_MATERIAL_INDEX_IDX - )); - pub static ref RENDER_QUEUE_KEY_MATERIAL_VERSION: RenderQueueField = - RenderQueueField::::new(get_render_queue_key_item_range( - RENDER_QUEUE_KEY_MATERIAL_VERSION_IDX - )); - pub static ref RENDER_QUEUE_KEY_MESH_INDEX: RenderQueueField = - RenderQueueField::::new(get_render_queue_key_item_range( - RENDER_QUEUE_KEY_MESH_INDEX_IDX - )); - pub static ref RENDER_QUEUE_KEY_MESH_VERSION: RenderQueueField = - RenderQueueField::::new(get_render_queue_key_item_range( - RENDER_QUEUE_KEY_MESH_VERSION_IDX - )); -} From 040bc05fc2ce12db478d62225992a432d019c74e Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Fri, 29 May 2026 09:36:29 +0200 Subject: [PATCH 09/31] example pbr helmet --- .gitignore | 4 + engine/Cargo.toml | 2 +- engine/pill_assets/Cargo.toml | 1 + engine/pill_assets/src/lib.rs | 2 +- .../src/rules/glb_to_cooked_mesh.rs | 232 ++++++++++++++++++ engine/pill_assets/src/rules/mod.rs | 3 + examples/pbr_helmet/Cargo.toml | 15 ++ examples/pbr_helmet/build.rs | 24 ++ examples/pbr_helmet/res/config.ini | 24 ++ examples/pbr_helmet/src/game.rs | 89 +++++++ examples/pbr_helmet/src/lib.rs | 5 + examples/pill_tunel/Cargo.toml | 1 + examples/pill_tunel/src/game.rs | 51 ++++ 13 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 engine/pill_assets/src/rules/glb_to_cooked_mesh.rs create mode 100644 examples/pbr_helmet/Cargo.toml create mode 100644 examples/pbr_helmet/build.rs create mode 100644 examples/pbr_helmet/res/config.ini create mode 100644 examples/pbr_helmet/src/game.rs create mode 100644 examples/pbr_helmet/src/lib.rs diff --git a/.gitignore b/.gitignore index 08957af5..d17afe97 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ engine/target_* *.log *.wgsl + +*.cooked_mesh +*.cooked_tex +examples/pbr_helmet/res/models/DamagedHelmet.glb diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 99836cbc..5e95497d 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,7 +8,7 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "/Users/mk/dev/demo/Pill-Engine/examples/pill_tunel", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) + "/Users/mk/dev/demo/Pill-Engine/examples/pbr_helmet", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at diff --git a/engine/pill_assets/Cargo.toml b/engine/pill_assets/Cargo.toml index 6ab39832..6fc96ef3 100644 --- a/engine/pill_assets/Cargo.toml +++ b/engine/pill_assets/Cargo.toml @@ -12,3 +12,4 @@ glob = "0.3" png = "0.17" tobj = "3.0" bytemuck = { version = "1.4", features = ["derive"] } +gltf = { version = "1", features = ["import"] } diff --git a/engine/pill_assets/src/lib.rs b/engine/pill_assets/src/lib.rs index b8ab120b..d9e1e6f1 100644 --- a/engine/pill_assets/src/lib.rs +++ b/engine/pill_assets/src/lib.rs @@ -19,7 +19,7 @@ use anyhow::{Context, Result}; pub mod rules; -pub use rules::{default_rules, HlslToWgsl, ObjToCookedMesh, PngToCookedTex}; +pub use rules::{default_rules, GlbToCookedMesh, HlslToWgsl, ObjToCookedMesh, PngToCookedTex}; pub trait Rule { /// Glob (relative to the pipeline root) selecting source inputs. diff --git a/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs b/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs new file mode 100644 index 00000000..60d976a7 --- /dev/null +++ b/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs @@ -0,0 +1,232 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use bytemuck::{Pod, Zeroable}; + +use crate::Rule; + +/// Must stay bit-for-bit identical to `pill_engine::resources::MeshVertex`. +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +struct Vertex { + position: [f32; 3], + texture_coordinates: [f32; 2], + normal: [f32; 3], + tangent: [f32; 3], + bitangent: [f32; 3], +} + +/// GLB → cooked_mesh + sidecar textures. +/// +/// Primary output: `{stem}.cooked_mesh` (RMSH format, same as ObjToCookedMesh). +/// Side outputs written in the same directory when textures are present: +/// `{stem}_albedo.cooked_tex` — base color (RTEX RGBA8) +/// `{stem}_normal.cooked_tex` — normal map (RTEX RGBA8) +pub struct GlbToCookedMesh; + +impl Rule for GlbToCookedMesh { + fn name(&self) -> &'static str { + "glb_to_cooked_mesh" + } + + fn input_glob(&self) -> &'static str { + "**/*.glb" + } + + fn output_for(&self, input: &Path) -> PathBuf { + input.with_extension("cooked_mesh") + } + + fn build(&self, input: &Path, output: &Path) -> Result<()> { + let bytes = std::fs::read(input).with_context(|| format!("read {input:?}"))?; + let (doc, buffers, images) = + gltf::import_slice(&bytes).with_context(|| format!("parse GLB {input:?}"))?; + + // --- Mesh --- + + let mesh = doc + .meshes() + .next() + .with_context(|| format!("{input:?}: no meshes in GLB"))?; + let prim = mesh + .primitives() + .next() + .with_context(|| format!("{input:?}: mesh has no primitives"))?; + let reader = prim.reader(|b| Some(&*buffers[b.index()])); + + let positions: Vec<[f32; 3]> = reader + .read_positions() + .with_context(|| format!("{input:?}: missing positions"))? + .collect(); + let normals: Vec<[f32; 3]> = reader + .read_normals() + .with_context(|| format!("{input:?}: missing normals"))? + .collect(); + let uvs: Vec<[f32; 2]> = reader + .read_tex_coords(0) + .with_context(|| format!("{input:?}: missing UV set 0"))? + .into_f32() + .collect(); + let tangents_glb: Vec<[f32; 4]> = reader + .read_tangents() + .map(|t| t.collect()) + .unwrap_or_default(); + let indices: Vec = reader + .read_indices() + .with_context(|| format!("{input:?}: missing indices"))? + .into_u32() + .collect(); + + let mut vertices: Vec = (0..positions.len()) + .map(|i| { + let n = normals[i]; + let (tx, ty, tz, sign) = tangents_glb + .get(i) + .map(|t| (t[0], t[1], t[2], t[3])) + .unwrap_or((1.0, 0.0, 0.0, 1.0)); + let bx = (n[1] * tz - n[2] * ty) * sign; + let by = (n[2] * tx - n[0] * tz) * sign; + let bz = (n[0] * ty - n[1] * tx) * sign; + Vertex { + position: positions[i], + texture_coordinates: uvs[i], + normal: n, + tangent: [tx, ty, tz], + bitangent: [bx, by, bz], + } + }) + .collect(); + + if tangents_glb.is_empty() { + compute_tangents(&mut vertices, &indices); + } + + let vertex_bytes: &[u8] = bytemuck::cast_slice(&vertices); + let index_bytes: &[u8] = bytemuck::cast_slice(&indices); + let mut out = Vec::with_capacity(16 + vertex_bytes.len() + index_bytes.len()); + out.extend_from_slice(b"RMSH"); + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(&(vertices.len() as u32).to_le_bytes()); + out.extend_from_slice(&(indices.len() as u32).to_le_bytes()); + out.extend_from_slice(vertex_bytes); + out.extend_from_slice(index_bytes); + std::fs::write(output, &out).with_context(|| format!("write {output:?}"))?; + + // --- Textures (side outputs) --- + + let stem = input + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let dir = output + .parent() + .with_context(|| "output has no parent directory")?; + + if let Some(mat) = doc.materials().next() { + if let Some(info) = mat.pbr_metallic_roughness().base_color_texture() { + let idx = info.texture().source().index(); + if let Some(img) = images.get(idx) { + let rtex = image_to_rtex(img) + .with_context(|| format!("{input:?}: base color texture"))?; + std::fs::write(dir.join(format!("{stem}_albedo.cooked_tex")), &rtex)?; + } + } + + if let Some(info) = mat.normal_texture() { + let idx = info.texture().source().index(); + if let Some(img) = images.get(idx) { + let rtex = + image_to_rtex(img).with_context(|| format!("{input:?}: normal texture"))?; + std::fs::write(dir.join(format!("{stem}_normal.cooked_tex")), &rtex)?; + } + } + } + + Ok(()) + } +} + +fn image_to_rtex(img: &gltf::image::Data) -> Result> { + use gltf::image::Format; + + let rgba: Vec = match img.format { + Format::R8G8B8A8 => img.pixels.clone(), + Format::R8G8B8 => img + .pixels + .chunks_exact(3) + .flat_map(|p| [p[0], p[1], p[2], 255u8]) + .collect(), + fmt => bail!("unsupported GLB image format: {fmt:?}"), + }; + + let mut out = Vec::with_capacity(16 + rgba.len()); + out.extend_from_slice(b"RTEX"); + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(&img.width.to_le_bytes()); + out.extend_from_slice(&img.height.to_le_bytes()); + out.extend_from_slice(&rgba); + Ok(out) +} + +fn compute_tangents(vertices: &mut [Vertex], indices: &[u32]) { + let mut triangle_counts = vec![0usize; vertices.len()]; + + for tri in indices.chunks(3) { + let (i0, i1, i2) = (tri[0] as usize, tri[1] as usize, tri[2] as usize); + let p0 = vertices[i0].position; + let p1 = vertices[i1].position; + let p2 = vertices[i2].position; + let uv0 = vertices[i0].texture_coordinates; + let uv1 = vertices[i1].texture_coordinates; + let uv2 = vertices[i2].texture_coordinates; + + let dp1 = sub3(p1, p0); + let dp2 = sub3(p2, p0); + let duv1 = sub2(uv1, uv0); + let duv2 = sub2(uv2, uv0); + + let det = duv1[0] * duv2[1] - duv1[1] * duv2[0]; + if det.abs() < 1e-8 { + continue; + } + let inv = 1.0 / det; + let tangent = scale3(sub3(scale3(dp1, duv2[1]), scale3(dp2, duv1[1])), inv); + let bitangent = scale3(sub3(scale3(dp2, duv1[0]), scale3(dp1, duv2[0])), inv); + + for &i in &[i0, i1, i2] { + vertices[i].tangent = add3(vertices[i].tangent, tangent); + vertices[i].bitangent = add3(vertices[i].bitangent, bitangent); + triangle_counts[i] += 1; + } + } + + for (i, &count) in triangle_counts.iter().enumerate() { + if count > 0 { + let inv = 1.0 / count as f32; + vertices[i].tangent = normalize3(scale3(vertices[i].tangent, inv)); + vertices[i].bitangent = normalize3(scale3(vertices[i].bitangent, inv)); + } + } +} + +fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn sub2(a: [f32; 2], b: [f32; 2]) -> [f32; 2] { + [a[0] - b[0], a[1] - b[1]] +} +fn scale3(a: [f32; 3], s: f32) -> [f32; 3] { + [a[0] * s, a[1] * s, a[2] * s] +} +fn add3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +fn normalize3(a: [f32; 3]) -> [f32; 3] { + let len = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt(); + if len < 1e-10 { + a + } else { + [a[0] / len, a[1] / len, a[2] / len] + } +} diff --git a/engine/pill_assets/src/rules/mod.rs b/engine/pill_assets/src/rules/mod.rs index 854f42f7..7a19aaa3 100644 --- a/engine/pill_assets/src/rules/mod.rs +++ b/engine/pill_assets/src/rules/mod.rs @@ -1,9 +1,11 @@ use crate::Rule; +pub mod glb_to_cooked_mesh; pub mod hlsl_to_wgsl; pub mod obj_to_cooked_mesh; pub mod png_to_cooked_tex; +pub use glb_to_cooked_mesh::GlbToCookedMesh; pub use hlsl_to_wgsl::HlslToWgsl; pub use obj_to_cooked_mesh::ObjToCookedMesh; pub use png_to_cooked_tex::PngToCookedTex; @@ -14,5 +16,6 @@ pub fn default_rules() -> Vec> { Box::new(HlslToWgsl), Box::new(PngToCookedTex), Box::new(ObjToCookedMesh), + Box::new(GlbToCookedMesh), ] } diff --git a/examples/pbr_helmet/Cargo.toml b/examples/pbr_helmet/Cargo.toml new file mode 100644 index 00000000..12772bbb --- /dev/null +++ b/examples/pbr_helmet/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pill_game" +version = "0.1.0" +edition = "2021" +workspace = "/Users/mk/dev/demo/Pill-Engine/engine" + +[lib] +crate-type = ["cdylib", "rlib"] + +[build-dependencies] +ureq = "2" +pill_assets = { path = "../../engine/pill_assets" } + +[dependencies] +pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal"] } diff --git a/examples/pbr_helmet/build.rs b/examples/pbr_helmet/build.rs new file mode 100644 index 00000000..20e0d34b --- /dev/null +++ b/examples/pbr_helmet/build.rs @@ -0,0 +1,24 @@ +use std::path::PathBuf; + +use pill_assets::{GlbToCookedMesh, Rule}; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let models = manifest.join("res/models"); + std::fs::create_dir_all(&models).unwrap(); + + let glb = models.join("DamagedHelmet.glb"); + if !glb.exists() { + let url = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/DamagedHelmet/glTF-Binary/DamagedHelmet.glb"; + let resp = ureq::get(url).call().expect("download DamagedHelmet.glb"); + let mut f = std::fs::File::create(&glb).unwrap(); + std::io::copy(&mut resp.into_reader(), &mut f).unwrap(); + } + + let cooked_mesh = models.join("DamagedHelmet.cooked_mesh"); + if !cooked_mesh.exists() { + GlbToCookedMesh + .build(&glb, &cooked_mesh) + .expect("cook DamagedHelmet.glb"); + } +} diff --git a/examples/pbr_helmet/res/config.ini b/examples/pbr_helmet/res/config.ini new file mode 100644 index 00000000..af9e3ac4 --- /dev/null +++ b/examples/pbr_helmet/res/config.ini @@ -0,0 +1,24 @@ +# GAME +TITLE=PBR Helmet + +# WINDOW +WINDOW_TITLE=PBR Helmet +WINDOW_WIDTH=1280 +WINDOW_HEIGHT=720 +WINDOW_FULLSCREEN=false + +# ENGINE GENERAL +MAX_ENTITYS=100 +MAX_CAMERAS=1 +MAX_CONCURRENT_2D_SOUNDS=0 +MAX_CONCURRENT_3D_SOUNDS=0 + +# ENGINE RESOURCES +MAX_TEXTURES=10 +MAX_MATERIALS=10 +MAX_MESHS=10 +MAX_SOUNDS=0 + +# OTHER +LOG_LEVEL=Info +PANIC_ON_GAME_ERRORS=true diff --git a/examples/pbr_helmet/src/game.rs b/examples/pbr_helmet/src/game.rs new file mode 100644 index 00000000..99583892 --- /dev/null +++ b/examples/pbr_helmet/src/game.rs @@ -0,0 +1,89 @@ +use pill_engine::game::*; + +pub struct Game {} + +const TARGET_FPS: f32 = 60.0; + +fn rotate_system(engine: &mut Engine) -> Result<()> { + let dt = engine + .get_global_component::()? + .delta_time + .min(1.0 / TARGET_FPS); + + for (_entity, transform, _pbr) in + engine.iterate_two_components_mut::()? + { + transform.rotate_around_axis(2.0 * dt, Vector3f::new(0.0, 0.0, 1.0)); + } + + Ok(()) +} + +impl PillGame for Game { + fn start(&self, engine: &mut Engine) -> Result<()> { + let scene = engine.create_scene("helmet")?; + engine.set_active_scene(scene)?; + + engine.register_component::(scene)?; + engine.register_component::(scene)?; + engine.register_component::(scene)?; + + let mesh_handle = engine.add_resource(Mesh::new( + "helmet_mesh", + "models/DamagedHelmet.cooked_mesh".into(), + ))?; + + let albedo_handle = engine.add_resource(Texture::new( + "helmet_albedo", + TextureType::Color, + ResourceLoader::Path("models/DamagedHelmet_albedo.cooked_tex".into()), + ))?; + + let normal_handle = engine.add_resource(Texture::new( + "helmet_normal", + TextureType::Normal, + ResourceLoader::Path("models/DamagedHelmet_normal.cooked_tex".into()), + ))?; + + let material_handle = engine.add_resource( + PBRMaterial::new("helmet_material") + .albedo_texture(albedo_handle) + .normal_texture(normal_handle), + )?; + + engine + .build_entity(scene) + .with_component( + TransformComponent::builder() + .position(Vector3f::new(0.0, 0.0, -3.0)) + .build(), + ) + .with_component( + CameraComponent::builder() + .enabled(true) + .fov(60.0) + .clear_color(Color::new(0.05, 0.05, 0.06)) + .build(), + ) + .build(); + + engine + .build_entity(scene) + .with_component( + TransformComponent::builder() + .rotation(Vector3f::new(90.0, 0.0, 0.0)) + .build(), + ) + .with_component( + PbrRenderableComponent::builder() + .mesh(&mesh_handle) + .pbr_material(&material_handle) + .build(), + ) + .build(); + + engine.add_system("rotate", rotate_system)?; + + Ok(()) + } +} diff --git a/examples/pbr_helmet/src/lib.rs b/examples/pbr_helmet/src/lib.rs new file mode 100644 index 00000000..3ab5b3fb --- /dev/null +++ b/examples/pbr_helmet/src/lib.rs @@ -0,0 +1,5 @@ +mod game; +pub use game::Game; + +use pill_engine::game::create_game; +create_game!(crate::game::Game {}, pill_engine::game::PillGame); diff --git a/examples/pill_tunel/Cargo.toml b/examples/pill_tunel/Cargo.toml index 3433b742..c7290ae9 100644 --- a/examples/pill_tunel/Cargo.toml +++ b/examples/pill_tunel/Cargo.toml @@ -9,3 +9,4 @@ crate-type = ["cdylib", "rlib"] [dependencies] pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal"] } +png = "0.17" diff --git a/examples/pill_tunel/src/game.rs b/examples/pill_tunel/src/game.rs index 32836ca2..5b90c400 100644 --- a/examples/pill_tunel/src/game.rs +++ b/examples/pill_tunel/src/game.rs @@ -1,4 +1,22 @@ use pill_engine::{define_component, game::*}; +use std::sync::{Arc, OnceLock}; + +static LOGO: OnceLock, u32, u32)>> = OnceLock::new(); + +fn load_logo() -> Arc<(Vec, u32, u32)> { + let bytes: &[u8] = include_bytes!("../res/textures/pill_logo.png"); + let decoder = png::Decoder::new(bytes); + let mut reader = decoder.read_info().unwrap(); + let mut buf = vec![0u8; reader.output_buffer_size()]; + let info = reader.next_frame(&mut buf).unwrap(); + let raw = &buf[..info.buffer_size()]; + let rgba: Vec = match info.color_type { + png::ColorType::Rgba => raw.to_vec(), + png::ColorType::Rgb => raw.chunks(3).flat_map(|c| [c[0], c[1], c[2], 255u8]).collect(), + _ => raw.to_vec(), + }; + Arc::new((rgba, info.width, info.height)) +} // --- Scene constants --------------------------------------------------------- @@ -183,6 +201,38 @@ fn camera_drift_system(engine: &mut Engine) -> Result<()> { Ok(()) } +fn logo_overlay_system(engine: &mut Engine) -> Result<()> { + let logo = LOGO.get_or_init(load_logo).clone(); + engine.set_game_overlay(move |ctx: &egui::Context| { + let tex_id = egui::Id::new("pill_logo_tex"); + let handle = ctx + .memory(|m| m.data.get_temp::(tex_id)) + .unwrap_or_else(|| { + let (rgba, w, h) = &*logo; + let img = egui::ColorImage::from_rgba_unmultiplied( + [*w as usize, *h as usize], + rgba, + ); + let t = ctx.load_texture("pill_logo", img, egui::TextureOptions::default()); + ctx.memory_mut(|m| m.data.insert_temp(tex_id, t.clone())); + t + }); + let display_w = 200.0f32; + let (_, src_w, src_h) = &*logo; + let display_h = display_w * (*src_h as f32 / *src_w as f32); + egui::Area::new("pill_logo_overlay".into()) + .anchor(egui::Align2::RIGHT_BOTTOM, [-16.0, -16.0]) + .interactable(false) + .show(ctx, |ui| { + ui.add(egui::Image::new(egui::load::SizedTexture::new( + handle.id(), + [display_w, display_h], + ))); + }); + })?; + Ok(()) +} + // --- Game -------------------------------------------------------------------- impl PillGame for WebGame { @@ -313,6 +363,7 @@ impl PillGame for WebGame { engine.add_system("pill_particle", pill_particle_system)?; engine.add_system("hero_pill", hero_pill_system)?; engine.add_system("camera_drift", camera_drift_system)?; + engine.add_system("logo_overlay", logo_overlay_system)?; Ok(()) } } From fc02e60bb69e158d3d0a8c3b94f702729ccf0bc3 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sat, 30 May 2026 15:21:06 +0200 Subject: [PATCH 10/31] example: pbr balls --- .gitignore | 1 + engine/Cargo.toml | 2 +- engine/pill_assets/src/lib.rs | 5 +- .../pill_assets/src/rules/equirect_to_ibl.rs | 338 ++++++++++++++++++ .../src/rules/glb_to_cooked_mesh.rs | 234 ++++++++---- engine/pill_assets/src/rules/mod.rs | 6 + .../pill_assets/src/rules/studio_equirect.rs | 180 ++++++++++ .../res/shaders/background_fragment.hlsl | 31 ++ .../res/shaders/background_vertex.hlsl | 17 + .../res/shaders/pbr_static_fragment.hlsl | 36 +- .../res/shaders/tonemap_fragment.hlsl | 9 + .../res/shaders/tonemap_vertex.hlsl | 16 + engine/pill_engine/res/textures/studio.studio | 0 engine/pill_engine/src/config.rs | 24 ++ .../src/ecs/components/camera_component.rs | 7 + engine/pill_engine/src/engine.rs | 25 ++ engine/pill_engine/src/graphics/mod.rs | 4 + .../src/graphics/pass_background.rs | 284 +++++++++++++++ .../src/graphics/pass_pbr_static.rs | 182 +++++++--- .../pill_engine/src/graphics/pass_tonemap.rs | 154 ++++++++ .../renderer/resources/renderer_material.rs | 8 +- .../renderer/resources/renderer_texture.rs | 1 + engine/pill_engine/src/renderer/state.rs | 140 +++++++- engine/pill_engine/src/resources/material.rs | 29 ++ engine/pill_engine/src/resources/texture.rs | 1 + .../res/templates/pill_default/res/config.ini | 2 +- engine/pill_launcher/src/main.rs | 13 + examples/Empty/res/config.ini | 2 +- examples/cube/res/config.ini | 2 +- examples/floating_pills/res/config.ini | 2 +- examples/italian_brainrot/res/config.ini | 2 +- examples/net_minimal/client/res/config.ini | 2 +- examples/pbr_balls/Cargo.toml | 15 + examples/pbr_balls/build.rs | 26 ++ examples/pbr_balls/res/config.ini | 24 ++ examples/pbr_balls/res/models/studio.studio | 0 examples/pbr_balls/res/studio.studio | 0 examples/pbr_balls/src/game.rs | 108 ++++++ examples/pbr_balls/src/lib.rs | 5 + examples/pbr_helmet/res/config.ini | 2 +- examples/pill_tunel/res/config.ini | 2 +- 41 files changed, 1803 insertions(+), 138 deletions(-) create mode 100644 engine/pill_assets/src/rules/equirect_to_ibl.rs create mode 100644 engine/pill_assets/src/rules/studio_equirect.rs create mode 100644 engine/pill_engine/res/shaders/background_fragment.hlsl create mode 100644 engine/pill_engine/res/shaders/background_vertex.hlsl create mode 100644 engine/pill_engine/res/shaders/tonemap_fragment.hlsl create mode 100644 engine/pill_engine/res/shaders/tonemap_vertex.hlsl create mode 100644 engine/pill_engine/res/textures/studio.studio create mode 100644 engine/pill_engine/src/graphics/pass_background.rs create mode 100644 engine/pill_engine/src/graphics/pass_tonemap.rs create mode 100644 examples/pbr_balls/Cargo.toml create mode 100644 examples/pbr_balls/build.rs create mode 100644 examples/pbr_balls/res/config.ini create mode 100644 examples/pbr_balls/res/models/studio.studio create mode 100644 examples/pbr_balls/res/studio.studio create mode 100644 examples/pbr_balls/src/game.rs create mode 100644 examples/pbr_balls/src/lib.rs diff --git a/.gitignore b/.gitignore index d17afe97..f5d9c8bd 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ engine/target_* *.cooked_mesh *.cooked_tex examples/pbr_helmet/res/models/DamagedHelmet.glb +examples/pbr_balls/res/models/MetalRoughSpheres.glb diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 5e95497d..00888499 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,7 +8,7 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "/Users/mk/dev/demo/Pill-Engine/examples/pbr_helmet", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) + "/Users/mk/dev/demo/Pill-Engine/examples/pbr_balls", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at diff --git a/engine/pill_assets/src/lib.rs b/engine/pill_assets/src/lib.rs index d9e1e6f1..575214f1 100644 --- a/engine/pill_assets/src/lib.rs +++ b/engine/pill_assets/src/lib.rs @@ -19,7 +19,10 @@ use anyhow::{Context, Result}; pub mod rules; -pub use rules::{default_rules, GlbToCookedMesh, HlslToWgsl, ObjToCookedMesh, PngToCookedTex}; +pub use rules::{ + default_rules, EquirectToIBL, GlbToCookedMesh, HlslToWgsl, ObjToCookedMesh, PngToCookedTex, + StudioEquirect, +}; pub trait Rule { /// Glob (relative to the pipeline root) selecting source inputs. diff --git a/engine/pill_assets/src/rules/equirect_to_ibl.rs b/engine/pill_assets/src/rules/equirect_to_ibl.rs new file mode 100644 index 00000000..2fd05dd7 --- /dev/null +++ b/engine/pill_assets/src/rules/equirect_to_ibl.rs @@ -0,0 +1,338 @@ +use std::f32::consts::PI; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +use super::studio_equirect::{linear_to_srgb, srgb_to_linear, write_rtex, write_rtex_hdr_mip}; +use crate::Rule; + +pub struct EquirectToIBL; + +impl Rule for EquirectToIBL { + fn name(&self) -> &'static str { + "equirect_to_ibl" + } + + fn input_glob(&self) -> &'static str { + "**/*_equirect.cooked_tex" + } + + fn output_for(&self, input: &Path) -> PathBuf { + // Anchor output is the diffuse IBL; build() also writes specular IBL + BRDF LUT. + let stem = input.file_stem().unwrap().to_str().unwrap(); + let base = stem.strip_suffix("_equirect").unwrap_or(stem); + input.with_file_name(format!("{base}_diffuse_ibl.cooked_tex")) + } + + fn build(&self, input: &Path, output: &Path) -> Result<()> { + let equirect = Equirect::load(input)?; + let dir = output.parent().unwrap(); + let stem = input.file_stem().unwrap().to_str().unwrap(); + let base = stem.strip_suffix("_equirect").unwrap_or(stem); + + // 1. Diffuse irradiance (32×16 sRGB) — anchor output + let irr = compute_irradiance(&equirect, 32, 16); + write_rtex(output, 32, 16, &irr)?; + + // 2. Specular prefilter HDR mip chain (RTEX v4, Rgba32Float). + // 5 mip levels; mip i roughness = i/4, matching shader roughness * MAX_REFLECTION_LOD(4). + const MIP_ROUGHNESS: [f32; 5] = [0.04, 0.25, 0.5, 0.75, 1.0]; + let spec_path = dir.join(format!("{base}_specular_ibl.cooked_tex")); + let mut spec_mips: Vec> = Vec::with_capacity(5); + for (mip, &r) in MIP_ROUGHNESS.iter().enumerate() { + let w = (128u32 >> mip).max(1); + let h = (64u32 >> mip).max(1); + spec_mips.push(compute_specular_prefilter(&equirect, w, h, r)); + } + write_rtex_hdr_mip(&spec_path, 128, 64, &spec_mips)?; + + // 3. BRDF LUT (256×256 linear) — split-sum preintegration, environment-independent + let lut = compute_brdf_lut(256, 256); + let lut_path = dir.join("brdf_lut.cooked_tex"); + write_rtex(&lut_path, 256, 256, &lut)?; + + Ok(()) + } +} + +// --- Equirect helper --- + +struct Equirect { + pixels: Vec, // flat RGBA f32 linear + w: u32, + h: u32, +} + +impl Equirect { + fn load(path: &Path) -> Result { + let data = std::fs::read(path).with_context(|| format!("read {path:?}"))?; + if data.len() < 16 || &data[0..4] != b"RTEX" { + bail!("invalid RTEX in {path:?}"); + } + let version = u32::from_le_bytes(data[4..8].try_into().unwrap()); + let w = u32::from_le_bytes(data[8..12].try_into().unwrap()); + let h = u32::from_le_bytes(data[12..16].try_into().unwrap()); + let pixels = if version == 2 { + // v2: Rgba32Float (4 bytes per channel, f32 LE) + data[16..] + .chunks_exact(4) + .map(|b| f32::from_le_bytes(b.try_into().unwrap())) + .collect() + } else { + // v1: Rgba8UnormSrgb — decode to linear f32 at load time + data[16..] + .iter() + .map(|&b| srgb_to_linear(b as f32 / 255.0)) + .collect() + }; + Ok(Self { pixels, w, h }) + } + + // Sample at a world-space direction; nearest-neighbor, returns linear HDR values. + fn sample(&self, dir: [f32; 3]) -> [f32; 3] { + let (u, v) = dir_to_equirect_uv(dir); + let x = ((u * self.w as f32) as u32).min(self.w - 1); + let y = ((v * self.h as f32) as u32).min(self.h - 1); + let i = ((y * self.w + x) * 4) as usize; + [self.pixels[i], self.pixels[i + 1], self.pixels[i + 2]] + } +} + +// --- Coordinate helpers --- + +fn equirect_uv_to_dir(u: f32, v: f32) -> [f32; 3] { + let az = (u - 0.5) * 2.0 * PI; // consistent with dir_to_equirect_uv: u = 0.5 + az/(2π) + let elev = (0.5 - v) * PI; + let cos_e = elev.cos(); + [cos_e * az.cos(), elev.sin(), cos_e * az.sin()] +} + +fn dir_to_equirect_uv(dir: [f32; 3]) -> (f32, f32) { + let [x, y, z] = normalize(dir); + let az = z.atan2(x); + let elev = y.clamp(-1.0, 1.0).asin(); + let u = (0.5 + az / (2.0 * PI)).rem_euclid(1.0); + let v = (0.5 - elev / PI).clamp(0.0, 1.0); + (u, v) +} + +// --- Vector math --- + +fn normalize(v: [f32; 3]) -> [f32; 3] { + let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-8); + [v[0] / len, v[1] / len, v[2] / len] +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +// Build an orthonormal tangent frame (t, b) from normal n. +fn tangent_frame(n: [f32; 3]) -> ([f32; 3], [f32; 3]) { + let up = if n[1].abs() < 0.9 { + [0.0f32, 1.0, 0.0] + } else { + [1.0f32, 0.0, 0.0] + }; + let t = normalize(cross(up, n)); + let b = cross(n, t); + (t, b) +} + +// Transform v from tangent space (where n=[0,0,1]) to world space. +fn tangent_to_world(v: [f32; 3], n: [f32; 3]) -> [f32; 3] { + let (t, b) = tangent_frame(n); + normalize([ + t[0] * v[0] + b[0] * v[1] + n[0] * v[2], + t[1] * v[0] + b[1] * v[1] + n[1] * v[2], + t[2] * v[0] + b[2] * v[1] + n[2] * v[2], + ]) +} + +// --- Hammersley quasi-random sequence --- + +fn radical_inverse_vdc(mut bits: u32) -> f32 { + bits = (bits << 16) | (bits >> 16); + bits = ((bits & 0x5555_5555) << 1) | ((bits & 0xAAAA_AAAA) >> 1); + bits = ((bits & 0x3333_3333) << 2) | ((bits & 0xCCCC_CCCC) >> 2); + bits = ((bits & 0x0F0F_0F0F) << 4) | ((bits & 0xF0F0_F0F0) >> 4); + bits = ((bits & 0x00FF_00FF) << 8) | ((bits & 0xFF00_FF00) >> 8); + bits as f32 * 2.328_306_4e-10 // / 2^32 +} + +fn hammersley(i: u32, n: u32) -> (f32, f32) { + (i as f32 / n as f32, radical_inverse_vdc(i)) +} + +// GGX importance sampling: returns half-vector in world space around n. +fn importance_sample_ggx(xi: (f32, f32), roughness: f32, n: [f32; 3]) -> [f32; 3] { + let a = roughness * roughness; + let phi = 2.0 * PI * xi.0; + let cos_t = ((1.0 - xi.1) / (1.0 + (a * a - 1.0) * xi.1)) + .max(0.0) + .sqrt(); + let sin_t = (1.0 - cos_t * cos_t).max(0.0).sqrt(); + let h_local = [sin_t * phi.cos(), sin_t * phi.sin(), cos_t]; + tangent_to_world(h_local, n) +} + +// --- Diffuse irradiance: cosine-weighted Riemann sum --- + +fn compute_irradiance(eq: &Equirect, out_w: u32, out_h: u32) -> Vec { + const N_PHI: u32 = 64; + const N_THETA: u32 = 32; + let dphi = 2.0 * PI / N_PHI as f32; + let dtheta = 0.5 * PI / N_THETA as f32; + + let mut out = Vec::with_capacity((out_w * out_h * 4) as usize); + for y in 0..out_h { + for x in 0..out_w { + let u = (x as f32 + 0.5) / out_w as f32; + let v = (y as f32 + 0.5) / out_h as f32; + let n = equirect_uv_to_dir(u, v); + + let mut irr = [0.0f32; 3]; + let mut wt = 0.0f32; + + for j in 0..N_THETA { + for i in 0..N_PHI { + let phi = (i as f32 + 0.5) * dphi; + let theta = (j as f32 + 0.5) * dtheta; + let sin_t = theta.sin(); + let cos_t = theta.cos(); + let local = [sin_t * phi.cos(), sin_t * phi.sin(), cos_t]; + let world = tangent_to_world(local, n); + let sample = eq.sample(world); + let w = cos_t * sin_t; + irr[0] += sample[0] * w; + irr[1] += sample[1] * w; + irr[2] += sample[2] * w; + wt += w; + } + } + + let s = PI / wt; + out.push(linear_to_srgb(irr[0] * s)); + out.push(linear_to_srgb(irr[1] * s)); + out.push(linear_to_srgb(irr[2] * s)); + out.push(255u8); + } + } + out +} + +// --- Specular prefilter: GGX importance sampling, HDR linear output --- + +fn compute_specular_prefilter(eq: &Equirect, out_w: u32, out_h: u32, roughness: f32) -> Vec { + const N_SAMPLES: u32 = 256; + + let mut out = Vec::with_capacity((out_w * out_h * 4) as usize); + for y in 0..out_h { + for x in 0..out_w { + let u = (x as f32 + 0.5) / out_w as f32; + let v = (y as f32 + 0.5) / out_h as f32; + let n = equirect_uv_to_dir(u, v); + + let mut col = [0.0f32; 3]; + let mut wt = 0.0f32; + + for i in 0..N_SAMPLES { + let xi = hammersley(i, N_SAMPLES); + let h = importance_sample_ggx(xi, roughness, n); + let vdoth = dot(n, h).max(0.0); + let l = normalize([ + 2.0 * vdoth * h[0] - n[0], + 2.0 * vdoth * h[1] - n[1], + 2.0 * vdoth * h[2] - n[2], + ]); + let ndotl = dot(n, l).max(0.0); + if ndotl > 0.0 { + let s = eq.sample(l); + col[0] += s[0] * ndotl; + col[1] += s[1] * ndotl; + col[2] += s[2] * ndotl; + wt += ndotl; + } + } + + let s = 1.0 / wt.max(1e-6); + out.push(col[0] * s); + out.push(col[1] * s); + out.push(col[2] * s); + out.push(1.0f32); + } + } + out +} + +// --- BRDF LUT: GGX split-sum preintegration (Karis / UE4) --- +// Stored linear (not sRGB): R = F0 scale, G = F0 bias. + +fn geometry_schlick_ggx_ibl(ndotv: f32, roughness: f32) -> f32 { + let k = roughness * roughness / 2.0; + ndotv / (ndotv * (1.0 - k) + k) +} + +fn geometry_smith_ibl(ndotv: f32, ndotl: f32, roughness: f32) -> f32 { + geometry_schlick_ggx_ibl(ndotv, roughness) * geometry_schlick_ggx_ibl(ndotl, roughness) +} + +fn compute_brdf_lut(w: u32, h: u32) -> Vec { + const N: u32 = 1024; + + let mut out = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + // x = NdotV (0=grazing, 1=normal incidence) + // y = roughness (0=smooth, 1=rough) — y=0 is top of texture + let ndotv = (x as f32 + 0.5) / w as f32; + let roughness = (y as f32 + 0.5) / h as f32; + let roughness = roughness.max(0.04); + + let v = [(1.0 - ndotv * ndotv).max(0.0).sqrt(), 0.0f32, ndotv]; + let n = [0.0f32, 0.0, 1.0]; + + let mut scale = 0.0f32; + let mut bias = 0.0f32; + + for i in 0..N { + let xi = hammersley(i, N); + let h_v = importance_sample_ggx(xi, roughness, n); + let vdoth = dot(v, h_v).max(0.0); + let l = normalize([ + 2.0 * vdoth * h_v[0] - v[0], + 2.0 * vdoth * h_v[1] - v[1], + 2.0 * vdoth * h_v[2] - v[2], + ]); + let ndotl = l[2].max(0.0); // n = [0,0,1] + let ndoth = h_v[2].max(0.0); + + if ndotl > 0.0 { + let g_vis = geometry_smith_ibl(ndotv, ndotl, roughness) * vdoth + / (ndoth * ndotv.max(0.001)); + let fc = (1.0 - vdoth).powi(5); + scale += (1.0 - fc) * g_vis; + bias += fc * g_vis; + } + } + + scale = (scale / N as f32).clamp(0.0, 1.0); + bias = (bias / N as f32).clamp(0.0, 1.0); + + // Linear bytes (not sRGB — BRDF coefficients are in linear space) + out.push((scale * 255.0 + 0.5) as u8); + out.push((bias * 255.0 + 0.5) as u8); + out.push(0u8); + out.push(255u8); + } + } + out +} diff --git a/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs b/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs index 60d976a7..20c8288f 100644 --- a/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs +++ b/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs @@ -43,71 +43,114 @@ impl Rule for GlbToCookedMesh { gltf::import_slice(&bytes).with_context(|| format!("parse GLB {input:?}"))?; // --- Mesh --- + // Walk the scene node hierarchy. For each node that references a mesh, apply the + // node's world transform to vertex positions and normals before cooked output. + // This bakes the root-node transform (scale, rotation, etc.) into the geometry so + // game code needs no compensating transform on the mesh entity. - let mesh = doc - .meshes() - .next() - .with_context(|| format!("{input:?}: no meshes in GLB"))?; - let prim = mesh - .primitives() - .next() - .with_context(|| format!("{input:?}: mesh has no primitives"))?; - let reader = prim.reader(|b| Some(&*buffers[b.index()])); - - let positions: Vec<[f32; 3]> = reader - .read_positions() - .with_context(|| format!("{input:?}: missing positions"))? - .collect(); - let normals: Vec<[f32; 3]> = reader - .read_normals() - .with_context(|| format!("{input:?}: missing normals"))? - .collect(); - let uvs: Vec<[f32; 2]> = reader - .read_tex_coords(0) - .with_context(|| format!("{input:?}: missing UV set 0"))? - .into_f32() - .collect(); - let tangents_glb: Vec<[f32; 4]> = reader - .read_tangents() - .map(|t| t.collect()) - .unwrap_or_default(); - let indices: Vec = reader - .read_indices() - .with_context(|| format!("{input:?}: missing indices"))? - .into_u32() - .collect(); - - let mut vertices: Vec = (0..positions.len()) - .map(|i| { - let n = normals[i]; - let (tx, ty, tz, sign) = tangents_glb - .get(i) - .map(|t| (t[0], t[1], t[2], t[3])) - .unwrap_or((1.0, 0.0, 0.0, 1.0)); - let bx = (n[1] * tz - n[2] * ty) * sign; - let by = (n[2] * tx - n[0] * tz) * sign; - let bz = (n[0] * ty - n[1] * tx) * sign; - Vertex { - position: positions[i], - texture_coordinates: uvs[i], - normal: n, - tangent: [tx, ty, tz], - bitangent: [bx, by, bz], + let mut all_vertices: Vec = Vec::new(); + let mut all_indices: Vec = Vec::new(); + + // Collect (mesh_index, world_transform_4x4) pairs by traversing the scene graph. + let mut mesh_instances: Vec<(usize, [[f32; 4]; 4])> = Vec::new(); + let identity = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0f32], + ]; + let default_scene = doc.default_scene().or_else(|| doc.scenes().next()); + if let Some(scene) = default_scene { + let mut stack: Vec<(gltf::Node, [[f32; 4]; 4])> = + scene.nodes().map(|n| (n, identity)).collect(); + while let Some((node, parent_transform)) = stack.pop() { + let local = node_transform(node.transform()); + let world = mat4_mul(parent_transform, local); + if let Some(mesh) = node.mesh() { + mesh_instances.push((mesh.index(), world)); + } + for child in node.children() { + stack.push((child, world)); } - }) - .collect(); + } + } else { + // No scene: fall back to processing every mesh with identity transform. + for (i, _) in doc.meshes().enumerate() { + mesh_instances.push((i, identity)); + } + } - if tangents_glb.is_empty() { - compute_tangents(&mut vertices, &indices); + if mesh_instances.is_empty() { + anyhow::bail!("{input:?}: no meshes in GLB"); } - let vertex_bytes: &[u8] = bytemuck::cast_slice(&vertices); - let index_bytes: &[u8] = bytemuck::cast_slice(&indices); + let meshes: Vec = doc.meshes().collect(); + for (mesh_idx, world) in mesh_instances { + let mesh = &meshes[mesh_idx]; + for prim in mesh.primitives() { + let reader = prim.reader(|b| Some(&*buffers[b.index()])); + let vertex_offset = all_vertices.len() as u32; + + let positions: Vec<[f32; 3]> = reader + .read_positions() + .with_context(|| format!("{input:?}: missing positions"))? + .collect(); + let normals: Vec<[f32; 3]> = reader + .read_normals() + .with_context(|| format!("{input:?}: missing normals"))? + .collect(); + let uvs: Vec<[f32; 2]> = reader + .read_tex_coords(0) + .with_context(|| format!("{input:?}: missing UV set 0"))? + .into_f32() + .collect(); + let tangents_glb: Vec<[f32; 4]> = reader + .read_tangents() + .map(|t| t.collect()) + .unwrap_or_default(); + let prim_indices: Vec = reader + .read_indices() + .with_context(|| format!("{input:?}: missing indices"))? + .into_u32() + .collect(); + + let mut vertices: Vec = (0..positions.len()) + .map(|i| { + let n = transform_normal(world, normals[i]); + let (tx, ty, tz, sign) = tangents_glb + .get(i) + .map(|t| (t[0], t[1], t[2], t[3])) + .unwrap_or((1.0, 0.0, 0.0, 1.0)); + let t_world = transform_normal(world, [tx, ty, tz]); + let bx = (n[1] * t_world[2] - n[2] * t_world[1]) * sign; + let by = (n[2] * t_world[0] - n[0] * t_world[2]) * sign; + let bz = (n[0] * t_world[1] - n[1] * t_world[0]) * sign; + Vertex { + position: transform_point(world, positions[i]), + texture_coordinates: uvs[i], + normal: n, + tangent: t_world, + bitangent: [bx, by, bz], + } + }) + .collect(); + + if tangents_glb.is_empty() { + compute_tangents(&mut vertices, &prim_indices); + } + + all_indices.extend(prim_indices.iter().map(|&i| i + vertex_offset)); + all_vertices.extend(vertices); + } + } + + let vertex_bytes: &[u8] = bytemuck::cast_slice(&all_vertices); + let index_bytes: &[u8] = bytemuck::cast_slice(&all_indices); let mut out = Vec::with_capacity(16 + vertex_bytes.len() + index_bytes.len()); out.extend_from_slice(b"RMSH"); out.extend_from_slice(&1u32.to_le_bytes()); - out.extend_from_slice(&(vertices.len() as u32).to_le_bytes()); - out.extend_from_slice(&(indices.len() as u32).to_le_bytes()); + out.extend_from_slice(&(all_vertices.len() as u32).to_le_bytes()); + out.extend_from_slice(&(all_indices.len() as u32).to_le_bytes()); out.extend_from_slice(vertex_bytes); out.extend_from_slice(index_bytes); std::fs::write(output, &out).with_context(|| format!("write {output:?}"))?; @@ -123,22 +166,40 @@ impl Rule for GlbToCookedMesh { .parent() .with_context(|| "output has no parent directory")?; - if let Some(mat) = doc.materials().next() { - if let Some(info) = mat.pbr_metallic_roughness().base_color_texture() { - let idx = info.texture().source().index(); - if let Some(img) = images.get(idx) { + for mat in doc.materials() { + let mat_idx = mat.index(); + let suffix = |base: &str| -> String { + match mat_idx { + None | Some(0) => format!("{stem}_{base}.cooked_tex"), + Some(i) => format!("{stem}_mat{i}_{base}.cooked_tex"), + } + }; + let pbr = mat.pbr_metallic_roughness(); + + if let Some(info) = pbr.base_color_texture() { + let img_idx = info.texture().source().index(); + if let Some(img) = images.get(img_idx) { let rtex = image_to_rtex(img) .with_context(|| format!("{input:?}: base color texture"))?; - std::fs::write(dir.join(format!("{stem}_albedo.cooked_tex")), &rtex)?; + std::fs::write(dir.join(suffix("albedo")), &rtex)?; } } if let Some(info) = mat.normal_texture() { - let idx = info.texture().source().index(); - if let Some(img) = images.get(idx) { + let img_idx = info.texture().source().index(); + if let Some(img) = images.get(img_idx) { let rtex = image_to_rtex(img).with_context(|| format!("{input:?}: normal texture"))?; - std::fs::write(dir.join(format!("{stem}_normal.cooked_tex")), &rtex)?; + std::fs::write(dir.join(suffix("normal")), &rtex)?; + } + } + + if let Some(info) = pbr.metallic_roughness_texture() { + let img_idx = info.texture().source().index(); + if let Some(img) = images.get(img_idx) { + let rtex = image_to_rtex(img) + .with_context(|| format!("{input:?}: metallic_roughness texture"))?; + std::fs::write(dir.join(suffix("metallic_roughness")), &rtex)?; } } } @@ -160,6 +221,7 @@ fn image_to_rtex(img: &gltf::image::Data) -> Result> { fmt => bail!("unsupported GLB image format: {fmt:?}"), }; + // glTF UV origin (0,0) is top-left, matching wgpu/Vulkan — no row flip needed. let mut out = Vec::with_capacity(16 + rgba.len()); out.extend_from_slice(b"RTEX"); out.extend_from_slice(&1u32.to_le_bytes()); @@ -230,3 +292,45 @@ fn normalize3(a: [f32; 3]) -> [f32; 3] { [a[0] / len, a[1] / len, a[2] / len] } } + +fn mat4_mul(a: [[f32; 4]; 4], b: [[f32; 4]; 4]) -> [[f32; 4]; 4] { + let mut c = [[0.0f32; 4]; 4]; + for i in 0..4 { + for j in 0..4 { + for k in 0..4 { + c[i][j] += a[i][k] * b[k][j]; + } + } + } + c +} + +fn node_transform(t: gltf::scene::Transform) -> [[f32; 4]; 4] { + let m = t.matrix(); + // gltf gives column-major; convert to row-major [[f32;4];4] + [ + [m[0][0], m[1][0], m[2][0], m[3][0]], + [m[0][1], m[1][1], m[2][1], m[3][1]], + [m[0][2], m[1][2], m[2][2], m[3][2]], + [m[0][3], m[1][3], m[2][3], m[3][3]], + ] +} + +fn transform_point(m: [[f32; 4]; 4], p: [f32; 3]) -> [f32; 3] { + [ + m[0][0] * p[0] + m[0][1] * p[1] + m[0][2] * p[2] + m[0][3], + m[1][0] * p[0] + m[1][1] * p[1] + m[1][2] * p[2] + m[1][3], + m[2][0] * p[0] + m[2][1] * p[1] + m[2][2] * p[2] + m[2][3], + ] +} + +fn transform_normal(m: [[f32; 4]; 4], n: [f32; 3]) -> [f32; 3] { + // Normals transform by the inverse-transpose of the upper-left 3x3. + // For uniform or orthogonal scaling this equals the 3x3 itself (re-normalized). + let r = [ + m[0][0] * n[0] + m[0][1] * n[1] + m[0][2] * n[2], + m[1][0] * n[0] + m[1][1] * n[1] + m[1][2] * n[2], + m[2][0] * n[0] + m[2][1] * n[1] + m[2][2] * n[2], + ]; + normalize3(r) +} diff --git a/engine/pill_assets/src/rules/mod.rs b/engine/pill_assets/src/rules/mod.rs index 7a19aaa3..a02a3c4f 100644 --- a/engine/pill_assets/src/rules/mod.rs +++ b/engine/pill_assets/src/rules/mod.rs @@ -1,20 +1,26 @@ use crate::Rule; +pub mod equirect_to_ibl; pub mod glb_to_cooked_mesh; pub mod hlsl_to_wgsl; pub mod obj_to_cooked_mesh; pub mod png_to_cooked_tex; +pub mod studio_equirect; +pub use equirect_to_ibl::EquirectToIBL; pub use glb_to_cooked_mesh::GlbToCookedMesh; pub use hlsl_to_wgsl::HlslToWgsl; pub use obj_to_cooked_mesh::ObjToCookedMesh; pub use png_to_cooked_tex::PngToCookedTex; +pub use studio_equirect::StudioEquirect; /// Built-in rule set used by both the cargo build-script and the launcher. pub fn default_rules() -> Vec> { vec![ Box::new(HlslToWgsl), Box::new(PngToCookedTex), + Box::new(StudioEquirect), // generates *_equirect.cooked_tex from *.studio + Box::new(EquirectToIBL), // generates IBL maps from *_equirect.cooked_tex Box::new(ObjToCookedMesh), Box::new(GlbToCookedMesh), ] diff --git a/engine/pill_assets/src/rules/studio_equirect.rs b/engine/pill_assets/src/rules/studio_equirect.rs new file mode 100644 index 00000000..61454a4d --- /dev/null +++ b/engine/pill_assets/src/rules/studio_equirect.rs @@ -0,0 +1,180 @@ +use std::f32::consts::PI; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::Rule; + +// Gradient (linear, inverse-Reinhard compensated: c/(1+c) → target on screen). +const TOP: [f32; 3] = [0.90, 0.90, 1.20]; // Reinhard → [0.47, 0.47, 0.55] cool blue ceiling +const HORIZON: [f32; 3] = [0.43, 0.43, 0.46]; // Reinhard → [0.30, 0.30, 0.32] neutral mid +const BOTTOM: [f32; 3] = [0.040, 0.040, 0.050]; // Reinhard → [0.04, 0.04, 0.05] dark floor + +// Key light: warm tungsten, upper-left, ~40° above horizon. +const KEY_U: f32 = 0.10; +const KEY_V: f32 = 0.28; +const KEY_INTENSITY: [f32; 3] = [6.0, 4.8, 2.5]; // Reinhard → [0.86, 0.83, 0.71] warm gold +const KEY_SIGMA: f32 = 0.0875; + +// Rim light: cool blue, upper-right, ~45° above horizon. +const RIM_U: f32 = 0.60; +const RIM_V: f32 = 0.25; +const RIM_INTENSITY: [f32; 3] = [1.5, 2.5, 5.0]; // Reinhard → [0.60, 0.71, 0.83] cool blue +const RIM_SIGMA: f32 = 0.100; + +pub struct StudioEquirect; + +impl Rule for StudioEquirect { + fn name(&self) -> &'static str { + "studio_equirect" + } + + fn input_glob(&self) -> &'static str { + "**/*.studio" + } + + fn output_for(&self, input: &Path) -> PathBuf { + let stem = input.file_stem().unwrap().to_str().unwrap(); + input.with_file_name(format!("{stem}_equirect.cooked_tex")) + } + + fn build(&self, _input: &Path, output: &Path) -> Result<()> { + let (w, h) = (512u32, 256u32); + let pixels = generate_equirect(w, h); + write_rtex_hdr(output, w, h, &pixels) + } +} + +fn generate_equirect(w: u32, h: u32) -> Vec { + let mut out = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let u = (x as f32 + 0.5) / w as f32; + let v = (y as f32 + 0.5) / h as f32; + let elev = (0.5 - v) * PI; + let sin_e = elev.sin(); + let t = (sin_e + 1.0) * 0.5; + let mut col = if t < 0.5 { + lerp3(BOTTOM, HORIZON, t * 2.0) + } else { + lerp3(HORIZON, TOP, (t - 0.5) * 2.0) + }; + let dku = u - KEY_U; + let dku = (dku - dku.round()).abs(); // wrap U to [-0.5, 0.5] + let dkv = v - KEY_V; + let w_key = (-(dku * dku + dkv * dkv) / (2.0 * KEY_SIGMA * KEY_SIGMA)).exp(); + col[0] = col[0].max(KEY_INTENSITY[0] * w_key); + col[1] = col[1].max(KEY_INTENSITY[1] * w_key); + col[2] = col[2].max(KEY_INTENSITY[2] * w_key); + let dru = u - RIM_U; + let dru = (dru - dru.round()).abs(); + let drv = v - RIM_V; + let w_rim = (-(dru * dru + drv * drv) / (2.0 * RIM_SIGMA * RIM_SIGMA)).exp(); + col[0] = col[0].max(RIM_INTENSITY[0] * w_rim); + col[1] = col[1].max(RIM_INTENSITY[1] * w_rim); + col[2] = col[2].max(RIM_INTENSITY[2] * w_rim); + out.push(col[0]); + out.push(col[1]); + out.push(col[2]); + out.push(1.0); + } + } + out +} + +fn _generate_equirect_debug(w: u32, h: u32) -> Vec { + const TILES_U: u32 = 8; + const TILES_V: u32 = 4; + let mut out = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let u = (x as f32 + 0.5) / w as f32; + let v = (y as f32 + 0.5) / h as f32; + let ti = (u * TILES_U as f32) as u32 % TILES_U; + let tj = (v * TILES_V as f32) as u32 % TILES_V; + let checker = (ti + tj) % 2 == 0; + let origin = ti == 0 && tj == 2; + let col: [f32; 4] = if origin { + [4.0, 0.0, 0.0, 1.0] + } else if checker { + [3.0, 3.0, 3.0, 1.0] + } else { + [0.05, 0.05, 0.05, 1.0] + }; + out.extend_from_slice(&col); + } + } + out +} + +// --- shared utilities --- + +// RTEX v2: Rgba32Float (4 bytes per channel, f32 LE), linear HDR. +pub(crate) fn write_rtex_hdr(path: &Path, w: u32, h: u32, rgba_f32: &[f32]) -> Result<()> { + let mut out = Vec::with_capacity(16 + rgba_f32.len() * 4); + out.extend_from_slice(b"RTEX"); + out.extend_from_slice(&2u32.to_le_bytes()); // version 2 = Rgba32Float HDR + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + for &v in rgba_f32 { + out.extend_from_slice(&v.to_le_bytes()); + } + std::fs::write(path, &out).with_context(|| format!("write_rtex_hdr {path:?}"))?; + Ok(()) +} + +// RTEX v4: Rgba32Float mip chain for HDR specular prefilter. +// Header: RTEX | 4 | w | h | mip_count | mip0_f32_pixels | mip1_f32_pixels | ... +pub(crate) fn write_rtex_hdr_mip(path: &Path, w: u32, h: u32, mips: &[Vec]) -> Result<()> { + let mut out = Vec::new(); + out.extend_from_slice(b"RTEX"); + out.extend_from_slice(&4u32.to_le_bytes()); + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + out.extend_from_slice(&(mips.len() as u32).to_le_bytes()); + for mip in mips { + for &v in mip { + out.extend_from_slice(&v.to_le_bytes()); + } + } + std::fs::write(path, &out).with_context(|| format!("write_rtex_hdr_mip {path:?}"))?; + Ok(()) +} + +// RTEX v1: Rgba8UnormSrgb (legacy LDR IBL outputs). +pub(crate) fn write_rtex(path: &Path, w: u32, h: u32, rgba: &[u8]) -> Result<()> { + let mut out = Vec::with_capacity(16 + rgba.len()); + out.extend_from_slice(b"RTEX"); + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + out.extend_from_slice(rgba); + std::fs::write(path, &out).with_context(|| format!("write_rtex {path:?}"))?; + Ok(()) +} + +pub(crate) fn linear_to_srgb(x: f32) -> u8 { + let c = x.clamp(0.0, 1.0); + let s = if c <= 0.003_130_8 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + }; + (s * 255.0 + 0.5) as u8 +} + +pub(crate) fn srgb_to_linear(x: f32) -> f32 { + if x <= 0.040_45 { + x / 12.92 + } else { + ((x + 0.055) / 1.055).powf(2.4) + } +} + +pub(crate) fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ] +} diff --git a/engine/pill_engine/res/shaders/background_fragment.hlsl b/engine/pill_engine/res/shaders/background_fragment.hlsl new file mode 100644 index 00000000..148a23cb --- /dev/null +++ b/engine/pill_engine/res/shaders/background_fragment.hlsl @@ -0,0 +1,31 @@ +struct SkyCam { + float3 right; + float tan_half_fov; + float3 up; + float aspect; + float3 fwd; + float _pad; +}; +[[vk::binding(0, 0)]] ConstantBuffer UCam; +[[vk::binding(1, 0)]] Texture2D texEquirect; +[[vk::binding(2, 0)]] SamplerState smpEquirect; + +static const float PI = 3.14159265359; + +float2 dir_to_equirect_uv(float3 dir) { + float3 d = normalize(dir); + float u = 0.5 + atan2(d.z, d.x) / (2.0 * PI); + float v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) / PI; + return float2(frac(u), clamp(v, 0.0, 1.0)); +} + +[shader("fragment")] +float4 fs_main(float2 ndc : TEXCOORD0) : SV_Target { + float3 dir = normalize( + UCam.right * (ndc.x * UCam.tan_half_fov * UCam.aspect) + + UCam.up * (ndc.y * UCam.tan_half_fov) + + UCam.fwd + ); + float2 uv = dir_to_equirect_uv(dir); + return texEquirect.Sample(smpEquirect, uv); +} diff --git a/engine/pill_engine/res/shaders/background_vertex.hlsl b/engine/pill_engine/res/shaders/background_vertex.hlsl new file mode 100644 index 00000000..8170c5c0 --- /dev/null +++ b/engine/pill_engine/res/shaders/background_vertex.hlsl @@ -0,0 +1,17 @@ +struct VOut { + float4 pos : SV_Position; + float2 ndc : TEXCOORD0; // NDC XY passed to fragment for ray reconstruction +}; + +// Full-screen triangle: 3 vertices cover NDC [-1,1]² without a vertex buffer. +VOut vs_main(uint vi : SV_VertexID) { + float2 p[3] = { + float2(-1.0, -3.0), + float2( 3.0, 1.0), + float2(-1.0, 1.0) + }; + VOut o; + o.pos = float4(p[vi], 0.0, 1.0); + o.ndc = p[vi]; + return o; +} diff --git a/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl b/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl index b449740e..4346708e 100644 --- a/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl +++ b/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl @@ -15,32 +15,37 @@ struct Camera { [[vk::binding(5, 0)]] Texture2D texBrdfLut; [[vk::binding(6, 0)]] SamplerState smpBrdfLut; -// PBR material textures (set 1) — bindings match DEFAULT_LIT_SHADER layout (0-3). +// PBR material textures (set 1) — bindings match DEFAULT_LIT_SHADER layout (0-5). [[vk::binding(0, 1)]] Texture2D texBaseColor; [[vk::binding(1, 1)]] SamplerState smpBaseColor; [[vk::binding(2, 1)]] Texture2D texNormal; [[vk::binding(3, 1)]] SamplerState smpNormal; +[[vk::binding(4, 1)]] Texture2D texMetallicRoughness; +[[vk::binding(5, 1)]] SamplerState smpMetallicRoughness; -// PBR params UBO (set 2) — 32 bytes: baseColorFactor (vec3 + pad), roughnessFactor (f32 + pad + vec2 pad). +// PBR params UBO (set 2) — 48 bytes: 3 × 16-byte slots. +// Each scalar slot uses float+float+float2 padding to stay 16 bytes without float3 alignment gaps. struct MaterialParams { float3 baseColorFactor; float _pad0; float roughnessFactor; float _pad1; float2 _pad2; + float metallicFactor; + float _pad3; + float2 _pad4; }; [[vk::binding(0, 2)]] ConstantBuffer UMaterial; static const float PI = 3.14159265359; -// Old renderer used a point light at (-10,10,-10); the resulting direction from light -// to scene (z≈12) is approximately (0.38,-0.38,0.84) — behind and above the camera. -static const float3 LIGHT_DIR0 = float3( 0.38, -0.38, 0.84); // key: behind-camera, upper-left -static const float3 LIGHT_DIR1 = float3(-0.50, 0.50, -0.71); // rim: front upper-right +// Camera at +Z looking -Z (glTF default). Z components flipped vs. -Z camera setup. +static const float3 LIGHT_DIR0 = float3( 0.38, -0.38, -0.84); // key: behind-camera, upper-left +static const float3 LIGHT_DIR1 = float3(-0.50, 0.50, 0.71); // rim: front upper-right static const float3 LIGHT_DIR2 = float3( 0.00, -1.00, 0.00); // bounce: from below -static const float4 LIGHT_COL0 = float4(1.0, 0.90, 0.80, 8.0); // warm key -static const float4 LIGHT_COL1 = float4(0.5, 0.55, 1.00, 2.5); // cool rim -static const float4 LIGHT_COL2 = float4(0.8, 0.80, 0.80, 0.8); // neutral bounce +static const float4 LIGHT_COL0 = float4(1.0, 0.98, 0.95, 2.2); // near-neutral key +static const float4 LIGHT_COL1 = float4(0.6, 0.65, 1.00, 0.8); // cool rim +static const float4 LIGHT_COL2 = float4(0.8, 0.80, 0.80, 0.3); // neutral bounce float DistributionGGX(float3 N, float3 H, float roughness) { // Add epsilon to avoid singularities at very low roughness. @@ -74,7 +79,7 @@ float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness) { float2 dir_to_equirect_uv(float3 dir) { float3 d = normalize(dir); - float u = 0.5 + atan2(d.x, -d.z) / (2.0 * PI); + float u = 0.5 + atan2(d.z, d.x) / (2.0 * PI); // match bake convention: atan2(z,x) float v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) / PI; return float2(frac(u), clamp(v, 0.0, 1.0)); } @@ -104,10 +109,10 @@ float4 fs_main( [[vk::location(2)]] float3 NormalIn: TEXCOORD2 ) : SV_TARGET { float3 albedo = texBaseColor.Sample(smpBaseColor, uv).rgb * UMaterial.baseColorFactor; - // roughnessFactor maps to specularity slot (offset 16 in current material layout). - // metallic is not stored yet — default to 0.0 (dielectric). - float roughness = clamp(UMaterial.roughnessFactor, 0.045, 0.99); - float metallic = 0.0; + float2 mr = texMetallicRoughness.Sample(smpMetallicRoughness, uv).gb; + // mr.x = G channel (roughness 0=smooth, 1=rough); mr.y = B channel (metallic 0=dielectric, 1=metal) + float roughness = clamp(mr.x * (1.0 - UMaterial.roughnessFactor), 0.045, 0.99); + float metallic = mr.y * UMaterial.metallicFactor; // TODO: Support normal mapping (tangent space) and AO texture. float3 N = normalize(NormalIn); float3 V = normalize(UCamera.position.xyz - WorldPos); @@ -128,7 +133,6 @@ float4 fs_main( float2 envBRDF = texBrdfLut.Sample(smpBrdfLut, float2(max(dot(N, V), 0.0), roughness)).rg; float3 F = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness); float3 specularIBL = prefilteredColor * (F * envBRDF.x + envBRDF.y); - float3 ambient = float3(0.04, 0.04, 0.04) * albedo; - float3 color = Lo + ambientDiffuse + specularIBL + ambient; + float3 color = Lo + ambientDiffuse + specularIBL; return float4(color, 1.0); } diff --git a/engine/pill_engine/res/shaders/tonemap_fragment.hlsl b/engine/pill_engine/res/shaders/tonemap_fragment.hlsl new file mode 100644 index 00000000..3f68b9d5 --- /dev/null +++ b/engine/pill_engine/res/shaders/tonemap_fragment.hlsl @@ -0,0 +1,9 @@ +[[vk::binding(0, 0)]] Texture2D texHdr; +[[vk::binding(1, 0)]] SamplerState smpHdr; + +[shader("fragment")] +float4 fs_main(float2 uv : TEXCOORD0) : SV_Target { + float3 hdr = texHdr.Sample(smpHdr, uv).rgb; + float3 mapped = hdr / (hdr + 1.0); + return float4(mapped, 1.0); +} diff --git a/engine/pill_engine/res/shaders/tonemap_vertex.hlsl b/engine/pill_engine/res/shaders/tonemap_vertex.hlsl new file mode 100644 index 00000000..66a8a1a1 --- /dev/null +++ b/engine/pill_engine/res/shaders/tonemap_vertex.hlsl @@ -0,0 +1,16 @@ +struct VOut { + float4 pos : SV_Position; + float2 uv : TEXCOORD0; +}; + +VOut vs_main(uint vi : SV_VertexID) { + float2 p[3] = { + float2(-1.0, -3.0), + float2( 3.0, 1.0), + float2(-1.0, 1.0) + }; + VOut o; + o.pos = float4(p[vi], 0.0, 1.0); + o.uv = float2(p[vi].x * 0.5 + 0.5, -p[vi].y * 0.5 + 0.5); + return o; +} diff --git a/engine/pill_engine/res/textures/studio.studio b/engine/pill_engine/res/textures/studio.studio new file mode 100644 index 00000000..e69de29b diff --git a/engine/pill_engine/src/config.rs b/engine/pill_engine/src/config.rs index a21db6f7..8d51182b 100644 --- a/engine/pill_engine/src/config.rs +++ b/engine/pill_engine/src/config.rs @@ -86,6 +86,7 @@ pub const MAX_SOUNDS: usize = 10; pub const DEFAULT_RESOURCE_PREFIX: &str = "pill_engine"; pub const DEFAULT_COLOR_TEXTURE_NAME: &str = "pill_engine_default_color"; pub const DEFAULT_NORMAL_TEXTURE_NAME: &str = "pill_engine_default_normal"; +pub const DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME: &str = "pill_engine_default_metallic_roughness"; // RTEX layout: b"RTEX" | u32LE version=1 | u32LE width | u32LE height | raw RGBA bytes pub const DEFAULT_COLOR_TEXTURE_BYTES: [u8; 20] = [ @@ -96,6 +97,10 @@ pub const DEFAULT_NORMAL_TEXTURE_BYTES: [u8; 20] = [ b'R', b'T', b'E', b'X', 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 128, 128, 255, 255, // flat normal (0,0,1) ]; +// Neutral metallic_roughness: G=255 (max roughness → scalar passes through), B=0 (metallic=0) +pub const DEFAULT_METALLIC_ROUGHNESS_TEXTURE_BYTES: [u8; 20] = [ + b'R', b'T', b'E', b'X', 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 255, 0, 255, +]; // Default lit shader pub const DEFAULT_LIT_SHADER_NAME: &str = "pill_engine_default_lit_shader"; @@ -103,8 +108,11 @@ pub const DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_NAME: &str = "color"; pub const DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_BINDINGS: (u32, u32) = (0, 1); pub const DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_NAME: &str = "normal"; pub const DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_BINDINGS: (u32, u32) = (2, 3); +pub const DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_NAME: &str = "metallic_roughness"; +pub const DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_BINDINGS: (u32, u32) = (4, 5); pub const DEFAULT_LIT_SHADER_TINT_PARAMETER_SLOT_NAME: &str = "tint"; pub const DEFAULT_LIT_SHADER_SPECULARITY_PARAMETER_SLOT_NAME: &str = "specularity"; +pub const DEFAULT_LIT_SHADER_METALLIC_FACTOR_PARAMETER_SLOT_NAME: &str = "metallic_factor"; pub const DEFAULT_LIT_MATERIAL_NAME: &str = "pill_engine_default_lit_material"; pub const DEFAULT_UNLIT_SHADER_NAME: &str = "pill_engine_default_unlit_shader"; @@ -143,6 +151,18 @@ pub const DEFAULT_RENDERER_NORMAL_TEXTURE_HANDLE: RendererTextureHandle = version: std::num::NonZeroU32::new(1).unwrap(), }); +// Default resource handle - MetallicRoughness texture +pub const DEFAULT_METALLIC_ROUGHNESS_TEXTURE_HANDLE: TextureHandle = + TextureHandle(PillSlotMapKeyData { + index: 3, + version: std::num::NonZeroU32::new(1).unwrap(), + }); +pub const DEFAULT_RENDERER_METALLIC_ROUGHNESS_TEXTURE_HANDLE: RendererTextureHandle = + RendererTextureHandle(PillSlotMapKeyData { + index: 3, + version: std::num::NonZeroU32::new(1).unwrap(), + }); + pub fn get_default_texture_handles( texture_type: TextureType, ) -> (TextureHandle, RendererTextureHandle) { @@ -155,6 +175,10 @@ pub fn get_default_texture_handles( DEFAULT_NORMAL_TEXTURE_HANDLE, DEFAULT_RENDERER_NORMAL_TEXTURE_HANDLE, ), + TextureType::MetallicRoughness => ( + DEFAULT_METALLIC_ROUGHNESS_TEXTURE_HANDLE, + DEFAULT_RENDERER_METALLIC_ROUGHNESS_TEXTURE_HANDLE, + ), } } diff --git a/engine/pill_engine/src/ecs/components/camera_component.rs b/engine/pill_engine/src/ecs/components/camera_component.rs index 00fa3d6a..617fa8a8 100644 --- a/engine/pill_engine/src/ecs/components/camera_component.rs +++ b/engine/pill_engine/src/ecs/components/camera_component.rs @@ -72,6 +72,11 @@ impl CameraComponentBuilder { self } + pub fn look_at(mut self, target: Option) -> Self { + self.component.look_at = target; + self + } + pub fn build(self) -> CameraComponent { self.component } @@ -84,6 +89,7 @@ pub struct CameraComponent { pub fov: f32, pub range: Range, pub clear_color: Vector3f, + pub look_at: Option, // Exponential-squared fog: final_color is mixed toward fog_color by // 1 - exp(-density² · distance²). density = 0.0 disables fog (default). pub fog_density: f32, @@ -111,6 +117,7 @@ impl CameraComponent { clear_color: Vector3f::new(0.15, 0.15, 0.15), fog_density: 0.0, fog_color: Vector3f::new(0.0, 0.0, 0.0), + look_at: None, renderer_resource_handle: None, enabled: false, } diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index b6eda158..b9d21248 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -165,6 +165,10 @@ impl Engine { DEFAULT_LIT_SHADER_SPECULARITY_PARAMETER_SLOT_NAME.to_string(), ShaderParameterSlot::new(ShaderParameterType::Scalar), ), + ( + DEFAULT_LIT_SHADER_METALLIC_FACTOR_PARAMETER_SLOT_NAME.to_string(), + ShaderParameterSlot::new(ShaderParameterType::Scalar), + ), ] .into_iter() .collect(), @@ -183,6 +187,13 @@ impl Engine { DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_BINDINGS, ), ), + ( + DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_NAME.to_string(), + ShaderTextureSlot::new( + TextureType::MetallicRoughness, + DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_BINDINGS, + ), + ), ] .into_iter() .collect(), @@ -237,6 +248,15 @@ impl Engine { ))?; debug!(LogContext::Engine => "Default normal texture {} created", DEFAULT_NORMAL_TEXTURE_NAME.name_style()); + debug!(LogContext::Engine => "Creating default metallic_roughness texture {}...", DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME.name_style()); + + let default_mr_texture_handle = self.add_default_resource(Texture::new( + DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME, + TextureType::MetallicRoughness, + ResourceLoader::Bytes(Box::new(DEFAULT_METALLIC_ROUGHNESS_TEXTURE_BYTES)), + ))?; + + debug!(LogContext::Engine => "Default metallic_roughness texture {} created", DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME.name_style()); debug!(LogContext::Engine => "Creating default material {}...", DEFAULT_LIT_MATERIAL_NAME.name_style()); // Create default lit material @@ -248,6 +268,7 @@ impl Engine { Color::new(1.0, 1.0, 1.0), )? .scalar_parameter(DEFAULT_LIT_SHADER_SPECULARITY_PARAMETER_SLOT_NAME, 0.5)? + .scalar_parameter(DEFAULT_LIT_SHADER_METALLIC_FACTOR_PARAMETER_SLOT_NAME, 0.0)? .texture( DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_NAME, default_color_texture_handle, @@ -256,6 +277,10 @@ impl Engine { DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_NAME, default_normal_texture_handle, )? + .texture( + DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_NAME, + default_mr_texture_handle, + )? .build(), )?; diff --git a/engine/pill_engine/src/graphics/mod.rs b/engine/pill_engine/src/graphics/mod.rs index 6617844c..d3827263 100644 --- a/engine/pill_engine/src/graphics/mod.rs +++ b/engine/pill_engine/src/graphics/mod.rs @@ -2,10 +2,12 @@ #[cfg(feature = "headless")] mod dummy_renderer; +mod pass_background; #[cfg(feature = "debug_ui")] mod pass_egui; mod pass_mesh; mod pass_pbr_static; +mod pass_tonemap; mod render_queue; mod renderer; @@ -19,10 +21,12 @@ pub use renderer::{ #[cfg(feature = "headless")] pub use self::dummy_renderer::DummyRenderer; +pub use pass_background::PassBackground; #[cfg(feature = "debug_ui")] pub use pass_egui::PassEgui; pub use pass_mesh::PassMesh; pub use pass_pbr_static::PassPBRStatic; +pub use pass_tonemap::PassTonemap; pub use render_queue::{ compose_pbr_render_queue_key, compose_render_queue_key, decompose_render_queue_key, diff --git a/engine/pill_engine/src/graphics/pass_background.rs b/engine/pill_engine/src/graphics/pass_background.rs new file mode 100644 index 00000000..bc599a20 --- /dev/null +++ b/engine/pill_engine/src/graphics/pass_background.rs @@ -0,0 +1,284 @@ +use crate::{ + ecs::{CameraComponent, ComponentStorage, TransformComponent}, + graphics::{ + Pass, PillRenderer, PipelineV2, PipelineV2Desc, RendererTextureHandle, ShaderDesc, + WorldQuery, + }, +}; +use glam::{Mat3, Vec3}; +use pill_core::{PillSlotMapKey, Result}; + +static VS: &str = include_str!("../../res/shaders/background_vertex.wgsl"); +static FS: &str = include_str!("../../res/shaders/background_fragment.wgsl"); + +static EQUIRECT_BYTES: &[u8] = include_bytes!("../../res/textures/studio_equirect.cooked_tex"); + +pub struct PassBackground { + hdr_target: RendererTextureHandle, + state: Option, +} + +struct BgState { + pipeline: PipelineV2, + bind_group: wgpu::BindGroup, + camera_buffer: wgpu::Buffer, + _texture: wgpu::Texture, + _sampler: wgpu::Sampler, +} + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct BgCameraUbo { + right: [f32; 3], + tan_half_fov: f32, + up: [f32; 3], + aspect: f32, + fwd: [f32; 3], + _pad: f32, +} + +impl PassBackground { + pub fn new(hdr_target: RendererTextureHandle) -> Self { + Self { + hdr_target, + state: None, + } + } +} + +fn decode_rtex(bytes: &[u8]) -> (&[u8], u32, u32, u32) { + let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap()); + let w = u32::from_le_bytes(bytes[8..12].try_into().unwrap()); + let h = u32::from_le_bytes(bytes[12..16].try_into().unwrap()); + (&bytes[16..], w, h, version) +} + +impl Pass for PassBackground { + fn get_label(&self) -> &str { + "pass_background" + } + + fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { + let bind_groups = vec![vec![ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ]]; + + let pipeline = renderer.create_pipeline_v2(PipelineV2Desc { + label: Some("pass_background"), + vs: ShaderDesc { + source: VS, + entry_func: "vs_main", + }, + ps: ShaderDesc { + source: FS, + entry_func: "fs_main", + }, + vertex_buffers: &[], + bind_groups, + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba16Float, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: false, + }, + })?; + + let (raw, w, h, version) = decode_rtex(EQUIRECT_BYTES); + let size = wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }; + let (tex_format, bytes_per_channel) = if version == 2 { + (wgpu::TextureFormat::Rgba32Float, 4u32) + } else { + (wgpu::TextureFormat::Rgba8UnormSrgb, 1u32) + }; + let device = renderer.get_device(); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("studio_equirect"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: tex_format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + renderer.get_queue().write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + raw, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * bytes_per_channel * w), + rows_per_image: Some(h), + }, + size, + ); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let sampler = renderer + .get_device() + .create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, // equirect wraps in U + address_mode_v: wgpu::AddressMode::ClampToEdge, // poles clamp in V + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + let camera_buffer = renderer + .get_device() + .create_buffer(&wgpu::BufferDescriptor { + label: Some("pass_background_camera"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let layout_ptr: *const wgpu::BindGroupLayout = &pipeline.bind_group_layouts[0] as *const _; + let bind_group = renderer + .get_device() + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_background_bind_group"), + layout: unsafe { &*layout_ptr }, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + + self.state = Some(BgState { + pipeline, + bind_group, + camera_buffer, + _texture: texture, + _sampler: sampler, + }); + Ok(()) + } + + fn draw( + &mut self, + encoder: &mut wgpu::CommandEncoder, + renderer: &mut dyn PillRenderer, + _frame: &wgpu::SurfaceTexture, + _view: &wgpu::TextureView, + world: &WorldQuery<'_>, + ) -> Result<()> { + let state = self.state.as_ref().unwrap(); + + // Compute inv_view_proj from active camera — same matrices as PBR pass. + let active_idx = world.active_camera.data().index as usize; + let cam = world + .camera_components + .data + .get(active_idx) + .unwrap() + .as_ref() + .unwrap(); + let tfm = world + .transform_components + .data + .get(active_idx) + .unwrap() + .as_ref() + .unwrap(); + + let eye = Vec3::new(tfm.position.x, tfm.position.y, tfm.position.z); + let fwd = if let Some(t) = cam.look_at { + (Vec3::new(t.x, t.y, t.z) - eye).normalize() + } else { + let roll = Mat3::from_rotation_z(tfm.rotation.z.to_radians()); + let yaw = Mat3::from_rotation_y(tfm.rotation.y.to_radians()); + let pitch = Mat3::from_rotation_x(tfm.rotation.x.to_radians()); + (yaw * pitch * roll) * Vec3::Z + }; + let right = fwd.cross(Vec3::Y).normalize(); + let up = right.cross(fwd); + + let ubo = BgCameraUbo { + right: right.to_array(), + tan_half_fov: (cam.fov.to_radians() / 2.0).tan(), + up: up.to_array(), + aspect: cam.aspect.get_value(), + fwd: fwd.to_array(), + _pad: 0.0, + }; + renderer + .get_queue() + .write_buffer(&state.camera_buffer, 0, bytemuck::bytes_of(&ubo)); + + let hdr_view = renderer.get_render_target_view(self.hdr_target).unwrap(); + let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("pass_background_render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: hdr_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rp.set_pipeline(&state.pipeline.pipeline); + rp.set_bind_group(0, &state.bind_group, &[]); + rp.draw(0..3, 0..1); + Ok(()) + } +} diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_static.rs index eee83d7a..a3a14ad9 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_static.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_static.rs @@ -39,22 +39,23 @@ impl CameraUniform { 0.0, ]; - // View matrix: engine uses +Z forward convention; look_to_lh preserves right-handed winding - // by computing right = up × (-dir) = +X, avoiding the NDC x-flip that look_to_rh produces. - let roll_matrix = Mat3::from_rotation_z(transform_component.rotation.z.to_radians()); - let yaw_matrix = Mat3::from_rotation_y(transform_component.rotation.y.to_radians()); - let pitch_matrix = Mat3::from_rotation_x(transform_component.rotation.x.to_radians()); - let rotation_matrix = yaw_matrix * pitch_matrix * roll_matrix; - let direction = rotation_matrix * Vec3::Z; let eye = Vec3::new( transform_component.position.x, transform_component.position.y, transform_component.position.z, ); - let view = Mat4::look_to_lh(eye, direction, Vec3::Y); + let view = if let Some(t) = camera_component.look_at { + Mat4::look_at_rh(eye, Vec3::new(t.x, t.y, t.z), Vec3::Y) + } else { + let roll_matrix = Mat3::from_rotation_z(transform_component.rotation.z.to_radians()); + let yaw_matrix = Mat3::from_rotation_y(transform_component.rotation.y.to_radians()); + let pitch_matrix = Mat3::from_rotation_x(transform_component.rotation.x.to_radians()); + let rotation_matrix = yaw_matrix * pitch_matrix * roll_matrix; + let direction = rotation_matrix * Vec3::Z; + Mat4::look_to_rh(eye, direction, Vec3::Y) + }; - // Projection: perspective_lh maps view-z ∈ [z_near, z_far] to NDC depth [0,1] for +Z forward. - let proj = Mat4::perspective_lh( + let proj = Mat4::perspective_rh( camera_component.fov.to_radians(), camera_component.aspect.get_value(), camera_component.range.start, @@ -118,9 +119,12 @@ struct PassPBRStaticState { globals_bind_group: wgpu::BindGroup, pipeline: PipelineV2, ibl_sampler: wgpu::Sampler, - // 1×1 white fallback used when no IBL textures are provided. ibl_fallback_texture: wgpu::Texture, ibl_fallback_view: wgpu::TextureView, + // Studio IBL textures loaded from embedded cooked assets. + _ibl_irradiance_texture: wgpu::Texture, + _ibl_prefilter_texture: wgpu::Texture, + _ibl_brdf_lut_texture: wgpu::Texture, per_draw_buffer: wgpu::Buffer, per_draw_bind_group: wgpu::BindGroup, } @@ -128,6 +132,7 @@ struct PassPBRStaticState { /// Default PBR pass: full GGX shading with optional IBL, per-draw dynamic UBO ring. /// No MeshDrawer — each entity is drawn with a direct indexed draw call. pub struct PassPBRStatic { + color_target: Option, ibl_irradiance_tex: Option, ibl_prefilter_tex: Option, ibl_brdf_lut_tex: Option, @@ -143,11 +148,13 @@ pub struct PassPBRStatic { impl PassPBRStatic { /// Creates the pass with optional IBL textures; `Pass::init` must run before the first frame. pub fn new( + color_target: Option, ibl_irradiance_tex: Option, ibl_prefilter_tex: Option, ibl_brdf_lut_tex: Option, ) -> Self { Self { + color_target, ibl_irradiance_tex, ibl_prefilter_tex, ibl_brdf_lut_tex, @@ -164,6 +171,85 @@ impl PassPBRStatic { } } +/// Decode an RTEX header: returns (rgba_bytes, width, height). +fn decode_rtex(bytes: &[u8]) -> (&[u8], u32, u32, u32, u32) { + let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap()); + let w = u32::from_le_bytes(bytes[8..12].try_into().unwrap()); + let h = u32::from_le_bytes(bytes[12..16].try_into().unwrap()); + if version == 3 || version == 4 { + let mip_count = u32::from_le_bytes(bytes[16..20].try_into().unwrap()); + (&bytes[20..], w, h, mip_count, version) + } else { + (&bytes[16..], w, h, 1, version) + } +} + +/// Upload an RTEX blob as a 2D texture. +/// v1/v3 = Rgba8UnormSrgb (LDR); v4 = Rgba32Float HDR mip chain. +fn load_ibl_texture( + renderer: &dyn PillRenderer, + label: &str, + bytes: &[u8], + is_srgb: bool, +) -> (wgpu::Texture, wgpu::TextureView) { + let (data, w, h, mip_count, version) = decode_rtex(bytes); + let size = wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }; + let (format, bytes_per_texel) = if version == 4 { + (wgpu::TextureFormat::Rgba32Float, 16usize) // 4 × f32 + } else if is_srgb { + (wgpu::TextureFormat::Rgba8UnormSrgb, 4usize) + } else { + (wgpu::TextureFormat::Rgba8Unorm, 4usize) + }; + let tex = renderer + .get_device() + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size, + mip_level_count: mip_count, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let mut offset = 0usize; + for mip in 0..mip_count { + let mip_w = (w >> mip).max(1); + let mip_h = (h >> mip).max(1); + let mip_bytes = (mip_w * mip_h) as usize * bytes_per_texel; + renderer.get_queue().write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: mip, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &data[offset..offset + mip_bytes], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(mip_w * bytes_per_texel as u32), + rows_per_image: Some(mip_h), + }, + wgpu::Extent3d { + width: mip_w, + height: mip_h, + depth_or_array_layers: 1, + }, + ); + offset += mip_bytes; + } + let view = tex.create_view(&wgpu::TextureViewDescriptor { + mip_level_count: Some(mip_count), + ..Default::default() + }); + (tex, view) +} + /// Returns the initialized pass state; panics in debug if `init` was not called. fn get_state(pass: &mut PassPBRStatic) -> &mut PassPBRStaticState { debug_assert!(pass.state.is_some()); @@ -181,7 +267,6 @@ impl Pass for PassPBRStatic { let fragment_wgsl = include_str!("../../res/shaders/pbr_static_fragment.wgsl"); - let surface_format = renderer.get_surface_format(); let bind_groups: Vec> = vec![ // 0: globals (camera + IBL irradiance + prefilter + BRDF LUT) vec![ @@ -244,7 +329,7 @@ impl Pass for PassPBRStatic { count: None, }, ], - // 1: material textures (base_color, normal, metallic_roughness, emissive) + // 1: material textures (base_color, normal, metallic_roughness) vec![ wgpu::BindGroupLayoutEntry { binding: 0, @@ -278,6 +363,22 @@ impl Pass for PassPBRStatic { ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, ], // 2: material params vec![wgpu::BindGroupLayoutEntry { @@ -321,7 +422,7 @@ impl Pass for PassPBRStatic { ], bind_groups, targets: &[Some(wgpu::ColorTargetState { - format: surface_format, + format: wgpu::TextureFormat::Rgba16Float, blend: Some(wgpu::BlendState::REPLACE), write_mask: wgpu::ColorWrites::ALL, })], @@ -337,8 +438,7 @@ impl Pass for PassPBRStatic { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - // Pill mesh uses CW exterior winding; disable culling to match old renderer behavior. - cull_mode: None, + cull_mode: Some(wgpu::Face::Back), polygon_mode: wgpu::PolygonMode::Fill, conservative: false, unclipped_depth: false, @@ -437,19 +537,19 @@ impl Pass for PassPBRStatic { }) }; - // Resolve IBL texture views; fall back to the 1×1 white texture when not provided. - let irradiance_view = self - .ibl_irradiance_tex - .and_then(|handle| renderer.get_render_target_view(handle)) - .unwrap_or(&ibl_fallback_view); - let prefilter_view = self - .ibl_prefilter_tex - .and_then(|handle| renderer.get_render_target_view(handle)) - .unwrap_or(&ibl_fallback_view); - let brdf_lut_view = self - .ibl_brdf_lut_tex - .and_then(|handle| renderer.get_render_target_view(handle)) - .unwrap_or(&ibl_fallback_view); + // Load studio IBL textures from embedded cooked assets. + static DIFFUSE_IBL: &[u8] = + include_bytes!("../../res/textures/studio_diffuse_ibl.cooked_tex"); + static SPECULAR_IBL: &[u8] = + include_bytes!("../../res/textures/studio_specular_ibl.cooked_tex"); + static BRDF_LUT: &[u8] = include_bytes!("../../res/textures/brdf_lut.cooked_tex"); + + let (ibl_irradiance_texture, irradiance_view) = + load_ibl_texture(renderer, "studio_diffuse_ibl", DIFFUSE_IBL, true); + let (ibl_prefilter_texture, prefilter_view) = + load_ibl_texture(renderer, "studio_specular_ibl", SPECULAR_IBL, true); + let (ibl_brdf_lut_texture, brdf_lut_view) = + load_ibl_texture(renderer, "brdf_lut", BRDF_LUT, false); // linear let prefilter_sampler = { let device = renderer.get_device(); @@ -480,7 +580,7 @@ impl Pass for PassPBRStatic { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::TextureView(irradiance_view), + resource: wgpu::BindingResource::TextureView(&irradiance_view), }, wgpu::BindGroupEntry { binding: 2, @@ -488,7 +588,7 @@ impl Pass for PassPBRStatic { }, wgpu::BindGroupEntry { binding: 3, - resource: wgpu::BindingResource::TextureView(prefilter_view), + resource: wgpu::BindingResource::TextureView(&prefilter_view), }, wgpu::BindGroupEntry { binding: 4, @@ -496,7 +596,7 @@ impl Pass for PassPBRStatic { }, wgpu::BindGroupEntry { binding: 5, - resource: wgpu::BindingResource::TextureView(brdf_lut_view), + resource: wgpu::BindingResource::TextureView(&brdf_lut_view), }, wgpu::BindGroupEntry { binding: 6, @@ -516,6 +616,9 @@ impl Pass for PassPBRStatic { ibl_sampler, ibl_fallback_texture, ibl_fallback_view, + _ibl_irradiance_texture: ibl_irradiance_texture, + _ibl_prefilter_texture: ibl_prefilter_texture, + _ibl_brdf_lut_texture: ibl_brdf_lut_texture, per_draw_buffer, per_draw_bind_group, }); @@ -562,7 +665,7 @@ impl Pass for PassPBRStatic { Mat4::from_cols_array_2d(&state.camera_uniform.view_projection_matrix) }; - let clear_color = active_camera_component.clear_color; + let _clear_color = active_camera_component.clear_color; // Build visible set from the render queue. self.visible_pre_draw_buffer.clear(); @@ -697,19 +800,18 @@ impl Pass for PassPBRStatic { let depth_view = renderer .get_render_target_view(self.depth_texture.unwrap()) .unwrap(); + let color_view = self + .color_target + .and_then(|h| renderer.get_render_target_view(h)) + .unwrap_or(view); let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("pass_pbr_static_render_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view, + view: color_view, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: clear_color.x as f64, - g: clear_color.y as f64, - b: clear_color.z as f64, - a: 1.0, - }), + load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], diff --git a/engine/pill_engine/src/graphics/pass_tonemap.rs b/engine/pill_engine/src/graphics/pass_tonemap.rs new file mode 100644 index 00000000..aaf31791 --- /dev/null +++ b/engine/pill_engine/src/graphics/pass_tonemap.rs @@ -0,0 +1,154 @@ +use crate::graphics::{ + Pass, PillRenderer, PipelineV2, PipelineV2Desc, RendererTextureHandle, ShaderDesc, WorldQuery, +}; + +use pill_core::Result; + +static VS: &str = include_str!("../../res/shaders/tonemap_vertex.wgsl"); +static FS: &str = include_str!("../../res/shaders/tonemap_fragment.wgsl"); + +pub struct PassTonemap { + hdr_source: RendererTextureHandle, + state: Option, +} + +struct TonemapState { + pipeline: PipelineV2, + sampler: wgpu::Sampler, +} + +impl PassTonemap { + pub fn new(hdr_source: RendererTextureHandle) -> Self { + Self { + hdr_source, + state: None, + } + } +} + +impl Pass for PassTonemap { + fn get_label(&self) -> &str { + "pass_tonemap" + } + + fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { + let surface_format = renderer.get_surface_format(); + + let bind_groups = vec![vec![ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ]]; + + let pipeline = renderer.create_pipeline_v2(PipelineV2Desc { + label: Some("pass_tonemap"), + vs: ShaderDesc { + source: VS, + entry_func: "vs_main", + }, + ps: ShaderDesc { + source: FS, + entry_func: "fs_main", + }, + vertex_buffers: &[], + bind_groups, + targets: &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: false, + }, + })?; + + let sampler = renderer + .get_device() + .create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + self.state = Some(TonemapState { pipeline, sampler }); + Ok(()) + } + + fn draw( + &mut self, + encoder: &mut wgpu::CommandEncoder, + renderer: &mut dyn PillRenderer, + frame: &wgpu::SurfaceTexture, + _view: &wgpu::TextureView, + _world: &WorldQuery<'_>, + ) -> Result<()> { + let state = self.state.as_ref().unwrap(); + let hdr_view = renderer.get_render_target_view(self.hdr_source).unwrap(); + let swapchain_view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + let layout_ptr: *const wgpu::BindGroupLayout = + &state.pipeline.bind_group_layouts[0] as *const _; + let bind_group = renderer + .get_device() + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_tonemap_bind_group"), + layout: unsafe { &*layout_ptr }, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(hdr_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&state.sampler), + }, + ], + }); + + let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("pass_tonemap_render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &swapchain_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rp.set_pipeline(&state.pipeline.pipeline); + rp.set_bind_group(0, &bind_group, &[]); + rp.draw(0..3, 0..1); + Ok(()) + } +} diff --git a/engine/pill_engine/src/renderer/resources/renderer_material.rs b/engine/pill_engine/src/renderer/resources/renderer_material.rs index c9852fe9..95f222bf 100644 --- a/engine/pill_engine/src/renderer/resources/renderer_material.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_material.rs @@ -3,7 +3,10 @@ use crate::graphics::RendererShaderHandle; use crate::graphics::RendererTextureHandle; use crate::{ - config::{DEFAULT_COLOR_TEXTURE_NAME, DEFAULT_NORMAL_TEXTURE_NAME}, + config::{ + DEFAULT_COLOR_TEXTURE_NAME, DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME, + DEFAULT_NORMAL_TEXTURE_NAME, + }, graphics::RendererMaterialHandle, renderer::resources::{RendererShader, RendererTexture}, resources::{ @@ -171,6 +174,9 @@ impl RendererMaterial { let default_name = match slot.texture_type { crate::resources::TextureType::Color => DEFAULT_COLOR_TEXTURE_NAME, crate::resources::TextureType::Normal => DEFAULT_NORMAL_TEXTURE_NAME, + crate::resources::TextureType::MetallicRoughness => { + DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME + } }; resource_manager .get_resource_handle::(default_name) diff --git a/engine/pill_engine/src/renderer/resources/renderer_texture.rs b/engine/pill_engine/src/renderer/resources/renderer_texture.rs index af7416a1..24babf0c 100644 --- a/engine/pill_engine/src/renderer/resources/renderer_texture.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_texture.rs @@ -31,6 +31,7 @@ impl RendererTexture { let format = match texture_type { TextureType::Color => wgpu::TextureFormat::Rgba8UnormSrgb, TextureType::Normal => wgpu::TextureFormat::Rgba8Unorm, + TextureType::MetallicRoughness => wgpu::TextureFormat::Rgba8Unorm, }; let texture = device.create_texture(&wgpu::TextureDescriptor { diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs index 7190dcaa..a407533a 100644 --- a/engine/pill_engine/src/renderer/state.rs +++ b/engine/pill_engine/src/renderer/state.rs @@ -143,7 +143,7 @@ impl PillRenderer for Renderer { }, }; - let view = frame + let swapchain_view = frame .texture .create_view(&wgpu::TextureViewDescriptor::default()); @@ -167,7 +167,7 @@ impl PillRenderer for Renderer { let mut passes = std::mem::take(&mut self.state.passes); for pass in &mut passes { - pass.draw(&mut encoder, self, &frame, &view, &world)?; + pass.draw(&mut encoder, self, &frame, &swapchain_view, &world)?; } self.state.passes = passes; @@ -175,7 +175,60 @@ impl PillRenderer for Renderer { timer.record("Submit commands and present frame"); + self.state.frame_counter += 1; + let capture = self.state.screenshot.as_ref().and_then(|(target, path)| { + if self.state.frame_counter == *target { + Some(path.clone()) + } else { + None + } + }); + + let screenshot_buf = if let Some(ref _path) = capture { + let w = self.state.surface_configuration.width; + let h = self.state.surface_configuration.height; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let bytes_per_row = (4 * w + align - 1) / align * align; + let buf = self.state.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("screenshot_readback"), + size: (bytes_per_row * h) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + frame.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + Some((buf, w, h, bytes_per_row)) + } else { + None + }; + self.state.queue.submit(std::iter::once(encoder.finish())); + + if let (Some((buf, w, h, bytes_per_row)), Some(path)) = (screenshot_buf, capture) { + let slice = buf.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + self.state.device.poll(wgpu::MaintainBase::Wait); + let data = slice.get_mapped_range(); + save_screenshot(&path, &data, w, h, bytes_per_row, self.state.color_format); + drop(data); + buf.unmap(); + self.state.screenshot = None; + } + frame.present(); Ok(()) @@ -193,17 +246,34 @@ impl PillRenderer for Renderer { /// Installs the default pass chain (scene + optional egui) on first frame bootstrap. fn init_default_passes(&mut self, egui_client: Arc) -> Result<()> { + use crate::graphics::{PassBackground, PassTonemap}; self.state.egui_client = Some(egui_client.clone()); + + let w = self.state.surface_configuration.width; + let h = self.state.surface_configuration.height; + let hdr = self.create_render_target(RendererTargetDesc { + name: "hdr_target".to_string(), + format: wgpu::TextureFormat::Rgba16Float, + width: w, + height: h, + })?; + #[cfg(feature = "debug_ui")] { use crate::graphics::PassEgui; self.set_passes(vec![ - Box::new(PassPBRStatic::new(None, None, None)), + Box::new(PassBackground::new(hdr)), + Box::new(PassPBRStatic::new(Some(hdr), None, None, None)), + Box::new(PassTonemap::new(hdr)), Box::new(PassEgui::new(self.state.window.clone(), egui_client)), ]) } #[cfg(not(feature = "debug_ui"))] - self.set_passes(vec![Box::new(PassPBRStatic::new(None, None, None))]) + self.set_passes(vec![ + Box::new(PassBackground::new(hdr)), + Box::new(PassPBRStatic::new(Some(hdr), None, None, None)), + Box::new(PassTonemap::new(hdr)), + ]) } fn get_device(&self) -> &wgpu::Device { @@ -355,6 +425,9 @@ pub struct State { egui_client: Option>, camera_bind_group_layout: wgpu::BindGroupLayout, window: Arc, + // Optional frame capture: (target_frame, output_path). Set via PILL_SCREENSHOT env var. + screenshot: Option<(u32, String)>, + frame_counter: u32, } impl State { @@ -405,7 +478,8 @@ impl State { let (device, queue) = { let wanted = wgpu::Features::DEPTH_CLIP_CONTROL | wgpu::Features::TIMESTAMP_QUERY - | wgpu::Features::PIPELINE_STATISTICS_QUERY; + | wgpu::Features::PIPELINE_STATISTICS_QUERY + | wgpu::Features::FLOAT32_FILTERABLE; let features = wanted & adapter.features(); adapter @@ -461,7 +535,7 @@ impl State { }; let surface_configuration = wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, format, width: window_size.width, height: window_size.height, @@ -501,6 +575,14 @@ impl State { let cameras = PillSlotMap::with_capacity_and_key(10); let pass_textures = PillSlotMap::with_capacity_and_key(10); + let screenshot = std::env::var("PILL_SCREENSHOT").ok().map(|s| { + let frame = std::env::var("PILL_SCREENSHOT_FRAME") + .ok() + .and_then(|f| f.parse::().ok()) + .unwrap_or(10); + (frame, s) + }); + Ok(Self { cameras, engine_parameters, @@ -517,6 +599,8 @@ impl State { egui_client: None, camera_bind_group_layout, window, + screenshot, + frame_counter: 0, }) } @@ -536,3 +620,47 @@ impl State { } } } + +fn save_screenshot( + path: &str, + data: &[u8], + width: u32, + height: u32, + bytes_per_row: u32, + format: wgpu::TextureFormat, +) { + use std::io::BufWriter; + + let is_bgra = matches!( + format, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb + ); + + let mut rgba: Vec = Vec::with_capacity((width * height * 4) as usize); + for row in 0..height { + let start = (row * bytes_per_row) as usize; + let row_data = &data[start..start + (width * 4) as usize]; + for pixel in row_data.chunks(4) { + if is_bgra { + rgba.extend_from_slice(&[pixel[2], pixel[1], pixel[0], pixel[3]]); + } else { + rgba.extend_from_slice(pixel); + } + } + } + + let file = match std::fs::File::create(path) { + Ok(f) => f, + Err(e) => { + eprintln!("screenshot: cannot create {path}: {e}"); + return; + } + }; + let mut enc = png::Encoder::new(BufWriter::new(file), width, height); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + if let Ok(mut writer) = enc.write_header() { + let _ = writer.write_image_data(&rgba); + println!("screenshot saved: {path}"); + } +} diff --git a/engine/pill_engine/src/resources/material.rs b/engine/pill_engine/src/resources/material.rs index 7e59dbe3..37f6a33a 100644 --- a/engine/pill_engine/src/resources/material.rs +++ b/engine/pill_engine/src/resources/material.rs @@ -76,10 +76,20 @@ impl PBRMaterial { self } + pub fn metallic(mut self, metallic: f32) -> Self { + self.metallic = metallic.clamp(0.0, 1.0); + self + } + pub fn normal_texture(mut self, handle: TextureHandle) -> Self { self.normal_texture = Some(handle); self } + + pub fn metallic_roughness_texture(mut self, handle: TextureHandle) -> Self { + self.metallic_roughness_texture = Some(handle); + self + } } impl PillTypeMapKey for PBRMaterial { @@ -105,6 +115,10 @@ impl Resource for PBRMaterial { DEFAULT_LIT_SHADER_SPECULARITY_PARAMETER_SLOT_NAME.to_string(), MaterialParameter::Scalar(1.0 - self.roughness), ); + parameters.insert( + DEFAULT_LIT_SHADER_METALLIC_FACTOR_PARAMETER_SLOT_NAME.to_string(), + MaterialParameter::Scalar(self.metallic), + ); let mut resolved_textures: HashMap = HashMap::new(); @@ -134,6 +148,21 @@ impl Resource for PBRMaterial { .insert(DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_NAME.to_string(), h); } + if let Some(texture_handle) = self.metallic_roughness_texture { + let tex_name = engine + .resource_manager + .get_resource::(&texture_handle)? + .name + .clone(); + let h = engine + .resource_manager + .get_resource_handle::(&tex_name)?; + resolved_textures.insert( + DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_NAME.to_string(), + h, + ); + } + let renderer_shader_handle = engine .resource_manager .get_resource_handle::(DEFAULT_LIT_SHADER_NAME)?; diff --git a/engine/pill_engine/src/resources/texture.rs b/engine/pill_engine/src/resources/texture.rs index f3afaeaf..0563c920 100644 --- a/engine/pill_engine/src/resources/texture.rs +++ b/engine/pill_engine/src/resources/texture.rs @@ -14,6 +14,7 @@ pill_core::define_new_pill_slotmap_key! { pub enum TextureType { Color, Normal, + MetallicRoughness, } #[readonly::make] diff --git a/engine/pill_launcher/res/templates/pill_default/res/config.ini b/engine/pill_launcher/res/templates/pill_default/res/config.ini index a8273c20..7e485638 100644 --- a/engine/pill_launcher/res/templates/pill_default/res/config.ini +++ b/engine/pill_launcher/res/templates/pill_default/res/config.ini @@ -2,7 +2,7 @@ TITLE=NoTitle # WINDOW -WINDOW_TITLE=NoTitle +WINDOW_TITLE=Pill Engine - NoTitle WINDOW_WIDTH=600 WINDOW_HEIGHT=600 WINDOW_FULLSCREEN=false diff --git a/engine/pill_launcher/src/main.rs b/engine/pill_launcher/src/main.rs index b2b1dd50..439a6928 100644 --- a/engine/pill_launcher/src/main.rs +++ b/engine/pill_launcher/src/main.rs @@ -188,6 +188,17 @@ pub(crate) fn modify_file String>( Ok(()) } +fn codesign_adhoc(path: &PathBuf) -> Result<()> { + let status = Command::new("codesign") + .args(["--force", "--sign", "-", path.to_str().unwrap_or("")]) + .status() + .with_context(|| format!("codesign failed for {}", path.display()))?; + if !status.success() { + bail!("codesign returned non-zero for {}", path.display()); + } + Ok(()) +} + fn copy_if_newer(source: &PathBuf, destination: &PathBuf) -> Result { // returns true if copied if !source.exists() { @@ -899,11 +910,13 @@ fn build_game_project( if *compile_mode != CompileMode::HotReload || !hot_reload_child { if copy_if_newer(&game_src, &data_dir.join(dylib("pill_game")))? { println!("Copied game dylib"); + codesign_adhoc(&data_dir.join(dylib("pill_game")))?; } else { println!("Skipping copying of game dylib"); } if copy_if_newer(&runtime_src, &data_dir.join(dylib("pill_runtime")))? { println!("Copied runtime dylib"); + codesign_adhoc(&data_dir.join(dylib("pill_runtime")))?; } else { println!("Skipping copying of runtime dylib"); } diff --git a/examples/Empty/res/config.ini b/examples/Empty/res/config.ini index 96131ded..eabaae67 100644 --- a/examples/Empty/res/config.ini +++ b/examples/Empty/res/config.ini @@ -2,7 +2,7 @@ TITLE=Empty # WINDOW -WINDOW_TITLE=Empty +WINDOW_TITLE=Pill Engine - Empty WINDOW_WIDTH=1200 WINDOW_HEIGHT=800 WINDOW_FULLSCREEN=false diff --git a/examples/cube/res/config.ini b/examples/cube/res/config.ini index 85295b7f..d838977c 100644 --- a/examples/cube/res/config.ini +++ b/examples/cube/res/config.ini @@ -2,7 +2,7 @@ TITLE=Cube # WINDOW -WINDOW_TITLE=Cube +WINDOW_TITLE=Pill Engine - Cube WINDOW_WIDTH=1200 WINDOW_HEIGHT=800 WINDOW_FULLSCREEN=false diff --git a/examples/floating_pills/res/config.ini b/examples/floating_pills/res/config.ini index 9a4443a7..92aee2af 100644 --- a/examples/floating_pills/res/config.ini +++ b/examples/floating_pills/res/config.ini @@ -2,7 +2,7 @@ TITLE=Floating-Pills # WINDOW -WINDOW_TITLE=Floating Pills +WINDOW_TITLE=Pill Engine - Floating Pills WINDOW_WIDTH=1200 WINDOW_HEIGHT=800 WINDOW_FULLSCREEN=false diff --git a/examples/italian_brainrot/res/config.ini b/examples/italian_brainrot/res/config.ini index 56141243..d7935cb1 100644 --- a/examples/italian_brainrot/res/config.ini +++ b/examples/italian_brainrot/res/config.ini @@ -2,7 +2,7 @@ TITLE=Italian Brainrot # WINDOW -WINDOW_TITLE=Italian Brainrot +WINDOW_TITLE=Pill Engine - Italian Brainrot WINDOW_WIDTH=1200 WINDOW_HEIGHT=800 WINDOW_FULLSCREEN=false diff --git a/examples/net_minimal/client/res/config.ini b/examples/net_minimal/client/res/config.ini index a093ebef..e0ec9a6d 100644 --- a/examples/net_minimal/client/res/config.ini +++ b/examples/net_minimal/client/res/config.ini @@ -2,7 +2,7 @@ TITLE=Net Minimal # WINDOW -WINDOW_TITLE=Net Minimal +WINDOW_TITLE=Pill Engine - Net Minimal WINDOW_WIDTH=600 WINDOW_HEIGHT=600 WINDOW_FULLSCREEN=false diff --git a/examples/pbr_balls/Cargo.toml b/examples/pbr_balls/Cargo.toml new file mode 100644 index 00000000..12772bbb --- /dev/null +++ b/examples/pbr_balls/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pill_game" +version = "0.1.0" +edition = "2021" +workspace = "/Users/mk/dev/demo/Pill-Engine/engine" + +[lib] +crate-type = ["cdylib", "rlib"] + +[build-dependencies] +ureq = "2" +pill_assets = { path = "../../engine/pill_assets" } + +[dependencies] +pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal"] } diff --git a/examples/pbr_balls/build.rs b/examples/pbr_balls/build.rs new file mode 100644 index 00000000..cff375bf --- /dev/null +++ b/examples/pbr_balls/build.rs @@ -0,0 +1,26 @@ +use std::path::PathBuf; + +use pill_assets::{GlbToCookedMesh, Rule}; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let models = manifest.join("res/models"); + std::fs::create_dir_all(&models).unwrap(); + + let glb = models.join("MetalRoughSpheres.glb"); + if !glb.exists() { + let url = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/MetalRoughSpheres/glTF-Binary/MetalRoughSpheres.glb"; + let resp = ureq::get(url) + .call() + .expect("download MetalRoughSpheres.glb"); + let mut f = std::fs::File::create(&glb).unwrap(); + std::io::copy(&mut resp.into_reader(), &mut f).unwrap(); + } + + let cooked_mesh = models.join("MetalRoughSpheres.cooked_mesh"); + if !cooked_mesh.exists() { + GlbToCookedMesh + .build(&glb, &cooked_mesh) + .expect("cook MetalRoughSpheres.glb"); + } +} diff --git a/examples/pbr_balls/res/config.ini b/examples/pbr_balls/res/config.ini new file mode 100644 index 00000000..65dd1fb3 --- /dev/null +++ b/examples/pbr_balls/res/config.ini @@ -0,0 +1,24 @@ +# GAME +TITLE=PBR Balls + +# WINDOW +WINDOW_TITLE=Pill Engine - PBR Balls +WINDOW_WIDTH=1920 +WINDOW_HEIGHT=1080 +WINDOW_FULLSCREEN=false + +# ENGINE GENERAL +MAX_ENTITYS=100 +MAX_CAMERAS=1 +MAX_CONCURRENT_2D_SOUNDS=0 +MAX_CONCURRENT_3D_SOUNDS=0 + +# ENGINE RESOURCES +MAX_TEXTURES=10 +MAX_MATERIALS=10 +MAX_MESHS=10 +MAX_SOUNDS=0 + +# OTHER +LOG_LEVEL=Info +PANIC_ON_GAME_ERRORS=true diff --git a/examples/pbr_balls/res/models/studio.studio b/examples/pbr_balls/res/models/studio.studio new file mode 100644 index 00000000..e69de29b diff --git a/examples/pbr_balls/res/studio.studio b/examples/pbr_balls/res/studio.studio new file mode 100644 index 00000000..e69de29b diff --git a/examples/pbr_balls/src/game.rs b/examples/pbr_balls/src/game.rs new file mode 100644 index 00000000..2bf9c60a --- /dev/null +++ b/examples/pbr_balls/src/game.rs @@ -0,0 +1,108 @@ +use pill_engine::{define_component, game::*}; + +define_component!(OrbitCamera { + yaw: f32, + pitch: f32, + radius: f32, +}); + +pub struct Game {} + +impl PillGame for Game { + fn start(&self, engine: &mut Engine) -> Result<()> { + let scene = engine.create_scene("pbr_balls")?; + engine.set_active_scene(scene)?; + + engine.register_component::(scene)?; + engine.register_component::(scene)?; + engine.register_component::(scene)?; + engine.register_component::(scene)?; + + engine.add_system("orbit_camera", orbit_camera_system)?; + + let mesh_handle = engine.add_resource(Mesh::new( + "spheres_mesh", + "models/MetalRoughSpheres.cooked_mesh".into(), + ))?; + + let albedo_handle = engine.add_resource(Texture::new( + "spheres_albedo", + TextureType::Color, + ResourceLoader::Path("models/MetalRoughSpheres_albedo.cooked_tex".into()), + ))?; + + let mr_handle = engine.add_resource(Texture::new( + "spheres_mr", + TextureType::MetallicRoughness, + ResourceLoader::Path("models/MetalRoughSpheres_metallic_roughness.cooked_tex".into()), + ))?; + + let mat_handle = engine.add_resource( + PBRMaterial::new("spheres_mat") + .albedo_texture(albedo_handle) + .metallic_roughness_texture(mr_handle) + .metallic(1.0) + .roughness(1.0), + )?; + + engine + .build_entity(scene) + .with_component(TransformComponent::builder().build()) + .with_component( + PbrRenderableComponent::builder() + .mesh(&mesh_handle) + .pbr_material(&mat_handle) + .build(), + ) + .build(); + + engine + .build_entity(scene) + .with_component( + TransformComponent::builder() + .position(Vector3f::new(0.0, 0.0, 10.0)) + .build(), + ) + .with_component( + CameraComponent::builder() + .enabled(true) + .fov(60.0) + .clear_color(Color::new(0.0, 0.0, 0.0)) + .look_at(Some(Vector3f::new(0.0, 0.0, 0.0))) + .build(), + ) + .with_component(OrbitCamera { + yaw: 0.0, + pitch: 0.0, + radius: 10.0, + }) + .build(); + + Ok(()) + } +} + +fn orbit_camera_system(engine: &mut Engine) -> Result<()> { + let input = engine.get_global_component_mut::()?; + let mouse_delta = input.get_mouse_delta(); + let scroll_delta = input.get_mouse_scroll_delta(); + let lmb = input.get_mouse_button(MouseButton::Left); + drop(input); + + for (_, tfm, orbit) in engine.iterate_two_components_mut::()? { + if lmb { + orbit.yaw -= mouse_delta.x * 0.3; + orbit.pitch = (orbit.pitch + mouse_delta.y * 0.3).clamp(-70.0, 70.0); + } + orbit.radius = (orbit.radius - scroll_delta.y * 0.5).clamp(1.0, 50.0); + + let pr = orbit.pitch.to_radians(); + let yr = orbit.yaw.to_radians(); + tfm.set_position(Vector3f::new( + orbit.radius * pr.cos() * yr.sin(), + orbit.radius * pr.sin(), + orbit.radius * pr.cos() * yr.cos(), + )); + } + Ok(()) +} diff --git a/examples/pbr_balls/src/lib.rs b/examples/pbr_balls/src/lib.rs new file mode 100644 index 00000000..3ab5b3fb --- /dev/null +++ b/examples/pbr_balls/src/lib.rs @@ -0,0 +1,5 @@ +mod game; +pub use game::Game; + +use pill_engine::game::create_game; +create_game!(crate::game::Game {}, pill_engine::game::PillGame); diff --git a/examples/pbr_helmet/res/config.ini b/examples/pbr_helmet/res/config.ini index af9e3ac4..1b121a81 100644 --- a/examples/pbr_helmet/res/config.ini +++ b/examples/pbr_helmet/res/config.ini @@ -2,7 +2,7 @@ TITLE=PBR Helmet # WINDOW -WINDOW_TITLE=PBR Helmet +WINDOW_TITLE=Pill Engine - PBR Helmet WINDOW_WIDTH=1280 WINDOW_HEIGHT=720 WINDOW_FULLSCREEN=false diff --git a/examples/pill_tunel/res/config.ini b/examples/pill_tunel/res/config.ini index 1ff66db1..2a2be121 100644 --- a/examples/pill_tunel/res/config.ini +++ b/examples/pill_tunel/res/config.ini @@ -2,7 +2,7 @@ TITLE=Pill-Demo # WINDOW -WINDOW_TITLE=Pill-Demo +WINDOW_TITLE=Pill Engine - Pill-Demo WINDOW_WIDTH=1920 WINDOW_HEIGHT=1200 WINDOW_FULLSCREEN=false From e26b2e87e0c9071b08c79b2223252f23a6857e69 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sat, 30 May 2026 18:12:26 +0200 Subject: [PATCH 11/31] example: pbr helmet --- engine/Cargo.toml | 2 +- .../src/rules/glb_to_cooked_mesh.rs | 102 +++++++++------- .../pill_assets/src/rules/studio_equirect.rs | 25 ---- .../res/shaders/background_fragment.hlsl | 9 +- .../res/shaders/include/equirect.hlsl | 9 ++ .../res/shaders/pbr_static_fragment.hlsl | 15 ++- engine/pill_engine/src/config.rs | 22 ++++ .../src/ecs/systems/audio_system.rs | 112 ++++++++++-------- engine/pill_engine/src/engine.rs | 20 ++++ .../src/graphics/pass_background.rs | 44 +++---- .../src/graphics/pass_pbr_static.rs | 94 +++++++-------- .../pill_engine/src/graphics/pass_tonemap.rs | 68 +++++++---- .../src/graphics/pass_tonemap_README.md | 22 ++++ .../renderer/resources/renderer_material.rs | 5 +- .../renderer/resources/renderer_texture.rs | 1 + engine/pill_engine/src/renderer/state.rs | 16 +-- engine/pill_engine/src/resources/material.rs | 72 ++++++----- engine/pill_engine/src/resources/texture.rs | 1 + engine/pill_launcher/src/main.rs | 75 ++++++++++-- examples/pbr_balls/src/game.rs | 1 - examples/pbr_helmet/res/config.ini | 4 +- examples/pbr_helmet/src/game.rs | 85 +++++++++---- 22 files changed, 488 insertions(+), 316 deletions(-) create mode 100644 engine/pill_engine/res/shaders/include/equirect.hlsl create mode 100644 engine/pill_engine/src/graphics/pass_tonemap_README.md diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 00888499..5e95497d 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,7 +8,7 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "/Users/mk/dev/demo/Pill-Engine/examples/pbr_balls", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) + "/Users/mk/dev/demo/Pill-Engine/examples/pbr_helmet", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at diff --git a/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs b/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs index 20c8288f..bac7b571 100644 --- a/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs +++ b/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs @@ -20,8 +20,10 @@ struct Vertex { /// /// Primary output: `{stem}.cooked_mesh` (RMSH format, same as ObjToCookedMesh). /// Side outputs written in the same directory when textures are present: -/// `{stem}_albedo.cooked_tex` — base color (RTEX RGBA8) -/// `{stem}_normal.cooked_tex` — normal map (RTEX RGBA8) +/// `{stem}_albedo.cooked_tex` — base color (RTEX RGBA8) +/// `{stem}_normal.cooked_tex` — normal map (RTEX RGBA8) +/// `{stem}_metallic_roughness.cooked_tex` — G=roughness, B=metallic (RTEX RGBA8) +/// `{stem}_emissive.cooked_tex` — emissive color (RTEX RGBA8) pub struct GlbToCookedMesh; impl Rule for GlbToCookedMesh { @@ -88,7 +90,7 @@ impl Rule for GlbToCookedMesh { for (mesh_idx, world) in mesh_instances { let mesh = &meshes[mesh_idx]; for prim in mesh.primitives() { - let reader = prim.reader(|b| Some(&*buffers[b.index()])); + let reader = prim.reader(|buffer| Some(&*buffers[buffer.index()])); let vertex_offset = all_vertices.len() as u32; let positions: Vec<[f32; 3]> = reader @@ -106,7 +108,7 @@ impl Rule for GlbToCookedMesh { .collect(); let tangents_glb: Vec<[f32; 4]> = reader .read_tangents() - .map(|t| t.collect()) + .map(|tangents_iter| tangents_iter.collect()) .unwrap_or_default(); let prim_indices: Vec = reader .read_indices() @@ -119,7 +121,7 @@ impl Rule for GlbToCookedMesh { let n = transform_normal(world, normals[i]); let (tx, ty, tz, sign) = tangents_glb .get(i) - .map(|t| (t[0], t[1], t[2], t[3])) + .map(|tangent| (tangent[0], tangent[1], tangent[2], tangent[3])) .unwrap_or((1.0, 0.0, 0.0, 1.0)); let t_world = transform_normal(world, [tx, ty, tz]); let bx = (n[1] * t_world[2] - n[2] * t_world[1]) * sign; @@ -177,30 +179,40 @@ impl Rule for GlbToCookedMesh { let pbr = mat.pbr_metallic_roughness(); if let Some(info) = pbr.base_color_texture() { - let img_idx = info.texture().source().index(); - if let Some(img) = images.get(img_idx) { - let rtex = image_to_rtex(img) - .with_context(|| format!("{input:?}: base color texture"))?; - std::fs::write(dir.join(suffix("albedo")), &rtex)?; - } + write_cooked_tex( + &images, + info.texture().source().index(), + dir.join(suffix("albedo")), + "base color texture", + input, + )?; } - if let Some(info) = mat.normal_texture() { - let img_idx = info.texture().source().index(); - if let Some(img) = images.get(img_idx) { - let rtex = - image_to_rtex(img).with_context(|| format!("{input:?}: normal texture"))?; - std::fs::write(dir.join(suffix("normal")), &rtex)?; - } + write_cooked_tex( + &images, + info.texture().source().index(), + dir.join(suffix("normal")), + "normal texture", + input, + )?; } - if let Some(info) = pbr.metallic_roughness_texture() { - let img_idx = info.texture().source().index(); - if let Some(img) = images.get(img_idx) { - let rtex = image_to_rtex(img) - .with_context(|| format!("{input:?}: metallic_roughness texture"))?; - std::fs::write(dir.join(suffix("metallic_roughness")), &rtex)?; - } + write_cooked_tex( + &images, + info.texture().source().index(), + dir.join(suffix("metallic_roughness")), + "metallic_roughness texture", + input, + )?; + } + if let Some(info) = mat.emissive_texture() { + write_cooked_tex( + &images, + info.texture().source().index(), + dir.join(suffix("emissive")), + "emissive texture", + input, + )?; } } @@ -208,27 +220,31 @@ impl Rule for GlbToCookedMesh { } } -fn image_to_rtex(img: &gltf::image::Data) -> Result> { +fn write_cooked_tex( + images: &[gltf::image::Data], + img_idx: usize, + path: std::path::PathBuf, + label: &str, + input: &Path, +) -> Result<()> { use gltf::image::Format; - - let rgba: Vec = match img.format { - Format::R8G8B8A8 => img.pixels.clone(), - Format::R8G8B8 => img - .pixels - .chunks_exact(3) - .flat_map(|p| [p[0], p[1], p[2], 255u8]) - .collect(), - fmt => bail!("unsupported GLB image format: {fmt:?}"), + use std::borrow::Cow; + let Some(img) = images.get(img_idx) else { + return Ok(()); + }; + let rgba: Cow<[u8]> = match img.format { + Format::R8G8B8A8 => Cow::Borrowed(&img.pixels), + Format::R8G8B8 => Cow::Owned( + img.pixels + .chunks_exact(3) + .flat_map(|pixel| [pixel[0], pixel[1], pixel[2], 255u8]) + .collect(), + ), + format => bail!("{input:?}: {label}: unsupported GLB image format: {format:?}"), }; - // glTF UV origin (0,0) is top-left, matching wgpu/Vulkan — no row flip needed. - let mut out = Vec::with_capacity(16 + rgba.len()); - out.extend_from_slice(b"RTEX"); - out.extend_from_slice(&1u32.to_le_bytes()); - out.extend_from_slice(&img.width.to_le_bytes()); - out.extend_from_slice(&img.height.to_le_bytes()); - out.extend_from_slice(&rgba); - Ok(out) + super::studio_equirect::write_rtex(&path, img.width, img.height, &rgba) + .with_context(|| format!("{input:?}: {label}")) } fn compute_tangents(vertices: &mut [Vertex], indices: &[u32]) { diff --git a/engine/pill_assets/src/rules/studio_equirect.rs b/engine/pill_assets/src/rules/studio_equirect.rs index 61454a4d..230073a6 100644 --- a/engine/pill_assets/src/rules/studio_equirect.rs +++ b/engine/pill_assets/src/rules/studio_equirect.rs @@ -82,31 +82,6 @@ fn generate_equirect(w: u32, h: u32) -> Vec { out } -fn _generate_equirect_debug(w: u32, h: u32) -> Vec { - const TILES_U: u32 = 8; - const TILES_V: u32 = 4; - let mut out = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h { - for x in 0..w { - let u = (x as f32 + 0.5) / w as f32; - let v = (y as f32 + 0.5) / h as f32; - let ti = (u * TILES_U as f32) as u32 % TILES_U; - let tj = (v * TILES_V as f32) as u32 % TILES_V; - let checker = (ti + tj) % 2 == 0; - let origin = ti == 0 && tj == 2; - let col: [f32; 4] = if origin { - [4.0, 0.0, 0.0, 1.0] - } else if checker { - [3.0, 3.0, 3.0, 1.0] - } else { - [0.05, 0.05, 0.05, 1.0] - }; - out.extend_from_slice(&col); - } - } - out -} - // --- shared utilities --- // RTEX v2: Rgba32Float (4 bytes per channel, f32 LE), linear HDR. diff --git a/engine/pill_engine/res/shaders/background_fragment.hlsl b/engine/pill_engine/res/shaders/background_fragment.hlsl index 148a23cb..575d28d5 100644 --- a/engine/pill_engine/res/shaders/background_fragment.hlsl +++ b/engine/pill_engine/res/shaders/background_fragment.hlsl @@ -10,14 +10,7 @@ struct SkyCam { [[vk::binding(1, 0)]] Texture2D texEquirect; [[vk::binding(2, 0)]] SamplerState smpEquirect; -static const float PI = 3.14159265359; - -float2 dir_to_equirect_uv(float3 dir) { - float3 d = normalize(dir); - float u = 0.5 + atan2(d.z, d.x) / (2.0 * PI); - float v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) / PI; - return float2(frac(u), clamp(v, 0.0, 1.0)); -} +#include "include/equirect.hlsl" [shader("fragment")] float4 fs_main(float2 ndc : TEXCOORD0) : SV_Target { diff --git a/engine/pill_engine/res/shaders/include/equirect.hlsl b/engine/pill_engine/res/shaders/include/equirect.hlsl new file mode 100644 index 00000000..15f6f5d3 --- /dev/null +++ b/engine/pill_engine/res/shaders/include/equirect.hlsl @@ -0,0 +1,9 @@ +// Equirectangular UV mapping: atan2(z,x) convention matches the bake in studio_equirect.rs. +static const float EQUIRECT_PI = 3.14159265359; + +float2 dir_to_equirect_uv(float3 dir) { + float3 d = normalize(dir); + float u = 0.5 + atan2(d.z, d.x) / (2.0 * EQUIRECT_PI); + float v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) / EQUIRECT_PI; + return float2(frac(u), clamp(v, 0.0, 1.0)); +} diff --git a/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl b/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl index 4346708e..e5c5414a 100644 --- a/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl +++ b/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl @@ -15,13 +15,15 @@ struct Camera { [[vk::binding(5, 0)]] Texture2D texBrdfLut; [[vk::binding(6, 0)]] SamplerState smpBrdfLut; -// PBR material textures (set 1) — bindings match DEFAULT_LIT_SHADER layout (0-5). +// PBR material textures (set 1) — bindings match DEFAULT_LIT_SHADER layout (0-7). [[vk::binding(0, 1)]] Texture2D texBaseColor; [[vk::binding(1, 1)]] SamplerState smpBaseColor; [[vk::binding(2, 1)]] Texture2D texNormal; [[vk::binding(3, 1)]] SamplerState smpNormal; [[vk::binding(4, 1)]] Texture2D texMetallicRoughness; [[vk::binding(5, 1)]] SamplerState smpMetallicRoughness; +[[vk::binding(6, 1)]] Texture2D texEmissive; +[[vk::binding(7, 1)]] SamplerState smpEmissive; // PBR params UBO (set 2) — 48 bytes: 3 × 16-byte slots. // Each scalar slot uses float+float+float2 padding to stay 16 bytes without float3 alignment gaps. @@ -39,6 +41,8 @@ struct MaterialParams { static const float PI = 3.14159265359; +#include "include/equirect.hlsl" + // Camera at +Z looking -Z (glTF default). Z components flipped vs. -Z camera setup. static const float3 LIGHT_DIR0 = float3( 0.38, -0.38, -0.84); // key: behind-camera, upper-left static const float3 LIGHT_DIR1 = float3(-0.50, 0.50, 0.71); // rim: front upper-right @@ -77,12 +81,6 @@ float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness) { return F0 + (max(float3(1.0 - roughness, 1.0 - roughness, 1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0); } -float2 dir_to_equirect_uv(float3 dir) { - float3 d = normalize(dir); - float u = 0.5 + atan2(d.z, d.x) / (2.0 * PI); // match bake convention: atan2(z,x) - float v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) / PI; - return float2(frac(u), clamp(v, 0.0, 1.0)); -} float3 accumulateDirLight( float3 N, float3 V, float3 F0, @@ -133,6 +131,7 @@ float4 fs_main( float2 envBRDF = texBrdfLut.Sample(smpBrdfLut, float2(max(dot(N, V), 0.0), roughness)).rg; float3 F = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness); float3 specularIBL = prefilteredColor * (F * envBRDF.x + envBRDF.y); - float3 color = Lo + ambientDiffuse + specularIBL; + float3 emissive = texEmissive.Sample(smpEmissive, uv).rgb; + float3 color = Lo + ambientDiffuse + specularIBL + emissive; return float4(color, 1.0); } diff --git a/engine/pill_engine/src/config.rs b/engine/pill_engine/src/config.rs index 8d51182b..06665414 100644 --- a/engine/pill_engine/src/config.rs +++ b/engine/pill_engine/src/config.rs @@ -87,6 +87,7 @@ pub const DEFAULT_RESOURCE_PREFIX: &str = "pill_engine"; pub const DEFAULT_COLOR_TEXTURE_NAME: &str = "pill_engine_default_color"; pub const DEFAULT_NORMAL_TEXTURE_NAME: &str = "pill_engine_default_normal"; pub const DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME: &str = "pill_engine_default_metallic_roughness"; +pub const DEFAULT_EMISSIVE_TEXTURE_NAME: &str = "pill_engine_default_emissive"; // RTEX layout: b"RTEX" | u32LE version=1 | u32LE width | u32LE height | raw RGBA bytes pub const DEFAULT_COLOR_TEXTURE_BYTES: [u8; 20] = [ @@ -101,6 +102,10 @@ pub const DEFAULT_NORMAL_TEXTURE_BYTES: [u8; 20] = [ pub const DEFAULT_METALLIC_ROUGHNESS_TEXTURE_BYTES: [u8; 20] = [ b'R', b'T', b'E', b'X', 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 255, 0, 255, ]; +// Black emissive: no emission by default +pub const DEFAULT_EMISSIVE_TEXTURE_BYTES: [u8; 20] = [ + b'R', b'T', b'E', b'X', 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 255, +]; // Default lit shader pub const DEFAULT_LIT_SHADER_NAME: &str = "pill_engine_default_lit_shader"; @@ -110,6 +115,8 @@ pub const DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_NAME: &str = "normal"; pub const DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_BINDINGS: (u32, u32) = (2, 3); pub const DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_NAME: &str = "metallic_roughness"; pub const DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_BINDINGS: (u32, u32) = (4, 5); +pub const DEFAULT_LIT_SHADER_EMISSIVE_TEXTURE_SLOT_NAME: &str = "emissive"; +pub const DEFAULT_LIT_SHADER_EMISSIVE_TEXTURE_SLOT_BINDINGS: (u32, u32) = (6, 7); pub const DEFAULT_LIT_SHADER_TINT_PARAMETER_SLOT_NAME: &str = "tint"; pub const DEFAULT_LIT_SHADER_SPECULARITY_PARAMETER_SLOT_NAME: &str = "specularity"; pub const DEFAULT_LIT_SHADER_METALLIC_FACTOR_PARAMETER_SLOT_NAME: &str = "metallic_factor"; @@ -163,6 +170,17 @@ pub const DEFAULT_RENDERER_METALLIC_ROUGHNESS_TEXTURE_HANDLE: RendererTextureHan version: std::num::NonZeroU32::new(1).unwrap(), }); +// Default resource handle - Emissive texture +pub const DEFAULT_EMISSIVE_TEXTURE_HANDLE: TextureHandle = TextureHandle(PillSlotMapKeyData { + index: 4, + version: std::num::NonZeroU32::new(1).unwrap(), +}); +pub const DEFAULT_RENDERER_EMISSIVE_TEXTURE_HANDLE: RendererTextureHandle = + RendererTextureHandle(PillSlotMapKeyData { + index: 4, + version: std::num::NonZeroU32::new(1).unwrap(), + }); + pub fn get_default_texture_handles( texture_type: TextureType, ) -> (TextureHandle, RendererTextureHandle) { @@ -179,6 +197,10 @@ pub fn get_default_texture_handles( DEFAULT_METALLIC_ROUGHNESS_TEXTURE_HANDLE, DEFAULT_RENDERER_METALLIC_ROUGHNESS_TEXTURE_HANDLE, ), + TextureType::Emissive => ( + DEFAULT_EMISSIVE_TEXTURE_HANDLE, + DEFAULT_RENDERER_EMISSIVE_TEXTURE_HANDLE, + ), } } diff --git a/engine/pill_engine/src/ecs/systems/audio_system.rs b/engine/pill_engine/src/ecs/systems/audio_system.rs index 940db605..0f4f629a 100644 --- a/engine/pill_engine/src/ecs/systems/audio_system.rs +++ b/engine/pill_engine/src/ecs/systems/audio_system.rs @@ -11,42 +11,51 @@ use pill_core::Result; fn get_rotation_matrix(angles: Vector3f) -> Result { // Get the angles from the vector and convert them to radians - let alfa = angles.x.to_radians(); - let beta = angles.y.to_radians(); - let gamma = angles.z.to_radians(); + let angle_x = angles.x.to_radians(); + let angle_y = angles.y.to_radians(); + let angle_z = angles.z.to_radians(); // Prepare rotation matrices - let rot_z = Matrix3f::from_rotation_z(alfa); - let rot_y = Matrix3f::from_rotation_y(beta); - let rot_x = Matrix3f::from_rotation_x(gamma); + let rot_z = Matrix3f::from_rotation_z(angle_x); + let rot_y = Matrix3f::from_rotation_y(angle_y); + let rot_x = Matrix3f::from_rotation_x(angle_z); // Return rotation matrix Ok(rot_z * rot_y * rot_x) } pub fn audio_system(engine: &mut Engine) -> Result<()> { + // Skip if no audio manager (headless or no audio device). + if engine + .get_global_component::() + .is_err() + { + return Ok(()); + } + // --- Update ear positions let mut left_ear_position = Vector3f::new(-1.0, 0.0, 0.0); let mut right_ear_position = Vector3f::new(1.0, 0.0, 0.0); // Update ear positions - for (_entity_handle, audio_listener_component, transform_component) in - engine.iterate_two_components::()? + if let Ok(iter) = engine.iterate_two_components::() { - if audio_listener_component.enabled { - // Get the retotation matrix - let left_rotation_matrix = get_rotation_matrix(transform_component.rotation)?; - let right_rotation_matrix = get_rotation_matrix(-transform_component.rotation)?; + for (_entity_handle, audio_listener_component, transform_component) in iter { + if audio_listener_component.enabled { + // Get the retotation matrix + let left_rotation_matrix = get_rotation_matrix(transform_component.rotation)?; + let right_rotation_matrix = get_rotation_matrix(-transform_component.rotation)?; - // Get two points for left and right ear relative to the origin multiplied to rotation matrix - left_ear_position = left_rotation_matrix * left_ear_position; - right_ear_position = right_rotation_matrix * right_ear_position; + // Get two points for left and right ear relative to the origin multiplied to rotation matrix + left_ear_position = left_rotation_matrix * left_ear_position; + right_ear_position = right_rotation_matrix * right_ear_position; - // Add the original position - left_ear_position += transform_component.position; - right_ear_position += transform_component.position; + // Add the original position + left_ear_position += transform_component.position; + right_ear_position += transform_component.position; - break; + break; + } } } @@ -58,15 +67,18 @@ pub fn audio_system(engine: &mut Engine) -> Result<()> { } // Update emitter position in all sinks based on transform components of entities to which audio source components are added - let active_scene = engine.scene_manager.get_active_scene()?; - for (_entity_handle, audio_source_component, transform_component) in - active_scene.get_two_component_iterator::()? - { - let audio_manager = engine.get_global_component::()?; - if let Some(index) = audio_source_component.sink_handle { - audio_manager - .get_spatial_sink(index) - .set_emitter_position(transform_component.position.into()); + if let Ok(active_scene) = engine.scene_manager.get_active_scene() { + if let Ok(iter) = + active_scene.get_two_component_iterator::() + { + for (_entity_handle, audio_source_component, transform_component) in iter { + let audio_manager = engine.get_global_component::()?; + if let Some(index) = audio_source_component.sink_handle { + audio_manager + .get_spatial_sink(index) + .set_emitter_position(transform_component.position.into()); + } + } } } @@ -80,29 +92,29 @@ pub fn audio_system(engine: &mut Engine) -> Result<()> { .data .as_mut() .unwrap(); - let active_scene = engine.scene_manager.get_active_scene_mut()?; - for (_entity_handle, audio_source_component) in - active_scene.get_one_component_iterator_mut::()? - { - // Check if the audio source has sink handle assigned - if let Some(sink_handle) = audio_source_component.sink_handle { - // Check if is playing - let sound_type = audio_source_component.sound_type; - let playing = match sound_type { - SoundType::Sound2D => { - let sink = audio_manager.get_ambient_sink(sink_handle); - !sink.is_paused() - } - SoundType::Sound3D => { - let sink = audio_manager.get_spatial_sink(sink_handle); - !sink.is_paused() - } - }; + if let Ok(active_scene) = engine.scene_manager.get_active_scene_mut() { + if let Ok(iter) = active_scene.get_one_component_iterator_mut::() { + for (_entity_handle, audio_source_component) in iter { + // Check if the audio source has sink handle assigned + if let Some(sink_handle) = audio_source_component.sink_handle { + // Check if is playing + let playing = match audio_source_component.sound_type { + SoundType::Sound2D => { + let sink = audio_manager.get_ambient_sink(sink_handle); + !sink.is_paused() + } + SoundType::Sound3D => { + let sink = audio_manager.get_spatial_sink(sink_handle); + !sink.is_paused() + } + }; - // Return sink to pool if stopped playing - if !playing { - let sink_handle = audio_source_component.return_sink().unwrap(); - audio_manager.return_sink(sink_handle, &sound_type); + // Return sink to pool if stopped playing + if !playing { + let sink_handle = audio_source_component.return_sink().unwrap(); + audio_manager.return_sink(sink_handle, &audio_source_component.sound_type); + } + } } } } diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index b9d21248..0d50d391 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -194,6 +194,13 @@ impl Engine { DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_BINDINGS, ), ), + ( + DEFAULT_LIT_SHADER_EMISSIVE_TEXTURE_SLOT_NAME.to_string(), + ShaderTextureSlot::new( + TextureType::Emissive, + DEFAULT_LIT_SHADER_EMISSIVE_TEXTURE_SLOT_BINDINGS, + ), + ), ] .into_iter() .collect(), @@ -257,6 +264,15 @@ impl Engine { ))?; debug!(LogContext::Engine => "Default metallic_roughness texture {} created", DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME.name_style()); + debug!(LogContext::Engine => "Creating default emissive texture {}...", DEFAULT_EMISSIVE_TEXTURE_NAME.name_style()); + + let default_emissive_texture_handle = self.add_default_resource(Texture::new( + DEFAULT_EMISSIVE_TEXTURE_NAME, + TextureType::Emissive, + ResourceLoader::Bytes(Box::new(DEFAULT_EMISSIVE_TEXTURE_BYTES)), + ))?; + + debug!(LogContext::Engine => "Default emissive texture {} created", DEFAULT_EMISSIVE_TEXTURE_NAME.name_style()); debug!(LogContext::Engine => "Creating default material {}...", DEFAULT_LIT_MATERIAL_NAME.name_style()); // Create default lit material @@ -281,6 +297,10 @@ impl Engine { DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_NAME, default_mr_texture_handle, )? + .texture( + DEFAULT_LIT_SHADER_EMISSIVE_TEXTURE_SLOT_NAME, + default_emissive_texture_handle, + )? .build(), )?; diff --git a/engine/pill_engine/src/graphics/pass_background.rs b/engine/pill_engine/src/graphics/pass_background.rs index bc599a20..0d79596e 100644 --- a/engine/pill_engine/src/graphics/pass_background.rs +++ b/engine/pill_engine/src/graphics/pass_background.rs @@ -178,27 +178,29 @@ impl Pass for PassBackground { mapped_at_creation: false, }); - let layout_ptr: *const wgpu::BindGroupLayout = &pipeline.bind_group_layouts[0] as *const _; - let bind_group = renderer - .get_device() - .create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("pass_background_bind_group"), - layout: unsafe { &*layout_ptr }, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: camera_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(&view), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::Sampler(&sampler), - }, - ], - }); + let bind_group = { + let layout = &pipeline.bind_group_layouts[0]; + renderer + .get_device() + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_background_bind_group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }) + }; self.state = Some(BgState { pipeline, diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_static.rs index a3a14ad9..f3803817 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_static.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_static.rs @@ -107,7 +107,6 @@ pub(crate) struct MeshBatch { /// Draw group: one pipeline + one material, containing one or more mesh batches. pub(crate) struct GroupCmd { - pub(crate) pipeline: *const wgpu::RenderPipeline, pub(crate) material_handle: RendererMaterialHandle, pub(crate) batches: Vec, } @@ -129,16 +128,11 @@ struct PassPBRStaticState { per_draw_bind_group: wgpu::BindGroup, } -/// Default PBR pass: full GGX shading with optional IBL, per-draw dynamic UBO ring. +/// Default PBR pass: full GGX shading with IBL, per-draw dynamic UBO ring. /// No MeshDrawer — each entity is drawn with a direct indexed draw call. pub struct PassPBRStatic { color_target: Option, - ibl_irradiance_tex: Option, - ibl_prefilter_tex: Option, - ibl_brdf_lut_tex: Option, depth_texture: Option, - per_draw_stride: u64, - per_draw_capacity: u64, visible_pre_draw_buffer: Vec, groups_buffer: Vec, staging_buffer: Vec, @@ -146,21 +140,11 @@ pub struct PassPBRStatic { } impl PassPBRStatic { - /// Creates the pass with optional IBL textures; `Pass::init` must run before the first frame. - pub fn new( - color_target: Option, - ibl_irradiance_tex: Option, - ibl_prefilter_tex: Option, - ibl_brdf_lut_tex: Option, - ) -> Self { + /// Creates the pass; `Pass::init` must run before the first frame. + pub fn new(color_target: Option) -> Self { Self { color_target, - ibl_irradiance_tex, - ibl_prefilter_tex, - ibl_brdf_lut_tex, depth_texture: None, - per_draw_stride: PER_DRAW_STRIDE_BYTES as u64, - per_draw_capacity: 0, visible_pre_draw_buffer: Vec::with_capacity(MAX_EXPECTED_PER_DRAW_INSTANCES), groups_buffer: Vec::with_capacity(2000), staging_buffer: Vec::with_capacity( @@ -171,7 +155,7 @@ impl PassPBRStatic { } } -/// Decode an RTEX header: returns (rgba_bytes, width, height). +/// Decode an RTEX header: returns (rgba_bytes, width, height, mip_count, version). fn decode_rtex(bytes: &[u8]) -> (&[u8], u32, u32, u32, u32) { let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap()); let w = u32::from_le_bytes(bytes[8..12].try_into().unwrap()); @@ -253,8 +237,9 @@ fn load_ibl_texture( /// Returns the initialized pass state; panics in debug if `init` was not called. fn get_state(pass: &mut PassPBRStatic) -> &mut PassPBRStaticState { debug_assert!(pass.state.is_some()); - // SAFETY: initialized once in init(), read every draw - unsafe { pass.state.as_mut().unwrap_unchecked() } + pass.state + .as_mut() + .expect("PassPBRStatic: state not initialized — call init() before draw()") } impl Pass for PassPBRStatic { @@ -329,7 +314,7 @@ impl Pass for PassPBRStatic { count: None, }, ], - // 1: material textures (base_color, normal, metallic_roughness) + // 1: material textures (base_color, normal, metallic_roughness, emissive) vec![ wgpu::BindGroupLayoutEntry { binding: 0, @@ -379,6 +364,22 @@ impl Pass for PassPBRStatic { ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, ], // 2: material params vec![wgpu::BindGroupLayoutEntry { @@ -457,19 +458,17 @@ impl Pass for PassPBRStatic { }) }; - self.per_draw_capacity = MAX_EXPECTED_PER_DRAW_INSTANCES as u64; let per_draw_buffer = renderer.create_buffer(BufferDesc { label: Some("pass_pbr_static_per_draw_ring"), - byte_size: self.per_draw_stride * self.per_draw_capacity, + byte_size: (PER_DRAW_STRIDE_BYTES * MAX_EXPECTED_PER_DRAW_INSTANCES) as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, })?; let per_draw_bind_group = { - let layout_ptr: *const wgpu::BindGroupLayout = - &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_PERDRAW] as *const _; + let layout = &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_PERDRAW]; let device = renderer.get_device(); device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pass_pbr_static_per_draw_bind_group"), - layout: unsafe { &*layout_ptr }, + layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { @@ -567,12 +566,11 @@ impl Pass for PassPBRStatic { }; let globals_bind_group = { - let layout_ptr: *const wgpu::BindGroupLayout = - &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_GLOBALS] as *const _; + let layout = &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_GLOBALS]; let device = renderer.get_device(); device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pass_pbr_static_globals_bind_group"), - layout: unsafe { &*layout_ptr }, + layout, entries: &[ wgpu::BindGroupEntry { binding: 0, @@ -665,8 +663,6 @@ impl Pass for PassPBRStatic { Mat4::from_cols_array_2d(&state.camera_uniform.view_projection_matrix) }; - let _clear_color = active_camera_component.clear_color; - // Build visible set from the render queue. self.visible_pre_draw_buffer.clear(); for render_queue_item in world.render_queue.iter() { @@ -717,10 +713,6 @@ impl Pass for PassPBRStatic { // Build group/batch command list. self.groups_buffer.clear(); - let pipeline_ptr: *const wgpu::RenderPipeline = { - let state = get_state(self); - &state.pipeline.pipeline as *const _ - }; for visible in &self.visible_pre_draw_buffer { let need_new_group = self .groups_buffer @@ -729,7 +721,6 @@ impl Pass for PassPBRStatic { .unwrap_or(true); if need_new_group { self.groups_buffer.push(GroupCmd { - pipeline: pipeline_ptr, material_handle: visible.material_handle, batches: Vec::new(), }); @@ -768,11 +759,11 @@ impl Pass for PassPBRStatic { .sum::() }) .sum(); - if self.per_draw_capacity < needed { + if needed > MAX_EXPECTED_PER_DRAW_INSTANCES as u64 { log::error!( "PassPBRStatic: per-draw capacity exceeded (needed={}, capacity={})", needed, - self.per_draw_capacity + MAX_EXPECTED_PER_DRAW_INSTANCES ); } self.staging_buffer.clear(); @@ -783,15 +774,17 @@ impl Pass for PassPBRStatic { for per_draw in &batch.instances { self.staging_buffer .extend_from_slice(bytemuck::bytes_of(per_draw)); - let pad = - (self.per_draw_stride as usize) - std::mem::size_of::(); + let pad = (PER_DRAW_STRIDE_BYTES) - std::mem::size_of::(); self.staging_buffer.extend(std::iter::repeat(0u8).take(pad)); - next_offset_u32 = next_offset_u32.wrapping_add(self.per_draw_stride as u32); + next_offset_u32 = next_offset_u32.wrapping_add(PER_DRAW_STRIDE_BYTES as u32); } } } { - let state_ref: &PassPBRStaticState = unsafe { self.state.as_ref().unwrap_unchecked() }; + let state_ref = self + .state + .as_ref() + .expect("PassPBRStatic: state not initialized — call init() before draw()"); renderer .get_queue() .write_buffer(&state_ref.per_draw_buffer, 0, &self.staging_buffer); @@ -828,9 +821,12 @@ impl Pass for PassPBRStatic { }); // Record draw commands: bind pipeline → globals → material → per-draw per instance. - let state_ref: &PassPBRStaticState = unsafe { self.state.as_ref().unwrap_unchecked() }; + let state_ref = self + .state + .as_ref() + .expect("PassPBRStatic: state not initialized — call init() before draw()"); + render_pass.set_pipeline(&state_ref.pipeline.pipeline); for group in &self.groups_buffer { - render_pass.set_pipeline(unsafe { &*group.pipeline }); render_pass.set_bind_group( MATERIAL_BIND_GROUP_GLOBALS as u32, &state_ref.globals_bind_group, @@ -840,7 +836,7 @@ impl Pass for PassPBRStatic { let mat = world .resources .get_resource::(&group.material_handle) - .unwrap(); + .expect("PassPBRStatic: RendererMaterial missing for draw group"); // Skip materials that don't have PBR-compatible bind groups. let (Some(textures_bg), Some(params_bg)) = ( @@ -856,14 +852,14 @@ impl Pass for PassPBRStatic { let mesh = world .resources .get_resource::(&batch.mesh_handle) - .unwrap(); + .expect("PassPBRStatic: RendererMesh missing for batch"); render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); render_pass .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); for instance_index in 0..batch.instances.len() { let offset = batch .base_offset_u32 - .wrapping_add((instance_index as u32) * (self.per_draw_stride as u32)); + .wrapping_add((instance_index as u32) * (PER_DRAW_STRIDE_BYTES as u32)); render_pass.set_bind_group( MATERIAL_BIND_GROUP_PERDRAW as u32, &state_ref.per_draw_bind_group, diff --git a/engine/pill_engine/src/graphics/pass_tonemap.rs b/engine/pill_engine/src/graphics/pass_tonemap.rs index aaf31791..c3d9f9d0 100644 --- a/engine/pill_engine/src/graphics/pass_tonemap.rs +++ b/engine/pill_engine/src/graphics/pass_tonemap.rs @@ -15,6 +15,8 @@ pub struct PassTonemap { struct TonemapState { pipeline: PipelineV2, sampler: wgpu::Sampler, + // sRGB view of the swapchain — GPU applies OETF on write even when surface format is linear (e.g. WebGPU/wasm32). + swapchain_view_format: wgpu::TextureFormat, } impl PassTonemap { @@ -33,6 +35,11 @@ impl Pass for PassTonemap { fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { let surface_format = renderer.get_surface_format(); + let swapchain_view_format = if surface_format.is_srgb() { + surface_format + } else { + surface_format.add_srgb_suffix() + }; let bind_groups = vec![vec![ wgpu::BindGroupLayoutEntry { @@ -66,7 +73,7 @@ impl Pass for PassTonemap { vertex_buffers: &[], bind_groups, targets: &[Some(wgpu::ColorTargetState { - format: surface_format, + format: swapchain_view_format, blend: Some(wgpu::BlendState::REPLACE), write_mask: wgpu::ColorWrites::ALL, })], @@ -95,7 +102,11 @@ impl Pass for PassTonemap { ..Default::default() }); - self.state = Some(TonemapState { pipeline, sampler }); + self.state = Some(TonemapState { + pipeline, + sampler, + swapchain_view_format, + }); Ok(()) } @@ -107,30 +118,37 @@ impl Pass for PassTonemap { _view: &wgpu::TextureView, _world: &WorldQuery<'_>, ) -> Result<()> { - let state = self.state.as_ref().unwrap(); - let hdr_view = renderer.get_render_target_view(self.hdr_source).unwrap(); - let swapchain_view = frame - .texture - .create_view(&wgpu::TextureViewDescriptor::default()); + let state = self + .state + .as_ref() + .expect("PassTonemap: state not initialized — call init() before draw()"); + let hdr_view = renderer + .get_render_target_view(self.hdr_source) + .expect("PassTonemap: HDR render target missing"); + let swapchain_view = frame.texture.create_view(&wgpu::TextureViewDescriptor { + format: Some(state.swapchain_view_format), + ..Default::default() + }); - let layout_ptr: *const wgpu::BindGroupLayout = - &state.pipeline.bind_group_layouts[0] as *const _; - let bind_group = renderer - .get_device() - .create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("pass_tonemap_bind_group"), - layout: unsafe { &*layout_ptr }, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(hdr_view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&state.sampler), - }, - ], - }); + let bind_group = { + let layout = &state.pipeline.bind_group_layouts[0]; + renderer + .get_device() + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_tonemap_bind_group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(hdr_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&state.sampler), + }, + ], + }) + }; let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("pass_tonemap_render_pass"), diff --git a/engine/pill_engine/src/graphics/pass_tonemap_README.md b/engine/pill_engine/src/graphics/pass_tonemap_README.md new file mode 100644 index 00000000..3c37d218 --- /dev/null +++ b/engine/pill_engine/src/graphics/pass_tonemap_README.md @@ -0,0 +1,22 @@ +# PassTonemap — gamma + +No `pow(color, 1/2.2)` in the shader. The swapchain view is always an `*Srgb` format; +the GPU applies the sRGB OETF on fragment write automatically. + +## Why a separate `swapchain_view_format` + +Desktop: surface format is already `Bgra8UnormSrgb` / `Rgba8UnormSrgb` — view = surface format. + +WebGPU/wasm32: canvas API only exposes `bgra8unorm` / `rgba8unorm` (no sRGB surface variant). +Fix: register the sRGB variant in `SurfaceConfiguration::view_formats`, build the pipeline +against it, and create the swapchain `TextureView` with it. The GPU still applies the OETF. + +Same pattern used by Bevy (`bevy_render/src/view/window/mod.rs`): +```rust +let texture_view_format = if !format.is_srgb() { Some(format.add_srgb_suffix()) } else { None }; +``` + +## References + +- wgpu `TextureFormat` docs — `*Srgb` variants: *"Apply sRGB transfer function for color-space conversion"* on write. https://docs.rs/wgpu/latest/wgpu/enum.TextureFormat.html +- wgpu wiki — *"When image format names contain the suffix `*_SRGB`, the sRGB gamma transform is applied transparently on every texel write."* https://github.com/gfx-rs/wgpu/wiki/Texture-Color-Formats-and-Srgb-conversions diff --git a/engine/pill_engine/src/renderer/resources/renderer_material.rs b/engine/pill_engine/src/renderer/resources/renderer_material.rs index 95f222bf..b58f02de 100644 --- a/engine/pill_engine/src/renderer/resources/renderer_material.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_material.rs @@ -4,8 +4,8 @@ use crate::graphics::RendererShaderHandle; use crate::graphics::RendererTextureHandle; use crate::{ config::{ - DEFAULT_COLOR_TEXTURE_NAME, DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME, - DEFAULT_NORMAL_TEXTURE_NAME, + DEFAULT_COLOR_TEXTURE_NAME, DEFAULT_EMISSIVE_TEXTURE_NAME, + DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME, DEFAULT_NORMAL_TEXTURE_NAME, }, graphics::RendererMaterialHandle, renderer::resources::{RendererShader, RendererTexture}, @@ -177,6 +177,7 @@ impl RendererMaterial { crate::resources::TextureType::MetallicRoughness => { DEFAULT_METALLIC_ROUGHNESS_TEXTURE_NAME } + crate::resources::TextureType::Emissive => DEFAULT_EMISSIVE_TEXTURE_NAME, }; resource_manager .get_resource_handle::(default_name) diff --git a/engine/pill_engine/src/renderer/resources/renderer_texture.rs b/engine/pill_engine/src/renderer/resources/renderer_texture.rs index 24babf0c..229a6a4a 100644 --- a/engine/pill_engine/src/renderer/resources/renderer_texture.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_texture.rs @@ -32,6 +32,7 @@ impl RendererTexture { TextureType::Color => wgpu::TextureFormat::Rgba8UnormSrgb, TextureType::Normal => wgpu::TextureFormat::Rgba8Unorm, TextureType::MetallicRoughness => wgpu::TextureFormat::Rgba8Unorm, + TextureType::Emissive => wgpu::TextureFormat::Rgba8UnormSrgb, }; let texture = device.create_texture(&wgpu::TextureDescriptor { diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs index a407533a..1c607ee0 100644 --- a/engine/pill_engine/src/renderer/state.rs +++ b/engine/pill_engine/src/renderer/state.rs @@ -221,7 +221,7 @@ impl PillRenderer for Renderer { if let (Some((buf, w, h, bytes_per_row)), Some(path)) = (screenshot_buf, capture) { let slice = buf.slice(..); slice.map_async(wgpu::MapMode::Read, |_| {}); - self.state.device.poll(wgpu::MaintainBase::Wait); + let _ = self.state.device.poll(wgpu::MaintainBase::Wait); let data = slice.get_mapped_range(); save_screenshot(&path, &data, w, h, bytes_per_row, self.state.color_format); drop(data); @@ -263,7 +263,7 @@ impl PillRenderer for Renderer { use crate::graphics::PassEgui; self.set_passes(vec![ Box::new(PassBackground::new(hdr)), - Box::new(PassPBRStatic::new(Some(hdr), None, None, None)), + Box::new(PassPBRStatic::new(Some(hdr))), Box::new(PassTonemap::new(hdr)), Box::new(PassEgui::new(self.state.window.clone(), egui_client)), ]) @@ -271,7 +271,7 @@ impl PillRenderer for Renderer { #[cfg(not(feature = "debug_ui"))] self.set_passes(vec![ Box::new(PassBackground::new(hdr)), - Box::new(PassPBRStatic::new(Some(hdr), None, None, None)), + Box::new(PassPBRStatic::new(Some(hdr))), Box::new(PassTonemap::new(hdr)), ]) } @@ -416,8 +416,6 @@ pub struct State { device: wgpu::Device, queue: wgpu::Queue, surface_configuration: wgpu::SurfaceConfiguration, - #[allow(dead_code)] - window_size: winit::dpi::PhysicalSize, color_format: wgpu::TextureFormat, depth_format: wgpu::TextureFormat, depth_texture: RendererTexture, @@ -542,7 +540,11 @@ impl State { desired_maximum_frame_latency: 2, present_mode, alpha_mode: wgpu::CompositeAlphaMode::Auto, - view_formats: vec![format], + view_formats: if format.is_srgb() { + vec![] + } else { + vec![format.add_srgb_suffix()] + }, }; surface.configure(&device, &surface_configuration); let color_format = surface_configuration.format; @@ -591,7 +593,6 @@ impl State { device, queue, surface_configuration, - window_size, color_format, depth_format, depth_texture, @@ -606,7 +607,6 @@ impl State { fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize) { if new_window_size.width > 0 && new_window_size.height > 0 { - self.window_size = new_window_size; self.surface_configuration.width = new_window_size.width; self.surface_configuration.height = new_window_size.height; self.surface diff --git a/engine/pill_engine/src/resources/material.rs b/engine/pill_engine/src/resources/material.rs index 37f6a33a..92f143e3 100644 --- a/engine/pill_engine/src/resources/material.rs +++ b/engine/pill_engine/src/resources/material.rs @@ -90,6 +90,11 @@ impl PBRMaterial { self.metallic_roughness_texture = Some(handle); self } + + pub fn emissive_texture(mut self, handle: TextureHandle) -> Self { + self.emissive_texture = Some(handle); + self + } } impl PillTypeMapKey for PBRMaterial { @@ -123,44 +128,35 @@ impl Resource for PBRMaterial { let mut resolved_textures: HashMap = HashMap::new(); - if let Some(texture_handle) = self.albedo_texture { - let tex_name = engine - .resource_manager - .get_resource::(&texture_handle)? - .name - .clone(); - let h = engine - .resource_manager - .get_resource_handle::(&tex_name)?; - resolved_textures.insert(DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_NAME.to_string(), h); - } - - if let Some(texture_handle) = self.normal_texture { - let tex_name = engine - .resource_manager - .get_resource::(&texture_handle)? - .name - .clone(); - let h = engine - .resource_manager - .get_resource_handle::(&tex_name)?; - resolved_textures - .insert(DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_NAME.to_string(), h); - } - - if let Some(texture_handle) = self.metallic_roughness_texture { - let tex_name = engine - .resource_manager - .get_resource::(&texture_handle)? - .name - .clone(); - let h = engine - .resource_manager - .get_resource_handle::(&tex_name)?; - resolved_textures.insert( - DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_NAME.to_string(), - h, - ); + for (opt_handle, slot_name) in [ + ( + self.albedo_texture, + DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_NAME, + ), + ( + self.normal_texture, + DEFAULT_LIT_SHADER_NORMAL_TEXTURE_SLOT_NAME, + ), + ( + self.metallic_roughness_texture, + DEFAULT_LIT_SHADER_METALLIC_ROUGHNESS_TEXTURE_SLOT_NAME, + ), + ( + self.emissive_texture, + DEFAULT_LIT_SHADER_EMISSIVE_TEXTURE_SLOT_NAME, + ), + ] { + if let Some(texture_handle) = opt_handle { + let tex_name = engine + .resource_manager + .get_resource::(&texture_handle)? + .name + .clone(); + let renderer_texture_handle = engine + .resource_manager + .get_resource_handle::(&tex_name)?; + resolved_textures.insert(slot_name.to_string(), renderer_texture_handle); + } } let renderer_shader_handle = engine diff --git a/engine/pill_engine/src/resources/texture.rs b/engine/pill_engine/src/resources/texture.rs index 0563c920..a99b044b 100644 --- a/engine/pill_engine/src/resources/texture.rs +++ b/engine/pill_engine/src/resources/texture.rs @@ -15,6 +15,7 @@ pub enum TextureType { Color, Normal, MetallicRoughness, + Emissive, } #[readonly::make] diff --git a/engine/pill_launcher/src/main.rs b/engine/pill_launcher/src/main.rs index 439a6928..b64c3525 100644 --- a/engine/pill_launcher/src/main.rs +++ b/engine/pill_launcher/src/main.rs @@ -431,6 +431,48 @@ fn try_remove_files_starting_with(directory_path: &PathBuf, file_name_prefix: &s } } +fn delete_cooked_files_recursive(dir: &Path) -> Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? { + let entry = entry?; + let file_type = entry.file_type()?; + let path = entry.path(); + if file_type.is_dir() { + delete_cooked_files_recursive(&path)?; + } else if file_type.is_file() { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext == "cooked_mesh" || ext == "cooked_tex" { + fs::remove_file(&path) + .with_context(|| format!("Failed to delete {}", path.display()))?; + println!("Deleted {}", path.display()); + } + } + } + Ok(()) +} + +fn run_asset_pipeline(res_dir: &Path, force_rebuild: bool) -> Result<()> { + if force_rebuild { + println!("Force-rebuild: deleting cooked files under {}...", res_dir.display()); + delete_cooked_files_recursive(res_dir)?; + } + let pipeline = pill_assets::Pipeline { + root: res_dir.to_path_buf(), + rules: pill_assets::default_rules(), + }; + let stats = pipeline.run().context("Asset pipeline failed")?; + println!( + "Assets: discovered={} rebuilt={} skipped={} (root: {})", + stats.discovered.len(), + stats.rebuilt.len(), + stats.skipped.len(), + res_dir.display() + ); + Ok(()) +} + // Render all *.puml under /docs/uml into /docs/uml_out as SVGs fn render_puml_for_crate(crate_dir: &Path) -> Result<()> { let in_dir = crate_dir.join("docs").join("uml"); @@ -1191,6 +1233,12 @@ fn run_app() -> Result<()> { .required(false) .help("Fail WASM release build if final binary exceeds N KB"); + let force_rebuild_option = Arg::with_name("force-rebuild") + .long("force-rebuild") + .takes_value(false) + .required(false) + .help("Delete all cooked asset files and rebuild from source. For 'assets': rebuild the pipeline. For 'run'/'build': rebuild assets then build."); + let game_args = Arg::with_name("game-args") .help("Arguments passed through to cargo/game (use `--` to separate them)") .multiple(true) @@ -1206,6 +1254,7 @@ fn run_app() -> Result<()> { .arg(compile_mode_option) .arg(target_option) .arg(max_wasm_size_option) + .arg(force_rebuild_option) .arg(game_args) .setting(AppSettings::TrailingVarArg); @@ -1240,6 +1289,8 @@ fn run_app() -> Result<()> { .value_of("max-wasm-size") .and_then(|s| s.parse().ok()); + let force_rebuild = matches.is_present("force-rebuild"); + match action_argument { "create" => { let game_parent_directory_path = PathBuf::from(directory_path_argument.expect("Game project parent directory path has to be specified using --path flag. For example: --path ")) @@ -1255,6 +1306,11 @@ fn run_app() -> Result<()> { .absolutize().context("Failed to absolutize game project directory path")? .to_path_buf(); + if force_rebuild { + run_asset_pipeline(&game_project_directory_path.join("res"), true) + .context("Asset rebuild failed")?; + } + match target { BuildTarget::Native => { let mut output_directory_path = PathBuf::from(output_directory_path_argument.expect("Output directory path has to be specified using --output-path flag. For example: --output-path ")); @@ -1283,6 +1339,11 @@ fn run_app() -> Result<()> { .absolutize().context("Failed to absolutize game project directory path")? .to_path_buf(); + if force_rebuild { + run_asset_pipeline(&game_project_directory_path.join("res"), true) + .context("Asset rebuild failed")?; + } + match target { BuildTarget::Native => { let mut output_directory_path = PathBuf::from(output_directory_path_argument.expect("Output directory path has to be specified using --output-path flag. For example: --output-path ")); @@ -1340,18 +1401,8 @@ fn run_app() -> Result<()> { .context("Failed to absolutize project directory path")? .to_path_buf(); - let pipeline = pill_assets::Pipeline { - root: project_dir.join("res"), - rules: pill_assets::default_rules(), - }; - let stats = pipeline.run().context("Asset pipeline failed")?; - println!( - "Assets: discovered={} rebuilt={} skipped={} (root: {})", - stats.discovered.len(), - stats.rebuilt.len(), - stats.skipped.len(), - pipeline.root.display() - ); + run_asset_pipeline(&project_dir.join("res"), force_rebuild) + .context("Asset pipeline failed")?; } _ => { println!("Undefined action"); diff --git a/examples/pbr_balls/src/game.rs b/examples/pbr_balls/src/game.rs index 2bf9c60a..62cff4f2 100644 --- a/examples/pbr_balls/src/game.rs +++ b/examples/pbr_balls/src/game.rs @@ -87,7 +87,6 @@ fn orbit_camera_system(engine: &mut Engine) -> Result<()> { let mouse_delta = input.get_mouse_delta(); let scroll_delta = input.get_mouse_scroll_delta(); let lmb = input.get_mouse_button(MouseButton::Left); - drop(input); for (_, tfm, orbit) in engine.iterate_two_components_mut::()? { if lmb { diff --git a/examples/pbr_helmet/res/config.ini b/examples/pbr_helmet/res/config.ini index 1b121a81..1ad0640d 100644 --- a/examples/pbr_helmet/res/config.ini +++ b/examples/pbr_helmet/res/config.ini @@ -3,8 +3,8 @@ TITLE=PBR Helmet # WINDOW WINDOW_TITLE=Pill Engine - PBR Helmet -WINDOW_WIDTH=1280 -WINDOW_HEIGHT=720 +WINDOW_WIDTH=1920 +WINDOW_HEIGHT=1080 WINDOW_FULLSCREEN=false # ENGINE GENERAL diff --git a/examples/pbr_helmet/src/game.rs b/examples/pbr_helmet/src/game.rs index 99583892..e7858276 100644 --- a/examples/pbr_helmet/src/game.rs +++ b/examples/pbr_helmet/src/game.rs @@ -1,21 +1,37 @@ -use pill_engine::game::*; +use pill_engine::{define_component, game::*}; -pub struct Game {} +define_component!(OrbitCamera { + yaw: f32, + pitch: f32, + radius: f32, +}); -const TARGET_FPS: f32 = 60.0; +pub struct Game {} -fn rotate_system(engine: &mut Engine) -> Result<()> { - let dt = engine - .get_global_component::()? - .delta_time - .min(1.0 / TARGET_FPS); +fn orbit_camera_system(engine: &mut Engine) -> Result<()> { + let input = engine.get_global_component_mut::()?; + let mouse_delta = input.get_mouse_delta(); + let scroll_delta = input.get_mouse_scroll_delta(); + let left_mouse_button_held = input.get_mouse_button(MouseButton::Left); - for (_entity, transform, _pbr) in - engine.iterate_two_components_mut::()? + for (_, transform, orbit) in + engine.iterate_two_components_mut::()? { - transform.rotate_around_axis(2.0 * dt, Vector3f::new(0.0, 0.0, 1.0)); - } + if left_mouse_button_held { + orbit.yaw -= mouse_delta.x * 0.3; + orbit.pitch = (orbit.pitch + mouse_delta.y * 0.3).clamp(-70.0, 70.0); + } + orbit.radius = (orbit.radius - scroll_delta.y * 0.5).clamp(1.0, 20.0); + let pitch_radians = orbit.pitch.to_radians(); + let yaw_radians = orbit.yaw.to_radians(); + let radius_cos_pitch = orbit.radius * pitch_radians.cos(); + transform.set_position(Vector3f::new( + radius_cos_pitch * yaw_radians.sin(), + orbit.radius * pitch_radians.sin(), + radius_cos_pitch * yaw_radians.cos(), + )); + } Ok(()) } @@ -27,6 +43,7 @@ impl PillGame for Game { engine.register_component::(scene)?; engine.register_component::(scene)?; engine.register_component::(scene)?; + engine.register_component::(scene)?; let mesh_handle = engine.add_resource(Mesh::new( "helmet_mesh", @@ -45,24 +62,39 @@ impl PillGame for Game { ResourceLoader::Path("models/DamagedHelmet_normal.cooked_tex".into()), ))?; + let metallic_roughness_handle = engine.add_resource(Texture::new( + "helmet_metallic_roughness", + TextureType::MetallicRoughness, + ResourceLoader::Path("models/DamagedHelmet_metallic_roughness.cooked_tex".into()), + ))?; + + let emissive_handle = engine.add_resource(Texture::new( + "helmet_emissive", + TextureType::Emissive, + ResourceLoader::Path("models/DamagedHelmet_emissive.cooked_tex".into()), + ))?; + let material_handle = engine.add_resource( PBRMaterial::new("helmet_material") .albedo_texture(albedo_handle) - .normal_texture(normal_handle), + .normal_texture(normal_handle) + .metallic_roughness_texture(metallic_roughness_handle) + .metallic(1.0) + .roughness(1.0) + .emissive_texture(emissive_handle), )?; engine .build_entity(scene) .with_component( TransformComponent::builder() - .position(Vector3f::new(0.0, 0.0, -3.0)) + .rotation(Vector3f::new(0.0, 0.0, 0.0)) .build(), ) .with_component( - CameraComponent::builder() - .enabled(true) - .fov(60.0) - .clear_color(Color::new(0.05, 0.05, 0.06)) + PbrRenderableComponent::builder() + .mesh(&mesh_handle) + .pbr_material(&material_handle) .build(), ) .build(); @@ -71,18 +103,25 @@ impl PillGame for Game { .build_entity(scene) .with_component( TransformComponent::builder() - .rotation(Vector3f::new(90.0, 0.0, 0.0)) + .position(Vector3f::new(0.0, 0.0, 2.0)) .build(), ) .with_component( - PbrRenderableComponent::builder() - .mesh(&mesh_handle) - .pbr_material(&material_handle) + CameraComponent::builder() + .enabled(true) + .fov(60.0) + .clear_color(Color::new(0.05, 0.05, 0.06)) + .look_at(Some(Vector3f::new(0.0, 0.0, 0.0))) .build(), ) + .with_component(OrbitCamera { + yaw: 0.0, + pitch: 0.0, + radius: 3.0, + }) .build(); - engine.add_system("rotate", rotate_system)?; + engine.add_system("orbit_camera", orbit_camera_system)?; Ok(()) } From 252a32779cab0595d7ca62da1c699f817f0e9894 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sat, 30 May 2026 18:23:46 +0200 Subject: [PATCH 12/31] fix ci --- engine/pill_assets/src/rules/equirect_to_ibl.rs | 2 +- engine/pill_engine/src/graphics/render_queue.rs | 2 ++ engine/pill_engine/src/renderer/state.rs | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/engine/pill_assets/src/rules/equirect_to_ibl.rs b/engine/pill_assets/src/rules/equirect_to_ibl.rs index 2fd05dd7..07d238e0 100644 --- a/engine/pill_assets/src/rules/equirect_to_ibl.rs +++ b/engine/pill_assets/src/rules/equirect_to_ibl.rs @@ -160,7 +160,7 @@ fn tangent_to_world(v: [f32; 3], n: [f32; 3]) -> [f32; 3] { // --- Hammersley quasi-random sequence --- fn radical_inverse_vdc(mut bits: u32) -> f32 { - bits = (bits << 16) | (bits >> 16); + bits = bits.rotate_right(16); bits = ((bits & 0x5555_5555) << 1) | ((bits & 0xAAAA_AAAA) >> 1); bits = ((bits & 0x3333_3333) << 2) | ((bits & 0xCCCC_CCCC) >> 2); bits = ((bits & 0x0F0F_0F0F) << 4) | ((bits & 0xF0F0_F0F0) >> 4); diff --git a/engine/pill_engine/src/graphics/render_queue.rs b/engine/pill_engine/src/graphics/render_queue.rs index 649a0f8c..d301b640 100644 --- a/engine/pill_engine/src/graphics/render_queue.rs +++ b/engine/pill_engine/src/graphics/render_queue.rs @@ -87,6 +87,7 @@ pub const RENDER_QUEUE_KEY_MESH_VERSION: RenderQueueFieldDesc = // --- Compose --- +#[cfg_attr(feature = "headless", allow(unused_variables))] pub fn compose_render_queue_key( resource_manager: &ResourceManager, material_handle: &MaterialHandle, @@ -128,6 +129,7 @@ pub fn compose_render_queue_key( } /// PBR path: PBRMaterial always uses default lit shader +#[cfg_attr(feature = "headless", allow(unused_variables))] pub fn compose_pbr_render_queue_key( resource_manager: &ResourceManager, material_handle: PBRMaterialHandle, diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs index 1c607ee0..0da92c5c 100644 --- a/engine/pill_engine/src/renderer/state.rs +++ b/engine/pill_engine/src/renderer/state.rs @@ -223,6 +223,7 @@ impl PillRenderer for Renderer { slice.map_async(wgpu::MapMode::Read, |_| {}); let _ = self.state.device.poll(wgpu::MaintainBase::Wait); let data = slice.get_mapped_range(); + #[cfg(not(target_arch = "wasm32"))] save_screenshot(&path, &data, w, h, bytes_per_row, self.state.color_format); drop(data); buf.unmap(); @@ -621,6 +622,7 @@ impl State { } } +#[cfg(not(target_arch = "wasm32"))] fn save_screenshot( path: &str, data: &[u8], From 93ea09893cd2ddcb6f062b9ae5d948302120a44c Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sat, 30 May 2026 19:05:48 +0200 Subject: [PATCH 13/31] fix CI failures: clippy lints, WASM egui size, codesign on Linux, workspace path - div_ceil/repeat_n: replace manual bit-alignment arithmetic with .div_ceil() and std::iter::repeat_n in pass_pbr_static.rs and state.rs - needless_range_loop: rewrite mat4_mul inner loop as (0..4).map(...).sum() in glb_to_cooked_mesh.rs - egui native-only: move egui + egui-wgpu from unconditional deps to [target.'cfg(not(target_arch = "wasm32"))'.dependencies]; gate egui_client.rs, EguiClient re-export, RenderStateComponent.egui_client field, and init_default_passes signature/call sites with #[cfg(not(target_arch = "wasm32"))] to restore WASM binary size below 499 KB budget - codesign: gate codesign_adhoc function and call sites with #[cfg(target_os = "macos")] so Linux CI does not try to invoke the tool - workspace path: restore NO_PATH placeholder in engine/Cargo.toml so the launcher can substitute the correct game path on any machine - pill_tunel: add egui = "0.32.1" direct dependency (game.rs uses egui:: types); fix workspace field to NO_PATH --- engine/Cargo.toml | 2 +- .../src/rules/glb_to_cooked_mesh.rs | 4 +-- engine/pill_engine/Cargo.toml | 6 ++-- .../ecs/components/render_state_component.rs | 15 ++++++---- engine/pill_engine/src/ecs/egui_client.rs | 2 ++ engine/pill_engine/src/ecs/mod.rs | 1 + .../src/ecs/systems/rendering_system.rs | 15 ++++++---- engine/pill_engine/src/engine.rs | 3 ++ .../src/graphics/pass_pbr_static.rs | 7 ++--- engine/pill_engine/src/graphics/renderer.rs | 7 ++++- engine/pill_engine/src/renderer/state.rs | 28 +++++++++++++++++-- engine/pill_launcher/src/main.rs | 3 ++ examples/cube/src/game.rs | 6 ++-- examples/pill_tunel/Cargo.toml | 1 + 14 files changed, 70 insertions(+), 30 deletions(-) diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 5e95497d..31944895 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,7 +8,7 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "/Users/mk/dev/demo/Pill-Engine/examples/pbr_helmet", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) + "NO_PATH", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at diff --git a/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs b/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs index bac7b571..d75149c4 100644 --- a/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs +++ b/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs @@ -313,9 +313,7 @@ fn mat4_mul(a: [[f32; 4]; 4], b: [[f32; 4]; 4]) -> [[f32; 4]; 4] { let mut c = [[0.0f32; 4]; 4]; for i in 0..4 { for j in 0..4 { - for k in 0..4 { - c[i][j] += a[i][k] * b[k][j]; - } + c[i][j] = (0..4).map(|k| a[i][k] * b[k][j]).sum(); } } c diff --git a/engine/pill_engine/Cargo.toml b/engine/pill_engine/Cargo.toml index e0867c9e..86d856eb 100644 --- a/engine/pill_engine/Cargo.toml +++ b/engine/pill_engine/Cargo.toml @@ -22,8 +22,6 @@ winit = "0.30.12" bytemuck = { version = "1.4", features = [ "derive" ] } lazy_static = "1.4.0" dyn-clone = "1.0.4" -egui = "0.32.1" -egui-wgpu = { version = "0.32.1", features = ["winit"] } wgpu = "25.0.0" serde = { version = "1.0", features = ["derive"] } bincode = "1.0" @@ -38,10 +36,10 @@ png = "0.17" rodio = { version = "0.20", features = ["wav", "mp3"] } gilrs = "0.11.0" pollster = "0.2" +egui = "0.32.1" +egui-wgpu = { version = "0.32.1", features = ["winit"] } egui-winit = { version = "0.32.1", features = ["clipboard"] } -[target.'cfg(target_arch = "wasm32")'.dependencies] -egui-winit = { version = "0.32.1", default-features = false } [build-dependencies] fs_extra = "1.2" diff --git a/engine/pill_engine/src/ecs/components/render_state_component.rs b/engine/pill_engine/src/ecs/components/render_state_component.rs index da74f28c..910ad23c 100644 --- a/engine/pill_engine/src/ecs/components/render_state_component.rs +++ b/engine/pill_engine/src/ecs/components/render_state_component.rs @@ -1,24 +1,27 @@ -use std::sync::Arc; - -use crate::ecs::egui_client::EguiClient; - use crate::ecs::components::{GlobalComponent, GlobalComponentStorage}; use pill_core::PillTypeMapKey; pub struct RenderStateComponent { pub boot_done: bool, - pub egui_client: Arc, + #[cfg(not(target_arch = "wasm32"))] + pub egui_client: std::sync::Arc, } impl RenderStateComponent { /// Creates the component in its pre-boot state with the provided egui client. - pub fn new(egui_client: Arc) -> Self { + #[cfg(not(target_arch = "wasm32"))] + pub fn new(egui_client: std::sync::Arc) -> Self { Self { boot_done: false, egui_client, } } + + #[cfg(target_arch = "wasm32")] + pub fn new() -> Self { + Self { boot_done: false } + } } impl PillTypeMapKey for RenderStateComponent { diff --git a/engine/pill_engine/src/ecs/egui_client.rs b/engine/pill_engine/src/ecs/egui_client.rs index c06431dd..869fe1d1 100644 --- a/engine/pill_engine/src/ecs/egui_client.rs +++ b/engine/pill_engine/src/ecs/egui_client.rs @@ -1,3 +1,5 @@ +#![cfg(not(target_arch = "wasm32"))] + use std::sync::{Arc, Mutex}; type UiFunction = Box; diff --git a/engine/pill_engine/src/ecs/mod.rs b/engine/pill_engine/src/ecs/mod.rs index 8e987d89..20578985 100644 --- a/engine/pill_engine/src/ecs/mod.rs +++ b/engine/pill_engine/src/ecs/mod.rs @@ -34,6 +34,7 @@ pub use components::egui_manager_component::EguiManagerComponent; pub use components::render_state_component::RenderStateComponent; +#[cfg(not(target_arch = "wasm32"))] pub use egui_client::EguiClient; pub use components::deferred_update_component::{ diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index 746a5679..f1a6440c 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -23,11 +23,16 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { .boot_done; if !boot_done { - let egui_client = engine - .get_global_component::()? - .egui_client - .clone(); - engine.renderer.init_default_passes(egui_client)?; + #[cfg(not(target_arch = "wasm32"))] + { + let egui_client = engine + .get_global_component::()? + .egui_client + .clone(); + engine.renderer.init_default_passes(egui_client)?; + } + #[cfg(target_arch = "wasm32")] + engine.renderer.init_default_passes()?; engine .get_global_component_mut::()? .boot_done = true; diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index 0d50d391..6bc032ec 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -369,7 +369,10 @@ impl Engine { #[cfg(not(feature = "headless"))] { self.add_global_component(InputComponent::new())?; + #[cfg(not(target_arch = "wasm32"))] self.add_global_component(RenderStateComponent::new(EguiClient::new()))?; + #[cfg(target_arch = "wasm32")] + self.add_global_component(RenderStateComponent::new())?; } #[cfg(all(not(feature = "headless"), not(target_arch = "wasm32")))] diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_static.rs index f3803817..9dca322f 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_static.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_static.rs @@ -79,9 +79,8 @@ unsafe impl bytemuck::Pod for PerDrawStd140 {} // Preallocated dynamic-UBO ring: 256-byte stride to satisfy min UBO offset alignment. const MAX_EXPECTED_PER_DRAW_INSTANCES: usize = 100_000; const UNIFORM_OFFSET_ALIGNMENT: usize = 256; -const PER_DRAW_STRIDE_BYTES: usize = ((std::mem::size_of::() - + (UNIFORM_OFFSET_ALIGNMENT - 1)) - / UNIFORM_OFFSET_ALIGNMENT) +const PER_DRAW_STRIDE_BYTES: usize = std::mem::size_of::() + .div_ceil(UNIFORM_OFFSET_ALIGNMENT) * UNIFORM_OFFSET_ALIGNMENT; pub const MATERIAL_BIND_GROUP_GLOBALS: usize = 0; @@ -775,7 +774,7 @@ impl Pass for PassPBRStatic { self.staging_buffer .extend_from_slice(bytemuck::bytes_of(per_draw)); let pad = (PER_DRAW_STRIDE_BYTES) - std::mem::size_of::(); - self.staging_buffer.extend(std::iter::repeat(0u8).take(pad)); + self.staging_buffer.extend(std::iter::repeat_n(0u8, pad)); next_offset_u32 = next_offset_u32.wrapping_add(PER_DRAW_STRIDE_BYTES as u32); } } diff --git a/engine/pill_engine/src/graphics/renderer.rs b/engine/pill_engine/src/graphics/renderer.rs index 17cb68f3..3c7ead56 100644 --- a/engine/pill_engine/src/graphics/renderer.rs +++ b/engine/pill_engine/src/graphics/renderer.rs @@ -1,7 +1,9 @@ #![allow(clippy::too_many_arguments)] +#[cfg(not(target_arch = "wasm32"))] +use crate::ecs::EguiClient; use crate::{ app_config::EngineConfig, - ecs::{CameraComponent, ComponentStorage, EguiClient, EntityHandle, TransformComponent}, + ecs::{CameraComponent, ComponentStorage, EntityHandle, TransformComponent}, graphics::RenderQueueItem, resources::{ResourceManager, ShaderParameterSlot, ShaderTextureSlot, TextureType}, }; @@ -157,7 +159,10 @@ pub trait PillRenderer { fn set_passes(&mut self, passes: Vec>) -> Result<()>; /// Installs the default pass chain (scene + optional egui) on first frame bootstrap. + #[cfg(not(target_arch = "wasm32"))] fn init_default_passes(&mut self, egui_client: Arc) -> Result<()>; + #[cfg(target_arch = "wasm32")] + fn init_default_passes(&mut self) -> Result<()>; /// Returns the wgpu `Device`; required by passes that allocate their own GPU resources. fn get_device(&self) -> &wgpu::Device; diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs index 0da92c5c..15b252b5 100644 --- a/engine/pill_engine/src/renderer/state.rs +++ b/engine/pill_engine/src/renderer/state.rs @@ -1,7 +1,6 @@ #![allow(clippy::too_many_arguments)] use crate::{ app_config::EngineConfig, - ecs::EguiClient, graphics::{ BufferDesc, Pass, PassPBRStatic, PillRenderer, PipelineV2, PipelineV2Desc, RendererCameraHandle, RendererTargetDesc, RendererTextureHandle, WorldQuery, @@ -19,6 +18,8 @@ use crate::{ resources::ResourceManager, }; +#[cfg(not(target_arch = "wasm32"))] +use crate::ecs::EguiClient; use pill_core::Result; use pill_core::{ debug, info, LogContext, PillSlotMap, PillSlotMapKey, PillStyle, RendererError, Timer, @@ -188,7 +189,7 @@ impl PillRenderer for Renderer { let w = self.state.surface_configuration.width; let h = self.state.surface_configuration.height; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; - let bytes_per_row = (4 * w + align - 1) / align * align; + let bytes_per_row = (4 * w).div_ceil(align) * align; let buf = self.state.device.create_buffer(&wgpu::BufferDescriptor { label: Some("screenshot_readback"), size: (bytes_per_row * h) as u64, @@ -246,6 +247,7 @@ impl PillRenderer for Renderer { } /// Installs the default pass chain (scene + optional egui) on first frame bootstrap. + #[cfg(not(target_arch = "wasm32"))] fn init_default_passes(&mut self, egui_client: Arc) -> Result<()> { use crate::graphics::{PassBackground, PassTonemap}; self.state.egui_client = Some(egui_client.clone()); @@ -277,6 +279,26 @@ impl PillRenderer for Renderer { ]) } + #[cfg(target_arch = "wasm32")] + fn init_default_passes(&mut self) -> Result<()> { + use crate::graphics::{PassBackground, PassTonemap}; + + let w = self.state.surface_configuration.width; + let h = self.state.surface_configuration.height; + let hdr = self.create_render_target(RendererTargetDesc { + name: "hdr_target".to_string(), + format: wgpu::TextureFormat::Rgba16Float, + width: w, + height: h, + })?; + + self.set_passes(vec![ + Box::new(PassBackground::new(hdr)), + Box::new(PassPBRStatic::new(Some(hdr))), + Box::new(PassTonemap::new(hdr)), + ]) + } + fn get_device(&self) -> &wgpu::Device { &self.state.device } @@ -421,6 +443,7 @@ pub struct State { depth_format: wgpu::TextureFormat, depth_texture: RendererTexture, passes: Vec>, + #[cfg(not(target_arch = "wasm32"))] egui_client: Option>, camera_bind_group_layout: wgpu::BindGroupLayout, window: Arc, @@ -598,6 +621,7 @@ impl State { depth_format, depth_texture, passes: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] egui_client: None, camera_bind_group_layout, window, diff --git a/engine/pill_launcher/src/main.rs b/engine/pill_launcher/src/main.rs index b64c3525..11a7b446 100644 --- a/engine/pill_launcher/src/main.rs +++ b/engine/pill_launcher/src/main.rs @@ -188,6 +188,7 @@ pub(crate) fn modify_file String>( Ok(()) } +#[cfg(target_os = "macos")] fn codesign_adhoc(path: &PathBuf) -> Result<()> { let status = Command::new("codesign") .args(["--force", "--sign", "-", path.to_str().unwrap_or("")]) @@ -952,12 +953,14 @@ fn build_game_project( if *compile_mode != CompileMode::HotReload || !hot_reload_child { if copy_if_newer(&game_src, &data_dir.join(dylib("pill_game")))? { println!("Copied game dylib"); + #[cfg(target_os = "macos")] codesign_adhoc(&data_dir.join(dylib("pill_game")))?; } else { println!("Skipping copying of game dylib"); } if copy_if_newer(&runtime_src, &data_dir.join(dylib("pill_runtime")))? { println!("Copied runtime dylib"); + #[cfg(target_os = "macos")] codesign_adhoc(&data_dir.join(dylib("pill_runtime")))?; } else { println!("Skipping copying of runtime dylib"); diff --git a/examples/cube/src/game.rs b/examples/cube/src/game.rs index c321e28c..86a5bba6 100644 --- a/examples/cube/src/game.rs +++ b/examples/cube/src/game.rs @@ -29,10 +29,8 @@ impl PillGame for WebGame { engine.register_component::(active_scene)?; let cube_mesh_handle = engine.add_resource(Mesh::cube("cube", 2.0))?; - let material_handle = engine.add_resource( - PBRMaterial::new("cube_material") - .albedo(Color::new(0.80, 0.80, 0.82)), - )?; + let material_handle = engine + .add_resource(PBRMaterial::new("cube_material").albedo(Color::new(0.80, 0.80, 0.82)))?; engine .build_entity(active_scene) diff --git a/examples/pill_tunel/Cargo.toml b/examples/pill_tunel/Cargo.toml index c7290ae9..f79413df 100644 --- a/examples/pill_tunel/Cargo.toml +++ b/examples/pill_tunel/Cargo.toml @@ -9,4 +9,5 @@ crate-type = ["cdylib", "rlib"] [dependencies] pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal"] } +egui = "0.32.1" png = "0.17" From 24c56e9ec30646e668602682c3ea5beed6e9e752 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sat, 30 May 2026 19:56:41 +0200 Subject: [PATCH 14/31] fix ci --- .gitignore | 5 ++- engine/pill_core/src/pill_typemap.rs | 8 ++-- engine/pill_engine/Cargo.toml | 10 +++-- engine/pill_engine/src/config.rs | 2 +- .../ecs/components/egui_manager_component.rs | 18 +++++++- engine/pill_engine/src/ecs/components/mod.rs | 2 +- .../ecs/components/render_state_component.rs | 13 +----- engine/pill_engine/src/ecs/egui_client.rs | 2 +- engine/pill_engine/src/ecs/mod.rs | 4 +- .../src/ecs/systems/rendering_system.rs | 19 ++------ engine/pill_engine/src/engine.rs | 22 +++++++--- .../src/graphics/dummy_renderer.rs | 2 +- engine/pill_engine/src/graphics/mod.rs | 4 +- engine/pill_engine/src/graphics/renderer.rs | 11 ++--- engine/pill_engine/src/lib.rs | 20 +++++---- engine/pill_engine/src/renderer/state.rs | 43 ++++++------------- examples/pill_tunel/Cargo.toml | 2 +- 17 files changed, 91 insertions(+), 96 deletions(-) diff --git a/.gitignore b/.gitignore index f5d9c8bd..8f06af58 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,6 @@ engine/target_* *.cooked_mesh *.cooked_tex -examples/pbr_helmet/res/models/DamagedHelmet.glb -examples/pbr_balls/res/models/MetalRoughSpheres.glb +DamagedHelmet.glb +MetalRoughSpheres.glb +.claude diff --git a/engine/pill_core/src/pill_typemap.rs b/engine/pill_core/src/pill_typemap.rs index deba4aba..ff2eb976 100644 --- a/engine/pill_core/src/pill_typemap.rs +++ b/engine/pill_core/src/pill_typemap.rs @@ -17,7 +17,7 @@ use std::marker::PhantomData; /// [`PillTypeMap`]: struct.PillTypeMap.html pub trait PillTypeMapKey: Any { /// Defines the value type that corresponds to this `PillTypeMapKey`. - type Storage: Send; //+ Sync; + type Storage; //: Send + Sync; } /// PillTypeMap is a simple abstraction around the standard library's [`HashMap`] @@ -27,7 +27,7 @@ pub trait PillTypeMapKey: Any { /// [`HashMap`]: std::collections::HashMap //pub struct PillTypeMap(HashMap>); #[derive(Default)] -pub struct PillTypeMap(HashMap>); +pub struct PillTypeMap(HashMap>); impl PillTypeMap { /// Creates a new instance of `PillTypeMap`. @@ -268,7 +268,7 @@ where K: PillTypeMapKey, { //entry: HashMapOccupiedEntry<'a, TypeId, Box<(dyn Any + Send + Sync)>>, - entry: HashMapOccupiedEntry<'a, TypeId, Box>, + entry: HashMapOccupiedEntry<'a, TypeId, Box>, _marker: PhantomData<&'a K::Storage>, } @@ -307,7 +307,7 @@ where K: PillTypeMapKey, { //entry: HashMapVacantEntry<'a, TypeId, Box<(dyn Any + Send + Sync)>>, - entry: HashMapVacantEntry<'a, TypeId, Box>, + entry: HashMapVacantEntry<'a, TypeId, Box>, _marker: PhantomData<&'a K::Storage>, } diff --git a/engine/pill_engine/Cargo.toml b/engine/pill_engine/Cargo.toml index 86d856eb..3fdef1f1 100644 --- a/engine/pill_engine/Cargo.toml +++ b/engine/pill_engine/Cargo.toml @@ -30,15 +30,17 @@ bitvec = "1.0.1" rand = "0.9.2" web-time = "1.1" +# Optional deps activated by feature flags +egui = { version = "0.32.1", optional = true } +egui-wgpu = { version = "0.32.1", features = ["winit"], optional = true } +egui-winit = { version = "0.32.1", default-features = false, optional = true } + # Native-only dependencies (not available on WASM) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] png = "0.17" rodio = { version = "0.20", features = ["wav", "mp3"] } gilrs = "0.11.0" pollster = "0.2" -egui = "0.32.1" -egui-wgpu = { version = "0.32.1", features = ["winit"] } -egui-winit = { version = "0.32.1", features = ["clipboard"] } [build-dependencies] @@ -50,6 +52,6 @@ game = [] internal = [] headless = [] networking = [] -debug_ui = [] +ui = ["dep:egui", "dep:egui-wgpu", "dep:egui-winit"] obj_loading = ["dep:tobj"] all = ["game", "internal", "networking"] diff --git a/engine/pill_engine/src/config.rs b/engine/pill_engine/src/config.rs index 06665414..c033c794 100644 --- a/engine/pill_engine/src/config.rs +++ b/engine/pill_engine/src/config.rs @@ -248,7 +248,7 @@ lazy_static! { ]; #[cfg(not(target_arch = "wasm32"))] component_types.push(TypeId::of::()); - #[cfg(feature = "debug_ui")] + #[cfg(feature = "ui")] component_types.push(TypeId::of::()); component_types }; diff --git a/engine/pill_engine/src/ecs/components/egui_manager_component.rs b/engine/pill_engine/src/ecs/components/egui_manager_component.rs index 6aba683d..430cba8e 100644 --- a/engine/pill_engine/src/ecs/components/egui_manager_component.rs +++ b/engine/pill_engine/src/ecs/components/egui_manager_component.rs @@ -1,6 +1,6 @@ -#![cfg(feature = "debug_ui")] +#![cfg(feature = "ui")] -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use crate::{ ecs::{ @@ -17,6 +17,7 @@ use pill_core::{ErrorContext, Result}; pub struct EguiManagerComponent { collapsing_state: HashMap, + game_overlay: Option>, } impl Default for EguiManagerComponent { @@ -29,10 +30,20 @@ impl EguiManagerComponent { pub fn new() -> Self { Self { collapsing_state: HashMap::new(), + game_overlay: None, } } + pub fn set_overlay(&mut self, f: impl Fn(&egui::Context) + Send + Sync + 'static) { + self.game_overlay = Some(Arc::new(f)); + } + pub fn get_ui(engine: &mut Engine) -> Box { + let game_overlay = engine + .get_global_component::() + .ok() + .and_then(|c| c.game_overlay.clone()); + let entity_count = engine .scene_manager .get_active_scene() @@ -76,6 +87,9 @@ impl EguiManagerComponent { let frame_delta_time = engine.frame_delta_time; let ui: Box = Box::new(move |ui: &egui::Context| { + if let Some(ref overlay) = game_overlay { + overlay(ui); + } egui::Window::new("Pill Engine") .default_open(false) .resizable(true) diff --git a/engine/pill_engine/src/ecs/components/mod.rs b/engine/pill_engine/src/ecs/components/mod.rs index 1baa1dab..75460ec8 100644 --- a/engine/pill_engine/src/ecs/components/mod.rs +++ b/engine/pill_engine/src/ecs/components/mod.rs @@ -10,7 +10,7 @@ pub(crate) mod camera_component; mod component; mod component_storage; pub(crate) mod deferred_update_component; -#[cfg(feature = "debug_ui")] +#[cfg(feature = "ui")] pub(crate) mod egui_manager_component; pub(crate) mod input_component; pub(crate) mod mesh_component; diff --git a/engine/pill_engine/src/ecs/components/render_state_component.rs b/engine/pill_engine/src/ecs/components/render_state_component.rs index 910ad23c..9df81a2e 100644 --- a/engine/pill_engine/src/ecs/components/render_state_component.rs +++ b/engine/pill_engine/src/ecs/components/render_state_component.rs @@ -4,21 +4,10 @@ use pill_core::PillTypeMapKey; pub struct RenderStateComponent { pub boot_done: bool, - #[cfg(not(target_arch = "wasm32"))] - pub egui_client: std::sync::Arc, } impl RenderStateComponent { - /// Creates the component in its pre-boot state with the provided egui client. - #[cfg(not(target_arch = "wasm32"))] - pub fn new(egui_client: std::sync::Arc) -> Self { - Self { - boot_done: false, - egui_client, - } - } - - #[cfg(target_arch = "wasm32")] + /// Creates the component in its pre-boot state. pub fn new() -> Self { Self { boot_done: false } } diff --git a/engine/pill_engine/src/ecs/egui_client.rs b/engine/pill_engine/src/ecs/egui_client.rs index 869fe1d1..eea24084 100644 --- a/engine/pill_engine/src/ecs/egui_client.rs +++ b/engine/pill_engine/src/ecs/egui_client.rs @@ -1,4 +1,4 @@ -#![cfg(not(target_arch = "wasm32"))] +#![cfg(feature = "ui")] use std::sync::{Arc, Mutex}; diff --git a/engine/pill_engine/src/ecs/mod.rs b/engine/pill_engine/src/ecs/mod.rs index 20578985..664d4e1b 100644 --- a/engine/pill_engine/src/ecs/mod.rs +++ b/engine/pill_engine/src/ecs/mod.rs @@ -29,12 +29,12 @@ pub use components::audio_listener_component::AudioListenerComponent; #[cfg(not(target_arch = "wasm32"))] pub use components::audio_source_component::AudioSourceComponent; -#[cfg(feature = "debug_ui")] +#[cfg(feature = "ui")] pub use components::egui_manager_component::EguiManagerComponent; pub use components::render_state_component::RenderStateComponent; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "ui")] pub use egui_client::EguiClient; pub use components::deferred_update_component::{ diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index f1a6440c..7229c78d 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -23,15 +23,6 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { .boot_done; if !boot_done { - #[cfg(not(target_arch = "wasm32"))] - { - let egui_client = engine - .get_global_component::()? - .egui_client - .clone(); - engine.renderer.init_default_passes(egui_client)?; - } - #[cfg(target_arch = "wasm32")] engine.renderer.init_default_passes()?; engine .get_global_component_mut::()? @@ -113,15 +104,13 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { timer.record("Get component storages"); // Build egui UI and push to egui_client - #[cfg(feature = "debug_ui")] + #[cfg(feature = "ui")] { use crate::ecs::EguiManagerComponent; let egui_ui = EguiManagerComponent::get_ui(engine); - let egui_client = engine - .get_global_component::()? - .egui_client - .clone(); - egui_client.set_ui(egui_ui); + if let Some(egui_client) = engine.renderer.get_egui_client() { + egui_client.set_ui(egui_ui); + } } let active_scene = engine.scene_manager.get_active_scene_mut()?; diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index 6bc032ec..4f5b15da 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -363,15 +363,12 @@ impl Engine { // Register global components self.add_global_component(TimeComponent::new())?; self.add_global_component(DeferredUpdateComponent::new())?; - #[cfg(feature = "debug_ui")] + #[cfg(feature = "ui")] self.add_global_component(EguiManagerComponent::new())?; #[cfg(not(feature = "headless"))] { self.add_global_component(InputComponent::new())?; - #[cfg(not(target_arch = "wasm32"))] - self.add_global_component(RenderStateComponent::new(EguiClient::new()))?; - #[cfg(target_arch = "wasm32")] self.add_global_component(RenderStateComponent::new())?; } @@ -590,9 +587,9 @@ impl Engine { } pub fn pass_input_to_egui(&mut self, event: &winit::event::WindowEvent) { - #[cfg(feature = "debug_ui")] + #[cfg(feature = "ui")] self.renderer.pass_input_to_egui(event).unwrap(); - #[cfg(not(feature = "debug_ui"))] + #[cfg(not(feature = "ui"))] let _ = event; } @@ -605,6 +602,19 @@ impl Engine { /// Pill Engine game API impl Engine { + // --- UI API --- + + /// Sets a game-defined egui overlay; called every frame before the engine debug window. + #[cfg(feature = "ui")] + pub fn set_game_overlay( + &mut self, + f: impl Fn(&egui::Context) + Send + Sync + 'static, + ) -> Result<()> { + self.get_global_component_mut::()? + .set_overlay(f); + Ok(()) + } + // --- System API --- /// Adds game-defined system to the game update phase diff --git a/engine/pill_engine/src/graphics/dummy_renderer.rs b/engine/pill_engine/src/graphics/dummy_renderer.rs index 1c9e989a..248e0aef 100644 --- a/engine/pill_engine/src/graphics/dummy_renderer.rs +++ b/engine/pill_engine/src/graphics/dummy_renderer.rs @@ -49,7 +49,7 @@ impl PillRenderer for DummyRenderer { fn resize(&mut self, _new_window_size: PhysicalSize) {} - #[cfg(feature = "debug_ui")] + #[cfg(feature = "ui")] fn pass_input_to_egui(&mut self, _event: &WindowEvent) -> Result<()> { Ok(()) } diff --git a/engine/pill_engine/src/graphics/mod.rs b/engine/pill_engine/src/graphics/mod.rs index d3827263..2e007d6e 100644 --- a/engine/pill_engine/src/graphics/mod.rs +++ b/engine/pill_engine/src/graphics/mod.rs @@ -3,7 +3,7 @@ #[cfg(feature = "headless")] mod dummy_renderer; mod pass_background; -#[cfg(feature = "debug_ui")] +#[cfg(feature = "ui")] mod pass_egui; mod pass_mesh; mod pass_pbr_static; @@ -22,7 +22,7 @@ pub use renderer::{ #[cfg(feature = "headless")] pub use self::dummy_renderer::DummyRenderer; pub use pass_background::PassBackground; -#[cfg(feature = "debug_ui")] +#[cfg(feature = "ui")] pub use pass_egui::PassEgui; pub use pass_mesh::PassMesh; pub use pass_pbr_static::PassPBRStatic; diff --git a/engine/pill_engine/src/graphics/renderer.rs b/engine/pill_engine/src/graphics/renderer.rs index 3c7ead56..cc0f3c25 100644 --- a/engine/pill_engine/src/graphics/renderer.rs +++ b/engine/pill_engine/src/graphics/renderer.rs @@ -1,5 +1,5 @@ #![allow(clippy::too_many_arguments)] -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "ui")] use crate::ecs::EguiClient; use crate::{ app_config::EngineConfig, @@ -137,7 +137,7 @@ pub trait PillRenderer { /// Reconfigures the swap chain and depth texture after a window resize. fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize); - #[cfg(feature = "debug_ui")] + #[cfg(feature = "ui")] /// Forwards a winit window event to the egui input handler. fn pass_input_to_egui(&mut self, event: &winit::event::WindowEvent) -> Result<()>; @@ -159,11 +159,12 @@ pub trait PillRenderer { fn set_passes(&mut self, passes: Vec>) -> Result<()>; /// Installs the default pass chain (scene + optional egui) on first frame bootstrap. - #[cfg(not(target_arch = "wasm32"))] - fn init_default_passes(&mut self, egui_client: Arc) -> Result<()>; - #[cfg(target_arch = "wasm32")] fn init_default_passes(&mut self) -> Result<()>; + /// Returns the shared egui client; available only when the `ui` feature is enabled. + #[cfg(feature = "ui")] + fn get_egui_client(&self) -> Option>; + /// Returns the wgpu `Device`; required by passes that allocate their own GPU resources. fn get_device(&self) -> &wgpu::Device; diff --git a/engine/pill_engine/src/lib.rs b/engine/pill_engine/src/lib.rs index 402b76e0..a9da8d3b 100644 --- a/engine/pill_engine/src/lib.rs +++ b/engine/pill_engine/src/lib.rs @@ -93,7 +93,9 @@ pub mod game { #[cfg(not(target_arch = "wasm32"))] mod internal_mod { pub use crate::app_config::EngineConfig; - #[cfg(feature = "debug_ui")] + #[cfg(feature = "ui")] + pub use crate::ecs::EguiClient; + #[cfg(feature = "ui")] pub use crate::ecs::EguiManagerComponent; pub use crate::{ config::*, @@ -102,9 +104,9 @@ mod internal_mod { get_renderer_resource_handle_from_camera_component, networking_system_client, networking_system_server, update_transform_matrices, AudioListenerComponent, AudioManagerComponent, AudioSourceComponent, CameraAspectRatio, CameraComponent, - ComponentStorage, EguiClient, EntityHandle, EntityUpdate, InputComponent, - MeshComponent, NetworkEntityAction, NetworkEntityState, NetworkManagerComponent, - NetworkSide, NetworkStateComponent, NetworkUpdatePayload, PbrRenderableComponent, + ComponentStorage, EntityHandle, EntityUpdate, InputComponent, MeshComponent, + NetworkEntityAction, NetworkEntityState, NetworkManagerComponent, NetworkSide, + NetworkStateComponent, NetworkUpdatePayload, PbrRenderableComponent, RenderStateComponent, Scene, TimeComponent, TransformComponent, }, engine::{Engine, PillGame}, @@ -126,16 +128,18 @@ mod internal_mod { #[cfg(target_arch = "wasm32")] mod internal_mod { pub use crate::app_config::EngineConfig; - #[cfg(feature = "debug_ui")] + #[cfg(feature = "ui")] + pub use crate::ecs::EguiClient; + #[cfg(feature = "ui")] pub use crate::ecs::EguiManagerComponent; pub use crate::{ config::*, ecs::{ get_model_matrix, get_normal_matrix, get_renderer_resource_handle_from_camera_component, update_transform_matrices, - CameraAspectRatio, CameraComponent, ComponentStorage, EguiClient, EntityHandle, - InputComponent, MeshComponent, PbrRenderableComponent, RenderStateComponent, Scene, - TimeComponent, TransformComponent, + CameraAspectRatio, CameraComponent, ComponentStorage, EntityHandle, InputComponent, + MeshComponent, PbrRenderableComponent, RenderStateComponent, Scene, TimeComponent, + TransformComponent, }, engine::{Engine, PillGame}, graphics::{ diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs index 15b252b5..035b0ebb 100644 --- a/engine/pill_engine/src/renderer/state.rs +++ b/engine/pill_engine/src/renderer/state.rs @@ -18,7 +18,7 @@ use crate::{ resources::ResourceManager, }; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "ui")] use crate::ecs::EguiClient; use pill_core::Result; use pill_core::{ @@ -110,7 +110,7 @@ impl PillRenderer for Renderer { self.state.resize(new_window_size) } - #[cfg(feature = "debug_ui")] + #[cfg(feature = "ui")] fn pass_input_to_egui(&mut self, event: &winit::event::WindowEvent) -> Result<()> { if let Some(c) = &self.state.egui_client { c.handle_input(event.clone()); @@ -247,10 +247,8 @@ impl PillRenderer for Renderer { } /// Installs the default pass chain (scene + optional egui) on first frame bootstrap. - #[cfg(not(target_arch = "wasm32"))] - fn init_default_passes(&mut self, egui_client: Arc) -> Result<()> { + fn init_default_passes(&mut self) -> Result<()> { use crate::graphics::{PassBackground, PassTonemap}; - self.state.egui_client = Some(egui_client.clone()); let w = self.state.surface_configuration.width; let h = self.state.surface_configuration.height; @@ -261,17 +259,19 @@ impl PillRenderer for Renderer { height: h, })?; - #[cfg(feature = "debug_ui")] + #[cfg(feature = "ui")] { use crate::graphics::PassEgui; - self.set_passes(vec![ + let egui_client = EguiClient::new(); + self.state.egui_client = Some(egui_client.clone()); + return self.set_passes(vec![ Box::new(PassBackground::new(hdr)), Box::new(PassPBRStatic::new(Some(hdr))), Box::new(PassTonemap::new(hdr)), Box::new(PassEgui::new(self.state.window.clone(), egui_client)), - ]) + ]); } - #[cfg(not(feature = "debug_ui"))] + #[cfg(not(feature = "ui"))] self.set_passes(vec![ Box::new(PassBackground::new(hdr)), Box::new(PassPBRStatic::new(Some(hdr))), @@ -279,24 +279,9 @@ impl PillRenderer for Renderer { ]) } - #[cfg(target_arch = "wasm32")] - fn init_default_passes(&mut self) -> Result<()> { - use crate::graphics::{PassBackground, PassTonemap}; - - let w = self.state.surface_configuration.width; - let h = self.state.surface_configuration.height; - let hdr = self.create_render_target(RendererTargetDesc { - name: "hdr_target".to_string(), - format: wgpu::TextureFormat::Rgba16Float, - width: w, - height: h, - })?; - - self.set_passes(vec![ - Box::new(PassBackground::new(hdr)), - Box::new(PassPBRStatic::new(Some(hdr))), - Box::new(PassTonemap::new(hdr)), - ]) + #[cfg(feature = "ui")] + fn get_egui_client(&self) -> Option> { + self.state.egui_client.clone() } fn get_device(&self) -> &wgpu::Device { @@ -443,7 +428,7 @@ pub struct State { depth_format: wgpu::TextureFormat, depth_texture: RendererTexture, passes: Vec>, - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "ui")] egui_client: Option>, camera_bind_group_layout: wgpu::BindGroupLayout, window: Arc, @@ -621,7 +606,7 @@ impl State { depth_format, depth_texture, passes: Vec::new(), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "ui")] egui_client: None, camera_bind_group_layout, window, diff --git a/examples/pill_tunel/Cargo.toml b/examples/pill_tunel/Cargo.toml index f79413df..4c6556a3 100644 --- a/examples/pill_tunel/Cargo.toml +++ b/examples/pill_tunel/Cargo.toml @@ -8,6 +8,6 @@ workspace = "/Users/mk/dev/demo/Pill-Engine/engine" crate-type = ["cdylib", "rlib"] [dependencies] -pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal"] } +pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal", "ui"] } egui = "0.32.1" png = "0.17" From d38a328155dfe23aed9639d194a57d32119fac1a Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sat, 30 May 2026 20:25:16 +0200 Subject: [PATCH 15/31] fix: do not embedd hdr by default --- engine/Cargo.toml | 2 +- engine/pill_engine/src/engine.rs | 20 +++ .../src/graphics/pass_background.rs | 130 ++++++++++++------ .../src/graphics/pass_pbr_static.rs | 85 ++++++++++-- engine/pill_engine/src/graphics/renderer.rs | 12 ++ engine/pill_engine/src/renderer/state.rs | 51 ++++++- examples/pbr_balls/src/game.rs | 12 ++ 7 files changed, 255 insertions(+), 57 deletions(-) diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 31944895..00888499 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,7 +8,7 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "NO_PATH", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) + "/Users/mk/dev/demo/Pill-Engine/examples/pbr_balls", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index 4f5b15da..8a4f246c 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -602,6 +602,26 @@ impl Engine { /// Pill Engine game API impl Engine { + // --- Renderer API --- + + /// Provides equirect background texture bytes; used by PassBackground on first-frame init. + /// Must be called before the first rendered frame (i.e. from `start()`). + pub fn set_background_texture(&mut self, bytes: Vec) -> Result<()> { + self.renderer.set_background_texture(bytes) + } + + /// Provides IBL map bytes (diffuse irradiance, specular prefilter, BRDF LUT); + /// used by PassPBRStatic on first-frame init. + /// Must be called before the first rendered frame (i.e. from `start()`). + pub fn set_ibl_textures( + &mut self, + diffuse: Vec, + specular: Vec, + brdf_lut: Vec, + ) -> Result<()> { + self.renderer.set_ibl_textures(diffuse, specular, brdf_lut) + } + // --- UI API --- /// Sets a game-defined egui overlay; called every frame before the engine debug window. diff --git a/engine/pill_engine/src/graphics/pass_background.rs b/engine/pill_engine/src/graphics/pass_background.rs index 0d79596e..0d03bb2f 100644 --- a/engine/pill_engine/src/graphics/pass_background.rs +++ b/engine/pill_engine/src/graphics/pass_background.rs @@ -11,10 +11,9 @@ use pill_core::{PillSlotMapKey, Result}; static VS: &str = include_str!("../../res/shaders/background_vertex.wgsl"); static FS: &str = include_str!("../../res/shaders/background_fragment.wgsl"); -static EQUIRECT_BYTES: &[u8] = include_bytes!("../../res/textures/studio_equirect.cooked_tex"); - pub struct PassBackground { hdr_target: RendererTextureHandle, + equirect_bytes: Option>, state: Option, } @@ -41,9 +40,15 @@ impl PassBackground { pub fn new(hdr_target: RendererTextureHandle) -> Self { Self { hdr_target, + equirect_bytes: None, state: None, } } + + pub fn with_equirect_bytes(mut self, bytes: Vec) -> Self { + self.equirect_bytes = Some(bytes); + self + } } fn decode_rtex(bytes: &[u8]) -> (&[u8], u32, u32, u32) { @@ -118,44 +123,91 @@ impl Pass for PassBackground { }, })?; - let (raw, w, h, version) = decode_rtex(EQUIRECT_BYTES); - let size = wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: 1, - }; - let (tex_format, bytes_per_channel) = if version == 2 { - (wgpu::TextureFormat::Rgba32Float, 4u32) - } else { - (wgpu::TextureFormat::Rgba8UnormSrgb, 1u32) + let (texture, view) = match &self.equirect_bytes { + Some(bytes) => { + let (raw, w, h, version) = decode_rtex(bytes); + let size = wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }; + let (tex_format, bytes_per_channel) = if version == 2 { + (wgpu::TextureFormat::Rgba32Float, 4u32) + } else { + (wgpu::TextureFormat::Rgba8UnormSrgb, 1u32) + }; + let texture = renderer + .get_device() + .create_texture(&wgpu::TextureDescriptor { + label: Some("studio_equirect"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: tex_format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + renderer.get_queue().write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + raw, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * bytes_per_channel * w), + rows_per_image: Some(h), + }, + size, + ); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) + } + None => { + // 1×1 Rgba32Float black fallback — no embedded asset + let texture = renderer + .get_device() + .create_texture(&wgpu::TextureDescriptor { + label: Some("equirect_fallback"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + // pixel: [0.0, 0.0, 0.0, 1.0] as f32 LE bytes + renderer.get_queue().write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 63], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(16), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) + } }; - let device = renderer.get_device(); - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("studio_equirect"), - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: tex_format, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - renderer.get_queue().write_texture( - wgpu::TexelCopyTextureInfo { - texture: &texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - raw, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4 * bytes_per_channel * w), - rows_per_image: Some(h), - }, - size, - ); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let sampler = renderer .get_device() diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_static.rs index 9dca322f..5b2b8d31 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_static.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_static.rs @@ -135,6 +135,9 @@ pub struct PassPBRStatic { visible_pre_draw_buffer: Vec, groups_buffer: Vec, staging_buffer: Vec, + ibl_diffuse: Option>, + ibl_specular: Option>, + ibl_brdf_lut: Option>, state: Option, } @@ -149,9 +152,19 @@ impl PassPBRStatic { staging_buffer: Vec::with_capacity( MAX_EXPECTED_PER_DRAW_INSTANCES * PER_DRAW_STRIDE_BYTES, ), + ibl_diffuse: None, + ibl_specular: None, + ibl_brdf_lut: None, state: None, } } + + pub fn with_ibl(mut self, diffuse: Vec, specular: Vec, brdf_lut: Vec) -> Self { + self.ibl_diffuse = Some(diffuse); + self.ibl_specular = Some(specular); + self.ibl_brdf_lut = Some(brdf_lut); + self + } } /// Decode an RTEX header: returns (rgba_bytes, width, height, mip_count, version). @@ -233,6 +246,53 @@ fn load_ibl_texture( (tex, view) } +/// Creates a 1×1 Rgba8Unorm neutral-gray texture as a no-op IBL fallback. +fn create_1px_ibl_fallback( + renderer: &dyn PillRenderer, + label: &str, +) -> (wgpu::Texture, wgpu::TextureView) { + let tex = renderer + .get_device() + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + renderer.get_queue().write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &[128, 128, 128, 255], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + let view = tex.create_view(&wgpu::TextureViewDescriptor { + mip_level_count: Some(1), + ..Default::default() + }); + (tex, view) +} + /// Returns the initialized pass state; panics in debug if `init` was not called. fn get_state(pass: &mut PassPBRStatic) -> &mut PassPBRStaticState { debug_assert!(pass.state.is_some()); @@ -535,19 +595,18 @@ impl Pass for PassPBRStatic { }) }; - // Load studio IBL textures from embedded cooked assets. - static DIFFUSE_IBL: &[u8] = - include_bytes!("../../res/textures/studio_diffuse_ibl.cooked_tex"); - static SPECULAR_IBL: &[u8] = - include_bytes!("../../res/textures/studio_specular_ibl.cooked_tex"); - static BRDF_LUT: &[u8] = include_bytes!("../../res/textures/brdf_lut.cooked_tex"); - - let (ibl_irradiance_texture, irradiance_view) = - load_ibl_texture(renderer, "studio_diffuse_ibl", DIFFUSE_IBL, true); - let (ibl_prefilter_texture, prefilter_view) = - load_ibl_texture(renderer, "studio_specular_ibl", SPECULAR_IBL, true); - let (ibl_brdf_lut_texture, brdf_lut_view) = - load_ibl_texture(renderer, "brdf_lut", BRDF_LUT, false); // linear + let (ibl_irradiance_texture, irradiance_view) = match &self.ibl_diffuse { + Some(bytes) => load_ibl_texture(renderer, "diffuse_ibl", bytes, true), + None => create_1px_ibl_fallback(renderer, "diffuse_ibl_fallback"), + }; + let (ibl_prefilter_texture, prefilter_view) = match &self.ibl_specular { + Some(bytes) => load_ibl_texture(renderer, "specular_ibl", bytes, true), + None => create_1px_ibl_fallback(renderer, "specular_ibl_fallback"), + }; + let (ibl_brdf_lut_texture, brdf_lut_view) = match &self.ibl_brdf_lut { + Some(bytes) => load_ibl_texture(renderer, "brdf_lut", bytes, false), // linear + None => create_1px_ibl_fallback(renderer, "brdf_lut_fallback"), + }; let prefilter_sampler = { let device = renderer.get_device(); diff --git a/engine/pill_engine/src/graphics/renderer.rs b/engine/pill_engine/src/graphics/renderer.rs index cc0f3c25..b791f955 100644 --- a/engine/pill_engine/src/graphics/renderer.rs +++ b/engine/pill_engine/src/graphics/renderer.rs @@ -194,6 +194,18 @@ pub trait PillRenderer { /// Returns the texture view for a previously created render target, or `None` if not found. fn get_render_target_view(&self, handle: RendererTextureHandle) -> Option<&wgpu::TextureView>; + + /// Stores equirect background bytes; used by PassBackground on first-frame init. + fn set_background_texture(&mut self, bytes: Vec) -> Result<()>; + + /// Stores IBL map bytes (diffuse irradiance, specular prefilter, BRDF LUT); + /// used by PassPBRStatic on first-frame init. + fn set_ibl_textures( + &mut self, + diffuse: Vec, + specular: Vec, + brdf_lut: Vec, + ) -> Result<()>; } pub type Renderer = Box; diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs index 035b0ebb..c0af4951 100644 --- a/engine/pill_engine/src/renderer/state.rs +++ b/engine/pill_engine/src/renderer/state.rs @@ -259,22 +259,40 @@ impl PillRenderer for Renderer { height: h, })?; + let bg = { + let mut p = PassBackground::new(hdr); + if let Some(bytes) = self.state.background_equirect.take() { + p = p.with_equirect_bytes(bytes); + } + p + }; + let pbr = { + let d = self.state.ibl_diffuse.take(); + let s = self.state.ibl_specular.take(); + let b = self.state.ibl_brdf_lut.take(); + let mut p = PassPBRStatic::new(Some(hdr)); + if let (Some(d), Some(s), Some(b)) = (d, s, b) { + p = p.with_ibl(d, s, b); + } + p + }; + #[cfg(feature = "ui")] { use crate::graphics::PassEgui; let egui_client = EguiClient::new(); self.state.egui_client = Some(egui_client.clone()); return self.set_passes(vec![ - Box::new(PassBackground::new(hdr)), - Box::new(PassPBRStatic::new(Some(hdr))), + Box::new(bg), + Box::new(pbr), Box::new(PassTonemap::new(hdr)), Box::new(PassEgui::new(self.state.window.clone(), egui_client)), ]); } #[cfg(not(feature = "ui"))] self.set_passes(vec![ - Box::new(PassBackground::new(hdr)), - Box::new(PassPBRStatic::new(Some(hdr))), + Box::new(bg), + Box::new(pbr), Box::new(PassTonemap::new(hdr)), ]) } @@ -414,6 +432,23 @@ impl PillRenderer for Renderer { .get(handle) .map(|t| &t.texture_view) } + + fn set_background_texture(&mut self, bytes: Vec) -> Result<()> { + self.state.background_equirect = Some(bytes); + Ok(()) + } + + fn set_ibl_textures( + &mut self, + diffuse: Vec, + specular: Vec, + brdf_lut: Vec, + ) -> Result<()> { + self.state.ibl_diffuse = Some(diffuse); + self.state.ibl_specular = Some(specular); + self.state.ibl_brdf_lut = Some(brdf_lut); + Ok(()) + } } pub struct State { @@ -435,6 +470,10 @@ pub struct State { // Optional frame capture: (target_frame, output_path). Set via PILL_SCREENSHOT env var. screenshot: Option<(u32, String)>, frame_counter: u32, + background_equirect: Option>, + ibl_diffuse: Option>, + ibl_specular: Option>, + ibl_brdf_lut: Option>, } impl State { @@ -612,6 +651,10 @@ impl State { window, screenshot, frame_counter: 0, + background_equirect: None, + ibl_diffuse: None, + ibl_specular: None, + ibl_brdf_lut: None, }) } diff --git a/examples/pbr_balls/src/game.rs b/examples/pbr_balls/src/game.rs index 62cff4f2..0112abf2 100644 --- a/examples/pbr_balls/src/game.rs +++ b/examples/pbr_balls/src/game.rs @@ -1,5 +1,10 @@ use pill_engine::{define_component, game::*}; +const EQUIRECT: &[u8] = include_bytes!("../res/textures/studio_equirect.cooked_tex"); +const DIFFUSE_IBL: &[u8] = include_bytes!("../res/textures/studio_diffuse_ibl.cooked_tex"); +const SPECULAR_IBL: &[u8] = include_bytes!("../res/textures/studio_specular_ibl.cooked_tex"); +const BRDF_LUT: &[u8] = include_bytes!("../res/textures/brdf_lut.cooked_tex"); + define_component!(OrbitCamera { yaw: f32, pitch: f32, @@ -10,6 +15,13 @@ pub struct Game {} impl PillGame for Game { fn start(&self, engine: &mut Engine) -> Result<()> { + engine.set_background_texture(EQUIRECT.to_vec())?; + engine.set_ibl_textures( + DIFFUSE_IBL.to_vec(), + SPECULAR_IBL.to_vec(), + BRDF_LUT.to_vec(), + )?; + let scene = engine.create_scene("pbr_balls")?; engine.set_active_scene(scene)?; From 279fb43e7c10340cf54b2def092a16be31a62546 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sat, 30 May 2026 21:39:31 +0200 Subject: [PATCH 16/31] fix wasm size --- engine/pill_launcher/res/templates/wasm/src/lib.rs | 5 +++++ engine/pill_launcher/src/wasm_build.rs | 2 ++ 2 files changed, 7 insertions(+) diff --git a/engine/pill_launcher/res/templates/wasm/src/lib.rs b/engine/pill_launcher/res/templates/wasm/src/lib.rs index e342ba07..164da889 100644 --- a/engine/pill_launcher/res/templates/wasm/src/lib.rs +++ b/engine/pill_launcher/res/templates/wasm/src/lib.rs @@ -12,6 +12,11 @@ use wasm_bindgen::prelude::*; use pill_game::WebGame; +#[cfg(target_arch = "wasm32")] +#[global_allocator] +static ALLOC: lol_alloc::AssumeSingleThreaded = + unsafe { lol_alloc::AssumeSingleThreaded::new(lol_alloc::FreeListAllocator::new()) }; + #[wasm_bindgen(start)] pub fn wasm_main() { pill_web::run(Box::new(WebGame {}), include_str!("../config.ini")); diff --git a/engine/pill_launcher/src/wasm_build.rs b/engine/pill_launcher/src/wasm_build.rs index cb25b4ad..70e11474 100644 --- a/engine/pill_launcher/src/wasm_build.rs +++ b/engine/pill_launcher/src/wasm_build.rs @@ -183,6 +183,8 @@ fn rewrite_scratch_manifest(scratch_pill_web_app_dir: &Path, game_dir: &Path) -> "panic = \"abort\"\n", "\n[package.metadata.wasm-pack.profile.release]\n", "wasm-opt = [\"-Oz\", \"--strip-debug\", \"--strip-producers\", \"--enable-nontrapping-float-to-int\", \"--enable-bulk-memory\", \"--enable-sign-ext\", \"--enable-mutable-globals\", \"--enable-reference-types\"]\n", + "\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\n", + "lol_alloc = \"0.4\"\n", ), pill_game = pill_game, ) From c10032a2dc0d05aeaa18be4b93be2db20c1e906c Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 31 May 2026 02:34:41 +0200 Subject: [PATCH 17/31] fix: fog and ibl tweaks --- engine/Cargo.toml | 2 +- engine/pill_core/src/utils.rs | 4 + .../res/shaders/background_fragment.hlsl | 13 +- .../res/shaders/pbr_static_fragment.hlsl | 6 +- .../res/shaders/pbr_static_vertex.hlsl | 2 + engine/pill_engine/src/config.rs | 22 +- ...manager_component.rs => egui_component.rs} | 18 +- engine/pill_engine/src/ecs/components/mod.rs | 2 +- .../ecs/components/render_state_component.rs | 23 +- engine/pill_engine/src/ecs/mod.rs | 5 +- .../src/ecs/systems/egui_system.rs | 14 + engine/pill_engine/src/ecs/systems/mod.rs | 2 + .../src/ecs/systems/rendering_system.rs | 57 ++- engine/pill_engine/src/engine.rs | 160 +++++++- .../src/graphics/dummy_renderer.rs | 15 +- .../src/graphics/pass_background.rs | 125 ++---- .../src/graphics/pass_pbr_static.rs | 257 +++--------- engine/pill_engine/src/graphics/renderer.rs | 36 +- engine/pill_engine/src/lib.rs | 9 +- .../renderer/resources/renderer_texture.rs | 80 ++++ engine/pill_engine/src/renderer/state.rs | 118 ++---- .../res/templates/wasm/src/lib.rs | 4 +- examples/cube/src/lib.rs | 2 +- examples/pbr_balls/src/bake.rs | 385 ++++++++++++++++++ examples/pbr_balls/src/game.rs | 24 +- examples/pbr_balls/src/lib.rs | 1 + examples/pbr_helmet/src/bake.rs | 381 +++++++++++++++++ examples/pbr_helmet/src/game.rs | 19 + examples/pbr_helmet/src/lib.rs | 2 +- examples/pill_tunel/Cargo.toml | 1 - examples/pill_tunel/src/game.rs | 64 +-- examples/pill_tunel/src/lib.rs | 1 - 32 files changed, 1336 insertions(+), 518 deletions(-) rename engine/pill_engine/src/ecs/components/{egui_manager_component.rs => egui_component.rs} (94%) create mode 100644 engine/pill_engine/src/ecs/systems/egui_system.rs create mode 100644 examples/pbr_balls/src/bake.rs create mode 100644 examples/pbr_helmet/src/bake.rs diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 00888499..5e95497d 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,7 +8,7 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "/Users/mk/dev/demo/Pill-Engine/examples/pbr_balls", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) + "/Users/mk/dev/demo/Pill-Engine/examples/pbr_helmet", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at diff --git a/engine/pill_core/src/utils.rs b/engine/pill_core/src/utils.rs index 057988ac..09f2eea6 100644 --- a/engine/pill_core/src/utils.rs +++ b/engine/pill_core/src/utils.rs @@ -88,5 +88,9 @@ macro_rules! create_game { let game: Box = Box::new($game_contructor); Box::into_raw(Box::new(game)) as *mut std::ffi::c_void } + + pub fn create_pill_game() -> Box { + Box::new($game_contructor) + } }; } diff --git a/engine/pill_engine/res/shaders/background_fragment.hlsl b/engine/pill_engine/res/shaders/background_fragment.hlsl index 575d28d5..395a8ef4 100644 --- a/engine/pill_engine/res/shaders/background_fragment.hlsl +++ b/engine/pill_engine/res/shaders/background_fragment.hlsl @@ -1,10 +1,8 @@ struct SkyCam { - float3 right; - float tan_half_fov; - float3 up; - float aspect; - float3 fwd; - float _pad; + float3 right; float tan_half_fov; + float3 up; float aspect; + float3 fwd; float _pad; + float3 bg_color; float _pad2; }; [[vk::binding(0, 0)]] ConstantBuffer UCam; [[vk::binding(1, 0)]] Texture2D texEquirect; @@ -20,5 +18,6 @@ float4 fs_main(float2 ndc : TEXCOORD0) : SV_Target { + UCam.fwd ); float2 uv = dir_to_equirect_uv(dir); - return texEquirect.Sample(smpEquirect, uv); + float3 sky = texEquirect.Sample(smpEquirect, uv).rgb; + return float4(sky * UCam.bg_color, 1.0); } diff --git a/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl b/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl index e5c5414a..f1c69cbe 100644 --- a/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl +++ b/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl @@ -4,6 +4,8 @@ struct Camera { float4 position; column_major float4x4 viewProjection; + float3 fog_color; + float fog_density; }; [[vk::binding(0, 0)]] ConstantBuffer UCamera; @@ -133,5 +135,7 @@ float4 fs_main( float3 specularIBL = prefilteredColor * (F * envBRDF.x + envBRDF.y); float3 emissive = texEmissive.Sample(smpEmissive, uv).rgb; float3 color = Lo + ambientDiffuse + specularIBL + emissive; - return float4(color, 1.0); + float dist = length(UCamera.position.xyz - WorldPos); + float fog = 1.0 - exp(-UCamera.fog_density * UCamera.fog_density * dist * dist); + return float4(lerp(color, UCamera.fog_color, fog), 1.0); } diff --git a/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl b/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl index a9a7fa29..cfc7043e 100644 --- a/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl +++ b/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl @@ -5,6 +5,8 @@ struct Camera { float4 position; column_major float4x4 viewProjection; + float3 fog_color; + float fog_density; }; [[vk::binding(0, 0)]] ConstantBuffer UCamera; diff --git a/engine/pill_engine/src/config.rs b/engine/pill_engine/src/config.rs index c033c794..1f6479d7 100644 --- a/engine/pill_engine/src/config.rs +++ b/engine/pill_engine/src/config.rs @@ -8,6 +8,9 @@ use crate::{ resources::{MaterialHandle, ShaderHandle, TextureHandle, TextureType}, }; +#[cfg(feature = "ui")] +use crate::ecs::egui_system; + #[cfg(not(target_arch = "wasm32"))] use crate::ecs::{audio_system, haptics_system, AudioManagerComponent}; @@ -72,6 +75,13 @@ pub const RENDERING_SYSTEM: SystemConfig = SystemConfig { update_phase: UpdatePhase::PostGame, }; +#[cfg(feature = "ui")] +pub const EGUI_SYSTEM: SystemConfig = SystemConfig { + name: "egui_system", + system_function: egui_system, + update_phase: UpdatePhase::PostGame, +}; + // --- Resources --- pub const RESOURCE_VERSION_LIMIT: usize = 255; @@ -107,6 +117,16 @@ pub const DEFAULT_EMISSIVE_TEXTURE_BYTES: [u8; 20] = [ b'R', b'T', b'E', b'X', 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 255, ]; +// 1×1 Rgba32Float white — PassBackground equirect fallback; bg_color tints it to the desired solid color +// 1.0f32 LE = 00 00 80 3F +pub const DEFAULT_EQUIRECT_FALLBACK_PIXEL: &[u8] = &[ + 0x00, 0x00, 0x80, 0x3F, 0x00, 0x00, 0x80, 0x3F, 0x00, 0x00, 0x80, 0x3F, 0x00, 0x00, 0x80, 0x3F, +]; +// 1×1 Rgba8Unorm neutral-gray — IBL diffuse/specular fallback: RGBA = [77, 77, 77, 255] ≈ 0.3 linear +pub const DEFAULT_IBL_FALLBACK_PIXEL: &[u8] = &[77, 77, 77, 255]; +// 1×1 Rgba8Unorm — BRDF LUT fallback: R=F0_scale=0.5, G=F0_bias=0.5 +pub const DEFAULT_BRDF_LUT_FALLBACK_PIXEL: &[u8] = &[128, 128, 0, 255]; + // Default lit shader pub const DEFAULT_LIT_SHADER_NAME: &str = "pill_engine_default_lit_shader"; pub const DEFAULT_LIT_SHADER_COLOR_TEXTURE_SLOT_NAME: &str = "color"; @@ -249,7 +269,7 @@ lazy_static! { #[cfg(not(target_arch = "wasm32"))] component_types.push(TypeId::of::()); #[cfg(feature = "ui")] - component_types.push(TypeId::of::()); + component_types.push(TypeId::of::()); component_types }; } diff --git a/engine/pill_engine/src/ecs/components/egui_manager_component.rs b/engine/pill_engine/src/ecs/components/egui_component.rs similarity index 94% rename from engine/pill_engine/src/ecs/components/egui_manager_component.rs rename to engine/pill_engine/src/ecs/components/egui_component.rs index 430cba8e..c0649251 100644 --- a/engine/pill_engine/src/ecs/components/egui_manager_component.rs +++ b/engine/pill_engine/src/ecs/components/egui_component.rs @@ -5,7 +5,7 @@ use std::{collections::HashMap, sync::Arc}; use crate::{ ecs::{ components::{GlobalComponent, GlobalComponentStorage}, - UpdatePhase, + EguiClient, UpdatePhase, }, engine::Engine, }; @@ -15,20 +15,22 @@ use pill_core::{PillTypeMapKey, Timer, TimerRecord}; use pill_core::{ErrorContext, Result}; -pub struct EguiManagerComponent { +pub struct EguiComponent { + pub egui_client: Arc, collapsing_state: HashMap, game_overlay: Option>, } -impl Default for EguiManagerComponent { +impl Default for EguiComponent { fn default() -> Self { Self::new() } } -impl EguiManagerComponent { +impl EguiComponent { pub fn new() -> Self { Self { + egui_client: EguiClient::new(), collapsing_state: HashMap::new(), game_overlay: None, } @@ -40,7 +42,7 @@ impl EguiManagerComponent { pub fn get_ui(engine: &mut Engine) -> Box { let game_overlay = engine - .get_global_component::() + .get_global_component::() .ok() .and_then(|c| c.game_overlay.clone()); @@ -188,8 +190,8 @@ impl EguiManagerComponent { } } -impl PillTypeMapKey for EguiManagerComponent { - type Storage = GlobalComponentStorage; +impl PillTypeMapKey for EguiComponent { + type Storage = GlobalComponentStorage; } -impl GlobalComponent for EguiManagerComponent {} +impl GlobalComponent for EguiComponent {} diff --git a/engine/pill_engine/src/ecs/components/mod.rs b/engine/pill_engine/src/ecs/components/mod.rs index 75460ec8..db9eeca9 100644 --- a/engine/pill_engine/src/ecs/components/mod.rs +++ b/engine/pill_engine/src/ecs/components/mod.rs @@ -11,7 +11,7 @@ mod component; mod component_storage; pub(crate) mod deferred_update_component; #[cfg(feature = "ui")] -pub(crate) mod egui_manager_component; +pub(crate) mod egui_component; pub(crate) mod input_component; pub(crate) mod mesh_component; #[cfg(not(target_arch = "wasm32"))] diff --git a/engine/pill_engine/src/ecs/components/render_state_component.rs b/engine/pill_engine/src/ecs/components/render_state_component.rs index 9df81a2e..2f41c479 100644 --- a/engine/pill_engine/src/ecs/components/render_state_component.rs +++ b/engine/pill_engine/src/ecs/components/render_state_component.rs @@ -1,15 +1,32 @@ -use crate::ecs::components::{GlobalComponent, GlobalComponentStorage}; +use crate::{ + ecs::components::{GlobalComponent, GlobalComponentStorage}, + graphics::RendererTextureHandle, +}; use pill_core::PillTypeMapKey; pub struct RenderStateComponent { pub boot_done: bool, + pub background: RendererTextureHandle, + pub ibl_diffuse: RendererTextureHandle, + pub ibl_specular: RendererTextureHandle, + pub ibl_brdf_lut: RendererTextureHandle, + pub bg_color: [f32; 3], // tint multiplied against equirect sample; [1,1,1] = identity + pub fog_density: f32, } impl RenderStateComponent { - /// Creates the component in its pre-boot state. + /// Creates the component in its pre-boot state; handles are replaced by `create_default_resources`. pub fn new() -> Self { - Self { boot_done: false } + Self { + boot_done: false, + background: RendererTextureHandle::default(), + ibl_diffuse: RendererTextureHandle::default(), + ibl_specular: RendererTextureHandle::default(), + ibl_brdf_lut: RendererTextureHandle::default(), + bg_color: [1.0, 1.0, 1.0], + fog_density: 0.0, + } } } diff --git a/engine/pill_engine/src/ecs/mod.rs b/engine/pill_engine/src/ecs/mod.rs index 664d4e1b..6f6ca5b6 100644 --- a/engine/pill_engine/src/ecs/mod.rs +++ b/engine/pill_engine/src/ecs/mod.rs @@ -30,7 +30,7 @@ pub use components::audio_listener_component::AudioListenerComponent; pub use components::audio_source_component::AudioSourceComponent; #[cfg(feature = "ui")] -pub use components::egui_manager_component::EguiManagerComponent; +pub use components::egui_component::EguiComponent; pub use components::render_state_component::RenderStateComponent; @@ -70,6 +70,9 @@ pub use systems::{SystemFunction, SystemManager, UpdatePhase}; pub use systems::rendering_system::rendering_system; +#[cfg(feature = "ui")] +pub use systems::egui_system::egui_system; + pub use systems::deferred_update_system::deferred_update_system; pub use systems::input_system::input_system; diff --git a/engine/pill_engine/src/ecs/systems/egui_system.rs b/engine/pill_engine/src/ecs/systems/egui_system.rs new file mode 100644 index 00000000..094cee3c --- /dev/null +++ b/engine/pill_engine/src/ecs/systems/egui_system.rs @@ -0,0 +1,14 @@ +#![cfg(feature = "ui")] + +use crate::{ecs::EguiComponent, engine::Engine}; +use pill_core::Result; + +pub fn egui_system(engine: &mut Engine) -> Result<()> { + let ui = EguiComponent::get_ui(engine); + let client = engine + .get_global_component::()? + .egui_client + .clone(); + client.set_ui(move |ctx| ui(ctx)); + Ok(()) +} diff --git a/engine/pill_engine/src/ecs/systems/mod.rs b/engine/pill_engine/src/ecs/systems/mod.rs index 6dee9b19..c9c9213b 100644 --- a/engine/pill_engine/src/ecs/systems/mod.rs +++ b/engine/pill_engine/src/ecs/systems/mod.rs @@ -3,6 +3,8 @@ #[cfg(not(target_arch = "wasm32"))] pub(crate) mod audio_system; pub(crate) mod deferred_update_system; +#[cfg(feature = "ui")] +pub(crate) mod egui_system; pub(crate) mod input_system; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod networking_system; diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index 7229c78d..15f22f11 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -5,10 +5,11 @@ use crate::{ RenderStateComponent, TransformComponent, }, engine::Engine, - graphics::RenderQueueItem, + graphics::{PassBackground, PassPBRStatic, PassTonemap, RenderQueueItem, RendererTargetDesc}, }; use pill_core::{warn, EngineError, LogContext, PillSlotMapKey, PillStyle, RendererError, Timer}; +use wgpu; use pill_core::{ErrorContext, Result}; use web_time::Instant; @@ -23,7 +24,49 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { .boot_done; if !boot_done { - engine.renderer.init_default_passes()?; + let (bg, diff, spec, lut, bg_color, fog_density) = { + let rs = engine.get_global_component::()?; + ( + rs.background, + rs.ibl_diffuse, + rs.ibl_specular, + rs.ibl_brdf_lut, + rs.bg_color, + rs.fog_density, + ) + }; + let (w, h) = engine.renderer.get_surface_size(); + let hdr = engine.renderer.create_render_target(RendererTargetDesc { + name: "hdr_target".to_string(), + format: wgpu::TextureFormat::Rgba16Float, + width: w, + height: h, + })?; + #[cfg_attr(not(feature = "ui"), allow(unused_mut))] + let mut passes: Vec> = vec![ + Box::new( + PassBackground::new(hdr) + .with_equirect(bg) + .with_bg_color(bg_color), + ), + Box::new( + PassPBRStatic::new(Some(hdr)) + .with_ibl(diff, spec, lut) + .with_fog(bg_color, fog_density), + ), + Box::new(PassTonemap::new(hdr)), + ]; + #[cfg(feature = "ui")] + { + use crate::{ecs::EguiComponent, graphics::PassEgui}; + let client = engine + .get_global_component::()? + .egui_client + .clone(); + let window = engine.renderer.get_window(); + passes.push(Box::new(PassEgui::new(window, client))); + } + engine.renderer.set_passes(passes)?; engine .get_global_component_mut::()? .boot_done = true; @@ -103,16 +146,6 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { timer.record("Get component storages"); - // Build egui UI and push to egui_client - #[cfg(feature = "ui")] - { - use crate::ecs::EguiManagerComponent; - let egui_ui = EguiManagerComponent::get_ui(engine); - if let Some(egui_client) = engine.renderer.get_egui_client() { - egui_client.set_ui(egui_ui); - } - } - let active_scene = engine.scene_manager.get_active_scene_mut()?; let camera_component_storage = active_scene .get_component_storage::() diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index 8a4f246c..f714cb9d 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -132,6 +132,41 @@ impl Engine { self.register_resource_type::(max_material_count)?; self.register_resource_type::(max_texture_count)?; self.register_resource_type::(max_mesh_count)?; + + // Upload neutral 1×1 fallback textures for the env slots; game may override before first frame. + let bg_h = self.renderer.create_texture_from_pixels( + "pill_engine_default_equirect", + &[DEFAULT_EQUIRECT_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba32Float, + ); + let diff_h = self.renderer.create_texture_from_pixels( + "pill_engine_default_ibl_diffuse", + &[DEFAULT_IBL_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ); + let spec_h = self.renderer.create_texture_from_pixels( + "pill_engine_default_ibl_specular", + &[DEFAULT_IBL_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ); + let lut_h = self.renderer.create_texture_from_pixels( + "pill_engine_default_brdf_lut", + &[DEFAULT_BRDF_LUT_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ); + let rs = self.get_global_component_mut::()?; + rs.background = bg_h; + rs.ibl_diffuse = diff_h; + rs.ibl_specular = spec_h; + rs.ibl_brdf_lut = lut_h; } self.register_resource_type::(max_shader_count)?; @@ -364,7 +399,7 @@ impl Engine { self.add_global_component(TimeComponent::new())?; self.add_global_component(DeferredUpdateComponent::new())?; #[cfg(feature = "ui")] - self.add_global_component(EguiManagerComponent::new())?; + self.add_global_component(EguiComponent::new())?; #[cfg(not(feature = "headless"))] { @@ -413,6 +448,12 @@ impl Engine { AUDIO_SYSTEM.system_function, AUDIO_SYSTEM.update_phase, )?; + #[cfg(feature = "ui")] + self.system_manager.add_system( + EGUI_SYSTEM.name, + EGUI_SYSTEM.system_function, + EGUI_SYSTEM.update_phase, + )?; self.system_manager.add_system( RENDERING_SYSTEM.name, RENDERING_SYSTEM.system_function, @@ -588,7 +629,9 @@ impl Engine { pub fn pass_input_to_egui(&mut self, event: &winit::event::WindowEvent) { #[cfg(feature = "ui")] - self.renderer.pass_input_to_egui(event).unwrap(); + if let Ok(mgr) = self.get_global_component::() { + mgr.egui_client.handle_input(event.clone()); + } #[cfg(not(feature = "ui"))] let _ = event; } @@ -600,6 +643,20 @@ impl Engine { // --- API ------------------------------------------------------------------ +/// Parse an RTEX binary header. +/// Returns `(pixel_bytes, width, height, mip_count, version)`. +fn decode_rtex_header(bytes: &[u8]) -> (&[u8], u32, u32, u32, u32) { + let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap()); + let w = u32::from_le_bytes(bytes[8..12].try_into().unwrap()); + let h = u32::from_le_bytes(bytes[12..16].try_into().unwrap()); + if version == 3 || version == 4 { + let mip_count = u32::from_le_bytes(bytes[16..20].try_into().unwrap()); + (&bytes[20..], w, h, mip_count, version) + } else { + (&bytes[16..], w, h, 1, version) + } +} + /// Pill Engine game API impl Engine { // --- Renderer API --- @@ -607,7 +664,18 @@ impl Engine { /// Provides equirect background texture bytes; used by PassBackground on first-frame init. /// Must be called before the first rendered frame (i.e. from `start()`). pub fn set_background_texture(&mut self, bytes: Vec) -> Result<()> { - self.renderer.set_background_texture(bytes) + let (raw, w, h, _mips, version) = decode_rtex_header(&bytes); + let format = if version == 2 { + wgpu::TextureFormat::Rgba32Float + } else { + wgpu::TextureFormat::Rgba8UnormSrgb + }; + let handle = + self.renderer + .create_texture_from_pixels("studio_equirect", &[raw], w, h, format); + self.get_global_component_mut::()? + .background = handle; + Ok(()) } /// Provides IBL map bytes (diffuse irradiance, specular prefilter, BRDF LUT); @@ -619,7 +687,89 @@ impl Engine { specular: Vec, brdf_lut: Vec, ) -> Result<()> { - self.renderer.set_ibl_textures(diffuse, specular, brdf_lut) + let (diff_raw, dw, dh, _, _) = decode_rtex_header(&diffuse); + let diff_h = self.renderer.create_texture_from_pixels( + "diffuse_ibl", + &[diff_raw], + dw, + dh, + wgpu::TextureFormat::Rgba8UnormSrgb, + ); + // specular: RTEX v4 — Rgba32Float mip chain + let (spec_raw, sw, sh, spec_mip_count, _) = decode_rtex_header(&specular); + const SPEC_BYTES_PER_TEXEL: usize = 16; // Rgba32Float = 4 channels × 4 bytes + let mut spec_slices: Vec<&[u8]> = Vec::with_capacity(spec_mip_count as usize); + let mut offset = 0usize; + for mip in 0..spec_mip_count { + let mip_bytes = + (sw >> mip).max(1) as usize * (sh >> mip).max(1) as usize * SPEC_BYTES_PER_TEXEL; + spec_slices.push(&spec_raw[offset..offset + mip_bytes]); + offset += mip_bytes; + } + let spec_h = self.renderer.create_texture_from_pixels( + "specular_ibl", + &spec_slices, + sw, + sh, + wgpu::TextureFormat::Rgba32Float, + ); + + let (lut_raw, lw, lh, _, _) = decode_rtex_header(&brdf_lut); + let lut_h = self.renderer.create_texture_from_pixels( + "brdf_lut", + &[lut_raw], + lw, + lh, + wgpu::TextureFormat::Rgba8Unorm, + ); + let rs = self.get_global_component_mut::()?; + rs.ibl_diffuse = diff_h; + rs.ibl_specular = spec_h; + rs.ibl_brdf_lut = lut_h; + Ok(()) + } + + /// Creates a single-mip Rgba32Float GPU texture from f32 pixels. + /// Returns a handle for assignment to `RenderStateComponent`. + /// Must be called before the first rendered frame (i.e. from `start()`). + pub fn create_gpu_texture_f32( + &mut self, + name: &str, + pixels: &[f32], + width: u32, + height: u32, + ) -> Result { + let bytes: &[u8] = bytemuck::cast_slice(pixels); + Ok(self.renderer.create_texture_from_pixels( + name, + &[bytes], + width, + height, + wgpu::TextureFormat::Rgba32Float, + )) + } + + /// Creates a mipped Rgba32Float GPU texture from f32 pixels; `mips[0]` is the base level. + /// Returns a handle for assignment to `RenderStateComponent`. + /// Must be called before the first rendered frame (i.e. from `start()`). + pub fn create_gpu_mipped_texture_f32( + &mut self, + name: &str, + mips: &[Vec], + base_width: u32, + base_height: u32, + ) -> Result { + let slices: Vec<&[u8]> = mips + .iter() + .map(|m| bytemuck::cast_slice(m.as_slice())) + .collect(); + Ok(self.renderer.create_texture_from_pixels( + name, + &slices, + base_width, + base_height, + wgpu::TextureFormat::Rgba32Float, + )) } // --- UI API --- @@ -630,7 +780,7 @@ impl Engine { &mut self, f: impl Fn(&egui::Context) + Send + Sync + 'static, ) -> Result<()> { - self.get_global_component_mut::()? + self.get_global_component_mut::()? .set_overlay(f); Ok(()) } diff --git a/engine/pill_engine/src/graphics/dummy_renderer.rs b/engine/pill_engine/src/graphics/dummy_renderer.rs index 248e0aef..53cd16f7 100644 --- a/engine/pill_engine/src/graphics/dummy_renderer.rs +++ b/engine/pill_engine/src/graphics/dummy_renderer.rs @@ -1,6 +1,6 @@ use crate::{ app_config::EngineConfig, - ecs::{CameraComponent, ComponentStorage, EguiClient, EntityHandle, TransformComponent}, + ecs::{CameraComponent, ComponentStorage, EntityHandle, TransformComponent}, graphics::{ BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, RenderQueueItem, RendererCameraHandle, RendererTargetDesc, RendererTextureHandle, WorldQuery, @@ -10,8 +10,8 @@ use crate::{ use pill_core::Result; use pill_core::Timer; -use std::{collections::HashMap, sync::Arc}; -use winit::{dpi::PhysicalSize, event::WindowEvent, window::Window}; +use std::collections::HashMap; +use winit::{dpi::PhysicalSize, window::Window}; pub struct DummyRenderer; @@ -49,9 +49,8 @@ impl PillRenderer for DummyRenderer { fn resize(&mut self, _new_window_size: PhysicalSize) {} - #[cfg(feature = "ui")] - fn pass_input_to_egui(&mut self, _event: &WindowEvent) -> Result<()> { - Ok(()) + fn get_window(&self) -> std::sync::Arc { + unimplemented!("DummyRenderer has no window") } fn render( @@ -73,8 +72,8 @@ impl PillRenderer for DummyRenderer { Ok(()) } - fn init_default_passes(&mut self, _egui_client: Arc) -> Result<()> { - Ok(()) + fn get_surface_size(&self) -> (u32, u32) { + (0, 0) } fn get_device(&self) -> &wgpu::Device { diff --git a/engine/pill_engine/src/graphics/pass_background.rs b/engine/pill_engine/src/graphics/pass_background.rs index 0d03bb2f..04403dc4 100644 --- a/engine/pill_engine/src/graphics/pass_background.rs +++ b/engine/pill_engine/src/graphics/pass_background.rs @@ -8,12 +8,15 @@ use crate::{ use glam::{Mat3, Vec3}; use pill_core::{PillSlotMapKey, Result}; +use crate::config::DEFAULT_EQUIRECT_FALLBACK_PIXEL; + static VS: &str = include_str!("../../res/shaders/background_vertex.wgsl"); static FS: &str = include_str!("../../res/shaders/background_fragment.wgsl"); pub struct PassBackground { hdr_target: RendererTextureHandle, - equirect_bytes: Option>, + equirect: Option, + bg_color: [f32; 3], state: Option, } @@ -21,7 +24,6 @@ struct BgState { pipeline: PipelineV2, bind_group: wgpu::BindGroup, camera_buffer: wgpu::Buffer, - _texture: wgpu::Texture, _sampler: wgpu::Sampler, } @@ -34,28 +36,29 @@ struct BgCameraUbo { aspect: f32, fwd: [f32; 3], _pad: f32, + bg_color: [f32; 3], + _pad2: f32, } impl PassBackground { pub fn new(hdr_target: RendererTextureHandle) -> Self { Self { hdr_target, - equirect_bytes: None, + equirect: None, + bg_color: [1.0, 1.0, 1.0], state: None, } } - pub fn with_equirect_bytes(mut self, bytes: Vec) -> Self { - self.equirect_bytes = Some(bytes); + pub fn with_equirect(mut self, handle: RendererTextureHandle) -> Self { + self.equirect = Some(handle); self } -} -fn decode_rtex(bytes: &[u8]) -> (&[u8], u32, u32, u32) { - let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap()); - let w = u32::from_le_bytes(bytes[8..12].try_into().unwrap()); - let h = u32::from_le_bytes(bytes[12..16].try_into().unwrap()); - (&bytes[16..], w, h, version) + pub fn with_bg_color(mut self, color: [f32; 3]) -> Self { + self.bg_color = color; + self + } } impl Pass for PassBackground { @@ -123,91 +126,20 @@ impl Pass for PassBackground { }, })?; - let (texture, view) = match &self.equirect_bytes { - Some(bytes) => { - let (raw, w, h, version) = decode_rtex(bytes); - let size = wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: 1, - }; - let (tex_format, bytes_per_channel) = if version == 2 { - (wgpu::TextureFormat::Rgba32Float, 4u32) - } else { - (wgpu::TextureFormat::Rgba8UnormSrgb, 1u32) - }; - let texture = renderer - .get_device() - .create_texture(&wgpu::TextureDescriptor { - label: Some("studio_equirect"), - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: tex_format, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - renderer.get_queue().write_texture( - wgpu::TexelCopyTextureInfo { - texture: &texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - raw, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4 * bytes_per_channel * w), - rows_per_image: Some(h), - }, - size, - ); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - (texture, view) - } - None => { - // 1×1 Rgba32Float black fallback — no embedded asset - let texture = renderer - .get_device() - .create_texture(&wgpu::TextureDescriptor { - label: Some("equirect_fallback"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba32Float, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - // pixel: [0.0, 0.0, 0.0, 1.0] as f32 LE bytes - renderer.get_queue().write_texture( - wgpu::TexelCopyTextureInfo { - texture: &texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 63], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(16), - rows_per_image: Some(1), - }, - wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - ); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - (texture, view) - } + // Create equirect view; register a 1×1 Rgba32Float black fallback when no handle is set. + let equirect_h = match self.equirect { + Some(h) => h, + None => renderer.create_texture_from_pixels( + "equirect_fallback", + &[DEFAULT_EQUIRECT_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba32Float, + ), }; + let view = renderer + .get_texture_view(equirect_h) + .expect("equirect handle invalid"); let sampler = renderer .get_device() @@ -258,7 +190,6 @@ impl Pass for PassBackground { pipeline, bind_group, camera_buffer, - _texture: texture, _sampler: sampler, }); Ok(()) @@ -310,6 +241,8 @@ impl Pass for PassBackground { aspect: cam.aspect.get_value(), fwd: fwd.to_array(), _pad: 0.0, + bg_color: self.bg_color, + _pad2: 0.0, }; renderer .get_queue() diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_static.rs index 5b2b8d31..5c051f2b 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_static.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_static.rs @@ -1,3 +1,4 @@ +use crate::config::{DEFAULT_BRDF_LUT_FALLBACK_PIXEL, DEFAULT_IBL_FALLBACK_PIXEL}; use crate::{ ecs::{CameraComponent, ComponentStorage, TransformComponent}, graphics::{ @@ -10,12 +11,14 @@ use glam::{Mat3, Mat4, Quat, Vec3}; use pill_core::{PillSlotMapKey, Result}; use std::num::NonZeroU32; -/// Camera uniform layout: position (vec4) + view-projection matrix (mat4x4). +/// Camera uniform layout: position (vec4) + view-projection matrix (mat4x4) + fog. #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] struct CameraUniform { position: [f32; 4], view_projection_matrix: [[f32; 4]; 4], + fog_color: [f32; 3], // packed with fog_density for 16-byte alignment + fog_density: f32, } impl CameraUniform { @@ -23,6 +26,8 @@ impl CameraUniform { Self { position: [0.0; 4], view_projection_matrix: Mat4::IDENTITY.to_cols_array_2d(), + fog_color: [1.0; 3], + fog_density: 0.0, } } @@ -117,12 +122,6 @@ struct PassPBRStaticState { globals_bind_group: wgpu::BindGroup, pipeline: PipelineV2, ibl_sampler: wgpu::Sampler, - ibl_fallback_texture: wgpu::Texture, - ibl_fallback_view: wgpu::TextureView, - // Studio IBL textures loaded from embedded cooked assets. - _ibl_irradiance_texture: wgpu::Texture, - _ibl_prefilter_texture: wgpu::Texture, - _ibl_brdf_lut_texture: wgpu::Texture, per_draw_buffer: wgpu::Buffer, per_draw_bind_group: wgpu::BindGroup, } @@ -135,9 +134,11 @@ pub struct PassPBRStatic { visible_pre_draw_buffer: Vec, groups_buffer: Vec, staging_buffer: Vec, - ibl_diffuse: Option>, - ibl_specular: Option>, - ibl_brdf_lut: Option>, + ibl_diffuse: Option, + ibl_specular: Option, + ibl_brdf_lut: Option, + fog_color: [f32; 3], + fog_density: f32, state: Option, } @@ -155,142 +156,29 @@ impl PassPBRStatic { ibl_diffuse: None, ibl_specular: None, ibl_brdf_lut: None, + fog_color: [1.0; 3], + fog_density: 0.0, state: None, } } - pub fn with_ibl(mut self, diffuse: Vec, specular: Vec, brdf_lut: Vec) -> Self { + pub fn with_ibl( + mut self, + diffuse: RendererTextureHandle, + specular: RendererTextureHandle, + brdf_lut: RendererTextureHandle, + ) -> Self { self.ibl_diffuse = Some(diffuse); self.ibl_specular = Some(specular); self.ibl_brdf_lut = Some(brdf_lut); self } -} - -/// Decode an RTEX header: returns (rgba_bytes, width, height, mip_count, version). -fn decode_rtex(bytes: &[u8]) -> (&[u8], u32, u32, u32, u32) { - let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap()); - let w = u32::from_le_bytes(bytes[8..12].try_into().unwrap()); - let h = u32::from_le_bytes(bytes[12..16].try_into().unwrap()); - if version == 3 || version == 4 { - let mip_count = u32::from_le_bytes(bytes[16..20].try_into().unwrap()); - (&bytes[20..], w, h, mip_count, version) - } else { - (&bytes[16..], w, h, 1, version) - } -} -/// Upload an RTEX blob as a 2D texture. -/// v1/v3 = Rgba8UnormSrgb (LDR); v4 = Rgba32Float HDR mip chain. -fn load_ibl_texture( - renderer: &dyn PillRenderer, - label: &str, - bytes: &[u8], - is_srgb: bool, -) -> (wgpu::Texture, wgpu::TextureView) { - let (data, w, h, mip_count, version) = decode_rtex(bytes); - let size = wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: 1, - }; - let (format, bytes_per_texel) = if version == 4 { - (wgpu::TextureFormat::Rgba32Float, 16usize) // 4 × f32 - } else if is_srgb { - (wgpu::TextureFormat::Rgba8UnormSrgb, 4usize) - } else { - (wgpu::TextureFormat::Rgba8Unorm, 4usize) - }; - let tex = renderer - .get_device() - .create_texture(&wgpu::TextureDescriptor { - label: Some(label), - size, - mip_level_count: mip_count, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - let mut offset = 0usize; - for mip in 0..mip_count { - let mip_w = (w >> mip).max(1); - let mip_h = (h >> mip).max(1); - let mip_bytes = (mip_w * mip_h) as usize * bytes_per_texel; - renderer.get_queue().write_texture( - wgpu::TexelCopyTextureInfo { - texture: &tex, - mip_level: mip, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - &data[offset..offset + mip_bytes], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(mip_w * bytes_per_texel as u32), - rows_per_image: Some(mip_h), - }, - wgpu::Extent3d { - width: mip_w, - height: mip_h, - depth_or_array_layers: 1, - }, - ); - offset += mip_bytes; + pub fn with_fog(mut self, color: [f32; 3], density: f32) -> Self { + self.fog_color = color; + self.fog_density = density; + self } - let view = tex.create_view(&wgpu::TextureViewDescriptor { - mip_level_count: Some(mip_count), - ..Default::default() - }); - (tex, view) -} - -/// Creates a 1×1 Rgba8Unorm neutral-gray texture as a no-op IBL fallback. -fn create_1px_ibl_fallback( - renderer: &dyn PillRenderer, - label: &str, -) -> (wgpu::Texture, wgpu::TextureView) { - let tex = renderer - .get_device() - .create_texture(&wgpu::TextureDescriptor { - label: Some(label), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - renderer.get_queue().write_texture( - wgpu::TexelCopyTextureInfo { - texture: &tex, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - &[128, 128, 128, 255], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4), - rows_per_image: Some(1), - }, - wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - ); - let view = tex.create_view(&wgpu::TextureViewDescriptor { - mip_level_count: Some(1), - ..Default::default() - }); - (tex, view) } /// Returns the initialized pass state; panics in debug if `init` was not called. @@ -542,46 +430,6 @@ impl Pass for PassPBRStatic { }) }; - // 1×1 black fallback texture used when IBL handles are None — zero IBL contribution. - let ibl_fallback_texture = { - let device = renderer.get_device(); - device.create_texture(&wgpu::TextureDescriptor { - label: Some("ibl_fallback"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }) - }; - renderer.get_queue().write_texture( - wgpu::TexelCopyTextureInfo { - texture: &ibl_fallback_texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - &[0u8, 0, 0, 0], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4), - rows_per_image: Some(1), - }, - wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - ); - let ibl_fallback_view = - ibl_fallback_texture.create_view(&wgpu::TextureViewDescriptor::default()); - let ibl_sampler = { let device = renderer.get_device(); device.create_sampler(&wgpu::SamplerDescriptor { @@ -595,18 +443,44 @@ impl Pass for PassPBRStatic { }) }; - let (ibl_irradiance_texture, irradiance_view) = match &self.ibl_diffuse { - Some(bytes) => load_ibl_texture(renderer, "diffuse_ibl", bytes, true), - None => create_1px_ibl_fallback(renderer, "diffuse_ibl_fallback"), - }; - let (ibl_prefilter_texture, prefilter_view) = match &self.ibl_specular { - Some(bytes) => load_ibl_texture(renderer, "specular_ibl", bytes, true), - None => create_1px_ibl_fallback(renderer, "specular_ibl_fallback"), - }; - let (ibl_brdf_lut_texture, brdf_lut_view) = match &self.ibl_brdf_lut { - Some(bytes) => load_ibl_texture(renderer, "brdf_lut", bytes, false), // linear - None => create_1px_ibl_fallback(renderer, "brdf_lut_fallback"), - }; + // Register 1px neutral-gray fallbacks for any missing IBL handle, + // then get owned views (texture lifetime managed by renderer's pass_textures). + let irr_h = self.ibl_diffuse.unwrap_or_else(|| { + renderer.create_texture_from_pixels( + "diffuse_ibl_fallback", + &[DEFAULT_IBL_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ) + }); + let pre_h = self.ibl_specular.unwrap_or_else(|| { + renderer.create_texture_from_pixels( + "specular_ibl_fallback", + &[DEFAULT_IBL_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ) + }); + let lut_h = self.ibl_brdf_lut.unwrap_or_else(|| { + renderer.create_texture_from_pixels( + "brdf_lut_fallback", + &[DEFAULT_BRDF_LUT_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ) + }); + let irradiance_view = renderer + .get_texture_view(irr_h) + .expect("ibl_diffuse handle invalid"); + let prefilter_view = renderer + .get_texture_view(pre_h) + .expect("ibl_specular handle invalid"); + let brdf_lut_view = renderer + .get_texture_view(lut_h) + .expect("ibl_brdf_lut handle invalid"); let prefilter_sampler = { let device = renderer.get_device(); @@ -670,11 +544,6 @@ impl Pass for PassPBRStatic { globals_bind_group, pipeline, ibl_sampler, - ibl_fallback_texture, - ibl_fallback_view, - _ibl_irradiance_texture: ibl_irradiance_texture, - _ibl_prefilter_texture: ibl_prefilter_texture, - _ibl_brdf_lut_texture: ibl_brdf_lut_texture, per_draw_buffer, per_draw_bind_group, }); @@ -708,11 +577,15 @@ impl Pass for PassPBRStatic { .unwrap(); // Update camera uniform and write to GPU buffer. + let fog_color = self.fog_color; + let fog_density = self.fog_density; let vp_mat: Mat4 = { let state = get_state(self); state .camera_uniform .update_data(active_camera_component, active_camera_transform); + state.camera_uniform.fog_color = fog_color; + state.camera_uniform.fog_density = fog_density; renderer.get_queue().write_buffer( &state.camera_buffer, 0, diff --git a/engine/pill_engine/src/graphics/renderer.rs b/engine/pill_engine/src/graphics/renderer.rs index b791f955..eca728d6 100644 --- a/engine/pill_engine/src/graphics/renderer.rs +++ b/engine/pill_engine/src/graphics/renderer.rs @@ -137,9 +137,8 @@ pub trait PillRenderer { /// Reconfigures the swap chain and depth texture after a window resize. fn resize(&mut self, new_window_size: winit::dpi::PhysicalSize); - #[cfg(feature = "ui")] - /// Forwards a winit window event to the egui input handler. - fn pass_input_to_egui(&mut self, event: &winit::event::WindowEvent) -> Result<()>; + /// Returns the OS window the renderer is bound to. + fn get_window(&self) -> std::sync::Arc; /// Drives the full frame: acquires the surface, runs all passes, and presents. fn render( @@ -158,12 +157,8 @@ pub trait PillRenderer { /// Replaces the current pass chain; calls `Pass::init` on each new pass before storing it. fn set_passes(&mut self, passes: Vec>) -> Result<()>; - /// Installs the default pass chain (scene + optional egui) on first frame bootstrap. - fn init_default_passes(&mut self) -> Result<()>; - - /// Returns the shared egui client; available only when the `ui` feature is enabled. - #[cfg(feature = "ui")] - fn get_egui_client(&self) -> Option>; + /// Returns the current surface dimensions `(width, height)` in pixels. + fn get_surface_size(&self) -> (u32, u32); /// Returns the wgpu `Device`; required by passes that allocate their own GPU resources. fn get_device(&self) -> &wgpu::Device; @@ -195,17 +190,20 @@ pub trait PillRenderer { /// Returns the texture view for a previously created render target, or `None` if not found. fn get_render_target_view(&self, handle: RendererTextureHandle) -> Option<&wgpu::TextureView>; - /// Stores equirect background bytes; used by PassBackground on first-frame init. - fn set_background_texture(&mut self, bytes: Vec) -> Result<()>; - - /// Stores IBL map bytes (diffuse irradiance, specular prefilter, BRDF LUT); - /// used by PassPBRStatic on first-frame init. - fn set_ibl_textures( + /// Uploads pixel data as a GPU texture and returns a handle stored in the renderer. + /// `mip_pixels[i]` is the raw byte slice for mip level `i`. + fn create_texture_from_pixels( &mut self, - diffuse: Vec, - specular: Vec, - brdf_lut: Vec, - ) -> Result<()>; + name: &str, + mip_pixels: &[&[u8]], + base_width: u32, + base_height: u32, + format: wgpu::TextureFormat, + ) -> RendererTextureHandle; + + /// Creates a new `TextureView` for a previously registered texture handle. + /// Returns `None` if the handle is not found. + fn get_texture_view(&self, handle: RendererTextureHandle) -> Option; } pub type Renderer = Box; diff --git a/engine/pill_engine/src/lib.rs b/engine/pill_engine/src/lib.rs index a9da8d3b..be431bcb 100644 --- a/engine/pill_engine/src/lib.rs +++ b/engine/pill_engine/src/lib.rs @@ -64,10 +64,11 @@ pub mod game { ecs::{ CameraAspectRatio, CameraComponent, Component, ComponentStorage, EntityHandle, GamepadAxis, GamepadButton, GlobalComponent, GlobalComponentStorage, InputComponent, - MeshComponent, PbrRenderableComponent, PlayerId, SceneHandle, TimeComponent, - TransformComponent, UpdatePhase, + MeshComponent, PbrRenderableComponent, PlayerId, RenderStateComponent, SceneHandle, + TimeComponent, TransformComponent, UpdatePhase, }, engine::{Engine, KeyboardKey, MouseButton, PillGame}, + graphics::RendererTextureHandle, resources::{ Material, MaterialHandle, Mesh, MeshHandle, PBRMaterial, PBRMaterialHandle, Resource, ResourceLoader, ResourceStorage, Shader, ShaderParameterSlot, ShaderParameterType, @@ -96,7 +97,7 @@ mod internal_mod { #[cfg(feature = "ui")] pub use crate::ecs::EguiClient; #[cfg(feature = "ui")] - pub use crate::ecs::EguiManagerComponent; + pub use crate::ecs::EguiComponent; pub use crate::{ config::*, ecs::{ @@ -131,7 +132,7 @@ mod internal_mod { #[cfg(feature = "ui")] pub use crate::ecs::EguiClient; #[cfg(feature = "ui")] - pub use crate::ecs::EguiManagerComponent; + pub use crate::ecs::EguiComponent; pub use crate::{ config::*, ecs::{ diff --git a/engine/pill_engine/src/renderer/resources/renderer_texture.rs b/engine/pill_engine/src/renderer/resources/renderer_texture.rs index 229a6a4a..2e171f3e 100644 --- a/engine/pill_engine/src/renderer/resources/renderer_texture.rs +++ b/engine/pill_engine/src/renderer/resources/renderer_texture.rs @@ -130,6 +130,86 @@ impl RendererTexture { }) } + /// Upload pre-computed pixel data as a 2D texture with an optional mip chain. + /// `mip_pixels[i]` is the raw byte slice for mip level `i`; all slices must be + /// sized consistently with `(base_width >> i) * (base_height >> i) * bytes_per_texel(format)`. + pub fn new_from_pixels( + device: &wgpu::Device, + queue: &wgpu::Queue, + name: &str, + mip_pixels: &[&[u8]], + base_width: u32, + base_height: u32, + format: wgpu::TextureFormat, + ) -> Result { + let mip_count = mip_pixels.len() as u32; + let bytes_per_texel: u32 = match format { + wgpu::TextureFormat::Rgba32Float => 16, + _ => 4, + }; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(name), + size: wgpu::Extent3d { + width: base_width, + height: base_height, + depth_or_array_layers: 1, + }, + mip_level_count: mip_count, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + for (mip, &mip_data) in mip_pixels.iter().enumerate() { + let mip = mip as u32; + let mip_w = (base_width >> mip).max(1); + let mip_h = (base_height >> mip).max(1); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: mip, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + mip_data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(mip_w * bytes_per_texel), + rows_per_image: Some(mip_h), + }, + wgpu::Extent3d { + width: mip_w, + height: mip_h, + depth_or_array_layers: 1, + }, + ); + } + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Linear, + lod_min_clamp: 0.0, + lod_max_clamp: 16.0, + ..Default::default() + }); + + Ok(Self { + name: name.to_string(), + texture, + texture_view, + sampler, + }) + } + pub fn new_render_target( device: &wgpu::Device, label: &str, diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs index c0af4951..d0a4a217 100644 --- a/engine/pill_engine/src/renderer/state.rs +++ b/engine/pill_engine/src/renderer/state.rs @@ -18,8 +18,6 @@ use crate::{ resources::ResourceManager, }; -#[cfg(feature = "ui")] -use crate::ecs::EguiClient; use pill_core::Result; use pill_core::{ debug, info, LogContext, PillSlotMap, PillSlotMapKey, PillStyle, RendererError, Timer, @@ -110,12 +108,8 @@ impl PillRenderer for Renderer { self.state.resize(new_window_size) } - #[cfg(feature = "ui")] - fn pass_input_to_egui(&mut self, event: &winit::event::WindowEvent) -> Result<()> { - if let Some(c) = &self.state.egui_client { - c.handle_input(event.clone()); - } - Ok(()) + fn get_window(&self) -> Arc { + self.state.window.clone() } fn render( @@ -246,60 +240,11 @@ impl PillRenderer for Renderer { Ok(()) } - /// Installs the default pass chain (scene + optional egui) on first frame bootstrap. - fn init_default_passes(&mut self) -> Result<()> { - use crate::graphics::{PassBackground, PassTonemap}; - - let w = self.state.surface_configuration.width; - let h = self.state.surface_configuration.height; - let hdr = self.create_render_target(RendererTargetDesc { - name: "hdr_target".to_string(), - format: wgpu::TextureFormat::Rgba16Float, - width: w, - height: h, - })?; - - let bg = { - let mut p = PassBackground::new(hdr); - if let Some(bytes) = self.state.background_equirect.take() { - p = p.with_equirect_bytes(bytes); - } - p - }; - let pbr = { - let d = self.state.ibl_diffuse.take(); - let s = self.state.ibl_specular.take(); - let b = self.state.ibl_brdf_lut.take(); - let mut p = PassPBRStatic::new(Some(hdr)); - if let (Some(d), Some(s), Some(b)) = (d, s, b) { - p = p.with_ibl(d, s, b); - } - p - }; - - #[cfg(feature = "ui")] - { - use crate::graphics::PassEgui; - let egui_client = EguiClient::new(); - self.state.egui_client = Some(egui_client.clone()); - return self.set_passes(vec![ - Box::new(bg), - Box::new(pbr), - Box::new(PassTonemap::new(hdr)), - Box::new(PassEgui::new(self.state.window.clone(), egui_client)), - ]); - } - #[cfg(not(feature = "ui"))] - self.set_passes(vec![ - Box::new(bg), - Box::new(pbr), - Box::new(PassTonemap::new(hdr)), - ]) - } - - #[cfg(feature = "ui")] - fn get_egui_client(&self) -> Option> { - self.state.egui_client.clone() + fn get_surface_size(&self) -> (u32, u32) { + ( + self.state.surface_configuration.width, + self.state.surface_configuration.height, + ) } fn get_device(&self) -> &wgpu::Device { @@ -433,21 +378,32 @@ impl PillRenderer for Renderer { .map(|t| &t.texture_view) } - fn set_background_texture(&mut self, bytes: Vec) -> Result<()> { - self.state.background_equirect = Some(bytes); - Ok(()) + fn create_texture_from_pixels( + &mut self, + name: &str, + mip_pixels: &[&[u8]], + base_width: u32, + base_height: u32, + format: wgpu::TextureFormat, + ) -> RendererTextureHandle { + let texture = RendererTexture::new_from_pixels( + &self.state.device, + &self.state.queue, + name, + mip_pixels, + base_width, + base_height, + format, + ) + .expect("create_texture_from_pixels failed"); + self.state.pass_textures.insert(texture) } - fn set_ibl_textures( - &mut self, - diffuse: Vec, - specular: Vec, - brdf_lut: Vec, - ) -> Result<()> { - self.state.ibl_diffuse = Some(diffuse); - self.state.ibl_specular = Some(specular); - self.state.ibl_brdf_lut = Some(brdf_lut); - Ok(()) + fn get_texture_view(&self, handle: RendererTextureHandle) -> Option { + self.state.pass_textures.get(handle).map(|t| { + t.texture + .create_view(&wgpu::TextureViewDescriptor::default()) + }) } } @@ -463,17 +419,11 @@ pub struct State { depth_format: wgpu::TextureFormat, depth_texture: RendererTexture, passes: Vec>, - #[cfg(feature = "ui")] - egui_client: Option>, camera_bind_group_layout: wgpu::BindGroupLayout, window: Arc, // Optional frame capture: (target_frame, output_path). Set via PILL_SCREENSHOT env var. screenshot: Option<(u32, String)>, frame_counter: u32, - background_equirect: Option>, - ibl_diffuse: Option>, - ibl_specular: Option>, - ibl_brdf_lut: Option>, } impl State { @@ -645,16 +595,10 @@ impl State { depth_format, depth_texture, passes: Vec::new(), - #[cfg(feature = "ui")] - egui_client: None, camera_bind_group_layout, window, screenshot, frame_counter: 0, - background_equirect: None, - ibl_diffuse: None, - ibl_specular: None, - ibl_brdf_lut: None, }) } diff --git a/engine/pill_launcher/res/templates/wasm/src/lib.rs b/engine/pill_launcher/res/templates/wasm/src/lib.rs index 164da889..0aa14b12 100644 --- a/engine/pill_launcher/res/templates/wasm/src/lib.rs +++ b/engine/pill_launcher/res/templates/wasm/src/lib.rs @@ -10,8 +10,6 @@ use wasm_bindgen::prelude::*; -use pill_game::WebGame; - #[cfg(target_arch = "wasm32")] #[global_allocator] static ALLOC: lol_alloc::AssumeSingleThreaded = @@ -19,5 +17,5 @@ static ALLOC: lol_alloc::AssumeSingleThreaded = #[wasm_bindgen(start)] pub fn wasm_main() { - pill_web::run(Box::new(WebGame {}), include_str!("../config.ini")); + pill_web::run(pill_game::create_pill_game(), include_str!("../config.ini")); } diff --git a/examples/cube/src/lib.rs b/examples/cube/src/lib.rs index 1265e0a8..6dce3a0d 100644 --- a/examples/cube/src/lib.rs +++ b/examples/cube/src/lib.rs @@ -1,5 +1,5 @@ mod game; -pub use game::WebGame; use pill_engine::game::create_game; create_game!(crate::game::WebGame {}, pill_engine::game::PillGame); + diff --git a/examples/pbr_balls/src/bake.rs b/examples/pbr_balls/src/bake.rs new file mode 100644 index 00000000..c5bb7cf3 --- /dev/null +++ b/examples/pbr_balls/src/bake.rs @@ -0,0 +1,385 @@ +use std::f32::consts::PI; + +// Gradient (linear, inverse-Reinhard compensated: c/(1+c) → target on screen). +const TOP: [f32; 3] = [0.90, 0.90, 1.20]; // Reinhard → [0.47, 0.47, 0.55] cool blue ceiling +const HORIZON: [f32; 3] = [0.43, 0.43, 0.46]; // Reinhard → [0.30, 0.30, 0.32] neutral mid +const BOTTOM: [f32; 3] = [0.040, 0.040, 0.050]; // Reinhard → [0.04, 0.04, 0.05] dark floor + +// Key light: warm tungsten, upper-left, ~40° above horizon. +const KEY_U: f32 = 0.10; +const KEY_V: f32 = 0.28; +const KEY_INTENSITY: [f32; 3] = [6.0, 4.8, 2.5]; // Reinhard → [0.86, 0.83, 0.71] warm gold + +// Rim light: cool blue, upper-right, ~45° above horizon. +const RIM_U: f32 = 0.60; +const RIM_V: f32 = 0.25; +const RIM_INTENSITY: [f32; 3] = [1.5, 2.5, 5.0]; // Reinhard → [0.60, 0.71, 0.83] cool blue + +const LIGHT_K: f32 = 8.0; // glare falloff exponent +const KEY_SKY: [f32; 3] = [0.010, 0.060, 0.200]; // 0.2 * vec3(0.05, 0.3, 1.) — cool blue haze away from key + +/// Generates the studio equirect panorama as Rgba32Float pixels (512×256 linear HDR). +pub fn generate() -> (Vec, u32, u32) { + let (w, h) = (512u32, 256u32); + let key_dir = equirect_uv_to_dir(KEY_U, KEY_V); + let rim_dir = equirect_uv_to_dir(RIM_U, RIM_V); + let mut out = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let u = (x as f32 + 0.5) / w as f32; + let v = (y as f32 + 0.5) / h as f32; + let pixel_dir = equirect_uv_to_dir(u, v); + let t = (pixel_dir[1] + 1.0) * 0.5; // pixel_dir[1] = sin(elev) ∈ [-1, 1] + let mut col = if t < 0.5 { + lerp3(BOTTOM, HORIZON, t * 2.0) + } else { + lerp3(HORIZON, TOP, (t - 0.5) * 2.0) + }; + // key: warm glare + cool sky haze (FF2023 sun glare formula) + let sun = dot(pixel_dir, key_dir).clamp(0.0, 1.0); + let sky = 1.0 - sun.powf(0.5); + col[0] += KEY_INTENSITY[0] * sun.powf(LIGHT_K) + KEY_SKY[0] * sky; + col[1] += KEY_INTENSITY[1] * sun.powf(LIGHT_K) + KEY_SKY[1] * sky; + col[2] += KEY_INTENSITY[2] * sun.powf(LIGHT_K) + KEY_SKY[2] * sky; + // rim: glare only + let w_rim = dot(pixel_dir, rim_dir).clamp(0.0, 1.0).powf(LIGHT_K); + col[0] += RIM_INTENSITY[0] * w_rim; + col[1] += RIM_INTENSITY[1] * w_rim; + col[2] += RIM_INTENSITY[2] * w_rim; + out.push(col[0]); + out.push(col[1]); + out.push(col[2]); + out.push(1.0); + } + } + (out, w, h) +} + +/// Bakes IBL maps from an Rgba32Float equirect panorama. +/// Returns (diffuse 32×16, specular mips [128×64 → 8×4], brdf_lut 256×256), all Rgba32Float. +pub fn bake_all( + equirect: &[f32], + width: u32, + height: u32, +) -> (Vec, Vec>, Vec) { + let eq = Equirect { + pixels: equirect.to_vec(), + w: width, + h: height, + }; + + // diffuse: irradiance as linear f32 (sRGB→linear round-trip for u8 intermediate) + let diff_u8 = compute_irradiance(&eq, 32, 16); + let diffuse: Vec = diff_u8 + .chunks(4) + .flat_map(|rgba| { + [ + srgb_to_linear(rgba[0] as f32 / 255.0), + srgb_to_linear(rgba[1] as f32 / 255.0), + srgb_to_linear(rgba[2] as f32 / 255.0), + 1.0f32, + ] + }) + .collect(); + + // specular: 5 mip levels, Rgba32Float HDR, roughness steps matching shader MAX_REFLECTION_LOD=4 + const MIP_ROUGHNESS: [f32; 5] = [0.04, 0.25, 0.5, 0.75, 1.0]; + let mut specular_mips: Vec> = Vec::with_capacity(5); + for (mip, &r) in MIP_ROUGHNESS.iter().enumerate() { + let w = (128u32 >> mip).max(1); + let h = (64u32 >> mip).max(1); + specular_mips.push(compute_specular_prefilter(&eq, w, h, r)); + } + + // brdf_lut: linear u8 → f32 + let lut_u8 = compute_brdf_lut(256, 256); + let brdf_lut: Vec = lut_u8.iter().map(|&b| b as f32 / 255.0).collect(); + + (diffuse, specular_mips, brdf_lut) +} + +// --- Equirect --- + +struct Equirect { + pixels: Vec, // flat RGBA f32 linear + w: u32, + h: u32, +} + +impl Equirect { + // Sample at a world-space direction; nearest-neighbor, returns linear HDR values. + fn sample(&self, dir: [f32; 3]) -> [f32; 3] { + let (u, v) = dir_to_equirect_uv(dir); + let x = ((u * self.w as f32) as u32).min(self.w - 1); + let y = ((v * self.h as f32) as u32).min(self.h - 1); + let i = ((y * self.w + x) * 4) as usize; + [self.pixels[i], self.pixels[i + 1], self.pixels[i + 2]] + } +} + +// --- Coordinate helpers --- + +fn equirect_uv_to_dir(u: f32, v: f32) -> [f32; 3] { + let az = (u - 0.5) * 2.0 * PI; // consistent with dir_to_equirect_uv: u = 0.5 + az/(2π) + let elev = (0.5 - v) * PI; + let cos_e = elev.cos(); + [cos_e * az.cos(), elev.sin(), cos_e * az.sin()] +} + +fn dir_to_equirect_uv(dir: [f32; 3]) -> (f32, f32) { + let [x, y, z] = normalize(dir); + let az = z.atan2(x); + let elev = y.clamp(-1.0, 1.0).asin(); + let u = (0.5 + az / (2.0 * PI)).rem_euclid(1.0); + let v = (0.5 - elev / PI).clamp(0.0, 1.0); + (u, v) +} + +// --- Vector math --- + +fn normalize(v: [f32; 3]) -> [f32; 3] { + let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-8); + [v[0] / len, v[1] / len, v[2] / len] +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +// Build an orthonormal tangent frame (t, b) from normal n. +fn tangent_frame(n: [f32; 3]) -> ([f32; 3], [f32; 3]) { + let up = if n[1].abs() < 0.9 { + [0.0f32, 1.0, 0.0] + } else { + [1.0f32, 0.0, 0.0] + }; + let t = normalize(cross(up, n)); + let b = cross(n, t); + (t, b) +} + +// Transform v from tangent space (where n=[0,0,1]) to world space. +fn tangent_to_world(v: [f32; 3], n: [f32; 3]) -> [f32; 3] { + let (t, b) = tangent_frame(n); + normalize([ + t[0] * v[0] + b[0] * v[1] + n[0] * v[2], + t[1] * v[0] + b[1] * v[1] + n[1] * v[2], + t[2] * v[0] + b[2] * v[1] + n[2] * v[2], + ]) +} + +// --- Hammersley quasi-random sequence --- + +fn radical_inverse_vdc(mut bits: u32) -> f32 { + bits = bits.rotate_right(16); + bits = ((bits & 0x5555_5555) << 1) | ((bits & 0xAAAA_AAAA) >> 1); + bits = ((bits & 0x3333_3333) << 2) | ((bits & 0xCCCC_CCCC) >> 2); + bits = ((bits & 0x0F0F_0F0F) << 4) | ((bits & 0xF0F0_F0F0) >> 4); + bits = ((bits & 0x00FF_00FF) << 8) | ((bits & 0xFF00_FF00) >> 8); + bits as f32 * 2.328_306_4e-10 // / 2^32 +} + +fn hammersley(i: u32, n: u32) -> (f32, f32) { + (i as f32 / n as f32, radical_inverse_vdc(i)) +} + +// GGX importance sampling: returns half-vector in world space around n. +fn importance_sample_ggx(xi: (f32, f32), roughness: f32, n: [f32; 3]) -> [f32; 3] { + let a = roughness * roughness; + let phi = 2.0 * PI * xi.0; + let cos_t = ((1.0 - xi.1) / (1.0 + (a * a - 1.0) * xi.1)) + .max(0.0) + .sqrt(); + let sin_t = (1.0 - cos_t * cos_t).max(0.0).sqrt(); + let h_local = [sin_t * phi.cos(), sin_t * phi.sin(), cos_t]; + tangent_to_world(h_local, n) +} + +// --- Diffuse irradiance: cosine-weighted Riemann sum --- + +fn compute_irradiance(eq: &Equirect, out_w: u32, out_h: u32) -> Vec { + const N_PHI: u32 = 64; + const N_THETA: u32 = 32; + let dphi = 2.0 * PI / N_PHI as f32; + let dtheta = 0.5 * PI / N_THETA as f32; + + let mut out = Vec::with_capacity((out_w * out_h * 4) as usize); + for y in 0..out_h { + for x in 0..out_w { + let u = (x as f32 + 0.5) / out_w as f32; + let v = (y as f32 + 0.5) / out_h as f32; + let n = equirect_uv_to_dir(u, v); + + let mut irr = [0.0f32; 3]; + let mut wt = 0.0f32; + + for j in 0..N_THETA { + for i in 0..N_PHI { + let phi = (i as f32 + 0.5) * dphi; + let theta = (j as f32 + 0.5) * dtheta; + let sin_t = theta.sin(); + let cos_t = theta.cos(); + let local = [sin_t * phi.cos(), sin_t * phi.sin(), cos_t]; + let world = tangent_to_world(local, n); + let sample = eq.sample(world); + let w = cos_t * sin_t; + irr[0] += sample[0] * w; + irr[1] += sample[1] * w; + irr[2] += sample[2] * w; + wt += w; + } + } + + let s = PI / wt; + out.push(linear_to_srgb(irr[0] * s)); + out.push(linear_to_srgb(irr[1] * s)); + out.push(linear_to_srgb(irr[2] * s)); + out.push(255u8); + } + } + out +} + +// --- Specular prefilter: GGX importance sampling, HDR linear output --- + +fn compute_specular_prefilter(eq: &Equirect, out_w: u32, out_h: u32, roughness: f32) -> Vec { + const N_SAMPLES: u32 = 256; + + let mut out = Vec::with_capacity((out_w * out_h * 4) as usize); + for y in 0..out_h { + for x in 0..out_w { + let u = (x as f32 + 0.5) / out_w as f32; + let v = (y as f32 + 0.5) / out_h as f32; + let n = equirect_uv_to_dir(u, v); + + let mut col = [0.0f32; 3]; + let mut wt = 0.0f32; + + for i in 0..N_SAMPLES { + let xi = hammersley(i, N_SAMPLES); + let h = importance_sample_ggx(xi, roughness, n); + let vdoth = dot(n, h).max(0.0); + let l = normalize([ + 2.0 * vdoth * h[0] - n[0], + 2.0 * vdoth * h[1] - n[1], + 2.0 * vdoth * h[2] - n[2], + ]); + let ndotl = dot(n, l).max(0.0); + if ndotl > 0.0 { + let s = eq.sample(l); + col[0] += s[0] * ndotl; + col[1] += s[1] * ndotl; + col[2] += s[2] * ndotl; + wt += ndotl; + } + } + + let s = 1.0 / wt.max(1e-6); + out.push(col[0] * s); + out.push(col[1] * s); + out.push(col[2] * s); + out.push(1.0f32); + } + } + out +} + +// --- BRDF LUT: GGX split-sum preintegration (Karis / UE4) --- +// Stored linear (not sRGB): R = F0 scale, G = F0 bias. + +fn geometry_schlick_ggx_ibl(ndotv: f32, roughness: f32) -> f32 { + let k = roughness * roughness / 2.0; + ndotv / (ndotv * (1.0 - k) + k) +} + +fn geometry_smith_ibl(ndotv: f32, ndotl: f32, roughness: f32) -> f32 { + geometry_schlick_ggx_ibl(ndotv, roughness) * geometry_schlick_ggx_ibl(ndotl, roughness) +} + +fn compute_brdf_lut(w: u32, h: u32) -> Vec { + const N: u32 = 1024; + + let mut out = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + // x = NdotV (0=grazing, 1=normal incidence) + // y = roughness (0=smooth, 1=rough) — y=0 is top of texture + let ndotv = (x as f32 + 0.5) / w as f32; + let roughness = (y as f32 + 0.5) / h as f32; + let roughness = roughness.max(0.04); + + let v = [(1.0 - ndotv * ndotv).max(0.0).sqrt(), 0.0f32, ndotv]; + let n = [0.0f32, 0.0, 1.0]; + + let mut scale = 0.0f32; + let mut bias = 0.0f32; + + for i in 0..N { + let xi = hammersley(i, N); + let h_v = importance_sample_ggx(xi, roughness, n); + let vdoth = dot(v, h_v).max(0.0); + let l = normalize([ + 2.0 * vdoth * h_v[0] - v[0], + 2.0 * vdoth * h_v[1] - v[1], + 2.0 * vdoth * h_v[2] - v[2], + ]); + let ndotl = l[2].max(0.0); // n = [0,0,1] + let ndoth = h_v[2].max(0.0); + + if ndotl > 0.0 { + let g_vis = geometry_smith_ibl(ndotv, ndotl, roughness) * vdoth + / (ndoth * ndotv.max(0.001)); + let fc = (1.0 - vdoth).powi(5); + scale += (1.0 - fc) * g_vis; + bias += fc * g_vis; + } + } + + scale = (scale / N as f32).clamp(0.0, 1.0); + bias = (bias / N as f32).clamp(0.0, 1.0); + + // Linear bytes (not sRGB — BRDF coefficients are in linear space) + out.push((scale * 255.0 + 0.5) as u8); + out.push((bias * 255.0 + 0.5) as u8); + out.push(0u8); + out.push(255u8); + } + } + out +} + +// --- Color utilities --- + +fn linear_to_srgb(x: f32) -> u8 { + let c = x.clamp(0.0, 1.0); + let s = if c <= 0.003_130_8 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + }; + (s * 255.0 + 0.5) as u8 +} + +fn srgb_to_linear(x: f32) -> f32 { + if x <= 0.040_45 { + x / 12.92 + } else { + ((x + 0.055) / 1.055).powf(2.4) + } +} + +fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ] +} diff --git a/examples/pbr_balls/src/game.rs b/examples/pbr_balls/src/game.rs index 0112abf2..7d10c876 100644 --- a/examples/pbr_balls/src/game.rs +++ b/examples/pbr_balls/src/game.rs @@ -1,9 +1,6 @@ use pill_engine::{define_component, game::*}; -const EQUIRECT: &[u8] = include_bytes!("../res/textures/studio_equirect.cooked_tex"); -const DIFFUSE_IBL: &[u8] = include_bytes!("../res/textures/studio_diffuse_ibl.cooked_tex"); -const SPECULAR_IBL: &[u8] = include_bytes!("../res/textures/studio_specular_ibl.cooked_tex"); -const BRDF_LUT: &[u8] = include_bytes!("../res/textures/brdf_lut.cooked_tex"); +use crate::bake; define_component!(OrbitCamera { yaw: f32, @@ -15,12 +12,19 @@ pub struct Game {} impl PillGame for Game { fn start(&self, engine: &mut Engine) -> Result<()> { - engine.set_background_texture(EQUIRECT.to_vec())?; - engine.set_ibl_textures( - DIFFUSE_IBL.to_vec(), - SPECULAR_IBL.to_vec(), - BRDF_LUT.to_vec(), - )?; + let (eq, eq_w, eq_h) = bake::generate(); + let (diffuse, specular_mips, brdf_lut) = bake::bake_all(&eq, eq_w, eq_h); + + let bg_h = engine.create_gpu_texture_f32("equirect", &eq, eq_w, eq_h)?; + let diff_h = engine.create_gpu_texture_f32("diffuse_ibl", &diffuse, 32, 16)?; + let spec_h = engine.create_gpu_mipped_texture_f32("specular_ibl", &specular_mips, 128, 64)?; + let lut_h = engine.create_gpu_texture_f32("brdf_lut", &brdf_lut, 256, 256)?; + + let rs = engine.get_global_component_mut::()?; + rs.background = bg_h; + rs.ibl_diffuse = diff_h; + rs.ibl_specular = spec_h; + rs.ibl_brdf_lut = lut_h; let scene = engine.create_scene("pbr_balls")?; engine.set_active_scene(scene)?; diff --git a/examples/pbr_balls/src/lib.rs b/examples/pbr_balls/src/lib.rs index 3ab5b3fb..f1d2fb91 100644 --- a/examples/pbr_balls/src/lib.rs +++ b/examples/pbr_balls/src/lib.rs @@ -1,3 +1,4 @@ +mod bake; mod game; pub use game::Game; diff --git a/examples/pbr_helmet/src/bake.rs b/examples/pbr_helmet/src/bake.rs new file mode 100644 index 00000000..e66b7c50 --- /dev/null +++ b/examples/pbr_helmet/src/bake.rs @@ -0,0 +1,381 @@ +use std::f32::consts::PI; + +// Gradient (linear, inverse-Reinhard compensated: c/(1+c) → target on screen). +const TOP: [f32; 3] = [0.90, 0.90, 1.20]; // Reinhard → [0.47, 0.47, 0.55] cool blue ceiling +const HORIZON: [f32; 3] = [0.43, 0.43, 0.46]; // Reinhard → [0.30, 0.30, 0.32] neutral mid +const BOTTOM: [f32; 3] = [0.040, 0.040, 0.050]; // Reinhard → [0.04, 0.04, 0.05] dark floor + +// Key light: warm tungsten, upper-left, ~40° above horizon. +const KEY_U: f32 = 0.10; +const KEY_V: f32 = 0.28; +const KEY_INTENSITY: [f32; 3] = [6.0, 4.8, 2.5]; // Reinhard → [0.86, 0.83, 0.71] warm gold + +// Rim light: cool blue, upper-right, ~45° above horizon. +const RIM_U: f32 = 0.60; +const RIM_V: f32 = 0.25; +const RIM_INTENSITY: [f32; 3] = [1.5, 2.5, 5.0]; // Reinhard → [0.60, 0.71, 0.83] cool blue + +const LIGHT_K: f32 = 8.0; // glare falloff exponent +const KEY_SKY: [f32; 3] = [0.010, 0.060, 0.200]; // 0.2 * vec3(0.05, 0.3, 1.) — cool blue haze away from key + +/// Generates the studio equirect panorama as Rgba32Float pixels (512×256 linear HDR). +pub fn generate() -> (Vec, u32, u32) { + let (w, h) = (512u32, 256u32); + let key_dir = equirect_uv_to_dir(KEY_U, KEY_V); + let rim_dir = equirect_uv_to_dir(RIM_U, RIM_V); + let mut out = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let u = (x as f32 + 0.5) / w as f32; + let v = (y as f32 + 0.5) / h as f32; + let pixel_dir = equirect_uv_to_dir(u, v); + let t = (pixel_dir[1] + 1.0) * 0.5; // pixel_dir[1] = sin(elev) ∈ [-1, 1] + let mut col = if t < 0.5 { + lerp3(BOTTOM, HORIZON, t * 2.0) + } else { + lerp3(HORIZON, TOP, (t - 0.5) * 2.0) + }; + // key: warm glare + cool sky haze (FF2023 sun glare formula) + let sun = dot(pixel_dir, key_dir).clamp(0.0, 1.0); + let sky = 1.0 - sun.powf(0.5); + col[0] += KEY_INTENSITY[0] * sun.powf(LIGHT_K) + KEY_SKY[0] * sky; + col[1] += KEY_INTENSITY[1] * sun.powf(LIGHT_K) + KEY_SKY[1] * sky; + col[2] += KEY_INTENSITY[2] * sun.powf(LIGHT_K) + KEY_SKY[2] * sky; + // rim: glare only + let w_rim = dot(pixel_dir, rim_dir).clamp(0.0, 1.0).powf(LIGHT_K); + col[0] += RIM_INTENSITY[0] * w_rim; + col[1] += RIM_INTENSITY[1] * w_rim; + col[2] += RIM_INTENSITY[2] * w_rim; + out.push(col[0]); + out.push(col[1]); + out.push(col[2]); + out.push(1.0); + } + } + (out, w, h) +} + +/// Bakes IBL maps from an Rgba32Float equirect panorama. +/// Returns (diffuse 32×16, specular mips [128×64 → 8×4], brdf_lut 256×256), all Rgba32Float. +pub fn bake_all(equirect: &[f32], width: u32, height: u32) -> (Vec, Vec>, Vec) { + let eq = Equirect { + pixels: equirect.to_vec(), + w: width, + h: height, + }; + + // diffuse: irradiance as linear f32 (sRGB→linear round-trip for u8 intermediate) + let diff_u8 = compute_irradiance(&eq, 32, 16); + let diffuse: Vec = diff_u8 + .chunks(4) + .flat_map(|rgba| { + [ + srgb_to_linear(rgba[0] as f32 / 255.0), + srgb_to_linear(rgba[1] as f32 / 255.0), + srgb_to_linear(rgba[2] as f32 / 255.0), + 1.0f32, + ] + }) + .collect(); + + // specular: 5 mip levels, Rgba32Float HDR, roughness steps matching shader MAX_REFLECTION_LOD=4 + const MIP_ROUGHNESS: [f32; 5] = [0.04, 0.25, 0.5, 0.75, 1.0]; + let mut specular_mips: Vec> = Vec::with_capacity(5); + for (mip, &r) in MIP_ROUGHNESS.iter().enumerate() { + let w = (128u32 >> mip).max(1); + let h = (64u32 >> mip).max(1); + specular_mips.push(compute_specular_prefilter(&eq, w, h, r)); + } + + // brdf_lut: linear u8 → f32 + let lut_u8 = compute_brdf_lut(256, 256); + let brdf_lut: Vec = lut_u8.iter().map(|&b| b as f32 / 255.0).collect(); + + (diffuse, specular_mips, brdf_lut) +} + +// --- Equirect --- + +struct Equirect { + pixels: Vec, // flat RGBA f32 linear + w: u32, + h: u32, +} + +impl Equirect { + // Sample at a world-space direction; nearest-neighbor, returns linear HDR values. + fn sample(&self, dir: [f32; 3]) -> [f32; 3] { + let (u, v) = dir_to_equirect_uv(dir); + let x = ((u * self.w as f32) as u32).min(self.w - 1); + let y = ((v * self.h as f32) as u32).min(self.h - 1); + let i = ((y * self.w + x) * 4) as usize; + [self.pixels[i], self.pixels[i + 1], self.pixels[i + 2]] + } +} + +// --- Coordinate helpers --- + +fn equirect_uv_to_dir(u: f32, v: f32) -> [f32; 3] { + let az = (u - 0.5) * 2.0 * PI; // consistent with dir_to_equirect_uv: u = 0.5 + az/(2π) + let elev = (0.5 - v) * PI; + let cos_e = elev.cos(); + [cos_e * az.cos(), elev.sin(), cos_e * az.sin()] +} + +fn dir_to_equirect_uv(dir: [f32; 3]) -> (f32, f32) { + let [x, y, z] = normalize(dir); + let az = z.atan2(x); + let elev = y.clamp(-1.0, 1.0).asin(); + let u = (0.5 + az / (2.0 * PI)).rem_euclid(1.0); + let v = (0.5 - elev / PI).clamp(0.0, 1.0); + (u, v) +} + +// --- Vector math --- + +fn normalize(v: [f32; 3]) -> [f32; 3] { + let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-8); + [v[0] / len, v[1] / len, v[2] / len] +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +// Build an orthonormal tangent frame (t, b) from normal n. +fn tangent_frame(n: [f32; 3]) -> ([f32; 3], [f32; 3]) { + let up = if n[1].abs() < 0.9 { + [0.0f32, 1.0, 0.0] + } else { + [1.0f32, 0.0, 0.0] + }; + let t = normalize(cross(up, n)); + let b = cross(n, t); + (t, b) +} + +// Transform v from tangent space (where n=[0,0,1]) to world space. +fn tangent_to_world(v: [f32; 3], n: [f32; 3]) -> [f32; 3] { + let (t, b) = tangent_frame(n); + normalize([ + t[0] * v[0] + b[0] * v[1] + n[0] * v[2], + t[1] * v[0] + b[1] * v[1] + n[1] * v[2], + t[2] * v[0] + b[2] * v[1] + n[2] * v[2], + ]) +} + +// --- Hammersley quasi-random sequence --- + +fn radical_inverse_vdc(mut bits: u32) -> f32 { + bits = bits.rotate_right(16); + bits = ((bits & 0x5555_5555) << 1) | ((bits & 0xAAAA_AAAA) >> 1); + bits = ((bits & 0x3333_3333) << 2) | ((bits & 0xCCCC_CCCC) >> 2); + bits = ((bits & 0x0F0F_0F0F) << 4) | ((bits & 0xF0F0_F0F0) >> 4); + bits = ((bits & 0x00FF_00FF) << 8) | ((bits & 0xFF00_FF00) >> 8); + bits as f32 * 2.328_306_4e-10 // / 2^32 +} + +fn hammersley(i: u32, n: u32) -> (f32, f32) { + (i as f32 / n as f32, radical_inverse_vdc(i)) +} + +// GGX importance sampling: returns half-vector in world space around n. +fn importance_sample_ggx(xi: (f32, f32), roughness: f32, n: [f32; 3]) -> [f32; 3] { + let a = roughness * roughness; + let phi = 2.0 * PI * xi.0; + let cos_t = ((1.0 - xi.1) / (1.0 + (a * a - 1.0) * xi.1)) + .max(0.0) + .sqrt(); + let sin_t = (1.0 - cos_t * cos_t).max(0.0).sqrt(); + let h_local = [sin_t * phi.cos(), sin_t * phi.sin(), cos_t]; + tangent_to_world(h_local, n) +} + +// --- Diffuse irradiance: cosine-weighted Riemann sum --- + +fn compute_irradiance(eq: &Equirect, out_w: u32, out_h: u32) -> Vec { + const N_PHI: u32 = 64; + const N_THETA: u32 = 32; + let dphi = 2.0 * PI / N_PHI as f32; + let dtheta = 0.5 * PI / N_THETA as f32; + + let mut out = Vec::with_capacity((out_w * out_h * 4) as usize); + for y in 0..out_h { + for x in 0..out_w { + let u = (x as f32 + 0.5) / out_w as f32; + let v = (y as f32 + 0.5) / out_h as f32; + let n = equirect_uv_to_dir(u, v); + + let mut irr = [0.0f32; 3]; + let mut wt = 0.0f32; + + for j in 0..N_THETA { + for i in 0..N_PHI { + let phi = (i as f32 + 0.5) * dphi; + let theta = (j as f32 + 0.5) * dtheta; + let sin_t = theta.sin(); + let cos_t = theta.cos(); + let local = [sin_t * phi.cos(), sin_t * phi.sin(), cos_t]; + let world = tangent_to_world(local, n); + let sample = eq.sample(world); + let w = cos_t * sin_t; + irr[0] += sample[0] * w; + irr[1] += sample[1] * w; + irr[2] += sample[2] * w; + wt += w; + } + } + + let s = PI / wt; + out.push(linear_to_srgb(irr[0] * s)); + out.push(linear_to_srgb(irr[1] * s)); + out.push(linear_to_srgb(irr[2] * s)); + out.push(255u8); + } + } + out +} + +// --- Specular prefilter: GGX importance sampling, HDR linear output --- + +fn compute_specular_prefilter(eq: &Equirect, out_w: u32, out_h: u32, roughness: f32) -> Vec { + const N_SAMPLES: u32 = 256; + + let mut out = Vec::with_capacity((out_w * out_h * 4) as usize); + for y in 0..out_h { + for x in 0..out_w { + let u = (x as f32 + 0.5) / out_w as f32; + let v = (y as f32 + 0.5) / out_h as f32; + let n = equirect_uv_to_dir(u, v); + + let mut col = [0.0f32; 3]; + let mut wt = 0.0f32; + + for i in 0..N_SAMPLES { + let xi = hammersley(i, N_SAMPLES); + let h = importance_sample_ggx(xi, roughness, n); + let vdoth = dot(n, h).max(0.0); + let l = normalize([ + 2.0 * vdoth * h[0] - n[0], + 2.0 * vdoth * h[1] - n[1], + 2.0 * vdoth * h[2] - n[2], + ]); + let ndotl = dot(n, l).max(0.0); + if ndotl > 0.0 { + let s = eq.sample(l); + col[0] += s[0] * ndotl; + col[1] += s[1] * ndotl; + col[2] += s[2] * ndotl; + wt += ndotl; + } + } + + let s = 1.0 / wt.max(1e-6); + out.push(col[0] * s); + out.push(col[1] * s); + out.push(col[2] * s); + out.push(1.0f32); + } + } + out +} + +// --- BRDF LUT: GGX split-sum preintegration (Karis / UE4) --- +// Stored linear (not sRGB): R = F0 scale, G = F0 bias. + +fn geometry_schlick_ggx_ibl(ndotv: f32, roughness: f32) -> f32 { + let k = roughness * roughness / 2.0; + ndotv / (ndotv * (1.0 - k) + k) +} + +fn geometry_smith_ibl(ndotv: f32, ndotl: f32, roughness: f32) -> f32 { + geometry_schlick_ggx_ibl(ndotv, roughness) * geometry_schlick_ggx_ibl(ndotl, roughness) +} + +fn compute_brdf_lut(w: u32, h: u32) -> Vec { + const N: u32 = 1024; + + let mut out = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + // x = NdotV (0=grazing, 1=normal incidence) + // y = roughness (0=smooth, 1=rough) — y=0 is top of texture + let ndotv = (x as f32 + 0.5) / w as f32; + let roughness = (y as f32 + 0.5) / h as f32; + let roughness = roughness.max(0.04); + + let v = [(1.0 - ndotv * ndotv).max(0.0).sqrt(), 0.0f32, ndotv]; + let n = [0.0f32, 0.0, 1.0]; + + let mut scale = 0.0f32; + let mut bias = 0.0f32; + + for i in 0..N { + let xi = hammersley(i, N); + let h_v = importance_sample_ggx(xi, roughness, n); + let vdoth = dot(v, h_v).max(0.0); + let l = normalize([ + 2.0 * vdoth * h_v[0] - v[0], + 2.0 * vdoth * h_v[1] - v[1], + 2.0 * vdoth * h_v[2] - v[2], + ]); + let ndotl = l[2].max(0.0); // n = [0,0,1] + let ndoth = h_v[2].max(0.0); + + if ndotl > 0.0 { + let g_vis = geometry_smith_ibl(ndotv, ndotl, roughness) * vdoth + / (ndoth * ndotv.max(0.001)); + let fc = (1.0 - vdoth).powi(5); + scale += (1.0 - fc) * g_vis; + bias += fc * g_vis; + } + } + + scale = (scale / N as f32).clamp(0.0, 1.0); + bias = (bias / N as f32).clamp(0.0, 1.0); + + // Linear bytes (not sRGB — BRDF coefficients are in linear space) + out.push((scale * 255.0 + 0.5) as u8); + out.push((bias * 255.0 + 0.5) as u8); + out.push(0u8); + out.push(255u8); + } + } + out +} + +// --- Color utilities --- + +fn linear_to_srgb(x: f32) -> u8 { + let c = x.clamp(0.0, 1.0); + let s = if c <= 0.003_130_8 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + }; + (s * 255.0 + 0.5) as u8 +} + +fn srgb_to_linear(x: f32) -> f32 { + if x <= 0.040_45 { + x / 12.92 + } else { + ((x + 0.055) / 1.055).powf(2.4) + } +} + +fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ] +} diff --git a/examples/pbr_helmet/src/game.rs b/examples/pbr_helmet/src/game.rs index e7858276..d431ee28 100644 --- a/examples/pbr_helmet/src/game.rs +++ b/examples/pbr_helmet/src/game.rs @@ -1,5 +1,7 @@ use pill_engine::{define_component, game::*}; +use crate::bake; + define_component!(OrbitCamera { yaw: f32, pitch: f32, @@ -37,6 +39,23 @@ fn orbit_camera_system(engine: &mut Engine) -> Result<()> { impl PillGame for Game { fn start(&self, engine: &mut Engine) -> Result<()> { + let (eq, eq_w, eq_h) = bake::generate(); + let (diffuse, specular_mips, brdf_lut) = bake::bake_all(&eq, eq_w, eq_h); + + let bg_h = engine.create_gpu_texture_f32("equirect", &eq, eq_w, eq_h)?; + let diff_h = engine.create_gpu_texture_f32("diffuse_ibl", &diffuse, 32, 16)?; + let spec_h = + engine.create_gpu_mipped_texture_f32("specular_ibl", &specular_mips, 128, 64)?; + let lut_h = engine.create_gpu_texture_f32("brdf_lut", &brdf_lut, 256, 256)?; + + { + let rs = engine.get_global_component_mut::()?; + rs.background = bg_h; + rs.ibl_diffuse = diff_h; + rs.ibl_specular = spec_h; + rs.ibl_brdf_lut = lut_h; + } + let scene = engine.create_scene("helmet")?; engine.set_active_scene(scene)?; diff --git a/examples/pbr_helmet/src/lib.rs b/examples/pbr_helmet/src/lib.rs index 3ab5b3fb..1ca7a9ea 100644 --- a/examples/pbr_helmet/src/lib.rs +++ b/examples/pbr_helmet/src/lib.rs @@ -1,5 +1,5 @@ +mod bake; mod game; -pub use game::Game; use pill_engine::game::create_game; create_game!(crate::game::Game {}, pill_engine::game::PillGame); diff --git a/examples/pill_tunel/Cargo.toml b/examples/pill_tunel/Cargo.toml index 4c6556a3..6666a300 100644 --- a/examples/pill_tunel/Cargo.toml +++ b/examples/pill_tunel/Cargo.toml @@ -10,4 +10,3 @@ crate-type = ["cdylib", "rlib"] [dependencies] pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal", "ui"] } egui = "0.32.1" -png = "0.17" diff --git a/examples/pill_tunel/src/game.rs b/examples/pill_tunel/src/game.rs index 5b90c400..2beb529f 100644 --- a/examples/pill_tunel/src/game.rs +++ b/examples/pill_tunel/src/game.rs @@ -1,22 +1,4 @@ use pill_engine::{define_component, game::*}; -use std::sync::{Arc, OnceLock}; - -static LOGO: OnceLock, u32, u32)>> = OnceLock::new(); - -fn load_logo() -> Arc<(Vec, u32, u32)> { - let bytes: &[u8] = include_bytes!("../res/textures/pill_logo.png"); - let decoder = png::Decoder::new(bytes); - let mut reader = decoder.read_info().unwrap(); - let mut buf = vec![0u8; reader.output_buffer_size()]; - let info = reader.next_frame(&mut buf).unwrap(); - let raw = &buf[..info.buffer_size()]; - let rgba: Vec = match info.color_type { - png::ColorType::Rgba => raw.to_vec(), - png::ColorType::Rgb => raw.chunks(3).flat_map(|c| [c[0], c[1], c[2], 255u8]).collect(), - _ => raw.to_vec(), - }; - Arc::new((rgba, info.width, info.height)) -} // --- Scene constants --------------------------------------------------------- @@ -201,38 +183,6 @@ fn camera_drift_system(engine: &mut Engine) -> Result<()> { Ok(()) } -fn logo_overlay_system(engine: &mut Engine) -> Result<()> { - let logo = LOGO.get_or_init(load_logo).clone(); - engine.set_game_overlay(move |ctx: &egui::Context| { - let tex_id = egui::Id::new("pill_logo_tex"); - let handle = ctx - .memory(|m| m.data.get_temp::(tex_id)) - .unwrap_or_else(|| { - let (rgba, w, h) = &*logo; - let img = egui::ColorImage::from_rgba_unmultiplied( - [*w as usize, *h as usize], - rgba, - ); - let t = ctx.load_texture("pill_logo", img, egui::TextureOptions::default()); - ctx.memory_mut(|m| m.data.insert_temp(tex_id, t.clone())); - t - }); - let display_w = 200.0f32; - let (_, src_w, src_h) = &*logo; - let display_h = display_w * (*src_h as f32 / *src_w as f32); - egui::Area::new("pill_logo_overlay".into()) - .anchor(egui::Align2::RIGHT_BOTTOM, [-16.0, -16.0]) - .interactable(false) - .show(ctx, |ui| { - ui.add(egui::Image::new(egui::load::SizedTexture::new( - handle.id(), - [display_w, display_h], - ))); - }); - })?; - Ok(()) -} - // --- Game -------------------------------------------------------------------- impl PillGame for WebGame { @@ -240,6 +190,15 @@ impl PillGame for WebGame { let active_scene = engine.create_scene("default")?; engine.set_active_scene(active_scene)?; + let zero_ibl = engine.create_gpu_texture_f32("zero_ibl", &[0.0_f32, 0.0, 0.0, 0.0], 1, 1)?; + { + let rs = engine.get_global_component_mut::()?; + rs.bg_color = [CLEAR_COLOR.0, CLEAR_COLOR.1, CLEAR_COLOR.2]; + rs.fog_density = FOG_DENSITY; + rs.ibl_diffuse = zero_ibl; + rs.ibl_specular = zero_ibl; + } + engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; engine.register_component::(active_scene)?; @@ -290,10 +249,6 @@ impl PillGame for WebGame { CameraComponent::builder() .enabled(true) .fov(CAMERA_FOV) - .clear_color(Color::new(CLEAR_COLOR.0, CLEAR_COLOR.1, CLEAR_COLOR.2)) - .fog_density(FOG_DENSITY) - // Fade distant pills toward the clear color so the tunnel wrap seam disappears. - .fog_color(Color::new(CLEAR_COLOR.0, CLEAR_COLOR.1, CLEAR_COLOR.2)) .build(), ) .build(); @@ -363,7 +318,6 @@ impl PillGame for WebGame { engine.add_system("pill_particle", pill_particle_system)?; engine.add_system("hero_pill", hero_pill_system)?; engine.add_system("camera_drift", camera_drift_system)?; - engine.add_system("logo_overlay", logo_overlay_system)?; Ok(()) } } diff --git a/examples/pill_tunel/src/lib.rs b/examples/pill_tunel/src/lib.rs index 1265e0a8..d6282808 100644 --- a/examples/pill_tunel/src/lib.rs +++ b/examples/pill_tunel/src/lib.rs @@ -1,5 +1,4 @@ mod game; -pub use game::WebGame; use pill_engine::game::create_game; create_game!(crate::game::WebGame {}, pill_engine::game::PillGame); From c4f86fd2647148dce4edc8de6e35c5b2beeb83b4 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 31 May 2026 03:05:26 +0200 Subject: [PATCH 18/31] feat: bg sub pass for better perf --- .../res/shaders/background_vertex.hlsl | 2 +- .../src/ecs/systems/rendering_system.rs | 8 +- .../src/graphics/pass_pbr_static.rs | 195 +++++++++++++++++- 3 files changed, 197 insertions(+), 8 deletions(-) diff --git a/engine/pill_engine/res/shaders/background_vertex.hlsl b/engine/pill_engine/res/shaders/background_vertex.hlsl index 8170c5c0..f9b8c7b9 100644 --- a/engine/pill_engine/res/shaders/background_vertex.hlsl +++ b/engine/pill_engine/res/shaders/background_vertex.hlsl @@ -11,7 +11,7 @@ VOut vs_main(uint vi : SV_VertexID) { float2(-1.0, 1.0) }; VOut o; - o.pos = float4(p[vi], 0.0, 1.0); + o.pos = float4(p[vi], 1.0, 1.0); // NDC z/w = 1.0 = far plane (LessEqual depth test) o.ndc = p[vi]; return o; } diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index 15f22f11..d824e5a7 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -5,7 +5,7 @@ use crate::{ RenderStateComponent, TransformComponent, }, engine::Engine, - graphics::{PassBackground, PassPBRStatic, PassTonemap, RenderQueueItem, RendererTargetDesc}, + graphics::{PassPBRStatic, PassTonemap, RenderQueueItem, RendererTargetDesc}, }; use pill_core::{warn, EngineError, LogContext, PillSlotMapKey, PillStyle, RendererError, Timer}; @@ -44,13 +44,9 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { })?; #[cfg_attr(not(feature = "ui"), allow(unused_mut))] let mut passes: Vec> = vec![ - Box::new( - PassBackground::new(hdr) - .with_equirect(bg) - .with_bg_color(bg_color), - ), Box::new( PassPBRStatic::new(Some(hdr)) + .with_background(bg, bg_color) .with_ibl(diff, spec, lut) .with_fog(bg_color, fog_density), ), diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_static.rs index 5c051f2b..4f0337dd 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_static.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_static.rs @@ -115,6 +115,28 @@ pub(crate) struct GroupCmd { pub(crate) batches: Vec, } +/// Camera uniform for the background sub-draw (view direction basis + fov). +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct BgCameraUbo { + right: [f32; 3], + tan_half_fov: f32, + up: [f32; 3], + aspect: f32, + fwd: [f32; 3], + _pad: f32, + bg_color: [f32; 3], + _pad2: f32, +} + +/// GPU-side state for the background sub-draw within the PBR render pass. +struct BgSubState { + pipeline: PipelineV2, + camera_buf: wgpu::Buffer, + bind_group: wgpu::BindGroup, + _sampler: wgpu::Sampler, +} + /// GPU-side pass state initialized in `Pass::init`, read every `Pass::draw`. struct PassPBRStaticState { camera_uniform: CameraUniform, @@ -124,6 +146,7 @@ struct PassPBRStaticState { ibl_sampler: wgpu::Sampler, per_draw_buffer: wgpu::Buffer, per_draw_bind_group: wgpu::BindGroup, + bg: Option, } /// Default PBR pass: full GGX shading with IBL, per-draw dynamic UBO ring. @@ -139,6 +162,8 @@ pub struct PassPBRStatic { ibl_brdf_lut: Option, fog_color: [f32; 3], fog_density: f32, + bg_equirect: Option, + bg_color: [f32; 3], state: Option, } @@ -158,6 +183,8 @@ impl PassPBRStatic { ibl_brdf_lut: None, fog_color: [1.0; 3], fog_density: 0.0, + bg_equirect: None, + bg_color: [1.0; 3], state: None, } } @@ -179,6 +206,12 @@ impl PassPBRStatic { self.fog_density = density; self } + + pub fn with_background(mut self, equirect: RendererTextureHandle, color: [f32; 3]) -> Self { + self.bg_equirect = Some(equirect); + self.bg_color = color; + self + } } /// Returns the initialized pass state; panics in debug if `init` was not called. @@ -538,6 +571,129 @@ impl Pass for PassPBRStatic { self.depth_texture = Some(renderer.create_depth_texture("pass_pbr_static_depth")?); + // Create background sub-draw state if an equirect handle was provided. + let bg = if let Some(equirect_h) = self.bg_equirect { + let bg_vs = include_str!("../../res/shaders/background_vertex.wgsl"); + let bg_fs = include_str!("../../res/shaders/background_fragment.wgsl"); + let bg_bind_groups = vec![vec![ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ]]; + let bg_pipeline = renderer.create_pipeline_v2(PipelineV2Desc { + label: Some("pass_pbr_static_bg_pipeline"), + vs: ShaderDesc { + source: bg_vs, + entry_func: "vs_main", + }, + ps: ShaderDesc { + source: bg_fs, + entry_func: "fs_main", + }, + vertex_buffers: &[], + bind_groups: bg_bind_groups, + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba16Float, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + // LessEqual + write disabled: fills only pixels where depth == 1.0 (far plane). + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: false, + }, + })?; + let equirect_view = renderer + .get_texture_view(equirect_h) + .expect("bg equirect handle invalid"); + let bg_sampler = renderer + .get_device() + .create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::Repeat, // equirect wraps in U + address_mode_v: wgpu::AddressMode::ClampToEdge, // poles clamp in V + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + let bg_camera_buf = renderer + .get_device() + .create_buffer(&wgpu::BufferDescriptor { + label: Some("pass_pbr_static_bg_camera"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bg_bind_group = { + let layout = &bg_pipeline.bind_group_layouts[0]; + renderer + .get_device() + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_pbr_static_bg_bind_group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: bg_camera_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&equirect_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&bg_sampler), + }, + ], + }) + }; + Some(BgSubState { + pipeline: bg_pipeline, + camera_buf: bg_camera_buf, + bind_group: bg_bind_group, + _sampler: bg_sampler, + }) + } else { + None + }; + self.state = Some(PassPBRStaticState { camera_uniform: CameraUniform::new(), camera_buffer, @@ -546,6 +702,7 @@ impl Pass for PassPBRStatic { ibl_sampler, per_draw_buffer, per_draw_bind_group, + bg, }); Ok(()) @@ -735,7 +892,7 @@ impl Pass for PassPBRStatic { view: color_view, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Load, + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], @@ -801,6 +958,42 @@ impl Pass for PassPBRStatic { } } + // Background sub-draw: within the same render pass, after opaque geometry. + // LessEqual depth test + write_enabled=false: fills only pixels where depth == 1.0 (far plane = no geometry). + if let Some(bg) = &state_ref.bg { + let eye = Vec3::new( + active_camera_transform.position.x, + active_camera_transform.position.y, + active_camera_transform.position.z, + ); + let fwd = if let Some(t) = active_camera_component.look_at { + (Vec3::new(t.x, t.y, t.z) - eye).normalize() + } else { + let roll = Mat3::from_rotation_z(active_camera_transform.rotation.z.to_radians()); + let yaw = Mat3::from_rotation_y(active_camera_transform.rotation.y.to_radians()); + let pitch = Mat3::from_rotation_x(active_camera_transform.rotation.x.to_radians()); + (yaw * pitch * roll) * Vec3::Z + }; + let right = fwd.cross(Vec3::Y).normalize(); + let up = right.cross(fwd); + let ubo = BgCameraUbo { + right: right.to_array(), + tan_half_fov: (active_camera_component.fov.to_radians() / 2.0).tan(), + up: up.to_array(), + aspect: active_camera_component.aspect.get_value(), + fwd: fwd.to_array(), + _pad: 0.0, + bg_color: self.bg_color, + _pad2: 0.0, + }; + renderer + .get_queue() + .write_buffer(&bg.camera_buf, 0, bytemuck::bytes_of(&ubo)); + render_pass.set_pipeline(&bg.pipeline.pipeline); + render_pass.set_bind_group(0, &bg.bind_group, &[]); + render_pass.draw(0..3, 0..1); + } + Ok(()) } } From b4e06fb449178dca6715e0b7d81cae36870d842d Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 31 May 2026 03:33:34 +0200 Subject: [PATCH 19/31] feat: hdr for helmet --- examples/pbr_helmet/Cargo.toml | 1 + examples/pbr_helmet/build.rs | 31 ++++++++++++++ examples/pbr_helmet/src/bake.rs | 73 ++++++--------------------------- examples/pbr_helmet/src/game.rs | 4 +- 4 files changed, 46 insertions(+), 63 deletions(-) diff --git a/examples/pbr_helmet/Cargo.toml b/examples/pbr_helmet/Cargo.toml index 12772bbb..ef6c9cd8 100644 --- a/examples/pbr_helmet/Cargo.toml +++ b/examples/pbr_helmet/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["cdylib", "rlib"] [build-dependencies] ureq = "2" pill_assets = { path = "../../engine/pill_assets" } +image = { version = "0.25", features = ["hdr"] } [dependencies] pill_engine = { path = "../../engine/pill_engine", features = ["game", "internal"] } diff --git a/examples/pbr_helmet/build.rs b/examples/pbr_helmet/build.rs index 20e0d34b..2fecca9b 100644 --- a/examples/pbr_helmet/build.rs +++ b/examples/pbr_helmet/build.rs @@ -1,3 +1,4 @@ +use std::io::Write; use std::path::PathBuf; use pill_assets::{GlbToCookedMesh, Rule}; @@ -21,4 +22,34 @@ fn main() { .build(&glb, &cooked_mesh) .expect("cook DamagedHelmet.glb"); } + + // Download studio HDR (low-frequency softbox, CC0) if missing. + let hdr_path = models.join("studio_small_08.hdr"); + if !hdr_path.exists() { + let url = "https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/studio_small_08_1k.hdr"; + let resp = ureq::get(url).call().expect("download studio_small_08 HDR"); + let mut f = std::fs::File::create(&hdr_path).unwrap(); + std::io::copy(&mut resp.into_reader(), &mut f).unwrap(); + } + + // Decode HDR → resize to 512×256 → write raw f32 RGBA binary to OUT_DIR for include_bytes!. + println!("cargo:rerun-if-changed=res/models/studio_small_08.hdr"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let f32bin = out_dir.join("equirect.f32bin"); + { + let img = image::open(&hdr_path).expect("open HDR"); + let src = img.to_rgb32f(); + let (out_w, out_h) = (512u32, 256u32); + let resized = + image::imageops::resize(&src, out_w, out_h, image::imageops::FilterType::Lanczos3); + let mut out = std::fs::File::create(&f32bin).unwrap(); + out.write_all(&out_w.to_le_bytes()).unwrap(); + out.write_all(&out_h.to_le_bytes()).unwrap(); + for px in resized.pixels() { + out.write_all(&px.0[0].to_le_bytes()).unwrap(); + out.write_all(&px.0[1].to_le_bytes()).unwrap(); + out.write_all(&px.0[2].to_le_bytes()).unwrap(); + out.write_all(&1.0f32.to_le_bytes()).unwrap(); + } + } } diff --git a/examples/pbr_helmet/src/bake.rs b/examples/pbr_helmet/src/bake.rs index e66b7c50..cfac5903 100644 --- a/examples/pbr_helmet/src/bake.rs +++ b/examples/pbr_helmet/src/bake.rs @@ -1,58 +1,17 @@ use std::f32::consts::PI; -// Gradient (linear, inverse-Reinhard compensated: c/(1+c) → target on screen). -const TOP: [f32; 3] = [0.90, 0.90, 1.20]; // Reinhard → [0.47, 0.47, 0.55] cool blue ceiling -const HORIZON: [f32; 3] = [0.43, 0.43, 0.46]; // Reinhard → [0.30, 0.30, 0.32] neutral mid -const BOTTOM: [f32; 3] = [0.040, 0.040, 0.050]; // Reinhard → [0.04, 0.04, 0.05] dark floor - -// Key light: warm tungsten, upper-left, ~40° above horizon. -const KEY_U: f32 = 0.10; -const KEY_V: f32 = 0.28; -const KEY_INTENSITY: [f32; 3] = [6.0, 4.8, 2.5]; // Reinhard → [0.86, 0.83, 0.71] warm gold - -// Rim light: cool blue, upper-right, ~45° above horizon. -const RIM_U: f32 = 0.60; -const RIM_V: f32 = 0.25; -const RIM_INTENSITY: [f32; 3] = [1.5, 2.5, 5.0]; // Reinhard → [0.60, 0.71, 0.83] cool blue - -const LIGHT_K: f32 = 8.0; // glare falloff exponent -const KEY_SKY: [f32; 3] = [0.010, 0.060, 0.200]; // 0.2 * vec3(0.05, 0.3, 1.) — cool blue haze away from key - -/// Generates the studio equirect panorama as Rgba32Float pixels (512×256 linear HDR). -pub fn generate() -> (Vec, u32, u32) { - let (w, h) = (512u32, 256u32); - let key_dir = equirect_uv_to_dir(KEY_U, KEY_V); - let rim_dir = equirect_uv_to_dir(RIM_U, RIM_V); - let mut out = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h { - for x in 0..w { - let u = (x as f32 + 0.5) / w as f32; - let v = (y as f32 + 0.5) / h as f32; - let pixel_dir = equirect_uv_to_dir(u, v); - let t = (pixel_dir[1] + 1.0) * 0.5; // pixel_dir[1] = sin(elev) ∈ [-1, 1] - let mut col = if t < 0.5 { - lerp3(BOTTOM, HORIZON, t * 2.0) - } else { - lerp3(HORIZON, TOP, (t - 0.5) * 2.0) - }; - // key: warm glare + cool sky haze (FF2023 sun glare formula) - let sun = dot(pixel_dir, key_dir).clamp(0.0, 1.0); - let sky = 1.0 - sun.powf(0.5); - col[0] += KEY_INTENSITY[0] * sun.powf(LIGHT_K) + KEY_SKY[0] * sky; - col[1] += KEY_INTENSITY[1] * sun.powf(LIGHT_K) + KEY_SKY[1] * sky; - col[2] += KEY_INTENSITY[2] * sun.powf(LIGHT_K) + KEY_SKY[2] * sky; - // rim: glare only - let w_rim = dot(pixel_dir, rim_dir).clamp(0.0, 1.0).powf(LIGHT_K); - col[0] += RIM_INTENSITY[0] * w_rim; - col[1] += RIM_INTENSITY[1] * w_rim; - col[2] += RIM_INTENSITY[2] * w_rim; - out.push(col[0]); - out.push(col[1]); - out.push(col[2]); - out.push(1.0); - } - } - (out, w, h) +/// Loads the studio equirect panorama embedded at compile time from the build script's OUT_DIR. +/// Returns RGBA f32 pixels + (width, height). +pub fn load_equirect() -> (Vec, u32, u32) { + let raw: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/equirect.f32bin")); + let w = u32::from_le_bytes(raw[0..4].try_into().unwrap()); + let h = u32::from_le_bytes(raw[4..8].try_into().unwrap()); + let pixel_bytes = &raw[8..]; + let pixels: Vec = pixel_bytes + .chunks_exact(4) + .map(|b| f32::from_le_bytes(b.try_into().unwrap())) + .collect(); + (pixels, w, h) } /// Bakes IBL maps from an Rgba32Float equirect panorama. @@ -371,11 +330,3 @@ fn srgb_to_linear(x: f32) -> f32 { ((x + 0.055) / 1.055).powf(2.4) } } - -fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { - [ - a[0] + (b[0] - a[0]) * t, - a[1] + (b[1] - a[1]) * t, - a[2] + (b[2] - a[2]) * t, - ] -} diff --git a/examples/pbr_helmet/src/game.rs b/examples/pbr_helmet/src/game.rs index d431ee28..ff56e887 100644 --- a/examples/pbr_helmet/src/game.rs +++ b/examples/pbr_helmet/src/game.rs @@ -39,7 +39,7 @@ fn orbit_camera_system(engine: &mut Engine) -> Result<()> { impl PillGame for Game { fn start(&self, engine: &mut Engine) -> Result<()> { - let (eq, eq_w, eq_h) = bake::generate(); + let (eq, eq_w, eq_h) = bake::load_equirect(); let (diffuse, specular_mips, brdf_lut) = bake::bake_all(&eq, eq_w, eq_h); let bg_h = engine.create_gpu_texture_f32("equirect", &eq, eq_w, eq_h)?; @@ -136,7 +136,7 @@ impl PillGame for Game { .with_component(OrbitCamera { yaw: 0.0, pitch: 0.0, - radius: 3.0, + radius: 2.5, }) .build(); From 124ded851f710f979ebaaae4f96258703ca6cd68 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 31 May 2026 04:28:51 +0200 Subject: [PATCH 20/31] perf: pill_tunel: 3 -> 12 FPS --- engine/Cargo.toml | 2 +- engine/pill_engine/Cargo.toml | 2 +- .../res/shaders/pbr_static_vertex.hlsl | 14 +++-- .../src/ecs/components/egui_component.rs | 40 ++++++++++-- .../src/ecs/components/transform_component.rs | 45 +++---------- .../src/ecs/systems/rendering_system.rs | 6 +- .../src/graphics/pass_pbr_static.rs | 63 +++++++------------ engine/pill_engine/src/renderer/state.rs | 1 + examples/pill_tunel/res/config.ini | 16 ++--- examples/pill_tunel/src/game.rs | 5 +- 10 files changed, 96 insertions(+), 98 deletions(-) diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 5e95497d..99836cbc 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,7 +8,7 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "/Users/mk/dev/demo/Pill-Engine/examples/pbr_helmet", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) + "/Users/mk/dev/demo/Pill-Engine/examples/pill_tunel", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at diff --git a/engine/pill_engine/Cargo.toml b/engine/pill_engine/Cargo.toml index 3fdef1f1..fa2515f7 100644 --- a/engine/pill_engine/Cargo.toml +++ b/engine/pill_engine/Cargo.toml @@ -33,7 +33,7 @@ web-time = "1.1" # Optional deps activated by feature flags egui = { version = "0.32.1", optional = true } egui-wgpu = { version = "0.32.1", features = ["winit"], optional = true } -egui-winit = { version = "0.32.1", default-features = false, optional = true } +egui-winit = { version = "0.32.1", default-features = false, features = ["clipboard"], optional = true } # Native-only dependencies (not available on WASM) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl b/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl index cfc7043e..40744631 100644 --- a/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl +++ b/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl @@ -1,6 +1,7 @@ // PBR static vertex shader. Edit here — pill_assets regenerates the .wgsl. // Reads pos/uv/normal from vertex buffer (locations 0, 4, 5); MVP and model -// matrix come from the per-draw dynamic UBO at group 3 binding 0. +// matrix come from the per-draw storage array at group 3 binding 0, +// indexed by SV_InstanceID so one instanced draw covers a full material batch. struct Camera { float4 position; @@ -14,7 +15,7 @@ struct PerDraw { column_major float4x4 mvp; column_major float4x4 model; }; -[[vk::binding(0, 3)]] ConstantBuffer UPerDraw; +[[vk::binding(0, 3)]] StructuredBuffer UPerDrawArray; struct VSOut { [[vk::location(0)]] float2 uv : TEXCOORD0; @@ -30,12 +31,13 @@ struct VSIn { }; [shader("vertex")] -VSOut vs_main(VSIn input) { - float4 worldPos4 = mul(UPerDraw.model, float4(input.pos, 1.0)); +VSOut vs_main(VSIn input, uint instance_id : SV_InstanceID) { + PerDraw per_draw = UPerDrawArray[instance_id]; + float4 worldPos4 = mul(per_draw.model, float4(input.pos, 1.0)); // TODO: Use a proper normal matrix (inverse-transpose of model) if non-uniform scaling is used. - float3 n = normalize(mul(UPerDraw.model, float4(input.normal, 0.0)).xyz); + float3 n = normalize(mul(per_draw.model, float4(input.normal, 0.0)).xyz); VSOut output; - output.sv_position = mul(UPerDraw.mvp, float4(input.pos, 1.0)); + output.sv_position = mul(per_draw.mvp, float4(input.pos, 1.0)); output.uv = input.uv; output.worldPos = worldPos4.xyz; output.worldNormal = n; diff --git a/engine/pill_engine/src/ecs/components/egui_component.rs b/engine/pill_engine/src/ecs/components/egui_component.rs index c0649251..3043b857 100644 --- a/engine/pill_engine/src/ecs/components/egui_component.rs +++ b/engine/pill_engine/src/ecs/components/egui_component.rs @@ -15,6 +15,33 @@ use pill_core::{PillTypeMapKey, Timer, TimerRecord}; use pill_core::{ErrorContext, Result}; +fn format_record(buf: &mut String, record: &TimerRecord, depth: usize) { + buf.push_str(&format!( + "{}{:.3} ms {}\n", + " ".repeat(depth), + record.duration, + record.label + )); + for sub in &record.subrecords { + format_record(buf, sub, depth + 1); + } +} + +fn format_timers(system_timers: &[(UpdatePhase, Vec<(String, Timer)>)]) -> String { + let mut buf = String::new(); + for (phase, timers) in system_timers { + let total: f32 = timers.iter().map(|(_, t)| t.total_duration()).sum(); + buf.push_str(&format!("Phase: {} {:.3} ms\n", phase, total)); + for (name, timer) in timers { + buf.push_str(&format!(" {}\n", name)); + for record in &timer.records { + format_record(&mut buf, record, 2); + } + } + } + buf +} + pub struct EguiComponent { pub egui_client: Arc, collapsing_state: HashMap, @@ -113,10 +140,15 @@ impl EguiComponent { ))); ui.add(egui::Label::new(format!("Entities: {}", entity_count))); ui.separator(); - ui.add(egui::Label::new(format!( - "Systems: {}, Total delta time: {:.3} ms", - system_count, total_systems_delta_time - ))); + ui.horizontal(|ui| { + ui.add(egui::Label::new(format!( + "Systems: {}, Total delta time: {:.3} ms", + system_count, total_systems_delta_time + ))); + if ui.small_button("[C]").clicked() { + ui.ctx().copy_text(format_timers(&system_timers)); + } + }); let mut phase_state = HashMap::new(); for (update_phase, system_timers) in system_timers.iter() { let phase_duration = system_timers diff --git a/engine/pill_engine/src/ecs/components/transform_component.rs b/engine/pill_engine/src/ecs/components/transform_component.rs index 20381387..20316938 100644 --- a/engine/pill_engine/src/ecs/components/transform_component.rs +++ b/engine/pill_engine/src/ecs/components/transform_component.rs @@ -1,5 +1,4 @@ use crate::ecs::{Component, ComponentStorage}; -use glam::Quat; use pill_core::{Direction, Matrix3f, Matrix3fA, Matrix4f, PillTypeMapKey, Vector3f}; use serde::{Deserialize, Serialize}; @@ -49,7 +48,9 @@ impl TransformComponentBuilder { // --- Transform Component --- -// NOTE: Setting position/rotation/scale directly is not possible since we need to update matrices after each change +// 36 bytes: position + rotation + scale only. +// model_matrix / normal_matrix removed — the renderer sends pos/rot/scale as +// vertex instance data and the GPU shader builds the matrix. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[readonly::make] pub struct TransformComponent { @@ -59,14 +60,6 @@ pub struct TransformComponent { pub rotation: Vector3f, #[readonly] pub scale: Vector3f, - - model_matrix: Matrix4f, - normal_matrix: Matrix3fA, - - // There may me multiple updates of the position/rotation/scale in the single frame. - // Not to calculate matrices multiple times, we will update them only once per frame - // The update happens in the rendering system - pub matrix_update_required: bool, } impl TransformComponent { @@ -79,9 +72,6 @@ impl TransformComponent { position: Vector3f::ZERO, rotation: Vector3f::ZERO, scale: Vector3f::new(1.0, 1.0, 1.0), - model_matrix: Matrix4f::IDENTITY, - normal_matrix: Matrix3fA::IDENTITY, - matrix_update_required: true, } } @@ -89,7 +79,6 @@ impl TransformComponent { pub fn set_position(&mut self, position: Vector3f) { self.position = position; - self.matrix_update_required = true; } pub fn translate(&mut self, delta: f32, direction: Direction) { @@ -107,19 +96,16 @@ impl TransformComponent { Direction::WorldUp => self.position.y += delta, Direction::WorldDown => self.position.y -= delta, } - self.matrix_update_required = true; } pub fn translate_world(&mut self, delta: Vector3f) { self.position += delta; - self.matrix_update_required = true; } pub fn translate_local(&mut self, delta: Vector3f) { self.position += self.get_forward_direction() * delta.z + self.get_right_direction() * delta.x + self.get_up_direction() * delta.y; - self.matrix_update_required = true; } // --- Directions --- @@ -159,41 +145,30 @@ impl TransformComponent { pub fn set_rotation(&mut self, rotation: Vector3f) { self.rotation = rotation; - self.matrix_update_required = true; } // TODO: Implement quaternion rotation pub fn rotate_around_axis(&mut self, angle: f32, axis: Vector3f) { self.rotation += angle * axis; - self.matrix_update_required = true; } // --- Scale --- pub fn set_scale(&mut self, scale: Vector3f) { self.scale = scale; - self.matrix_update_required = true; } } -pub fn update_transform_matrices(transform_component: &mut TransformComponent) { - let rotation = Quat::from_rotation_y(transform_component.rotation.y.to_radians()) - * Quat::from_rotation_x(transform_component.rotation.x.to_radians()) - * Quat::from_rotation_z(transform_component.rotation.z.to_radians()); - transform_component.model_matrix = Matrix4f::from_scale_rotation_translation( - transform_component.scale, - rotation, - transform_component.position, - ); - transform_component.normal_matrix = Matrix3fA::from_quat(rotation); -} +// Kept for backward compatibility — no longer needed since the renderer +// computes matrices on the GPU from pos/rot/scale instance data. +pub fn update_transform_matrices(_transform_component: &mut TransformComponent) {} -pub fn get_model_matrix(transform_component: &TransformComponent) -> Matrix4f { - transform_component.model_matrix +pub fn get_model_matrix(_transform_component: &TransformComponent) -> Matrix4f { + Matrix4f::IDENTITY } -pub fn get_normal_matrix(transform_component: &TransformComponent) -> Matrix3fA { - transform_component.normal_matrix +pub fn get_normal_matrix(_transform_component: &TransformComponent) -> Matrix3fA { + Matrix3fA::IDENTITY } impl PillTypeMapKey for TransformComponent { diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index d824e5a7..8b3b4a45 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -175,7 +175,8 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { ); match render_result { Ok(_) => { - timer.end_context()?; + timer.end_context()?; // end "Render" + timer.end_context()?; // end "rendering_system update" engine.system_manager.update_system_timer( RENDERING_SYSTEM.name, RENDERING_SYSTEM.update_phase, @@ -185,7 +186,8 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { } Err(error) => match error.downcast_ref::() { Some(RendererError::SurfaceLost) => { - timer.end_context()?; + timer.end_context()?; // end "Render" + timer.end_context()?; // end "rendering_system update" engine.system_manager.update_system_timer( RENDERING_SYSTEM.name, RENDERING_SYSTEM.update_phase, diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_static.rs index 4f0337dd..3393fd6e 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_static.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_static.rs @@ -81,12 +81,9 @@ pub(crate) struct PerDrawStd140 { unsafe impl bytemuck::Zeroable for PerDrawStd140 {} unsafe impl bytemuck::Pod for PerDrawStd140 {} -// Preallocated dynamic-UBO ring: 256-byte stride to satisfy min UBO offset alignment. +// Per-draw storage buffer: tightly packed, indexed by instance_index in the shader. const MAX_EXPECTED_PER_DRAW_INSTANCES: usize = 100_000; -const UNIFORM_OFFSET_ALIGNMENT: usize = 256; -const PER_DRAW_STRIDE_BYTES: usize = std::mem::size_of::() - .div_ceil(UNIFORM_OFFSET_ALIGNMENT) - * UNIFORM_OFFSET_ALIGNMENT; +const PER_DRAW_STRIDE_BYTES: usize = std::mem::size_of::(); pub const MATERIAL_BIND_GROUP_GLOBALS: usize = 0; pub const MATERIAL_BIND_GROUP_TEXTURES: usize = 1; @@ -372,17 +369,14 @@ impl Pass for PassPBRStatic { }, count: None, }], - // 3: per-draw dynamic UBO (MVP + model) + // 3: per-draw storage array (MVP + model), indexed by instance_index vec![wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: true, - min_binding_size: Some( - std::num::NonZeroU64::new(std::mem::size_of::() as u64) - .unwrap(), - ), + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, }, count: None, }], @@ -439,9 +433,9 @@ impl Pass for PassPBRStatic { }; let per_draw_buffer = renderer.create_buffer(BufferDesc { - label: Some("pass_pbr_static_per_draw_ring"), + label: Some("pass_pbr_static_per_draw"), byte_size: (PER_DRAW_STRIDE_BYTES * MAX_EXPECTED_PER_DRAW_INSTANCES) as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, })?; let per_draw_bind_group = { let layout = &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_PERDRAW]; @@ -451,14 +445,7 @@ impl Pass for PassPBRStatic { layout, entries: &[wgpu::BindGroupEntry { binding: 0, - resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { - buffer: &per_draw_buffer, - offset: 0, - size: Some( - std::num::NonZeroU64::new(std::mem::size_of::() as u64) - .unwrap(), - ), - }), + resource: per_draw_buffer.as_entire_binding(), }], }) }; @@ -854,17 +841,16 @@ impl Pass for PassPBRStatic { MAX_EXPECTED_PER_DRAW_INSTANCES ); } + // Tightly pack all per-draw entries; no alignment padding needed for storage buffers. self.staging_buffer.clear(); - let mut next_offset_u32: u32 = 0; + let mut next_instance: u32 = 0; for group in self.groups_buffer.iter_mut() { for batch in group.batches.iter_mut() { - batch.base_offset_u32 = next_offset_u32; + batch.base_offset_u32 = next_instance; // first instance index for this batch for per_draw in &batch.instances { self.staging_buffer .extend_from_slice(bytemuck::bytes_of(per_draw)); - let pad = (PER_DRAW_STRIDE_BYTES) - std::mem::size_of::(); - self.staging_buffer.extend(std::iter::repeat_n(0u8, pad)); - next_offset_u32 = next_offset_u32.wrapping_add(PER_DRAW_STRIDE_BYTES as u32); + next_instance = next_instance.wrapping_add(1); } } } @@ -908,12 +894,18 @@ impl Pass for PassPBRStatic { occlusion_query_set: None, }); - // Record draw commands: bind pipeline → globals → material → per-draw per instance. + // Record draw commands: pipeline → per-draw storage (once) → per group: globals + material → per batch: instanced draw. let state_ref = self .state .as_ref() .expect("PassPBRStatic: state not initialized — call init() before draw()"); render_pass.set_pipeline(&state_ref.pipeline.pipeline); + // Bind per-draw storage array once — instance_index selects the right entry per vertex. + render_pass.set_bind_group( + MATERIAL_BIND_GROUP_PERDRAW as u32, + &state_ref.per_draw_bind_group, + &[], + ); for group in &self.groups_buffer { render_pass.set_bind_group( MATERIAL_BIND_GROUP_GLOBALS as u32, @@ -944,17 +936,10 @@ impl Pass for PassPBRStatic { render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); render_pass .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); - for instance_index in 0..batch.instances.len() { - let offset = batch - .base_offset_u32 - .wrapping_add((instance_index as u32) * (PER_DRAW_STRIDE_BYTES as u32)); - render_pass.set_bind_group( - MATERIAL_BIND_GROUP_PERDRAW as u32, - &state_ref.per_draw_bind_group, - &[offset], - ); - render_pass.draw_indexed(0..mesh.index_count, 0, 0..1); - } + let first = batch.base_offset_u32; + let count = batch.instances.len() as u32; + // One instanced draw per batch: instance_index in [first, first+count) selects per-draw data. + render_pass.draw_indexed(0..mesh.index_count, 0, first..(first + count)); } } diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs index d0a4a217..35f66c23 100644 --- a/engine/pill_engine/src/renderer/state.rs +++ b/engine/pill_engine/src/renderer/state.rs @@ -162,6 +162,7 @@ impl PillRenderer for Renderer { let mut passes = std::mem::take(&mut self.state.passes); for pass in &mut passes { + timer.record(pass.get_label()); pass.draw(&mut encoder, self, &frame, &swapchain_view, &world)?; } self.state.passes = passes; diff --git a/examples/pill_tunel/res/config.ini b/examples/pill_tunel/res/config.ini index 2a2be121..0dc46ade 100644 --- a/examples/pill_tunel/res/config.ini +++ b/examples/pill_tunel/res/config.ini @@ -1,24 +1,24 @@ # GAME -TITLE=Pill-Demo +TITLE=Pill-Tunel # WINDOW -WINDOW_TITLE=Pill Engine - Pill-Demo +WINDOW_TITLE=Pill Engine - 60k Pills Benchmark WINDOW_WIDTH=1920 WINDOW_HEIGHT=1200 WINDOW_FULLSCREEN=false # ENGINE GENERAL -MAX_ENTITIES=500000 +MAX_ENTITIES=65000 MAX_CAMERAS=10 MAX_CONCURRENT_2D_SOUNDS=10 MAX_CONCURRENT_3D_SOUNDS=10 # ENGINE RESOURCES -MAX_TEXTURES=1000 -MAX_MATERIALS=20000 -MAX_MESHES=1000 -MAX_MODELS=100 -MAX_SOUNDS=50 +MAX_TEXTURES=10 +MAX_MATERIALS=10 +MAX_MESHES=10 +MAX_MODELS=10 +MAX_SOUNDS=10 # OTHER LOG_LEVEL=Info diff --git a/examples/pill_tunel/src/game.rs b/examples/pill_tunel/src/game.rs index 2beb529f..b339de84 100644 --- a/examples/pill_tunel/src/game.rs +++ b/examples/pill_tunel/src/game.rs @@ -19,7 +19,7 @@ const CAMERA_DRIFT_AMPLITUDE: (f32, f32) = (0.35, 0.25); // Particle tunnel — N pills streaming forward through a donut-shaped emitter. // Camera looks down +Z; pills start spread uniformly along Z so steady-state // density is reached on frame 1. Each pill wraps back to far when it crosses near. -const PILL_COUNT: usize = 1440; +const PILL_COUNT: usize = 60000; const EMITTER_RADIUS_MIN: f32 = 6.0; const EMITTER_RADIUS_MAX: f32 = 13.0; const TUNNEL_NEAR_Z: f32 = -3.0; @@ -190,7 +190,8 @@ impl PillGame for WebGame { let active_scene = engine.create_scene("default")?; engine.set_active_scene(active_scene)?; - let zero_ibl = engine.create_gpu_texture_f32("zero_ibl", &[0.0_f32, 0.0, 0.0, 0.0], 1, 1)?; + let zero_ibl = + engine.create_gpu_texture_f32("zero_ibl", &[0.0_f32, 0.0, 0.0, 0.0], 1, 1)?; { let rs = engine.get_global_component_mut::()?; rs.bg_color = [CLEAR_COLOR.0, CLEAR_COLOR.1, CLEAR_COLOR.2]; From 98e9e15669e167ca02a43535324495e90b32f7aa Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 31 May 2026 06:14:42 +0200 Subject: [PATCH 21/31] perf: pill_tunel: 12 -> 30 FPS --- .../res/shaders/pbr_static_vertex.hlsl | 33 ++-- .../src/ecs/components/transform_component.rs | 6 +- .../src/ecs/systems/rendering_system.rs | 19 +- .../src/graphics/pass_pbr_static.rs | 178 ++++++------------ .../pill_engine/src/graphics/render_queue.rs | 6 + examples/pill_tunel/src/game.rs | 15 +- 6 files changed, 113 insertions(+), 144 deletions(-) diff --git a/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl b/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl index 40744631..9632f8da 100644 --- a/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl +++ b/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl @@ -1,7 +1,8 @@ // PBR static vertex shader. Edit here — pill_assets regenerates the .wgsl. -// Reads pos/uv/normal from vertex buffer (locations 0, 4, 5); MVP and model -// matrix come from the per-draw storage array at group 3 binding 0, -// indexed by SV_InstanceID so one instanced draw covers a full material batch. +// Per-draw storage holds raw position/rotation(radians)/scale; the GPU builds the +// model matrix here (hardware sin/cos), so the CPU does ZERO per-entity trig and +// uploads 48 B instead of a 64 B matrix. MVP = viewProjection·model, also on GPU. +// [Aaltonen "HypeHype" GDC 2023; Lottes @NOTimothyLottes 2025-01-23] struct Camera { float4 position; @@ -12,8 +13,9 @@ struct Camera { [[vk::binding(0, 0)]] ConstantBuffer UCamera; struct PerDraw { - column_major float4x4 mvp; - column_major float4x4 model; + float4 position; // xyz + float4 rotation; // xyz, radians + float4 scale; // xyz }; [[vk::binding(0, 3)]] StructuredBuffer UPerDrawArray; @@ -30,16 +32,25 @@ struct VSIn { [[vk::location(5)]] float3 normal; }; +// Row-major float3x3 constructors; mul(R, v) applies standard right-handed rotation. +float3x3 rot_x(float a) { float c = cos(a), s = sin(a); return float3x3(1, 0, 0, 0, c, -s, 0, s, c); } +float3x3 rot_y(float a) { float c = cos(a), s = sin(a); return float3x3(c, 0, s, 0, 1, 0, -s, 0, c); } +float3x3 rot_z(float a) { float c = cos(a), s = sin(a); return float3x3(c, -s, 0, s, c, 0, 0, 0, 1); } + [shader("vertex")] VSOut vs_main(VSIn input, uint instance_id : SV_InstanceID) { PerDraw per_draw = UPerDrawArray[instance_id]; - float4 worldPos4 = mul(per_draw.model, float4(input.pos, 1.0)); - // TODO: Use a proper normal matrix (inverse-transpose of model) if non-uniform scaling is used. - float3 n = normalize(mul(per_draw.model, float4(input.normal, 0.0)).xyz); + + // model = T * (Rx*Ry*Rz) * S — matches glam from_scale_rotation_translation(Quat::x*y*z). + float3x3 rotation = mul(rot_x(per_draw.rotation.x), mul(rot_y(per_draw.rotation.y), rot_z(per_draw.rotation.z))); + float3 scaled = input.pos * per_draw.scale.xyz; + float3 worldPos = per_draw.position.xyz + mul(rotation, scaled); + float4 worldPos4 = float4(worldPos, 1.0); + VSOut output; - output.sv_position = mul(per_draw.mvp, float4(input.pos, 1.0)); + output.sv_position = mul(UCamera.viewProjection, worldPos4); output.uv = input.uv; - output.worldPos = worldPos4.xyz; - output.worldNormal = n; + output.worldPos = worldPos; + output.worldNormal = normalize(mul(rotation, input.normal)); return output; } diff --git a/engine/pill_engine/src/ecs/components/transform_component.rs b/engine/pill_engine/src/ecs/components/transform_component.rs index 20316938..d2deb41e 100644 --- a/engine/pill_engine/src/ecs/components/transform_component.rs +++ b/engine/pill_engine/src/ecs/components/transform_component.rs @@ -48,9 +48,9 @@ impl TransformComponentBuilder { // --- Transform Component --- -// 36 bytes: position + rotation + scale only. -// model_matrix / normal_matrix removed — the renderer sends pos/rot/scale as -// vertex instance data and the GPU shader builds the matrix. +// 36 bytes hot (pos+rot+scale only); model_matrix/normal_matrix were dead weight — GPU +// computes matrices from pos/rot/scale sent as instance data. [Acton "Data-Oriented Design +// and C++" CppCon 2014 — hot/cold struct split reduces cache lines per entity 152→36 B] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[readonly::make] pub struct TransformComponent { diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index 8b3b4a45..431ee9a2 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -107,18 +107,25 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { let mut _matrix_calculation_duration: f32 = 0.0; let mut add_to_render_queue_duration: f32 = 0.0; - for (entity_handle, _transform_component, pbr_renderable_component) in engine + // GPU fast-path: copy raw pos/rot/scale during the cache-warm sequential scan — NO trig, + // no matrix build. The vertex shader composes model = T·R·S on the GPU. [Aaltonen GDC 2023] + for (entity_handle, transform, pbr_renderable_component) in engine .scene_manager .get_two_component_iterator_mut::( active_scene_handle, )? { let add_to_render_queue_start_time = Instant::now(); if let Some(render_queue_key) = pbr_renderable_component.render_queue_key { - let render_queue_item = RenderQueueItem { + let p = transform.position; + let r = transform.rotation; + let s = transform.scale; + engine.render_queue.push(RenderQueueItem { key: render_queue_key, entity_index: entity_handle.data().index, - }; - engine.render_queue.push(render_queue_item); + position: [p.x, p.y, p.z, 0.0], + rotation: [r.x, r.y, r.z, 0.0], + scale: [s.x, s.y, s.z, 0.0], + }); } else { warn!(LogContext::Rendering => "Invalid render queue key"); continue; @@ -138,7 +145,9 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { timer.record("Sort render queue"); - engine.render_queue.sort(); + // pdqsort by key — measured faster here than hand-rolled radix/counting sort, because the + // scene has few distinct keys and the builtin handles many-equal-elements partitioning well. + engine.render_queue.sort_unstable_by_key(|item| item.key); timer.record("Get component storages"); diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_static.rs index 3393fd6e..9b94993e 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_static.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_static.rs @@ -7,7 +7,7 @@ use crate::{ }, renderer::resources::{RendererMaterial, RendererMesh}, }; -use glam::{Mat3, Mat4, Quat, Vec3}; +use glam::{Mat3, Mat4, Vec3}; use pill_core::{PillSlotMapKey, Result}; use std::num::NonZeroU32; @@ -71,17 +71,20 @@ impl CameraUniform { } } -/// Per-draw uniform: MVP and model matrix packed into a std140-aligned dynamic UBO. +/// Per-draw storage entry: raw pos/rot/scale (48 B). GPU builds model = T·R·S and MVP. #[repr(C)] #[derive(Copy, Clone)] pub(crate) struct PerDrawStd140 { - mvp: [[f32; 4]; 4], - model: [[f32; 4]; 4], + position: [f32; 4], // xyz + rotation: [f32; 4], // xyz, radians + scale: [f32; 4], // xyz } unsafe impl bytemuck::Zeroable for PerDrawStd140 {} unsafe impl bytemuck::Pod for PerDrawStd140 {} -// Per-draw storage buffer: tightly packed, indexed by instance_index in the shader. +// Storage buffer (StructuredBuffer / var) indexed by SV_InstanceID replaces +// dynamic-offset UBO: eliminates Metal's 256-B alignment tax and 120k per-instance API calls. +// [Lottes @NOTimothyLottes 2025-01-23; Aaltonen "Modern Mobile Rendering at HypeHype" GDC 2023] const MAX_EXPECTED_PER_DRAW_INSTANCES: usize = 100_000; const PER_DRAW_STRIDE_BYTES: usize = std::mem::size_of::(); @@ -90,20 +93,11 @@ pub const MATERIAL_BIND_GROUP_TEXTURES: usize = 1; pub const MATERIAL_BIND_GROUP_PARAMS: usize = 2; pub const MATERIAL_BIND_GROUP_PERDRAW: usize = 3; -/// Visible entity ready for batching into a draw call. -pub(crate) struct VisiblePreDraw { - pub(crate) material_handle: RendererMaterialHandle, - pub(crate) mesh_handle: RendererMeshHandle, - pub(crate) entity_index: u32, - pub(crate) mvp: [[f32; 4]; 4], - pub(crate) model: [[f32; 4]; 4], -} - -/// Mesh batch within a material group: same mesh handle, consecutive per-draw entries in the ring. +/// Mesh batch within a material group: same mesh, a contiguous instance range in the storage array. pub(crate) struct MeshBatch { pub(crate) mesh_handle: RendererMeshHandle, - pub(crate) instances: Vec, - pub(crate) base_offset_u32: u32, + pub(crate) base_offset_u32: u32, // first instance index + pub(crate) instance_count: u32, } /// Draw group: one pipeline + one material, containing one or more mesh batches. @@ -146,12 +140,10 @@ struct PassPBRStaticState { bg: Option, } -/// Default PBR pass: full GGX shading with IBL, per-draw dynamic UBO ring. -/// No MeshDrawer — each entity is drawn with a direct indexed draw call. +/// Default PBR pass: GGX shading with IBL, one instanced draw per material/mesh batch. pub struct PassPBRStatic { color_target: Option, depth_texture: Option, - visible_pre_draw_buffer: Vec, groups_buffer: Vec, staging_buffer: Vec, ibl_diffuse: Option, @@ -170,7 +162,6 @@ impl PassPBRStatic { Self { color_target, depth_texture: None, - visible_pre_draw_buffer: Vec::with_capacity(MAX_EXPECTED_PER_DRAW_INSTANCES), groups_buffer: Vec::with_capacity(2000), staging_buffer: Vec::with_capacity( MAX_EXPECTED_PER_DRAW_INSTANCES * PER_DRAW_STRIDE_BYTES, @@ -369,7 +360,7 @@ impl Pass for PassPBRStatic { }, count: None, }], - // 3: per-draw storage array (MVP + model), indexed by instance_index + // 3: per-draw storage array — no dynamic offset, no 256-B Metal alignment (Apple Metal Spec §4.1.1). vec![wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX, @@ -720,10 +711,11 @@ impl Pass for PassPBRStatic { .as_ref() .unwrap(); - // Update camera uniform and write to GPU buffer. + // Update camera uniform and write to GPU buffer. MVP = viewProjection·model is computed + // in the vertex shader, so the CPU never multiplies per-entity matrices. let fog_color = self.fog_color; let fog_density = self.fog_density; - let vp_mat: Mat4 = { + { let state = get_state(self); state .camera_uniform @@ -735,12 +727,25 @@ impl Pass for PassPBRStatic { 0, bytemuck::bytes_of(&state.camera_uniform), ); - Mat4::from_cols_array_2d(&state.camera_uniform.view_projection_matrix) - }; + } - // Build visible set from the render queue. - self.visible_pre_draw_buffer.clear(); + // Single pass over the pre-sorted render queue (key order = shader→material→mesh, so + // same material/mesh runs are contiguous): build groups/batches AND stream model + // matrices into the staging buffer in one sweep. No visible set, no MVP on CPU, no + // per-batch search. [Aaltonen "HypeHype" GDC 2023 — batch-upload once per pass] + self.groups_buffer.clear(); + self.staging_buffer.clear(); + let mut next_instance: u32 = 0; + let mut last_material: Option = None; + let mut last_mesh: Option = None; for render_queue_item in world.render_queue.iter() { + if next_instance as usize >= MAX_EXPECTED_PER_DRAW_INSTANCES { + log::error!( + "PassPBRStatic: per-draw capacity exceeded (capacity={})", + MAX_EXPECTED_PER_DRAW_INSTANCES + ); + break; + } let key_fields = decompose_render_queue_key(render_queue_item.key); let mesh_handle = RendererMeshHandle::new( key_fields.mesh_index.into(), @@ -751,108 +756,33 @@ impl Pass for PassPBRStatic { NonZeroU32::new(key_fields.material_version.into()).unwrap(), ); - let entity_index = render_queue_item.entity_index as usize; - let transform = world - .transform_components - .data - .get(entity_index) - .unwrap() - .as_ref() - .unwrap(); - // Rotation stored in radians (game convention); match old shader's X*Y*Z quaternion order. - let rotation = Quat::from_rotation_x(transform.rotation.x) - * Quat::from_rotation_y(transform.rotation.y) - * Quat::from_rotation_z(transform.rotation.z); - let model_mat: Mat4 = Mat4::from_scale_rotation_translation( - transform.scale, - rotation, - transform.position, - ); - let mvp: [[f32; 4]; 4] = (vp_mat * model_mat).to_cols_array_2d(); - let model: [[f32; 4]; 4] = model_mat.to_cols_array_2d(); - - self.visible_pre_draw_buffer.push(VisiblePreDraw { - material_handle, - mesh_handle, - entity_index: render_queue_item.entity_index, - mvp, - model, - }); - } + // Stream raw pos/rot/scale directly (GPU builds model = T·R·S, then MVP). + self.staging_buffer + .extend_from_slice(bytemuck::bytes_of(&PerDrawStd140 { + position: render_queue_item.position, + rotation: render_queue_item.rotation, + scale: render_queue_item.scale, + })); - // Sort visible set by material (Pipeline → Material → Mesh). - self.visible_pre_draw_buffer.sort_by_key(|visible| { - ((visible.material_handle.data().version.get() as u64) << 32) - | (visible.material_handle.data().index as u64) - }); - - // Build group/batch command list. - self.groups_buffer.clear(); - for visible in &self.visible_pre_draw_buffer { - let need_new_group = self - .groups_buffer - .last() - .map(|group| group.material_handle != visible.material_handle) - .unwrap_or(true); - if need_new_group { + if last_material != Some(material_handle) { self.groups_buffer.push(GroupCmd { - material_handle: visible.material_handle, + material_handle, batches: Vec::new(), }); + last_material = Some(material_handle); + last_mesh = None; } let group = self.groups_buffer.last_mut().unwrap(); - if let Some(batch) = group - .batches - .iter_mut() - .find(|batch| batch.mesh_handle == visible.mesh_handle) - { - batch.instances.push(PerDrawStd140 { - mvp: visible.mvp, - model: visible.model, - }); - } else { + if last_mesh != Some(mesh_handle) { group.batches.push(MeshBatch { - mesh_handle: visible.mesh_handle, - instances: vec![PerDrawStd140 { - mvp: visible.mvp, - model: visible.model, - }], - base_offset_u32: 0, + mesh_handle, + base_offset_u32: next_instance, + instance_count: 0, }); + last_mesh = Some(mesh_handle); } - } - - // Upload per-draw data to the ring buffer. - let needed: u64 = self - .groups_buffer - .iter() - .map(|group| { - group - .batches - .iter() - .map(|batch| batch.instances.len() as u64) - .sum::() - }) - .sum(); - if needed > MAX_EXPECTED_PER_DRAW_INSTANCES as u64 { - log::error!( - "PassPBRStatic: per-draw capacity exceeded (needed={}, capacity={})", - needed, - MAX_EXPECTED_PER_DRAW_INSTANCES - ); - } - // Tightly pack all per-draw entries; no alignment padding needed for storage buffers. - self.staging_buffer.clear(); - let mut next_instance: u32 = 0; - for group in self.groups_buffer.iter_mut() { - for batch in group.batches.iter_mut() { - batch.base_offset_u32 = next_instance; // first instance index for this batch - for per_draw in &batch.instances { - self.staging_buffer - .extend_from_slice(bytemuck::bytes_of(per_draw)); - next_instance = next_instance.wrapping_add(1); - } - } + group.batches.last_mut().unwrap().instance_count += 1; + next_instance = next_instance.wrapping_add(1); } { let state_ref = self @@ -894,13 +824,13 @@ impl Pass for PassPBRStatic { occlusion_query_set: None, }); - // Record draw commands: pipeline → per-draw storage (once) → per group: globals + material → per batch: instanced draw. let state_ref = self .state .as_ref() .expect("PassPBRStatic: state not initialized — call init() before draw()"); render_pass.set_pipeline(&state_ref.pipeline.pipeline); - // Bind per-draw storage array once — instance_index selects the right entry per vertex. + // Per-draw array bound once; instance_index selects per-entity data — 5 draws instead of 60k. + // [Lottes @NOTimothyLottes 2025-01-23; WebGPU W3C 2024 §drawIndexed firstInstance] render_pass.set_bind_group( MATERIAL_BIND_GROUP_PERDRAW as u32, &state_ref.per_draw_bind_group, @@ -937,7 +867,7 @@ impl Pass for PassPBRStatic { render_pass .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); let first = batch.base_offset_u32; - let count = batch.instances.len() as u32; + let count = batch.instance_count; // One instanced draw per batch: instance_index in [first, first+count) selects per-draw data. render_pass.draw_indexed(0..mesh.index_count, 0, first..(first + count)); } diff --git a/engine/pill_engine/src/graphics/render_queue.rs b/engine/pill_engine/src/graphics/render_queue.rs index d301b640..f7985a0e 100644 --- a/engine/pill_engine/src/graphics/render_queue.rs +++ b/engine/pill_engine/src/graphics/render_queue.rs @@ -15,9 +15,15 @@ use std::cmp::Ordering; // --- Render queue item --- +#[derive(Clone, Copy)] pub struct RenderQueueItem { pub key: RenderQueueKey, pub entity_index: u32, + /// Raw transform copied (no trig) during the cache-warm sequential ECS scan; + /// the vertex shader builds the model matrix on the GPU. xyz used, w padding. [Aaltonen GDC 2023] + pub position: [f32; 4], + pub rotation: [f32; 4], + pub scale: [f32; 4], } impl Ord for RenderQueueItem { diff --git a/examples/pill_tunel/src/game.rs b/examples/pill_tunel/src/game.rs index b339de84..55ec52ec 100644 --- a/examples/pill_tunel/src/game.rs +++ b/examples/pill_tunel/src/game.rs @@ -130,6 +130,19 @@ pub struct WebGame {} // --- Systems ----------------------------------------------------------------- +/// Fast sine approximation (~0.1% error) — replaces std `sin` in the 60k-pill hot loop, where the +/// transcendental dominated per-pill cost. Parabola fit + one refinement pass; range-reduced to +/// [-π, π]. [Nick, "A Fast, Compact Approximation of the Sine Function", devmaster.net 2006; +/// cf. Bhāskara I's sine approximation, 7th c.] +fn fast_sin(mut x: f32) -> f32 { + use std::f32::consts::{PI, TAU}; + x -= TAU * (x * (1.0 / TAU)).round(); // range-reduce to [-π, π] + const B: f32 = 4.0 / PI; + const C: f32 = -4.0 / (PI * PI); + let y = B * x + C * x * x.abs(); + 0.225 * (y * y.abs() - y) + y // refinement: ~1% → ~0.1% error +} + fn pill_particle_system(engine: &mut Engine) -> Result<()> { let dt = engine.get_global_component::()?.delta_time; let tunnel_length = TUNNEL_FAR_Z - TUNNEL_NEAR_Z; @@ -141,7 +154,7 @@ fn pill_particle_system(engine: &mut Engine) -> Result<()> { while z < TUNNEL_NEAR_Z { z += tunnel_length; } - let y_wobble = (z * PILL_WOBBLE_Z_K + pill.wobble_phase).sin() * PILL_WOBBLE_AMPLITUDE; + let y_wobble = fast_sin(z * PILL_WOBBLE_Z_K + pill.wobble_phase) * PILL_WOBBLE_AMPLITUDE; transform.set_position(Vector3f::new( transform.position.x, pill.base_y + y_wobble, From f0bca713752b2caec584921bc1a3ba3d1e2d4af4 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 31 May 2026 16:14:06 +0200 Subject: [PATCH 22/31] feat: rendering system cleanup --- ...fragment.hlsl => pbr_opaque_fragment.hlsl} | 0 ...tic_vertex.hlsl => pbr_opaque_vertex.hlsl} | 0 .../src/ecs/systems/rendering_system.rs | 94 +------- engine/pill_engine/src/engine.rs | 5 +- .../src/graphics/dummy_renderer.rs | 10 +- engine/pill_engine/src/graphics/mod.rs | 4 +- .../src/graphics/pass_background.rs | 8 +- engine/pill_engine/src/graphics/pass_mesh.rs | 44 ++-- ...{pass_pbr_static.rs => pass_pbr_opaque.rs} | 204 +++++++++++------- engine/pill_engine/src/graphics/renderer.rs | 38 +++- engine/pill_engine/src/renderer/state.rs | 23 +- 11 files changed, 214 insertions(+), 216 deletions(-) rename engine/pill_engine/res/shaders/{pbr_static_fragment.hlsl => pbr_opaque_fragment.hlsl} (100%) rename engine/pill_engine/res/shaders/{pbr_static_vertex.hlsl => pbr_opaque_vertex.hlsl} (100%) rename engine/pill_engine/src/graphics/{pass_pbr_static.rs => pass_pbr_opaque.rs} (85%) diff --git a/engine/pill_engine/res/shaders/pbr_static_fragment.hlsl b/engine/pill_engine/res/shaders/pbr_opaque_fragment.hlsl similarity index 100% rename from engine/pill_engine/res/shaders/pbr_static_fragment.hlsl rename to engine/pill_engine/res/shaders/pbr_opaque_fragment.hlsl diff --git a/engine/pill_engine/res/shaders/pbr_static_vertex.hlsl b/engine/pill_engine/res/shaders/pbr_opaque_vertex.hlsl similarity index 100% rename from engine/pill_engine/res/shaders/pbr_static_vertex.hlsl rename to engine/pill_engine/res/shaders/pbr_opaque_vertex.hlsl diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index 431ee9a2..297efb27 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -1,18 +1,14 @@ use crate::{ config::RENDERING_SYSTEM, - ecs::{ - CameraAspectRatio, CameraComponent, EntityHandle, PbrRenderableComponent, - RenderStateComponent, TransformComponent, - }, + ecs::{CameraAspectRatio, CameraComponent, EntityHandle, RenderStateComponent}, engine::Engine, - graphics::{PassPBRStatic, PassTonemap, RenderQueueItem, RendererTargetDesc}, + graphics::{PassPBROpaque, PassTonemap, RendererTargetDesc}, }; -use pill_core::{warn, EngineError, LogContext, PillSlotMapKey, PillStyle, RendererError, Timer}; +use pill_core::{EngineError, RendererError, Timer}; use wgpu; -use pill_core::{ErrorContext, Result}; -use web_time::Instant; +use pill_core::Result; pub fn rendering_system(engine: &mut Engine) -> Result<()> { let mut timer = Timer::new(); @@ -45,7 +41,7 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { #[cfg_attr(not(feature = "ui"), allow(unused_mut))] let mut passes: Vec> = vec![ Box::new( - PassPBRStatic::new(Some(hdr)) + PassPBROpaque::new(Some(hdr)) .with_background(bg, bg_color) .with_ibl(diff, spec, lut) .with_fog(bg_color, fog_density), @@ -71,7 +67,6 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { timer.record("Get active camera"); - let active_scene_handle = engine.scene_manager.get_active_scene_handle()?; let mut active_camera_entity_handle_result: Option = None; { @@ -97,87 +92,16 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { pill_core::PillError::from(EngineError::NoActiveCamera) })?; - timer.record("Clear render queue"); - - engine.render_queue.clear(); - engine.render_queue.reserve(200000); - - timer.record("Prepare render queue"); - - let mut _matrix_calculation_duration: f32 = 0.0; - let mut add_to_render_queue_duration: f32 = 0.0; - - // GPU fast-path: copy raw pos/rot/scale during the cache-warm sequential scan — NO trig, - // no matrix build. The vertex shader composes model = T·R·S on the GPU. [Aaltonen GDC 2023] - for (entity_handle, transform, pbr_renderable_component) in engine - .scene_manager - .get_two_component_iterator_mut::( - active_scene_handle, - )? { - let add_to_render_queue_start_time = Instant::now(); - if let Some(render_queue_key) = pbr_renderable_component.render_queue_key { - let p = transform.position; - let r = transform.rotation; - let s = transform.scale; - engine.render_queue.push(RenderQueueItem { - key: render_queue_key, - entity_index: entity_handle.data().index, - position: [p.x, p.y, p.z, 0.0], - rotation: [r.x, r.y, r.z, 0.0], - scale: [s.x, s.y, s.z, 0.0], - }); - } else { - warn!(LogContext::Rendering => "Invalid render queue key"); - continue; - } - add_to_render_queue_duration += - add_to_render_queue_start_time.elapsed().as_secs_f32() * 1000.0; - } - - timer.record(format!( - "Matrix calculation {} ms", - _matrix_calculation_duration - )); - timer.record(format!( - "Add to render queue {} ms", - add_to_render_queue_duration - )); - - timer.record("Sort render queue"); - - // pdqsort by key — measured faster here than hand-rolled radix/counting sort, because the - // scene has few distinct keys and the builtin handles many-equal-elements partitioning well. - engine.render_queue.sort_unstable_by_key(|item| item.key); - - timer.record("Get component storages"); - - let active_scene = engine.scene_manager.get_active_scene_mut()?; - let camera_component_storage = active_scene - .get_component_storage::() - .context(format!( - "{}: Cannot get active {}", - "rendering_system".specific_object_style(), - "Camera".general_object_style() - ))?; - let transform_component_storage = active_scene - .get_component_storage::() - .context(format!( - "{}: Cannot get {}", - "rendering_system".specific_object_style(), - "TransformComponents".specific_object_style() - )) - .unwrap(); - timer.begin_context("Render"); - // Render + // Stateless: the engine hands the active scene to the renderer; each pass queries the + // component types it needs via WorldQuery::query::() and builds its own draw list. + let active_scene = engine.scene_manager.get_active_scene()?; let delta_time = engine.frame_delta_time; let render_result = engine.renderer.render( active_camera_entity_handle, - &engine.render_queue, - camera_component_storage, - transform_component_storage, + active_scene, delta_time, &mut timer, &engine.resource_manager, diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index f714cb9d..57972602 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -39,7 +39,6 @@ pub struct Engine { pub(crate) resource_manager: ResourceManager, pub(crate) global_components: PillTypeMap, pub(crate) input_queue: VecDeque, - pub(crate) render_queue: Vec, pub(crate) window_size: winit::dpi::PhysicalSize, pub(crate) game_resources_directory_path: std::path::PathBuf, pub(crate) frame_delta_time: f32, // In milliseconds @@ -70,7 +69,6 @@ impl Engine { resource_manager: ResourceManager::new(), global_components: PillTypeMap::new(), input_queue: VecDeque::new(), - render_queue: Vec::::with_capacity(max_entity_count), window_size: winit::dpi::PhysicalSize::::default(), game_resources_directory_path, frame_delta_time: 0.0, @@ -93,7 +91,6 @@ impl Engine { resource_manager: ResourceManager::new(), global_components: PillTypeMap::new(), input_queue: VecDeque::new(), - render_queue: Vec::::with_capacity(max_entity_count), window_size: winit::dpi::PhysicalSize::::default(), game_resources_directory_path: std::path::PathBuf::new(), frame_delta_time: 0.0.into(), @@ -679,7 +676,7 @@ impl Engine { } /// Provides IBL map bytes (diffuse irradiance, specular prefilter, BRDF LUT); - /// used by PassPBRStatic on first-frame init. + /// used by PassPBROpaque on first-frame init. /// Must be called before the first rendered frame (i.e. from `start()`). pub fn set_ibl_textures( &mut self, diff --git a/engine/pill_engine/src/graphics/dummy_renderer.rs b/engine/pill_engine/src/graphics/dummy_renderer.rs index 53cd16f7..e42b8404 100644 --- a/engine/pill_engine/src/graphics/dummy_renderer.rs +++ b/engine/pill_engine/src/graphics/dummy_renderer.rs @@ -1,9 +1,9 @@ use crate::{ app_config::EngineConfig, - ecs::{CameraComponent, ComponentStorage, EntityHandle, TransformComponent}, + ecs::{EntityHandle, Scene}, graphics::{ - BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, RenderQueueItem, - RendererCameraHandle, RendererTargetDesc, RendererTextureHandle, WorldQuery, + BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, RendererCameraHandle, + RendererTargetDesc, RendererTextureHandle, WorldQuery, }, resources::{ResourceManager, ShaderParameterSlot, ShaderTextureSlot}, }; @@ -56,9 +56,7 @@ impl PillRenderer for DummyRenderer { fn render( &mut self, _active_camera_entity_handle: EntityHandle, - _render_queue: &[RenderQueueItem], - _camera_component_storage: &ComponentStorage, - _transform_component_storage: &ComponentStorage, + _scene: &Scene, _delta_time: f32, _timer: &mut Timer, _resource_manager: &ResourceManager, diff --git a/engine/pill_engine/src/graphics/mod.rs b/engine/pill_engine/src/graphics/mod.rs index 2e007d6e..be42a06e 100644 --- a/engine/pill_engine/src/graphics/mod.rs +++ b/engine/pill_engine/src/graphics/mod.rs @@ -6,7 +6,7 @@ mod pass_background; #[cfg(feature = "ui")] mod pass_egui; mod pass_mesh; -mod pass_pbr_static; +mod pass_pbr_opaque; mod pass_tonemap; mod render_queue; mod renderer; @@ -25,7 +25,7 @@ pub use pass_background::PassBackground; #[cfg(feature = "ui")] pub use pass_egui::PassEgui; pub use pass_mesh::PassMesh; -pub use pass_pbr_static::PassPBRStatic; +pub use pass_pbr_opaque::PassPBROpaque; pub use pass_tonemap::PassTonemap; pub use render_queue::{ diff --git a/engine/pill_engine/src/graphics/pass_background.rs b/engine/pill_engine/src/graphics/pass_background.rs index 04403dc4..67b2fa24 100644 --- a/engine/pill_engine/src/graphics/pass_background.rs +++ b/engine/pill_engine/src/graphics/pass_background.rs @@ -206,16 +206,16 @@ impl Pass for PassBackground { let state = self.state.as_ref().unwrap(); // Compute inv_view_proj from active camera — same matrices as PBR pass. + let camera_components = world.query::()?; + let transform_components = world.query::()?; let active_idx = world.active_camera.data().index as usize; - let cam = world - .camera_components + let cam = camera_components .data .get(active_idx) .unwrap() .as_ref() .unwrap(); - let tfm = world - .transform_components + let tfm = transform_components .data .get(active_idx) .unwrap() diff --git a/engine/pill_engine/src/graphics/pass_mesh.rs b/engine/pill_engine/src/graphics/pass_mesh.rs index 48bb5b25..c1380b1e 100644 --- a/engine/pill_engine/src/graphics/pass_mesh.rs +++ b/engine/pill_engine/src/graphics/pass_mesh.rs @@ -1,8 +1,7 @@ use std::{num::NonZeroU32, ops::Range}; -use crate::graphics::{ - RenderQueueItem, RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle, -}; +use crate::ecs::{CameraComponent, PbrRenderableComponent, TransformComponent}; +use crate::graphics::{RendererMaterialHandle, RendererMeshHandle, RendererShaderHandle}; use crate::internal::decompose_render_queue_key; use crate::{ graphics::{Pass, PillRenderer, RendererTextureHandle, WorldQuery}, @@ -227,17 +226,20 @@ impl Pass for PassMesh { view: &wgpu::TextureView, world: &WorldQuery<'_>, ) -> Result<()> { + // Query the component storages this pass needs. + let camera_components = world.query::()?; + let transform_components = world.query::()?; + let pbr_renderable_components = world.query::()?; + // Resolve active camera. let active_camera_index = world.active_camera.data().index as usize; - let active_camera_component = world - .camera_components + let active_camera_component = camera_components .data .get(active_camera_index) .unwrap() .as_ref() .unwrap(); - let active_camera_transform = world - .transform_components + let active_camera_transform = transform_components .data .get(active_camera_index) .unwrap() @@ -291,8 +293,15 @@ impl Pass for PassMesh { let render_pass: &mut wgpu::RenderPass<'static> = unsafe { std::mem::transmute(&mut render_pass) }; - for (i, instance_batch) in world - .render_queue + // Build this pass's draw list by querying the world (data-driven; no engine-global queue): + // every entity with a PbrRenderableComponent is an instance. + let renderable_indices: Vec = pbr_renderable_components + .data + .iter() + .enumerate() + .filter_map(|(index, slot)| slot.as_ref().map(|_| index as u32)) + .collect(); + for (i, instance_batch) in renderable_indices .chunks(MAX_INSTANCE_PER_DRAWCALL_COUNT) .enumerate() { @@ -302,11 +311,10 @@ impl Pass for PassMesh { self.instances.clear(); - for render_queue_item in instance_batch { - let transform_slot = world - .transform_components + for &entity_index in instance_batch { + let transform_slot = transform_components .data - .get(render_queue_item.entity_index as usize) + .get(entity_index as usize) .ok_or_else(|| -> pill_core::PillError { RendererError::Other.into() })?; let transform_component = transform_slot .as_ref() @@ -324,8 +332,14 @@ impl Pass for PassMesh { current_drawing_context.accumulated_instance_range = 0..0; - for (j, render_queue_item) in instance_batch.iter().enumerate() { - let key_fields = decompose_render_queue_key(render_queue_item.key); + for (j, &entity_index) in instance_batch.iter().enumerate() { + let key = pbr_renderable_components + .data + .get(entity_index as usize) + .and_then(|slot| slot.as_ref()) + .and_then(|pbr| pbr.render_queue_key) + .unwrap_or(0); + let key_fields = decompose_render_queue_key(key); let renderer_shader_handle = RendererShaderHandle::new( key_fields.shader_index.into(), diff --git a/engine/pill_engine/src/graphics/pass_pbr_static.rs b/engine/pill_engine/src/graphics/pass_pbr_opaque.rs similarity index 85% rename from engine/pill_engine/src/graphics/pass_pbr_static.rs rename to engine/pill_engine/src/graphics/pass_pbr_opaque.rs index 9b94993e..72103268 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_static.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_opaque.rs @@ -1,6 +1,6 @@ use crate::config::{DEFAULT_BRDF_LUT_FALLBACK_PIXEL, DEFAULT_IBL_FALLBACK_PIXEL}; use crate::{ - ecs::{CameraComponent, ComponentStorage, TransformComponent}, + ecs::{CameraComponent, ComponentStorage, PbrRenderableComponent, TransformComponent}, graphics::{ decompose_render_queue_key, BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, RendererMaterialHandle, RendererMeshHandle, RendererTextureHandle, ShaderDesc, WorldQuery, @@ -93,11 +93,12 @@ pub const MATERIAL_BIND_GROUP_TEXTURES: usize = 1; pub const MATERIAL_BIND_GROUP_PARAMS: usize = 2; pub const MATERIAL_BIND_GROUP_PERDRAW: usize = 3; -/// Mesh batch within a material group: same mesh, a contiguous instance range in the storage array. +/// Mesh batch within a material group: same mesh, its per-instance transforms accumulated this +/// frame; `base_offset_u32` is its first index in the concatenated storage buffer. pub(crate) struct MeshBatch { pub(crate) mesh_handle: RendererMeshHandle, - pub(crate) base_offset_u32: u32, // first instance index - pub(crate) instance_count: u32, + pub(crate) instances: Vec, + pub(crate) base_offset_u32: u32, } /// Draw group: one pipeline + one material, containing one or more mesh batches. @@ -129,7 +130,7 @@ struct BgSubState { } /// GPU-side pass state initialized in `Pass::init`, read every `Pass::draw`. -struct PassPBRStaticState { +struct PassPBROpaqueState { camera_uniform: CameraUniform, camera_buffer: wgpu::Buffer, globals_bind_group: wgpu::BindGroup, @@ -141,7 +142,7 @@ struct PassPBRStaticState { } /// Default PBR pass: GGX shading with IBL, one instanced draw per material/mesh batch. -pub struct PassPBRStatic { +pub struct PassPBROpaque { color_target: Option, depth_texture: Option, groups_buffer: Vec, @@ -153,10 +154,10 @@ pub struct PassPBRStatic { fog_density: f32, bg_equirect: Option, bg_color: [f32; 3], - state: Option, + state: Option, } -impl PassPBRStatic { +impl PassPBROpaque { /// Creates the pass; `Pass::init` must run before the first frame. pub fn new(color_target: Option) -> Self { Self { @@ -203,22 +204,22 @@ impl PassPBRStatic { } /// Returns the initialized pass state; panics in debug if `init` was not called. -fn get_state(pass: &mut PassPBRStatic) -> &mut PassPBRStaticState { +fn get_state(pass: &mut PassPBROpaque) -> &mut PassPBROpaqueState { debug_assert!(pass.state.is_some()); pass.state .as_mut() - .expect("PassPBRStatic: state not initialized — call init() before draw()") + .expect("PassPBROpaque: state not initialized — call init() before draw()") } -impl Pass for PassPBRStatic { +impl Pass for PassPBROpaque { fn get_label(&self) -> &str { - "pass_pbr_static" + "pass_pbr_opaque" } fn init(&mut self, renderer: &mut dyn PillRenderer) -> Result<()> { - let vertex_wgsl = include_str!("../../res/shaders/pbr_static_vertex.wgsl"); + let vertex_wgsl = include_str!("../../res/shaders/pbr_opaque_vertex.wgsl"); - let fragment_wgsl = include_str!("../../res/shaders/pbr_static_fragment.wgsl"); + let fragment_wgsl = include_str!("../../res/shaders/pbr_opaque_fragment.wgsl"); let bind_groups: Vec> = vec![ // 0: globals (camera + IBL irradiance + prefilter + BRDF LUT) @@ -374,7 +375,7 @@ impl Pass for PassPBRStatic { ]; let desc = PipelineV2Desc { - label: Some("pass_pbr_static_pipeline"), + label: Some("pass_pbr_opaque_pipeline"), vs: ShaderDesc { source: vertex_wgsl, entry_func: "vs_main", @@ -416,7 +417,7 @@ impl Pass for PassPBRStatic { let camera_buffer = { let device = renderer.get_device(); device.create_buffer(&wgpu::BufferDescriptor { - label: Some("pass_pbr_static_camera_buffer"), + label: Some("pass_pbr_opaque_camera_buffer"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, @@ -424,7 +425,7 @@ impl Pass for PassPBRStatic { }; let per_draw_buffer = renderer.create_buffer(BufferDesc { - label: Some("pass_pbr_static_per_draw"), + label: Some("pass_pbr_opaque_per_draw"), byte_size: (PER_DRAW_STRIDE_BYTES * MAX_EXPECTED_PER_DRAW_INSTANCES) as u64, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, })?; @@ -432,7 +433,7 @@ impl Pass for PassPBRStatic { let layout = &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_PERDRAW]; let device = renderer.get_device(); device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("pass_pbr_static_per_draw_bind_group"), + label: Some("pass_pbr_opaque_per_draw_bind_group"), layout, entries: &[wgpu::BindGroupEntry { binding: 0, @@ -512,7 +513,7 @@ impl Pass for PassPBRStatic { let layout = &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_GLOBALS]; let device = renderer.get_device(); device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("pass_pbr_static_globals_bind_group"), + label: Some("pass_pbr_opaque_globals_bind_group"), layout, entries: &[ wgpu::BindGroupEntry { @@ -547,7 +548,7 @@ impl Pass for PassPBRStatic { }) }; - self.depth_texture = Some(renderer.create_depth_texture("pass_pbr_static_depth")?); + self.depth_texture = Some(renderer.create_depth_texture("pass_pbr_opaque_depth")?); // Create background sub-draw state if an equirect handle was provided. let bg = if let Some(equirect_h) = self.bg_equirect { @@ -582,7 +583,7 @@ impl Pass for PassPBRStatic { }, ]]; let bg_pipeline = renderer.create_pipeline_v2(PipelineV2Desc { - label: Some("pass_pbr_static_bg_pipeline"), + label: Some("pass_pbr_opaque_bg_pipeline"), vs: ShaderDesc { source: bg_vs, entry_func: "vs_main", @@ -634,7 +635,7 @@ impl Pass for PassPBRStatic { let bg_camera_buf = renderer .get_device() .create_buffer(&wgpu::BufferDescriptor { - label: Some("pass_pbr_static_bg_camera"), + label: Some("pass_pbr_opaque_bg_camera"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, @@ -644,7 +645,7 @@ impl Pass for PassPBRStatic { renderer .get_device() .create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("pass_pbr_static_bg_bind_group"), + label: Some("pass_pbr_opaque_bg_bind_group"), layout, entries: &[ wgpu::BindGroupEntry { @@ -672,7 +673,7 @@ impl Pass for PassPBRStatic { None }; - self.state = Some(PassPBRStaticState { + self.state = Some(PassPBROpaqueState { camera_uniform: CameraUniform::new(), camera_buffer, globals_bind_group, @@ -694,17 +695,20 @@ impl Pass for PassPBRStatic { view: &wgpu::TextureView, world: &WorldQuery<'_>, ) -> Result<()> { + // Query the component storages this pass needs. + let camera_components = world.query::()?; + let transform_components = world.query::()?; + let pbr_renderable_components = world.query::()?; + // Read active camera and transform. let active_camera_index = world.active_camera.data().index as usize; - let active_camera_component = world - .camera_components + let active_camera_component = camera_components .data .get(active_camera_index) .unwrap() .as_ref() .unwrap(); - let active_camera_transform = world - .transform_components + let active_camera_transform = transform_components .data .get(active_camera_index) .unwrap() @@ -729,24 +733,31 @@ impl Pass for PassPBRStatic { ); } - // Single pass over the pre-sorted render queue (key order = shader→material→mesh, so - // same material/mesh runs are contiguous): build groups/batches AND stream model - // matrices into the staging buffer in one sweep. No visible set, no MVP on CPU, no - // per-batch search. [Aaltonen "HypeHype" GDC 2023 — batch-upload once per pass] - self.groups_buffer.clear(); - self.staging_buffer.clear(); - let mut next_instance: u32 = 0; - let mut last_material: Option = None; - let mut last_mesh: Option = None; - for render_queue_item in world.render_queue.iter() { - if next_instance as usize >= MAX_EXPECTED_PER_DRAW_INSTANCES { - log::error!( - "PassPBRStatic: per-draw capacity exceeded (capacity={})", - MAX_EXPECTED_PER_DRAW_INSTANCES - ); - break; + // Data-driven: this pass builds its OWN opaque draw list by querying the world. Iterate the + // contiguous transform + pbr_renderable arrays, group by material+mesh (find-or-create, since + // ECS order is unsorted) to minimize bind-group/vertex-buffer switches — the grouping exists + // to cut GPU state changes, not for visual order. Group order is irrelevant (opaque, depth- + // tested). [Aaltonen "HypeHype" GDC 2023] + for group in self.groups_buffer.iter_mut() { + for batch in group.batches.iter_mut() { + batch.instances.clear(); // reuse capacity across frames } - let key_fields = decompose_render_queue_key(render_queue_item.key); + } + let transforms = &transform_components.data; + let pbrs = &pbr_renderable_components.data; + for (slot_transform, slot_pbr) in transforms.iter().zip(pbrs.iter()) { + let transform = match slot_transform { + Some(transform) => transform, + None => continue, + }; + let pbr = match slot_pbr { + Some(pbr) => pbr, + None => continue, + }; + let Some(key) = pbr.render_queue_key else { + continue; + }; + let key_fields = decompose_render_queue_key(key); let mesh_handle = RendererMeshHandle::new( key_fields.mesh_index.into(), NonZeroU32::new(key_fields.mesh_version.into()).unwrap(), @@ -755,40 +766,76 @@ impl Pass for PassPBRStatic { key_fields.material_index.into(), NonZeroU32::new(key_fields.material_version.into()).unwrap(), ); - - // Stream raw pos/rot/scale directly (GPU builds model = T·R·S, then MVP). - self.staging_buffer - .extend_from_slice(bytemuck::bytes_of(&PerDrawStd140 { - position: render_queue_item.position, - rotation: render_queue_item.rotation, - scale: render_queue_item.scale, - })); - - if last_material != Some(material_handle) { - self.groups_buffer.push(GroupCmd { - material_handle, - batches: Vec::new(), - }); - last_material = Some(material_handle); - last_mesh = None; - } - let group = self.groups_buffer.last_mut().unwrap(); - if last_mesh != Some(mesh_handle) { - group.batches.push(MeshBatch { + let per_draw = PerDrawStd140 { + position: [ + transform.position.x, + transform.position.y, + transform.position.z, + 0.0, + ], + rotation: [ + transform.rotation.x, + transform.rotation.y, + transform.rotation.z, + 0.0, + ], + scale: [transform.scale.x, transform.scale.y, transform.scale.z, 0.0], + }; + let group_index = match self + .groups_buffer + .iter() + .position(|group| group.material_handle == material_handle) + { + Some(index) => index, + None => { + self.groups_buffer.push(GroupCmd { + material_handle, + batches: Vec::new(), + }); + self.groups_buffer.len() - 1 + } + }; + let group = &mut self.groups_buffer[group_index]; + match group + .batches + .iter_mut() + .find(|batch| batch.mesh_handle == mesh_handle) + { + Some(batch) => batch.instances.push(per_draw), + None => group.batches.push(MeshBatch { mesh_handle, - base_offset_u32: next_instance, - instance_count: 0, - }); - last_mesh = Some(mesh_handle); + instances: vec![per_draw], + base_offset_u32: 0, + }), + } + } + + // Concatenate per-batch instances into the storage buffer (sequential — cache-friendly) and + // assign each batch its first-instance offset. Single upload per pass. [Aaltonen GDC 2023] + self.staging_buffer.clear(); + let mut next_instance: u32 = 0; + for group in self.groups_buffer.iter_mut() { + for batch in group.batches.iter_mut() { + batch.base_offset_u32 = next_instance; + for per_draw in &batch.instances { + self.staging_buffer + .extend_from_slice(bytemuck::bytes_of(per_draw)); + next_instance = next_instance.wrapping_add(1); + } } - group.batches.last_mut().unwrap().instance_count += 1; - next_instance = next_instance.wrapping_add(1); + } + if next_instance as usize > MAX_EXPECTED_PER_DRAW_INSTANCES { + log::error!( + "PassPBROpaque: per-draw capacity exceeded (needed={}, capacity={})", + next_instance, + MAX_EXPECTED_PER_DRAW_INSTANCES + ); } { let state_ref = self .state .as_ref() - .expect("PassPBRStatic: state not initialized — call init() before draw()"); + .expect("PassPBROpaque: state not initialized — call init() before draw()"); renderer .get_queue() .write_buffer(&state_ref.per_draw_buffer, 0, &self.staging_buffer); @@ -803,7 +850,7 @@ impl Pass for PassPBRStatic { .unwrap_or(view); let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("pass_pbr_static_render_pass"), + label: Some("pass_pbr_opaque_render_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: color_view, resolve_target: None, @@ -827,7 +874,7 @@ impl Pass for PassPBRStatic { let state_ref = self .state .as_ref() - .expect("PassPBRStatic: state not initialized — call init() before draw()"); + .expect("PassPBROpaque: state not initialized — call init() before draw()"); render_pass.set_pipeline(&state_ref.pipeline.pipeline); // Per-draw array bound once; instance_index selects per-entity data — 5 draws instead of 60k. // [Lottes @NOTimothyLottes 2025-01-23; WebGPU W3C 2024 §drawIndexed firstInstance] @@ -846,7 +893,7 @@ impl Pass for PassPBRStatic { let mat = world .resources .get_resource::(&group.material_handle) - .expect("PassPBRStatic: RendererMaterial missing for draw group"); + .expect("PassPBROpaque: RendererMaterial missing for draw group"); // Skip materials that don't have PBR-compatible bind groups. let (Some(textures_bg), Some(params_bg)) = ( @@ -862,12 +909,15 @@ impl Pass for PassPBRStatic { let mesh = world .resources .get_resource::(&batch.mesh_handle) - .expect("PassPBRStatic: RendererMesh missing for batch"); + .expect("PassPBROpaque: RendererMesh missing for batch"); render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); render_pass .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); let first = batch.base_offset_u32; - let count = batch.instance_count; + let count = batch.instances.len() as u32; + if count == 0 { + continue; // material/mesh with no instances this frame + } // One instanced draw per batch: instance_index in [first, first+count) selects per-draw data. render_pass.draw_indexed(0..mesh.index_count, 0, first..(first + count)); } diff --git a/engine/pill_engine/src/graphics/renderer.rs b/engine/pill_engine/src/graphics/renderer.rs index eca728d6..7a4f935a 100644 --- a/engine/pill_engine/src/graphics/renderer.rs +++ b/engine/pill_engine/src/graphics/renderer.rs @@ -3,8 +3,7 @@ use crate::ecs::EguiClient; use crate::{ app_config::EngineConfig, - ecs::{CameraComponent, ComponentStorage, EntityHandle, TransformComponent}, - graphics::RenderQueueItem, + ecs::{Component, ComponentStorage, EntityHandle, Scene}, resources::{ResourceManager, ShaderParameterSlot, ShaderTextureSlot, TextureType}, }; @@ -37,13 +36,38 @@ pill_core::define_new_pill_slotmap_key! { // --- Pass API types --- +/// Read-only view of the world handed to each pass. A pass calls `query::()` for whatever +/// component types it needs and builds its OWN draw list (opaque, translucent, shadow, …). +/// The renderer stays domain-agnostic — it never names concrete component types. pub struct WorldQuery<'a> { pub active_camera: EntityHandle, - pub render_queue: &'a [RenderQueueItem], - pub camera_components: &'a ComponentStorage, - pub transform_components: &'a ComponentStorage, pub delta_time: f32, pub resources: &'a ResourceManager, + scene: &'a Scene, +} + +impl<'a> WorldQuery<'a> { + pub fn new( + active_camera: EntityHandle, + scene: &'a Scene, + delta_time: f32, + resources: &'a ResourceManager, + ) -> Self { + Self { + active_camera, + delta_time, + resources, + scene, + } + } + + /// Borrow the storage for component type `T` from the active scene. + pub fn query(&self) -> Result<&'a ComponentStorage> + where + T: Component>, + { + self.scene.get_component_storage::() + } } #[derive(Clone, Copy)] @@ -144,9 +168,7 @@ pub trait PillRenderer { fn render( &mut self, active_camera_entity_handle: EntityHandle, - render_queue: &[RenderQueueItem], - camera_component_storage: &ComponentStorage, - transform_component_storage: &ComponentStorage, + scene: &Scene, delta_time: f32, timer: &mut Timer, resource_manager: &ResourceManager, diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs index 35f66c23..773743ef 100644 --- a/engine/pill_engine/src/renderer/state.rs +++ b/engine/pill_engine/src/renderer/state.rs @@ -2,13 +2,10 @@ use crate::{ app_config::EngineConfig, graphics::{ - BufferDesc, Pass, PassPBRStatic, PillRenderer, PipelineV2, PipelineV2Desc, + BufferDesc, Pass, PassPBROpaque, PillRenderer, PipelineV2, PipelineV2Desc, RendererCameraHandle, RendererTargetDesc, RendererTextureHandle, WorldQuery, }, - internal::{ - get_renderer_resource_handle_from_camera_component, CameraComponent, ComponentStorage, - EntityHandle, RenderQueueItem, TransformComponent, - }, + internal::{get_renderer_resource_handle_from_camera_component, EntityHandle}, renderer::{ instance::Instance, resources::{ @@ -115,9 +112,7 @@ impl PillRenderer for Renderer { fn render( &mut self, active_camera_entity_handle: EntityHandle, - render_queue: &[RenderQueueItem], - camera_component_storage: &ComponentStorage, - transform_component_storage: &ComponentStorage, + scene: &crate::ecs::Scene, delta_time: f32, timer: &mut Timer, resource_manager: &ResourceManager, @@ -149,14 +144,12 @@ impl PillRenderer for Renderer { label: Some("render_encoder"), }); - let world = WorldQuery { - active_camera: active_camera_entity_handle, - render_queue, - camera_components: camera_component_storage, - transform_components: transform_component_storage, + let world = WorldQuery::new( + active_camera_entity_handle, + scene, delta_time, - resources: resource_manager, - }; + resource_manager, + ); timer.begin_context("Scene Passes"); From dba3e0f038eba1530897ec5080c16ef7e9406822 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 31 May 2026 17:19:49 +0200 Subject: [PATCH 23/31] feat: renderer cleanup --- .gitignore | 3 +- engine/Cargo.toml | 2 +- .../src/ecs/components/component_storage.rs | 18 + engine/pill_engine/src/ecs/components/mod.rs | 2 +- engine/pill_engine/src/ecs/mod.rs | 4 +- .../src/ecs/systems/rendering_system.rs | 27 +- engine/pill_engine/src/engine.rs | 13 +- .../src/graphics/dummy_renderer.rs | 1 + .../src/graphics/pass_pbr_opaque.rs | 364 +++++++++++------- engine/pill_engine/src/graphics/renderer.rs | 20 +- engine/pill_engine/src/renderer/state.rs | 2 + 11 files changed, 270 insertions(+), 186 deletions(-) diff --git a/.gitignore b/.gitignore index 8f06af58..9b07acda 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ target/ build/ .vscode/ *.lock +.claude docs/* engine/pill_engine/docs/uml_out/* @@ -15,4 +16,4 @@ engine/target_* *.cooked_tex DamagedHelmet.glb MetalRoughSpheres.glb -.claude +studio_small_08.hdr diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 99836cbc..5e95497d 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,7 +8,7 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "/Users/mk/dev/demo/Pill-Engine/examples/pill_tunel", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) + "/Users/mk/dev/demo/Pill-Engine/examples/pbr_helmet", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at diff --git a/engine/pill_engine/src/ecs/components/component_storage.rs b/engine/pill_engine/src/ecs/components/component_storage.rs index a8eb1f20..9c2e47e5 100644 --- a/engine/pill_engine/src/ecs/components/component_storage.rs +++ b/engine/pill_engine/src/ecs/components/component_storage.rs @@ -1,3 +1,6 @@ +use crate::ecs::GlobalComponent; +use pill_core::{get_type_name, EngineError, PillTypeMap, PillTypeMapKey, Result}; + // --- Component storage --- pub struct ComponentStorage { @@ -29,3 +32,18 @@ impl GlobalComponentStorage { Self { data: Some(data) } } } + +/// Borrows global component `T` out of a `PillTypeMap`, erroring if absent. +/// Single source of truth for `Engine::get_global_component` and `WorldQuery::get_global` — +/// lives here because it depends on `GlobalComponentStorage`'s `data` layout. +pub fn get_global_component_from(globals: &PillTypeMap) -> Result<&T> +where + T: GlobalComponent> + PillTypeMapKey, +{ + globals + .get::() + .and_then(|storage| storage.data.as_ref()) + .ok_or_else(|| -> pill_core::PillError { + EngineError::GlobalComponentNotFound(get_type_name::()).into() + }) +} diff --git a/engine/pill_engine/src/ecs/components/mod.rs b/engine/pill_engine/src/ecs/components/mod.rs index db9eeca9..468490bb 100644 --- a/engine/pill_engine/src/ecs/components/mod.rs +++ b/engine/pill_engine/src/ecs/components/mod.rs @@ -27,4 +27,4 @@ pub(crate) mod transform_component; pub use component::{Component, ComponentDestroyer, ConcreteComponentDestroyer, GlobalComponent}; -pub use component_storage::{ComponentStorage, GlobalComponentStorage}; +pub use component_storage::{get_global_component_from, ComponentStorage, GlobalComponentStorage}; diff --git a/engine/pill_engine/src/ecs/mod.rs b/engine/pill_engine/src/ecs/mod.rs index 6f6ca5b6..1def33f8 100644 --- a/engine/pill_engine/src/ecs/mod.rs +++ b/engine/pill_engine/src/ecs/mod.rs @@ -12,8 +12,8 @@ mod systems; // - Components pub use components::{ - Component, ComponentDestroyer, ComponentStorage, ConcreteComponentDestroyer, GlobalComponent, - GlobalComponentStorage, + get_global_component_from, Component, ComponentDestroyer, ComponentStorage, + ConcreteComponentDestroyer, GlobalComponent, GlobalComponentStorage, }; pub use components::camera_component::{ diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index 297efb27..b1949739 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -20,17 +20,8 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { .boot_done; if !boot_done { - let (bg, diff, spec, lut, bg_color, fog_density) = { - let rs = engine.get_global_component::()?; - ( - rs.background, - rs.ibl_diffuse, - rs.ibl_specular, - rs.ibl_brdf_lut, - rs.bg_color, - rs.fog_density, - ) - }; + // Bootstrap wires only GPU resources (render targets). Per-frame state (bg/IBL/fog) is read + // from RenderStateComponent each frame inside the passes via WorldQuery::get_global. let (w, h) = engine.renderer.get_surface_size(); let hdr = engine.renderer.create_render_target(RendererTargetDesc { name: "hdr_target".to_string(), @@ -40,12 +31,7 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { })?; #[cfg_attr(not(feature = "ui"), allow(unused_mut))] let mut passes: Vec> = vec![ - Box::new( - PassPBROpaque::new(Some(hdr)) - .with_background(bg, bg_color) - .with_ibl(diff, spec, lut) - .with_fog(bg_color, fog_density), - ), + Box::new(PassPBROpaque::new(Some(hdr))), Box::new(PassTonemap::new(hdr)), ]; #[cfg(feature = "ui")] @@ -94,14 +80,17 @@ pub fn rendering_system(engine: &mut Engine) -> Result<()> { timer.begin_context("Render"); - // Stateless: the engine hands the active scene to the renderer; each pass queries the - // component types it needs via WorldQuery::query::() and builds its own draw list. + // Stateless: the engine hands the active scene + globals to the renderer; each pass queries the + // per-entity components (WorldQuery::query::()) and globals (WorldQuery::get_global::()) + // it needs, and builds its own draw list. let active_scene = engine.scene_manager.get_active_scene()?; + let globals = &engine.global_components; let delta_time = engine.frame_delta_time; let render_result = engine.renderer.render( active_camera_entity_handle, active_scene, + globals, delta_time, &mut timer, &engine.resource_manager, diff --git a/engine/pill_engine/src/engine.rs b/engine/pill_engine/src/engine.rs index 57972602..bd042d9d 100644 --- a/engine/pill_engine/src/engine.rs +++ b/engine/pill_engine/src/engine.rs @@ -1068,18 +1068,7 @@ impl Engine { where T: GlobalComponent>, { - // Get component - let component = self - .global_components - .get::() - .ok_or_else(|| -> pill_core::PillError { - EngineError::GlobalComponentNotFound(get_type_name::()).into() - })? - .data - .as_ref() - .unwrap(); - - Ok(component) + crate::ecs::get_global_component_from::(&self.global_components) } /// Returns global mutable component diff --git a/engine/pill_engine/src/graphics/dummy_renderer.rs b/engine/pill_engine/src/graphics/dummy_renderer.rs index e42b8404..c6aebd8b 100644 --- a/engine/pill_engine/src/graphics/dummy_renderer.rs +++ b/engine/pill_engine/src/graphics/dummy_renderer.rs @@ -57,6 +57,7 @@ impl PillRenderer for DummyRenderer { &mut self, _active_camera_entity_handle: EntityHandle, _scene: &Scene, + _globals: &pill_core::PillTypeMap, _delta_time: f32, _timer: &mut Timer, _resource_manager: &ResourceManager, diff --git a/engine/pill_engine/src/graphics/pass_pbr_opaque.rs b/engine/pill_engine/src/graphics/pass_pbr_opaque.rs index 72103268..fa18f7b0 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_opaque.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_opaque.rs @@ -1,6 +1,11 @@ -use crate::config::{DEFAULT_BRDF_LUT_FALLBACK_PIXEL, DEFAULT_IBL_FALLBACK_PIXEL}; +use crate::config::{ + DEFAULT_BRDF_LUT_FALLBACK_PIXEL, DEFAULT_EQUIRECT_FALLBACK_PIXEL, DEFAULT_IBL_FALLBACK_PIXEL, +}; use crate::{ - ecs::{CameraComponent, ComponentStorage, PbrRenderableComponent, TransformComponent}, + ecs::{ + CameraComponent, ComponentStorage, PbrRenderableComponent, RenderStateComponent, + TransformComponent, + }, graphics::{ decompose_render_queue_key, BufferDesc, Pass, PillRenderer, PipelineV2, PipelineV2Desc, RendererMaterialHandle, RendererMeshHandle, RendererTextureHandle, ShaderDesc, WorldQuery, @@ -126,7 +131,9 @@ struct BgSubState { pipeline: PipelineV2, camera_buf: wgpu::Buffer, bind_group: wgpu::BindGroup, - _sampler: wgpu::Sampler, + sampler: wgpu::Sampler, + // Equirect handle the current bind_group was built from; rebuild only when it changes. + cached_bg: RendererTextureHandle, } /// GPU-side pass state initialized in `Pass::init`, read every `Pass::draw`. @@ -136,24 +143,99 @@ struct PassPBROpaqueState { globals_bind_group: wgpu::BindGroup, pipeline: PipelineV2, ibl_sampler: wgpu::Sampler, + prefilter_sampler: wgpu::Sampler, per_draw_buffer: wgpu::Buffer, per_draw_bind_group: wgpu::BindGroup, - bg: Option, + bg: BgSubState, + // IBL handles the current globals_bind_group was built from; rebuild only when these change. + cached_ibl: [RendererTextureHandle; 3], +} + +/// Builds the globals bind group (camera UBO + IBL irradiance/prefilter/BRDF-LUT views). +/// Shared by init() and the per-frame rebuild-on-change path in draw(). +#[allow(clippy::too_many_arguments)] +fn build_globals_bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + camera_buffer: &wgpu::Buffer, + irradiance_view: &wgpu::TextureView, + prefilter_view: &wgpu::TextureView, + brdf_lut_view: &wgpu::TextureView, + ibl_sampler: &wgpu::Sampler, + prefilter_sampler: &wgpu::Sampler, +) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_pbr_opaque_globals_bind_group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(irradiance_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(ibl_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(prefilter_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(prefilter_sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(brdf_lut_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(ibl_sampler), + }, + ], + }) +} + +/// Builds the background bind group (camera UBO + equirect view + sampler). +fn build_bg_bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + camera_buffer: &wgpu::Buffer, + equirect_view: &wgpu::TextureView, + sampler: &wgpu::Sampler, +) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pass_pbr_opaque_bg_bind_group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(equirect_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(sampler), + }, + ], + }) } /// Default PBR pass: GGX shading with IBL, one instanced draw per material/mesh batch. +/// Per-frame state (IBL handles, background equirect, bg_color, fog) is read from +/// RenderStateComponent each frame via WorldQuery::get_global — nothing is baked at construction. pub struct PassPBROpaque { color_target: Option, depth_texture: Option, groups_buffer: Vec, staging_buffer: Vec, - ibl_diffuse: Option, - ibl_specular: Option, - ibl_brdf_lut: Option, - fog_color: [f32; 3], - fog_density: f32, - bg_equirect: Option, - bg_color: [f32; 3], state: Option, } @@ -167,40 +249,9 @@ impl PassPBROpaque { staging_buffer: Vec::with_capacity( MAX_EXPECTED_PER_DRAW_INSTANCES * PER_DRAW_STRIDE_BYTES, ), - ibl_diffuse: None, - ibl_specular: None, - ibl_brdf_lut: None, - fog_color: [1.0; 3], - fog_density: 0.0, - bg_equirect: None, - bg_color: [1.0; 3], state: None, } } - - pub fn with_ibl( - mut self, - diffuse: RendererTextureHandle, - specular: RendererTextureHandle, - brdf_lut: RendererTextureHandle, - ) -> Self { - self.ibl_diffuse = Some(diffuse); - self.ibl_specular = Some(specular); - self.ibl_brdf_lut = Some(brdf_lut); - self - } - - pub fn with_fog(mut self, color: [f32; 3], density: f32) -> Self { - self.fog_color = color; - self.fog_density = density; - self - } - - pub fn with_background(mut self, equirect: RendererTextureHandle, color: [f32; 3]) -> Self { - self.bg_equirect = Some(equirect); - self.bg_color = color; - self - } } /// Returns the initialized pass state; panics in debug if `init` was not called. @@ -457,42 +508,38 @@ impl Pass for PassPBROpaque { // Register 1px neutral-gray fallbacks for any missing IBL handle, // then get owned views (texture lifetime managed by renderer's pass_textures). - let irr_h = self.ibl_diffuse.unwrap_or_else(|| { - renderer.create_texture_from_pixels( - "diffuse_ibl_fallback", - &[DEFAULT_IBL_FALLBACK_PIXEL], - 1, - 1, - wgpu::TextureFormat::Rgba8Unorm, - ) - }); - let pre_h = self.ibl_specular.unwrap_or_else(|| { - renderer.create_texture_from_pixels( - "specular_ibl_fallback", - &[DEFAULT_IBL_FALLBACK_PIXEL], - 1, - 1, - wgpu::TextureFormat::Rgba8Unorm, - ) - }); - let lut_h = self.ibl_brdf_lut.unwrap_or_else(|| { - renderer.create_texture_from_pixels( - "brdf_lut_fallback", - &[DEFAULT_BRDF_LUT_FALLBACK_PIXEL], - 1, - 1, - wgpu::TextureFormat::Rgba8Unorm, - ) - }); + // Initial bind group is built from neutral fallbacks; draw() rebuilds it from + // RenderStateComponent's IBL handles the first frame (and whenever they change). + let irr_h = renderer.create_texture_from_pixels( + "diffuse_ibl_fallback", + &[DEFAULT_IBL_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ); + let pre_h = renderer.create_texture_from_pixels( + "specular_ibl_fallback", + &[DEFAULT_IBL_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ); + let lut_h = renderer.create_texture_from_pixels( + "brdf_lut_fallback", + &[DEFAULT_BRDF_LUT_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba8Unorm, + ); let irradiance_view = renderer .get_texture_view(irr_h) - .expect("ibl_diffuse handle invalid"); + .expect("ibl_diffuse fallback invalid"); let prefilter_view = renderer .get_texture_view(pre_h) - .expect("ibl_specular handle invalid"); + .expect("ibl_specular fallback invalid"); let brdf_lut_view = renderer .get_texture_view(lut_h) - .expect("ibl_brdf_lut handle invalid"); + .expect("ibl_brdf_lut fallback invalid"); let prefilter_sampler = { let device = renderer.get_device(); @@ -509,49 +556,24 @@ impl Pass for PassPBROpaque { }) }; - let globals_bind_group = { - let layout = &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_GLOBALS]; - let device = renderer.get_device(); - device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("pass_pbr_opaque_globals_bind_group"), - layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: camera_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(&irradiance_view), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::Sampler(&ibl_sampler), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: wgpu::BindingResource::TextureView(&prefilter_view), - }, - wgpu::BindGroupEntry { - binding: 4, - resource: wgpu::BindingResource::Sampler(&prefilter_sampler), - }, - wgpu::BindGroupEntry { - binding: 5, - resource: wgpu::BindingResource::TextureView(&brdf_lut_view), - }, - wgpu::BindGroupEntry { - binding: 6, - resource: wgpu::BindingResource::Sampler(&ibl_sampler), - }, - ], - }) - }; + let globals_bind_group = build_globals_bind_group( + renderer.get_device(), + &pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_GLOBALS], + &camera_buffer, + &irradiance_view, + &prefilter_view, + &brdf_lut_view, + &ibl_sampler, + &prefilter_sampler, + ); + let cached_ibl = [irr_h, pre_h, lut_h]; self.depth_texture = Some(renderer.create_depth_texture("pass_pbr_opaque_depth")?); - // Create background sub-draw state if an equirect handle was provided. - let bg = if let Some(equirect_h) = self.bg_equirect { + // Background sub-draw is always created; its equirect comes from RenderStateComponent.background + // each frame (draw() rebuilds the bind group when the handle changes). Initial bind group uses + // a 1px fallback equirect. + let bg = { let bg_vs = include_str!("../../res/shaders/background_vertex.wgsl"); let bg_fs = include_str!("../../res/shaders/background_fragment.wgsl"); let bg_bind_groups = vec![vec![ @@ -618,9 +640,16 @@ impl Pass for PassPBROpaque { unclipped_depth: false, }, })?; + let equirect_fallback_h = renderer.create_texture_from_pixels( + "bg_equirect_fallback", + &[DEFAULT_EQUIRECT_FALLBACK_PIXEL], + 1, + 1, + wgpu::TextureFormat::Rgba32Float, + ); let equirect_view = renderer - .get_texture_view(equirect_h) - .expect("bg equirect handle invalid"); + .get_texture_view(equirect_fallback_h) + .expect("bg equirect fallback invalid"); let bg_sampler = renderer .get_device() .create_sampler(&wgpu::SamplerDescriptor { @@ -640,37 +669,20 @@ impl Pass for PassPBROpaque { usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let bg_bind_group = { - let layout = &bg_pipeline.bind_group_layouts[0]; - renderer - .get_device() - .create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("pass_pbr_opaque_bg_bind_group"), - layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: bg_camera_buf.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(&equirect_view), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::Sampler(&bg_sampler), - }, - ], - }) - }; - Some(BgSubState { + let bg_bind_group = build_bg_bind_group( + renderer.get_device(), + &bg_pipeline.bind_group_layouts[0], + &bg_camera_buf, + &equirect_view, + &bg_sampler, + ); + BgSubState { pipeline: bg_pipeline, camera_buf: bg_camera_buf, bind_group: bg_bind_group, - _sampler: bg_sampler, - }) - } else { - None + sampler: bg_sampler, + cached_bg: equirect_fallback_h, + } }; self.state = Some(PassPBROpaqueState { @@ -679,9 +691,11 @@ impl Pass for PassPBROpaque { globals_bind_group, pipeline, ibl_sampler, + prefilter_sampler, per_draw_buffer, per_draw_bind_group, bg, + cached_ibl, }); Ok(()) @@ -695,10 +709,64 @@ impl Pass for PassPBROpaque { view: &wgpu::TextureView, world: &WorldQuery<'_>, ) -> Result<()> { - // Query the component storages this pass needs. + // Query per-entity storages + the global render state (bg/IBL/fog) this pass needs. let camera_components = world.query::()?; let transform_components = world.query::()?; let pbr_renderable_components = world.query::()?; + let render_state = world.get_global::()?; + + // Rebuild the IBL globals bind group only when the component's handles change (steady state: 0). + { + let ibl = [ + render_state.ibl_diffuse, + render_state.ibl_specular, + render_state.ibl_brdf_lut, + ]; + if get_state(self).cached_ibl != ibl { + let irradiance_view = renderer + .get_texture_view(ibl[0]) + .expect("ibl_diffuse handle invalid"); + let prefilter_view = renderer + .get_texture_view(ibl[1]) + .expect("ibl_specular handle invalid"); + let brdf_lut_view = renderer + .get_texture_view(ibl[2]) + .expect("ibl_brdf_lut handle invalid"); + let device = renderer.get_device(); + let state = get_state(self); + state.globals_bind_group = build_globals_bind_group( + device, + &state.pipeline.bind_group_layouts[MATERIAL_BIND_GROUP_GLOBALS], + &state.camera_buffer, + &irradiance_view, + &prefilter_view, + &brdf_lut_view, + &state.ibl_sampler, + &state.prefilter_sampler, + ); + state.cached_ibl = ibl; + } + } + + // Rebuild the background bind group only when the component's equirect handle changes. + { + let bg_h = render_state.background; + if get_state(self).bg.cached_bg != bg_h { + let equirect_view = renderer + .get_texture_view(bg_h) + .expect("background handle invalid"); + let device = renderer.get_device(); + let state = get_state(self); + state.bg.bind_group = build_bg_bind_group( + device, + &state.bg.pipeline.bind_group_layouts[0], + &state.bg.camera_buf, + &equirect_view, + &state.bg.sampler, + ); + state.bg.cached_bg = bg_h; + } + } // Read active camera and transform. let active_camera_index = world.active_camera.data().index as usize; @@ -717,8 +785,9 @@ impl Pass for PassPBROpaque { // Update camera uniform and write to GPU buffer. MVP = viewProjection·model is computed // in the vertex shader, so the CPU never multiplies per-entity matrices. - let fog_color = self.fog_color; - let fog_density = self.fog_density; + // Fog fades to the background color (no separate fog_color); read from the component. + let fog_color = render_state.bg_color; + let fog_density = render_state.fog_density; { let state = get_state(self); state @@ -925,7 +994,8 @@ impl Pass for PassPBROpaque { // Background sub-draw: within the same render pass, after opaque geometry. // LessEqual depth test + write_enabled=false: fills only pixels where depth == 1.0 (far plane = no geometry). - if let Some(bg) = &state_ref.bg { + { + let bg = &state_ref.bg; let eye = Vec3::new( active_camera_transform.position.x, active_camera_transform.position.y, @@ -948,7 +1018,7 @@ impl Pass for PassPBROpaque { aspect: active_camera_component.aspect.get_value(), fwd: fwd.to_array(), _pad: 0.0, - bg_color: self.bg_color, + bg_color: render_state.bg_color, _pad2: 0.0, }; renderer diff --git a/engine/pill_engine/src/graphics/renderer.rs b/engine/pill_engine/src/graphics/renderer.rs index 7a4f935a..78ab7d87 100644 --- a/engine/pill_engine/src/graphics/renderer.rs +++ b/engine/pill_engine/src/graphics/renderer.rs @@ -3,11 +3,13 @@ use crate::ecs::EguiClient; use crate::{ app_config::EngineConfig, - ecs::{Component, ComponentStorage, EntityHandle, Scene}, + ecs::{ + Component, ComponentStorage, EntityHandle, GlobalComponent, GlobalComponentStorage, Scene, + }, resources::{ResourceManager, ShaderParameterSlot, ShaderTextureSlot, TextureType}, }; -use pill_core::Timer; +use pill_core::{PillTypeMap, Timer}; use pill_core::Result; use std::{collections::HashMap, sync::Arc}; @@ -44,12 +46,14 @@ pub struct WorldQuery<'a> { pub delta_time: f32, pub resources: &'a ResourceManager, scene: &'a Scene, + globals: &'a PillTypeMap, } impl<'a> WorldQuery<'a> { pub fn new( active_camera: EntityHandle, scene: &'a Scene, + globals: &'a PillTypeMap, delta_time: f32, resources: &'a ResourceManager, ) -> Self { @@ -58,16 +62,25 @@ impl<'a> WorldQuery<'a> { delta_time, resources, scene, + globals, } } - /// Borrow the storage for component type `T` from the active scene. + /// Borrow the storage for per-entity component type `T` from the active scene. pub fn query(&self) -> Result<&'a ComponentStorage> where T: Component>, { self.scene.get_component_storage::() } + + /// Borrow a global component `T` (e.g. RenderStateComponent) from the world. + pub fn get_global(&self) -> Result<&'a T> + where + T: GlobalComponent>, + { + crate::ecs::get_global_component_from::(self.globals) + } } #[derive(Clone, Copy)] @@ -169,6 +182,7 @@ pub trait PillRenderer { &mut self, active_camera_entity_handle: EntityHandle, scene: &Scene, + globals: &PillTypeMap, delta_time: f32, timer: &mut Timer, resource_manager: &ResourceManager, diff --git a/engine/pill_engine/src/renderer/state.rs b/engine/pill_engine/src/renderer/state.rs index 773743ef..cd363643 100644 --- a/engine/pill_engine/src/renderer/state.rs +++ b/engine/pill_engine/src/renderer/state.rs @@ -113,6 +113,7 @@ impl PillRenderer for Renderer { &mut self, active_camera_entity_handle: EntityHandle, scene: &crate::ecs::Scene, + globals: &pill_core::PillTypeMap, delta_time: f32, timer: &mut Timer, resource_manager: &ResourceManager, @@ -147,6 +148,7 @@ impl PillRenderer for Renderer { let world = WorldQuery::new( active_camera_entity_handle, scene, + globals, delta_time, resource_manager, ); From 3ecedfab874be647e145e0f89827b89ea7bce36e Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 31 May 2026 18:49:11 +0200 Subject: [PATCH 24/31] feat: rust safety review --- engine/Cargo.toml | 2 +- engine/pill_engine/src/graphics/pass_egui.rs | 7 +++---- engine/pill_engine/src/graphics/pass_mesh.rs | 21 +++++++++---------- .../src/graphics/pass_pbr_opaque.rs | 14 +++++-------- .../res/templates/wasm/src/lib.rs | 1 + examples/pbr_helmet/build.rs | 10 +++++---- examples/pbr_helmet/src/bake.rs | 2 ++ 7 files changed, 28 insertions(+), 29 deletions(-) diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 5e95497d..99836cbc 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,7 +8,7 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "/Users/mk/dev/demo/Pill-Engine/examples/pbr_helmet", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) + "/Users/mk/dev/demo/Pill-Engine/examples/pill_tunel", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at diff --git a/engine/pill_engine/src/graphics/pass_egui.rs b/engine/pill_engine/src/graphics/pass_egui.rs index fadb835d..1841299d 100644 --- a/engine/pill_engine/src/graphics/pass_egui.rs +++ b/engine/pill_engine/src/graphics/pass_egui.rs @@ -98,7 +98,7 @@ impl EguiDrawer { self.renderer .update_buffers(device, queue, encoder, &tris, &screen_descriptor); - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + let render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: window_surface_view, resolve_target: None, @@ -115,11 +115,10 @@ impl EguiDrawer { timer.record("Render"); - let render_pass: &mut wgpu::RenderPass<'static> = - unsafe { std::mem::transmute(&mut render_pass) }; + let mut render_pass = render_pass.forget_lifetime(); self.renderer - .render(&mut *render_pass, &tris, &screen_descriptor); + .render(&mut render_pass, &tris, &screen_descriptor); for texture_id in &full_output.textures_delta.free { self.renderer.free_texture(texture_id) diff --git a/engine/pill_engine/src/graphics/pass_mesh.rs b/engine/pill_engine/src/graphics/pass_mesh.rs index c1380b1e..408061bf 100644 --- a/engine/pill_engine/src/graphics/pass_mesh.rs +++ b/engine/pill_engine/src/graphics/pass_mesh.rs @@ -261,7 +261,7 @@ impl Pass for PassMesh { debug!(LogContext::Frame => "Recording mesh draw commands"); - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + let render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("pass_mesh"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view, @@ -290,8 +290,7 @@ impl Pass for PassMesh { let mut current_drawing_context = DrawingContext::default(); - let render_pass: &mut wgpu::RenderPass<'static> = - unsafe { std::mem::transmute(&mut render_pass) }; + let mut render_pass = render_pass.forget_lifetime(); // Build this pass's draw list by querying the world (data-driven; no engine-global queue): // every entity with a PbrRenderableComponent is an instance. @@ -355,43 +354,43 @@ impl Pass for PassMesh { ); if current_drawing_context.rendering_order > key_fields.order { - current_drawing_context.record_draw_accumulated_instances(render_pass); + current_drawing_context.record_draw_accumulated_instances(&mut render_pass); current_drawing_context.change_rendering_order(key_fields.order); } if current_drawing_context.shader_handle != Some(renderer_shader_handle) { - current_drawing_context.record_draw_accumulated_instances(render_pass); + current_drawing_context.record_draw_accumulated_instances(&mut render_pass); current_drawing_context.change_shader( world.resources, renderer.get_engine_parameters(), renderer_shader_handle, - render_pass, + &mut render_pass, self.camera.as_ref().unwrap(), ); } if current_drawing_context.material_handle != Some(renderer_material_handle) { - current_drawing_context.record_draw_accumulated_instances(render_pass); + current_drawing_context.record_draw_accumulated_instances(&mut render_pass); current_drawing_context.change_material( world.resources, renderer_material_handle, - render_pass, + &mut render_pass, ); } if current_drawing_context.mesh_handle != Some(renderer_mesh_handle) { - current_drawing_context.record_draw_accumulated_instances(render_pass); + current_drawing_context.record_draw_accumulated_instances(&mut render_pass); current_drawing_context.change_mesh( world.resources, renderer_mesh_handle, - render_pass, + &mut render_pass, ); } current_drawing_context.accumulate_instance(); if j == batch_size - 1 { - current_drawing_context.record_draw_accumulated_instances(render_pass); + current_drawing_context.record_draw_accumulated_instances(&mut render_pass); } } } diff --git a/engine/pill_engine/src/graphics/pass_pbr_opaque.rs b/engine/pill_engine/src/graphics/pass_pbr_opaque.rs index fa18f7b0..11808e5a 100644 --- a/engine/pill_engine/src/graphics/pass_pbr_opaque.rs +++ b/engine/pill_engine/src/graphics/pass_pbr_opaque.rs @@ -78,14 +78,12 @@ impl CameraUniform { /// Per-draw storage entry: raw pos/rot/scale (48 B). GPU builds model = T·R·S and MVP. #[repr(C)] -#[derive(Copy, Clone)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub(crate) struct PerDrawStd140 { position: [f32; 4], // xyz rotation: [f32; 4], // xyz, radians scale: [f32; 4], // xyz } -unsafe impl bytemuck::Zeroable for PerDrawStd140 {} -unsafe impl bytemuck::Pod for PerDrawStd140 {} // Storage buffer (StructuredBuffer / var) indexed by SV_InstanceID replaces // dynamic-offset UBO: eliminates Metal's 256-B alignment tax and 120k per-instance API calls. @@ -815,13 +813,11 @@ impl Pass for PassPBROpaque { let transforms = &transform_components.data; let pbrs = &pbr_renderable_components.data; for (slot_transform, slot_pbr) in transforms.iter().zip(pbrs.iter()) { - let transform = match slot_transform { - Some(transform) => transform, - None => continue, + let Some(transform) = slot_transform else { + continue; }; - let pbr = match slot_pbr { - Some(pbr) => pbr, - None => continue, + let Some(pbr) = slot_pbr else { + continue; }; let Some(key) = pbr.render_queue_key else { continue; diff --git a/engine/pill_launcher/res/templates/wasm/src/lib.rs b/engine/pill_launcher/res/templates/wasm/src/lib.rs index 0aa14b12..ab55531f 100644 --- a/engine/pill_launcher/res/templates/wasm/src/lib.rs +++ b/engine/pill_launcher/res/templates/wasm/src/lib.rs @@ -12,6 +12,7 @@ use wasm_bindgen::prelude::*; #[cfg(target_arch = "wasm32")] #[global_allocator] +// SAFETY: wasm32 is single-threaded, so assuming single-threaded allocator access is sound. static ALLOC: lol_alloc::AssumeSingleThreaded = unsafe { lol_alloc::AssumeSingleThreaded::new(lol_alloc::FreeListAllocator::new()) }; diff --git a/examples/pbr_helmet/build.rs b/examples/pbr_helmet/build.rs index 2fecca9b..56d23b89 100644 --- a/examples/pbr_helmet/build.rs +++ b/examples/pbr_helmet/build.rs @@ -45,11 +45,13 @@ fn main() { let mut out = std::fs::File::create(&f32bin).unwrap(); out.write_all(&out_w.to_le_bytes()).unwrap(); out.write_all(&out_h.to_le_bytes()).unwrap(); + let mut row = Vec::with_capacity((out_w * 4 * 4) as usize); for px in resized.pixels() { - out.write_all(&px.0[0].to_le_bytes()).unwrap(); - out.write_all(&px.0[1].to_le_bytes()).unwrap(); - out.write_all(&px.0[2].to_le_bytes()).unwrap(); - out.write_all(&1.0f32.to_le_bytes()).unwrap(); + row.extend_from_slice(&px.0[0].to_le_bytes()); + row.extend_from_slice(&px.0[1].to_le_bytes()); + row.extend_from_slice(&px.0[2].to_le_bytes()); + row.extend_from_slice(&1.0f32.to_le_bytes()); } + out.write_all(&row).unwrap(); } } diff --git a/examples/pbr_helmet/src/bake.rs b/examples/pbr_helmet/src/bake.rs index cfac5903..28da923b 100644 --- a/examples/pbr_helmet/src/bake.rs +++ b/examples/pbr_helmet/src/bake.rs @@ -3,10 +3,12 @@ use std::f32::consts::PI; /// Loads the studio equirect panorama embedded at compile time from the build script's OUT_DIR. /// Returns RGBA f32 pixels + (width, height). pub fn load_equirect() -> (Vec, u32, u32) { + // Blob layout written by build.rs: [u32 width][u32 height][f32 RGBA pixels...], all little-endian. let raw: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/equirect.f32bin")); let w = u32::from_le_bytes(raw[0..4].try_into().unwrap()); let h = u32::from_le_bytes(raw[4..8].try_into().unwrap()); let pixel_bytes = &raw[8..]; + // Reinterpret every 4 bytes as one f32 (chunks_exact(4) walks the blob 4 bytes at a time). let pixels: Vec = pixel_bytes .chunks_exact(4) .map(|b| f32::from_le_bytes(b.try_into().unwrap())) From 9ef885131db8ccc54f9c84851d828969c168de5b Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 31 May 2026 19:47:42 +0200 Subject: [PATCH 25/31] add pbr_balls, pbr_helmet to wasm ci --- .github/workflows/ci.yml | 4 ++++ engine/Cargo.toml | 2 +- engine/pill_engine/Cargo.toml | 4 +++- .../ecs/components/render_state_component.rs | 6 +++++ .../src/ecs/systems/rendering_system.rs | 1 - examples/pbr_balls/src/bake.rs | 10 +++------ examples/pbr_balls/src/game.rs | 17 +++++++------- examples/pbr_helmet/src/game.rs | 22 +++++++++---------- examples/pill_tunel/res/config.ini | 4 ++-- examples/pill_tunel/src/game.rs | 2 +- 10 files changed, 40 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a53f0a43..ea49da63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,8 @@ jobs: example: - examples/cube - examples/pill_tunel + - examples/pbr_helmet + - examples/pbr_balls steps: - uses: actions/checkout@v4 @@ -176,6 +178,8 @@ jobs: workspaces: | examples/cube/build/wasm/.build/pill_web -> target examples/pill_tunel/build/wasm/.build/pill_web -> target + examples/pbr_helmet/build/wasm/.build/pill_web -> target + examples/pbr_balls/build/wasm/.build/pill_web -> target - uses: jetli/wasm-pack-action@v0.4.0 with: diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 99836cbc..00888499 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,7 +8,7 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "/Users/mk/dev/demo/Pill-Engine/examples/pill_tunel", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) + "/Users/mk/dev/demo/Pill-Engine/examples/pbr_balls", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at diff --git a/engine/pill_engine/Cargo.toml b/engine/pill_engine/Cargo.toml index fa2515f7..4ae61672 100644 --- a/engine/pill_engine/Cargo.toml +++ b/engine/pill_engine/Cargo.toml @@ -33,7 +33,7 @@ web-time = "1.1" # Optional deps activated by feature flags egui = { version = "0.32.1", optional = true } egui-wgpu = { version = "0.32.1", features = ["winit"], optional = true } -egui-winit = { version = "0.32.1", default-features = false, features = ["clipboard"], optional = true } +egui-winit = { version = "0.32.1", default-features = false, optional = true } # Native-only dependencies (not available on WASM) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] @@ -41,6 +41,8 @@ png = "0.17" rodio = { version = "0.20", features = ["wav", "mp3"] } gilrs = "0.11.0" pollster = "0.2" +# clipboard pulls arboard, which has no wasm32 backend — desktop only (powers the egui [C] copy button) +egui-winit = { version = "0.32.1", default-features = false, features = ["clipboard"], optional = true } [build-dependencies] diff --git a/engine/pill_engine/src/ecs/components/render_state_component.rs b/engine/pill_engine/src/ecs/components/render_state_component.rs index 2f41c479..9293a9ce 100644 --- a/engine/pill_engine/src/ecs/components/render_state_component.rs +++ b/engine/pill_engine/src/ecs/components/render_state_component.rs @@ -30,6 +30,12 @@ impl RenderStateComponent { } } +impl Default for RenderStateComponent { + fn default() -> Self { + Self::new() + } +} + impl PillTypeMapKey for RenderStateComponent { type Storage = GlobalComponentStorage; } diff --git a/engine/pill_engine/src/ecs/systems/rendering_system.rs b/engine/pill_engine/src/ecs/systems/rendering_system.rs index b1949739..e5dc7d1a 100644 --- a/engine/pill_engine/src/ecs/systems/rendering_system.rs +++ b/engine/pill_engine/src/ecs/systems/rendering_system.rs @@ -6,7 +6,6 @@ use crate::{ }; use pill_core::{EngineError, RendererError, Timer}; -use wgpu; use pill_core::Result; diff --git a/examples/pbr_balls/src/bake.rs b/examples/pbr_balls/src/bake.rs index c5bb7cf3..e66b7c50 100644 --- a/examples/pbr_balls/src/bake.rs +++ b/examples/pbr_balls/src/bake.rs @@ -15,8 +15,8 @@ const RIM_U: f32 = 0.60; const RIM_V: f32 = 0.25; const RIM_INTENSITY: [f32; 3] = [1.5, 2.5, 5.0]; // Reinhard → [0.60, 0.71, 0.83] cool blue -const LIGHT_K: f32 = 8.0; // glare falloff exponent -const KEY_SKY: [f32; 3] = [0.010, 0.060, 0.200]; // 0.2 * vec3(0.05, 0.3, 1.) — cool blue haze away from key +const LIGHT_K: f32 = 8.0; // glare falloff exponent +const KEY_SKY: [f32; 3] = [0.010, 0.060, 0.200]; // 0.2 * vec3(0.05, 0.3, 1.) — cool blue haze away from key /// Generates the studio equirect panorama as Rgba32Float pixels (512×256 linear HDR). pub fn generate() -> (Vec, u32, u32) { @@ -57,11 +57,7 @@ pub fn generate() -> (Vec, u32, u32) { /// Bakes IBL maps from an Rgba32Float equirect panorama. /// Returns (diffuse 32×16, specular mips [128×64 → 8×4], brdf_lut 256×256), all Rgba32Float. -pub fn bake_all( - equirect: &[f32], - width: u32, - height: u32, -) -> (Vec, Vec>, Vec) { +pub fn bake_all(equirect: &[f32], width: u32, height: u32) -> (Vec, Vec>, Vec) { let eq = Equirect { pixels: equirect.to_vec(), w: width, diff --git a/examples/pbr_balls/src/game.rs b/examples/pbr_balls/src/game.rs index 7d10c876..668df37c 100644 --- a/examples/pbr_balls/src/game.rs +++ b/examples/pbr_balls/src/game.rs @@ -17,7 +17,8 @@ impl PillGame for Game { let bg_h = engine.create_gpu_texture_f32("equirect", &eq, eq_w, eq_h)?; let diff_h = engine.create_gpu_texture_f32("diffuse_ibl", &diffuse, 32, 16)?; - let spec_h = engine.create_gpu_mipped_texture_f32("specular_ibl", &specular_mips, 128, 64)?; + let spec_h = + engine.create_gpu_mipped_texture_f32("specular_ibl", &specular_mips, 128, 64)?; let lut_h = engine.create_gpu_texture_f32("brdf_lut", &brdf_lut, 256, 256)?; let rs = engine.get_global_component_mut::()?; @@ -36,21 +37,21 @@ impl PillGame for Game { engine.add_system("orbit_camera", orbit_camera_system)?; - let mesh_handle = engine.add_resource(Mesh::new( + let mesh_handle = engine.add_resource(Mesh::from_cooked_mesh_bytes( "spheres_mesh", - "models/MetalRoughSpheres.cooked_mesh".into(), - ))?; + include_bytes!("../res/models/MetalRoughSpheres.cooked_mesh"), + )?)?; - let albedo_handle = engine.add_resource(Texture::new( + let albedo_handle = engine.add_resource(Texture::from_bytes( "spheres_albedo", TextureType::Color, - ResourceLoader::Path("models/MetalRoughSpheres_albedo.cooked_tex".into()), + include_bytes!("../res/models/MetalRoughSpheres_albedo.cooked_tex"), ))?; - let mr_handle = engine.add_resource(Texture::new( + let mr_handle = engine.add_resource(Texture::from_bytes( "spheres_mr", TextureType::MetallicRoughness, - ResourceLoader::Path("models/MetalRoughSpheres_metallic_roughness.cooked_tex".into()), + include_bytes!("../res/models/MetalRoughSpheres_metallic_roughness.cooked_tex"), ))?; let mat_handle = engine.add_resource( diff --git a/examples/pbr_helmet/src/game.rs b/examples/pbr_helmet/src/game.rs index ff56e887..ea72604c 100644 --- a/examples/pbr_helmet/src/game.rs +++ b/examples/pbr_helmet/src/game.rs @@ -64,33 +64,33 @@ impl PillGame for Game { engine.register_component::(scene)?; engine.register_component::(scene)?; - let mesh_handle = engine.add_resource(Mesh::new( + let mesh_handle = engine.add_resource(Mesh::from_cooked_mesh_bytes( "helmet_mesh", - "models/DamagedHelmet.cooked_mesh".into(), - ))?; + include_bytes!("../res/models/DamagedHelmet.cooked_mesh"), + )?)?; - let albedo_handle = engine.add_resource(Texture::new( + let albedo_handle = engine.add_resource(Texture::from_bytes( "helmet_albedo", TextureType::Color, - ResourceLoader::Path("models/DamagedHelmet_albedo.cooked_tex".into()), + include_bytes!("../res/models/DamagedHelmet_albedo.cooked_tex"), ))?; - let normal_handle = engine.add_resource(Texture::new( + let normal_handle = engine.add_resource(Texture::from_bytes( "helmet_normal", TextureType::Normal, - ResourceLoader::Path("models/DamagedHelmet_normal.cooked_tex".into()), + include_bytes!("../res/models/DamagedHelmet_normal.cooked_tex"), ))?; - let metallic_roughness_handle = engine.add_resource(Texture::new( + let metallic_roughness_handle = engine.add_resource(Texture::from_bytes( "helmet_metallic_roughness", TextureType::MetallicRoughness, - ResourceLoader::Path("models/DamagedHelmet_metallic_roughness.cooked_tex".into()), + include_bytes!("../res/models/DamagedHelmet_metallic_roughness.cooked_tex"), ))?; - let emissive_handle = engine.add_resource(Texture::new( + let emissive_handle = engine.add_resource(Texture::from_bytes( "helmet_emissive", TextureType::Emissive, - ResourceLoader::Path("models/DamagedHelmet_emissive.cooked_tex".into()), + include_bytes!("../res/models/DamagedHelmet_emissive.cooked_tex"), ))?; let material_handle = engine.add_resource( diff --git a/examples/pill_tunel/res/config.ini b/examples/pill_tunel/res/config.ini index 0dc46ade..3e29c271 100644 --- a/examples/pill_tunel/res/config.ini +++ b/examples/pill_tunel/res/config.ini @@ -2,13 +2,13 @@ TITLE=Pill-Tunel # WINDOW -WINDOW_TITLE=Pill Engine - 60k Pills Benchmark +WINDOW_TITLE=Pill Engine - Pill Tunnel WINDOW_WIDTH=1920 WINDOW_HEIGHT=1200 WINDOW_FULLSCREEN=false # ENGINE GENERAL -MAX_ENTITIES=65000 +MAX_ENTITIES=2000 MAX_CAMERAS=10 MAX_CONCURRENT_2D_SOUNDS=10 MAX_CONCURRENT_3D_SOUNDS=10 diff --git a/examples/pill_tunel/src/game.rs b/examples/pill_tunel/src/game.rs index 55ec52ec..1eedb66e 100644 --- a/examples/pill_tunel/src/game.rs +++ b/examples/pill_tunel/src/game.rs @@ -19,7 +19,7 @@ const CAMERA_DRIFT_AMPLITUDE: (f32, f32) = (0.35, 0.25); // Particle tunnel — N pills streaming forward through a donut-shaped emitter. // Camera looks down +Z; pills start spread uniformly along Z so steady-state // density is reached on frame 1. Each pill wraps back to far when it crosses near. -const PILL_COUNT: usize = 60000; +const PILL_COUNT: usize = 1440; const EMITTER_RADIUS_MIN: f32 = 6.0; const EMITTER_RADIUS_MAX: f32 = 13.0; const TUNNEL_NEAR_Z: f32 = -3.0; From 256c3a4982fee4b255f8b3398b49e90f3b3f6d9a Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 31 May 2026 19:56:14 +0200 Subject: [PATCH 26/31] fix ci --- .../pill_engine/src/graphics/dummy_renderer.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/engine/pill_engine/src/graphics/dummy_renderer.rs b/engine/pill_engine/src/graphics/dummy_renderer.rs index c6aebd8b..99c85dae 100644 --- a/engine/pill_engine/src/graphics/dummy_renderer.rs +++ b/engine/pill_engine/src/graphics/dummy_renderer.rs @@ -11,6 +11,7 @@ use crate::{ use pill_core::Result; use pill_core::Timer; use std::collections::HashMap; +use std::sync::Arc; use winit::{dpi::PhysicalSize, window::Window}; pub struct DummyRenderer; @@ -114,4 +115,19 @@ impl PillRenderer for DummyRenderer { fn get_render_target_view(&self, _handle: RendererTextureHandle) -> Option<&wgpu::TextureView> { None } + + fn create_texture_from_pixels( + &mut self, + _name: &str, + _mip_pixels: &[&[u8]], + _base_width: u32, + _base_height: u32, + _format: wgpu::TextureFormat, + ) -> RendererTextureHandle { + RendererTextureHandle::default() + } + + fn get_texture_view(&self, _handle: RendererTextureHandle) -> Option { + None + } } From ba9fd216296a3cf158df2a2b3b15516470fb6815 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 31 May 2026 20:39:00 +0200 Subject: [PATCH 27/31] =?UTF-8?q?531=E2=86=92497=20K:=20str::to=5Fuppercas?= =?UTF-8?q?e/to=5Flowercase=20in=20config=20parsing=20pulled=20the=20full?= =?UTF-8?q?=20Unicode=20case-folding=20tables=20(~34=20KB=20of=20.rodata)?= =?UTF-8?q?=20into=20the=20binary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- engine/pill_engine/src/app_config.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/engine/pill_engine/src/app_config.rs b/engine/pill_engine/src/app_config.rs index 9f077625..4e374b1e 100644 --- a/engine/pill_engine/src/app_config.rs +++ b/engine/pill_engine/src/app_config.rs @@ -20,7 +20,7 @@ impl EngineConfig { continue; } if let Some(eq) = line.find('=') { - let key = line[..eq].trim().to_uppercase(); + let key = line[..eq].trim().to_ascii_uppercase(); let value = line[eq + 1..].trim().to_string(); values.insert(key, value); } @@ -29,13 +29,14 @@ impl EngineConfig { } pub fn set(&mut self, key: &str, value: i64) { - self.values.insert(key.to_uppercase(), value.to_string()); + self.values + .insert(key.to_ascii_uppercase(), value.to_string()); } pub fn get_int(&self, key: &str) -> Result { use pill_core::PillError; self.values - .get(&key.to_uppercase()) + .get(&key.to_ascii_uppercase()) .ok_or_else(|| -> PillError { format!("{key} not found in config").into() })? .parse::() .map_err(|e| -> PillError { @@ -47,9 +48,9 @@ impl EngineConfig { use pill_core::PillError; let v = self .values - .get(&key.to_uppercase()) + .get(&key.to_ascii_uppercase()) .ok_or_else(|| -> PillError { format!("{key} not found in config").into() })?; - match v.to_lowercase().as_str() { + match v.to_ascii_lowercase().as_str() { "true" | "1" | "yes" => Ok(true), "false" | "0" | "no" => Ok(false), _ => Err(format!("Config key {key} is not a valid bool: {v}").into()), From 23a72916a5e8782de36c77b1b46dfa57d39a0003 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 31 May 2026 21:25:12 +0200 Subject: [PATCH 28/31] =?UTF-8?q?501=E2=86=92494=20KB:=20strip=20symbol=20?= =?UTF-8?q?names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- engine/pill_launcher/src/wasm_build.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/engine/pill_launcher/src/wasm_build.rs b/engine/pill_launcher/src/wasm_build.rs index 70e11474..c9b70a6f 100644 --- a/engine/pill_launcher/src/wasm_build.rs +++ b/engine/pill_launcher/src/wasm_build.rs @@ -164,9 +164,10 @@ fn rewrite_scratch_manifest(scratch_pill_web_app_dir: &Path, game_dir: &Path) -> // parent workspace; resolver matches engine/Cargo.toml (wgpu feature // unification is sensitive to resolver version) // - [profile.release] + wasm-pack's wasm-opt flags for Tier 1 size opt. - // `strip` is NOT set here: the size report analyzes the pre-opt wasm - // and needs the function-names subsection. wasm-opt strips the shipped - // binary via --strip-debug --strip-producers. + // `strip = true` drops the function-names subsection from the pre-opt + // wasm too (~4 KB off the final binary), so the size report's per-crate + // attribution reads 0 B — accepted: shipping under the size budget beats + // the dev-only breakdown. The final binary is also wasm-opt --strip-debug'd. let mut f = OpenOptions::new() .append(true) .open(&manifest) @@ -181,6 +182,7 @@ fn rewrite_scratch_manifest(scratch_pill_web_app_dir: &Path, game_dir: &Path) -> "lto = \"fat\"\n", "codegen-units = 1\n", "panic = \"abort\"\n", + "strip = true\n", "\n[package.metadata.wasm-pack.profile.release]\n", "wasm-opt = [\"-Oz\", \"--strip-debug\", \"--strip-producers\", \"--enable-nontrapping-float-to-int\", \"--enable-bulk-memory\", \"--enable-sign-ext\", \"--enable-mutable-globals\", \"--enable-reference-types\"]\n", "\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\n", From a67da0ff01c56bae1deeda08167d5267b458bcef Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Mon, 1 Jun 2026 12:26:00 +0200 Subject: [PATCH 29/31] fix: examples Cargo.toml NO_PATH --- engine/Cargo.toml | 2 +- examples/cube/Cargo.toml | 2 +- examples/pbr_balls/Cargo.toml | 2 +- examples/pbr_helmet/Cargo.toml | 2 +- examples/pill_tunel/Cargo.toml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 00888499..31944895 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,7 +8,7 @@ members = [ "pill_native", "pill_web", "pill_runtime", - "/Users/mk/dev/demo/Pill-Engine/examples/pbr_balls", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) + "NO_PATH", ### Game project crate (This will be changed by Pill Launcher on build to allow proper compilation of game project) ] # The per-game wasm cdylib (pill_web_app) lives as a template at diff --git a/examples/cube/Cargo.toml b/examples/cube/Cargo.toml index 3433b742..241c1f4b 100644 --- a/examples/cube/Cargo.toml +++ b/examples/cube/Cargo.toml @@ -2,7 +2,7 @@ name = "pill_game" version = "0.1.0" edition = "2021" -workspace = "/Users/mk/dev/demo/Pill-Engine/engine" +workspace = "NO_PATH" [lib] crate-type = ["cdylib", "rlib"] diff --git a/examples/pbr_balls/Cargo.toml b/examples/pbr_balls/Cargo.toml index 12772bbb..b45a1996 100644 --- a/examples/pbr_balls/Cargo.toml +++ b/examples/pbr_balls/Cargo.toml @@ -2,7 +2,7 @@ name = "pill_game" version = "0.1.0" edition = "2021" -workspace = "/Users/mk/dev/demo/Pill-Engine/engine" +workspace = "NO_PATH" [lib] crate-type = ["cdylib", "rlib"] diff --git a/examples/pbr_helmet/Cargo.toml b/examples/pbr_helmet/Cargo.toml index ef6c9cd8..05577209 100644 --- a/examples/pbr_helmet/Cargo.toml +++ b/examples/pbr_helmet/Cargo.toml @@ -2,7 +2,7 @@ name = "pill_game" version = "0.1.0" edition = "2021" -workspace = "/Users/mk/dev/demo/Pill-Engine/engine" +workspace = "NO_PATH" [lib] crate-type = ["cdylib", "rlib"] diff --git a/examples/pill_tunel/Cargo.toml b/examples/pill_tunel/Cargo.toml index 6666a300..f16f85e6 100644 --- a/examples/pill_tunel/Cargo.toml +++ b/examples/pill_tunel/Cargo.toml @@ -2,7 +2,7 @@ name = "pill_game" version = "0.1.0" edition = "2021" -workspace = "/Users/mk/dev/demo/Pill-Engine/engine" +workspace = "NO_PATH" [lib] crate-type = ["cdylib", "rlib"] From ce88a4419116155326fcc4b8016ef1f254118bab Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 7 Jun 2026 17:17:00 +0200 Subject: [PATCH 30/31] feat: review --- engine/pill_assets/src/lib.rs | 2 +- .../pill_assets/src/rules/equirect_to_ibl.rs | 387 ++++++++++-------- .../src/rules/glb_to_cooked_mesh.rs | 275 +++++++------ engine/pill_assets/src/rules/mod.rs | 8 +- .../src/rules/procedural_equirect.rs | 169 ++++++++ .../pill_assets/src/rules/studio_equirect.rs | 155 ------- .../res/shaders/background_fragment.hlsl | 32 +- .../res/shaders/background_vertex.hlsl | 18 +- .../res/shaders/include/equirect.hlsl | 12 +- .../res/shaders/pbr_opaque_fragment.hlsl | 176 ++++---- .../res/shaders/pbr_opaque_vertex.hlsl | 50 +-- .../res/shaders/tonemap_fragment.hlsl | 12 +- .../res/shaders/tonemap_vertex.hlsl | 18 +- ...udio.studio => studio.procedural_equirect} | 0 .../src/graphics/pass_background.rs | 100 ++--- ...udio.studio => studio.procedural_equirect} | 0 ...udio.studio => studio.procedural_equirect} | 0 examples/pbr_balls/src/game.rs | 60 +-- 18 files changed, 779 insertions(+), 695 deletions(-) create mode 100644 engine/pill_assets/src/rules/procedural_equirect.rs delete mode 100644 engine/pill_assets/src/rules/studio_equirect.rs rename engine/pill_engine/res/textures/{studio.studio => studio.procedural_equirect} (100%) rename examples/pbr_balls/res/models/{studio.studio => studio.procedural_equirect} (100%) rename examples/pbr_balls/res/{studio.studio => studio.procedural_equirect} (100%) diff --git a/engine/pill_assets/src/lib.rs b/engine/pill_assets/src/lib.rs index 575214f1..42bc4c64 100644 --- a/engine/pill_assets/src/lib.rs +++ b/engine/pill_assets/src/lib.rs @@ -21,7 +21,7 @@ pub mod rules; pub use rules::{ default_rules, EquirectToIBL, GlbToCookedMesh, HlslToWgsl, ObjToCookedMesh, PngToCookedTex, - StudioEquirect, + ProceduralEquirect, }; pub trait Rule { diff --git a/engine/pill_assets/src/rules/equirect_to_ibl.rs b/engine/pill_assets/src/rules/equirect_to_ibl.rs index 07d238e0..c5ad4ad0 100644 --- a/engine/pill_assets/src/rules/equirect_to_ibl.rs +++ b/engine/pill_assets/src/rules/equirect_to_ibl.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; -use super::studio_equirect::{linear_to_srgb, srgb_to_linear, write_rtex, write_rtex_hdr_mip}; +use super::procedural_equirect::{linear_to_srgb, srgb_to_linear, write_rtex, write_rtex_hdr_mip}; use crate::Rule; pub struct EquirectToIBL; @@ -26,30 +26,32 @@ impl Rule for EquirectToIBL { fn build(&self, input: &Path, output: &Path) -> Result<()> { let equirect = Equirect::load(input)?; - let dir = output.parent().unwrap(); + let directory = output.parent().unwrap(); let stem = input.file_stem().unwrap().to_str().unwrap(); let base = stem.strip_suffix("_equirect").unwrap_or(stem); // 1. Diffuse irradiance (32×16 sRGB) — anchor output - let irr = compute_irradiance(&equirect, 32, 16); - write_rtex(output, 32, 16, &irr)?; + let irradiance = compute_irradiance(&equirect, 32, 16); + write_rtex(output, 32, 16, &irradiance)?; // 2. Specular prefilter HDR mip chain (RTEX v4, Rgba32Float). // 5 mip levels; mip i roughness = i/4, matching shader roughness * MAX_REFLECTION_LOD(4). const MIP_ROUGHNESS: [f32; 5] = [0.04, 0.25, 0.5, 0.75, 1.0]; - let spec_path = dir.join(format!("{base}_specular_ibl.cooked_tex")); - let mut spec_mips: Vec> = Vec::with_capacity(5); - for (mip, &r) in MIP_ROUGHNESS.iter().enumerate() { - let w = (128u32 >> mip).max(1); - let h = (64u32 >> mip).max(1); - spec_mips.push(compute_specular_prefilter(&equirect, w, h, r)); + let specular_path = directory.join(format!("{base}_specular_ibl.cooked_tex")); + let mut specular_mips: Vec> = Vec::with_capacity(5); + for (mip_level, &roughness) in MIP_ROUGHNESS.iter().enumerate() { + let width = (128u32 >> mip_level).max(1); + let height = (64u32 >> mip_level).max(1); + specular_mips.push(compute_specular_prefilter( + &equirect, width, height, roughness, + )); } - write_rtex_hdr_mip(&spec_path, 128, 64, &spec_mips)?; + write_rtex_hdr_mip(&specular_path, 128, 64, &specular_mips)?; // 3. BRDF LUT (256×256 linear) — split-sum preintegration, environment-independent - let lut = compute_brdf_lut(256, 256); - let lut_path = dir.join("brdf_lut.cooked_tex"); - write_rtex(&lut_path, 256, 256, &lut)?; + let brdf_lut = compute_brdf_lut(256, 256); + let brdf_lut_path = directory.join("brdf_lut.cooked_tex"); + write_rtex(&brdf_lut_path, 256, 256, &brdf_lut)?; Ok(()) } @@ -59,8 +61,8 @@ impl Rule for EquirectToIBL { struct Equirect { pixels: Vec, // flat RGBA f32 linear - w: u32, - h: u32, + width: u32, + height: u32, } impl Equirect { @@ -70,96 +72,110 @@ impl Equirect { bail!("invalid RTEX in {path:?}"); } let version = u32::from_le_bytes(data[4..8].try_into().unwrap()); - let w = u32::from_le_bytes(data[8..12].try_into().unwrap()); - let h = u32::from_le_bytes(data[12..16].try_into().unwrap()); + let width = u32::from_le_bytes(data[8..12].try_into().unwrap()); + let height = u32::from_le_bytes(data[12..16].try_into().unwrap()); let pixels = if version == 2 { // v2: Rgba32Float (4 bytes per channel, f32 LE) data[16..] .chunks_exact(4) - .map(|b| f32::from_le_bytes(b.try_into().unwrap())) + .map(|chunk| f32::from_le_bytes(chunk.try_into().unwrap())) .collect() } else { // v1: Rgba8UnormSrgb — decode to linear f32 at load time data[16..] .iter() - .map(|&b| srgb_to_linear(b as f32 / 255.0)) + .map(|&byte| srgb_to_linear(byte as f32 / 255.0)) .collect() }; - Ok(Self { pixels, w, h }) + Ok(Self { + pixels, + width, + height, + }) } // Sample at a world-space direction; nearest-neighbor, returns linear HDR values. - fn sample(&self, dir: [f32; 3]) -> [f32; 3] { - let (u, v) = dir_to_equirect_uv(dir); - let x = ((u * self.w as f32) as u32).min(self.w - 1); - let y = ((v * self.h as f32) as u32).min(self.h - 1); - let i = ((y * self.w + x) * 4) as usize; - [self.pixels[i], self.pixels[i + 1], self.pixels[i + 2]] + fn sample(&self, direction: [f32; 3]) -> [f32; 3] { + let (u_coordinate, v_coordinate) = dir_to_equirect_uv(direction); + let x = ((u_coordinate * self.width as f32) as u32).min(self.width - 1); + let y = ((v_coordinate * self.height as f32) as u32).min(self.height - 1); + let pixel_index = ((y * self.width + x) * 4) as usize; + [ + self.pixels[pixel_index], + self.pixels[pixel_index + 1], + self.pixels[pixel_index + 2], + ] } } // --- Coordinate helpers --- -fn equirect_uv_to_dir(u: f32, v: f32) -> [f32; 3] { - let az = (u - 0.5) * 2.0 * PI; // consistent with dir_to_equirect_uv: u = 0.5 + az/(2π) - let elev = (0.5 - v) * PI; - let cos_e = elev.cos(); - [cos_e * az.cos(), elev.sin(), cos_e * az.sin()] +fn equirect_uv_to_dir(u_coordinate: f32, v_coordinate: f32) -> [f32; 3] { + let azimuth = (u_coordinate - 0.5) * 2.0 * PI; // consistent with dir_to_equirect_uv: u = 0.5 + azimuth/(2π) + let elevation = (0.5 - v_coordinate) * PI; + let cos_elevation = elevation.cos(); + [ + cos_elevation * azimuth.cos(), + elevation.sin(), + cos_elevation * azimuth.sin(), + ] } -fn dir_to_equirect_uv(dir: [f32; 3]) -> (f32, f32) { - let [x, y, z] = normalize(dir); - let az = z.atan2(x); - let elev = y.clamp(-1.0, 1.0).asin(); - let u = (0.5 + az / (2.0 * PI)).rem_euclid(1.0); - let v = (0.5 - elev / PI).clamp(0.0, 1.0); - (u, v) +fn dir_to_equirect_uv(direction: [f32; 3]) -> (f32, f32) { + let [x, y, z] = normalize(direction); + let azimuth = z.atan2(x); + let elevation = y.clamp(-1.0, 1.0).asin(); + let u_coordinate = (0.5 + azimuth / (2.0 * PI)).rem_euclid(1.0); + let v_coordinate = (0.5 - elevation / PI).clamp(0.0, 1.0); + (u_coordinate, v_coordinate) } // --- Vector math --- -fn normalize(v: [f32; 3]) -> [f32; 3] { - let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-8); - [v[0] / len, v[1] / len, v[2] / len] +fn normalize(vector: [f32; 3]) -> [f32; 3] { + let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]) + .sqrt() + .max(1e-8); + [vector[0] / length, vector[1] / length, vector[2] / length] } -fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { - a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +fn dot(left: [f32; 3], right: [f32; 3]) -> f32 { + left[0] * right[0] + left[1] * right[1] + left[2] * right[2] } -fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { +fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0], + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], ] } -// Build an orthonormal tangent frame (t, b) from normal n. -fn tangent_frame(n: [f32; 3]) -> ([f32; 3], [f32; 3]) { - let up = if n[1].abs() < 0.9 { +// Build an orthonormal tangent frame (tangent, bitangent) from normal. +fn tangent_frame(normal: [f32; 3]) -> ([f32; 3], [f32; 3]) { + let up = if normal[1].abs() < 0.9 { [0.0f32, 1.0, 0.0] } else { [1.0f32, 0.0, 0.0] }; - let t = normalize(cross(up, n)); - let b = cross(n, t); - (t, b) + let tangent = normalize(cross(up, normal)); + let bitangent = cross(normal, tangent); + (tangent, bitangent) } -// Transform v from tangent space (where n=[0,0,1]) to world space. -fn tangent_to_world(v: [f32; 3], n: [f32; 3]) -> [f32; 3] { - let (t, b) = tangent_frame(n); +// Transform vector from tangent space (where normal=[0,0,1]) to world space. +fn tangent_to_world(vector: [f32; 3], normal: [f32; 3]) -> [f32; 3] { + let (tangent, bitangent) = tangent_frame(normal); normalize([ - t[0] * v[0] + b[0] * v[1] + n[0] * v[2], - t[1] * v[0] + b[1] * v[1] + n[1] * v[2], - t[2] * v[0] + b[2] * v[1] + n[2] * v[2], + tangent[0] * vector[0] + bitangent[0] * vector[1] + normal[0] * vector[2], + tangent[1] * vector[0] + bitangent[1] * vector[1] + normal[1] * vector[2], + tangent[2] * vector[0] + bitangent[2] * vector[1] + normal[2] * vector[2], ]) } // --- Hammersley quasi-random sequence --- -fn radical_inverse_vdc(mut bits: u32) -> f32 { +fn radical_inverse_van_der_corput(mut bits: u32) -> f32 { bits = bits.rotate_right(16); bits = ((bits & 0x5555_5555) << 1) | ((bits & 0xAAAA_AAAA) >> 1); bits = ((bits & 0x3333_3333) << 2) | ((bits & 0xCCCC_CCCC) >> 2); @@ -168,171 +184,186 @@ fn radical_inverse_vdc(mut bits: u32) -> f32 { bits as f32 * 2.328_306_4e-10 // / 2^32 } -fn hammersley(i: u32, n: u32) -> (f32, f32) { - (i as f32 / n as f32, radical_inverse_vdc(i)) +fn hammersley(index: u32, count: u32) -> (f32, f32) { + ( + index as f32 / count as f32, + radical_inverse_van_der_corput(index), + ) } -// GGX importance sampling: returns half-vector in world space around n. -fn importance_sample_ggx(xi: (f32, f32), roughness: f32, n: [f32; 3]) -> [f32; 3] { - let a = roughness * roughness; - let phi = 2.0 * PI * xi.0; - let cos_t = ((1.0 - xi.1) / (1.0 + (a * a - 1.0) * xi.1)) +// GGX importance sampling: returns half-vector in world space around normal. +fn importance_sample_ggx(sample_point: (f32, f32), roughness: f32, normal: [f32; 3]) -> [f32; 3] { + let alpha = roughness * roughness; + let phi = 2.0 * PI * sample_point.0; + let cos_theta = ((1.0 - sample_point.1) / (1.0 + (alpha * alpha - 1.0) * sample_point.1)) .max(0.0) .sqrt(); - let sin_t = (1.0 - cos_t * cos_t).max(0.0).sqrt(); - let h_local = [sin_t * phi.cos(), sin_t * phi.sin(), cos_t]; - tangent_to_world(h_local, n) + let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt(); + let half_vector_local = [sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta]; + tangent_to_world(half_vector_local, normal) } // --- Diffuse irradiance: cosine-weighted Riemann sum --- -fn compute_irradiance(eq: &Equirect, out_w: u32, out_h: u32) -> Vec { - const N_PHI: u32 = 64; - const N_THETA: u32 = 32; - let dphi = 2.0 * PI / N_PHI as f32; - let dtheta = 0.5 * PI / N_THETA as f32; - - let mut out = Vec::with_capacity((out_w * out_h * 4) as usize); - for y in 0..out_h { - for x in 0..out_w { - let u = (x as f32 + 0.5) / out_w as f32; - let v = (y as f32 + 0.5) / out_h as f32; - let n = equirect_uv_to_dir(u, v); - - let mut irr = [0.0f32; 3]; - let mut wt = 0.0f32; - - for j in 0..N_THETA { - for i in 0..N_PHI { - let phi = (i as f32 + 0.5) * dphi; - let theta = (j as f32 + 0.5) * dtheta; - let sin_t = theta.sin(); - let cos_t = theta.cos(); - let local = [sin_t * phi.cos(), sin_t * phi.sin(), cos_t]; - let world = tangent_to_world(local, n); - let sample = eq.sample(world); - let w = cos_t * sin_t; - irr[0] += sample[0] * w; - irr[1] += sample[1] * w; - irr[2] += sample[2] * w; - wt += w; +fn compute_irradiance(equirect: &Equirect, output_width: u32, output_height: u32) -> Vec { + const PHI_SAMPLE_COUNT: u32 = 64; + const THETA_SAMPLE_COUNT: u32 = 32; + let delta_phi = 2.0 * PI / PHI_SAMPLE_COUNT as f32; + let delta_theta = 0.5 * PI / THETA_SAMPLE_COUNT as f32; + + let mut output = Vec::with_capacity((output_width * output_height * 4) as usize); + for y in 0..output_height { + for x in 0..output_width { + let u_coordinate = (x as f32 + 0.5) / output_width as f32; + let v_coordinate = (y as f32 + 0.5) / output_height as f32; + let normal = equirect_uv_to_dir(u_coordinate, v_coordinate); + + let mut irradiance = [0.0f32; 3]; + let mut total_weight = 0.0f32; + + for theta_index in 0..THETA_SAMPLE_COUNT { + for phi_index in 0..PHI_SAMPLE_COUNT { + let phi = (phi_index as f32 + 0.5) * delta_phi; + let theta = (theta_index as f32 + 0.5) * delta_theta; + let sin_theta = theta.sin(); + let cos_theta = theta.cos(); + let local = [sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta]; + let world = tangent_to_world(local, normal); + let sample = equirect.sample(world); + let weight = cos_theta * sin_theta; + irradiance[0] += sample[0] * weight; + irradiance[1] += sample[1] * weight; + irradiance[2] += sample[2] * weight; + total_weight += weight; } } - let s = PI / wt; - out.push(linear_to_srgb(irr[0] * s)); - out.push(linear_to_srgb(irr[1] * s)); - out.push(linear_to_srgb(irr[2] * s)); - out.push(255u8); + let normalization = PI / total_weight; + output.push(linear_to_srgb(irradiance[0] * normalization)); + output.push(linear_to_srgb(irradiance[1] * normalization)); + output.push(linear_to_srgb(irradiance[2] * normalization)); + output.push(255u8); } } - out + output } // --- Specular prefilter: GGX importance sampling, HDR linear output --- -fn compute_specular_prefilter(eq: &Equirect, out_w: u32, out_h: u32, roughness: f32) -> Vec { - const N_SAMPLES: u32 = 256; - - let mut out = Vec::with_capacity((out_w * out_h * 4) as usize); - for y in 0..out_h { - for x in 0..out_w { - let u = (x as f32 + 0.5) / out_w as f32; - let v = (y as f32 + 0.5) / out_h as f32; - let n = equirect_uv_to_dir(u, v); - - let mut col = [0.0f32; 3]; - let mut wt = 0.0f32; - - for i in 0..N_SAMPLES { - let xi = hammersley(i, N_SAMPLES); - let h = importance_sample_ggx(xi, roughness, n); - let vdoth = dot(n, h).max(0.0); - let l = normalize([ - 2.0 * vdoth * h[0] - n[0], - 2.0 * vdoth * h[1] - n[1], - 2.0 * vdoth * h[2] - n[2], +fn compute_specular_prefilter( + equirect: &Equirect, + output_width: u32, + output_height: u32, + roughness: f32, +) -> Vec { + const SAMPLE_COUNT: u32 = 256; + + let mut output = Vec::with_capacity((output_width * output_height * 4) as usize); + for y in 0..output_height { + for x in 0..output_width { + let u_coordinate = (x as f32 + 0.5) / output_width as f32; + let v_coordinate = (y as f32 + 0.5) / output_height as f32; + let normal = equirect_uv_to_dir(u_coordinate, v_coordinate); + + let mut color = [0.0f32; 3]; + let mut total_weight = 0.0f32; + + for sample_index in 0..SAMPLE_COUNT { + let sample_point = hammersley(sample_index, SAMPLE_COUNT); + let half_vector = importance_sample_ggx(sample_point, roughness, normal); + let normal_dot_half = dot(normal, half_vector).max(0.0); + let light_direction = normalize([ + 2.0 * normal_dot_half * half_vector[0] - normal[0], + 2.0 * normal_dot_half * half_vector[1] - normal[1], + 2.0 * normal_dot_half * half_vector[2] - normal[2], ]); - let ndotl = dot(n, l).max(0.0); - if ndotl > 0.0 { - let s = eq.sample(l); - col[0] += s[0] * ndotl; - col[1] += s[1] * ndotl; - col[2] += s[2] * ndotl; - wt += ndotl; + let normal_dot_light = dot(normal, light_direction).max(0.0); + if normal_dot_light > 0.0 { + let sampled = equirect.sample(light_direction); + color[0] += sampled[0] * normal_dot_light; + color[1] += sampled[1] * normal_dot_light; + color[2] += sampled[2] * normal_dot_light; + total_weight += normal_dot_light; } } - let s = 1.0 / wt.max(1e-6); - out.push(col[0] * s); - out.push(col[1] * s); - out.push(col[2] * s); - out.push(1.0f32); + let normalization = 1.0 / total_weight.max(1e-6); + output.push(color[0] * normalization); + output.push(color[1] * normalization); + output.push(color[2] * normalization); + output.push(1.0f32); } } - out + output } // --- BRDF LUT: GGX split-sum preintegration (Karis / UE4) --- // Stored linear (not sRGB): R = F0 scale, G = F0 bias. -fn geometry_schlick_ggx_ibl(ndotv: f32, roughness: f32) -> f32 { +fn geometry_schlick_ggx_ibl(normal_dot_view: f32, roughness: f32) -> f32 { let k = roughness * roughness / 2.0; - ndotv / (ndotv * (1.0 - k) + k) + normal_dot_view / (normal_dot_view * (1.0 - k) + k) } -fn geometry_smith_ibl(ndotv: f32, ndotl: f32, roughness: f32) -> f32 { - geometry_schlick_ggx_ibl(ndotv, roughness) * geometry_schlick_ggx_ibl(ndotl, roughness) +fn geometry_smith_ibl(normal_dot_view: f32, normal_dot_light: f32, roughness: f32) -> f32 { + geometry_schlick_ggx_ibl(normal_dot_view, roughness) + * geometry_schlick_ggx_ibl(normal_dot_light, roughness) } -fn compute_brdf_lut(w: u32, h: u32) -> Vec { - const N: u32 = 1024; +fn compute_brdf_lut(width: u32, height: u32) -> Vec { + const SAMPLE_COUNT: u32 = 1024; - let mut out = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h { - for x in 0..w { + let mut output = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + for x in 0..width { // x = NdotV (0=grazing, 1=normal incidence) // y = roughness (0=smooth, 1=rough) — y=0 is top of texture - let ndotv = (x as f32 + 0.5) / w as f32; - let roughness = (y as f32 + 0.5) / h as f32; + let normal_dot_view = (x as f32 + 0.5) / width as f32; + let roughness = (y as f32 + 0.5) / height as f32; let roughness = roughness.max(0.04); - let v = [(1.0 - ndotv * ndotv).max(0.0).sqrt(), 0.0f32, ndotv]; - let n = [0.0f32, 0.0, 1.0]; + let view_direction = [ + (1.0 - normal_dot_view * normal_dot_view).max(0.0).sqrt(), + 0.0f32, + normal_dot_view, + ]; + let normal = [0.0f32, 0.0, 1.0]; let mut scale = 0.0f32; let mut bias = 0.0f32; - for i in 0..N { - let xi = hammersley(i, N); - let h_v = importance_sample_ggx(xi, roughness, n); - let vdoth = dot(v, h_v).max(0.0); - let l = normalize([ - 2.0 * vdoth * h_v[0] - v[0], - 2.0 * vdoth * h_v[1] - v[1], - 2.0 * vdoth * h_v[2] - v[2], + for sample_index in 0..SAMPLE_COUNT { + let sample_point = hammersley(sample_index, SAMPLE_COUNT); + let half_vector = importance_sample_ggx(sample_point, roughness, normal); + let view_dot_half = dot(view_direction, half_vector).max(0.0); + let light_direction = normalize([ + 2.0 * view_dot_half * half_vector[0] - view_direction[0], + 2.0 * view_dot_half * half_vector[1] - view_direction[1], + 2.0 * view_dot_half * half_vector[2] - view_direction[2], ]); - let ndotl = l[2].max(0.0); // n = [0,0,1] - let ndoth = h_v[2].max(0.0); - - if ndotl > 0.0 { - let g_vis = geometry_smith_ibl(ndotv, ndotl, roughness) * vdoth - / (ndoth * ndotv.max(0.001)); - let fc = (1.0 - vdoth).powi(5); - scale += (1.0 - fc) * g_vis; - bias += fc * g_vis; + let normal_dot_light = light_direction[2].max(0.0); // normal = [0,0,1] + let normal_dot_half = half_vector[2].max(0.0); + + if normal_dot_light > 0.0 { + let geometry_visibility = + geometry_smith_ibl(normal_dot_view, normal_dot_light, roughness) + * view_dot_half + / (normal_dot_half * normal_dot_view.max(0.001)); + let fresnel_coefficient = (1.0 - view_dot_half).powi(5); + scale += (1.0 - fresnel_coefficient) * geometry_visibility; + bias += fresnel_coefficient * geometry_visibility; } } - scale = (scale / N as f32).clamp(0.0, 1.0); - bias = (bias / N as f32).clamp(0.0, 1.0); + scale = (scale / SAMPLE_COUNT as f32).clamp(0.0, 1.0); + bias = (bias / SAMPLE_COUNT as f32).clamp(0.0, 1.0); // Linear bytes (not sRGB — BRDF coefficients are in linear space) - out.push((scale * 255.0 + 0.5) as u8); - out.push((bias * 255.0 + 0.5) as u8); - out.push(0u8); - out.push(255u8); + output.push((scale * 255.0 + 0.5) as u8); + output.push((bias * 255.0 + 0.5) as u8); + output.push(0u8); + output.push(255u8); } } - out + output } diff --git a/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs b/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs index d75149c4..8dc484e0 100644 --- a/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs +++ b/engine/pill_assets/src/rules/glb_to_cooked_mesh.rs @@ -41,7 +41,7 @@ impl Rule for GlbToCookedMesh { fn build(&self, input: &Path, output: &Path) -> Result<()> { let bytes = std::fs::read(input).with_context(|| format!("read {input:?}"))?; - let (doc, buffers, images) = + let (document, buffers, images) = gltf::import_slice(&bytes).with_context(|| format!("parse GLB {input:?}"))?; // --- Mesh --- @@ -61,24 +61,26 @@ impl Rule for GlbToCookedMesh { [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0f32], ]; - let default_scene = doc.default_scene().or_else(|| doc.scenes().next()); + let default_scene = document + .default_scene() + .or_else(|| document.scenes().next()); if let Some(scene) = default_scene { let mut stack: Vec<(gltf::Node, [[f32; 4]; 4])> = - scene.nodes().map(|n| (n, identity)).collect(); + scene.nodes().map(|node| (node, identity)).collect(); while let Some((node, parent_transform)) = stack.pop() { - let local = node_transform(node.transform()); - let world = mat4_mul(parent_transform, local); + let local_transform = node_transform(node.transform()); + let world_transform = mat4_mul(parent_transform, local_transform); if let Some(mesh) = node.mesh() { - mesh_instances.push((mesh.index(), world)); + mesh_instances.push((mesh.index(), world_transform)); } for child in node.children() { - stack.push((child, world)); + stack.push((child, world_transform)); } } } else { // No scene: fall back to processing every mesh with identity transform. - for (i, _) in doc.meshes().enumerate() { - mesh_instances.push((i, identity)); + for (mesh_index, _) in document.meshes().enumerate() { + mesh_instances.push((mesh_index, identity)); } } @@ -86,11 +88,11 @@ impl Rule for GlbToCookedMesh { anyhow::bail!("{input:?}: no meshes in GLB"); } - let meshes: Vec = doc.meshes().collect(); - for (mesh_idx, world) in mesh_instances { - let mesh = &meshes[mesh_idx]; - for prim in mesh.primitives() { - let reader = prim.reader(|buffer| Some(&*buffers[buffer.index()])); + let meshes: Vec = document.meshes().collect(); + for (mesh_index, world_transform) in mesh_instances { + let mesh = &meshes[mesh_index]; + for primitive in mesh.primitives() { + let reader = primitive.reader(|buffer| Some(&*buffers[buffer.index()])); let vertex_offset = all_vertices.len() as u32; let positions: Vec<[f32; 3]> = reader @@ -101,61 +103,65 @@ impl Rule for GlbToCookedMesh { .read_normals() .with_context(|| format!("{input:?}: missing normals"))? .collect(); - let uvs: Vec<[f32; 2]> = reader + let texture_coordinates: Vec<[f32; 2]> = reader .read_tex_coords(0) .with_context(|| format!("{input:?}: missing UV set 0"))? .into_f32() .collect(); - let tangents_glb: Vec<[f32; 4]> = reader + let glb_tangents: Vec<[f32; 4]> = reader .read_tangents() .map(|tangents_iter| tangents_iter.collect()) .unwrap_or_default(); - let prim_indices: Vec = reader + let primitive_indices: Vec = reader .read_indices() .with_context(|| format!("{input:?}: missing indices"))? .into_u32() .collect(); let mut vertices: Vec = (0..positions.len()) - .map(|i| { - let n = transform_normal(world, normals[i]); - let (tx, ty, tz, sign) = tangents_glb - .get(i) + .map(|vertex_index| { + let normal = transform_normal(world_transform, normals[vertex_index]); + let (tangent_x, tangent_y, tangent_z, sign) = glb_tangents + .get(vertex_index) .map(|tangent| (tangent[0], tangent[1], tangent[2], tangent[3])) .unwrap_or((1.0, 0.0, 0.0, 1.0)); - let t_world = transform_normal(world, [tx, ty, tz]); - let bx = (n[1] * t_world[2] - n[2] * t_world[1]) * sign; - let by = (n[2] * t_world[0] - n[0] * t_world[2]) * sign; - let bz = (n[0] * t_world[1] - n[1] * t_world[0]) * sign; + let tangent_world = + transform_normal(world_transform, [tangent_x, tangent_y, tangent_z]); + let bitangent_x = + (normal[1] * tangent_world[2] - normal[2] * tangent_world[1]) * sign; + let bitangent_y = + (normal[2] * tangent_world[0] - normal[0] * tangent_world[2]) * sign; + let bitangent_z = + (normal[0] * tangent_world[1] - normal[1] * tangent_world[0]) * sign; Vertex { - position: transform_point(world, positions[i]), - texture_coordinates: uvs[i], - normal: n, - tangent: t_world, - bitangent: [bx, by, bz], + position: transform_point(world_transform, positions[vertex_index]), + texture_coordinates: texture_coordinates[vertex_index], + normal, + tangent: tangent_world, + bitangent: [bitangent_x, bitangent_y, bitangent_z], } }) .collect(); - if tangents_glb.is_empty() { - compute_tangents(&mut vertices, &prim_indices); + if glb_tangents.is_empty() { + compute_tangents(&mut vertices, &primitive_indices); } - all_indices.extend(prim_indices.iter().map(|&i| i + vertex_offset)); + all_indices.extend(primitive_indices.iter().map(|&index| index + vertex_offset)); all_vertices.extend(vertices); } } let vertex_bytes: &[u8] = bytemuck::cast_slice(&all_vertices); let index_bytes: &[u8] = bytemuck::cast_slice(&all_indices); - let mut out = Vec::with_capacity(16 + vertex_bytes.len() + index_bytes.len()); - out.extend_from_slice(b"RMSH"); - out.extend_from_slice(&1u32.to_le_bytes()); - out.extend_from_slice(&(all_vertices.len() as u32).to_le_bytes()); - out.extend_from_slice(&(all_indices.len() as u32).to_le_bytes()); - out.extend_from_slice(vertex_bytes); - out.extend_from_slice(index_bytes); - std::fs::write(output, &out).with_context(|| format!("write {output:?}"))?; + let mut output_bytes = Vec::with_capacity(16 + vertex_bytes.len() + index_bytes.len()); + output_bytes.extend_from_slice(b"RMSH"); + output_bytes.extend_from_slice(&1u32.to_le_bytes()); + output_bytes.extend_from_slice(&(all_vertices.len() as u32).to_le_bytes()); + output_bytes.extend_from_slice(&(all_indices.len() as u32).to_le_bytes()); + output_bytes.extend_from_slice(vertex_bytes); + output_bytes.extend_from_slice(index_bytes); + std::fs::write(output, &output_bytes).with_context(|| format!("write {output:?}"))?; // --- Textures (side outputs) --- @@ -164,52 +170,52 @@ impl Rule for GlbToCookedMesh { .unwrap_or_default() .to_string_lossy() .to_string(); - let dir = output + let directory = output .parent() .with_context(|| "output has no parent directory")?; - for mat in doc.materials() { - let mat_idx = mat.index(); + for material in document.materials() { + let material_index = material.index(); let suffix = |base: &str| -> String { - match mat_idx { + match material_index { None | Some(0) => format!("{stem}_{base}.cooked_tex"), - Some(i) => format!("{stem}_mat{i}_{base}.cooked_tex"), + Some(index) => format!("{stem}_mat{index}_{base}.cooked_tex"), } }; - let pbr = mat.pbr_metallic_roughness(); + let pbr_metallic_roughness = material.pbr_metallic_roughness(); - if let Some(info) = pbr.base_color_texture() { + if let Some(info) = pbr_metallic_roughness.base_color_texture() { write_cooked_tex( &images, info.texture().source().index(), - dir.join(suffix("albedo")), + directory.join(suffix("albedo")), "base color texture", input, )?; } - if let Some(info) = mat.normal_texture() { + if let Some(info) = material.normal_texture() { write_cooked_tex( &images, info.texture().source().index(), - dir.join(suffix("normal")), + directory.join(suffix("normal")), "normal texture", input, )?; } - if let Some(info) = pbr.metallic_roughness_texture() { + if let Some(info) = pbr_metallic_roughness.metallic_roughness_texture() { write_cooked_tex( &images, info.texture().source().index(), - dir.join(suffix("metallic_roughness")), + directory.join(suffix("metallic_roughness")), "metallic_roughness texture", input, )?; } - if let Some(info) = mat.emissive_texture() { + if let Some(info) = material.emissive_texture() { write_cooked_tex( &images, info.texture().source().index(), - dir.join(suffix("emissive")), + directory.join(suffix("emissive")), "emissive texture", input, )?; @@ -222,20 +228,21 @@ impl Rule for GlbToCookedMesh { fn write_cooked_tex( images: &[gltf::image::Data], - img_idx: usize, + image_index: usize, path: std::path::PathBuf, label: &str, input: &Path, ) -> Result<()> { use gltf::image::Format; use std::borrow::Cow; - let Some(img) = images.get(img_idx) else { + let Some(image) = images.get(image_index) else { return Ok(()); }; - let rgba: Cow<[u8]> = match img.format { - Format::R8G8B8A8 => Cow::Borrowed(&img.pixels), + let rgba: Cow<[u8]> = match image.format { + Format::R8G8B8A8 => Cow::Borrowed(&image.pixels), Format::R8G8B8 => Cow::Owned( - img.pixels + image + .pixels .chunks_exact(3) .flat_map(|pixel| [pixel[0], pixel[1], pixel[2], 255u8]) .collect(), @@ -243,108 +250,128 @@ fn write_cooked_tex( format => bail!("{input:?}: {label}: unsupported GLB image format: {format:?}"), }; // glTF UV origin (0,0) is top-left, matching wgpu/Vulkan — no row flip needed. - super::studio_equirect::write_rtex(&path, img.width, img.height, &rgba) + super::procedural_equirect::write_rtex(&path, image.width, image.height, &rgba) .with_context(|| format!("{input:?}: {label}")) } fn compute_tangents(vertices: &mut [Vertex], indices: &[u32]) { let mut triangle_counts = vec![0usize; vertices.len()]; - for tri in indices.chunks(3) { - let (i0, i1, i2) = (tri[0] as usize, tri[1] as usize, tri[2] as usize); - let p0 = vertices[i0].position; - let p1 = vertices[i1].position; - let p2 = vertices[i2].position; - let uv0 = vertices[i0].texture_coordinates; - let uv1 = vertices[i1].texture_coordinates; - let uv2 = vertices[i2].texture_coordinates; - - let dp1 = sub3(p1, p0); - let dp2 = sub3(p2, p0); - let duv1 = sub2(uv1, uv0); - let duv2 = sub2(uv2, uv0); - - let det = duv1[0] * duv2[1] - duv1[1] * duv2[0]; - if det.abs() < 1e-8 { + for triangle in indices.chunks(3) { + let (index_0, index_1, index_2) = ( + triangle[0] as usize, + triangle[1] as usize, + triangle[2] as usize, + ); + let position_0 = vertices[index_0].position; + let position_1 = vertices[index_1].position; + let position_2 = vertices[index_2].position; + let texture_coordinates_0 = vertices[index_0].texture_coordinates; + let texture_coordinates_1 = vertices[index_1].texture_coordinates; + let texture_coordinates_2 = vertices[index_2].texture_coordinates; + + let delta_position_1 = sub3(position_1, position_0); + let delta_position_2 = sub3(position_2, position_0); + let delta_texture_coordinates_1 = sub2(texture_coordinates_1, texture_coordinates_0); + let delta_texture_coordinates_2 = sub2(texture_coordinates_2, texture_coordinates_0); + + let determinant = delta_texture_coordinates_1[0] * delta_texture_coordinates_2[1] + - delta_texture_coordinates_1[1] * delta_texture_coordinates_2[0]; + if determinant.abs() < 1e-8 { continue; } - let inv = 1.0 / det; - let tangent = scale3(sub3(scale3(dp1, duv2[1]), scale3(dp2, duv1[1])), inv); - let bitangent = scale3(sub3(scale3(dp2, duv1[0]), scale3(dp1, duv2[0])), inv); - - for &i in &[i0, i1, i2] { - vertices[i].tangent = add3(vertices[i].tangent, tangent); - vertices[i].bitangent = add3(vertices[i].bitangent, bitangent); - triangle_counts[i] += 1; + let inverse_determinant = 1.0 / determinant; + let tangent = scale3( + sub3( + scale3(delta_position_1, delta_texture_coordinates_2[1]), + scale3(delta_position_2, delta_texture_coordinates_1[1]), + ), + inverse_determinant, + ); + let bitangent = scale3( + sub3( + scale3(delta_position_2, delta_texture_coordinates_1[0]), + scale3(delta_position_1, delta_texture_coordinates_2[0]), + ), + inverse_determinant, + ); + + for &index in &[index_0, index_1, index_2] { + vertices[index].tangent = add3(vertices[index].tangent, tangent); + vertices[index].bitangent = add3(vertices[index].bitangent, bitangent); + triangle_counts[index] += 1; } } - for (i, &count) in triangle_counts.iter().enumerate() { + for (index, &count) in triangle_counts.iter().enumerate() { if count > 0 { - let inv = 1.0 / count as f32; - vertices[i].tangent = normalize3(scale3(vertices[i].tangent, inv)); - vertices[i].bitangent = normalize3(scale3(vertices[i].bitangent, inv)); + let inverse_count = 1.0 / count as f32; + vertices[index].tangent = normalize3(scale3(vertices[index].tangent, inverse_count)); + vertices[index].bitangent = + normalize3(scale3(vertices[index].bitangent, inverse_count)); } } } -fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { - [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] } -fn sub2(a: [f32; 2], b: [f32; 2]) -> [f32; 2] { - [a[0] - b[0], a[1] - b[1]] +fn sub2(left: [f32; 2], right: [f32; 2]) -> [f32; 2] { + [left[0] - right[0], left[1] - right[1]] } -fn scale3(a: [f32; 3], s: f32) -> [f32; 3] { - [a[0] * s, a[1] * s, a[2] * s] +fn scale3(vector: [f32; 3], scalar: f32) -> [f32; 3] { + [vector[0] * scalar, vector[1] * scalar, vector[2] * scalar] } -fn add3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { - [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +fn add3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] + right[0], left[1] + right[1], left[2] + right[2]] } -fn normalize3(a: [f32; 3]) -> [f32; 3] { - let len = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt(); - if len < 1e-10 { - a +fn normalize3(vector: [f32; 3]) -> [f32; 3] { + let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt(); + if length < 1e-10 { + vector } else { - [a[0] / len, a[1] / len, a[2] / len] + [vector[0] / length, vector[1] / length, vector[2] / length] } } -fn mat4_mul(a: [[f32; 4]; 4], b: [[f32; 4]; 4]) -> [[f32; 4]; 4] { - let mut c = [[0.0f32; 4]; 4]; - for i in 0..4 { - for j in 0..4 { - c[i][j] = (0..4).map(|k| a[i][k] * b[k][j]).sum(); +fn mat4_mul(left: [[f32; 4]; 4], right: [[f32; 4]; 4]) -> [[f32; 4]; 4] { + let mut result = [[0.0f32; 4]; 4]; + for row in 0..4 { + for column in 0..4 { + result[row][column] = (0..4) + .map(|inner| left[row][inner] * right[inner][column]) + .sum(); } } - c + result } -fn node_transform(t: gltf::scene::Transform) -> [[f32; 4]; 4] { - let m = t.matrix(); +fn node_transform(transform: gltf::scene::Transform) -> [[f32; 4]; 4] { + let matrix = transform.matrix(); // gltf gives column-major; convert to row-major [[f32;4];4] [ - [m[0][0], m[1][0], m[2][0], m[3][0]], - [m[0][1], m[1][1], m[2][1], m[3][1]], - [m[0][2], m[1][2], m[2][2], m[3][2]], - [m[0][3], m[1][3], m[2][3], m[3][3]], + [matrix[0][0], matrix[1][0], matrix[2][0], matrix[3][0]], + [matrix[0][1], matrix[1][1], matrix[2][1], matrix[3][1]], + [matrix[0][2], matrix[1][2], matrix[2][2], matrix[3][2]], + [matrix[0][3], matrix[1][3], matrix[2][3], matrix[3][3]], ] } -fn transform_point(m: [[f32; 4]; 4], p: [f32; 3]) -> [f32; 3] { +fn transform_point(matrix: [[f32; 4]; 4], point: [f32; 3]) -> [f32; 3] { [ - m[0][0] * p[0] + m[0][1] * p[1] + m[0][2] * p[2] + m[0][3], - m[1][0] * p[0] + m[1][1] * p[1] + m[1][2] * p[2] + m[1][3], - m[2][0] * p[0] + m[2][1] * p[1] + m[2][2] * p[2] + m[2][3], + matrix[0][0] * point[0] + matrix[0][1] * point[1] + matrix[0][2] * point[2] + matrix[0][3], + matrix[1][0] * point[0] + matrix[1][1] * point[1] + matrix[1][2] * point[2] + matrix[1][3], + matrix[2][0] * point[0] + matrix[2][1] * point[1] + matrix[2][2] * point[2] + matrix[2][3], ] } -fn transform_normal(m: [[f32; 4]; 4], n: [f32; 3]) -> [f32; 3] { +fn transform_normal(matrix: [[f32; 4]; 4], normal: [f32; 3]) -> [f32; 3] { // Normals transform by the inverse-transpose of the upper-left 3x3. // For uniform or orthogonal scaling this equals the 3x3 itself (re-normalized). - let r = [ - m[0][0] * n[0] + m[0][1] * n[1] + m[0][2] * n[2], - m[1][0] * n[0] + m[1][1] * n[1] + m[1][2] * n[2], - m[2][0] * n[0] + m[2][1] * n[1] + m[2][2] * n[2], + let transformed = [ + matrix[0][0] * normal[0] + matrix[0][1] * normal[1] + matrix[0][2] * normal[2], + matrix[1][0] * normal[0] + matrix[1][1] * normal[1] + matrix[1][2] * normal[2], + matrix[2][0] * normal[0] + matrix[2][1] * normal[1] + matrix[2][2] * normal[2], ]; - normalize3(r) + normalize3(transformed) } diff --git a/engine/pill_assets/src/rules/mod.rs b/engine/pill_assets/src/rules/mod.rs index a02a3c4f..5d24a31b 100644 --- a/engine/pill_assets/src/rules/mod.rs +++ b/engine/pill_assets/src/rules/mod.rs @@ -5,22 +5,22 @@ pub mod glb_to_cooked_mesh; pub mod hlsl_to_wgsl; pub mod obj_to_cooked_mesh; pub mod png_to_cooked_tex; -pub mod studio_equirect; +pub mod procedural_equirect; pub use equirect_to_ibl::EquirectToIBL; pub use glb_to_cooked_mesh::GlbToCookedMesh; pub use hlsl_to_wgsl::HlslToWgsl; pub use obj_to_cooked_mesh::ObjToCookedMesh; pub use png_to_cooked_tex::PngToCookedTex; -pub use studio_equirect::StudioEquirect; +pub use procedural_equirect::ProceduralEquirect; /// Built-in rule set used by both the cargo build-script and the launcher. pub fn default_rules() -> Vec> { vec![ Box::new(HlslToWgsl), Box::new(PngToCookedTex), - Box::new(StudioEquirect), // generates *_equirect.cooked_tex from *.studio - Box::new(EquirectToIBL), // generates IBL maps from *_equirect.cooked_tex + Box::new(ProceduralEquirect), // generates *_equirect.cooked_tex from *.procedural_equirect + Box::new(EquirectToIBL), // generates IBL maps from *_equirect.cooked_tex Box::new(ObjToCookedMesh), Box::new(GlbToCookedMesh), ] diff --git a/engine/pill_assets/src/rules/procedural_equirect.rs b/engine/pill_assets/src/rules/procedural_equirect.rs new file mode 100644 index 00000000..83246ad8 --- /dev/null +++ b/engine/pill_assets/src/rules/procedural_equirect.rs @@ -0,0 +1,169 @@ +use std::f32::consts::PI; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::Rule; + +// Gradient (linear, inverse-Reinhard compensated: c/(1+c) → target on screen). +const TOP: [f32; 3] = [0.90, 0.90, 1.20]; // Reinhard → [0.47, 0.47, 0.55] cool blue ceiling +const HORIZON: [f32; 3] = [0.43, 0.43, 0.46]; // Reinhard → [0.30, 0.30, 0.32] neutral mid +const BOTTOM: [f32; 3] = [0.040, 0.040, 0.050]; // Reinhard → [0.04, 0.04, 0.05] dark floor + +// Key light: warm tungsten, upper-left, ~40° above horizon. +const KEY_U: f32 = 0.10; +const KEY_V: f32 = 0.28; +const KEY_INTENSITY: [f32; 3] = [6.0, 4.8, 2.5]; // Reinhard → [0.86, 0.83, 0.71] warm gold +const KEY_SIGMA: f32 = 0.0875; + +// Rim light: cool blue, upper-right, ~45° above horizon. +const RIM_U: f32 = 0.60; +const RIM_V: f32 = 0.25; +const RIM_INTENSITY: [f32; 3] = [1.5, 2.5, 5.0]; // Reinhard → [0.60, 0.71, 0.83] cool blue +const RIM_SIGMA: f32 = 0.100; + +/// Procedurally generates a studio-lit equirectangular HDR panorama. +/// +/// The `.procedural_equirect` input is a 0-byte trigger marker — its contents are +/// ignored; the panorama (gradient sky + two Gaussian softbox lights) is generated +/// entirely in code. Output: `{stem}_equirect.cooked_tex` (RTEX v2, Rgba32Float HDR). +pub struct ProceduralEquirect; + +impl Rule for ProceduralEquirect { + fn name(&self) -> &'static str { + "procedural_equirect" + } + + fn input_glob(&self) -> &'static str { + "**/*.procedural_equirect" + } + + fn output_for(&self, input: &Path) -> PathBuf { + let stem = input.file_stem().unwrap().to_str().unwrap(); + input.with_file_name(format!("{stem}_equirect.cooked_tex")) + } + + fn build(&self, _input: &Path, output: &Path) -> Result<()> { + let (width, height) = (512u32, 256u32); + let pixels = generate_equirect(width, height); + write_rtex_hdr(output, width, height, &pixels) + } +} + +fn generate_equirect(width: u32, height: u32) -> Vec { + let mut pixels = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + for x in 0..width { + let longitude_u = (x as f32 + 0.5) / width as f32; + let latitude_v = (y as f32 + 0.5) / height as f32; + let elevation = (0.5 - latitude_v) * PI; + let sin_elevation = elevation.sin(); + let gradient_factor = (sin_elevation + 1.0) * 0.5; + let mut color = if gradient_factor < 0.5 { + lerp3(BOTTOM, HORIZON, gradient_factor * 2.0) + } else { + lerp3(HORIZON, TOP, (gradient_factor - 0.5) * 2.0) + }; + let key_delta_u = longitude_u - KEY_U; + let key_delta_u = (key_delta_u - key_delta_u.round()).abs(); // wrap U to [-0.5, 0.5] + let key_delta_v = latitude_v - KEY_V; + let key_weight = (-(key_delta_u * key_delta_u + key_delta_v * key_delta_v) + / (2.0 * KEY_SIGMA * KEY_SIGMA)) + .exp(); + color[0] = color[0].max(KEY_INTENSITY[0] * key_weight); + color[1] = color[1].max(KEY_INTENSITY[1] * key_weight); + color[2] = color[2].max(KEY_INTENSITY[2] * key_weight); + let rim_delta_u = longitude_u - RIM_U; + let rim_delta_u = (rim_delta_u - rim_delta_u.round()).abs(); + let rim_delta_v = latitude_v - RIM_V; + let rim_weight = (-(rim_delta_u * rim_delta_u + rim_delta_v * rim_delta_v) + / (2.0 * RIM_SIGMA * RIM_SIGMA)) + .exp(); + color[0] = color[0].max(RIM_INTENSITY[0] * rim_weight); + color[1] = color[1].max(RIM_INTENSITY[1] * rim_weight); + color[2] = color[2].max(RIM_INTENSITY[2] * rim_weight); + pixels.push(color[0]); + pixels.push(color[1]); + pixels.push(color[2]); + pixels.push(1.0); + } + } + pixels +} + +// --- shared utilities --- + +// RTEX v2: Rgba32Float (4 bytes per channel, f32 LE), linear HDR. +pub(crate) fn write_rtex_hdr(path: &Path, width: u32, height: u32, rgba_f32: &[f32]) -> Result<()> { + let mut output = Vec::with_capacity(16 + rgba_f32.len() * 4); + output.extend_from_slice(b"RTEX"); + output.extend_from_slice(&2u32.to_le_bytes()); // version 2 = Rgba32Float HDR + output.extend_from_slice(&width.to_le_bytes()); + output.extend_from_slice(&height.to_le_bytes()); + for &value in rgba_f32 { + output.extend_from_slice(&value.to_le_bytes()); + } + std::fs::write(path, &output).with_context(|| format!("write_rtex_hdr {path:?}"))?; + Ok(()) +} + +// RTEX v4: Rgba32Float mip chain for HDR specular prefilter. +// Header: RTEX | 4 | w | h | mip_count | mip0_f32_pixels | mip1_f32_pixels | ... +pub(crate) fn write_rtex_hdr_mip( + path: &Path, + width: u32, + height: u32, + mips: &[Vec], +) -> Result<()> { + let mut output = Vec::new(); + output.extend_from_slice(b"RTEX"); + output.extend_from_slice(&4u32.to_le_bytes()); + output.extend_from_slice(&width.to_le_bytes()); + output.extend_from_slice(&height.to_le_bytes()); + output.extend_from_slice(&(mips.len() as u32).to_le_bytes()); + for mip in mips { + for &value in mip { + output.extend_from_slice(&value.to_le_bytes()); + } + } + std::fs::write(path, &output).with_context(|| format!("write_rtex_hdr_mip {path:?}"))?; + Ok(()) +} + +// RTEX v1: Rgba8UnormSrgb (legacy LDR IBL outputs). +pub(crate) fn write_rtex(path: &Path, width: u32, height: u32, rgba: &[u8]) -> Result<()> { + let mut output = Vec::with_capacity(16 + rgba.len()); + output.extend_from_slice(b"RTEX"); + output.extend_from_slice(&1u32.to_le_bytes()); + output.extend_from_slice(&width.to_le_bytes()); + output.extend_from_slice(&height.to_le_bytes()); + output.extend_from_slice(rgba); + std::fs::write(path, &output).with_context(|| format!("write_rtex {path:?}"))?; + Ok(()) +} + +pub(crate) fn linear_to_srgb(linear: f32) -> u8 { + let clamped = linear.clamp(0.0, 1.0); + let srgb = if clamped <= 0.003_130_8 { + clamped * 12.92 + } else { + 1.055 * clamped.powf(1.0 / 2.4) - 0.055 + }; + (srgb * 255.0 + 0.5) as u8 +} + +pub(crate) fn srgb_to_linear(srgb: f32) -> f32 { + if srgb <= 0.040_45 { + srgb / 12.92 + } else { + ((srgb + 0.055) / 1.055).powf(2.4) + } +} + +pub(crate) fn lerp3(start: [f32; 3], end: [f32; 3], factor: f32) -> [f32; 3] { + [ + start[0] + (end[0] - start[0]) * factor, + start[1] + (end[1] - start[1]) * factor, + start[2] + (end[2] - start[2]) * factor, + ] +} diff --git a/engine/pill_assets/src/rules/studio_equirect.rs b/engine/pill_assets/src/rules/studio_equirect.rs deleted file mode 100644 index 230073a6..00000000 --- a/engine/pill_assets/src/rules/studio_equirect.rs +++ /dev/null @@ -1,155 +0,0 @@ -use std::f32::consts::PI; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; - -use crate::Rule; - -// Gradient (linear, inverse-Reinhard compensated: c/(1+c) → target on screen). -const TOP: [f32; 3] = [0.90, 0.90, 1.20]; // Reinhard → [0.47, 0.47, 0.55] cool blue ceiling -const HORIZON: [f32; 3] = [0.43, 0.43, 0.46]; // Reinhard → [0.30, 0.30, 0.32] neutral mid -const BOTTOM: [f32; 3] = [0.040, 0.040, 0.050]; // Reinhard → [0.04, 0.04, 0.05] dark floor - -// Key light: warm tungsten, upper-left, ~40° above horizon. -const KEY_U: f32 = 0.10; -const KEY_V: f32 = 0.28; -const KEY_INTENSITY: [f32; 3] = [6.0, 4.8, 2.5]; // Reinhard → [0.86, 0.83, 0.71] warm gold -const KEY_SIGMA: f32 = 0.0875; - -// Rim light: cool blue, upper-right, ~45° above horizon. -const RIM_U: f32 = 0.60; -const RIM_V: f32 = 0.25; -const RIM_INTENSITY: [f32; 3] = [1.5, 2.5, 5.0]; // Reinhard → [0.60, 0.71, 0.83] cool blue -const RIM_SIGMA: f32 = 0.100; - -pub struct StudioEquirect; - -impl Rule for StudioEquirect { - fn name(&self) -> &'static str { - "studio_equirect" - } - - fn input_glob(&self) -> &'static str { - "**/*.studio" - } - - fn output_for(&self, input: &Path) -> PathBuf { - let stem = input.file_stem().unwrap().to_str().unwrap(); - input.with_file_name(format!("{stem}_equirect.cooked_tex")) - } - - fn build(&self, _input: &Path, output: &Path) -> Result<()> { - let (w, h) = (512u32, 256u32); - let pixels = generate_equirect(w, h); - write_rtex_hdr(output, w, h, &pixels) - } -} - -fn generate_equirect(w: u32, h: u32) -> Vec { - let mut out = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h { - for x in 0..w { - let u = (x as f32 + 0.5) / w as f32; - let v = (y as f32 + 0.5) / h as f32; - let elev = (0.5 - v) * PI; - let sin_e = elev.sin(); - let t = (sin_e + 1.0) * 0.5; - let mut col = if t < 0.5 { - lerp3(BOTTOM, HORIZON, t * 2.0) - } else { - lerp3(HORIZON, TOP, (t - 0.5) * 2.0) - }; - let dku = u - KEY_U; - let dku = (dku - dku.round()).abs(); // wrap U to [-0.5, 0.5] - let dkv = v - KEY_V; - let w_key = (-(dku * dku + dkv * dkv) / (2.0 * KEY_SIGMA * KEY_SIGMA)).exp(); - col[0] = col[0].max(KEY_INTENSITY[0] * w_key); - col[1] = col[1].max(KEY_INTENSITY[1] * w_key); - col[2] = col[2].max(KEY_INTENSITY[2] * w_key); - let dru = u - RIM_U; - let dru = (dru - dru.round()).abs(); - let drv = v - RIM_V; - let w_rim = (-(dru * dru + drv * drv) / (2.0 * RIM_SIGMA * RIM_SIGMA)).exp(); - col[0] = col[0].max(RIM_INTENSITY[0] * w_rim); - col[1] = col[1].max(RIM_INTENSITY[1] * w_rim); - col[2] = col[2].max(RIM_INTENSITY[2] * w_rim); - out.push(col[0]); - out.push(col[1]); - out.push(col[2]); - out.push(1.0); - } - } - out -} - -// --- shared utilities --- - -// RTEX v2: Rgba32Float (4 bytes per channel, f32 LE), linear HDR. -pub(crate) fn write_rtex_hdr(path: &Path, w: u32, h: u32, rgba_f32: &[f32]) -> Result<()> { - let mut out = Vec::with_capacity(16 + rgba_f32.len() * 4); - out.extend_from_slice(b"RTEX"); - out.extend_from_slice(&2u32.to_le_bytes()); // version 2 = Rgba32Float HDR - out.extend_from_slice(&w.to_le_bytes()); - out.extend_from_slice(&h.to_le_bytes()); - for &v in rgba_f32 { - out.extend_from_slice(&v.to_le_bytes()); - } - std::fs::write(path, &out).with_context(|| format!("write_rtex_hdr {path:?}"))?; - Ok(()) -} - -// RTEX v4: Rgba32Float mip chain for HDR specular prefilter. -// Header: RTEX | 4 | w | h | mip_count | mip0_f32_pixels | mip1_f32_pixels | ... -pub(crate) fn write_rtex_hdr_mip(path: &Path, w: u32, h: u32, mips: &[Vec]) -> Result<()> { - let mut out = Vec::new(); - out.extend_from_slice(b"RTEX"); - out.extend_from_slice(&4u32.to_le_bytes()); - out.extend_from_slice(&w.to_le_bytes()); - out.extend_from_slice(&h.to_le_bytes()); - out.extend_from_slice(&(mips.len() as u32).to_le_bytes()); - for mip in mips { - for &v in mip { - out.extend_from_slice(&v.to_le_bytes()); - } - } - std::fs::write(path, &out).with_context(|| format!("write_rtex_hdr_mip {path:?}"))?; - Ok(()) -} - -// RTEX v1: Rgba8UnormSrgb (legacy LDR IBL outputs). -pub(crate) fn write_rtex(path: &Path, w: u32, h: u32, rgba: &[u8]) -> Result<()> { - let mut out = Vec::with_capacity(16 + rgba.len()); - out.extend_from_slice(b"RTEX"); - out.extend_from_slice(&1u32.to_le_bytes()); - out.extend_from_slice(&w.to_le_bytes()); - out.extend_from_slice(&h.to_le_bytes()); - out.extend_from_slice(rgba); - std::fs::write(path, &out).with_context(|| format!("write_rtex {path:?}"))?; - Ok(()) -} - -pub(crate) fn linear_to_srgb(x: f32) -> u8 { - let c = x.clamp(0.0, 1.0); - let s = if c <= 0.003_130_8 { - c * 12.92 - } else { - 1.055 * c.powf(1.0 / 2.4) - 0.055 - }; - (s * 255.0 + 0.5) as u8 -} - -pub(crate) fn srgb_to_linear(x: f32) -> f32 { - if x <= 0.040_45 { - x / 12.92 - } else { - ((x + 0.055) / 1.055).powf(2.4) - } -} - -pub(crate) fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { - [ - a[0] + (b[0] - a[0]) * t, - a[1] + (b[1] - a[1]) * t, - a[2] + (b[2] - a[2]) * t, - ] -} diff --git a/engine/pill_engine/res/shaders/background_fragment.hlsl b/engine/pill_engine/res/shaders/background_fragment.hlsl index 395a8ef4..2cde4592 100644 --- a/engine/pill_engine/res/shaders/background_fragment.hlsl +++ b/engine/pill_engine/res/shaders/background_fragment.hlsl @@ -1,23 +1,23 @@ -struct SkyCam { - float3 right; float tan_half_fov; - float3 up; float aspect; - float3 fwd; float _pad; - float3 bg_color; float _pad2; +struct BackgroundCamera { + float3 right; float tangent_half_fov; + float3 up; float aspect; + float3 forward; float _padding; + float3 background_color; float _padding_2; }; -[[vk::binding(0, 0)]] ConstantBuffer UCam; -[[vk::binding(1, 0)]] Texture2D texEquirect; -[[vk::binding(2, 0)]] SamplerState smpEquirect; +[[vk::binding(0, 0)]] ConstantBuffer camera; +[[vk::binding(1, 0)]] Texture2D equirect_texture; +[[vk::binding(2, 0)]] SamplerState equirect_sampler; #include "include/equirect.hlsl" [shader("fragment")] -float4 fs_main(float2 ndc : TEXCOORD0) : SV_Target { - float3 dir = normalize( - UCam.right * (ndc.x * UCam.tan_half_fov * UCam.aspect) - + UCam.up * (ndc.y * UCam.tan_half_fov) - + UCam.fwd +float4 fs_main(float2 normalized_device_coordinates : TEXCOORD0) : SV_Target { + float3 direction = normalize( + camera.right * (normalized_device_coordinates.x * camera.tangent_half_fov * camera.aspect) + + camera.up * (normalized_device_coordinates.y * camera.tangent_half_fov) + + camera.forward ); - float2 uv = dir_to_equirect_uv(dir); - float3 sky = texEquirect.Sample(smpEquirect, uv).rgb; - return float4(sky * UCam.bg_color, 1.0); + float2 texture_coordinates = dir_to_equirect_uv(direction); + float3 sky_color = equirect_texture.Sample(equirect_sampler, texture_coordinates).rgb; + return float4(sky_color * camera.background_color, 1.0); } diff --git a/engine/pill_engine/res/shaders/background_vertex.hlsl b/engine/pill_engine/res/shaders/background_vertex.hlsl index f9b8c7b9..15decb6d 100644 --- a/engine/pill_engine/res/shaders/background_vertex.hlsl +++ b/engine/pill_engine/res/shaders/background_vertex.hlsl @@ -1,17 +1,17 @@ -struct VOut { - float4 pos : SV_Position; - float2 ndc : TEXCOORD0; // NDC XY passed to fragment for ray reconstruction +struct VertexOutput { + float4 sv_position : SV_Position; + float2 normalized_device_coordinates : TEXCOORD0; // NDC XY passed to fragment for ray reconstruction }; // Full-screen triangle: 3 vertices cover NDC [-1,1]² without a vertex buffer. -VOut vs_main(uint vi : SV_VertexID) { - float2 p[3] = { +VertexOutput vs_main(uint vertex_id : SV_VertexID) { + float2 positions[3] = { float2(-1.0, -3.0), float2( 3.0, 1.0), float2(-1.0, 1.0) }; - VOut o; - o.pos = float4(p[vi], 1.0, 1.0); // NDC z/w = 1.0 = far plane (LessEqual depth test) - o.ndc = p[vi]; - return o; + VertexOutput output; + output.sv_position = float4(positions[vertex_id], 1.0, 1.0); // NDC z/w = 1.0 = far plane (LessEqual depth test) + output.normalized_device_coordinates = positions[vertex_id]; + return output; } diff --git a/engine/pill_engine/res/shaders/include/equirect.hlsl b/engine/pill_engine/res/shaders/include/equirect.hlsl index 15f6f5d3..8f23c010 100644 --- a/engine/pill_engine/res/shaders/include/equirect.hlsl +++ b/engine/pill_engine/res/shaders/include/equirect.hlsl @@ -1,9 +1,9 @@ -// Equirectangular UV mapping: atan2(z,x) convention matches the bake in studio_equirect.rs. +// Equirectangular UV mapping: atan2(z,x) convention matches the bake in procedural_equirect.rs. static const float EQUIRECT_PI = 3.14159265359; -float2 dir_to_equirect_uv(float3 dir) { - float3 d = normalize(dir); - float u = 0.5 + atan2(d.z, d.x) / (2.0 * EQUIRECT_PI); - float v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) / EQUIRECT_PI; - return float2(frac(u), clamp(v, 0.0, 1.0)); +float2 dir_to_equirect_uv(float3 direction) { + float3 normalized_direction = normalize(direction); + float u_coordinate = 0.5 + atan2(normalized_direction.z, normalized_direction.x) / (2.0 * EQUIRECT_PI); + float v_coordinate = 0.5 - asin(clamp(normalized_direction.y, -1.0, 1.0)) / EQUIRECT_PI; + return float2(frac(u_coordinate), clamp(v_coordinate, 0.0, 1.0)); } diff --git a/engine/pill_engine/res/shaders/pbr_opaque_fragment.hlsl b/engine/pill_engine/res/shaders/pbr_opaque_fragment.hlsl index f1c69cbe..852f5d68 100644 --- a/engine/pill_engine/res/shaders/pbr_opaque_fragment.hlsl +++ b/engine/pill_engine/res/shaders/pbr_opaque_fragment.hlsl @@ -3,45 +3,46 @@ struct Camera { float4 position; - column_major float4x4 viewProjection; + column_major float4x4 view_projection; float3 fog_color; float fog_density; }; -[[vk::binding(0, 0)]] ConstantBuffer UCamera; +[[vk::binding(0, 0)]] ConstantBuffer camera; // IBL resources in globals bind group (set 0, bindings 1-6). -[[vk::binding(1, 0)]] Texture2D texIrradiance; -[[vk::binding(2, 0)]] SamplerState smpIrradiance; -[[vk::binding(3, 0)]] Texture2D texPrefilter; -[[vk::binding(4, 0)]] SamplerState smpPrefilter; -[[vk::binding(5, 0)]] Texture2D texBrdfLut; -[[vk::binding(6, 0)]] SamplerState smpBrdfLut; +[[vk::binding(1, 0)]] Texture2D irradiance_texture; +[[vk::binding(2, 0)]] SamplerState irradiance_sampler; +[[vk::binding(3, 0)]] Texture2D prefilter_texture; +[[vk::binding(4, 0)]] SamplerState prefilter_sampler; +[[vk::binding(5, 0)]] Texture2D brdf_lut_texture; +[[vk::binding(6, 0)]] SamplerState brdf_lut_sampler; // PBR material textures (set 1) — bindings match DEFAULT_LIT_SHADER layout (0-7). -[[vk::binding(0, 1)]] Texture2D texBaseColor; -[[vk::binding(1, 1)]] SamplerState smpBaseColor; -[[vk::binding(2, 1)]] Texture2D texNormal; -[[vk::binding(3, 1)]] SamplerState smpNormal; -[[vk::binding(4, 1)]] Texture2D texMetallicRoughness; -[[vk::binding(5, 1)]] SamplerState smpMetallicRoughness; -[[vk::binding(6, 1)]] Texture2D texEmissive; -[[vk::binding(7, 1)]] SamplerState smpEmissive; +[[vk::binding(0, 1)]] Texture2D base_color_texture; +[[vk::binding(1, 1)]] SamplerState base_color_sampler; +[[vk::binding(2, 1)]] Texture2D normal_texture; +[[vk::binding(3, 1)]] SamplerState normal_sampler; +[[vk::binding(4, 1)]] Texture2D metallic_roughness_texture; +[[vk::binding(5, 1)]] SamplerState metallic_roughness_sampler; +[[vk::binding(6, 1)]] Texture2D emissive_texture; +[[vk::binding(7, 1)]] SamplerState emissive_sampler; // PBR params UBO (set 2) — 48 bytes: 3 × 16-byte slots. // Each scalar slot uses float+float+float2 padding to stay 16 bytes without float3 alignment gaps. struct MaterialParams { - float3 baseColorFactor; - float _pad0; - float roughnessFactor; - float _pad1; - float2 _pad2; - float metallicFactor; - float _pad3; - float2 _pad4; + float3 base_color_factor; + float _padding_0; + float roughness_factor; + float _padding_1; + float2 _padding_2; + float metallic_factor; + float _padding_3; + float2 _padding_4; }; -[[vk::binding(0, 2)]] ConstantBuffer UMaterial; +[[vk::binding(0, 2)]] ConstantBuffer material; -static const float PI = 3.14159265359; +static const float PI = 3.14159265359; +static const float MAX_REFLECTION_LOD = 4.0; #include "include/equirect.hlsl" @@ -53,89 +54,88 @@ static const float4 LIGHT_COL0 = float4(1.0, 0.98, 0.95, 2.2); // near-neutral static const float4 LIGHT_COL1 = float4(0.6, 0.65, 1.00, 0.8); // cool rim static const float4 LIGHT_COL2 = float4(0.8, 0.80, 0.80, 0.3); // neutral bounce -float DistributionGGX(float3 N, float3 H, float roughness) { +float distribution_ggx(float3 normal, float3 half_vector, float roughness) { // Add epsilon to avoid singularities at very low roughness. - float a = max(roughness * roughness, 0.0025); - float a2 = a * a; - float NdotH = max(dot(N, H), 0.0); - float denom = (NdotH * NdotH * (a2 - 1.0) + 1.0); - return a2 / (PI * denom * denom + 1e-7); + float alpha = max(roughness * roughness, 0.0025); + float alpha_squared = alpha * alpha; + float normal_dot_half = max(dot(normal, half_vector), 0.0); + float denominator = (normal_dot_half * normal_dot_half * (alpha_squared - 1.0) + 1.0); + return alpha_squared / (PI * denominator * denominator + 1e-7); } -float GeometrySchlickGGX(float NdotV, float roughness) { +float geometry_schlick_ggx(float normal_dot_view, float roughness) { // Heitz's k for direct lighting approximation. - float r = roughness + 1.0; - float k = (r * r) / 8.0; - return NdotV / (NdotV * (1.0 - k) + k); + float roughness_plus_one = roughness + 1.0; + float k_factor = (roughness_plus_one * roughness_plus_one) / 8.0; + return normal_dot_view / (normal_dot_view * (1.0 - k_factor) + k_factor); } -float GeometrySmith(float3 N, float3 V, float3 L, float roughness) { - float NdotV = max(dot(N, V), 0.0); - float NdotL = max(dot(N, L), 0.0); - return GeometrySchlickGGX(NdotL, roughness) * GeometrySchlickGGX(NdotV, roughness); +float geometry_smith(float3 normal, float3 view_direction, float3 light_direction, float roughness) { + float normal_dot_view = max(dot(normal, view_direction), 0.0); + float normal_dot_light = max(dot(normal, light_direction), 0.0); + return geometry_schlick_ggx(normal_dot_light, roughness) * geometry_schlick_ggx(normal_dot_view, roughness); } -float3 fresnelSchlick(float cosTheta, float3 F0) { - return F0 + (float3(1.0, 1.0, 1.0) - F0) * pow(1.0 - cosTheta, 5.0); +float3 fresnel_schlick(float cos_theta, float3 base_reflectivity) { + return base_reflectivity + (float3(1.0, 1.0, 1.0) - base_reflectivity) * pow(1.0 - cos_theta, 5.0); } -float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness) { - return F0 + (max(float3(1.0 - roughness, 1.0 - roughness, 1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0); +float3 fresnel_schlick_roughness(float cos_theta, float3 base_reflectivity, float roughness) { + return base_reflectivity + (max(float3(1.0 - roughness, 1.0 - roughness, 1.0 - roughness), base_reflectivity) - base_reflectivity) * pow(1.0 - cos_theta, 5.0); } -float3 accumulateDirLight( - float3 N, float3 V, float3 F0, +float3 accumulate_directional_light( + float3 normal, float3 view_direction, float3 base_reflectivity, float3 albedo, float roughness, float metallic, - float3 lightDir, float4 lightColor + float3 light_direction_to_surface, float4 light_color ) { - // lightDir is direction from light to surface; incoming L is opposite. - float3 L = normalize(-lightDir); - float3 H = normalize(V + L); - float3 radiance = lightColor.w * lightColor.xyz; - float NDF = DistributionGGX(N, H, roughness); - float G = GeometrySmith(N, V, L, roughness); - float3 F = fresnelSchlick(max(dot(H, V), 0.0), F0); - float3 kD = (float3(1.0, 1.0, 1.0) - F) * (1.0 - metallic); - float denom = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001; - float3 specular = (NDF * G * F) / float3(denom, denom, denom); - return (kD * (albedo / PI) + specular) * radiance * max(dot(N, L), 0.0); + // light_direction_to_surface is direction from light to surface; incoming light is opposite. + float3 light_direction = normalize(-light_direction_to_surface); + float3 half_vector = normalize(view_direction + light_direction); + float3 radiance = light_color.w * light_color.xyz; + float normal_distribution = distribution_ggx(normal, half_vector, roughness); + float geometry = geometry_smith(normal, view_direction, light_direction, roughness); + float3 fresnel = fresnel_schlick(max(dot(half_vector, view_direction), 0.0), base_reflectivity); + float3 diffuse_factor = (float3(1.0, 1.0, 1.0) - fresnel) * (1.0 - metallic); + float denominator = 4.0 * max(dot(normal, view_direction), 0.0) * max(dot(normal, light_direction), 0.0) + 0.0001; + float3 specular = (normal_distribution * geometry * fresnel) / float3(denominator, denominator, denominator); + return (diffuse_factor * (albedo / PI) + specular) * radiance * max(dot(normal, light_direction), 0.0); } [shader("fragment")] float4 fs_main( - [[vk::location(0)]] float2 uv : TEXCOORD0, - [[vk::location(1)]] float3 WorldPos: TEXCOORD1, - [[vk::location(2)]] float3 NormalIn: TEXCOORD2 + [[vk::location(0)]] float2 texture_coordinates : TEXCOORD0, + [[vk::location(1)]] float3 world_position : TEXCOORD1, + [[vk::location(2)]] float3 input_normal : TEXCOORD2 ) : SV_TARGET { - float3 albedo = texBaseColor.Sample(smpBaseColor, uv).rgb * UMaterial.baseColorFactor; - float2 mr = texMetallicRoughness.Sample(smpMetallicRoughness, uv).gb; - // mr.x = G channel (roughness 0=smooth, 1=rough); mr.y = B channel (metallic 0=dielectric, 1=metal) - float roughness = clamp(mr.x * (1.0 - UMaterial.roughnessFactor), 0.045, 0.99); - float metallic = mr.y * UMaterial.metallicFactor; + float3 albedo = base_color_texture.Sample(base_color_sampler, texture_coordinates).rgb * material.base_color_factor; + float2 metallic_roughness = metallic_roughness_texture.Sample(metallic_roughness_sampler, texture_coordinates).gb; + // metallic_roughness.x = G channel (roughness 0=smooth, 1=rough); metallic_roughness.y = B channel (metallic 0=dielectric, 1=metal) + float roughness = clamp(metallic_roughness.x * (1.0 - material.roughness_factor), 0.045, 0.99); + float metallic = metallic_roughness.y * material.metallic_factor; // TODO: Support normal mapping (tangent space) and AO texture. - float3 N = normalize(NormalIn); - float3 V = normalize(UCamera.position.xyz - WorldPos); - float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, float3(metallic, metallic, metallic)); - float3 Lo = float3(0.0, 0.0, 0.0); - Lo += accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR0, LIGHT_COL0); - Lo += accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR1, LIGHT_COL1); - Lo += accumulateDirLight(N, V, F0, albedo, roughness, metallic, LIGHT_DIR2, LIGHT_COL2); + float3 normal = normalize(input_normal); + float3 view_direction = normalize(camera.position.xyz - world_position); + float3 base_reflectivity = lerp(float3(0.04, 0.04, 0.04), albedo, float3(metallic, metallic, metallic)); + float3 outgoing_radiance = float3(0.0, 0.0, 0.0); + outgoing_radiance += accumulate_directional_light(normal, view_direction, base_reflectivity, albedo, roughness, metallic, LIGHT_DIR0, LIGHT_COL0); + outgoing_radiance += accumulate_directional_light(normal, view_direction, base_reflectivity, albedo, roughness, metallic, LIGHT_DIR1, LIGHT_COL1); + outgoing_radiance += accumulate_directional_light(normal, view_direction, base_reflectivity, albedo, roughness, metallic, LIGHT_DIR2, LIGHT_COL2); // Diffuse IBL ambient. - float3 kS = fresnelSchlick(max(dot(N, V), 0.0), F0); - float3 kD = (float3(1.0, 1.0, 1.0) - kS) * (1.0 - metallic); - float3 irradiance = texIrradiance.Sample(smpIrradiance, dir_to_equirect_uv(N)).rgb; - float3 ambientDiffuse = kD * irradiance * albedo; + float3 specular_factor = fresnel_schlick(max(dot(normal, view_direction), 0.0), base_reflectivity); + float3 diffuse_factor = (float3(1.0, 1.0, 1.0) - specular_factor) * (1.0 - metallic); + float3 irradiance = irradiance_texture.Sample(irradiance_sampler, dir_to_equirect_uv(normal)).rgb; + float3 ambient_diffuse = diffuse_factor * irradiance * albedo; // Specular IBL. - float3 R = reflect(-V, N); - float MAX_REFLECTION_LOD = 4.0; - float3 prefilteredColor = texPrefilter.SampleLevel(smpPrefilter, dir_to_equirect_uv(R), roughness * MAX_REFLECTION_LOD).rgb; - float2 envBRDF = texBrdfLut.Sample(smpBrdfLut, float2(max(dot(N, V), 0.0), roughness)).rg; - float3 F = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness); - float3 specularIBL = prefilteredColor * (F * envBRDF.x + envBRDF.y); - float3 emissive = texEmissive.Sample(smpEmissive, uv).rgb; - float3 color = Lo + ambientDiffuse + specularIBL + emissive; - float dist = length(UCamera.position.xyz - WorldPos); - float fog = 1.0 - exp(-UCamera.fog_density * UCamera.fog_density * dist * dist); - return float4(lerp(color, UCamera.fog_color, fog), 1.0); + float3 reflection = reflect(-view_direction, normal); + float3 prefiltered_color = prefilter_texture.SampleLevel(prefilter_sampler, dir_to_equirect_uv(reflection), roughness * MAX_REFLECTION_LOD).rgb; + float2 environment_brdf = brdf_lut_texture.Sample(brdf_lut_sampler, float2(max(dot(normal, view_direction), 0.0), roughness)).rg; + float3 fresnel = fresnel_schlick_roughness(max(dot(normal, view_direction), 0.0), base_reflectivity, roughness); + float3 specular_ibl = prefiltered_color * (fresnel * environment_brdf.x + environment_brdf.y); + float3 emissive = emissive_texture.Sample(emissive_sampler, texture_coordinates).rgb; + float3 color = outgoing_radiance + ambient_diffuse + specular_ibl + emissive; + float distance = length(camera.position.xyz - world_position); + float fog = 1.0 - exp(-camera.fog_density * camera.fog_density * distance * distance); + return float4(lerp(color, camera.fog_color, fog), 1.0); } diff --git a/engine/pill_engine/res/shaders/pbr_opaque_vertex.hlsl b/engine/pill_engine/res/shaders/pbr_opaque_vertex.hlsl index 9632f8da..969eccdd 100644 --- a/engine/pill_engine/res/shaders/pbr_opaque_vertex.hlsl +++ b/engine/pill_engine/res/shaders/pbr_opaque_vertex.hlsl @@ -6,51 +6,51 @@ struct Camera { float4 position; - column_major float4x4 viewProjection; + column_major float4x4 view_projection; float3 fog_color; float fog_density; }; -[[vk::binding(0, 0)]] ConstantBuffer UCamera; +[[vk::binding(0, 0)]] ConstantBuffer camera; struct PerDraw { float4 position; // xyz float4 rotation; // xyz, radians float4 scale; // xyz }; -[[vk::binding(0, 3)]] StructuredBuffer UPerDrawArray; +[[vk::binding(0, 3)]] StructuredBuffer per_draw_array; -struct VSOut { - [[vk::location(0)]] float2 uv : TEXCOORD0; - [[vk::location(1)]] float3 worldPos : TEXCOORD1; - [[vk::location(2)]] float3 worldNormal: TEXCOORD2; - float4 sv_position: SV_POSITION; +struct VertexOutput { + [[vk::location(0)]] float2 texture_coordinates : TEXCOORD0; + [[vk::location(1)]] float3 world_position : TEXCOORD1; + [[vk::location(2)]] float3 world_normal : TEXCOORD2; + float4 sv_position : SV_POSITION; }; -struct VSIn { - [[vk::location(0)]] float3 pos; - [[vk::location(4)]] float2 uv; +struct VertexInput { + [[vk::location(0)]] float3 position; + [[vk::location(4)]] float2 texture_coordinates; [[vk::location(5)]] float3 normal; }; // Row-major float3x3 constructors; mul(R, v) applies standard right-handed rotation. -float3x3 rot_x(float a) { float c = cos(a), s = sin(a); return float3x3(1, 0, 0, 0, c, -s, 0, s, c); } -float3x3 rot_y(float a) { float c = cos(a), s = sin(a); return float3x3(c, 0, s, 0, 1, 0, -s, 0, c); } -float3x3 rot_z(float a) { float c = cos(a), s = sin(a); return float3x3(c, -s, 0, s, c, 0, 0, 0, 1); } +float3x3 rot_x(float angle) { float cosine = cos(angle), sine = sin(angle); return float3x3(1, 0, 0, 0, cosine, -sine, 0, sine, cosine); } +float3x3 rot_y(float angle) { float cosine = cos(angle), sine = sin(angle); return float3x3(cosine, 0, sine, 0, 1, 0, -sine, 0, cosine); } +float3x3 rot_z(float angle) { float cosine = cos(angle), sine = sin(angle); return float3x3(cosine, -sine, 0, sine, cosine, 0, 0, 0, 1); } [shader("vertex")] -VSOut vs_main(VSIn input, uint instance_id : SV_InstanceID) { - PerDraw per_draw = UPerDrawArray[instance_id]; +VertexOutput vs_main(VertexInput input, uint instance_id : SV_InstanceID) { + PerDraw per_draw = per_draw_array[instance_id]; // model = T * (Rx*Ry*Rz) * S — matches glam from_scale_rotation_translation(Quat::x*y*z). float3x3 rotation = mul(rot_x(per_draw.rotation.x), mul(rot_y(per_draw.rotation.y), rot_z(per_draw.rotation.z))); - float3 scaled = input.pos * per_draw.scale.xyz; - float3 worldPos = per_draw.position.xyz + mul(rotation, scaled); - float4 worldPos4 = float4(worldPos, 1.0); - - VSOut output; - output.sv_position = mul(UCamera.viewProjection, worldPos4); - output.uv = input.uv; - output.worldPos = worldPos; - output.worldNormal = normalize(mul(rotation, input.normal)); + float3 scaled_position = input.position * per_draw.scale.xyz; + float3 world_position = per_draw.position.xyz + mul(rotation, scaled_position); + float4 world_position_homogeneous = float4(world_position, 1.0); + + VertexOutput output; + output.sv_position = mul(camera.view_projection, world_position_homogeneous); + output.texture_coordinates = input.texture_coordinates; + output.world_position = world_position; + output.world_normal = normalize(mul(rotation, input.normal)); return output; } diff --git a/engine/pill_engine/res/shaders/tonemap_fragment.hlsl b/engine/pill_engine/res/shaders/tonemap_fragment.hlsl index 3f68b9d5..adebcd55 100644 --- a/engine/pill_engine/res/shaders/tonemap_fragment.hlsl +++ b/engine/pill_engine/res/shaders/tonemap_fragment.hlsl @@ -1,9 +1,9 @@ -[[vk::binding(0, 0)]] Texture2D texHdr; -[[vk::binding(1, 0)]] SamplerState smpHdr; +[[vk::binding(0, 0)]] Texture2D hdr_texture; +[[vk::binding(1, 0)]] SamplerState hdr_sampler; [shader("fragment")] -float4 fs_main(float2 uv : TEXCOORD0) : SV_Target { - float3 hdr = texHdr.Sample(smpHdr, uv).rgb; - float3 mapped = hdr / (hdr + 1.0); - return float4(mapped, 1.0); +float4 fs_main(float2 texture_coordinates : TEXCOORD0) : SV_Target { + float3 hdr_color = hdr_texture.Sample(hdr_sampler, texture_coordinates).rgb; + float3 mapped_color = hdr_color / (hdr_color + 1.0); + return float4(mapped_color, 1.0); } diff --git a/engine/pill_engine/res/shaders/tonemap_vertex.hlsl b/engine/pill_engine/res/shaders/tonemap_vertex.hlsl index 66a8a1a1..3c6c64f4 100644 --- a/engine/pill_engine/res/shaders/tonemap_vertex.hlsl +++ b/engine/pill_engine/res/shaders/tonemap_vertex.hlsl @@ -1,16 +1,16 @@ -struct VOut { - float4 pos : SV_Position; - float2 uv : TEXCOORD0; +struct VertexOutput { + float4 sv_position : SV_Position; + float2 texture_coordinates : TEXCOORD0; }; -VOut vs_main(uint vi : SV_VertexID) { - float2 p[3] = { +VertexOutput vs_main(uint vertex_id : SV_VertexID) { + float2 positions[3] = { float2(-1.0, -3.0), float2( 3.0, 1.0), float2(-1.0, 1.0) }; - VOut o; - o.pos = float4(p[vi], 0.0, 1.0); - o.uv = float2(p[vi].x * 0.5 + 0.5, -p[vi].y * 0.5 + 0.5); - return o; + VertexOutput output; + output.sv_position = float4(positions[vertex_id], 0.0, 1.0); + output.texture_coordinates = float2(positions[vertex_id].x * 0.5 + 0.5, -positions[vertex_id].y * 0.5 + 0.5); + return output; } diff --git a/engine/pill_engine/res/textures/studio.studio b/engine/pill_engine/res/textures/studio.procedural_equirect similarity index 100% rename from engine/pill_engine/res/textures/studio.studio rename to engine/pill_engine/res/textures/studio.procedural_equirect diff --git a/engine/pill_engine/src/graphics/pass_background.rs b/engine/pill_engine/src/graphics/pass_background.rs index 67b2fa24..89981e98 100644 --- a/engine/pill_engine/src/graphics/pass_background.rs +++ b/engine/pill_engine/src/graphics/pass_background.rs @@ -10,17 +10,17 @@ use pill_core::{PillSlotMapKey, Result}; use crate::config::DEFAULT_EQUIRECT_FALLBACK_PIXEL; -static VS: &str = include_str!("../../res/shaders/background_vertex.wgsl"); -static FS: &str = include_str!("../../res/shaders/background_fragment.wgsl"); +static VERTEX_SHADER: &str = include_str!("../../res/shaders/background_vertex.wgsl"); +static FRAGMENT_SHADER: &str = include_str!("../../res/shaders/background_fragment.wgsl"); pub struct PassBackground { hdr_target: RendererTextureHandle, equirect: Option, - bg_color: [f32; 3], - state: Option, + background_color: [f32; 3], + state: Option, } -struct BgState { +struct BackgroundState { pipeline: PipelineV2, bind_group: wgpu::BindGroup, camera_buffer: wgpu::Buffer, @@ -29,15 +29,15 @@ struct BgState { #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -struct BgCameraUbo { +struct BackgroundCameraUniform { right: [f32; 3], - tan_half_fov: f32, + tangent_half_fov: f32, up: [f32; 3], aspect: f32, - fwd: [f32; 3], - _pad: f32, - bg_color: [f32; 3], - _pad2: f32, + forward: [f32; 3], + _padding: f32, + background_color: [f32; 3], + _padding_2: f32, } impl PassBackground { @@ -45,7 +45,7 @@ impl PassBackground { Self { hdr_target, equirect: None, - bg_color: [1.0, 1.0, 1.0], + background_color: [1.0, 1.0, 1.0], state: None, } } @@ -55,8 +55,8 @@ impl PassBackground { self } - pub fn with_bg_color(mut self, color: [f32; 3]) -> Self { - self.bg_color = color; + pub fn with_background_color(mut self, color: [f32; 3]) -> Self { + self.background_color = color; self } } @@ -99,11 +99,11 @@ impl Pass for PassBackground { let pipeline = renderer.create_pipeline_v2(PipelineV2Desc { label: Some("pass_background"), vs: ShaderDesc { - source: VS, + source: VERTEX_SHADER, entry_func: "vs_main", }, ps: ShaderDesc { - source: FS, + source: FRAGMENT_SHADER, entry_func: "fs_main", }, vertex_buffers: &[], @@ -127,8 +127,8 @@ impl Pass for PassBackground { })?; // Create equirect view; register a 1×1 Rgba32Float black fallback when no handle is set. - let equirect_h = match self.equirect { - Some(h) => h, + let equirect_handle = match self.equirect { + Some(handle) => handle, None => renderer.create_texture_from_pixels( "equirect_fallback", &[DEFAULT_EQUIRECT_FALLBACK_PIXEL], @@ -138,7 +138,7 @@ impl Pass for PassBackground { ), }; let view = renderer - .get_texture_view(equirect_h) + .get_texture_view(equirect_handle) .expect("equirect handle invalid"); let sampler = renderer @@ -157,7 +157,7 @@ impl Pass for PassBackground { .get_device() .create_buffer(&wgpu::BufferDescriptor { label: Some("pass_background_camera"), - size: std::mem::size_of::() as u64, + size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -186,7 +186,7 @@ impl Pass for PassBackground { }) }; - self.state = Some(BgState { + self.state = Some(BackgroundState { pipeline, bind_group, camera_buffer, @@ -208,51 +208,55 @@ impl Pass for PassBackground { // Compute inv_view_proj from active camera — same matrices as PBR pass. let camera_components = world.query::()?; let transform_components = world.query::()?; - let active_idx = world.active_camera.data().index as usize; - let cam = camera_components + let active_camera_index = world.active_camera.data().index as usize; + let camera = camera_components .data - .get(active_idx) + .get(active_camera_index) .unwrap() .as_ref() .unwrap(); - let tfm = transform_components + let transform = transform_components .data - .get(active_idx) + .get(active_camera_index) .unwrap() .as_ref() .unwrap(); - let eye = Vec3::new(tfm.position.x, tfm.position.y, tfm.position.z); - let fwd = if let Some(t) = cam.look_at { - (Vec3::new(t.x, t.y, t.z) - eye).normalize() + let eye = Vec3::new( + transform.position.x, + transform.position.y, + transform.position.z, + ); + let forward = if let Some(target) = camera.look_at { + (Vec3::new(target.x, target.y, target.z) - eye).normalize() } else { - let roll = Mat3::from_rotation_z(tfm.rotation.z.to_radians()); - let yaw = Mat3::from_rotation_y(tfm.rotation.y.to_radians()); - let pitch = Mat3::from_rotation_x(tfm.rotation.x.to_radians()); + let roll = Mat3::from_rotation_z(transform.rotation.z.to_radians()); + let yaw = Mat3::from_rotation_y(transform.rotation.y.to_radians()); + let pitch = Mat3::from_rotation_x(transform.rotation.x.to_radians()); (yaw * pitch * roll) * Vec3::Z }; - let right = fwd.cross(Vec3::Y).normalize(); - let up = right.cross(fwd); + let right = forward.cross(Vec3::Y).normalize(); + let up = right.cross(forward); - let ubo = BgCameraUbo { + let uniform = BackgroundCameraUniform { right: right.to_array(), - tan_half_fov: (cam.fov.to_radians() / 2.0).tan(), + tangent_half_fov: (camera.fov.to_radians() / 2.0).tan(), up: up.to_array(), - aspect: cam.aspect.get_value(), - fwd: fwd.to_array(), - _pad: 0.0, - bg_color: self.bg_color, - _pad2: 0.0, + aspect: camera.aspect.get_value(), + forward: forward.to_array(), + _padding: 0.0, + background_color: self.background_color, + _padding_2: 0.0, }; renderer .get_queue() - .write_buffer(&state.camera_buffer, 0, bytemuck::bytes_of(&ubo)); + .write_buffer(&state.camera_buffer, 0, bytemuck::bytes_of(&uniform)); - let hdr_view = renderer.get_render_target_view(self.hdr_target).unwrap(); - let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + let hdr_target_view = renderer.get_render_target_view(self.hdr_target).unwrap(); + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("pass_background_render_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: hdr_view, + view: hdr_target_view, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), @@ -263,9 +267,9 @@ impl Pass for PassBackground { timestamp_writes: None, occlusion_query_set: None, }); - rp.set_pipeline(&state.pipeline.pipeline); - rp.set_bind_group(0, &state.bind_group, &[]); - rp.draw(0..3, 0..1); + render_pass.set_pipeline(&state.pipeline.pipeline); + render_pass.set_bind_group(0, &state.bind_group, &[]); + render_pass.draw(0..3, 0..1); Ok(()) } } diff --git a/examples/pbr_balls/res/models/studio.studio b/examples/pbr_balls/res/models/studio.procedural_equirect similarity index 100% rename from examples/pbr_balls/res/models/studio.studio rename to examples/pbr_balls/res/models/studio.procedural_equirect diff --git a/examples/pbr_balls/res/studio.studio b/examples/pbr_balls/res/studio.procedural_equirect similarity index 100% rename from examples/pbr_balls/res/studio.studio rename to examples/pbr_balls/res/studio.procedural_equirect diff --git a/examples/pbr_balls/src/game.rs b/examples/pbr_balls/src/game.rs index 668df37c..e58a26a9 100644 --- a/examples/pbr_balls/src/game.rs +++ b/examples/pbr_balls/src/game.rs @@ -12,20 +12,26 @@ pub struct Game {} impl PillGame for Game { fn start(&self, engine: &mut Engine) -> Result<()> { - let (eq, eq_w, eq_h) = bake::generate(); - let (diffuse, specular_mips, brdf_lut) = bake::bake_all(&eq, eq_w, eq_h); - - let bg_h = engine.create_gpu_texture_f32("equirect", &eq, eq_w, eq_h)?; - let diff_h = engine.create_gpu_texture_f32("diffuse_ibl", &diffuse, 32, 16)?; - let spec_h = + let (equirect, equirect_width, equirect_height) = bake::generate(); + let (diffuse, specular_mips, brdf_lut) = + bake::bake_all(&equirect, equirect_width, equirect_height); + + let background_handle = engine.create_gpu_texture_f32( + "equirect", + &equirect, + equirect_width, + equirect_height, + )?; + let diffuse_handle = engine.create_gpu_texture_f32("diffuse_ibl", &diffuse, 32, 16)?; + let specular_handle = engine.create_gpu_mipped_texture_f32("specular_ibl", &specular_mips, 128, 64)?; - let lut_h = engine.create_gpu_texture_f32("brdf_lut", &brdf_lut, 256, 256)?; + let brdf_lut_handle = engine.create_gpu_texture_f32("brdf_lut", &brdf_lut, 256, 256)?; - let rs = engine.get_global_component_mut::()?; - rs.background = bg_h; - rs.ibl_diffuse = diff_h; - rs.ibl_specular = spec_h; - rs.ibl_brdf_lut = lut_h; + let render_state = engine.get_global_component_mut::()?; + render_state.background = background_handle; + render_state.ibl_diffuse = diffuse_handle; + render_state.ibl_specular = specular_handle; + render_state.ibl_brdf_lut = brdf_lut_handle; let scene = engine.create_scene("pbr_balls")?; engine.set_active_scene(scene)?; @@ -48,16 +54,16 @@ impl PillGame for Game { include_bytes!("../res/models/MetalRoughSpheres_albedo.cooked_tex"), ))?; - let mr_handle = engine.add_resource(Texture::from_bytes( - "spheres_mr", + let metallic_roughness_handle = engine.add_resource(Texture::from_bytes( + "spheres_metallic_roughness", TextureType::MetallicRoughness, include_bytes!("../res/models/MetalRoughSpheres_metallic_roughness.cooked_tex"), ))?; - let mat_handle = engine.add_resource( + let material_handle = engine.add_resource( PBRMaterial::new("spheres_mat") .albedo_texture(albedo_handle) - .metallic_roughness_texture(mr_handle) + .metallic_roughness_texture(metallic_roughness_handle) .metallic(1.0) .roughness(1.0), )?; @@ -68,7 +74,7 @@ impl PillGame for Game { .with_component( PbrRenderableComponent::builder() .mesh(&mesh_handle) - .pbr_material(&mat_handle) + .pbr_material(&material_handle) .build(), ) .build(); @@ -103,21 +109,23 @@ fn orbit_camera_system(engine: &mut Engine) -> Result<()> { let input = engine.get_global_component_mut::()?; let mouse_delta = input.get_mouse_delta(); let scroll_delta = input.get_mouse_scroll_delta(); - let lmb = input.get_mouse_button(MouseButton::Left); + let left_mouse_button = input.get_mouse_button(MouseButton::Left); - for (_, tfm, orbit) in engine.iterate_two_components_mut::()? { - if lmb { + for (_, transform, orbit) in + engine.iterate_two_components_mut::()? + { + if left_mouse_button { orbit.yaw -= mouse_delta.x * 0.3; orbit.pitch = (orbit.pitch + mouse_delta.y * 0.3).clamp(-70.0, 70.0); } orbit.radius = (orbit.radius - scroll_delta.y * 0.5).clamp(1.0, 50.0); - let pr = orbit.pitch.to_radians(); - let yr = orbit.yaw.to_radians(); - tfm.set_position(Vector3f::new( - orbit.radius * pr.cos() * yr.sin(), - orbit.radius * pr.sin(), - orbit.radius * pr.cos() * yr.cos(), + let pitch_radians = orbit.pitch.to_radians(); + let yaw_radians = orbit.yaw.to_radians(); + transform.set_position(Vector3f::new( + orbit.radius * pitch_radians.cos() * yaw_radians.sin(), + orbit.radius * pitch_radians.sin(), + orbit.radius * pitch_radians.cos() * yaw_radians.cos(), )); } Ok(()) From af052d1ef9bc31e2419275d50be9177e6abc68be Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Sun, 7 Jun 2026 17:43:35 +0200 Subject: [PATCH 31/31] fix: ci, wasm cube budget gate edge case --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea49da63..0e08a3f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,10 +223,10 @@ jobs: run: | WASM="examples/cube/build/wasm/pill_web_app_bg.wasm" ACTUAL=$(wc -c < "$WASM") - MAX=$((499 * 1024)) - if [ "$ACTUAL" -gt "$MAX" ]; then - echo "FAIL: WASM $((ACTUAL / 1024)) KB exceeds 499 KB budget" + ACTUAL_KB=$((ACTUAL / 1024)) + if [ "$ACTUAL_KB" -gt 499 ]; then + echo "FAIL: WASM $ACTUAL_KB KB exceeds 499 KB budget" exit 1 fi - echo "OK: WASM $((ACTUAL / 1024)) KB <= 499 KB" + echo "OK: WASM $ACTUAL_KB KB <= 499 KB"