Files
airdef/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch
T
chexit ccc3e20902
build / mod (push) Successful in 4m4s
release / windows (push) Successful in 23m13s
Playtest fixes: smoke by engine, searchlights, far launch sites that load their chunks, air defence balance, last chance and radar-cued shots, radars in smart raids, echo, Windows release build from a tag
Smoke: one rule by engine, no heat on trails, no trail on P1-SUN. Searchlights: two lamps a target, off with an empty sky. Halos fade before their reach and their sum is limited. Launch sites load chunks through the server thread, go off-map as a last resort, and reach 4000 blocks. Air defence: ranges x1.5, shots grow with urgency, claims released, /airdef lastchance, long shots on a big radar's tracks. Build: rust-toolchain.toml, airdef.py check, generator manifest and --check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:44:14 +03:00

1153 lines
51 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 13:18:43 +0300
Subject: [PATCH 11/40] 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>
---
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<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> {
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<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;
+ }
+ }
}
}
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<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
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<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);
+ }
+}
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<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; }
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<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.
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<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));
--
2.54.0 (Apple Git-157)