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 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 13:18:43 +03:00
committed by airdef
parent 4ae19d7dfc
commit fff9f9a746
9 changed files with 845 additions and 7 deletions
+22
View File
@@ -56,6 +56,8 @@ struct Out {
@location(11) glint_uv: vec2<f32>,
// 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<f32>,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -118,6 +120,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
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<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
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<f32>,
@location(1) normal: vec4<f32>,
}
@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<f32> {
+5
View File
@@ -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),
+57 -1
View File
@@ -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<horizon_client::gfx::ModView> {
Vec::new()
}
pub(super) fn mod_hd(&self) -> Option<horizon_client::gfx::HdSettings> {
None
}
pub(super) fn mod_lights(&self, _camera: glam::DVec3, _alpha: f64) -> Vec<horizon_client::gfx::HdLight> {
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<CameraView> = 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<horizon_client::gfx::HdSettings> {
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<horizon_client::gfx::HdLight> {
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<horizon_client::gfx::ModView> {
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;
}
}
}
}
+146 -3
View File
@@ -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<HdSettings>,
pub hd_lights: &'a [HdLight],
pub yaw: f32,
pub pitch: f32,
pub fov_degrees: f32,
@@ -550,7 +555,15 @@ pub struct Renderer {
native: Option<native_pack::NativePack>,
/// AirDef fork: a mod's post pass, its parameters and the partial tick they are for.
mod_post: Option<mod_post::ModPost>,
/// 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<String>,
mod_post_hdr: bool,
mod_views: Option<mod_views::Views>,
/// AirDef fork: the HD lighting's G-buffer pipelines (terrain, entities) and its targets.
gbuffer_terrain: wgpu::RenderPipeline,
gbuffer_entity: wgpu::RenderPipeline,
hd: Option<hd::Hd>,
mod_params: (Vec<f32>, 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
+520
View File
@@ -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<f32>,
resolution: vec4<f32>,
params: vec4<f32>,
}
struct FullOut {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vs_full(@builtin(vertex_index) i: u32) -> FullOut {
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: FullOut;
out.position = vec4<f32>(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<f32>,
color: vec4<f32>,
}
@group(0) @binding(0) var<uniform> frame: HdFrame;
@group(0) @binding(1) var scene_color: texture_2d<f32>;
@group(0) @binding(2) var scene_depth: texture_depth_2d;
@group(0) @binding(3) var albedo_tex: texture_2d<f32>;
@group(0) @binding(4) var normal_tex: texture_2d<f32>;
@group(0) @binding(5) var gbuffer_depth: texture_depth_2d;
@group(0) @binding(6) var<storage, read> lights: array<Light>;
@fragment
fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let px = vec2<i32>(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<f32>(c, 1.0);
}
let ndc = vec4<f32>(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<f32>(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<f32>(c, 1.0);
}
"#;
const BLOOM: &str = r#"
@group(0) @binding(0) var<uniform> frame: HdFrame;
@group(0) @binding(1) var source: texture_2d<f32>;
@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<f32> {
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<f32>(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<f32> {
let t = 1.0 / vec2<f32>(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<f32>(o, 1.0);
}
// A 3×3 tent, added onto the bigger level.
@fragment
fn fs_up(in: FullOut) -> @location(0) vec4<f32> {
let t = 1.0 / vec2<f32>(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<f32>(o / 16.0, 1.0);
}
"#;
const TONEMAP: &str = r#"
@group(0) @binding(0) var<uniform> frame: HdFrame;
@group(0) @binding(1) var hdr: texture_2d<f32>;
@group(0) @binding(2) var bloom: texture_2d<f32>;
@group(0) @binding(3) var linear: sampler;
@fragment
fn fs_tonemap(in: FullOut) -> @location(0) vec4<f32> {
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<f32>(rolled), clamp((peak - 2.0) / 8.0, 0.0, 0.6));
}
return vec4<f32>(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<Target>,
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::BlendState>,
) -> 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<wgpu::BindGroupLayoutEntry> {
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::<HdFrame>() 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::<HdLight>()) 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);
}
}
+25
View File
@@ -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<f32>,
@location(7) tint: vec4<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
@@ -127,6 +130,8 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
out.emission = 15u - (u32(round(color.a * 255.0)) & 15u);
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
out.pos = pos;
out.tint = vec4(color.rgb, 1.0);
return out;
}
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
@@ -148,6 +153,26 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
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<f32>,
@location(1) normal: vec4<f32>,
}
fn face_normal(pos: vec3<f32>) -> vec3<f32> {
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<f32> {
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
if color.a < 0.01 { discard; }
+28
View File
@@ -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<camera-view>);
/// 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<f32, f32, f32>,
/// 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<point-light>);
/// 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.
+4 -2
View File
@@ -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
+38 -1
View File
@@ -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<f32>,
/// `render.set-views`' cameras (AirDef fork).
views: Vec<CameraView>,
/// `render.set-lights` and `render.set-lighting` (AirDef fork).
lights: Vec<PointLight>,
lighting: Option<ModLighting>,
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<Item = (usize, &PointLight)> {
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<ModLighting> {
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<PointLight>) {
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));