From 76f680eba64b64eb28435e71ce9f22a84b0e5da1 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 09:28:13 +0300 Subject: [PATCH] Searchlights with real spot lights and volumetric beams (#60); replay director: several angles per event, slow-motion kills, /airdef replay film and export to MP4 (#58) (fork patch 32) Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + airdef-mod/src/film.rs | 575 ++++ airdef-mod/src/lib.rs | 220 +- airdef-mod/src/replay.rs | 182 +- airdef-mod/src/sim/lights.rs | 58 +- airdef-mod/src/sim/mod.rs | 15 +- airdef-mod/src/sim/terrain.rs | 10 + docs/HD-RENDERER.md | 19 +- ...ork-world-models-client-particles-so.patch | 2 +- ...izon-mod-report-particles-and-sounds.patch | 2 +- ...-fork-full-screen-post-pass-for-mods.patch | 2 +- ...far-models-as-scaled-impostors-past-.patch | 2 +- ...er.set-views-draws-the-world-from-ot.patch | 2 +- ...er-tick-and-per-event-AirDef-plans-r.patch | 2 +- ...-up-to-1920x1080-full-screen-replays.patch | 2 +- ...ant-terrain-tiles-fog-measured-from-.patch | 2 +- ...id-so-mods-keep-state-per-world-API-.patch | 2 +- ...d-trial-runs-behind-feature-full-so-.patch | 2 +- ...ffer-pass-per-pixel-mod-point-lights.patch | 2 +- ...e-2-sun-and-moon-shadow-cascades-the.patch | 2 +- ...ached-until-a-resize-or-a-new-source.patch | 2 +- ...-per-16x16-tile-surface-and-halo-lis.patch | 2 +- ...pass-in-the-F3-timing-list-half-reso.patch | 2 +- ...only-sun-facing-quads-and-skip-burie.patch | 2 +- ...es-render.model-id-model-generation-.patch | 2 +- ...aling-HORIZON_HD_SCALE-world-G-buffe.patch | 2 +- ...-pass-colour-normal-depth-the-floor-.patch | 2 +- ...lla-texture-on-the-surface-the-biome.patch | 2 +- ...-a-physical-atmosphere-Rayleigh-Mie-.patch | 2 +- ...r-blue-a-little-clearer-ripples-a-li.patch | 2 +- ...sunsets-Rec.-2020-atmosphere-sun-tin.patch | 2 +- ...es-for-simulations-render.query-rays.patch | 2 +- ...vel-from-the-world-not-from-rain-geo.patch | 2 +- ...dules-gfx-hd-one-Rust-file-and-one-W.patch | 2 +- ...scales-that-divide-the-camera-s-6553.patch | 2 +- ...oke-and-missile-trails-with-clear-tu.patch | 2 +- ...ited-by-depth-so-terrain-and-vehicle.patch | 2 +- ...rails-drawn-at-least-as-wide-as-a-gr.patch | 2 +- ...main-view-API-level-15-renders-the-s.patch | 2 +- ...mods-render.set-spot-lights-API-leve.patch | 2736 +++++++++++++++++ 40 files changed, 3827 insertions(+), 52 deletions(-) create mode 100644 airdef-mod/src/film.rs create mode 100644 horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch diff --git a/README.md b/README.md index db815cb..a5bf19b 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ API мода `render.set-lights` (до 512, плавно интерполиру Команды: - `/airdef wave <тип> [кол-во] [дальность]` — налёт на ближайшую цель. Типы: `shahed`, `geran3`, `lancet`, `gerbera`, `kh101`, `kalibr`, `kh22`, `zircon`, `iskander`, `kinzhal`, `oreshnik` и `mix` (комбинированный удар двумя секторами: «Герберы», шахеды, Герани-3, Х-101, «Калибры», Х-22, Циркон, «Искандер», «Кинжал», при 30+ — «Орешник»); - `/airdef replay [map|full|reel|stop]` — повтор последнего налёта; +- `/airdef replay film [best|all|N]` — фильм режиссёра: для каждого перехвата/прилёта несколько планов (комплекс, пуск, погоня за ракетой, взгляд с цели, замедленный крупный план поражения, общий план после), склейки, плавные движения камеры, камера не уходит под землю; играется в максимальном качестве (HD, мир в полном разрешении, больше шагов облаков и тумана; настройки возвращаются после); +- `/airdef replay export [имя] [best|all|N] [fps=60] [size=1920x1080] [crf=14] [sec=N] [noaudio]` — тот же фильм кадр за кадром с фиксированным шагом (скорость компьютера не важна) в MP4 с звуком: `/airdef-films/<имя>.mp4` (нужен `ffmpeg` в PATH или `HORIZON_FFMPEG`; без него — PNG-последовательность и готовая команда ffmpeg). Прогресс в чате, отмена — `/airdef replay stop`. Окно игры держите в фокусе (пауза останавливает съёмку); чат попадает в кадр. - `/airdef import` — перенести в текущий мир комплексы и цели из старой версии мода (до 0.2.x они были общими для всех миров); - `/airdef raid` — налёт, настроенный в меню; `/airdef target here` — отметить объект под ногами; `/airdef repair` — ремонт; - `/airdef roles` — показать, по каким классам целей работает каждый комплекс; `/airdef roles <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`; diff --git a/airdef-mod/src/film.rs b/airdef-mod/src/film.rs new file mode 100644 index 0000000..9797c33 --- /dev/null +++ b/airdef-mod/src/film.rs @@ -0,0 +1,575 @@ +//! The replay director: a raid's best moments planned as a film. Every event becomes a few shots +//! from different angles (the launcher, behind the interceptor, ahead of the target, a slow-motion +//! close-up at the kill, a wide aftermath), joined by cuts, with eased camera moves and the time +//! slowed round the moment itself. +//! +//! The plan is made once, from the recording alone: nothing random, nothing read from the live +//! raid, so the same film comes out every time. The land is asked for before the film starts (the +//! planner needs the heights under every camera and along every line of sight); each shot has a +//! few candidate placements, and the one the land hides least is kept; the camera is lifted over +//! the ground wherever it would dip into it. Only what the recording knows is shown: a launch is +//! the interceptor's first point, a kill the recorded event; no lock-on or detection is invented. +use crate::replay::{interest, title}; +use crate::sim::record::{EventKind, Recording, STEP}; +use crate::sim::Ground; +use glam::DVec3; +use std::collections::{HashMap, HashSet}; + +/// Seconds of film in one game tick. +pub const TICK_S: f64 = 0.05; +/// Seconds between the samples the planner checks a shot at. +const SAMPLE_S: f64 = 0.5; +/// Ticks the planner waits for the land's answers. +const PREP_TICKS: u32 = 160; +/// A land lattice cell, as the ground samples it. +const CELL: f64 = 8.0; +/// Seconds of black at the start and the end. +const FADE_S: f64 = 0.7; + +/// Which moments a film is made of. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Which { + /// The three best. + Best, + /// Every worthwhile one. + All, + /// The n best. + Top(usize), +} + +#[derive(Clone, Debug)] +enum Aim { + Track(u32, DVec3), + Point(DVec3), + /// Between two tracks (the interceptor and its target): both stay in frame. + Mid(u32, u32, DVec3), +} + +#[derive(Clone, Debug)] +enum Eye { + /// From one place to another, eased over the shot. + Dolly(DVec3, DVec3), + /// Behind a track, a little above and to its side. + Behind { track: u32, back: f64, up: f64, side: f64 }, + /// In front of a track, looking back at what comes. + Ahead { track: u32, ahead: f64, up: f64, side: f64 }, + Static(DVec3), +} + +#[derive(Clone, Debug)] +struct Rig { + eye: Eye, + aim: Aim, + fov: (f32, f32), +} + +struct Shot { + start: f64, + len: f64, + /// The replay's tick at every `TICK_S` of the shot. + table: Vec, + variants: Vec, + pick: usize, + /// The least height the eye keeps, at the planner's samples. + clear: Vec, + head: String, + sub: String, + event: usize, +} + +/// Where the camera is, and what the replay shows, at a moment of the film. +pub struct Frame { + pub shot: usize, + pub eye: DVec3, + pub look: DVec3, + pub fov: f32, + /// The replay's tick. + pub t: f64, + pub event: usize, + /// 0 to 1 how visible the caption is. + pub caption: f32, + /// 0 to 1 how black the picture is (the film's start and end). + pub black: f32, +} + +pub struct Film { + shots: Vec, + pub len: f64, + cells: Vec<(i32, i32)>, + heights: HashMap<(i32, i32), Option>, + waited: u32, + pub ready: bool, +} + +fn smooth(x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + x * x * (3.0 - 2.0 * x) +} + +fn cell(p: DVec3) -> (i32, i32) { + ((p.x / CELL).floor() as i32, (p.z / CELL).floor() as i32) +} +fn cell_centre(c: (i32, i32)) -> DVec3 { + DVec3::new((c.0 as f64 + 0.5) * CELL, 0.0, (c.1 as f64 + 0.5) * CELL) +} + +/// Where a track was at `t`, held at its ends. +fn at(rec: &Recording, id: u32, t: f64) -> Option { + let tr = rec.track(id)?; + tr.at(rec.origin, t.clamp(tr.first as f64, tr.last() as f64)) +} +/// Its heading, from a window wide enough not to shake with the recording's steps. +fn heading(rec: &Recording, id: u32, t: f64) -> DVec3 { + let w = 3.0 * STEP as f64; + match (at(rec, id, t - w), at(rec, id, t + w)) { + (Some(a), Some(b)) => (b - a).normalize_or(DVec3::NEG_Z), + _ => DVec3::NEG_Z, + } +} +/// Horizontal, across the line from `from` to `at`. +fn across(at: DVec3, from: DVec3) -> DVec3 { + DVec3::new(-(at - from).z, 0.0, (at - from).x).normalize_or(DVec3::X) +} + +impl Aim { + fn at(&self, rec: &Recording, t: f64) -> DVec3 { + match self { + Aim::Track(id, fallback) => at(rec, *id, t).unwrap_or(*fallback), + Aim::Point(p) => *p, + Aim::Mid(a, b, fallback) => match (at(rec, *a, t), at(rec, *b, t)) { + (Some(a), Some(b)) => a.lerp(b, 0.5), + (Some(p), None) | (None, Some(p)) => p, + _ => *fallback, + }, + } + } +} + +impl Rig { + /// The eye, the look-at point and the field of view at replay tick `t`, a fraction `f` into the shot. + fn at(&self, rec: &Recording, t: f64, f: f64) -> (DVec3, DVec3, f32) { + let look = self.aim.at(rec, t); + let eye = match &self.eye { + Eye::Dolly(a, b) => a.lerp(*b, smooth(f)), + Eye::Static(p) => *p, + Eye::Behind { track, back, up, side } => match at(rec, *track, t) { + Some(p) => { + let d = heading(rec, *track, t); + let s = across(p + d, p); + p - d * *back + DVec3::Y * *up + s * *side + } + None => look + DVec3::new(30.0, 10.0, 30.0), + }, + Eye::Ahead { track, ahead, up, side } => match at(rec, *track, t) { + Some(p) => { + let d = heading(rec, *track, t); + let s = across(p + d, p); + p + d * *ahead + DVec3::Y * *up + s * *side + } + None => look + DVec3::new(30.0, 10.0, 30.0), + }, + }; + let fov = self.fov.0 + (self.fov.1 - self.fov.0) * smooth(f) as f32; + (eye, look, fov) + } +} + +impl Shot { + fn new(a: f64, b: f64, profile: &[(f64, f64)], variants: Vec, head: &str, sub: &str, event: usize) -> Shot { + // Replay ticks a game tick of film plays: the profile's speed at the replay's moment. + let speed = |t: f64| -> f64 { + let mut v = profile.first().map_or(1.0, |p| p.1); + for w in profile.windows(2) { + if t >= w[0].0 && t <= w[1].0 { + let k = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9); + v = w[0].1 + (w[1].1 - w[0].1) * k; + } else if t > w[1].0 { + v = w[1].1; + } + } + v.max(0.02) + }; + let mut table = vec![a]; + let mut t = a; + while t < b && table.len() < 4000 { + t = (t + speed(t)).min(b); + table.push(t); + } + if table.len() < 2 { + table.push(b.max(a)); + } + let len = (table.len() - 1) as f64 * TICK_S; + Shot { start: 0.0, len, table, variants, pick: 0, clear: Vec::new(), head: head.into(), sub: sub.into(), event } + } + + /// The replay's tick `local` seconds into the shot. + fn t_at(&self, local: f64) -> f64 { + let k = (local / TICK_S).clamp(0.0, (self.table.len() - 1) as f64); + let i = (k.floor() as usize).min(self.table.len() - 1); + let j = (i + 1).min(self.table.len() - 1); + self.table[i] + (self.table[j] - self.table[i]) * (k - i as f64) + } + + fn samples(&self) -> usize { + ((self.len / SAMPLE_S).ceil() as usize + 1).clamp(3, 17) + } +} + +/// The events a film of `which` is made of, in the order they happened. +pub fn pick(rec: &Recording, which: Which) -> Vec { + let (n, floor) = match which { + Which::Best => (3, 2.0), + Which::All => (12, 2.0), + Which::Top(n) => (n.clamp(1, 12), 2.0), + }; + let mut best: Vec<(f64, usize)> = (0..rec.events.len()).map(|i| (interest(rec, i), i)).filter(|b| b.0 >= floor).collect(); + best.sort_by(|a, b| b.0.total_cmp(&a.0)); + let mut chosen: Vec = Vec::new(); + for (_, i) in best { + if chosen.len() >= n { + break; + } + let at = rec.events[i].at as i64; + if chosen.iter().all(|&c| (rec.events[c].at as i64 - at).abs() > 160) { + chosen.push(i); + } + } + chosen.sort_by_key(|&i| rec.events[i].at); + chosen +} + +/// The four placements a shot is tried in: either side, low and lifted. +fn placements Rig>(mk: F) -> Vec { + vec![mk(1.0, 0.0), mk(-1.0, 0.0), mk(1.0, 28.0), mk(-1.0, 28.0)] +} + +/// The shots of event `i`. +fn plan_event(rec: &Recording, i: usize) -> Vec { + let e = rec.events[i]; + let te = e.at as f64; + let o = rec.origin; + let end = rec.len as f64; + let pos = e.pos; + let mut shots: Vec = Vec::new(); + let mut add = |shot: Shot| shots.push(shot); + // The slow moment: half speed into it, a tenth through it, back to normal after. + let slow = |lead: f64, after: f64| -> Vec<(f64, f64)> { + vec![(te - lead, 0.6), (te - 4.0, 0.15), (te + 6.0, 0.15), (te + 22.0, 0.6), (te + after, 1.0)] + }; + let kill_close = |from: DVec3, aim_id: Option, head: &str, sub: String| -> Shot { + let a = (te - 10.0).max(0.0); + let b = (te + 48.0).min(end).max(a + 4.0); + let variants = placements(|side, lift| { + let s = across(pos, from) * side; + let near = pos + s * 42.0 + DVec3::Y * (6.0 + lift * 0.4); + let closer = pos + s * 26.0 + DVec3::Y * (4.0 + lift * 0.3); + Rig { + eye: Eye::Dolly(near, closer), + aim: aim_id.map_or(Aim::Point(pos), |id| Aim::Track(id, pos)), + fov: (44.0, 30.0), + } + }); + Shot::new(a, b, &slow(10.0, 48.0), variants, head, &sub, i) + }; + let aftermath = |from: DVec3, head: &str| -> Option { + let a = (te + 48.0).min(end); + let b = (te + 150.0).min(end); + if b - a < 20.0 { + return None; + } + let variants = placements(|side, lift| { + let s = across(pos, from) * side; + Rig { + eye: Eye::Dolly(pos + s * 90.0 + DVec3::Y * (34.0 + lift), pos + s * 150.0 + DVec3::Y * (52.0 + lift)), + aim: Aim::Point(pos + DVec3::Y * 6.0), + fov: (58.0, 52.0), + } + }); + Some(Shot::new(a, b, &[(a, 1.0), (b, 1.4)], variants, head, "", i)) + }; + match e.kind { + EventKind::Kill { drone, kind, sam, sam_kind, sam_index, missile } => { + let sp = rec.sam(sam).map_or(pos - DVec3::new(150.0, 0.0, 0.0), |s| s.1); + let name = format!("{} №{}", sam_kind.name(), sam_index + 1); + let sub = format!("Перехват «{}»", kind.spec().name); + let mt = missile.and_then(|m| rec.track(m).map(|t| (m, t.first as f64))); + match mt { + Some((m, tl)) => { + // The hero: the launcher, three-quarters, a little before the launch. + let a = (tl - 60.0).max(0.0); + let b = tl + 6.0; + let launcher = sp + DVec3::Y * 4.0; + let variants = placements(|side, lift| { + let dir = (pos - sp).with_y(0.0).normalize_or(DVec3::X); + let s = across(pos, sp) * side; + let base = sp - dir * 38.0 + s * 34.0; + Rig { + eye: Eye::Dolly(base + DVec3::Y * (7.0 + lift), base + s * 6.0 - dir * 4.0 + DVec3::Y * (9.0 + lift)), + aim: Aim::Point(launcher), + fov: (46.0, 40.0), + } + }); + add(Shot::new(a, b.max(a + 20.0), &[(a, 1.0)], variants, &name, &sub, i)); + // The launch, from the side, the camera low and the interceptor climbing away. + let a2 = b.max(a + 20.0); + let b2 = (a2 + 44.0).min(te - 20.0).max(a2 + 10.0); + let variants = placements(|side, lift| { + let s = across(pos, sp) * side; + Rig { eye: Eye::Static(sp + s * 46.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(m, sp), fov: (42.0, 24.0) } + }); + add(Shot::new(a2, b2, &[(a2, 1.0)], variants, "Пуск", "", i)); + // The flight, from behind the interceptor with the target ahead; long flights run faster. + let a3 = b2; + let b3 = te - 32.0; + if b3 - a3 > 14.0 { + let sp3 = (((b3 - a3) / 6.0) / 20.0).clamp(1.0, 4.0); + let variants = placements(|side, lift| Rig { + eye: Eye::Behind { track: m, back: 15.0, up: 3.5 + lift * 0.2, side: 5.0 * side }, + aim: Aim::Track(drone, pos), + fov: (56.0, 56.0), + }); + add(Shot::new(a3, b3, &[(a3, sp3)], variants, "Сближение", "", i)); + } + // Ahead of the target, looking back at the interceptor closing. + let a4 = te - 32.0; + let b4 = te - 10.0; + let variants = placements(|side, lift| Rig { + eye: Eye::Ahead { track: drone, ahead: 48.0, up: 6.0 + lift * 0.3, side: 14.0 * side }, + aim: Aim::Mid(m, drone, pos), + fov: (46.0, 38.0), + }); + add(Shot::new(a4.max(0.0), b4.max(a4 + 6.0), &[(a4, 0.6)], variants, "", "", i)); + } + None => { + // A gun kill: the gun's view of the target. + let a = (te - 110.0).max(0.0); + let b = te - 10.0; + let variants = placements(|side, lift| { + let s = across(pos, sp) * side; + Rig { eye: Eye::Static(sp + s * 14.0 + DVec3::Y * (5.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (34.0, 18.0) } + }); + add(Shot::new(a, b.max(a + 12.0), &[(a, 1.0)], variants, &name, &sub, i)); + } + } + let high = (pos.y - sp.y).max(0.0); + add(kill_close(sp, Some(drone), "Поражение цели", format!("высота {high:.0} м"))); + if let Some(s) = aftermath(sp, "") { + add(s); + } + } + EventKind::Hit { drone, .. } | EventKind::Crash { drone, .. } => { + let from = rec.track(drone).and_then(|t| t.at(o, te - 60.0)).unwrap_or(o); + let head = title(rec, i); + let a = (te - 150.0).max(0.0); + let b = te - 55.0; + let variants = placements(|side, lift| Rig { + eye: Eye::Behind { track: drone, back: 16.0, up: 4.0 + lift * 0.2, side: 6.0 * side }, + aim: Aim::Point(pos), + fov: (55.0, 55.0), + }); + add(Shot::new(a, b.max(a + 16.0), &[(a, 1.4)], variants, &head, "", i)); + let a2 = te - 55.0; + let variants = placements(|side, lift| { + let s = across(pos, from) * side; + Rig { eye: Eye::Static(pos + s * 80.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (40.0, 28.0) } + }); + add(Shot::new(a2.max(0.0), (te - 10.0).max(a2 + 8.0), &[(a2, 0.8)], variants, "", "", i)); + let sub = match e.kind { + EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => { + format!("сила взрыва: {}", ["хлопок", "средний", "сильный", "тяжёлый", "мощнейший"][(power as usize).min(4)]) + } + _ => String::new(), + }; + add(kill_close(from, None, "Прилёт", sub)); + if let Some(s) = aftermath(from, "") { + add(s); + } + } + EventKind::SamLost { .. } => { + let from = pos + DVec3::new(300.0, 0.0, 0.0); + let a = (te - 70.0).max(0.0); + let variants = placements(|side, lift| { + let s = across(pos, from) * side; + Rig { eye: Eye::Dolly(pos + s * 70.0 + DVec3::Y * (12.0 + lift), pos + s * 52.0 + DVec3::Y * (9.0 + lift)), aim: Aim::Point(pos + DVec3::Y * 3.0), fov: (50.0, 44.0) } + }); + add(Shot::new(a, (te - 10.0).max(a + 10.0), &[(a, 1.0)], variants, &title(rec, i), "", i)); + add(kill_close(from, None, "Комплекс уничтожен", String::new())); + if let Some(s) = aftermath(from, "") { + add(s); + } + } + EventKind::Split { .. } => { + let ground = DVec3::new(pos.x, o.y, pos.z); + let a = (te - 40.0).max(0.0); + let b = (te + 170.0).min(end); + let variants = placements(|side, lift| Rig { + eye: Eye::Dolly(ground + DVec3::new(170.0 * side, 8.0 + lift, 40.0), ground + DVec3::new(150.0 * side, 14.0 + lift, 20.0)), + aim: Aim::Point(pos.lerp(ground, 0.4)), + fov: (60.0, 52.0), + }); + add(Shot::new(a, b.max(a + 20.0), &[(a, 1.0), (te - 4.0, 0.3), (te + 40.0, 1.0)], variants, &title(rec, i), "", i)); + } + } + shots +} + +impl Film { + /// The film of the best moments of recording `rec`, or None when there is nothing to show. + pub fn build(rec: &Recording, which: Which) -> Option { + let events = pick(rec, which); + if events.is_empty() { + return None; + } + let mut shots: Vec = Vec::new(); + for i in events { + shots.extend(plan_event(rec, i)); + } + let mut start = 0.0; + for s in &mut shots { + s.start = start; + start += s.len; + } + let len = start; + // Every land cell the planner will look at: under each sample's eye and along its line of sight. + let mut cells: HashSet<(i32, i32)> = HashSet::new(); + for s in &shots { + let n = s.samples(); + for rig in &s.variants { + for k in 0..n { + let f = k as f64 / (n - 1) as f64; + let (eye, look, _) = rig.at(rec, s.t_at(f * s.len), f); + cells.insert(cell(eye)); + let steps = ((eye.distance(look) / CELL) as usize).min(40); + for j in 1..steps { + cells.insert(cell(eye.lerp(look, j as f64 / steps as f64))); + } + } + } + } + let mut cells: Vec<(i32, i32)> = cells.into_iter().collect(); + cells.sort_unstable(); + Some(Film { shots, len, cells, heights: HashMap::new(), waited: 0, ready: false }) + } + + /// Asks the game for the land the plan needs; true once the shots are placed. Call every tick. + pub fn prepare(&mut self, rec: &Recording, ground: &mut Ground) -> bool { + if self.ready { + return true; + } + let mut pending = 0; + for c in &self.cells { + if self.heights.contains_key(c) { + continue; + } + match ground.land_state(cell_centre(*c)) { + Some(h) => { + self.heights.insert(*c, h); + } + None => pending += 1, + } + } + self.waited += 1; + if pending == 0 || self.waited > PREP_TICKS { + self.place(rec); + self.ready = true; + } + self.ready + } + + /// How far the land's answers are, for a progress line. + pub fn progress(&self) -> f32 { + if self.cells.is_empty() { 1.0 } else { self.heights.len() as f32 / self.cells.len() as f32 } + } + + /// Picks each shot's placement: the one with the fewest hidden samples (a ridge across the line of sight). + fn place(&mut self, rec: &Recording) { + let land = |h: &HashMap<(i32, i32), Option>, p: DVec3| h.get(&cell(p)).copied().flatten(); + for s in &mut self.shots { + let n = s.samples(); + let mut best = (usize::MAX, 0usize); + for (v, rig) in s.variants.iter().enumerate() { + let mut hidden = 0; + for k in 0..n { + let f = k as f64 / (n - 1) as f64; + let (eye, look, _) = rig.at(rec, s.t_at(f * s.len), f); + let steps = ((eye.distance(look) / CELL) as usize).min(40); + // The camera inside the ground counts a little: it is lifted, but a better place is kept. + if land(&self.heights, eye).is_some_and(|g| eye.y < g + 1.5) { + hidden += 1; + } + for j in 1..steps.saturating_sub(1) { + let u = j as f64 / steps as f64; + let p = eye.lerp(look, u); + if land(&self.heights, p).is_some_and(|g| g > p.y - 1.0) { + hidden += 3; + break; + } + } + } + if hidden < best.0 { + best = (hidden, v); + } + } + s.pick = best.1; + let rig = &s.variants[s.pick]; + s.clear = (0..n) + .map(|k| { + let f = k as f64 / (n - 1) as f64; + let (eye, _, _) = rig.at(rec, s.t_at(f * s.len), f); + land(&self.heights, eye).map_or(f32::MIN, |g| g as f32 + 3.0) + }) + .collect(); + } + } + + /// The shot at video time `tau`, with its local time. + fn locate(&self, tau: f64) -> (usize, f64) { + let i = self.shots.iter().rposition(|s| s.start <= tau).unwrap_or(0); + (i, (tau - self.shots[i].start).clamp(0.0, self.shots[i].len)) + } + + /// The camera and the replay's moment at video time `tau` (seconds from the film's start). + pub fn frame(&self, rec: &Recording, tau: f64) -> Frame { + let (i, local) = self.locate(tau); + let s = &self.shots[i]; + let f = if s.len > 0.0 { local / s.len } else { 0.0 }; + let t = s.t_at(local); + let (mut eye, look, fov) = s.variants[s.pick.min(s.variants.len() - 1)].at(rec, t, f); + if !s.clear.is_empty() { + let x = f * (s.clear.len() - 1) as f64; + let k = (x.floor() as usize).min(s.clear.len() - 1); + let l = (k + 1).min(s.clear.len() - 1); + // The least of the two neighbours that is known, so an unknown never lowers the floor. + let (a, b) = (s.clear[k], s.clear[l]); + let floor = if a == f32::MIN || b == f32::MIN { a.max(b) } else { a + (b - a) * (x - k as f64) as f32 }; + if floor > f32::MIN { + eye.y = eye.y.max(floor as f64); + } + } + // Captions come in after a beat and go out before the cut. + let caption = if s.head.is_empty() && s.sub.is_empty() { + 0.0 + } else { + (smooth((local - 0.25) / 0.4).min(smooth((s.len - 0.2 - local) / 0.4))) as f32 + }; + let black = smooth(1.0 - tau / FADE_S).max(smooth(1.0 - (self.len - tau) / FADE_S)) as f32; + Frame { shot: i, eye, look, fov, t, event: s.event, caption, black } + } + + /// The caption of a shot: (head, sub). + pub fn caption(&self, shot: usize) -> (&str, &str) { + self.shots.get(shot).map_or(("", ""), |s| (s.head.as_str(), s.sub.as_str())) + } + pub fn shots(&self) -> usize { + self.shots.len() + } + /// The video time a shot starts at. + pub fn start_of(&self, shot: usize) -> f64 { + self.shots.get(shot).map_or(0.0, |s| s.start) + } + /// A one-line plan, for the chat. + pub fn summary(&self) -> String { + let events: HashSet = self.shots.iter().map(|s| s.event).collect(); + format!("{} моментов, {} планов, {:.0} с", events.len(), self.shots.len(), self.len) + } +} diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 821e1dc..9fb52e3 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -7,12 +7,13 @@ use horizon_mod_sdk::{ Mod, Registration, chat, command, game, hud::{self, Corner}, input::{self, Event}, - render::{self, Lighting, ModelInstanceId, ParticleSpawn, PointLight}, + render::{self, Lighting, ModelInstanceId, ParticleSpawn, PointLight, SpotLight}, storage, ui::{self, ButtonWidget, ItemWidget, ListWidget, Screen, Widget}, }; mod cinema; +mod film; mod monitor; mod panel; mod radar; @@ -75,6 +76,10 @@ struct AirDef { wave: wave::WaveConfig, /// A replay playing (it takes the screen and the mouse wheel). replay: Option, + /// An MP4 export in progress (`/airdef replay export`). + export: Option, + /// The effects preset a film replaced: (shader, hd). + film_prev: Option<(bool, bool)>, /// The world drawn beyond the render distance (replays and cameras see far off). far: bool, /// The unit the move remote picked (its id), waiting for where to go. @@ -101,6 +106,29 @@ struct AirDef { by_id: Vec, } +/// What `/airdef replay export` was asked for. +struct ExportOpts { + name: String, + which: String, + fps: u32, + size: (u32, u32), + crf: u32, + audio: bool, + seconds: Option, +} +#[derive(PartialEq)] +enum Stage { + Planning, + Arming, + Recording, + Finishing, +} +struct Export { + opts: ExportOpts, + stage: Stage, + ticks: u32, +} + /// A sound heard later: when, what, where, and how loud and high it was. struct PendingSound { due: u64, @@ -377,8 +405,9 @@ impl AirDef { if !s.alive() && s.burning() { add(s.id + 15, s.pos + DVec3::Y * 1.5, (1.0, 0.5, 0.18), 80.0 * flicker(s.id), 30.0); } - // A searchlight's spot where its beam meets the target. - if s.kind == SamKind::Light + // Without the renderer's spot lights, a searchlight's spot where its beam meets the target. + if !self.sim.spot_hd + && s.kind == SamKind::Light && let Some(t) = s.target.and_then(|t| self.sim.drones.iter().find(|d| d.id == t)) && self.sim.lit.contains(&t.id) { @@ -388,6 +417,35 @@ impl AirDef { lights } + /// The searchlights' spot lights (HD): a cone from the head along its real direction, as far + /// as the beam runs free; at most 16, the renderer draws the nearest. + fn spots(&self) -> Vec { + if self.shown().replay { + return Vec::new(); + } + self.sim + .sams + .iter() + .filter_map(|s| s.spot_beam()) + .take(16) + .map(|b| SpotLight { + id: b.id, + x: b.origin.x, + y: b.origin.y, + z: b.origin.z, + dx: b.dir.x as f32, + dy: b.dir.y as f32, + dz: b.dir.z as f32, + color: (0.95, 0.97, 1.0), + intensity: 90_000.0, + range: (b.free + 3.5).min(1024.0) as f32, + inner_angle: sim::lights::BEAM_INNER as f32, + outer_angle: sim::lights::BEAM_OUTER as f32, + beam: 1.0, + }) + .collect() + } + /// The effects preset: none, the post pass, or the post pass under the HD lighting. fn set_effects(&mut self, shader: bool, hd: bool) { self.sim.shader = shader; @@ -398,6 +456,9 @@ impl AirDef { } self.hd = shader && hd; self.apply_hd(); + if !self.hd { + self.sim.spot_hd = false; + } self.dirty = true; } @@ -407,6 +468,7 @@ impl AirDef { // (The punch of the next blast sets its own.) if !self.hd { render::set_lights(&[]); + let _ = render::set_spot_lights(&[]); } } @@ -545,6 +607,7 @@ impl AirDef { 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.sim.spot_hd = render::set_spot_lights(&self.spots()); let (clock, segments) = self.shown_smoke(); self.smoke.hd = render::set_smoke(clock, &segments); } else { @@ -893,6 +956,14 @@ impl AirDef { } fn stop_replay(&mut self) { + if self.export.take().is_some() { + render::capture_stop(true); + say("Экспорт отменён"); + } + render::set_film_quality(false); + if let Some((shader, hd)) = self.film_prev.take() { + self.set_effects(shader, hd); + } self.replay = None; render::set_time_override(None); render::set_main_view(None); @@ -902,6 +973,140 @@ impl AirDef { self.monitor_shown = false; } + /// Plays the director's film of the last raid at the best quality: HD effects on (restored after). + fn start_film(&mut self, which: &str, export: Option<&ExportOpts>) -> Result<(), String> { + let n = self.sim.replays.len(); + if n == 0 { + return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into()); + } + let which = match which { + "best" => film::Which::Best, + "all" => film::Which::All, + n => film::Which::Top(n.parse().map_err(|_| "/airdef replay film [best|all|N]")?), + }; + let rec = &self.sim.replays[n - 1]; + let f = film::Film::build(rec, which).ok_or("В этом налёте нечего показать")?; + let mut player = replay::Player::new(rec.number, replay::Mode::Film(Box::new(f)), &self.sim); + if let Some(o) = export { + player.wait = true; + player.clean = true; + player.limit = o.seconds; + } + self.replay = Some(player); + self.grid = false; + input::claim_wheel(true); + // The best picture: HD on, the renderer's film quality (full-size world, more cloud and fog steps). + if self.film_prev.is_none() { + self.film_prev = Some((self.sim.shader, self.hd)); + } + self.set_effects(true, true); + render::set_film_quality(true); + Ok(()) + } + + /// `/airdef replay export [имя] [best|all|N] [fps=60] [size=1920x1080] [crf=14] [sec=N] [noaudio]`. + fn start_export(&mut self, args: &[&str]) -> Result<(), String> { + let mut o = ExportOpts { name: String::new(), which: "best".into(), fps: 60, size: (1920, 1080), crf: 14, audio: true, seconds: None }; + for a in args { + if let Some(v) = a.strip_prefix("fps=") { + o.fps = v.parse().map_err(|_| "fps=число")?; + } else if let Some(v) = a.strip_prefix("size=") { + let (w, h) = v.split_once('x').ok_or("size=1920x1080")?; + o.size = (w.parse().map_err(|_| "size=1920x1080")?, h.parse().map_err(|_| "size=1920x1080")?); + } else if let Some(v) = a.strip_prefix("crf=") { + o.crf = v.parse().map_err(|_| "crf=число")?; + } else if let Some(v) = a.strip_prefix("sec=") { + o.seconds = Some(v.parse().map_err(|_| "sec=число")?); + } else if *a == "noaudio" { + o.audio = false; + } else if matches!(*a, "best" | "all") || a.parse::().is_ok() { + o.which = a.to_string(); + } else { + o.name = a.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect(); + } + } + if self.export.is_some() { + return Err("Экспорт уже идёт (/airdef replay stop — отмена)".into()); + } + if o.name.is_empty() { + let n = self.sim.replays.last().map_or(0, |r| r.number); + o.name = format!("raid{n}-{}", o.which); + } + let which = o.which.clone(); + self.start_film(&which, Some(&o))?; + say(&format!("Экспорт «{}»: {} fps, {}x{}; планирую кадры…", o.name, o.fps, o.size.0, o.size.1)); + self.export = Some(Export { opts: o, stage: Stage::Planning, ticks: 0 }); + Ok(()) + } + + /// Drives an export: plan, start the recorder, wait for its first frame, report, stop at the end. + fn export_step(&mut self, r: &mut replay::Player) { + let Some(ex) = &mut self.export else { return }; + ex.ticks += 1; + let st = render::capture_status(); + let failed = |s: &render::CaptureState| s.phase == render::CapturePhase::Failed; + let mut finish: Option> = None; + match ex.stage { + Stage::Planning if r.film_ready() => { + let o = &ex.opts; + let res = render::capture_start(&render::CaptureOptions { + folder: "airdef-films".into(), + name: o.name.clone(), + fps: o.fps, + width: o.size.0, + height: o.size.1, + crf: o.crf, + audio: o.audio, + warmup: 8, + }); + match res { + Ok(()) => { + say(&format!("Кадры готовы ({}); запись…", r.film_summary())); + ex.stage = Stage::Arming; + } + Err(e) => finish = Some(Err(e)), + } + } + Stage::Arming => { + if st.phase == render::CapturePhase::Recording { + r.wait = false; + ex.stage = Stage::Recording; + ex.ticks = 0; + } else if failed(&st) { + finish = Some(Err(st.message.clone())); + } + } + Stage::Recording => { + if failed(&st) { + finish = Some(Err(st.message.clone())); + } else if r.ended { + render::capture_stop(false); + ex.stage = Stage::Finishing; + } else if ex.ticks % 100 == 0 { + let len = r.film_len().unwrap_or(1.0).max(0.1); + say(&format!("Экспорт: {:.0}% ({} кадров)", (r.film_time() / len * 100.0).min(100.0), st.frames)); + } + } + Stage::Finishing => match st.phase { + render::CapturePhase::Done => { + let note = if st.message.is_empty() { String::new() } else { format!(" ({})", st.message) }; + finish = Some(Ok(format!("{}x{}, {} кадров: {}{note}", st.width, st.height, st.frames, st.file))); + } + render::CapturePhase::Failed => finish = Some(Err(st.message.clone())), + _ => {} + }, + _ => {} + } + if let Some(res) = finish { + self.export = None; + match res { + Ok(m) => say(&format!("Экспорт готов: {m}")), + Err(e) => say(&format!("Экспорт не удался: {e}")), + } + r.done = true; + } + } + /// What a raid strikes: the marked objects, else where the player stands. fn objects(&self, player: DVec3) -> Vec { let live: Vec = self @@ -1090,7 +1295,7 @@ impl AirDef { } } -const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | truth on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats"; +const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|film [best|all|N]|export [имя] [best|all|N] [fps=60 size=1920x1080 crf=14 sec=N noaudio]|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | truth on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats"; impl Mod for AirDef { fn init() -> Result<(Self, Registration), String> { @@ -1107,6 +1312,8 @@ impl Mod for AirDef { grid_ids: Vec::new(), wave: wave::WaveConfig::default(), replay: None, + export: None, + film_prev: None, smoke: Default::default(), far: true, moving: None, @@ -1258,7 +1465,8 @@ impl Mod for AirDef { out.sounds.clear(); out.shake = None; } - r.tick(&self.sim, self.listener, &mut out); + r.tick(&self.sim, &mut self.ground, self.listener, &mut out); + self.export_step(&mut r); if r.done { self.replay = Some(r); self.stop_replay(); @@ -1527,6 +1735,8 @@ impl Mod for AirDef { ["replay", rest @ ..] => { match rest.first().copied().unwrap_or("map") { "stop" => self.stop_replay(), + "film" => self.start_film(rest.get(1).copied().unwrap_or("best"), None)?, + "export" => self.start_export(&rest[1..])?, what => self.start_replay(what, None)?, } None diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index 8e09112..91961db 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -4,7 +4,9 @@ //! world and a director's camera (a full-screen camera view) follows them — //! behind the drone, then behind the interceptor or from the side — slowing //! time round the moment itself. +use crate::film::{self, Film}; use crate::panel::distance; +use crate::sim::Ground; use crate::sim::record::{EventKind, Recording, STEP, What}; use std::cell::Cell; use crate::sim::{Class, DroneKind, Out, Sim}; @@ -23,6 +25,8 @@ pub enum Mode { Full, /// Clips one after another (the wheel skips): one event, or the highlight reel. Clips { list: Vec, at: usize, reel: bool }, + /// The director's film: planned shots from several angles with cuts and slow motion. + Film(Box), } pub struct Clip { @@ -55,6 +59,20 @@ pub struct Player { heard: f64, /// Time stands still (Space, or the end reached). paused: bool, + /// The film's own clock in seconds (the replay's moment follows from the plan). + tau: f64, + /// The film waits for the recorder to start (an export). + pub wait: bool, + /// Titles and bars only: no keys, clock or help on the screen (an export). + pub clean: bool, + /// The film stops at this many seconds (a short test export). + pub limit: Option, + /// The film reached its end. + pub ended: bool, + /// Which of the two sets of ghost ids is in use (a cut changes it, so nothing glides across it). + alt: bool, + /// The film's shot, caption and blackness this tick. + cut: Option<(usize, f32, f32)>, /// Ticks the controls stay on screen. help: u32, pub done: bool, @@ -131,7 +149,7 @@ pub fn row(rec: &Recording, i: usize) -> String { } /// How worth watching an event is: the reel takes the best. -fn interest(rec: &Recording, i: usize) -> f64 { +pub fn interest(rec: &Recording, i: usize) -> f64 { let e = &rec.events[i]; match e.kind { EventKind::Kill { kind, missile, .. } => { @@ -206,7 +224,7 @@ impl Player { let speed = match mode { Mode::Map => 8.0, Mode::Full => 2.0, - Mode::Clips { .. } => 1.0, + Mode::Clips { .. } | Mode::Film(_) => 1.0, }; let _ = sim; Player { @@ -226,6 +244,13 @@ impl Player { fov: 50.0, heard: start, paused: false, + tau: 0.0, + wait: false, + clean: false, + limit: None, + ended: false, + alt: false, + cut: None, help: 100, done: false, } @@ -264,6 +289,13 @@ impl Player { self.heard = self.t; self.shot = (usize::MAX, 0); } + Mode::Film(f) => { + // To the next or the previous plan. + let now = self.cut.map_or(0, |c| c.0); + let to = (now as i32 + dir).clamp(0, f.shots() as i32 - 1) as usize; + self.tau = f.start_of(to); + self.paused = false; + } Mode::Clips { list, at, reel } => { let next = (*at as i32 + dir).clamp(0, list.len() as i32 - 1) as usize; // Back on the first moment: from its start again. @@ -283,6 +315,7 @@ impl Player { match &self.mode { Mode::Map | Mode::Full => self.t >= rec.len as f64, Mode::Clips { list, at, .. } => *at + 1 >= list.len() && self.t >= list[*at].to, + Mode::Film(_) => self.ended, } } @@ -295,6 +328,8 @@ impl Player { } _ => 0.0, }; + self.tau = 0.0; + self.ended = false; self.heard = self.t; self.card = if reel { 50 } else { 0 }; self.fade = 6; @@ -326,11 +361,15 @@ impl Player { } /// One tick: time runs, the ghosts fly, the director films. Models and sounds go to `out`. - pub fn tick(&mut self, sim: &Sim, listener: DVec3, out: &mut Out) { + pub fn tick(&mut self, sim: &Sim, ground: &mut Ground, listener: DVec3, out: &mut Out) { let Some(rec) = sim.replay(self.rec) else { self.done = true; return; }; + if matches!(self.mode, Mode::Film(_)) { + self.tick_film(sim, ground, listener, out, rec); + return; + } self.fade = self.fade.saturating_sub(1); if self.card > 0 { self.card -= 1; @@ -360,6 +399,7 @@ impl Player { self.paused = true; } } + Mode::Film(_) => {} Mode::Clips { list, at, reel } => { if self.t >= list[*at].to { if *at + 1 < list.len() { @@ -380,7 +420,11 @@ impl Player { } sim.draw_ghosts(rec, self.t, GHOSTS, out); self.direct(rec); - // Sounds as the camera hears them: moved so the player stands where the camera is. + self.sounds(rec, listener, out); + } + + /// Sounds as the camera hears them: moved so the player stands where the camera is. + fn sounds(&mut self, rec: &Recording, listener: DVec3, out: &mut Out) { let heard = |p: DVec3| listener + (p - self.eye); for e in rec.events.iter().filter(|e| (self.heard..self.t).contains(&(e.at as f64))) { let event = match e.kind { @@ -402,7 +446,125 @@ impl Player { self.heard = self.t; } - /// The event the camera films now, and how it shoots it: (eye, look, fov, shot). + /// One tick of the film: its clock runs, the plan gives the camera and the replay's moment. + fn tick_film(&mut self, sim: &Sim, ground: &mut Ground, listener: DVec3, out: &mut Out, rec: &Recording) { + let Mode::Film(mut film) = std::mem::replace(&mut self.mode, Mode::Map) else { return }; + self.fade = self.fade.saturating_sub(1); + self.help = self.help.saturating_sub(1); + if !film.prepare(rec, ground) { + self.rate = 0.0; + self.mode = Mode::Film(film); + return; + } + let end = self.limit.map_or(film.len, |l| l.min(film.len)); + if !self.paused && !self.wait && !self.ended { + self.tau += film::TICK_S; + } + if self.tau >= end { + self.tau = end; + self.ended = true; + // A live film stops on its last frame (Space plays it again); an export is ended by the caller. + if !self.clean { + self.paused = true; + } + } + let f = film.frame(rec, self.tau); + let changed = self.cut.is_none_or(|c| c.0 != f.shot); + if changed { + self.view = self.view.wrapping_add(1).max(VIEW); + self.alt = !self.alt; + self.heard = f.t; + self.fade = if self.cut.is_some() { 2 } else { 0 }; + } + self.rate = if changed { 1.0 } else { (f.t - self.t).clamp(0.0, 1.0) }; + self.t = f.t; + self.eye = f.eye; + self.look = f.look; + self.fov = f.fov; + self.shot = (f.event, f.shot.min(255) as u8); + self.cut = Some((f.shot, f.caption, f.black)); + let base = GHOSTS + if self.alt { 0x0100_0000 } else { 0 }; + sim.draw_ghosts(rec, self.t, base, out); + if !self.wait { + self.sounds(rec, listener, out); + } else { + self.heard = self.t; + } + self.mode = Mode::Film(film); + } + + /// The film's length in seconds (what an export records), after the limit. + pub fn film_len(&self) -> Option { + match &self.mode { + Mode::Film(f) => Some(self.limit.map_or(f.len, |l| l.min(f.len))), + _ => None, + } + } + /// Whether the film's shots are placed (an export waits for it). + pub fn film_ready(&self) -> bool { + matches!(&self.mode, Mode::Film(f) if f.ready) + } + /// The film's clock, in seconds. + pub fn film_time(&self) -> f64 { + self.tau + } + /// A line about the film, for the chat. + pub fn film_summary(&self) -> String { + match &self.mode { + Mode::Film(f) => f.summary(), + _ => String::new(), + } + } + + /// The film's screen: bars, captions, the black of its start and end. + fn film_hud(&self, rec: &Recording, film: &Film, sim: &Sim, gw: f32, gh: f32) -> Vec { + let mut s: Vec = Vec::new(); + let bar = (gh * 0.09).round(); + if !film.ready { + s.push(fill(0.0, 0.0, gw, gh, 0xFF000000)); + s.push(text(format!("Готовлю кадры… {:.0}%", film.progress() * 100.0), gw / 2.0, gh / 2.0 - 4.0, 0xFFC0C0C0, true)); + return s; + } + s.push(fill(0.0, 0.0, gw, bar, 0xFF000000)); + s.push(fill(0.0, gh - bar, gw, bar, 0xFF000000)); + let (shot, caption, black) = self.cut.unwrap_or((0, 0.0, 1.0)); + if !self.clean { + let blink = (sim.tick / 10) % 2 == 0; + s.push(fill(8.0, bar / 2.0 - 3.0, 6.0, 6.0, if blink { 0xFFFF2020 } else { 0x60FF2020 })); + s.push(text(format!("ФИЛЬМ · налёт №{} · план {}/{}", rec.number, shot + 1, film.shots()), 18.0, bar / 2.0 - 4.0, 0xFFFFFFFF, false)); + let time = format!("{} / {}{}", clock(self.tau * 20.0), clock(film.len * 20.0), if self.paused { " ПАУЗА" } else { "" }); + let tw = horizon_mod_sdk::render::text_width(&time); + s.push(text(time, gw - 8.0 - tw, bar / 2.0 - 4.0, 0xFFC0C0C0, false)); + // Progress. + s.push(fill(0.0, gh - bar - 2.0, gw * (self.tau / film.len.max(0.01)) as f32, 2.0, 0xC0FFD040)); + } + // The caption: a lower third that comes in after the cut and goes before the next. + let (head, sub) = film.caption(shot); + if caption > 0.01 { + let a = (caption * 255.0) as u32; + let x = (gw * 0.07).round(); + let y = gh - bar - 52.0; + let w = horizon_mod_sdk::render::text_width(head).max(horizon_mod_sdk::render::text_width(sub)) + 22.0; + s.push(fill(x - 8.0, y, 3.0, if sub.is_empty() { 18.0 } else { 30.0 }, (a << 24) | 0xFFD040)); + s.push(fill(x - 5.0, y, w, if sub.is_empty() { 18.0 } else { 30.0 }, ((a * 140 / 255) << 24) | 0x000000)); + s.push(text(head, x + 4.0, y + 5.0, (a << 24) | 0xFFFFFF, false)); + if !sub.is_empty() { + s.push(text(sub, x + 4.0, y + 17.0, (a << 24) | 0xC0C0C0, false)); + } + } + if self.fade > 0 { + s.push(fill(0.0, 0.0, gw, gh, ((self.fade.min(6) * 42) as u32) << 24)); + } + if black > 0.003 { + s.push(fill(0.0, 0.0, gw, gh, (((black * 255.0) as u32).min(255)) << 24)); + } + if !self.clean { + self.controls(&mut s, gw, gh, bar); + } + s + } + + /// The event the camera films now: (eye, look, fov, shot). fn shoot(&mut self, rec: &Recording, i: usize) -> (DVec3, DVec3, f32, u8) { let e = rec.events[i]; let t = self.t; @@ -478,7 +640,7 @@ impl Player { .iter() .position(|e| (self.t - 30.0..self.t + 120.0).contains(&(e.at as f64))) .or(if self.shot.0 != usize::MAX && (rec.events[self.shot.0].at as f64) > self.t - 60.0 { Some(self.shot.0) } else { None }), - Mode::Map => None, + Mode::Map | Mode::Film(_) => None, }; let (eye, look, fov, shot) = match focus { Some(i) => { @@ -522,9 +684,10 @@ impl Player { let d = (self.look - self.eye).normalize_or(DVec3::NEG_Z); let yaw = (-d.x).atan2(d.z).to_degrees() as f32; let pitch = (-d.y).asin().to_degrees() as f32; - let width = 1280u32; + // The main view takes the window's size; the picture size only matters to the small views. + let width = 1920u32; let height = ((width as f32) * gh / gw.max(1.0)) as u32; - vec![CameraView { id: self.view, x: self.eye.x, y: self.eye.y, z: self.eye.z, yaw, pitch, fov: self.fov, width, height: height.clamp(240, 1080) }] + vec![CameraView { id: self.view, x: self.eye.x, y: self.eye.y, z: self.eye.z, yaw, pitch, fov: self.fov, width, height: height.clamp(240, 2160) }] } /// The replay's screen: the film with its titles, or the map. @@ -533,6 +696,9 @@ impl Player { if matches!(self.mode, Mode::Map) { return self.map(rec, gw, gh); } + if let Mode::Film(f) = &self.mode { + return self.film_hud(rec, f, sim, gw, gh); + } // 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. diff --git a/airdef-mod/src/sim/lights.rs b/airdef-mod/src/sim/lights.rs index 6c68051..964bb14 100644 --- a/airdef-mod/src/sim/lights.rs +++ b/airdef-mod/src/sim/lights.rs @@ -10,6 +10,13 @@ const FLARE_LIGHT: f64 = 90.0; const FLARE_CLIMB: u32 = 28; /// How far a searchlight's beam reaches. pub const BEAM: f64 = 450.0; +/// A searchlight's cone: full strength inside the inner half angle, none past the outer (radians). +pub const BEAM_INNER: f64 = 0.03; +pub const BEAM_OUTER: f64 = 0.075; +/// A target is lit once the axis is this close to it (inside the cone's soft edge). +const LIT_ANGLE: f64 = 0.05; +/// Ticks between casts of the beam's axis against the land. +const CAST_EVERY: u64 = 4; /// Guns' hits at night on an unlit target. pub const DARK_HITS: f64 = 0.4; /// Ticks between a gun's own flares. @@ -35,7 +42,7 @@ impl Sim { } /// The flares and the searchlights, and what they light; the guns' own flares. - pub(super) fn step_lights(&mut self, out: &mut Out) { + pub(super) fn step_lights(&mut self, ground: &mut Ground, out: &mut Out) { self.lit.clear(); let now = self.tick; // Flares: up, a burst, then down slowly, swaying, lighting all round. @@ -71,12 +78,14 @@ impl Sim { let gun_targets: Vec = self.sams.iter().filter_map(|s| s.gun_target).collect(); for i in 0..self.sams.len() { if self.sams[i].kind != SamKind::Light || !self.sams[i].alive() || self.sams[i].moving() { + self.sams[i].beam_on = false; continue; } let (pos, id) = (self.sams[i].pos, self.sams[i].id); if !self.night { let sam = &mut self.sams[i]; sam.target = None; + sam.beam_on = false; sam.pitch += (0.0 - sam.pitch) * 0.05; continue; } @@ -103,15 +112,34 @@ impl Sim { sam.yaw = slew(sam.yaw, want_yaw, 0.12); sam.pitch = slew(sam.pitch, want_pitch.clamp(0.05, 1.5), 0.08); sam.target = target.map(|t| t.0); - // The beam: segments widening with distance, out to the target. + // The beam: a spot light from the head (see `Sam::beam`); the axis is cast against the land + // now and then, and the light and the lit mark stop where the land does. let (head, rot) = self.sams[i].mount(); let dir = rot * DVec3::NEG_Z; - // The target is lit once the beam is on it, not while it still swings round. + let sam = &mut self.sams[i]; + sam.beam_on = true; + if now.saturating_sub(sam.beam_cast) >= CAST_EVERY || sam.beam_free <= 0.0 { + sam.beam_cast = now; + sam.beam_free = match ground.world.probe(head + dir, head + dir * BEAM, now) { + world::Sweep::Hit { point, .. } => point.distance(head).max(1.0), + _ => BEAM, + }; + } + let free = sam.beam_free; + // The target is lit once the beam is on it and nothing of the land is between, not while + // it still swings round. if let Some((t, at)) = target - && dir.angle_between(at - head) < 0.08 + && dir.angle_between(at - head) < LIT_ANGLE + && (at - head).length() <= free + 2.0 { self.lit.push(t); } + // Without the renderer's spot light: segments widening with distance, thin; none with + // the effects off. + if self.spot_hd || !self.shader { + continue; + } + let reach = reach.min(free); let mut along = 1.0; // Up to 14 segments (the unit's part ids 2..15), each longer than the last: one // reaches about 550 blocks, past the beam's reach, about 9 blocks wide out there. @@ -119,7 +147,7 @@ impl Sim { if along >= reach { break; } - let size = 0.8 + along * 0.12; + let size = 0.5 + along * 0.1; let len = 3.0 * size; out.model(id + 2 + k, "airdef:beam", head + dir * (along + len / 2.0), rot, size, true); along += len; @@ -147,3 +175,23 @@ impl Sim { } } } + +/// A searchlight's beam for the renderer: where it starts, which way it points and how far it runs free. +pub struct Beam { + pub id: u32, + pub origin: DVec3, + pub dir: DVec3, + pub free: f64, +} + +impl Sam { + /// The beam of a lit searchlight, from the lens of its head. + pub fn spot_beam(&self) -> Option { + if self.kind != SamKind::Light || !self.alive() || !self.beam_on { + return None; + } + let (head, rot) = self.mount(); + let dir = rot * DVec3::NEG_Z; + Some(Beam { id: self.id, origin: head + dir * 1.0, dir, free: self.beam_free }) + } +} diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 884f119..3c1124c 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -16,7 +16,7 @@ pub mod record; mod sight; mod terrain; mod ew; -mod lights; +pub mod lights; mod logistics; mod orders; mod units; @@ -1231,6 +1231,11 @@ pub struct Sam { pub ew_lock: bool, /// Ticks till a gun fires its next illumination flare. flare_cd: u32, + /// A searchlight: lit up (night), how far its beam runs free along the axis (the land stops it), + /// and the tick that was last cast. + beam_on: bool, + beam_free: f64, + beam_cast: u64, /// Shots the land ruled out, by target: when it was judged and why (`terrain::MASKED`, `terrain::PATH_BLOCKED`). pub(super) terrain: HashMap, /// Targets whose land hasn't been answered yet, and since when it has been waited for. @@ -1286,6 +1291,9 @@ impl Sam { heat: 0.0, ew_lock: false, flare_cd: 0, + beam_on: false, + beam_free: 0.0, + beam_cast: 0, terrain: HashMap::new(), waits: HashMap::new(), } @@ -1431,6 +1439,8 @@ pub struct Sim { /// Illumination flares in the sky, and the drones lit this tick. pub flares: Vec, pub lit: Vec, + /// The renderer draws the searchlights' spot lights and beams (HD); else the beam models do. + pub spot_hd: bool, } /// Ids: objects get multiples of 16, their parts the low bits. @@ -1475,6 +1485,7 @@ impl Sim { day_time: None, flares: Vec::new(), lit: Vec::new(), + spot_hd: false, } } pub fn id(&mut self) -> u32 { @@ -1579,7 +1590,7 @@ impl Sim { self.step_supply(out); self.step_ew(out); self.update_sight(ground); - self.step_lights(out); + self.step_lights(ground, out); self.step_sams(ground, out); self.step_missiles(ground, out); self.step_fx(ground, out); diff --git a/airdef-mod/src/sim/terrain.rs b/airdef-mod/src/sim/terrain.rs index 57832bd..a2ad525 100644 --- a/airdef-mod/src/sim/terrain.rs +++ b/airdef-mod/src/sim/terrain.rs @@ -35,6 +35,16 @@ impl Ground { } } + /// For a planner: None while the game has not answered, Some(None) where it has no land to + /// tell of, else Some(Some(height)). + pub fn land_state(&mut self, p: DVec3) -> Option> { + match self.rise(p) { + Rise::Pending => None, + Rise::Void => Some(None), + Rise::At(g) => Some(Some(g)), + } + } + /// The same, saying whether the game has not answered yet or has no land there to tell of. pub(super) fn rise(&mut self, p: DVec3) -> Rise { let key = ( diff --git a/docs/HD-RENDERER.md b/docs/HD-RENDERER.md index 03ce928..5fbe758 100644 --- a/docs/HD-RENDERER.md +++ b/docs/HD-RENDERER.md @@ -32,7 +32,7 @@ Horizon действует правило clean-room. Исключение — P | Материалы (классы спрайтов: листва, растения, металл, полированное), ветер, мокрые поверхности | `gfx/hd/materials.rs`; `terrain.wgsl` (`terrain_vertex`/`wind_offset`, `material_params`), `entity.wgsl`, `gfx/hd/light.wgsl` (`shade_surface`) | | Пост-проход мода (взрывы, дым, вспышки, свечение) и его прелюдия | `airdef-mod/shaders/effects.wgsl`, прелюдия в `horizon/.../gfx/mod_post.rs` | | Источники света мода (пламя, ракеты, взрывы, прожекторы) | `airdef-mod/src/lib.rs`, `fn lights` | -| API мода: `render.set-lights`, `render.set-lighting` (уровень API 10) | `horizon/crates/horizon-mod-sdk/wit/horizon.wit`, `horizon/crates/horizon-mods/src/runtime.rs` | +| API мода: `render.set-lights`, `render.set-lighting` (уровень API 10), `render.set-spot-lights` (16) | `horizon/crates/horizon-mod-sdk/wit/horizon.wit`, `horizon/crates/horizon-mods/src/runtime.rs` | Форк ведётся в `horizon/` (ветка `airdef`), в репозиторий мода попадает **патчами**: @@ -339,3 +339,20 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home - **Малые виды** (мониторы, `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` широкое гало, подсветка неба/облаков и яркое кольцо ударной волны на экране. + +## Прожекторы: направленный свет и объёмный луч (#60) + +- **API 16** `render.set-spot-lights(list) -> bool`: апекс, направление, цвет, интенсивность, `range` (до 1024), внутренний/внешний полуугол (рад), `beam` (видимость луча 0..1). До 32 от мода, рисуются ближайшие 16 (`MAX_SPOTS`). Возвращает, рисует ли HD (`HORIZON_HD_SPOTS=0` выключает), иначе мод держит свой запасной луч. Игра интерполирует по id между двумя вызовами (`mod_models.rs: mod_spots`) и переводит в `HdSpot` (`gfx/hd/frame.rs`): координаты камеры, косинусы углов. +- **Данные в шейдере**: `HdFrame.spots` (4 vec4 на источник: апекс+range, ось+cos внешнего, цвет+интенсивность, cos внутреннего+beam), число в `vfog.w`. Новых привязок нет. +- **Тайлы**: заголовок тайла 5 слов (`TILE_WORDS`), пятое: маска конусов, бит j стоит, если выпуклая оболочка конуса (апекс и квадрат вокруг дальнего круга, `tiles.rs: spot_hull`, `cover_points`) на экране накрывает тайл. Маска служит и поверхностям, и лучу. Индекс тайла в WGSL: `* 5u`. +- **Поверхности и цели**: `spot_light` (`spot.wgsl`, вставляется маркером `//SPOT` в `assemble`): конус с мягкой кромкой (smoothstep по косинусам), `I/(d²+1)`, окно в последние 3 блока до `range`, угол падения как у точечных; в `shade_surface` (земля, стены, дроны: они в G-буфере). Теней нет: свет обрывается по `range`, который мод ставит по броску оси на землю (`ground.world.probe`, раз в 4 тика). +- **Луч**: `spot_beams` (`spot.wgsl`): пересечение луча взгляда с конусом (`spot_span`: квадратное уравнение конуса, обрезка по оси и range); внутри 8 шагов с джиттером IGN; яркое ядро, мягкая периферия; затухание `1/(1+d/70)`; плотность воздуха `max(spot_air, 0.0009)` (в чистом воздухе слабый луч, в дымке/тумане ярче и короче); фаза Henyey-Greenstein вперёд (на лампу ярче). Складывается (прозрачный), ограничен глубиной сцены: за стеной не продолжается; камера внутри конуса работает. **Туман вкл**: считается в `fs_fog` (`spot_air = fog_sigma`, длина до 1100 блоков, в небо тоже, не 192 как у тумана); **туман выкл или нет атмосферы**: тот же код в `fs_light` с постоянной плотностью `ambient.z`. +- **Мод**: `sim/lights.rs`: голова прожектора шлёт один `SpotLight` (`Sam::spot_beam`, `lib.rs: spots`; конус 0.03/0.075 рад, range = свободная длина оси + 3.5). Цель «освещена» (`lit`) только в угле 0.05 от оси и если она не дальше свободной длины (за горой бонуса нет). Старые `airdef:beam` рисуются, только когда HD не рисует споты и эффекты включены (тоньше), при эффектах off луча нет. Точечный «шар» на цели остаётся лишь без спотов. В реплеях споты не шлются (реплей не пишет голову прожектора; #56/#58 отдельно). +- **Ограничения**: тень рельефа вне оси (боковая часть конуса за гребнем светит), запасной луч непрозрачен (текстура модели без альфы), GPU-цена на массовой сцене не снята. + +## Кинорежиссёр и экспорт MP4 (#58, API 17) + +- **Качество фильма** (`render.set-film-quality`, включается и на время захвата): мир HD в полном размере окна (`hd::scale_override` → 1.0), шаги облаков ×2 (шаг 7 блоков, 16–52 шага), шаги тумана ×2; флаг идёт в `HdFrame.surface.z`. Смена `set-main-view` id (склейка) сбрасывает историю TAA и облаков (`Renderer::cut`). +- **Захват** (`render.capture-start/stop/status`, `game/film.rs`, `Renderer::capture_*`): часы игры идут ровно `1/fps` секунды за кадр (тики, интерполяция, `tick` мода), кадр копируется с GPU через очередь из ≤3 буферов и уходит в поток-писатель → `ffmpeg` (libx264, иначе h264_videotoolbox; иначе PNG-последовательность). Звук: микшер OpenAL-подобный (`audio.rs`) работает офлайн (`film_begin/film_mix`), слышны только звуки мода, ориентация — по камере; дорожка вмуксивается AAC. После склейки рисуются `warmup` кадров без записи, чтобы сошлись TAA и объёмы. Размер: offscreen-игра ресайзится, окно просится (`request_inner_size`). +- Мод-HUD (`set-hud`) теперь рисуется и при `set-main-view` (титры, леттербокс фильма); ванильный HUD по-прежнему скрыт. +- Режиссура: `airdef-mod/src/film.rs` (план по записи, варианты ракурсов, проверка рельефа по высотам `Ground::land_state`, ускорение/замедление времени). 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 9946934..6765fac 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/31] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/32] 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 9122a88..55f6f13 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/31] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/32] 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 b2aa5dd..eef1670 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/31] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/32] 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 3a3ac49..1172587 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/31] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/32] 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 cc37848..af0c93f 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/31] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/32] 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 de85e9f..b914c48 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/31] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/32] 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 b501751..5952089 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/31] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/32] 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 46d20d7..6ebc193 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/31] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/32] 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 ab9b783..9884387 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/31] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/32] 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 f75c9a2..ddb8df6 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/31] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/32] 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 ae5f6bf..239dd31 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/31] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/32] 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 40edd3d..0c66c6e 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/31] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/32] 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 3d85200..e943874 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/31] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/32] 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 4d0b7db..81283f3 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/31] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/32] 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 c5c9dd4..3ac7a76 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/31] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/32] 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 0dfeb2c..f2e96d9 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/31] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/32] 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 1866511..22a77f5 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/31] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/32] 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 f607c73..3ff7869 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/31] HD: temporal upscaling (HORIZON_HD_SCALE: world, +Subject: [PATCH 18/32] 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 853d03e..c21fc56 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/31] HD water: its own pass (colour, normal, depth), the +Subject: [PATCH 19/32] 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 97470f2..a439723 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/31] HD water: no vanilla texture on the surface (the biome +Subject: [PATCH 20/32] 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 32d2805..8e292e4 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/31] HD sky and light: a physical atmosphere (Rayleigh, Mie, +Subject: [PATCH 21/32] 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 f894c22..851e6ca 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/31] HD water: a darker blue, a little clearer, ripples a +Subject: [PATCH 22/32] 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 c92cfdc..3e988e3 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/31] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun +Subject: [PATCH 23/32] 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 83b8c2a..e581726 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/31] Mods: world queries for simulations (render.query-rays, +Subject: [PATCH 24/32] 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 6768f72..95364bf 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/31] HD fixes: rain level from the world not from rain +Subject: [PATCH 25/32] 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 c1a3aa9..9632aac 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/31] HD split into modules (gfx/hd/: one Rust file and one +Subject: [PATCH 26/32] 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 0bc3e46..d3ee3a8 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/31] HD clouds: noise scales that divide the camera's +Subject: [PATCH 27/32] 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 1d9ff6a..2c5bcc3 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/31] HD: volumetric smoke and missile trails with clear +Subject: [PATCH 28/32] 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 5e8520c..6f9ce07 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/31] HD clouds composited by depth so terrain and vehicles +Subject: [PATCH 29/32] 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 81b405b..88b1dd3 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/31] HD smoke: young trails drawn at least as wide as a grid +Subject: [PATCH 30/32] 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 index 49e38a0..fceb1a0 100644 --- 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 @@ -1,7 +1,7 @@ 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 +Subject: [PATCH 31/32] 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 diff --git a/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch b/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch new file mode 100644 index 0000000..380ad81 --- /dev/null +++ b/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch @@ -0,0 +1,2736 @@ +From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001 +From: chexit +Date: Fri, 30 Oct 2026 09:28:05 +0300 +Subject: [PATCH 32/32] Spot lights for mods (render.set-spot-lights, API level + 16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film + capture (render.capture-*, set-film-quality, API level 17): fixed-step clock, + frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58); + the client builds without the mods feature again + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/audio.rs | 80 +- + crates/horizon-client/src/game.rs | 38 +- + crates/horizon-client/src/game/film.rs | 806 ++++++++++++++++++ + crates/horizon-client/src/game/mod_models.rs | 76 +- + crates/horizon-client/src/game/mods.rs | 60 ++ + crates/horizon-client/src/gfx.rs | 134 ++- + crates/horizon-client/src/gfx/hd/cloud.wgsl | 4 +- + crates/horizon-client/src/gfx/hd/fog.wgsl | 20 +- + crates/horizon-client/src/gfx/hd/frame.rs | 25 +- + crates/horizon-client/src/gfx/hd/light.wgsl | 27 +- + crates/horizon-client/src/gfx/hd/lighting.rs | 4 +- + crates/horizon-client/src/gfx/hd/mod.rs | 43 +- + crates/horizon-client/src/gfx/hd/prelude.wgsl | 1 + + crates/horizon-client/src/gfx/hd/spot.wgsl | 116 +++ + crates/horizon-client/src/gfx/hd/taa.rs | 17 + + crates/horizon-client/src/gfx/hd/tiles.rs | 119 ++- + crates/horizon-mod-sdk/wit/horizon.wit | 110 ++- + crates/horizon-mods/src/api_level.rs | 11 +- + crates/horizon-mods/src/runtime.rs | 208 ++++- + docs/api/mods-reference.md | 66 +- + 20 files changed, 1917 insertions(+), 48 deletions(-) + create mode 100644 crates/horizon-client/src/game/film.rs + create mode 100644 crates/horizon-client/src/gfx/hd/spot.wgsl + +diff --git a/crates/horizon-client/src/audio.rs b/crates/horizon-client/src/audio.rs +index e45362e19..b09178c1d 100644 +--- a/crates/horizon-client/src/audio.rs ++++ b/crates/horizon-client/src/audio.rs +@@ -47,6 +47,8 @@ struct Mixer { + listener_yaw: f32, + listener_pitch: f32, + limiter_peak: f32, ++ /// Film capture: the output device hears nothing, `Audio::film_mix` mixes instead. ++ offline: bool, + } + /// A sound the player could have heard (`SoundEventListener.onPlaySound`): + /// what the subtitle overlay needs. +@@ -69,6 +71,10 @@ pub struct MixerBackend { + decoder: Option>, + next: ChannelId, + live: bool, ++ /// Film capture: sounds decode at once (the mix runs on the film's own time) and only the ++ /// ones played while `allow` is set (a mod's) start. ++ offline: bool, ++ allow: bool, + } + /// Decoded samples and their rate (`None`: the file did not load). + type Samples = Option<(Arc>, u32)>; +@@ -164,6 +170,8 @@ pub struct Audio { + /// How many times each selection was stopped ("record *", "* minecraft:…", `*` for + /// any), with or without an audio device, as `heard`. + pub stopped: std::collections::BTreeMap, ++ /// What film capture set aside: (live, loaded) as they were. ++ film_saved: Option<(bool, bool)>, + } + fn decode(path: &Path) -> Option<(Vec, u32)> { + use symphonia::core::{ +@@ -264,11 +272,27 @@ impl Backend for MixerBackend { + /// `alSourcePlay`. A sound that cannot load leaves its source initial, + /// as a failed buffer future does in vanilla. + fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) { +- if !self.live { ++ if !self.live || (self.offline && !self.allow) { + return; + } + let (namespace, path) = sound.path(); + let file = self.root.join("assets").join(namespace).join(&path); ++ if self.offline { ++ // Decoded here, so the sound starts at the film frame that asked for it. ++ let key = format!("{namespace}:{path}"); ++ let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key)); ++ let samples = cached.unwrap_or_else(|| { ++ let samples = decode(&file).map(|(s, r)| (Arc::new(s), r)); ++ if !sound.stream ++ && let Ok(mut c) = self.cache.lock() ++ { ++ c.insert(key, samples.clone()); ++ } ++ samples ++ }); ++ attach(&self.mixer, channel, samples, looping); ++ return; ++ } + if sound.stream { + // A streamed sound loops by restarting its stream. + let mixer = self.mixer.clone(); +@@ -530,7 +554,11 @@ fn build_stream>( + move |data: &mut [T], _| { + scratch.resize(data.len(), 0.0f32); + if let Ok(mut m) = mixer.lock() { +- mix(&mut m, &mut scratch, channels, rate); ++ if m.offline { ++ scratch.fill(0.0); ++ } else { ++ mix(&mut m, &mut scratch, channels, rate); ++ } + } else { + scratch.fill(0.0); + } +@@ -589,6 +617,8 @@ impl Audio { + decoder: None, + next: 0, + live, ++ offline: false, ++ allow: false, + }, + record_heard: false, + listener: [0.0; 3], +@@ -599,6 +629,52 @@ impl Audio { + capture: None, + heard: Default::default(), + stopped: Default::default(), ++ film_saved: None, ++ } ++ } ++ /// Film capture starts: every sound stops, and from now on only the sounds played between ++ /// `film_allow(true)` and `film_allow(false)` (a mod's) are heard, mixed by `film_mix` on ++ /// the film's own clock instead of reaching the output device. ++ pub fn film_begin(&mut self) { ++ if self.film_saved.is_some() { ++ return; ++ } ++ self.stop_all(); ++ self.film_saved = Some((self.backend.live, self.engine.loaded)); ++ self.backend.live = true; ++ self.backend.offline = true; ++ self.backend.allow = false; ++ self.engine.loaded = true; ++ if let Ok(mut m) = self.backend.mixer.lock() { ++ m.offline = true; ++ m.channels.clear(); ++ } ++ } ++ /// Whether the sounds played now are the film's. ++ pub fn film_allow(&mut self, on: bool) { ++ self.backend.allow = on; ++ } ++ /// The film's next `frames` stereo frames, interleaved, at `rate`. ++ pub fn film_mix(&mut self, frames: usize, rate: u32) -> Vec { ++ let mut data = vec![0.0f32; frames * 2]; ++ if let Ok(mut m) = self.backend.mixer.lock() { ++ mix(&mut m, &mut data, 2, rate); ++ } ++ data ++ } ++ /// Film capture ends: the output device hears the game again. ++ pub fn film_end(&mut self) { ++ let Some((live, loaded)) = self.film_saved.take() else { ++ return; ++ }; ++ self.stop_all(); ++ self.backend.live = live; ++ self.backend.offline = false; ++ self.backend.allow = false; ++ self.engine.loaded = loaded; ++ if let Ok(mut m) = self.backend.mixer.lock() { ++ m.offline = false; ++ m.channels.clear(); + } + } + #[cfg(not(feature = "audio"))] +diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs +index 0e779aaa0..bace52193 100644 +--- a/crates/horizon-client/src/game.rs ++++ b/crates/horizon-client/src/game.rs +@@ -60,6 +60,7 @@ mod explosion; + mod extras; + mod falling_damage; + mod fangs; ++mod film; + mod fireworks; + mod fishing; + mod fov; +@@ -1818,6 +1819,8 @@ struct Game<'a> { + extras: extras::ExtrasState, + /// Horizon mods (`game/mods.rs`), none running unless turned on. + mods: mods::ModsState, ++ /// AirDef fork: film capture (`game/film.rs`). ++ film: film::FilmState, + /// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`). + native_shaders: native_shaders::NativeShaders, + /// Meshed sections waiting for an upload budget (Horizon settings). +@@ -6010,6 +6013,9 @@ impl Game<'_> { + self.draw_boss_bars(&mut batch); + self.draw_extras(&mut batch); + self.draw_mod_shapes(&mut batch); ++ } else if !self.ui.hide_gui { ++ // A mod's camera as the screen's view: only the mod's own shapes (its titles, bars). ++ self.draw_mod_shapes(&mut batch); + } + if show_hud { + // Hud.nextContextualInfoState: the jump bar, the experience bar or +@@ -6143,7 +6149,10 @@ impl Game<'_> { + let tw = HudBatch::text_width(atlas, text); + batch.text(atlas, text, (w - tw) / 2.0, h - 59.0, u32::MAX, true); + } +- self.draw_chat(&mut batch); ++ // AirDef fork: a film's frames carry no chat (its progress messages go there). ++ if !self.film.recording() { ++ self.draw_chat(&mut batch); ++ } + self.draw_tab_list(&mut batch); + // Screen.extractBlurredBackground blurs the world and HUD, while the + // screen's background, widgets, toasts and debug overlay stay sharp. +@@ -8373,12 +8382,17 @@ impl Game<'_> { + } + + /// The time of day the player sees: a mod's override (a replay's recorded time), else the world's. ++ #[cfg(feature = "mods")] + fn shown_day_time(&self) -> i64 { + self.mods + .mods + .time_override() + .map_or(self.world.day_time, |t| t as i64) + } ++ #[cfg(not(feature = "mods"))] ++ fn shown_day_time(&self) -> i64 { ++ self.world.day_time ++ } + + /// The sky's darkening for the game's rules (mob AI, spawning, block behaviour, stats): always + /// the world's real time, never a mod's render-only override. +@@ -11575,6 +11589,21 @@ impl Game<'_> { + // 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); ++ // AirDef fork: a cut starts the temporal histories over; a film hears from the camera's ++ // orientation (the sounds are already placed round the player). ++ let view_id = main_view.as_ref().map(|v| v.id); ++ if view_id != self.film.last_view { ++ self.film.last_view = view_id; ++ if let Some(r) = self.renderer.as_mut() { ++ r.cut = true; ++ } ++ } ++ if let Some(v) = &main_view ++ && self.film.recording() ++ { ++ self.audio ++ .set_listener([camera.x, camera.y, camera.z], v.yaw, v.pitch); ++ } + 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), +@@ -11850,11 +11879,13 @@ impl Game<'_> { + .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_spots = self.mod_spots(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_spots: &hd_spots, + hd_smoke: &hd_smoke, + hd_smoke_clock, + weather: &weather, +@@ -13397,6 +13428,7 @@ impl ApplicationHandler for Game<'_> { + // For the "I'm playing" ping: an atomic, sent on when it changes. + horizon_client::heartbeat::set_lan(self.is_published()); + self.update_discord_presence(); ++ let elapsed = self.film_step(elapsed); + let steps = self.clock.advance(elapsed); + // The world ticking in the frame: once its ticks took a + // tick's length, the rest are dropped (`FixedStep::tick_fits`). +@@ -13438,7 +13470,9 @@ impl ApplicationHandler for Game<'_> { + self.pad_look(elapsed.as_secs_f32()); + self.advance_flight(); + let ticked = Instant::now(); ++ self.film_before_render()?; + let rendered = self.render()?; ++ self.film_after_render(rendered); + self.apply_cursor_icon(); + if let Some(threshold) = self.options.spikes + && let Some(r) = self.renderer.as_ref() +@@ -13972,6 +14006,7 @@ impl<'a> Game<'a> { + perf: Default::default(), + pending_screenshot: None, + pending_bench: None, ++ film: Default::default(), + flight: None, + ui_wait: Default::default(), + events: Default::default(), +@@ -14790,6 +14825,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { + mod_views: &[], + hd: None, + hd_lights: &[], ++ hd_spots: &[], + hd_smoke: &[], + hd_smoke_clock: 0.0, + weather: &[], +diff --git a/crates/horizon-client/src/game/film.rs b/crates/horizon-client/src/game/film.rs +new file mode 100644 +index 000000000..8d1995030 +--- /dev/null ++++ b/crates/horizon-client/src/game/film.rs +@@ -0,0 +1,806 @@ ++//! AirDef fork: film capture (`render.capture-start`). While a mod films, the game clock runs at a ++//! fixed step (exactly `1 / fps` seconds a frame, whatever the machine's speed), each frame is ++//! copied back from the GPU through a bounded queue and handed to a writer thread that pipes it to ++//! `ffmpeg` (or writes a PNG sequence when there is none), and the sounds the mod plays are mixed ++//! offline on the same clock into the file's audio track. ++use super::Game; ++use horizon_client::gfx::CapturedFrame; ++use std::{ ++ io::Write, ++ path::{Path, PathBuf}, ++ process::{Child, Command, Stdio}, ++ sync::{Arc, Mutex, mpsc}, ++ time::Duration, ++}; ++ ++/// The audio track's sample rate. ++const RATE: u32 = 48_000; ++/// Frames the writer holds waiting (each 8 MB at 1080p): the renderer waits when it is full. ++const QUEUE: usize = 3; ++ ++/// What a mod asked for (`CaptureOptions`). ++pub(super) struct FilmRequest { ++ pub folder: String, ++ pub name: String, ++ pub fps: u32, ++ pub width: u32, ++ pub height: u32, ++ pub crf: u32, ++ pub audio: bool, ++ pub warmup: u32, ++} ++ ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub(super) enum Phase { ++ Idle, ++ Arming, ++ Recording, ++ Encoding, ++ Done, ++ Failed, ++} ++ ++/// Where the capture is (`CaptureState`). ++#[derive(Clone, Debug)] ++pub(super) struct FilmReport { ++ pub phase: Phase, ++ pub frames: u32, ++ pub size: [u32; 2], ++ pub file: String, ++ pub message: String, ++} ++ ++impl Default for FilmReport { ++ fn default() -> Self { ++ Self { ++ phase: Phase::Idle, ++ frames: 0, ++ size: [0, 0], ++ file: String::new(), ++ message: String::new(), ++ } ++ } ++} ++ ++enum Msg { ++ Frame(CapturedFrame), ++ Audio(Vec), ++ Finish, ++ Cancel, ++} ++ ++/// What the writer ended with. ++struct Outcome { ++ ok: bool, ++ file: String, ++ message: String, ++} ++ ++/// A running capture. ++pub(super) struct Film { ++ req: FilmRequest, ++ dir: PathBuf, ++ phase: Phase, ++ /// Frames spent waiting for the picture to reach the size. ++ waited: u32, ++ /// Frames whose time step has been taken, and frames sent to the writer. ++ stepped: u64, ++ sent: u32, ++ size: [u32; 2], ++ /// The size the screen had before. ++ before: [u32; 2], ++ sink: Option>, ++ outcome: Arc>>, ++ thread: Option>, ++ /// The shot the last frame showed (a change is a cut: warm-up frames follow). ++ view: Option, ++ /// Audio frames mixed so far. ++ mixed: u64, ++ /// Told to stop (cancel or not) and what the writer is finishing. ++ cancel: bool, ++ note: String, ++} ++ ++/// The capture's state for the game: the running one and the last report. ++#[derive(Default)] ++pub(super) struct FilmState { ++ pub(super) film: Option, ++ pub(super) report: FilmReport, ++ /// The main view of the last rendered frame (a cut resets the temporal histories). ++ pub(super) last_view: Option, ++} ++ ++/// An `ffmpeg` to run: `HORIZON_FFMPEG`, the `PATH`, or where Homebrew and packages put it (a ++/// game started from the desktop does not see a shell's `PATH`). ++fn find_ffmpeg() -> Option { ++ if let Some(p) = std::env::var_os("HORIZON_FFMPEG").filter(|p| !p.is_empty()) { ++ return Some(PathBuf::from(p)); ++ } ++ let name = if cfg!(windows) { ++ "ffmpeg.exe" ++ } else { ++ "ffmpeg" ++ }; ++ let mut dirs: Vec = std::env::var_os("PATH") ++ .map(|p| std::env::split_paths(&p).collect()) ++ .unwrap_or_default(); ++ for d in [ ++ "/opt/homebrew/bin", ++ "/usr/local/bin", ++ "/usr/bin", ++ "/snap/bin", ++ ] { ++ dirs.push(d.into()); ++ } ++ dirs.into_iter().map(|d| d.join(name)).find(|p| p.is_file()) ++} ++ ++/// The H.264 encoder arguments the `ffmpeg` has, best first (software x264, then the Mac's ++/// hardware encoder), or None. ++fn encoder(ffmpeg: &Path, crf: u32) -> Option> { ++ let out = Command::new(ffmpeg) ++ .args(["-hide_banner", "-encoders"]) ++ .output() ++ .ok()?; ++ let text = String::from_utf8_lossy(&out.stdout); ++ let has = |name: &str| { ++ text.lines() ++ .any(|l| l.split_whitespace().nth(1) == Some(name)) ++ }; ++ let args: &[&str] = if has("libx264") { ++ &["-c:v", "libx264", "-preset", "slow", "-crf"] ++ } else if has("h264_videotoolbox") { ++ return Some( ++ ["-c:v", "h264_videotoolbox", "-b:v", "60M", "-allow_sw", "1"] ++ .map(String::from) ++ .to_vec(), ++ ); ++ } else { ++ return None; ++ }; ++ let mut v: Vec = args.iter().map(|s| s.to_string()).collect(); ++ v.push(crf.to_string()); ++ Some(v) ++} ++ ++/// The writer thread: frames to `ffmpeg`'s pipe (or PNG files), sound to a raw file, then the ++/// file is closed and the sound muxed in. ++fn write( ++ rx: mpsc::Receiver, ++ dir: PathBuf, ++ name: String, ++ fps: u32, ++ size: [u32; 2], ++ crf: u32, ++ audio: bool, ++ outcome: Arc>>, ++) { ++ let result = (|| -> Result<(String, String), String> { ++ let pcm_path = dir.join(format!("{name}.audio.raw")); ++ let mut pcm = if audio { ++ Some(std::io::BufWriter::new( ++ std::fs::File::create(&pcm_path).map_err(|e| format!("audio file: {e}"))?, ++ )) ++ } else { ++ None ++ }; ++ let ffmpeg = find_ffmpeg(); ++ let encode = ffmpeg.as_deref().and_then(|f| encoder(f, crf)); ++ let video_path = dir.join(if audio { ++ format!("{name}.video.mp4") ++ } else { ++ format!("{name}.mp4") ++ }); ++ let frames_dir = dir.join(format!("{name}_frames")); ++ let mut child: Option = None; ++ let mut log_path = None; ++ if let (Some(ffmpeg), Some(encode)) = (&ffmpeg, &encode) { ++ let log = dir.join(format!("{name}.ffmpeg.log")); ++ let stderr = std::fs::File::create(&log).map_err(|e| format!("log file: {e}"))?; ++ log_path = Some(log); ++ let bgra = true; ++ let c = Command::new(ffmpeg) ++ .args(["-y", "-hide_banner", "-loglevel", "error", "-f", "rawvideo"]) ++ .args(["-pix_fmt", if bgra { "bgra" } else { "rgba" }]) ++ .args(["-video_size", &format!("{}x{}", size[0], size[1])]) ++ .args(["-framerate", &fps.to_string(), "-i", "pipe:0", "-an"]) ++ .args(encode) ++ .args([ ++ "-vf", ++ "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p", ++ "-colorspace", ++ "bt709", ++ "-color_primaries", ++ "bt709", ++ "-color_trc", ++ "bt709", ++ "-color_range", ++ "tv", ++ "-movflags", ++ "+faststart", ++ ]) ++ .arg(&video_path) ++ .stdin(Stdio::piped()) ++ .stdout(Stdio::null()) ++ .stderr(stderr) ++ .spawn() ++ .map_err(|e| format!("could not run ffmpeg: {e}"))?; ++ child = Some(c); ++ } else { ++ std::fs::create_dir_all(&frames_dir).map_err(|e| format!("frames folder: {e}"))?; ++ } ++ let mut stdin = child.as_mut().and_then(|c| c.stdin.take()); ++ let mut n = 0u32; ++ let mut failed: Option = None; ++ let mut cancelled = false; ++ for msg in rx { ++ match msg { ++ Msg::Frame(f) => { ++ if failed.is_some() { ++ continue; ++ } ++ n += 1; ++ if let Some(pipe) = stdin.as_mut() { ++ // The frames arrive as the surface has them: BGRA, or RGBA swapped here. ++ let mut data = f.data; ++ if !f.bgra { ++ for px in data.chunks_exact_mut(4) { ++ px.swap(0, 2); ++ } ++ } ++ if let Err(e) = pipe.write_all(&data) { ++ failed = Some(format!("ffmpeg stopped (see {name}.ffmpeg.log): {e}")); ++ } ++ } else if let Err(e) = ++ write_png(&frames_dir.join(format!("frame_{n:06}.png")), &f) ++ { ++ failed = Some(format!("PNG: {e}")); ++ } ++ } ++ Msg::Audio(samples) => { ++ if let Some(w) = pcm.as_mut() { ++ let mut bytes = Vec::with_capacity(samples.len() * 2); ++ for s in samples { ++ bytes.extend_from_slice( ++ &((s.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes(), ++ ); ++ } ++ if let Err(e) = w.write_all(&bytes) { ++ failed.get_or_insert(format!("audio file: {e}")); ++ } ++ } ++ } ++ Msg::Finish => break, ++ Msg::Cancel => { ++ cancelled = true; ++ break; ++ } ++ } ++ } ++ drop(stdin); ++ if let Some(w) = pcm.as_mut() { ++ let _ = w.flush(); ++ } ++ drop(pcm); ++ let tidy = |paths: &[&Path]| { ++ for p in paths { ++ let _ = std::fs::remove_file(p); ++ } ++ }; ++ if let Some(mut c) = child { ++ let status = c.wait().map_err(|e| format!("ffmpeg: {e}"))?; ++ if cancelled || failed.is_some() || !status.success() { ++ let why = failed.unwrap_or_else(|| { ++ if cancelled { ++ "cancelled".into() ++ } else { ++ format!("ffmpeg failed (see {name}.ffmpeg.log)") ++ } ++ }); ++ if cancelled { ++ tidy(&[&video_path, &pcm_path]); ++ if let Some(l) = &log_path { ++ tidy(&[l]); ++ } ++ } ++ return Err(why); ++ } ++ let out = dir.join(format!("{name}.mp4")); ++ let mut note = String::new(); ++ if audio { ++ let mux = Command::new(ffmpeg.as_ref().expect("ffmpeg")) ++ .args(["-y", "-hide_banner", "-loglevel", "error", "-i"]) ++ .arg(&video_path) ++ .args(["-f", "s16le", "-ar", &RATE.to_string(), "-ac", "2", "-i"]) ++ .arg(&pcm_path) ++ .args([ ++ "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", "-b:a", "256k", ++ ]) ++ .args(["-shortest", "-movflags", "+faststart"]) ++ .arg(&out) ++ .stdin(Stdio::null()) ++ .output(); ++ match mux { ++ Ok(o) if o.status.success() => tidy(&[&video_path, &pcm_path]), ++ other => { ++ // The picture is good: keep it without sound. ++ let _ = std::fs::rename(&video_path, &out); ++ note = format!( ++ "audio could not be mixed in ({}); the sound is in {name}.audio.raw (s16le, {RATE} Hz, stereo)", ++ match other { ++ Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(), ++ Err(e) => e.to_string(), ++ } ++ ); ++ } ++ } ++ } ++ if let Some(l) = &log_path { ++ tidy(&[l]); ++ } ++ let encoder_note = if encode ++ .as_ref() ++ .is_some_and(|e| e.iter().any(|a| a == "h264_videotoolbox")) ++ { ++ "encoded with the hardware encoder (no libx264 in this ffmpeg)" ++ } else { ++ "" ++ }; ++ let note = [note.as_str(), encoder_note] ++ .iter() ++ .filter(|s| !s.is_empty()) ++ .copied() ++ .collect::>() ++ .join("; "); ++ Ok((out.display().to_string(), note)) ++ } else { ++ if cancelled { ++ let _ = std::fs::remove_dir_all(&frames_dir); ++ tidy(&[&pcm_path]); ++ return Err("cancelled".into()); ++ } ++ if let Some(why) = failed { ++ return Err(why); ++ } ++ let wav = if audio { ++ write_wav(&pcm_path, &dir.join(format!("{name}.wav"))) ++ } else { ++ Ok(false) ++ }; ++ let mut note = format!( ++ "no ffmpeg found (put it on the PATH or set HORIZON_FFMPEG); {n} PNG frames written. To make the video: ffmpeg -framerate {fps} -i {}/frame_%06d.png{} -c:v libx264 -crf {crf} -pix_fmt yuv420p {}", ++ frames_dir.display(), ++ if matches!(wav, Ok(true)) { ++ format!(" -i {}", dir.join(format!("{name}.wav")).display()) ++ } else { ++ String::new() ++ }, ++ dir.join(format!("{name}.mp4")).display() ++ ); ++ if let Err(e) = wav { ++ note.push_str(&format!("; audio: {e}")); ++ } ++ Ok((frames_dir.display().to_string(), note)) ++ } ++ })(); ++ if let Ok(mut o) = outcome.lock() { ++ *o = Some(match result { ++ Ok((file, message)) => Outcome { ++ ok: true, ++ file, ++ message, ++ }, ++ Err(message) => Outcome { ++ ok: false, ++ file: String::new(), ++ message, ++ }, ++ }); ++ } ++} ++ ++fn write_png(path: &Path, f: &CapturedFrame) -> Result<(), String> { ++ let mut rgba = Vec::with_capacity(f.data.len()); ++ for px in f.data.chunks_exact(4) { ++ let (r, b) = if f.bgra { ++ (px[2], px[0]) ++ } else { ++ (px[0], px[2]) ++ }; ++ rgba.extend_from_slice(&[r, px[1], b, 255]); ++ } ++ let file = std::fs::File::create(path).map_err(|e| e.to_string())?; ++ let mut enc = png::Encoder::new(std::io::BufWriter::new(file), f.size[0], f.size[1]); ++ enc.set_color(png::ColorType::Rgba); ++ enc.set_depth(png::BitDepth::Eight); ++ enc.set_compression(png::Compression::Fast); ++ enc.write_header() ++ .and_then(|mut w| w.write_image_data(&rgba)) ++ .map_err(|e| e.to_string()) ++} ++ ++/// The raw sound as a WAV file; false when there was none. ++fn write_wav(raw: &Path, wav: &Path) -> Result { ++ let data = std::fs::read(raw).map_err(|e| e.to_string())?; ++ if data.is_empty() { ++ return Ok(false); ++ } ++ let mut out = Vec::with_capacity(data.len() + 44); ++ out.extend_from_slice(b"RIFF"); ++ out.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); ++ out.extend_from_slice(b"WAVEfmt "); ++ out.extend_from_slice(&16u32.to_le_bytes()); ++ out.extend_from_slice(&1u16.to_le_bytes()); ++ out.extend_from_slice(&2u16.to_le_bytes()); ++ out.extend_from_slice(&RATE.to_le_bytes()); ++ out.extend_from_slice(&(RATE * 4).to_le_bytes()); ++ out.extend_from_slice(&4u16.to_le_bytes()); ++ out.extend_from_slice(&16u16.to_le_bytes()); ++ out.extend_from_slice(b"data"); ++ out.extend_from_slice(&(data.len() as u32).to_le_bytes()); ++ out.extend_from_slice(&data); ++ std::fs::write(wav, out).map_err(|e| e.to_string())?; ++ let _ = std::fs::remove_file(raw); ++ Ok(true) ++} ++ ++impl FilmState { ++ /// Whether frames are being recorded now. ++ pub(super) fn recording(&self) -> bool { ++ self.film ++ .as_ref() ++ .is_some_and(|f| f.phase == Phase::Recording) ++ } ++} ++ ++impl Game<'_> { ++ /// Start of a frame: a mod's start or stop, the writer's end, and the time step to run the ++ /// frame by: `real` normally, exactly one frame of the film while it records. ++ pub(super) fn film_step(&mut self, real: Duration) -> Duration { ++ self.film_poll(); ++ let paused = self.pause; ++ let Some(film) = self.film.film.as_mut() else { ++ return real; ++ }; ++ match film.phase { ++ Phase::Arming => { ++ self.film_arm(); ++ real ++ } ++ Phase::Recording => { ++ // A pause menu stops the film, not the frame count. ++ if paused { ++ return Duration::ZERO; ++ } ++ let fps = u64::from(film.req.fps.max(1)); ++ let n = film.stepped; ++ film.stepped += 1; ++ let at = |k: u64| (k * 1_000_000_000 + fps / 2) / fps; ++ Duration::from_nanos(at(n + 1) - at(n)) ++ } ++ _ => real, ++ } ++ } ++ ++ /// The mods' requests, the writer's outcome, the quality, and the report back to the mods. ++ fn film_poll(&mut self) { ++ let (start, stop) = self.mods_film_requests(); ++ if let Some(req) = start { ++ if self.film.film.is_some() { ++ self.film_fail("a capture is already running"); ++ } else { ++ match self.film_open(req) { ++ Ok(film) => { ++ self.film.report = FilmReport { ++ phase: Phase::Arming, ++ ..Default::default() ++ }; ++ self.film.film = Some(film); ++ } ++ Err(e) => self.film_fail(&e), ++ } ++ } ++ } ++ if let Some(cancel) = stop { ++ self.film_finish(cancel); ++ } ++ // The writer is done. ++ if let Some(film) = self.film.film.as_mut() ++ && film.phase == Phase::Encoding ++ { ++ let outcome = film.outcome.lock().ok().and_then(|mut o| o.take()); ++ if let Some(o) = outcome { ++ if let Some(t) = film.thread.take() { ++ let _ = t.join(); ++ } ++ let (frames, size) = (film.sent, film.size); ++ let cancelled = film.cancel; ++ let before = film.before; ++ let audio = film.req.audio; ++ self.film.film = None; ++ if audio { ++ self.audio.film_end(); ++ } ++ self.film_restore(before); ++ self.film.report = FilmReport { ++ phase: if o.ok { Phase::Done } else { Phase::Failed }, ++ frames, ++ size, ++ file: o.file, ++ message: if cancelled && !o.ok { ++ "cancelled".into() ++ } else { ++ o.message ++ }, ++ }; ++ } ++ } ++ // Film quality: a capture needs it; otherwise it is the mods' choice. ++ let want = self.film.film.is_some() || self.mods_film_quality(); ++ horizon_client::gfx::set_film_quality(want); ++ self.film_report(); ++ } ++ ++ fn film_fail(&mut self, message: &str) { ++ self.film.report = FilmReport { ++ phase: Phase::Failed, ++ message: message.into(), ++ ..Default::default() ++ }; ++ } ++ ++ /// The capture's report, as the mods read it. ++ fn film_report(&mut self) { ++ let mut r = self.film.report.clone(); ++ if let Some(f) = &self.film.film { ++ r.phase = f.phase; ++ r.frames = f.sent; ++ r.size = f.size; ++ if !f.note.is_empty() { ++ r.message = f.note.clone(); ++ } ++ } ++ self.mods_film_report(&r); ++ } ++ ++ fn film_open(&mut self, req: FilmRequest) -> Result { ++ let Some(renderer) = self.renderer.as_ref() else { ++ return Err("no renderer".into()); ++ }; ++ renderer.can_capture().map_err(|e| e.to_string())?; ++ let dir = horizon_client::logging::game_home().join(&req.folder); ++ std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; ++ let before = renderer.frame_size(); ++ Ok(Film { ++ req, ++ dir, ++ phase: Phase::Arming, ++ waited: 0, ++ stepped: 0, ++ sent: 0, ++ size: [0, 0], ++ before, ++ sink: None, ++ outcome: Arc::new(Mutex::new(None)), ++ thread: None, ++ view: None, ++ mixed: 0, ++ cancel: false, ++ note: String::new(), ++ }) ++ } ++ ++ /// Arming: the picture is brought to the size; at the size (or when the window will not ++ /// give it) the writer starts and the next frame is the film's first. ++ fn film_arm(&mut self) { ++ let offscreen = self.options.offscreen; ++ let Some(film) = self.film.film.as_mut() else { ++ return; ++ }; ++ let Some(renderer) = self.renderer.as_mut() else { ++ return; ++ }; ++ let have = renderer.frame_size(); ++ let want = [film.req.width, film.req.height]; ++ film.waited += 1; ++ let at_size = want[0] == 0 || have == want; ++ if !at_size && film.waited == 1 { ++ if offscreen { ++ renderer.resize(want[0], want[1]); ++ } else if let Some(w) = &self.window { ++ let _ = w.request_inner_size(winit::dpi::PhysicalSize::new(want[0], want[1])); ++ } ++ return; ++ } ++ if !at_size && film.waited < 120 { ++ return; ++ } ++ if !at_size { ++ film.note = format!( ++ "the window would not become {}x{}; filming at {}x{}", ++ want[0], want[1], have[0], have[1] ++ ); ++ } ++ film.size = have; ++ let (tx, rx) = mpsc::sync_channel(QUEUE); ++ let (dir, name, fps, crf, audio) = ( ++ film.dir.clone(), ++ film.req.name.clone(), ++ film.req.fps, ++ film.req.crf, ++ film.req.audio, ++ ); ++ let outcome = film.outcome.clone(); ++ film.thread = std::thread::Builder::new() ++ .name("film writer".into()) ++ .spawn(move || write(rx, dir, name, fps, have, crf, audio, outcome)) ++ .ok(); ++ if film.thread.is_none() { ++ self.film_fail("could not start the writer"); ++ self.film.film = None; ++ return; ++ } ++ film.sink = Some(tx); ++ film.phase = Phase::Recording; ++ film.stepped = 0; ++ if film.req.audio { ++ self.audio.film_begin(); ++ } ++ // Ticks count from here, whole, each `fps / 20` frames apart. ++ self.clock.reset(); ++ } ++ ++ /// Before the frame is drawn: the cut's warm-up frames, then the copy for the writer. ++ pub(super) fn film_before_render(&mut self) -> horizon_client::Result<()> { ++ let view = self.mods_main_view_id(); ++ let pause = self.pause; ++ let Some(film) = self.film.film.as_mut() else { ++ return Ok(()); ++ }; ++ if film.phase != Phase::Recording || pause { ++ return Ok(()); ++ } ++ let warm = film.req.warmup; ++ let cut = view != film.view; ++ film.view = view; ++ if cut && warm > 0 && film.stepped > 1 { ++ // The new shot's histories are built from frames nobody sees. ++ for _ in 0..warm { ++ self.render()?; ++ } ++ } ++ if let Some(r) = self.renderer.as_mut() { ++ r.capture_next = true; ++ } ++ Ok(()) ++ } ++ ++ /// After the frame: the finished copies go to the writer, the audio of the frame is mixed. ++ pub(super) fn film_after_render(&mut self, rendered: bool) { ++ let Some(film) = self.film.film.as_mut() else { ++ return; ++ }; ++ if film.phase != Phase::Recording { ++ return; ++ } ++ let Some(renderer) = self.renderer.as_mut() else { ++ return; ++ }; ++ if renderer.frame_size() != film.size { ++ let (w, h) = (renderer.frame_size()[0], renderer.frame_size()[1]); ++ renderer.capture_next = false; ++ self.film_end( ++ &format!("the picture changed size to {w}x{h}: the film was stopped"), ++ false, ++ ); ++ return; ++ } ++ if rendered && !self.pause && renderer.capture_pending() < 64 { ++ // Sounds for this frame, on the film's clock. ++ if film.req.audio { ++ let fps = u64::from(film.req.fps.max(1)); ++ let rate = u64::from(RATE); ++ let f = u64::from(film.sent) + renderer.capture_pending() as u64; ++ let frames = ((f + 1) * rate / fps - f * rate / fps) as usize; ++ let mix = self.audio.film_mix(frames, RATE); ++ film.mixed += frames as u64; ++ if let Some(tx) = &film.sink { ++ let _ = tx.send(Msg::Audio(mix)); ++ } ++ } ++ } ++ match renderer.capture_ready(2) { ++ Ok(frames) => { ++ for f in frames { ++ film.sent += 1; ++ if let Some(tx) = &film.sink ++ && tx.send(Msg::Frame(f)).is_err() ++ { ++ film.note = "the writer stopped".into(); ++ } ++ } ++ } ++ Err(e) => { ++ let why = e.to_string(); ++ self.film_end(&why, false); ++ } ++ } ++ let outcome_early = self ++ .film ++ .film ++ .as_ref() ++ .and_then(|f| f.outcome.lock().ok().map(|o| o.is_some())) ++ .unwrap_or(false); ++ if outcome_early { ++ self.film_finish(true); ++ } ++ } ++ ++ /// Ends the capture with a reason (an error); the writer is told to keep what it has. ++ fn film_end(&mut self, why: &str, cancel: bool) { ++ if let Some(f) = self.film.film.as_mut() { ++ f.note = why.to_string(); ++ } ++ self.film_finish(cancel); ++ } ++ ++ /// Stops recording: the copies still in flight go to the writer, which closes the file (or ++ /// throws it away); the end is seen in `film_poll`. ++ fn film_finish(&mut self, cancel: bool) { ++ let Some(film) = self.film.film.as_mut() else { ++ return; ++ }; ++ match film.phase { ++ Phase::Arming => { ++ let before = film.before; ++ self.film.film = None; ++ self.film_restore(before); ++ self.film.report = FilmReport { ++ phase: Phase::Failed, ++ message: "cancelled".into(), ++ ..Default::default() ++ }; ++ } ++ Phase::Recording => { ++ if let Some(r) = self.renderer.as_mut() { ++ r.capture_next = false; ++ let rest = if cancel { ++ r.capture_discard(); ++ Ok(Vec::new()) ++ } else { ++ r.capture_ready(0).and_then(|_| { ++ // Everything in flight, waited for. ++ r.capture_ready(0) ++ }) ++ }; ++ if let (Ok(frames), Some(tx)) = (rest, &film.sink) { ++ for f in frames { ++ film.sent += 1; ++ let _ = tx.send(Msg::Frame(f)); ++ } ++ } ++ } ++ film.cancel = cancel; ++ if let Some(tx) = film.sink.take() { ++ let _ = tx.send(if cancel { Msg::Cancel } else { Msg::Finish }); ++ } ++ film.phase = Phase::Encoding; ++ } ++ _ => {} ++ } ++ } ++ ++ /// The screen's size as it was before the film. ++ fn film_restore(&mut self, before: [u32; 2]) { ++ let offscreen = self.options.offscreen; ++ if let Some(r) = self.renderer.as_mut() ++ && r.frame_size() != before ++ { ++ if offscreen { ++ r.resize(before[0], before[1]); ++ } else if let Some(w) = &self.window { ++ let _ = w.request_inner_size(winit::dpi::PhysicalSize::new(before[0], before[1])); ++ } ++ } ++ } ++} +diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs +index 7a4d742df..9367bb994 100644 +--- a/crates/horizon-client/src/game/mod_models.rs ++++ b/crates/horizon-client/src/game/mod_models.rs +@@ -11,7 +11,7 @@ use { + particles::{ParticleOptions, ParticleType}, + }, + horizon_core::random::RandomSource, +- horizon_mods::{CameraView, ModelRef, ParticleSpawn, PointLight}, ++ horizon_mods::{CameraView, ModelRef, ParticleSpawn, PointLight, SpotLight}, + horizon_world::registry::BlockStateId, + std::collections::HashMap, + }; +@@ -42,6 +42,8 @@ pub(super) struct ModModels { + views: Vec<(CameraView, CameraView)>, + /// The mods' lights by (mod, id): a tick ago and now. + lights: HashMap<(usize, u32), (PointLight, PointLight)>, ++ /// The mods' spot lights by (mod, id): a tick ago and now. ++ spots: HashMap<(usize, u32), (SpotLight, SpotLight)>, + } + + /// The overlays of the mods' models (entity.wgsl): glowing. +@@ -84,6 +86,13 @@ impl Game<'_> { + ) -> Vec { + Vec::new() + } ++ pub(super) fn mod_spots( ++ &mut self, ++ _camera: glam::DVec3, ++ _alpha: f64, ++ ) -> Vec { ++ Vec::new() ++ } + pub(super) fn mod_smoke( + &mut self, + _camera: glam::DVec3, +@@ -170,6 +179,13 @@ impl Game<'_> { + lights.insert(key, (before, *l)); + } + self.mods.models.lights = lights; ++ let mut spots: HashMap<(usize, u32), (SpotLight, SpotLight)> = HashMap::new(); ++ for (owner, l) in self.mods.mods.spot_lights() { ++ let key = (owner, l.id); ++ let before = self.mods.models.spots.get(&key).map_or(*l, |(_, now)| *now); ++ spots.insert(key, (before, *l)); ++ } ++ self.mods.models.spots = spots; + let views: Vec = self.mods.mods.views().map(|(_, v)| *v).collect(); + let old = std::mem::take(&mut self.mods.models.views); + self.mods.models.views = views +@@ -230,6 +246,64 @@ impl Game<'_> { + out.into_iter().take(512).map(|(_, l)| l).collect() + } + ++ /// The mods' spot lights between their last two settings, relative to the camera; the nearest ++ /// (to the cone, not only the apex) first, at most `MAX_SPOTS`. Also tells the mods whether HD ++ /// draws them (so they keep their own beam models otherwise). ++ pub(super) fn mod_spots( ++ &mut self, ++ camera: DVec3, ++ alpha: f64, ++ ) -> Vec { ++ let on = self.mod_hd().is_some() && horizon_client::gfx::hd_spots_enabled(); ++ self.mods.mods.set_spot_active(on); ++ if !on { ++ return Vec::new(); ++ } ++ let t = alpha.clamp(0.0, 1.0); ++ let lerp = |a: f32, b: f32| a + (b - a) * t as f32; ++ let mut out: Vec<(f32, horizon_client::gfx::HdSpot)> = self ++ .mods ++ .models ++ .spots ++ .values() ++ .filter_map(|(a, b)| { ++ let at = DVec3::new(a.x, a.y, a.z).lerp(DVec3::new(b.x, b.y, b.z), t) - camera; ++ let dir = glam::Vec3::new(lerp(a.dx, b.dx), lerp(a.dy, b.dy), lerp(a.dz, b.dz)) ++ .try_normalize()?; ++ // Nearest point of the axis to the camera: far cones that point away do not count. ++ let along = (-at).dot(dir.as_dvec3()).clamp(0.0, f64::from(b.range)); ++ let d = (at + dir.as_dvec3() * along).length() as f32; ++ if d > 1500.0 { ++ return None; ++ } ++ let inner = lerp(a.inner_angle, b.inner_angle).cos(); ++ let outer = lerp(a.outer_angle, b.outer_angle).cos(); ++ let spot = horizon_client::gfx::HdSpot { ++ pos: [ ++ at.x as f32, ++ at.y as f32, ++ at.z as f32, ++ lerp(a.range, b.range), ++ ], ++ dir: [dir.x, dir.y, dir.z, outer], ++ color: [ ++ b.color.0, ++ b.color.1, ++ b.color.2, ++ lerp(a.intensity, b.intensity), ++ ], ++ cone: [inner, lerp(a.beam, b.beam), 0.0, 0.0], ++ }; ++ Some((d, spot)) ++ }) ++ .collect(); ++ out.sort_by(|x, y| x.0.total_cmp(&y.0)); ++ out.into_iter() ++ .take(horizon_client::gfx::HD_MAX_SPOTS) ++ .map(|(_, s)| s) ++ .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. +diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs +index effc9c23d..6623905f0 100644 +--- a/crates/horizon-client/src/game/mods.rs ++++ b/crates/horizon-client/src/game/mods.rs +@@ -280,6 +280,18 @@ impl Game<'_> { + pub(super) fn sync_mods(&mut self) {} + pub(super) fn tick_mods(&mut self) {} + pub(super) fn mods_frame(&mut self, _elapsed: Duration) {} ++ pub(super) fn mods_film_requests( ++ &mut self, ++ ) -> (Option, Option) { ++ (None, None) ++ } ++ pub(super) fn mods_film_quality(&self) -> bool { ++ false ++ } ++ pub(super) fn mods_film_report(&mut self, _report: &super::film::FilmReport) {} ++ pub(super) fn mods_main_view_id(&self) -> Option { ++ None ++ } + pub(super) fn mod_command(&mut self, _command: &str) -> bool { + false + } +@@ -657,6 +669,51 @@ impl Game<'_> { + } + } + /// Once per frame: the frame hook, and dev mods' rebuilds. ++ /// AirDef fork: a mod's `render.capture-start` and `capture-stop`, taken. ++ pub(super) fn mods_film_requests( ++ &mut self, ++ ) -> (Option, Option) { ++ let start = self ++ .mods ++ .mods ++ .take_capture_request() ++ .map(|o| super::film::FilmRequest { ++ folder: o.folder, ++ name: o.name, ++ fps: o.fps, ++ width: o.width, ++ height: o.height, ++ crf: o.crf, ++ audio: o.audio, ++ warmup: o.warmup, ++ }); ++ (start, self.mods.mods.take_capture_stop()) ++ } ++ pub(super) fn mods_film_quality(&self) -> bool { ++ self.mods.mods.film_quality() ++ } ++ pub(super) fn mods_film_report(&mut self, r: &super::film::FilmReport) { ++ use super::film::Phase; ++ use horizon_mods::{CapturePhase, CaptureState}; ++ self.mods.mods.set_capture_state(CaptureState { ++ phase: match r.phase { ++ Phase::Idle => CapturePhase::Idle, ++ Phase::Arming => CapturePhase::Arming, ++ Phase::Recording => CapturePhase::Recording, ++ Phase::Encoding => CapturePhase::Encoding, ++ Phase::Done => CapturePhase::Done, ++ Phase::Failed => CapturePhase::Failed, ++ }, ++ frames: r.frames, ++ width: r.size[0], ++ height: r.size[1], ++ file: r.file.clone(), ++ message: r.message.clone(), ++ }); ++ } ++ pub(super) fn mods_main_view_id(&self) -> Option { ++ self.mods.mods.main_view() ++ } + pub(super) fn mods_frame(&mut self, elapsed: Duration) { + self.reload_dev_mods(); + if !self.mods.mods.is_running() { +@@ -758,7 +815,10 @@ impl Game<'_> { + volume, + pitch, + } => { ++ // A film hears only the mods' sounds (`Audio::film_begin`). ++ self.audio.film_allow(true); + self.audio.play(&event, Some(at), volume, pitch); ++ self.audio.film_allow(false); + continue; + } + Output::PlaceRecipe { book_id, all } => { +diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs +index 1a4e68b88..9482cf0d2 100644 +--- a/crates/horizon-client/src/gfx.rs ++++ b/crates/horizon-client/src/gfx.rs +@@ -84,7 +84,11 @@ mod mod_views; + mod native_material; + mod native_pack; + mod native_post; +-pub use hd::{HdLight, HdSettings, HdSmoke, smoke_enabled as hd_smoke_enabled}; ++pub use hd::set_film_quality; ++pub use hd::{ ++ HdLight, HdSettings, HdSmoke, HdSpot, MAX_SPOTS as HD_MAX_SPOTS, ++ smoke_enabled as hd_smoke_enabled, spots_enabled as hd_spots_enabled, ++}; + pub use mod_views::ModView; + mod native_sky; + mod occlusion; +@@ -235,6 +239,8 @@ pub struct Frame<'a> { + /// The HD lighting (AirDef fork): on with its settings, and the mods' lights. + pub hd: Option, + pub hd_lights: &'a [HdLight], ++ /// The mods' spot lights (searchlights), camera-relative, the nearest first. ++ pub hd_spots: &'a [HdSpot], + /// The mods' smoke segments for HD's volumetric smoke, camera-relative, and the mod's clock + /// in seconds. + pub hd_smoke: &'a [HdSmoke], +@@ -622,6 +628,12 @@ pub struct Renderer { + started: Instant, + /// Write the next presented frame to this PNG path (control socket). + pub screenshot: Option, ++ /// AirDef fork (film capture, `game/film.rs`): the next frame is copied back for the ++ /// encoder, `capture_queue` holding the copies the GPU has not finished. ++ pub capture_next: bool, ++ capture_queue: std::collections::VecDeque, ++ /// AirDef fork: the shot changed (a mod's main view): TAA and the volumes start over. ++ pub cut: bool, + /// Outcome of the last screenshot: frame size, or the error. + pub screenshot_result: Option>, + /// Brightness option (lightmap gamma), 0..1. +@@ -2920,6 +2932,9 @@ impl Renderer { + uploaded_bytes: 0, + started: Instant::now(), + screenshot: None, ++ capture_next: false, ++ capture_queue: std::collections::VecDeque::new(), ++ cut: false, + screenshot_result: None, + gamma: 0.5, + fullbright: false, +@@ -3436,7 +3451,13 @@ impl Renderer { + ¶ms, + self.mod_params.1, + ); +- post.run_view(&self.device, encoder, &t.post_buffer, (&t.color, &t.depth), &t.fx); ++ post.run_view( ++ &self.device, ++ encoder, ++ &t.post_buffer, ++ (&t.color, &t.depth), ++ &t.fx, ++ ); + t.post = true; + } + } +@@ -4099,6 +4120,12 @@ impl Renderer { + &mut self.taa_prev, + taa.then_some((unjittered, frame.camera, view_proj)), + ); ++ // AirDef fork: a cut starts the temporal histories over (no ghost of the last shot). ++ let taa_prev = if std::mem::take(&mut self.cut) { ++ None ++ } else { ++ taa_prev ++ }; + if frame.title { + self.panorama.prepare( + &self.queue, +@@ -5239,6 +5266,7 @@ impl Renderer { + frame.hd_lights, + frame.hd_smoke_clock, + ); ++ hd.write_spots(frame.hd_spots); + hd.write(&self.queue, &hd_scene, frame.hd_lights, settings); + } + // vanilla's translucent water leaves the colour while the HD water draws it. +@@ -6121,6 +6149,8 @@ impl Renderer { + (&view_target, self.config.format.remove_srgb_suffix()), + ); + } ++ let capture_copy = std::mem::take(&mut self.capture_next) ++ .then(|| self.copy_frame(&mut encoder, &target_texture)); + let readback = self.screenshot.take().and_then(|path| { + // The copy and PNG writer both assume 8-bit pixels; a float surface would make + // the row stride too small for the copy and abort in validation. +@@ -6162,6 +6192,22 @@ impl Renderer { + if hd_on && let Some(hd) = &mut self.hd { + hd.timing_submitted(&self.queue); + } ++ if let Some(buffer) = capture_copy { ++ let ready = Arc::new(std::sync::atomic::AtomicBool::new(false)); ++ let flag = ready.clone(); ++ buffer.map_async(wgpu::MapMode::Read, .., move |r| { ++ flag.store(r.is_ok(), std::sync::atomic::Ordering::Release); ++ }); ++ self.capture_queue.push_back(CaptureRead { ++ buffer, ++ ready, ++ size: [self.config.width, self.config.height], ++ bgra: matches!( ++ self.config.format.remove_srgb_suffix(), ++ wgpu::TextureFormat::Bgra8Unorm ++ ), ++ }); ++ } + if let Some((path, buffer)) = readback { + self.screenshot_result = Some( + self.write_screenshot(&path, &buffer) +@@ -6209,7 +6255,91 @@ impl Renderer { + Ok(true) + } + } ++/// A frame copied for a capture, waiting for the GPU (AirDef fork). ++struct CaptureRead { ++ buffer: wgpu::Buffer, ++ ready: Arc, ++ size: [u32; 2], ++ bgra: bool, ++} ++/// A captured frame: tightly packed 8-bit pixels, BGRA or RGBA by `bgra`. ++pub struct CapturedFrame { ++ pub size: [u32; 2], ++ pub bgra: bool, ++ pub data: Vec, ++} + impl Renderer { ++ /// Whether frames can be captured (the surface is 8-bit and readable). ++ pub fn can_capture(&self) -> Result<()> { ++ if self.config.format.block_copy_size(None) != Some(4) { ++ return Err(format!( ++ "capture needs a 32-bit surface format, not {:?}", ++ self.config.format ++ ) ++ .into()); ++ } ++ if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) { ++ return Err("this surface does not support frame readback".into()); ++ } ++ Ok(()) ++ } ++ /// The size frames are drawn at. ++ pub fn frame_size(&self) -> [u32; 2] { ++ [self.config.width, self.config.height] ++ } ++ /// How many captured frames wait for the GPU or for the caller. ++ pub fn capture_pending(&self) -> usize { ++ self.capture_queue.len() ++ } ++ /// The copies the GPU has finished, oldest first, as frames; with `keep` the queue is ++ /// held to at most that many by waiting for the oldest (a bounded queue, never ++ /// dropping a frame). ++ pub fn capture_ready(&mut self, keep: usize) -> Result> { ++ let _ = self.device.poll(wgpu::PollType::Poll); ++ let mut out = Vec::new(); ++ while let Some(front) = self.capture_queue.front() { ++ let ready = front.ready.load(std::sync::atomic::Ordering::Acquire); ++ if !ready && self.capture_queue.len() <= keep { ++ break; ++ } ++ if !ready { ++ self.device ++ .poll(wgpu::PollType::wait_indefinitely()) ++ .map_err(|e| e.to_string())?; ++ if !self.capture_queue[0] ++ .ready ++ .load(std::sync::atomic::Ordering::Acquire) ++ { ++ return Err("frame readback failed".into()); ++ } ++ } ++ let read = self.capture_queue.pop_front().expect("front"); ++ let [w, h] = read.size; ++ let row = (w * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) as usize; ++ let mut data = Vec::with_capacity((w * h * 4) as usize); ++ { ++ let mapped = read ++ .buffer ++ .get_mapped_range(..) ++ .map_err(|e| e.to_string())?; ++ for y in 0..h as usize { ++ data.extend_from_slice(&mapped[y * row..y * row + w as usize * 4]); ++ } ++ } ++ read.buffer.unmap(); ++ out.push(CapturedFrame { ++ size: read.size, ++ bgra: read.bgra, ++ data, ++ }); ++ } ++ Ok(out) ++ } ++ /// Drops the frames still on their way (a cancelled capture). ++ pub fn capture_discard(&mut self) { ++ let _ = self.device.poll(wgpu::PollType::wait_indefinitely()); ++ self.capture_queue.clear(); ++ } + fn padded_row(&self) -> u32 { + (self.config.width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) + } +diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl +index 39a239f4d..b2bdd3080 100644 +--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl ++++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl +@@ -112,7 +112,9 @@ fn fs_cloud(in: FullOut) -> CloudOut { + let hi = min(max(ta, tb), min(4200.0, scene_len)); + if hi > lo { + let dither = ign(in.position.xy); +- let n = clamp(i32(ceil((hi - lo) / 14.0)), 8, 26); ++ // Film quality (surface.z): half the step length, twice the cap. ++ let fine = frame.surface.z > 0.5; ++ let n = clamp(i32(ceil((hi - lo) / select(14.0, 7.0, fine))), select(8, 16, fine), select(26, 52, fine)); + let step_len = (hi - lo) / f32(n); + let light = frame.cloud_dir.xyz; + let cos_theta = dot(dir, light); +diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl +index d035bbf0d..3fde6e22f 100644 +--- a/crates/horizon-client/src/gfx/hd/fog.wgsl ++++ b/crates/horizon-client/src/gfx/hd/fog.wgsl +@@ -9,7 +9,16 @@ + //SKY7 + @group(0) @binding(8) var smoke_tex: texture_2d; + ++//SPOT ++ ++// The air's extinction for the spot lights' beams. ++fn spot_air(p: vec3) -> f32 { ++ return fog_sigma(p); ++} ++ + const FOG_MAX: f32 = 192.0; ++// Beams reach past the fog's own range, also into the sky. ++const BEAM_FAR: f32 = 1100.0; + const FOG_STEPS: i32 = 18; + + struct FogOut { +@@ -172,9 +181,11 @@ fn fs_fog(in: FullOut) -> FogOut { + var trans = 1.0; + var light = vec3(0.0); + var lit_sum = 0.0; +- for (var i = 0; i < FOG_STEPS; i++) { +- let u0 = (f32(i) + jitter) / f32(FOG_STEPS); +- let u1 = (f32(i) + 1.0 + jitter) / f32(FOG_STEPS); ++ // Film quality (surface.z) marches twice the steps. ++ let steps = select(FOG_STEPS, FOG_STEPS * 2, frame.surface.z > 0.5); ++ for (var i = 0; i < steps; i++) { ++ let u0 = (f32(i) + jitter) / f32(steps); ++ let u1 = (f32(i) + 1.0 + jitter) / f32(steps); + let f0 = 0.5 * u0 + 0.5 * u0 * u0; + let f1 = 0.5 * u1 + 0.5 * u1 * u1; + let t = len * f0; +@@ -195,6 +206,9 @@ fn fs_fog(in: FullOut) -> FogOut { + 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)); + var fog = vec4(light * glow, trans); ++ if frame.vfog.w > 0.5 { ++ fog = vec4(fog.rgb + spot_beams(dir, select(BEAM_FAR, length(p), d > 0.0), jitter), fog.a); ++ } + 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. +diff --git a/crates/horizon-client/src/gfx/hd/frame.rs b/crates/horizon-client/src/gfx/hd/frame.rs +index 415b12cdb..6b06732c3 100644 +--- a/crates/horizon-client/src/gfx/hd/frame.rs ++++ b/crates/horizon-client/src/gfx/hd/frame.rs +@@ -30,6 +30,25 @@ pub struct HdLight { + pub color: [f32; 4], + } + ++/// The most spot lights (searchlights) drawn a frame. ++pub const MAX_SPOTS: usize = 16; ++ ++/// A spot light for this frame, relative to the camera: a cone from its apex along `dir`, out to ++/// `pos.w`, that lights surfaces and is drawn as a volume of scattered light in the air. ++#[repr(C)] ++#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)] ++pub struct HdSpot { ++ /// xyz: camera-relative apex; w: range (blocks). ++ pub pos: [f32; 4], ++ /// xyz: unit axis; w: cosine of the outer half angle (past it: dark). ++ pub dir: [f32; 4], ++ /// rgb: colour; w: intensity (as a point light's). ++ pub color: [f32; 4], ++ /// x: cosine of the inner half angle (inside it: full); y: how visible the beam is in the ++ /// air (0..1); zw: unused. ++ pub cone: [f32; 4], ++} ++ + /// The frame's HD settings. + #[derive(Clone, Copy, Debug)] + pub struct HdSettings { +@@ -88,7 +107,8 @@ pub(super) struct HdFrame { + pub(super) cloud_dir: [f32; 4], + /// rgb: the colour of that light at the clouds; w: its strength. + 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. ++ /// x: 1 when the volumetric fog is drawn; y: the camera's height in the world; z: the morning mist; ++ /// w: the number of spot lights. + 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. +@@ -101,6 +121,9 @@ pub(super) struct HdFrame { + /// 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], ++ /// Up to `MAX_SPOTS` spot lights, four vec4 each (`HdSpot`: pos+range, dir+cos outer, ++ /// colour+intensity, cone); their number is `vfog.w`. ++ pub(super) spots: [[f32; 4]; 64], + } + + /// What the world around looks like this frame (from the renderer's globals). +diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl +index 870ae0d7d..a9e1fb7f4 100644 +--- a/crates/horizon-client/src/gfx/hd/light.wgsl ++++ b/crates/horizon-client/src/gfx/hd/light.wgsl +@@ -16,8 +16,8 @@ struct Light { + @group(0) @binding(10) var lightmap: texture_2d; + @group(0) @binding(11) var linear: sampler; + // Per tile of 16x16 pixels: where its surface list and its halo list start in this array and +-// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means +-// every light. ++// how long they are (4 words), the mask of the spot lights whose cone can show in the tile (the ++// 5th word), then the lists of light indices. A length of 0xffffffff means every light. + @group(0) @binding(12) var tiles: array; + // Ambient occlusion at half resolution: x the occlusion (1 open), y the distance it was taken at. + @group(0) @binding(13) var ao_tex: texture_2d; +@@ -225,6 +225,13 @@ fn cloud_at(px: vec2, dist: f32) -> vec4 { + return sum / total; + } + ++//SPOT ++ ++// Without the volumetric fog the beams use the air's constant density. ++fn spot_air(p: vec3) -> f32 { ++ return frame.ambient.z; ++} ++ + struct LightOut { + // The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an + // object in front of the water (the scene as drawn). +@@ -255,7 +262,7 @@ fn shade_surface(px: vec2, p: vec3, water: bool) -> vec3 { + let sun_on = frame.sun.w; + let atmosphere = frame.sky_sun.w > 0.5; + let count = u32(frame.params.x); +- let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u; ++ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 5u; + let surface_start = tiles[tile]; + let surface_all = tiles[tile + 1u] == 0xffffffffu; + let surface_count = select(tiles[tile + 1u], count, surface_all); +@@ -311,6 +318,13 @@ fn shade_surface(px: vec2, p: vec3, water: bool) -> vec3 { + sky_tint = mix(vec3(1.0), hue * shape, reach); + } + var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct; ++ // The spot lights whose cone reaches this tile. ++ let spot_mask = tiles[tile + 4u]; ++ for (var j = 0; j < i32(frame.vfog.w); j++) { ++ if ((spot_mask >> u32(j)) & 1u) != 0u { ++ light += spot_light(j, p, n) * ao_lights; ++ } ++ } + // The mods' lights. + for (var k = 0u; k < surface_count; k++) { + let l = lights[select(tiles[surface_start + k], k, surface_all)]; +@@ -384,7 +398,7 @@ fn fs_light(in: FullOut) -> LightOut { + let sun = frame.sun.xyz; + let sun_on = frame.sun.w; + let count = u32(frame.params.x); +- let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u; ++ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 5u; + let halo_start = tiles[tile + 2u]; + let halo_all = tiles[tile + 3u] == 0xffffffffu; + let halo_count = select(tiles[tile + 3u], count, halo_all); +@@ -473,6 +487,11 @@ fn fs_light(in: FullOut) -> LightOut { + let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08; + air_s += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density)); + } ++ // The spot lights' beams: the fog march draws them when it runs; else a cone integration here. ++ if !volumetric && frame.vfog.w > 0.5 && d >= 0.0 { ++ let beam_len = select(1100.0, length(ps), d > 0.0); ++ air_s += spot_beams(view_s, beam_len, ign(in.position.xy)); ++ } + for (var k = 0u; k < halo_count; k++) { + let l = lights[select(tiles[halo_start + k], k, halo_all)]; + // Nearest point of the view ray to the light, and the glow along the ray in front of it. +diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs +index d26f1e513..8c56768f8 100644 +--- a/crates/horizon-client/src/gfx/hd/lighting.rs ++++ b/crates/horizon-client/src/gfx/hd/lighting.rs +@@ -22,7 +22,7 @@ + //! 0.2%). + //! - The sun is the light `frame::light_of` picks (moon at night), in `HdFrame.sun`. + //! - Bind groups are cached per (world views, lightmap) and per volumetric-history flip. +-use super::frame::HdLight; ++use super::frame::{HdLight, HdSpot}; + use super::tiles::{TILE, tile_lists, tiles_buffer}; + use super::{ + DEPTH, FLOAT, HDR, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, STORAGE, UNFILTERED, UNIFORM, +@@ -157,6 +157,7 @@ impl Lighting { + queue: &wgpu::Queue, + view_proj: &glam::Mat4, + lights: &[HdLight], ++ spots: &[HdSpot], + size: [u32; 2], + ) -> usize { + let n = lights.len().min(MAX_LIGHTS); +@@ -167,6 +168,7 @@ impl Lighting { + tile_lists( + view_proj, + &lights[..n], ++ spots, + size, + &mut self.tile_scratch, + &mut self.tile_words, +diff --git a/crates/horizon-client/src/gfx/hd/mod.rs b/crates/horizon-client/src/gfx/hd/mod.rs +index 1199e073e..ccc793e9a 100644 +--- a/crates/horizon-client/src/gfx/hd/mod.rs ++++ b/crates/horizon-client/src/gfx/hd/mod.rs +@@ -89,13 +89,19 @@ use tonemap::Tonemap; + use volumes::HdVolumes; + use water::Water; + +-pub use frame::{HdLight, HdScene, HdSettings, light_of, vanilla_cloud_tint}; ++pub use frame::{HdLight, HdScene, HdSettings, HdSpot, MAX_SPOTS, 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 taa::{film_quality, jitter, jitter_count, scale_override, set_film_quality, taa_enabled}; + pub use volumes::clouds_enabled; ++ ++/// Spot lights (searchlights) are drawn unless `HORIZON_HD_SPOTS=0`. ++pub fn spots_enabled() -> bool { ++ static ON: std::sync::OnceLock = std::sync::OnceLock::new(); ++ *ON.get_or_init(|| std::env::var("HORIZON_HD_SPOTS").map_or(true, |v| v != "0")) ++} + pub use water::{WATER_NORMAL, WATER_TINT}; + + use frame::{HdFrame, cloud_light, smooth, sun_dir_x}; +@@ -162,9 +168,15 @@ pub(super) fn assemble(body: &str) -> String { + &sky::SKY_FUNCS.replace("SKYBIND", &binding.to_string()), + ); + } ++ // `//SPOT`: the spot lights' cone functions (`spot.wgsl`), which call the shader's own ++ // `spot_air(pos)` (the air's extinction per block). ++ let body = body.replace("//SPOT", SPOT); + format!("{}{body}", frame::PRELUDE) + } + ++/// The spot lights' cone intersection and beam march, shared by the fog and the lighting. ++const SPOT: &str = include_str!("spot.wgsl"); ++ + pub(super) fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule { + // Development: with `HORIZON_HD_SHADER_DIR` set, a shader's body is read from + // `/