diff --git a/crates/horizon-client/src/dynamic_light.rs b/crates/horizon-client/src/dynamic_light.rs new file mode 100644 index 000000000..4502b3356 --- /dev/null +++ b/crates/horizon-client/src/dynamic_light.rs @@ -0,0 +1,133 @@ +//! Horizon Extras' dynamic lights (docs/extras.md): a torch, lantern or other light source held by +//! a player or mob, dropped as an item, or an entity on fire lights the blocks and entities +//! around it. Not in vanilla; off by default. +//! +//! Client-only and at render time: the light engine, chunk meshes and saves never see it. Each +//! frame the game picks the [`MAX`] sources nearest the camera; terrain.wgsl and entity.wgsl +//! raise a vertex's block light to `level - distance` (one level per block, as block light falls +//! off) when that is brighter. Off, the list is empty and the shaders skip the loop. +use crate::blocks::BlockFacts; +use glam::DVec3; +use horizon_world::registry::BlockRegistry; +use std::collections::HashMap; + +/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl. +pub const MAX: usize = 16; +/// What an entity on fire gives off, as a lit campfire or fire block does. +pub const BURNING: u8 = 15; +/// Sources farther than this from the camera light nothing it can see well. +const REACH: f64 = 96.0; + +/// How brightly each item glows when held or dropped. +#[derive(Debug, Default)] +pub struct Luminance(HashMap); + +impl Luminance { + /// A block item glows like its block's default state (`getLightEmission`): torches 14, + /// lanterns and glowstone 15, a lit redstone torch 7; a furnace's item, unlit, not at all. + pub fn new(facts: &BlockFacts, registry: &BlockRegistry) -> Self { + let mut map = HashMap::new(); + for (id, state) in facts.states.iter().enumerate() { + if state.emission == 0 || *state.item != *state.name { + continue; + } + if registry.default_state(&state.name).map(|d| d.0 as usize) == Some(id) { + map.insert(state.item.to_string(), state.emission); + } + } + // A bucket of lava glows as lava does. + map.insert("minecraft:lava_bucket".into(), 15); + Self(map) + } + + pub fn of(&self, item: &str) -> u8 { + self.0.get(item).copied().unwrap_or(0) + } + + #[cfg(test)] + fn from_pairs(pairs: &[(&str, u8)]) -> Self { + Self(pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()) + } +} + +/// One light this frame: where it is (world space) and its level, 1 to 15. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Source { + pub at: DVec3, + pub level: u8, +} + +/// The [`MAX`] sources nearest `camera` within reach, as the shaders read them: camera-relative +/// position and level. Overwrites `out`. +pub fn select(sources: &[Source], camera: DVec3, out: &mut Vec<[f32; 4]>) { + out.clear(); + let key = |s: &Source| s.at.distance_squared(camera); + let mut near: Vec<&Source> = sources + .iter() + .filter(|s| s.level > 0 && key(s) <= REACH * REACH) + .collect(); + near.sort_by(|a, b| key(a).total_cmp(&key(b))); + out.extend(near.into_iter().take(MAX).map(|s| { + let p = (s.at - camera).as_vec3(); + [p.x, p.y, p.z, f32::from(s.level.min(15))] + })); +} + +/// The block light a vertex at `pos` (camera-relative) gets from `lights`, 0 to 15: the WGSL +/// `dynamic_block_light` in Rust, for tests. +pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 { + lights.iter().fold(0.0f32, |level, l| { + let d = + ((pos[0] - l[0]).powi(2) + (pos[1] - l[1]).powi(2) + (pos[2] - l[2]).powi(2)).sqrt(); + level.max(l[3] - d) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nearest_sources_win() { + let camera = DVec3::new(100.0, 64.0, 100.0); + let mut sources: Vec = (0..40) + .map(|i| Source { + at: camera + DVec3::new(f64::from(i), 0.0, 0.0), + level: 14, + }) + .collect(); + sources.push(Source { + at: camera + DVec3::new(0.5, 0.0, 0.0), + level: 0, + }); + sources.push(Source { + at: camera + DVec3::new(0.0, 0.0, 200.0), + level: 15, + }); + let mut out = Vec::new(); + select(&sources, camera, &mut out); + assert_eq!(out.len(), MAX); + assert_eq!(out[0], [0.0, 0.0, 0.0, 14.0]); + assert_eq!(out[MAX - 1][0], (MAX - 1) as f32); + select(&[], camera, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn light_falls_off_a_level_per_block() { + let torch = [[0.0, 0.0, 0.0, 14.0]]; + assert_eq!(level_at([0.0, 0.0, 0.0], &torch), 14.0); + assert_eq!(level_at([3.0, 4.0, 0.0], &torch), 9.0); + assert_eq!(level_at([20.0, 0.0, 0.0], &torch), 0.0); + let two = [[0.0, 0.0, 0.0, 14.0], [10.0, 0.0, 0.0, 15.0]]; + assert_eq!(level_at([8.0, 0.0, 0.0], &two), 13.0); + assert_eq!(level_at([0.0; 3], &[]), 0.0); + } + + #[test] + fn item_luminance() { + let l = Luminance::from_pairs(&[("minecraft:torch", 14)]); + assert_eq!(l.of("minecraft:torch"), 14); + assert_eq!(l.of("minecraft:stone"), 0); + } +} diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl index 42113df78..2c6af26f9 100644 --- a/crates/horizon-client/src/entity.wgsl +++ b/crates/horizon-client/src/entity.wgsl @@ -14,11 +14,13 @@ struct Globals { // Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png. end_portal_sprites: array, 2>, // x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000) - // y: Nether cardinal lighting. + // y: Nether cardinal lighting, z: how many dynamic_lights are set. time: vec4, crumbling_sprite: vec4, // Selection outline parameters (read by terrain.wgsl). outline_params: vec4, + // Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs). + dynamic_lights: array, 16>, } @group(0) @binding(0) var globals: Globals; @group(0) @binding(1) var atlas: texture_2d; @@ -58,13 +60,25 @@ fn overlay_texel(overlay: u32) -> vec4 { if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); } return vec4(1.0); } +// Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units +// (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one +// level less per block. time.z of them are set: none when the extra is off, so no loop runs. +fn dynamic_block_light(pos: vec3) -> f32 { + let count = min(u32(globals.time.z), 16u); + var level = 0.0; + for (var i = 0u; i < count; i++) { + let l = globals.dynamic_lights[i]; + level = max(level, l.w - distance(pos, l.xyz)); + } + return level * 16.0; +} fn entity_vertex(position: vec3, uv: vec2, overlay: u32, color: vec4, light: vec4, normal: vec4) -> Out { var out: Out; out.clip = globals.view_proj * vec4(position, 1.0); out.proj = vec4(out.clip.xy * 0.5 + out.clip.w * 0.5, out.clip.zw); out.uv = uv; - let l = vec2(f32(light.y), f32(light.x)); + let l = vec2(max(f32(light.y), dynamic_block_light(position)), f32(light.x)); let lm = textureSampleLevel(lightmap, lightmap_sampler, clamp(l / 256.0 + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0); // Celestial bodies, clouds, beacon beams, End portals and breaking cracks are unlit. diff --git a/crates/horizon-client/src/extras.rs b/crates/horizon-client/src/extras.rs index adbc6e23a..c7be78019 100644 --- a/crates/horizon-client/src/extras.rs +++ b/crates/horizon-client/src/extras.rs @@ -69,6 +69,8 @@ impl Corner { #[derive(Clone, Debug, PartialEq)] pub struct Extras { pub fullbright: bool, + /// Held, dropped and burning light sources light their surroundings (`dynamic_light`). + pub dynamic_lights: bool, pub zoom: bool, /// A vanilla key name (`key.keyboard.z`). pub zoom_key: String, @@ -110,6 +112,7 @@ impl Default for Extras { fn default() -> Self { Self { fullbright: false, + dynamic_lights: false, zoom: false, zoom_key: "key.keyboard.z".into(), zoom_level: 4, @@ -158,6 +161,7 @@ impl Extras { }; match key.trim() { "fullbright" => e.fullbright = flag(e.fullbright), + "dynamicLights" => e.dynamic_lights = flag(e.dynamic_lights), "zoom" => e.zoom = flag(e.zoom), "zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(), "zoomLevel" => { @@ -201,8 +205,9 @@ impl Extras { } pub fn serialize(&self) -> String { format!( - "fullbright:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n", + "fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n", bool_text(self.fullbright), + bool_text(self.dynamic_lights), bool_text(self.zoom), self.zoom_key, self.zoom_level, @@ -423,6 +428,7 @@ impl Cps { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Item { Fullbright, + DynamicLights, Zoom, ZoomKey, ZoomLevel, @@ -452,6 +458,7 @@ pub fn rows() -> Vec> { vec![ Err("Lighting"), Ok(Fullbright), + Ok(DynamicLights), Err("Zoom"), Ok(Zoom), Ok(ZoomKey), @@ -501,6 +508,7 @@ impl Item { pub fn caption(self) -> &'static str { match self { Item::Fullbright => "Fullbright", + Item::DynamicLights => "Dynamic lights", Item::Zoom => "Zoom", Item::ZoomKey => "Zoom key", Item::ZoomLevel => "Starting zoom", @@ -533,7 +541,8 @@ impl Item { KeystrokesCorner | KeystrokesCps => Keystrokes, MouseTweaksWheel => MouseTweaks, SortKey => Sort, - Fullbright | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort | AutoReconnect => { + Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort + | AutoReconnect => { return None; } }) @@ -542,6 +551,9 @@ impl Item { pub fn description(self) -> &'static str { match self { Item::Fullbright => "Show blocks and entities at full light, even in dark areas.", + Item::DynamicLights => { + "Torches and other light sources held, dropped or on fire light the blocks around them." + } Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.", Item::ZoomKey => "Click, then press a key. Escape unbinds it.", Item::ZoomLevel => "The zoom the key starts at.", @@ -581,7 +593,7 @@ impl Item { use crate::graphics_style::Cost; match self { Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None), - Item::Minimap => Some(Cost::Low), + Item::Minimap | Item::DynamicLights => Some(Cost::Low), Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None), _ => None, } @@ -590,6 +602,7 @@ impl Item { pub fn switch(self, e: &Extras) -> Option { Some(match self { Item::Fullbright => e.fullbright, + Item::DynamicLights => e.dynamic_lights, Item::Zoom => e.zoom, Item::ZoomSmooth => e.zoom_smooth, Item::ZoomSensitivity => e.zoom_sensitivity, @@ -632,6 +645,7 @@ impl Item { pub fn cycle(self, e: &mut Extras, back: bool) { match self { Item::Fullbright => e.fullbright = !e.fullbright, + Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights, Item::Zoom => e.zoom = !e.zoom, Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth, Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity, @@ -677,7 +691,7 @@ mod tests { #[test] fn everything_is_off_by_default_and_round_trips() { let d = Extras::default(); - assert!(!d.fullbright); + assert!(!d.fullbright && !d.dynamic_lights); assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect); assert!(!d.mouse_tweaks && !d.sort); assert_eq!(Extras::parse(""), d); @@ -685,6 +699,9 @@ mod tests { let mut e = d.clone(); Item::Fullbright.cycle(&mut e, false); assert_eq!(Item::Fullbright.switch(&e), Some(true)); + Item::DynamicLights.cycle(&mut e, false); + assert_eq!(Item::DynamicLights.switch(&e), Some(true)); + assert_eq!(Extras::parse(&e.serialize()), e); e.zoom = true; e.zoom_key = "key.keyboard.c".into(); e.zoom_level = 6; diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 2683b0e0a..bad635c5c 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -38,6 +38,7 @@ mod deflect; mod dispense_fixture; mod dispenser; mod dragon; +mod dynamic_lights; mod enchant_effects; mod end; mod entity_culling; @@ -1184,6 +1185,10 @@ struct Game<'a> { renderer: Option, /// CPU geometry storage retained across frames, including empty/title frames. entity_vertices: Vec, + /// Horizon Extras' dynamic lights this frame (`dynamic_lights.rs`), and how brightly each + /// item glows (built the first time the extra is on). + dynamic_lights: Vec<[f32; 4]>, + luminance: Option, /// Name tags drawn over everything (`RenderPipelines.TEXT_SEE_THROUGH`). see_through_vertices: Vec, started: Instant, @@ -9665,6 +9670,11 @@ impl Game<'_> { .then(|| self.border_draw(camera.to_array(), alpha as f32)) .flatten(); let see_through = std::mem::take(&mut self.see_through_vertices); + if title { + self.dynamic_lights.clear(); + } else { + self.gather_dynamic_lights(camera, alpha); + } let frame = Frame { shader: self.shader_inputs(camera, alpha as f32), nether_lighting: self.dim == Dim::Nether, @@ -9722,6 +9732,7 @@ impl Game<'_> { color: sky.cloud_color, }), entities: &entities, + dynamic_lights: &self.dynamic_lights, additive: &additive, see_through: &see_through, celestial: if self.settings.horizon.sky || title { @@ -11312,6 +11323,8 @@ impl<'a> Game<'a> { window: None, renderer: None, entity_vertices: Vec::new(), + dynamic_lights: Vec::new(), + luminance: None, see_through_vertices: Vec::new(), health_hud: hud_layout::HealthHud::default(), started: now, @@ -12185,6 +12198,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { hud, nether_lighting: false, entities: &[], + dynamic_lights: &[], clouds: None, additive: &[], see_through: &[], diff --git a/crates/horizon-client/src/game/dynamic_lights.rs b/crates/horizon-client/src/game/dynamic_lights.rs new file mode 100644 index 000000000..cd4223d82 --- /dev/null +++ b/crates/horizon-client/src/game/dynamic_lights.rs @@ -0,0 +1,67 @@ +//! Horizon Extras' dynamic lights (`horizon_client::dynamic_light`): the light sources near the +//! camera this frame. Nothing is gathered while the extra is off. +use super::{Game, shield}; +use glam::DVec3; +use horizon_client::dynamic_light::{self, BURNING, Luminance, Source}; + +impl Game<'_> { + /// Fills `dynamic_lights` for the frame: the player's and mobs' held items and fire, and + /// glowing dropped items, the nearest [`dynamic_light::MAX`] of them. + pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) { + let mut out = std::mem::take(&mut self.dynamic_lights); + out.clear(); + if !self.settings.extras.dynamic_lights { + self.dynamic_lights = out; + return; + } + if self.luminance.is_none() { + self.luminance = Some(Luminance::new(self.res.facts(), self.pack.assets.registry)); + } + let lum = self.luminance.as_ref().expect("built above"); + let held = |item: Option<&str>| item.map_or(0, |i| lum.of(i)); + let mut sources = Vec::new(); + if let Some(p) = &self.player + && !p.spectator + { + let feet = self + .previous_position + .lerp(p.position, self.clock.camera_fraction()); + let level = held(self.stack_in(self.selected).map(|s| &*s.item)) + .max(held(self.stack_in(shield::OFFHAND_SLOT).map(|s| &*s.item))) + .max(if p.fire_ticks > 0 { BURNING } else { 0 }); + // About where the hands are. + sources.push(Source { + at: DVec3::new(feet.x, feet.y + 1.2, feet.z), + level, + }); + } + for m in self.mobs.iter().filter(|m| !m.removed) { + let level = held(m.extra.main_hand.as_deref()) + .max(held(m.extra.off_hand.as_deref())) + .max(if m.fire_ticks > 0 { BURNING } else { 0 }); + if level == 0 { + continue; + } + let at = m.previous.lerp(m.position, alpha); + sources.push(Source { + at: DVec3::new(at.x, at.y + f64::from(m.height()) * 0.6, at.z), + level, + }); + } + for item in &self.dropped { + let level = lum + .of(&item.stack.item) + .max(if item.fire_ticks > 0 { BURNING } else { 0 }); + if level == 0 { + continue; + } + let at = item.previous.lerp(item.position, alpha); + sources.push(Source { + at: DVec3::new(at.x, at.y + 0.25, at.z), + level, + }); + } + dynamic_light::select(&sources, camera, &mut out); + self.dynamic_lights = out; + } +} diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 127aaf737..da53c42ed 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -107,13 +107,16 @@ struct Globals { fog_ends: [f32; 4], /// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png. end_portal_sprites: [[f32; 4]; 2], - /// x: vanilla's `GameTime` uniform; y: nether cardinal lighting, zw unused. + /// x: vanilla's `GameTime` uniform; y: nether cardinal lighting; z: how many + /// `dynamic_lights` are set; w unused. time: [f32; 4], /// Atlas rect of a destroy stage (entity.wgsl takes only its size: every stage's is the same, /// and each crack carries its own stage's origin). crumbling_sprite: [f32; 4], /// Framebuffer size, selection stroke width, perspective layering scale. outline_params: [f32; 4], + /// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`). + dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX], } pub type SectionKey = (i32, i32, i32); struct SectionGpu { @@ -186,6 +189,8 @@ pub struct Frame<'a> { pub nether_lighting: bool, /// Camera-relative entity geometry textured from the shared atlas. pub entities: &'a [Vertex], + /// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off. + pub dynamic_lights: &'a [[f32; 4]], /// Cloud mesh inputs; geometry and GPU storage are retained by the renderer. pub clouds: Option>, /// Additive translucent geometry after the entities (lightning, dragon rays). @@ -2908,10 +2913,17 @@ impl Renderer { time: [ frame.shader_game_time, f32::from(frame.nether_lighting), - 0.0, + frame.dynamic_lights.len().min(crate::dynamic_light::MAX) as f32, 0.0, ], outline_params: outline_params(self.config.width, self.config.height), + dynamic_lights: { + let mut lights = [[0.0; 4]; crate::dynamic_light::MAX]; + for (to, from) in lights.iter_mut().zip(frame.dynamic_lights) { + *to = *from; + } + lights + }, }; self.uploads .buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals)); diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index 7a69de4aa..4cf0c5c48 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -51,6 +51,7 @@ pub mod datapack; pub mod debug_chart; pub mod debug_keys; pub mod debug_overlay; +pub mod dynamic_light; pub mod effects; pub mod enchant; pub mod end_crystal; diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl index be3405b23..c0a2fc6fd 100644 --- a/crates/horizon-client/src/terrain.wgsl +++ b/crates/horizon-client/src/terrain.wgsl @@ -16,11 +16,14 @@ struct Globals { fog_ends: vec4, // Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png. end_portal_sprites: array, 2>, - // x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000) + // x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000), z: how many + // dynamic_lights are set time: vec4, // Atlas rect of the current destroy stage (read by entity.wgsl). crumbling_sprite: vec4, outline_params: vec4, + // Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs). + dynamic_lights: array, 16>, } @group(0) @binding(0) var globals: Globals; @group(0) @binding(1) var sprites: texture_2d_array; @@ -67,6 +70,18 @@ struct TerrainOut { @location(3) spherical: f32, @location(4) cylindrical: f32, } +// Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units +// (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one +// level less per block. time.z of them are set: none when the extra is off, so no loop runs. +fn dynamic_block_light(pos: vec3) -> f32 { + let count = min(u32(globals.time.z), 16u); + var level = 0.0; + for (var i = 0u; i < count; i++) { + let l = globals.dynamic_lights[i]; + level = max(level, l.w - distance(pos, l.xyz)); + } + return level * 16.0; +} fn sample_lightmap(sky: f32, block: f32) -> vec4 { let uv = vec2(block, sky) * 16.0; return textureSampleLevel(lightmap, lightmap_sampler, @@ -105,7 +120,8 @@ fn terrain_vertex(position: vec3, uv: vec2, layer: u32, color: vec4