Skip to content

Solari specular scene/PT support #20242

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 41 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
90e02c7
Adjust specular_multiscatter to not take LightingInput
JMS55 Jul 9, 2025
c73405b
Fix SSAO specular occlusion roughness bug
JMS55 Jul 9, 2025
f340c77
Add specular material properties to solari bindings
JMS55 Jul 9, 2025
dfb3401
Fix inverted ray in path tracer (dosen't actually affect results)
JMS55 Jul 9, 2025
bf5ed11
WIP specular material
JMS55 Jul 9, 2025
d80b8ef
Merge commit 'f964ee1e3ae2fc28ab2e7586b581271fb973e913' into solari6-…
JMS55 Jul 17, 2025
0ecbd8f
Fix merge
JMS55 Jul 17, 2025
18b5d1f
The good parts
JMS55 Jul 18, 2025
c474114
Remove extraneous h parameter for D_GGX
JMS55 Jul 19, 2025
b143c10
Add VNDF sampling routines
JMS55 Jul 19, 2025
b4a2f77
WIP specular pathtracer (removed NEE for now)
JMS55 Jul 19, 2025
09f36c4
Diffuse/specular weights always sum to 1
JMS55 Jul 19, 2025
82519a5
Add world_tangent to ResolvedRayHitFull
JMS55 Jul 19, 2025
02841d7
Fix specular
JMS55 Jul 19, 2025
3ebb7d4
Allow smoother specular materials
JMS55 Jul 19, 2025
3df77e2
Remove uneeded saturates
JMS55 Jul 19, 2025
6f9b1b5
Add back NEE
JMS55 Jul 19, 2025
4714d4e
Merge commit '6354a950ee4d2a282ebf955cdd0b9524872571b1' into solari6-…
JMS55 Jul 20, 2025
92bc08b
Refactoring
JMS55 Jul 20, 2025
454a861
Paper links
JMS55 Jul 20, 2025
548f73f
Update release notes with PR number
JMS55 Jul 20, 2025
aa4c545
CI
JMS55 Jul 20, 2025
140baf2
Merge branch 'main' into solari6-perfopt-good
JMS55 Jul 20, 2025
eb84298
Small fix
JMS55 Jul 20, 2025
ca464f1
Merge branch 'solari6-perfopt-good' of https://github.com/JMS55/bevy …
JMS55 Jul 20, 2025
5c5f099
Add scene limit checks
JMS55 Jul 20, 2025
aefcddf
Fix error message
JMS55 Jul 20, 2025
b377f53
Merge commit 'aefcddf2087b941e13b69a2a410cd1d35524dc9d' into solari6-…
JMS55 Jul 21, 2025
7430d90
Adjust example
JMS55 Jul 21, 2025
be3142b
Merge commit 'f858c0d6e10cb7b73d69c3d4de0c98d89ed041c4' into solari6-…
JMS55 Jul 22, 2025
2f8a1a4
Adjust release notes
JMS55 Jul 22, 2025
65b9caf
Misc
JMS55 Jul 23, 2025
47a4d04
Misc
JMS55 Jul 23, 2025
ae3aa09
Merge commit '4b1b70d5011cfac9fd5be3aab76ee4b01300c863' into solari6-…
JMS55 Jul 23, 2025
e79d7ae
implement mis into pt
SparkyPotato Jul 24, 2025
9aea643
add to release notes
SparkyPotato Jul 24, 2025
70f8ad8
address feedback and special case specular
SparkyPotato Jul 24, 2025
fb0cc10
Merge pull request #34 from SparkyPotato/solari6-mis
JMS55 Jul 25, 2025
b1464ca
Merge branch 'main' into solari6-specular
JMS55 Jul 25, 2025
5761e40
fix lower exposure
SparkyPotato Jul 26, 2025
2b2b3c7
Merge pull request #35 from SparkyPotato/solari6-mis
JMS55 Jul 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 45 additions & 12 deletions crates/bevy_solari/src/pathtracer/pathtracer.wgsl
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
#import bevy_core_pipeline::tonemapping::tonemapping_luminance as luminance
#import bevy_pbr::pbr_functions::calculate_tbn_mikktspace
#import bevy_pbr::utils::{rand_f, rand_vec2f, sample_cosine_hemisphere}
#import bevy_render::maths::PI
#import bevy_render::view::View
#import bevy_solari::sampling::sample_random_light
#import bevy_solari::scene_bindings::{trace_ray, resolve_ray_hit_full, RAY_T_MIN, RAY_T_MAX}
#import bevy_solari::brdf::evaluate_brdf
#import bevy_solari::sampling::{sample_random_light, sample_ggx_vndf, ggx_vndf_pdf}
#import bevy_solari::scene_bindings::{trace_ray, resolve_ray_hit_full, ResolvedRayHitFull, RAY_T_MIN, RAY_T_MAX}

@group(1) @binding(0) var accumulation_texture: texture_storage_2d<rgba32float, read_write>;
@group(1) @binding(1) var view_output: texture_storage_2d<rgba16float, write>;
Expand Down Expand Up @@ -39,28 +41,26 @@ fn pathtrace(@builtin(global_invocation_id) global_id: vec3<u32>) {
let ray_hit = trace_ray(ray_origin, ray_direction, ray_t_min, RAY_T_MAX, RAY_FLAG_NONE);
if ray_hit.kind != RAY_QUERY_INTERSECTION_NONE {
let ray_hit = resolve_ray_hit_full(ray_hit);

// Evaluate material BRDF
let diffuse_brdf = ray_hit.material.base_color / PI;
let wo = -ray_direction;

// Use emissive only on the first ray (coming from the camera)
if ray_t_min == 0.0 { radiance = ray_hit.material.emissive; }

// Sample direct lighting
let direct_lighting = sample_random_light(ray_hit.world_position, ray_hit.world_normal, &rng);
radiance += throughput * diffuse_brdf * direct_lighting.radiance * direct_lighting.inverse_pdf;
let direct_lighting_brdf = evaluate_brdf(ray_hit.world_normal, wo, direct_lighting.wi, ray_hit.material);
radiance += throughput * direct_lighting.radiance * direct_lighting.inverse_pdf * direct_lighting_brdf;

// Sample new ray direction from the material BRDF for next bounce
ray_direction = sample_cosine_hemisphere(ray_hit.world_normal, &rng);

// Update other variables for next bounce
let next_bounce = importance_sample_next_bounce(wo, ray_hit, &rng);
ray_direction = next_bounce.wi;
ray_origin = ray_hit.world_position;
ray_t_min = RAY_T_MIN;

// Update throughput for next bounce
let cos_theta = dot(-ray_direction, ray_hit.world_normal);
let cosine_hemisphere_pdf = cos_theta / PI; // Weight for the next bounce because we importance sampled the diffuse BRDF for the next ray direction
throughput *= (diffuse_brdf * cos_theta) / cosine_hemisphere_pdf;
let brdf = evaluate_brdf(ray_hit.world_normal, wo, next_bounce.wi, ray_hit.material);
let cos_theta = dot(next_bounce.wi, ray_hit.world_normal);
throughput *= (brdf * cos_theta) / next_bounce.pdf;

// Russian roulette for early termination
let p = luminance(throughput);
Expand All @@ -77,3 +77,36 @@ fn pathtrace(@builtin(global_invocation_id) global_id: vec3<u32>) {
textureStore(accumulation_texture, global_id.xy, vec4(new_color, old_color.a + 1.0));
textureStore(view_output, global_id.xy, vec4(new_color, 1.0));
}

struct NextBounce {
wi: vec3<f32>,
pdf: f32,
}

fn importance_sample_next_bounce(wo: vec3<f32>, ray_hit: ResolvedRayHitFull, rng: ptr<function, u32>) -> NextBounce {
let diffuse_weight = 1.0 - ray_hit.material.metallic;
let specular_weight = ray_hit.material.metallic;

let TBN = calculate_tbn_mikktspace(ray_hit.world_normal, ray_hit.world_tangent);
let T = TBN[0];
let B = TBN[1];
let N = TBN[2];

let wo_tangent = vec3(dot(wo, T), dot(wo, B), dot(wo, N));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If TBN is a mat3<f32>, this is just TBN * wo

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm this is the code we use everywhere else in bevy 😅


var wi: vec3<f32>;
var wi_tangent: vec3<f32>;
if rand_f(rng) < diffuse_weight {
wi = sample_cosine_hemisphere(ray_hit.world_normal, rng);
wi_tangent = vec3(dot(wi, T), dot(wi, B), dot(wi, N));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

} else {
wi_tangent = sample_ggx_vndf(wo_tangent, ray_hit.material.roughness, rng);
wi = wi_tangent.x * T + wi_tangent.y * B + wi_tangent.z * N;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this is a mul with the transpose.

}

let diffuse_pdf = dot(wi, ray_hit.world_normal) / PI;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have wi_tangent here, can just use the Z component (I assume that's up) instead of the dot.

let specular_pdf = ggx_vndf_pdf(wo_tangent, wi_tangent, ray_hit.material.roughness);
let pdf = (diffuse_weight * diffuse_pdf) + (specular_weight * specular_pdf);

return NextBounce(wi, pdf);
}
31 changes: 23 additions & 8 deletions crates/bevy_solari/src/scene/binder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,23 @@ pub fn prepare_raytracing_scene_bindings(
let Some(emissive_texture_id) = process_texture(&material.emissive_texture) else {
continue;
};
let Some(metallic_roughness_texture_id) =
process_texture(&material.metallic_roughness_texture)
else {
continue;
};

materials.get_mut().push(GpuMaterial {
base_color: material.base_color.to_linear(),
emissive: material.emissive,
base_color_texture_id,
normal_map_texture_id,
base_color_texture_id,
emissive_texture_id,
metallic_roughness_texture_id,

base_color: LinearRgba::from(material.base_color).to_vec3(),
perceptual_roughness: material.perceptual_roughness,
emissive: material.emissive.to_vec3(),
metallic: material.metallic,
reflectance: LinearRgba::from(material.specular_tint).to_vec3() * material.reflectance,
_padding: Default::default(),
});

Expand Down Expand Up @@ -184,7 +194,7 @@ pub fn prepare_raytracing_scene_bindings(

material_ids.get_mut().push(material_id);

if material.emissive != LinearRgba::BLACK {
if material.emissive != Vec3::ZERO {
light_sources
.get_mut()
.push(GpuLightSource::new_emissive_mesh_light(
Expand Down Expand Up @@ -346,12 +356,17 @@ struct GpuInstanceGeometryIds {

#[derive(ShaderType)]
struct GpuMaterial {
base_color: LinearRgba,
emissive: LinearRgba,
base_color_texture_id: u32,
normal_map_texture_id: u32,
base_color_texture_id: u32,
emissive_texture_id: u32,
_padding: u32,
metallic_roughness_texture_id: u32,

base_color: Vec3,
perceptual_roughness: f32,
emissive: Vec3,
metallic: f32,
reflectance: Vec3,
_padding: f32,
}

#[derive(ShaderType)]
Expand Down
56 changes: 56 additions & 0 deletions crates/bevy_solari/src/scene/brdf.wgsl
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#define_import_path bevy_solari::brdf

#import bevy_pbr::lighting::{F_AB, D_GGX, V_SmithGGXCorrelated, fresnel, specular_multiscatter}
#import bevy_pbr::pbr_functions::{calculate_diffuse_color, calculate_F0}
#import bevy_render::maths::PI
#import bevy_solari::scene_bindings::ResolvedMaterial

fn evaluate_brdf(
world_normal: vec3<f32>,
wo: vec3<f32>,
wi: vec3<f32>,
material: ResolvedMaterial,
) -> vec3<f32> {
let diffuse_brdf = diffuse_brdf(material.base_color, material.metallic);
let specular_brdf = specular_brdf(
world_normal,
wo,
wi,
material.base_color,
material.metallic,
material.reflectance,
material.perceptual_roughness,
material.roughness,
);
return diffuse_brdf + specular_brdf;
}

fn diffuse_brdf(base_color: vec3<f32>, metallic: f32) -> vec3<f32> {
let diffuse_color = calculate_diffuse_color(base_color, metallic, 0.0, 0.0);
return diffuse_color / PI;
}

fn specular_brdf(
N: vec3<f32>,
V: vec3<f32>,
L: vec3<f32>,
base_color: vec3<f32>,
metallic: f32,
reflectance: vec3<f32>,
perceptual_roughness: f32,
roughness: f32,
) -> vec3<f32> {
let H = normalize(L + V);
let NdotL = saturate(dot(N, L));
let NdotH = saturate(dot(N, H));
let LdotH = saturate(dot(L, H));
let NdotV = max(dot(N, V), 0.0001);

let F0 = calculate_F0(base_color, metallic, reflectance);
let F_ab = F_AB(perceptual_roughness, NdotV);

let D = D_GGX(roughness, NdotH);
let Vs = V_SmithGGXCorrelated(roughness, NdotV, NdotL);
let F = fresnel(F0, LdotH);
return specular_multiscatter(D, Vs, F, F0, F_ab, 1.0);
}
1 change: 1 addition & 0 deletions crates/bevy_solari/src/scene/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub struct RaytracingScenePlugin;

impl Plugin for RaytracingScenePlugin {
fn build(&self, app: &mut App) {
load_shader_library!(app, "brdf.wgsl");
load_shader_library!(app, "raytracing_scene_bindings.wgsl");
load_shader_library!(app, "sampling.wgsl");

Expand Down
49 changes: 39 additions & 10 deletions crates/bevy_solari/src/scene/raytracing_scene_bindings.wgsl
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#define_import_path bevy_solari::scene_bindings

#import bevy_pbr::lighting::perceptualRoughnessToRoughness
#import bevy_pbr::pbr_functions::calculate_tbn_mikktspace

struct InstanceGeometryIds {
vertex_buffer_id: u32,
vertex_buffer_offset: u32,
Expand Down Expand Up @@ -34,12 +37,17 @@ fn unpack_vertex(packed: PackedVertex) -> Vertex {
}

struct Material {
base_color: vec4<f32>,
emissive: vec4<f32>,
base_color_texture_id: u32,
normal_map_texture_id: u32,
base_color_texture_id: u32,
emissive_texture_id: u32,
_padding: u32,
metallic_roughness_texture_id: u32,

base_color: vec3<f32>,
perceptual_roughness: f32,
emissive: vec3<f32>,
metallic: f32,
reflectance: vec3<f32>,
_padding: f32,
}

const TEXTURE_MAP_NONE = 0xFFFFFFFFu;
Expand Down Expand Up @@ -94,12 +102,17 @@ fn sample_texture(id: u32, uv: vec2<f32>) -> vec3<f32> {
struct ResolvedMaterial {
base_color: vec3<f32>,
emissive: vec3<f32>,
reflectance: vec3<f32>,
perceptual_roughness: f32,
roughness: f32,
metallic: f32,
}

struct ResolvedRayHitFull {
world_position: vec3<f32>,
world_normal: vec3<f32>,
geometric_world_normal: vec3<f32>,
world_tangent: vec4<f32>,
uv: vec2<f32>,
triangle_area: f32,
material: ResolvedMaterial,
Expand All @@ -118,6 +131,17 @@ fn resolve_material(material: Material, uv: vec2<f32>) -> ResolvedMaterial {
m.emissive *= sample_texture(material.emissive_texture_id, uv);
}

m.reflectance = material.reflectance;

m.perceptual_roughness = material.perceptual_roughness;
m.metallic = material.metallic;
if material.metallic_roughness_texture_id != TEXTURE_MAP_NONE {
let metallic_roughness = sample_texture(material.metallic_roughness_texture_id, uv);
m.perceptual_roughness *= metallic_roughness.g;
m.metallic *= metallic_roughness.b;
}
m.roughness = clamp(m.perceptual_roughness * m.perceptual_roughness, 0.001, 1.0);

return m;
}

Expand All @@ -144,15 +168,20 @@ fn resolve_triangle_data_full(instance_id: u32, triangle_id: u32, barycentrics:

let uv = mat3x2(vertices[0].uv, vertices[1].uv, vertices[2].uv) * barycentrics;

let local_tangent = mat3x3(vertices[0].tangent.xyz, vertices[1].tangent.xyz, vertices[2].tangent.xyz) * barycentrics;
let world_tangent = vec4(
normalize(mat3x3(transform[0].xyz, transform[1].xyz, transform[2].xyz) * local_tangent),
vertices[0].tangent.w,
);

let local_normal = mat3x3(vertices[0].normal, vertices[1].normal, vertices[2].normal) * barycentrics; // TODO: Use barycentric lerp, ray_hit.object_to_world, cross product geo normal
var world_normal = normalize(mat3x3(transform[0].xyz, transform[1].xyz, transform[2].xyz) * local_normal);
let geometric_world_normal = world_normal;
if material.normal_map_texture_id != TEXTURE_MAP_NONE {
let local_tangent = mat3x3(vertices[0].tangent.xyz, vertices[1].tangent.xyz, vertices[2].tangent.xyz) * barycentrics;
let world_tangent = normalize(mat3x3(transform[0].xyz, transform[1].xyz, transform[2].xyz) * local_tangent);
let N = world_normal;
let T = world_tangent;
let B = vertices[0].tangent.w * cross(N, T);
let TBN = calculate_tbn_mikktspace(world_normal, world_tangent);
let T = TBN[0];
let B = TBN[1];
let N = TBN[2];
let Nt = sample_texture(material.normal_map_texture_id, uv);
world_normal = normalize(Nt.x * T + Nt.y * B + Nt.z * N);
}
Expand All @@ -163,5 +192,5 @@ fn resolve_triangle_data_full(instance_id: u32, triangle_id: u32, barycentrics:

let resolved_material = resolve_material(material, uv);

return ResolvedRayHitFull(world_position, world_normal, geometric_world_normal, uv, triangle_area, resolved_material);
return ResolvedRayHitFull(world_position, world_normal, geometric_world_normal, world_tangent, uv, triangle_area, resolved_material);
}
43 changes: 42 additions & 1 deletion crates/bevy_solari/src/scene/sampling.wgsl
Original file line number Diff line number Diff line change
@@ -1,9 +1,50 @@
#define_import_path bevy_solari::sampling

#import bevy_pbr::lighting::D_GGX
#import bevy_pbr::utils::{rand_f, rand_vec2f, rand_u, rand_range_u}
#import bevy_render::maths::PI_2
#import bevy_render::maths::{PI_2, orthonormalize}
#import bevy_solari::scene_bindings::{trace_ray, RAY_T_MIN, RAY_T_MAX, light_sources, directional_lights, LightSource, LIGHT_SOURCE_KIND_DIRECTIONAL, resolve_triangle_data_full}

// https://gpuopen.com/download/Bounded_VNDF_Sampling_for_Smith-GGX_Reflections.pdf (Listing 1)
fn sample_ggx_vndf(wi_tangent: vec3<f32>, roughness: f32, rng: ptr<function, u32>) -> vec3<f32> {
let i = wi_tangent;
let rand = rand_vec2f(rng);
let i_std = normalize(vec3(i.xy * roughness, i.z));
let phi = PI_2 * rand.x;
let a = roughness;
let s = 1.0 + length(vec2(i.xy));
let a2 = a * a;
let s2 = s * s;
let k = (1.0 - a2) * s2 / (s2 + a2 * i.z * i.z);
let b = select(i_std.z, k * i_std.z, i.z > 0.0);
let z = fma(1.0 - rand.y, 1.0 + b, -b);
let sin_theta = sqrt(saturate(1.0 - z * z));
let o_std = vec3(sin_theta * cos(phi), sin_theta * sin(phi), z);
let m_std = i_std + o_std;
let m = normalize(vec3(m_std.xy * roughness, m_std.z));
return 2.0 * dot(i, m) * m - i;
}

// https://gpuopen.com/download/Bounded_VNDF_Sampling_for_Smith-GGX_Reflections.pdf (Listing 2)
fn ggx_vndf_pdf(wi_tangent: vec3<f32>, wo_tangent: vec3<f32>, roughness: f32) -> f32 {
let i = wi_tangent;
let o = wo_tangent;
let m = normalize(i + o);
let ndf = D_GGX(roughness, saturate(m.z));
let ai = roughness * i.xy;
let len2 = dot(ai, ai);
let t = sqrt(len2 + i.z * i.z);
if i.z >= 0.0 {
let a = roughness;
let s = 1.0 + length(i.xy);
let a2 = a * a;
let s2 = s * s;
let k = (1.0 - a2) * s2 / (s2 + a2 * i.z * i.z);
return ndf / (2.0 * (k * i.z + t));
}
return ndf * (t - i.z) / (2.0 * len2);
}

struct LightSample {
light_id: u32,
seed: u32,
Expand Down
9 changes: 7 additions & 2 deletions examples/3d/solari.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ fn add_raytracing_meshes_on_scene_load(
mut commands: Commands,
args: Res<Args>,
) {
// Ensure meshes are bevy_solari compatible
// Ensure meshes are Solari compatible
for (_, mesh) in meshes.iter_mut() {
mesh.remove_attribute(Mesh::ATTRIBUTE_UV_1.id);
mesh.remove_attribute(Mesh::ATTRIBUTE_COLOR.id);
Expand All @@ -113,8 +113,13 @@ fn add_raytracing_meshes_on_scene_load(
}
}

// Increase material emissive intensity to make it prettier for the example
// Adjust scene materials to better demo Solari features
for (_, material) in materials.iter_mut() {
material.emissive *= 200.0;

if material.base_color.to_linear() == LinearRgba::new(0.5, 0.5, 0.5, 1.0) {
material.metallic = 1.0;
material.perceptual_roughness = 0.15;
}
}
}
2 changes: 1 addition & 1 deletion release-content/release-notes/bevy_solari.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Initial raytraced lighting progress (bevy_solari)
authors: ["@JMS55"]
pull_requests: [19058, 19620, 19790, 20020, 20113, 20213]
pull_requests: [19058, 19620, 19790, 20020, 20113, 20213, 20242]
---

(TODO: Embed solari example screenshot here)
Expand Down
Loading