Files
airdef/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.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

2076 lines
85 KiB
Diff

From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:17:11 +0300
Subject: [PATCH 28/40] HD: volumetric smoke and missile trails with clear
tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun
speculars by material, wet surfaces and puddles in rain, wind in foliage
(HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders
checked for HLSL translation in their own test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/entity.wgsl | 12 +-
crates/horizon-client/src/game.rs | 7 +
crates/horizon-client/src/game/mod_models.rs | 74 ++-
crates/horizon-client/src/gfx.rs | 42 +-
crates/horizon-client/src/gfx/hd/cloud.wgsl | 3 +
crates/horizon-client/src/gfx/hd/fog.wgsl | 91 +++-
crates/horizon-client/src/gfx/hd/frame.rs | 11 +
crates/horizon-client/src/gfx/hd/light.wgsl | 64 ++-
crates/horizon-client/src/gfx/hd/materials.rs | 242 +++++++++
crates/horizon-client/src/gfx/hd/mod.rs | 96 ++++
crates/horizon-client/src/gfx/hd/prelude.wgsl | 4 +
crates/horizon-client/src/gfx/hd/smoke.rs | 477 ++++++++++++++++++
.../horizon-client/src/gfx/hd/smoke_grid.wgsl | 61 +++
.../src/gfx/hd/smoke_sample.wgsl | 29 ++
crates/horizon-client/src/gfx/hd/volumes.rs | 29 +-
crates/horizon-client/src/terrain.wgsl | 67 ++-
crates/horizon-mod-sdk/wit/horizon.wit | 41 ++
crates/horizon-mods/src/api_level.rs | 6 +-
crates/horizon-mods/src/runtime.rs | 61 ++-
docs/api/mods-reference.md | 24 +
20 files changed, 1417 insertions(+), 24 deletions(-)
create mode 100644 crates/horizon-client/src/gfx/hd/materials.rs
create mode 100644 crates/horizon-client/src/gfx/hd/smoke.rs
create mode 100644 crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
create mode 100644 crates/horizon-client/src/gfx/hd/smoke_sample.wgsl
diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl
index b3fc9bc82..55b93fd7d 100644
--- a/crates/horizon-client/src/entity.wgsl
+++ b/crates/horizon-client/src/entity.wgsl
@@ -24,6 +24,10 @@ struct Globals {
hd_flags: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
+ // AirDef fork, HD wind. x: strength (0 off), y: seconds (wraps at 600), zw: camera x, z mod 256.
+ hd_wind: vec4<f32>,
+ // AirDef fork: the sprite layers' material classes, 4 bits each (gfx/hd/materials.rs).
+ hd_materials: array<vec4<u32>, 64>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -285,7 +289,13 @@ struct EntityGBufferOut {
var out: EntityGBufferOut;
out.albedo = vec4(color.rgb, glow);
out.normal = vec4(n, 1.0);
- out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), 0.0, 1.0);
+ // Roughness and metalness: creatures and items are matte; the mods' models (overlay 41) are
+ // painted metal, 42 bare metal (game/mod_models.rs).
+ var material = vec2(0.9, 0.0);
+ if in.overlay == 41u { material = vec2(0.42, 0.3); }
+ if in.overlay == 42u { material = vec2(0.28, 1.0); }
+ if in.overlay == 40u { material = vec2(0.5, 0.0); }
+ out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), material);
return out;
}
// AirDef fork: entities in the sun's shadow map.
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index fad8de187..2130d8d88 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -11819,10 +11819,13 @@ impl Game<'_> {
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 (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha);
let frame = Frame {
mod_views: &mod_views,
hd: self.mod_hd(),
hd_lights: &hd_lights,
+ hd_smoke: &hd_smoke,
+ hd_smoke_clock,
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
nether_lighting: self.dim == Dim::Nether,
@@ -14676,6 +14679,7 @@ fn build_renderer(
.copied()
.unwrap_or(0xffff)
}),
+ horizon_client::gfx::hd_material_table(&assets.sprite_layers),
))?;
renderer.hud.sprite_animations = atlas
.sprite_animations
@@ -14736,6 +14740,7 @@ fn loading_renderer(
previous,
api,
[0xffff; 2],
+ [[0; 4]; 64],
))?;
if let Some(atlas) = atlas {
renderer.hud.sprite_animations = atlas
@@ -14753,6 +14758,8 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
mod_views: &[],
hd: None,
hd_lights: &[],
+ hd_smoke: &[],
+ hd_smoke_clock: 0.0,
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 f2329a123..f5e68566a 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -44,6 +44,15 @@ pub(super) struct ModModels {
lights: HashMap<(usize, u32), (PointLight, PointLight)>,
}
+/// The overlays of the mods' models (entity.wgsl): glowing.
+#[cfg(feature = "mods")]
+const MODEL_GLOW: u32 = 40;
+#[cfg(feature = "mods")]
+const MODEL_PAINTED: u32 = 41;
+/// Bare metal: smooth and fully metallic (entity.wgsl's fs_entity_gbuffer).
+#[cfg(feature = "mods")]
+#[allow(dead_code)]
+const MODEL_BARE_METAL: u32 = 42;
/// Full brightness: sky and block light 15, as the entity shader packs them.
#[cfg(feature = "mods")]
const GLOW: u32 = 240 | (240 << 8);
@@ -72,6 +81,13 @@ impl Game<'_> {
) -> Vec<horizon_client::gfx::HdLight> {
Vec::new()
}
+ pub(super) fn mod_smoke(
+ &mut self,
+ _camera: glam::DVec3,
+ _alpha: f64,
+ ) -> (Vec<horizon_client::gfx::HdSmoke>, f32) {
+ (Vec::new(), 0.0)
+ }
}
#[cfg(feature = "mods")]
@@ -211,6 +227,56 @@ impl Game<'_> {
out.into_iter().take(512).map(|(_, l)| l).collect()
}
+ /// The mods' smoke segments for this frame, relative to the camera and aged to the clock the
+ /// mod gave (between its last two calls, as the lights); the nearest first when there are
+ /// too many. Also tells the mods whether HD draws it.
+ pub(super) fn mod_smoke(
+ &mut self,
+ camera: DVec3,
+ alpha: f64,
+ ) -> (Vec<horizon_client::gfx::HdSmoke>, f32) {
+ let on = self.mod_hd().is_some() && horizon_client::gfx::hd_smoke_enabled();
+ self.mods.mods.set_smoke_active(on);
+ let Some((before, now)) = self.mods.mods.smoke_clock().filter(|_| on) else {
+ return (Vec::new(), 0.0);
+ };
+ let clock = before + (now - before) * alpha.clamp(0.0, 1.0);
+ let mut out: Vec<(f64, horizon_client::gfx::HdSmoke)> = self
+ .mods
+ .mods
+ .smoke()
+ .filter_map(|s| {
+ let age = (clock - s.born) / 20.0;
+ if age < 0.0 || age >= f64::from(s.life) / 20.0 {
+ return None;
+ }
+ let a = DVec3::new(s.ax, s.ay, s.az) - camera;
+ let b = DVec3::new(s.bx, s.by, s.bz) - camera;
+ let near = a.length().min(b.length());
+ // Far past the grid, nothing of it can show.
+ if near > 600.0 {
+ return None;
+ }
+ let seed = (s.id.wrapping_mul(2_654_435_761) >> 8) as f32 / 16_777_216.0;
+ Some((
+ near,
+ horizon_client::gfx::HdSmoke {
+ a: [a.x as f32, a.y as f32, a.z as f32, age as f32],
+ b: [b.x as f32, b.y as f32, b.z as f32, s.life / 20.0],
+ shape: [s.radius_start, s.radius_end, s.density, s.heat],
+ motion: [s.rise, s.clear, s.soot, seed],
+ },
+ ))
+ })
+ .collect();
+ out.sort_by(|x, y| x.0.total_cmp(&y.0));
+ out.truncate(3072);
+ (
+ out.into_iter().map(|(_, s)| s).collect(),
+ clock.rem_euclid(86_400.0 * 20.0) as f32 / 20.0,
+ )
+ }
+
/// 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);
@@ -285,10 +351,10 @@ impl Game<'_> {
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;
- }
+ // Any other model is overlay 41, painted metal (medium rough, a little metallic);
+ // 42 (MODEL_BARE_METAL) is bare metal, for a mod to ask for later.
+ for v in &mut out[start..] {
+ v.layer = if p.glow { MODEL_GLOW } else { MODEL_PAINTED };
}
}
}
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index bdc5b867a..93a2f7c63 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -72,6 +72,10 @@ mod cull;
mod end_sky;
mod flat;
mod hd;
+/// AirDef fork: the HD material classes of the sprite layers (`hd::materials`).
+pub fn hd_material_table(layers: &std::collections::BTreeMap<String, u32>) -> hd::MaterialTable {
+ hd::material_table(layers)
+}
mod hdr;
pub mod lightning;
mod lod;
@@ -80,7 +84,7 @@ mod mod_views;
mod native_material;
mod native_pack;
mod native_post;
-pub use hd::{HdLight, HdSettings};
+pub use hd::{HdLight, HdSettings, HdSmoke, smoke_enabled as hd_smoke_enabled};
pub use mod_views::ModView;
mod native_sky;
mod occlusion;
@@ -196,6 +200,11 @@ struct Globals {
hd_flags: [f32; 4],
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
+ /// AirDef fork, HD wind. x: strength (0 off); y: seconds (wraps at 600); zw: the camera's x
+ /// and z modulo 256.
+ hd_wind: [f32; 4],
+ /// AirDef fork: the sprite layers' material classes, 4 bits each (`hd::materials`).
+ hd_materials: hd::MaterialTable,
}
pub type SectionKey = (i32, i32, i32);
struct SectionGpu {
@@ -226,6 +235,10 @@ pub struct Frame<'a> {
/// The HD lighting (AirDef fork): on with its settings, and the mods' lights.
pub hd: Option<HdSettings>,
pub hd_lights: &'a [HdLight],
+ /// The mods' smoke segments for HD's volumetric smoke, camera-relative, and the mod's clock
+ /// in seconds.
+ pub hd_smoke: &'a [HdSmoke],
+ pub hd_smoke_clock: f32,
pub yaw: f32,
pub pitch: f32,
pub fov_degrees: f32,
@@ -567,6 +580,8 @@ pub struct Renderer {
mod_views: Option<mod_views::Views>,
/// AirDef fork: the HD lighting's G-buffer pipelines (terrain, entities) and its targets.
gbuffer_terrain: wgpu::RenderPipeline,
+ /// The sprite layers' material classes (leaves, plants, metal) for the HD wind and specular.
+ hd_materials: hd::MaterialTable,
/// The water quads alone into the HD water targets (section draws; `flat.water` for flat ones).
gbuffer_water: wgpu::RenderPipeline,
gbuffer_entity: wgpu::RenderPipeline,
@@ -1089,6 +1104,7 @@ impl Renderer {
previous: Option<&Self>,
api: GraphicsApi,
water_layers: [u32; 2],
+ hd_materials: hd::MaterialTable,
) -> Result<Self> {
let split = sprite_split();
if layers > split * SPRITE_ARRAYS {
@@ -2877,6 +2893,7 @@ impl Renderer {
mod_post_hdr: false,
mod_views: None,
gbuffer_terrain,
+ hd_materials,
gbuffer_water,
gbuffer_entity,
shadow_terrain,
@@ -4141,6 +4158,19 @@ impl Renderer {
}
lights
},
+ hd_wind: if hd_wanted && hd::wind_enabled() {
+ // Stronger gusts in rain.
+ let rain = frame.sky.rain.clamp(0.0, 1.0);
+ [
+ 1.0 + rain * 1.2,
+ hd::wind_seconds(),
+ frame.camera.x.rem_euclid(256.0) as f32,
+ frame.camera.z.rem_euclid(256.0) as f32,
+ ]
+ } else {
+ [0.0; 4]
+ },
+ hd_materials: self.hd_materials,
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
@@ -5167,6 +5197,13 @@ impl Renderer {
None => translucent_draws > 0,
};
if let Some(settings) = frame.hd {
+ hd.write_smoke(
+ &self.queue,
+ &hd_scene,
+ frame.hd_smoke,
+ frame.hd_lights,
+ frame.hd_smoke_clock,
+ );
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
}
// vanilla's translucent water leaves the colour while the HD water draws it.
@@ -6454,7 +6491,8 @@ mod tests {
fn wgsl_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
- if path.is_dir() {
+ // AirDef fork: the HD shaders need their prelude; gfx/hd's own test translates them.
+ if path.is_dir() && !path.ends_with("gfx/hd") {
wgsl_files(&path, out);
} else if path.extension().is_some_and(|e| e == "wgsl") {
out.push(path);
diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl
index a3c0fffae..fad234a46 100644
--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl
+++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl
@@ -1,5 +1,6 @@
@group(0) @binding(6) var history: texture_2d<f32>;
+@group(0) @binding(7) var smoke_tex: texture_2d<f32>;
fn ign(px: vec2<f32>) -> f32 {
let p = px + vec2<f32>(5.588238 * frame.params.w);
@@ -98,6 +99,8 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
var d = cloud_density(pos, 1.0);
if d < 0.002 { continue; }
d *= 1.0 - smoothstep(0.8, 1.0, t / 4200.0);
+ // A flying thing's tunnel through the cloud.
+ if frame.smoke.x > 0.5 { d *= 1.0 - smoke_at(smoke_tex, linear, pos).a; }
let step_od = d * ext * step_len;
let step_t = exp(-step_od);
let h = clamp((pos.y - y0) / frame.clouds.y, 0.0, 1.0);
diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl
index 8979534d7..38b9098e3 100644
--- a/crates/horizon-client/src/gfx/hd/fog.wgsl
+++ b/crates/horizon-client/src/gfx/hd/fog.wgsl
@@ -7,6 +7,7 @@
@group(0) @binding(5) var repeat_s: sampler;
@group(0) @binding(6) var linear: sampler;
//SKY7
+@group(0) @binding(8) var smoke_tex: texture_2d<f32>;
const FOG_MAX: f32 = 192.0;
const FOG_STEPS: i32 = 18;
@@ -55,6 +56,85 @@ fn fog_sigma(p: vec3<f32>) -> f32 {
return s * patchy * smoothstep(28.0, 58.0, wy);
}
+const SMOKE_MAX: f32 = 200.0;
+const SMOKE_STEP: f32 = 3.5;
+const SMOKE_WIND: vec2<f32> = vec2<f32>(1.6, 1.2);
+
+fn fire_colour(h: f32) -> vec3<f32> {
+ return mix(vec3<f32>(1.0, 0.38, 0.08), vec3<f32>(1.0, 0.8, 0.45), clamp(h, 0.0, 1.0));
+}
+
+// The smoke grid's density at a point with the fine detail on it: two Worley octaves that ride the
+// wind with the smoke (the noise lives in the world, the smoke drifts with it).
+fn smoke_detail(pos: vec3<f32>, core: f32) -> f32 {
+ let w = vec3<f32>(pos.x + frame.cloud_cam.x, pos.y + frame.vfog.y, pos.z + frame.cloud_cam.y)
+ - vec3<f32>(SMOKE_WIND.x, 0.0, SMOKE_WIND.y) * frame.smoke.z;
+ let n1 = textureSampleLevel(noise3, repeat_s, w / 64.0, 0.0).r;
+ let n2 = textureSampleLevel(noise3, repeat_s, w / 22.0, 0.0).g;
+ let n = 0.55 * n1 + 0.45 * n2;
+ // Thin smoke breaks up into wisps, thick smoke stays whole.
+ let keep = smoothstep(0.18, 0.62, n + 0.45 * min(core, 1.5) - 0.12);
+ return core * mix(0.35, 1.45, keep) * mix(0.6, 1.0, smoothstep(0.0, 0.6, core));
+}
+
+// The smoke between the camera and `len` along `dir`: light and transmittance (a).
+fn march_smoke(dir: vec3<f32>, len: f32, jitter: f32, haze: vec3<f32>, cos_v: f32) -> vec4<f32> {
+ let lo = frame.smoke_min.xyz;
+ let hi = lo + vec3<f32>(128.0, 64.0, 128.0) * frame.smoke.y;
+ let inv = vec3<f32>(1.0) / select(dir, vec3<f32>(1e-5), abs(dir) < vec3<f32>(1e-5));
+ let t0v = (lo - vec3<f32>(0.0)) * inv;
+ let t1v = (hi - vec3<f32>(0.0)) * inv;
+ let t_in = max(max(min(t0v.x, t1v.x), min(t0v.y, t1v.y)), max(min(t0v.z, t1v.z), 0.0));
+ let t_out = min(min(max(t0v.x, t1v.x), max(t0v.y, t1v.y)), min(max(t0v.z, t1v.z), len));
+ if t_out <= t_in { return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
+ let n = clamp(i32(ceil((t_out - t_in) / SMOKE_STEP)), 1, 64);
+ let ds = (t_out - t_in) / f32(n);
+ let sun = frame.sun.xyz;
+ let sun_on = frame.sun.w;
+ let sun_light = frame.sun_color.rgb * sun_on;
+ let phase = 0.45 + 0.55 * min(hgn(cos_v, 0.5), 6.0) * 0.5 + 0.2 * hgn(cos_v, -0.3);
+ let up_sky = haze;
+ var trans = 1.0;
+ var light = vec3<f32>(0.0);
+ for (var i = 0; i < n; i++) {
+ if trans < 0.02 { break; }
+ let pos = dir * (t_in + (f32(i) + jitter) * ds);
+ if smoke_probe(smoke_tex, linear, pos) < 0.003 { continue; }
+ let g = smoke_at(smoke_tex, linear, pos);
+ if g.x < 0.004 { continue; }
+ let dens = smoke_detail(pos, g.x);
+ let a = 1.0 - exp(-dens * ds);
+ let inv_d = 1.0 / max(g.x, 1e-4);
+ let soot = clamp(g.y * inv_d, 0.0, 1.0);
+ let heat = clamp(g.z * inv_d, 0.0, 1.0);
+ let albedo = mix(0.9, 0.16, soot);
+ // Sunlight: the cascades' shadow, then the smoke's own along the way to the sun.
+ var vis = 1.0;
+ if sun_on > 0.0 { vis = sun_visible(pos); }
+ var od = 0.0;
+ for (var k = 0; k < 3; k++) {
+ od += smoke_at(smoke_tex, linear, pos + sun * (f32(k) + 0.5 + 0.5 * jitter) * 4.0).x * 4.0;
+ }
+ let through = 0.65 * exp(-od * 0.9) + 0.35 * exp(-od * 0.22);
+ var glow = vec3<f32>(0.0);
+ let count = i32(frame.smoke_min.w);
+ for (var j = 0; j < count; j++) {
+ let l = frame.smoke_lights[j * 2];
+ let to = l.xyz - pos;
+ let dist = length(to);
+ if dist >= l.w { continue; }
+ let window = pow(clamp(1.0 - pow(dist / l.w, 4.0), 0.0, 1.0), 2.0);
+ glow += frame.smoke_lights[j * 2 + 1].rgb / (dist * dist + 6.0) * window;
+ }
+ let ambient = up_sky * (0.25 + 0.75 * exp(-od * 0.35)) * FOG_AMBIENT;
+ var s = albedo * (sun_light * vis * phase * through * FOG_SUN + ambient + glow * 1.5);
+ s += fire_colour(heat) * heat * heat * 9.0;
+ light += trans * a * s;
+ trans *= 1.0 - a;
+ }
+ return vec4<f32>(light, trans);
+}
+
@fragment
fn fs_fog(in: FullOut) -> FogOut {
// The nearest of the four world pixels this texel stands for (so edges keep the near depth).
@@ -100,7 +180,8 @@ fn fs_fog(in: FullOut) -> FogOut {
let t = len * f0;
let ds = len * (f1 - f0);
let pos = dir * (t + 0.5 * ds);
- let sigma = fog_sigma(pos);
+ var sigma = fog_sigma(pos);
+ if frame.smoke.x > 0.5 { sigma *= 1.0 - smoke_at(smoke_tex, linear, pos).a; }
let a = 1.0 - exp(-sigma * ds);
var vis = 1.0;
if sun_on > 0.0 { vis = sun_visible(pos); }
@@ -113,7 +194,13 @@ fn fs_fog(in: FullOut) -> FogOut {
// Photon brightens the fog a little in the early morning and the evening.
let sy = frame.sky_sun.y + 0.02;
let glow = 1.0 + 2.0 * linear_step(0.05, 1.0, exp(-300.0 * sy * sy));
- out.fog = vec4<f32>(light * glow, trans);
+ var fog = vec4<f32>(light * glow, trans);
+ if frame.smoke.x > 0.5 {
+ let sm = march_smoke(dir, select(SMOKE_MAX, min(length(p), SMOKE_MAX), d > 0.0), jitter, haze, cos_v);
+ // The smoke is lit by the sun itself and stands in the haze: it is laid over the fog.
+ fog = vec4<f32>(fog.rgb + fog.a * sm.rgb, fog.a * sm.a);
+ }
+ out.fog = fog;
return out;
}
diff --git a/crates/horizon-client/src/gfx/hd/frame.rs b/crates/horizon-client/src/gfx/hd/frame.rs
index 9d4dfabdb..415b12cdb 100644
--- a/crates/horizon-client/src/gfx/hd/frame.rs
+++ b/crates/horizon-client/src/gfx/hd/frame.rs
@@ -90,6 +90,17 @@ pub(super) struct HdFrame {
pub(super) cloud_sun: [f32; 4],
/// x: 1 when the volumetric fog is drawn; y: the camera's height in the world; z: the morning mist.
pub(super) vfog: [f32; 4],
+ /// x: how wet the surfaces are (0 dry .. 1 soaked; follows the rain, dries slowly); y: 1 when
+ /// the specular and the wet look are on.
+ pub(super) surface: [f32; 4],
+ /// Smoke (see `smoke.rs`). x: 1 when the smoke grid is drawn; y: the grid's cell size in blocks;
+ /// z: the smoke clock in seconds modulo 86400 (the noise rides the wind with it); w: unused.
+ pub(super) smoke: [f32; 4],
+ /// xyz: the grid's minimum corner relative to the camera; w: the number of glow lights.
+ pub(super) smoke_min: [f32; 4],
+ /// Up to 16 lights that glow on the smoke: xyz position relative to the camera, w radius; then
+ /// rgb colour times intensity.
+ pub(super) smoke_lights: [[f32; 4]; 32],
}
/// What the world around looks like this frame (from the renderer's globals).
diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl
index 034a04607..2534a3db8 100644
--- a/crates/horizon-client/src/gfx/hd/light.wgsl
+++ b/crates/horizon-client/src/gfx/hd/light.wgsl
@@ -203,6 +203,21 @@ struct LightOut {
@location(1) air: vec4<f32>,
}
+// Value noise on an integer lattice that repeats every 256 cells, for the puddles.
+fn wet_hash(c: vec2<i32>) -> f32 {
+ var h = u32(c.x & 255) * 374761393u + u32(c.y & 255) * 668265263u;
+ h = (h ^ (h >> 13u)) * 1274126177u;
+ h = h ^ (h >> 16u);
+ return f32(h & 0xffffu) / 65535.0;
+}
+fn wet_noise(p: vec2<f32>) -> f32 {
+ let i = vec2<i32>(floor(p));
+ let f = fract(p);
+ let u = f * f * (3.0 - 2.0 * f);
+ return mix(mix(wet_hash(i), wet_hash(i + vec2<i32>(1, 0)), u.x),
+ mix(wet_hash(i + vec2<i32>(0, 1)), wet_hash(i + vec2<i32>(1, 1)), u.x), u.y);
+}
+
// An opaque surface lit: the ambient, the sun and the mods' lights over its albedo.
fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
let sun = frame.sun.xyz;
@@ -216,6 +231,27 @@ fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
let n = normalize(textureLoad(normal_tex, px, 0).xyz);
let albedo = textureLoad(albedo_tex, px, 0);
let lm = textureLoad(light_tex, px, 0).xy;
+ // Material: roughness and metalness (the G-buffer's light-level texture, zw).
+ let material = textureLoad(light_tex, px, 0).zw;
+ var rough = material.x;
+ let metal = material.y;
+ var surface_rgb = albedo.rgb;
+ var puddle = 0.0;
+ let shiny = frame.surface.y > 0.5 && !water;
+ // Rain: surfaces under open sky darken and turn glossy; flat ones grow puddles in patches of a
+ // world-space noise.
+ let wet = frame.surface.x * select(0.0, 1.0, shiny);
+ if wet > 0.0 {
+ let open_sky = smoothstep(0.8, 0.97, lm.x);
+ let flat_up = smoothstep(0.9, 0.98, n.y);
+ let soak = wet * open_sky * (0.35 + 0.65 * flat_up);
+ let wp = frame.cloud_cam.xy + p.xz;
+ let nz = 0.6 * wet_noise(wp * 0.45) + 0.4 * wet_noise(wp * 1.3 + vec2<f32>(17.0, 5.0));
+ puddle = smoothstep(0.66 - 0.16 * wet, 0.74 - 0.16 * wet, nz) * smoothstep(0.3, 0.7, wet) * open_sky * flat_up;
+ surface_rgb *= mix(1.0, 0.7, soak) * mix(1.0, 0.6, puddle);
+ rough = mix(rough, 0.12, soak * (1.0 - 0.5 * metal));
+ rough = mix(rough, 0.03, puddle);
+ }
// Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach.
let base = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
let block = textureSampleLevel(lightmap, linear, vec2<f32>(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
@@ -255,7 +291,33 @@ fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0);
light += l.color.rgb * falloff * k * ao_lights;
}
- return albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
+ // Metal keeps most of its diffuse look (there is no environment map to light it with).
+ var out = surface_rgb * (1.0 - metal * 0.65) * light + albedo.rgb * albedo.a * 1.5;
+ if shiny {
+ // The sun's GGX highlight and the sky's reflection, with Fresnel that rough surfaces barely show.
+ let v = -normalize(p);
+ let ndv = max(dot(n, v), 0.001);
+ let gloss = 1.0 - rough;
+ let f0 = mix(vec3<f32>(0.04), surface_rgb, metal);
+ let fresnel = f0 + (max(vec3<f32>(gloss), f0) - f0) * pow(1.0 - ndv, 5.0);
+ if sun_on > 0.0 && ndl > 0.0 && s > 0.0 {
+ let h = normalize(sun + v);
+ let ndh = max(dot(n, h), 0.0);
+ let a = max(rough * rough, 0.03);
+ let a2 = a * a;
+ let dd = ndh * ndh * (a2 - 1.0) + 1.0;
+ let d = a2 / (3.14159265 * dd * dd);
+ let vis = 0.5 / (ndl * sqrt(ndv * ndv * (1.0 - a2) + a2) + ndv * sqrt(ndl * ndl * (1.0 - a2) + a2));
+ out += min(fresnel * (d * vis * ndl) * frame.sun_color.rgb * (sun_on * s * sky_reach), vec3<f32>(6.0));
+ }
+ if atmosphere && gloss > 0.12 {
+ let r = reflect(-v, n);
+ let sky = atmosphere_sky(normalize(vec3<f32>(r.x, max(r.y, 0.02), r.z)));
+ let reach = smoothstep(0.35, 0.95, lm.x) * smoothstep(-0.2, 0.15, r.y);
+ out += sky * fresnel * (gloss * gloss * reach * ao_ambient);
+ }
+ }
+ return out;
}
@fragment
diff --git a/crates/horizon-client/src/gfx/hd/materials.rs b/crates/horizon-client/src/gfx/hd/materials.rs
new file mode 100644
index 000000000..32d080878
--- /dev/null
+++ b/crates/horizon-client/src/gfx/hd/materials.rs
@@ -0,0 +1,242 @@
+//! Surface materials and wind for the HD renderer: which sprite layers are leaves, plants or metal.
+//!
+//! The terrain shader (`terrain.wgsl`) cannot ask a sprite its name, so the renderer gives it a table:
+//! 4 bits per sprite layer (the first `LAYERS` layers; mods' sprites past them are plain), packed
+//! 8 to a word and 4 words to a `vec4<u32>` in the `Globals` block (`hd_materials`). The classes
+//! are used twice: the vertex stage sways leaves and plants (`HORIZON_HD_WIND=0` stops it), the
+//! G-buffer stage writes roughness and metalness for the sun's specular (`HORIZON_HD_SPECULAR=0`
+//! turns that off in the lighting). Keep the numbers in sync with `terrain.wgsl` (`MAT_*`).
+use std::collections::BTreeMap;
+
+/// Sprite layers the table covers (the first sprite array's size).
+pub const LAYERS: usize = 2048;
+/// The table as it sits in `Globals`: `LAYERS` 4-bit entries.
+pub type Table = [[u32; 4]; LAYERS / 32];
+
+pub const PLAIN: u8 = 0;
+pub const LEAVES: u8 = 1;
+/// A plant: crossed quads sway from the bottom edge of the sprite.
+pub const PLANT: u8 = 2;
+/// The lower and the upper half of a two-block plant (they share the bend at the join).
+pub const PLANT_LOWER: u8 = 3;
+pub const PLANT_UPPER: u8 = 4;
+pub const METAL: u8 = 5;
+/// Polished stone, quartz, concrete: smoother than a natural block.
+pub const SMOOTH: u8 = 6;
+/// Glazed, glassy or gem-like blocks.
+pub const GLOSSY: u8 = 7;
+pub const WOOD: u8 = 8;
+
+/// The class of a block sprite by its name (`minecraft:block/<name>`).
+pub fn classify(name: &str) -> u8 {
+ let Some(n) = name.strip_prefix("minecraft:block/") else {
+ return PLAIN;
+ };
+ if n.ends_with("_leaves") {
+ return LEAVES;
+ }
+ if n == "vine" {
+ return LEAVES;
+ }
+ // Two-block plants: tall grass, ferns, flowers, the sunflower.
+ for double in [
+ "tall_grass",
+ "large_fern",
+ "lilac",
+ "rose_bush",
+ "peony",
+ "sunflower",
+ ] {
+ if n == format!("{double}_bottom") {
+ return PLANT_LOWER;
+ }
+ if n == format!("{double}_top") {
+ return PLANT_UPPER;
+ }
+ }
+ const PLANTS: &[&str] = &[
+ "short_grass",
+ "fern",
+ "dead_bush",
+ "dandelion",
+ "golden_dandelion",
+ "poppy",
+ "blue_orchid",
+ "allium",
+ "azure_bluet",
+ "red_tulip",
+ "orange_tulip",
+ "white_tulip",
+ "pink_tulip",
+ "oxeye_daisy",
+ "cornflower",
+ "lily_of_the_valley",
+ "torchflower",
+ "open_eyeblossom",
+ "closed_eyeblossom",
+ "wither_rose",
+ "short_dry_grass",
+ "tall_dry_grass",
+ "sugar_cane",
+ "bush",
+ "firefly_bush",
+ "sweet_berry_bush_stage0",
+ "sweet_berry_bush_stage1",
+ "sweet_berry_bush_stage2",
+ "sweet_berry_bush_stage3",
+ ];
+ if PLANTS.contains(&n)
+ || n.ends_with("_sapling")
+ || [
+ "wheat_stage",
+ "carrots_stage",
+ "potatoes_stage",
+ "beetroots_stage",
+ ]
+ .iter()
+ .any(|s| n.starts_with(s))
+ {
+ return PLANT;
+ }
+ // Metal: whole blocks and fittings.
+ let metal_block = [
+ "iron_block",
+ "gold_block",
+ "netherite_block",
+ "copper_block",
+ "exposed_copper",
+ "weathered_copper",
+ "oxidized_copper",
+ "chain",
+ "iron_bars",
+ "iron_door_top",
+ "iron_door_bottom",
+ "iron_trapdoor",
+ "copper_door_top",
+ "copper_door_bottom",
+ "copper_trapdoor",
+ "heavy_core",
+ "anvil",
+ "anvil_top",
+ "chipped_anvil_top",
+ "damaged_anvil_top",
+ "cauldron_side",
+ "cauldron_top",
+ "cauldron_inner",
+ "hopper_outside",
+ "hopper_top",
+ "lightning_rod",
+ ];
+ if metal_block.contains(&n)
+ || n.contains("cut_copper")
+ || n.contains("chiseled_copper")
+ || n.contains("copper_grate")
+ || n.contains("copper_bulb")
+ || n.contains("waxed_") && n.contains("copper")
+ {
+ return METAL;
+ }
+ if n.starts_with("polished_")
+ || n.starts_with("smooth_")
+ || n.starts_with("quartz_block")
+ || n.starts_with("chiseled_quartz")
+ || n.ends_with("_concrete")
+ || n.starts_with("purpur_block")
+ || n.starts_with("prismarine")
+ || n.starts_with("dark_prismarine")
+ || n == "calcite"
+ || n == "stone"
+ {
+ return SMOOTH;
+ }
+ if n.ends_with("_glazed_terracotta")
+ || [
+ "obsidian",
+ "crying_obsidian",
+ "diamond_block",
+ "emerald_block",
+ "lapis_block",
+ ]
+ .contains(&n)
+ || n.starts_with("amethyst_block")
+ || n == "gilded_blackstone"
+ {
+ return GLOSSY;
+ }
+ if n.ends_with("_planks") {
+ return WOOD;
+ }
+ PLAIN
+}
+
+/// The table for the renderer's sprite layers (`name -> layer`).
+pub fn table(layers: &BTreeMap<String, u32>) -> Table {
+ let mut nibbles = vec![0u8; LAYERS];
+ for (name, &layer) in layers {
+ if let Some(slot) = nibbles.get_mut(layer as usize) {
+ *slot = classify(name);
+ }
+ }
+ let mut out = [[0u32; 4]; LAYERS / 32];
+ for (layer, class) in nibbles.into_iter().enumerate() {
+ let word = layer / 8;
+ out[word / 4][word % 4] |= u32::from(class) << ((layer % 8) * 4);
+ }
+ out
+}
+
+/// Whether the wind sways leaves and plants (`HORIZON_HD_WIND=0` stops it).
+pub fn wind_enabled() -> bool {
+ std::env::var("HORIZON_HD_WIND").is_ok_and(|v| v != "0")
+ || std::env::var("HORIZON_HD_WIND").is_err()
+}
+
+/// Whether surfaces get the sun's specular and the wet look (`HORIZON_HD_SPECULAR=0` turns both off).
+pub fn specular_enabled() -> bool {
+ std::env::var("HORIZON_HD_SPECULAR").map_or(true, |v| v != "0")
+}
+
+/// Seconds for the wind's waves, wrapping at 600 (the shader's waves repeat in whole cycles of it).
+pub fn wind_seconds() -> f32 {
+ static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
+ let start = START.get_or_init(std::time::Instant::now);
+ (start.elapsed().as_secs_f64() % 600.0) as f32
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn names_get_their_classes() {
+ let c = |n: &str| classify(&format!("minecraft:block/{n}"));
+ assert_eq!(c("oak_leaves"), LEAVES);
+ assert_eq!(c("short_grass"), PLANT);
+ assert_eq!(c("tall_grass_top"), PLANT_UPPER);
+ assert_eq!(c("lilac_bottom"), PLANT_LOWER);
+ assert_eq!(c("wheat_stage3"), PLANT);
+ assert_eq!(c("oak_sapling"), PLANT);
+ assert_eq!(c("iron_block"), METAL);
+ assert_eq!(c("cut_copper"), METAL);
+ assert_eq!(c("polished_andesite"), SMOOTH);
+ assert_eq!(c("white_concrete"), SMOOTH);
+ assert_eq!(c("dirt"), PLAIN);
+ assert_eq!(c("grass_block_top"), PLAIN);
+ assert_eq!(classify("airdef:block/iron_block"), PLAIN);
+ }
+
+ #[test]
+ fn the_table_packs_four_bits_a_layer() {
+ let mut layers = BTreeMap::new();
+ layers.insert("minecraft:block/oak_leaves".to_owned(), 0);
+ layers.insert("minecraft:block/iron_block".to_owned(), 9);
+ layers.insert("minecraft:block/short_grass".to_owned(), 2047);
+ layers.insert("minecraft:block/fern".to_owned(), 5000);
+ let t = table(&layers);
+ let at = |l: usize| (t[l / 32][(l / 8) % 4] >> ((l % 8) * 4)) & 15;
+ assert_eq!(at(0), u32::from(LEAVES));
+ assert_eq!(at(9), u32::from(METAL));
+ assert_eq!(at(2047), u32::from(PLANT));
+ assert_eq!(at(1), 0);
+ }
+}
diff --git a/crates/horizon-client/src/gfx/hd/mod.rs b/crates/horizon-client/src/gfx/hd/mod.rs
index ed336692d..1199e073e 100644
--- a/crates/horizon-client/src/gfx/hd/mod.rs
+++ b/crates/horizon-client/src/gfx/hd/mod.rs
@@ -64,9 +64,11 @@ mod bloom;
mod frame;
mod gbuffer;
mod lighting;
+mod materials;
mod noise;
mod shadows;
mod sky;
+mod smoke;
mod taa;
mod tiles;
mod tonemap;
@@ -80,6 +82,7 @@ use gbuffer::GBuffer;
use lighting::Lighting;
use shadows::Shadows;
use sky::Sky;
+use smoke::Smoke;
use std::sync::atomic::{AtomicU64, Ordering};
use taa::Taa;
use tonemap::Tonemap;
@@ -88,7 +91,9 @@ use water::Water;
pub use frame::{HdLight, HdScene, HdSettings, light_of, vanilla_cloud_tint};
pub use gbuffer::{ALBEDO, LIGHT_LEVELS, NORMAL};
+pub use materials::{Table as MaterialTable, table as material_table, wind_enabled, wind_seconds};
pub use shadows::{CASCADES, cascades};
+pub use smoke::{HdSmoke, smoke_enabled};
pub use taa::{jitter, jitter_count, scale_override, taa_enabled};
pub use volumes::clouds_enabled;
pub use water::{WATER_NORMAL, WATER_TINT};
@@ -356,7 +361,11 @@ pub struct Hd {
clouds_active: bool,
fog_active: bool,
sky: Sky,
+ /// The volumetric smoke's grid (see `smoke`).
+ smoke: Smoke,
started: std::time::Instant,
+ /// The surfaces' wetness and when it was last updated (see `wetness`).
+ wet: (f32, std::time::Instant),
taa: Taa,
bloom: Bloom,
tonemap: Tonemap,
@@ -421,6 +430,8 @@ impl Hd {
clouds_active: false,
fog_active: false,
sky: Sky::new(device, &frame),
+ smoke: Smoke::new(device, &frame),
+ wet: (0.0, std::time::Instant::now()),
started: std::time::Instant::now(),
taa: Taa::new(device),
bloom: Bloom::new(device),
@@ -462,6 +473,25 @@ impl Hd {
}
/// The world is smaller than the output, so TAA upsamples it.
+ /// How wet the world is: soaks in over ~15 s of rain, dries out over ~2.5 minutes after it.
+ fn wetness(&mut self, scene: &HdScene) -> f32 {
+ let now = std::time::Instant::now();
+ let dt = now.duration_since(self.wet.1).as_secs_f32().min(1.0);
+ let target = if scene.sky {
+ (scene.rain / 0.7).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ let wet = self.wet.0;
+ let wet = if target > wet {
+ (wet + dt / 15.0).min(target)
+ } else {
+ (wet - dt / 150.0).max(target)
+ };
+ self.wet = (wet, now);
+ wet
+ }
+
fn upscaling(&self) -> bool {
self.out != self.size
}
@@ -580,6 +610,19 @@ impl Hd {
mist,
0.0,
],
+ smoke: self.smoke.frame.smoke,
+ smoke_min: self.smoke.frame.min,
+ smoke_lights: self.smoke.frame.lights,
+ surface: [
+ self.wetness(scene),
+ if materials::specular_enabled() {
+ 1.0
+ } else {
+ 0.0
+ },
+ 0.0,
+ 0.0,
+ ],
taa_params: [
if self.upscaling() {
UPSCALE_HISTORY
@@ -627,6 +670,24 @@ impl Hd {
queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame));
}
+ /// This frame's smoke segments (camera-relative, ages in seconds) and the mod's clock in
+ /// seconds; call before `write`. The mod's lights glow on it.
+ pub fn write_smoke(
+ &mut self,
+ queue: &wgpu::Queue,
+ scene: &HdScene,
+ smoke: &[HdSmoke],
+ lights: &[HdLight],
+ clock: f32,
+ ) {
+ if scene.sky && volumes::fog_enabled() && smoke_enabled() {
+ self.smoke.prepare(queue, scene, smoke, lights, clock);
+ } else {
+ self.smoke.frame = Default::default();
+ self.smoke.count = 0;
+ }
+ }
+
fn draw(
encoder: &mut wgpu::CommandEncoder,
label: &str,
@@ -735,6 +796,7 @@ mod tests {
("sky", sky::SKY_PASS.to_owned(), &sky::SKY_BINDINGS),
];
all.extend(volumes::shader_sources());
+ all.extend(smoke::shader_sources());
all
}
@@ -759,6 +821,40 @@ mod tests {
}
}
+ /// Each assembled HD shader translates to HLSL as wgpu's DirectX 12 backend writes it, entry
+ /// point by entry point (gfx.rs's test of the loose files skips these: they need the prelude).
+ #[test]
+ fn hd_shaders_translate_to_hlsl_for_directx_12() {
+ for (name, body, _) in shaders() {
+ let module = parse(name, &body);
+ let info = naga::valid::Validator::new(
+ naga::valid::ValidationFlags::all(),
+ naga::valid::Capabilities::all(),
+ )
+ .validate(&module)
+ .unwrap();
+ for entry in &module.entry_points {
+ let (module, info) = naga::back::pipeline_constants::process_overrides(
+ &module,
+ &info,
+ Some((entry.stage, &entry.name)),
+ &Default::default(),
+ )
+ .unwrap();
+ let mut hlsl = String::new();
+ naga::back::hlsl::Writer::new(
+ &mut hlsl,
+ &naga::back::hlsl::Options::default(),
+ &naga::back::hlsl::PipelineOptions {
+ entry_point: Some((entry.stage, entry.name.clone())),
+ },
+ )
+ .write(&module, &info, None)
+ .unwrap_or_else(|e| panic!("{name} {}: {e}", entry.name));
+ }
+ }
+ }
+
/// Each shader's file is part of some tested shader, so a new `.wgsl` cannot be forgotten.
#[test]
fn every_wgsl_file_is_tested() {
diff --git a/crates/horizon-client/src/gfx/hd/prelude.wgsl b/crates/horizon-client/src/gfx/hd/prelude.wgsl
index bd43a7c75..c62af3563 100644
--- a/crates/horizon-client/src/gfx/hd/prelude.wgsl
+++ b/crates/horizon-client/src/gfx/hd/prelude.wgsl
@@ -24,6 +24,10 @@ struct HdFrame {
cloud_dir: vec4<f32>,
cloud_sun: vec4<f32>,
vfog: vec4<f32>,
+ surface: vec4<f32>,
+ smoke: vec4<f32>,
+ smoke_min: vec4<f32>,
+ smoke_lights: array<vec4<f32>, 32>,
}
struct FullOut {
@builtin(position) position: vec4<f32>,
diff --git a/crates/horizon-client/src/gfx/hd/smoke.rs b/crates/horizon-client/src/gfx/hd/smoke.rs
new file mode 100644
index 000000000..dee0932e8
--- /dev/null
+++ b/crates/horizon-client/src/gfx/hd/smoke.rs
@@ -0,0 +1,477 @@
+//! AirDef fork: the HD renderer's volumetric smoke and exhaust trails (#54, plan item 8).
+//!
+//! A mod hands over segments of smoke (`render.set-smoke`: a tube or a puff with a birth time, a life,
+//! radii, a density, heat, soot, a rise and the radius of the clear tunnel it leaves in fog and cloud).
+//! Everything about a segment follows from its *age*, so nothing is simulated here and a replay that
+//! seeks, pauses or slows down just sends other segments or another clock. Each frame:
+//!
+//! 1. The CPU bakes every segment for its age (wind drift, rise, radius, thinning, cooling, the tunnel
+//! filling back in), sorts them nearest first, keeps `MAX_SEGMENTS` and bins them into 8-cell
+//! bricks of a camera-centred grid (`brick lists`: start and count, then the indices).
+//! 2. `fs_smoke_grid` (pass `hd smoke grid`) fills the grid, a 128 x 64 x 128 lattice of 3-block cells
+//! snapped to the world (so it does not swim) and laid out as a 1024 x 1024 atlas of 64 slices:
+//! r density, g soot x density, b heat x density, a clear amount. Each cell sums the capsules of
+//! its brick.
+//! 3. `fog.wgsl` ray-marches the grid inside the half-resolution fog pass (so TAA resolves its
+//! jitter and `fs_light` needs no change): sun with the cascades' shadows, self shadow along the
+//! sun, sky ambient and the nearest glowing lights of the mod. The clear amount thins the fog;
+//! `cloud.wgsl` thins the clouds with it.
+//!
+//! Contract:
+//! - Input segments (`HdSmoke`) are camera-relative, ages and lives in seconds. The grid reaches
+//! `GRID_XZ * CELL / 2` = 192 blocks round the camera horizontally, 48 below and 144 above.
+//! - The grid atlas is `Rgba16Float`, rewritten every frame the smoke is active (`active`).
+//! - Nothing here changes unless the mod sends segments; `HORIZON_HD_SMOKE=0` turns it off (the mod is
+//! told, and draws its own smoke).
+
+use super::frame::{HdLight, HdScene};
+use super::{STORAGE, UNIFORM, entries, fullscreen, module, stamped_pass, target};
+
+pub const GRID_XZ: u32 = 128;
+pub const GRID_Y: u32 = 64;
+pub const CELL: f32 = 3.0;
+/// Cells under the camera's height the grid reaches.
+const CELLS_BELOW: f32 = 16.0;
+const BRICK: u32 = 8;
+const BRICKS_XZ: u32 = GRID_XZ / BRICK;
+const BRICKS_Y: u32 = GRID_Y / BRICK;
+const BRICKS: usize = (BRICKS_XZ * BRICKS_XZ * BRICKS_Y) as usize;
+/// The atlas holds the slices in tiles of GRID_XZ, `TILES` to a row.
+const TILES: u32 = 8;
+pub const ATLAS: u32 = GRID_XZ * TILES;
+pub const ATLAS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
+pub const MAX_SEGMENTS: usize = 3072;
+/// Brick-list entries: a segment is in every brick its box touches.
+const MAX_INDICES: usize = 120_000;
+pub const MAX_LIGHTS: usize = 16;
+/// How fast the wind carries smoke (blocks per second, x and z).
+const WIND: [f32; 2] = [1.6, 1.2];
+
+/// Smoke is on unless `HORIZON_HD_SMOKE=0`.
+pub fn smoke_enabled() -> bool {
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ *ON.get_or_init(|| std::env::var("HORIZON_HD_SMOKE").map_or(true, |v| v != "0"))
+}
+
+/// A segment of smoke as the game hands it over for a frame, relative to the camera.
+#[repr(C)]
+#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
+pub struct HdSmoke {
+ /// xyz: where it starts; w: its age in seconds.
+ pub a: [f32; 4],
+ /// xyz: where it ends; w: its life in seconds.
+ pub b: [f32; 4],
+ /// Radius at birth, radius at the end, density, heat.
+ pub shape: [f32; 4],
+ /// Rise (blocks per second), clear radius, soot, seed (0..1).
+ pub motion: [f32; 4],
+}
+
+/// A segment baked for its age: what `fs_smoke_grid` reads (4 x vec4).
+#[repr(C)]
+#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
+struct Baked {
+ /// xyz start (drifted), w radius.
+ pa: [f32; 4],
+ /// xyz end, w density.
+ pb: [f32; 4],
+ /// soot, heat, clear radius, clear amount.
+ extra: [f32; 4],
+ /// seed, 0, 0, 0.
+ seed: [f32; 4],
+}
+
+/// `fs_smoke_grid`'s group 0: frame, the segments, the brick lists.
+pub(super) const SMOKE_GRID_BINDINGS: [wgpu::BindingType; 3] = [UNIFORM, STORAGE, STORAGE];
+
+pub(super) const SMOKE_GRID: &str = include_str!("smoke_grid.wgsl");
+/// The grid's trilinear lookup, shared by the fog and the clouds. Needs `frame` in scope.
+pub(super) const SMOKE_SAMPLE: &str = include_str!("smoke_sample.wgsl");
+
+/// What `write` copies into the frame block.
+#[derive(Clone, Copy, Default)]
+pub(super) struct SmokeFrame {
+ pub smoke: [f32; 4],
+ pub min: [f32; 4],
+ pub lights: [[f32; 4]; 32],
+}
+
+pub struct Smoke {
+ pub atlas: wgpu::TextureView,
+ segments: wgpu::Buffer,
+ bricks: wgpu::Buffer,
+ pipeline: wgpu::RenderPipeline,
+ group: wgpu::BindGroup,
+ /// Segments in the grid this frame (0: nothing is drawn).
+ pub count: usize,
+ pub frame: SmokeFrame,
+ baked: Vec<Baked>,
+ order: Vec<(f32, usize)>,
+ bricks_cpu: Vec<u32>,
+ lights: Vec<(f32, [f32; 4], [f32; 4])>,
+}
+
+impl Smoke {
+ pub fn new(device: &wgpu::Device, frame: &wgpu::Buffer) -> Self {
+ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("HD smoke grid"),
+ entries: &entries(&SMOKE_GRID_BINDINGS),
+ });
+ let storage = |label, size: usize| {
+ device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some(label),
+ size: size as u64,
+ usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ })
+ };
+ let segments = storage(
+ "HD smoke segments",
+ MAX_SEGMENTS * std::mem::size_of::<Baked>(),
+ );
+ let bricks = storage("HD smoke bricks", (BRICKS * 2 + MAX_INDICES) * 4);
+ let module = module(device, "HD smoke grid", SMOKE_GRID);
+ let pipeline = fullscreen(
+ device,
+ "HD smoke grid",
+ &layout,
+ &module,
+ "fs_smoke_grid",
+ ATLAS_FORMAT,
+ None,
+ );
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("HD smoke grid"),
+ layout: &layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: frame.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: segments.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: bricks.as_entire_binding(),
+ },
+ ],
+ });
+ Self {
+ atlas: target(device, "HD smoke grid", [ATLAS, ATLAS], ATLAS_FORMAT).view,
+ segments,
+ bricks,
+ pipeline,
+ group,
+ count: 0,
+ frame: SmokeFrame::default(),
+ baked: Vec::new(),
+ order: Vec::new(),
+ bricks_cpu: Vec::new(),
+ lights: Vec::new(),
+ }
+ }
+
+ /// Bakes this frame's segments, bins them into the grid's bricks and uploads them; picks the
+ /// lights that glow on the smoke. `clock` is the mod's clock in seconds.
+ pub fn prepare(
+ &mut self,
+ queue: &wgpu::Queue,
+ scene: &HdScene,
+ smoke: &[HdSmoke],
+ lights: &[HdLight],
+ clock: f32,
+ ) {
+ self.count = 0;
+ self.frame = SmokeFrame::default();
+ if std::env::var_os("HORIZON_HD_SMOKE_LOG").is_some() && !smoke.is_empty() {
+ log::info!("HD smoke: {} segments given", smoke.len());
+ }
+ if smoke.is_empty() {
+ return;
+ }
+ // The grid's corner on the world's lattice, relative to the camera.
+ let cam = [
+ scene.camera_world[0],
+ scene.camera_world[2],
+ scene.camera_world[1],
+ ];
+ let min = [
+ (cam[0] / CELL).floor() * CELL - (GRID_XZ / 2) as f32 * CELL - cam[0],
+ (cam[1] / CELL).floor() * CELL - CELLS_BELOW * CELL - cam[1],
+ (cam[2] / CELL).floor() * CELL - (GRID_XZ / 2) as f32 * CELL - cam[2],
+ ];
+ let max = [
+ min[0] + GRID_XZ as f32 * CELL,
+ min[1] + GRID_Y as f32 * CELL,
+ min[2] + GRID_XZ as f32 * CELL,
+ ];
+ // Bake, keep the ones in reach, nearest first.
+ self.baked.clear();
+ self.order.clear();
+ for s in smoke {
+ let Some(b) = bake(s) else { continue };
+ let reach = b.pa[3].max(b.extra[2]) * 1.3 + CELL;
+ let lo = [
+ b.pa[0].min(b.pb[0]) - reach,
+ b.pa[1].min(b.pb[1]) - reach,
+ b.pa[2].min(b.pb[2]) - reach,
+ ];
+ let hi = [
+ b.pa[0].max(b.pb[0]) + reach,
+ b.pa[1].max(b.pb[1]) + reach,
+ b.pa[2].max(b.pb[2]) + reach,
+ ];
+ if (0..3).any(|k| hi[k] < min[k] || lo[k] > max[k]) {
+ continue;
+ }
+ let mid = [
+ (b.pa[0] + b.pb[0]) * 0.5,
+ (b.pa[1] + b.pb[1]) * 0.5,
+ (b.pa[2] + b.pb[2]) * 0.5,
+ ];
+ // The nearest point of the tube counts: a long trail close by is kept whole.
+ let near =
+ mid[0] * mid[0] + mid[1] * mid[1] + mid[2] * mid[2] - reach * reach.min(100.0);
+ self.order.push((near, self.baked.len()));
+ self.baked.push(b);
+ }
+ if self.baked.is_empty() {
+ return;
+ }
+ self.order.sort_by(|x, y| x.0.total_cmp(&y.0));
+ self.order.truncate(MAX_SEGMENTS);
+ // Bricks: count per brick, then fill; the nearest segments first, so a full list drops the
+ // farthest.
+ let brick_of = |lo: f32, hi: f32, axis: usize, bricks: u32| -> (i32, i32) {
+ let a = ((lo - min[axis]) / (CELL * BRICK as f32)).floor() as i32;
+ let b = ((hi - min[axis]) / (CELL * BRICK as f32)).floor() as i32;
+ (a.max(0), b.min(bricks as i32 - 1))
+ };
+ let mut counts = vec![0u32; BRICKS];
+ let mut boxes = Vec::with_capacity(self.order.len());
+ let mut total = 0usize;
+ let mut kept: Vec<Baked> = Vec::with_capacity(self.order.len());
+ for &(_, i) in &self.order {
+ let b = self.baked[i];
+ let reach = b.pa[3].max(b.extra[2]) * 1.3 + CELL;
+ let (x0, x1) = brick_of(
+ b.pa[0].min(b.pb[0]) - reach,
+ b.pa[0].max(b.pb[0]) + reach,
+ 0,
+ BRICKS_XZ,
+ );
+ let (y0, y1) = brick_of(
+ b.pa[1].min(b.pb[1]) - reach,
+ b.pa[1].max(b.pb[1]) + reach,
+ 1,
+ BRICKS_Y,
+ );
+ let (z0, z1) = brick_of(
+ b.pa[2].min(b.pb[2]) - reach,
+ b.pa[2].max(b.pb[2]) + reach,
+ 2,
+ BRICKS_XZ,
+ );
+ if x0 > x1 || y0 > y1 || z0 > z1 {
+ continue;
+ }
+ let n = ((x1 - x0 + 1) * (y1 - y0 + 1) * (z1 - z0 + 1)) as usize;
+ if total + n > MAX_INDICES {
+ break;
+ }
+ total += n;
+ let slot = kept.len() as u32;
+ for z in z0..=z1 {
+ for y in y0..=y1 {
+ for x in x0..=x1 {
+ counts[brick_index(x as u32, y as u32, z as u32)] += 1;
+ }
+ }
+ }
+ boxes.push((x0, x1, y0, y1, z0, z1, slot));
+ kept.push(b);
+ }
+ if kept.is_empty() {
+ return;
+ }
+ let mut data = vec![0u32; BRICKS * 2 + total];
+ let mut start = 0u32;
+ for (b, &c) in counts.iter().enumerate() {
+ data[b * 2] = start;
+ data[b * 2 + 1] = 0;
+ start += c;
+ }
+ for &(x0, x1, y0, y1, z0, z1, slot) in &boxes {
+ for z in z0..=z1 {
+ for y in y0..=y1 {
+ for x in x0..=x1 {
+ let b = brick_index(x as u32, y as u32, z as u32);
+ let at = data[b * 2] + data[b * 2 + 1];
+ data[BRICKS * 2 + at as usize] = slot;
+ data[b * 2 + 1] += 1;
+ }
+ }
+ }
+ }
+ self.bricks_cpu = data;
+ queue.write_buffer(&self.segments, 0, bytemuck::cast_slice(&kept));
+ queue.write_buffer(&self.bricks, 0, bytemuck::cast_slice(&self.bricks_cpu));
+ self.count = kept.len();
+ if std::env::var_os("HORIZON_HD_SMOKE_LOG").is_some() {
+ let n = &self.baked[0];
+ log::info!(
+ "HD smoke: {} in, {} baked, {} in the grid; first pa {:?} pb {:?} extra {:?} min {:?}",
+ smoke.len(),
+ self.baked.len(),
+ self.count,
+ n.pa,
+ n.pb,
+ n.extra,
+ min
+ );
+ }
+ // The lights that glow on the smoke: the brightest near ones.
+ self.lights.clear();
+ for l in lights {
+ let d2 = l.pos[0] * l.pos[0] + l.pos[1] * l.pos[1] + l.pos[2] * l.pos[2];
+ let d = d2.sqrt();
+ if d > 300.0 || l.color[3] < 1.0 {
+ continue;
+ }
+ let rank = l.color[3] / (1.0 + d2 * 0.0004);
+ self.lights.push((rank, l.pos, l.color));
+ }
+ self.lights.sort_by(|a, b| b.0.total_cmp(&a.0));
+ self.lights.truncate(MAX_LIGHTS);
+ let mut out = SmokeFrame {
+ smoke: [1.0, CELL, clock.rem_euclid(86_400.0), 0.0],
+ min: [min[0], min[1], min[2], self.lights.len() as f32],
+ lights: [[0.0; 4]; 32],
+ };
+ for (i, (_, pos, color)) in self.lights.iter().enumerate() {
+ out.lights[i * 2] = *pos;
+ out.lights[i * 2 + 1] = [
+ color[0] * color[3],
+ color[1] * color[3],
+ color[2] * color[3],
+ 0.0,
+ ];
+ }
+ self.frame = out;
+ }
+
+ /// Fills the grid from the segments (when there are any).
+ pub fn draw(
+ &self,
+ encoder: &mut wgpu::CommandEncoder,
+ stamp: Option<wgpu::RenderPassTimestampWrites<'_>>,
+ ) {
+ if self.count == 0 {
+ return;
+ }
+ stamped_pass(
+ encoder,
+ "HD smoke grid",
+ &[&self.atlas],
+ &self.pipeline,
+ &self.group,
+ stamp,
+ );
+ }
+}
+
+fn brick_index(x: u32, y: u32, z: u32) -> usize {
+ (x + BRICKS_XZ * (z + BRICKS_XZ * y)) as usize
+}
+
+/// A segment at its age: drifted with the wind, risen, grown, thinned, cooled; None once spent.
+fn bake(s: &HdSmoke) -> Option<Baked> {
+ let t = s.a[3];
+ let life = s.b[3].max(0.05);
+ if !(t >= 0.0) || t >= life {
+ return None;
+ }
+ let u = t / life;
+ let rise = s.motion[0] * 8.0 * (1.0 - (-t / 8.0).exp());
+ let drift = [WIND[0] * t, rise, WIND[1] * t];
+ let r0 = s.shape[0].max(0.05);
+ let radius = r0 + (s.shape[1] - r0) * u.powf(0.6);
+ let radius = radius.max(0.2);
+ // Spreading thins it (the core's mass over a wider tube), and it fades out towards its end.
+ let thin = (r0 / radius).powf(0.5);
+ let density = s.shape[2] * (1.0 - u).powi(2) * thin * (t / 0.25).min(1.0).max(0.2);
+ let heat = s.shape[3] * (-t / 1.5).exp();
+ let clear_amount = (1.0 - u).powf(0.8);
+ let clear = s.motion[1] * (1.0 + 0.4 * u);
+ if density < 0.003 && !(clear > 0.0 && clear_amount > 0.02) {
+ return None;
+ }
+ Some(Baked {
+ pa: [
+ s.a[0] + drift[0],
+ s.a[1] + drift[1],
+ s.a[2] + drift[2],
+ radius,
+ ],
+ pb: [
+ s.b[0] + drift[0],
+ s.b[1] + drift[1],
+ s.b[2] + drift[2],
+ density,
+ ],
+ extra: [s.motion[2], heat, clear, clear_amount],
+ seed: [s.motion[3], 0.0, 0.0, 0.0],
+ })
+}
+
+#[cfg(test)]
+pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingType])> {
+ vec![("smoke grid", SMOKE_GRID.to_owned(), &SMOKE_GRID_BINDINGS)]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn puff(age: f32, life: f32) -> HdSmoke {
+ HdSmoke {
+ a: [1.0, 2.0, 3.0, age],
+ b: [1.0, 2.0, 3.0, life],
+ shape: [2.0, 10.0, 1.0, 0.5],
+ motion: [1.0, 0.0, 0.5, 0.0],
+ }
+ }
+
+ #[test]
+ fn a_segment_grows_thins_and_ends() {
+ let young = bake(&puff(1.0, 40.0)).unwrap();
+ let old = bake(&puff(30.0, 40.0)).unwrap();
+ assert!(old.pa[3] > young.pa[3], "grows");
+ assert!(old.pb[3] < young.pb[3], "thins");
+ assert!(
+ old.pa[0] > young.pa[0] && old.pa[1] > young.pa[1],
+ "drifts and rises"
+ );
+ assert!(bake(&puff(40.0, 40.0)).is_none(), "gone at its life");
+ assert!(bake(&puff(-1.0, 40.0)).is_none(), "not born yet");
+ }
+
+ #[test]
+ fn the_same_age_bakes_the_same() {
+ // Showing a frame again (a pause, a seek back to it) gives the same smoke.
+ let a = bake(&puff(12.5, 40.0)).unwrap();
+ let b = bake(&puff(12.5, 40.0)).unwrap();
+ assert_eq!(bytemuck::bytes_of(&a), bytemuck::bytes_of(&b));
+ }
+
+ #[test]
+ fn a_tunnel_without_smoke_is_kept_until_it_fills() {
+ let mut s = puff(10.0, 40.0);
+ s.shape[2] = 0.0;
+ s.motion[1] = 3.0;
+ let b = bake(&s).unwrap();
+ assert!(b.extra[3] > 0.5 && b.extra[2] >= 3.0);
+ }
+}
diff --git a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
new file mode 100644
index 000000000..01b6e082a
--- /dev/null
+++ b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
@@ -0,0 +1,61 @@
+// The smoke grid: one texel of a 1024 x 1024 atlas per cell of a 128 x 64 x 128 lattice (64 slices
+// of 128 x 128, 8 to a row). Each cell sums the capsules of its 8-cell brick.
+// r density, g soot x density, b heat x density, a clear amount (the largest of the tunnels).
+// Segments: 4 vec4 each (see smoke.rs `Baked`); bricks: (start, count) per brick, then the indices.
+
+@group(0) @binding(0) var<uniform> frame: HdFrame;
+@group(0) @binding(1) var<storage, read> segs: array<vec4<f32>>;
+@group(0) @binding(2) var<storage, read> bricks: array<u32>;
+
+const BRICKS_XZ: u32 = 16u;
+const BRICK_LISTS: u32 = 4096u;
+
+@fragment
+fn fs_smoke_grid(in: FullOut) -> @location(0) vec4<f32> {
+ let px = vec2<u32>(in.position.xy);
+ let tile = px / 128u;
+ let c = vec3<u32>(px.x % 128u, tile.y * 8u + tile.x, px.y % 128u);
+ let p = frame.smoke_min.xyz + (vec3<f32>(c) + vec3<f32>(0.5)) * frame.smoke.y;
+ let b = (c.x >> 3u) + BRICKS_XZ * ((c.z >> 3u) + BRICKS_XZ * (c.y >> 3u));
+ let start = bricks[2u * b];
+ let count = bricks[2u * b + 1u];
+ var dens = 0.0;
+ var soot = 0.0;
+ var heat = 0.0;
+ var clear = 0.0;
+ for (var k = 0u; k < count; k++) {
+ let i = bricks[BRICK_LISTS + start + k] * 4u;
+ let pa = segs[i];
+ let pb = segs[i + 1u];
+ let ex = segs[i + 2u];
+ let ab = pb.xyz - pa.xyz;
+ let len2 = dot(ab, ab);
+ let along = dot(p - pa.xyz, ab) / max(len2, 1e-4);
+ let t = clamp(along, 0.0, 1.0);
+ let d = length(p - (pa.xyz + ab * t));
+ if ex.z > 0.0 && d < ex.z {
+ let f = 1.0 - d / ex.z;
+ clear = max(clear, f * f * (3.0 - 2.0 * f) * ex.w);
+ }
+ let seed = segs[i + 3u].x;
+ let len = sqrt(len2);
+ // Bulges along the tube, so it is not a pipe.
+ let r = pa.w * (1.0 + 0.22 * sin(along * len * 0.6 + seed * 40.0));
+ if d < r && pb.w > 0.0 {
+ let q = d / r;
+ var w = (1.0 - q * q);
+ w *= w;
+ // The ends of a tube fade over half a radius, so joined tubes do not double up.
+ let h = 0.5 * pa.w;
+ if len2 > 0.01 {
+ let s = along * len;
+ w *= smoothstep(-h, h, s) * smoothstep(-h, h, len - s);
+ }
+ let m = w * pb.w;
+ dens += m;
+ soot += m * ex.x;
+ heat += m * ex.y;
+ }
+ }
+ return vec4<f32>(dens, soot, heat, clear);
+}
diff --git a/crates/horizon-client/src/gfx/hd/smoke_sample.wgsl b/crates/horizon-client/src/gfx/hd/smoke_sample.wgsl
new file mode 100644
index 000000000..024a1ac8e
--- /dev/null
+++ b/crates/horizon-client/src/gfx/hd/smoke_sample.wgsl
@@ -0,0 +1,29 @@
+
+// The smoke grid (smoke_grid.wgsl) read trilinearly at a camera-relative point: r density, g soot x
+// density, b heat x density, a clear amount. Zero outside it, fading over its last cells.
+fn smoke_slice(tex: texture_2d<f32>, s: sampler, x: f32, z: f32, y: f32) -> vec4<f32> {
+ let tile = vec2<f32>(y % 8.0, floor(y / 8.0));
+ return textureSampleLevel(tex, s, (tile * 128.0 + vec2<f32>(x + 0.5, z + 0.5)) / 1024.0, 0.0);
+}
+
+fn smoke_at(tex: texture_2d<f32>, s: sampler, p: vec3<f32>) -> vec4<f32> {
+ let g = (p - frame.smoke_min.xyz) / frame.smoke.y - vec3<f32>(0.5);
+ if g.x < 0.0 || g.z < 0.0 || g.y < 0.0 || g.x > 127.0 || g.z > 127.0 || g.y > 63.0 {
+ return vec4<f32>(0.0);
+ }
+ let y0 = floor(g.y);
+ let y1 = min(y0 + 1.0, 63.0);
+ let a = smoke_slice(tex, s, g.x, g.z, y0);
+ let b = smoke_slice(tex, s, g.x, g.z, y1);
+ let edge = min(min(g.x, 127.0 - g.x), min(g.z, 127.0 - g.z));
+ return mix(a, b, g.y - y0) * smoothstep(0.0, 6.0, edge);
+}
+
+// Only the density, from the nearer slice: a cheap look to skip empty space.
+fn smoke_probe(tex: texture_2d<f32>, s: sampler, p: vec3<f32>) -> f32 {
+ let g = (p - frame.smoke_min.xyz) / frame.smoke.y - vec3<f32>(0.5);
+ if g.x < 0.0 || g.z < 0.0 || g.y < 0.0 || g.x > 127.0 || g.z > 127.0 || g.y > 63.0 {
+ return 0.0;
+ }
+ return smoke_slice(tex, s, g.x, g.z, floor(g.y + 0.5)).x;
+}
diff --git a/crates/horizon-client/src/gfx/hd/volumes.rs b/crates/horizon-client/src/gfx/hd/volumes.rs
index a49c58f86..a338d756c 100644
--- a/crates/horizon-client/src/gfx/hd/volumes.rs
+++ b/crates/horizon-client/src/gfx/hd/volumes.rs
@@ -67,11 +67,16 @@ const CLOUD_SHADOW: &str = include_str!("cloud_shadow.wgsl");
/// texture. Targets: the in-scattered light (rgb) with the transmittance (a), and the distance the
/// ray was marched (for the depth-aware upsample in `fs_light`).
const FOG: &str = include_str!("fog.wgsl");
+use super::smoke::SMOKE_SAMPLE;
fn cloud_body(extra: &str) -> String {
format!("{CLOUD_COMMON}{extra}")
}
+fn fog_body() -> String {
+ format!("{FOG}{SMOKE_SAMPLE}")
+}
+
/// The cloud and fog targets, noise, pipelines and bind groups.
pub struct HdVolumes {
noise3: wgpu::TextureView,
@@ -107,14 +112,14 @@ const D3: wgpu::BindingType = wgpu::BindingType::Texture {
/// `fs_cloud`'s group 0: frame, 3D noise, 2D noise, clamped and repeating samplers, the sky
/// texture, the previous cloud frame.
-pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 7] =
- [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT];
+pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 8] =
+ [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT];
/// `fs_cloud_shadow`'s group 0: as the clouds' without the history.
pub(super) const CLOUD_SHADOW_BINDINGS: [wgpu::BindingType; 6] =
[UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT];
/// `fs_fog`'s group 0: frame, the world's depth, the shadow cascades and their sampler, 3D noise,
/// repeating and clamped samplers, the sky texture.
-pub(super) const FOG_BINDINGS: [wgpu::BindingType; 8] = [
+pub(super) const FOG_BINDINGS: [wgpu::BindingType; 9] = [
UNIFORM,
DEPTH,
SHADOW_ARRAY,
@@ -123,6 +128,7 @@ pub(super) const FOG_BINDINGS: [wgpu::BindingType; 8] = [
SAMPLER,
SAMPLER,
FLOAT,
+ FLOAT,
];
impl HdVolumes {
@@ -204,14 +210,14 @@ impl HdVolumes {
label: Some("HD fog"),
entries: &entries(&FOG_BINDINGS),
});
- let cloud_src = format!("{}{CLOUD}", cloud_body(""));
+ let cloud_src = format!("{}{CLOUD}{SMOKE_SAMPLE}", cloud_body(""));
let shadow_src = format!(
"const SHADOW_R: f32 = {SHADOW_RADIUS:?};\n{}{CLOUD_SHADOW}",
cloud_body("")
);
let cloud_module = module(device, "HD clouds", &cloud_src);
let shadow_module = module(device, "HD cloud shadows", &shadow_src);
- let fog_module = module(device, "HD fog", FOG);
+ let fog_module = module(device, "HD fog", &fog_body());
let cloud_pipeline = fullscreen(
device,
"HD clouds",
@@ -365,6 +371,7 @@ impl HdVolumes {
device: &wgpu::Device,
frame: &wgpu::Buffer,
sky_lut: &wgpu::TextureView,
+ atlas: &wgpu::TextureView,
) {
if self.shadow_group.is_none() {
let layout = self.shadow_layout.clone();
@@ -411,6 +418,7 @@ impl HdVolumes {
wgpu::BindingResource::Sampler(&repeat),
wgpu::BindingResource::TextureView(sky_lut),
wgpu::BindingResource::TextureView(&hist[read]),
+ wgpu::BindingResource::TextureView(atlas),
],
)
});
@@ -466,6 +474,7 @@ impl HdVolumes {
depth: &wgpu::TextureView,
shadow_array: &wgpu::TextureView,
sky_lut: &wgpu::TextureView,
+ atlas: &wgpu::TextureView,
stamp: Option<wgpu::RenderPassTimestampWrites<'_>>,
) {
if self.fog_group.as_ref().is_none_or(|(v, _)| v != depth) {
@@ -489,6 +498,7 @@ impl HdVolumes {
wgpu::BindingResource::Sampler(&repeat),
wgpu::BindingResource::Sampler(&clamp),
wgpu::BindingResource::TextureView(sky_lut),
+ wgpu::BindingResource::TextureView(atlas),
],
);
self.fog_group = Some((depth.clone(), group));
@@ -516,7 +526,7 @@ impl Hd {
) {
if self.clouds_active {
self.volumes
- .prepare_clouds(device, &self.frame, &self.sky.lut);
+ .prepare_clouds(device, &self.frame, &self.sky.lut, &self.smoke.atlas);
self.volumes
.draw_cloud_shadow(encoder, stamp(&mut self.timing, "hd cloud shadows"));
self.volumes
@@ -525,6 +535,8 @@ impl Hd {
self.volumes.reset_history();
}
if self.fog_active {
+ self.smoke
+ .draw(encoder, stamp(&mut self.timing, "hd smoke grid"));
self.volumes.draw_fog(
device,
encoder,
@@ -532,6 +544,7 @@ impl Hd {
depth,
&self.shadows.array,
&self.sky.lut,
+ &self.smoke.atlas,
stamp(&mut self.timing, "hd fog"),
);
}
@@ -543,7 +556,7 @@ pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingTyp
vec![
(
"clouds",
- format!("{}{CLOUD}", cloud_body("")),
+ format!("{}{CLOUD}{SMOKE_SAMPLE}", cloud_body("")),
&CLOUD_BINDINGS,
),
(
@@ -554,6 +567,6 @@ pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingTyp
),
&CLOUD_SHADOW_BINDINGS,
),
- ("fog", FOG.to_owned(), &FOG_BINDINGS),
+ ("fog", fog_body(), &FOG_BINDINGS),
]
}
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
index cb098a64a..de96226e9 100644
--- a/crates/horizon-client/src/terrain.wgsl
+++ b/crates/horizon-client/src/terrain.wgsl
@@ -26,6 +26,10 @@ struct Globals {
hd_flags: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
+ // AirDef fork, HD wind. x: strength (0 off), y: seconds (wraps at 600), zw: camera x, z mod 256.
+ hd_wind: vec4<f32>,
+ // AirDef fork: the sprite layers' material classes, 4 bits each (gfx/hd/materials.rs).
+ hd_materials: array<vec4<u32>, 64>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
@@ -122,9 +126,67 @@ fn terrain_uv(packed: vec2<u32>, step: f32) -> vec2<f32> {
let origin = slot_origins[block_slots[vertex >> 8u]];
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz);
}
+// AirDef fork, HD materials: the mat_class of a sprite layer (gfx/hd/materials.rs, same numbers).
+const MAT_LEAVES: u32 = 1u;
+const MAT_PLANT_LOWER: u32 = 3u;
+const MAT_PLANT_UPPER: u32 = 4u;
+const MAT_METAL: u32 = 5u;
+const MAT_SMOOTH: u32 = 6u;
+const MAT_GLOSSY: u32 = 7u;
+const MAT_WOOD: u32 = 8u;
+fn material_class(layer: u32) -> u32 {
+ let l = layer & 0x7fffu;
+ if l >= 2048u { return 0u; }
+ let word = globals.hd_materials[l >> 5u][(l >> 3u) & 3u];
+ return (word >> ((l & 7u) * 4u)) & 15u;
+}
+// Roughness and metalness of a mat_class.
+fn material_params(mat_class: u32) -> vec2<f32> {
+ switch mat_class {
+ case 1u, 2u, 3u, 4u: { return vec2(0.92, 0.0); }
+ case 5u: { return vec2(0.3, 1.0); }
+ case 6u: { return vec2(0.42, 0.0); }
+ case 7u: { return vec2(0.22, 0.0); }
+ case 8u: { return vec2(0.7, 0.0); }
+ default: { return vec2(0.88, 0.0); }
+ }
+}
+// AirDef fork, HD wind: waves of whole cycles over 256 blocks and 600 seconds, so they hold still
+// in the world and do not jump when the camera's coordinates wrap. Returns the sway along the
+// wind (x, z) and a flutter (y), each about -1..1.
+fn wind_sway(w: vec2<f32>) -> vec3<f32> {
+ let tau = 6.2831853;
+ let t = globals.hd_wind.y;
+ let a1 = tau * (5.0 * w.x + 3.0 * w.y) / 256.0 + tau * 120.0 / 600.0 * t;
+ let a2 = tau * (-2.0 * w.x + 7.0 * w.y) / 256.0 + tau * 200.0 / 600.0 * t;
+ let a3 = tau * (23.0 * w.x - 17.0 * w.y) / 256.0 + tau * 350.0 / 600.0 * t;
+ let push = 0.45 + 0.35 * sin(a1) + 0.2 * sin(a2);
+ let dir = vec2(0.8, 0.6);
+ let side = 0.25 * sin(a2 + 1.3);
+ let xz = dir * push + vec2(-dir.y, dir.x) * side;
+ return vec3(xz.x, sin(a3), xz.y);
+}
+// How far the vertex of a leaf or a plant moves (camera-relative blocks). Everything in it is a
+// continuous function of the world position, so neighbouring blocks stay joined; plants are
+// pinned at the bottom edge of their sprite.
+fn wind_offset(pos: vec3<f32>, uv: vec2<f32>, layer: u32) -> vec3<f32> {
+ if globals.hd_wind.x <= 0.0 { return vec3(0.0); }
+ let mat_class = material_class(layer);
+ if mat_class == 0u || mat_class > MAT_PLANT_UPPER { return vec3(0.0); }
+ let s = wind_sway(pos.xz + globals.hd_wind.zw);
+ let k = globals.hd_wind.x;
+ if mat_class == MAT_LEAVES {
+ return vec3(s.x, s.y * 0.5, s.z) * (0.025 * k);
+ }
+ var h = clamp(1.0 - uv.y, 0.0, 1.0);
+ if mat_class == MAT_PLANT_LOWER { h = h * 0.5; }
+ if mat_class == MAT_PLANT_UPPER { h = 0.5 + h * 0.5; }
+ let a = 0.14 * k * h * h;
+ return vec3(s.x * a, -abs(s.x * a + s.z * a) * 0.25, s.z * a);
+}
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> TerrainOut {
- let pos = position + origin;
+ let pos = position + origin + wind_offset(position + origin, uv, layer);
var out: TerrainOut;
out.clip = globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
@@ -181,7 +243,8 @@ fn face_normal(pos: vec3<f32>) -> vec3<f32> {
var out: GBufferOut;
out.albedo = vec4(color.rgb, f32(in.emission) / 15.0);
out.normal = vec4(face_normal(in.pos), 1.0);
- out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), 0.0, 1.0);
+ // Roughness and metalness for the sun's specular and the wet look.
+ out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), material_params(material_class(in.layer)));
return out;
}
// AirDef fork, HD water: the water quads once more, after the G-buffer, into small targets of
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index 5fa5e8a27..fba8f162d 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -699,6 +699,47 @@ interface render {
/// as `clock`). Only what the player sees changes: the world's own clock,
/// its saves and its rules do not. None shows the world's own time.
set-time-override: func(day-time: option<u64>);
+ /// A piece of smoke or exhaust trail (AirDef fork): a tube from `a` to `b`
+ /// (equal ends: a puff) that the HD renderer ray-marches as a volume, lit by
+ /// the sun, the sky and the mod's lights, and that thins the fog and clouds
+ /// it passes through. The game derives everything from the age
+ /// (`clock - born`): the radius grows from `radius-start` to `radius-end`,
+ /// the density fades to nothing at `life`, the smoke drifts with the wind
+ /// and rises by `rise`, so a segment is sent once per change of the set, not
+ /// per frame, and a replay that seeks sends the segments of that moment.
+ record smoke-segment {
+ /// Seeds the smoke's noise.
+ id: u32,
+ ax: f64,
+ ay: f64,
+ az: f64,
+ bx: f64,
+ by: f64,
+ bz: f64,
+ /// When it was made, in the `clock` of `set-smoke` (ticks).
+ born: f64,
+ /// How long it lasts, in the same ticks (1 to 72000).
+ life: f32,
+ /// Radius of the tube in blocks at birth and at the end of its life.
+ radius-start: f32,
+ radius-end: f32,
+ /// How thick it is at the core at birth: extinction per block (0 to 8).
+ density: f32,
+ /// How hot it glows at birth, 0 soot to 1 white hot; it cools in seconds.
+ heat: f32,
+ /// How dark it is, 0 white vapour to 1 black soot.
+ soot: f32,
+ /// Blocks per second it floats up (negative sinks).
+ rise: f32,
+ /// Radius in blocks of the clear tunnel it leaves in fog and cloud, filling
+ /// back in over `life` (0 none).
+ clear: f32,
+ }
+ /// Replaces the mod's smoke: up to 4096 segments, `clock` being now in ticks
+ /// (the game shows the age between the last two calls). Returns whether the
+ /// HD renderer draws them; when it does not (the effects preset, HD smoke
+ /// off), the mod draws its own smoke.
+ set-smoke: func(clock: f64, segments: list<smoke-segment>) -> bool;
}
/// 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 bc1ade067..f438eadbd 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 = 13;
+pub const LEVEL: u32 = 14;
/// 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); 67] = [
+pub const FUNCTIONS: [(&str, u32); 68] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -84,6 +84,8 @@ pub const FUNCTIONS: [(&str, u32); 67] = [
("render.world-changes", 12),
// AirDef fork: a replay shows its recorded time of day.
("render.set-time-override", 13),
+ // AirDef fork: volumetric smoke and exhaust trails.
+ ("render.set-smoke", 14),
];
/// 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 fb6180f1c..495ef6cae 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -83,8 +83,8 @@ pub use render_bindings::horizon::mods::render::{
FillShape, FrameRate, ItemShape, Lighting as ModLighting, Minimap as ModMinimap, ModelInstance,
ModelInstanceId, ParticleSpawn, Point as RenderPoint, PointLight, Policy as WorldPolicy,
QuadShape, Ray as WorldRay, RayAnswer as WorldRayAnswer, Rect as RenderRect,
- Section as WorldSection, SectionFill, Shape, SpriteShape, TextShape, Verdict as WorldVerdict,
- ViewShape, Zoom as ModZoom,
+ Section as WorldSection, SectionFill, Shape, SmokeSegment, SpriteShape, TextShape,
+ Verdict as WorldVerdict, ViewShape, Zoom as ModZoom,
};
use render_bindings::{RenderHost, horizon::mods::render};
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
@@ -304,6 +304,8 @@ pub struct ModelTable {
key: usize,
generation: u32,
resolve: Option<ModelResolver>,
+ /// The renderer draws the mods' smoke (`render.set-smoke` says so to the mod).
+ smoke_active: bool,
}
type ModelSlot = Arc<Mutex<ModelTable>>;
@@ -509,6 +511,9 @@ struct State {
lighting: Option<ModLighting>,
/// `render.set-time-override` (AirDef fork).
time_override: Option<u64>,
+ /// `render.set-smoke` (AirDef fork): the segments, the clock of this call and of the one before.
+ smoke: Vec<SmokeSegment>,
+ smoke_clock: (f64, f64),
input: InputState,
browser: BrowserState,
/// Capability `storage`'s folder; made on first `set`.
@@ -752,6 +757,8 @@ impl Mods {
lights: Vec::new(),
lighting: None,
time_override: None,
+ smoke: Vec::new(),
+ smoke_clock: (0.0, 0.0),
input: InputState::default(),
browser: BrowserState::default(),
data_dir,
@@ -1337,6 +1344,25 @@ impl Mods {
.iter()
.find_map(|e| e.run.as_ref()?.store.data().lighting)
}
+ /// Every running mod's smoke segments (AirDef fork).
+ pub fn smoke(&self) -> impl Iterator<Item = &SmokeSegment> {
+ self.entries
+ .iter()
+ .flat_map(|e| e.run.iter().flat_map(|r| r.store.data().smoke.iter()))
+ }
+ /// The clocks (ticks) of the first mod that set smoke: of its last call and of the one before.
+ pub fn smoke_clock(&self) -> Option<(f64, f64)> {
+ self.entries
+ .iter()
+ .find_map(|e| e.run.as_ref().filter(|r| !r.store.data().smoke.is_empty()))
+ .map(|r| r.store.data().smoke_clock)
+ }
+ /// Tells the mods whether the renderer draws their smoke (the answer of `render.set-smoke`).
+ pub fn set_smoke_active(&mut self, on: bool) {
+ if let Ok(mut t) = self.model_table.lock() {
+ t.smoke_active = on;
+ }
+ }
/// The time of day the first running mod asked to show, if any.
pub fn time_override(&self) -> Option<u64> {
self.entries
@@ -2246,6 +2272,37 @@ impl render::Host for State {
};
self.lighting = Some(lighting);
}
+ fn set_smoke(&mut self, clock: f64, segments: Vec<SmokeSegment>) -> bool {
+ let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
+ let num = |v: f32, lo: f32, hi: f32, default: f32| {
+ if v.is_finite() {
+ v.clamp(lo, hi)
+ } else {
+ default
+ }
+ };
+ let clock = if clock.is_finite() { clock } else { 0.0 };
+ self.smoke_clock = (self.smoke_clock.1, clock);
+ self.smoke = segments
+ .into_iter()
+ .filter(|s| {
+ [s.ax, s.ay, s.az, s.bx, s.by, s.bz].into_iter().all(finite) && s.born.is_finite()
+ })
+ .take(4096)
+ .map(|mut s| {
+ s.life = num(s.life, 1.0, 72_000.0, 1.0);
+ s.radius_start = num(s.radius_start, 0.0, 64.0, 1.0);
+ s.radius_end = num(s.radius_end, 0.0, 64.0, 1.0);
+ s.density = num(s.density, 0.0, 8.0, 0.0);
+ s.heat = num(s.heat, 0.0, 1.0, 0.0);
+ s.soot = num(s.soot, 0.0, 1.0, 0.5);
+ s.rise = num(s.rise, -64.0, 64.0, 0.0);
+ s.clear = num(s.clear, 0.0, 64.0, 0.0);
+ s
+ })
+ .collect();
+ self.model_table.lock().is_ok_and(|t| t.smoke_active)
+ }
fn set_time_override(&mut self, day_time: Option<u64>) {
self.time_override = day_time.map(|t| t.min(1 << 40));
}
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
index b58f863c2..fefc4903f 100644
--- a/docs/api/mods-reference.md
+++ b/docs/api/mods-reference.md
@@ -615,6 +615,29 @@ The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in h
| `exposure` | `f32` | 1 as is; more brightens. |
| `bloom` | `f32` | How much bright things glow (0 none, 1 strong). |
+### record `smoke-segment`
+
+A piece of smoke or exhaust trail (AirDef fork): a tube from `a` to `b` (equal ends: a puff) that the HD renderer ray-marches as a volume, lit by the sun, the sky and the mod's lights, and that thins the fog and clouds it passes through. The game derives everything from the age (`clock - born`): the radius grows from `radius-start` to `radius-end`, the density fades to nothing at `life`, the smoke drifts with the wind and rises by `rise`, so a segment is sent once per change of the set, not per frame, and a replay that seeks sends the segments of that moment.
+
+| Field | Type | |
+| --- | --- | --- |
+| `id` | `u32` | Seeds the smoke's noise. |
+| `ax` | `f64` | |
+| `ay` | `f64` | |
+| `az` | `f64` | |
+| `bx` | `f64` | |
+| `by` | `f64` | |
+| `bz` | `f64` | |
+| `born` | `f64` | When it was made, in the `clock` of `set-smoke` (ticks). |
+| `life` | `f32` | How long it lasts, in the same ticks (1 to 72000). |
+| `radius-start` | `f32` | Radius of the tube in blocks at birth and at the end of its life. |
+| `radius-end` | `f32` | |
+| `density` | `f32` | How thick it is at the core at birth: extinction per block (0 to 8). |
+| `heat` | `f32` | How hot it glows at birth, 0 soot to 1 white hot; it cools in seconds. |
+| `soot` | `f32` | How dark it is, 0 white vapour to 1 black soot. |
+| `rise` | `f32` | Blocks per second it floats up (negative sinks). |
+| `clear` | `f32` | Radius in blocks of the clear tunnel it leaves in fog and cloud, filling back in over `life` (0 none). |
+
| Function | Signature | What it does |
| --- | --- | --- |
| `set-hud` | `func(shapes: list<shape>)` | Replaces the mod's HUD shapes: up to 1024, drawn every frame in order over the HUD (after the hotbar, under chat and screens), hidden with the HUD (F1). An empty list removes them. |
@@ -647,6 +670,7 @@ The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in h
| `set-lights` | `func(lights: list<point-light>)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. |
| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
| `set-time-override` | `func(day-time: option<u64>)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. |
+| `set-smoke` | `func(clock: f64, segments: list<smoke-segment>) -> bool` | Replaces the mod's smoke: up to 4096 segments, `clock` being now in ticks (the game shows the age between the last two calls). Returns whether the HD renderer draws them; when it does not (the effects preset, HD smoke off), the mod draws its own smoke. |
## `events`
--
2.54.0 (Apple Git-157)