diff --git a/airdef-mod/shaders/effects.wgsl b/airdef-mod/shaders/effects.wgsl index 2eea9e2..e62a809 100644 --- a/airdef-mod/shaders/effects.wgsl +++ b/airdef-mod/shaders/effects.wgsl @@ -8,6 +8,7 @@ // [0] now (mod ticks), blasts, lights, - // [1 + 2i], [2 + 2i] blast i: x, y, z, size | start tick, life ticks, kind, - // [97 + 2j], [98 + 2j] light j: x, y, z, radius | r, g, b, intensity +// [255].x hold: 0 live; a replay's share of the frame's partial tick it does not advance with const BLAST_BASE: i32 = 1; const LIGHT_BASE: i32 = 97; @@ -80,7 +81,7 @@ fn screen_radius_clip(c0: vec4, radius: f32) -> f32 { } fn now() -> f32 { - return frame.params[0].x + frame.time.y; + return frame.params[0].x + frame.time.y * (1.0 - frame.params[255].x); } // Fire's colour from its heat (0 cold soot .. 1 white-hot). @@ -231,12 +232,15 @@ fn post(uv: vec2, pixel: vec2) -> vec4 { } color = color * trans + lit; - // 3. Flashes: a bright bloom at the blast and the whole view lit up for a moment. + // 3. Flashes (#51): a huge bright bloom at the blast, a wide halo that lifts the sky and the + // clouds, a white shock ring racing out, and the view lit up for a moment. The sum is + // squashed, so a crowded sky never whites the screen out for long. + var flare = vec3(0.0); for (var i = 0; i < blasts; i++) { let a = frame.params[BLAST_BASE + 2 * i]; let b = frame.params[BLAST_BASE + 2 * i + 1]; let age = t_now - b.x; - if age < 0.0 || age > 10.0 { continue; } + if age < 0.0 || age > 24.0 { continue; } let c0 = clip_of(a.xyz); if c0.w <= 0.5 { continue; } let p = vec3(clip_uv(c0), c0.w); @@ -244,15 +248,37 @@ fn post(uv: vec2, pixel: vec2) -> vec4 { // than the distance along the ray towards the screen edge. let dist = length(a.xyz - frame.camera.xyz); let visible = select(0.25, 1.0, scene_distance(p.xy) > dist - a.w); - let f = exp(-age * 0.7) * min(a.w / 4.0, 2.5) * visible; - let r = screen_radius_clip(c0, a.w * 2.5) + 20.0; - let dd = length(px - p.xy * frame.resolution.xy) / r; - // The bloom's tail is below 5e-4 (under 8 bits) past nine radii. - if dd < 9.0 { - color += vec3(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 2.5 + exp(-dd * 0.8) * 0.25); + let mass = min(a.w / 4.0, 2.5); + // How close it is, in its own sizes: a burst overhead is felt, a far one only seen. + let near = clamp(1.0 - dist / (a.w * 40.0), 0.0, 1.0); + let centre = p.xy * frame.resolution.xy; + if age <= 10.0 { + let f = exp(-age * 0.7) * mass * visible; + let r = screen_radius_clip(c0, a.w * 2.5) + 20.0; + let dd = length(px - centre) / r; + // The bloom's tail is below 5e-4 (under 8 bits) past nine radii. + if dd < 9.0 { + flare += vec3(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 3.2 + exp(-dd * 0.8) * 0.3); + } + // The halo: a third of the screen wide for a close blast, lifting the sky and clouds most. + let wide = (0.12 + 0.3 * near) * frame.resolution.y; + let dw = length(px - centre) / wide; + if dw < 4.0 { + let sky = select(1.0, 1.7, wall > 1.0e5); + flare += vec3(1.0, 0.82, 0.58) * f * near * sky * exp(-dw * dw) * 0.55; + } + flare += vec3(1.0, 0.8, 0.55) * f * (0.12 + 0.2 * near) / (1.0 + p.z * 0.01); + } + // The shock ring: the same radius as the wave that bends the picture. + let ring = screen_radius_clip(c0, age * 3.2 * (0.6 + a.w * 0.1)); + let width = 3.0 + ring * 0.025; + let x = (length(px - centre) - ring) / width; + if abs(x) < 4.0 { + let fade = 1.0 - age / 24.0; + flare += vec3(1.0, 0.9, 0.72) * exp(-x * x) * fade * fade * mass * (0.25 + 0.5 * near) * visible; } - color += vec3(1.0, 0.8, 0.55) * f * 0.12 / (1.0 + p.z * 0.01); } + color += flare / (1.0 + flare * 0.2); // 4. Glows: engine flames and muzzle flashes. for (var j = 0; j < lights; j++) { diff --git a/airdef-mod/src/cinema.rs b/airdef-mod/src/cinema.rs new file mode 100644 index 0000000..7c731ed --- /dev/null +++ b/airdef-mod/src/cinema.rs @@ -0,0 +1,111 @@ +//! The cinematic look of a blast (#51): a layered sound (a close crack, a low thump, a rolling +//! tail; from afar the muffled report and a late boom), and the camera's reaction (a brief +//! exposure and bloom punch). Everything is a function of the blast, its distance and its age, so +//! a replay hears and sees the same as the live raid did, from the replay's camera. + +/// The power (0 air burst .. 4 ballistic) of a blast sound event, if it is one. +pub fn blast_power(event: &str) -> Option { + Some(match event { + "airdef:blast.air" => 0, + "airdef:blast.medium" => 1, + "airdef:blast.big" => 2, + "airdef:blast.heavy" => 3, + "airdef:blast.huge" => 4, + _ => return None, + }) +} + +/// One voice of a blast: the sound event (with `_far` where the listener is far), its loudness +/// and pitch, and the ticks it follows the first after the sound has travelled. +#[derive(Clone, Debug, PartialEq)] +pub struct Layer { + pub name: String, + pub volume: f32, + pub pitch: f32, + pub delay: u64, +} + +/// Blasts of one tick that get all their layers, the nearest first: the rest of a mass raid +/// plays its one voice, so the key events are not buried and the mix does not clip. +pub const FULL: usize = 3; + +/// The voices of a blast of `power`, `dist` blocks from the listener (`far`: where the muffled +/// variants take over), `gain` its loudness (1 normal; bigger warheads more), the `rank`-th +/// nearest of its tick. +pub fn layers(power: usize, dist: f64, far: f64, gain: f32, pitch: f32, rank: usize) -> Vec { + let g = gain.clamp(0.3, 2.0); + let own = ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][power.min(4)]; + let layer = |name: &str, volume: f32, pitch: f32, delay: u64| Layer { name: name.to_string(), volume, pitch, delay }; + let far_away = dist > far; + let suffix = if far_away { "_far" } else { "" }; + if rank >= FULL { + return vec![layer(&format!("{own}{suffix}"), g, pitch, 0)]; + } + if far_away { + // The muffled report, then a deep boom that arrives late. + return vec![ + layer(&format!("{own}_far"), g, pitch, 0), + layer("airdef:blast.big_far", 0.7 * g, pitch * 0.6, 10 + 2 * power as u64), + ]; + } + let tail = (0.45 + 0.08 * power as f32) * g; + if power == 0 { + // An air burst has no body of its own: the crack, the thump, the roll. + vec![ + layer("airdef:blast.air", g, pitch * 1.12, 0), + layer("airdef:blast.big", 0.85 * g, pitch * 0.66, 2), + layer("airdef:blast.huge", tail, pitch * 0.5, 7), + ] + } else { + vec![ + layer(own, g, pitch, 0), + layer("airdef:blast.air", 0.7 * g, pitch * 1.15, 0), + layer("airdef:blast.huge", tail, pitch * 0.5, 7), + ] + } +} + +/// How hard the picture is punched now (0 to 1) by a blast of fireball radius `size` that went +/// off `age` ticks ago `dist` blocks from the camera: bright and brief, near and big blasts most. +pub fn punch(size: f32, age: f32, dist: f64) -> f32 { + if age < 0.0 { + return 0.0; + } + let reach = (size as f64 * 45.0).max(30.0); + let near = (1.0 - dist / reach).clamp(0.0, 1.0) as f32; + let big = (size / 8.0).clamp(0.35, 1.0); + // A rise over a tick, then gone in about half a second. + (near * big * (-age / 3.0).exp() * (age / 0.8).min(1.0)).clamp(0.0, 1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_near_intercept_is_layered_and_a_mass_raid_is_bounded() { + let l = layers(0, 40.0, 140.0, 1.2, 1.0, 0); + assert_eq!(l.len(), 3); + assert!(l.iter().all(|v| !v.name.ends_with("_far"))); + assert!(l[0].delay == 0 && l[2].delay > l[1].delay, "crack, then thump, then the roll"); + // A far one is the muffled report and a late boom. + let f = layers(0, 400.0, 140.0, 1.0, 1.0, 0); + assert!(f.iter().all(|v| v.name.ends_with("_far")) && f[1].delay > 0); + // The rest of a tick's blasts play one voice, and the whole mix stays bounded. + let total: f32 = (0..12).flat_map(|r| layers(2, 60.0, 140.0, 1.0, 1.0, r)).map(|v| v.volume).sum(); + let voices: usize = (0..12).map(|r| layers(2, 60.0, 140.0, 1.0, 1.0, r).len()).sum(); + assert_eq!(voices, FULL * 3 + (12 - FULL)); + assert!(total < 18.0, "{total}"); + assert_eq!(blast_power("airdef:blast.huge"), Some(4)); + assert_eq!(blast_power("airdef:boom.sonic"), None); + } + + #[test] + fn the_punch_is_brief_near_and_bounded() { + assert_eq!(punch(5.0, -1.0, 10.0), 0.0); + assert!(punch(5.0, 1.5, 20.0) > punch(5.0, 1.5, 150.0)); + assert!(punch(5.0, 1.5, 20.0) > punch(2.0, 1.5, 20.0)); + assert!(punch(13.0, 1.0, 0.0) <= 1.0); + assert!(punch(5.0, 30.0, 20.0) < 0.01, "gone in a second and a half"); + } +} diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index f7a4a5c..821e1dc 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -12,6 +12,7 @@ use horizon_mod_sdk::{ ui::{self, ButtonWidget, ItemWidget, ListWidget, Screen, Widget}, }; +mod cinema; mod monitor; mod panel; mod radar; @@ -88,6 +89,8 @@ struct AirDef { menu: Option, /// Where the player is, for the sounds' delay and far variants. listener: DVec3, + /// The picture punch (#51) the HD exposure and bloom were last set for. + punch: f32, /// Sounds on their way to the player at the speed of sound. pending: Vec, /// The handles `render.model-id` gave the model names (valid under `model_generation`); none: @@ -163,6 +166,16 @@ fn say(text: &str) { chat::show(text); } +/// What the effects draw this tick (`Mod::shown`). +struct Shown { + blasts: Vec, + /// The clock the blasts' `start` is on: the simulation's ticks, or the recording's. + now: f32, + /// 1 minus the share of the frame's partial tick the effects advance with. + hold: f32, + replay: bool, +} + fn player() -> Option { game::get_player() } @@ -303,14 +316,37 @@ impl AirDef { /// blasts (a flash, then the fireball's glow dying down), burning wrecks and the /// searchlights' spots. fn lights(&self, out: &Out) -> Vec { + let shown = self.shown(); let mut lights: Vec = Vec::new(); let mut add = |id: u32, at: DVec3, rgb: (f32, f32, f32), intensity: f32, radius: f32| { if lights.len() < 500 && intensity > 0.05 { lights.push(PointLight { id, x: at.x, y: at.y, z: at.z, color: rgb, intensity, radius: radius.clamp(1.0, 256.0) }); } }; - let tick = self.sim.tick; + let tick = shown.now as u64; let flicker = |id: u32| 0.85 + 0.15 * (((tick as u32).wrapping_mul(2654435761) ^ id.wrapping_mul(97)) % 100) as f32 / 100.0; + // Blasts first, the nearest first: a crowded sky never costs the key intercept its light. + let viewer = self.viewer(&shown); + let mut near: Vec<&sim::Blast> = shown.blasts.iter().collect(); + if let Some(e) = viewer { + near.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e))); + } + for (rank, b) in near.into_iter().enumerate() { + let age = (shown.now - b.start as f32).max(0.0); + let size = b.size; + let glow = (-age / (b.life as f32 * 0.15).max(1.0)).exp(); + let flash = (-age * 0.35).exp() * 4.0; + // Its own id range: the models' and the systems' ids are small numbers. + let id = 0x6100_0000 | (b.id & 0x00ff_ffff); + let centre = b.at + DVec3::Y * size as f64 * 0.5; + add(id, centre, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0); + // The punch (#51): a white burst that lights the land, the smoke and the wrecks far round + // for a moment; for the nearest few, scaled by the blast. + if rank < 6 { + let burst = (-age / 2.0).exp(); + add(0x6300_0000 | (b.id & 0x00ff_ffff), centre, (1.0, 0.9, 0.74), size * size * 95.0 * burst, (size * 30.0).min(256.0)); + } + } for m in out.models.iter().filter(|m| m.glow) { let s = m.scale.max(0.1); match m.name { @@ -323,14 +359,9 @@ impl AirDef { _ => {} } } - for b in &self.sim.blasts { - let age = tick.saturating_sub(b.start) as f32; - let size = b.size; - let glow = (-age / (b.life as f32 * 0.15).max(1.0)).exp(); - let flash = (-age * 0.35).exp() * 4.0; - // Its own id range: the models' and the systems' ids are small numbers. - let id = 0x6100_0000 | (b.id & 0x00ff_ffff); - add(id, b.at + DVec3::Y * size as f64 * 0.5, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0); + // A replay shows its own flames (the models of the ghosts) and none of the live world's. + if shown.replay { + return lights; } // Fires burn on for minutes: a flickering, wide glow that dies down with the flames. for f in self.sim.fires.list.iter().filter(|f| f.shown) { @@ -373,6 +404,7 @@ impl AirDef { /// Switches the HD lighting. fn apply_hd(&self) { render::set_lighting(Lighting { hd: self.hd, exposure: 1.0, bloom: 0.55 }); + // (The punch of the next blast sets its own.) if !self.hd { render::set_lights(&[]); } @@ -381,15 +413,15 @@ impl AirDef { /// Hands the simulation's output to the game. /// The post pass's parameters: blasts and glowing parts (shaders/effects.wgsl). fn shader_params(&self, out: &Out) -> Vec { + let shown = self.shown(); let mut p = vec![0.0f32; 4]; - p[0] = self.sim.tick as f32; + p[0] = shown.now; p[3] = if self.smoke.hd { 2.0 } else if self.hd { 1.0 } else { 0.0 }; - let mut blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect(); + let mut blasts: Vec<&sim::Blast> = shown.blasts.iter().rev().take(48).collect(); // The shader composites the volumes front to back with one shared transmittance, so // the nearest to the player goes first. This is exact for volumes that do not // overlap; where two intersect, no order by centre is right along every ray. - if let Some(pl) = player() { - let e = eye(&pl); + if let Some(e) = self.viewer(&shown) { blasts.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e))); } p[1] = blasts.len() as f32; @@ -420,9 +452,52 @@ impl AirDef { lights += 1; } p[2] = lights as f32; + // The last vec4: how much of the frame's partial tick the effects run on (0 live; a + // slowed or paused replay runs on its own time, not the frame's). + p.resize(1024, 0.0); + p[1020] = shown.hold; p } + /// The smoke volumes the renderer draws and the clock they are aged by: the live ones, or (in a + /// replay) the blasts' billows at the replay's moment, never the live raid's trails and fires. + /// The billows are the live ones' own function (`Smoke::segments`), run on the recording's + /// blasts and time with the live state set aside for the call. + fn shown_smoke(&mut self) -> (f64, Vec) { + let shown = self.shown(); + if !shown.replay { + return (self.sim.tick as f64, self.smoke.segments(&self.sim)); + } + let blasts = std::mem::replace(&mut self.sim.blasts, shown.blasts); + let fires = std::mem::take(&mut self.sim.fires.list); + let tick = std::mem::replace(&mut self.sim.tick, shown.now as u64); + let segments = smoke::Smoke::default().segments(&self.sim); + self.sim.blasts = blasts; + self.sim.fires.list = fires; + self.sim.tick = tick; + (f64::from(shown.now), segments) + } + + /// Where the picture is seen from: the replay's camera, or the player's eyes. + fn viewer(&self, shown: &Shown) -> Option { + match &self.replay { + Some(r) if shown.replay => Some(r.eye()), + _ => player().map(|pl| eye(&pl)), + } + } + + /// The blasts the effects show and the clock they run on: the live ones, or (in a replay) the + /// recording's at the replay's moment, never the live world's. + fn shown(&self) -> Shown { + if let Some(r) = &self.replay + && r.filming() + && let Some(rec) = self.sim.replay(r.rec) + { + return Shown { blasts: sim::record::replay_blasts(rec, r.time()), now: r.time() as f32, hold: 1.0 - r.rate().clamp(0.0, 1.0), replay: true }; + } + Shown { blasts: self.sim.blasts.clone(), now: self.sim.tick as f32, hold: 0.0, replay: false } + } + /// The models to the game by handle: each name crosses once, the buffer stays allocated. fn send_models(&mut self, models: &[sim::Model]) { let generation = render::model_generation(); @@ -460,8 +535,18 @@ impl AirDef { render::set_params(&self.shader_params(&out)); } if self.hd { + // The camera's reaction to a near blast: a brief exposure and bloom punch. + let shown = self.shown(); + let p = self.viewer(&shown).map_or(0.0, |e| { + shown.blasts.iter().map(|b| cinema::punch(b.size, shown.now - b.start as f32, b.at.distance(e))).fold(0.0, f32::max) + }); + if (p - self.punch).abs() > 0.01 || (p == 0.0 && self.punch != 0.0) { + self.punch = p; + render::set_lighting(Lighting { hd: true, exposure: 1.0 + 0.4 * p, bloom: 0.55 + 0.9 * p }); + } render::set_lights(&self.lights(&out)); - self.smoke.hd = render::set_smoke(self.sim.tick as f64, &self.smoke.segments(&self.sim)); + let (clock, segments) = self.shown_smoke(); + self.smoke.hd = render::set_smoke(clock, &segments); } else { self.smoke.hd = false; } @@ -469,7 +554,7 @@ impl AirDef { self.send_models(&out.models); } if !out.particles.is_empty() { - let hd_smoke = self.smoke.hd; + let hd_smoke = self.smoke.hd && !self.replay.as_ref().is_some_and(|r| r.filming()); let spawns: Vec = out .particles .into_iter() @@ -492,9 +577,13 @@ impl AirDef { render::spawn_particles(&spawns); } let now = self.sim.tick; + // Blasts play layered (cinema.rs), the nearest ones in full. + let mut booms: Vec<(f64, usize, DVec3, f32, f32)> = Vec::new(); for (event, at, volume, pitch) in out.sounds { let dist = at.distance(self.listener); - if travels(event) { + if let Some(power) = cinema::blast_power(event) { + booms.push((dist, power, at, volume, pitch)); + } else if travels(event) { // Seen first, heard later; from afar muffled. let name = if dist > FAR { format!("{event}_far") } else { event.to_string() }; let delay = (dist / SOUND_SPEED) as u64; @@ -507,6 +596,18 @@ impl AirDef { render::play_sound(event, at.x, at.y, at.z, volume, pitch); } } + booms.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (rank, (dist, power, at, volume, pitch)) in booms.into_iter().enumerate() { + for l in cinema::layers(power, dist, FAR, volume, pitch, rank) { + // Seen first, heard later: each voice after the sound has travelled. + let delay = (dist / SOUND_SPEED) as u64 + l.delay; + if delay == 0 { + render::play_sound(&l.name, at.x, at.y, at.z, l.volume, l.pitch); + } else if self.pending.len() < 256 { + self.pending.push(PendingSound { due: now + delay, name: l.name, at, volume: l.volume, pitch: l.pitch }); + } + } + } let mut i = 0; while i < self.pending.len() { if self.pending[i].due <= now { @@ -794,6 +895,7 @@ impl AirDef { fn stop_replay(&mut self) { self.replay = None; render::set_time_override(None); + render::set_main_view(None); input::claim_wheel(false); render::set_hud(&[]); render::set_views(&[]); @@ -1013,6 +1115,7 @@ impl Mod for AirDef { health_mark: 0, menu: None, listener: DVec3::ZERO, + punch: 0.0, pending: Vec::new(), model_ids: std::collections::HashMap::new(), model_generation: u32::MAX, @@ -1148,6 +1251,13 @@ impl Mod for AirDef { render::query_sections(render::Policy::Collision, §ions); } if let Some(mut r) = self.replay.take() { + // A replay shows the recording alone: not the live raid's models, particles, sounds or jolts. + if r.filming() { + out.models.clear(); + out.particles.clear(); + out.sounds.clear(); + out.shake = None; + } r.tick(&self.sim, self.listener, &mut out); if r.done { self.replay = Some(r); @@ -1163,6 +1273,8 @@ impl Mod for AirDef { let size = render::gui_size(); render::set_hud(&r.hud(&self.sim, size)); render::set_views(&r.views(size)); + // The world through the director's camera, with the full pipeline (HD, the post pass). + render::set_main_view(r.filming().then(|| r.view_id())); // The sky of the raid, not of now (None for a raid whose clock was unknown). render::set_time_override(r.day_time(&self.sim)); self.monitor_shown = true; diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index 9b31362..8e09112 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -9,7 +9,7 @@ use crate::sim::record::{EventKind, Recording, STEP, What}; use std::cell::Cell; use crate::sim::{Class, DroneKind, Out, Sim}; use glam::DVec3; -use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape, ViewShape}; +use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape}; /// Ghost models' ids, clear of the live ones. const GHOSTS: u32 = 0x5000_0000; @@ -37,6 +37,8 @@ pub struct Player { pub mode: Mode, t: f64, speed: f64, + /// How fast the replay's time ran this tick (0 paused or held on a card; slowed round a moment). + rate: f64, /// The director's current shot (event, shot) and the view id it films with. shot: (usize, u8), view: u32, @@ -213,6 +215,7 @@ impl Player { mode, t: start, speed, + rate: 0.0, shot: (usize::MAX, 0), view: VIEW, fade: 0, @@ -297,6 +300,26 @@ impl Player { self.fade = 6; } + /// Where the camera is this tick. + pub fn eye(&self) -> DVec3 { + self.eye + } + /// The replay's moment, in the recording's ticks. + pub fn time(&self) -> f64 { + self.t + } + /// How fast the replay's time runs now, as a share of real time (1 normal, 0 held). + pub fn rate(&self) -> f32 { + self.rate as f32 + } + /// Whether the world is filmed (the map is drawn on the HUD instead). + pub fn filming(&self) -> bool { + !matches!(self.mode, Mode::Map) + } + /// The camera the world is rendered from (`set-main-view`). + pub fn view_id(&self) -> u32 { + self.view + } /// The time of day the raid was flown in, as of the replay's moment now. pub fn day_time(&self, sim: &Sim) -> Option { sim.replay(self.rec)?.day_time(self.t) @@ -325,6 +348,7 @@ impl Player { speed = 0.0; } } + self.rate = if self.paused { 0.0 } else { speed }; if !self.paused { self.t += speed; } @@ -365,7 +389,8 @@ impl Player { EventKind::SamLost { .. } => "airdef:blast.big", EventKind::Split { .. } => "airdef:boom.sonic", }; - out.sound(event, heard(e.pos), 1.0, 1.0); + let gain = if let EventKind::Kill { kind, .. } = e.kind { crate::sim::record::kill_look(kind).2 } else { 1.0 }; + out.sound(event, heard(e.pos), gain, 1.0); } for tr in rec.tracks.iter().filter(|tr| (self.heard..self.t).contains(&(tr.first as f64))) { if let What::Missile(kind, _) = tr.what @@ -508,8 +533,8 @@ impl Player { if matches!(self.mode, Mode::Map) { return self.map(rec, gw, gh); } - let mut s = vec![fill(0.0, 0.0, gw, gh, 0xFF000000)]; - s.push(Shape::View(ViewShape { view: self.view, rect: Rect { x: 0.0, y: 0.0, w: gw, h: gh }, mode: 0 })); + // The world itself is the camera's (`set-main-view`): the HUD only frames it. + let mut s: Vec = Vec::new(); // Letterbox, the recording's marks. let bar = (gh * 0.09).round(); s.push(fill(0.0, 0.0, gw, bar, 0xFF000000)); diff --git a/airdef-mod/src/sim/fires.rs b/airdef-mod/src/sim/fires.rs index 21da2a9..3fc2feb 100644 --- a/airdef-mod/src/sim/fires.rs +++ b/airdef-mod/src/sim/fires.rs @@ -251,9 +251,15 @@ impl Sim { /// A fire for a replay: the event's power at age `age` ticks, as a glowing flame model's scale /// (zero when it is out or not started) and a smoke strength. pub fn replay_fire(power: u32, age: f64) -> (f32, f32) { - if age < 0.0 { + replay_fire_of(size_for(power), life_for(power), age) +} + +/// The same for a fire of a given `size` and `life` (a merged fire has grown): out (zero) before +/// it starts and from the end of its life on, as the live fire is removed then. +pub fn replay_fire_of(size: f32, life: u32, age: f64) -> (f32, f32) { + if !(0.0..life as f64).contains(&age) { return (0.0, 0.0); } - let (fl, sm) = level(age, life_for(power) as f64); - (fl * size_for(power), sm) + let (fl, sm) = level(age, life as f64); + (fl * size, sm) } diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 327359c..884f119 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -2015,10 +2015,10 @@ impl Sim { } if let Some(p) = player { let d = p.distance(pos); - let reach = [45.0, 45.0, 100.0, 160.0, 240.0][power]; + let reach = [70.0, 45.0, 100.0, 160.0, 240.0][power]; if d < reach { - let strength = [0.4, 0.4, 0.9, 1.3, 1.8][power]; - out.jolt((1.0 - d / reach) as f32 * strength, [0.8, 0.8, 1.6, 2.2, 3.0][power]); + let strength = [0.55, 0.4, 0.9, 1.3, 1.8][power]; + out.jolt((1.0 - d / reach) as f32 * strength, [1.0, 0.8, 1.6, 2.2, 3.0][power]); } } self.blast_damage(pos, power, out); @@ -2075,11 +2075,13 @@ impl Sim { if let Some(s) = self.sams.iter_mut().find(|s| s.id == by) { s.kills += 1; } - self.fireball(pos, 3.5, 8); + // The look of the kill goes by the warhead it blew up (replays use the same). + let (size, life, gain) = record::kill_look(self.drones[idx].kind); + self.fireball(pos, size as f64, life / 9); out.particles("minecraft:explosion", pos, 0.6, DVec3::ZERO, 0.0, 4); out.particles("minecraft:large_smoke", pos, 1.0, DVec3::ZERO, 0.06, 25); out.particles("minecraft:firework", pos, 0.3, DVec3::ZERO, 0.35, 30); - out.sound("airdef:blast.air", pos, 1.0, 1.0 + self.rng.f() as f32 * 0.2); + out.sound("airdef:blast.air", pos, gain, 1.0 + self.rng.f() as f32 * 0.2); for _ in 0..5 { let id = self.id(); let v = vel + DVec3::new(self.rng.s(), self.rng.f() * 0.8, self.rng.s()) * 0.35; diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index 10d2f64..ccf69cf 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -114,9 +114,11 @@ pub struct Recording { /// The systems and objects as they stood: (id, kind, where). pub sams: Vec<(u32, SamKind, DVec3)>, pub objects: Vec, - /// The world clock's time of day (ticks) every `TIME_STEP` ticks of the recording, from its - /// start: replays show the sky of the raid, not the current one. Empty when the clock was unknown. - times: Vec, + /// The world clock's time of day as (recording tick, day ticks), sampled on every + /// `TIME_STEP`-th tick the clock was known: replays show the sky of the raid, not the current + /// one. A tick the clock was unknown has no sample (a gap), so a late start or a lost clock + /// never shifts later samples; empty when the clock was never known. + times: Vec<(u32, u64)>, /// Ticks the sky has been clear. quiet: u32, } @@ -166,6 +168,86 @@ impl Track { } } +/// A fire as a replay shows it: what the events lit and kept alight, merged as the live fires are. +#[derive(Clone, Copy, Debug)] +pub struct ReplayFire { + pub at: DVec3, + /// The recording's tick it was lit. + pub start: u32, + pub life: u32, + pub size: f32, + /// The event that lit it (the models' ids). + pub key: usize, +} + +/// The look of an interception by the drone's warhead: (fireball radius, ticks it burns, loudness). +/// A bare decoy pops; a big warhead lights the sky. The same in the live raid and its replay. +pub fn kill_look(kind: DroneKind) -> (f32, u32, f32) { + let w = kind.spec().warhead.min(8) as f32; + (3.5 * (0.85 + 0.12 * w), (8 + (w as u32) / 2) * 9, 1.0 + 0.06 * w) +} + +/// A blast's look for an event: (fireball radius, ticks it burns). The live blasts use the same. +pub fn blast_spec(kind: EventKind) -> (f32, u32) { + const SIZE: [f32; 5] = [2.5, 4.5, 7.0, 10.0, 13.0]; + const LIFE: [u32; 5] = [11, 11, 18, 22, 26]; + match kind { + EventKind::Kill { kind, .. } => { + let (size, life, _) = kill_look(kind); + (size, life) + } + EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => { + let p = (power as usize).min(4); + (SIZE[p], LIFE[p] * 9) + } + EventKind::SamLost { .. } => (8.0, 18 * 9), + EventKind::Split { .. } => (2.0, 7 * 9), + } +} + +/// The blasts of the recording's events burning at `t` (its ticks, fractional): the same +/// `Blast`s the live simulation keeps, with `start` in the recording's ticks and the event's +/// own id range. A pure function of `t`, so a seek or a pause shows the same frame. +pub fn replay_blasts(rec: &Recording, t: f64) -> Vec { + rec.events + .iter() + .enumerate() + .filter_map(|(k, e)| { + let (size, life) = blast_spec(e.kind); + let age = t - e.at as f64; + (0.0..life as f64).contains(&age).then_some(Blast { id: 0x4000_0000 | k as u32, at: e.pos, size, start: e.at as u64, life }) + }) + .collect() +} + +/// The fires burning at `t` in the recording: events within 6 blocks of a fire still burning +/// feed it (the greater size, the longer life), as `Sim::ignite` does; those after it died light +/// a new one. A pure function of `t`. +pub fn replay_fires(rec: &Recording, t: f64) -> Vec { + let mut fires: Vec = Vec::new(); + for (k, e) in rec.events.iter().enumerate() { + if e.at as f64 > t { + break; + } + let power = match e.kind { + EventKind::Crash { power, .. } => power, + EventKind::Hit { power, .. } => power.max(1), + EventKind::SamLost { .. } => 2, + _ => continue, + }; + let (size, life) = (fires::size_for(power), fires::life_for(power)); + let alive = |f: &ReplayFire| e.at < f.start + f.life; + if let Some(f) = fires.iter_mut().find(|f| alive(f) && f.at.distance(e.pos) < 6.0) { + f.size = f.size.max(size); + f.life = f.life.max(e.at - f.start + life); + } else { + fires.push(ReplayFire { at: e.pos, start: e.at, life, size, key: k }); + } + } + fires.retain(|f| t < (f.start + f.life) as f64); + fires +} + impl Recording { fn new(number: u32, sim: &Sim) -> Self { let origin = sim.targets.first().copied().or_else(|| sim.sams.first().map(|s| s.pos)).unwrap_or(DVec3::ZERO); @@ -199,12 +281,16 @@ impl Recording { /// clock as sampled, run on between samples at the pace it ran (a jump is a step), or None /// when the clock was not known (the replay then shows the world's own time). pub fn day_time(&self, t: f64) -> Option { - let k = (t.max(0.0) / TIME_STEP as f64).floor() as usize; - let i = k.min(self.times.len().checked_sub(1)?); - let a = self.times[i]; - let into = if i == k { t.max(0.0) - (i as u32 * TIME_STEP) as f64 } else { 0.0 }; + let t = t.max(0.0); + // The last sample at or before `t`; none before the first one means unknown. + let i = self.times.partition_point(|s| s.0 as f64 <= t).checked_sub(1)?; + let (ta, a) = self.times[i]; match self.times.get(i + 1) { - Some(&b) if b >= a && b - a <= 2 * TIME_STEP as u64 => Some(a + ((b - a) as f64 * into / TIME_STEP as f64) as u64), + // Neighbouring samples with no gap between: run on at the pace between them. + Some(&(tb, b)) if tb - ta <= TIME_STEP && b >= a && b - a <= 2 * TIME_STEP as u64 => { + let f = ((t - ta as f64) / (tb - ta) as f64).clamp(0.0, 1.0); + Some(a + ((b - a) as f64 * f) as u64) + } _ => Some(a), } } @@ -271,9 +357,8 @@ impl Sim { let Some(r) = &mut self.rec else { return }; if r.len % TIME_STEP == 0 && let Some(dt) = self.day_time - && r.times.len() == (r.len / TIME_STEP) as usize { - r.times.push(dt); + r.times.push((r.len, dt)); } if r.len % STEP == 0 { for d in &self.drones { @@ -349,43 +434,32 @@ impl Sim { } } } - // Fires: what the events left burning (the flames dim and the smoke thickens with time). - let mut lit: Vec<(DVec3, f64)> = Vec::new(); - for (k, e) in rec.events.iter().enumerate() { - let power = match e.kind { - EventKind::Crash { power, .. } => power, - EventKind::Hit { power, .. } => power.max(1), - EventKind::SamLost { .. } => 2, - _ => continue, - }; - let age = t - e.at as f64; - // One fire where several events met. - if lit.iter().any(|(p, at)| p.distance(e.pos) < 6.0 && *at <= e.at as f64) { - continue; - } - lit.push((e.pos, e.at as f64)); - let (flame, smoke) = fires::replay_fire(power, age); - let id = base + 0x00c0_0000 + k as u32 * PARTS; + // Fires: what the events left burning (the flames dim and the smoke thickens with time), + // out when their life ends. + for f in replay_fires(rec, t) { + let age = t - f.start as f64; + let (flame, smoke) = fires::replay_fire_of(f.size, f.life, age); + let id = base + 0x00c0_0000 + f.key as u32 * PARTS; if flame > 0.05 { - let wob = 1.0 + 0.15 * (age * 0.9 + k as f64).sin(); - out.model(id, "airdef:flame", e.pos + DVec3::Y * 0.5, DQuat::from_rotation_y(age * 0.07), (flame as f64 * 0.8 * wob).max(0.05), true); + let wob = 1.0 + 0.15 * (age * 0.9 + f.key as f64).sin(); + out.model(id, "airdef:flame", f.at + DVec3::Y * 0.5, DQuat::from_rotation_y(age * 0.07), (flame as f64 * 0.8 * wob).max(0.05), true); } if smoke > 0.1 && (age as u64) % 3 == 0 { - out.particles("minecraft:large_smoke", e.pos + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32); + out.particles("minecraft:large_smoke", f.at + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32); } } - // Blasts: a fireball growing and fading for 1.5 s after each. + // Blasts: with the shader effects on, the post pass and the HD lights draw them from + // `replay_blasts` (the same as the live ones); the models are the fallback without them. + if self.shader { + return; + } for (k, e) in rec.events.iter().enumerate() { let age = t - e.at as f64; if !(0.0..30.0).contains(&age) { continue; } - let size = match e.kind { - EventKind::Kill { .. } => 3.5, - EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => [2.5, 4.5, 7.0, 10.0, 13.0][(power as usize).min(4)], - EventKind::SamLost { .. } => 8.0, - EventKind::Split { .. } => 2.0, - }; + let (size, _) = blast_spec(e.kind); + let size = size as f64; let f = age / 30.0; let s = size * (f * 4.0).min(1.0) * (1.0 - f * f); let id = base + 0x0080_0000 + k as u32 * PARTS; diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 3981074..47eda29 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -534,6 +534,81 @@ fn recordings_keep_the_time_of_day() { } } +// #57: a clock missing at the start, in the middle, or later recovering never loses or shifts the samples. +#[test] +fn recorded_clock_survives_gaps() { + let mut sim = Sim::new(1); + sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0)); + let mut g = Ground::default(); + let mut out = Out::default(); + for i in 0..100u64 { + // Unknown at the start (ticks 0-19) and for a stretch in the middle (50-69). + sim.day_time = if i < 20 || (50..70).contains(&i) { None } else { Some(1000 + i) }; + sim.step(&mut g, None, &mut out); + } + let r = sim.rec.as_ref().unwrap(); + assert_eq!(r.day_time(5.0), None, "before the first sample: unknown, not the current time"); + let v = r.day_time(30.0).expect("recovered after a missing start"); + assert!((1020..1040).contains(&v), "{v}"); + // Inside the gap the last known time holds; nothing is invented. + let held = r.day_time(60.0).unwrap(); + assert!((1030..1050).contains(&held), "{held}"); + // After the gap the new samples land at their own ticks (seek forward and back alike). + let after = r.day_time(85.0).unwrap(); + assert!((1080..1100).contains(&after), "{after}"); + assert!(r.day_time(30.0).unwrap() < r.day_time(85.0).unwrap()); + assert!(r.day_time(85.0).unwrap() > r.day_time(60.0).unwrap()); + assert!(r.day_time(25.0).unwrap() <= r.day_time(35.0).unwrap()); +} + +// #56: a replay's fires and blasts are pure functions of its time: they end with their life, +// merge as the live ones do, and a seek back shows the same frame. +#[test] +fn replay_fires_and_blasts_follow_the_recording() { + use record::{Event, EventKind}; + let mut sim = Sim::new(1); + sim.day_time = Some(1000); + sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0)); + let mut g = Ground::default(); + let mut out = Out::default(); + sim.step(&mut g, None, &mut out); + let mut rec = sim.rec.take().unwrap(); + let at = DVec3::new(5.0, 70.0, 5.0); + let crash = |power| EventKind::Crash { drone: 1, kind: DroneKind::Geran3, power }; + let life = fires::life_for(1); + rec.events = vec![ + Event { at: 10, pos: at, kind: crash(1) }, + // The same spot while it burns: one fire, burning longer. + Event { at: 500, pos: at + DVec3::X, kind: crash(3) }, + // The same spot long after it died: a new fire. + Event { at: 500 + 4000 + fires::life_for(3), pos: at, kind: crash(1) }, + ]; + assert!(record::replay_fires(&rec, 0.0).is_empty(), "before the first event"); + let f = record::replay_fires(&rec, 600.0); + assert_eq!(f.len(), 1); + assert_eq!(f[0].start, 10); + assert!(f[0].life >= 490 + fires::life_for(3) && f[0].size >= fires::size_for(3), "fed: {:?}", f[0]); + // Past its life it is gone: no flames, no smoke. + let end = f[0].start + f[0].life; + assert!(record::replay_fires(&rec, end as f64 - 1.0).len() == 1); + assert!(record::replay_fires(&rec, end as f64 + 1.0).is_empty()); + assert_eq!(fires::replay_fire(1, life as f64 + 10.0), (0.0, 0.0)); + assert_eq!(fires::replay_fire(1, 1.0e5), (0.0, 0.0)); + assert!(fires::replay_fire(1, 100.0).1 > 0.0); + // The later event lights a new one. + let f = record::replay_fires(&rec, (500 + 4000 + fires::life_for(3) + 5) as f64); + assert_eq!(f.len(), 1); + assert_eq!(f[0].start, 500 + 4000 + fires::life_for(3)); + // Seeking back and forth gives the same blasts. + let a = record::replay_blasts(&rec, 20.0); + let _ = record::replay_blasts(&rec, 510.0); + let b = record::replay_blasts(&rec, 20.0); + assert_eq!(a.len(), 1); + assert_eq!((a[0].id, a[0].start, a[0].life), (b[0].id, b[0].start, b[0].life)); + assert!(record::replay_blasts(&rec, 5.0).is_empty()); + assert!(record::replay_blasts(&rec, 10.0 + a[0].life as f64).is_empty(), "burnt out"); +} + fn sim_first(r: &record::Recording) -> u64 { r.tracks.first().map_or(0, |t| t.first as u64) } diff --git a/docs/HD-RENDERER.md b/docs/HD-RENDERER.md index 70afe8d..03ce928 100644 --- a/docs/HD-RENDERER.md +++ b/docs/HD-RENDERER.md @@ -332,3 +332,10 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home - Мерцание: `hills_river_noon` 9% без ветра (`out/nowind`) против 31% с ветром — это движение листвы вблизи камеры, не шум; амплитуда листвы снижена с 0.04 до 0.025 после этого замера (повторно не мерилось). - Тайминги **не сняты**: параллельно шли игры других агентов (числа 25–155 мс недействительны). Перед замером брать `run-home/.gpu-lock`. - Известно: `shaders_translate_to_hlsl_for_directx_12` падает на `cloud.wgsl` (не мой файл). + +## Камеры мода: полный вид и мониторы (#56, #51) + +- **Главный вид** (`render.set-main-view`, API 15): мир экрана рисуется из камеры мода (`set-views`) полным конвейером — HD, небо, облака, туман, объёмный дым, TAA, пост-проход мода, разрешение окна. Руку, контур блока и ванильный HUD не рисуем (`game.rs`: `mod_main_view`, `show_hud`). Так показываются повторы Full/Reel/моменты; чанки остаются вокруг игрока, слушатель — игрок. +- **Малые виды** (мониторы, `gfx/mod_views.rs`) остаются экономными: небо, твёрдая земля, сущности и вторым проходом пост-эффект мода с камерой этого вида (`ModPost::write_to/run_view`, режим `params[0].w = 0`: пост сам освещает землю и рисует дым). Бюджет по площади картинки (`post_budget`: <4096 px без пост-прохода, до 150k px — 4 взрыва/12 огней, до 600k — 12/32, больше — всё). HD-освещения, облаков и TAA у малых видов нет. +- Мод в повторе берёт взрывы из записи (`record::replay_blasts`, чистая функция времени), время идёт по часам повтора (`params[255].x` — доля частичного тика, которую эффекты не используют при паузе/замедлении). +- #51: на перехвате слои звука (`cinema.rs`), свет-«удар» (белая вспышка в HD-освещении, радиус до 256), пик экспозиции/bloom (`set-lighting`), в `effects.wgsl` широкое гало, подсветка неба/облаков и яркое кольцо ударной волны на экране. diff --git a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch index 763204c..9946934 100644 --- a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch +++ b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch @@ -1,7 +1,7 @@ From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:37:45 +0300 -Subject: [PATCH 01/30] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/31] Mod API (AirDef fork): world models, client particles, sounds, ground heights render.set-models draws block models with an interpolated pose per id; diff --git a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch index 9c15b7c..9122a88 100644 --- a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch +++ b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch @@ -1,7 +1,7 @@ From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:39:45 +0300 -Subject: [PATCH 02/30] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/31] cargo-horizon-mod: report particles and sounds Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch index ab051ac..b2aa5dd 100644 --- a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch +++ b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch @@ -1,7 +1,7 @@ From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 13:33:10 +0300 -Subject: [PATCH 03/30] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/31] Mod API (AirDef fork): full-screen post pass for mods render.set-post compiles a mod's WGSL after a prelude (camera matrices, time, 256 vec4 of params, the world's colour and depth) and runs it after diff --git a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch index e0c9968..3a3ac49 100644 --- a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch +++ b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch @@ -1,7 +1,7 @@ From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:09:30 +0300 -Subject: [PATCH 04/30] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/31] Mod models: draw far models as scaled impostors past the far plane, cull at 4096 --- diff --git a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch index c6360dc..cc37848 100644 --- a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch +++ b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch @@ -1,7 +1,7 @@ From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:31:00 +0300 -Subject: [PATCH 05/30] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/31] Mod cameras: render.set-views draws the world from other points into pictures that HUD view shapes show (API level 9) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch index 6711c63..de85e9f 100644 --- a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch +++ b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch @@ -1,7 +1,7 @@ From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:08:21 +0300 -Subject: [PATCH 06/30] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/31] Mods: more fuel per tick and per event (AirDef plans raids) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch index 4b0f7bc..b501751 100644 --- a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch +++ b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch @@ -1,7 +1,7 @@ From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:25:29 +0300 -Subject: [PATCH 07/30] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/31] Mod cameras up to 1920x1080 (full-screen replays) Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch index e711250..46d20d7 100644 --- a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch +++ b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch @@ -1,7 +1,7 @@ From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:15:11 +0300 -Subject: [PATCH 08/30] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/31] Mod cameras: distant terrain tiles, fog measured from the camera, no impostors while one is shown Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch index 41e38c8..ab9b783 100644 --- a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch +++ b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch @@ -1,7 +1,7 @@ From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:25:45 +0300 -Subject: [PATCH 09/30] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/31] Mods: game.world-id, so mods keep state per world (API level 9) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch index 89b92a5..f75c9a2 100644 --- a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch +++ b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch @@ -1,7 +1,7 @@ From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 12:01:08 +0300 -Subject: [PATCH 10/30] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/31] cargo-horizon-mod: trial runs behind feature full, so a packing-only tool skips wasmtime Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch index 8a25288..ae5f6bf 100644 --- a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch +++ b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch @@ -1,7 +1,7 @@ From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:18:43 +0300 -Subject: [PATCH 11/30] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/31] HD lighting: G-buffer pass, per-pixel mod point lights in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API level 10) diff --git a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch index 4b7debe..40edd3d 100644 --- a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch +++ b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch @@ -1,7 +1,7 @@ From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:59:43 +0300 -Subject: [PATCH 12/30] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/31] HD lighting stage 2: sun and moon shadow cascades, the world relit (sun by face angle and shadow, sky and block light through the lightmap), light shafts and halos, fog over it again, a light colour grade diff --git a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch index 62dc1a5..3d85200 100644 --- a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch +++ b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch @@ -1,7 +1,7 @@ From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:19:53 +0300 -Subject: [PATCH 13/30] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/31] HD: bind groups cached until a resize or a new source, shadow passes draw only the entities that cast shadows Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch index 92ee597..4d0b7db 100644 --- a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch +++ b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch @@ -1,7 +1,7 @@ From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:25:44 +0300 -Subject: [PATCH 14/30] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/31] HD: lights culled per 16x16 tile (surface and halo lists built on the CPU), a naga test for the lighting shaders Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch index edb0f27..c5c9dd4 100644 --- a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch +++ b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch @@ -1,7 +1,7 @@ From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:43:36 +0300 -Subject: [PATCH 15/30] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/31] HD: GPU time per pass in the F3 timing list, half-resolution GTAO with a depth-aware blur Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch index 0881cf7..0dfeb2c 100644 --- a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch +++ b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch @@ -1,7 +1,7 @@ From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 16/30] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/31] HD: shadows draw only sun-facing quads and skip buried sections, the far cascade every second frame; TAA (Halton jitter, reprojection by depth, YCoCg variance clipping) before bloom diff --git a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch index 6f10b09..1866511 100644 --- a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch +++ b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch @@ -1,7 +1,7 @@ From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 17/30] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/31] Mods: model handles (render.model-id, model-generation, set-models-by-id; API level 11) so mods send no model names each tick; clear-post and set-lighting documented diff --git a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch index b11b93e..f607c73 100644 --- a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch +++ b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch @@ -1,7 +1,7 @@ From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:19:57 +0300 -Subject: [PATCH 18/30] HD: temporal upscaling (HORIZON_HD_SCALE: world, +Subject: [PATCH 18/31] HD: temporal upscaling (HORIZON_HD_SCALE: world, G-buffer and lighting at a lower size, rebuilt to the window in the TAA pass); fix flickering shadows near the player (the near cascade lost its sections on frames the far one was held, the held far matrix ignored the diff --git a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch index 5f722ae..853d03e 100644 --- a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch +++ b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch @@ -1,7 +1,7 @@ From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:47:52 +0300 -Subject: [PATCH 19/30] HD water: its own pass (colour, normal, depth), the +Subject: [PATCH 19/31] HD water: its own pass (colour, normal, depth), the floor under it lit, a composite with world-space waves, refraction with depth absorption in the biome colour, screen-space reflections, Fresnel and a shadowed sun glint (HORIZON_HD_WATER=0 to switch off) diff --git a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch index 3fdf4af..97470f2 100644 --- a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch +++ b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch @@ -1,7 +1,7 @@ From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:56:04 +0300 -Subject: [PATCH 20/30] HD water: no vanilla texture on the surface (the biome +Subject: [PATCH 20/31] HD water: no vanilla texture on the surface (the biome colour only), a darker, deeper colour Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch index bf815ac..32d2805 100644 --- a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch +++ b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch @@ -1,7 +1,7 @@ From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 18:38:19 +0300 -Subject: [PATCH 21/30] HD sky and light: a physical atmosphere (Rayleigh, Mie, +Subject: [PATCH 21/31] HD sky and light: a physical atmosphere (Rayleigh, Mie, ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog and water reflections; warmer sunlight from the atmosphere's transmittance, a gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze diff --git a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch index 8b6cb49..f894c22 100644 --- a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch +++ b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch @@ -1,7 +1,7 @@ From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 19:02:16 +0300 -Subject: [PATCH 22/30] HD water: a darker blue, a little clearer, ripples a +Subject: [PATCH 22/31] HD water: a darker blue, a little clearer, ripples a little higher Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch index 91bc2ee..c92cfdc 100644 --- a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch +++ b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch @@ -1,7 +1,7 @@ From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:41:48 +0300 -Subject: [PATCH 23/30] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun +Subject: [PATCH 23/31] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun tint, blue hour; MIT), volumetric clouds with ground shadows instead of vanilla's in HD, volumetric fog with sun shafts and morning mist (HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0) diff --git a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch index 158ad79..83b8c2a 100644 --- a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch +++ b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch @@ -1,7 +1,7 @@ From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:43:22 +0300 -Subject: [PATCH 24/30] Mods: world queries for simulations (render.query-rays, +Subject: [PATCH 24/31] Mods: world queries for simulations (render.query-rays, query-sections, world-changes; API level 12): block-exact rays and section occupancy by collision, sight or sound, answered next tick within budgets, and a log of changed sections diff --git a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch index 4c664ba..6768f72 100644 --- a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch +++ b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch @@ -1,7 +1,7 @@ From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 22:56:15 +0300 -Subject: [PATCH 25/30] HD fixes: rain level from the world not from rain +Subject: [PATCH 25/31] HD fixes: rain level from the world not from rain geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint without a depth heuristic (#35), air fog once over water (#37), translucent things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick diff --git a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch index 0c756be..c1a3aa9 100644 --- a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch +++ b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch @@ -1,7 +1,7 @@ From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:19:53 +0300 -Subject: [PATCH 26/30] HD split into modules (gfx/hd/: one Rust file and one +Subject: [PATCH 26/31] HD split into modules (gfx/hd/: one Rust file and one WGSL file per pass, a shared prelude), contracts for colour, depth, alpha, time and coordinates written down, tests that the frame layout and bindings match between Rust and WGSL (#45) diff --git a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch index 67df28e..0bc3e46 100644 --- a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch +++ b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch @@ -1,7 +1,7 @@ From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:22:19 +0300 -Subject: [PATCH 27/30] HD clouds: noise scales that divide the camera's +Subject: [PATCH 27/31] HD clouds: noise scales that divide the camera's 65536-block wrap, so the clouds and their shadows no longer jump at x or z = 0 diff --git a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch index f757219..1d9ff6a 100644 --- a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch +++ b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch @@ -1,7 +1,7 @@ From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:17:11 +0300 -Subject: [PATCH 28/30] HD: volumetric smoke and missile trails with clear +Subject: [PATCH 28/31] 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 diff --git a/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch b/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch index 4a7f2c3..5e8520c 100644 --- a/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch +++ b/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch @@ -1,7 +1,7 @@ From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:26:09 +0300 -Subject: [PATCH 29/30] HD clouds composited by depth so terrain and vehicles +Subject: [PATCH 29/31] HD clouds composited by depth so terrain and vehicles inside or behind the layer are hidden and those in front stay in front (#67); world-anchored noise everywhere (cloud detail by height above the layer, fog, smoke and puddle scales dividing the wrap) (#68); the replay's time is visual diff --git a/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch b/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch index 37f4044..81b405b 100644 --- a/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch +++ b/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch @@ -1,7 +1,7 @@ From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:44:45 +0300 -Subject: [PATCH 30/30] HD smoke: young trails drawn at least as wide as a grid +Subject: [PATCH 30/31] HD smoke: young trails drawn at least as wide as a grid cell (density scaled to keep the mass), gentler bulges, so a trail is a smooth ribbon and not a chain of puffs diff --git a/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch b/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch new file mode 100644 index 0000000..49e38a0 --- /dev/null +++ b/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch @@ -0,0 +1,642 @@ +From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001 +From: chexit +Date: Fri, 30 Oct 2026 03:59:41 +0300 +Subject: [PATCH 31/31] Mods: render.set-main-view (API level 15) renders the + screen's world from a mod camera with the full pipeline, so replays get HD + and the post effects; small mod views get the post pass with their own camera + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/game.rs | 31 +++- + crates/horizon-client/src/game/mod_models.rs | 9 ++ + crates/horizon-client/src/gfx.rs | 39 ++++- + crates/horizon-client/src/gfx/mod_post.rs | 152 +++++++++++++++---- + crates/horizon-client/src/gfx/mod_views.rs | 69 ++++++--- + crates/horizon-mod-sdk/wit/horizon.wit | 10 ++ + crates/horizon-mods/src/api_level.rs | 6 +- + crates/horizon-mods/src/runtime.rs | 12 ++ + docs/api/mods-reference.md | 1 + + 9 files changed, 269 insertions(+), 60 deletions(-) + +diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs +index f21b3dcdd..0e779aaa0 100644 +--- a/crates/horizon-client/src/game.rs ++++ b/crates/horizon-client/src/game.rs +@@ -5950,7 +5950,7 @@ impl Game<'_> { + // the sleep overlay, debug screen and open screens still draw. + let spectator = self.spectator(); + let (wi, hi) = (w as i32, h as i32); +- let show_hud = !self.ui.hide_gui; ++ let show_hud = !self.ui.hide_gui && self.mod_main_view(self.clock.fraction()).is_none(); + // max(Hud.leftHeight, Hud.rightHeight) after the bars: the item name and + // the action bar sit above it. + let mut bar_height = 39; +@@ -11572,6 +11572,13 @@ impl Game<'_> { + let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch); + self.audio + .set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch); ++ // AirDef fork: a mod's camera as the screen's own view (replays): the listener stays the ++ // player's, everything seen is drawn from the camera with the full pipeline. ++ let main_view = self.mod_main_view(alpha); ++ let (camera, underwater, view_yaw, view_pitch) = match &main_view { ++ Some(v) => (v.camera, false, v.yaw, v.pitch), ++ None => (camera, underwater, view_yaw, view_pitch), ++ }; + let block = [ + camera.x.floor() as i32, + camera.y.floor() as i32, +@@ -11579,6 +11586,7 @@ impl Game<'_> { + ]; + // The camera inside an opaque block sees nothing through it: disable. + let cave_culling = self.settings.horizon.cave_culling ++ && main_view.is_none() + && !self + .res + .facts() +@@ -11670,7 +11678,11 @@ impl Game<'_> { + // player who may build. Adventure players only get one with a CAN_BREAK or + // CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the + // components nor opens menus for spectators, so those never show it. +- let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() { ++ let outline = if self.screen == Screen::Playing ++ && !self.ui.hide_gui ++ && main_view.is_none() ++ && self.may_build() ++ { + self.pick() + .map(|h| { + let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]); +@@ -11733,17 +11745,19 @@ impl Game<'_> { + }; + let fov_degrees = if title { + 85.0 ++ } else if let Some(v) = &main_view { ++ v.fov + } else { + self.fov_degrees(camera_alpha as f32, camera.to_array()) + }; + self.locator_camera = + (!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees)); +- let bob = if title { ++ let bob = if title || main_view.is_some() { + glam::Mat4::IDENTITY + } else { + self.bob_matrix(camera_alpha as f32) + }; +- let spin = if title { ++ let spin = if title || main_view.is_some() { + glam::Mat4::IDENTITY + } else { + self.spinning_matrix(camera_alpha as f32) +@@ -11791,7 +11805,7 @@ impl Game<'_> { + } else { + (Vec::new(), None) + }; +- let hand = if title || self.scoping() { ++ let hand = if title || self.scoping() || main_view.is_some() { + Vec::new() + } else { + self.hand_geometry(camera_alpha) +@@ -11829,7 +11843,12 @@ impl Game<'_> { + hud.patches.append(&mut self.map_patches); + let entity_spans = std::mem::take(self.entity_spans.get_mut()); + let glowing = std::mem::take(self.glowing_vertices.get_mut()); +- let mod_views = self.mod_views(alpha); ++ // The main view is the frame itself, not a picture of its own. ++ let mod_views: Vec<_> = self ++ .mod_views(alpha) ++ .into_iter() ++ .filter(|v| main_view.as_ref().is_none_or(|m| m.id != v.id)) ++ .collect(); + let hd_lights = self.mod_lights(camera, alpha); + let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha); + let frame = Frame { +diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs +index f5e68566a..7a4d742df 100644 +--- a/crates/horizon-client/src/game/mod_models.rs ++++ b/crates/horizon-client/src/game/mod_models.rs +@@ -71,6 +71,9 @@ impl Game<'_> { + pub(super) fn mod_views(&self, _alpha: f64) -> Vec { + Vec::new() + } ++ pub(super) fn mod_main_view(&self, _alpha: f64) -> Option { ++ None ++ } + pub(super) fn mod_hd(&self) -> Option { + None + } +@@ -300,6 +303,12 @@ impl Game<'_> { + .collect() + } + ++ /// The camera a mod asked the screen's world from (`render.set-main-view`), for this frame. ++ pub(super) fn mod_main_view(&self, alpha: f64) -> Option { ++ let id = self.mods.mods.main_view()?; ++ self.mod_views(alpha).into_iter().find(|v| v.id == id) ++ } ++ + /// Draws every mod model between its last two poses, lit by the world + /// (or at full brightness), its faces shaded by its own rotation. + pub(super) fn mod_model_geometry(&self, camera: DVec3, alpha: f64, out: &mut Vec) { +diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs +index 93a2f7c63..1a4e68b88 100644 +--- a/crates/horizon-client/src/gfx.rs ++++ b/crates/horizon-client/src/gfx.rs +@@ -3363,7 +3363,8 @@ impl Renderer { + view: &t.depth, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0.0), +- store: wgpu::StoreOp::Discard, ++ // The mod's post pass reads it. ++ store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), +@@ -3411,6 +3412,33 @@ impl Renderer { + pass.set_vertex_buffer(0, self.entity_buffer.slice(..)); + pass.draw_indexed(0..entity_quads * 6, 0, 0..1); + } ++ drop(pass); ++ // The mod's post pass with this camera: its blasts, flashes and glows in the picture, ++ // as much as the picture's size is worth. The picture's own camera and world are ++ // lit by the pass itself (no HD lighting here: it is the world's, not a picture's). ++ t.post = false; ++ if let Some(post) = &self.mod_post ++ && let Some((blasts, lights)) = mod_views::post_budget(t.size) ++ { ++ let mut params = self.mod_params.0.clone(); ++ if params.len() >= 4 { ++ // Mode 0: the pass lights the land and draws the smoke itself. ++ params[3] = 0.0; ++ params[1] = params[1].min(blasts as f32); ++ params[2] = params[2].min(lights as f32); ++ } ++ post.write_to( ++ &self.queue, ++ &t.post_buffer, ++ (d.view, d.projection, d.camera), ++ frame.sky.sun_angle, ++ t.size, ++ ¶ms, ++ self.mod_params.1, ++ ); ++ post.run_view(&self.device, encoder, &t.post_buffer, (&t.color, &t.depth), &t.fx); ++ t.post = true; ++ } + } + encoder.copy_buffer_to_buffer(&views.main, 0, &self.globals, 0, size); + } +@@ -3703,7 +3731,12 @@ impl Renderer { + } else { + self.config.format.remove_srgb_suffix() + }; +- self.mod_post = Some(mod_post::ModPost::new(&self.device, source, format)?); ++ self.mod_post = Some(mod_post::ModPost::new( ++ &self.device, ++ source, ++ format, ++ self.config.format.remove_srgb_suffix(), ++ )?); + self.mod_post_source = Some(source.to_owned()); + } + } +@@ -4424,6 +4457,8 @@ impl Renderer { + id: v.id, + view_proj, + view, ++ projection: world_projection(v.fov, aspect, frame.depth_far), ++ camera: v.camera, + aspect, + fov: v.fov, + visible: seen, +diff --git a/crates/horizon-client/src/gfx/mod_post.rs b/crates/horizon-client/src/gfx/mod_post.rs +index d1f54a4bb..3d67ff25b 100644 +--- a/crates/horizon-client/src/gfx/mod_post.rs ++++ b/crates/horizon-client/src/gfx/mod_post.rs +@@ -106,6 +106,8 @@ fn horizon_mod_post_fs(in: HorizonModPostOut) -> @location(0) vec4 { + + pub struct ModPost { + pipeline: wgpu::RenderPipeline, ++ /// The same pass onto the mod cameras' pictures (the surface's format). ++ view_pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, + buffer: wgpu::Buffer, + sampler: wgpu::Sampler, +@@ -144,6 +146,7 @@ impl ModPost { + device: &wgpu::Device, + source: &str, + format: wgpu::TextureFormat, ++ view_format: wgpu::TextureFormat, + ) -> Result { + let module = compile(source)?; + let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry { +@@ -189,31 +192,35 @@ impl ModPost { + label: Some("Mod post"), + source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)), + }); +- let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { +- label: Some("Mod post"), +- layout: Some(&pipeline_layout), +- vertex: wgpu::VertexState { +- module: &shader, +- entry_point: Some("horizon_mod_post_vs"), +- compilation_options: Default::default(), +- buffers: &[], +- }, +- primitive: Default::default(), +- depth_stencil: None, +- multisample: Default::default(), +- fragment: Some(wgpu::FragmentState { +- module: &shader, +- entry_point: Some("horizon_mod_post_fs"), +- compilation_options: Default::default(), +- targets: &[Some(wgpu::ColorTargetState { +- format, +- blend: None, +- write_mask: wgpu::ColorWrites::ALL, +- })], +- }), +- multiview_mask: None, +- cache: None, +- }); ++ let make = |format: wgpu::TextureFormat| { ++ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { ++ label: Some("Mod post"), ++ layout: Some(&pipeline_layout), ++ vertex: wgpu::VertexState { ++ module: &shader, ++ entry_point: Some("horizon_mod_post_vs"), ++ compilation_options: Default::default(), ++ buffers: &[], ++ }, ++ primitive: Default::default(), ++ depth_stencil: None, ++ multisample: Default::default(), ++ fragment: Some(wgpu::FragmentState { ++ module: &shader, ++ entry_point: Some("horizon_mod_post_fs"), ++ compilation_options: Default::default(), ++ targets: &[Some(wgpu::ColorTargetState { ++ format, ++ blend: None, ++ write_mask: wgpu::ColorWrites::ALL, ++ })], ++ }), ++ multiview_mask: None, ++ cache: None, ++ }) ++ }; ++ let pipeline = make(format); ++ let view_pipeline = make(view_format); + if let Some(e) = pollster::block_on(scope.pop()) { + return Err(e.to_string()); + } +@@ -231,6 +238,7 @@ impl ModPost { + }); + Ok(Self { + pipeline, ++ view_pipeline, + layout, + buffer, + sampler, +@@ -253,7 +261,30 @@ impl ModPost { + let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far) + * frame.bob + * frame.spin; +- let a = frame.sky.sun_angle.to_radians(); ++ self.write_to( ++ queue, ++ &self.buffer, ++ (view, projection, frame.camera), ++ frame.sky.sun_angle, ++ size, ++ params, ++ partial, ++ ); ++ } ++ ++ /// The same for another camera (a mod view's picture) into its own `buffer`. ++ #[allow(clippy::too_many_arguments)] ++ pub fn write_to( ++ &self, ++ queue: &wgpu::Queue, ++ buffer: &wgpu::Buffer, ++ (view, projection, camera): (Mat4, Mat4, glam::DVec3), ++ sun_angle: f32, ++ size: [u32; 2], ++ params: &[f32], ++ partial: f32, ++ ) { ++ let a = sun_angle.to_radians(); + let sun = [-a.sin(), a.cos(), 0.0, 0.0]; + let mut p = [[0.0f32; 4]; PARAMS / 4]; + for (i, v) in params.iter().take(PARAMS).enumerate() { +@@ -264,12 +295,7 @@ impl ModPost { + view_proj: m(projection * view), + view_inverse: m(view.inverse()), + projection_inverse: m(projection.inverse()), +- camera: [ +- frame.camera.x as f32, +- frame.camera.y as f32, +- frame.camera.z as f32, +- 0.0, +- ], ++ camera: [camera.x as f32, camera.y as f32, camera.z as f32, 0.0], + resolution: [ + size[0] as f32, + size[1] as f32, +@@ -285,7 +311,67 @@ impl ModPost { + sun, + params: p, + }; +- queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs)); ++ queue.write_buffer(buffer, 0, bytemuck::bytes_of(&inputs)); ++ } ++ ++ /// A buffer for one more camera's `write_to`. ++ pub fn new_buffer(device: &wgpu::Device) -> wgpu::Buffer { ++ device.create_buffer(&wgpu::BufferDescriptor { ++ label: Some("Mod post frame (view)"), ++ size: size_of::() as u64, ++ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, ++ mapped_at_creation: false, ++ }) ++ } ++ ++ /// Draws the pass over a mod camera's picture (`world`: colour and depth, `buffer`: what ++ /// `write_to` filled) onto `target`, in the surface's format. ++ pub fn run_view( ++ &self, ++ device: &wgpu::Device, ++ encoder: &mut wgpu::CommandEncoder, ++ buffer: &wgpu::Buffer, ++ world: (&wgpu::TextureView, &wgpu::TextureView), ++ target: &wgpu::TextureView, ++ ) { ++ let group = device.create_bind_group(&wgpu::BindGroupDescriptor { ++ label: Some("Mod post (view)"), ++ layout: &self.layout, ++ entries: &[ ++ wgpu::BindGroupEntry { ++ binding: 0, ++ resource: buffer.as_entire_binding(), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 1, ++ resource: wgpu::BindingResource::TextureView(world.0), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 2, ++ resource: wgpu::BindingResource::TextureView(world.1), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 3, ++ resource: wgpu::BindingResource::Sampler(&self.sampler), ++ }, ++ ], ++ }); ++ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { ++ label: Some("Mod post (view)"), ++ color_attachments: &[Some(wgpu::RenderPassColorAttachment { ++ view: target, ++ resolve_target: None, ++ depth_slice: None, ++ ops: wgpu::Operations { ++ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), ++ store: wgpu::StoreOp::Store, ++ }, ++ })], ++ ..Default::default() ++ }); ++ pass.set_pipeline(&self.view_pipeline); ++ pass.set_bind_group(0, &group, &[]); ++ pass.draw(0..3, 0..1); + } + + /// Draws the pass over the world target `world` (colour, depth) onto `screen`. +diff --git a/crates/horizon-client/src/gfx/mod_views.rs b/crates/horizon-client/src/gfx/mod_views.rs +index aa2c5b971..bb65480ea 100644 +--- a/crates/horizon-client/src/gfx/mod_views.rs ++++ b/crates/horizon-client/src/gfx/mod_views.rs +@@ -26,15 +26,34 @@ pub(super) const PER_FRAME: usize = 3; + pub(super) const RADIUS: i32 = 12; + + pub(super) struct Target { +- size: [u32; 2], ++ pub size: [u32; 2], + pub color: wgpu::TextureView, + pub depth: wgpu::TextureView, + pub globals: wgpu::Buffer, + blit: wgpu::BindGroup, ++ /// The picture after the mod's post pass (`post`), and what shows it. ++ pub fx: wgpu::TextureView, ++ blit_fx: wgpu::BindGroup, ++ /// The post pass's uniform for this camera. ++ pub post_buffer: wgpu::Buffer, ++ /// The picture shown went through the post pass this time. ++ pub post: bool, + /// Drawn at least once: the HUD shows nothing before. + pub drawn: bool, + } + ++/// How much of the mod's post pass a picture of `size` pays for: (blasts, lights) the pass ++/// marches, or None for a picture too small to see them in. Full-screen pictures get all. ++pub(super) fn post_budget(size: [u32; 2]) -> Option<(usize, usize)> { ++ let area = size[0] as u64 * size[1] as u64; ++ match area { ++ 0..4096 => None, ++ 4096..150_000 => Some((4, 12)), ++ 150_000..600_000 => Some((12, 32)), ++ _ => Some((48, 79)), ++ } ++} ++ + /// A camera drawn this frame. + pub(super) struct Due { + pub id: u32, +@@ -42,6 +61,9 @@ pub(super) struct Due { + /// world's other geometry are. + pub view_proj: Mat4, + pub view: Mat4, ++ /// Its own projection and place, for the mod's post pass. ++ pub projection: Mat4, ++ pub camera: DVec3, + pub aspect: f32, + pub fov: f32, + pub visible: Vec, +@@ -239,22 +261,31 @@ impl Views { + let depth = texture( + "Mod view depth", + wgpu::TextureFormat::Depth32Float, +- wgpu::TextureUsages::RENDER_ATTACHMENT, ++ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + ); +- let blit = device.create_bind_group(&wgpu::BindGroupDescriptor { +- label: Some("Mod view picture"), +- layout: &self.layout, +- entries: &[ +- wgpu::BindGroupEntry { +- binding: 0, +- resource: wgpu::BindingResource::TextureView(&color), +- }, +- wgpu::BindGroupEntry { +- binding: 1, +- resource: wgpu::BindingResource::Sampler(&self.sampler), +- }, +- ], +- }); ++ let fx = texture( ++ "Mod view post", ++ self.format, ++ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, ++ ); ++ let group = |view: &wgpu::TextureView| { ++ device.create_bind_group(&wgpu::BindGroupDescriptor { ++ label: Some("Mod view picture"), ++ layout: &self.layout, ++ entries: &[ ++ wgpu::BindGroupEntry { ++ binding: 0, ++ resource: wgpu::BindingResource::TextureView(view), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 1, ++ resource: wgpu::BindingResource::Sampler(&self.sampler), ++ }, ++ ], ++ }) ++ }; ++ let blit = group(&color); ++ let blit_fx = group(&fx); + let globals = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Mod view globals"), + size: globals_size, +@@ -267,6 +298,10 @@ impl Views { + depth, + globals, + blit, ++ fx, ++ blit_fx, ++ post_buffer: super::mod_post::ModPost::new_buffer(device), ++ post: false, + drawn: false, + } + } +@@ -306,7 +341,7 @@ impl Views { + pass.set_viewport(x, y, w, h, 0.0, 1.0); + pass.set_scissor_rect(x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32); + pass.set_pipeline(&self.pipeline); +- pass.set_bind_group(0, &t.blit, &[]); ++ pass.set_bind_group(0, if t.post { &t.blit_fx } else { &t.blit }, &[]); + let mode = view.mode.min(2); + pass.draw(0..3, mode..mode + 1); + } +diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit +index fba8f162d..bae0d2134 100644 +--- a/crates/horizon-mod-sdk/wit/horizon.wit ++++ b/crates/horizon-mod-sdk/wit/horizon.wit +@@ -664,6 +664,16 @@ interface render { + /// Replaces the mod's cameras: up to 8. A `view` shape shows one; the + /// game renders up to three a frame, in turn. + set-views: func(views: list); ++ /// Renders the screen's world from one of the `set-views` cameras ++ /// (AirDef fork), with everything the player's own view has: the HD ++ /// lighting, sky, clouds, fog, smoke, TAA and the post passes, at the ++ /// window's resolution. The picture size of that camera is ignored, the ++ /// player's hand, block outline and the game's HUD are not drawn (the mod's ++ /// own `set-hud` shapes are); a `view` shape of that camera draws nothing. ++ /// For a full-screen replay or a director's camera; the small pictures of ++ /// monitors stay `view` shapes. None (or an unknown id) is the player's ++ /// own view. The chunks stay those round the player. ++ set-main-view: func(view: option); + /// A light the mod's things give off (AirDef fork): rocket flames, + /// flares, blasts. Drawn by the HD lighting (`set-lighting`). + record point-light { +diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs +index f438eadbd..e578de688 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 = 14; ++pub const LEVEL: u32 = 15; + + /// 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); 68] = [ ++pub const FUNCTIONS: [(&str, u32); 69] = [ + ("log.write", 0), + ("game.get-player", 0), + ("game.get-clock", 0), +@@ -86,6 +86,8 @@ pub const FUNCTIONS: [(&str, u32); 68] = [ + ("render.set-time-override", 13), + // AirDef fork: volumetric smoke and exhaust trails. + ("render.set-smoke", 14), ++ // AirDef fork: a mod camera as the screen's own view. ++ ("render.set-main-view", 15), + ]; + + /// 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 495ef6cae..74da34673 100644 +--- a/crates/horizon-mods/src/runtime.rs ++++ b/crates/horizon-mods/src/runtime.rs +@@ -506,6 +506,8 @@ struct State { + post_params: Vec, + /// `render.set-views`' cameras (AirDef fork). + views: Vec, ++ /// `render.set-main-view`: the camera the screen's world is rendered from. ++ main_view: Option, + /// `render.set-lights` and `render.set-lighting` (AirDef fork). + lights: Vec, + lighting: Option, +@@ -754,6 +756,7 @@ impl Mods { + post: None, + post_params: Vec::new(), + views: Vec::new(), ++ main_view: None, + lights: Vec::new(), + lighting: None, + time_override: None, +@@ -1363,6 +1366,12 @@ impl Mods { + t.smoke_active = on; + } + } ++ /// The camera the first running mod asked the screen's world from, if any. ++ pub fn main_view(&self) -> Option { ++ self.entries ++ .iter() ++ .find_map(|e| e.run.as_ref()?.store.data().main_view) ++ } + /// The time of day the first running mod asked to show, if any. + pub fn time_override(&self) -> Option { + self.entries +@@ -2230,6 +2239,9 @@ impl render::Host for State { + }) + .collect(); + } ++ fn set_main_view(&mut self, view: Option) { ++ self.main_view = view; ++ } + fn set_lights(&mut self, lights: Vec) { + let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0; + let unit = |v: f32| { +diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md +index fefc4903f..1bd2c7eae 100644 +--- a/docs/api/mods-reference.md ++++ b/docs/api/mods-reference.md +@@ -667,6 +667,7 @@ A piece of smoke or exhaust trail (AirDef fork): a tube from `a` to `b` (equal e + | `clear-post` | `func()` | Removes this mod's post pass: the world is shown as the game draws it. | + | `set-params` | `func(params: list)` | The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until replaced; call it once a tick (the pass gets the partial tick in `frame.time.y`). | + | `set-views` | `func(views: list)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. | ++| `set-main-view` | `func(view: option)` | Renders the screen's world from one of the `set-views` cameras (AirDef fork), with everything the player's own view has: the HD lighting, sky, clouds, fog, smoke, TAA and the post passes, at the window's resolution. The picture size of that camera is ignored, the player's hand, block outline and the game's HUD are not drawn (the mod's own `set-hud` shapes are); a `view` shape of that camera draws nothing. For a full-screen replay or a director's camera; the small pictures of monitors stay `view` shapes. None (or an unknown id) is the player's own view. The chunks stay those round the player. | + | `set-lights` | `func(lights: list)` | 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)` | 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. | +-- +2.54.0 (Apple Git-157) +