diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md new file mode 100644 index 00000000..5550fdcc --- /dev/null +++ b/THIRD-PARTY.md @@ -0,0 +1,8 @@ +# Third-party notices + +## WarcraftXL colour grading + +The colour-grading pass in `crates/benilla-app/src/post/grading.rs` and `grading.wgsl` is ported +from WarcraftXL's `wxl-retail-grading` implementation, Copyright (C) 2026 WarcraftXL. The source +implementation is licensed under GPL-3.0-or-later. The port preserves its 32³ LUT convention, +strength control, and strip lookup mathematics while uploading the strip as a native 3D texture. diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index e8b7b0fa..de717b2d 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -497,6 +497,7 @@ -- MONKEY (post): HDR excess + halo quality. bloom = "Bloom", sunShafts = "Sun Shafts", + colorGrading = "Colour Grading", fogOff = "Off", waterQuality = "Water Quality", waterClassic = "Classic", @@ -540,6 +541,8 @@ "Makes fire, lava, spells and lit windows carry a soft halo. Low uses a smaller blur chain; High is smoother. Off leaves the original image unchanged.", SUN_SHAFTS = "Draws sunlight through gaps in trees and buildings. Scene depth blocks the rays; they fade at night, off-screen and indoors.", + COLOR_GRADING = + "Applies a subtle zone-specific colour palette to the 3D world and blends between its day and night looks. The interface is never graded.", VOLUMETRIC_FOG = "Off disables volumetric fog. Low adds nearby haze and sun or moon shafts with fewer steps; High uses more steps for smoother shafts. Mist is heavier at dawn and in bad weather, and subtle indoors. Shafts require shadows; with shadows off only haze remains.", WATER_QUALITY = @@ -627,6 +630,7 @@ -- MONKEY (post): Russian HDR excess + halo quality row. bloom = "Свечение", sunShafts = "Солнечные лучи", + colorGrading = "Цветокоррекция", -- MONKEY (volumetric fog): Russian atmospheric quality row. volumetricFog = "Объёмный туман", fogOff = "Выкл", @@ -673,6 +677,8 @@ "Добавляет мягкое сияние огню, лаве, заклинаниям и освещённым окнам. Низкое качество использует короткое размытие, высокое — более плавное. Выключение сохраняет исходное изображение.", SUN_SHAFTS = "Показывает солнечные лучи сквозь деревья и здания. Рельеф и здания перекрывают свет; ночью и в помещениях эффект исчезает.", + COLOR_GRADING = + "Применяет мягкую цветовую палитру зоны и плавно меняет дневной вариант на ночной. Интерфейс не изменяется.", -- MONKEY (volumetric fog): localised effect and cost explanation. VOLUMETRIC_FOG = "Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.", @@ -811,7 +817,7 @@ -- three of eighteen shadow settings and no hint that the other fifteen existed. AdvancedGraphics = { -- MONKEY (volumetric fog): include in refresh, defaults and search. - "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", "RowBloom", "RowSunShafts", + "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", "RowBloom", "RowSunShafts", "RowColorGrading", "RowCharacterShadows", "RowWorldShadows", "RowShadowDistance", "RowShadowResolution", "RowMoonShadows", "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft", @@ -2645,11 +2651,22 @@ "BENILLA_TOOLTIP_SUN_SHAFTS") + + + + + + + + OptionsRow_OnLoad(self, "colorGrading", BENILLA_ADVGFX.colorGrading, + "BENILLA_TOOLTIP_COLOR_GRADING") + + - - + + OptionsRow_OnLoad(self, "characterShadows", diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index d970a9fd..ccc57ff2 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -1379,6 +1379,40 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 0, ui: None, }, + // MONKEY (post): lane-specific A/B and perf viewpoints. Kept at the end for merge isolation. + Scenario { + name: "post-lava-searing", + map: Some(MAP_AZEROTH), + eye: LAVA_RIVER_EYE, + look: LAVA_RIVER_LOOK, + minute: 0, + ui: None, + }, + Scenario { + name: "post-stormwind-night", + map: Some(MAP_AZEROTH), + eye: [-8833.38, 628.63, 96.0], + look: [-8809.1, 672.3, 94.0], + minute: 0, + ui: None, + }, + Scenario { + name: "post-sunshafts-noon", + map: Some(MAP_AZEROTH), + eye: WATER_EYE, + // Noon's 85-degree sun above the Goldshire trees (azimuth 45 degrees). + look: [-9508.5, -292.1, 369.7], + minute: 720, + ui: None, + }, + Scenario { + name: "post-duskwood-grade", + map: Some(MAP_AZEROTH), + eye: [-10580.0, -1200.0, 45.0], + look: [-10540.0, -1160.0, 30.0], + minute: 1260, + ui: None, + }, ]; /// The owner's reported vantage for the lava-glow report: `(-7048.8, -1000.6, 242.0)` facing diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 2a4f80b5..7d7c5a25 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -924,6 +924,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // MONKEY (post): tier 0 leaves every emissive site and the frame byte-identical. ours("bloom", "2", "benilla's own: HDR emissive bloom, 0 Off / 1 Low / 2 High"), ours("sunShafts", "1", "benilla's own: depth-occluded screen-space sun shafts"), + ours("colorGrading", "1", "benilla's own: zone and day/night 32-cube colour grade"), ours( "waterQuality", "1", @@ -2014,6 +2015,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ // MONKEY (post): no HDR lift or post pass is the byte-identical baseline. ("bloom", "0"), ("sunShafts", "0"), + ("colorGrading", "0"), ("lavaLightGain", "0"), ("fireLightGain", "0"), ("nightGain", "1.0"), @@ -2040,6 +2042,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ // MONKEY (post): quarter-resolution halo. ("bloom", "1"), ("sunShafts", "0"), + ("colorGrading", "0"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2069,6 +2072,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ // MONKEY (post): quarter-resolution halo. ("bloom", "1"), ("sunShafts", "0"), + ("colorGrading", "0"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2099,6 +2103,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ // MONKEY (post): half-resolution halo. ("bloom", "2"), ("sunShafts", "1"), + ("colorGrading", "1"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2913,13 +2918,14 @@ mod tests { let shadows = VideoConfig::default(); let flag = |b: bool| if b { 1.0 } else { 0.0 }; // MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table. - let lighting: [(&str, f32); 35] = [ + let lighting: [(&str, f32); 36] = [ ("waterQuality", shadows.water_quality as f32), // MONKEY (volumetric fog): weld registry and renderer defaults. ("volumetricFog", shadows.volumetric_fog as f32), // MONKEY (post): weld registry and renderer defaults. ("bloom", shadows.bloom as f32), ("sunShafts", flag(shadows.sun_shafts)), + ("colorGrading", flag(shadows.color_grading)), ("lavaLightGain", shadows.lava_light_gain), // The two sun lanes and the cascade they share. ("worldShadows", flag(shadows.world_shadows)), @@ -2972,10 +2978,10 @@ mod tests { assert_eq!(d[name], want, "{name}: registered default left the knob"); } // …and the census IS the row set. A name here that nothing registers would weld against a - // row the client does not have; the length is the other half — 35 rows, 35 welds. + // row the client does not have; the length is the other half — 36 rows, 36 welds. let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect(); // MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld. - assert_eq!(welded.len(), 35, "the lighting lane welds 35 distinct rows"); + assert_eq!(welded.len(), 36, "the lighting lane welds 36 distinct rows"); for name in &welded { assert!( REGISTERED.iter().any(|r| r.name == *name), @@ -3154,6 +3160,7 @@ mod tests { // MONKEY (post): the post lane's live tier. "bloom" => video.bloom as f32, "sunShafts" => video.sun_shafts as u32 as f32, + "colorGrading" => video.color_grading as u32 as f32, "lavaLightGain" => video.lava_light_gain, "fireLightGain" => video.fire_light_gain, "moonShadowStrength" => video.moon_shadow_strength, diff --git a/crates/benilla-app/src/post/grading.rs b/crates/benilla-app/src/post/grading.rs new file mode 100644 index 00000000..5a4dc42a --- /dev/null +++ b/crates/benilla-app/src/post/grading.rs @@ -0,0 +1,432 @@ +//! MONKEY (post): zone/day-night colour grading through a real 32³ GPU texture. +//! +//! Ported from WarcraftXL's `wxl-retail-grading` (Copyright (C) 2026 WarcraftXL, GPL-3.0-or-later). +//! Its 1024×32 BLP strip/two-bilinear-tap cube lookup becomes the equivalent hardware-trilinear +//! lookup after upload to a 3D texture. See `THIRD-PARTY.md`. + +use super::bloom::BloomLabel; +use crate::video::VideoConfig; +use benilla_assets::{AssetSet, LockRecover, WorldAssets}; +use benilla_world::{lighting::WorldTime, terrain_stream::CurrentArea, view::WorldCamera}; +use bevy::{ + asset::RenderAssetUsages, + core_pipeline::{core_3d::graph::Core3d, FullscreenShader}, + ecs::query::QueryItem, + image::Image, + prelude::*, + render::{ + diagnostic::RecordDiagnostics, + extract_component::{ExtractComponent, ExtractComponentPlugin}, + render_asset::RenderAssets, + render_graph::{ + NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, + }, + render_resource::{binding_types::*, *}, + renderer::{RenderContext, RenderDevice, RenderQueue}, + texture::GpuImage, + view::ViewTarget, + RenderApp, RenderStartup, + }, +}; +use std::collections::{HashMap, HashSet}; + +pub(super) struct GradingPlugin; + +#[derive(Resource)] +struct GradeCatalog { + rows: benilla_formats::MonkeyZoneGrades, + luts: HashMap>, + identity: Handle, +} + +#[derive(Component, Clone, PartialEq, ExtractComponent)] +struct GradeView { + day: Handle, + night: Handle, + // x = authored strength, y = night blend. + control: Vec4, +} + +#[derive(Clone, Copy, ShaderType)] +struct GradeUniform { + control: Vec4, +} + +impl Plugin for GradingPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(ExtractComponentPlugin::::default()) + .add_systems(Last, update_views); + if !app.is_plugin_added::() { + return; + } + app.add_systems(Startup, load_catalog.after(AssetSet::Open)); + let shader = app + .world_mut() + .resource_mut::>() + .add(Shader::from_wgsl( + include_str!("grading.wgsl"), + "post/grading.wgsl", + )); + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .insert_resource(GradeShader(shader)) + .add_systems(RenderStartup, init_pipeline) + .add_render_graph_node::>(Core3d, GradeLabel) + .add_render_graph_edges( + Core3d, + ( + BloomLabel, + GradeLabel, + benilla_world::ffx_glow::FfxGlowLabel, + ), + ); + } +} + +fn load_catalog( + mut commands: Commands, + assets: Option>, + mut images: ResMut>, +) { + let identity = images.add(volume_image(identity_cube())); + let Some(assets) = assets else { + commands.insert_resource(GradeCatalog { + rows: default(), + luts: default(), + identity, + }); + return; + }; + let mut chain = assets.chain.lock_recover(); + let loose_root = benilla_formats::wow_data(); + let loaded_rows = loose_root + .as_ref() + .map(|root| root.join("DBFilesClient").join("MonkeyZoneGrade.dbc")) + .filter(|path| path.is_file()) + .map(|path| { + std::fs::read(path) + .map_err(anyhow::Error::from) + .and_then(|bytes| benilla_formats::parse_monkey_zone_grades(&bytes)) + }) + .unwrap_or_else(|| benilla_formats::load_monkey_zone_grades(&mut chain)); + let rows = match loaded_rows { + Ok(rows) => rows, + Err(error) => { + info!("post grading: no MonkeyZoneGrade.dbc; identity fallback ({error:#})"); + commands.insert_resource(GradeCatalog { + rows: default(), + luts: default(), + identity, + }); + return; + } + }; + let paths: HashSet = rows + .rows() + .flat_map(|row| [row.day_lut.clone(), row.night_lut.clone()]) + .collect(); + let mut luts = HashMap::new(); + for path in paths { + let loose = loose_root + .as_ref() + .map(|root| root.join(path.replace('\\', "/"))) + .filter(|path| path.is_file()) + .map(std::fs::read) + .transpose() + .map_err(anyhow::Error::from); + let result = loose + .and_then(|bytes| match bytes { + Some(bytes) => Ok(bytes), + None => chain.read_file(&path.replace('/', "\\")), + }) + .and_then(|bytes| benilla_formats::blp_bytes_to_mip_chain(&bytes)); + match result.and_then(|blp| strip_to_cube(&blp)) { + Ok(cube) => { + luts.insert(path, images.add(volume_image(cube))); + } + Err(error) => warn!("post grading: {path} rejected; identity fallback: {error:#}"), + } + } + info!( + "post grading: {} area rows, {} resident LUTs", + rows.rows().count(), + luts.len() + ); + commands.insert_resource(GradeCatalog { + rows, + luts, + identity, + }); +} + +fn strip_to_cube(blp: &benilla_formats::BlpMipChain) -> anyhow::Result> { + anyhow::ensure!( + blp.width == 1024 && blp.height == 32, + "expected 1024x32, got {}x{}", + blp.width, + blp.height + ); + anyhow::ensure!(!blp.mips.is_empty(), "BLP has no base mip"); + let strip = &blp.mips[0]; + anyhow::ensure!( + strip.len() == 1024 * 32 * 4, + "base mip has {} bytes", + strip.len() + ); + let mut cube = vec![0; 32 * 32 * 32 * 4]; + for b in 0..32usize { + for g in 0..32usize { + for r in 0..32usize { + let source = (g * 1024 + b * 32 + r) * 4; + let destination = ((b * 32 + g) * 32 + r) * 4; + cube[destination..destination + 4].copy_from_slice(&strip[source..source + 4]); + } + } + } + Ok(cube) +} + +fn identity_cube() -> Vec { + let mut cube = Vec::with_capacity(32 * 32 * 32 * 4); + for b in 0..32u32 { + for g in 0..32u32 { + for r in 0..32u32 { + cube.extend([r, g, b].map(|v| ((v * 255 + 15) / 31) as u8)); + cube.push(255); + } + } + } + cube +} + +fn volume_image(cube: Vec) -> Image { + Image::new( + Extent3d { + width: 32, + height: 32, + depth_or_array_layers: 32, + }, + TextureDimension::D3, + cube, + TextureFormat::Rgba8Unorm, + RenderAssetUsages::RENDER_WORLD, + ) +} + +fn update_views( + mut commands: Commands, + video: Res, + catalog: Option>, + area: Res, + areas: Option>, + time: Res, + cameras: Query<(Entity, &Camera, Option<&GradeView>), With>, +) { + let selected = catalog.as_ref().and_then(|catalog| { + let leaf = area.0?; + let zone = areas + .as_ref() + .and_then(|areas| areas.0.top_zone(leaf)) + .unwrap_or(leaf); + let row = catalog + .rows + .for_area(leaf) + .or_else(|| catalog.rows.for_area(zone))?; + (row.strength > 0.0).then(|| GradeView { + day: catalog + .luts + .get(&row.day_lut) + .unwrap_or(&catalog.identity) + .clone(), + night: catalog + .luts + .get(&row.night_lut) + .unwrap_or(&catalog.identity) + .clone(), + control: Vec4::new(row.strength, night_weight(time.minute_f), 0.0, 0.0), + }) + }); + for (entity, camera, old) in &cameras { + let next = (video.color_grading && camera.is_active) + .then(|| selected.clone()) + .flatten(); + match next { + Some(next) if old != Some(&next) => { + commands.entity(entity).insert(next); + } + None if old.is_some() => { + commands.entity(entity).remove::(); + } + _ => {} + } + } +} + +fn night_weight(minute: f32) -> f32 { + let dawn = smoothstep(330.0, 450.0, minute); + let dusk = smoothstep(1110.0, 1230.0, minute); + 1.0 - dawn * (1.0 - dusk) +} + +fn smoothstep(a: f32, b: f32, value: f32) -> f32 { + let t = ((value - a) / (b - a)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +#[derive(Resource)] +struct GradeShader(Handle); + +#[derive(Resource)] +struct GradePipeline { + layout: BindGroupLayoutDescriptor, + scene_sampler: Sampler, + lut_sampler: Sampler, + pipeline: CachedRenderPipelineId, +} + +#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] +struct GradeLabel; + +fn init_pipeline( + mut commands: Commands, + device: Res, + cache: Res, + shader: Res, + fullscreen: Res, +) { + let layout = BindGroupLayoutDescriptor::new( + "post_grading_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + texture_3d(TextureSampleType::Float { filterable: true }), + texture_3d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + uniform_buffer::(false), + ), + ), + ); + let pipeline = cache.queue_render_pipeline(RenderPipelineDescriptor { + label: Some("post_grading".into()), + layout: vec![layout.clone()], + vertex: fullscreen.to_vertex_state(), + fragment: Some(FragmentState { + shader: shader.0.clone(), + shader_defs: vec![], + entry_point: Some("fragment".into()), + targets: vec![Some(ColorTargetState { + format: ViewTarget::TEXTURE_FORMAT_HDR, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + ..default() + }); + let sampler = |filter| { + device.create_sampler(&SamplerDescriptor { + min_filter: filter, + mag_filter: filter, + address_mode_u: AddressMode::ClampToEdge, + address_mode_v: AddressMode::ClampToEdge, + address_mode_w: AddressMode::ClampToEdge, + ..default() + }) + }; + commands.insert_resource(GradePipeline { + layout, + scene_sampler: sampler(FilterMode::Nearest), + lut_sampler: sampler(FilterMode::Linear), + pipeline, + }); +} + +#[derive(Default)] +struct GradeNode; + +impl ViewNode for GradeNode { + type ViewQuery = (&'static ViewTarget, &'static GradeView); + + fn run<'w>( + &self, + _graph: &mut RenderGraphContext, + context: &mut RenderContext<'w>, + (target, grade): QueryItem<'w, '_, Self::ViewQuery>, + world: &'w World, + ) -> Result<(), NodeRunError> { + let settings = world.resource::(); + let cache = world.resource::(); + let Some(pipeline) = cache.get_render_pipeline(settings.pipeline) else { + return Ok(()); + }; + let images = world.resource::>(); + let (Some(day), Some(night)) = (images.get(&grade.day), images.get(&grade.night)) else { + return Ok(()); + }; + let device = context.render_device(); + let mut uniform = UniformBuffer::from(GradeUniform { + control: grade.control, + }); + uniform.write_buffer(device, world.resource::()); + let out = target.post_process_write(); + let bind = device.create_bind_group( + "post_grading", + &cache.get_bind_group_layout(&settings.layout), + &BindGroupEntries::sequential(( + out.source, + &settings.scene_sampler, + &day.texture_view, + &night.texture_view, + &settings.lut_sampler, + uniform.binding().unwrap(), + )), + ); + let diagnostics = context.diagnostic_recorder(); + let mut pass = context + .command_encoder() + .begin_render_pass(&RenderPassDescriptor { + label: Some("post_grading"), + color_attachments: &[Some(RenderPassColorAttachment { + view: out.destination, + depth_slice: None, + resolve_target: None, + ops: Operations::default(), + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + let span = diagnostics.pass_span(&mut pass, "post_grading"); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &bind, &[]); + pass.draw(0..3, 0..1); + span.end(&mut pass); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_volume_has_exact_axes() { + let cube = identity_cube(); + let at = |r: usize, g: usize, b: usize| &cube[((b * 32 + g) * 32 + r) * 4..][..4]; + assert_eq!(at(0, 0, 0), &[0, 0, 0, 255]); + assert_eq!(at(31, 0, 0), &[255, 0, 0, 255]); + assert_eq!(at(0, 31, 0), &[0, 255, 0, 255]); + assert_eq!(at(0, 0, 31), &[0, 0, 255, 255]); + } + + #[test] + fn day_night_blend_is_continuous() { + assert_eq!(night_weight(0.0), 1.0); + assert_eq!(night_weight(720.0), 0.0); + assert_eq!(night_weight(1439.0), 1.0); + assert!((night_weight(390.0) - 0.5).abs() < 1.0e-6); + } +} diff --git a/crates/benilla-app/src/post/grading.wgsl b/crates/benilla-app/src/post/grading.wgsl new file mode 100644 index 00000000..fefdc9da --- /dev/null +++ b/crates/benilla-app/src/post/grading.wgsl @@ -0,0 +1,35 @@ +// Colour grading ported from WarcraftXL wxl-retail-grading. +// Copyright (C) 2026 WarcraftXL. GPL-3.0-or-later; see THIRD-PARTY.md. +// MONKEY (post): real 32³ textures make the original 1024x32 strip's two bilinear taps plus +// lerp(fract(b*31)) one hardware-trilinear lookup, for both the day and night cubes. + +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput + +@group(0) @binding(0) var scene: texture_2d; +@group(0) @binding(1) var scene_sampler: sampler; +@group(0) @binding(2) var day_lut: texture_3d; +@group(0) @binding(3) var night_lut: texture_3d; +@group(0) @binding(4) var lut_sampler: sampler; + +struct Grade { + control: vec4, +}; +@group(0) @binding(5) var grade: Grade; + +fn grade_one(lut: texture_3d, color: vec3) -> vec3 { + // Half a texel in from each face, with 31 intervals across the 32-texel cube. + let uvw = color * (31.0 / 32.0) + vec3(0.5 / 32.0); + return textureSampleLevel(lut, lut_sampler, uvw, 0.0).rgb; +} + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let source = textureSampleLevel(scene, scene_sampler, in.uv, 0.0); + let base = clamp(source.rgb, vec3(0.0), vec3(1.0)); + 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); + // 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); +} diff --git a/crates/benilla-app/src/post/mod.rs b/crates/benilla-app/src/post/mod.rs index 3d61ee58..2952e5a6 100644 --- a/crates/benilla-app/src/post/mod.rs +++ b/crates/benilla-app/src/post/mod.rs @@ -1,6 +1,7 @@ //! MONKEY (post): optional world-camera effects ahead of FFXGlow's gamma clamp. mod bloom; +mod grading; mod sun_shafts; pub(crate) struct PostPlugin; @@ -8,6 +9,7 @@ pub(crate) struct PostPlugin; impl bevy::prelude::Plugin for PostPlugin { fn build(&self, app: &mut bevy::prelude::App) { app.add_plugins(bloom::BloomPlugin); + app.add_plugins(grading::GradingPlugin); app.add_plugins(sun_shafts::SunShaftsPlugin); } } diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index d1492045..e440d6ac 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -226,6 +226,8 @@ pub(crate) struct VideoConfig { pub(crate) bloom: u8, // MONKEY (post): depth-occluded radial sun shafts. pub(crate) sun_shafts: bool, + // MONKEY (post): zone/day-night LUT grading at the world-to-UI boundary. + pub(crate) color_grading: bool, /// Brightness of lava lighting its surroundings, 0..4; 0 disables the glow. /// Published to `benilla_world::lighting::LavaLightGain` by `dynamic_interior::bridge`. pub(crate) lava_light_gain: f32, @@ -475,6 +477,7 @@ impl Default for VideoConfig { bloom: 2, // MONKEY (post): part of the shipped High graphics preset. sun_shafts: true, + color_grading: true, lava_light_gain: 1.0, fire_flicker: 1.0, display: if windowed_env() { @@ -559,6 +562,7 @@ pub(crate) fn on_cvar( "bloom" => cfg.bloom = v.clamp(0.0, 2.0) as u8, // MONKEY (post): the shafts lane is binary. "sunshafts" => cfg.sun_shafts = v != 0.0, + "colorgrading" => cfg.color_grading = v != 0.0, "lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0), "worldshadows" => cfg.world_shadows = ev.flag(), "charactershadows" => cfg.character_shadows = ev.flag(), diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index ad87a31e..fb9d9216 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -29,6 +29,10 @@ mod camera_shakes; mod cinematics; mod creatures; mod dbc; +mod monkey_zone_grade; +pub use monkey_zone_grade::{ + load_monkey_zone_grades, parse_monkey_zone_grades, MonkeyZoneGrade, MonkeyZoneGrades, +}; mod unit_blood; pub use camera_shakes::{load_camera_shakes, CameraShake, CameraShakeCatalog, SpellShakeGroup}; pub use cinematics::{ diff --git a/crates/benilla-formats/src/monkey_zone_grade.rs b/crates/benilla-formats/src/monkey_zone_grade.rs new file mode 100644 index 00000000..2c690d92 --- /dev/null +++ b/crates/benilla-formats/src/monkey_zone_grade.rs @@ -0,0 +1,69 @@ +//! MONKEY (post): `MonkeyZoneGrade.dbc`, the optional area-to-day/night LUT table. + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, str_at, u32_at}; +use crate::Chain; + +const TABLE: &str = "DBFilesClient\\MonkeyZoneGrade.dbc"; + +/// One authored zone grade. Missing files and absent area rows are handled by the caller as the +/// identity cube, so this type only represents complete rows. +#[derive(Clone, Debug, PartialEq)] +pub struct MonkeyZoneGrade { + pub id: u32, + pub area_id: u32, + pub day_lut: String, + pub night_lut: String, + pub strength: f32, +} + +#[derive(Clone, Debug, Default)] +pub struct MonkeyZoneGrades { + rows: Vec, +} + +impl MonkeyZoneGrades { + pub fn for_area(&self, area_id: u32) -> Option<&MonkeyZoneGrade> { + self.rows.iter().find(|row| row.area_id == area_id) + } + + pub fn rows(&self) -> impl Iterator { + self.rows.iter() + } +} + +/// Read the five-column extension table through the same typed DBC parser as the stock tables. +pub fn load_monkey_zone_grades(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(TABLE) + .context("reading MonkeyZoneGrade.dbc")?; + parse_monkey_zone_grades(&bytes) +} + +/// Parse an in-memory extension table through the typed DBC reader. This is used for the loose +/// capture patch; the MPQ-backed path above and this path therefore share the exact schema. +pub fn parse_monkey_zone_grades(bytes: &[u8]) -> Result { + let mut schema = Schema::new("MonkeyZoneGrade"); + schema.add_field(SchemaField::new("ID", FieldType::UInt32)); + schema.add_field(SchemaField::new("AreaID", FieldType::UInt32)); + schema.add_field(SchemaField::new("DayLUT", FieldType::String)); + schema.add_field(SchemaField::new("NightLUT", FieldType::String)); + schema.add_field(SchemaField::new("Strength", FieldType::Float32)); + let records = parse(bytes, schema, "MonkeyZoneGrade.dbc")?; + let rows = records + .records() + .iter() + .filter_map(|record| { + Some(MonkeyZoneGrade { + id: u32_at(record, 0)?, + area_id: u32_at(record, 1)?, + day_lut: str_at(&records, record, 2)?, + night_lut: str_at(&records, record, 3)?, + strength: f32_at(record, 4).unwrap_or(0.0).clamp(0.0, 1.0), + }) + }) + .collect(); + Ok(MonkeyZoneGrades { rows }) +}