From 0647516cd139191dc78fd9571d03877a136b8cc3 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:01:54 +0000 Subject: [PATCH 1/2] drive the sky, clouds and lightmap from environment attributes with a per-tick probe --- pomme-client/src/app/core.rs | 8 +- pomme-client/src/app/phases/in_game.rs | 52 +- pomme-client/src/net/environment_wire.rs | 180 ++++- pomme-client/src/net/lifecycle_wire.rs | 23 +- pomme-client/src/net/mod.rs | 7 +- pomme-client/src/renderer/camera.rs | 5 +- pomme-client/src/renderer/mod.rs | 10 +- pomme-client/src/renderer/pipelines/clouds.rs | 6 +- pomme-client/src/renderer/pipelines/sky.rs | 385 +--------- pomme-client/src/renderer/shaders/clouds.frag | 4 +- pomme-client/src/renderer/shaders/sky.frag | 6 +- pomme-client/src/renderer/util.rs | 13 + pomme-client/src/world/environment.rs | 683 +++++++++++++++--- 13 files changed, 804 insertions(+), 578 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 849611471..3aebf2c7a 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -38,6 +38,7 @@ use crate::ui::text::InlineObject; use crate::user::UserData; use crate::world::block::block_registry::BlockDefinitions; use crate::world::chunk::ChunkStore; +use crate::world::environment::EnvironmentProbe; pub struct PendingPackDownload { pub id: uuid::Uuid, @@ -390,18 +391,17 @@ impl GameState { has_skylight, cardinal_light, ambient_light, - overworld_day, default_clock, - light_environment, + environment, } = dim; tracing::info!( "Dimension: height={height}, min_y={min_y}, skylight={has_skylight}, cardinal_light={cardinal_light:?}" ); self.cardinal_light = cardinal_light; self.ambient_light = ambient_light; - self.overworld_day = overworld_day; self.default_clock = default_clock; - self.light_environment = light_environment; + self.environment = *environment; + self.environment_probe = EnvironmentProbe::new(self.resolve_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 90cdb4394..a87ef12d8 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -268,13 +268,13 @@ pub struct GameState { /// Dimension-type `ambient_light`: 0 in the overworld, 0.1 in the /// nether, 0.25 in the end. pub ambient_light: f32, - /// The dimension follows the overworld's day (see `SkyState::sky_darken`). - pub overworld_day: bool, /// The current dimension's `default_clock`, whose ticks are its day time. pub default_clock: Option, pub clocks: crate::world::clock::ClientClocks, - /// The current dimension's lightmap attribute layers. - pub light_environment: crate::world::environment::LightEnvironment, + /// The current dimension's environment attribute layers, and the + /// attributes they resolve to each tick. + pub environment: crate::world::environment::Environment, + pub environment_probe: crate::world::environment::EnvironmentProbe, /// Vanilla `LightmapRenderStateExtractor.blockLightFlicker`. pub block_light_flicker: f32, /// Set by the test harness's `deterministic` step. @@ -391,9 +391,16 @@ impl GameState { .filter(|_| self.spectator_effect_active) } - /// This dimension's `skyDarken` right now. + /// This dimension's `skyDarken` as of the latest tick. pub fn sky_darken(&self) -> u8 { - self.sky_state.sky_darken(self.overworld_day) + self.environment_probe.current().sky_darken() + } + + /// The environment attributes at the current clock and weather. + pub fn resolve_environment(&self) -> crate::world::environment::EnvAttributes { + let sky = &self.sky_state; + self.environment + .resolve(sky.day_time as i64, sky.rain(), sky.thunder()) } /// Notes a played sound for the harness, keeping the last 64. @@ -547,10 +554,10 @@ impl GameState { dimension: String::new(), cardinal_light: Default::default(), ambient_light: 0.0, - overworld_day: true, default_clock: None, clocks: Default::default(), - light_environment: Default::default(), + environment: Default::default(), + environment_probe: Default::default(), block_light_flicker: 0.0, deterministic: false, game_mode_switcher: None, @@ -2311,9 +2318,9 @@ pub fn update_game( core.time_tick_accumulator = (core.time_tick_accumulator + dt).min(1.0); while core.time_tick_accumulator >= TICK_RATE { game.sky_state.game_time = game.sky_state.game_time.wrapping_add(1); - game.sky_state.prev_day_time = game.sky_state.day_time; game.clocks.tick(game.sky_state.game_time); game.sync_day_time(); + game.environment_probe.tick(game.resolve_environment()); core.time_tick_accumulator -= TICK_RATE; } @@ -2433,8 +2440,11 @@ pub fn update_game( core.tick_accumulator -= TICK_RATE; } if ticked { - gfx.renderer - .tick_lightmap(lightmap_info(game, core.menu.brightness)); + gfx.renderer.tick_lightmap(lightmap_info( + game, + core.menu.brightness, + sky_partial_tick(core), + )); } // Once per frame after the frame's ticks, where vanilla `Minecraft.runTick` @@ -4084,9 +4094,10 @@ pub fn update_game( }); } - let sky_partial_tick = (core.time_tick_accumulator / TICK_RATE).clamp(0.0, 1.0); + let partial_tick = sky_partial_tick(core); let sky = crate::renderer::SkyState { - partial_tick: sky_partial_tick, + partial_tick, + attributes: game.environment_probe.at(partial_tick), ..game.sky_state.clone() }; if game.show_chunk_borders { @@ -5314,13 +5325,16 @@ fn tick_block_light_flicker(flicker: f32) -> f32 { (flicker + (r() - r()) * r() * r() * 0.1) * 0.9 } -/// Vanilla `LightmapRenderStateExtractor.extract`. +/// How far the clock is between its last tick and the next. +fn sky_partial_tick(core: &AppCore) -> f32 { + (core.time_tick_accumulator / TICK_RATE).clamp(0.0, 1.0) +} + +/// Vanilla `LightmapRenderStateExtractor.extract`, which samples the +/// attribute probe at the frame's partial tick. /// 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()); +fn lightmap_info(game: &GameState, brightness: f32, partial_tick: f32) -> LightmapInfo { + let attributes = game.environment_probe.at(partial_tick); 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), diff --git a/pomme-client/src/net/environment_wire.rs b/pomme-client/src/net/environment_wire.rs index 4df5bdf2f..38d34ae8a 100644 --- a/pomme-client/src/net/environment_wire.rs +++ b/pomme-client/src/net/environment_wire.rs @@ -1,4 +1,4 @@ -//! Reads a dimension type's lighting attributes and its timelines' lighting +//! Reads a dimension type's environment attributes and its timelines' //! tracks out of the synced registries, in the shapes of vanilla's //! `EnvironmentAttributeMap` and `Timeline` network codecs. @@ -6,29 +6,35 @@ use std::collections::HashMap; use azalea_core::registry_holder::RegistryHolder; use azalea_registry::identifier::Identifier; -use glam::Vec3; +use glam::{Vec3, Vec4}; use simdnbt::owned::{NbtCompound, NbtList, NbtTag}; use crate::world::environment::{ - Argument, AttributeLayers, AttributeValue, LightAttribute, LightEnvironment, Modifier, + Argument, AttributeLayers, AttributeValue, Ease, EnvAttribute, Environment, Modifier, ModifierOp, Track, }; pub(super) const TIMELINE_REGISTRY: &str = "minecraft:timeline"; -/// A lighting attribute's value type and its codecs. +/// An 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>; + /// `add` and `subtract`'s argument, which is RGB for every colour. + fn read_rgb(tag: &NbtTag) -> Option { + Self::read(tag) + } } impl WireValue for f32 { const FLOAT: bool = true; + /// A number, or a `MoonPhase` by name as its index; no other float + /// attribute takes a string. fn read(tag: &NbtTag) -> Option { - number(tag) + number(tag).or_else(|| moon_phase(tag)) } fn read_with_alpha(tag: &NbtTag) -> Option> { @@ -62,6 +68,48 @@ impl WireValue for Vec3 { } } +impl WireValue for Vec4 { + const FLOAT: bool = false; + + /// `STRING_ARGB_VEC4_COLOR`. + fn read(tag: &NbtTag) -> Option { + let [a, r, g, b] = argb(tag, 8)?; + Some(Vec4::new(r, g, b, a)) + } + + fn read_with_alpha(tag: &NbtTag) -> Option> { + let value = Self::read(tag)?; + Some(Argument { + value, + alpha: value.w, + }) + } + + fn read_rgb(tag: &NbtTag) -> Option { + Some(Vec3::read(tag)?.extend(1.0)) + } +} + +/// `MoonPhase`'s serialized names, in order. +const MOON_PHASES: [&str; 8] = [ + "full_moon", + "waning_gibbous", + "third_quarter", + "waning_crescent", + "new_moon", + "waxing_crescent", + "first_quarter", + "waxing_gibbous", +]; + +fn moon_phase(tag: &NbtTag) -> Option { + let name = tag.string()?.to_str(); + MOON_PHASES + .iter() + .position(|phase| *phase == name) + .map(|i| i as f32) +} + fn number(tag: &NbtTag) -> Option { Some(match tag { NbtTag::Byte(v) => *v as f32, @@ -111,13 +159,14 @@ fn floats(list: &NbtList) -> Option> { fn argument(op: ModifierOp, tag: &NbtTag) -> Option> { match op { ModifierOp::AlphaBlend => T::read_with_alpha(tag), + ModifierOp::Add | ModifierOp::Subtract => T::read_rgb(tag).map(Argument::opaque), _ => 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 { +fn op(attribute: EnvAttribute, entry: &NbtCompound) -> Option { let Some(name) = entry.string("modifier") else { return Some(ModifierOp::Override); }; @@ -134,10 +183,7 @@ fn op(attribute: LightAttribute, entry: &NbtCompound) -> Option( - attribute: LightAttribute, - tag: &NbtTag, -) -> Option> { +fn dimension_modifier(attribute: EnvAttribute, 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), @@ -148,17 +194,34 @@ fn dimension_modifier( }) } -/// 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" +/// `EasingType.CODEC`: a simple easing's name, or `{cubic_bezier: [x1, y1, +/// x2, y2]}`; linear when absent. +fn ease(attribute: EnvAttribute, tag: Option<&NbtTag>) -> Ease { + let Some(tag) = tag else { + return Ease::Linear; + }; + if let Some(bezier) = tag.compound().and_then(|c| c.list("cubic_bezier")) + && let Ok(controls) = <[f32; 4]>::try_from(floats(bezier).unwrap_or_default()) { - tracing::warn!( - "{}: ease {ease} is not ported, sampling linearly", - attribute.id() - ); + return Ease::CubicBezier(controls); + } + match tag.string().map(|name| name.to_str()) { + Some(name) if name == "linear" => Ease::Linear, + Some(name) if name == "constant" => Ease::Constant, + name => { + tracing::warn!( + "{}: ease {name:?} is not ported, sampling linearly", + attribute.id() + ); + Ease::Linear + } } +} + +/// An `AttributeTrack`: `{modifier?, keyframes: [{ticks, value}], ease?}`. +fn track(attribute: EnvAttribute, entry: &NbtCompound) -> Option> { + let op = op::(attribute, entry)?; + let ease = ease(attribute, entry.get("ease")); let keyframes: Vec<_> = entry .list("keyframes")? .compounds()? @@ -168,12 +231,16 @@ fn track(attribute: LightAttribute, entry: &NbtCompound) -> Option Some((ticks, argument(op, frame.get("value")?)?)) }) .collect::>()?; - (!keyframes.is_empty()).then_some(Track { op, keyframes }) + (!keyframes.is_empty()).then_some(Track { + op, + ease, + keyframes, + }) } /// One attribute's dimension modifier and timeline tracks. fn layers( - attribute: LightAttribute, + attribute: EnvAttribute, attributes: Option<&NbtCompound>, timelines: &[&NbtCompound], ) -> AttributeLayers { @@ -194,13 +261,13 @@ fn layers( AttributeLayers { dimension, tracks } } -/// The dimension type's lighting layers. `timelines` must already be a list +/// The dimension type's attribute layers. `timelines` must already be a list /// of ids (see [`resolve_timeline_tags`]). -pub(super) fn light_environment( +pub(super) fn environment( extra: &HashMap, registries: &RegistryHolder, has_weather: bool, -) -> LightEnvironment { +) -> Environment { 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")) @@ -208,12 +275,30 @@ pub(super) fn light_environment( .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), + let argb = |attribute| layers::(attribute, attributes, &timelines); + let float = |attribute| layers::(attribute, attributes, &timelines); + let mut moon_phase = float(EnvAttribute::MoonPhase); + // An enum's keyframes step (`AttributeType.ofNotInterpolated`). + for (track, _) in &mut moon_phase.tracks { + track.ease = Ease::Constant; + } + Environment { + block_light_tint: rgb(EnvAttribute::BlockLightTint), + sky_light_color: rgb(EnvAttribute::SkyLightColor), + sky_light_factor: float(EnvAttribute::SkyLightFactor), + night_vision_color: rgb(EnvAttribute::NightVisionColor), + ambient_light_color: rgb(EnvAttribute::AmbientLightColor), + sky_color: rgb(EnvAttribute::SkyColor), + fog_color: rgb(EnvAttribute::FogColor), + cloud_color: argb(EnvAttribute::CloudColor), + cloud_height: float(EnvAttribute::CloudHeight), + sunrise_sunset_color: argb(EnvAttribute::SunriseSunsetColor), + star_brightness: float(EnvAttribute::StarBrightness), + sun_angle: float(EnvAttribute::SunAngle), + moon_angle: float(EnvAttribute::MoonAngle), + star_angle: float(EnvAttribute::StarAngle), + moon_phase, + sky_light_level: float(EnvAttribute::SkyLightLevel), has_weather, } } @@ -311,7 +396,7 @@ mod tests { #[test] fn dimension_entries_are_overrides_or_modifiers() { - let attribute = LightAttribute::SkyLightFactor; + let attribute = EnvAttribute::SkyLightFactor; assert_eq!( dimension_modifier::(attribute, &NbtTag::Float(0.0)), Some(Modifier { @@ -346,11 +431,42 @@ mod tests { "keyframes", NbtTag::List(NbtList::Compound(vec![frame(730, 1.0), frame(13140, 0.24)])), )]); - let track = track::(LightAttribute::SkyLightFactor, &entry).unwrap(); + let track = track::(EnvAttribute::SkyLightFactor, &entry).unwrap(); assert_eq!(track.op, ModifierOp::Override); + assert_eq!(track.ease, Ease::Linear); assert_eq!(track.keyframes[1], (13140, Argument::opaque(0.24))); } + #[test] + fn eases_read_names_and_bezier_controls() { + let attribute = EnvAttribute::SunAngle; + let bezier = NbtTag::Compound(compound(vec![( + "cubic_bezier", + NbtTag::List(NbtList::Float(vec![0.362, 0.241, 0.638, 0.759])), + )])); + assert_eq!( + ease(attribute, Some(&bezier)), + Ease::CubicBezier([0.362, 0.241, 0.638, 0.759]) + ); + let constant = NbtTag::String("constant".into()); + assert_eq!(ease(attribute, Some(&constant)), Ease::Constant); + assert_eq!(ease(attribute, None), Ease::Linear); + } + + #[test] + fn argb_colours_and_moon_phases_read() { + assert_eq!( + Vec4::read(&NbtTag::String("#ccffffff".into())), + Some(Vec4::new(1.0, 1.0, 1.0, 0xCC as f32 / 255.0)) + ); + assert_eq!( + Vec4::read_rgb(&NbtTag::String("#7f7f99".into())), + Some(Vec4::new(127.0 / 255.0, 127.0 / 255.0, 153.0 / 255.0, 1.0)) + ); + assert_eq!(f32::read(&NbtTag::String("new_moon".into())), Some(4.0)); + assert_eq!(f32::read(&NbtTag::String("blue_moon".into())), None); + } + #[test] fn timeline_tags_resolve_to_their_ids() { use azalea_core::registry_holder::RegistryType; diff --git a/pomme-client/src/net/lifecycle_wire.rs b/pomme-client/src/net/lifecycle_wire.rs index 86ce939c3..5fbd9f2e4 100644 --- a/pomme-client/src/net/lifecycle_wire.rs +++ b/pomme-client/src/net/lifecycle_wire.rs @@ -12,7 +12,6 @@ use crossbeam_channel::Sender; use pomme_protocol::codec::{DecodeResult, Reader}; use pomme_protocol::lifecycle::{self, LifecyclePacket, Login, SpawnInfo}; use pomme_protocol::{Direction, PacketTable, Phase}; -use simdnbt::owned::NbtTag; use super::chat_security::ProfileKeyServices; use super::handler::send_ordered; @@ -281,29 +280,15 @@ fn dimension_info( .get("ambient_light") .and_then(|tag| tag.float()) .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( + environment: Box::new(super::environment_wire::environment( &dim._extra, registries, has_weather, - ), + )), }) } -/// Whether a `timelines` holder set names `minecraft:day`, directly or through -/// the `#minecraft:in_overworld` tag; other tags aren't tracked client-side. -fn names_overworld_day(timelines: &NbtTag) -> bool { - let names = |id: &str| matches!(id, "minecraft:day" | "#minecraft:in_overworld"); - match timelines { - NbtTag::String(id) => names(&id.to_str()), - NbtTag::List(list) => list - .strings() - .is_some_and(|ids| ids.iter().any(|id| names(&id.to_str()))), - _ => false, - } -} - #[cfg(test)] mod tests { use std::collections::HashMap; @@ -375,7 +360,6 @@ mod tests { has_skylight, cardinal_light, ambient_light, - overworld_day, default_clock, .. }) = dimension_info( @@ -401,7 +385,6 @@ mod tests { assert_eq!((height, min_y, has_skylight), (384, -64, true)); assert_eq!(cardinal_light, CardinalLightType::Nether); assert_eq!(ambient_light, 0.1); - assert!(overworld_day); assert_eq!(default_clock.as_deref(), Some("minecraft:overworld")); } @@ -411,7 +394,6 @@ mod tests { has_skylight, cardinal_light, ambient_light, - overworld_day, default_clock, .. }) = dimension_info( @@ -425,7 +407,6 @@ mod tests { assert!(has_skylight); assert_eq!(cardinal_light, CardinalLightType::Default); assert_eq!(ambient_light, 0.0); - assert!(!overworld_day); assert_eq!(default_clock, None); } } diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index 9fc4305f9..402b51803 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -113,13 +113,10 @@ pub struct DimensionInfo { pub cardinal_light: crate::world::block::model::CardinalLightType, /// Dimension-type `ambient_light`, the lightmap's floor. pub ambient_light: f32, - /// The dimension's `timelines` include the overworld day, which dims - /// its sky light at night. - 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, + /// The lightmap's and the sky's attribute layers. + pub environment: Box, } pub enum NetworkEvent { diff --git a/pomme-client/src/renderer/camera.rs b/pomme-client/src/renderer/camera.rs index 48aab4731..990f128d4 100644 --- a/pomme-client/src/renderer/camera.rs +++ b/pomme-client/src/renderer/camera.rs @@ -620,7 +620,10 @@ impl CameraUniform { Self { view_proj: camera.view_projection().to_cols_array_2d(), camera_pos: [pos.x, pos.y, pos.z, fog_start], - fog_color: [fog_rgb[0], fog_rgb[1], fog_rgb[2], fog_end], + fog_color: { + let [r, g, b] = super::util::srgb_to_linear(fog_rgb); + [r, g, b, fog_end] + }, camera_block: anchor.as_ivec3().extend(0).to_array(), fog_env: [env_start, env_end, 0.0, 0.0], } diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index c20df618e..b02a293a1 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -1145,15 +1145,15 @@ impl Renderer { // terrain shows the clear color, so it must match the sky/terrain or it // reads as a horizon band (visible at night). Underwater, clear to the // water fog color so background gaps read as water rather than sky. - let clear_col = if eyes_in_water { + let [r, g, b] = util::srgb_to_linear(if eyes_in_water { camera::WATER_FOG_COLOR } else { - sky.sky_color() - }; + sky.attributes.sky_color.to_array() + }); self.render_frame( window, hide_cursor, - [clear_col[0], clear_col[1], clear_col[2], 1.0], + [r, g, b, 1.0], RenderMode::World { block_render, overlay, @@ -1481,7 +1481,7 @@ impl Renderer { { let uniform = CameraUniform::new( &self.camera, - sky.sky_color(), + sky.attributes.sky_color.to_array(), render_distance, eyes_in_water, ); diff --git a/pomme-client/src/renderer/pipelines/clouds.rs b/pomme-client/src/renderer/pipelines/clouds.rs index 6fc92e966..1e66f129a 100644 --- a/pomme-client/src/renderer/pipelines/clouds.rs +++ b/pomme-client/src/renderer/pipelines/clouds.rs @@ -6,8 +6,6 @@ use crate::renderer::pipelines::sky::SkyState; use crate::renderer::rhi::{self, VertexFormat}; use crate::renderer::{MAX_FRAMES_IN_FLIGHT, shader, util}; -/// World-space Y of the cloud layer bottom (vanilla overworld `cloud_height`). -const CLOUD_HEIGHT: f32 = 192.33; /// Vertical thickness of the cloud layer in blocks. const CLOUD_THICKNESS: f32 = 4.0; /// Horizontal size of one cloud cell in blocks (one clouds.png texel). @@ -220,7 +218,7 @@ impl CloudPipeline { let cell_z = (cloud_z / CELL_SIZE as f64).floor() as i32; let x_in_cell = (cloud_x - cell_x as f64 * CELL_SIZE as f64) as f32; let z_in_cell = (cloud_z - cell_z as f64 * CELL_SIZE as f64) as f32; - let relative_bottom_y = (CLOUD_HEIGHT as f64 - eye.y) as f32; + let relative_bottom_y = (f64::from(sky.attributes.cloud_height) - eye.y) as f32; let relative_top_y = relative_bottom_y + CLOUD_THICKNESS; let rel = if relative_top_y < 0.0 { RelativePos::Above @@ -258,7 +256,7 @@ impl CloudPipeline { self.dirty[frame] = false; } - let tint = sky.cloud_color(); + let tint = sky.attributes.cloud_color.to_array(); // offset.w carries the fog-fade end (blocks): the fragment shader fades // cloud alpha to zero by this distance so the field melts into the sky // instead of ending at a hard circle. diff --git a/pomme-client/src/renderer/pipelines/sky.rs b/pomme-client/src/renderer/pipelines/sky.rs index 2f03a34ad..70d30772d 100644 --- a/pomme-client/src/renderer/pipelines/sky.rs +++ b/pomme-client/src/renderer/pipelines/sky.rs @@ -1,92 +1,22 @@ -use std::f32::consts::{FRAC_PI_2, PI, TAU}; +use std::f32::consts::TAU; use std::path::Path; -use glam::{FloatExt, Vec3}; +use glam::Vec3; use crate::assets::AssetIndex; use crate::renderer::camera::Camera; use crate::renderer::{rhi, shader, util}; +use crate::world::environment::{EnvAttributes, Environment}; const STAR_COUNT: u32 = 1500; const SUN_SIZE: f32 = 30.0; const MOON_SIZE: f32 = 20.0; const CELESTIAL_DIST: f32 = 100.0; const SKY_DISC_RADIUS: f32 = 512.0; -const TICKS_PER_DAY: f32 = 24000.0; const SUNRISE_STEPS: u32 = 16; const MOON_BRIGHTNESS_PER_PHASE: [f32; 8] = [1.0, 0.75, 0.5, 0.25, 0.0, 0.25, 0.5, 0.75]; -const STAR_BRIGHTNESS_KEYFRAMES: &[(f32, f32)] = &[ - (92.0, 0.037), - (627.0, 0.0), - (11373.0, 0.0), - (11732.0, 0.016), - (11959.0, 0.044), - (12399.0, 0.143), - (12729.0, 0.258), - (13228.0, 0.5), - (22772.0, 0.5), - (23032.0, 0.364), - (23356.0, 0.225), - (23758.0, 0.101), -]; - -const SKY_COLOR_KEYFRAMES: &[(f32, [f32; 3])] = &[ - (133.0, [1.0, 1.0, 1.0]), - (11867.0, [1.0, 1.0, 1.0]), - (13670.0, [0.0, 0.0, 0.0]), - (22330.0, [0.0, 0.0, 0.0]), -]; - -/// The overworld timeline's `SKY_LIGHT_LEVEL` multiplier. -const SKY_LIGHT_LEVEL_KEYFRAMES: &[(f32, f32)] = &[ - (133.0, 1.0), - (11867.0, 1.0), - (13670.0, 0.266_666_68), - (22330.0, 0.266_666_68), -]; - -const SUNRISE_COLOR_KEYFRAMES: &[(f32, i32)] = &[ - (71.0, 1609540403), - (310.0, 703969843), - (565.0, 117167155), - (730.0, 16770355), - (11270.0, 16770355), - (11397.0, 83679283), - (11522.0, 268028723), - (11690.0, 703969843), - (11929.0, 1609540403), - (12243.0, -1310226637), - (12358.0, -857440717), - (12512.0, -371166669), - (12613.0, -153261261), - (12732.0, -19242189), - (12841.0, -19440589), - (13035.0, -321760973), - (13252.0, -1043577037), - (13775.0, 918435635), - (13888.0, 532362547), - (14039.0, 163001139), - (14192.0, 11744051), - (21807.0, 11678515), - (21961.0, 163001139), - (22112.0, 532362547), - (22225.0, 918435635), - (22748.0, -1043577037), - (22965.0, -321760973), - (23159.0, -19440589), - (23272.0, -19242189), - (23488.0, -371166669), - (23642.0, -857440717), - (23757.0, -1310226637), -]; - -const BASE_SKY_COLOR: [f32; 3] = [0.478, 0.659, 1.0]; - -// EasingType.symmetricCubicBezier(0.362, 0.241) -const SKY_ANGLE_BEZIER: (f32, f32, f32, f32) = (0.362, 0.241, 0.638, 0.759); - #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] struct SkyVertex { @@ -113,41 +43,26 @@ struct SkyUniform { #[derive(Clone)] pub struct SkyState { pub day_time: u64, - /// The day time as of the last tick, which frames blend from. - pub prev_day_time: u64, pub game_time: u64, pub rain_level: f32, pub thunder_level: f32, pub partial_tick: f32, + /// The environment attributes, blended for this frame. + pub attributes: EnvAttributes, } impl SkyState { pub fn default_day() -> Self { Self { day_time: 6000, - prev_day_time: 6000, game_time: 6000, rain_level: 0.0, thunder_level: 0.0, partial_tick: 0.0, + attributes: Environment::default().resolve(6000, 0.0, 0.0), } } - /// The day's tick for this frame. Vanilla samples the sky's attributes at - /// whole ticks (`AttributeTrackSampler`), then blends the last tick's into - /// this one's by the partial tick (`EnvironmentAttributeProbe`), its angles - /// snapping on a jump of 90 degrees or more (`LerpFunction.ofDegrees`). - pub fn day_tick(&self) -> f32 { - let (from, to) = (tick_of_day(self.prev_day_time), tick_of_day(self.day_time)); - let half_day = TICKS_PER_DAY / 2.0; - let delta = (to - from + half_day).rem_euclid(TICKS_PER_DAY) - half_day; - let turn = (sun_angle(to) - sun_angle(from) + PI).rem_euclid(TAU) - PI; - if turn.abs() >= FRAC_PI_2 { - return to; - } - (from + self.partial_tick * delta).rem_euclid(TICKS_PER_DAY) - } - /// Clamped rain level (server-driven). Vanilla `setRainLevel` stores prev /// == current, so no sub-tick interpolation is needed here. pub fn rain(&self) -> f32 { @@ -159,96 +74,6 @@ impl SkyState { pub fn thunder(&self) -> f32 { self.thunder_level.clamp(0.0, 1.0) * self.rain() } - - pub fn sky_color(&self) -> [f32; 3] { - let base = self.day_color(SKY_COLOR_KEYFRAMES); - self.apply_weather( - base, - |c| blend_to_gray(c, 0.6, 0.75), - |c| blend_to_gray(c, 0.24, 0.94), - ) - } - - /// Cloud tint (rgba): vanilla base is white at 0.8 alpha, darkened toward - /// night and blended to grey under rain/thunder. The day/night curve reuses - /// the sky-colour keyframes (1.0 by day, 0.0 at midnight) with a floor so - /// clouds stay faintly lit at night. - pub fn cloud_color(&self) -> [f32; 4] { - let day = sample_rgb_keyframes(self.day_tick(), SKY_COLOR_KEYFRAMES, TICKS_PER_DAY)[0]; - // Vanilla's night cloud multiplier is (0.1, 0.1, 0.15) — faintly blue. - let base = [0.1 + 0.9 * day, 0.1 + 0.9 * day, 0.15 + 0.85 * day]; - let rgb = self.apply_weather( - base, - |c| blend_to_gray(c, 0.24, 0.5), - |c| blend_to_gray(c, 0.095, 0.94), - ); - [rgb[0], rgb[1], rgb[2], 0.8] - } - - fn day_color(&self, keyframes: &[(f32, [f32; 3])]) -> [f32; 3] { - let mult = sample_rgb_keyframes(self.day_tick(), keyframes, TICKS_PER_DAY); - [ - BASE_SKY_COLOR[0] * mult[0], - BASE_SKY_COLOR[1] * mult[1], - BASE_SKY_COLOR[2] * mult[2], - ] - } - - /// Layers the rain then thunder color modifiers over the base color, each - /// scaled by its level (thunder is gated by rain), matching vanilla - /// `WeatherAttributes`: the sky blends to grey, the fog multiplies its RGB. - fn apply_weather( - &self, - rgb: [f32; 3], - rain_mod: impl Fn([f32; 3]) -> [f32; 3], - thunder_mod: impl Fn([f32; 3]) -> [f32; 3], - ) -> [f32; 3] { - self.weather_layers(rgb, rain_mod, thunder_mod, lerp_rgb) - } - - /// Vanilla `WeatherAttributes.addLayer`: the rain-only share of the rain - /// level, then the thunder level, each blending toward its modified value. - fn weather_layers( - &self, - mut value: T, - rain_mod: impl Fn(T) -> T, - thunder_mod: impl Fn(T) -> T, - lerp: impl Fn(T, T, f32) -> T, - ) -> T { - let thunder = self.thunder(); - let rain_only = (self.rain() - thunder).max(0.0); - if rain_only > 0.0 { - value = lerp(value, rain_mod(value), rain_only); - } - if thunder > 0.0 { - value = lerp(value, thunder_mod(value), thunder); - } - value - } - - /// Vanilla `Level.skyDarken`: 15 less `SKY_LIGHT_LEVEL`, which the - /// overworld day timeline takes from 15 by day to 4 at night, and rain - /// and thunder pull toward 4. Sampled per whole tick, as vanilla caches - /// it. The end has no day timeline; the nether has no sky light to dim. - pub fn sky_darken(&self, overworld_day: bool) -> u8 { - let toward_night = |alpha: f32| move |level: f32| level.lerp(4.0, alpha); - let day = if overworld_day { - sample_float_keyframes( - tick_of_day(self.day_time), - SKY_LIGHT_LEVEL_KEYFRAMES, - TICKS_PER_DAY, - ) - } else { - 1.0 - }; - let level = self.weather_layers( - 15.0 * day, - toward_night(0.3125), - toward_night(0.527_343_75), - FloatExt::lerp, - ); - (15.0 - level) as u8 - } } pub struct SkyPipeline { @@ -383,23 +208,10 @@ impl SkyPipeline { camera: &Camera, sky: &SkyState, ) { - let day_tick = sky.day_tick(); - - let sun_angle = sun_angle(day_tick); - let moon_angle = sun_angle + PI; - let star_angle = sun_angle; - - // Stars fade out as rain ramps up (vanilla forces star brightness to 0). - let star_brightness = - sample_float_keyframes(day_tick, STAR_BRIGHTNESS_KEYFRAMES, TICKS_PER_DAY) - * (1.0 - sky.rain()); - - let dome = sky.sky_color(); - let sky_color = [dome[0], dome[1], dome[2], 1.0]; - - let sunrise_argb = sample_argb_keyframes(day_tick, SUNRISE_COLOR_KEYFRAMES, TICKS_PER_DAY); - - let moon_phase_idx = ((sky.game_time / TICKS_PER_DAY as u64) % 8) as usize; + let attributes = &sky.attributes; + let sky_color = attributes.sky_color.extend(1.0).to_array(); + let sunrise_argb = attributes.sunrise_sunset_color.to_array(); + let moon_phase_idx = usize::from(attributes.moon_phase % 8); let moon_brightness = MOON_BRIGHTNESS_PER_PHASE[moon_phase_idx]; let celestial_alpha = 1.0 - sky.rain(); @@ -410,10 +222,10 @@ impl SkyPipeline { view_proj: view_proj.to_cols_array_2d(), sky_color, sunrise_color: sunrise_argb, - sun_angle, - moon_angle, - star_angle, - star_brightness, + sun_angle: attributes.sun_angle.to_radians(), + moon_angle: attributes.moon_angle.to_radians(), + star_angle: attributes.star_angle.to_radians(), + star_brightness: attributes.star_brightness, celestial_alpha, moon_brightness, moon_phase: moon_phase_idx as f32, @@ -460,7 +272,7 @@ impl SkyPipeline { push_mode(pass, 3); pass.draw(6, 1, self.moon_offset, 0); - if star_brightness > 0.01 { + if attributes.star_brightness > 0.01 { push_mode(pass, 1); pass.draw(self.star_count, 1, self.star_offset, 0); } @@ -527,119 +339,6 @@ impl JavaRandom { } } -fn tick_of_day(day_time: u64) -> f32 { - (day_time % TICKS_PER_DAY as u64) as f32 -} - -/// The sun's angle at a day tick, in radians. -fn sun_angle(day_tick: f32) -> f32 { - let t = (day_tick - 6000.0).rem_euclid(TICKS_PER_DAY) / TICKS_PER_DAY; - let (x1, y1, x2, y2) = SKY_ANGLE_BEZIER; - cubic_bezier_ease(t, x1, y1, x2, y2) * TAU -} - -/// CSS-style cubic Bezier easing with P0=(0,0), P3=(1,1). Solves Bx(s)=x via 4 -/// Newton-Raphson iterations (matches vanilla EasingType.CubicBezier) and -/// returns By(s). -fn cubic_bezier_ease(x: f32, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 { - let x = x.clamp(0.0, 1.0); - let cx = 3.0 * x1; - let bx = 3.0 * (x2 - x1) - cx; - let ax = 1.0 - cx - bx; - let cy = 3.0 * y1; - let by = 3.0 * (y2 - y1) - cy; - let ay = 1.0 - cy - by; - - let mut s = x; - for _ in 0..4 { - let d = (3.0 * ax * s + 2.0 * bx) * s + cx; - if d.abs() < 1e-6 { - break; - } - s -= (((ax * s + bx) * s + cx) * s - x) / d; - } - let s = s.clamp(0.0, 1.0); - ((ay * s + by) * s + cy) * s -} - -fn keyframe_segment( - t: f32, - count: usize, - tick_at: impl Fn(usize) -> f32, - period: f32, -) -> (usize, usize, f32) { - let mut i = 0; - while i < count && tick_at(i) <= t { - i += 1; - } - - if i == 0 { - let span = tick_at(0) + period - tick_at(count - 1); - (count - 1, 0, (t + period - tick_at(count - 1)) / span) - } else if i == count { - let span = tick_at(0) + period - tick_at(count - 1); - (count - 1, 0, (t - tick_at(count - 1)) / span) - } else { - ( - i - 1, - i, - (t - tick_at(i - 1)) / (tick_at(i) - tick_at(i - 1)), - ) - } -} - -fn sample_float_keyframes(tick: f32, keyframes: &[(f32, f32)], period: f32) -> f32 { - let (a, b, frac) = keyframe_segment(tick % period, keyframes.len(), |i| keyframes[i].0, period); - keyframes[a].1 + (keyframes[b].1 - keyframes[a].1) * frac -} - -fn sample_rgb_keyframes(tick: f32, keyframes: &[(f32, [f32; 3])], period: f32) -> [f32; 3] { - let (a, b, frac) = keyframe_segment(tick % period, keyframes.len(), |i| keyframes[i].0, period); - let (v0, v1) = (keyframes[a].1, keyframes[b].1); - [ - v0[0] + (v1[0] - v0[0]) * frac, - v0[1] + (v1[1] - v0[1]) * frac, - v0[2] + (v1[2] - v0[2]) * frac, - ] -} - -fn lerp_rgb(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { - [ - a[0] + (b[0] - a[0]) * t, - a[1] + (b[1] - a[1]) * t, - a[2] + (b[2] - a[2]) * t, - ] -} - -/// Vanilla `ColorModifier.BlendToGray`: greyscale the color, scale it by -/// `brightness`, then lerp the original toward that darkened grey by `factor`. -fn blend_to_gray(rgb: [f32; 3], brightness: f32, factor: f32) -> [f32; 3] { - let luma = rgb[0] * 0.3 + rgb[1] * 0.59 + rgb[2] * 0.11; - let grey = [luma * brightness, luma * brightness, luma * brightness]; - lerp_rgb(rgb, grey, factor) -} - -fn argb_to_rgba(argb: i32) -> [f32; 4] { - let u = argb as u32; - [ - ((u >> 16) & 0xFF) as f32 / 255.0, - ((u >> 8) & 0xFF) as f32 / 255.0, - (u & 0xFF) as f32 / 255.0, - ((u >> 24) & 0xFF) as f32 / 255.0, - ] -} - -fn sample_argb_keyframes(tick: f32, keyframes: &[(f32, i32)], period: f32) -> [f32; 4] { - let (a, b, frac) = keyframe_segment(tick % period, keyframes.len(), |i| keyframes[i].0, period); - let (c0, c1) = (argb_to_rgba(keyframes[a].1), argb_to_rgba(keyframes[b].1)); - [ - c0[0] + (c1[0] - c0[0]) * frac, - c0[1] + (c1[1] - c0[1]) * frac, - c0[2] + (c1[2] - c0[2]) * frac, - c0[3] + (c1[3] - c0[3]) * frac, - ] -} - struct Geometry { vertices: Vec, top_disc_offset: u32, @@ -857,57 +556,3 @@ fn create_pipelines( create("sky overlay", rhi::BlendState::OVERLAY), ) } - -#[cfg(test)] -mod tests { - use super::SkyState; - - fn at(day_time: u64, rain_level: f32, thunder_level: f32) -> SkyState { - SkyState { - day_time, - prev_day_time: day_time, - game_time: day_time, - rain_level, - thunder_level, - partial_tick: 0.0, - } - } - - fn blended(prev_day_time: u64, day_time: u64, partial_tick: f32) -> f32 { - SkyState { - prev_day_time, - partial_tick, - ..at(day_time, 0.0, 0.0) - } - .day_tick() - } - - #[test] - fn frames_blend_from_the_last_ticks_day_time() { - assert_eq!(blended(6000, 6001, 0.25), 6000.25); - assert_eq!(blended(6000, 6000, 0.75), 6000.0); - // Across midnight, the short way round. - assert_eq!(blended(23999, 24000, 0.5), 23999.5); - // Sped up, still blended. - assert_eq!(blended(6000, 6100, 0.5), 6050.0); - } - - #[test] - fn a_jump_of_a_quarter_turn_or_more_snaps() { - assert_eq!(blended(6000, 13000, 0.5), 13000.0); - assert_eq!(blended(6000, 18000, 0.5), 18000.0); - } - - #[test] - fn sky_darkens_at_night_and_in_weather() { - assert_eq!(at(6000, 0.0, 0.0).sky_darken(true), 0); - // 15 x 0.26666668 rounds to 4 in f32, as in vanilla. - assert_eq!(at(18000, 0.0, 0.0).sky_darken(true), 11); - assert_eq!(at(6000, 1.0, 0.0).sky_darken(true), 3); - assert_eq!(at(6000, 1.0, 1.0).sky_darken(true), 5); - // Halfway through dusk: 15 x 0.633. - assert_eq!(at(12768, 0.0, 0.0).sky_darken(true), 5); - // The end keeps its sky light through the night. - assert_eq!(at(18000, 0.0, 0.0).sky_darken(false), 0); - } -} diff --git a/pomme-client/src/renderer/shaders/clouds.frag b/pomme-client/src/renderer/shaders/clouds.frag index 1be7e48ac..6974695b1 100644 --- a/pomme-client/src/renderer/shaders/clouds.frag +++ b/pomme-client/src/renderer/shaders/clouds.frag @@ -10,11 +10,13 @@ layout(location = 1) in float v_dist; layout(location = 0) out vec4 out_color; +#include "fog.glsl" + void main() { // pc.offset.w = fog-fade end; fade cloud alpha linearly to zero by it, so the // field melts into the sky (vanilla `1 - linear_fog_value(dist, 0, FogCloudsEnd)`). float fog = clamp(v_dist / pc.offset.w, 0.0, 1.0); float a = pc.tint.a * (1.0 - fog); if (a < 0.01) discard; - out_color = vec4(v_color.rgb * pc.tint.rgb, a); + out_color = vec4(srgb_to_linear(v_color.rgb * pc.tint.rgb), a); } diff --git a/pomme-client/src/renderer/shaders/sky.frag b/pomme-client/src/renderer/shaders/sky.frag index b5949a5c6..cd3bfff79 100644 --- a/pomme-client/src/renderer/shaders/sky.frag +++ b/pomme-client/src/renderer/shaders/sky.frag @@ -25,9 +25,11 @@ layout(location = 0) in vec2 v_uv; layout(location = 0) out vec4 out_color; +#include "fog.glsl" + void main() { if (mode == 0) { - out_color = vec4(sky_color.rgb, 1.0); + out_color = vec4(srgb_to_linear(sky_color.rgb), 1.0); } else if (mode == 1) { float b = star_brightness; if (b < 0.01) discard; @@ -49,6 +51,6 @@ void main() { } else if (mode == 5) { float alpha = v_uv.x * sunrise_color.a; if (alpha < 0.001) discard; - out_color = vec4(sunrise_color.rgb, alpha); + out_color = vec4(srgb_to_linear(sunrise_color.rgb), alpha); } } diff --git a/pomme-client/src/renderer/util.rs b/pomme-client/src/renderer/util.rs index fbbecc4fa..34027b4fb 100644 --- a/pomme-client/src/renderer/util.rs +++ b/pomme-client/src/renderer/util.rs @@ -4,6 +4,19 @@ use super::{MAX_FRAMES_IN_FLIGHT, rhi}; use crate::assets::{AssetIndex, resolve_asset_path}; /// An sRGB texture holding `pixels`, ready for shaders to read. +/// The exact sRGB decode (`fog.glsl`'s `srgb_to_linear`): vanilla's colours +/// are sRGB values its framebuffer holds as they are, while pomme's swapchain +/// encodes what it's given. +pub fn srgb_to_linear(rgb: [f32; 3]) -> [f32; 3] { + rgb.map(|c| { + if c < 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + }) +} + pub fn create_sampled_texture( device: &rhi::backend::Device, label: &str, diff --git a/pomme-client/src/world/environment.rs b/pomme-client/src/world/environment.rs index e7b18fc4d..e610602c8 100644 --- a/pomme-client/src/world/environment.rs +++ b/pomme-client/src/world/environment.rs @@ -1,24 +1,36 @@ -//! 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. +//! The environment attributes behind the lightmap and the sky (vanilla +//! `EnvironmentAttributes`' `visual/*` entries and `gameplay/sky_light_level`), +//! resolved like `EnvironmentAttributeSystem`: the attribute's default, then +//! the dimension's layer, its timelines' tracks, and the weather. +//! TODO: the biome layer, and the client's lightning layer (`ClientLevel`'s +//! sky flash, which lifts the sky colour and the sky light factor). use std::ops::{Add, Mul, Sub}; -use glam::Vec3; +use glam::{Vec3, Vec4}; -/// The lighting attributes the lightmap reads. +/// The attributes pomme resolves. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LightAttribute { +pub enum EnvAttribute { BlockLightTint, SkyLightColor, SkyLightFactor, NightVisionColor, AmbientLightColor, + SkyColor, + FogColor, + CloudColor, + CloudHeight, + SunriseSunsetColor, + StarBrightness, + SunAngle, + MoonAngle, + StarAngle, + MoonPhase, + SkyLightLevel, } -impl LightAttribute { +impl EnvAttribute { /// The attribute's registry id. pub fn id(self) -> &'static str { match self { @@ -27,12 +39,24 @@ impl LightAttribute { Self::SkyLightFactor => "minecraft:visual/sky_light_factor", Self::NightVisionColor => "minecraft:visual/night_vision_color", Self::AmbientLightColor => "minecraft:visual/ambient_light_color", + Self::SkyColor => "minecraft:visual/sky_color", + Self::FogColor => "minecraft:visual/fog_color", + Self::CloudColor => "minecraft:visual/cloud_color", + Self::CloudHeight => "minecraft:visual/cloud_height", + Self::SunriseSunsetColor => "minecraft:visual/sunrise_sunset_color", + Self::StarBrightness => "minecraft:visual/star_brightness", + Self::SunAngle => "minecraft:visual/sun_angle", + Self::MoonAngle => "minecraft:visual/moon_angle", + Self::StarAngle => "minecraft:visual/star_angle", + Self::MoonPhase => "minecraft:visual/moon_phase", + Self::SkyLightLevel => "minecraft:gameplay/sky_light_level", } } } -/// A float or RGB attribute value; colours are vanilla's 0..1 sRGB floats, -/// which `LerpFunction.ofColorVec3` lerps component-wise. +/// A float, RGB or ARGB attribute value; colours are vanilla's 0..1 sRGB +/// floats, which `ARGB.srgbLerp` lerps component-wise. ARGB colours are +/// `Vec4`s of red, green, blue then alpha, as vanilla's `Vector4fc`. pub trait AttributeValue: Copy + Add + Sub + Mul + Mul { @@ -43,6 +67,21 @@ pub trait AttributeValue: fn lerp(self, to: Self, t: f32) -> Self { self + (to - self) * t } + + /// The `add` modifier; colours add their RGB and clamp it to 1. + fn apply_add(self, other: Self) -> Self { + self + other + } + + /// The `subtract` modifier; colours clamp their RGB to 0. + fn apply_subtract(self, other: Self) -> Self { + self - other + } + + /// The `alpha_blend` modifier. + fn alpha_blend(self, argument: Argument) -> Self { + self.lerp(argument.value, argument.alpha) + } } impl AttributeValue for f32 { @@ -61,10 +100,59 @@ impl AttributeValue for Vec3 { fn max(self, other: Self) -> Self { Vec3::max(self, other) } + /// `ARGB.addRgb`. + fn apply_add(self, other: Self) -> Self { + (self + other).min(Vec3::ONE) + } + /// `ARGB.subtractRgb`. + fn apply_subtract(self, other: Self) -> Self { + (self - other).max(Vec3::ZERO) + } } -/// The modifiers vanilla's float and RGB libraries share, plus the float-only -/// `minimum` and `maximum`. +impl AttributeValue for Vec4 { + fn min(self, other: Self) -> Self { + Vec4::min(self, other) + } + fn max(self, other: Self) -> Self { + Vec4::max(self, other) + } + /// `ARGB.addRgb`, which keeps the subject's alpha. + fn apply_add(self, other: Self) -> Self { + (self.truncate() + other.truncate()) + .min(Vec3::ONE) + .extend(self.w) + } + /// `ARGB.subtractRgb`, which keeps the subject's alpha. + fn apply_subtract(self, other: Self) -> Self { + (self.truncate() - other.truncate()) + .max(Vec3::ZERO) + .extend(self.w) + } + /// `ARGB.alphaBlend`: the argument composited over the subject. + fn alpha_blend(self, argument: Argument) -> Self { + let (source, alpha) = (argument.value, argument.value.w); + if alpha == 1.0 { + return source; + } + if alpha == 0.0 { + return self; + } + let result = alpha + self.w * (1.0 - alpha); + let channel = |d: f32, s: f32| (s * alpha + d * (result - alpha)) / result; + Vec4::new( + channel(self.x, source.x), + channel(self.y, source.y), + channel(self.z, source.z), + result, + ) + } +} + +/// The modifiers vanilla's float and colour libraries share, plus the +/// float-only `minimum` and `maximum`. +/// TODO: `blend_to_gray` in data; vanilla only applies it in its weather +/// layer ([`blend_to_gray`]), but a datapack's timeline can use it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ModifierOp { Override, @@ -126,61 +214,123 @@ impl Modifier { match op { ModifierOp::Override => value, ModifierOp::Multiply => subject * value, - ModifierOp::Add => subject + value, - ModifierOp::Subtract => subject - value, + ModifierOp::Add => subject.apply_add(value), + ModifierOp::Subtract => subject.apply_subtract(value), ModifierOp::Minimum => subject.min(value), ModifierOp::Maximum => subject.max(value), - ModifierOp::AlphaBlend => subject.lerp(value, argument.alpha), + ModifierOp::AlphaBlend => subject.alpha_blend(argument), + } + } +} + +/// Vanilla `EasingType`: how a track moves between two keyframes. +/// TODO: the `Ease` curves (`in_sine` and the rest); no vanilla timeline uses +/// them, and pomme samples them linearly. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Ease { + Constant, + Linear, + /// `CubicBezier`'s control points `x1, y1, x2, y2`. + CubicBezier([f32; 4]), +} + +impl Ease { + pub fn apply(self, x: f32) -> f32 { + match self { + Self::Constant => 0.0, + Self::Linear => x, + Self::CubicBezier([x1, y1, x2, y2]) => { + let curve = + |v1: f32, v2: f32| [3.0 * v1 - 3.0 * v2 + 1.0, -6.0 * v1 + 3.0 * v2, 3.0 * v1]; + let sample = |[a, b, c]: [f32; 3], t: f32| ((a * t + b) * t + c) * t; + let (x_curve, y_curve) = (curve(x1, x2), curve(y1, y2)); + sample(y_curve, solve_bezier_t(x_curve, sample, x)) + } + } + } +} + +/// `CubicBezier.solveT`: up to four Newton-Raphson steps, then bisection. +fn solve_bezier_t(curve: [f32; 3], sample: impl Fn([f32; 3], f32) -> f32, x: f32) -> f32 { + let [a, b, c] = curve; + let mut t = x; + for _ in 0..4 { + let error = sample(curve, t) - x; + if error.abs() < 1.0e-5 { + return t; + } + let gradient = (3.0 * a * t + 2.0 * b) * t + c; + if gradient < 1.0e-5 { + break; + } + t -= (error / gradient).clamp(-0.25, 0.25); + } + let (mut t0, mut t1) = (0.0f32, 1.0f32); + while t0 < t1 { + let error = sample(curve, t) - x; + if error.abs() < 1.0e-5 { + return t; } + if error < 0.0 { + t0 = t; + } else { + t1 = t; + } + t = (t1 + t0) / 2.0; } + t } -/// 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. +/// A timeline track: keyframes of a modifier's argument, eased between them, +/// wrapping around the timeline's period when it has one. #[derive(Clone, Debug, PartialEq)] pub struct Track { pub op: ModifierOp, + pub ease: Ease, pub keyframes: Vec<(i64, Argument)>, } impl Track { - /// `KeyframeTrackSampler.sample` at whole clock `ticks`. + /// `KeyframeTrackSampler.sample` at whole clock `ticks`: the first of + /// `bakeSegments`' segments that ends after them. fn sample(&self, ticks: i64, period: Option) -> Argument { let frames = &self.keyframes; - let (Some(first), Some(last)) = (frames.first(), frames.last()) else { + 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; + if frames.len() == 1 { + return first.1; + } + let wrap = period.map(|p| { + ( + ((last.0 - p, last.1), first), + (last, (first.0 + p, first.1)), + ) + }); + let segments = wrap + .map(|(into, _)| into) + .into_iter() + .chain(frames.windows(2).map(|pair| (pair[0], pair[1]))) + .chain(wrap.map(|(_, out)| out)); + let t = period.map_or(ticks, |p| ticks.rem_euclid(p)); + let mut segment = None; + for candidate in segments { + segment = Some(candidate); + if t < candidate.1.0 { + break; } - 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), } + let ((from, a), (to, b)) = segment.expect("two keyframes make a segment"); + if t <= from { + return a; + } + if t >= to { + return b; + } + a.lerp(b, self.ease.apply((t - from) as f32 / (to - from) as f32)) } } -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 { @@ -211,34 +361,119 @@ impl AttributeLayers { } } -/// A dimension's lighting layers, from its dimension type and timelines. +/// A dimension's attribute layers, from its dimension type and timelines. #[derive(Clone, Debug, Default, PartialEq)] -pub struct LightEnvironment { +pub struct Environment { 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, + pub sky_color: AttributeLayers, + pub fog_color: AttributeLayers, + pub cloud_color: AttributeLayers, + pub cloud_height: AttributeLayers, + pub sunrise_sunset_color: AttributeLayers, + pub star_brightness: AttributeLayers, + pub sun_angle: AttributeLayers, + pub moon_angle: AttributeLayers, + pub star_angle: AttributeLayers, + /// The `MoonPhase` index, from full moon (0) to waxing gibbous (7). + pub moon_phase: AttributeLayers, + pub sky_light_level: AttributeLayers, /// Vanilla `Level.canHaveWeather`, which gates the weather layer. pub has_weather: bool, } -/// The resolved attributes for one tick. +/// The resolved attributes. #[derive(Clone, Copy, Debug, PartialEq)] -pub struct LightAttributes { +pub struct EnvAttributes { 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, + pub sky_color: Vec3, + pub fog_color: Vec3, + pub cloud_color: Vec4, + pub cloud_height: f32, + pub sunrise_sunset_color: Vec4, + pub star_brightness: f32, + /// Degrees. + pub sun_angle: f32, + pub moon_angle: f32, + pub star_angle: f32, + pub moon_phase: u8, + pub sky_light_level: f32, +} + +impl EnvAttributes { + /// Vanilla `Level.skyDarken`: 15 less the sky light level. + pub fn sky_darken(&self) -> u8 { + (15.0 - self.sky_light_level) as u8 + } + + /// `EnvironmentAttributeProbe`'s per-frame lerp from `self` (the last + /// tick's values) to `to`, with each attribute type's `partialTickLerp`: + /// colours and floats linearly, angles by `LerpFunction.ofDegrees(90)`, + /// and the moon phase (a step at 0) straight to `to`. + pub fn lerp(&self, to: &Self, t: f32) -> Self { + let degrees = |from: f32, to: f32| { + let delta = wrap_degrees(to - from); + if delta.abs() >= 90.0 { + to + } else { + from + t * delta + } + }; + Self { + block_light_tint: self.block_light_tint.lerp(to.block_light_tint, t), + sky_light_color: self.sky_light_color.lerp(to.sky_light_color, t), + sky_light_factor: self.sky_light_factor.lerp(to.sky_light_factor, t), + night_vision_color: self.night_vision_color.lerp(to.night_vision_color, t), + ambient_light_color: self.ambient_light_color.lerp(to.ambient_light_color, t), + sky_color: self.sky_color.lerp(to.sky_color, t), + fog_color: self.fog_color.lerp(to.fog_color, t), + cloud_color: self.cloud_color.lerp(to.cloud_color, t), + cloud_height: self.cloud_height.lerp(to.cloud_height, t), + sunrise_sunset_color: self.sunrise_sunset_color.lerp(to.sunrise_sunset_color, t), + star_brightness: self.star_brightness.lerp(to.star_brightness, t), + sun_angle: degrees(self.sun_angle, to.sun_angle), + moon_angle: degrees(self.moon_angle, to.moon_angle), + star_angle: degrees(self.star_angle, to.star_angle), + moon_phase: to.moon_phase, + sky_light_level: self.sky_light_level.lerp(to.sky_light_level, t), + } + } +} + +/// `Mth.wrapDegrees`. +fn wrap_degrees(angle: f32) -> f32 { + let mut wrapped = angle % 360.0; + if wrapped >= 180.0 { + wrapped -= 360.0; + } + if wrapped < -180.0 { + wrapped += 360.0; + } + wrapped } /// `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. +/// The sky light level rain and thunder blend toward. +const NIGHT_SKY_LIGHT_LEVEL: f32 = 4.0; +/// `WeatherAttributes`' rain and thunder alphas for the float blends, then +/// the sky light 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)]; +/// `WeatherAttributes`' rain and thunder `BlendToGray` (brightness, factor) +/// for the sky colour, then the cloud colour. +const WEATHER_GRAY: [((f32, f32), (f32, f32)); 2] = + [((0.6, 0.75), (0.24, 0.5)), ((0.24, 0.94), (0.095, 0.94))]; +/// `WeatherAttributes`' rain and thunder fog and sunrise multipliers, packed +/// to 8 bits by `ARGB.colorFromFloat`: (0.5, 0.5, 0.6), then (0.25, 0.25, 0.3). +const WEATHER_TINTS: [u32; 2] = [0x7F7F99, 0x3F3F4C]; /// `Timelines.NIGHT_SKY_LIGHT_COLOR` (0.48, 0.48, 1.0), as its packed RGB. fn night_sky_light_color() -> Vec3 { @@ -254,79 +489,212 @@ pub fn rgb_hex(rgb: u32) -> Vec3 { ) / 255.0 } -impl LightEnvironment { +/// `ColorModifier.BLEND_TO_GRAY_*`: the colour toward its greyscale scaled by +/// `brightness`, by `factor`; an ARGB colour keeps its alpha. +fn blend_to_gray(rgb: Vec3, (brightness, factor): (f32, f32)) -> Vec3 { + let gray = rgb.dot(Vec3::new(0.3, 0.59, 0.11)); + rgb.lerp(Vec3::splat((gray * brightness).clamp(0.0, 1.0)), factor) +} + +impl Environment { /// 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); + pub fn resolve(&self, ticks: i64, rain: f32, thunder: f32) -> EnvAttributes { + let mut a = EnvAttributes { + block_light_tint: self.block_light_tint.resolve(rgb_hex(0xFFD88C), ticks), + sky_light_color: self.sky_light_color.resolve(Vec3::ONE, ticks), + sky_light_factor: self.sky_light_factor.resolve(1.0, ticks), + night_vision_color: self.night_vision_color.resolve(rgb_hex(0x999999), ticks), + ambient_light_color: self.ambient_light_color.resolve(Vec3::ZERO, ticks), + sky_color: self.sky_color.resolve(Vec3::ZERO, ticks), + fog_color: self.fog_color.resolve(Vec3::ZERO, ticks), + cloud_color: self.cloud_color.resolve(Vec4::ZERO, ticks), + cloud_height: self.cloud_height.resolve(192.33, ticks), + sunrise_sunset_color: self.sunrise_sunset_color.resolve(Vec4::ZERO, ticks), + star_brightness: self.star_brightness.resolve(0.0, ticks), + sun_angle: self.sun_angle.resolve(0.0, ticks), + moon_angle: self.moon_angle.resolve(0.0, ticks), + star_angle: self.star_angle.resolve(0.0, ticks), + moon_phase: self.moon_phase.resolve(0.0, ticks) as u8, + sky_light_level: self.sky_light_level.resolve(15.0, 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); + let layers = [rain - thunder, thunder].into_iter().zip( + WEATHER_ALPHAS + .into_iter() + .zip(WEATHER_GRAY) + .zip(WEATHER_TINTS), + ); + for (level, (((alpha, color_alpha), (sky_gray, cloud_gray)), tint)) in layers { + if level <= 0.0 { + continue; } + let tint = rgb_hex(tint); + // Each layer: `lerp(level, value, modified value)`. + let blend = |v: f32, target: f32| v.lerp(v.lerp(target, alpha), level); + a.sky_light_factor = blend(a.sky_light_factor, NIGHT_SKY_LIGHT_FACTOR); + a.sky_light_level = blend(a.sky_light_level, NIGHT_SKY_LIGHT_LEVEL); + let night = a.sky_light_color.lerp(night_sky_light_color(), color_alpha); + a.sky_light_color = a.sky_light_color.lerp(night, level); + a.sky_color = a + .sky_color + .lerp(blend_to_gray(a.sky_color, sky_gray), level); + a.fog_color = a.fog_color.lerp(a.fog_color * tint, level); + let cloud = + blend_to_gray(a.cloud_color.truncate(), cloud_gray).extend(a.cloud_color.w); + a.cloud_color = a.cloud_color.lerp(cloud, level); + let sunrise = a.sunrise_sunset_color * tint.extend(1.0); + a.sunrise_sunset_color = a.sunrise_sunset_color.lerp(sunrise, level); + a.star_brightness = a.star_brightness.lerp(0.0, 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), + // `AttributeRange`'s sanitize. + a.sky_light_factor = a.sky_light_factor.clamp(0.0, 1.0); + a.star_brightness = a.star_brightness.clamp(0.0, 1.0); + a.sky_light_level = a.sky_light_level.clamp(0.0, 15.0); + a + } +} + +/// Vanilla `EnvironmentAttributeProbe`: each tick's attributes, kept with +/// the last tick's so frames can blend between them. +#[derive(Clone, Copy, Debug)] +pub struct EnvironmentProbe { + last: EnvAttributes, + new: EnvAttributes, +} + +impl EnvironmentProbe { + pub fn new(attributes: EnvAttributes) -> Self { + Self { + last: attributes, + new: attributes, } } + + pub fn tick(&mut self, attributes: EnvAttributes) { + self.last = self.new; + self.new = attributes; + } + + /// The attributes as of the latest tick. + pub fn current(&self) -> &EnvAttributes { + &self.new + } + + /// The attributes `partial_tick` of the way from the last tick to the + /// latest. + pub fn at(&self, partial_tick: f32) -> EnvAttributes { + self.last.lerp(&self.new, partial_tick) + } +} + +impl Default for EnvironmentProbe { + fn default() -> Self { + Self::new(Environment::default().resolve(0, 0.0, 0.0)) + } } #[cfg(test)] mod tests { use super::*; - fn day() -> LightEnvironment { - let track = |op, frames: [(i64, f32); 4]| Track { + /// A `0xAARRGGBB` colour as 0..1 floats, red to alpha. + fn argb_hex(argb: u32) -> Vec4 { + rgb_hex(argb).extend((argb >> 24) as f32 / 255.0) + } + + fn track(op: ModifierOp, ease: Ease, frames: &[(i64, T)]) -> Track { + Track { op, - keyframes: frames.map(|(t, v)| (t, Argument::opaque(v))).to_vec(), + ease, + keyframes: frames + .iter() + .map(|&(t, v)| (t, Argument::opaque(v))) + .collect(), + } + } + + fn timeline(track: Track) -> AttributeLayers { + AttributeLayers { + dimension: None, + tracks: vec![(track, Some(24000))], + } + } + + fn dimension(value: T) -> AttributeLayers { + AttributeLayers { + dimension: Some(Modifier { + op: ModifierOp::Override, + argument: Argument::opaque(value), + }), + tracks: Vec::new(), + } + } + + const SUN_EASE: Ease = Ease::CubicBezier([0.362, 0.241, 0.638, 0.759]); + + /// The overworld's dimension type and `day` and `moon` timelines, as + /// vanilla's data has them. + fn overworld() -> Environment { + let (multiply, linear) = (ModifierOp::Multiply, Ease::Linear); + // The sky, fog and cloud colours' day and night keyframes. + fn day_night(day: T, night: T) -> [(i64, T); 4] { + [(133, day), (11867, day), (13670, night), (22330, night)] + } + let colour = |base: u32, night: u32| AttributeLayers { + dimension: dimension(rgb_hex(base)).dimension, + ..timeline(track( + multiply, + linear, + &day_night(Vec3::ONE, rgb_hex(night)), + )) }; - let color_track = Track { - op: ModifierOp::Multiply, - keyframes: [(730, Vec3::ONE), (11270, Vec3::ONE)] - .into_iter() - .chain([ + Environment { + sky_light_factor: timeline(track( + multiply, + linear, + &[(730, 1.0), (11270, 1.0), (13140, 0.24), (22860, 0.24)], + )), + sky_light_color: timeline(track( + multiply, + linear, + &[ + (730, Vec3::ONE), + (11270, Vec3::ONE), (13140, night_sky_light_color()), (22860, night_sky_light_color()), - ]) - .map(|(t, v)| (t, Argument::opaque(v))) - .collect(), - }; - LightEnvironment { - sky_light_factor: AttributeLayers { + ], + )), + ambient_light_color: dimension(rgb_hex(0x0A0A0A)), + sky_color: colour(0x78A7FF, 0x000000), + fog_color: colour(0xC0D8FF, 0x0F0F16), + cloud_color: AttributeLayers { + dimension: dimension(argb_hex(0xCCFFFFFF)).dimension, + ..timeline(track( + multiply, + linear, + &day_night(Vec4::ONE, argb_hex(0xFF191926)), + )) + }, + sun_angle: timeline(track( + ModifierOp::Override, + SUN_EASE, + &[(6000, 360.0), (6000, 0.0)], + )), + moon_phase: AttributeLayers { dimension: None, tracks: vec![( track( - ModifierOp::Multiply, - [(730, 1.0), (11270, 1.0), (13140, 0.24), (22860, 0.24)], + ModifierOp::Override, + Ease::Constant, + &(0..8).map(|i| (i * 24000, i as f32)).collect::>(), ), - Some(24000), + Some(192000), )], }, - 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(), - }, + sky_light_level: timeline(track(multiply, linear, &day_night(1.0, 0.266_666_68))), has_weather: true, ..Default::default() } @@ -334,7 +702,7 @@ mod tests { #[test] fn the_overworld_day_dims_the_sky_light_at_night() { - let env = day(); + let env = overworld(); 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); @@ -351,47 +719,134 @@ mod tests { #[test] fn keyframes_wrap_around_the_period() { - let env = day(); + let env = overworld(); // 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 the_sky_and_fog_darken_at_night() { + let env = overworld(); + let noon = env.resolve(6000, 0.0, 0.0); + assert_eq!(noon.sky_color, rgb_hex(0x78A7FF)); + assert_eq!(noon.fog_color, rgb_hex(0xC0D8FF)); + assert_eq!(noon.cloud_color, argb_hex(0xCCFFFFFF)); + let midnight = env.resolve(18000, 0.0, 0.0); + assert_eq!(midnight.sky_color, Vec3::ZERO); + assert_eq!(midnight.fog_color, rgb_hex(0xC0D8FF) * rgb_hex(0x0F0F16)); + assert_eq!( + midnight.cloud_color, + argb_hex(0xCCFFFFFF) * argb_hex(0xFF191926) + ); + } + + #[test] + fn the_sun_sweeps_a_full_turn_from_noon() { + let env = overworld(); + let angle = |t| env.resolve(t, 0.0, 0.0).sun_angle; + assert_eq!(angle(6000), 0.0); + assert!((angle(18000) - 180.0).abs() < 1e-3); + let mut last = angle(6000); + for t in (6001..30000).step_by(250) { + let a = angle(t); + assert!(a >= last, "{t}: {a} < {last}"); + last = a; + } + } + + #[test] + fn the_moon_steps_through_its_phases() { + let env = overworld(); + assert_eq!(env.resolve(0, 0.0, 0.0).moon_phase, 0); + assert_eq!(env.resolve(23999, 0.0, 0.0).moon_phase, 0); + assert_eq!(env.resolve(24000, 0.0, 0.0).moon_phase, 1); + assert_eq!(env.resolve(7 * 24000 + 100, 0.0, 0.0).moon_phase, 7); + assert_eq!(env.resolve(8 * 24000, 0.0, 0.0).moon_phase, 0); + } + #[test] fn rain_and_thunder_blend_toward_night() { - let env = day(); + let env = overworld(); 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 { + assert_eq!(storm.star_brightness, 0.0); + assert_eq!(storm.fog_color, rgb_hex(0xC0D8FF) * rgb_hex(0x3F3F4C)); + let gray = blend_to_gray(rgb_hex(0x78A7FF), (0.24, 0.94)); + assert!((storm.sky_color - gray).abs().max_element() < 1e-6); + let dry = Environment { has_weather: false, - ..day() + ..overworld() }; assert_eq!(dry.resolve(6000, 1.0, 1.0).sky_light_factor, 1.0); } + #[test] + fn the_sky_darkens_at_night_and_in_weather() { + let env = overworld(); + let darken = |t, rain, thunder| env.resolve(t, rain, thunder).sky_darken(); + assert_eq!(darken(6000, 0.0, 0.0), 0); + // 15 x 0.26666668 rounds to 4 in f32, as in vanilla. + assert_eq!(darken(18000, 0.0, 0.0), 11); + assert_eq!(darken(6000, 1.0, 0.0), 3); + assert_eq!(darken(6000, 1.0, 1.0), 5); + assert_eq!( + Environment::default().resolve(18000, 0.0, 0.0).sky_darken(), + 0 + ); + } + #[test] fn a_dimension_without_layers_keeps_the_defaults() { - let attributes = LightEnvironment::default().resolve(18000, 0.0, 0.0); + let attributes = Environment::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); + assert_eq!(attributes.cloud_height, 192.33); + assert_eq!(attributes.sky_light_level, 15.0); } #[test] - fn alpha_blend_uses_the_argument_alpha() { - let blend = Modifier { - op: ModifierOp::AlphaBlend, - argument: Argument { - value: 0.0, - alpha: 0.25, - }, + fn modifiers_follow_their_colour_library() { + let blend = Argument { + value: 0.0, + alpha: 0.25, }; - assert_eq!(Modifier::apply(blend.op, 1.0, blend.argument), 0.75); + assert_eq!(Modifier::apply(ModifierOp::AlphaBlend, 1.0, blend), 0.75); assert_eq!(ModifierOp::parse("minecraft:minimum", false), None); assert_eq!(ModifierOp::parse("blend_to_gray", false), None); + let add = Modifier::apply( + ModifierOp::Add, + Vec3::splat(0.75), + Argument::opaque(Vec3::splat(0.5)), + ); + assert_eq!(add, Vec3::ONE); + let over = Vec4::new(1.0, 0.0, 0.0, 0.5) + .alpha_blend(Argument::opaque(Vec4::new(0.0, 0.0, 1.0, 0.5))); + assert_eq!(over, Vec4::new(1.0 / 3.0, 0.0, 2.0 / 3.0, 0.75)); + } + + #[test] + fn a_single_keyframe_is_constant() { + let single = track(ModifierOp::Override, Ease::Linear, &[(500, 0.3f32)]); + assert_eq!(single.sample(0, Some(24000)).value, 0.3); + assert_eq!(single.sample(23000, None).value, 0.3); + } + + #[test] + fn frames_blend_like_the_attribute_probe() { + let env = overworld(); + let mut probe = EnvironmentProbe::new(env.resolve(6000, 0.0, 0.0)); + probe.tick(env.resolve(6001, 0.0, 0.0)); + let half = probe.at(0.5).sun_angle; + assert!(half > 0.0 && half < probe.current().sun_angle); + // A jump of a quarter turn or more snaps. + probe.tick(env.resolve(13000, 0.0, 0.0)); + assert_eq!(probe.at(0.5).sun_angle, probe.current().sun_angle); + assert_eq!(probe.at(0.5).moon_phase, probe.current().moon_phase); } } From ff7b048d7a93045b14c0ff7fa5bb9c973a9227fd Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:33:28 +0000 Subject: [PATCH 2/2] keep the moon phase to override modifiers and note per-timeline clocks --- pomme-client/src/net/environment_wire.rs | 6 +++++- pomme-client/src/renderer/util.rs | 2 +- pomme-client/src/world/environment.rs | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pomme-client/src/net/environment_wire.rs b/pomme-client/src/net/environment_wire.rs index 38d34ae8a..23858a73b 100644 --- a/pomme-client/src/net/environment_wire.rs +++ b/pomme-client/src/net/environment_wire.rs @@ -277,8 +277,12 @@ pub(super) fn environment( let rgb = |attribute| layers::(attribute, attributes, &timelines); let argb = |attribute| layers::(attribute, attributes, &timelines); let float = |attribute| layers::(attribute, attributes, &timelines); + // An enum takes only `override`, and its keyframes step + // (`AttributeType.ofNotInterpolated`). let mut moon_phase = float(EnvAttribute::MoonPhase); - // An enum's keyframes step (`AttributeType.ofNotInterpolated`). + let overrides = |op| op == ModifierOp::Override; + moon_phase.dimension = moon_phase.dimension.filter(|m| overrides(m.op)); + moon_phase.tracks.retain(|(track, _)| overrides(track.op)); for (track, _) in &mut moon_phase.tracks { track.ease = Ease::Constant; } diff --git a/pomme-client/src/renderer/util.rs b/pomme-client/src/renderer/util.rs index 34027b4fb..e2c39fd38 100644 --- a/pomme-client/src/renderer/util.rs +++ b/pomme-client/src/renderer/util.rs @@ -3,7 +3,6 @@ use std::path::Path; use super::{MAX_FRAMES_IN_FLIGHT, rhi}; use crate::assets::{AssetIndex, resolve_asset_path}; -/// An sRGB texture holding `pixels`, ready for shaders to read. /// The exact sRGB decode (`fog.glsl`'s `srgb_to_linear`): vanilla's colours /// are sRGB values its framebuffer holds as they are, while pomme's swapchain /// encodes what it's given. @@ -17,6 +16,7 @@ pub fn srgb_to_linear(rgb: [f32; 3]) -> [f32; 3] { }) } +/// An sRGB texture holding `pixels`, ready for shaders to read. pub fn create_sampled_texture( device: &rhi::backend::Device, label: &str, diff --git a/pomme-client/src/world/environment.rs b/pomme-client/src/world/environment.rs index e610602c8..bd2f2106d 100644 --- a/pomme-client/src/world/environment.rs +++ b/pomme-client/src/world/environment.rs @@ -336,6 +336,8 @@ impl Track { pub struct AttributeLayers { pub dimension: Option>, /// Each timeline's track for this attribute, with that timeline's period. + /// TODO: each timeline's own `clock`; every track reads the dimension's + /// default clock, which every vanilla timeline uses. pub tracks: Vec<(Track, Option)>, }