Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions native/shared/src/renderer/final_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ impl Renderer {
binding: 7,
resource: wgpu::BindingResource::TextureView(&self.depth_view),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&self.tonemap_lut.view),
},
],
});
self.composite_bind_group_cache[composite_cache_index] = Some(composite_bg);
Expand Down
15 changes: 15 additions & 0 deletions native/shared/src/renderer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ mod temporal_diagnostics;
mod temporal_history;
mod temporal_reactive;
mod texture_store;
mod tonemap_lut;
mod transmitted_shadows;
mod transparent_gi;
#[cfg(feature = "models3d")]
Expand Down Expand Up @@ -609,6 +610,8 @@ pub struct Renderer {
/// the tonemap.
pub composite_pipeline: LazyRenderPipeline,
pub composite_layout: wgpu::BindGroupLayout,
/// The AgX tonemap as a 3D table (tonemap_lut.rs), read by the composite.
tonemap_lut: tonemap_lut::TonemapLut,
pub composite_sampler: wgpu::Sampler,
composite_bind_group_cache: [Option<wgpu::BindGroup>; postfx_chain::CompositeSource::COUNT * 2],
/// 0 = ACES (default, matches bloom-reference), 1 = AgX.
Expand Down Expand Up @@ -4123,6 +4126,7 @@ impl Renderer {
label: Some("composite_shader"),
source: wgpu::ShaderSource::Wgsl(COMPOSITE_SHADER_WGSL.into()),
});
let tonemap_lut = tonemap_lut::TonemapLut::new(&device, &queue);
let composite_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("composite_layout"),
entries: &[
Expand Down Expand Up @@ -4194,6 +4198,16 @@ impl Renderer {
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 8,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D3,
multisampled: false,
},
count: None,
},
],
});
let composite_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
Expand Down Expand Up @@ -8128,6 +8142,7 @@ impl Renderer {
scene_compose_bind_group_cache: std::array::from_fn(|_| None),
composite_pipeline,
composite_layout,
tonemap_lut,
composite_sampler,
composite_bind_group_cache: std::array::from_fn(|_| None),
// 1 = AgX (Troy Sobotka 2022). Matches Blender 4.0+ and
Expand Down
1 change: 1 addition & 0 deletions native/shared/src/renderer/shaders/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ mod post;
pub(super) use post::{
BLOOM_SHADER_WGSL, COMPOSITE_SHADER_WGSL, DOF_SHADER_WGSL, EXPOSURE_SHADER_WGSL,
MOTION_BLUR_SHADER_WGSL, SCENE_COMPOSE_SHADER_WGSL, SSS_SHADER_WGSL, TAA_SHADER_WGSL,
TONEMAP_LUT_BAKE_WGSL,
};
mod post_upscale;
pub(super) use post_upscale::{RCAS_SHADER_WGSL, UPSCALE_SHADER_WGSL};
171 changes: 33 additions & 138 deletions native/shared/src/renderer/shaders/post.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1520,7 +1520,10 @@ fn fs_main() -> @location(0) vec4<f32> {
/// reads hdr_rt and writes ACES-tonemapped linear-RGB. Hardware
/// performs the linear→sRGB encode on write because the surface
/// format is sRGB.
pub(in crate::renderer) const COMPOSITE_SHADER_WGSL: &str = "
pub(in crate::renderer) const COMPOSITE_SHADER_WGSL: &str = concat!(
include_str!("tonemap_lut.wgsl"),
include_str!("tonemap.wgsl"),
"
struct CompositeParams {
/// x = tonemap mode (0 = ACES, 1 = AgX)
/// y = auto-exposure enabled (0 = off, uses manual x)
Expand Down Expand Up @@ -1549,6 +1552,7 @@ struct CompositeParams {
@group(0) @binding(5) var ssao_tex: texture_2d<f32>;
@group(0) @binding(6) var ssao_samp: sampler;
@group(0) @binding(7) var scene_depth_tex: texture_depth_2d;
@group(0) @binding(8) var tonemap_lut: texture_3d<f32>;

struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
Expand All @@ -1565,150 +1569,22 @@ fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut {
return out;
}

fn aces_tone(c: vec3<f32>) -> vec3<f32> {
let a = 2.51;
let b = 0.03;
let cc = 2.43;
let d = 0.59;
let e = 0.14;
return clamp((c * (c * a + b)) / (c * (c * cc + d) + e), vec3<f32>(0.0), vec3<f32>(1.0));
}

// ACES RRT+ODT fit with an explicit 6.0 white point. This is the MJP
// BakingLab fit used by Godot 4's ACES mode, including its 1.8 exposure bias
// and gamut transforms. Keep the compact Narkowicz fit above as mode 0 for
// output compatibility; mode 2 is the reference-quality/highlight-preserving
// path used by scenes that request the full transform.
fn aces_full_tone(c_in: vec3<f32>) -> vec3<f32> {
let exposure_bias = 1.8;
let a = 0.0245786;
let b = 0.000090537;
let cc = 0.983729;
let d = 0.432951;
let e = 0.238081;
let rgb_to_rrt = mat3x3<f32>(
vec3<f32>(0.59719, 0.35458, 0.04823) * exposure_bias,
vec3<f32>(0.07600, 0.90834, 0.01566) * exposure_bias,
vec3<f32>(0.02840, 0.13383, 0.83777) * exposure_bias,
);
let odt_to_rgb = mat3x3<f32>(
vec3<f32>( 1.60475, -0.53108, -0.07367),
vec3<f32>(-0.10208, 1.10813, -0.00605),
vec3<f32>(-0.00327, -0.07276, 1.07602),
);
// Godot's source uses row-vector multiplication (`color *= matrix`).
// Preserve that convention explicitly; swapping this order transposes the
// gamut transform and gives neutral surfaces a strong green cast.
let c = max(c_in, vec3<f32>(0.0)) * rgb_to_rrt;
let mapped = (c * (c + a) - b) / (c * (cc * c + d) + e);
let white = 6.0 * exposure_bias;
let white_mapped = (white * (white + a) - b)
/ (white * (cc * white + d) + e);
return clamp(mapped * odt_to_rgb / white_mapped,
vec3<f32>(0.0), vec3<f32>(1.0));
}

// --- AgX tonemap (Blender/Filament reference) ---
// Better hue preservation than ACES in saturated regions — reds
// stay red instead of shifting toward orange, blues stay blue
// instead of shifting toward cyan. Same sigmoid shape overall,
// so the overall contrast is similar.

fn agx_default_contrast_approx(x: vec3<f32>) -> vec3<f32> {
let x2 = x * x;
let x4 = x2 * x2;
return vec3<f32>(15.5) * x4 * x2
- vec3<f32>(40.14) * x4 * x
+ vec3<f32>(31.96) * x4
- vec3<f32>(6.868) * x2 * x
+ vec3<f32>(0.4298) * x2
+ vec3<f32>(0.1191) * x
- vec3<f32>(0.00232);
}

fn agx_tone(val_in: vec3<f32>) -> vec3<f32> {
// AgX input transform — compresses the input color gamut.
let agx_mat = mat3x3<f32>(
vec3<f32>(0.842479062253094, 0.0423282422610123, 0.0423756549057051),
vec3<f32>(0.0784335999999992, 0.878468636469772, 0.0784336),
vec3<f32>(0.0792237451477643, 0.0791661274605434, 0.879142973793104),
);
// Log2-space normalization range. Anything outside gets clamped
// — the sigmoid maps this window to [0, 1].
let min_ev = -12.47393;
let max_ev = 4.026069;

var val = agx_mat * val_in;
// Log2 encode, clamp to range, normalize to [0, 1].
val = max(val, vec3<f32>(1e-10));
val = clamp(log2(val), vec3<f32>(min_ev), vec3<f32>(max_ev));
val = (val - vec3<f32>(min_ev)) / (max_ev - min_ev);

// Sigmoid contrast curve.
val = agx_default_contrast_approx(val);
return val;
}

fn agx_eotf(val_in: vec3<f32>) -> vec3<f32> {
// AgX inverse input transform — re-expands back to target
// display gamut. The surface is sRGB-format so hardware
// applies the sRGB EOTF on write; we output linear here.
let agx_mat_inv = mat3x3<f32>(
vec3<f32>( 1.19687900512017, -0.0528968517574562, -0.0529716355144438),
vec3<f32>(-0.0980208811401368, 1.15190312990417, -0.0980434501171241),
vec3<f32>(-0.0990297440797205, -0.0989611768448433, 1.15107367264116),
);
return agx_mat_inv * val_in;
}

// Tonemap — branches on u.params.x (0 = compact ACES, 1 = AgX,
// 2 = full ACES RRT+ODT fit). Extracted
// so the sharpen pass can tonemap neighbour HDR samples through the
// same path as the center pixel.
// 2 = full ACES RRT+ODT fit; tonemap.wgsl). Extracted so the sharpen pass can
// tonemap neighbour HDR samples through the same path as the center pixel.
// AgX is read from its baked table (renderer/tonemap_lut.rs): the curve costs
// dozens of ALU ops and fifteen transcendentals, and this runs up to four times
// per pixel (centre, sharpen average, chromatic aberration R and B). On mobile
// GPUs that dominated the pass: the live state of two AgX evaluations cost a
// Pixel 4a 21 ms of a 25 ms composite at 1080x2340.
fn tonemap_select(hdr: vec3<f32>) -> vec3<f32> {
if (u.params.x < 0.5) {
return aces_tone(hdr);
}
if (u.params.x > 1.5) {
return aces_full_tone(hdr);
}
// Full Filament AgX pipeline:
// agx_tone (inset + log2 + sigmoid)
// agx_eotf (outset = inverse inset)
// pow(2.2) (display-encoded → linear, required for sRGB surface)
//
// Filament's polynomial-approximation AgX (what we use) systematically
// under-saturates vs Blender/Cycles' LUT-based AgX. Apply the 'Punchy'
// look — saturation 1.4, slight contrast — as a post step to bring
// colours closer to the Cycles ground-truth reference. Matches
// Filament::ToneMapper::AGX_PUNCHY.
let hdr_safe = max(hdr, vec3<f32>(0.0));
let agx_display = clamp(agx_eotf(agx_tone(hdr_safe)), vec3<f32>(0.0), vec3<f32>(1.0));
let linear = pow(agx_display, vec3<f32>(2.2));
// Punchy look: post-tonemap saturation + contrast in display space.
// slope/offset/power are per-channel lift/gamma/gain; saturation
// multiplies chroma around the pixel's luma.
let agx_punchy = agx_look_punchy(linear);
return agx_punchy;
}

fn agx_look_punchy(val: vec3<f32>) -> vec3<f32> {
// Per-channel slope (gain): subtle warm white-balance toward the
// 'golden hour' feel of Cycles-AgX renders — +3% red, -2% blue.
// The Bistro outdoor.hdr leans cool; without this, the shadows
// and IBL fill pull the overall image toward blue-grey vs.
// Cycles's warmer midtone neutral.
let slope = vec3<f32>(1.03, 1.00, 0.98);
let offset = vec3<f32>(0.0);
// A whisker above neutral — Cycles-AgX with its LUT-based DRT
// preserves enough chroma on its own that our polynomial fit only
// needs a gentle post-boost to catch up.
let power = vec3<f32>(1.1);
let saturation = 1.1;
// ASC-CDL-ish: (val * slope + offset) ^ power
let toned = pow(max(val * slope + offset, vec3<f32>(0.0)), power);
let luma = dot(toned, vec3<f32>(0.2126, 0.7152, 0.0722));
return vec3<f32>(luma) + saturation * (toned - vec3<f32>(luma));
return textureSampleLevel(tonemap_lut, hdr_samp, tonemap_lut_uvw(hdr), 0.0).rgb;
}

// Hash-based pseudo-random in [0, 1). Cheap noise function for grain;
Expand Down Expand Up @@ -2005,4 +1881,23 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {

return vec4<f32>(ldr, 1.0);
}
";
",
);

/// Bakes agx_curve() into the AgX table (renderer/tonemap_lut.rs): one texel
/// per HDR colour on the table's grid.
pub(in crate::renderer) const TONEMAP_LUT_BAKE_WGSL: &str = concat!(
include_str!("tonemap_lut.wgsl"),
include_str!("tonemap.wgsl"),
"
@group(0) @binding(0) var lut: texture_storage_3d<rgba16float, write>;

@compute @workgroup_size(4, 4, 4)
fn bake(@builtin(global_invocation_id) id: vec3<u32>) {
if (any(id >= vec3<u32>(u32(TONEMAP_LUT_SIZE)))) {
return;
}
textureStore(lut, id, vec4<f32>(agx_curve(tonemap_lut_hdr(id)), 1.0));
}
",
);
Loading
Loading