struct Settings {
exposure: f32,
bloom: f32,
curve: u32,
}
const NEUTRAL: u32 = 0u;
const ACES: u32 = 1u;
// The fraction of the larger mip an upsample keeps; the rest is the
// smaller one spread over it, so the chain neither grows nor loses light.
const SPREAD: f32 = 0.5;
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;
@group(1) @binding(1) var<uniform> settings: Settings;
@group(2) @binding(0) var scattered: texture_2d<f32>;
struct Fragment {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
// One triangle large enough to cover the target, so no vertex buffer is bound.
@vertex
fn fullscreen(@builtin(vertex_index) index: u32) -> Fragment {
let corner = vec2<f32>(f32((index << 1u) & 2u), f32(index & 2u));
var fragment: Fragment;
fragment.position = vec4<f32>(corner * 2.0 - 1.0, 0.0, 1.0);
fragment.uv = vec2<f32>(corner.x, 1.0 - corner.y);
return fragment;
}
fn tap(uv: vec2<f32>, offset: vec2<f32>, texel: vec2<f32>) -> vec3<f32> {
return textureSample(source, source_sampler, uv + offset * texel).rgb;
}
// Thirteen taps in four overlapping squares, scaled to sum to one.
@fragment
fn downsample(fragment: Fragment) -> @location(0) vec4<f32> {
let texel = 1.0 / vec2<f32>(textureDimensions(source));
let uv = fragment.uv;
let corners = tap(uv, vec2(-2.0, -2.0), texel)
+ tap(uv, vec2(2.0, -2.0), texel)
+ tap(uv, vec2(-2.0, 2.0), texel)
+ tap(uv, vec2(2.0, 2.0), texel);
let sides = tap(uv, vec2(0.0, -2.0), texel)
+ tap(uv, vec2(-2.0, 0.0), texel)
+ tap(uv, vec2(2.0, 0.0), texel)
+ tap(uv, vec2(0.0, 2.0), texel);
let inner = tap(uv, vec2(-1.0, -1.0), texel)
+ tap(uv, vec2(1.0, -1.0), texel)
+ tap(uv, vec2(-1.0, 1.0), texel)
+ tap(uv, vec2(1.0, 1.0), texel);
let color = tap(uv, vec2(0.0, 0.0), texel) * 0.125
+ corners * 0.03125
+ sides * 0.0625
+ inner * 0.125;
return vec4<f32>(color, 1.0);
}
// Nine taps in a tent, blended over the larger mip by SPREAD.
@fragment
fn upsample(fragment: Fragment) -> @location(0) vec4<f32> {
let texel = 1.0 / vec2<f32>(textureDimensions(source));
let uv = fragment.uv;
let corners = tap(uv, vec2(-1.0, -1.0), texel)
+ tap(uv, vec2(1.0, -1.0), texel)
+ tap(uv, vec2(-1.0, 1.0), texel)
+ tap(uv, vec2(1.0, 1.0), texel);
let sides = tap(uv, vec2(0.0, -1.0), texel)
+ tap(uv, vec2(-1.0, 0.0), texel)
+ tap(uv, vec2(1.0, 0.0), texel)
+ tap(uv, vec2(0.0, 1.0), texel);
let color = (tap(uv, vec2(0.0, 0.0), texel) * 4.0 + sides * 2.0 + corners) / 16.0;
return vec4<f32>(color, SPREAD);
}
// The Khronos PBR neutral curve: it bounds the peak channel and leaves
// the ratios between channels alone until it has to desaturate.
fn neutral(color: vec3<f32>) -> vec3<f32> {
let start = 0.8 - 0.04;
let desaturation = 0.15;
let darkest = min(color.r, min(color.g, color.b));
var offset = 0.04;
if darkest < 0.08 {
offset = darkest - 6.25 * darkest * darkest;
}
var lifted = color - offset;
let peak = max(lifted.r, max(lifted.g, lifted.b));
if peak < start {
return lifted;
}
let span = 1.0 - start;
let compressed = 1.0 - span * span / (peak + span - start);
lifted *= compressed / peak;
let wash = 1.0 - 1.0 / (desaturation * (peak - compressed) + 1.0);
return mix(lifted, vec3<f32>(compressed), wash);
}
// The Narkowicz fit of the ACES filmic curve.
fn aces(color: vec3<f32>) -> vec3<f32> {
let mapped = (color * (2.51 * color + 0.03)) / (color * (2.43 * color + 0.59) + 0.14);
return clamp(mapped, vec3<f32>(0.0), vec3<f32>(1.0));
}
@fragment
fn tonemap(fragment: Fragment) -> @location(0) vec4<f32> {
var color = textureSample(source, source_sampler, fragment.uv).rgb;
if settings.bloom > 0.0 {
let bloom = textureSample(scattered, source_sampler, fragment.uv).rgb;
color = mix(color, bloom, settings.bloom);
}
color *= settings.exposure;
switch settings.curve {
case NEUTRAL: {
color = neutral(color);
}
case ACES: {
color = aces(color);
}
default: {
color = clamp(color, vec3<f32>(0.0), vec3<f32>(1.0));
}
}
return vec4<f32>(color, 1.0);
}