ccc3e20902
Smoke: one rule by engine, no heat on trails, no trail on P1-SUN. Searchlights: two lamps a target, off with an empty sky. Halos fade before their reach and their sum is limited. Launch sites load chunks through the server thread, go off-map as a last resort, and reach 4000 blocks. Air defence: ranges x1.5, shots grow with urgency, claims released, /airdef lastchance, long shots on a big radar's tracks. Build: rust-toolchain.toml, airdef.py check, generator manifest and --check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2737 lines
115 KiB
Diff
2737 lines
115 KiB
Diff
From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001
|
||
From: chexit <chexit@duck.com>
|
||
Date: Fri, 30 Oct 2026 09:28:05 +0300
|
||
Subject: [PATCH 32/40] Spot lights for mods (render.set-spot-lights, API level
|
||
16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film
|
||
capture (render.capture-*, set-film-quality, API level 17): fixed-step clock,
|
||
frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58);
|
||
the client builds without the mods feature again
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
---
|
||
crates/horizon-client/src/audio.rs | 80 +-
|
||
crates/horizon-client/src/game.rs | 38 +-
|
||
crates/horizon-client/src/game/film.rs | 806 ++++++++++++++++++
|
||
crates/horizon-client/src/game/mod_models.rs | 76 +-
|
||
crates/horizon-client/src/game/mods.rs | 60 ++
|
||
crates/horizon-client/src/gfx.rs | 134 ++-
|
||
crates/horizon-client/src/gfx/hd/cloud.wgsl | 4 +-
|
||
crates/horizon-client/src/gfx/hd/fog.wgsl | 20 +-
|
||
crates/horizon-client/src/gfx/hd/frame.rs | 25 +-
|
||
crates/horizon-client/src/gfx/hd/light.wgsl | 27 +-
|
||
crates/horizon-client/src/gfx/hd/lighting.rs | 4 +-
|
||
crates/horizon-client/src/gfx/hd/mod.rs | 43 +-
|
||
crates/horizon-client/src/gfx/hd/prelude.wgsl | 1 +
|
||
crates/horizon-client/src/gfx/hd/spot.wgsl | 116 +++
|
||
crates/horizon-client/src/gfx/hd/taa.rs | 17 +
|
||
crates/horizon-client/src/gfx/hd/tiles.rs | 119 ++-
|
||
crates/horizon-mod-sdk/wit/horizon.wit | 110 ++-
|
||
crates/horizon-mods/src/api_level.rs | 11 +-
|
||
crates/horizon-mods/src/runtime.rs | 208 ++++-
|
||
docs/api/mods-reference.md | 66 +-
|
||
20 files changed, 1917 insertions(+), 48 deletions(-)
|
||
create mode 100644 crates/horizon-client/src/game/film.rs
|
||
create mode 100644 crates/horizon-client/src/gfx/hd/spot.wgsl
|
||
|
||
diff --git a/crates/horizon-client/src/audio.rs b/crates/horizon-client/src/audio.rs
|
||
index e45362e19..b09178c1d 100644
|
||
--- a/crates/horizon-client/src/audio.rs
|
||
+++ b/crates/horizon-client/src/audio.rs
|
||
@@ -47,6 +47,8 @@ struct Mixer {
|
||
listener_yaw: f32,
|
||
listener_pitch: f32,
|
||
limiter_peak: f32,
|
||
+ /// Film capture: the output device hears nothing, `Audio::film_mix` mixes instead.
|
||
+ offline: bool,
|
||
}
|
||
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
|
||
/// what the subtitle overlay needs.
|
||
@@ -69,6 +71,10 @@ pub struct MixerBackend {
|
||
decoder: Option<std::sync::mpsc::Sender<Decode>>,
|
||
next: ChannelId,
|
||
live: bool,
|
||
+ /// Film capture: sounds decode at once (the mix runs on the film's own time) and only the
|
||
+ /// ones played while `allow` is set (a mod's) start.
|
||
+ offline: bool,
|
||
+ allow: bool,
|
||
}
|
||
/// Decoded samples and their rate (`None`: the file did not load).
|
||
type Samples = Option<(Arc<Vec<f32>>, u32)>;
|
||
@@ -164,6 +170,8 @@ pub struct Audio {
|
||
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
|
||
/// any), with or without an audio device, as `heard`.
|
||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||
+ /// What film capture set aside: (live, loaded) as they were.
|
||
+ film_saved: Option<(bool, bool)>,
|
||
}
|
||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||
use symphonia::core::{
|
||
@@ -264,11 +272,27 @@ impl Backend for MixerBackend {
|
||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||
/// as a failed buffer future does in vanilla.
|
||
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
|
||
- if !self.live {
|
||
+ if !self.live || (self.offline && !self.allow) {
|
||
return;
|
||
}
|
||
let (namespace, path) = sound.path();
|
||
let file = self.root.join("assets").join(namespace).join(&path);
|
||
+ if self.offline {
|
||
+ // Decoded here, so the sound starts at the film frame that asked for it.
|
||
+ let key = format!("{namespace}:{path}");
|
||
+ let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key));
|
||
+ let samples = cached.unwrap_or_else(|| {
|
||
+ let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
|
||
+ if !sound.stream
|
||
+ && let Ok(mut c) = self.cache.lock()
|
||
+ {
|
||
+ c.insert(key, samples.clone());
|
||
+ }
|
||
+ samples
|
||
+ });
|
||
+ attach(&self.mixer, channel, samples, looping);
|
||
+ return;
|
||
+ }
|
||
if sound.stream {
|
||
// A streamed sound loops by restarting its stream.
|
||
let mixer = self.mixer.clone();
|
||
@@ -530,7 +554,11 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||
move |data: &mut [T], _| {
|
||
scratch.resize(data.len(), 0.0f32);
|
||
if let Ok(mut m) = mixer.lock() {
|
||
- mix(&mut m, &mut scratch, channels, rate);
|
||
+ if m.offline {
|
||
+ scratch.fill(0.0);
|
||
+ } else {
|
||
+ mix(&mut m, &mut scratch, channels, rate);
|
||
+ }
|
||
} else {
|
||
scratch.fill(0.0);
|
||
}
|
||
@@ -589,6 +617,8 @@ impl Audio {
|
||
decoder: None,
|
||
next: 0,
|
||
live,
|
||
+ offline: false,
|
||
+ allow: false,
|
||
},
|
||
record_heard: false,
|
||
listener: [0.0; 3],
|
||
@@ -599,6 +629,52 @@ impl Audio {
|
||
capture: None,
|
||
heard: Default::default(),
|
||
stopped: Default::default(),
|
||
+ film_saved: None,
|
||
+ }
|
||
+ }
|
||
+ /// Film capture starts: every sound stops, and from now on only the sounds played between
|
||
+ /// `film_allow(true)` and `film_allow(false)` (a mod's) are heard, mixed by `film_mix` on
|
||
+ /// the film's own clock instead of reaching the output device.
|
||
+ pub fn film_begin(&mut self) {
|
||
+ if self.film_saved.is_some() {
|
||
+ return;
|
||
+ }
|
||
+ self.stop_all();
|
||
+ self.film_saved = Some((self.backend.live, self.engine.loaded));
|
||
+ self.backend.live = true;
|
||
+ self.backend.offline = true;
|
||
+ self.backend.allow = false;
|
||
+ self.engine.loaded = true;
|
||
+ if let Ok(mut m) = self.backend.mixer.lock() {
|
||
+ m.offline = true;
|
||
+ m.channels.clear();
|
||
+ }
|
||
+ }
|
||
+ /// Whether the sounds played now are the film's.
|
||
+ pub fn film_allow(&mut self, on: bool) {
|
||
+ self.backend.allow = on;
|
||
+ }
|
||
+ /// The film's next `frames` stereo frames, interleaved, at `rate`.
|
||
+ pub fn film_mix(&mut self, frames: usize, rate: u32) -> Vec<f32> {
|
||
+ let mut data = vec![0.0f32; frames * 2];
|
||
+ if let Ok(mut m) = self.backend.mixer.lock() {
|
||
+ mix(&mut m, &mut data, 2, rate);
|
||
+ }
|
||
+ data
|
||
+ }
|
||
+ /// Film capture ends: the output device hears the game again.
|
||
+ pub fn film_end(&mut self) {
|
||
+ let Some((live, loaded)) = self.film_saved.take() else {
|
||
+ return;
|
||
+ };
|
||
+ self.stop_all();
|
||
+ self.backend.live = live;
|
||
+ self.backend.offline = false;
|
||
+ self.backend.allow = false;
|
||
+ self.engine.loaded = loaded;
|
||
+ if let Ok(mut m) = self.backend.mixer.lock() {
|
||
+ m.offline = false;
|
||
+ m.channels.clear();
|
||
}
|
||
}
|
||
#[cfg(not(feature = "audio"))]
|
||
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
|
||
index 0e779aaa0..bace52193 100644
|
||
--- a/crates/horizon-client/src/game.rs
|
||
+++ b/crates/horizon-client/src/game.rs
|
||
@@ -60,6 +60,7 @@ mod explosion;
|
||
mod extras;
|
||
mod falling_damage;
|
||
mod fangs;
|
||
+mod film;
|
||
mod fireworks;
|
||
mod fishing;
|
||
mod fov;
|
||
@@ -1818,6 +1819,8 @@ struct Game<'a> {
|
||
extras: extras::ExtrasState,
|
||
/// Horizon mods (`game/mods.rs`), none running unless turned on.
|
||
mods: mods::ModsState,
|
||
+ /// AirDef fork: film capture (`game/film.rs`).
|
||
+ film: film::FilmState,
|
||
/// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`).
|
||
native_shaders: native_shaders::NativeShaders,
|
||
/// Meshed sections waiting for an upload budget (Horizon settings).
|
||
@@ -6010,6 +6013,9 @@ impl Game<'_> {
|
||
self.draw_boss_bars(&mut batch);
|
||
self.draw_extras(&mut batch);
|
||
self.draw_mod_shapes(&mut batch);
|
||
+ } else if !self.ui.hide_gui {
|
||
+ // A mod's camera as the screen's view: only the mod's own shapes (its titles, bars).
|
||
+ self.draw_mod_shapes(&mut batch);
|
||
}
|
||
if show_hud {
|
||
// Hud.nextContextualInfoState: the jump bar, the experience bar or
|
||
@@ -6143,7 +6149,10 @@ impl Game<'_> {
|
||
let tw = HudBatch::text_width(atlas, text);
|
||
batch.text(atlas, text, (w - tw) / 2.0, h - 59.0, u32::MAX, true);
|
||
}
|
||
- self.draw_chat(&mut batch);
|
||
+ // AirDef fork: a film's frames carry no chat (its progress messages go there).
|
||
+ if !self.film.recording() {
|
||
+ self.draw_chat(&mut batch);
|
||
+ }
|
||
self.draw_tab_list(&mut batch);
|
||
// Screen.extractBlurredBackground blurs the world and HUD, while the
|
||
// screen's background, widgets, toasts and debug overlay stay sharp.
|
||
@@ -8373,12 +8382,17 @@ impl Game<'_> {
|
||
}
|
||
|
||
/// The time of day the player sees: a mod's override (a replay's recorded time), else the world's.
|
||
+ #[cfg(feature = "mods")]
|
||
fn shown_day_time(&self) -> i64 {
|
||
self.mods
|
||
.mods
|
||
.time_override()
|
||
.map_or(self.world.day_time, |t| t as i64)
|
||
}
|
||
+ #[cfg(not(feature = "mods"))]
|
||
+ fn shown_day_time(&self) -> i64 {
|
||
+ self.world.day_time
|
||
+ }
|
||
|
||
/// The sky's darkening for the game's rules (mob AI, spawning, block behaviour, stats): always
|
||
/// the world's real time, never a mod's render-only override.
|
||
@@ -11575,6 +11589,21 @@ impl Game<'_> {
|
||
// AirDef fork: a mod's camera as the screen's own view (replays): the listener stays the
|
||
// player's, everything seen is drawn from the camera with the full pipeline.
|
||
let main_view = self.mod_main_view(alpha);
|
||
+ // AirDef fork: a cut starts the temporal histories over; a film hears from the camera's
|
||
+ // orientation (the sounds are already placed round the player).
|
||
+ let view_id = main_view.as_ref().map(|v| v.id);
|
||
+ if view_id != self.film.last_view {
|
||
+ self.film.last_view = view_id;
|
||
+ if let Some(r) = self.renderer.as_mut() {
|
||
+ r.cut = true;
|
||
+ }
|
||
+ }
|
||
+ if let Some(v) = &main_view
|
||
+ && self.film.recording()
|
||
+ {
|
||
+ self.audio
|
||
+ .set_listener([camera.x, camera.y, camera.z], v.yaw, v.pitch);
|
||
+ }
|
||
let (camera, underwater, view_yaw, view_pitch) = match &main_view {
|
||
Some(v) => (v.camera, false, v.yaw, v.pitch),
|
||
None => (camera, underwater, view_yaw, view_pitch),
|
||
@@ -11850,11 +11879,13 @@ impl Game<'_> {
|
||
.filter(|v| main_view.as_ref().is_none_or(|m| m.id != v.id))
|
||
.collect();
|
||
let hd_lights = self.mod_lights(camera, alpha);
|
||
+ let hd_spots = self.mod_spots(camera, alpha);
|
||
let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha);
|
||
let frame = Frame {
|
||
mod_views: &mod_views,
|
||
hd: self.mod_hd(),
|
||
hd_lights: &hd_lights,
|
||
+ hd_spots: &hd_spots,
|
||
hd_smoke: &hd_smoke,
|
||
hd_smoke_clock,
|
||
weather: &weather,
|
||
@@ -13397,6 +13428,7 @@ impl ApplicationHandler for Game<'_> {
|
||
// For the "I'm playing" ping: an atomic, sent on when it changes.
|
||
horizon_client::heartbeat::set_lan(self.is_published());
|
||
self.update_discord_presence();
|
||
+ let elapsed = self.film_step(elapsed);
|
||
let steps = self.clock.advance(elapsed);
|
||
// The world ticking in the frame: once its ticks took a
|
||
// tick's length, the rest are dropped (`FixedStep::tick_fits`).
|
||
@@ -13438,7 +13470,9 @@ impl ApplicationHandler for Game<'_> {
|
||
self.pad_look(elapsed.as_secs_f32());
|
||
self.advance_flight();
|
||
let ticked = Instant::now();
|
||
+ self.film_before_render()?;
|
||
let rendered = self.render()?;
|
||
+ self.film_after_render(rendered);
|
||
self.apply_cursor_icon();
|
||
if let Some(threshold) = self.options.spikes
|
||
&& let Some(r) = self.renderer.as_ref()
|
||
@@ -13972,6 +14006,7 @@ impl<'a> Game<'a> {
|
||
perf: Default::default(),
|
||
pending_screenshot: None,
|
||
pending_bench: None,
|
||
+ film: Default::default(),
|
||
flight: None,
|
||
ui_wait: Default::default(),
|
||
events: Default::default(),
|
||
@@ -14790,6 +14825,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||
mod_views: &[],
|
||
hd: None,
|
||
hd_lights: &[],
|
||
+ hd_spots: &[],
|
||
hd_smoke: &[],
|
||
hd_smoke_clock: 0.0,
|
||
weather: &[],
|
||
diff --git a/crates/horizon-client/src/game/film.rs b/crates/horizon-client/src/game/film.rs
|
||
new file mode 100644
|
||
index 000000000..8d1995030
|
||
--- /dev/null
|
||
+++ b/crates/horizon-client/src/game/film.rs
|
||
@@ -0,0 +1,806 @@
|
||
+//! AirDef fork: film capture (`render.capture-start`). While a mod films, the game clock runs at a
|
||
+//! fixed step (exactly `1 / fps` seconds a frame, whatever the machine's speed), each frame is
|
||
+//! copied back from the GPU through a bounded queue and handed to a writer thread that pipes it to
|
||
+//! `ffmpeg` (or writes a PNG sequence when there is none), and the sounds the mod plays are mixed
|
||
+//! offline on the same clock into the file's audio track.
|
||
+use super::Game;
|
||
+use horizon_client::gfx::CapturedFrame;
|
||
+use std::{
|
||
+ io::Write,
|
||
+ path::{Path, PathBuf},
|
||
+ process::{Child, Command, Stdio},
|
||
+ sync::{Arc, Mutex, mpsc},
|
||
+ time::Duration,
|
||
+};
|
||
+
|
||
+/// The audio track's sample rate.
|
||
+const RATE: u32 = 48_000;
|
||
+/// Frames the writer holds waiting (each 8 MB at 1080p): the renderer waits when it is full.
|
||
+const QUEUE: usize = 3;
|
||
+
|
||
+/// What a mod asked for (`CaptureOptions`).
|
||
+pub(super) struct FilmRequest {
|
||
+ pub folder: String,
|
||
+ pub name: String,
|
||
+ pub fps: u32,
|
||
+ pub width: u32,
|
||
+ pub height: u32,
|
||
+ pub crf: u32,
|
||
+ pub audio: bool,
|
||
+ pub warmup: u32,
|
||
+}
|
||
+
|
||
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
+pub(super) enum Phase {
|
||
+ Idle,
|
||
+ Arming,
|
||
+ Recording,
|
||
+ Encoding,
|
||
+ Done,
|
||
+ Failed,
|
||
+}
|
||
+
|
||
+/// Where the capture is (`CaptureState`).
|
||
+#[derive(Clone, Debug)]
|
||
+pub(super) struct FilmReport {
|
||
+ pub phase: Phase,
|
||
+ pub frames: u32,
|
||
+ pub size: [u32; 2],
|
||
+ pub file: String,
|
||
+ pub message: String,
|
||
+}
|
||
+
|
||
+impl Default for FilmReport {
|
||
+ fn default() -> Self {
|
||
+ Self {
|
||
+ phase: Phase::Idle,
|
||
+ frames: 0,
|
||
+ size: [0, 0],
|
||
+ file: String::new(),
|
||
+ message: String::new(),
|
||
+ }
|
||
+ }
|
||
+}
|
||
+
|
||
+enum Msg {
|
||
+ Frame(CapturedFrame),
|
||
+ Audio(Vec<f32>),
|
||
+ Finish,
|
||
+ Cancel,
|
||
+}
|
||
+
|
||
+/// What the writer ended with.
|
||
+struct Outcome {
|
||
+ ok: bool,
|
||
+ file: String,
|
||
+ message: String,
|
||
+}
|
||
+
|
||
+/// A running capture.
|
||
+pub(super) struct Film {
|
||
+ req: FilmRequest,
|
||
+ dir: PathBuf,
|
||
+ phase: Phase,
|
||
+ /// Frames spent waiting for the picture to reach the size.
|
||
+ waited: u32,
|
||
+ /// Frames whose time step has been taken, and frames sent to the writer.
|
||
+ stepped: u64,
|
||
+ sent: u32,
|
||
+ size: [u32; 2],
|
||
+ /// The size the screen had before.
|
||
+ before: [u32; 2],
|
||
+ sink: Option<mpsc::SyncSender<Msg>>,
|
||
+ outcome: Arc<Mutex<Option<Outcome>>>,
|
||
+ thread: Option<std::thread::JoinHandle<()>>,
|
||
+ /// The shot the last frame showed (a change is a cut: warm-up frames follow).
|
||
+ view: Option<u32>,
|
||
+ /// Audio frames mixed so far.
|
||
+ mixed: u64,
|
||
+ /// Told to stop (cancel or not) and what the writer is finishing.
|
||
+ cancel: bool,
|
||
+ note: String,
|
||
+}
|
||
+
|
||
+/// The capture's state for the game: the running one and the last report.
|
||
+#[derive(Default)]
|
||
+pub(super) struct FilmState {
|
||
+ pub(super) film: Option<Film>,
|
||
+ pub(super) report: FilmReport,
|
||
+ /// The main view of the last rendered frame (a cut resets the temporal histories).
|
||
+ pub(super) last_view: Option<u32>,
|
||
+}
|
||
+
|
||
+/// An `ffmpeg` to run: `HORIZON_FFMPEG`, the `PATH`, or where Homebrew and packages put it (a
|
||
+/// game started from the desktop does not see a shell's `PATH`).
|
||
+fn find_ffmpeg() -> Option<PathBuf> {
|
||
+ if let Some(p) = std::env::var_os("HORIZON_FFMPEG").filter(|p| !p.is_empty()) {
|
||
+ return Some(PathBuf::from(p));
|
||
+ }
|
||
+ let name = if cfg!(windows) {
|
||
+ "ffmpeg.exe"
|
||
+ } else {
|
||
+ "ffmpeg"
|
||
+ };
|
||
+ let mut dirs: Vec<PathBuf> = std::env::var_os("PATH")
|
||
+ .map(|p| std::env::split_paths(&p).collect())
|
||
+ .unwrap_or_default();
|
||
+ for d in [
|
||
+ "/opt/homebrew/bin",
|
||
+ "/usr/local/bin",
|
||
+ "/usr/bin",
|
||
+ "/snap/bin",
|
||
+ ] {
|
||
+ dirs.push(d.into());
|
||
+ }
|
||
+ dirs.into_iter().map(|d| d.join(name)).find(|p| p.is_file())
|
||
+}
|
||
+
|
||
+/// The H.264 encoder arguments the `ffmpeg` has, best first (software x264, then the Mac's
|
||
+/// hardware encoder), or None.
|
||
+fn encoder(ffmpeg: &Path, crf: u32) -> Option<Vec<String>> {
|
||
+ let out = Command::new(ffmpeg)
|
||
+ .args(["-hide_banner", "-encoders"])
|
||
+ .output()
|
||
+ .ok()?;
|
||
+ let text = String::from_utf8_lossy(&out.stdout);
|
||
+ let has = |name: &str| {
|
||
+ text.lines()
|
||
+ .any(|l| l.split_whitespace().nth(1) == Some(name))
|
||
+ };
|
||
+ let args: &[&str] = if has("libx264") {
|
||
+ &["-c:v", "libx264", "-preset", "slow", "-crf"]
|
||
+ } else if has("h264_videotoolbox") {
|
||
+ return Some(
|
||
+ ["-c:v", "h264_videotoolbox", "-b:v", "60M", "-allow_sw", "1"]
|
||
+ .map(String::from)
|
||
+ .to_vec(),
|
||
+ );
|
||
+ } else {
|
||
+ return None;
|
||
+ };
|
||
+ let mut v: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||
+ v.push(crf.to_string());
|
||
+ Some(v)
|
||
+}
|
||
+
|
||
+/// The writer thread: frames to `ffmpeg`'s pipe (or PNG files), sound to a raw file, then the
|
||
+/// file is closed and the sound muxed in.
|
||
+fn write(
|
||
+ rx: mpsc::Receiver<Msg>,
|
||
+ dir: PathBuf,
|
||
+ name: String,
|
||
+ fps: u32,
|
||
+ size: [u32; 2],
|
||
+ crf: u32,
|
||
+ audio: bool,
|
||
+ outcome: Arc<Mutex<Option<Outcome>>>,
|
||
+) {
|
||
+ let result = (|| -> Result<(String, String), String> {
|
||
+ let pcm_path = dir.join(format!("{name}.audio.raw"));
|
||
+ let mut pcm = if audio {
|
||
+ Some(std::io::BufWriter::new(
|
||
+ std::fs::File::create(&pcm_path).map_err(|e| format!("audio file: {e}"))?,
|
||
+ ))
|
||
+ } else {
|
||
+ None
|
||
+ };
|
||
+ let ffmpeg = find_ffmpeg();
|
||
+ let encode = ffmpeg.as_deref().and_then(|f| encoder(f, crf));
|
||
+ let video_path = dir.join(if audio {
|
||
+ format!("{name}.video.mp4")
|
||
+ } else {
|
||
+ format!("{name}.mp4")
|
||
+ });
|
||
+ let frames_dir = dir.join(format!("{name}_frames"));
|
||
+ let mut child: Option<Child> = None;
|
||
+ let mut log_path = None;
|
||
+ if let (Some(ffmpeg), Some(encode)) = (&ffmpeg, &encode) {
|
||
+ let log = dir.join(format!("{name}.ffmpeg.log"));
|
||
+ let stderr = std::fs::File::create(&log).map_err(|e| format!("log file: {e}"))?;
|
||
+ log_path = Some(log);
|
||
+ let bgra = true;
|
||
+ let c = Command::new(ffmpeg)
|
||
+ .args(["-y", "-hide_banner", "-loglevel", "error", "-f", "rawvideo"])
|
||
+ .args(["-pix_fmt", if bgra { "bgra" } else { "rgba" }])
|
||
+ .args(["-video_size", &format!("{}x{}", size[0], size[1])])
|
||
+ .args(["-framerate", &fps.to_string(), "-i", "pipe:0", "-an"])
|
||
+ .args(encode)
|
||
+ .args([
|
||
+ "-vf",
|
||
+ "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p",
|
||
+ "-colorspace",
|
||
+ "bt709",
|
||
+ "-color_primaries",
|
||
+ "bt709",
|
||
+ "-color_trc",
|
||
+ "bt709",
|
||
+ "-color_range",
|
||
+ "tv",
|
||
+ "-movflags",
|
||
+ "+faststart",
|
||
+ ])
|
||
+ .arg(&video_path)
|
||
+ .stdin(Stdio::piped())
|
||
+ .stdout(Stdio::null())
|
||
+ .stderr(stderr)
|
||
+ .spawn()
|
||
+ .map_err(|e| format!("could not run ffmpeg: {e}"))?;
|
||
+ child = Some(c);
|
||
+ } else {
|
||
+ std::fs::create_dir_all(&frames_dir).map_err(|e| format!("frames folder: {e}"))?;
|
||
+ }
|
||
+ let mut stdin = child.as_mut().and_then(|c| c.stdin.take());
|
||
+ let mut n = 0u32;
|
||
+ let mut failed: Option<String> = None;
|
||
+ let mut cancelled = false;
|
||
+ for msg in rx {
|
||
+ match msg {
|
||
+ Msg::Frame(f) => {
|
||
+ if failed.is_some() {
|
||
+ continue;
|
||
+ }
|
||
+ n += 1;
|
||
+ if let Some(pipe) = stdin.as_mut() {
|
||
+ // The frames arrive as the surface has them: BGRA, or RGBA swapped here.
|
||
+ let mut data = f.data;
|
||
+ if !f.bgra {
|
||
+ for px in data.chunks_exact_mut(4) {
|
||
+ px.swap(0, 2);
|
||
+ }
|
||
+ }
|
||
+ if let Err(e) = pipe.write_all(&data) {
|
||
+ failed = Some(format!("ffmpeg stopped (see {name}.ffmpeg.log): {e}"));
|
||
+ }
|
||
+ } else if let Err(e) =
|
||
+ write_png(&frames_dir.join(format!("frame_{n:06}.png")), &f)
|
||
+ {
|
||
+ failed = Some(format!("PNG: {e}"));
|
||
+ }
|
||
+ }
|
||
+ Msg::Audio(samples) => {
|
||
+ if let Some(w) = pcm.as_mut() {
|
||
+ let mut bytes = Vec::with_capacity(samples.len() * 2);
|
||
+ for s in samples {
|
||
+ bytes.extend_from_slice(
|
||
+ &((s.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes(),
|
||
+ );
|
||
+ }
|
||
+ if let Err(e) = w.write_all(&bytes) {
|
||
+ failed.get_or_insert(format!("audio file: {e}"));
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ Msg::Finish => break,
|
||
+ Msg::Cancel => {
|
||
+ cancelled = true;
|
||
+ break;
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ drop(stdin);
|
||
+ if let Some(w) = pcm.as_mut() {
|
||
+ let _ = w.flush();
|
||
+ }
|
||
+ drop(pcm);
|
||
+ let tidy = |paths: &[&Path]| {
|
||
+ for p in paths {
|
||
+ let _ = std::fs::remove_file(p);
|
||
+ }
|
||
+ };
|
||
+ if let Some(mut c) = child {
|
||
+ let status = c.wait().map_err(|e| format!("ffmpeg: {e}"))?;
|
||
+ if cancelled || failed.is_some() || !status.success() {
|
||
+ let why = failed.unwrap_or_else(|| {
|
||
+ if cancelled {
|
||
+ "cancelled".into()
|
||
+ } else {
|
||
+ format!("ffmpeg failed (see {name}.ffmpeg.log)")
|
||
+ }
|
||
+ });
|
||
+ if cancelled {
|
||
+ tidy(&[&video_path, &pcm_path]);
|
||
+ if let Some(l) = &log_path {
|
||
+ tidy(&[l]);
|
||
+ }
|
||
+ }
|
||
+ return Err(why);
|
||
+ }
|
||
+ let out = dir.join(format!("{name}.mp4"));
|
||
+ let mut note = String::new();
|
||
+ if audio {
|
||
+ let mux = Command::new(ffmpeg.as_ref().expect("ffmpeg"))
|
||
+ .args(["-y", "-hide_banner", "-loglevel", "error", "-i"])
|
||
+ .arg(&video_path)
|
||
+ .args(["-f", "s16le", "-ar", &RATE.to_string(), "-ac", "2", "-i"])
|
||
+ .arg(&pcm_path)
|
||
+ .args([
|
||
+ "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", "-b:a", "256k",
|
||
+ ])
|
||
+ .args(["-shortest", "-movflags", "+faststart"])
|
||
+ .arg(&out)
|
||
+ .stdin(Stdio::null())
|
||
+ .output();
|
||
+ match mux {
|
||
+ Ok(o) if o.status.success() => tidy(&[&video_path, &pcm_path]),
|
||
+ other => {
|
||
+ // The picture is good: keep it without sound.
|
||
+ let _ = std::fs::rename(&video_path, &out);
|
||
+ note = format!(
|
||
+ "audio could not be mixed in ({}); the sound is in {name}.audio.raw (s16le, {RATE} Hz, stereo)",
|
||
+ match other {
|
||
+ Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(),
|
||
+ Err(e) => e.to_string(),
|
||
+ }
|
||
+ );
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ if let Some(l) = &log_path {
|
||
+ tidy(&[l]);
|
||
+ }
|
||
+ let encoder_note = if encode
|
||
+ .as_ref()
|
||
+ .is_some_and(|e| e.iter().any(|a| a == "h264_videotoolbox"))
|
||
+ {
|
||
+ "encoded with the hardware encoder (no libx264 in this ffmpeg)"
|
||
+ } else {
|
||
+ ""
|
||
+ };
|
||
+ let note = [note.as_str(), encoder_note]
|
||
+ .iter()
|
||
+ .filter(|s| !s.is_empty())
|
||
+ .copied()
|
||
+ .collect::<Vec<_>>()
|
||
+ .join("; ");
|
||
+ Ok((out.display().to_string(), note))
|
||
+ } else {
|
||
+ if cancelled {
|
||
+ let _ = std::fs::remove_dir_all(&frames_dir);
|
||
+ tidy(&[&pcm_path]);
|
||
+ return Err("cancelled".into());
|
||
+ }
|
||
+ if let Some(why) = failed {
|
||
+ return Err(why);
|
||
+ }
|
||
+ let wav = if audio {
|
||
+ write_wav(&pcm_path, &dir.join(format!("{name}.wav")))
|
||
+ } else {
|
||
+ Ok(false)
|
||
+ };
|
||
+ let mut note = format!(
|
||
+ "no ffmpeg found (put it on the PATH or set HORIZON_FFMPEG); {n} PNG frames written. To make the video: ffmpeg -framerate {fps} -i {}/frame_%06d.png{} -c:v libx264 -crf {crf} -pix_fmt yuv420p {}",
|
||
+ frames_dir.display(),
|
||
+ if matches!(wav, Ok(true)) {
|
||
+ format!(" -i {}", dir.join(format!("{name}.wav")).display())
|
||
+ } else {
|
||
+ String::new()
|
||
+ },
|
||
+ dir.join(format!("{name}.mp4")).display()
|
||
+ );
|
||
+ if let Err(e) = wav {
|
||
+ note.push_str(&format!("; audio: {e}"));
|
||
+ }
|
||
+ Ok((frames_dir.display().to_string(), note))
|
||
+ }
|
||
+ })();
|
||
+ if let Ok(mut o) = outcome.lock() {
|
||
+ *o = Some(match result {
|
||
+ Ok((file, message)) => Outcome {
|
||
+ ok: true,
|
||
+ file,
|
||
+ message,
|
||
+ },
|
||
+ Err(message) => Outcome {
|
||
+ ok: false,
|
||
+ file: String::new(),
|
||
+ message,
|
||
+ },
|
||
+ });
|
||
+ }
|
||
+}
|
||
+
|
||
+fn write_png(path: &Path, f: &CapturedFrame) -> Result<(), String> {
|
||
+ let mut rgba = Vec::with_capacity(f.data.len());
|
||
+ for px in f.data.chunks_exact(4) {
|
||
+ let (r, b) = if f.bgra {
|
||
+ (px[2], px[0])
|
||
+ } else {
|
||
+ (px[0], px[2])
|
||
+ };
|
||
+ rgba.extend_from_slice(&[r, px[1], b, 255]);
|
||
+ }
|
||
+ let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
|
||
+ let mut enc = png::Encoder::new(std::io::BufWriter::new(file), f.size[0], f.size[1]);
|
||
+ enc.set_color(png::ColorType::Rgba);
|
||
+ enc.set_depth(png::BitDepth::Eight);
|
||
+ enc.set_compression(png::Compression::Fast);
|
||
+ enc.write_header()
|
||
+ .and_then(|mut w| w.write_image_data(&rgba))
|
||
+ .map_err(|e| e.to_string())
|
||
+}
|
||
+
|
||
+/// The raw sound as a WAV file; false when there was none.
|
||
+fn write_wav(raw: &Path, wav: &Path) -> Result<bool, String> {
|
||
+ let data = std::fs::read(raw).map_err(|e| e.to_string())?;
|
||
+ if data.is_empty() {
|
||
+ return Ok(false);
|
||
+ }
|
||
+ let mut out = Vec::with_capacity(data.len() + 44);
|
||
+ out.extend_from_slice(b"RIFF");
|
||
+ out.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
||
+ out.extend_from_slice(b"WAVEfmt ");
|
||
+ out.extend_from_slice(&16u32.to_le_bytes());
|
||
+ out.extend_from_slice(&1u16.to_le_bytes());
|
||
+ out.extend_from_slice(&2u16.to_le_bytes());
|
||
+ out.extend_from_slice(&RATE.to_le_bytes());
|
||
+ out.extend_from_slice(&(RATE * 4).to_le_bytes());
|
||
+ out.extend_from_slice(&4u16.to_le_bytes());
|
||
+ out.extend_from_slice(&16u16.to_le_bytes());
|
||
+ out.extend_from_slice(b"data");
|
||
+ out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||
+ out.extend_from_slice(&data);
|
||
+ std::fs::write(wav, out).map_err(|e| e.to_string())?;
|
||
+ let _ = std::fs::remove_file(raw);
|
||
+ Ok(true)
|
||
+}
|
||
+
|
||
+impl FilmState {
|
||
+ /// Whether frames are being recorded now.
|
||
+ pub(super) fn recording(&self) -> bool {
|
||
+ self.film
|
||
+ .as_ref()
|
||
+ .is_some_and(|f| f.phase == Phase::Recording)
|
||
+ }
|
||
+}
|
||
+
|
||
+impl Game<'_> {
|
||
+ /// Start of a frame: a mod's start or stop, the writer's end, and the time step to run the
|
||
+ /// frame by: `real` normally, exactly one frame of the film while it records.
|
||
+ pub(super) fn film_step(&mut self, real: Duration) -> Duration {
|
||
+ self.film_poll();
|
||
+ let paused = self.pause;
|
||
+ let Some(film) = self.film.film.as_mut() else {
|
||
+ return real;
|
||
+ };
|
||
+ match film.phase {
|
||
+ Phase::Arming => {
|
||
+ self.film_arm();
|
||
+ real
|
||
+ }
|
||
+ Phase::Recording => {
|
||
+ // A pause menu stops the film, not the frame count.
|
||
+ if paused {
|
||
+ return Duration::ZERO;
|
||
+ }
|
||
+ let fps = u64::from(film.req.fps.max(1));
|
||
+ let n = film.stepped;
|
||
+ film.stepped += 1;
|
||
+ let at = |k: u64| (k * 1_000_000_000 + fps / 2) / fps;
|
||
+ Duration::from_nanos(at(n + 1) - at(n))
|
||
+ }
|
||
+ _ => real,
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// The mods' requests, the writer's outcome, the quality, and the report back to the mods.
|
||
+ fn film_poll(&mut self) {
|
||
+ let (start, stop) = self.mods_film_requests();
|
||
+ if let Some(req) = start {
|
||
+ if self.film.film.is_some() {
|
||
+ self.film_fail("a capture is already running");
|
||
+ } else {
|
||
+ match self.film_open(req) {
|
||
+ Ok(film) => {
|
||
+ self.film.report = FilmReport {
|
||
+ phase: Phase::Arming,
|
||
+ ..Default::default()
|
||
+ };
|
||
+ self.film.film = Some(film);
|
||
+ }
|
||
+ Err(e) => self.film_fail(&e),
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ if let Some(cancel) = stop {
|
||
+ self.film_finish(cancel);
|
||
+ }
|
||
+ // The writer is done.
|
||
+ if let Some(film) = self.film.film.as_mut()
|
||
+ && film.phase == Phase::Encoding
|
||
+ {
|
||
+ let outcome = film.outcome.lock().ok().and_then(|mut o| o.take());
|
||
+ if let Some(o) = outcome {
|
||
+ if let Some(t) = film.thread.take() {
|
||
+ let _ = t.join();
|
||
+ }
|
||
+ let (frames, size) = (film.sent, film.size);
|
||
+ let cancelled = film.cancel;
|
||
+ let before = film.before;
|
||
+ let audio = film.req.audio;
|
||
+ self.film.film = None;
|
||
+ if audio {
|
||
+ self.audio.film_end();
|
||
+ }
|
||
+ self.film_restore(before);
|
||
+ self.film.report = FilmReport {
|
||
+ phase: if o.ok { Phase::Done } else { Phase::Failed },
|
||
+ frames,
|
||
+ size,
|
||
+ file: o.file,
|
||
+ message: if cancelled && !o.ok {
|
||
+ "cancelled".into()
|
||
+ } else {
|
||
+ o.message
|
||
+ },
|
||
+ };
|
||
+ }
|
||
+ }
|
||
+ // Film quality: a capture needs it; otherwise it is the mods' choice.
|
||
+ let want = self.film.film.is_some() || self.mods_film_quality();
|
||
+ horizon_client::gfx::set_film_quality(want);
|
||
+ self.film_report();
|
||
+ }
|
||
+
|
||
+ fn film_fail(&mut self, message: &str) {
|
||
+ self.film.report = FilmReport {
|
||
+ phase: Phase::Failed,
|
||
+ message: message.into(),
|
||
+ ..Default::default()
|
||
+ };
|
||
+ }
|
||
+
|
||
+ /// The capture's report, as the mods read it.
|
||
+ fn film_report(&mut self) {
|
||
+ let mut r = self.film.report.clone();
|
||
+ if let Some(f) = &self.film.film {
|
||
+ r.phase = f.phase;
|
||
+ r.frames = f.sent;
|
||
+ r.size = f.size;
|
||
+ if !f.note.is_empty() {
|
||
+ r.message = f.note.clone();
|
||
+ }
|
||
+ }
|
||
+ self.mods_film_report(&r);
|
||
+ }
|
||
+
|
||
+ fn film_open(&mut self, req: FilmRequest) -> Result<Film, String> {
|
||
+ let Some(renderer) = self.renderer.as_ref() else {
|
||
+ return Err("no renderer".into());
|
||
+ };
|
||
+ renderer.can_capture().map_err(|e| e.to_string())?;
|
||
+ let dir = horizon_client::logging::game_home().join(&req.folder);
|
||
+ std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||
+ let before = renderer.frame_size();
|
||
+ Ok(Film {
|
||
+ req,
|
||
+ dir,
|
||
+ phase: Phase::Arming,
|
||
+ waited: 0,
|
||
+ stepped: 0,
|
||
+ sent: 0,
|
||
+ size: [0, 0],
|
||
+ before,
|
||
+ sink: None,
|
||
+ outcome: Arc::new(Mutex::new(None)),
|
||
+ thread: None,
|
||
+ view: None,
|
||
+ mixed: 0,
|
||
+ cancel: false,
|
||
+ note: String::new(),
|
||
+ })
|
||
+ }
|
||
+
|
||
+ /// Arming: the picture is brought to the size; at the size (or when the window will not
|
||
+ /// give it) the writer starts and the next frame is the film's first.
|
||
+ fn film_arm(&mut self) {
|
||
+ let offscreen = self.options.offscreen;
|
||
+ let Some(film) = self.film.film.as_mut() else {
|
||
+ return;
|
||
+ };
|
||
+ let Some(renderer) = self.renderer.as_mut() else {
|
||
+ return;
|
||
+ };
|
||
+ let have = renderer.frame_size();
|
||
+ let want = [film.req.width, film.req.height];
|
||
+ film.waited += 1;
|
||
+ let at_size = want[0] == 0 || have == want;
|
||
+ if !at_size && film.waited == 1 {
|
||
+ if offscreen {
|
||
+ renderer.resize(want[0], want[1]);
|
||
+ } else if let Some(w) = &self.window {
|
||
+ let _ = w.request_inner_size(winit::dpi::PhysicalSize::new(want[0], want[1]));
|
||
+ }
|
||
+ return;
|
||
+ }
|
||
+ if !at_size && film.waited < 120 {
|
||
+ return;
|
||
+ }
|
||
+ if !at_size {
|
||
+ film.note = format!(
|
||
+ "the window would not become {}x{}; filming at {}x{}",
|
||
+ want[0], want[1], have[0], have[1]
|
||
+ );
|
||
+ }
|
||
+ film.size = have;
|
||
+ let (tx, rx) = mpsc::sync_channel(QUEUE);
|
||
+ let (dir, name, fps, crf, audio) = (
|
||
+ film.dir.clone(),
|
||
+ film.req.name.clone(),
|
||
+ film.req.fps,
|
||
+ film.req.crf,
|
||
+ film.req.audio,
|
||
+ );
|
||
+ let outcome = film.outcome.clone();
|
||
+ film.thread = std::thread::Builder::new()
|
||
+ .name("film writer".into())
|
||
+ .spawn(move || write(rx, dir, name, fps, have, crf, audio, outcome))
|
||
+ .ok();
|
||
+ if film.thread.is_none() {
|
||
+ self.film_fail("could not start the writer");
|
||
+ self.film.film = None;
|
||
+ return;
|
||
+ }
|
||
+ film.sink = Some(tx);
|
||
+ film.phase = Phase::Recording;
|
||
+ film.stepped = 0;
|
||
+ if film.req.audio {
|
||
+ self.audio.film_begin();
|
||
+ }
|
||
+ // Ticks count from here, whole, each `fps / 20` frames apart.
|
||
+ self.clock.reset();
|
||
+ }
|
||
+
|
||
+ /// Before the frame is drawn: the cut's warm-up frames, then the copy for the writer.
|
||
+ pub(super) fn film_before_render(&mut self) -> horizon_client::Result<()> {
|
||
+ let view = self.mods_main_view_id();
|
||
+ let pause = self.pause;
|
||
+ let Some(film) = self.film.film.as_mut() else {
|
||
+ return Ok(());
|
||
+ };
|
||
+ if film.phase != Phase::Recording || pause {
|
||
+ return Ok(());
|
||
+ }
|
||
+ let warm = film.req.warmup;
|
||
+ let cut = view != film.view;
|
||
+ film.view = view;
|
||
+ if cut && warm > 0 && film.stepped > 1 {
|
||
+ // The new shot's histories are built from frames nobody sees.
|
||
+ for _ in 0..warm {
|
||
+ self.render()?;
|
||
+ }
|
||
+ }
|
||
+ if let Some(r) = self.renderer.as_mut() {
|
||
+ r.capture_next = true;
|
||
+ }
|
||
+ Ok(())
|
||
+ }
|
||
+
|
||
+ /// After the frame: the finished copies go to the writer, the audio of the frame is mixed.
|
||
+ pub(super) fn film_after_render(&mut self, rendered: bool) {
|
||
+ let Some(film) = self.film.film.as_mut() else {
|
||
+ return;
|
||
+ };
|
||
+ if film.phase != Phase::Recording {
|
||
+ return;
|
||
+ }
|
||
+ let Some(renderer) = self.renderer.as_mut() else {
|
||
+ return;
|
||
+ };
|
||
+ if renderer.frame_size() != film.size {
|
||
+ let (w, h) = (renderer.frame_size()[0], renderer.frame_size()[1]);
|
||
+ renderer.capture_next = false;
|
||
+ self.film_end(
|
||
+ &format!("the picture changed size to {w}x{h}: the film was stopped"),
|
||
+ false,
|
||
+ );
|
||
+ return;
|
||
+ }
|
||
+ if rendered && !self.pause && renderer.capture_pending() < 64 {
|
||
+ // Sounds for this frame, on the film's clock.
|
||
+ if film.req.audio {
|
||
+ let fps = u64::from(film.req.fps.max(1));
|
||
+ let rate = u64::from(RATE);
|
||
+ let f = u64::from(film.sent) + renderer.capture_pending() as u64;
|
||
+ let frames = ((f + 1) * rate / fps - f * rate / fps) as usize;
|
||
+ let mix = self.audio.film_mix(frames, RATE);
|
||
+ film.mixed += frames as u64;
|
||
+ if let Some(tx) = &film.sink {
|
||
+ let _ = tx.send(Msg::Audio(mix));
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ match renderer.capture_ready(2) {
|
||
+ Ok(frames) => {
|
||
+ for f in frames {
|
||
+ film.sent += 1;
|
||
+ if let Some(tx) = &film.sink
|
||
+ && tx.send(Msg::Frame(f)).is_err()
|
||
+ {
|
||
+ film.note = "the writer stopped".into();
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ Err(e) => {
|
||
+ let why = e.to_string();
|
||
+ self.film_end(&why, false);
|
||
+ }
|
||
+ }
|
||
+ let outcome_early = self
|
||
+ .film
|
||
+ .film
|
||
+ .as_ref()
|
||
+ .and_then(|f| f.outcome.lock().ok().map(|o| o.is_some()))
|
||
+ .unwrap_or(false);
|
||
+ if outcome_early {
|
||
+ self.film_finish(true);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Ends the capture with a reason (an error); the writer is told to keep what it has.
|
||
+ fn film_end(&mut self, why: &str, cancel: bool) {
|
||
+ if let Some(f) = self.film.film.as_mut() {
|
||
+ f.note = why.to_string();
|
||
+ }
|
||
+ self.film_finish(cancel);
|
||
+ }
|
||
+
|
||
+ /// Stops recording: the copies still in flight go to the writer, which closes the file (or
|
||
+ /// throws it away); the end is seen in `film_poll`.
|
||
+ fn film_finish(&mut self, cancel: bool) {
|
||
+ let Some(film) = self.film.film.as_mut() else {
|
||
+ return;
|
||
+ };
|
||
+ match film.phase {
|
||
+ Phase::Arming => {
|
||
+ let before = film.before;
|
||
+ self.film.film = None;
|
||
+ self.film_restore(before);
|
||
+ self.film.report = FilmReport {
|
||
+ phase: Phase::Failed,
|
||
+ message: "cancelled".into(),
|
||
+ ..Default::default()
|
||
+ };
|
||
+ }
|
||
+ Phase::Recording => {
|
||
+ if let Some(r) = self.renderer.as_mut() {
|
||
+ r.capture_next = false;
|
||
+ let rest = if cancel {
|
||
+ r.capture_discard();
|
||
+ Ok(Vec::new())
|
||
+ } else {
|
||
+ r.capture_ready(0).and_then(|_| {
|
||
+ // Everything in flight, waited for.
|
||
+ r.capture_ready(0)
|
||
+ })
|
||
+ };
|
||
+ if let (Ok(frames), Some(tx)) = (rest, &film.sink) {
|
||
+ for f in frames {
|
||
+ film.sent += 1;
|
||
+ let _ = tx.send(Msg::Frame(f));
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ film.cancel = cancel;
|
||
+ if let Some(tx) = film.sink.take() {
|
||
+ let _ = tx.send(if cancel { Msg::Cancel } else { Msg::Finish });
|
||
+ }
|
||
+ film.phase = Phase::Encoding;
|
||
+ }
|
||
+ _ => {}
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// The screen's size as it was before the film.
|
||
+ fn film_restore(&mut self, before: [u32; 2]) {
|
||
+ let offscreen = self.options.offscreen;
|
||
+ if let Some(r) = self.renderer.as_mut()
|
||
+ && r.frame_size() != before
|
||
+ {
|
||
+ if offscreen {
|
||
+ r.resize(before[0], before[1]);
|
||
+ } else if let Some(w) = &self.window {
|
||
+ let _ = w.request_inner_size(winit::dpi::PhysicalSize::new(before[0], before[1]));
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+}
|
||
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
|
||
index 7a4d742df..9367bb994 100644
|
||
--- a/crates/horizon-client/src/game/mod_models.rs
|
||
+++ b/crates/horizon-client/src/game/mod_models.rs
|
||
@@ -11,7 +11,7 @@ use {
|
||
particles::{ParticleOptions, ParticleType},
|
||
},
|
||
horizon_core::random::RandomSource,
|
||
- horizon_mods::{CameraView, ModelRef, ParticleSpawn, PointLight},
|
||
+ horizon_mods::{CameraView, ModelRef, ParticleSpawn, PointLight, SpotLight},
|
||
horizon_world::registry::BlockStateId,
|
||
std::collections::HashMap,
|
||
};
|
||
@@ -42,6 +42,8 @@ pub(super) struct ModModels {
|
||
views: Vec<(CameraView, CameraView)>,
|
||
/// The mods' lights by (mod, id): a tick ago and now.
|
||
lights: HashMap<(usize, u32), (PointLight, PointLight)>,
|
||
+ /// The mods' spot lights by (mod, id): a tick ago and now.
|
||
+ spots: HashMap<(usize, u32), (SpotLight, SpotLight)>,
|
||
}
|
||
|
||
/// The overlays of the mods' models (entity.wgsl): glowing.
|
||
@@ -84,6 +86,13 @@ impl Game<'_> {
|
||
) -> Vec<horizon_client::gfx::HdLight> {
|
||
Vec::new()
|
||
}
|
||
+ pub(super) fn mod_spots(
|
||
+ &mut self,
|
||
+ _camera: glam::DVec3,
|
||
+ _alpha: f64,
|
||
+ ) -> Vec<horizon_client::gfx::HdSpot> {
|
||
+ Vec::new()
|
||
+ }
|
||
pub(super) fn mod_smoke(
|
||
&mut self,
|
||
_camera: glam::DVec3,
|
||
@@ -170,6 +179,13 @@ impl Game<'_> {
|
||
lights.insert(key, (before, *l));
|
||
}
|
||
self.mods.models.lights = lights;
|
||
+ let mut spots: HashMap<(usize, u32), (SpotLight, SpotLight)> = HashMap::new();
|
||
+ for (owner, l) in self.mods.mods.spot_lights() {
|
||
+ let key = (owner, l.id);
|
||
+ let before = self.mods.models.spots.get(&key).map_or(*l, |(_, now)| *now);
|
||
+ spots.insert(key, (before, *l));
|
||
+ }
|
||
+ self.mods.models.spots = spots;
|
||
let views: Vec<CameraView> = self.mods.mods.views().map(|(_, v)| *v).collect();
|
||
let old = std::mem::take(&mut self.mods.models.views);
|
||
self.mods.models.views = views
|
||
@@ -230,6 +246,64 @@ impl Game<'_> {
|
||
out.into_iter().take(512).map(|(_, l)| l).collect()
|
||
}
|
||
|
||
+ /// The mods' spot lights between their last two settings, relative to the camera; the nearest
|
||
+ /// (to the cone, not only the apex) first, at most `MAX_SPOTS`. Also tells the mods whether HD
|
||
+ /// draws them (so they keep their own beam models otherwise).
|
||
+ pub(super) fn mod_spots(
|
||
+ &mut self,
|
||
+ camera: DVec3,
|
||
+ alpha: f64,
|
||
+ ) -> Vec<horizon_client::gfx::HdSpot> {
|
||
+ let on = self.mod_hd().is_some() && horizon_client::gfx::hd_spots_enabled();
|
||
+ self.mods.mods.set_spot_active(on);
|
||
+ if !on {
|
||
+ return Vec::new();
|
||
+ }
|
||
+ let t = alpha.clamp(0.0, 1.0);
|
||
+ let lerp = |a: f32, b: f32| a + (b - a) * t as f32;
|
||
+ let mut out: Vec<(f32, horizon_client::gfx::HdSpot)> = self
|
||
+ .mods
|
||
+ .models
|
||
+ .spots
|
||
+ .values()
|
||
+ .filter_map(|(a, b)| {
|
||
+ let at = DVec3::new(a.x, a.y, a.z).lerp(DVec3::new(b.x, b.y, b.z), t) - camera;
|
||
+ let dir = glam::Vec3::new(lerp(a.dx, b.dx), lerp(a.dy, b.dy), lerp(a.dz, b.dz))
|
||
+ .try_normalize()?;
|
||
+ // Nearest point of the axis to the camera: far cones that point away do not count.
|
||
+ let along = (-at).dot(dir.as_dvec3()).clamp(0.0, f64::from(b.range));
|
||
+ let d = (at + dir.as_dvec3() * along).length() as f32;
|
||
+ if d > 1500.0 {
|
||
+ return None;
|
||
+ }
|
||
+ let inner = lerp(a.inner_angle, b.inner_angle).cos();
|
||
+ let outer = lerp(a.outer_angle, b.outer_angle).cos();
|
||
+ let spot = horizon_client::gfx::HdSpot {
|
||
+ pos: [
|
||
+ at.x as f32,
|
||
+ at.y as f32,
|
||
+ at.z as f32,
|
||
+ lerp(a.range, b.range),
|
||
+ ],
|
||
+ dir: [dir.x, dir.y, dir.z, outer],
|
||
+ color: [
|
||
+ b.color.0,
|
||
+ b.color.1,
|
||
+ b.color.2,
|
||
+ lerp(a.intensity, b.intensity),
|
||
+ ],
|
||
+ cone: [inner, lerp(a.beam, b.beam), 0.0, 0.0],
|
||
+ };
|
||
+ Some((d, spot))
|
||
+ })
|
||
+ .collect();
|
||
+ out.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||
+ out.into_iter()
|
||
+ .take(horizon_client::gfx::HD_MAX_SPOTS)
|
||
+ .map(|(_, s)| s)
|
||
+ .collect()
|
||
+ }
|
||
+
|
||
/// The mods' smoke segments for this frame, relative to the camera and aged to the clock the
|
||
/// mod gave (between its last two calls, as the lights); the nearest first when there are
|
||
/// too many. Also tells the mods whether HD draws it.
|
||
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
|
||
index effc9c23d..6623905f0 100644
|
||
--- a/crates/horizon-client/src/game/mods.rs
|
||
+++ b/crates/horizon-client/src/game/mods.rs
|
||
@@ -280,6 +280,18 @@ impl Game<'_> {
|
||
pub(super) fn sync_mods(&mut self) {}
|
||
pub(super) fn tick_mods(&mut self) {}
|
||
pub(super) fn mods_frame(&mut self, _elapsed: Duration) {}
|
||
+ pub(super) fn mods_film_requests(
|
||
+ &mut self,
|
||
+ ) -> (Option<super::film::FilmRequest>, Option<bool>) {
|
||
+ (None, None)
|
||
+ }
|
||
+ pub(super) fn mods_film_quality(&self) -> bool {
|
||
+ false
|
||
+ }
|
||
+ pub(super) fn mods_film_report(&mut self, _report: &super::film::FilmReport) {}
|
||
+ pub(super) fn mods_main_view_id(&self) -> Option<u32> {
|
||
+ None
|
||
+ }
|
||
pub(super) fn mod_command(&mut self, _command: &str) -> bool {
|
||
false
|
||
}
|
||
@@ -657,6 +669,51 @@ impl Game<'_> {
|
||
}
|
||
}
|
||
/// Once per frame: the frame hook, and dev mods' rebuilds.
|
||
+ /// AirDef fork: a mod's `render.capture-start` and `capture-stop`, taken.
|
||
+ pub(super) fn mods_film_requests(
|
||
+ &mut self,
|
||
+ ) -> (Option<super::film::FilmRequest>, Option<bool>) {
|
||
+ let start = self
|
||
+ .mods
|
||
+ .mods
|
||
+ .take_capture_request()
|
||
+ .map(|o| super::film::FilmRequest {
|
||
+ folder: o.folder,
|
||
+ name: o.name,
|
||
+ fps: o.fps,
|
||
+ width: o.width,
|
||
+ height: o.height,
|
||
+ crf: o.crf,
|
||
+ audio: o.audio,
|
||
+ warmup: o.warmup,
|
||
+ });
|
||
+ (start, self.mods.mods.take_capture_stop())
|
||
+ }
|
||
+ pub(super) fn mods_film_quality(&self) -> bool {
|
||
+ self.mods.mods.film_quality()
|
||
+ }
|
||
+ pub(super) fn mods_film_report(&mut self, r: &super::film::FilmReport) {
|
||
+ use super::film::Phase;
|
||
+ use horizon_mods::{CapturePhase, CaptureState};
|
||
+ self.mods.mods.set_capture_state(CaptureState {
|
||
+ phase: match r.phase {
|
||
+ Phase::Idle => CapturePhase::Idle,
|
||
+ Phase::Arming => CapturePhase::Arming,
|
||
+ Phase::Recording => CapturePhase::Recording,
|
||
+ Phase::Encoding => CapturePhase::Encoding,
|
||
+ Phase::Done => CapturePhase::Done,
|
||
+ Phase::Failed => CapturePhase::Failed,
|
||
+ },
|
||
+ frames: r.frames,
|
||
+ width: r.size[0],
|
||
+ height: r.size[1],
|
||
+ file: r.file.clone(),
|
||
+ message: r.message.clone(),
|
||
+ });
|
||
+ }
|
||
+ pub(super) fn mods_main_view_id(&self) -> Option<u32> {
|
||
+ self.mods.mods.main_view()
|
||
+ }
|
||
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
|
||
self.reload_dev_mods();
|
||
if !self.mods.mods.is_running() {
|
||
@@ -758,7 +815,10 @@ impl Game<'_> {
|
||
volume,
|
||
pitch,
|
||
} => {
|
||
+ // A film hears only the mods' sounds (`Audio::film_begin`).
|
||
+ self.audio.film_allow(true);
|
||
self.audio.play(&event, Some(at), volume, pitch);
|
||
+ self.audio.film_allow(false);
|
||
continue;
|
||
}
|
||
Output::PlaceRecipe { book_id, all } => {
|
||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||
index 1a4e68b88..9482cf0d2 100644
|
||
--- a/crates/horizon-client/src/gfx.rs
|
||
+++ b/crates/horizon-client/src/gfx.rs
|
||
@@ -84,7 +84,11 @@ mod mod_views;
|
||
mod native_material;
|
||
mod native_pack;
|
||
mod native_post;
|
||
-pub use hd::{HdLight, HdSettings, HdSmoke, smoke_enabled as hd_smoke_enabled};
|
||
+pub use hd::set_film_quality;
|
||
+pub use hd::{
|
||
+ HdLight, HdSettings, HdSmoke, HdSpot, MAX_SPOTS as HD_MAX_SPOTS,
|
||
+ smoke_enabled as hd_smoke_enabled, spots_enabled as hd_spots_enabled,
|
||
+};
|
||
pub use mod_views::ModView;
|
||
mod native_sky;
|
||
mod occlusion;
|
||
@@ -235,6 +239,8 @@ pub struct Frame<'a> {
|
||
/// The HD lighting (AirDef fork): on with its settings, and the mods' lights.
|
||
pub hd: Option<HdSettings>,
|
||
pub hd_lights: &'a [HdLight],
|
||
+ /// The mods' spot lights (searchlights), camera-relative, the nearest first.
|
||
+ pub hd_spots: &'a [HdSpot],
|
||
/// The mods' smoke segments for HD's volumetric smoke, camera-relative, and the mod's clock
|
||
/// in seconds.
|
||
pub hd_smoke: &'a [HdSmoke],
|
||
@@ -622,6 +628,12 @@ pub struct Renderer {
|
||
started: Instant,
|
||
/// Write the next presented frame to this PNG path (control socket).
|
||
pub screenshot: Option<PathBuf>,
|
||
+ /// AirDef fork (film capture, `game/film.rs`): the next frame is copied back for the
|
||
+ /// encoder, `capture_queue` holding the copies the GPU has not finished.
|
||
+ pub capture_next: bool,
|
||
+ capture_queue: std::collections::VecDeque<CaptureRead>,
|
||
+ /// AirDef fork: the shot changed (a mod's main view): TAA and the volumes start over.
|
||
+ pub cut: bool,
|
||
/// Outcome of the last screenshot: frame size, or the error.
|
||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||
/// Brightness option (lightmap gamma), 0..1.
|
||
@@ -2920,6 +2932,9 @@ impl Renderer {
|
||
uploaded_bytes: 0,
|
||
started: Instant::now(),
|
||
screenshot: None,
|
||
+ capture_next: false,
|
||
+ capture_queue: std::collections::VecDeque::new(),
|
||
+ cut: false,
|
||
screenshot_result: None,
|
||
gamma: 0.5,
|
||
fullbright: false,
|
||
@@ -3436,7 +3451,13 @@ impl Renderer {
|
||
¶ms,
|
||
self.mod_params.1,
|
||
);
|
||
- post.run_view(&self.device, encoder, &t.post_buffer, (&t.color, &t.depth), &t.fx);
|
||
+ post.run_view(
|
||
+ &self.device,
|
||
+ encoder,
|
||
+ &t.post_buffer,
|
||
+ (&t.color, &t.depth),
|
||
+ &t.fx,
|
||
+ );
|
||
t.post = true;
|
||
}
|
||
}
|
||
@@ -4099,6 +4120,12 @@ impl Renderer {
|
||
&mut self.taa_prev,
|
||
taa.then_some((unjittered, frame.camera, view_proj)),
|
||
);
|
||
+ // AirDef fork: a cut starts the temporal histories over (no ghost of the last shot).
|
||
+ let taa_prev = if std::mem::take(&mut self.cut) {
|
||
+ None
|
||
+ } else {
|
||
+ taa_prev
|
||
+ };
|
||
if frame.title {
|
||
self.panorama.prepare(
|
||
&self.queue,
|
||
@@ -5239,6 +5266,7 @@ impl Renderer {
|
||
frame.hd_lights,
|
||
frame.hd_smoke_clock,
|
||
);
|
||
+ hd.write_spots(frame.hd_spots);
|
||
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
|
||
}
|
||
// vanilla's translucent water leaves the colour while the HD water draws it.
|
||
@@ -6121,6 +6149,8 @@ impl Renderer {
|
||
(&view_target, self.config.format.remove_srgb_suffix()),
|
||
);
|
||
}
|
||
+ let capture_copy = std::mem::take(&mut self.capture_next)
|
||
+ .then(|| self.copy_frame(&mut encoder, &target_texture));
|
||
let readback = self.screenshot.take().and_then(|path| {
|
||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||
// the row stride too small for the copy and abort in validation.
|
||
@@ -6162,6 +6192,22 @@ impl Renderer {
|
||
if hd_on && let Some(hd) = &mut self.hd {
|
||
hd.timing_submitted(&self.queue);
|
||
}
|
||
+ if let Some(buffer) = capture_copy {
|
||
+ let ready = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||
+ let flag = ready.clone();
|
||
+ buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||
+ flag.store(r.is_ok(), std::sync::atomic::Ordering::Release);
|
||
+ });
|
||
+ self.capture_queue.push_back(CaptureRead {
|
||
+ buffer,
|
||
+ ready,
|
||
+ size: [self.config.width, self.config.height],
|
||
+ bgra: matches!(
|
||
+ self.config.format.remove_srgb_suffix(),
|
||
+ wgpu::TextureFormat::Bgra8Unorm
|
||
+ ),
|
||
+ });
|
||
+ }
|
||
if let Some((path, buffer)) = readback {
|
||
self.screenshot_result = Some(
|
||
self.write_screenshot(&path, &buffer)
|
||
@@ -6209,7 +6255,91 @@ impl Renderer {
|
||
Ok(true)
|
||
}
|
||
}
|
||
+/// A frame copied for a capture, waiting for the GPU (AirDef fork).
|
||
+struct CaptureRead {
|
||
+ buffer: wgpu::Buffer,
|
||
+ ready: Arc<std::sync::atomic::AtomicBool>,
|
||
+ size: [u32; 2],
|
||
+ bgra: bool,
|
||
+}
|
||
+/// A captured frame: tightly packed 8-bit pixels, BGRA or RGBA by `bgra`.
|
||
+pub struct CapturedFrame {
|
||
+ pub size: [u32; 2],
|
||
+ pub bgra: bool,
|
||
+ pub data: Vec<u8>,
|
||
+}
|
||
impl Renderer {
|
||
+ /// Whether frames can be captured (the surface is 8-bit and readable).
|
||
+ pub fn can_capture(&self) -> Result<()> {
|
||
+ if self.config.format.block_copy_size(None) != Some(4) {
|
||
+ return Err(format!(
|
||
+ "capture needs a 32-bit surface format, not {:?}",
|
||
+ self.config.format
|
||
+ )
|
||
+ .into());
|
||
+ }
|
||
+ if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
|
||
+ return Err("this surface does not support frame readback".into());
|
||
+ }
|
||
+ Ok(())
|
||
+ }
|
||
+ /// The size frames are drawn at.
|
||
+ pub fn frame_size(&self) -> [u32; 2] {
|
||
+ [self.config.width, self.config.height]
|
||
+ }
|
||
+ /// How many captured frames wait for the GPU or for the caller.
|
||
+ pub fn capture_pending(&self) -> usize {
|
||
+ self.capture_queue.len()
|
||
+ }
|
||
+ /// The copies the GPU has finished, oldest first, as frames; with `keep` the queue is
|
||
+ /// held to at most that many by waiting for the oldest (a bounded queue, never
|
||
+ /// dropping a frame).
|
||
+ pub fn capture_ready(&mut self, keep: usize) -> Result<Vec<CapturedFrame>> {
|
||
+ let _ = self.device.poll(wgpu::PollType::Poll);
|
||
+ let mut out = Vec::new();
|
||
+ while let Some(front) = self.capture_queue.front() {
|
||
+ let ready = front.ready.load(std::sync::atomic::Ordering::Acquire);
|
||
+ if !ready && self.capture_queue.len() <= keep {
|
||
+ break;
|
||
+ }
|
||
+ if !ready {
|
||
+ self.device
|
||
+ .poll(wgpu::PollType::wait_indefinitely())
|
||
+ .map_err(|e| e.to_string())?;
|
||
+ if !self.capture_queue[0]
|
||
+ .ready
|
||
+ .load(std::sync::atomic::Ordering::Acquire)
|
||
+ {
|
||
+ return Err("frame readback failed".into());
|
||
+ }
|
||
+ }
|
||
+ let read = self.capture_queue.pop_front().expect("front");
|
||
+ let [w, h] = read.size;
|
||
+ let row = (w * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) as usize;
|
||
+ let mut data = Vec::with_capacity((w * h * 4) as usize);
|
||
+ {
|
||
+ let mapped = read
|
||
+ .buffer
|
||
+ .get_mapped_range(..)
|
||
+ .map_err(|e| e.to_string())?;
|
||
+ for y in 0..h as usize {
|
||
+ data.extend_from_slice(&mapped[y * row..y * row + w as usize * 4]);
|
||
+ }
|
||
+ }
|
||
+ read.buffer.unmap();
|
||
+ out.push(CapturedFrame {
|
||
+ size: read.size,
|
||
+ bgra: read.bgra,
|
||
+ data,
|
||
+ });
|
||
+ }
|
||
+ Ok(out)
|
||
+ }
|
||
+ /// Drops the frames still on their way (a cancelled capture).
|
||
+ pub fn capture_discard(&mut self) {
|
||
+ let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
|
||
+ self.capture_queue.clear();
|
||
+ }
|
||
fn padded_row(&self) -> u32 {
|
||
(self.config.width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||
}
|
||
diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
||
index 39a239f4d..b2bdd3080 100644
|
||
--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
||
+++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
||
@@ -112,7 +112,9 @@ fn fs_cloud(in: FullOut) -> CloudOut {
|
||
let hi = min(max(ta, tb), min(4200.0, scene_len));
|
||
if hi > lo {
|
||
let dither = ign(in.position.xy);
|
||
- let n = clamp(i32(ceil((hi - lo) / 14.0)), 8, 26);
|
||
+ // Film quality (surface.z): half the step length, twice the cap.
|
||
+ let fine = frame.surface.z > 0.5;
|
||
+ let n = clamp(i32(ceil((hi - lo) / select(14.0, 7.0, fine))), select(8, 16, fine), select(26, 52, fine));
|
||
let step_len = (hi - lo) / f32(n);
|
||
let light = frame.cloud_dir.xyz;
|
||
let cos_theta = dot(dir, light);
|
||
diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl
|
||
index d035bbf0d..3fde6e22f 100644
|
||
--- a/crates/horizon-client/src/gfx/hd/fog.wgsl
|
||
+++ b/crates/horizon-client/src/gfx/hd/fog.wgsl
|
||
@@ -9,7 +9,16 @@
|
||
//SKY7
|
||
@group(0) @binding(8) var smoke_tex: texture_2d<f32>;
|
||
|
||
+//SPOT
|
||
+
|
||
+// The air's extinction for the spot lights' beams.
|
||
+fn spot_air(p: vec3<f32>) -> f32 {
|
||
+ return fog_sigma(p);
|
||
+}
|
||
+
|
||
const FOG_MAX: f32 = 192.0;
|
||
+// Beams reach past the fog's own range, also into the sky.
|
||
+const BEAM_FAR: f32 = 1100.0;
|
||
const FOG_STEPS: i32 = 18;
|
||
|
||
struct FogOut {
|
||
@@ -172,9 +181,11 @@ fn fs_fog(in: FullOut) -> FogOut {
|
||
var trans = 1.0;
|
||
var light = vec3<f32>(0.0);
|
||
var lit_sum = 0.0;
|
||
- for (var i = 0; i < FOG_STEPS; i++) {
|
||
- let u0 = (f32(i) + jitter) / f32(FOG_STEPS);
|
||
- let u1 = (f32(i) + 1.0 + jitter) / f32(FOG_STEPS);
|
||
+ // Film quality (surface.z) marches twice the steps.
|
||
+ let steps = select(FOG_STEPS, FOG_STEPS * 2, frame.surface.z > 0.5);
|
||
+ for (var i = 0; i < steps; i++) {
|
||
+ let u0 = (f32(i) + jitter) / f32(steps);
|
||
+ let u1 = (f32(i) + 1.0 + jitter) / f32(steps);
|
||
let f0 = 0.5 * u0 + 0.5 * u0 * u0;
|
||
let f1 = 0.5 * u1 + 0.5 * u1 * u1;
|
||
let t = len * f0;
|
||
@@ -195,6 +206,9 @@ fn fs_fog(in: FullOut) -> FogOut {
|
||
let sy = frame.sky_sun.y + 0.02;
|
||
let glow = 1.0 + 2.0 * linear_step(0.05, 1.0, exp(-300.0 * sy * sy));
|
||
var fog = vec4<f32>(light * glow, trans);
|
||
+ if frame.vfog.w > 0.5 {
|
||
+ fog = vec4<f32>(fog.rgb + spot_beams(dir, select(BEAM_FAR, length(p), d > 0.0), jitter), fog.a);
|
||
+ }
|
||
if frame.smoke.x > 0.5 {
|
||
let sm = march_smoke(dir, select(SMOKE_MAX, min(length(p), SMOKE_MAX), d > 0.0), jitter, haze, cos_v);
|
||
// The smoke is lit by the sun itself and stands in the haze: it is laid over the fog.
|
||
diff --git a/crates/horizon-client/src/gfx/hd/frame.rs b/crates/horizon-client/src/gfx/hd/frame.rs
|
||
index 415b12cdb..6b06732c3 100644
|
||
--- a/crates/horizon-client/src/gfx/hd/frame.rs
|
||
+++ b/crates/horizon-client/src/gfx/hd/frame.rs
|
||
@@ -30,6 +30,25 @@ pub struct HdLight {
|
||
pub color: [f32; 4],
|
||
}
|
||
|
||
+/// The most spot lights (searchlights) drawn a frame.
|
||
+pub const MAX_SPOTS: usize = 16;
|
||
+
|
||
+/// A spot light for this frame, relative to the camera: a cone from its apex along `dir`, out to
|
||
+/// `pos.w`, that lights surfaces and is drawn as a volume of scattered light in the air.
|
||
+#[repr(C)]
|
||
+#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||
+pub struct HdSpot {
|
||
+ /// xyz: camera-relative apex; w: range (blocks).
|
||
+ pub pos: [f32; 4],
|
||
+ /// xyz: unit axis; w: cosine of the outer half angle (past it: dark).
|
||
+ pub dir: [f32; 4],
|
||
+ /// rgb: colour; w: intensity (as a point light's).
|
||
+ pub color: [f32; 4],
|
||
+ /// x: cosine of the inner half angle (inside it: full); y: how visible the beam is in the
|
||
+ /// air (0..1); zw: unused.
|
||
+ pub cone: [f32; 4],
|
||
+}
|
||
+
|
||
/// The frame's HD settings.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct HdSettings {
|
||
@@ -88,7 +107,8 @@ pub(super) struct HdFrame {
|
||
pub(super) cloud_dir: [f32; 4],
|
||
/// rgb: the colour of that light at the clouds; w: its strength.
|
||
pub(super) cloud_sun: [f32; 4],
|
||
- /// x: 1 when the volumetric fog is drawn; y: the camera's height in the world; z: the morning mist.
|
||
+ /// x: 1 when the volumetric fog is drawn; y: the camera's height in the world; z: the morning mist;
|
||
+ /// w: the number of spot lights.
|
||
pub(super) vfog: [f32; 4],
|
||
/// x: how wet the surfaces are (0 dry .. 1 soaked; follows the rain, dries slowly); y: 1 when
|
||
/// the specular and the wet look are on.
|
||
@@ -101,6 +121,9 @@ pub(super) struct HdFrame {
|
||
/// Up to 16 lights that glow on the smoke: xyz position relative to the camera, w radius; then
|
||
/// rgb colour times intensity.
|
||
pub(super) smoke_lights: [[f32; 4]; 32],
|
||
+ /// Up to `MAX_SPOTS` spot lights, four vec4 each (`HdSpot`: pos+range, dir+cos outer,
|
||
+ /// colour+intensity, cone); their number is `vfog.w`.
|
||
+ pub(super) spots: [[f32; 4]; 64],
|
||
}
|
||
|
||
/// What the world around looks like this frame (from the renderer's globals).
|
||
diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl
|
||
index 870ae0d7d..a9e1fb7f4 100644
|
||
--- a/crates/horizon-client/src/gfx/hd/light.wgsl
|
||
+++ b/crates/horizon-client/src/gfx/hd/light.wgsl
|
||
@@ -16,8 +16,8 @@ struct Light {
|
||
@group(0) @binding(10) var lightmap: texture_2d<f32>;
|
||
@group(0) @binding(11) var linear: sampler;
|
||
// Per tile of 16x16 pixels: where its surface list and its halo list start in this array and
|
||
-// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means
|
||
-// every light.
|
||
+// how long they are (4 words), the mask of the spot lights whose cone can show in the tile (the
|
||
+// 5th word), then the lists of light indices. A length of 0xffffffff means every light.
|
||
@group(0) @binding(12) var<storage, read> tiles: array<u32>;
|
||
// Ambient occlusion at half resolution: x the occlusion (1 open), y the distance it was taken at.
|
||
@group(0) @binding(13) var ao_tex: texture_2d<f32>;
|
||
@@ -225,6 +225,13 @@ fn cloud_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
|
||
return sum / total;
|
||
}
|
||
|
||
+//SPOT
|
||
+
|
||
+// Without the volumetric fog the beams use the air's constant density.
|
||
+fn spot_air(p: vec3<f32>) -> f32 {
|
||
+ return frame.ambient.z;
|
||
+}
|
||
+
|
||
struct LightOut {
|
||
// The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an
|
||
// object in front of the water (the scene as drawn).
|
||
@@ -255,7 +262,7 @@ fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
|
||
let sun_on = frame.sun.w;
|
||
let atmosphere = frame.sky_sun.w > 0.5;
|
||
let count = u32(frame.params.x);
|
||
- let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
|
||
+ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 5u;
|
||
let surface_start = tiles[tile];
|
||
let surface_all = tiles[tile + 1u] == 0xffffffffu;
|
||
let surface_count = select(tiles[tile + 1u], count, surface_all);
|
||
@@ -311,6 +318,13 @@ fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
|
||
sky_tint = mix(vec3<f32>(1.0), hue * shape, reach);
|
||
}
|
||
var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
|
||
+ // The spot lights whose cone reaches this tile.
|
||
+ let spot_mask = tiles[tile + 4u];
|
||
+ for (var j = 0; j < i32(frame.vfog.w); j++) {
|
||
+ if ((spot_mask >> u32(j)) & 1u) != 0u {
|
||
+ light += spot_light(j, p, n) * ao_lights;
|
||
+ }
|
||
+ }
|
||
// The mods' lights.
|
||
for (var k = 0u; k < surface_count; k++) {
|
||
let l = lights[select(tiles[surface_start + k], k, surface_all)];
|
||
@@ -384,7 +398,7 @@ fn fs_light(in: FullOut) -> LightOut {
|
||
let sun = frame.sun.xyz;
|
||
let sun_on = frame.sun.w;
|
||
let count = u32(frame.params.x);
|
||
- let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
|
||
+ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 5u;
|
||
let halo_start = tiles[tile + 2u];
|
||
let halo_all = tiles[tile + 3u] == 0xffffffffu;
|
||
let halo_count = select(tiles[tile + 3u], count, halo_all);
|
||
@@ -473,6 +487,11 @@ fn fs_light(in: FullOut) -> LightOut {
|
||
let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08;
|
||
air_s += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
|
||
}
|
||
+ // The spot lights' beams: the fog march draws them when it runs; else a cone integration here.
|
||
+ if !volumetric && frame.vfog.w > 0.5 && d >= 0.0 {
|
||
+ let beam_len = select(1100.0, length(ps), d > 0.0);
|
||
+ air_s += spot_beams(view_s, beam_len, ign(in.position.xy));
|
||
+ }
|
||
for (var k = 0u; k < halo_count; k++) {
|
||
let l = lights[select(tiles[halo_start + k], k, halo_all)];
|
||
// Nearest point of the view ray to the light, and the glow along the ray in front of it.
|
||
diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs
|
||
index d26f1e513..8c56768f8 100644
|
||
--- a/crates/horizon-client/src/gfx/hd/lighting.rs
|
||
+++ b/crates/horizon-client/src/gfx/hd/lighting.rs
|
||
@@ -22,7 +22,7 @@
|
||
//! 0.2%).
|
||
//! - The sun is the light `frame::light_of` picks (moon at night), in `HdFrame.sun`.
|
||
//! - Bind groups are cached per (world views, lightmap) and per volumetric-history flip.
|
||
-use super::frame::HdLight;
|
||
+use super::frame::{HdLight, HdSpot};
|
||
use super::tiles::{TILE, tile_lists, tiles_buffer};
|
||
use super::{
|
||
DEPTH, FLOAT, HDR, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, STORAGE, UNFILTERED, UNIFORM,
|
||
@@ -157,6 +157,7 @@ impl Lighting {
|
||
queue: &wgpu::Queue,
|
||
view_proj: &glam::Mat4,
|
||
lights: &[HdLight],
|
||
+ spots: &[HdSpot],
|
||
size: [u32; 2],
|
||
) -> usize {
|
||
let n = lights.len().min(MAX_LIGHTS);
|
||
@@ -167,6 +168,7 @@ impl Lighting {
|
||
tile_lists(
|
||
view_proj,
|
||
&lights[..n],
|
||
+ spots,
|
||
size,
|
||
&mut self.tile_scratch,
|
||
&mut self.tile_words,
|
||
diff --git a/crates/horizon-client/src/gfx/hd/mod.rs b/crates/horizon-client/src/gfx/hd/mod.rs
|
||
index 1199e073e..ccc793e9a 100644
|
||
--- a/crates/horizon-client/src/gfx/hd/mod.rs
|
||
+++ b/crates/horizon-client/src/gfx/hd/mod.rs
|
||
@@ -89,13 +89,19 @@ use tonemap::Tonemap;
|
||
use volumes::HdVolumes;
|
||
use water::Water;
|
||
|
||
-pub use frame::{HdLight, HdScene, HdSettings, light_of, vanilla_cloud_tint};
|
||
+pub use frame::{HdLight, HdScene, HdSettings, HdSpot, MAX_SPOTS, light_of, vanilla_cloud_tint};
|
||
pub use gbuffer::{ALBEDO, LIGHT_LEVELS, NORMAL};
|
||
pub use materials::{Table as MaterialTable, table as material_table, wind_enabled, wind_seconds};
|
||
pub use shadows::{CASCADES, cascades};
|
||
pub use smoke::{HdSmoke, smoke_enabled};
|
||
-pub use taa::{jitter, jitter_count, scale_override, taa_enabled};
|
||
+pub use taa::{film_quality, jitter, jitter_count, scale_override, set_film_quality, taa_enabled};
|
||
pub use volumes::clouds_enabled;
|
||
+
|
||
+/// Spot lights (searchlights) are drawn unless `HORIZON_HD_SPOTS=0`.
|
||
+pub fn spots_enabled() -> bool {
|
||
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||
+ *ON.get_or_init(|| std::env::var("HORIZON_HD_SPOTS").map_or(true, |v| v != "0"))
|
||
+}
|
||
pub use water::{WATER_NORMAL, WATER_TINT};
|
||
|
||
use frame::{HdFrame, cloud_light, smooth, sun_dir_x};
|
||
@@ -162,9 +168,15 @@ pub(super) fn assemble(body: &str) -> String {
|
||
&sky::SKY_FUNCS.replace("SKYBIND", &binding.to_string()),
|
||
);
|
||
}
|
||
+ // `//SPOT`: the spot lights' cone functions (`spot.wgsl`), which call the shader's own
|
||
+ // `spot_air(pos)` (the air's extinction per block).
|
||
+ let body = body.replace("//SPOT", SPOT);
|
||
format!("{}{body}", frame::PRELUDE)
|
||
}
|
||
|
||
+/// The spot lights' cone intersection and beam march, shared by the fog and the lighting.
|
||
+const SPOT: &str = include_str!("spot.wgsl");
|
||
+
|
||
pub(super) fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
|
||
// Development: with `HORIZON_HD_SHADER_DIR` set, a shader's body is read from
|
||
// `<dir>/<label>.wgsl` (written there from the built-in one when missing), so it can be tuned
|
||
@@ -363,6 +375,8 @@ pub struct Hd {
|
||
sky: Sky,
|
||
/// The volumetric smoke's grid (see `smoke`).
|
||
smoke: Smoke,
|
||
+ /// This frame's spot lights (camera-relative, at most `MAX_SPOTS`; see `write_spots`).
|
||
+ spots: Vec<HdSpot>,
|
||
started: std::time::Instant,
|
||
/// The surfaces' wetness and when it was last updated (see `wetness`).
|
||
wet: (f32, std::time::Instant),
|
||
@@ -431,6 +445,7 @@ impl Hd {
|
||
fog_active: false,
|
||
sky: Sky::new(device, &frame),
|
||
smoke: Smoke::new(device, &frame),
|
||
+ spots: Vec::new(),
|
||
wet: (0.0, std::time::Instant::now()),
|
||
started: std::time::Instant::now(),
|
||
taa: Taa::new(device),
|
||
@@ -506,7 +521,7 @@ impl Hd {
|
||
) {
|
||
let n = self
|
||
.lighting
|
||
- .upload(queue, &scene.view_proj, lights, self.size);
|
||
+ .upload(queue, &scene.view_proj, lights, &self.spots, self.size);
|
||
self.frames = self.frames.wrapping_add(1);
|
||
let made = self.groups_made.load(Ordering::Relaxed) + self.volumes.groups_made;
|
||
self.groups_last_frame = made - self.groups_seen;
|
||
@@ -608,11 +623,18 @@ impl Hd {
|
||
if self.fog_active { 1.0 } else { 0.0 },
|
||
scene.camera_world[2],
|
||
mist,
|
||
- 0.0,
|
||
+ self.spots.len() as f32,
|
||
],
|
||
smoke: self.smoke.frame.smoke,
|
||
smoke_min: self.smoke.frame.min,
|
||
smoke_lights: self.smoke.frame.lights,
|
||
+ spots: {
|
||
+ let mut all = [[0.0; 4]; 64];
|
||
+ for (i, s) in self.spots.iter().enumerate() {
|
||
+ all[i * 4..i * 4 + 4].copy_from_slice(&[s.pos, s.dir, s.color, s.cone]);
|
||
+ }
|
||
+ all
|
||
+ },
|
||
surface: [
|
||
self.wetness(scene),
|
||
if materials::specular_enabled() {
|
||
@@ -620,7 +642,8 @@ impl Hd {
|
||
} else {
|
||
0.0
|
||
},
|
||
- 0.0,
|
||
+ // Film quality: more steps for the clouds and the fog.
|
||
+ if film_quality() { 1.0 } else { 0.0 },
|
||
0.0,
|
||
],
|
||
taa_params: [
|
||
@@ -670,6 +693,16 @@ impl Hd {
|
||
queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame));
|
||
}
|
||
|
||
+ /// This frame's spot lights (camera-relative, the nearest first, at most `MAX_SPOTS`); call
|
||
+ /// before `write`. They light surfaces in `fs_light`; the beam is drawn by the fog march, or
|
||
+ /// by `fs_light` when the volumetric fog is off.
|
||
+ pub fn write_spots(&mut self, spots: &[HdSpot]) {
|
||
+ self.spots.clear();
|
||
+ if spots_enabled() {
|
||
+ self.spots.extend(spots.iter().take(MAX_SPOTS));
|
||
+ }
|
||
+ }
|
||
+
|
||
/// This frame's smoke segments (camera-relative, ages in seconds) and the mod's clock in
|
||
/// seconds; call before `write`. The mod's lights glow on it.
|
||
pub fn write_smoke(
|
||
diff --git a/crates/horizon-client/src/gfx/hd/prelude.wgsl b/crates/horizon-client/src/gfx/hd/prelude.wgsl
|
||
index c62af3563..7e937a63b 100644
|
||
--- a/crates/horizon-client/src/gfx/hd/prelude.wgsl
|
||
+++ b/crates/horizon-client/src/gfx/hd/prelude.wgsl
|
||
@@ -28,6 +28,7 @@ struct HdFrame {
|
||
smoke: vec4<f32>,
|
||
smoke_min: vec4<f32>,
|
||
smoke_lights: array<vec4<f32>, 32>,
|
||
+ spots: array<vec4<f32>, 64>,
|
||
}
|
||
struct FullOut {
|
||
@builtin(position) position: vec4<f32>,
|
||
diff --git a/crates/horizon-client/src/gfx/hd/spot.wgsl b/crates/horizon-client/src/gfx/hd/spot.wgsl
|
||
new file mode 100644
|
||
index 000000000..726bfe312
|
||
--- /dev/null
|
||
+++ b/crates/horizon-client/src/gfx/hd/spot.wgsl
|
||
@@ -0,0 +1,116 @@
|
||
+// Spot lights (searchlights): a cone from an apex along an axis, out to a range, its edge fading
|
||
+// between the inner and the outer half angle. `frame.spots` holds four vec4 each (HdSpot: apex and
|
||
+// range, axis and cos(outer), colour and intensity, cos(inner) and beam strength); their number is
|
||
+// `frame.vfog.w`. The shader that includes this defines `spot_air(pos) -> f32`, the air's
|
||
+// extinction per block at a camera-relative point.
|
||
+
|
||
+const SPOT_BEAM_GAIN: f32 = 260.0;
|
||
+const SPOT_BEAM_STEPS: i32 = 8;
|
||
+// Even clear air shows a faint beam: extinction per block, at least.
|
||
+const SPOT_AIR_FLOOR: f32 = 0.0009;
|
||
+
|
||
+// The light of spot j on a surface point p with the normal n: inside the cone, down the range,
|
||
+// by the surface's angle to the light.
|
||
+fn spot_light(j: i32, p: vec3<f32>, n: vec3<f32>) -> vec3<f32> {
|
||
+ let a = frame.spots[j * 4];
|
||
+ let b = frame.spots[j * 4 + 1];
|
||
+ let c = frame.spots[j * 4 + 2];
|
||
+ let k = frame.spots[j * 4 + 3];
|
||
+ let to = a.xyz - p;
|
||
+ let dist = length(to);
|
||
+ if dist >= a.w || dist < 0.01 { return vec3<f32>(0.0); }
|
||
+ let l = to / dist;
|
||
+ let edge = smoothstep(b.w, k.x, dot(-l, b.xyz));
|
||
+ if edge <= 0.0 { return vec3<f32>(0.0); }
|
||
+ // The range is where the beam is free: the light stops there over a few blocks.
|
||
+ let window = clamp((a.w - dist) / 3.0, 0.0, 1.0);
|
||
+ let facing = max(dot(n, l) * 0.9 + 0.1, 0.0);
|
||
+ return c.rgb * (c.w / (dist * dist + 1.0)) * edge * window * facing;
|
||
+}
|
||
+
|
||
+// The part of the ray from the camera along v, within [0, len], that is inside spot j's cone and
|
||
+// short of its range: x the entry, y the exit (y <= x: none).
|
||
+fn spot_span(j: i32, v: vec3<f32>, len: f32) -> vec2<f32> {
|
||
+ let a = frame.spots[j * 4];
|
||
+ let b = frame.spots[j * 4 + 1];
|
||
+ let w = -a.xyz;
|
||
+ let d = b.xyz;
|
||
+ let c2 = b.w * b.w;
|
||
+ let vd = dot(v, d);
|
||
+ let wd = dot(w, d);
|
||
+ var lo = 0.0;
|
||
+ var hi = len;
|
||
+ // Along the axis between the apex and the range.
|
||
+ if abs(vd) > 1.0e-5 {
|
||
+ let ta = (0.0 - wd) / vd;
|
||
+ let tb = (a.w - wd) / vd;
|
||
+ lo = max(lo, min(ta, tb));
|
||
+ hi = min(hi, max(ta, tb));
|
||
+ } else if wd < 0.0 || wd > a.w {
|
||
+ return vec2<f32>(1.0, 0.0);
|
||
+ }
|
||
+ // Inside the cone: ((w + t v) . d)^2 >= cos^2 |w + t v|^2, on the apex's forward side.
|
||
+ var qa = vd * vd - c2;
|
||
+ if abs(qa) < 1.0e-6 { qa = select(-1.0e-6, 1.0e-6, qa >= 0.0); }
|
||
+ let qb = vd * wd - c2 * dot(v, w);
|
||
+ let qc = wd * wd - c2 * dot(w, w);
|
||
+ let disc = qb * qb - qa * qc;
|
||
+ if qa < 0.0 {
|
||
+ if disc <= 0.0 { return vec2<f32>(1.0, 0.0); }
|
||
+ let sq = sqrt(disc);
|
||
+ lo = max(lo, (0.0 - qb + sq) / qa);
|
||
+ hi = min(hi, (0.0 - qb - sq) / qa);
|
||
+ } else if disc > 0.0 {
|
||
+ // The ray points inside the cone's angle: it is inside from the far root on (looking along
|
||
+ // the axis) or up to the near one (looking against it).
|
||
+ let sq = sqrt(disc);
|
||
+ if vd > 0.0 {
|
||
+ lo = max(lo, (0.0 - qb + sq) / qa);
|
||
+ } else {
|
||
+ hi = min(hi, (0.0 - qb - sq) / qa);
|
||
+ }
|
||
+ }
|
||
+ return vec2<f32>(lo, hi);
|
||
+}
|
||
+
|
||
+// The light the spots' beams scatter towards the camera along the ray v out to len (the depth of
|
||
+// the surface, or the far end for sky): the air in the cone scatters the spot's light (more where
|
||
+// the air is thick, a faint beam in clear air), softest at the cone's edge, thinning with
|
||
+// distance from the lens and dimmed by the air in between. Translucent: it only adds.
|
||
+fn spot_beams(v: vec3<f32>, len: f32, jitter: f32) -> vec3<f32> {
|
||
+ var sum = vec3<f32>(0.0);
|
||
+ let count = i32(frame.vfog.w);
|
||
+ for (var j = 0; j < count; j++) {
|
||
+ let k = frame.spots[j * 4 + 3];
|
||
+ if k.y <= 0.0 { continue; }
|
||
+ let span = spot_span(j, v, len);
|
||
+ if span.y <= span.x { continue; }
|
||
+ let a = frame.spots[j * 4];
|
||
+ let b = frame.spots[j * 4 + 1];
|
||
+ let c = frame.spots[j * 4 + 2];
|
||
+ let ds = (span.y - span.x) / f32(SPOT_BEAM_STEPS);
|
||
+ // Looking into the lamp the beam scatters forward: brighter.
|
||
+ let cos_s = dot(b.xyz, -v);
|
||
+ let g = 0.55;
|
||
+ let phase = 0.4 + 0.25 * min((1.0 - g * g) / pow(1.0 + g * g - 2.0 * g * cos_s, 1.5), 8.0);
|
||
+ // The air between the camera and the beam's first point.
|
||
+ let first = v * span.x;
|
||
+ let through0 = exp(-spot_air(first) * span.x);
|
||
+ var od = 0.0;
|
||
+ var acc = vec3<f32>(0.0);
|
||
+ for (var i = 0; i < SPOT_BEAM_STEPS; i++) {
|
||
+ let x = v * (span.x + (f32(i) + jitter) * ds);
|
||
+ let q = x - a.xyz;
|
||
+ let dist = max(length(q), 0.5);
|
||
+ let edge = smoothstep(b.w, k.x, dot(q, b.xyz) / dist);
|
||
+ let sigma = max(spot_air(x), SPOT_AIR_FLOOR);
|
||
+ od += sigma * ds;
|
||
+ // A bright core, a soft periphery; fainter far from the lens, dimmed by the air both ways.
|
||
+ let core = edge * (0.4 + 0.6 * edge);
|
||
+ let radiance = core / (1.0 + dist / 70.0) * exp(-sigma * dist * 0.5);
|
||
+ acc += radiance * sigma * exp(-od);
|
||
+ }
|
||
+ sum += c.rgb * (acc * ds * phase * k.y * SPOT_BEAM_GAIN) * through0;
|
||
+ }
|
||
+ return sum;
|
||
+}
|
||
diff --git a/crates/horizon-client/src/gfx/hd/taa.rs b/crates/horizon-client/src/gfx/hd/taa.rs
|
||
index c21c92482..2b758cec6 100644
|
||
--- a/crates/horizon-client/src/gfx/hd/taa.rs
|
||
+++ b/crates/horizon-client/src/gfx/hd/taa.rs
|
||
@@ -47,6 +47,10 @@ pub(super) const UPSCALE_HISTORY: f32 = 0.92;
|
||
/// The world's size relative to the output (`HORIZON_HD_SCALE`, per axis, 0.25..=1) while HD is on, if
|
||
/// set: it replaces the resolution scale of Horizon's settings for HD. 1 renders at full size.
|
||
pub fn scale_override() -> Option<f32> {
|
||
+ // Film quality renders the world at the window's full size.
|
||
+ if film_quality() {
|
||
+ return Some(1.0);
|
||
+ }
|
||
static SCALE: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
|
||
*SCALE.get_or_init(|| {
|
||
let v: f32 = std::env::var("HORIZON_HD_SCALE").ok()?.parse().ok()?;
|
||
@@ -54,6 +58,19 @@ pub fn scale_override() -> Option<f32> {
|
||
})
|
||
}
|
||
|
||
+static FILM: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||
+
|
||
+/// Film quality (`render.set-film-quality`, and while a capture runs): the world at the window's
|
||
+/// full size, the volumetric clouds and fog marched with more steps. Off restores the settings.
|
||
+pub fn set_film_quality(on: bool) {
|
||
+ FILM.store(on, std::sync::atomic::Ordering::Relaxed);
|
||
+}
|
||
+
|
||
+/// Whether film quality is on.
|
||
+pub fn film_quality() -> bool {
|
||
+ FILM.load(std::sync::atomic::Ordering::Relaxed)
|
||
+}
|
||
+
|
||
/// How many jitter positions the sequence cycles through for an output `ratio` times the world's
|
||
/// size per axis: 16 without upscaling, else 8 per output pixel's share of a world pixel
|
||
/// (ratio squared), so every output pixel gets samples all over the world pixel it lies in.
|
||
diff --git a/crates/horizon-client/src/gfx/hd/tiles.rs b/crates/horizon-client/src/gfx/hd/tiles.rs
|
||
index 0b069e0be..3fdb0a1ee 100644
|
||
--- a/crates/horizon-client/src/gfx/hd/tiles.rs
|
||
+++ b/crates/horizon-client/src/gfx/hd/tiles.rs
|
||
@@ -1,15 +1,19 @@
|
||
//! Light tiles: for every 16x16 pixel tile of the world target, the short lists of the mods' point
|
||
//! lights that can reach it, so `fs_light` loops over a few lights instead of all of them.
|
||
//!
|
||
-//! Contract: a storage buffer of `u32`, per tile 4 words (surface list start, surface length, halo
|
||
-//! list start, halo length), then the index lists. A length of `EVERY_LIGHT` (0xffffffff) means
|
||
+//! Contract: a storage buffer of `u32`, per tile `TILE_WORDS` (5) words (surface list start, surface
|
||
+//! length, halo list start, halo length, spot mask), then the index lists. The spot mask has bit
|
||
+//! `j` set when spot light `j`'s cone can show in the tile (its bounds, apex and the square round its
|
||
+//! far cap, projected); the spots light surfaces and draw their beams only where it is set. A length of `EVERY_LIGHT` (0xffffffff) means
|
||
//! "all lights" (the list overflowed `TILE_INDICES`). Light positions are camera-relative and the
|
||
//! cover test uses the *jittered* view-projection the world was drawn with. Rebuilt on the CPU
|
||
//! every frame (an empty one too: the shader reads the counts, not `params.x`) and sized at
|
||
//! `resize`.
|
||
-use super::frame::HdLight;
|
||
+use super::frame::{HdLight, HdSpot};
|
||
use glam::Mat4;
|
||
|
||
+/// Words of a tile's header.
|
||
+pub(super) const TILE_WORDS: usize = 5;
|
||
/// The side (pixels) of a tile of the screen with its own short list of lights.
|
||
pub(super) const TILE: u32 = 16;
|
||
/// The indices all the tile lists may hold together; a tile past it loops over every light.
|
||
@@ -22,7 +26,7 @@ pub(super) fn tiles_buffer(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Buffe
|
||
let tiles = (size[0].div_ceil(TILE).max(1) * size[1].div_ceil(TILE).max(1)) as usize;
|
||
device.create_buffer(&wgpu::BufferDescriptor {
|
||
label: Some("HD light tiles"),
|
||
- size: ((tiles * 4 + TILE_INDICES) * std::mem::size_of::<u32>()) as u64,
|
||
+ size: ((tiles * TILE_WORDS + TILE_INDICES) * std::mem::size_of::<u32>()) as u64,
|
||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||
mapped_at_creation: false,
|
||
})
|
||
@@ -49,15 +53,25 @@ fn cover(view_proj: &Mat4, c: glam::Vec3, r: f32, size: [u32; 2]) -> Cover {
|
||
}
|
||
// A little more than the shader's radius, against rounding.
|
||
let r = r * 1.01 + 0.01;
|
||
- let (mut lo, mut hi) = (glam::Vec2::splat(f32::MAX), glam::Vec2::splat(f32::MIN));
|
||
- let mut front = 0;
|
||
- for corner in 0..8 {
|
||
- let o = glam::Vec3::new(
|
||
+ let corners: [glam::Vec3; 8] = std::array::from_fn(|corner| {
|
||
+ c + glam::Vec3::new(
|
||
if corner & 1 == 0 { -r } else { r },
|
||
if corner & 2 == 0 { -r } else { r },
|
||
if corner & 4 == 0 { -r } else { r },
|
||
- );
|
||
- let h = *view_proj * (c + o).extend(1.0);
|
||
+ )
|
||
+ });
|
||
+ cover_points(view_proj, &corners, size)
|
||
+}
|
||
+
|
||
+/// Where the convex hull of camera-relative points can show: the same rule as `cover`.
|
||
+fn cover_points(view_proj: &Mat4, points: &[glam::Vec3], size: [u32; 2]) -> Cover {
|
||
+ let (mut lo, mut hi) = (glam::Vec2::splat(f32::MAX), glam::Vec2::splat(f32::MIN));
|
||
+ let mut front = 0;
|
||
+ for p in points {
|
||
+ if !p.is_finite() {
|
||
+ return Cover::Everywhere;
|
||
+ }
|
||
+ let h = *view_proj * p.extend(1.0);
|
||
if h.w > 0.0 {
|
||
front += 1;
|
||
let ndc = glam::Vec2::new(h.x, h.y) / h.w;
|
||
@@ -71,11 +85,30 @@ fn cover(view_proj: &Mat4, c: glam::Vec3, r: f32, size: [u32; 2]) -> Cover {
|
||
}
|
||
match front {
|
||
0 => Cover::Nothing,
|
||
- 8 => Cover::Rect([lo.x - 2.0, lo.y - 2.0, hi.x + 2.0, hi.y + 2.0]),
|
||
+ n if n == points.len() => Cover::Rect([lo.x - 2.0, lo.y - 2.0, hi.x + 2.0, hi.y + 2.0]),
|
||
_ => Cover::Everywhere,
|
||
}
|
||
}
|
||
|
||
+/// The points whose hull holds a spot light's cone: the apex and the corners of the square that
|
||
+/// holds the circle of its far cap (a flat cap at `range`, radius `range * tan(outer)`).
|
||
+fn spot_hull(s: &HdSpot) -> [glam::Vec3; 5] {
|
||
+ let apex = glam::Vec3::new(s.pos[0], s.pos[1], s.pos[2]);
|
||
+ let dir = glam::Vec3::new(s.dir[0], s.dir[1], s.dir[2]).normalize_or(glam::Vec3::NEG_Z);
|
||
+ let cos = s.dir[3].clamp(0.05, 1.0);
|
||
+ let radius = s.pos[3] * (1.0 - cos * cos).sqrt() / cos * 1.02 + 0.5;
|
||
+ let u = dir.any_orthonormal_vector();
|
||
+ let v = dir.cross(u);
|
||
+ let centre = apex + dir * (s.pos[3] * 1.01 + 0.5);
|
||
+ [
|
||
+ apex,
|
||
+ centre + (u + v) * radius,
|
||
+ centre + (u - v) * radius,
|
||
+ centre + (-u + v) * radius,
|
||
+ centre + (-u - v) * radius,
|
||
+ ]
|
||
+}
|
||
+
|
||
/// Fills `words` with each 16x16 tile's two lists of the lights that can reach it: header
|
||
/// (surface start, surface length, halo start, halo length) per tile, then the indices; a
|
||
/// length of `EVERY_LIGHT` stands for all of `lights` (a list past the buffer's room). The
|
||
@@ -87,6 +120,7 @@ fn cover(view_proj: &Mat4, c: glam::Vec3, r: f32, size: [u32; 2]) -> Cover {
|
||
pub(super) fn tile_lists(
|
||
view_proj: &Mat4,
|
||
lights: &[HdLight],
|
||
+ spots: &[HdSpot],
|
||
size: [u32; 2],
|
||
scratch: &mut Vec<Vec<u16>>,
|
||
words: &mut Vec<u32>,
|
||
@@ -128,14 +162,38 @@ pub(super) fn tile_lists(
|
||
}
|
||
}
|
||
words.clear();
|
||
- words.resize(tiles * 4, 0);
|
||
+ words.resize(tiles * TILE_WORDS, 0);
|
||
+ // The spots' masks: a bit per spot in every tile its cone's bounds can reach.
|
||
+ for (j, s) in spots.iter().take(32).enumerate() {
|
||
+ let tile = TILE as f32;
|
||
+ let area = cover_points(view_proj, &spot_hull(s), size);
|
||
+ let from = |v: f32, n: usize| ((v / tile).floor().max(0.0) as usize).min(n - 1);
|
||
+ match area {
|
||
+ Cover::Nothing => {}
|
||
+ Cover::Everywhere => {
|
||
+ for t in 0..tiles {
|
||
+ words[t * TILE_WORDS + 4] |= 1 << j;
|
||
+ }
|
||
+ }
|
||
+ Cover::Rect([x0, y0, x1, y1]) => {
|
||
+ if x1 < 0.0 || y1 < 0.0 || x0 >= size[0] as f32 || y0 >= size[1] as f32 {
|
||
+ continue;
|
||
+ }
|
||
+ for y in from(y0, ty)..=from(y1, ty) {
|
||
+ for x in from(x0, tx)..=from(x1, tx) {
|
||
+ words[(y * tx + x) * TILE_WORDS + 4] |= 1 << j;
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ }
|
||
let mut room = TILE_INDICES;
|
||
for t in 0..tiles {
|
||
for list in 0..2 {
|
||
let ids = &mut scratch[t * 2 + list];
|
||
// Both the sphere and its mirror can name a light: once is enough.
|
||
ids.dedup();
|
||
- let at = t * 4 + list * 2;
|
||
+ let at = t * TILE_WORDS + list * 2;
|
||
if ids.len() > room {
|
||
words[at + 1] = EVERY_LIGHT;
|
||
} else {
|
||
@@ -168,9 +226,12 @@ mod tests {
|
||
light(1.0, 0.0, 0.0, 6.0),
|
||
];
|
||
let (mut scratch, mut words) = (Vec::new(), Vec::new());
|
||
- tile_lists(&proj, &lights, size, &mut scratch, &mut words);
|
||
+ tile_lists(&proj, &lights, &[], size, &mut scratch, &mut words);
|
||
let list = |tile: usize, which: usize| -> Vec<u32> {
|
||
- let (start, len) = (words[tile * 4 + which * 2], words[tile * 4 + which * 2 + 1]);
|
||
+ let (start, len) = (
|
||
+ words[tile * TILE_WORDS + which * 2],
|
||
+ words[tile * TILE_WORDS + which * 2 + 1],
|
||
+ );
|
||
words[start as usize..(start + len) as usize].to_vec()
|
||
};
|
||
// The centre tile sees the first light and the one at the camera, in both lists.
|
||
@@ -183,4 +244,32 @@ mod tests {
|
||
assert!(list(centre, 1).contains(&2));
|
||
assert!(!list(0, 0).contains(&2));
|
||
}
|
||
+
|
||
+ #[test]
|
||
+ fn spot_masks_follow_the_cone() {
|
||
+ let size = [320, 160];
|
||
+ let proj = Mat4::perspective_rh(1.0, 2.0, 0.05, 1000.0);
|
||
+ let spot = |pos: [f32; 3], dir: [f32; 3], range: f32, outer: f32| HdSpot {
|
||
+ pos: [pos[0], pos[1], pos[2], range],
|
||
+ dir: [dir[0], dir[1], dir[2], outer.cos()],
|
||
+ color: [1.0; 4],
|
||
+ cone: [outer.cos(), 1.0, 0.0, 0.0],
|
||
+ };
|
||
+ let spots = [
|
||
+ // Ahead, pointing away from the camera, narrow: only the middle shows it.
|
||
+ spot([0.0, 0.0, -10.0], [0.0, 0.0, -1.0], 200.0, 0.05),
|
||
+ // Far to the side pointing sideways, away.
|
||
+ spot([100.0, 0.0, -10.0], [1.0, 0.0, 0.0], 50.0, 0.05),
|
||
+ // Behind the camera, pointing away: nothing.
|
||
+ spot([0.0, 0.0, 10.0], [0.0, 0.0, 1.0], 80.0, 0.1),
|
||
+ ];
|
||
+ let (mut scratch, mut words) = (Vec::new(), Vec::new());
|
||
+ tile_lists(&proj, &[], &spots, size, &mut scratch, &mut words);
|
||
+ let mask = |tile: usize| words[tile * TILE_WORDS + 4];
|
||
+ let centre = 5 * 20 + 10;
|
||
+ assert_eq!(mask(centre) & 1, 1);
|
||
+ assert_eq!(mask(0) & 1, 0);
|
||
+ assert_eq!(mask(centre) & 2, 0);
|
||
+ assert_eq!(mask(centre) & 4, 0);
|
||
+ }
|
||
}
|
||
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
|
||
index bae0d2134..a0cc60c72 100644
|
||
--- a/crates/horizon-mod-sdk/wit/horizon.wit
|
||
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
|
||
@@ -657,7 +657,7 @@ interface render {
|
||
pitch: f32,
|
||
/// Vertical field of view, degrees (1 to 110).
|
||
fov: f32,
|
||
- /// The picture's size in pixels (16 to 1920 × 16 to 1080).
|
||
+ /// The picture's size in pixels (16 to 3840 × 16 to 2160).
|
||
width: u32,
|
||
height: u32,
|
||
}
|
||
@@ -750,6 +750,114 @@ interface render {
|
||
/// HD renderer draws them; when it does not (the effects preset, HD smoke
|
||
/// off), the mod draws its own smoke.
|
||
set-smoke: func(clock: f64, segments: list<smoke-segment>) -> bool;
|
||
+ /// A directional light with a visible beam (AirDef fork): a searchlight, a
|
||
+ /// headlight. The HD lighting lights the surfaces and flying things inside
|
||
+ /// its cone (smoothly from the inner to the outer angle, down to nothing at
|
||
+ /// `range`) and draws the beam as a translucent volume lit from inside: it
|
||
+ /// scatters in the air (more in fog and smoke, a faint artistic one in clear
|
||
+ /// air), the world hides it where it is in front, and the camera may stand
|
||
+ /// inside it. The light does not look for walls: send `range` as far as the
|
||
+ /// beam is free (a cast along the axis) so it stops where the land stops it.
|
||
+ record spot-light {
|
||
+ /// Stays the same while the light lives: the game moves it smoothly.
|
||
+ id: u32,
|
||
+ /// The apex, in the lens.
|
||
+ x: f64,
|
||
+ y: f64,
|
||
+ z: f64,
|
||
+ /// Where it points (any length but zero; it is normalised).
|
||
+ dx: f32,
|
||
+ dy: f32,
|
||
+ dz: f32,
|
||
+ /// Linear RGB, 0 to 1.
|
||
+ color: tuple<f32, f32, f32>,
|
||
+ /// Brightness: about what it lights one block away (0 to 100000).
|
||
+ intensity: f32,
|
||
+ /// Nothing past this many blocks (1 to 1024).
|
||
+ range: f32,
|
||
+ /// Half angle in radians where the light is at full strength (0.005 to 1.2).
|
||
+ inner-angle: f32,
|
||
+ /// Half angle in radians where it has faded to nothing (past the inner one, to 1.4).
|
||
+ outer-angle: f32,
|
||
+ /// How visible the beam is in the air, 0 (the light only lights things) to 1.
|
||
+ beam: f32,
|
||
+ }
|
||
+ /// Replaces the mod's spot lights: up to 32 (the nearest 16 are drawn),
|
||
+ /// interpolated per id between the last two calls. Returns whether the HD
|
||
+ /// renderer draws them with their beams; when it does not (the effects
|
||
+ /// preset, HD off), the mod draws its own beam if it wants one.
|
||
+ set-spot-lights: func(lights: list<spot-light>) -> bool;
|
||
+ /// What `capture-start` records (AirDef fork, API level 17).
|
||
+ record capture-options {
|
||
+ /// A folder under the game's home (letters, digits, `-`, `_` and `/`; no
|
||
+ /// `..`); made if missing. The result lands in it.
|
||
+ folder: string,
|
||
+ /// The file name without its extension (letters, digits, `-`, `_`, `.`).
|
||
+ name: string,
|
||
+ /// Frames a second of the picture (1 to 120).
|
||
+ fps: u32,
|
||
+ /// The picture's size in pixels; 0 keeps the window's. An offscreen game
|
||
+ /// is resized to it, a window asked to be (what it gets is in the state).
|
||
+ width: u32,
|
||
+ height: u32,
|
||
+ /// x264 quality, 0 (lossless) to 51; 14 is visually clean.
|
||
+ crf: u32,
|
||
+ /// Mix the sounds the mod plays (`play-sound`) into the file's audio
|
||
+ /// track, heard from the main view's camera at the capture's own time.
|
||
+ audio: bool,
|
||
+ /// Frames drawn and thrown away after a cut (a new `set-main-view` id) so
|
||
+ /// the anti-aliasing and the volumes settle in the new shot (0 to 32).
|
||
+ warmup: u32,
|
||
+ }
|
||
+ /// Where a capture is.
|
||
+ enum capture-phase {
|
||
+ /// Nothing running (or never started).
|
||
+ idle,
|
||
+ /// Asked; waiting for the picture to reach the size.
|
||
+ arming,
|
||
+ /// Frames are being rendered and written.
|
||
+ recording,
|
||
+ /// The frames are written; the encoder is closing the file.
|
||
+ encoding,
|
||
+ /// Finished: `file` is the result.
|
||
+ done,
|
||
+ /// Stopped by an error or cancelled: `message` says why.
|
||
+ failed,
|
||
+ }
|
||
+ record capture-state {
|
||
+ phase: capture-phase,
|
||
+ /// Frames written so far.
|
||
+ frames: u32,
|
||
+ /// The size of the frames, once known.
|
||
+ width: u32,
|
||
+ height: u32,
|
||
+ /// The result: the MP4, or the folder of the PNG sequence when no
|
||
+ /// `ffmpeg` was found. Empty until `done`.
|
||
+ file: string,
|
||
+ /// An error, or a note about the result (no encoder, no audio).
|
||
+ message: string,
|
||
+ }
|
||
+ /// Records the screen frame by frame (AirDef fork), at a fixed time step
|
||
+ /// whatever the speed of the machine: while it records, the game clock
|
||
+ /// advances exactly `1 / fps` seconds a frame (ticks, interpolation, the
|
||
+ /// mod's `tick`s and the sounds with it), so a slow frame is a longer wait
|
||
+ /// and not a skipped one. The frames are read back from the GPU and
|
||
+ /// encoded to H.264 MP4 by `ffmpeg` (found on the `PATH`, or named by
|
||
+ /// `HORIZON_FFMPEG`) through a bounded queue, never held in memory; with no
|
||
+ /// `ffmpeg` they are written as a PNG sequence. Returns an error for a
|
||
+ /// capture already running, a bad name, or an invalid size. Only the
|
||
+ /// game's own files are written, under its home.
|
||
+ capture-start: func(options: capture-options) -> result<_, string>;
|
||
+ /// Ends the capture: finishes the file, or (`cancel`) throws it away.
|
||
+ capture-stop: func(cancel: bool);
|
||
+ /// The capture's state; `done` and `failed` stay until the next start.
|
||
+ capture-status: func() -> capture-state;
|
||
+ /// Asks for the best picture the renderer can give (AirDef fork, level 17):
|
||
+ /// the HD world at the full size of the window (no resolution scale, so no
|
||
+ /// upscaling), and the volumetric clouds and fog marched with more steps.
|
||
+ /// Meant for replays and films; false restores the player's settings. A
|
||
+ /// capture turns it on for its duration.
|
||
+ set-film-quality: func(on: bool);
|
||
}
|
||
|
||
/// What the game calls. Only the hooks `init` asks for are ever called.
|
||
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
|
||
index e578de688..ce4555e32 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 = 15;
|
||
+pub const LEVEL: u32 = 17;
|
||
|
||
/// 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); 69] = [
|
||
+pub const FUNCTIONS: [(&str, u32); 74] = [
|
||
("log.write", 0),
|
||
("game.get-player", 0),
|
||
("game.get-clock", 0),
|
||
@@ -88,6 +88,13 @@ pub const FUNCTIONS: [(&str, u32); 69] = [
|
||
("render.set-smoke", 14),
|
||
// AirDef fork: a mod camera as the screen's own view.
|
||
("render.set-main-view", 15),
|
||
+ // AirDef fork: directional lights with a volumetric beam.
|
||
+ ("render.set-spot-lights", 16),
|
||
+ // AirDef fork: frame-by-frame capture to MP4 and the film quality.
|
||
+ ("render.capture-start", 17),
|
||
+ ("render.capture-stop", 17),
|
||
+ ("render.capture-status", 17),
|
||
+ ("render.set-film-quality", 17),
|
||
];
|
||
|
||
/// 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 74da34673..b77e32c43 100644
|
||
--- a/crates/horizon-mods/src/runtime.rs
|
||
+++ b/crates/horizon-mods/src/runtime.rs
|
||
@@ -79,12 +79,13 @@ use render_bindings::horizon::mods::input;
|
||
pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, ItemUse};
|
||
/// What capability `render` asks the game to draw (WIT `interface render`).
|
||
pub use render_bindings::horizon::mods::render::{
|
||
- CameraView, Changes as WorldChanges, Corner as RenderCorner, FarTerrain as ModFarTerrain,
|
||
- FillShape, FrameRate, ItemShape, Lighting as ModLighting, Minimap as ModMinimap, ModelInstance,
|
||
- ModelInstanceId, ParticleSpawn, Point as RenderPoint, PointLight, Policy as WorldPolicy,
|
||
- QuadShape, Ray as WorldRay, RayAnswer as WorldRayAnswer, Rect as RenderRect,
|
||
- Section as WorldSection, SectionFill, Shape, SmokeSegment, SpriteShape, TextShape,
|
||
- Verdict as WorldVerdict, ViewShape, Zoom as ModZoom,
|
||
+ CameraView, CaptureOptions, CapturePhase, CaptureState, Changes as WorldChanges,
|
||
+ Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape,
|
||
+ Lighting as ModLighting, Minimap as ModMinimap, ModelInstance, ModelInstanceId, ParticleSpawn,
|
||
+ Point as RenderPoint, PointLight, Policy as WorldPolicy, QuadShape, Ray as WorldRay,
|
||
+ RayAnswer as WorldRayAnswer, Rect as RenderRect, Section as WorldSection, SectionFill, Shape,
|
||
+ SmokeSegment, SpotLight, SpriteShape, TextShape, Verdict as WorldVerdict, ViewShape,
|
||
+ Zoom as ModZoom,
|
||
};
|
||
use render_bindings::{RenderHost, horizon::mods::render};
|
||
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
|
||
@@ -306,6 +307,16 @@ pub struct ModelTable {
|
||
resolve: Option<ModelResolver>,
|
||
/// The renderer draws the mods' smoke (`render.set-smoke` says so to the mod).
|
||
smoke_active: bool,
|
||
+ /// The renderer draws the mods' spot lights and their beams (`render.set-spot-lights`).
|
||
+ spot_active: bool,
|
||
+ /// `render.capture-start`: the request the game has not taken yet.
|
||
+ capture_request: Option<CaptureOptions>,
|
||
+ /// `render.capture-stop`: asked, with whether to throw the file away.
|
||
+ capture_stop: Option<bool>,
|
||
+ /// The game's note of the capture (`render.capture-status`).
|
||
+ capture_state: Option<CaptureState>,
|
||
+ /// `render.set-film-quality`.
|
||
+ film_quality: bool,
|
||
}
|
||
|
||
type ModelSlot = Arc<Mutex<ModelTable>>;
|
||
@@ -510,6 +521,8 @@ struct State {
|
||
main_view: Option<u32>,
|
||
/// `render.set-lights` and `render.set-lighting` (AirDef fork).
|
||
lights: Vec<PointLight>,
|
||
+ /// `render.set-spot-lights` (AirDef fork).
|
||
+ spots: Vec<SpotLight>,
|
||
lighting: Option<ModLighting>,
|
||
/// `render.set-time-override` (AirDef fork).
|
||
time_override: Option<u64>,
|
||
@@ -758,6 +771,7 @@ impl Mods {
|
||
views: Vec::new(),
|
||
main_view: None,
|
||
lights: Vec::new(),
|
||
+ spots: Vec::new(),
|
||
lighting: None,
|
||
time_override: None,
|
||
smoke: Vec::new(),
|
||
@@ -1341,6 +1355,38 @@ impl Mods {
|
||
.flat_map(move |r| r.store.data().lights.iter().map(move |l| (i, l)))
|
||
})
|
||
}
|
||
+ /// Every running mod's spot lights (AirDef fork), with the mod's index.
|
||
+ pub fn spot_lights(&self) -> impl Iterator<Item = (usize, &SpotLight)> {
|
||
+ self.entries.iter().enumerate().flat_map(|(i, e)| {
|
||
+ e.run
|
||
+ .iter()
|
||
+ .flat_map(move |r| r.store.data().spots.iter().map(move |l| (i, l)))
|
||
+ })
|
||
+ }
|
||
+ /// Tells the mods whether the renderer draws their spot lights (the answer of `render.set-spot-lights`).
|
||
+ pub fn set_spot_active(&mut self, on: bool) {
|
||
+ if let Ok(mut t) = self.model_table.lock() {
|
||
+ t.spot_active = on;
|
||
+ }
|
||
+ }
|
||
+ /// The capture a mod asked to start since the last call (`render.capture-start`).
|
||
+ pub fn take_capture_request(&self) -> Option<CaptureOptions> {
|
||
+ self.model_table.lock().ok()?.capture_request.take()
|
||
+ }
|
||
+ /// A capture stop asked since the last call; true to cancel (throw the file away).
|
||
+ pub fn take_capture_stop(&self) -> Option<bool> {
|
||
+ self.model_table.lock().ok()?.capture_stop.take()
|
||
+ }
|
||
+ /// The game's note of where the capture is (`render.capture-status`).
|
||
+ pub fn set_capture_state(&self, state: CaptureState) {
|
||
+ if let Ok(mut t) = self.model_table.lock() {
|
||
+ t.capture_state = Some(state);
|
||
+ }
|
||
+ }
|
||
+ /// Whether a mod asked for the best picture (`render.set-film-quality`).
|
||
+ pub fn film_quality(&self) -> bool {
|
||
+ self.model_table.lock().is_ok_and(|t| t.film_quality)
|
||
+ }
|
||
/// The first running mod's lighting, if it set one.
|
||
pub fn lighting(&self) -> Option<ModLighting> {
|
||
self.entries
|
||
@@ -2233,8 +2279,8 @@ impl render::Host for State {
|
||
} else {
|
||
70.0
|
||
};
|
||
- v.width = v.width.clamp(16, 1920);
|
||
- v.height = v.height.clamp(16, 1080);
|
||
+ v.width = v.width.clamp(16, 3840);
|
||
+ v.height = v.height.clamp(16, 2160);
|
||
v
|
||
})
|
||
.collect();
|
||
@@ -2271,6 +2317,152 @@ impl render::Host for State {
|
||
})
|
||
.collect();
|
||
}
|
||
+ fn set_spot_lights(&mut self, lights: Vec<SpotLight>) -> bool {
|
||
+ let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
|
||
+ let num = |v: f32, lo: f32, hi: f32, default: f32| {
|
||
+ if v.is_finite() {
|
||
+ v.clamp(lo, hi)
|
||
+ } else {
|
||
+ default
|
||
+ }
|
||
+ };
|
||
+ self.spots = lights
|
||
+ .into_iter()
|
||
+ .filter(|l| {
|
||
+ finite(l.x)
|
||
+ && finite(l.y)
|
||
+ && finite(l.z)
|
||
+ && [l.dx, l.dy, l.dz].into_iter().all(f32::is_finite)
|
||
+ && (l.dx * l.dx + l.dy * l.dy + l.dz * l.dz) > 1e-12
|
||
+ })
|
||
+ .take(32)
|
||
+ .map(|mut l| {
|
||
+ let len = (l.dx * l.dx + l.dy * l.dy + l.dz * l.dz).sqrt();
|
||
+ (l.dx, l.dy, l.dz) = (l.dx / len, l.dy / len, l.dz / len);
|
||
+ l.color = (
|
||
+ num(l.color.0, 0.0, 1.0, 0.0),
|
||
+ num(l.color.1, 0.0, 1.0, 0.0),
|
||
+ num(l.color.2, 0.0, 1.0, 0.0),
|
||
+ );
|
||
+ l.intensity = num(l.intensity, 0.0, 100_000.0, 0.0);
|
||
+ l.range = num(l.range, 1.0, 1024.0, 1.0);
|
||
+ l.inner_angle = num(l.inner_angle, 0.005, 1.2, 0.05);
|
||
+ l.outer_angle = num(
|
||
+ l.outer_angle,
|
||
+ l.inner_angle + 0.002,
|
||
+ 1.4,
|
||
+ l.inner_angle + 0.05,
|
||
+ );
|
||
+ l.beam = num(l.beam, 0.0, 1.0, 0.0);
|
||
+ l
|
||
+ })
|
||
+ .collect();
|
||
+ self.model_table.lock().is_ok_and(|t| t.spot_active)
|
||
+ }
|
||
+ fn capture_start(&mut self, mut options: CaptureOptions) -> Result<(), String> {
|
||
+ let folder_ok = |f: &str| {
|
||
+ !f.is_empty()
|
||
+ && f.len() <= 120
|
||
+ && !f.starts_with('/')
|
||
+ && !f.ends_with('/')
|
||
+ && f.split('/').all(|part| {
|
||
+ !part.is_empty()
|
||
+ && part != "."
|
||
+ && part != ".."
|
||
+ && part
|
||
+ .chars()
|
||
+ .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||
+ })
|
||
+ };
|
||
+ let name_ok = |n: &str| {
|
||
+ !n.is_empty()
|
||
+ && n.len() <= 80
|
||
+ && !n.starts_with('.')
|
||
+ && n.chars()
|
||
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||
+ };
|
||
+ if !folder_ok(&options.folder) {
|
||
+ return Err("capture folder: letters, digits, - _ and / under the game's home".into());
|
||
+ }
|
||
+ if !name_ok(&options.name) {
|
||
+ return Err("capture name: letters, digits, - _ and . (no leading dot)".into());
|
||
+ }
|
||
+ if (options.width == 0) != (options.height == 0)
|
||
+ || options.width > 7680
|
||
+ || options.height > 4320
|
||
+ {
|
||
+ return Err("capture size: both 0 (the window's) or both up to 7680 x 4320".into());
|
||
+ }
|
||
+ if options.width != 0 && (options.width < 16 || options.height < 16) {
|
||
+ return Err("capture size: at least 16 x 16".into());
|
||
+ }
|
||
+ // H.264 in 4:2:0 needs even sides.
|
||
+ options.width &= !1;
|
||
+ options.height &= !1;
|
||
+ options.fps = options.fps.clamp(1, 120);
|
||
+ options.crf = options.crf.min(51);
|
||
+ options.warmup = options.warmup.min(32);
|
||
+ let Ok(mut t) = self.model_table.lock() else {
|
||
+ return Err("the game is busy".into());
|
||
+ };
|
||
+ let running = t.capture_request.is_some()
|
||
+ || t.capture_state.as_ref().is_some_and(|s| {
|
||
+ matches!(
|
||
+ s.phase,
|
||
+ CapturePhase::Arming | CapturePhase::Recording | CapturePhase::Encoding
|
||
+ )
|
||
+ });
|
||
+ if running {
|
||
+ return Err("a capture is already running".into());
|
||
+ }
|
||
+ t.capture_request = Some(options);
|
||
+ // Until the game answers, the request is the state.
|
||
+ t.capture_state = Some(CaptureState {
|
||
+ phase: CapturePhase::Arming,
|
||
+ frames: 0,
|
||
+ width: 0,
|
||
+ height: 0,
|
||
+ file: String::new(),
|
||
+ message: String::new(),
|
||
+ });
|
||
+ Ok(())
|
||
+ }
|
||
+ fn capture_stop(&mut self, cancel: bool) {
|
||
+ if let Ok(mut t) = self.model_table.lock() {
|
||
+ // A request the game has not seen is simply withdrawn.
|
||
+ if t.capture_request.take().is_some() {
|
||
+ t.capture_state = Some(CaptureState {
|
||
+ phase: CapturePhase::Failed,
|
||
+ frames: 0,
|
||
+ width: 0,
|
||
+ height: 0,
|
||
+ file: String::new(),
|
||
+ message: "cancelled".into(),
|
||
+ });
|
||
+ } else {
|
||
+ t.capture_stop = Some(cancel || t.capture_stop == Some(true));
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ fn capture_status(&mut self) -> CaptureState {
|
||
+ self.model_table
|
||
+ .lock()
|
||
+ .ok()
|
||
+ .and_then(|t| t.capture_state.clone())
|
||
+ .unwrap_or(CaptureState {
|
||
+ phase: CapturePhase::Idle,
|
||
+ frames: 0,
|
||
+ width: 0,
|
||
+ height: 0,
|
||
+ file: String::new(),
|
||
+ message: String::new(),
|
||
+ })
|
||
+ }
|
||
+ fn set_film_quality(&mut self, on: bool) {
|
||
+ if let Ok(mut t) = self.model_table.lock() {
|
||
+ t.film_quality = on;
|
||
+ }
|
||
+ }
|
||
fn set_lighting(&mut self, mut lighting: ModLighting) {
|
||
lighting.exposure = if lighting.exposure.is_finite() {
|
||
lighting.exposure.clamp(0.05, 8.0)
|
||
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
|
||
index 1bd2c7eae..d33e26705 100644
|
||
--- a/docs/api/mods-reference.md
|
||
+++ b/docs/api/mods-reference.md
|
||
@@ -588,7 +588,7 @@ A camera the game renders the world from besides the player's eye (AirDef fork):
|
||
| `yaw` | `f32` | Degrees, as the player's: yaw 0 looks south (+z), pitch 90 down. |
|
||
| `pitch` | `f32` | |
|
||
| `fov` | `f32` | Vertical field of view, degrees (1 to 110). |
|
||
-| `width` | `u32` | The picture's size in pixels (16 to 1920 × 16 to 1080). |
|
||
+| `width` | `u32` | The picture's size in pixels (16 to 3840 × 16 to 2160). |
|
||
| `height` | `u32` | |
|
||
|
||
### record `point-light`
|
||
@@ -638,6 +638,65 @@ A piece of smoke or exhaust trail (AirDef fork): a tube from `a` to `b` (equal e
|
||
| `rise` | `f32` | Blocks per second it floats up (negative sinks). |
|
||
| `clear` | `f32` | Radius in blocks of the clear tunnel it leaves in fog and cloud, filling back in over `life` (0 none). |
|
||
|
||
+### record `spot-light`
|
||
+
|
||
+A directional light with a visible beam (AirDef fork): a searchlight, a headlight. The HD lighting lights the surfaces and flying things inside its cone (smoothly from the inner to the outer angle, down to nothing at `range`) and draws the beam as a translucent volume lit from inside: it scatters in the air (more in fog and smoke, a faint artistic one in clear air), the world hides it where it is in front, and the camera may stand inside it. The light does not look for walls: send `range` as far as the beam is free (a cast along the axis) so it stops where the land stops it.
|
||
+
|
||
+| Field | Type | |
|
||
+| --- | --- | --- |
|
||
+| `id` | `u32` | Stays the same while the light lives: the game moves it smoothly. |
|
||
+| `x` | `f64` | The apex, in the lens. |
|
||
+| `y` | `f64` | |
|
||
+| `z` | `f64` | |
|
||
+| `dx` | `f32` | Where it points (any length but zero; it is normalised). |
|
||
+| `dy` | `f32` | |
|
||
+| `dz` | `f32` | |
|
||
+| `color` | `tuple<f32, f32, f32>` | Linear RGB, 0 to 1. |
|
||
+| `intensity` | `f32` | Brightness: about what it lights one block away (0 to 100000). |
|
||
+| `range` | `f32` | Nothing past this many blocks (1 to 1024). |
|
||
+| `inner-angle` | `f32` | Half angle in radians where the light is at full strength (0.005 to 1.2). |
|
||
+| `outer-angle` | `f32` | Half angle in radians where it has faded to nothing (past the inner one, to 1.4). |
|
||
+| `beam` | `f32` | How visible the beam is in the air, 0 (the light only lights things) to 1. |
|
||
+
|
||
+### record `capture-options`
|
||
+
|
||
+What `capture-start` records (AirDef fork, API level 17).
|
||
+
|
||
+| Field | Type | |
|
||
+| --- | --- | --- |
|
||
+| `folder` | `string` | A folder under the game's home (letters, digits, `-`, `_` and `/`; no `..`); made if missing. The result lands in it. |
|
||
+| `name` | `string` | The file name without its extension (letters, digits, `-`, `_`, `.`). |
|
||
+| `fps` | `u32` | Frames a second of the picture (1 to 120). |
|
||
+| `width` | `u32` | The picture's size in pixels; 0 keeps the window's. An offscreen game is resized to it, a window asked to be (what it gets is in the state). |
|
||
+| `height` | `u32` | |
|
||
+| `crf` | `u32` | x264 quality, 0 (lossless) to 51; 14 is visually clean. |
|
||
+| `audio` | `bool` | Mix the sounds the mod plays (`play-sound`) into the file's audio track, heard from the main view's camera at the capture's own time. |
|
||
+| `warmup` | `u32` | Frames drawn and thrown away after a cut (a new `set-main-view` id) so the anti-aliasing and the volumes settle in the new shot (0 to 32). |
|
||
+
|
||
+### enum `capture-phase`
|
||
+
|
||
+Where a capture is.
|
||
+
|
||
+| Case | |
|
||
+| --- | --- |
|
||
+| `idle` | Nothing running (or never started). |
|
||
+| `arming` | Asked; waiting for the picture to reach the size. |
|
||
+| `recording` | Frames are being rendered and written. |
|
||
+| `encoding` | The frames are written; the encoder is closing the file. |
|
||
+| `done` | Finished: `file` is the result. |
|
||
+| `failed` | Stopped by an error or cancelled: `message` says why. |
|
||
+
|
||
+### record `capture-state`
|
||
+
|
||
+| Field | Type | |
|
||
+| --- | --- | --- |
|
||
+| `phase` | `capture-phase` | |
|
||
+| `frames` | `u32` | Frames written so far. |
|
||
+| `width` | `u32` | The size of the frames, once known. |
|
||
+| `height` | `u32` | |
|
||
+| `file` | `string` | The result: the MP4, or the folder of the PNG sequence when no `ffmpeg` was found. Empty until `done`. |
|
||
+| `message` | `string` | An error, or a note about the result (no encoder, no audio). |
|
||
+
|
||
| Function | Signature | What it does |
|
||
| --- | --- | --- |
|
||
| `set-hud` | `func(shapes: list<shape>)` | Replaces the mod's HUD shapes: up to 1024, drawn every frame in order over the HUD (after the hotbar, under chat and screens), hidden with the HUD (F1). An empty list removes them. |
|
||
@@ -672,6 +731,11 @@ A piece of smoke or exhaust trail (AirDef fork): a tube from `a` to `b` (equal e
|
||
| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
|
||
| `set-time-override` | `func(day-time: option<u64>)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. |
|
||
| `set-smoke` | `func(clock: f64, segments: list<smoke-segment>) -> bool` | Replaces the mod's smoke: up to 4096 segments, `clock` being now in ticks (the game shows the age between the last two calls). Returns whether the HD renderer draws them; when it does not (the effects preset, HD smoke off), the mod draws its own smoke. |
|
||
+| `set-spot-lights` | `func(lights: list<spot-light>) -> bool` | Replaces the mod's spot lights: up to 32 (the nearest 16 are drawn), interpolated per id between the last two calls. Returns whether the HD renderer draws them with their beams; when it does not (the effects preset, HD off), the mod draws its own beam if it wants one. |
|
||
+| `capture-start` | `func(options: capture-options) -> result<_, string>` | Records the screen frame by frame (AirDef fork), at a fixed time step whatever the speed of the machine: while it records, the game clock advances exactly `1 / fps` seconds a frame (ticks, interpolation, the mod's `tick`s and the sounds with it), so a slow frame is a longer wait and not a skipped one. The frames are read back from the GPU and encoded to H.264 MP4 by `ffmpeg` (found on the `PATH`, or named by `HORIZON_FFMPEG`) through a bounded queue, never held in memory; with no `ffmpeg` they are written as a PNG sequence. Returns an error for a capture already running, a bad name, or an invalid size. Only the game's own files are written, under its home. |
|
||
+| `capture-stop` | `func(cancel: bool)` | Ends the capture: finishes the file, or (`cancel`) throws it away. |
|
||
+| `capture-status` | `func() -> capture-state` | The capture's state; `done` and `failed` stay until the next start. |
|
||
+| `set-film-quality` | `func(on: bool)` | Asks for the best picture the renderer can give (AirDef fork, level 17): the HD world at the full size of the window (no resolution scale, so no upscaling), and the volumetric clouds and fog marched with more steps. Meant for replays and films; false restores the player's settings. A capture turns it on for its duration. |
|
||
|
||
## `events`
|
||
|
||
--
|
||
2.54.0 (Apple Git-157)
|
||
|