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>
This commit is contained in:
2026-10-30 03:17:11 +03:00
committed by airdef
parent 74d1e61813
commit b1aaf32851
20 changed files with 1417 additions and 24 deletions
+11 -1
View File
@@ -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.
+7
View File
@@ -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),
+70 -4
View File
@@ -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 };
}
}
}
+40 -2
View File
@@ -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);
@@ -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);
+89 -2
View File
@@ -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;
}
+11
View File
@@ -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).
+63 -1
View File
@@ -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
@@ -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);
}
}
+96
View File
@@ -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() {
@@ -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>,
+477
View File
@@ -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);
}
}
@@ -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);
}
@@ -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;
}
+21 -8
View File
@@ -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),
]
}
+65 -2
View File
@@ -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
+41
View File
@@ -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.
+4 -2
View File
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
pub const LEVEL: u32 = 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
+59 -2
View File
@@ -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));
}
+24
View File
@@ -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`