MONKEY (polish): crossfade colour grading across zone borders
The grade snapped to the new zone's LUT pair on the frame `CurrentArea` changed. `GradeBlend` now holds the current and previous pairs and fades between them over GRADE_FADE_S = 2 s: the view binds a second day/night pair (bindings 6, 7; identity when there is none) and `control.z` is the current pair's weight, `control.w` the previous pair's strength. The shader runs the previous pair through the same day/night blend at its own strength and mixes, only while z < 1. A zone with no row fades to identity (strength 0) instead of cutting; stepping back across the border mid-fade reverses the fade from the image on screen. The first graded zone after login, and a disabled grade, snap. Missing LUTs still bind the identity cube. Verified: cargo test -p benilla-app --lib post:: (4 passed, including the new zone_change_crossfades_and_a_quick_return_reverses); grading.wgsl composes and validates under naga_oil 0.20 / naga 27 offline. No GPU or WoW data here; built on Linux only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6yUWgPosSakvL23gtgvpH
This commit is contained in:
@@ -43,10 +43,66 @@ struct GradeCatalog {
|
||||
struct GradeView {
|
||||
day: Handle<Image>,
|
||||
night: Handle<Image>,
|
||||
// x = authored strength, y = night blend.
|
||||
/// MONKEY (polish): the pair being faded out (identity once the fade is done).
|
||||
prev_day: Handle<Image>,
|
||||
prev_night: Handle<Image>,
|
||||
// x = authored strength, y = night blend, z = crossfade weight of the current pair (1 = done),
|
||||
// w = the previous pair's strength.
|
||||
control: Vec4,
|
||||
}
|
||||
|
||||
/// MONKEY (polish): the zone crossfade's length; a border crossing no longer snaps the grade.
|
||||
const GRADE_FADE_S: f32 = 2.0;
|
||||
|
||||
/// One zone's LUT pair at its authored strength; a zone with no row grades at strength 0.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
struct GradePair {
|
||||
day: Handle<Image>,
|
||||
night: Handle<Image>,
|
||||
strength: f32,
|
||||
}
|
||||
|
||||
/// MONKEY (polish): the crossfade from the previous zone's pair to the current one.
|
||||
#[derive(Resource, Default)]
|
||||
struct GradeBlend {
|
||||
current: Option<GradePair>,
|
||||
previous: Option<GradePair>,
|
||||
/// The current pair's weight, 0..1.
|
||||
fade: f32,
|
||||
primed: bool,
|
||||
}
|
||||
|
||||
impl GradeBlend {
|
||||
/// Steps toward `target`. The first graded zone after login (or re-enabling) and a disabled
|
||||
/// grade snap; a return to the
|
||||
/// pair still fading out reverses the fade instead of restarting it.
|
||||
fn step(&mut self, target: Option<GradePair>, dt: f32, snap: bool) {
|
||||
if snap || !self.primed {
|
||||
*self = Self {
|
||||
primed: !snap && target.is_some(),
|
||||
current: target,
|
||||
previous: None,
|
||||
fade: 1.0,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if target != self.current {
|
||||
if self.fade < 1.0 && target == self.previous {
|
||||
std::mem::swap(&mut self.current, &mut self.previous);
|
||||
self.fade = 1.0 - self.fade;
|
||||
} else {
|
||||
self.previous = self.current.take();
|
||||
self.current = target;
|
||||
self.fade = 0.0;
|
||||
}
|
||||
}
|
||||
self.fade = (self.fade + dt / GRADE_FADE_S).min(1.0);
|
||||
if self.fade >= 1.0 {
|
||||
self.previous = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, ShaderType)]
|
||||
struct GradeUniform {
|
||||
control: Vec4,
|
||||
@@ -55,6 +111,7 @@ struct GradeUniform {
|
||||
impl Plugin for GradingPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins(ExtractComponentPlugin::<GradeView>::default())
|
||||
.init_resource::<GradeBlend>()
|
||||
.add_systems(Last, update_views);
|
||||
if !app.is_plugin_added::<AssetPlugin>() {
|
||||
return;
|
||||
@@ -219,6 +276,7 @@ fn volume_image(cube: Vec<u8>) -> Image {
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn update_views(
|
||||
mut commands: Commands,
|
||||
video: Res<VideoConfig>,
|
||||
@@ -226,6 +284,8 @@ fn update_views(
|
||||
area: Res<CurrentArea>,
|
||||
areas: Option<Res<crate::area::AreaTableRes>>,
|
||||
time: Res<WorldTime>,
|
||||
clock: Res<Time>,
|
||||
mut blend: ResMut<GradeBlend>,
|
||||
cameras: Query<(Entity, &Camera, Option<&GradeView>), With<WorldCamera>>,
|
||||
) {
|
||||
let selected = catalog.as_ref().and_then(|catalog| {
|
||||
@@ -238,7 +298,7 @@ fn update_views(
|
||||
.rows
|
||||
.for_area(leaf)
|
||||
.or_else(|| catalog.rows.for_area(zone))?;
|
||||
(row.strength > 0.0).then(|| GradeView {
|
||||
(row.strength > 0.0).then(|| GradePair {
|
||||
day: catalog
|
||||
.luts
|
||||
.get(&row.day_lut)
|
||||
@@ -249,7 +309,33 @@ fn update_views(
|
||||
.get(&row.night_lut)
|
||||
.unwrap_or(&catalog.identity)
|
||||
.clone(),
|
||||
control: Vec4::new(row.strength, night_weight(time.minute_f), 0.0, 0.0),
|
||||
strength: row.strength,
|
||||
})
|
||||
});
|
||||
blend.step(selected, clock.delta_secs(), !video.color_grading);
|
||||
let selected = catalog.as_ref().and_then(|catalog| {
|
||||
if blend.current.is_none() && blend.previous.is_none() {
|
||||
return None;
|
||||
}
|
||||
let pair = |p: &Option<GradePair>| {
|
||||
p.as_ref().map_or_else(
|
||||
|| (catalog.identity.clone(), catalog.identity.clone(), 0.0),
|
||||
|p| (p.day.clone(), p.night.clone(), p.strength),
|
||||
)
|
||||
};
|
||||
let (day, night, strength) = pair(&blend.current);
|
||||
let (prev_day, prev_night, prev_strength) = pair(&blend.previous);
|
||||
Some(GradeView {
|
||||
day,
|
||||
night,
|
||||
prev_day,
|
||||
prev_night,
|
||||
control: Vec4::new(
|
||||
strength,
|
||||
night_weight(time.minute_f),
|
||||
blend.fade,
|
||||
prev_strength,
|
||||
),
|
||||
})
|
||||
});
|
||||
for (entity, camera, old) in &cameras {
|
||||
@@ -311,6 +397,9 @@ fn init_pipeline(
|
||||
texture_3d(TextureSampleType::Float { filterable: true }),
|
||||
sampler(SamplerBindingType::Filtering),
|
||||
uniform_buffer::<GradeUniform>(false),
|
||||
// MONKEY (polish): the previous zone's pair, crossfaded out.
|
||||
texture_3d(TextureSampleType::Float { filterable: true }),
|
||||
texture_3d(TextureSampleType::Float { filterable: true }),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -397,7 +486,12 @@ impl ViewNode for GradeNode {
|
||||
return Ok(());
|
||||
};
|
||||
let images = world.resource::<RenderAssets<GpuImage>>();
|
||||
let (Some(day), Some(night)) = (images.get(&grade.day), images.get(&grade.night)) else {
|
||||
let (Some(day), Some(night), Some(prev_day), Some(prev_night)) = (
|
||||
images.get(&grade.day),
|
||||
images.get(&grade.night),
|
||||
images.get(&grade.prev_day),
|
||||
images.get(&grade.prev_night),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
let device = context.render_device();
|
||||
@@ -412,6 +506,8 @@ impl ViewNode for GradeNode {
|
||||
&night.texture_view,
|
||||
&settings.lut_sampler,
|
||||
uniform.0.as_entire_binding(),
|
||||
&prev_day.texture_view,
|
||||
&prev_night.texture_view,
|
||||
)),
|
||||
);
|
||||
let diagnostics = context.diagnostic_recorder();
|
||||
@@ -452,6 +548,44 @@ mod tests {
|
||||
assert_eq!(at(0, 0, 31), &[0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_change_crossfades_and_a_quick_return_reverses() {
|
||||
let pair = |id: u128, strength| GradePair {
|
||||
day: Handle::Uuid(bevy::asset::uuid::Uuid::from_u128(id), default()),
|
||||
night: Handle::Uuid(bevy::asset::uuid::Uuid::from_u128(id + 1), default()),
|
||||
strength,
|
||||
};
|
||||
let (a, b) = (pair(10, 1.0), pair(20, 0.5));
|
||||
let mut blend = GradeBlend::default();
|
||||
// No zone yet, then the first graded zone snaps in.
|
||||
blend.step(None, 0.016, false);
|
||||
blend.step(Some(a.clone()), 0.016, false);
|
||||
assert_eq!((blend.current.clone(), blend.fade), (Some(a.clone()), 1.0));
|
||||
// A border crossing fades over GRADE_FADE_S.
|
||||
blend.step(Some(b.clone()), 0.5, false);
|
||||
assert_eq!(blend.previous, Some(a.clone()));
|
||||
assert!((blend.fade - 0.25).abs() < 1e-6);
|
||||
// Stepping back mid-fade reverses from the same image.
|
||||
blend.step(Some(a.clone()), 0.0, false);
|
||||
assert_eq!(
|
||||
(blend.current.clone(), blend.previous.clone()),
|
||||
(Some(a.clone()), Some(b))
|
||||
);
|
||||
assert!((blend.fade - 0.75).abs() < 1e-6);
|
||||
blend.step(Some(a.clone()), 1.0, false);
|
||||
assert_eq!((blend.fade, blend.previous.is_none()), (1.0, true));
|
||||
// An ungraded zone fades out to identity rather than cutting.
|
||||
blend.step(None, 1.0, false);
|
||||
assert_eq!(
|
||||
(blend.current.is_none(), blend.previous.clone()),
|
||||
(true, Some(a))
|
||||
);
|
||||
assert!((blend.fade - 0.5).abs() < 1e-6);
|
||||
// Disabling snaps.
|
||||
blend.step(None, 0.0, true);
|
||||
assert!(blend.previous.is_none() && blend.current.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_night_blend_is_continuous() {
|
||||
assert_eq!(night_weight(0.0), 1.0);
|
||||
|
||||
@@ -15,6 +15,9 @@ struct Grade {
|
||||
control: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(5) var<uniform> grade: Grade;
|
||||
// MONKEY (polish): the previous zone's pair, crossfaded out over `grade.control.z`.
|
||||
@group(0) @binding(6) var prev_day_lut: texture_3d<f32>;
|
||||
@group(0) @binding(7) var prev_night_lut: texture_3d<f32>;
|
||||
|
||||
fn grade_one(lut: texture_3d<f32>, color: vec3<f32>) -> vec3<f32> {
|
||||
// Half a texel in from each face, with 31 intervals across the 32-texel cube.
|
||||
@@ -29,7 +32,16 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
let day = grade_one(day_lut, base);
|
||||
let night = grade_one(night_lut, base);
|
||||
let authored = mix(day, night, grade.control.y);
|
||||
let graded = mix(base, authored, grade.control.x);
|
||||
var graded = mix(base, authored, grade.control.x);
|
||||
// MONKEY (polish): mid-crossfade, the previous zone's pair through the same day/night blend
|
||||
// at its own strength; a missing zone or LUT is the identity cube, so it fades to ungraded.
|
||||
if (grade.control.z < 1.0) {
|
||||
let prev_day = grade_one(prev_day_lut, base);
|
||||
let prev_night = grade_one(prev_night_lut, base);
|
||||
let prev_authored = mix(prev_day, prev_night, grade.control.y);
|
||||
let prev_graded = mix(base, prev_authored, grade.control.w);
|
||||
graded = mix(prev_graded, graded, grade.control.z);
|
||||
}
|
||||
// Bloom already consumed the HDR signal; preserve its remaining excess for the FFX clamp.
|
||||
return vec4(graded + max(source.rgb - vec3(1.0), vec3(0.0)), source.a);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user