From 251bb6777f897f1a09d2e5574d852f2a72003fef Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 09:28:05 +0300 Subject: [PATCH] Spot lights for mods (render.set-spot-lights, API level 16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film capture (render.capture-*, set-film-quality, API level 17): fixed-step clock, frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58); the client builds without the mods feature again Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/audio.rs | 80 +- crates/horizon-client/src/game.rs | 38 +- crates/horizon-client/src/game/film.rs | 806 ++++++++++++++++++ crates/horizon-client/src/game/mod_models.rs | 76 +- crates/horizon-client/src/game/mods.rs | 60 ++ crates/horizon-client/src/gfx.rs | 134 ++- crates/horizon-client/src/gfx/hd/cloud.wgsl | 4 +- crates/horizon-client/src/gfx/hd/fog.wgsl | 20 +- crates/horizon-client/src/gfx/hd/frame.rs | 25 +- crates/horizon-client/src/gfx/hd/light.wgsl | 27 +- crates/horizon-client/src/gfx/hd/lighting.rs | 4 +- crates/horizon-client/src/gfx/hd/mod.rs | 43 +- crates/horizon-client/src/gfx/hd/prelude.wgsl | 1 + crates/horizon-client/src/gfx/hd/spot.wgsl | 116 +++ crates/horizon-client/src/gfx/hd/taa.rs | 17 + crates/horizon-client/src/gfx/hd/tiles.rs | 119 ++- crates/horizon-mod-sdk/wit/horizon.wit | 110 ++- crates/horizon-mods/src/api_level.rs | 11 +- crates/horizon-mods/src/runtime.rs | 208 ++++- docs/api/mods-reference.md | 66 +- 20 files changed, 1917 insertions(+), 48 deletions(-) create mode 100644 crates/horizon-client/src/game/film.rs create mode 100644 crates/horizon-client/src/gfx/hd/spot.wgsl diff --git a/crates/horizon-client/src/audio.rs b/crates/horizon-client/src/audio.rs index e45362e19..b09178c1d 100644 --- a/crates/horizon-client/src/audio.rs +++ b/crates/horizon-client/src/audio.rs @@ -47,6 +47,8 @@ struct Mixer { listener_yaw: f32, listener_pitch: f32, limiter_peak: f32, + /// Film capture: the output device hears nothing, `Audio::film_mix` mixes instead. + offline: bool, } /// A sound the player could have heard (`SoundEventListener.onPlaySound`): /// what the subtitle overlay needs. @@ -69,6 +71,10 @@ pub struct MixerBackend { decoder: Option>, next: ChannelId, live: bool, + /// Film capture: sounds decode at once (the mix runs on the film's own time) and only the + /// ones played while `allow` is set (a mod's) start. + offline: bool, + allow: bool, } /// Decoded samples and their rate (`None`: the file did not load). type Samples = Option<(Arc>, u32)>; @@ -164,6 +170,8 @@ pub struct Audio { /// How many times each selection was stopped ("record *", "* minecraft:…", `*` for /// any), with or without an audio device, as `heard`. pub stopped: std::collections::BTreeMap, + /// What film capture set aside: (live, loaded) as they were. + film_saved: Option<(bool, bool)>, } fn decode(path: &Path) -> Option<(Vec, u32)> { use symphonia::core::{ @@ -264,11 +272,27 @@ impl Backend for MixerBackend { /// `alSourcePlay`. A sound that cannot load leaves its source initial, /// as a failed buffer future does in vanilla. fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) { - if !self.live { + if !self.live || (self.offline && !self.allow) { return; } let (namespace, path) = sound.path(); let file = self.root.join("assets").join(namespace).join(&path); + if self.offline { + // Decoded here, so the sound starts at the film frame that asked for it. + let key = format!("{namespace}:{path}"); + let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key)); + let samples = cached.unwrap_or_else(|| { + let samples = decode(&file).map(|(s, r)| (Arc::new(s), r)); + if !sound.stream + && let Ok(mut c) = self.cache.lock() + { + c.insert(key, samples.clone()); + } + samples + }); + attach(&self.mixer, channel, samples, looping); + return; + } if sound.stream { // A streamed sound loops by restarting its stream. let mixer = self.mixer.clone(); @@ -530,7 +554,11 @@ fn build_stream>( move |data: &mut [T], _| { scratch.resize(data.len(), 0.0f32); if let Ok(mut m) = mixer.lock() { - mix(&mut m, &mut scratch, channels, rate); + if m.offline { + scratch.fill(0.0); + } else { + mix(&mut m, &mut scratch, channels, rate); + } } else { scratch.fill(0.0); } @@ -589,6 +617,8 @@ impl Audio { decoder: None, next: 0, live, + offline: false, + allow: false, }, record_heard: false, listener: [0.0; 3], @@ -599,6 +629,52 @@ impl Audio { capture: None, heard: Default::default(), stopped: Default::default(), + film_saved: None, + } + } + /// Film capture starts: every sound stops, and from now on only the sounds played between + /// `film_allow(true)` and `film_allow(false)` (a mod's) are heard, mixed by `film_mix` on + /// the film's own clock instead of reaching the output device. + pub fn film_begin(&mut self) { + if self.film_saved.is_some() { + return; + } + self.stop_all(); + self.film_saved = Some((self.backend.live, self.engine.loaded)); + self.backend.live = true; + self.backend.offline = true; + self.backend.allow = false; + self.engine.loaded = true; + if let Ok(mut m) = self.backend.mixer.lock() { + m.offline = true; + m.channels.clear(); + } + } + /// Whether the sounds played now are the film's. + pub fn film_allow(&mut self, on: bool) { + self.backend.allow = on; + } + /// The film's next `frames` stereo frames, interleaved, at `rate`. + pub fn film_mix(&mut self, frames: usize, rate: u32) -> Vec { + let mut data = vec![0.0f32; frames * 2]; + if let Ok(mut m) = self.backend.mixer.lock() { + mix(&mut m, &mut data, 2, rate); + } + data + } + /// Film capture ends: the output device hears the game again. + pub fn film_end(&mut self) { + let Some((live, loaded)) = self.film_saved.take() else { + return; + }; + self.stop_all(); + self.backend.live = live; + self.backend.offline = false; + self.backend.allow = false; + self.engine.loaded = loaded; + if let Ok(mut m) = self.backend.mixer.lock() { + m.offline = false; + m.channels.clear(); } } #[cfg(not(feature = "audio"))] diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 0e779aaa0..bace52193 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -60,6 +60,7 @@ mod explosion; mod extras; mod falling_damage; mod fangs; +mod film; mod fireworks; mod fishing; mod fov; @@ -1818,6 +1819,8 @@ struct Game<'a> { extras: extras::ExtrasState, /// Horizon mods (`game/mods.rs`), none running unless turned on. mods: mods::ModsState, + /// AirDef fork: film capture (`game/film.rs`). + film: film::FilmState, /// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`). native_shaders: native_shaders::NativeShaders, /// Meshed sections waiting for an upload budget (Horizon settings). @@ -6010,6 +6013,9 @@ impl Game<'_> { self.draw_boss_bars(&mut batch); self.draw_extras(&mut batch); self.draw_mod_shapes(&mut batch); + } else if !self.ui.hide_gui { + // A mod's camera as the screen's view: only the mod's own shapes (its titles, bars). + self.draw_mod_shapes(&mut batch); } if show_hud { // Hud.nextContextualInfoState: the jump bar, the experience bar or @@ -6143,7 +6149,10 @@ impl Game<'_> { let tw = HudBatch::text_width(atlas, text); batch.text(atlas, text, (w - tw) / 2.0, h - 59.0, u32::MAX, true); } - self.draw_chat(&mut batch); + // AirDef fork: a film's frames carry no chat (its progress messages go there). + if !self.film.recording() { + self.draw_chat(&mut batch); + } self.draw_tab_list(&mut batch); // Screen.extractBlurredBackground blurs the world and HUD, while the // screen's background, widgets, toasts and debug overlay stay sharp. @@ -8373,12 +8382,17 @@ impl Game<'_> { } /// The time of day the player sees: a mod's override (a replay's recorded time), else the world's. + #[cfg(feature = "mods")] fn shown_day_time(&self) -> i64 { self.mods .mods .time_override() .map_or(self.world.day_time, |t| t as i64) } + #[cfg(not(feature = "mods"))] + fn shown_day_time(&self) -> i64 { + self.world.day_time + } /// The sky's darkening for the game's rules (mob AI, spawning, block behaviour, stats): always /// the world's real time, never a mod's render-only override. @@ -11575,6 +11589,21 @@ impl Game<'_> { // AirDef fork: a mod's camera as the screen's own view (replays): the listener stays the // player's, everything seen is drawn from the camera with the full pipeline. let main_view = self.mod_main_view(alpha); + // AirDef fork: a cut starts the temporal histories over; a film hears from the camera's + // orientation (the sounds are already placed round the player). + let view_id = main_view.as_ref().map(|v| v.id); + if view_id != self.film.last_view { + self.film.last_view = view_id; + if let Some(r) = self.renderer.as_mut() { + r.cut = true; + } + } + if let Some(v) = &main_view + && self.film.recording() + { + self.audio + .set_listener([camera.x, camera.y, camera.z], v.yaw, v.pitch); + } let (camera, underwater, view_yaw, view_pitch) = match &main_view { Some(v) => (v.camera, false, v.yaw, v.pitch), None => (camera, underwater, view_yaw, view_pitch), @@ -11850,11 +11879,13 @@ impl Game<'_> { .filter(|v| main_view.as_ref().is_none_or(|m| m.id != v.id)) .collect(); let hd_lights = self.mod_lights(camera, alpha); + let hd_spots = self.mod_spots(camera, alpha); let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha); let frame = Frame { mod_views: &mod_views, hd: self.mod_hd(), hd_lights: &hd_lights, + hd_spots: &hd_spots, hd_smoke: &hd_smoke, hd_smoke_clock, weather: &weather, @@ -13397,6 +13428,7 @@ impl ApplicationHandler for Game<'_> { // For the "I'm playing" ping: an atomic, sent on when it changes. horizon_client::heartbeat::set_lan(self.is_published()); self.update_discord_presence(); + let elapsed = self.film_step(elapsed); let steps = self.clock.advance(elapsed); // The world ticking in the frame: once its ticks took a // tick's length, the rest are dropped (`FixedStep::tick_fits`). @@ -13438,7 +13470,9 @@ impl ApplicationHandler for Game<'_> { self.pad_look(elapsed.as_secs_f32()); self.advance_flight(); let ticked = Instant::now(); + self.film_before_render()?; let rendered = self.render()?; + self.film_after_render(rendered); self.apply_cursor_icon(); if let Some(threshold) = self.options.spikes && let Some(r) = self.renderer.as_ref() @@ -13972,6 +14006,7 @@ impl<'a> Game<'a> { perf: Default::default(), pending_screenshot: None, pending_bench: None, + film: Default::default(), flight: None, ui_wait: Default::default(), events: Default::default(), @@ -14790,6 +14825,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { mod_views: &[], hd: None, hd_lights: &[], + hd_spots: &[], hd_smoke: &[], hd_smoke_clock: 0.0, weather: &[], diff --git a/crates/horizon-client/src/game/film.rs b/crates/horizon-client/src/game/film.rs new file mode 100644 index 000000000..8d1995030 --- /dev/null +++ b/crates/horizon-client/src/game/film.rs @@ -0,0 +1,806 @@ +//! AirDef fork: film capture (`render.capture-start`). While a mod films, the game clock runs at a +//! fixed step (exactly `1 / fps` seconds a frame, whatever the machine's speed), each frame is +//! copied back from the GPU through a bounded queue and handed to a writer thread that pipes it to +//! `ffmpeg` (or writes a PNG sequence when there is none), and the sounds the mod plays are mixed +//! offline on the same clock into the file's audio track. +use super::Game; +use horizon_client::gfx::CapturedFrame; +use std::{ + io::Write, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::{Arc, Mutex, mpsc}, + time::Duration, +}; + +/// The audio track's sample rate. +const RATE: u32 = 48_000; +/// Frames the writer holds waiting (each 8 MB at 1080p): the renderer waits when it is full. +const QUEUE: usize = 3; + +/// What a mod asked for (`CaptureOptions`). +pub(super) struct FilmRequest { + pub folder: String, + pub name: String, + pub fps: u32, + pub width: u32, + pub height: u32, + pub crf: u32, + pub audio: bool, + pub warmup: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Phase { + Idle, + Arming, + Recording, + Encoding, + Done, + Failed, +} + +/// Where the capture is (`CaptureState`). +#[derive(Clone, Debug)] +pub(super) struct FilmReport { + pub phase: Phase, + pub frames: u32, + pub size: [u32; 2], + pub file: String, + pub message: String, +} + +impl Default for FilmReport { + fn default() -> Self { + Self { + phase: Phase::Idle, + frames: 0, + size: [0, 0], + file: String::new(), + message: String::new(), + } + } +} + +enum Msg { + Frame(CapturedFrame), + Audio(Vec), + Finish, + Cancel, +} + +/// What the writer ended with. +struct Outcome { + ok: bool, + file: String, + message: String, +} + +/// A running capture. +pub(super) struct Film { + req: FilmRequest, + dir: PathBuf, + phase: Phase, + /// Frames spent waiting for the picture to reach the size. + waited: u32, + /// Frames whose time step has been taken, and frames sent to the writer. + stepped: u64, + sent: u32, + size: [u32; 2], + /// The size the screen had before. + before: [u32; 2], + sink: Option>, + outcome: Arc>>, + thread: Option>, + /// The shot the last frame showed (a change is a cut: warm-up frames follow). + view: Option, + /// Audio frames mixed so far. + mixed: u64, + /// Told to stop (cancel or not) and what the writer is finishing. + cancel: bool, + note: String, +} + +/// The capture's state for the game: the running one and the last report. +#[derive(Default)] +pub(super) struct FilmState { + pub(super) film: Option, + pub(super) report: FilmReport, + /// The main view of the last rendered frame (a cut resets the temporal histories). + pub(super) last_view: Option, +} + +/// An `ffmpeg` to run: `HORIZON_FFMPEG`, the `PATH`, or where Homebrew and packages put it (a +/// game started from the desktop does not see a shell's `PATH`). +fn find_ffmpeg() -> Option { + if let Some(p) = std::env::var_os("HORIZON_FFMPEG").filter(|p| !p.is_empty()) { + return Some(PathBuf::from(p)); + } + let name = if cfg!(windows) { + "ffmpeg.exe" + } else { + "ffmpeg" + }; + let mut dirs: Vec = std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default(); + for d in [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/snap/bin", + ] { + dirs.push(d.into()); + } + dirs.into_iter().map(|d| d.join(name)).find(|p| p.is_file()) +} + +/// The H.264 encoder arguments the `ffmpeg` has, best first (software x264, then the Mac's +/// hardware encoder), or None. +fn encoder(ffmpeg: &Path, crf: u32) -> Option> { + let out = Command::new(ffmpeg) + .args(["-hide_banner", "-encoders"]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + let has = |name: &str| { + text.lines() + .any(|l| l.split_whitespace().nth(1) == Some(name)) + }; + let args: &[&str] = if has("libx264") { + &["-c:v", "libx264", "-preset", "slow", "-crf"] + } else if has("h264_videotoolbox") { + return Some( + ["-c:v", "h264_videotoolbox", "-b:v", "60M", "-allow_sw", "1"] + .map(String::from) + .to_vec(), + ); + } else { + return None; + }; + let mut v: Vec = args.iter().map(|s| s.to_string()).collect(); + v.push(crf.to_string()); + Some(v) +} + +/// The writer thread: frames to `ffmpeg`'s pipe (or PNG files), sound to a raw file, then the +/// file is closed and the sound muxed in. +fn write( + rx: mpsc::Receiver, + dir: PathBuf, + name: String, + fps: u32, + size: [u32; 2], + crf: u32, + audio: bool, + outcome: Arc>>, +) { + let result = (|| -> Result<(String, String), String> { + let pcm_path = dir.join(format!("{name}.audio.raw")); + let mut pcm = if audio { + Some(std::io::BufWriter::new( + std::fs::File::create(&pcm_path).map_err(|e| format!("audio file: {e}"))?, + )) + } else { + None + }; + let ffmpeg = find_ffmpeg(); + let encode = ffmpeg.as_deref().and_then(|f| encoder(f, crf)); + let video_path = dir.join(if audio { + format!("{name}.video.mp4") + } else { + format!("{name}.mp4") + }); + let frames_dir = dir.join(format!("{name}_frames")); + let mut child: Option = None; + let mut log_path = None; + if let (Some(ffmpeg), Some(encode)) = (&ffmpeg, &encode) { + let log = dir.join(format!("{name}.ffmpeg.log")); + let stderr = std::fs::File::create(&log).map_err(|e| format!("log file: {e}"))?; + log_path = Some(log); + let bgra = true; + let c = Command::new(ffmpeg) + .args(["-y", "-hide_banner", "-loglevel", "error", "-f", "rawvideo"]) + .args(["-pix_fmt", if bgra { "bgra" } else { "rgba" }]) + .args(["-video_size", &format!("{}x{}", size[0], size[1])]) + .args(["-framerate", &fps.to_string(), "-i", "pipe:0", "-an"]) + .args(encode) + .args([ + "-vf", + "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p", + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-color_range", + "tv", + "-movflags", + "+faststart", + ]) + .arg(&video_path) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(stderr) + .spawn() + .map_err(|e| format!("could not run ffmpeg: {e}"))?; + child = Some(c); + } else { + std::fs::create_dir_all(&frames_dir).map_err(|e| format!("frames folder: {e}"))?; + } + let mut stdin = child.as_mut().and_then(|c| c.stdin.take()); + let mut n = 0u32; + let mut failed: Option = None; + let mut cancelled = false; + for msg in rx { + match msg { + Msg::Frame(f) => { + if failed.is_some() { + continue; + } + n += 1; + if let Some(pipe) = stdin.as_mut() { + // The frames arrive as the surface has them: BGRA, or RGBA swapped here. + let mut data = f.data; + if !f.bgra { + for px in data.chunks_exact_mut(4) { + px.swap(0, 2); + } + } + if let Err(e) = pipe.write_all(&data) { + failed = Some(format!("ffmpeg stopped (see {name}.ffmpeg.log): {e}")); + } + } else if let Err(e) = + write_png(&frames_dir.join(format!("frame_{n:06}.png")), &f) + { + failed = Some(format!("PNG: {e}")); + } + } + Msg::Audio(samples) => { + if let Some(w) = pcm.as_mut() { + let mut bytes = Vec::with_capacity(samples.len() * 2); + for s in samples { + bytes.extend_from_slice( + &((s.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes(), + ); + } + if let Err(e) = w.write_all(&bytes) { + failed.get_or_insert(format!("audio file: {e}")); + } + } + } + Msg::Finish => break, + Msg::Cancel => { + cancelled = true; + break; + } + } + } + drop(stdin); + if let Some(w) = pcm.as_mut() { + let _ = w.flush(); + } + drop(pcm); + let tidy = |paths: &[&Path]| { + for p in paths { + let _ = std::fs::remove_file(p); + } + }; + if let Some(mut c) = child { + let status = c.wait().map_err(|e| format!("ffmpeg: {e}"))?; + if cancelled || failed.is_some() || !status.success() { + let why = failed.unwrap_or_else(|| { + if cancelled { + "cancelled".into() + } else { + format!("ffmpeg failed (see {name}.ffmpeg.log)") + } + }); + if cancelled { + tidy(&[&video_path, &pcm_path]); + if let Some(l) = &log_path { + tidy(&[l]); + } + } + return Err(why); + } + let out = dir.join(format!("{name}.mp4")); + let mut note = String::new(); + if audio { + let mux = Command::new(ffmpeg.as_ref().expect("ffmpeg")) + .args(["-y", "-hide_banner", "-loglevel", "error", "-i"]) + .arg(&video_path) + .args(["-f", "s16le", "-ar", &RATE.to_string(), "-ac", "2", "-i"]) + .arg(&pcm_path) + .args([ + "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", "-b:a", "256k", + ]) + .args(["-shortest", "-movflags", "+faststart"]) + .arg(&out) + .stdin(Stdio::null()) + .output(); + match mux { + Ok(o) if o.status.success() => tidy(&[&video_path, &pcm_path]), + other => { + // The picture is good: keep it without sound. + let _ = std::fs::rename(&video_path, &out); + note = format!( + "audio could not be mixed in ({}); the sound is in {name}.audio.raw (s16le, {RATE} Hz, stereo)", + match other { + Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(), + Err(e) => e.to_string(), + } + ); + } + } + } + if let Some(l) = &log_path { + tidy(&[l]); + } + let encoder_note = if encode + .as_ref() + .is_some_and(|e| e.iter().any(|a| a == "h264_videotoolbox")) + { + "encoded with the hardware encoder (no libx264 in this ffmpeg)" + } else { + "" + }; + let note = [note.as_str(), encoder_note] + .iter() + .filter(|s| !s.is_empty()) + .copied() + .collect::>() + .join("; "); + Ok((out.display().to_string(), note)) + } else { + if cancelled { + let _ = std::fs::remove_dir_all(&frames_dir); + tidy(&[&pcm_path]); + return Err("cancelled".into()); + } + if let Some(why) = failed { + return Err(why); + } + let wav = if audio { + write_wav(&pcm_path, &dir.join(format!("{name}.wav"))) + } else { + Ok(false) + }; + let mut note = format!( + "no ffmpeg found (put it on the PATH or set HORIZON_FFMPEG); {n} PNG frames written. To make the video: ffmpeg -framerate {fps} -i {}/frame_%06d.png{} -c:v libx264 -crf {crf} -pix_fmt yuv420p {}", + frames_dir.display(), + if matches!(wav, Ok(true)) { + format!(" -i {}", dir.join(format!("{name}.wav")).display()) + } else { + String::new() + }, + dir.join(format!("{name}.mp4")).display() + ); + if let Err(e) = wav { + note.push_str(&format!("; audio: {e}")); + } + Ok((frames_dir.display().to_string(), note)) + } + })(); + if let Ok(mut o) = outcome.lock() { + *o = Some(match result { + Ok((file, message)) => Outcome { + ok: true, + file, + message, + }, + Err(message) => Outcome { + ok: false, + file: String::new(), + message, + }, + }); + } +} + +fn write_png(path: &Path, f: &CapturedFrame) -> Result<(), String> { + let mut rgba = Vec::with_capacity(f.data.len()); + for px in f.data.chunks_exact(4) { + let (r, b) = if f.bgra { + (px[2], px[0]) + } else { + (px[0], px[2]) + }; + rgba.extend_from_slice(&[r, px[1], b, 255]); + } + let file = std::fs::File::create(path).map_err(|e| e.to_string())?; + let mut enc = png::Encoder::new(std::io::BufWriter::new(file), f.size[0], f.size[1]); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + enc.set_compression(png::Compression::Fast); + enc.write_header() + .and_then(|mut w| w.write_image_data(&rgba)) + .map_err(|e| e.to_string()) +} + +/// The raw sound as a WAV file; false when there was none. +fn write_wav(raw: &Path, wav: &Path) -> Result { + let data = std::fs::read(raw).map_err(|e| e.to_string())?; + if data.is_empty() { + return Ok(false); + } + let mut out = Vec::with_capacity(data.len() + 44); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + out.extend_from_slice(b"WAVEfmt "); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&2u16.to_le_bytes()); + out.extend_from_slice(&RATE.to_le_bytes()); + out.extend_from_slice(&(RATE * 4).to_le_bytes()); + out.extend_from_slice(&4u16.to_le_bytes()); + out.extend_from_slice(&16u16.to_le_bytes()); + out.extend_from_slice(b"data"); + out.extend_from_slice(&(data.len() as u32).to_le_bytes()); + out.extend_from_slice(&data); + std::fs::write(wav, out).map_err(|e| e.to_string())?; + let _ = std::fs::remove_file(raw); + Ok(true) +} + +impl FilmState { + /// Whether frames are being recorded now. + pub(super) fn recording(&self) -> bool { + self.film + .as_ref() + .is_some_and(|f| f.phase == Phase::Recording) + } +} + +impl Game<'_> { + /// Start of a frame: a mod's start or stop, the writer's end, and the time step to run the + /// frame by: `real` normally, exactly one frame of the film while it records. + pub(super) fn film_step(&mut self, real: Duration) -> Duration { + self.film_poll(); + let paused = self.pause; + let Some(film) = self.film.film.as_mut() else { + return real; + }; + match film.phase { + Phase::Arming => { + self.film_arm(); + real + } + Phase::Recording => { + // A pause menu stops the film, not the frame count. + if paused { + return Duration::ZERO; + } + let fps = u64::from(film.req.fps.max(1)); + let n = film.stepped; + film.stepped += 1; + let at = |k: u64| (k * 1_000_000_000 + fps / 2) / fps; + Duration::from_nanos(at(n + 1) - at(n)) + } + _ => real, + } + } + + /// The mods' requests, the writer's outcome, the quality, and the report back to the mods. + fn film_poll(&mut self) { + let (start, stop) = self.mods_film_requests(); + if let Some(req) = start { + if self.film.film.is_some() { + self.film_fail("a capture is already running"); + } else { + match self.film_open(req) { + Ok(film) => { + self.film.report = FilmReport { + phase: Phase::Arming, + ..Default::default() + }; + self.film.film = Some(film); + } + Err(e) => self.film_fail(&e), + } + } + } + if let Some(cancel) = stop { + self.film_finish(cancel); + } + // The writer is done. + if let Some(film) = self.film.film.as_mut() + && film.phase == Phase::Encoding + { + let outcome = film.outcome.lock().ok().and_then(|mut o| o.take()); + if let Some(o) = outcome { + if let Some(t) = film.thread.take() { + let _ = t.join(); + } + let (frames, size) = (film.sent, film.size); + let cancelled = film.cancel; + let before = film.before; + let audio = film.req.audio; + self.film.film = None; + if audio { + self.audio.film_end(); + } + self.film_restore(before); + self.film.report = FilmReport { + phase: if o.ok { Phase::Done } else { Phase::Failed }, + frames, + size, + file: o.file, + message: if cancelled && !o.ok { + "cancelled".into() + } else { + o.message + }, + }; + } + } + // Film quality: a capture needs it; otherwise it is the mods' choice. + let want = self.film.film.is_some() || self.mods_film_quality(); + horizon_client::gfx::set_film_quality(want); + self.film_report(); + } + + fn film_fail(&mut self, message: &str) { + self.film.report = FilmReport { + phase: Phase::Failed, + message: message.into(), + ..Default::default() + }; + } + + /// The capture's report, as the mods read it. + fn film_report(&mut self) { + let mut r = self.film.report.clone(); + if let Some(f) = &self.film.film { + r.phase = f.phase; + r.frames = f.sent; + r.size = f.size; + if !f.note.is_empty() { + r.message = f.note.clone(); + } + } + self.mods_film_report(&r); + } + + fn film_open(&mut self, req: FilmRequest) -> Result { + let Some(renderer) = self.renderer.as_ref() else { + return Err("no renderer".into()); + }; + renderer.can_capture().map_err(|e| e.to_string())?; + let dir = horizon_client::logging::game_home().join(&req.folder); + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let before = renderer.frame_size(); + Ok(Film { + req, + dir, + phase: Phase::Arming, + waited: 0, + stepped: 0, + sent: 0, + size: [0, 0], + before, + sink: None, + outcome: Arc::new(Mutex::new(None)), + thread: None, + view: None, + mixed: 0, + cancel: false, + note: String::new(), + }) + } + + /// Arming: the picture is brought to the size; at the size (or when the window will not + /// give it) the writer starts and the next frame is the film's first. + fn film_arm(&mut self) { + let offscreen = self.options.offscreen; + let Some(film) = self.film.film.as_mut() else { + return; + }; + let Some(renderer) = self.renderer.as_mut() else { + return; + }; + let have = renderer.frame_size(); + let want = [film.req.width, film.req.height]; + film.waited += 1; + let at_size = want[0] == 0 || have == want; + if !at_size && film.waited == 1 { + if offscreen { + renderer.resize(want[0], want[1]); + } else if let Some(w) = &self.window { + let _ = w.request_inner_size(winit::dpi::PhysicalSize::new(want[0], want[1])); + } + return; + } + if !at_size && film.waited < 120 { + return; + } + if !at_size { + film.note = format!( + "the window would not become {}x{}; filming at {}x{}", + want[0], want[1], have[0], have[1] + ); + } + film.size = have; + let (tx, rx) = mpsc::sync_channel(QUEUE); + let (dir, name, fps, crf, audio) = ( + film.dir.clone(), + film.req.name.clone(), + film.req.fps, + film.req.crf, + film.req.audio, + ); + let outcome = film.outcome.clone(); + film.thread = std::thread::Builder::new() + .name("film writer".into()) + .spawn(move || write(rx, dir, name, fps, have, crf, audio, outcome)) + .ok(); + if film.thread.is_none() { + self.film_fail("could not start the writer"); + self.film.film = None; + return; + } + film.sink = Some(tx); + film.phase = Phase::Recording; + film.stepped = 0; + if film.req.audio { + self.audio.film_begin(); + } + // Ticks count from here, whole, each `fps / 20` frames apart. + self.clock.reset(); + } + + /// Before the frame is drawn: the cut's warm-up frames, then the copy for the writer. + pub(super) fn film_before_render(&mut self) -> horizon_client::Result<()> { + let view = self.mods_main_view_id(); + let pause = self.pause; + let Some(film) = self.film.film.as_mut() else { + return Ok(()); + }; + if film.phase != Phase::Recording || pause { + return Ok(()); + } + let warm = film.req.warmup; + let cut = view != film.view; + film.view = view; + if cut && warm > 0 && film.stepped > 1 { + // The new shot's histories are built from frames nobody sees. + for _ in 0..warm { + self.render()?; + } + } + if let Some(r) = self.renderer.as_mut() { + r.capture_next = true; + } + Ok(()) + } + + /// After the frame: the finished copies go to the writer, the audio of the frame is mixed. + pub(super) fn film_after_render(&mut self, rendered: bool) { + let Some(film) = self.film.film.as_mut() else { + return; + }; + if film.phase != Phase::Recording { + return; + } + let Some(renderer) = self.renderer.as_mut() else { + return; + }; + if renderer.frame_size() != film.size { + let (w, h) = (renderer.frame_size()[0], renderer.frame_size()[1]); + renderer.capture_next = false; + self.film_end( + &format!("the picture changed size to {w}x{h}: the film was stopped"), + false, + ); + return; + } + if rendered && !self.pause && renderer.capture_pending() < 64 { + // Sounds for this frame, on the film's clock. + if film.req.audio { + let fps = u64::from(film.req.fps.max(1)); + let rate = u64::from(RATE); + let f = u64::from(film.sent) + renderer.capture_pending() as u64; + let frames = ((f + 1) * rate / fps - f * rate / fps) as usize; + let mix = self.audio.film_mix(frames, RATE); + film.mixed += frames as u64; + if let Some(tx) = &film.sink { + let _ = tx.send(Msg::Audio(mix)); + } + } + } + match renderer.capture_ready(2) { + Ok(frames) => { + for f in frames { + film.sent += 1; + if let Some(tx) = &film.sink + && tx.send(Msg::Frame(f)).is_err() + { + film.note = "the writer stopped".into(); + } + } + } + Err(e) => { + let why = e.to_string(); + self.film_end(&why, false); + } + } + let outcome_early = self + .film + .film + .as_ref() + .and_then(|f| f.outcome.lock().ok().map(|o| o.is_some())) + .unwrap_or(false); + if outcome_early { + self.film_finish(true); + } + } + + /// Ends the capture with a reason (an error); the writer is told to keep what it has. + fn film_end(&mut self, why: &str, cancel: bool) { + if let Some(f) = self.film.film.as_mut() { + f.note = why.to_string(); + } + self.film_finish(cancel); + } + + /// Stops recording: the copies still in flight go to the writer, which closes the file (or + /// throws it away); the end is seen in `film_poll`. + fn film_finish(&mut self, cancel: bool) { + let Some(film) = self.film.film.as_mut() else { + return; + }; + match film.phase { + Phase::Arming => { + let before = film.before; + self.film.film = None; + self.film_restore(before); + self.film.report = FilmReport { + phase: Phase::Failed, + message: "cancelled".into(), + ..Default::default() + }; + } + Phase::Recording => { + if let Some(r) = self.renderer.as_mut() { + r.capture_next = false; + let rest = if cancel { + r.capture_discard(); + Ok(Vec::new()) + } else { + r.capture_ready(0).and_then(|_| { + // Everything in flight, waited for. + r.capture_ready(0) + }) + }; + if let (Ok(frames), Some(tx)) = (rest, &film.sink) { + for f in frames { + film.sent += 1; + let _ = tx.send(Msg::Frame(f)); + } + } + } + film.cancel = cancel; + if let Some(tx) = film.sink.take() { + let _ = tx.send(if cancel { Msg::Cancel } else { Msg::Finish }); + } + film.phase = Phase::Encoding; + } + _ => {} + } + } + + /// The screen's size as it was before the film. + fn film_restore(&mut self, before: [u32; 2]) { + let offscreen = self.options.offscreen; + if let Some(r) = self.renderer.as_mut() + && r.frame_size() != before + { + if offscreen { + r.resize(before[0], before[1]); + } else if let Some(w) = &self.window { + let _ = w.request_inner_size(winit::dpi::PhysicalSize::new(before[0], before[1])); + } + } + } +} diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs index 7a4d742df..9367bb994 100644 --- a/crates/horizon-client/src/game/mod_models.rs +++ b/crates/horizon-client/src/game/mod_models.rs @@ -11,7 +11,7 @@ use { particles::{ParticleOptions, ParticleType}, }, horizon_core::random::RandomSource, - horizon_mods::{CameraView, ModelRef, ParticleSpawn, PointLight}, + horizon_mods::{CameraView, ModelRef, ParticleSpawn, PointLight, SpotLight}, horizon_world::registry::BlockStateId, std::collections::HashMap, }; @@ -42,6 +42,8 @@ pub(super) struct ModModels { views: Vec<(CameraView, CameraView)>, /// The mods' lights by (mod, id): a tick ago and now. lights: HashMap<(usize, u32), (PointLight, PointLight)>, + /// The mods' spot lights by (mod, id): a tick ago and now. + spots: HashMap<(usize, u32), (SpotLight, SpotLight)>, } /// The overlays of the mods' models (entity.wgsl): glowing. @@ -84,6 +86,13 @@ impl Game<'_> { ) -> Vec { Vec::new() } + pub(super) fn mod_spots( + &mut self, + _camera: glam::DVec3, + _alpha: f64, + ) -> Vec { + Vec::new() + } pub(super) fn mod_smoke( &mut self, _camera: glam::DVec3, @@ -170,6 +179,13 @@ impl Game<'_> { lights.insert(key, (before, *l)); } self.mods.models.lights = lights; + let mut spots: HashMap<(usize, u32), (SpotLight, SpotLight)> = HashMap::new(); + for (owner, l) in self.mods.mods.spot_lights() { + let key = (owner, l.id); + let before = self.mods.models.spots.get(&key).map_or(*l, |(_, now)| *now); + spots.insert(key, (before, *l)); + } + self.mods.models.spots = spots; let views: Vec = self.mods.mods.views().map(|(_, v)| *v).collect(); let old = std::mem::take(&mut self.mods.models.views); self.mods.models.views = views @@ -230,6 +246,64 @@ impl Game<'_> { out.into_iter().take(512).map(|(_, l)| l).collect() } + /// The mods' spot lights between their last two settings, relative to the camera; the nearest + /// (to the cone, not only the apex) first, at most `MAX_SPOTS`. Also tells the mods whether HD + /// draws them (so they keep their own beam models otherwise). + pub(super) fn mod_spots( + &mut self, + camera: DVec3, + alpha: f64, + ) -> Vec { + let on = self.mod_hd().is_some() && horizon_client::gfx::hd_spots_enabled(); + self.mods.mods.set_spot_active(on); + if !on { + return Vec::new(); + } + let t = alpha.clamp(0.0, 1.0); + let lerp = |a: f32, b: f32| a + (b - a) * t as f32; + let mut out: Vec<(f32, horizon_client::gfx::HdSpot)> = self + .mods + .models + .spots + .values() + .filter_map(|(a, b)| { + let at = DVec3::new(a.x, a.y, a.z).lerp(DVec3::new(b.x, b.y, b.z), t) - camera; + let dir = glam::Vec3::new(lerp(a.dx, b.dx), lerp(a.dy, b.dy), lerp(a.dz, b.dz)) + .try_normalize()?; + // Nearest point of the axis to the camera: far cones that point away do not count. + let along = (-at).dot(dir.as_dvec3()).clamp(0.0, f64::from(b.range)); + let d = (at + dir.as_dvec3() * along).length() as f32; + if d > 1500.0 { + return None; + } + let inner = lerp(a.inner_angle, b.inner_angle).cos(); + let outer = lerp(a.outer_angle, b.outer_angle).cos(); + let spot = horizon_client::gfx::HdSpot { + pos: [ + at.x as f32, + at.y as f32, + at.z as f32, + lerp(a.range, b.range), + ], + dir: [dir.x, dir.y, dir.z, outer], + color: [ + b.color.0, + b.color.1, + b.color.2, + lerp(a.intensity, b.intensity), + ], + cone: [inner, lerp(a.beam, b.beam), 0.0, 0.0], + }; + Some((d, spot)) + }) + .collect(); + out.sort_by(|x, y| x.0.total_cmp(&y.0)); + out.into_iter() + .take(horizon_client::gfx::HD_MAX_SPOTS) + .map(|(_, s)| s) + .collect() + } + /// The mods' smoke segments for this frame, relative to the camera and aged to the clock the /// mod gave (between its last two calls, as the lights); the nearest first when there are /// too many. Also tells the mods whether HD draws it. diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index effc9c23d..6623905f0 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -280,6 +280,18 @@ impl Game<'_> { pub(super) fn sync_mods(&mut self) {} pub(super) fn tick_mods(&mut self) {} pub(super) fn mods_frame(&mut self, _elapsed: Duration) {} + pub(super) fn mods_film_requests( + &mut self, + ) -> (Option, Option) { + (None, None) + } + pub(super) fn mods_film_quality(&self) -> bool { + false + } + pub(super) fn mods_film_report(&mut self, _report: &super::film::FilmReport) {} + pub(super) fn mods_main_view_id(&self) -> Option { + None + } pub(super) fn mod_command(&mut self, _command: &str) -> bool { false } @@ -657,6 +669,51 @@ impl Game<'_> { } } /// Once per frame: the frame hook, and dev mods' rebuilds. + /// AirDef fork: a mod's `render.capture-start` and `capture-stop`, taken. + pub(super) fn mods_film_requests( + &mut self, + ) -> (Option, Option) { + let start = self + .mods + .mods + .take_capture_request() + .map(|o| super::film::FilmRequest { + folder: o.folder, + name: o.name, + fps: o.fps, + width: o.width, + height: o.height, + crf: o.crf, + audio: o.audio, + warmup: o.warmup, + }); + (start, self.mods.mods.take_capture_stop()) + } + pub(super) fn mods_film_quality(&self) -> bool { + self.mods.mods.film_quality() + } + pub(super) fn mods_film_report(&mut self, r: &super::film::FilmReport) { + use super::film::Phase; + use horizon_mods::{CapturePhase, CaptureState}; + self.mods.mods.set_capture_state(CaptureState { + phase: match r.phase { + Phase::Idle => CapturePhase::Idle, + Phase::Arming => CapturePhase::Arming, + Phase::Recording => CapturePhase::Recording, + Phase::Encoding => CapturePhase::Encoding, + Phase::Done => CapturePhase::Done, + Phase::Failed => CapturePhase::Failed, + }, + frames: r.frames, + width: r.size[0], + height: r.size[1], + file: r.file.clone(), + message: r.message.clone(), + }); + } + pub(super) fn mods_main_view_id(&self) -> Option { + self.mods.mods.main_view() + } pub(super) fn mods_frame(&mut self, elapsed: Duration) { self.reload_dev_mods(); if !self.mods.mods.is_running() { @@ -758,7 +815,10 @@ impl Game<'_> { volume, pitch, } => { + // A film hears only the mods' sounds (`Audio::film_begin`). + self.audio.film_allow(true); self.audio.play(&event, Some(at), volume, pitch); + self.audio.film_allow(false); continue; } Output::PlaceRecipe { book_id, all } => { diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 1a4e68b88..9482cf0d2 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -84,7 +84,11 @@ mod mod_views; mod native_material; mod native_pack; mod native_post; -pub use hd::{HdLight, HdSettings, HdSmoke, smoke_enabled as hd_smoke_enabled}; +pub use hd::set_film_quality; +pub use hd::{ + HdLight, HdSettings, HdSmoke, HdSpot, MAX_SPOTS as HD_MAX_SPOTS, + smoke_enabled as hd_smoke_enabled, spots_enabled as hd_spots_enabled, +}; pub use mod_views::ModView; mod native_sky; mod occlusion; @@ -235,6 +239,8 @@ pub struct Frame<'a> { /// The HD lighting (AirDef fork): on with its settings, and the mods' lights. pub hd: Option, pub hd_lights: &'a [HdLight], + /// The mods' spot lights (searchlights), camera-relative, the nearest first. + pub hd_spots: &'a [HdSpot], /// The mods' smoke segments for HD's volumetric smoke, camera-relative, and the mod's clock /// in seconds. pub hd_smoke: &'a [HdSmoke], @@ -622,6 +628,12 @@ pub struct Renderer { started: Instant, /// Write the next presented frame to this PNG path (control socket). pub screenshot: Option, + /// AirDef fork (film capture, `game/film.rs`): the next frame is copied back for the + /// encoder, `capture_queue` holding the copies the GPU has not finished. + pub capture_next: bool, + capture_queue: std::collections::VecDeque, + /// AirDef fork: the shot changed (a mod's main view): TAA and the volumes start over. + pub cut: bool, /// Outcome of the last screenshot: frame size, or the error. pub screenshot_result: Option>, /// Brightness option (lightmap gamma), 0..1. @@ -2920,6 +2932,9 @@ impl Renderer { uploaded_bytes: 0, started: Instant::now(), screenshot: None, + capture_next: false, + capture_queue: std::collections::VecDeque::new(), + cut: false, screenshot_result: None, gamma: 0.5, fullbright: false, @@ -3436,7 +3451,13 @@ impl Renderer { ¶ms, self.mod_params.1, ); - post.run_view(&self.device, encoder, &t.post_buffer, (&t.color, &t.depth), &t.fx); + post.run_view( + &self.device, + encoder, + &t.post_buffer, + (&t.color, &t.depth), + &t.fx, + ); t.post = true; } } @@ -4099,6 +4120,12 @@ impl Renderer { &mut self.taa_prev, taa.then_some((unjittered, frame.camera, view_proj)), ); + // AirDef fork: a cut starts the temporal histories over (no ghost of the last shot). + let taa_prev = if std::mem::take(&mut self.cut) { + None + } else { + taa_prev + }; if frame.title { self.panorama.prepare( &self.queue, @@ -5239,6 +5266,7 @@ impl Renderer { frame.hd_lights, frame.hd_smoke_clock, ); + hd.write_spots(frame.hd_spots); hd.write(&self.queue, &hd_scene, frame.hd_lights, settings); } // vanilla's translucent water leaves the colour while the HD water draws it. @@ -6121,6 +6149,8 @@ impl Renderer { (&view_target, self.config.format.remove_srgb_suffix()), ); } + let capture_copy = std::mem::take(&mut self.capture_next) + .then(|| self.copy_frame(&mut encoder, &target_texture)); let readback = self.screenshot.take().and_then(|path| { // The copy and PNG writer both assume 8-bit pixels; a float surface would make // the row stride too small for the copy and abort in validation. @@ -6162,6 +6192,22 @@ impl Renderer { if hd_on && let Some(hd) = &mut self.hd { hd.timing_submitted(&self.queue); } + if let Some(buffer) = capture_copy { + let ready = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = ready.clone(); + buffer.map_async(wgpu::MapMode::Read, .., move |r| { + flag.store(r.is_ok(), std::sync::atomic::Ordering::Release); + }); + self.capture_queue.push_back(CaptureRead { + buffer, + ready, + size: [self.config.width, self.config.height], + bgra: matches!( + self.config.format.remove_srgb_suffix(), + wgpu::TextureFormat::Bgra8Unorm + ), + }); + } if let Some((path, buffer)) = readback { self.screenshot_result = Some( self.write_screenshot(&path, &buffer) @@ -6209,7 +6255,91 @@ impl Renderer { Ok(true) } } +/// A frame copied for a capture, waiting for the GPU (AirDef fork). +struct CaptureRead { + buffer: wgpu::Buffer, + ready: Arc, + size: [u32; 2], + bgra: bool, +} +/// A captured frame: tightly packed 8-bit pixels, BGRA or RGBA by `bgra`. +pub struct CapturedFrame { + pub size: [u32; 2], + pub bgra: bool, + pub data: Vec, +} impl Renderer { + /// Whether frames can be captured (the surface is 8-bit and readable). + pub fn can_capture(&self) -> Result<()> { + if self.config.format.block_copy_size(None) != Some(4) { + return Err(format!( + "capture needs a 32-bit surface format, not {:?}", + self.config.format + ) + .into()); + } + if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) { + return Err("this surface does not support frame readback".into()); + } + Ok(()) + } + /// The size frames are drawn at. + pub fn frame_size(&self) -> [u32; 2] { + [self.config.width, self.config.height] + } + /// How many captured frames wait for the GPU or for the caller. + pub fn capture_pending(&self) -> usize { + self.capture_queue.len() + } + /// The copies the GPU has finished, oldest first, as frames; with `keep` the queue is + /// held to at most that many by waiting for the oldest (a bounded queue, never + /// dropping a frame). + pub fn capture_ready(&mut self, keep: usize) -> Result> { + let _ = self.device.poll(wgpu::PollType::Poll); + let mut out = Vec::new(); + while let Some(front) = self.capture_queue.front() { + let ready = front.ready.load(std::sync::atomic::Ordering::Acquire); + if !ready && self.capture_queue.len() <= keep { + break; + } + if !ready { + self.device + .poll(wgpu::PollType::wait_indefinitely()) + .map_err(|e| e.to_string())?; + if !self.capture_queue[0] + .ready + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("frame readback failed".into()); + } + } + let read = self.capture_queue.pop_front().expect("front"); + let [w, h] = read.size; + let row = (w * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) as usize; + let mut data = Vec::with_capacity((w * h * 4) as usize); + { + let mapped = read + .buffer + .get_mapped_range(..) + .map_err(|e| e.to_string())?; + for y in 0..h as usize { + data.extend_from_slice(&mapped[y * row..y * row + w as usize * 4]); + } + } + read.buffer.unmap(); + out.push(CapturedFrame { + size: read.size, + bgra: read.bgra, + data, + }); + } + Ok(out) + } + /// Drops the frames still on their way (a cancelled capture). + pub fn capture_discard(&mut self) { + let _ = self.device.poll(wgpu::PollType::wait_indefinitely()); + self.capture_queue.clear(); + } fn padded_row(&self) -> u32 { (self.config.width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) } diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl index 39a239f4d..b2bdd3080 100644 --- a/crates/horizon-client/src/gfx/hd/cloud.wgsl +++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl @@ -112,7 +112,9 @@ fn fs_cloud(in: FullOut) -> CloudOut { let hi = min(max(ta, tb), min(4200.0, scene_len)); if hi > lo { let dither = ign(in.position.xy); - let n = clamp(i32(ceil((hi - lo) / 14.0)), 8, 26); + // Film quality (surface.z): half the step length, twice the cap. + let fine = frame.surface.z > 0.5; + let n = clamp(i32(ceil((hi - lo) / select(14.0, 7.0, fine))), select(8, 16, fine), select(26, 52, fine)); let step_len = (hi - lo) / f32(n); let light = frame.cloud_dir.xyz; let cos_theta = dot(dir, light); diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl index d035bbf0d..3fde6e22f 100644 --- a/crates/horizon-client/src/gfx/hd/fog.wgsl +++ b/crates/horizon-client/src/gfx/hd/fog.wgsl @@ -9,7 +9,16 @@ //SKY7 @group(0) @binding(8) var smoke_tex: texture_2d; +//SPOT + +// The air's extinction for the spot lights' beams. +fn spot_air(p: vec3) -> f32 { + return fog_sigma(p); +} + const FOG_MAX: f32 = 192.0; +// Beams reach past the fog's own range, also into the sky. +const BEAM_FAR: f32 = 1100.0; const FOG_STEPS: i32 = 18; struct FogOut { @@ -172,9 +181,11 @@ fn fs_fog(in: FullOut) -> FogOut { var trans = 1.0; var light = vec3(0.0); var lit_sum = 0.0; - for (var i = 0; i < FOG_STEPS; i++) { - let u0 = (f32(i) + jitter) / f32(FOG_STEPS); - let u1 = (f32(i) + 1.0 + jitter) / f32(FOG_STEPS); + // Film quality (surface.z) marches twice the steps. + let steps = select(FOG_STEPS, FOG_STEPS * 2, frame.surface.z > 0.5); + for (var i = 0; i < steps; i++) { + let u0 = (f32(i) + jitter) / f32(steps); + let u1 = (f32(i) + 1.0 + jitter) / f32(steps); let f0 = 0.5 * u0 + 0.5 * u0 * u0; let f1 = 0.5 * u1 + 0.5 * u1 * u1; let t = len * f0; @@ -195,6 +206,9 @@ fn fs_fog(in: FullOut) -> FogOut { let sy = frame.sky_sun.y + 0.02; let glow = 1.0 + 2.0 * linear_step(0.05, 1.0, exp(-300.0 * sy * sy)); var fog = vec4(light * glow, trans); + if frame.vfog.w > 0.5 { + fog = vec4(fog.rgb + spot_beams(dir, select(BEAM_FAR, length(p), d > 0.0), jitter), fog.a); + } if frame.smoke.x > 0.5 { let sm = march_smoke(dir, select(SMOKE_MAX, min(length(p), SMOKE_MAX), d > 0.0), jitter, haze, cos_v); // The smoke is lit by the sun itself and stands in the haze: it is laid over the fog. diff --git a/crates/horizon-client/src/gfx/hd/frame.rs b/crates/horizon-client/src/gfx/hd/frame.rs index 415b12cdb..6b06732c3 100644 --- a/crates/horizon-client/src/gfx/hd/frame.rs +++ b/crates/horizon-client/src/gfx/hd/frame.rs @@ -30,6 +30,25 @@ pub struct HdLight { pub color: [f32; 4], } +/// The most spot lights (searchlights) drawn a frame. +pub const MAX_SPOTS: usize = 16; + +/// A spot light for this frame, relative to the camera: a cone from its apex along `dir`, out to +/// `pos.w`, that lights surfaces and is drawn as a volume of scattered light in the air. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)] +pub struct HdSpot { + /// xyz: camera-relative apex; w: range (blocks). + pub pos: [f32; 4], + /// xyz: unit axis; w: cosine of the outer half angle (past it: dark). + pub dir: [f32; 4], + /// rgb: colour; w: intensity (as a point light's). + pub color: [f32; 4], + /// x: cosine of the inner half angle (inside it: full); y: how visible the beam is in the + /// air (0..1); zw: unused. + pub cone: [f32; 4], +} + /// The frame's HD settings. #[derive(Clone, Copy, Debug)] pub struct HdSettings { @@ -88,7 +107,8 @@ pub(super) struct HdFrame { pub(super) cloud_dir: [f32; 4], /// rgb: the colour of that light at the clouds; w: its strength. pub(super) cloud_sun: [f32; 4], - /// x: 1 when the volumetric fog is drawn; y: the camera's height in the world; z: the morning mist. + /// x: 1 when the volumetric fog is drawn; y: the camera's height in the world; z: the morning mist; + /// w: the number of spot lights. pub(super) vfog: [f32; 4], /// x: how wet the surfaces are (0 dry .. 1 soaked; follows the rain, dries slowly); y: 1 when /// the specular and the wet look are on. @@ -101,6 +121,9 @@ pub(super) struct HdFrame { /// Up to 16 lights that glow on the smoke: xyz position relative to the camera, w radius; then /// rgb colour times intensity. pub(super) smoke_lights: [[f32; 4]; 32], + /// Up to `MAX_SPOTS` spot lights, four vec4 each (`HdSpot`: pos+range, dir+cos outer, + /// colour+intensity, cone); their number is `vfog.w`. + pub(super) spots: [[f32; 4]; 64], } /// What the world around looks like this frame (from the renderer's globals). diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl index 870ae0d7d..a9e1fb7f4 100644 --- a/crates/horizon-client/src/gfx/hd/light.wgsl +++ b/crates/horizon-client/src/gfx/hd/light.wgsl @@ -16,8 +16,8 @@ struct Light { @group(0) @binding(10) var lightmap: texture_2d; @group(0) @binding(11) var linear: sampler; // Per tile of 16x16 pixels: where its surface list and its halo list start in this array and -// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means -// every light. +// how long they are (4 words), the mask of the spot lights whose cone can show in the tile (the +// 5th word), then the lists of light indices. A length of 0xffffffff means every light. @group(0) @binding(12) var tiles: array; // Ambient occlusion at half resolution: x the occlusion (1 open), y the distance it was taken at. @group(0) @binding(13) var ao_tex: texture_2d; @@ -225,6 +225,13 @@ fn cloud_at(px: vec2, dist: f32) -> vec4 { return sum / total; } +//SPOT + +// Without the volumetric fog the beams use the air's constant density. +fn spot_air(p: vec3) -> f32 { + return frame.ambient.z; +} + struct LightOut { // The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an // object in front of the water (the scene as drawn). @@ -255,7 +262,7 @@ fn shade_surface(px: vec2, p: vec3, water: bool) -> vec3 { let sun_on = frame.sun.w; let atmosphere = frame.sky_sun.w > 0.5; let count = u32(frame.params.x); - let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u; + let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 5u; let surface_start = tiles[tile]; let surface_all = tiles[tile + 1u] == 0xffffffffu; let surface_count = select(tiles[tile + 1u], count, surface_all); @@ -311,6 +318,13 @@ fn shade_surface(px: vec2, p: vec3, water: bool) -> vec3 { sky_tint = mix(vec3(1.0), hue * shape, reach); } var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct; + // The spot lights whose cone reaches this tile. + let spot_mask = tiles[tile + 4u]; + for (var j = 0; j < i32(frame.vfog.w); j++) { + if ((spot_mask >> u32(j)) & 1u) != 0u { + light += spot_light(j, p, n) * ao_lights; + } + } // The mods' lights. for (var k = 0u; k < surface_count; k++) { let l = lights[select(tiles[surface_start + k], k, surface_all)]; @@ -384,7 +398,7 @@ fn fs_light(in: FullOut) -> LightOut { let sun = frame.sun.xyz; let sun_on = frame.sun.w; let count = u32(frame.params.x); - let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u; + let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 5u; let halo_start = tiles[tile + 2u]; let halo_all = tiles[tile + 3u] == 0xffffffffu; let halo_count = select(tiles[tile + 3u], count, halo_all); @@ -473,6 +487,11 @@ fn fs_light(in: FullOut) -> LightOut { let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08; air_s += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density)); } + // The spot lights' beams: the fog march draws them when it runs; else a cone integration here. + if !volumetric && frame.vfog.w > 0.5 && d >= 0.0 { + let beam_len = select(1100.0, length(ps), d > 0.0); + air_s += spot_beams(view_s, beam_len, ign(in.position.xy)); + } for (var k = 0u; k < halo_count; k++) { let l = lights[select(tiles[halo_start + k], k, halo_all)]; // Nearest point of the view ray to the light, and the glow along the ray in front of it. diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs index d26f1e513..8c56768f8 100644 --- a/crates/horizon-client/src/gfx/hd/lighting.rs +++ b/crates/horizon-client/src/gfx/hd/lighting.rs @@ -22,7 +22,7 @@ //! 0.2%). //! - The sun is the light `frame::light_of` picks (moon at night), in `HdFrame.sun`. //! - Bind groups are cached per (world views, lightmap) and per volumetric-history flip. -use super::frame::HdLight; +use super::frame::{HdLight, HdSpot}; use super::tiles::{TILE, tile_lists, tiles_buffer}; use super::{ DEPTH, FLOAT, HDR, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, STORAGE, UNFILTERED, UNIFORM, @@ -157,6 +157,7 @@ impl Lighting { queue: &wgpu::Queue, view_proj: &glam::Mat4, lights: &[HdLight], + spots: &[HdSpot], size: [u32; 2], ) -> usize { let n = lights.len().min(MAX_LIGHTS); @@ -167,6 +168,7 @@ impl Lighting { tile_lists( view_proj, &lights[..n], + spots, size, &mut self.tile_scratch, &mut self.tile_words, diff --git a/crates/horizon-client/src/gfx/hd/mod.rs b/crates/horizon-client/src/gfx/hd/mod.rs index 1199e073e..ccc793e9a 100644 --- a/crates/horizon-client/src/gfx/hd/mod.rs +++ b/crates/horizon-client/src/gfx/hd/mod.rs @@ -89,13 +89,19 @@ use tonemap::Tonemap; use volumes::HdVolumes; use water::Water; -pub use frame::{HdLight, HdScene, HdSettings, light_of, vanilla_cloud_tint}; +pub use frame::{HdLight, HdScene, HdSettings, HdSpot, MAX_SPOTS, light_of, vanilla_cloud_tint}; pub use gbuffer::{ALBEDO, LIGHT_LEVELS, NORMAL}; pub use materials::{Table as MaterialTable, table as material_table, wind_enabled, wind_seconds}; pub use shadows::{CASCADES, cascades}; pub use smoke::{HdSmoke, smoke_enabled}; -pub use taa::{jitter, jitter_count, scale_override, taa_enabled}; +pub use taa::{film_quality, jitter, jitter_count, scale_override, set_film_quality, taa_enabled}; pub use volumes::clouds_enabled; + +/// Spot lights (searchlights) are drawn unless `HORIZON_HD_SPOTS=0`. +pub fn spots_enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var("HORIZON_HD_SPOTS").map_or(true, |v| v != "0")) +} pub use water::{WATER_NORMAL, WATER_TINT}; use frame::{HdFrame, cloud_light, smooth, sun_dir_x}; @@ -162,9 +168,15 @@ pub(super) fn assemble(body: &str) -> String { &sky::SKY_FUNCS.replace("SKYBIND", &binding.to_string()), ); } + // `//SPOT`: the spot lights' cone functions (`spot.wgsl`), which call the shader's own + // `spot_air(pos)` (the air's extinction per block). + let body = body.replace("//SPOT", SPOT); format!("{}{body}", frame::PRELUDE) } +/// The spot lights' cone intersection and beam march, shared by the fog and the lighting. +const SPOT: &str = include_str!("spot.wgsl"); + pub(super) fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule { // Development: with `HORIZON_HD_SHADER_DIR` set, a shader's body is read from // `/