// -------------------------------------------------------------
// PBR (metal/roughness) BRDF with Image-Based Lighting (WGSL)
// Implements Cook-Torrance specular BRDF with split-sum IBL approximation
// Safe for HDR workflows (no final saturate - tone mapping applied elsewhere)
// Supports: KHR_materials_ior, KHR_materials_transmission, KHR_materials_volume,
// KHR_materials_clearcoat, KHR_materials_sheen
// -------------------------------------------------------------
// -------------------------------------------------------------
// IOR and Refraction Utilities
// -------------------------------------------------------------
// Get effective IOR value, defaulting to 1.5 when invalid (< 1.0)
// IOR = 1.0 is valid (air, no refraction), IOR < 1.0 is physically invalid
// Note: Rust side should default to 1.5 when KHR_materials_ior extension is absent
fn effective_ior(ior: f32) -> f32 {
return select(ior, 1.5, ior < 1.0);
}
// Convert index of refraction to F0 (reflectance at normal incidence)
// Default IOR of 1.5 yields F0 = 0.04 (standard dielectric)
fn ior_to_f0(ior: f32) -> f32 {
let ior_val = effective_ior(ior);
let ratio = (ior_val - 1.0) / (ior_val + 1.0);
return ratio * ratio;
}
// Calculate refracted direction using Snell's law
// Returns vec3(0) if total internal reflection occurs
fn refract_direction(incident: vec3<f32>, normal: vec3<f32>, eta: f32) -> vec3<f32> {
// Optimization: no refraction when eta ≈ 1.0 (same medium)
if (abs(eta - 1.0) < 0.001) {
return incident;
}
// eta = ior_outside / ior_inside (typically 1.0 / ior for entering)
let cos_i = -dot(incident, normal);
let sin_t2 = eta * eta * (1.0 - cos_i * cos_i);
// Total internal reflection check
if (sin_t2 > 1.0) {
return vec3<f32>(0.0); // Signal TIR to caller
}
let cos_t = sqrt(1.0 - sin_t2);
return eta * incident + (eta * cos_i - cos_t) * normal;
}
// -------------------------------------------------------------
// Volume Attenuation (Beer's Law)
// -------------------------------------------------------------
// Calculate light attenuation through a medium using Beer's Law
// T(x) = attenuation_color^(distance / attenuation_distance)
fn volume_attenuation(
distance: f32,
attenuation_color: vec3<f32>,
attenuation_distance: f32
) -> vec3<f32> {
// Early exit: no distance = no attenuation
if (distance <= 0.0) {
return vec3<f32>(1.0);
}
// Early exit: infinite distance = no attenuation
if (attenuation_distance <= 0.0 || attenuation_distance > 1e10) {
return vec3<f32>(1.0);
}
// Early exit: white = no color shift
if (all(attenuation_color >= vec3<f32>(0.999))) {
return vec3<f32>(1.0);
}
// Beer's Law: T(x) = c^(x/d)
return pow(attenuation_color, vec3<f32>(distance / attenuation_distance));
}
// Check if volume attenuation should be applied (optimization)
fn should_apply_volume_attenuation(
thickness: f32,
attenuation_distance: f32,
attenuation_color: vec3<f32>
) -> bool {
return thickness > 0.0
&& attenuation_distance < 1e10
&& any(attenuation_color < vec3<f32>(1.0));
}
// -------------------------------------------------------------
// Microfacet BRDF Components
// -------------------------------------------------------------
// Compute half-vector robustly.
// Returns zero when view and light are antiparallel (v + l == 0), which avoids
// injecting an arbitrary fallback direction into the BRDF.
fn safe_half_vector(v: vec3<f32>, l: vec3<f32>) -> vec3<f32> {
let sum = v + l;
let len_sq = dot(sum, sum);
if (len_sq > 1e-8) {
return sum * inverseSqrt(len_sq);
}
return vec3<f32>(0.0);
}
// Fresnel-Schlick approximation: view-dependent reflectance
fn fresnel_schlick(cos_theta: f32, F0: vec3<f32>) -> vec3<f32> {
let ct = saturate(cos_theta);
let one_minus = 1.0 - ct;
return F0 + (1.0 - F0) * pow(one_minus, 5.0);
}
// Fresnel-Schlick with explicit f90 for KHR_materials_specular
fn fresnel_schlick_f90(cos_theta: f32, F0: vec3<f32>, f90: f32) -> vec3<f32> {
let ct = saturate(cos_theta);
let one_minus = 1.0 - ct;
return F0 + (vec3<f32>(f90) - F0) * pow(one_minus, 5.0);
}
// GGX/Trowbridge-Reitz normal distribution function
fn distribution_ggx(n_dot_h: f32, alpha: f32) -> f32 {
let a = max(alpha, 0.001);
let a2 = a * a;
let ndh = saturate(n_dot_h);
let d = ndh * ndh * (a2 - 1.0) + 1.0;
return a2 / (PI * d * d + EPSILON);
}
// Schlick-GGX geometry function (single direction)
fn geometry_schlick_ggx(n_dot_x: f32, alpha: f32) -> f32 {
let a = max(alpha, 0.001);
let k = ((a + 1.0) * (a + 1.0)) * 0.125; // Direct lighting: k = (α+1)²/8
let ndx = saturate(n_dot_x);
return ndx / (ndx * (1.0 - k) + k);
}
// Smith geometry function (combines view and light directions)
fn geometry_smith(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>, alpha: f32) -> f32 {
let n_dot_v = saturate(dot(n, v));
let n_dot_l = saturate(dot(n, l));
return geometry_schlick_ggx(n_dot_v, alpha) * geometry_schlick_ggx(n_dot_l, alpha);
}
// -------------------------------------------------------------
// Clearcoat BRDF (KHR_materials_clearcoat)
// -------------------------------------------------------------
// Clearcoat uses a fixed F0 of 0.04 (standard dielectric)
const CLEARCOAT_F0: f32 = 0.04;
// Compute clearcoat specular contribution for direct lighting
fn clearcoat_brdf_direct(
clearcoat: f32,
clearcoat_roughness: f32,
clearcoat_normal: vec3<f32>,
v: vec3<f32>,
l: vec3<f32>,
) -> f32 {
// Early exit if no clearcoat
if (clearcoat <= 0.0) {
return 0.0;
}
let cc_n = safe_normalize(clearcoat_normal);
let h = safe_half_vector(v, l);
if (dot(h, h) == 0.0) {
return 0.0;
}
let cc_n_dot_l = max(dot(cc_n, l), 0.0);
let cc_n_dot_v = max(dot(cc_n, v), 1e-4);
let cc_n_dot_h = max(dot(cc_n, h), 0.0);
let cc_v_dot_h = max(dot(v, h), 0.0);
// Clearcoat uses squared roughness (alpha)
let cc_alpha = max(clearcoat_roughness * clearcoat_roughness, 0.001);
// GGX specular BRDF for clearcoat
let Fc = fresnel_schlick(cc_v_dot_h, vec3<f32>(CLEARCOAT_F0)).r;
let Dc = distribution_ggx(cc_n_dot_h, cc_alpha);
let Gc = geometry_smith(cc_n, v, l, cc_alpha);
return clearcoat * Fc * Dc * Gc / max(4.0 * cc_n_dot_l * cc_n_dot_v, EPSILON);
}
// Compute clearcoat Fresnel for energy conservation (attenuates base layer)
fn clearcoat_fresnel(clearcoat: f32, v_dot_h: f32) -> f32 {
if (clearcoat <= 0.0) {
return 0.0;
}
return clearcoat * fresnel_schlick(v_dot_h, vec3<f32>(CLEARCOAT_F0)).r;
}
// -------------------------------------------------------------
// Sheen BRDF (KHR_materials_sheen)
// Uses Charlie distribution for cloth-like sheen
// -------------------------------------------------------------
// Charlie distribution function for sheen (inverted Gaussian)
// This creates a soft, cloth-like highlight at grazing angles
fn distribution_charlie(n_dot_h: f32, roughness: f32) -> f32 {
let alpha = roughness * roughness;
let inv_alpha = 1.0 / alpha;
let cos2h = n_dot_h * n_dot_h;
let sin2h = 1.0 - cos2h;
// Charlie distribution: (2 + 1/alpha) * sin^(1/alpha) / (2*PI)
return (2.0 + inv_alpha) * pow(sin2h, inv_alpha * 0.5) / (2.0 * PI);
}
// Visibility function for sheen (Ashikhmin)
fn visibility_ashikhmin(n_dot_v: f32, n_dot_l: f32) -> f32 {
return 1.0 / (4.0 * (n_dot_l + n_dot_v - n_dot_l * n_dot_v));
}
// Compute sheen contribution for direct lighting
fn sheen_brdf_direct(
sheen_color: vec3<f32>,
sheen_roughness: f32,
n: vec3<f32>,
v: vec3<f32>,
l: vec3<f32>,
) -> vec3<f32> {
// Early exit if no sheen
if (all(sheen_color <= vec3<f32>(0.0))) {
return vec3<f32>(0.0);
}
let h = safe_half_vector(v, l);
if (dot(h, h) == 0.0) {
return vec3<f32>(0.0);
}
let n_dot_l = max(dot(n, l), 0.0);
let n_dot_v = max(dot(n, v), 1e-4);
let n_dot_h = max(dot(n, h), 0.0);
// Use minimum roughness to avoid division issues
let roughness = max(sheen_roughness, 0.07);
let D = distribution_charlie(n_dot_h, roughness);
let V = visibility_ashikhmin(n_dot_v, n_dot_l);
return sheen_color * D * V;
}
// Estimate sheen albedo scaling for energy conservation
// Based on KHR_materials_sheen spec: sheen_albedo_scaling = 1.0 - max3(sheenColor) * E(VdotN)
// E(x) is the directional albedo of the sheen BRDF, approximated here without an LUT
fn sheen_albedo_scaling(sheen_color: vec3<f32>, sheen_roughness: f32, n_dot_v: f32) -> f32 {
// Use max component as per spec (not luminance)
let sheen_max = max(max(sheen_color.r, sheen_color.g), sheen_color.b);
if (sheen_max <= 0.0) {
return 1.0; // No sheen = no scaling
}
// Approximate E(n_dot_v) - the directional albedo of the Charlie sheen BRDF
// E increases with roughness and at grazing angles (lower n_dot_v)
// This approximation is based on fitting to reference LUT values
let alpha = sheen_roughness * sheen_roughness;
// E ranges from ~0.0 at roughness=0 to ~0.2 at roughness=1 for normal incidence
// And increases at grazing angles
let E = alpha * (0.18 + 0.06 * (1.0 - n_dot_v));
return 1.0 - sheen_max * E;
}
// -------------------------------------------------------------
// IBL Sampling Functions
// -------------------------------------------------------------
// Sample pre-convolved irradiance map for diffuse IBL
fn sampleIrradiance(
n: vec3<f32>,
irradiance_tex: texture_cube<f32>,
irradiance_sampler: sampler
) -> vec3<f32> {
return textureSampleLevel(irradiance_tex, irradiance_sampler, n, 0.0).rgb;
}
// Sample prefiltered environment map for specular IBL (split-sum approximation)
// Roughness selects mip level: 0 = sharp reflections, max = fully diffuse
fn samplePrefilteredEnv(
R: vec3<f32>,
roughness: f32,
filtered_env_tex: texture_cube<f32>,
filtered_env_sampler: sampler,
ibl_info: IblInfo
) -> vec3<f32> {
let max_mip = f32(ibl_info.prefiltered_env_mip_count - 1u);
let mip_level = roughness * max_mip;
return textureSampleLevel(filtered_env_tex, filtered_env_sampler, R, mip_level).rgb;
}
// Sample BRDF integration LUT (2D texture indexed by N·V and roughness)
// Returns (scale, bias) for computing F0 * scale + bias
fn sampleBRDFLUT(
n_dot_v: f32,
roughness: f32,
brdf_lut_tex: texture_2d<f32>,
brdf_lut_sampler: sampler
) -> vec2<f32> {
let uv = vec2<f32>(saturate(n_dot_v), saturate(roughness));
return textureSampleLevel(brdf_lut_tex, brdf_lut_sampler, uv, 0.0).rg;
}
// -------------------------------------------------------------
// Direct Lighting BRDF (Cook-Torrance)
// With clearcoat and sheen extensions
// -------------------------------------------------------------
fn brdf_direct(color: PbrMaterialColor, light_brdf: LightBrdf, surface_to_camera: vec3<f32>) -> vec3<f32> {
let n = safe_normalize(light_brdf.normal);
let v = safe_normalize(surface_to_camera);
let l = safe_normalize(light_brdf.light_dir);
let h = safe_half_vector(v, l);
// Material properties
let base_color = color.base.rgb;
let metallic = clamp(color.metallic_roughness.x, 0.0, 1.0);
let roughness = max(clamp(color.metallic_roughness.y, 0.0, 1.0), 0.04);
let alpha = roughness * roughness;
// Lighting geometry
let n_dot_l = max(dot(n, l), 0.0);
let n_dot_v = max(dot(n, v), 1e-4);
let has_half = dot(h, h) > 0.0;
let n_dot_h = select(0.0, max(dot(n, h), 0.0), has_half);
let v_dot_h = select(0.0, max(dot(v, h), 0.0), has_half);
// F0: base reflectivity at normal incidence
// KHR_materials_ior: dielectric_f0_base = ((ior - 1) / (ior + 1))^2
// KHR_materials_specular: dielectric_f0 = min(f0_base * specular_color, 1.0) * specular
let dielectric_f0_base = ior_to_f0(color.ior);
let dielectric_f0 = min(vec3<f32>(dielectric_f0_base) * color.specular_color, vec3<f32>(1.0)) * color.specular;
let F0 = mix(dielectric_f0, base_color, metallic);
// f90: grazing angle reflectivity (specular for dielectrics, 1.0 for metals per spec)
let f90 = mix(color.specular, 1.0, metallic);
// Cook-Torrance specular BRDF: DFG / (4 * N·L * N·V)
// When V and L are antiparallel, H is undefined. Treat that as zero specular
// and use view-Fresnel for diffuse energy conservation.
let F = select(
fresnel_schlick_f90(n_dot_v, F0, f90),
fresnel_schlick_f90(v_dot_h, F0, f90),
has_half
);
let D = distribution_ggx(n_dot_h, alpha);
let G = geometry_smith(n, v, l, alpha);
let specular = select(
vec3<f32>(0.0),
F * (D * G) / max(4.0 * n_dot_l * n_dot_v, EPSILON),
has_half
);
// Lambertian diffuse (energy-conserving: scaled by (1-F_max) and non-metallic portion)
// Note: transmission modifies diffuse in brdf_ibl, but for direct lighting we keep
// standard diffuse since punctual lights don't transmit through surfaces
let F_max = max(max(F.r, F.g), F.b);
let k_d = (1.0 - F_max) * (1.0 - metallic);
let diffuse = k_d * base_color * (1.0 / PI);
// Base layer contribution
var result = (diffuse + specular) * light_brdf.radiance * n_dot_l * color.occlusion;
// Sheen contribution (cloth-like rim highlight)
let sheen = sheen_brdf_direct(color.sheen_color, color.sheen_roughness, n, v, l);
let sheen_scaling = sheen_albedo_scaling(color.sheen_color, color.sheen_roughness, n_dot_v);
result = result * sheen_scaling + sheen * light_brdf.radiance * n_dot_l * color.occlusion;
// Clearcoat contribution (additional specular layer)
let clearcoat_spec = clearcoat_brdf_direct(
color.clearcoat,
color.clearcoat_roughness,
color.clearcoat_normal,
v,
l,
);
let cc_fresnel = clearcoat_fresnel(color.clearcoat, v_dot_h);
// Attenuate base layer by clearcoat Fresnel, then add clearcoat specular
result = result * (1.0 - cc_fresnel) + clearcoat_spec * light_brdf.radiance * n_dot_l;
return result;
}
// -------------------------------------------------------------
// Image-Based Lighting (IBL) - Split-sum Approximation
// -------------------------------------------------------------
// IBL with transmission background provided by caller
// transmission_background: pre-sampled color from behind the surface (screen-space or IBL)
fn brdf_ibl_with_transmission(
color: PbrMaterialColor,
normal: vec3<f32>,
surface_to_camera: vec3<f32>,
ibl_filtered_env_tex: texture_cube<f32>,
ibl_filtered_env_sampler: sampler,
ibl_irradiance_tex: texture_cube<f32>,
ibl_irradiance_sampler: sampler,
brdf_lut_tex: texture_2d<f32>,
brdf_lut_sampler: sampler,
ibl_info: IblInfo,
transmission_background: vec3<f32>,
) -> vec3<f32> {
let n = safe_normalize(normal);
let v = safe_normalize(surface_to_camera);
// Material properties
let base_color = color.base.rgb;
let metallic = clamp(color.metallic_roughness.x, 0.0, 1.0);
let roughness = max(clamp(color.metallic_roughness.y, 0.0, 1.0), 0.04);
let n_dot_v = saturate(dot(n, v));
// F0: base reflectivity at normal incidence
// KHR_materials_ior: dielectric_f0_base = ((ior - 1) / (ior + 1))^2
// KHR_materials_specular: dielectric_f0 = min(f0_base * specular_color, 1.0) * specular
let dielectric_f0_base = ior_to_f0(color.ior);
let dielectric_f0 = min(vec3<f32>(dielectric_f0_base) * color.specular_color, vec3<f32>(1.0)) * color.specular;
let F0 = mix(dielectric_f0, base_color, metallic);
// f90: grazing angle reflectivity (specular for dielectrics, 1.0 for metals per spec)
let f90 = mix(color.specular, 1.0, metallic);
// Fresnel at view direction
let F_view = fresnel_schlick_f90(n_dot_v, F0, f90);
let F_view_max = max(max(F_view.r, F_view.g), F_view.b);
// Effective transmission: metals don't transmit
let effective_transmission = color.transmission * (1.0 - metallic);
// Calculate base layer (diffuse or transmission)
var base_layer = vec3<f32>(0.0);
if (effective_transmission > 0.0) {
// Diffuse IBL contribution
let irradiance = sampleIrradiance(n, ibl_irradiance_tex, ibl_irradiance_sampler);
let diffuse_brdf = base_color * (1.0 / PI) * irradiance;
// Transmission BTDF contribution
// Apply volume attenuation if thickness > 0
var attenuation = vec3<f32>(1.0);
if (should_apply_volume_attenuation(
color.volume_thickness,
color.volume_attenuation_distance,
color.volume_attenuation_color
)) {
attenuation = volume_attenuation(
color.volume_thickness,
color.volume_attenuation_color,
color.volume_attenuation_distance
);
}
// BTDF: transmitted background * base_color * attenuation
let transmission_btdf = transmission_background * base_color * attenuation;
// Mix diffuse and transmission based on transmission factor
// Per spec: base = mix(diffuse_brdf, specular_btdf * baseColor, transmission)
base_layer = mix(diffuse_brdf, transmission_btdf, effective_transmission);
} else {
// No transmission - standard diffuse
let irradiance = sampleIrradiance(n, ibl_irradiance_tex, ibl_irradiance_sampler);
base_layer = base_color * (1.0 / PI) * irradiance;
}
// Apply diffuse/transmission energy conservation
let k_d = (1.0 - F_view_max) * (1.0 - metallic);
let base_contribution = k_d * base_layer * color.occlusion;
// Specular IBL: prefiltered environment * (F0 * scale + f90 * bias) from BRDF LUT
let R = reflect(-v, n);
let prefiltered = samplePrefilteredEnv(R, roughness, ibl_filtered_env_tex, ibl_filtered_env_sampler, ibl_info);
let brdf_lut = sampleBRDFLUT(n_dot_v, roughness, brdf_lut_tex, brdf_lut_sampler);
// Apply occlusion to specular with reduced strength to avoid over-darkening reflections
let specular = prefiltered * (F0 * brdf_lut.x + vec3<f32>(f90) * brdf_lut.y) * mix(1.0, color.occlusion, 0.5);
// Sheen contribution for IBL (approximate using diffuse irradiance)
let sheen_scaling = sheen_albedo_scaling(color.sheen_color, color.sheen_roughness, n_dot_v);
var base_with_sheen = base_contribution * sheen_scaling;
// Add sheen IBL (approximate: sheen color * irradiance for rim effect)
if (any(color.sheen_color > vec3<f32>(0.0))) {
let irradiance_sheen = sampleIrradiance(n, ibl_irradiance_tex, ibl_irradiance_sampler);
// Sheen is strongest at grazing angles, scaled by roughness
// Using a gentler approximation that factors in roughness
let alpha = color.sheen_roughness * color.sheen_roughness;
let fresnel_sheen = pow(1.0 - n_dot_v, 3.0); // Softer falloff
let sheen_contrib = color.sheen_color * irradiance_sheen * alpha * fresnel_sheen * color.occlusion;
base_with_sheen += sheen_contrib;
}
var result = base_with_sheen + specular + color.emissive;
// Clearcoat IBL layer
if (color.clearcoat > 0.0) {
let cc_n = safe_normalize(color.clearcoat_normal);
let cc_n_dot_v = saturate(dot(cc_n, v));
let cc_R = reflect(-v, cc_n);
let cc_roughness = max(color.clearcoat_roughness, 0.04);
// Sample prefiltered environment for clearcoat reflection
let cc_prefiltered = samplePrefilteredEnv(cc_R, cc_roughness, ibl_filtered_env_tex, ibl_filtered_env_sampler, ibl_info);
let cc_brdf_lut = sampleBRDFLUT(cc_n_dot_v, cc_roughness, brdf_lut_tex, brdf_lut_sampler);
// Clearcoat specular (F0 = 0.04 for dielectric)
let cc_specular = cc_prefiltered * (CLEARCOAT_F0 * cc_brdf_lut.x + cc_brdf_lut.y);
// Clearcoat Fresnel attenuation
let cc_fresnel = clearcoat_fresnel(color.clearcoat, n_dot_v);
// Final: attenuated base + clearcoat
result = result * (1.0 - cc_fresnel) + color.clearcoat * cc_specular;
}
return result;
}
// Standard IBL without explicit transmission background (uses IBL for transmission)
fn brdf_ibl(
color: PbrMaterialColor,
normal: vec3<f32>,
surface_to_camera: vec3<f32>,
ibl_filtered_env_tex: texture_cube<f32>,
ibl_filtered_env_sampler: sampler,
ibl_irradiance_tex: texture_cube<f32>,
ibl_irradiance_sampler: sampler,
brdf_lut_tex: texture_2d<f32>,
brdf_lut_sampler: sampler,
ibl_info: IblInfo
) -> vec3<f32> {
// For IBL-only transmission, sample the environment in the refracted direction
var transmission_background = vec3<f32>(0.0);
let effective_transmission = color.transmission * (1.0 - clamp(color.metallic_roughness.x, 0.0, 1.0));
if (effective_transmission > 0.0) {
let n = safe_normalize(normal);
let v = safe_normalize(surface_to_camera);
let roughness = max(clamp(color.metallic_roughness.y, 0.0, 1.0), 0.04);
// Determine sample direction for transmission
var sample_dir = -v; // Default: straight through (thin-walled)
// If volumetric (thickness > 0), apply refraction
let ior_val = effective_ior(color.ior);
if (color.volume_thickness > 0.0 && ior_val != 1.0) {
let refracted = refract_direction(v, n, 1.0 / ior_val);
// Use dot product instead of length to avoid sqrt (checking for non-zero)
if (dot(refracted, refracted) > 1e-6) {
sample_dir = refracted;
}
// else: TIR occurred, keep straight-through direction
}
// Sample environment with roughness-based blur
transmission_background = samplePrefilteredEnv(
sample_dir,
roughness,
ibl_filtered_env_tex,
ibl_filtered_env_sampler,
ibl_info
);
}
return brdf_ibl_with_transmission(
color,
normal,
surface_to_camera,
ibl_filtered_env_tex,
ibl_filtered_env_sampler,
ibl_irradiance_tex,
ibl_irradiance_sampler,
brdf_lut_tex,
brdf_lut_sampler,
ibl_info,
transmission_background
);
}