From 5232552aa4598f9f28fefdd2e1f1012b1c0b5852 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Mar 2026 21:28:32 +0900 Subject: [PATCH] feat(renderer): add normal mapping and procedural IBL to PBR shader - Add tangent input (location 3) and TBN computation in vertex shader - Add normal map sampling (group 1, bindings 2-3) for tangent-space normal mapping - Add BRDF LUT binding (group 4, bindings 0-1) for specular IBL - Add procedural sky environment function for diffuse/specular IBL - Replace flat ambient with split-sum IBL approximation - Add pbr_texture_bind_group_layout (4 entries: albedo + normal) - Add create_pbr_texture_bind_group helper and flat_normal_1x1 texture - Update create_pbr_pipeline to accept ibl_layout parameter (group 4) Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/voltex_renderer/src/lib.rs | 2 +- crates/voltex_renderer/src/pbr_pipeline.rs | 3 +- crates/voltex_renderer/src/pbr_shader.wgsl | 64 +++++++++- crates/voltex_renderer/src/texture.rs | 136 +++++++++++++++++++++ 4 files changed, 200 insertions(+), 5 deletions(-) diff --git a/crates/voltex_renderer/src/lib.rs b/crates/voltex_renderer/src/lib.rs index a6fd64f..b659762 100644 --- a/crates/voltex_renderer/src/lib.rs +++ b/crates/voltex_renderer/src/lib.rs @@ -18,7 +18,7 @@ pub use gpu::{GpuContext, DEPTH_FORMAT}; pub use light::{CameraUniform, LightUniform, LightData, LightsUniform, MAX_LIGHTS, LIGHT_DIRECTIONAL, LIGHT_POINT, LIGHT_SPOT}; pub use mesh::Mesh; pub use camera::{Camera, FpsController}; -pub use texture::GpuTexture; +pub use texture::{GpuTexture, pbr_texture_bind_group_layout, create_pbr_texture_bind_group}; pub use material::MaterialUniform; pub use sphere::generate_sphere; pub use pbr_pipeline::create_pbr_pipeline; diff --git a/crates/voltex_renderer/src/pbr_pipeline.rs b/crates/voltex_renderer/src/pbr_pipeline.rs index 1cd82f3..a147b24 100644 --- a/crates/voltex_renderer/src/pbr_pipeline.rs +++ b/crates/voltex_renderer/src/pbr_pipeline.rs @@ -8,6 +8,7 @@ pub fn create_pbr_pipeline( texture_layout: &wgpu::BindGroupLayout, material_layout: &wgpu::BindGroupLayout, shadow_layout: &wgpu::BindGroupLayout, + ibl_layout: &wgpu::BindGroupLayout, ) -> wgpu::RenderPipeline { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("PBR Shader"), @@ -16,7 +17,7 @@ pub fn create_pbr_pipeline( let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("PBR Pipeline Layout"), - bind_group_layouts: &[camera_light_layout, texture_layout, material_layout, shadow_layout], + bind_group_layouts: &[camera_light_layout, texture_layout, material_layout, shadow_layout, ibl_layout], immediate_size: 0, }); diff --git a/crates/voltex_renderer/src/pbr_shader.wgsl b/crates/voltex_renderer/src/pbr_shader.wgsl index ffb280d..ab29559 100644 --- a/crates/voltex_renderer/src/pbr_shader.wgsl +++ b/crates/voltex_renderer/src/pbr_shader.wgsl @@ -34,6 +34,8 @@ struct MaterialUniform { @group(1) @binding(0) var t_diffuse: texture_2d; @group(1) @binding(1) var s_diffuse: sampler; +@group(1) @binding(2) var t_normal: texture_2d; +@group(1) @binding(3) var s_normal: sampler; @group(2) @binding(0) var material: MaterialUniform; @@ -47,10 +49,14 @@ struct ShadowUniform { @group(3) @binding(1) var s_shadow: sampler_comparison; @group(3) @binding(2) var shadow: ShadowUniform; +@group(4) @binding(0) var t_brdf_lut: texture_2d; +@group(4) @binding(1) var s_brdf_lut: sampler; + struct VertexInput { @location(0) position: vec3, @location(1) normal: vec3, @location(2) uv: vec2, + @location(3) tangent: vec4, }; struct VertexOutput { @@ -59,6 +65,8 @@ struct VertexOutput { @location(1) world_pos: vec3, @location(2) uv: vec2, @location(3) light_space_pos: vec4, + @location(4) world_tangent: vec3, + @location(5) world_bitangent: vec3, }; @vertex @@ -70,6 +78,13 @@ fn vs_main(model_v: VertexInput) -> VertexOutput { out.clip_position = camera.view_proj * world_pos; out.uv = model_v.uv; out.light_space_pos = shadow.light_view_proj * world_pos; + + let T = normalize((camera.model * vec4(model_v.tangent.xyz, 0.0)).xyz); + let N_out = normalize((camera.model * vec4(model_v.normal, 0.0)).xyz); + let B = cross(N_out, T) * model_v.tangent.w; + out.world_tangent = T; + out.world_bitangent = B; + return out; } @@ -215,6 +230,23 @@ fn calculate_shadow(light_space_pos: vec4) -> f32 { return shadow_val / 9.0; } +// Procedural environment sampling for IBL +fn sample_environment(direction: vec3, roughness: f32) -> vec3 { + let t = direction.y * 0.5 + 0.5; + var env: vec3; + if direction.y > 0.0 { + let horizon = vec3(0.6, 0.6, 0.5); + let sky = vec3(0.3, 0.5, 0.9); + env = mix(horizon, sky, pow(direction.y, 0.4)); + } else { + let horizon = vec3(0.6, 0.6, 0.5); + let ground = vec3(0.1, 0.08, 0.06); + env = mix(horizon, ground, pow(-direction.y, 0.4)); + } + let avg = vec3(0.3, 0.35, 0.4); + return mix(env, avg, roughness * roughness); +} + @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { let tex_color = textureSample(t_diffuse, s_diffuse, in.uv); @@ -223,7 +255,19 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { let roughness = material.roughness; let ao = material.ao; - let N = normalize(in.world_normal); + // Normal mapping via TBN matrix + let T = normalize(in.world_tangent); + let B = normalize(in.world_bitangent); + let N_geom = normalize(in.world_normal); + + // Sample normal map (tangent space normal) + let normal_sample = textureSample(t_normal, s_normal, in.uv).rgb; + let tangent_normal = normal_sample * 2.0 - 1.0; + + // TBN matrix transforms tangent space -> world space + let TBN = mat3x3(T, B, N_geom); + let N = normalize(TBN * tangent_normal); + let V = normalize(camera.camera_pos - in.world_pos); // F0: base reflectivity; 0.04 for dielectrics, albedo for metals @@ -244,8 +288,22 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { Lo += contribution; } - // Ambient term - let ambient = lights_uniform.ambient_color * albedo * ao; + // IBL ambient term + let NdotV_ibl = max(dot(N, V), 0.0); + let R = reflect(-V, N); + + // Diffuse IBL + let irradiance = sample_environment(N, 1.0); + let F_env = fresnel_schlick(NdotV_ibl, F0); + let kd_ibl = (vec3(1.0) - F_env) * (1.0 - metallic); + let diffuse_ibl = kd_ibl * albedo * irradiance; + + // Specular IBL + let prefiltered = sample_environment(R, roughness); + let brdf_val = textureSample(t_brdf_lut, s_brdf_lut, vec2(NdotV_ibl, roughness)); + let specular_ibl = prefiltered * (F0 * brdf_val.r + vec3(brdf_val.g)); + + let ambient = (diffuse_ibl + specular_ibl) * ao; var color = ambient + Lo; diff --git a/crates/voltex_renderer/src/texture.rs b/crates/voltex_renderer/src/texture.rs index 0b95f33..965ead5 100644 --- a/crates/voltex_renderer/src/texture.rs +++ b/crates/voltex_renderer/src/texture.rs @@ -178,6 +178,142 @@ impl GpuTexture { ], }) } + + /// Create a 1x1 flat normal map texture (tangent-space up: 0,0,1). + /// Uses Rgba8Unorm (linear) since normal data is not sRGB. + pub fn flat_normal_1x1( + device: &wgpu::Device, + queue: &wgpu::Queue, + ) -> (wgpu::Texture, wgpu::TextureView, wgpu::Sampler) { + let size = wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }; + + // [128, 128, 255, 255] maps to (0, 0, 1) after * 2 - 1 + let pixels: [u8; 4] = [128, 128, 255, 255]; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("FlatNormalTexture"), + size, + 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: &[], + }); + + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + size, + ); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("FlatNormalSampler"), + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::Repeat, + address_mode_w: wgpu::AddressMode::Repeat, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Linear, + ..Default::default() + }); + + (texture, view, sampler) + } +} + +/// Bind group layout for PBR textures: albedo (binding 0-1) + normal map (binding 2-3). +pub fn pbr_texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("PBR Texture Bind Group Layout"), + entries: &[ + // binding 0: albedo texture + 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, + }, + // binding 1: albedo sampler + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // binding 2: normal map texture + 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, + }, + // binding 3: normal map sampler + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }) +} + +/// Create a bind group for PBR textures (albedo + normal map). +pub fn create_pbr_texture_bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + albedo_view: &wgpu::TextureView, + albedo_sampler: &wgpu::Sampler, + normal_view: &wgpu::TextureView, + normal_sampler: &wgpu::Sampler, +) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("PBR Texture Bind Group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(albedo_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(albedo_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(normal_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(normal_sampler), + }, + ], + }) } #[cfg(test)]