MONKEY (post): port zone colour grading
Co-Authored-By: GPT-5.6 Sol (Codex)
This commit is contained in:
co-authored by
GPT-5.6 Sol (Codex)
parent
57220f558f
commit
08c1de3177
@@ -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.
|
||||
@@ -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")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- MONKEY (post): zone/day-night 32-cube grade, world only. -->
|
||||
<Frame name="$parentRowColorGrading" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowSunShafts" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowSunShafts" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "colorGrading", BENILLA_ADVGFX.colorGrading,
|
||||
"BENILLA_TOOLTIP_COLOR_GRADING")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<!-- MONKEY (post): screen effects sit immediately above this one. -->
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowSunShafts" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowSunShafts" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowColorGrading" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowColorGrading" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "characterShadows",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, Handle<Image>>,
|
||||
identity: Handle<Image>,
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, PartialEq, ExtractComponent)]
|
||||
struct GradeView {
|
||||
day: Handle<Image>,
|
||||
night: Handle<Image>,
|
||||
// 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::<GradeView>::default())
|
||||
.add_systems(Last, update_views);
|
||||
if !app.is_plugin_added::<AssetPlugin>() {
|
||||
return;
|
||||
}
|
||||
app.add_systems(Startup, load_catalog.after(AssetSet::Open));
|
||||
let shader = app
|
||||
.world_mut()
|
||||
.resource_mut::<Assets<Shader>>()
|
||||
.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::<ViewNodeRunner<GradeNode>>(Core3d, GradeLabel)
|
||||
.add_render_graph_edges(
|
||||
Core3d,
|
||||
(
|
||||
BloomLabel,
|
||||
GradeLabel,
|
||||
benilla_world::ffx_glow::FfxGlowLabel,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_catalog(
|
||||
mut commands: Commands,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
) {
|
||||
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<String> = 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<Vec<u8>> {
|
||||
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<u8> {
|
||||
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<u8>) -> 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<VideoConfig>,
|
||||
catalog: Option<Res<GradeCatalog>>,
|
||||
area: Res<CurrentArea>,
|
||||
areas: Option<Res<crate::area::AreaTableRes>>,
|
||||
time: Res<WorldTime>,
|
||||
cameras: Query<(Entity, &Camera, Option<&GradeView>), With<WorldCamera>>,
|
||||
) {
|
||||
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::<GradeView>();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<Shader>);
|
||||
|
||||
#[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<RenderDevice>,
|
||||
cache: Res<PipelineCache>,
|
||||
shader: Res<GradeShader>,
|
||||
fullscreen: Res<FullscreenShader>,
|
||||
) {
|
||||
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::<GradeUniform>(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::<GradePipeline>();
|
||||
let cache = world.resource::<PipelineCache>();
|
||||
let Some(pipeline) = cache.get_render_pipeline(settings.pipeline) else {
|
||||
return Ok(());
|
||||
};
|
||||
let images = world.resource::<RenderAssets<GpuImage>>();
|
||||
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::<RenderQueue>());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<f32>;
|
||||
@group(0) @binding(1) var scene_sampler: sampler;
|
||||
@group(0) @binding(2) var day_lut: texture_3d<f32>;
|
||||
@group(0) @binding(3) var night_lut: texture_3d<f32>;
|
||||
@group(0) @binding(4) var lut_sampler: sampler;
|
||||
|
||||
struct Grade {
|
||||
control: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(5) var<uniform> grade: Grade;
|
||||
|
||||
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.
|
||||
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<f32> {
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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<MonkeyZoneGrade>,
|
||||
}
|
||||
|
||||
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<Item = &MonkeyZoneGrade> {
|
||||
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<MonkeyZoneGrades> {
|
||||
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<MonkeyZoneGrades> {
|
||||
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 })
|
||||
}
|
||||
Reference in New Issue
Block a user