From 832f879b27f2fd9e4db913db51c6f836bf928d3f Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:52:00 +0200 Subject: [PATCH] Dynamic lights: held, dropped and burning light sources (Horizon Extras) A new Horizon Extras option, Lighting > Dynamic lights (`dynamicLights`, off by default): a torch, lantern or other light-emitting item held by a player or mob, dropped as an item, or any entity on fire lights the blocks, entities and hand around it, as LambDynamicLights and OptiFine do. Client-only and at render time: the light engine, chunk meshes and saves never see it. Each frame the game picks the 16 sources nearest the camera (dynamic_light::select) and passes them in Globals; terrain.wgsl and entity.wgsl raise each vertex's block light to `level - distance` when that is brighter. Off, the count in globals.time.z is 0, nothing is gathered and the shaders' loop runs zero times, so no pipeline is rebuilt either way. An item glows like its block's default state (torch 14, lantern and glowstone 15, lit redstone torch 7); a lava bucket 15; burning entities 15. Fixes #814 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/dynamic_light.rs | 133 ++++++++++++++++++ crates/horizon-client/src/entity.wgsl | 18 ++- crates/horizon-client/src/extras.rs | 25 +++- crates/horizon-client/src/game.rs | 14 ++ .../horizon-client/src/game/dynamic_lights.rs | 67 +++++++++ crates/horizon-client/src/gfx.rs | 16 ++- crates/horizon-client/src/lib.rs | 1 + crates/horizon-client/src/terrain.wgsl | 20 ++- docs/extras.md | 18 ++- 9 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 crates/horizon-client/src/dynamic_light.rs create mode 100644 crates/horizon-client/src/game/dynamic_lights.rs 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 a25e8fdeb..092783ca8 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -37,6 +37,7 @@ mod dedicated; mod dispense_fixture; mod dispenser; mod dragon; +mod dynamic_lights; mod enchant_effects; mod end; mod entity_culling; @@ -1177,6 +1178,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, @@ -9636,6 +9641,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, @@ -9693,6 +9703,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 { @@ -11283,6 +11294,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, @@ -12156,6 +12169,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