diff --git a/Cargo.toml b/Cargo.toml index 3dd8bf5cf..23f949e98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -192,3 +192,8 @@ path = "examples/multiwindow/src/main.rs" [[example]] name = "logo" path = "examples/logo/src/main.rs" + +[[example]] +name = "heightmap" +path = "examples/heightmap/src/main.rs" +required-features = ["egui-gui"] diff --git a/examples/assets/attribution.md b/examples/assets/attribution.md new file mode 100644 index 000000000..c9814179b --- /dev/null +++ b/examples/assets/attribution.md @@ -0,0 +1,9 @@ +# Asset Attribution + +| Asset | Source | +|----------------------|-----------------------------------------------------| +| /textures/brick/* | https://freepbr.com/product/alley-brick-wall-pbr/ | +| /textures/concrete/* | https://freepbr.com/product/broken-down-concrete-2/ | +| /textures/metal/* | https://freepbr.com/product/alien-abstract-metal/ | + +All assets used under non-commercial terms and fair use doctrine. diff --git a/examples/assets/textures/brick/brick_albedo.jpg b/examples/assets/textures/brick/brick_albedo.jpg new file mode 100644 index 000000000..c4dd8b13f Binary files /dev/null and b/examples/assets/textures/brick/brick_albedo.jpg differ diff --git a/examples/assets/textures/brick/brick_ao.jpg b/examples/assets/textures/brick/brick_ao.jpg new file mode 100644 index 000000000..467e5b12b Binary files /dev/null and b/examples/assets/textures/brick/brick_ao.jpg differ diff --git a/examples/assets/textures/brick/brick_height.jpg b/examples/assets/textures/brick/brick_height.jpg new file mode 100644 index 000000000..d479f75d6 Binary files /dev/null and b/examples/assets/textures/brick/brick_height.jpg differ diff --git a/examples/assets/textures/brick/brick_metallic.jpg b/examples/assets/textures/brick/brick_metallic.jpg new file mode 100644 index 000000000..8e8548212 Binary files /dev/null and b/examples/assets/textures/brick/brick_metallic.jpg differ diff --git a/examples/assets/textures/brick/brick_normal.jpg b/examples/assets/textures/brick/brick_normal.jpg new file mode 100644 index 000000000..9f3b84472 Binary files /dev/null and b/examples/assets/textures/brick/brick_normal.jpg differ diff --git a/examples/assets/textures/brick/brick_roughness.jpg b/examples/assets/textures/brick/brick_roughness.jpg new file mode 100644 index 000000000..a872f8117 Binary files /dev/null and b/examples/assets/textures/brick/brick_roughness.jpg differ diff --git a/examples/assets/textures/concrete/concrete_albedo.jpg b/examples/assets/textures/concrete/concrete_albedo.jpg new file mode 100644 index 000000000..c2b949444 Binary files /dev/null and b/examples/assets/textures/concrete/concrete_albedo.jpg differ diff --git a/examples/assets/textures/concrete/concrete_ao.jpg b/examples/assets/textures/concrete/concrete_ao.jpg new file mode 100644 index 000000000..cd9ef86f5 Binary files /dev/null and b/examples/assets/textures/concrete/concrete_ao.jpg differ diff --git a/examples/assets/textures/concrete/concrete_height.jpg b/examples/assets/textures/concrete/concrete_height.jpg new file mode 100644 index 000000000..52c99fe18 Binary files /dev/null and b/examples/assets/textures/concrete/concrete_height.jpg differ diff --git a/examples/assets/textures/concrete/concrete_metallic.jpg b/examples/assets/textures/concrete/concrete_metallic.jpg new file mode 100644 index 000000000..b62c48e3c Binary files /dev/null and b/examples/assets/textures/concrete/concrete_metallic.jpg differ diff --git a/examples/assets/textures/concrete/concrete_normal.jpg b/examples/assets/textures/concrete/concrete_normal.jpg new file mode 100644 index 000000000..a3fa9bdb8 Binary files /dev/null and b/examples/assets/textures/concrete/concrete_normal.jpg differ diff --git a/examples/assets/textures/concrete/concrete_roughness.jpg b/examples/assets/textures/concrete/concrete_roughness.jpg new file mode 100644 index 000000000..a4a7c1401 Binary files /dev/null and b/examples/assets/textures/concrete/concrete_roughness.jpg differ diff --git a/examples/assets/textures/metal/metal_albedo.jpg b/examples/assets/textures/metal/metal_albedo.jpg new file mode 100644 index 000000000..a1ea724b5 Binary files /dev/null and b/examples/assets/textures/metal/metal_albedo.jpg differ diff --git a/examples/assets/textures/metal/metal_ao.jpg b/examples/assets/textures/metal/metal_ao.jpg new file mode 100644 index 000000000..c70d08320 Binary files /dev/null and b/examples/assets/textures/metal/metal_ao.jpg differ diff --git a/examples/assets/textures/metal/metal_height.jpg b/examples/assets/textures/metal/metal_height.jpg new file mode 100644 index 000000000..6ddf1f32a Binary files /dev/null and b/examples/assets/textures/metal/metal_height.jpg differ diff --git a/examples/assets/textures/metal/metal_metallic.jpg b/examples/assets/textures/metal/metal_metallic.jpg new file mode 100644 index 000000000..8a64f960f Binary files /dev/null and b/examples/assets/textures/metal/metal_metallic.jpg differ diff --git a/examples/assets/textures/metal/metal_normal.jpg b/examples/assets/textures/metal/metal_normal.jpg new file mode 100644 index 000000000..e588ad06d Binary files /dev/null and b/examples/assets/textures/metal/metal_normal.jpg differ diff --git a/examples/assets/textures/metal/metal_roughness.jpg b/examples/assets/textures/metal/metal_roughness.jpg new file mode 100644 index 000000000..819cd01d6 Binary files /dev/null and b/examples/assets/textures/metal/metal_roughness.jpg differ diff --git a/examples/heightmap/Cargo.toml b/examples/heightmap/Cargo.toml new file mode 100644 index 000000000..6e3fc55df --- /dev/null +++ b/examples/heightmap/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "heightmap" +version = "0.1.0" +authors = ["Asger Nyman Christiansen "] +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +three-d = { path = "../../" } +three-d-asset = {git = "https://github.com/asny/three-d-asset", features = ["png", "jpeg", "http"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +log = "0.4" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +console_error_panic_hook = "0.1" +console_log = "1" diff --git a/examples/heightmap/src/lib.rs b/examples/heightmap/src/lib.rs new file mode 100644 index 000000000..b18f250d2 --- /dev/null +++ b/examples/heightmap/src/lib.rs @@ -0,0 +1,19 @@ +#![allow(special_module_name)] +mod main; + +// Entry point for wasm +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::*; + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen(start)] +pub async fn start() -> Result<(), JsValue> { + console_log::init_with_level(log::Level::Debug).unwrap(); + + use log::info; + info!("Logging works!"); + + std::panic::set_hook(Box::new(console_error_panic_hook::hook)); + main::run().await; + Ok(()) +} diff --git a/examples/heightmap/src/main.rs b/examples/heightmap/src/main.rs new file mode 100644 index 000000000..cc27961b4 --- /dev/null +++ b/examples/heightmap/src/main.rs @@ -0,0 +1,652 @@ +//! Heightmap Example +//! +//! Demonstrates Parallax Occlusion Mapping (POM) with PhysicalMaterial and +//! automatic normal/AO map generation from heightmaps. +//! +//! Shows 9 rendering modes for 3 different materials (brick, concrete, metal): +//! 1. PBR (Albedo + Metallic + Roughness) +//! 2. PBR + Normal +//! 3. PBR + AO +//! 4. PBR + Normal + AO +//! 5. PBR + Height + Normal + AO (POM enabled) +//! 6. PBR + Height only (POM enabled) +//! 7. PBR + Height + Generated Normal +//! 8. PBR + Height + Generated AO +//! 9. PBR + Height + Generated Normal + Generated AO + +// Entry point for non-wasm +#[cfg(not(target_arch = "wasm32"))] +#[tokio::main] +async fn main() { + run().await; +} + +use three_d::*; + +/// A complete set of PBR textures for a material +struct TextureSet { + name: String, + albedo: CpuTexture, + normal: CpuTexture, + ao: CpuTexture, + heightmap: CpuTexture, + metallic_roughness: CpuTexture, + generated_normal: CpuTexture, + generated_ao: CpuTexture, +} + +/// Track which quads have POM enabled (columns 4-8 have height textures) +fn has_pom(quad_index: usize) -> bool { + let col = quad_index % 9; + col >= 4 +} + +/// Project a 3D world position to 2D screen coordinates +fn world_to_screen(camera: &Camera, world_pos: Vec3, viewport: Viewport) -> Option<(f32, f32)> { + let view_proj = camera.projection() * camera.view(); + let clip = view_proj * world_pos.extend(1.0); + + // Behind camera check + if clip.w <= 0.0 { + return None; + } + + // Perspective divide to get NDC (-1 to 1) + let ndc = clip.truncate() / clip.w; + + // Convert NDC to screen coordinates + let screen_x = (ndc.x + 1.0) * 0.5 * viewport.width as f32 + viewport.x as f32; + let screen_y = (1.0 - ndc.y) * 0.5 * viewport.height as f32 + viewport.y as f32; + + Some((screen_x, screen_y)) +} + +pub async fn run() { + let window = Window::new(WindowSettings { + title: "Heightmap (Parallax Occlusion Mapping)".to_string(), + max_size: Some((1920, 1080)), + ..Default::default() + }) + .unwrap(); + let context = window.gl(); + + // ======================================================================== + // Load textures + // ======================================================================== + println!("Loading textures..."); + + println!(" Loading brick textures..."); + let brick = load_texture_set( + "brick", + "examples/assets/textures/brick/brick", + ) + .await; + + println!(" Loading concrete textures..."); + let concrete = load_texture_set( + "concrete", + "examples/assets/textures/concrete/concrete", + ) + .await; + + println!(" Loading metal textures..."); + let metal = load_texture_set( + "metal", + "examples/assets/textures/metal/metal", + ) + .await; + + println!("All textures loaded!"); + + // ======================================================================== + // Create materials and quads for each texture set + // ======================================================================== + println!("Creating materials..."); + + let texture_sets = [brick, concrete, metal]; + let mut all_quads: Vec> = Vec::new(); + + // Create quad mesh with tangents + let mut quad_cpu = CpuMesh::square(); + quad_cpu.compute_tangents(); + + // Labels for columns and rows + let col_labels = [ + "PBR", + "+Normal", + "+AO", + "+Normal+AO", + "+Height+Normal+AO", + "+Height", + "+Height+GenNormal", + "+Height+GenAO", + "+Height+GenNormal+GenAO", + ]; + let row_labels = ["Brick", "Concrete", "Metal"]; + + // Spacing (8 columns, 3 rows) + let num_cols = col_labels.len(); + let col_spacing = 2.2_f32; + let row_spacing = 2.4_f32; + let start_x = -((num_cols as f32 - 1.0) / 2.0) * col_spacing; + let start_y = ((texture_sets.len() as f32 - 1.0) / 2.0) * row_spacing; + + for (row, tex_set) in texture_sets.iter().enumerate() { + println!(" Creating {} materials...", tex_set.name); + + let y = start_y - row as f32 * row_spacing; + + // Preload textures to GPU + let albedo_tex = Texture2DRef::from_cpu_texture(&context, &tex_set.albedo); + let metallic_roughness_tex = Texture2DRef::from_cpu_texture(&context, &tex_set.metallic_roughness); + let normal_tex = Texture2DRef::from_cpu_texture(&context, &tex_set.normal); + let ao_tex = Texture2DRef::from_cpu_texture(&context, &tex_set.ao); + let height_tex = Texture2DRef::from_cpu_texture(&context, &tex_set.heightmap); + let gen_normal_tex = Texture2DRef::from_cpu_texture(&context, &tex_set.generated_normal); + let gen_ao_tex = Texture2DRef::from_cpu_texture(&context, &tex_set.generated_ao); + + // Column 0: PBR (Albedo + Metallic + Roughness) + let mat0 = PhysicalMaterial { + name: format!("{}_pbr", tex_set.name), + albedo: Srgba::WHITE, + albedo_texture: Some(albedo_tex.clone()), + metallic: 1.0, + roughness: 1.0, + metallic_roughness_texture: Some(metallic_roughness_tex.clone()), + occlusion_strength: 1.0, + occlusion_texture: None, + normal_scale: 1.0, + normal_texture: None, + render_states: RenderStates::default(), + is_transparent: false, + emissive: Srgba::BLACK, + emissive_texture: None, + lighting_model: LightingModel::Cook( + NormalDistributionFunction::TrowbridgeReitzGGX, + GeometryFunction::SmithSchlickGGX, + ), + height_texture: None, + height_scale: 0.0, + height_quality: HeightQuality::High, + }; + + // Column 1: PBR + Normal + let mat1 = PhysicalMaterial { + name: format!("{}_pbr_normal", tex_set.name), + albedo: Srgba::WHITE, + albedo_texture: Some(albedo_tex.clone()), + metallic: 1.0, + roughness: 1.0, + metallic_roughness_texture: Some(metallic_roughness_tex.clone()), + occlusion_strength: 1.0, + occlusion_texture: None, + normal_scale: 1.0, + normal_texture: Some(normal_tex.clone()), + render_states: RenderStates::default(), + is_transparent: false, + emissive: Srgba::BLACK, + emissive_texture: None, + lighting_model: LightingModel::Cook( + NormalDistributionFunction::TrowbridgeReitzGGX, + GeometryFunction::SmithSchlickGGX, + ), + height_texture: None, + height_scale: 0.0, + height_quality: HeightQuality::High, + }; + + // Column 2: PBR + AO + let mat2 = PhysicalMaterial { + name: format!("{}_pbr_ao", tex_set.name), + albedo: Srgba::WHITE, + albedo_texture: Some(albedo_tex.clone()), + metallic: 1.0, + roughness: 1.0, + metallic_roughness_texture: Some(metallic_roughness_tex.clone()), + occlusion_strength: 1.0, + occlusion_texture: Some(ao_tex.clone()), + normal_scale: 1.0, + normal_texture: None, + render_states: RenderStates::default(), + is_transparent: false, + emissive: Srgba::BLACK, + emissive_texture: None, + lighting_model: LightingModel::Cook( + NormalDistributionFunction::TrowbridgeReitzGGX, + GeometryFunction::SmithSchlickGGX, + ), + height_texture: None, + height_scale: 0.0, + height_quality: HeightQuality::High, + }; + + // Column 3: PBR + Normal + AO + let mat3 = PhysicalMaterial { + name: format!("{}_pbr_normal_ao", tex_set.name), + albedo: Srgba::WHITE, + albedo_texture: Some(albedo_tex.clone()), + metallic: 1.0, + roughness: 1.0, + metallic_roughness_texture: Some(metallic_roughness_tex.clone()), + occlusion_strength: 1.0, + occlusion_texture: Some(ao_tex.clone()), + normal_scale: 1.0, + normal_texture: Some(normal_tex.clone()), + render_states: RenderStates::default(), + is_transparent: false, + emissive: Srgba::BLACK, + emissive_texture: None, + lighting_model: LightingModel::Cook( + NormalDistributionFunction::TrowbridgeReitzGGX, + GeometryFunction::SmithSchlickGGX, + ), + height_texture: None, + height_scale: 0.0, + height_quality: HeightQuality::High, + }; + + // Column 4: PBR + Height + Normal + AO (POM enabled) + let mat4 = PhysicalMaterial { + name: format!("{}_pbr_height_normal_ao", tex_set.name), + albedo: Srgba::WHITE, + albedo_texture: Some(albedo_tex.clone()), + metallic: 1.0, + roughness: 1.0, + metallic_roughness_texture: Some(metallic_roughness_tex.clone()), + occlusion_strength: 1.0, + occlusion_texture: Some(ao_tex.clone()), + normal_scale: 1.0, + normal_texture: Some(normal_tex.clone()), + render_states: RenderStates::default(), + is_transparent: false, + emissive: Srgba::BLACK, + emissive_texture: None, + lighting_model: LightingModel::Cook( + NormalDistributionFunction::TrowbridgeReitzGGX, + GeometryFunction::SmithSchlickGGX, + ), + height_texture: Some(height_tex.clone()), + height_scale: 0.05, + height_quality: HeightQuality::High, + }; + + // Column 5: PBR + Height only (POM enabled) + let mat5 = PhysicalMaterial { + name: format!("{}_pbr_height", tex_set.name), + albedo: Srgba::WHITE, + albedo_texture: Some(albedo_tex.clone()), + metallic: 1.0, + roughness: 1.0, + metallic_roughness_texture: Some(metallic_roughness_tex.clone()), + occlusion_strength: 1.0, + occlusion_texture: None, + normal_scale: 1.0, + normal_texture: None, + render_states: RenderStates::default(), + is_transparent: false, + emissive: Srgba::BLACK, + emissive_texture: None, + lighting_model: LightingModel::Cook( + NormalDistributionFunction::TrowbridgeReitzGGX, + GeometryFunction::SmithSchlickGGX, + ), + height_texture: Some(height_tex.clone()), + height_scale: 0.05, + height_quality: HeightQuality::High, + }; + + // Column 6: PBR + Height + Generated Normal + let mat6 = PhysicalMaterial { + name: format!("{}_pbr_height_gen_normal", tex_set.name), + albedo: Srgba::WHITE, + albedo_texture: Some(albedo_tex.clone()), + metallic: 1.0, + roughness: 1.0, + metallic_roughness_texture: Some(metallic_roughness_tex.clone()), + occlusion_strength: 1.0, + occlusion_texture: None, + normal_scale: 1.0, + normal_texture: Some(gen_normal_tex.clone()), + render_states: RenderStates::default(), + is_transparent: false, + emissive: Srgba::BLACK, + emissive_texture: None, + lighting_model: LightingModel::Cook( + NormalDistributionFunction::TrowbridgeReitzGGX, + GeometryFunction::SmithSchlickGGX, + ), + height_texture: Some(height_tex.clone()), + height_scale: 0.05, + height_quality: HeightQuality::High, + }; + + // Column 7: PBR + Height + Generated AO + let mat7 = PhysicalMaterial { + name: format!("{}_pbr_height_gen_ao", tex_set.name), + albedo: Srgba::WHITE, + albedo_texture: Some(albedo_tex.clone()), + metallic: 1.0, + roughness: 1.0, + metallic_roughness_texture: Some(metallic_roughness_tex.clone()), + occlusion_strength: 1.0, + occlusion_texture: Some(gen_ao_tex.clone()), + normal_scale: 1.0, + normal_texture: None, + render_states: RenderStates::default(), + is_transparent: false, + emissive: Srgba::BLACK, + emissive_texture: None, + lighting_model: LightingModel::Cook( + NormalDistributionFunction::TrowbridgeReitzGGX, + GeometryFunction::SmithSchlickGGX, + ), + height_texture: Some(height_tex.clone()), + height_scale: 0.05, + height_quality: HeightQuality::High, + }; + + // Column 8: PBR + Height + Generated Normal + Generated AO + let mat8 = PhysicalMaterial { + name: format!("{}_pbr_height_gen_normal_ao", tex_set.name), + albedo: Srgba::WHITE, + albedo_texture: Some(albedo_tex.clone()), + metallic: 1.0, + roughness: 1.0, + metallic_roughness_texture: Some(metallic_roughness_tex.clone()), + occlusion_strength: 1.0, + occlusion_texture: Some(gen_ao_tex.clone()), + normal_scale: 1.0, + normal_texture: Some(gen_normal_tex.clone()), + render_states: RenderStates::default(), + is_transparent: false, + emissive: Srgba::BLACK, + emissive_texture: None, + lighting_model: LightingModel::Cook( + NormalDistributionFunction::TrowbridgeReitzGGX, + GeometryFunction::SmithSchlickGGX, + ), + height_texture: Some(height_tex.clone()), + height_scale: 0.05, + height_quality: HeightQuality::High, + }; + + let materials = [mat0, mat1, mat2, mat3, mat4, mat5, mat6, mat7, mat8]; + + for (col, mat) in materials.into_iter().enumerate() { + let x = start_x + col as f32 * col_spacing; + let mut quad = Gm::new(Mesh::new(&context, &quad_cpu), mat); + quad.set_transformation(Mat4::from_translation(vec3(x, y, 0.0))); + all_quads.push(quad); + } + } + + println!("Setup complete! Starting render loop..."); + + // Camera and controls + let mut camera = Camera::new_perspective( + window.viewport(), + vec3(0.0, 0.0, 12.0), + vec3(0.0, 0.0, 0.0), + vec3(0.0, 1.0, 0.0), + degrees(45.0), + 0.1, + 1000.0, + ); + let mut control = FlyControl::new(0.05); + + // Lighting + let mut ambient = AmbientLight::new(&context, 0.3, Srgba::WHITE); + let mut directional = DirectionalLight::new(&context, 2.0, Srgba::WHITE, vec3(-1.0, -1.0, -1.0)); + + // GUI + let mut gui = three_d::GUI::new(&context); + let mut height_scale = 0.05_f32; + let mut height_quality_idx = 3_usize; // High + let mut pom_enabled = true; + let mut ambient_intensity = 0.3_f32; + let mut directional_intensity = 2.0_f32; + let mut light_angle = 225.0_f32; // degrees, 225 = (-1, -1) direction + + // Keyboard state for WASD + let mut move_forward = false; + let mut move_backward = false; + let mut move_left = false; + let mut move_right = false; + let move_speed = 0.1_f32; + + // Main loop + window.render_loop(move |mut frame_input| { + // Handle keyboard input for WASD + for event in frame_input.events.iter_mut() { + match event { + Event::KeyPress { kind, handled, .. } if !*handled => { + match kind { + Key::W => move_forward = true, + Key::S => move_backward = true, + Key::A => move_left = true, + Key::D => move_right = true, + _ => {} + } + } + Event::KeyRelease { kind, handled, .. } if !*handled => { + match kind { + Key::W => move_forward = false, + Key::S => move_backward = false, + Key::A => move_left = false, + Key::D => move_right = false, + _ => {} + } + } + _ => {} + } + } + + // Apply WASD movement (W/S = up/down, A/D = left/right) + let right = camera.right_direction(); + let up = vec3(0.0, 1.0, 0.0); + let mut movement = vec3(0.0, 0.0, 0.0); + if move_forward { + movement += up * move_speed; + } + if move_backward { + movement -= up * move_speed; + } + if move_right { + movement += right * move_speed; + } + if move_left { + movement -= right * move_speed; + } + if movement.magnitude() > 0.0 { + camera.translate(movement); + } + + let mut panel_width = 0.0; + gui.update( + &mut frame_input.events, + frame_input.accumulated_time, + frame_input.viewport, + frame_input.device_pixel_ratio, + |gui_context| { + use three_d::egui::*; + SidePanel::left("side_panel").show(gui_context, |ui| { + ui.heading("Heightmap Demo"); + ui.separator(); + + ui.checkbox(&mut pom_enabled, "Enable POM"); + + ui.add(Slider::new(&mut height_scale, 0.0..=0.2).text("Height Scale")); + + ui.label("POM Quality:"); + ComboBox::from_label("") + .selected_text(match height_quality_idx { + 0 => "Very Low", + 1 => "Low", + 2 => "Medium", + 3 => "High", + _ => "Very High", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut height_quality_idx, 0, "Very Low"); + ui.selectable_value(&mut height_quality_idx, 1, "Low"); + ui.selectable_value(&mut height_quality_idx, 2, "Medium"); + ui.selectable_value(&mut height_quality_idx, 3, "High"); + ui.selectable_value(&mut height_quality_idx, 4, "Very High"); + }); + + ui.separator(); + ui.label("Lighting:"); + ui.add(Slider::new(&mut ambient_intensity, 0.0..=1.0).text("Ambient")); + ui.add(Slider::new(&mut directional_intensity, 0.0..=5.0).text("Directional")); + ui.add(Slider::new(&mut light_angle, 0.0..=360.0).text("Light Angle")); + + ui.separator(); + ui.label("Controls:"); + ui.label(" W/S: Up/Down"); + ui.label(" A/D: Left/Right"); + ui.label(" Scroll: In/Out"); + ui.label(" Mouse drag: Look"); + }); + panel_width = gui_context.used_rect().width(); + }, + ); + + // Update material parameters + let quality = match height_quality_idx { + 0 => HeightQuality::VeryLow, + 1 => HeightQuality::Low, + 2 => HeightQuality::Medium, + 3 => HeightQuality::High, + _ => HeightQuality::VeryHigh, + }; + + for (i, quad) in all_quads.iter_mut().enumerate() { + // Only apply POM settings to quads in columns 2, 3, 4 + if has_pom(i) { + if pom_enabled { + quad.material.height_scale = height_scale; + quad.material.height_quality = quality; + } else { + quad.material.height_scale = 0.0; + } + } + } + + // Update lighting + ambient.intensity = ambient_intensity; + directional.intensity = directional_intensity; + let angle_rad = light_angle.to_radians(); + directional.direction = vec3(angle_rad.cos(), -1.0, angle_rad.sin()).normalize(); + + let viewport = Viewport { + x: (panel_width * frame_input.device_pixel_ratio) as i32, + y: 0, + width: frame_input.viewport.width + - (panel_width * frame_input.device_pixel_ratio) as u32, + height: frame_input.viewport.height, + }; + camera.set_viewport(viewport); + control.handle_events(&mut camera, &mut frame_input.events); + + // Render scene + frame_input + .screen() + .clear(ClearState::color_and_depth(0.15, 0.15, 0.18, 1.0, 1.0)) + .render( + &camera, + all_quads.iter().collect::>().as_slice(), + &[&ambient, &directional], + ) + .write(|| { + // Draw 3D labels using egui painter + let painter = gui.context().layer_painter(three_d::egui::LayerId::new( + three_d::egui::Order::Foreground, + three_d::egui::Id::new("labels"), + )); + + // Column labels (above top row) + for (col, label) in col_labels.iter().enumerate() { + let x = start_x + col as f32 * col_spacing; + let world_pos = vec3(x, start_y + 1.2, 0.0); + if let Some((screen_x, screen_y)) = world_to_screen(&camera, world_pos, viewport) + { + painter.text( + three_d::egui::pos2(screen_x, screen_y), + three_d::egui::Align2::CENTER_BOTTOM, + label, + three_d::egui::FontId::proportional(12.0), + three_d::egui::Color32::WHITE, + ); + } + } + + // Row labels (left of each row) + for (row, label) in row_labels.iter().enumerate() { + let y = start_y - row as f32 * row_spacing; + let world_pos = vec3(start_x - 1.2, y, 0.0); + if let Some((screen_x, screen_y)) = world_to_screen(&camera, world_pos, viewport) + { + painter.text( + three_d::egui::pos2(screen_x, screen_y), + three_d::egui::Align2::RIGHT_CENTER, + label, + three_d::egui::FontId::proportional(12.0), + three_d::egui::Color32::WHITE, + ); + } + } + + gui.render() + }) + .unwrap(); + + FrameOutput::default() + }); +} + +async fn load_texture_set(name: &str, base_path: &str) -> TextureSet { + let albedo_path = format!("{}_albedo.jpg", base_path); + let normal_path = format!("{}_normal.jpg", base_path); + let ao_path = format!("{}_ao.jpg", base_path); + let height_path = format!("{}_height.jpg", base_path); + let roughness_path = format!("{}_roughness.jpg", base_path); + let metallic_path = format!("{}_metallic.jpg", base_path); + + let mut loaded = three_d_asset::io::load_async(&[ + albedo_path.as_str(), + normal_path.as_str(), + ao_path.as_str(), + height_path.as_str(), + roughness_path.as_str(), + metallic_path.as_str(), + ]) + .await + .unwrap_or_else(|_| panic!("Failed to load {} textures", name)); + + let albedo: CpuTexture = loaded.deserialize("albedo").unwrap(); + let normal: CpuTexture = loaded.deserialize("normal").unwrap(); + let ao: CpuTexture = loaded.deserialize("ao").unwrap(); + let heightmap: CpuTexture = loaded.deserialize("height").unwrap(); + let roughness: CpuTexture = loaded.deserialize("roughness").unwrap(); + let metallic: CpuTexture = loaded.deserialize("metallic").unwrap(); + + println!(" Generating normal map from heightmap..."); + let generated_normal = create_normal_from_heightmap(&heightmap, 2.0); + + println!(" Generating AO map from heightmap..."); + let generated_ao = create_ao_from_heightmap(&heightmap, 5, 8, 3.0, 0.1); + + let metallic_roughness = create_metallic_roughness(&metallic, &roughness); + + TextureSet { + name: name.to_string(), + albedo, + normal, + ao, + heightmap, + metallic_roughness, + generated_normal, + generated_ao, + } +} + diff --git a/src/renderer/material.rs b/src/renderer/material.rs index 147cad881..ad7f8f7ec 100644 --- a/src/renderer/material.rs +++ b/src/renderer/material.rs @@ -75,6 +75,10 @@ mod isosurface_material; #[doc(inline)] pub use isosurface_material::*; +mod pbr_utils; +#[doc(inline)] +pub use pbr_utils::*; + use std::{ops::Deref, sync::Arc}; /// diff --git a/src/renderer/material/pbr_utils.rs b/src/renderer/material/pbr_utils.rs new file mode 100644 index 000000000..9d038e922 --- /dev/null +++ b/src/renderer/material/pbr_utils.rs @@ -0,0 +1,369 @@ +//! +//! Utilities for cross-generating PBR textures. +//! + +use crate::core::*; + +/// Extracts the red channel from a CpuTexture as normalized f32 values (0.0 to 1.0). +/// Works with all supported texture formats. +fn extract_red_channel(cpu_texture: &CpuTexture) -> Vec { + match &cpu_texture.data { + TextureData::RU8(data) => data.iter().map(|&v| v as f32 / 255.0).collect(), + TextureData::RgU8(data) => data.iter().map(|v| v[0] as f32 / 255.0).collect(), + TextureData::RgbU8(data) => data.iter().map(|v| v[0] as f32 / 255.0).collect(), + TextureData::RgbaU8(data) => data.iter().map(|v| v[0] as f32 / 255.0).collect(), + TextureData::RF16(data) => data.iter().map(|v| v.to_f32()).collect(), + TextureData::RgF16(data) => data.iter().map(|v| v[0].to_f32()).collect(), + TextureData::RgbF16(data) => data.iter().map(|v| v[0].to_f32()).collect(), + TextureData::RgbaF16(data) => data.iter().map(|v| v[0].to_f32()).collect(), + TextureData::RF32(data) => data.clone(), + TextureData::RgF32(data) => data.iter().map(|v| v[0]).collect(), + TextureData::RgbF32(data) => data.iter().map(|v| v[0]).collect(), + TextureData::RgbaF32(data) => data.iter().map(|v| v[0]).collect(), + } +} + +/// Samples a texel from a flat data array with clamped boundary handling. +#[inline] +fn get_texel_clamped(data: &[f32], width: u32, height: u32, x: i32, y: i32) -> f32 { + let x = x.clamp(0, width as i32 - 1) as usize; + let y = y.clamp(0, height as i32 - 1) as usize; + data[y * width as usize + x] +} + +/// Generates a normal map from a heightmap using Sobel filtering. +/// +/// # Arguments +/// * `heightmap` - The source heightmap texture (height values read from red channel) +/// * `strength` - Normal strength multiplier (typical range: 1.0 to 10.0) +/// +/// # Returns +/// A new CpuTexture containing the generated normal map in tangent space (RGB format). +/// The normals are stored as `(normal * 0.5 + 0.5)` to fit in [0, 1] range. +pub fn create_normal_from_heightmap(heightmap: &CpuTexture, strength: f32) -> CpuTexture { + let width = heightmap.width; + let height = heightmap.height; + + // Handle empty texture + if width == 0 || height == 0 { + return CpuTexture { + name: format!("{}_normal", heightmap.name), + data: TextureData::RgbU8(vec![]), + width, + height, + min_filter: heightmap.min_filter, + mag_filter: heightmap.mag_filter, + mipmap: heightmap.mipmap, + wrap_s: heightmap.wrap_s, + wrap_t: heightmap.wrap_t, + }; + } + + let heights = extract_red_channel(heightmap); + + let mut normals: Vec<[u8; 3]> = Vec::with_capacity((width * height) as usize); + + for y in 0..height as i32 { + for x in 0..width as i32 { + // Sobel filter for better quality gradients + let tl = get_texel_clamped(&heights, width, height, x - 1, y - 1); + let t = get_texel_clamped(&heights, width, height, x, y - 1); + let tr = get_texel_clamped(&heights, width, height, x + 1, y - 1); + let l = get_texel_clamped(&heights, width, height, x - 1, y); + let r = get_texel_clamped(&heights, width, height, x + 1, y); + let bl = get_texel_clamped(&heights, width, height, x - 1, y + 1); + let b = get_texel_clamped(&heights, width, height, x, y + 1); + let br = get_texel_clamped(&heights, width, height, x + 1, y + 1); + + // Sobel operators + let dx = (tr + 2.0 * r + br) - (tl + 2.0 * l + bl); + let dy = (bl + 2.0 * b + br) - (tl + 2.0 * t + tr); + + // Construct normal vector + let nx = -dx * strength; + let ny = -dy * strength; + let nz = 1.0; + + // Normalize + let len = (nx * nx + ny * ny + nz * nz).sqrt(); + let nx = nx / len; + let ny = ny / len; + let nz = nz / len; + + // Pack to [0, 255] range: normal * 0.5 + 0.5 + normals.push([ + ((nx * 0.5 + 0.5) * 255.0) as u8, + ((ny * 0.5 + 0.5) * 255.0) as u8, + ((nz * 0.5 + 0.5) * 255.0) as u8, + ]); + } + } + + CpuTexture { + name: format!("{}_normal", heightmap.name), + data: TextureData::RgbU8(normals), + width, + height, + min_filter: heightmap.min_filter, + mag_filter: heightmap.mag_filter, + mipmap: heightmap.mipmap, + wrap_s: heightmap.wrap_s, + wrap_t: heightmap.wrap_t, + } +} + +/// Generates an ambient occlusion map from a heightmap using horizon-based ray tracing. +/// +/// For each texel, traces rays in multiple directions along the heightmap surface +/// to determine how much of the hemisphere is occluded by nearby elevated regions. +/// +/// # Arguments +/// * `heightmap` - The source heightmap texture (height values read from red channel) +/// * `ray_count` - Number of rays to trace per texel (typical: 4-16, higher = better quality but slower) +/// * `max_distance` - Maximum ray distance in texels (typical: 8-32) +/// * `intensity` - AO intensity multiplier (typical: 1.0 to 2.0) +/// * `angle_offset` - Rotation offset in radians to avoid axis-aligned artifacts. +/// Use ~0.1 radians (≈6 deg) for good results. Use 0.0 for no offset. +/// +/// # Returns +/// A new CpuTexture containing the generated ambient occlusion map (grayscale, R channel). +/// White (1.0) = fully lit, Black (0.0) = fully occluded. +pub fn create_ao_from_heightmap( + heightmap: &CpuTexture, + ray_count: u32, + max_distance: u32, + intensity: f32, + angle_offset: f32, +) -> CpuTexture { + let width = heightmap.width; + let height = heightmap.height; + + // Handle empty texture or zero ray count - return white (no occlusion) texture + if width == 0 || height == 0 || ray_count == 0 { + return CpuTexture { + name: format!("{}_ao", heightmap.name), + data: TextureData::RU8(vec![255; (width * height) as usize]), + width, + height, + min_filter: heightmap.min_filter, + mag_filter: heightmap.mag_filter, + mipmap: heightmap.mipmap, + wrap_s: heightmap.wrap_s, + wrap_t: heightmap.wrap_t, + }; + } + + let heights = extract_red_channel(heightmap); + + let mut ao_values: Vec = Vec::with_capacity((width * height) as usize); + + // Precompute ray directions (evenly distributed around circle with offset to avoid axis alignment) + let ray_dirs: Vec<(f32, f32)> = (0..ray_count) + .map(|i| { + let angle = (i as f32 / ray_count as f32) * std::f32::consts::TAU + angle_offset; + (angle.cos(), angle.sin()) + }) + .collect(); + + for y in 0..height as i32 { + for x in 0..width as i32 { + let center_height = get_texel_clamped(&heights, width, height, x, y); + let mut total_occlusion = 0.0; + + // Trace rays in each direction + for &(dx, dy) in &ray_dirs { + let mut max_horizon_angle = 0.0_f32; + + // March along the ray + for step in 1..=max_distance { + let sample_x = x as f32 + dx * step as f32; + let sample_y = y as f32 + dy * step as f32; + + // Bilinear sample would be better, but for simplicity use nearest + let sx = sample_x.round() as i32; + let sy = sample_y.round() as i32; + + // Skip if out of bounds + if sx < 0 || sx >= width as i32 || sy < 0 || sy >= height as i32 { + break; + } + + let sample_height = get_texel_clamped(&heights, width, height, sx, sy); + let height_diff = sample_height - center_height; + let distance = step as f32; + + // Calculate horizon angle (atan2 of height difference over distance) + // Scale height_diff by max_distance to make height comparable to distance + // This treats the heightmap as if max height difference spans max_distance texels + let scaled_height_diff = height_diff * max_distance as f32; + let horizon_angle = (scaled_height_diff / distance).atan(); + max_horizon_angle = max_horizon_angle.max(horizon_angle); + } + + // Convert horizon angle to occlusion (0 = no occlusion, 1 = full occlusion) + // Normalize from [-PI/2, PI/2] range, but we only care about positive angles + let occlusion = (max_horizon_angle * 2.0 / std::f32::consts::PI).max(0.0); + total_occlusion += occlusion; + } + + // Average occlusion across all rays and apply intensity + let avg_occlusion = (total_occlusion / ray_count as f32) * intensity; + // Invert: high occlusion = dark, convert to [0, 255] + let ao = ((1.0 - avg_occlusion).clamp(0.0, 1.0) * 255.0) as u8; + ao_values.push(ao); + } + } + + CpuTexture { + name: format!("{}_ao", heightmap.name), + data: TextureData::RU8(ao_values), + width, + height, + min_filter: heightmap.min_filter, + mag_filter: heightmap.mag_filter, + mipmap: heightmap.mipmap, + wrap_s: heightmap.wrap_s, + wrap_t: heightmap.wrap_t, + } +} + +/// Combines separate metallic and roughness textures into a single glTF-format metallic-roughness texture. +/// +/// The glTF PBR metallic-roughness format stores: R=unused, G=roughness, B=metallic, A=unused. +/// This function extracts the red channel from each input texture and combines them accordingly. +/// +/// # Arguments +/// * `metallic` - The metallic texture (values read from red channel) +/// * `roughness` - The roughness texture (values read from red channel) +/// +/// # Returns +/// A new CpuTexture in RGBA format with roughness in the green channel and metallic in the blue channel. +/// If the textures have different dimensions, the metallic texture is sampled using nearest-neighbor interpolation. +pub fn create_metallic_roughness(metallic: &CpuTexture, roughness: &CpuTexture) -> CpuTexture { + let width = roughness.width; + let height = roughness.height; + + let roughness_data = extract_red_channel(roughness); + let metallic_data = extract_red_channel(metallic); + + // Combine into glTF format, sampling metallic if sizes differ + let pixels: Vec<[u8; 4]> = if metallic.width == width && metallic.height == height { + roughness_data + .iter() + .zip(metallic_data.iter()) + .map(|(&r, &m)| [0, (r * 255.0) as u8, (m * 255.0) as u8, 255]) + .collect() + } else { + // Sample metallic with clamped boundaries for different sizes + let mut pixels = Vec::with_capacity((width * height) as usize); + for y in 0..height { + for x in 0..width { + let r_idx = (y * width + x) as usize; + let m_x = (x as f32 * metallic.width as f32 / width as f32) as i32; + let m_y = (y as f32 * metallic.height as f32 / height as f32) as i32; + let r = roughness_data[r_idx]; + let m = get_texel_clamped(&metallic_data, metallic.width, metallic.height, m_x, m_y); + pixels.push([0, (r * 255.0) as u8, (m * 255.0) as u8, 255]); + } + } + pixels + }; + + CpuTexture { + name: "metallic_roughness".to_string(), + data: TextureData::RgbaU8(pixels), + width, + height, + min_filter: roughness.min_filter, + mag_filter: roughness.mag_filter, + mipmap: roughness.mipmap, + wrap_s: roughness.wrap_s, + wrap_t: roughness.wrap_t, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_normal_from_heightmap_flat() { + // A flat heightmap should produce normals pointing straight up (0, 0, 1) + let heightmap = CpuTexture { + name: "test".to_string(), + data: TextureData::RU8(vec![128; 16]), // 4x4 flat + width: 4, + height: 4, + min_filter: Interpolation::Linear, + mag_filter: Interpolation::Linear, + mipmap: None, + wrap_s: Wrapping::Repeat, + wrap_t: Wrapping::Repeat, + }; + + let normal_map = create_normal_from_heightmap(&heightmap, 1.0); + + if let TextureData::RgbU8(data) = &normal_map.data { + // Center should be (0.5, 0.5, 1.0) in packed form = (127, 127, 255) + for pixel in data { + // Allow some tolerance due to edge effects + assert!(pixel[2] > 250, "Z component should be near 1.0"); + } + } else { + panic!("Expected RgbU8 output"); + } + } + + #[test] + fn test_create_ao_from_heightmap_flat() { + // A flat heightmap should produce white AO (no occlusion) + let heightmap = CpuTexture { + name: "test".to_string(), + data: TextureData::RU8(vec![128; 16]), // 4x4 flat + width: 4, + height: 4, + min_filter: Interpolation::Linear, + mag_filter: Interpolation::Linear, + mipmap: None, + wrap_s: Wrapping::Repeat, + wrap_t: Wrapping::Repeat, + }; + + let ao_map = create_ao_from_heightmap(&heightmap, 8, 4, 1.0, 0.0); + + if let TextureData::RU8(data) = &ao_map.data { + for &value in data { + // Flat surface should have minimal occlusion (high AO value) + assert!(value > 200, "Flat surface should have minimal occlusion"); + } + } else { + panic!("Expected RU8 output"); + } + } + + #[test] + fn test_create_ao_from_heightmap_zero_rays() { + // Zero rays should return white (no occlusion) + let heightmap = CpuTexture { + name: "test".to_string(), + data: TextureData::RU8(vec![128; 4]), + width: 2, + height: 2, + min_filter: Interpolation::Linear, + mag_filter: Interpolation::Linear, + mipmap: None, + wrap_s: Wrapping::Repeat, + wrap_t: Wrapping::Repeat, + }; + + let ao_map = create_ao_from_heightmap(&heightmap, 0, 4, 1.0, 0.0); + + if let TextureData::RU8(data) = &ao_map.data { + for &value in data { + assert_eq!(value, 255, "Zero rays should produce white (no occlusion)"); + } + } else { + panic!("Expected RU8 output"); + } + } +} diff --git a/src/renderer/material/physical_material.rs b/src/renderer/material/physical_material.rs index d28dcdd93..a4f41aa76 100644 --- a/src/renderer/material/physical_material.rs +++ b/src/renderer/material/physical_material.rs @@ -1,6 +1,58 @@ use crate::core::*; use crate::renderer::*; +/// Quality presets for parallax occlusion mapping. +/// Controls the number of ray-march layers, secant refinement iterations, and fade distances. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum HeightQuality { + /// Minimal quality with short fade range. Use for subtle displacement + /// or when many POM surfaces are visible simultaneously. + VeryLow, + /// Low quality with medium fade range. Use for background surfaces + /// or floors where the camera rarely gets close. + Low, + /// Balanced quality and performance. Suitable for most surfaces that + /// the player can approach but won't inspect closely. + #[default] + Medium, + /// High quality with extended range. Use for important surfaces like + /// walls or objects the player will examine up close. + High, + /// Maximum quality. Use sparingly for hero assets or surfaces where + /// fine displacement detail is critical. + VeryHigh, +} + +impl HeightQuality { + /// Returns (base_layers, refinement_iterations, fade_dist_start, fade_dist_end) for this quality level. + /// fade_dist_start: distance where POM starts fading, fade_dist_end: distance where POM is fully off. + #[inline] + pub const fn params(self) -> (u32, u32, f32, f32) { + match self { + HeightQuality::VeryLow => (4, 0, 5.0, 15.0), + HeightQuality::Low => (8, 0, 8.0, 25.0), + HeightQuality::Medium => (8, 2, 10.0, 50.0), + HeightQuality::High => (12, 3, 15.0, 50.0), + HeightQuality::VeryHigh => (16, 4, 15.0, 50.0), + } + } +} + +/// Computes maximum POM layer count from base layers and height scale. +/// Smaller height scales need fewer layers; larger scales need more for quality. +/// Returns at least 2 layers. +#[inline] +fn compute_height_max_layers(base_layers: u32, height_scale: f32) -> i32 { + let multiplier = if height_scale < 0.02 { + let t = ((height_scale - 0.001) / (0.02 - 0.001)).clamp(0.0, 1.0); + 0.25 + t * 0.75 + } else { + let t = ((height_scale - 0.02) / (0.1 - 0.02)).clamp(0.0, 1.0); + 1.0 + t * 2.0 + }; + (base_layers as f32 * multiplier).round().max(2.0) as i32 +} + /// /// A physically-based material that renders a [Geometry] in an approximate correct physical manner based on Physically Based Rendering (PBR). /// This material is affected by lights. @@ -41,6 +93,15 @@ pub struct PhysicalMaterial { pub emissive_texture: Option, /// The lighting model used when rendering this material pub lighting_model: LightingModel, + /// Height map texture for parallax occlusion mapping. + /// White = raised, 50% gray = surface level, Black = lowered. + /// Height values are sampled from the red channel. + pub height_texture: Option, + /// Height scale (depth) for parallax occlusion mapping. + /// Typical range: 0.01 - 0.1. Higher = more depth but more artifacts at glancing angles. + pub height_scale: f32, + /// Quality preset for parallax occlusion mapping. + pub height_quality: HeightQuality, } impl PhysicalMaterial { @@ -139,6 +200,9 @@ impl PhysicalMaterial { emissive: cpu_material.emissive, emissive_texture, lighting_model: cpu_material.lighting_model, + height_texture: None, + height_scale: 0.05, + height_quality: HeightQuality::default(), } } } @@ -151,38 +215,51 @@ impl FromCpuMaterial for PhysicalMaterial { impl Material for PhysicalMaterial { fn id(&self) -> EffectMaterialId { + let use_height = self.height_texture.is_some() && self.height_scale != 0.0; EffectMaterialId::PhysicalMaterial( self.albedo_texture.is_some(), self.metallic_roughness_texture.is_some(), self.occlusion_texture.is_some(), self.normal_texture.is_some(), self.emissive_texture.is_some(), + use_height, ) } fn fragment_shader_source(&self, lights: &[&dyn Light]) -> String { let mut output = lights_shader_source(lights); - if self.albedo_texture.is_some() + // Height texture is only active when scale is non-zero + let use_height = self.height_texture.is_some() && self.height_scale != 0.0; + let use_textures = self.albedo_texture.is_some() || self.metallic_roughness_texture.is_some() || self.normal_texture.is_some() || self.occlusion_texture.is_some() || self.emissive_texture.is_some() - { + || use_height; + + if use_textures { output.push_str("in vec2 uvs;\n"); if self.albedo_texture.is_some() { - output.push_str("#define USE_ALBEDO_TEXTURE;\n"); + output.push_str("#define USE_ALBEDO_TEXTURE\n"); } if self.metallic_roughness_texture.is_some() { - output.push_str("#define USE_METALLIC_ROUGHNESS_TEXTURE;\n"); + output.push_str("#define USE_METALLIC_ROUGHNESS_TEXTURE\n"); } if self.occlusion_texture.is_some() { - output.push_str("#define USE_OCCLUSION_TEXTURE;\n"); + output.push_str("#define USE_OCCLUSION_TEXTURE\n"); + } + // Normal texture OR height texture requires tangent/bitangent + if self.normal_texture.is_some() || use_height { + output.push_str("in vec3 tang;\nin vec3 bitang;\n"); } if self.normal_texture.is_some() { - output.push_str("#define USE_NORMAL_TEXTURE;\nin vec3 tang;\nin vec3 bitang;\n"); + output.push_str("#define USE_NORMAL_TEXTURE\n"); } if self.emissive_texture.is_some() { - output.push_str("#define USE_EMISSIVE_TEXTURE;\n"); + output.push_str("#define USE_EMISSIVE_TEXTURE\n"); + } + if use_height { + output.push_str("#define USE_HEIGHT_TEXTURE\n"); } } output.push_str(ToneMapping::fragment_shader_source()); @@ -236,6 +313,26 @@ impl Material for PhysicalMaterial { program.use_texture("emissiveTexture", texture); } } + if program.requires_uniform("heightTexture") { + if let Some(ref texture) = self.height_texture { + let (base_layers, refinement_iterations, fade_dist_start, fade_dist_end) = + self.height_quality.params(); + // UV transformation matrix for height texture + program.use_uniform("heightTexTransform", texture.transformation); + // Depth scale for parallax displacement (typical: 0.01-0.1) + program.use_uniform("heightScale", self.height_scale); + // Max number of ray-march layers (will decrease according to POM quality) + program.use_uniform("heightMaxLayers", compute_height_max_layers(base_layers, self.height_scale)); + // Secant refinement iterations for sub-layer precision + program.use_uniform("heightRefinementIterations", refinement_iterations as i32); + // Distance where POM quality starts fading + program.use_uniform("heightFadeDistStart", fade_dist_start); + // Distance where POM is fully disabled (falls back to flat UVs) + program.use_uniform("heightFadeDistEnd", fade_dist_end); + // Height map sampler (red channel: white = raised, 50% gray = surface level, black = lowered) + program.use_texture("heightTexture", texture); + } + } } fn render_states(&self) -> RenderStates { @@ -268,6 +365,9 @@ impl Default for PhysicalMaterial { emissive: Srgba::BLACK, emissive_texture: None, lighting_model: LightingModel::Blinn, + height_texture: None, + height_scale: 0.05, + height_quality: HeightQuality::default(), } } } diff --git a/src/renderer/material/shaders/physical_material.frag b/src/renderer/material/shaders/physical_material.frag index 08ecfa0be..68a8ccdb1 100644 --- a/src/renderer/material/shaders/physical_material.frag +++ b/src/renderer/material/shaders/physical_material.frag @@ -32,17 +32,125 @@ uniform mat3 normalTexTransform; uniform float normalScale; #endif +#ifdef USE_HEIGHT_TEXTURE +uniform sampler2D heightTexture; +uniform mat3 heightTexTransform; +uniform float heightScale; +uniform int heightMaxLayers; // Precomputed: base_layers * height_scale-based multiplier +uniform int heightRefinementIterations; +uniform float heightFadeDistStart; // Distance where POM starts fading +uniform float heightFadeDistEnd; // Distance where POM is fully off +#endif + in vec3 pos; in vec3 nor; in vec4 col; layout (location = 0) out vec4 outColor; +#ifdef USE_HEIGHT_TEXTURE +// Parallax Occlusion Mapping with smooth distance fade +// Full quality POM close up, smoothly blends to flat at distance +vec2 parallaxOcclusionMapping(vec2 texCoords, vec3 viewDirTangent, float dist, out float pomStrength) { + // Smooth fade based on quality setting + pomStrength = 1.0 - smoothstep(heightFadeDistStart, heightFadeDistEnd, dist); + + // Early out for distant surfaces + if (pomStrength < 0.001) { + return texCoords; + } + + // Extract transform components once + mat2 txLinear = mat2(heightTexTransform[0].xy, heightTexTransform[1].xy); + vec2 txOffset = heightTexTransform[2].xy; + + // Layer count scales with pomStrength - fewer layers at distance + float fNumLayers = max(2.0, floor(float(heightMaxLayers) * pomStrength + 0.5)); + float invNumLayers = 1.0 / fNumLayers; + + // Shift per layer (divide by z for correct view-angle scaling) + vec2 P = viewDirTangent.xy / max(viewDirTangent.z, 0.0001) * heightScale; + vec2 deltaUV = P * invNumLayers; + + // Start centered: offset by P*0.5 so 50% gray = surface level (DO NOT CHANGE) + vec2 currentUV = texCoords + P * 0.5; + float currentLayerDepth = 0.0; + vec2 sampleUV = txLinear * currentUV + txOffset; + float currentDepth = 1.0 - textureLod(heightTexture, sampleUV, 0.0).r; + + // Linear search + for (float layer = 0.0; layer < fNumLayers && currentLayerDepth < currentDepth; layer += 1.0) { + currentUV -= deltaUV; + currentLayerDepth += invNumLayers; + sampleUV = txLinear * currentUV + txOffset; + currentDepth = 1.0 - textureLod(heightTexture, sampleUV, 0.0).r; + } + + // Get previous position for interpolation + vec2 prevUV = currentUV + deltaUV; + float prevLayerDepth = currentLayerDepth - invNumLayers; + float prevDepth = 1.0 - textureLod(heightTexture, txLinear * prevUV + txOffset, 0.0).r; + + // Track differences for secant method + float d0 = prevDepth - prevLayerDepth; + float d1 = currentDepth - currentLayerDepth; + + // Secant refinement (scale iterations by pomStrength) + int refinementIters = int(float(heightRefinementIterations) * pomStrength + 0.5); + for (int i = 0; i < refinementIters; i++) { + float denom = d0 - d1; + float t = (abs(denom) > 0.0001) ? d0 / denom : 0.5; + vec2 newUV = mix(prevUV, currentUV, t); + float newLayerDepth = mix(prevLayerDepth, currentLayerDepth, t); + float newDepth = 1.0 - textureLod(heightTexture, txLinear * newUV + txOffset, 0.0).r; + float dNew = newDepth - newLayerDepth; + + if (dNew > 0.0) { + prevUV = newUV; + prevLayerDepth = newLayerDepth; + d0 = dNew; + } else { + currentUV = newUV; + currentLayerDepth = newLayerDepth; + d1 = dNew; + } + } + + // Final interpolation (guard against division by zero) + float denom = d0 - d1; + float t = (abs(denom) > 0.0001) ? d0 / denom : 0.5; + return mix(prevUV, currentUV, clamp(t, 0.0, 1.0)); +} +#endif + void main() { + vec3 normal = normalize(gl_FrontFacing ? nor : -nor); + + // Build TBN matrix early (needed for parallax and/or normal mapping) +#if defined(USE_NORMAL_TEXTURE) || defined(USE_HEIGHT_TEXTURE) + vec3 tangent = normalize(gl_FrontFacing ? tang : -tang); + vec3 bitangent = normalize(gl_FrontFacing ? bitang : -bitang); + mat3 tbn = mat3(tangent, bitangent, normal); +#endif + + // Calculate parallax-displaced UVs with smooth distance fade + // Compiler will optimize-out this assignment if USE_HEIGHT_TEXTURE is false + vec2 texCoords = uvs; +#ifdef USE_HEIGHT_TEXTURE + vec3 toCamera = cameraPosition - pos; + float distToCamera = length(toCamera); + vec3 viewDir = toCamera / max(distToCamera, 0.0001); + float pomStrength; + vec3 viewDirTangent = normalize(transpose(tbn) * viewDir); + vec2 pomUV = parallaxOcclusionMapping(uvs, viewDirTangent, distToCamera, pomStrength); + // Blend between POM result and flat UVs based on distance + texCoords = mix(uvs, pomUV, pomStrength); +#endif + vec4 surface_color = albedo * col; #ifdef USE_ALBEDO_TEXTURE - vec4 c = texture(albedoTexture, (albedoTexTransform * vec3(uvs, 1.0)).xy); + vec4 c = texture(albedoTexture, (albedoTexTransform * vec3(texCoords, 1.0)).xy); #ifdef ALPHACUT if (c.a < acut) discard; #endif @@ -52,31 +160,28 @@ void main() float metallic_factor = metallic; float roughness_factor = roughness; #ifdef USE_METALLIC_ROUGHNESS_TEXTURE - vec2 t = texture(metallicRoughnessTexture, (metallicRoughnessTexTransform * vec3(uvs, 1.0)).xy).gb; + vec2 t = texture(metallicRoughnessTexture, (metallicRoughnessTexTransform * vec3(texCoords, 1.0)).xy).gb; roughness_factor *= t.x; metallic_factor *= t.y; #endif float occlusion = 1.0; #ifdef USE_OCCLUSION_TEXTURE - occlusion = mix(1.0, texture(occlusionTexture, (occlusionTexTransform * vec3(uvs, 1.0)).xy).r, occlusionStrength); + occlusion = mix(1.0, texture(occlusionTexture, (occlusionTexTransform * vec3(texCoords, 1.0)).xy).r, occlusionStrength); #endif - vec3 normal = normalize(gl_FrontFacing ? nor : -nor); -#ifdef USE_NORMAL_TEXTURE - vec3 tangent = normalize(gl_FrontFacing ? tang : -tang); - vec3 bitangent = normalize(gl_FrontFacing ? bitang : -bitang); - mat3 tbn = mat3(tangent, bitangent, normal); - normal = tbn * ((2.0 * texture(normalTexture, (normalTexTransform * vec3(uvs, 1.0)).xy).xyz - 1.0) * vec3(normalScale, normalScale, 1.0)); + // Normal mapping +#if defined(USE_NORMAL_TEXTURE) + normal = tbn * ((2.0 * texture(normalTexture, (normalTexTransform * vec3(texCoords, 1.0)).xy).xyz - 1.0) * vec3(normalScale, normalScale, 1.0)); #endif vec3 total_emissive = emissive.rgb; #ifdef USE_EMISSIVE_TEXTURE - total_emissive *= texture(emissiveTexture, (emissiveTexTransform * vec3(uvs, 1.0)).xy).rgb; + total_emissive *= texture(emissiveTexture, (emissiveTexTransform * vec3(texCoords, 1.0)).xy).rgb; #endif outColor.rgb = total_emissive + calculate_lighting(cameraPosition, surface_color.rgb, pos, normal, metallic_factor, roughness_factor, occlusion); outColor.rgb = tone_mapping(outColor.rgb); outColor.rgb = color_mapping(outColor.rgb); outColor.a = surface_color.a; -} \ No newline at end of file +} diff --git a/src/renderer/shader_ids.rs b/src/renderer/shader_ids.rs index 1c388d0b1..d4f6c4a57 100644 --- a/src/renderer/shader_ids.rs +++ b/src/renderer/shader_ids.rs @@ -24,7 +24,7 @@ macro_rules! bitfield_bit { ($field:ident, $($fields:ident),+ << $shift:expr) => { // Recursive case, breaking off the first bit and adding one to the shift of the remainder bitfield_bit!($field << $shift) - | bitfield_bit!($($fields),+ << $shift + 1) + | bitfield_bit!($($fields),+ << ($shift + 1)) }; } @@ -141,9 +141,9 @@ pub enum EffectMaterialId { BrdfMaterial = 0x800E, IrradianceMaterial = 0x800F, ORMMaterialBase = 0x8010, // To 0x8013 - PhysicalMaterialBase = 0x8020, // To 0x803F - DeferredPhysicalMaterialBase = 0x8040, // To 0x807F - PrefilterMaterial = 0x8080, + PhysicalMaterialBase = 0x8040, // To 0x807F (6 bits: albedo, metallic_roughness, occlusion, normal, emissive, height) + DeferredPhysicalMaterialBase = 0x8080, // To 0x80BF (6 bits: albedo, metallic_roughness, occlusion, normal, emissive, alpha_cutout) + PrefilterMaterial = 0x80C0, } impl EffectMaterialId { @@ -168,6 +168,7 @@ impl EffectMaterialId { occlusion_texture, normal_texture, emissive_texture, + height_texture, ) ); enum_bitfield!(