ccc3e20902
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>
1153 lines
51 KiB
Diff
1153 lines
51 KiB
Diff
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)
|
||
|