From fff9f9a746df392fbff27045a2c61ba672dcfb3f Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:18:43 +0300 Subject: [PATCH] HD lighting: G-buffer pass, per-pixel mod point lights in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API level 10) Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/entity.wgsl | 22 + crates/horizon-client/src/game.rs | 5 + crates/horizon-client/src/game/mod_models.rs | 58 ++- crates/horizon-client/src/gfx.rs | 149 +++++- crates/horizon-client/src/gfx/hd.rs | 520 +++++++++++++++++++ crates/horizon-client/src/terrain.wgsl | 25 + crates/horizon-mod-sdk/wit/horizon.wit | 28 + crates/horizon-mods/src/api_level.rs | 6 +- crates/horizon-mods/src/runtime.rs | 39 +- 9 files changed, 845 insertions(+), 7 deletions(-) create mode 100644 crates/horizon-client/src/gfx/hd.rs diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl index 919c5edbb..01c64def9 100644 --- a/crates/horizon-client/src/entity.wgsl +++ b/crates/horizon-client/src/entity.wgsl @@ -56,6 +56,8 @@ struct Out { @location(11) glint_uv: vec2, // 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type. @location(12) @interpolate(flat) cull: u32, + // AirDef fork, HD lighting's G-buffer: the camera-relative position. + @location(13) pos: vec3, } // Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed. fn entity_light(normal: vec3, nether: bool) -> f32 { @@ -118,6 +120,7 @@ fn entity_vertex(position: vec3, uv: vec2, out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u); out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u); out.overlay = overlay; + out.pos = position; out.glint_uv = vec2(0.0); if overlay == 4u { out.spherical = length(position); } return out; @@ -261,6 +264,25 @@ fn crumbling_uv(uv: vec2, packed_origin: vec2, sprite_size: vec2, return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a); } +// AirDef fork: the HD lighting's G-buffer for entities (and the mods' models): the textured +// colour without the lightmap; full-bright ones (unlit overlays) glow. +struct EntityGBufferOut { + @location(0) albedo: vec4, + @location(1) normal: vec4, +} +@fragment fn fs_entity_gbuffer(in: Out, @builtin(front_facing) front: bool) -> EntityGBufferOut { + let color = textureSample(atlas, atlas_sampler, in.uv) * select(in.back_color, in.color, front); + if color.a < 0.1 || (in.cull != 0u && !front) || in.overlay == 4u || in.overlay == 9u || in.overlay == 10u { discard; } + var n = normalize(cross(dpdx(in.pos), dpdy(in.pos))); + if dot(n, in.pos) > 0.0 { n = -n; } + // The mods' glowing parts carry overlay 40 (game/mod_models.rs): they glow. + let glow = select(0.0, 1.0, in.overlay == 40u); + var out: EntityGBufferOut; + out.albedo = vec4(color.rgb, glow); + out.normal = vec4(n, 1.0); + return out; +} + // rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no // normal, so no cardinal light) wherever the texture is not fully clear. @fragment fn fs_outline(in: Out) -> @location(0) vec4 { diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index c6b642345..3c97bbfb6 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -11808,8 +11808,11 @@ impl Game<'_> { let entity_spans = std::mem::take(self.entity_spans.get_mut()); let glowing = std::mem::take(self.glowing_vertices.get_mut()); let mod_views = self.mod_views(alpha); + let hd_lights = self.mod_lights(camera, alpha); let frame = Frame { mod_views: &mod_views, + hd: self.mod_hd(), + hd_lights: &hd_lights, weather: &weather, weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32), nether_lighting: self.dim == Dim::Nether, @@ -14729,6 +14732,8 @@ fn loading_renderer( fn loading_frame(hud: &HudBatch) -> Frame<'_> { Frame { mod_views: &[], + hd: None, + hd_lights: &[], weather: &[], weather_time: horizon_client::weather::Time::new(0, 0.0), camera: glam::DVec3::new(0.5, 100.0, 0.5), diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs index 87d46c843..e6dbc7953 100644 --- a/crates/horizon-client/src/game/mod_models.rs +++ b/crates/horizon-client/src/game/mod_models.rs @@ -11,7 +11,7 @@ use { particles::{ParticleOptions, ParticleType}, }, horizon_core::random::RandomSource, - horizon_mods::{CameraView, ParticleSpawn}, + horizon_mods::{CameraView, ParticleSpawn, PointLight}, horizon_world::registry::BlockStateId, std::collections::HashMap, }; @@ -40,6 +40,8 @@ pub(super) struct ModModels { post: u64, /// The mods' cameras by id: a tick ago and now. views: Vec<(CameraView, CameraView)>, + /// The mods' lights by (mod, id): a tick ago and now. + lights: HashMap<(usize, u32), (PointLight, PointLight)>, } /// Full brightness: sky and block light 15, as the entity shader packs them. @@ -60,6 +62,12 @@ impl Game<'_> { pub(super) fn mod_views(&self, _alpha: f64) -> Vec { Vec::new() } + pub(super) fn mod_hd(&self) -> Option { + None + } + pub(super) fn mod_lights(&self, _camera: glam::DVec3, _alpha: f64) -> Vec { + Vec::new() + } } #[cfg(feature = "mods")] @@ -116,6 +124,13 @@ impl Game<'_> { let seen: std::collections::HashSet<_> = seen.into_iter().collect(); models.poses.retain(|k, _| seen.contains(k)); } + let mut lights: HashMap<(usize, u32), (PointLight, PointLight)> = HashMap::new(); + for (owner, l) in self.mods.mods.lights() { + let key = (owner, l.id); + let before = self.mods.models.lights.get(&key).map_or(*l, |(_, now)| *now); + lights.insert(key, (before, *l)); + } + self.mods.models.lights = lights; let views: Vec = self.mods.mods.views().map(|(_, v)| *v).collect(); let old = std::mem::take(&mut self.mods.models.views); self.mods.models.views = views @@ -127,6 +142,40 @@ impl Game<'_> { .collect(); } + /// The HD lighting a mod asked for, if any. + pub(super) fn mod_hd(&self) -> Option { + let l = self.mods.mods.lighting()?; + l.hd.then_some(horizon_client::gfx::HdSettings { exposure: l.exposure, bloom: l.bloom }) + } + + /// The mods' lights between their last two settings, relative to the camera; the nearest and + /// brightest first when there are too many. + pub(super) fn mod_lights(&self, camera: DVec3, alpha: f64) -> Vec { + let t = alpha.clamp(0.0, 1.0); + let mut out: Vec<(f32, horizon_client::gfx::HdLight)> = self + .mods + .models + .lights + .values() + .filter_map(|(a, b)| { + let at = DVec3::new(a.x, a.y, a.z).lerp(DVec3::new(b.x, b.y, b.z), t) - camera; + let radius = b.radius; + let d = at.length() as f32; + // Too far to light anything in sight. + if d > radius + 512.0 { + return None; + } + let light = horizon_client::gfx::HdLight { + pos: [at.x as f32, at.y as f32, at.z as f32, radius], + color: [b.color.0, b.color.1, b.color.2, a.intensity + (b.intensity - a.intensity) * t as f32], + }; + Some((light.color[3] / (1.0 + d * d * 0.001), light)) + }) + .collect(); + out.sort_by(|x, y| y.0.total_cmp(&x.0)); + out.into_iter().take(512).map(|(_, l)| l).collect() + } + /// The mods' cameras between their last two settings, for this frame. pub(super) fn mod_views(&self, alpha: f64) -> Vec { let t = alpha.clamp(0.0, 1.0); @@ -198,7 +247,14 @@ impl Game<'_> { rotation, (at - camera).as_vec3(), ) * Mat4::from_translation(Vec3::splat(-0.5)); + let start = out.len(); self.block_model_vertices(p.state, 0, m, light, true, out); + // Overlay 40 marks a glowing part for the HD lighting (entity.wgsl): drawn as any other. + if p.glow { + for v in &mut out[start..] { + v.layer = 40; + } + } } } diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 9772e3a83..be51a0289 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -77,8 +77,10 @@ mod lod; mod native_material; mod native_pack; mod native_post; +mod hd; mod mod_post; mod mod_views; +pub use hd::{HdLight, HdSettings}; pub use mod_views::ModView; mod native_sky; mod occlusion; @@ -218,6 +220,9 @@ pub struct Frame<'a> { pub camera: DVec3, /// Mods' cameras (AirDef fork), drawn into the pictures the HUD shows. pub mod_views: &'a [ModView], + /// The HD lighting (AirDef fork): on with its settings, and the mods' lights. + pub hd: Option, + pub hd_lights: &'a [HdLight], pub yaw: f32, pub pitch: f32, pub fov_degrees: f32, @@ -550,7 +555,15 @@ pub struct Renderer { native: Option, /// AirDef fork: a mod's post pass, its parameters and the partial tick they are for. mod_post: Option, + /// The mod post pass's WGSL, and whether it was built for the HD target (it is rebuilt when + /// the HD lighting comes or goes). + mod_post_source: Option, + mod_post_hdr: bool, mod_views: Option, + /// AirDef fork: the HD lighting's G-buffer pipelines (terrain, entities) and its targets. + gbuffer_terrain: wgpu::RenderPipeline, + gbuffer_entity: wgpu::RenderPipeline, + hd: Option, mod_params: (Vec, f32), /// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`). pass_timing: bool, @@ -1332,6 +1345,39 @@ impl Renderer { ) }; let tri = wgpu::PrimitiveTopology::TriangleList; + // AirDef fork: the HD lighting's G-buffer (terrain.wgsl's fs_gbuffer): albedo and normal. + let gbuffer_targets = [ + Some(wgpu::ColorTargetState { format: hd::ALBEDO, blend: None, write_mask: wgpu::ColorWrites::ALL }), + Some(wgpu::ColorTargetState { format: hd::NORMAL, blend: None, write_mask: wgpu::ColorWrites::ALL }), + ]; + let gbuffer_depth = Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::GreaterEqual), + stencil: Default::default(), + bias: Default::default(), + }); + let gbuffer_terrain = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("HD G-buffer terrain"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_terrain"), + compilation_options: Default::default(), + buffers: &terrain_buffers, + }, + primitive: wgpu::PrimitiveState { cull_mode: Some(wgpu::Face::Back), ..Default::default() }, + depth_stencil: gbuffer_depth.clone(), + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_gbuffer"), + compilation_options: wgpu::PipelineCompilationOptions { constants: &sprite_constants, ..Default::default() }, + targets: &gbuffer_targets, + }), + multiview_mask: None, + cache: None, + }); let solid = pipeline( "Terrain solid", "vs_terrain", @@ -1928,6 +1974,28 @@ impl Renderer { cache: None, }) }; + // AirDef fork: the entities' G-buffer (entity.wgsl's fs_entity_gbuffer). + let gbuffer_entity = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("HD G-buffer entities"), + layout: Some(&entity_layout), + vertex: wgpu::VertexState { + module: &entity_shader, + entry_point: Some("vs_entity"), + compilation_options: Default::default(), + buffers: &entity_vertex, + }, + primitive: Default::default(), + depth_stencil: gbuffer_depth.clone(), + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &entity_shader, + entry_point: Some("fs_entity_gbuffer"), + compilation_options: Default::default(), + targets: &gbuffer_targets, + }), + multiview_mask: None, + cache: None, + }); let entity_pipeline = |label, cull_mode, depth_write, compare, bias, blend, clouds| { entity_pipeline_with( label, @@ -2656,7 +2724,12 @@ impl Renderer { pack_post: None, native: None, mod_post: None, + mod_post_source: None, + mod_post_hdr: false, mod_views: None, + gbuffer_terrain, + gbuffer_entity, + hd: None, mod_params: (Vec::new(), 0.0), pass_timing: pack_timing::PassTiming::logging(), pack_entity_rest: Vec::new(), @@ -3441,10 +3514,15 @@ impl Renderer { /// that ran keeps running. pub fn set_mod_post(&mut self, source: Option<&str>) -> std::result::Result<(), String> { match source { - None => self.mod_post = None, + None => { + self.mod_post = None; + self.mod_post_source = None; + } Some(source) => { - let format = self.config.format.remove_srgb_suffix(); + // Under the HD lighting it draws into the HD target, else onto the screen. + let format = if self.mod_post_hdr { hd::HDR } else { self.config.format.remove_srgb_suffix() }; self.mod_post = Some(mod_post::ModPost::new(&self.device, source, format)?); + self.mod_post_source = Some(source.to_owned()); } } Ok(()) @@ -4694,7 +4772,15 @@ impl Renderer { } // AirDef fork: a mod's post pass reads the world target too. let mod_post = !frame.title && !pack_post && !native_post && self.mod_post.is_some(); - let pack_reads_world = pack_post || native_post || mod_post; + // AirDef fork: the HD lighting (its own pipeline; a shader pack's replaces it). + let hd_on = !frame.title && !pack_post && !native_post && frame.hd.is_some(); + if hd_on != self.mod_post_hdr { + self.mod_post_hdr = hd_on; + if let Some(source) = self.mod_post_source.clone() { + let _ = self.set_mod_post(Some(&source)); + } + } + let pack_reads_world = pack_post || native_post || mod_post || hd_on; // The pack's program reads the vertices this frame's draws bind. let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat; // The shadow camera's lists, drawn by the pack's shadow programs. @@ -4744,6 +4830,47 @@ impl Renderer { Some((color, depth)) => (color, depth), None => (&view_target, &self.depth), }; + // AirDef fork: the HD lighting's G-buffer — the solid terrain, distant tiles and + // entities once more, their colour without light and their normals. + if hd_on && let Some(size) = self.world_post.size() { + let format = self.config.format.remove_srgb_suffix(); + let device = &self.device; + let hd = self.hd.get_or_insert_with(|| hd::Hd::new(device, size, format)); + hd.resize(device, size); + if let Some(settings) = frame.hd { + hd.write(&self.queue, view_proj, frame.hd_lights, settings); + } + let mut pass = hd.gbuffer_pass(&mut encoder); + pass.set_bind_group(0, &self.group, &[]); + pass.set_pipeline(&self.gbuffer_terrain); + pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); + pass.set_vertex_buffer(1, self.instances.slice(..)); + let mut page = None; + let sections = visible.iter().enumerate().map(|(k, v)| (k, self.sections.get(v.slot))); + let tiles = visible_lods + .iter() + .enumerate() + .filter_map(|(k, v)| Some((visible.len() + k, &self.lods.get(&v.key)?.mesh))); + for (instance, s) in sections.chain(tiles) { + if s.solid == 0 { + continue; + } + if page != Some(s.page) { + pass.set_vertex_buffer(0, self.arena.buffer(s.page).slice(..)); + page = Some(s.page); + } + let instance = instance as u32; + pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1); + } + if entity_quads > 0 { + pass.set_pipeline(&self.gbuffer_entity); + pass.set_bind_group(0, &self.entity_group, &[]); + pass.set_vertex_buffer(0, self.entity_buffer.slice(..)); + pass.draw_indexed(0..entity_quads * 6, 0, 0..1); + } + } else if !hd_on { + self.hd = None; + } if culling && let Some(cull) = &mut self.cull { cull.bind_depth(&self.device, world_depth); } @@ -5264,6 +5391,22 @@ impl Renderer { size, native.timing.as_mut(), ); + } else if let Some((color, depth)) = &scaled + && hd_on + && let Some(hd) = &self.hd + { + // AirDef fork: lit by the mods' lights into the HD target, the mod's post pass on + // that, then bloom and the tone curve onto the screen. + hd.light(&self.device, &mut encoder, (color, depth)); + match &mut self.mod_post { + Some(post) if mod_post => { + let size = self.world_post.size().unwrap_or([1, 1]); + post.write(&self.queue, frame, size, &self.mod_params.0, self.mod_params.1); + post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx); + hd.finish(&self.device, &mut encoder, &hd.fx, &view_target); + } + _ => hd.finish(&self.device, &mut encoder, &hd.lit, &view_target), + } } else if let Some((color, depth)) = &scaled && mod_post && let Some(post) = &mut self.mod_post diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs new file mode 100644 index 000000000..b40484e5e --- /dev/null +++ b/crates/horizon-client/src/gfx/hd.rs @@ -0,0 +1,520 @@ +//! AirDef fork: HD lighting. Before the world pass the solid terrain and the +//! entities (with the mods' models) are drawn once more into a G-buffer: the +//! surface colour without light and its normal. After the world pass the +//! mods' point lights (rocket flames, flares, blasts) light every pixel of it +//! into a high-dynamic-range target; the mod's post pass runs on that; then +//! bright parts bloom and a tone curve brings it to the screen — vanilla's +//! range (up to about 0.8) left as it is, only what is brighter rolled off. +use glam::Mat4; + +/// The G-buffer's formats. +pub const ALBEDO: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +pub const NORMAL: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; +/// The lit world and the post pass's output. +pub const HDR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; +/// The most lights drawn a frame. +pub const MAX_LIGHTS: usize = 512; +/// Bloom's mip levels. +const BLOOM_LEVELS: usize = 6; + +/// A light for this frame, relative to the camera. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)] +pub struct HdLight { + /// xyz: camera-relative position; w: radius. + pub pos: [f32; 4], + /// rgb: colour; w: intensity. + pub color: [f32; 4], +} + +/// The frame's HD settings. +#[derive(Clone, Copy, Debug)] +pub struct HdSettings { + pub exposure: f32, + pub bloom: f32, +} + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct HdFrame { + /// Clip space to camera-relative position. + inverse_view_proj: [[f32; 4]; 4], + /// width, height, 1/width, 1/height. + resolution: [f32; 4], + /// lights, exposure, bloom, -. + params: [f32; 4], +} + +const COMMON: &str = r#" +struct HdFrame { + inverse_view_proj: mat4x4, + resolution: vec4, + params: vec4, +} +struct FullOut { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} +@vertex +fn vs_full(@builtin(vertex_index) i: u32) -> FullOut { + let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); + var out: FullOut; + out.position = vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0); + out.uv = uv; + return out; +} +"#; + +const LIGHT: &str = r#" +struct Light { + pos: vec4, + color: vec4, +} +@group(0) @binding(0) var frame: HdFrame; +@group(0) @binding(1) var scene_color: texture_2d; +@group(0) @binding(2) var scene_depth: texture_depth_2d; +@group(0) @binding(3) var albedo_tex: texture_2d; +@group(0) @binding(4) var normal_tex: texture_2d; +@group(0) @binding(5) var gbuffer_depth: texture_depth_2d; +@group(0) @binding(6) var lights: array; + +@fragment +fn fs_light(in: FullOut) -> @location(0) vec4 { + let px = vec2(in.position.xy); + var c = textureLoad(scene_color, px, 0).rgb; + let d = textureLoad(scene_depth, px, 0); + let g = textureLoad(gbuffer_depth, px, 0); + // Only where the G-buffer holds what the world shows (not the sky, water, particles). + if d <= 0.0 || abs(d - g) > max(d, g) * 0.002 { + return vec4(c, 1.0); + } + let ndc = vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, d, 1.0); + let h = frame.inverse_view_proj * ndc; + let p = h.xyz / h.w; + let n = normalize(textureLoad(normal_tex, px, 0).xyz); + let albedo = textureLoad(albedo_tex, px, 0); + var light = vec3(0.0); + let count = u32(frame.params.x); + for (var i = 0u; i < count; i++) { + let l = lights[i]; + let to = l.pos.xyz - p; + let dist = length(to); + if dist >= l.pos.w { continue; } + // Inverse square, softened near the light, faded to nothing at its radius. + let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0); + let falloff = l.color.w / (dist * dist + 1.0) * window; + let ndl = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0); + light += l.color.rgb * falloff * ndl; + } + c += albedo.rgb * light; + // Glowing parts (flames, flares) shine past white, for the bloom. + c += albedo.rgb * albedo.a * 1.5; + return vec4(c, 1.0); +} +"#; + +const BLOOM: &str = r#" +@group(0) @binding(0) var frame: HdFrame; +@group(0) @binding(1) var source: texture_2d; +@group(0) @binding(2) var linear: sampler; + +// The first step down keeps only what is brighter than white (a soft knee). +@fragment +fn fs_prefilter(in: FullOut) -> @location(0) vec4 { + let c = textureSampleLevel(source, linear, in.uv, 0.0).rgb; + let peak = max(c.r, max(c.g, c.b)); + let knee = 0.35; + let soft = clamp(peak - 0.85 + knee, 0.0, 2.0 * knee); + let weight = max(soft * soft / (4.0 * knee + 0.0001), peak - 0.85) / max(peak, 0.0001); + return vec4(c * weight, 1.0); +} +// 13 taps (Call of Duty: Advanced Warfare's bloom), half size each step. +@fragment +fn fs_down(in: FullOut) -> @location(0) vec4 { + let t = 1.0 / vec2(textureDimensions(source)); + let s = in.uv; + let a = textureSampleLevel(source, linear, s + t * vec2(-2.0, -2.0), 0.0).rgb; + let b = textureSampleLevel(source, linear, s + t * vec2(0.0, -2.0), 0.0).rgb; + let c = textureSampleLevel(source, linear, s + t * vec2(2.0, -2.0), 0.0).rgb; + let d = textureSampleLevel(source, linear, s + t * vec2(-2.0, 0.0), 0.0).rgb; + let e = textureSampleLevel(source, linear, s, 0.0).rgb; + let f = textureSampleLevel(source, linear, s + t * vec2(2.0, 0.0), 0.0).rgb; + let g = textureSampleLevel(source, linear, s + t * vec2(-2.0, 2.0), 0.0).rgb; + let h = textureSampleLevel(source, linear, s + t * vec2(0.0, 2.0), 0.0).rgb; + let i = textureSampleLevel(source, linear, s + t * vec2(2.0, 2.0), 0.0).rgb; + let j = textureSampleLevel(source, linear, s + t * vec2(-1.0, -1.0), 0.0).rgb; + let k = textureSampleLevel(source, linear, s + t * vec2(1.0, -1.0), 0.0).rgb; + let l = textureSampleLevel(source, linear, s + t * vec2(-1.0, 1.0), 0.0).rgb; + let m = textureSampleLevel(source, linear, s + t * vec2(1.0, 1.0), 0.0).rgb; + var o = e * 0.125 + (a + c + g + i) * 0.03125 + (b + d + f + h) * 0.0625 + (j + k + l + m) * 0.125; + return vec4(o, 1.0); +} +// A 3×3 tent, added onto the bigger level. +@fragment +fn fs_up(in: FullOut) -> @location(0) vec4 { + let t = 1.0 / vec2(textureDimensions(source)); + let s = in.uv; + var o = textureSampleLevel(source, linear, s, 0.0).rgb * 4.0; + o += (textureSampleLevel(source, linear, s + t * vec2(-1.0, 0.0), 0.0).rgb + + textureSampleLevel(source, linear, s + t * vec2(1.0, 0.0), 0.0).rgb + + textureSampleLevel(source, linear, s + t * vec2(0.0, -1.0), 0.0).rgb + + textureSampleLevel(source, linear, s + t * vec2(0.0, 1.0), 0.0).rgb) * 2.0; + o += textureSampleLevel(source, linear, s + t * vec2(-1.0, -1.0), 0.0).rgb + + textureSampleLevel(source, linear, s + t * vec2(1.0, -1.0), 0.0).rgb + + textureSampleLevel(source, linear, s + t * vec2(-1.0, 1.0), 0.0).rgb + + textureSampleLevel(source, linear, s + t * vec2(1.0, 1.0), 0.0).rgb; + return vec4(o / 16.0, 1.0); +} +"#; + +const TONEMAP: &str = r#" +@group(0) @binding(0) var frame: HdFrame; +@group(0) @binding(1) var hdr: texture_2d; +@group(0) @binding(2) var bloom: texture_2d; +@group(0) @binding(3) var linear: sampler; + +@fragment +fn fs_tonemap(in: FullOut) -> @location(0) vec4 { + var c = textureSampleLevel(hdr, linear, in.uv, 0.0).rgb * frame.params.y; + c += textureSampleLevel(bloom, linear, in.uv, 0.0).rgb * frame.params.z; + // Up to 0.8 as it is (vanilla's look); above it, rolled off towards white by the + // brightest channel, so hues hold instead of clipping. + let peak = max(c.r, max(c.g, c.b)); + let t = 0.8; + if peak > t { + let x = (peak - t) / (1.0 - t); + let rolled = t + (1.0 - t) * (x / (1.0 + x)); + c = c * (rolled / peak); + // Very bright light bleaches towards white, as film does. + c = mix(c, vec3(rolled), clamp((peak - 2.0) / 8.0, 0.0, 0.6)); + } + return vec4(c, 1.0); +} +"#; + +struct Target { + view: wgpu::TextureView, +} + +fn target(device: &wgpu::Device, label: &str, size: [u32; 2], format: wgpu::TextureFormat) -> Target { + let view = device + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { width: size[0].max(1), height: size[1].max(1), depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&Default::default()); + Target { view } +} + +pub struct Hd { + size: [u32; 2], + pub albedo: wgpu::TextureView, + pub normal: wgpu::TextureView, + pub depth: wgpu::TextureView, + /// The lit world, and the post pass's output. + pub lit: wgpu::TextureView, + pub fx: wgpu::TextureView, + bloom: Vec, + frame: wgpu::Buffer, + lights: wgpu::Buffer, + sampler: wgpu::Sampler, + light_layout: wgpu::BindGroupLayout, + light_pipeline: wgpu::RenderPipeline, + bloom_layout: wgpu::BindGroupLayout, + prefilter: wgpu::RenderPipeline, + down: wgpu::RenderPipeline, + up: wgpu::RenderPipeline, + tone_layout: wgpu::BindGroupLayout, + tone: wgpu::RenderPipeline, +} + +fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(label), + source: wgpu::ShaderSource::Wgsl(format!("{COMMON}{body}").into()), + }) +} + +fn fullscreen( + device: &wgpu::Device, + label: &str, + layout: &wgpu::BindGroupLayout, + module: &wgpu::ShaderModule, + entry: &str, + format: wgpu::TextureFormat, + blend: Option, +) -> wgpu::RenderPipeline { + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(label), + bind_group_layouts: &[Some(layout)], + ..Default::default() + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { module, entry_point: Some("vs_full"), compilation_options: Default::default(), buffers: &[] }, + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some(entry), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { format, blend, write_mask: wgpu::ColorWrites::ALL })], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) +} + +fn entries(kinds: &[wgpu::BindingType]) -> Vec { + kinds + .iter() + .enumerate() + .map(|(i, ty)| wgpu::BindGroupLayoutEntry { + binding: i as u32, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: *ty, + count: None, + }) + .collect() +} + +const UNIFORM: wgpu::BindingType = + wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }; +const FLOAT: wgpu::BindingType = wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, +}; +const UNFILTERED: wgpu::BindingType = wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, +}; +const DEPTH: wgpu::BindingType = wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, +}; +const SAMPLER: wgpu::BindingType = wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering); + +impl Hd { + pub fn new(device: &wgpu::Device, size: [u32; 2], screen: wgpu::TextureFormat) -> Self { + let light_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("HD lighting"), + entries: &entries(&[ + UNIFORM, + UNFILTERED, + DEPTH, + UNFILTERED, + UNFILTERED, + DEPTH, + wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + ]), + }); + let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("HD bloom"), + entries: &entries(&[UNIFORM, FLOAT, SAMPLER]), + }); + let tone_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("HD tone mapping"), + entries: &entries(&[UNIFORM, FLOAT, FLOAT, SAMPLER]), + }); + let light = module(device, "HD lighting", LIGHT); + let bloom = module(device, "HD bloom", BLOOM); + let tone = module(device, "HD tone mapping", TONEMAP); + let add = wgpu::BlendState { + color: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Add }, + alpha: wgpu::BlendComponent::REPLACE, + }; + let light_pipeline = fullscreen(device, "HD lighting", &light_layout, &light, "fs_light", HDR, None); + let prefilter = fullscreen(device, "HD bloom prefilter", &bloom_layout, &bloom, "fs_prefilter", HDR, None); + let down = fullscreen(device, "HD bloom down", &bloom_layout, &bloom, "fs_down", HDR, None); + let up = fullscreen(device, "HD bloom up", &bloom_layout, &bloom, "fs_up", HDR, Some(add)); + let tone = fullscreen(device, "HD tone mapping", &tone_layout, &tone, "fs_tonemap", screen, None); + let frame = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("HD frame"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let lights = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("HD lights"), + size: (MAX_LIGHTS * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("HD"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + ..Default::default() + }); + let mut hd = Hd { + size: [0, 0], + albedo: target(device, "HD albedo", [1, 1], ALBEDO).view, + normal: target(device, "HD normal", [1, 1], NORMAL).view, + depth: target(device, "HD depth", [1, 1], wgpu::TextureFormat::Depth32Float).view, + lit: target(device, "HD lit", [1, 1], HDR).view, + fx: target(device, "HD post", [1, 1], HDR).view, + bloom: Vec::new(), + frame, + lights, + sampler, + light_layout, + light_pipeline, + bloom_layout, + prefilter, + down, + up, + tone_layout, + tone, + }; + hd.resize(device, size); + hd + } + + /// The targets, at the world target's size. + pub fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) { + if self.size == size { + return; + } + self.size = size; + self.albedo = target(device, "HD albedo", size, ALBEDO).view; + self.normal = target(device, "HD normal", size, NORMAL).view; + self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view; + self.lit = target(device, "HD lit", size, HDR).view; + self.fx = target(device, "HD post", size, HDR).view; + self.bloom = (0..BLOOM_LEVELS) + .map(|k| { + let s = [(size[0] >> (k + 1)).max(1), (size[1] >> (k + 1)).max(1)]; + target(device, "HD bloom", s, HDR) + }) + .collect(); + } + + /// This frame's camera, lights and settings. + pub fn write(&mut self, queue: &wgpu::Queue, view_proj: Mat4, lights: &[HdLight], settings: HdSettings) { + let n = lights.len().min(MAX_LIGHTS); + if n > 0 { + queue.write_buffer(&self.lights, 0, bytemuck::cast_slice(&lights[..n])); + } + let frame = HdFrame { + inverse_view_proj: view_proj.inverse().to_cols_array_2d(), + resolution: [self.size[0] as f32, self.size[1] as f32, 1.0 / self.size[0] as f32, 1.0 / self.size[1] as f32], + params: [n as f32, settings.exposure, settings.bloom, 0.0], + }; + queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame)); + } + + /// The G-buffer pass's attachments: albedo and normal cleared, its own depth. + pub fn gbuffer_pass<'e>(&self, encoder: &'e mut wgpu::CommandEncoder) -> wgpu::RenderPass<'e> { + let clear = |view| { + Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store }, + }) + }; + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("HD G-buffer"), + color_attachments: &[clear(&self.albedo), clear(&self.normal)], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth, + depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0.0), store: wgpu::StoreOp::Store }), + stencil_ops: None, + }), + ..Default::default() + }) + .forget_lifetime() + } + + fn draw(encoder: &mut wgpu::CommandEncoder, label: &str, to: &wgpu::TextureView, load: bool, pipeline: &wgpu::RenderPipeline, group: &wgpu::BindGroup) { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(label), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: to, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: if load { wgpu::LoadOp::Load } else { wgpu::LoadOp::Clear(wgpu::Color::BLACK) }, + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, group, &[]); + pass.draw(0..3, 0..1); + } + + /// Lights the world (its colour and depth) into `lit`. + pub fn light(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, world: (&wgpu::TextureView, &wgpu::TextureView)) { + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("HD lighting"), + layout: &self.light_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: self.frame.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(world.0) }, + wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(world.1) }, + wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.albedo) }, + wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.normal) }, + wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.depth) }, + wgpu::BindGroupEntry { binding: 6, resource: self.lights.as_entire_binding() }, + ], + }); + Self::draw(encoder, "HD lighting", &self.lit, false, &self.light_pipeline, &group); + } + + fn bloom_group(&self, device: &wgpu::Device, source: &wgpu::TextureView) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("HD bloom"), + layout: &self.bloom_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: self.frame.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(source) }, + wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + ], + }) + } + + /// Bloom from `source` (the lit world or the post pass's output), then the tone curve onto `screen`. + pub fn finish(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, source: &wgpu::TextureView, screen: &wgpu::TextureView) { + // Down: the prefilter into the first level, then each level from the one above. + Self::draw(encoder, "HD bloom prefilter", &self.bloom[0].view, false, &self.prefilter, &self.bloom_group(device, source)); + for k in 1..self.bloom.len() { + let g = self.bloom_group(device, &self.bloom[k - 1].view); + Self::draw(encoder, "HD bloom down", &self.bloom[k].view, false, &self.down, &g); + } + // Up: each level added onto the bigger one. + for k in (1..self.bloom.len()).rev() { + let g = self.bloom_group(device, &self.bloom[k].view); + Self::draw(encoder, "HD bloom up", &self.bloom[k - 1].view, true, &self.up, &g); + } + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("HD tone mapping"), + layout: &self.tone_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: self.frame.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(source) }, + wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.bloom[0].view) }, + wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + ], + }); + Self::draw(encoder, "HD tone mapping", screen, false, &self.tone, &group); + } +} diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl index 3aea6fc1a..293729e33 100644 --- a/crates/horizon-client/src/terrain.wgsl +++ b/crates/horizon-client/src/terrain.wgsl @@ -71,6 +71,9 @@ struct TerrainOut { @location(4) cylindrical: f32, // The light the face emits, 0 to 15 (mesher.rs's `EMISSION_BITS`). @location(5) @interpolate(flat) emission: u32, + // AirDef fork, HD lighting's G-buffer: the camera-relative position and the unlit colour. + @location(6) pos: vec3, + @location(7) tint: vec4, } // 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 @@ -127,6 +130,8 @@ fn terrain_vertex(position: vec3, uv: vec2, layer: u32, color: vec4 f32 { @@ -148,6 +153,26 @@ fn apply_fog(color: vec4, spherical: f32, cylindrical: f32) -> vec4 { let emissive = select(1.0, 0.0, in.emission > 0u); return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical); } +// AirDef fork: the HD lighting's G-buffer. The surface's colour without light (alpha: how much +// it glows) and its normal, found from how the position changes across the pixel (faces are +// flat, so this is exact). +struct GBufferOut { + @location(0) albedo: vec4, + @location(1) normal: vec4, +} +fn face_normal(pos: vec3) -> vec3 { + var n = normalize(cross(dpdx(pos), dpdy(pos))); + if dot(n, pos) > 0.0 { n = -n; } + return n; +} +@fragment fn fs_gbuffer(in: TerrainOut) -> GBufferOut { + let color = sprite(in.uv, in.layer & 0x7fffu) * in.tint; + if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; } + var out: GBufferOut; + out.albedo = vec4(color.rgb, f32(in.emission) / 15.0); + out.normal = vec4(face_normal(in.pos), 1.0); + return out; +} @fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4 { let color = sprite(in.uv, in.layer & 0x7fffu) * in.color; if color.a < 0.01 { discard; } diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index b6758d9fb..6696fa134 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -534,6 +534,34 @@ interface render { /// Replaces the mod's cameras: up to 8. A `view` shape shows one; the /// game renders up to three a frame, in turn. set-views: func(views: list); + /// A light the mod's things give off (AirDef fork): rocket flames, + /// flares, blasts. Drawn by the HD lighting (`set-lighting`). + record point-light { + /// Stays the same while the light lives: the game moves it smoothly. + id: u32, + x: f64, + y: f64, + z: f64, + /// Linear RGB, 0 to 1. + color: tuple, + /// Brightness: about what it lights one block away (0 to 100000). + intensity: f32, + /// Nothing past this many blocks (1 to 256). + radius: f32, + } + /// Replaces the mod's lights: up to 512, interpolated per id between + /// the last two calls. + set-lights: func(lights: list); + /// The HD lighting (AirDef fork): the world lit per pixel by the mods' + /// lights, in high dynamic range, with bloom and tone mapping. + record lighting { + hd: bool, + /// 1 as is; more brightens. + exposure: f32, + /// How much bright things glow (0 none, 1 strong). + bloom: f32, + } + set-lighting: func(lighting: lighting); } /// What the game calls. Only the hooks `init` asks for are ever called. diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index cf2993186..80ac74b6f 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -7,13 +7,13 @@ //! version's level so the launcher can tell before it installs. /// The newest level this Horizon serves. -pub const LEVEL: u32 = 9; +pub const LEVEL: u32 = 10; /// Every function the game offers mods, as `interface.function`, with its /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new /// function takes the next level and raises [`LEVEL`] (the `api_reference` /// test fails until it is listed here). -pub const FUNCTIONS: [(&str, u32); 56] = [ +pub const FUNCTIONS: [(&str, u32); 58] = [ ("log.write", 0), ("game.get-player", 0), ("game.get-clock", 0), @@ -71,6 +71,8 @@ pub const FUNCTIONS: [(&str, u32); 56] = [ ("render.set-params", 8), ("render.set-views", 9), ("game.world-id", 9), + ("render.set-lights", 10), + ("render.set-lighting", 10), ]; /// What the game's program carries so the launcher can read its level diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index abed5b31f..57713e228 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -81,7 +81,7 @@ pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, I pub use render_bindings::horizon::mods::render::{ Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape, Minimap as ModMinimap, ModelInstance, ParticleSpawn, Point as RenderPoint, QuadShape, - CameraView, Rect as RenderRect, Shape, SpriteShape, TextShape, ViewShape, Zoom as ModZoom, + CameraView, Lighting as ModLighting, PointLight, Rect as RenderRect, Shape, SpriteShape, TextShape, ViewShape, Zoom as ModZoom, }; use render_bindings::{RenderHost, horizon::mods::render}; /// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items. @@ -439,6 +439,9 @@ struct State { post_params: Vec, /// `render.set-views`' cameras (AirDef fork). views: Vec, + /// `render.set-lights` and `render.set-lighting` (AirDef fork). + lights: Vec, + lighting: Option, input: InputState, browser: BrowserState, /// Capability `storage`'s folder; made on first `set`. @@ -648,6 +651,8 @@ impl Mods { post: None, post_params: Vec::new(), views: Vec::new(), + lights: Vec::new(), + lighting: None, input: InputState::default(), browser: BrowserState::default(), data_dir, @@ -1219,6 +1224,18 @@ impl Mods { .flat_map(move |r| r.store.data().views.iter().map(move |v| (i, v))) }) } + /// Every running mod's lights (AirDef fork), with the mod's index. + pub fn lights(&self) -> impl Iterator { + self.entries.iter().enumerate().flat_map(|(i, e)| { + e.run + .iter() + .flat_map(move |r| r.store.data().lights.iter().map(move |l| (i, l))) + }) + } + /// The first running mod's lighting, if it set one. + pub fn lighting(&self) -> Option { + self.entries.iter().find_map(|e| e.run.as_ref()?.store.data().lighting) + } /// The columns each running mod asked the ground height of, taken. pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> { self.entries @@ -2002,6 +2019,26 @@ impl render::Host for State { }) .collect(); } + fn set_lights(&mut self, lights: Vec) { + let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0; + let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + self.lights = lights + .into_iter() + .filter(|l| finite(l.x) && finite(l.y) && finite(l.z)) + .take(512) + .map(|mut l| { + l.color = (unit(l.color.0), unit(l.color.1), unit(l.color.2)); + l.intensity = if l.intensity.is_finite() { l.intensity.clamp(0.0, 100_000.0) } else { 0.0 }; + l.radius = if l.radius.is_finite() { l.radius.clamp(1.0, 256.0) } else { 1.0 }; + l + }) + .collect(); + } + fn set_lighting(&mut self, mut lighting: ModLighting) { + lighting.exposure = if lighting.exposure.is_finite() { lighting.exposure.clamp(0.05, 8.0) } else { 1.0 }; + lighting.bloom = if lighting.bloom.is_finite() { lighting.bloom.clamp(0.0, 1.0) } else { 0.0 }; + self.lighting = Some(lighting); + } fn query_ground(&mut self, columns: Vec<(i32, i32)>) { let room = 512usize.saturating_sub(self.ground_queries.len()); self.ground_queries.extend(columns.into_iter().take(room));