Compare commits

..

5 Commits

Author SHA1 Message Date
chexit 76f680eba6 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)
build / mod (push) Successful in 3m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:28:13 +03:00
chexit 8a12225ba1 Replays in full HD with shader effects driven by the recording (#56), recorded clock survives gaps (#57), cinematic intercepts: layered sound with travel delay, big flash, light burst, exposure punch, shock ring (#51) (fork patch 31)
build / mod (push) Successful in 3m21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:59:47 +03:00
chexit 4bab572293 HD smoke trails as smooth ribbons (fork patch 30)
build / mod (push) Successful in 3m21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:44:45 +03:00
chexit f43fa5ee66 Clouds occlude by depth, world-anchored noise, visual-only replay time (fork patch 29; #67, #68, #70)
build / mod (push) Successful in 3m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:26:10 +03:00
chexit 336ae9f7cc gitignore the smoke agent's test home
build / mod (push) Successful in 3m22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:17:21 +03:00
46 changed files with 5646 additions and 124 deletions
+1
View File
@@ -6,3 +6,4 @@ __pycache__/
/out/
.DS_Store
run-home-bench/
run-home-smoke/
+2
View File
@@ -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 с звуком: `<HORIZON_HOME>/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 <patriot|gepard|pantsir|s400|s500|all> <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`;
+36 -10
View File
@@ -8,6 +8,7 @@
// [0] now (mod ticks), blasts, lights, -
// [1 + 2i], [2 + 2i] blast i: x, y, z, size | start tick, life ticks, kind, -
// [97 + 2j], [98 + 2j] light j: x, y, z, radius | r, g, b, intensity
// [255].x hold: 0 live; a replay's share of the frame's partial tick it does not advance with
const BLAST_BASE: i32 = 1;
const LIGHT_BASE: i32 = 97;
@@ -80,7 +81,7 @@ fn screen_radius_clip(c0: vec4<f32>, radius: f32) -> f32 {
}
fn now() -> f32 {
return frame.params[0].x + frame.time.y;
return frame.params[0].x + frame.time.y * (1.0 - frame.params[255].x);
}
// Fire's colour from its heat (0 cold soot .. 1 white-hot).
@@ -231,12 +232,15 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
}
color = color * trans + lit;
// 3. Flashes: a bright bloom at the blast and the whole view lit up for a moment.
// 3. Flashes (#51): a huge bright bloom at the blast, a wide halo that lifts the sky and the
// clouds, a white shock ring racing out, and the view lit up for a moment. The sum is
// squashed, so a crowded sky never whites the screen out for long.
var flare = vec3<f32>(0.0);
for (var i = 0; i < blasts; i++) {
let a = frame.params[BLAST_BASE + 2 * i];
let b = frame.params[BLAST_BASE + 2 * i + 1];
let age = t_now - b.x;
if age < 0.0 || age > 10.0 { continue; }
if age < 0.0 || age > 24.0 { continue; }
let c0 = clip_of(a.xyz);
if c0.w <= 0.5 { continue; }
let p = vec3<f32>(clip_uv(c0), c0.w);
@@ -244,15 +248,37 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
// than the distance along the ray towards the screen edge.
let dist = length(a.xyz - frame.camera.xyz);
let visible = select(0.25, 1.0, scene_distance(p.xy) > dist - a.w);
let f = exp(-age * 0.7) * min(a.w / 4.0, 2.5) * visible;
let r = screen_radius_clip(c0, a.w * 2.5) + 20.0;
let dd = length(px - p.xy * frame.resolution.xy) / r;
// The bloom's tail is below 5e-4 (under 8 bits) past nine radii.
if dd < 9.0 {
color += vec3<f32>(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 2.5 + exp(-dd * 0.8) * 0.25);
let mass = min(a.w / 4.0, 2.5);
// How close it is, in its own sizes: a burst overhead is felt, a far one only seen.
let near = clamp(1.0 - dist / (a.w * 40.0), 0.0, 1.0);
let centre = p.xy * frame.resolution.xy;
if age <= 10.0 {
let f = exp(-age * 0.7) * mass * visible;
let r = screen_radius_clip(c0, a.w * 2.5) + 20.0;
let dd = length(px - centre) / r;
// The bloom's tail is below 5e-4 (under 8 bits) past nine radii.
if dd < 9.0 {
flare += vec3<f32>(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 3.2 + exp(-dd * 0.8) * 0.3);
}
// The halo: a third of the screen wide for a close blast, lifting the sky and clouds most.
let wide = (0.12 + 0.3 * near) * frame.resolution.y;
let dw = length(px - centre) / wide;
if dw < 4.0 {
let sky = select(1.0, 1.7, wall > 1.0e5);
flare += vec3<f32>(1.0, 0.82, 0.58) * f * near * sky * exp(-dw * dw) * 0.55;
}
flare += vec3<f32>(1.0, 0.8, 0.55) * f * (0.12 + 0.2 * near) / (1.0 + p.z * 0.01);
}
// The shock ring: the same radius as the wave that bends the picture.
let ring = screen_radius_clip(c0, age * 3.2 * (0.6 + a.w * 0.1));
let width = 3.0 + ring * 0.025;
let x = (length(px - centre) - ring) / width;
if abs(x) < 4.0 {
let fade = 1.0 - age / 24.0;
flare += vec3<f32>(1.0, 0.9, 0.72) * exp(-x * x) * fade * fade * mass * (0.25 + 0.5 * near) * visible;
}
color += vec3<f32>(1.0, 0.8, 0.55) * f * 0.12 / (1.0 + p.z * 0.01);
}
color += flare / (1.0 + flare * 0.2);
// 4. Glows: engine flames and muzzle flashes.
for (var j = 0; j < lights; j++) {
+111
View File
@@ -0,0 +1,111 @@
//! The cinematic look of a blast (#51): a layered sound (a close crack, a low thump, a rolling
//! tail; from afar the muffled report and a late boom), and the camera's reaction (a brief
//! exposure and bloom punch). Everything is a function of the blast, its distance and its age, so
//! a replay hears and sees the same as the live raid did, from the replay's camera.
/// The power (0 air burst .. 4 ballistic) of a blast sound event, if it is one.
pub fn blast_power(event: &str) -> Option<usize> {
Some(match event {
"airdef:blast.air" => 0,
"airdef:blast.medium" => 1,
"airdef:blast.big" => 2,
"airdef:blast.heavy" => 3,
"airdef:blast.huge" => 4,
_ => return None,
})
}
/// One voice of a blast: the sound event (with `_far` where the listener is far), its loudness
/// and pitch, and the ticks it follows the first after the sound has travelled.
#[derive(Clone, Debug, PartialEq)]
pub struct Layer {
pub name: String,
pub volume: f32,
pub pitch: f32,
pub delay: u64,
}
/// Blasts of one tick that get all their layers, the nearest first: the rest of a mass raid
/// plays its one voice, so the key events are not buried and the mix does not clip.
pub const FULL: usize = 3;
/// The voices of a blast of `power`, `dist` blocks from the listener (`far`: where the muffled
/// variants take over), `gain` its loudness (1 normal; bigger warheads more), the `rank`-th
/// nearest of its tick.
pub fn layers(power: usize, dist: f64, far: f64, gain: f32, pitch: f32, rank: usize) -> Vec<Layer> {
let g = gain.clamp(0.3, 2.0);
let own = ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][power.min(4)];
let layer = |name: &str, volume: f32, pitch: f32, delay: u64| Layer { name: name.to_string(), volume, pitch, delay };
let far_away = dist > far;
let suffix = if far_away { "_far" } else { "" };
if rank >= FULL {
return vec![layer(&format!("{own}{suffix}"), g, pitch, 0)];
}
if far_away {
// The muffled report, then a deep boom that arrives late.
return vec![
layer(&format!("{own}_far"), g, pitch, 0),
layer("airdef:blast.big_far", 0.7 * g, pitch * 0.6, 10 + 2 * power as u64),
];
}
let tail = (0.45 + 0.08 * power as f32) * g;
if power == 0 {
// An air burst has no body of its own: the crack, the thump, the roll.
vec![
layer("airdef:blast.air", g, pitch * 1.12, 0),
layer("airdef:blast.big", 0.85 * g, pitch * 0.66, 2),
layer("airdef:blast.huge", tail, pitch * 0.5, 7),
]
} else {
vec![
layer(own, g, pitch, 0),
layer("airdef:blast.air", 0.7 * g, pitch * 1.15, 0),
layer("airdef:blast.huge", tail, pitch * 0.5, 7),
]
}
}
/// How hard the picture is punched now (0 to 1) by a blast of fireball radius `size` that went
/// off `age` ticks ago `dist` blocks from the camera: bright and brief, near and big blasts most.
pub fn punch(size: f32, age: f32, dist: f64) -> f32 {
if age < 0.0 {
return 0.0;
}
let reach = (size as f64 * 45.0).max(30.0);
let near = (1.0 - dist / reach).clamp(0.0, 1.0) as f32;
let big = (size / 8.0).clamp(0.35, 1.0);
// A rise over a tick, then gone in about half a second.
(near * big * (-age / 3.0).exp() * (age / 0.8).min(1.0)).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_near_intercept_is_layered_and_a_mass_raid_is_bounded() {
let l = layers(0, 40.0, 140.0, 1.2, 1.0, 0);
assert_eq!(l.len(), 3);
assert!(l.iter().all(|v| !v.name.ends_with("_far")));
assert!(l[0].delay == 0 && l[2].delay > l[1].delay, "crack, then thump, then the roll");
// A far one is the muffled report and a late boom.
let f = layers(0, 400.0, 140.0, 1.0, 1.0, 0);
assert!(f.iter().all(|v| v.name.ends_with("_far")) && f[1].delay > 0);
// The rest of a tick's blasts play one voice, and the whole mix stays bounded.
let total: f32 = (0..12).flat_map(|r| layers(2, 60.0, 140.0, 1.0, 1.0, r)).map(|v| v.volume).sum();
let voices: usize = (0..12).map(|r| layers(2, 60.0, 140.0, 1.0, 1.0, r).len()).sum();
assert_eq!(voices, FULL * 3 + (12 - FULL));
assert!(total < 18.0, "{total}");
assert_eq!(blast_power("airdef:blast.huge"), Some(4));
assert_eq!(blast_power("airdef:boom.sonic"), None);
}
#[test]
fn the_punch_is_brief_near_and_bounded() {
assert_eq!(punch(5.0, -1.0, 10.0), 0.0);
assert!(punch(5.0, 1.5, 20.0) > punch(5.0, 1.5, 150.0));
assert!(punch(5.0, 1.5, 20.0) > punch(2.0, 1.5, 20.0));
assert!(punch(13.0, 1.0, 0.0) <= 1.0);
assert!(punch(5.0, 30.0, 20.0) < 0.01, "gone in a second and a half");
}
}
+575
View File
@@ -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<f64>,
variants: Vec<Rig>,
pick: usize,
/// The least height the eye keeps, at the planner's samples.
clear: Vec<f32>,
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<Shot>,
pub len: f64,
cells: Vec<(i32, i32)>,
heights: HashMap<(i32, i32), Option<f64>>,
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<DVec3> {
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<Rig>, 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<usize> {
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<usize> = 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<F: Fn(f64, f64) -> Rig>(mk: F) -> Vec<Rig> {
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<Shot> {
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<Shot> = 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<u32>, 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<Shot> {
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<Film> {
let events = pick(rec, which);
if events.is_empty() {
return None;
}
let mut shots: Vec<Shot> = 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<f64>>, 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<usize> = self.shots.iter().map(|s| s.event).collect();
format!("{} моментов, {} планов, {:.0} с", events.len(), self.shots.len(), self.len)
}
}
+343 -21
View File
@@ -7,11 +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;
@@ -74,6 +76,10 @@ struct AirDef {
wave: wave::WaveConfig,
/// A replay playing (it takes the screen and the mouse wheel).
replay: Option<replay::Player>,
/// An MP4 export in progress (`/airdef replay export`).
export: Option<Export>,
/// 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.
@@ -88,6 +94,8 @@ struct AirDef {
menu: Option<Page>,
/// Where the player is, for the sounds' delay and far variants.
listener: DVec3,
/// The picture punch (#51) the HD exposure and bloom were last set for.
punch: f32,
/// Sounds on their way to the player at the speed of sound.
pending: Vec<PendingSound>,
/// The handles `render.model-id` gave the model names (valid under `model_generation`); none:
@@ -98,6 +106,29 @@ struct AirDef {
by_id: Vec<ModelInstanceId>,
}
/// What `/airdef replay export` was asked for.
struct ExportOpts {
name: String,
which: String,
fps: u32,
size: (u32, u32),
crf: u32,
audio: bool,
seconds: Option<f64>,
}
#[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,
@@ -163,6 +194,16 @@ fn say(text: &str) {
chat::show(text);
}
/// What the effects draw this tick (`Mod::shown`).
struct Shown {
blasts: Vec<sim::Blast>,
/// The clock the blasts' `start` is on: the simulation's ticks, or the recording's.
now: f32,
/// 1 minus the share of the frame's partial tick the effects advance with.
hold: f32,
replay: bool,
}
fn player() -> Option<game::Player> {
game::get_player()
}
@@ -303,14 +344,37 @@ impl AirDef {
/// blasts (a flash, then the fireball's glow dying down), burning wrecks and the
/// searchlights' spots.
fn lights(&self, out: &Out) -> Vec<PointLight> {
let shown = self.shown();
let mut lights: Vec<PointLight> = Vec::new();
let mut add = |id: u32, at: DVec3, rgb: (f32, f32, f32), intensity: f32, radius: f32| {
if lights.len() < 500 && intensity > 0.05 {
lights.push(PointLight { id, x: at.x, y: at.y, z: at.z, color: rgb, intensity, radius: radius.clamp(1.0, 256.0) });
}
};
let tick = self.sim.tick;
let tick = shown.now as u64;
let flicker = |id: u32| 0.85 + 0.15 * (((tick as u32).wrapping_mul(2654435761) ^ id.wrapping_mul(97)) % 100) as f32 / 100.0;
// Blasts first, the nearest first: a crowded sky never costs the key intercept its light.
let viewer = self.viewer(&shown);
let mut near: Vec<&sim::Blast> = shown.blasts.iter().collect();
if let Some(e) = viewer {
near.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e)));
}
for (rank, b) in near.into_iter().enumerate() {
let age = (shown.now - b.start as f32).max(0.0);
let size = b.size;
let glow = (-age / (b.life as f32 * 0.15).max(1.0)).exp();
let flash = (-age * 0.35).exp() * 4.0;
// Its own id range: the models' and the systems' ids are small numbers.
let id = 0x6100_0000 | (b.id & 0x00ff_ffff);
let centre = b.at + DVec3::Y * size as f64 * 0.5;
add(id, centre, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0);
// The punch (#51): a white burst that lights the land, the smoke and the wrecks far round
// for a moment; for the nearest few, scaled by the blast.
if rank < 6 {
let burst = (-age / 2.0).exp();
add(0x6300_0000 | (b.id & 0x00ff_ffff), centre, (1.0, 0.9, 0.74), size * size * 95.0 * burst, (size * 30.0).min(256.0));
}
}
for m in out.models.iter().filter(|m| m.glow) {
let s = m.scale.max(0.1);
match m.name {
@@ -323,14 +387,9 @@ impl AirDef {
_ => {}
}
}
for b in &self.sim.blasts {
let age = tick.saturating_sub(b.start) as f32;
let size = b.size;
let glow = (-age / (b.life as f32 * 0.15).max(1.0)).exp();
let flash = (-age * 0.35).exp() * 4.0;
// Its own id range: the models' and the systems' ids are small numbers.
let id = 0x6100_0000 | (b.id & 0x00ff_ffff);
add(id, b.at + DVec3::Y * size as f64 * 0.5, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0);
// A replay shows its own flames (the models of the ghosts) and none of the live world's.
if shown.replay {
return lights;
}
// Fires burn on for minutes: a flickering, wide glow that dies down with the flames.
for f in self.sim.fires.list.iter().filter(|f| f.shown) {
@@ -346,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)
{
@@ -357,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<SpotLight> {
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;
@@ -367,29 +456,34 @@ impl AirDef {
}
self.hd = shader && hd;
self.apply_hd();
if !self.hd {
self.sim.spot_hd = false;
}
self.dirty = true;
}
/// Switches the HD lighting.
fn apply_hd(&self) {
render::set_lighting(Lighting { hd: self.hd, exposure: 1.0, bloom: 0.55 });
// (The punch of the next blast sets its own.)
if !self.hd {
render::set_lights(&[]);
let _ = render::set_spot_lights(&[]);
}
}
/// Hands the simulation's output to the game.
/// The post pass's parameters: blasts and glowing parts (shaders/effects.wgsl).
fn shader_params(&self, out: &Out) -> Vec<f32> {
let shown = self.shown();
let mut p = vec![0.0f32; 4];
p[0] = self.sim.tick as f32;
p[0] = shown.now;
p[3] = if self.smoke.hd { 2.0 } else if self.hd { 1.0 } else { 0.0 };
let mut blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect();
let mut blasts: Vec<&sim::Blast> = shown.blasts.iter().rev().take(48).collect();
// The shader composites the volumes front to back with one shared transmittance, so
// the nearest to the player goes first. This is exact for volumes that do not
// overlap; where two intersect, no order by centre is right along every ray.
if let Some(pl) = player() {
let e = eye(&pl);
if let Some(e) = self.viewer(&shown) {
blasts.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e)));
}
p[1] = blasts.len() as f32;
@@ -420,9 +514,52 @@ impl AirDef {
lights += 1;
}
p[2] = lights as f32;
// The last vec4: how much of the frame's partial tick the effects run on (0 live; a
// slowed or paused replay runs on its own time, not the frame's).
p.resize(1024, 0.0);
p[1020] = shown.hold;
p
}
/// The smoke volumes the renderer draws and the clock they are aged by: the live ones, or (in a
/// replay) the blasts' billows at the replay's moment, never the live raid's trails and fires.
/// The billows are the live ones' own function (`Smoke::segments`), run on the recording's
/// blasts and time with the live state set aside for the call.
fn shown_smoke(&mut self) -> (f64, Vec<render::SmokeSegment>) {
let shown = self.shown();
if !shown.replay {
return (self.sim.tick as f64, self.smoke.segments(&self.sim));
}
let blasts = std::mem::replace(&mut self.sim.blasts, shown.blasts);
let fires = std::mem::take(&mut self.sim.fires.list);
let tick = std::mem::replace(&mut self.sim.tick, shown.now as u64);
let segments = smoke::Smoke::default().segments(&self.sim);
self.sim.blasts = blasts;
self.sim.fires.list = fires;
self.sim.tick = tick;
(f64::from(shown.now), segments)
}
/// Where the picture is seen from: the replay's camera, or the player's eyes.
fn viewer(&self, shown: &Shown) -> Option<DVec3> {
match &self.replay {
Some(r) if shown.replay => Some(r.eye()),
_ => player().map(|pl| eye(&pl)),
}
}
/// The blasts the effects show and the clock they run on: the live ones, or (in a replay) the
/// recording's at the replay's moment, never the live world's.
fn shown(&self) -> Shown {
if let Some(r) = &self.replay
&& r.filming()
&& let Some(rec) = self.sim.replay(r.rec)
{
return Shown { blasts: sim::record::replay_blasts(rec, r.time()), now: r.time() as f32, hold: 1.0 - r.rate().clamp(0.0, 1.0), replay: true };
}
Shown { blasts: self.sim.blasts.clone(), now: self.sim.tick as f32, hold: 0.0, replay: false }
}
/// The models to the game by handle: each name crosses once, the buffer stays allocated.
fn send_models(&mut self, models: &[sim::Model]) {
let generation = render::model_generation();
@@ -460,8 +597,19 @@ impl AirDef {
render::set_params(&self.shader_params(&out));
}
if self.hd {
// The camera's reaction to a near blast: a brief exposure and bloom punch.
let shown = self.shown();
let p = self.viewer(&shown).map_or(0.0, |e| {
shown.blasts.iter().map(|b| cinema::punch(b.size, shown.now - b.start as f32, b.at.distance(e))).fold(0.0, f32::max)
});
if (p - self.punch).abs() > 0.01 || (p == 0.0 && self.punch != 0.0) {
self.punch = p;
render::set_lighting(Lighting { hd: true, exposure: 1.0 + 0.4 * p, bloom: 0.55 + 0.9 * p });
}
render::set_lights(&self.lights(&out));
self.smoke.hd = render::set_smoke(self.sim.tick as f64, &self.smoke.segments(&self.sim));
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 {
self.smoke.hd = false;
}
@@ -469,7 +617,7 @@ impl AirDef {
self.send_models(&out.models);
}
if !out.particles.is_empty() {
let hd_smoke = self.smoke.hd;
let hd_smoke = self.smoke.hd && !self.replay.as_ref().is_some_and(|r| r.filming());
let spawns: Vec<ParticleSpawn> = out
.particles
.into_iter()
@@ -492,9 +640,13 @@ impl AirDef {
render::spawn_particles(&spawns);
}
let now = self.sim.tick;
// Blasts play layered (cinema.rs), the nearest ones in full.
let mut booms: Vec<(f64, usize, DVec3, f32, f32)> = Vec::new();
for (event, at, volume, pitch) in out.sounds {
let dist = at.distance(self.listener);
if travels(event) {
if let Some(power) = cinema::blast_power(event) {
booms.push((dist, power, at, volume, pitch));
} else if travels(event) {
// Seen first, heard later; from afar muffled.
let name = if dist > FAR { format!("{event}_far") } else { event.to_string() };
let delay = (dist / SOUND_SPEED) as u64;
@@ -507,6 +659,18 @@ impl AirDef {
render::play_sound(event, at.x, at.y, at.z, volume, pitch);
}
}
booms.sort_by(|a, b| a.0.total_cmp(&b.0));
for (rank, (dist, power, at, volume, pitch)) in booms.into_iter().enumerate() {
for l in cinema::layers(power, dist, FAR, volume, pitch, rank) {
// Seen first, heard later: each voice after the sound has travelled.
let delay = (dist / SOUND_SPEED) as u64 + l.delay;
if delay == 0 {
render::play_sound(&l.name, at.x, at.y, at.z, l.volume, l.pitch);
} else if self.pending.len() < 256 {
self.pending.push(PendingSound { due: now + delay, name: l.name, at, volume: l.volume, pitch: l.pitch });
}
}
}
let mut i = 0;
while i < self.pending.len() {
if self.pending[i].due <= now {
@@ -792,14 +956,157 @@ 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);
input::claim_wheel(false);
render::set_hud(&[]);
render::set_views(&[]);
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::<usize>().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<Result<String, String>> = 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<DVec3> {
let live: Vec<DVec3> = self
@@ -988,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> {
@@ -1005,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,
@@ -1013,6 +1322,7 @@ impl Mod for AirDef {
health_mark: 0,
menu: None,
listener: DVec3::ZERO,
punch: 0.0,
pending: Vec::new(),
model_ids: std::collections::HashMap::new(),
model_generation: u32::MAX,
@@ -1148,7 +1458,15 @@ impl Mod for AirDef {
render::query_sections(render::Policy::Collision, &sections);
}
if let Some(mut r) = self.replay.take() {
r.tick(&self.sim, self.listener, &mut out);
// A replay shows the recording alone: not the live raid's models, particles, sounds or jolts.
if r.filming() {
out.models.clear();
out.particles.clear();
out.sounds.clear();
out.shake = None;
}
r.tick(&self.sim, &mut self.ground, self.listener, &mut out);
self.export_step(&mut r);
if r.done {
self.replay = Some(r);
self.stop_replay();
@@ -1163,6 +1481,8 @@ impl Mod for AirDef {
let size = render::gui_size();
render::set_hud(&r.hud(&self.sim, size));
render::set_views(&r.views(size));
// The world through the director's camera, with the full pipeline (HD, the post pass).
render::set_main_view(r.filming().then(|| r.view_id()));
// The sky of the raid, not of now (None for a raid whose clock was unknown).
render::set_time_override(r.day_time(&self.sim));
self.monitor_shown = true;
@@ -1415,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
+203 -12
View File
@@ -4,12 +4,14 @@
//! 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};
use glam::DVec3;
use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape, ViewShape};
use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape};
/// Ghost models' ids, clear of the live ones.
const GHOSTS: u32 = 0x5000_0000;
@@ -23,6 +25,8 @@ pub enum Mode {
Full,
/// Clips one after another (the wheel skips): one event, or the highlight reel.
Clips { list: Vec<Clip>, at: usize, reel: bool },
/// The director's film: planned shots from several angles with cuts and slow motion.
Film(Box<Film>),
}
pub struct Clip {
@@ -37,6 +41,8 @@ pub struct Player {
pub mode: Mode,
t: f64,
speed: f64,
/// How fast the replay's time ran this tick (0 paused or held on a card; slowed round a moment).
rate: f64,
/// The director's current shot (event, shot) and the view id it films with.
shot: (usize, u8),
view: u32,
@@ -53,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<f64>,
/// 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,
@@ -129,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, .. } => {
@@ -204,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 {
@@ -213,6 +233,7 @@ impl Player {
mode,
t: start,
speed,
rate: 0.0,
shot: (usize::MAX, 0),
view: VIEW,
fade: 0,
@@ -223,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,
}
@@ -261,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.
@@ -280,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,
}
}
@@ -292,22 +328,48 @@ 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;
}
/// Where the camera is this tick.
pub fn eye(&self) -> DVec3 {
self.eye
}
/// The replay's moment, in the recording's ticks.
pub fn time(&self) -> f64 {
self.t
}
/// How fast the replay's time runs now, as a share of real time (1 normal, 0 held).
pub fn rate(&self) -> f32 {
self.rate as f32
}
/// Whether the world is filmed (the map is drawn on the HUD instead).
pub fn filming(&self) -> bool {
!matches!(self.mode, Mode::Map)
}
/// The camera the world is rendered from (`set-main-view`).
pub fn view_id(&self) -> u32 {
self.view
}
/// The time of day the raid was flown in, as of the replay's moment now.
pub fn day_time(&self, sim: &Sim) -> Option<u64> {
sim.replay(self.rec)?.day_time(self.t)
}
/// 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;
@@ -325,6 +387,7 @@ impl Player {
speed = 0.0;
}
}
self.rate = if self.paused { 0.0 } else { speed };
if !self.paused {
self.t += speed;
}
@@ -336,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() {
@@ -356,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 {
@@ -365,7 +433,8 @@ impl Player {
EventKind::SamLost { .. } => "airdef:blast.big",
EventKind::Split { .. } => "airdef:boom.sonic",
};
out.sound(event, heard(e.pos), 1.0, 1.0);
let gain = if let EventKind::Kill { kind, .. } = e.kind { crate::sim::record::kill_look(kind).2 } else { 1.0 };
out.sound(event, heard(e.pos), gain, 1.0);
}
for tr in rec.tracks.iter().filter(|tr| (self.heard..self.t).contains(&(tr.first as f64))) {
if let What::Missile(kind, _) = tr.what
@@ -377,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<f64> {
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<Shape> {
let mut s: Vec<Shape> = 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;
@@ -453,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) => {
@@ -497,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.
@@ -508,8 +696,11 @@ impl Player {
if matches!(self.mode, Mode::Map) {
return self.map(rec, gw, gh);
}
let mut s = vec![fill(0.0, 0.0, gw, gh, 0xFF000000)];
s.push(Shape::View(ViewShape { view: self.view, rect: Rect { x: 0.0, y: 0.0, w: gw, h: gh }, mode: 0 }));
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<Shape> = Vec::new();
// Letterbox, the recording's marks.
let bar = (gh * 0.09).round();
s.push(fill(0.0, 0.0, gw, bar, 0xFF000000));
+9 -3
View File
@@ -251,9 +251,15 @@ impl Sim {
/// A fire for a replay: the event's power at age `age` ticks, as a glowing flame model's scale
/// (zero when it is out or not started) and a smoke strength.
pub fn replay_fire(power: u32, age: f64) -> (f32, f32) {
if age < 0.0 {
replay_fire_of(size_for(power), life_for(power), age)
}
/// The same for a fire of a given `size` and `life` (a merged fire has grown): out (zero) before
/// it starts and from the end of its life on, as the live fire is removed then.
pub fn replay_fire_of(size: f32, life: u32, age: f64) -> (f32, f32) {
if !(0.0..life as f64).contains(&age) {
return (0.0, 0.0);
}
let (fl, sm) = level(age, life_for(power) as f64);
(fl * size_for(power), sm)
let (fl, sm) = level(age, life as f64);
(fl * size, sm)
}
+53 -5
View File
@@ -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<u32> = 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<Beam> {
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 })
}
}
+20 -7
View File
@@ -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<u32, (u64, u8)>,
/// 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<lights::Flare>,
pub lit: Vec<u32>,
/// 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);
@@ -2015,10 +2026,10 @@ impl Sim {
}
if let Some(p) = player {
let d = p.distance(pos);
let reach = [45.0, 45.0, 100.0, 160.0, 240.0][power];
let reach = [70.0, 45.0, 100.0, 160.0, 240.0][power];
if d < reach {
let strength = [0.4, 0.4, 0.9, 1.3, 1.8][power];
out.jolt((1.0 - d / reach) as f32 * strength, [0.8, 0.8, 1.6, 2.2, 3.0][power]);
let strength = [0.55, 0.4, 0.9, 1.3, 1.8][power];
out.jolt((1.0 - d / reach) as f32 * strength, [1.0, 0.8, 1.6, 2.2, 3.0][power]);
}
}
self.blast_damage(pos, power, out);
@@ -2075,11 +2086,13 @@ impl Sim {
if let Some(s) = self.sams.iter_mut().find(|s| s.id == by) {
s.kills += 1;
}
self.fireball(pos, 3.5, 8);
// The look of the kill goes by the warhead it blew up (replays use the same).
let (size, life, gain) = record::kill_look(self.drones[idx].kind);
self.fireball(pos, size as f64, life / 9);
out.particles("minecraft:explosion", pos, 0.6, DVec3::ZERO, 0.0, 4);
out.particles("minecraft:large_smoke", pos, 1.0, DVec3::ZERO, 0.06, 25);
out.particles("minecraft:firework", pos, 0.3, DVec3::ZERO, 0.35, 30);
out.sound("airdef:blast.air", pos, 1.0, 1.0 + self.rng.f() as f32 * 0.2);
out.sound("airdef:blast.air", pos, gain, 1.0 + self.rng.f() as f32 * 0.2);
for _ in 0..5 {
let id = self.id();
let v = vel + DVec3::new(self.rng.s(), self.rng.f() * 0.8, self.rng.s()) * 0.35;
+111 -37
View File
@@ -114,9 +114,11 @@ pub struct Recording {
/// The systems and objects as they stood: (id, kind, where).
pub sams: Vec<(u32, SamKind, DVec3)>,
pub objects: Vec<DVec3>,
/// The world clock's time of day (ticks) every `TIME_STEP` ticks of the recording, from its
/// start: replays show the sky of the raid, not the current one. Empty when the clock was unknown.
times: Vec<u64>,
/// The world clock's time of day as (recording tick, day ticks), sampled on every
/// `TIME_STEP`-th tick the clock was known: replays show the sky of the raid, not the current
/// one. A tick the clock was unknown has no sample (a gap), so a late start or a lost clock
/// never shifts later samples; empty when the clock was never known.
times: Vec<(u32, u64)>,
/// Ticks the sky has been clear.
quiet: u32,
}
@@ -166,6 +168,86 @@ impl Track {
}
}
/// A fire as a replay shows it: what the events lit and kept alight, merged as the live fires are.
#[derive(Clone, Copy, Debug)]
pub struct ReplayFire {
pub at: DVec3,
/// The recording's tick it was lit.
pub start: u32,
pub life: u32,
pub size: f32,
/// The event that lit it (the models' ids).
pub key: usize,
}
/// The look of an interception by the drone's warhead: (fireball radius, ticks it burns, loudness).
/// A bare decoy pops; a big warhead lights the sky. The same in the live raid and its replay.
pub fn kill_look(kind: DroneKind) -> (f32, u32, f32) {
let w = kind.spec().warhead.min(8) as f32;
(3.5 * (0.85 + 0.12 * w), (8 + (w as u32) / 2) * 9, 1.0 + 0.06 * w)
}
/// A blast's look for an event: (fireball radius, ticks it burns). The live blasts use the same.
pub fn blast_spec(kind: EventKind) -> (f32, u32) {
const SIZE: [f32; 5] = [2.5, 4.5, 7.0, 10.0, 13.0];
const LIFE: [u32; 5] = [11, 11, 18, 22, 26];
match kind {
EventKind::Kill { kind, .. } => {
let (size, life, _) = kill_look(kind);
(size, life)
}
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
let p = (power as usize).min(4);
(SIZE[p], LIFE[p] * 9)
}
EventKind::SamLost { .. } => (8.0, 18 * 9),
EventKind::Split { .. } => (2.0, 7 * 9),
}
}
/// The blasts of the recording's events burning at `t` (its ticks, fractional): the same
/// `Blast`s the live simulation keeps, with `start` in the recording's ticks and the event's
/// own id range. A pure function of `t`, so a seek or a pause shows the same frame.
pub fn replay_blasts(rec: &Recording, t: f64) -> Vec<Blast> {
rec.events
.iter()
.enumerate()
.filter_map(|(k, e)| {
let (size, life) = blast_spec(e.kind);
let age = t - e.at as f64;
(0.0..life as f64).contains(&age).then_some(Blast { id: 0x4000_0000 | k as u32, at: e.pos, size, start: e.at as u64, life })
})
.collect()
}
/// The fires burning at `t` in the recording: events within 6 blocks of a fire still burning
/// feed it (the greater size, the longer life), as `Sim::ignite` does; those after it died light
/// a new one. A pure function of `t`.
pub fn replay_fires(rec: &Recording, t: f64) -> Vec<ReplayFire> {
let mut fires: Vec<ReplayFire> = Vec::new();
for (k, e) in rec.events.iter().enumerate() {
if e.at as f64 > t {
break;
}
let power = match e.kind {
EventKind::Crash { power, .. } => power,
EventKind::Hit { power, .. } => power.max(1),
EventKind::SamLost { .. } => 2,
_ => continue,
};
let (size, life) = (fires::size_for(power), fires::life_for(power));
let alive = |f: &ReplayFire| e.at < f.start + f.life;
if let Some(f) = fires.iter_mut().find(|f| alive(f) && f.at.distance(e.pos) < 6.0) {
f.size = f.size.max(size);
f.life = f.life.max(e.at - f.start + life);
} else {
fires.push(ReplayFire { at: e.pos, start: e.at, life, size, key: k });
}
}
fires.retain(|f| t < (f.start + f.life) as f64);
fires
}
impl Recording {
fn new(number: u32, sim: &Sim) -> Self {
let origin = sim.targets.first().copied().or_else(|| sim.sams.first().map(|s| s.pos)).unwrap_or(DVec3::ZERO);
@@ -199,12 +281,16 @@ impl Recording {
/// clock as sampled, run on between samples at the pace it ran (a jump is a step), or None
/// when the clock was not known (the replay then shows the world's own time).
pub fn day_time(&self, t: f64) -> Option<u64> {
let k = (t.max(0.0) / TIME_STEP as f64).floor() as usize;
let i = k.min(self.times.len().checked_sub(1)?);
let a = self.times[i];
let into = if i == k { t.max(0.0) - (i as u32 * TIME_STEP) as f64 } else { 0.0 };
let t = t.max(0.0);
// The last sample at or before `t`; none before the first one means unknown.
let i = self.times.partition_point(|s| s.0 as f64 <= t).checked_sub(1)?;
let (ta, a) = self.times[i];
match self.times.get(i + 1) {
Some(&b) if b >= a && b - a <= 2 * TIME_STEP as u64 => Some(a + ((b - a) as f64 * into / TIME_STEP as f64) as u64),
// Neighbouring samples with no gap between: run on at the pace between them.
Some(&(tb, b)) if tb - ta <= TIME_STEP && b >= a && b - a <= 2 * TIME_STEP as u64 => {
let f = ((t - ta as f64) / (tb - ta) as f64).clamp(0.0, 1.0);
Some(a + ((b - a) as f64 * f) as u64)
}
_ => Some(a),
}
}
@@ -271,9 +357,8 @@ impl Sim {
let Some(r) = &mut self.rec else { return };
if r.len % TIME_STEP == 0
&& let Some(dt) = self.day_time
&& r.times.len() == (r.len / TIME_STEP) as usize
{
r.times.push(dt);
r.times.push((r.len, dt));
}
if r.len % STEP == 0 {
for d in &self.drones {
@@ -349,43 +434,32 @@ impl Sim {
}
}
}
// Fires: what the events left burning (the flames dim and the smoke thickens with time).
let mut lit: Vec<(DVec3, f64)> = Vec::new();
for (k, e) in rec.events.iter().enumerate() {
let power = match e.kind {
EventKind::Crash { power, .. } => power,
EventKind::Hit { power, .. } => power.max(1),
EventKind::SamLost { .. } => 2,
_ => continue,
};
let age = t - e.at as f64;
// One fire where several events met.
if lit.iter().any(|(p, at)| p.distance(e.pos) < 6.0 && *at <= e.at as f64) {
continue;
}
lit.push((e.pos, e.at as f64));
let (flame, smoke) = fires::replay_fire(power, age);
let id = base + 0x00c0_0000 + k as u32 * PARTS;
// Fires: what the events left burning (the flames dim and the smoke thickens with time),
// out when their life ends.
for f in replay_fires(rec, t) {
let age = t - f.start as f64;
let (flame, smoke) = fires::replay_fire_of(f.size, f.life, age);
let id = base + 0x00c0_0000 + f.key as u32 * PARTS;
if flame > 0.05 {
let wob = 1.0 + 0.15 * (age * 0.9 + k as f64).sin();
out.model(id, "airdef:flame", e.pos + DVec3::Y * 0.5, DQuat::from_rotation_y(age * 0.07), (flame as f64 * 0.8 * wob).max(0.05), true);
let wob = 1.0 + 0.15 * (age * 0.9 + f.key as f64).sin();
out.model(id, "airdef:flame", f.at + DVec3::Y * 0.5, DQuat::from_rotation_y(age * 0.07), (flame as f64 * 0.8 * wob).max(0.05), true);
}
if smoke > 0.1 && (age as u64) % 3 == 0 {
out.particles("minecraft:large_smoke", e.pos + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32);
out.particles("minecraft:large_smoke", f.at + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32);
}
}
// Blasts: a fireball growing and fading for 1.5 s after each.
// Blasts: with the shader effects on, the post pass and the HD lights draw them from
// `replay_blasts` (the same as the live ones); the models are the fallback without them.
if self.shader {
return;
}
for (k, e) in rec.events.iter().enumerate() {
let age = t - e.at as f64;
if !(0.0..30.0).contains(&age) {
continue;
}
let size = match e.kind {
EventKind::Kill { .. } => 3.5,
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => [2.5, 4.5, 7.0, 10.0, 13.0][(power as usize).min(4)],
EventKind::SamLost { .. } => 8.0,
EventKind::Split { .. } => 2.0,
};
let (size, _) = blast_spec(e.kind);
let size = size as f64;
let f = age / 30.0;
let s = size * (f * 4.0).min(1.0) * (1.0 - f * f);
let id = base + 0x0080_0000 + k as u32 * PARTS;
+75
View File
@@ -534,6 +534,81 @@ fn recordings_keep_the_time_of_day() {
}
}
// #57: a clock missing at the start, in the middle, or later recovering never loses or shifts the samples.
#[test]
fn recorded_clock_survives_gaps() {
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0));
let mut g = Ground::default();
let mut out = Out::default();
for i in 0..100u64 {
// Unknown at the start (ticks 0-19) and for a stretch in the middle (50-69).
sim.day_time = if i < 20 || (50..70).contains(&i) { None } else { Some(1000 + i) };
sim.step(&mut g, None, &mut out);
}
let r = sim.rec.as_ref().unwrap();
assert_eq!(r.day_time(5.0), None, "before the first sample: unknown, not the current time");
let v = r.day_time(30.0).expect("recovered after a missing start");
assert!((1020..1040).contains(&v), "{v}");
// Inside the gap the last known time holds; nothing is invented.
let held = r.day_time(60.0).unwrap();
assert!((1030..1050).contains(&held), "{held}");
// After the gap the new samples land at their own ticks (seek forward and back alike).
let after = r.day_time(85.0).unwrap();
assert!((1080..1100).contains(&after), "{after}");
assert!(r.day_time(30.0).unwrap() < r.day_time(85.0).unwrap());
assert!(r.day_time(85.0).unwrap() > r.day_time(60.0).unwrap());
assert!(r.day_time(25.0).unwrap() <= r.day_time(35.0).unwrap());
}
// #56: a replay's fires and blasts are pure functions of its time: they end with their life,
// merge as the live ones do, and a seek back shows the same frame.
#[test]
fn replay_fires_and_blasts_follow_the_recording() {
use record::{Event, EventKind};
let mut sim = Sim::new(1);
sim.day_time = Some(1000);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0));
let mut g = Ground::default();
let mut out = Out::default();
sim.step(&mut g, None, &mut out);
let mut rec = sim.rec.take().unwrap();
let at = DVec3::new(5.0, 70.0, 5.0);
let crash = |power| EventKind::Crash { drone: 1, kind: DroneKind::Geran3, power };
let life = fires::life_for(1);
rec.events = vec![
Event { at: 10, pos: at, kind: crash(1) },
// The same spot while it burns: one fire, burning longer.
Event { at: 500, pos: at + DVec3::X, kind: crash(3) },
// The same spot long after it died: a new fire.
Event { at: 500 + 4000 + fires::life_for(3), pos: at, kind: crash(1) },
];
assert!(record::replay_fires(&rec, 0.0).is_empty(), "before the first event");
let f = record::replay_fires(&rec, 600.0);
assert_eq!(f.len(), 1);
assert_eq!(f[0].start, 10);
assert!(f[0].life >= 490 + fires::life_for(3) && f[0].size >= fires::size_for(3), "fed: {:?}", f[0]);
// Past its life it is gone: no flames, no smoke.
let end = f[0].start + f[0].life;
assert!(record::replay_fires(&rec, end as f64 - 1.0).len() == 1);
assert!(record::replay_fires(&rec, end as f64 + 1.0).is_empty());
assert_eq!(fires::replay_fire(1, life as f64 + 10.0), (0.0, 0.0));
assert_eq!(fires::replay_fire(1, 1.0e5), (0.0, 0.0));
assert!(fires::replay_fire(1, 100.0).1 > 0.0);
// The later event lights a new one.
let f = record::replay_fires(&rec, (500 + 4000 + fires::life_for(3) + 5) as f64);
assert_eq!(f.len(), 1);
assert_eq!(f[0].start, 500 + 4000 + fires::life_for(3));
// Seeking back and forth gives the same blasts.
let a = record::replay_blasts(&rec, 20.0);
let _ = record::replay_blasts(&rec, 510.0);
let b = record::replay_blasts(&rec, 20.0);
assert_eq!(a.len(), 1);
assert_eq!((a[0].id, a[0].start, a[0].life), (b[0].id, b[0].start, b[0].life));
assert!(record::replay_blasts(&rec, 5.0).is_empty());
assert!(record::replay_blasts(&rec, 10.0 + a[0].life as f64).is_empty(), "burnt out");
}
fn sim_first(r: &record::Recording) -> u64 {
r.tracks.first().map_or(0, |t| t.first as u64)
}
+10
View File
@@ -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<Option<f64>> {
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 = (
+29 -1
View File
@@ -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`), в репозиторий мода попадает **патчами**:
@@ -118,6 +118,7 @@ Bind groups кэшируются и пересоздаются только пр
| 15 | текстура неба (`sky_lut`) |
| 16, 17 | объёмный туман (свет+пропускание) и длина его луча |
| 18, 19 | объёмные облака, карта теней облаков |
| 20 | длина луча каждого текселя облаков (`cloud_dist_tex`, для апсэмплинга по глубине) |
`HdFrame` (Rust и WGSL держать в синхроне!): `inverse_view_proj`, `resolution` (w, h, 1/w, 1/h), `params` (число
источников, экспозиция, bloom, счётчик кадров), `cascades[2]`, `sun` (направление, сила), `sun_color` (rgb, ночь),
@@ -227,6 +228,9 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
| Материалы и блики (план 5): G-буфер `levels.zw` = шероховатость, металличность; GGX-блик солнца с Френелем и отражение неба (`sky_along`/`atmosphere_sky`) в `shade_surface`; мокрые поверхности и лужи в дождь (`HdFrame.surface`); `HORIZON_HD_SPECULAR=0` — выкл. | готово | в рабочем дереве форка |
| Ветер (план 7): листва и растения качаются в вершинной стадии террейна (`Globals.hd_wind`, `hd_materials`), те же смещения в тенях; `HORIZON_HD_WIND=0` — выкл. | готово | в рабочем дереве форка |
| #67: облака марш идут до ближайшей поверхности (`fs_cloud` читает глубину мира, вторая цель — длина луча, история отбрасывается при другой длине); `fs_light` кладёт облака на все пиксели (`cloud_at`: 4 текселя с весом по близости длины луча), под туман и над поверхностью, для воды — в `air`; луч внутри слоя вдоль горизонта больше не пропускается | готово; проверено только пустой полигон (нет гор/техники в слое, снимки `scratchpad/c*.png`), бюджет GPU на Mac не мерился | в рабочем дереве форка |
| #68: шум облаков по Y — от низа слоя, а не от камеры; шкалы тумана 170→128, дыма 22→32, луж 0.45/1.3→0.5/1.25 делят период обёртки 65536/256; тест `noise_scales_divide_the_camera_wrap` | готово | в рабочем дереве форка |
| #70: `Game::sky_darken` — всегда реальное время мира (ИИ, спавн, блоки, статистика); `shown_sky_darken` (время реплея) — только тени сущностей и свет глаза; небо/луна/цвета остаются по показанному времени | готово (собрано, игровой проверки день/ночь реплея нет) | в рабочем дереве форка |
| Объёмный дым и следы ракет (#54, п. 8 плана): API уровня 14 `render.set-smoke`, сетка дыма 128×64×128 по 3 блока (`gfx/hd/smoke.rs`, `smoke_grid.wgsl`), марш в проходе тумана, «туннель» в тумане и облаках, `HORIZON_HD_SMOKE=0`; мод: `airdef-mod/src/smoke.rs` (следы дронов и ракет, колонны над пожарами, дым взрывов) | готово, в рабочем дереве форка; вид не настраивался (см. «Дым и следы») | не закоммичено |
## План (по порядку) и ТЗ
@@ -328,3 +332,27 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
- Мерцание: `hills_river_noon` 9% без ветра (`out/nowind`) против 31% с ветром — это движение листвы вблизи камеры, не шум; амплитуда листвы снижена с 0.04 до 0.025 после этого замера (повторно не мерилось).
- Тайминги **не сняты**: параллельно шли игры других агентов (числа 25–155 мс недействительны). Перед замером брать `run-home/.gpu-lock`.
- Известно: `shaders_translate_to_hlsl_for_directx_12` падает на `cloud.wgsl` (не мой файл).
## Камеры мода: полный вид и мониторы (#56, #51)
- **Главный вид** (`render.set-main-view`, API 15): мир экрана рисуется из камеры мода (`set-views`) полным конвейером — HD, небо, облака, туман, объёмный дым, TAA, пост-проход мода, разрешение окна. Руку, контур блока и ванильный HUD не рисуем (`game.rs`: `mod_main_view`, `show_hud`). Так показываются повторы Full/Reel/моменты; чанки остаются вокруг игрока, слушатель — игрок.
- **Малые виды** (мониторы, `gfx/mod_views.rs`) остаются экономными: небо, твёрдая земля, сущности и вторым проходом пост-эффект мода с камерой этого вида (`ModPost::write_to/run_view`, режим `params[0].w = 0`: пост сам освещает землю и рисует дым). Бюджет по площади картинки (`post_budget`: <4096 px без пост-прохода, до 150k px — 4 взрыва/12 огней, до 600k — 12/32, больше — всё). HD-освещения, облаков и TAA у малых видов нет.
- Мод в повторе берёт взрывы из записи (`record::replay_blasts`, чистая функция времени), время идёт по часам повтора (`params[255].x` — доля частичного тика, которую эффекты не используют при паузе/замедлении).
- #51: на перехвате слои звука (`cinema.rs`), свет-«удар» (белая вспышка в HD-освещении, радиус до 256), пик экспозиции/bloom (`set-lighting`), в `effects.wgsl` широкое гало, подсветка неба/облаков и яркое кольцо ударной волны на экране.
## Прожекторы: направленный свет и объёмный луч (#60)
- **API 16** `render.set-spot-lights(list<spot-light>) -> 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`, ускорение/замедление времени).
@@ -1,7 +1,7 @@
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:37:45 +0300
Subject: [PATCH 01/28] 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;
@@ -1,7 +1,7 @@
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:39:45 +0300
Subject: [PATCH 02/28] 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 <noreply@anthropic.com>
---
@@ -1,7 +1,7 @@
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 13:33:10 +0300
Subject: [PATCH 03/28] 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
@@ -1,7 +1,7 @@
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 22:09:30 +0300
Subject: [PATCH 04/28] 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
---
@@ -1,7 +1,7 @@
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 22:31:00 +0300
Subject: [PATCH 05/28] 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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 23:08:21 +0300
Subject: [PATCH 06/28] 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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 23:25:29 +0300
Subject: [PATCH 07/28] 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 <noreply@anthropic.com>
---
@@ -1,7 +1,7 @@
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 11:15:11 +0300
Subject: [PATCH 08/28] 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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 11:25:45 +0300
Subject: [PATCH 09/28] 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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 12:01:08 +0300
Subject: [PATCH 10/28] 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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 13:18:43 +0300
Subject: [PATCH 11/28] 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)
@@ -1,7 +1,7 @@
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 13:59:43 +0300
Subject: [PATCH 12/28] 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
@@ -1,7 +1,7 @@
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:19:53 +0300
Subject: [PATCH 13/28] 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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:25:44 +0300
Subject: [PATCH 14/28] 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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:43:36 +0300
Subject: [PATCH 15/28] 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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:02:02 +0300
Subject: [PATCH 16/28] 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
@@ -1,7 +1,7 @@
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:02:02 +0300
Subject: [PATCH 17/28] 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
@@ -1,7 +1,7 @@
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:19:57 +0300
Subject: [PATCH 18/28] 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
@@ -1,7 +1,7 @@
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:47:52 +0300
Subject: [PATCH 19/28] 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)
@@ -1,7 +1,7 @@
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:56:04 +0300
Subject: [PATCH 20/28] 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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 18:38:19 +0300
Subject: [PATCH 21/28] 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
@@ -1,7 +1,7 @@
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 19:02:16 +0300
Subject: [PATCH 22/28] 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 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 21:41:48 +0300
Subject: [PATCH 23/28] 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)
@@ -1,7 +1,7 @@
From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 21:43:22 +0300
Subject: [PATCH 24/28] 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
@@ -1,7 +1,7 @@
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 22:56:15 +0300
Subject: [PATCH 25/28] 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
@@ -1,7 +1,7 @@
From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 23:19:53 +0300
Subject: [PATCH 26/28] 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)
@@ -1,7 +1,7 @@
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 23:22:19 +0300
Subject: [PATCH 27/28] 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
@@ -1,7 +1,7 @@
From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:17:11 +0300
Subject: [PATCH 28/28] 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
@@ -0,0 +1,603 @@
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:26:09 +0300
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
only, gameplay sky darkening uses the world's time (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game.rs | 37 +++--
crates/horizon-client/src/game/render_tick.rs | 2 +-
crates/horizon-client/src/gfx/hd/cloud.wgsl | 54 ++++++-
.../src/gfx/hd/cloud_common.wgsl | 3 +-
crates/horizon-client/src/gfx/hd/fog.wgsl | 6 +-
crates/horizon-client/src/gfx/hd/light.wgsl | 42 +++++-
crates/horizon-client/src/gfx/hd/lighting.rs | 4 +-
crates/horizon-client/src/gfx/hd/volumes.rs | 135 +++++++++++++++---
8 files changed, 239 insertions(+), 44 deletions(-)
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 2130d8d88..f21b3dcdd 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -1838,6 +1838,8 @@ struct Game<'a> {
events: events::Events,
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
sky_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
+ /// The same for the time a mod shows (a replay's): render only.
+ shown_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
persist: persist::EntityMemory,
/// Connected to a server: the world comes from the network (game/remote.rs).
remote: Option<remote::Remote>,
@@ -8020,7 +8022,7 @@ impl Game<'_> {
.flatten();
if let Some(sprite) = shadow_sprite {
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
- let darken = self.sky_darken();
+ let darken = self.shown_sky_darken();
let a = alpha;
let lerp = |p: Vec3, q: Vec3| {
glam::DVec3::new(
@@ -8378,33 +8380,43 @@ impl Game<'_> {
.map_or(self.world.day_time, |t| t as i64)
}
+ /// 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.
fn sky_darken(&self) -> i32 {
+ self.sky_darken_at(self.world.day_time, &self.sky_darken_memo)
+ }
+
+ /// The sky's darkening as the player sees it (entity shadows, particle light): follows the mod's
+ /// time override, so a replay's night looks dark without touching the rules.
+ fn shown_sky_darken(&self) -> i32 {
+ self.sky_darken_at(self.shown_day_time(), &self.shown_darken_memo)
+ }
+
+ fn sky_darken_at(
+ &self,
+ time: i64,
+ memo: &std::cell::Cell<Option<((i64, u32, u32), i32)>>,
+ ) -> i32 {
if self.dim == Dim::Nether {
return 11;
}
if self.dim == Dim::End {
- let sky = self
- .res
- .end_environment()
- .sample(self.shown_day_time(), None, None);
+ let sky = self.res.end_environment().sample(time, None, None);
return (15.0 - 15.0 * sky.sky_light_level).round() as i32;
}
let (rain, thunder) = self.world.weather.levels(1.0);
// Sampled once per (time, weather): every mob asks each tick.
- let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits());
- if let Some((k, darken)) = self.sky_darken_memo.get()
+ let key = (time, rain.to_bits(), thunder.to_bits());
+ if let Some((k, darken)) = memo.get()
&& k == key
{
return darken;
}
- let mut sky = self
- .res
- .environment()
- .sample(self.shown_day_time(), None, None);
+ let mut sky = self.res.environment().sample(time, None, None);
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
// Level.updateSkyBrightness: darkening from the sky light level.
let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32;
- self.sky_darken_memo.set(Some((key, darken)));
+ memo.set(Some((key, darken)));
darken
}
fn mob_hit(&self) -> Option<(usize, f64)> {
@@ -13954,6 +13966,7 @@ impl<'a> Game<'a> {
mods: Default::default(),
native_shaders: Default::default(),
sky_darken_memo: Default::default(),
+ shown_darken_memo: Default::default(),
persist: Default::default(),
remote: None,
lan: None,
diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs
index 6807d7718..9912970a5 100644
--- a/crates/horizon-client/src/game/render_tick.rs
+++ b/crates/horizon-client/src/game/render_tick.rs
@@ -241,7 +241,7 @@ impl Game<'_> {
eye[2].floor() as i32,
];
let packed = self.world.light(b[0], b[1], b[2]);
- let sky = i32::from(packed >> 4) - self.sky_darken();
+ let sky = i32::from(packed >> 4) - self.shown_sky_darken();
let raw = sky.max(i32::from(packed & 15)).clamp(0, 15) as u8;
lightmap_brightness(if self.dim == Dim::Nether { 0.1 } else { 0.0 }, raw)
}
diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl
index fad234a46..39a239f4d 100644
--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl
+++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl
@@ -1,6 +1,17 @@
@group(0) @binding(6) var history: texture_2d<f32>;
@group(0) @binding(7) var smoke_tex: texture_2d<f32>;
+@group(0) @binding(8) var scene_depth: texture_depth_2d;
+@group(0) @binding(9) var history_dist: texture_2d<f32>;
+
+// What the sky pixels report as the length of their ray (nothing stops them).
+const SKY_LEN: f32 = 20000.0;
+
+struct CloudOut {
+ @location(0) cloud: vec4<f32>,
+ // How far the ray was marched: the surface behind this texel, or SKY_LEN for sky.
+ @location(1) dist: vec4<f32>,
+}
fn ign(px: vec2<f32>) -> f32 {
let p = px + vec2<f32>(5.588238 * frame.params.w);
@@ -65,7 +76,26 @@ fn cloud_scatter(
}
@fragment
-fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
+fn fs_cloud(in: FullOut) -> CloudOut {
+ // The nearest of the four world pixels this texel stands for: the march ends at the surface
+ // there (the sky counts as infinitely far), so what stands in front of a cloud stays in front
+ // and what stands behind it is hidden by it.
+ let size = vec2<i32>(frame.resolution.xy);
+ let base = vec2<i32>(in.position.xy) * 2;
+ var zmax = 0.0;
+ var best = base;
+ for (var k = 0; k < 4; k++) {
+ let q = min(base + vec2<i32>(k & 1, k >> 1), size - 1);
+ let z = textureLoad(scene_depth, q, 0);
+ if z > zmax {
+ zmax = z;
+ best = q;
+ }
+ }
+ let suv = (vec2<f32>(best) + 0.5) * frame.resolution.zw;
+ let sndc = vec4<f32>(suv.x * 2.0 - 1.0, 1.0 - suv.y * 2.0, max(zmax, 0.000001), 1.0);
+ let sh = frame.inverse_view_proj * sndc;
+ let scene_len = select(SKY_LEN, length(sh.xyz / sh.w), zmax > 0.0);
let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, 0.5, 1.0);
let hh = frame.inverse_view_proj * ndc;
let dir = normalize(hh.xyz / hh.w);
@@ -73,11 +103,13 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
var hit_t = 900.0;
let y0 = frame.clouds.x;
let y1 = y0 + frame.clouds.y;
- if abs(dir.y) > 0.004 {
- let ta = y0 / dir.y;
- let tb = y1 / dir.y;
+ // A level ray stays in the slab if the camera is in it (it never leaves), else misses it.
+ let dy = select(dir.y, select(-1.0e-4, 1.0e-4, dir.y >= 0.0), abs(dir.y) < 1.0e-4);
+ {
+ let ta = y0 / dy;
+ let tb = y1 / dy;
let lo = max(min(ta, tb), 0.0);
- let hi = min(max(ta, tb), 4200.0);
+ 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);
@@ -131,6 +163,8 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
cur = vec4<f32>(colour, trans);
}
}
+ var out: CloudOut;
+ out.dist = vec4<f32>(scene_len, 0.0, 0.0, 0.0);
// Reproject the history by where the clouds are (rotation and the camera's step) and blend.
if frame.cloud_cam.w > 0.5 {
let hp = frame.prev_view_proj * vec4<f32>(dir * hit_t + frame.taa.xyz, 1.0);
@@ -138,12 +172,18 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
let huv = vec2<f32>(hp.x / hp.w * 0.5 + 0.5, 0.5 - hp.y / hp.w * 0.5);
if all(huv > vec2<f32>(0.0)) && all(huv < vec2<f32>(1.0)) {
let old = textureSampleLevel(history, linear, huv, 0.0);
+ // A history that was marched to another surface (an edge, something that moved in
+ // front) does not describe this ray.
+ let hs = vec2<f32>(textureDimensions(history_dist));
+ let old_len = textureLoad(history_dist, vec2<i32>(huv * hs), 0).x;
+ let same_len = 1.0 - smoothstep(0.5, 1.0, abs(old_len - scene_len) / (0.1 * min(scene_len, old_len) + 2.0));
// Not too much of a history that disagrees by a lot (a gust, a fast turn).
let diff = length(old.rgb - cur.rgb) + abs(old.a - cur.a);
- let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff));
+ let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff)) * same_len;
cur = mix(cur, old, keep);
}
}
}
- return cur;
+ out.cloud = cur;
+ return out;
}
diff --git a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
index e2352f008..8ee014420 100644
--- a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
+++ b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
@@ -44,7 +44,8 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
d *= smoothstep(0.0, 0.2, h);
if d < 0.002 { return 0.0; }
if detail > 0.0 {
- let p3 = vec3<f32>(w.x, pos.y, w.y);
+ // Height above the layer's base, not the camera's height: the noise stays put in the world.
+ let p3 = vec3<f32>(w.x, pos.y - frame.clouds.x, w.y);
let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1024.0, 0.0).b - 0.5) * 0.4;
let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 512.0 + vec3<f32>(warp), 0.0).r;
let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 64.0 + vec3<f32>(warp * 3.0), 0.0).g;
diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl
index 38b9098e3..d035bbf0d 100644
--- a/crates/horizon-client/src/gfx/hd/fog.wgsl
+++ b/crates/horizon-client/src/gfx/hd/fog.wgsl
@@ -40,7 +40,7 @@ fn sun_visible(p: vec3<f32>) -> f32 {
}
// The air's extinction per block at a camera-relative point: a haze that thins with height above
-// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts; none
+// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts (its scale divides 65536, the wrap of the camera's x, z, so it does not jump at x or z = 0); none
// deep underground.
fn fog_sigma(p: vec3<f32>) -> f32 {
let wy = p.y + frame.vfog.y;
@@ -50,7 +50,7 @@ fn fog_sigma(p: vec3<f32>) -> f32 {
s += frame.sky_extra.z * 0.003;
let w = vec3<f32>(p.x + frame.cloud_cam.x, wy, p.z + frame.cloud_cam.y)
+ vec3<f32>(0.5, 0.0, 0.35) * frame.clouds.w;
- let n = textureSampleLevel(noise3, repeat_s, w / 170.0, 0.0).b;
+ let n = textureSampleLevel(noise3, repeat_s, w / 128.0, 0.0).b;
let patchy = 0.35 + 1.3 * smoothstep(0.25, 0.75, n);
// Fade out far below the sea's height: caves keep their own dark air.
return s * patchy * smoothstep(28.0, 58.0, wy);
@@ -70,7 +70,7 @@ fn smoke_detail(pos: vec3<f32>, core: f32) -> f32 {
let w = vec3<f32>(pos.x + frame.cloud_cam.x, pos.y + frame.vfog.y, pos.z + frame.cloud_cam.y)
- vec3<f32>(SMOKE_WIND.x, 0.0, SMOKE_WIND.y) * frame.smoke.z;
let n1 = textureSampleLevel(noise3, repeat_s, w / 64.0, 0.0).r;
- let n2 = textureSampleLevel(noise3, repeat_s, w / 22.0, 0.0).g;
+ let n2 = textureSampleLevel(noise3, repeat_s, w / 32.0, 0.0).g;
let n = 0.55 * n1 + 0.45 * n2;
// Thin smoke breaks up into wisps, thick smoke stays whole.
let keep = smoothstep(0.18, 0.62, n + 0.45 * min(core, 1.5) - 0.12);
diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl
index 2534a3db8..870ae0d7d 100644
--- a/crates/horizon-client/src/gfx/hd/light.wgsl
+++ b/crates/horizon-client/src/gfx/hd/light.wgsl
@@ -30,6 +30,9 @@ struct Light {
@group(0) @binding(17) var fog_dist_tex: texture_2d<f32>;
@group(0) @binding(18) var cloud_tex: texture_2d<f32>;
@group(0) @binding(19) var cloud_shadow_tex: texture_2d<f32>;
+// How far each cloud texel's ray was marched (the surface behind it, or SKY_RAY for sky).
+@group(0) @binding(20) var cloud_dist_tex: texture_2d<f32>;
+const SKY_RAY: f32 = 20000.0;
const CLOUD_SHADOW_R: f32 = 1200.0;
// The pixel being lit, for the shadow filter's noise.
var<private> frag_px: vec2<f32>;
@@ -194,6 +197,34 @@ fn fog_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
return sum / total;
}
+// The volumetric clouds between the camera and a surface `dist` away (SKY_RAY for sky): the four
+// nearest half-resolution texels weighted by closeness and by how near the length they were
+// marched to is, so a cloud in front of a mountain does not bleed onto the sky beside it.
+fn cloud_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
+ let size = vec2<i32>(textureDimensions(cloud_tex));
+ let f = vec2<f32>(px) * 0.5 - vec2<f32>(0.25);
+ let base = vec2<i32>(floor(f));
+ let t = f - floor(f);
+ var sum = vec4<f32>(0.0);
+ var total = 0.0;
+ for (var j = 0; j < 2; j++) {
+ for (var i = 0; i < 2; i++) {
+ let q = clamp(base + vec2<i32>(i, j), vec2<i32>(0), size - 1);
+ let s = textureLoad(cloud_tex, q, 0);
+ let sd = textureLoad(cloud_dist_tex, q, 0).x;
+ let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1);
+ let dz = (sd - dist) / (0.06 * min(sd, dist) + 0.4);
+ let w = bw / (1.0 + dz * dz * dz * dz);
+ sum += s * w;
+ total += w;
+ }
+ }
+ if total < 0.0001 {
+ return textureLoad(cloud_tex, clamp(px / 2, vec2<i32>(0), size - 1), 0);
+ }
+ return sum / total;
+}
+
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).
@@ -246,7 +277,7 @@ fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
let flat_up = smoothstep(0.9, 0.98, n.y);
let soak = wet * open_sky * (0.35 + 0.65 * flat_up);
let wp = frame.cloud_cam.xy + p.xz;
- let nz = 0.6 * wet_noise(wp * 0.45) + 0.4 * wet_noise(wp * 1.3 + vec2<f32>(17.0, 5.0));
+ let nz = 0.6 * wet_noise(wp * 0.5) + 0.4 * wet_noise(wp * 1.25 + vec2<f32>(17.0, 5.0));
puddle = smoothstep(0.66 - 0.16 * wet, 0.74 - 0.16 * wet, nz) * smoothstep(0.3, 0.7, wet) * open_sky * flat_up;
surface_rgb *= mix(1.0, 0.7, soak) * mix(1.0, 0.6, puddle);
rough = mix(rough, 0.12, soak * (1.0 - 0.5 * metal));
@@ -377,7 +408,7 @@ fn fs_light(in: FullOut) -> LightOut {
c = sky + kept;
// The volumetric clouds over the sky and the sun.
if frame.clouds.z > 0.5 {
- let cl = textureSampleLevel(cloud_tex, linear, in.uv, 0.0);
+ let cl = cloud_at(px, SKY_RAY);
c = c * cl.a + cl.rgb;
}
}
@@ -421,6 +452,13 @@ fn fs_light(in: FullOut) -> LightOut {
air_t = fg.a;
air_s = fg.rgb;
}
+ // The clouds between the camera and the surface (inside the layer, or in front of a mountain
+ // or a flying thing): laid over the surface and under the fog, as the sky's are.
+ if d > 0.0 && atmosphere && frame.clouds.z > 0.5 {
+ let cl = cloud_at(px, length(ps));
+ air_s += air_t * cl.rgb;
+ air_t *= cl.a;
+ }
if !volumetric && sun_on > 0.0 && density > 0.0 {
let steps = 14;
let jitter = ign(in.position.xy);
diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs
index da56d00ba..d26f1e513 100644
--- a/crates/horizon-client/src/gfx/hd/lighting.rs
+++ b/crates/horizon-client/src/gfx/hd/lighting.rs
@@ -34,7 +34,7 @@ pub const MAX_LIGHTS: usize = 512;
pub(super) const LIGHT: &str = include_str!("light.wgsl");
/// The lighting's group 0, binding by binding (see the contract above).
-pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [
+pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 21] = [
UNIFORM,
UNFILTERED,
DEPTH,
@@ -55,6 +55,7 @@ pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [
UNFILTERED,
FLOAT,
FLOAT,
+ UNFILTERED,
];
/// The outside views and sampler the lighting group reads.
@@ -219,6 +220,7 @@ impl Hd {
wgpu::BindingResource::TextureView(&self.volumes.fog_dist),
wgpu::BindingResource::TextureView(self.volumes.cloud_view()),
wgpu::BindingResource::TextureView(&self.volumes.cloud_shadow),
+ wgpu::BindingResource::TextureView(self.volumes.cloud_dist_view()),
];
let entries: Vec<_> = resources
.into_iter()
diff --git a/crates/horizon-client/src/gfx/hd/volumes.rs b/crates/horizon-client/src/gfx/hd/volumes.rs
index a338d756c..d3b55e2f2 100644
--- a/crates/horizon-client/src/gfx/hd/volumes.rs
+++ b/crates/horizon-client/src/gfx/hd/volumes.rs
@@ -26,8 +26,8 @@
use super::noise as hd_noise;
use super::{
- DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNIFORM, entries, fullscreen, module,
- stamp, stamped_pass, target,
+ DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNFILTERED, UNIFORM, entries,
+ fullscreen, module, stamp, stamped_pass, target,
};
pub const CLOUD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
@@ -86,6 +86,9 @@ pub struct HdVolumes {
shadow_sampler: wgpu::Sampler,
/// The clouds' half-resolution target and its history (read one, write the other).
pub clouds: [wgpu::TextureView; 2],
+ /// How far each cloud texel's ray was marched (ping-pong with `clouds`).
+ cloud_dist: [wgpu::TextureView; 2],
+ cloud_depth: Option<wgpu::TextureView>,
flip: usize,
history_ready: bool,
pub cloud_shadow: wgpu::TextureView,
@@ -111,9 +114,10 @@ const D3: wgpu::BindingType = wgpu::BindingType::Texture {
};
/// `fs_cloud`'s group 0: frame, 3D noise, 2D noise, clamped and repeating samplers, the sky
-/// texture, the previous cloud frame.
-pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 8] =
- [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT];
+/// texture, the previous cloud frame, the smoke atlas, the world's depth, the previous march lengths.
+pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 10] = [
+ UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT, DEPTH, UNFILTERED,
+];
/// `fs_cloud_shadow`'s group 0: as the clouds' without the history.
pub(super) const CLOUD_SHADOW_BINDINGS: [wgpu::BindingType; 6] =
[UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT];
@@ -218,15 +222,40 @@ impl HdVolumes {
let cloud_module = module(device, "HD clouds", &cloud_src);
let shadow_module = module(device, "HD cloud shadows", &shadow_src);
let fog_module = module(device, "HD fog", &fog_body());
- let cloud_pipeline = fullscreen(
- device,
- "HD clouds",
- &cloud_layout,
- &cloud_module,
- "fs_cloud",
- CLOUD_FORMAT,
- None,
- );
+ let cloud_pipeline = {
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("HD clouds"),
+ bind_group_layouts: &[Some(&cloud_layout)],
+ ..Default::default()
+ });
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("HD clouds"),
+ layout: Some(&layout),
+ vertex: wgpu::VertexState {
+ module: &cloud_module,
+ entry_point: Some("vs_full"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &cloud_module,
+ entry_point: Some("fs_cloud"),
+ compilation_options: Default::default(),
+ targets: &[CLOUD_FORMAT, FOG_DIST_FORMAT].map(|format| {
+ Some(wgpu::ColorTargetState {
+ format,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })
+ }),
+ }),
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ multiview_mask: None,
+ cache: None,
+ })
+ };
let shadow_pipeline = fullscreen(
device,
"HD cloud shadows",
@@ -286,6 +315,11 @@ impl HdVolumes {
one("HD clouds", [1, 1], CLOUD_FORMAT),
one("HD clouds", [1, 1], CLOUD_FORMAT),
],
+ cloud_dist: [
+ one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
+ one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
+ ],
+ cloud_depth: None,
flip: 0,
history_ready: false,
cloud_shadow: one(
@@ -316,6 +350,10 @@ impl HdVolumes {
one("HD clouds", CLOUD_FORMAT),
one("HD clouds", CLOUD_FORMAT),
];
+ self.cloud_dist = [
+ one("HD cloud distance", FOG_DIST_FORMAT),
+ one("HD cloud distance", FOG_DIST_FORMAT),
+ ];
self.fog = one("HD fog", FOG_FORMAT);
self.fog_dist = one("HD fog distance", FOG_DIST_FORMAT);
self.history_ready = false;
@@ -342,6 +380,11 @@ impl HdVolumes {
&self.clouds[self.flip]
}
+ /// The march lengths that go with `cloud_view`.
+ pub fn cloud_dist_view(&self) -> &wgpu::TextureView {
+ &self.cloud_dist[self.flip]
+ }
+
fn group(
&mut self,
device: &wgpu::Device,
@@ -372,7 +415,12 @@ impl HdVolumes {
frame: &wgpu::Buffer,
sky_lut: &wgpu::TextureView,
atlas: &wgpu::TextureView,
+ depth: &wgpu::TextureView,
) {
+ if self.cloud_depth.as_ref() != Some(depth) {
+ self.cloud_groups = None;
+ self.cloud_depth = Some(depth.clone());
+ }
if self.shadow_group.is_none() {
let layout = self.shadow_layout.clone();
let (n3, n2, clamp, repeat) = (
@@ -405,6 +453,7 @@ impl HdVolumes {
self.repeat.clone(),
);
let hist = self.clouds.clone();
+ let hist_dist = self.cloud_dist.clone();
let groups = [0, 1].map(|read| {
self.group(
device,
@@ -419,6 +468,8 @@ impl HdVolumes {
wgpu::BindingResource::TextureView(sky_lut),
wgpu::BindingResource::TextureView(&hist[read]),
wgpu::BindingResource::TextureView(atlas),
+ wgpu::BindingResource::TextureView(depth),
+ wgpu::BindingResource::TextureView(&hist_dist[read]),
],
)
});
@@ -455,7 +506,7 @@ impl HdVolumes {
stamped_pass(
encoder,
"HD clouds",
- &[&self.clouds[write]],
+ &[&self.clouds[write], &self.cloud_dist[write]],
&self.cloud_pipeline,
&groups[read],
stamp,
@@ -525,8 +576,13 @@ impl Hd {
depth: &wgpu::TextureView,
) {
if self.clouds_active {
- self.volumes
- .prepare_clouds(device, &self.frame, &self.sky.lut, &self.smoke.atlas);
+ self.volumes.prepare_clouds(
+ device,
+ &self.frame,
+ &self.sky.lut,
+ &self.smoke.atlas,
+ depth,
+ );
self.volumes
.draw_cloud_shadow(encoder, stamp(&mut self.timing, "hd cloud shadows"));
self.volumes
@@ -570,3 +626,48 @@ pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingTyp
("fog", fog_body(), &FOG_BINDINGS),
]
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Every world-anchored noise lookup divides the world coordinate (the camera's x, z wrapped to
+ /// 65536) by a scale that divides 65536, or the pattern jumps where the wrap happens (x or z = 0).
+ #[test]
+ fn noise_scales_divide_the_camera_wrap() {
+ let mut found = 0;
+ for (name, src) in [("cloud_common", CLOUD_COMMON), ("fog", FOG)] {
+ for key in ["w / ", "p3 / "] {
+ for part in src.split(key).skip(1) {
+ let num: String = part
+ .chars()
+ .take_while(|c| c.is_ascii_digit() || *c == '.')
+ .collect();
+ let Ok(scale) = num.parse::<f64>() else {
+ continue;
+ };
+ found += 1;
+ assert!(
+ scale >= 1.0 && scale.fract() == 0.0 && 65536 % (scale as u64) == 0,
+ "{name}: the scale {scale} does not divide 65536"
+ );
+ }
+ }
+ }
+ assert!(found >= 8, "found only {found} noise lookups");
+ }
+
+ /// The cloud noise's height is measured from the layer's base, not from the camera: the same
+ /// world point gives the same noise coordinate at any camera height.
+ #[test]
+ fn cloud_noise_height_is_anchored_to_the_layer() {
+ assert!(CLOUD_COMMON.contains("pos.y - frame.clouds.x"));
+ // frame.clouds.x = CLOUD_BASE - camera height: y_world - CLOUD_BASE however high the camera.
+ for cam in [64.0f32, 210.0, 1000.0] {
+ let world_y = 230.0f32;
+ let rel = world_y - cam;
+ let base_rel = CLOUD_BASE - cam;
+ assert!((rel - base_rel - (world_y - CLOUD_BASE)).abs() < 1e-3);
+ }
+ }
+}
--
2.54.0 (Apple Git-157)
@@ -0,0 +1,59 @@
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:44:45 +0300
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx/hd/smoke.rs | 12 ++++++++++++
crates/horizon-client/src/gfx/hd/smoke_grid.wgsl | 2 +-
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/crates/horizon-client/src/gfx/hd/smoke.rs b/crates/horizon-client/src/gfx/hd/smoke.rs
index dee0932e8..c751df4f4 100644
--- a/crates/horizon-client/src/gfx/hd/smoke.rs
+++ b/crates/horizon-client/src/gfx/hd/smoke.rs
@@ -46,6 +46,8 @@ const MAX_INDICES: usize = 120_000;
pub const MAX_LIGHTS: usize = 16;
/// How fast the wind carries smoke (blocks per second, x and z).
const WIND: [f32; 2] = [1.6, 1.2];
+/// The thinnest a trail is drawn, in blocks of radius (a cell is `CELL`).
+const MIN_TUBE: f32 = 3.6;
/// Smoke is on unless `HORIZON_HD_SMOKE=0`.
pub fn smoke_enabled() -> bool {
@@ -402,6 +404,16 @@ fn bake(s: &HdSmoke) -> Option<Baked> {
// Spreading thins it (the core's mass over a wider tube), and it fades out towards its end.
let thin = (r0 / radius).powf(0.5);
let density = s.shape[2] * (1.0 - u).powi(2) * thin * (t / 0.25).min(1.0).max(0.2);
+ // A tube thinner than the grid's cells is sampled unevenly (cells near its axis get it, their
+ // neighbours miss it): a chain of blobs. Trails are kept at least a cell and a bit wide, with
+ // the same mass per length, so a young trail is a continuous ribbon.
+ let d = [s.b[0] - s.a[0], s.b[1] - s.a[1], s.b[2] - s.a[2]];
+ let tube = d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 0.25;
+ let (radius, density) = if tube && radius < MIN_TUBE {
+ (MIN_TUBE, density * radius / MIN_TUBE)
+ } else {
+ (radius, density)
+ };
let heat = s.shape[3] * (-t / 1.5).exp();
let clear_amount = (1.0 - u).powf(0.8);
let clear = s.motion[1] * (1.0 + 0.4 * u);
diff --git a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
index 01b6e082a..0adcd55f6 100644
--- a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
+++ b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
@@ -40,7 +40,7 @@ fn fs_smoke_grid(in: FullOut) -> @location(0) vec4<f32> {
let seed = segs[i + 3u].x;
let len = sqrt(len2);
// Bulges along the tube, so it is not a pipe.
- let r = pa.w * (1.0 + 0.22 * sin(along * len * 0.6 + seed * 40.0));
+ let r = pa.w * (1.0 + 0.07 * sin(along * len * 0.35 + seed * 40.0));
if d < r && pb.w > 0.0 {
let q = d / r;
var w = (1.0 - q * q);
--
2.54.0 (Apple Git-157)
@@ -0,0 +1,642 @@
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:59:41 +0300
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game.rs | 31 +++-
crates/horizon-client/src/game/mod_models.rs | 9 ++
crates/horizon-client/src/gfx.rs | 39 ++++-
crates/horizon-client/src/gfx/mod_post.rs | 152 +++++++++++++++----
crates/horizon-client/src/gfx/mod_views.rs | 69 ++++++---
crates/horizon-mod-sdk/wit/horizon.wit | 10 ++
crates/horizon-mods/src/api_level.rs | 6 +-
crates/horizon-mods/src/runtime.rs | 12 ++
docs/api/mods-reference.md | 1 +
9 files changed, 269 insertions(+), 60 deletions(-)
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index f21b3dcdd..0e779aaa0 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -5950,7 +5950,7 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
- let show_hud = !self.ui.hide_gui;
+ let show_hud = !self.ui.hide_gui && self.mod_main_view(self.clock.fraction()).is_none();
// max(Hud.leftHeight, Hud.rightHeight) after the bars: the item name and
// the action bar sit above it.
let mut bar_height = 39;
@@ -11572,6 +11572,13 @@ impl Game<'_> {
let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch);
self.audio
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
+ // AirDef fork: a mod's camera as the screen's own view (replays): the listener stays the
+ // player's, everything seen is drawn from the camera with the full pipeline.
+ let main_view = self.mod_main_view(alpha);
+ let (camera, underwater, view_yaw, view_pitch) = match &main_view {
+ Some(v) => (v.camera, false, v.yaw, v.pitch),
+ None => (camera, underwater, view_yaw, view_pitch),
+ };
let block = [
camera.x.floor() as i32,
camera.y.floor() as i32,
@@ -11579,6 +11586,7 @@ impl Game<'_> {
];
// The camera inside an opaque block sees nothing through it: disable.
let cave_culling = self.settings.horizon.cave_culling
+ && main_view.is_none()
&& !self
.res
.facts()
@@ -11670,7 +11678,11 @@ impl Game<'_> {
// player who may build. Adventure players only get one with a CAN_BREAK or
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
// components nor opens menus for spectators, so those never show it.
- let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() {
+ let outline = if self.screen == Screen::Playing
+ && !self.ui.hide_gui
+ && main_view.is_none()
+ && self.may_build()
+ {
self.pick()
.map(|h| {
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
@@ -11733,17 +11745,19 @@ impl Game<'_> {
};
let fov_degrees = if title {
85.0
+ } else if let Some(v) = &main_view {
+ v.fov
} else {
self.fov_degrees(camera_alpha as f32, camera.to_array())
};
self.locator_camera =
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
- let bob = if title {
+ let bob = if title || main_view.is_some() {
glam::Mat4::IDENTITY
} else {
self.bob_matrix(camera_alpha as f32)
};
- let spin = if title {
+ let spin = if title || main_view.is_some() {
glam::Mat4::IDENTITY
} else {
self.spinning_matrix(camera_alpha as f32)
@@ -11791,7 +11805,7 @@ impl Game<'_> {
} else {
(Vec::new(), None)
};
- let hand = if title || self.scoping() {
+ let hand = if title || self.scoping() || main_view.is_some() {
Vec::new()
} else {
self.hand_geometry(camera_alpha)
@@ -11829,7 +11843,12 @@ impl Game<'_> {
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
- let mod_views = self.mod_views(alpha);
+ // The main view is the frame itself, not a picture of its own.
+ let mod_views: Vec<_> = self
+ .mod_views(alpha)
+ .into_iter()
+ .filter(|v| main_view.as_ref().is_none_or(|m| m.id != v.id))
+ .collect();
let hd_lights = self.mod_lights(camera, alpha);
let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha);
let frame = Frame {
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
index f5e68566a..7a4d742df 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -71,6 +71,9 @@ impl Game<'_> {
pub(super) fn mod_views(&self, _alpha: f64) -> Vec<horizon_client::gfx::ModView> {
Vec::new()
}
+ pub(super) fn mod_main_view(&self, _alpha: f64) -> Option<horizon_client::gfx::ModView> {
+ None
+ }
pub(super) fn mod_hd(&self) -> Option<horizon_client::gfx::HdSettings> {
None
}
@@ -300,6 +303,12 @@ impl Game<'_> {
.collect()
}
+ /// The camera a mod asked the screen's world from (`render.set-main-view`), for this frame.
+ pub(super) fn mod_main_view(&self, alpha: f64) -> Option<horizon_client::gfx::ModView> {
+ let id = self.mods.mods.main_view()?;
+ self.mod_views(alpha).into_iter().find(|v| v.id == id)
+ }
+
/// Draws every mod model between its last two poses, lit by the world
/// (or at full brightness), its faces shaded by its own rotation.
pub(super) fn mod_model_geometry(&self, camera: DVec3, alpha: f64, out: &mut Vec<Vertex>) {
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index 93a2f7c63..1a4e68b88 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -3363,7 +3363,8 @@ impl Renderer {
view: &t.depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
- store: wgpu::StoreOp::Discard,
+ // The mod's post pass reads it.
+ store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
@@ -3411,6 +3412,33 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
}
+ drop(pass);
+ // The mod's post pass with this camera: its blasts, flashes and glows in the picture,
+ // as much as the picture's size is worth. The picture's own camera and world are
+ // lit by the pass itself (no HD lighting here: it is the world's, not a picture's).
+ t.post = false;
+ if let Some(post) = &self.mod_post
+ && let Some((blasts, lights)) = mod_views::post_budget(t.size)
+ {
+ let mut params = self.mod_params.0.clone();
+ if params.len() >= 4 {
+ // Mode 0: the pass lights the land and draws the smoke itself.
+ params[3] = 0.0;
+ params[1] = params[1].min(blasts as f32);
+ params[2] = params[2].min(lights as f32);
+ }
+ post.write_to(
+ &self.queue,
+ &t.post_buffer,
+ (d.view, d.projection, d.camera),
+ frame.sky.sun_angle,
+ t.size,
+ &params,
+ self.mod_params.1,
+ );
+ post.run_view(&self.device, encoder, &t.post_buffer, (&t.color, &t.depth), &t.fx);
+ t.post = true;
+ }
}
encoder.copy_buffer_to_buffer(&views.main, 0, &self.globals, 0, size);
}
@@ -3703,7 +3731,12 @@ impl Renderer {
} else {
self.config.format.remove_srgb_suffix()
};
- self.mod_post = Some(mod_post::ModPost::new(&self.device, source, format)?);
+ self.mod_post = Some(mod_post::ModPost::new(
+ &self.device,
+ source,
+ format,
+ self.config.format.remove_srgb_suffix(),
+ )?);
self.mod_post_source = Some(source.to_owned());
}
}
@@ -4424,6 +4457,8 @@ impl Renderer {
id: v.id,
view_proj,
view,
+ projection: world_projection(v.fov, aspect, frame.depth_far),
+ camera: v.camera,
aspect,
fov: v.fov,
visible: seen,
diff --git a/crates/horizon-client/src/gfx/mod_post.rs b/crates/horizon-client/src/gfx/mod_post.rs
index d1f54a4bb..3d67ff25b 100644
--- a/crates/horizon-client/src/gfx/mod_post.rs
+++ b/crates/horizon-client/src/gfx/mod_post.rs
@@ -106,6 +106,8 @@ fn horizon_mod_post_fs(in: HorizonModPostOut) -> @location(0) vec4<f32> {
pub struct ModPost {
pipeline: wgpu::RenderPipeline,
+ /// The same pass onto the mod cameras' pictures (the surface's format).
+ view_pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
buffer: wgpu::Buffer,
sampler: wgpu::Sampler,
@@ -144,6 +146,7 @@ impl ModPost {
device: &wgpu::Device,
source: &str,
format: wgpu::TextureFormat,
+ view_format: wgpu::TextureFormat,
) -> Result<Self, String> {
let module = compile(source)?;
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
@@ -189,31 +192,35 @@ impl ModPost {
label: Some("Mod post"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
- let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
- label: Some("Mod post"),
- layout: Some(&pipeline_layout),
- vertex: wgpu::VertexState {
- module: &shader,
- entry_point: Some("horizon_mod_post_vs"),
- compilation_options: Default::default(),
- buffers: &[],
- },
- primitive: Default::default(),
- depth_stencil: None,
- multisample: Default::default(),
- fragment: Some(wgpu::FragmentState {
- module: &shader,
- entry_point: Some("horizon_mod_post_fs"),
- compilation_options: Default::default(),
- targets: &[Some(wgpu::ColorTargetState {
- format,
- blend: None,
- write_mask: wgpu::ColorWrites::ALL,
- })],
- }),
- multiview_mask: None,
- cache: None,
- });
+ let make = |format: wgpu::TextureFormat| {
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("Mod post"),
+ layout: Some(&pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &shader,
+ entry_point: Some("horizon_mod_post_vs"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ fragment: Some(wgpu::FragmentState {
+ module: &shader,
+ entry_point: Some("horizon_mod_post_fs"),
+ compilation_options: Default::default(),
+ targets: &[Some(wgpu::ColorTargetState {
+ format,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ }),
+ multiview_mask: None,
+ cache: None,
+ })
+ };
+ let pipeline = make(format);
+ let view_pipeline = make(view_format);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(e.to_string());
}
@@ -231,6 +238,7 @@ impl ModPost {
});
Ok(Self {
pipeline,
+ view_pipeline,
layout,
buffer,
sampler,
@@ -253,7 +261,30 @@ impl ModPost {
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
* frame.bob
* frame.spin;
- let a = frame.sky.sun_angle.to_radians();
+ self.write_to(
+ queue,
+ &self.buffer,
+ (view, projection, frame.camera),
+ frame.sky.sun_angle,
+ size,
+ params,
+ partial,
+ );
+ }
+
+ /// The same for another camera (a mod view's picture) into its own `buffer`.
+ #[allow(clippy::too_many_arguments)]
+ pub fn write_to(
+ &self,
+ queue: &wgpu::Queue,
+ buffer: &wgpu::Buffer,
+ (view, projection, camera): (Mat4, Mat4, glam::DVec3),
+ sun_angle: f32,
+ size: [u32; 2],
+ params: &[f32],
+ partial: f32,
+ ) {
+ let a = sun_angle.to_radians();
let sun = [-a.sin(), a.cos(), 0.0, 0.0];
let mut p = [[0.0f32; 4]; PARAMS / 4];
for (i, v) in params.iter().take(PARAMS).enumerate() {
@@ -264,12 +295,7 @@ impl ModPost {
view_proj: m(projection * view),
view_inverse: m(view.inverse()),
projection_inverse: m(projection.inverse()),
- camera: [
- frame.camera.x as f32,
- frame.camera.y as f32,
- frame.camera.z as f32,
- 0.0,
- ],
+ camera: [camera.x as f32, camera.y as f32, camera.z as f32, 0.0],
resolution: [
size[0] as f32,
size[1] as f32,
@@ -285,7 +311,67 @@ impl ModPost {
sun,
params: p,
};
- queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
+ queue.write_buffer(buffer, 0, bytemuck::bytes_of(&inputs));
+ }
+
+ /// A buffer for one more camera's `write_to`.
+ pub fn new_buffer(device: &wgpu::Device) -> wgpu::Buffer {
+ device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("Mod post frame (view)"),
+ size: size_of::<ModFrame>() as u64,
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ })
+ }
+
+ /// Draws the pass over a mod camera's picture (`world`: colour and depth, `buffer`: what
+ /// `write_to` filled) onto `target`, in the surface's format.
+ pub fn run_view(
+ &self,
+ device: &wgpu::Device,
+ encoder: &mut wgpu::CommandEncoder,
+ buffer: &wgpu::Buffer,
+ world: (&wgpu::TextureView, &wgpu::TextureView),
+ target: &wgpu::TextureView,
+ ) {
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("Mod post (view)"),
+ layout: &self.layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: buffer.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::TextureView(world.0),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::TextureView(world.1),
+ },
+ wgpu::BindGroupEntry {
+ binding: 3,
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
+ },
+ ],
+ });
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("Mod post (view)"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: target,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ ..Default::default()
+ });
+ pass.set_pipeline(&self.view_pipeline);
+ pass.set_bind_group(0, &group, &[]);
+ pass.draw(0..3, 0..1);
}
/// Draws the pass over the world target `world` (colour, depth) onto `screen`.
diff --git a/crates/horizon-client/src/gfx/mod_views.rs b/crates/horizon-client/src/gfx/mod_views.rs
index aa2c5b971..bb65480ea 100644
--- a/crates/horizon-client/src/gfx/mod_views.rs
+++ b/crates/horizon-client/src/gfx/mod_views.rs
@@ -26,15 +26,34 @@ pub(super) const PER_FRAME: usize = 3;
pub(super) const RADIUS: i32 = 12;
pub(super) struct Target {
- size: [u32; 2],
+ pub size: [u32; 2],
pub color: wgpu::TextureView,
pub depth: wgpu::TextureView,
pub globals: wgpu::Buffer,
blit: wgpu::BindGroup,
+ /// The picture after the mod's post pass (`post`), and what shows it.
+ pub fx: wgpu::TextureView,
+ blit_fx: wgpu::BindGroup,
+ /// The post pass's uniform for this camera.
+ pub post_buffer: wgpu::Buffer,
+ /// The picture shown went through the post pass this time.
+ pub post: bool,
/// Drawn at least once: the HUD shows nothing before.
pub drawn: bool,
}
+/// How much of the mod's post pass a picture of `size` pays for: (blasts, lights) the pass
+/// marches, or None for a picture too small to see them in. Full-screen pictures get all.
+pub(super) fn post_budget(size: [u32; 2]) -> Option<(usize, usize)> {
+ let area = size[0] as u64 * size[1] as u64;
+ match area {
+ 0..4096 => None,
+ 4096..150_000 => Some((4, 12)),
+ 150_000..600_000 => Some((12, 32)),
+ _ => Some((48, 79)),
+ }
+}
+
/// A camera drawn this frame.
pub(super) struct Due {
pub id: u32,
@@ -42,6 +61,9 @@ pub(super) struct Due {
/// world's other geometry are.
pub view_proj: Mat4,
pub view: Mat4,
+ /// Its own projection and place, for the mod's post pass.
+ pub projection: Mat4,
+ pub camera: DVec3,
pub aspect: f32,
pub fov: f32,
pub visible: Vec<super::resident::Visible>,
@@ -239,22 +261,31 @@ impl Views {
let depth = texture(
"Mod view depth",
wgpu::TextureFormat::Depth32Float,
- wgpu::TextureUsages::RENDER_ATTACHMENT,
+ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
);
- let blit = device.create_bind_group(&wgpu::BindGroupDescriptor {
- label: Some("Mod view picture"),
- layout: &self.layout,
- entries: &[
- wgpu::BindGroupEntry {
- binding: 0,
- resource: wgpu::BindingResource::TextureView(&color),
- },
- wgpu::BindGroupEntry {
- binding: 1,
- resource: wgpu::BindingResource::Sampler(&self.sampler),
- },
- ],
- });
+ let fx = texture(
+ "Mod view post",
+ self.format,
+ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
+ );
+ let group = |view: &wgpu::TextureView| {
+ device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("Mod view picture"),
+ layout: &self.layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: wgpu::BindingResource::TextureView(view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
+ },
+ ],
+ })
+ };
+ let blit = group(&color);
+ let blit_fx = group(&fx);
let globals = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Mod view globals"),
size: globals_size,
@@ -267,6 +298,10 @@ impl Views {
depth,
globals,
blit,
+ fx,
+ blit_fx,
+ post_buffer: super::mod_post::ModPost::new_buffer(device),
+ post: false,
drawn: false,
}
}
@@ -306,7 +341,7 @@ impl Views {
pass.set_viewport(x, y, w, h, 0.0, 1.0);
pass.set_scissor_rect(x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32);
pass.set_pipeline(&self.pipeline);
- pass.set_bind_group(0, &t.blit, &[]);
+ pass.set_bind_group(0, if t.post { &t.blit_fx } else { &t.blit }, &[]);
let mode = view.mode.min(2);
pass.draw(0..3, mode..mode + 1);
}
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index fba8f162d..bae0d2134 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -664,6 +664,16 @@ interface render {
/// Replaces the mod's cameras: up to 8. A `view` shape shows one; the
/// game renders up to three a frame, in turn.
set-views: func(views: list<camera-view>);
+ /// Renders the screen's world from one of the `set-views` cameras
+ /// (AirDef fork), with everything the player's own view has: the HD
+ /// lighting, sky, clouds, fog, smoke, TAA and the post passes, at the
+ /// window's resolution. The picture size of that camera is ignored, the
+ /// player's hand, block outline and the game's HUD are not drawn (the mod's
+ /// own `set-hud` shapes are); a `view` shape of that camera draws nothing.
+ /// For a full-screen replay or a director's camera; the small pictures of
+ /// monitors stay `view` shapes. None (or an unknown id) is the player's
+ /// own view. The chunks stay those round the player.
+ set-main-view: func(view: option<u32>);
/// A light the mod's things give off (AirDef fork): rocket flames,
/// flares, blasts. Drawn by the HD lighting (`set-lighting`).
record point-light {
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index f438eadbd..e578de688 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
-pub const LEVEL: u32 = 14;
+pub const LEVEL: u32 = 15;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 68] = [
+pub const FUNCTIONS: [(&str, u32); 69] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -86,6 +86,8 @@ pub const FUNCTIONS: [(&str, u32); 68] = [
("render.set-time-override", 13),
// AirDef fork: volumetric smoke and exhaust trails.
("render.set-smoke", 14),
+ // AirDef fork: a mod camera as the screen's own view.
+ ("render.set-main-view", 15),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 495ef6cae..74da34673 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -506,6 +506,8 @@ struct State {
post_params: Vec<f32>,
/// `render.set-views`' cameras (AirDef fork).
views: Vec<CameraView>,
+ /// `render.set-main-view`: the camera the screen's world is rendered from.
+ main_view: Option<u32>,
/// `render.set-lights` and `render.set-lighting` (AirDef fork).
lights: Vec<PointLight>,
lighting: Option<ModLighting>,
@@ -754,6 +756,7 @@ impl Mods {
post: None,
post_params: Vec::new(),
views: Vec::new(),
+ main_view: None,
lights: Vec::new(),
lighting: None,
time_override: None,
@@ -1363,6 +1366,12 @@ impl Mods {
t.smoke_active = on;
}
}
+ /// The camera the first running mod asked the screen's world from, if any.
+ pub fn main_view(&self) -> Option<u32> {
+ self.entries
+ .iter()
+ .find_map(|e| e.run.as_ref()?.store.data().main_view)
+ }
/// The time of day the first running mod asked to show, if any.
pub fn time_override(&self) -> Option<u64> {
self.entries
@@ -2230,6 +2239,9 @@ impl render::Host for State {
})
.collect();
}
+ fn set_main_view(&mut self, view: Option<u32>) {
+ self.main_view = view;
+ }
fn set_lights(&mut self, lights: Vec<PointLight>) {
let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
let unit = |v: f32| {
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
index fefc4903f..1bd2c7eae 100644
--- a/docs/api/mods-reference.md
+++ b/docs/api/mods-reference.md
@@ -667,6 +667,7 @@ A piece of smoke or exhaust trail (AirDef fork): a tube from `a` to `b` (equal e
| `clear-post` | `func()` | Removes this mod's post pass: the world is shown as the game draws it. |
| `set-params` | `func(params: list<f32>)` | The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until replaced; call it once a tick (the pass gets the partial tick in `frame.time.y`). |
| `set-views` | `func(views: list<camera-view>)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. |
+| `set-main-view` | `func(view: option<u32>)` | Renders the screen's world from one of the `set-views` cameras (AirDef fork), with everything the player's own view has: the HD lighting, sky, clouds, fog, smoke, TAA and the post passes, at the window's resolution. The picture size of that camera is ignored, the player's hand, block outline and the game's HUD are not drawn (the mod's own `set-hud` shapes are); a `view` shape of that camera draws nothing. For a full-screen replay or a director's camera; the small pictures of monitors stay `view` shapes. None (or an unknown id) is the player's own view. The chunks stay those round the player. |
| `set-lights` | `func(lights: list<point-light>)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. |
| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
| `set-time-override` | `func(day-time: option<u64>)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. |
--
2.54.0 (Apple Git-157)
File diff suppressed because it is too large Load Diff