Files
benilla-octo/crates/benilla-app/src/post/grading.rs
T

629 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! MONKEY (post): zone/day-night colour grading through a real 32³ GPU texture.
//!
//! Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-grading, Grading.cpp, shaders/Grading.ps.hlsl.
//! Used with the author's permission; attribution required. 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,
Render, RenderApp, RenderStartup, RenderSystems,
},
};
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>,
/// 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,
}
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;
}
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_systems(
Render,
prepare_uniforms.in_set(RenderSystems::PrepareResources),
)
.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,
)
}
#[allow(clippy::too_many_arguments)]
fn update_views(
mut commands: Commands,
video: Res<VideoConfig>,
catalog: Option<Res<GradeCatalog>>,
area: Res<CurrentArea>,
// MONKEY (reviewfix-a): a capture has no avatar; it grades by the area under the camera.
capture_area: Option<Res<benilla_world::terrain_stream::CaptureCameraArea>>,
areas: Option<Res<crate::area::AreaTableRes>>,
time: Res<WorldTime>,
// MONKEY (reviewfix-a): the minute the lighting actually rendered (server, or the manual /
// capture clock); `WorldTime` alone is the server's and stays at its noon default offline.
rendered: Option<Res<benilla_world::lighting::GameClock>>,
clock: Res<Time>,
mut blend: ResMut<GradeBlend>,
cameras: Query<(Entity, &Camera, Option<&GradeView>), With<WorldCamera>>,
) {
let selected = catalog.as_ref().and_then(|catalog| {
let leaf = area.0.or_else(|| capture_area.as_ref().and_then(|a| a.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(|| GradePair {
day: catalog
.luts
.get(&row.day_lut)
.unwrap_or(&catalog.identity)
.clone(),
night: catalog
.luts
.get(&row.night_lut)
.unwrap_or(&catalog.identity)
.clone(),
strength: row.strength,
})
});
blend.step(selected, clock.delta_secs(), !video.color_grading);
let minute = rendered_minute(&time, rendered.as_deref());
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(minute), blend.fade, prev_strength),
})
});
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>();
}
_ => {}
}
}
}
/// MONKEY (reviewfix-a): the server's fractional minute while it is the minute being rendered,
/// else the rendered (manual or capture) minute.
pub(crate) fn rendered_minute(
time: &WorldTime,
rendered: Option<&benilla_world::lighting::GameClock>,
) -> f32 {
match rendered {
Some(c) if time.minute_f.floor() as u32 != c.minute => c.minute as f32,
_ => time.minute_f,
}
}
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),
// MONKEY (polish): the previous zone's pair, crossfaded out.
texture_3d(TextureSampleType::Float { filterable: true }),
texture_3d(TextureSampleType::Float { filterable: true }),
),
),
);
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,
});
}
/// MONKEY (polish): the view's uniform, created once and rewritten in prepare, like bloom's.
#[derive(Component)]
struct GradeUniformBuffer(Buffer);
fn prepare_uniforms(
mut commands: Commands,
device: Res<RenderDevice>,
queue: Res<RenderQueue>,
views: Query<(Entity, &GradeView, Option<&GradeUniformBuffer>)>,
) {
for (entity, grade, buffer) in &views {
let rows = [grade.control.to_array()];
match buffer {
Some(buffer) => queue.write_buffer(&buffer.0, 0, bytemuck::cast_slice(&rows)),
None => {
let buffer = device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("post_grading_uniform"),
contents: bytemuck::cast_slice(&rows),
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
});
commands.entity(entity).insert(GradeUniformBuffer(buffer));
}
}
}
}
#[derive(Default)]
struct GradeNode;
impl ViewNode for GradeNode {
type ViewQuery = (
&'static ViewTarget,
&'static GradeView,
&'static GradeUniformBuffer,
);
fn run<'w>(
&self,
_graph: &mut RenderGraphContext,
context: &mut RenderContext<'w>,
(target, grade, uniform): 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), 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();
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.0.as_entire_binding(),
&prev_day.texture_view,
&prev_night.texture_view,
)),
);
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::*;
/// MONKEY (reviewfix-a): offline (capture / manual clock) the grade follows the rendered
/// minute, not the server's noon default; live it keeps the server's fraction.
#[test]
fn grade_night_weight_follows_the_rendered_minute() {
let server = WorldTime::default();
let manual = benilla_world::lighting::GameClock {
minute: 1260,
..Default::default()
};
assert_eq!(rendered_minute(&server, Some(&manual)), 1260.0);
assert_eq!(night_weight(rendered_minute(&server, Some(&manual))), 1.0);
let live = WorldTime {
minute_f: 1260.5,
..Default::default()
};
assert_eq!(rendered_minute(&live, Some(&manual)), 1260.5);
assert_eq!(rendered_minute(&live, None), 1260.5);
}
#[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 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);
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);
}
}