diff --git a/pomme-client/build.rs b/pomme-client/build.rs index 62fd4d5e6..8d8f17eaf 100644 --- a/pomme-client/build.rs +++ b/pomme-client/build.rs @@ -123,6 +123,7 @@ fn main() { shaderc::ShaderKind::Fragment, ), ("post_blit.frag", shaderc::ShaderKind::Fragment), + ("lightmap.frag", shaderc::ShaderKind::Fragment), ("post_gamma.frag", shaderc::ShaderKind::Fragment), ("post_invert.frag", shaderc::ShaderKind::Fragment), ("post_color_convolve.frag", shaderc::ShaderKind::Fragment), diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 3c890e676..120664528 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -392,6 +392,7 @@ impl GameState { ambient_light, overworld_day, default_clock, + light_environment, } = dim; tracing::info!( "Dimension: height={height}, min_y={min_y}, skylight={has_skylight}, cardinal_light={cardinal_light:?}" @@ -400,6 +401,7 @@ impl GameState { self.ambient_light = ambient_light; self.overworld_day = overworld_day; self.default_clock = default_clock; + self.light_environment = light_environment; self.chunk_store = ChunkStore::new_with_dimension(render_distance, height, min_y); self.light_engine = crate::world::light::LevelLightEngine::new(height, min_y, has_skylight); // The old level's block entities, moving blocks among them. diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 21cbde545..15de3a853 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -42,6 +42,7 @@ use crate::renderer::pipelines::entity_renderer::{ standing_eye_height, wool_color_tint, }; use crate::renderer::pipelines::entity_shadow::{ShadowCaster, ShadowScene}; +use crate::renderer::pipelines::lightmap::LightmapInfo; use crate::renderer::pipelines::menu_overlay::MenuElement; use crate::renderer::pipelines::post_effect::PostEffect; use crate::renderer::{Renderer, SkyState}; @@ -271,6 +272,10 @@ pub struct GameState { pub overworld_day: bool, /// The current dimension's `default_clock`, whose ticks are its day time. pub default_clock: Option, + /// The current dimension's lightmap attribute layers. + pub light_environment: crate::world::environment::LightEnvironment, + /// Vanilla `LightmapRenderStateExtractor.blockLightFlicker`. + pub block_light_flicker: f32, /// F3+F4 game-mode switcher overlay, while open. pub game_mode_switcher: Option, /// Spectator hotbar menu (vanilla `SpectatorGui`). Not a GUI screen: the @@ -536,6 +541,8 @@ impl GameState { ambient_light: 0.0, overworld_day: true, default_clock: None, + light_environment: Default::default(), + block_light_flicker: 0.0, game_mode_switcher: None, spectator: Default::default(), switcher_was_open: false, @@ -2315,11 +2322,13 @@ pub fn update_game( // ticks, before them. // TODO: vanilla also skips this while singleplayer is paused and under // `/tick freeze` (`isLevelRunningNormally`); pomme tracks neither yet. - if core.tick_accumulator >= TICK_RATE { + let ticked = core.tick_accumulator >= TICK_RATE; + if ticked { gfx.renderer.tick_texture_animations(); } while core.tick_accumulator >= TICK_RATE { game.tick_count = game.tick_count.wrapping_add(1); + game.block_light_flicker = tick_block_light_flicker(game.block_light_flicker); // Vanilla `Minecraft.tick` order: `gameMode.tick` drives the connection // tick (and so the level load tracker) before the level's entities, // i.e. before the local player moves or sends anything. @@ -2404,6 +2413,10 @@ pub fn update_game( AppCore::send_client_tick_end(connection); core.tick_accumulator -= TICK_RATE; } + if ticked { + gfx.renderer + .tick_lightmap(lightmap_info(game, core.menu.brightness)); + } // Once per frame after the frame's ticks, where vanilla `Minecraft.runTick` // calls `level.update()`. @@ -5270,9 +5283,46 @@ fn sheep_eat_scales(eat_tick: u8, prev_eat_tick: u8, alpha: f32) -> (f32, f32) { (pos_scale, angle_scale) } +/// Vanilla `LightmapRenderStateExtractor.tick`: the block light's random +/// walk. +fn tick_block_light_flicker(flicker: f32) -> f32 { + let r = fastrand::f32; + (flicker + (r() - r()) * r() * r() * 0.1) * 0.9 +} + +/// Vanilla `LightmapRenderStateExtractor.extract`. +/// TODO: night vision, darkness, boss fog and the lightning and end flashes. +fn lightmap_info(game: &GameState, brightness: f32) -> LightmapInfo { + let sky = &game.sky_state; + let attributes = game + .light_environment + .resolve(sky.day_time as i64, sky.rain(), sky.thunder()); + let with = |rgb: glam::Vec3, w: f32| rgb.extend(w).to_array(); + LightmapInfo { + block_light_tint: with(attributes.block_light_tint, game.block_light_flicker + 1.4), + sky_light_color: with(attributes.sky_light_color, attributes.sky_light_factor), + ambient_color: with(attributes.ambient_light_color, brightness), + night_vision_color: with(attributes.night_vision_color, 0.0), + ..bytemuck::Zeroable::zeroed() + } +} + #[cfg(test)] mod tests { - use super::{detached_distance, has_red_overlay, scroll_wheel, section_bit, section_bits}; + use super::{ + detached_distance, has_red_overlay, scroll_wheel, section_bit, section_bits, + tick_block_light_flicker, + }; + + #[test] + fn block_light_flicker_stays_within_its_steady_bound() { + // Each step adds under 0.1 before the 0.9 decay, so it never passes 0.9. + let mut flicker = 0.0; + for _ in 0..100_000 { + flicker = tick_block_light_flicker(flicker); + assert!(flicker.abs() < 0.9, "{flicker}"); + } + } #[test] fn third_person_distance_takes_the_further_of_player_and_mount() { diff --git a/pomme-client/src/cpu_bench/mesh_hash.rs b/pomme-client/src/cpu_bench/mesh_hash.rs index 1f3517017..e826220c1 100644 --- a/pomme-client/src/cpu_bench/mesh_hash.rs +++ b/pomme-client/src/cpu_bench/mesh_hash.rs @@ -3,57 +3,81 @@ //! hashed as bytes, and sprites are named rather than numbered, since atlas //! sprite ids and vertex and triangle order vary between runs. -use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; use super::fixtures::{Assets, Scene, build_world}; use crate::position::ChunkPos; use crate::renderer::chunk::mesher::PackedVertex; -/// Hashes the centre column of `scene`, meshed like `mesh::bench` does. -pub fn scene_hash(assets: &Assets, scene: Scene) -> u64 { +/// Hashes the centre column of `scene`, meshed like `mesh::bench` does: its +/// geometry alone (each quad's positions, UVs and sprites, however it splits +/// into triangles), then every triangle with every vertex byte. +pub fn scene_hashes(assets: &Assets, scene: Scene) -> [u64; 2] { let dispatcher = assets.mesh_dispatcher(); let store = build_world(scene, assets.block_state_table); let data = dispatcher.mesh_sections_now(&store, ChunkPos::new(0, 0), 0..store.section_count(), 0); let names = assets.mesh.uv_map.sprite_names(); - let mut hasher = DefaultHasher::new(); + let geometry = |v: &PackedVertex| (v.pos, v.sprite_uv_fixed(), names.get(&v.sprite).copied()); + let mut hashers = [DefaultHasher::new(), DefaultHasher::new()]; for section in &data.sections { let (solid, cutout) = section.indices.split_at(section.solid_index_count as usize); - section.section_index.hash(&mut hasher); + for hasher in &mut hashers { + section.section_index.hash(hasher); + } for indices in [solid, cutout, §ion.water_indices[..]] { - triangles_hash(§ion.vertices, indices, &names).hash(&mut hasher); + let vertex = |i: u32| §ion.vertices[i as usize]; + quads_hash(indices, |i| geometry(vertex(i))).hash(&mut hashers[0]); + triangles_hash(indices, |i| { + let v = vertex(i); + (geometry(v), v.uv >> 24, v.light_tint) + }) + .hash(&mut hashers[1]); } } - hasher.finish() + hashers.map(|hasher| hasher.finish()) +} + +/// The pass's quads (every six indices) as a sorted set of their corners. +fn quads_hash(indices: &[u32], decode: impl Fn(u32) -> K) -> u64 { + sorted_hash( + indices + .as_chunks::<6>() + .0 + .iter() + .map(|quad| { + let mut corners: Vec<_> = quad.iter().map(|&i| decode(i)).collect(); + corners.sort_unstable(); + corners.dedup(); + corners + }) + .collect(), + ) } /// The pass's triangles as a sorted set, each starting from its smallest /// vertex so winding is kept but the starting corner isn't. -fn triangles_hash(vertices: &[PackedVertex], indices: &[u32], names: &HashMap) -> u64 { - let decode = |i: u32| { - let v = &vertices[i as usize]; - ( - v.pos, - v.sprite_uv_fixed(), - names.get(&v.sprite).copied(), - v.light_tint, - ) - }; - let mut triangles: Vec<_> = indices +fn triangles_hash(indices: &[u32], decode: impl Fn(u32) -> K) -> u64 { + let triangles = indices .as_chunks::<3>() .0 .iter() .map(|tri| { - let mut corners = tri.map(decode); - let first = (0..3).min_by_key(|&i| corners[i]).unwrap_or(0); + let mut corners = tri.map(&decode); + let first = (0..3) + .min_by(|&a, &b| corners[a].cmp(&corners[b])) + .unwrap_or(0); corners.rotate_left(first); corners }) .collect(); - triangles.sort_unstable(); + sorted_hash(triangles) +} + +fn sorted_hash(mut items: Vec) -> u64 { + items.sort_unstable(); let mut hasher = DefaultHasher::new(); - triangles.hash(&mut hasher); + items.hash(&mut hasher); hasher.finish() } diff --git a/pomme-client/src/cpu_bench/mod.rs b/pomme-client/src/cpu_bench/mod.rs index 1598946f0..cd8145da1 100644 --- a/pomme-client/src/cpu_bench/mod.rs +++ b/pomme-client/src/cpu_bench/mod.rs @@ -48,18 +48,16 @@ pub fn run(version: &str, dirs: &DataDirs, filter: Option<&str>) -> i32 { 0 } -/// Prints [`mesh_hash::scene_hash`] for every bench scene; returns the +/// Prints [`mesh_hash::scene_hashes`] for every bench scene; returns the /// process exit code. pub fn mesh_hash(version: &str, dirs: &DataDirs) -> i32 { let Some(assets) = load_assets(version, dirs, block_state_table(version)) else { return 1; }; + println!("scene geometry all bytes"); for scene in fixtures::Scene::ALL { - println!( - "{:<8} {:016x}", - scene.name(), - mesh_hash::scene_hash(&assets, scene) - ); + let [geometry, all] = mesh_hash::scene_hashes(&assets, scene); + println!("{:<8} {geometry:016x} {all:016x}", scene.name()); } 0 } diff --git a/pomme-client/src/harness/exec.rs b/pomme-client/src/harness/exec.rs index 486bea71b..e84f29d2c 100644 --- a/pomme-client/src/harness/exec.rs +++ b/pomme-client/src/harness/exec.rs @@ -206,6 +206,9 @@ fn set_options(core: &mut AppCore, options: &protocol::OptionSet) -> Result, /// Menu Background Blur, 0 (off) to 10. pub menu_background_blur: Option, + /// Brightness, 0 (Moody) to 1 (Bright). + pub brightness: Option, } #[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] diff --git a/pomme-client/src/harness/script.rs b/pomme-client/src/harness/script.rs index 2f8740c56..8fa0e86fe 100644 --- a/pomme-client/src/harness/script.rs +++ b/pomme-client/src/harness/script.rs @@ -734,6 +734,7 @@ mod tests { Step::Run(Command::Option(OptionSet { main_hand: Some(Hand::Left), menu_background_blur: None, + brightness: None, })) ); assert_eq!( diff --git a/pomme-client/src/net/connection.rs b/pomme-client/src/net/connection.rs index 3df236e17..6609108eb 100644 --- a/pomme-client/src/net/connection.rs +++ b/pomme-client/src/net/connection.rs @@ -491,6 +491,7 @@ async fn config_sequence( let mut received_registry_data = false; let mut selected_known_packs = false; let mut received_dialog_tags = None; + let mut received_timeline_tags = None; // Vanilla sends brand and client information once, from the login // listener; a reconfiguration sends neither. @@ -611,9 +612,14 @@ async fn config_sequence( } ClientboundConfigPacket::UpdateTags(p) => { // A later packet replaces an earlier one's tags per registry. - if let Some(tags) = dialog_tags(&p.tags) { + if let Some(tags) = registry_tags(&p.tags, &dialog_registry_key()) { received_dialog_tags = Some(tags); } + if let Some(tags) = + registry_tags(&p.tags, &super::environment_wire::TIMELINE_REGISTRY.into()) + { + received_timeline_tags = Some(tags); + } forward_tags(event_tx, &p.tags); } ClientboundConfigPacket::SelectKnownPacks(p) => { @@ -664,13 +670,21 @@ async fn config_sequence( None => previous.dialogs.clone(), }, }, - _ => Configured { - dialogs: std::sync::Arc::new(dialog_registry( - ®istry_holder, - received_dialog_tags.unwrap_or_default(), - )), - registries: std::sync::Arc::new(registry_holder), - }, + // TODO: timeline tags resent without registry data + // keep the previous resolution. + _ => { + super::environment_wire::resolve_timeline_tags( + &mut registry_holder, + &received_timeline_tags.unwrap_or_default(), + ); + Configured { + dialogs: std::sync::Arc::new(dialog_registry( + ®istry_holder, + received_dialog_tags.unwrap_or_default(), + )), + registries: std::sync::Arc::new(registry_holder), + } + } }); } ClientboundConfigPacket::Disconnect(p) => { @@ -857,11 +871,12 @@ fn dialog_registry( DialogRegistry::new(entries, tags) } -/// The `minecraft:dialog` tags of an `update_tags` packet, if it has any. -fn dialog_tags( +/// One registry's tags from an `update_tags` packet, if it has any. +fn registry_tags( tags: &azalea_protocol::common::tags::TagMap, + registry: &azalea_registry::identifier::Identifier, ) -> Option>> { - let tags = tags.0.get(&dialog_registry_key())?; + let tags = tags.0.get(registry)?; Some( tags.iter() .map(|tag| { @@ -1115,7 +1130,7 @@ async fn game_loop( continue; } if let ClientboundGamePacket::UpdateTags(p) = &packet { - if let Some(tags) = dialog_tags(&p.tags) { + if let Some(tags) = registry_tags(&p.tags, &dialog_registry_key()) { configured.dialogs = std::sync::Arc::new(configured.dialogs.with_tags(tags)); send_ordered( diff --git a/pomme-client/src/net/environment_wire.rs b/pomme-client/src/net/environment_wire.rs new file mode 100644 index 000000000..4df5bdf2f --- /dev/null +++ b/pomme-client/src/net/environment_wire.rs @@ -0,0 +1,389 @@ +//! Reads a dimension type's lighting attributes and its timelines' lighting +//! tracks out of the synced registries, in the shapes of vanilla's +//! `EnvironmentAttributeMap` and `Timeline` network codecs. + +use std::collections::HashMap; + +use azalea_core::registry_holder::RegistryHolder; +use azalea_registry::identifier::Identifier; +use glam::Vec3; +use simdnbt::owned::{NbtCompound, NbtList, NbtTag}; + +use crate::world::environment::{ + Argument, AttributeLayers, AttributeValue, LightAttribute, LightEnvironment, Modifier, + ModifierOp, Track, +}; + +pub(super) const TIMELINE_REGISTRY: &str = "minecraft:timeline"; + +/// A lighting attribute's value type and its codecs. +trait WireValue: AttributeValue { + const FLOAT: bool; + fn read(tag: &NbtTag) -> Option; + /// `alpha_blend`'s argument: a `FloatWithAlpha`, or an ARGB colour. + fn read_with_alpha(tag: &NbtTag) -> Option>; +} + +impl WireValue for f32 { + const FLOAT: bool = true; + + fn read(tag: &NbtTag) -> Option { + number(tag) + } + + fn read_with_alpha(tag: &NbtTag) -> Option> { + match tag.compound() { + Some(full) => Some(Argument { + value: number(full.get("value")?)?, + alpha: full.get("alpha").map_or(Some(1.0), number)?, + }), + None => Some(Argument::opaque(number(tag)?)), + } + } +} + +impl WireValue for Vec3 { + const FLOAT: bool = false; + + /// `ExtraCodecs.STRING_RGB_VEC3_COLOR`: `#rrggbb`, a packed int, or + /// three floats. + fn read(tag: &NbtTag) -> Option { + let [_, rgb @ ..] = argb(tag, 6)?; + Some(Vec3::from_array(rgb)) + } + + /// `STRING_ARGB_VEC4_COLOR`: `#aarrggbb`, a packed int, or four floats. + fn read_with_alpha(tag: &NbtTag) -> Option> { + let [alpha, rgb @ ..] = argb(tag, 8)?; + Some(Argument { + value: Vec3::from_array(rgb), + alpha, + }) + } +} + +fn number(tag: &NbtTag) -> Option { + Some(match tag { + NbtTag::Byte(v) => *v as f32, + NbtTag::Short(v) => *v as f32, + NbtTag::Int(v) => *v as f32, + NbtTag::Long(v) => *v as f32, + NbtTag::Float(v) => *v, + NbtTag::Double(v) => *v as f32, + _ => return None, + }) +} + +/// A colour as `[a, r, g, b]` floats: a hex string of `digits` digits (an +/// RGB one is opaque), a packed ARGB int, or a float list (RGB or RGBA). +fn argb(tag: &NbtTag, digits: usize) -> Option<[f32; 4]> { + let channel = |packed: u32, shift: u32| ((packed >> shift) & 0xFF) as f32 / 255.0; + let packed = |packed: u32| [24, 16, 8, 0].map(|shift| channel(packed, shift)); + match tag { + NbtTag::String(hex) => { + let hex = hex.to_str(); + let digits_str = hex.strip_prefix('#').filter(|d| d.len() == digits)?; + let value = u32::from_str_radix(digits_str, 16).ok()?; + Some(packed(if digits == 6 { + 0xFF00_0000 | value + } else { + value + })) + } + NbtTag::Int(value) => Some(packed(*value as u32)), + NbtTag::List(list) => match floats(list)?.as_slice() { + [r, g, b] => Some([1.0, *r, *g, *b]), + [r, g, b, a] => Some([*a, *r, *g, *b]), + _ => None, + }, + _ => None, + } +} + +fn floats(list: &NbtList) -> Option> { + list.floats().or_else(|| { + list.doubles() + .map(|d| d.into_iter().map(|v| v as f32).collect()) + }) +} + +/// A modifier's argument, read with the codec its op takes. +fn argument(op: ModifierOp, tag: &NbtTag) -> Option> { + match op { + ModifierOp::AlphaBlend => T::read_with_alpha(tag), + _ => T::read(tag).map(Argument::opaque), + } +} + +/// An entry's `modifier` (override when absent), warning about ones pomme +/// doesn't port. +fn op(attribute: LightAttribute, entry: &NbtCompound) -> Option { + let Some(name) = entry.string("modifier") else { + return Some(ModifierOp::Override); + }; + let name = name.to_str(); + let op = ModifierOp::parse(&name, T::FLOAT); + if op.is_none() { + tracing::warn!( + "{}: unsupported modifier {name}, ignoring it", + attribute.id() + ); + } + op +} + +/// An `EnvironmentAttributeMap` entry: a bare value (override) or +/// `{modifier, argument}`. +fn dimension_modifier( + attribute: LightAttribute, + tag: &NbtTag, +) -> Option> { + let (op, argument_tag) = match tag.compound().filter(|entry| entry.contains("modifier")) { + Some(entry) => (op::(attribute, entry)?, entry.get("argument")?), + None => (ModifierOp::Override, tag), + }; + Some(Modifier { + op, + argument: argument(op, argument_tag)?, + }) +} + +/// An `AttributeTrack`: `{modifier?, keyframes: [{ticks, value}], ease?}`. +fn track(attribute: LightAttribute, entry: &NbtCompound) -> Option> { + let op = op::(attribute, entry)?; + if let Some(ease) = entry.get("ease").and_then(|e| e.string()) + && ease.to_str() != "linear" + { + tracing::warn!( + "{}: ease {ease} is not ported, sampling linearly", + attribute.id() + ); + } + let keyframes: Vec<_> = entry + .list("keyframes")? + .compounds()? + .iter() + .map(|frame| { + let ticks = number(frame.get("ticks")?)? as i64; + Some((ticks, argument(op, frame.get("value")?)?)) + }) + .collect::>()?; + (!keyframes.is_empty()).then_some(Track { op, keyframes }) +} + +/// One attribute's dimension modifier and timeline tracks. +fn layers( + attribute: LightAttribute, + attributes: Option<&NbtCompound>, + timelines: &[&NbtCompound], +) -> AttributeLayers { + let dimension = attributes + .and_then(|map| map.get(attribute.id())) + .and_then(|tag| dimension_modifier(attribute, tag)); + let tracks = timelines + .iter() + .filter_map(|timeline| { + let entry = timeline.compound("tracks")?.compound(attribute.id())?; + let period = timeline + .get("period_ticks") + .and_then(number) + .map(|p| p as i64); + Some((track(attribute, entry)?, period)) + }) + .collect(); + AttributeLayers { dimension, tracks } +} + +/// The dimension type's lighting layers. `timelines` must already be a list +/// of ids (see [`resolve_timeline_tags`]). +pub(super) fn light_environment( + extra: &HashMap, + registries: &RegistryHolder, + has_weather: bool, +) -> LightEnvironment { + let attributes = extra.get("attributes").and_then(NbtTag::compound); + let timeline_registry = registries.extra.get(&TIMELINE_REGISTRY.into()); + let timelines: Vec<&NbtCompound> = timeline_ids(extra.get("timelines")) + .iter() + .filter_map(|id| timeline_registry?.map.get(&Identifier::new(id))) + .collect(); + let rgb = |attribute| layers::(attribute, attributes, &timelines); + LightEnvironment { + block_light_tint: rgb(LightAttribute::BlockLightTint), + sky_light_color: rgb(LightAttribute::SkyLightColor), + sky_light_factor: layers(LightAttribute::SkyLightFactor, attributes, &timelines), + night_vision_color: rgb(LightAttribute::NightVisionColor), + ambient_light_color: rgb(LightAttribute::AmbientLightColor), + has_weather, + } +} + +/// A `timelines` holder set's ids; a tag that wasn't resolved names none. +pub(super) fn timeline_ids(holder_set: Option<&NbtTag>) -> Vec { + match holder_set { + Some(NbtTag::String(id)) if !id.to_str().starts_with('#') => vec![id.to_string()], + Some(NbtTag::List(list)) => list + .strings() + .map(|ids| ids.iter().map(|id| id.to_string()).collect()) + .unwrap_or_default(), + _ => Vec::new(), + } +} + +/// Rewrites each dimension type's `timelines` tag reference as the list of +/// timeline ids it names, from the synced `minecraft:timeline` tags. +pub(super) fn resolve_timeline_tags( + registries: &mut RegistryHolder, + tags: &HashMap>, +) { + let names: Vec = registries + .extra + .get(&TIMELINE_REGISTRY.into()) + .map(|registry| registry.map.keys().map(ToString::to_string).collect()) + .unwrap_or_default(); + for dim in registries.dimension_type.map.values_mut() { + let Some(NbtTag::String(reference)) = dim._extra.get("timelines") else { + continue; + }; + let Some(tag) = reference.to_str().strip_prefix('#').map(str::to_owned) else { + continue; + }; + let Some(entries) = tags.get(&tag) else { + tracing::warn!("timeline tag #{tag} wasn't synced"); + continue; + }; + let ids: Vec<_> = entries + .iter() + .filter_map(|&i| names.get(i)) + .map(|name| name.as_str().into()) + .collect(); + dim._extra + .insert("timelines".to_owned(), NbtTag::List(NbtList::String(ids))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn compound(entries: Vec<(&str, NbtTag)>) -> NbtCompound { + NbtCompound::from_values( + entries + .into_iter() + .map(|(key, tag)| (key.into(), tag)) + .collect(), + ) + } + + #[test] + fn colours_read_every_codec_form() { + let hex = NbtTag::String("#7a7aff".into()); + assert_eq!( + Vec3::read(&hex), + Some(Vec3::new(122.0, 122.0, 255.0) / 255.0) + ); + assert_eq!( + Vec3::read(&NbtTag::Int(0x0A0A0A)), + Some(Vec3::splat(10.0 / 255.0)) + ); + let list = NbtTag::List(NbtList::Float(vec![0.5, 0.25, 1.0])); + assert_eq!(Vec3::read(&list), Some(Vec3::new(0.5, 0.25, 1.0))); + let argb = Vec3::read_with_alpha(&NbtTag::String("#507a7aff".into())).unwrap(); + assert_eq!(argb.alpha, 0x50 as f32 / 255.0); + assert_eq!(Vec3::read(&NbtTag::String("#7a7a".into())), None); + } + + #[test] + fn floats_read_bare_and_with_alpha() { + assert_eq!(f32::read(&NbtTag::Double(0.24)), Some(0.24)); + let full = NbtTag::Compound(compound(vec![ + ("value", NbtTag::Float(0.24)), + ("alpha", NbtTag::Float(0.3125)), + ])); + assert_eq!( + f32::read_with_alpha(&full), + Some(Argument { + value: 0.24, + alpha: 0.3125 + }) + ); + } + + #[test] + fn dimension_entries_are_overrides_or_modifiers() { + let attribute = LightAttribute::SkyLightFactor; + assert_eq!( + dimension_modifier::(attribute, &NbtTag::Float(0.0)), + Some(Modifier { + op: ModifierOp::Override, + argument: Argument::opaque(0.0) + }) + ); + let multiply = NbtTag::Compound(compound(vec![ + ("modifier", NbtTag::String("multiply".into())), + ("argument", NbtTag::Float(0.5)), + ])); + assert_eq!( + dimension_modifier::(attribute, &multiply).map(|m| m.op), + Some(ModifierOp::Multiply) + ); + let unknown = NbtTag::Compound(compound(vec![ + ("modifier", NbtTag::String("blend_to_gray".into())), + ("argument", NbtTag::Float(0.5)), + ])); + assert_eq!(dimension_modifier::(attribute, &unknown), None); + } + + #[test] + fn tracks_read_keyframes_and_default_to_override() { + let frame = |ticks, value| { + compound(vec![ + ("ticks", NbtTag::Int(ticks)), + ("value", NbtTag::Float(value)), + ]) + }; + let entry = compound(vec![( + "keyframes", + NbtTag::List(NbtList::Compound(vec![frame(730, 1.0), frame(13140, 0.24)])), + )]); + let track = track::(LightAttribute::SkyLightFactor, &entry).unwrap(); + assert_eq!(track.op, ModifierOp::Override); + assert_eq!(track.keyframes[1], (13140, Argument::opaque(0.24))); + } + + #[test] + fn timeline_tags_resolve_to_their_ids() { + use azalea_core::registry_holder::RegistryType; + use azalea_core::registry_holder::dimension_type::DimensionKindElement; + + let mut registries = RegistryHolder::default(); + let mut timelines = RegistryType::::default(); + for name in [ + "minecraft:day", + "minecraft:moon", + "minecraft:villager_schedule", + ] { + timelines.map.insert(name.into(), NbtCompound::new()); + } + registries.extra.insert(TIMELINE_REGISTRY.into(), timelines); + registries.dimension_type.map.insert( + "minecraft:overworld".into(), + DimensionKindElement { + height: 384, + min_y: -64, + ultrawarm: None, + _extra: HashMap::from([( + "timelines".to_owned(), + NbtTag::String("#minecraft:in_overworld".into()), + )]), + }, + ); + let tags = HashMap::from([("minecraft:in_overworld".to_owned(), vec![2, 0])]); + resolve_timeline_tags(&mut registries, &tags); + let dim = ®istries.dimension_type.map[0]; + assert_eq!( + timeline_ids(dim._extra.get("timelines")), + ["minecraft:villager_schedule", "minecraft:day"] + ); + } +} diff --git a/pomme-client/src/net/lifecycle_wire.rs b/pomme-client/src/net/lifecycle_wire.rs index 3877e5682..9c5fc29f6 100644 --- a/pomme-client/src/net/lifecycle_wire.rs +++ b/pomme-client/src/net/lifecycle_wire.rs @@ -218,7 +218,10 @@ fn spawn_events( .get_index(spawn.dimension_type as usize) { Some((_, dim)) => { - send_ordered(event_tx, dimension_info(dim)); + send_ordered( + event_tx, + dimension_info(dim, &spawn.dimension, registry_holder), + ); } None => tracing::error!("Unknown dimension_type {}", spawn.dimension_type), } @@ -244,22 +247,31 @@ fn spawn_events( /// Dimension info from a login/respawn registry entry. Fields that azalea does /// not model directly live in its flattened extras. Missing `has_skylight` /// defaults to true; missing `cardinal_light` defaults to vanilla's `default`. -fn dimension_info(dim: &DimensionKindElement) -> NetworkEvent { +fn dimension_info( + dim: &DimensionKindElement, + dimension: &str, + registries: &RegistryHolder, +) -> NetworkEvent { let string = |key: &str| { dim._extra .get(key) .and_then(|tag| tag.string()) .map(|value| value.to_str().into_owned()) }; + let flag = |key: &str| { + dim._extra + .get(key) + .and_then(|tag| tag.byte()) + .map(|b| b != 0) + }; + let has_skylight = flag("has_skylight").unwrap_or(true); + // Vanilla `Level.canHaveWeather`, which names the end. + let has_weather = + has_skylight && !flag("has_ceiling").unwrap_or(false) && dimension != "minecraft:the_end"; NetworkEvent::DimensionInfo(DimensionInfo { height: dim.height, min_y: dim.min_y, - has_skylight: dim - ._extra - .get("has_skylight") - .and_then(|tag| tag.byte()) - .map(|b| b != 0) - .unwrap_or(true), + has_skylight, cardinal_light: match string("cardinal_light").as_deref() { Some("nether") => CardinalLightType::Nether, _ => CardinalLightType::Default, @@ -271,6 +283,11 @@ fn dimension_info(dim: &DimensionKindElement) -> NetworkEvent { .unwrap_or(0.0), overworld_day: dim._extra.get("timelines").is_some_and(names_overworld_day), default_clock: string("default_clock"), + light_environment: super::environment_wire::light_environment( + &dim._extra, + registries, + has_weather, + ), }) } @@ -360,19 +377,24 @@ mod tests { ambient_light, overworld_day, default_clock, - }) = dimension_info(&dimension(&[ - ("has_skylight", NbtTag::Byte(1)), - ("cardinal_light", NbtTag::String("nether".into())), - ("ambient_light", NbtTag::Float(0.1)), - ( - "timelines", - NbtTag::String("#minecraft:in_overworld".into()), - ), - ( - "default_clock", - NbtTag::String("minecraft:overworld".into()), - ), - ])) + .. + }) = dimension_info( + &dimension(&[ + ("has_skylight", NbtTag::Byte(1)), + ("cardinal_light", NbtTag::String("nether".into())), + ("ambient_light", NbtTag::Float(0.1)), + ( + "timelines", + NbtTag::String("#minecraft:in_overworld".into()), + ), + ( + "default_clock", + NbtTag::String("minecraft:overworld".into()), + ), + ]), + "minecraft:overworld", + &RegistryHolder::default(), + ) else { panic!("dimension_info returned the wrong event variant"); }; @@ -392,7 +414,11 @@ mod tests { overworld_day, default_clock, .. - }) = dimension_info(&dimension(&[])) + }) = dimension_info( + &dimension(&[]), + "minecraft:overworld", + &RegistryHolder::default(), + ) else { panic!("dimension_info returned the wrong event variant"); }; diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index 52d8ef6f2..51c65b8be 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -14,6 +14,7 @@ mod container_wire; mod dialog; mod entity_data; mod entity_wire; +mod environment_wire; pub mod handler; pub(crate) mod http; mod hud_wire; @@ -117,6 +118,8 @@ pub struct DimensionInfo { pub overworld_day: bool, /// The `world_clock` driving this dimension's day time. pub default_clock: Option, + /// The lightmap's attribute layers. + pub light_environment: crate::world::environment::LightEnvironment, } pub enum NetworkEvent { diff --git a/pomme-client/src/renderer/chunk/block_ao.rs b/pomme-client/src/renderer/chunk/block_ao.rs deleted file mode 100644 index d9c24b78d..000000000 --- a/pomme-client/src/renderer/chunk/block_ao.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Vanilla ambient occlusion for cubic block faces, ported from -//! `BlockModelLighter.prepareQuadAmbientOcclusion` (snapshot 26.2). -//! -//! Each vertex brightness is `(side1 + side2 + corner + center) * 0.25`, where -//! every term is `0.2` (full-collision block) or `1.0`. `corner` is the -//! in-plane diagonal, except when both sides occlude: vanilla then substitutes -//! `shade0`, the brightness of `AdjacencyInfo.corners[0]` (the face's first -//! edge neighbour), which gives concave corners their asymmetric brightness. -//! With an exposed face (`center = 1.0`) the value is one of four brightnesses. - -/// Vertex brightness by AO level; index 0 (three occluders) is darkest. -pub const AO_BRIGHTNESS: [f32; 4] = [0.4, 0.6, 0.8, 1.0]; - -#[inline] -fn occludes(shade: f32) -> bool { - shade < 0.5 -} - -/// Vanilla per-vertex AO level (`0`..=`3`, `0` darkest) for an exposed face. -/// -/// `shade0_occ` is whether the face's `corners[0]` neighbour occludes, used as -/// the diagonal fallback when both sides occlude. -#[inline] -pub fn vertex_ao_level(side1_occ: bool, side2_occ: bool, diag_occ: bool, shade0_occ: bool) -> u8 { - let corner_occ = if side1_occ && side2_occ { - shade0_occ - } else { - diag_occ - }; - 3 - (side1_occ as u8 + side2_occ as u8 + corner_occ as u8) -} - -/// [`vertex_ao_level`] as a brightness, for callers working in shade floats. -#[inline] -pub fn vertex_brightness(side1: f32, side2: f32, diag: f32, shade0: f32) -> f32 { - let level = vertex_ao_level( - occludes(side1), - occludes(side2), - occludes(diag), - occludes(shade0), - ); - AO_BRIGHTNESS[level as usize] -} diff --git a/pomme-client/src/renderer/chunk/block_lighter.rs b/pomme-client/src/renderer/chunk/block_lighter.rs new file mode 100644 index 000000000..2adcd2f90 --- /dev/null +++ b/pomme-client/src/renderer/chunk/block_lighter.rs @@ -0,0 +1,757 @@ +//! Vanilla `BlockModelLighter`: each terrain quad's per-vertex light +//! coordinates and shade, smooth (`prepareQuadAmbientOcclusion`) or flat +//! (`prepareQuadFlat`), with `LightCoordsUtil`'s blending. + +use crate::physics::block_shape::is_collision_shape_full_block; +use crate::world::block::BlockStateData; +use crate::world::block::model::{Direction, quad_bounds}; + +/// What the lighter reads from the world around the block it lights. +pub(super) trait LightSource { + fn state_data(&self, at: [i32; 3]) -> &BlockStateData; + /// The light engine's sky and block levels at a position. + fn levels(&self, at: [i32; 3]) -> LightLevels; +} + +/// `at` moved by `by`. +pub(super) fn offset(at: [i32; 3], by: [i32; 3]) -> [i32; 3] { + [at[0] + by[0], at[1] + by[1], at[2] + by[2]] +} + +/// A block's sky and block light levels, packed `sky << 4 | block`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct LightLevels(pub u8); + +impl LightLevels { + pub const FULL_BRIGHT: Self = Self::new(15, 15); + + pub const fn new(sky: u8, block: u8) -> Self { + Self(sky << 4 | block) + } + + pub fn block(self) -> u8 { + self.0 & 0xF + } + + pub fn sky(self) -> u8 { + self.0 >> 4 + } + + /// Vanilla `LightCoordsUtil.max`: each channel's brighter level. + pub fn max(self, other: Self) -> Self { + Self::new(self.sky().max(other.sky()), self.block().max(other.block())) + } + + /// Vanilla `lightCoordsWithEmission`: both channels raised to a quad's + /// own `light_emission`. + pub fn with_emission(self, emission: u8) -> Self { + if emission == 0 { + return self; + } + Self::new(self.sky().max(emission), self.block().max(emission)) + } +} + +/// A terrain vertex's light, as vanilla's BLOCK vertex carries it: the colour +/// factor (ambient occlusion × face shade) and the smooth block and sky light +/// coordinates, each 0..240. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct VertexLight { + pub shade: f32, + pub block: u8, + pub sky: u8, +} + +impl VertexLight { + /// A flat-lit vertex: `levels` at face shade `shade`. + pub fn flat(shade: f32, levels: LightLevels) -> Self { + Self { + shade, + block: levels.block() * 16, + sky: levels.sky() * 16, + } + } + + /// What the quad's diagonal balances. + pub fn brightness(self) -> f32 { + self.shade * f32::from(self.block.max(self.sky)) + } +} + +/// The quad being lit, in block-local coordinates. +pub(super) struct Quad<'a> { + pub positions: &'a [[f32; 3]; 4], + /// Vanilla `BakedQuad.direction()`. + pub direction: Direction, + /// The face whose cardinal shade applies; `None` for `shade: false`. + pub shade_face: Option, + /// The element's `light_emission`. + pub emission: u8, +} + +/// The light at a position, by the state there. +pub(super) fn light_coords(world: &impl LightSource, at: [i32; 3]) -> LightLevels { + light_coords_of(world.state_data(at), world, at) +} + +/// Vanilla `LightCoordsUtil.getLightCoords`: full bright when `data` renders +/// emissive, else the light engine's levels at the position with block light +/// raised to `data`'s emission. The flat path passes the lit block's own state +/// with its neighbour's position. +pub(super) fn light_coords_of( + data: &BlockStateData, + world: &impl LightSource, + at: [i32; 3], +) -> LightLevels { + if emissive_rendering(data) { + return LightLevels::FULL_BRIGHT; + } + let levels = world.levels(at); + let emission = data.light_props().emission; + if levels.block() < emission { + LightLevels::new(levels.sky(), emission) + } else { + levels + } +} + +/// Vanilla `Properties.emissiveRendering` predicates (`Blocks`): magma blocks, +/// and sculk sensors while active. +pub(super) fn emissive_rendering(data: &BlockStateData) -> bool { + match data.id() { + "magma_block" => true, + "sculk_sensor" | "calibrated_sculk_sensor" => { + data.properties().get("sculk_sensor_phase") == Some("active") + } + _ => false, + } +} + +/// Vanilla `getShadeBrightness`: 0.2 for a full collision cube, with the +/// blocks that override it. +pub(super) fn shade_brightness(data: &BlockStateData) -> f32 { + let id = data.id(); + match id { + "barrier" | "light" | "structure_void" | "glass" | "tinted_glass" => 1.0, + _ if id.ends_with("stained_glass") || id.ends_with("copper_grate") => 1.0, + "mud" | "soul_sand" => 0.2, + "snow" => { + if data.properties().get("layers") == Some("8") { + 0.2 + } else { + 1.0 + } + } + _ if is_collision_shape_full_block(data) => 0.2, + _ => 1.0, + } +} + +/// Vanilla `isSolidRender`: an occluding state whose shape fills the cell. +pub(super) fn is_solid_render(data: &BlockStateData) -> bool { + data.light_props().can_occlude && data.outline_boxes().is_none() +} + +/// Vanilla `isLightPermeable`. +fn is_light_permeable(data: &BlockStateData) -> bool { + !is_solid_render(data) || data.light_props().dampening == 0 +} + +/// Vanilla `SizeInfo`'s indices into the face shape. +#[derive(Clone, Copy)] +enum Size { + Down, + Up, + North, + South, + West, + East, + FlipDown, + FlipUp, + FlipNorth, + FlipSouth, + FlipWest, + FlipEast, +} + +/// Vanilla `prepareQuadShape`: whether the face lies on the block's boundary +/// (or the block is a full collision cube), whether it covers less than the +/// whole face, and its bounds for the partial-face weights. +struct Shape { + cubic: bool, + partial: bool, + bounds: [f32; 12], +} + +impl Shape { + fn new(quad: &Quad, data: &BlockStateData) -> Self { + let ([min_x, min_y, min_z], [max_x, max_y, max_z]) = quad_bounds(quad.positions); + const MIN: f32 = 1.0e-4; + const MAX: f32 = 0.9999; + let partial = match quad.direction { + Direction::Down | Direction::Up => { + min_x >= MIN || min_z >= MIN || max_x <= MAX || max_z <= MAX + } + Direction::North | Direction::South => { + min_x >= MIN || min_y >= MIN || max_x <= MAX || max_y <= MAX + } + Direction::West | Direction::East => { + min_y >= MIN || min_z >= MIN || max_y <= MAX || max_z <= MAX + } + }; + let full = || is_collision_shape_full_block(data); + let cubic = match quad.direction { + Direction::Down => min_y == max_y && (min_y < MIN || full()), + Direction::Up => min_y == max_y && (max_y > MAX || full()), + Direction::North => min_z == max_z && (min_z < MIN || full()), + Direction::South => min_z == max_z && (max_z > MAX || full()), + Direction::West => min_x == max_x && (min_x < MIN || full()), + Direction::East => min_x == max_x && (max_x > MAX || full()), + }; + let mut bounds = [0.0; 12]; + for (size, value) in [ + (Size::West, min_x), + (Size::East, max_x), + (Size::Down, min_y), + (Size::Up, max_y), + (Size::North, min_z), + (Size::South, max_z), + (Size::FlipWest, 1.0 - min_x), + (Size::FlipEast, 1.0 - max_x), + (Size::FlipDown, 1.0 - min_y), + (Size::FlipUp, 1.0 - max_y), + (Size::FlipNorth, 1.0 - min_z), + (Size::FlipSouth, 1.0 - max_z), + ] { + bounds[size as usize] = value; + } + Self { + cubic, + partial, + bounds, + } + } + + fn weight(&self, [a, b]: [Size; 2]) -> f32 { + self.bounds[a as usize] * self.bounds[b as usize] + } +} + +/// Vanilla `AdjacencyInfo`: the face's four edge neighbours and, per vanilla +/// vertex, the bound pairs weighting a partial face. +struct Adjacency { + corners: [Direction; 4], + weights: [[[Size; 2]; 4]; 4], +} + +fn adjacency(direction: Direction) -> Adjacency { + use Direction as D; + use Size::*; + let (corners, weights) = match direction { + D::Down => ( + [D::West, D::East, D::North, D::South], + [ + [ + [FlipWest, South], + [FlipWest, FlipSouth], + [West, FlipSouth], + [West, South], + ], + [ + [FlipWest, North], + [FlipWest, FlipNorth], + [West, FlipNorth], + [West, North], + ], + [ + [FlipEast, North], + [FlipEast, FlipNorth], + [East, FlipNorth], + [East, North], + ], + [ + [FlipEast, South], + [FlipEast, FlipSouth], + [East, FlipSouth], + [East, South], + ], + ], + ), + D::Up => ( + [D::East, D::West, D::North, D::South], + [ + [ + [East, South], + [East, FlipSouth], + [FlipEast, FlipSouth], + [FlipEast, South], + ], + [ + [East, North], + [East, FlipNorth], + [FlipEast, FlipNorth], + [FlipEast, North], + ], + [ + [West, North], + [West, FlipNorth], + [FlipWest, FlipNorth], + [FlipWest, North], + ], + [ + [West, South], + [West, FlipSouth], + [FlipWest, FlipSouth], + [FlipWest, South], + ], + ], + ), + D::North => ( + [D::Up, D::Down, D::East, D::West], + [ + [ + [Up, FlipWest], + [Up, West], + [FlipUp, West], + [FlipUp, FlipWest], + ], + [ + [Up, FlipEast], + [Up, East], + [FlipUp, East], + [FlipUp, FlipEast], + ], + [ + [Down, FlipEast], + [Down, East], + [FlipDown, East], + [FlipDown, FlipEast], + ], + [ + [Down, FlipWest], + [Down, West], + [FlipDown, West], + [FlipDown, FlipWest], + ], + ], + ), + D::South => ( + [D::West, D::East, D::Down, D::Up], + [ + [ + [Up, FlipWest], + [FlipUp, FlipWest], + [FlipUp, West], + [Up, West], + ], + [ + [Down, FlipWest], + [FlipDown, FlipWest], + [FlipDown, West], + [Down, West], + ], + [ + [Down, FlipEast], + [FlipDown, FlipEast], + [FlipDown, East], + [Down, East], + ], + [ + [Up, FlipEast], + [FlipUp, FlipEast], + [FlipUp, East], + [Up, East], + ], + ], + ), + D::West => ( + [D::Up, D::Down, D::North, D::South], + [ + [ + [Up, South], + [Up, FlipSouth], + [FlipUp, FlipSouth], + [FlipUp, South], + ], + [ + [Up, North], + [Up, FlipNorth], + [FlipUp, FlipNorth], + [FlipUp, North], + ], + [ + [Down, North], + [Down, FlipNorth], + [FlipDown, FlipNorth], + [FlipDown, North], + ], + [ + [Down, South], + [Down, FlipSouth], + [FlipDown, FlipSouth], + [FlipDown, South], + ], + ], + ), + D::East => ( + [D::Down, D::Up, D::North, D::South], + [ + [ + [FlipDown, South], + [FlipDown, FlipSouth], + [Down, FlipSouth], + [Down, South], + ], + [ + [FlipDown, North], + [FlipDown, FlipNorth], + [Down, FlipNorth], + [Down, North], + ], + [ + [FlipUp, North], + [FlipUp, FlipNorth], + [Up, FlipNorth], + [Up, North], + ], + [ + [FlipUp, South], + [FlipUp, FlipSouth], + [Up, FlipSouth], + [Up, South], + ], + ], + ), + }; + Adjacency { corners, weights } +} + +/// Vanilla `AmbientVertexRemap`: the quad vertex each computed vertex goes to. +fn remap(direction: Direction) -> [usize; 4] { + match direction { + Direction::Down | Direction::South => [0, 1, 2, 3], + Direction::Up => [2, 3, 0, 1], + Direction::North | Direction::West => [3, 0, 1, 2], + Direction::East => [1, 2, 3, 0], + } +} + +/// Smooth light coordinates, each channel 0..240. +#[derive(Clone, Copy)] +struct Smooth { + block: f32, + sky: f32, +} + +impl Smooth { + /// Vanilla `lightCoordsWithEmission` on smooth coordinates: an emitting + /// quad's channels become whole levels, raised to its emission. + fn with_emission(self, emission: u8) -> Self { + if emission == 0 { + return self; + } + let level = |v: f32| ((v as u8 >> 4).max(emission) * 16) as f32; + Self { + block: level(self.block), + sky: level(self.sky), + } + } +} + +/// Vanilla `LightCoordsUtil.smoothBlend`: around a lit centre, a neighbour +/// that's dark (both channels 0) takes the centre's light, and one without +/// sky light takes the centre's sky; then the four average. +fn smooth_blend(neighbours: [LightLevels; 3], center: LightLevels) -> Smooth { + let lit = center.sky() > 2 || center.block() > 2; + let mut block = u32::from(center.block()); + let mut sky = u32::from(center.sky()); + for mut n in neighbours { + if lit { + if n.0 == 0 { + n = center; + } else if n.sky() == 0 { + n = LightLevels::new(center.sky(), n.block()); + } + } + block += u32::from(n.block()); + sky += u32::from(n.sky()); + } + // Each level is 16 coordinate units; `>> 2` averages the four. + Smooth { + block: ((block * 16) >> 2) as f32, + sky: ((sky * 16) >> 2) as f32, + } +} + +/// Vanilla `prepareQuadAmbientOcclusion`; `cardinal` is the face shade +/// (`getDirectionalBrightness`). +pub(super) fn ambient_occlusion( + world: &impl LightSource, + cardinal: impl Fn(Option) -> f32, + pos: [i32; 3], + quad: &Quad, +) -> [VertexLight; 4] { + let shape = Shape::new(quad, world.state_data(pos)); + let dir = quad.direction.offset(); + let next = offset(pos, dir); + let base = if shape.cubic { next } else { pos }; + let info = adjacency(quad.direction); + let corner = info.corners.map(|d| d.offset()); + let sample = |at: [i32; 3]| { + ( + light_coords(world, at), + shade_brightness(world.state_data(at)), + ) + }; + let edges = corner.map(|c| sample(offset(base, c))); + let permeable = + corner.map(|c| is_light_permeable(world.state_data(offset(offset(base, c), dir)))); + // Vanilla falls back to edge 0 for every unreachable diagonal. + let diagonal = |a: usize, b: usize| { + if permeable[b] || permeable[a] { + sample(offset(offset(base, corner[a]), corner[b])) + } else { + edges[0] + } + }; + let c02 = diagonal(0, 2); + let c03 = diagonal(0, 3); + let c12 = diagonal(1, 2); + let c13 = diagonal(1, 3); + + let center = if shape.cubic || !is_solid_render(world.state_data(next)) { + next + } else { + pos + }; + let light_center = light_coords(world, center); + let shade_center = shade_brightness(world.state_data(base)); + + // Vanilla's vertices 0..3: edges (3, 0), (2, 0), (2, 1), (3, 1) and the + // diagonal between each pair. + let corners = [ + (edges[3], edges[0], c03), + (edges[2], edges[0], c02), + (edges[2], edges[1], c12), + (edges[3], edges[1], c13), + ]; + let shades = corners.map(|(a, b, c)| (a.1 + b.1 + c.1 + shade_center) * 0.25); + let lights = corners + .map(|(a, b, c)| smooth_blend([a.0, b.0, c.0], light_center).with_emission(quad.emission)); + let face_shade = cardinal(quad.shade_face); + let mut out = [VertexLight::flat(0.0, LightLevels(0)); 4]; + for (vertex, &target) in remap(quad.direction).iter().enumerate() { + let (shade, light) = if shape.partial { + let weights = info.weights[vertex].map(|pair| shape.weight(pair)); + let weighted = |value: &dyn Fn(usize) -> f32| -> f32 { + (0..4).map(|i| value(i) * weights[i]).sum() + }; + ( + weighted(&|i| shades[i]).clamp(0.0, 1.0), + Smooth { + block: weighted(&|i| lights[i].block).trunc(), + sky: weighted(&|i| lights[i].sky).trunc(), + }, + ) + } else { + (shades[vertex], lights[vertex]) + }; + out[target] = VertexLight { + shade: shade * face_shade, + block: light.block as u8, + sky: light.sky as u8, + }; + } + out +} + +/// Vanilla `prepareQuadFlat`: `culled` is a culled quad's light (see +/// [`light_coords_of`] at its neighbour); an unculled quad reads its neighbour +/// when it lies on the block's boundary, else the block itself. +pub(super) fn flat( + world: &impl LightSource, + cardinal: impl Fn(Option) -> f32, + pos: [i32; 3], + quad: &Quad, + culled: Option, +) -> [VertexLight; 4] { + let state = world.state_data(pos); + let levels = culled.unwrap_or_else(|| { + let at = if Shape::new(quad, state).cubic { + offset(pos, quad.direction.offset()) + } else { + pos + }; + light_coords_of(state, world, at) + }); + [VertexLight::flat( + cardinal(quad.shade_face), + levels.with_emission(quad.emission), + ); 4] +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::world::block::BlockStateTable; + + /// A block name and its properties. + type Block = (&'static str, Vec<(&'static str, &'static str)>); + + /// A world of named blocks, air elsewhere, and their light levels. + struct World { + table: BlockStateTable, + blocks: HashMap<[i32; 3], Block>, + light: HashMap<[i32; 3], LightLevels>, + default_light: LightLevels, + } + + impl World { + fn new(default_light: LightLevels) -> Self { + Self { + table: BlockStateTable::native(), + blocks: HashMap::new(), + light: HashMap::new(), + default_light, + } + } + + fn set(&mut self, at: [i32; 3], name: &'static str) { + self.blocks.insert(at, (name, Vec::new())); + self.light.insert(at, LightLevels(0)); + } + } + + impl LightSource for World { + fn state_data(&self, at: [i32; 3]) -> &BlockStateData { + let (name, props) = self.blocks.get(&at).cloned().unwrap_or(("air", Vec::new())); + self.table + .block_state_data(self.table.find_state(name, &props)) + } + + fn levels(&self, at: [i32; 3]) -> LightLevels { + self.light.get(&at).copied().unwrap_or(self.default_light) + } + } + + fn up_face() -> [[f32; 3]; 4] { + crate::world::block::model::face_positions(Direction::Up, [0.0; 3], [1.0; 3]) + } + + fn quad(positions: &[[f32; 3]; 4], direction: Direction) -> Quad<'_> { + Quad { + positions, + direction, + shade_face: Some(direction), + emission: 0, + } + } + + #[test] + fn a_wall_beside_a_lit_floor_takes_the_floors_light() { + // A stone floor at y 0 in torchlight, a stone wall along x 1 whose + // inside is dark: `smoothBlend` swaps the wall's zeros for the centre. + let mut world = World::new(LightLevels::new(0, 14)); + world.set([0, 0, 0], "stone"); + for z in -1..=1 { + world.set([1, 1, z], "stone"); + } + let positions = up_face(); + let lights = + ambient_occlusion(&world, |_| 1.0, [0, 0, 0], &quad(&positions, Direction::Up)); + for light in lights { + assert_eq!(light.block, 224, "{lights:?}"); + } + } + + #[test] + fn smooth_blend_keeps_a_dark_centre_dark() { + let blended = smooth_blend([LightLevels(0); 3], LightLevels::new(0, 2)); + assert_eq!(blended.block, 8.0); + } + + #[test] + fn light_giving_and_emissive_blocks_light_their_faces_fully() { + let world = { + let mut world = World::new(LightLevels(0)); + world.set([0, 0, 0], "glowstone"); + world.set([5, 0, 0], "magma_block"); + world + }; + let positions = up_face(); + let glowstone = flat( + &world, + |_| 1.0, + [0, 0, 0], + &quad(&positions, Direction::Up), + None, + ); + assert_eq!((glowstone[0].block, glowstone[0].sky), (240, 0)); + let magma = flat( + &world, + |_| 1.0, + [5, 0, 0], + &quad(&positions, Direction::Up), + None, + ); + assert_eq!((magma[0].block, magma[0].sky), (240, 240)); + } + + #[test] + fn a_full_cube_face_reads_its_neighbour_and_a_slab_top_itself() { + let mut world = World::new(LightLevels(0)); + world.set([0, 0, 0], "stone"); + world.light.insert([0, 1, 0], LightLevels::new(15, 0)); + world + .blocks + .insert([0, 0, 2], ("smooth_stone_slab", vec![("type", "bottom")])); + world.light.insert([0, 0, 2], LightLevels::new(9, 0)); + let positions = up_face(); + let cube = flat( + &world, + |_| 1.0, + [0, 0, 0], + &quad(&positions, Direction::Up), + None, + ); + assert_eq!(cube[0].sky, 240); + let slab_top = + crate::world::block::model::face_positions(Direction::Up, [0.0; 3], [1.0, 0.5, 1.0]); + let slab = flat( + &world, + |_| 1.0, + [0, 0, 2], + &quad(&slab_top, Direction::Up), + None, + ); + assert_eq!(slab[0].sky, 144); + } + + #[test] + fn shade_brightness_follows_vanillas_overrides() { + let world = World::new(LightLevels(0)); + let data = |name, props: &[(&str, &str)]| { + world + .table + .block_state_data(world.table.find_state(name, props)) + }; + assert_eq!(shade_brightness(data("stone", &[])), 0.2); + assert_eq!(shade_brightness(data("glass", &[])), 1.0); + assert_eq!(shade_brightness(data("air", &[])), 1.0); + assert_eq!(shade_brightness(data("snow", &[("layers", "8")])), 0.2); + assert_eq!(shade_brightness(data("snow", &[("layers", "1")])), 1.0); + assert_eq!(shade_brightness(data("soul_sand", &[])), 0.2); + } + + #[test] + fn quad_emission_rounds_to_whole_levels() { + let raised = Smooth { + block: 100.0, + sky: 20.0, + } + .with_emission(7); + assert_eq!((raised.block, raised.sky), (112.0, 112.0)); + } +} diff --git a/pomme-client/src/renderer/chunk/mesher.rs b/pomme-client/src/renderer/chunk/mesher.rs index c0420db3b..55425543c 100644 --- a/pomme-client/src/renderer/chunk/mesher.rs +++ b/pomme-client/src/renderer/chunk/mesher.rs @@ -4,13 +4,12 @@ use std::sync::{Arc, Condvar, Mutex}; use azalea_block::BlockState; +use super::block_lighter::{self, LightLevels, LightSource, VertexLight}; use super::occlusion_graph::{VisibilitySet, compute_visibility}; use crate::position::ChunkPos; use crate::renderer::chunk::atlas::{AtlasRegion, AtlasUVMap}; use crate::renderer::rhi; -use crate::world::block::model::{ - BakedModel, CardinalLighting, Direction, face_positions, face_uvs, -}; +use crate::world::block::model::{CardinalLighting, Direction, face_positions, face_uvs}; use crate::world::block::render_assets::{BlockRenderView, OwnedBlockRenderView, Tint}; use crate::world::block::{BlockStateData, BlockStateTable, FluidKind}; use crate::world::chunk; @@ -31,7 +30,9 @@ struct TerrainVertex { sprite_uv: [f32; 2], /// `AtlasRegion::sprite`, resolved to a rectangle in the fragment shader. sprite: u16, - light_tint: u32, + light: VertexLight, + /// The quad's tint, as [`pack_tint_shifted`] packs it. + tint: u32, } impl ChunkVertex { @@ -42,9 +43,10 @@ impl ChunkVertex { include!("packing_consts.rs"); /// Compact terrain GPU vertex (16 bytes). `uv` packs the sprite-local UV as -/// two [`TERRAIN_UV_BITS`]-bit fixed-point axes, u low and v above it; its top -/// byte is reserved and zero. `sprite` indexes the atlas's rectangle buffer, -/// which the shader resolves the UV inside, avoiding atlas-boundary rounding. +/// two [`TERRAIN_UV_BITS`]-bit fixed-point axes, u low and v above it, and sky +/// light in its top byte. `sprite` indexes the atlas's rectangle buffer, which +/// the shader resolves the UV inside, avoiding atlas-boundary rounding. +/// `light_tint` is block light, then the RGB colour (tint × AO × face shade). #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct PackedVertex { @@ -93,8 +95,14 @@ fn pack_vertex(v: &TerrainVertex) -> PackedVertex { quantize_coord(v.position[2]), ], sprite: v.sprite, - uv: pack_sprite_uv(v.sprite_uv[0]) | pack_sprite_uv(v.sprite_uv[1]) << TERRAIN_UV_BITS, - light_tint: v.light_tint.to_le_bytes(), + uv: pack_sprite_uv(v.sprite_uv[0]) + | pack_sprite_uv(v.sprite_uv[1]) << TERRAIN_UV_BITS + | u32::from(v.light.sky) << 24, + light_tint: { + let [_, r, g, b] = v.tint.to_le_bytes(); + let shaded = |c: u8| (f32::from(c) * v.light.shade + 0.5) as u8; + [v.light.block, shaded(r), shaded(g), shaded(b)] + }, } } @@ -1447,6 +1455,17 @@ impl BlockSlice { } } +impl LightSource for ChunkStoreSnapshot { + fn state_data(&self, [x, y, z]: [i32; 3]) -> &BlockStateData { + self.block_state_table + .block_state_data(self.get_block_state(x, y, z)) + } + + fn levels(&self, [x, y, z]: [i32; 3]) -> LightLevels { + self.get_light(x, y, z) + } +} + impl ChunkStoreSnapshot { /// Vanilla `BlockModelLighter`: an unshaded face takes the table's up /// value, which is the brightest in both tables. @@ -1575,23 +1594,29 @@ impl ChunkStoreSnapshot { blend_color(x, z, |bx, bz| self.water_color_at(bx, y, bz)) } - fn get_light(&self, x: i32, y: i32, z: i32) -> f32 { - let level = || { + /// The block's light levels; a column without light data reads as open + /// sky. + fn get_light(&self, x: i32, y: i32, z: i32) -> LightLevels { + let levels = || { let lx = x.rem_euclid(16); let lz = z.rem_euclid(16); match self.light.get(&(x.div_euclid(16), z.div_euclid(16))) { - Some(light) => light - .get_sky_light(lx, y, lz) - .max(light.get_block_light(lx, y, lz)), - None => 15, + Some(light) => { + LightLevels::new( + light.get_sky_light(lx, y, lz), + light.get_block_light(lx, y, lz), + ) + .0 + } + None => LightLevels::new(15, 0).0, } }; - let level = self - .light_levels - .as_ref() - .and_then(|levels| levels.get_or_insert_with([x, y, z], level)) - .unwrap_or_else(level); - LIGHT_TABLE[level as usize] + LightLevels( + self.light_levels + .as_ref() + .and_then(|cache| cache.get_or_insert_with([x, y, z], levels)) + .unwrap_or_else(levels), + ) } } @@ -1804,12 +1829,30 @@ fn emit_block_model( bz: i32, ) -> bool { if let Some(baked) = registry.get_baked_model(state) { - emit_baked_model( - sink, model_pos, state, baked, snapshot, registry, uv_map, bx, by, bz, + emit_quads( + sink, + model_pos, + state, + baked.quads.iter(), + snapshot, + registry, + uv_map, + bx, + by, + bz, ); } else if let Some(quads) = registry.get_multipart_quads(state) { - emit_multipart( - sink, model_pos, state, &quads, snapshot, registry, uv_map, bx, by, bz, + emit_quads( + sink, + model_pos, + state, + quads.iter().copied(), + snapshot, + registry, + uv_map, + bx, + by, + bz, ); } else if let Some(textures) = registry.get_textures(state) { emit_cube_faces( @@ -1822,12 +1865,63 @@ fn emit_block_model( true } +/// Vanilla `ModelBlockRenderer.tesselateBlock`'s choice: smooth lighting +/// unless the block gives off light or its model (the first part, for a +/// multipart) opts out of ambient occlusion. +fn smooth_lighting( + snapshot: &ChunkStoreSnapshot, + state: BlockState, + ambient_occlusion: bool, +) -> bool { + ambient_occlusion + && snapshot + .block_state_table + .block_state_data(state) + .light_props() + .emission + == 0 +} + +/// One quad's vertex light, through [`block_lighter`]: smooth, or flat from +/// its culled neighbour ([`neighbour_light`]). +fn quad_light( + snapshot: &ChunkStoreSnapshot, + smooth: bool, + [bx, by, bz]: [i32; 3], + positions: &[[f32; 3]; 4], + shade_face: Option, + cullface: Option, + emission: u8, +) -> [VertexLight; 4] { + let quad = block_lighter::Quad { + positions, + direction: crate::world::block::model::direction_from_positions(positions) + .unwrap_or(Direction::Up), + shade_face, + emission, + }; + let shade = |face| snapshot.shade(face); + if smooth { + return block_lighter::ambient_occlusion(snapshot, shade, [bx, by, bz], &quad); + } + let culled = cullface.map(|dir| neighbour_light(snapshot, [bx, by, bz], dir)); + block_lighter::flat(snapshot, shade, [bx, by, bz], &quad, culled) +} + +/// The light a flat face toward `dir` takes: its neighbour's levels, read +/// with the block's own state, as vanilla's `tesselateFlat` does. +fn neighbour_light(snapshot: &ChunkStoreSnapshot, pos: [i32; 3], dir: Direction) -> LightLevels { + let at = block_lighter::offset(pos, dir.offset()); + block_lighter::light_coords_of(snapshot.state_data(pos), snapshot, at) +} + +/// A baked model's or multipart's quads, culled against their neighbours. #[allow(clippy::too_many_arguments)] -fn emit_baked_model( +fn emit_quads<'q>( sink: &mut MeshSink, block_pos: [f32; 3], state: azalea_block::BlockState, - model: &BakedModel, + quads: impl Iterator + Clone, snapshot: &ChunkStoreSnapshot, registry: BlockRenderView<'_>, uv_map: &AtlasUVMap, @@ -1835,7 +1929,12 @@ fn emit_baked_model( by: i32, bz: i32, ) { - for quad in &model.quads { + let first_ao = quads + .clone() + .next() + .is_some_and(|quad| quad.ambient_occlusion); + let smooth = smooth_lighting(snapshot, state, first_ao); + for quad in quads { if let Some(cullface) = quad.cullface { let offset = cullface.offset(); let neighbor = snapshot.get_block_state(bx + offset[0], by + offset[1], bz + offset[2]); @@ -1846,11 +1945,15 @@ fn emit_baked_model( let region = uv_map.region(quad.texture); let tint = tint_color(quad.tint, snapshot, state, bx, by, bz); - let lights = if let Some(dir) = quad.cullface { - compute_face_ao(snapshot, registry, bx, by, bz, dir, quad.shade_face) - } else { - [snapshot.shade(quad.shade_face); 4] - }; + let lights = quad_light( + snapshot, + smooth, + [bx, by, bz], + &quad.positions, + quad.shade_face, + quad.cullface, + quad.emission, + ); emit_face( sink, block_pos, @@ -1877,6 +1980,7 @@ fn emit_cube_faces( bz: i32, ) { let tint = tint_color(textures.tint, snapshot, state, bx, by, bz); + let smooth = smooth_lighting(snapshot, state, true); for (i, dir) in CUBE_FACE_DIRS.iter().enumerate() { let offset = dir.offset(); @@ -1895,7 +1999,15 @@ fn emit_cube_faces( }; let region = uv_map.get_region(face_tex); let (positions, uvs) = cube_face_geometry(*dir); - let lights = compute_face_ao(snapshot, registry, bx, by, bz, *dir, Some(*dir)); + let lights = quad_light( + snapshot, + smooth, + [bx, by, bz], + &positions, + Some(*dir), + Some(*dir), + 0, + ); let is_side = i >= 2; if let Some(overlay) = textures.side_overlay.as_deref().filter(|_| is_side) { @@ -2138,6 +2250,9 @@ fn emit_fluid( ) { let (region, tint) = block_face_tex_tint(state, Direction::Up, uv_map, snapshot, registry, bx, by, bz); + // TODO: vanilla `FluidRenderer`'s per-face light positions. + let fluid_light = block_lighter::light_coords(snapshot, [bx, by, bz]) + .max(block_lighter::light_coords(snapshot, [bx, by + 1, bz])); // Water is translucent (separate blended pass); lava is opaque. let MeshSink { @@ -2193,7 +2308,7 @@ fn emit_fluid( } let (mut positions, uvs) = cube_face_geometry(*dir); - let light = snapshot.cardinal_lighting.by_face(*dir); + let light = [VertexLight::flat(snapshot.cardinal_lighting.by_face(*dir), fluid_light); 4]; match dir { Direction::Up => { if !render_up { @@ -2205,7 +2320,7 @@ fn emit_fluid( positions[3][1] = north_east; emit_face_into( - vertices, indices, block_pos, &positions, &uvs, [light; 4], region, tint, + vertices, indices, block_pos, &positions, &uvs, light, region, tint, ); // TODO: gate on `FluidState.shouldRenderBackwardUpFace`. @@ -2217,7 +2332,7 @@ fn emit_fluid( block_pos, &rev_positions, &rev_uvs, - [light; 4], + light, region, tint, ); @@ -2259,43 +2374,7 @@ fn emit_fluid( } emit_face_into( - vertices, indices, block_pos, &positions, &uvs, [light; 4], region, tint, - ); - } -} - -#[allow(clippy::too_many_arguments)] -fn emit_multipart( - sink: &mut MeshSink, - block_pos: [f32; 3], - state: azalea_block::BlockState, - quads: &[&crate::world::block::model::BakedQuad], - snapshot: &ChunkStoreSnapshot, - registry: BlockRenderView<'_>, - uv_map: &AtlasUVMap, - bx: i32, - by: i32, - bz: i32, -) { - for quad in quads { - if let Some(cullface) = quad.cullface { - let offset = cullface.offset(); - let neighbor = snapshot.get_block_state(bx + offset[0], by + offset[1], bz + offset[2]); - if registry.occludes_neighbor(neighbor) { - continue; - } - } - - let region = uv_map.region(quad.texture); - let tint = tint_color(quad.tint, snapshot, state, bx, by, bz); - emit_face( - sink, - block_pos, - &quad.positions, - &quad.uvs, - [snapshot.shade(quad.shade_face); 4], - region, - tint, + vertices, indices, block_pos, &positions, &uvs, light, region, tint, ); } } @@ -2354,7 +2433,14 @@ fn emit_lod_cube( block_face_tex_tint(state, *dir, uv_map, snapshot, registry, bx, by, bz); let (positions, uvs) = cube_face_geometry(*dir); - let light = snapshot.cardinal_lighting.by_face(*dir); + let light = VertexLight::flat( + snapshot.cardinal_lighting.by_face(*dir), + block_lighter::light_coords_of( + snapshot.state_data([bx, by, bz]), + snapshot, + [nx, ny, nz], + ), + ); let s = step as f32; let sy = if is_fluid { fluid_top } else { s }; let base = sink.vertices.len() as u32; @@ -2367,7 +2453,8 @@ fn emit_lod_cube( ], sprite_uv: uvs[i], sprite: region.sprite, - light_tint: pack_light_tint(light, tint), + light, + tint, }); } sink.indices_for(region.opaque).extend_from_slice(&[ @@ -2403,7 +2490,10 @@ fn emit_missing_cube( } let (positions, uvs) = cube_face_geometry(*dir); - let light = snapshot.cardinal_lighting.by_face(*dir); + let light = VertexLight::flat( + snapshot.cardinal_lighting.by_face(*dir), + neighbour_light(snapshot, [bx, by, bz], *dir), + ); let base = sink.vertices.len() as u32; for (pos, uv) in positions.iter().zip(uvs) { sink.vertices.push(TerrainVertex { @@ -2414,7 +2504,8 @@ fn emit_missing_cube( ], sprite_uv: uv, sprite: missing.sprite, - light_tint: pack_light_tint(light, MISSING_TINT), + light, + tint: MISSING_TINT, }); } // The missing tile is a solid checker, so the cube goes in the solid pass. @@ -2440,7 +2531,7 @@ fn emit_face( block_pos: [f32; 3], positions: &[[f32; 3]; 4], uvs: &[[f32; 2]; 4], - lights: [f32; 4], + lights: [VertexLight; 4], region: AtlasRegion, tint: u32, ) { @@ -2464,7 +2555,7 @@ fn emit_face_into( block_pos: [f32; 3], positions: &[[f32; 3]; 4], uvs: &[[f32; 2]; 4], - lights: [f32; 4], + lights: [VertexLight; 4], region: AtlasRegion, tint: u32, ) { @@ -2478,147 +2569,19 @@ fn emit_face_into( ], sprite_uv: uvs[i], sprite: region.sprite, - light_tint: pack_light_tint(lights[i], tint), + light: lights[i], + tint, }); } - if lights[0] + lights[2] > lights[1] + lights[3] { + let brightness = lights.map(VertexLight::brightness); + if brightness[0] + brightness[2] > brightness[1] + brightness[3] { indices.extend_from_slice(&[base + 1, base + 2, base + 3, base + 3, base, base + 1]); } else { indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 3, base]); } } -fn shade_brightness(state: azalea_block::BlockState, registry: BlockRenderView<'_>) -> f32 { - // TODO: non-occluding full cubes (leaves, glass, ice) still darken adjacent - // faces here. Vanilla's are `isViewBlocking=never` and don't contribute AO. - if registry.is_opaque_full_cube(state) { - 0.2 - } else { - 1.0 - } -} - -/// Centre-relative offset of vanilla's `AdjacencyInfo.corners[0]` neighbour -/// (`centre + dir + corners[0]`), the `shade0` occlusion fallback. -fn corners0_offset(dir: Direction) -> [i32; 3] { - match dir { - // corners[0] = EAST(+x) - Direction::Up => [1, 1, 0], - // corners[0] = WEST(-x) - Direction::Down => [-1, -1, 0], - // corners[0] = UP(+y) - Direction::North => [0, 1, -1], - // corners[0] = WEST(-x) - Direction::South => [-1, 0, 1], - // corners[0] = UP(+y) - Direction::West => [-1, 1, 0], - // corners[0] = DOWN(-y) - Direction::East => [1, -1, 0], - } -} - -/// Per-vertex brightness of `dir`'s face: ambient occlusion, sampled light and -/// the face's cardinal shade, where `shade_face` is `None` for a model element -/// with `shade: false`. -#[allow(clippy::too_many_arguments)] -fn compute_face_ao( - snapshot: &ChunkStoreSnapshot, - registry: BlockRenderView<'_>, - bx: i32, - by: i32, - bz: i32, - dir: Direction, - shade_face: Option, -) -> [f32; 4] { - // Every sample lies in the face's 3×3 neighbour plane (`n` plus in-plane - // offsets), so read each of its cells once. - let n = dir.offset(); - let normal_axis = n.iter().position(|&c| c != 0).expect("unit offset"); - let [a, b] = match normal_axis { - 0 => [1, 2], - 1 => [0, 2], - _ => [0, 1], - }; - let cell = |o: [i32; 3]| ((o[a] + 1) * 3 + o[b] + 1) as usize; - let offset = |i: usize| { - let mut o = n; - o[a] = i as i32 / 3 - 1; - o[b] = i as i32 % 3 - 1; - o - }; - let at = |[dx, dy, dz]: [i32; 3]| [bx + dx, by + dy, bz + dz]; - let shades: [f32; 9] = std::array::from_fn(|i| { - let [x, y, z] = at(offset(i)); - // The centre (`n` itself) never feeds AO. - if i == 4 { - 1.0 - } else { - shade_brightness(snapshot.get_block_state(x, y, z), registry) - } - }); - let lights: [f32; 9] = std::array::from_fn(|i| { - let [x, y, z] = at(offset(i)); - snapshot.get_light(x, y, z) - }); - let s = |o: [i32; 3]| shades[cell(o)]; - let l = |o: [i32; 3]| lights[cell(o)]; - - let shade0 = s(corners0_offset(dir)); - - // Each vertex's (side1, side2, corner) neighbour offsets, in - // `face_positions`' vertex order. - let rows: [[[i32; 3]; 3]; 4] = match dir { - Direction::Up => [ - [[0, 1, -1], [-1, 1, 0], [-1, 1, -1]], - [[0, 1, 1], [-1, 1, 0], [-1, 1, 1]], - [[0, 1, 1], [1, 1, 0], [1, 1, 1]], - [[0, 1, -1], [1, 1, 0], [1, 1, -1]], - ], - Direction::Down => [ - [[0, -1, 1], [-1, -1, 0], [-1, -1, 1]], - [[0, -1, -1], [-1, -1, 0], [-1, -1, -1]], - [[0, -1, -1], [1, -1, 0], [1, -1, -1]], - [[0, -1, 1], [1, -1, 0], [1, -1, 1]], - ], - Direction::North => [ - [[1, 0, -1], [0, 1, -1], [1, 1, -1]], - [[1, 0, -1], [0, -1, -1], [1, -1, -1]], - [[-1, 0, -1], [0, -1, -1], [-1, -1, -1]], - [[-1, 0, -1], [0, 1, -1], [-1, 1, -1]], - ], - Direction::South => [ - [[-1, 0, 1], [0, 1, 1], [-1, 1, 1]], - [[-1, 0, 1], [0, -1, 1], [-1, -1, 1]], - [[1, 0, 1], [0, -1, 1], [1, -1, 1]], - [[1, 0, 1], [0, 1, 1], [1, 1, 1]], - ], - Direction::West => [ - [[-1, 0, -1], [-1, 1, 0], [-1, 1, -1]], - [[-1, 0, -1], [-1, -1, 0], [-1, -1, -1]], - [[-1, 0, 1], [-1, -1, 0], [-1, -1, 1]], - [[-1, 0, 1], [-1, 1, 0], [-1, 1, 1]], - ], - Direction::East => [ - [[1, 0, 1], [1, 1, 0], [1, 1, 1]], - [[1, 0, 1], [1, -1, 0], [1, -1, 1]], - [[1, 0, -1], [1, -1, 0], [1, -1, -1]], - [[1, 0, -1], [1, 1, 0], [1, 1, -1]], - ], - }; - - let dir_shade = snapshot.shade(shade_face); - rows.map(|[side1, side2, corner]| { - let ao = super::block_ao::vertex_brightness(s(side1), s(side2), s(corner), shade0); - let light = avg4(l(n), l(side1), l(side2), l(corner)); - ao * light * dir_shade - }) -} - -fn avg4(a: f32, b: f32, c: f32, d: f32) -> f32 { - (a + b + c + d) * 0.25 -} - pub(crate) fn cube_face_geometry(dir: Direction) -> ([[f32; 3]; 4], [[f32; 2]; 4]) { let (from, to) = ([0.0; 3], [1.0; 3]); ( @@ -2708,14 +2671,17 @@ mod fluid_height_tests { #[cfg(test)] mod terrain_uv_tests { - use super::{PackedVertex, TERRAIN_UV_FIXED_SCALE, TerrainVertex, pack_vertex}; + use super::{ + LightLevels, PackedVertex, TERRAIN_UV_FIXED_SCALE, TerrainVertex, VertexLight, pack_vertex, + }; fn packed(sprite_uv: [f32; 2]) -> PackedVertex { pack_vertex(&TerrainVertex { position: [0.0; 3], sprite_uv, sprite: 0, - light_tint: 0, + light: VertexLight::flat(1.0, LightLevels::new(0, 0)), + tint: 0, }) } @@ -2749,12 +2715,25 @@ mod terrain_uv_tests { } #[test] - fn u_packs_low_v_above_and_the_top_byte_stays_free() { + fn u_packs_low_v_above_and_sky_light_on_top() { assert_eq!(size_of::(), 16); assert_eq!(packed([1.0, 0.0]).uv, 0x0000_0FFF); assert_eq!(packed([0.0, 1.0]).uv, 0x00FF_F000); assert_eq!(packed([1.0, 1.0]).uv >> 24, 0); } + + #[test] + fn light_packs_block_and_sky_with_the_shaded_tint() { + let vertex = pack_vertex(&TerrainVertex { + position: [0.0; 3], + sprite_uv: [0.0; 2], + sprite: 0, + light: VertexLight::flat(0.5, LightLevels::new(15, 7)), + tint: super::pack_tint_shifted([1.0, 0.5, 0.0]), + }); + assert_eq!(vertex.uv >> 24, 240); + assert_eq!(vertex.light_tint, [112, 128, 64, 0]); + } } #[cfg(test)] diff --git a/pomme-client/src/renderer/chunk/mod.rs b/pomme-client/src/renderer/chunk/mod.rs index df4c455a5..b5f4edf53 100644 --- a/pomme-client/src/renderer/chunk/mod.rs +++ b/pomme-client/src/renderer/chunk/mod.rs @@ -1,5 +1,5 @@ pub mod atlas; -pub mod block_ao; +mod block_lighter; pub mod buffer; pub mod mesh_columns; pub mod mesh_source; diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index 6b923a4fb..f02782d65 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -38,6 +38,7 @@ use pipelines::entity_outline::EntityOutline; use pipelines::entity_renderer::{ArmorRenderInfo, EntityRenderInfo, EntityRenderer}; use pipelines::entity_shadow::{EntityShadowPipeline, ShadowScene}; use pipelines::hand::HandPipeline; +use pipelines::lightmap::{Lightmap, LightmapInfo}; use pipelines::menu_overlay::{MenuElement, MenuOverlayPipeline, SpriteId}; use pipelines::panorama::PanoramaPipeline; pub use pipelines::particle::{ParticlePipeline, ParticleQuad}; @@ -244,6 +245,7 @@ pub struct Renderer { asset_index: Option, chunk_pipeline: ChunkPipeline, + lightmap: Lightmap, hand_pipeline: HandPipeline, block_overlay_pipeline: BlockOverlayPipeline, sky_pipeline: SkyPipeline, @@ -357,7 +359,9 @@ impl Renderer { splash(&mut menu_pipeline, 0.5, "Creating pipelines..."); - let chunk_pipeline = ChunkPipeline::new(&ctx.device, swapchain_state.targets(), &atlas); + let lightmap = Lightmap::new(&ctx.device); + let chunk_pipeline = + ChunkPipeline::new(&ctx.device, swapchain_state.targets(), &atlas, &lightmap); let hand_pipeline = HandPipeline::new( &ctx.device, @@ -506,6 +510,7 @@ impl Renderer { asset_index: asset_index.clone(), atlas, chunk_pipeline, + lightmap, hand_pipeline, block_overlay_pipeline, sky_pipeline, @@ -730,7 +735,8 @@ impl Renderer { let targets = self.swapchain.targets(); if targets != old_targets { self.chunk_pipeline.destroy(&self.ctx.device); - self.chunk_pipeline = ChunkPipeline::new(&self.ctx.device, targets, &self.atlas); + self.chunk_pipeline = + ChunkPipeline::new(&self.ctx.device, targets, &self.atlas, &self.lightmap); self.hand_pipeline .recreate_pipeline(&self.ctx.device, targets); @@ -820,6 +826,12 @@ impl Renderer { self.atlas.tick_animations(); } + /// Redraws the lightmap with `info` on the next frame, as vanilla does + /// once per tick. + pub fn tick_lightmap(&mut self, info: LightmapInfo) { + self.lightmap.tick(info); + } + pub fn set_entity_camera_look(&mut self, look_dir: Option) { self.camera.set_entity_look(look_dir); } @@ -1487,6 +1499,7 @@ impl Renderer { let encoder = self.frames.begin(&self.ctx.device)?; self.atlas.record_animation_upload(&encoder, frame); + self.lightmap.record(&self.ctx.device, &encoder); let scissor = rhi::Rect::covering(self.swapchain.extent); @@ -2335,6 +2348,7 @@ impl Drop for Renderer { self.screenshot.destroy(&self.ctx.device); self.chunk_buffers.destroy(&self.ctx.device); self.chunk_pipeline.destroy(&self.ctx.device); + self.lightmap.destroy(&self.ctx.device); self.hand_pipeline.destroy(&self.ctx.device); self.block_overlay_pipeline.destroy(&self.ctx.device); self.sky_pipeline.destroy(&self.ctx.device); diff --git a/pomme-client/src/renderer/pipelines/chunk.rs b/pomme-client/src/renderer/pipelines/chunk.rs index c73770544..7583143f5 100644 --- a/pomme-client/src/renderer/pipelines/chunk.rs +++ b/pomme-client/src/renderer/pipelines/chunk.rs @@ -1,6 +1,7 @@ use crate::renderer::camera::CameraUniform; use crate::renderer::chunk::atlas::TextureAtlas; use crate::renderer::chunk::buffer::CHUNK_VERTEX_BUFFERS; +use crate::renderer::pipelines::lightmap::Lightmap; use crate::renderer::{rhi, shader, util}; /// Set 1: the atlas, its sampler and the sprite-rectangle buffer the fragment @@ -24,6 +25,8 @@ pub struct ChunkPipeline { pub atlas_layout: rhi::backend::BindGroupLayout, pub camera_sets: Vec, pub atlas_set: rhi::backend::BindGroup, + /// Set 2, which [`Lightmap`] owns. + lightmap_set: rhi::backend::BindGroup, camera_buffers: Vec, } @@ -32,6 +35,7 @@ impl ChunkPipeline { device: &rhi::backend::Device, targets: rhi::backend::PipelineTargets, atlas: &TextureAtlas, + lightmap: &Lightmap, ) -> Self { let camera_layout = rhi::backend::create_bind_group_layout(device, util::VERTEX_UNIFORM); let atlas_layout = rhi::backend::create_bind_group_layout(device, ATLAS_LAYOUT); @@ -39,7 +43,7 @@ impl ChunkPipeline { // indirect passes read them per-instance and ignore the range. let pipeline_layout = rhi::backend::create_pipeline_layout( device, - &[camera_layout, atlas_layout], + &[camera_layout, atlas_layout, lightmap.layout], Some(rhi::PushConstants { visibility: rhi::ShaderStages::VERTEX, size: 16, @@ -65,6 +69,7 @@ impl ChunkPipeline { atlas_layout, camera_sets, atlas_set: rhi::backend::BindGroup::default(), + lightmap_set: lightmap.group, camera_buffers, }; pipeline.rebind_atlas(device, atlas); @@ -116,7 +121,10 @@ impl ChunkPipeline { frame: usize, ) { pass.set_pipeline(&pipeline); - pass.set_bind_groups(0, &[self.camera_sets[frame], self.atlas_set]); + pass.set_bind_groups( + 0, + &[self.camera_sets[frame], self.atlas_set, self.lightmap_set], + ); } pub fn destroy(&mut self, device: &rhi::backend::Device) { diff --git a/pomme-client/src/renderer/pipelines/lightmap.rs b/pomme-client/src/renderer/pipelines/lightmap.rs new file mode 100644 index 000000000..6fcf58ffd --- /dev/null +++ b/pomme-client/src/renderer/pipelines/lightmap.rs @@ -0,0 +1,129 @@ +//! Vanilla `Lightmap`: a 16×16 texture of block light (x) by sky light (y), +//! redrawn by `core/lightmap.fsh` after a tick. Terrain samples it per +//! vertex (`sample_lightmap` in `lightmap.glsl`). + +use crate::renderer::pipelines::post; +use crate::renderer::{rhi, shader}; + +const EXTENT: rhi::Extent = rhi::Extent { + width: 16, + height: 16, +}; + +/// `lightmap.frag`'s push constants: vanilla's `LightmapInfo`, each colour's +/// fourth lane carrying one of its floats. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +pub struct LightmapInfo { + /// `BlockLightTint`, then `BlockFactor`. + pub block_light_tint: [f32; 4], + /// `SkyLightColor`, then `SkyFactor`. + pub sky_light_color: [f32; 4], + /// `AmbientColor`, then `BrightnessFactor`. + pub ambient_color: [f32; 4], + /// `NightVisionColor`, then `NightVisionFactor`. + pub night_vision_color: [f32; 4], + pub darkness_scale: f32, + pub boss_overlay_world_darkening_factor: f32, + pub _pad: [f32; 2], +} + +/// The lightmap's texture and sampler, for the stages that sample it. +pub const SAMPLED_LAYOUT: &[rhi::BindGroupLayoutEntry] = &[ + rhi::BindGroupLayoutEntry::texture(rhi::ShaderStages::VERTEX), + rhi::BindGroupLayoutEntry::sampler(rhi::ShaderStages::VERTEX), +]; + +pub struct Lightmap { + texture: rhi::backend::Texture, + sampler: rhi::backend::Sampler, + pipeline_layout: rhi::backend::PipelineLayout, + pipeline: rhi::backend::RenderPipeline, + /// [`SAMPLED_LAYOUT`], and a group of it pointed at the texture. + pub layout: rhi::backend::BindGroupLayout, + pub group: rhi::backend::BindGroup, + info: LightmapInfo, + /// A tick ran since the last redraw; vanilla's `needsUpdate`. + needs_update: bool, +} + +impl Lightmap { + pub fn new(device: &rhi::backend::Device) -> Self { + let texture = post::create_target(device, EXTENT, "lightmap"); + // Vanilla `getClampToEdge(FilterMode.LINEAR)`. + let sampler = rhi::backend::create_sampler(device, &rhi::SamplerDesc::LINEAR); + let pipeline_layout = rhi::backend::create_pipeline_layout( + device, + &[], + Some(rhi::PushConstants { + visibility: rhi::ShaderStages::FRAGMENT, + size: size_of::() as u32, + }), + ); + let pipeline = post::create_pipeline( + device, + post::targets(), + pipeline_layout, + "lightmap", + None, + shader::include_shader!("lightmap.frag"), + None, + ); + let layout = rhi::backend::create_bind_group_layout(device, SAMPLED_LAYOUT); + let group = device.create_bind_group( + layout, + &crate::renderer::util::texture_binding(texture.handle(), sampler), + ); + Self { + texture, + sampler, + pipeline_layout, + pipeline, + layout, + group, + // White, as vanilla clears it before the first tick. + info: LightmapInfo { + ambient_color: [1.0, 1.0, 1.0, 0.0], + ..bytemuck::Zeroable::zeroed() + }, + needs_update: true, + } + } + + /// Vanilla `LightmapRenderStateExtractor.extract` after a tick: the next + /// frame redraws with `info`. + pub fn tick(&mut self, info: LightmapInfo) { + self.info = info; + self.needs_update = true; + } + + /// Redraws the lightmap if a tick ran since the last redraw. + pub fn record( + &mut self, + device: &rhi::backend::Device, + encoder: &rhi::backend::CommandEncoder, + ) { + if !std::mem::take(&mut self.needs_update) { + return; + } + let target = self.texture.handle(); + let mut pass = post::open_pass(device, encoder, target, EXTENT); + pass.set_pipeline(&self.pipeline); + pass.set_push_constants( + rhi::ShaderStages::FRAGMENT, + 0, + bytemuck::bytes_of(&self.info), + ); + pass.draw(3, 1, 0, 0); + post::close_pass(encoder, pass, target); + } + + pub fn destroy(&mut self, device: &rhi::backend::Device) { + std::mem::take(&mut self.texture).destroy(device); + self.group.destroy(device); + self.layout.destroy(device); + self.pipeline.destroy(device); + self.pipeline_layout.destroy(device); + self.sampler.destroy(device); + } +} diff --git a/pomme-client/src/renderer/pipelines/mod.rs b/pomme-client/src/renderer/pipelines/mod.rs index 12c3ec2c8..85dd7e222 100644 --- a/pomme-client/src/renderer/pipelines/mod.rs +++ b/pomme-client/src/renderer/pipelines/mod.rs @@ -13,6 +13,7 @@ pub mod hand; pub mod held_item; pub mod item_display; pub mod item_entity; +pub mod lightmap; pub mod menu_overlay; pub mod panorama; pub mod particle; diff --git a/pomme-client/src/renderer/shaders/chunk.frag b/pomme-client/src/renderer/shaders/chunk.frag index 73101a166..f4def12c9 100644 --- a/pomme-client/src/renderer/shaders/chunk.frag +++ b/pomme-client/src/renderer/shaders/chunk.frag @@ -4,8 +4,7 @@ #include "atlas_sprite.glsl" layout(location = 0) in vec2 v_sprite_uv; -layout(location = 1) in float v_light; -layout(location = 2) in vec3 v_tint; +layout(location = 1) in vec3 v_color; layout(location = 3) flat in float v_visibility; layout(location = 4) in vec3 v_fog_color; layout(location = 5) in float v_fog; @@ -17,6 +16,6 @@ void main() { vec4 color = sample_atlas_sprite(v_sprite_uv, v_sprite); if (color.a < 0.5) discard; vec3 shaded = - shade_chunk_surface(color.rgb, v_tint, v_light, v_visibility, v_fog_color, v_fog); + shade_chunk_surface(color.rgb, v_color, v_visibility, v_fog_color, v_fog); out_color = vec4(shaded, color.a); } diff --git a/pomme-client/src/renderer/shaders/chunk.vert b/pomme-client/src/renderer/shaders/chunk.vert index 934935bcc..a84978599 100644 --- a/pomme-client/src/renderer/shaders/chunk.vert +++ b/pomme-client/src/renderer/shaders/chunk.vert @@ -3,6 +3,7 @@ #include "fog.glsl" #include "camera_ubo.glsl" #include "packing.glsl" +#include "lightmap.glsl" // Binding 0: packed section-local terrain vertex. layout(location = 0) in vec2 in_pos_xy; @@ -15,8 +16,7 @@ layout(location = 5) in ivec3 in_origin; layout(location = 6) in float in_visibility; layout(location = 0) out vec2 v_sprite_uv; -layout(location = 1) out float v_light; -layout(location = 2) out vec3 v_tint; +layout(location = 1) out vec3 v_color; layout(location = 3) flat out float v_visibility; layout(location = 4) out vec3 v_fog_color; layout(location = 5) out float v_fog; @@ -27,8 +27,7 @@ void main() { vec3 rel = vec3(in_origin - camera_block.xyz) - camera_pos.xyz + local; gl_Position = view_proj * vec4(rel, 1.0); v_sprite_uv = unpack_sprite_uv(in_uv); - v_light = in_light_tint.r; - v_tint = in_light_tint.gba; + v_color = terrain_vertex_color(in_light_tint, in_uv); v_visibility = in_visibility; v_fog_color = fog_color.rgb; v_fog = total_fog_value(rel, fog_env, camera_pos.w, fog_color.w); diff --git a/pomme-client/src/renderer/shaders/chunk_solid.frag b/pomme-client/src/renderer/shaders/chunk_solid.frag index 1d180f132..79e80c258 100644 --- a/pomme-client/src/renderer/shaders/chunk_solid.frag +++ b/pomme-client/src/renderer/shaders/chunk_solid.frag @@ -8,8 +8,7 @@ layout(early_fragment_tests) in; #include "atlas_sprite.glsl" layout(location = 0) in vec2 v_sprite_uv; -layout(location = 1) in float v_light; -layout(location = 2) in vec3 v_tint; +layout(location = 1) in vec3 v_color; layout(location = 3) flat in float v_visibility; layout(location = 4) in vec3 v_fog_color; layout(location = 5) in float v_fog; @@ -20,6 +19,6 @@ layout(location = 0) out vec4 out_color; void main() { vec4 color = sample_atlas_sprite(v_sprite_uv, v_sprite); vec3 shaded = - shade_chunk_surface(color.rgb, v_tint, v_light, v_visibility, v_fog_color, v_fog); + shade_chunk_surface(color.rgb, v_color, v_visibility, v_fog_color, v_fog); out_color = vec4(shaded, 1.0); } diff --git a/pomme-client/src/renderer/shaders/fog.glsl b/pomme-client/src/renderer/shaders/fog.glsl index 8117477d3..aa36e3019 100644 --- a/pomme-client/src/renderer/shaders/fog.glsl +++ b/pomme-client/src/renderer/shaders/fog.glsl @@ -25,20 +25,28 @@ vec3 apply_fog(vec3 color, float fog, vec3 fog_color) { return mix(color, fog_color, clamp(fog, 0.0, 1.0)); } -// Shared chunk surface shading (opaque terrain and translucent water): linear -// tint and light, fade to fog while a section is still appearing, then distance -// fog. Callers supply their own alpha (cutout vs blend). +// The sRGB transfer functions, exact rather than a 2.2 power, which crushes +// dark values against the swapchain's own encode. +vec3 srgb_to_linear(vec3 c) { + return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c)); +} + +vec3 linear_to_srgb(vec3 c) { + return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c)); +} + +// Shared chunk surface shading (opaque terrain and translucent water): the +// texture times the lit vertex colour in gamma space, as vanilla's +// framebuffer holds it, fade to fog while a section is still appearing, then +// distance fog. Callers supply their own alpha (cutout vs blend). vec3 shade_chunk_surface( vec3 tex_rgb, - vec3 tint, - float light, + vec3 color, float visibility, vec3 fog_color, float fog ) { - vec3 linear_tint = pow(tint, vec3(2.2)); - float linear_light = pow(light, 2.2); - vec3 tinted = tex_rgb * linear_tint * linear_light; + vec3 tinted = srgb_to_linear(linear_to_srgb(tex_rgb) * color); if (visibility < 1.0) { tinted = mix(fog_color, tinted, visibility); } diff --git a/pomme-client/src/renderer/shaders/lightmap.frag b/pomme-client/src/renderer/shaders/lightmap.frag new file mode 100644 index 000000000..e3cf0348d --- /dev/null +++ b/pomme-client/src/renderer/shaders/lightmap.frag @@ -0,0 +1,56 @@ +#version 450 + +// Vanilla `core/lightmap.fsh`: one texel per block (x) and sky (y) light +// level. Texel rows run top-down here and bottom-up in vanilla, as do the +// rows terrain samples, so `gl_FragCoord` stands in for `texCoord * 16`. + +layout(push_constant) uniform LightmapInfo { + vec4 block_light_tint; // w: BlockFactor + vec4 sky_light_color; // w: SkyFactor + vec4 ambient_color; // w: BrightnessFactor + vec4 night_vision_color; // w: NightVisionFactor + float darkness_scale; + float boss_overlay_world_darkening_factor; +}; + +layout(location = 0) out vec4 frag_color; + +float get_brightness(float level) { + return level / (4.0 - 3.0 * level); +} + +vec3 not_gamma(vec3 color) { + float max_component = max(max(color.x, color.y), color.z); + float max_inverted = 1.0 - max_component; + float max_scaled = 1.0 - max_inverted * max_inverted * max_inverted * max_inverted; + return color * (max_scaled / max_component); +} + +float parabolic_mix_factor(float level) { + return (2.0 * level - 1.0) * (2.0 * level - 1.0); +} + +void main() { + float block_level = floor(gl_FragCoord.x) / 15.0; + float sky_level = floor(gl_FragCoord.y) / 15.0; + + float block_brightness = get_brightness(block_level) * block_light_tint.w; + float sky_brightness = get_brightness(sky_level) * sky_light_color.w; + + vec3 night_vision = night_vision_color.rgb * night_vision_color.w; + vec3 color = max(ambient_color.rgb, night_vision); + + color += sky_light_color.rgb * sky_brightness; + + vec3 block_light_color = mix(block_light_tint.rgb, vec3(1.0), 0.9 * parabolic_mix_factor(block_level)); + color += block_light_color * block_brightness; + + color = mix(color, color * vec3(0.7, 0.6, 0.6), boss_overlay_world_darkening_factor); + + color = color - vec3(darkness_scale); + + color = clamp(color, 0.0, 1.0); + color = mix(color, not_gamma(color), ambient_color.w); + + frag_color = vec4(color, 1.0); +} diff --git a/pomme-client/src/renderer/shaders/lightmap.glsl b/pomme-client/src/renderer/shaders/lightmap.glsl new file mode 100644 index 000000000..44ce94b28 --- /dev/null +++ b/pomme-client/src/renderer/shaders/lightmap.glsl @@ -0,0 +1,20 @@ +// Vanilla `include/sample_lightmap.glsl`: `uv` is (block, sky) light in +// 0..240, between texel centres so the bilinear sampler blends smooth light. +layout(set = 2, binding = 0) uniform texture2D lightmap_texture; +layout(set = 2, binding = 1) uniform sampler lightmap_sampler; + +vec4 sample_lightmap(vec2 uv) { + return textureLod( + sampler2D(lightmap_texture, lightmap_sampler), + clamp(uv / 256.0 + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), + 0.0 + ); +} + +// Vanilla `terrain.vsh`'s `Color * sample_lightmap(Sampler2, UV2)` for a +// packed terrain vertex: block light in `light_tint.r`, sky light in the top +// byte of `uv`. +vec3 terrain_vertex_color(vec4 light_tint, uint uv) { + vec2 light = vec2(round(light_tint.r * 255.0), float(uv >> 24)); + return light_tint.gba * sample_lightmap(light).rgb; +} diff --git a/pomme-client/src/renderer/shaders/water.frag b/pomme-client/src/renderer/shaders/water.frag index 76f407117..203e07d74 100644 --- a/pomme-client/src/renderer/shaders/water.frag +++ b/pomme-client/src/renderer/shaders/water.frag @@ -4,8 +4,7 @@ #include "atlas_sprite.glsl" layout(location = 0) in vec2 v_sprite_uv; -layout(location = 1) in float v_light; -layout(location = 2) in vec3 v_tint; +layout(location = 1) in vec3 v_color; layout(location = 3) flat in float v_visibility; layout(location = 4) in vec3 v_fog_color; layout(location = 5) in float v_fog; @@ -18,6 +17,6 @@ const float WATER_ALPHA = 0.7; void main() { vec4 color = sample_atlas_sprite(v_sprite_uv, v_sprite); vec3 shaded = - shade_chunk_surface(color.rgb, v_tint, v_light, v_visibility, v_fog_color, v_fog); + shade_chunk_surface(color.rgb, v_color, v_visibility, v_fog_color, v_fog); out_color = vec4(shaded, WATER_ALPHA); } diff --git a/pomme-client/src/renderer/shaders/water.vert b/pomme-client/src/renderer/shaders/water.vert index 976654d3c..4ef723ebc 100644 --- a/pomme-client/src/renderer/shaders/water.vert +++ b/pomme-client/src/renderer/shaders/water.vert @@ -6,6 +6,7 @@ #include "fog.glsl" #include "camera_ubo.glsl" #include "packing.glsl" +#include "lightmap.glsl" layout(push_constant) uniform SectionPc { vec4 origin_fade; @@ -18,8 +19,7 @@ layout(location = 3) in uint in_sprite; layout(location = 4) in vec4 in_light_tint; layout(location = 0) out vec2 v_sprite_uv; -layout(location = 1) out float v_light; -layout(location = 2) out vec3 v_tint; +layout(location = 1) out vec3 v_color; layout(location = 3) flat out float v_visibility; layout(location = 4) out vec3 v_fog_color; layout(location = 5) out float v_fog; @@ -31,8 +31,7 @@ void main() { vec3 rel = world - camera_pos.xyz; gl_Position = view_proj * vec4(rel, 1.0); v_sprite_uv = unpack_sprite_uv(in_uv); - v_light = in_light_tint.r; - v_tint = in_light_tint.gba; + v_color = terrain_vertex_color(in_light_tint, in_uv); v_visibility = origin_fade.w; v_fog_color = fog_color.rgb; v_fog = total_fog_value(rel, fog_env, camera_pos.w, fog_color.w); diff --git a/pomme-client/src/ui/menu/mod.rs b/pomme-client/src/ui/menu/mod.rs index f64a8e5ef..dcbef9256 100644 --- a/pomme-client/src/ui/menu/mod.rs +++ b/pomme-client/src/ui/menu/mod.rs @@ -45,6 +45,8 @@ struct Settings { fov_effect_scale: f32, #[serde(default = "default_damage_tilt_strength")] damage_tilt_strength: f32, + #[serde(default = "default_brightness")] + brightness: f32, #[serde(default = "default_sensitivity")] sensitivity: f32, #[serde(default)] @@ -145,6 +147,11 @@ fn default_damage_tilt_strength() -> f32 { 1.0 } +/// Vanilla `gamma`. +fn default_brightness() -> f32 { + 0.5 +} + /// Vanilla `menuBackgroundBlurriness`: 0 (off) to 10, default 5. const MAX_MENU_BACKGROUND_BLURRINESS: u8 = 10; pub const DEFAULT_MENU_BACKGROUND_BLURRINESS: u8 = 5; @@ -207,6 +214,7 @@ impl Default for Settings { fov: 70, fov_effect_scale: 1.0, damage_tilt_strength: 1.0, + brightness: default_brightness(), sensitivity: 0.5, invert_mouse_x: false, invert_mouse_y: false, @@ -704,6 +712,8 @@ pub struct MainMenu { /// FOV Effects slider fraction (0..1); squared by `fov_effect()`. pub fov_effect_scale: f32, pub damage_tilt_strength: f32, + /// The Brightness option (vanilla `gamma`), 0..1. + pub brightness: f32, pub sensitivity: f32, pub invert_mouse_x: bool, pub invert_mouse_y: bool, @@ -859,6 +869,7 @@ impl MainMenu { fov: settings.fov, fov_effect_scale: settings.fov_effect_scale, damage_tilt_strength: unit_range(settings.damage_tilt_strength), + brightness: unit_range(settings.brightness), // Cubed by the look curve, so an out-of-range value reaches // infinity and leaves the look direction NaN for good. sensitivity: unit_range(settings.sensitivity), @@ -1107,6 +1118,7 @@ impl MainMenu { fov: self.fov, fov_effect_scale: self.fov_effect_scale, damage_tilt_strength: self.damage_tilt_strength, + brightness: self.brightness, sensitivity: self.sensitivity, invert_mouse_x: self.invert_mouse_x, invert_mouse_y: self.invert_mouse_y, diff --git a/pomme-client/src/ui/menu/options.rs b/pomme-client/src/ui/menu/options.rs index e7248077e..96540c082 100644 --- a/pomme-client/src/ui/menu/options.rs +++ b/pomme-client/src/ui/menu/options.rs @@ -78,6 +78,18 @@ fn on_off(value: bool) -> &'static str { if value { "ON" } else { "OFF" } } +const BRIGHTNESS: &str = "Brightness:"; + +/// Vanilla `gamma`'s label: named at 0, 50 and 100, a number otherwise. +fn brightness_label(brightness: f32) -> String { + match (brightness * 100.0) as i32 { + 0 => format!("{BRIGHTNESS} Moody"), + 50 => format!("{BRIGHTNESS} Default"), + 100 => format!("{BRIGHTNESS} Bright"), + value => format!("{BRIGHTNESS} {value}"), + } +} + /// Vanilla `Options.percentValueLabel`, which truncates. fn percent_label(prefix: &str, value: f64) -> String { format!("{prefix} {}%", (value * 100.0) as i32) @@ -293,13 +305,14 @@ impl MainMenu { "Entity Shadows: OFF" }; let (blur_label, blur_frac) = self.menu_background_blur_slider(); + let brightness = brightness_label(self.brightness); let rows: Vec = vec![ OptRow::Header("Display"), OptRow::Big("Fullscreen Resolution: Current"), OptRow::Pair(&mf, vsync_label), OptRow::Pair("Inactivity FPS Limit: 1 minute", &gui_label), OptRow::Pair(fullscreen_label, "Exclusive Fullscreen: OFF"), - OptRow::Pair("Brightness: 50%", "Graphics Backend: Default"), + OptRow::Pair(&brightness, "Graphics Backend: Default"), OptRow::Header("Quality"), OptRow::Big("Graphics: Fancy"), OptRow::Pair("Biome Blend: 5x5", &rd), @@ -326,13 +339,13 @@ impl MainMenu { ("Chunk Detail:", cd_frac), ("Simulation Distance:", sd_frac), ("Max Framerate:", mf_frac), + (BRIGHTNESS, self.brightness), (MENU_BACKGROUND_BLUR, blur_frac), ]; let disabled = &[ "Fullscreen Resolution:", "Inactivity FPS Limit:", "Exclusive Fullscreen:", - "Brightness:", "Graphics Backend:", "Graphics:", "Biome Blend:", @@ -1551,6 +1564,7 @@ impl MainMenu { } "FOV Effects:" => self.fov_effect_scale = v, "Damage Tilt:" => self.damage_tilt_strength = v, + BRIGHTNESS => self.brightness = v, "Sensitivity:" => self.sensitivity = v, "Master Volume:" => self.master_volume = v, "Music:" => self.music_volume = v, @@ -2043,6 +2057,14 @@ mod tests { assert!(option_enabled("Graphics Backend: Default", disabled)); } + #[test] + fn brightness_label_names_its_ends_and_default() { + assert_eq!(brightness_label(0.0), "Brightness: Moody"); + assert_eq!(brightness_label(0.5), "Brightness: Default"); + assert_eq!(brightness_label(1.0), "Brightness: Bright"); + assert_eq!(brightness_label(0.379), "Brightness: 37"); + } + #[test] fn menu_background_blur_label_is_generic_value_or_off() { assert_eq!(menu_background_blur_label(0), "Menu Background Blur: OFF"); diff --git a/pomme-client/src/world/block/model.rs b/pomme-client/src/world/block/model.rs index 1b39288d8..b7be11159 100644 --- a/pomme-client/src/world/block/model.rs +++ b/pomme-client/src/world/block/model.rs @@ -75,6 +75,7 @@ struct ModelFile { #[serde(default)] display: HashMap, gui_light: Option, + ambientocclusion: Option, } #[derive(Deserialize, Default, Clone, Copy, Debug, PartialEq, Eq)] @@ -187,6 +188,8 @@ struct ElementDef { faces: HashMap, #[serde(default = "default_true")] shade: bool, + #[serde(default)] + light_emission: u8, } fn default_true() -> bool { @@ -492,6 +495,10 @@ pub struct BakedQuad { pub shade_light: f32, /// The face terrain shades this quad as, `None` for `shade: false`. pub shade_face: Option, + /// The element's `light_emission`, 0..15. + pub emission: u8, + /// The model's `ambientocclusion`, which smooth lighting needs. + pub ambient_occlusion: bool, } #[derive(Clone, Debug)] @@ -1228,6 +1235,8 @@ fn add_chest_cube( tint_index: None, shade_light: spec.shade, shade_face: None, + emission: 0, + ambient_occlusion: true, }); } } @@ -1669,6 +1678,9 @@ fn extract_default_model_ref(blockstate: &BlockstateFile) -> Option { struct ResolvedModel { textures: HashMap, elements: Vec, + /// Vanilla `BlockModel.getAmbientOcclusion`: the nearest model in the + /// parent chain that sets it, else true. + ambient_occlusion: bool, gui_transform: DisplayTransform, gui_light: GuiLight, ground_transform: Mat4, @@ -1686,6 +1698,7 @@ fn resolve_model( let mut gui_transform: Option = None; let mut gui_light: Option = None; let mut ground_transform: Option = None; + let mut ambient_occlusion: Option = None; let mut current_id = model_id.to_string(); for _ in 0..20 { @@ -1710,6 +1723,9 @@ fn resolve_model( if gui_light.is_none() { gui_light = model.gui_light; } + if ambient_occlusion.is_none() { + ambient_occlusion = model.ambientocclusion; + } if ground_transform.is_none() && let Some(transform) = model .display @@ -1733,6 +1749,7 @@ fn resolve_model( ResolvedModel { textures: resolved_textures, elements: elements.unwrap_or_default(), + ambient_occlusion: ambient_occlusion.unwrap_or(true), gui_transform: gui_transform.unwrap_or(DisplayTransform::IDENTITY), gui_light: gui_light.unwrap_or_default(), ground_transform: ground_transform.unwrap_or(Mat4::IDENTITY), @@ -1869,7 +1886,7 @@ fn bake_resolved_model( }; let positions = face_positions(dir, from, to); - let uvs = face_uvs(dir, from, to, face_def.uv.as_ref(), face_def.rotation); + let mut uvs = face_uvs(dir, from, to, face_def.uv.as_ref(), face_def.rotation); let mut positions = apply_element_rotation(positions, &element.rotation); @@ -1884,6 +1901,11 @@ fn bake_resolved_model( positions = rotate_positions(positions, rot_x, rot_y); cullface = cullface.map(|d| d.rotate_x(rot_x).rotate_y(rot_y)); } + if element.rotation.is_none() + && let Some(facing) = direction_from_positions(&positions) + { + recalculate_winding(&mut positions, &mut uvs, facing); + } // Vanilla `FaceBakery.bakeQuad`: the shade direction is the // rotated quad's nearest cardinal, `UP` for a degenerate quad. @@ -1902,6 +1924,8 @@ fn bake_resolved_model( .and_then(|index| u32::try_from(index).ok()), shade_light: shade_face.map_or(1.0, |face| face.shade_light()), shade_face, + emission: element.light_emission, + ambient_occlusion: resolved.ambient_occlusion, }); } } @@ -2077,33 +2101,47 @@ pub(crate) fn direction_from_positions(positions: &[[f32; 3]; 4]) -> Option [[f32; 3]; 4] { - let center = 0.5f32; - - if rot_x != 0 { - let angle = (rot_x as f32).to_radians(); - let cos = angle.cos(); - let sin = angle.sin(); - for pos in &mut positions { - let dy = pos[1] - center; - let dz = pos[2] - center; - pos[1] = center + cos * dy + sin * dz; - pos[2] = center - sin * dy + cos * dz; +/// A quad's smallest and largest corner coordinates, per axis. +pub(crate) fn quad_bounds(positions: &[[f32; 3]; 4]) -> ([f32; 3], [f32; 3]) { + let mut min = [f32::MAX; 3]; + let mut max = [f32::MIN; 3]; + for p in positions { + for i in 0..3 { + min[i] = min[i].min(p[i]); + max[i] = max[i].max(p[i]); } } + (min, max) +} - if rot_y != 0 { - let angle = (rot_y as f32).to_radians(); - let cos = angle.cos(); - let sin = angle.sin(); - for pos in &mut positions { - let dx = pos[0] - center; - let dz = pos[2] - center; - pos[0] = center + cos * dx - sin * dz; - pos[2] = center + sin * dx + cos * dz; - } +/// Vanilla `FaceBakery.recalculateWinding`: a rotated face's corners (and +/// their uvs) back in `facing`'s vertex order, which per-vertex lighting +/// relies on. +fn recalculate_winding(positions: &mut [[f32; 3]; 4], uvs: &mut [[f32; 2]; 4], facing: Direction) { + let (min, max) = quad_bounds(positions); + let target = face_positions(facing, min, max); + for (vertex, &corner) in target.iter().enumerate() { + let Some(found) = (vertex..4).find(|&i| positions[i] == corner) else { + return; + }; + positions.swap(vertex, found); + uvs.swap(vertex, found); } +} +/// A blockstate's `x` then `y` rotation, in exact quarter turns about the +/// block centre (vanilla's are only ever multiples of 90), so corners on the +/// block's edge stay exactly on it. +fn rotate_positions(mut positions: [[f32; 3]; 4], rot_x: i32, rot_y: i32) -> [[f32; 3]; 4] { + let quarter_turns = |degrees: i32| degrees.rem_euclid(360) / 90; + for pos in &mut positions { + for _ in 0..quarter_turns(rot_x) { + *pos = [pos[0], pos[2], 1.0 - pos[1]]; + } + for _ in 0..quarter_turns(rot_y) { + *pos = [1.0 - pos[2], pos[1], pos[0]]; + } + } positions } @@ -2368,6 +2406,8 @@ mod tests { tint_index: None, shade_light: 0.25, shade_face: Some(Direction::Up), + emission: 0, + ambient_occlusion: true, }; apply_gui_lambert( std::slice::from_mut(&mut quad), @@ -2447,7 +2487,9 @@ mod tests { rotation: None, faces: HashMap::from([("west".to_string(), face)]), shade: true, + light_emission: 0, }], + ambient_occlusion: true, gui_transform: DisplayTransform::IDENTITY, gui_light: GuiLight::Side, ground_transform: Mat4::IDENTITY, @@ -2475,7 +2517,9 @@ mod tests { rotation: None, faces: HashMap::from([("south".to_string(), face)]), shade: true, + light_emission: 0, }], + ambient_occlusion: true, gui_transform: DisplayTransform::IDENTITY, gui_light: GuiLight::default(), ground_transform: Mat4::IDENTITY, @@ -2532,6 +2576,23 @@ mod tests { assert!((rotated[0][2] - 0.25).abs() < 1.0e-6); } + #[test] + fn rotated_faces_wind_back_to_their_facings_vertex_order() { + // A full cube's top turned on its side, as a sideways log's is. + let mut positions = + rotate_positions(face_positions(Direction::Up, [0.0; 3], [1.0; 3]), 90, 0); + let mut uvs = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]; + let facing = direction_from_positions(&positions).unwrap(); + let before: Vec<([f32; 3], [f32; 2])> = positions.into_iter().zip(uvs).collect(); + recalculate_winding(&mut positions, &mut uvs, facing); + let target = face_positions(facing, [0.0; 3], [1.0; 3]); + assert_eq!(positions, target); + // Each corner keeps its own uv. + for (p, uv) in positions.iter().zip(uvs) { + assert!(before.contains(&(*p, uv))); + } + } + /// Every face must show the full-tile texture upright at rotation 0 and /// spin it clockwise per 90 degrees, vanilla's `FaceInfo` + /// `CuboidFace.UVs` + `Quadrant` behavior (the piston's side faces use @@ -2678,6 +2739,8 @@ mod tests { tint_index: Some(0), shade_light: 1.0, shade_face: None, + emission: 0, + ambient_occlusion: true, }, BakedQuad { positions: [[0.0; 3]; 4], @@ -2688,6 +2751,8 @@ mod tests { tint_index: None, shade_light: 1.0, shade_face: None, + emission: 0, + ambient_occlusion: true, }, ], is_full_cube: false, diff --git a/pomme-client/src/world/environment.rs b/pomme-client/src/world/environment.rs new file mode 100644 index 000000000..e7b18fc4d --- /dev/null +++ b/pomme-client/src/world/environment.rs @@ -0,0 +1,397 @@ +//! The environment attributes behind the lightmap (vanilla +//! `EnvironmentAttributes`' `visual/*` lighting entries), resolved like +//! `EnvironmentAttributeSystem`: the attribute's default, then the dimension's +//! layer, its timelines' tracks, and the weather. +//! TODO: the biome layer; no vanilla biome sets these, but a datapack can. + +use std::ops::{Add, Mul, Sub}; + +use glam::Vec3; + +/// The lighting attributes the lightmap reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LightAttribute { + BlockLightTint, + SkyLightColor, + SkyLightFactor, + NightVisionColor, + AmbientLightColor, +} + +impl LightAttribute { + /// The attribute's registry id. + pub fn id(self) -> &'static str { + match self { + Self::BlockLightTint => "minecraft:visual/block_light_tint", + Self::SkyLightColor => "minecraft:visual/sky_light_color", + Self::SkyLightFactor => "minecraft:visual/sky_light_factor", + Self::NightVisionColor => "minecraft:visual/night_vision_color", + Self::AmbientLightColor => "minecraft:visual/ambient_light_color", + } + } +} + +/// A float or RGB attribute value; colours are vanilla's 0..1 sRGB floats, +/// which `LerpFunction.ofColorVec3` lerps component-wise. +pub trait AttributeValue: + Copy + Add + Sub + Mul + Mul +{ + fn min(self, other: Self) -> Self; + fn max(self, other: Self) -> Self; + + /// `Mth.lerp`. + fn lerp(self, to: Self, t: f32) -> Self { + self + (to - self) * t + } +} + +impl AttributeValue for f32 { + fn min(self, other: Self) -> Self { + f32::min(self, other) + } + fn max(self, other: Self) -> Self { + f32::max(self, other) + } +} + +impl AttributeValue for Vec3 { + fn min(self, other: Self) -> Self { + Vec3::min(self, other) + } + fn max(self, other: Self) -> Self { + Vec3::max(self, other) + } +} + +/// The modifiers vanilla's float and RGB libraries share, plus the float-only +/// `minimum` and `maximum`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ModifierOp { + Override, + Multiply, + Add, + Subtract, + Minimum, + Maximum, + AlphaBlend, +} + +impl ModifierOp { + /// The modifier by its registry name; `minimum` and `maximum` only exist + /// for floats. + pub fn parse(name: &str, float: bool) -> Option { + Some(match name.strip_prefix("minecraft:").unwrap_or(name) { + "override" => Self::Override, + "multiply" => Self::Multiply, + "add" => Self::Add, + "subtract" => Self::Subtract, + "minimum" if float => Self::Minimum, + "maximum" if float => Self::Maximum, + "alpha_blend" => Self::AlphaBlend, + _ => return None, + }) + } +} + +/// A modifier's argument. `alpha` is only read by `alpha_blend` (vanilla +/// `FloatWithAlpha`, or the ARGB colour's alpha) and is 1 otherwise. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Argument { + pub value: T, + pub alpha: f32, +} + +impl Argument { + pub fn opaque(value: T) -> Self { + Self { value, alpha: 1.0 } + } + + fn lerp(self, to: Self, t: f32) -> Self { + Self { + value: self.value.lerp(to.value, t), + alpha: self.alpha.lerp(to.alpha, t), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Modifier { + pub op: ModifierOp, + pub argument: Argument, +} + +impl Modifier { + fn apply(op: ModifierOp, subject: T, argument: Argument) -> T { + let value = argument.value; + match op { + ModifierOp::Override => value, + ModifierOp::Multiply => subject * value, + ModifierOp::Add => subject + value, + ModifierOp::Subtract => subject - value, + ModifierOp::Minimum => subject.min(value), + ModifierOp::Maximum => subject.max(value), + ModifierOp::AlphaBlend => subject.lerp(value, argument.alpha), + } + } +} + +/// A timeline track: keyframes of a modifier's argument, linear between +/// them (`KeyframeTrack`'s default ease), wrapping around the timeline's +/// period when it has one. +/// TODO: non-linear `ease`; no vanilla lighting track uses one. +#[derive(Clone, Debug, PartialEq)] +pub struct Track { + pub op: ModifierOp, + pub keyframes: Vec<(i64, Argument)>, +} + +impl Track { + /// `KeyframeTrackSampler.sample` at whole clock `ticks`. + fn sample(&self, ticks: i64, period: Option) -> Argument { + let frames = &self.keyframes; + let (Some(first), Some(last)) = (frames.first(), frames.last()) else { + unreachable!("parsed tracks have keyframes"); + }; + let Some(period) = period else { + if ticks <= first.0 { + return first.1; + } + return match frames.windows(2).find(|pair| ticks < pair[1].0) { + Some(pair) => segment(pair[0], pair[1], ticks), + None => last.1, + }; + }; + let t = ticks.rem_euclid(period); + match frames + .windows(2) + .find(|pair| pair[0].0 <= t && t < pair[1].0) + { + Some(pair) => segment(pair[0], pair[1], t), + None if t < first.0 => segment((last.0 - period, last.1), *first, t), + None => segment(*last, (first.0 + period, first.1), t), + } + } +} + +fn segment( + (from, a): (i64, Argument), + (to, b): (i64, Argument), + t: i64, +) -> Argument { + a.lerp(b, (t - from) as f32 / (to - from) as f32) +} + +/// One attribute's layers from the dimension and its timelines. +#[derive(Clone, Debug, PartialEq)] +pub struct AttributeLayers { + pub dimension: Option>, + /// Each timeline's track for this attribute, with that timeline's period. + pub tracks: Vec<(Track, Option)>, +} + +impl Default for AttributeLayers { + fn default() -> Self { + Self { + dimension: None, + tracks: Vec::new(), + } + } +} + +impl AttributeLayers { + fn resolve(&self, default: T, ticks: i64) -> T { + let mut value = default; + if let Some(modifier) = self.dimension { + value = Modifier::apply(modifier.op, value, modifier.argument); + } + for (track, period) in &self.tracks { + value = Modifier::apply(track.op, value, track.sample(ticks, *period)); + } + value + } +} + +/// A dimension's lighting layers, from its dimension type and timelines. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LightEnvironment { + pub block_light_tint: AttributeLayers, + pub sky_light_color: AttributeLayers, + pub sky_light_factor: AttributeLayers, + pub night_vision_color: AttributeLayers, + pub ambient_light_color: AttributeLayers, + /// Vanilla `Level.canHaveWeather`, which gates the weather layer. + pub has_weather: bool, +} + +/// The resolved attributes for one tick. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LightAttributes { + pub block_light_tint: Vec3, + pub sky_light_color: Vec3, + pub sky_light_factor: f32, + pub night_vision_color: Vec3, + pub ambient_light_color: Vec3, +} + +/// `Timelines.NIGHT_SKY_LIGHT_FACTOR`, which rain and thunder blend the sky +/// light factor toward. +const NIGHT_SKY_LIGHT_FACTOR: f32 = 0.24; +/// `WeatherAttributes`' rain and thunder layers: the sky light factor's blend +/// alpha, then the colour's, which vanilla packs into an 8-bit ARGB alpha. +const WEATHER_ALPHAS: [(f32, f32); 2] = [(0.3125, 79.0 / 255.0), (0.527_343_75, 134.0 / 255.0)]; + +/// `Timelines.NIGHT_SKY_LIGHT_COLOR` (0.48, 0.48, 1.0), as its packed RGB. +fn night_sky_light_color() -> Vec3 { + rgb_hex(0x7A7AFF) +} + +/// A `0xRRGGBB` colour as 0..1 floats. +pub fn rgb_hex(rgb: u32) -> Vec3 { + Vec3::new( + ((rgb >> 16) & 0xFF) as f32, + ((rgb >> 8) & 0xFF) as f32, + (rgb & 0xFF) as f32, + ) / 255.0 +} + +impl LightEnvironment { + /// The attributes at whole clock `ticks`, then `WeatherAttributes`' rain + /// and thunder layers. `rain` and `thunder` are the level's rain and + /// thunder levels, thunder already scaled by rain. + pub fn resolve(&self, ticks: i64, rain: f32, thunder: f32) -> LightAttributes { + let mut sky_light_factor = self.sky_light_factor.resolve(1.0, ticks); + let mut sky_light_color = self.sky_light_color.resolve(Vec3::ONE, ticks); + if self.has_weather { + let rain_only = rain - thunder; + for (level, (factor_alpha, color_alpha)) in + [rain_only, thunder].into_iter().zip(WEATHER_ALPHAS) + { + if level > 0.0 { + let factor = sky_light_factor.lerp(NIGHT_SKY_LIGHT_FACTOR, factor_alpha); + sky_light_factor = sky_light_factor.lerp(factor, level); + let color = sky_light_color.lerp(night_sky_light_color(), color_alpha); + sky_light_color = sky_light_color.lerp(color, level); + } + } + } + LightAttributes { + block_light_tint: self.block_light_tint.resolve(rgb_hex(0xFFD88C), ticks), + sky_light_color, + // `AttributeRange.UNIT_FLOAT`'s sanitize. + sky_light_factor: sky_light_factor.clamp(0.0, 1.0), + night_vision_color: self.night_vision_color.resolve(rgb_hex(0x999999), ticks), + ambient_light_color: self.ambient_light_color.resolve(Vec3::ZERO, ticks), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn day() -> LightEnvironment { + let track = |op, frames: [(i64, f32); 4]| Track { + op, + keyframes: frames.map(|(t, v)| (t, Argument::opaque(v))).to_vec(), + }; + let color_track = Track { + op: ModifierOp::Multiply, + keyframes: [(730, Vec3::ONE), (11270, Vec3::ONE)] + .into_iter() + .chain([ + (13140, night_sky_light_color()), + (22860, night_sky_light_color()), + ]) + .map(|(t, v)| (t, Argument::opaque(v))) + .collect(), + }; + LightEnvironment { + sky_light_factor: AttributeLayers { + dimension: None, + tracks: vec![( + track( + ModifierOp::Multiply, + [(730, 1.0), (11270, 1.0), (13140, 0.24), (22860, 0.24)], + ), + Some(24000), + )], + }, + sky_light_color: AttributeLayers { + dimension: None, + tracks: vec![(color_track, Some(24000))], + }, + ambient_light_color: AttributeLayers { + dimension: Some(Modifier { + op: ModifierOp::Override, + argument: Argument::opaque(rgb_hex(0x0A0A0A)), + }), + tracks: Vec::new(), + }, + has_weather: true, + ..Default::default() + } + } + + #[test] + fn the_overworld_day_dims_the_sky_light_at_night() { + let env = day(); + let noon = env.resolve(6000, 0.0, 0.0); + assert_eq!(noon.sky_light_factor, 1.0); + assert_eq!(noon.sky_light_color, Vec3::ONE); + assert_eq!(noon.ambient_light_color, rgb_hex(0x0A0A0A)); + assert_eq!(noon.block_light_tint, rgb_hex(0xFFD88C)); + + let midnight = env.resolve(18000, 0.0, 0.0); + assert_eq!(midnight.sky_light_factor, 0.24); + assert_eq!(midnight.sky_light_color, night_sky_light_color()); + + let dusk = env.resolve(12205, 0.0, 0.0); + assert!((dusk.sky_light_factor - 0.62).abs() < 1e-6); + } + + #[test] + fn keyframes_wrap_around_the_period() { + let env = day(); + // Halfway from 22860 (0.24) to 730 of the next day (1.0). + let dawn = env.resolve(24000 + 23795, 0.0, 0.0); + assert!((dawn.sky_light_factor - 0.62).abs() < 1e-6); + assert_eq!(env.resolve(-18000, 0.0, 0.0).sky_light_factor, 1.0); + } + + #[test] + fn rain_and_thunder_blend_toward_night() { + let env = day(); + let rain = env.resolve(6000, 1.0, 0.0); + assert!((rain.sky_light_factor - 1.0.lerp(0.24, 0.3125)).abs() < 1e-6); + let storm = env.resolve(6000, 1.0, 1.0); + assert!((storm.sky_light_factor - 1.0.lerp(0.24, 0.527_343_75)).abs() < 1e-6); + let dry = LightEnvironment { + has_weather: false, + ..day() + }; + assert_eq!(dry.resolve(6000, 1.0, 1.0).sky_light_factor, 1.0); + } + + #[test] + fn a_dimension_without_layers_keeps_the_defaults() { + let attributes = LightEnvironment::default().resolve(18000, 0.0, 0.0); + assert_eq!(attributes.sky_light_factor, 1.0); + assert_eq!(attributes.sky_light_color, Vec3::ONE); + assert_eq!(attributes.night_vision_color, rgb_hex(0x999999)); + assert_eq!(attributes.ambient_light_color, Vec3::ZERO); + } + + #[test] + fn alpha_blend_uses_the_argument_alpha() { + let blend = Modifier { + op: ModifierOp::AlphaBlend, + argument: Argument { + value: 0.0, + alpha: 0.25, + }, + }; + assert_eq!(Modifier::apply(blend.op, 1.0, blend.argument), 0.75); + assert_eq!(ModifierOp::parse("minecraft:minimum", false), None); + assert_eq!(ModifierOp::parse("blend_to_gray", false), None); + } +} diff --git a/pomme-client/src/world/mod.rs b/pomme-client/src/world/mod.rs index 0564de78c..1e43bc665 100644 --- a/pomme-client/src/world/mod.rs +++ b/pomme-client/src/world/mod.rs @@ -2,6 +2,7 @@ pub mod block; pub mod block_entity; pub mod block_entity_anim; pub mod chunk; +pub mod environment; pub mod level; pub mod light; pub mod piston; diff --git a/tests/ingame/world/lightmap.toml b/tests/ingame/world/lightmap.toml new file mode 100644 index 000000000..d4da9a5a1 --- /dev/null +++ b/tests/ingame/world/lightmap.toml @@ -0,0 +1,178 @@ +# Terrain lit through the lightmap: sky light dims at night and in a storm, a +# torch lights a sealed room warm, the nether has its own colours, and the +# Brightness option lifts the dark. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/time set 6000" + +[[step]] +chat = "/tp @s 60.5 -60 50.5 0 15" + +[[step]] +expect = { player = { position = [60.5, -60.0, 50.5] } } + +# F1 hides the chat and HUD, so the screenshots show terrain only. +[[step]] +press = { action = "key.toggleGui" } + +[[step]] +wait_ms = 3000 + +[[step]] +screenshot = "noon" + +[[step]] +chat = "/time set 18000" + +[[step]] +wait_ms = 500 + +[[step]] +screenshot = "midnight" + +[[step]] +option = { brightness = 0.0 } + +[[step]] +wait_ms = 500 + +[[step]] +screenshot = "moody" + +[[step]] +option = { brightness = 1.0 } + +[[step]] +wait_ms = 500 + +[[step]] +screenshot = "bright" + +[[step]] +option = { brightness = 0.5 } + +[[step]] +chat = "/time set 6000" + +[[step]] +chat = "/weather thunder" + +[[step]] +expect = { player = { raining = true } } + +# Past the rain and thunder levels' ramp. +[[step]] +wait_ms = 6000 + +[[step]] +screenshot = "thunder" + +[[step]] +chat = "/weather clear" + +# A sealed stone room lit by one torch. +[[step]] +chat = "/fill 58 -61 54 64 -56 60 minecraft:stone hollow" + +[[step]] +chat = "/setblock 61 -60 58 minecraft:torch" + +[[step]] +chat = "/tp @s 61.5 -60 55.5 0 15" + +[[step]] +expect = { player = { position = [61.5, -60.0, 55.5] } } + +[[step]] +wait_ms = 1500 + +[[step]] +screenshot = "torch" + +# Multipart full cubes, an emissive block, light-giving blocks, partial faces +# and rotated models (whose corners are rewound after the blockstate turn), +# which vanilla's lighter treats each its own way. +[[step]] +chat = "/setblock 59 -60 59 minecraft:red_mushroom_block" + +[[step]] +chat = "/setblock 60 -60 59 minecraft:mushroom_stem" + +[[step]] +chat = "/setblock 62 -60 59 minecraft:chiseled_bookshelf" + +[[step]] +chat = "/setblock 63 -60 59 minecraft:magma_block" + +[[step]] +chat = "/setblock 59 -59 59 minecraft:glowstone" + +[[step]] +chat = "/setblock 63 -59 59 minecraft:sea_lantern" + +[[step]] +chat = "/setblock 60 -60 57 minecraft:stone_slab" + +[[step]] +chat = "/setblock 62 -60 57 minecraft:stone_stairs" + +[[step]] +chat = "/setblock 59 -60 57 minecraft:oak_log[axis=x]" + +[[step]] +chat = "/setblock 63 -60 57 minecraft:hay_block[axis=z]" + +[[step]] +chat = "/setblock 63 -60 58 minecraft:furnace[facing=east]" + +[[step]] +expect = { block = "stone_stairs", at = [62, -60, 57] } + +[[step]] +wait_ms = 1500 + +[[step]] +screenshot = "lit_blocks" + +[[step]] +chat = "/fill 58 -60 54 64 -56 60 minecraft:air" + +[[step]] +chat = "/fill 58 -61 54 64 -61 60 minecraft:grass_block" + +[[step]] +chat = "/execute in minecraft:the_nether run tp @s 0 100 0 0 20" + +[[step]] +expect = { player = { dimension = "the_nether" } } + +# Past the respawn's chunk loading. +[[step]] +wait_ms = 3000 + +[[step]] +screenshot = "nether" + +[[step]] +chat = "/execute in minecraft:overworld run tp @s 0.5 -60 0.5" + +[[step]] +expect = { player = { dimension = "overworld" } } + +[[step]] +press = { action = "key.toggleGui" } + +[[step]] +chat = "/gamemode survival" + +[[step]] +chat = "/gamerule advance_time true"