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>
2076 lines
85 KiB
Diff
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)
|
|
|