diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fdcc0bd..f60eeda 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,12 +8,41 @@ The scene engine is deliberately split into small stages so Wallpaper Engine's original OpenGL pass model can be rebuilt incrementally in Rust, and so each stage can be inspected on its own when a wallpaper renders wrongly. +## Source layout + +```text +src/ + main.rs, cli/ the wp-engine binary and its commands, one file per area + bin/ the scan-wallpapers tool + workshop/ finding Workshop items; project.json and user properties + formats/ .pkg archives, .tex textures, the asset store + engine/ the scene engine + model/ what a scene is: scene.json types, resolving into layers + sim/ what changes: particles, timelines, SceneScript, cameras, puppets + gpu/ how it's drawn: the renderer, targets, lighting, shadows, shaders + cpu/ the CPU fallback renderer + content/ wallpaper content (scene/video/web/image), frame sources, + render settings, the per-output session the backends share + services/ what wallpapers react to: audio, media, battery/lock, pause rules + platform/ presentation: wayland/, x11/, macos/, GPU device, scaler + daemon/, application/, settings.rs, schedule.rs lifecycle and saved setup + ui/ the desktop app, one file per part of the window +``` + +Large modules are folders whose `mod.rs` holds the types and says where the +rest lives — for example `engine/gpu/renderer/` splits into `pipelines`, +`build`, `live`, `draw`, `lights` and `params`, and `engine/sim/particle/` +into `config`, `build`, `step`, `operators` and `draw`. Tests sit beside +them in `tests.rs`. + +## Pipeline + ### 1. Workshop Content Detection The app starts from either a Steam Workshop item or a direct file path. - `src/workshop` scans Wallpaper Engine Workshop projects. -- `src/render/content.rs` classifies content as static image, video, scene, or +- `src/content/wallpaper.rs` classifies content as static image, video, scene, or web. - Static, video, and web wallpapers flow through the frame-source path. - Scene wallpapers are handed to the engine pipeline below. @@ -22,7 +51,7 @@ The app starts from either a Steam Workshop item or a direct file path. Scene assets can exist as loose files or packed archives. -- `src/engine/assets.rs` mounts the wallpaper directory. +- `src/formats/assets.rs` mounts the wallpaper directory. - Loose files are preferred. - `scene.pkg` and `gifscene.pkg` are loaded as fallbacks. - Asset paths are normalized to Wallpaper Engine's forward-slash layout. @@ -36,7 +65,7 @@ lookup layer. The engine reads `scene.json` into loose, tolerant Rust structs. -- `src/engine/scene.rs` parses objects, camera data, effects, visibility, +- `src/engine/model/scene.rs` parses objects, camera data, effects, visibility, origins, sizes, parallax, and texture overrides. - Fields that vary between Wallpaper Engine versions are kept as `serde_json::Value` until a later stage knows how to interpret them. @@ -48,7 +77,7 @@ slightly different schema. The scene graph resolves object references into typed engine data. -- `src/engine/material.rs` loads model JSON files. +- `src/engine/cpu/material.rs` loads model JSON files for the CPU path. - Models point at material JSON files. - Materials contain passes, shaders, texture bindings, combos, constants, blending, culling, and depth flags. @@ -69,7 +98,7 @@ scene object ### 5. Scene Graph -`src/engine/graph.rs` builds a resolved scene graph. +`src/engine/cpu/graph.rs` builds a resolved scene graph. For each visible object it records: @@ -87,7 +116,7 @@ rendering. ### 6. Shader Translation Layer -`src/engine/shaders/transpiler.rs` turns Wallpaper Engine's GLSL into WGSL: +`src/engine/gpu/shaders/transpiler/` turns Wallpaper Engine's GLSL into WGSL: resolve `shaders/.vert|frag`, expand includes and combo `#define`s, compile to SPIR-V with shaderc, then translate to WGSL with Naga. @@ -104,7 +133,7 @@ compile — shaderc's line numbers refer to that text and nothing else prints it ### 7. GPU Scene Renderer -`src/engine/gpu_renderer.rs` is the production renderer — `GpuSceneInstance` and +`src/engine/gpu/renderer/` is the production renderer — `GpuSceneInstance` and `gpu_scene_render_loop`, used by `set`, `set-file`, and `test-scene`. It owns the full pass model: per-object composite buffers, ping-pong FBOs, @@ -119,7 +148,7 @@ only way to inspect the live path off-screen. ### 8. Web Wallpapers -`src/render/web.rs` renders HTML wallpapers with an embedded Chromium through +`src/content/web.rs` renders HTML wallpapers with an embedded Chromium through CEF, off-screen: each painted frame becomes an `RgbaImage` on the same channel video and CPU scene rendering use, so every presentation path works unchanged. @@ -130,7 +159,8 @@ bands. Behind the off-by-default `web` feature; see **Building**. ### 9. CPU Fallback Renderer -`src/engine/render.rs` keeps the older CPU compositor alive. +`ResolvedScene::render` (`src/engine/model/resolve/`) keeps the older CPU +compositor alive, with its frame loop and scene graph in `src/engine/cpu/`. It: @@ -161,15 +191,17 @@ handle; if that fails (or `WP_ENGINE_FORCE_SHM` is set) it falls back to RGBA readback + SHM buffers. The same module feeds the real cursor position into camera parallax and `g_PointerPosition`. -On **X11**, `src/platform/x11` draws into the root window's background pixmap -and publishes `_XROOTPMAP_ID`/`ESETROOT_PMAP_ID`, the convention every WM and -compositor honours (it is what `feh --bg` sets). There is no direct-presentation -equivalent, so every frame is read back and pushed with `PutImage`. X11 gets +On **X11**, `src/platform/x11` draws each frame into a root-sized pixmap and +publishes it as the root background (`_XROOTPMAP_ID`/`ESETROOT_PMAP_ID`, what +`feh --bg` sets). Desktop environments cover the root with their own desktop, so +when the window manager supports EWMH desktop windows the same pixmap also backs +a click-through `_NET_WM_WINDOW_TYPE_DESKTOP` window of ours. Frames are read +back and pushed with `PutImage`; there is no direct-presentation path yet. X11 gets better parallax for free: `QueryPointer` reports the global cursor regardless of which window has focus. Both the X11 and Wayland backends also implement the real Wallpaper Engine's -own "pause wallpaper rendering when..." settings (`engine::playback_gate` — +own "pause wallpaper rendering when..." settings (`services::playback_gate` — recovered from the Ghidra dump's import table, not the usual property scan; see the report's Follow-up (ff)): on battery power, when the session is locked, and when another application is fullscreen or maximized (X11 via @@ -177,17 +209,19 @@ EWMH's `_NET_ACTIVE_WINDOW`/`_NET_WM_STATE`; Wayland via `zwlr_foreign_toplevel_management`, so it only works on compositors that support that wlroots-ecosystem protocol — Sway/Hyprland/river, not GNOME/Mutter). Session-lock detection is Linux-only (logind over D-Bus, via -`platform::power::LockWatcher`). Each condition defaults per -`engine::playback_gate::PauseConditions::default`'s own doc comment (WE's +`services::power::LockWatcher`). Each condition defaults per +`services::playback_gate::PlaybackPolicy::default`'s own doc comment (WE's real defaults for these aren't recoverable from the binary) and is overridable with `WP_ENGINE_PAUSE_ON_BATTERY`/`_LOCK`/`_FULLSCREEN`/ `_MAXIMIZED=0`/`1`. macOS currently has none of this wired in (see -`platform::macos`'s own "step 2 of the port" status note) — `platform:: +`platform::macos`'s own "step 2 of the port" status note) — `services:: power::is_on_battery` already works there (via `pmset`) for whenever that backend's render loop is built out far enough to use it. -Static images, videos, and web wallpapers flow through `src/render/frame.rs` -frame sources. +Static images, videos, and web wallpapers flow through `src/content/frame.rs` +frame sources. What every output does whichever backend shows it — building its +renderer, live property edits, the pause rules — lives once in +`src/content/session.rs`. ### 11. Application Lifecycle @@ -210,7 +244,7 @@ rather than stack. The GUI forwards live quality/volume changes the same way. ### 12. User Properties -`src/engine/properties.rs` loads `project.json` `general.properties`, +`src/workshop/properties.rs` loads `project.json` `general.properties`, applies `--set-property NAME=VALUE` overrides, and rewrites `{"user": ...}` references inside `scene.json` before parsing — the Rust counterpart of the reference `PropertyParser`/`UserSettingParser`. `wp-engine list-properties @@ -218,7 +252,7 @@ reference `PropertyParser`/`UserSettingParser`. `wp-engine list-properties ### 13. Camera Dynamics -`src/engine/camera_dynamics.rs` implements scene-level `camerashake`, +`src/engine/sim/camera_dynamics.rs` implements scene-level `camerashake`, `camerafade`, and `cameraparallax` from the resolved general settings. Shake and per-layer parallax feed the composite pass as UV offsets; fade drives global scene opacity. diff --git a/Cargo.toml b/Cargo.toml index 254683d..90a36bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ path = "src/main.rs" [[bin]] name = "scan-wallpapers" -path = "src/tests/scan_wallpapers.rs" +path = "src/bin/scan_wallpapers.rs" [features] default = [] diff --git a/src/application/application_context.rs b/src/application/application_context.rs index 226ce24..fafed03 100644 --- a/src/application/application_context.rs +++ b/src/application/application_context.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use crate::render::RenderSettings; +use crate::content::RenderSettings; /// Runtime configuration for a wallpaper run (the Rust counterpart of the C++ /// `ApplicationContext`). Built from the CLI by `main.rs` and consumed by @@ -36,7 +36,7 @@ pub struct ApplicationContext { impl ApplicationContext { pub fn new(background: PathBuf) -> Self { let saved = crate::settings::WpSettings::load(); - let fps = saved.fps.unwrap_or(crate::render::settings::DEFAULT_FPS); + let fps = saved.fps.unwrap_or(crate::content::settings::DEFAULT_FPS); let scaling = saved.scaling.unwrap_or_default(); Self { background, @@ -83,7 +83,7 @@ impl ApplicationContext { S: AsRef, { for arg in args { - let (name, value) = crate::engine::properties::parse_property_arg(arg.as_ref()); + let (name, value) = crate::workshop::properties::parse_property_arg(arg.as_ref()); self.properties.insert(name, value); } } diff --git a/src/application/mod.rs b/src/application/mod.rs index 7111b7c..17fe4a8 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -12,8 +12,8 @@ use std::time::Duration; use anyhow::{Context, Result}; +use crate::content::{ScreenContent, ScreenSettings, WallpaperContent}; use crate::platform::{self, WallpaperHandle}; -use crate::render::{ScreenContent, ScreenSettings, WallpaperContent}; pub mod application_context; pub use application_context::ApplicationContext; @@ -37,7 +37,7 @@ impl WallpaperApplication { /// Prepare the application: installs the context's property overrides so /// every scene loaded afterwards resolves user settings against them. pub fn new(context: ApplicationContext) -> Self { - crate::engine::properties::set_overrides_for( + crate::workshop::properties::set_overrides_for( &context.background, context.properties.clone(), ); diff --git a/src/tests/scan_wallpapers.rs b/src/bin/scan_wallpapers.rs similarity index 98% rename from src/tests/scan_wallpapers.rs rename to src/bin/scan_wallpapers.rs index b260bd4..743c3fb 100644 --- a/src/tests/scan_wallpapers.rs +++ b/src/bin/scan_wallpapers.rs @@ -272,7 +272,8 @@ fn main() -> Result<(), Box> { results.sort_by(|a, b| a.id.cmp(&b.id)); // ── Write errors.txt ────────────────────────────────────────────────────── - let out_path = "src/tests/errors.txt"; + // Generated output: target/ is already ignored and always exists after a build. + let out_path = "target/scan-wallpapers-errors.txt"; let mut out = fs::File::create(out_path)?; for r in &results { diff --git a/src/cli/apply.rs b/src/cli/apply.rs new file mode 100644 index 0000000..defa8a6 --- /dev/null +++ b/src/cli/apply.rs @@ -0,0 +1,220 @@ +//! Showing wallpapers: `set`, `set-file` and `run`, and the saved settings they +//! apply. + +use super::*; + +pub(super) fn cmd_set( + id: &str, + properties: Vec, + quality: Option, + save: bool, + foreground: bool, +) -> Result<()> { + let w = workshop::find_by_id(id).ok_or_else(|| anyhow!("workshop item '{id}' not found"))?; + + println!("Loading: {}", w.path.display()); + println!("Applying \"{}\" to all outputs...", w.title()); + + let mut context = ApplicationContext::new(w.path.clone()); + apply_saved_settings(&mut context, id, &properties, quality.as_deref(), save)?; + launch(context, Some(w.title().to_string()), foreground) +} + +/// Installs property overrides and a quality level on `context` in priority +/// order (saved < CLI flags — `--set-property`/`--quality` — later write +/// wins), and — when `save` is set and there's anything new to save — +/// persists the CLI-provided values under `id_or_path` (via `settings:: +/// normalize_key`) for next time. +pub(super) fn apply_saved_settings( + context: &mut ApplicationContext, + id_or_path: &str, + cli_properties: &[String], + cli_quality: Option<&str>, + save: bool, +) -> Result<()> { + let mut settings = wp_engine::settings::WpSettings::load(); + let saved = settings.property_overrides(id_or_path); + if !saved.is_empty() { + println!( + "Loaded {} saved property override(s) from {}", + saved.len(), + wp_engine::settings::settings_path().display() + ); + } + context.properties.extend(saved); + context.add_property_args(cli_properties); + + let saved_quality = settings.quality_override(id_or_path); + let cli_quality_parsed = cli_quality + .map(|s| { + wp_engine::platform::RenderQuality::parse(s).ok_or_else(|| { + anyhow!("invalid --quality '{s}' (expected Ultra, High, Medium, or Low)") + }) + }) + .transpose()?; + if let Some(q) = cli_quality_parsed.or(saved_quality) { + if cli_quality_parsed.is_none() { + println!("Loaded saved quality: {}", q.label()); + } + context.settings.lock().unwrap().quality = q; + } + + if save { + let mut saved_anything = false; + for arg in cli_properties { + let (name, value) = workshop::properties::parse_property_arg(arg); + settings.set_property(id_or_path, &name, &value); + saved_anything = true; + } + if !cli_properties.is_empty() { + println!( + "Saved {} property override(s) for next time", + cli_properties.len() + ); + } + if let Some(q) = cli_quality_parsed { + settings.set_quality(id_or_path, q); + println!("Saved quality override for next time: {}", q.label()); + saved_anything = true; + } + if saved_anything { + settings.save()?; + } + } + Ok(()) +} + +/// Runs using persisted settings (`wp-engine config` to set them up first): +/// every `screens` entry gets its own independent wallpaper *and* its own +/// independent render quality on its own output (real per-screen rendering +/// — see `content::ScreenContent`/`content::ScreenSettings`); any output with +/// no specific entry falls back to the first configured screen's wallpaper, +/// else `default_background`, else the first playlist's first item. +/// +/// Honest scope note: property overrides are still process-global (see +/// `workshop::properties`), so only the *default* background's saved +/// overrides are applied — a per-screen wallpaper's own saved overrides +/// aren't isolated from each other yet if two different screens' wallpapers +/// happen to declare a property with the same name. With an active +/// playlist (`config set-active-playlist`), the daemon plays that instead. +pub(super) fn cmd_run(foreground: bool) -> Result<()> { + let settings = wp_engine::settings::WpSettings::load(); + + #[cfg(target_os = "linux")] + if let (Some(name), false) = (&settings.active_playlist, foreground) { + cmd_playlist(PlaylistAction::Play { name: name.clone() })?; + println!("Playing playlist '{name}' (background daemon)."); + return Ok(()); + } + + let default_id_or_path = settings + .screens + .values() + .next() + .or(settings.default_background.as_ref()) + .or_else(|| { + settings + .playlists + .values() + .next() + .and_then(|p| p.items.first()) + }) + .ok_or_else(|| { + anyhow!( + "no saved wallpaper — use `wp-engine config set-screen`/`set-default`/\ + `set-playlist` first, or `wp-engine set ` directly" + ) + })? + .clone(); + let default_path = resolve_id_or_path(&default_id_or_path)?; + + println!("Default background: {default_id_or_path}"); + let mut context = ApplicationContext::new(default_path); + for (screen, id_or_path) in &settings.screens { + match resolve_id_or_path(id_or_path) { + Ok(path) => { + print!("Screen '{screen}': {id_or_path}"); + context.screens.insert(screen.clone(), path); + // Independent per-screen quality — looked up by the + // *original* id-or-path string (not the resolved path + // above): that's the same key `settings::normalize_key` + // would derive from it, and using the resolved path here + // instead would silently miss every workshop-ID-keyed + // saved override (a real bug this comment exists to head + // off — `normalize_key("2821407073")` and + // `normalize_key("/path/to/2821407073")` are NOT the same + // key). + if let Some(q) = settings.quality_override(id_or_path) { + println!(" (quality: {})", q.label()); + context.screen_settings.insert( + screen.clone(), + Arc::new(Mutex::new(wp_engine::content::RenderSettings { + quality: q, + fps: context.settings.lock().unwrap().fps, + ..Default::default() + })), + ); + } else { + println!(); + } + } + Err(e) => println!("Screen '{screen}': {id_or_path} — {e} (skipping)"), + } + } + + apply_saved_settings(&mut context, &default_id_or_path, &[], None, false)?; + launch(context, Some(default_id_or_path), foreground) +} + +/// Show `context`'s wallpaper: by default hand it to the single background +/// daemon (starting it if needed) and return, replacing whatever it was +/// showing; with `foreground`, render in this process until Ctrl-C. +/// +/// macOS always renders in the foreground — its backend needs the process +/// main thread, and its GUI launches wallpapers as `set-file` children. +pub(super) fn launch( + context: ApplicationContext, + title: Option, + foreground: bool, +) -> Result<()> { + #[cfg(target_os = "linux")] + { + use wp_engine::daemon::{self, ApplySpec, Request}; + if !foreground { + daemon::request_or_spawn(&Request::Apply(ApplySpec::from_context(&context, title)))?; + println!("Wallpaper active (background daemon). `wp-engine stop` removes it."); + return Ok(()); + } + if daemon::is_running() { + eprintln!( + "warning: a wp-engine daemon is also running — both will draw the wallpaper \ + (`wp-engine stop` stops it)" + ); + } + } + let _ = (&title, foreground); + let mut app = WallpaperApplication::new(context); + app.setup()?; + println!("Wallpaper active. Press Ctrl-C to exit."); + app.show() +} + +pub(super) fn cmd_set_file( + path: &PathBuf, + properties: Vec, + quality: Option, + save: bool, + foreground: bool, +) -> Result<()> { + if !path.exists() { + return Err(anyhow!("file not found: {}", path.display())); + } + println!("Loading: {}", path.display()); + println!("Applying to all outputs…"); + + let mut context = ApplicationContext::new(path.clone()); + let key = path.display().to_string(); + apply_saved_settings(&mut context, &key, &properties, quality.as_deref(), save)?; + let title = path.file_name().map(|n| n.to_string_lossy().into_owned()); + launch(context, title, foreground) +} diff --git a/src/cli/config.rs b/src/cli/config.rs new file mode 100644 index 0000000..df9f48b --- /dev/null +++ b/src/cli/config.rs @@ -0,0 +1,193 @@ +//! Changing saved settings: `config` and `autostart`. + +use super::*; + +pub(super) fn cmd_config(action: ConfigAction) -> Result<()> { + use wp_engine::settings::{Playlist, WpSettings}; + + let mut settings = WpSettings::load(); + // Property edits to show live on a running daemon once saved. + let mut live: Option<(String, Vec)> = None; + + match action { + ConfigAction::Show => { + let path = wp_engine::settings::settings_path(); + println!("Settings file: {}", path.display()); + if !path.exists() { + println!("(nothing saved yet)"); + return Ok(()); + } + println!("{}", serde_json::to_string_pretty(&settings)?); + return Ok(()); + } + ConfigAction::SetProperty { id, properties } => { + let mut names = Vec::new(); + for arg in &properties { + let (name, value) = workshop::properties::parse_property_arg(arg); + println!("{id}: {name} = {value}"); + settings.set_property(&id, &name, &value); + names.push(name); + } + live = Some((id, names)); + } + ConfigAction::UnsetProperty { id, name } => { + if settings.unset_property(&id, &name) { + println!("Removed {id}: {name}"); + live = Some((id, vec![name])); + } else { + println!("No saved override for {id}: {name}"); + return Ok(()); + } + } + ConfigAction::SetScreen { screen, id } => { + settings.set_screen(&screen, &id); + println!("{screen} -> {id}"); + } + ConfigAction::UnsetScreen { screen } => { + if settings.unset_screen(&screen) { + println!("Removed screen assignment: {screen}"); + } else { + println!("No saved assignment for screen: {screen}"); + return Ok(()); + } + } + ConfigAction::SetDefault { id } => { + settings.set_default_background(&id); + println!("Default background -> {id}"); + } + ConfigAction::SetQuality { id, quality } => { + let q = platform::RenderQuality::parse(&quality).ok_or_else(|| { + anyhow!("invalid quality '{quality}' (expected Ultra, High, Medium, or Low)") + })?; + settings.set_quality(&id, q); + println!("{id}: quality = {}", q.label()); + } + ConfigAction::UnsetQuality { id } => { + if settings.unset_quality(&id) { + println!("Removed quality override for {id}"); + } else { + println!("No saved quality override for {id}"); + return Ok(()); + } + } + ConfigAction::SetPlaylist { + name, + items, + delay, + mode, + order, + times, + } => { + if !["timer", "daytime", "dayofweek", "login"].contains(&mode.as_str()) { + return Err(anyhow!( + "invalid mode '{mode}' (expected timer, daytime, dayofweek or login)" + )); + } + if !["sequential", "random"].contains(&order.as_str()) { + return Err(anyhow!( + "invalid order '{order}' (expected sequential or random)" + )); + } + if mode == "daytime" { + if times.len() != items.len() { + return Err(anyhow!("daytime mode needs one --times entry per item")); + } + if let Some(bad) = times + .iter() + .find(|t| wp_engine::schedule::parse_time_of_day(t).is_none()) + { + return Err(anyhow!("invalid time '{bad}' (expected HH:MM)")); + } + } + println!("Playlist '{name}': {} item(s)", items.len()); + settings.playlists.insert( + name, + Playlist { + items, + delay_minutes: delay, + mode, + order, + times, + }, + ); + } + ConfigAction::SetActivePlaylist { name } => { + if let Some(n) = &name { + if !settings.playlists.contains_key(n) { + return Err(anyhow!("no playlist named '{n}'")); + } + println!("`wp-engine run` plays playlist '{n}'"); + } else { + println!("No active playlist"); + } + settings.active_playlist = name; + } + ConfigAction::SetPlayback { condition, action } => { + let a = wp_engine::services::playback_gate::PlaybackAction::parse(&action).ok_or_else( + || anyhow!("invalid action '{action}' (expected run, mute, pause or stop)"), + )?; + if !settings.playback.set(&condition, a) { + return Err(anyhow!( + "invalid condition '{condition}' (expected focus, maximized, fullscreen, audio, battery or lock)" + )); + } + println!("While {condition}: {}", a.label()); + } + ConfigAction::SetScaling { mode } => { + let m = wp_engine::content::ScaleMode::parse(&mode).ok_or_else(|| { + anyhow!("invalid mode '{mode}' (expected fill, fit, stretch or center)") + })?; + settings.scaling = Some(m); + println!("Scaling: {}", m.label()); + } + ConfigAction::SetLayout { layout } => { + let l = wp_engine::content::ScreenLayout::parse(&layout).ok_or_else(|| { + anyhow!("invalid layout '{layout}' (expected per-screen, span or clone)") + })?; + settings.layout = Some(l); + println!( + "Layout: {} (applies the next time a wallpaper is set)", + l.label() + ); + } + ConfigAction::SetFps { fps } => { + if !(1..=240).contains(&fps) { + return Err(anyhow!("fps must be between 1 and 240")); + } + settings.fps = Some(fps); + println!("Frame-rate cap: {fps} fps"); + } + ConfigAction::RemovePlaylist { name } => { + if settings.playlists.remove(&name).is_some() { + println!("Removed playlist: {name}"); + } else { + println!("No such playlist: {name}"); + return Ok(()); + } + } + } + + settings.save()?; + if let Some((wallpaper, names)) = live.filter(|_| wp_engine::daemon::is_running()) { + let _ = wp_engine::daemon::request(&wp_engine::daemon::Request::PropertiesChanged { + wallpaper, + names, + }); + } + Ok(()) +} + +/// [`resolve_id_or_path`] for the scene tools: a preset item resolves to the +/// item it builds on, with the preset's values registered as overrides. +pub(super) fn cmd_autostart(state: &str) -> Result<()> { + if state != "status" { + wp_engine::settings::set_autostart(state == "on")?; + } + if wp_engine::settings::autostart_enabled() { + let path = wp_engine::settings::autostart_path(); + println!("Autostart on ({})", path.display()); + } else { + println!("Autostart off"); + } + Ok(()) +} diff --git a/src/cli/daemon.rs b/src/cli/daemon.rs new file mode 100644 index 0000000..8cee5b5 --- /dev/null +++ b/src/cli/daemon.rs @@ -0,0 +1,89 @@ +//! Talking to the background daemon: `daemon`, `status`, `stop` and `playlist` +//! (Linux; elsewhere they report the daemon is unavailable). + +use super::*; + +#[cfg(target_os = "linux")] +pub(super) fn cmd_daemon() -> Result<()> { + wp_engine::daemon::run_daemon() +} + +#[cfg(target_os = "linux")] +pub(super) fn cmd_status() -> Result<()> { + use wp_engine::daemon::{self, Request, Response}; + if !daemon::is_running() { + println!("Daemon: not running"); + return Ok(()); + } + let Response::Status(status) = daemon::request(&Request::Status)? else { + return Err(anyhow!("unexpected daemon reply to status")); + }; + println!("Daemon: running (pid {})", status.pid); + match status.active { + Some(active) => { + if let Some(title) = &active.title { + println!("Wallpaper: {title}"); + } + println!("Path: {}", active.background.display()); + println!("Quality: {}", active.quality); + } + None => println!("Wallpaper: none"), + } + if let Some((name, index, count)) = status.playlist { + println!("Playlist: {name} (item {} of {count})", index + 1); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +pub(super) fn cmd_stop() -> Result<()> { + use wp_engine::daemon::{self, Request}; + if !daemon::is_running() { + println!("Daemon: not running"); + return Ok(()); + } + daemon::request(&Request::Shutdown)?; + println!("Daemon stopped."); + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub(super) fn cmd_daemon() -> Result<()> { + Err(anyhow!("the background daemon is Linux-only")) +} + +#[cfg(target_os = "linux")] +pub(super) fn cmd_playlist(action: PlaylistAction) -> Result<()> { + use wp_engine::daemon::{self, Request, Response}; + let req = match &action { + PlaylistAction::Play { name } => Request::PlayPlaylist { name: name.clone() }, + PlaylistAction::Next => Request::NextItem, + PlaylistAction::Previous => Request::PreviousItem, + PlaylistAction::Stop => Request::StopPlaylist, + }; + let reply = if matches!(action, PlaylistAction::Play { .. }) { + daemon::request_or_spawn(&req)? + } else { + daemon::request(&req)? + }; + if let Response::Error { message } = reply { + return Err(anyhow!(message)); + } + println!("OK"); + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub(super) fn cmd_playlist(_action: PlaylistAction) -> Result<()> { + cmd_daemon() +} + +#[cfg(not(target_os = "linux"))] +pub(super) fn cmd_status() -> Result<()> { + cmd_daemon() +} + +#[cfg(not(target_os = "linux"))] +pub(super) fn cmd_stop() -> Result<()> { + cmd_daemon() +} diff --git a/src/cli/debug.rs b/src/cli/debug.rs new file mode 100644 index 0000000..2f06fce --- /dev/null +++ b/src/cli/debug.rs @@ -0,0 +1,357 @@ +//! Inspection and debugging tools: `probe`, `pkg-info`, `tex-info`, `render- +//! scene`, `render-frame`, `test-scene` and `preview-scene`. + +use super::*; + +pub(super) fn cmd_probe() -> Result<()> { + // ── 1. List all visible adapters ───────────────────────────────────────── + let adapters = platform::probe_adapters(); + if adapters.is_empty() { + println!("No GPU adapters found."); + return Ok(()); + } + println!( + "{:<40} {:<10} {:<12} {}", + "Name", "Backend", "Type", "PCI IDs" + ); + println!("{}", "─".repeat(74)); + for a in &adapters { + println!( + "{:<40} {:<10} {:<12} {:04x}:{:04x}", + a.name, a.backend, a.device_type, a.vendor_id, a.device_id + ); + } + println!("\n{} adapter(s) found\n", adapters.len()); + let outputs = platform::display::output_names(); + if !outputs.is_empty() { + println!( + "Screens (for `config set-screen`): {}\n", + outputs.join(", ") + ); + } + + // ── 2. Open the best device to confirm driver access works ─────────────── + print!("Opening best device... "); + match platform::open_device() { + Ok(dev) => println!( + "OK → {} ({}, {})", + dev.info.name, dev.info.backend, dev.info.device_type + ), + Err(e) => println!("FAILED: {e}"), + } + + Ok(()) +} + +pub(super) fn cmd_pkg_info(path: &std::path::Path, dump: Option<&std::path::Path>) -> Result<()> { + let pkg = formats::pkg::Package::from_file(path)?; + + println!("Files : {}", pkg.len()); + println!("Empty : {}", pkg.is_empty()); + println!( + "Scene : {}", + if pkg.contains("scene.json") { + "yes" + } else { + "no" + } + ); + println!(); + + // Collect and sort paths for a stable, readable table. + let mut paths: Vec<&str> = pkg.paths().collect(); + paths.sort_unstable(); + + for p in &paths { + let size_kb = pkg.get(p).map(|b| b.len()).unwrap_or(0) / 1024; + println!("{size_kb:>6} KB {p}"); + } + + if let Some(dump_dir) = dump { + std::fs::create_dir_all(dump_dir) + .with_context(|| format!("failed to create dump directory: {}", dump_dir.display()))?; + + let mut count = 0usize; + for p in &paths { + if let Some(bytes) = pkg.get(p) { + let dest = dump_dir.join(p); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create directory: {}", parent.display()) + })?; + } + std::fs::write(&dest, bytes) + .with_context(|| format!("failed to write: {}", dest.display()))?; + count += 1; + } + } + println!("\nExtracted {count} files to {}", dump_dir.display()); + } + + Ok(()) +} + +pub(super) fn cmd_tex_info(path: &std::path::Path, save: Option<&std::path::Path>) -> Result<()> { + let data = std::fs::read(path)?; + let tex = formats::tex::TexFile::parse(&data)?; + println!("Format: {:?}", tex.format()); + println!("Image size: {}x{}", tex.image_width, tex.image_height); + println!("Texture size: {}x{}", tex.texture_width, tex.texture_height); + println!("Flags: 0x{:x}", tex.flags()); + println!( + "Frames: {} (animated: {})", + tex.frames().len(), + tex.is_animated() + ); + if let Some(out) = save { + let img = tex.to_rgba()?; + img.save(out)?; + println!("Saved to {}", out.display()); + } + Ok(()) +} + +pub(super) fn cmd_render_scene( + id_or_path: &str, + output: &std::path::Path, + properties: Vec, +) -> Result<()> { + let overrides = properties + .iter() + .map(|p| workshop::properties::parse_property_arg(p)) + .collect(); + workshop::properties::set_global_overrides(overrides); + + let dir = resolve_scene_dir(id_or_path)?; + println!("Loading scene from {}...", dir.display()); + let graph = engine::SceneGraph::from_directory(&dir)?; + println!("Scene graph: {}", graph.stats().summary()); + + let scene = engine::ResolvedScene::from_directory(&dir)?; + println!( + "Rendering {}x{} with {} layers...", + scene.width, + scene.height, + scene.layers.len() + ); + for layer in scene.layers.iter().take(3) { + println!( + "Layer: {} origin={:.1},{:.1},{:.1} size={:.1},{:.1},{:.1} parallax={:.3},{:.3}", + if layer.name.is_empty() { + "" + } else { + &layer.name + }, + layer.origin[0], + layer.origin[1], + layer.origin[2], + layer.size[0], + layer.size[1], + layer.size[2], + layer.parallax_depth[0], + layer.parallax_depth[1], + ); + } + let img = scene.render(); + img.save(output)?; + println!("Saved to {}", output.display()); + Ok(()) +} + +pub(super) fn cmd_render_frame( + id_or_path: &str, + output: &std::path::Path, + size: (u32, u32), + fps: u32, + seconds: f32, + properties: Vec, +) -> Result<()> { + workshop::properties::set_global_overrides( + properties + .iter() + .map(|p| workshop::properties::parse_property_arg(p)) + .collect(), + ); + let path = resolve_id_or_path(id_or_path)?; + let content = wp_engine::content::WallpaperContent::from_any_path(&path)?; + let mut source = wp_engine::content::FrameSource::from_content_with( + content, + wp_engine::content::web::WebOptions { size, fps }, + )?; + let until = std::time::Instant::now() + std::time::Duration::from_secs_f32(seconds.max(0.0)); + while std::time::Instant::now() < until { + source.try_advance(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let frame = source.current_frame(); + frame.save(output)?; + println!( + "Saved {}x{} frame to {}", + frame.width(), + frame.height(), + output.display() + ); + Ok(()) +} + +pub(super) fn cmd_test_scene(id_or_path: &str, num_frames: usize) -> Result<()> { + use std::sync::{mpsc::sync_channel, Arc}; + + let dir = resolve_scene_dir(id_or_path)?; + + println!( + "GPU scene animation test: collecting {num_frames} frames from {}", + dir.display() + ); + let (tx, rx) = sync_channel::>(2); + let render_dir = dir.clone(); + let handle = std::thread::spawn(move || { + if let Err(e) = engine::gpu::renderer::gpu_scene_render_loop(&render_dir, &tx, 30.0) { + tracing::error!(target: "render", "gpu scene error: {e}"); + } + }); + + let mut collected = Vec::with_capacity(num_frames); + for i in 0..num_frames { + match rx.recv() { + Ok(frame) => { + if i == 0 || i == num_frames / 2 || i == num_frames - 1 { + eprintln!(" frame {i}: {}x{}", frame.width(), frame.height()); + } + collected.push(frame); + } + Err(_) => { + eprintln!(" channel closed after {} frames", collected.len()); + break; + } + } + } + + // Drop the receiver so the render loop's next `send` fails and it returns, + // then join it: letting the process exit while it's still mid-frame (still + // holding the wgpu device/queue) can crash the driver on shutdown. + drop(rx); + let _ = handle.join(); + + // Headless GPU-frame capture for debugging "renders weirdly" reports + // (the only way to see a perspective/live-path frame off-screen). + if let Ok(path) = std::env::var("WP_DEBUG_DUMP_FRAME") { + if let Some(f) = collected.last() { + let _ = f.save(&path); + eprintln!(" saved last frame to {path}"); + } + } + + if collected.len() < 2 { + anyhow::bail!("not enough frames collected (got {})", collected.len()); + } + + // Compare frame[0] against every other frame; take the maximum change found. + // Using max rather than first-vs-last avoids false negatives when a periodic + // effect's endpoints happen to land near the same phase. + let first = collected.first().unwrap(); + let first_bytes = first.as_raw(); + let total_pixels = (first.width() * first.height()) as usize; + + fn count_changed(a: &[u8], b: &[u8]) -> usize { + a.chunks(4) + .zip(b.chunks(4)) + .filter(|(a, b)| { + let dr = (a[0] as i32 - b[0] as i32).abs(); + let dg = (a[1] as i32 - b[1] as i32).abs(); + let db = (a[2] as i32 - b[2] as i32).abs(); + dr + dg + db > 6 + }) + .count() + } + + let (best_frame, changed_pixels) = collected + .iter() + .enumerate() + .skip(1) + .map(|(i, f)| (i, count_changed(first_bytes, f.as_raw()))) + .max_by_key(|(_, n)| *n) + .unwrap_or((0, 0)); + + let pct = changed_pixels as f64 / total_pixels as f64 * 100.0; + println!( + "\nCollected {} frames at 30fps (~{:.1}s)", + collected.len(), + collected.len() as f64 / 30.0 + ); + println!("Changed pixels (frame[0] vs frame[{best_frame}]): {changed_pixels}/{total_pixels} ({pct:.1}%)"); + + if pct > 0.1 { + println!("PASS: scene IS animating ({pct:.1}% of pixels changed)"); + } else { + println!( + "FAIL: scene appears static (only {pct:.2}% changed — likely effect/rendering bug)" + ); + } + + // Save first and best-changed frames for visual inspection + let last = collected.last().unwrap(); + first.save("/tmp/wp_frame_first.png")?; + last.save("/tmp/wp_frame_last.png")?; + println!("Saved /tmp/wp_frame_first.png and /tmp/wp_frame_last.png for visual comparison"); + + Ok(()) +} + +pub(super) fn cmd_preview_scene(id_or_path: &str, width: u32, height: u32) -> Result<()> { + use image::imageops::FilterType; + use minifb::{Key, Window, WindowOptions}; + use std::sync::{mpsc::sync_channel, Arc}; + + let dir = resolve_scene_dir(id_or_path)?; + + println!("Loading scene from {}...", dir.display()); + let (tx, rx) = sync_channel::>(2); + let render_dir = dir.clone(); + let handle = std::thread::spawn(move || { + if let Err(e) = engine::gpu::renderer::gpu_scene_render_loop(&render_dir, &tx, 30.0) { + tracing::error!(target: "render", "gpu scene error: {e}"); + } + }); + + println!("Waiting for first frame..."); + let mut current = rx.recv()?; + println!( + "First frame received — opening {}x{} preview window. Press Esc to quit.", + width, height + ); + + let mut window = Window::new( + "wp-engine preview", + width as usize, + height as usize, + WindowOptions::default(), + )?; + + while window.is_open() && !window.is_key_down(Key::Escape) { + if let Ok(frame) = rx.try_recv() { + current = frame; + } + + let scaled = image::imageops::resize(&*current, width, height, FilterType::Nearest); + let buf: Vec = scaled + .pixels() + .map(|p| { + let [r, g, b, _a] = p.0; + (r as u32) << 16 | (g as u32) << 8 | b as u32 + }) + .collect(); + + window + .update_with_buffer(&buf, width as usize, height as usize) + .unwrap_or(()); + std::thread::sleep(std::time::Duration::from_millis(16)); + } + + // See cmd_test_scene: join the render thread before exiting so it isn't + // killed mid-frame while still holding the wgpu device/queue. + drop(rx); + let _ = handle.join(); + + Ok(()) +} diff --git a/src/cli/library.rs b/src/cli/library.rs new file mode 100644 index 0000000..d1fbc52 --- /dev/null +++ b/src/cli/library.rs @@ -0,0 +1,126 @@ +//! Browsing what is installed: `list`, `info` and `list-properties`. + +use super::*; + +pub(super) fn cmd_list(type_filter: Option) -> Result<()> { + let wallpapers = workshop::scan_wallpapers(); + + if wallpapers.is_empty() { + println!("No Wallpaper Engine Workshop items found."); + println!(); + println!( + "Expected path: ~/.local/share/Steam/steamapps/workshop/content/{}/", + workshop::WALLPAPER_ENGINE_APP_ID + ); + return Ok(()); + } + + let items: Vec<&Wallpaper> = wallpapers + .iter() + .filter(|w| { + type_filter + .as_deref() + .map(|f| w.wallpaper_type().to_string() == f) + .unwrap_or(true) + }) + .collect(); + + println!("{:<20} {:<12} {}", "ID", "Type", "Title"); + println!("{}", "─".repeat(64)); + for w in &items { + println!( + "{:<20} {:<12} {}", + w.workshop_id, + w.wallpaper_type(), + w.title() + ); + } + println!("\n{} wallpaper(s)", items.len()); + Ok(()) +} + +pub(super) fn cmd_info(id: &str) -> Result<()> { + let w = workshop::find_by_id(id).ok_or_else(|| anyhow!("workshop item '{id}' not found"))?; + + println!("ID: {}", w.workshop_id); + println!("Title: {}", w.title()); + println!("Type: {}", w.wallpaper_type()); + println!("Path: {}", w.path.display()); + if let Some(f) = w.wallpaper_file() { + println!( + "File: {} {}", + f.display(), + if f.exists() { "" } else { "(MISSING)" } + ); + } + if let Some(tags) = &w.project.tags { + if !tags.is_empty() { + println!("Tags: {}", tags.join(", ")); + } + } + if let Some(rating) = &w.project.contentrating { + if !rating.is_empty() { + println!("Rating: {rating}"); + } + } + if let Some(description) = &w.project.description { + if !description.is_empty() { + println!("Description: {}", description.replace('\n', " ")); + } + } + Ok(()) +} + +pub(super) fn cmd_list_properties(id: &str) -> Result<()> { + let w = workshop::find_by_id(id).ok_or_else(|| anyhow!("workshop item '{id}' not found"))?; + let all_props = workshop::properties::list_properties(&w.path)?; + if all_props.is_empty() { + println!( + "\"{}\" declares no user-configurable properties.", + w.title() + ); + return Ok(()); + } + // Decorative declarations (HTML separators/section headers — real + // content ships plenty of these) aren't things a user can actually + // set; listing them as if they were is misleading, not just noisy. + let (settable, decorative): (Vec<_>, Vec<_>) = + all_props.into_iter().partition(|p| p.is_settable()); + + if settable.is_empty() { + println!( + "\"{}\" declares no settable properties ({} decorative label(s) only).", + w.title(), + decorative.len() + ); + return Ok(()); + } + + println!("{:<28} {:<8} {:<24} {}", "Name", "Type", "Default", "Label"); + println!("{}", "─".repeat(80)); + for p in &settable { + println!("{:<28} {:<8} {:<24} {}", p.name, p.kind, p.value, p.text); + if p.kind == "combo" && !p.options.is_empty() { + let choices = p + .options + .iter() + .map(|(value, label)| format!("{value}={label}")) + .collect::>() + .join(", "); + println!("{:<28} {:<8} choices: {choices}", "", ""); + } else if p.kind == "slider" { + if let (Some(min), Some(max)) = (p.min, p.max) { + let step = p.step.map(|s| format!(", step {s}")).unwrap_or_default(); + println!("{:<28} {:<8} range: {min}..={max}{step}", "", ""); + } + } + } + if !decorative.is_empty() { + println!( + "\n({} decorative label(s) not shown — no real value to set)", + decorative.len() + ); + } + println!("\nOverride with: wp-engine set {id} --set-property NAME=VALUE"); + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..ee76574 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,389 @@ +//! The `wp-engine` command line: argument parsing and dispatch, with the +//! commands split by area into this module's files. + +use anyhow::{anyhow, Context, Result}; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use wp_engine::application::{ApplicationContext, WallpaperApplication}; +use wp_engine::workshop::{self, Wallpaper}; +use wp_engine::{engine, formats, platform, ui}; + +mod library; +use library::*; + +mod apply; +use apply::*; + +mod config; +use config::*; + +mod daemon; +use daemon::*; + +mod debug; +use debug::*; + +#[derive(Parser)] +#[command( + name = "wp-engine", + about = "Wallpaper Engine client for Wayland", + long_about = "Browse and apply Steam Workshop wallpapers on Wayland, X11, and macOS.\n\ + Run without arguments to open the graphical interface." +)] +struct Cli { + #[command(subcommand)] + command: Option, + + /// Increase logging verbosity: -v (debug), -vv (trace), -vvv (+ deps). + /// `RUST_LOG` overrides this if set. + #[arg( + short, + long, + action = clap::ArgAction::Count, + global = true, + )] + verbose: u8, +} + +#[derive(Subcommand)] +enum Command { + /// List available Workshop wallpapers (CLI) + List { + #[arg(long, help = "Filter by type: video, scene, web, application")] + r#type: Option, + }, + /// Apply a wallpaper by Steam Workshop ID (via the background daemon) + Set { + id: String, + /// Override a user property: NAME=VALUE (repeatable; bare NAME = true) + #[arg(long = "set-property", value_name = "NAME=VALUE")] + properties: Vec, + /// Render quality for this run: Ultra, High, Medium, or Low + #[arg(long)] + quality: Option, + /// Persist these --set-property/--quality overrides for next time (see `config`) + #[arg(long)] + save: bool, + /// Render in this process until Ctrl-C instead of handing the + /// wallpaper to the background daemon + #[arg(long)] + foreground: bool, + }, + /// Apply a scene directory, video, image, or HTML file (via the background daemon) + SetFile { + path: PathBuf, + /// Override a user property: NAME=VALUE (repeatable; bare NAME = true) + #[arg(long = "set-property", value_name = "NAME=VALUE")] + properties: Vec, + /// Render quality for this run: Ultra, High, Medium, or Low + #[arg(long)] + quality: Option, + /// Persist these --set-property/--quality overrides for next time (see `config`) + #[arg(long)] + save: bool, + /// Render in this process until Ctrl-C instead of handing the + /// wallpaper to the background daemon + #[arg(long)] + foreground: bool, + }, + /// List the user-configurable properties of a Workshop item + ListProperties { id: String }, + /// Show metadata for a Workshop item (CLI) + Info { id: String }, + /// List available GPU adapters (PAL probe) + Probe, + /// Inspect (and optionally extract) a Wallpaper Engine PKG archive + PkgInfo { + path: PathBuf, + /// Dump all contained files to this directory + #[arg(long)] + dump: Option, + }, + /// Inspect a Wallpaper Engine .tex texture file + TexInfo { + path: PathBuf, + /// Save decoded texture as PNG + #[arg(long)] + save: Option, + }, + /// Render a scene wallpaper to a PNG file (for debugging) + RenderScene { + /// Workshop ID or directory path + id_or_path: String, + /// Output PNG path + #[arg(long, default_value = "scene_output.png")] + output: PathBuf, + /// Override a user property: NAME=VALUE (repeatable; bare NAME = true) + #[arg(long = "set-property", value_name = "NAME=VALUE")] + properties: Vec, + }, + /// Render a video, image or web wallpaper's frame to a PNG at a given + /// size (for debugging) + RenderFrame { + /// Workshop ID, directory, or file + id_or_path: String, + #[arg(long, default_value = "frame_output.png")] + output: PathBuf, + #[arg(long, default_value_t = 1920)] + width: u32, + #[arg(long, default_value_t = 1080)] + height: u32, + /// Frame rate handed to web pages + #[arg(long, default_value_t = 30)] + fps: u32, + /// Keep rendering this long before saving the latest frame + #[arg(long, default_value_t = 2.0)] + seconds: f32, + /// Override a user property: NAME=VALUE (repeatable) + #[arg(long = "set-property", value_name = "NAME=VALUE")] + properties: Vec, + }, + /// Preview an animated scene wallpaper in a window (for testing animation) + PreviewScene { + /// Workshop ID or directory path + id_or_path: String, + /// Window width + #[arg(long, default_value_t = 960)] + width: u32, + /// Window height + #[arg(long, default_value_t = 540)] + height: u32, + }, + /// Test whether a scene wallpaper animates (headless — no window required) + TestScene { + /// Workshop ID or directory path + id_or_path: String, + /// Number of frames to collect (at 30fps, 60 = 2 seconds) + #[arg(long, default_value_t = 60)] + frames: usize, + }, + /// Manage persisted settings: per-wallpaper property overrides, screen + /// assignments, and playlists (~/.config/wp-engine/settings.json) + Config { + #[command(subcommand)] + action: ConfigAction, + }, + /// Run using persisted settings — see `config` to set them up first + Run { + /// Render in this process until Ctrl-C instead of handing the + /// wallpaper to the background daemon + #[arg(long)] + foreground: bool, + }, + /// Run the single-instance wallpaper daemon in the foreground (it is + /// started automatically in the background when needed; run it directly + /// for systemd/autostart or to watch its log) + Daemon, + /// Show what the background daemon is displaying + Status, + /// Remove the wallpaper and stop the background daemon + Stop, + /// Control playlist playback in the background daemon + Playlist { + #[command(subcommand)] + action: PlaylistAction, + }, + /// Start `wp-engine run` at login: on, off, or show the current state + Autostart { + #[arg(value_parser = ["on", "off", "status"], default_value = "status")] + state: String, + }, +} + +#[derive(Subcommand)] +enum PlaylistAction { + /// Start playing a saved playlist (see `config set-playlist`) + Play { name: String }, + /// Skip to the next item + Next, + /// Go back to the previous item + Previous, + /// Stop rotating, keeping the current wallpaper + Stop, +} + +#[derive(Subcommand)] +enum ConfigAction { + /// Print the full persisted settings file + Show, + /// Persist a property override for a wallpaper without launching it + SetProperty { + id: String, + /// NAME=VALUE (repeatable; bare NAME = true) + #[arg(required = true, value_name = "NAME=VALUE")] + properties: Vec, + }, + /// Remove a persisted property override + UnsetProperty { id: String, name: String }, + /// Assign a wallpaper to a named screen/output + SetScreen { screen: String, id: String }, + /// Remove a screen's saved wallpaper assignment + UnsetScreen { screen: String }, + /// Set the fallback wallpaper `wp-engine run` uses with no screen assigned + SetDefault { id: String }, + /// Persist a render-quality override for a wallpaper: Ultra, High, Medium, or Low + SetQuality { id: String, quality: String }, + /// Remove a persisted render-quality override + UnsetQuality { id: String }, + /// Define or replace a playlist + SetPlaylist { + name: String, + /// Workshop IDs or paths, in playback order + #[arg(required = true)] + items: Vec, + #[arg(long, default_value_t = 60)] + delay: u32, + /// timer (every --delay minutes), daytime (each item at its --times + /// entry), dayofweek (item N on weekday N, Monday first), or login + /// (next item each time playback starts) + #[arg(long, default_value = "timer")] + mode: String, + /// sequential or random + #[arg(long, default_value = "sequential")] + order: String, + /// daytime mode: comma-separated start times, one per item ("07:00,19:30") + #[arg(long, value_delimiter = ',')] + times: Vec, + }, + /// Make `wp-engine run` play this playlist (omit NAME to clear) + SetActivePlaylist { name: Option }, + /// Remove a playlist + RemovePlaylist { name: String }, + /// Set what happens while a condition holds: CONDITION is focus, + /// maximized, fullscreen, audio, battery or lock; ACTION is run, mute, + /// pause or stop (pause and free memory) + SetPlayback { condition: String, action: String }, + /// Cap the frame rate of animated wallpapers (1-240) + SetFps { fps: u32 }, + /// How video, image and web wallpapers fit the screen: fill (crop, + /// default), fit (black bars), stretch, or center + SetScaling { mode: String }, + /// How one wallpaper covers several monitors: per-screen (default; see + /// set-screen), span (stretched across all of them) or clone (the same + /// picture on each) + SetLayout { layout: String }, +} + +pub fn main() { + // CEF launches its renderer/GPU/utility processes by re-executing THIS + // binary with `--type=...`. That must be handled before anything else — + // before clap, which would reject the flag, and before any thread starts. + // Returns `Some` only in a child process, which must then exit at once. + if let Some(code) = wp_engine::content::web::subprocess_main() { + std::process::exit(code); + } + + let cli = Cli::parse(); + wp_engine::logging::init(cli.verbose); + #[cfg(target_os = "linux")] + wp_engine::daemon::set_spawn_verbosity(cli.verbose); + tracing::debug!(target: "cli", verbosity = cli.verbose, "wp-engine starting"); + + let result = match cli.command { + // No subcommand → open GUI + None => run_ui(), + Some(Command::List { r#type }) => cmd_list(r#type), + Some(Command::Set { + id, + properties, + quality, + save, + foreground, + }) => cmd_set(&id, properties, quality, save, foreground), + Some(Command::SetFile { + path, + properties, + quality, + save, + foreground, + }) => cmd_set_file(&path, properties, quality, save, foreground), + Some(Command::ListProperties { id }) => cmd_list_properties(&id), + Some(Command::Info { id }) => cmd_info(&id), + Some(Command::Probe) => cmd_probe(), + Some(Command::PkgInfo { path, dump }) => cmd_pkg_info(&path, dump.as_deref()), + Some(Command::TexInfo { path, save }) => cmd_tex_info(&path, save.as_deref()), + Some(Command::RenderScene { + id_or_path, + output, + properties, + }) => cmd_render_scene(&id_or_path, &output, properties), + Some(Command::PreviewScene { + id_or_path, + width, + height, + }) => cmd_preview_scene(&id_or_path, width, height), + Some(Command::TestScene { id_or_path, frames }) => cmd_test_scene(&id_or_path, frames), + Some(Command::RenderFrame { + id_or_path, + output, + width, + height, + fps, + seconds, + properties, + }) => cmd_render_frame( + &id_or_path, + &output, + (width, height), + fps, + seconds, + properties, + ), + Some(Command::Config { action }) => cmd_config(action), + Some(Command::Run { foreground }) => cmd_run(foreground), + Some(Command::Daemon) => cmd_daemon(), + Some(Command::Status) => cmd_status(), + Some(Command::Stop) => cmd_stop(), + Some(Command::Playlist { action }) => cmd_playlist(action), + Some(Command::Autostart { state }) => cmd_autostart(&state), + }; + + if let Err(e) = result { + eprintln!("error: {e:#}"); + std::process::exit(1); + } +} + +// ── GUI ─────────────────────────────────────────────────────────────────────── + +fn run_ui() -> Result<()> { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("wp-engine") + .with_inner_size([1100.0, 700.0]) + .with_min_inner_size([800.0, 500.0]), + ..Default::default() + }; + + eframe::run_native( + "wp-engine", + options, + Box::new(|cc| Ok(Box::new(ui::WpApp::new(cc)))), + ) + .map_err(|e| anyhow!("GUI error: {e}")) +} + +// ── CLI commands ────────────────────────────────────────────────────────────── + +/// A workshop ID or a filesystem path, resolved to a real path on disk — +/// the same "ID first, then treat it as a path" rule `test-scene`/ +/// `render-scene` already use. +fn resolve_id_or_path(id_or_path: &str) -> Result { + if let Some(w) = workshop::find_by_id(id_or_path) { + return Ok(w.path); + } + let path = PathBuf::from(id_or_path); + if path.exists() { + Ok(path) + } else { + Err(anyhow!( + "not a directory and workshop item '{id_or_path}' not found" + )) + } +} + +fn resolve_scene_dir(id_or_path: &str) -> Result { + let dir = resolve_id_or_path(id_or_path)?; + wp_engine::content::WallpaperContent::preset_base_dir(&dir) +} diff --git a/src/render/frame.rs b/src/content/frame.rs similarity index 88% rename from src/render/frame.rs rename to src/content/frame.rs index c933d10..0e044c8 100644 --- a/src/render/frame.rs +++ b/src/content/frame.rs @@ -4,7 +4,7 @@ use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; use std::sync::Arc; use std::thread; -use super::content::WallpaperContent; +use crate::content::wallpaper::WallpaperContent; /// A live source of RGBA frames for the Wayland renderer. /// @@ -20,12 +20,12 @@ pub enum FrameSource { /// Receive end of the bounded channel from the decoder thread. rx: Receiver>, /// `Some` only for a web wallpaper — lets the platform layer forward - /// mouse input into the embedded page (see `render::web::WebInputEvent`). + /// mouse input into the embedded page (see `content::web::WebInputEvent`). /// `None` for real video and CPU-scene-fallback, which share this /// same variant but have no page to receive input. web_input: Option>, /// A video's soundtrack, playing while this source lives. - audio: Option, + audio: Option, }, } @@ -52,15 +52,15 @@ impl FrameSource { thread::spawn(move || { let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - crate::engine::gpu_renderer::gpu_scene_render_loop(&dir, &tx, target_fps) + crate::engine::gpu::renderer::gpu_scene_render_loop(&dir, &tx, target_fps) })); match r { Ok(Ok(())) => {} Ok(Err(e)) => { tracing::warn!(target: "render", "GPU scene render failed, falling back to CPU: {e}"); - if let Err(e2) = - crate::engine::animated::scene_render_loop(&dir, &tx, target_fps) - { + if let Err(e2) = crate::engine::cpu::animated::scene_render_loop( + &dir, &tx, target_fps, + ) { tracing::error!(target: "render", "CPU scene render error: {e2}"); } } @@ -71,9 +71,9 @@ impl FrameSource { .or_else(|| panic_val.downcast_ref::<&str>().map(|s| s.to_string())) .unwrap_or_else(|| "(unknown panic)".into()); tracing::error!(target: "render", "GPU render thread panicked: {msg} — falling back to CPU"); - if let Err(e2) = - crate::engine::animated::scene_render_loop(&dir, &tx, target_fps) - { + if let Err(e2) = crate::engine::cpu::animated::scene_render_loop( + &dir, &tx, target_fps, + ) { tracing::error!(target: "render", "CPU scene render error: {e2}"); } } @@ -106,10 +106,10 @@ impl FrameSource { } WallpaperContent::Video { path } => { let (tx, rx) = sync_channel::>(2); - let audio = crate::engine::audio::StreamingAudio::start(&path); + let audio = crate::services::audio::StreamingAudio::start(&path); thread::spawn(move || { - if let Err(e) = super::ffmpeg::video_decode_loop(&path, &tx) { + if let Err(e) = crate::content::video::video_decode_loop(&path, &tx) { tracing::error!(target: "video", "video decoder error for '{}': {e}", path.display()); } }); diff --git a/src/content/mod.rs b/src/content/mod.rs new file mode 100644 index 0000000..c7500a1 --- /dev/null +++ b/src/content/mod.rs @@ -0,0 +1,17 @@ +//! Wallpaper content and how it becomes frames: the wallpaper types +//! ([`wallpaper`]), CPU frame sources for images, video and web pages, +//! render settings, and the per-output session the Wayland and X11 backends +//! share. + +pub mod frame; +pub mod settings; +// Shared by the Wayland and X11 backends; uses the MPRIS watcher, Linux-only. +#[cfg(target_os = "linux")] +pub mod session; +pub mod video; +pub mod wallpaper; +pub mod web; + +pub use frame::FrameSource; +pub use settings::{RenderSettings, ScaleMode, ScreenSettings}; +pub use wallpaper::{ScreenContent, ScreenLayout, WallpaperContent}; diff --git a/src/content/session.rs b/src/content/session.rs new file mode 100644 index 0000000..0ab926f --- /dev/null +++ b/src/content/session.rs @@ -0,0 +1,230 @@ +//! What every wallpaper output does whatever way it reaches the screen, +//! shared by the Wayland and X11 backends: building an output's renderer, +//! applying live user-property edits, and deciding from the playback rules +//! (`services::playback_gate`) whether to run, mute, pause or stop. The +//! backends keep only what is theirs: querying window states and getting +//! pixels onto the screen. + +use std::path::{Path, PathBuf}; +use std::sync::mpsc::SyncSender; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::Result; + +use super::web::{WebInputEvent, WebOptions}; +use super::{FrameSource, RenderSettings, WallpaperContent}; +use crate::engine::gpu::renderer::GpuSceneInstance; +use crate::services::media::{MediaWatcher, PlaybackState as MediaState}; +use crate::services::playback_gate::{self, PlaybackAction, PlaybackPolicy, PlaybackState}; +use crate::services::power::{self, LockWatcher}; + +/// How often the playback conditions are re-checked. Each check is real I/O +/// (sysfs reads, a D-Bus call, a window-manager query), so this stays well +/// below frame rate. +pub const PAUSE_CHECK_INTERVAL: Duration = Duration::from_millis(750); + +/// How an output's wallpaper produces pixels. +pub enum ContentRenderer { + /// CPU frames: images, video, web pages, and the scene fallback loop. + Frames(FrameSource), + /// A scene rendered on the backend's own GPU device. + Scene(Box), +} + +impl ContentRenderer { + /// The renderer for one output's content. Scenes render on `device` + /// unless `gpu_scene` is off; everything else, and a scene whose GPU + /// setup fails, produces CPU frames. + pub fn build( + content: WallpaperContent, + device: wgpu::Device, + queue: wgpu::Queue, + gpu_scene: bool, + web: WebOptions, + ) -> Result { + Ok(match content { + WallpaperContent::Scene { dir } if gpu_scene => { + match GpuSceneInstance::with_device(device, queue, &dir) { + Ok(instance) => Self::Scene(Box::new(instance)), + Err(e) => { + tracing::warn!(target: "wallpaper", "GPU scene init failed ({e}); using frame-loop fallback"); + Self::Frames(FrameSource::from_content(WallpaperContent::Scene { dir })?) + } + } + } + other => Self::Frames(FrameSource::from_content_with(other, web)?), + }) + } + + pub fn is_animated(&self) -> bool { + match self { + Self::Frames(fs) => fs.is_animated(), + Self::Scene(_) => true, + } + } + + /// The page's input channel when this is a web wallpaper. + pub fn web_input(&self) -> Option<&SyncSender> { + match self { + Self::Frames(fs) => fs.web_input(), + Self::Scene(_) => None, + } + } + + /// The next CPU frame: advances video and web content, renders a scene. + pub fn next_frame(&mut self) -> Result> { + match self { + Self::Frames(fs) => { + fs.try_advance(); + Ok(Arc::clone(fs.current_frame())) + } + Self::Scene(instance) => Ok(Arc::new(instance.render_rgba()?)), + } + } + + /// Show live edits of user properties `names` of the wallpaper in `dir`: + /// scenes in place, web pages through `applyUserProperties`. Returns + /// whether that worked; `false` means rebuild the renderer instead. + pub fn apply_properties(&mut self, dir: &Path, names: &[String]) -> bool { + match self { + Self::Scene(scene) => scene.set_user_properties(names), + Self::Frames(fs) => { + if let Some(tx) = fs.web_input() { + let json = super::web::user_properties_subset(dir, names); + let _ = tx.try_send(WebInputEvent::Properties(json)); + } + true + } + } + } +} + +/// Property edits made since `seen` that concern the wallpaper an output +/// shows (`content`): its folder and the changed names. Advances `seen`. +pub fn pending_property_edits( + settings: &Mutex, + seen: &mut usize, + content: &WallpaperContent, +) -> Option<(PathBuf, Vec)> { + let settings = settings.lock().unwrap(); + if settings.property_edits.len() == *seen { + return None; + } + let Some(dir) = content.project_dir() else { + *seen = settings.property_edits.len(); + return None; + }; + let names = settings.property_edits_since(seen, dir); + (!names.is_empty()).then(|| (dir.to_path_buf(), names)) +} + +/// What the window manager reports about the active window, for the +/// playback rules. +#[derive(Debug, Clone, Copy, Default)] +pub struct WindowState { + pub fullscreen: bool, + pub maximized: bool, + /// An application window (not the desktop or a panel) is active. + pub focused: bool, +} + +/// The playback rules and the system state they look at. +pub struct PlaybackMonitor { + policy: PlaybackPolicy, + /// MPRIS watcher for "another application is playing audio"; only + /// started when the policy acts on it. + media: Option, + media_playing: bool, + lock: Option, + state: PlaybackState, + last_check: Option, +} + +impl Default for PlaybackMonitor { + fn default() -> Self { + Self::new() + } +} + +impl PlaybackMonitor { + pub fn new() -> Self { + let policy = PlaybackPolicy::load(); + Self { + policy, + media: policy.watches_audio().then(MediaWatcher::start).flatten(), + media_playing: false, + lock: LockWatcher::start(), + state: PlaybackState::default(), + last_check: None, + } + } + + /// The action the rules call for at `now`. Conditions are re-checked at + /// most every [`PAUSE_CHECK_INTERVAL`]; `windows` is only called then. + pub fn action( + &mut self, + now: Instant, + windows: impl FnOnce() -> WindowState, + ) -> PlaybackAction { + if self + .last_check + .is_none_or(|t| now.duration_since(t) >= PAUSE_CHECK_INTERVAL) + { + self.last_check = Some(now); + let windows = windows(); + while let Some(info) = self.media.as_ref().and_then(|m| m.try_recv()) { + self.media_playing = info.playback_state == MediaState::Playing; + } + self.state = PlaybackState { + on_battery: power::is_on_battery(), + locked_or_sleeping: self.lock.as_mut().is_some_and(|l| l.is_locked()), + fullscreen_app: windows.fullscreen, + maximized_app: windows.maximized, + focused_app: windows.focused, + other_audio: self.media_playing, + }; + } + playback_gate::decide(&self.policy, &self.state) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::settings::PropertyEdit; + + #[test] + fn pending_edits_pick_this_wallpaper_and_advance() { + let mine = std::env::temp_dir(); + let edit = |dir: &Path, name: &str| PropertyEdit { + dir: std::fs::canonicalize(dir).unwrap(), + name: name.into(), + }; + let settings = Mutex::new(RenderSettings::default()); + let content = WallpaperContent::Scene { dir: mine.clone() }; + let mut seen = 0; + assert!(pending_property_edits(&settings, &mut seen, &content).is_none()); + + settings.lock().unwrap().property_edits = vec![ + edit(&mine, "speed"), + edit(Path::new("/"), "other wallpaper"), + edit(&mine, "color"), + ]; + let (dir, names) = pending_property_edits(&settings, &mut seen, &content).unwrap(); + assert_eq!(dir, mine); + assert_eq!(names, ["speed", "color"]); + assert_eq!(seen, 3); + assert!(pending_property_edits(&settings, &mut seen, &content).is_none()); + + // Content without a folder just catches up. + settings + .lock() + .unwrap() + .property_edits + .push(edit(&mine, "x")); + let image = WallpaperContent::Static(Arc::new(image::RgbaImage::new(1, 1))); + assert!(pending_property_edits(&settings, &mut seen, &image).is_none()); + assert_eq!(seen, 4); + } +} diff --git a/src/render/settings.rs b/src/content/settings.rs similarity index 98% rename from src/render/settings.rs rename to src/content/settings.rs index 1b28be8..e6bdc45 100644 --- a/src/render/settings.rs +++ b/src/content/settings.rs @@ -8,7 +8,7 @@ pub struct RenderSettings { /// GPU render quality / source down-sample factor. pub quality: RenderQuality, /// Master volume in 0.0–1.0, applied to every wallpaper audio stream via - /// `engine::audio::set_master_volume` each frame. + /// `services::audio::set_master_volume` each frame. pub volume: f32, /// Frame-rate cap for animated content. pub fps: u32, @@ -179,7 +179,7 @@ impl RenderSettings { } } -/// Per-output `RenderSettings`: the twin of [`crate::render::ScreenContent`] +/// Per-output `RenderSettings`: the twin of [`crate::content::ScreenContent`] /// for live-adjustable settings rather than content. Content became /// genuinely independent per output (different outputs can show different /// wallpapers); quality/volume should follow the same shape rather than diff --git a/src/render/ffmpeg.rs b/src/content/video.rs similarity index 100% rename from src/render/ffmpeg.rs rename to src/content/video.rs diff --git a/src/render/content.rs b/src/content/wallpaper.rs similarity index 99% rename from src/render/content.rs rename to src/content/wallpaper.rs index 2a001b5..2452008 100644 --- a/src/render/content.rs +++ b/src/content/wallpaper.rs @@ -140,7 +140,7 @@ impl WallpaperContent { ) })?; if let Some(preset) = &project.preset { - crate::engine::properties::register_preset(&parent, dir, preset); + crate::workshop::properties::register_preset(&parent, dir, preset); } tracing::info!(target: "content", "preset {} → base item {}", dir.display(), parent.display()); Ok(parent) diff --git a/src/render/web.rs b/src/content/web.rs similarity index 98% rename from src/render/web.rs rename to src/content/web.rs index e027f90..4fd62f7 100644 --- a/src/render/web.rs +++ b/src/content/web.rs @@ -38,7 +38,7 @@ impl Default for WebOptions { fn default() -> Self { Self { size: (WEB_WIDTH, WEB_HEIGHT), - fps: crate::render::settings::DEFAULT_FPS, + fps: crate::content::settings::DEFAULT_FPS, } } } @@ -46,7 +46,7 @@ impl Default for WebOptions { /// Start rendering `html` and return its first frame, a stream of the rest, /// and a sender the caller can push mouse input into (see [`WebInputEvent`]). /// -/// Mirrors `render::ffmpeg::video_decode_loop`'s contract: the receiver yields +/// Mirrors `content::video::video_decode_loop`'s contract: the receiver yields /// frames until the sender is dropped, and blocking for the first frame means /// callers never present a blank surface. pub fn start_web_stream( @@ -239,7 +239,7 @@ mod imp { wants_audio: bool, /// Whether this wallpaper calls `wallpaperMediaIntegration.listen`. /// Same reasoning as `wants_audio`: only then do we start the D-Bus - /// MPRIS watcher (`engine::media`). + /// MPRIS watcher (`services::media`). wants_media: bool, /// Mouse events the platform layer pushes in — drained once per /// message-loop tick and forwarded to CEF's `BrowserHost`. See @@ -566,11 +566,11 @@ mod imp { let mut open: Vec<(Browser, Receiver)> = Vec::new(); let mut properties = String::new(); let mut general = String::new(); - let mut audio: Option = None; + let mut audio: Option = None; let mut last_audio_push = Instant::now(); - // MPRIS is a freedesktop.org/Linux-only surface — see `engine::media`. + // MPRIS is a freedesktop.org/Linux-only surface — see `services::media`. #[cfg(target_os = "linux")] - let mut media: Option = None; + let mut media: Option = None; loop { match rx.try_recv() { @@ -593,7 +593,7 @@ mod imp { None => tracing::error!(target: "web", "failed to create CEF browser"), } if wants_audio { - audio = crate::engine::audio::AudioCapture::start(); + audio = crate::services::audio::AudioCapture::start(); if audio.is_none() { tracing::warn!( target: "web", @@ -604,7 +604,7 @@ mod imp { } #[cfg(target_os = "linux")] if wants_media { - media = crate::engine::media::MediaWatcher::start(); + media = crate::services::media::MediaWatcher::start(); if media.is_none() { tracing::warn!( target: "web", @@ -754,7 +754,7 @@ mod imp { /// WE hands the listener 128 values: 64 left-channel bands followed by 64 /// right-channel bands — precisely `AudioSpectrum`'s `s64_*` pair, so no /// resampling is needed. - fn audio_push_script(spectrum: &crate::engine::audio::AudioSpectrum) -> String { + fn audio_push_script(spectrum: &crate::services::audio::AudioSpectrum) -> String { let mut s = String::with_capacity(1024); s.push_str("window.__wpAudio&&window.__wpAudio(["); for (i, v) in spectrum @@ -782,7 +782,7 @@ mod imp { /// through in the same raw microseconds `MediaInfo` itself carries — /// see its own doc comment for why. #[cfg(target_os = "linux")] - fn media_push_script(info: &crate::engine::media::MediaInfo) -> String { + fn media_push_script(info: &crate::services::media::MediaInfo) -> String { let json_str = |s: &str| serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()); let art_url = info .art_url @@ -856,7 +856,7 @@ mod imp { /// 1. **Applying `--set-property`/saved overrides** — this used to send /// the project.json defaults straight through, meaning web wallpapers /// silently ignored every override scene wallpapers already got via - /// `engine::properties::SceneProperties`. Same override source + /// `workshop::properties::SceneProperties`. Same override source /// (`global_overrides`), so `wp-engine set /// --set-property name=value` (and a saved `settings::WpSettings` /// override) now actually reaches the page. @@ -912,7 +912,7 @@ mod imp { return String::new(); }; - let resolved = crate::engine::properties::SceneProperties::from_project_dir(dir); + let resolved = crate::workshop::properties::SceneProperties::from_project_dir(dir); for (name, decl) in props.iter_mut() { if let Some(value) = resolved.get(name) { decl["value"] = value.clone(); @@ -994,7 +994,7 @@ mod imp { /// Does this wallpaper call `wallpaperMediaIntegration.listen`? The /// answer decides whether we start the D-Bus MPRIS watcher - /// (`engine::media`) at all — same reasoning as `uses_audio_listener`. + /// (`services::media`) at all — same reasoning as `uses_audio_listener`. #[cfg(target_os = "linux")] fn uses_media_listener(dir: &Path) -> bool { scans_bundle_for(dir, b"wallpaperMediaIntegration") diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index 05340eb..1760ddf 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -10,8 +10,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::application::ApplicationContext; +use crate::content::RenderSettings; use crate::platform::RenderQuality; -use crate::render::RenderSettings; /// Everything an [`ApplicationContext`] carries, in a serializable shape. /// Quality travels as its [`RenderQuality::label`] string. @@ -29,7 +29,7 @@ pub struct ApplySpec { #[serde(default)] pub fps: Option, #[serde(default)] - pub scaling: Option, + pub scaling: Option, /// Per-screen quality, keyed like `screens`. #[serde(default)] pub screen_quality: HashMap, diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 2c979f1..d837cb6 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -277,7 +277,7 @@ pub(super) fn handle_request(req: Request, state: &Mutex) -> Response { for name in &names { let (dir, name) = (dir.clone(), name.clone()); s.property_edits - .push(crate::render::settings::PropertyEdit { dir, name }); + .push(crate::content::settings::PropertyEdit { dir, name }); } } } @@ -290,7 +290,7 @@ pub(super) fn handle_request(req: Request, state: &Mutex) -> Response { Response::Ok } Request::SetAudioDevice { device } => { - crate::platform::audio::set_preferred_device(device); + crate::services::audio_devices::set_preferred_device(device); Response::Ok } Request::PlayPlaylist { name } => { @@ -393,7 +393,14 @@ mod tests { ); drop(first); - InstanceLock::acquire(&path).expect("lock is free again once dropped"); + // A process another test spawns at this moment briefly shares our + // open file (fork to exec) and with it the lock, so allow a moment. + let again = (0..100).find_map(|_| { + InstanceLock::acquire(&path) + .inspect_err(|_| std::thread::sleep(std::time::Duration::from_millis(10))) + .ok() + }); + assert!(again.is_some(), "lock is free again once dropped"); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/engine/camera.rs b/src/engine/camera.rs deleted file mode 100644 index 6bbea67..0000000 --- a/src/engine/camera.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Orthogonal scene camera (mirrors Camera from linux-wallpaperengine). -//! Handles world-space → screen-space coordinate conversion for 2D scene objects. - -/// Orthogonal projection camera for a 2D WE scene. -pub struct SceneCamera { - pub width: f32, - pub height: f32, -} - -impl SceneCamera { - pub fn new(width: f32, height: f32) -> Self { - Self { width, height } - } - - /// Derive dimensions from scene.json, falling back to `fallback_wh`. - /// Priority: camera.orthogonal_projection → general.orthogonal_projection → fallback. - pub fn from_scene(scene: &crate::engine::scene::Scene, fallback_wh: (u32, u32)) -> Self { - if let Some(proj) = scene - .camera - .as_ref() - .and_then(|c| c.orthogonal_projection.as_ref()) - { - if let (Some(w), Some(h)) = (proj.width, proj.height) { - return Self::new(w as f32, h as f32); - } - } - if let Some(proj) = scene - .general - .as_ref() - .and_then(|g| g.orthogonal_projection.as_ref()) - { - if let (Some(w), Some(h)) = (proj.width, proj.height) { - return Self::new(w as f32, h as f32); - } - } - Self::new(fallback_wh.0 as f32, fallback_wh.1 as f32) - } - - /// Convert WE world-space position+size to quad shader parameters. - /// - /// WE world space: absolute scene coordinates with Y-up and (0,0) at the - /// bottom-left; `origin` is the object's center (a fullscreen object sits - /// at `(w/2, h/2)`). Returns: - /// - `offset_norm`: top-left corner as fraction of scene size `[0,1]` - /// - `size_norm`: object size as fraction of scene size - pub fn object_to_quad( - &self, - world_origin: [f32; 3], - world_size: [f32; 2], - ) -> ([f32; 2], [f32; 2]) { - let left_px = world_origin[0] - world_size[0] / 2.0; - let top_px = self.height - world_origin[1] - world_size[1] / 2.0; - let offset_norm = [left_px / self.width, top_px / self.height]; - let size_norm = [world_size[0] / self.width, world_size[1] / self.height]; - (offset_norm, size_norm) - } - - /// Column-major orthogonal projection matrix mapping world coords to NDC. - /// Maps x∈[0,w], y∈[0,h] → NDC [-1,1]. - pub fn mvp_matrix(&self) -> [f32; 16] { - let sx = 2.0 / self.width; - let sy = -2.0 / self.height; - [ - sx, 0.0, 0.0, 0.0, // col 0 - 0.0, sy, 0.0, 0.0, // col 1 - 0.0, 0.0, 1.0, 0.0, // col 2 - -1.0, 1.0, 0.0, 1.0, // col 3 - ] - } -} diff --git a/src/engine/animated.rs b/src/engine/cpu/animated.rs similarity index 94% rename from src/engine/animated.rs rename to src/engine/cpu/animated.rs index 45f7e3d..f4a423f 100644 --- a/src/engine/animated.rs +++ b/src/engine/cpu/animated.rs @@ -5,12 +5,14 @@ use std::sync::mpsc::SyncSender; use std::sync::Arc; use std::time::{Duration, Instant}; -use super::assets::AssetStore; -use super::effect::SceneEffect; -use super::particle::{InstanceOverride, ParticleConfig, ParticleSprite, ParticleSystem}; -use super::render::ResolvedScene; -use super::scene::SceneObject; -use super::tex::TexFile; +use crate::engine::cpu::effect::SceneEffect; +use crate::engine::model::resolve::ResolvedScene; +use crate::engine::model::scene::SceneObject; +use crate::engine::sim::particle::{ + InstanceOverride, ParticleConfig, ParticleSprite, ParticleSystem, +}; +use crate::formats::assets::AssetStore; +use crate::formats::tex::TexFile; struct SceneAnimState { /// Paired with each layer's `order_index` (its position in @@ -41,7 +43,7 @@ impl SceneAnimState { // ponytail: layers flatten to one image here, so an embedded-video // layer's stream handle is dropped and this path shows its first frame // forever. The live GPU path streams them properly - // (`gpu_renderer`'s per-frame `latest_frame` re-upload); this is only + // (`gpu::renderer`'s per-frame `latest_frame` re-upload); this is only // the fallback for when GPU init fails, and streaming here would mean a // Lanczos3 resize of a 4K frame every tick on the CPU. Carry the // `video` handle through and swap per frame if that fallback ever @@ -72,7 +74,7 @@ impl SceneAnimState { if let Ok(assets) = AssetStore::from_directory(dir) { if let Ok(scene_json) = assets.scene_json() { - if let Ok(scene) = super::scene::Scene::from_json(&scene_json) { + if let Ok(scene) = crate::engine::model::scene::Scene::from_json(&scene_json) { // Same iteration order `ResolvedScene` uses for `layers` // (`scene.visible_objects()`), so `order_index` here is // directly comparable to `Layer::order_index` above. @@ -192,7 +194,7 @@ fn load_particles( // WE origin is the object's center in absolute scene coordinates // (Y-up, (0,0) at bottom-left); convert to top-left pixel coords, matching - // render.rs's image-layer positioning. + // model::resolve's image-layer positioning. let origin = obj.parsed_origin(); let spawn_center = [origin[0] as f32, height as f32 - origin[1] as f32]; @@ -227,7 +229,7 @@ fn load_particles( /// `"materials/presets/water_faucet.json"`) to its first pass's base /// texture (every sprite-sheet frame, if animated), following one level of /// `material`-field indirection in case the path points at a model JSON -/// instead (mirrors `render.rs`'s `resolve_particle_sprite_dir`/`_pkg`, but +/// instead (mirrors `model::resolve`'s `resolve_particle_sprite_dir`/`_pkg`, but /// via `AssetStore` since this loader doesn't have direct `dir`/`pkg` access). fn resolve_particle_sprite(assets: &AssetStore, material_path: &str) -> Option { let mut val: serde_json::Value = assets.read_json(material_path).ok()?; diff --git a/src/engine/cpu/compose.rs b/src/engine/cpu/compose.rs new file mode 100644 index 0000000..8157024 --- /dev/null +++ b/src/engine/cpu/compose.rs @@ -0,0 +1,186 @@ +//! The CPU compositor: draws a resolved scene's image and particle layers +//! into one RGBA image in scene order. Used by `render-scene` and the CPU +//! fallback path; live rendering is `engine::gpu::renderer`. + +use image::RgbaImage; + +use crate::engine::model::resolve::{Layer, ResolvedScene}; +use crate::engine::sim::particle; + +impl ResolvedScene { + /// Composite all layers into a single RGBA image, in true scene z-order + /// (image and particle layers interleaved by `order_index`, matching the + /// reference's single shared per-object render order — CScene.cpp's + /// `m_objectsByRenderOrder` — instead of drawing all particles after all + /// images). + pub fn render(&self) -> RgbaImage { + let mut canvas = RgbaImage::new(self.width, self.height); + + enum DrawItem { + Image(usize), + Particle(usize), + } + let mut items: Vec<(usize, DrawItem)> = self + .layers + .iter() + .enumerate() + .map(|(i, l)| (l.order_index, DrawItem::Image(i))) + .chain( + self.particle_layers + .iter() + .enumerate() + .map(|(i, pl)| (pl.order_index, DrawItem::Particle(i))), + ) + .collect(); + items.sort_by_key(|(order, _)| *order); + + for (_, item) in items { + match item { + DrawItem::Image(i) => self.draw_image_layer(&self.layers[i], &mut canvas), + // This is a single-shot preview render, so seed the + // simulation forward a few seconds instead of drawing an + // empty, freshly-spawned system. + DrawItem::Particle(i) => { + let pl = &self.particle_layers[i]; + let spawn_center = [ + pl.origin[0] as f32, + self.height as f32 - pl.origin[1] as f32, + ]; + let mut system = particle::ParticleSystem::from_config( + &pl.config, + spawn_center, + pl.overrides.as_ref(), + ); + if let Some(sprite) = &pl.sprite_texture { + system.set_sprite_frames(sprite.frames.len(), sprite.duration); + } + for child in &pl.children { + system.add_child( + child.config.clone(), + child.sprite.clone(), + child.additive, + &child.child_ref, + spawn_center, + ); + } + for _ in 0..150 { + system.step(1.0 / 30.0); + } + // The canvas here *is* the opaque scene, so additive + // accumulation directly implements the reference's + // GL_SRC_ALPHA/GL_ONE quad blending against it. + system.render_onto_blended( + &mut canvas, + pl.sprite_texture.as_ref(), + [0.0, 0.0], + pl.additive_blend, + ); + } + } + } + + canvas + } + + pub(super) fn draw_image_layer(&self, layer: &Layer, canvas: &mut RgbaImage) { + let draw_w = if layer.size[0] != 0.0 { + (layer.size[0] * layer.scale[0]) as u32 + } else { + layer.image.width() + }; + let draw_h = if layer.size[1] != 0.0 { + (layer.size[1] * layer.scale[1]) as u32 + } else { + layer.image.height() + }; + if draw_w == 0 || draw_h == 0 { + return; + } + + let resized = if layer.image.width() != draw_w || layer.image.height() != draw_h { + image::imageops::resize( + &layer.image, + draw_w, + draw_h, + image::imageops::FilterType::Lanczos3, + ) + } else { + layer.image.clone() + }; + + // WE origin is the object's center in absolute scene coordinates + // (Y-up, (0,0) at bottom-left); convert to top-left pixel coords. + let px = (layer.origin[0] - draw_w as f64 / 2.0) as i64; + let py = (self.height as f64 - layer.origin[1] - draw_h as f64 / 2.0) as i64; + + let cw = canvas.width() as i64; + let ch = canvas.height() as i64; + + let mut blend_pixel = |cx: i64, cy: i64, s: image::Rgba| { + if cx < 0 || cx >= cw || cy < 0 || cy >= ch { + return; + } + let src_rgb = [ + (s[0] as f32 / 255.0) * layer.color[0] * layer.brightness, + (s[1] as f32 / 255.0) * layer.color[1] * layer.brightness, + (s[2] as f32 / 255.0) * layer.color[2] * layer.brightness, + ]; + let src_a = (s[3] as f32 / 255.0) * layer.alpha; + if src_a <= 0.0 { + return; + } + let dst = canvas.get_pixel_mut(cx as u32, cy as u32); + let dest_rgb = [ + dst[0] as f32 / 255.0, + dst[1] as f32 / 255.0, + dst[2] as f32 / 255.0, + ]; + let out = crate::engine::gpu::blend::apply_blending( + layer.blend_mode, + dest_rgb, + src_rgb, + src_a, + ); + dst[0] = (out[0].clamp(0.0, 1.0) * 255.0) as u8; + dst[1] = (out[1].clamp(0.0, 1.0) * 255.0) as u8; + dst[2] = (out[2].clamp(0.0, 1.0) * 255.0) as u8; + let dst_a = dst[3] as f32 / 255.0; + dst[3] = ((dst_a + src_a * (1.0 - dst_a)).clamp(0.0, 1.0) * 255.0) as u8; + }; + + if layer.angle == 0.0 { + for y in 0..draw_h { + for x in 0..draw_w { + blend_pixel(px + x as i64, py + y as i64, *resized.get_pixel(x, y)); + } + } + } else { + // Rotated layer: walk the canvas-space bounding box of the + // rotated rect and inverse-sample back into the unrotated + // `resized` image (nearest-neighbor). + let ccx = px as f64 + draw_w as f64 / 2.0; + let ccy = py as f64 + draw_h as f64 / 2.0; + let half_diag = ((draw_w as f64 / 2.0).powi(2) + (draw_h as f64 / 2.0).powi(2)).sqrt(); + let (sin_a, cos_a) = (layer.angle as f64).sin_cos(); + let x0 = ((ccx - half_diag).floor() as i64).max(0); + let x1 = ((ccx + half_diag).ceil() as i64).min(cw); + let y0 = ((ccy - half_diag).floor() as i64).max(0); + let y1 = ((ccy + half_diag).ceil() as i64).min(ch); + for cy in y0..y1 { + for cx in x0..x1 { + let fx = cx as f64 + 0.5 - ccx; + let fy = cy as f64 + 0.5 - ccy; + // Inverse rotation (screen → unrotated local space). + let lx = cos_a * fx + sin_a * fy; + let ly = -sin_a * fx + cos_a * fy; + let sx = (lx + draw_w as f64 / 2.0).floor(); + let sy = (ly + draw_h as f64 / 2.0).floor(); + if sx < 0.0 || sy < 0.0 || sx >= draw_w as f64 || sy >= draw_h as f64 { + continue; + } + blend_pixel(cx, cy, *resized.get_pixel(sx as u32, sy as u32)); + } + } + } + } +} diff --git a/src/engine/effect.rs b/src/engine/cpu/effect.rs similarity index 100% rename from src/engine/effect.rs rename to src/engine/cpu/effect.rs diff --git a/src/engine/graph.rs b/src/engine/cpu/graph.rs similarity index 97% rename from src/engine/graph.rs rename to src/engine/cpu/graph.rs index faa8363..f050410 100644 --- a/src/engine/graph.rs +++ b/src/engine/cpu/graph.rs @@ -1,9 +1,9 @@ use anyhow::Result; use std::path::Path; -use super::assets::AssetStore; -use super::material::{EffectDefinition, Model}; -use super::scene::{Scene, SceneObject}; +use crate::engine::cpu::material::{EffectDefinition, Model}; +use crate::engine::model::scene::{Scene, SceneObject}; +use crate::formats::assets::AssetStore; pub struct SceneGraph { pub assets: AssetStore, @@ -305,7 +305,10 @@ impl SceneGraphStats { } } -fn collect_material_stats(material: &super::material::Material, stats: &mut SceneGraphStats) { +fn collect_material_stats( + material: &crate::engine::cpu::material::Material, + stats: &mut SceneGraphStats, +) { stats.material_passes += material.passes.len(); for pass in &material.passes { diff --git a/src/engine/material.rs b/src/engine/cpu/material.rs similarity index 99% rename from src/engine/material.rs rename to src/engine/cpu/material.rs index 3641c50..7518af7 100644 --- a/src/engine/material.rs +++ b/src/engine/cpu/material.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Deserializer}; use serde_json::Value; use std::collections::BTreeMap; -use super::assets::AssetStore; +use crate::formats::assets::AssetStore; pub type TextureMap = BTreeMap; pub type ComboMap = BTreeMap; diff --git a/src/engine/cpu/mod.rs b/src/engine/cpu/mod.rs new file mode 100644 index 0000000..cd89836 --- /dev/null +++ b/src/engine/cpu/mod.rs @@ -0,0 +1,8 @@ +//! The CPU scene renderer: the fallback when the GPU path fails to start. +//! Production rendering is `engine::gpu::renderer`. + +pub mod animated; +pub mod compose; +pub mod effect; +pub mod graph; +pub mod material; diff --git a/src/engine/blend.rs b/src/engine/gpu/blend.rs similarity index 98% rename from src/engine/blend.rs rename to src/engine/gpu/blend.rs index 00fbfd1..73c046d 100644 --- a/src/engine/blend.rs +++ b/src/engine/gpu/blend.rs @@ -2,7 +2,7 @@ //! //! Mirrors `ApplyBlending`/`WE_COMMON_BLENDING_H` in //! `shaders/transpiler.rs` (the GLSL/WGSL version used by the GPU compositor), -//! for the CPU-side compositor in `render.rs`. `dest` is the pixel already on +//! for the CPU-side compositor in `model::resolve`. `dest` is the pixel already on //! the canvas, `src` is the incoming layer's own color, `opacity` is the //! layer's own alpha at that pixel. diff --git a/src/engine/fbo.rs b/src/engine/gpu/fbo.rs similarity index 100% rename from src/engine/fbo.rs rename to src/engine/gpu/fbo.rs diff --git a/src/engine/lighting.rs b/src/engine/gpu/lighting.rs similarity index 99% rename from src/engine/lighting.rs rename to src/engine/gpu/lighting.rs index 7f2ffc5..647a0e7 100644 --- a/src/engine/lighting.rs +++ b/src/engine/gpu/lighting.rs @@ -28,7 +28,7 @@ //! //! Both shadow-cascade mixing and the spot-light cookie texture lookup this //! comment used to list here have since been wired in on the GPU side — -//! `engine::shadow`/`mesh3d_shadow_factor` in `gpu_shaders.wgsl` for +//! `engine::gpu::shadow`/`mesh3d_shadow_factor` in `gpu_shaders.wgsl` for //! shadows, `mesh3d_cookie_factor` for the cookie — because both need a //! real depth/color texture sampler this pure-CPU-math module intentionally //! doesn't have. `shadow_factor` stays a plain parameter here for the same @@ -542,7 +542,7 @@ pub enum Light { radius: f32, /// The raw `outercone` half-angle in degrees — kept alongside the /// already-derived Phong `exponent` because the shadow-atlas - /// projection (`engine::shadow::spot_light_view_proj`) needs the + /// projection (`engine::gpu::shadow::spot_light_view_proj`) needs the /// actual cone width for its frustum FOV, and recovering it by /// inverting `exponent`'s formula would be lossy at the exponent's /// clamp bounds. diff --git a/src/engine/gpu/mod.rs b/src/engine/gpu/mod.rs new file mode 100644 index 0000000..8d1a13e --- /dev/null +++ b/src/engine/gpu/mod.rs @@ -0,0 +1,10 @@ +//! How the scene is drawn on the GPU: render targets, blend modes, lighting, +//! shadows and text rasterization. + +pub mod blend; +pub mod fbo; +pub mod lighting; +pub mod renderer; +pub mod shaders; +pub mod shadow; +pub mod text; diff --git a/src/engine/gpu/renderer/build.rs b/src/engine/gpu/renderer/build.rs new file mode 100644 index 0000000..621a6c5 --- /dev/null +++ b/src/engine/gpu/renderer/build.rs @@ -0,0 +1,1404 @@ +//! Building a running scene: resolving scene.json into GPU layers, 3D +//! meshes and sounds, loading each effect's passes into runtimes, and +//! uploading the initial geometry. + +use super::*; + +impl GpuSceneInstance { + pub(super) fn build(mut renderer: GpuSceneRenderer, dir: &Path) -> Result { + let resolved = crate::engine::model::resolve::ResolvedScene::from_directory(dir)?; + // The unparsed scene.json: scripts and timelines read fields the + // typed model drops. + let raw_scene: serde_json::Value = crate::formats::assets::AssetStore::from_directory(dir) + .ok() + .and_then(|a| a.scene_json().ok()) + .and_then(|t| serde_json::from_str(&t).ok()) + .unwrap_or_default(); + let w = resolved.width; + let h = resolved.height; + + let scene_model = crate::engine::model::scene_to_model(&resolved.scene)?; + + let general = resolved.scene.general.as_ref(); + let clear_color: [f64; 3] = general + .and_then(|g| g.clear_color.as_ref()) + .and_then(|v| { + let s = if let Some(s) = v.as_str() { + s.to_string() + } else if let Some(inner) = v.get("value").and_then(|i| i.as_str()) { + inner.to_string() + } else { + return None; + }; + let parts: Vec = s + .split_whitespace() + .filter_map(|p| p.parse().ok()) + .collect(); + if parts.len() >= 3 { + Some([parts[0], parts[1], parts[2]]) + } else { + None + } + }) + .unwrap_or([0.0, 0.0, 0.0]); + // `clearenabled: false` → don't paint the clear color (the surface is + // opaque, so this reads as black behind any gaps in the scene). + let mut clear_color = if general + .and_then(|g| g.clearenabled.as_ref()) + .and_then(parse_value_bool) + == Some(false) + { + [0.0, 0.0, 0.0] + } else { + clear_color + }; + // NOTE: `ambientcolor`/`skylightcolor` are inputs to LIT materials, not + // a global tint. The whole corpus is unlit (0 shaders use LightingV1) + // and these default to "0.3 0.3 0.3" on 195/197 scenes, so applying + // them globally would darken every scene to 30%. They stay unused + // (parsed only) — the visible lighting is the light-object glow. + + // HDR wallpapers author a separate `bloomhdr*` set; with a single SDR + // bloom chain we pick that set when `hdr` is on, else the SDR one. + let hdr = general + .and_then(|g| g.hdr.as_ref()) + .and_then(parse_value_bool) + .unwrap_or(false); + let bloom = BloomSettings { + enabled: hdr + || general + .and_then(|g| g.bloom.as_ref()) + .and_then(parse_value_bool) + .unwrap_or(false), + strength: general + .and_then(|g| { + if hdr { + g.bloom_hdr_strength.as_ref() + } else { + g.bloom_strength.as_ref() + } + }) + .and_then(parse_value_f32) + .unwrap_or(1.0), + threshold: general + .and_then(|g| { + if hdr { + g.bloom_hdr_threshold.as_ref() + } else { + g.bloom_threshold.as_ref() + } + }) + .and_then(parse_value_f32) + .unwrap_or(0.5), + }; + + // `general.zoom` (default 1.0) — a scene-wide scale about the center. + let zoom = general + .and_then(|g| g.zoom.as_ref()) + .and_then(parse_value_f32) + .filter(|z| *z > 0.0) + .unwrap_or(1.0); + // Scene-global particle force (gravity + wind). + let scene_force = general.map(|g| g.particle_force()).unwrap_or([0.0, 0.0]); + + let dynamics = CameraDynamics::from_scene(&resolved.scene); + + // Perspective (3D) scenes project layer quads through the scene + // camera instead of mapping pixel origins to NDC directly. + let camera3d = crate::engine::sim::camera3d::PerspectiveCamera::from_scene( + &resolved.scene, + w as f32 / h as f32, + ); + + let layers: Vec = resolved + .layers + .iter() + .map(|l| { + let authored = resolved + .transform_graph + .local + .get(l.order_index) + .map(|x| x.t) + .unwrap_or(l.local_origin); + gpu_layer_from(&renderer, l, w, h, zoom, camera3d.as_ref(), authored) + }) + .collect(); + + let scene_effects = collect_effects(&scene_model); + let effect_runtimes = load_effect_runtimes(&mut renderer, dir, &scene_effects); + + // Start desktop-audio capture only for scenes that actually react to + // it (avoids opening a capture device for every wallpaper). + // Start capture for audio shaders OR audio-reactive particle emitters. + let particles_use_audio = resolved.particle_layers.iter().any(|pl| { + pl.config + .emitter + .iter() + .any(|e| e.audioprocessingmode.is_some() || e.audioprocessingbounds.is_some()) + }); + let uses_audio = + particles_use_audio || effect_runtimes.iter().flatten().any(|r| r.uses_audio); + let audio_capture = if uses_audio { + crate::services::audio::AudioCapture::start() + } else { + None + }; + + // Sound objects: decode each file and play it looped; `startsilent` + // ones are held ready but silent so a script can `play()` them. + let sounds: Vec = resolved + .scene + .objects + .iter() + .enumerate() + .filter_map(|(object, obj)| { + start_scene_sound(dir, obj).map(|playback| SceneSound { object, playback }) + }) + .collect(); + + // Computed early (not just before `build_shadow_atlas` needs it, its + // original spot) so the FBO-pool setup below can also gate + // `_rt_VolumetricsBuffer`'s allocation on whether any light actually + // needs it — see `engine::model::scene::SceneObject::volumetrics_params`. + let lights = resolved.scene.lights(); + let has_volumetrics = lights.iter().any(|(_, _, v, _)| v.is_some()); + + // Allocate all persistent render targets up front so the render loop + // can look them up immutably. Per-layer effect-chain FBOs (ping-pong + // pair + named targets) are sized to that layer's own object size, + // matching the reference (`CImage`'s m_mainFBO/m_subFBO and + // `FBOProvider::create` both use the object's unscaled size, not the + // scene resolution). + let mut fbo_pool = RenderTargetPool::new(); + for (layer_idx, layer) in layers.iter().enumerate() { + let (ow, oh) = (layer.object_size.0.max(1), layer.object_size.1.max(1)); + fbo_pool.get_or_create(renderer.device(), &pingpong_key(layer_idx, 0), ow, oh); + fbo_pool.get_or_create(renderer.device(), &pingpong_key(layer_idx, 1), ow, oh); + } + for (instance_idx, runtime) in effect_runtimes.iter().enumerate() { + if let Some(runtime) = runtime { + // `inst.layer_idx` is a raw scene-object index; find the + // loaded layer that came from that object (positional + // indexing would misalign whenever particles/skipped + // objects precede the layer, sizing FBOs off — and routing + // effects onto — a completely unrelated layer). + let (ow, oh) = scene_effects + .get(instance_idx) + .and_then(|inst| layers.iter().find(|l| l.order_index == inst.layer_idx)) + .map(|layer| (layer.object_size.0.max(1), layer.object_size.1.max(1))) + .unwrap_or((w, h)); + for (fbo_name, scale) in &runtime.fbos { + let div = scale.max(1.0); + let key = named_fbo_key(instance_idx, fbo_name); + fbo_pool.get_or_create( + renderer.device(), + &key, + ((ow as f32 / div) as u32).max(1), + ((oh as f32 / div) as u32).max(1), + ); + } + } + } + // `_rt_imageLayerComposite__`: WE's per-object composite + // buffer, referenced by effects on *other* objects (2321732083's + // samurai adds 5x object 101's buffer). Create one scene-sized target + // per referenced name; each is filled right after its owning layer + // composites. Aliasing these to the whole-scene snapshot instead — + // which is what we used to do — meant `blend` added 5x the entire lit + // city onto the samurai and blew it out to white. + let layer_composite_names: Vec = scene_effects + .iter() + .flat_map(|inst| inst.pass_overrides.iter()) + .flat_map(|over| { + over.textures + .iter() + .filter_map(|t| t.clone()) + .collect::>() + }) + .filter(|n| n.starts_with("_rt_imageLayerComposite")) + .collect(); + for name in &layer_composite_names { + fbo_pool.get_or_create(renderer.device(), name, w, h); + } + + if bloom.enabled { + // Matches the reference's three-buffer chain exactly (CScene.cpp): + // no ping-pong needed since each stage writes a *different* buffer. + let (qw, qh) = ((w / 4).max(1), (h / 4).max(1)); + let (ew, eh) = ((w / 8).max(1), (h / 8).max(1)); + fbo_pool.get_or_create(renderer.device(), "_rt_4FrameBuffer", qw, qh); + fbo_pool.get_or_create(renderer.device(), "_rt_8FrameBuffer", ew, eh); + fbo_pool.get_or_create(renderer.device(), "_rt_Bloom", ew, eh); + } + + // Volumetric-light-shaft ray march (see `build_volumetrics` and + // `record_volumetrics`) — quarter-res like bloom's own chain, cheap + // enough for a per-pixel ray march, upsampled naturally by the blur + // pass. Gated on `has_volumetrics`: real content essentially never + // sets `castvolumetrics` (see the Ghidra report's `_rt_volumetrics*` + // follow-up), so this stays a zero-cost no-op for the common case. + if has_volumetrics { + let (qw, qh) = ((w / 4).max(1), (h / 4).max(1)); + fbo_pool.get_or_create(renderer.device(), "_rt_VolumetricsBuffer", qw, qh); + fbo_pool.get_or_create(renderer.device(), "_rt_VolumetricsBlurTmp", qw, qh); + } + + let target = renderer.create_render_target(w, h); + let scene_copy = renderer.create_render_target(w, h); + + let particle_systems: Vec = resolved + .particle_layers + .iter() + .map(|pl| { + let spawn_center = [pl.origin[0] as f32, h as f32 - pl.origin[1] as f32]; + let mut system = particle::ParticleSystem::from_config( + &pl.config, + spawn_center, + pl.overrides.as_ref(), + ); + system.set_scene_force(scene_force); + if let Some(sprite) = &pl.sprite_texture { + system.set_sprite_frames(sprite.frames.len(), sprite.duration); + } + for child in &pl.children { + system.add_child( + child.config.clone(), + child.sprite.clone(), + child.additive, + &child.child_ref, + spawn_center, + ); + } + system + }) + .collect(); + let particle_sprites: Vec> = resolved + .particle_layers + .iter() + .map(|pl| pl.sprite_texture.clone()) + .collect(); + let particle_order: Vec = resolved + .particle_layers + .iter() + .map(|pl| pl.order_index) + .collect(); + let particle_additive: Vec = resolved + .particle_layers + .iter() + .map(|pl| pl.additive_blend) + .collect(); + + // GPU particle sprite arrays. Sprite-less systems draw the CPU + // path's soft radial-falloff disc (alpha = (1-d)^2), baked once; + // texture-less ropes fall back to a solid white strip, matching the + // CPU flat fill. + let soft_disc = { + let mut img = RgbaImage::new(64, 64); + for y in 0..64u32 { + for x in 0..64u32 { + let dx = (x as f32 + 0.5) / 32.0 - 1.0; + let dy = (y as f32 + 0.5) / 32.0 - 1.0; + let d = (dx * dx + dy * dy).sqrt().min(1.0); + let t = 1.0 - d; + let a = (t * t * 255.0) as u8; + img.put_pixel(x, y, image::Rgba([255, 255, 255, a])); + } + } + img + }; + let white = RgbaImage::from_pixel(1, 1, image::Rgba([255, 255, 255, 255])); + let array_view = |tex: &wgpu::Texture| { + tex.create_view(&wgpu::TextureViewDescriptor { + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }) + }; + let make_gpu_tex = |sprite: Option<&particle::ParticleSprite>, + rope: bool| + -> ParticleGpuTex { + match sprite { + Some(sp) if !sp.frames.is_empty() => { + let (tex, frames) = renderer.upload_texture_array(&sp.frames); + ParticleGpuTex { + view: array_view(&tex), + frames, + overbright: sp.overbright, + repeat: rope, + } + } + _ => { + let img = if rope { &white } else { &soft_disc }; + let (tex, frames) = renderer.upload_texture_array(std::slice::from_ref(img)); + ParticleGpuTex { + view: array_view(&tex), + frames, + overbright: 1.0, + repeat: rope, + } + } + } + }; + let particle_gpu_assets: Vec = resolved + .particle_layers + .iter() + .zip(particle_systems.iter()) + .map(|(pl, system)| ParticleGpuAssets { + parent: make_gpu_tex(pl.sprite_texture.as_ref(), system.is_rope()), + children: system + .child_sprite_info() + .into_iter() + .map(|(sprite, rope)| make_gpu_tex(sprite, rope)) + .collect(), + }) + .collect(); + + // Static 3D meshes: upload geometry + bake each MVP. Only reachable + // when the scene has a perspective camera to draw them through. + // Draw only meshes whose name contains this — the way to tell "not + // drawn" apart from "drawn but occluded" in a scene the camera sits + // inside of (same role as WP_DEBUG_DUMP_FRAME). + let mesh_filter = std::env::var("WP_MESH_FILTER").ok(); + // Shadow atlas: one shared depth texture, packed with a tile per + // shadow-casting `light` object — see `build_shadow_atlas` and + // `engine::gpu::shadow`. Built before the lighting uniform and the final + // mesh3d draws because both need to bind its (by-then-final) view. + // `lights` itself was computed earlier, alongside `has_volumetrics`. + let (shadow_atlas_tex, shadow_slots, shadow_slot_data) = if camera3d.is_some() { + Self::build_shadow_atlas(&renderer, &resolved.mesh3d_layers, &lights) + } else { + ( + renderer.device.create_texture(&wgpu::TextureDescriptor { + label: Some("shadow_atlas_dummy"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: MESH3D_DEPTH_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }), + vec![None; lights.len()], + Vec::new(), + ) + }; + let shadow_atlas_view = shadow_atlas_tex.create_view(&Default::default()); + // Spot-light cookie texture (`usecookie`/`cookie` — see + // `SceneObject::cookie_texture_path`). Scoped to the *first* Spot + // light with a real, loadable cookie image — real content sets + // `usecookie` on zero lights, ever (see the Ghidra report's + // property-schema survey), so there's no real-content case to + // generalize "one" into "N" against, same precedent + // `build_volumetrics` already uses for its own "first light with + // the flag" scoping. Only resolves against loose files under `dir` + // (no `scene.pkg` fallback) — the pkg handle isn't otherwise + // plumbed into `build`, and every real `usecookie` occurrence in + // this sandbox's cache is zero either way. + let cookie_data = { + use crate::engine::gpu::lighting::Light; + let (center, radius) = + crate::engine::gpu::shadow::scene_bounds(&resolved.mesh3d_layers); + lights + .iter() + .enumerate() + .find_map(|(i, (light, _, _, cookie_path))| { + let path = cookie_path.as_ref()?; + let Light::Spot { + origin, + direction, + outer_cone_degrees, + .. + } = light + else { + return None; + }; + let image = crate::engine::model::resolve::resolve_static_texture( + Some(dir), + None, + path, + )?; + let vp = crate::engine::gpu::shadow::spot_light_view_proj( + *origin, + *direction, + *outer_cone_degrees, + center, + radius, + ); + Some((i, vp, image)) + }) + }; + let cookie_view = renderer + .upload_texture( + &cookie_data + .as_ref() + .map(|(_, _, img)| img.clone()) + .unwrap_or_else(|| { + RgbaImage::from_pixel(1, 1, image::Rgba([255, 255, 255, 255])) + }), + ) + .create_view(&Default::default()); + // Volumetric-light-shaft ray march's static per-scene bind group — + // `None` unless `has_volumetrics` (checked again inside, cheaply, + // since it also needs to find *which* light). See `build_volumetrics`. + let volumetrics_bind_group = camera3d.as_ref().and_then(|cam| { + Self::build_volumetrics( + &renderer, + cam, + &lights, + &shadow_slots, + &shadow_slot_data, + &shadow_atlas_view, + ) + }); + // Scene-wide lighting/fog/shadow uniform, shared by every mesh3d + // bind group (binding 3) — see `mesh3d_lighting_bytes`. Zeroed (and + // thus read as "no lighting") when there's no perspective camera to + // place lights through, matching `mesh3d` staying empty in that case. + let mesh3d_lighting_bytes = camera3d + .as_ref() + .map(|cam| { + mesh3d_lighting_bytes( + &resolved.scene, + cam, + &shadow_slots, + &shadow_slot_data, + cookie_data.as_ref().map(|(i, vp, _)| (*i, *vp)), + ) + }) + .unwrap_or_else(|| vec![0u8; MESH3D_LIGHTING_BYTES_LEN]); + let mesh3d_lighting_ubo = renderer.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("mesh3d_lighting"), + size: mesh3d_lighting_bytes.len() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + renderer + .queue + .write_buffer(&mesh3d_lighting_ubo, 0, &mesh3d_lighting_bytes); + let mesh3d: Vec = match &camera3d { + Some(cam) => resolved + .mesh3d_layers + .iter() + .filter(|m| { + mesh_filter + .as_ref() + .is_none_or(|f| m.name.contains(f.as_str())) + }) + .map(|m| { + Self::build_mesh3d( + &renderer, + cam, + m, + &mesh3d_lighting_ubo, + &shadow_atlas_view, + &cookie_view, + ) + }) + .collect(), + None => Vec::new(), + }; + let mesh3d_depth = (!mesh3d.is_empty()).then(|| { + renderer + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("mesh3d_depth"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: MESH3D_DEPTH_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }) + .create_view(&Default::default()) + }); + + let mut instance = Self { + renderer, + layers, + scene_effects, + effect_runtimes, + fbo_pool, + target, + scene_copy, + width: w, + height: h, + clear_color, + dynamics, + bloom, + volumetrics_bind_group, + particle_systems, + particle_sprites, + particle_order, + particle_additive, + particle_gpu_assets, + start: Instant::now(), + last_time: 0.0, + mouse_norm: [0.5, 0.5], + scripts: build_script_host( + dir, + &resolved.scene, + &resolved.transform_graph, + [w, h], + &raw_scene, + ), + timelines: build_timelines(&raw_scene, &resolved.transform_graph), + live_props: LiveProps::new(dir, &raw_scene), + object_visible: resolved + .scene + .objects + .iter() + .map(|o| o.is_visible()) + .collect(), + propagation_disabled: vec![false; resolved.scene.objects.len()], + scripts_want_audio: false, + cursor_scene: [w as f32 * 0.5, h as f32 * 0.5], + cursor_left_down: false, + pending_buttons: Vec::new(), + cursor_targets: Vec::new(), + hovered: None, + pressed: None, + last_cursor_scene: [w as f32 * 0.5, h as f32 * 0.5], + effect_enabled: Vec::new(), + draw_rank: (0..resolved.scene.objects.len()) + .map(|i| i as f64) + .collect(), + sort_count: 0, + scene_dir: dir.to_path_buf(), + scene_pkg: None, + audio_capture, + sounds, + #[cfg(target_os = "linux")] + media: crate::services::media::MediaWatcher::start(), + perspective: camera3d.is_some(), + mesh3d, + mesh3d_depth, + mesh3d_lighting_ubo, + shadow_atlas_tex, + camera3d, + current_locals: resolved.transform_graph.local.clone(), + transform_graph: resolved.transform_graph, + zoom, + }; + // Hidden ancestors hide their subtree; script-referenced hidden + // objects are built but start hidden. + instance.refresh_layer_visibility(); + instance.effect_enabled = vec![true; instance.scene_effects.len()]; + instance.cursor_targets = instance + .scripts + .objects_with_handlers(crate::engine::sim::script::CURSOR_EVENTS); + Ok(instance) + } + + /// Interleave position+uv+normal into the vs_mesh3d vertex layout + /// (stride 32: 3 + 2 + 3 floats) and upload it plus the index buffer. + /// Shared by the final mesh3d draw (`build_mesh3d`) and the shadow-atlas + /// depth pass (`build_shadow_atlas`), which both need the same geometry + /// on the GPU but at different points in scene setup — the shadow atlas + /// has to exist before `build_mesh3d` can bind it, so its geometry + /// upload can't simply be reused from a `Mesh3dGpu` built later. + pub(super) fn upload_mesh3d_geometry( + renderer: &GpuSceneRenderer, + mesh: &crate::engine::model::mesh3d::Mesh3d, + ) -> (wgpu::Buffer, wgpu::Buffer, u32) { + let mut verts: Vec = Vec::with_capacity(mesh.positions.len() * 32); + for ((p, uv), n) in mesh.positions.iter().zip(&mesh.uvs).zip(&mesh.normals) { + for f in [p[0], p[1], p[2], uv[0], uv[1], n[0], n[1], n[2]] { + verts.extend_from_slice(&f.to_le_bytes()); + } + } + let vbuf = renderer.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("mesh3d_verts"), + size: verts.len() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + renderer.queue.write_buffer(&vbuf, 0, &verts); + + let idx: Vec = mesh.indices.iter().flat_map(|i| i.to_le_bytes()).collect(); + let ibuf = renderer.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("mesh3d_indices"), + size: idx.len() as u64, + usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + renderer.queue.write_buffer(&ibuf, 0, &idx); + (vbuf, ibuf, mesh.indices.len() as u32) + } + + /// Upload one mesh's geometry and bake its MVP. Geometry and transform are + /// both static in real content, so this runs once at load. + pub(super) fn build_mesh3d( + renderer: &GpuSceneRenderer, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, + layer: &crate::engine::model::resolve::Mesh3dLayer, + lighting_ubo: &wgpu::Buffer, + shadow_atlas_view: &wgpu::TextureView, + cookie_view: &wgpu::TextureView, + ) -> Mesh3dGpu { + let (vbuf, ibuf, index_count) = Self::upload_mesh3d_geometry(renderer, &layer.mesh); + + let params = mesh3d_transform_bytes(cam, layer.origin, layer.angles, layer.scale); + let ubo = renderer.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("mesh3d_xform"), + size: params.len() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + renderer.queue.write_buffer(&ubo, 0, ¶ms); + + let tex = renderer.upload_texture(&layer.texture); + let view = tex.create_view(&Default::default()); + let bind_group = renderer + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("mesh3d_bg"), + layout: &renderer.mesh3d_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: ubo.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 2, + // Meshes wrap their UVs (skyboxes/spheres tile). + resource: wgpu::BindingResource::Sampler(&renderer.samplers[0]), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: lighting_ubo.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(shadow_atlas_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler( + &renderer.shadow_comparison_sampler, + ), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(cookie_view), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&renderer.samplers[0]), + }, + ], + }); + + Mesh3dGpu { + vbuf, + ibuf, + index_count, + bind_group, + nocull: layer.nocull, + depthtest: layer.depthtest, + ubo, + local: (layer.local_origin, layer.local_angles, layer.local_scale), + order_index: layer.order_index, + depth: cam.view_depth(layer.origin), + } + } +} + +/// Start a `sound` object's first decodable file, held silent when +/// `startsilent` is set. +pub(super) fn start_scene_sound( + dir: &Path, + obj: &crate::engine::model::scene::SceneObject, +) -> Option { + let sound = obj.sound.as_ref()?; + let start_silent = obj + .startsilent + .as_ref() + .and_then(|v| { + v.as_bool() + .or_else(|| v.get("value").and_then(|i| i.as_bool())) + }) + .unwrap_or(false); + let volume = obj + .volume + .as_ref() + .and_then(crate::engine::model::scene::parse_value_f32) + .unwrap_or(1.0); + let mode = obj.playbackmode.as_ref().and_then(|v| v.as_str()); + let looping = mode != Some("nointerrupt") && mode != Some("once"); + let files: Vec = match sound { + serde_json::Value::Array(a) => a + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(), + serde_json::Value::String(s) => vec![s.clone()], + _ => Vec::new(), + }; + files.into_iter().find_map(|f| { + crate::services::audio::SoundPlayback::start(&dir.join(&f), volume, looping, !start_silent) + }) +} + +/// Load pipelines/textures for every effect instance in the scene: hardcoded +/// kernels get single-pass runtimes, everything else is loaded from the WE +/// assets directory pass-by-pass and translated GLSL→WGSL with the instance's +/// combos baked in. +pub(super) fn load_effect_runtimes( + renderer: &mut GpuSceneRenderer, + wallpaper_dir: &Path, + scene_effects: &[EffectInstanceDef], +) -> Vec> { + let assets_dir = loader::find_we_assets_dir(); + // Wallpaper-local effects/materials/shaders (loose files or scene.pkg) + // take priority over the global Steam assets dir, mirroring the + // reference's Container mount order (dir, scene.pkg, gifscene.pkg, assets). + let resolver = resolver::AssetResolver::new(Some(wallpaper_dir), assets_dir); + + // Debug escape hatch: WP_ENGINE_SKIP_EFFECTS=clouds,bloom disables the + // named effects (useful to bisect a misbehaving shader translation). + let skip: Vec = std::env::var("WP_ENGINE_SKIP_EFFECTS") + .map(|v| v.split(',').map(|s| s.trim().to_lowercase()).collect()) + .unwrap_or_default(); + + scene_effects + .iter() + .enumerate() + .map(|(instance_idx, inst)| { + if skip.contains(&inst.name) { + tracing::debug!( + target: "effect", + "SKIP '{}': disabled via WP_ENGINE_SKIP_EFFECTS", + inst.name + ); + return None; + } + load_effect_instance(renderer, &resolver, instance_idx, inst) + }) + .collect() +} + +#[tracing::instrument(target = "effect", level = "debug", skip(renderer, resolver, inst), fields(effect = %inst.name, instance = instance_idx))] +pub(super) fn load_effect_instance( + renderer: &mut GpuSceneRenderer, + resolver: &resolver::AssetResolver, + instance_idx: usize, + inst: &EffectInstanceDef, +) -> Option { + let effect_name = &inst.name; + tracing::trace!(target: "effect", "loading effect instance"); + if HARDCODED_EFFECTS.contains(&effect_name.as_str()) { + // Scene instances may override secondary texture slots (typically + // slot 1 = an opacity mask, e.g. waterwaves/shake masks) — load + // them under a per-instance key so the kernel's extra-slot binding + // sees the real mask instead of the white 1×1 dummy. + let tex_key = format!("fx{instance_idx}:{effect_name}#0"); + let mut textures: Vec = Vec::new(); + if let Some(over) = inst.pass_overrides.first() { + for (slot_i, name) in over.textures.iter().skip(1).enumerate() { + let img = name + .as_deref() + .filter(|n| !n.is_empty() && !n.starts_with("_rt_")) + .and_then(|n| { + let candidates = [format!("materials/{n}.tex"), format!("{n}.tex")]; + candidates.iter().find_map(|rel| { + let bytes = resolver.read(rel)?; + crate::formats::tex::TexFile::parse(&bytes) + .ok()? + .to_rgba() + .ok() + }) + }); + // Missing-slot fallbacks: normally white (= unmasked), but + // shake's slot 1 is a FLOW map whose authored default is + // util/noflow — rg 0.498 gray, "no pixel moves" (white would + // shear the whole layer diagonally), and pulse's slot 1 is + // its noise source, authored default util/noise. + let img = img.or_else(|| { + if effect_name == "pulse" && slot_i == 0 { + let bytes = resolver.read("materials/util/noise.tex")?; + crate::formats::tex::TexFile::parse(&bytes) + .ok()? + .to_rgba() + .ok() + } else { + None + } + }); + let fallback = if effect_name == "shake" && slot_i == 0 { + image::Rgba([127, 127, 127, 255]) + } else { + image::Rgba([255, 255, 255, 255]) + }; + textures.push( + renderer.upload_texture( + &img.unwrap_or_else(|| RgbaImage::from_pixel(1, 1, fallback)), + ), + ); + } + } + // A shake with no texture overrides at all authors no flow map either + // — bind the gray no-flow dummy so the effect stays static like WE. + if effect_name == "shake" && textures.is_empty() { + textures.push(renderer.upload_texture(&RgbaImage::from_pixel( + 1, + 1, + image::Rgba([127, 127, 127, 255]), + ))); + } + if std::env::var("WP_DEBUG_TEX_SLOTS").is_ok() { + eprintln!( + "[texslots] {effect_name} (hardcoded, instance {instance_idx}): overrides={:?} loaded={}", + inst.pass_overrides.first().map(|o| o.textures.clone()), + textures.len() + ); + } + if !textures.is_empty() { + renderer.dynamic_textures.insert(tex_key.clone(), textures); + } + return Some(EffectRuntime { + passes: vec![EffectPassRuntime { + key: effect_name.clone(), + tex_key, + hardcoded: true, + target: None, + // REVERTED (2026-08-26, see the Ghidra report's godrays/ + // waterwaves follow-up): this used to bind slot 0 to + // `_rt_FullFrameBuffer` for waterripple/waterwaves, on the + // theory that their `g_Texture0` ("hidden"/"framebuffer" in + // the real shader's own annotation) meant the global scene + // buffer. Real content proved that wrong: a real downloaded + // wallpaper attaches 8 waterwaves instances directly to its + // own background image (a shimmer filter on itself, not a + // transparent water surface revealing what's behind it) — + // at the point this object's own effect chain runs, the + // global scene target is still blank (this object hasn't + // composited onto it yet), so binding it there fed every + // instance blank input, chained through 8 passes to a flat + // grey wash-out. "framebuffer" evidently means this pass's + // own chain input here, which is exactly what leaving + // `binds` empty (falling through to `chain_view`) already + // gives it — the pre-fix behavior, restored. + binds: Vec::new(), + values: { + // Hardcoded kernels read combos (e.g. shake's DIRECTION/ + // NOISE) as pseudo-values with a `combo_` prefix. + let mut vals = inst + .pass_overrides + .first() + .map(|o| o.values.clone()) + .unwrap_or_default(); + if let Some(over) = inst.pass_overrides.first() { + for (k, v) in &over.combos { + vals.insert(format!("combo_{k}"), *v as f32); + } + } + vals + }, + vertex_buffers: Vec::new(), + }], + fbos: Vec::new(), + uses_audio: false, + }); + } + let Ok(eff_def) = effect_def::load_effect_by_file(resolver, &inst.file) else { + tracing::warn!( + target: "effect", + "SKIP '{effect_name}': no effect.json at '{}'", + inst.file + ); + return None; + }; + // Bundle directory for this effect (e.g. "effects/clouds" or a + // workshop-nested "effects/workshop/2138904733/foo"), used to check for + // bundled materials/shaders before falling back to root-relative paths. + let effect_dir = inst + .file + .rsplit_once('/') + .map(|(dir, _)| dir) + .unwrap_or(inst.file.as_str()); + + let fbos: Vec<(String, f32)> = eff_def + .fbos + .iter() + .filter_map(|f| { + f.name + .as_ref() + .map(|n| (n.clone(), f.scale.unwrap_or(1.0) as f32)) + }) + .collect(); + + let default_override = PassOverride::default(); + let mut passes: Vec = Vec::new(); + let mut uses_audio = false; + for (pass_idx, pass) in eff_def.passes.iter().enumerate() { + let Some(mat_path) = pass.material.as_deref() else { + continue; + }; + let Ok(mat_def) = effect_def::load_material_from_effect(resolver, effect_dir, mat_path) + else { + tracing::warn!( + target: "effect", + "SKIP '{effect_name}' pass {pass_idx}: material '{mat_path}' not found" + ); + continue; + }; + let Some(mat_pass) = mat_def.passes.first() else { + continue; + }; + let Some(shader_name) = mat_pass.shader.as_deref() else { + continue; + }; + let Ok((frag_glsl, vert_glsl)) = + resolver.load_glsl_shader_for_effect(shader_name, Some(effect_dir)) + else { + tracing::warn!( + target: "effect", + "SKIP '{effect_name}' pass {pass_idx}: shader '{shader_name}' not found" + ); + continue; + }; + // Some WE downsample/blur shaders read a fixed-size array varying + // through a `for` loop (`v_TexCoord[i]`) rather than literal indices; + // unroll that loop into literal accesses first so the array-varying + // pass below (which only handles literal indices) can then unroll + // the declaration too. + let frag_glsl = transpiler::unroll_simple_for_loops(&frag_glsl); + let vert_glsl = transpiler::unroll_simple_for_loops(&vert_glsl); + // WE's downsample/blur/bloom shaders declare literally-indexed array + // varyings (`v_TexCoord[4]`) that naga's GLSL frontend can't accept + // as entry-point I/O; unroll them to scalar varyings before anything + // else touches the source. + let frag_glsl = transpiler::unroll_array_varyings(&frag_glsl); + let vert_glsl = transpiler::unroll_array_varyings(&vert_glsl); + // Some shaders call max/min/clamp with a bare integer literal where a + // float is expected (e.g. nitro.frag's `max(0, albedo.rgb)`) — legacy + // NVIDIA compilers implicitly promoted it, naga/shaderc don't. + let frag_glsl = transpiler::coerce_int_literal_builtin_args(&frag_glsl); + let vert_glsl = transpiler::coerce_int_literal_builtin_args(&vert_glsl); + // Some shaders assign a swizzle of a wider vector from `mix()` using + // the un-swizzled vector as the first arg (e.g. shift_hue.frag's + // `albedo.rgb = mix(albedo, newAlbedo, mask)` where `albedo` is + // vec4) — WE's compiler truncates implicitly, naga/shaderc don't. + let frag_glsl = transpiler::coerce_swizzle_mismatched_mix_arg(&frag_glsl); + let vert_glsl = transpiler::coerce_swizzle_mismatched_mix_arg(&vert_glsl); + + // Scene.json pass overrides align with effect.json pass indices + // (the reference's ImageEffectPassOverride). + let override_ = inst + .pass_overrides + .get(pass_idx) + .unwrap_or(&default_override); + + // Combos: material's, then the scene override's on top, uppercased + // (the reference uppercases names when emitting #defines). + let mut combos: HashMap = HashMap::new(); + for (k, v) in &mat_pass.combos { + combos.insert(k.to_uppercase(), *v); + } + for (k, v) in &override_.combos { + combos.insert(k.to_uppercase(), *v); + } + // Texture bindings: the material's own list, with the scene + // instance's per-pass override applied on top position-by-position + // (a scene `null` entry means "keep the material's default", not + // "clear it" — matches the reference's ImageEffectPassOverride). + let merged_textures: Vec> = { + let len = mat_pass.textures.len().max(override_.textures.len()); + (0..len) + .map(|i| { + override_ + .textures + .get(i) + .and_then(|t| t.clone()) + .or_else(|| mat_pass.textures.get(i).and_then(|t| t.clone())) + }) + .collect() + }; + // Some sampler2D uniforms annotate a `"combo":"NAME"` (e.g. nitro's + // opacitymask slot) whose real semantics is "enabled whenever a real + // texture is assigned to this slot" rather than a JSON-declared + // combo default. Without this, an optional mask texture the scene + // *does* provide never actually gates anything — the masked effect + // washes over the whole layer instead of being confined to the mask + // (e.g. nitro.frag's `#if MASK` block silently compiling out). + let sampler_combos: Vec> = + crate::engine::gpu::shaders::uniform_meta::parse_uniform_metadata(&frag_glsl) + .into_iter() + .filter(|m| m.uniform_type == "sampler2D") + .map(|m| m.combo) + .collect(); + for (i, tex) in merged_textures.iter().enumerate() { + let Some(name) = tex else { continue }; + if name.is_empty() || name.starts_with("_rt_") || name.starts_with("_alias_") { + continue; + } + if let Some(Some(combo_name)) = sampler_combos.get(i) { + combos.insert(combo_name.to_uppercase(), 1); + } + } + + // Constants: material JSON defaults, overridden by scene values. + let mut values: ShaderVals = ShaderVals::new(); + for (k, v) in &mat_pass.constant_shader_values { + insert_value_components(&mut values, k, v); + } + for (k, v) in &override_.values { + values.insert(k.clone(), *v); + } + + let blending_str = mat_pass.blending.as_deref().unwrap_or("normal"); + let model = ShaderModel::from_resolved_glsl( + shader_name.to_string(), + frag_glsl, + combos, + WEBlending::from_str(blending_str), + ); + let translated = match transpiler::translate_full(&model, Some(&vert_glsl)) { + Ok(t) => t, + Err(e) => { + tracing::warn!(target: "effect", "SKIP '{effect_name}' pass {pass_idx}: GLSL→WGSL failed: {e:#}"); + continue; + } + }; + uses_audio |= translated.uses_audio; + let key = format!("fx{instance_idx}:{effect_name}#{pass_idx}"); + // Temporary debug hook: dump translated WGSL per pass. + if std::env::var("WP_DEBUG_DUMP_WGSL").is_ok() { + let safe = key.replace([':', '/', '#'], "_"); + let _ = std::fs::write(format!("/tmp/wgsl_{safe}.frag.wgsl"), &translated.wgsl); + if let Some(v) = &translated.vert_wgsl { + let _ = std::fs::write(format!("/tmp/wgsl_{safe}.vert.wgsl"), v); + } + } + let blend = wgpu_blend_state(&model.blending); + let mut pipeline_attrs = translated.attributes.clone(); + let mut added = renderer.add_dynamic_pipeline( + key.clone(), + &translated.wgsl, + translated.vert_wgsl.as_deref(), + translated.uniform_keys.clone(), + &pipeline_attrs, + blend, + ); + if added.is_err() && !pipeline_attrs.is_empty() { + // Real-VS pipeline failed (usually an interface mismatch the + // preprocessor couldn't reconcile) — retry with the synthetic VS. + tracing::warn!( + target: "effect", + "'{effect_name}' pass {pass_idx}: real VS failed ({}), synthetic fallback", + added.as_ref().err().map(|e| e.to_string()).unwrap_or_default() + ); + if let Ok(fallback) = transpiler::translate(&model) { + pipeline_attrs = Vec::new(); + added = renderer.add_dynamic_pipeline( + key.clone(), + &fallback.wgsl, + fallback.vert_wgsl.as_deref(), + fallback.uniform_keys.clone(), + &[], + blend, + ); + } + } + if let Err(e) = added { + tracing::warn!(target: "effect", "SKIP '{effect_name}' pass {pass_idx}: pipeline failed: {e}"); + continue; + } + let vertex_buffers = renderer.attr_buffers_for(&pipeline_attrs); + + // Secondary textures (g_Texture1..): shader-annotation defaults, + // overridden by the material pass's texture list (with the scene + // instance's own override merged in above). Slot indices must stay + // aligned, so failed loads push a white 1×1 stand-in instead of + // shifting later slots down. + let mut texture_names: Vec> = model + .texture_slots + .iter() + .skip(1) + .map(|slot| slot.default_path.clone()) + .collect(); + // Framebuffer refs in the texture list (e.g. refraction's compose + // pass: `"textures": [null, "_rt_FullFrameBuffer"]`) aren't files — + // they become extra binds, resolved at draw time like `bind` entries. + let mut fbo_ref_binds: Vec<(u32, String)> = Vec::new(); + for (i, tex) in merged_textures.iter().enumerate().skip(1) { + let slot = i - 1; + if slot >= texture_names.len() { + texture_names.resize(slot + 1, None); + } + if let Some(name) = tex { + if name.starts_with("_rt_") { + fbo_ref_binds.push((i as u32, name.clone())); + } else if !name.starts_with("_alias_") { + texture_names[slot] = Some(name.clone()); + } + } + } + for (slot, name) in texture_names.iter().enumerate() { + if let Some(n) = name { + if n.starts_with("_rt_") { + fbo_ref_binds.push((slot as u32 + 1, n.clone())); + } + } + } + texture_names + .iter_mut() + .filter(|n| n.as_deref().is_some_and(|n| n.starts_with("_rt_"))) + .for_each(|n| *n = None); + if std::env::var("WP_DEBUG_TEX_SLOTS").is_ok() { + eprintln!( + "[texslots] {effect_name} pass{pass_idx}: merged={:?} names={:?} slots={:?}", + merged_textures, + texture_names, + model + .texture_slots + .iter() + .map(|s| ( + s.glsl_name.clone(), + s.default_path.clone(), + s.is_framebuffer + )) + .collect::>() + ); + } + let mut textures: Vec = Vec::new(); + for name in &texture_names { + let path = name.as_deref().unwrap_or(""); + // WE texture names resolve under materials/ first + // (e.g. "util/clouds_256" → materials/util/clouds_256.tex). + let candidates = [format!("materials/{path}.tex"), format!("{path}.tex")]; + let img = candidates.iter().find_map(|rel| { + let bytes = resolver.read(rel)?; + crate::formats::tex::TexFile::parse(&bytes) + .ok()? + .to_rgba() + .ok() + }); + match img { + Some(img) => textures.push(renderer.upload_texture(&img)), + None => { + if !path.is_empty() { + tracing::warn!( + target: "effect", + "'{effect_name}': texture '{path}' not found — using white" + ); + } + textures.push(renderer.upload_texture(&RgbaImage::from_pixel( + 1, + 1, + image::Rgba([255, 255, 255, 255]), + ))); + } + } + } + renderer.dynamic_textures.insert(key.clone(), textures); + + passes.push(EffectPassRuntime { + tex_key: key.clone(), + key, + hardcoded: false, + target: pass.target.clone(), + // Draw-time binding applies these in order, so the effect's own + // `bind` entries must come LAST — CPass.cpp:809 "binds are set + // last as they're the most important to be set". With the + // material's texture-list _rt_ refs winning instead, godrays' + // combine pass read the (still empty) scene snapshot as its + // albedo and washed the whole layer to the clear colour. + binds: fbo_ref_binds + .into_iter() + .chain( + pass.bind + .iter() + .filter_map(|b| b.name.as_ref().map(|n| (b.index.unwrap_or(0), n.clone()))), + ) + .collect(), + values, + vertex_buffers, + }); + } + + if passes.is_empty() { + tracing::warn!(target: "effect", "SKIP '{effect_name}': no usable passes"); + return None; + } + tracing::debug!(target: "effect", "LOADED '{effect_name}' ({} passes)", passes.len()); + Some(EffectRuntime { + passes, + fbos, + uses_audio, + }) +} + +pub(super) fn collect_effects(scene: &crate::engine::model::SceneModel) -> Vec { + let alias = |k: &str| -> String { + k.strip_prefix("ui_editor_properties_") + .unwrap_or(k) + .replace('_', "") + .to_lowercase() + }; + let mut result = Vec::new(); + for (i, obj) in scene.objects.iter().enumerate() { + if !obj.is_visible() { + continue; + } + for (effect_index, eff) in obj.effects.iter().enumerate() { + if !eff.visible { + continue; + } + let file = match &eff.file { + Some(s) => s, + None => continue, + }; + // WE effect paths look like "effects/{name}/effect.json"; the effect + // name is the directory, not the filename (which is always "effect.json"). + let mut parts = file.rsplitn(3, '/'); + let _filename = parts.next().unwrap_or(""); // "effect.json" + let name = parts + .next() + .unwrap_or(file) // "waterflow" + .trim_end_matches(".json") + .to_lowercase(); + + // Scene.json pass entries align with effect.json pass indices. + let pass_overrides: Vec = eff + .passes + .iter() + .map(|p| { + let mut values = ShaderVals::new(); + for (k, v) in &p.shader_values { + insert_animated_components(&mut values, k, &alias(k), v); + } + PassOverride { + combos: p.combos.clone(), + values, + textures: p.textures.clone(), + } + }) + .collect(); + + result.push(EffectInstanceDef { + layer_idx: i, + effect_index, + name, + file: file.clone(), + pass_overrides, + }); + } + } + result +} + +/// Upload one CPU `Layer` and derive its GPU-side quad. `authored_origin` is +/// the object's scene.json origin, before alignment. +pub(super) fn gpu_layer_from( + renderer: &GpuSceneRenderer, + l: &crate::engine::model::resolve::Layer, + w: u32, + h: u32, + zoom: f32, + camera3d: Option<&crate::engine::sim::camera3d::PerspectiveCamera>, + authored_origin: [f64; 3], +) -> SceneLayerGpu { + let all_frames: Vec<&RgbaImage> = std::iter::once(&l.image) + .chain(l.extra_frames.iter()) + .collect(); + // Textures stay at native resolution; the composite quad and + // sampler handle scaling to the object's on-screen size. + let mut frames: Vec = all_frames + .iter() + .map(|frame| renderer.upload_texture(frame)) + .collect(); + + // Unscaled object size: explicit scene.json size, else the + // texture's native resolution (CImage.cpp lines 213-222). + // True scene-fullscreen layers (models/util/fullscreenlayer.json + // etc.) never reach here — they're filtered out upstream by + // `is_special_layer` — so an unspecified size always means + // "use the texture's own dimensions", never "cover the scene". + let effective_size = if l.size[0] > 0.0 && l.size[1] > 0.0 { + [l.size[0], l.size[1]] + } else { + [l.image.width() as f64, l.image.height() as f64] + }; + let object_size = ( + effective_size[0].round() as u32, + effective_size[1].round() as u32, + ); + + // Object quad rect in NDC. WE origins are absolute scene + // coordinates (Y-up, bottom-left origin) pointing at the + // object's center. + let size_px = [ + effective_size[0] * l.scale[0], + effective_size[1] * l.scale[1], + ]; + let (rect, angle, depth, quad_corners) = if let Some(cam) = camera3d { + let center = [l.origin[0] as f32, l.origin[1] as f32, l.origin[2] as f32]; + let (rect, depth) = project_quad_ndc(cam, center, l.angles, size_px); + let quad_corners = project_quad_corners(cam, center, l.angles, size_px); + (rect, 0.0, depth, quad_corners) + } else { + // `general.zoom` scales the whole scene about its center; + // NDC (0,0) is the scene center, so a uniform rect scale + // does it (center and half-extent both × zoom). + let rect = [ + (2.0 * l.origin[0] / w as f64 - 1.0) as f32 * zoom, + (2.0 * l.origin[1] / h as f64 - 1.0) as f32 * zoom, + (size_px[0] / w as f64) as f32 * zoom, + (size_px[1] / h as f64) as f32 * zoom, + ]; + (rect, l.angle, 0.0, None) + }; + + // A backdrop layer's texture is a copy of the scene under its quad, + // sized to that on-screen region. + if l.backdrop.is_some() { + let px = ( + ((rect[2] * w as f32).round() as u32).clamp(1, w.max(1)), + ((rect[3] * h as f32).round() as u32).clamp(1, h.max(1)), + ); + frames = vec![renderer.create_render_target(px.0, px.1)]; + } + SceneLayerGpu { + frames, + puppet: l.puppet.clone(), + puppet_posed_at: 0.0, + video: l.video.clone(), + blend_mode: l.blend_mode, + alpha: l.alpha, + text_dynamic: l.text_dynamic.clone(), + color: l.color, + brightness: l.brightness, + frame_duration_ms: l.frame_duration_ms, + parallax_depth: [l.parallax_depth[0] as f32, l.parallax_depth[1] as f32], + angle, + rect, + quad_corners, + depth, + object_size, + no_interpolation: l.no_interpolation, + clamp_uvs: l.clamp_uvs, + order_index: l.order_index, + object_id: l.object_id, + visible: l.visible_base, + effective_size, + align_offset: std::array::from_fn(|i| l.local_origin[i] - authored_origin[i]), + script_frame: None, + backdrop: l.backdrop, + pending_text: None, + text_rastered_at: None, + text_raster_cost: Duration::ZERO, + } +} diff --git a/src/engine/gpu/renderer/draw.rs b/src/engine/gpu/renderer/draw.rs new file mode 100644 index 0000000..02160e8 --- /dev/null +++ b/src/engine/gpu/renderer/draw.rs @@ -0,0 +1,1219 @@ +//! Drawing a frame: the per-frame render (layers in scene order, effects, +//! composite, bloom), the image, particle and 3D mesh layer passes, the +//! backdrop copy refraction effects sample, and handing the result over as +//! pixels or onto a surface. + +use super::*; + +impl GpuSceneInstance { + /// For a composition/fullscreen layer, copy the scene drawn so far under + /// its quad into its texture, so its effects process the backdrop. + pub(super) fn capture_backdrop(&self, encoder: &mut wgpu::CommandEncoder, layer_idx: usize) { + let layer = &self.layers[layer_idx]; + if layer.backdrop.is_none() { + return; + } + let dst = &layer.frames[0]; + let (w, h) = (self.width as i64, self.height as i64); + // Quad → target pixels (texture rows run top-down). + let x0 = (((layer.rect[0] - layer.rect[2]) + 1.0) * 0.5 * w as f32).round() as i64; + let y0 = ((1.0 - (layer.rect[1] + layer.rect[3])) * 0.5 * h as f32).round() as i64; + let (sx, sy) = (x0.max(0), y0.max(0)); + let (dx, dy) = (sx - x0, sy - y0); + let cw = (dst.width() as i64 - dx).min(w - sx); + let ch = (dst.height() as i64 - dy).min(h - sy); + if cw <= 0 || ch <= 0 { + return; + } + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.target, + mip_level: 0, + origin: wgpu::Origin3d { + x: sx as u32, + y: sy as u32, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: dst, + mip_level: 0, + origin: wgpu::Origin3d { + x: dx as u32, + y: dy as u32, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: cw as u32, + height: ch as u32, + depth_or_array_layers: 1, + }, + ); + } + + /// Draw every static mesh in one depth-tested pass, over the cleared scene + /// target and under the 2D layers. + /// + /// ponytail: meshes always draw before all 2D layers rather than + /// interleaving by `order_index` — real mesh scenes put their 2D content on + /// top (overlays/UI) so it hasn't mattered. Interleaving needs the depth + /// buffer shared with the composite pipeline. + /// Draw one mesh, depth-tested, into the scene target. + /// + /// Per-mesh rather than one batched pass because meshes interleave with 2D + /// layers by `order_index` — a scene's background layers sort *before* its + /// meshes and must not paint over them (3453730450's moon sits at order 10 + /// behind nine full-screen fills). `clear_depth` is set for the first mesh + /// of a frame; later ones load the existing buffer so they still occlude + /// each other correctly. + pub(super) fn draw_one_mesh3d( + &self, + encoder: &mut wgpu::CommandEncoder, + target_view: &wgpu::TextureView, + idx: usize, + clear_depth: bool, + ) { + let (Some(depth), Some(m)) = (&self.mesh3d_depth, self.mesh3d.get(idx)) else { + return; + }; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("mesh3d_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: depth, + depth_ops: Some(wgpu::Operations { + load: if clear_depth { + wgpu::LoadOp::Clear(1.0) + } else { + wgpu::LoadOp::Load + }, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + ..Default::default() + }); + pass.set_pipeline( + &self.renderer.mesh3d_pipelines[m.nocull as usize | ((!m.depthtest as usize) << 1)], + ); + pass.set_bind_group(0, &m.bind_group, &[]); + pass.set_vertex_buffer(0, m.vbuf.slice(..)); + pass.set_index_buffer(m.ibuf.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..m.index_count, 0, 0..1); + } + + /// Render the next frame into the internal scene target. + pub fn render(&mut self) { + let time = self.start.elapsed().as_secs_f32(); + let delta = (time - self.last_time).max(0.0); + self.last_time = time; + let dynamics = self.dynamics.update(time, delta, self.mouse_norm); + + self.tick_timelines(delta); + self.tick_scripts(time, delta); + + // Refresh the g_AudioSpectrum* UBO from the latest captured window, and + // drive audio-reactive particle emitters from the overall loudness. + if let Some(cap) = &self.audio_capture { + let spectrum = cap.spectrum(); + self.renderer.queue.write_buffer( + &self.renderer.audio_buf, + 0, + &spectrum.to_uniform_bytes(), + ); + let bands = + std::array::from_fn(|i| (spectrum.s16_left[i] + spectrum.s16_right[i]) * 0.5); + for system in &mut self.particle_systems { + system.set_audio_bands(bands); + } + } + + let target_view = self.target.create_view(&Default::default()); + let mut encoder = + self.renderer + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("frame_encoder"), + }); + + // 1. Clear scene target. + { + let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("clear_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &target_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: self.clear_color[0], + g: self.clear_color[1], + b: self.clear_color[2], + a: 1.0, + }), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + ..Default::default() + }); + } + + // Re-pose animated puppet layers at a capped rate: skin the mesh at + // the current animation time and re-upload frames[0]. CPU skinning + + // rasterization runs ~30ms at 4K, so this ticks at + // PUPPET_UPDATE_INTERVAL rather than every frame — the idle-style + // animations these carry (breathing, hair sway) are far slower than + // even that. + // Pull the newest decoded frame for embedded-video layers (the + // ffmpeg thread paces itself; an empty channel means no new frame + // this tick and the current texture stays). + for i in 0..self.layers.len() { + let Some(stream) = self.layers[i].video.clone() else { + continue; + }; + let Some(frame) = stream.lock().ok().and_then(|s| s.latest_frame()) else { + continue; + }; + let tex = self.renderer.upload_texture(&frame); + self.layers[i].frames[0] = tex; + } + + const PUPPET_UPDATE_INTERVAL: f32 = 1.0 / 15.0; + for i in 0..self.layers.len() { + let Some(runtime) = self.layers[i].puppet.clone() else { + continue; + }; + if (time - self.layers[i].puppet_posed_at).abs() < PUPPET_UPDATE_INTERVAL { + continue; + } + let (w, h) = (runtime.atlas.width(), runtime.atlas.height()); + let posed = runtime.render_at(time, w, h); + let tex = self.renderer.upload_texture(&posed); + self.layers[i].frames[0] = tex; + self.layers[i].puppet_posed_at = time; + } + + // 2/3. Image layers and particle systems, interleaved by + // `order_index` (true scene z-order, matching the reference's single + // shared per-object render order) instead of drawing all particles + // after all images. + enum DrawItem { + Image(usize), + Particle(usize), + Mesh(usize), + } + let mut items: Vec<(usize, DrawItem)> = self + .layers + .iter() + .enumerate() + // A `visible` script can hide a layer this frame — drop it here. + .filter(|(_, l)| l.visible) + .map(|(i, l)| (l.order_index, DrawItem::Image(i))) + .chain( + self.particle_systems + .iter() + .enumerate() + .map(|(i, _)| (self.particle_order[i], DrawItem::Particle(i))), + ) + // Meshes interleave by scene order like everything else: a scene's + // background layers can sort before its meshes. + .chain( + self.mesh3d + .iter() + .enumerate() + .map(|(i, m)| (m.order_index, DrawItem::Mesh(i))), + ) + .collect(); + let rank = |order: usize| self.draw_rank.get(order).copied().unwrap_or(order as f64); + items.sort_by(|(a, _), (b, _)| rank(*a).total_cmp(&rank(*b))); + + if self.perspective { + // Painter's algorithm for 3D scenes: draw image layers strictly + // back-to-front by view-space depth (no depth buffer yet), with + // culled quads (depth < 0) dropped. Particles keep drawing after + // images, as in the ortho path's known simplification. + items.retain(|(_, item)| match item { + DrawItem::Image(i) => self.layers[*i].depth >= 0.0, + DrawItem::Particle(_) => true, + // Never culled on centre depth — a skybox surrounds the camera. + DrawItem::Mesh(_) => true, + }); + items.sort_by(|(oa, a), (ob, b)| { + let key = |it: &DrawItem, order: usize| match it { + DrawItem::Image(i) => (0, -self.layers[*i].depth, order), + // Meshes share the images' back-to-front ordering so a + // distant backdrop layer still draws behind them. + DrawItem::Mesh(i) => (0, -self.mesh3d[*i].depth, order), + DrawItem::Particle(_) => (1, 0.0, order), + }; + key(a, *oa) + .partial_cmp(&key(b, *ob)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } + + // The depth buffer is cleared by the frame's first mesh; later meshes + // load it so they still occlude one another. + let mut mesh_depth_cleared = false; + for (_, item) in items { + match item { + DrawItem::Mesh(idx) => { + self.draw_one_mesh3d(&mut encoder, &target_view, idx, !mesh_depth_cleared); + mesh_depth_cleared = true; + } + DrawItem::Image(layer_idx) => { + self.draw_image_layer_gpu( + &mut encoder, + &target_view, + layer_idx, + time, + dynamics, + ); + } + DrawItem::Particle(idx) => { + self.draw_particle_layer_gpu(&mut encoder, &target_view, idx, delta); + } + } + } + + // 4. Volumetric light shafts (before bloom, so a bright shaft can + // itself contribute to the bloom pass — matching how particles are + // ordered before bloom for the same reason, see their own doc + // comment on `particle_systems`). + self.record_volumetrics(&mut encoder, &target_view); + + // 5. Scene bloom chain. + if self.bloom.enabled { + self.record_bloom(&mut encoder, &target_view); + } + + self.renderer.queue.submit(Some(encoder.finish())); + } + + pub(super) fn draw_image_layer_gpu( + &self, + encoder: &mut wgpu::CommandEncoder, + target_view: &wgpu::TextureView, + layer_idx: usize, + time: f32, + dynamics: CameraFrameDynamics, + ) { + self.capture_backdrop(encoder, layer_idx); + { + let layer = &self.layers[layer_idx]; + let frame_idx = if let Some(f) = layer.script_frame { + f.min(layer.frames.len() - 1) + } else if layer.frames.len() > 1 && layer.frame_duration_ms > 0 { + ((time * 1000.0 / layer.frame_duration_ms as f32) as usize) % layer.frames.len() + } else { + 0 + }; + let base_tex = &layer.frames[frame_idx]; + let base_view = base_tex.create_view(&Default::default()); + let tex_res = |t: &wgpu::Texture| -> [f32; 4] { + let (tw, th) = (t.width() as f32, t.height() as f32); + [tw, th, tw, th] + }; + + // Engine uniforms shared by every pass of this layer. + let engine_base = EngineUniforms { + time, + daytime: daytime_fraction(), + texel_size: [1.0 / self.width as f32, 1.0 / self.height as f32], + pointer: self.mouse_norm, + resolutions: [[ + self.width as f32, + self.height as f32, + self.width as f32, + self.height as f32, + ]; 8], + color: layer.color, + alpha: layer.alpha, + brightness: layer.brightness, + }; + + // Ping-pong through this layer's effect passes (own object-sized pair). + let pp = [ + self.fbo_pool.get(&pingpong_key(layer_idx, 0)).unwrap(), + self.fbo_pool.get(&pingpong_key(layer_idx, 1)).unwrap(), + ]; + let layer_sampler = self + .renderer + .sampler_for(layer.no_interpolation, layer.clamp_uvs); + // Effects reference their owner by raw scene-object index + // (layer.order_index), NOT by position in `self.layers` — + // positional matching applied effects to whichever layer + // happened to share the count once particles/skipped objects + // offset the two spaces. + let obj_index = layer.order_index; + let has_effects = self + .scene_effects + .iter() + .zip(&self.effect_enabled) + .any(|(inst, on)| *on && inst.layer_idx == obj_index); + + // Base material pass (genericimage3-equivalent): reference always + // renders `texture * g_Color4` into the object's own FBO *before* + // any effects run, so effects see the tinted/alpha'd image rather + // than the raw texture (CImage::setup pushes the material's own + // passes first, ahead of the effect chain). Skipped when the layer + // has no effects: with nothing in between, tinting at the final + // composite step produces an identical result for a fraction of + // the draw calls. + let mut cur: Option = None; // None = still reading base texture + if has_effects { + let base_tint = [ + layer.color[0] * layer.brightness, + layer.color[1] * layer.brightness, + layer.color[2] * layer.brightness, + ]; + let base_pass_buf = self.renderer.make_uniform_buffer( + &composite_params( + layer.alpha, + 0, + [0.0, 0.0], + base_tint, + 0.0, + FULLSCREEN_RECT, + 1.0, + [1.0, 1.0], + ), + 64, + ); + let base_pass_bg = self.renderer.make_base_bind_group( + &base_view, + layer_sampler, + &base_pass_buf, + &[], + ); + self.renderer.run_pass( + encoder, + &self.renderer.base_pass_pipeline, + &base_pass_bg, + None, + &pp[0].view(), + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + "base_material_pass", + 6, + &[], + ); + cur = Some(0); + } + for (instance_idx, inst) in self + .scene_effects + .iter() + .enumerate() + .filter(|(k, inst)| inst.layer_idx == obj_index && self.effect_enabled[*k]) + { + let Some(runtime) = self + .effect_runtimes + .get(instance_idx) + .and_then(|r| r.as_ref()) + else { + continue; + }; + for pass in &runtime.passes { + let Some(pipeline) = self.renderer.find_effect_pipeline(&pass.key) else { + continue; + }; + + // Chain source before bind-0 overrides ("previous" binds + // and the default input both refer to it). + let (chain_view, chain_res) = match cur { + None => (base_view.clone(), tex_res(base_tex)), + Some(i) => (pp[i].view(), tex_res(&pp[i].texture)), + }; + + let mut engine = engine_base; + engine.resolutions[0] = chain_res; + + // Extra texture slots: shader/material textures first, + // FBO binds override. + let mut extra: Vec> = vec![None; 7]; + if let Some(texs) = self.renderer.dynamic_textures.get(&pass.tex_key) { + for (i, tex) in texs.iter().enumerate().take(7) { + extra[i] = Some(tex.create_view(&Default::default())); + engine.resolutions[i + 1] = tex_res(tex); + } + } + let mut src_view = chain_view.clone(); + for (slot, fbo_name) in &pass.binds { + // A bind named "previous" is the chain input itself. + let (view, res) = if fbo_name == "previous" { + (chain_view.clone(), chain_res) + } else if let Some(rt) = fbo_name + .starts_with("_rt_imageLayerComposite") + .then(|| self.fbo_pool.get(fbo_name.as_str())) + .flatten() + { + // The owning layer's own composite, captured when it + // drew (see draw_image_layer_gpu). Transparent when + // that layer hasn't drawn yet this frame, which adds + // nothing — the safe direction. + (rt.view(), tex_res(&rt.texture)) + } else if fbo_name == "_rt_FullFrameBuffer" + || fbo_name == "_rt_MipMappedFrameBuffer" + // A per-layer composite we never captured falls back + // to the scene-so-far snapshot: effects run before + // their layer composites, so the scene target holds + // "everything behind this layer". Without any bind + // the sampler keeps its `util/white` default — which + // is what blended 2821407073's mountains to white. + || fbo_name.starts_with("_rt_imageLayerComposite") + { + // The wallpaper-global scene buffer (CWallpaper.cpp + // creates it at scene size; MipMapped is an alias). + // Effects run before their layer composites, so the + // scene target currently holds exactly "the scene + // behind this layer" — snapshot it, since a pass + // can't sample its own render target. + encoder.copy_texture_to_texture( + self.target.as_image_copy(), + self.scene_copy.as_image_copy(), + wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + ); + ( + self.scene_copy.create_view(&Default::default()), + [ + self.width as f32, + self.height as f32, + self.width as f32, + self.height as f32, + ], + ) + } else { + let key = named_fbo_key(instance_idx, fbo_name); + match self.fbo_pool.get(&key) { + Some(rt) => (rt.view(), tex_res(&rt.texture)), + None => continue, + } + }; + if *slot == 0 { + src_view = view; + engine.resolutions[0] = res; + } else if (*slot as usize) <= 7 { + extra[(*slot - 1) as usize] = Some(view); + engine.resolutions[*slot as usize] = res; + } + } + + let params = if pass.hardcoded { + make_effect_params(&inst.name, time, &pass.values) + } else { + let keys = self + .renderer + .dynamic_uniform_keys + .get(&pass.key) + .map(|v| v.as_slice()) + .unwrap_or(&[]); + make_params_from_translated_typed(keys, &engine, &pass.values) + }; + + let composite_buf = self + .renderer + .make_uniform_buffer(&passthrough_composite_params(), 64); + let layer_sampler = self + .renderer + .sampler_for(layer.no_interpolation, layer.clamp_uvs); + let base_bg = self.renderer.make_base_bind_group( + &src_view, + layer_sampler, + &composite_buf, + &extra, + ); + let param_buf = self.renderer.make_uniform_buffer(¶ms, 16); + let effect_bg = self.renderer.make_effect_bind_group(¶m_buf); + + let next = cur.map(|i| 1 - i).unwrap_or(0); + let dst_view = match &pass.target { + Some(fbo_name) => { + let key = named_fbo_key(instance_idx, fbo_name); + match self.fbo_pool.get(&key) { + Some(rt) => rt.view(), + None => pp[next].view(), + } + } + None => pp[next].view(), + }; + + // Real-VS pipelines draw a 6-vertex quad from attribute + // buffers; synthetic-VS ones draw the fullscreen triangle. + let vertex_count = if pass.vertex_buffers.is_empty() { 3 } else { 6 }; + + self.renderer.run_pass( + encoder, + pipeline, + &base_bg, + Some(&effect_bg), + &dst_view, + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + "effect_pass", + vertex_count, + &pass.vertex_buffers, + ); + + // Named-target passes render aside; the chain continues + // from the same source. + if pass.target.is_none() { + cur = Some(next); + } + } + } + + // Composite the layer onto the scene target with camera dynamics. + let uv_offset = [ + dynamics.shake_offset[0] + + dynamics.parallax_displacement[0] * layer.parallax_depth[0], + dynamics.shake_offset[1] + + dynamics.parallax_displacement[1] * layer.parallax_depth[1], + ]; + // Color/alpha/brightness were already baked in by the base + // material pass above when this layer has effects; re-applying + // them here would double them up. + let (tint, opacity) = if has_effects { + ([1.0, 1.0, 1.0], dynamics.fade) + } else { + ( + [ + layer.color[0] * layer.brightness, + layer.color[1] * layer.brightness, + layer.color[2] * layer.brightness, + ], + dynamics.fade * layer.alpha, + ) + }; + let src_view = match cur { + None => base_view, + Some(i) => pp[i].view(), + }; + // True perspective-quad silhouette, normal blending only — see + // `vs_quad3d`/`fs_quad3d` and the Ghidra report's quad-warp + // finding. `uv_offset` (shake/parallax) isn't applied on this + // path: under true perspective it belongs on the camera or the + // object's world position, not a flat post-hoc UV shift, and + // wiring that through is future work, not this fix. Falls + // through to the same draw + `_rt_imageLayerComposite` capture + // code below as the rect-based path, just with a different + // pipeline/bind group, so neither can drift out of sync with + // the other. + let (pipeline, base_bg) = if layer.blend_mode == 0 && layer.quad_corners.is_some() { + let corners = layer.quad_corners.unwrap(); + let mut params = Vec::with_capacity(80); + for c in corners { + params.extend_from_slice(&c[0].to_le_bytes()); + params.extend_from_slice(&c[1].to_le_bytes()); + params.extend_from_slice(&0.0f32.to_le_bytes()); + params.extend_from_slice(&0.0f32.to_le_bytes()); + } + for f in [tint[0], tint[1], tint[2], opacity] { + params.extend_from_slice(&f.to_le_bytes()); + } + let quad3d_buf = self.renderer.make_uniform_buffer(¶ms, 80); + let quad3d_bg = + self.renderer + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("quad3d_bg"), + layout: &self.renderer.quad3d_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: quad3d_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&src_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(layer_sampler), + }, + ], + }); + (&self.renderer.quad3d_pipeline, quad3d_bg) + } else { + let composite_buf = self.renderer.make_uniform_buffer( + &composite_params( + opacity, + layer.blend_mode as i32, + uv_offset, + tint, + layer.angle, + layer.rect, + self.width as f32 / self.height as f32, + [self.width as f32, self.height as f32], + ), + 64, + ); + // Photoshop-style blend modes read the destination + // in-shader, so snapshot the scene target before drawing + // over it. + let extra: Vec> = if layer.blend_mode != 0 { + encoder.copy_texture_to_texture( + self.target.as_image_copy(), + self.scene_copy.as_image_copy(), + wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + ); + vec![Some(self.scene_copy.create_view(&Default::default()))] + } else { + vec![] + }; + let base_bg = self.renderer.make_base_bind_group( + &src_view, + layer_sampler, + &composite_buf, + &extra, + ); + (self.renderer.composite_pipeline(layer.blend_mode), base_bg) + }; + self.renderer.run_pass( + encoder, + pipeline, + &base_bg, + None, + target_view, + wgpu::LoadOp::Load, + "composite_pass", + 6, + &[], + ); + + // Capture this layer's own composite for effects on other objects + // that bind `_rt_imageLayerComposite__*` (WE keeps one + // buffer per object; ours is filled with the same draw, on a + // cleared scene-sized target so only this layer is in it). + if let Some(id) = layer.object_id { + for suffix in ['a', 'b'] { + let name = format!("_rt_imageLayerComposite_{id}_{suffix}"); + if let Some(rt) = self.fbo_pool.get(name.as_str()) { + self.renderer.run_pass( + encoder, + pipeline, + &base_bg, + None, + &rt.view(), + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + "layer_composite_capture", + 6, + &[], + ); + } + } + } + } + } + + /// Steps and CPU-renders one particle system into a scene-sized RGBA + /// buffer, uploads it, and composites it like a normal fullscreen layer. + /// Steps a particle system and, if anything is alive, CPU-rasters + + /// uploads only its bounding box (`ParticleSystem::bounds()`) instead of + /// a full scene-sized buffer, compositing it as a positioned quad (the + /// same `rect`/`CompositeParams` mechanism image layers use) rather than + /// a fullscreen passthrough. Systems with no living particles are + /// skipped entirely — no raster, no allocation, no upload, no draw call. + /// A further possible optimization (not done here): reusing one + /// persistent GPU texture per system across frames instead of a fresh + /// `upload_texture` each frame — deferred since safely doing that while + /// the bbox size changes frame-to-frame needs UV-subregion plumbing the + /// composite shader doesn't have today. + pub(super) fn draw_particle_layer_gpu( + &mut self, + encoder: &mut wgpu::CommandEncoder, + target_view: &wgpu::TextureView, + idx: usize, + delta: f32, + ) { + // Escape hatch for A/B comparison against the old budgeted CPU + // rasterizer during the GPU-pipeline transition. + if std::env::var("WP_PARTICLE_CPU").is_ok() { + self.draw_particle_layer_cpu_raster(encoder, target_view, idx, delta); + return; + } + self.particle_systems[idx].step(delta); + + // Draw units: the parent system, then every live child instance + // (each child preset has its own sprite/blending). + let system = &self.particle_systems[idx]; + let assets = &self.particle_gpu_assets[idx]; + let mut units: Vec<(Vec, &ParticleGpuTex, bool)> = Vec::new(); + let mut verts = Vec::new(); + system.emit_gpu_vertices(&mut verts, assets.parent.frames as usize); + if !verts.is_empty() { + units.push((verts, &assets.parent, self.particle_additive[idx])); + } + system.visit_gpu_children(&mut |inst, child_idx, child_additive| { + let tex = assets.children.get(child_idx).unwrap_or(&assets.parent); + let mut v = Vec::new(); + inst.emit_gpu_vertices(&mut v, tex.frames as usize); + if !v.is_empty() { + units.push((v, tex, child_additive)); + } + }); + + for (verts, tex, additive) in units { + let bytes = particle::GpuVertex::as_bytes(&verts); + let vbuf = self.renderer.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("particle_verts"), + size: bytes.len() as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.renderer.queue.write_buffer(&vbuf, 0, &bytes); + + let mut params = Vec::with_capacity(16); + for f in [ + self.width as f32, + self.height as f32, + tex.overbright, + tex.frames as f32, + ] { + params.extend_from_slice(&f.to_le_bytes()); + } + let pbuf = self.renderer.make_uniform_buffer(¶ms, 16); + // Linear-repeat for ropes (V tiles past 1.0), linear-clamp for + // sprite quads (padding-safe at the edges). + let sampler = &self.renderer.samplers[if tex.repeat { 0 } else { 1 }]; + let bg = self + .renderer + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("particle_bg"), + layout: &self.renderer.particle_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: vbuf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&tex.view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: pbuf.as_entire_binding(), + }, + ], + }); + + let pipeline = if additive { + &self.renderer.particle_pipeline_add + } else { + &self.renderer.particle_pipeline_over + }; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("particle_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target_view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.draw(0..verts.len() as u32, 0..1); + } + } + + /// The pre-GPU-pipeline particle path: budgeted CPU rasterization into a + /// bbox buffer, uploaded and composited as a stretched quad. Kept behind + /// WP_PARTICLE_CPU=1 for A/B comparison; the CPU compositor paths + /// (model::resolve/cpu::animated) share the same underlying rasterizer. + pub(super) fn draw_particle_layer_cpu_raster( + &mut self, + encoder: &mut wgpu::CommandEncoder, + target_view: &wgpu::TextureView, + idx: usize, + delta: f32, + ) { + let system = &mut self.particle_systems[idx]; + let _t_step = std::time::Instant::now(); + system.step(delta); + let step_ms = _t_step.elapsed().as_secs_f32() * 1000.0; + let Some((min_x, min_y, max_x, max_y)) = system.bounds() else { + return; + }; + let min_x = min_x.max(0.0); + let min_y = min_y.max(0.0); + let max_x = max_x.min(self.width as f32); + let max_y = max_y.min(self.height as f32); + if max_x <= min_x || max_y <= min_y { + return; + } + let bw = (max_x - min_x).ceil().max(1.0) as u32; + let bh = (max_y - min_y).ceil().max(1.0) as u32; + + // Cap the CPU rasterization cost: huge soft sprites (smoke clouds, + // rain sheets with radii in the hundreds of pixels) can cover most + // of a 4K scene — rasterize into a budgeted buffer and let the + // composite quad stretch it back up. Cost scales with the square of + // this factor; the blur from upscaling is invisible on sprites that + // are soft gradients to begin with (e.g. 2491009392's rain+smoke + // went from ~0.6s/frame of CPU to real-time). + const PARTICLE_PIXEL_BUDGET: f32 = 1_500_000.0; + let area = bw as f32 * bh as f32; + // Budget against total *coverage* (sum of per-particle quad areas), + // not the buffer size: overdraw from many huge overlapping sprites + // is what actually costs, and it shrinks with scale² the same way. + let coverage = system.coverage(area).max(area); + let raster_scale = (PARTICLE_PIXEL_BUDGET / coverage).sqrt().min(1.0).max(0.2); + let sw = ((bw as f32 * raster_scale).ceil() as u32).max(1); + let sh = ((bh as f32 * raster_scale).ceil() as u32).max(1); + + let additive = self.particle_additive.get(idx).copied().unwrap_or(false); + let mut buf = RgbaImage::new(sw, sh); + let _t_raster = std::time::Instant::now(); + system.render_onto_scaled( + &mut buf, + self.particle_sprites[idx].as_ref(), + [min_x, min_y], + additive, + raster_scale, + ); + if std::env::var("WP_DEBUG_PARTICLE_TIMING").is_ok() { + tracing::trace!( + target: "timing", + "particles[{idx}]: step {step_ms:.1}ms, raster {sw}x{sh} (scale {raster_scale:.2}) {:.1}ms", + _t_raster.elapsed().as_secs_f32() * 1000.0 + ); + } + let tex = self.renderer.upload_texture(&buf); + let view = tex.create_view(&Default::default()); + let sampler = self.renderer.sampler_for(false, false); + + // Same NDC `rect` convention as image layers (`gpu_shaders.wgsl`'s + // `vs_composite_quad`): center + half-extent, Y-up — flip our + // pixel-space (Y-down) bbox center back into WE's Y-up scene + // convention first, matching `spawn_center`'s own flip. + let cx_px = min_x + bw as f32 / 2.0; + let cy_px = min_y + bh as f32 / 2.0; + let we_cy = self.height as f32 - cy_px; + let rect = [ + 2.0 * cx_px / self.width as f32 - 1.0, + 2.0 * we_cy / self.height as f32 - 1.0, + bw as f32 / self.width as f32, + bh as f32 / self.height as f32, + ]; + // Additive particle materials (fog/smoke/embers/rain/lightning — the + // overwhelming majority of real particle materials) need the + // dest-read blend pipeline. Mode 30 is our pure premultiplied add: + // the rasterizer above already accumulated `src * src_a` per + // particle (the reference's GL_SRC_ALPHA/GL_ONE quad blending), so + // the composite adds the buffer's RGB to the scene as-is. Plain + // alpha-over (mode 0) otherwise makes a sprite's near-black + // background visibly darken/box the scene instead of contributing + // nothing, since particle compositing previously always used mode 0. + const BLEND_MODE_PARTICLE_ADD: i32 = 100; + let mode = if additive { BLEND_MODE_PARTICLE_ADD } else { 0 }; + let composite_buf = self.renderer.make_uniform_buffer( + &composite_params( + 1.0, + mode, + [0.0, 0.0], + [1.0, 1.0, 1.0], + 0.0, + rect, + self.width as f32 / self.height as f32, + [self.width as f32, self.height as f32], + ), + 64, + ); + let extra: Vec> = if additive { + encoder.copy_texture_to_texture( + self.target.as_image_copy(), + self.scene_copy.as_image_copy(), + wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + ); + vec![Some(self.scene_copy.create_view(&Default::default()))] + } else { + vec![] + }; + let base_bg = self + .renderer + .make_base_bind_group(&view, sampler, &composite_buf, &extra); + self.renderer.run_pass( + encoder, + self.renderer.composite_pipeline(mode as u32), + &base_bg, + None, + target_view, + wgpu::LoadOp::Load, + "particle_composite", + 6, + &[], + ); + } + + pub(super) fn record_bloom( + &self, + encoder: &mut wgpu::CommandEncoder, + target_view: &wgpu::TextureView, + ) { + let q = self.fbo_pool.get("_rt_4FrameBuffer").unwrap(); + let e = self.fbo_pool.get("_rt_8FrameBuffer").unwrap(); + let bloom_rt = self.fbo_pool.get("_rt_Bloom").unwrap(); + let scene_view = self.target.create_view(&Default::default()); + let scene_texel = [1.0 / self.width as f32, 1.0 / self.height as f32]; + + // Snapshot the pre-bloom scene: the final combine pass both reads + // and writes `self.target`, which a GPU can't do within one pass. + encoder.copy_texture_to_texture( + self.target.as_image_copy(), + self.scene_copy.as_image_copy(), + wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + ); + let scene_copy_view = self.scene_copy.create_view(&Default::default()); + + let passthrough = self + .renderer + .make_uniform_buffer(&passthrough_composite_params(), 64); + + let run = |encoder: &mut wgpu::CommandEncoder, + pipeline: &wgpu::RenderPipeline, + src: &wgpu::TextureView, + extra: &[Option], + dst: &wgpu::TextureView, + params: &[u8]| { + let default_sampler = self.renderer.sampler_for(false, false); + let base_bg = + self.renderer + .make_base_bind_group(src, default_sampler, &passthrough, extra); + let param_buf = self.renderer.make_uniform_buffer(params, 16); + let effect_bg = self.renderer.make_effect_bind_group(¶m_buf); + self.renderer.run_pass( + encoder, + pipeline, + &base_bg, + Some(&effect_bg), + dst, + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + "bloom", + 3, + &[], + ); + }; + + let f32s = + |vals: &[f32]| -> Vec { vals.iter().flat_map(|f| f.to_le_bytes()).collect() }; + + // Pass 1: threshold + 4-tap box downsample, scene → quarter + // (downsample_quarter_bloom.frag: texel/threshold/strength; tint fixed + // at white since it isn't exposed as a scene setting). + run( + encoder, + &self.renderer.bloom_threshold_pipeline, + &scene_view, + &[], + &q.view(), + &f32s(&[ + scene_texel[0], + scene_texel[1], + self.bloom.threshold, + self.bloom.strength, + ]), + ); + // Pass 2: 13-tap blur along x (step = scene texel * 8), quarter → eighth. + run( + encoder, + &self.renderer.bloom_blur_pipeline, + &q.view(), + &[], + &e.view(), + &f32s(&[scene_texel[0] * 8.0, 0.0, 0.0, 0.0]), + ); + // Pass 3: 13-tap blur along y (step = scene texel * 8), eighth → bloom. + run( + encoder, + &self.renderer.bloom_blur_pipeline, + &e.view(), + &[], + &bloom_rt.view(), + &f32s(&[0.0, scene_texel[1] * 8.0, 0.0, 0.0]), + ); + // Pass 4: combine.frag — plain add of the pre-bloom scene and bloom. + run( + encoder, + &self.renderer.bloom_combine_pipeline, + &bloom_rt.view(), + &[Some(scene_copy_view)], + target_view, + &f32s(&[0.0, 0.0, 0.0, 0.0]), + ); + } + + /// Render one frame and read it back to CPU RGBA (preview / test / SHM + /// fallback paths). + pub fn render_rgba(&mut self) -> Result { + self.render(); + // Temporary debug hook: dump every pooled render target to PNG. + if std::env::var("WP_DEBUG_DUMP_FBOS").is_ok() { + for (name, rt) in self.fbo_pool.iter() { + if let Ok(img) = self.renderer.readback(&rt.texture, rt.width, rt.height) { + let safe = name.replace([':', '/'], "_"); + let _ = img.save(format!("/tmp/fbo_{safe}.png")); + let mut a = img.clone(); + for p in a.pixels_mut() { + p.0 = [p.0[3], p.0[3], p.0[3], 255]; + } + let _ = a.save(format!("/tmp/fbo_{safe}_alpha.png")); + } + } + } + self.renderer + .readback(&self.target, self.width, self.height) + } + + /// Render one frame and blit it aspect-fill into an external texture view + /// (e.g. an acquired Wayland surface frame). No CPU readback happens. + pub fn render_to_view( + &mut self, + view: &wgpu::TextureView, + view_w: u32, + view_h: u32, + format: wgpu::TextureFormat, + ) { + self.render(); + let full = crate::content::settings::FULL_REGION; + self.present_region(view, format, (view_w, view_h), full); + } + + /// Blit the last rendered frame into `view`, which shows `region` of a + /// `canvas` the scene is aspect-filled into (a spanned or cloned + /// wallpaper renders once and presents to every monitor this way). + pub fn present_region( + &mut self, + view: &wgpu::TextureView, + format: wgpu::TextureFormat, + canvas: (u32, u32), + region: [f32; 4], + ) { + let (uv_scale, uv_offset) = crate::content::ScaleMode::Fill.uv_transform_region( + (self.width, self.height), + canvas, + region, + ); + let mut params = [0u8; 16]; + params[0..4].copy_from_slice(&uv_scale[0].to_le_bytes()); + params[4..8].copy_from_slice(&uv_scale[1].to_le_bytes()); + params[8..12].copy_from_slice(&uv_offset[0].to_le_bytes()); + params[12..16].copy_from_slice(&uv_offset[1].to_le_bytes()); + + let blit_buf = self.renderer.make_uniform_buffer(¶ms, 16); + let scene_view = self.target.create_view(&Default::default()); + let default_sampler = self.renderer.sampler_for(false, false); + let base_bg = + self.renderer + .make_base_bind_group(&scene_view, default_sampler, &blit_buf, &[]); + + // Ensure the pipeline exists before borrowing immutably for the pass. + self.renderer.blit_pipeline(format); + let pipeline = &self.renderer.blit_pipelines[&format]; + + let mut encoder = + self.renderer + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("present_blit"), + }); + self.renderer.run_pass( + &mut encoder, + pipeline, + &base_bg, + None, + view, + wgpu::LoadOp::Clear(wgpu::Color::BLACK), + "present_blit", + 3, + &[], + ); + self.renderer + .queue + .submit(std::iter::once(encoder.finish())); + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn composite_params( + opacity: f32, + mode: i32, + uv_offset: [f32; 2], + color: [f32; 3], + angle: f32, + rect: [f32; 4], + aspect: f32, + resolution: [f32; 2], +) -> [u8; 64] { + // WGSL CompositeParams layout: opacity@0, mode@4, uv_offset@8, color@16, + // angle@28, rect@32, aspect@48, resolution@56 (bytes 52-55 are padding). + let mut data = [0u8; 64]; + data[0..4].copy_from_slice(&opacity.to_le_bytes()); + data[4..8].copy_from_slice(&mode.to_le_bytes()); + data[8..12].copy_from_slice(&uv_offset[0].to_le_bytes()); + data[12..16].copy_from_slice(&uv_offset[1].to_le_bytes()); + data[16..20].copy_from_slice(&color[0].to_le_bytes()); + data[20..24].copy_from_slice(&color[1].to_le_bytes()); + data[24..28].copy_from_slice(&color[2].to_le_bytes()); + data[28..32].copy_from_slice(&angle.to_le_bytes()); + for (i, v) in rect.iter().enumerate() { + data[32 + i * 4..36 + i * 4].copy_from_slice(&v.to_le_bytes()); + } + data[48..52].copy_from_slice(&aspect.to_le_bytes()); + data[56..60].copy_from_slice(&resolution[0].to_le_bytes()); + data[60..64].copy_from_slice(&resolution[1].to_le_bytes()); + data +} + +/// Fullscreen rect: centered, full extents. +pub(super) const FULLSCREEN_RECT: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + +/// Always mode 0 (`fs_composite`, never reads `dest_copy_tex`), so the +/// resolution field is unused — a dummy value is harmless here. +pub(super) fn passthrough_composite_params() -> [u8; 64] { + composite_params( + 1.0, + 0, + [0.0, 0.0], + [1.0, 1.0, 1.0], + 0.0, + FULLSCREEN_RECT, + 1.0, + [1.0, 1.0], + ) +} diff --git a/src/engine/shaders/gpu_shaders.wgsl b/src/engine/gpu/renderer/gpu_shaders.wgsl similarity index 100% rename from src/engine/shaders/gpu_shaders.wgsl rename to src/engine/gpu/renderer/gpu_shaders.wgsl diff --git a/src/engine/gpu/renderer/lights.rs b/src/engine/gpu/renderer/lights.rs new file mode 100644 index 0000000..4263f6d --- /dev/null +++ b/src/engine/gpu/renderer/lights.rs @@ -0,0 +1,723 @@ +//! Scene lights on the GPU: the shadow-map atlas for shadow-casting lights, +//! volumetric light shafts (built once, recorded each frame), and the +//! per-mesh transform and lighting uniform bytes. + +use super::*; + +impl GpuSceneInstance { + /// Build the volumetric-light-shaft ray march's static per-scene bind + /// group (`VolumetricsParams` uniform + the shadow atlas + its + /// comparison sampler — see `vs_fullscreen`/`fs_volumetrics` in + /// gpu_shaders.wgsl), when the scene has a `castvolumetrics` light — + /// `None` otherwise (the overwhelmingly common case: skipped entirely, + /// no buffers allocated). See `SceneObject::volumetrics_params` and the + /// Ghidra report's `_rt_volumetrics*` follow-up. + /// + /// Scoped to the *first* volumetric light found. Real content doesn't + /// use this system at all (confirmed against the one downloaded + /// Workshop sample this session found — see the report), so building + /// multi-light support (looping N lights' worth of ray marches in one + /// pass, like `fs_mesh3d`'s `MESH3D_MAX_LIGHTS` loop) would be + /// speculative complexity with no real-content grounding to verify it + /// against, for a feature that's already an approximation (a + /// ray-marched-against-the-shadow-map technique, not WE's real + /// procedural per-light cone/box mesh — see the report's scoping note). + pub(super) fn build_volumetrics( + renderer: &GpuSceneRenderer, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, + lights: &[( + crate::engine::gpu::lighting::Light, + bool, + Option<(f32, f32)>, + Option, + )], + shadow_slots: &[Option], + shadow_slot_data: &[(crate::engine::sim::camera3d::Mat4, [f32; 4])], + shadow_atlas_view: &wgpu::TextureView, + ) -> Option { + use crate::engine::gpu::lighting::Light; + + let (idx, origin, color_intensity, density, exponent) = lights + .iter() + .enumerate() + .find_map(|(i, (light, _, volumetrics, _))| { + let (density, exponent) = (*volumetrics)?; + let (origin, color, intensity) = match light { + // Directional has no position to march a shaft from; + // Tube isn't constructed by `Scene::lights()` yet (see + // `LightType::Tube`'s doc comment) — neither reaches here. + Light::Point { + origin, + color, + intensity, + .. + } + | Light::Spot { + origin, + color, + intensity, + .. + } => (*origin, *color, *intensity), + _ => return None, + }; + let ci = [ + color[0] * intensity, + color[1] * intensity, + color[2] * intensity, + ]; + Some((i, origin, ci, density, exponent)) + })?; + + // Reuses this light's shadow-atlas tile when it also casts a shadow + // (`castshadow: true`) — the shaft respects real occlusion, not just + // WE's own documented "unoccluded" fallback for when shadows aren't + // built. `has_shadow = 0.0` (no matching slot) makes + // `fs_volumetrics` skip the occlusion test entirely, matching that + // same documented fallback for a volumetric light that isn't also a + // shadow caster. + let (shadow_view_proj, shadow_uv_rect, has_shadow) = shadow_slots + .get(idx) + .copied() + .flatten() + .and_then(|slot| shadow_slot_data.get(slot)) + .map(|(vp, rect)| (*vp, *rect, 1.0f32)) + .unwrap_or((crate::engine::sim::camera3d::identity(), [0.0; 4], 0.0)); + + let inv_view_proj = crate::engine::sim::camera3d::mat4_inverse(&cam.view_proj_raw()); + let eye = cam.eye(); + // Fixed, honest default range for the ray march — no real content + // to derive a better heuristic from (see the fn doc comment). + // Comparable to `radius`'s own 256px-ish default scene scale. + const MAX_RANGE: f32 = 40.0; + + let mut bytes = Vec::with_capacity(4 * 16 * 4 + 16 * 5); + let mut push_mat = |m: &crate::engine::sim::camera3d::Mat4| { + for col in m.iter() { + for f in col { + bytes.extend_from_slice(&f.to_le_bytes()); + } + } + }; + push_mat(&inv_view_proj); + push_mat(&shadow_view_proj); + let mut push4 = |v: [f32; 4]| { + for f in v { + bytes.extend_from_slice(&f.to_le_bytes()); + } + }; + push4([eye[0], eye[1], eye[2], 0.0]); + push4([origin[0], origin[1], origin[2], density]); + push4([ + color_intensity[0], + color_intensity[1], + color_intensity[2], + exponent, + ]); + push4(shadow_uv_rect); + push4([has_shadow, MAX_RANGE, 0.0, 0.0]); + + let ubo = renderer.make_uniform_buffer(&bytes, bytes.len() as u64); + Some( + renderer + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("volumetrics_bg"), + layout: &renderer.volumetrics_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: ubo.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(shadow_atlas_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler( + &renderer.shadow_comparison_sampler, + ), + }, + ], + }), + ) + } + + /// Render the shadow atlas once at scene setup (shadow-casting lights + /// are treated as static, same precedent as mesh MVPs — see + /// `build_mesh3d`'s own doc comment). For every `light` object with + /// `castshadow: true` (capped at `engine::gpu::shadow::MAX_SHADOW_LIGHTS`), + /// depth-renders every mesh3d object into that light's tile of one + /// shared atlas texture — the architecture recovered from the original + /// binary's `_rt_shadowAtlas` (see the Ghidra report's shadow-mapping + /// follow-up), not a separate texture per light. + /// + /// Returns the atlas texture (a harmless 1×1 dummy when there are no + /// shadow-casting lights or no mesh3d geometry, so `build_mesh3d`'s bind + /// group always has a valid view to reference), a per-`scene.lights()` + /// shadow-slot index (`None` = doesn't cast a shadow, or capped out), + /// and the per-slot `(view_proj, uv_rect)` data for `mesh3d_lighting_bytes`. + pub(super) fn build_shadow_atlas( + renderer: &GpuSceneRenderer, + mesh3d_layers: &[crate::engine::model::resolve::Mesh3dLayer], + lights: &[( + crate::engine::gpu::lighting::Light, + bool, + Option<(f32, f32)>, + Option, + )], + ) -> ( + wgpu::Texture, + Vec>, + Vec<(crate::engine::sim::camera3d::Mat4, [f32; 4])>, + ) { + use crate::engine::gpu::lighting::Light; + use crate::engine::gpu::shadow; + + let mut shadow_slots: Vec> = vec![None; lights.len()]; + + // Needed to build either projection (`point_light_view_proj`'s own + // aim-and-size target, `spot_light_view_proj`'s near/far sizing) — + // computed once up front now that both light types consume it, + // rather than after filtering casters like the point-only version + // did. + let (center, radius) = shadow::scene_bounds(mesh3d_layers); + let shadow_casters: Vec<(usize, crate::engine::sim::camera3d::Mat4)> = lights + .iter() + .enumerate() + .filter_map(|(i, (light, casts_shadow, _volumetrics, _))| { + if !*casts_shadow { + return None; + } + let view_proj = match light { + Light::Point { origin, .. } => { + shadow::point_light_view_proj(*origin, center, radius) + } + Light::Spot { + origin, + direction, + outer_cone_degrees, + .. + } => shadow::spot_light_view_proj( + *origin, + *direction, + *outer_cone_degrees, + center, + radius, + ), + // Directional/tube shadows aren't built yet — see the + // Ghidra report's shadow-mapping follow-up. Directional + // has no natural "position" to cast a perspective + // shadow from (it'd need an orthographic projection, a + // different pipeline shape); tube isn't constructed by + // `Scene::lights()` at all yet. + _ => return None, + }; + Some((i, view_proj)) + }) + .take(shadow::MAX_SHADOW_LIGHTS) + .collect(); + + if shadow_casters.is_empty() || mesh3d_layers.is_empty() { + let dummy = renderer.device.create_texture(&wgpu::TextureDescriptor { + label: Some("shadow_atlas_dummy"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: MESH3D_DEPTH_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + return (dummy, shadow_slots, Vec::new()); + } + + let (atlas_w, atlas_h, tiles) = shadow::pack_tiles( + shadow_casters.len(), + shadow::SHADOW_TILE_SIZE, + shadow::SHADOW_TILE_SIZE * 4, + ); + + let atlas = renderer.device.create_texture(&wgpu::TextureDescriptor { + label: Some("shadow_atlas"), + size: wgpu::Extent3d { + width: atlas_w, + height: atlas_h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: MESH3D_DEPTH_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let atlas_view = atlas.create_view(&Default::default()); + + // Geometry uploaded once, reused for every shadow-casting light's + // tile pass. Duplicates the upload `build_mesh3d` does later for the + // final draw — real content has ~0 shadow-casting lights (see the + // Ghidra report), so trading a little redundant GPU upload for a + // simple, independent setup path is the right side of that trade. + let geometry: Vec<(wgpu::Buffer, wgpu::Buffer, u32)> = mesh3d_layers + .iter() + .map(|m| Self::upload_mesh3d_geometry(renderer, &m.mesh)) + .collect(); + + let mut slot_data = Vec::with_capacity(shadow_casters.len()); + let mut encoder = renderer + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("shadow_atlas_pass"), + }); + for (slot, ((light_idx, view_proj), tile)) in + shadow_casters.iter().zip(tiles.iter()).enumerate() + { + let view_proj = *view_proj; + let uv_rect = shadow::tile_uv_rect(*tile, atlas_w, atlas_h); + shadow_slots[*light_idx] = Some(slot); + slot_data.push((view_proj, uv_rect)); + + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("shadow_tile"), + color_attachments: &[], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &atlas_view, + depth_ops: Some(wgpu::Operations { + // Only the first tile clears — clearing is a + // whole-texture operation in wgpu, so clearing again + // per tile would erase every tile drawn before it. + // Every pixel outside every tile's scissor rect stays + // at this initial 1.0 (far) regardless. + load: if slot == 0 { + wgpu::LoadOp::Clear(1.0) + } else { + wgpu::LoadOp::Load + }, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_viewport( + tile.x as f32, + tile.y as f32, + tile.w as f32, + tile.h as f32, + 0.0, + 1.0, + ); + pass.set_scissor_rect(tile.x, tile.y, tile.w, tile.h); + pass.set_pipeline(&renderer.mesh3d_shadow_pipeline); + for (layer, (vbuf, ibuf, index_count)) in mesh3d_layers.iter().zip(geometry.iter()) { + let model = crate::engine::sim::camera3d::model_matrix( + layer.origin, + layer.angles, + layer.scale, + ); + let light_mvp = crate::engine::sim::camera3d::mat4_mul(&view_proj, &model); + let mut mvp_bytes = Vec::with_capacity(64); + for col in light_mvp.iter() { + for f in col { + mvp_bytes.extend_from_slice(&f.to_le_bytes()); + } + } + let mvp_buf = renderer.make_uniform_buffer(&mvp_bytes, 64); + let bg = renderer + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("shadow_pass_bg"), + layout: &renderer.shadow_pass_bgl, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: mvp_buf.as_entire_binding(), + }], + }); + pass.set_bind_group(0, &bg, &[]); + pass.set_vertex_buffer(0, vbuf.slice(..)); + pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..*index_count, 0, 0..1); + } + } + renderer.queue.submit(std::iter::once(encoder.finish())); + + (atlas, shadow_slots, slot_data) + } + + /// Bloom: threshold at quarter res, gaussian blur at quarter and eighth + /// res, then additive combine back onto the scene target. + /// Reproduces the reference's exact four-pass chain (`downsample_quarter_bloom` + /// → `downsample_eighth_blur_v` → `blur_h_bloom` → `combine`, all real WE + /// utility shaders the linux port wires up as a hidden fullscreen effect — + /// see CScene.cpp/WallpaperApplication.cpp). All texel offsets use the + /// *scene's* texel size, matching WE's always-scene-relative `g_TexelSize`. + /// Volumetric-light-shaft ray march, generated at quarter res, blurred + /// (reusing the bloom chain's own separable-blur pipeline unmodified — + /// it's already a generic single-source-texture operation), then + /// additively combined onto the scene (reusing bloom's combine pipeline + /// too, for the same reason). See `build_volumetrics`/`fs_volumetrics` + /// and the Ghidra report's `_rt_volumetrics*` follow-up. A no-op unless + /// `self.volumetrics_bind_group` is `Some` (a `castvolumetrics` light + /// exists) — checked by the one caller, `render()`. + pub(super) fn record_volumetrics( + &self, + encoder: &mut wgpu::CommandEncoder, + target_view: &wgpu::TextureView, + ) { + let Some(vol_bg) = &self.volumetrics_bind_group else { + return; + }; + let buf = self.fbo_pool.get("_rt_VolumetricsBuffer").unwrap(); + let tmp = self.fbo_pool.get("_rt_VolumetricsBlurTmp").unwrap(); + let buf_texel = [1.0 / buf.width as f32, 1.0 / buf.height as f32]; + + // Snapshot the pre-volumetrics scene: the combine pass both reads + // and writes `self.target`, which a GPU can't do within one pass — + // same constraint `record_bloom` works around the same way. + encoder.copy_texture_to_texture( + self.target.as_image_copy(), + self.scene_copy.as_image_copy(), + wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + ); + let scene_copy_view = self.scene_copy.create_view(&Default::default()); + + // Pass 1: the ray march itself — its own dedicated bind group + // (shadow atlas + comparison sampler + this light's params), not + // the generic base_bgl/effect_bgl shape the blur/combine passes + // below use. + self.renderer.run_pass( + encoder, + &self.renderer.volumetrics_pipeline, + vol_bg, + None, + &buf.view(), + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + "volumetrics_march", + 3, + &[], + ); + + let run = |encoder: &mut wgpu::CommandEncoder, + pipeline: &wgpu::RenderPipeline, + src: &wgpu::TextureView, + extra: &[Option], + dst: &wgpu::TextureView, + params: &[u8]| { + let default_sampler = self.renderer.sampler_for(false, false); + let passthrough = self + .renderer + .make_uniform_buffer(&passthrough_composite_params(), 64); + let base_bg = + self.renderer + .make_base_bind_group(src, default_sampler, &passthrough, extra); + let param_buf = self.renderer.make_uniform_buffer(params, 16); + let effect_bg = self.renderer.make_effect_bind_group(¶m_buf); + self.renderer.run_pass( + encoder, + pipeline, + &base_bg, + Some(&effect_bg), + dst, + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + "volumetrics_blur_combine", + 3, + &[], + ); + }; + let f32s = + |vals: &[f32]| -> Vec { vals.iter().flat_map(|f| f.to_le_bytes()).collect() }; + + // Passes 2-3: separable blur, in place within the same quarter-res + // buffer (via the tmp buffer) — softens the fixed-step-count ray + // march's banding and gives the shaft a less hard-edged look. + run( + encoder, + &self.renderer.bloom_blur_pipeline, + &buf.view(), + &[], + &tmp.view(), + &f32s(&[buf_texel[0] * 2.0, 0.0, 0.0, 0.0]), + ); + run( + encoder, + &self.renderer.bloom_blur_pipeline, + &tmp.view(), + &[], + &buf.view(), + &f32s(&[0.0, buf_texel[1] * 2.0, 0.0, 0.0]), + ); + // Pass 4: additive combine onto the scene (combine.frag: plain + // `scene + src`, the same math bloom's own combine pass uses). + run( + encoder, + &self.renderer.bloom_combine_pipeline, + &buf.view(), + &[Some(scene_copy_view)], + target_view, + &f32s(&[0.0, 0.0, 0.0, 0.0]), + ); + } +} + +/// Pack one mesh's `Mesh3dTransform` uniform (mvp, model_view, normal_view, +/// model) — layout must match `Mesh3dTransform` in `gpu_shaders.wgsl` +/// exactly. `model` (object→world, no view/projection) is what the shadow +/// lookup in `fs_mesh3d` needs `world_pos` for — the light's view-projection +/// is naturally built in world space, unlike everything else here which +/// works in the main camera's view space. +pub(super) fn mesh3d_transform_bytes( + cam: &crate::engine::sim::camera3d::PerspectiveCamera, + origin: [f32; 3], + angles: [f32; 3], + scale: [f32; 3], +) -> Vec { + let mut bytes = Vec::with_capacity(64 * 4); + for mat in [ + cam.mvp(origin, angles, scale), + cam.model_view(origin, angles, scale), + cam.normal_view(angles, scale), + crate::engine::sim::camera3d::model_matrix(origin, angles, scale), + ] { + for col in mat.iter() { + for f in col { + bytes.extend_from_slice(&f.to_le_bytes()); + } + } + } + bytes +} + +/// Pack the scene-wide `Mesh3dLighting` uniform: ambient + fog (already +/// resolved by `General::ambient_color`/`General::fog`), up to +/// `MESH3D_MAX_LIGHTS` point or spot lights from `Scene::lights` (positions +/// in the camera's view space, matching everything else `fs_mesh3d` works +/// in; a spot's facing direction goes into `light_spot`, also view-space), +/// and up to `MESH3D_MAX_SHADOW_LIGHTS` shadow view-projections + atlas UV +/// rects (world space — see `mesh3d_transform_bytes`). Layout must match +/// `Mesh3dLighting` in `gpu_shaders.wgsl` exactly. +/// +/// `shadow_slots[i]` is the shadow-atlas slot index for `scene.lights()[i]`, +/// or `None` when that light doesn't cast a shadow (unlit, or beyond +/// `MESH3D_MAX_SHADOW_LIGHTS` — see `GpuSceneInstance::build_shadow_atlas`). +/// A light's `light_pos[i].w` carries `(slot + 1) as f32` so the shader can +/// tell "no shadow" (0.0) from "shadow slot 0" without a separate flags +/// field — `light_color.a <= 0.0` (unused light slot) already uses the same +/// "skip" convention. +/// +/// `cookie` is `Some((light_index, view_proj))` when one of `scene.lights()` +/// is a Spot light with a resolvable cookie texture (see +/// `GpuSceneInstance::build`'s cookie-resolution step) — packed into +/// `flags.y` (as `light_index + 1`, `0.0` = none) and the trailing +/// `cookie_view_proj` field. +pub(super) fn mesh3d_lighting_bytes( + scene: &crate::engine::model::scene::Scene, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, + shadow_slots: &[Option], + shadow_slot_data: &[(crate::engine::sim::camera3d::Mat4, [f32; 4])], + cookie: Option<(usize, crate::engine::sim::camera3d::Mat4)>, +) -> Vec { + let lights = scene.lights(); + let general = scene.general.as_ref(); + let fog = general.and_then(|g| g.fog()); + let ambient = general.and_then(|g| g.ambient_color()).unwrap_or([0.0; 3]); + + let mut bytes = Vec::with_capacity(MESH3D_LIGHTING_BYTES_LEN); + let mut push4 = |v: [f32; 4]| { + for f in v { + bytes.extend_from_slice(&f.to_le_bytes()); + } + }; + + // Gates on "at least one light this loop can actually render" — every + // `Scene::lights()` variant is renderable now (Point, Spot, Directional, + // Tube), so in practice this only matters for an empty `lights` list. A + // scene with no renderable lights must still fall through to the plain + // unlit look, not engage the lit branch with zero actual contributions + // (which would multiply by `ambient` alone — usually black, per the + // corpus's near-universal unset `ambientcolor`). + let has_renderable_light = lights.iter().any(|(l, ..)| { + matches!( + l, + crate::engine::gpu::lighting::Light::Point { .. } + | crate::engine::gpu::lighting::Light::Spot { .. } + | crate::engine::gpu::lighting::Light::Directional { .. } + | crate::engine::gpu::lighting::Light::Tube { .. } + ) + }); + let cookie_light_flag = cookie.map(|(idx, _)| (idx + 1) as f32).unwrap_or(0.0); + push4([ + if has_renderable_light { 1.0 } else { 0.0 }, + cookie_light_flag, + 0.0, + 0.0, + ]); + push4([ambient[0], ambient[1], ambient[2], 0.0]); + match &fog { + Some(f) => { + push4([ + f.distance_color[0], + f.distance_color[1], + f.distance_color[2], + f.distance_density, + ]); + push4([ + f.height_color[0], + f.height_color[1], + f.height_color[2], + f.height_density, + ]); + push4([f.height_exponent, f.height_offset, 0.0, 0.0]); + } + None => { + push4([0.0; 4]); + push4([0.0; 4]); + push4([0.0; 4]); + } + } + + let mut positions = [[0.0f32; 4]; MESH3D_MAX_LIGHTS]; + let mut colors = [[0.0f32; 4]; MESH3D_MAX_LIGHTS]; + let mut spots = [[0.0f32; 4]; MESH3D_MAX_LIGHTS]; + for (i, ((pos_slot, (color_slot, spot_slot)), (light, ..))) in positions + .iter_mut() + .zip(colors.iter_mut().zip(spots.iter_mut())) + .zip(lights.iter()) + .enumerate() + { + let shadow_w = || { + shadow_slots + .get(i) + .copied() + .flatten() + .map(|s| (s + 1) as f32) + .unwrap_or(0.0) + }; + match light { + crate::engine::gpu::lighting::Light::Point { + origin, + color, + intensity, + .. + } => { + let vp = cam.to_view_space(*origin); + *pos_slot = [vp[0], vp[1], vp[2], shadow_w()]; + *color_slot = [color[0], color[1], color[2], *intensity]; + // spot_slot stays [0,0,0,0] — zero-length direction reads as + // "not a spot" in `mesh3d_spot_factor`. + } + crate::engine::gpu::lighting::Light::Spot { + origin, + direction, + exponent, + color, + intensity, + .. + } => { + let vp = cam.to_view_space(*origin); + // `build_shadow_atlas` now casts shadows from `Light::Spot` + // too (see `engine::gpu::shadow::spot_light_view_proj`), so + // `shadow_w()` is real here, not always 0.0. + *pos_slot = [vp[0], vp[1], vp[2], shadow_w()]; + *color_slot = [color[0], color[1], color[2], *intensity]; + let vd = cam.to_view_direction(*direction); + *spot_slot = [vd[0], vd[1], vd[2], *exponent]; + } + crate::engine::gpu::lighting::Light::Directional { + direction, + color, + intensity, + } => { + // Infinite light: `light_pos[i].xyz` carries a + // pre-normalized view-space direction from the surface + // toward the light (the negation of the light's own + // travel direction, matching `to_light`'s convention for + // Point/Spot), flagged via `w = -1.0` — a sentinel real + // shadow_w values never take (0, or a positive slot+1). + // See `fs_mesh3d`'s light loop in gpu_shaders.wgsl. + let vd = cam.to_view_direction(*direction); + let neg = [-vd[0], -vd[1], -vd[2]]; + let len = (neg[0] * neg[0] + neg[1] * neg[1] + neg[2] * neg[2]) + .sqrt() + .max(1e-6); + *pos_slot = [neg[0] / len, neg[1] / len, neg[2] / len, -1.0]; + *color_slot = [color[0], color[1], color[2], *intensity]; + // spot_slot stays [0,0,0,0] — mesh3d_spot_factor's own + // "not a spot" check already no-ops for a directional light. + } + crate::engine::gpu::lighting::Light::Tube { + origin_a, + origin_b, + color, + intensity, + } => { + // Line-segment light: `light_pos[i].xyz` carries endpoint A + // (view space), `light_spot[i].xyz` carries endpoint B + // (view space, *not* a direction) — flagged via `w = -3.0`, + // a sentinel distinct from directional's `-1.0` (both are + // already `< 0.5`, so `mesh3d_shadow_factor` already treats + // either as "unshadowed" with no extra branching needed + // there; `mesh3d_spot_factor` does need an explicit skip, + // since a non-zero-length `light_spot.xyz` would otherwise + // be misread as a spot direction — see `fs_mesh3d`'s light + // loop in gpu_shaders.wgsl). + let va = cam.to_view_space(*origin_a); + let vb = cam.to_view_space(*origin_b); + *pos_slot = [va[0], va[1], va[2], -3.0]; + *color_slot = [color[0], color[1], color[2], *intensity]; + *spot_slot = [vb[0], vb[1], vb[2], 0.0]; + } + } + } + for p in positions { + push4(p); + } + for c in colors { + push4(c); + } + for s in spots { + push4(s); + } + + // `Mesh3dLighting` in WGSL declares these as two *separate* fixed-size + // arrays (`shadow_view_proj` then `shadow_uv_rect`), not an + // array-of-structs — every matrix must be written before any rect, or + // WGSL indexes into the wrong bytes entirely (confirmed the hard way: an + // earlier interleaved version of this loop packed matrix+rect pairs + // per-slot, which happened to leave `shadow_view_proj[0]` intact by + // coincidence but made every `shadow_uv_rect[slot]` read garbage spliced + // from a neighboring slot's matrix). + for slot in 0..MESH3D_MAX_SHADOW_LIGHTS { + let vp = shadow_slot_data + .get(slot) + .map(|(vp, _)| *vp) + .unwrap_or_else(crate::engine::sim::camera3d::identity); + for col in vp.iter() { + push4(*col); + } + } + for slot in 0..MESH3D_MAX_SHADOW_LIGHTS { + let uv_rect = shadow_slot_data + .get(slot) + .map(|(_, r)| *r) + .unwrap_or([0.0; 4]); + push4(uv_rect); + } + let cookie_view_proj = cookie + .map(|(_, vp)| vp) + .unwrap_or_else(crate::engine::sim::camera3d::identity); + for col in cookie_view_proj.iter() { + push4(*col); + } + bytes +} diff --git a/src/engine/gpu/renderer/live.rs b/src/engine/gpu/renderer/live.rs new file mode 100644 index 0000000..9a3d23c --- /dev/null +++ b/src/engine/gpu/renderer/live.rs @@ -0,0 +1,1152 @@ +//! The scene while it runs: keyframed timelines, SceneScript ticks and the +//! property changes scripts make, cursor events and hit tests, live user +//! property edits, objects scripts create, text re-rasterization, and the +//! per-frame object transforms. + +use super::*; + +impl GpuSceneInstance { + /// Advance keyframe animations and apply the properties they move. + pub(super) fn tick_timelines(&mut self, delta: f32) { + use crate::engine::sim::script::{ObjectChange, ScriptChanges}; + if self.timelines.is_empty() { + return; + } + let mut changes: Vec<(usize, ObjectChange)> = Vec::new(); + let mut events: Vec<(usize, String)> = Vec::new(); + let mut synced: Vec<(usize, String, serde_json::Value)> = Vec::new(); + for t in &mut self.timelines { + for name in t.anim.advance(delta) { + events.push((t.object, name)); + } + let mut v = t.anim.sample(); + if t.anim.relative { + for (k, x) in v.iter_mut().enumerate() { + *x += t.base.get(k).copied().unwrap_or(0.0); + } + } + if t.last.as_ref() == Some(&v) { + continue; + } + t.last = Some(v.clone()); + let idx = match changes.iter().position(|(o, _)| *o == t.object) { + Some(i) => i, + None => { + changes.push((t.object, ObjectChange::default())); + changes.len() - 1 + } + }; + let c = &mut changes[idx].1; + let v3 = |fill: f32| -> [f32; 3] { + std::array::from_fn(|k| { + v.get(k).copied().or(t.base.get(k).copied()).unwrap_or(fill) + }) + }; + let json = if v.len() == 1 { + serde_json::json!(v[0]) + } else { + serde_json::json!(v) + }; + match t.key.as_str() { + "origin" => c.origin = Some(v3(0.0)), + "scale" => c.scale = Some(v3(1.0)), + "angles" => c.angles = Some(v3(0.0)), + "color" => c.color = Some(v3(1.0)), + "alpha" => c.alpha = v.first().copied(), + "brightness" => c.brightness = v.first().copied(), + key => { + c.extra.insert(key.to_string(), json.clone()); + } + } + synced.push((t.object, t.key.clone(), json)); + } + if self.scripts.has_scripts() { + for (object, key, value) in &synced { + self.scripts.sync_property(*object, key, value); + } + for (object, name) in &events { + self.scripts.dispatch_event( + *object, + "animationEvent", + &serde_json::json!({ "name": name }), + ); + } + } + if !changes.is_empty() { + self.apply_script_changes(ScriptChanges { + objects: changes, + commands: Vec::new(), + }); + } + } + + /// `thisLayer.getAnimation(name).play()` & co: control the animations on + /// object `index` named `property` (by animation name or property key), + /// along with the animations grouped with them. + pub(super) fn control_timeline( + &mut self, + index: usize, + property: &str, + op: &str, + frame: Option, + ) { + let matched: Vec = self + .timelines + .iter() + .filter(|t| t.object == index) + .filter(|t| t.key == property || t.anim.name.as_deref() == Some(property)) + .flat_map(|t| std::iter::once(t.key.clone()).chain(t.anim.children.iter().cloned())) + .collect(); + for t in self.timelines.iter_mut().filter(|t| t.object == index) { + let grouped = matched.contains(&t.key) + || t.anim.parent.as_ref().is_some_and(|p| matched.contains(p)); + if grouped { + t.anim.control(op, frame); + } + } + } + + /// Run this frame's SceneScripts and apply what they changed. + pub(super) fn tick_scripts(&mut self, time: f32, delta: f32) { + if !self.scripts.has_scripts() { + return; + } + #[cfg(target_os = "linux")] + if let Some(info) = self.media.as_ref().and_then(|w| w.try_recv()) { + use crate::services::media::PlaybackState; + self.scripts.set_media( + info.available, + match info.playback_state { + PlaybackState::Stopped => 0, + PlaybackState::Playing => 1, + PlaybackState::Paused => 2, + }, + &info.title, + &info.artist, + &info.album, + info.position_us, + info.duration_us, + info.art_url.as_deref(), + ); + } + if self.scripts_want_audio { + if let Some(cap) = &self.audio_capture { + let sp = cap.spectrum(); + self.scripts.set_audio([ + &sp.s16_left, + &sp.s16_right, + &sp.s32_left, + &sp.s32_right, + &sp.s64_left, + &sp.s64_right, + ]); + } + } + self.scripts.set_cursor( + self.cursor_scene, + self.cursor_screen(), + self.cursor_left_down, + ); + self.dispatch_cursor_events(); + self.scripts + .frame(time, crate::engine::sim::script::local_time_of_day(), delta); + let changes = self.scripts.collect(); + self.apply_script_changes(changes); + self.flush_text(); + + // `registerAudioBuffers` usually runs at module load, so this is + // known after the first frame; start capture then if nothing else did. + if !self.scripts_want_audio && self.scripts.wants_audio() { + self.scripts_want_audio = true; + if self.audio_capture.is_none() { + self.audio_capture = crate::services::audio::AudioCapture::start(); + } + } + } + + /// Fire `cursorEnter`/`Leave`/`Move`/`Down`/`Up`/`Click` on handler + /// objects from this frame's pointer state. + pub(super) fn dispatch_cursor_events(&mut self) { + let buttons = std::mem::take(&mut self.pending_buttons); + if self.cursor_targets.is_empty() { + return; + } + let pos = self.cursor_scene; + let hit = self.hit_test(self.mouse_norm); + if hit != self.hovered { + if let Some(old) = self.hovered { + self.scripts.dispatch_cursor(old, "cursorLeave", pos); + } + if let Some(new) = hit { + self.scripts.dispatch_cursor(new, "cursorEnter", pos); + } + self.hovered = hit; + } + if pos != self.last_cursor_scene { + self.last_cursor_scene = pos; + if let Some(h) = hit { + self.scripts.dispatch_cursor(h, "cursorMove", pos); + } + // A drag keeps reporting to the layer it started on. + if let Some(p) = self.pressed.filter(|p| Some(*p) != hit) { + self.scripts.dispatch_cursor(p, "cursorMove", pos); + } + } + for down in buttons { + if down { + self.pressed = hit; + if let Some(h) = hit { + self.scripts.dispatch_cursor(h, "cursorDown", pos); + } + } else if let Some(p) = self.pressed.take() { + self.scripts.dispatch_cursor(p, "cursorUp", pos); + if hit == Some(p) { + self.scripts.dispatch_cursor(p, "cursorClick", pos); + } + } + } + } + + /// The topmost cursor-handler object whose quad contains the cursor + /// (`norm`: `[0,1]²`, top-left origin). Hidden layers still count: + /// real content uses invisible hitboxes that toggle themselves. + pub(super) fn hit_test(&self, norm: [f32; 2]) -> Option { + let (w, h) = (self.width as f32, self.height as f32); + // Output pixels, Y-up, origin at the centre — the space `rect` is in. + let p = [(norm[0] - 0.5) * w, (0.5 - norm[1]) * h]; + self.layers + .iter() + .filter(|l| self.cursor_targets.contains(&l.order_index)) + .filter(|l| { + let corners: [[f32; 2]; 4] = match l.quad_corners { + Some(c) => c.map(|c| [c[0] * w * 0.5, c[1] * h * 0.5]), + None => quad_corners_px(l.rect, l.angle, w, h), + }; + point_in_convex_quad(p, &corners) + }) + .map(|l| l.order_index) + .max() + } + + /// Record a left-button press or release (from the platform layer). + pub fn set_mouse_button(&mut self, left_down: bool) { + if left_down != self.cursor_left_down { + self.cursor_left_down = left_down; + self.pending_buttons.push(left_down); + } + } + + /// The cursor in output pixels, top-left origin (`input.cursorScreenPosition`). + pub(super) fn cursor_screen(&self) -> [f32; 2] { + [ + self.mouse_norm[0] * self.width as f32, + self.mouse_norm[1] * self.height as f32, + ] + } + + /// Apply a frame's script changes: transforms recompose through the + /// parent graph, everything else lands on the object's layers and sounds. + pub(super) fn apply_script_changes( + &mut self, + changes: crate::engine::sim::script::ScriptChanges, + ) { + use crate::engine::sim::script::ScriptCommand; + let n = self.current_locals.len(); + let mut moved = vec![false; n]; + let mut visibility_changed = false; + for (i, c) in &changes.objects { + let i = *i; + if i >= n { + continue; + } + let x = &mut self.current_locals[i]; + if let Some(o) = c.origin { + x.t = o.map(|v| v as f64); + moved[i] = true; + } + if let Some(a) = c.angles { + x.r = a.map(|v| v as f64); + moved[i] = true; + } + if let Some(sc) = c.scale { + x.s = sc.map(|v| v as f64); + moved[i] = true; + } + if let Some(d) = c.propagation_disabled { + self.propagation_disabled[i] = d; + moved[i] = true; + } + if let Some(v) = c.visible { + self.object_visible[i] = v; + visibility_changed = true; + } + if let Some(v) = c.volume { + for snd in self.sounds_of(i) { + snd.set_volume(v); + } + } + for (key, value) in &c.extra { + self.apply_script_extra(i, key, value); + } + for li in 0..self.layers.len() { + if self.layers[li].order_index != i { + continue; + } + let layer = &mut self.layers[li]; + if let Some(v) = c.visible { + layer.visible = v; + } + if let Some(a) = c.alpha { + layer.alpha = a; + } + if let Some(col) = c.color { + layer.color = col; + } + if let Some(b) = c.brightness { + layer.brightness = b; + } + if let Some(f) = c.frame { + layer.script_frame = Some(f as usize); + } + if let Some(ps) = c.pointsize { + if let Some(td) = &mut layer.text_dynamic { + td.point_size = ps.max(1.0); + td.last_text.clear(); + } + } + if c.text.is_some() || c.pointsize.is_some() { + self.rerasterize_text(li, c.text.as_deref()); + } + } + } + for cmd in changes.commands { + match cmd { + ScriptCommand::Sound { index, op } => { + for snd in self.sounds_of(index) { + match op.as_str() { + "play" => snd.play(), + "pause" => snd.pause(), + "stop" => snd.stop(), + _ => {} + } + } + } + ScriptCommand::Sort { index, to } => self.sort_object(index, to), + ScriptCommand::Animation { + index, + property, + op, + frame, + } => self.control_timeline(index, &property, &op, frame), + ScriptCommand::Create { index, config } => { + self.create_object(index, &config); + visibility_changed = true; + } + ScriptCommand::Destroy { index } => { + // Hidden for good rather than removed: per-layer render + // targets are keyed by position in `self.layers`. + if let Some(v) = self.object_visible.get_mut(index) { + *v = false; + } + self.cursor_targets.retain(|t| *t != index); + visibility_changed = true; + } + other => { + tracing::debug!(target: "script", "unhandled script command {other:?}"); + } + } + } + if visibility_changed { + self.refresh_layer_visibility(); + } + if moved.iter().any(|m| *m) { + self.apply_object_transforms(&moved); + } + } + + /// The user properties `names` changed in the saved settings: show them + /// live. Returns `false` when the scene must be rebuilt instead (see + /// [`Self::apply_user_properties`]). + pub fn set_user_properties(&mut self, names: &[String]) -> bool { + let props = + crate::workshop::properties::SceneProperties::from_project_dir(&self.live_props.dir); + self.apply_user_properties(props, names) + } + + /// Re-resolve the scene against `props` and push each object's + /// differences through the same path SceneScript writes take; scripts + /// get `applyUserProperties` for `names`. Returns `false` when a change + /// can't be applied live (scene-wide settings, an effect that wasn't + /// built at load, a field scripts can't set). + pub(super) fn apply_user_properties( + &mut self, + props: crate::workshop::properties::SceneProperties, + names: &[String], + ) -> bool { + let lp = &mut self.live_props; + lp.props = props; + let mut next = lp.raw.clone(); + lp.props.resolve_scene_json(&mut next); + let prev = std::mem::replace(&mut lp.resolved, next); + if prev.get("general") != lp.resolved.get("general") { + return false; + } + let objects = |v: &serde_json::Value| { + v.get("objects") + .and_then(|o| o.as_array()) + .cloned() + .unwrap_or_default() + }; + let (before, after) = (objects(&prev), objects(&lp.resolved)); + if before.len() != after.len() { + return false; + } + let user: serde_json::Map = names + .iter() + .filter_map(|n| { + Some(( + n.clone(), + serde_json::json!({ "value": lp.props.get(n)?.clone() }), + )) + }) + .collect(); + let mut objects = Vec::new(); + for (i, (a, b)) in before.iter().zip(&after).enumerate() { + let mut diffs = Vec::new(); + changed_leaves(a, b, &mut Vec::new(), &mut diffs); + if diffs.is_empty() { + continue; + } + let mut change = crate::engine::sim::script::ObjectChange::default(); + for (path, v) in &diffs { + if self.live_change(i, path, v, &mut change).is_none() { + tracing::debug!(target: "properties", "object {i}: {} needs a reload", path.join(".")); + return false; + } + } + objects.push((i, change)); + } + self.apply_script_changes(crate::engine::sim::script::ScriptChanges { + objects, + commands: Vec::new(), + }); + self.scripts + .apply_user_properties(&serde_json::Value::Object(user)); + true + } + + /// One changed scene.json field of object `object`, as a script change. + /// `None` when it has no live counterpart. + pub(super) fn live_change( + &self, + object: usize, + path: &[String], + v: &serde_json::Value, + c: &mut crate::engine::sim::script::ObjectChange, + ) -> Option<()> { + use crate::engine::model::scene::{parse_value_bool, parse_value_f32, parse_value_vec3}; + // `{"user": …, "value": X}` bindings resolve to their `value`. + let v = v.get("value").unwrap_or(v); + let vec3 = |v| parse_value_vec3(v).map(|a| a.map(|x| x as f32)); + let path: Vec<&str> = path.iter().map(String::as_str).collect(); + match path.as_slice() { + ["visible"] => c.visible = Some(parse_value_bool(v)?), + ["alpha"] => c.alpha = Some(parse_value_f32(v)?), + ["brightness"] => c.brightness = Some(parse_value_f32(v)?), + ["color"] => c.color = Some(vec3(v)?), + ["origin"] => c.origin = Some(vec3(v)?), + ["scale"] => c.scale = Some(vec3(v)?), + ["volume"] => c.volume = Some(parse_value_f32(v)?), + ["pointsize"] => c.pointsize = Some(parse_value_f32(v)?), + ["text"] => c.text = Some(v.as_str()?.to_string()), + ["instanceoverride", k] => { + c.extra.insert(format!("instance.{k}"), v.clone()); + } + ["effects", e, rest @ ..] => { + let e: usize = e.parse().ok()?; + // Effects hidden at load were never built. + self.scene_effects + .iter() + .any(|d| d.layer_idx == object && d.effect_index == e) + .then_some(())?; + match rest { + ["visible"] => { + c.extra + .insert(format!("effects.{e}.visible"), parse_value_bool(v)?.into()); + } + ["passes", pass, "constantshadervalues", k] => { + c.extra.insert(format!("effects.{e}.{pass}.{k}"), v.clone()); + } + _ => return None, + } + } + _ => return None, + } + Some(()) + } + + /// A script wrote a declared extra property: `instance.` (particle + /// override), `effects..visible`, or `effects...`. + pub(super) fn apply_script_extra( + &mut self, + object: usize, + key: &str, + value: &serde_json::Value, + ) { + if let Some(k) = key.strip_prefix("instance.") { + for (sys, _) in self + .particle_systems + .iter_mut() + .zip(&self.particle_order) + .filter(|(_, o)| **o == object) + { + sys.set_instance_value(k, value); + } + return; + } + let Some(rest) = key.strip_prefix("effects.") else { + return; + }; + let mut parts = rest.splitn(3, '.'); + let Some(e) = parts.next().and_then(|e| e.parse::().ok()) else { + return; + }; + let Some(k) = self + .scene_effects + .iter() + .position(|d| d.layer_idx == object && d.effect_index == e) + else { + return; + }; + match (parts.next(), parts.next()) { + (Some("visible"), None) => { + self.effect_enabled[k] = value.as_bool().unwrap_or(true); + } + (Some(pass), Some(name)) => { + let Some(pass) = pass.parse::().ok() else { + return; + }; + let Some(runtime) = self.effect_runtimes.get_mut(k).and_then(|r| r.as_mut()) else { + return; + }; + if let Some(p) = runtime.passes.get_mut(pass) { + // Vectors come back as arrays; the value parser wants "x y z". + let v = match value { + serde_json::Value::Array(a) => serde_json::Value::String( + a.iter() + .map(|x| x.as_f64().unwrap_or(0.0).to_string()) + .collect::>() + .join(" "), + ), + other => other.clone(), + }; + insert_value_components(&mut p.values, name, &v); + } + } + _ => {} + } + } + + /// `thisScene.sortLayer(layer, index)`: draw `index`'s object just below + /// the object currently at `to`, later sorts landing lower. + pub(super) fn sort_object(&mut self, index: usize, to: usize) { + let Some(base) = self.draw_rank.get(to).copied() else { + return; + }; + self.sort_count += 1; + if let Some(r) = self.draw_rank.get_mut(index) { + *r = base - 0.5 + 0.49 / self.sort_count as f64; + } + } + + /// `thisScene.createLayer(config)`: build object `index` from a + /// scene.json-shaped config. Image, solid and text objects only; their + /// effects are not instantiated. + pub(super) fn create_object(&mut self, index: usize, config: &serde_json::Value) { + use crate::engine::model::resolve::Xform; + // Keep every per-object table indexed in step with the script side, + // even when this object can't be built. + let n = index + 1; + if self.current_locals.len() < n { + self.current_locals.resize(n, Xform::IDENTITY); + self.transform_graph.parent.resize(n, None); + self.transform_graph.local.resize(n, Xform::IDENTITY); + self.transform_graph.name.resize(n, None); + self.transform_graph.id.resize(n, None); + self.object_visible.resize(n, true); + self.propagation_disabled.resize(n, false); + let len = self.draw_rank.len(); + self.draw_rank.extend((len..n).map(|i| i as f64)); + } + let obj: crate::engine::model::scene::SceneObject = + match serde_json::from_value(config.clone()) { + Ok(o) => o, + Err(e) => { + tracing::warn!(target: "script", "createLayer: bad config: {e}"); + return; + } + }; + if obj.sound.is_some() { + if let Some(playback) = start_scene_sound(&self.scene_dir, &obj) { + self.sounds.push(SceneSound { + object: index, + playback, + }); + } + return; + } + if self.scene_pkg.is_none() { + self.scene_pkg = + crate::formats::pkg::Package::from_file(&self.scene_dir.join("scene.pkg")).ok(); + } + let Some(mut layer) = crate::engine::model::resolve::runtime_layer( + &self.scene_dir, + self.scene_pkg.as_ref(), + &obj, + ) else { + tracing::debug!(target: "script", "createLayer: object {index} has nothing to draw"); + return; + }; + layer.order_index = index; + let local = crate::engine::model::resolve::obj_local_xform(&obj); + self.current_locals[index] = local; + self.transform_graph.local[index] = local; + self.transform_graph.name[index] = obj.name.clone(); + self.object_visible[index] = obj.is_visible(); + let gpu = gpu_layer_from( + &self.renderer, + &layer, + self.width, + self.height, + self.zoom, + self.camera3d.as_ref(), + local.t, + ); + let (ow, oh) = (gpu.object_size.0.max(1), gpu.object_size.1.max(1)); + let li = self.layers.len(); + let device = self.renderer.device().clone(); + self.fbo_pool + .get_or_create(&device, &pingpong_key(li, 0), ow, oh); + self.fbo_pool + .get_or_create(&device, &pingpong_key(li, 1), ow, oh); + self.layers.push(gpu); + } + + /// Sound controls belonging to scene object `index`. + pub(super) fn sounds_of( + &self, + index: usize, + ) -> Vec> { + self.sounds + .iter() + .filter(|s| s.object == index) + .map(|s| s.playback.control()) + .collect() + } + + /// Queue new text (or, with `None`, a re-raster at a new point size) for + /// a text layer; [`Self::flush_text`] rasterizes it. + pub(super) fn rerasterize_text(&mut self, li: usize, text: Option<&str>) { + let layer = &mut self.layers[li]; + let Some(td) = &layer.text_dynamic else { + return; + }; + let new_text = text + .map(str::to_string) + .or_else(|| layer.pending_text.clone()) + .unwrap_or_else(|| td.last_text.clone()); + if new_text.is_empty() || (text.is_some() && new_text == td.last_text) { + layer.pending_text = None; + return; + } + layer.pending_text = Some(new_text); + } + + /// Rasterize pending text. Throttled per layer to every 100 ms, or every + /// 10× the last raster's cost when that is longer, so a script that + /// rewrites a huge text block every frame (debug overlays, timers with + /// milliseconds) can't stall rendering; the newest text always lands. + pub(super) fn flush_text(&mut self) { + let now = Instant::now(); + for li in 0..self.layers.len() { + let layer = &mut self.layers[li]; + let (Some(text), Some(td)) = (&layer.pending_text, &mut layer.text_dynamic) else { + continue; + }; + let wait = (layer.text_raster_cost * 10).max(Duration::from_millis(100)); + if layer + .text_rastered_at + .is_some_and(|t| now.duration_since(t) < wait) + { + continue; + } + let started = Instant::now(); + if let Some(img) = + crate::engine::gpu::text::rasterize(&td.font_data, text, td.point_size) + { + // The quad keeps its authored box; only the texture changes. + layer.frames[0] = self.renderer.upload_texture(&img); + td.last_text = text.clone(); + } + layer.text_raster_cost = started.elapsed(); + layer.text_rastered_at = Some(now); + layer.pending_text = None; + } + } + + /// A layer draws only when its own object and every ancestor are visible. + pub(super) fn refresh_layer_visibility(&mut self) { + let parent = &self.transform_graph.parent; + let visible = &self.object_visible; + let effective = |mut i: usize| -> bool { + for _ in 0..64 { + if !visible.get(i).copied().unwrap_or(true) { + return false; + } + match parent.get(i).copied().flatten() { + Some(p) if p != i => i = p, + _ => return true, + } + } + true + }; + for layer in &mut self.layers { + layer.visible = effective(layer.order_index); + } + } + + /// Recompose world transforms from `current_locals` and push them into + /// the layers and meshes they move: those whose own object or any + /// ancestor `moved`. + pub(super) fn apply_object_transforms(&mut self, moved: &[bool]) { + use crate::engine::model::resolve::Xform; + let n = self.current_locals.len(); + let parent = self.transform_graph.parent.clone(); + // affected[i]: object i or an ancestor moved. + let mut affected = vec![false; n]; + for i in 0..n { + let mut cur = i; + for _ in 0..64 { + if moved.get(cur).copied().unwrap_or(false) { + affected[i] = true; + break; + } + match parent.get(cur).copied().flatten() { + Some(p) if p != cur => cur = p, + _ => break, + } + } + } + let locals = self.current_locals.clone(); + let worlds = self + .transform_graph + .world(&locals, &self.propagation_disabled); + // The PARENT's world transform, not the object's own — `worlds[i]` + // already folds in object i's local transform. + let parent_world = |i: usize| -> Xform { + if self.propagation_disabled.get(i).copied().unwrap_or(false) { + return Xform::IDENTITY; + } + match parent.get(i).copied().flatten() { + Some(p) if p != i => worlds[p], + _ => Xform::IDENTITY, + } + }; + + // 3D meshes: rebuild the MVP from the recomposed world transform. + // Computed up front so the write-back can borrow `self.mesh3d` mutably. + let mesh_updates: Vec<(usize, Vec, f32)> = match &self.camera3d { + Some(cam) => self + .mesh3d + .iter() + .filter(|m| affected.get(m.order_index).copied().unwrap_or(false)) + .map(|m| { + let w = parent_world(m.order_index); + let (lo, la, ls) = match locals.get(m.order_index) { + Some(x) => ( + [x.t[0] as f32, x.t[1] as f32, x.t[2] as f32], + [x.r[0] as f32, x.r[1] as f32, x.r[2] as f32], + [x.s[0] as f32, x.s[1] as f32, x.s[2] as f32], + ), + None => m.local, + }; + let o = w.apply_point([lo[0] as f64, lo[1] as f64, lo[2] as f64]); + let centre = [o[0] as f32, o[1] as f32, o[2] as f32]; + let angles: [f32; 3] = std::array::from_fn(|i| la[i] + w.r[i] as f32); + let scale: [f32; 3] = std::array::from_fn(|i| ls[i] * w.s[i] as f32); + let bytes = mesh3d_transform_bytes(cam, centre, angles, scale); + (m.order_index, bytes, cam.view_depth(centre)) + }) + .collect(), + None => Vec::new(), + }; + for (idx, bytes, depth) in mesh_updates { + let Some(m) = self.mesh3d.iter_mut().find(|m| m.order_index == idx) else { + continue; + }; + self.renderer.queue.write_buffer(&m.ubo, 0, &bytes); + m.depth = depth; + } + + // 2D layers, both projections. The rect derivations differ (the + // perspective one projects the quad's corners through the camera) but + // both start from the same recomposed world transform. + let (w_px, h_px) = (self.width as f64, self.height as f64); + for layer in &mut self.layers { + let i = layer.order_index; + if !affected.get(i).copied().unwrap_or(false) { + continue; + } + let w = parent_world(i); + let local = locals[i]; + let o = w.apply_point(std::array::from_fn(|k| local.t[k] + layer.align_offset[k])); + let size_px = [ + layer.effective_size[0] * local.s[0] * w.s[0], + layer.effective_size[1] * local.s[1] * w.s[1], + ]; + let local_angles: [f32; 3] = std::array::from_fn(|k| local.r[k] as f32); + match &self.camera3d { + Some(cam) => { + let angles: [f32; 3] = std::array::from_fn(|k| local_angles[k] + w.r[k] as f32); + let centre = [o[0] as f32, o[1] as f32, o[2] as f32]; + let (rect, depth) = project_quad_ndc(cam, centre, angles, size_px); + layer.rect = rect; + layer.quad_corners = project_quad_corners(cam, centre, angles, size_px); + // Feeds the painter's-algorithm sort in `render()`; a + // negative depth culls the quad, same as at build. + layer.depth = depth; + } + None => { + // Same `general.zoom` scene-wide scale the build path uses. + let z = self.zoom; + layer.rect = [ + (2.0 * o[0] / w_px - 1.0) as f32 * z, + (2.0 * o[1] / h_px - 1.0) as f32 * z, + (size_px[0] / w_px) as f32 * z, + (size_px[1] / h_px) as f32 * z, + ]; + layer.angle = -(local_angles[2] + w.r[2] as f32); + } + } + } + } + + /// Feed pointer position in [0,1]² (drives camera parallax). + pub fn set_mouse(&mut self, norm: [f32; 2]) { + self.mouse_norm = norm; + // Scene space is Y-up with the origin bottom-left. + self.cursor_scene = [ + norm[0] * self.width as f32, + (1.0 - norm[1]) * self.height as f32, + ]; + } +} + +impl LiveProps { + pub(super) fn new(dir: &Path, raw: &serde_json::Value) -> Self { + let props = crate::workshop::properties::SceneProperties::from_project_dir(dir); + let mut resolved = raw.clone(); + props.resolve_scene_json(&mut resolved); + Self { + dir: dir.to_path_buf(), + raw: raw.clone(), + props, + resolved, + } + } +} + +/// Collect the leaves that differ between `a` and `b` as (path, new value). +/// A `{"user": …}` binding counts as one leaf, as do arrays that changed +/// length. +pub(super) fn changed_leaves( + a: &serde_json::Value, + b: &serde_json::Value, + path: &mut Vec, + out: &mut Vec<(Vec, serde_json::Value)>, +) { + use serde_json::Value; + if a == b { + return; + } + match (a, b) { + (Value::Object(x), Value::Object(y)) if !y.contains_key("user") => { + for k in x.keys().chain(y.keys().filter(|k| !x.contains_key(*k))) { + path.push(k.clone()); + changed_leaves( + x.get(k).unwrap_or(&Value::Null), + y.get(k).unwrap_or(&Value::Null), + path, + out, + ); + path.pop(); + } + } + (Value::Array(x), Value::Array(y)) if x.len() == y.len() => { + for (i, (p, q)) in x.iter().zip(y).enumerate() { + path.push(i.to_string()); + changed_leaves(p, q, path, out); + path.pop(); + } + } + _ => out.push((path.clone(), b.clone())), + } +} + +/// A layer quad's corners in output pixels (Y-up, centred), from its NDC +/// `rect` (centre, half-extent) and z-rotation. +pub(super) fn quad_corners_px(rect: [f32; 4], angle: f32, w: f32, h: f32) -> [[f32; 2]; 4] { + let (cx, cy) = (rect[0] * w * 0.5, rect[1] * h * 0.5); + let (hx, hy) = (rect[2] * w * 0.5, rect[3] * h * 0.5); + let (s, c) = angle.sin_cos(); + [[-hx, -hy], [hx, -hy], [hx, hy], [-hx, hy]] + .map(|[x, y]| [cx + x * c - y * s, cy + x * s + y * c]) +} + +/// Whether `p` lies inside the convex quad `q` (either winding). +pub(super) fn point_in_convex_quad(p: [f32; 2], q: &[[f32; 2]; 4]) -> bool { + let mut sign = 0.0f32; + for i in 0..4 { + let a = q[i]; + let b = q[(i + 1) % 4]; + let cross = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); + if cross.abs() < f32::EPSILON { + continue; + } + if sign == 0.0 { + sign = cross.signum(); + } else if cross.signum() != sign { + return false; + } + } + sign != 0.0 +} + +/// Collect every animated property from the raw scene.json objects. +pub(super) fn build_timelines( + raw_scene: &serde_json::Value, + graph: &crate::engine::model::resolve::TransformGraph, +) -> Vec { + use crate::engine::sim::timeline::PropertyAnimation; + let mut out = Vec::new(); + let objects = raw_scene.get("objects").and_then(|o| o.as_array()); + for (i, raw) in objects.into_iter().flatten().enumerate() { + let mut props: Vec<(String, &serde_json::Value)> = + ["origin", "scale", "angles", "alpha", "color", "brightness"] + .iter() + .filter_map(|k| raw.get(*k).map(|v| (k.to_string(), v))) + .collect(); + if let Some(m) = raw.get("instanceoverride").and_then(|v| v.as_object()) { + props.extend(m.iter().map(|(k, v)| (format!("instance.{k}"), v))); + } + for (e, eff) in raw + .get("effects") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .enumerate() + { + for (p, pass) in eff + .get("passes") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .enumerate() + { + if let Some(m) = pass.get("constantshadervalues").and_then(|v| v.as_object()) { + props.extend(m.iter().map(|(k, v)| (format!("effects.{e}.{p}.{k}"), v))); + } + } + } + for (key, value) in props { + let Some(anim) = value + .get("animation") + .and_then(|a| PropertyAnimation::parse(&key, a)) + else { + continue; + }; + let local = graph + .local + .get(i) + .copied() + .unwrap_or(crate::engine::model::resolve::Xform::IDENTITY); + let base: Vec = match key.as_str() { + "origin" => local.t.iter().map(|v| *v as f32).collect(), + "scale" => local.s.iter().map(|v| *v as f32).collect(), + "angles" => local.r.iter().map(|v| *v as f32).collect(), + _ => Vec::new(), + }; + out.push(TimelineEntry { + object: i, + key, + base, + anim, + last: None, + }); + } + } + out +} + +/// Build the scene's SceneScript runtime: one layer proxy per scene object +/// (indexed like `scene.objects`, the same index `order_index` and the +/// transform graph use), then one instance per scripted property. +pub(super) fn build_script_host( + dir: &Path, + scene: &crate::engine::model::scene::Scene, + graph: &crate::engine::model::resolve::TransformGraph, + canvas: [u32; 2], + raw_scene: &serde_json::Value, +) -> crate::engine::sim::script::ScriptHost { + use crate::engine::model::scene::{parse_value_f32, parse_value_vec3}; + use crate::engine::sim::script::{ObjectInit, ScriptHost}; + + let mut host = ScriptHost::new(); + if scene.objects.iter().all(|o| o.scripts().is_empty()) { + return host; + } + if let Some(path) = ScriptHost::storage_path_for(dir) { + host = host.with_storage(path); + } + // The raw scene.json entries back `thisScene.getInitialLayerConfig`. + let raw_objects: Vec = raw_scene + .get("objects") + .and_then(|o| o.as_array()) + .cloned() + .unwrap_or_default(); + if let Some(general) = raw_scene.get("general") { + host.set_scene_general(general); + } + + let user_props: serde_json::Map = + crate::workshop::properties::SceneProperties::from_project_dir(dir) + .iter() + .map(|p| (p.name.clone(), p.value.clone())) + .collect(); + host.set_scene_info( + [canvas[0] as f32, canvas[1] as f32], + canvas, + &serde_json::Value::Object(user_props), + ); + + let f32v = |v: [f64; 3]| [v[0] as f32, v[1] as f32, v[2] as f32]; + for (i, obj) in scene.objects.iter().enumerate() { + let local = graph + .local + .get(i) + .copied() + .unwrap_or(crate::engine::model::resolve::Xform::IDENTITY); + let text = obj.text.as_ref().and_then(|t| match t { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Object(m) => { + m.get("value").and_then(|v| v.as_str()).map(str::to_string) + } + _ => None, + }); + let init = ObjectInit { + name: obj.name.clone(), + id: obj.id, + origin: f32v(local.t), + angles: f32v(local.r), + scale: f32v(local.s), + size: f32v(obj.parsed_size()), + color: obj + .color + .as_ref() + .and_then(parse_value_vec3) + .map(f32v) + .unwrap_or([1.0; 3]), + visible: obj.is_visible(), + alpha: obj.alpha.as_ref().and_then(parse_value_f32).unwrap_or(1.0), + brightness: obj + .brightness + .as_ref() + .and_then(parse_value_f32) + .unwrap_or(1.0), + text, + pointsize: obj + .pointsize + .as_ref() + .and_then(parse_value_f32) + .unwrap_or(32.0), + volume: obj.volume.as_ref().and_then(parse_value_f32).unwrap_or(1.0), + parallax_depth: obj + .parallax_depth + .as_ref() + .and_then(parse_value_vec3) + .map(|v| [v[0] as f32, v[1] as f32]) + .unwrap_or([0.0; 2]), + parent_index: graph.parent.get(i).copied().flatten(), + frame_count: 1, + raw: raw_objects.get(i).cloned(), + }; + host.register_object(i, &init); + } + + for (i, obj) in scene.objects.iter().enumerate() { + let props: [(&str, Option<&serde_json::Value>); 10] = [ + ("visible", obj.visible.as_ref()), + ("origin", obj.origin.as_ref()), + ("angles", obj.angles.as_ref()), + ("scale", obj.scale.as_ref()), + ("alpha", obj.alpha.as_ref()), + ("color", obj.color.as_ref()), + ("brightness", obj.brightness.as_ref()), + ("text", obj.text.as_ref()), + ("pointsize", obj.pointsize.as_ref()), + ("volume", obj.volume.as_ref()), + ]; + for (prop, value) in props { + if let Some((script, authored)) = crate::engine::sim::script::scripted(value) { + host.attach(i, Some(prop), script, authored); + } + } + // Scripted particle overrides and effect constants/visibility, read + // from the raw entry (the typed model drops their scripts). + let Some(raw) = raw_objects.get(i) else { + continue; + }; + let mut extras: Vec<(String, &serde_json::Value)> = Vec::new(); + if let Some(m) = raw.get("instanceoverride").and_then(|v| v.as_object()) { + extras.extend(m.iter().map(|(k, v)| (format!("instance.{k}"), v))); + } + for (e, eff) in raw + .get("effects") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .enumerate() + { + if let Some(v) = eff.get("visible") { + extras.push((format!("effects.{e}.visible"), v)); + } + for (p, pass) in eff + .get("passes") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .enumerate() + { + if let Some(m) = pass.get("constantshadervalues").and_then(|v| v.as_object()) { + extras.extend(m.iter().map(|(k, v)| (format!("effects.{e}.{p}.{k}"), v))); + } + } + } + for (key, value) in extras { + if let Some((script, authored)) = crate::engine::sim::script::scripted(Some(value)) { + let initial = value + .get("value") + .cloned() + .unwrap_or(serde_json::Value::Null); + host.declare_property( + i, + &key, + crate::engine::sim::script::value_kind(&initial), + &initial, + ); + host.attach(i, Some(&key), script, authored); + } + } + } + host +} diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs new file mode 100644 index 0000000..acd1fc2 --- /dev/null +++ b/src/engine/gpu/renderer/mod.rs @@ -0,0 +1,755 @@ +//! GPU scene renderer: pipelines, effect/FBO pass chaining, and the +//! per-scene [`GpuSceneInstance`] runtime. +//! +//! Pass model (mirrors linux-wallpaperengine's CWallpaper/CPass chain): +//! - every layer's base texture runs through its effect passes, ping-ponging +//! between two persistent scene-sized render targets; +//! - effect passes may render into named FBOs (`effect.json` `fbos` + +//! `target`) and re-bind them as texture slots (`bind`); +//! - each layer is then composited into the scene target with its blend mode, +//! camera-dynamics UV offset (shake + parallax) and fade opacity; +//! - when scene bloom is enabled a threshold → blur chain runs over +//! quarter/eighth-res buffers (`_rt_4FrameBuffer` / `_rt_8FrameBuffer`) +//! and is added back onto the scene target. +//! +//! This file holds the types, constants and constructors; the behaviour is +//! split by role: +//! - `pipelines`: device-level state on [`GpuSceneRenderer`] (samplers, bind +//! group layouts, pipeline cache, pass-recording helpers) +//! - `build`: turning a scene into a running [`GpuSceneInstance`] +//! - `live`: changes while it runs (timelines, SceneScript, cursor events, +//! property edits, script-created objects) +//! - `draw`: rendering a frame and presenting it +//! - `lights`: shadow atlas, volumetrics, mesh lighting data +//! - `params`: packing effect constants into uniform buffers + +use anyhow::{anyhow, Context, Result}; +use image::RgbaImage; +use std::collections::HashMap; +use std::path::Path; +use std::sync::mpsc::SyncSender; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::engine::gpu::fbo::RenderTargetPool; +use crate::engine::gpu::shaders::{effect_def, loader, resolver, transpiler}; +use crate::engine::model::scene::{parse_value_bool, parse_value_f32}; +use crate::engine::model::{ShaderModel, WEBlending}; +use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; +use crate::engine::sim::particle; +use crate::platform::GpuDevice; + +mod params; +use params::*; + +mod pipelines; +use pipelines::*; + +mod lights; +use lights::*; + +mod draw; +use draw::*; + +mod live; +use live::*; + +mod build; +use build::*; + +const SHADER_SRC: &str = include_str!("gpu_shaders.wgsl"); + +/// Per-layer ping-pong buffer key in the FBO pool (each layer's effect chain +/// gets its own pair, sized to that layer's object size). +fn pingpong_key(layer_idx: usize, slot: u8) -> String { + format!("_rt_pingpong{slot}_{layer_idx}") +} + +/// Built-in effects with handwritten WGSL kernels. +const HARDCODED_EFFECTS: &[&str] = &[ + "pulse", + "scroll", + "shake", + "tint", + "opacity", + "waterripple", + "waterwaves", + "spin", +]; + +pub struct GpuSceneRenderer { + device: wgpu::Device, + queue: wgpu::Queue, + /// Indexed by `(no_interpolation, clamp_uvs)` — the `.tex` `NoInterpolation` + /// and `ClampUVs`/`ClampUVsBorder` flags select nearest-vs-linear filtering + /// and clamp-vs-repeat addressing per layer. + samplers: [wgpu::Sampler; 4], + base_bgl: wgpu::BindGroupLayout, + effect_bgl: wgpu::BindGroupLayout, + /// The g_AudioSpectrum* UBO (effect_bgl binding 1), rewritten each frame + /// from the FFT. Zero-filled when no audio is captured. + audio_buf: wgpu::Buffer, + composite_pipelines: Vec<(u32, wgpu::RenderPipeline)>, + /// Same vs_composite_quad/fs_composite pair as `composite_pipelines[0]` + /// but with blending disabled (plain overwrite): used for the intra-chain + /// base material pass, which always renders into a freshly-cleared, + /// transparent object FBO. Reusing the alpha-blended composite pipeline + /// there would double-attenuate rgb by alpha (once via the shader's own + /// `s.rgb * opacity` and again via the GPU blend equation). + base_pass_pipeline: wgpu::RenderPipeline, + effect_pipelines: Vec<(&'static str, wgpu::RenderPipeline)>, + dynamic_pipelines: HashMap, + // pipeline key → UBO layout (uniform key, glsl_type, typed default) + dynamic_uniform_keys: HashMap>, + // pipeline key → vertex attributes (glsl type, name); empty = fullscreen + // triangle with no vertex buffers (synthetic VS path) + dynamic_vertex_attrs: HashMap>, + dynamic_textures: HashMap>, + // (kind, dims) → shared quad attribute buffer (kind: 0=pos NDC, 1=uv, 2=zero) + attr_buffers: HashMap<(u8, u8), wgpu::Buffer>, + composite_pipeline_layout: wgpu::PipelineLayout, + effect_pipeline_layout: wgpu::PipelineLayout, + shader_module: wgpu::ShaderModule, + dummy_tex: wgpu::Texture, + // Scene bloom chain + bloom_threshold_pipeline: wgpu::RenderPipeline, + bloom_blur_pipeline: wgpu::RenderPipeline, + bloom_combine_pipeline: wgpu::RenderPipeline, + // Format → blit pipeline (surface presentation, FBO up/down-sampling) + blit_pipelines: HashMap, + // GPU particle pipeline (vs_particles/fs_particles): storage-buffer + // vertices, texture-array sprites, hardware blending straight into the + // scene target — replaces the budgeted CPU particle rasterizer. + particle_bgl: wgpu::BindGroupLayout, + particle_pipeline_add: wgpu::RenderPipeline, + particle_pipeline_over: wgpu::RenderPipeline, + // Static 3D mesh pipeline (vs_mesh3d/fs_mesh3d): real indexed geometry + // through the scene camera, and the only pipeline here with a depth + // buffer — a sphere self-occludes, which no painter's ordering can fake. + mesh3d_bgl: wgpu::BindGroupLayout, + /// Indexed by the material's `nocull`: back-face culling is what makes a + /// skybox work (its near hemisphere culls away instead of hiding the + /// scene inside it), while `"cullmode": "nocull"` materials — hollow + /// shells meant to be seen from both sides — must keep every face. + mesh3d_pipelines: [wgpu::RenderPipeline; 4], + // Shadow-caster depth pass: see `engine::gpu::shadow` and + // `GpuSceneInstance::build_shadow_atlas`. + shadow_pass_bgl: wgpu::BindGroupLayout, + mesh3d_shadow_pipeline: wgpu::RenderPipeline, + shadow_comparison_sampler: wgpu::Sampler, + // Volumetric-light-shaft ray march: see `fs_volumetrics`, + // `GpuSceneInstance::build_volumetrics`/`record_volumetrics`. + volumetrics_bgl: wgpu::BindGroupLayout, + volumetrics_pipeline: wgpu::RenderPipeline, + // Perspective-projected 2D quads: see `vs_quad3d`/`fs_quad3d`. + quad3d_bgl: wgpu::BindGroupLayout, + quad3d_pipeline: wgpu::RenderPipeline, +} + +/// Depth format for the 3D mesh pass. Depth32Float is guaranteed everywhere +/// wgpu runs, so there's no fallback to pick. +const MESH3D_DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; + +// ── Effect runtimes ─────────────────────────────────────────────────────────── + +/// Scene.json overrides for one effect pass: shader combos plus constant +/// values (indexed by material key, with normalized aliases and per-component +/// `_r/_g/_b`/`_x/_y/_z/_w` entries). +#[derive(Debug, Clone, Default)] +struct PassOverride { + combos: HashMap, + values: ShaderVals, + /// Positional scene.json texture override for this pass (e.g. a custom + /// opacity mask or noise map); `None` entries mean "leave this slot at + /// the material's own default". See `scene::Pass::textures`. + textures: Vec>, +} + +/// One effect attached to one layer (an *instance* — the same effect on two +/// layers gets separate pipelines because combos/constants can differ). +struct EffectInstanceDef { + layer_idx: usize, + /// Position in the object's own `effects` list (scripts address effects + /// by it: `thisLayer.getEffect(1)`). + effect_index: usize, + name: String, + /// Verbatim scene.json effect `file` path (e.g. `"effects/clouds/effect.json"` + /// or a workshop-nested `"effects/workshop/2138904733/foo/effect.json"`) — + /// used as-is rather than reconstructed from `name`, since custom-effect + /// bundles don't follow the `effects/{name}/` convention. + file: String, + pass_overrides: Vec, +} + +/// One executable pass of an effect. +struct EffectPassRuntime { + /// Pipeline lookup key: the effect name for hardcoded kernels, + /// `"fx{instance}:{effect}#{pass}"` for translated WE shaders. + key: String, + /// `dynamic_textures` lookup key. Equals `key` for translated shaders; + /// hardcoded kernels get their own `"fx{instance}:{effect}#0"` since + /// their pipeline `key` (the bare effect name) is shared across every + /// instance and would collide (e.g. two masked waterwaves on one layer). + tex_key: String, + hardcoded: bool, + /// Named FBO to render into (skips the ping-pong advance). + target: Option, + /// (texture slot index, FBO name) bindings. + binds: Vec<(u32, String)>, + /// Resolved constants: material defaults overridden by scene values. + values: ShaderVals, + /// Quad attribute buffers for the real-VS path; empty = synthetic VS + /// (fullscreen triangle, no vertex buffers). + vertex_buffers: Vec, +} + +/// A fully loaded effect instance: its passes plus auxiliary FBO declarations. +struct EffectRuntime { + passes: Vec, + /// (name, downscale divisor) — allocated per instance in the pool. + fbos: Vec<(String, f32)>, + /// True when any pass reads `g_AudioSpectrum*` — drives lazy capture start. + uses_audio: bool, +} + +/// Per-frame values for WE's engine-provided uniforms (the counterpart of +/// CPass::setupUniforms in the reference). +#[derive(Debug, Clone, Copy)] +struct EngineUniforms { + time: f32, + /// Fraction of the day in [0,1). + daytime: f32, + texel_size: [f32; 2], + pointer: [f32; 2], + /// g_Texture{N}Resolution: (tex_w, tex_h, real_w, real_h) per slot. + resolutions: [[f32; 4]; 8], + color: [f32; 3], + alpha: f32, + brightness: f32, +} + +impl EngineUniforms { + /// Look up an engine uniform by its GLSL name. Returns up to 4 components. + fn get(&self, key: &str) -> Option<[f32; 4]> { + let v = |a: f32, b: f32, c: f32, d: f32| Some([a, b, c, d]); + match key { + "g_Time" | "g_AnimationTime" | "g_FrameTime" => v(self.time, 0.0, 0.0, 0.0), + "g_Daytime" => v(self.daytime, 0.0, 0.0, 0.0), + "g_TexelSize" => v(self.texel_size[0], self.texel_size[1], 0.0, 0.0), + "g_TexelSizeHalf" => v(self.texel_size[0] * 0.5, self.texel_size[1] * 0.5, 0.0, 0.0), + "g_PointerPosition" | "g_PointerPositionLast" => { + v(self.pointer[0], self.pointer[1], 0.0, 0.0) + } + "g_Color" | "g_CompositeColor" | "g_TintColor" => { + v(self.color[0], self.color[1], self.color[2], 1.0) + } + "g_Color4" => v(self.color[0], self.color[1], self.color[2], self.alpha), + "g_Alpha" | "g_UserAlpha" | "g_BlendAlpha" | "g_CompositeAlpha" => { + v(self.alpha, 0.0, 0.0, 0.0) + } + "g_Brightness" => v(self.brightness, 0.0, 0.0, 0.0), + "g_TextureReductionScale" => v(1.0, 1.0, 1.0, 1.0), + _ => { + if let Some(rest) = key.strip_prefix("g_Texture") { + if let Some(idx) = rest.strip_suffix("Resolution") { + let i: usize = idx.parse().ok()?; + return self.resolutions.get(i).copied(); + } + } + None + } + } + } +} + +/// Scene-level bloom settings (general.bloom / bloomstrength / bloomthreshold). +#[derive(Debug, Clone, Default)] +struct BloomSettings { + enabled: bool, + strength: f32, + threshold: f32, +} + +struct SceneLayerGpu { + frames: Vec, + /// Animated puppet runtime — `frames[0]` is re-rasterized from the + /// posed mesh at `PUPPET_UPDATE_INTERVAL` (skinning + CPU raster costs + /// ~30ms at 4K, so it runs at a capped rate rather than per frame; + /// idle-style puppet animations are slow enough not to notice). + puppet: Option>, + /// Last animation time `frames[0]` was posed at, seconds. + puppet_posed_at: f32, + /// Embedded-video stream — `frames[0]` is replaced with the newest + /// decoded frame each tick (the decoder thread paces itself by PTS; + /// draining to the latest frame drops rather than lags). + video: + Option>>, + blend_mode: u32, + /// Live alpha; SceneScript may change it per frame. + alpha: f32, + /// Script-driven text: re-evaluated each tick; when the output string + /// changes, `frames[0]` is re-rasterized and `rect` re-derived (clocks + /// tick, dates roll over — 42 of the corpus's 58 script wallpapers). + text_dynamic: Option, + color: [f32; 3], + brightness: f32, + frame_duration_ms: u32, + parallax_depth: [f32; 2], + /// z-rotation in radians (WE `angles.z`). + angle: f32, + /// Object quad: (center_ndc.x, center_ndc.y, half_extent_ndc.x, half_extent_ndc.y). + rect: [f32; 4], + /// True perspective-projected quad corners (NDC, already divided) in + /// `project_quad_ndc`'s corner order — `Some` only for perspective-scene + /// layers where every corner projects in front of the camera. `rect` + /// above is still always populated (their axis-aligned bounding box, via + /// `project_quad_ndc`) since depth-sort and the fallback draw path both + /// need it regardless. See `project_quad_corners` and the Ghidra + /// report's quad-perspective-warp finding: without this, an off-axis + /// quad's silhouette collapses to a rectangle instead of the true + /// trapezoid. Only consumed when `blend_mode == 0` (normal) — non-normal + /// blend modes on perspective quads keep the existing rect-based + /// approximation, a narrow and rare combination not worth a second + /// pipeline variant per blend mode. + quad_corners: Option<[[f32; 2]; 4]>, + /// The object's own visibility (authored, then as scripts set it). The + /// layer draws only when it and every ancestor are visible — see + /// `GpuSceneInstance::object_visible`. + visible: bool, + /// Object size in px before scale — rebuilds `rect` when a script + /// changes scale. + effective_size: [f64; 2], + /// Text a script set but not yet rasterized (throttled — see + /// `flush_text`), and when the last raster happened and how long it took. + pending_text: Option, + text_rastered_at: Option, + text_raster_cost: Duration, + /// Composition/fullscreen layer: `frames[0]` is refilled each frame + /// with the scene drawn beneath it (see `capture_backdrop`). + backdrop: Option, + /// Spritesheet frame a script picked (`getTextureAnimation().setFrame`), + /// overriding the timed animation. + script_frame: Option, + /// `alignment`'s shift of the quad centre from the authored origin, kept + /// so a scripted origin moves the quad the same way. + align_offset: [f64; 3], + no_interpolation: bool, + clamp_uvs: bool, + /// Unscaled object size in pixels — matches the reference's `CImage::m_size` + /// (explicit scene.json `size`, falling back to the texture's native + /// resolution). Effect-chain FBOs (ping-pong + named targets) are sized to + /// this, not the scene resolution, per `CImage.cpp`/`FBOProvider::create`. + object_size: (u32, u32), + /// This layer's position in `scene.visible_objects()` — lets `render()` + /// interleave with particle systems in true scene z-order. + order_index: usize, + /// The scene object's `id`, for `_rt_imageLayerComposite__*` binds. + object_id: Option, + /// Perspective scenes only: view-space distance of the quad center from + /// the camera, for painter's-algorithm back-to-front sorting. Negative = + /// behind the camera (culled). Always 0.0 in orthographic scenes. + depth: f32, +} + +// ── GpuSceneInstance ────────────────────────────────────────────────────────── + +/// A loaded scene ready to render frames on the GPU. +/// +/// Owns the renderer, layer textures, effect runtimes, the persistent FBO +/// pool, and per-frame camera dynamics. Render either to RGBA (readback +/// paths: preview/testing/SHM fallback) or straight into an external texture +/// view (Wayland surface presentation — no readback). +/// One sprite texture array for a GPU particle draw unit. +struct ParticleGpuTex { + view: wgpu::TextureView, + frames: u32, + overbright: f32, + /// Ropes tile V past 1.0 (`uvscale`/scrolling) — sample with the repeat + /// sampler; sprite quads clamp. + repeat: bool, +} + +/// Per-layer GPU particle textures: the parent system's sprite plus one per +/// child preset (child *instances* spawn at runtime; textures are per child). +struct ParticleGpuAssets { + parent: ParticleGpuTex, + children: Vec, +} + +/// A scene `sound` object's playback stream, kept with its owner's identity +/// so SceneScript can find it by layer name or id. +pub(crate) struct SceneSound { + /// Index of the owning object in `scene.objects` (or a script-created one). + pub object: usize, + pub playback: crate::services::audio::SoundPlayback, +} + +pub struct GpuSceneInstance { + renderer: GpuSceneRenderer, + layers: Vec, + scene_effects: Vec, + /// Parallel to `scene_effects`; None = the instance failed to load. + effect_runtimes: Vec>, + fbo_pool: RenderTargetPool, + target: wgpu::Texture, + /// Snapshot of `target` before each colorBlendMode>0 layer composites, so + /// the dest-read Photoshop blend can sample "what's already there" + /// without reading and writing the same attachment in one pass. + scene_copy: wgpu::Texture, + width: u32, + height: u32, + clear_color: [f64; 3], + dynamics: CameraDynamics, + bloom: BloomSettings, + /// The volumetric-light-shaft ray march's static per-scene bind group — + /// `None` unless a light sets `castvolumetrics` (see `build_volumetrics`, + /// `record_volumetrics`, and the Ghidra report's `_rt_volumetrics*` + /// follow-up). Static because the camera and every light's world + /// transform are themselves static in this engine (mesh3d/lighting are + /// baked once at load, not per-frame — same precedent `mesh3d_lighting_ubo` + /// already sets). + volumetrics_bind_group: Option, + /// Each owns independent simulation state; stepped and CPU-rendered into a + /// scene-sized RGBA buffer every frame, then uploaded and composited like + /// any other layer (see `render()` step 3). A known simplification: real + /// particle objects interleave into the scene's actual render order, but + /// these always draw on top of images (before bloom, so bright particles + /// still contribute to the bloom pass) — see [[wp_engine_project]] memory. + particle_systems: Vec, + /// Parallel to `particle_systems`: each system's resolved sprite (from + /// its config's `material`, if any), sampled CPU-side by `render_onto`'s + /// textured path. `None` falls back to flat-color circles. + particle_sprites: Vec>, + /// Parallel to `particle_systems`: each system's `order_index` (from its + /// source `ParticleLayer`), so `render()` can interleave particles with + /// image layers in true scene z-order. + particle_order: Vec, + /// Parallel to `particle_systems`: true when the resolved sprite's + /// material declares `"blending":"additive"` (fog/smoke/embers/rain/ + /// lightning — most real particle materials). Composited additively + /// instead of the default alpha-over, which otherwise makes a sprite's + /// near-black background visibly darken/box the scene behind it. + particle_additive: Vec, + /// Parallel to `particle_systems`: GPU sprite texture arrays (parent + + /// per-child), built once at load for the GPU particle pipeline. + particle_gpu_assets: Vec, + start: Instant, + last_time: f32, + mouse_norm: [f32; 2], + /// The scene's SceneScript runtime, ticked once per frame. Lives here (on + /// the render thread) because boa's `Context` is `!Send`. + scripts: crate::engine::sim::script::ScriptHost, + /// What live user-property edits diff against. + live_props: LiveProps, + /// Per-object visibility as scripts leave it, indexed like `scene.objects` + /// (authored visibility at load). A layer draws only when its object and + /// every ancestor are visible. + object_visible: Vec, + /// Live `disablepropagation()` state per object. + propagation_disabled: Vec, + /// Whether a script registered audio buffers (feeds them each frame). + scripts_want_audio: bool, + /// Last cursor position in scene pixels, and button state — for + /// `input.*` and cursor events. + cursor_scene: [f32; 2], + cursor_left_down: bool, + /// Left-button transitions since the last frame, in order, so a click + /// that starts and ends between two frames still registers. + pending_buttons: Vec, + /// Objects with cursor handlers (`cursorClick` & co), hit-tested each + /// frame; empty for the vast majority of scenes. + cursor_targets: Vec, + /// The handler object under the cursor last frame, and the one a press + /// started on (receives the matching `cursorUp`/`cursorClick`). + hovered: Option, + pressed: Option, + last_cursor_scene: [f32; 2], + /// Keyframed property animations (`"animation"` timelines). + timelines: Vec, + /// Parallel to `scene_effects`: false once a script hides the effect. + effect_enabled: Vec, + /// Per-object draw position; starts as the object index, and + /// `thisScene.sortLayer` moves objects between their neighbours. + draw_rank: Vec, + sort_count: u32, + /// For `thisScene.createLayer`: the scene directory and its package. + scene_dir: std::path::PathBuf, + scene_pkg: Option, + /// Desktop-audio capture, started only when an effect reads + /// `g_AudioSpectrum*` (else `None` and the spectrum stays silent). Polled + /// once per frame into the renderer's `audio_buf`. + audio_capture: Option, + /// Live playback streams for `sound` objects; kept alive for the scene's + /// lifetime (dropping stops the audio). + sounds: Vec, + /// MPRIS "what's playing" watcher (`services::media`) — Linux-only (see + /// its own module doc). Started unconditionally rather than gated on + /// whether any script actually reads `media.*` (matching + /// `engine.runtime`'s own always-on treatment): the D-Bus connection is + /// a cheap background poll, and unlike the CEF bridge's `wants_media` + /// scan there's no equivalent "does this bundle reference it" signal to + /// scan for across independent per-property scripts. + #[cfg(target_os = "linux")] + media: Option, + /// True for genuine 3D scenes (`Scene::is_perspective`): layer rects were + /// projected through a perspective camera and `render()` sorts image + /// layers back-to-front by `SceneLayerGpu::depth` instead of scene order. + perspective: bool, + /// Static 3D meshes (`model` -> .mdl), drawn as real geometry before the + /// 2D layers composite over them. Empty for every scene that has none. + mesh3d: Vec, + /// Depth buffer for `mesh3d`; `None` when there are no meshes. The 2D + /// layers never touch it — they keep their painter's ordering. + mesh3d_depth: Option, + /// Scene-wide lighting/fog state, shared by every mesh3d draw call's + /// bind group (binding 3) rather than duplicated per mesh. Built once at + /// scene setup: `Scene::lights()`/`general.fog()` have no per-frame + /// animation path today, matching `build_mesh3d`'s own "static in real + /// content" assumption for MVP. Always allocated, even for scenes with no + /// meshes — the cost is one small buffer, and it keeps this field + /// unconditional rather than `Option`. Not read back after construction — + /// kept alive here (rather than as a `build_mesh3d`-local) so its + /// lifetime is explicit and it's in place for a future per-frame rebuild + /// if scripted-parent lights ever need one, matching how `Mesh3dGpu::ubo` + /// already gets rewritten for scripted-parent meshes. + #[allow(dead_code)] + mesh3d_lighting_ubo: wgpu::Buffer, + /// Shared shadow atlas texture (see `build_shadow_atlas`) — kept alive + /// for the same reason as `mesh3d_lighting_ubo`: every mesh3d bind group + /// holds a view into it, built once here rather than per mesh. + #[allow(dead_code)] + shadow_atlas_tex: wgpu::Texture, + /// Retained for the per-frame parent recompose, which has to rebuild each + /// mesh's MVP and each layer's projected rect. + camera3d: Option, + /// Parent chain per object, for recomposing transforms scripts change. + transform_graph: crate::engine::model::resolve::TransformGraph, + /// `general.zoom`, retained because the per-frame parent recompose rebuilds + /// orthographic rects and has to apply the same scene-wide scale the build + /// path does. + zoom: f32, + /// Live per-object local transforms, carried across frames. WE's + /// `update(value)` is handed the CURRENT value, so a script written as + /// `value.y += ...` accumulates — resetting to the authored value each + /// frame turns continuous rotation into jitter in place. + current_locals: Vec, +} + +/// One static 3D mesh, uploaded once: geometry never changes, and its MVP is +/// baked at load because these objects don't animate in real content. +/// World-space corners of a quad centered at `center`, rotated by `angles`, +/// sized `size_px` — bottom-left, bottom-right, top-right, top-left in the +/// quad's own unrotated local space. Shared by `project_quad_ndc` and +/// `project_quad_corners` so their corner math can't drift apart. +fn quad_world_corners(center: [f32; 3], angles: [f32; 3], size_px: [f64; 2]) -> [[f32; 3]; 4] { + let hx = (size_px[0] / 2.0) as f32; + let hy = (size_px[1] / 2.0) as f32; + [[-hx, -hy], [hx, -hy], [hx, hy], [-hx, hy]].map(|[cx, cy]| { + let off = crate::engine::sim::camera3d::rotate_euler([cx, cy, 0.0], angles); + [center[0] + off[0], center[1] + off[1], center[2] + off[2]] + }) +} + +/// Project a world-space quad through the perspective camera into an NDC rect. +/// +/// Returns `(rect, depth)`; a negative depth means the quad was culled (some +/// corner sat at or behind the eye plane). Shared by the build path and the +/// per-frame parent recompose so the two can't drift apart. +/// +/// Approximation: no in-quad perspective warp — the four projected corners +/// collapse to their axis-aligned bounding box. Used for depth-sort and as +/// the composite fallback in all cases; `project_quad_corners` (below) gives +/// the true quad shape for the common normal-blend case — see its own docs. +fn project_quad_ndc( + cam: &crate::engine::sim::camera3d::PerspectiveCamera, + center: [f32; 3], + angles: [f32; 3], + size_px: [f64; 2], +) -> ([f32; 4], f32) { + let corners = quad_world_corners(center, angles, size_px); + let ndc: Vec<[f32; 2]> = corners.iter().filter_map(|c| cam.project(*c)).collect(); + if ndc.len() < 4 { + return ([-10.0, -10.0, 0.0, 0.0], -1.0); + } + let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); + let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); + for p in &ndc { + min_x = min_x.min(p[0]); + min_y = min_y.min(p[1]); + max_x = max_x.max(p[0]); + max_y = max_y.max(p[1]); + } + ( + [ + (min_x + max_x) / 2.0, + (min_y + max_y) / 2.0, + (max_x - min_x) / 2.0, + (max_y - min_y) / 2.0, + ], + cam.view_depth(center), + ) +} + +/// True perspective-projected quad corners (NDC, already divided), in the +/// same bottom-left/bottom-right/top-right/top-left order +/// `quad_world_corners` builds — `None` if any corner sits at or behind the +/// eye plane (same cull condition `project_quad_ndc` uses, kept consistent +/// rather than drawing a degenerate partial quad). +/// +/// This fixes the silhouette bug `project_quad_ndc`'s AABB collapse has: an +/// off-axis or steeply-angled quad renders as a true trapezoid instead of a +/// rectangle. UV mapping across it is bilinear, not hardware +/// perspective-correct (that would need clip-space corners with real w, not +/// pre-divided NDC) — a scoped simplification: the silhouette is what the +/// Ghidra report flagged as visibly wrong, and bilinear already fixes that; +/// full perspective-correct texture mapping is a further, separate +/// improvement. +fn project_quad_corners( + cam: &crate::engine::sim::camera3d::PerspectiveCamera, + center: [f32; 3], + angles: [f32; 3], + size_px: [f64; 2], +) -> Option<[[f32; 2]; 4]> { + let corners = quad_world_corners(center, angles, size_px); + let mut ndc = [[0.0f32; 2]; 4]; + for (i, c) in corners.iter().enumerate() { + ndc[i] = cam.project(*c)?; + } + Some(ndc) +} + +struct Mesh3dGpu { + vbuf: wgpu::Buffer, + ibuf: wgpu::Buffer, + index_count: u32, + bind_group: wgpu::BindGroup, + nocull: bool, + depthtest: bool, + /// Kept so a script-driven parent can rewrite the MVP each frame. + ubo: wgpu::Buffer, + /// Pre-parent transform + which scene object this is, so the per-frame + /// pass can recompose the chain (see `render::TransformGraph`). + local: ([f32; 3], [f32; 3], [f32; 3]), + order_index: usize, + /// View depth of the mesh centre, for the perspective back-to-front sort. + /// Meshes are never culled on it (a skybox legitimately surrounds the + /// camera); it only orders them against the 2D layers. + depth: f32, +} + +/// Point-light cap for the mesh3d lighting pass. Must match +/// `MESH3D_MAX_LIGHTS` in `gpu_shaders.wgsl` exactly — there's no shared +/// constant across the Rust/WGSL boundary, so keep the two in sync by hand. +const MESH3D_MAX_LIGHTS: usize = 8; + +/// Shadow-casting-light cap, mirrored from `engine::gpu::shadow::MAX_SHADOW_LIGHTS` +/// — must also match `MESH3D_MAX_SHADOW_LIGHTS` in `gpu_shaders.wgsl` by +/// hand, same caveat as `MESH3D_MAX_LIGHTS`. +const MESH3D_MAX_SHADOW_LIGHTS: usize = crate::engine::gpu::shadow::MAX_SHADOW_LIGHTS; + +/// Byte length of the `Mesh3dLighting` uniform buffer: 5 leading `vec4`s +/// (flags, ambient, fog_distance, fog_height, fog_extra), three +/// `MESH3D_MAX_LIGHTS`-length `vec4` arrays (positions, colors, spot +/// direction+exponent), then `MESH3D_MAX_SHADOW_LIGHTS` `mat4x4`s (shadow +/// view-projections) and `MESH3D_MAX_SHADOW_LIGHTS` `vec4`s (their atlas UV +/// sub-rects), then one trailing `mat4x4` (`cookie_view_proj`). +const MESH3D_LIGHTING_BYTES_LEN: usize = + 16 * 5 + 16 * MESH3D_MAX_LIGHTS * 3 + (64 + 16) * MESH3D_MAX_SHADOW_LIGHTS + 64; + +impl GpuSceneInstance { + /// Open a scene from a wallpaper directory with a freshly opened GPU device. + pub fn open(dir: &Path) -> Result { + let gpu = GpuDevice::open_low_power() + .or_else(|_| GpuDevice::open_best()) + .context("opening GPU device")?; + Self::with_gpu(gpu, dir) + } + + /// Open a scene using an existing device/queue (e.g. the one that owns a + /// Wayland surface, so rendered frames can be presented directly). + pub fn with_device(device: wgpu::Device, queue: wgpu::Queue, dir: &Path) -> Result { + let renderer = GpuSceneRenderer::with_device(device, queue)?; + Self::build(renderer, dir) + } + + pub fn with_gpu(gpu: GpuDevice, dir: &Path) -> Result { + let renderer = GpuSceneRenderer::new(gpu)?; + Self::build(renderer, dir) + } + + pub fn size(&self) -> (u32, u32) { + (self.width, self.height) + } + + pub fn device(&self) -> &wgpu::Device { + self.renderer.device() + } + + pub fn queue(&self) -> &wgpu::Queue { + self.renderer.queue() + } +} + +fn named_fbo_key(instance_idx: usize, fbo_name: &str) -> String { + format!("fx{instance_idx}:{fbo_name}") +} + +/// Continuously render scene frames into a channel (preview window, headless +/// tests, and the CPU/SHM fallback path). +#[tracing::instrument(target = "render", level = "debug", skip(tx), fields(dir = %dir.display(), target_fps))] +pub fn gpu_scene_render_loop( + dir: &std::path::Path, + tx: &SyncSender>, + target_fps: f64, +) -> Result<()> { + tracing::info!(target: "render", "opening GPU scene instance"); + let mut instance = GpuSceneInstance::open(dir)?; + let frame_duration = Duration::from_secs_f64(1.0 / target_fps); + let start = Instant::now(); + tracing::info!(target: "render", "entering GPU render loop"); + + let mut frame_no: u64 = 0; + loop { + let frame = instance.render_rgba()?; + tracing::trace!(target: "render", frame = frame_no, "rendered frame"); + frame_no += 1; + if tx.send(Arc::new(frame)).is_err() { + tracing::debug!(target: "render", frames = frame_no, "receiver dropped; ending render loop"); + return Ok(()); + } + + let elapsed = start.elapsed(); + let next = Duration::from_secs_f64( + (elapsed.as_secs_f64() / frame_duration.as_secs_f64()).ceil() + * frame_duration.as_secs_f64(), + ); + if next > elapsed { + std::thread::sleep(next - elapsed); + } + } +} + +/// The unresolved scene.json, the wallpaper's user properties, and the scene +/// as last resolved against them. +struct LiveProps { + dir: std::path::PathBuf, + raw: serde_json::Value, + props: crate::workshop::properties::SceneProperties, + resolved: serde_json::Value, +} + +/// One property's keyframe animation on one object. +struct TimelineEntry { + object: usize, + /// `origin`/`alpha`/…, or a script-style extra key (`instance.rate`, + /// `effects...`). + key: String, + /// Base value, which transform keyframes are offsets from. + base: Vec, + anim: crate::engine::sim::timeline::PropertyAnimation, + /// Last value applied, to skip re-applying an idle animation. + last: Option>, +} + +#[cfg(test)] +mod tests; diff --git a/src/engine/gpu/renderer/params.rs b/src/engine/gpu/renderer/params.rs new file mode 100644 index 0000000..530eed9 --- /dev/null +++ b/src/engine/gpu/renderer/params.rs @@ -0,0 +1,340 @@ +//! Effect uniform packing: turning a pass's shader constants (scene values, +//! user properties, script overrides) into the std140 bytes its uniform +//! buffer expects. + +use super::*; + +/// Fraction of the local day in [0,1) (the reference's g_Daytime). +/// `g_Daytime`: fraction of the local day elapsed. +pub(super) fn daytime_fraction() -> f32 { + crate::engine::sim::script::local_time_of_day() +} + +pub(super) type ShaderVals = std::collections::HashMap; + +/// Insert a constant under every spelling the packer might look up: the raw +/// key, its normalized alias (scene constants use +/// "ui_editor_properties_color_start" while asset shaders annotate materials +/// as "colorstart"), and per-component suffixes for vector values. +pub(super) fn insert_value_components(map: &mut ShaderVals, key: &str, value: &serde_json::Value) { + let alias = |k: &str| -> String { + k.strip_prefix("ui_editor_properties_") + .unwrap_or(k) + .replace('_', "") + .to_lowercase() + }; + let animated = crate::engine::model::json_to_animated(value); + insert_animated_components(map, key, &alias(key), &animated); +} + +pub(super) fn insert_animated_components( + map: &mut ShaderVals, + key: &str, + alias: &str, + v: &crate::engine::model::AnimatedValue, +) { + if let Some(f) = v.as_float() { + map.insert(key.to_string(), f); + map.insert(alias.to_string(), f); + } + if let Some(comps) = v.components() { + // Both spellings: colors are read as _r/_g/_b/_a, geometry as + // _x/_y/_z/_w. A vec3 keeps its historical implicit _w/_a of 1.0. + let mut named: Vec<(&str, f32)> = Vec::new(); + for (i, val) in comps.iter().enumerate() { + named.push((["r", "g", "b", "a"][i.min(3)], *val)); + named.push((["x", "y", "z", "w"][i.min(3)], *val)); + } + if comps.len() == 3 { + named.push(("a", 1.0)); + named.push(("w", 1.0)); + } + for (name, val) in named { + map.insert(format!("{key}_{name}"), val); + map.insert(format!("{alias}_{name}"), val); + } + } +} + +/// Pack effect uniform values using std140 layout rules — the transpiled +/// UBO (GLSL uniform block → SPIR-V → WGSL) uses std140 offsets, so each +/// member is aligned to its own alignment, NOT padded to 16 bytes: +/// float/int align 4, vec2 align 8, vec3/vec4/matrix columns align 16. +/// +/// Value resolution per uniform (mirrors CPass::setupUniforms + +/// setupShaderVariables): engine-provided uniforms first, then material/scene +/// constants, then the shader annotation's default. +pub(super) fn make_params_from_translated_typed( + keys: &[transpiler::UniformEntry], + engine: &EngineUniforms, + vals: &ShaderVals, +) -> Vec { + let mut bytes: Vec = Vec::new(); + + // Resolve one uniform into up to 4 components. A scene may key the value + // by the annotation's material key ("alpha") or by its UI label + // ("ui_editor_properties_outline_background") — and those two are not + // always spellings of each other, so both are tried. + let resolve = |entry: &transpiler::UniformEntry, default: &[f32; 4]| -> [f32; 4] { + let key = entry.key.as_str(); + if let Some(v) = engine.get(key) { + return v; + } + let names = || std::iter::once(key).chain(entry.label.as_deref()); + let mut out = *default; + if let Some(f) = names().find_map(|k| vals.get(k)) { + out = [*f; 4]; + } + // Per-component spellings win over the scalar broadcast. + for (i, suffixes) in [ + &["_x", "_r"][..], + &["_y", "_g"][..], + &["_z", "_b"][..], + &["_w", "_a"][..], + ] + .iter() + .enumerate() + { + if let Some(f) = suffixes + .iter() + .flat_map(|s| names().map(move |k| format!("{k}{s}"))) + .find_map(|k| vals.get(&k)) + { + out[i] = *f; + } + } + out + }; + + fn align_to(bytes: &mut Vec, align: usize) { + let rem = bytes.len() % align; + if rem != 0 { + bytes.resize(bytes.len() + (align - rem), 0); + } + } + fn push_f32(bytes: &mut Vec, v: f32) { + bytes.extend_from_slice(&v.to_le_bytes()); + } + + for entry in keys { + match entry.glsl_type.as_str() { + "vec2" => { + let v = resolve(entry, &entry.default); + align_to(&mut bytes, 8); + push_f32(&mut bytes, v[0]); + push_f32(&mut bytes, v[1]); + } + "vec3" => { + let v = resolve(entry, &entry.default); + align_to(&mut bytes, 16); + push_f32(&mut bytes, v[0]); + push_f32(&mut bytes, v[1]); + push_f32(&mut bytes, v[2]); + // std140 vec3 has size 12; a following float packs into the + // tail 4 bytes, so do NOT pad here. + } + "vec4" => { + let v = resolve(entry, &entry.default); + align_to(&mut bytes, 16); + for f in v { + push_f32(&mut bytes, f); + } + } + "mat4" => { + align_to(&mut bytes, 16); + let identity: [f32; 16] = [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ]; + for f in identity { + push_f32(&mut bytes, f); + } + } + "mat3" => { + // std140 mat3: 3 columns × (vec3 + 4 bytes pad) = 48 bytes + align_to(&mut bytes, 16); + let cols: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + for col in cols { + for f in col { + push_f32(&mut bytes, f); + } + bytes.extend_from_slice(&[0u8; 4]); + } + } + _ => { + // float / int / bool / unknown: 4 bytes, align 4 + let v = resolve(entry, &entry.default); + align_to(&mut bytes, 4); + push_f32(&mut bytes, v[0]); + } + } + } + + // Uniform buffers must be non-empty and sized to a 16-byte multiple. + if bytes.is_empty() { + bytes.resize(16, 0); + } + align_to(&mut bytes, 16); + bytes +} + +pub(super) fn make_effect_params(name: &str, time: f32, vals: &ShaderVals) -> Vec { + // Scene `constantshadervalues` are keyed by the shader annotation's + // *material key* ("speed", "scale", ...), not its ui_editor_properties_* + // label — look the bare key up first and keep the label as a fallback + // for any older call sites below that still pass the long form. + fn get(vals: &ShaderVals, key: &str, default: f32) -> f32 { + if let Some(v) = vals.get(key) { + return *v; + } + // Scenes key a value by the annotation's material key ("amount") or by + // its UI label ("ui_editor_properties_pulse_amount", normalized to + // "pulseamount") — and the label is not always the material key with a + // prefix chopped off. LABEL_ALIASES covers the pairs the corpus + // actually uses; the translated-effect packer gets this from the + // shader annotation itself (see make_params_from_translated_typed). + const LABEL_ALIASES: &[(&str, &[&str])] = &[ + ("amount", &["pulseamount"]), + ("speed", &["pulsespeed"]), + ("phase", &["pulsephase", "timeoffset"]), + ("bounds", &["pulsebounds"]), + ("scale", &["ripplescale"]), + ]; + // Vector params arrive here already split ("bounds_r"), so match on the + // stem and carry the suffix over. + let (stem, suffix) = match key.rfind('_') { + Some(i) if key.len() - i <= 2 => (&key[..i], &key[i..]), + _ => (key, ""), + }; + if let Some(v) = LABEL_ALIASES + .iter() + .find(|(k, _)| *k == stem) + .into_iter() + .flat_map(|(_, aliases)| aliases.iter()) + .find_map(|a| vals.get(&format!("{a}{suffix}"))) + { + return *v; + } + key.strip_prefix("ui_editor_properties_") + .and_then(|bare| vals.get(bare).copied()) + .unwrap_or(default) + } + fn pack(floats: &[f32]) -> Vec { + floats.iter().flat_map(|f| f.to_le_bytes()).collect() + } + + // Keys below are the shader annotations' material keys (the names + // scene.json constantshadervalues actually use); defaults match each + // annotation's declared default. + match name { + "pulse" => pack(&[ + time, + get(vals, "speed", 3.0), + get(vals, "amount", 1.0), + get(vals, "power", 1.0), + get(vals, "phase", 0.0), + get(vals, "bounds_r", 0.0), + get(vals, "bounds_g", 1.0), + get(vals, "combo_BLENDMODE", 9.0), + get(vals, "tintlow_r", 1.0), + get(vals, "tintlow_g", 1.0), + get(vals, "tintlow_b", 1.0), + get(vals, "combo_PULSECOLOR", 1.0), + get(vals, "tinthigh_r", 1.0), + get(vals, "tinthigh_g", 1.0), + get(vals, "tinthigh_b", 1.0), + get(vals, "combo_PULSEALPHA", 0.0), + get(vals, "noisespeed", 0.5), + get(vals, "noiseamount", 0.0), + get(vals, "combo_AUDIOPROCESSING", 0.0), + get(vals, "frequencymin", 0.0), + get(vals, "frequencymax", 1.0), + get(vals, "audiobounds_x", 0.5), + get(vals, "audiobounds_y", 1.0), + get(vals, "audioexponent", 1.0), + get(vals, "audioamount", 1.0), + // PulseParams rounds up to 112 bytes (vec3 members force a + // 16-byte struct alignment) — pad or wgpu rejects the bind group. + 0.0, + 0.0, + 0.0, + ]), + "scroll" => pack(&[ + time, + get(vals, "speedx", 0.2), + get(vals, "speedy", 0.2), + get(vals, "repeat_r", 1.0), + get(vals, "repeat_g", 1.0), + 0.0, + 0.0, + 0.0, + ]), + "shake" => { + // shake.vert precomputes v_Bounds = (x, 1/(y-x)) from `bounds`. + let bounds_x = get(vals, "bounds_x", 0.0); + let bounds_y = get(vals, "bounds_y", 1.0); + pack(&[ + time, + get(vals, "speed", 1.0), + get(vals, "strength", 0.1), + get(vals, "combo_DIRECTION", 0.0), + get(vals, "friction_x", 1.0), + get(vals, "friction_y", 1.0), + bounds_x, + 1.0 / (bounds_y - bounds_x).max(0.0001), + get(vals, "combo_NOISE", 0.0), + 0.0, + 0.0, + 0.0, + ]) + } + "tint" => { + let r = get(vals, "color_r", 1.0); + let g = get(vals, "color_g", 0.0); + let b = get(vals, "color_b", 0.0); + let a = get(vals, "alpha", 1.0); + pack(&[ + r, + g, + b, + a, + get(vals, "combo_BLENDMODE", 30.0), + 0.0, + 0.0, + 0.0, + ]) + } + "opacity" => pack(&[get(vals, "alpha", 1.0), 0.0, 0.0, 0.0]), + "waterripple" => pack(&[ + time, + get(vals, "ripplestrength", 0.1), + get(vals, "animationspeed", 0.15), + get(vals, "scale", 1.0), + ]), + "waterwaves" => { + // Direction is the reference's rotateVec2((0, 1), angle) — + // base vector (0, 1), not (1, 0). + let dir = get(vals, "direction", 0.0); + pack(&[ + time, + get(vals, "speed", 5.0), + get(vals, "scale", 200.0), + get(vals, "strength", 0.1), + -dir.sin(), + dir.cos(), + get(vals, "exponent", 1.0), + 0.0, + ]) + } + "spin" => pack(&[ + time, + get(vals, "speed", 1.0), + get(vals, "center_r", 0.5), + get(vals, "center_g", 0.5), + get(vals, "size", 0.1), + get(vals, "feather", 0.002), + get(vals, "combo_REPEAT", 1.0), + 0.0, + ]), + _ => vec![0u8; 32], + } +} diff --git a/src/engine/gpu/renderer/pipelines.rs b/src/engine/gpu/renderer/pipelines.rs new file mode 100644 index 0000000..40e16bf --- /dev/null +++ b/src/engine/gpu/renderer/pipelines.rs @@ -0,0 +1,1405 @@ +//! Device-level GPU state shared by every layer of a scene: samplers, bind +//! group layouts, the pipeline cache, and the draw helpers that record +//! passes with them. + +use super::*; + +impl GpuSceneRenderer { + pub fn new(gpu: GpuDevice) -> Result { + let GpuDevice { device, queue, .. } = gpu; + Self::with_device(device, queue) + } + + pub fn with_device(device: wgpu::Device, queue: wgpu::Queue) -> Result { + let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene_shaders"), + source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()), + }); + + let make_sampler = |no_interpolation: bool, clamp: bool| { + let filter = if no_interpolation { + wgpu::FilterMode::Nearest + } else { + wgpu::FilterMode::Linear + }; + let address = if clamp { + wgpu::AddressMode::ClampToEdge + } else { + wgpu::AddressMode::Repeat + }; + device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("scene_sampler"), + mag_filter: filter, + min_filter: filter, + address_mode_u: address, + address_mode_v: address, + ..Default::default() + }) + }; + // Index by (no_interpolation as usize) << 1 | (clamp as usize). + let samplers = [ + make_sampler(false, false), + make_sampler(false, true), + make_sampler(true, false), + make_sampler(true, true), + ]; + + let tex_entry = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }; + let base_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("base_bgl"), + entries: &[ + tex_entry(0), + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + // VERTEX too: vs_composite_quad reads the object rect. + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // Extra texture slots for multi-texture effects (g_Texture1..7, + // bound at N+2 — 7 is the highest the workshop corpus uses). + tex_entry(3), + tex_entry(4), + tex_entry(5), + tex_entry(6), + tex_entry(7), + tex_entry(8), + tex_entry(9), + ], + }); + + let effect_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("effect_bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + // VERTEX too: real WE vertex shaders read the shared UBO + // (g_ModelViewProjectionMatrix, animation params, ...). + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // binding 1: the g_AudioSpectrum* storage buffer, updated per + // frame from the FFT. Read-only storage (not uniform) because a + // `float[N]` needs WGSL-illegal 16-byte uniform array stride; + // storage packs tightly. Always present so every effect + // pipeline's layout matches; audio shaders read it in the + // fragment stage, others ignore the binding. + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + let audio_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("audio_spectrum"), + size: crate::services::audio::UNIFORM_BYTES as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let composite_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("composite_layout"), + bind_group_layouts: &[&base_bgl], + push_constant_ranges: &[], + }); + + // Normal (colorBlendMode == 0) layers use hardware alpha blending. + // Any other colorBlendMode is a Photoshop-style blend that needs to + // read the destination in-shader (see fs_composite_blend), so that + // pipeline writes its already-blended result straight through. + let mut composite_pipelines = Vec::new(); + composite_pipelines.push(( + 0u32, + Self::create_pipeline( + &device, + &composite_pipeline_layout, + &shader_module, + "vs_composite_quad", + "fs_composite", + "composite_normal", + Some(wgpu::BlendState::ALPHA_BLENDING), + wgpu::TextureFormat::Rgba8Unorm, + ), + )); + composite_pipelines.push(( + 1u32, + Self::create_pipeline( + &device, + &composite_pipeline_layout, + &shader_module, + "vs_composite_quad", + "fs_composite_blend", + "composite_blend", + Some(wgpu::BlendState::REPLACE), + wgpu::TextureFormat::Rgba8Unorm, + ), + )); + // Base material pass: same quad/tint shader, no blending — the + // destination (a layer's own FBO) is always freshly cleared to + // transparent, so this is a plain "write the tinted pixel" pass, not + // a composite (see BlendingMode_Normal == GL_ONE,GL_ZERO in CPass.cpp). + let base_pass_pipeline = Self::create_pipeline( + &device, + &composite_pipeline_layout, + &shader_module, + "vs_composite_quad", + "fs_composite", + "base_material_pass", + None, + wgpu::TextureFormat::Rgba8Unorm, + ); + + let effect_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("effect_layout"), + bind_group_layouts: &[&base_bgl, &effect_bgl], + push_constant_ranges: &[], + }); + + let effect_entry_points: Vec<(&str, &str)> = HARDCODED_EFFECTS + .iter() + .map(|n| { + ( + *n, + match *n { + "pulse" => "fs_pulse", + "scroll" => "fs_scroll", + "shake" => "fs_shake", + "tint" => "fs_tint", + "opacity" => "fs_opacity", + "waterripple" => "fs_waterripple", + "waterwaves" => "fs_waterwaves", + "spin" => "fs_spin", + _ => "fs_composite", + }, + ) + }) + .collect(); + + let mut effect_pipelines = Vec::new(); + for (name, entry) in &effect_entry_points { + let pipeline = Self::create_pipeline( + &device, + &effect_layout, + &shader_module, + "vs_fullscreen", + entry, + name, + None, + wgpu::TextureFormat::Rgba8Unorm, + ); + effect_pipelines.push((*name, pipeline)); + } + + // Bloom chain pipelines (all render into Rgba8Unorm pool targets). + let bloom_threshold_pipeline = Self::create_pipeline( + &device, + &effect_layout, + &shader_module, + "vs_fullscreen", + "fs_bloom_threshold", + "bloom_threshold", + None, + wgpu::TextureFormat::Rgba8Unorm, + ); + let bloom_blur_pipeline = Self::create_pipeline( + &device, + &effect_layout, + &shader_module, + "vs_fullscreen", + "fs_blur9", + "bloom_blur", + None, + wgpu::TextureFormat::Rgba8Unorm, + ); + // fs_bloom_combine now samples the pre-bloom scene copy itself and + // adds bloom in-shader (matching combine.frag), so this pass is a + // plain overwrite (WE's `combine.json` pass uses default/"normal" + // blending, not a GPU-side additive blend). + let bloom_combine_pipeline = Self::create_pipeline( + &device, + &effect_layout, + &shader_module, + "vs_fullscreen", + "fs_bloom_combine", + "bloom_combine", + None, + wgpu::TextureFormat::Rgba8Unorm, + ); + + let particle_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("particle_bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2Array, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + let particle_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("particle_layout"), + bind_group_layouts: &[&particle_bgl], + push_constant_ranges: &[], + }); + // Additive: the reference draws particle quads with + // glBlendFuncSeparate(SRC_ALPHA, ONE, SRC_ALPHA, ONE) (CPass.cpp). + let particle_add_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }; + let particle_over_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + }; + let particle_pipeline_add = Self::create_pipeline( + &device, + &particle_layout, + &shader_module, + "vs_particles", + "fs_particles", + "particles_add", + Some(particle_add_blend), + wgpu::TextureFormat::Rgba8Unorm, + ); + let particle_pipeline_over = Self::create_pipeline( + &device, + &particle_layout, + &shader_module, + "vs_particles", + "fs_particles", + "particles_over", + Some(particle_over_blend), + wgpu::TextureFormat::Rgba8Unorm, + ); + + let mesh3d_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("mesh3d_bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // Shared shadow atlas (one texture, every shadow-casting + // light's tile packed into it — see `engine::gpu::shadow`) plus + // its comparison sampler, for `textureSampleCompareLevel` PCF + // lookups in `fs_mesh3d`. + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Depth, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), + count: None, + }, + // Spot-light cookie texture (`usecookie`/`cookie`) — a + // harmless 1x1 white dummy when no light has one, same + // "always-valid dummy binding" precedent as `shadow_atlas`. + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let mesh3d_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("mesh3d_layout"), + bind_group_layouts: &[&mesh3d_bgl], + push_constant_ranges: &[], + }); + let make_mesh3d_pipeline = |cull: Option, depth: bool, label: &str| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&mesh3d_layout), + vertex: wgpu::VertexState { + module: &shader_module, + entry_point: Some("vs_mesh3d"), + buffers: &[wgpu::VertexBufferLayout { + array_stride: 32, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x3], + }], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader_module, + entry_point: Some("fs_mesh3d"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: cull, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: MESH3D_DEPTH_FORMAT, + depth_write_enabled: depth, + depth_compare: if depth { + wgpu::CompareFunction::Less + } else { + wgpu::CompareFunction::Always + }, + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + multiview: None, + cache: None, + }) + }; + // Indexed by `nocull as usize | (!depthtest as usize) << 1`. + let mesh3d_pipelines = [ + make_mesh3d_pipeline(Some(wgpu::Face::Back), true, "mesh3d"), + make_mesh3d_pipeline(None, true, "mesh3d_nocull"), + make_mesh3d_pipeline(Some(wgpu::Face::Back), false, "mesh3d_nodepth"), + make_mesh3d_pipeline(None, false, "mesh3d_nocull_nodepth"), + ]; + + // Shadow-caster depth pass: renders every mesh3d object's geometry, + // position-only, into one tile of the shared shadow atlas (see + // `engine::gpu::shadow` and `GpuSceneInstance::build_shadow_atlas`). No + // fragment stage — depth-only. No back-face culling: a caster should + // block light through any face, not just the ones the main camera + // would see. + let shadow_pass_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("shadow_pass_bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let shadow_pass_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("shadow_pass_layout"), + bind_group_layouts: &[&shadow_pass_bgl], + push_constant_ranges: &[], + }); + let mesh3d_shadow_pipeline = + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("mesh3d_shadow"), + layout: Some(&shadow_pass_layout), + vertex: wgpu::VertexState { + module: &shader_module, + entry_point: Some("vs_shadow_depth"), + // Same buffer the main mesh3d pipeline uses (stride 32, + // pos+uv+normal) — only position is declared/read here, but + // the stride must still match the real per-vertex layout. + buffers: &[wgpu::VertexBufferLayout { + array_stride: 32, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x3], + }], + compilation_options: Default::default(), + }, + fragment: None, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: MESH3D_DEPTH_FORMAT, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + multiview: None, + cache: None, + }); + let shadow_comparison_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("shadow_comparison_sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + compare: Some(wgpu::CompareFunction::LessEqual), + ..Default::default() + }); + + // Volumetric-light-shaft ray march (`fs_volumetrics`): its own small + // bind group layout rather than extending `base_bgl`/`effect_bgl` — + // it needs the shadow atlas + a comparison sampler, which those + // don't declare, and nothing else. Downstream blur/combine passes + // reuse the existing bloom pipelines unchanged (both are already + // generic single-source-texture operations — see `record_volumetrics`). + let volumetrics_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("volumetrics_bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Depth, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), + count: None, + }, + ], + }); + let volumetrics_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("volumetrics_layout"), + bind_group_layouts: &[&volumetrics_bgl], + push_constant_ranges: &[], + }); + let volumetrics_pipeline = Self::create_pipeline( + &device, + &volumetrics_layout, + &shader_module, + "vs_fullscreen", + "fs_volumetrics", + "volumetrics", + None, + wgpu::TextureFormat::Rgba8Unorm, + ); + + // Perspective-projected 2D quads (true silhouette, not the AABB + // `project_quad_ndc` collapses to) — see `vs_quad3d`/`fs_quad3d` in + // gpu_shaders.wgsl and the Ghidra report's quad-warp finding. Its + // own small bind group layout rather than extending the widely + // shared `base_bgl`: `Quad3DParams` (corners) has nothing to do with + // any other pass that layout serves. + let quad3d_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("quad3d_bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + tex_entry(1), + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let quad3d_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("quad3d_layout"), + bind_group_layouts: &[&quad3d_bgl], + push_constant_ranges: &[], + }); + let quad3d_pipeline = Self::create_pipeline( + &device, + &quad3d_layout, + &shader_module, + "vs_quad3d", + "fs_quad3d", + "quad3d", + Some(wgpu::BlendState::ALPHA_BLENDING), + wgpu::TextureFormat::Rgba8Unorm, + ); + + let dummy_tex = Self::create_white_1x1_texture(&device, &queue); + + Ok(Self { + device, + queue, + samplers, + base_bgl, + effect_bgl, + audio_buf, + composite_pipelines, + base_pass_pipeline, + effect_pipelines, + dynamic_pipelines: HashMap::new(), + dynamic_uniform_keys: HashMap::new(), + dynamic_vertex_attrs: HashMap::new(), + dynamic_textures: HashMap::new(), + attr_buffers: HashMap::new(), + composite_pipeline_layout, + effect_pipeline_layout: effect_layout, + shader_module, + dummy_tex, + bloom_threshold_pipeline, + bloom_blur_pipeline, + bloom_combine_pipeline, + blit_pipelines: HashMap::new(), + mesh3d_bgl, + mesh3d_pipelines, + shadow_pass_bgl, + mesh3d_shadow_pipeline, + shadow_comparison_sampler, + volumetrics_bgl, + volumetrics_pipeline, + quad3d_bgl, + quad3d_pipeline, + particle_bgl, + particle_pipeline_add, + particle_pipeline_over, + }) + } + + pub fn device(&self) -> &wgpu::Device { + &self.device + } + pub fn queue(&self) -> &wgpu::Queue { + &self.queue + } + + pub(super) fn create_white_1x1_texture( + device: &wgpu::Device, + queue: &wgpu::Queue, + ) -> wgpu::Texture { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("dummy_white"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + tex.as_image_copy(), + &[255u8, 255, 255, 255], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + tex + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn create_pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + module: &wgpu::ShaderModule, + vs_entry: &str, + fs_entry: &str, + label: &str, + blend: Option, + format: wgpu::TextureFormat, + ) -> wgpu::RenderPipeline { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(layout), + vertex: wgpu::VertexState { + module, + entry_point: Some(vs_entry), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some(fs_entry), + targets: &[Some(wgpu::ColorTargetState { + format, + blend, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: Default::default(), + multiview: None, + cache: None, + }) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn create_pipeline_split_modules( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + vs_module: &wgpu::ShaderModule, + vs_entry: &str, + fs_module: &wgpu::ShaderModule, + fs_entry: &str, + label: &str, + blend: Option, + vertex_layouts: &[wgpu::VertexBufferLayout<'_>], + ) -> wgpu::RenderPipeline { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(layout), + vertex: wgpu::VertexState { + module: vs_module, + entry_point: Some(vs_entry), + buffers: vertex_layouts, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: fs_module, + entry_point: Some(fs_entry), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: Default::default(), + multiview: None, + cache: None, + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn add_dynamic_pipeline( + &mut self, + key: String, + wgsl: &str, + vert_wgsl: Option<&str>, + uniform_keys: Vec, + attributes: &[(String, String)], + blend: Option, + ) -> Result<()> { + use std::panic::AssertUnwindSafe; + let wgsl_owned: std::borrow::Cow<'static, str> = wgsl.to_string().into(); + let label = key.clone(); + let device = &self.device; + let fs_module = std::panic::catch_unwind(AssertUnwindSafe(|| { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(label.as_str()), + source: wgpu::ShaderSource::Wgsl(wgsl_owned), + }) + })) + .map_err(|e| { + let msg = e + .downcast_ref::() + .cloned() + .or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| "(unknown)".into()); + anyhow!("shader module panicked for '{key}': {msg}") + })?; + + // One vertex buffer per attribute; formats follow the GLSL types. + let attr_descs: Vec<[wgpu::VertexAttribute; 1]> = attributes + .iter() + .enumerate() + .map(|(i, (ty, _))| { + [wgpu::VertexAttribute { + format: vertex_format_for(ty), + offset: 0, + shader_location: i as u32, + }] + }) + .collect(); + let vertex_layouts: Vec = attr_descs + .iter() + .map(|attrs| wgpu::VertexBufferLayout { + array_stride: attrs[0].format.size(), + step_mode: wgpu::VertexStepMode::Vertex, + attributes: attrs, + }) + .collect(); + + let device = &self.device; + let layout = &self.effect_pipeline_layout; + let base = &self.shader_module; + let label2 = key.clone(); + let vert_src_owned: Option = vert_wgsl.map(|s| s.to_string()); + let pipeline = std::panic::catch_unwind(AssertUnwindSafe(|| { + if let Some(ref vert_src) = vert_src_owned { + let vert_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(&format!("{label2}-vs")), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(vert_src.as_str())), + }); + Self::create_pipeline_split_modules( + device, + layout, + &vert_module, + "main", + &fs_module, + "main", + &label2, + blend, + &vertex_layouts, + ) + } else { + Self::create_pipeline_split_modules( + device, + layout, + base, + "vs_we_effect", + &fs_module, + "main", + &label2, + blend, + &[], + ) + } + })) + .map_err(|e| { + let msg = e + .downcast_ref::() + .cloned() + .or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| "(unknown)".into()); + anyhow!("pipeline panicked for '{key}': {msg}") + })?; + + self.dynamic_pipelines.insert(key.clone(), pipeline); + self.dynamic_uniform_keys.insert(key.clone(), uniform_keys); + self.dynamic_vertex_attrs.insert(key, attributes.to_vec()); + Ok(()) + } + + /// Shared quad buffer for one vertex attribute. `kind`: 0 = NDC position, + /// 1 = UV (v=0 at the vertex that lands on screen top after naga's Y + /// flip — same convention as the synthetic VS), 2 = zeros. + pub(super) fn attr_buffer(&mut self, kind: u8, dims: u8) -> &wgpu::Buffer { + let device = &self.device; + self.attr_buffers.entry((kind, dims)).or_insert_with(|| { + const CORNERS: [[f32; 2]; 6] = [ + [-1.0, -1.0], + [1.0, -1.0], + [-1.0, 1.0], + [1.0, -1.0], + [1.0, 1.0], + [-1.0, 1.0], + ]; + let mut data: Vec = Vec::with_capacity(6 * dims as usize); + for [x, y] in CORNERS { + let full: [f32; 4] = match kind { + 0 => [x, y, 0.0, 1.0], + 1 => { + let u = (x + 1.0) * 0.5; + let v = (y + 1.0) * 0.5; + [u, v, u, v] + } + _ => [0.0; 4], + }; + data.extend_from_slice(&full[..dims as usize]); + } + let bytes: Vec = data.iter().flat_map(|f| f.to_le_bytes()).collect(); + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("quad_attr"), + size: bytes.len() as u64, + usage: wgpu::BufferUsages::VERTEX, + mapped_at_creation: true, + }); + buf.slice(..).get_mapped_range_mut()[..].copy_from_slice(&bytes); + buf.unmap(); + buf + }) + } + + /// Resolve the quad buffers for a translated shader's attribute list. + pub(super) fn attr_buffers_for( + &mut self, + attributes: &[(String, String)], + ) -> Vec { + let specs: Vec<(u8, u8)> = attributes + .iter() + .map(|(ty, name)| { + let dims = match ty.as_str() { + "float" => 1, + "vec2" => 2, + "vec3" => 3, + _ => 4, + }; + let kind = if name.contains("Position") { + 0 + } else if name.contains("TexCoord") { + 1 + } else { + 2 + }; + (kind, dims) + }) + .collect(); + specs + .into_iter() + .map(|(kind, dims)| self.attr_buffer(kind, dims).clone()) + .collect() + } + + /// Get (or lazily create) the blit pipeline for a given output format. + /// Used for surface presentation and FBO up/down-sampling. + pub fn blit_pipeline(&mut self, format: wgpu::TextureFormat) -> &wgpu::RenderPipeline { + if !self.blit_pipelines.contains_key(&format) { + let p = Self::create_pipeline( + &self.device, + &self.composite_pipeline_layout, + &self.shader_module, + "vs_fullscreen", + "fs_blit", + "blit", + None, + format, + ); + self.blit_pipelines.insert(format, p); + } + &self.blit_pipelines[&format] + } + + pub fn upload_texture(&self, img: &RgbaImage) -> wgpu::Texture { + let (fw, fh) = + crate::engine::gpu::fbo::fit_texture_limit(&self.device, img.width(), img.height()); + let downscaled; + let img = if (fw, fh) != img.dimensions() { + tracing::warn!( + "layer texture {}x{} exceeds the GPU limit — downscaling to {fw}x{fh}", + img.width(), + img.height() + ); + downscaled = + image::imageops::resize(img, fw, fh, image::imageops::FilterType::Triangle); + &downscaled + } else { + img + }; + let (w, h) = img.dimensions(); + let tex = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("layer_tex"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + img.as_raw(), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(w * 4), + rows_per_image: Some(h), + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + tex + } + + /// Uploads sprite-sheet frames as one 2D texture array (layer per frame) + /// for the GPU particle pipeline. Frames are padded to the largest + /// extent; TEXS sheets are uniform in practice, so padding is a + /// theoretical safety net, not an expected path. + pub fn upload_texture_array(&self, frames: &[RgbaImage]) -> (wgpu::Texture, u32) { + let n = frames.len().max(1) as u32; + let w = frames.iter().map(|f| f.width()).max().unwrap_or(1).max(1); + let h = frames.iter().map(|f| f.height()).max().unwrap_or(1).max(1); + let tex = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("particle_sprite_array"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: n, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + for (i, frame) in frames.iter().enumerate() { + let (fw, fh) = (frame.width(), frame.height()); + let padded = if fw == w && fh == h { + frame.clone() + } else { + let mut img = RgbaImage::new(w, h); + image::imageops::overlay(&mut img, frame, 0, 0); + img + }; + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: i as u32, + }, + aspect: wgpu::TextureAspect::All, + }, + padded.as_raw(), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * w), + rows_per_image: Some(h), + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + } + (tex, frames.len().max(1) as u32) + } + + pub fn create_render_target(&self, w: u32, h: u32) -> wgpu::Texture { + self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("render_target"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }) + } + + pub(super) fn find_effect_pipeline(&self, key: &str) -> Option<&wgpu::RenderPipeline> { + self.effect_pipelines + .iter() + .find(|(n, _)| *n == key) + .map(|(_, p)| p) + .or_else(|| self.dynamic_pipelines.get(key)) + } + + /// Pipeline key: 0 = normal alpha blend, 1 = Photoshop-style dest-read + /// blend (any nonzero WE colorBlendMode; the mode itself travels in + /// CompositeParams.mode). + pub(super) fn composite_pipeline(&self, blend_mode: u32) -> &wgpu::RenderPipeline { + let key = if blend_mode == 0 { 0 } else { 1 }; + self.composite_pipelines + .iter() + .find(|(m, _)| *m == key) + .or_else(|| self.composite_pipelines.first()) + .map(|(_, p)| p) + .unwrap() + } + + /// Select the sampler matching a `.tex`'s `NoInterpolation`/`ClampUVs` + /// flags (nearest-vs-linear filtering, clamp-vs-repeat addressing). + pub(super) fn sampler_for(&self, no_interpolation: bool, clamp_uvs: bool) -> &wgpu::Sampler { + let idx = ((no_interpolation as usize) << 1) | (clamp_uvs as usize); + &self.samplers[idx] + } + + /// Create the group(0) bind group: source texture, sampler, a uniform + /// buffer, and up to six extra texture slots (dummy white when absent). + pub(super) fn make_base_bind_group( + &self, + src_view: &wgpu::TextureView, + sampler: &wgpu::Sampler, + uniform_buf: &wgpu::Buffer, + extra: &[Option], + ) -> wgpu::BindGroup { + let dummy_view = self.dummy_tex.create_view(&Default::default()); + let slot = |i: usize| -> &wgpu::TextureView { + extra.get(i).and_then(|v| v.as_ref()).unwrap_or(&dummy_view) + }; + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &self.base_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(src_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: uniform_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(slot(0)), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(slot(1)), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(slot(2)), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(slot(3)), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(slot(4)), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(slot(5)), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(slot(6)), + }, + ], + }) + } + + pub(super) fn make_uniform_buffer(&self, data: &[u8], min_size: u64) -> wgpu::Buffer { + let size = (data.len() as u64).max(min_size); + let buf = self.device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut padded = vec![0u8; size as usize]; + padded[..data.len()].copy_from_slice(data); + self.queue.write_buffer(&buf, 0, &padded); + buf + } + + pub(super) fn make_effect_bind_group(&self, param_buf: &wgpu::Buffer) -> wgpu::BindGroup { + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &self.effect_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: param_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self.audio_buf.as_entire_binding(), + }, + ], + }) + } + + /// Record one pass drawing `vertex_count` vertices (3 = fullscreen + /// triangle, 6 = object quad / real-VS quad with vertex buffers). + #[allow(clippy::too_many_arguments)] + pub(super) fn run_pass( + &self, + encoder: &mut wgpu::CommandEncoder, + pipeline: &wgpu::RenderPipeline, + base_bg: &wgpu::BindGroup, + effect_bg: Option<&wgpu::BindGroup>, + dst_view: &wgpu::TextureView, + load: wgpu::LoadOp, + label: &str, + vertex_count: u32, + vertex_buffers: &[wgpu::Buffer], + ) { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(label), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: dst_view, + resolve_target: None, + ops: wgpu::Operations { + load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + ..Default::default() + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, base_bg, &[]); + if let Some(bg) = effect_bg { + pass.set_bind_group(1, bg, &[]); + } + for (i, buf) in vertex_buffers.iter().enumerate() { + pass.set_vertex_buffer(i as u32, buf.slice(..)); + } + pass.draw(0..vertex_count, 0..1); + } + + pub fn readback(&self, target: &wgpu::Texture, w: u32, h: u32) -> Result { + // wgpu requires bytes_per_row to be a multiple of COPY_BYTES_PER_ROW_ALIGNMENT (256). + // Widths not divisible by 64 (e.g. 1080, 1366) would panic without this padding. + let unpadded_bpr = w * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded_bpr = (unpadded_bpr + align - 1) / align * align; + let staging_size = (padded_bpr * h) as u64; + + let staging = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: staging_size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let mut encoder = self.device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &staging, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bpr), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + self.queue.submit(std::iter::once(encoder.finish())); + + let slice = staging.slice(..); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = self.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); + rx.recv() + .map_err(|_| anyhow!("readback channel disconnected"))? + .map_err(|e| anyhow!("buffer map failed: {e:?}"))?; + + let data = slice.get_mapped_range(); + // Strip row padding to produce a tightly-packed RGBA image. + let pixels: Vec = data + .chunks(padded_bpr as usize) + .flat_map(|row| &row[..unpadded_bpr as usize]) + .copied() + .collect(); + drop(data); + staging.unmap(); + + RgbaImage::from_raw(w, h, pixels).context("creating RgbaImage from GPU readback") + } +} + +pub(super) fn vertex_format_for(glsl_type: &str) -> wgpu::VertexFormat { + match glsl_type { + "float" => wgpu::VertexFormat::Float32, + "vec2" => wgpu::VertexFormat::Float32x2, + "vec3" => wgpu::VertexFormat::Float32x3, + _ => wgpu::VertexFormat::Float32x4, + } +} + +/// Map a material pass's `blending` mode to a GPU blend state. Effect passes +/// always render a fullscreen-covering quad into a just-cleared (transparent) +/// target, so this only changes output for passes whose shader legitimately +/// writes alpha<1 — Normal/Disabled write it through unmodified (replace), +/// while Additive/Translucent premultiply color by that alpha, matching the +/// reference's (ONE,ZERO) / (SRC_ALPHA,ONE) / (SRC_ALPHA,1-SRC_ALPHA) blend +/// functions for a zero destination. +/// Material pass blending → wgpu blend state, matching CPass::render's GL +/// states exactly: `normal` is GL_ONE/GL_ZERO and `disabled` is +/// glDisable(GL_BLEND) — both plain replace (`None` here); only +/// `additive`/`translucent` actually blend. +pub(super) fn wgpu_blend_state(blending: &WEBlending) -> Option { + match blending { + WEBlending::Normal | WEBlending::Disabled => None, + WEBlending::Additive => Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }), + WEBlending::Translucent => Some(wgpu::BlendState::ALPHA_BLENDING), + } +} diff --git a/src/engine/gpu/renderer/tests.rs b/src/engine/gpu/renderer/tests.rs new file mode 100644 index 0000000..decd304 --- /dev/null +++ b/src/engine/gpu/renderer/tests.rs @@ -0,0 +1,530 @@ +use super::*; + +/// `gpu_shaders.wgsl` must parse and validate as WGSL on its own — the +/// only check in this crate that catches a shader syntax/type error +/// before it surfaces at runtime as an opaque wgpu pipeline-creation +/// failure the first time a scene that uses that pass actually loads. +/// Uses naga directly (no GPU adapter needed — pure frontend/validator). +#[test] +fn gpu_shaders_wgsl_parses_and_validates() { + let module = + naga::front::wgsl::parse_str(SHADER_SRC).expect("gpu_shaders.wgsl failed to parse"); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .expect("gpu_shaders.wgsl failed validation"); +} + +fn quad3d_test_camera() -> crate::engine::sim::camera3d::PerspectiveCamera { + let scene: crate::engine::model::scene::Scene = serde_json::from_str( + r#"{"camera": {"eye": "0 0 10", "center": "0 0 0", "up": "0 1 0"}, + "general": {"orthogonalprojection": null, "fov": 60.0}}"#, + ) + .unwrap(); + crate::engine::sim::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).unwrap() +} + +/// A camera-facing quad (no rotation, centered on the view axis) must +/// project to a symmetric rectangle: opposite corners mirror each other +/// around the NDC origin. This is the case `project_quad_ndc`'s AABB +/// collapse already gets right — `project_quad_corners` must agree with +/// it exactly here, only diverging once the quad is off-axis/rotated. +#[test] +fn project_quad_corners_symmetric_for_camera_facing_quad() { + let cam = quad3d_test_camera(); + let corners = project_quad_corners(&cam, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [4.0, 2.0]) + .expect("camera-facing quad in front of the eye must project"); + // Order: bottom-left, bottom-right, top-right, top-left. + assert!((corners[0][0] + corners[1][0]).abs() < 1e-5, "{corners:?}"); + assert!((corners[0][1] + corners[3][1]).abs() < 1e-5, "{corners:?}"); + assert!(corners[0][0] < corners[1][0], "left must be left of right"); + assert!(corners[0][1] < corners[2][1], "bottom must be below top"); +} + +/// The whole point of this function: a quad rotated hard off-axis +/// projects to a genuine trapezoid, not a rectangle. Rotating around Y +/// swings one vertical edge toward the camera and the other away from +/// it (left edge: x=-2 → z=+1.73, closer to the eye at z=10; right +/// edge: x=+2 → z=-1.73, farther away) — the closer edge must span a +/// taller NDC range than the farther one. `project_quad_ndc`'s AABB +/// would hide this entirely (both edges would just extend its bounding +/// box, indistinguishable from a same-size rectangle). +#[test] +fn project_quad_corners_is_a_true_trapezoid_when_rotated() { + let cam = quad3d_test_camera(); + let corners = project_quad_corners( + &cam, + [0.0, 0.0, 0.0], + [0.0, 60f32.to_radians(), 0.0], + [4.0, 2.0], + ) + .expect("quad must still project in front of the eye"); + // Left edge: corner0 (bottom-left) to corner3 (top-left). + let left_height = (corners[3][1] - corners[0][1]).abs(); + // Right edge: corner1 (bottom-right) to corner2 (top-right). + let right_height = (corners[2][1] - corners[1][1]).abs(); + assert!( + left_height > right_height + 1e-4, + "expected the closer (left) edge to project taller than the \ + farther (right) edge: left={left_height} right={right_height}" + ); +} + +/// A corner behind the eye plane must cull the whole quad — same rule +/// `project_quad_ndc` uses, kept consistent rather than drawing a +/// partially-degenerate quad. +#[test] +fn project_quad_corners_none_when_behind_camera() { + let cam = quad3d_test_camera(); + // Eye is at z=10 looking toward the origin; z=20 is behind it. + assert!(project_quad_corners(&cam, [0.0, 0.0, 20.0], [0.0, 0.0, 0.0], [4.0, 2.0]).is_none()); +} + +/// Same rule as `hardcoded_effect_params_are_uniform_sized` below, for the +/// mesh3d lighting UBO: the Rust-side byte length must match WGSL's +/// std140 struct size (a 16-byte multiple) and `mesh3d_lighting_bytes` +/// must actually produce that many bytes, or wgpu rejects the mesh3d bind +/// group at draw time. +#[test] +fn mesh3d_lighting_bytes_are_uniform_sized_and_match_const() { + assert_eq!(MESH3D_LIGHTING_BYTES_LEN % 16, 0); + let scene: crate::engine::model::scene::Scene = serde_json::from_str( + r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}}"#, + ) + .unwrap(); + let cam = crate::engine::sim::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).unwrap(); + + // The common case: no shadow-casting lights at all. + let bytes = mesh3d_lighting_bytes(&scene, &cam, &[], &[], None); + assert_eq!(bytes.len(), MESH3D_LIGHTING_BYTES_LEN); + + // One shadow-casting light occupying slot 0 — same length either way, + // since the shadow arrays are fixed-size regardless of how many + // slots are actually in use. + let shadow_slots = vec![Some(0)]; + let shadow_slot_data = vec![( + crate::engine::sim::camera3d::identity(), + [0.0, 0.0, 1.0, 1.0], + )]; + let bytes = mesh3d_lighting_bytes(&scene, &cam, &shadow_slots, &shadow_slot_data, None); + assert_eq!(bytes.len(), MESH3D_LIGHTING_BYTES_LEN); +} + +/// `cookie: Some((light_index, view_proj))` must write `flags.y == +/// light_index + 1` and pack `view_proj` into the trailing +/// `cookie_view_proj` field (the last `MESH3D_MAX_SHADOW_LIGHTS` shadow +/// mat4s + the `MESH3D_MAX_SHADOW_LIGHTS` uv-rect vec4s precede it — +/// see `MESH3D_LIGHTING_BYTES_LEN`'s own layout doc). +#[test] +fn mesh3d_lighting_bytes_encodes_cookie_flag_and_matrix() { + let scene: crate::engine::model::scene::Scene = serde_json::from_str( + r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}}"#, + ) + .unwrap(); + let cam = crate::engine::sim::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).unwrap(); + + let mut vp = crate::engine::sim::camera3d::identity(); + vp[3][0] = 42.0; // an arbitrary, recognizable value to round-trip + let bytes = mesh3d_lighting_bytes(&scene, &cam, &[], &[], Some((2, vp))); + + let flags_y = f32::from_le_bytes(bytes[4..8].try_into().unwrap()); + assert_eq!(flags_y, 3.0, "flags.y must be light_index + 1"); + + let cookie_mat_offset = MESH3D_LIGHTING_BYTES_LEN - 64; + let read_f32 = |i: usize| { + f32::from_le_bytes( + bytes[cookie_mat_offset + i * 4..cookie_mat_offset + i * 4 + 4] + .try_into() + .unwrap(), + ) + }; + // Column 3, row 0 (the value we perturbed) is float index 12. + assert_eq!( + read_f32(12), + 42.0, + "cookie_view_proj must round-trip the matrix" + ); +} + +/// A spot light must (a) count as renderable (`flags.x == 1.0` — the +/// bug fixed alongside GPU spot support: `Scene::lights()` producing a +/// `Light::Spot` must not silently fall back to the unlit branch) and +/// (b) write a non-zero direction into its `light_spot` slot, so +/// `mesh3d_spot_factor` in the shader doesn't read it as "not a spot". +#[test] +fn mesh3d_lighting_bytes_encodes_spot_direction() { + let cam_scene: crate::engine::model::scene::Scene = serde_json::from_str( + r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}}"#, + ) + .unwrap(); + let cam = crate::engine::sim::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); + + let scene: crate::engine::model::scene::Scene = serde_json::from_str( + r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, + "general": {"orthogonalprojection": null}, + "objects": [ + {"id": 1, "light": true, "origin": "0 0 0", "radius": 100, + "intensity": 0.8, "color": "1 1 1", + "innercone": 20, "outercone": 45} + ]}"#, + ) + .unwrap(); + let bytes = mesh3d_lighting_bytes(&scene, &cam, &[], &[], None); + + // flags.x is the buffer's first f32. + let flags_x = f32::from_le_bytes(bytes[0..4].try_into().unwrap()); + assert_eq!( + flags_x, 1.0, + "a spot-only scene must still engage the lit branch" + ); + + // light_spot[0] starts after 5 leading vec4s + 2 MESH3D_MAX_LIGHTS + // vec4 arrays (positions, colors). + let spot0_offset = 16 * 5 + 16 * MESH3D_MAX_LIGHTS * 2; + let read_f32 = |i: usize| { + f32::from_le_bytes( + bytes[spot0_offset + i * 4..spot0_offset + i * 4 + 4] + .try_into() + .unwrap(), + ) + }; + let dir = [read_f32(0), read_f32(1), read_f32(2)]; + let len2 = dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]; + assert!(len2 > 0.25, "spot direction must not read as zero: {dir:?}"); + assert!( + read_f32(3) >= 1.0, + "exponent must be clamped to at least 1.0" + ); +} + +/// A directional light must flag itself via `light_pos[0].w == -1.0` +/// (the sentinel `fs_mesh3d`'s light loop branches on) with a unit-length +/// view-space direction in xyz, and still engage the lit branch. +#[test] +fn mesh3d_lighting_bytes_encodes_directional_sentinel_and_direction() { + let cam_scene: crate::engine::model::scene::Scene = serde_json::from_str( + r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}}"#, + ) + .unwrap(); + let cam = crate::engine::sim::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); + + let scene: crate::engine::model::scene::Scene = serde_json::from_str( + r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, + "general": {"orthogonalprojection": null}, + "objects": [ + {"id": 1, "light": "ldirectional", "angles": "0 1.5707963 0", + "intensity": 0.6, "color": "1 1 1"} + ]}"#, + ) + .unwrap(); + let bytes = mesh3d_lighting_bytes(&scene, &cam, &[], &[], None); + + let flags_x = f32::from_le_bytes(bytes[0..4].try_into().unwrap()); + assert_eq!( + flags_x, 1.0, + "a directional-only scene must still engage the lit branch" + ); + + // light_pos[0] starts right after the 5 leading vec4s. + let pos0_offset = 16 * 5; + let read_f32 = |i: usize| { + f32::from_le_bytes( + bytes[pos0_offset + i * 4..pos0_offset + i * 4 + 4] + .try_into() + .unwrap(), + ) + }; + let dir = [read_f32(0), read_f32(1), read_f32(2)]; + let len2 = dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]; + assert!( + (len2 - 1.0).abs() < 1e-4, + "direction must be unit-length: {dir:?}" + ); + assert_eq!(read_f32(3), -1.0, "w must carry the directional sentinel"); +} + +/// A tube light must flag itself via `light_pos[0].w == -3.0` (the +/// sentinel `fs_mesh3d`'s light loop branches on), with endpoint A in +/// `light_pos[0].xyz` and endpoint B in `light_spot[0].xyz` — not a +/// direction, so its magnitude should reflect the real endpoint spacing, +/// not be unit-length like a spot's. +#[test] +fn mesh3d_lighting_bytes_encodes_tube_sentinel_and_endpoints() { + let cam_scene: crate::engine::model::scene::Scene = serde_json::from_str( + r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}}"#, + ) + .unwrap(); + let cam = crate::engine::sim::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); + + let scene: crate::engine::model::scene::Scene = serde_json::from_str( + r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, + "general": {"orthogonalprojection": null}, + "objects": [ + {"id": 1, "light": "ltube", "origin": "0 0 0", + "controlpoint": "0 0 -4", "intensity": 0.9, "color": "1 1 1"} + ]}"#, + ) + .unwrap(); + let bytes = mesh3d_lighting_bytes(&scene, &cam, &[], &[], None); + + let flags_x = f32::from_le_bytes(bytes[0..4].try_into().unwrap()); + assert_eq!( + flags_x, 1.0, + "a tube-only scene must still engage the lit branch" + ); + + let pos0_offset = 16 * 5; + let spot0_offset = 16 * 5 + 16 * MESH3D_MAX_LIGHTS * 2; + let read_f32 = |offset: usize, i: usize| { + f32::from_le_bytes( + bytes[offset + i * 4..offset + i * 4 + 4] + .try_into() + .unwrap(), + ) + }; + assert_eq!( + read_f32(pos0_offset, 3), + -3.0, + "w must carry the tube sentinel" + ); + let endpoint_b = [ + read_f32(spot0_offset, 0), + read_f32(spot0_offset, 1), + read_f32(spot0_offset, 2), + ]; + let len2 = endpoint_b[0] * endpoint_b[0] + + endpoint_b[1] * endpoint_b[1] + + endpoint_b[2] * endpoint_b[2]; + assert!( + len2 > 1.0, + "endpoint B should be a real view-space position, not a unit vector: {endpoint_b:?}" + ); +} + +/// WGSL rounds every uniform-address-space struct up to a 16-byte +/// multiple, so a param buffer that isn't one can never match its shader +/// struct — wgpu rejects the bind group at draw time ("Buffer is bound +/// with size N where the shader expects M") and the wallpaper dies. +#[test] +fn hardcoded_effect_params_are_uniform_sized() { + let vals = ShaderVals::new(); + for name in HARDCODED_EFFECTS { + let n = make_effect_params(name, 0.0, &vals).len(); + assert_eq!(n % 16, 0, "{name} params are {n} bytes, not a 16-multiple"); + } +} + +/// Hardcoded kernels must find a value the scene keyed by UI label +/// ("ui_editor_properties_pulse_amount") as well as by material key. +#[test] +fn hardcoded_params_resolve_label_keyed_values() { + let mut vals = ShaderVals::new(); + insert_value_components( + &mut vals, + "ui_editor_properties_pulse_amount", + &serde_json::json!(0.25), + ); + insert_value_components( + &mut vals, + "ui_editor_properties_pulse_bounds", + &serde_json::json!("0.2 0.8"), + ); + let bytes = make_effect_params("pulse", 0.0, &vals); + let f = |i: usize| f32::from_le_bytes(bytes[i * 4..i * 4 + 4].try_into().unwrap()); + assert_eq!(f(2), 0.25, "amount"); + assert_eq!(f(5), 0.2, "bounds low"); + assert_eq!(f(6), 0.8, "bounds high"); +} + +/// vec2 constants (audiobounds, texture scales, ...) must expose their +/// components like vec3 colors do, or the packer silently falls back to +/// the shader default and the artist's tuning is lost. +#[test] +fn vector_constants_expose_components_at_every_width() { + let mut map = ShaderVals::new(); + insert_value_components(&mut map, "audiobounds", &serde_json::json!("0.25 0.75")); + assert_eq!(map.get("audiobounds_x"), Some(&0.25)); + assert_eq!(map.get("audiobounds_y"), Some(&0.75)); + assert_eq!(map.get("audiobounds_r"), Some(&0.25)); + + insert_value_components(&mut map, "tintlow", &serde_json::json!("1 0 0")); + assert_eq!(map.get("tintlow_b"), Some(&0.0)); + // vec3s keep their implicit opaque alpha. + assert_eq!(map.get("tintlow_a"), Some(&1.0)); +} + +mod hit_tests { + use super::*; + + #[test] + fn hit_test_geometry() { + // A 200x100 px quad centred at (100, 50) px in a 1000x500 output. + let rect = [0.2, 0.2, 0.2, 0.2]; + let q = quad_corners_px(rect, 0.0, 1000.0, 500.0); + assert!(point_in_convex_quad([100.0, 50.0], &q)); + assert!(point_in_convex_quad([199.0, 74.0], &q)); + assert!(!point_in_convex_quad([201.0, 50.0], &q)); + // Rotated 90°: the long axis is now vertical. + let q = quad_corners_px(rect, std::f32::consts::FRAC_PI_2, 1000.0, 500.0); + assert!(point_in_convex_quad([100.0, 140.0], &q)); + assert!(!point_in_convex_quad([190.0, 50.0], &q)); + } + /// Debug aid: run a scene's scripts headlessly (no GPU) for a few + /// frames and print every script log line. `WP_SCRIPT_SCENE=`. + #[test] + #[ignore] + fn run_scene_scripts() { + let Ok(target) = std::env::var("WP_SCRIPT_SCENE") else { + return; + }; + let dir = crate::workshop::find_item_dir(&target, None) + .unwrap_or_else(|| std::path::PathBuf::from(&target)); + let resolved = crate::engine::model::resolve::ResolvedScene::from_directory(&dir).unwrap(); + let raw_scene: serde_json::Value = crate::formats::assets::AssetStore::from_directory(&dir) + .ok() + .and_then(|a| a.scene_json().ok()) + .and_then(|t| serde_json::from_str(&t).ok()) + .unwrap_or_default(); + let mut host = build_script_host( + &dir, + &resolved.scene, + &resolved.transform_graph, + [resolved.width, resolved.height], + &raw_scene, + ); + for f in 0..30 { + host.frame(f as f32 * 0.5, 0.5, 0.5); + let c = host.collect(); + if f == 0 { + eprintln!( + "frame 0: {} objects changed, {} commands", + c.objects.len(), + c.commands.len() + ); + } + } + } + + /// A real intro sequence (3453730450): layers keyed to fade in over the + /// first ~13 s start hidden and end fully visible. + #[test] + #[ignore] + fn keyframed_alpha_fades_in() { + let Some(dir) = crate::workshop::find_item_dir("3453730450", None) else { + eprintln!("workshop item 3453730450 not installed; skipping"); + return; + }; + let mut scene = GpuSceneInstance::open(&dir).expect("scene loads"); + let animated: Vec = scene + .timelines + .iter() + .filter(|t| t.key == "alpha") + .map(|t| t.object) + .collect(); + assert!(!animated.is_empty()); + let alpha_of = |s: &GpuSceneInstance, o: usize| { + s.layers + .iter() + .find(|l| l.order_index == o) + .map(|l| l.alpha) + }; + scene.tick_timelines(0.1); + let early: Vec = animated + .iter() + .filter_map(|o| alpha_of(&scene, *o)) + .collect(); + assert!( + early.iter().all(|a| *a < 0.05), + "hidden at start: {early:?}" + ); + for _ in 0..140 { + scene.tick_timelines(0.1); + } + let late: Vec = animated + .iter() + .filter_map(|o| alpha_of(&scene, *o)) + .collect(); + assert!( + late.iter().filter(|a| **a > 0.3).count() >= late.len() - 1, + "faded in: {late:?}" + ); + } + + #[test] + fn changed_leaves_stops_at_user_bindings() { + let a = serde_json::json!({"visible": {"user": "grid", "value": false}, + "effects": [{"passes": [{"constantshadervalues": {"speed": 1}}]}], "name": "x"}); + let b = serde_json::json!({"visible": {"user": "grid", "value": true}, + "effects": [{"passes": [{"constantshadervalues": {"speed": 2}}]}], "name": "x"}); + let mut out = Vec::new(); + changed_leaves(&a, &b, &mut Vec::new(), &mut out); + let paths: Vec = out.iter().map(|(p, _)| p.join(".")).collect(); + assert_eq!( + paths, + ["effects.0.passes.0.constantshadervalues.speed", "visible"] + ); + assert_eq!(out[1].1["value"], true); + } + + /// A user setting bound to layer visibility (2156479578: `grid` shows + /// the Grid layer) applies live, without rebuilding the scene. + #[test] + #[ignore] + fn bool_setting_toggles_a_layer_live() { + let Some(dir) = crate::workshop::find_item_dir("2156479578", None) else { + eprintln!("workshop item 2156479578 not installed; skipping"); + return; + }; + let mut scene = GpuSceneInstance::open(&dir).expect("scene loads"); + let before = scene.object_visible[4]; + let flipped = if before { "0" } else { "1" }; + assert!( + { + let mut props = scene.live_props.props.clone(); + props.apply_overrides(HashMap::from([("grid".to_string(), flipped.to_string())])); + scene.apply_user_properties(props, &["grid".to_string()]) + }, + "applied live" + ); + assert_eq!(scene.object_visible[4], !before); + scene.render(); + } + + /// End to end against a real Workshop scene with clickable layers + /// (2348914357: clicking the radio/music buttons toggles them). Needs + /// the item installed and a GPU, hence ignored by default. + #[test] + #[ignore] + fn clicking_a_scripted_button_toggles_it() { + let Some(dir) = crate::workshop::find_item_dir("2348914357", None) else { + eprintln!("workshop item 2348914357 not installed; skipping"); + return; + }; + let mut scene = GpuSceneInstance::open(&dir).expect("scene loads"); + assert!(!scene.cursor_targets.is_empty(), "scene has click targets"); + scene.render(); + let target = scene + .layers + .iter() + .find(|l| scene.cursor_targets.contains(&l.order_index)) + .expect("a click target has a layer"); + let (idx, before) = (target.order_index, scene.object_visible[target.order_index]); + let norm = [(target.rect[0] + 1.0) * 0.5, (1.0 - target.rect[1]) * 0.5]; + assert_eq!( + scene.hit_test(norm), + Some(idx), + "hit test finds the target at its centre" + ); + scene.set_mouse(norm); + scene.render(); + scene.set_mouse_button(true); + scene.set_mouse_button(false); + scene.render(); + assert_eq!( + scene.object_visible[idx], !before, + "the click toggled the layer" + ); + } +} diff --git a/src/engine/shaders/compiler.rs b/src/engine/gpu/shaders/compiler.rs similarity index 100% rename from src/engine/shaders/compiler.rs rename to src/engine/gpu/shaders/compiler.rs diff --git a/src/engine/shaders/effect_def.rs b/src/engine/gpu/shaders/effect_def.rs similarity index 100% rename from src/engine/shaders/effect_def.rs rename to src/engine/gpu/shaders/effect_def.rs diff --git a/src/engine/shaders/loader.rs b/src/engine/gpu/shaders/loader.rs similarity index 100% rename from src/engine/shaders/loader.rs rename to src/engine/gpu/shaders/loader.rs diff --git a/src/engine/shaders/mod.rs b/src/engine/gpu/shaders/mod.rs similarity index 100% rename from src/engine/shaders/mod.rs rename to src/engine/gpu/shaders/mod.rs diff --git a/src/engine/shaders/particle_def.rs b/src/engine/gpu/shaders/particle_def.rs similarity index 100% rename from src/engine/shaders/particle_def.rs rename to src/engine/gpu/shaders/particle_def.rs diff --git a/src/engine/shaders/resolver.rs b/src/engine/gpu/shaders/resolver.rs similarity index 98% rename from src/engine/shaders/resolver.rs rename to src/engine/gpu/shaders/resolver.rs index 1e89104..b2fd241 100644 --- a/src/engine/shaders/resolver.rs +++ b/src/engine/gpu/shaders/resolver.rs @@ -10,7 +10,7 @@ //! the same relative path under (3). use super::loader; -use crate::engine::pkg::Package; +use crate::formats::pkg::Package; use std::path::{Path, PathBuf}; pub struct AssetResolver { diff --git a/src/engine/gpu/shaders/transpiler/mod.rs b/src/engine/gpu/shaders/transpiler/mod.rs new file mode 100644 index 0000000..00280d3 --- /dev/null +++ b/src/engine/gpu/shaders/transpiler/mod.rs @@ -0,0 +1,393 @@ +//! Wallpaper Engine GLSL to WGSL: [`translate_full`] assembles a pass's +//! shader source, compiles it to SPIR-V with shaderc, and translates that to +//! WGSL with Naga, collecting the uniforms the pass must bind. +//! +//! - `preprocess`: building the GLSL from the shader model +//! - `rewrite`: fixes applied before compiling (unrolling, coercions) +//! - `repair`: fixes driven by compile errors, and the SPIR-V/WGSL steps + +use crate::engine::model::ShaderModel; +use anyhow::{Context, Result}; +use std::collections::HashMap; + +mod rewrite; +pub use rewrite::*; + +mod repair; +use repair::*; + +mod preprocess; +pub use preprocess::*; + +pub(crate) const WE_COMMON_H: &str = "\ +#define M_PI 3.14159265359\n\ +#define M_PI_HALF 1.57079632679\n\ +#define M_PI_2 6.28318530718\n\ +#define SQRT_2 1.41421356237\n\ +#define SQRT_3 1.73205080756\n\ +float greyscale(vec3 c){return dot(c,vec3(0.299,0.587,0.114));}\n\ +vec3 hsv2rgb(vec3 c){vec4 K=vec4(1.0,2.0/3.0,1.0/3.0,3.0);vec3 p=abs(fract(c.xxx+K.xyz)*6.0-K.www);return c.z*mix(K.xxx,clamp(p-K.xxx,0.0,1.0),c.y);}\n\ +vec3 rgb2hsv(vec3 RGB){vec4 P=(RGB.g, + pub glsl_type: String, + pub default: [f32; 4], +} + +#[derive(Debug)] +pub struct TranslatedShader { + pub wgsl: String, + pub vert_wgsl: Option, + pub uniform_keys: Vec, + pub texture_count: usize, + /// Vertex attributes (glsl type, name) in declaration order when the real + /// WE vertex shader was translated; empty for the synthetic-VS path. + pub attributes: Vec<(String, String)>, + /// True when the shader reads `g_AudioSpectrum*` — the renderer only + /// starts audio capture for scenes that actually react to it. + pub uses_audio: bool, +} + +pub fn translate(model: &ShaderModel) -> Result { + translate_full(model, None) +} + +/// Translate the fragment shader and, when `vert_glsl` is given, the real WE +/// vertex shader. Both stages share one UBO layout (union of their scalar +/// uniforms, fragment's first) at group(1) binding(0), and varying locations +/// are matched by name so the naga-generated interfaces line up. +#[tracing::instrument(target = "shader", level = "trace", skip_all, fields(shader = %model.name, real_vs = vert_glsl.is_some()))] +pub fn translate_full(model: &ShaderModel, vert_glsl: Option<&str>) -> Result { + tracing::trace!(target: "shader", "translating GLSL → WGSL"); + + // Unroll literal-indexed array varyings (`varying vec2 v[7]` → `v_0`, + // `v_1`, ...) before anything else touches the source — naga's SPIR-V + // reader rejects array-typed entry-point I/O outright, so this has to + // happen up front. `unroll_array_varyings` was already fully + // implemented and tested (see the tests below) but was never actually + // wired into this function — real content that uses this exact pattern + // (godrays_gaussian.frag's `v_TexCoord[13|7|3]` per KERNEL combo, its + // own motivating comment) failed to transpile as a result. Rebinding + // `model` to an owned, patched clone means every function below that + // takes `&ShaderModel` sees the unrolled source with no signature + // changes needed. + let mut model = model.clone(); + model.frag_glsl = unroll_array_varyings(&model.frag_glsl); + let vert_unrolled = vert_glsl.map(unroll_array_varyings); + let vert_glsl = vert_unrolled.as_deref(); + let model = &model; + + // Shaders that still have an array varying at this point use a + // variable index somewhere in the body (a `for` loop reading `tex[i]`) + // — `unroll_array_varyings` deliberately leaves those alone rather than + // unrolling a declaration out from under a reference that's still + // there (see its own doc comment), so this honestly rejects them: naga's + // SPIR-V reader rejects array-typed entry-point I/O either way. + for line in model.frag_glsl.lines() { + let t = line.trim(); + if t.starts_with("varying ") || t.starts_with("attribute ") { + let tok: Vec<&str> = t.split_whitespace().collect(); + if tok.len() >= 3 { + let name_raw = t + .split("//") + .next() + .unwrap_or(t) + .split_whitespace() + .nth(2) + .unwrap_or("") + .trim_end_matches(';'); + if name_raw.contains('[') { + anyhow::bail!( + "array varying '{}' not supported by naga — skipping shader '{}'", + name_raw, + model.name + ); + } + } + } + } + + // Some workshop shaders declare a varying `vec2` in the vertex stage + // but `vec4` in the fragment (WE's own compiler links them anyway) — + // upgrade the vertex side so our strict pipeline's interface matches + // (the reference does the same, ShaderUnit.cpp + // applyLinkedVaryingCompatibility). + let vert_patched: Option = + vert_glsl.map(|v| harmonize_varying_widths(v, &model.frag_glsl)); + let vert_glsl = vert_patched.as_deref(); + + // Union scalar-uniform list: fragment's declarations first (their order + // defines the front of the UBO), then vertex-only ones appended. Both + // stages emit the SAME block so std140 offsets agree. + let frag_scalars = collect_scalar_uniforms(&model.frag_glsl); + let vert_only_scalars: Vec<(String, String)> = vert_glsl + .map(|v| { + collect_scalar_uniforms(v) + .into_iter() + .filter(|(_, name)| !frag_scalars.iter().any(|(_, f)| f == name)) + .collect() + }) + .unwrap_or_default(); + + let glsl = preprocess_frag(model, &vert_only_scalars); + let spv = glsl_to_spirv_with_repairs(&glsl, naga::ShaderStage::Fragment) + .context("GLSL→SPIR-V compilation failed")?; + let wgsl = spirv_to_wgsl(&spv).context("SPIR-V→WGSL conversion failed")?; + + // glsl_name → (material_key, typed default) from `// {"material": ...}` + // annotations, collected from BOTH stages (fragment wins on conflicts). + let mut annotated: std::collections::HashMap, [f32; 4])> = + std::collections::HashMap::new(); + if let Some(v) = vert_glsl { + for meta in crate::engine::gpu::shaders::uniform_meta::parse_uniform_metadata(v) { + annotated.insert( + meta.uniform_name.clone(), + ( + meta.material_key + .unwrap_or_else(|| meta.uniform_name.clone()), + meta.label.clone(), + meta.default_value + .as_ref() + .map(json_default_vec4) + .unwrap_or([0.0; 4]), + ), + ); + } + } + for u in &model.value_uniforms { + annotated.insert( + u.glsl_name.clone(), + (u.material_key.clone(), u.label.clone(), u.default.as_vec4()), + ); + } + + // The UBO contains all union scalars in emission order. We store the GLSL + // type so the renderer can compute proper std140 layout/padding. + let uniform_keys: Vec = frag_scalars + .iter() + .chain(vert_only_scalars.iter()) + .map(|(ty, name)| { + let (key, label, default) = annotated + .get(name) + .cloned() + .unwrap_or_else(|| (name.clone(), None, [0.0; 4])); + UniformEntry { + key, + label, + glsl_type: ty.clone(), + default, + } + }) + .collect(); + + let inputs = frag_inputs(&model.frag_glsl); + + // Prefer the real WE vertex shader: it computes scrolled/scaled UVs, wave + // phases, blur offsets etc. Fall back to a synthetic passthrough VS when + // translation fails (the caller falls back again if the pipeline fails). + let mut attributes: Vec<(String, String)> = Vec::new(); + let mut vert_wgsl: Option = None; + if let Some(v) = vert_glsl { + let union_scalars: Vec<(String, String)> = frag_scalars + .iter() + .chain(vert_only_scalars.iter()) + .cloned() + .collect(); + match preprocess_vert_matched(v, &union_scalars, &inputs, &model.effective_combos()) { + Ok((vsrc, attrs)) => { + match glsl_to_spirv_with_repairs(&vsrc, naga::ShaderStage::Vertex) + .and_then(|spv| spirv_to_wgsl(&spv)) + { + Ok(w) => { + vert_wgsl = Some(w); + attributes = attrs; + } + Err(e) => tracing::warn!( + target: "shader", + "'{}': real VS translation failed ({e}); using synthetic VS", + model.name + ), + } + } + Err(e) => tracing::warn!( + target: "shader", + "'{}': real VS preprocess failed ({e}); using synthetic VS", + model.name + ), + } + } + + // Synthetic fallback: outputs match the fragment shader's declared varyings. + // vs_we_effect always outputs (vec4 v_TexCoord, vec2 v_Scroll) which causes + // a pipeline validation panic when the frag expects different types. + if vert_wgsl.is_none() && needs_custom_vs(&inputs) { + let vert_glsl = synthetic_vs_glsl(&inputs); + vert_wgsl = glsl_to_spirv(&vert_glsl, naga::ShaderStage::Vertex) + .and_then(|spv| spirv_to_wgsl(&spv)) + .ok(); + } + + Ok(TranslatedShader { + wgsl, + vert_wgsl, + uniform_keys, + texture_count: model.texture_slots.len(), + attributes, + uses_audio: model.frag_glsl.contains("AudioSpectrum"), + }) +} + +/// Convert an annotation `default` JSON value into a vec4-shaped default. +fn json_default_vec4(val: &serde_json::Value) -> [f32; 4] { + let mut out = [0.0f32; 4]; + match val { + serde_json::Value::Number(n) => { + let f = n.as_f64().unwrap_or(0.0) as f32; + out = [f; 4]; + } + serde_json::Value::String(s) => { + for (i, p) in s.split_whitespace().take(4).enumerate() { + out[i] = p.parse().unwrap_or(0.0); + } + } + serde_json::Value::Array(a) => { + for (i, p) in a.iter().take(4).enumerate() { + out[i] = p.as_f64().unwrap_or(0.0) as f32; + } + } + _ => {} + } + out +} + +// ── GLSL → SPIR-V via shaderc ──────────────────────────────────────────────── +// +// shaderc wraps glslangValidator as a linkable library — no external binary +// required, works identically on Linux (Vulkan) and macOS (Metal via wgpu). + +// ── SPIR-V → WGSL via naga ─────────────────────────────────────────────────── + +// ── Preprocessors ───────────────────────────────────────────────────────────── +// +// Convert WE-style GLSL (OpenGL 2.x) to Vulkan-compatible GLSL 4.50 with +// separate texture2D + sampler bindings (naga's SPIR-V reader doesn't support +// combined sampler2D loads via OpLoad; it requires OpSampledImage from separate parts). +// +// Binding layout matches our GPU renderer's bind groups: +// Group 0, binding 0 : texture 0 (source / framebuffer) +// Group 0, binding 1 : shared sampler +// Group 0, binding 2 : (buffer — unused by dynamic shader, reserved) +// Group 0, binding 3 : texture 1 +// Group 0, binding 4 : texture 2 +// Group 0, binding 5 : texture 3 +// Group 1, binding 0 : effect UBO (scalar uniforms) + +// ── Synthetic vertex shader helpers ────────────────────────────────────────── + +#[cfg(test)] +mod tests; diff --git a/src/engine/gpu/shaders/transpiler/preprocess.rs b/src/engine/gpu/shaders/transpiler/preprocess.rs new file mode 100644 index 0000000..87f5627 --- /dev/null +++ b/src/engine/gpu/shaders/transpiler/preprocess.rs @@ -0,0 +1,479 @@ +//! Assembling the GLSL to compile from a shader model: the fragment and +//! matching vertex stages with Wallpaper Engine's headers, combo defines and +//! uniform declarations, or a synthetic vertex shader when the effect has +//! none that fits. + +use super::*; + +pub(super) fn preprocess_frag(model: &ShaderModel, extra_scalars: &[(String, String)]) -> String { + let src = &rename_shadowed_builtins(&model.frag_glsl); + + // First pass: collect declarations in order + let mut sampler_names: Vec = Vec::new(); // g_Texture0, g_Texture1, ... + let mut scalars: Vec<(String, String)> = Vec::new(); // (type, name) → UBO + let mut inputs: Vec<(String, String)> = Vec::new(); // varyings + let mut zero_arrays: Vec<(String, usize)> = Vec::new(); // float name[N] → const zeros + + for line in src.lines() { + let t = line.trim(); + let decl = t.split("//").next().unwrap_or(t).trim(); + let tok: Vec<&str> = decl.split_whitespace().collect(); + if tok.len() >= 3 && tok[0] == "uniform" { + let ty = tok[1]; + let name = tok[2].trim_end_matches(';'); + if ty.starts_with("sampler") { + sampler_names.push(name.to_string()); + } else if !ty.is_empty() && !name.contains('[') { + scalars.push((ty.to_string(), name.to_string())); + } else if ty == "float" { + // Float-array uniforms (`uniform float g_AudioSpectrum64Left[64];`) + // don't fit the 16-byte-per-scalar UBO model, so they're + // re-emitted below as zero-filled const arrays instead of + // silently vanishing (which left the identifiers undeclared + // and failed the whole compile). The only such uniforms in + // practice are WE's audio-spectrum bands, and zero is exactly + // what they hold when no audio is playing — the right output + // for an engine with no audio-capture pipeline (yet). + if let Some((base, rest)) = name.split_once('[') { + if let Ok(n) = rest.trim_end_matches(']').parse::() { + if n > 0 && !zero_arrays.iter().any(|(b, _)| b == base) { + zero_arrays.push((base.to_string(), n)); + } + } + } + } + } else if t.starts_with("varying ") || t.starts_with("attribute ") { + if tok.len() >= 3 { + let ty = tok[1]; + let name_raw = decl + .split_whitespace() + .nth(2) + .unwrap_or("") + .trim_end_matches(';'); + // Skip array varyings (e.g. `varying vec2 v_TexCoord[7]`) — naga cannot + // represent arrays as entry-point I/O; the shader would fail at SPIR-V parse. + if name_raw.contains('[') { + continue; + } + inputs.push((ty.to_string(), name_raw.to_string())); + } + } + } + + // Texture binding indices: g_Texture0 → 0, g_TextureN → N+2 (3, 4, 5, ...). + // + // The binding MUST come from the `N` in the uniform's own name, not from + // its position in the declaration scan: the scan reads the raw source + // and ignores `#if` blocks, so a conditionally-declared sampler that the + // compile strips (e.g. shine_combine.frag declares `g_Texture2` inside + // `#if COPYBG` *above* g_Texture0/g_Texture1) would otherwise shift every + // later sampler's binding by one — the shader then samples neighbouring + // slots (or the white 1×1 dummy) instead of its real inputs. WE itself + // keys texture units off the name's index (CPass binds unit N to + // g_TextureN), so the name is the authoritative slot. + let tex_binding = |i: usize, name: &str| -> u32 { + let n = name + .strip_prefix("g_Texture") + .and_then(|rest| rest.parse::().ok()) + .unwrap_or(i); + match n { + 0 => 0, + n => 2 + n as u32, + } // 0→0, 1→3, 2→4, 3→5 + }; + + let mut out = String::new(); + out.push_str("#version 450\n"); + out.push_str("#define frac(x) fract(x)\n"); + out.push_str("#define saturate(x) clamp((x), 0.0, 1.0)\n"); + out.push_str("#define CAST2(x) vec2(x)\n"); + out.push_str("#define CAST3(x) vec3(x)\n"); + out.push_str("#define CAST4(x) vec4(x)\n"); + // Use separate texture2D + sampler for all texture sample macros so naga sees + // OpSampledImage (from constructor) rather than OpLoad of a combined sampler2D. + out.push_str("#define texSample2D(s, uv) texture(sampler2D(s, _wp_sampler), (uv).xy)\n"); + out.push_str( + "#define texSample2DLod(s, uv, lod) textureLod(sampler2D(s, _wp_sampler), (uv).xy, lod)\n", + ); + out.push_str("#define textureSample2D(s, uv) texture(sampler2D(s, _wp_sampler), (uv).xy)\n"); + out.push_str("#define texture2D(s, uv) texture(sampler2D(s, _wp_sampler), (uv).xy)\n"); + out.push_str( + "#define texture2DLod(s, uv, lod) textureLod(sampler2D(s, _wp_sampler), (uv).xy, lod)\n", + ); + out.push_str("#define textureCube(s, uv) texture(samplerCube(s, _wp_sampler), (uv).xyz)\n"); + out.push_str("#define lerp(a, b, t) mix(a, b, t)\n"); + out.push_str("#define mul(a, b) ((b) * (a))\n"); + out.push_str(&combo_defines(&model.effective_combos())); + // HLSL type aliases used pervasively in WE shaders. + // These must come BEFORE any inlined shader body (common.h etc. are inlined by the loader). + // We do NOT emit our WE_COMMON_H / WE_COMMON_BLENDING_H function bodies here because + // the loader already inlines the real WE common.h content into the source, and emitting + // our copies would cause "redefinition" errors in shaderc. + out.push_str("#define float2 vec2\n"); + out.push_str("#define float3 vec3\n"); + out.push_str("#define float4 vec4\n"); + out.push_str("#define int2 ivec2\n"); + out.push_str("#define int3 ivec3\n"); + out.push_str("#define int4 ivec4\n"); + out.push_str("#define CAST3X3(x) mat3(x)\n"); + out.push_str("#define atan2(y,x) atan(y,x)\n"); + out.push_str("#define fmod(x,y) ((x)-(y)*trunc((x)/(y)))\n"); + out.push_str("#define ddx dFdx\n"); + out.push_str("#define ddy(x) dFdy(-(x))\n"); + out.push_str("#define log10(x) (log2(x)*0.301029995663981)\n"); + // Constants sometimes needed by shaders that don't include common.h + out.push_str("#ifndef M_PI\n#define M_PI 3.14159265359\n#endif\n"); + out.push_str("#ifndef M_PI_2\n#define M_PI_2 6.28318530718\n#endif\n"); + + // HLSL's pow() broadcasts scalar arguments to the other side's vector + // width; strict GLSL only defines pow(genType, genType). Real workshop + // shaders (e.g. color_grading.frag's `pow(vec3, float)`) rely on the + // lenient form, so every body call site is renamed to these overloads — + // the same-shape ones are passthroughs, so legal calls are unaffected. + out.push_str(concat!( + "float _wp_pow(float a, float b) { return pow(a, b); }\n", + "vec2 _wp_pow(vec2 a, vec2 b) { return pow(a, b); }\n", + "vec3 _wp_pow(vec3 a, vec3 b) { return pow(a, b); }\n", + "vec4 _wp_pow(vec4 a, vec4 b) { return pow(a, b); }\n", + "vec2 _wp_pow(vec2 a, float b) { return pow(a, vec2(b)); }\n", + "vec3 _wp_pow(vec3 a, float b) { return pow(a, vec3(b)); }\n", + "vec4 _wp_pow(vec4 a, float b) { return pow(a, vec4(b)); }\n", + "vec2 _wp_pow(float a, vec2 b) { return pow(vec2(a), b); }\n", + "vec3 _wp_pow(float a, vec3 b) { return pow(vec3(a), b); }\n", + "vec4 _wp_pow(float a, vec4 b) { return pow(vec4(a), b); }\n", + )); + + // Separate texture2D declarations with matching bindings + for (i, name) in sampler_names.iter().enumerate() { + let b = tex_binding(i, name); + out.push_str(&format!( + "layout(set=0, binding={b}) uniform texture2D {name};\n" + )); + } + // Shared sampler at binding 1 + if !sampler_names.is_empty() { + out.push_str("layout(set=0, binding=1) uniform sampler _wp_sampler;\n"); + } + // UBO for scalars at group 1 binding 0 (matches effect_bgl). The block is + // the union of fragment + vertex scalars (fragment first) so both stages + // agree on std140 offsets. + if !scalars.is_empty() || !extra_scalars.is_empty() { + out.push_str("layout(set=1, binding=0) uniform WEUniforms {\n"); + for (ty, name) in scalars.iter().chain(extra_scalars.iter()) { + out.push_str(&format!(" {ty} {name};\n")); + } + out.push_str("};\n"); + } + // Audio-spectrum arrays (`g_AudioSpectrum{16,32,64}{Left,Right}`) become a + // live read-only STORAGE buffer (std430) at set=1 binding=1 filled from FFT + // each frame. The full canonical set is always declared in a fixed order + // (so element offsets are deterministic regardless of which arrays this + // shader happens to reference); the renderer always binds the buffer, and + // shaders that read none of it simply ignore the binding. + let uses_audio = zero_arrays + .iter() + .any(|(name, _)| name.contains("AudioSpectrum")); + if uses_audio { + // std430 storage: `float[N]` packs at 4-byte stride (naga-valid), + // unlike a std140 uniform member's WGSL-illegal 16-byte array stride. + out.push_str("layout(set=1, binding=1, std430) readonly buffer WEAudio {\n"); + for (name, n) in [ + ("g_AudioSpectrum16Left", 16), + ("g_AudioSpectrum16Right", 16), + ("g_AudioSpectrum32Left", 32), + ("g_AudioSpectrum32Right", 32), + ("g_AudioSpectrum64Left", 64), + ("g_AudioSpectrum64Right", 64), + ] { + out.push_str(&format!(" float {name}[{n}];\n")); + } + out.push_str("};\n"); + } + // Any remaining (non-audio) float-array uniforms stay zero-filled consts. + for (name, n) in &zero_arrays { + if name.contains("AudioSpectrum") { + continue; + } + let zeros = vec!["0.0"; *n].join(", "); + out.push_str(&format!("const float {name}[{n}] = float[{n}]({zeros});\n")); + } + // Input varyings with explicit locations + for (i, (ty, name)) in inputs.iter().enumerate() { + out.push_str(&format!("layout(location={i}) in {ty} {name};\n")); + } + out.push_str("layout(location=0) out vec4 fragColor;\n"); + + // Seed known vector widths (varyings + uniforms) for the truncation-coercion + // pass below; it grows this map with local declarations as it walks the body. + let mut var_width: HashMap = HashMap::new(); + for (ty, name) in inputs.iter().chain(scalars.iter()) { + if let Some(w) = vec_width(ty) { + var_width.insert(name.clone(), w); + } + } + + // Emit shader body (skip declarations already emitted above) + for line in src.lines() { + let t = line.trim_start(); + if t.starts_with("// [COMBO]") || t.starts_with("#version") || t.starts_with("precision ") { + continue; + } + let decl = t.split("//").next().unwrap_or(t).trim(); + let first = decl.split_whitespace().next().unwrap_or(""); + if first == "varying" || first == "attribute" || first == "uniform" { + continue; + } + + // Skip #include directives — included content is inlined in the header. + if t.starts_with("#include ") { + continue; + } + + // A shader that spells a prelude macro differently (M_PI as + // 3.1415926535897932384626433832795, say) is a hard glslang error, not + // a warning. Let the shader's own definition win, like HLSL does. + if let Some(name) = redefined_macro(t) { + out.push_str(&format!("#undef {name}\n")); + } + + // Replace #require LightingV1 with a no-op stub (same as linux-wallpaperengine). + if t.starts_with("#require LightingV1") { + out.push_str( + "vec3 PerformLighting_V1(vec3 worldPos, vec3 albedo, vec3 normal, vec3 viewDir,\n", + ); + out.push_str( + " vec3 specularTint, vec3 baseReflectance, float roughness, float metallic)\n", + ); + out.push_str("{ return vec3(0.0); }\n"); + continue; + } + + let coerced = coerce_vector_widths(line, &mut var_width); + let renamed = rename_reserved_word(&coerced, "sample", "_wp_s"); + // Route pow through the scalar-broadcasting overload shims above. + let renamed = rename_reserved_word(&renamed, "pow", "_wp_pow"); + let l = renamed + .replace("gl_FragColor", "fragColor") + .replace("gl_FragData[0]", "fragColor"); + out.push_str(&l); + out.push('\n'); + } + out +} + +/// Preprocess a real WE vertex shader into Vulkan GLSL 4.50. +/// +/// - Emits the SAME `WEUniforms` block as the fragment stage (union scalar +/// list, set=1 binding=0) so std140 offsets agree across stages. +/// - Assigns each varying the location of the same-named fragment input; +/// fragment inputs the VS never writes are declared and zero-initialized at +/// the top of `main()` (wgpu requires every FS input to have a VS output). +/// - Returns the processed source plus the attribute list (type, name) in +/// declaration order for vertex-buffer layout construction. +pub(super) fn preprocess_vert_matched( + glsl: &str, + union_scalars: &[(String, String)], + frag_inputs: &[(String, String)], + combos: &HashMap, +) -> Result<(String, Vec<(String, String)>)> { + // Vertex shaders that sample textures need image bindings we don't + // provide in the vertex stage; let the synthetic fallback handle those. + if glsl.contains("texSample2D") || glsl.contains("texture2D") { + anyhow::bail!("vertex shader samples textures"); + } + + let mut out = String::new(); + out.push_str("#version 450\n"); + out.push_str("#define frac(x) fract(x)\n"); + out.push_str("#define saturate(x) clamp((x), 0.0, 1.0)\n"); + out.push_str("#define CAST2(x) vec2(x)\n"); + out.push_str("#define CAST3(x) vec3(x)\n"); + out.push_str("#define CAST4(x) vec4(x)\n"); + out.push_str("#define lerp(a, b, t) mix(a, b, t)\n"); + out.push_str("#define mul(a, b) ((b) * (a))\n"); + out.push_str(&combo_defines(combos)); + out.push_str("#define float2 vec2\n"); + out.push_str("#define float3 vec3\n"); + out.push_str("#define float4 vec4\n"); + out.push_str("#define int2 ivec2\n"); + out.push_str("#define int3 ivec3\n"); + out.push_str("#define int4 ivec4\n"); + out.push_str("#define CAST3X3(x) mat3(x)\n"); + out.push_str("#define atan2(y,x) atan(y,x)\n"); + out.push_str("#define fmod(x,y) ((x)-(y)*trunc((x)/(y)))\n"); + out.push_str("#define log10(x) (log2(x)*0.301029995663981)\n"); + out.push_str("#ifndef M_PI\n#define M_PI 3.14159265359\n#endif\n"); + out.push_str("#ifndef M_PI_2\n#define M_PI_2 6.28318530718\n#endif\n"); + + if !union_scalars.is_empty() { + out.push_str("layout(set=1, binding=0) uniform WEUniforms {\n"); + for (ty, name) in union_scalars { + out.push_str(&format!(" {ty} {name};\n")); + } + out.push_str("};\n"); + } + + // Fragment-input locations by name; varyings must line up with them. + let frag_loc: HashMap<&str, usize> = frag_inputs + .iter() + .enumerate() + .map(|(i, (_, name))| (name.as_str(), i)) + .collect(); + let mut next_free_loc = frag_inputs.len(); + + let mut attributes: Vec<(String, String)> = Vec::new(); + let mut declared_varyings: Vec = Vec::new(); + let mut body = String::new(); + + for line in glsl.lines() { + let t = line.trim_start(); + if t.starts_with("#version") + || t.starts_with("precision ") + || t.starts_with("// [COMBO]") + || t.starts_with("#include ") + { + continue; + } + // Shader-local redefinition of a prelude macro wins (see frag stage). + if let Some(name) = redefined_macro(t) { + body.push_str(&format!("#undef {name}\n")); + } + let decl = t.split("//").next().unwrap_or(t).trim(); + let tok: Vec<&str> = decl.split_whitespace().collect(); + if tok.len() >= 3 && tok[0] == "attribute" { + let ty = tok[1].to_string(); + let name = tok[2].trim_end_matches(';').to_string(); + out.push_str(&format!( + "layout(location={}) in {ty} {name};\n", + attributes.len() + )); + attributes.push((ty, name)); + continue; + } + if tok.len() >= 3 && tok[0] == "varying" { + let ty = tok[1].to_string(); + let name = tok[2].trim_end_matches(';').to_string(); + if name.contains('[') { + anyhow::bail!("array varying '{name}' not supported"); + } + let loc = frag_loc.get(name.as_str()).copied().unwrap_or_else(|| { + let l = next_free_loc; + next_free_loc += 1; + l + }); + out.push_str(&format!("layout(location={loc}) out {ty} {name};\n")); + declared_varyings.push(name); + continue; + } + if tok.first() == Some(&"uniform") { + continue; // already in the shared UBO + } + body.push_str(line); + body.push('\n'); + } + + // Fragment inputs the vertex shader never declares: emit + zero-init so + // the wgpu interface check passes. + let mut missing_inits = String::new(); + for (ty, name) in frag_inputs { + if !declared_varyings.iter().any(|v| v == name) { + out.push_str(&format!( + "layout(location={}) out {ty} {name};\n", + frag_loc[name.as_str()] + )); + missing_inits.push_str(&format!(" {name} = {ty}(0.0);\n")); + } + } + if !missing_inits.is_empty() { + // Insert right after the opening brace of main(). + let main_pos = body + .find("void main") + .context("vertex shader has no main()")?; + let brace = body[main_pos..] + .find('{') + .map(|i| main_pos + i + 1) + .context("vertex shader main() has no body")?; + body.insert_str(brace, &format!("\n{missing_inits}")); + } + + out.push_str(&body); + Ok((out, attributes)) +} + +/// Extract (type, name) pairs from fragment `varying` declarations (array varyings excluded). +pub(super) fn frag_inputs(frag_glsl: &str) -> Vec<(String, String)> { + let mut inputs = Vec::new(); + for line in frag_glsl.lines() { + let t = line.trim(); + if !t.starts_with("varying ") && !t.starts_with("attribute ") { + continue; + } + let tok: Vec<&str> = t.split_whitespace().collect(); + if tok.len() < 3 { + continue; + } + let name_raw = t + .split("//") + .next() + .unwrap_or(t) + .split_whitespace() + .nth(2) + .unwrap_or("") + .trim_end_matches(';'); + if name_raw.contains('[') { + continue; + } // array varyings already rejected upstream + inputs.push((tok[1].to_string(), name_raw.to_string())); + } + inputs +} + +/// Return true when the fragment shader's varyings differ from the vs_we_effect interface +/// (vec4 v_TexCoord @ loc 0, vec2 v_Scroll @ loc 1). A mismatch causes a wgpu panic. +pub(super) fn needs_custom_vs(inputs: &[(String, String)]) -> bool { + match inputs { + [] => false, + [(t0, _)] => t0 != "vec4", + [(t0, _), (t1, _)] => t0 != "vec4" || t1 != "vec2", + _ => true, // more than 2 varyings → vs_we_effect definitely can't match + } +} + +/// Build a Vulkan GLSL 4.50 vertex shader that outputs every declared varying as a +/// simple UV-derived value. No uniforms — this shader works with any bind group layout. +pub(super) fn synthetic_vs_glsl(inputs: &[(String, String)]) -> String { + let mut out = String::new(); + out.push_str("#version 450\n"); + for (i, (ty, name)) in inputs.iter().enumerate() { + out.push_str(&format!("layout(location={i}) out {ty} {name};\n")); + } + out.push_str("void main() {\n"); + out.push_str(" int vi = gl_VertexIndex;\n"); + out.push_str(" float x = float(vi & 1) * 4.0 - 1.0;\n"); + out.push_str(" float y = float(vi >> 1) * 4.0 - 1.0;\n"); + out.push_str(" gl_Position = vec4(x, y, 0.0, 1.0);\n"); + out.push_str(" float u = (x + 1.0) * 0.5;\n"); + // naga's SPIR-V frontend runs with adjust_coordinate_space=true, which + // negates gl_Position.y. UVs must be derived from the POST-flip screen + // position, so v maps y=-1 (screen top after flip) → 0. + out.push_str(" float v = (y + 1.0) * 0.5;\n"); + for (ty, name) in inputs { + let val = match ty.as_str() { + "float" => "u".to_string(), + "vec2" => "vec2(u, v)".to_string(), + "vec3" => "vec3(u, v, 0.0)".to_string(), + "vec4" => "vec4(u, v, u, v)".to_string(), + _ => format!("{ty}(0.0)"), + }; + out.push_str(&format!(" {name} = {val};\n")); + } + out.push_str("}\n"); + out +} + +pub fn combo_defines(combos: &HashMap) -> String { + combos + .iter() + .map(|(name, value)| format!("#define {} {}\n", name, value)) + .collect() +} diff --git a/src/engine/gpu/shaders/transpiler/repair.rs b/src/engine/gpu/shaders/transpiler/repair.rs new file mode 100644 index 0000000..cb6f2c5 --- /dev/null +++ b/src/engine/gpu/shaders/transpiler/repair.rs @@ -0,0 +1,763 @@ +//! Compiling to SPIR-V and translating to WGSL, with repairs driven by +//! shaderc's error messages: when a shader fails to compile, the failing +//! lines are patched (float modulo, implicit conversions, operand and call +//! argument widths, bool conditions) and it is compiled again. + +use super::*; + +/// `glsl_to_spirv` with a bounded repair loop for HLSL-style implicit +/// conversions that strict GLSL rejects but Wallpaper Engine's own (D3D) +/// compiler accepts silently — e.g. `float p = vec2Expr;` (truncates to .x), +/// `int i = -floatVar;`, or a scalar assigned to a vec3. shaderc's error +/// messages carry the line number and both types, which is exactly enough +/// to wrap the right-hand side in the coercion HLSL would have applied, +/// then recompile. Bails out (returning the latest error) once a round +/// fixes nothing or after a few rounds, so pathological shaders still fail +/// cleanly instead of looping. +pub(super) fn glsl_to_spirv_with_repairs(glsl: &str, stage: naga::ShaderStage) -> Result> { + // shaderc reports line numbers in the *generated* source, which nothing + // else ever prints — WP_DEBUG_DUMP_GLSL=1 makes those numbers usable. + let dump = |src: &str| { + if std::env::var("WP_DEBUG_DUMP_GLSL").is_err() { + return; + } + let path = format!( + "/tmp/glsl_fail_{:x}.{}", + src.len() as u64 ^ src.as_bytes().iter().map(|b| *b as u64).sum::(), + if stage == naga::ShaderStage::Vertex { + "vert" + } else { + "frag" + } + ); + let _ = std::fs::write(&path, src); + tracing::warn!("dumped failing GLSL to {path}"); + }; + let mut src: Option = None; + let mut last_err = match glsl_to_spirv(glsl, stage) { + Ok(spv) => return Ok(spv), + Err(e) => { + dump(glsl); + e + } + }; + for _ in 0..5 { + let current = src.as_deref().unwrap_or(glsl); + let err = format!("{last_err:#}"); + // Apply both repairs against the same error text: `float % int` becomes + // `mod()` and HLSL-implicit assignment conversions get their casts. + // Either may fix a different error the same round. + let mut patched = repair_float_modulo(current, &err); + for repair in [ + repair_assignment_conversions, + repair_bool_conditions, + repair_input_assignment, + repair_bool_operands, + repair_call_arg_widths, + repair_operand_widths, + ] { + let so_far = patched.as_deref().unwrap_or(current); + if let Some(p) = repair(so_far, &err) { + patched = Some(p); + } + } + let Some(patched) = patched else { + return Err(last_err); + }; + match glsl_to_spirv(&patched, stage) { + Ok(spv) => return Ok(spv), + Err(e) => last_err = e, + } + src = Some(patched); + } + Err(last_err) +} + +/// Best-effort classification of a shaderc type description ("temp highp +/// 2-component vector of float", "const float", "temp highp int", ...). +pub(super) fn parse_shaderc_type(desc: &str) -> Option<(&'static str, u8)> { + if let Some(pos) = desc.find("-component vector of float") { + let n: u8 = desc[..pos].split_whitespace().last()?.parse().ok()?; + return Some(("vec", n)); + } + if desc.ends_with("float") { + return Some(("float", 1)); + } + if desc.ends_with("uint") { + return Some(("uint", 1)); + } + if desc.ends_with("int") { + return Some(("int", 1)); + } + if desc.ends_with("bool") { + return Some(("bool", 1)); + } + None +} + +/// Rewrite every `a % b` on `line` to `mod(a, b)`. HLSL's `%` accepts float +/// operands (D3D `fmod` semantics); strict GLSL's `%` is integer-only, so WE +/// shaders that write `float % N` (e.g. simple_audio_bars' bar bucketing) are +/// rejected. `mod()` is GLSL's float remainder and int→float promotion makes +/// the second operand fit. Returns `None` when the line has no modulo. +pub(super) fn rewrite_modulo_on_line(line: &str) -> Option { + let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'.'; + let mut out = line.to_string(); + // Re-scan from the start after each rewrite (indices shift). + loop { + let bytes = out.as_bytes(); + // Find a modulo `%` that isn't the compound-assign `%=`. + let Some(pct) = + (0..bytes.len()).find(|&i| bytes[i] == b'%' && bytes.get(i + 1) != Some(&b'=')) + else { + break; + }; + + // Left operand: skip whitespace, then take a parenthesized group (plus + // any function name in front of it) or a bare identifier/number. + let mut le = pct; + while le > 0 && bytes[le - 1].is_ascii_whitespace() { + le -= 1; + } + let ls = if le > 0 && (bytes[le - 1] == b')' || bytes[le - 1] == b']') { + let (open, close) = if bytes[le - 1] == b')' { + (b'(', b')') + } else { + (b'[', b']') + }; + let mut depth = 0i32; + let mut k = le - 1; + loop { + if bytes[k] == close { + depth += 1; + } else if bytes[k] == open { + depth -= 1; + if depth == 0 { + break; + } + } + if k == 0 { + break; + } + k -= 1; + } + let mut s = k; + while s > 0 && is_ident(bytes[s - 1]) { + s -= 1; + } + s + } else { + let mut s = le; + while s > 0 && is_ident(bytes[s - 1]) { + s -= 1; + } + s + }; + + // Right operand: skip whitespace, then a parenthesized group or a + // bare identifier/number optionally followed by a call's parens. + let mut rs = pct + 1; + while rs < bytes.len() && bytes[rs].is_ascii_whitespace() { + rs += 1; + } + let re = if rs < bytes.len() && (bytes[rs] == b'(' || bytes[rs] == b'[') { + let close = if bytes[rs] == b'(' { b')' } else { b']' }; + let open = bytes[rs]; + let mut depth = 0i32; + let mut k = rs; + while k < bytes.len() { + if bytes[k] == open { + depth += 1; + } else if bytes[k] == close { + depth -= 1; + if depth == 0 { + k += 1; + break; + } + } + k += 1; + } + k + } else { + let mut k = rs; + while k < bytes.len() && is_ident(bytes[k]) { + k += 1; + } + if k < bytes.len() && bytes[k] == b'(' { + let mut depth = 0i32; + while k < bytes.len() { + if bytes[k] == b'(' { + depth += 1; + } else if bytes[k] == b')' { + depth -= 1; + if depth == 0 { + k += 1; + break; + } + } + k += 1; + } + } + k + }; + + // Bail if either operand came out empty (malformed `%`) — leave as-is. + if ls >= le || rs >= re { + break; + } + let left = out[ls..le].trim().to_string(); + let right = out[rs..re].trim().to_string(); + out = format!("{}mod({left}, {right}){}", &out[..ls], &out[re..]); + } + (out != line).then_some(out) +} + +/// Parse `shader.frag:N: error: '%' : wrong operand types` messages and rewrite +/// the flagged line's modulo(s) to `mod()`. Returns `None` when nothing matched. +pub(super) fn repair_float_modulo(glsl: &str, err_text: &str) -> Option { + let mut lines: Vec = glsl.lines().map(str::to_string).collect(); + let mut repaired = false; + for msg in err_text.lines() { + if !msg.contains("'%'") || !msg.contains("operand") { + continue; + } + let Some(rest) = msg + .split_once("shader.frag:") + .or_else(|| msg.split_once("shader.vert:")) + .map(|(_, r)| r) + else { + continue; + }; + let Some((line_str, _)) = rest.split_once(':') else { + continue; + }; + let Ok(line_no) = line_str.trim().parse::() else { + continue; + }; + if let Some(line) = lines.get_mut(line_no.saturating_sub(1)) { + if let Some(patched) = rewrite_modulo_on_line(line) { + *line = patched; + repaired = true; + } + } + } + repaired.then(|| lines.join("\n")) +} + +/// Parse `shader.frag:N: error: '=' : cannot convert from ' X' to ' Y'` +/// messages and wrap the offending line's assignment RHS in the conversion +/// HLSL would apply implicitly. Returns `None` when nothing was repaired. +pub(super) fn repair_assignment_conversions(glsl: &str, err_text: &str) -> Option { + let mut lines: Vec = glsl.lines().map(str::to_string).collect(); + let mut repaired = false; + + for msg in err_text.lines() { + let Some(rest) = msg + .split_once("shader.frag:") + .or_else(|| msg.split_once("shader.vert:")) + .map(|(_, r)| r) + else { + continue; + }; + let Some((line_str, detail)) = rest.split_once(':') else { + continue; + }; + let Ok(line_no) = line_str.trim().parse::() else { + continue; + }; + let Some(conv) = detail.split("cannot convert from '").nth(1) else { + continue; + }; + // detail shape: ` ' to ' '` + let mut parts = conv.split('\''); + let from_desc = parts.next().unwrap_or("").trim(); + let to_desc = parts.nth(1).unwrap_or("").trim(); + let (Some(from), Some(to)) = (parse_shaderc_type(from_desc), parse_shaderc_type(to_desc)) + else { + continue; + }; + + // Pick the wrapper HLSL's implicit conversion corresponds to. + let wrap: (String, String) = match (from, to) { + // vecN -> scalar: HLSL truncation warning, takes the leading + // component(s). + (("vec", _), ("float", _)) => ("(".into(), ").x".into()), + (("vec", m), ("vec", n)) if m > n => { + ("(".into(), format!(").{}", &"xyzw"[..n as usize])) + } + // scalar -> vecN: HLSL splats. + (("float", _) | ("int", _) | (("bool", _)), ("vec", n)) => { + (format!("vec{n}("), ")".into()) + } + // bool -> number: HLSL's true/false are 1/0 (simple_audio_bars + // assigns a comparison straight into an int bar index). + (("bool", _), ("float", _)) => ("float(".into(), ")".into()), + (("bool", _), ("int", _)) => ("int(".into(), ")".into()), + (("bool", _), ("uint", _)) => ("uint(".into(), ")".into()), + // float -> int/uint: HLSL truncates toward zero, same as GLSL's + // explicit constructor. + (("float", _), ("int", _)) => ("int(".into(), ")".into()), + (("float", _), ("uint", _)) => ("uint(".into(), ")".into()), + _ => continue, + }; + + let Some(line) = lines.get_mut(line_no.saturating_sub(1)) else { + continue; + }; + if let Some(patched) = wrap_assignment_rhs(line, &wrap.0, &wrap.1) { + *line = patched; + repaired = true; + } + } + + repaired.then(|| lines.join("\n")) +} + +/// Declared widths of everything named in a generated shader: input varyings, +/// UBO members and plain local declarations. Feeds the error-driven repairs +/// below, which run on the final GLSL text and so have no access to the +/// preprocessor's own tracking. +pub(super) fn declared_widths(glsl: &str) -> HashMap { + let mut map = HashMap::new(); + for line in glsl.lines() { + let t = line.trim_start(); + // "layout(location=0) in vec4 v_TexCoord;" / " vec2 u_rOffset;" / + // " vec4 timer = texSample2D(...);" + let decl = t.rsplit(')').next().unwrap_or(t).trim(); + let mut tok = decl.split_whitespace(); + let (Some(first), Some(second)) = (tok.next(), tok.next()) else { + continue; + }; + let (ty, name) = if first == "in" || first == "out" || first == "uniform" { + match tok.next() { + Some(n) => (second, n), + None => continue, + } + } else { + (first, second) + }; + let Some(w) = vec_width(ty).or(matches!(ty, "float" | "int").then_some(1)) else { + continue; + }; + let name = name + .trim_end_matches([';', ',']) + .split('=') + .next() + .unwrap_or("") + .trim(); + if !name.is_empty() && name.chars().all(|c| is_word_char(c as u8)) { + map.insert(name.to_string(), w); + } + } + map +} + +/// Line numbers shaderc reported for messages containing `needle`. +pub(super) fn error_lines(err_text: &str, needle: &str) -> Vec { + err_text + .lines() + .filter(|m| m.contains(needle)) + .filter_map(|m| { + let rest = m + .split_once("shader.frag:") + .or_else(|| m.split_once("shader.vert:"))? + .1; + rest.split(':').next()?.trim().parse::().ok() + }) + .collect() +} + +/// `v_TexCoord += ...` — chromatic_aberration.frag writes to its own varying, +/// which WE's compiler allows and GLSL does not ("l-value required ... can't +/// modify shader input"). Rename the input and give `main()` a mutable copy +/// under the original name, so the body compiles unchanged. +pub(super) fn repair_input_assignment(glsl: &str, err_text: &str) -> Option { + let mut out = glsl.to_string(); + let mut repaired = false; + for msg in err_text.lines() { + if !msg.contains("can't modify shader input") { + continue; + } + let Some(name) = msg + .split('"') + .nth(1) + .filter(|n| !n.is_empty() && n.chars().all(|c| is_word_char(c as u8))) + else { + continue; + }; + let alias = format!("_wp_in_{name}"); + if out.contains(&alias) { + continue; + } + // Find the input declaration to learn its type, and rename it there. + let Some(decl) = out.lines().find(|l| { + let t = l.trim_start(); + t.starts_with("layout(") && t.contains(" in ") && t.ends_with(&format!(" {name};")) + }) else { + continue; + }; + let ty = decl.split_whitespace().nth(2)?.to_string(); + let renamed = decl.replace(&format!(" {name};"), &format!(" {alias};")); + out = out.replace(decl, &renamed); + // Shadow it with a local copy at the top of main(). + let main_brace = out + .find("void main") + .and_then(|p| out[p..].find('{').map(|i| p + i + 1))?; + out.insert_str(main_brace, &format!("\n\t{ty} {name} = {alias};")); + repaired = true; + } + repaired.then_some(out) +} + +/// `depth *= (depth < limit) * 6.0` — bokeh_blur.frag does arithmetic on a +/// comparison. HLSL reads true as 1.0; GLSL refuses. Wrap the parenthesised +/// comparison in `float(...)`. +pub(super) fn repair_bool_operands(glsl: &str, err_text: &str) -> Option { + let mut lines: Vec = glsl.lines().map(str::to_string).collect(); + let mut repaired = false; + for line_no in error_lines(err_text, "operand of type ' temp bool'") { + let Some(line) = lines.get_mut(line_no.saturating_sub(1)) else { + continue; + }; + let mut patched = line.clone(); + let mut from = 0; + while let Some(rel) = patched[from..].find('(') { + let open = from + rel; + let Some((group, after)) = extract_balanced(&patched, open, b'(', b')') else { + break; + }; + let inner = &group[1..group.len() - 1]; + let is_comparison = ["<", ">", "<=", ">=", "==", "!="] + .iter() + .any(|op| inner.contains(op)) + && !inner.contains("float("); + if is_comparison && !patched[..open].ends_with("float") { + let replacement = format!("float{group}"); + patched = format!("{}{replacement}{}", &patched[..open], &patched[after..]); + from = open + replacement.len(); + repaired = true; + continue; + } + from = open + 1; + } + *line = patched; + } + repaired.then(|| lines.join("\n")) +} + +/// `rotateVec2(v_TexCoord, ...)` where the varying is a vec4 — usually because +/// `harmonize_varying_widths` widened the fragment's declaration to match the +/// vertex stage. Coerce each argument to the callee's declared parameter type. +pub(super) fn repair_call_arg_widths(glsl: &str, err_text: &str) -> Option { + let widths = declared_widths(glsl); + let mut lines: Vec = glsl.lines().map(str::to_string).collect(); + let mut repaired = false; + for msg in err_text.lines() { + if !msg.contains("no matching overloaded function found") { + continue; + } + let Some(func) = msg.split('\'').nth(1) else { + continue; + }; + let Some(line_no) = error_lines(msg, "no matching overloaded function") + .first() + .copied() + else { + continue; + }; + // The callee's parameter widths, from its definition in this source. + let Some(params) = glsl.lines().find_map(|l| { + let t = l.trim_start(); + let rest = t.split_once(&format!(" {func}("))?.1; + let end = rest.find(')')?; + Some( + rest[..end] + .split(',') + .filter_map(|p| { + let ty = p.split_whitespace().next()?; + vec_width(ty).or(matches!(ty, "float" | "int").then_some(1)) + }) + .collect::>(), + ) + }) else { + continue; + }; + let Some(line) = lines.get_mut(line_no.saturating_sub(1)) else { + continue; + }; + let Some(call) = line.find(&format!("{func}(")) else { + continue; + }; + let open = call + func.len(); + let Some((group, after)) = extract_balanced(line, open, b'(', b')') else { + continue; + }; + let args: Vec = split_top_level_args(&group[1..group.len() - 1]) + .iter() + .map(|(a, _)| a.trim().to_string()) + .collect(); + if args.len() != params.len() { + continue; + } + let coerced: Vec = args + .iter() + .zip(¶ms) + .map(|(arg, &want)| match widths.get(arg.as_str()) { + Some(&have) if have > want && want >= 2 => { + format!("{arg}{}", swizzle_prefix(want)) + } + _ => arg.clone(), + }) + .collect(); + if coerced == args { + continue; + } + *line = format!( + "{}{func}({}){}", + &line[..call], + coerced.join(", "), + &line[after..] + ); + repaired = true; + } + repaired.then(|| lines.join("\n")) +} + +/// `u_rOffset * timer` where one is a vec2 and the other a vec4 — HLSL +/// truncates the wider side, GLSL rejects the pair. Swizzle the wider +/// identifiers on the offending line down to the narrower width. +pub(super) fn repair_operand_widths(glsl: &str, err_text: &str) -> Option { + let widths = declared_widths(glsl); + let mut lines: Vec = glsl.lines().map(str::to_string).collect(); + let mut repaired = false; + for msg in err_text.lines() { + if !msg.contains("wrong operand types") { + continue; + } + let mut sides = msg.split("operand of type").skip(1).filter_map(|s| { + let desc = s.split('\'').nth(1)?; + parse_shaderc_type(desc.trim()).map(|(_, n)| n) + }); + let (Some(a), Some(b)) = (sides.next(), sides.next()) else { + continue; + }; + let (wide, narrow) = (a.max(b), a.min(b)); + if wide == narrow || narrow < 2 { + continue; + } + let Some(line_no) = error_lines(msg, "wrong operand types").first().copied() else { + continue; + }; + let Some(line) = lines.get_mut(line_no.saturating_sub(1)) else { + continue; + }; + let patched = truncate_wide_identifiers_of(line, wide, narrow, &widths); + if patched != *line { + *line = patched; + repaired = true; + } + } + repaired.then(|| lines.join("\n")) +} + +/// Swizzle every bare identifier of exactly `wide` components down to +/// `narrow`. Unlike `truncate_wide_identifiers` this runs on a whole +/// statement (call arguments included) and only when shaderc already +/// rejected that line, so it cannot narrow a call that was fine. +pub(super) fn truncate_wide_identifiers_of( + line: &str, + wide: u8, + narrow: u8, + widths: &HashMap, +) -> String { + let mut out = String::with_capacity(line.len()); + let bytes = line.as_bytes(); + let mut prev_word: Option = None; + let mut i = 0; + while i < bytes.len() { + if !is_word_char(bytes[i]) + || (i > 0 && (is_word_char(bytes[i - 1]) || bytes[i - 1] == b'.')) + { + out.push(bytes[i] as char); + i += 1; + continue; + } + let start = i; + while i < bytes.len() && is_word_char(bytes[i]) { + i += 1; + } + let ident = &line[start..i]; + out.push_str(ident); + let next = bytes.get(i).copied().unwrap_or(b' '); + // `vec4 rValue = ...` declares rValue — swizzling the declared name + // is a syntax error, and the declaration's own width is not the bug. + let is_declaration = matches!( + prev_word.as_deref(), + Some("vec2" | "vec3" | "vec4" | "float" | "int" | "uint" | "bool") + ); + if !is_declaration + && next != b'.' + && next != b'(' + && next != b'[' + && widths.get(ident) == Some(&wide) + { + out.push_str(swizzle_prefix(narrow)); + } + prev_word = Some(ident.to_string()); + } + out +} + +/// `if (u_userInvertDepthMap)` where the uniform is a float — HLSL treats +/// nonzero as true, strict GLSL wants a bool ("boolean expression expected"). +/// Wrap the condition in `bool(...)`. Returns `None` when nothing was repaired. +pub(super) fn repair_bool_conditions(glsl: &str, err_text: &str) -> Option { + let mut lines: Vec = glsl.lines().map(str::to_string).collect(); + let mut repaired = false; + for msg in err_text.lines() { + if !msg.contains("boolean expression expected") { + continue; + } + let Some(rest) = msg + .split_once("shader.frag:") + .or_else(|| msg.split_once("shader.vert:")) + .map(|(_, r)| r) + else { + continue; + }; + let Ok(line_no) = rest.split(':').next().unwrap_or("").trim().parse::() else { + continue; + }; + let Some(line) = lines.get_mut(line_no.saturating_sub(1)) else { + continue; + }; + for kw in ["if", "while"] { + let Some(kw_pos) = line + .find(&format!("{kw}(")) + .or_else(|| line.find(&format!("{kw} ("))) + else { + continue; + }; + let Some(open) = line[kw_pos..].find('(').map(|i| kw_pos + i) else { + continue; + }; + // extract_balanced yields the parens too, and `after` is the index + // just past the closing one. + let Some((parens, after)) = extract_balanced(line, open, b'(', b')') else { + continue; + }; + let cond = parens[1..parens.len() - 1].trim(); + if cond.is_empty() || cond.contains("bool(") { + continue; + } + *line = format!("{}(bool({cond})){}", &line[..open], &line[after..]); + repaired = true; + break; + } + } + repaired.then(|| lines.join("\n")) +} + +/// Wrap the right-hand side of the first plain assignment on `line` in +/// `prefix`/`suffix`. The RHS runs from the `=` to the first `;` or `,` at +/// paren depth zero relative to the RHS start (so a `for (int i = x; ...)` +/// init terminates at its own `;`). Compound (`+=`) and comparison +/// (`==`/`<=`/...) operators are not assignments and are skipped. +pub(super) fn wrap_assignment_rhs(line: &str, prefix: &str, suffix: &str) -> Option { + let bytes = line.as_bytes(); + let mut eq = None; + for (i, &b) in bytes.iter().enumerate() { + if b != b'=' { + continue; + } + let prev = if i > 0 { bytes[i - 1] } else { b' ' }; + let next = bytes.get(i + 1).copied().unwrap_or(b' '); + // Skip comparisons (`==`, `!=`, `<=`, `>=`). Compound assignments + // (`+= -= *= /=`) ARE valid targets: `int x *= floatExpr` needs the + // RHS wrapped just like a plain `int x = floatExpr` does. + if matches!(prev, b'=' | b'!' | b'<' | b'>') || next == b'=' { + continue; + } + eq = Some(i); + break; + } + let eq = eq?; + + let rhs_start = eq + 1; + let mut depth = 0i32; + let mut rhs_end = line.len(); + for (i, ch) in line[rhs_start..].char_indices() { + match ch { + '(' | '[' => depth += 1, + ')' | ']' => depth -= 1, + ';' | ',' if depth <= 0 => { + rhs_end = rhs_start + i; + break; + } + _ => {} + } + } + let rhs = line[rhs_start..rhs_end].trim(); + if rhs.is_empty() { + return None; + } + if rhs.starts_with(prefix.trim_end_matches('(')) && rhs.ends_with(suffix) { + // Already wrapped on an earlier repair round — move on to the next + // assignment on this line. simple_audio_bars.frag puts two of them on + // one line ("barLeft *= isLeftChannel; barRight *= isRightChannel;") + // and both need the same cast. + let rest = wrap_assignment_rhs(&line[rhs_end..], prefix, suffix)?; + return Some(format!("{}{rest}", &line[..rhs_end])); + } + Some(format!( + "{} {prefix}{rhs}{suffix}{}", + &line[..rhs_start], + &line[rhs_end..] + )) +} + +pub(super) fn glsl_to_spirv(glsl: &str, stage: naga::ShaderStage) -> Result> { + let (kind, filename) = match stage { + naga::ShaderStage::Fragment => (shaderc::ShaderKind::Fragment, "shader.frag"), + naga::ShaderStage::Vertex => (shaderc::ShaderKind::Vertex, "shader.vert"), + _ => (shaderc::ShaderKind::Compute, "shader.comp"), + }; + + let compiler = shaderc::Compiler::new().context("failed to create shaderc compiler")?; + let mut options = shaderc::CompileOptions::new().context("failed to create shaderc options")?; + // Target Vulkan SPIR-V — required for naga's spv-in reader. + // Our preprocessor already emits Vulkan GLSL 4.50 with explicit set/binding/location qualifiers. + options.set_target_env( + shaderc::TargetEnv::Vulkan, + shaderc::EnvVersion::Vulkan1_1 as u32, + ); + options.set_source_language(shaderc::SourceLanguage::GLSL); + options.set_optimization_level(shaderc::OptimizationLevel::Performance); + + let artifact = compiler + .compile_into_spirv(glsl, kind, filename, "main", Some(&options)) + .context("shaderc GLSL→SPIR-V compilation failed")?; + + Ok(artifact.as_binary_u8().to_vec()) +} + +pub(super) fn spirv_to_wgsl(spv: &[u8]) -> Result { + let module = naga::front::spv::parse_u8_slice( + spv, + &naga::front::spv::Options { + adjust_coordinate_space: true, + ..Default::default() + }, + ) + .context("naga SPIR-V parse failed")?; + + let info = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .context("naga validation failed")?; + + naga::back::wgsl::write_string(&module, &info, naga::back::wgsl::WriterFlags::empty()) + .context("naga WGSL output failed") +} diff --git a/src/engine/gpu/shaders/transpiler/rewrite.rs b/src/engine/gpu/shaders/transpiler/rewrite.rs new file mode 100644 index 0000000..3b2ee1c --- /dev/null +++ b/src/engine/gpu/shaders/transpiler/rewrite.rs @@ -0,0 +1,965 @@ +//! Source rewrites applied before compiling: real Workshop shaders rely on +//! lenient GLSL compilers, so loops with constant bounds are unrolled, array +//! varyings expanded, int literals and vector widths coerced, and names that +//! clash with built-ins renamed. + +use super::*; + +/// Collect non-sampler `uniform` declarations (type, name) in source order. +/// The macro name of a `#define NAME ...` line, if this line is one. +pub(super) fn redefined_macro(trimmed_line: &str) -> Option<&str> { + let rest = trimmed_line.strip_prefix("#define")?; + if !rest.starts_with([' ', '\t']) { + return None; + } + let name = rest.trim_start(); + let end = name + .find(|c: char| !(c.is_alphanumeric() || c == '_')) + .unwrap_or(name.len()); + (end > 0).then(|| &name[..end]) +} + +pub(super) fn collect_scalar_uniforms(src: &str) -> Vec<(String, String)> { + let mut scalars = Vec::new(); + for line in src.lines() { + let decl = line.trim().split("//").next().unwrap_or("").trim(); + let tok: Vec<&str> = decl.split_whitespace().collect(); + if tok.len() >= 3 && tok[0] == "uniform" { + let ty = tok[1]; + if !ty.starts_with("sampler") && !ty.is_empty() { + let name = tok[2].trim_end_matches(';'); + // Array uniforms (audio spectra etc.) can't be packed by the + // renderer's flat UBO writer; skip them (they read as zero). + if !name.contains('[') { + scalars.push((ty.to_string(), name.to_string())); + } + } + } + } + scalars +} + +/// True if every `name[...]` access in `src` has a plain non-negative-integer +/// literal inside the brackets (no variables/expressions). +pub(super) fn all_indices_are_literal(src: &str, name: &str) -> bool { + let pattern = format!("{name}["); + let mut search_from = 0; + while let Some(rel) = src[search_from..].find(pattern.as_str()) { + let start = search_from + rel + pattern.len(); + let Some(end_rel) = src[start..].find(']') else { + return false; // unterminated — be conservative + }; + let content = src[start..start + end_rel].trim(); + if content.parse::().is_err() { + return false; + } + search_from = start + end_rel + 1; + } + true +} + +/// Finds the balanced-bracket span starting at `open_pos` (which must point +/// at an `open` byte), skipping bracket-like bytes inside `//` line comments. +/// Returns `(span including both brackets, index just past the closer)`. +pub(super) fn extract_balanced( + s: &str, + open_pos: usize, + open: u8, + close: u8, +) -> Option<(&str, usize)> { + let bytes = s.as_bytes(); + if bytes.get(open_pos).copied() != Some(open) { + return None; + } + let mut depth = 0i32; + let mut i = open_pos; + let mut in_line_comment = false; + while i < bytes.len() { + let b = bytes[i]; + if in_line_comment { + if b == b'\n' { + in_line_comment = false; + } + i += 1; + continue; + } + if b == b'/' && bytes.get(i + 1) == Some(&b'/') { + in_line_comment = true; + i += 2; + continue; + } + if b == open { + depth += 1; + } else if b == close { + depth -= 1; + if depth == 0 { + return Some((&s[open_pos..=i], i + 1)); + } + } + i += 1; + } + None +} + +/// Parses a `for` header's three `;`-separated clauses into +/// `(loop_var_name, iteration_count)`, only recognizing the exact shape real +/// WE shaders use: `int NAME = 0; NAME < N; NAME++` (or `++NAME`, or `<=`). +/// Returns `None` for anything else — callers must leave the input untouched +/// rather than guess. +pub(super) fn parse_simple_for_header(header_inner: &str) -> Option<(String, usize)> { + let clauses: Vec<&str> = header_inner.split(';').collect(); + if clauses.len() != 3 { + return None; + } + let init = clauses[0].trim(); + let init = init + .strip_prefix("int ") + .or_else(|| init.strip_prefix("uint "))?; + let (name, start_val) = init.split_once('=')?; + let name = name.trim(); + if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') { + return None; + } + if start_val.trim().parse::() != Ok(0) { + return None; // only handle the universal `= 0` starting point + } + + let cond = clauses[1].trim(); + let (cmp_name, op, end_str) = if let Some((n, v)) = cond.split_once("<=") { + (n.trim(), "<=", v.trim()) + } else if let Some((n, v)) = cond.split_once('<') { + (n.trim(), "<", v.trim()) + } else { + return None; + }; + if cmp_name != name { + return None; + } + let end_val: i64 = end_str.parse().ok()?; + let count = match op { + "<" => end_val, + "<=" => end_val + 1, + _ => return None, + }; + if count <= 0 || count > 64 { + return None; // sanity bound — real shaders use small fixed kernels + } + + let incr = clauses[2].trim(); + if incr != format!("{name}++") && incr != format!("++{name}") { + return None; + } + + Some((name.to_string(), count as usize)) +} + +/// Unrolls simple, fixed-bound `for (int i = 0; i < N; i++) { BODY }` loops +/// into N literal copies of `BODY` with `i` replaced by each literal index. +/// +/// Needed alongside `unroll_array_varyings`: naga's GLSL frontend rejects +/// array-typed varyings outright, and that pass only unrolls *literally* +/// indexed arrays — but some real shaders (e.g. `blur_downsample4.frag`) read +/// a fixed-size array varying through exactly this kind of loop +/// (`for (i...) texSample2D(tex, v_TexCoord[i])`). Unrolling the loop first +/// turns `v_TexCoord[i]` into `v_TexCoord[0]`, `v_TexCoord[1]`, ... so the +/// array-varying pass can then unroll the declaration too, and the shader +/// translates instead of being skipped. +/// +/// Only ever touches text that matches this exact shape; anything else (a +/// different loop form, a non-zero start, a non-literal bound, nested loops +/// it can't balance) is left completely untouched — no partial rewrites. +pub fn unroll_simple_for_loops(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + let mut rest = src; + loop { + let Some(for_rel) = rest.find("for") else { + out.push_str(rest); + break; + }; + let before_ok = for_rel == 0 || !is_word_char(rest.as_bytes()[for_rel - 1]); + let after_rel = for_rel + 3; + let after_ok = rest + .as_bytes() + .get(after_rel) + .is_none_or(|&b| !is_word_char(b)); + if !before_ok || !after_ok { + out.push_str(&rest[..after_rel]); + rest = &rest[after_rel..]; + continue; + } + + let paren_start = + after_rel + rest[after_rel..].len() - rest[after_rel..].trim_start().len(); + if rest.as_bytes().get(paren_start).copied() != Some(b'(') { + out.push_str(&rest[..after_rel]); + rest = &rest[after_rel..]; + continue; + } + let Some((header, header_end)) = extract_balanced(rest, paren_start, b'(', b')') else { + out.push_str(&rest[..after_rel]); + rest = &rest[after_rel..]; + continue; + }; + let Some((name, count)) = parse_simple_for_header(&header[1..header.len() - 1]) else { + out.push_str(&rest[..header_end]); + rest = &rest[header_end..]; + continue; + }; + + let after_header = &rest[header_end..]; + let body_open_rel = after_header.len() - after_header.trim_start().len(); + let body_open_abs = header_end + body_open_rel; + if rest.as_bytes().get(body_open_abs).copied() != Some(b'{') { + out.push_str(&rest[..header_end]); + rest = &rest[header_end..]; + continue; + } + let Some((body, body_end)) = extract_balanced(rest, body_open_abs, b'{', b'}') else { + out.push_str(&rest[..header_end]); + rest = &rest[header_end..]; + continue; + }; + let body_inner = &body[1..body.len() - 1]; + + out.push_str(&rest[..for_rel]); + for i in 0..count { + // Each iteration gets its own `{}` scope, matching the original + // loop body's scoping — otherwise a variable declared inside the + // loop (e.g. `vec4 sample = ...`) becomes a flat redeclaration + // once concatenated N times into the same enclosing scope. + out.push_str("{\n"); + out.push_str(&rename_reserved_word(body_inner, &name, &i.to_string())); + out.push_str("\n}\n"); + } + rest = &rest[body_end..]; + } + out +} + +/// Splits a function-call argument list on *top-level* commas (not inside +/// nested parens), returning each argument's `(text, start_offset)` within +/// `args`. +pub(super) fn split_top_level_args(args: &str) -> Vec<(&str, usize)> { + let bytes = args.as_bytes(); + let mut depth = 0i32; + let mut start = 0usize; + let mut out = Vec::new(); + for (i, &b) in bytes.iter().enumerate() { + match b { + b'(' | b'[' => depth += 1, + b')' | b']' => depth -= 1, + b',' if depth == 0 => { + out.push((&args[start..i], start)); + start = i + 1; + } + _ => {} + } + } + out.push((&args[start..], start)); + out +} + +/// True if `s` (already trimmed) is a bare numeric literal: an optional `-`, +/// digits, and an optional `.` plus more digits — no identifiers, swizzles, +/// or function calls. +pub(super) fn is_bare_number(s: &str) -> bool { + if s.is_empty() { + return false; + } + let s = s.strip_prefix('-').unwrap_or(s); + if s.is_empty() { + return false; + } + let mut seen_digit = false; + let mut seen_dot = false; + for c in s.chars() { + match c { + '0'..='9' => seen_digit = true, + '.' if !seen_dot => seen_dot = true, + _ => return false, + } + } + seen_digit +} + +pub(super) fn ensure_float_literal(trimmed: &str) -> String { + if trimmed.contains('.') { + trimmed.to_string() + } else { + format!("{trimmed}.0") + } +} + +/// Legacy NVIDIA GLSL compilers tolerated `max`/`min` calls with a bare +/// numeric literal as the *first* argument and a vector expression second +/// (e.g. nitro.frag's `max(0, albedo.rgb)`), including when the literal was +/// an integer where a float is needed. Strict Vulkan GLSL (naga/shaderc) +/// requires both: the literal to be a genuine float, *and* the vector +/// argument first — GLSL only defines `genType max(genType x, float y)`, not +/// the reverse. `max`/`min` are commutative, so swapping is always safe (not +/// just when the second argument turns out to be a vector). `clamp`'s +/// numeric arguments are only ever coerced to float, never reordered, since +/// `clamp(x, minVal, maxVal)` isn't commutative. +pub fn coerce_int_literal_builtin_args(src: &str) -> String { + const COMMUTATIVE_FUNCS: &[&str] = &["max(", "min("]; + const OTHER_FUNCS: &[&str] = &["clamp("]; + let mut out = String::with_capacity(src.len()); + let mut rest = src; + 'outer: loop { + let mut best: Option<(usize, usize, bool)> = None; // (rel_pos, fn_len, commutative) + for f in COMMUTATIVE_FUNCS { + if let Some(rel) = rest.find(f) { + if best.is_none_or(|(bp, ..)| rel < bp) { + best = Some((rel, f.len(), true)); + } + } + } + for f in OTHER_FUNCS { + if let Some(rel) = rest.find(f) { + if best.is_none_or(|(bp, ..)| rel < bp) { + best = Some((rel, f.len(), false)); + } + } + } + let Some((rel, flen, commutative)) = best else { + out.push_str(rest); + break 'outer; + }; + let open_pos = rel + flen - 1; // index of the '(' + out.push_str(&rest[..open_pos]); + let Some((call, call_end)) = extract_balanced(rest, open_pos, b'(', b')') else { + out.push_str(&rest[open_pos..open_pos + 1]); + rest = &rest[open_pos + 1..]; + continue; + }; + let inner = &call[1..call.len() - 1]; + let args = split_top_level_args(inner); + let trimmed: Vec<&str> = args.iter().map(|(a, _)| a.trim()).collect(); + + let mut fixed_args: Vec = trimmed.iter().map(|t| t.to_string()).collect(); + for t in fixed_args.iter_mut() { + if is_bare_number(t) { + *t = ensure_float_literal(t); + } + } + // Only max/min, only the exact 2-arg "literal first, non-literal + // second" shape — swap so the literal ends up in the (required) + // second position. + if commutative + && fixed_args.len() == 2 + && is_bare_number(trimmed[0]) + && !is_bare_number(trimmed[1]) + { + fixed_args.swap(0, 1); + } + + out.push('('); + out.push_str(&fixed_args.join(", ")); + out.push(')'); + rest = &rest[call_end..]; + } + out +} + +/// WE's shader compiler tolerates truncating a wider vector to match a +/// narrower swizzled assignment target — e.g. `albedo.rgb = mix(albedo, +/// newAlbedo, mask)` where `albedo` is `vec4` but the assignment (and +/// `newAlbedo`) are `vec3`. Strict GLSL (naga/shaderc) requires `mix()`'s +/// first two arguments to have the same component count and rejects this. +/// When an assignment's LHS is `IDENT.SWIZZLE` and the RHS is a `mix(...)` +/// call whose first argument is the same bare `IDENT`, apply the LHS's +/// swizzle to that argument too: a no-op when the types already matched, and +/// exactly the truncation WE's compiler performed implicitly otherwise. +pub fn coerce_swizzle_mismatched_mix_arg(src: &str) -> String { + fn is_swizzle(s: &str) -> bool { + (1..=4).contains(&s.len()) + && (s.bytes().all(|b| b"xyzw".contains(&b)) || s.bytes().all(|b| b"rgba".contains(&b))) + } + fn is_ident(s: &str) -> bool { + !s.is_empty() + && s.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') + && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + } + + let mut out = String::with_capacity(src.len()); + let mut rest = src; + loop { + let Some(rel) = rest.find("mix(") else { + out.push_str(rest); + break; + }; + // Skip matches that are just the tail of a longer identifier (e.g. a + // hypothetical `remix(`). + if rel > 0 { + let prev = rest.as_bytes()[rel - 1]; + if prev.is_ascii_alphanumeric() || prev == b'_' { + out.push_str(&rest[..rel + 4]); + rest = &rest[rel + 4..]; + continue; + } + } + let open_pos = rel + 3; // index of '(' in "mix(" + let Some((call, call_end)) = extract_balanced(rest, open_pos, b'(', b')') else { + out.push_str(&rest[..open_pos + 1]); + rest = &rest[open_pos + 1..]; + continue; + }; + + // Find the assignment this call is the RHS of: walk back over + // whitespace and a trailing '=' immediately preceding "mix(". + let before_trim = rest[..rel].trim_end(); + let Some(lhs_and_stmt) = before_trim.strip_suffix('=') else { + out.push_str(&rest[..call_end]); + rest = &rest[call_end..]; + continue; + }; + // '==' is a comparison, not an assignment — reject if another '=' + // immediately precedes. + if lhs_and_stmt.ends_with('=') { + out.push_str(&rest[..call_end]); + rest = &rest[call_end..]; + continue; + } + let lhs = lhs_and_stmt.trim_end(); + let stmt_start = lhs.rfind(['\n', ';', '{', '}']).map(|i| i + 1).unwrap_or(0); + let lhs_expr = lhs[stmt_start..].trim(); + let Some((base, swizzle)) = lhs_expr.split_once('.') else { + out.push_str(&rest[..call_end]); + rest = &rest[call_end..]; + continue; + }; + if !is_ident(base) || !is_swizzle(swizzle) { + out.push_str(&rest[..call_end]); + rest = &rest[call_end..]; + continue; + } + + let inner = &call[1..call.len() - 1]; + let args = split_top_level_args(inner); + if args.is_empty() || args[0].0.trim() != base { + out.push_str(&rest[..call_end]); + rest = &rest[call_end..]; + continue; + } + + out.push_str(&rest[..open_pos + 1]); + let mut fixed: Vec = args.iter().map(|(a, _)| a.trim().to_string()).collect(); + fixed[0] = format!("{base}.{swizzle}"); + out.push_str(&fixed.join(", ")); + out.push(')'); + rest = &rest[call_end..]; + } + out +} + +pub fn unroll_array_varyings(src: &str) -> String { + // name -> (qualifier, type, count) + let mut arrays: Vec<(String, String, String, usize)> = Vec::new(); + for line in src.lines() { + let t = line.trim(); + let (qualifier, rest) = if let Some(r) = t.strip_prefix("varying ") { + ("varying", r) + } else if let Some(r) = t.strip_prefix("attribute ") { + ("attribute", r) + } else { + continue; + }; + let rest = rest.split("//").next().unwrap_or(rest).trim(); + let rest = rest.trim_end_matches(';').trim(); + let mut parts = rest.splitn(2, char::is_whitespace); + let Some(ty) = parts.next() else { continue }; + let Some(name_and_dim) = parts.next().map(str::trim) else { + continue; + }; + let Some(bracket) = name_and_dim.find('[') else { + continue; + }; + let name = &name_and_dim[..bracket]; + let Some(close) = name_and_dim[bracket + 1..].find(']') else { + continue; + }; + let dim_str = &name_and_dim[bracket + 1..bracket + 1 + close]; + let Ok(n) = dim_str.parse::() else { + continue; + }; + if n == 0 { + continue; + } + arrays.push((name.to_string(), qualifier.to_string(), ty.to_string(), n)); + } + + if arrays.is_empty() { + return src.to_string(); + } + + // WE shaders often declare the same array varying multiple times at + // different sizes under mutually-exclusive `#if COMBO==N` branches (e.g. + // godrays_gaussian.frag's `v_TexCoord[13|7|3]` per KERNEL quality level). + // Since this pass runs before `#if` resolution, all branches are still + // literally present; collapse same-named declarations to one, sized to + // the largest variant seen (extra unused varyings are harmless). + let mut merged: Vec<(String, String, String, usize)> = Vec::new(); + for (name, qualifier, ty, n) in arrays { + if let Some(existing) = merged.iter_mut().find(|(en, ..)| *en == name) { + existing.3 = existing.3.max(n); + } else { + merged.push((name, qualifier, ty, n)); + } + } + // Only unroll names indexed *exclusively* by literal integers everywhere + // in the shader. Some real shaders (e.g. blur_downsample4.frag) loop over + // the array with a variable index (`for (i...) tex[i]`); rewriting just + // the literal-looking declaration while leaving `tex[i]` untouched would + // unroll the declaration out from under a body reference that's still + // there, producing "undeclared identifier" instead of a clean bail-out. + // Leaving such names alone lets the existing "array varying not + // supported" check below reject the shader honestly instead. + let arrays: Vec<_> = merged + .into_iter() + .filter(|(name, ..)| all_indices_are_literal(src, name)) + .collect(); + + // Re-extract this line's declared array name (if any) the same way as the + // collection pass, so the declaration line is matched precisely instead + // of by fragile substring checks against the qualifier/type prefix. + let declared_name = |t: &str| -> Option { + let rest = t + .strip_prefix("varying ") + .or_else(|| t.strip_prefix("attribute "))?; + let rest = rest.split("//").next().unwrap_or(rest).trim(); + let rest = rest.trim_end_matches(';').trim(); + let mut parts = rest.splitn(2, char::is_whitespace); + parts.next()?; + let name_and_dim = parts.next()?.trim(); + let bracket = name_and_dim.find('[')?; + Some(name_and_dim[..bracket].to_string()) + }; + + let mut emitted: Vec = Vec::new(); + let mut out = String::with_capacity(src.len()); + for line in src.lines() { + let t = line.trim(); + if let Some(decl_name) = declared_name(t) { + if emitted.contains(&decl_name) { + // Same name already declared (a smaller #if/#else variant) — + // drop this line rather than redeclare it. + continue; + } + if let Some((name, qualifier, ty, n)) = arrays.iter().find(|(n, ..)| *n == decl_name) { + for i in 0..*n { + out.push_str(&format!("{qualifier} {ty} {name}_{i};\n")); + } + emitted.push(decl_name); + continue; + } + } + let mut rewritten = line.to_string(); + for (name, _, _, n) in &arrays { + for i in 0..*n { + rewritten = rewritten.replace(&format!("{name}[{i}]"), &format!("{name}_{i}")); + } + } + out.push_str(&rewritten); + out.push('\n'); + } + out +} + +/// Port of the reference's `applyLinkedVaryingCompatibility` +/// (ShaderUnit.cpp:379-415): for every `varying vec4 NAME;` the fragment +/// declares where the vertex declares `varying vec2 NAME;`, upgrade the +/// vertex declaration to vec4 and rewrite the vertex's whole-variable +/// assignments (`NAME = expr;` at statement start — swizzled stores like +/// `NAME.xy = ...` are already type-correct and left alone) to +/// `NAME = vec4(expr, 0.0, 1.0);`. WE's own compiler links such mismatched +/// interfaces silently; strict Vulkan pipelines reject them. +pub(super) fn harmonize_varying_widths(vert: &str, frag: &str) -> String { + // Fragment-side vec4 varyings. + let mut vec4_names: Vec = Vec::new(); + for line in frag.lines() { + let decl = line.trim().split("//").next().unwrap_or("").trim(); + let tok: Vec<&str> = decl.split_whitespace().collect(); + if tok.len() >= 3 && tok[0] == "varying" && tok[1] == "vec4" { + let name = tok[2].trim_end_matches(';'); + if !name.contains('[') { + vec4_names.push(name.to_string()); + } + } + } + if vec4_names.is_empty() { + return vert.to_string(); + } + + let mut out: Vec = Vec::new(); + let mut upgraded: Vec = Vec::new(); + for line in vert.lines() { + let decl = line.trim().split("//").next().unwrap_or("").trim(); + let tok: Vec<&str> = decl.split_whitespace().collect(); + if tok.len() >= 3 && tok[0] == "varying" && tok[1] == "vec2" { + let name = tok[2].trim_end_matches(';'); + if vec4_names.iter().any(|n| n == name) { + out.push(format!("varying vec4 {name};")); + upgraded.push(name.to_string()); + continue; + } + } + out.push(line.to_string()); + } + if upgraded.is_empty() { + return vert.to_string(); + } + + // Rewrite whole-variable assignments of upgraded varyings. + for line in &mut out { + let trimmed = line.trim_start(); + let indent_len = line.len() - trimmed.len(); + for name in &upgraded { + let Some(rest) = trimmed.strip_prefix(name.as_str()) else { + continue; + }; + let rest = rest.trim_start(); + // Plain `=` only (not `.xy =`, `==`, `+=`); expression runs to + // the trailing `;`. + let Some(expr_and_tail) = rest.strip_prefix('=') else { + continue; + }; + if expr_and_tail.starts_with('=') { + continue; + } + let Some(semi) = expr_and_tail.find(';') else { + continue; + }; + let expr = expr_and_tail[..semi].trim(); + let tail = &expr_and_tail[semi + 1..]; + *line = format!( + "{}{name} = vec4({expr}, 0.0, 1.0);{tail}", + &line[..indent_len] + ); + break; + } + } + out.join("\n") +} + +pub(super) fn vec_width(ty: &str) -> Option { + match ty { + "vec2" => Some(2), + "vec3" => Some(3), + "vec4" => Some(4), + _ => None, + } +} + +pub(super) fn swizzle_prefix(n: u8) -> &'static str { + match n { + 1 => ".x", + 2 => ".xy", + 3 => ".xyz", + 4 => ".xyzw", + _ => "", + } +} + +pub(super) const TEX_SAMPLE_CALLS: &[&str] = &[ + "texSample2DLod(", + "texSample2D(", + "texture2DLod(", + "textureSample2D(", + "texture2D(", + "textureCube(", + "texture(", +]; + +/// Legacy NVIDIA GLSL compilers silently truncated the wider operand when +/// vector widths mismatched in an assignment or binary op (e.g. assigning a +/// vec4 texture sample to a `vec3` without a `.rgb` swizzle, or subtracting a +/// `vec2` from a `vec3` varying). Workshop shaders authored and only ever +/// tested on NVIDIA rely on this; naga/shaderc's stricter frontend rejects it +/// outright. `var_width` is updated in place as declarations are seen so the +/// caller can process a shader body line by line. +/// Rename functions the shader defines under a name that is already a GLSL +/// builtin or one of our HLSL-compat macros. `crt_scan_line.frag` ships its +/// own `float mod(float, float)`, which glslang rejects (the builtin's +/// parameters are `highp`, the shader's are not); `lerp`/`saturate` would +/// expand through the macro into an even stranger error. +pub(super) fn rename_shadowed_builtins(src: &str) -> String { + const SHADOWABLE: &[&str] = &["mod", "lerp", "saturate", "frac", "mul", "atan2", "log10"]; + let mut out = src.to_string(); + for name in SHADOWABLE { + let defined = src.lines().any(|l| { + let t = l.trim_start(); + let Some(rest) = t.split_once(&format!(" {name}(")) else { + return false; + }; + // A definition starts the line with just a return type — `return + // frac(x);` also has one leading word, so match real types only. + matches!( + rest.0, + "float" + | "vec2" + | "vec3" + | "vec4" + | "int" + | "uint" + | "bool" + | "void" + | "mat2" + | "mat3" + | "mat4" + ) + }); + if defined { + out = rename_reserved_word(&out, name, &format!("_wp_{name}")); + } + } + out +} + +/// Swizzle every bare identifier in `expr` that is known to be wider than +/// `w` down to `w` components (`v_TexCoord` → `v_TexCoord.xy`). Identifiers +/// that already carry a swizzle, are being called, or are struct/array +/// accesses are left alone. +pub(super) fn truncate_wide_identifiers( + expr: &str, + w: u8, + var_width: &HashMap, +) -> String { + let mut out = String::with_capacity(expr.len()); + let bytes = expr.as_bytes(); + // A call's arguments are typed by its signature, not by the declaration + // being assigned to: narrowing `get_noise(v_TexCoord)` to `.xyz` breaks the + // call it was fine in. Only bare arithmetic operands get truncated. + let mut call_depth = 0usize; + let mut last_word_end: Option = None; + let mut i = 0; + while i < bytes.len() { + if !is_word_char(bytes[i]) + || (i > 0 && (is_word_char(bytes[i - 1]) || bytes[i - 1] == b'.')) + { + match bytes[i] { + b'(' => { + let is_call = last_word_end == Some(i); + if is_call || call_depth > 0 { + call_depth += 1; + } + } + b')' => call_depth = call_depth.saturating_sub(1), + _ => {} + } + if bytes[i] != b' ' { + last_word_end = None; + } + out.push(bytes[i] as char); + i += 1; + continue; + } + let start = i; + while i < bytes.len() && is_word_char(bytes[i]) { + i += 1; + } + let ident = &expr[start..i]; + out.push_str(ident); + last_word_end = Some(i); + let next = bytes.get(i).copied().unwrap_or(b' '); + if call_depth == 0 && next != b'.' && next != b'(' && next != b'[' { + if let Some(&iw) = var_width.get(ident) { + if iw > w { + out.push_str(swizzle_prefix(w)); + } + } + } + } + out +} + +/// `timer.xyz` where `timer` is a `float` — lens_flare_sun.frag does this and +/// WE's compiler treats it as a splat. Strict GLSL calls it a swizzle out of +/// range, so rewrite it to `vec3(timer)`. +pub(super) fn splat_scalar_swizzles(line: &str, var_width: &HashMap) -> String { + let mut out = String::with_capacity(line.len()); + let bytes = line.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if !is_word_char(bytes[i]) + || (i > 0 && (is_word_char(bytes[i - 1]) || bytes[i - 1] == b'.')) + { + out.push(bytes[i] as char); + i += 1; + continue; + } + let start = i; + while i < bytes.len() && is_word_char(bytes[i]) { + i += 1; + } + let ident = &line[start..i]; + // A swizzle of 2-4 components on something declared `float`. + let swz_start = i + 1; + let swz_end = if bytes.get(i) == Some(&b'.') { + let mut j = swz_start; + while j < bytes.len() && b"xyzwrgba".contains(&bytes[j]) { + j += 1; + } + j + } else { + swz_start + }; + let n = swz_end.saturating_sub(swz_start); + if (2..=4).contains(&n) + && var_width.get(ident) == Some(&1) + && !bytes.get(swz_end).is_some_and(|b| is_word_char(*b)) + { + out.push_str(&format!("vec{n}({ident})")); + i = swz_end; + continue; + } + out.push_str(ident); + } + out +} + +pub(super) fn coerce_vector_widths(line: &str, var_width: &mut HashMap) -> String { + let indent_len = line.len() - line.trim_start().len(); + let indent = &line[..indent_len]; + let trimmed = line.trim_start(); + let trimmed = trimmed.strip_suffix('\r').unwrap_or(trimmed); + + // `vecN name = ;` — track the declared width, and if is an + // unswizzled texture sample (always vec4) narrower than N, truncate it. + // Scalars are tracked too — only so a `.xyz` on one can be splatted below. + if let Some(rest) = trimmed.strip_prefix("float ") { + if let Some(eq) = rest.find('=') { + let name = rest[..eq].trim(); + if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') { + var_width.insert(name.to_string(), 1); + } + } + } + let mut declared: Option<(u8, &str)> = None; + for w in [2u8, 3u8] { + let prefix = format!("vec{w} "); + if let Some(rest) = trimmed.strip_prefix(prefix.as_str()) { + if let Some(eq_pos) = rest.find('=') { + let name = rest[..eq_pos].trim(); + if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') { + declared = Some((w, name)); + } + } + } + } + if let Some((w, name)) = declared { + var_width.insert(name.to_string(), w); + if let Some(body) = trimmed.strip_suffix(';') { + if let Some(eq_pos) = body.find('=') { + let rhs = body[eq_pos + 1..].trim(); + if TEX_SAMPLE_CALLS.iter().any(|c| rhs.starts_with(c)) + && rhs.ends_with(')') + && w < 4 + { + let lhs = &body[..=eq_pos]; + return format!("{indent}{lhs} {rhs}{};", swizzle_prefix(w)); + } + // Wider operands inside a narrower declaration's expression: + // clipping_mask.frag computes `vec2 uv = (v_TexCoord * 2.0 - + // 1.0 - texScaleCenter) / ...` with a vec4 varying. HLSL + // truncates the wide side with a warning; GLSL rejects it. + let narrowed = truncate_wide_identifiers(rhs, w, var_width); + if narrowed != rhs { + let lhs = &body[..=eq_pos]; + return format!("{indent}{lhs} {narrowed};"); + } + } + } + } + + // Plain reassignment `name = ;` to an already-known narrower var. + if declared.is_none() { + if let Some(body) = trimmed.strip_suffix(';') { + if let Some(eq_pos) = body.find('=') { + let (lhs, rhs) = (body[..eq_pos].trim(), body[eq_pos + 1..].trim()); + if lhs.chars().all(|c| c.is_alphanumeric() || c == '_') { + if let Some(&w) = var_width.get(lhs) { + if w < 4 + && TEX_SAMPLE_CALLS.iter().any(|c| rhs.starts_with(c)) + && rhs.ends_with(')') + { + return format!("{indent}{lhs} = {rhs}{};", swizzle_prefix(w)); + } + } + } + } + } + } + + // Binary arithmetic against a `CASTn(...)` broadcast: `wide_var - CASTn(x)` where + // wide_var's known width exceeds n. Truncate wide_var's trailing components. + let mut out = splat_scalar_swizzles(trimmed, var_width); + for cast_w in [2u8, 3u8] { + let marker = format!("CAST{cast_w}("); + let mut search_from = 0; + while let Some(rel) = out[search_from..].find(marker.as_str()) { + let cast_pos = search_from + rel; + // Walk left over whitespace, then the binary operator, then whitespace, + // to find the end of the preceding operand. + let mut i = cast_pos; + let bytes = out.as_bytes(); + while i > 0 && bytes[i - 1] == b' ' { + i -= 1; + } + if i == 0 || !matches!(bytes[i - 1], b'-' | b'+' | b'*' | b'/') { + search_from = cast_pos + marker.len(); + continue; + } + i -= 1; // consume operator + while i > 0 && bytes[i - 1] == b' ' { + i -= 1; + } + let operand_end = i; + let mut start = i; + while start > 0 + && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') + { + start -= 1; + } + let operand = &out[start..operand_end]; + if let Some(&w) = var_width.get(operand) { + if w > cast_w && start > 0 && bytes[start - 1] != b'.' { + let swz = swizzle_prefix(cast_w); + out = format!("{}{}{}", &out[..operand_end], swz, &out[operand_end..]); + search_from = operand_end + swz.len() + marker.len(); + continue; + } + } + search_from = cast_pos + marker.len(); + } + } + format!("{indent}{out}") +} + +// Replace a reserved keyword with `replacement` only at word boundaries (i.e., not when +// adjacent to alphanumeric chars or underscore). Used to rename `sample` → `_wp_s`. +pub(super) fn rename_reserved_word(src: &str, keyword: &str, replacement: &str) -> String { + let klen = keyword.len(); + let sbytes = src.as_bytes(); + let kbytes = keyword.as_bytes(); + let mut out = String::with_capacity(src.len()); + let mut i = 0usize; + while i < sbytes.len() { + if sbytes[i..].starts_with(kbytes) { + let before_ok = i == 0 || !is_word_char(sbytes[i - 1]); + let after_ok = i + klen >= sbytes.len() || !is_word_char(sbytes[i + klen]); + if before_ok && after_ok { + out.push_str(replacement); + i += klen; + continue; + } + } + out.push(sbytes[i] as char); + i += 1; + } + out +} + +#[inline] +pub(super) fn is_word_char(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} diff --git a/src/engine/gpu/shaders/transpiler/tests.rs b/src/engine/gpu/shaders/transpiler/tests.rs new file mode 100644 index 0000000..128a9dd --- /dev/null +++ b/src/engine/gpu/shaders/transpiler/tests.rs @@ -0,0 +1,642 @@ +use super::*; + +/// Research check (not permanent coverage — depends on an untracked, +/// locally-downloaded Workshop sample that won't exist in CI): does the +/// real "godrays" effect's GLSL, exactly as authored by Valve/WE and +/// shipped in a real wallpaper, transpile through the *fully generic* +/// effect pipeline with zero wp-engine-specific code? See +/// ~/Applications/ghidra-dump/REPORT.md's volumetric-lighting follow-up +/// for why this matters: godrays is what real content actually uses for +/// light shafts, not the Light-object-driven `castvolumetrics` system. +#[test] +fn real_godrays_effect_transpiles_through_generic_pipeline() { + let wallpaper_dir = std::path::Path::new("/home/tupretiw/wp-engine/analysis/orig_1137423685"); + if !wallpaper_dir.exists() { + eprintln!("SKIP: analysis/ sample not present locally"); + return; + } + // No real WE assets dir in this sandbox — forces the embedded + // WE_COMMON_H/WE_COMMON_BLENDING_H fallback in loader.rs, exactly + // the path a Linux/macOS install without the Steam client hits too. + let resolver = super::super::resolver::AssetResolver::new(Some(wallpaper_dir), None); + + let cases: &[(&str, &str, &[(&str, i32)])] = &[ + ( + "effects/godrays_downsample2", + "effects/godrays_downsample2", + &[], + ), + ( + "effects/godrays_cast", + "effects/godrays_cast", + &[("CASTER", 0), ("SAMPLES", 0)], + ), + ( + "effects/godrays_gaussian_x", + "effects/godrays_gaussian", + &[("KERNEL", 1), ("VERTICAL", 0)], + ), + ( + "effects/godrays_gaussian_y", + "effects/godrays_gaussian", + &[("KERNEL", 1), ("VERTICAL", 1)], + ), + ( + "effects/godrays_combine", + "effects/godrays_combine", + &[("BLENDMODE", 9)], + ), + ]; + + let mut failures = Vec::new(); + for (label, shader_name, combo_pairs) in cases { + let (frag_glsl, vert_glsl) = match super::super::loader::load_glsl_shader_with_resolver( + &resolver, + shader_name, + None, + ) { + Ok(pair) => pair, + Err(e) => { + failures.push(format!("{label}: load failed: {e:#}")); + continue; + } + }; + let combos: std::collections::HashMap = combo_pairs + .iter() + .map(|(k, v)| (k.to_string(), *v)) + .collect(); + let model = crate::engine::model::ShaderModel::from_resolved_glsl( + label.to_string(), + frag_glsl, + combos, + crate::engine::model::WEBlending::Normal, + ); + if let Err(e) = translate_full(&model, Some(&vert_glsl)) { + failures.push(format!("{label}: {e:#}")); + } + } + assert!( + failures.is_empty(), + "godrays passes failed to transpile:\n{}", + failures.join("\n") + ); +} + +#[test] +fn unrolls_literal_indexed_array_varying() { + let src = "varying vec2 v_TexCoord[4];\nvarying vec2 v_TexCoordBase;\n\ + void main() {\n\ + \tv_TexCoord[0] = a;\n\tv_TexCoord[1] = b;\n\ + \tv_TexCoord[2] = c;\n\tv_TexCoord[3] = d;\n}\n"; + let out = unroll_array_varyings(src); + assert!(out.contains("varying vec2 v_TexCoord_0;")); + assert!(out.contains("varying vec2 v_TexCoord_1;")); + assert!(out.contains("varying vec2 v_TexCoord_2;")); + assert!(out.contains("varying vec2 v_TexCoord_3;")); + assert!(out.contains("varying vec2 v_TexCoordBase;")); + assert!(!out.contains('[')); + assert!(out.contains("v_TexCoord_0 = a;")); + assert!(out.contains("v_TexCoord_3 = d;")); +} + +#[test] +fn leaves_source_without_array_varyings_untouched() { + let src = "varying vec2 v_TexCoord;\nvoid main() { gl_FragColor = vec4(1.0); }\n"; + assert_eq!(unroll_array_varyings(src), src); +} + +#[test] +fn leaves_dynamically_sized_arrays_alone() { + let src = "varying vec2 v_TexCoord[LIGHTS_POINT];\n"; + let out = unroll_array_varyings(src); + assert_eq!(out, src); +} + +/// blur_downsample4.frag: a *fixed*-size array (`[4]`) but indexed by a +/// loop variable (`v_TexCoord[i]`), not literals. Unrolling only the +/// declaration here previously left `v_TexCoord[i]` referring to a name +/// that no longer existed ("undeclared identifier"), instead of leaving +/// the whole thing untouched for the honest "array varying not +/// supported" bail-out. +#[test] +fn leaves_loop_indexed_fixed_size_arrays_alone() { + let src = "varying vec2 v_TexCoord[4];\n\ + void main() {\n\ + \tfor (int i = 0; i < 4; ++i) {\n\ + \t\tvec4 s = texSample2D(g_Texture0, v_TexCoord[i]);\n\ + \t}\n}\n"; + let out = unroll_array_varyings(src); + assert_eq!(out, src); +} + +#[test] +fn unroll_simple_for_loops_expands_fixed_bound_loop() { + let src = "void main() {\n\tfor (int i = 0; i < 3; ++i) {\n\t\tx += arr[i];\n\t}\n}\n"; + let out = unroll_simple_for_loops(src); + assert!(!out.contains("for (")); + assert!(out.contains("x += arr[0];")); + assert!(out.contains("x += arr[1];")); + assert!(out.contains("x += arr[2];")); + assert!(!out.contains("arr[3]")); +} + +#[test] +fn unroll_simple_for_loops_handles_post_increment_and_less_equal() { + let src = "for (int i = 0; i <= 2; i++) { y += a[i]; }"; + let out = unroll_simple_for_loops(src); + assert!(out.contains("y += a[0];")); + assert!(out.contains("y += a[1];")); + assert!(out.contains("y += a[2];")); + assert!(!out.contains("a[3]")); +} + +#[test] +fn unroll_simple_for_loops_leaves_non_zero_start_untouched() { + let src = "for (int i = 1; i < 4; ++i) { x += arr[i]; }"; + assert_eq!(unroll_simple_for_loops(src), src); +} + +#[test] +fn unroll_simple_for_loops_leaves_non_literal_bound_untouched() { + let src = "for (int i = 0; i < N; ++i) { x += arr[i]; }"; + assert_eq!(unroll_simple_for_loops(src), src); +} + +#[test] +fn unroll_simple_for_loops_leaves_mismatched_loop_var_untouched() { + let src = "for (int i = 0; j < 4; ++i) { x += arr[i]; }"; + assert_eq!(unroll_simple_for_loops(src), src); +} + +#[test] +fn unroll_simple_for_loops_ignores_braces_inside_line_comments() { + let src = "for (int i = 0; i < 2; ++i) {\n\t// a stray brace: }\n\tx += arr[i];\n}\n"; + let out = unroll_simple_for_loops(src); + assert!(!out.contains("for (")); + assert!(out.contains("x += arr[0];")); + assert!(out.contains("x += arr[1];")); +} + +/// The exact real-world shape from blur_downsample4.frag: loop-unroll +/// then array-varying-unroll together must fully eliminate the array. +#[test] +fn loop_then_array_unroll_fully_resolves_real_world_pattern() { + let src = "varying vec2 v_TexCoord[4];\n\ + uniform sampler2D g_Texture0;\n\ + void main() {\n\ + \tfloat weight = 0.0;\n\ + \tvec4 result = CAST4(0.0);\n\ + \tfor (int i = 0; i < 4; ++i)\n\ + \t{\n\ + \t\tvec4 sample = texSample2D(g_Texture0, v_TexCoord[i]);\n\ + \t\tresult += sample * sample.a;\n\ + \t\tweight += sample.a;\n\ + \t}\n\ + \tgl_FragColor.rgb = result.rgb / max(0.001, weight);\n\ + }\n"; + let loop_unrolled = unroll_simple_for_loops(src); + let fully_unrolled = unroll_array_varyings(&loop_unrolled); + assert!(!fully_unrolled.contains('[')); + assert!(fully_unrolled.contains("varying vec2 v_TexCoord_0;")); + assert!(fully_unrolled.contains("varying vec2 v_TexCoord_3;")); + assert!(fully_unrolled.contains("v_TexCoord_0")); + assert!(fully_unrolled.contains("v_TexCoord_3")); +} + +/// The exact real-world case (nitro.frag): `max(0, albedo.rgb)` is wrong +/// two ways — GLSL only defines `genType max(genType, float)` (vector +/// first), and the literal must be a genuine float. Both need fixing. +#[test] +fn coerce_int_literal_fixes_bare_integer_and_swaps_order_in_max_call() { + let src = "gl_FragColor = vec4(max(0, albedo.rgb), albedo.a);"; + let out = coerce_int_literal_builtin_args(src); + assert_eq!(out, "gl_FragColor = vec4(max(albedo.rgb, 0.0), albedo.a);"); +} + +#[test] +fn coerce_int_literal_handles_min_and_clamp_and_negative() { + assert_eq!(coerce_int_literal_builtin_args("min(0, x)"), "min(x, 0.0)"); + assert_eq!( + coerce_int_literal_builtin_args("clamp(x, 0, 1)"), + "clamp(x, 0.0, 1.0)" + ); + assert_eq!( + coerce_int_literal_builtin_args("max(-1, x)"), + "max(x, -1.0)" + ); +} + +/// A literal that's already a float still needs reordering if it's in +/// the (invalid) first position — order matters independently of type. +/// `clamp`'s args are never reordered (not commutative), and calls that +/// aren't max/min/clamp at all are left alone entirely. +#[test] +fn coerce_int_literal_reorders_already_float_literal_and_ignores_other_calls() { + assert_eq!( + coerce_int_literal_builtin_args("max(0.0, x)"), + "max(x, 0.0)" + ); + assert_eq!( + coerce_int_literal_builtin_args("clamp(y, a, b)"), + "clamp(y, a, b)" + ); + assert_eq!(coerce_int_literal_builtin_args("foo(0, 1)"), "foo(0, 1)"); +} + +#[test] +fn coerce_int_literal_leaves_correctly_ordered_call_untouched() { + assert_eq!( + coerce_int_literal_builtin_args("max(albedo.rgb, 0.0)"), + "max(albedo.rgb, 0.0)" + ); +} + +#[test] +fn coerce_int_literal_handles_nested_calls() { + let src = "max(0, texture(tex, uv).rgb)"; + assert_eq!( + coerce_int_literal_builtin_args(src), + "max(texture(tex, uv).rgb, 0.0)" + ); +} + +#[test] +fn coerce_swizzle_mix_fixes_real_shift_hue_pattern() { + let src = "albedo.rgb = mix(albedo, newAlbedo, mask);"; + assert_eq!( + coerce_swizzle_mismatched_mix_arg(src), + "albedo.rgb = mix(albedo.rgb, newAlbedo, mask);" + ); +} + +#[test] +fn coerce_swizzle_mix_leaves_already_matching_types_untouched() { + let src = "color.xyz = mix(color.xyz, other, t);"; + assert_eq!(coerce_swizzle_mismatched_mix_arg(src), src); +} + +#[test] +fn coerce_swizzle_mix_ignores_unrelated_mix_calls() { + let src = "vec3 c = mix(a, b, t);"; + assert_eq!(coerce_swizzle_mismatched_mix_arg(src), src); +} + +#[test] +fn coerce_swizzle_mix_ignores_comparison_not_assignment() { + let src = "if (foo.rgb == mix(foo, bar, t)) { }"; + assert_eq!(coerce_swizzle_mismatched_mix_arg(src), src); +} + +#[test] +fn coerce_swizzle_mix_ignores_different_base_identifier() { + let src = "albedo.rgb = mix(other, newAlbedo, mask);"; + assert_eq!(coerce_swizzle_mismatched_mix_arg(src), src); +} + +/// godrays_gaussian.frag declares the same varying at three different +/// sizes under mutually-exclusive `#if KERNEL==N` branches; only one +/// (max-sized) declaration block should survive, with no duplicates. +#[test] +fn merges_same_name_declared_at_multiple_sizes_across_if_branches() { + let src = "#if KERNEL == 0\nvarying vec2 v_TexCoord[13];\n#endif\n\ + #if KERNEL == 1\nvarying vec2 v_TexCoord[7];\n#endif\n\ + #if KERNEL == 2\nvarying vec2 v_TexCoord[3];\n#endif\n\ + void main() {\n\ + #if KERNEL == 2\n\tvec2 a = v_TexCoord[0] + v_TexCoord[2];\n#endif\n}\n"; + let out = unroll_array_varyings(src); + assert_eq!(out.matches("varying vec2 v_TexCoord_0;").count(), 1); + assert_eq!(out.matches("varying vec2 v_TexCoord_12;").count(), 1); + assert!(!out.contains("v_TexCoord_13")); + assert!(out.contains("v_TexCoord_0 + v_TexCoord_2")); + assert!(!out.contains('[')); +} + +#[test] +fn shadowed_builtin_definitions_are_renamed_but_calls_are_not() { + // crt_scan_line.frag defines its own mod(); glslang rejects the + // precision-qualifier clash with the builtin. + let src = "float mod(float x, float y) {\n return x - y * floor(x/y);\n}\nfloat f() { return mod(a, b); }\n"; + let out = rename_shadowed_builtins(src); + assert!(out.contains("float _wp_mod(float x"), "{out}"); + assert!(out.contains("return _wp_mod(a, b)"), "{out}"); + // A shader that only CALLS frac keeps the macro name. + let call_only = "float f() {\n return frac(x);\n}\n"; + assert_eq!(rename_shadowed_builtins(call_only), call_only); +} + +#[test] +fn coerce_leaves_call_arguments_and_splats_scalar_swizzles() { + let mut w = HashMap::new(); + w.insert("v_TexCoord".to_string(), 4u8); + // contrast_based_sharpness: get_noise(vec4) must keep its vec4 argument. + let out = coerce_vector_widths("vec3 noise_color = get_noise(v_TexCoord);", &mut w); + assert!(!out.contains("v_TexCoord."), "{out}"); + // lens_flare_sun: `.xyz` on a float is a splat in WE's compiler. + let mut w2 = HashMap::new(); + let _ = coerce_vector_widths("float timer = sin(g_Time);", &mut w2); + let out2 = coerce_vector_widths("vec3 c = rotation + timer.xyz;", &mut w2); + assert!(out2.contains("vec3(timer)"), "{out2}"); +} + +#[test] +fn coerce_truncates_wide_varying_inside_narrow_declaration() { + // clipping_mask.frag: vec4 varying driving vec2 math (HLSL truncates). + let mut w = HashMap::new(); + w.insert("v_TexCoord".to_string(), 4u8); + let out = coerce_vector_widths( + "vec2 uvTex = ((v_TexCoord * 2.0 - 1.0 - texScaleCenter) / u_scale + 1.0) / 2.0;", + &mut w, + ); + assert!(out.contains("v_TexCoord.xy"), "{out}"); + // Already-swizzled and unknown identifiers stay untouched. + let out2 = coerce_vector_widths("vec2 b = v_TexCoord.zw + something;", &mut w); + assert!(!out2.contains(".zw.xy"), "{out2}"); +} + +#[test] +fn coerce_truncates_unswizzled_texture_sample_to_declared_width() { + let mut vw = HashMap::new(); + let out = coerce_vector_widths( + "vec3 albedo = texSample2D(g_Texture0, v_TexCoord.xy);", + &mut vw, + ); + assert_eq!( + out, + "vec3 albedo = texSample2D(g_Texture0, v_TexCoord.xy).xyz;" + ); + assert_eq!(vw.get("albedo"), Some(&3)); +} + +#[test] +fn coerce_truncates_plain_reassignment_using_tracked_width() { + let mut vw = HashMap::new(); + vw.insert("albedo".to_string(), 3u8); + let out = coerce_vector_widths("albedo = texSample2D(g_Texture0, uv);", &mut vw); + assert_eq!(out, "albedo = texSample2D(g_Texture0, uv).xyz;"); +} + +#[test] +fn coerce_truncates_wide_varying_against_cast_broadcast() { + let mut vw = HashMap::new(); + vw.insert("v_TexCoord".to_string(), 3u8); + let out = coerce_vector_widths( + "float scale = pow(length(abs(v_TexCoord - CAST2(u_offset)) * 1.0), 3.0);", + &mut vw, + ); + assert_eq!( + out, + "float scale = pow(length(abs(v_TexCoord.xy - CAST2(u_offset)) * 1.0), 3.0);" + ); +} + +#[test] +fn coerce_leaves_matching_widths_untouched() { + let mut vw = HashMap::new(); + vw.insert("v_TexCoord".to_string(), 2u8); + let line = "float scale = length(v_TexCoord - CAST2(u_offset));"; + assert_eq!(coerce_vector_widths(line, &mut vw), line); +} + +#[test] +fn coerce_leaves_swizzled_texture_sample_untouched() { + let mut vw = HashMap::new(); + let line = "vec3 albedo = texSample2D(g_Texture0, uv).rgb;"; + assert_eq!(coerce_vector_widths(line, &mut vw), line); +} + +#[test] +fn coerce_is_idempotent_on_already_truncated_operand() { + let mut vw = HashMap::new(); + vw.insert("v_TexCoord".to_string(), 3u8); + let once = coerce_vector_widths( + "float scale = length(v_TexCoord - CAST2(u_offset));", + &mut vw, + ); + let twice = coerce_vector_widths(&once, &mut vw); + assert_eq!(once, twice); +} + +/// The reference's applyLinkedVaryingCompatibility: a vertex-side +/// `varying vec2` matching a fragment-side `varying vec4` gets upgraded, +/// with whole-variable assignments wrapped in vec4(expr, 0.0, 1.0); +/// swizzled stores and unrelated varyings stay untouched. +#[test] +fn harmonize_upgrades_mismatched_varying_and_assignments() { + let vert = "varying vec2 v_TexCoord;\nvarying vec2 v_Other;\nvoid main() {\n\tv_TexCoord = a_TexCoord * 2.0;\n\tv_TexCoord.xy = a_TexCoord;\n\tv_Other = a_TexCoord;\n}\n"; + let frag = "varying vec4 v_TexCoord;\nvarying vec2 v_Other;\nvoid main() { gl_FragColor = v_TexCoord; }\n"; + let out = harmonize_varying_widths(vert, frag); + assert!(out.contains("varying vec4 v_TexCoord;")); + assert!(out.contains("v_TexCoord = vec4(a_TexCoord * 2.0, 0.0, 1.0);")); + assert!( + out.contains("v_TexCoord.xy = a_TexCoord;"), + "swizzled store untouched" + ); + assert!( + out.contains("varying vec2 v_Other;"), + "matching widths untouched" + ); + assert!(out.contains("v_Other = a_TexCoord;")); +} + +#[test] +fn harmonize_leaves_matched_interfaces_alone() { + let vert = "varying vec2 v_TexCoord;\nvoid main() { v_TexCoord = a_TexCoord; }\n"; + let frag = + "varying vec2 v_TexCoord;\nvoid main() { gl_FragColor = vec4(v_TexCoord, 0.0, 1.0); }\n"; + assert_eq!(harmonize_varying_widths(vert, frag), vert); +} + +#[test] +fn wrap_assignment_rhs_handles_for_loop_init() { + // The RHS must stop at the for-init's own `;`, and `<=` must not be + // mistaken for an assignment (blur_gaussian.frag's exact shape). + let line = "for (int i = -iterations; i <= iterations; i++)"; + let out = wrap_assignment_rhs(line, "int(", ")").unwrap(); + assert_eq!(out, "for (int i = int(-iterations); i <= iterations; i++)"); +} + +#[test] +fn wrap_assignment_rhs_skips_comparisons_but_wraps_compound_assign() { + // Comparisons are never assignment targets. + assert!(wrap_assignment_rhs("if (a == b) return;", "(", ").x").is_none()); + // Compound assignments ARE targets: `int x *= floatExpr` needs the RHS + // coerced just like a plain assignment (simple_audio_bars' `bar *= step`). + assert_eq!( + wrap_assignment_rhs("\tbarLeft *= step(a, b);", "int(", ")").as_deref(), + Some("\tbarLeft *= int(step(a, b));") + ); +} + +#[test] +fn repair_patches_lines_named_in_shaderc_errors() { + let glsl = "float pointer = g_PointerPosition.xy * u_pointerSpeed;\n\ + int i = -iterations;\n\ + vec3 c = 0.5;"; + let errs = "shader.frag:1: error: '=' : cannot convert from ' temp highp 2-component vector of float' to ' temp highp float'\n\ + shader.frag:2: error: '=' : cannot convert from ' temp highp float' to ' temp highp int'\n\ + shader.frag:3: error: '=' : cannot convert from ' const float' to ' temp highp 3-component vector of float'"; + let out = repair_assignment_conversions(glsl, errs).expect("should repair"); + assert!(out.contains("float pointer = (g_PointerPosition.xy * u_pointerSpeed).x;")); + assert!(out.contains("int i = int(-iterations);")); + assert!(out.contains("vec3 c = vec3(0.5);")); +} + +/// End-to-end: a shader with the HLSL-leniency patterns from real +/// workshop content (lens_flare_sun's vec2→float, blur_gaussian's +/// float→int loop bound) must compile after the repair loop. +#[test] +fn repairs_implicit_conversions_through_real_compile() { + let glsl = "#version 450\n\ + layout(location=0) out vec4 fragColor;\n\ + void main() {\n\ + \tvec2 v = vec2(1.5, 2.5);\n\ + \tfloat pointer = v * 2.0;\n\ + \tfloat iterations = 3.0;\n\ + \tfloat acc = 0.0;\n\ + \tfor (int i = -iterations; i <= iterations; i++) { acc += float(i) + pointer; }\n\ + \tfragColor = vec4(acc);\n\ + }\n"; + assert!(glsl_to_spirv(glsl, naga::ShaderStage::Fragment).is_err()); + glsl_to_spirv_with_repairs(glsl, naga::ShaderStage::Fragment) + .expect("repair loop should make this compile"); +} + +/// depth_map.frag branches on a float uniform; HLSL calls nonzero true. +#[test] +fn bool_condition_repair_wraps_float_conditions() { + let glsl = "#version 450\n\ + layout(location=0) out vec4 fragColor;\n\ + void main() {\n\ + \tfloat flag = 1.0;\n\ + \tfloat d = 0.25;\n\ + \tif(flag){ d = 1.0 - d; }\n\ + \tfragColor = vec4(d);\n\ + }\n"; + assert!(glsl_to_spirv(glsl, naga::ShaderStage::Fragment).is_err()); + glsl_to_spirv_with_repairs(glsl, naga::ShaderStage::Fragment) + .expect("repair loop should make this compile"); +} + +/// `pow(vec3, float)` (color_grading.frag's shape) is illegal in strict +/// GLSL; the `_wp_pow` overload shims must let it translate. +#[test] +fn pow_scalar_broadcast_translates() { + let src = "uniform sampler2D g_Texture0;\n\ + varying vec2 v_TexCoord;\n\ + void main() {\n\ + \tvec3 c = pow(texSample2D(g_Texture0, v_TexCoord).rgb, 2.2);\n\ + \tgl_FragColor = vec4(c, 1.0);\n\ + }\n"; + let model = ShaderModel::from_resolved_glsl( + "pow_test".to_string(), + src.to_string(), + HashMap::new(), + crate::engine::model::shader_model::WEBlending::Normal, + ); + translate(&model).expect("pow(vec3, float) should translate via _wp_pow shims"); +} + +/// The embedded common_perspective.h body (squareToQuad) must be valid +/// GLSL: a vertex shader shaped like the `perspective` effect's vert — +/// `inverse(squareToQuad(p0..p3)) * vec3(uv,1)` — compiles with it inlined. +/// Without the body squareToQuad is undefined and this fails, which is the +/// exact macOS case the embedded-header fallback repairs. +#[test] +fn embedded_perspective_header_compiles() { + let glsl = format!( + "#version 450\n\ + layout(location=0) in vec3 a_Position;\n\ + layout(location=1) in vec2 a_TexCoord;\n\ + layout(location=0) out vec3 v_TexCoord;\n\ + {WE_COMMON_PERSPECTIVE_H}\n\ + void main() {{\n\ + \tmat3 xform = inverse(squareToQuad(vec2(0.0), vec2(1.0, 0.0), vec2(1.0), vec2(0.0, 1.0)));\n\ + \tv_TexCoord = xform * vec3(a_TexCoord, 1.0);\n\ + \tgl_Position = vec4(a_Position, 1.0);\n\ + }}\n" + ); + glsl_to_spirv_with_repairs(&glsl, naga::ShaderStage::Vertex) + .expect("perspective vert with embedded squareToQuad should compile"); +} + +#[test] +fn int_assigned_float_step_repairs() { + let glsl = "#version 450\n\ + layout(location=0) out vec4 fragColor;\n\ + void main() {\n\ + \tfloat a = 0.3; float b = 0.7;\n\ + \tint barLeft = step(a, b);\n\ + \tfragColor = vec4(float(barLeft));\n\ + }\n"; + assert!(glsl_to_spirv(glsl, naga::ShaderStage::Fragment).is_err()); + glsl_to_spirv_with_repairs(glsl, naga::ShaderStage::Fragment) + .expect("int = step(float) should repair via int() wrap"); +} + +#[test] +fn rewrite_modulo_handles_real_audio_bar_patterns() { + // simple_audio_bars.frag's two shapes. + assert_eq!( + rewrite_modulo_on_line("\tuint barFreq1 = frequency % RESOLUTION;").as_deref(), + Some("\tuint barFreq1 = mod(frequency, RESOLUTION);") + ); + assert_eq!( + rewrite_modulo_on_line("\tuint barFreq2 = (barFreq1 + 1) % RESOLUTION;").as_deref(), + Some("\tuint barFreq2 = mod((barFreq1 + 1), RESOLUTION);") + ); +} + +#[test] +fn rewrite_modulo_leaves_compound_assign_and_plain_lines() { + assert_eq!(rewrite_modulo_on_line("\tx %= 4;"), None); + assert_eq!(rewrite_modulo_on_line("\tfloat y = a + b;"), None); +} + +/// End-to-end: a `float % int` shader (int-only `%` in strict GLSL) must +/// compile through the repair loop, mirroring simple_audio_bars. +#[test] +fn float_modulo_repairs_through_real_compile() { + let glsl = "#version 450\n\ + layout(location=0) out vec4 fragColor;\n\ + void main() {\n\ + \tfloat frequency = 6.5;\n\ + \tuint bar = frequency % 4;\n\ + \tfragColor = vec4(float(bar));\n\ + }\n"; + assert!(glsl_to_spirv(glsl, naga::ShaderStage::Fragment).is_err()); + glsl_to_spirv_with_repairs(glsl, naga::ShaderStage::Fragment) + .expect("float % int + float->uint assign should repair"); +} + +/// A shader reading `g_AudioSpectrum64Left` must translate to WGSL with the +/// array backed by a live uniform binding (not a zero `const`), and the +/// result must flag `uses_audio` so the renderer starts capture. +#[test] +fn audio_spectrum_becomes_live_uniform() { + let src = "uniform sampler2D g_Texture0;\n\ + uniform float g_AudioSpectrum64Left[64];\n\ + varying vec2 v_TexCoord;\n\ + void main() {\n\ + \tint i = int(v_TexCoord.x * 64.0);\n\ + \tfloat amp = g_AudioSpectrum64Left[i];\n\ + \tgl_FragColor = vec4(amp, amp, amp, 1.0);\n\ + }\n"; + let model = ShaderModel::from_resolved_glsl( + "audio_test".to_string(), + src.to_string(), + HashMap::new(), + crate::engine::model::shader_model::WEBlending::Normal, + ); + let t = translate(&model).expect("audio spectrum shader should translate"); + assert!( + t.uses_audio, + "uses_audio must be set for g_AudioSpectrum reads" + ); + // The array is now a bound uniform member, so the emitted WGSL must not + // contain a zero-const fallback for it. + assert!( + !t.wgsl.contains("array(0.0"), + "audio array must not be a zero const:\n{}", + t.wgsl + ); +} diff --git a/src/engine/shaders/uniform_meta.rs b/src/engine/gpu/shaders/uniform_meta.rs similarity index 100% rename from src/engine/shaders/uniform_meta.rs rename to src/engine/gpu/shaders/uniform_meta.rs diff --git a/src/engine/shaders/util_textures.rs b/src/engine/gpu/shaders/util_textures.rs similarity index 98% rename from src/engine/shaders/util_textures.rs rename to src/engine/gpu/shaders/util_textures.rs index ae01466..cf09726 100644 --- a/src/engine/shaders/util_textures.rs +++ b/src/engine/gpu/shaders/util_textures.rs @@ -2,7 +2,7 @@ use image::RgbaImage; use std::collections::HashMap; use std::path::Path; -use crate::engine::tex::TexFile; +use crate::formats::tex::TexFile; pub fn load_utility_textures(assets_dir: &Path) -> HashMap { let mut textures = HashMap::new(); diff --git a/src/engine/shadow.rs b/src/engine/gpu/shadow.rs similarity index 99% rename from src/engine/shadow.rs rename to src/engine/gpu/shadow.rs index 3b1e15c..89ffdaa 100644 --- a/src/engine/shadow.rs +++ b/src/engine/gpu/shadow.rs @@ -15,8 +15,8 @@ //! though WE likely warps that projection (dual-paraboloid or similar) for //! wider-than-hemisphere coverage, which this doesn't attempt to replicate. -use crate::engine::camera3d::{self, Mat4}; -use crate::engine::render::Mesh3dLayer; +use crate::engine::model::resolve::Mesh3dLayer; +use crate::engine::sim::camera3d::{self, Mat4}; /// Fixed per-light shadow-map tile size. See module docs: WE's real tile /// size is a per-light heuristic not recovered from the binary. diff --git a/src/engine/text.rs b/src/engine/gpu/text.rs similarity index 99% rename from src/engine/text.rs rename to src/engine/gpu/text.rs index 41436b3..8787bf9 100644 --- a/src/engine/text.rs +++ b/src/engine/gpu/text.rs @@ -215,7 +215,7 @@ pub fn with_background( pub fn resolve_font_data( font_ref: Option<&str>, dir: Option<&std::path::Path>, - pkg: Option<&super::pkg::Package>, + pkg: Option<&crate::formats::pkg::Package>, ) -> Option> { if let Some(font_ref) = font_ref { if !font_ref.is_empty() && !font_ref.starts_with("systemfont_") { diff --git a/src/engine/gpu_renderer.rs b/src/engine/gpu_renderer.rs deleted file mode 100644 index 62718c5..0000000 --- a/src/engine/gpu_renderer.rs +++ /dev/null @@ -1,7419 +0,0 @@ -//! GPU scene renderer: pipelines, effect/FBO pass chaining, and the -//! per-scene [`GpuSceneInstance`] runtime. -//! -//! Pass model (mirrors linux-wallpaperengine's CWallpaper/CPass chain): -//! - every layer's base texture runs through its effect passes, ping-ponging -//! between two persistent scene-sized render targets; -//! - effect passes may render into named FBOs (`effect.json` `fbos` + -//! `target`) and re-bind them as texture slots (`bind`); -//! - each layer is then composited into the scene target with its blend mode, -//! camera-dynamics UV offset (shake + parallax) and fade opacity; -//! - when scene bloom is enabled a threshold → blur chain runs over -//! quarter/eighth-res buffers (`_rt_4FrameBuffer` / `_rt_8FrameBuffer`) -//! and is added back onto the scene target. - -use anyhow::{anyhow, Context, Result}; -use image::RgbaImage; -use std::collections::HashMap; -use std::path::Path; -use std::sync::mpsc::SyncSender; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use crate::engine::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; -use crate::engine::fbo::RenderTargetPool; -use crate::engine::model::{ShaderModel, WEBlending}; -use crate::engine::particle; -use crate::engine::scene::{parse_value_bool, parse_value_f32}; -use crate::engine::shaders::{effect_def, loader, resolver, transpiler}; -use crate::platform::GpuDevice; - -const SHADER_SRC: &str = include_str!("shaders/gpu_shaders.wgsl"); - -/// Per-layer ping-pong buffer key in the FBO pool (each layer's effect chain -/// gets its own pair, sized to that layer's object size). -fn pingpong_key(layer_idx: usize, slot: u8) -> String { - format!("_rt_pingpong{slot}_{layer_idx}") -} - -/// Built-in effects with handwritten WGSL kernels. -const HARDCODED_EFFECTS: &[&str] = &[ - "pulse", - "scroll", - "shake", - "tint", - "opacity", - "waterripple", - "waterwaves", - "spin", -]; - -pub struct GpuSceneRenderer { - device: wgpu::Device, - queue: wgpu::Queue, - /// Indexed by `(no_interpolation, clamp_uvs)` — the `.tex` `NoInterpolation` - /// and `ClampUVs`/`ClampUVsBorder` flags select nearest-vs-linear filtering - /// and clamp-vs-repeat addressing per layer. - samplers: [wgpu::Sampler; 4], - base_bgl: wgpu::BindGroupLayout, - effect_bgl: wgpu::BindGroupLayout, - /// The g_AudioSpectrum* UBO (effect_bgl binding 1), rewritten each frame - /// from the FFT. Zero-filled when no audio is captured. - audio_buf: wgpu::Buffer, - composite_pipelines: Vec<(u32, wgpu::RenderPipeline)>, - /// Same vs_composite_quad/fs_composite pair as `composite_pipelines[0]` - /// but with blending disabled (plain overwrite): used for the intra-chain - /// base material pass, which always renders into a freshly-cleared, - /// transparent object FBO. Reusing the alpha-blended composite pipeline - /// there would double-attenuate rgb by alpha (once via the shader's own - /// `s.rgb * opacity` and again via the GPU blend equation). - base_pass_pipeline: wgpu::RenderPipeline, - effect_pipelines: Vec<(&'static str, wgpu::RenderPipeline)>, - dynamic_pipelines: HashMap, - // pipeline key → UBO layout (uniform key, glsl_type, typed default) - dynamic_uniform_keys: HashMap>, - // pipeline key → vertex attributes (glsl type, name); empty = fullscreen - // triangle with no vertex buffers (synthetic VS path) - dynamic_vertex_attrs: HashMap>, - dynamic_textures: HashMap>, - // (kind, dims) → shared quad attribute buffer (kind: 0=pos NDC, 1=uv, 2=zero) - attr_buffers: HashMap<(u8, u8), wgpu::Buffer>, - composite_pipeline_layout: wgpu::PipelineLayout, - effect_pipeline_layout: wgpu::PipelineLayout, - shader_module: wgpu::ShaderModule, - dummy_tex: wgpu::Texture, - // Scene bloom chain - bloom_threshold_pipeline: wgpu::RenderPipeline, - bloom_blur_pipeline: wgpu::RenderPipeline, - bloom_combine_pipeline: wgpu::RenderPipeline, - // Format → blit pipeline (surface presentation, FBO up/down-sampling) - blit_pipelines: HashMap, - // GPU particle pipeline (vs_particles/fs_particles): storage-buffer - // vertices, texture-array sprites, hardware blending straight into the - // scene target — replaces the budgeted CPU particle rasterizer. - particle_bgl: wgpu::BindGroupLayout, - particle_pipeline_add: wgpu::RenderPipeline, - particle_pipeline_over: wgpu::RenderPipeline, - // Static 3D mesh pipeline (vs_mesh3d/fs_mesh3d): real indexed geometry - // through the scene camera, and the only pipeline here with a depth - // buffer — a sphere self-occludes, which no painter's ordering can fake. - mesh3d_bgl: wgpu::BindGroupLayout, - /// Indexed by the material's `nocull`: back-face culling is what makes a - /// skybox work (its near hemisphere culls away instead of hiding the - /// scene inside it), while `"cullmode": "nocull"` materials — hollow - /// shells meant to be seen from both sides — must keep every face. - mesh3d_pipelines: [wgpu::RenderPipeline; 4], - // Shadow-caster depth pass: see `engine::shadow` and - // `GpuSceneInstance::build_shadow_atlas`. - shadow_pass_bgl: wgpu::BindGroupLayout, - mesh3d_shadow_pipeline: wgpu::RenderPipeline, - shadow_comparison_sampler: wgpu::Sampler, - // Volumetric-light-shaft ray march: see `fs_volumetrics`, - // `GpuSceneInstance::build_volumetrics`/`record_volumetrics`. - volumetrics_bgl: wgpu::BindGroupLayout, - volumetrics_pipeline: wgpu::RenderPipeline, - // Perspective-projected 2D quads: see `vs_quad3d`/`fs_quad3d`. - quad3d_bgl: wgpu::BindGroupLayout, - quad3d_pipeline: wgpu::RenderPipeline, -} - -/// Depth format for the 3D mesh pass. Depth32Float is guaranteed everywhere -/// wgpu runs, so there's no fallback to pick. -const MESH3D_DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; - -impl GpuSceneRenderer { - pub fn new(gpu: GpuDevice) -> Result { - let GpuDevice { device, queue, .. } = gpu; - Self::with_device(device, queue) - } - - pub fn with_device(device: wgpu::Device, queue: wgpu::Queue) -> Result { - let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("scene_shaders"), - source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()), - }); - - let make_sampler = |no_interpolation: bool, clamp: bool| { - let filter = if no_interpolation { - wgpu::FilterMode::Nearest - } else { - wgpu::FilterMode::Linear - }; - let address = if clamp { - wgpu::AddressMode::ClampToEdge - } else { - wgpu::AddressMode::Repeat - }; - device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("scene_sampler"), - mag_filter: filter, - min_filter: filter, - address_mode_u: address, - address_mode_v: address, - ..Default::default() - }) - }; - // Index by (no_interpolation as usize) << 1 | (clamp as usize). - let samplers = [ - make_sampler(false, false), - make_sampler(false, true), - make_sampler(true, false), - make_sampler(true, true), - ]; - - let tex_entry = |binding: u32| wgpu::BindGroupLayoutEntry { - binding, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - multisampled: false, - view_dimension: wgpu::TextureViewDimension::D2, - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - }, - count: None, - }; - let base_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("base_bgl"), - entries: &[ - tex_entry(0), - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - // VERTEX too: vs_composite_quad reads the object rect. - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - // Extra texture slots for multi-texture effects (g_Texture1..7, - // bound at N+2 — 7 is the highest the workshop corpus uses). - tex_entry(3), - tex_entry(4), - tex_entry(5), - tex_entry(6), - tex_entry(7), - tex_entry(8), - tex_entry(9), - ], - }); - - let effect_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("effect_bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - // VERTEX too: real WE vertex shaders read the shared UBO - // (g_ModelViewProjectionMatrix, animation params, ...). - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - // binding 1: the g_AudioSpectrum* storage buffer, updated per - // frame from the FFT. Read-only storage (not uniform) because a - // `float[N]` needs WGSL-illegal 16-byte uniform array stride; - // storage packs tightly. Always present so every effect - // pipeline's layout matches; audio shaders read it in the - // fragment stage, others ignore the binding. - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - ], - }); - - let audio_buf = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("audio_spectrum"), - size: crate::engine::audio::UNIFORM_BYTES as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - let composite_pipeline_layout = - device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("composite_layout"), - bind_group_layouts: &[&base_bgl], - push_constant_ranges: &[], - }); - - // Normal (colorBlendMode == 0) layers use hardware alpha blending. - // Any other colorBlendMode is a Photoshop-style blend that needs to - // read the destination in-shader (see fs_composite_blend), so that - // pipeline writes its already-blended result straight through. - let mut composite_pipelines = Vec::new(); - composite_pipelines.push(( - 0u32, - Self::create_pipeline( - &device, - &composite_pipeline_layout, - &shader_module, - "vs_composite_quad", - "fs_composite", - "composite_normal", - Some(wgpu::BlendState::ALPHA_BLENDING), - wgpu::TextureFormat::Rgba8Unorm, - ), - )); - composite_pipelines.push(( - 1u32, - Self::create_pipeline( - &device, - &composite_pipeline_layout, - &shader_module, - "vs_composite_quad", - "fs_composite_blend", - "composite_blend", - Some(wgpu::BlendState::REPLACE), - wgpu::TextureFormat::Rgba8Unorm, - ), - )); - // Base material pass: same quad/tint shader, no blending — the - // destination (a layer's own FBO) is always freshly cleared to - // transparent, so this is a plain "write the tinted pixel" pass, not - // a composite (see BlendingMode_Normal == GL_ONE,GL_ZERO in CPass.cpp). - let base_pass_pipeline = Self::create_pipeline( - &device, - &composite_pipeline_layout, - &shader_module, - "vs_composite_quad", - "fs_composite", - "base_material_pass", - None, - wgpu::TextureFormat::Rgba8Unorm, - ); - - let effect_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("effect_layout"), - bind_group_layouts: &[&base_bgl, &effect_bgl], - push_constant_ranges: &[], - }); - - let effect_entry_points: Vec<(&str, &str)> = HARDCODED_EFFECTS - .iter() - .map(|n| { - ( - *n, - match *n { - "pulse" => "fs_pulse", - "scroll" => "fs_scroll", - "shake" => "fs_shake", - "tint" => "fs_tint", - "opacity" => "fs_opacity", - "waterripple" => "fs_waterripple", - "waterwaves" => "fs_waterwaves", - "spin" => "fs_spin", - _ => "fs_composite", - }, - ) - }) - .collect(); - - let mut effect_pipelines = Vec::new(); - for (name, entry) in &effect_entry_points { - let pipeline = Self::create_pipeline( - &device, - &effect_layout, - &shader_module, - "vs_fullscreen", - entry, - name, - None, - wgpu::TextureFormat::Rgba8Unorm, - ); - effect_pipelines.push((*name, pipeline)); - } - - // Bloom chain pipelines (all render into Rgba8Unorm pool targets). - let bloom_threshold_pipeline = Self::create_pipeline( - &device, - &effect_layout, - &shader_module, - "vs_fullscreen", - "fs_bloom_threshold", - "bloom_threshold", - None, - wgpu::TextureFormat::Rgba8Unorm, - ); - let bloom_blur_pipeline = Self::create_pipeline( - &device, - &effect_layout, - &shader_module, - "vs_fullscreen", - "fs_blur9", - "bloom_blur", - None, - wgpu::TextureFormat::Rgba8Unorm, - ); - // fs_bloom_combine now samples the pre-bloom scene copy itself and - // adds bloom in-shader (matching combine.frag), so this pass is a - // plain overwrite (WE's `combine.json` pass uses default/"normal" - // blending, not a GPU-side additive blend). - let bloom_combine_pipeline = Self::create_pipeline( - &device, - &effect_layout, - &shader_module, - "vs_fullscreen", - "fs_bloom_combine", - "bloom_combine", - None, - wgpu::TextureFormat::Rgba8Unorm, - ); - - let particle_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("particle_bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - multisampled: false, - view_dimension: wgpu::TextureViewDimension::D2Array, - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - ], - }); - let particle_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("particle_layout"), - bind_group_layouts: &[&particle_bgl], - push_constant_ranges: &[], - }); - // Additive: the reference draws particle quads with - // glBlendFuncSeparate(SRC_ALPHA, ONE, SRC_ALPHA, ONE) (CPass.cpp). - let particle_add_blend = wgpu::BlendState { - color: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::SrcAlpha, - dst_factor: wgpu::BlendFactor::One, - operation: wgpu::BlendOperation::Add, - }, - alpha: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::SrcAlpha, - dst_factor: wgpu::BlendFactor::One, - operation: wgpu::BlendOperation::Add, - }, - }; - let particle_over_blend = wgpu::BlendState { - color: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::SrcAlpha, - dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, - operation: wgpu::BlendOperation::Add, - }, - alpha: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::One, - dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, - operation: wgpu::BlendOperation::Add, - }, - }; - let particle_pipeline_add = Self::create_pipeline( - &device, - &particle_layout, - &shader_module, - "vs_particles", - "fs_particles", - "particles_add", - Some(particle_add_blend), - wgpu::TextureFormat::Rgba8Unorm, - ); - let particle_pipeline_over = Self::create_pipeline( - &device, - &particle_layout, - &shader_module, - "vs_particles", - "fs_particles", - "particles_over", - Some(particle_over_blend), - wgpu::TextureFormat::Rgba8Unorm, - ); - - let mesh3d_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("mesh3d_bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - multisampled: false, - view_dimension: wgpu::TextureViewDimension::D2, - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 3, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - // Shared shadow atlas (one texture, every shadow-casting - // light's tile packed into it — see `engine::shadow`) plus - // its comparison sampler, for `textureSampleCompareLevel` PCF - // lookups in `fs_mesh3d`. - wgpu::BindGroupLayoutEntry { - binding: 4, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - multisampled: false, - view_dimension: wgpu::TextureViewDimension::D2, - sample_type: wgpu::TextureSampleType::Depth, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 5, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), - count: None, - }, - // Spot-light cookie texture (`usecookie`/`cookie`) — a - // harmless 1x1 white dummy when no light has one, same - // "always-valid dummy binding" precedent as `shadow_atlas`. - wgpu::BindGroupLayoutEntry { - binding: 6, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - multisampled: false, - view_dimension: wgpu::TextureViewDimension::D2, - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 7, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let mesh3d_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("mesh3d_layout"), - bind_group_layouts: &[&mesh3d_bgl], - push_constant_ranges: &[], - }); - let make_mesh3d_pipeline = |cull: Option, depth: bool, label: &str| { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(label), - layout: Some(&mesh3d_layout), - vertex: wgpu::VertexState { - module: &shader_module, - entry_point: Some("vs_mesh3d"), - buffers: &[wgpu::VertexBufferLayout { - array_stride: 32, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x3], - }], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader_module, - entry_point: Some("fs_mesh3d"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - cull_mode: cull, - ..Default::default() - }, - depth_stencil: Some(wgpu::DepthStencilState { - format: MESH3D_DEPTH_FORMAT, - depth_write_enabled: depth, - depth_compare: if depth { - wgpu::CompareFunction::Less - } else { - wgpu::CompareFunction::Always - }, - stencil: Default::default(), - bias: Default::default(), - }), - multisample: Default::default(), - multiview: None, - cache: None, - }) - }; - // Indexed by `nocull as usize | (!depthtest as usize) << 1`. - let mesh3d_pipelines = [ - make_mesh3d_pipeline(Some(wgpu::Face::Back), true, "mesh3d"), - make_mesh3d_pipeline(None, true, "mesh3d_nocull"), - make_mesh3d_pipeline(Some(wgpu::Face::Back), false, "mesh3d_nodepth"), - make_mesh3d_pipeline(None, false, "mesh3d_nocull_nodepth"), - ]; - - // Shadow-caster depth pass: renders every mesh3d object's geometry, - // position-only, into one tile of the shared shadow atlas (see - // `engine::shadow` and `GpuSceneInstance::build_shadow_atlas`). No - // fragment stage — depth-only. No back-face culling: a caster should - // block light through any face, not just the ones the main camera - // would see. - let shadow_pass_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("shadow_pass_bgl"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }], - }); - let shadow_pass_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("shadow_pass_layout"), - bind_group_layouts: &[&shadow_pass_bgl], - push_constant_ranges: &[], - }); - let mesh3d_shadow_pipeline = - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("mesh3d_shadow"), - layout: Some(&shadow_pass_layout), - vertex: wgpu::VertexState { - module: &shader_module, - entry_point: Some("vs_shadow_depth"), - // Same buffer the main mesh3d pipeline uses (stride 32, - // pos+uv+normal) — only position is declared/read here, but - // the stride must still match the real per-vertex layout. - buffers: &[wgpu::VertexBufferLayout { - array_stride: 32, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &wgpu::vertex_attr_array![0 => Float32x3], - }], - compilation_options: Default::default(), - }, - fragment: None, - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - cull_mode: None, - ..Default::default() - }, - depth_stencil: Some(wgpu::DepthStencilState { - format: MESH3D_DEPTH_FORMAT, - depth_write_enabled: true, - depth_compare: wgpu::CompareFunction::Less, - stencil: Default::default(), - bias: Default::default(), - }), - multisample: Default::default(), - multiview: None, - cache: None, - }); - let shadow_comparison_sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("shadow_comparison_sampler"), - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - address_mode_u: wgpu::AddressMode::ClampToEdge, - address_mode_v: wgpu::AddressMode::ClampToEdge, - compare: Some(wgpu::CompareFunction::LessEqual), - ..Default::default() - }); - - // Volumetric-light-shaft ray march (`fs_volumetrics`): its own small - // bind group layout rather than extending `base_bgl`/`effect_bgl` — - // it needs the shadow atlas + a comparison sampler, which those - // don't declare, and nothing else. Downstream blur/combine passes - // reuse the existing bloom pipelines unchanged (both are already - // generic single-source-texture operations — see `record_volumetrics`). - let volumetrics_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("volumetrics_bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - multisampled: false, - view_dimension: wgpu::TextureViewDimension::D2, - sample_type: wgpu::TextureSampleType::Depth, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), - count: None, - }, - ], - }); - let volumetrics_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("volumetrics_layout"), - bind_group_layouts: &[&volumetrics_bgl], - push_constant_ranges: &[], - }); - let volumetrics_pipeline = Self::create_pipeline( - &device, - &volumetrics_layout, - &shader_module, - "vs_fullscreen", - "fs_volumetrics", - "volumetrics", - None, - wgpu::TextureFormat::Rgba8Unorm, - ); - - // Perspective-projected 2D quads (true silhouette, not the AABB - // `project_quad_ndc` collapses to) — see `vs_quad3d`/`fs_quad3d` in - // gpu_shaders.wgsl and the Ghidra report's quad-warp finding. Its - // own small bind group layout rather than extending the widely - // shared `base_bgl`: `Quad3DParams` (corners) has nothing to do with - // any other pass that layout serves. - let quad3d_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("quad3d_bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - tex_entry(1), - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let quad3d_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("quad3d_layout"), - bind_group_layouts: &[&quad3d_bgl], - push_constant_ranges: &[], - }); - let quad3d_pipeline = Self::create_pipeline( - &device, - &quad3d_layout, - &shader_module, - "vs_quad3d", - "fs_quad3d", - "quad3d", - Some(wgpu::BlendState::ALPHA_BLENDING), - wgpu::TextureFormat::Rgba8Unorm, - ); - - let dummy_tex = Self::create_white_1x1_texture(&device, &queue); - - Ok(Self { - device, - queue, - samplers, - base_bgl, - effect_bgl, - audio_buf, - composite_pipelines, - base_pass_pipeline, - effect_pipelines, - dynamic_pipelines: HashMap::new(), - dynamic_uniform_keys: HashMap::new(), - dynamic_vertex_attrs: HashMap::new(), - dynamic_textures: HashMap::new(), - attr_buffers: HashMap::new(), - composite_pipeline_layout, - effect_pipeline_layout: effect_layout, - shader_module, - dummy_tex, - bloom_threshold_pipeline, - bloom_blur_pipeline, - bloom_combine_pipeline, - blit_pipelines: HashMap::new(), - mesh3d_bgl, - mesh3d_pipelines, - shadow_pass_bgl, - mesh3d_shadow_pipeline, - shadow_comparison_sampler, - volumetrics_bgl, - volumetrics_pipeline, - quad3d_bgl, - quad3d_pipeline, - particle_bgl, - particle_pipeline_add, - particle_pipeline_over, - }) - } - - pub fn device(&self) -> &wgpu::Device { - &self.device - } - pub fn queue(&self) -> &wgpu::Queue { - &self.queue - } - - fn create_white_1x1_texture(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::Texture { - let tex = device.create_texture(&wgpu::TextureDescriptor { - label: Some("dummy_white"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - queue.write_texture( - tex.as_image_copy(), - &[255u8, 255, 255, 255], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4), - rows_per_image: Some(1), - }, - wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - ); - tex - } - - #[allow(clippy::too_many_arguments)] - fn create_pipeline( - device: &wgpu::Device, - layout: &wgpu::PipelineLayout, - module: &wgpu::ShaderModule, - vs_entry: &str, - fs_entry: &str, - label: &str, - blend: Option, - format: wgpu::TextureFormat, - ) -> wgpu::RenderPipeline { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(label), - layout: Some(layout), - vertex: wgpu::VertexState { - module, - entry_point: Some(vs_entry), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module, - entry_point: Some(fs_entry), - targets: &[Some(wgpu::ColorTargetState { - format, - blend, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - multisample: Default::default(), - multiview: None, - cache: None, - }) - } - - #[allow(clippy::too_many_arguments)] - fn create_pipeline_split_modules( - device: &wgpu::Device, - layout: &wgpu::PipelineLayout, - vs_module: &wgpu::ShaderModule, - vs_entry: &str, - fs_module: &wgpu::ShaderModule, - fs_entry: &str, - label: &str, - blend: Option, - vertex_layouts: &[wgpu::VertexBufferLayout<'_>], - ) -> wgpu::RenderPipeline { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(label), - layout: Some(layout), - vertex: wgpu::VertexState { - module: vs_module, - entry_point: Some(vs_entry), - buffers: vertex_layouts, - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: fs_module, - entry_point: Some(fs_entry), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - multisample: Default::default(), - multiview: None, - cache: None, - }) - } - - #[allow(clippy::too_many_arguments)] - pub fn add_dynamic_pipeline( - &mut self, - key: String, - wgsl: &str, - vert_wgsl: Option<&str>, - uniform_keys: Vec, - attributes: &[(String, String)], - blend: Option, - ) -> Result<()> { - use std::panic::AssertUnwindSafe; - let wgsl_owned: std::borrow::Cow<'static, str> = wgsl.to_string().into(); - let label = key.clone(); - let device = &self.device; - let fs_module = std::panic::catch_unwind(AssertUnwindSafe(|| { - device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some(label.as_str()), - source: wgpu::ShaderSource::Wgsl(wgsl_owned), - }) - })) - .map_err(|e| { - let msg = e - .downcast_ref::() - .cloned() - .or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())) - .unwrap_or_else(|| "(unknown)".into()); - anyhow!("shader module panicked for '{key}': {msg}") - })?; - - // One vertex buffer per attribute; formats follow the GLSL types. - let attr_descs: Vec<[wgpu::VertexAttribute; 1]> = attributes - .iter() - .enumerate() - .map(|(i, (ty, _))| { - [wgpu::VertexAttribute { - format: vertex_format_for(ty), - offset: 0, - shader_location: i as u32, - }] - }) - .collect(); - let vertex_layouts: Vec = attr_descs - .iter() - .map(|attrs| wgpu::VertexBufferLayout { - array_stride: attrs[0].format.size(), - step_mode: wgpu::VertexStepMode::Vertex, - attributes: attrs, - }) - .collect(); - - let device = &self.device; - let layout = &self.effect_pipeline_layout; - let base = &self.shader_module; - let label2 = key.clone(); - let vert_src_owned: Option = vert_wgsl.map(|s| s.to_string()); - let pipeline = std::panic::catch_unwind(AssertUnwindSafe(|| { - if let Some(ref vert_src) = vert_src_owned { - let vert_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some(&format!("{label2}-vs")), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(vert_src.as_str())), - }); - Self::create_pipeline_split_modules( - device, - layout, - &vert_module, - "main", - &fs_module, - "main", - &label2, - blend, - &vertex_layouts, - ) - } else { - Self::create_pipeline_split_modules( - device, - layout, - base, - "vs_we_effect", - &fs_module, - "main", - &label2, - blend, - &[], - ) - } - })) - .map_err(|e| { - let msg = e - .downcast_ref::() - .cloned() - .or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())) - .unwrap_or_else(|| "(unknown)".into()); - anyhow!("pipeline panicked for '{key}': {msg}") - })?; - - self.dynamic_pipelines.insert(key.clone(), pipeline); - self.dynamic_uniform_keys.insert(key.clone(), uniform_keys); - self.dynamic_vertex_attrs.insert(key, attributes.to_vec()); - Ok(()) - } - - /// Shared quad buffer for one vertex attribute. `kind`: 0 = NDC position, - /// 1 = UV (v=0 at the vertex that lands on screen top after naga's Y - /// flip — same convention as the synthetic VS), 2 = zeros. - fn attr_buffer(&mut self, kind: u8, dims: u8) -> &wgpu::Buffer { - let device = &self.device; - self.attr_buffers.entry((kind, dims)).or_insert_with(|| { - const CORNERS: [[f32; 2]; 6] = [ - [-1.0, -1.0], - [1.0, -1.0], - [-1.0, 1.0], - [1.0, -1.0], - [1.0, 1.0], - [-1.0, 1.0], - ]; - let mut data: Vec = Vec::with_capacity(6 * dims as usize); - for [x, y] in CORNERS { - let full: [f32; 4] = match kind { - 0 => [x, y, 0.0, 1.0], - 1 => { - let u = (x + 1.0) * 0.5; - let v = (y + 1.0) * 0.5; - [u, v, u, v] - } - _ => [0.0; 4], - }; - data.extend_from_slice(&full[..dims as usize]); - } - let bytes: Vec = data.iter().flat_map(|f| f.to_le_bytes()).collect(); - let buf = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("quad_attr"), - size: bytes.len() as u64, - usage: wgpu::BufferUsages::VERTEX, - mapped_at_creation: true, - }); - buf.slice(..).get_mapped_range_mut()[..].copy_from_slice(&bytes); - buf.unmap(); - buf - }) - } - - /// Resolve the quad buffers for a translated shader's attribute list. - fn attr_buffers_for(&mut self, attributes: &[(String, String)]) -> Vec { - let specs: Vec<(u8, u8)> = attributes - .iter() - .map(|(ty, name)| { - let dims = match ty.as_str() { - "float" => 1, - "vec2" => 2, - "vec3" => 3, - _ => 4, - }; - let kind = if name.contains("Position") { - 0 - } else if name.contains("TexCoord") { - 1 - } else { - 2 - }; - (kind, dims) - }) - .collect(); - specs - .into_iter() - .map(|(kind, dims)| self.attr_buffer(kind, dims).clone()) - .collect() - } - - /// Get (or lazily create) the blit pipeline for a given output format. - /// Used for surface presentation and FBO up/down-sampling. - pub fn blit_pipeline(&mut self, format: wgpu::TextureFormat) -> &wgpu::RenderPipeline { - if !self.blit_pipelines.contains_key(&format) { - let p = Self::create_pipeline( - &self.device, - &self.composite_pipeline_layout, - &self.shader_module, - "vs_fullscreen", - "fs_blit", - "blit", - None, - format, - ); - self.blit_pipelines.insert(format, p); - } - &self.blit_pipelines[&format] - } - - pub fn upload_texture(&self, img: &RgbaImage) -> wgpu::Texture { - let (fw, fh) = - crate::engine::fbo::fit_texture_limit(&self.device, img.width(), img.height()); - let downscaled; - let img = if (fw, fh) != img.dimensions() { - tracing::warn!( - "layer texture {}x{} exceeds the GPU limit — downscaling to {fw}x{fh}", - img.width(), - img.height() - ); - downscaled = - image::imageops::resize(img, fw, fh, image::imageops::FilterType::Triangle); - &downscaled - } else { - img - }; - let (w, h) = img.dimensions(); - let tex = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("layer_tex"), - size: wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &tex, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - img.as_raw(), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(w * 4), - rows_per_image: Some(h), - }, - wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: 1, - }, - ); - tex - } - - /// Uploads sprite-sheet frames as one 2D texture array (layer per frame) - /// for the GPU particle pipeline. Frames are padded to the largest - /// extent; TEXS sheets are uniform in practice, so padding is a - /// theoretical safety net, not an expected path. - pub fn upload_texture_array(&self, frames: &[RgbaImage]) -> (wgpu::Texture, u32) { - let n = frames.len().max(1) as u32; - let w = frames.iter().map(|f| f.width()).max().unwrap_or(1).max(1); - let h = frames.iter().map(|f| f.height()).max().unwrap_or(1).max(1); - let tex = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("particle_sprite_array"), - size: wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: n, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - for (i, frame) in frames.iter().enumerate() { - let (fw, fh) = (frame.width(), frame.height()); - let padded = if fw == w && fh == h { - frame.clone() - } else { - let mut img = RgbaImage::new(w, h); - image::imageops::overlay(&mut img, frame, 0, 0); - img - }; - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &tex, - mip_level: 0, - origin: wgpu::Origin3d { - x: 0, - y: 0, - z: i as u32, - }, - aspect: wgpu::TextureAspect::All, - }, - padded.as_raw(), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4 * w), - rows_per_image: Some(h), - }, - wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: 1, - }, - ); - } - (tex, frames.len().max(1) as u32) - } - - pub fn create_render_target(&self, w: u32, h: u32) -> wgpu::Texture { - self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("render_target"), - size: wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_SRC - | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }) - } - - fn find_effect_pipeline(&self, key: &str) -> Option<&wgpu::RenderPipeline> { - self.effect_pipelines - .iter() - .find(|(n, _)| *n == key) - .map(|(_, p)| p) - .or_else(|| self.dynamic_pipelines.get(key)) - } - - /// Pipeline key: 0 = normal alpha blend, 1 = Photoshop-style dest-read - /// blend (any nonzero WE colorBlendMode; the mode itself travels in - /// CompositeParams.mode). - fn composite_pipeline(&self, blend_mode: u32) -> &wgpu::RenderPipeline { - let key = if blend_mode == 0 { 0 } else { 1 }; - self.composite_pipelines - .iter() - .find(|(m, _)| *m == key) - .or_else(|| self.composite_pipelines.first()) - .map(|(_, p)| p) - .unwrap() - } - - /// Select the sampler matching a `.tex`'s `NoInterpolation`/`ClampUVs` - /// flags (nearest-vs-linear filtering, clamp-vs-repeat addressing). - fn sampler_for(&self, no_interpolation: bool, clamp_uvs: bool) -> &wgpu::Sampler { - let idx = ((no_interpolation as usize) << 1) | (clamp_uvs as usize); - &self.samplers[idx] - } - - /// Create the group(0) bind group: source texture, sampler, a uniform - /// buffer, and up to six extra texture slots (dummy white when absent). - fn make_base_bind_group( - &self, - src_view: &wgpu::TextureView, - sampler: &wgpu::Sampler, - uniform_buf: &wgpu::Buffer, - extra: &[Option], - ) -> wgpu::BindGroup { - let dummy_view = self.dummy_tex.create_view(&Default::default()); - let slot = |i: usize| -> &wgpu::TextureView { - extra.get(i).and_then(|v| v.as_ref()).unwrap_or(&dummy_view) - }; - self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &self.base_bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(src_view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(sampler), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: uniform_buf.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: wgpu::BindingResource::TextureView(slot(0)), - }, - wgpu::BindGroupEntry { - binding: 4, - resource: wgpu::BindingResource::TextureView(slot(1)), - }, - wgpu::BindGroupEntry { - binding: 5, - resource: wgpu::BindingResource::TextureView(slot(2)), - }, - wgpu::BindGroupEntry { - binding: 6, - resource: wgpu::BindingResource::TextureView(slot(3)), - }, - wgpu::BindGroupEntry { - binding: 7, - resource: wgpu::BindingResource::TextureView(slot(4)), - }, - wgpu::BindGroupEntry { - binding: 8, - resource: wgpu::BindingResource::TextureView(slot(5)), - }, - wgpu::BindGroupEntry { - binding: 9, - resource: wgpu::BindingResource::TextureView(slot(6)), - }, - ], - }) - } - - fn make_uniform_buffer(&self, data: &[u8], min_size: u64) -> wgpu::Buffer { - let size = (data.len() as u64).max(min_size); - let buf = self.device.create_buffer(&wgpu::BufferDescriptor { - label: None, - size, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let mut padded = vec![0u8; size as usize]; - padded[..data.len()].copy_from_slice(data); - self.queue.write_buffer(&buf, 0, &padded); - buf - } - - fn make_effect_bind_group(&self, param_buf: &wgpu::Buffer) -> wgpu::BindGroup { - self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &self.effect_bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: param_buf.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: self.audio_buf.as_entire_binding(), - }, - ], - }) - } - - /// Record one pass drawing `vertex_count` vertices (3 = fullscreen - /// triangle, 6 = object quad / real-VS quad with vertex buffers). - #[allow(clippy::too_many_arguments)] - fn run_pass( - &self, - encoder: &mut wgpu::CommandEncoder, - pipeline: &wgpu::RenderPipeline, - base_bg: &wgpu::BindGroup, - effect_bg: Option<&wgpu::BindGroup>, - dst_view: &wgpu::TextureView, - load: wgpu::LoadOp, - label: &str, - vertex_count: u32, - vertex_buffers: &[wgpu::Buffer], - ) { - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some(label), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: dst_view, - resolve_target: None, - ops: wgpu::Operations { - load, - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - ..Default::default() - }); - pass.set_pipeline(pipeline); - pass.set_bind_group(0, base_bg, &[]); - if let Some(bg) = effect_bg { - pass.set_bind_group(1, bg, &[]); - } - for (i, buf) in vertex_buffers.iter().enumerate() { - pass.set_vertex_buffer(i as u32, buf.slice(..)); - } - pass.draw(0..vertex_count, 0..1); - } - - pub fn readback(&self, target: &wgpu::Texture, w: u32, h: u32) -> Result { - // wgpu requires bytes_per_row to be a multiple of COPY_BYTES_PER_ROW_ALIGNMENT (256). - // Widths not divisible by 64 (e.g. 1080, 1366) would panic without this padding. - let unpadded_bpr = w * 4; - let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; - let padded_bpr = (unpadded_bpr + align - 1) / align * align; - let staging_size = (padded_bpr * h) as u64; - - let staging = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("readback"), - size: staging_size, - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - - let mut encoder = self.device.create_command_encoder(&Default::default()); - encoder.copy_texture_to_buffer( - wgpu::TexelCopyTextureInfo { - texture: target, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyBufferInfo { - buffer: &staging, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(padded_bpr), - rows_per_image: Some(h), - }, - }, - wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: 1, - }, - ); - self.queue.submit(std::iter::once(encoder.finish())); - - let slice = staging.slice(..); - let (tx, rx) = std::sync::mpsc::sync_channel(1); - slice.map_async(wgpu::MapMode::Read, move |r| { - let _ = tx.send(r); - }); - let _ = self.device.poll(wgpu::PollType::Wait { - submission_index: None, - timeout: None, - }); - rx.recv() - .map_err(|_| anyhow!("readback channel disconnected"))? - .map_err(|e| anyhow!("buffer map failed: {e:?}"))?; - - let data = slice.get_mapped_range(); - // Strip row padding to produce a tightly-packed RGBA image. - let pixels: Vec = data - .chunks(padded_bpr as usize) - .flat_map(|row| &row[..unpadded_bpr as usize]) - .copied() - .collect(); - drop(data); - staging.unmap(); - - RgbaImage::from_raw(w, h, pixels).context("creating RgbaImage from GPU readback") - } -} - -// ── Effect runtimes ─────────────────────────────────────────────────────────── - -fn vertex_format_for(glsl_type: &str) -> wgpu::VertexFormat { - match glsl_type { - "float" => wgpu::VertexFormat::Float32, - "vec2" => wgpu::VertexFormat::Float32x2, - "vec3" => wgpu::VertexFormat::Float32x3, - _ => wgpu::VertexFormat::Float32x4, - } -} - -/// Map a material pass's `blending` mode to a GPU blend state. Effect passes -/// always render a fullscreen-covering quad into a just-cleared (transparent) -/// target, so this only changes output for passes whose shader legitimately -/// writes alpha<1 — Normal/Disabled write it through unmodified (replace), -/// while Additive/Translucent premultiply color by that alpha, matching the -/// reference's (ONE,ZERO) / (SRC_ALPHA,ONE) / (SRC_ALPHA,1-SRC_ALPHA) blend -/// functions for a zero destination. -/// Material pass blending → wgpu blend state, matching CPass::render's GL -/// states exactly: `normal` is GL_ONE/GL_ZERO and `disabled` is -/// glDisable(GL_BLEND) — both plain replace (`None` here); only -/// `additive`/`translucent` actually blend. -fn wgpu_blend_state(blending: &WEBlending) -> Option { - match blending { - WEBlending::Normal | WEBlending::Disabled => None, - WEBlending::Additive => Some(wgpu::BlendState { - color: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::SrcAlpha, - dst_factor: wgpu::BlendFactor::One, - operation: wgpu::BlendOperation::Add, - }, - alpha: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::One, - dst_factor: wgpu::BlendFactor::One, - operation: wgpu::BlendOperation::Add, - }, - }), - WEBlending::Translucent => Some(wgpu::BlendState::ALPHA_BLENDING), - } -} - -/// Scene.json overrides for one effect pass: shader combos plus constant -/// values (indexed by material key, with normalized aliases and per-component -/// `_r/_g/_b`/`_x/_y/_z/_w` entries). -#[derive(Debug, Clone, Default)] -struct PassOverride { - combos: HashMap, - values: ShaderVals, - /// Positional scene.json texture override for this pass (e.g. a custom - /// opacity mask or noise map); `None` entries mean "leave this slot at - /// the material's own default". See `scene::Pass::textures`. - textures: Vec>, -} - -/// One effect attached to one layer (an *instance* — the same effect on two -/// layers gets separate pipelines because combos/constants can differ). -struct EffectInstanceDef { - layer_idx: usize, - /// Position in the object's own `effects` list (scripts address effects - /// by it: `thisLayer.getEffect(1)`). - effect_index: usize, - name: String, - /// Verbatim scene.json effect `file` path (e.g. `"effects/clouds/effect.json"` - /// or a workshop-nested `"effects/workshop/2138904733/foo/effect.json"`) — - /// used as-is rather than reconstructed from `name`, since custom-effect - /// bundles don't follow the `effects/{name}/` convention. - file: String, - pass_overrides: Vec, -} - -/// One executable pass of an effect. -struct EffectPassRuntime { - /// Pipeline lookup key: the effect name for hardcoded kernels, - /// `"fx{instance}:{effect}#{pass}"` for translated WE shaders. - key: String, - /// `dynamic_textures` lookup key. Equals `key` for translated shaders; - /// hardcoded kernels get their own `"fx{instance}:{effect}#0"` since - /// their pipeline `key` (the bare effect name) is shared across every - /// instance and would collide (e.g. two masked waterwaves on one layer). - tex_key: String, - hardcoded: bool, - /// Named FBO to render into (skips the ping-pong advance). - target: Option, - /// (texture slot index, FBO name) bindings. - binds: Vec<(u32, String)>, - /// Resolved constants: material defaults overridden by scene values. - values: ShaderVals, - /// Quad attribute buffers for the real-VS path; empty = synthetic VS - /// (fullscreen triangle, no vertex buffers). - vertex_buffers: Vec, -} - -/// A fully loaded effect instance: its passes plus auxiliary FBO declarations. -struct EffectRuntime { - passes: Vec, - /// (name, downscale divisor) — allocated per instance in the pool. - fbos: Vec<(String, f32)>, - /// True when any pass reads `g_AudioSpectrum*` — drives lazy capture start. - uses_audio: bool, -} - -/// Per-frame values for WE's engine-provided uniforms (the counterpart of -/// CPass::setupUniforms in the reference). -#[derive(Debug, Clone, Copy)] -struct EngineUniforms { - time: f32, - /// Fraction of the day in [0,1). - daytime: f32, - texel_size: [f32; 2], - pointer: [f32; 2], - /// g_Texture{N}Resolution: (tex_w, tex_h, real_w, real_h) per slot. - resolutions: [[f32; 4]; 8], - color: [f32; 3], - alpha: f32, - brightness: f32, -} - -impl EngineUniforms { - /// Look up an engine uniform by its GLSL name. Returns up to 4 components. - fn get(&self, key: &str) -> Option<[f32; 4]> { - let v = |a: f32, b: f32, c: f32, d: f32| Some([a, b, c, d]); - match key { - "g_Time" | "g_AnimationTime" | "g_FrameTime" => v(self.time, 0.0, 0.0, 0.0), - "g_Daytime" => v(self.daytime, 0.0, 0.0, 0.0), - "g_TexelSize" => v(self.texel_size[0], self.texel_size[1], 0.0, 0.0), - "g_TexelSizeHalf" => v(self.texel_size[0] * 0.5, self.texel_size[1] * 0.5, 0.0, 0.0), - "g_PointerPosition" | "g_PointerPositionLast" => { - v(self.pointer[0], self.pointer[1], 0.0, 0.0) - } - "g_Color" | "g_CompositeColor" | "g_TintColor" => { - v(self.color[0], self.color[1], self.color[2], 1.0) - } - "g_Color4" => v(self.color[0], self.color[1], self.color[2], self.alpha), - "g_Alpha" | "g_UserAlpha" | "g_BlendAlpha" | "g_CompositeAlpha" => { - v(self.alpha, 0.0, 0.0, 0.0) - } - "g_Brightness" => v(self.brightness, 0.0, 0.0, 0.0), - "g_TextureReductionScale" => v(1.0, 1.0, 1.0, 1.0), - _ => { - if let Some(rest) = key.strip_prefix("g_Texture") { - if let Some(idx) = rest.strip_suffix("Resolution") { - let i: usize = idx.parse().ok()?; - return self.resolutions.get(i).copied(); - } - } - None - } - } - } -} - -/// Scene-level bloom settings (general.bloom / bloomstrength / bloomthreshold). -#[derive(Debug, Clone, Default)] -struct BloomSettings { - enabled: bool, - strength: f32, - threshold: f32, -} - -struct SceneLayerGpu { - frames: Vec, - /// Animated puppet runtime — `frames[0]` is re-rasterized from the - /// posed mesh at `PUPPET_UPDATE_INTERVAL` (skinning + CPU raster costs - /// ~30ms at 4K, so it runs at a capped rate rather than per frame; - /// idle-style puppet animations are slow enough not to notice). - puppet: Option>, - /// Last animation time `frames[0]` was posed at, seconds. - puppet_posed_at: f32, - /// Embedded-video stream — `frames[0]` is replaced with the newest - /// decoded frame each tick (the decoder thread paces itself by PTS; - /// draining to the latest frame drops rather than lags). - video: Option>>, - blend_mode: u32, - /// Live alpha; SceneScript may change it per frame. - alpha: f32, - /// Script-driven text: re-evaluated each tick; when the output string - /// changes, `frames[0]` is re-rasterized and `rect` re-derived (clocks - /// tick, dates roll over — 42 of the corpus's 58 script wallpapers). - text_dynamic: Option, - color: [f32; 3], - brightness: f32, - frame_duration_ms: u32, - parallax_depth: [f32; 2], - /// z-rotation in radians (WE `angles.z`). - angle: f32, - /// Object quad: (center_ndc.x, center_ndc.y, half_extent_ndc.x, half_extent_ndc.y). - rect: [f32; 4], - /// True perspective-projected quad corners (NDC, already divided) in - /// `project_quad_ndc`'s corner order — `Some` only for perspective-scene - /// layers where every corner projects in front of the camera. `rect` - /// above is still always populated (their axis-aligned bounding box, via - /// `project_quad_ndc`) since depth-sort and the fallback draw path both - /// need it regardless. See `project_quad_corners` and the Ghidra - /// report's quad-perspective-warp finding: without this, an off-axis - /// quad's silhouette collapses to a rectangle instead of the true - /// trapezoid. Only consumed when `blend_mode == 0` (normal) — non-normal - /// blend modes on perspective quads keep the existing rect-based - /// approximation, a narrow and rare combination not worth a second - /// pipeline variant per blend mode. - quad_corners: Option<[[f32; 2]; 4]>, - /// The object's own visibility (authored, then as scripts set it). The - /// layer draws only when it and every ancestor are visible — see - /// `GpuSceneInstance::object_visible`. - visible: bool, - /// Object size in px before scale — rebuilds `rect` when a script - /// changes scale. - effective_size: [f64; 2], - /// Text a script set but not yet rasterized (throttled — see - /// `flush_text`), and when the last raster happened and how long it took. - pending_text: Option, - text_rastered_at: Option, - text_raster_cost: Duration, - /// Composition/fullscreen layer: `frames[0]` is refilled each frame - /// with the scene drawn beneath it (see `capture_backdrop`). - backdrop: Option, - /// Spritesheet frame a script picked (`getTextureAnimation().setFrame`), - /// overriding the timed animation. - script_frame: Option, - /// `alignment`'s shift of the quad centre from the authored origin, kept - /// so a scripted origin moves the quad the same way. - align_offset: [f64; 3], - no_interpolation: bool, - clamp_uvs: bool, - /// Unscaled object size in pixels — matches the reference's `CImage::m_size` - /// (explicit scene.json `size`, falling back to the texture's native - /// resolution). Effect-chain FBOs (ping-pong + named targets) are sized to - /// this, not the scene resolution, per `CImage.cpp`/`FBOProvider::create`. - object_size: (u32, u32), - /// This layer's position in `scene.visible_objects()` — lets `render()` - /// interleave with particle systems in true scene z-order. - order_index: usize, - /// The scene object's `id`, for `_rt_imageLayerComposite__*` binds. - object_id: Option, - /// Perspective scenes only: view-space distance of the quad center from - /// the camera, for painter's-algorithm back-to-front sorting. Negative = - /// behind the camera (culled). Always 0.0 in orthographic scenes. - depth: f32, -} - -// ── GpuSceneInstance ────────────────────────────────────────────────────────── - -/// A loaded scene ready to render frames on the GPU. -/// -/// Owns the renderer, layer textures, effect runtimes, the persistent FBO -/// pool, and per-frame camera dynamics. Render either to RGBA (readback -/// paths: preview/testing/SHM fallback) or straight into an external texture -/// view (Wayland surface presentation — no readback). -/// One sprite texture array for a GPU particle draw unit. -struct ParticleGpuTex { - view: wgpu::TextureView, - frames: u32, - overbright: f32, - /// Ropes tile V past 1.0 (`uvscale`/scrolling) — sample with the repeat - /// sampler; sprite quads clamp. - repeat: bool, -} - -/// Per-layer GPU particle textures: the parent system's sprite plus one per -/// child preset (child *instances* spawn at runtime; textures are per child). -struct ParticleGpuAssets { - parent: ParticleGpuTex, - children: Vec, -} - -/// A scene `sound` object's playback stream, kept with its owner's identity -/// so SceneScript can find it by layer name or id. -pub(crate) struct SceneSound { - /// Index of the owning object in `scene.objects` (or a script-created one). - pub object: usize, - pub playback: crate::engine::audio::SoundPlayback, -} - -/// Start a `sound` object's first decodable file, held silent when -/// `startsilent` is set. -fn start_scene_sound( - dir: &Path, - obj: &crate::engine::scene::SceneObject, -) -> Option { - let sound = obj.sound.as_ref()?; - let start_silent = obj - .startsilent - .as_ref() - .and_then(|v| { - v.as_bool() - .or_else(|| v.get("value").and_then(|i| i.as_bool())) - }) - .unwrap_or(false); - let volume = obj - .volume - .as_ref() - .and_then(crate::engine::scene::parse_value_f32) - .unwrap_or(1.0); - let mode = obj.playbackmode.as_ref().and_then(|v| v.as_str()); - let looping = mode != Some("nointerrupt") && mode != Some("once"); - let files: Vec = match sound { - serde_json::Value::Array(a) => a - .iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect(), - serde_json::Value::String(s) => vec![s.clone()], - _ => Vec::new(), - }; - files.into_iter().find_map(|f| { - crate::engine::audio::SoundPlayback::start(&dir.join(&f), volume, looping, !start_silent) - }) -} - -pub struct GpuSceneInstance { - renderer: GpuSceneRenderer, - layers: Vec, - scene_effects: Vec, - /// Parallel to `scene_effects`; None = the instance failed to load. - effect_runtimes: Vec>, - fbo_pool: RenderTargetPool, - target: wgpu::Texture, - /// Snapshot of `target` before each colorBlendMode>0 layer composites, so - /// the dest-read Photoshop blend can sample "what's already there" - /// without reading and writing the same attachment in one pass. - scene_copy: wgpu::Texture, - width: u32, - height: u32, - clear_color: [f64; 3], - dynamics: CameraDynamics, - bloom: BloomSettings, - /// The volumetric-light-shaft ray march's static per-scene bind group — - /// `None` unless a light sets `castvolumetrics` (see `build_volumetrics`, - /// `record_volumetrics`, and the Ghidra report's `_rt_volumetrics*` - /// follow-up). Static because the camera and every light's world - /// transform are themselves static in this engine (mesh3d/lighting are - /// baked once at load, not per-frame — same precedent `mesh3d_lighting_ubo` - /// already sets). - volumetrics_bind_group: Option, - /// Each owns independent simulation state; stepped and CPU-rendered into a - /// scene-sized RGBA buffer every frame, then uploaded and composited like - /// any other layer (see `render()` step 3). A known simplification: real - /// particle objects interleave into the scene's actual render order, but - /// these always draw on top of images (before bloom, so bright particles - /// still contribute to the bloom pass) — see [[wp_engine_project]] memory. - particle_systems: Vec, - /// Parallel to `particle_systems`: each system's resolved sprite (from - /// its config's `material`, if any), sampled CPU-side by `render_onto`'s - /// textured path. `None` falls back to flat-color circles. - particle_sprites: Vec>, - /// Parallel to `particle_systems`: each system's `order_index` (from its - /// source `ParticleLayer`), so `render()` can interleave particles with - /// image layers in true scene z-order. - particle_order: Vec, - /// Parallel to `particle_systems`: true when the resolved sprite's - /// material declares `"blending":"additive"` (fog/smoke/embers/rain/ - /// lightning — most real particle materials). Composited additively - /// instead of the default alpha-over, which otherwise makes a sprite's - /// near-black background visibly darken/box the scene behind it. - particle_additive: Vec, - /// Parallel to `particle_systems`: GPU sprite texture arrays (parent + - /// per-child), built once at load for the GPU particle pipeline. - particle_gpu_assets: Vec, - start: Instant, - last_time: f32, - mouse_norm: [f32; 2], - /// The scene's SceneScript runtime, ticked once per frame. Lives here (on - /// the render thread) because boa's `Context` is `!Send`. - scripts: crate::engine::script::ScriptHost, - /// What live user-property edits diff against. - live_props: LiveProps, - /// Per-object visibility as scripts leave it, indexed like `scene.objects` - /// (authored visibility at load). A layer draws only when its object and - /// every ancestor are visible. - object_visible: Vec, - /// Live `disablepropagation()` state per object. - propagation_disabled: Vec, - /// Whether a script registered audio buffers (feeds them each frame). - scripts_want_audio: bool, - /// Last cursor position in scene pixels, and button state — for - /// `input.*` and cursor events. - cursor_scene: [f32; 2], - cursor_left_down: bool, - /// Left-button transitions since the last frame, in order, so a click - /// that starts and ends between two frames still registers. - pending_buttons: Vec, - /// Objects with cursor handlers (`cursorClick` & co), hit-tested each - /// frame; empty for the vast majority of scenes. - cursor_targets: Vec, - /// The handler object under the cursor last frame, and the one a press - /// started on (receives the matching `cursorUp`/`cursorClick`). - hovered: Option, - pressed: Option, - last_cursor_scene: [f32; 2], - /// Keyframed property animations (`"animation"` timelines). - timelines: Vec, - /// Parallel to `scene_effects`: false once a script hides the effect. - effect_enabled: Vec, - /// Per-object draw position; starts as the object index, and - /// `thisScene.sortLayer` moves objects between their neighbours. - draw_rank: Vec, - sort_count: u32, - /// For `thisScene.createLayer`: the scene directory and its package. - scene_dir: std::path::PathBuf, - scene_pkg: Option, - /// Desktop-audio capture, started only when an effect reads - /// `g_AudioSpectrum*` (else `None` and the spectrum stays silent). Polled - /// once per frame into the renderer's `audio_buf`. - audio_capture: Option, - /// Live playback streams for `sound` objects; kept alive for the scene's - /// lifetime (dropping stops the audio). - sounds: Vec, - /// MPRIS "what's playing" watcher (`engine::media`) — Linux-only (see - /// its own module doc). Started unconditionally rather than gated on - /// whether any script actually reads `media.*` (matching - /// `engine.runtime`'s own always-on treatment): the D-Bus connection is - /// a cheap background poll, and unlike the CEF bridge's `wants_media` - /// scan there's no equivalent "does this bundle reference it" signal to - /// scan for across independent per-property scripts. - #[cfg(target_os = "linux")] - media: Option, - /// True for genuine 3D scenes (`Scene::is_perspective`): layer rects were - /// projected through a perspective camera and `render()` sorts image - /// layers back-to-front by `SceneLayerGpu::depth` instead of scene order. - perspective: bool, - /// Static 3D meshes (`model` -> .mdl), drawn as real geometry before the - /// 2D layers composite over them. Empty for every scene that has none. - mesh3d: Vec, - /// Depth buffer for `mesh3d`; `None` when there are no meshes. The 2D - /// layers never touch it — they keep their painter's ordering. - mesh3d_depth: Option, - /// Scene-wide lighting/fog state, shared by every mesh3d draw call's - /// bind group (binding 3) rather than duplicated per mesh. Built once at - /// scene setup: `Scene::lights()`/`general.fog()` have no per-frame - /// animation path today, matching `build_mesh3d`'s own "static in real - /// content" assumption for MVP. Always allocated, even for scenes with no - /// meshes — the cost is one small buffer, and it keeps this field - /// unconditional rather than `Option`. Not read back after construction — - /// kept alive here (rather than as a `build_mesh3d`-local) so its - /// lifetime is explicit and it's in place for a future per-frame rebuild - /// if scripted-parent lights ever need one, matching how `Mesh3dGpu::ubo` - /// already gets rewritten for scripted-parent meshes. - #[allow(dead_code)] - mesh3d_lighting_ubo: wgpu::Buffer, - /// Shared shadow atlas texture (see `build_shadow_atlas`) — kept alive - /// for the same reason as `mesh3d_lighting_ubo`: every mesh3d bind group - /// holds a view into it, built once here rather than per mesh. - #[allow(dead_code)] - shadow_atlas_tex: wgpu::Texture, - /// Retained for the per-frame parent recompose, which has to rebuild each - /// mesh's MVP and each layer's projected rect. - camera3d: Option, - /// Parent chain per object, for recomposing transforms scripts change. - transform_graph: crate::engine::render::TransformGraph, - /// `general.zoom`, retained because the per-frame parent recompose rebuilds - /// orthographic rects and has to apply the same scene-wide scale the build - /// path does. - zoom: f32, - /// Live per-object local transforms, carried across frames. WE's - /// `update(value)` is handed the CURRENT value, so a script written as - /// `value.y += ...` accumulates — resetting to the authored value each - /// frame turns continuous rotation into jitter in place. - current_locals: Vec, -} - -/// One static 3D mesh, uploaded once: geometry never changes, and its MVP is -/// baked at load because these objects don't animate in real content. -/// World-space corners of a quad centered at `center`, rotated by `angles`, -/// sized `size_px` — bottom-left, bottom-right, top-right, top-left in the -/// quad's own unrotated local space. Shared by `project_quad_ndc` and -/// `project_quad_corners` so their corner math can't drift apart. -fn quad_world_corners(center: [f32; 3], angles: [f32; 3], size_px: [f64; 2]) -> [[f32; 3]; 4] { - let hx = (size_px[0] / 2.0) as f32; - let hy = (size_px[1] / 2.0) as f32; - [[-hx, -hy], [hx, -hy], [hx, hy], [-hx, hy]].map(|[cx, cy]| { - let off = crate::engine::camera3d::rotate_euler([cx, cy, 0.0], angles); - [center[0] + off[0], center[1] + off[1], center[2] + off[2]] - }) -} - -/// Project a world-space quad through the perspective camera into an NDC rect. -/// -/// Returns `(rect, depth)`; a negative depth means the quad was culled (some -/// corner sat at or behind the eye plane). Shared by the build path and the -/// per-frame parent recompose so the two can't drift apart. -/// -/// Approximation: no in-quad perspective warp — the four projected corners -/// collapse to their axis-aligned bounding box. Used for depth-sort and as -/// the composite fallback in all cases; `project_quad_corners` (below) gives -/// the true quad shape for the common normal-blend case — see its own docs. -fn project_quad_ndc( - cam: &crate::engine::camera3d::PerspectiveCamera, - center: [f32; 3], - angles: [f32; 3], - size_px: [f64; 2], -) -> ([f32; 4], f32) { - let corners = quad_world_corners(center, angles, size_px); - let ndc: Vec<[f32; 2]> = corners.iter().filter_map(|c| cam.project(*c)).collect(); - if ndc.len() < 4 { - return ([-10.0, -10.0, 0.0, 0.0], -1.0); - } - let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); - let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); - for p in &ndc { - min_x = min_x.min(p[0]); - min_y = min_y.min(p[1]); - max_x = max_x.max(p[0]); - max_y = max_y.max(p[1]); - } - ( - [ - (min_x + max_x) / 2.0, - (min_y + max_y) / 2.0, - (max_x - min_x) / 2.0, - (max_y - min_y) / 2.0, - ], - cam.view_depth(center), - ) -} - -/// True perspective-projected quad corners (NDC, already divided), in the -/// same bottom-left/bottom-right/top-right/top-left order -/// `quad_world_corners` builds — `None` if any corner sits at or behind the -/// eye plane (same cull condition `project_quad_ndc` uses, kept consistent -/// rather than drawing a degenerate partial quad). -/// -/// This fixes the silhouette bug `project_quad_ndc`'s AABB collapse has: an -/// off-axis or steeply-angled quad renders as a true trapezoid instead of a -/// rectangle. UV mapping across it is bilinear, not hardware -/// perspective-correct (that would need clip-space corners with real w, not -/// pre-divided NDC) — a scoped simplification: the silhouette is what the -/// Ghidra report flagged as visibly wrong, and bilinear already fixes that; -/// full perspective-correct texture mapping is a further, separate -/// improvement. -fn project_quad_corners( - cam: &crate::engine::camera3d::PerspectiveCamera, - center: [f32; 3], - angles: [f32; 3], - size_px: [f64; 2], -) -> Option<[[f32; 2]; 4]> { - let corners = quad_world_corners(center, angles, size_px); - let mut ndc = [[0.0f32; 2]; 4]; - for (i, c) in corners.iter().enumerate() { - ndc[i] = cam.project(*c)?; - } - Some(ndc) -} - -struct Mesh3dGpu { - vbuf: wgpu::Buffer, - ibuf: wgpu::Buffer, - index_count: u32, - bind_group: wgpu::BindGroup, - nocull: bool, - depthtest: bool, - /// Kept so a script-driven parent can rewrite the MVP each frame. - ubo: wgpu::Buffer, - /// Pre-parent transform + which scene object this is, so the per-frame - /// pass can recompose the chain (see `render::TransformGraph`). - local: ([f32; 3], [f32; 3], [f32; 3]), - order_index: usize, - /// View depth of the mesh centre, for the perspective back-to-front sort. - /// Meshes are never culled on it (a skybox legitimately surrounds the - /// camera); it only orders them against the 2D layers. - depth: f32, -} - -/// Point-light cap for the mesh3d lighting pass. Must match -/// `MESH3D_MAX_LIGHTS` in `gpu_shaders.wgsl` exactly — there's no shared -/// constant across the Rust/WGSL boundary, so keep the two in sync by hand. -const MESH3D_MAX_LIGHTS: usize = 8; - -/// Shadow-casting-light cap, mirrored from `engine::shadow::MAX_SHADOW_LIGHTS` -/// — must also match `MESH3D_MAX_SHADOW_LIGHTS` in `gpu_shaders.wgsl` by -/// hand, same caveat as `MESH3D_MAX_LIGHTS`. -const MESH3D_MAX_SHADOW_LIGHTS: usize = crate::engine::shadow::MAX_SHADOW_LIGHTS; - -/// Byte length of the `Mesh3dLighting` uniform buffer: 5 leading `vec4`s -/// (flags, ambient, fog_distance, fog_height, fog_extra), three -/// `MESH3D_MAX_LIGHTS`-length `vec4` arrays (positions, colors, spot -/// direction+exponent), then `MESH3D_MAX_SHADOW_LIGHTS` `mat4x4`s (shadow -/// view-projections) and `MESH3D_MAX_SHADOW_LIGHTS` `vec4`s (their atlas UV -/// sub-rects), then one trailing `mat4x4` (`cookie_view_proj`). -const MESH3D_LIGHTING_BYTES_LEN: usize = - 16 * 5 + 16 * MESH3D_MAX_LIGHTS * 3 + (64 + 16) * MESH3D_MAX_SHADOW_LIGHTS + 64; - -/// Pack one mesh's `Mesh3dTransform` uniform (mvp, model_view, normal_view, -/// model) — layout must match `Mesh3dTransform` in `gpu_shaders.wgsl` -/// exactly. `model` (object→world, no view/projection) is what the shadow -/// lookup in `fs_mesh3d` needs `world_pos` for — the light's view-projection -/// is naturally built in world space, unlike everything else here which -/// works in the main camera's view space. -fn mesh3d_transform_bytes( - cam: &crate::engine::camera3d::PerspectiveCamera, - origin: [f32; 3], - angles: [f32; 3], - scale: [f32; 3], -) -> Vec { - let mut bytes = Vec::with_capacity(64 * 4); - for mat in [ - cam.mvp(origin, angles, scale), - cam.model_view(origin, angles, scale), - cam.normal_view(angles, scale), - crate::engine::camera3d::model_matrix(origin, angles, scale), - ] { - for col in mat.iter() { - for f in col { - bytes.extend_from_slice(&f.to_le_bytes()); - } - } - } - bytes -} - -/// Pack the scene-wide `Mesh3dLighting` uniform: ambient + fog (already -/// resolved by `General::ambient_color`/`General::fog`), up to -/// `MESH3D_MAX_LIGHTS` point or spot lights from `Scene::lights` (positions -/// in the camera's view space, matching everything else `fs_mesh3d` works -/// in; a spot's facing direction goes into `light_spot`, also view-space), -/// and up to `MESH3D_MAX_SHADOW_LIGHTS` shadow view-projections + atlas UV -/// rects (world space — see `mesh3d_transform_bytes`). Layout must match -/// `Mesh3dLighting` in `gpu_shaders.wgsl` exactly. -/// -/// `shadow_slots[i]` is the shadow-atlas slot index for `scene.lights()[i]`, -/// or `None` when that light doesn't cast a shadow (unlit, or beyond -/// `MESH3D_MAX_SHADOW_LIGHTS` — see `GpuSceneInstance::build_shadow_atlas`). -/// A light's `light_pos[i].w` carries `(slot + 1) as f32` so the shader can -/// tell "no shadow" (0.0) from "shadow slot 0" without a separate flags -/// field — `light_color.a <= 0.0` (unused light slot) already uses the same -/// "skip" convention. -/// -/// `cookie` is `Some((light_index, view_proj))` when one of `scene.lights()` -/// is a Spot light with a resolvable cookie texture (see -/// `GpuSceneInstance::build`'s cookie-resolution step) — packed into -/// `flags.y` (as `light_index + 1`, `0.0` = none) and the trailing -/// `cookie_view_proj` field. -fn mesh3d_lighting_bytes( - scene: &crate::engine::scene::Scene, - cam: &crate::engine::camera3d::PerspectiveCamera, - shadow_slots: &[Option], - shadow_slot_data: &[(crate::engine::camera3d::Mat4, [f32; 4])], - cookie: Option<(usize, crate::engine::camera3d::Mat4)>, -) -> Vec { - let lights = scene.lights(); - let general = scene.general.as_ref(); - let fog = general.and_then(|g| g.fog()); - let ambient = general.and_then(|g| g.ambient_color()).unwrap_or([0.0; 3]); - - let mut bytes = Vec::with_capacity(MESH3D_LIGHTING_BYTES_LEN); - let mut push4 = |v: [f32; 4]| { - for f in v { - bytes.extend_from_slice(&f.to_le_bytes()); - } - }; - - // Gates on "at least one light this loop can actually render" — every - // `Scene::lights()` variant is renderable now (Point, Spot, Directional, - // Tube), so in practice this only matters for an empty `lights` list. A - // scene with no renderable lights must still fall through to the plain - // unlit look, not engage the lit branch with zero actual contributions - // (which would multiply by `ambient` alone — usually black, per the - // corpus's near-universal unset `ambientcolor`). - let has_renderable_light = lights.iter().any(|(l, ..)| { - matches!( - l, - crate::engine::lighting::Light::Point { .. } - | crate::engine::lighting::Light::Spot { .. } - | crate::engine::lighting::Light::Directional { .. } - | crate::engine::lighting::Light::Tube { .. } - ) - }); - let cookie_light_flag = cookie.map(|(idx, _)| (idx + 1) as f32).unwrap_or(0.0); - push4([ - if has_renderable_light { 1.0 } else { 0.0 }, - cookie_light_flag, - 0.0, - 0.0, - ]); - push4([ambient[0], ambient[1], ambient[2], 0.0]); - match &fog { - Some(f) => { - push4([ - f.distance_color[0], - f.distance_color[1], - f.distance_color[2], - f.distance_density, - ]); - push4([ - f.height_color[0], - f.height_color[1], - f.height_color[2], - f.height_density, - ]); - push4([f.height_exponent, f.height_offset, 0.0, 0.0]); - } - None => { - push4([0.0; 4]); - push4([0.0; 4]); - push4([0.0; 4]); - } - } - - let mut positions = [[0.0f32; 4]; MESH3D_MAX_LIGHTS]; - let mut colors = [[0.0f32; 4]; MESH3D_MAX_LIGHTS]; - let mut spots = [[0.0f32; 4]; MESH3D_MAX_LIGHTS]; - for (i, ((pos_slot, (color_slot, spot_slot)), (light, ..))) in positions - .iter_mut() - .zip(colors.iter_mut().zip(spots.iter_mut())) - .zip(lights.iter()) - .enumerate() - { - let shadow_w = || { - shadow_slots - .get(i) - .copied() - .flatten() - .map(|s| (s + 1) as f32) - .unwrap_or(0.0) - }; - match light { - crate::engine::lighting::Light::Point { - origin, - color, - intensity, - .. - } => { - let vp = cam.to_view_space(*origin); - *pos_slot = [vp[0], vp[1], vp[2], shadow_w()]; - *color_slot = [color[0], color[1], color[2], *intensity]; - // spot_slot stays [0,0,0,0] — zero-length direction reads as - // "not a spot" in `mesh3d_spot_factor`. - } - crate::engine::lighting::Light::Spot { - origin, - direction, - exponent, - color, - intensity, - .. - } => { - let vp = cam.to_view_space(*origin); - // `build_shadow_atlas` now casts shadows from `Light::Spot` - // too (see `engine::shadow::spot_light_view_proj`), so - // `shadow_w()` is real here, not always 0.0. - *pos_slot = [vp[0], vp[1], vp[2], shadow_w()]; - *color_slot = [color[0], color[1], color[2], *intensity]; - let vd = cam.to_view_direction(*direction); - *spot_slot = [vd[0], vd[1], vd[2], *exponent]; - } - crate::engine::lighting::Light::Directional { - direction, - color, - intensity, - } => { - // Infinite light: `light_pos[i].xyz` carries a - // pre-normalized view-space direction from the surface - // toward the light (the negation of the light's own - // travel direction, matching `to_light`'s convention for - // Point/Spot), flagged via `w = -1.0` — a sentinel real - // shadow_w values never take (0, or a positive slot+1). - // See `fs_mesh3d`'s light loop in gpu_shaders.wgsl. - let vd = cam.to_view_direction(*direction); - let neg = [-vd[0], -vd[1], -vd[2]]; - let len = (neg[0] * neg[0] + neg[1] * neg[1] + neg[2] * neg[2]) - .sqrt() - .max(1e-6); - *pos_slot = [neg[0] / len, neg[1] / len, neg[2] / len, -1.0]; - *color_slot = [color[0], color[1], color[2], *intensity]; - // spot_slot stays [0,0,0,0] — mesh3d_spot_factor's own - // "not a spot" check already no-ops for a directional light. - } - crate::engine::lighting::Light::Tube { - origin_a, - origin_b, - color, - intensity, - } => { - // Line-segment light: `light_pos[i].xyz` carries endpoint A - // (view space), `light_spot[i].xyz` carries endpoint B - // (view space, *not* a direction) — flagged via `w = -3.0`, - // a sentinel distinct from directional's `-1.0` (both are - // already `< 0.5`, so `mesh3d_shadow_factor` already treats - // either as "unshadowed" with no extra branching needed - // there; `mesh3d_spot_factor` does need an explicit skip, - // since a non-zero-length `light_spot.xyz` would otherwise - // be misread as a spot direction — see `fs_mesh3d`'s light - // loop in gpu_shaders.wgsl). - let va = cam.to_view_space(*origin_a); - let vb = cam.to_view_space(*origin_b); - *pos_slot = [va[0], va[1], va[2], -3.0]; - *color_slot = [color[0], color[1], color[2], *intensity]; - *spot_slot = [vb[0], vb[1], vb[2], 0.0]; - } - } - } - for p in positions { - push4(p); - } - for c in colors { - push4(c); - } - for s in spots { - push4(s); - } - - // `Mesh3dLighting` in WGSL declares these as two *separate* fixed-size - // arrays (`shadow_view_proj` then `shadow_uv_rect`), not an - // array-of-structs — every matrix must be written before any rect, or - // WGSL indexes into the wrong bytes entirely (confirmed the hard way: an - // earlier interleaved version of this loop packed matrix+rect pairs - // per-slot, which happened to leave `shadow_view_proj[0]` intact by - // coincidence but made every `shadow_uv_rect[slot]` read garbage spliced - // from a neighboring slot's matrix). - for slot in 0..MESH3D_MAX_SHADOW_LIGHTS { - let vp = shadow_slot_data - .get(slot) - .map(|(vp, _)| *vp) - .unwrap_or_else(crate::engine::camera3d::identity); - for col in vp.iter() { - push4(*col); - } - } - for slot in 0..MESH3D_MAX_SHADOW_LIGHTS { - let uv_rect = shadow_slot_data - .get(slot) - .map(|(_, r)| *r) - .unwrap_or([0.0; 4]); - push4(uv_rect); - } - let cookie_view_proj = cookie - .map(|(_, vp)| vp) - .unwrap_or_else(crate::engine::camera3d::identity); - for col in cookie_view_proj.iter() { - push4(*col); - } - bytes -} - -impl GpuSceneInstance { - /// Open a scene from a wallpaper directory with a freshly opened GPU device. - pub fn open(dir: &Path) -> Result { - let gpu = GpuDevice::open_low_power() - .or_else(|_| GpuDevice::open_best()) - .context("opening GPU device")?; - Self::with_gpu(gpu, dir) - } - - /// Open a scene using an existing device/queue (e.g. the one that owns a - /// Wayland surface, so rendered frames can be presented directly). - pub fn with_device(device: wgpu::Device, queue: wgpu::Queue, dir: &Path) -> Result { - let renderer = GpuSceneRenderer::with_device(device, queue)?; - Self::build(renderer, dir) - } - - pub fn with_gpu(gpu: GpuDevice, dir: &Path) -> Result { - let renderer = GpuSceneRenderer::new(gpu)?; - Self::build(renderer, dir) - } - - fn build(mut renderer: GpuSceneRenderer, dir: &Path) -> Result { - let resolved = super::render::ResolvedScene::from_directory(dir)?; - // The unparsed scene.json: scripts and timelines read fields the - // typed model drops. - let raw_scene: serde_json::Value = crate::engine::assets::AssetStore::from_directory(dir) - .ok() - .and_then(|a| a.scene_json().ok()) - .and_then(|t| serde_json::from_str(&t).ok()) - .unwrap_or_default(); - let w = resolved.width; - let h = resolved.height; - - let scene_model = crate::engine::model::scene_to_model(&resolved.scene)?; - - let general = resolved.scene.general.as_ref(); - let clear_color: [f64; 3] = general - .and_then(|g| g.clear_color.as_ref()) - .and_then(|v| { - let s = if let Some(s) = v.as_str() { - s.to_string() - } else if let Some(inner) = v.get("value").and_then(|i| i.as_str()) { - inner.to_string() - } else { - return None; - }; - let parts: Vec = s - .split_whitespace() - .filter_map(|p| p.parse().ok()) - .collect(); - if parts.len() >= 3 { - Some([parts[0], parts[1], parts[2]]) - } else { - None - } - }) - .unwrap_or([0.0, 0.0, 0.0]); - // `clearenabled: false` → don't paint the clear color (the surface is - // opaque, so this reads as black behind any gaps in the scene). - let mut clear_color = if general - .and_then(|g| g.clearenabled.as_ref()) - .and_then(parse_value_bool) - == Some(false) - { - [0.0, 0.0, 0.0] - } else { - clear_color - }; - // NOTE: `ambientcolor`/`skylightcolor` are inputs to LIT materials, not - // a global tint. The whole corpus is unlit (0 shaders use LightingV1) - // and these default to "0.3 0.3 0.3" on 195/197 scenes, so applying - // them globally would darken every scene to 30%. They stay unused - // (parsed only) — the visible lighting is the light-object glow. - - // HDR wallpapers author a separate `bloomhdr*` set; with a single SDR - // bloom chain we pick that set when `hdr` is on, else the SDR one. - let hdr = general - .and_then(|g| g.hdr.as_ref()) - .and_then(parse_value_bool) - .unwrap_or(false); - let bloom = BloomSettings { - enabled: hdr - || general - .and_then(|g| g.bloom.as_ref()) - .and_then(parse_value_bool) - .unwrap_or(false), - strength: general - .and_then(|g| { - if hdr { - g.bloom_hdr_strength.as_ref() - } else { - g.bloom_strength.as_ref() - } - }) - .and_then(parse_value_f32) - .unwrap_or(1.0), - threshold: general - .and_then(|g| { - if hdr { - g.bloom_hdr_threshold.as_ref() - } else { - g.bloom_threshold.as_ref() - } - }) - .and_then(parse_value_f32) - .unwrap_or(0.5), - }; - - // `general.zoom` (default 1.0) — a scene-wide scale about the center. - let zoom = general - .and_then(|g| g.zoom.as_ref()) - .and_then(parse_value_f32) - .filter(|z| *z > 0.0) - .unwrap_or(1.0); - // Scene-global particle force (gravity + wind). - let scene_force = general.map(|g| g.particle_force()).unwrap_or([0.0, 0.0]); - - let dynamics = CameraDynamics::from_scene(&resolved.scene); - - // Perspective (3D) scenes project layer quads through the scene - // camera instead of mapping pixel origins to NDC directly. - let camera3d = crate::engine::camera3d::PerspectiveCamera::from_scene( - &resolved.scene, - w as f32 / h as f32, - ); - - let layers: Vec = resolved - .layers - .iter() - .map(|l| { - let authored = resolved - .transform_graph - .local - .get(l.order_index) - .map(|x| x.t) - .unwrap_or(l.local_origin); - gpu_layer_from(&renderer, l, w, h, zoom, camera3d.as_ref(), authored) - }) - .collect(); - - let scene_effects = collect_effects(&scene_model); - let effect_runtimes = load_effect_runtimes(&mut renderer, dir, &scene_effects); - - // Start desktop-audio capture only for scenes that actually react to - // it (avoids opening a capture device for every wallpaper). - // Start capture for audio shaders OR audio-reactive particle emitters. - let particles_use_audio = resolved.particle_layers.iter().any(|pl| { - pl.config - .emitter - .iter() - .any(|e| e.audioprocessingmode.is_some() || e.audioprocessingbounds.is_some()) - }); - let uses_audio = - particles_use_audio || effect_runtimes.iter().flatten().any(|r| r.uses_audio); - let audio_capture = if uses_audio { - crate::engine::audio::AudioCapture::start() - } else { - None - }; - - // Sound objects: decode each file and play it looped; `startsilent` - // ones are held ready but silent so a script can `play()` them. - let sounds: Vec = resolved - .scene - .objects - .iter() - .enumerate() - .filter_map(|(object, obj)| { - start_scene_sound(dir, obj).map(|playback| SceneSound { object, playback }) - }) - .collect(); - - // Computed early (not just before `build_shadow_atlas` needs it, its - // original spot) so the FBO-pool setup below can also gate - // `_rt_VolumetricsBuffer`'s allocation on whether any light actually - // needs it — see `engine::scene::SceneObject::volumetrics_params`. - let lights = resolved.scene.lights(); - let has_volumetrics = lights.iter().any(|(_, _, v, _)| v.is_some()); - - // Allocate all persistent render targets up front so the render loop - // can look them up immutably. Per-layer effect-chain FBOs (ping-pong - // pair + named targets) are sized to that layer's own object size, - // matching the reference (`CImage`'s m_mainFBO/m_subFBO and - // `FBOProvider::create` both use the object's unscaled size, not the - // scene resolution). - let mut fbo_pool = RenderTargetPool::new(); - for (layer_idx, layer) in layers.iter().enumerate() { - let (ow, oh) = (layer.object_size.0.max(1), layer.object_size.1.max(1)); - fbo_pool.get_or_create(renderer.device(), &pingpong_key(layer_idx, 0), ow, oh); - fbo_pool.get_or_create(renderer.device(), &pingpong_key(layer_idx, 1), ow, oh); - } - for (instance_idx, runtime) in effect_runtimes.iter().enumerate() { - if let Some(runtime) = runtime { - // `inst.layer_idx` is a raw scene-object index; find the - // loaded layer that came from that object (positional - // indexing would misalign whenever particles/skipped - // objects precede the layer, sizing FBOs off — and routing - // effects onto — a completely unrelated layer). - let (ow, oh) = scene_effects - .get(instance_idx) - .and_then(|inst| layers.iter().find(|l| l.order_index == inst.layer_idx)) - .map(|layer| (layer.object_size.0.max(1), layer.object_size.1.max(1))) - .unwrap_or((w, h)); - for (fbo_name, scale) in &runtime.fbos { - let div = scale.max(1.0); - let key = named_fbo_key(instance_idx, fbo_name); - fbo_pool.get_or_create( - renderer.device(), - &key, - ((ow as f32 / div) as u32).max(1), - ((oh as f32 / div) as u32).max(1), - ); - } - } - } - // `_rt_imageLayerComposite__`: WE's per-object composite - // buffer, referenced by effects on *other* objects (2321732083's - // samurai adds 5x object 101's buffer). Create one scene-sized target - // per referenced name; each is filled right after its owning layer - // composites. Aliasing these to the whole-scene snapshot instead — - // which is what we used to do — meant `blend` added 5x the entire lit - // city onto the samurai and blew it out to white. - let layer_composite_names: Vec = scene_effects - .iter() - .flat_map(|inst| inst.pass_overrides.iter()) - .flat_map(|over| { - over.textures - .iter() - .filter_map(|t| t.clone()) - .collect::>() - }) - .filter(|n| n.starts_with("_rt_imageLayerComposite")) - .collect(); - for name in &layer_composite_names { - fbo_pool.get_or_create(renderer.device(), name, w, h); - } - - if bloom.enabled { - // Matches the reference's three-buffer chain exactly (CScene.cpp): - // no ping-pong needed since each stage writes a *different* buffer. - let (qw, qh) = ((w / 4).max(1), (h / 4).max(1)); - let (ew, eh) = ((w / 8).max(1), (h / 8).max(1)); - fbo_pool.get_or_create(renderer.device(), "_rt_4FrameBuffer", qw, qh); - fbo_pool.get_or_create(renderer.device(), "_rt_8FrameBuffer", ew, eh); - fbo_pool.get_or_create(renderer.device(), "_rt_Bloom", ew, eh); - } - - // Volumetric-light-shaft ray march (see `build_volumetrics` and - // `record_volumetrics`) — quarter-res like bloom's own chain, cheap - // enough for a per-pixel ray march, upsampled naturally by the blur - // pass. Gated on `has_volumetrics`: real content essentially never - // sets `castvolumetrics` (see the Ghidra report's `_rt_volumetrics*` - // follow-up), so this stays a zero-cost no-op for the common case. - if has_volumetrics { - let (qw, qh) = ((w / 4).max(1), (h / 4).max(1)); - fbo_pool.get_or_create(renderer.device(), "_rt_VolumetricsBuffer", qw, qh); - fbo_pool.get_or_create(renderer.device(), "_rt_VolumetricsBlurTmp", qw, qh); - } - - let target = renderer.create_render_target(w, h); - let scene_copy = renderer.create_render_target(w, h); - - let particle_systems: Vec = resolved - .particle_layers - .iter() - .map(|pl| { - let spawn_center = [pl.origin[0] as f32, h as f32 - pl.origin[1] as f32]; - let mut system = particle::ParticleSystem::from_config( - &pl.config, - spawn_center, - pl.overrides.as_ref(), - ); - system.set_scene_force(scene_force); - if let Some(sprite) = &pl.sprite_texture { - system.set_sprite_frames(sprite.frames.len(), sprite.duration); - } - for child in &pl.children { - system.add_child( - child.config.clone(), - child.sprite.clone(), - child.additive, - &child.child_ref, - spawn_center, - ); - } - system - }) - .collect(); - let particle_sprites: Vec> = resolved - .particle_layers - .iter() - .map(|pl| pl.sprite_texture.clone()) - .collect(); - let particle_order: Vec = resolved - .particle_layers - .iter() - .map(|pl| pl.order_index) - .collect(); - let particle_additive: Vec = resolved - .particle_layers - .iter() - .map(|pl| pl.additive_blend) - .collect(); - - // GPU particle sprite arrays. Sprite-less systems draw the CPU - // path's soft radial-falloff disc (alpha = (1-d)^2), baked once; - // texture-less ropes fall back to a solid white strip, matching the - // CPU flat fill. - let soft_disc = { - let mut img = RgbaImage::new(64, 64); - for y in 0..64u32 { - for x in 0..64u32 { - let dx = (x as f32 + 0.5) / 32.0 - 1.0; - let dy = (y as f32 + 0.5) / 32.0 - 1.0; - let d = (dx * dx + dy * dy).sqrt().min(1.0); - let t = 1.0 - d; - let a = (t * t * 255.0) as u8; - img.put_pixel(x, y, image::Rgba([255, 255, 255, a])); - } - } - img - }; - let white = RgbaImage::from_pixel(1, 1, image::Rgba([255, 255, 255, 255])); - let array_view = |tex: &wgpu::Texture| { - tex.create_view(&wgpu::TextureViewDescriptor { - dimension: Some(wgpu::TextureViewDimension::D2Array), - ..Default::default() - }) - }; - let make_gpu_tex = |sprite: Option<&particle::ParticleSprite>, - rope: bool| - -> ParticleGpuTex { - match sprite { - Some(sp) if !sp.frames.is_empty() => { - let (tex, frames) = renderer.upload_texture_array(&sp.frames); - ParticleGpuTex { - view: array_view(&tex), - frames, - overbright: sp.overbright, - repeat: rope, - } - } - _ => { - let img = if rope { &white } else { &soft_disc }; - let (tex, frames) = renderer.upload_texture_array(std::slice::from_ref(img)); - ParticleGpuTex { - view: array_view(&tex), - frames, - overbright: 1.0, - repeat: rope, - } - } - } - }; - let particle_gpu_assets: Vec = resolved - .particle_layers - .iter() - .zip(particle_systems.iter()) - .map(|(pl, system)| ParticleGpuAssets { - parent: make_gpu_tex(pl.sprite_texture.as_ref(), system.is_rope()), - children: system - .child_sprite_info() - .into_iter() - .map(|(sprite, rope)| make_gpu_tex(sprite, rope)) - .collect(), - }) - .collect(); - - // Static 3D meshes: upload geometry + bake each MVP. Only reachable - // when the scene has a perspective camera to draw them through. - // Draw only meshes whose name contains this — the way to tell "not - // drawn" apart from "drawn but occluded" in a scene the camera sits - // inside of (same role as WP_DEBUG_DUMP_FRAME). - let mesh_filter = std::env::var("WP_MESH_FILTER").ok(); - // Shadow atlas: one shared depth texture, packed with a tile per - // shadow-casting `light` object — see `build_shadow_atlas` and - // `engine::shadow`. Built before the lighting uniform and the final - // mesh3d draws because both need to bind its (by-then-final) view. - // `lights` itself was computed earlier, alongside `has_volumetrics`. - let (shadow_atlas_tex, shadow_slots, shadow_slot_data) = if camera3d.is_some() { - Self::build_shadow_atlas(&renderer, &resolved.mesh3d_layers, &lights) - } else { - ( - renderer.device.create_texture(&wgpu::TextureDescriptor { - label: Some("shadow_atlas_dummy"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: MESH3D_DEPTH_FORMAT, - usage: wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: &[], - }), - vec![None; lights.len()], - Vec::new(), - ) - }; - let shadow_atlas_view = shadow_atlas_tex.create_view(&Default::default()); - // Spot-light cookie texture (`usecookie`/`cookie` — see - // `SceneObject::cookie_texture_path`). Scoped to the *first* Spot - // light with a real, loadable cookie image — real content sets - // `usecookie` on zero lights, ever (see the Ghidra report's - // property-schema survey), so there's no real-content case to - // generalize "one" into "N" against, same precedent - // `build_volumetrics` already uses for its own "first light with - // the flag" scoping. Only resolves against loose files under `dir` - // (no `scene.pkg` fallback) — the pkg handle isn't otherwise - // plumbed into `build`, and every real `usecookie` occurrence in - // this sandbox's cache is zero either way. - let cookie_data = { - use crate::engine::lighting::Light; - let (center, radius) = crate::engine::shadow::scene_bounds(&resolved.mesh3d_layers); - lights - .iter() - .enumerate() - .find_map(|(i, (light, _, _, cookie_path))| { - let path = cookie_path.as_ref()?; - let Light::Spot { - origin, - direction, - outer_cone_degrees, - .. - } = light - else { - return None; - }; - let image = super::render::resolve_static_texture(Some(dir), None, path)?; - let vp = crate::engine::shadow::spot_light_view_proj( - *origin, - *direction, - *outer_cone_degrees, - center, - radius, - ); - Some((i, vp, image)) - }) - }; - let cookie_view = renderer - .upload_texture( - &cookie_data - .as_ref() - .map(|(_, _, img)| img.clone()) - .unwrap_or_else(|| { - RgbaImage::from_pixel(1, 1, image::Rgba([255, 255, 255, 255])) - }), - ) - .create_view(&Default::default()); - // Volumetric-light-shaft ray march's static per-scene bind group — - // `None` unless `has_volumetrics` (checked again inside, cheaply, - // since it also needs to find *which* light). See `build_volumetrics`. - let volumetrics_bind_group = camera3d.as_ref().and_then(|cam| { - Self::build_volumetrics( - &renderer, - cam, - &lights, - &shadow_slots, - &shadow_slot_data, - &shadow_atlas_view, - ) - }); - // Scene-wide lighting/fog/shadow uniform, shared by every mesh3d - // bind group (binding 3) — see `mesh3d_lighting_bytes`. Zeroed (and - // thus read as "no lighting") when there's no perspective camera to - // place lights through, matching `mesh3d` staying empty in that case. - let mesh3d_lighting_bytes = camera3d - .as_ref() - .map(|cam| { - mesh3d_lighting_bytes( - &resolved.scene, - cam, - &shadow_slots, - &shadow_slot_data, - cookie_data.as_ref().map(|(i, vp, _)| (*i, *vp)), - ) - }) - .unwrap_or_else(|| vec![0u8; MESH3D_LIGHTING_BYTES_LEN]); - let mesh3d_lighting_ubo = renderer.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("mesh3d_lighting"), - size: mesh3d_lighting_bytes.len() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - renderer - .queue - .write_buffer(&mesh3d_lighting_ubo, 0, &mesh3d_lighting_bytes); - let mesh3d: Vec = match &camera3d { - Some(cam) => resolved - .mesh3d_layers - .iter() - .filter(|m| { - mesh_filter - .as_ref() - .is_none_or(|f| m.name.contains(f.as_str())) - }) - .map(|m| { - Self::build_mesh3d( - &renderer, - cam, - m, - &mesh3d_lighting_ubo, - &shadow_atlas_view, - &cookie_view, - ) - }) - .collect(), - None => Vec::new(), - }; - let mesh3d_depth = (!mesh3d.is_empty()).then(|| { - renderer - .device - .create_texture(&wgpu::TextureDescriptor { - label: Some("mesh3d_depth"), - size: wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: MESH3D_DEPTH_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: &[], - }) - .create_view(&Default::default()) - }); - - let mut instance = Self { - renderer, - layers, - scene_effects, - effect_runtimes, - fbo_pool, - target, - scene_copy, - width: w, - height: h, - clear_color, - dynamics, - bloom, - volumetrics_bind_group, - particle_systems, - particle_sprites, - particle_order, - particle_additive, - particle_gpu_assets, - start: Instant::now(), - last_time: 0.0, - mouse_norm: [0.5, 0.5], - scripts: build_script_host( - dir, - &resolved.scene, - &resolved.transform_graph, - [w, h], - &raw_scene, - ), - timelines: build_timelines(&raw_scene, &resolved.transform_graph), - live_props: LiveProps::new(dir, &raw_scene), - object_visible: resolved - .scene - .objects - .iter() - .map(|o| o.is_visible()) - .collect(), - propagation_disabled: vec![false; resolved.scene.objects.len()], - scripts_want_audio: false, - cursor_scene: [w as f32 * 0.5, h as f32 * 0.5], - cursor_left_down: false, - pending_buttons: Vec::new(), - cursor_targets: Vec::new(), - hovered: None, - pressed: None, - last_cursor_scene: [w as f32 * 0.5, h as f32 * 0.5], - effect_enabled: Vec::new(), - draw_rank: (0..resolved.scene.objects.len()) - .map(|i| i as f64) - .collect(), - sort_count: 0, - scene_dir: dir.to_path_buf(), - scene_pkg: None, - audio_capture, - sounds, - #[cfg(target_os = "linux")] - media: crate::engine::media::MediaWatcher::start(), - perspective: camera3d.is_some(), - mesh3d, - mesh3d_depth, - mesh3d_lighting_ubo, - shadow_atlas_tex, - camera3d, - current_locals: resolved.transform_graph.local.clone(), - transform_graph: resolved.transform_graph, - zoom, - }; - // Hidden ancestors hide their subtree; script-referenced hidden - // objects are built but start hidden. - instance.refresh_layer_visibility(); - instance.effect_enabled = vec![true; instance.scene_effects.len()]; - instance.cursor_targets = instance - .scripts - .objects_with_handlers(crate::engine::script::CURSOR_EVENTS); - Ok(instance) - } - - /// Build the volumetric-light-shaft ray march's static per-scene bind - /// group (`VolumetricsParams` uniform + the shadow atlas + its - /// comparison sampler — see `vs_fullscreen`/`fs_volumetrics` in - /// gpu_shaders.wgsl), when the scene has a `castvolumetrics` light — - /// `None` otherwise (the overwhelmingly common case: skipped entirely, - /// no buffers allocated). See `SceneObject::volumetrics_params` and the - /// Ghidra report's `_rt_volumetrics*` follow-up. - /// - /// Scoped to the *first* volumetric light found. Real content doesn't - /// use this system at all (confirmed against the one downloaded - /// Workshop sample this session found — see the report), so building - /// multi-light support (looping N lights' worth of ray marches in one - /// pass, like `fs_mesh3d`'s `MESH3D_MAX_LIGHTS` loop) would be - /// speculative complexity with no real-content grounding to verify it - /// against, for a feature that's already an approximation (a - /// ray-marched-against-the-shadow-map technique, not WE's real - /// procedural per-light cone/box mesh — see the report's scoping note). - fn build_volumetrics( - renderer: &GpuSceneRenderer, - cam: &crate::engine::camera3d::PerspectiveCamera, - lights: &[( - crate::engine::lighting::Light, - bool, - Option<(f32, f32)>, - Option, - )], - shadow_slots: &[Option], - shadow_slot_data: &[(crate::engine::camera3d::Mat4, [f32; 4])], - shadow_atlas_view: &wgpu::TextureView, - ) -> Option { - use crate::engine::lighting::Light; - - let (idx, origin, color_intensity, density, exponent) = lights - .iter() - .enumerate() - .find_map(|(i, (light, _, volumetrics, _))| { - let (density, exponent) = (*volumetrics)?; - let (origin, color, intensity) = match light { - // Directional has no position to march a shaft from; - // Tube isn't constructed by `Scene::lights()` yet (see - // `LightType::Tube`'s doc comment) — neither reaches here. - Light::Point { - origin, - color, - intensity, - .. - } - | Light::Spot { - origin, - color, - intensity, - .. - } => (*origin, *color, *intensity), - _ => return None, - }; - let ci = [ - color[0] * intensity, - color[1] * intensity, - color[2] * intensity, - ]; - Some((i, origin, ci, density, exponent)) - })?; - - // Reuses this light's shadow-atlas tile when it also casts a shadow - // (`castshadow: true`) — the shaft respects real occlusion, not just - // WE's own documented "unoccluded" fallback for when shadows aren't - // built. `has_shadow = 0.0` (no matching slot) makes - // `fs_volumetrics` skip the occlusion test entirely, matching that - // same documented fallback for a volumetric light that isn't also a - // shadow caster. - let (shadow_view_proj, shadow_uv_rect, has_shadow) = shadow_slots - .get(idx) - .copied() - .flatten() - .and_then(|slot| shadow_slot_data.get(slot)) - .map(|(vp, rect)| (*vp, *rect, 1.0f32)) - .unwrap_or((crate::engine::camera3d::identity(), [0.0; 4], 0.0)); - - let inv_view_proj = crate::engine::camera3d::mat4_inverse(&cam.view_proj_raw()); - let eye = cam.eye(); - // Fixed, honest default range for the ray march — no real content - // to derive a better heuristic from (see the fn doc comment). - // Comparable to `radius`'s own 256px-ish default scene scale. - const MAX_RANGE: f32 = 40.0; - - let mut bytes = Vec::with_capacity(4 * 16 * 4 + 16 * 5); - let mut push_mat = |m: &crate::engine::camera3d::Mat4| { - for col in m.iter() { - for f in col { - bytes.extend_from_slice(&f.to_le_bytes()); - } - } - }; - push_mat(&inv_view_proj); - push_mat(&shadow_view_proj); - let mut push4 = |v: [f32; 4]| { - for f in v { - bytes.extend_from_slice(&f.to_le_bytes()); - } - }; - push4([eye[0], eye[1], eye[2], 0.0]); - push4([origin[0], origin[1], origin[2], density]); - push4([ - color_intensity[0], - color_intensity[1], - color_intensity[2], - exponent, - ]); - push4(shadow_uv_rect); - push4([has_shadow, MAX_RANGE, 0.0, 0.0]); - - let ubo = renderer.make_uniform_buffer(&bytes, bytes.len() as u64); - Some( - renderer - .device - .create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("volumetrics_bg"), - layout: &renderer.volumetrics_bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: ubo.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(shadow_atlas_view), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::Sampler( - &renderer.shadow_comparison_sampler, - ), - }, - ], - }), - ) - } - - /// Render the shadow atlas once at scene setup (shadow-casting lights - /// are treated as static, same precedent as mesh MVPs — see - /// `build_mesh3d`'s own doc comment). For every `light` object with - /// `castshadow: true` (capped at `engine::shadow::MAX_SHADOW_LIGHTS`), - /// depth-renders every mesh3d object into that light's tile of one - /// shared atlas texture — the architecture recovered from the original - /// binary's `_rt_shadowAtlas` (see the Ghidra report's shadow-mapping - /// follow-up), not a separate texture per light. - /// - /// Returns the atlas texture (a harmless 1×1 dummy when there are no - /// shadow-casting lights or no mesh3d geometry, so `build_mesh3d`'s bind - /// group always has a valid view to reference), a per-`scene.lights()` - /// shadow-slot index (`None` = doesn't cast a shadow, or capped out), - /// and the per-slot `(view_proj, uv_rect)` data for `mesh3d_lighting_bytes`. - fn build_shadow_atlas( - renderer: &GpuSceneRenderer, - mesh3d_layers: &[crate::engine::render::Mesh3dLayer], - lights: &[( - crate::engine::lighting::Light, - bool, - Option<(f32, f32)>, - Option, - )], - ) -> ( - wgpu::Texture, - Vec>, - Vec<(crate::engine::camera3d::Mat4, [f32; 4])>, - ) { - use crate::engine::lighting::Light; - use crate::engine::shadow; - - let mut shadow_slots: Vec> = vec![None; lights.len()]; - - // Needed to build either projection (`point_light_view_proj`'s own - // aim-and-size target, `spot_light_view_proj`'s near/far sizing) — - // computed once up front now that both light types consume it, - // rather than after filtering casters like the point-only version - // did. - let (center, radius) = shadow::scene_bounds(mesh3d_layers); - let shadow_casters: Vec<(usize, crate::engine::camera3d::Mat4)> = lights - .iter() - .enumerate() - .filter_map(|(i, (light, casts_shadow, _volumetrics, _))| { - if !*casts_shadow { - return None; - } - let view_proj = match light { - Light::Point { origin, .. } => { - shadow::point_light_view_proj(*origin, center, radius) - } - Light::Spot { - origin, - direction, - outer_cone_degrees, - .. - } => shadow::spot_light_view_proj( - *origin, - *direction, - *outer_cone_degrees, - center, - radius, - ), - // Directional/tube shadows aren't built yet — see the - // Ghidra report's shadow-mapping follow-up. Directional - // has no natural "position" to cast a perspective - // shadow from (it'd need an orthographic projection, a - // different pipeline shape); tube isn't constructed by - // `Scene::lights()` at all yet. - _ => return None, - }; - Some((i, view_proj)) - }) - .take(shadow::MAX_SHADOW_LIGHTS) - .collect(); - - if shadow_casters.is_empty() || mesh3d_layers.is_empty() { - let dummy = renderer.device.create_texture(&wgpu::TextureDescriptor { - label: Some("shadow_atlas_dummy"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: MESH3D_DEPTH_FORMAT, - usage: wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: &[], - }); - return (dummy, shadow_slots, Vec::new()); - } - - let (atlas_w, atlas_h, tiles) = shadow::pack_tiles( - shadow_casters.len(), - shadow::SHADOW_TILE_SIZE, - shadow::SHADOW_TILE_SIZE * 4, - ); - - let atlas = renderer.device.create_texture(&wgpu::TextureDescriptor { - label: Some("shadow_atlas"), - size: wgpu::Extent3d { - width: atlas_w, - height: atlas_h, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: MESH3D_DEPTH_FORMAT, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: &[], - }); - let atlas_view = atlas.create_view(&Default::default()); - - // Geometry uploaded once, reused for every shadow-casting light's - // tile pass. Duplicates the upload `build_mesh3d` does later for the - // final draw — real content has ~0 shadow-casting lights (see the - // Ghidra report), so trading a little redundant GPU upload for a - // simple, independent setup path is the right side of that trade. - let geometry: Vec<(wgpu::Buffer, wgpu::Buffer, u32)> = mesh3d_layers - .iter() - .map(|m| Self::upload_mesh3d_geometry(renderer, &m.mesh)) - .collect(); - - let mut slot_data = Vec::with_capacity(shadow_casters.len()); - let mut encoder = renderer - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("shadow_atlas_pass"), - }); - for (slot, ((light_idx, view_proj), tile)) in - shadow_casters.iter().zip(tiles.iter()).enumerate() - { - let view_proj = *view_proj; - let uv_rect = shadow::tile_uv_rect(*tile, atlas_w, atlas_h); - shadow_slots[*light_idx] = Some(slot); - slot_data.push((view_proj, uv_rect)); - - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("shadow_tile"), - color_attachments: &[], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &atlas_view, - depth_ops: Some(wgpu::Operations { - // Only the first tile clears — clearing is a - // whole-texture operation in wgpu, so clearing again - // per tile would erase every tile drawn before it. - // Every pixel outside every tile's scissor rect stays - // at this initial 1.0 (far) regardless. - load: if slot == 0 { - wgpu::LoadOp::Clear(1.0) - } else { - wgpu::LoadOp::Load - }, - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }), - timestamp_writes: None, - occlusion_query_set: None, - }); - pass.set_viewport( - tile.x as f32, - tile.y as f32, - tile.w as f32, - tile.h as f32, - 0.0, - 1.0, - ); - pass.set_scissor_rect(tile.x, tile.y, tile.w, tile.h); - pass.set_pipeline(&renderer.mesh3d_shadow_pipeline); - for (layer, (vbuf, ibuf, index_count)) in mesh3d_layers.iter().zip(geometry.iter()) { - let model = - crate::engine::camera3d::model_matrix(layer.origin, layer.angles, layer.scale); - let light_mvp = crate::engine::camera3d::mat4_mul(&view_proj, &model); - let mut mvp_bytes = Vec::with_capacity(64); - for col in light_mvp.iter() { - for f in col { - mvp_bytes.extend_from_slice(&f.to_le_bytes()); - } - } - let mvp_buf = renderer.make_uniform_buffer(&mvp_bytes, 64); - let bg = renderer - .device - .create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("shadow_pass_bg"), - layout: &renderer.shadow_pass_bgl, - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: mvp_buf.as_entire_binding(), - }], - }); - pass.set_bind_group(0, &bg, &[]); - pass.set_vertex_buffer(0, vbuf.slice(..)); - pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..*index_count, 0, 0..1); - } - } - renderer.queue.submit(std::iter::once(encoder.finish())); - - (atlas, shadow_slots, slot_data) - } - - /// Interleave position+uv+normal into the vs_mesh3d vertex layout - /// (stride 32: 3 + 2 + 3 floats) and upload it plus the index buffer. - /// Shared by the final mesh3d draw (`build_mesh3d`) and the shadow-atlas - /// depth pass (`build_shadow_atlas`), which both need the same geometry - /// on the GPU but at different points in scene setup — the shadow atlas - /// has to exist before `build_mesh3d` can bind it, so its geometry - /// upload can't simply be reused from a `Mesh3dGpu` built later. - fn upload_mesh3d_geometry( - renderer: &GpuSceneRenderer, - mesh: &crate::engine::mesh3d::Mesh3d, - ) -> (wgpu::Buffer, wgpu::Buffer, u32) { - let mut verts: Vec = Vec::with_capacity(mesh.positions.len() * 32); - for ((p, uv), n) in mesh.positions.iter().zip(&mesh.uvs).zip(&mesh.normals) { - for f in [p[0], p[1], p[2], uv[0], uv[1], n[0], n[1], n[2]] { - verts.extend_from_slice(&f.to_le_bytes()); - } - } - let vbuf = renderer.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("mesh3d_verts"), - size: verts.len() as u64, - usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - renderer.queue.write_buffer(&vbuf, 0, &verts); - - let idx: Vec = mesh.indices.iter().flat_map(|i| i.to_le_bytes()).collect(); - let ibuf = renderer.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("mesh3d_indices"), - size: idx.len() as u64, - usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - renderer.queue.write_buffer(&ibuf, 0, &idx); - (vbuf, ibuf, mesh.indices.len() as u32) - } - - /// Upload one mesh's geometry and bake its MVP. Geometry and transform are - /// both static in real content, so this runs once at load. - fn build_mesh3d( - renderer: &GpuSceneRenderer, - cam: &crate::engine::camera3d::PerspectiveCamera, - layer: &crate::engine::render::Mesh3dLayer, - lighting_ubo: &wgpu::Buffer, - shadow_atlas_view: &wgpu::TextureView, - cookie_view: &wgpu::TextureView, - ) -> Mesh3dGpu { - let (vbuf, ibuf, index_count) = Self::upload_mesh3d_geometry(renderer, &layer.mesh); - - let params = mesh3d_transform_bytes(cam, layer.origin, layer.angles, layer.scale); - let ubo = renderer.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("mesh3d_xform"), - size: params.len() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - renderer.queue.write_buffer(&ubo, 0, ¶ms); - - let tex = renderer.upload_texture(&layer.texture); - let view = tex.create_view(&Default::default()); - let bind_group = renderer - .device - .create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("mesh3d_bg"), - layout: &renderer.mesh3d_bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: ubo.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(&view), - }, - wgpu::BindGroupEntry { - binding: 2, - // Meshes wrap their UVs (skyboxes/spheres tile). - resource: wgpu::BindingResource::Sampler(&renderer.samplers[0]), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: lighting_ubo.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 4, - resource: wgpu::BindingResource::TextureView(shadow_atlas_view), - }, - wgpu::BindGroupEntry { - binding: 5, - resource: wgpu::BindingResource::Sampler( - &renderer.shadow_comparison_sampler, - ), - }, - wgpu::BindGroupEntry { - binding: 6, - resource: wgpu::BindingResource::TextureView(cookie_view), - }, - wgpu::BindGroupEntry { - binding: 7, - resource: wgpu::BindingResource::Sampler(&renderer.samplers[0]), - }, - ], - }); - - Mesh3dGpu { - vbuf, - ibuf, - index_count, - bind_group, - nocull: layer.nocull, - depthtest: layer.depthtest, - ubo, - local: (layer.local_origin, layer.local_angles, layer.local_scale), - order_index: layer.order_index, - depth: cam.view_depth(layer.origin), - } - } - - /// For a composition/fullscreen layer, copy the scene drawn so far under - /// its quad into its texture, so its effects process the backdrop. - fn capture_backdrop(&self, encoder: &mut wgpu::CommandEncoder, layer_idx: usize) { - let layer = &self.layers[layer_idx]; - if layer.backdrop.is_none() { - return; - } - let dst = &layer.frames[0]; - let (w, h) = (self.width as i64, self.height as i64); - // Quad → target pixels (texture rows run top-down). - let x0 = (((layer.rect[0] - layer.rect[2]) + 1.0) * 0.5 * w as f32).round() as i64; - let y0 = ((1.0 - (layer.rect[1] + layer.rect[3])) * 0.5 * h as f32).round() as i64; - let (sx, sy) = (x0.max(0), y0.max(0)); - let (dx, dy) = (sx - x0, sy - y0); - let cw = (dst.width() as i64 - dx).min(w - sx); - let ch = (dst.height() as i64 - dy).min(h - sy); - if cw <= 0 || ch <= 0 { - return; - } - encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: &self.target, - mip_level: 0, - origin: wgpu::Origin3d { - x: sx as u32, - y: sy as u32, - z: 0, - }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: dst, - mip_level: 0, - origin: wgpu::Origin3d { - x: dx as u32, - y: dy as u32, - z: 0, - }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { - width: cw as u32, - height: ch as u32, - depth_or_array_layers: 1, - }, - ); - } - - /// Advance keyframe animations and apply the properties they move. - fn tick_timelines(&mut self, delta: f32) { - use crate::engine::script::{ObjectChange, ScriptChanges}; - if self.timelines.is_empty() { - return; - } - let mut changes: Vec<(usize, ObjectChange)> = Vec::new(); - let mut events: Vec<(usize, String)> = Vec::new(); - let mut synced: Vec<(usize, String, serde_json::Value)> = Vec::new(); - for t in &mut self.timelines { - for name in t.anim.advance(delta) { - events.push((t.object, name)); - } - let mut v = t.anim.sample(); - if t.anim.relative { - for (k, x) in v.iter_mut().enumerate() { - *x += t.base.get(k).copied().unwrap_or(0.0); - } - } - if t.last.as_ref() == Some(&v) { - continue; - } - t.last = Some(v.clone()); - let idx = match changes.iter().position(|(o, _)| *o == t.object) { - Some(i) => i, - None => { - changes.push((t.object, ObjectChange::default())); - changes.len() - 1 - } - }; - let c = &mut changes[idx].1; - let v3 = |fill: f32| -> [f32; 3] { - std::array::from_fn(|k| { - v.get(k).copied().or(t.base.get(k).copied()).unwrap_or(fill) - }) - }; - let json = if v.len() == 1 { - serde_json::json!(v[0]) - } else { - serde_json::json!(v) - }; - match t.key.as_str() { - "origin" => c.origin = Some(v3(0.0)), - "scale" => c.scale = Some(v3(1.0)), - "angles" => c.angles = Some(v3(0.0)), - "color" => c.color = Some(v3(1.0)), - "alpha" => c.alpha = v.first().copied(), - "brightness" => c.brightness = v.first().copied(), - key => { - c.extra.insert(key.to_string(), json.clone()); - } - } - synced.push((t.object, t.key.clone(), json)); - } - if self.scripts.has_scripts() { - for (object, key, value) in &synced { - self.scripts.sync_property(*object, key, value); - } - for (object, name) in &events { - self.scripts.dispatch_event( - *object, - "animationEvent", - &serde_json::json!({ "name": name }), - ); - } - } - if !changes.is_empty() { - self.apply_script_changes(ScriptChanges { - objects: changes, - commands: Vec::new(), - }); - } - } - - /// `thisLayer.getAnimation(name).play()` & co: control the animations on - /// object `index` named `property` (by animation name or property key), - /// along with the animations grouped with them. - fn control_timeline(&mut self, index: usize, property: &str, op: &str, frame: Option) { - let matched: Vec = self - .timelines - .iter() - .filter(|t| t.object == index) - .filter(|t| t.key == property || t.anim.name.as_deref() == Some(property)) - .flat_map(|t| std::iter::once(t.key.clone()).chain(t.anim.children.iter().cloned())) - .collect(); - for t in self.timelines.iter_mut().filter(|t| t.object == index) { - let grouped = matched.contains(&t.key) - || t.anim.parent.as_ref().is_some_and(|p| matched.contains(p)); - if grouped { - t.anim.control(op, frame); - } - } - } - - /// Run this frame's SceneScripts and apply what they changed. - fn tick_scripts(&mut self, time: f32, delta: f32) { - if !self.scripts.has_scripts() { - return; - } - #[cfg(target_os = "linux")] - if let Some(info) = self.media.as_ref().and_then(|w| w.try_recv()) { - use crate::engine::media::PlaybackState; - self.scripts.set_media( - info.available, - match info.playback_state { - PlaybackState::Stopped => 0, - PlaybackState::Playing => 1, - PlaybackState::Paused => 2, - }, - &info.title, - &info.artist, - &info.album, - info.position_us, - info.duration_us, - info.art_url.as_deref(), - ); - } - if self.scripts_want_audio { - if let Some(cap) = &self.audio_capture { - let sp = cap.spectrum(); - self.scripts.set_audio([ - &sp.s16_left, - &sp.s16_right, - &sp.s32_left, - &sp.s32_right, - &sp.s64_left, - &sp.s64_right, - ]); - } - } - self.scripts.set_cursor( - self.cursor_scene, - self.cursor_screen(), - self.cursor_left_down, - ); - self.dispatch_cursor_events(); - self.scripts - .frame(time, crate::engine::script::local_time_of_day(), delta); - let changes = self.scripts.collect(); - self.apply_script_changes(changes); - self.flush_text(); - - // `registerAudioBuffers` usually runs at module load, so this is - // known after the first frame; start capture then if nothing else did. - if !self.scripts_want_audio && self.scripts.wants_audio() { - self.scripts_want_audio = true; - if self.audio_capture.is_none() { - self.audio_capture = crate::engine::audio::AudioCapture::start(); - } - } - } - - /// Fire `cursorEnter`/`Leave`/`Move`/`Down`/`Up`/`Click` on handler - /// objects from this frame's pointer state. - fn dispatch_cursor_events(&mut self) { - let buttons = std::mem::take(&mut self.pending_buttons); - if self.cursor_targets.is_empty() { - return; - } - let pos = self.cursor_scene; - let hit = self.hit_test(self.mouse_norm); - if hit != self.hovered { - if let Some(old) = self.hovered { - self.scripts.dispatch_cursor(old, "cursorLeave", pos); - } - if let Some(new) = hit { - self.scripts.dispatch_cursor(new, "cursorEnter", pos); - } - self.hovered = hit; - } - if pos != self.last_cursor_scene { - self.last_cursor_scene = pos; - if let Some(h) = hit { - self.scripts.dispatch_cursor(h, "cursorMove", pos); - } - // A drag keeps reporting to the layer it started on. - if let Some(p) = self.pressed.filter(|p| Some(*p) != hit) { - self.scripts.dispatch_cursor(p, "cursorMove", pos); - } - } - for down in buttons { - if down { - self.pressed = hit; - if let Some(h) = hit { - self.scripts.dispatch_cursor(h, "cursorDown", pos); - } - } else if let Some(p) = self.pressed.take() { - self.scripts.dispatch_cursor(p, "cursorUp", pos); - if hit == Some(p) { - self.scripts.dispatch_cursor(p, "cursorClick", pos); - } - } - } - } - - /// The topmost cursor-handler object whose quad contains the cursor - /// (`norm`: `[0,1]²`, top-left origin). Hidden layers still count: - /// real content uses invisible hitboxes that toggle themselves. - fn hit_test(&self, norm: [f32; 2]) -> Option { - let (w, h) = (self.width as f32, self.height as f32); - // Output pixels, Y-up, origin at the centre — the space `rect` is in. - let p = [(norm[0] - 0.5) * w, (0.5 - norm[1]) * h]; - self.layers - .iter() - .filter(|l| self.cursor_targets.contains(&l.order_index)) - .filter(|l| { - let corners: [[f32; 2]; 4] = match l.quad_corners { - Some(c) => c.map(|c| [c[0] * w * 0.5, c[1] * h * 0.5]), - None => quad_corners_px(l.rect, l.angle, w, h), - }; - point_in_convex_quad(p, &corners) - }) - .map(|l| l.order_index) - .max() - } - - /// Record a left-button press or release (from the platform layer). - pub fn set_mouse_button(&mut self, left_down: bool) { - if left_down != self.cursor_left_down { - self.cursor_left_down = left_down; - self.pending_buttons.push(left_down); - } - } - - /// The cursor in output pixels, top-left origin (`input.cursorScreenPosition`). - fn cursor_screen(&self) -> [f32; 2] { - [ - self.mouse_norm[0] * self.width as f32, - self.mouse_norm[1] * self.height as f32, - ] - } - - /// Apply a frame's script changes: transforms recompose through the - /// parent graph, everything else lands on the object's layers and sounds. - fn apply_script_changes(&mut self, changes: crate::engine::script::ScriptChanges) { - use crate::engine::script::ScriptCommand; - let n = self.current_locals.len(); - let mut moved = vec![false; n]; - let mut visibility_changed = false; - for (i, c) in &changes.objects { - let i = *i; - if i >= n { - continue; - } - let x = &mut self.current_locals[i]; - if let Some(o) = c.origin { - x.t = o.map(|v| v as f64); - moved[i] = true; - } - if let Some(a) = c.angles { - x.r = a.map(|v| v as f64); - moved[i] = true; - } - if let Some(sc) = c.scale { - x.s = sc.map(|v| v as f64); - moved[i] = true; - } - if let Some(d) = c.propagation_disabled { - self.propagation_disabled[i] = d; - moved[i] = true; - } - if let Some(v) = c.visible { - self.object_visible[i] = v; - visibility_changed = true; - } - if let Some(v) = c.volume { - for snd in self.sounds_of(i) { - snd.set_volume(v); - } - } - for (key, value) in &c.extra { - self.apply_script_extra(i, key, value); - } - for li in 0..self.layers.len() { - if self.layers[li].order_index != i { - continue; - } - let layer = &mut self.layers[li]; - if let Some(v) = c.visible { - layer.visible = v; - } - if let Some(a) = c.alpha { - layer.alpha = a; - } - if let Some(col) = c.color { - layer.color = col; - } - if let Some(b) = c.brightness { - layer.brightness = b; - } - if let Some(f) = c.frame { - layer.script_frame = Some(f as usize); - } - if let Some(ps) = c.pointsize { - if let Some(td) = &mut layer.text_dynamic { - td.point_size = ps.max(1.0); - td.last_text.clear(); - } - } - if c.text.is_some() || c.pointsize.is_some() { - self.rerasterize_text(li, c.text.as_deref()); - } - } - } - for cmd in changes.commands { - match cmd { - ScriptCommand::Sound { index, op } => { - for snd in self.sounds_of(index) { - match op.as_str() { - "play" => snd.play(), - "pause" => snd.pause(), - "stop" => snd.stop(), - _ => {} - } - } - } - ScriptCommand::Sort { index, to } => self.sort_object(index, to), - ScriptCommand::Animation { - index, - property, - op, - frame, - } => self.control_timeline(index, &property, &op, frame), - ScriptCommand::Create { index, config } => { - self.create_object(index, &config); - visibility_changed = true; - } - ScriptCommand::Destroy { index } => { - // Hidden for good rather than removed: per-layer render - // targets are keyed by position in `self.layers`. - if let Some(v) = self.object_visible.get_mut(index) { - *v = false; - } - self.cursor_targets.retain(|t| *t != index); - visibility_changed = true; - } - other => { - tracing::debug!(target: "script", "unhandled script command {other:?}"); - } - } - } - if visibility_changed { - self.refresh_layer_visibility(); - } - if moved.iter().any(|m| *m) { - self.apply_object_transforms(&moved); - } - } - - /// The user properties `names` changed in the saved settings: show them - /// live. Returns `false` when the scene must be rebuilt instead (see - /// [`Self::apply_user_properties`]). - pub fn set_user_properties(&mut self, names: &[String]) -> bool { - let props = - crate::engine::properties::SceneProperties::from_project_dir(&self.live_props.dir); - self.apply_user_properties(props, names) - } - - /// Re-resolve the scene against `props` and push each object's - /// differences through the same path SceneScript writes take; scripts - /// get `applyUserProperties` for `names`. Returns `false` when a change - /// can't be applied live (scene-wide settings, an effect that wasn't - /// built at load, a field scripts can't set). - fn apply_user_properties( - &mut self, - props: crate::engine::properties::SceneProperties, - names: &[String], - ) -> bool { - let lp = &mut self.live_props; - lp.props = props; - let mut next = lp.raw.clone(); - lp.props.resolve_scene_json(&mut next); - let prev = std::mem::replace(&mut lp.resolved, next); - if prev.get("general") != lp.resolved.get("general") { - return false; - } - let objects = |v: &serde_json::Value| { - v.get("objects") - .and_then(|o| o.as_array()) - .cloned() - .unwrap_or_default() - }; - let (before, after) = (objects(&prev), objects(&lp.resolved)); - if before.len() != after.len() { - return false; - } - let user: serde_json::Map = names - .iter() - .filter_map(|n| { - Some(( - n.clone(), - serde_json::json!({ "value": lp.props.get(n)?.clone() }), - )) - }) - .collect(); - let mut objects = Vec::new(); - for (i, (a, b)) in before.iter().zip(&after).enumerate() { - let mut diffs = Vec::new(); - changed_leaves(a, b, &mut Vec::new(), &mut diffs); - if diffs.is_empty() { - continue; - } - let mut change = crate::engine::script::ObjectChange::default(); - for (path, v) in &diffs { - if self.live_change(i, path, v, &mut change).is_none() { - tracing::debug!(target: "properties", "object {i}: {} needs a reload", path.join(".")); - return false; - } - } - objects.push((i, change)); - } - self.apply_script_changes(crate::engine::script::ScriptChanges { - objects, - commands: Vec::new(), - }); - self.scripts - .apply_user_properties(&serde_json::Value::Object(user)); - true - } - - /// One changed scene.json field of object `object`, as a script change. - /// `None` when it has no live counterpart. - fn live_change( - &self, - object: usize, - path: &[String], - v: &serde_json::Value, - c: &mut crate::engine::script::ObjectChange, - ) -> Option<()> { - use crate::engine::scene::{parse_value_bool, parse_value_f32, parse_value_vec3}; - // `{"user": …, "value": X}` bindings resolve to their `value`. - let v = v.get("value").unwrap_or(v); - let vec3 = |v| parse_value_vec3(v).map(|a| a.map(|x| x as f32)); - let path: Vec<&str> = path.iter().map(String::as_str).collect(); - match path.as_slice() { - ["visible"] => c.visible = Some(parse_value_bool(v)?), - ["alpha"] => c.alpha = Some(parse_value_f32(v)?), - ["brightness"] => c.brightness = Some(parse_value_f32(v)?), - ["color"] => c.color = Some(vec3(v)?), - ["origin"] => c.origin = Some(vec3(v)?), - ["scale"] => c.scale = Some(vec3(v)?), - ["volume"] => c.volume = Some(parse_value_f32(v)?), - ["pointsize"] => c.pointsize = Some(parse_value_f32(v)?), - ["text"] => c.text = Some(v.as_str()?.to_string()), - ["instanceoverride", k] => { - c.extra.insert(format!("instance.{k}"), v.clone()); - } - ["effects", e, rest @ ..] => { - let e: usize = e.parse().ok()?; - // Effects hidden at load were never built. - self.scene_effects - .iter() - .any(|d| d.layer_idx == object && d.effect_index == e) - .then_some(())?; - match rest { - ["visible"] => { - c.extra - .insert(format!("effects.{e}.visible"), parse_value_bool(v)?.into()); - } - ["passes", pass, "constantshadervalues", k] => { - c.extra.insert(format!("effects.{e}.{pass}.{k}"), v.clone()); - } - _ => return None, - } - } - _ => return None, - } - Some(()) - } - - /// A script wrote a declared extra property: `instance.` (particle - /// override), `effects..visible`, or `effects...`. - fn apply_script_extra(&mut self, object: usize, key: &str, value: &serde_json::Value) { - if let Some(k) = key.strip_prefix("instance.") { - for (sys, _) in self - .particle_systems - .iter_mut() - .zip(&self.particle_order) - .filter(|(_, o)| **o == object) - { - sys.set_instance_value(k, value); - } - return; - } - let Some(rest) = key.strip_prefix("effects.") else { - return; - }; - let mut parts = rest.splitn(3, '.'); - let Some(e) = parts.next().and_then(|e| e.parse::().ok()) else { - return; - }; - let Some(k) = self - .scene_effects - .iter() - .position(|d| d.layer_idx == object && d.effect_index == e) - else { - return; - }; - match (parts.next(), parts.next()) { - (Some("visible"), None) => { - self.effect_enabled[k] = value.as_bool().unwrap_or(true); - } - (Some(pass), Some(name)) => { - let Some(pass) = pass.parse::().ok() else { - return; - }; - let Some(runtime) = self.effect_runtimes.get_mut(k).and_then(|r| r.as_mut()) else { - return; - }; - if let Some(p) = runtime.passes.get_mut(pass) { - // Vectors come back as arrays; the value parser wants "x y z". - let v = match value { - serde_json::Value::Array(a) => serde_json::Value::String( - a.iter() - .map(|x| x.as_f64().unwrap_or(0.0).to_string()) - .collect::>() - .join(" "), - ), - other => other.clone(), - }; - insert_value_components(&mut p.values, name, &v); - } - } - _ => {} - } - } - - /// `thisScene.sortLayer(layer, index)`: draw `index`'s object just below - /// the object currently at `to`, later sorts landing lower. - fn sort_object(&mut self, index: usize, to: usize) { - let Some(base) = self.draw_rank.get(to).copied() else { - return; - }; - self.sort_count += 1; - if let Some(r) = self.draw_rank.get_mut(index) { - *r = base - 0.5 + 0.49 / self.sort_count as f64; - } - } - - /// `thisScene.createLayer(config)`: build object `index` from a - /// scene.json-shaped config. Image, solid and text objects only; their - /// effects are not instantiated. - fn create_object(&mut self, index: usize, config: &serde_json::Value) { - use crate::engine::render::Xform; - // Keep every per-object table indexed in step with the script side, - // even when this object can't be built. - let n = index + 1; - if self.current_locals.len() < n { - self.current_locals.resize(n, Xform::IDENTITY); - self.transform_graph.parent.resize(n, None); - self.transform_graph.local.resize(n, Xform::IDENTITY); - self.transform_graph.name.resize(n, None); - self.transform_graph.id.resize(n, None); - self.object_visible.resize(n, true); - self.propagation_disabled.resize(n, false); - let len = self.draw_rank.len(); - self.draw_rank.extend((len..n).map(|i| i as f64)); - } - let obj: crate::engine::scene::SceneObject = match serde_json::from_value(config.clone()) { - Ok(o) => o, - Err(e) => { - tracing::warn!(target: "script", "createLayer: bad config: {e}"); - return; - } - }; - if obj.sound.is_some() { - if let Some(playback) = start_scene_sound(&self.scene_dir, &obj) { - self.sounds.push(SceneSound { - object: index, - playback, - }); - } - return; - } - if self.scene_pkg.is_none() { - self.scene_pkg = - crate::engine::pkg::Package::from_file(&self.scene_dir.join("scene.pkg")).ok(); - } - let Some(mut layer) = - crate::engine::render::runtime_layer(&self.scene_dir, self.scene_pkg.as_ref(), &obj) - else { - tracing::debug!(target: "script", "createLayer: object {index} has nothing to draw"); - return; - }; - layer.order_index = index; - let local = crate::engine::render::obj_local_xform(&obj); - self.current_locals[index] = local; - self.transform_graph.local[index] = local; - self.transform_graph.name[index] = obj.name.clone(); - self.object_visible[index] = obj.is_visible(); - let gpu = gpu_layer_from( - &self.renderer, - &layer, - self.width, - self.height, - self.zoom, - self.camera3d.as_ref(), - local.t, - ); - let (ow, oh) = (gpu.object_size.0.max(1), gpu.object_size.1.max(1)); - let li = self.layers.len(); - let device = self.renderer.device().clone(); - self.fbo_pool - .get_or_create(&device, &pingpong_key(li, 0), ow, oh); - self.fbo_pool - .get_or_create(&device, &pingpong_key(li, 1), ow, oh); - self.layers.push(gpu); - } - - /// Sound controls belonging to scene object `index`. - fn sounds_of(&self, index: usize) -> Vec> { - self.sounds - .iter() - .filter(|s| s.object == index) - .map(|s| s.playback.control()) - .collect() - } - - /// Queue new text (or, with `None`, a re-raster at a new point size) for - /// a text layer; [`Self::flush_text`] rasterizes it. - fn rerasterize_text(&mut self, li: usize, text: Option<&str>) { - let layer = &mut self.layers[li]; - let Some(td) = &layer.text_dynamic else { - return; - }; - let new_text = text - .map(str::to_string) - .or_else(|| layer.pending_text.clone()) - .unwrap_or_else(|| td.last_text.clone()); - if new_text.is_empty() || (text.is_some() && new_text == td.last_text) { - layer.pending_text = None; - return; - } - layer.pending_text = Some(new_text); - } - - /// Rasterize pending text. Throttled per layer to every 100 ms, or every - /// 10× the last raster's cost when that is longer, so a script that - /// rewrites a huge text block every frame (debug overlays, timers with - /// milliseconds) can't stall rendering; the newest text always lands. - fn flush_text(&mut self) { - let now = Instant::now(); - for li in 0..self.layers.len() { - let layer = &mut self.layers[li]; - let (Some(text), Some(td)) = (&layer.pending_text, &mut layer.text_dynamic) else { - continue; - }; - let wait = (layer.text_raster_cost * 10).max(Duration::from_millis(100)); - if layer - .text_rastered_at - .is_some_and(|t| now.duration_since(t) < wait) - { - continue; - } - let started = Instant::now(); - if let Some(img) = crate::engine::text::rasterize(&td.font_data, text, td.point_size) { - // The quad keeps its authored box; only the texture changes. - layer.frames[0] = self.renderer.upload_texture(&img); - td.last_text = text.clone(); - } - layer.text_raster_cost = started.elapsed(); - layer.text_rastered_at = Some(now); - layer.pending_text = None; - } - } - - /// A layer draws only when its own object and every ancestor are visible. - fn refresh_layer_visibility(&mut self) { - let parent = &self.transform_graph.parent; - let visible = &self.object_visible; - let effective = |mut i: usize| -> bool { - for _ in 0..64 { - if !visible.get(i).copied().unwrap_or(true) { - return false; - } - match parent.get(i).copied().flatten() { - Some(p) if p != i => i = p, - _ => return true, - } - } - true - }; - for layer in &mut self.layers { - layer.visible = effective(layer.order_index); - } - } - - /// Recompose world transforms from `current_locals` and push them into - /// the layers and meshes they move: those whose own object or any - /// ancestor `moved`. - fn apply_object_transforms(&mut self, moved: &[bool]) { - use crate::engine::render::Xform; - let n = self.current_locals.len(); - let parent = self.transform_graph.parent.clone(); - // affected[i]: object i or an ancestor moved. - let mut affected = vec![false; n]; - for i in 0..n { - let mut cur = i; - for _ in 0..64 { - if moved.get(cur).copied().unwrap_or(false) { - affected[i] = true; - break; - } - match parent.get(cur).copied().flatten() { - Some(p) if p != cur => cur = p, - _ => break, - } - } - } - let locals = self.current_locals.clone(); - let worlds = self - .transform_graph - .world(&locals, &self.propagation_disabled); - // The PARENT's world transform, not the object's own — `worlds[i]` - // already folds in object i's local transform. - let parent_world = |i: usize| -> Xform { - if self.propagation_disabled.get(i).copied().unwrap_or(false) { - return Xform::IDENTITY; - } - match parent.get(i).copied().flatten() { - Some(p) if p != i => worlds[p], - _ => Xform::IDENTITY, - } - }; - - // 3D meshes: rebuild the MVP from the recomposed world transform. - // Computed up front so the write-back can borrow `self.mesh3d` mutably. - let mesh_updates: Vec<(usize, Vec, f32)> = match &self.camera3d { - Some(cam) => self - .mesh3d - .iter() - .filter(|m| affected.get(m.order_index).copied().unwrap_or(false)) - .map(|m| { - let w = parent_world(m.order_index); - let (lo, la, ls) = match locals.get(m.order_index) { - Some(x) => ( - [x.t[0] as f32, x.t[1] as f32, x.t[2] as f32], - [x.r[0] as f32, x.r[1] as f32, x.r[2] as f32], - [x.s[0] as f32, x.s[1] as f32, x.s[2] as f32], - ), - None => m.local, - }; - let o = w.apply_point([lo[0] as f64, lo[1] as f64, lo[2] as f64]); - let centre = [o[0] as f32, o[1] as f32, o[2] as f32]; - let angles: [f32; 3] = std::array::from_fn(|i| la[i] + w.r[i] as f32); - let scale: [f32; 3] = std::array::from_fn(|i| ls[i] * w.s[i] as f32); - let bytes = mesh3d_transform_bytes(cam, centre, angles, scale); - (m.order_index, bytes, cam.view_depth(centre)) - }) - .collect(), - None => Vec::new(), - }; - for (idx, bytes, depth) in mesh_updates { - let Some(m) = self.mesh3d.iter_mut().find(|m| m.order_index == idx) else { - continue; - }; - self.renderer.queue.write_buffer(&m.ubo, 0, &bytes); - m.depth = depth; - } - - // 2D layers, both projections. The rect derivations differ (the - // perspective one projects the quad's corners through the camera) but - // both start from the same recomposed world transform. - let (w_px, h_px) = (self.width as f64, self.height as f64); - for layer in &mut self.layers { - let i = layer.order_index; - if !affected.get(i).copied().unwrap_or(false) { - continue; - } - let w = parent_world(i); - let local = locals[i]; - let o = w.apply_point(std::array::from_fn(|k| local.t[k] + layer.align_offset[k])); - let size_px = [ - layer.effective_size[0] * local.s[0] * w.s[0], - layer.effective_size[1] * local.s[1] * w.s[1], - ]; - let local_angles: [f32; 3] = std::array::from_fn(|k| local.r[k] as f32); - match &self.camera3d { - Some(cam) => { - let angles: [f32; 3] = std::array::from_fn(|k| local_angles[k] + w.r[k] as f32); - let centre = [o[0] as f32, o[1] as f32, o[2] as f32]; - let (rect, depth) = project_quad_ndc(cam, centre, angles, size_px); - layer.rect = rect; - layer.quad_corners = project_quad_corners(cam, centre, angles, size_px); - // Feeds the painter's-algorithm sort in `render()`; a - // negative depth culls the quad, same as at build. - layer.depth = depth; - } - None => { - // Same `general.zoom` scene-wide scale the build path uses. - let z = self.zoom; - layer.rect = [ - (2.0 * o[0] / w_px - 1.0) as f32 * z, - (2.0 * o[1] / h_px - 1.0) as f32 * z, - (size_px[0] / w_px) as f32 * z, - (size_px[1] / h_px) as f32 * z, - ]; - layer.angle = -(local_angles[2] + w.r[2] as f32); - } - } - } - } - - /// Draw every static mesh in one depth-tested pass, over the cleared scene - /// target and under the 2D layers. - /// - /// ponytail: meshes always draw before all 2D layers rather than - /// interleaving by `order_index` — real mesh scenes put their 2D content on - /// top (overlays/UI) so it hasn't mattered. Interleaving needs the depth - /// buffer shared with the composite pipeline. - /// Draw one mesh, depth-tested, into the scene target. - /// - /// Per-mesh rather than one batched pass because meshes interleave with 2D - /// layers by `order_index` — a scene's background layers sort *before* its - /// meshes and must not paint over them (3453730450's moon sits at order 10 - /// behind nine full-screen fills). `clear_depth` is set for the first mesh - /// of a frame; later ones load the existing buffer so they still occlude - /// each other correctly. - fn draw_one_mesh3d( - &self, - encoder: &mut wgpu::CommandEncoder, - target_view: &wgpu::TextureView, - idx: usize, - clear_depth: bool, - ) { - let (Some(depth), Some(m)) = (&self.mesh3d_depth, self.mesh3d.get(idx)) else { - return; - }; - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("mesh3d_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: target_view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: depth, - depth_ops: Some(wgpu::Operations { - load: if clear_depth { - wgpu::LoadOp::Clear(1.0) - } else { - wgpu::LoadOp::Load - }, - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }), - ..Default::default() - }); - pass.set_pipeline( - &self.renderer.mesh3d_pipelines[m.nocull as usize | ((!m.depthtest as usize) << 1)], - ); - pass.set_bind_group(0, &m.bind_group, &[]); - pass.set_vertex_buffer(0, m.vbuf.slice(..)); - pass.set_index_buffer(m.ibuf.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..m.index_count, 0, 0..1); - } - - pub fn size(&self) -> (u32, u32) { - (self.width, self.height) - } - - pub fn device(&self) -> &wgpu::Device { - self.renderer.device() - } - - pub fn queue(&self) -> &wgpu::Queue { - self.renderer.queue() - } - - /// Feed pointer position in [0,1]² (drives camera parallax). - pub fn set_mouse(&mut self, norm: [f32; 2]) { - self.mouse_norm = norm; - // Scene space is Y-up with the origin bottom-left. - self.cursor_scene = [ - norm[0] * self.width as f32, - (1.0 - norm[1]) * self.height as f32, - ]; - } - - /// Render the next frame into the internal scene target. - pub fn render(&mut self) { - let time = self.start.elapsed().as_secs_f32(); - let delta = (time - self.last_time).max(0.0); - self.last_time = time; - let dynamics = self.dynamics.update(time, delta, self.mouse_norm); - - self.tick_timelines(delta); - self.tick_scripts(time, delta); - - // Refresh the g_AudioSpectrum* UBO from the latest captured window, and - // drive audio-reactive particle emitters from the overall loudness. - if let Some(cap) = &self.audio_capture { - let spectrum = cap.spectrum(); - self.renderer.queue.write_buffer( - &self.renderer.audio_buf, - 0, - &spectrum.to_uniform_bytes(), - ); - let bands = - std::array::from_fn(|i| (spectrum.s16_left[i] + spectrum.s16_right[i]) * 0.5); - for system in &mut self.particle_systems { - system.set_audio_bands(bands); - } - } - - let target_view = self.target.create_view(&Default::default()); - let mut encoder = - self.renderer - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("frame_encoder"), - }); - - // 1. Clear scene target. - { - let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("clear_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &target_view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: self.clear_color[0], - g: self.clear_color[1], - b: self.clear_color[2], - a: 1.0, - }), - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - ..Default::default() - }); - } - - // Re-pose animated puppet layers at a capped rate: skin the mesh at - // the current animation time and re-upload frames[0]. CPU skinning + - // rasterization runs ~30ms at 4K, so this ticks at - // PUPPET_UPDATE_INTERVAL rather than every frame — the idle-style - // animations these carry (breathing, hair sway) are far slower than - // even that. - // Pull the newest decoded frame for embedded-video layers (the - // ffmpeg thread paces itself; an empty channel means no new frame - // this tick and the current texture stays). - for i in 0..self.layers.len() { - let Some(stream) = self.layers[i].video.clone() else { - continue; - }; - let Some(frame) = stream.lock().ok().and_then(|s| s.latest_frame()) else { - continue; - }; - let tex = self.renderer.upload_texture(&frame); - self.layers[i].frames[0] = tex; - } - - const PUPPET_UPDATE_INTERVAL: f32 = 1.0 / 15.0; - for i in 0..self.layers.len() { - let Some(runtime) = self.layers[i].puppet.clone() else { - continue; - }; - if (time - self.layers[i].puppet_posed_at).abs() < PUPPET_UPDATE_INTERVAL { - continue; - } - let (w, h) = (runtime.atlas.width(), runtime.atlas.height()); - let posed = runtime.render_at(time, w, h); - let tex = self.renderer.upload_texture(&posed); - self.layers[i].frames[0] = tex; - self.layers[i].puppet_posed_at = time; - } - - // 2/3. Image layers and particle systems, interleaved by - // `order_index` (true scene z-order, matching the reference's single - // shared per-object render order) instead of drawing all particles - // after all images. - enum DrawItem { - Image(usize), - Particle(usize), - Mesh(usize), - } - let mut items: Vec<(usize, DrawItem)> = self - .layers - .iter() - .enumerate() - // A `visible` script can hide a layer this frame — drop it here. - .filter(|(_, l)| l.visible) - .map(|(i, l)| (l.order_index, DrawItem::Image(i))) - .chain( - self.particle_systems - .iter() - .enumerate() - .map(|(i, _)| (self.particle_order[i], DrawItem::Particle(i))), - ) - // Meshes interleave by scene order like everything else: a scene's - // background layers can sort before its meshes. - .chain( - self.mesh3d - .iter() - .enumerate() - .map(|(i, m)| (m.order_index, DrawItem::Mesh(i))), - ) - .collect(); - let rank = |order: usize| self.draw_rank.get(order).copied().unwrap_or(order as f64); - items.sort_by(|(a, _), (b, _)| rank(*a).total_cmp(&rank(*b))); - - if self.perspective { - // Painter's algorithm for 3D scenes: draw image layers strictly - // back-to-front by view-space depth (no depth buffer yet), with - // culled quads (depth < 0) dropped. Particles keep drawing after - // images, as in the ortho path's known simplification. - items.retain(|(_, item)| match item { - DrawItem::Image(i) => self.layers[*i].depth >= 0.0, - DrawItem::Particle(_) => true, - // Never culled on centre depth — a skybox surrounds the camera. - DrawItem::Mesh(_) => true, - }); - items.sort_by(|(oa, a), (ob, b)| { - let key = |it: &DrawItem, order: usize| match it { - DrawItem::Image(i) => (0, -self.layers[*i].depth, order), - // Meshes share the images' back-to-front ordering so a - // distant backdrop layer still draws behind them. - DrawItem::Mesh(i) => (0, -self.mesh3d[*i].depth, order), - DrawItem::Particle(_) => (1, 0.0, order), - }; - key(a, *oa) - .partial_cmp(&key(b, *ob)) - .unwrap_or(std::cmp::Ordering::Equal) - }); - } - - // The depth buffer is cleared by the frame's first mesh; later meshes - // load it so they still occlude one another. - let mut mesh_depth_cleared = false; - for (_, item) in items { - match item { - DrawItem::Mesh(idx) => { - self.draw_one_mesh3d(&mut encoder, &target_view, idx, !mesh_depth_cleared); - mesh_depth_cleared = true; - } - DrawItem::Image(layer_idx) => { - self.draw_image_layer_gpu( - &mut encoder, - &target_view, - layer_idx, - time, - dynamics, - ); - } - DrawItem::Particle(idx) => { - self.draw_particle_layer_gpu(&mut encoder, &target_view, idx, delta); - } - } - } - - // 4. Volumetric light shafts (before bloom, so a bright shaft can - // itself contribute to the bloom pass — matching how particles are - // ordered before bloom for the same reason, see their own doc - // comment on `particle_systems`). - self.record_volumetrics(&mut encoder, &target_view); - - // 5. Scene bloom chain. - if self.bloom.enabled { - self.record_bloom(&mut encoder, &target_view); - } - - self.renderer.queue.submit(Some(encoder.finish())); - } - - fn draw_image_layer_gpu( - &self, - encoder: &mut wgpu::CommandEncoder, - target_view: &wgpu::TextureView, - layer_idx: usize, - time: f32, - dynamics: CameraFrameDynamics, - ) { - self.capture_backdrop(encoder, layer_idx); - { - let layer = &self.layers[layer_idx]; - let frame_idx = if let Some(f) = layer.script_frame { - f.min(layer.frames.len() - 1) - } else if layer.frames.len() > 1 && layer.frame_duration_ms > 0 { - ((time * 1000.0 / layer.frame_duration_ms as f32) as usize) % layer.frames.len() - } else { - 0 - }; - let base_tex = &layer.frames[frame_idx]; - let base_view = base_tex.create_view(&Default::default()); - let tex_res = |t: &wgpu::Texture| -> [f32; 4] { - let (tw, th) = (t.width() as f32, t.height() as f32); - [tw, th, tw, th] - }; - - // Engine uniforms shared by every pass of this layer. - let engine_base = EngineUniforms { - time, - daytime: daytime_fraction(), - texel_size: [1.0 / self.width as f32, 1.0 / self.height as f32], - pointer: self.mouse_norm, - resolutions: [[ - self.width as f32, - self.height as f32, - self.width as f32, - self.height as f32, - ]; 8], - color: layer.color, - alpha: layer.alpha, - brightness: layer.brightness, - }; - - // Ping-pong through this layer's effect passes (own object-sized pair). - let pp = [ - self.fbo_pool.get(&pingpong_key(layer_idx, 0)).unwrap(), - self.fbo_pool.get(&pingpong_key(layer_idx, 1)).unwrap(), - ]; - let layer_sampler = self - .renderer - .sampler_for(layer.no_interpolation, layer.clamp_uvs); - // Effects reference their owner by raw scene-object index - // (layer.order_index), NOT by position in `self.layers` — - // positional matching applied effects to whichever layer - // happened to share the count once particles/skipped objects - // offset the two spaces. - let obj_index = layer.order_index; - let has_effects = self - .scene_effects - .iter() - .zip(&self.effect_enabled) - .any(|(inst, on)| *on && inst.layer_idx == obj_index); - - // Base material pass (genericimage3-equivalent): reference always - // renders `texture * g_Color4` into the object's own FBO *before* - // any effects run, so effects see the tinted/alpha'd image rather - // than the raw texture (CImage::setup pushes the material's own - // passes first, ahead of the effect chain). Skipped when the layer - // has no effects: with nothing in between, tinting at the final - // composite step produces an identical result for a fraction of - // the draw calls. - let mut cur: Option = None; // None = still reading base texture - if has_effects { - let base_tint = [ - layer.color[0] * layer.brightness, - layer.color[1] * layer.brightness, - layer.color[2] * layer.brightness, - ]; - let base_pass_buf = self.renderer.make_uniform_buffer( - &composite_params( - layer.alpha, - 0, - [0.0, 0.0], - base_tint, - 0.0, - FULLSCREEN_RECT, - 1.0, - [1.0, 1.0], - ), - 64, - ); - let base_pass_bg = self.renderer.make_base_bind_group( - &base_view, - layer_sampler, - &base_pass_buf, - &[], - ); - self.renderer.run_pass( - encoder, - &self.renderer.base_pass_pipeline, - &base_pass_bg, - None, - &pp[0].view(), - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - "base_material_pass", - 6, - &[], - ); - cur = Some(0); - } - for (instance_idx, inst) in self - .scene_effects - .iter() - .enumerate() - .filter(|(k, inst)| inst.layer_idx == obj_index && self.effect_enabled[*k]) - { - let Some(runtime) = self - .effect_runtimes - .get(instance_idx) - .and_then(|r| r.as_ref()) - else { - continue; - }; - for pass in &runtime.passes { - let Some(pipeline) = self.renderer.find_effect_pipeline(&pass.key) else { - continue; - }; - - // Chain source before bind-0 overrides ("previous" binds - // and the default input both refer to it). - let (chain_view, chain_res) = match cur { - None => (base_view.clone(), tex_res(base_tex)), - Some(i) => (pp[i].view(), tex_res(&pp[i].texture)), - }; - - let mut engine = engine_base; - engine.resolutions[0] = chain_res; - - // Extra texture slots: shader/material textures first, - // FBO binds override. - let mut extra: Vec> = vec![None; 7]; - if let Some(texs) = self.renderer.dynamic_textures.get(&pass.tex_key) { - for (i, tex) in texs.iter().enumerate().take(7) { - extra[i] = Some(tex.create_view(&Default::default())); - engine.resolutions[i + 1] = tex_res(tex); - } - } - let mut src_view = chain_view.clone(); - for (slot, fbo_name) in &pass.binds { - // A bind named "previous" is the chain input itself. - let (view, res) = if fbo_name == "previous" { - (chain_view.clone(), chain_res) - } else if let Some(rt) = fbo_name - .starts_with("_rt_imageLayerComposite") - .then(|| self.fbo_pool.get(fbo_name.as_str())) - .flatten() - { - // The owning layer's own composite, captured when it - // drew (see draw_image_layer_gpu). Transparent when - // that layer hasn't drawn yet this frame, which adds - // nothing — the safe direction. - (rt.view(), tex_res(&rt.texture)) - } else if fbo_name == "_rt_FullFrameBuffer" - || fbo_name == "_rt_MipMappedFrameBuffer" - // A per-layer composite we never captured falls back - // to the scene-so-far snapshot: effects run before - // their layer composites, so the scene target holds - // "everything behind this layer". Without any bind - // the sampler keeps its `util/white` default — which - // is what blended 2821407073's mountains to white. - || fbo_name.starts_with("_rt_imageLayerComposite") - { - // The wallpaper-global scene buffer (CWallpaper.cpp - // creates it at scene size; MipMapped is an alias). - // Effects run before their layer composites, so the - // scene target currently holds exactly "the scene - // behind this layer" — snapshot it, since a pass - // can't sample its own render target. - encoder.copy_texture_to_texture( - self.target.as_image_copy(), - self.scene_copy.as_image_copy(), - wgpu::Extent3d { - width: self.width, - height: self.height, - depth_or_array_layers: 1, - }, - ); - ( - self.scene_copy.create_view(&Default::default()), - [ - self.width as f32, - self.height as f32, - self.width as f32, - self.height as f32, - ], - ) - } else { - let key = named_fbo_key(instance_idx, fbo_name); - match self.fbo_pool.get(&key) { - Some(rt) => (rt.view(), tex_res(&rt.texture)), - None => continue, - } - }; - if *slot == 0 { - src_view = view; - engine.resolutions[0] = res; - } else if (*slot as usize) <= 7 { - extra[(*slot - 1) as usize] = Some(view); - engine.resolutions[*slot as usize] = res; - } - } - - let params = if pass.hardcoded { - make_effect_params(&inst.name, time, &pass.values) - } else { - let keys = self - .renderer - .dynamic_uniform_keys - .get(&pass.key) - .map(|v| v.as_slice()) - .unwrap_or(&[]); - make_params_from_translated_typed(keys, &engine, &pass.values) - }; - - let composite_buf = self - .renderer - .make_uniform_buffer(&passthrough_composite_params(), 64); - let layer_sampler = self - .renderer - .sampler_for(layer.no_interpolation, layer.clamp_uvs); - let base_bg = self.renderer.make_base_bind_group( - &src_view, - layer_sampler, - &composite_buf, - &extra, - ); - let param_buf = self.renderer.make_uniform_buffer(¶ms, 16); - let effect_bg = self.renderer.make_effect_bind_group(¶m_buf); - - let next = cur.map(|i| 1 - i).unwrap_or(0); - let dst_view = match &pass.target { - Some(fbo_name) => { - let key = named_fbo_key(instance_idx, fbo_name); - match self.fbo_pool.get(&key) { - Some(rt) => rt.view(), - None => pp[next].view(), - } - } - None => pp[next].view(), - }; - - // Real-VS pipelines draw a 6-vertex quad from attribute - // buffers; synthetic-VS ones draw the fullscreen triangle. - let vertex_count = if pass.vertex_buffers.is_empty() { 3 } else { 6 }; - - self.renderer.run_pass( - encoder, - pipeline, - &base_bg, - Some(&effect_bg), - &dst_view, - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - "effect_pass", - vertex_count, - &pass.vertex_buffers, - ); - - // Named-target passes render aside; the chain continues - // from the same source. - if pass.target.is_none() { - cur = Some(next); - } - } - } - - // Composite the layer onto the scene target with camera dynamics. - let uv_offset = [ - dynamics.shake_offset[0] - + dynamics.parallax_displacement[0] * layer.parallax_depth[0], - dynamics.shake_offset[1] - + dynamics.parallax_displacement[1] * layer.parallax_depth[1], - ]; - // Color/alpha/brightness were already baked in by the base - // material pass above when this layer has effects; re-applying - // them here would double them up. - let (tint, opacity) = if has_effects { - ([1.0, 1.0, 1.0], dynamics.fade) - } else { - ( - [ - layer.color[0] * layer.brightness, - layer.color[1] * layer.brightness, - layer.color[2] * layer.brightness, - ], - dynamics.fade * layer.alpha, - ) - }; - let src_view = match cur { - None => base_view, - Some(i) => pp[i].view(), - }; - // True perspective-quad silhouette, normal blending only — see - // `vs_quad3d`/`fs_quad3d` and the Ghidra report's quad-warp - // finding. `uv_offset` (shake/parallax) isn't applied on this - // path: under true perspective it belongs on the camera or the - // object's world position, not a flat post-hoc UV shift, and - // wiring that through is future work, not this fix. Falls - // through to the same draw + `_rt_imageLayerComposite` capture - // code below as the rect-based path, just with a different - // pipeline/bind group, so neither can drift out of sync with - // the other. - let (pipeline, base_bg) = if layer.blend_mode == 0 && layer.quad_corners.is_some() { - let corners = layer.quad_corners.unwrap(); - let mut params = Vec::with_capacity(80); - for c in corners { - params.extend_from_slice(&c[0].to_le_bytes()); - params.extend_from_slice(&c[1].to_le_bytes()); - params.extend_from_slice(&0.0f32.to_le_bytes()); - params.extend_from_slice(&0.0f32.to_le_bytes()); - } - for f in [tint[0], tint[1], tint[2], opacity] { - params.extend_from_slice(&f.to_le_bytes()); - } - let quad3d_buf = self.renderer.make_uniform_buffer(¶ms, 80); - let quad3d_bg = - self.renderer - .device - .create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("quad3d_bg"), - layout: &self.renderer.quad3d_bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: quad3d_buf.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(&src_view), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::Sampler(layer_sampler), - }, - ], - }); - (&self.renderer.quad3d_pipeline, quad3d_bg) - } else { - let composite_buf = self.renderer.make_uniform_buffer( - &composite_params( - opacity, - layer.blend_mode as i32, - uv_offset, - tint, - layer.angle, - layer.rect, - self.width as f32 / self.height as f32, - [self.width as f32, self.height as f32], - ), - 64, - ); - // Photoshop-style blend modes read the destination - // in-shader, so snapshot the scene target before drawing - // over it. - let extra: Vec> = if layer.blend_mode != 0 { - encoder.copy_texture_to_texture( - self.target.as_image_copy(), - self.scene_copy.as_image_copy(), - wgpu::Extent3d { - width: self.width, - height: self.height, - depth_or_array_layers: 1, - }, - ); - vec![Some(self.scene_copy.create_view(&Default::default()))] - } else { - vec![] - }; - let base_bg = self.renderer.make_base_bind_group( - &src_view, - layer_sampler, - &composite_buf, - &extra, - ); - (self.renderer.composite_pipeline(layer.blend_mode), base_bg) - }; - self.renderer.run_pass( - encoder, - pipeline, - &base_bg, - None, - target_view, - wgpu::LoadOp::Load, - "composite_pass", - 6, - &[], - ); - - // Capture this layer's own composite for effects on other objects - // that bind `_rt_imageLayerComposite__*` (WE keeps one - // buffer per object; ours is filled with the same draw, on a - // cleared scene-sized target so only this layer is in it). - if let Some(id) = layer.object_id { - for suffix in ['a', 'b'] { - let name = format!("_rt_imageLayerComposite_{id}_{suffix}"); - if let Some(rt) = self.fbo_pool.get(name.as_str()) { - self.renderer.run_pass( - encoder, - pipeline, - &base_bg, - None, - &rt.view(), - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - "layer_composite_capture", - 6, - &[], - ); - } - } - } - } - } - - /// Steps and CPU-renders one particle system into a scene-sized RGBA - /// buffer, uploads it, and composites it like a normal fullscreen layer. - /// Steps a particle system and, if anything is alive, CPU-rasters + - /// uploads only its bounding box (`ParticleSystem::bounds()`) instead of - /// a full scene-sized buffer, compositing it as a positioned quad (the - /// same `rect`/`CompositeParams` mechanism image layers use) rather than - /// a fullscreen passthrough. Systems with no living particles are - /// skipped entirely — no raster, no allocation, no upload, no draw call. - /// A further possible optimization (not done here): reusing one - /// persistent GPU texture per system across frames instead of a fresh - /// `upload_texture` each frame — deferred since safely doing that while - /// the bbox size changes frame-to-frame needs UV-subregion plumbing the - /// composite shader doesn't have today. - fn draw_particle_layer_gpu( - &mut self, - encoder: &mut wgpu::CommandEncoder, - target_view: &wgpu::TextureView, - idx: usize, - delta: f32, - ) { - // Escape hatch for A/B comparison against the old budgeted CPU - // rasterizer during the GPU-pipeline transition. - if std::env::var("WP_PARTICLE_CPU").is_ok() { - self.draw_particle_layer_cpu_raster(encoder, target_view, idx, delta); - return; - } - self.particle_systems[idx].step(delta); - - // Draw units: the parent system, then every live child instance - // (each child preset has its own sprite/blending). - let system = &self.particle_systems[idx]; - let assets = &self.particle_gpu_assets[idx]; - let mut units: Vec<(Vec, &ParticleGpuTex, bool)> = Vec::new(); - let mut verts = Vec::new(); - system.emit_gpu_vertices(&mut verts, assets.parent.frames as usize); - if !verts.is_empty() { - units.push((verts, &assets.parent, self.particle_additive[idx])); - } - system.visit_gpu_children(&mut |inst, child_idx, child_additive| { - let tex = assets.children.get(child_idx).unwrap_or(&assets.parent); - let mut v = Vec::new(); - inst.emit_gpu_vertices(&mut v, tex.frames as usize); - if !v.is_empty() { - units.push((v, tex, child_additive)); - } - }); - - for (verts, tex, additive) in units { - let bytes = particle::GpuVertex::as_bytes(&verts); - let vbuf = self.renderer.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("particle_verts"), - size: bytes.len() as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - self.renderer.queue.write_buffer(&vbuf, 0, &bytes); - - let mut params = Vec::with_capacity(16); - for f in [ - self.width as f32, - self.height as f32, - tex.overbright, - tex.frames as f32, - ] { - params.extend_from_slice(&f.to_le_bytes()); - } - let pbuf = self.renderer.make_uniform_buffer(¶ms, 16); - // Linear-repeat for ropes (V tiles past 1.0), linear-clamp for - // sprite quads (padding-safe at the edges). - let sampler = &self.renderer.samplers[if tex.repeat { 0 } else { 1 }]; - let bg = self - .renderer - .device - .create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("particle_bg"), - layout: &self.renderer.particle_bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: vbuf.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(&tex.view), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::Sampler(sampler), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: pbuf.as_entire_binding(), - }, - ], - }); - - let pipeline = if additive { - &self.renderer.particle_pipeline_add - } else { - &self.renderer.particle_pipeline_over - }; - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("particle_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: target_view, - depth_slice: None, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - }); - pass.set_pipeline(pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..verts.len() as u32, 0..1); - } - } - - /// The pre-GPU-pipeline particle path: budgeted CPU rasterization into a - /// bbox buffer, uploaded and composited as a stretched quad. Kept behind - /// WP_PARTICLE_CPU=1 for A/B comparison; the CPU compositor paths - /// (render.rs/animated.rs) share the same underlying rasterizer. - fn draw_particle_layer_cpu_raster( - &mut self, - encoder: &mut wgpu::CommandEncoder, - target_view: &wgpu::TextureView, - idx: usize, - delta: f32, - ) { - let system = &mut self.particle_systems[idx]; - let _t_step = std::time::Instant::now(); - system.step(delta); - let step_ms = _t_step.elapsed().as_secs_f32() * 1000.0; - let Some((min_x, min_y, max_x, max_y)) = system.bounds() else { - return; - }; - let min_x = min_x.max(0.0); - let min_y = min_y.max(0.0); - let max_x = max_x.min(self.width as f32); - let max_y = max_y.min(self.height as f32); - if max_x <= min_x || max_y <= min_y { - return; - } - let bw = (max_x - min_x).ceil().max(1.0) as u32; - let bh = (max_y - min_y).ceil().max(1.0) as u32; - - // Cap the CPU rasterization cost: huge soft sprites (smoke clouds, - // rain sheets with radii in the hundreds of pixels) can cover most - // of a 4K scene — rasterize into a budgeted buffer and let the - // composite quad stretch it back up. Cost scales with the square of - // this factor; the blur from upscaling is invisible on sprites that - // are soft gradients to begin with (e.g. 2491009392's rain+smoke - // went from ~0.6s/frame of CPU to real-time). - const PARTICLE_PIXEL_BUDGET: f32 = 1_500_000.0; - let area = bw as f32 * bh as f32; - // Budget against total *coverage* (sum of per-particle quad areas), - // not the buffer size: overdraw from many huge overlapping sprites - // is what actually costs, and it shrinks with scale² the same way. - let coverage = system.coverage(area).max(area); - let raster_scale = (PARTICLE_PIXEL_BUDGET / coverage).sqrt().min(1.0).max(0.2); - let sw = ((bw as f32 * raster_scale).ceil() as u32).max(1); - let sh = ((bh as f32 * raster_scale).ceil() as u32).max(1); - - let additive = self.particle_additive.get(idx).copied().unwrap_or(false); - let mut buf = RgbaImage::new(sw, sh); - let _t_raster = std::time::Instant::now(); - system.render_onto_scaled( - &mut buf, - self.particle_sprites[idx].as_ref(), - [min_x, min_y], - additive, - raster_scale, - ); - if std::env::var("WP_DEBUG_PARTICLE_TIMING").is_ok() { - tracing::trace!( - target: "timing", - "particles[{idx}]: step {step_ms:.1}ms, raster {sw}x{sh} (scale {raster_scale:.2}) {:.1}ms", - _t_raster.elapsed().as_secs_f32() * 1000.0 - ); - } - let tex = self.renderer.upload_texture(&buf); - let view = tex.create_view(&Default::default()); - let sampler = self.renderer.sampler_for(false, false); - - // Same NDC `rect` convention as image layers (`gpu_shaders.wgsl`'s - // `vs_composite_quad`): center + half-extent, Y-up — flip our - // pixel-space (Y-down) bbox center back into WE's Y-up scene - // convention first, matching `spawn_center`'s own flip. - let cx_px = min_x + bw as f32 / 2.0; - let cy_px = min_y + bh as f32 / 2.0; - let we_cy = self.height as f32 - cy_px; - let rect = [ - 2.0 * cx_px / self.width as f32 - 1.0, - 2.0 * we_cy / self.height as f32 - 1.0, - bw as f32 / self.width as f32, - bh as f32 / self.height as f32, - ]; - // Additive particle materials (fog/smoke/embers/rain/lightning — the - // overwhelming majority of real particle materials) need the - // dest-read blend pipeline. Mode 30 is our pure premultiplied add: - // the rasterizer above already accumulated `src * src_a` per - // particle (the reference's GL_SRC_ALPHA/GL_ONE quad blending), so - // the composite adds the buffer's RGB to the scene as-is. Plain - // alpha-over (mode 0) otherwise makes a sprite's near-black - // background visibly darken/box the scene instead of contributing - // nothing, since particle compositing previously always used mode 0. - const BLEND_MODE_PARTICLE_ADD: i32 = 100; - let mode = if additive { BLEND_MODE_PARTICLE_ADD } else { 0 }; - let composite_buf = self.renderer.make_uniform_buffer( - &composite_params( - 1.0, - mode, - [0.0, 0.0], - [1.0, 1.0, 1.0], - 0.0, - rect, - self.width as f32 / self.height as f32, - [self.width as f32, self.height as f32], - ), - 64, - ); - let extra: Vec> = if additive { - encoder.copy_texture_to_texture( - self.target.as_image_copy(), - self.scene_copy.as_image_copy(), - wgpu::Extent3d { - width: self.width, - height: self.height, - depth_or_array_layers: 1, - }, - ); - vec![Some(self.scene_copy.create_view(&Default::default()))] - } else { - vec![] - }; - let base_bg = self - .renderer - .make_base_bind_group(&view, sampler, &composite_buf, &extra); - self.renderer.run_pass( - encoder, - self.renderer.composite_pipeline(mode as u32), - &base_bg, - None, - target_view, - wgpu::LoadOp::Load, - "particle_composite", - 6, - &[], - ); - } - - /// Bloom: threshold at quarter res, gaussian blur at quarter and eighth - /// res, then additive combine back onto the scene target. - /// Reproduces the reference's exact four-pass chain (`downsample_quarter_bloom` - /// → `downsample_eighth_blur_v` → `blur_h_bloom` → `combine`, all real WE - /// utility shaders the linux port wires up as a hidden fullscreen effect — - /// see CScene.cpp/WallpaperApplication.cpp). All texel offsets use the - /// *scene's* texel size, matching WE's always-scene-relative `g_TexelSize`. - /// Volumetric-light-shaft ray march, generated at quarter res, blurred - /// (reusing the bloom chain's own separable-blur pipeline unmodified — - /// it's already a generic single-source-texture operation), then - /// additively combined onto the scene (reusing bloom's combine pipeline - /// too, for the same reason). See `build_volumetrics`/`fs_volumetrics` - /// and the Ghidra report's `_rt_volumetrics*` follow-up. A no-op unless - /// `self.volumetrics_bind_group` is `Some` (a `castvolumetrics` light - /// exists) — checked by the one caller, `render()`. - fn record_volumetrics( - &self, - encoder: &mut wgpu::CommandEncoder, - target_view: &wgpu::TextureView, - ) { - let Some(vol_bg) = &self.volumetrics_bind_group else { - return; - }; - let buf = self.fbo_pool.get("_rt_VolumetricsBuffer").unwrap(); - let tmp = self.fbo_pool.get("_rt_VolumetricsBlurTmp").unwrap(); - let buf_texel = [1.0 / buf.width as f32, 1.0 / buf.height as f32]; - - // Snapshot the pre-volumetrics scene: the combine pass both reads - // and writes `self.target`, which a GPU can't do within one pass — - // same constraint `record_bloom` works around the same way. - encoder.copy_texture_to_texture( - self.target.as_image_copy(), - self.scene_copy.as_image_copy(), - wgpu::Extent3d { - width: self.width, - height: self.height, - depth_or_array_layers: 1, - }, - ); - let scene_copy_view = self.scene_copy.create_view(&Default::default()); - - // Pass 1: the ray march itself — its own dedicated bind group - // (shadow atlas + comparison sampler + this light's params), not - // the generic base_bgl/effect_bgl shape the blur/combine passes - // below use. - self.renderer.run_pass( - encoder, - &self.renderer.volumetrics_pipeline, - vol_bg, - None, - &buf.view(), - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - "volumetrics_march", - 3, - &[], - ); - - let run = |encoder: &mut wgpu::CommandEncoder, - pipeline: &wgpu::RenderPipeline, - src: &wgpu::TextureView, - extra: &[Option], - dst: &wgpu::TextureView, - params: &[u8]| { - let default_sampler = self.renderer.sampler_for(false, false); - let passthrough = self - .renderer - .make_uniform_buffer(&passthrough_composite_params(), 64); - let base_bg = - self.renderer - .make_base_bind_group(src, default_sampler, &passthrough, extra); - let param_buf = self.renderer.make_uniform_buffer(params, 16); - let effect_bg = self.renderer.make_effect_bind_group(¶m_buf); - self.renderer.run_pass( - encoder, - pipeline, - &base_bg, - Some(&effect_bg), - dst, - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - "volumetrics_blur_combine", - 3, - &[], - ); - }; - let f32s = - |vals: &[f32]| -> Vec { vals.iter().flat_map(|f| f.to_le_bytes()).collect() }; - - // Passes 2-3: separable blur, in place within the same quarter-res - // buffer (via the tmp buffer) — softens the fixed-step-count ray - // march's banding and gives the shaft a less hard-edged look. - run( - encoder, - &self.renderer.bloom_blur_pipeline, - &buf.view(), - &[], - &tmp.view(), - &f32s(&[buf_texel[0] * 2.0, 0.0, 0.0, 0.0]), - ); - run( - encoder, - &self.renderer.bloom_blur_pipeline, - &tmp.view(), - &[], - &buf.view(), - &f32s(&[0.0, buf_texel[1] * 2.0, 0.0, 0.0]), - ); - // Pass 4: additive combine onto the scene (combine.frag: plain - // `scene + src`, the same math bloom's own combine pass uses). - run( - encoder, - &self.renderer.bloom_combine_pipeline, - &buf.view(), - &[Some(scene_copy_view)], - target_view, - &f32s(&[0.0, 0.0, 0.0, 0.0]), - ); - } - - fn record_bloom(&self, encoder: &mut wgpu::CommandEncoder, target_view: &wgpu::TextureView) { - let q = self.fbo_pool.get("_rt_4FrameBuffer").unwrap(); - let e = self.fbo_pool.get("_rt_8FrameBuffer").unwrap(); - let bloom_rt = self.fbo_pool.get("_rt_Bloom").unwrap(); - let scene_view = self.target.create_view(&Default::default()); - let scene_texel = [1.0 / self.width as f32, 1.0 / self.height as f32]; - - // Snapshot the pre-bloom scene: the final combine pass both reads - // and writes `self.target`, which a GPU can't do within one pass. - encoder.copy_texture_to_texture( - self.target.as_image_copy(), - self.scene_copy.as_image_copy(), - wgpu::Extent3d { - width: self.width, - height: self.height, - depth_or_array_layers: 1, - }, - ); - let scene_copy_view = self.scene_copy.create_view(&Default::default()); - - let passthrough = self - .renderer - .make_uniform_buffer(&passthrough_composite_params(), 64); - - let run = |encoder: &mut wgpu::CommandEncoder, - pipeline: &wgpu::RenderPipeline, - src: &wgpu::TextureView, - extra: &[Option], - dst: &wgpu::TextureView, - params: &[u8]| { - let default_sampler = self.renderer.sampler_for(false, false); - let base_bg = - self.renderer - .make_base_bind_group(src, default_sampler, &passthrough, extra); - let param_buf = self.renderer.make_uniform_buffer(params, 16); - let effect_bg = self.renderer.make_effect_bind_group(¶m_buf); - self.renderer.run_pass( - encoder, - pipeline, - &base_bg, - Some(&effect_bg), - dst, - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - "bloom", - 3, - &[], - ); - }; - - let f32s = - |vals: &[f32]| -> Vec { vals.iter().flat_map(|f| f.to_le_bytes()).collect() }; - - // Pass 1: threshold + 4-tap box downsample, scene → quarter - // (downsample_quarter_bloom.frag: texel/threshold/strength; tint fixed - // at white since it isn't exposed as a scene setting). - run( - encoder, - &self.renderer.bloom_threshold_pipeline, - &scene_view, - &[], - &q.view(), - &f32s(&[ - scene_texel[0], - scene_texel[1], - self.bloom.threshold, - self.bloom.strength, - ]), - ); - // Pass 2: 13-tap blur along x (step = scene texel * 8), quarter → eighth. - run( - encoder, - &self.renderer.bloom_blur_pipeline, - &q.view(), - &[], - &e.view(), - &f32s(&[scene_texel[0] * 8.0, 0.0, 0.0, 0.0]), - ); - // Pass 3: 13-tap blur along y (step = scene texel * 8), eighth → bloom. - run( - encoder, - &self.renderer.bloom_blur_pipeline, - &e.view(), - &[], - &bloom_rt.view(), - &f32s(&[0.0, scene_texel[1] * 8.0, 0.0, 0.0]), - ); - // Pass 4: combine.frag — plain add of the pre-bloom scene and bloom. - run( - encoder, - &self.renderer.bloom_combine_pipeline, - &bloom_rt.view(), - &[Some(scene_copy_view)], - target_view, - &f32s(&[0.0, 0.0, 0.0, 0.0]), - ); - } - - /// Render one frame and read it back to CPU RGBA (preview / test / SHM - /// fallback paths). - pub fn render_rgba(&mut self) -> Result { - self.render(); - // Temporary debug hook: dump every pooled render target to PNG. - if std::env::var("WP_DEBUG_DUMP_FBOS").is_ok() { - for (name, rt) in self.fbo_pool.iter() { - if let Ok(img) = self.renderer.readback(&rt.texture, rt.width, rt.height) { - let safe = name.replace([':', '/'], "_"); - let _ = img.save(format!("/tmp/fbo_{safe}.png")); - let mut a = img.clone(); - for p in a.pixels_mut() { - p.0 = [p.0[3], p.0[3], p.0[3], 255]; - } - let _ = a.save(format!("/tmp/fbo_{safe}_alpha.png")); - } - } - } - self.renderer - .readback(&self.target, self.width, self.height) - } - - /// Render one frame and blit it aspect-fill into an external texture view - /// (e.g. an acquired Wayland surface frame). No CPU readback happens. - pub fn render_to_view( - &mut self, - view: &wgpu::TextureView, - view_w: u32, - view_h: u32, - format: wgpu::TextureFormat, - ) { - self.render(); - let full = crate::render::settings::FULL_REGION; - self.present_region(view, format, (view_w, view_h), full); - } - - /// Blit the last rendered frame into `view`, which shows `region` of a - /// `canvas` the scene is aspect-filled into (a spanned or cloned - /// wallpaper renders once and presents to every monitor this way). - pub fn present_region( - &mut self, - view: &wgpu::TextureView, - format: wgpu::TextureFormat, - canvas: (u32, u32), - region: [f32; 4], - ) { - let (uv_scale, uv_offset) = crate::render::ScaleMode::Fill.uv_transform_region( - (self.width, self.height), - canvas, - region, - ); - let mut params = [0u8; 16]; - params[0..4].copy_from_slice(&uv_scale[0].to_le_bytes()); - params[4..8].copy_from_slice(&uv_scale[1].to_le_bytes()); - params[8..12].copy_from_slice(&uv_offset[0].to_le_bytes()); - params[12..16].copy_from_slice(&uv_offset[1].to_le_bytes()); - - let blit_buf = self.renderer.make_uniform_buffer(¶ms, 16); - let scene_view = self.target.create_view(&Default::default()); - let default_sampler = self.renderer.sampler_for(false, false); - let base_bg = - self.renderer - .make_base_bind_group(&scene_view, default_sampler, &blit_buf, &[]); - - // Ensure the pipeline exists before borrowing immutably for the pass. - self.renderer.blit_pipeline(format); - let pipeline = &self.renderer.blit_pipelines[&format]; - - let mut encoder = - self.renderer - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("present_blit"), - }); - self.renderer.run_pass( - &mut encoder, - pipeline, - &base_bg, - None, - view, - wgpu::LoadOp::Clear(wgpu::Color::BLACK), - "present_blit", - 3, - &[], - ); - self.renderer - .queue - .submit(std::iter::once(encoder.finish())); - } -} - -fn named_fbo_key(instance_idx: usize, fbo_name: &str) -> String { - format!("fx{instance_idx}:{fbo_name}") -} - -/// Fraction of the local day in [0,1) (the reference's g_Daytime). -/// `g_Daytime`: fraction of the local day elapsed. -fn daytime_fraction() -> f32 { - crate::engine::script::local_time_of_day() -} - -#[allow(clippy::too_many_arguments)] -fn composite_params( - opacity: f32, - mode: i32, - uv_offset: [f32; 2], - color: [f32; 3], - angle: f32, - rect: [f32; 4], - aspect: f32, - resolution: [f32; 2], -) -> [u8; 64] { - // WGSL CompositeParams layout: opacity@0, mode@4, uv_offset@8, color@16, - // angle@28, rect@32, aspect@48, resolution@56 (bytes 52-55 are padding). - let mut data = [0u8; 64]; - data[0..4].copy_from_slice(&opacity.to_le_bytes()); - data[4..8].copy_from_slice(&mode.to_le_bytes()); - data[8..12].copy_from_slice(&uv_offset[0].to_le_bytes()); - data[12..16].copy_from_slice(&uv_offset[1].to_le_bytes()); - data[16..20].copy_from_slice(&color[0].to_le_bytes()); - data[20..24].copy_from_slice(&color[1].to_le_bytes()); - data[24..28].copy_from_slice(&color[2].to_le_bytes()); - data[28..32].copy_from_slice(&angle.to_le_bytes()); - for (i, v) in rect.iter().enumerate() { - data[32 + i * 4..36 + i * 4].copy_from_slice(&v.to_le_bytes()); - } - data[48..52].copy_from_slice(&aspect.to_le_bytes()); - data[56..60].copy_from_slice(&resolution[0].to_le_bytes()); - data[60..64].copy_from_slice(&resolution[1].to_le_bytes()); - data -} - -/// Fullscreen rect: centered, full extents. -const FULLSCREEN_RECT: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; - -/// Always mode 0 (`fs_composite`, never reads `dest_copy_tex`), so the -/// resolution field is unused — a dummy value is harmless here. -fn passthrough_composite_params() -> [u8; 64] { - composite_params( - 1.0, - 0, - [0.0, 0.0], - [1.0, 1.0, 1.0], - 0.0, - FULLSCREEN_RECT, - 1.0, - [1.0, 1.0], - ) -} - -/// Load pipelines/textures for every effect instance in the scene: hardcoded -/// kernels get single-pass runtimes, everything else is loaded from the WE -/// assets directory pass-by-pass and translated GLSL→WGSL with the instance's -/// combos baked in. -fn load_effect_runtimes( - renderer: &mut GpuSceneRenderer, - wallpaper_dir: &Path, - scene_effects: &[EffectInstanceDef], -) -> Vec> { - let assets_dir = loader::find_we_assets_dir(); - // Wallpaper-local effects/materials/shaders (loose files or scene.pkg) - // take priority over the global Steam assets dir, mirroring the - // reference's Container mount order (dir, scene.pkg, gifscene.pkg, assets). - let resolver = resolver::AssetResolver::new(Some(wallpaper_dir), assets_dir); - - // Debug escape hatch: WP_ENGINE_SKIP_EFFECTS=clouds,bloom disables the - // named effects (useful to bisect a misbehaving shader translation). - let skip: Vec = std::env::var("WP_ENGINE_SKIP_EFFECTS") - .map(|v| v.split(',').map(|s| s.trim().to_lowercase()).collect()) - .unwrap_or_default(); - - scene_effects - .iter() - .enumerate() - .map(|(instance_idx, inst)| { - if skip.contains(&inst.name) { - tracing::debug!( - target: "effect", - "SKIP '{}': disabled via WP_ENGINE_SKIP_EFFECTS", - inst.name - ); - return None; - } - load_effect_instance(renderer, &resolver, instance_idx, inst) - }) - .collect() -} - -#[tracing::instrument(target = "effect", level = "debug", skip(renderer, resolver, inst), fields(effect = %inst.name, instance = instance_idx))] -fn load_effect_instance( - renderer: &mut GpuSceneRenderer, - resolver: &resolver::AssetResolver, - instance_idx: usize, - inst: &EffectInstanceDef, -) -> Option { - let effect_name = &inst.name; - tracing::trace!(target: "effect", "loading effect instance"); - if HARDCODED_EFFECTS.contains(&effect_name.as_str()) { - // Scene instances may override secondary texture slots (typically - // slot 1 = an opacity mask, e.g. waterwaves/shake masks) — load - // them under a per-instance key so the kernel's extra-slot binding - // sees the real mask instead of the white 1×1 dummy. - let tex_key = format!("fx{instance_idx}:{effect_name}#0"); - let mut textures: Vec = Vec::new(); - if let Some(over) = inst.pass_overrides.first() { - for (slot_i, name) in over.textures.iter().skip(1).enumerate() { - let img = name - .as_deref() - .filter(|n| !n.is_empty() && !n.starts_with("_rt_")) - .and_then(|n| { - let candidates = [format!("materials/{n}.tex"), format!("{n}.tex")]; - candidates.iter().find_map(|rel| { - let bytes = resolver.read(rel)?; - crate::engine::tex::TexFile::parse(&bytes) - .ok()? - .to_rgba() - .ok() - }) - }); - // Missing-slot fallbacks: normally white (= unmasked), but - // shake's slot 1 is a FLOW map whose authored default is - // util/noflow — rg 0.498 gray, "no pixel moves" (white would - // shear the whole layer diagonally), and pulse's slot 1 is - // its noise source, authored default util/noise. - let img = img.or_else(|| { - if effect_name == "pulse" && slot_i == 0 { - let bytes = resolver.read("materials/util/noise.tex")?; - crate::engine::tex::TexFile::parse(&bytes) - .ok()? - .to_rgba() - .ok() - } else { - None - } - }); - let fallback = if effect_name == "shake" && slot_i == 0 { - image::Rgba([127, 127, 127, 255]) - } else { - image::Rgba([255, 255, 255, 255]) - }; - textures.push( - renderer.upload_texture( - &img.unwrap_or_else(|| RgbaImage::from_pixel(1, 1, fallback)), - ), - ); - } - } - // A shake with no texture overrides at all authors no flow map either - // — bind the gray no-flow dummy so the effect stays static like WE. - if effect_name == "shake" && textures.is_empty() { - textures.push(renderer.upload_texture(&RgbaImage::from_pixel( - 1, - 1, - image::Rgba([127, 127, 127, 255]), - ))); - } - if std::env::var("WP_DEBUG_TEX_SLOTS").is_ok() { - eprintln!( - "[texslots] {effect_name} (hardcoded, instance {instance_idx}): overrides={:?} loaded={}", - inst.pass_overrides.first().map(|o| o.textures.clone()), - textures.len() - ); - } - if !textures.is_empty() { - renderer.dynamic_textures.insert(tex_key.clone(), textures); - } - return Some(EffectRuntime { - passes: vec![EffectPassRuntime { - key: effect_name.clone(), - tex_key, - hardcoded: true, - target: None, - // REVERTED (2026-08-26, see the Ghidra report's godrays/ - // waterwaves follow-up): this used to bind slot 0 to - // `_rt_FullFrameBuffer` for waterripple/waterwaves, on the - // theory that their `g_Texture0` ("hidden"/"framebuffer" in - // the real shader's own annotation) meant the global scene - // buffer. Real content proved that wrong: a real downloaded - // wallpaper attaches 8 waterwaves instances directly to its - // own background image (a shimmer filter on itself, not a - // transparent water surface revealing what's behind it) — - // at the point this object's own effect chain runs, the - // global scene target is still blank (this object hasn't - // composited onto it yet), so binding it there fed every - // instance blank input, chained through 8 passes to a flat - // grey wash-out. "framebuffer" evidently means this pass's - // own chain input here, which is exactly what leaving - // `binds` empty (falling through to `chain_view`) already - // gives it — the pre-fix behavior, restored. - binds: Vec::new(), - values: { - // Hardcoded kernels read combos (e.g. shake's DIRECTION/ - // NOISE) as pseudo-values with a `combo_` prefix. - let mut vals = inst - .pass_overrides - .first() - .map(|o| o.values.clone()) - .unwrap_or_default(); - if let Some(over) = inst.pass_overrides.first() { - for (k, v) in &over.combos { - vals.insert(format!("combo_{k}"), *v as f32); - } - } - vals - }, - vertex_buffers: Vec::new(), - }], - fbos: Vec::new(), - uses_audio: false, - }); - } - let Ok(eff_def) = effect_def::load_effect_by_file(resolver, &inst.file) else { - tracing::warn!( - target: "effect", - "SKIP '{effect_name}': no effect.json at '{}'", - inst.file - ); - return None; - }; - // Bundle directory for this effect (e.g. "effects/clouds" or a - // workshop-nested "effects/workshop/2138904733/foo"), used to check for - // bundled materials/shaders before falling back to root-relative paths. - let effect_dir = inst - .file - .rsplit_once('/') - .map(|(dir, _)| dir) - .unwrap_or(inst.file.as_str()); - - let fbos: Vec<(String, f32)> = eff_def - .fbos - .iter() - .filter_map(|f| { - f.name - .as_ref() - .map(|n| (n.clone(), f.scale.unwrap_or(1.0) as f32)) - }) - .collect(); - - let default_override = PassOverride::default(); - let mut passes: Vec = Vec::new(); - let mut uses_audio = false; - for (pass_idx, pass) in eff_def.passes.iter().enumerate() { - let Some(mat_path) = pass.material.as_deref() else { - continue; - }; - let Ok(mat_def) = effect_def::load_material_from_effect(resolver, effect_dir, mat_path) - else { - tracing::warn!( - target: "effect", - "SKIP '{effect_name}' pass {pass_idx}: material '{mat_path}' not found" - ); - continue; - }; - let Some(mat_pass) = mat_def.passes.first() else { - continue; - }; - let Some(shader_name) = mat_pass.shader.as_deref() else { - continue; - }; - let Ok((frag_glsl, vert_glsl)) = - resolver.load_glsl_shader_for_effect(shader_name, Some(effect_dir)) - else { - tracing::warn!( - target: "effect", - "SKIP '{effect_name}' pass {pass_idx}: shader '{shader_name}' not found" - ); - continue; - }; - // Some WE downsample/blur shaders read a fixed-size array varying - // through a `for` loop (`v_TexCoord[i]`) rather than literal indices; - // unroll that loop into literal accesses first so the array-varying - // pass below (which only handles literal indices) can then unroll - // the declaration too. - let frag_glsl = transpiler::unroll_simple_for_loops(&frag_glsl); - let vert_glsl = transpiler::unroll_simple_for_loops(&vert_glsl); - // WE's downsample/blur/bloom shaders declare literally-indexed array - // varyings (`v_TexCoord[4]`) that naga's GLSL frontend can't accept - // as entry-point I/O; unroll them to scalar varyings before anything - // else touches the source. - let frag_glsl = transpiler::unroll_array_varyings(&frag_glsl); - let vert_glsl = transpiler::unroll_array_varyings(&vert_glsl); - // Some shaders call max/min/clamp with a bare integer literal where a - // float is expected (e.g. nitro.frag's `max(0, albedo.rgb)`) — legacy - // NVIDIA compilers implicitly promoted it, naga/shaderc don't. - let frag_glsl = transpiler::coerce_int_literal_builtin_args(&frag_glsl); - let vert_glsl = transpiler::coerce_int_literal_builtin_args(&vert_glsl); - // Some shaders assign a swizzle of a wider vector from `mix()` using - // the un-swizzled vector as the first arg (e.g. shift_hue.frag's - // `albedo.rgb = mix(albedo, newAlbedo, mask)` where `albedo` is - // vec4) — WE's compiler truncates implicitly, naga/shaderc don't. - let frag_glsl = transpiler::coerce_swizzle_mismatched_mix_arg(&frag_glsl); - let vert_glsl = transpiler::coerce_swizzle_mismatched_mix_arg(&vert_glsl); - - // Scene.json pass overrides align with effect.json pass indices - // (the reference's ImageEffectPassOverride). - let override_ = inst - .pass_overrides - .get(pass_idx) - .unwrap_or(&default_override); - - // Combos: material's, then the scene override's on top, uppercased - // (the reference uppercases names when emitting #defines). - let mut combos: HashMap = HashMap::new(); - for (k, v) in &mat_pass.combos { - combos.insert(k.to_uppercase(), *v); - } - for (k, v) in &override_.combos { - combos.insert(k.to_uppercase(), *v); - } - // Texture bindings: the material's own list, with the scene - // instance's per-pass override applied on top position-by-position - // (a scene `null` entry means "keep the material's default", not - // "clear it" — matches the reference's ImageEffectPassOverride). - let merged_textures: Vec> = { - let len = mat_pass.textures.len().max(override_.textures.len()); - (0..len) - .map(|i| { - override_ - .textures - .get(i) - .and_then(|t| t.clone()) - .or_else(|| mat_pass.textures.get(i).and_then(|t| t.clone())) - }) - .collect() - }; - // Some sampler2D uniforms annotate a `"combo":"NAME"` (e.g. nitro's - // opacitymask slot) whose real semantics is "enabled whenever a real - // texture is assigned to this slot" rather than a JSON-declared - // combo default. Without this, an optional mask texture the scene - // *does* provide never actually gates anything — the masked effect - // washes over the whole layer instead of being confined to the mask - // (e.g. nitro.frag's `#if MASK` block silently compiling out). - let sampler_combos: Vec> = - crate::engine::shaders::uniform_meta::parse_uniform_metadata(&frag_glsl) - .into_iter() - .filter(|m| m.uniform_type == "sampler2D") - .map(|m| m.combo) - .collect(); - for (i, tex) in merged_textures.iter().enumerate() { - let Some(name) = tex else { continue }; - if name.is_empty() || name.starts_with("_rt_") || name.starts_with("_alias_") { - continue; - } - if let Some(Some(combo_name)) = sampler_combos.get(i) { - combos.insert(combo_name.to_uppercase(), 1); - } - } - - // Constants: material JSON defaults, overridden by scene values. - let mut values: ShaderVals = ShaderVals::new(); - for (k, v) in &mat_pass.constant_shader_values { - insert_value_components(&mut values, k, v); - } - for (k, v) in &override_.values { - values.insert(k.clone(), *v); - } - - let blending_str = mat_pass.blending.as_deref().unwrap_or("normal"); - let model = ShaderModel::from_resolved_glsl( - shader_name.to_string(), - frag_glsl, - combos, - WEBlending::from_str(blending_str), - ); - let translated = match transpiler::translate_full(&model, Some(&vert_glsl)) { - Ok(t) => t, - Err(e) => { - tracing::warn!(target: "effect", "SKIP '{effect_name}' pass {pass_idx}: GLSL→WGSL failed: {e:#}"); - continue; - } - }; - uses_audio |= translated.uses_audio; - let key = format!("fx{instance_idx}:{effect_name}#{pass_idx}"); - // Temporary debug hook: dump translated WGSL per pass. - if std::env::var("WP_DEBUG_DUMP_WGSL").is_ok() { - let safe = key.replace([':', '/', '#'], "_"); - let _ = std::fs::write(format!("/tmp/wgsl_{safe}.frag.wgsl"), &translated.wgsl); - if let Some(v) = &translated.vert_wgsl { - let _ = std::fs::write(format!("/tmp/wgsl_{safe}.vert.wgsl"), v); - } - } - let blend = wgpu_blend_state(&model.blending); - let mut pipeline_attrs = translated.attributes.clone(); - let mut added = renderer.add_dynamic_pipeline( - key.clone(), - &translated.wgsl, - translated.vert_wgsl.as_deref(), - translated.uniform_keys.clone(), - &pipeline_attrs, - blend, - ); - if added.is_err() && !pipeline_attrs.is_empty() { - // Real-VS pipeline failed (usually an interface mismatch the - // preprocessor couldn't reconcile) — retry with the synthetic VS. - tracing::warn!( - target: "effect", - "'{effect_name}' pass {pass_idx}: real VS failed ({}), synthetic fallback", - added.as_ref().err().map(|e| e.to_string()).unwrap_or_default() - ); - if let Ok(fallback) = transpiler::translate(&model) { - pipeline_attrs = Vec::new(); - added = renderer.add_dynamic_pipeline( - key.clone(), - &fallback.wgsl, - fallback.vert_wgsl.as_deref(), - fallback.uniform_keys.clone(), - &[], - blend, - ); - } - } - if let Err(e) = added { - tracing::warn!(target: "effect", "SKIP '{effect_name}' pass {pass_idx}: pipeline failed: {e}"); - continue; - } - let vertex_buffers = renderer.attr_buffers_for(&pipeline_attrs); - - // Secondary textures (g_Texture1..): shader-annotation defaults, - // overridden by the material pass's texture list (with the scene - // instance's own override merged in above). Slot indices must stay - // aligned, so failed loads push a white 1×1 stand-in instead of - // shifting later slots down. - let mut texture_names: Vec> = model - .texture_slots - .iter() - .skip(1) - .map(|slot| slot.default_path.clone()) - .collect(); - // Framebuffer refs in the texture list (e.g. refraction's compose - // pass: `"textures": [null, "_rt_FullFrameBuffer"]`) aren't files — - // they become extra binds, resolved at draw time like `bind` entries. - let mut fbo_ref_binds: Vec<(u32, String)> = Vec::new(); - for (i, tex) in merged_textures.iter().enumerate().skip(1) { - let slot = i - 1; - if slot >= texture_names.len() { - texture_names.resize(slot + 1, None); - } - if let Some(name) = tex { - if name.starts_with("_rt_") { - fbo_ref_binds.push((i as u32, name.clone())); - } else if !name.starts_with("_alias_") { - texture_names[slot] = Some(name.clone()); - } - } - } - for (slot, name) in texture_names.iter().enumerate() { - if let Some(n) = name { - if n.starts_with("_rt_") { - fbo_ref_binds.push((slot as u32 + 1, n.clone())); - } - } - } - texture_names - .iter_mut() - .filter(|n| n.as_deref().is_some_and(|n| n.starts_with("_rt_"))) - .for_each(|n| *n = None); - if std::env::var("WP_DEBUG_TEX_SLOTS").is_ok() { - eprintln!( - "[texslots] {effect_name} pass{pass_idx}: merged={:?} names={:?} slots={:?}", - merged_textures, - texture_names, - model - .texture_slots - .iter() - .map(|s| ( - s.glsl_name.clone(), - s.default_path.clone(), - s.is_framebuffer - )) - .collect::>() - ); - } - let mut textures: Vec = Vec::new(); - for name in &texture_names { - let path = name.as_deref().unwrap_or(""); - // WE texture names resolve under materials/ first - // (e.g. "util/clouds_256" → materials/util/clouds_256.tex). - let candidates = [format!("materials/{path}.tex"), format!("{path}.tex")]; - let img = candidates.iter().find_map(|rel| { - let bytes = resolver.read(rel)?; - crate::engine::tex::TexFile::parse(&bytes) - .ok()? - .to_rgba() - .ok() - }); - match img { - Some(img) => textures.push(renderer.upload_texture(&img)), - None => { - if !path.is_empty() { - tracing::warn!( - target: "effect", - "'{effect_name}': texture '{path}' not found — using white" - ); - } - textures.push(renderer.upload_texture(&RgbaImage::from_pixel( - 1, - 1, - image::Rgba([255, 255, 255, 255]), - ))); - } - } - } - renderer.dynamic_textures.insert(key.clone(), textures); - - passes.push(EffectPassRuntime { - tex_key: key.clone(), - key, - hardcoded: false, - target: pass.target.clone(), - // Draw-time binding applies these in order, so the effect's own - // `bind` entries must come LAST — CPass.cpp:809 "binds are set - // last as they're the most important to be set". With the - // material's texture-list _rt_ refs winning instead, godrays' - // combine pass read the (still empty) scene snapshot as its - // albedo and washed the whole layer to the clear colour. - binds: fbo_ref_binds - .into_iter() - .chain( - pass.bind - .iter() - .filter_map(|b| b.name.as_ref().map(|n| (b.index.unwrap_or(0), n.clone()))), - ) - .collect(), - values, - vertex_buffers, - }); - } - - if passes.is_empty() { - tracing::warn!(target: "effect", "SKIP '{effect_name}': no usable passes"); - return None; - } - tracing::debug!(target: "effect", "LOADED '{effect_name}' ({} passes)", passes.len()); - Some(EffectRuntime { - passes, - fbos, - uses_audio, - }) -} - -/// Continuously render scene frames into a channel (preview window, headless -/// tests, and the CPU/SHM fallback path). -#[tracing::instrument(target = "render", level = "debug", skip(tx), fields(dir = %dir.display(), target_fps))] -pub fn gpu_scene_render_loop( - dir: &std::path::Path, - tx: &SyncSender>, - target_fps: f64, -) -> Result<()> { - tracing::info!(target: "render", "opening GPU scene instance"); - let mut instance = GpuSceneInstance::open(dir)?; - let frame_duration = Duration::from_secs_f64(1.0 / target_fps); - let start = Instant::now(); - tracing::info!(target: "render", "entering GPU render loop"); - - let mut frame_no: u64 = 0; - loop { - let frame = instance.render_rgba()?; - tracing::trace!(target: "render", frame = frame_no, "rendered frame"); - frame_no += 1; - if tx.send(Arc::new(frame)).is_err() { - tracing::debug!(target: "render", frames = frame_no, "receiver dropped; ending render loop"); - return Ok(()); - } - - let elapsed = start.elapsed(); - let next = Duration::from_secs_f64( - (elapsed.as_secs_f64() / frame_duration.as_secs_f64()).ceil() - * frame_duration.as_secs_f64(), - ); - if next > elapsed { - std::thread::sleep(next - elapsed); - } - } -} - -/// The unresolved scene.json, the wallpaper's user properties, and the scene -/// as last resolved against them. -struct LiveProps { - dir: std::path::PathBuf, - raw: serde_json::Value, - props: crate::engine::properties::SceneProperties, - resolved: serde_json::Value, -} - -impl LiveProps { - fn new(dir: &Path, raw: &serde_json::Value) -> Self { - let props = crate::engine::properties::SceneProperties::from_project_dir(dir); - let mut resolved = raw.clone(); - props.resolve_scene_json(&mut resolved); - Self { - dir: dir.to_path_buf(), - raw: raw.clone(), - props, - resolved, - } - } -} - -/// Collect the leaves that differ between `a` and `b` as (path, new value). -/// A `{"user": …}` binding counts as one leaf, as do arrays that changed -/// length. -fn changed_leaves( - a: &serde_json::Value, - b: &serde_json::Value, - path: &mut Vec, - out: &mut Vec<(Vec, serde_json::Value)>, -) { - use serde_json::Value; - if a == b { - return; - } - match (a, b) { - (Value::Object(x), Value::Object(y)) if !y.contains_key("user") => { - for k in x.keys().chain(y.keys().filter(|k| !x.contains_key(*k))) { - path.push(k.clone()); - changed_leaves( - x.get(k).unwrap_or(&Value::Null), - y.get(k).unwrap_or(&Value::Null), - path, - out, - ); - path.pop(); - } - } - (Value::Array(x), Value::Array(y)) if x.len() == y.len() => { - for (i, (p, q)) in x.iter().zip(y).enumerate() { - path.push(i.to_string()); - changed_leaves(p, q, path, out); - path.pop(); - } - } - _ => out.push((path.clone(), b.clone())), - } -} - -type ShaderVals = std::collections::HashMap; - -/// Insert a constant under every spelling the packer might look up: the raw -/// key, its normalized alias (scene constants use -/// "ui_editor_properties_color_start" while asset shaders annotate materials -/// as "colorstart"), and per-component suffixes for vector values. -fn insert_value_components(map: &mut ShaderVals, key: &str, value: &serde_json::Value) { - let alias = |k: &str| -> String { - k.strip_prefix("ui_editor_properties_") - .unwrap_or(k) - .replace('_', "") - .to_lowercase() - }; - let animated = crate::engine::model::json_to_animated(value); - insert_animated_components(map, key, &alias(key), &animated); -} - -fn insert_animated_components( - map: &mut ShaderVals, - key: &str, - alias: &str, - v: &crate::engine::model::AnimatedValue, -) { - if let Some(f) = v.as_float() { - map.insert(key.to_string(), f); - map.insert(alias.to_string(), f); - } - if let Some(comps) = v.components() { - // Both spellings: colors are read as _r/_g/_b/_a, geometry as - // _x/_y/_z/_w. A vec3 keeps its historical implicit _w/_a of 1.0. - let mut named: Vec<(&str, f32)> = Vec::new(); - for (i, val) in comps.iter().enumerate() { - named.push((["r", "g", "b", "a"][i.min(3)], *val)); - named.push((["x", "y", "z", "w"][i.min(3)], *val)); - } - if comps.len() == 3 { - named.push(("a", 1.0)); - named.push(("w", 1.0)); - } - for (name, val) in named { - map.insert(format!("{key}_{name}"), val); - map.insert(format!("{alias}_{name}"), val); - } - } -} - -fn collect_effects(scene: &crate::engine::model::SceneModel) -> Vec { - let alias = |k: &str| -> String { - k.strip_prefix("ui_editor_properties_") - .unwrap_or(k) - .replace('_', "") - .to_lowercase() - }; - let mut result = Vec::new(); - for (i, obj) in scene.objects.iter().enumerate() { - if !obj.is_visible() { - continue; - } - for (effect_index, eff) in obj.effects.iter().enumerate() { - if !eff.visible { - continue; - } - let file = match &eff.file { - Some(s) => s, - None => continue, - }; - // WE effect paths look like "effects/{name}/effect.json"; the effect - // name is the directory, not the filename (which is always "effect.json"). - let mut parts = file.rsplitn(3, '/'); - let _filename = parts.next().unwrap_or(""); // "effect.json" - let name = parts - .next() - .unwrap_or(file) // "waterflow" - .trim_end_matches(".json") - .to_lowercase(); - - // Scene.json pass entries align with effect.json pass indices. - let pass_overrides: Vec = eff - .passes - .iter() - .map(|p| { - let mut values = ShaderVals::new(); - for (k, v) in &p.shader_values { - insert_animated_components(&mut values, k, &alias(k), v); - } - PassOverride { - combos: p.combos.clone(), - values, - textures: p.textures.clone(), - } - }) - .collect(); - - result.push(EffectInstanceDef { - layer_idx: i, - effect_index, - name, - file: file.clone(), - pass_overrides, - }); - } - } - result -} - -/// Pack effect uniform values using std140 layout rules — the transpiled -/// UBO (GLSL uniform block → SPIR-V → WGSL) uses std140 offsets, so each -/// member is aligned to its own alignment, NOT padded to 16 bytes: -/// float/int align 4, vec2 align 8, vec3/vec4/matrix columns align 16. -/// -/// Value resolution per uniform (mirrors CPass::setupUniforms + -/// setupShaderVariables): engine-provided uniforms first, then material/scene -/// constants, then the shader annotation's default. -fn make_params_from_translated_typed( - keys: &[transpiler::UniformEntry], - engine: &EngineUniforms, - vals: &ShaderVals, -) -> Vec { - let mut bytes: Vec = Vec::new(); - - // Resolve one uniform into up to 4 components. A scene may key the value - // by the annotation's material key ("alpha") or by its UI label - // ("ui_editor_properties_outline_background") — and those two are not - // always spellings of each other, so both are tried. - let resolve = |entry: &transpiler::UniformEntry, default: &[f32; 4]| -> [f32; 4] { - let key = entry.key.as_str(); - if let Some(v) = engine.get(key) { - return v; - } - let names = || std::iter::once(key).chain(entry.label.as_deref()); - let mut out = *default; - if let Some(f) = names().find_map(|k| vals.get(k)) { - out = [*f; 4]; - } - // Per-component spellings win over the scalar broadcast. - for (i, suffixes) in [ - &["_x", "_r"][..], - &["_y", "_g"][..], - &["_z", "_b"][..], - &["_w", "_a"][..], - ] - .iter() - .enumerate() - { - if let Some(f) = suffixes - .iter() - .flat_map(|s| names().map(move |k| format!("{k}{s}"))) - .find_map(|k| vals.get(&k)) - { - out[i] = *f; - } - } - out - }; - - fn align_to(bytes: &mut Vec, align: usize) { - let rem = bytes.len() % align; - if rem != 0 { - bytes.resize(bytes.len() + (align - rem), 0); - } - } - fn push_f32(bytes: &mut Vec, v: f32) { - bytes.extend_from_slice(&v.to_le_bytes()); - } - - for entry in keys { - match entry.glsl_type.as_str() { - "vec2" => { - let v = resolve(entry, &entry.default); - align_to(&mut bytes, 8); - push_f32(&mut bytes, v[0]); - push_f32(&mut bytes, v[1]); - } - "vec3" => { - let v = resolve(entry, &entry.default); - align_to(&mut bytes, 16); - push_f32(&mut bytes, v[0]); - push_f32(&mut bytes, v[1]); - push_f32(&mut bytes, v[2]); - // std140 vec3 has size 12; a following float packs into the - // tail 4 bytes, so do NOT pad here. - } - "vec4" => { - let v = resolve(entry, &entry.default); - align_to(&mut bytes, 16); - for f in v { - push_f32(&mut bytes, f); - } - } - "mat4" => { - align_to(&mut bytes, 16); - let identity: [f32; 16] = [ - 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, - ]; - for f in identity { - push_f32(&mut bytes, f); - } - } - "mat3" => { - // std140 mat3: 3 columns × (vec3 + 4 bytes pad) = 48 bytes - align_to(&mut bytes, 16); - let cols: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; - for col in cols { - for f in col { - push_f32(&mut bytes, f); - } - bytes.extend_from_slice(&[0u8; 4]); - } - } - _ => { - // float / int / bool / unknown: 4 bytes, align 4 - let v = resolve(entry, &entry.default); - align_to(&mut bytes, 4); - push_f32(&mut bytes, v[0]); - } - } - } - - // Uniform buffers must be non-empty and sized to a 16-byte multiple. - if bytes.is_empty() { - bytes.resize(16, 0); - } - align_to(&mut bytes, 16); - bytes -} - -fn make_effect_params(name: &str, time: f32, vals: &ShaderVals) -> Vec { - // Scene `constantshadervalues` are keyed by the shader annotation's - // *material key* ("speed", "scale", ...), not its ui_editor_properties_* - // label — look the bare key up first and keep the label as a fallback - // for any older call sites below that still pass the long form. - fn get(vals: &ShaderVals, key: &str, default: f32) -> f32 { - if let Some(v) = vals.get(key) { - return *v; - } - // Scenes key a value by the annotation's material key ("amount") or by - // its UI label ("ui_editor_properties_pulse_amount", normalized to - // "pulseamount") — and the label is not always the material key with a - // prefix chopped off. LABEL_ALIASES covers the pairs the corpus - // actually uses; the translated-effect packer gets this from the - // shader annotation itself (see make_params_from_translated_typed). - const LABEL_ALIASES: &[(&str, &[&str])] = &[ - ("amount", &["pulseamount"]), - ("speed", &["pulsespeed"]), - ("phase", &["pulsephase", "timeoffset"]), - ("bounds", &["pulsebounds"]), - ("scale", &["ripplescale"]), - ]; - // Vector params arrive here already split ("bounds_r"), so match on the - // stem and carry the suffix over. - let (stem, suffix) = match key.rfind('_') { - Some(i) if key.len() - i <= 2 => (&key[..i], &key[i..]), - _ => (key, ""), - }; - if let Some(v) = LABEL_ALIASES - .iter() - .find(|(k, _)| *k == stem) - .into_iter() - .flat_map(|(_, aliases)| aliases.iter()) - .find_map(|a| vals.get(&format!("{a}{suffix}"))) - { - return *v; - } - key.strip_prefix("ui_editor_properties_") - .and_then(|bare| vals.get(bare).copied()) - .unwrap_or(default) - } - fn pack(floats: &[f32]) -> Vec { - floats.iter().flat_map(|f| f.to_le_bytes()).collect() - } - - // Keys below are the shader annotations' material keys (the names - // scene.json constantshadervalues actually use); defaults match each - // annotation's declared default. - match name { - "pulse" => pack(&[ - time, - get(vals, "speed", 3.0), - get(vals, "amount", 1.0), - get(vals, "power", 1.0), - get(vals, "phase", 0.0), - get(vals, "bounds_r", 0.0), - get(vals, "bounds_g", 1.0), - get(vals, "combo_BLENDMODE", 9.0), - get(vals, "tintlow_r", 1.0), - get(vals, "tintlow_g", 1.0), - get(vals, "tintlow_b", 1.0), - get(vals, "combo_PULSECOLOR", 1.0), - get(vals, "tinthigh_r", 1.0), - get(vals, "tinthigh_g", 1.0), - get(vals, "tinthigh_b", 1.0), - get(vals, "combo_PULSEALPHA", 0.0), - get(vals, "noisespeed", 0.5), - get(vals, "noiseamount", 0.0), - get(vals, "combo_AUDIOPROCESSING", 0.0), - get(vals, "frequencymin", 0.0), - get(vals, "frequencymax", 1.0), - get(vals, "audiobounds_x", 0.5), - get(vals, "audiobounds_y", 1.0), - get(vals, "audioexponent", 1.0), - get(vals, "audioamount", 1.0), - // PulseParams rounds up to 112 bytes (vec3 members force a - // 16-byte struct alignment) — pad or wgpu rejects the bind group. - 0.0, - 0.0, - 0.0, - ]), - "scroll" => pack(&[ - time, - get(vals, "speedx", 0.2), - get(vals, "speedy", 0.2), - get(vals, "repeat_r", 1.0), - get(vals, "repeat_g", 1.0), - 0.0, - 0.0, - 0.0, - ]), - "shake" => { - // shake.vert precomputes v_Bounds = (x, 1/(y-x)) from `bounds`. - let bounds_x = get(vals, "bounds_x", 0.0); - let bounds_y = get(vals, "bounds_y", 1.0); - pack(&[ - time, - get(vals, "speed", 1.0), - get(vals, "strength", 0.1), - get(vals, "combo_DIRECTION", 0.0), - get(vals, "friction_x", 1.0), - get(vals, "friction_y", 1.0), - bounds_x, - 1.0 / (bounds_y - bounds_x).max(0.0001), - get(vals, "combo_NOISE", 0.0), - 0.0, - 0.0, - 0.0, - ]) - } - "tint" => { - let r = get(vals, "color_r", 1.0); - let g = get(vals, "color_g", 0.0); - let b = get(vals, "color_b", 0.0); - let a = get(vals, "alpha", 1.0); - pack(&[ - r, - g, - b, - a, - get(vals, "combo_BLENDMODE", 30.0), - 0.0, - 0.0, - 0.0, - ]) - } - "opacity" => pack(&[get(vals, "alpha", 1.0), 0.0, 0.0, 0.0]), - "waterripple" => pack(&[ - time, - get(vals, "ripplestrength", 0.1), - get(vals, "animationspeed", 0.15), - get(vals, "scale", 1.0), - ]), - "waterwaves" => { - // Direction is the reference's rotateVec2((0, 1), angle) — - // base vector (0, 1), not (1, 0). - let dir = get(vals, "direction", 0.0); - pack(&[ - time, - get(vals, "speed", 5.0), - get(vals, "scale", 200.0), - get(vals, "strength", 0.1), - -dir.sin(), - dir.cos(), - get(vals, "exponent", 1.0), - 0.0, - ]) - } - "spin" => pack(&[ - time, - get(vals, "speed", 1.0), - get(vals, "center_r", 0.5), - get(vals, "center_g", 0.5), - get(vals, "size", 0.1), - get(vals, "feather", 0.002), - get(vals, "combo_REPEAT", 1.0), - 0.0, - ]), - _ => vec![0u8; 32], - } -} - -/// Upload one CPU `Layer` and derive its GPU-side quad. `authored_origin` is -/// the object's scene.json origin, before alignment. -fn gpu_layer_from( - renderer: &GpuSceneRenderer, - l: &crate::engine::render::Layer, - w: u32, - h: u32, - zoom: f32, - camera3d: Option<&crate::engine::camera3d::PerspectiveCamera>, - authored_origin: [f64; 3], -) -> SceneLayerGpu { - let all_frames: Vec<&RgbaImage> = std::iter::once(&l.image) - .chain(l.extra_frames.iter()) - .collect(); - // Textures stay at native resolution; the composite quad and - // sampler handle scaling to the object's on-screen size. - let mut frames: Vec = all_frames - .iter() - .map(|frame| renderer.upload_texture(frame)) - .collect(); - - // Unscaled object size: explicit scene.json size, else the - // texture's native resolution (CImage.cpp lines 213-222). - // True scene-fullscreen layers (models/util/fullscreenlayer.json - // etc.) never reach here — they're filtered out upstream by - // `is_special_layer` — so an unspecified size always means - // "use the texture's own dimensions", never "cover the scene". - let effective_size = if l.size[0] > 0.0 && l.size[1] > 0.0 { - [l.size[0], l.size[1]] - } else { - [l.image.width() as f64, l.image.height() as f64] - }; - let object_size = ( - effective_size[0].round() as u32, - effective_size[1].round() as u32, - ); - - // Object quad rect in NDC. WE origins are absolute scene - // coordinates (Y-up, bottom-left origin) pointing at the - // object's center. - let size_px = [ - effective_size[0] * l.scale[0], - effective_size[1] * l.scale[1], - ]; - let (rect, angle, depth, quad_corners) = if let Some(cam) = camera3d { - let center = [l.origin[0] as f32, l.origin[1] as f32, l.origin[2] as f32]; - let (rect, depth) = project_quad_ndc(cam, center, l.angles, size_px); - let quad_corners = project_quad_corners(cam, center, l.angles, size_px); - (rect, 0.0, depth, quad_corners) - } else { - // `general.zoom` scales the whole scene about its center; - // NDC (0,0) is the scene center, so a uniform rect scale - // does it (center and half-extent both × zoom). - let rect = [ - (2.0 * l.origin[0] / w as f64 - 1.0) as f32 * zoom, - (2.0 * l.origin[1] / h as f64 - 1.0) as f32 * zoom, - (size_px[0] / w as f64) as f32 * zoom, - (size_px[1] / h as f64) as f32 * zoom, - ]; - (rect, l.angle, 0.0, None) - }; - - // A backdrop layer's texture is a copy of the scene under its quad, - // sized to that on-screen region. - if l.backdrop.is_some() { - let px = ( - ((rect[2] * w as f32).round() as u32).clamp(1, w.max(1)), - ((rect[3] * h as f32).round() as u32).clamp(1, h.max(1)), - ); - frames = vec![renderer.create_render_target(px.0, px.1)]; - } - SceneLayerGpu { - frames, - puppet: l.puppet.clone(), - puppet_posed_at: 0.0, - video: l.video.clone(), - blend_mode: l.blend_mode, - alpha: l.alpha, - text_dynamic: l.text_dynamic.clone(), - color: l.color, - brightness: l.brightness, - frame_duration_ms: l.frame_duration_ms, - parallax_depth: [l.parallax_depth[0] as f32, l.parallax_depth[1] as f32], - angle, - rect, - quad_corners, - depth, - object_size, - no_interpolation: l.no_interpolation, - clamp_uvs: l.clamp_uvs, - order_index: l.order_index, - object_id: l.object_id, - visible: l.visible_base, - effective_size, - align_offset: std::array::from_fn(|i| l.local_origin[i] - authored_origin[i]), - script_frame: None, - backdrop: l.backdrop, - pending_text: None, - text_rastered_at: None, - text_raster_cost: Duration::ZERO, - } -} - -/// A layer quad's corners in output pixels (Y-up, centred), from its NDC -/// `rect` (centre, half-extent) and z-rotation. -fn quad_corners_px(rect: [f32; 4], angle: f32, w: f32, h: f32) -> [[f32; 2]; 4] { - let (cx, cy) = (rect[0] * w * 0.5, rect[1] * h * 0.5); - let (hx, hy) = (rect[2] * w * 0.5, rect[3] * h * 0.5); - let (s, c) = angle.sin_cos(); - [[-hx, -hy], [hx, -hy], [hx, hy], [-hx, hy]] - .map(|[x, y]| [cx + x * c - y * s, cy + x * s + y * c]) -} - -/// Whether `p` lies inside the convex quad `q` (either winding). -fn point_in_convex_quad(p: [f32; 2], q: &[[f32; 2]; 4]) -> bool { - let mut sign = 0.0f32; - for i in 0..4 { - let a = q[i]; - let b = q[(i + 1) % 4]; - let cross = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); - if cross.abs() < f32::EPSILON { - continue; - } - if sign == 0.0 { - sign = cross.signum(); - } else if cross.signum() != sign { - return false; - } - } - sign != 0.0 -} - -#[cfg(test)] -mod hit_tests { - use super::*; - - #[test] - fn hit_test_geometry() { - // A 200x100 px quad centred at (100, 50) px in a 1000x500 output. - let rect = [0.2, 0.2, 0.2, 0.2]; - let q = quad_corners_px(rect, 0.0, 1000.0, 500.0); - assert!(point_in_convex_quad([100.0, 50.0], &q)); - assert!(point_in_convex_quad([199.0, 74.0], &q)); - assert!(!point_in_convex_quad([201.0, 50.0], &q)); - // Rotated 90°: the long axis is now vertical. - let q = quad_corners_px(rect, std::f32::consts::FRAC_PI_2, 1000.0, 500.0); - assert!(point_in_convex_quad([100.0, 140.0], &q)); - assert!(!point_in_convex_quad([190.0, 50.0], &q)); - } - /// Debug aid: run a scene's scripts headlessly (no GPU) for a few - /// frames and print every script log line. `WP_SCRIPT_SCENE=`. - #[test] - #[ignore] - fn run_scene_scripts() { - let Ok(target) = std::env::var("WP_SCRIPT_SCENE") else { - return; - }; - let dir = crate::workshop::find_item_dir(&target, None) - .unwrap_or_else(|| std::path::PathBuf::from(&target)); - let resolved = crate::engine::render::ResolvedScene::from_directory(&dir).unwrap(); - let raw_scene: serde_json::Value = crate::engine::assets::AssetStore::from_directory(&dir) - .ok() - .and_then(|a| a.scene_json().ok()) - .and_then(|t| serde_json::from_str(&t).ok()) - .unwrap_or_default(); - let mut host = build_script_host( - &dir, - &resolved.scene, - &resolved.transform_graph, - [resolved.width, resolved.height], - &raw_scene, - ); - for f in 0..30 { - host.frame(f as f32 * 0.5, 0.5, 0.5); - let c = host.collect(); - if f == 0 { - eprintln!( - "frame 0: {} objects changed, {} commands", - c.objects.len(), - c.commands.len() - ); - } - } - } - - /// A real intro sequence (3453730450): layers keyed to fade in over the - /// first ~13 s start hidden and end fully visible. - #[test] - #[ignore] - fn keyframed_alpha_fades_in() { - let Some(dir) = crate::workshop::find_item_dir("3453730450", None) else { - eprintln!("workshop item 3453730450 not installed; skipping"); - return; - }; - let mut scene = GpuSceneInstance::open(&dir).expect("scene loads"); - let animated: Vec = scene - .timelines - .iter() - .filter(|t| t.key == "alpha") - .map(|t| t.object) - .collect(); - assert!(!animated.is_empty()); - let alpha_of = |s: &GpuSceneInstance, o: usize| { - s.layers - .iter() - .find(|l| l.order_index == o) - .map(|l| l.alpha) - }; - scene.tick_timelines(0.1); - let early: Vec = animated - .iter() - .filter_map(|o| alpha_of(&scene, *o)) - .collect(); - assert!( - early.iter().all(|a| *a < 0.05), - "hidden at start: {early:?}" - ); - for _ in 0..140 { - scene.tick_timelines(0.1); - } - let late: Vec = animated - .iter() - .filter_map(|o| alpha_of(&scene, *o)) - .collect(); - assert!( - late.iter().filter(|a| **a > 0.3).count() >= late.len() - 1, - "faded in: {late:?}" - ); - } - - #[test] - fn changed_leaves_stops_at_user_bindings() { - let a = serde_json::json!({"visible": {"user": "grid", "value": false}, - "effects": [{"passes": [{"constantshadervalues": {"speed": 1}}]}], "name": "x"}); - let b = serde_json::json!({"visible": {"user": "grid", "value": true}, - "effects": [{"passes": [{"constantshadervalues": {"speed": 2}}]}], "name": "x"}); - let mut out = Vec::new(); - changed_leaves(&a, &b, &mut Vec::new(), &mut out); - let paths: Vec = out.iter().map(|(p, _)| p.join(".")).collect(); - assert_eq!( - paths, - ["effects.0.passes.0.constantshadervalues.speed", "visible"] - ); - assert_eq!(out[1].1["value"], true); - } - - /// A user setting bound to layer visibility (2156479578: `grid` shows - /// the Grid layer) applies live, without rebuilding the scene. - #[test] - #[ignore] - fn bool_setting_toggles_a_layer_live() { - let Some(dir) = crate::workshop::find_item_dir("2156479578", None) else { - eprintln!("workshop item 2156479578 not installed; skipping"); - return; - }; - let mut scene = GpuSceneInstance::open(&dir).expect("scene loads"); - let before = scene.object_visible[4]; - let flipped = if before { "0" } else { "1" }; - assert!( - { - let mut props = scene.live_props.props.clone(); - props.apply_overrides(HashMap::from([("grid".to_string(), flipped.to_string())])); - scene.apply_user_properties(props, &["grid".to_string()]) - }, - "applied live" - ); - assert_eq!(scene.object_visible[4], !before); - scene.render(); - } - - /// End to end against a real Workshop scene with clickable layers - /// (2348914357: clicking the radio/music buttons toggles them). Needs - /// the item installed and a GPU, hence ignored by default. - #[test] - #[ignore] - fn clicking_a_scripted_button_toggles_it() { - let Some(dir) = crate::workshop::find_item_dir("2348914357", None) else { - eprintln!("workshop item 2348914357 not installed; skipping"); - return; - }; - let mut scene = GpuSceneInstance::open(&dir).expect("scene loads"); - assert!(!scene.cursor_targets.is_empty(), "scene has click targets"); - scene.render(); - let target = scene - .layers - .iter() - .find(|l| scene.cursor_targets.contains(&l.order_index)) - .expect("a click target has a layer"); - let (idx, before) = (target.order_index, scene.object_visible[target.order_index]); - let norm = [(target.rect[0] + 1.0) * 0.5, (1.0 - target.rect[1]) * 0.5]; - assert_eq!( - scene.hit_test(norm), - Some(idx), - "hit test finds the target at its centre" - ); - scene.set_mouse(norm); - scene.render(); - scene.set_mouse_button(true); - scene.set_mouse_button(false); - scene.render(); - assert_eq!( - scene.object_visible[idx], !before, - "the click toggled the layer" - ); - } -} - -/// One property's keyframe animation on one object. -struct TimelineEntry { - object: usize, - /// `origin`/`alpha`/…, or a script-style extra key (`instance.rate`, - /// `effects...`). - key: String, - /// Base value, which transform keyframes are offsets from. - base: Vec, - anim: crate::engine::timeline::PropertyAnimation, - /// Last value applied, to skip re-applying an idle animation. - last: Option>, -} - -/// Collect every animated property from the raw scene.json objects. -fn build_timelines( - raw_scene: &serde_json::Value, - graph: &crate::engine::render::TransformGraph, -) -> Vec { - use crate::engine::timeline::PropertyAnimation; - let mut out = Vec::new(); - let objects = raw_scene.get("objects").and_then(|o| o.as_array()); - for (i, raw) in objects.into_iter().flatten().enumerate() { - let mut props: Vec<(String, &serde_json::Value)> = - ["origin", "scale", "angles", "alpha", "color", "brightness"] - .iter() - .filter_map(|k| raw.get(*k).map(|v| (k.to_string(), v))) - .collect(); - if let Some(m) = raw.get("instanceoverride").and_then(|v| v.as_object()) { - props.extend(m.iter().map(|(k, v)| (format!("instance.{k}"), v))); - } - for (e, eff) in raw - .get("effects") - .and_then(|v| v.as_array()) - .into_iter() - .flatten() - .enumerate() - { - for (p, pass) in eff - .get("passes") - .and_then(|v| v.as_array()) - .into_iter() - .flatten() - .enumerate() - { - if let Some(m) = pass.get("constantshadervalues").and_then(|v| v.as_object()) { - props.extend(m.iter().map(|(k, v)| (format!("effects.{e}.{p}.{k}"), v))); - } - } - } - for (key, value) in props { - let Some(anim) = value - .get("animation") - .and_then(|a| PropertyAnimation::parse(&key, a)) - else { - continue; - }; - let local = graph - .local - .get(i) - .copied() - .unwrap_or(crate::engine::render::Xform::IDENTITY); - let base: Vec = match key.as_str() { - "origin" => local.t.iter().map(|v| *v as f32).collect(), - "scale" => local.s.iter().map(|v| *v as f32).collect(), - "angles" => local.r.iter().map(|v| *v as f32).collect(), - _ => Vec::new(), - }; - out.push(TimelineEntry { - object: i, - key, - base, - anim, - last: None, - }); - } - } - out -} - -/// Build the scene's SceneScript runtime: one layer proxy per scene object -/// (indexed like `scene.objects`, the same index `order_index` and the -/// transform graph use), then one instance per scripted property. -fn build_script_host( - dir: &Path, - scene: &crate::engine::scene::Scene, - graph: &crate::engine::render::TransformGraph, - canvas: [u32; 2], - raw_scene: &serde_json::Value, -) -> crate::engine::script::ScriptHost { - use crate::engine::scene::{parse_value_f32, parse_value_vec3}; - use crate::engine::script::{ObjectInit, ScriptHost}; - - let mut host = ScriptHost::new(); - if scene.objects.iter().all(|o| o.scripts().is_empty()) { - return host; - } - if let Some(path) = ScriptHost::storage_path_for(dir) { - host = host.with_storage(path); - } - // The raw scene.json entries back `thisScene.getInitialLayerConfig`. - let raw_objects: Vec = raw_scene - .get("objects") - .and_then(|o| o.as_array()) - .cloned() - .unwrap_or_default(); - if let Some(general) = raw_scene.get("general") { - host.set_scene_general(general); - } - - let user_props: serde_json::Map = - crate::engine::properties::SceneProperties::from_project_dir(dir) - .iter() - .map(|p| (p.name.clone(), p.value.clone())) - .collect(); - host.set_scene_info( - [canvas[0] as f32, canvas[1] as f32], - canvas, - &serde_json::Value::Object(user_props), - ); - - let f32v = |v: [f64; 3]| [v[0] as f32, v[1] as f32, v[2] as f32]; - for (i, obj) in scene.objects.iter().enumerate() { - let local = graph - .local - .get(i) - .copied() - .unwrap_or(crate::engine::render::Xform::IDENTITY); - let text = obj.text.as_ref().and_then(|t| match t { - serde_json::Value::String(s) => Some(s.clone()), - serde_json::Value::Object(m) => { - m.get("value").and_then(|v| v.as_str()).map(str::to_string) - } - _ => None, - }); - let init = ObjectInit { - name: obj.name.clone(), - id: obj.id, - origin: f32v(local.t), - angles: f32v(local.r), - scale: f32v(local.s), - size: f32v(obj.parsed_size()), - color: obj - .color - .as_ref() - .and_then(parse_value_vec3) - .map(f32v) - .unwrap_or([1.0; 3]), - visible: obj.is_visible(), - alpha: obj.alpha.as_ref().and_then(parse_value_f32).unwrap_or(1.0), - brightness: obj - .brightness - .as_ref() - .and_then(parse_value_f32) - .unwrap_or(1.0), - text, - pointsize: obj - .pointsize - .as_ref() - .and_then(parse_value_f32) - .unwrap_or(32.0), - volume: obj.volume.as_ref().and_then(parse_value_f32).unwrap_or(1.0), - parallax_depth: obj - .parallax_depth - .as_ref() - .and_then(parse_value_vec3) - .map(|v| [v[0] as f32, v[1] as f32]) - .unwrap_or([0.0; 2]), - parent_index: graph.parent.get(i).copied().flatten(), - frame_count: 1, - raw: raw_objects.get(i).cloned(), - }; - host.register_object(i, &init); - } - - for (i, obj) in scene.objects.iter().enumerate() { - let props: [(&str, Option<&serde_json::Value>); 10] = [ - ("visible", obj.visible.as_ref()), - ("origin", obj.origin.as_ref()), - ("angles", obj.angles.as_ref()), - ("scale", obj.scale.as_ref()), - ("alpha", obj.alpha.as_ref()), - ("color", obj.color.as_ref()), - ("brightness", obj.brightness.as_ref()), - ("text", obj.text.as_ref()), - ("pointsize", obj.pointsize.as_ref()), - ("volume", obj.volume.as_ref()), - ]; - for (prop, value) in props { - if let Some((script, authored)) = crate::engine::script::scripted(value) { - host.attach(i, Some(prop), script, authored); - } - } - // Scripted particle overrides and effect constants/visibility, read - // from the raw entry (the typed model drops their scripts). - let Some(raw) = raw_objects.get(i) else { - continue; - }; - let mut extras: Vec<(String, &serde_json::Value)> = Vec::new(); - if let Some(m) = raw.get("instanceoverride").and_then(|v| v.as_object()) { - extras.extend(m.iter().map(|(k, v)| (format!("instance.{k}"), v))); - } - for (e, eff) in raw - .get("effects") - .and_then(|v| v.as_array()) - .into_iter() - .flatten() - .enumerate() - { - if let Some(v) = eff.get("visible") { - extras.push((format!("effects.{e}.visible"), v)); - } - for (p, pass) in eff - .get("passes") - .and_then(|v| v.as_array()) - .into_iter() - .flatten() - .enumerate() - { - if let Some(m) = pass.get("constantshadervalues").and_then(|v| v.as_object()) { - extras.extend(m.iter().map(|(k, v)| (format!("effects.{e}.{p}.{k}"), v))); - } - } - } - for (key, value) in extras { - if let Some((script, authored)) = crate::engine::script::scripted(Some(value)) { - let initial = value - .get("value") - .cloned() - .unwrap_or(serde_json::Value::Null); - host.declare_property( - i, - &key, - crate::engine::script::value_kind(&initial), - &initial, - ); - host.attach(i, Some(&key), script, authored); - } - } - } - host -} - -#[cfg(test)] -mod tests { - use super::*; - - /// `gpu_shaders.wgsl` must parse and validate as WGSL on its own — the - /// only check in this crate that catches a shader syntax/type error - /// before it surfaces at runtime as an opaque wgpu pipeline-creation - /// failure the first time a scene that uses that pass actually loads. - /// Uses naga directly (no GPU adapter needed — pure frontend/validator). - #[test] - fn gpu_shaders_wgsl_parses_and_validates() { - let module = - naga::front::wgsl::parse_str(SHADER_SRC).expect("gpu_shaders.wgsl failed to parse"); - naga::valid::Validator::new( - naga::valid::ValidationFlags::all(), - naga::valid::Capabilities::all(), - ) - .validate(&module) - .expect("gpu_shaders.wgsl failed validation"); - } - - fn quad3d_test_camera() -> crate::engine::camera3d::PerspectiveCamera { - let scene: crate::engine::scene::Scene = serde_json::from_str( - r#"{"camera": {"eye": "0 0 10", "center": "0 0 0", "up": "0 1 0"}, - "general": {"orthogonalprojection": null, "fov": 60.0}}"#, - ) - .unwrap(); - crate::engine::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).unwrap() - } - - /// A camera-facing quad (no rotation, centered on the view axis) must - /// project to a symmetric rectangle: opposite corners mirror each other - /// around the NDC origin. This is the case `project_quad_ndc`'s AABB - /// collapse already gets right — `project_quad_corners` must agree with - /// it exactly here, only diverging once the quad is off-axis/rotated. - #[test] - fn project_quad_corners_symmetric_for_camera_facing_quad() { - let cam = quad3d_test_camera(); - let corners = project_quad_corners(&cam, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [4.0, 2.0]) - .expect("camera-facing quad in front of the eye must project"); - // Order: bottom-left, bottom-right, top-right, top-left. - assert!((corners[0][0] + corners[1][0]).abs() < 1e-5, "{corners:?}"); - assert!((corners[0][1] + corners[3][1]).abs() < 1e-5, "{corners:?}"); - assert!(corners[0][0] < corners[1][0], "left must be left of right"); - assert!(corners[0][1] < corners[2][1], "bottom must be below top"); - } - - /// The whole point of this function: a quad rotated hard off-axis - /// projects to a genuine trapezoid, not a rectangle. Rotating around Y - /// swings one vertical edge toward the camera and the other away from - /// it (left edge: x=-2 → z=+1.73, closer to the eye at z=10; right - /// edge: x=+2 → z=-1.73, farther away) — the closer edge must span a - /// taller NDC range than the farther one. `project_quad_ndc`'s AABB - /// would hide this entirely (both edges would just extend its bounding - /// box, indistinguishable from a same-size rectangle). - #[test] - fn project_quad_corners_is_a_true_trapezoid_when_rotated() { - let cam = quad3d_test_camera(); - let corners = project_quad_corners( - &cam, - [0.0, 0.0, 0.0], - [0.0, 60f32.to_radians(), 0.0], - [4.0, 2.0], - ) - .expect("quad must still project in front of the eye"); - // Left edge: corner0 (bottom-left) to corner3 (top-left). - let left_height = (corners[3][1] - corners[0][1]).abs(); - // Right edge: corner1 (bottom-right) to corner2 (top-right). - let right_height = (corners[2][1] - corners[1][1]).abs(); - assert!( - left_height > right_height + 1e-4, - "expected the closer (left) edge to project taller than the \ - farther (right) edge: left={left_height} right={right_height}" - ); - } - - /// A corner behind the eye plane must cull the whole quad — same rule - /// `project_quad_ndc` uses, kept consistent rather than drawing a - /// partially-degenerate quad. - #[test] - fn project_quad_corners_none_when_behind_camera() { - let cam = quad3d_test_camera(); - // Eye is at z=10 looking toward the origin; z=20 is behind it. - assert!( - project_quad_corners(&cam, [0.0, 0.0, 20.0], [0.0, 0.0, 0.0], [4.0, 2.0]).is_none() - ); - } - - /// Same rule as `hardcoded_effect_params_are_uniform_sized` below, for the - /// mesh3d lighting UBO: the Rust-side byte length must match WGSL's - /// std140 struct size (a 16-byte multiple) and `mesh3d_lighting_bytes` - /// must actually produce that many bytes, or wgpu rejects the mesh3d bind - /// group at draw time. - #[test] - fn mesh3d_lighting_bytes_are_uniform_sized_and_match_const() { - assert_eq!(MESH3D_LIGHTING_BYTES_LEN % 16, 0); - let scene: crate::engine::scene::Scene = serde_json::from_str( - r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}}"#, - ) - .unwrap(); - let cam = crate::engine::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).unwrap(); - - // The common case: no shadow-casting lights at all. - let bytes = mesh3d_lighting_bytes(&scene, &cam, &[], &[], None); - assert_eq!(bytes.len(), MESH3D_LIGHTING_BYTES_LEN); - - // One shadow-casting light occupying slot 0 — same length either way, - // since the shadow arrays are fixed-size regardless of how many - // slots are actually in use. - let shadow_slots = vec![Some(0)]; - let shadow_slot_data = vec![(crate::engine::camera3d::identity(), [0.0, 0.0, 1.0, 1.0])]; - let bytes = mesh3d_lighting_bytes(&scene, &cam, &shadow_slots, &shadow_slot_data, None); - assert_eq!(bytes.len(), MESH3D_LIGHTING_BYTES_LEN); - } - - /// `cookie: Some((light_index, view_proj))` must write `flags.y == - /// light_index + 1` and pack `view_proj` into the trailing - /// `cookie_view_proj` field (the last `MESH3D_MAX_SHADOW_LIGHTS` shadow - /// mat4s + the `MESH3D_MAX_SHADOW_LIGHTS` uv-rect vec4s precede it — - /// see `MESH3D_LIGHTING_BYTES_LEN`'s own layout doc). - #[test] - fn mesh3d_lighting_bytes_encodes_cookie_flag_and_matrix() { - let scene: crate::engine::scene::Scene = serde_json::from_str( - r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}}"#, - ) - .unwrap(); - let cam = crate::engine::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).unwrap(); - - let mut vp = crate::engine::camera3d::identity(); - vp[3][0] = 42.0; // an arbitrary, recognizable value to round-trip - let bytes = mesh3d_lighting_bytes(&scene, &cam, &[], &[], Some((2, vp))); - - let flags_y = f32::from_le_bytes(bytes[4..8].try_into().unwrap()); - assert_eq!(flags_y, 3.0, "flags.y must be light_index + 1"); - - let cookie_mat_offset = MESH3D_LIGHTING_BYTES_LEN - 64; - let read_f32 = |i: usize| { - f32::from_le_bytes( - bytes[cookie_mat_offset + i * 4..cookie_mat_offset + i * 4 + 4] - .try_into() - .unwrap(), - ) - }; - // Column 3, row 0 (the value we perturbed) is float index 12. - assert_eq!( - read_f32(12), - 42.0, - "cookie_view_proj must round-trip the matrix" - ); - } - - /// A spot light must (a) count as renderable (`flags.x == 1.0` — the - /// bug fixed alongside GPU spot support: `Scene::lights()` producing a - /// `Light::Spot` must not silently fall back to the unlit branch) and - /// (b) write a non-zero direction into its `light_spot` slot, so - /// `mesh3d_spot_factor` in the shader doesn't read it as "not a spot". - #[test] - fn mesh3d_lighting_bytes_encodes_spot_direction() { - let cam_scene: crate::engine::scene::Scene = serde_json::from_str( - r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}}"#, - ) - .unwrap(); - let cam = crate::engine::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); - - let scene: crate::engine::scene::Scene = serde_json::from_str( - r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, - "general": {"orthogonalprojection": null}, - "objects": [ - {"id": 1, "light": true, "origin": "0 0 0", "radius": 100, - "intensity": 0.8, "color": "1 1 1", - "innercone": 20, "outercone": 45} - ]}"#, - ) - .unwrap(); - let bytes = mesh3d_lighting_bytes(&scene, &cam, &[], &[], None); - - // flags.x is the buffer's first f32. - let flags_x = f32::from_le_bytes(bytes[0..4].try_into().unwrap()); - assert_eq!( - flags_x, 1.0, - "a spot-only scene must still engage the lit branch" - ); - - // light_spot[0] starts after 5 leading vec4s + 2 MESH3D_MAX_LIGHTS - // vec4 arrays (positions, colors). - let spot0_offset = 16 * 5 + 16 * MESH3D_MAX_LIGHTS * 2; - let read_f32 = |i: usize| { - f32::from_le_bytes( - bytes[spot0_offset + i * 4..spot0_offset + i * 4 + 4] - .try_into() - .unwrap(), - ) - }; - let dir = [read_f32(0), read_f32(1), read_f32(2)]; - let len2 = dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]; - assert!(len2 > 0.25, "spot direction must not read as zero: {dir:?}"); - assert!( - read_f32(3) >= 1.0, - "exponent must be clamped to at least 1.0" - ); - } - - /// A directional light must flag itself via `light_pos[0].w == -1.0` - /// (the sentinel `fs_mesh3d`'s light loop branches on) with a unit-length - /// view-space direction in xyz, and still engage the lit branch. - #[test] - fn mesh3d_lighting_bytes_encodes_directional_sentinel_and_direction() { - let cam_scene: crate::engine::scene::Scene = serde_json::from_str( - r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}}"#, - ) - .unwrap(); - let cam = crate::engine::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); - - let scene: crate::engine::scene::Scene = serde_json::from_str( - r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, - "general": {"orthogonalprojection": null}, - "objects": [ - {"id": 1, "light": "ldirectional", "angles": "0 1.5707963 0", - "intensity": 0.6, "color": "1 1 1"} - ]}"#, - ) - .unwrap(); - let bytes = mesh3d_lighting_bytes(&scene, &cam, &[], &[], None); - - let flags_x = f32::from_le_bytes(bytes[0..4].try_into().unwrap()); - assert_eq!( - flags_x, 1.0, - "a directional-only scene must still engage the lit branch" - ); - - // light_pos[0] starts right after the 5 leading vec4s. - let pos0_offset = 16 * 5; - let read_f32 = |i: usize| { - f32::from_le_bytes( - bytes[pos0_offset + i * 4..pos0_offset + i * 4 + 4] - .try_into() - .unwrap(), - ) - }; - let dir = [read_f32(0), read_f32(1), read_f32(2)]; - let len2 = dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]; - assert!( - (len2 - 1.0).abs() < 1e-4, - "direction must be unit-length: {dir:?}" - ); - assert_eq!(read_f32(3), -1.0, "w must carry the directional sentinel"); - } - - /// A tube light must flag itself via `light_pos[0].w == -3.0` (the - /// sentinel `fs_mesh3d`'s light loop branches on), with endpoint A in - /// `light_pos[0].xyz` and endpoint B in `light_spot[0].xyz` — not a - /// direction, so its magnitude should reflect the real endpoint spacing, - /// not be unit-length like a spot's. - #[test] - fn mesh3d_lighting_bytes_encodes_tube_sentinel_and_endpoints() { - let cam_scene: crate::engine::scene::Scene = serde_json::from_str( - r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}}"#, - ) - .unwrap(); - let cam = crate::engine::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); - - let scene: crate::engine::scene::Scene = serde_json::from_str( - r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, - "general": {"orthogonalprojection": null}, - "objects": [ - {"id": 1, "light": "ltube", "origin": "0 0 0", - "controlpoint": "0 0 -4", "intensity": 0.9, "color": "1 1 1"} - ]}"#, - ) - .unwrap(); - let bytes = mesh3d_lighting_bytes(&scene, &cam, &[], &[], None); - - let flags_x = f32::from_le_bytes(bytes[0..4].try_into().unwrap()); - assert_eq!( - flags_x, 1.0, - "a tube-only scene must still engage the lit branch" - ); - - let pos0_offset = 16 * 5; - let spot0_offset = 16 * 5 + 16 * MESH3D_MAX_LIGHTS * 2; - let read_f32 = |offset: usize, i: usize| { - f32::from_le_bytes( - bytes[offset + i * 4..offset + i * 4 + 4] - .try_into() - .unwrap(), - ) - }; - assert_eq!( - read_f32(pos0_offset, 3), - -3.0, - "w must carry the tube sentinel" - ); - let endpoint_b = [ - read_f32(spot0_offset, 0), - read_f32(spot0_offset, 1), - read_f32(spot0_offset, 2), - ]; - let len2 = endpoint_b[0] * endpoint_b[0] - + endpoint_b[1] * endpoint_b[1] - + endpoint_b[2] * endpoint_b[2]; - assert!( - len2 > 1.0, - "endpoint B should be a real view-space position, not a unit vector: {endpoint_b:?}" - ); - } - - /// WGSL rounds every uniform-address-space struct up to a 16-byte - /// multiple, so a param buffer that isn't one can never match its shader - /// struct — wgpu rejects the bind group at draw time ("Buffer is bound - /// with size N where the shader expects M") and the wallpaper dies. - #[test] - fn hardcoded_effect_params_are_uniform_sized() { - let vals = ShaderVals::new(); - for name in HARDCODED_EFFECTS { - let n = make_effect_params(name, 0.0, &vals).len(); - assert_eq!(n % 16, 0, "{name} params are {n} bytes, not a 16-multiple"); - } - } - - /// Hardcoded kernels must find a value the scene keyed by UI label - /// ("ui_editor_properties_pulse_amount") as well as by material key. - #[test] - fn hardcoded_params_resolve_label_keyed_values() { - let mut vals = ShaderVals::new(); - insert_value_components( - &mut vals, - "ui_editor_properties_pulse_amount", - &serde_json::json!(0.25), - ); - insert_value_components( - &mut vals, - "ui_editor_properties_pulse_bounds", - &serde_json::json!("0.2 0.8"), - ); - let bytes = make_effect_params("pulse", 0.0, &vals); - let f = |i: usize| f32::from_le_bytes(bytes[i * 4..i * 4 + 4].try_into().unwrap()); - assert_eq!(f(2), 0.25, "amount"); - assert_eq!(f(5), 0.2, "bounds low"); - assert_eq!(f(6), 0.8, "bounds high"); - } - - /// vec2 constants (audiobounds, texture scales, ...) must expose their - /// components like vec3 colors do, or the packer silently falls back to - /// the shader default and the artist's tuning is lost. - #[test] - fn vector_constants_expose_components_at_every_width() { - let mut map = ShaderVals::new(); - insert_value_components(&mut map, "audiobounds", &serde_json::json!("0.25 0.75")); - assert_eq!(map.get("audiobounds_x"), Some(&0.25)); - assert_eq!(map.get("audiobounds_y"), Some(&0.75)); - assert_eq!(map.get("audiobounds_r"), Some(&0.25)); - - insert_value_components(&mut map, "tintlow", &serde_json::json!("1 0 0")); - assert_eq!(map.get("tintlow_b"), Some(&0.0)); - // vec3s keep their implicit opaque alpha. - assert_eq!(map.get("tintlow_a"), Some(&1.0)); - } -} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 943bc38..e475b27 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1,52 +1,21 @@ -//! Wallpaper Engine scene rendering engine. +//! The scene engine: turns a Wallpaper Engine scene into frames. //! -//! The production live-render path is [`gpu_renderer::GpuSceneInstance`]; the -//! backend-neutral [`graph::SceneGraph`] feeds the CPU compositor and -//! diagnostics. +//! - [`model`]: what the scene is — `scene.json` types, resolving them into +//! layers, materials and shader metadata, static meshes +//! - [`sim`]: what changes over time — particles, timelines, SceneScript, +//! camera motion, puppet animation +//! - [`gpu`]: how it is drawn — the renderer +//! ([`gpu::renderer::GpuSceneInstance`] is the live path), render targets, +//! blending, lighting, shadows, text, and the GLSL→WGSL shader pipeline +//! - [`cpu`]: the CPU fallback renderer, used when the GPU path can't start +//! +//! File formats live in [`crate::formats`], user properties in +//! [`crate::workshop::properties`]. -pub mod animated; -pub mod assets; -pub mod audio; -pub mod blend; -pub mod camera; -pub mod camera3d; -pub mod camera_dynamics; -pub mod effect; -pub mod fbo; -pub mod gpu_renderer; -pub mod graph; -pub mod lighting; -pub mod material; -#[cfg(target_os = "linux")] -pub mod media; -pub mod mesh3d; +pub mod cpu; +pub mod gpu; pub mod model; -pub mod noise; -pub mod particle; -pub mod pass; -pub mod pkg; -pub mod playback_gate; -pub mod properties; -pub mod puppet; -pub mod render; -pub mod resource; -pub mod scene; -pub mod script; -pub mod shaders; -pub mod shadow; -pub mod tex; -pub mod text; -pub mod timeline; +pub mod sim; -// Convenience re-exports. -pub use camera::SceneCamera; -pub use camera_dynamics::{CameraDynamics, CameraFrameDynamics}; -pub use fbo::{RenderTarget, RenderTargetPool}; -pub use graph::SceneGraph; -pub use pass::{ScenePass, UniformValue}; -pub use pkg::Package; -pub use properties::SceneProperties; -pub use render::ResolvedScene; -pub use resource::ResourceManager; -pub use scene::Scene; -pub use tex::TexFile; +pub use cpu::graph::SceneGraph; +pub use model::resolve::ResolvedScene; diff --git a/src/engine/mesh3d.rs b/src/engine/model/mesh3d.rs similarity index 100% rename from src/engine/mesh3d.rs rename to src/engine/model/mesh3d.rs diff --git a/src/engine/model/mod.rs b/src/engine/model/mod.rs index e64bce4..ddd569e 100644 --- a/src/engine/model/mod.rs +++ b/src/engine/model/mod.rs @@ -1,7 +1,14 @@ +//! What a scene is: the `scene.json` types ([`scene`]), resolving them into +//! drawable layers ([`resolve`]), static `.mdl` meshes, and the typed +//! object/shader model with its per-frame dynamic values. + pub mod dynamic_value; +pub mod mesh3d; pub mod objects; pub mod parser; pub mod property; +pub mod resolve; +pub mod scene; pub mod shader_model; pub use dynamic_value::{AnimatedValue, DynamicValue}; diff --git a/src/engine/model/parser.rs b/src/engine/model/parser.rs index 323007b..3fa500c 100644 --- a/src/engine/model/parser.rs +++ b/src/engine/model/parser.rs @@ -6,7 +6,7 @@ // camera is Option, must use as_ref() use super::dynamic_value::{AnimatedValue, DynamicValue}; use super::objects::{EffectModel, ObjectModel, PassModel, SceneModel}; -use crate::engine::scene; +use crate::engine::model::scene; use anyhow::Result; use std::collections::HashMap; diff --git a/src/engine/model/resolve/layers.rs b/src/engine/model/resolve/layers.rs new file mode 100644 index 0000000..53e3c3a --- /dev/null +++ b/src/engine/model/resolve/layers.rs @@ -0,0 +1,1042 @@ +//! Turning scene objects into layers: image, particle, text, 3D mesh, +//! light and backdrop layers, with their placement (alignment, parallax +//! depth, spritesheet frame) and the puppet mesh an image may carry. + +use super::*; + +/// WE `parallaxDepth` is always a vec2 (independent x/y depth), but a bare +/// number in scene.json means "same depth on both axes". +pub(super) fn parse_parallax_depth(v: &serde_json::Value) -> [f64; 2] { + use crate::engine::model::DynamicValue; + match crate::engine::model::json_to_animated(v).value { + DynamicValue::Float(f) => [f as f64; 2], + DynamicValue::Vec3([x, y, _]) => [x as f64, y as f64], + _ => [0.0, 0.0], + } +} + +/// WE origin convention: Y-up, (0,0) at bottom-left (same as `Layer::origin`). +/// "top"/"bottom" shift the object vertically by half its scaled height so +/// the origin becomes that edge instead of the center; "left"/"right" do the +/// same horizontally. Matches CImage.cpp lines 242-256 exactly (each pair is +/// mutually exclusive — "top bottom" together, like the reference, only +/// honors "top"). +pub(crate) fn alignment_offset(alignment: Option<&str>, scaled_size: [f64; 2]) -> [f64; 2] { + let a = alignment.unwrap_or("").to_lowercase(); + let mut offset = [0.0, 0.0]; + if a.contains("top") { + offset[1] -= scaled_size[1] / 2.0; + } else if a.contains("bottom") { + offset[1] += scaled_size[1] / 2.0; + } + if a.contains("left") { + offset[0] += scaled_size[0] / 2.0; + } else if a.contains("right") { + offset[0] -= scaled_size[0] / 2.0; + } + offset +} + +/// `alignment_override`, when given, replaces `obj.alignment` (text objects +/// combine separate `horizontalalign`/`verticalalign` fields into one string +/// before calling this, rather than reading `obj.alignment` directly). +/// Brightness multiplier for additive (colorBlendMode 9) image layers — see the +/// call site. Default 0.55; override live with `WP_ENGINE_ADDITIVE_BRIGHTNESS`. +pub(super) fn additive_brightness_scale() -> f32 { + std::env::var("WP_ENGINE_ADDITIVE_BRIGHTNESS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0.55) +} + +/// For a `SPRITESHEET` layer, pick the single frame the object's frame script +/// selects and make it the layer image. Frames are the `.tex` TEXS sub-rects; the +/// index comes from the object's frame-select script (WE hangs +/// `getTextureAnimation().setFrame` off the `visible` script — e.g. AM/PM by +/// `getHours()`). Falls back to frame 0 if the script sets none. +pub(super) fn select_spritesheet_frame(obj: &SceneObject, loaded: &mut LoadedImage) { + if loaded.sprite_frames.len() < 2 { + return; + } + let frame = obj + .visible + .as_ref() + .and_then(|v| v.get("script")) + .and_then(|s| s.as_str()) + .and_then(|script| { + let init = crate::engine::sim::script::ObjectInit { + visible: true, + alpha: 1.0, + frame_count: loaded.sprite_frames.len() as u32, + ..Default::default() + }; + crate::engine::sim::script::evaluate_once("visible", script, &init, None).frame + }) + .unwrap_or(0) + .min(loaded.sprite_frames.len() as u32 - 1); + loaded.image = loaded.sprite_frames[frame as usize].clone(); +} + +/// Build the layer for one object outside the scene load — SceneScript's +/// `thisScene.createLayer`. Covers image, solid and text objects; particle +/// and model objects return `None`. `pkg` is the scene's package, if any. +pub fn runtime_layer(dir: &Path, pkg: Option<&Package>, obj: &SceneObject) -> Option { + if obj.text.is_some() { + return text_layer_from_object(obj, Some(dir), pkg); + } + let path = obj.image.as_deref()?; + if is_special_layer(path) { + return backdrop_layer_from_object(obj, path); + } + let resolved_path = resolve_workshop_asset(dir, pkg, path); + let path = resolved_path.as_str(); + let loaded = if is_solid_layer_path(path) { + LoadedImage::single(white_pixel()) + } else { + let from_pkg = pkg.map(|p| load_texture_from_pkg(p, path)); + match from_pkg { + Some(Ok(l)) => l, + _ => load_texture_from_dir(dir, path) + .inspect_err( + |e| tracing::warn!(target: "scene", "createLayer: texture '{path}': {e:#}"), + ) + .ok()?, + } + }; + Some(layer_from_object(obj, loaded, None)) +} + +pub(super) fn layer_from_object( + obj: &SceneObject, + mut loaded: LoadedImage, + alignment_override: Option<&str>, +) -> Layer { + // Spritesheet: replace the flattened atlas with the single cell the object's + // frame script selects (e.g. AM/PM by `getHours()`), so the one-cell quad + // shows one glyph instead of every cell squished together. + // ponytail: frame is picked once at load from the current wall clock; a live + // wallpaper won't flip AM↔PM mid-session without a reload (rare — re-select in + // the render loop if that ceiling ever matters). + select_spritesheet_frame(obj, &mut loaded); + // Attach puppet `animationlayers` (blended each frame) to the fresh runtime. + if let (Some(rt), Some(entries)) = ( + loaded.puppet.as_mut(), + obj.animationlayers.as_ref().and_then(|v| v.as_array()), + ) { + if let Some(rt) = std::sync::Arc::get_mut(rt) { + let n = rt.model.animations.len(); + if n > 0 { + let layers: Vec = entries + .iter() + .enumerate() + // `visible` is often a user-property binding + // (`{"user": "head", "value": false}`), not a bare bool. + .filter(|(_, e)| { + e.get("visible") + .and_then(crate::engine::model::scene::parse_value_bool) + .unwrap_or(true) + }) + .map(|(i, e)| { + // `animation` is the MDLA record's own id (2321732083's + // samurai layers name 60/63/80/105). Match it; fall back + // to index-then-declaration-order for files whose ids we + // failed to read. + let raw = e + .get("animation") + .and_then(|v| v.as_u64()) + .unwrap_or(i as u64); + let anim_idx = rt + .model + .animations + .iter() + .position(|a| a.id as u64 == raw) + .unwrap_or_else(|| { + if (raw as usize) < n { + raw as usize + } else { + i.min(n - 1) + } + }); + crate::engine::sim::puppet::AnimLayer { + anim_idx, + additive: e.get("additive").and_then(|v| v.as_bool()).unwrap_or(false), + blend: e.get("blend").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32, + rate: e.get("rate").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32, + } + }) + .collect(); + rt.layers = layers; + } + } + } + let parallax = obj + .parallax_depth + .as_ref() + .map(parse_parallax_depth) + .unwrap_or([0.0, 0.0]); + + // WE stores angles already in radians; only the z component (2D roll) + // applies to a flat layer quad. The reference negates it (rotate(-angle, + // ...)) when building the object's screen transform. + let angles_animated = obj + .angles + .as_ref() + .map(crate::engine::model::json_to_animated); + let angles3 = angles_animated + .as_ref() + .and_then(|v| v.as_vec3()) + .unwrap_or([0.0, 0.0, 0.0]); + let angle = -angles3[2]; + + let scale_animated = obj + .scale + .as_ref() + .map(crate::engine::model::json_to_animated); + let scale = scale_animated + .as_ref() + .and_then(|v| v.as_vec3()) + .map(|s| [s[0] as f64, s[1] as f64, s[2] as f64]) + .unwrap_or([1.0, 1.0, 1.0]); + let alpha_animated = obj + .alpha + .as_ref() + .map(crate::engine::model::json_to_animated); + let alpha = alpha_animated + .as_ref() + .and_then(|v| v.as_float()) + .unwrap_or(1.0); + let color = obj + .color + .as_ref() + .map(crate::engine::model::json_to_animated) + .and_then(|v| v.as_vec3()) + .unwrap_or([1.0, 1.0, 1.0]); + let brightness = obj + .brightness + .as_ref() + .map(crate::engine::model::json_to_animated) + .and_then(|v| v.as_float()) + .unwrap_or(1.0); + // ponytail: additive (colorBlendMode 9) image planes with a near-white base + // (e.g. LonelyCat's water-ripple layer) blow out to a flat white disc, + // because our water effects refract the layer's *own* bright base instead of + // the real backdrop (backdrop capture is unimplemented — see PROGRESS.md). + // Until that lands, scale additive layers down so the ripple structure stays + // visible. Ceiling: this dims *every* additive image layer, not just ripples; + // remove once screen-space backdrop capture exists. Tunable live via + // WP_ENGINE_ADDITIVE_BRIGHTNESS (default 0.55). + let brightness = if obj.color_blend_mode == 9 { + brightness * additive_brightness_scale() + } else { + brightness + }; + + // Alignment shifts the object so its origin becomes an edge rather than + // its center (CImage.cpp lines 242-256), expressed here as an offset + // folded directly into `origin` — every consumer of `Layer::origin` + // (both render paths) then gets alignment for free. + // Text objects (the only caller passing `alignment_override`) size their + // quad from the rasterized glyph bitmap, not the scene `size` field — + // WE's CText uses `m_quadSize` (the raster dims) and ignores `size` + // (which is editor bounding-box metadata). Honoring it would stretch the + // glyphs to an unrelated box. + let is_text = alignment_override.is_some(); + let raw_size = obj.parsed_size(); + let effective_size = if !is_text && raw_size[0] > 0.0 && raw_size[1] > 0.0 { + [raw_size[0], raw_size[1]] + } else { + [loaded.image.width() as f64, loaded.image.height() as f64] + }; + let scaled_size = [effective_size[0] * scale[0], effective_size[1] * scale[1]]; + let alignment = alignment_override.or(obj.alignment.as_deref()); + let align_offset = alignment_offset(alignment, scaled_size); + let origin = { + let o = obj.parsed_origin(); + [o[0] + align_offset[0], o[1] + align_offset[1], o[2]] + }; + + Layer { + backdrop: None, + name: obj.name.clone().unwrap_or_default(), + image: loaded.image, + extra_frames: loaded.extra_frames, + frame_duration_ms: loaded.frame_duration_ms, + origin, + // Text: leave size 0 so both render paths fall back to the raster dims. + size: if is_text { + [0.0, 0.0, 0.0] + } else { + obj.parsed_size() + }, + scale, + local_origin: origin, + local_scale: scale, + local_angles: angles3, + parallax_depth: parallax, + angle, + angles: angles3, + blend_mode: obj.color_blend_mode, + alpha, + color, + brightness, + copybackground: obj.copybackground, + no_interpolation: loaded.no_interpolation, + // Object-level `clampuvs` overrides the .tex ClampUVs flag when set. + clamp_uvs: loaded.clamp_uvs + || obj + .clampuvs + .as_ref() + .and_then(|v| { + v.as_bool() + .or_else(|| v.get("value").and_then(|i| i.as_bool())) + }) + .unwrap_or(false), + puppet: loaded.puppet, + video: loaded.video, + text_dynamic: None, + order_index: 0, + object_id: obj.id, + visible_base: obj.is_visible(), + } +} + +pub(super) fn read_particle_json( + dir: Option<&Path>, + pkg: Option<&Package>, + rel_path: &str, +) -> Option { + String::from_utf8(read_asset_bytes(dir, pkg, rel_path)?).ok() +} + +/// Build a `ParticleLayer` from a scene object whose `particle` field is a +/// preset path, resolving and parsing the referenced JSON. Returns `None` +/// (silently skipped, like a missing texture) if the preset can't be found +/// or parsed. +pub(super) fn particle_layer_from_object( + obj: &SceneObject, + dir: Option<&Path>, + pkg: Option<&Package>, +) -> Option { + let particle_ref = match &obj.particle { + Some(serde_json::Value::String(s)) => s.as_str(), + _ => return None, + }; + let json = read_particle_json(dir, pkg, particle_ref)?; + let mut config: particle::ParticleConfig = serde_json::from_str(&json) + .inspect_err( + |e| tracing::warn!(target: "particle", "failed to parse '{particle_ref}': {e}"), + ) + .ok()?; + let overrides: Option = obj + .instanceoverride + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + // `instanceoverride.enabled: false` turns the whole system off + // (ObjectParser defaults it to true). + if overrides.as_ref().is_some_and(|o| { + o.enabled + .as_ref() + .is_some_and(|v| v.as_bool() == Some(false) || v.as_u64() == Some(0)) + }) { + return None; + } + + // Control-point plumbing the simulation can't do itself (it only knows + // spawn-relative screen offsets): + // 1. `instanceoverride.controlpointN` repositions preset control points + // with absolute scene coordinates (e.g. the discharge preset's arc + // endpoint, "1586.7 993.2") — absolute values are world-space, so + // force flag 2 on. + // 2. Any world-space control point (flags & 2) is then converted to a + // spawn-relative offset here, where the object's origin is known: + // offset = (x_we - origin_x, origin_y - y_we) in the simulation's + // y-down screen space. + if let Some(over) = overrides.as_ref() { + for (key, value) in &over.extra { + let Some(n) = key + .strip_prefix("controlpoint") + .and_then(|s| s.parse::().ok()) + else { + continue; + }; + while config.controlpoint.len() <= n { + config + .controlpoint + .push(particle::ControlPointConfig::default()); + } + config.controlpoint[n].offset = Some(value.clone()); + config.controlpoint[n].flags = Some(config.controlpoint[n].flags.unwrap_or(0) | 2); + } + } + let origin = obj.parsed_origin(); + for cp in &mut config.controlpoint { + if cp.flags.unwrap_or(0) & 2 == 0 { + continue; + } + if let Some(world) = cp.offset.as_ref().and_then(particle::value_as_vec3_pub) { + let rel = [ + world[0] - origin[0] as f32, + origin[1] as f32 - world[1], + world[2], + ]; + cp.offset = Some(serde_json::json!(format!( + "{} {} {}", + rel[0], rel[1], rel[2] + ))); + cp.flags = Some(cp.flags.unwrap_or(0) & !2); + } + } + let parallax_depth = obj + .parallax_depth + .as_ref() + .map(parse_parallax_depth) + .unwrap_or([0.0, 0.0]); + + let resolved_sprite = config.material.as_deref().and_then(|mat_path| { + // A failure here silently degrades to the flat-color circle draw — + // visually plausible for dust/snow but wrong for shaped sprites, so + // it must at least be visible in the log. + if let Some(pkg) = pkg { + resolve_particle_sprite_pkg(pkg, mat_path) + .inspect_err(|e| { + eprintln!("[particle] sprite '{mat_path}' failed (untextured fallback): {e:#}") + }) + .ok() + } else { + dir.and_then(|dir| { + resolve_particle_sprite_dir(dir, mat_path) + .inspect_err(|e| { + eprintln!( + "[particle] sprite '{mat_path}' failed (untextured fallback): {e:#}" + ) + }) + .ok() + }) + } + }); + let additive_blend = resolved_sprite + .as_ref() + .is_some_and(|(_, blending)| blending.as_deref() == Some("additive")); + let sprite_texture = resolved_sprite.map(|(sprite, _)| sprite); + + // Child presets: same preset-JSON + material resolution as the parent. + // Failures degrade to "child skipped" (logged), never sink the layer. + let children: Vec = config + .children + .clone() + .unwrap_or_default() + .into_iter() + .filter_map(|child_ref| { + let json = read_particle_json(dir, pkg, &child_ref.name).or_else(|| { + eprintln!("[particle] child preset '{}' not found", child_ref.name); + None + })?; + let child_cfg: particle::ParticleConfig = serde_json::from_str(&json) + .inspect_err(|e| { + eprintln!("[particle] child '{}' parse failed: {e}", child_ref.name) + }) + .ok()?; + let resolved = child_cfg.material.as_deref().and_then(|mat_path| { + if let Some(pkg) = pkg { + resolve_particle_sprite_pkg(pkg, mat_path).ok() + } else { + dir.and_then(|dir| resolve_particle_sprite_dir(dir, mat_path).ok()) + } + }); + let additive = resolved + .as_ref() + .is_some_and(|(_, blending)| blending.as_deref() == Some("additive")); + let sprite = resolved.map(|(sprite, _)| sprite); + Some(ResolvedChildParticle { + config: child_cfg, + sprite, + additive, + child_ref, + }) + }) + .collect(); + + Some(ParticleLayer { + name: obj.name.clone().unwrap_or_default(), + origin: obj.parsed_origin(), + local_origin: obj.parsed_origin(), + parallax_depth, + config, + overrides, + sprite_texture, + additive_blend, + order_index: 0, + children, + }) +} + +/// `text` is either a plain JSON string or a `{"user": ..., "value": "..."}` +/// wrapper (scene.json's usual user-setting shape) — deliberately NOT routed +/// through `json_to_animated`, which treats 2-3 whitespace-separated numbers +/// as a vector; that heuristic is wrong for text content that happens to be +/// purely numeric (e.g. a countdown reading "12 34"). +pub(super) fn extract_text_string(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Object(map) => map.get("value").and_then(extract_text_string), + _ => None, + } +} + +/// Build a text object's rendered bitmap as a normal `Layer` (see +/// `engine::gpu::text` module docs for why: no scripting engine means text never +/// changes after this one rasterization, so it can reuse 100% of the +/// existing image-layer pipeline in both render paths for free). +pub(super) fn text_layer_from_object( + obj: &SceneObject, + dir: Option<&Path>, + pkg: Option<&Package>, +) -> Option { + // A text object's `text` is either a plain string, a {"value": …} + // wrapper, or a scripted {"script": "…", "value"?: …} — clocks/dates + // (the dominant case in real content) often ship the script with NO + // static value at all, so the script's first evaluation IS the content. + let text_value = obj.text.as_ref()?; + let script = text_value + .get("script") + .and_then(|v| v.as_str()) + .map(str::to_string); + let script_properties = text_value.get("scriptproperties").cloned(); + let static_str = extract_text_string(text_value).unwrap_or_default(); + let text_str = match (&script, static_str.is_empty()) { + (Some(script), _) => { + // One-shot load-time evaluation so the layer rasterizes real + // content (and sizes itself correctly) from frame 0. + let init = crate::engine::sim::script::ObjectInit { + name: obj.name.clone(), + id: obj.id, + visible: true, + alpha: 1.0, + text: Some(static_str.clone()), + ..Default::default() + }; + crate::engine::sim::script::evaluate_once( + "text", + script, + &init, + script_properties.as_ref(), + ) + .text + .filter(|t| !t.is_empty()) + .unwrap_or(static_str) + } + (None, false) => static_str, + (None, true) => return None, + }; + if text_str.is_empty() { + return None; + } + let point_size = obj + .pointsize + .as_ref() + .and_then(|v| { + v.as_f64() + .or_else(|| v.get("value").and_then(|v| v.as_f64())) + }) + .unwrap_or(32.0) as f32; + let scale = obj + .scale + .as_ref() + .and_then(|v| crate::engine::model::json_to_animated(v).as_vec3()) + .unwrap_or([1.0, 1.0, 1.0]); + // The on-screen text height is driven by the object's `size` BOX height, + // not pointsize: `layer_from_object` scales the rasterized bitmap by the + // object's `scale`, and WE authors size text via that box (a big box + + // scale>1 = big text; a modest box + scale<1 = small text). Rasterize at + // the box height so the bitmap ≈ its on-screen size (crisp, natural aspect, + // no stretch). `pointsize` here is only the fallback resolution when the + // object ships no box (then compensate for `scale` like CText does, so the + // glyphs don't collapse to a couple of pixels). + let box_h = obj.parsed_size()[1] as f32; + let raster_px = if box_h > 0.0 { + box_h + } else { + let avg_scale = (scale[0] + scale[1]) * 0.5; + let compensate = if avg_scale > 0.0 && avg_scale < 1.0 { + (1.0 / avg_scale).min(32.0) + } else { + 1.0 + }; + point_size * compensate + } + .clamp(1.0, 1024.0); + let font_data = crate::engine::gpu::text::resolve_font_data(obj.font.as_deref(), dir, pkg)?; + // Word-wrap to `maxwidth` (scene px == bitmap px at our raster resolution) + // and cap at `maxrows`, if authored. + let gate = |v: &Option| { + v.as_ref() + .and_then(crate::engine::model::scene::parse_value_bool) + .unwrap_or(false) + }; + let text_str = match obj + .maxwidth + .as_ref() + .and_then(crate::engine::model::scene::parse_value_f32) + .filter(|w| *w > 0.0) + .filter(|_| gate(&obj.limitwidth)) + { + Some(mw) => { + let rows = obj + .maxrows + .as_ref() + .and_then(crate::engine::model::scene::parse_value_f32) + .filter(|_| gate(&obj.limitrows)) + .unwrap_or(0.0) + .max(0.0) as usize; + crate::engine::gpu::text::wrap_text( + &font_data, + &text_str, + raster_px, + mw, + rows, + gate(&obj.limituseellipsis), + ) + } + None => text_str, + }; + let image = crate::engine::gpu::text::rasterize(&font_data, &text_str, raster_px)?; + + // Opaque background box: bake the text color + bg color + padding into the + // bitmap and neutralize the layer tint (color set to white below). `pad` + // is authored in scene px, which maps 1:1 to bitmap px (we raster at the + // box height). Off by default — `opaquebackground` is usually false. + let opaque_bg = obj + .opaquebackground + .as_ref() + .and_then(|v| { + v.as_bool() + .or_else(|| v.get("value").and_then(|i| i.as_bool())) + }) + .unwrap_or(false); + let (image, bg_baked) = if opaque_bg { + let text_color = obj + .color + .as_ref() + .and_then(|v| crate::engine::model::scene::parse_value_vec3(v)) + .map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]) + .unwrap_or([1.0, 1.0, 1.0]); + let bright = obj + .backgroundbrightness + .as_ref() + .and_then(crate::engine::model::scene::parse_value_f32) + .unwrap_or(1.0); + let bg = obj + .backgroundcolor + .as_ref() + .and_then(|v| crate::engine::model::scene::parse_value_vec3(v)) + .map(|c| { + [ + c[0] as f32 * bright, + c[1] as f32 * bright, + c[2] as f32 * bright, + ] + }) + .unwrap_or([0.0, 0.0, 0.0]); + let pad = obj + .padding + .as_ref() + .and_then(crate::engine::model::scene::parse_value_f32) + .unwrap_or(0.0) + .max(0.0) as u32; + ( + crate::engine::gpu::text::with_background(&image, text_color, bg, pad), + true, + ) + } else { + (image, false) + }; + + let halign = obj + .horizontalalign + .as_deref() + .or(obj.alignment.as_deref()) + .unwrap_or("center"); + let valign = obj.verticalalign.as_deref().unwrap_or("center"); + let combined = format!("{halign} {valign}"); + + let mut layer = layer_from_object(obj, LoadedImage::single(image), Some(&combined)); + // WE fits the glyphs into the authored `size` box preserving their + // aspect (letterbox), not stretch-to-fill: a tall clock box (375×161) + // over ~99×26 glyphs would render the block ~6× too tall and collide with + // the layer below it. Shrink the box to the bitmap aspect — the + // constrained dimension keeps the box, the other pulls in. + // ponytail: refit only; origin's alignment offset was folded for the + // original box, so this is exact for center-aligned text (all clocks/dates + // here) and would need an offset re-fold for left/top anchors. + let bw = layer.image.width() as f64; + let bh = layer.image.height() as f64; + if bw > 0.0 && bh > 0.0 && layer.size[0] > 0.0 && layer.size[1] > 0.0 { + let aspect = bw / bh; + let (box_w, box_h) = (layer.size[0], layer.size[1]); + let fitted = if box_w / box_h > aspect { + [box_h * aspect, box_h] + } else { + [box_w, box_w / aspect] + }; + layer.size[0] = fitted[0]; + layer.size[1] = fitted[1]; + } + if bg_baked { + // Text + bg colors are baked into the bitmap; don't tint it again. + layer.color = [1.0, 1.0, 1.0]; + } + // Any text layer can be re-rasterized: its own script, or another + // object's script, may set its text. + layer.text_dynamic = Some(TextDynamic { + font_data, + point_size: raster_px, + last_text: text_str, + }); + Some(layer) +} + +/// Build a static-3D-mesh layer for an object whose `model` names a `.mdl`. +/// `read` fetches an asset by relative path (dir- or pkg-backed), and +/// `resolve_tex` turns the mesh's embedded material path into its texture. +pub(super) fn mesh3d_layer_from_object( + obj: &SceneObject, + dir: Option<&Path>, + pkg: Option<&Package>, +) -> Option { + let path = obj.mesh3d_path()?; + // The pkg stores every path lowercased, while scene.json and the `.mdl` + // header keep the author's casing (`models/LP/LP.mdl`, and real content + // has non-ASCII names too) — so retry lowercased on any miss. + let bytes = read_asset_bytes(dir, pkg, path) + .or_else(|| read_asset_bytes(dir, pkg, &path.to_lowercase()))?; + let mesh = crate::engine::model::mesh3d::parse(&bytes)?; + let resolve_tex = |p: &str| { + dir.and_then(|d| load_texture_from_dir(d, p).ok()) + .or_else(|| pkg.and_then(|p2| load_texture_from_pkg(p2, p).ok())) + .map(|l| l.image) + }; + let pass = material_first_pass(dir, pkg, &mesh.material); + let texture = resolve_tex(&mesh.material) + .or_else(|| resolve_tex(&mesh.material.to_lowercase())) + // Untextured materials are normal here (`"textures": [null,null,null]` + // with a flat `color` constant): draw them as that color. An + // unresolvable material falls through to white rather than vanishing. + .unwrap_or_else(|| { + let [r, g, b] = pass + .as_ref() + .and_then(pass_constant_color) + .unwrap_or([255; 3]); + RgbaImage::from_pixel(1, 1, image::Rgba([r, g, b, 255])) + }); + let nocull = pass + .as_ref() + .and_then(|p| p.get("cullmode")?.as_str()) + .is_some_and(|m| m == "nocull"); + // The object's own `depthtest` wins over the material pass's. + let depthtest = obj + .depthtest + .as_ref() + .and_then(|v| v.as_str()) + .or_else(|| pass.as_ref().and_then(|p| p.get("depthtest")?.as_str())) + .map(|m| m != "disabled") + .unwrap_or(true); + + let scale = obj + .scale + .as_ref() + .and_then(|v| crate::engine::model::json_to_animated(v).as_vec3()) + .unwrap_or([1.0, 1.0, 1.0]); + let angles = obj + .angles + .as_ref() + .and_then(|v| crate::engine::model::json_to_animated(v).as_vec3()) + .unwrap_or([0.0, 0.0, 0.0]); + let o = obj.parsed_origin(); + + tracing::debug!( + target: "scene", + "mesh3d '{path}': {} verts, {} tris, material '{}'", + mesh.positions.len(), + mesh.indices.len() / 3, + mesh.material + ); + Some(Mesh3dLayer { + name: obj.name.clone().unwrap_or_default(), + mesh, + texture, + origin: [o[0] as f32, o[1] as f32, o[2] as f32], + scale, + angles, + local_origin: [o[0] as f32, o[1] as f32, o[2] as f32], + local_scale: scale, + local_angles: angles, + nocull, + depthtest, + order_index: 0, + }) +} + +/// A WE light object rendered as an additive radial glow — a minimal stand-in +/// for the full lighting model. Colored by `color`, sized by `radius`, with a +/// smooth quadratic falloff scaled by `intensity`. Additively blended so it +/// brightens whatever is behind it (like a real light). +pub(super) fn light_layer_from_object(obj: &SceneObject) -> Option { + let (radius, intensity, color) = obj.light_params()?; + + let d = (radius * 2.0).min(2048.0) as u32; + let mut img = RgbaImage::new(d, d); + let c = d as f32 / 2.0; + for y in 0..d { + for x in 0..d { + let dist = (((x as f32 - c).powi(2) + (y as f32 - c).powi(2)).sqrt() / c).min(1.0); + let a = (1.0 - dist).powi(2) * intensity; // quadratic falloff + img.put_pixel( + x, + y, + image::Rgba([ + (color[0] * 255.0) as u8, + (color[1] * 255.0) as u8, + (color[2] * 255.0) as u8, + (a * 255.0) as u8, + ]), + ); + } + } + + let mut layer = layer_from_object(obj, LoadedImage::single(img), None); + // colorBlendMode 9 = Add (linear dodge) — the glow adds light. + layer.blend_mode = 9; + layer.color = [1.0, 1.0, 1.0]; + Some(layer) +} + +/// If the model JSON names a `"puppet"` mesh, its texture is a packed UV +/// atlas — reassemble it by rasterizing the rest-pose mesh (see +/// `engine::sim::puppet`), and keep the parsed model + atlas around as a +/// `PuppetRuntime` so the live render loop can re-pose it over time when +/// the model carries MDLA animations. On any parse failure the decoded +/// texture passes through unchanged, so a malformed .mdl degrades to the +/// old scrambled-quad behavior instead of dropping the layer. +pub(super) fn apply_puppet_mesh( + atlas: RgbaImage, + model_json_path: &str, + read: impl Fn(&str) -> Option>, +) -> LoadedImage { + let plain = |atlas: RgbaImage| LoadedImage::single(atlas); + let Some(model_bytes) = read(model_json_path) else { + return plain(atlas); + }; + let Ok(model_json) = serde_json::from_slice::(&model_bytes) else { + return plain(atlas); + }; + let Some(puppet_path) = model_json.get("puppet").and_then(|v| v.as_str()) else { + return plain(atlas); + }; + let Some(mdl_bytes) = read(puppet_path) else { + tracing::warn!(target: "scene", "puppet mesh '{puppet_path}' not found — drawing raw atlas"); + return plain(atlas); + }; + let Some(model) = crate::engine::sim::puppet::parse_model(&mdl_bytes) else { + tracing::warn!(target: "scene", "puppet mesh '{puppet_path}' unparsable — drawing raw atlas"); + return plain(atlas); + }; + tracing::debug!( + target: "scene", + "puppet '{puppet_path}': {} vertices, {} triangles, {} bones, {} animations", + model.mesh.positions.len(), + model.mesh.indices.len() / 3, + model.bones.len(), + model.animations.len() + ); + // With a skeleton + animation, frame 0 of the animation IS the + // assembled pose (skin = worldAnim * inverse(atlas-space bind)); the + // raw mesh alone only reproduces the packed atlas layout. + let runtime = crate::engine::sim::puppet::PuppetRuntime { + model, + atlas, + layers: Vec::new(), + }; + let assembled = if runtime.model.has_animation() { + runtime.render_at(0.0, runtime.atlas.width(), runtime.atlas.height()) + } else { + crate::engine::sim::puppet::rasterize( + &runtime.model.mesh, + &runtime.atlas, + runtime.atlas.width(), + runtime.atlas.height(), + ) + }; + let mut loaded = LoadedImage::single(assembled); + if runtime.model.has_animation() { + loaded.puppet = Some(std::sync::Arc::new(runtime)); + } + loaded +} + +/// Same model -> material -> texture chain as `resolve_model_chain_{pkg,dir}`, +/// but for particle sprites: keeps every sprite-sheet frame (sliced from the +/// `.tex`'s TEXS table, if any) instead of collapsing to one flat image, plus +/// the animation's total loop duration so `ParticleSystem::set_sprite_frames` +/// can drive per-particle frame advance, and the material pass's own +/// `"blending"` string so the caller can composite additive-glow presets +/// (fog/smoke/embers/rain/lightning — the overwhelming majority of real +/// particle materials) correctly instead of always alpha-blending, which +/// makes a sprite's near-black background visibly darken the scene instead +/// of contributing nothing. +pub(super) fn resolve_particle_sprite_pkg( + pkg: &Package, + json_path: &str, +) -> Result<(particle::ParticleSprite, Option)> { + let (tex, blending, overbright) = find_model_chain_tex_pkg(pkg, json_path)?; + let duration: f32 = tex.frames().iter().map(|f| f.frametime).sum(); + Ok(( + particle::ParticleSprite { + frames: tex.to_particle_rgba_frames()?, + duration, + overbright, + }, + blending, + )) +} + +pub(super) fn resolve_particle_sprite_dir( + dir: &Path, + json_path: &str, +) -> Result<(particle::ParticleSprite, Option)> { + let (tex, blending, overbright) = find_model_chain_tex_dir(dir, json_path)?; + let duration: f32 = tex.frames().iter().map(|f| f.frametime).sum(); + Ok(( + particle::ParticleSprite { + frames: tex.to_particle_rgba_frames()?, + duration, + overbright, + }, + blending, + )) +} + +pub(super) fn guess_scene_dimensions(scene: &Scene, layers: &[Layer]) -> (u32, u32) { + // Perspective 3D scenes have no orthogonal projection and their layer + // sizes are world-space, not pixels — inferring pixel dimensions from + // them yields garbage (e.g. 1619x29). Render at a fixed 16:9 target; the + // perspective camera maps world → NDC independently of this resolution. + if scene.is_perspective() { + return (1920, 1080); + } + + // 1. Try orthogonal projection dimensions from camera or general settings + if let Some(cam) = &scene.camera { + if let Some(proj) = &cam.orthogonal_projection { + if let (Some(w), Some(h)) = (proj.width, proj.height) { + if w > 0 && h > 0 { + return (w, h); + } + } + } + } + if let Some(gen) = &scene.general { + if let Some(proj) = &gen.orthogonal_projection { + if let (Some(w), Some(h)) = (proj.width, proj.height) { + if w > 0 && h > 0 { + return (w, h); + } + } + } + } + + // 2. Use the largest layer dimensions + let mut max_w = 0u32; + let mut max_h = 0u32; + for layer in layers { + max_w = max_w.max(layer.image.width()); + max_h = max_h.max(layer.image.height()); + } + if max_w > 0 && max_h > 0 { + return (max_w, max_h); + } + + // 3. Fallback + (1920, 1080) +} + +/// For model/copybackground layers we can't render, create a transparent +/// placeholder so any screen-space effects applied to them have a base to work +/// with. It must be transparent, not opaque grey: these are usually full-screen +/// effect-only overlays (e.g. "Light shafts"), so an opaque base paints a giant +/// grey box over the scene whenever the effect is a SKIP or an additive overlay. +/// ponytail: transparent base; revisit if an effect needs to sample the real +/// scene behind it (backdrop capture — not implemented). +pub(super) fn placeholder_for(obj: &SceneObject) -> RgbaImage { + let size = obj.parsed_size(); + let w = if size[0] > 0.0 { size[0] as u32 } else { 1920 }; + let h = if size[1] > 0.0 { size[1] as u32 } else { 1080 }; + RgbaImage::from_pixel(w, h, image::Rgba([0u8, 0, 0, 0])) +} + +pub(super) fn is_special_layer(image_path: &str) -> bool { + let p = image_path.to_lowercase(); + p.contains("projectlayer") || p.contains("composelayer") || p.contains("fullscreenlayer") +} + +/// WE's solid-color model bundles (`models/util/solidlayer.json`, +/// `solid_instance_model_*.json`) reference a "flat" shader that ignores its +/// texture entirely and just outputs `vec4(g_Color, g_Alpha)` — i.e. the +/// object's own color/alpha, no image. A 1×1 white texture reproduces that +/// exactly once tinted by `Layer::color`/`alpha`/`brightness`. +pub(super) fn is_solid_layer_path(image_path: &str) -> bool { + let p = image_path.to_lowercase(); + p.contains("solidlayer") || p.contains("solid_instance_model") +} + +pub(super) fn white_pixel() -> RgbaImage { + RgbaImage::from_pixel(1, 1, image::Rgba([255, 255, 255, 255])) +} + +/// WE sizes a solid layer to the full scene when it has no explicit size +/// (`CImage.cpp:199`: `if (solidlayer && size == 0) size = sceneSize`) — but +/// only to give an effect chain a dummy target. A size-less solid layer with +/// no effects is a script/cursor holder ("Player Options" in 2988724897); +/// stretching its opaque white over the scene hides the whole wallpaper, so +/// callers only register those that carry effects. +/// Composition (`composelayer`), fullscreen (`fullscreenlayer`) and +/// projection (`projectlayer`) layers have no image of their own: their +/// texture is the scene already drawn beneath them — the region under the +/// layer for a composition layer, the whole scene otherwise — which their +/// effects then process (audio bars, fisheye, colour grading, blur…). +/// Built with a placeholder image; the live renderer captures the backdrop +/// each frame. Fullscreen and projection layers (and composition layers +/// without a size) are sized to the scene by `fill_solid_layer_sizes`. +pub(super) fn backdrop_layer_from_object(obj: &SceneObject, path: &str) -> Option { + let p = path.to_lowercase(); + let kind = if p.contains("composelayer") { + Backdrop::Region + } else if p.contains("fullscreenlayer") || p.contains("projectlayer") { + Backdrop::FullScene + } else { + return None; + }; + let mut layer = layer_from_object( + obj, + LoadedImage::single(RgbaImage::from_pixel(1, 1, image::Rgba([0, 0, 0, 0]))), + None, + ); + if kind == Backdrop::FullScene { + layer.size = [0.0, 0.0, 0.0]; + } + layer.backdrop = Some(kind); + Some(layer) +} + +pub(super) fn fill_solid_layer_sizes( + layers: &mut [Layer], + solid_indices: &[usize], + width: u32, + height: u32, +) { + for &i in solid_indices { + if layers[i].size[0] == 0.0 && layers[i].size[1] == 0.0 { + layers[i].size = [width as f64, height as f64, 0.0]; + } + } +} diff --git a/src/engine/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs new file mode 100644 index 0000000..58e7c01 --- /dev/null +++ b/src/engine/model/resolve/mod.rs @@ -0,0 +1,609 @@ +//! Resolving a scene: `scene.json` (with user properties applied) becomes a +//! [`ResolvedScene`] of drawable layers with their textures loaded. +//! +//! - `layers`: building image, particle, text, mesh, light and backdrop +//! layers from scene objects +//! - `textures`: loading textures from the folder, `scene.pkg` and shared +//! assets, and streaming embedded video +//! - `transform`: object transforms and the parent chain +//! +//! The CPU compositor that draws a `ResolvedScene` is `engine::cpu::compose`. + +use anyhow::{Context, Result}; +use image::RgbaImage; +use std::collections::HashMap; +use std::path::Path; + +use crate::engine::model::scene::{Scene, SceneObject}; +use crate::engine::sim::particle; +use crate::formats::pkg::Package; +use crate::formats::tex::TexFile; +use crate::workshop::properties::SceneProperties; + +mod textures; +pub use textures::*; + +mod transform; +pub use transform::*; + +mod layers; +pub use layers::*; + +/// Parse scene.json, first resolving `{"user": ...}` references against the +/// wallpaper's project.json properties (plus any `--set-property` overrides). +fn parse_scene_with_properties(scene_json: &str, project_dir: Option<&Path>) -> Result { + let mut value: serde_json::Value = serde_json::from_str(scene_json).with_context(|| { + let preview = if scene_json.len() > 200 { + &scene_json[..200] + } else { + scene_json + }; + format!("failed to parse scene.json: {preview}...") + })?; + if let Some(dir) = project_dir { + let props = SceneProperties::from_project_dir(dir); + props.resolve_scene_json(&mut value); + } + Scene::from_value(value) +} + +/// A fully resolved scene ready to composite into a single frame. +pub struct ResolvedScene { + pub width: u32, + pub height: u32, + pub layers: Vec, + pub particle_layers: Vec, + /// Static 3D mesh objects (`model` → `.mdl`) — only genuine perspective + /// scenes have these; the 2D compositor ignores them. + pub mesh3d_layers: Vec, + /// Parent chain + transform scripts per object, for the live path's + /// per-frame recompose. See [`TransformGraph`]. + pub transform_graph: TransformGraph, + pub scene: Scene, +} + +/// A scene object whose `model` field references a static 3D mesh. Rendered as +/// real geometry through the perspective camera (see `engine::model::mesh3d`), not as +/// a flat quad — spheres, skyboxes and cylinders can't be billboarded. +pub struct Mesh3dLayer { + pub name: String, + pub mesh: crate::engine::model::mesh3d::Mesh3d, + /// Resolved from the material path embedded in the `.mdl` header. + pub texture: RgbaImage, + /// World-space transform (WE scene units; the mesh's own space is ≈ unit). + pub origin: [f32; 3], + pub scale: [f32; 3], + pub angles: [f32; 3], + /// The same three before `apply_parent_transforms` composed the parent + /// chain in. A script-driven parent moves per frame, so the live path + /// recomposes from these rather than trying to invert the baked world value. + pub local_origin: [f32; 3], + pub local_scale: [f32; 3], + pub local_angles: [f32; 3], + /// The material's `"cullmode": "nocull"` — draw both faces. Everything + /// else culls back faces, which is what lets a skybox's near hemisphere + /// drop out instead of hiding the whole scene inside it. + pub nocull: bool, + /// `depthtest: "disabled"` on the object (or its material pass) — draw + /// without depth comparison. Real content always leaves it enabled; this + /// exists so content that disables it isn't silently depth-culled. + pub depthtest: bool, + pub order_index: usize, +} + +/// A scene object whose `particle` field references a particle preset JSON +/// (e.g. `"particles/presets/fog1.json"`). Holds enough static data for any +/// renderer to build its own `particle::ParticleSystem` (each render path +/// owns independent simulation state, so this is config, not a live system). +pub struct ParticleLayer { + pub name: String, + /// WE origin: absolute scene coordinates, Y-up, (0,0) at bottom-left — + /// same convention as `Layer::origin`; callers convert to pixel space at + /// render time (`px = origin.x`, `py = height - origin.y`). + pub origin: [f64; 3], + /// `origin` before the parent chain was composed in (see `Layer`). + pub local_origin: [f64; 3], + pub parallax_depth: [f64; 2], + pub config: particle::ParticleConfig, + pub overrides: Option, + /// Resolved once from `config.material` (if present) via the same + /// model/material→texture chain image layers use, keeping every + /// sprite-sheet frame. `None` falls back to `render_onto`'s flat-color + /// circle draw. + pub sprite_texture: Option, + /// True when the material's own pass declares `"blending":"additive"` + /// (the overwhelming majority of real particle materials — fog, smoke, + /// embers, rain, lightning). Composited additively onto the scene + /// instead of the default alpha-over, which otherwise makes a sprite's + /// near-black background visibly darken/box the scene behind it. + pub additive_blend: bool, + /// This object's raw index in `scene.objects` (already the scene's + /// topological/declaration render order) — lets render loops interleave + /// particle systems with image layers in true scene z-order, and lets + /// effect instances (which record the same raw object index) find their + /// owning layer regardless of how many particles/skipped/invisible + /// objects precede it. + pub order_index: usize, + /// Child presets from `config.children`, resolved (JSON + material + /// sprite) here where asset access lives; attached to the built system + /// via `ParticleSystem::add_child` at construction time. + pub children: Vec, +} + +/// One resolved `children` entry of a particle preset (see +/// [`particle::ChildRef`]). +pub struct ResolvedChildParticle { + pub config: particle::ParticleConfig, + pub sprite: Option, + pub additive: bool, + pub child_ref: particle::ChildRef, +} + +/// Everything needed to re-rasterize a script-driven text layer at runtime: +/// the script and font. The layer's projected `rect` (position + size) is +/// computed once at build through the full camera/parent transform; on a +/// content change we keep that rect's center and only rescale its extent by +/// the ratio of the new bitmap size to the old, so no projection is re-derived. +#[derive(Clone)] +pub struct TextDynamic { + pub font_data: Vec, + pub point_size: f32, + /// Text currently rasterized into the layer image (the `value` WE feeds + /// back into `update` each tick). + pub last_text: String, +} + +/// What a backdrop layer captures each frame (see `backdrop_layer_from_object`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Backdrop { + /// The scene beneath the layer's own quad (composition layer). + Region, + /// The whole scene (fullscreen / projection layer). + FullScene, +} + +pub struct Layer { + /// `Some` for composition/fullscreen layers, whose texture is the scene + /// beneath them rather than an image. + pub backdrop: Option, + pub name: String, + pub image: RgbaImage, + /// Additional animation frames (frame 0 is `image`). Empty = not animated. + pub extra_frames: Vec, + /// Duration of each frame in milliseconds. 0 = not animated / unknown. + pub frame_duration_ms: u32, + pub origin: [f64; 3], + pub size: [f64; 3], + pub scale: [f64; 3], // WE scale multiplier (default 1.0), separate from size + /// `origin`/`scale`/`angles` before `apply_parent_transforms` composed the + /// parent chain in — the live path recomposes from these each frame when a + /// parent is script-driven. See `ResolvedScene::transform_graph`. + pub local_origin: [f64; 3], + pub local_scale: [f64; 3], + pub local_angles: [f32; 3], + pub parallax_depth: [f64; 2], + /// z-rotation in radians (WE `angles.z`; the JSON value is already in radians). + pub angle: f32, + /// Full 3D rotation in radians, un-negated as stored in scene.json. + /// Only used by the perspective (3D scene) path; 2D uses `angle`. + pub angles: [f32; 3], + pub blend_mode: u32, + pub alpha: f32, + pub color: [f32; 3], + pub brightness: f32, + /// True for WE "copybackground" layers — source is the rendered canvas so far. + pub copybackground: bool, + /// `.tex` `NoInterpolation` flag: sample nearest instead of linear. + pub no_interpolation: bool, + /// `.tex` `ClampUVs`/`ClampUVsBorder` flag: clamp-to-edge instead of repeat. + pub clamp_uvs: bool, + /// Animated puppet runtime (mesh + skeleton + MDLA animations) — the + /// live GPU path re-poses and re-rasterizes `image` from this. + pub puppet: Option>, + /// Embedded-video stream (mp4 inside the .tex) — the live GPU path + /// re-uploads decoded frames from this; `image` is the first frame. + pub video: Option>>, + /// Script-driven text: the GPU path re-evaluates and re-rasterizes + /// `image` when the script's output changes (clocks, dates, countdowns). + pub text_dynamic: Option, + /// This object's raw index in `scene.objects` — see + /// `ParticleLayer::order_index`. + pub order_index: usize, + /// The scene object's own `id`. Effects reference another object's + /// rendered output as `_rt_imageLayerComposite__a`, so the live path + /// needs to know which layer each id belongs to. + pub object_id: Option, + /// The object's AUTHORED `visible` value — the starting visibility and the + /// `value` fed to a `visible` script. Critical: scripts that only define + /// cursor handlers (invisible click hitboxes, `"value": false`) have no + /// `update()`, so the script returns `value` unchanged — feeding `true` + /// here would render those hitboxes as opaque boxes. + pub visible_base: bool, +} + +impl ResolvedScene { + /// Load a scene wallpaper from a directory. + /// + /// Automatically detects whether assets are loose files or packed in a + /// `.pkg` archive and loads accordingly. The scene file is usually + /// `scene.json`/`scene.pkg`, but project.json's `file` field can name a + /// different one — GIF-converted wallpapers ship + /// `gifscene.json`/`gifscene.pkg` (e.g. workshop item 2036522973). + #[tracing::instrument(target = "scene", level = "debug", fields(dir = %dir.display()))] + pub fn from_directory(dir: &Path) -> Result { + tracing::debug!(target: "scene", "resolving scene from directory"); + let scene_name = std::fs::read_to_string(dir.join("project.json")) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|p| p.get("file")?.as_str().map(str::to_string)) + .filter(|f| f.ends_with(".json")) + .unwrap_or_else(|| "scene.json".to_string()); + let pkg_name = format!("{}.pkg", scene_name.trim_end_matches(".json")); + + let scene_json_path = dir.join(&scene_name); + let scene_pkg_path = dir.join(&pkg_name); + + if scene_pkg_path.exists() { + let pkg = Package::from_file(&scene_pkg_path)?; + + let scene_json = if scene_json_path.exists() { + std::fs::read_to_string(&scene_json_path) + .with_context(|| format!("reading {}", scene_json_path.display()))? + } else if let Some(data) = pkg.get(&scene_name).or_else(|| pkg.get("scene.json")) { + String::from_utf8(data.to_vec()) + .context("scene json inside PKG is not valid UTF-8")? + } else { + anyhow::bail!("no {scene_name} found in directory or PKG archive"); + }; + + return Self::from_package_with_dir(&pkg, &scene_json, dir); + } + + let scene_json = std::fs::read_to_string(&scene_json_path) + .with_context(|| format!("reading {}", scene_json_path.display()))?; + let scene = parse_scene_with_properties(&scene_json, Some(dir))?; + + let mut layers = Vec::new(); + let mut solid_indices = Vec::new(); + let mut particle_layers = Vec::new(); + let mut mesh3d_layers = Vec::new(); + // Effective visibility (own AND every ancestor) — see Scene::visibility_mask. + let visible = scene.visibility_mask(); + // Raw `scene.objects` index (not a visible-only count): effect + // instances record the same raw index, so layers and effects agree + // on object identity no matter what gets skipped in between. + for (obj_index, obj) in scene.objects.iter().enumerate() { + if !visible[obj_index] { + continue; + } + if obj.light.is_some() && obj.image.is_none() { + if let Some(mut layer) = light_layer_from_object(obj) { + layer.order_index = obj_index; + layers.push(layer); + } + continue; + } + if obj.mesh3d_path().is_some() { + if let Some(mut ml) = mesh3d_layer_from_object(obj, Some(dir), None) { + ml.order_index = obj_index; + mesh3d_layers.push(ml); + } + continue; + } + if obj.particle.is_some() { + if let Some(mut pl) = particle_layer_from_object(obj, Some(dir), None) { + pl.order_index = obj_index; + particle_layers.push(pl); + } + continue; + } + if obj.text.is_some() { + if let Some(mut layer) = text_layer_from_object(obj, Some(dir), None) { + layer.order_index = obj_index; + layers.push(layer); + } + continue; + } + // Note: `copybackground` objects still carry a real image/model — + // the reference engine ignores the flag and draws them normally. + let loaded = match obj.image.as_deref() { + None => { + if obj.effects.is_empty() { + continue; + } + LoadedImage::single(placeholder_for(obj)) + } + Some(path) if is_solid_layer_path(path) => { + // Scene-size expansion is for effect chains only — see + // fill_solid_layer_sizes. + if !obj.effects.is_empty() { + solid_indices.push(layers.len()); + } + LoadedImage::single(white_pixel()) + } + Some(path) => { + if let Some(mut layer) = backdrop_layer_from_object(obj, path) { + if layer.size[0] == 0.0 || layer.size[1] == 0.0 { + solid_indices.push(layers.len()); + } + layer.order_index = obj_index; + layers.push(layer); + continue; + } + match load_texture_from_dir(dir, path) { + Ok(loaded) => loaded, + Err(e) => { + tracing::warn!(target: "scene", "texture load failed for '{path}': {e:#}"); + if obj.effects.is_empty() { + continue; + } + LoadedImage::single(placeholder_for(obj)) + } + } + } + }; + let mut layer = layer_from_object(obj, loaded, None); + layer.order_index = obj_index; + layers.push(layer); + } + + let (width, height) = guess_scene_dimensions(&scene, &layers); + fill_solid_layer_sizes(&mut layers, &solid_indices, width, height); + apply_parent_transforms( + &scene, + &mut layers, + &mut particle_layers, + &mut mesh3d_layers, + ); + Ok(Self { + width, + height, + layers, + particle_layers, + mesh3d_layers, + transform_graph: build_transform_graph(&scene), + scene, + }) + } + + /// Load from a PKG archive, falling back to the directory for loose files. + fn from_package_with_dir(pkg: &Package, scene_json: &str, dir: &Path) -> Result { + let scene = parse_scene_with_properties(scene_json, Some(dir))?; + + let mut layers = Vec::new(); + let mut solid_indices = Vec::new(); + let mut particle_layers = Vec::new(); + let mut mesh3d_layers = Vec::new(); + // Effective visibility (own AND every ancestor) — see Scene::visibility_mask. + let visible = scene.visibility_mask(); + // Raw `scene.objects` index (not a visible-only count): effect + // instances record the same raw index, so layers and effects agree + // on object identity no matter what gets skipped in between. + for (obj_index, obj) in scene.objects.iter().enumerate() { + if !visible[obj_index] { + continue; + } + if obj.light.is_some() && obj.image.is_none() { + if let Some(mut layer) = light_layer_from_object(obj) { + layer.order_index = obj_index; + layers.push(layer); + } + continue; + } + if obj.mesh3d_path().is_some() { + if let Some(mut ml) = mesh3d_layer_from_object(obj, Some(dir), Some(pkg)) { + ml.order_index = obj_index; + mesh3d_layers.push(ml); + } + continue; + } + if obj.particle.is_some() { + if let Some(mut pl) = particle_layer_from_object(obj, Some(dir), Some(pkg)) { + pl.order_index = obj_index; + particle_layers.push(pl); + } + continue; + } + if obj.text.is_some() { + if let Some(mut layer) = text_layer_from_object(obj, Some(dir), Some(pkg)) { + layer.order_index = obj_index; + layers.push(layer); + } + continue; + } + let loaded = match obj.image.as_deref() { + None => { + if obj.effects.is_empty() { + continue; + } + LoadedImage::single(placeholder_for(obj)) + } + Some(path) if is_solid_layer_path(path) => { + // Scene-size expansion is for effect chains only — see + // fill_solid_layer_sizes. + if !obj.effects.is_empty() { + solid_indices.push(layers.len()); + } + LoadedImage::single(white_pixel()) + } + Some(path) => { + if let Some(mut layer) = backdrop_layer_from_object(obj, path) { + if layer.size[0] == 0.0 || layer.size[1] == 0.0 { + solid_indices.push(layers.len()); + } + layer.order_index = obj_index; + layers.push(layer); + continue; + } + match load_texture_from_pkg(pkg, path).or_else(|pkg_err| { + load_texture_from_dir(dir, path) + .map_err(|dir_err| anyhow::anyhow!("pkg: {pkg_err}; dir: {dir_err}")) + }) { + Ok(loaded) => loaded, + Err(e) => { + tracing::warn!(target: "scene", "texture load failed for '{path}': {e:#}"); + if obj.effects.is_empty() { + continue; + } + LoadedImage::single(placeholder_for(obj)) + } + } + } + }; + let mut layer = layer_from_object(obj, loaded, None); + layer.order_index = obj_index; + layers.push(layer); + } + + let (width, height) = guess_scene_dimensions(&scene, &layers); + fill_solid_layer_sizes(&mut layers, &solid_indices, width, height); + apply_parent_transforms( + &scene, + &mut layers, + &mut particle_layers, + &mut mesh3d_layers, + ); + Ok(Self { + width, + height, + layers, + particle_layers, + mesh3d_layers, + transform_graph: build_transform_graph(&scene), + scene, + }) + } + + /// Load a scene wallpaper from a PKG archive + scene.json string. + /// No project.json is available here, so user properties keep defaults. + pub fn from_package(pkg: &Package, scene_json: &str) -> Result { + let scene = parse_scene_with_properties(scene_json, None)?; + + let mut layers = Vec::new(); + let mut solid_indices = Vec::new(); + let mut particle_layers = Vec::new(); + let mut mesh3d_layers = Vec::new(); + // Effective visibility (own AND every ancestor) — see Scene::visibility_mask. + let visible = scene.visibility_mask(); + // Raw `scene.objects` index (not a visible-only count): effect + // instances record the same raw index, so layers and effects agree + // on object identity no matter what gets skipped in between. + for (obj_index, obj) in scene.objects.iter().enumerate() { + if !visible[obj_index] { + continue; + } + if obj.light.is_some() && obj.image.is_none() { + if let Some(mut layer) = light_layer_from_object(obj) { + layer.order_index = obj_index; + layers.push(layer); + } + continue; + } + if obj.mesh3d_path().is_some() { + if let Some(mut ml) = mesh3d_layer_from_object(obj, None, Some(pkg)) { + ml.order_index = obj_index; + mesh3d_layers.push(ml); + } + continue; + } + if obj.particle.is_some() { + if let Some(mut pl) = particle_layer_from_object(obj, None, Some(pkg)) { + pl.order_index = obj_index; + particle_layers.push(pl); + } + continue; + } + if obj.text.is_some() { + if let Some(mut layer) = text_layer_from_object(obj, None, Some(pkg)) { + layer.order_index = obj_index; + layers.push(layer); + } + continue; + } + let loaded = match obj.image.as_deref() { + None => { + if obj.effects.is_empty() { + continue; + } + LoadedImage::single(placeholder_for(obj)) + } + Some(path) if is_solid_layer_path(path) => { + // Scene-size expansion is for effect chains only — see + // fill_solid_layer_sizes. + if !obj.effects.is_empty() { + solid_indices.push(layers.len()); + } + LoadedImage::single(white_pixel()) + } + Some(path) => { + if let Some(mut layer) = backdrop_layer_from_object(obj, path) { + if layer.size[0] == 0.0 || layer.size[1] == 0.0 { + solid_indices.push(layers.len()); + } + layer.order_index = obj_index; + layers.push(layer); + continue; + } + match load_texture_from_pkg(pkg, path) { + Ok(loaded) => loaded, + Err(_) => { + if obj.effects.is_empty() { + continue; + } + LoadedImage::single(placeholder_for(obj)) + } + } + } + }; + let mut layer = layer_from_object(obj, loaded, None); + layer.order_index = obj_index; + layers.push(layer); + } + + let (width, height) = guess_scene_dimensions(&scene, &layers); + fill_solid_layer_sizes(&mut layers, &solid_indices, width, height); + apply_parent_transforms( + &scene, + &mut layers, + &mut particle_layers, + &mut mesh3d_layers, + ); + Ok(Self { + width, + height, + layers, + particle_layers, + mesh3d_layers, + transform_graph: build_transform_graph(&scene), + scene, + }) + } +} + +impl Drop for VideoLayerStream { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +// --------------------------------------------------------------------------- +// Parent-child transform composition (full TRS) +// +// In Wallpaper Engine a scene is a tree: an object's `origin`/`angles`/`scale` +// are expressed in its PARENT's frame, not screen space. `layer_from_object` +// bakes only the object's OWN local transform into each Layer, so anything with +// a parent lands at its local offset instead of its true world position (the +// clock/particles-in-the-corner bug). This pass walks each object's `parent` +// chain and pre-multiplies the parent's world transform onto the already-baked +// local layer. +// +// Position is exact TRS (parent scale → parent rotation → parent translation +// applied to the child point). Orientation and scale compose as Euler-sum and +// component-wise product respectively: exact for the flat z-rotation these 2D +// scenes use and for a single parent level, an approximation only under nested, +// non-coaxial 3D rotation (rare; the perspective path already treats layer +// angles approximately). +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests; diff --git a/src/engine/model/resolve/tests.rs b/src/engine/model/resolve/tests.rs new file mode 100644 index 0000000..b6da099 --- /dev/null +++ b/src/engine/model/resolve/tests.rs @@ -0,0 +1,345 @@ +use super::*; + +#[test] +fn compose_and_fullscreen_objects_become_backdrop_layers() { + let objs: Vec = serde_json::from_str( + r#"[{"image": "models/util/composelayer.json", "size": "200 100"}, + {"image": "models/util/fullscreenlayer.json", "size": "200 100"}, + {"image": "models/util/projectlayer.json"}, + {"image": "models/other.json"}]"#, + ) + .unwrap(); + let a = backdrop_layer_from_object(&objs[0], objs[0].image.as_deref().unwrap()).unwrap(); + assert_eq!(a.backdrop, Some(Backdrop::Region)); + assert_eq!([a.size[0], a.size[1]], [200.0, 100.0]); + let b = backdrop_layer_from_object(&objs[1], objs[1].image.as_deref().unwrap()).unwrap(); + assert_eq!(b.backdrop, Some(Backdrop::FullScene)); + assert_eq!( + [b.size[0], b.size[1]], + [0.0, 0.0], + "sized to the scene later" + ); + assert!(backdrop_layer_from_object(&objs[2], "models/util/projectlayer.json").is_some()); + assert!(backdrop_layer_from_object(&objs[3], "models/other.json").is_none()); +} + +/// Untextured mesh materials are common in real content +/// (`"textures": [null,null,null]`), and their flat `color` is the only +/// thing that keeps the mesh from drawing plain white. +/// `maxwidth`/`maxrows` are only active when their `limit*` checkbox is +/// on. WE keeps the numbers around while the box is unchecked, so applying +/// them unconditionally wrapped text that should run free — 3091474852's +/// day label wrapped at 500px despite `limitwidth: false`. +/// `disablepropagation` severs transform inheritance while leaving the +/// `parent` link intact for grouping/visibility. +#[test] +fn disablepropagation_severs_transform_inheritance() { + let objs: Vec = serde_json::from_str( + r#"[{"id": 1, "origin": "10 0 0"}, + {"id": 2, "origin": "1 0 0", "parent": 1}, + {"id": 3, "origin": "1 0 0", "parent": 1, "disablepropagation": true}]"#, + ) + .expect("valid objects"); + let id_to_idx: HashMap = objs + .iter() + .enumerate() + .filter_map(|(i, o)| o.id.map(|d| (d, i))) + .collect(); + let name_to_idx: HashMap<&str, usize> = HashMap::new(); + assert_eq!(parent_index(&objs[1], &id_to_idx, &name_to_idx), Some(0)); + assert_eq!( + parent_index(&objs[2], &id_to_idx, &name_to_idx), + None, + "disablepropagation must stop transform inheritance" + ); +} + +/// The object-attachment API's `thisLayer.disablepropagation()` (see +/// `engine::sim::script::OBJECT_ATTACHMENT_API_JS`) is this same +/// `disablepropagation` rule, just evaluated live per-frame instead of +/// baked once from scene.json — `TransformGraph::world`'s `disabled` +/// slice must sever object `i`'s *own* inheritance from its parent +/// (matching `parent_index` above exactly), not affect what `i`'s +/// children inherit from `i`. +#[test] +fn live_disablepropagation_severs_only_the_flagged_objects_own_inheritance() { + // 0 (root, moves) -> 1 (child of 0) -> 2 (grandchild, child of 1) + let graph = TransformGraph { + parent: vec![None, Some(0), Some(1)], + local: vec![Xform::IDENTITY; 3], + name: vec![None, None, None], + id: vec![None, None, None], + }; + let locals = vec![ + Xform { + t: [10.0, 0.0, 0.0], + r: [0.0; 3], + s: [1.0; 3], + }, + Xform { + t: [1.0, 0.0, 0.0], + r: [0.0; 3], + s: [1.0; 3], + }, + Xform { + t: [1.0, 0.0, 0.0], + r: [0.0; 3], + s: [1.0; 3], + }, + ]; + + let worlds = graph.world(&locals, &[false, false, false]); + assert_eq!( + worlds[2].t, + [12.0, 0.0, 0.0], + "baseline: full chain composes" + ); + + // Sever object 1's own inheritance from object 0. + let worlds = graph.world(&locals, &[false, true, false]); + assert_eq!(worlds[0].t, [10.0, 0.0, 0.0], "object 0 is untouched"); + assert_eq!( + worlds[1].t, + [1.0, 0.0, 0.0], + "object 1 must stop inheriting object 0's transform" + ); + assert_eq!( + worlds[2].t, + [2.0, 0.0, 0.0], + "object 2 still inherits normally from (now-severed) object 1" + ); +} + +/// `depthtest: "disabled"` on the object overrides the material pass. +#[test] +fn object_depthtest_overrides_material() { + let read = |o: &SceneObject, mat: Option<&str>| -> bool { + o.depthtest + .as_ref() + .and_then(|v| v.as_str()) + .or(mat) + .map(|m| m != "disabled") + .unwrap_or(true) + }; + let plain: SceneObject = serde_json::from_str("{}").expect("valid"); + let off: SceneObject = serde_json::from_str(r#"{"depthtest": "disabled"}"#).expect("valid"); + assert!(read(&plain, None), "default is depth-tested"); + assert!(!read(&plain, Some("disabled")), "material pass applies"); + assert!(!read(&off, Some("enabled")), "object wins over material"); +} + +#[test] +fn wrap_limits_respect_their_gates() { + let obj = |lw: bool| -> SceneObject { + serde_json::from_value(serde_json::json!({ + "text": "SUNDAY", "maxwidth": 500.0, "maxrows": 1, + "limitwidth": lw, "limitrows": lw, + })) + .expect("valid object") + }; + let active = |o: &SceneObject| { + o.maxwidth + .as_ref() + .and_then(crate::engine::model::scene::parse_value_f32) + .filter(|w| *w > 0.0) + .filter(|_| { + o.limitwidth + .as_ref() + .and_then(crate::engine::model::scene::parse_value_bool) + .unwrap_or(false) + }) + .is_some() + }; + assert!(active(&obj(true)), "gate on: maxwidth applies"); + assert!(!active(&obj(false)), "gate off: maxwidth must be ignored"); +} + +#[test] +fn material_constant_color_reads_flat_color() { + // `color` wins over the separate (usually white) `Color` tint. + let m: serde_json::Value = serde_json::from_str( + r#"{"textures":[null],"constantshadervalues": + {"Color":"1.0 1.0 1.0","color":"0.6 0.0 0.2"}}"#, + ) + .expect("valid json"); + assert_eq!(pass_constant_color(&m), Some([153, 0, 51])); + assert_eq!(pass_constant_color(&serde_json::json!({})), None); +} + +fn solid_particle_config(color: &str) -> particle::ParticleConfig { + let json = format!( + r#"{{ + "maxcount": 2, + "emitter": [{{"name":"box","rate":1000,"distancemin":"0 0 0","distancemax":"0 0 0"}}], + "initializer": [ + {{"id":1,"name":"lifetimerandom","min":100,"max":100}}, + {{"id":2,"name":"sizerandom","min":300,"max":300}}, + {{"id":3,"name":"velocityrandom","min":"0 0 0","max":"0 0 0"}}, + {{"id":4,"name":"colorrandom","min":"{color}","max":"{color}"}}, + {{"id":5,"name":"alpharandom","min":1,"max":1}} + ] + }}"# + ); + serde_json::from_str(&json).expect("should parse") +} + +/// Particles should interleave with image layers by `order_index` in +/// true scene z-order, not always draw on top of every image — build a +/// scene with [particle(order 0, blue), image(order 1, opaque red), +/// particle(order 2, green)] and confirm the final canvas shows the +/// blue particle fully hidden under the opaque red layer, with the green +/// particle visible on top of it. +#[test] +fn particles_and_images_interleave_by_order_index() { + let scene: Scene = serde_json::from_str("{}").unwrap(); + let red_image = RgbaImage::from_pixel(100, 100, image::Rgba([255, 0, 0, 255])); + + let layers = vec![Layer { + backdrop: None, + local_origin: [0.0; 3], + local_scale: [1.0; 3], + local_angles: [0.0; 3], + name: "red".to_string(), + image: red_image, + extra_frames: Vec::new(), + frame_duration_ms: 0, + origin: [50.0, 50.0, 0.0], + size: [100.0, 100.0, 0.0], + scale: [1.0, 1.0, 1.0], + parallax_depth: [0.0, 0.0], + angle: 0.0, + angles: [0.0, 0.0, 0.0], + blend_mode: 0, + alpha: 1.0, + color: [1.0, 1.0, 1.0], + brightness: 1.0, + copybackground: false, + no_interpolation: true, + clamp_uvs: false, + puppet: None, + video: None, + text_dynamic: None, + order_index: 1, + object_id: None, + visible_base: true, + }]; + + let particle_layers = vec![ + ParticleLayer { + local_origin: [0.0; 3], + name: "blue_particles".to_string(), + origin: [50.0, 50.0, 0.0], + parallax_depth: [0.0, 0.0], + config: solid_particle_config("0 0 255"), + overrides: None, + sprite_texture: None, + additive_blend: false, + order_index: 0, + children: Vec::new(), + }, + ParticleLayer { + local_origin: [0.0; 3], + name: "green_particles".to_string(), + origin: [50.0, 50.0, 0.0], + parallax_depth: [0.0, 0.0], + config: solid_particle_config("0 255 0"), + overrides: None, + sprite_texture: None, + additive_blend: false, + order_index: 2, + children: Vec::new(), + }, + ]; + + let resolved = ResolvedScene { + transform_graph: build_transform_graph(&scene), + width: 100, + height: 100, + layers, + particle_layers, + mesh3d_layers: Vec::new(), + scene, + }; + + let canvas = resolved.render(); + let px = canvas.get_pixel(50, 50); + assert!( + px[2] < 20, + "blue particle (order 0) should be fully hidden under the opaque red layer (order 1), got {px:?}" + ); + assert!( + px[1] > px[0], + "green particle (order 2) should be visible on top of the red layer, got {px:?}" + ); +} + +#[test] +fn parallax_depth_number_broadcasts_to_both_axes() { + let v = serde_json::json!(0.5); + assert_eq!(parse_parallax_depth(&v), [0.5, 0.5]); +} + +#[test] +fn parallax_depth_vec2_string_keeps_axes_independent() { + let v = serde_json::json!("0.5 0.0"); + assert_eq!(parse_parallax_depth(&v), [0.5, 0.0]); +} + +#[test] +fn parallax_depth_user_wrapped_value_unwraps() { + let v = serde_json::json!({"user": "ui_editor_properties_depth", "value": "0.25 0.75"}); + assert_eq!(parse_parallax_depth(&v), [0.25, 0.75]); +} + +#[test] +fn alignment_none_produces_no_offset() { + assert_eq!(alignment_offset(None, [100.0, 200.0]), [0.0, 0.0]); +} + +#[test] +fn alignment_top_shifts_down_by_half_height() { + assert_eq!(alignment_offset(Some("top"), [100.0, 200.0]), [0.0, -100.0]); +} + +#[test] +fn alignment_bottom_shifts_up_by_half_height() { + assert_eq!( + alignment_offset(Some("bottom"), [100.0, 200.0]), + [0.0, 100.0] + ); +} + +#[test] +fn alignment_left_shifts_right_by_half_width() { + assert_eq!(alignment_offset(Some("left"), [100.0, 200.0]), [50.0, 0.0]); +} + +#[test] +fn alignment_right_shifts_left_by_half_width() { + assert_eq!( + alignment_offset(Some("right"), [100.0, 200.0]), + [-50.0, 0.0] + ); +} + +#[test] +fn alignment_combines_vertical_and_horizontal() { + assert_eq!( + alignment_offset(Some("top left"), [100.0, 200.0]), + [50.0, -100.0] + ); +} + +#[test] +fn alignment_top_and_bottom_together_only_honors_top() { + // Matches the reference's if/else-if: mutually exclusive per axis. + assert_eq!( + alignment_offset(Some("top bottom"), [100.0, 200.0]), + [0.0, -100.0] + ); +} + +#[test] +fn alignment_is_case_insensitive() { + assert_eq!(alignment_offset(Some("TOP"), [100.0, 200.0]), [0.0, -100.0]); +} diff --git a/src/engine/model/resolve/textures.rs b/src/engine/model/resolve/textures.rs new file mode 100644 index 0000000..40eda40 --- /dev/null +++ b/src/engine/model/resolve/textures.rs @@ -0,0 +1,560 @@ +//! Loading what layers show: textures from the wallpaper folder, its +//! scene.pkg or Wallpaper Engine's shared assets, following model, material +//! and texture chains, and embedded-video textures streamed while they play. + +use super::*; + +/// A loaded texture plus any additional animation frames (e.g. from a `.tex` +/// TEXS spritesheet table or a multi-image container). +pub(super) struct LoadedImage { + pub(super) image: RgbaImage, + pub(super) extra_frames: Vec, + pub(super) frame_duration_ms: u32, + pub(super) no_interpolation: bool, + pub(super) clamp_uvs: bool, + /// Present when the model is an animated puppet (mesh + skeleton + + /// MDLA animations): `image` holds the rest pose, and the live GPU + /// path re-poses/re-rasterizes from this over time. + pub(super) puppet: Option>, + /// Present for embedded-video textures: `image` holds the first frame, + /// and the live GPU path re-uploads frames from this stream over time. + pub(super) video: Option>>, + /// Set when the model's material declares the `SPRITESHEET` combo and the + /// `.tex` carries a frame table (e.g. a 2-cell AM/PM atlas): the per-frame + /// sub-rect images. `layer_from_object` picks the single frame the object's + /// frame script selects and uses it as the layer image. Without this the + /// flattened atlas stretches into the one-cell quad (all cells overlaid). + pub(super) sprite_frames: Vec, +} + +impl LoadedImage { + pub(super) fn single(image: RgbaImage) -> Self { + Self { + image, + extra_frames: Vec::new(), + frame_duration_ms: 0, + no_interpolation: false, + clamp_uvs: false, + puppet: None, + video: None, + sprite_frames: Vec::new(), + } + } + + /// Build from a parsed `.tex`, pulling out extra animation frames if any + /// and carrying over its `NoInterpolation`/`ClampUVs` sampler flags. + pub(super) fn from_tex(tex: &TexFile) -> Result { + let no_interpolation = tex.no_interpolation(); + let clamp_uvs = tex.clamp_uvs(); + if tex.is_animated() { + if let Ok(mut frames) = tex.to_rgba_frames() { + if frames.len() > 1 { + let image = frames.remove(0); + let frame_duration_ms = tex + .frames() + .first() + .map(|f| ((f.frametime * 1000.0) as u32).max(1)) + .unwrap_or(100); + return Ok(Self { + image, + extra_frames: frames, + frame_duration_ms, + no_interpolation, + clamp_uvs, + puppet: None, + video: None, + sprite_frames: Vec::new(), + }); + } + } + } + if let Some(bytes) = tex.video_bytes() { + return video_tex_to_loaded(tex, bytes); + } + Ok(Self { + no_interpolation, + clamp_uvs, + ..Self::single(tex.to_rgba()?) + }) + } +} + +/// Scripts copied from a Workshop asset pack name assets relative to that +/// pack (`models/bar.json`), which ships at `models/workshop//bar.json`. +/// Returns `path` itself when it exists or no such copy is found. +pub(super) fn resolve_workshop_asset(dir: &Path, pkg: Option<&Package>, path: &str) -> String { + let exists = |p: &str| pkg.is_some_and(|k| k.contains(p)) || dir.join(p).is_file(); + if exists(path) { + return path.to_string(); + } + let Some((first, rest)) = path.split_once('/') else { + return path.to_string(); + }; + let prefix = format!("{first}/workshop/"); + let suffix = format!("/{rest}"); + let matches = |p: &str| { + p.strip_prefix(&prefix) + .and_then(|r| r.strip_suffix(&suffix)) + .is_some_and(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit())) + }; + if let Some(found) = pkg.and_then(|k| k.paths().find(|p| matches(p)).map(str::to_string)) { + return found; + } + if let Ok(entries) = std::fs::read_dir(dir.join(first).join("workshop")) { + for e in entries.flatten() { + let candidate = format!("{prefix}{}{suffix}", e.file_name().to_string_lossy()); + if matches(&candidate) && dir.join(&candidate).is_file() { + return candidate; + } + } + } + path.to_string() +} + +pub(super) fn is_video_path(p: &str) -> bool { + let p = p.to_lowercase(); + p.ends_with(".mp4") || p.ends_with(".webm") || p.ends_with(".mkv") || p.ends_with(".avi") +} + +/// Resolve a particle preset's JSON text, checking the wallpaper's own +/// directory, then its `scene.pkg` (if given), then the global Steam assets +/// dir — mirroring the same wallpaper-first priority used for shaders/effects +/// (`shaders::resolver::AssetResolver`). +/// Read a scene asset from wherever it lives: loose files, the pkg archive, +/// then the shared WE assets dir. +pub(super) fn read_asset_bytes( + dir: Option<&Path>, + pkg: Option<&Package>, + rel_path: &str, +) -> Option> { + if let Some(dir) = dir { + if let Ok(data) = std::fs::read(dir.join(rel_path)) { + return Some(data); + } + } + if let Some(pkg) = pkg { + if let Some(data) = pkg.get(rel_path) { + return Some(data.to_vec()); + } + } + read_from_global_assets(rel_path) +} + +/// A live decode stream for an embedded-video texture layer: a detached +/// ffmpeg thread loops the video (PTS-paced, same `video_decode_loop` the +/// full-video wallpaper path uses) and feeds frames through a small bounded +/// channel. The consumer drains to the newest frame each render tick, so +/// slow consumers drop frames instead of lagging. The thread exits on its +/// own once this struct (the receiver) is dropped, and the temp file +/// backing the looping decoder is removed then too. +pub struct VideoLayerStream { + pub(super) rx: std::sync::mpsc::Receiver>, + /// Temp file the decoder re-opens on every loop — must outlive playback. + pub(super) path: std::path::PathBuf, +} + +impl VideoLayerStream { + /// Newest decoded frame since the last call, if any arrived. + pub fn latest_frame(&self) -> Option> { + let mut latest = None; + while let Ok(frame) = self.rx.try_recv() { + latest = Some(frame); + } + latest + } +} + +/// Start looping playback for an embedded video payload: writes the bytes +/// to a temp file, decodes the first frame synchronously (so the layer has +/// correct content immediately), and spawns the paced decode loop for the +/// rest. +pub(super) fn start_video_stream(bytes: &[u8]) -> Result<(RgbaImage, VideoLayerStream)> { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + let tmp = std::env::temp_dir().join(format!( + "we_embvid_{}_{}.mp4", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::write(&tmp, bytes) + .with_context(|| format!("writing embedded video temp file: {}", tmp.display()))?; + let first = match crate::content::video::decode_first_frame(&tmp) { + Ok(img) => img, + Err(e) => { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + }; + let (tx, rx) = std::sync::mpsc::sync_channel::>(2); + let decode_path = tmp.clone(); + std::thread::spawn(move || { + // Exits when the receiver (VideoLayerStream) is dropped. + if let Err(e) = crate::content::video::video_decode_loop(&decode_path, &tx) { + eprintln!( + "embedded video decoder error for '{}': {e}", + decode_path.display() + ); + } + }); + Ok((first, VideoLayerStream { rx, path: tmp })) +} + +/// Build a `LoadedImage` for an embedded-video texture: first frame as the +/// image, plus the live stream for the GPU path to animate. +pub(super) fn video_tex_to_loaded(tex: &TexFile, bytes: &[u8]) -> Result { + let (first, stream) = start_video_stream(bytes)?; + let mut loaded = LoadedImage::single(first); + loaded.no_interpolation = tex.no_interpolation(); + loaded.clamp_uvs = tex.clamp_uvs(); + loaded.video = Some(std::sync::Arc::new(std::sync::Mutex::new(stream))); + Ok(loaded) +} + +pub(super) fn load_texture_from_dir(dir: &Path, image_path: &str) -> Result { + // If it's a .json reference, resolve the model/material chain + if image_path.ends_with(".json") { + return resolve_model_chain_dir(dir, image_path); + } + + let full_path = dir.join(image_path); + + // Video file — extract first frame as static thumbnail + if is_video_path(image_path) && full_path.exists() { + return crate::content::video::decode_first_frame(&full_path).map(LoadedImage::single); + } + + // Try as .tex first + let tex_path = full_path.with_extension("tex"); + if tex_path.exists() { + let data = + std::fs::read(&tex_path).with_context(|| format!("reading {}", tex_path.display()))?; + let tex = + TexFile::parse(&data).with_context(|| format!("parsing {}", tex_path.display()))?; + return LoadedImage::from_tex(&tex); + } + + if full_path.exists() { + return image::open(&full_path) + .map(|i| LoadedImage::single(i.into_rgba8())) + .with_context(|| format!("loading {}", full_path.display())); + } + + let tex_path_str = format!("{}.tex", full_path.display()); + let tex_appended = Path::new(&tex_path_str); + if tex_appended.exists() { + let data = std::fs::read(tex_appended) + .with_context(|| format!("reading {}", tex_appended.display()))?; + let tex = TexFile::parse(&data)?; + return LoadedImage::from_tex(&tex); + } + + anyhow::bail!("texture not found: {image_path}") +} + +pub(super) fn load_texture_from_pkg(pkg: &Package, image_path: &str) -> Result { + // If the image path points to a .json model/material, resolve the chain. + if image_path.ends_with(".json") { + return resolve_model_chain_pkg(pkg, image_path); + } + + // Try as .tex + let tex_name = if image_path.ends_with(".tex") { + image_path.to_string() + } else { + format!("{image_path}.tex") + }; + + if let Some(data) = pkg.get(&tex_name) { + let tex = + TexFile::parse(data).with_context(|| format!("parsing .tex from pkg: {tex_name}"))?; + return LoadedImage::from_tex(&tex); + } + + // Try original path (png/jpg or video embedded in pkg) + if let Some(data) = pkg.get(image_path) { + if is_video_path(image_path) { + let ext = std::path::Path::new(image_path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("mp4"); + let tmp = std::env::temp_dir().join(format!( + "we_vidtex_{}.{ext}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0) + )); + std::fs::write(&tmp, data) + .with_context(|| format!("writing video temp file: {}", tmp.display()))?; + let result = crate::content::video::decode_first_frame(&tmp); + let _ = std::fs::remove_file(&tmp); + return result.map(LoadedImage::single); + } + return image::load_from_memory(data) + .map(|i| LoadedImage::single(i.into_rgba8())) + .with_context(|| format!("loading image from pkg: {image_path}")); + } + + anyhow::bail!("texture not found in package: {image_path}") +} + +/// Resolve a bare texture-reference path (same rules as any other material +/// texture reference — loose file under `dir`, `.tex` fallback, then `pkg`) +/// to a decoded image, or `None` if it doesn't resolve. Used by +/// `GpuSceneInstance::build` (gpu::renderer) to load a spot light's +/// `cookie` texture without duplicating `load_texture_from_dir`/ +/// `load_texture_from_pkg`'s resolution logic. +pub(crate) fn resolve_static_texture( + dir: Option<&Path>, + pkg: Option<&Package>, + path: &str, +) -> Option { + dir.and_then(|d| load_texture_from_dir(d, path).ok()) + .or_else(|| pkg.and_then(|p| load_texture_from_pkg(p, path).ok())) + .map(|l| l.image) +} + +/// Follow the model -> material -> texture reference chain in a PKG archive. +/// Shared global WE assets install, used as a fallback when a model/material/ +/// texture isn't bundled with this specific wallpaper. Built-in particle +/// presets (e.g. "fog1") ship their own `particles/presets/*.json` per +/// wallpaper but rely on the shared `materials/presets/*.json` + `.tex` +/// living only in the global assets install, never copied into the +/// wallpaper's own directory/pkg — see [[wp_engine_project]] memory. +pub(super) fn read_from_global_assets(rel_path: &str) -> Option> { + let dir = crate::engine::gpu::shaders::loader::find_we_assets_dir()?; + std::fs::read(dir.join(rel_path)).ok() +} + +/// Returns the resolved texture plus the material pass's own `"blending"` +/// string (e.g. `"additive"`) and `ui_editor_properties_overbright` +/// brightness constant (default 1.0) — particles need both to composite +/// correctly (see `resolve_particle_sprite_{pkg,dir}`); plain image layers +/// just discard them. +pub(super) fn find_model_chain_tex_pkg( + pkg: &Package, + json_path: &str, +) -> Result<(TexFile, Option, f32)> { + let data = pkg + .get(json_path) + .map(|d| d.to_vec()) + .or_else(|| read_from_global_assets(json_path)) + .with_context(|| { + format!("model/material not found in pkg or global assets: {json_path}") + })?; + let val: serde_json::Value = + serde_json::from_slice(&data).with_context(|| format!("parsing {json_path}"))?; + + if let Some(mat_path) = val.get("material").and_then(|v| v.as_str()) { + return find_model_chain_tex_pkg(pkg, mat_path); + } + + if let Some(passes) = val.get("passes").and_then(|v| v.as_array()) { + for pass in passes { + let blending = pass + .get("blending") + .and_then(|v| v.as_str()) + .map(str::to_string); + let overbright = pass_overbright(pass); + if let Some(textures) = pass.get("textures").and_then(|v| v.as_array()) { + for tex_ref in textures { + if let Some(tex_name) = tex_ref.as_str() { + let tex_path = format!("materials/{tex_name}.tex"); + if let Some(tex_data) = pkg + .get(&tex_path) + .map(|d| d.to_vec()) + .or_else(|| read_from_global_assets(&tex_path)) + { + let tex = TexFile::parse(&tex_data) + .with_context(|| format!("parsing {tex_path}"))?; + return Ok((tex, blending, overbright)); + } + + let alt_path = format!("{tex_name}.tex"); + if let Some(tex_data) = pkg + .get(&alt_path) + .map(|d| d.to_vec()) + .or_else(|| read_from_global_assets(&alt_path)) + { + return Ok((TexFile::parse(&tex_data)?, blending, overbright)); + } + } + } + } + } + } + + anyhow::bail!("could not resolve texture from {json_path}") +} + +/// `ui_editor_properties_overbright` from a material pass's +/// `constantshadervalues` (CParticle reads it off the first pass; 1.0 when +/// absent). +pub(super) fn pass_overbright(pass: &serde_json::Value) -> f32 { + pass.get("constantshadervalues") + .and_then(|c| c.get("ui_editor_properties_overbright")) + .and_then(|v| v.as_f64()) + .map(|v| v as f32) + .unwrap_or(1.0) +} + +/// A mesh material's first pass. Meshes need two things out of it that the +/// texture chain doesn't carry: the flat `color` constant and `cullmode`. +pub(super) fn material_first_pass( + dir: Option<&Path>, + pkg: Option<&Package>, + json_path: &str, +) -> Option { + let data = read_asset_bytes(dir, pkg, json_path) + .or_else(|| read_asset_bytes(dir, pkg, &json_path.to_lowercase()))?; + let val: serde_json::Value = serde_json::from_slice(&data).ok()?; + val.get("passes")?.as_array()?.first().cloned() +} + +/// A material's flat `color` constant, as 8-bit RGB. Only meaningful for +/// materials with no texture at all — see `mesh3d_layer_from_object`. +pub(super) fn pass_constant_color(pass: &serde_json::Value) -> Option<[u8; 3]> { + let consts = pass.get("constantshadervalues")?; + // WE keys this as `color`; `Color` is a separate (usually white) tint. + let s = consts + .get("color") + .or_else(|| consts.get("Color"))? + .as_str()?; + let c = crate::engine::cpu::effect::parse_color(s); + Some([ + (c[0].clamp(0.0, 1.0) * 255.0) as u8, + (c[1].clamp(0.0, 1.0) * 255.0) as u8, + (c[2].clamp(0.0, 1.0) * 255.0) as u8, + ]) +} + +/// Follow the model -> material -> texture chain for loose files on disk. +pub(super) fn find_model_chain_tex_dir( + dir: &Path, + json_path: &str, +) -> Result<(TexFile, Option, f32)> { + let data = std::fs::read(dir.join(json_path)) + .ok() + .or_else(|| read_from_global_assets(json_path)) + .with_context(|| { + format!( + "model/material not found in {} or global assets: {json_path}", + dir.display() + ) + })?; + let val: serde_json::Value = + serde_json::from_slice(&data).with_context(|| format!("parsing {json_path}"))?; + + if let Some(mat_path) = val.get("material").and_then(|v| v.as_str()) { + return find_model_chain_tex_dir(dir, mat_path); + } + + if let Some(passes) = val.get("passes").and_then(|v| v.as_array()) { + for pass in passes { + let blending = pass + .get("blending") + .and_then(|v| v.as_str()) + .map(str::to_string); + let overbright = pass_overbright(pass); + if let Some(textures) = pass.get("textures").and_then(|v| v.as_array()) { + for tex_ref in textures { + if let Some(tex_name) = tex_ref.as_str() { + let tex_path = format!("materials/{tex_name}.tex"); + if let Some(tex_data) = std::fs::read(dir.join(&tex_path)) + .ok() + .or_else(|| read_from_global_assets(&tex_path)) + { + return Ok((TexFile::parse(&tex_data)?, blending, overbright)); + } + } + } + } + } + } + + anyhow::bail!("could not resolve texture from {json_path}") +} + +/// Walk model → material and report whether any pass declares the `SPRITESHEET` +/// combo (`image` is a multi-cell grid). `read` fetches a chain member's bytes. +pub(super) fn chain_has_spritesheet( + json_path: &str, + read: &dyn Fn(&str) -> Option>, +) -> bool { + let Some(data) = read(json_path) else { + return false; + }; + let Ok(val) = serde_json::from_slice::(&data) else { + return false; + }; + if let Some(mat) = val.get("material").and_then(|v| v.as_str()) { + return chain_has_spritesheet(mat, read); + } + val.get("passes") + .and_then(|p| p.as_array()) + .is_some_and(|passes| { + passes.iter().any(|pass| { + pass.get("combos") + .and_then(|c| c.as_object()) + // Materials spell it both "SPRITESHEET" and "spritesheet". + .and_then(|c| { + c.iter() + .find(|(k, _)| k.eq_ignore_ascii_case("spritesheet")) + .and_then(|(_, v)| v.as_i64()) + }) + .unwrap_or(0) + != 0 + }) + }) +} + +/// A model chain's texture, as a layer image. An animated `.tex` (TEXS frame +/// table + the IsGif flag) keeps its frames so the layer plays them — +/// otherwise the whole atlas gets squeezed into the one-cell quad, which is +/// how 2325500626's bonfire rendered as a 16-up contact sheet. +pub(super) fn loaded_from_chain_tex( + tex: &TexFile, + json_path: &str, + read: &dyn Fn(&str) -> Option>, +) -> Result { + if tex.is_animated() { + return LoadedImage::from_tex(tex); + } + Ok(apply_puppet_mesh(tex.to_rgba()?, json_path, read)) +} + +pub(super) fn resolve_model_chain_pkg(pkg: &Package, json_path: &str) -> Result { + let tex = find_model_chain_tex_pkg(pkg, json_path)?.0; + if let Some(bytes) = tex.video_bytes() { + // e.g. 2914504963's Taj Mahal backdrop: the model chain resolves to + // an embedded-video texture — stream it instead of puppet handling. + return video_tex_to_loaded(&tex, bytes); + } + let read = |rel: &str| { + pkg.get(rel) + .map(|d| d.to_vec()) + .or_else(|| read_from_global_assets(rel)) + }; + let mut loaded = loaded_from_chain_tex(&tex, json_path, &read)?; + if chain_has_spritesheet(json_path, &read) { + loaded.sprite_frames = tex.to_rgba_frames().unwrap_or_default(); + } + Ok(loaded) +} + +pub(super) fn resolve_model_chain_dir(dir: &Path, json_path: &str) -> Result { + let tex = find_model_chain_tex_dir(dir, json_path)?.0; + if let Some(bytes) = tex.video_bytes() { + return video_tex_to_loaded(&tex, bytes); + } + let read = |rel: &str| { + std::fs::read(dir.join(rel)) + .ok() + .or_else(|| read_from_global_assets(rel)) + }; + let mut loaded = loaded_from_chain_tex(&tex, json_path, &read)?; + if chain_has_spritesheet(json_path, &read) { + loaded.sprite_frames = tex.to_rgba_frames().unwrap_or_default(); + } + Ok(loaded) +} diff --git a/src/engine/model/resolve/transform.rs b/src/engine/model/resolve/transform.rs new file mode 100644 index 0000000..949dcee --- /dev/null +++ b/src/engine/model/resolve/transform.rs @@ -0,0 +1,322 @@ +//! Object transforms: each object's local origin, angles and scale, and the +//! parent chain that turns them into world transforms. + +use super::*; + +/// An affine transform in WE scene space (Y-up), kept as decomposed +/// translation / Euler-rotation (radians, un-negated) / scale. +#[derive(Clone, Copy)] +pub struct Xform { + pub t: [f64; 3], + pub r: [f64; 3], + pub s: [f64; 3], +} + +impl Xform { + pub const IDENTITY: Xform = Xform { + t: [0.0; 3], + r: [0.0; 3], + s: [1.0, 1.0, 1.0], + }; + + pub fn is_identity(&self) -> bool { + self.t == [0.0; 3] && self.r == [0.0; 3] && self.s == [1.0, 1.0, 1.0] + } + + /// Map a point living in a child frame into the frame this Xform is + /// expressed in: `p' = t + R · (s ⊙ p)`. + pub fn apply_point(&self, p: [f64; 3]) -> [f64; 3] { + let scaled = [p[0] * self.s[0], p[1] * self.s[1], p[2] * self.s[2]]; + let rot = rotate_euler_f64(scaled, self.r); + [rot[0] + self.t[0], rot[1] + self.t[1], rot[2] + self.t[2]] + } + + /// `world = self (parent world) ∘ local`. + pub fn compose(&self, local: &Xform) -> Xform { + Xform { + t: self.apply_point(local.t), + r: [ + self.r[0] + local.r[0], + self.r[1] + local.r[1], + self.r[2] + local.r[2], + ], + s: [ + self.s[0] * local.s[0], + self.s[1] * local.s[1], + self.s[2] * local.s[2], + ], + } + } +} + +/// f64 mirror of `camera3d::rotate_euler` (Rx then Ry then Rz) — scene coords +/// reach a few thousand pixels, so we keep the composition in f64. +pub(super) fn rotate_euler_f64(v: [f64; 3], a: [f64; 3]) -> [f64; 3] { + let (sx, cx) = a[0].sin_cos(); + let (sy, cy) = a[1].sin_cos(); + let (sz, cz) = a[2].sin_cos(); + let v = [v[0], cx * v[1] - sx * v[2], sx * v[1] + cx * v[2]]; + let v = [cy * v[0] + sy * v[2], v[1], -sy * v[0] + cy * v[2]]; + [cz * v[0] - sz * v[1], sz * v[0] + cz * v[1], v[2]] +} + +/// This object's own local transform, read from the raw scene fields (the same +/// sources `layer_from_object` uses, minus alignment which is a per-layer draw +/// concern). +pub fn obj_local_xform(obj: &SceneObject) -> Xform { + let t = obj.parsed_origin(); + let r = obj + .angles + .as_ref() + .map(crate::engine::model::json_to_animated) + .and_then(|v| v.as_vec3()) + .map(|a| [a[0] as f64, a[1] as f64, a[2] as f64]) + .unwrap_or([0.0; 3]); + let s = obj + .scale + .as_ref() + .and_then(crate::engine::model::scene::parse_value_vec3) + .unwrap_or([1.0, 1.0, 1.0]); + Xform { t, r, s } +} + +/// Resolve an object's `parent` reference to an index in `scene.objects`. +/// WE stores the parent's `id` (a number), occasionally a name string. +pub(super) fn parent_index( + obj: &SceneObject, + id_to_idx: &HashMap, + name_to_idx: &HashMap<&str, usize>, +) -> Option { + // `disablepropagation` severs the link: the object keeps its parent for + // grouping/visibility but stops inheriting its transform. Every occurrence + // in the 197 installed scenes is `false`, so this changes nothing today — + // it's here so authored content that does set it behaves correctly. + if obj + .disablepropagation + .as_ref() + .and_then(crate::engine::model::scene::parse_value_bool) + .unwrap_or(false) + { + return None; + } + let p = obj.parent.as_ref()?; + if let Some(id) = p.as_i64() { + return id_to_idx.get(&id).copied(); + } + if let Some(id) = p.as_u64() { + return id_to_idx.get(&(id as i64)).copied(); + } + if let Some(name) = p.as_str() { + return name_to_idx.get(name).copied(); + } + None +} + +/// World transform of object `idx`, including its own local transform, composed +/// up the parent chain. Memoized; guards against cycles / dangling parents. +pub(super) fn world_xform( + idx: usize, + objects: &[SceneObject], + id_to_idx: &HashMap, + name_to_idx: &HashMap<&str, usize>, + memo: &mut [Option], + depth: u32, +) -> Xform { + if let Some(x) = memo[idx] { + return x; + } + let local = obj_local_xform(&objects[idx]); + // depth guard: a malformed scene can loop or nest absurdly deep; fall back + // to the local transform rather than blow the stack (tolerant-parsing). + let world = match parent_index(&objects[idx], id_to_idx, name_to_idx) { + Some(p) if p != idx && depth < 64 => { + world_xform(p, objects, id_to_idx, name_to_idx, memo, depth + 1).compose(&local) + } + _ => local, + }; + memo[idx] = Some(world); + world +} + +/// Per-object transform data the live path needs to recompose the parent chain +/// every frame. +/// +/// `apply_parent_transforms` bakes the chain once at load, which is right for a +/// static parent but wrong for a scripted one: a parent node's transform can +/// change every frame, and those nodes are usually image-less (no `Layer` at +/// all), so their scripts never run from the layer list. This carries them. +pub struct TransformGraph { + /// Resolved parent index per object (`None` = root). + pub parent: Vec>, + /// Authored local transform per object — the base a script updates from. + pub local: Vec, + /// Object name/id, indexed the same as every other field here — lets the + /// live path find an object's sounds and layers by identity. + pub name: Vec>, + pub id: Vec>, +} + +impl TransformGraph { + /// World transform per object, composing `locals` up each parent chain. + /// `disabled[i]` (this frame's live `thisLayer.disablepropagation()` + /// call — see the object-attachment API) makes object `i` stop + /// inheriting *its own* parent's transform, exactly like the static + /// scene.json `disablepropagation: true` field already does via + /// `parent_index` returning `None` — see + /// `disablepropagation_severs_transform_inheritance` below. This is + /// that same rule evaluated live per-frame instead of once at load, for + /// an object whose `parent` link is real (baked into `self.parent`) but + /// wants to sever it dynamically. + pub fn world(&self, locals: &[Xform], disabled: &[bool]) -> Vec { + let mut memo: Vec> = vec![None; self.parent.len()]; + (0..self.parent.len()) + .map(|i| self.world_of(i, locals, disabled, &mut memo, 0)) + .collect() + } + + pub(super) fn world_of( + &self, + idx: usize, + locals: &[Xform], + disabled: &[bool], + memo: &mut Vec>, + depth: u32, + ) -> Xform { + if let Some(x) = memo[idx] { + return x; + } + let severed = disabled.get(idx).copied().unwrap_or(false); + // Same 64-deep guard as the load-time walker: a malformed scene must + // not blow the stack. + let w = match self.parent[idx] { + Some(p) if p != idx && depth < 64 && !severed => self + .world_of(p, locals, disabled, memo, depth + 1) + .compose(&locals[idx]), + _ => locals[idx], + }; + memo[idx] = Some(w); + w + } +} + +pub(super) fn build_transform_graph(scene: &Scene) -> TransformGraph { + let objects = &scene.objects; + let id_to_idx: HashMap = objects + .iter() + .enumerate() + .filter_map(|(i, o)| o.id.map(|id| (id, i))) + .collect(); + let name_to_idx: HashMap<&str, usize> = objects + .iter() + .enumerate() + .filter_map(|(i, o)| o.name.as_deref().map(|n| (n, i))) + .collect(); + TransformGraph { + parent: objects + .iter() + .map(|o| parent_index(o, &id_to_idx, &name_to_idx)) + .collect(), + local: objects.iter().map(obj_local_xform).collect(), + name: objects.iter().map(|o| o.name.clone()).collect(), + id: objects.iter().map(|o| o.id).collect(), + } +} + +/// Parent world transform for object `idx` (identity if it has no parent). This +/// is what gets applied to a layer, whose OWN local transform is already baked +/// in by `layer_from_object`. +pub(super) fn parent_world_xform( + idx: usize, + objects: &[SceneObject], + id_to_idx: &HashMap, + name_to_idx: &HashMap<&str, usize>, + memo: &mut [Option], +) -> Xform { + match parent_index(&objects[idx], id_to_idx, name_to_idx) { + Some(p) if p != idx => world_xform(p, objects, id_to_idx, name_to_idx, memo, 0), + _ => Xform::IDENTITY, + } +} + +/// Compose each layer's baked-in local transform with its parent chain so +/// parented objects (clocks, dates, ripples, particle emitters) render at their +/// true world position/orientation/scale instead of their local offset. +pub(super) fn apply_parent_transforms( + scene: &Scene, + layers: &mut [Layer], + particle_layers: &mut [ParticleLayer], + mesh3d_layers: &mut [Mesh3dLayer], +) { + let objects = &scene.objects; + if objects.is_empty() { + return; + } + let id_to_idx: HashMap = objects + .iter() + .enumerate() + .filter_map(|(i, o)| o.id.map(|id| (id, i))) + .collect(); + let name_to_idx: HashMap<&str, usize> = objects + .iter() + .enumerate() + .filter_map(|(i, o)| o.name.as_deref().map(|n| (n, i))) + .collect(); + let mut memo: Vec> = vec![None; objects.len()]; + + for layer in layers.iter_mut() { + let idx = layer.order_index; + if idx >= objects.len() { + continue; + } + let pw = parent_world_xform(idx, objects, &id_to_idx, &name_to_idx, &mut memo); + if pw.is_identity() { + continue; + } + layer.origin = pw.apply_point(layer.origin); + layer.scale = [ + layer.scale[0] * pw.s[0], + layer.scale[1] * pw.s[1], + layer.scale[2] * pw.s[2], + ]; + // `Layer::angle` is the negated z-roll (screen convention); the raw 3D + // `angles` are un-negated. + layer.angle -= pw.r[2] as f32; + layer.angles = [ + layer.angles[0] + pw.r[0] as f32, + layer.angles[1] + pw.r[1] as f32, + layer.angles[2] + pw.r[2] as f32, + ]; + } + + for pl in particle_layers.iter_mut() { + let idx = pl.order_index; + if idx >= objects.len() { + continue; + } + let pw = parent_world_xform(idx, objects, &id_to_idx, &name_to_idx, &mut memo); + if pw.is_identity() { + continue; + } + pl.origin = pw.apply_point(pl.origin); + } + + // Meshes take the same treatment as image layers — they're ordinary scene + // objects that happen to carry geometry. Without this a parented skybox + // stays at the origin while the camera moves out of it. + for m in mesh3d_layers.iter_mut() { + let idx = m.order_index; + if idx >= objects.len() { + continue; + } + let pw = parent_world_xform(idx, objects, &id_to_idx, &name_to_idx, &mut memo); + if pw.is_identity() { + continue; + } + let o = pw.apply_point([m.origin[0] as f64, m.origin[1] as f64, m.origin[2] as f64]); + m.origin = [o[0] as f32, o[1] as f32, o[2] as f32]; + for i in 0..3 { + m.scale[i] *= pw.s[i] as f32; + m.angles[i] += pw.r[i] as f32; + } + } +} diff --git a/src/engine/scene.rs b/src/engine/model/scene.rs similarity index 97% rename from src/engine/scene.rs rename to src/engine/model/scene.rs index 387042a..f99c6c5 100644 --- a/src/engine/scene.rs +++ b/src/engine/model/scene.rs @@ -61,7 +61,7 @@ impl Scene { pub fn lights( &self, ) -> Vec<( - crate::engine::lighting::Light, + crate::engine::gpu::lighting::Light, bool, Option<(f32, f32)>, Option, @@ -91,12 +91,13 @@ impl Scene { .and_then(parse_value_vec3) .map(|a| [a[0] as f32, a[1] as f32, a[2] as f32]) .unwrap_or([0.0; 3]); - let direction = || crate::engine::camera3d::rotate_euler([0.0, 0.0, -1.0], angles); + let direction = + || crate::engine::sim::camera3d::rotate_euler([0.0, 0.0, -1.0], angles); let is_spot = matches!(o.light_type(), LightType::Spot) || o.spot_cone_degrees().is_some(); let light = if matches!(o.light_type(), LightType::Directional) { - crate::engine::lighting::Light::Directional { + crate::engine::gpu::lighting::Light::Directional { direction: direction(), color, intensity, @@ -119,7 +120,7 @@ impl Scene { let outer = o.spot_cone_degrees().map(|(_, o)| o).unwrap_or(30.0); let exponent = (2.0 / (1.0 - outer.to_radians().cos()).max(1e-4)).clamp(1.0, 64.0); - crate::engine::lighting::Light::Spot { + crate::engine::gpu::lighting::Light::Spot { origin, direction: direction(), exponent, @@ -130,14 +131,14 @@ impl Scene { } } else if matches!(o.light_type(), LightType::Tube) { let (origin_a, origin_b) = o.tube_endpoints(); - crate::engine::lighting::Light::Tube { + crate::engine::gpu::lighting::Light::Tube { origin_a, origin_b, color, intensity, } } else { - crate::engine::lighting::Light::Point { + crate::engine::gpu::lighting::Light::Point { origin, color, intensity, @@ -307,10 +308,10 @@ impl General { /// Resolve the scene's fog parameters from the `g_Fog*` property surface. /// /// Returns `None` when both distance and height fog density are 0 / unset - /// (fog off). The caller (render.rs / gpu_renderer.rs) should only run + /// (fog off). The caller (model::resolve / gpu::renderer) should only run /// the fog pass when this returns `Some`. - pub fn fog(&self) -> Option { - use crate::engine::lighting::Fog; + pub fn fog(&self) -> Option { + use crate::engine::gpu::lighting::Fog; let d = self .fog_distance .as_ref() @@ -606,7 +607,7 @@ pub struct SceneObject { /// A static 3D mesh reference (`models/x/x.mdl`) — an alternative to /// `image` used by genuine 3D scenes for spheres/skyboxes/cylinders. NOT /// redundant with `image`: these objects carry no `image` at all, so - /// without this they're skipped entirely. See `engine::mesh3d`. + /// without this they're skipped entirely. See `engine::model::mesh3d`. #[serde(default)] pub model: Option, } @@ -623,7 +624,7 @@ impl SceneObject { } /// Resolve `(radius, intensity, color)` for a `light` object — `None` when - /// this isn't one. Shared by the 2D glow stand-in (`render.rs`) and the + /// this isn't one. Shared by the 2D glow stand-in (`model::resolve`) and the /// mesh3d per-pixel lighting pass (`Scene::lights`), so both treat a /// `light` object's fields identically. pub fn light_params(&self) -> Option<(f32, f32, [f32; 3])> { @@ -668,7 +669,7 @@ impl SceneObject { /// folder, so they're unreachable here — this only resolves to /// something real for a wallpaper that ships its own cookie image /// relative to its own directory (same resolution rules as any other - /// texture reference — see `GpuSceneInstance::build` in gpu_renderer.rs). + /// texture reference — see `GpuSceneInstance::build` in gpu::renderer). pub fn cookie_texture_path(&self) -> Option<&str> { let on = self .usecookie @@ -699,7 +700,7 @@ impl SceneObject { /// `(density, exponent)` for a volumetric light shaft, when /// `castvolumetrics` is truthy — `None` otherwise. See /// `GpuSceneInstance::build_volumetrics`/`record_volumetrics` in - /// gpu_renderer.rs for the consumer; defaults picked to give a + /// gpu::renderer for the consumer; defaults picked to give a /// visible-but-not-overwhelming shaft when a scene sets /// `castvolumetrics` without also tuning `density`/`volumetricsexponent` /// — unverified against real content, no downloaded Workshop light @@ -761,7 +762,7 @@ impl SceneObject { /// discriminant and the endpoint field are both known). Not shadow-mapped /// (matches Point/Spot's own staged rollout — shadows for spot lights only /// shipped after point lights had already been working for a while) — no -/// tube-light case exists in `engine::shadow` yet. +/// tube-light case exists in `engine::gpu::shadow` yet. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LightType { Point, @@ -1211,7 +1212,7 @@ mod tests { let lights = scene.lights(); assert_eq!(lights.len(), 1); match &lights[0].0 { - crate::engine::lighting::Light::Spot { + crate::engine::gpu::lighting::Light::Spot { origin, intensity, exponent, @@ -1241,7 +1242,7 @@ mod tests { assert_eq!(lights.len(), 1); assert!(matches!( lights[0].0, - crate::engine::lighting::Light::Point { .. } + crate::engine::gpu::lighting::Light::Point { .. } )); } @@ -1257,7 +1258,7 @@ mod tests { .unwrap(); assert!(matches!( scene.lights()[0].0, - crate::engine::lighting::Light::Point { .. } + crate::engine::gpu::lighting::Light::Point { .. } )); } @@ -1277,7 +1278,7 @@ mod tests { let lights = scene.lights(); assert_eq!(lights.len(), 1); match &lights[0].0 { - crate::engine::lighting::Light::Directional { + crate::engine::gpu::lighting::Light::Directional { direction, intensity, .. @@ -1300,7 +1301,7 @@ mod tests { .unwrap(); assert!(matches!( scene.lights()[0].0, - crate::engine::lighting::Light::Spot { .. } + crate::engine::gpu::lighting::Light::Spot { .. } )); } @@ -1320,11 +1321,11 @@ mod tests { assert_eq!(lights.len(), 2); assert!(matches!( lights[0].0, - crate::engine::lighting::Light::Point { .. } + crate::engine::gpu::lighting::Light::Point { .. } )); assert!(matches!( lights[1].0, - crate::engine::lighting::Light::Point { .. } + crate::engine::gpu::lighting::Light::Point { .. } )); } @@ -1410,7 +1411,7 @@ mod tests { let lights = scene.lights(); assert_eq!(lights.len(), 1); match &lights[0].0 { - crate::engine::lighting::Light::Tube { + crate::engine::gpu::lighting::Light::Tube { origin_a, origin_b, intensity, @@ -1432,7 +1433,7 @@ mod tests { Scene::from_json(r#"{"objects": [{"id": 1, "light": "ltube", "origin": "5 0 0"}]}"#) .unwrap(); match &scene.lights()[0].0 { - crate::engine::lighting::Light::Tube { + crate::engine::gpu::lighting::Light::Tube { origin_a, origin_b, .. } => { assert_eq!(origin_a, origin_b); diff --git a/src/engine/model/shader_model.rs b/src/engine/model/shader_model.rs index e5ae7c8..457eded 100644 --- a/src/engine/model/shader_model.rs +++ b/src/engine/model/shader_model.rs @@ -2,7 +2,7 @@ // regex dep removed → ComboAnnotation::parse_combos uses manual serde_json parse // UniformDefault::from_json String branch uses bounds-safe .get() indexing // from_resolved_glsl wraps Option correctly -use crate::engine::shaders::uniform_meta::parse_uniform_metadata; +use crate::engine::gpu::shaders::uniform_meta::parse_uniform_metadata; use std::collections::HashMap; #[derive(Debug, Clone, PartialEq)] diff --git a/src/engine/particle.rs b/src/engine/particle.rs deleted file mode 100644 index 0412857..0000000 --- a/src/engine/particle.rs +++ /dev/null @@ -1,5610 +0,0 @@ -use image::RgbaImage; -use serde::Deserialize; - -// Numeric/count fields use `Option` rather than bare `T` with -// `#[serde(default)]`: real presets frequently write an explicit JSON `null` -// for unused fields (e.g. `"flags": null`), and `#[serde(default)]` only -// fills in *missing* keys — it still rejects an explicit `null` against a -// non-Option numeric type. -#[derive(Debug, Clone, Deserialize)] -pub struct ParticleConfig { - #[serde(default)] - pub emitter: Vec, - #[serde(default)] - pub maxcount: Option, - #[serde(default)] - pub initializer: Vec, - #[serde(default)] - pub operator: Vec, - #[serde(default)] - pub renderer: Vec, - #[serde(default)] - pub flags: Option, - /// Real JSON key is singular `"controlpoint"` (an array), referenced by - /// index (not `id`-keyed lookup at use-site) from operators/emitters like - /// `controlpointattract` (CParticle.cpp/ObjectParser.cpp). - #[serde(default, deserialize_with = "null_entries_as_default")] - pub controlpoint: Vec, - /// Delay in seconds before this system begins emitting. Present on nearly - /// every real preset (610/610) so a burst ramps in instead of all - /// particles appearing at frame 0. - #[serde(default)] - pub starttime: Option, - /// Path to the material JSON providing this system's sprite texture - /// (e.g. `"materials/presets/water_faucet.json"`). Resolved by callers - /// (this module stays asset-agnostic) and passed into `render_onto`. - #[serde(default)] - pub material: Option, - /// How a particle's sprite-sheet frame advances over time: `"once"` - /// (play through over the particle's lifetime), `"randomframe"` (pick - /// one fixed random frame at spawn), or anything else/absent (loop - /// continuously) — CParticle.cpp's animation-frame update. - #[serde(default)] - pub animationmode: Option, - /// Playback speed multiplier for sprite-sheet animation; `<= 0` (or - /// absent) means 1.0, matching the reference's `sequenceMultiplier > 0 - /// ? sequenceMultiplier : 1.0`. - #[serde(default)] - pub sequencemultiplier: Option, - /// Nested child particle presets (`"children"` array; often JSON `null`). - /// Loaded/resolved by the caller (this module stays asset-agnostic) and - /// attached via [`ParticleSystem::add_child`]. - #[serde(default)] - pub children: Option>, -} - -/// One `children` entry: a nested particle preset attached to this system. -/// `type` values seen in real content: absent/`"static"` (a sub-system -/// running continuously at the parent object's origin), `"eventfollow"` -/// (an instance per parent particle, tracking it), `"eventspawn"` / -/// `"eventdeath"` (a burst where a parent particle appears/dies). -#[derive(Debug, Clone, Deserialize)] -pub struct ChildRef { - pub name: String, - #[serde(rename = "type", default)] - pub child_type: Option, - #[serde(default)] - pub probability: Option, - #[serde(default)] - pub maxcount: Option, -} - -/// One resolved particle sprite: every sprite-sheet frame (already sliced -/// out of the `.tex` atlas by `TexFile::to_rgba_frames`) plus the total -/// loop duration in seconds (sum of each frame's `frametime`, 0 for a -/// single-frame/non-animated sprite), plus the material's -/// `ui_editor_properties_overbright` brightness multiplier -/// (genericparticle.frag's `color.rgb *= g_Overbright`, default 1.0). -#[derive(Clone)] -pub struct ParticleSprite { - pub frames: Vec, - pub duration: f32, - pub overbright: f32, -} - -impl ParticleSprite { - pub fn single(image: RgbaImage) -> Self { - Self { - frames: vec![image], - duration: 0.0, - overbright: 1.0, - } - } -} - -/// Parsed `animationmode` (defaults to `Loop` for anything unrecognized or -/// absent, matching the reference's `else` fallback). -#[derive(Debug, Clone, Copy, PartialEq, Default)] -enum AnimationMode { - #[default] - Loop, - Once, - RandomFrame, -} - -impl AnimationMode { - fn from_config(mode: Option<&str>) -> Self { - match mode { - Some("once") => Self::Once, - Some("randomframe") => Self::RandomFrame, - _ => Self::Loop, - } - } -} - -/// Real presets pad their `controlpoint` array with literal `null`s (unused -/// slots). Serde rejects the whole preset over one, so nulls become defaults — -/// the array is indexed positionally, so the padding has to stay in place. -fn null_entries_as_default<'de, D, T>(d: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, - T: Deserialize<'de> + Default, -{ - Ok(Vec::>::deserialize(d)? - .into_iter() - .map(Option::unwrap_or_default) - .collect()) -} - -#[derive(Default, Debug, Clone, Deserialize)] -pub struct ControlPointConfig { - #[serde(default)] - pub id: Option, - #[serde(default)] - pub offset: Option, - #[serde(default)] - pub flags: Option, - /// Orientation of the control point's local frame (z used in 2D): rotates - /// the `offset` about the object origin so oriented control points land in - /// the right place. - #[serde(default)] - pub angles: Option, - /// `locktopointer`: the control point tracks the mouse cursor. Parsed; - /// we have no live pointer input, so it resolves to its static offset. - #[serde(default)] - pub locktopointer: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct Emitter { - #[serde(default)] - pub name: String, - /// Emission rate per second. `None` (absent) takes the reference's - /// default of 10; an explicit `0` stays 0 (instantaneous-only emitters). - #[serde(default)] - pub rate: Option, - #[serde(default)] - pub origin: Option, - #[serde(default)] - pub directions: Option, - /// A scalar radius for sphere emitters, but a `"x y z"` box-extent vector - /// for box emitters (e.g. `"distancemax": "1024 512 0"`). - #[serde(default)] - pub distancemin: Option, - #[serde(default)] - pub distancemax: Option, - /// Optional emitter-driven radial speed range (rare; most presets set - /// velocity via initializers). - #[serde(default)] - pub speedmin: Option, - #[serde(default)] - pub speedmax: Option, - #[serde(default)] - pub sign: Option, - #[serde(default)] - pub id: Option, - /// One-shot burst count: this many particles are emitted in a single frame - /// when the emitter first activates (at `starttime`), then normal `rate` - /// emission continues. `0`/absent = purely rate-driven. - #[serde(default)] - pub instantaneous: Option, - /// Emitter lifetime in seconds: after `starttime + duration`, this emitter - /// stops emitting. Absent = emits forever. - #[serde(default)] - pub duration: Option, - /// Presence of `audioprocessingmode`/`bounds` makes emission react to - /// captured audio; the rest shape the response (see `AudioResponse`). - #[serde(default)] - pub audioprocessingmode: Option, - #[serde(default)] - pub audioprocessingbounds: Option, - #[serde(default)] - pub audioprocessingexponent: Option, - #[serde(default)] - pub audioprocessingfrequencystart: Option, - #[serde(default)] - pub audioprocessingfrequencyend: Option, -} - -/// An emitter's audio response, with the defaults and clamping of WE's own -/// parser (wallpaper64.exe `FUN_1401c1e20`): `exponent` 2.0, `bounds` -/// "0.8 1.0", bands `0..=1` of the 16-band spectrum, both clamped to 15 and -/// swapped if reversed. -#[derive(Debug, Clone, Copy, PartialEq)] -struct AudioResponse { - exponent: f32, - bounds: [f32; 2], - bands: [usize; 2], -} - -impl AudioResponse { - fn from_emitter(e: &Emitter) -> Option { - if e.audioprocessingmode.is_none() && e.audioprocessingbounds.is_none() { - return None; - } - let band = |v: &Option, d: usize| { - v.as_ref() - .and_then(|v| v.as_u64()) - .map_or(d, |b| b as usize) - .min(15) - }; - let (a, b) = ( - band(&e.audioprocessingfrequencystart, 0), - band(&e.audioprocessingfrequencyend, 1), - ); - let bounds = match &e.audioprocessingbounds { - Some(serde_json::Value::String(s)) => { - let p: Vec = s - .split_whitespace() - .filter_map(|x| x.parse().ok()) - .collect(); - [ - p.first().copied().unwrap_or(0.0), - p.get(1).copied().unwrap_or(0.0), - ] - } - Some(v) => value_as_f32(v).map_or([0.8, 1.0], |x| [x, x]), - None => [0.8, 1.0], - }; - Some(Self { - exponent: e - .audioprocessingexponent - .as_ref() - .and_then(value_as_f32) - .unwrap_or(2.0), - bounds, - bands: [a.min(b), a.max(b)], - }) - } - - /// Rate multiplier for one 16-band spectrum (L/R averaged). Per WE's - /// emitter docs the emitter is "only active when audio is playing" and - /// `bounds` are the volume levels where the response starts and stops, - /// so silence -> 0 and the rate ramps from `bounds[0]` to `bounds[1]`. - // ponytail: WE's runtime consumer isn't decompiled; the exponent-then- - // bounds order follows the docs' wording, and `mode` (left/right/center) - // is treated only as an on-switch. Revisit if the consumer is recovered. - fn gain(&self, bands16: &[f32; 16]) -> f32 { - let s = &bands16[self.bands[0]..=self.bands[1]]; - let level = (s.iter().sum::() / s.len() as f32) - .clamp(0.0, 1.0) - .powf(self.exponent); - let [lo, hi] = self.bounds; - if hi <= lo { - return if level >= lo { 1.0 } else { 0.0 }; - } - ((level - lo) / (hi - lo)).clamp(0.0, 1.0) - } -} - -/// `min`/`max` are untyped because real presets mix plain numbers -/// (`lifetimerandom`, `sizerandom`, `alpharandom`) with `"r g b"`/`"x y z"` -/// vector strings (`velocityrandom`, `colorrandom`, `angularvelocityrandom`) — -/// declaring these as `f64` makes serde reject any config using a vector -/// initializer. -#[derive(Debug, Clone, Deserialize)] -pub struct Initializer { - #[serde(default)] - pub name: String, - #[serde(default)] - pub max: Option, - #[serde(default)] - pub min: Option, - /// Biases the uniform `random(min,max)` toward `min` (exponent > 1) or - /// `max` (< 1): the sample `t` is raised to this power before the lerp. - /// Absent = 1.0 (uniform). - #[serde(default)] - pub exponent: Option, - /// `turbulentvelocityrandom` (CParticle.cpp - /// createTurbulentVelocityRandomInitializer): a curl-noise-directed - /// spawn velocity. `forward`/`right` are `"x y z"` vector strings; - /// `offset` here is the scalar tilt angle around `right`, unrelated to - /// an emitter's positional offset. `speedmin`/`speedmax` are untyped - /// because turbulentvelocityrandom uses plain numbers but - /// `mapsequencearoundcontrolpoint` uses `"x y z"` vector strings. - #[serde(default)] - pub speedmin: Option, - #[serde(default)] - pub speedmax: Option, - /// `mapsequencearoundcontrolpoint`: spawn the Nth particle at the - /// control point, launched at angle `(N % count) / count * 2pi`. - #[serde(default)] - pub controlpoint: Option, - #[serde(default)] - pub count: Option, - /// `mapsequencebetweencontrolpoints`: `"mirror"` ping-pongs the - /// sequence along the segment instead of wrapping. - #[serde(default)] - pub limitbehavior: Option, - #[serde(default)] - pub offset: Option, - #[serde(default)] - pub scale: Option, - #[serde(default)] - pub forward: Option, - #[serde(default)] - pub timescale: Option, - #[serde(default)] - pub phasemin: Option, - #[serde(default)] - pub phasemax: Option, - #[serde(default)] - pub right: Option, -} - -#[derive(Debug, Default, Clone, Deserialize)] -pub struct Operator { - #[serde(default)] - pub name: String, - /// The real field on a WE "movement" operator (a constant acceleration - /// vector), not a generic scalar. - #[serde(default)] - pub gravity: Option, - /// `alphafade`: fade in over `[0, fadeintime]`, hold, fade out over - /// `[fadeouttime, 1]` of normalized lifetime (CParticle.cpp createAlphaFadeOperator). - #[serde(default)] - pub fadeintime: Option, - #[serde(default)] - pub fadeouttime: Option, - /// `sizechange`/`alphachange`/`colorchange`: linear ramp from `startvalue` - /// to `endvalue` over normalized-lifetime `[starttime, endtime]` - /// (CParticle.cpp's generic `fadeValue`). `startvalue`/`endvalue` are - /// untyped because `sizechange`/`alphachange` use a plain number but - /// `colorchange` uses an `"r g b"` vector string (e.g. `"1 0.75 0"`). - #[serde(default)] - pub starttime: Option, - #[serde(default)] - pub endtime: Option, - #[serde(default)] - pub startvalue: Option, - #[serde(default)] - pub endvalue: Option, - /// `oscillatealpha`/`oscillatesize`/`oscillateposition`: per-particle - /// random frequency/scale/phase, resampled once at spawn. - #[serde(default)] - pub frequencymin: Option, - #[serde(default)] - pub frequencymax: Option, - #[serde(default)] - pub scalemin: Option, - #[serde(default)] - pub scalemax: Option, - #[serde(default)] - pub phasemin: Option, - #[serde(default)] - pub phasemax: Option, - /// `controlpointattract`: pulls particles toward `controlpoint` (index - /// into the particle config's `controlpoint` array) whenever they're - /// within `threshold/2` of it (CParticle.cpp createControlPointAttractOperator). - #[serde(default)] - pub controlpoint: Option, - #[serde(default)] - pub origin: Option, - #[serde(default)] - pub scale: Option, - #[serde(default)] - pub threshold: Option, - /// `angularmovement`: integrates `rotation.z` from `angularvelocityrandom`'s - /// sampled `angular_velocity`, itself accelerated by `force.z` and decayed - /// by `drag` each step (CParticle.cpp createAngularMovementOperator). - #[serde(default)] - pub drag: Option, - #[serde(default)] - pub force: Option, - /// `turbulence` (CParticle.cpp createTurbulenceOperator): curl-noise - /// acceleration. Shares `scale`/`phasemin`/`phasemax` above. - #[serde(default)] - pub speedmin: Option, - #[serde(default)] - pub speedmax: Option, - #[serde(default)] - pub timescale: Option, - #[serde(default)] - pub mask: Option, - /// `vortex` (CParticle.cpp createVortexOperator): tangential spin around - /// a control point. `flags`: 1 = infinite axis, 2 = maintain distance, - /// 4 = ring shape. - #[serde(default)] - pub flags: Option, - #[serde(default)] - pub axis: Option, - #[serde(default)] - pub offset: Option, - #[serde(default)] - pub distanceinner: Option, - #[serde(default)] - pub distanceouter: Option, - #[serde(default)] - pub speedinner: Option, - #[serde(default)] - pub speedouter: Option, - #[serde(default)] - pub centerforce: Option, - #[serde(default)] - pub ringradius: Option, - #[serde(default)] - pub ringwidth: Option, - #[serde(default)] - pub ringpulldistance: Option, - #[serde(default)] - pub ringpullforce: Option, - /// `remapvalue` (CParticle createValueRemapOperator, real retail-binary - /// dispatch tables recovered in the Ghidra report's Follow-up (x)): - /// reads `input` (a named particle field, or noise sampled at the - /// particle position/time when unset), shapes it through - /// `transformfunction`, remaps `[inputrangemin, inputrangemax]` into - /// `[outputrangemin, outputrangemax]`, and combines it into `output` via - /// `operation`. `outputrangemin`/`max` are `"x y z"` vec3 for - /// vector-valued outputs (velocity/position/color), scalar otherwise. - #[serde(default)] - pub input: Option, - #[serde(default)] - pub inputcomponent: Option, - #[serde(default)] - pub output: Option, - #[serde(default)] - pub outputcomponent: Option, - #[serde(default)] - pub operation: Option, - #[serde(default)] - pub inputrangemin: Option, - #[serde(default)] - pub inputrangemax: Option, - #[serde(default)] - pub outputrangemin: Option, - #[serde(default)] - pub outputrangemax: Option, - #[serde(default)] - pub transformfunction: Option, - #[serde(default)] - pub transforminputscale: Option, - /// `collisionsphere`/`collisionbox`/`collisionbounds`/`collisionquad`/ - /// `collisionplane` (real operator names, Ghidra `strings.txt` around - /// `140490008`, alongside `boids`/`collisionbehavior`/`bouncefactor` — - /// no C++ reference implements any of these, so field semantics below - /// are a reasoned reconstruction, not byte-verified). A sphere/box/ - /// bounds/quad collider's half-size or radius; box/bounds/quad read - /// `size`'s x/y, sphere reads `radius`. - #[serde(default)] - pub radius: Option, - #[serde(default)] - pub size: Option, - /// `slide` (zero the into-surface velocity component), `bounce` - /// (reflect it, scaled by `bouncefactor`), or `delete` (kill the - /// particle on contact). Defaults to `bounce`, the most common - /// particle-collision behavior in comparable engines. - #[serde(default)] - pub collisionbehavior: Option, - #[serde(default)] - pub bouncefactor: Option, - /// `boids` flocking rules (separation/alignment/cohesion) — real field - /// names from the same table (`variablestrength`, `separationthreshold`, - /// `neighborthreshold`, `maxspeed`, `separationfactor`, - /// `alignmentfactor`, `cohesionfactor`). - #[serde(default)] - pub separationthreshold: Option, - #[serde(default)] - pub neighborthreshold: Option, - #[serde(default)] - pub maxspeed: Option, - #[serde(default)] - pub separationfactor: Option, - #[serde(default)] - pub alignmentfactor: Option, - #[serde(default)] - pub cohesionfactor: Option, - #[serde(default)] - pub variablestrength: Option, - /// Plane-collider orientation (reuses the same "z-axis rotates a default - /// normal" convention `controlpoint`/vortex offsets already use). - #[serde(default)] - pub angles: Option, -} - -/// Scene.json's per-instance `instanceoverride` on a particle object — -/// overrides the referenced preset's rate/size/speed/alpha/color. -#[derive(Debug, Default, Clone, Deserialize)] -pub struct InstanceOverride { - pub alpha: Option, - pub color: Option, - pub rate: Option, - pub size: Option, - pub speed: Option, - /// Multiplier on the preset's `maxcount` pool size - /// (CParticle.cpp: `adjustedMaxCount = maxCount * count`). - pub count: Option, - /// Multiplier on every spawned particle's lifetime — the reference - /// applies it both in `createLifetimeRandomInitializer` and the - /// default-spawn path, so a single multiply at spawn covers both. - pub lifetime: Option, - /// Per-channel color multiplier ("r g b", 0–1 floats) applied to the - /// `colorrandom` result (CParticle.cpp:715-719). Distinct from `color`. - pub colorn: Option, - /// `false` disables the whole system (ObjectParser default true). - pub enabled: Option, - /// Remaining override keys — notably `controlpointN` ("x y z" strings, - /// absolute scene coordinates) that reposition a preset's control - /// points per instance (e.g. the discharge preset's arc endpoint). - #[serde(flatten)] - pub extra: std::collections::HashMap, -} - -/// Per-particle oscillator state for `oscillatealpha`/`oscillatesize`: a -/// random frequency/scale/phase drawn once at spawn (CParticle.cpp captures -/// these lazily on first use, but since we never re-use particle slots -/// across configs, drawing them at spawn is equivalent). -#[derive(Clone, Copy)] -struct Oscillator1D { - frequency: f32, - scale: f32, - phase: f32, -} - -/// Same idea as `Oscillator1D` but per-axis for `oscillateposition`, which -/// integrates a velocity (derivative of `scale*cos(w*t+phase)`) into position -/// each frame rather than overwriting an absolute value. -#[derive(Clone, Copy)] -struct OscillatorPos { - frequency: [f32; 2], - scale: [f32; 2], - phase: [f32; 2], -} - -#[derive(Clone)] -struct Particle { - /// Stable identity across `retain` compaction — child `eventfollow` - /// instances track their parent particle by this, not by index. - id: u64, - x: f32, - y: f32, - vx: f32, - vy: f32, - life: f32, - max_life: f32, - size: f32, - alpha: f32, - color: [u8; 3], - /// Alpha/size/color as set by the initializers at spawn — `alphafade`/ - /// `sizechange`/`colorchange` scale *this*, not the live (already-modified) - /// value. `initial_color` is 0..1 (matching `colorchange`'s multiplier). - initial_alpha: f32, - initial_size: f32, - initial_color: [f32; 3], - osc_alpha: Option, - osc_size: Option, - osc_pos: Option, - /// Screen-space (Z-axis) rotation angle, radians. Only visually relevant - /// once a sprite texture is drawn (`render_onto`'s textured path) — a - /// flat-color circle is rotation-invariant. - rotation: f32, - angular_velocity: f32, - /// Current sprite-sheet frame (fractional, truncated at draw time); - /// `-1.0` at spawn means "not yet assigned" (only meaningful for - /// `AnimationMode::RandomFrame`, which assigns it once and freezes it). - frame: f32, -} - -/// Parsed `oscillatealpha`/`oscillatesize`/`oscillateposition` operator -/// config — random ranges each spawned particle draws its own values from. -#[derive(Clone, Copy)] -struct OscillateParams { - freq_min: f32, - freq_max: f32, - scale_min: f32, - scale_max: f32, - phase_min: f32, - phase_max: f32, -} - -impl OscillateParams { - fn from_operator(op: &Operator) -> Self { - let freq_min = op.frequencymin.unwrap_or(0.0) as f32; - let freq_max = op.frequencymax.unwrap_or(freq_min as f64) as f32; - let scale_min = op.scalemin.or(op.scalemax).unwrap_or(0.0) as f32; - let scale_max = op.scalemax.or(op.scalemin).unwrap_or(0.0) as f32; - let phase_min = op.phasemin.unwrap_or(0.0) as f32; - let phase_max = op.phasemax.unwrap_or(0.0) as f32; - Self { - freq_min, - freq_max, - scale_min, - scale_max, - phase_min, - phase_max, - } - } - - /// CParticle.cpp always draws phase from `[phaseMin, phaseMax + 2*PI]` — - /// even with no phase fields at all (both 0), this yields a fully random - /// phase in `[0, 2*PI]`, which is what real presets (lacking phase - /// fields entirely) actually rely on. - fn sample(&self) -> Oscillator1D { - Oscillator1D { - frequency: self.freq_min + fastrand::f32() * (self.freq_max - self.freq_min), - scale: self.scale_min + fastrand::f32() * (self.scale_max - self.scale_min), - phase: self.phase_min - + fastrand::f32() * (self.phase_max + std::f32::consts::TAU - self.phase_min), - } - } -} - -pub struct ParticleSystem { - particles: Vec, - emitters: Vec, - max_count: usize, - life_min: f32, - life_max: f32, - size_min: f32, - size_max: f32, - /// `exponent` distribution bias for lifetime/size random sampling (1.0 = uniform). - life_exp: f32, - size_exp: f32, - /// `alpharandom` initializer range (CParticle.cpp's `AlphaRandomInitializer`): - /// defaults to `(1.0, 1.0)` — a no-op — when the config has no such - /// initializer at all, matching `ParticleInstance::alpha`'s `1.0f` default. - alpha_min: f32, - alpha_max: f32, - velocity_min: Option<[f32; 3]>, - velocity_max: Option<[f32; 3]>, - color_min: [f32; 3], - color_max: [f32; 3], - gravity: [f32; 2], - /// Scene-global constant acceleration (`general.gravity*` + `wind*`), - /// added to every particle's velocity each step. Set post-construction. - scene_force: [f32; 2], - /// Latest 16-band spectrum (L/R averaged), set per frame; audio-reactive - /// emitters scale their emission rate by it. - audio_bands: [f32; 16], - /// `movement.drag`: velocity decays by `1 - drag*dt` (clamped at 0) - /// each step, matching the reference's reversal-preventing clamp. - movement_drag: f32, - /// `alphafade`: (fadeintime, fadeouttime), normalized-lifetime fractions. - alphafade: Option<(f32, f32)>, - /// `sizechange`/`alphachange`: (starttime, endtime, startvalue, endvalue). - sizechange: Option<(f32, f32, f32, f32)>, - alphachange: Option<(f32, f32, f32, f32)>, - /// `colorchange`: (starttime, endtime, start_color, end_color), colors in - /// 0..1 (unlike `colorrandom`'s 0..255 — matches the real presets, e.g. - /// `"startvalue": "1 0.75 0"`). - colorchange: Option<(f32, f32, [f32; 3], [f32; 3])>, - oscillate_alpha: Option, - oscillate_size: Option, - oscillate_position: Option, - alpha_mult: f32, - size_mult: f32, - speed_mult: f32, - rate_mult: f32, - /// `instanceoverride.lifetime` multiplier on spawned lifetimes. - lifetime_mult: f32, - /// `instanceoverride.colorn` per-channel multiplier (0-1) on the - /// `colorrandom` result. - colorn_mult: [f32; 3], - color_override: Option<[u8; 3]>, - /// Resolved once at construction: `spawn_center + offset` per control - /// point, indexed positionally (falls back to declaration order when a - /// config omits `id`). Nested parent-space transforms and mouse-linked - /// (`locktopointer`)/world-space (`flags & 2`) control points are not - /// modeled — same simplification already accepted for emitter origins. - control_points: Vec<[f32; 3]>, - /// `controlpointattract`: (control point index, origin offset, scale, threshold). - control_point_attract: Option<(usize, [f32; 3], f32, f32)>, - /// True when `config.renderer` names a `"rope"`/`"ropetrail"` renderer — - /// draws a connected ribbon through living particles instead of - /// independent circles (CParticle::renderRope). - rope_mode: bool, - rope_subdivision: usize, - /// Rope UV controls (`uvscale`/`uvscrolling`/`uvsmoothing` renderer keys). - rope_uv_scale: f32, - rope_uv_scrolling: bool, - rope_uv_smoothing: bool, - /// `angularvelocityrandom` initializer range (z-axis component only). - angular_velocity_min: f32, - angular_velocity_max: f32, - /// `angularmovement` operator (z-axis `force` component, plus `drag`). - angular_force: f32, - angular_drag: f32, - /// Sprite-sheet animation: how many frames the resolved sprite has (0 if - /// no sprite, or a plain single-frame one) and its total loop duration - /// in seconds — set post-construction via `set_sprite_frames` once the - /// caller has actually resolved the sprite texture (this module stays - /// asset-agnostic, same convention as `material`/`render_onto`). - sprite_frame_count: usize, - sprite_duration: f32, - animation_mode: AnimationMode, - sequence_multiplier: f32, - /// `rotationrandom` initializer range (z-axis component, radians) — - /// `None` leaves spawn rotation at 0, matching the reference's absent- - /// initializer default. - rotation_range: Option<(f32, f32)>, - /// `spritetrail` renderer parameters. - sprite_trail: Option, - /// `mapsequencearoundcontrolpoint` initializer + its running sequence - /// counter (shared across spawns, wraps at `count`). - map_sequence: Option, - map_sequence_index: u32, - /// `mapsequencebetweencontrolpoints` initializer + its counter. - map_seq_between: Option, - map_seq_between_index: u32, - /// `turbulentvelocityrandom` initializers; each adds its own velocity. - turbulent_velocity: Vec, - /// `turbulence` operator parameters. - turbulence: Option, - /// `vortex` operator parameters. - vortex: Option, - /// `remapvalue` operator parameters — a `Vec` since real content - /// (`particles/presets/rain_screen.json`) attaches more than one (one - /// targeting `velocity`, one targeting `speed`) and both must run; a - /// single `Option` here used to silently drop every instance after the - /// first. - remap: Vec, - /// `collisionsphere`/`collisionbox`/`collisionbounds`/`collisionquad`/ - /// `collisionplane` operators — every one attached, since a system can - /// have more than one collider (e.g. a floor plane plus a wall box). - collisions: Vec, - /// `boids` operator parameters. - boids: Option, - /// Simulation clock (sum of `step` dts) — drives the time-scrolled - /// noise fields, the reference's `m_time`/`currentTime`. - time: f32, - /// `starttime`: emission is suppressed until the clock reaches this. - start_time: f32, - /// Preset `flags & 4` (perspective particles): sphere emitters use the - /// 3D spherical-shell spawn distribution instead of the 2D disk. - shell_3d: bool, - /// Monotonic source for `Particle::id`. - next_particle_id: u64, - /// Attached child presets (see [`ChildRef`]); each holds its own live - /// instances, stepped and drawn with this system. - children: Vec, -} - -/// How a child preset binds to its parent (see [`ChildRef`]). -#[derive(Debug, Clone, Copy, PartialEq)] -enum ChildType { - Static, - Follow, - Spawn, - Death, -} - -/// A resolved child preset plus its live instances. The child's material -/// sprite/blending are its own (a glow child can be additive over a -/// normal-blended parent); both stages draw into the same canvas. -pub struct ChildSystem { - child_type: ChildType, - probability: f32, - max_instances: usize, - config: ParticleConfig, - sprite: Option, - additive: bool, - instances: Vec, -} - -struct ChildInstance { - /// `Some` while following a live parent particle (`eventfollow`); - /// cleared when the parent dies so the instance stops emitting and - /// retires once its remaining particles expire. - parent_id: Option, - /// Where this instance's emitters currently sit (parent particle - /// position for `eventfollow`, event position for bursts). - last_center: [f32; 2], - /// Burst instances (`eventspawn`/`eventdeath`) stop emitting at this - /// system-time cutoff — the preset itself has no end marker. - emit_until: Option, - /// `static` children run for the parent system's whole life and are - /// never retired, even while momentarily empty between spawns. - persistent: bool, - system: ParticleSystem, -} - -/// `turbulentvelocityrandom` (CParticle.cpp): spawn velocity picked from a -/// curl-noise field, angle-limited around `forward`. -#[derive(Clone, Copy)] -struct TurbulentVelocityParams { - speed_min: f32, - speed_max: f32, - /// Tilt angle (radians) around `right`. - offset: f32, - /// Direction cone: `< 2` limits deviation from `forward` to - /// `scale/2 * pi`. - scale: f32, - forward: [f32; 3], - right: [f32; 3], - timescale: f32, - phase_min: f32, - phase_max: f32, -} - -/// `turbulence` operator (CParticle.cpp): curl-noise acceleration. `phase` -/// and `speed` are drawn once per operator instance, not per particle. -#[derive(Clone, Copy)] -struct TurbulenceParams { - noise_scale: f32, - timescale: f32, - mask: [f32; 3], - phase: f32, - speed: f32, -} - -/// `spritetrail` renderer: velocity-aligned, velocity-stretched sprite quad. -#[derive(Clone, Copy)] -struct SpriteTrailParams { - length: f32, - max_length: f32, - min_length: f32, -} - -/// `mapsequencebetweencontrolpoints` initializer: spawns successive -/// particles at evenly spaced points along the segment from control point -/// 0 to control point 1 (`count` slots). `limitbehavior: "mirror"` -/// ping-pongs the sequence; anything else wraps. Neither the C++ reference -/// nor its parser implements this one — semantics derived from real -/// content (the `discharge` preset draws a rope through the chain, -/// yielding a lightning arc between the two anchors). -#[derive(Clone, Copy)] -struct MapSequenceBetweenParams { - count: u32, - mirror: bool, -} - -/// `mapsequencearoundcontrolpoint` initializer (CParticle.cpp): spawns each -/// particle at the control point with a velocity rotated by the next angle -/// in an evenly-divided circle (`sequence / count * 2pi`). -#[derive(Clone, Copy)] -struct MapSequenceParams { - control_point: usize, - count: u32, - speed_min: [f32; 3], - speed_max: [f32; 3], -} - -/// `vortex` operator (CParticle.cpp): tangential spin around a control -/// point, with optional ring shape and center attraction. -#[derive(Clone, Copy)] -struct VortexParams { - control_point: i64, - infinite_axis: bool, - maintain_distance: bool, - ring_shape: bool, - axis: [f32; 3], - offset: [f32; 3], - distance_inner: f32, - distance_outer: f32, - speed_inner: f32, - speed_outer: f32, - center_force: f32, - ring_radius: f32, - ring_width: f32, - ring_pull_distance: f32, - ring_pull_force: f32, -} - -/// Collider shape for a `collisionsphere`/`collisionbox`/`collisionbounds`/ -/// `collisionquad`/`collisionplane` operator (see the `Operator` field docs -/// for the "no C++ reference, reasoned reconstruction" caveat). `Box` also -/// covers `collisionbounds`/`collisionquad` — this 2D engine has no distinct -/// notion of "world bounds" vs. "an oriented flat quad" vs. "a box", so all -/// three collapse to the same axis-aligned-rectangle test. -#[derive(Clone, Copy)] -enum CollisionShape { - Sphere { - radius: f32, - }, - Box { - half_extent: [f32; 2], - }, - /// `normal` points away from the solid side (into free space). - Plane { - normal: [f32; 2], - }, -} - -/// `collisionbehavior`: what happens to a particle that penetrates a -/// collider this frame. -#[derive(Clone, Copy, PartialEq)] -enum CollisionBehavior { - Bounce, - Slide, - Delete, -} - -impl CollisionBehavior { - fn parse(s: Option<&str>) -> Self { - match s { - Some("slide") => Self::Slide, - Some("delete") => Self::Delete, - _ => Self::Bounce, - } - } -} - -/// One resolved collider: anchored at a `controlpoint` (+ `offset`), same -/// static-at-construction simplification `vortex`/`controlpointattract` -/// already accept for control points. -#[derive(Clone, Copy)] -struct CollisionParams { - shape: CollisionShape, - center: [f32; 2], - behavior: CollisionBehavior, - bounce_factor: f32, -} - -/// `boids` operator: standard separation/alignment/cohesion flocking rules. -/// Real field names recovered from the binary (see the `Operator` field -/// docs); the flocking algorithm itself is the industry-standard -/// three-rule formulation, not independently verified against WE's own -/// (unrecoverable) per-pixel/per-particle GPU dispatch. -#[derive(Clone, Copy)] -struct BoidsParams { - separation_threshold: f32, - neighbor_threshold: f32, - max_speed: f32, - separation_factor: f32, - alignment_factor: f32, - cohesion_factor: f32, - /// `variablestrength != 0`: jitter each particle's flocking-force - /// magnitude by a deterministic per-particle hash of its id, so a flock - /// doesn't move as one mechanically uniform block. - variable_strength: bool, -} - -/// `remapvalue`'s `input`/`output` field selector. Real, named fields -/// recovered directly from the retail binary's own dispatch table (Ghidra -/// report Follow-up (x)); only the subset this engine already tracks -/// per-particle state for is implemented. The rest -/// (`distancetocontrolpoint`/`positionbetweentwocontrolpoints`/`runtime`/ -/// `timeofday`/`particlesystemtime`/`layertime`/`controlpoint`/ -/// `deltatocontrolpoint`/`directiontocontrolpoint`/`layerorigin`) are real -/// names too, but need control-point/scene-clock plumbing this operator -/// doesn't have access to and no cached real content (271 Workshop items -/// checked) exercises any of them — falling through to `None` (noise mode) -/// rather than silently misreading a wrong field. -#[derive(Clone, Copy, PartialEq, Debug)] -enum RemapField { - /// No `input` configured: sample noise at the particle position/time — - /// the original, real-content-validated behavior, unchanged. - None, - LifetimeFraction, - MaxLifetime, - Size, - Opacity, - Speed, - Rotation, - AngularSpeed, - Color, - Position, - Velocity, -} - -impl RemapField { - fn parse(s: Option<&str>) -> Self { - match s { - Some("lifetimefraction") => Self::LifetimeFraction, - Some("maxlifetime") => Self::MaxLifetime, - Some("size") => Self::Size, - Some("opacity") => Self::Opacity, - Some("speed") => Self::Speed, - Some("rotation") => Self::Rotation, - Some("angularspeed") => Self::AngularSpeed, - Some("color") => Self::Color, - Some("position") => Self::Position, - Some("velocity") => Self::Velocity, - _ => Self::None, - } - } -} - -/// `remapvalue`'s `output` field — same real dispatch table as -/// [`RemapField`], restricted to fields this operator can write back to. -#[derive(Clone, Copy, PartialEq, Debug)] -enum RemapOutput { - Velocity, - Speed, - Size, - Opacity, - Rotation, - AngularSpeed, - Color, - Position, -} - -impl RemapOutput { - fn parse(s: Option<&str>) -> Self { - match s { - Some("speed") => Self::Speed, - Some("size") => Self::Size, - Some("opacity") => Self::Opacity, - Some("rotation") => Self::Rotation, - Some("angularspeed") => Self::AngularSpeed, - Some("color") => Self::Color, - Some("position") => Self::Position, - // "velocity" and anything unrecognized: existing default. - _ => Self::Velocity, - } - } -} - -/// `remapvalue`'s `operation` — how the remapped value combines with the -/// output field's current value. `Multiply` is corroborated directly: the -/// retail binary's own `alphafade`-family default-preset builders -/// (`FUN_1401bfbb0`/`FUN_1401bc4b0`, Follow-up (x)) construct exactly -/// `operation=multiply, input=lifetimefraction` to fade a field by a -/// lifetime-driven factor — the same shape as this engine's own -/// (independently, already-validated) `alphafade` — so "multiply the -/// current value" is a corroborated reading, not a guess. `Add`/`Subtract` -/// follow the same current-value-combine pattern; no real cached content -/// exercises them to cross-check against. -#[derive(Clone, Copy, PartialEq, Debug)] -enum RemapOp { - /// Default: replace the output field outright (the original, - /// real-content-validated `remapvalue` behavior). - Remap, - Multiply, - Add, - Subtract, -} - -impl RemapOp { - fn parse(s: Option<&str>) -> Self { - match s { - Some("multiply") => Self::Multiply, - Some("add") => Self::Add, - Some("subtract") => Self::Subtract, - _ => Self::Remap, - } - } - - fn combine(self, current: f32, remapped: f32) -> f32 { - match self { - Self::Remap => remapped, - Self::Multiply => current * remapped, - Self::Add => current + remapped, - Self::Subtract => current - remapped, - } - } -} - -/// `remapvalue`'s `transformfunction` — shapes the normalized `[0,1]` input -/// before it's remapped into the output range. The retail binary gave up -/// only the *names* (Follow-up (x)), not formulas: `Sine`/`Square`/ -/// `Triangle` are the standard one-period constructions for each name, an -/// honest best-effort reading rather than a verified port. `Saw` is a -/// linear ramp over a single non-wrapping `[0,1]` pass, which makes it -/// indistinguishable here from `None` — documented, not hidden. -/// `Simplex`/`Fbm` are unchanged from before: real, position/time-driven -/// noise sampling, validated against real content -/// (`particles/presets/rain_screen.json`). -#[derive(Clone, Copy, PartialEq, Debug)] -enum RemapTransform { - None, - Sine, - Square, - Saw, - Triangle, - Simplex, - Fbm, -} - -impl RemapTransform { - /// Parses an explicit, non-empty `transformfunction` value. Whether an - /// *absent* key defaults to `Simplex` (noise generator) or `None` - /// (passthrough) depends on whether `input` was also given — see the - /// call site in `ParticleSystem::new` — so that's handled there, not - /// here. - fn parse(s: &str) -> Self { - match s { - "none" => Self::None, - "sine" => Self::Sine, - "square" => Self::Square, - "saw" => Self::Saw, - "triangle" => Self::Triangle, - "fbmnoise" => Self::Fbm, - // "simplexnoise" and anything unrecognized. - _ => Self::Simplex, - } - } - - fn is_noise(self) -> bool { - matches!(self, Self::Simplex | Self::Fbm) - } - - /// Shape an already-clamped `[0,1]` value. Noise variants are handled - /// separately (via position/time sampling, not this function). - fn shape(self, t: f32) -> f32 { - match self { - Self::None | Self::Saw | Self::Simplex | Self::Fbm => t, - Self::Sine => 0.5 + 0.5 * (t * std::f32::consts::TAU).sin(), - Self::Square => { - if t < 0.5 { - 0.0 - } else { - 1.0 - } - } - Self::Triangle => 1.0 - (2.0 * t - 1.0).abs(), - } - } -} - -/// `remapvalue`'s `inputcomponent`/`outputcomponent` — real, named -/// selectors recovered from the retail binary (Follow-up (x)). -#[derive(Clone, Copy, PartialEq, Debug)] -enum RemapComponent { - All, - X, - Y, - Z, - Sum, - Average, - Max, - Min, -} - -impl RemapComponent { - fn parse(s: Option<&str>) -> Self { - match s { - Some("x") => Self::X, - Some("y") => Self::Y, - Some("z") => Self::Z, - Some("sum") => Self::Sum, - Some("average") => Self::Average, - Some("max") => Self::Max, - Some("min") => Self::Min, - _ => Self::All, - } - } - - /// Collapse a (2- or 3-axis) vector to one scalar, for reading a - /// vector-valued input field. `All`/`Average` both average — a vector - /// input must collapse to one scalar for the remap math regardless, and - /// averaging is the least-arbitrary choice among these (stays in the - /// field's native range, unlike `Sum`, which scales with axis count). - fn reduce(self, v: [f32; 3], axes: usize) -> f32 { - let axes = axes.min(3); - match self { - Self::X => v[0], - Self::Y => v[1], - Self::Z => v[2], - Self::Sum => v[..axes].iter().sum(), - Self::Max => v[..axes].iter().cloned().fold(f32::MIN, f32::max), - Self::Min => v[..axes].iter().cloned().fold(f32::MAX, f32::min), - Self::All | Self::Average => v[..axes].iter().sum::() / axes as f32, - } - } -} - -/// `remapvalue`: reads `input` (a named particle field, or noise sampled at -/// the particle position when unset), shapes it, remaps it into -/// `[out_min, out_max]`, and combines it into `output` via `operation`. See -/// [`RemapField`]/[`RemapOutput`]/[`RemapOp`]/[`RemapTransform`] for what's -/// grounded in the retail binary vs. an honest best-effort reading. -#[derive(Clone, Copy)] -struct RemapParams { - input: RemapField, - input_component: RemapComponent, - output: RemapOutput, - output_component: RemapComponent, - operation: RemapOp, - transform: RemapTransform, - in_min: f32, - in_max: f32, - out_min: [f32; 3], - out_max: [f32; 3], - input_scale: f32, -} - -impl RemapParams { - /// Read the configured input field as a single scalar (vector fields - /// collapse via `input_component`). Only meaningful when `input != - /// RemapField::None` — noise mode is handled entirely in `sample01`. - fn read_input(&self, p: &Particle, lifetime_pos: f32, max_life: f32) -> f32 { - match self.input { - RemapField::None => 0.0, - RemapField::LifetimeFraction => lifetime_pos, - RemapField::MaxLifetime => max_life, - RemapField::Size => p.size, - RemapField::Opacity => p.alpha, - RemapField::Speed => (p.vx * p.vx + p.vy * p.vy).sqrt(), - RemapField::Rotation => p.rotation, - RemapField::AngularSpeed => p.angular_velocity, - RemapField::Color => self.input_component.reduce( - [ - p.color[0] as f32 / 255.0, - p.color[1] as f32 / 255.0, - p.color[2] as f32 / 255.0, - ], - 3, - ), - RemapField::Position => self.input_component.reduce([p.x, p.y, 0.0], 2), - RemapField::Velocity => self.input_component.reduce([p.vx, p.vy, 0.0], 2), - } - } - - /// The shaped `[0,1]` value to lerp into `[out_min, out_max]`. - /// `axis_seed` only matters in noise mode (a different seed per output - /// axis, exactly the original x/y-decorrelation this operator always - /// used); field mode ignores it, which is what gives a scalar field's - /// single value its "broadcast to every output axis" behavior. - fn sample01( - &self, - axis_seed: f64, - p: &Particle, - time: f32, - lifetime_pos: f32, - max_life: f32, - ) -> f32 { - if self.input == RemapField::None || self.transform.is_noise() { - use crate::engine::noise::fbm_noise; - let s = self.input_scale; - let (x, y, t) = ((p.x * s) as f64, (p.y * s) as f64, time as f64); - let octaves = if self.transform == RemapTransform::Fbm { - 4 - } else { - 1 - }; - let n = fbm_noise(x + axis_seed, y, t, octaves); - ((n * 0.5 + 0.5) as f32).clamp(0.0, 1.0) // [-1,1] → [0,1] - } else { - let raw = self.read_input(p, lifetime_pos, max_life); - let range = (self.in_max - self.in_min).abs().max(1e-6); - let normalized = ((raw - self.in_min) / range).clamp(0.0, 1.0); - self.transform.shape(normalized) - } - } - - /// Apply this operator to a 2- or 3-axis vector field (`velocity`/ - /// `position`/`color`), honoring `output_component` (writes only that - /// one axis, leaving the rest of `current` untouched) or every in-range - /// axis when it's `All`. Distinct per-axis noise seeds match the - /// original x/y-decorrelated noise sampling exactly; field-mode inputs - /// naturally broadcast the same value to every written axis instead - /// (`sample01` ignores the seed outside noise mode). - fn apply_vector( - &self, - p: &Particle, - time: f32, - lifetime_pos: f32, - current: [f32; 3], - axes: usize, - ) -> [f32; 3] { - let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t; - let seeds = [0.0f64, 13.7, 27.4]; - let mut out = current; - let apply_axis = |axis: usize, out: &mut [f32; 3]| { - let t = self.sample01(seeds[axis], p, time, lifetime_pos, p.max_life); - let remapped = lerp(self.out_min[axis], self.out_max[axis], t); - out[axis] = self.operation.combine(current[axis], remapped); - }; - match self.output_component { - RemapComponent::X => apply_axis(0, &mut out), - RemapComponent::Y if axes >= 2 => apply_axis(1, &mut out), - RemapComponent::Z if axes >= 3 => apply_axis(2, &mut out), - _ => { - for axis in 0..axes { - apply_axis(axis, &mut out); - } - } - } - out - } -} - -struct EmitterState { - id: u32, - rate: f32, - origin: [f32; 3], - directions: [f32; 3], - sign: [f32; 3], - /// True for `sphererandom` (annulus/shell spawn), false for box. - is_sphere: bool, - /// Spawn-offset range: sphere radius in `[0]` (min/max annulus), or the - /// box emitter's per-axis half-extents. CParticle.cpp spawns particles - /// randomly WITHIN this volume, scaled per-axis by `directions` — - /// distancemax is a POSITION range, never a velocity. - distance_min: [f32; 3], - distance_max: [f32; 3], - /// Optional emitter-driven radial speed (`speedmin`/`speedmax` JSON - /// fields — distinct from distance!). 0 = the reference's default: - /// emitters set no velocity, initializers do. - speed_min: f32, - speed_max: f32, - accumulator: f32, - /// `instantaneous` one-shot burst count, emitted once when the emitter - /// activates; `burst_done` tracks that it's fired. - burst: u32, - burst_done: bool, - /// `duration`: emitter stops after `start_time + duration` (None = forever). - duration: Option, - /// Set when the emitter has `audioprocessing*` — its rate follows the - /// audio spectrum. - audio: Option, -} - -impl ParticleSystem { - /// Build a running particle system for one scene object. - /// - /// `spawn_center` is the object's own scene position, already converted to - /// canvas pixel coordinates (see `render.rs`'s WE-origin-to-pixel - /// conversion) — a WE emitter's own `origin` field is a *local* offset - /// from the object's position, not an absolute canvas coordinate. - pub fn from_config( - config: &ParticleConfig, - spawn_center: [f32; 2], - overrides: Option<&InstanceOverride>, - ) -> Self { - let emitters = config - .emitter - .iter() - .map(|e| { - let local_origin = parse_f32_vec3(e.origin.as_deref()); - // Reference default (1, 1, 0) — a zero default would scale - // every spawn offset to nothing for emitters that omit the - // field, collapsing them to a point at the origin. - let directions = e - .directions - .as_deref() - .map(|d| parse_f32_vec3(Some(d))) - .unwrap_or([1.0, 1.0, 0.0]); - let sign = parse_f32_vec3(e.sign.as_deref()); - let is_sphere = e.name == "sphererandom"; - - // Sphere radii are scalars, box extents "x y z" vectors — - // accept either shape for both (splat scalars). - let dist = |v: &Option, default: f32| -> [f32; 3] { - v.as_ref() - .and_then(|v| { - value_as_vec3(v).or_else(|| value_as_f32(v).map(|f| [f, f, f])) - }) - .unwrap_or([default, default, default]) - }; - let distance_min = dist(&e.distancemin, 0.0); - // Reference default 256 for both emitter kinds. - let distance_max = dist(&e.distancemax, 256.0); - let speed_min = e.speedmin.as_ref().and_then(value_as_f32).unwrap_or(0.0); - let speed_max = e.speedmax.as_ref().and_then(value_as_f32).unwrap_or(0.0); - - EmitterState { - id: e.id.unwrap_or(0), - rate: e.rate.unwrap_or(10.0) as f32, - origin: [ - spawn_center[0] + local_origin[0], - // The emitter's local origin is authored y-up - // (CParticle.cpp negates it in both createBoxEmitter - // and createSphereEmitter): "0 768 0" means 768 - // ABOVE the object — e.g. rainperspective's spawn - // band sits above the frame so drops fall through - // it, not 768 below (which pushed the whole band - // off-screen on 1080p scenes and rain never showed). - spawn_center[1] - local_origin[1], - local_origin[2], - ], - directions, - sign, - is_sphere, - distance_min, - distance_max, - speed_min, - speed_max, - accumulator: 0.0, - burst: e.instantaneous.unwrap_or(0.0).max(0.0) as u32, - burst_done: false, - duration: e.duration.map(|d| d as f32), - audio: AudioResponse::from_emitter(e), - } - }) - .collect(); - - // `instanceoverride.count` scales the pool before the default kicks - // in (CParticle.cpp: `adjustedMaxCount = maxCount * countMultiplier; - // maxParticles = adjusted > 0 ? adjusted : DEFAULT`). - let count_mult = overrides.and_then(|o| o.count).unwrap_or(1.0); - let max_count = match config.maxcount { - Some(n) if n > 0 => match (n as f64 * count_mult) as usize { - 0 => 500, - adjusted => adjusted, - }, - _ => 500, - }; - - // Initializer-absent defaults are the reference's exact spawn values - // (CParticle.cpp: `p.lifetime = 1.0`, `p.size = 20.0` before - // initializers run) — size passes through the sizerandom /2, so the - // range default is 40. - let (life_min, life_max) = - scalar_range_from_initializers(&config.initializer, "lifetimerandom", 1.0, 1.0); - let (size_min, size_max) = - scalar_range_from_initializers(&config.initializer, "sizerandom", 40.0, 40.0); - let life_exp = initializer_exponent(&config.initializer, "lifetimerandom"); - let size_exp = initializer_exponent(&config.initializer, "sizerandom"); - let (alpha_min, alpha_max) = - scalar_range_from_initializers(&config.initializer, "alpharandom", 1.0, 1.0); - // Particle vectors are authored y-up; this simulation is y-down, so - // y flips on the way in (CParticle.cpp does the same for velocity, - // gravity and turbulent directions, as it does for emitter origins). - let flip_y = |v: [f32; 3]| [v[0], -v[1], v[2]]; - let velocity_range = vec3_range_from_initializers( - &config.initializer, - "velocityrandom", - [-32.0; 3], - [32.0; 3], - ); - let (velocity_min, velocity_max) = match velocity_range { - Some((min, max)) => (Some(flip_y(min)), Some(flip_y(max))), - None => (None, None), - }; - let (color_min, color_max) = - vec3_range_from_initializers(&config.initializer, "colorrandom", [0.0; 3], [255.0; 3]) - .unwrap_or(([255.0; 3], [255.0; 3])); - - let movement_op = config.operator.iter().find(|op| op.name == "movement"); - let gravity = movement_op - .and_then(|op| op.gravity.as_deref()) - .map(|s| parse_f32_vec3(Some(s))) - .map(|v| [v[0], -v[1]]) - .unwrap_or([0.0, 0.0]); - // `movement.drag`: per-second velocity decay (CParticle.cpp - // createMovementOperator) — without it, operators that keep adding - // velocity every frame (turbulence, vortex) grow unbounded instead - // of settling into the drifting equilibrium real presets tune for. - let movement_drag = movement_op.and_then(|op| op.drag).unwrap_or(0.0) as f32; - - let color_override = overrides - .and_then(|o| o.color.as_deref()) - .map(|s| parse_f32_vec3(Some(s))) - .map(|v| [v[0] as u8, v[1] as u8, v[2] as u8]); - - let alphafade = config - .operator - .iter() - .find(|op| op.name == "alphafade") - .map(|op| { - ( - op.fadeintime.unwrap_or(0.0) as f32, - op.fadeouttime.unwrap_or(1.0) as f32, - ) - }); - let fade_range_op = |name: &str| -> Option<(f32, f32, f32, f32)> { - config.operator.iter().find(|op| op.name == name).map(|op| { - ( - op.starttime.unwrap_or(0.0) as f32, - op.endtime.unwrap_or(1.0) as f32, - op.startvalue.as_ref().and_then(value_as_f32).unwrap_or(0.0), - op.endvalue.as_ref().and_then(value_as_f32).unwrap_or(1.0), - ) - }) - }; - let sizechange = fade_range_op("sizechange"); - let alphachange = fade_range_op("alphachange"); - let colorchange = config - .operator - .iter() - .find(|op| op.name == "colorchange") - .map(|op| { - ( - op.starttime.unwrap_or(0.0) as f32, - op.endtime.unwrap_or(1.0) as f32, - op.startvalue - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([1.0; 3]), - op.endvalue - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([1.0; 3]), - ) - }); - let oscillate_op = |name: &str| -> Option { - config - .operator - .iter() - .find(|op| op.name == name) - .map(OscillateParams::from_operator) - }; - let oscillate_alpha = oscillate_op("oscillatealpha"); - let oscillate_size = oscillate_op("oscillatesize"); - let oscillate_position = oscillate_op("oscillateposition"); - - // Positional indexing (declaration order), not `id`-keyed slot - // placement — real presets declare control points in id order, and - // consumers (e.g. `controlpointattract`) reference them by a plain - // array index anyway. - let control_points: Vec<[f32; 3]> = config - .controlpoint - .iter() - .map(|cp| { - let mut offset = cp - .offset - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([0.0; 3]); - // `angles.z` rotates the offset about the object origin. - if let Some(az) = cp - .angles - .as_ref() - .and_then(value_as_vec3) - .map(|a| a[2]) - .filter(|z| z.abs() > 1e-6) - { - let (s, c) = az.sin_cos(); - offset = [ - offset[0] * c - offset[1] * s, - offset[0] * s + offset[1] * c, - offset[2], - ]; - } - [ - spawn_center[0] + offset[0], - spawn_center[1] + offset[1], - offset[2], - ] - }) - .collect(); - - let control_point_attract = config - .operator - .iter() - .find(|op| op.name == "controlpointattract") - .map(|op| { - let idx = op.controlpoint.unwrap_or(0).max(0) as usize; - let origin = op - .origin - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([0.0; 3]); - let scale = op.scale.unwrap_or(100.0) as f32; - let threshold = op.threshold.unwrap_or(1000.0) as f32; - (idx, origin, scale, threshold) - }); - - let rope_renderer = config.renderer.iter().find(|r| { - r.get("name") - .and_then(|n| n.as_str()) - .map(|n| n == "rope" || n == "ropetrail") - .unwrap_or(false) - }); - let rope_mode = rope_renderer.is_some(); - let rope_subdivision = rope_renderer - .and_then(|r| r.get("subdivision")) - .and_then(|v| v.as_u64()) - .unwrap_or(1) - .max(1) as usize; - // Rope UV controls (ObjectParser::parseParticleRenderer defaults: - // uvscale 1, uvscrolling false, uvsmoothing true). `uvscale` divides - // the rope's UV length so values > 1 tile the texture along it. - let json_bool = |v: &serde_json::Value| v.as_bool().or_else(|| v.as_u64().map(|n| n != 0)); - let rope_uv_scale = rope_renderer - .and_then(|r| r.get("uvscale")) - .and_then(|v| v.as_f64()) - .map(|v| v as f32) - .filter(|v| *v > 0.0) - .unwrap_or(1.0); - let rope_uv_scrolling = rope_renderer - .and_then(|r| r.get("uvscrolling")) - .and_then(json_bool) - .unwrap_or(false); - let rope_uv_smoothing = rope_renderer - .and_then(|r| r.get("uvsmoothing")) - .and_then(json_bool) - .unwrap_or(true); - - // `spritetrail` renderer: each particle draws as a quad stretched - // along its velocity by `clamp(|v| * length, minlength, maxlength)` - // (genericparticle.vert's TRAILRENDERER `ComputeParticleTrailTangents`; - // parser defaults from ObjectParser::parseParticleRenderer). - let sprite_trail = config - .renderer - .iter() - .find(|r| r.get("name").and_then(|n| n.as_str()) == Some("spritetrail")) - .map(|r| { - let f = |key: &str, default: f32| -> f32 { - r.get(key) - .and_then(|v| v.as_f64()) - .map(|v| v as f32) - .unwrap_or(default) - }; - SpriteTrailParams { - length: f("length", 0.05), - max_length: f("maxlength", 10.0), - min_length: f("minlength", 0.0), - } - }); - - // `angularvelocityrandom`'s min/max are vec3 (reference default - // `(0,0,-5)`/`(0,0,5)`) — we only model the z-axis (screen-space) - // component, matching how `rotation.z` is the only axis our flat - // 2D renderer can express. - let (angular_velocity_min, angular_velocity_max) = config - .initializer - .iter() - .find(|init| init.name == "angularvelocityrandom") - .map(|init| { - let min = init - .min - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([0.0, 0.0, -5.0]); - let max = init - .max - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([0.0, 0.0, 5.0]); - (min[2], max[2]) - }) - .unwrap_or((0.0, 0.0)); - - let angular_op = config - .operator - .iter() - .find(|op| op.name == "angularmovement"); - let angular_drag = angular_op.and_then(|op| op.drag).unwrap_or(0.0) as f32; - let angular_force = angular_op - .and_then(|op| op.force.as_ref()) - .and_then(value_as_vec3) - .map(|v| v[2]) - .unwrap_or(0.0); - - // `rotationrandom`: z component of the random vec3 (our renderer is - // flat 2D, same reduction as `angularvelocityrandom`). Reference - // defaults: min (0,0,0), max (0,0,2pi). - let rotation_range = config - .initializer - .iter() - .find(|init| init.name == "rotationrandom") - .map(|init| { - let min = init - .min - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([0.0; 3]); - let max = init.max.as_ref().and_then(value_as_vec3).unwrap_or([ - 0.0, - 0.0, - std::f32::consts::TAU, - ]); - (min[2], max[2]) - }); - - let turbulent_velocity = config - .initializer - .iter() - .filter(|init| init.name == "turbulentvelocityrandom") - .map(|init| TurbulentVelocityParams { - speed_min: init - .speedmin - .as_ref() - .and_then(value_as_f32) - .unwrap_or(100.0), - speed_max: init - .speedmax - .as_ref() - .and_then(value_as_f32) - .unwrap_or(250.0), - offset: init.offset.unwrap_or(0.0) as f32, - scale: init.scale.unwrap_or(1.0) as f32, - forward: init - .forward - .as_ref() - .and_then(value_as_vec3) - .map(flip_y) - .unwrap_or([0.0, -1.0, 0.0]), - timescale: init.timescale.unwrap_or(1.0) as f32, - phase_min: init.phasemin.unwrap_or(0.0) as f32, - phase_max: init.phasemax.unwrap_or(0.1) as f32, - right: init - .right - .as_ref() - .and_then(value_as_vec3) - .map(flip_y) - .unwrap_or([0.0, 0.0, 1.0]), - }) - .collect(); - - // `turbulence`: phase and speed are randomized once per operator - // instance (the reference draws them in the factory, not per - // particle or per frame). - let turbulence = config - .operator - .iter() - .find(|op| op.name == "turbulence") - .map(|op| { - let phase_min = op.phasemin.unwrap_or(0.0) as f32; - let phase_max = op.phasemax.unwrap_or(0.0) as f32; - let speed_min = op.speedmin.unwrap_or(500.0) as f32; - let speed_max = op.speedmax.unwrap_or(1000.0) as f32; - TurbulenceParams { - noise_scale: op.scale.unwrap_or(0.005) as f32 * 2.0, - timescale: op.timescale.unwrap_or(0.01) as f32, - mask: op - .mask - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([1.0, 1.0, 0.0]), - phase: phase_min + fastrand::f32() * (phase_max - phase_min).max(0.0), - speed: speed_min + fastrand::f32() * (speed_max - speed_min).max(0.0), - } - }); - - let map_sequence = config - .initializer - .iter() - .find(|init| init.name == "mapsequencearoundcontrolpoint") - .map(|init| MapSequenceParams { - control_point: init.controlpoint.unwrap_or(0).max(0) as usize, - count: (init.count.unwrap_or(1.0) as u32).max(1), - speed_min: init - .speedmin - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([0.0; 3]), - speed_max: init - .speedmax - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([100.0; 3]), - }); - - let map_seq_between = config - .initializer - .iter() - .find(|init| init.name == "mapsequencebetweencontrolpoints") - .map(|init| MapSequenceBetweenParams { - count: (init.count.unwrap_or(2.0) as u32).max(2), - mirror: init.limitbehavior.as_deref() == Some("mirror"), - }); - - let vortex = config - .operator - .iter() - .find(|op| op.name == "vortex" || op.name == "vortex_v2") - .map(|op| { - let flags = op.flags.as_ref().and_then(|v| v.as_i64()).unwrap_or(0); - VortexParams { - control_point: op.controlpoint.unwrap_or(0), - infinite_axis: flags & 1 != 0, - maintain_distance: flags & 2 != 0, - ring_shape: flags & 4 != 0, - axis: op - .axis - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([0.0, 0.0, 1.0]), - offset: op - .offset - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([0.0; 3]), - distance_inner: op.distanceinner.unwrap_or(500.0) as f32, - distance_outer: op.distanceouter.unwrap_or(650.0) as f32, - speed_inner: op.speedinner.unwrap_or(2500.0) as f32, - speed_outer: op.speedouter.unwrap_or(0.0) as f32, - center_force: op.centerforce.unwrap_or(1.0) as f32, - ring_radius: op.ringradius.unwrap_or(300.0) as f32, - ring_width: op.ringwidth.unwrap_or(50.0) as f32, - ring_pull_distance: op.ringpulldistance.unwrap_or(50.0) as f32, - ring_pull_force: op.ringpullforce.unwrap_or(10.0) as f32, - } - }); - - let remap: Vec = config - .operator - .iter() - .filter(|op| op.name == "remapvalue") - .map(|op| { - let range = |v: &Option, d: f32| { - v.as_ref() - .and_then(value_as_vec3) - .or_else(|| v.as_ref().and_then(value_as_f32).map(|s| [s, s, s])) - .unwrap_or([d, d, d]) - }; - let scalar = |v: &Option, d: f32| { - v.as_ref().and_then(value_as_f32).unwrap_or(d) - }; - let input = RemapField::parse(op.input.as_deref()); - // No `transformfunction` key at all: noise-generator default - // when there's no `input` field either (the classic, - // real-content-validated case, `rain_screen.json`); - // passthrough when an `input` field IS given, so setting - // `input` alone uses the field's value directly instead of - // silently overriding it with noise. Confirmed against the - // retail binary's own `alphafade`-preset builder - // (Follow-up (x)), which pairs `input=lifetimefraction` with - // an *explicit* `transformfunction=none` — this default - // reproduces that pairing even when a wallpaper's own config - // omits the (redundant) explicit "none". - let transform = match op.transformfunction.as_deref() { - Some(s) => RemapTransform::parse(s), - None if input == RemapField::None => RemapTransform::Simplex, - None => RemapTransform::None, - }; - RemapParams { - input, - input_component: RemapComponent::parse(op.inputcomponent.as_deref()), - output: RemapOutput::parse(op.output.as_deref()), - output_component: RemapComponent::parse(op.outputcomponent.as_deref()), - operation: RemapOp::parse(op.operation.as_deref()), - transform, - in_min: scalar(&op.inputrangemin, 0.0), - in_max: scalar(&op.inputrangemax, 1.0), - out_min: range(&op.outputrangemin, 0.0), - out_max: range(&op.outputrangemax, 0.0), - input_scale: op.transforminputscale.unwrap_or(1.0) as f32, - } - }) - .collect(); - - // `collisionsphere`/`collisionbox`/`collisionbounds`/`collisionquad`/ - // `collisionplane` — see the `Operator`/`CollisionParams` field docs - // for the "no C++ reference" caveat. Anchored at `controlpoint` (+ - // `offset`), same static-resolve-once convention as - // `controlpointattract`/`vortex`. - const COLLISION_NAMES: [&str; 5] = [ - "collisionsphere", - "collisionbox", - "collisionbounds", - "collisionquad", - "collisionplane", - ]; - let collisions: Vec = config - .operator - .iter() - .filter(|op| COLLISION_NAMES.contains(&op.name.as_str())) - .map(|op| { - let cp_idx = op.controlpoint.unwrap_or(0).max(0) as usize; - let cp = control_points.get(cp_idx).copied().unwrap_or([0.0; 3]); - let offset = op - .offset - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([0.0; 3]); - let center = [cp[0] + offset[0], cp[1] + offset[1]]; - let shape = match op.name.as_str() { - "collisionsphere" => CollisionShape::Sphere { - radius: op.radius.unwrap_or(100.0) as f32, - }, - "collisionplane" => { - // Default normal (0,1) is "up" in our y-down space - // (screen-up); `angles.z` rotates it, same convention - // `mapsequencearoundcontrolpoint`-adjacent offset - // rotation already uses elsewhere in this module. - let az = op - .angles - .as_ref() - .and_then(value_as_vec3) - .map(|a| a[2]) - .unwrap_or(0.0); - let (s, c) = az.sin_cos(); - CollisionShape::Plane { normal: [-s, c] } - } - // collisionbox/collisionbounds/collisionquad - _ => { - let size = op - .size - .as_ref() - .and_then(value_as_vec3) - .unwrap_or([200.0, 200.0, 0.0]); - CollisionShape::Box { - half_extent: [size[0] * 0.5, size[1] * 0.5], - } - } - }; - CollisionParams { - shape, - center, - behavior: CollisionBehavior::parse(op.collisionbehavior.as_deref()), - bounce_factor: op.bouncefactor.unwrap_or(1.0) as f32, - } - }) - .collect(); - - // `boids`: standard separation/alignment/cohesion flocking. - let boids = config - .operator - .iter() - .find(|op| op.name == "boids") - .map(|op| BoidsParams { - separation_threshold: op.separationthreshold.unwrap_or(50.0) as f32, - neighbor_threshold: op.neighborthreshold.unwrap_or(150.0) as f32, - max_speed: op.maxspeed.unwrap_or(500.0) as f32, - separation_factor: op.separationfactor.unwrap_or(1.0) as f32, - alignment_factor: op.alignmentfactor.unwrap_or(1.0) as f32, - cohesion_factor: op.cohesionfactor.unwrap_or(1.0) as f32, - variable_strength: op.variablestrength.unwrap_or(0.0) != 0.0, - }); - - Self { - particles: Vec::with_capacity(max_count), - emitters, - max_count, - life_min, - life_max, - size_min, - size_max, - life_exp, - size_exp, - alpha_min, - alpha_max, - velocity_min, - velocity_max, - color_min, - color_max, - gravity, - movement_drag, - alphafade, - sizechange, - alphachange, - colorchange, - oscillate_alpha, - oscillate_size, - oscillate_position, - alpha_mult: overrides.and_then(|o| o.alpha).unwrap_or(1.0) as f32, - lifetime_mult: overrides.and_then(|o| o.lifetime).unwrap_or(1.0) as f32, - colorn_mult: overrides - .and_then(|o| o.colorn.as_ref()) - .and_then(value_as_vec3_pub) - .unwrap_or([1.0, 1.0, 1.0]), - size_mult: overrides.and_then(|o| o.size).unwrap_or(1.0) as f32, - speed_mult: overrides.and_then(|o| o.speed).unwrap_or(1.0) as f32, - rate_mult: overrides.and_then(|o| o.rate).unwrap_or(1.0) as f32, - color_override, - control_points, - control_point_attract, - rope_mode, - rope_subdivision, - rope_uv_scale, - rope_uv_scrolling, - rope_uv_smoothing, - shell_3d: config.flags.unwrap_or(0) & 4 != 0, - next_particle_id: 0, - children: Vec::new(), - angular_velocity_min, - angular_velocity_max, - angular_force, - angular_drag, - sprite_frame_count: 0, - sprite_duration: 0.0, - animation_mode: AnimationMode::from_config(config.animationmode.as_deref()), - sequence_multiplier: config - .sequencemultiplier - .map(|v| v as f32) - .filter(|v| *v > 0.0) - .unwrap_or(1.0), - rotation_range, - sprite_trail, - map_sequence, - map_sequence_index: 0, - map_seq_between, - map_seq_between_index: 0, - turbulent_velocity, - turbulence, - vortex, - remap, - collisions, - boids, - time: 0.0, - start_time: config.starttime.unwrap_or(0.0).max(0.0) as f32, - scene_force: [0.0, 0.0], - audio_bands: [0.0; 16], - } - } - - /// Change one `instanceoverride` value live (SceneScript - /// `thisLayer.instance. = …`). `count` (the pool size) only applies - /// at load and is ignored here. - pub fn set_instance_value(&mut self, key: &str, value: &serde_json::Value) { - let num = || match value { - serde_json::Value::Number(n) => n.as_f64().map(|v| v as f32), - serde_json::Value::String(s) => s.trim().parse().ok(), - _ => None, - }; - let vec3 = || -> Option<[f32; 3]> { - match value { - serde_json::Value::Array(a) if a.len() >= 3 => { - Some(std::array::from_fn(|i| a[i].as_f64().unwrap_or(0.0) as f32)) - } - other => value_as_vec3_pub(other), - } - }; - match key { - "alpha" => self.alpha_mult = num().unwrap_or(self.alpha_mult), - "rate" => self.rate_mult = num().unwrap_or(self.rate_mult).max(0.0), - "size" => self.size_mult = num().unwrap_or(self.size_mult), - "speed" => self.speed_mult = num().unwrap_or(self.speed_mult), - "lifetime" => self.lifetime_mult = num().unwrap_or(self.lifetime_mult), - "colorn" => self.colorn_mult = vec3().unwrap_or(self.colorn_mult), - "color" => { - if let Some(c) = vec3() { - self.color_override = Some(c.map(|v| v.clamp(0.0, 255.0) as u8)); - } - } - _ => {} - } - } - - /// Apply the scene-global constant force (`general.gravity*`/`wind*`). - pub fn set_scene_force(&mut self, force: [f32; 2]) { - self.scene_force = force; - } - - /// Set the 16-band spectrum (~[0,1] per band) driving audio-reactive emitters. - pub fn set_audio_bands(&mut self, bands: [f32; 16]) { - self.audio_bands = bands; - } - - /// Tell the system its resolved sprite's frame count/loop duration, so - /// `step()` can advance each particle's `frame` — called once by the - /// caller right after resolving the sprite (this module itself never - /// loads textures). A no-op frame count of 0/1 leaves every particle's - /// `frame` at its spawn sentinel, matching `render_onto`'s "always frame - /// 0" behavior for non-animated sprites. - pub fn set_sprite_frames(&mut self, frame_count: usize, duration: f32) { - self.sprite_frame_count = frame_count; - self.sprite_duration = duration; - } - - pub fn step(&mut self, dt: f32) { - self.time += dt; - // `boids`: needs every particle's position/velocity as of the START - // of this frame (not yet mutated by this frame's own movement), so - // it's computed as a separate read-only pass before the main - // per-particle loop mutates anything. - let boids_accel = self - .boids - .as_ref() - .map(|b| compute_boids_accel(&self.particles, b)); - for (particle_index, p) in self.particles.iter_mut().enumerate() { - // `movement` operator (CParticle.cpp createMovementOperator): - // position integrates the CURRENT velocity first, and only then - // do this frame's forces modify velocity (for the next frame) — - // gravity scaled by the instance speed override, then drag decay - // clamped so `drag*dt > 1` stops the particle instead of - // reversing it. - p.x += p.vx * dt; - p.y += p.vy * dt; - p.vx += (self.gravity[0] * self.speed_mult + self.scene_force[0]) * dt; - p.vy += (self.gravity[1] * self.speed_mult + self.scene_force[1]) * dt; - let drag_factor = (1.0 - self.movement_drag * dt).max(0.0); - p.vx *= drag_factor; - p.vy *= drag_factor; - - // `turbulence` operator (CParticle.cpp createTurbulenceOperator): - // normalized curl-noise direction, sampled at a time-scrolled, - // scaled particle position, masked per axis, added to velocity. - if let Some(t) = self.turbulence { - if t.speed > 0.0001 { - let noise_pos = [ - (p.x + t.phase + t.timescale * self.time) * t.noise_scale, - p.y * t.noise_scale, - 0.0, - ]; - let curl = crate::engine::noise::curl_noise(noise_pos); - let len = vec3_length(curl); - if len > 0.0001 { - let s = t.speed / len * dt * self.speed_mult; - p.vx += curl[0] * t.mask[0] * s; - p.vy += curl[1] * t.mask[1] * s; - } - } - } - - // `vortex` operator (CParticle.cpp createVortexOperator). - if let Some(v) = self.vortex { - let center_base = self - .control_points - .get(v.control_point.max(0) as usize) - .copied() - .unwrap_or([0.0; 3]); - let center = [ - center_base[0] + v.offset[0], - center_base[1] + v.offset[1], - center_base[2] + v.offset[2], - ]; - // The reference computes tangents in its y-up sim space and - // projects y-up; our space is y-down (a reflection, det -1), - // under which cross products flip sign: mirror the axis's y - // and negate the resulting tangent to reproduce the same - // on-screen spin direction. - let axis = vec3_normalize_or([v.axis[0], -v.axis[1], v.axis[2]], [0.0, 0.0, 1.0]); - let to_particle = [p.x - center[0], p.y - center[1], -center[2]]; - - // Infinite axis: cylinder shape (project out the axis - // component); else full 3D distance (sphere shape). - let radial = if v.infinite_axis { - let axial = vec3_dot(to_particle, axis); - [ - to_particle[0] - axis[0] * axial, - to_particle[1] - axis[1] * axial, - to_particle[2] - axis[2] * axial, - ] - } else { - to_particle - }; - let distance = vec3_length(radial); - - let tangent = vec3_cross(axis, radial); - let tangent_len = vec3_length(tangent); - if tangent_len > 0.001 { - // Negated: see the reflection note on `axis` above. - let tangent = [ - -tangent[0] / tangent_len, - -tangent[1] / tangent_len, - -tangent[2] / tangent_len, - ]; - - let mut speed = 0.0; - let mut radial_force = [0.0f32; 3]; - if v.ring_shape { - let ring_inner = v.ring_radius - v.ring_width * 0.5; - let ring_outer = v.ring_radius + v.ring_width * 0.5; - if distance < ring_inner { - // Hollow center: no spin. - } else if distance <= ring_outer { - let t = (distance - ring_inner) / v.ring_width; - speed = v.speed_inner + (v.speed_outer - v.speed_inner) * t; - } else if distance <= ring_outer + v.ring_pull_distance { - let pull_t = (distance - ring_outer) / v.ring_pull_distance; - speed = v.speed_outer * (1.0 - pull_t); - if distance > 0.001 { - let toward = vec3_normalize_or(radial, [0.0; 3]); - let f = -v.ring_pull_force * pull_t; - radial_force = [toward[0] * f, toward[1] * f, toward[2] * f]; - } - } - } else { - let dis_mid = v.distance_outer - v.distance_inner + 0.1; - speed = if dis_mid < 0.0 || distance < v.distance_inner { - v.speed_inner - } else if distance > v.distance_outer { - v.speed_outer - } else { - let t = (distance - v.distance_inner) / dis_mid; - v.speed_inner + (v.speed_outer - v.speed_inner) * t - }; - } - - let k = dt * self.speed_mult; - p.vx += (tangent[0] * speed + radial_force[0]) * k; - p.vy += (tangent[1] * speed + radial_force[1]) * k; - if v.maintain_distance && distance > 0.001 { - let toward = vec3_normalize_or(radial, [0.0; 3]); - p.vx -= toward[0] * v.center_force * k; - p.vy -= toward[1] * v.center_force * k; - } - } - } - - // `boids` operator: apply this particle's precomputed - // separation/alignment/cohesion acceleration, then cap speed. - if let (Some(accel), Some(b)) = (&boids_accel, &self.boids) { - let [ax, ay] = accel[particle_index]; - let strength = if b.variable_strength { - // Deterministic per-particle jitter in [0.5, 1.5] from a - // cheap integer hash of the particle's stable id — avoids - // adding a new per-particle RNG-state field for a value - // that only needs to be stable, not independently random - // each frame. - let h = p.id.wrapping_mul(2654435761); - 0.5 + ((h >> 8) & 0xFFFF) as f32 / 65535.0 - } else { - 1.0 - }; - let k = dt * self.speed_mult * strength; - p.vx += ax * k; - p.vy += ay * k; - if b.max_speed > 0.0 { - let speed2 = p.vx * p.vx + p.vy * p.vy; - if speed2 > b.max_speed * b.max_speed { - let s = b.max_speed / speed2.sqrt(); - p.vx *= s; - p.vy *= s; - } - } - } - - // `remapvalue` targeting `velocity`/`speed`: runs here (before - // this frame's velocity is used for next frame's position - // integration), matching the original, real-content-validated - // timing exactly. Fields targeting other outputs run later, - // after alpha/size/color are computed (see below) so - // `remapvalue` can override them. - let lifetime_pos_pre = if p.max_life > 0.0 { - ((p.max_life - p.life) / p.max_life).clamp(0.0, 1.0) - } else { - 1.0 - }; - for r in &self.remap { - match r.output { - RemapOutput::Velocity => { - let [vx, vy, _] = - r.apply_vector(p, self.time, lifetime_pos_pre, [p.vx, p.vy, 0.0], 2); - p.vx = vx; - p.vy = vy; - } - RemapOutput::Speed => { - let t = r.sample01(0.0, p, self.time, lifetime_pos_pre, p.max_life); - let target = r.out_min[0] + (r.out_max[0] - r.out_min[0]) * t; - let mag = (p.vx * p.vx + p.vy * p.vy).sqrt(); - let sp = r.operation.combine(mag, target); - if mag > 1e-4 { - let k = sp / mag; - p.vx *= k; - p.vy *= k; - } - } - _ => {} - } - } - - // Position-oscillate integrates a velocity (derivative of - // scale*cos(w*t+phase)) into position each frame, matching - // CParticle.cpp's createOscillatePositionOperator exactly. - if let Some(osc) = &p.osc_pos { - let age = p.max_life - p.life; - for axis in 0..2 { - let w = osc.frequency[axis]; - let d = -osc.scale[axis] * w * (w * age + osc.phase[axis]).sin() * dt; - if axis == 0 { - p.x += d; - } else { - p.y += d; - } - } - } - - if let Some((idx, origin, scale, threshold)) = self.control_point_attract { - if let Some(cp) = self.control_points.get(idx) { - let center = [cp[0] + origin[0], cp[1] + origin[1]]; - let to_center = [center[0] - p.x, center[1] - p.y]; - let distance = - (to_center[0] * to_center[0] + to_center[1] * to_center[1]).sqrt(); - let radius = threshold / 2.0; - if distance > 0.001 && distance < radius { - let force = scale * dt * self.speed_mult; - p.vx += (to_center[0] / distance) * force; - p.vy += (to_center[1] / distance) * force; - } - } - } - - // `collisionsphere`/`collisionbox`/`collisionbounds`/ - // `collisionquad`/`collisionplane`: checked last among the - // motion-affecting operators, against this frame's final - // position, so a collider reacts to where the particle actually - // ended up (matching how a physics response is conventionally - // the last word on a frame's motion). - for collider in &self.collisions { - resolve_collision(p, collider); - } - - p.life -= dt; - - // Reference order (CParticle.cpp createAngularMovementOperator): - // integrate rotation from the *current* angular velocity first, - // then accelerate angular velocity, then decay it by drag. - p.rotation += p.angular_velocity * dt; - p.angular_velocity += self.angular_force * dt; - let drag_factor = (1.0 - self.angular_drag * dt).max(0.0); - p.angular_velocity *= drag_factor; - p.rotation = wrap_angle(p.rotation); - - let age = p.max_life - p.life; - let lifetime_pos = if p.max_life > 0.0 { - (age / p.max_life).clamp(0.0, 1.0) - } else { - 1.0 - }; - - // Sprite-sheet frame advance (CParticle.cpp's "Update animation - // frames"): only meaningful once the caller has told us the - // resolved sprite actually has more than one frame. - if self.sprite_frame_count > 1 { - let frame_count = self.sprite_frame_count as f32; - match self.animation_mode { - AnimationMode::RandomFrame => { - if p.frame < 0.0 { - p.frame = (fastrand::f32() * frame_count) - .floor() - .min(frame_count - 1.0); - } - } - AnimationMode::Once => { - p.frame = (lifetime_pos * frame_count * self.sequence_multiplier) - .min(frame_count - 1.0); - } - AnimationMode::Loop => { - p.frame = if self.sprite_duration > 0.0 { - let time_in_cycle = - (age * self.sequence_multiplier) % self.sprite_duration; - let cycle_pos = time_in_cycle / self.sprite_duration; - (cycle_pos * frame_count) % frame_count - } else { - (lifetime_pos * frame_count * self.sequence_multiplier) % frame_count - }; - } - } - } - - // Alpha: alphafade takes precedence (its very common fade-in/hold/ - // fade-out shape), then a generic alphachange ramp, else fall back - // to a plain linear death-fade so particles don't hard-cut when a - // config has no alpha-affecting operator at all. - let mut alpha = if let Some((fade_in, fade_out)) = self.alphafade { - let fade = if lifetime_pos <= fade_in { - fade_value(lifetime_pos, 0.0, fade_in, 0.0, 1.0) - } else if lifetime_pos > fade_out { - 1.0 - fade_value(lifetime_pos, fade_out, 1.0, 0.0, 1.0) - } else { - 1.0 - }; - p.initial_alpha * fade - } else if let Some((start, end, sv, ev)) = self.alphachange { - p.initial_alpha * fade_value(lifetime_pos, start, end, sv, ev) - } else { - p.initial_alpha * (p.life / p.max_life).max(0.0) - }; - - let mut size = if let Some((start, end, sv, ev)) = self.sizechange { - p.initial_size * fade_value(lifetime_pos, start, end, sv, ev) - } else { - p.size - }; - - if let Some(osc) = &p.osc_alpha { - let cos_val = ((osc.frequency * age + osc.phase).cos() + 1.0) * 0.5; - let mult = self - .oscillate_alpha - .map(|o| o.scale_min + (o.scale_max - o.scale_min) * cos_val) - .unwrap_or(1.0); - alpha *= mult; - } - if let Some(osc) = &p.osc_size { - let cos_val = ((osc.frequency * age + osc.phase).cos() + 1.0) * 0.5; - let mult = self - .oscillate_size - .map(|o| o.scale_min + (o.scale_max - o.scale_min) * cos_val) - .unwrap_or(1.0); - size *= mult; - } - - if let Some((start, end, start_color, end_color)) = self.colorchange { - let mult = [ - fade_value(lifetime_pos, start, end, start_color[0], end_color[0]), - fade_value(lifetime_pos, start, end, start_color[1], end_color[1]), - fade_value(lifetime_pos, start, end, start_color[2], end_color[2]), - ]; - p.color = [ - (p.initial_color[0] * mult[0] * 255.0).clamp(0.0, 255.0) as u8, - (p.initial_color[1] * mult[1] * 255.0).clamp(0.0, 255.0) as u8, - (p.initial_color[2] * mult[2] * 255.0).clamp(0.0, 255.0) as u8, - ]; - } - - // `remapvalue` targeting anything other than velocity/speed - // (handled above, before position integration needs it): runs - // last, after alphafade/sizechange/colorchange/oscillate, so an - // author who explicitly attaches remapvalue to one of these - // fields gets it as the final, overriding value. - for r in &self.remap { - match r.output { - RemapOutput::Velocity | RemapOutput::Speed => {} - RemapOutput::Opacity => { - let t = r.sample01(0.0, p, self.time, lifetime_pos, p.max_life); - let remapped = r.out_min[0] + (r.out_max[0] - r.out_min[0]) * t; - alpha = r.operation.combine(alpha, remapped); - } - RemapOutput::Size => { - let t = r.sample01(0.0, p, self.time, lifetime_pos, p.max_life); - let remapped = r.out_min[0] + (r.out_max[0] - r.out_min[0]) * t; - size = r.operation.combine(size, remapped); - } - RemapOutput::Rotation => { - let t = r.sample01(0.0, p, self.time, lifetime_pos, p.max_life); - let remapped = r.out_min[0] + (r.out_max[0] - r.out_min[0]) * t; - p.rotation = r.operation.combine(p.rotation, remapped); - } - RemapOutput::AngularSpeed => { - let t = r.sample01(0.0, p, self.time, lifetime_pos, p.max_life); - let remapped = r.out_min[0] + (r.out_max[0] - r.out_min[0]) * t; - p.angular_velocity = r.operation.combine(p.angular_velocity, remapped); - } - RemapOutput::Color => { - let current = [ - p.color[0] as f32 / 255.0, - p.color[1] as f32 / 255.0, - p.color[2] as f32 / 255.0, - ]; - let out = r.apply_vector(p, self.time, lifetime_pos, current, 3); - p.color = [ - (out[0] * 255.0).clamp(0.0, 255.0) as u8, - (out[1] * 255.0).clamp(0.0, 255.0) as u8, - (out[2] * 255.0).clamp(0.0, 255.0) as u8, - ]; - } - RemapOutput::Position => { - let [x, y, _] = - r.apply_vector(p, self.time, lifetime_pos, [p.x, p.y, 0.0], 2); - p.x = x; - p.y = y; - } - } - } - - // `self.alpha_mult` (the instance-override multiplier) is already - // baked into `p.initial_alpha` at spawn, matching the - // reference's `p.alpha = random(min,max) * override` — applying - // it again here would square it whenever an override isn't 1.0. - p.alpha = alpha; - p.size = size; - } - - // Child-system event capture: deaths before the retain, spawns as - // they're pushed below (only bothering when children are attached). - let mut died: Vec<(u64, [f32; 2])> = Vec::new(); - let mut spawned: Vec<(u64, [f32; 2])> = Vec::new(); - if !self.children.is_empty() { - died.extend( - self.particles - .iter() - .filter(|p| p.life <= 0.0) - .map(|p| (p.id, [p.x, p.y])), - ); - } - self.particles.retain(|p| p.life > 0.0); - - // Hold off emission until `starttime` — existing particles still - // simulate and die, the burst just ramps in from zero at start_time. - let emitting = self.time >= self.start_time; - let since_start = self.time - self.start_time; - for emitter in &mut self.emitters { - // Per-emitter `duration` cutoff (after start_time). - let expired = emitter.duration.is_some_and(|d| since_start > d); - if !emitting || expired { - continue; - } - // One-shot burst: seed the accumulator with the full count once, so - // the spawn loop below emits it in this frame (capped by max_count). - if !emitter.burst_done { - emitter.accumulator += emitter.burst as f32; - emitter.burst_done = true; - } - let audio_gain = emitter.audio.map_or(1.0, |a| a.gain(&self.audio_bands)); - emitter.accumulator += emitter.rate * self.rate_mult * audio_gain * dt; - while emitter.accumulator >= 1.0 && self.particles.len() < self.max_count { - emitter.accumulator -= 1.0; - // `createLifetimeRandomInitializer`: `p.lifetime = - // random(min, max) * lifetimeOverride`. `exponent` biases the - // uniform sample toward one end before the lerp. - let life = (self.life_min - + fastrand::f32().powf(self.life_exp) - * (self.life_max - self.life_min).max(0.0)) - * self.lifetime_mult; - // Reference halves the sizerandom range (CParticle.cpp: `size = (min + - // t*(max-min)) * override / 2.0`) — the initializer's value is a - // diameter, not a radius. - let size = (self.size_min - + fastrand::f32().powf(self.size_exp) - * (self.size_max - self.size_min).max(0.0)) - * self.size_mult - / 2.0; - - let ox = emitter.origin[0]; - let oy = emitter.origin[1]; - - // Spawn offset — CParticle.cpp's create{Box,Sphere}Emitter: - // a random point WITHIN the emitter volume, scaled per-axis - // by `directions` (the emitter never encodes velocity in - // its distances; rainperspective's 1024 radius is a spawn - // band, which our old model collapsed into a ±15px column - // at the emitter origin while misreading the radius as a - // fallback speed). - let mut offset = if emitter.is_sphere { - let (rmin, rmax) = (emitter.distance_min[0], emitter.distance_max[0]); - if self.shell_3d { - // flags & 4 (perspective): 3D spherical shell, - // cbrt-uniform radius; our 2D sim keeps xy. - let theta = fastrand::f32() * std::f32::consts::TAU; - let cos_t = fastrand::f32() * 2.0 - 1.0; - let sin_t = (1.0 - cos_t * cos_t).sqrt(); - let r = - (rmin.powi(3) + fastrand::f32() * (rmax.powi(3) - rmin.powi(3))).cbrt(); - [sin_t * theta.cos() * r, sin_t * theta.sin() * r] - } else { - // 2D disk/annulus, sqrt-uniform for even area density. - let angle = fastrand::f32() * std::f32::consts::TAU; - let r2 = rmin * rmin + fastrand::f32() * (rmax * rmax - rmin * rmin); - let r = r2.max(0.0).sqrt(); - [angle.cos() * r, angle.sin() * r] - } - } else { - // Box: per-axis distance in [min, max], random sign flip. - let mut o = [0.0f32; 2]; - for (axis, out) in o.iter_mut().enumerate() { - let d = emitter.distance_min[axis] - + fastrand::f32() - * (emitter.distance_max[axis] - emitter.distance_min[axis]); - *out = if fastrand::f32() < 0.5 { -d } else { d }; - } - o - }; - offset[0] *= emitter.directions[0]; - offset[1] *= emitter.directions[1]; - // `sign`: 1 forces positive, -1 negative, 0 leaves both. - for axis in 0..2 { - if emitter.sign[axis] >= 1.0 { - offset[axis] = offset[axis].abs(); - } else if emitter.sign[axis] <= -1.0 { - offset[axis] = -offset[axis].abs(); - } - } - let mut spawn_x = ox + offset[0]; - let mut spawn_y = oy + offset[1]; - - // Velocity: velocityrandom initializer first; else the - // emitter's own optional radial speed (`speedmin`/`speedmax` - // fields, pointing outward from the offset); else zero — - // "Emitter does not set velocity - initializers handle that". - let (mut vx, mut vy) = - if let (Some(min), Some(max)) = (self.velocity_min, self.velocity_max) { - ( - (min[0] + fastrand::f32() * (max[0] - min[0])) * self.speed_mult, - (min[1] + fastrand::f32() * (max[1] - min[1])) * self.speed_mult, - ) - } else if emitter.speed_max > 0.0 || emitter.speed_min != 0.0 { - let len = (offset[0] * offset[0] + offset[1] * offset[1]).sqrt(); - let dir = if len > 0.0 { - [offset[0] / len, offset[1] / len] - } else { - [0.0, 1.0] - }; - let speed = (emitter.speed_min - + fastrand::f32() * (emitter.speed_max - emitter.speed_min)) - * self.speed_mult; - (dir[0] * speed, dir[1] * speed) - } else { - (0.0, 0.0) - }; - - // `mapsequencebetweencontrolpoints`: the Nth spawn sits at - // the Nth slot along the cp0->cp1 segment (mirror = - // ping-pong); velocityrandom still jitters it afterwards, - // which is what shapes the discharge preset's lightning. - if let Some(ms) = self.map_seq_between { - let last = ms.count - 1; - let idx = self.map_seq_between_index; - self.map_seq_between_index = self.map_seq_between_index.wrapping_add(1); - let slot = if ms.mirror { - let period = 2 * last; - let k = idx % period.max(1); - if k <= last { - k - } else { - period - k - } - } else { - idx % ms.count - }; - let t = slot as f32 / last as f32; - if let (Some(a), Some(b)) = - (self.control_points.first(), self.control_points.get(1)) - { - spawn_x = a[0] + (b[0] - a[0]) * t; - spawn_y = a[1] + (b[1] - a[1]) * t; - } - } - - // `mapsequencearoundcontrolpoint` (CParticle.cpp): spawn at - // the control point, launching successive particles at - // evenly-spaced angles around a circle (the initializer - // *assigns* position/velocity, unlike the additive ones). - if let Some(ms) = self.map_sequence { - let angle = - (self.map_sequence_index as f32 / ms.count as f32) * std::f32::consts::TAU; - self.map_sequence_index = (self.map_sequence_index + 1) % ms.count; - if let Some(cp) = self.control_points.get(ms.control_point) { - spawn_x = cp[0]; - spawn_y = cp[1]; - } - let sx = ms.speed_min[0] - + fastrand::f32() * (ms.speed_max[0] - ms.speed_min[0]).max(0.0); - let sy = ms.speed_min[1] - + fastrand::f32() * (ms.speed_max[1] - ms.speed_min[1]).max(0.0); - let (sin, cos) = angle.sin_cos(); - // The reference flips y, then rotates by - // (cos*x + sin*y, -sin*x + cos*y) (column-major - // glm::mat3); with the flip folded in that is the - // conjugate rotation followed by a y flip. - vx = (cos * sx - sin * sy) * self.speed_mult; - vy = -(sin * sx + cos * sy) * self.speed_mult; - } - - // `turbulentvelocityrandom` (CParticle.cpp): curl-noise - // spawn velocity, angle-limited around `forward`, added on - // top of any plain `velocityrandom` contribution. - for &t in &self.turbulent_velocity { - let forward = vec3_normalize_or(t.forward, [0.0, -1.0, 0.0]); - let right = vec3_normalize_or(t.right, [1.0, 0.0, 0.0]); - let speed = - t.speed_min + fastrand::f32() * (t.speed_max - t.speed_min).max(0.0); - let phase = - t.phase_min + fastrand::f32() * (t.phase_max - t.phase_min).max(0.0); - let time_shift = self.time * t.timescale; - let sample = [ - spawn_x * 0.1 + time_shift + phase, - spawn_y * 0.1 + time_shift + phase * 0.7, - time_shift + phase * 1.3, - ]; - let curl = crate::engine::noise::curl_noise(sample); - let mut dir = vec3_normalize_or(curl, forward); - - // `scale` < 2 limits how far the direction may deviate - // from `forward` (normalized angle, max = scale/2); 0 - // means straight along `forward`. - if t.scale <= 0.0 { - dir = forward; - } else if t.scale < 2.0 { - let cos_angle = vec3_dot(dir, forward).clamp(-1.0, 1.0); - let angle = cos_angle.acos() / std::f32::consts::PI; - let max_angle = t.scale / 2.0; - if angle > max_angle { - let axis = vec3_cross(dir, forward); - if vec3_length(axis) > 0.0001 { - let axis = vec3_normalize_or(axis, [0.0, 0.0, 1.0]); - let rot = (angle - max_angle) * std::f32::consts::PI; - dir = vec3_rotate_axis(dir, axis, rot); - } - } - } - - // `offset` tilts the result around `right`. - if t.offset.abs() > 0.0001 { - dir = vec3_rotate_axis(dir, right, -t.offset); - } - - // 2D particles: project onto the XY plane and - // renormalize (the reference's `flags & 4 == 0` branch — - // our renderer is always orthographic/2D). - dir[2] = 0.0; - let dir = vec3_normalize_or(dir, forward); - - vx += dir[0] * speed * self.speed_mult; - vy += dir[1] * speed * self.speed_mult; - } - - // `rotationrandom`: uniform in [min.z, max.z], scaled by the - // instance speed override (the reference multiplies rotation - // by `speedOverride` too, odd as that reads). - let spawn_rotation = self - .rotation_range - .map(|(min, max)| { - (min + fastrand::f32() * (max - min).max(0.0)) * self.speed_mult - }) - .unwrap_or(0.0); - - // `createColorRandomInitializer`: `p.color = randomVec3(min, - // max) * colorOverride` — colorn is a 0-1 per-channel tint. - let color = self.color_override.unwrap_or_else(|| { - [ - (lerp_u8(self.color_min[0], self.color_max[0]) as f32 * self.colorn_mult[0]) - .clamp(0.0, 255.0) as u8, - (lerp_u8(self.color_min[1], self.color_max[1]) as f32 * self.colorn_mult[1]) - .clamp(0.0, 255.0) as u8, - (lerp_u8(self.color_min[2], self.color_max[2]) as f32 * self.colorn_mult[2]) - .clamp(0.0, 255.0) as u8, - ] - }); - - // `alpharandom`: CParticle.cpp's `p.alpha = random(min, max) * - // override` — a plain multiply (unlike sizerandom, not halved). - let spawn_alpha = (self.alpha_min - + fastrand::f32() * (self.alpha_max - self.alpha_min).max(0.0)) - * self.alpha_mult; - - let particle_id = self.next_particle_id; - self.next_particle_id += 1; - if !self.children.is_empty() { - spawned.push((particle_id, [spawn_x, spawn_y])); - } - self.particles.push(Particle { - id: particle_id, - x: spawn_x, - y: spawn_y, - vx, - vy, - life, - max_life: life, - size, - alpha: spawn_alpha, - color, - initial_alpha: spawn_alpha, - initial_size: size, - initial_color: [ - color[0] as f32 / 255.0, - color[1] as f32 / 255.0, - color[2] as f32 / 255.0, - ], - osc_alpha: self.oscillate_alpha.map(|o| o.sample()), - osc_size: self.oscillate_size.map(|o| o.sample()), - osc_pos: self.oscillate_position.map(|o| { - let a = o.sample(); - let b = o.sample(); - OscillatorPos { - frequency: [a.frequency, b.frequency], - scale: [a.scale, b.scale], - phase: [a.phase, b.phase], - } - }), - rotation: spawn_rotation, - angular_velocity: self.angular_velocity_min - + fastrand::f32() * (self.angular_velocity_max - self.angular_velocity_min), - frame: -1.0, - }); - } - } - - if !self.children.is_empty() { - self.step_children(dt, &spawned, &died); - } - } - - /// Attaches a resolved child preset (see [`ChildRef`]). `static` - /// children get their single persistent instance immediately, at the - /// same spawn center the parent was built with. - pub fn add_child( - &mut self, - config: ParticleConfig, - sprite: Option, - additive: bool, - child_ref: &ChildRef, - spawn_center: [f32; 2], - ) { - let child_type = match child_ref.child_type.as_deref() { - Some("eventfollow") => ChildType::Follow, - Some("eventspawn") => ChildType::Spawn, - Some("eventdeath") => ChildType::Death, - // Absent or "static": a continuously-running sub-system. - _ => ChildType::Static, - }; - let mut child = ChildSystem { - child_type, - probability: child_ref.probability.unwrap_or(1.0) as f32, - max_instances: child_ref.maxcount.unwrap_or(16).max(1) as usize, - config, - sprite, - additive, - instances: Vec::new(), - }; - if child_type == ChildType::Static { - let mut system = ParticleSystem::from_config(&child.config, spawn_center, None); - if let Some(s) = &child.sprite { - system.set_sprite_frames(s.frames.len(), s.duration); - } - child.instances.push(ChildInstance { - parent_id: None, - last_center: spawn_center, - emit_until: None, - persistent: true, - system, - }); - } - self.children.push(child); - } - - /// Spawns/updates/retires child instances for this frame's parent - /// spawn/death events. No counterpart in the C++ reference (it ignores - /// `children` entirely); semantics derived from real preset content. - fn step_children(&mut self, dt: f32, spawned: &[(u64, [f32; 2])], died: &[(u64, [f32; 2])]) { - // Burst instances stop emitting after this long — the child preset - // itself has no end marker, and a one-shot puff is the authored - // intent of eventspawn/eventdeath (vs. a permanent stream). - const BURST_EMIT_SECS: f32 = 0.1; - - let mut children = std::mem::take(&mut self.children); - for child in &mut children { - let events: &[(u64, [f32; 2])] = match child.child_type { - ChildType::Static => &[], - ChildType::Follow | ChildType::Spawn => spawned, - ChildType::Death => died, - }; - for &(pid, pos) in events { - if child.instances.len() >= child.max_instances { - break; - } - if fastrand::f32() > child.probability { - continue; - } - let mut system = ParticleSystem::from_config(&child.config, pos, None); - if let Some(s) = &child.sprite { - system.set_sprite_frames(s.frames.len(), s.duration); - } - let follows = child.child_type == ChildType::Follow; - child.instances.push(ChildInstance { - parent_id: follows.then_some(pid), - last_center: pos, - emit_until: (!follows).then_some(BURST_EMIT_SECS), - persistent: false, - system, - }); - } - - for inst in &mut child.instances { - if let Some(pid) = inst.parent_id { - if let Some(p) = self.particles.iter().find(|p| p.id == pid) { - let delta = [p.x - inst.last_center[0], p.y - inst.last_center[1]]; - if delta != [0.0, 0.0] { - inst.system.shift_origin(delta); - inst.last_center = [p.x, p.y]; - } - } else { - // Parent died: stop emitting, let the remainder live out. - inst.parent_id = None; - inst.system.stop_emitting(); - } - } - if let Some(cutoff) = inst.emit_until { - if inst.system.time >= cutoff { - inst.system.stop_emitting(); - } - } - inst.system.step(dt); - } - - child.instances.retain(|inst| { - inst.persistent || inst.parent_id.is_some() || !inst.system.particles.is_empty() - }); - } - self.children = children; - } - - /// Moves every emitter origin and resolved control point by `delta` — - /// how an `eventfollow` child tracks its parent particle. - fn shift_origin(&mut self, delta: [f32; 2]) { - for emitter in &mut self.emitters { - emitter.origin[0] += delta[0]; - emitter.origin[1] += delta[1]; - } - for cp in &mut self.control_points { - cp[0] += delta[0]; - cp[1] += delta[1]; - } - } - - fn stop_emitting(&mut self) { - for emitter in &mut self.emitters { - emitter.rate = 0.0; - } - } - - /// Total pixel coverage the rasterizer would write for one frame: the - /// sum of every particle's quad area, each clipped to the bounding-box - /// area. Overdraw — many huge overlapping sprites — makes this far - /// larger than the bounding box itself, and it is the number a raster - /// budget must divide, not the buffer size. - pub fn coverage(&self, bbox_area: f32) -> f32 { - self.particles - .iter() - .map(|p| { - let side = (p.size * 2.0).max(1.0); - (side * side).min(bbox_area) - }) - .sum() - } - - /// Bounding box (`min_x, min_y, max_x, max_y`) over all alive particles' - /// `position ± size` (with a small margin for the soft-falloff glow - /// radius) — `None` when nothing is alive, so callers can skip a wasted - /// raster/upload entirely. Lets `render_onto` be called with a canvas - /// sized to just this box instead of the full scene. - pub fn bounds(&self) -> Option<(f32, f32, f32, f32)> { - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - for p in &self.particles { - // `p.size` is a radius (see `sizerandom`'s halving comment) for - // both the circle and textured-quad draws; pad by a couple of - // pixels for the soft falloff's antialiasing. - let half = p.size.max(1.0) + 2.0; - min_x = min_x.min(p.x - half); - min_y = min_y.min(p.y - half); - max_x = max_x.max(p.x + half); - max_y = max_y.max(p.y + half); - } - (!self.particles.is_empty()).then_some((min_x, min_y, max_x, max_y)) - } - - /// `origin` shifts every particle's canvas-space position by `-origin` — - /// lets callers raster into a canvas sized to just a system's bounding - /// box (see `bounds()`) instead of the full scene. Existing full-canvas - /// callers pass `[0.0, 0.0]`. - pub fn render_onto( - &self, - canvas: &mut RgbaImage, - sprite: Option<&ParticleSprite>, - origin: [f32; 2], - ) { - self.render_onto_blended(canvas, sprite, origin, false); - } - - /// `render_onto` with the material's blending mode: `additive` layers - /// accumulate premultiplied contributions (the reference renders each - /// particle quad with `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`, CPass.cpp), - /// so overlapping particles brighten each other instead of the newer - /// one occluding the older via "over" compositing. - pub fn render_onto_blended( - &self, - canvas: &mut RgbaImage, - sprite: Option<&ParticleSprite>, - origin: [f32; 2], - additive: bool, - ) { - self.render_onto_scaled(canvas, sprite, origin, additive, 1.0); - } - - /// `render_onto_blended` with all positions/sizes multiplied by `scale` - /// — lets a caller rasterize into a smaller-than-scene buffer that the - /// GPU compositor then stretches back up. Soft, huge sprites (smoke - /// clouds, rain sheets — radii in the hundreds of pixels) are the ones - /// that need this, and they lose nothing visible to the upscale, while - /// the CPU rasterization cost drops with scale². - pub fn render_onto_scaled( - &self, - canvas: &mut RgbaImage, - sprite: Option<&ParticleSprite>, - origin: [f32; 2], - additive: bool, - scale: f32, - ) { - if self.rope_mode { - self.render_rope_onto(canvas, sprite, origin, additive, scale); - self.render_children_onto(canvas, origin, scale); - return; - } - - let w = canvas.width() as i32; - let h = canvas.height() as i32; - - for p in &self.particles { - // Authored alpha straight through — the reference draws - // `v_Color.a = p.alpha` with no damping factor. - let alpha = (p.alpha * 255.0).clamp(0.0, 255.0) as u8; - if alpha == 0 { - continue; - } - let sz = (p.size * scale) as i32; - if sz <= 0 { - continue; - } - let px_pos = (p.x - origin[0]) * scale; - let py_pos = (p.y - origin[1]) * scale; - - if let Some(sprite) = sprite { - // `p.frame` is only ever set (in `step`) when the sprite has - // more than one frame; a single-frame sprite always draws - // frame 0 regardless of its (unused, still -1.0) value. - // - // Cross-fade: the fractional part of `p.frame` blends toward - // the NEXT frame, clamped at the last frame (the real - // engine's ComputeSpriteFrame uses - // `nextFrame = min(numFrames - 1, currentFrame + 1)` — no - // wrap, in any animation mode). RandomFrame assigns integer - // frames, so its fraction is 0 and no second sample happens. - let (frame_idx, next_frame) = if sprite.frames.len() > 1 { - let f = p.frame.max(0.0); - let a = (f as usize).min(sprite.frames.len() - 1); - let b = (a + 1).min(sprite.frames.len() - 1); - let blend = f.fract(); - if b != a && blend > 1.0 / 255.0 { - (a, Some((&sprite.frames[b], blend))) - } else { - (a, None) - } - } else { - (0, None) - }; - let tex = &sprite.frames[frame_idx]; - // `spritetrail`: align the quad's V axis with the particle's - // velocity and stretch it by clamp(|v|*length, min, max) — - // genericparticle.vert's TRAILRENDERER path, where `up` = - // normalized velocity scaled by that clamp and texture-top - // (v=0) points along it. - let (half_w, half_h, rotation) = match self.sprite_trail { - Some(t) => { - let vel_len = (p.vx * p.vx + p.vy * p.vy).sqrt(); - let trail = (vel_len * t.length) - .clamp(t.min_length.min(t.max_length), t.max_length) - .max(0.05); - let angle = if vel_len > 0.0001 { - p.vy.atan2(p.vx) + std::f32::consts::FRAC_PI_2 - } else { - p.rotation - }; - (p.size * scale, p.size * trail * scale, angle) - } - None => (p.size * scale, p.size * scale, p.rotation), - }; - draw_textured_particle( - canvas, - px_pos, - py_pos, - half_w, - half_h, - rotation, - p.color, - alpha, - tex, - next_frame, - sprite.overbright, - additive, - scale < 0.9, - ); - continue; - } - - let cx = px_pos as i32; - let cy = py_pos as i32; - - let sz2 = (sz * sz) as f32; - for dy in -sz..=sz { - for dx in -sz..=sz { - let d2 = (dx * dx + dy * dy) as f32; - if d2 > sz2 { - continue; - } - let px = cx + dx; - let py = cy + dy; - if px < 0 || py < 0 || px >= w || py >= h { - continue; - } - // Soft radial falloff — a flat-alpha disc reads as a harsh, - // hard-edged blob, especially for large ambient sprites - // (fog/light shafts) that are meant to be soft glows. - let t = 1.0 - (d2 / sz2).sqrt(); - let falloff = t * t; - let src_a = (alpha as f32 / 255.0) * falloff; - let src_c = [ - p.color[0] as f32 / 255.0, - p.color[1] as f32 / 255.0, - p.color[2] as f32 / 255.0, - ]; - let dst = canvas.get_pixel_mut(px as u32, py as u32); - blend_pixel(dst, src_c, src_a, additive); - } - } - } - - self.render_children_onto(canvas, origin, scale); - } - - /// Draws every child instance over the parent's output, each with its - /// own material sprite and blending. - fn render_children_onto(&self, canvas: &mut RgbaImage, origin: [f32; 2], scale: f32) { - for child in &self.children { - for inst in &child.instances { - inst.system.render_onto_scaled( - canvas, - child.sprite.as_ref(), - origin, - child.additive, - scale, - ); - } - } - } - - /// Draws a connected ribbon through living particles (oldest-first, since - /// `step()`'s retain preserves order and new spawns are appended) instead - /// of independent circles. Position is interpolated with the reference's - /// exact Catmull-Rom formula (CParticle.cpp renderRope); size/color/alpha - /// are linearly interpolated per sub-segment, and the material texture is - /// stretched across the ribbon: U spans the width (the beam profile that - /// makes a 100px-wide rope read as a hairline filament), V runs along the - /// rope as `(trailPosition + uvY) / (trailLength - 1)` with `uvscale` - /// tiling, optional arc-length smoothing, and 1-cycle/sec scrolling — - /// CParticle::renderRope's exact vertex semantics, evaluated per-pixel - /// here instead of in its rope shader. - fn render_rope_onto( - &self, - canvas: &mut RgbaImage, - sprite: Option<&ParticleSprite>, - origin: [f32; 2], - additive: bool, - scale: f32, - ) { - let Some(geo) = self.build_rope_geometry(origin, scale) else { - return; - }; - let tex = sprite.and_then(|s| s.frames.first()); - let overbright = sprite.map(|s| s.overbright).unwrap_or(1.0); - for &(i, v_a, v_b) in &geo.quads { - let (a, b) = (&geo.points[i], &geo.points[i + 1]); - fill_rope_segment( - canvas, a.pos, b.pos, a.normal, b.normal, a.size, b.size, a.color, b.color, - a.alpha, b.alpha, additive, tex, overbright, v_a, v_b, - ); - } - } - - /// Builds the rope's shared geometry — Catmull-Rom subpoints, per-joint - /// central-difference normals, and per-quad V ranges — consumed by both - /// the CPU scanline fill and the GPU vertex emitter. - fn build_rope_geometry(&self, origin: [f32; 2], scale: f32) -> Option { - let n = self.particles.len(); - if n < 2 { - return None; - } - - let subdiv = self.rope_subdivision; - let mut subpoints = Vec::with_capacity((n - 1) * subdiv + 1); - for i in 0..(n - 1) { - let idx0 = i.saturating_sub(1); - let p0 = &self.particles[idx0]; - let p1 = &self.particles[i]; - let p2 = &self.particles[i + 1]; - let idx3 = (i + 2).min(n - 1); - let p3 = &self.particles[idx3]; - - for step in 0..subdiv { - let t = step as f32 / subdiv as f32; - let pos = - catmull_rom_vec2([p0.x, p0.y], [p1.x, p1.y], [p2.x, p2.y], [p3.x, p3.y], t); - subpoints.push(RopePoint { - pos: [(pos[0] - origin[0]) * scale, (pos[1] - origin[1]) * scale], - size: lerp_f32(p1.size, p2.size, t) * scale, - alpha: lerp_f32(p1.alpha, p2.alpha, t), - color: [ - lerp_f32(p1.color[0] as f32, p2.color[0] as f32, t) as u8, - lerp_f32(p1.color[1] as f32, p2.color[1] as f32, t) as u8, - lerp_f32(p1.color[2] as f32, p2.color[2] as f32, t) as u8, - ], - normal: [0.0, 1.0], - }); - } - } - let last = &self.particles[n - 1]; - subpoints.push(RopePoint { - pos: [(last.x - origin[0]) * scale, (last.y - origin[1]) * scale], - size: last.size * scale, - color: last.color, - alpha: last.alpha, - normal: [0.0, 1.0], - }); - - // V coordinates along the rope — CParticle::renderRope's exact - // semantics: each sub-segment quad consumes 1/(trailLength-1) of UV - // space, `uvscale` divides the usable length (values > 1 tile the - // texture), `uvsmoothing` redistributes V by arc length (only when - // all lifetimes match and scrolling is off), and `uvscrolling` - // shifts one full UV cycle per second. - let total_sub = (subpoints.len() - 1) as f32; - let usable_len = (total_sub / self.rope_uv_scale).max(f32::EPSILON); - let use_smoothing = - self.rope_uv_smoothing && self.life_min == self.life_max && !self.rope_uv_scrolling; - let mut cumulative_arc = Vec::new(); - let mut total_arc = 0.0f32; - if use_smoothing { - cumulative_arc = Vec::with_capacity(subpoints.len()); - cumulative_arc.push(0.0); - for pair in subpoints.windows(2) { - let (dx, dy) = ( - pair[1].pos[0] - pair[0].pos[0], - pair[1].pos[1] - pair[0].pos[1], - ); - total_arc += (dx * dx + dy * dy).sqrt(); - cumulative_arc.push(total_arc); - } - } - let scroll_offset = if self.rope_uv_scrolling { - (self.time % 10000.0) * usable_len - } else { - 0.0 - }; - - // Per-joint width directions — genericropeparticle.vert's - // `trailRightStart = cross(eye, end − prev)` / `trailRightEnd = - // cross(eye, after − start)`: the perpendicular of the CENTRAL- - // DIFFERENCE tangent at each joint, so consecutive quads share - // their edge exactly and the ribbon reads as connected trapezoids - // instead of independently-angled facets. In screen space - // `cross((0,0,1), t)` is the 90° rotate `(−t.y, t.x)`. - let joint_normal = |i: usize| -> [f32; 2] { - let prev = &subpoints[i.saturating_sub(1)]; - let next = &subpoints[(i + 1).min(subpoints.len() - 1)]; - let t = [next.pos[0] - prev.pos[0], next.pos[1] - prev.pos[1]]; - let len = (t[0] * t[0] + t[1] * t[1]).sqrt(); - if len < 0.0001 { - [0.0, 1.0] - } else { - [-t[1] / len, t[0] / len] - } - }; - let normals: Vec<[f32; 2]> = (0..subpoints.len()).map(joint_normal).collect(); - for (i, n) in normals.into_iter().enumerate() { - subpoints[i].normal = n; - } - - let quads = (0..subpoints.len() - 1) - .map(|s| { - let trail_position = if use_smoothing && total_arc > 0.0 { - cumulative_arc[s] / total_arc * total_sub - } else { - s as f32 - } + scroll_offset; - ( - s, - trail_position / usable_len, - (trail_position + 1.0) / usable_len, - ) - }) - .collect(); - - Some(RopeGeometry { - points: subpoints, - quads, - }) - } - - /// Appends pre-transformed triangle-list vertices for the GPU particle - /// pipeline: 6 per sprite quad (rotation/spritetrail applied here, the - /// same math as the CPU textured draw) or 6 per rope sub-quad. Positions - /// are absolute scene pixels; the vertex shader converts to NDC. - /// `frame_count` is the sprite's frame count (0/1 = static frame 0). - pub fn emit_gpu_vertices(&self, out: &mut Vec, frame_count: usize) { - if self.rope_mode { - self.emit_rope_gpu_vertices(out); - return; - } - for p in &self.particles { - if p.alpha <= 0.0 || p.size <= 0.0 { - continue; - } - // Frame/cross-fade selection — mirrors the CPU textured draw. - let (frame, blend) = if frame_count > 1 { - let f = p.frame.max(0.0); - let a = (f as usize).min(frame_count - 1); - let b = (a + 1).min(frame_count - 1); - let fract = f.fract(); - if b != a && fract > 1.0 / 255.0 { - (a as f32, fract) - } else { - (a as f32, 0.0) - } - } else { - (0.0, 0.0) - }; - let (half_w, half_h, rotation) = match self.sprite_trail { - Some(t) => { - let vel_len = (p.vx * p.vx + p.vy * p.vy).sqrt(); - let trail = (vel_len * t.length) - .clamp(t.min_length.min(t.max_length), t.max_length) - .max(0.05); - let angle = if vel_len > 0.0001 { - p.vy.atan2(p.vx) + std::f32::consts::FRAC_PI_2 - } else { - p.rotation - }; - (p.size, p.size * trail, angle) - } - None => (p.size, p.size, p.rotation), - }; - let (c, sn) = (rotation.cos(), rotation.sin()); - let color = [ - p.color[0] as f32 / 255.0, - p.color[1] as f32 / 255.0, - p.color[2] as f32 / 255.0, - p.alpha.clamp(0.0, 1.0), - ]; - let corner = |lx: f32, ly: f32, u: f32, v: f32| GpuVertex { - pos: [p.x + lx * c - ly * sn, p.y + lx * sn + ly * c], - uv: [u, v], - color, - frame_blend: [frame, blend, 0.0, 0.0], - }; - let tl = corner(-half_w, -half_h, 0.0, 0.0); - let tr = corner(half_w, -half_h, 1.0, 0.0); - let br = corner(half_w, half_h, 1.0, 1.0); - let bl = corner(-half_w, half_h, 0.0, 1.0); - out.extend_from_slice(&[tl, tr, br, br, bl, tl]); - } - } - - fn emit_rope_gpu_vertices(&self, out: &mut Vec) { - let Some(geo) = self.build_rope_geometry([0.0, 0.0], 1.0) else { - return; - }; - for &(i, v_a, v_b) in &geo.quads { - let (a, b) = (&geo.points[i], &geo.points[i + 1]); - // Same convexity guard as fill_rope_segment: keep both joint - // normals on the same side when the chain doubles back. - let nb = if a.normal[0] * b.normal[0] + a.normal[1] * b.normal[1] < 0.0 { - [-b.normal[0], -b.normal[1]] - } else { - b.normal - }; - let (ha, hb) = (a.size.max(0.5), b.size.max(0.5)); - let col = |p: &RopePoint| { - [ - p.color[0] as f32 / 255.0, - p.color[1] as f32 / 255.0, - p.color[2] as f32 / 255.0, - p.alpha.clamp(0.0, 1.0), - ] - }; - let (ca, cb) = (col(a), col(b)); - let vert = |pos: [f32; 2], uv: [f32; 2], color: [f32; 4]| GpuVertex { - pos, - uv, - color, - frame_blend: [0.0, 0.0, 0.0, 0.0], - }; - // U convention matches fill_rope_segment: +normal side = u 1. - let a1 = vert( - [a.pos[0] + a.normal[0] * ha, a.pos[1] + a.normal[1] * ha], - [1.0, v_a], - ca, - ); - let b1 = vert( - [b.pos[0] + nb[0] * hb, b.pos[1] + nb[1] * hb], - [1.0, v_b], - cb, - ); - let b0 = vert( - [b.pos[0] - nb[0] * hb, b.pos[1] - nb[1] * hb], - [0.0, v_b], - cb, - ); - let a0 = vert( - [a.pos[0] - a.normal[0] * ha, a.pos[1] - a.normal[1] * ha], - [0.0, v_a], - ca, - ); - out.extend_from_slice(&[a1, b1, b0, b0, a0, a1]); - } - } - - /// Visits every live child instance with its child index (the GPU path - /// keys uploaded textures by child, since instances spawn at runtime) - /// and its material blending. - pub fn visit_gpu_children(&self, f: &mut dyn FnMut(&ParticleSystem, usize, bool)) { - for (child_idx, child) in self.children.iter().enumerate() { - for inst in &child.instances { - f(&inst.system, child_idx, child.additive); - } - } - } - - /// Build-time child info for GPU texture upload: each child's sprite and - /// whether its preset uses a rope renderer. - pub fn child_sprite_info(&self) -> Vec<(Option<&ParticleSprite>, bool)> { - self.children - .iter() - .map(|c| { - let rope = c.config.renderer.iter().any(|r| { - r.get("name") - .and_then(|n| n.as_str()) - .map(|n| n == "rope" || n == "ropetrail") - .unwrap_or(false) - }); - (c.sprite.as_ref(), rope) - }) - .collect() - } - - pub fn is_rope(&self) -> bool { - self.rope_mode - } -} - -/// One point along a rope's subdivided spine (see `build_rope_geometry`). -struct RopePoint { - pos: [f32; 2], - size: f32, - color: [u8; 3], - alpha: f32, - /// Per-joint central-difference perpendicular (width direction). - normal: [f32; 2], -} - -/// Shared rope geometry: spine points and per-quad `(start_index, v_a, v_b)`. -struct RopeGeometry { - points: Vec, - quads: Vec<(usize, f32, f32)>, -} - -/// One pre-transformed vertex for the GPU particle pipeline (triangle list, -/// read from a storage buffer by `vs_particles`). 48 bytes, std430-compatible. -#[derive(Clone, Copy, Debug, Default)] -pub struct GpuVertex { - /// Absolute scene pixels (y-down). - pub pos: [f32; 2], - pub uv: [f32; 2], - /// rgb 0-1, a = particle alpha. - pub color: [f32; 4], - /// x = sprite-sheet frame, y = cross-fade weight toward frame+1. - pub frame_blend: [f32; 4], -} - -impl GpuVertex { - /// Flattens vertices to the byte layout `vs_particles` reads. - pub fn as_bytes(verts: &[GpuVertex]) -> Vec { - let mut out = Vec::with_capacity(verts.len() * 48); - for v in verts { - for f in v - .pos - .iter() - .chain(v.uv.iter()) - .chain(v.color.iter()) - .chain(v.frame_blend.iter()) - { - out.extend_from_slice(&f.to_le_bytes()); - } - } - out - } -} - -/// Reference's exact Catmull-Rom formula (CParticle.cpp:2124-2130), applied -/// per-axis. -fn catmull_rom_vec2(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] { - let t2 = t * t; - let t3 = t2 * t; - let mut out = [0.0; 2]; - for axis in 0..2 { - let (p0, p1, p2, p3) = (p0[axis], p1[axis], p2[axis], p3[axis]); - out[axis] = 0.5 - * ((2.0 * p1) - + (-p0 + p2) * t - + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 - + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); - } - out -} - -fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { - a + (b - a) * t -} - -/// Rasterizes one ribbon quad between two consecutive sub-points: offsets -/// each endpoint by its half-width along its own JOINT normal (the central- -/// difference perpendicular computed by the caller), producing edge-sharing -/// trapezoids exactly like genericropeparticle.vert's -/// `position + mix(trailRightStart, trailRightEnd, uvY) * (uvX*2−1)` — where -/// `trailRight* = normalize(cross(eye, tangent)) * size`, i.e. the half-width -/// IS the particle size (spawn already halved `sizerandom`, so `p.size` is a -/// radius). With a material texture, each pixel samples it at (U = signed -/// offset across the width, V = lerp(v_a, v_b) along the rope, wrapping for -/// tiled `uvscale`) and modulates the particle color/alpha — the beam strip's -/// transparent edges are what make a wide rope render as a thin filament. -/// Without a texture (material failed to resolve), falls back to a flat fill. -#[allow(clippy::too_many_arguments)] -fn fill_rope_segment( - canvas: &mut RgbaImage, - a: [f32; 2], - b: [f32; 2], - normal_a: [f32; 2], - normal_b: [f32; 2], - size_a: f32, - size_b: f32, - color_a: [u8; 3], - color_b: [u8; 3], - alpha_a: f32, - alpha_b: f32, - additive: bool, - tex: Option<&RgbaImage>, - overbright: f32, - v_a: f32, - v_b: f32, -) { - let dir = [b[0] - a[0], b[1] - a[1]]; - let len = (dir[0] * dir[0] + dir[1] * dir[1]).sqrt(); - if len < 0.0001 { - return; - } - let dir = [dir[0] / len, dir[1] / len]; - let (half_a, half_b) = (size_a.max(0.5), size_b.max(0.5)); - - // Keep both joint normals on the same side so the quad stays convex — - // a chain doubling back >90° would otherwise produce a bowtie that the - // point-in-quad test rejects entirely (the GPU draws such quads as two - // crossing triangles; a same-side flip is the closest scanline analog). - let normal_b = if normal_a[0] * normal_b[0] + normal_a[1] * normal_b[1] < 0.0 { - [-normal_b[0], -normal_b[1]] - } else { - normal_b - }; - - let corners = [ - [a[0] + normal_a[0] * half_a, a[1] + normal_a[1] * half_a], - [b[0] + normal_b[0] * half_b, b[1] + normal_b[1] * half_b], - [b[0] - normal_b[0] * half_b, b[1] - normal_b[1] * half_b], - [a[0] - normal_a[0] * half_a, a[1] - normal_a[1] * half_a], - ]; - - let min_x = corners - .iter() - .map(|c| c[0]) - .fold(f32::MAX, f32::min) - .floor() - .max(0.0) as i32; - let max_x = corners - .iter() - .map(|c| c[0]) - .fold(f32::MIN, f32::max) - .ceil() - .min(canvas.width() as f32 - 1.0) as i32; - let min_y = corners - .iter() - .map(|c| c[1]) - .fold(f32::MAX, f32::min) - .floor() - .max(0.0) as i32; - let max_y = corners - .iter() - .map(|c| c[1]) - .fold(f32::MIN, f32::max) - .ceil() - .min(canvas.height() as f32 - 1.0) as i32; - if min_x > max_x || min_y > max_y { - return; - } - - let w = canvas.width() as i32; - let h = canvas.height() as i32; - for py in min_y..=max_y { - for px in min_x..=max_x { - if px < 0 || py < 0 || px >= w || py >= h { - continue; - } - let point = [px as f32 + 0.5, py as f32 + 0.5]; - if !point_in_convex_quad(point, &corners) { - continue; - } - // Blend color/alpha by which endpoint the pixel is nearer along - // the segment (a cheap stand-in for true per-pixel interpolation). - let seg_t = ((point[0] - a[0]) * dir[0] + (point[1] - a[1]) * dir[1]) / len; - let seg_t = seg_t.clamp(0.0, 1.0); - - if let Some(tex) = tex { - // Signed offset from the centerline along the interpolated - // joint normal, in [-half, +half] at this point's width. - let center = [lerp_f32(a[0], b[0], seg_t), lerp_f32(a[1], b[1], seg_t)]; - let nl = [ - lerp_f32(normal_a[0], normal_b[0], seg_t), - lerp_f32(normal_a[1], normal_b[1], seg_t), - ]; - let nl_len = (nl[0] * nl[0] + nl[1] * nl[1]).sqrt().max(0.0001); - let d_perp = - ((point[0] - center[0]) * nl[0] + (point[1] - center[1]) * nl[1]) / nl_len; - let half_t = lerp_f32(half_a, half_b, seg_t).max(0.5); - let u = (0.5 + 0.5 * (d_perp / half_t)).clamp(0.0, 1.0); - let v = lerp_f32(v_a, v_b, seg_t).rem_euclid(1.0); - let sample = sample_bilinear(tex, u, v); - let src_a = - lerp_f32(alpha_a, alpha_b, seg_t).clamp(0.0, 1.0) * (sample[3] as f32 / 255.0); - if src_a <= 0.0 { - continue; - } - let src_c = [ - (sample[0] as f32 / 255.0) - * (lerp_f32(color_a[0] as f32, color_b[0] as f32, seg_t) / 255.0) - * overbright, - (sample[1] as f32 / 255.0) - * (lerp_f32(color_a[1] as f32, color_b[1] as f32, seg_t) / 255.0) - * overbright, - (sample[2] as f32 / 255.0) - * (lerp_f32(color_a[2] as f32, color_b[2] as f32, seg_t) / 255.0) - * overbright, - ]; - let dst = canvas.get_pixel_mut(px as u32, py as u32); - blend_pixel(dst, src_c, src_a, additive); - continue; - } - - let alpha = (lerp_f32(alpha_a, alpha_b, seg_t) * 180.0).clamp(0.0, 255.0) as u8; - if alpha == 0 { - continue; - } - let color = [ - lerp_f32(color_a[0] as f32, color_b[0] as f32, seg_t) as u8, - lerp_f32(color_a[1] as f32, color_b[1] as f32, seg_t) as u8, - lerp_f32(color_a[2] as f32, color_b[2] as f32, seg_t) as u8, - ]; - - let src_a = alpha as f32 / 255.0; - let src_c = [ - color[0] as f32 / 255.0, - color[1] as f32 / 255.0, - color[2] as f32 / 255.0, - ]; - let dst = canvas.get_pixel_mut(px as u32, py as u32); - blend_pixel(dst, src_c, src_a, additive); - } - } -} - -/// Point-in-convex-quad test via consistent cross-product sign across all -/// four edges. `corners` must be wound consistently (either all-CW or -/// all-CCW) — true by construction here (two parallel perpendicular offsets). -fn point_in_convex_quad(point: [f32; 2], corners: &[[f32; 2]; 4]) -> bool { - let mut sign = 0.0f32; - for i in 0..4 { - let a = corners[i]; - let b = corners[(i + 1) % 4]; - let edge = [b[0] - a[0], b[1] - a[1]]; - let to_point = [point[0] - a[0], point[1] - a[1]]; - let cross = edge[0] * to_point[1] - edge[1] * to_point[0]; - if i == 0 { - sign = cross; - } else if cross * sign < 0.0 { - return false; - } - } - true -} - -/// `boids` operator: O(n^2) separation/alignment/cohesion, using each -/// particle's position/velocity as of the start of the frame (the caller -/// snapshots `self.particles` before any per-particle mutation happens this -/// step). Fine for the particle counts real presets use (`maxcount` is -/// typically in the hundreds, not thousands). -fn compute_boids_accel(particles: &[Particle], b: &BoidsParams) -> Vec<[f32; 2]> { - let n = particles.len(); - let mut accel = vec![[0.0f32; 2]; n]; - for i in 0..n { - let pi = &particles[i]; - let mut separation = [0.0f32; 2]; - let mut sep_count: u32 = 0; - let mut avg_vel = [0.0f32; 2]; - let mut avg_pos = [0.0f32; 2]; - let mut neighbor_count: u32 = 0; - for (j, pj) in particles.iter().enumerate() { - if i == j { - continue; - } - let d = [pi.x - pj.x, pi.y - pj.y]; - let dist = (d[0] * d[0] + d[1] * d[1]).sqrt(); - if dist > 0.0001 && dist < b.separation_threshold { - let f = 1.0 / dist; - separation[0] += d[0] * f; - separation[1] += d[1] * f; - sep_count += 1; - } - if dist < b.neighbor_threshold { - avg_vel[0] += pj.vx; - avg_vel[1] += pj.vy; - avg_pos[0] += pj.x; - avg_pos[1] += pj.y; - neighbor_count += 1; - } - } - let mut a = [0.0f32; 2]; - if sep_count > 0 { - let n = sep_count as f32; - a[0] += separation[0] / n * b.separation_factor; - a[1] += separation[1] / n * b.separation_factor; - } - if neighbor_count > 0 { - let n = neighbor_count as f32; - a[0] += (avg_vel[0] / n - pi.vx) * b.alignment_factor; - a[1] += (avg_vel[1] / n - pi.vy) * b.alignment_factor; - a[0] += (avg_pos[0] / n - pi.x) * b.cohesion_factor; - a[1] += (avg_pos[1] / n - pi.y) * b.cohesion_factor; - } - accel[i] = a; - } - accel -} - -/// Apply one collider's contact response to a particle already moved this -/// frame — see `CollisionShape`/`CollisionBehavior` docs for the "no C++ -/// reference" caveat on exact semantics. -fn resolve_collision(p: &mut Particle, c: &CollisionParams) { - // `normal` points from the collider's solid surface toward free space; - // `penetration` is how far past the surface the particle currently is - // (<= 0.0 means "not colliding", skipped by the caller's-eye-view check - // below in each shape branch). - let (normal, penetration): ([f32; 2], f32) = match c.shape { - CollisionShape::Sphere { radius } => { - let d = [p.x - c.center[0], p.y - c.center[1]]; - let dist = (d[0] * d[0] + d[1] * d[1]).sqrt(); - if dist >= radius || dist < 1e-5 { - return; - } - ([d[0] / dist, d[1] / dist], radius - dist) - } - CollisionShape::Box { half_extent } => { - let d = [p.x - c.center[0], p.y - c.center[1]]; - if d[0].abs() >= half_extent[0] || d[1].abs() >= half_extent[1] { - return; - } - let px = half_extent[0] - d[0].abs(); - let py = half_extent[1] - d[1].abs(); - if px < py { - ([d[0].signum(), 0.0], px) - } else { - ([0.0, d[1].signum()], py) - } - } - CollisionShape::Plane { normal } => { - let d = [p.x - c.center[0], p.y - c.center[1]]; - let signed = d[0] * normal[0] + d[1] * normal[1]; - if signed >= 0.0 { - return; - } - (normal, -signed) - } - }; - - match c.behavior { - CollisionBehavior::Delete => { - p.life = -1.0; - } - CollisionBehavior::Slide => { - p.x += normal[0] * penetration; - p.y += normal[1] * penetration; - let vn = p.vx * normal[0] + p.vy * normal[1]; - if vn < 0.0 { - p.vx -= normal[0] * vn; - p.vy -= normal[1] * vn; - } - } - CollisionBehavior::Bounce => { - p.x += normal[0] * penetration; - p.y += normal[1] * penetration; - let vn = p.vx * normal[0] + p.vy * normal[1]; - if vn < 0.0 { - let restitution = 1.0 + c.bounce_factor; - p.vx -= normal[0] * vn * restitution; - p.vy -= normal[1] * vn * restitution; - } - } - } -} - -/// Wraps an angle to `[-pi, pi]`, matching `createAngularMovementOperator`'s -/// per-axis wrap (CParticle.cpp). -fn wrap_angle(a: f32) -> f32 { - let mut a = a % std::f32::consts::TAU; - if a > std::f32::consts::PI { - a -= std::f32::consts::TAU; - } else if a < -std::f32::consts::PI { - a += std::f32::consts::TAU; - } - a -} - -// Minimal vec3 math for the turbulent/vortex ports (the reference uses glm; -// pulling in a linear-algebra crate for five one-liners isn't worth it). - -fn vec3_dot(a: [f32; 3], b: [f32; 3]) -> f32 { - a[0] * b[0] + a[1] * b[1] + a[2] * b[2] -} - -fn vec3_cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { - [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0], - ] -} - -fn vec3_length(v: [f32; 3]) -> f32 { - vec3_dot(v, v).sqrt() -} - -/// Normalize, or return `fallback` for a (near-)zero vector — the -/// reference's recurring `length > 0.0001 ? normalize(v) : default` guard. -fn vec3_normalize_or(v: [f32; 3], fallback: [f32; 3]) -> [f32; 3] { - let len = vec3_length(v); - if len > 0.0001 { - [v[0] / len, v[1] / len, v[2] / len] - } else { - fallback - } -} - -/// Rotate `v` by `angle` radians around unit-length `axis` (Rodrigues' -/// formula — equivalent to the reference's `glm::rotate` mat3 use). -fn vec3_rotate_axis(v: [f32; 3], axis: [f32; 3], angle: f32) -> [f32; 3] { - let (sin, cos) = angle.sin_cos(); - let cross = vec3_cross(axis, v); - let dot = vec3_dot(axis, v) * (1.0 - cos); - [ - v[0] * cos + cross[0] * sin + axis[0] * dot, - v[1] * cos + cross[1] * sin + axis[1] * dot, - v[2] * cos + cross[2] * sin + axis[2] * dot, - ] -} - -/// Draws one particle as a rotated, textured quad: inverse-rotates each -/// destination pixel in the quad's bounding box back into texture space, -/// bilinear-samples, and composites tinted-by-`color`/scaled-by-`alpha_byte` -/// over the canvas. The reference hands rotation+size to an external, -/// proprietary vertex shader to build the quad (not present in the -/// open-source reference repo) — this CPU inverse-mapping is our own design -/// for the same visual effect. -#[allow(clippy::too_many_arguments)] -#[allow(clippy::too_many_arguments)] -fn draw_textured_particle( - canvas: &mut RgbaImage, - cx: f32, - cy: f32, - half_w: f32, - half_h: f32, - rotation: f32, - color: [u8; 3], - alpha_byte: u8, - tex: &RgbaImage, - next_frame: Option<(&RgbaImage, f32)>, - overbright: f32, - additive: bool, - nearest: bool, -) { - // `p.size` is already a radius, not a diameter (see `sizerandom`'s - // comment on the halving done at spawn) — matches the flat-color circle - // draw's `sz = p.size` convention, so a particle looks the same size - // whether or not it has a material/texture. The two half-extents differ - // only for `spritetrail` quads (stretched along velocity). - let half_w = half_w.max(0.5); - let half_h = half_h.max(0.5); - let cos_r = rotation.cos(); - let sin_r = rotation.sin(); - - // Bounding box of the rotated quad: the diagonal half-extent covers any - // rotation angle. - let diag = (half_w * half_w + half_h * half_h).sqrt(); - let w = canvas.width() as i32; - let h = canvas.height() as i32; - let min_x = ((cx - diag).floor() as i32).max(0); - let max_x = ((cx + diag).ceil() as i32).min(w - 1); - let min_y = ((cy - diag).floor() as i32).max(0); - let max_y = ((cy + diag).ceil() as i32).min(h - 1); - - for py in min_y..=max_y { - for px in min_x..=max_x { - let dx = px as f32 + 0.5 - cx; - let dy = py as f32 + 0.5 - cy; - // Inverse-rotate the destination offset back into the quad's own - // (unrotated) local space. - let lx = dx * cos_r + dy * sin_r; - let ly = -dx * sin_r + dy * cos_r; - if lx < -half_w || lx > half_w || ly < -half_h || ly > half_h { - continue; - } - let u = (lx + half_w) / (2.0 * half_w); - let v = (ly + half_h) / (2.0 * half_h); - let mut sample = if nearest { - sample_nearest(tex, u, v) - } else { - sample_bilinear(tex, u, v) - }; - // Spritesheet cross-fade: mix toward the next frame's sample - // (genericparticle.frag's SPRITESHEETBLEND path). - if let Some((tex_b, blend)) = next_frame { - let b = if nearest { - sample_nearest(tex_b, u, v) - } else { - sample_bilinear(tex_b, u, v) - }; - for c in 0..4 { - sample[c] = (sample[c] as f32 + (b[c] as f32 - sample[c] as f32) * blend) - .clamp(0.0, 255.0) as u8; - } - } - let src_a = (sample[3] as f32 / 255.0) * (alpha_byte as f32 / 255.0); - if src_a <= 0.0 { - continue; - } - // genericparticle.frag: `color.rgb *= g_Overbright` after the - // texture × vertex-color multiply. blend_pixel clamps, so - // overbright > 1 saturates exactly like the GPU's clamped u8 target. - let src_c = [ - (sample[0] as f32 / 255.0) * (color[0] as f32 / 255.0) * overbright, - (sample[1] as f32 / 255.0) * (color[1] as f32 / 255.0) * overbright, - (sample[2] as f32 / 255.0) * (color[2] as f32 / 255.0) * overbright, - ]; - let dst = canvas.get_pixel_mut(px as u32, py as u32); - blend_pixel(dst, src_c, src_a, additive); - } - } -} - -/// Composites one source sample into a canvas pixel. -/// -/// Normal/translucent layers use standard "over" compositing (not a plain -/// lerp): the destination isn't always opaque — the GPU path renders -/// particles into their own transparent scene-sized buffer before -/// compositing it as a texture, so overlapping particles must accumulate -/// alpha correctly rather than assuming a fully-opaque backdrop. -/// -/// Additive layers accumulate premultiplied contributions instead — the -/// reference draws every particle quad with -/// `glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE, GL_SRC_ALPHA, GL_ONE)` -/// (CPass.cpp), i.e. `dst += src * src_a` — so overlapping glows brighten -/// each other rather than the newest quad occluding the ones below. The -/// buffer then holds already-premultiplied color, which the GPU composite's -/// pure-add mode adds to the scene without re-weighting by alpha. -fn blend_pixel(dst: &mut image::Rgba, src_c: [f32; 3], src_a: f32, additive: bool) { - if additive { - for i in 0..3 { - let dst_c = dst[i] as f32 / 255.0; - dst[i] = ((dst_c + src_c[i] * src_a) * 255.0).clamp(0.0, 255.0) as u8; - } - let dst_a = dst[3] as f32 / 255.0; - dst[3] = ((dst_a + src_a * src_a) * 255.0).clamp(0.0, 255.0) as u8; - return; - } - let dst_a = dst[3] as f32 / 255.0; - let out_a = src_a + dst_a * (1.0 - src_a); - if out_a > 0.0 { - for i in 0..3 { - let dst_c = dst[i] as f32 / 255.0; - let out_c = (src_c[i] * src_a + dst_c * dst_a * (1.0 - src_a)) / out_a; - dst[i] = (out_c * 255.0).clamp(0.0, 255.0) as u8; - } - } - dst[3] = (out_a * 255.0).clamp(0.0, 255.0) as u8; -} - -/// Point sampling — used when the raster target is itself downscaled (the -/// GPU upscale blurs anyway), saving the 4-tap filter per pixel. -fn sample_nearest(tex: &RgbaImage, u: f32, v: f32) -> [u8; 4] { - let (tw, th) = (tex.width(), tex.height()); - if tw == 0 || th == 0 { - return [0; 4]; - } - let x = ((u.clamp(0.0, 1.0) * (tw as f32 - 1.0)) + 0.5) as u32; - let y = ((v.clamp(0.0, 1.0) * (th as f32 - 1.0)) + 0.5) as u32; - tex.get_pixel(x.min(tw - 1), y.min(th - 1)).0 -} - -fn sample_bilinear(tex: &RgbaImage, u: f32, v: f32) -> [u8; 4] { - let (tw, th) = (tex.width(), tex.height()); - let fx = (u.clamp(0.0, 1.0) * (tw as f32 - 1.0).max(0.0)).max(0.0); - let fy = (v.clamp(0.0, 1.0) * (th as f32 - 1.0).max(0.0)).max(0.0); - let x0 = fx.floor() as u32; - let y0 = fy.floor() as u32; - let x1 = (x0 + 1).min(tw - 1); - let y1 = (y0 + 1).min(th - 1); - let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); - - let p00 = tex.get_pixel(x0, y0); - let p10 = tex.get_pixel(x1, y0); - let p01 = tex.get_pixel(x0, y1); - let p11 = tex.get_pixel(x1, y1); - - let mut out = [0u8; 4]; - for i in 0..4 { - let top = p00[i] as f32 * (1.0 - tx) + p10[i] as f32 * tx; - let bottom = p01[i] as f32 * (1.0 - tx) + p11[i] as f32 * tx; - out[i] = (top * (1.0 - ty) + bottom * ty).clamp(0.0, 255.0) as u8; - } - out -} - -fn lerp_u8(min: f32, max: f32) -> u8 { - (min + fastrand::f32() * (max - min)).clamp(0.0, 255.0) as u8 -} - -/// Linear ramp from `start_val` to `end_val` over `[start, end]` of `t`, -/// clamped flat outside that range. Matches `Maths::fadeValue` exactly. -fn fade_value(t: f32, start: f32, end: f32, start_val: f32, end_val: f32) -> f32 { - if t <= start { - start_val - } else if t >= end { - end_val - } else { - let f = (t - start) / (end - start); - start_val + f * (end_val - start_val) - } -} - -/// Initializer names are matched exactly — they're fixed strings in the -/// reference's ObjectParser, and substring matching let -/// `turbulentvelocityrandom`/`angularvelocityrandom` shadow -/// `velocityrandom` (or `angularmovement` shadow `movement`) depending on -/// declaration order. -/// The `exponent` (distribution bias) of a named initializer, or 1.0 (uniform). -fn initializer_exponent(initializers: &[Initializer], name: &str) -> f32 { - initializers - .iter() - .find(|i| i.name == name) - .and_then(|i| i.exponent) - .map(|e| e as f32) - .filter(|e| *e > 0.0) - .unwrap_or(1.0) -} - -fn scalar_range_from_initializers( - initializers: &[Initializer], - name: &str, - default_min: f32, - default_max: f32, -) -> (f32, f32) { - let Some(init) = initializers.iter().find(|init| init.name == name) else { - return (default_min, default_max); - }; - ( - init.min - .as_ref() - .and_then(value_as_f32) - .unwrap_or(default_min), - init.max - .as_ref() - .and_then(value_as_f32) - .unwrap_or(default_max), - ) -} - -/// Returns `Some((min, max))` when the named initializer exists and both -/// bounds parse as a 3-component vector (e.g. `velocityrandom`/`colorrandom`). -/// Exact-name matching for the same reason as `scalar_range_from_initializers`. -/// The (min, max) of initializer `name`, each falling back to WE's default -/// when omitted (ObjectParser.cpp: `colorrandom` black..white, -/// `velocityrandom` -32..32). `None` when there is no such initializer. -fn vec3_range_from_initializers( - initializers: &[Initializer], - name: &str, - default_min: [f32; 3], - default_max: [f32; 3], -) -> Option<([f32; 3], [f32; 3])> { - let init = initializers.iter().find(|init| init.name == name)?; - let min = init - .min - .as_ref() - .and_then(value_as_vec3) - .unwrap_or(default_min); - let max = init - .max - .as_ref() - .and_then(value_as_vec3) - .unwrap_or(default_max); - Some((min, max)) -} - -fn value_as_f32(v: &serde_json::Value) -> Option { - v.as_f64().map(|f| f as f32) -} - -/// Public alias for callers outside this module (e.g. render.rs's -/// control-point override plumbing). -pub fn value_as_vec3_pub(v: &serde_json::Value) -> Option<[f32; 3]> { - value_as_vec3(v) -} - -fn value_as_vec3(v: &serde_json::Value) -> Option<[f32; 3]> { - match v { - serde_json::Value::String(s) => Some(parse_f32_vec3(Some(s))), - serde_json::Value::Array(arr) => { - let parts: Vec = arr - .iter() - .filter_map(|x| x.as_f64().map(|f| f as f32)) - .collect(); - (parts.len() >= 3).then(|| [parts[0], parts[1], parts[2]]) - } - _ => None, - } -} - -fn parse_f32_vec3(s: Option<&str>) -> [f32; 3] { - let Some(s) = s else { return [0.0; 3] }; - let parts: Vec = s - .split_whitespace() - .filter_map(|p| p.parse().ok()) - .collect(); - [ - parts.first().copied().unwrap_or(0.0), - parts.get(1).copied().unwrap_or(0.0), - parts.get(2).copied().unwrap_or(0.0), - ] -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Real preset fields (velocityrandom, colorrandom, angularvelocityrandom) - /// use `"x y z"` vector strings for min/max, not numbers — the old - /// `Option` typing rejected these outright. - #[test] - fn parses_real_preset_with_vector_initializers() { - let json = r#"{ - "maxcount": 20, - "emitter": [{"name":"sphererandom","rate":1.5,"distancemin":0,"distancemax":512}], - "initializer": [ - {"id":2,"name":"lifetimerandom","min":3,"max":5}, - {"id":3,"name":"sizerandom","min":1000,"max":2200}, - {"id":4,"name":"velocityrandom","min":"25 0 0","max":"80 0 0"}, - {"id":5,"name":"colorrandom","min":"255 255 255","max":"255 255 255"} - ], - "operator": [{"id":9,"name":"movement","gravity":"0 0 0"}], - "renderer": [{"id":1,"name":"sprite"}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let sys = ParticleSystem::from_config(&config, [100.0, 200.0], None); - assert_eq!(sys.velocity_min, Some([25.0, 0.0, 0.0])); - assert_eq!(sys.velocity_max, Some([80.0, 0.0, 0.0])); - assert_eq!(sys.color_min, [255.0, 255.0, 255.0]); - } - - #[test] - fn scalar_initializer_without_vector_fields_still_parses() { - let json = r#"{ - "maxcount": 16, - "emitter": [{"name":"box","rate":1}], - "initializer": [{"id":2,"name":"lifetimerandom","min":1,"max":1}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - assert_eq!(sys.life_min, 1.0); - assert_eq!(sys.life_max, 1.0); - assert!(sys.velocity_min.is_none()); - } - - #[test] - fn instance_override_sets_color_and_multipliers() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"box","rate":1}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let overrides = InstanceOverride { - alpha: Some(0.5), - color: Some("192 192 192".to_string()), - rate: Some(2.0), - ..Default::default() - }; - let sys = ParticleSystem::from_config(&config, [0.0, 0.0], Some(&overrides)); - assert_eq!(sys.color_override, Some([192, 192, 192])); - assert_eq!(sys.alpha_mult, 0.5); - assert_eq!(sys.rate_mult, 2.0); - } - - /// `count` scales the pool, `lifetime` scales spawned lifetimes, and - /// `colorn` tints the colorrandom result — the multiplier semantics of - /// CParticle.cpp (lines 59, 755-765, 712-720). - #[test] - fn instance_override_count_lifetime_colorn() { - let json = r#"{ - "maxcount": 100, - "emitter": [{"name":"box","rate":1000}], - "initializer": [ - {"id":1,"name":"lifetimerandom","min":2,"max":2}, - {"id":2,"name":"colorrandom","min":"200 100 50","max":"200 100 50"} - ] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let overrides = InstanceOverride { - count: Some(0.5), - lifetime: Some(2.0), - colorn: Some(serde_json::json!("0.5 1 1")), - ..Default::default() - }; - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], Some(&overrides)); - assert_eq!(sys.max_count, 50, "count 0.5 should halve maxcount 100"); - sys.step(0.01); - let p = &sys.particles[0]; - assert_eq!(p.life, 4.0, "lifetime 2.0 should double the 2s lifetime"); - assert_eq!( - p.color, - [100, 100, 50], - "colorn should tint the red channel only" - ); - } - - /// `enabled: false` in an instanceoverride must drop the whole system at - /// the layer level (checked in render.rs/animated.rs, config-level here). - #[test] - fn instance_override_enabled_parses() { - let o: InstanceOverride = - serde_json::from_str(r#"{"enabled": false, "id": 3}"#).expect("should parse"); - assert_eq!(o.enabled.as_ref().and_then(|v| v.as_bool()), Some(false)); - } - - /// Emitter local origins are authored y-up (CParticle.cpp negates the y - /// in both emitter constructors): "10 20 0" sits 20 ABOVE the object in - /// screen space — rainperspective's "0 768 0" spawn band must end up - /// above the frame, not below it. - #[test] - fn emitter_origin_is_local_offset_from_spawn_center() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"box","rate":1,"origin":"10 20 0"}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let sys = ParticleSystem::from_config(&config, [100.0, 200.0], None); - assert_eq!(sys.emitters[0].origin[0], 110.0); - assert_eq!(sys.emitters[0].origin[1], 180.0); - } - - #[test] - fn movement_operator_gravity_field_parses() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"box","rate":1}], - "operator": [{"id":1,"name":"movement","gravity":"0 -50 0"}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - // Authored y-up: "0 -50 0" pulls down the screen, +y in this - // y-down simulation. - assert_eq!(sys.gravity, [0.0, 50.0]); - } - - #[test] - fn fade_value_clamps_outside_range_and_lerps_inside() { - assert_eq!(fade_value(0.0, 0.3, 0.7, 0.0, 1.0), 0.0); - assert_eq!(fade_value(0.3, 0.3, 0.7, 0.0, 1.0), 0.0); - assert_eq!(fade_value(0.5, 0.3, 0.7, 0.0, 1.0), 0.5); - assert_eq!(fade_value(0.7, 0.3, 0.7, 0.0, 1.0), 1.0); - assert_eq!(fade_value(1.0, 0.3, 0.7, 0.0, 1.0), 1.0); - } - - /// A real `alphafade` preset (fog1.json): fadeintime=0.5 means alpha - /// should be 0 at spawn, ~full partway through the ramp, and full for - /// the rest of the particle's life (no fadeouttime = holds at 1.0). - #[test] - fn alphafade_ramps_in_then_holds() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"box","rate":1}], - "initializer": [{"id":1,"name":"alpharandom","min":1,"max":1}], - "operator": [{"id":1,"name":"alphafade","fadeintime":0.5}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - // Force-spawn one particle with a known, fixed lifetime. - sys.life_min = 10.0; - sys.life_max = 10.0; - sys.emitters[0].rate = 1000.0; // spawn immediately - sys.step(0.001); // this step only spawns; the fade math runs on existing particles first - assert_eq!(sys.particles.len(), 1); - - // Just after spawn (age≈0), fully faded in from 0. - sys.step(0.001); - assert!(sys.particles[0].alpha < 0.1); - - // Halfway through the fade-in window (age=2.5s of 10s life → lifetime_pos=0.25, - // half of fadeintime=0.5): alpha should be roughly half. - sys.step(2.499); - let mid = sys.particles[0].alpha; - assert!((0.3..0.7).contains(&mid), "expected ~0.5, got {mid}"); - - // Well past the fade-in window, alpha should be at full (no fadeouttime set → holds at 1.0). - sys.step(5.0); - assert!(sys.particles[0].alpha > 0.9); - } - - /// A real `sizechange` preset (magic_pulse.json): startvalue=0, endvalue=2, - /// endtime=1 (normalized) — size should start near 0 and grow past the - /// initializer-sampled size as lifetime_pos approaches endtime. - #[test] - fn sizechange_scales_initial_size_over_lifetime() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"box","rate":1}], - "initializer": [{"id":1,"name":"sizerandom","min":100,"max":100}], - "operator": [{"id":1,"name":"sizechange","startvalue":0,"endvalue":2,"endtime":1}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.life_min = 10.0; - sys.life_max = 10.0; - sys.emitters[0].rate = 1000.0; - sys.step(0.001); // this step only spawns; the fade math runs on existing particles first - assert_eq!(sys.particles.len(), 1); - let initial_size = sys.particles[0].initial_size; - sys.step(0.001); - assert!(sys.particles[0].size < initial_size * 0.1); - - // lifetime_pos=1.0 reached at age=10s (endtime=1 normalized == full life here). - sys.step(9.99); - assert!((sys.particles[0].size - initial_size * 2.0).abs() < initial_size * 0.1); - } - - /// A real `colorchange` preset (torch.json): "r g b" 0..1 vector strings - /// for startvalue/endvalue — these previously broke parsing entirely - /// (Operator.startvalue/endvalue were typed as bare f64). - #[test] - fn colorchange_parses_and_multiplies_initial_color_over_lifetime() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"box","rate":1}], - "initializer": [{"id":1,"name":"colorrandom","min":"255 255 255","max":"255 255 255"}], - "operator": [{"id":1,"name":"colorchange","startvalue":"1 0.75 0","endvalue":"1 0 0","endtime":1}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.life_min = 10.0; - sys.life_max = 10.0; - sys.emitters[0].rate = 1000.0; - sys.step(0.001); // spawns the particle; fade math runs on existing particles first - assert_eq!(sys.particles.len(), 1); - - sys.step(0.001); // age≈0 → at startvalue (1, 0.75, 0) * white - let c0 = sys.particles[0].color; - assert_eq!(c0[0], 255); - assert!((c0[1] as i32 - 191).abs() <= 2); // 0.75*255≈191 - assert_eq!(c0[2], 0); - - sys.step(9.99); // age≈10 → lifetime_pos=1.0 → at endvalue (1, 0, 0) * white - let c1 = sys.particles[0].color; - assert_eq!(c1, [255, 0, 0]); - } - - /// A real `controlpointattract` preset pulls particles within `threshold/2` - /// of a control point toward it, and leaves particles outside that radius - /// untouched (CParticle.cpp createControlPointAttractOperator). - #[test] - fn controlpointattract_pulls_nearby_particles_toward_control_point() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"box","rate":1}], - "controlpoint": [{"id":0,"offset":"200 0 0"}], - "operator": [{"id":1,"name":"controlpointattract","controlpoint":0,"scale":500,"threshold":1000}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - // spawn_center [0,0] + control point offset "200 0 0" -> control point at [200, 0]. - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - assert_eq!(sys.control_points, vec![[200.0, 0.0, 0.0]]); - - sys.life_min = 10.0; - sys.life_max = 10.0; - sys.emitters[0].rate = 1000.0; - sys.step(0.001); // spawns the particle at/near [0,0] - assert_eq!(sys.particles.len(), 1); - let start_dist = ((sys.particles[0].x - 200.0).powi(2) + sys.particles[0].y.powi(2)).sqrt(); - - for _ in 0..10 { - sys.step(0.05); - } - let end_dist = ((sys.particles[0].x - 200.0).powi(2) + sys.particles[0].y.powi(2)).sqrt(); - assert!( - end_dist < start_dist, - "expected particle to be pulled toward control point: start={start_dist} end={end_dist}" - ); - } - - /// A particle outside the attract radius (`threshold/2`) should be left - /// alone entirely. - #[test] - fn controlpointattract_ignores_particles_outside_threshold() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"box","rate":1}], - "controlpoint": [{"id":0,"offset":"5000 0 0"}], - "operator": [{"id":1,"name":"controlpointattract","controlpoint":0,"scale":500,"threshold":10}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.life_min = 10.0; - sys.life_max = 10.0; - sys.emitters[0].rate = 1000.0; - sys.step(0.001); - assert_eq!(sys.particles.len(), 1); - // Capture the emitter-assigned spawn velocity (unrelated to the - // control point), then confirm a step leaves it untouched since the - // particle is well outside the attract radius. - let (vx0, vy0) = (sys.particles[0].vx, sys.particles[0].vy); - sys.step(0.05); - assert_eq!(sys.particles[0].vx, vx0); - assert_eq!(sys.particles[0].vy, vy0); - } - - /// A `rope`/`ropetrail` renderer should draw a connected ribbon through - /// living particles rather than isolated circles — verify pixels are - /// filled *between* particle centers, not just at them. - #[test] - fn rope_renderer_fills_ribbon_between_particles() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"box","rate":1}], - "initializer": [{"id":1,"name":"sizerandom","min":40,"max":40}], - "renderer": [{"id":1,"name":"rope","subdivision":4}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - assert!(sys.rope_mode); - assert_eq!(sys.rope_subdivision, 4); - - // Manually place three particles in a horizontal line, all alive. - let mut sys = sys; - for (i, x) in [0.0f32, 50.0, 100.0].into_iter().enumerate() { - sys.particles.push(Particle { - id: 0, - x, - y: 100.0, - vx: 0.0, - vy: 0.0, - life: 5.0, - max_life: 5.0, - size: 20.0, - alpha: 1.0, - color: [255, 0, 0], - initial_alpha: 1.0, - initial_size: 20.0, - initial_color: [1.0, 0.0, 0.0], - osc_alpha: None, - osc_size: None, - osc_pos: None, - rotation: 0.0, - angular_velocity: 0.0, - frame: -1.0, - }); - let _ = i; - } - - let mut canvas = RgbaImage::new(200, 200); - sys.render_onto(&mut canvas, None, [0.0, 0.0]); - - // Midpoint between the first two particles (25, 100) should be filled - // — a plain per-particle circle draw (radius 10) would leave this gap - // empty since it's 25px from the nearest particle center. - let mid_pixel = canvas.get_pixel(25, 100); - assert!( - mid_pixel[3] > 0, - "expected ribbon fill at midpoint, got {mid_pixel:?}" - ); - } - - /// A rope with a material texture must sample it across the ribbon width - /// (U = perpendicular offset): a beam strip that's transparent except for - /// its center column should render as a thin filament along the rope's - /// spine, leaving the outer ribbon area untouched — the fix for fat - /// solid lightning bolts (discharge presets) and giant trail wedges. - #[test] - fn rope_renderer_samples_texture_across_width() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"box","rate":1}], - "renderer": [{"id":1,"name":"rope","subdivision":1}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - for x in [0.0f32, 50.0, 100.0] { - sys.particles.push(make_particle(x, 100.0, 20.0, 0.0)); - } - - // Beam strip: transparent except the 3 center columns (white, opaque). - let mut beam = RgbaImage::new(33, 4); - for y in 0..4 { - for x in 15..=17 { - beam.put_pixel(x, y, image::Rgba([255, 255, 255, 255])); - } - } - let sprite = ParticleSprite { - frames: vec![beam], - duration: 0.0, - overbright: 1.0, - }; - - let mut canvas = RgbaImage::new(200, 200); - sys.render_onto(&mut canvas, Some(&sprite), [0.0, 0.0]); - - // The rope spine (u = 0.5 → the white center column) is filled… - let spine = canvas.get_pixel(25, 100); - assert!( - spine[3] > 0, - "expected filament on rope spine, got {spine:?}" - ); - // …but the ribbon area off-center (still inside the 10px half-width - // the old flat fill covered) samples the transparent texture edge. - let edge = canvas.get_pixel(25, 92); - assert_eq!(edge[3], 0, "expected transparent ribbon edge, got {edge:?}"); - } - - fn child_ref(child_type: Option<&str>, probability: f64) -> ChildRef { - ChildRef { - name: "child.json".into(), - child_type: child_type.map(str::to_string), - probability: Some(probability), - maxcount: None, - } - } - - fn simple_child_config() -> ParticleConfig { - serde_json::from_str( - r#"{ - "maxcount": 10, - "emitter": [{"name":"box","rate":1000}], - "initializer": [{"id":1,"name":"lifetimerandom","min":5,"max":5}] - }"#, - ) - .expect("child config should parse") - } - - /// An `eventfollow` child spawns one instance per parent particle and - /// keeps its emitters glued to the parent as it moves. - #[test] - fn child_eventfollow_tracks_parent_particle() { - let json = r#"{ - "maxcount": 1, - "emitter": [{"name":"box","rate":1000}], - "initializer": [ - {"id":1,"name":"lifetimerandom","min":10,"max":10}, - {"id":2,"name":"velocityrandom","min":"100 0 0","max":"100 0 0"} - ] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.add_child( - simple_child_config(), - None, - false, - &child_ref(Some("eventfollow"), 1.0), - [0.0, 0.0], - ); - - sys.step(0.01); // spawns the parent + its follow instance - assert_eq!(sys.particles.len(), 1); - assert_eq!(sys.children[0].instances.len(), 1); - - sys.step(1.0); // parent moves +100x; instance must follow - let parent = [sys.particles[0].x, sys.particles[0].y]; - let inst = &sys.children[0].instances[0]; - assert_eq!(inst.last_center, parent, "instance should track the parent"); - assert!( - !inst.system.particles.is_empty(), - "follow instance should be emitting its own particles" - ); - } - - /// Probability 0 must never attach an instance; a dead parent stops the - /// instance's emitters and lets it retire once its particles expire. - #[test] - fn child_probability_and_parent_death() { - let json = r#"{ - "maxcount": 1, - "emitter": [{"name":"box","rate":1000}], - "initializer": [{"id":1,"name":"lifetimerandom","min":0.05,"max":0.05}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - - let mut never = ParticleSystem::from_config(&config, [0.0, 0.0], None); - never.add_child( - simple_child_config(), - None, - false, - &child_ref(Some("eventfollow"), 0.0), - [0.0, 0.0], - ); - never.step(0.01); - assert!(never.children[0].instances.is_empty()); - - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.add_child( - simple_child_config(), - None, - false, - &child_ref(Some("eventfollow"), 1.0), - [0.0, 0.0], - ); - sys.step(0.01); - assert_eq!(sys.children[0].instances.len(), 1); - sys.step(0.1); // parent (life 0.05) dies - let inst = &sys.children[0].instances[0]; - assert!( - inst.parent_id.is_none(), - "dead parent should detach the instance" - ); - assert!( - inst.system.emitters.iter().all(|e| e.rate == 0.0), - "detached instance should stop emitting" - ); - } - - /// A type-less (static) child gets one persistent instance at the parent - /// object's origin that emits continuously, surviving empty stretches. - #[test] - fn child_static_runs_continuously() { - let json = r#"{ - "maxcount": 1, - "emitter": [{"name":"box","rate":0}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [40.0, 40.0], None); - sys.add_child( - simple_child_config(), - None, - false, - &child_ref(None, 1.0), - [40.0, 40.0], - ); - assert_eq!( - sys.children[0].instances.len(), - 1, - "static child instantiates immediately" - ); - for _ in 0..10 { - sys.step(0.05); - } - assert!(!sys.children[0].instances[0].system.particles.is_empty()); - assert!(sys.children[0].instances[0].persistent); - } - - fn make_particle(x: f32, y: f32, size: f32, rotation: f32) -> Particle { - Particle { - id: 0, - x, - y, - vx: 0.0, - vy: 0.0, - life: 5.0, - max_life: 5.0, - size, - alpha: 1.0, - color: [255, 255, 255], - initial_alpha: 1.0, - initial_size: size, - initial_color: [1.0, 1.0, 1.0], - osc_alpha: None, - osc_size: None, - osc_pos: None, - rotation, - angular_velocity: 0.0, - frame: -1.0, - } - } - - /// A textured particle's rotation should actually affect what's drawn — - /// sampling the same canvas location at two different rotation angles - /// must produce different colors, proving the quad (and the texture - /// sampled onto it) is genuinely rotated, not just passed through. - #[test] - fn textured_particle_rotation_changes_sampled_footprint() { - // 8x8 sprite: left half red, right half blue, fully opaque. - let mut tex = RgbaImage::new(8, 8); - for y in 0..8 { - for x in 0..8 { - let color = if x < 4 { - [255, 0, 0, 255] - } else { - [0, 0, 255, 255] - }; - tex.put_pixel(x, y, image::Rgba(color)); - } - } - - let config: ParticleConfig = - serde_json::from_str(r#"{"maxcount":1,"emitter":[{"name":"box","rate":1}]}"#) - .expect("should parse"); - - let sprite = ParticleSprite::single(tex.clone()); - let render_at = |rotation: f32| -> image::Rgba { - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.particles - .push(make_particle(50.0, 50.0, 20.0, rotation)); - let mut canvas = RgbaImage::new(100, 100); - sys.render_onto(&mut canvas, Some(&sprite), [0.0, 0.0]); - *canvas.get_pixel(44, 50) - }; - - let unrotated = render_at(0.0); - let rotated = render_at(std::f32::consts::FRAC_PI_2); - assert_ne!( - unrotated, rotated, - "expected rotation to change the sampled footprint at a fixed canvas point" - ); - } - - /// `rotationrandom` with a degenerate `[min.z, max.z]` range must spawn - /// every particle at exactly that rotation (CParticle.cpp - /// createRotationRandomInitializer, z-axis reduction); absent, spawn - /// rotation stays 0. - #[test] - fn rotationrandom_sets_spawn_rotation() { - let json = r#"{ - "maxcount": 4, - "emitter": [{"name":"box","rate":1000}], - "initializer": [{"id":1,"name":"rotationrandom","min":"0 0 1","max":"0 0 1"}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.step(0.001); - assert!((sys.particles[0].rotation - 1.0).abs() < 1e-4); - - let plain: ParticleConfig = - serde_json::from_str(r#"{"maxcount":1,"emitter":[{"name":"box","rate":1000}]}"#) - .expect("should parse"); - let mut sys = ParticleSystem::from_config(&plain, [0.0, 0.0], None); - sys.step(0.001); - assert_eq!(sys.particles[0].rotation, 0.0); - } - - /// `turbulentvelocityrandom` adds a curl-noise-directed velocity of - /// magnitude in `[speedmin, speedmax]` on top of the plain velocity — - /// with a zero base velocity, the spawn speed must equal the turbulent - /// speed exactly (the direction is noise, the magnitude is not). - #[test] - fn turbulentvelocityrandom_sets_spawn_speed_magnitude() { - let json = r#"{ - "maxcount": 8, - "emitter": [{"name":"box","rate":1000}], - "initializer": [ - {"id":1,"name":"velocityrandom","min":"0 0 0","max":"0 0 0"}, - {"id":2,"name":"turbulentvelocityrandom","speedmin":100,"speedmax":100} - ] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.step(0.001); - assert!(!sys.particles.is_empty()); - for p in &sys.particles { - let speed = (p.vx * p.vx + p.vy * p.vy).sqrt(); - assert!( - (speed - 100.0).abs() < 0.5, - "expected turbulent spawn speed 100, got {speed}" - ); - } - } - - /// The `turbulence` operator must push otherwise-motionless particles - /// around (curl-noise acceleration, CParticle.cpp - /// createTurbulenceOperator). - #[test] - fn turbulence_operator_accelerates_particles() { - let json = r#"{ - "maxcount": 4, - "emitter": [{"name":"box","rate":1000}], - "initializer": [{"id":1,"name":"velocityrandom","min":"0 0 0","max":"0 0 0"}], - "operator": [{"id":1,"name":"turbulence","speedmin":500,"speedmax":500,"scale":0.01}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [10.0, 20.0], None); - for _ in 0..10 { - sys.step(1.0 / 30.0); - } - let moving = sys - .particles - .iter() - .any(|p| (p.vx * p.vx + p.vy * p.vy).sqrt() > 1.0); - assert!(moving, "turbulence should have accelerated particles"); - } - - /// `vortex` spins particles tangentially around its control point: a - /// particle at +X from the center with a +Z axis must gain -Y velocity - /// in our y-down space (the reference's y-up cross(axis, radial), - /// mirrored — see the reflection note in `step`), scaled by speedinner - /// inside distanceinner. - #[test] - fn vortex_operator_adds_tangential_velocity() { - let json = r#"{ - "maxcount": 4, - "emitter": [{"name":"box","rate":0}], - "controlpoint": [{"id":0,"offset":"0 0 0"}], - "operator": [{"id":1,"name":"vortex","controlpoint":0, - "distanceinner":500,"distanceouter":650, - "speedinner":100,"speedouter":0}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.particles.push(make_particle(10.0, 0.0, 5.0, 0.0)); - sys.step(0.1); - let p = &sys.particles[0]; - assert!( - p.vy < -0.5, - "expected tangential -Y velocity from vortex, got vy={}", - p.vy - ); - assert!( - p.vx.abs() < 1e-3, - "no radial force expected, got vx={}", - p.vx - ); - } - - /// `collisionsphere` with the default `bounce` behavior: a particle that - /// has penetrated the sphere gets pushed back to the surface and its - /// into-surface velocity component reflected (scaled by `1+bouncefactor`). - #[test] - fn collisionsphere_bounces_particle_off_surface() { - let json = r#"{ - "maxcount": 1, - "emitter": [{"name":"box","rate":0}], - "controlpoint": [{"id":0,"offset":"0 0 0"}], - "operator": [{"id":1,"name":"collisionsphere","controlpoint":0,"radius":50}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - let mut p = make_particle(10.0, 0.0, 5.0, 0.0); - p.vx = -50.0; - sys.particles.push(p); - sys.step(0.1); - let p = &sys.particles[0]; - assert!( - (p.x - 50.0).abs() < 1e-3, - "expected pushed to surface, x={}", - p.x - ); - assert!( - p.vx > 0.0, - "expected reflected outward velocity, vx={}", - p.vx - ); - } - - /// `collisionbox` with `slide`: the into-wall velocity component is - /// zeroed (not reflected), and the particle is pushed back to the wall. - #[test] - fn collisionbox_slide_zeroes_into_wall_velocity() { - let json = r#"{ - "maxcount": 1, - "emitter": [{"name":"box","rate":0}], - "controlpoint": [{"id":0,"offset":"0 0 0"}], - "operator": [{"id":1,"name":"collisionbox","controlpoint":0, - "size":"100 100 0","collisionbehavior":"slide"}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - let mut p = make_particle(60.0, 0.0, 5.0, 0.0); - p.vx = -100.0; - sys.particles.push(p); - sys.step(0.15); - let p = &sys.particles[0]; - assert!( - (p.x - 50.0).abs() < 1e-3, - "expected pushed to wall, x={}", - p.x - ); - assert!( - p.vx.abs() < 1e-3, - "expected into-wall velocity zeroed, vx={}", - p.vx - ); - } - - /// `collisionplane` with `delete`: a particle on the solid side of the - /// plane is killed outright (removed by `step`'s own retain). - #[test] - fn collisionplane_delete_removes_particle() { - let json = r#"{ - "maxcount": 1, - "emitter": [{"name":"box","rate":0}], - "controlpoint": [{"id":0,"offset":"0 0 0"}], - "operator": [{"id":1,"name":"collisionplane","controlpoint":0, - "collisionbehavior":"delete"}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.particles.push(make_particle(0.0, -10.0, 5.0, 0.0)); - sys.step(0.1); - assert!( - sys.particles.is_empty(), - "particle should be deleted on contact" - ); - } - - /// Audio response: WE's parser defaults, band clamp/swap, and the gain - /// ramp runs between the `bounds` volume levels over the selected bands. - #[test] - fn audio_response_defaults_bands_and_bounds() { - let em = |j: &str| serde_json::from_str::(j).unwrap(); - assert_eq!(AudioResponse::from_emitter(&em("{}")), None); - let d = AudioResponse::from_emitter(&em(r#"{"audioprocessingmode": 1}"#)).unwrap(); - assert_eq!((d.exponent, d.bounds, d.bands), (2.0, [0.8, 1.0], [0, 1])); - let r = AudioResponse::from_emitter(&em( - r#"{"audioprocessingbounds": "0.2 1.5", "audioprocessingexponent": 1, - "audioprocessingfrequencystart": 40, "audioprocessingfrequencyend": 6}"#, - )) - .unwrap(); - assert_eq!(r.bands, [6, 15]); - let mut bands = [0.0; 16]; - assert_eq!(r.gain(&bands), 0.0); // silence: emitter inactive - bands[0] = 1.0; // outside 6..=15: no effect - assert_eq!(r.gain(&bands), 0.0); - bands[6..].fill(0.85); - assert!((r.gain(&bands) - 0.5).abs() < 1e-6); // (0.85-0.2)/(1.5-0.2) - bands[6..].fill(1.0); - assert!((r.gain(&bands) - 0.8 / 1.3).abs() < 1e-6); - } - - /// `boids` cohesion pulls two distant particles toward each other. - #[test] - fn boids_cohesion_pulls_particles_together() { - let json = r#"{ - "maxcount": 2, - "emitter": [{"name":"box","rate":0}], - "operator": [{"id":1,"name":"boids","neighborthreshold":500, - "separationthreshold":1,"cohesionfactor":1.0,"alignmentfactor":0}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.particles.push(make_particle(-100.0, 0.0, 5.0, 0.0)); - sys.particles.push(make_particle(100.0, 0.0, 5.0, 0.0)); - sys.step(0.1); - assert!( - sys.particles[0].vx > 1.0, - "left particle should move right, vx={}", - sys.particles[0].vx - ); - assert!( - sys.particles[1].vx < -1.0, - "right particle should move left, vx={}", - sys.particles[1].vx - ); - } - - /// `boids` separation pushes two nearby particles apart. - #[test] - fn boids_separation_pushes_particles_apart() { - let json = r#"{ - "maxcount": 2, - "emitter": [{"name":"box","rate":0}], - "operator": [{"id":1,"name":"boids","separationthreshold":50, - "neighborthreshold":1,"separationfactor":1.0}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.particles.push(make_particle(-2.0, 0.0, 5.0, 0.0)); - sys.particles.push(make_particle(2.0, 0.0, 5.0, 0.0)); - sys.step(0.1); - assert!( - sys.particles[0].vx < 0.0, - "left particle should move further left, vx={}", - sys.particles[0].vx - ); - assert!( - sys.particles[1].vx > 0.0, - "right particle should move further right, vx={}", - sys.particles[1].vx - ); - } - - /// `movement.drag` decays velocity by `1 - drag*dt` per step, clamped - /// at zero so an over-large `drag*dt` stops the particle instead of - /// reversing it (CParticle.cpp createMovementOperator). - #[test] - fn movement_drag_decays_velocity_without_reversal() { - let json = r#"{ - "maxcount": 1, - "emitter": [{"name":"box","rate":0}], - "operator": [{"id":1,"name":"movement","gravity":"0 0 0","drag":4.0}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - let mut p = make_particle(50.0, 50.0, 5.0, 0.0); - p.vx = 100.0; - sys.particles.push(p); - - sys.step(0.1); // drag*dt = 0.4 -> vx *= 0.6 - assert!((sys.particles[0].vx - 60.0).abs() < 1e-3); - - sys.step(0.5); // drag*dt = 2.0 -> clamps to 0, must not reverse - assert_eq!(sys.particles[0].vx, 0.0); - } - - /// `mapsequencebetweencontrolpoints` walks spawn positions along the - /// cp0->cp1 segment; `limitbehavior: "mirror"` ping-pongs (0, 1/3, 2/3, - /// 1, 2/3, 1/3, 0, ...) instead of wrapping. - #[test] - fn mapsequence_between_walks_segment_with_mirror() { - let json = r#"{ - "maxcount": 8, - "emitter": [{"name":"box","rate":100000}], - "controlpoint": [{"id":0,"offset":"0 0 0"},{"id":1,"offset":"90 0 0"}], - "initializer": [ - {"id":1,"name":"velocityrandom","min":"0 0 0","max":"0 0 0"}, - {"id":2,"name":"mapsequencebetweencontrolpoints","count":4,"limitbehavior":"mirror"} - ] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.step(0.001); - assert!(sys.particles.len() >= 7, "expected at least 7 spawns"); - let xs: Vec = sys.particles.iter().take(7).map(|p| p.x).collect(); - let expect = [0.0, 30.0, 60.0, 90.0, 60.0, 30.0, 0.0]; - for (i, (&x, &e)) in xs.iter().zip(expect.iter()).enumerate() { - assert!((x - e).abs() < 1e-3, "slot {i}: expected x={e}, got {x}"); - } - } - - /// `mapsequencearoundcontrolpoint` spawns at the control point and - /// launches successive particles at evenly-divided angles: with - /// `count: 4` and a +X-only speed, consecutive spawn velocities must - /// point right/down-rotated/left/up-rotated (the reference's clockwise - /// mat3), all with the same magnitude. - #[test] - fn mapsequence_spawns_at_control_point_with_rotating_velocity() { - let json = r#"{ - "maxcount": 4, - "emitter": [{"name":"box","rate":100000}], - "controlpoint": [{"id":0,"offset":"50 60 0"}], - "initializer": [{"id":1,"name":"mapsequencearoundcontrolpoint", - "controlpoint":0,"count":4, - "speedmin":"80 0 0","speedmax":"80 0 0"}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.step(0.001); - assert!(sys.particles.len() >= 4, "expected at least 4 spawns"); - for p in &sys.particles { - assert_eq!((p.x, p.y), (50.0, 60.0), "must spawn at the control point"); - let speed = (p.vx * p.vx + p.vy * p.vy).sqrt(); - assert!( - (speed - 80.0).abs() < 1e-3, - "speed must stay 80, got {speed}" - ); - } - // First two spawns: angle 0 (+X) then 2pi/4 = 90deg rotated. - assert!((sys.particles[0].vx - 80.0).abs() < 1e-3); - assert!(sys.particles[0].vy.abs() < 1e-3); - assert!(sys.particles[1].vx.abs() < 1e-3); - assert!(sys.particles[1].vy.abs() > 79.0); - } - - fn two_frame_sprite() -> ParticleSprite { - ParticleSprite { - frames: vec![ - RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])), - RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 255, 255])), - ], - duration: 1.0, - overbright: 1.0, - } - } - - fn render_center_with_frame(frame: f32) -> image::Rgba { - let config: ParticleConfig = - serde_json::from_str(r#"{"maxcount":1,"emitter":[{"name":"box","rate":0}]}"#) - .expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - let mut p = make_particle(20.0, 20.0, 8.0, 0.0); - p.frame = frame; - sys.particles.push(p); - let sprite = two_frame_sprite(); - let mut canvas = RgbaImage::new(40, 40); - sys.render_onto(&mut canvas, Some(&sprite), [0.0, 0.0]); - *canvas.get_pixel(20, 20) - } - - /// A fractional frame position cross-fades between the two adjacent - /// frames (genericparticle.frag's SPRITESHEETBLEND): halfway between a - /// red and a blue frame must paint ~50/50 purple, not hard-switch. - #[test] - fn fractional_frame_cross_fades_adjacent_frames() { - let px = render_center_with_frame(0.5); - assert!(px[0] > 100 && px[0] < 155, "red ~half, got {px:?}"); - assert!(px[2] > 100 && px[2] < 155, "blue ~half, got {px:?}"); - assert_eq!(px[1], 0); - } - - /// The next frame is clamped at the last frame (ComputeSpriteFrame's - /// `min(numFrames - 1, currentFrame + 1)`) — a fraction past the final - /// frame must NOT wrap-blend back toward frame 0. - #[test] - fn cross_fade_clamps_at_last_frame_without_wrapping() { - let px = render_center_with_frame(1.5); - assert_eq!(px[0], 0, "no red from a wrap to frame 0: {px:?}"); - assert!(px[2] > 200, "pure last frame, got {px:?}"); - } - - /// Integer frame positions (RandomFrame assigns whole frames) take the - /// single-sample path — exactly one frame's color, no blending. - #[test] - fn integer_frame_draws_single_frame_unblended() { - let px = render_center_with_frame(1.0); - assert_eq!(px[0], 0, "{px:?}"); - assert!(px[2] > 200, "{px:?}"); - let px0 = render_center_with_frame(0.0); - assert!(px0[0] > 200, "{px0:?}"); - assert_eq!(px0[2], 0, "{px0:?}"); - } - - /// `spritetrail` stretches the quad along the particle's velocity: - /// a fast horizontal particle must paint a footprint wider than tall - /// (its quad's long axis lies along X), unlike the square default. - #[test] - fn spritetrail_stretches_quad_along_velocity() { - let json = r#"{ - "maxcount": 1, - "emitter": [{"name":"box","rate":0}], - "renderer": [{"name":"spritetrail","length":1.0,"maxlength":10}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - let mut p = make_particle(50.0, 50.0, 5.0, 0.0); - p.vx = 8.0; // trail = clamp(8 * 1.0, 0, 10) = 8 -> half-height 40 along X - let mut sys = sys; - sys.particles.push(p); - - let tex = RgbaImage::from_pixel(4, 4, image::Rgba([255, 255, 255, 255])); - let sprite = ParticleSprite::single(tex); - let mut canvas = RgbaImage::new(100, 100); - sys.render_onto(&mut canvas, Some(&sprite), [0.0, 0.0]); - - let painted_x = canvas.get_pixel(80, 50)[3] > 0; // 30px right of center - let painted_y = canvas.get_pixel(50, 80)[3] > 0; // 30px below center - assert!(painted_x, "trail should extend along +X (velocity)"); - assert!(!painted_y, "trail should stay narrow across velocity"); - } - - /// Name matching must be exact: `turbulentvelocityrandom` declared - /// before `velocityrandom` (and `angularmovement` before `movement`) - /// must not shadow them — the old substring matcher (`contains`) - /// read the turbulent initializer's fields as the plain velocity - /// range and the angular operator's (absent) gravity as movement's. - /// An omitted `min`/`max` takes WE's default instead of discarding the - /// initializer: 3598808038's vapor sets only `colorrandom.max = 0 0 0` - /// and must come out black, not the no-initializer white. - #[test] - fn partial_vec3_initializers_use_we_defaults() { - let json = r#"{ - "maxcount": 4, - "emitter": [{"name":"box","rate":1}], - "initializer": [ - {"id":1,"name":"colorrandom","max":"0 0 0"}, - {"id":2,"name":"velocityrandom","min":"0 10 0"} - ] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - assert_eq!((sys.color_min, sys.color_max), ([0.0; 3], [0.0; 3])); - assert_eq!(sys.velocity_min, Some([0.0, -10.0, 0.0])); - assert_eq!(sys.velocity_max, Some([32.0, -32.0, 32.0])); - } - - #[test] - fn similarly_named_initializers_do_not_shadow_each_other() { - let json = r#"{ - "maxcount": 4, - "emitter": [{"name":"box","rate":1000}], - "initializer": [ - {"id":1,"name":"turbulentvelocityrandom","speedmin":500,"speedmax":900}, - {"id":2,"name":"velocityrandom","min":"3 3 0","max":"3 3 0"} - ], - "operator": [ - {"id":1,"name":"angularmovement","drag":0.5}, - {"id":2,"name":"movement","gravity":"0 -7 0"} - ] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - // Authored y-up vectors flip into this y-down simulation. Real - // content settles the direction: snow, rain and leaf presets fall - // with -y velocity, embers rise with +y gravity. - assert_eq!(sys.velocity_min, Some([3.0, -3.0, 0.0])); - assert_eq!(sys.velocity_max, Some([3.0, -3.0, 0.0])); - assert_eq!(sys.gravity[1], 7.0); - } - - /// A config with no `alpharandom` initializer at all must stay a no-op - /// (full alpha, matching `ParticleInstance::alpha`'s `1.0` default) — - /// the common case, and the one that must not regress. - #[test] - fn no_alpharandom_initializer_defaults_to_full_alpha() { - let config: ParticleConfig = - serde_json::from_str(r#"{"maxcount":1,"emitter":[{"name":"box","rate":1000}]}"#) - .expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.step(0.001); - assert_eq!(sys.particles[0].initial_alpha, 1.0); - } - - /// `alpharandom` (e.g. fog1.json's `"min":0.15,"max":0.2`) must actually - /// constrain spawn alpha to that range instead of always spawning at - /// full opacity — this was silently ignored before. - #[test] - fn alpharandom_constrains_spawn_alpha_to_its_range() { - let json = r#"{ - "maxcount": 20, - "emitter": [{"name":"box","rate":1000}], - "initializer": [{"id":1,"name":"alpharandom","min":0.15,"max":0.2}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.step(0.05); // spawn several at once (rate 1000 * 0.05s = 50, capped at maxcount) - assert!(!sys.particles.is_empty()); - for p in &sys.particles { - assert!( - (0.15..=0.2).contains(&p.initial_alpha), - "expected initial_alpha in [0.15, 0.2], got {}", - p.initial_alpha - ); - assert_eq!( - p.alpha, p.initial_alpha, - "no alphafade — alpha should equal initial_alpha before any fade operator runs" - ); - } - } - - /// An instance-override alpha multiplier must apply exactly once (not - /// squared) even when combined with `alpharandom`. - #[test] - fn alpha_override_multiplier_applies_once_not_twice() { - let json = r#"{ - "maxcount": 1, - "emitter": [{"name":"box","rate":1000}], - "initializer": [{"id":1,"name":"alpharandom","min":1.0,"max":1.0}] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let overrides = InstanceOverride { - alpha: Some(0.5), - ..Default::default() - }; - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], Some(&overrides)); - sys.step(0.01); - assert_eq!(sys.particles[0].initial_alpha, 0.5); - sys.step(0.01); - // No alphafade/alphachange configured, so the plain lifetime-based - // death-fade fallback (`initial_alpha * life/max_life`) applies — - // barely distinguishable from 0.5 this soon after spawn, but not - // bit-exact. The point of this assertion is that it's nowhere near - // 0.5*0.5=0.25 (the double-multiply bug), not exact equality. - assert!( - (sys.particles[0].alpha - 0.5).abs() < 0.01, - "override should apply once (~0.5), not 0.5*0.5=0.25; got {}", - sys.particles[0].alpha - ); - } - - /// Default (no `animationmode`) sprite-sheet playback loops continuously - /// based on lifetime position: at half life with 4 frames, the particle - /// should be sitting on frame 2. - #[test] - fn sprite_frame_loops_by_lifetime_position() { - let config: ParticleConfig = - serde_json::from_str(r#"{"maxcount":1,"emitter":[{"name":"box","rate":1000}]}"#) - .expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.life_min = 10.0; - sys.life_max = 10.0; - sys.set_sprite_frames(4, 0.0); - sys.step(0.001); // spawn - assert_eq!(sys.particles.len(), 1); - sys.step(5.0); // half of a 10s life - assert!((sys.particles[0].frame - 2.0).abs() < 0.01); - } - - /// `animationmode: "once"` plays through the sheet across the particle's - /// lifetime and clamps at the last frame instead of wrapping. - #[test] - fn sprite_frame_clamps_at_last_frame_when_animation_mode_once() { - let config: ParticleConfig = serde_json::from_str( - r#"{"maxcount":1,"animationmode":"once","emitter":[{"name":"box","rate":1000}]}"#, - ) - .expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.life_min = 10.0; - sys.life_max = 10.0; - sys.set_sprite_frames(4, 0.0); - sys.step(0.001); // spawn - sys.step(9.99); // nearly the whole 10s life - assert_eq!(sys.particles[0].frame, 3.0); - } - - /// `animationmode: "randomframe"` assigns one frame at spawn and freezes - /// it — a second `step()` must not change it. - #[test] - fn sprite_random_frame_stays_fixed_after_assignment() { - let config: ParticleConfig = serde_json::from_str( - r#"{"maxcount":1,"animationmode":"randomframe","emitter":[{"name":"box","rate":1000}]}"#, - ) - .expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.life_min = 10.0; - sys.life_max = 10.0; - sys.set_sprite_frames(5, 0.0); - sys.step(0.001); // spawn (frame assignment lags by one step, like other operators) - sys.step(0.001); // assigns a random frame - let frame = sys.particles[0].frame; - assert!((0.0..5.0).contains(&frame)); - sys.step(1.0); - assert_eq!(sys.particles[0].frame, frame); - } - - /// A sprite with only one frame should never advance `p.frame` at all — - /// `render_onto` always draws frame 0 for it regardless. - #[test] - fn single_frame_sprite_leaves_frame_at_spawn_sentinel() { - let config: ParticleConfig = - serde_json::from_str(r#"{"maxcount":1,"emitter":[{"name":"box","rate":1000}]}"#) - .expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.life_min = 10.0; - sys.life_max = 10.0; - sys.set_sprite_frames(1, 0.0); - sys.step(0.001); - sys.step(5.0); - assert_eq!(sys.particles[0].frame, -1.0); - } - - #[test] - fn bounds_is_none_when_no_particles_alive() { - let config: ParticleConfig = - serde_json::from_str(r#"{"maxcount":5,"emitter":[{"name":"box","rate":0}]}"#) - .expect("should parse"); - let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - assert!(sys.bounds().is_none()); - } - - #[test] - fn bounds_covers_alive_particle_extents() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"box","rate":1,"distancemax":"0 0 0"}], - "initializer": [ - {"id":1,"name":"lifetimerandom","min":10,"max":10}, - {"id":2,"name":"sizerandom","min":20,"max":20}, - {"id":3,"name":"velocityrandom","min":"0 0 0","max":"0 0 0"} - ] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [100.0, 100.0], None); - sys.emitters[0].rate = 1000.0; - sys.step(0.001); - assert_eq!(sys.particles.len(), 1); - - let (min_x, min_y, max_x, max_y) = sys.bounds().expect("should have bounds"); - // Particle spawned at (100,100) (spread disabled via distancemax=0). - // `sizerandom`'s value is halved at spawn (diameter → radius, see - // the comment on that halving elsewhere in this file), so a - // `sizerandom` of 20 gives an actual radius of 10; `bounds()` adds a - // couple of pixels of margin on top of that. - assert!(min_x < 100.0 - 9.0 && min_x > 100.0 - 16.0, "min_x={min_x}"); - assert!(max_x > 100.0 + 9.0 && max_x < 100.0 + 16.0, "max_x={max_x}"); - assert!(min_y < 100.0 - 9.0 && min_y > 100.0 - 16.0, "min_y={min_y}"); - assert!(max_y > 100.0 + 9.0 && max_y < 100.0 + 16.0, "max_y={max_y}"); - } - - /// `render_onto`'s `origin` param should shift particle canvas positions, - /// letting a caller raster into a canvas sized to just the bbox instead - /// of the full scene. - #[test] - fn render_onto_origin_shifts_particle_positions() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"box","rate":1}], - "initializer": [ - {"id":1,"name":"lifetimerandom","min":10,"max":10}, - {"id":2,"name":"sizerandom","min":10,"max":10}, - {"id":3,"name":"velocityrandom","min":"0 0 0","max":"0 0 0"}, - {"id":4,"name":"alpharandom","min":1,"max":1} - ] - }"#; - let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); - let mut sys = ParticleSystem::from_config(&config, [50.0, 50.0], None); - sys.emitters[0].rate = 1000.0; - sys.step(0.001); - assert_eq!(sys.particles.len(), 1); - - // A canvas the same size as the bbox, with origin shifted to the - // bbox's top-left, should show the particle rendered near its - // center rather than clipped at (0,0). - let (min_x, min_y, max_x, max_y) = sys.bounds().unwrap(); - let w = (max_x - min_x).ceil() as u32; - let h = (max_y - min_y).ceil() as u32; - let mut canvas = RgbaImage::new(w, h); - sys.render_onto(&mut canvas, None, [min_x, min_y]); - - let cx = (w / 2).min(w - 1); - let cy = (h / 2).min(h - 1); - assert!( - canvas.get_pixel(cx, cy)[3] > 0, - "expected particle to render near the shifted canvas center" - ); - } - - #[test] - fn oscillate_params_defaults_scale_min_from_scale_max_when_absent() { - let op = Operator { - name: "oscillatealpha".to_string(), - frequencymin: Some(0.5), - frequencymax: Some(1.0), - scalemax: Some(10.0), - ..Default::default() - }; - let params = OscillateParams::from_operator(&op); - assert_eq!(params.scale_min, 10.0); - assert_eq!(params.scale_max, 10.0); - assert_eq!(params.freq_min, 0.5); - assert_eq!(params.freq_max, 1.0); - } - - #[test] - fn oscillate_sample_phase_covers_full_circle_when_unspecified() { - let op = Operator { - name: "oscillatealpha".to_string(), - frequencymin: Some(1.0), - frequencymax: Some(1.0), - scalemin: Some(1.0), - scalemax: Some(1.0), - ..Default::default() - }; - let params = OscillateParams::from_operator(&op); - for _ in 0..50 { - let s = params.sample(); - assert!((0.0..std::f32::consts::TAU).contains(&s.phase)); - } - } - - #[test] - fn remapvalue_sets_velocity_within_output_range() { - let json = r#"{ - "maxcount": 20, - "emitter": [{"name":"boxrandom","rate":1000,"distancemin":"0 0 0","distancemax":"0 0 0"}], - "initializer": [ - {"id":1,"name":"lifetimerandom","min":100,"max":100}, - {"id":2,"name":"sizerandom","min":10,"max":10} - ], - "operator": [ - {"id":1,"name":"remapvalue","output":"velocity", - "outputrangemin":"-100 -200 0","outputrangemax":"100 -1200 1", - "transformfunction":"simplexnoise","transforminputscale":10} - ] - }"#; - let config: ParticleConfig = serde_json::from_str(json).unwrap(); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - for _ in 0..10 { - sys.step(1.0 / 30.0); - } - assert!(!sys.particles.is_empty(), "should have spawned particles"); - for p in &sys.particles { - // Velocity is set (then position-integrated) into the remapped - // range each frame; check it lands within bounds (+ a small margin - // for the integration/other operators). - assert!( - (-101.0..=101.0).contains(&p.vx), - "vx {} out of remapped x-range", - p.vx - ); - assert!( - (-1201.0..=-199.0).contains(&p.vy), - "vy {} out of remapped y-range", - p.vy - ); - } - } - - /// Real content (`particles/presets/rain_screen.json`, workshop item - /// `3564529588`) attaches *two* `remapvalue` operators — one targeting - /// `velocity`, one `speed` — and both must take effect. The old - /// `Option` silently dropped every instance after the - /// first; this is the regression test for the `Vec` fix. - #[test] - fn multiple_remapvalue_operators_all_apply() { - let json = r#"{ - "maxcount": 20, - "emitter": [{"name":"boxrandom","rate":1000,"distancemin":"0 0 0","distancemax":"0 0 0"}], - "initializer": [ - {"id":1,"name":"lifetimerandom","min":100,"max":100} - ], - "operator": [ - {"id":1,"name":"remapvalue","output":"velocity", - "outputrangemin":"5 5 0","outputrangemax":"5 5 0", - "transformfunction":"simplexnoise","transforminputscale":10}, - {"id":2,"name":"remapvalue","output":"speed", - "outputrangemin":"50","outputrangemax":"50", - "transformfunction":"fbmnoise","transforminputscale":8} - ] - }"#; - let config: ParticleConfig = serde_json::from_str(json).unwrap(); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - // Particles spawn at the *end* of `step()`, after the per-particle - // update loop that applies `remapvalue` — a freshly-spawned particle - // hasn't had it applied yet on the frame it's born (same reasoning - // `remapvalue_sets_velocity_within_output_range` above steps - // multiple times for), so step twice before checking. - sys.step(1.0 / 30.0); - sys.step(1.0 / 30.0); - assert!(!sys.particles.is_empty(), "should have spawned particles"); - for p in &sys.particles { - // A degenerate [5,5]x[5,5] range collapses velocity's own remap - // to a fixed heading; the speed operator then rescales its - // magnitude to 50 — only observable if *both* operators ran. - let mag = (p.vx * p.vx + p.vy * p.vy).sqrt(); - assert!((mag - 50.0).abs() < 0.5, "speed {mag} not rescaled to ~50"); - } - } - - /// The general field-to-field form: `input=lifetimefraction, - /// operation=multiply, output=opacity` is exactly the shape the retail - /// binary's own `alphafade`-preset builders construct internally - /// (Ghidra report Follow-up (x), `FUN_1401bfbb0`/`FUN_1401bc4b0`) — a - /// lifetime-driven fade-out multiplier. Checks opacity decreases - /// monotonically as the particle ages toward its declared output range. - #[test] - fn remapvalue_multiply_by_lifetimefraction_fades_opacity() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"boxrandom","rate":1000,"distancemin":"0 0 0","distancemax":"0 0 0"}], - "initializer": [ - {"id":1,"name":"lifetimerandom","min":30,"max":30}, - {"id":2,"name":"alpharandom","min":1,"max":1} - ], - "operator": [ - {"id":1,"name":"remapvalue","input":"lifetimefraction", - "operation":"multiply","output":"opacity", - "inputrangemin":0,"inputrangemax":1, - "outputrangemin":1,"outputrangemax":0, - "transformfunction":"none"} - ] - }"#; - let config: ParticleConfig = serde_json::from_str(json).unwrap(); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - sys.step(1.0 / 30.0); - assert!(!sys.particles.is_empty(), "should have spawned particles"); - let alpha_early = sys.particles[0].alpha; - for _ in 0..20 { - sys.step(1.0 / 30.0); - } - assert!(!sys.particles.is_empty(), "particle should still be alive"); - let alpha_late = sys.particles[0].alpha; - assert!( - alpha_late < alpha_early, - "opacity should fade as lifetimefraction rises toward 1 (early={alpha_early}, late={alpha_late})" - ); - } - - /// `transformfunction:"none"` with a real `input` field is a straight - /// remap (default `operation`), no shaping — the simplest, most literal - /// case of the general form, targeting `size` this time. - #[test] - fn remapvalue_remaps_size_from_maxlifetime() { - let json = r#"{ - "maxcount": 5, - "emitter": [{"name":"boxrandom","rate":1000,"distancemin":"0 0 0","distancemax":"0 0 0"}], - "initializer": [ - {"id":1,"name":"lifetimerandom","min":40,"max":40}, - {"id":2,"name":"sizerandom","min":1,"max":1} - ], - "operator": [ - {"id":1,"name":"remapvalue","input":"maxlifetime", - "output":"size","transformfunction":"none", - "inputrangemin":0,"inputrangemax":40, - "outputrangemin":0,"outputrangemax":200} - ] - }"#; - let config: ParticleConfig = serde_json::from_str(json).unwrap(); - let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); - // See the comment in `multiple_remapvalue_operators_all_apply`: a - // freshly-spawned particle hasn't had `remapvalue` applied yet on - // its spawn frame. - sys.step(1.0 / 30.0); - sys.step(1.0 / 30.0); - assert!(!sys.particles.is_empty(), "should have spawned particles"); - for p in &sys.particles { - // maxlifetime=40 sits at the top of [0,40] -> size should land - // at (or very near) the top of [0,200], overriding sizerandom's - // spawn value of 1. - assert!( - (p.size - 200.0).abs() < 1.0, - "size {} not remapped from maxlifetime", - p.size - ); - } - } -} - -#[cfg(test)] -mod colorn_probe { - use super::*; - - #[test] - fn colorn_override_tints_the_spawn_color() { - let preset = r#"{ - "maxcount": 8, - "emitter": [{"name": "sphererandom", "id": 1, "rate": 100, "distancemax": "1 1 1"}], - "initializer": [ - {"id": 4, "max": "255 255 255", "min": "255 255 255", "name": "colorrandom"}, - {"id": 3, "max": 10, "min": 10, "name": "sizerandom"}, - {"id": 2, "max": 1, "min": 1, "name": "lifetimerandom"} - ], - "operator": [] - }"#; - let cfg: ParticleConfig = serde_json::from_str(preset).expect("preset parses"); - let ov: InstanceOverride = - serde_json::from_str(r#"{"colorn": "0.180 0.408 0.773", "id": null, "lifetime": 2.0}"#) - .expect("override parses"); - assert_eq!( - ov.colorn.as_ref().and_then(value_as_vec3_pub), - Some([0.18, 0.408, 0.773]) - ); - let mut sys = ParticleSystem::from_config(&cfg, [0.0, 0.0], Some(&ov)); - for _ in 0..20 { - sys.step(0.05); - } - let p = sys.particles.first().expect("something spawned"); - // `colorrandom` is pinned to white (min == max) so the tint is the only - // thing acting on the colour, and every spawned particle is identical. - // With a random base this assertion was only true for most samples: a - // blue draw near the low end of [47,255] tints to ~36, which is *below* - // the fixed red of 255*0.180 ≈ 46, so the suite failed at random. - let expected = [255.0 * 0.180, 255.0 * 0.408, 255.0 * 0.773]; - for (channel, want) in expected.iter().enumerate() { - let got = p.color[channel] as f32; - assert!( - (got - want).abs() <= 2.0, - "channel {channel} is {got}, expected ~{want}: {:?}", - p.color - ); - } - } -} diff --git a/src/engine/pass.rs b/src/engine/pass.rs deleted file mode 100644 index 35275fe..0000000 --- a/src/engine/pass.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Single render pass descriptor (mirrors CPass from linux-wallpaperengine). -//! Holds the parameters for one effect step: pipeline key, extra textures, and UBO bytes. -//! Does not own the GPU pipeline (that lives in GpuSceneRenderer). - -/// One effect pass: pipeline key + uniform data + extra texture keys. -pub struct ScenePass { - /// Key into the pipeline registry (e.g. "waterripple", "pulse", "custom_abc123"). - pub pipeline_key: String, - /// Packed bytes for the effect UBO (group 1, binding 0). Always ≥16 bytes. - pub uniform_data: Vec, - /// Extra texture keys (g_Texture1, ...) looked up in ResourceManager. - pub extra_texture_keys: Vec, -} - -impl ScenePass { - pub fn new( - pipeline_key: impl Into, - uniform_data: Vec, - extra_texture_keys: Vec, - ) -> Self { - let mut data = uniform_data; - while data.len() < 16 || data.len() % 16 != 0 { - data.push(0); - } - Self { - pipeline_key: pipeline_key.into(), - uniform_data: data, - extra_texture_keys, - } - } - - /// Pack f32 values into a 16-byte-aligned UBO byte vec. - pub fn pack_uniforms(values: &[f32]) -> Vec { - let mut bytes: Vec = values.iter().flat_map(|v| v.to_le_bytes()).collect(); - while bytes.len() < 16 || bytes.len() % 16 != 0 { - bytes.push(0); - } - bytes - } - - /// True if this is one of the hardcoded built-in WGSL effects. - pub fn is_builtin(&self) -> bool { - const BUILTIN: &[&str] = &[ - "pulse", - "scroll", - "shake", - "tint", - "opacity", - "waterripple", - "waterwaves", - "spin", - ]; - BUILTIN.contains(&self.pipeline_key.as_str()) - } -} - -/// Named f32 uniform value for building pass UBOs. -pub struct UniformValue { - pub name: String, - pub value: f32, -} - -impl UniformValue { - pub fn new(name: impl Into, value: f32) -> Self { - Self { - name: name.into(), - value, - } - } -} diff --git a/src/engine/render.rs b/src/engine/render.rs deleted file mode 100644 index 3395a05..0000000 --- a/src/engine/render.rs +++ /dev/null @@ -1,2993 +0,0 @@ -use anyhow::{Context, Result}; -use image::RgbaImage; -use std::collections::HashMap; -use std::path::Path; - -use super::particle; -use super::pkg::Package; -use super::properties::SceneProperties; -use super::scene::{Scene, SceneObject}; -use super::tex::TexFile; - -/// Parse scene.json, first resolving `{"user": ...}` references against the -/// wallpaper's project.json properties (plus any `--set-property` overrides). -fn parse_scene_with_properties(scene_json: &str, project_dir: Option<&Path>) -> Result { - let mut value: serde_json::Value = serde_json::from_str(scene_json).with_context(|| { - let preview = if scene_json.len() > 200 { - &scene_json[..200] - } else { - scene_json - }; - format!("failed to parse scene.json: {preview}...") - })?; - if let Some(dir) = project_dir { - let props = SceneProperties::from_project_dir(dir); - props.resolve_scene_json(&mut value); - } - Scene::from_value(value) -} - -/// A fully resolved scene ready to composite into a single frame. -pub struct ResolvedScene { - pub width: u32, - pub height: u32, - pub layers: Vec, - pub particle_layers: Vec, - /// Static 3D mesh objects (`model` → `.mdl`) — only genuine perspective - /// scenes have these; the 2D compositor ignores them. - pub mesh3d_layers: Vec, - /// Parent chain + transform scripts per object, for the live path's - /// per-frame recompose. See [`TransformGraph`]. - pub transform_graph: TransformGraph, - pub scene: Scene, -} - -/// A scene object whose `model` field references a static 3D mesh. Rendered as -/// real geometry through the perspective camera (see `engine::mesh3d`), not as -/// a flat quad — spheres, skyboxes and cylinders can't be billboarded. -pub struct Mesh3dLayer { - pub name: String, - pub mesh: crate::engine::mesh3d::Mesh3d, - /// Resolved from the material path embedded in the `.mdl` header. - pub texture: RgbaImage, - /// World-space transform (WE scene units; the mesh's own space is ≈ unit). - pub origin: [f32; 3], - pub scale: [f32; 3], - pub angles: [f32; 3], - /// The same three before `apply_parent_transforms` composed the parent - /// chain in. A script-driven parent moves per frame, so the live path - /// recomposes from these rather than trying to invert the baked world value. - pub local_origin: [f32; 3], - pub local_scale: [f32; 3], - pub local_angles: [f32; 3], - /// The material's `"cullmode": "nocull"` — draw both faces. Everything - /// else culls back faces, which is what lets a skybox's near hemisphere - /// drop out instead of hiding the whole scene inside it. - pub nocull: bool, - /// `depthtest: "disabled"` on the object (or its material pass) — draw - /// without depth comparison. Real content always leaves it enabled; this - /// exists so content that disables it isn't silently depth-culled. - pub depthtest: bool, - pub order_index: usize, -} - -/// A scene object whose `particle` field references a particle preset JSON -/// (e.g. `"particles/presets/fog1.json"`). Holds enough static data for any -/// renderer to build its own `particle::ParticleSystem` (each render path -/// owns independent simulation state, so this is config, not a live system). -pub struct ParticleLayer { - pub name: String, - /// WE origin: absolute scene coordinates, Y-up, (0,0) at bottom-left — - /// same convention as `Layer::origin`; callers convert to pixel space at - /// render time (`px = origin.x`, `py = height - origin.y`). - pub origin: [f64; 3], - /// `origin` before the parent chain was composed in (see `Layer`). - pub local_origin: [f64; 3], - pub parallax_depth: [f64; 2], - pub config: particle::ParticleConfig, - pub overrides: Option, - /// Resolved once from `config.material` (if present) via the same - /// model/material→texture chain image layers use, keeping every - /// sprite-sheet frame. `None` falls back to `render_onto`'s flat-color - /// circle draw. - pub sprite_texture: Option, - /// True when the material's own pass declares `"blending":"additive"` - /// (the overwhelming majority of real particle materials — fog, smoke, - /// embers, rain, lightning). Composited additively onto the scene - /// instead of the default alpha-over, which otherwise makes a sprite's - /// near-black background visibly darken/box the scene behind it. - pub additive_blend: bool, - /// This object's raw index in `scene.objects` (already the scene's - /// topological/declaration render order) — lets render loops interleave - /// particle systems with image layers in true scene z-order, and lets - /// effect instances (which record the same raw object index) find their - /// owning layer regardless of how many particles/skipped/invisible - /// objects precede it. - pub order_index: usize, - /// Child presets from `config.children`, resolved (JSON + material - /// sprite) here where asset access lives; attached to the built system - /// via `ParticleSystem::add_child` at construction time. - pub children: Vec, -} - -/// One resolved `children` entry of a particle preset (see -/// [`particle::ChildRef`]). -pub struct ResolvedChildParticle { - pub config: particle::ParticleConfig, - pub sprite: Option, - pub additive: bool, - pub child_ref: particle::ChildRef, -} - -/// Everything needed to re-rasterize a script-driven text layer at runtime: -/// the script and font. The layer's projected `rect` (position + size) is -/// computed once at build through the full camera/parent transform; on a -/// content change we keep that rect's center and only rescale its extent by -/// the ratio of the new bitmap size to the old, so no projection is re-derived. -#[derive(Clone)] -pub struct TextDynamic { - pub font_data: Vec, - pub point_size: f32, - /// Text currently rasterized into the layer image (the `value` WE feeds - /// back into `update` each tick). - pub last_text: String, -} - -/// What a backdrop layer captures each frame (see `backdrop_layer_from_object`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Backdrop { - /// The scene beneath the layer's own quad (composition layer). - Region, - /// The whole scene (fullscreen / projection layer). - FullScene, -} - -pub struct Layer { - /// `Some` for composition/fullscreen layers, whose texture is the scene - /// beneath them rather than an image. - pub backdrop: Option, - pub name: String, - pub image: RgbaImage, - /// Additional animation frames (frame 0 is `image`). Empty = not animated. - pub extra_frames: Vec, - /// Duration of each frame in milliseconds. 0 = not animated / unknown. - pub frame_duration_ms: u32, - pub origin: [f64; 3], - pub size: [f64; 3], - pub scale: [f64; 3], // WE scale multiplier (default 1.0), separate from size - /// `origin`/`scale`/`angles` before `apply_parent_transforms` composed the - /// parent chain in — the live path recomposes from these each frame when a - /// parent is script-driven. See `ResolvedScene::transform_graph`. - pub local_origin: [f64; 3], - pub local_scale: [f64; 3], - pub local_angles: [f32; 3], - pub parallax_depth: [f64; 2], - /// z-rotation in radians (WE `angles.z`; the JSON value is already in radians). - pub angle: f32, - /// Full 3D rotation in radians, un-negated as stored in scene.json. - /// Only used by the perspective (3D scene) path; 2D uses `angle`. - pub angles: [f32; 3], - pub blend_mode: u32, - pub alpha: f32, - pub color: [f32; 3], - pub brightness: f32, - /// True for WE "copybackground" layers — source is the rendered canvas so far. - pub copybackground: bool, - /// `.tex` `NoInterpolation` flag: sample nearest instead of linear. - pub no_interpolation: bool, - /// `.tex` `ClampUVs`/`ClampUVsBorder` flag: clamp-to-edge instead of repeat. - pub clamp_uvs: bool, - /// Animated puppet runtime (mesh + skeleton + MDLA animations) — the - /// live GPU path re-poses and re-rasterizes `image` from this. - pub puppet: Option>, - /// Embedded-video stream (mp4 inside the .tex) — the live GPU path - /// re-uploads decoded frames from this; `image` is the first frame. - pub video: Option>>, - /// Script-driven text: the GPU path re-evaluates and re-rasterizes - /// `image` when the script's output changes (clocks, dates, countdowns). - pub text_dynamic: Option, - /// This object's raw index in `scene.objects` — see - /// `ParticleLayer::order_index`. - pub order_index: usize, - /// The scene object's own `id`. Effects reference another object's - /// rendered output as `_rt_imageLayerComposite__a`, so the live path - /// needs to know which layer each id belongs to. - pub object_id: Option, - /// The object's AUTHORED `visible` value — the starting visibility and the - /// `value` fed to a `visible` script. Critical: scripts that only define - /// cursor handlers (invisible click hitboxes, `"value": false`) have no - /// `update()`, so the script returns `value` unchanged — feeding `true` - /// here would render those hitboxes as opaque boxes. - pub visible_base: bool, -} - -/// A loaded texture plus any additional animation frames (e.g. from a `.tex` -/// TEXS spritesheet table or a multi-image container). -struct LoadedImage { - image: RgbaImage, - extra_frames: Vec, - frame_duration_ms: u32, - no_interpolation: bool, - clamp_uvs: bool, - /// Present when the model is an animated puppet (mesh + skeleton + - /// MDLA animations): `image` holds the rest pose, and the live GPU - /// path re-poses/re-rasterizes from this over time. - puppet: Option>, - /// Present for embedded-video textures: `image` holds the first frame, - /// and the live GPU path re-uploads frames from this stream over time. - video: Option>>, - /// Set when the model's material declares the `SPRITESHEET` combo and the - /// `.tex` carries a frame table (e.g. a 2-cell AM/PM atlas): the per-frame - /// sub-rect images. `layer_from_object` picks the single frame the object's - /// frame script selects and uses it as the layer image. Without this the - /// flattened atlas stretches into the one-cell quad (all cells overlaid). - sprite_frames: Vec, -} - -impl LoadedImage { - fn single(image: RgbaImage) -> Self { - Self { - image, - extra_frames: Vec::new(), - frame_duration_ms: 0, - no_interpolation: false, - clamp_uvs: false, - puppet: None, - video: None, - sprite_frames: Vec::new(), - } - } - - /// Build from a parsed `.tex`, pulling out extra animation frames if any - /// and carrying over its `NoInterpolation`/`ClampUVs` sampler flags. - fn from_tex(tex: &TexFile) -> Result { - let no_interpolation = tex.no_interpolation(); - let clamp_uvs = tex.clamp_uvs(); - if tex.is_animated() { - if let Ok(mut frames) = tex.to_rgba_frames() { - if frames.len() > 1 { - let image = frames.remove(0); - let frame_duration_ms = tex - .frames() - .first() - .map(|f| ((f.frametime * 1000.0) as u32).max(1)) - .unwrap_or(100); - return Ok(Self { - image, - extra_frames: frames, - frame_duration_ms, - no_interpolation, - clamp_uvs, - puppet: None, - video: None, - sprite_frames: Vec::new(), - }); - } - } - } - if let Some(bytes) = tex.video_bytes() { - return video_tex_to_loaded(tex, bytes); - } - Ok(Self { - no_interpolation, - clamp_uvs, - ..Self::single(tex.to_rgba()?) - }) - } -} - -impl ResolvedScene { - /// Load a scene wallpaper from a directory. - /// - /// Automatically detects whether assets are loose files or packed in a - /// `.pkg` archive and loads accordingly. The scene file is usually - /// `scene.json`/`scene.pkg`, but project.json's `file` field can name a - /// different one — GIF-converted wallpapers ship - /// `gifscene.json`/`gifscene.pkg` (e.g. workshop item 2036522973). - #[tracing::instrument(target = "scene", level = "debug", fields(dir = %dir.display()))] - pub fn from_directory(dir: &Path) -> Result { - tracing::debug!(target: "scene", "resolving scene from directory"); - let scene_name = std::fs::read_to_string(dir.join("project.json")) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|p| p.get("file")?.as_str().map(str::to_string)) - .filter(|f| f.ends_with(".json")) - .unwrap_or_else(|| "scene.json".to_string()); - let pkg_name = format!("{}.pkg", scene_name.trim_end_matches(".json")); - - let scene_json_path = dir.join(&scene_name); - let scene_pkg_path = dir.join(&pkg_name); - - if scene_pkg_path.exists() { - let pkg = Package::from_file(&scene_pkg_path)?; - - let scene_json = if scene_json_path.exists() { - std::fs::read_to_string(&scene_json_path) - .with_context(|| format!("reading {}", scene_json_path.display()))? - } else if let Some(data) = pkg.get(&scene_name).or_else(|| pkg.get("scene.json")) { - String::from_utf8(data.to_vec()) - .context("scene json inside PKG is not valid UTF-8")? - } else { - anyhow::bail!("no {scene_name} found in directory or PKG archive"); - }; - - return Self::from_package_with_dir(&pkg, &scene_json, dir); - } - - let scene_json = std::fs::read_to_string(&scene_json_path) - .with_context(|| format!("reading {}", scene_json_path.display()))?; - let scene = parse_scene_with_properties(&scene_json, Some(dir))?; - - let mut layers = Vec::new(); - let mut solid_indices = Vec::new(); - let mut particle_layers = Vec::new(); - let mut mesh3d_layers = Vec::new(); - // Effective visibility (own AND every ancestor) — see Scene::visibility_mask. - let visible = scene.visibility_mask(); - // Raw `scene.objects` index (not a visible-only count): effect - // instances record the same raw index, so layers and effects agree - // on object identity no matter what gets skipped in between. - for (obj_index, obj) in scene.objects.iter().enumerate() { - if !visible[obj_index] { - continue; - } - if obj.light.is_some() && obj.image.is_none() { - if let Some(mut layer) = light_layer_from_object(obj) { - layer.order_index = obj_index; - layers.push(layer); - } - continue; - } - if obj.mesh3d_path().is_some() { - if let Some(mut ml) = mesh3d_layer_from_object(obj, Some(dir), None) { - ml.order_index = obj_index; - mesh3d_layers.push(ml); - } - continue; - } - if obj.particle.is_some() { - if let Some(mut pl) = particle_layer_from_object(obj, Some(dir), None) { - pl.order_index = obj_index; - particle_layers.push(pl); - } - continue; - } - if obj.text.is_some() { - if let Some(mut layer) = text_layer_from_object(obj, Some(dir), None) { - layer.order_index = obj_index; - layers.push(layer); - } - continue; - } - // Note: `copybackground` objects still carry a real image/model — - // the reference engine ignores the flag and draws them normally. - let loaded = match obj.image.as_deref() { - None => { - if obj.effects.is_empty() { - continue; - } - LoadedImage::single(placeholder_for(obj)) - } - Some(path) if is_solid_layer_path(path) => { - // Scene-size expansion is for effect chains only — see - // fill_solid_layer_sizes. - if !obj.effects.is_empty() { - solid_indices.push(layers.len()); - } - LoadedImage::single(white_pixel()) - } - Some(path) => { - if let Some(mut layer) = backdrop_layer_from_object(obj, path) { - if layer.size[0] == 0.0 || layer.size[1] == 0.0 { - solid_indices.push(layers.len()); - } - layer.order_index = obj_index; - layers.push(layer); - continue; - } - match load_texture_from_dir(dir, path) { - Ok(loaded) => loaded, - Err(e) => { - tracing::warn!(target: "scene", "texture load failed for '{path}': {e:#}"); - if obj.effects.is_empty() { - continue; - } - LoadedImage::single(placeholder_for(obj)) - } - } - } - }; - let mut layer = layer_from_object(obj, loaded, None); - layer.order_index = obj_index; - layers.push(layer); - } - - let (width, height) = guess_scene_dimensions(&scene, &layers); - fill_solid_layer_sizes(&mut layers, &solid_indices, width, height); - apply_parent_transforms( - &scene, - &mut layers, - &mut particle_layers, - &mut mesh3d_layers, - ); - Ok(Self { - width, - height, - layers, - particle_layers, - mesh3d_layers, - transform_graph: build_transform_graph(&scene), - scene, - }) - } - - /// Load from a PKG archive, falling back to the directory for loose files. - fn from_package_with_dir(pkg: &Package, scene_json: &str, dir: &Path) -> Result { - let scene = parse_scene_with_properties(scene_json, Some(dir))?; - - let mut layers = Vec::new(); - let mut solid_indices = Vec::new(); - let mut particle_layers = Vec::new(); - let mut mesh3d_layers = Vec::new(); - // Effective visibility (own AND every ancestor) — see Scene::visibility_mask. - let visible = scene.visibility_mask(); - // Raw `scene.objects` index (not a visible-only count): effect - // instances record the same raw index, so layers and effects agree - // on object identity no matter what gets skipped in between. - for (obj_index, obj) in scene.objects.iter().enumerate() { - if !visible[obj_index] { - continue; - } - if obj.light.is_some() && obj.image.is_none() { - if let Some(mut layer) = light_layer_from_object(obj) { - layer.order_index = obj_index; - layers.push(layer); - } - continue; - } - if obj.mesh3d_path().is_some() { - if let Some(mut ml) = mesh3d_layer_from_object(obj, Some(dir), Some(pkg)) { - ml.order_index = obj_index; - mesh3d_layers.push(ml); - } - continue; - } - if obj.particle.is_some() { - if let Some(mut pl) = particle_layer_from_object(obj, Some(dir), Some(pkg)) { - pl.order_index = obj_index; - particle_layers.push(pl); - } - continue; - } - if obj.text.is_some() { - if let Some(mut layer) = text_layer_from_object(obj, Some(dir), Some(pkg)) { - layer.order_index = obj_index; - layers.push(layer); - } - continue; - } - let loaded = match obj.image.as_deref() { - None => { - if obj.effects.is_empty() { - continue; - } - LoadedImage::single(placeholder_for(obj)) - } - Some(path) if is_solid_layer_path(path) => { - // Scene-size expansion is for effect chains only — see - // fill_solid_layer_sizes. - if !obj.effects.is_empty() { - solid_indices.push(layers.len()); - } - LoadedImage::single(white_pixel()) - } - Some(path) => { - if let Some(mut layer) = backdrop_layer_from_object(obj, path) { - if layer.size[0] == 0.0 || layer.size[1] == 0.0 { - solid_indices.push(layers.len()); - } - layer.order_index = obj_index; - layers.push(layer); - continue; - } - match load_texture_from_pkg(pkg, path).or_else(|pkg_err| { - load_texture_from_dir(dir, path) - .map_err(|dir_err| anyhow::anyhow!("pkg: {pkg_err}; dir: {dir_err}")) - }) { - Ok(loaded) => loaded, - Err(e) => { - tracing::warn!(target: "scene", "texture load failed for '{path}': {e:#}"); - if obj.effects.is_empty() { - continue; - } - LoadedImage::single(placeholder_for(obj)) - } - } - } - }; - let mut layer = layer_from_object(obj, loaded, None); - layer.order_index = obj_index; - layers.push(layer); - } - - let (width, height) = guess_scene_dimensions(&scene, &layers); - fill_solid_layer_sizes(&mut layers, &solid_indices, width, height); - apply_parent_transforms( - &scene, - &mut layers, - &mut particle_layers, - &mut mesh3d_layers, - ); - Ok(Self { - width, - height, - layers, - particle_layers, - mesh3d_layers, - transform_graph: build_transform_graph(&scene), - scene, - }) - } - - /// Load a scene wallpaper from a PKG archive + scene.json string. - /// No project.json is available here, so user properties keep defaults. - pub fn from_package(pkg: &Package, scene_json: &str) -> Result { - let scene = parse_scene_with_properties(scene_json, None)?; - - let mut layers = Vec::new(); - let mut solid_indices = Vec::new(); - let mut particle_layers = Vec::new(); - let mut mesh3d_layers = Vec::new(); - // Effective visibility (own AND every ancestor) — see Scene::visibility_mask. - let visible = scene.visibility_mask(); - // Raw `scene.objects` index (not a visible-only count): effect - // instances record the same raw index, so layers and effects agree - // on object identity no matter what gets skipped in between. - for (obj_index, obj) in scene.objects.iter().enumerate() { - if !visible[obj_index] { - continue; - } - if obj.light.is_some() && obj.image.is_none() { - if let Some(mut layer) = light_layer_from_object(obj) { - layer.order_index = obj_index; - layers.push(layer); - } - continue; - } - if obj.mesh3d_path().is_some() { - if let Some(mut ml) = mesh3d_layer_from_object(obj, None, Some(pkg)) { - ml.order_index = obj_index; - mesh3d_layers.push(ml); - } - continue; - } - if obj.particle.is_some() { - if let Some(mut pl) = particle_layer_from_object(obj, None, Some(pkg)) { - pl.order_index = obj_index; - particle_layers.push(pl); - } - continue; - } - if obj.text.is_some() { - if let Some(mut layer) = text_layer_from_object(obj, None, Some(pkg)) { - layer.order_index = obj_index; - layers.push(layer); - } - continue; - } - let loaded = match obj.image.as_deref() { - None => { - if obj.effects.is_empty() { - continue; - } - LoadedImage::single(placeholder_for(obj)) - } - Some(path) if is_solid_layer_path(path) => { - // Scene-size expansion is for effect chains only — see - // fill_solid_layer_sizes. - if !obj.effects.is_empty() { - solid_indices.push(layers.len()); - } - LoadedImage::single(white_pixel()) - } - Some(path) => { - if let Some(mut layer) = backdrop_layer_from_object(obj, path) { - if layer.size[0] == 0.0 || layer.size[1] == 0.0 { - solid_indices.push(layers.len()); - } - layer.order_index = obj_index; - layers.push(layer); - continue; - } - match load_texture_from_pkg(pkg, path) { - Ok(loaded) => loaded, - Err(_) => { - if obj.effects.is_empty() { - continue; - } - LoadedImage::single(placeholder_for(obj)) - } - } - } - }; - let mut layer = layer_from_object(obj, loaded, None); - layer.order_index = obj_index; - layers.push(layer); - } - - let (width, height) = guess_scene_dimensions(&scene, &layers); - fill_solid_layer_sizes(&mut layers, &solid_indices, width, height); - apply_parent_transforms( - &scene, - &mut layers, - &mut particle_layers, - &mut mesh3d_layers, - ); - Ok(Self { - width, - height, - layers, - particle_layers, - mesh3d_layers, - transform_graph: build_transform_graph(&scene), - scene, - }) - } - - /// Composite all layers into a single RGBA image, in true scene z-order - /// (image and particle layers interleaved by `order_index`, matching the - /// reference's single shared per-object render order — CScene.cpp's - /// `m_objectsByRenderOrder` — instead of drawing all particles after all - /// images). - pub fn render(&self) -> RgbaImage { - let mut canvas = RgbaImage::new(self.width, self.height); - - enum DrawItem { - Image(usize), - Particle(usize), - } - let mut items: Vec<(usize, DrawItem)> = self - .layers - .iter() - .enumerate() - .map(|(i, l)| (l.order_index, DrawItem::Image(i))) - .chain( - self.particle_layers - .iter() - .enumerate() - .map(|(i, pl)| (pl.order_index, DrawItem::Particle(i))), - ) - .collect(); - items.sort_by_key(|(order, _)| *order); - - for (_, item) in items { - match item { - DrawItem::Image(i) => self.draw_image_layer(&self.layers[i], &mut canvas), - // This is a single-shot preview render, so seed the - // simulation forward a few seconds instead of drawing an - // empty, freshly-spawned system. - DrawItem::Particle(i) => { - let pl = &self.particle_layers[i]; - let spawn_center = [ - pl.origin[0] as f32, - self.height as f32 - pl.origin[1] as f32, - ]; - let mut system = particle::ParticleSystem::from_config( - &pl.config, - spawn_center, - pl.overrides.as_ref(), - ); - if let Some(sprite) = &pl.sprite_texture { - system.set_sprite_frames(sprite.frames.len(), sprite.duration); - } - for child in &pl.children { - system.add_child( - child.config.clone(), - child.sprite.clone(), - child.additive, - &child.child_ref, - spawn_center, - ); - } - for _ in 0..150 { - system.step(1.0 / 30.0); - } - // The canvas here *is* the opaque scene, so additive - // accumulation directly implements the reference's - // GL_SRC_ALPHA/GL_ONE quad blending against it. - system.render_onto_blended( - &mut canvas, - pl.sprite_texture.as_ref(), - [0.0, 0.0], - pl.additive_blend, - ); - } - } - } - - canvas - } - - fn draw_image_layer(&self, layer: &Layer, canvas: &mut RgbaImage) { - let draw_w = if layer.size[0] != 0.0 { - (layer.size[0] * layer.scale[0]) as u32 - } else { - layer.image.width() - }; - let draw_h = if layer.size[1] != 0.0 { - (layer.size[1] * layer.scale[1]) as u32 - } else { - layer.image.height() - }; - if draw_w == 0 || draw_h == 0 { - return; - } - - let resized = if layer.image.width() != draw_w || layer.image.height() != draw_h { - image::imageops::resize( - &layer.image, - draw_w, - draw_h, - image::imageops::FilterType::Lanczos3, - ) - } else { - layer.image.clone() - }; - - // WE origin is the object's center in absolute scene coordinates - // (Y-up, (0,0) at bottom-left); convert to top-left pixel coords. - let px = (layer.origin[0] - draw_w as f64 / 2.0) as i64; - let py = (self.height as f64 - layer.origin[1] - draw_h as f64 / 2.0) as i64; - - let cw = canvas.width() as i64; - let ch = canvas.height() as i64; - - let mut blend_pixel = |cx: i64, cy: i64, s: image::Rgba| { - if cx < 0 || cx >= cw || cy < 0 || cy >= ch { - return; - } - let src_rgb = [ - (s[0] as f32 / 255.0) * layer.color[0] * layer.brightness, - (s[1] as f32 / 255.0) * layer.color[1] * layer.brightness, - (s[2] as f32 / 255.0) * layer.color[2] * layer.brightness, - ]; - let src_a = (s[3] as f32 / 255.0) * layer.alpha; - if src_a <= 0.0 { - return; - } - let dst = canvas.get_pixel_mut(cx as u32, cy as u32); - let dest_rgb = [ - dst[0] as f32 / 255.0, - dst[1] as f32 / 255.0, - dst[2] as f32 / 255.0, - ]; - let out = - crate::engine::blend::apply_blending(layer.blend_mode, dest_rgb, src_rgb, src_a); - dst[0] = (out[0].clamp(0.0, 1.0) * 255.0) as u8; - dst[1] = (out[1].clamp(0.0, 1.0) * 255.0) as u8; - dst[2] = (out[2].clamp(0.0, 1.0) * 255.0) as u8; - let dst_a = dst[3] as f32 / 255.0; - dst[3] = ((dst_a + src_a * (1.0 - dst_a)).clamp(0.0, 1.0) * 255.0) as u8; - }; - - if layer.angle == 0.0 { - for y in 0..draw_h { - for x in 0..draw_w { - blend_pixel(px + x as i64, py + y as i64, *resized.get_pixel(x, y)); - } - } - } else { - // Rotated layer: walk the canvas-space bounding box of the - // rotated rect and inverse-sample back into the unrotated - // `resized` image (nearest-neighbor). - let ccx = px as f64 + draw_w as f64 / 2.0; - let ccy = py as f64 + draw_h as f64 / 2.0; - let half_diag = ((draw_w as f64 / 2.0).powi(2) + (draw_h as f64 / 2.0).powi(2)).sqrt(); - let (sin_a, cos_a) = (layer.angle as f64).sin_cos(); - let x0 = ((ccx - half_diag).floor() as i64).max(0); - let x1 = ((ccx + half_diag).ceil() as i64).min(cw); - let y0 = ((ccy - half_diag).floor() as i64).max(0); - let y1 = ((ccy + half_diag).ceil() as i64).min(ch); - for cy in y0..y1 { - for cx in x0..x1 { - let fx = cx as f64 + 0.5 - ccx; - let fy = cy as f64 + 0.5 - ccy; - // Inverse rotation (screen → unrotated local space). - let lx = cos_a * fx + sin_a * fy; - let ly = -sin_a * fx + cos_a * fy; - let sx = (lx + draw_w as f64 / 2.0).floor(); - let sy = (ly + draw_h as f64 / 2.0).floor(); - if sx < 0.0 || sy < 0.0 || sx >= draw_w as f64 || sy >= draw_h as f64 { - continue; - } - blend_pixel(cx, cy, *resized.get_pixel(sx as u32, sy as u32)); - } - } - } - } -} - -/// WE `parallaxDepth` is always a vec2 (independent x/y depth), but a bare -/// number in scene.json means "same depth on both axes". -fn parse_parallax_depth(v: &serde_json::Value) -> [f64; 2] { - use crate::engine::model::DynamicValue; - match crate::engine::model::json_to_animated(v).value { - DynamicValue::Float(f) => [f as f64; 2], - DynamicValue::Vec3([x, y, _]) => [x as f64, y as f64], - _ => [0.0, 0.0], - } -} - -/// WE origin convention: Y-up, (0,0) at bottom-left (same as `Layer::origin`). -/// "top"/"bottom" shift the object vertically by half its scaled height so -/// the origin becomes that edge instead of the center; "left"/"right" do the -/// same horizontally. Matches CImage.cpp lines 242-256 exactly (each pair is -/// mutually exclusive — "top bottom" together, like the reference, only -/// honors "top"). -pub(crate) fn alignment_offset(alignment: Option<&str>, scaled_size: [f64; 2]) -> [f64; 2] { - let a = alignment.unwrap_or("").to_lowercase(); - let mut offset = [0.0, 0.0]; - if a.contains("top") { - offset[1] -= scaled_size[1] / 2.0; - } else if a.contains("bottom") { - offset[1] += scaled_size[1] / 2.0; - } - if a.contains("left") { - offset[0] += scaled_size[0] / 2.0; - } else if a.contains("right") { - offset[0] -= scaled_size[0] / 2.0; - } - offset -} - -/// `alignment_override`, when given, replaces `obj.alignment` (text objects -/// combine separate `horizontalalign`/`verticalalign` fields into one string -/// before calling this, rather than reading `obj.alignment` directly). -/// Brightness multiplier for additive (colorBlendMode 9) image layers — see the -/// call site. Default 0.55; override live with `WP_ENGINE_ADDITIVE_BRIGHTNESS`. -fn additive_brightness_scale() -> f32 { - std::env::var("WP_ENGINE_ADDITIVE_BRIGHTNESS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(0.55) -} - -/// For a `SPRITESHEET` layer, pick the single frame the object's frame script -/// selects and make it the layer image. Frames are the `.tex` TEXS sub-rects; the -/// index comes from the object's frame-select script (WE hangs -/// `getTextureAnimation().setFrame` off the `visible` script — e.g. AM/PM by -/// `getHours()`). Falls back to frame 0 if the script sets none. -fn select_spritesheet_frame(obj: &SceneObject, loaded: &mut LoadedImage) { - if loaded.sprite_frames.len() < 2 { - return; - } - let frame = obj - .visible - .as_ref() - .and_then(|v| v.get("script")) - .and_then(|s| s.as_str()) - .and_then(|script| { - let init = crate::engine::script::ObjectInit { - visible: true, - alpha: 1.0, - frame_count: loaded.sprite_frames.len() as u32, - ..Default::default() - }; - crate::engine::script::evaluate_once("visible", script, &init, None).frame - }) - .unwrap_or(0) - .min(loaded.sprite_frames.len() as u32 - 1); - loaded.image = loaded.sprite_frames[frame as usize].clone(); -} - -/// Build the layer for one object outside the scene load — SceneScript's -/// `thisScene.createLayer`. Covers image, solid and text objects; particle -/// and model objects return `None`. `pkg` is the scene's package, if any. -pub fn runtime_layer(dir: &Path, pkg: Option<&Package>, obj: &SceneObject) -> Option { - if obj.text.is_some() { - return text_layer_from_object(obj, Some(dir), pkg); - } - let path = obj.image.as_deref()?; - if is_special_layer(path) { - return backdrop_layer_from_object(obj, path); - } - let resolved_path = resolve_workshop_asset(dir, pkg, path); - let path = resolved_path.as_str(); - let loaded = if is_solid_layer_path(path) { - LoadedImage::single(white_pixel()) - } else { - let from_pkg = pkg.map(|p| load_texture_from_pkg(p, path)); - match from_pkg { - Some(Ok(l)) => l, - _ => load_texture_from_dir(dir, path) - .inspect_err( - |e| tracing::warn!(target: "scene", "createLayer: texture '{path}': {e:#}"), - ) - .ok()?, - } - }; - Some(layer_from_object(obj, loaded, None)) -} - -/// Scripts copied from a Workshop asset pack name assets relative to that -/// pack (`models/bar.json`), which ships at `models/workshop//bar.json`. -/// Returns `path` itself when it exists or no such copy is found. -fn resolve_workshop_asset(dir: &Path, pkg: Option<&Package>, path: &str) -> String { - let exists = |p: &str| pkg.is_some_and(|k| k.contains(p)) || dir.join(p).is_file(); - if exists(path) { - return path.to_string(); - } - let Some((first, rest)) = path.split_once('/') else { - return path.to_string(); - }; - let prefix = format!("{first}/workshop/"); - let suffix = format!("/{rest}"); - let matches = |p: &str| { - p.strip_prefix(&prefix) - .and_then(|r| r.strip_suffix(&suffix)) - .is_some_and(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit())) - }; - if let Some(found) = pkg.and_then(|k| k.paths().find(|p| matches(p)).map(str::to_string)) { - return found; - } - if let Ok(entries) = std::fs::read_dir(dir.join(first).join("workshop")) { - for e in entries.flatten() { - let candidate = format!("{prefix}{}{suffix}", e.file_name().to_string_lossy()); - if matches(&candidate) && dir.join(&candidate).is_file() { - return candidate; - } - } - } - path.to_string() -} - -fn layer_from_object( - obj: &SceneObject, - mut loaded: LoadedImage, - alignment_override: Option<&str>, -) -> Layer { - // Spritesheet: replace the flattened atlas with the single cell the object's - // frame script selects (e.g. AM/PM by `getHours()`), so the one-cell quad - // shows one glyph instead of every cell squished together. - // ponytail: frame is picked once at load from the current wall clock; a live - // wallpaper won't flip AM↔PM mid-session without a reload (rare — re-select in - // the render loop if that ceiling ever matters). - select_spritesheet_frame(obj, &mut loaded); - // Attach puppet `animationlayers` (blended each frame) to the fresh runtime. - if let (Some(rt), Some(entries)) = ( - loaded.puppet.as_mut(), - obj.animationlayers.as_ref().and_then(|v| v.as_array()), - ) { - if let Some(rt) = std::sync::Arc::get_mut(rt) { - let n = rt.model.animations.len(); - if n > 0 { - let layers: Vec = entries - .iter() - .enumerate() - // `visible` is often a user-property binding - // (`{"user": "head", "value": false}`), not a bare bool. - .filter(|(_, e)| { - e.get("visible") - .and_then(crate::engine::scene::parse_value_bool) - .unwrap_or(true) - }) - .map(|(i, e)| { - // `animation` is the MDLA record's own id (2321732083's - // samurai layers name 60/63/80/105). Match it; fall back - // to index-then-declaration-order for files whose ids we - // failed to read. - let raw = e - .get("animation") - .and_then(|v| v.as_u64()) - .unwrap_or(i as u64); - let anim_idx = rt - .model - .animations - .iter() - .position(|a| a.id as u64 == raw) - .unwrap_or_else(|| { - if (raw as usize) < n { - raw as usize - } else { - i.min(n - 1) - } - }); - crate::engine::puppet::AnimLayer { - anim_idx, - additive: e.get("additive").and_then(|v| v.as_bool()).unwrap_or(false), - blend: e.get("blend").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32, - rate: e.get("rate").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32, - } - }) - .collect(); - rt.layers = layers; - } - } - } - let parallax = obj - .parallax_depth - .as_ref() - .map(parse_parallax_depth) - .unwrap_or([0.0, 0.0]); - - // WE stores angles already in radians; only the z component (2D roll) - // applies to a flat layer quad. The reference negates it (rotate(-angle, - // ...)) when building the object's screen transform. - let angles_animated = obj - .angles - .as_ref() - .map(crate::engine::model::json_to_animated); - let angles3 = angles_animated - .as_ref() - .and_then(|v| v.as_vec3()) - .unwrap_or([0.0, 0.0, 0.0]); - let angle = -angles3[2]; - - let scale_animated = obj - .scale - .as_ref() - .map(crate::engine::model::json_to_animated); - let scale = scale_animated - .as_ref() - .and_then(|v| v.as_vec3()) - .map(|s| [s[0] as f64, s[1] as f64, s[2] as f64]) - .unwrap_or([1.0, 1.0, 1.0]); - let alpha_animated = obj - .alpha - .as_ref() - .map(crate::engine::model::json_to_animated); - let alpha = alpha_animated - .as_ref() - .and_then(|v| v.as_float()) - .unwrap_or(1.0); - let color = obj - .color - .as_ref() - .map(crate::engine::model::json_to_animated) - .and_then(|v| v.as_vec3()) - .unwrap_or([1.0, 1.0, 1.0]); - let brightness = obj - .brightness - .as_ref() - .map(crate::engine::model::json_to_animated) - .and_then(|v| v.as_float()) - .unwrap_or(1.0); - // ponytail: additive (colorBlendMode 9) image planes with a near-white base - // (e.g. LonelyCat's water-ripple layer) blow out to a flat white disc, - // because our water effects refract the layer's *own* bright base instead of - // the real backdrop (backdrop capture is unimplemented — see PROGRESS.md). - // Until that lands, scale additive layers down so the ripple structure stays - // visible. Ceiling: this dims *every* additive image layer, not just ripples; - // remove once screen-space backdrop capture exists. Tunable live via - // WP_ENGINE_ADDITIVE_BRIGHTNESS (default 0.55). - let brightness = if obj.color_blend_mode == 9 { - brightness * additive_brightness_scale() - } else { - brightness - }; - - // Alignment shifts the object so its origin becomes an edge rather than - // its center (CImage.cpp lines 242-256), expressed here as an offset - // folded directly into `origin` — every consumer of `Layer::origin` - // (both render paths) then gets alignment for free. - // Text objects (the only caller passing `alignment_override`) size their - // quad from the rasterized glyph bitmap, not the scene `size` field — - // WE's CText uses `m_quadSize` (the raster dims) and ignores `size` - // (which is editor bounding-box metadata). Honoring it would stretch the - // glyphs to an unrelated box. - let is_text = alignment_override.is_some(); - let raw_size = obj.parsed_size(); - let effective_size = if !is_text && raw_size[0] > 0.0 && raw_size[1] > 0.0 { - [raw_size[0], raw_size[1]] - } else { - [loaded.image.width() as f64, loaded.image.height() as f64] - }; - let scaled_size = [effective_size[0] * scale[0], effective_size[1] * scale[1]]; - let alignment = alignment_override.or(obj.alignment.as_deref()); - let align_offset = alignment_offset(alignment, scaled_size); - let origin = { - let o = obj.parsed_origin(); - [o[0] + align_offset[0], o[1] + align_offset[1], o[2]] - }; - - Layer { - backdrop: None, - name: obj.name.clone().unwrap_or_default(), - image: loaded.image, - extra_frames: loaded.extra_frames, - frame_duration_ms: loaded.frame_duration_ms, - origin, - // Text: leave size 0 so both render paths fall back to the raster dims. - size: if is_text { - [0.0, 0.0, 0.0] - } else { - obj.parsed_size() - }, - scale, - local_origin: origin, - local_scale: scale, - local_angles: angles3, - parallax_depth: parallax, - angle, - angles: angles3, - blend_mode: obj.color_blend_mode, - alpha, - color, - brightness, - copybackground: obj.copybackground, - no_interpolation: loaded.no_interpolation, - // Object-level `clampuvs` overrides the .tex ClampUVs flag when set. - clamp_uvs: loaded.clamp_uvs - || obj - .clampuvs - .as_ref() - .and_then(|v| { - v.as_bool() - .or_else(|| v.get("value").and_then(|i| i.as_bool())) - }) - .unwrap_or(false), - puppet: loaded.puppet, - video: loaded.video, - text_dynamic: None, - order_index: 0, - object_id: obj.id, - visible_base: obj.is_visible(), - } -} - -fn is_video_path(p: &str) -> bool { - let p = p.to_lowercase(); - p.ends_with(".mp4") || p.ends_with(".webm") || p.ends_with(".mkv") || p.ends_with(".avi") -} - -/// Resolve a particle preset's JSON text, checking the wallpaper's own -/// directory, then its `scene.pkg` (if given), then the global Steam assets -/// dir — mirroring the same wallpaper-first priority used for shaders/effects -/// (`shaders::resolver::AssetResolver`). -/// Read a scene asset from wherever it lives: loose files, the pkg archive, -/// then the shared WE assets dir. -fn read_asset_bytes(dir: Option<&Path>, pkg: Option<&Package>, rel_path: &str) -> Option> { - if let Some(dir) = dir { - if let Ok(data) = std::fs::read(dir.join(rel_path)) { - return Some(data); - } - } - if let Some(pkg) = pkg { - if let Some(data) = pkg.get(rel_path) { - return Some(data.to_vec()); - } - } - read_from_global_assets(rel_path) -} - -fn read_particle_json(dir: Option<&Path>, pkg: Option<&Package>, rel_path: &str) -> Option { - String::from_utf8(read_asset_bytes(dir, pkg, rel_path)?).ok() -} - -/// Build a `ParticleLayer` from a scene object whose `particle` field is a -/// preset path, resolving and parsing the referenced JSON. Returns `None` -/// (silently skipped, like a missing texture) if the preset can't be found -/// or parsed. -fn particle_layer_from_object( - obj: &SceneObject, - dir: Option<&Path>, - pkg: Option<&Package>, -) -> Option { - let particle_ref = match &obj.particle { - Some(serde_json::Value::String(s)) => s.as_str(), - _ => return None, - }; - let json = read_particle_json(dir, pkg, particle_ref)?; - let mut config: particle::ParticleConfig = serde_json::from_str(&json) - .inspect_err( - |e| tracing::warn!(target: "particle", "failed to parse '{particle_ref}': {e}"), - ) - .ok()?; - let overrides: Option = obj - .instanceoverride - .as_ref() - .and_then(|v| serde_json::from_value(v.clone()).ok()); - - // `instanceoverride.enabled: false` turns the whole system off - // (ObjectParser defaults it to true). - if overrides.as_ref().is_some_and(|o| { - o.enabled - .as_ref() - .is_some_and(|v| v.as_bool() == Some(false) || v.as_u64() == Some(0)) - }) { - return None; - } - - // Control-point plumbing the simulation can't do itself (it only knows - // spawn-relative screen offsets): - // 1. `instanceoverride.controlpointN` repositions preset control points - // with absolute scene coordinates (e.g. the discharge preset's arc - // endpoint, "1586.7 993.2") — absolute values are world-space, so - // force flag 2 on. - // 2. Any world-space control point (flags & 2) is then converted to a - // spawn-relative offset here, where the object's origin is known: - // offset = (x_we - origin_x, origin_y - y_we) in the simulation's - // y-down screen space. - if let Some(over) = overrides.as_ref() { - for (key, value) in &over.extra { - let Some(n) = key - .strip_prefix("controlpoint") - .and_then(|s| s.parse::().ok()) - else { - continue; - }; - while config.controlpoint.len() <= n { - config - .controlpoint - .push(particle::ControlPointConfig::default()); - } - config.controlpoint[n].offset = Some(value.clone()); - config.controlpoint[n].flags = Some(config.controlpoint[n].flags.unwrap_or(0) | 2); - } - } - let origin = obj.parsed_origin(); - for cp in &mut config.controlpoint { - if cp.flags.unwrap_or(0) & 2 == 0 { - continue; - } - if let Some(world) = cp.offset.as_ref().and_then(particle::value_as_vec3_pub) { - let rel = [ - world[0] - origin[0] as f32, - origin[1] as f32 - world[1], - world[2], - ]; - cp.offset = Some(serde_json::json!(format!( - "{} {} {}", - rel[0], rel[1], rel[2] - ))); - cp.flags = Some(cp.flags.unwrap_or(0) & !2); - } - } - let parallax_depth = obj - .parallax_depth - .as_ref() - .map(parse_parallax_depth) - .unwrap_or([0.0, 0.0]); - - let resolved_sprite = config.material.as_deref().and_then(|mat_path| { - // A failure here silently degrades to the flat-color circle draw — - // visually plausible for dust/snow but wrong for shaped sprites, so - // it must at least be visible in the log. - if let Some(pkg) = pkg { - resolve_particle_sprite_pkg(pkg, mat_path) - .inspect_err(|e| { - eprintln!("[particle] sprite '{mat_path}' failed (untextured fallback): {e:#}") - }) - .ok() - } else { - dir.and_then(|dir| { - resolve_particle_sprite_dir(dir, mat_path) - .inspect_err(|e| { - eprintln!( - "[particle] sprite '{mat_path}' failed (untextured fallback): {e:#}" - ) - }) - .ok() - }) - } - }); - let additive_blend = resolved_sprite - .as_ref() - .is_some_and(|(_, blending)| blending.as_deref() == Some("additive")); - let sprite_texture = resolved_sprite.map(|(sprite, _)| sprite); - - // Child presets: same preset-JSON + material resolution as the parent. - // Failures degrade to "child skipped" (logged), never sink the layer. - let children: Vec = config - .children - .clone() - .unwrap_or_default() - .into_iter() - .filter_map(|child_ref| { - let json = read_particle_json(dir, pkg, &child_ref.name).or_else(|| { - eprintln!("[particle] child preset '{}' not found", child_ref.name); - None - })?; - let child_cfg: particle::ParticleConfig = serde_json::from_str(&json) - .inspect_err(|e| { - eprintln!("[particle] child '{}' parse failed: {e}", child_ref.name) - }) - .ok()?; - let resolved = child_cfg.material.as_deref().and_then(|mat_path| { - if let Some(pkg) = pkg { - resolve_particle_sprite_pkg(pkg, mat_path).ok() - } else { - dir.and_then(|dir| resolve_particle_sprite_dir(dir, mat_path).ok()) - } - }); - let additive = resolved - .as_ref() - .is_some_and(|(_, blending)| blending.as_deref() == Some("additive")); - let sprite = resolved.map(|(sprite, _)| sprite); - Some(ResolvedChildParticle { - config: child_cfg, - sprite, - additive, - child_ref, - }) - }) - .collect(); - - Some(ParticleLayer { - name: obj.name.clone().unwrap_or_default(), - origin: obj.parsed_origin(), - local_origin: obj.parsed_origin(), - parallax_depth, - config, - overrides, - sprite_texture, - additive_blend, - order_index: 0, - children, - }) -} - -/// `text` is either a plain JSON string or a `{"user": ..., "value": "..."}` -/// wrapper (scene.json's usual user-setting shape) — deliberately NOT routed -/// through `json_to_animated`, which treats 2-3 whitespace-separated numbers -/// as a vector; that heuristic is wrong for text content that happens to be -/// purely numeric (e.g. a countdown reading "12 34"). -fn extract_text_string(v: &serde_json::Value) -> Option { - match v { - serde_json::Value::String(s) => Some(s.clone()), - serde_json::Value::Object(map) => map.get("value").and_then(extract_text_string), - _ => None, - } -} - -/// Build a text object's rendered bitmap as a normal `Layer` (see -/// `engine::text` module docs for why: no scripting engine means text never -/// changes after this one rasterization, so it can reuse 100% of the -/// existing image-layer pipeline in both render paths for free). -fn text_layer_from_object( - obj: &SceneObject, - dir: Option<&Path>, - pkg: Option<&Package>, -) -> Option { - // A text object's `text` is either a plain string, a {"value": …} - // wrapper, or a scripted {"script": "…", "value"?: …} — clocks/dates - // (the dominant case in real content) often ship the script with NO - // static value at all, so the script's first evaluation IS the content. - let text_value = obj.text.as_ref()?; - let script = text_value - .get("script") - .and_then(|v| v.as_str()) - .map(str::to_string); - let script_properties = text_value.get("scriptproperties").cloned(); - let static_str = extract_text_string(text_value).unwrap_or_default(); - let text_str = match (&script, static_str.is_empty()) { - (Some(script), _) => { - // One-shot load-time evaluation so the layer rasterizes real - // content (and sizes itself correctly) from frame 0. - let init = crate::engine::script::ObjectInit { - name: obj.name.clone(), - id: obj.id, - visible: true, - alpha: 1.0, - text: Some(static_str.clone()), - ..Default::default() - }; - crate::engine::script::evaluate_once("text", script, &init, script_properties.as_ref()) - .text - .filter(|t| !t.is_empty()) - .unwrap_or(static_str) - } - (None, false) => static_str, - (None, true) => return None, - }; - if text_str.is_empty() { - return None; - } - let point_size = obj - .pointsize - .as_ref() - .and_then(|v| { - v.as_f64() - .or_else(|| v.get("value").and_then(|v| v.as_f64())) - }) - .unwrap_or(32.0) as f32; - let scale = obj - .scale - .as_ref() - .and_then(|v| crate::engine::model::json_to_animated(v).as_vec3()) - .unwrap_or([1.0, 1.0, 1.0]); - // The on-screen text height is driven by the object's `size` BOX height, - // not pointsize: `layer_from_object` scales the rasterized bitmap by the - // object's `scale`, and WE authors size text via that box (a big box + - // scale>1 = big text; a modest box + scale<1 = small text). Rasterize at - // the box height so the bitmap ≈ its on-screen size (crisp, natural aspect, - // no stretch). `pointsize` here is only the fallback resolution when the - // object ships no box (then compensate for `scale` like CText does, so the - // glyphs don't collapse to a couple of pixels). - let box_h = obj.parsed_size()[1] as f32; - let raster_px = if box_h > 0.0 { - box_h - } else { - let avg_scale = (scale[0] + scale[1]) * 0.5; - let compensate = if avg_scale > 0.0 && avg_scale < 1.0 { - (1.0 / avg_scale).min(32.0) - } else { - 1.0 - }; - point_size * compensate - } - .clamp(1.0, 1024.0); - let font_data = super::text::resolve_font_data(obj.font.as_deref(), dir, pkg)?; - // Word-wrap to `maxwidth` (scene px == bitmap px at our raster resolution) - // and cap at `maxrows`, if authored. - let gate = |v: &Option| { - v.as_ref() - .and_then(crate::engine::scene::parse_value_bool) - .unwrap_or(false) - }; - let text_str = match obj - .maxwidth - .as_ref() - .and_then(crate::engine::scene::parse_value_f32) - .filter(|w| *w > 0.0) - .filter(|_| gate(&obj.limitwidth)) - { - Some(mw) => { - let rows = obj - .maxrows - .as_ref() - .and_then(crate::engine::scene::parse_value_f32) - .filter(|_| gate(&obj.limitrows)) - .unwrap_or(0.0) - .max(0.0) as usize; - super::text::wrap_text( - &font_data, - &text_str, - raster_px, - mw, - rows, - gate(&obj.limituseellipsis), - ) - } - None => text_str, - }; - let image = super::text::rasterize(&font_data, &text_str, raster_px)?; - - // Opaque background box: bake the text color + bg color + padding into the - // bitmap and neutralize the layer tint (color set to white below). `pad` - // is authored in scene px, which maps 1:1 to bitmap px (we raster at the - // box height). Off by default — `opaquebackground` is usually false. - let opaque_bg = obj - .opaquebackground - .as_ref() - .and_then(|v| { - v.as_bool() - .or_else(|| v.get("value").and_then(|i| i.as_bool())) - }) - .unwrap_or(false); - let (image, bg_baked) = if opaque_bg { - let text_color = obj - .color - .as_ref() - .and_then(|v| crate::engine::scene::parse_value_vec3(v)) - .map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]) - .unwrap_or([1.0, 1.0, 1.0]); - let bright = obj - .backgroundbrightness - .as_ref() - .and_then(crate::engine::scene::parse_value_f32) - .unwrap_or(1.0); - let bg = obj - .backgroundcolor - .as_ref() - .and_then(|v| crate::engine::scene::parse_value_vec3(v)) - .map(|c| { - [ - c[0] as f32 * bright, - c[1] as f32 * bright, - c[2] as f32 * bright, - ] - }) - .unwrap_or([0.0, 0.0, 0.0]); - let pad = obj - .padding - .as_ref() - .and_then(crate::engine::scene::parse_value_f32) - .unwrap_or(0.0) - .max(0.0) as u32; - ( - super::text::with_background(&image, text_color, bg, pad), - true, - ) - } else { - (image, false) - }; - - let halign = obj - .horizontalalign - .as_deref() - .or(obj.alignment.as_deref()) - .unwrap_or("center"); - let valign = obj.verticalalign.as_deref().unwrap_or("center"); - let combined = format!("{halign} {valign}"); - - let mut layer = layer_from_object(obj, LoadedImage::single(image), Some(&combined)); - // WE fits the glyphs into the authored `size` box preserving their - // aspect (letterbox), not stretch-to-fill: a tall clock box (375×161) - // over ~99×26 glyphs would render the block ~6× too tall and collide with - // the layer below it. Shrink the box to the bitmap aspect — the - // constrained dimension keeps the box, the other pulls in. - // ponytail: refit only; origin's alignment offset was folded for the - // original box, so this is exact for center-aligned text (all clocks/dates - // here) and would need an offset re-fold for left/top anchors. - let bw = layer.image.width() as f64; - let bh = layer.image.height() as f64; - if bw > 0.0 && bh > 0.0 && layer.size[0] > 0.0 && layer.size[1] > 0.0 { - let aspect = bw / bh; - let (box_w, box_h) = (layer.size[0], layer.size[1]); - let fitted = if box_w / box_h > aspect { - [box_h * aspect, box_h] - } else { - [box_w, box_w / aspect] - }; - layer.size[0] = fitted[0]; - layer.size[1] = fitted[1]; - } - if bg_baked { - // Text + bg colors are baked into the bitmap; don't tint it again. - layer.color = [1.0, 1.0, 1.0]; - } - // Any text layer can be re-rasterized: its own script, or another - // object's script, may set its text. - layer.text_dynamic = Some(TextDynamic { - font_data, - point_size: raster_px, - last_text: text_str, - }); - Some(layer) -} - -/// Build a static-3D-mesh layer for an object whose `model` names a `.mdl`. -/// `read` fetches an asset by relative path (dir- or pkg-backed), and -/// `resolve_tex` turns the mesh's embedded material path into its texture. -fn mesh3d_layer_from_object( - obj: &SceneObject, - dir: Option<&Path>, - pkg: Option<&Package>, -) -> Option { - let path = obj.mesh3d_path()?; - // The pkg stores every path lowercased, while scene.json and the `.mdl` - // header keep the author's casing (`models/LP/LP.mdl`, and real content - // has non-ASCII names too) — so retry lowercased on any miss. - let bytes = read_asset_bytes(dir, pkg, path) - .or_else(|| read_asset_bytes(dir, pkg, &path.to_lowercase()))?; - let mesh = crate::engine::mesh3d::parse(&bytes)?; - let resolve_tex = |p: &str| { - dir.and_then(|d| load_texture_from_dir(d, p).ok()) - .or_else(|| pkg.and_then(|p2| load_texture_from_pkg(p2, p).ok())) - .map(|l| l.image) - }; - let pass = material_first_pass(dir, pkg, &mesh.material); - let texture = resolve_tex(&mesh.material) - .or_else(|| resolve_tex(&mesh.material.to_lowercase())) - // Untextured materials are normal here (`"textures": [null,null,null]` - // with a flat `color` constant): draw them as that color. An - // unresolvable material falls through to white rather than vanishing. - .unwrap_or_else(|| { - let [r, g, b] = pass - .as_ref() - .and_then(pass_constant_color) - .unwrap_or([255; 3]); - RgbaImage::from_pixel(1, 1, image::Rgba([r, g, b, 255])) - }); - let nocull = pass - .as_ref() - .and_then(|p| p.get("cullmode")?.as_str()) - .is_some_and(|m| m == "nocull"); - // The object's own `depthtest` wins over the material pass's. - let depthtest = obj - .depthtest - .as_ref() - .and_then(|v| v.as_str()) - .or_else(|| pass.as_ref().and_then(|p| p.get("depthtest")?.as_str())) - .map(|m| m != "disabled") - .unwrap_or(true); - - let scale = obj - .scale - .as_ref() - .and_then(|v| crate::engine::model::json_to_animated(v).as_vec3()) - .unwrap_or([1.0, 1.0, 1.0]); - let angles = obj - .angles - .as_ref() - .and_then(|v| crate::engine::model::json_to_animated(v).as_vec3()) - .unwrap_or([0.0, 0.0, 0.0]); - let o = obj.parsed_origin(); - - tracing::debug!( - target: "scene", - "mesh3d '{path}': {} verts, {} tris, material '{}'", - mesh.positions.len(), - mesh.indices.len() / 3, - mesh.material - ); - Some(Mesh3dLayer { - name: obj.name.clone().unwrap_or_default(), - mesh, - texture, - origin: [o[0] as f32, o[1] as f32, o[2] as f32], - scale, - angles, - local_origin: [o[0] as f32, o[1] as f32, o[2] as f32], - local_scale: scale, - local_angles: angles, - nocull, - depthtest, - order_index: 0, - }) -} - -/// A WE light object rendered as an additive radial glow — a minimal stand-in -/// for the full lighting model. Colored by `color`, sized by `radius`, with a -/// smooth quadratic falloff scaled by `intensity`. Additively blended so it -/// brightens whatever is behind it (like a real light). -fn light_layer_from_object(obj: &SceneObject) -> Option { - let (radius, intensity, color) = obj.light_params()?; - - let d = (radius * 2.0).min(2048.0) as u32; - let mut img = RgbaImage::new(d, d); - let c = d as f32 / 2.0; - for y in 0..d { - for x in 0..d { - let dist = (((x as f32 - c).powi(2) + (y as f32 - c).powi(2)).sqrt() / c).min(1.0); - let a = (1.0 - dist).powi(2) * intensity; // quadratic falloff - img.put_pixel( - x, - y, - image::Rgba([ - (color[0] * 255.0) as u8, - (color[1] * 255.0) as u8, - (color[2] * 255.0) as u8, - (a * 255.0) as u8, - ]), - ); - } - } - - let mut layer = layer_from_object(obj, LoadedImage::single(img), None); - // colorBlendMode 9 = Add (linear dodge) — the glow adds light. - layer.blend_mode = 9; - layer.color = [1.0, 1.0, 1.0]; - Some(layer) -} - -/// A live decode stream for an embedded-video texture layer: a detached -/// ffmpeg thread loops the video (PTS-paced, same `video_decode_loop` the -/// full-video wallpaper path uses) and feeds frames through a small bounded -/// channel. The consumer drains to the newest frame each render tick, so -/// slow consumers drop frames instead of lagging. The thread exits on its -/// own once this struct (the receiver) is dropped, and the temp file -/// backing the looping decoder is removed then too. -pub struct VideoLayerStream { - rx: std::sync::mpsc::Receiver>, - /// Temp file the decoder re-opens on every loop — must outlive playback. - path: std::path::PathBuf, -} - -impl VideoLayerStream { - /// Newest decoded frame since the last call, if any arrived. - pub fn latest_frame(&self) -> Option> { - let mut latest = None; - while let Ok(frame) = self.rx.try_recv() { - latest = Some(frame); - } - latest - } -} - -impl Drop for VideoLayerStream { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.path); - } -} - -/// Start looping playback for an embedded video payload: writes the bytes -/// to a temp file, decodes the first frame synchronously (so the layer has -/// correct content immediately), and spawns the paced decode loop for the -/// rest. -fn start_video_stream(bytes: &[u8]) -> Result<(RgbaImage, VideoLayerStream)> { - use std::sync::atomic::{AtomicU64, Ordering}; - static SEQ: AtomicU64 = AtomicU64::new(0); - let tmp = std::env::temp_dir().join(format!( - "we_embvid_{}_{}.mp4", - std::process::id(), - SEQ.fetch_add(1, Ordering::Relaxed) - )); - std::fs::write(&tmp, bytes) - .with_context(|| format!("writing embedded video temp file: {}", tmp.display()))?; - let first = match crate::render::ffmpeg::decode_first_frame(&tmp) { - Ok(img) => img, - Err(e) => { - let _ = std::fs::remove_file(&tmp); - return Err(e); - } - }; - let (tx, rx) = std::sync::mpsc::sync_channel::>(2); - let decode_path = tmp.clone(); - std::thread::spawn(move || { - // Exits when the receiver (VideoLayerStream) is dropped. - if let Err(e) = crate::render::ffmpeg::video_decode_loop(&decode_path, &tx) { - eprintln!( - "embedded video decoder error for '{}': {e}", - decode_path.display() - ); - } - }); - Ok((first, VideoLayerStream { rx, path: tmp })) -} - -/// Build a `LoadedImage` for an embedded-video texture: first frame as the -/// image, plus the live stream for the GPU path to animate. -fn video_tex_to_loaded(tex: &TexFile, bytes: &[u8]) -> Result { - let (first, stream) = start_video_stream(bytes)?; - let mut loaded = LoadedImage::single(first); - loaded.no_interpolation = tex.no_interpolation(); - loaded.clamp_uvs = tex.clamp_uvs(); - loaded.video = Some(std::sync::Arc::new(std::sync::Mutex::new(stream))); - Ok(loaded) -} - -fn load_texture_from_dir(dir: &Path, image_path: &str) -> Result { - // If it's a .json reference, resolve the model/material chain - if image_path.ends_with(".json") { - return resolve_model_chain_dir(dir, image_path); - } - - let full_path = dir.join(image_path); - - // Video file — extract first frame as static thumbnail - if is_video_path(image_path) && full_path.exists() { - return crate::render::ffmpeg::decode_first_frame(&full_path).map(LoadedImage::single); - } - - // Try as .tex first - let tex_path = full_path.with_extension("tex"); - if tex_path.exists() { - let data = - std::fs::read(&tex_path).with_context(|| format!("reading {}", tex_path.display()))?; - let tex = - TexFile::parse(&data).with_context(|| format!("parsing {}", tex_path.display()))?; - return LoadedImage::from_tex(&tex); - } - - if full_path.exists() { - return image::open(&full_path) - .map(|i| LoadedImage::single(i.into_rgba8())) - .with_context(|| format!("loading {}", full_path.display())); - } - - let tex_path_str = format!("{}.tex", full_path.display()); - let tex_appended = Path::new(&tex_path_str); - if tex_appended.exists() { - let data = std::fs::read(tex_appended) - .with_context(|| format!("reading {}", tex_appended.display()))?; - let tex = TexFile::parse(&data)?; - return LoadedImage::from_tex(&tex); - } - - anyhow::bail!("texture not found: {image_path}") -} - -fn load_texture_from_pkg(pkg: &Package, image_path: &str) -> Result { - // If the image path points to a .json model/material, resolve the chain. - if image_path.ends_with(".json") { - return resolve_model_chain_pkg(pkg, image_path); - } - - // Try as .tex - let tex_name = if image_path.ends_with(".tex") { - image_path.to_string() - } else { - format!("{image_path}.tex") - }; - - if let Some(data) = pkg.get(&tex_name) { - let tex = - TexFile::parse(data).with_context(|| format!("parsing .tex from pkg: {tex_name}"))?; - return LoadedImage::from_tex(&tex); - } - - // Try original path (png/jpg or video embedded in pkg) - if let Some(data) = pkg.get(image_path) { - if is_video_path(image_path) { - let ext = std::path::Path::new(image_path) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("mp4"); - let tmp = std::env::temp_dir().join(format!( - "we_vidtex_{}.{ext}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.subsec_nanos()) - .unwrap_or(0) - )); - std::fs::write(&tmp, data) - .with_context(|| format!("writing video temp file: {}", tmp.display()))?; - let result = crate::render::ffmpeg::decode_first_frame(&tmp); - let _ = std::fs::remove_file(&tmp); - return result.map(LoadedImage::single); - } - return image::load_from_memory(data) - .map(|i| LoadedImage::single(i.into_rgba8())) - .with_context(|| format!("loading image from pkg: {image_path}")); - } - - anyhow::bail!("texture not found in package: {image_path}") -} - -/// Resolve a bare texture-reference path (same rules as any other material -/// texture reference — loose file under `dir`, `.tex` fallback, then `pkg`) -/// to a decoded image, or `None` if it doesn't resolve. Used by -/// `GpuSceneInstance::build` (gpu_renderer.rs) to load a spot light's -/// `cookie` texture without duplicating `load_texture_from_dir`/ -/// `load_texture_from_pkg`'s resolution logic. -pub(crate) fn resolve_static_texture( - dir: Option<&Path>, - pkg: Option<&Package>, - path: &str, -) -> Option { - dir.and_then(|d| load_texture_from_dir(d, path).ok()) - .or_else(|| pkg.and_then(|p| load_texture_from_pkg(p, path).ok())) - .map(|l| l.image) -} - -/// Follow the model -> material -> texture reference chain in a PKG archive. -/// Shared global WE assets install, used as a fallback when a model/material/ -/// texture isn't bundled with this specific wallpaper. Built-in particle -/// presets (e.g. "fog1") ship their own `particles/presets/*.json` per -/// wallpaper but rely on the shared `materials/presets/*.json` + `.tex` -/// living only in the global assets install, never copied into the -/// wallpaper's own directory/pkg — see [[wp_engine_project]] memory. -fn read_from_global_assets(rel_path: &str) -> Option> { - let dir = crate::engine::shaders::loader::find_we_assets_dir()?; - std::fs::read(dir.join(rel_path)).ok() -} - -/// Returns the resolved texture plus the material pass's own `"blending"` -/// string (e.g. `"additive"`) and `ui_editor_properties_overbright` -/// brightness constant (default 1.0) — particles need both to composite -/// correctly (see `resolve_particle_sprite_{pkg,dir}`); plain image layers -/// just discard them. -fn find_model_chain_tex_pkg( - pkg: &Package, - json_path: &str, -) -> Result<(TexFile, Option, f32)> { - let data = pkg - .get(json_path) - .map(|d| d.to_vec()) - .or_else(|| read_from_global_assets(json_path)) - .with_context(|| { - format!("model/material not found in pkg or global assets: {json_path}") - })?; - let val: serde_json::Value = - serde_json::from_slice(&data).with_context(|| format!("parsing {json_path}"))?; - - if let Some(mat_path) = val.get("material").and_then(|v| v.as_str()) { - return find_model_chain_tex_pkg(pkg, mat_path); - } - - if let Some(passes) = val.get("passes").and_then(|v| v.as_array()) { - for pass in passes { - let blending = pass - .get("blending") - .and_then(|v| v.as_str()) - .map(str::to_string); - let overbright = pass_overbright(pass); - if let Some(textures) = pass.get("textures").and_then(|v| v.as_array()) { - for tex_ref in textures { - if let Some(tex_name) = tex_ref.as_str() { - let tex_path = format!("materials/{tex_name}.tex"); - if let Some(tex_data) = pkg - .get(&tex_path) - .map(|d| d.to_vec()) - .or_else(|| read_from_global_assets(&tex_path)) - { - let tex = TexFile::parse(&tex_data) - .with_context(|| format!("parsing {tex_path}"))?; - return Ok((tex, blending, overbright)); - } - - let alt_path = format!("{tex_name}.tex"); - if let Some(tex_data) = pkg - .get(&alt_path) - .map(|d| d.to_vec()) - .or_else(|| read_from_global_assets(&alt_path)) - { - return Ok((TexFile::parse(&tex_data)?, blending, overbright)); - } - } - } - } - } - } - - anyhow::bail!("could not resolve texture from {json_path}") -} - -/// `ui_editor_properties_overbright` from a material pass's -/// `constantshadervalues` (CParticle reads it off the first pass; 1.0 when -/// absent). -fn pass_overbright(pass: &serde_json::Value) -> f32 { - pass.get("constantshadervalues") - .and_then(|c| c.get("ui_editor_properties_overbright")) - .and_then(|v| v.as_f64()) - .map(|v| v as f32) - .unwrap_or(1.0) -} - -/// A mesh material's first pass. Meshes need two things out of it that the -/// texture chain doesn't carry: the flat `color` constant and `cullmode`. -fn material_first_pass( - dir: Option<&Path>, - pkg: Option<&Package>, - json_path: &str, -) -> Option { - let data = read_asset_bytes(dir, pkg, json_path) - .or_else(|| read_asset_bytes(dir, pkg, &json_path.to_lowercase()))?; - let val: serde_json::Value = serde_json::from_slice(&data).ok()?; - val.get("passes")?.as_array()?.first().cloned() -} - -/// A material's flat `color` constant, as 8-bit RGB. Only meaningful for -/// materials with no texture at all — see `mesh3d_layer_from_object`. -fn pass_constant_color(pass: &serde_json::Value) -> Option<[u8; 3]> { - let consts = pass.get("constantshadervalues")?; - // WE keys this as `color`; `Color` is a separate (usually white) tint. - let s = consts - .get("color") - .or_else(|| consts.get("Color"))? - .as_str()?; - let c = crate::engine::effect::parse_color(s); - Some([ - (c[0].clamp(0.0, 1.0) * 255.0) as u8, - (c[1].clamp(0.0, 1.0) * 255.0) as u8, - (c[2].clamp(0.0, 1.0) * 255.0) as u8, - ]) -} - -/// Follow the model -> material -> texture chain for loose files on disk. -fn find_model_chain_tex_dir(dir: &Path, json_path: &str) -> Result<(TexFile, Option, f32)> { - let data = std::fs::read(dir.join(json_path)) - .ok() - .or_else(|| read_from_global_assets(json_path)) - .with_context(|| { - format!( - "model/material not found in {} or global assets: {json_path}", - dir.display() - ) - })?; - let val: serde_json::Value = - serde_json::from_slice(&data).with_context(|| format!("parsing {json_path}"))?; - - if let Some(mat_path) = val.get("material").and_then(|v| v.as_str()) { - return find_model_chain_tex_dir(dir, mat_path); - } - - if let Some(passes) = val.get("passes").and_then(|v| v.as_array()) { - for pass in passes { - let blending = pass - .get("blending") - .and_then(|v| v.as_str()) - .map(str::to_string); - let overbright = pass_overbright(pass); - if let Some(textures) = pass.get("textures").and_then(|v| v.as_array()) { - for tex_ref in textures { - if let Some(tex_name) = tex_ref.as_str() { - let tex_path = format!("materials/{tex_name}.tex"); - if let Some(tex_data) = std::fs::read(dir.join(&tex_path)) - .ok() - .or_else(|| read_from_global_assets(&tex_path)) - { - return Ok((TexFile::parse(&tex_data)?, blending, overbright)); - } - } - } - } - } - } - - anyhow::bail!("could not resolve texture from {json_path}") -} - -/// Walk model → material and report whether any pass declares the `SPRITESHEET` -/// combo (`image` is a multi-cell grid). `read` fetches a chain member's bytes. -fn chain_has_spritesheet(json_path: &str, read: &dyn Fn(&str) -> Option>) -> bool { - let Some(data) = read(json_path) else { - return false; - }; - let Ok(val) = serde_json::from_slice::(&data) else { - return false; - }; - if let Some(mat) = val.get("material").and_then(|v| v.as_str()) { - return chain_has_spritesheet(mat, read); - } - val.get("passes") - .and_then(|p| p.as_array()) - .is_some_and(|passes| { - passes.iter().any(|pass| { - pass.get("combos") - .and_then(|c| c.as_object()) - // Materials spell it both "SPRITESHEET" and "spritesheet". - .and_then(|c| { - c.iter() - .find(|(k, _)| k.eq_ignore_ascii_case("spritesheet")) - .and_then(|(_, v)| v.as_i64()) - }) - .unwrap_or(0) - != 0 - }) - }) -} - -/// A model chain's texture, as a layer image. An animated `.tex` (TEXS frame -/// table + the IsGif flag) keeps its frames so the layer plays them — -/// otherwise the whole atlas gets squeezed into the one-cell quad, which is -/// how 2325500626's bonfire rendered as a 16-up contact sheet. -fn loaded_from_chain_tex( - tex: &TexFile, - json_path: &str, - read: &dyn Fn(&str) -> Option>, -) -> Result { - if tex.is_animated() { - return LoadedImage::from_tex(tex); - } - Ok(apply_puppet_mesh(tex.to_rgba()?, json_path, read)) -} - -fn resolve_model_chain_pkg(pkg: &Package, json_path: &str) -> Result { - let tex = find_model_chain_tex_pkg(pkg, json_path)?.0; - if let Some(bytes) = tex.video_bytes() { - // e.g. 2914504963's Taj Mahal backdrop: the model chain resolves to - // an embedded-video texture — stream it instead of puppet handling. - return video_tex_to_loaded(&tex, bytes); - } - let read = |rel: &str| { - pkg.get(rel) - .map(|d| d.to_vec()) - .or_else(|| read_from_global_assets(rel)) - }; - let mut loaded = loaded_from_chain_tex(&tex, json_path, &read)?; - if chain_has_spritesheet(json_path, &read) { - loaded.sprite_frames = tex.to_rgba_frames().unwrap_or_default(); - } - Ok(loaded) -} - -fn resolve_model_chain_dir(dir: &Path, json_path: &str) -> Result { - let tex = find_model_chain_tex_dir(dir, json_path)?.0; - if let Some(bytes) = tex.video_bytes() { - return video_tex_to_loaded(&tex, bytes); - } - let read = |rel: &str| { - std::fs::read(dir.join(rel)) - .ok() - .or_else(|| read_from_global_assets(rel)) - }; - let mut loaded = loaded_from_chain_tex(&tex, json_path, &read)?; - if chain_has_spritesheet(json_path, &read) { - loaded.sprite_frames = tex.to_rgba_frames().unwrap_or_default(); - } - Ok(loaded) -} - -/// If the model JSON names a `"puppet"` mesh, its texture is a packed UV -/// atlas — reassemble it by rasterizing the rest-pose mesh (see -/// `engine::puppet`), and keep the parsed model + atlas around as a -/// `PuppetRuntime` so the live render loop can re-pose it over time when -/// the model carries MDLA animations. On any parse failure the decoded -/// texture passes through unchanged, so a malformed .mdl degrades to the -/// old scrambled-quad behavior instead of dropping the layer. -fn apply_puppet_mesh( - atlas: RgbaImage, - model_json_path: &str, - read: impl Fn(&str) -> Option>, -) -> LoadedImage { - let plain = |atlas: RgbaImage| LoadedImage::single(atlas); - let Some(model_bytes) = read(model_json_path) else { - return plain(atlas); - }; - let Ok(model_json) = serde_json::from_slice::(&model_bytes) else { - return plain(atlas); - }; - let Some(puppet_path) = model_json.get("puppet").and_then(|v| v.as_str()) else { - return plain(atlas); - }; - let Some(mdl_bytes) = read(puppet_path) else { - tracing::warn!(target: "scene", "puppet mesh '{puppet_path}' not found — drawing raw atlas"); - return plain(atlas); - }; - let Some(model) = crate::engine::puppet::parse_model(&mdl_bytes) else { - tracing::warn!(target: "scene", "puppet mesh '{puppet_path}' unparsable — drawing raw atlas"); - return plain(atlas); - }; - tracing::debug!( - target: "scene", - "puppet '{puppet_path}': {} vertices, {} triangles, {} bones, {} animations", - model.mesh.positions.len(), - model.mesh.indices.len() / 3, - model.bones.len(), - model.animations.len() - ); - // With a skeleton + animation, frame 0 of the animation IS the - // assembled pose (skin = worldAnim * inverse(atlas-space bind)); the - // raw mesh alone only reproduces the packed atlas layout. - let runtime = crate::engine::puppet::PuppetRuntime { - model, - atlas, - layers: Vec::new(), - }; - let assembled = if runtime.model.has_animation() { - runtime.render_at(0.0, runtime.atlas.width(), runtime.atlas.height()) - } else { - crate::engine::puppet::rasterize( - &runtime.model.mesh, - &runtime.atlas, - runtime.atlas.width(), - runtime.atlas.height(), - ) - }; - let mut loaded = LoadedImage::single(assembled); - if runtime.model.has_animation() { - loaded.puppet = Some(std::sync::Arc::new(runtime)); - } - loaded -} - -/// Same model -> material -> texture chain as `resolve_model_chain_{pkg,dir}`, -/// but for particle sprites: keeps every sprite-sheet frame (sliced from the -/// `.tex`'s TEXS table, if any) instead of collapsing to one flat image, plus -/// the animation's total loop duration so `ParticleSystem::set_sprite_frames` -/// can drive per-particle frame advance, and the material pass's own -/// `"blending"` string so the caller can composite additive-glow presets -/// (fog/smoke/embers/rain/lightning — the overwhelming majority of real -/// particle materials) correctly instead of always alpha-blending, which -/// makes a sprite's near-black background visibly darken the scene instead -/// of contributing nothing. -fn resolve_particle_sprite_pkg( - pkg: &Package, - json_path: &str, -) -> Result<(particle::ParticleSprite, Option)> { - let (tex, blending, overbright) = find_model_chain_tex_pkg(pkg, json_path)?; - let duration: f32 = tex.frames().iter().map(|f| f.frametime).sum(); - Ok(( - particle::ParticleSprite { - frames: tex.to_particle_rgba_frames()?, - duration, - overbright, - }, - blending, - )) -} - -fn resolve_particle_sprite_dir( - dir: &Path, - json_path: &str, -) -> Result<(particle::ParticleSprite, Option)> { - let (tex, blending, overbright) = find_model_chain_tex_dir(dir, json_path)?; - let duration: f32 = tex.frames().iter().map(|f| f.frametime).sum(); - Ok(( - particle::ParticleSprite { - frames: tex.to_particle_rgba_frames()?, - duration, - overbright, - }, - blending, - )) -} - -// --------------------------------------------------------------------------- -// Parent-child transform composition (full TRS) -// -// In Wallpaper Engine a scene is a tree: an object's `origin`/`angles`/`scale` -// are expressed in its PARENT's frame, not screen space. `layer_from_object` -// bakes only the object's OWN local transform into each Layer, so anything with -// a parent lands at its local offset instead of its true world position (the -// clock/particles-in-the-corner bug). This pass walks each object's `parent` -// chain and pre-multiplies the parent's world transform onto the already-baked -// local layer. -// -// Position is exact TRS (parent scale → parent rotation → parent translation -// applied to the child point). Orientation and scale compose as Euler-sum and -// component-wise product respectively: exact for the flat z-rotation these 2D -// scenes use and for a single parent level, an approximation only under nested, -// non-coaxial 3D rotation (rare; the perspective path already treats layer -// angles approximately). -// --------------------------------------------------------------------------- - -/// An affine transform in WE scene space (Y-up), kept as decomposed -/// translation / Euler-rotation (radians, un-negated) / scale. -#[derive(Clone, Copy)] -pub struct Xform { - pub t: [f64; 3], - pub r: [f64; 3], - pub s: [f64; 3], -} - -impl Xform { - pub const IDENTITY: Xform = Xform { - t: [0.0; 3], - r: [0.0; 3], - s: [1.0, 1.0, 1.0], - }; - - pub fn is_identity(&self) -> bool { - self.t == [0.0; 3] && self.r == [0.0; 3] && self.s == [1.0, 1.0, 1.0] - } - - /// Map a point living in a child frame into the frame this Xform is - /// expressed in: `p' = t + R · (s ⊙ p)`. - pub fn apply_point(&self, p: [f64; 3]) -> [f64; 3] { - let scaled = [p[0] * self.s[0], p[1] * self.s[1], p[2] * self.s[2]]; - let rot = rotate_euler_f64(scaled, self.r); - [rot[0] + self.t[0], rot[1] + self.t[1], rot[2] + self.t[2]] - } - - /// `world = self (parent world) ∘ local`. - pub fn compose(&self, local: &Xform) -> Xform { - Xform { - t: self.apply_point(local.t), - r: [ - self.r[0] + local.r[0], - self.r[1] + local.r[1], - self.r[2] + local.r[2], - ], - s: [ - self.s[0] * local.s[0], - self.s[1] * local.s[1], - self.s[2] * local.s[2], - ], - } - } -} - -/// f64 mirror of `camera3d::rotate_euler` (Rx then Ry then Rz) — scene coords -/// reach a few thousand pixels, so we keep the composition in f64. -fn rotate_euler_f64(v: [f64; 3], a: [f64; 3]) -> [f64; 3] { - let (sx, cx) = a[0].sin_cos(); - let (sy, cy) = a[1].sin_cos(); - let (sz, cz) = a[2].sin_cos(); - let v = [v[0], cx * v[1] - sx * v[2], sx * v[1] + cx * v[2]]; - let v = [cy * v[0] + sy * v[2], v[1], -sy * v[0] + cy * v[2]]; - [cz * v[0] - sz * v[1], sz * v[0] + cz * v[1], v[2]] -} - -/// This object's own local transform, read from the raw scene fields (the same -/// sources `layer_from_object` uses, minus alignment which is a per-layer draw -/// concern). -pub fn obj_local_xform(obj: &SceneObject) -> Xform { - let t = obj.parsed_origin(); - let r = obj - .angles - .as_ref() - .map(crate::engine::model::json_to_animated) - .and_then(|v| v.as_vec3()) - .map(|a| [a[0] as f64, a[1] as f64, a[2] as f64]) - .unwrap_or([0.0; 3]); - let s = obj - .scale - .as_ref() - .and_then(crate::engine::scene::parse_value_vec3) - .unwrap_or([1.0, 1.0, 1.0]); - Xform { t, r, s } -} - -/// Resolve an object's `parent` reference to an index in `scene.objects`. -/// WE stores the parent's `id` (a number), occasionally a name string. -fn parent_index( - obj: &SceneObject, - id_to_idx: &HashMap, - name_to_idx: &HashMap<&str, usize>, -) -> Option { - // `disablepropagation` severs the link: the object keeps its parent for - // grouping/visibility but stops inheriting its transform. Every occurrence - // in the 197 installed scenes is `false`, so this changes nothing today — - // it's here so authored content that does set it behaves correctly. - if obj - .disablepropagation - .as_ref() - .and_then(crate::engine::scene::parse_value_bool) - .unwrap_or(false) - { - return None; - } - let p = obj.parent.as_ref()?; - if let Some(id) = p.as_i64() { - return id_to_idx.get(&id).copied(); - } - if let Some(id) = p.as_u64() { - return id_to_idx.get(&(id as i64)).copied(); - } - if let Some(name) = p.as_str() { - return name_to_idx.get(name).copied(); - } - None -} - -/// World transform of object `idx`, including its own local transform, composed -/// up the parent chain. Memoized; guards against cycles / dangling parents. -fn world_xform( - idx: usize, - objects: &[SceneObject], - id_to_idx: &HashMap, - name_to_idx: &HashMap<&str, usize>, - memo: &mut [Option], - depth: u32, -) -> Xform { - if let Some(x) = memo[idx] { - return x; - } - let local = obj_local_xform(&objects[idx]); - // depth guard: a malformed scene can loop or nest absurdly deep; fall back - // to the local transform rather than blow the stack (tolerant-parsing). - let world = match parent_index(&objects[idx], id_to_idx, name_to_idx) { - Some(p) if p != idx && depth < 64 => { - world_xform(p, objects, id_to_idx, name_to_idx, memo, depth + 1).compose(&local) - } - _ => local, - }; - memo[idx] = Some(world); - world -} - -/// Per-object transform data the live path needs to recompose the parent chain -/// every frame. -/// -/// `apply_parent_transforms` bakes the chain once at load, which is right for a -/// static parent but wrong for a scripted one: a parent node's transform can -/// change every frame, and those nodes are usually image-less (no `Layer` at -/// all), so their scripts never run from the layer list. This carries them. -pub struct TransformGraph { - /// Resolved parent index per object (`None` = root). - pub parent: Vec>, - /// Authored local transform per object — the base a script updates from. - pub local: Vec, - /// Object name/id, indexed the same as every other field here — lets the - /// live path find an object's sounds and layers by identity. - pub name: Vec>, - pub id: Vec>, -} - -impl TransformGraph { - /// World transform per object, composing `locals` up each parent chain. - /// `disabled[i]` (this frame's live `thisLayer.disablepropagation()` - /// call — see the object-attachment API) makes object `i` stop - /// inheriting *its own* parent's transform, exactly like the static - /// scene.json `disablepropagation: true` field already does via - /// `parent_index` returning `None` — see - /// `disablepropagation_severs_transform_inheritance` below. This is - /// that same rule evaluated live per-frame instead of once at load, for - /// an object whose `parent` link is real (baked into `self.parent`) but - /// wants to sever it dynamically. - pub fn world(&self, locals: &[Xform], disabled: &[bool]) -> Vec { - let mut memo: Vec> = vec![None; self.parent.len()]; - (0..self.parent.len()) - .map(|i| self.world_of(i, locals, disabled, &mut memo, 0)) - .collect() - } - - fn world_of( - &self, - idx: usize, - locals: &[Xform], - disabled: &[bool], - memo: &mut Vec>, - depth: u32, - ) -> Xform { - if let Some(x) = memo[idx] { - return x; - } - let severed = disabled.get(idx).copied().unwrap_or(false); - // Same 64-deep guard as the load-time walker: a malformed scene must - // not blow the stack. - let w = match self.parent[idx] { - Some(p) if p != idx && depth < 64 && !severed => self - .world_of(p, locals, disabled, memo, depth + 1) - .compose(&locals[idx]), - _ => locals[idx], - }; - memo[idx] = Some(w); - w - } -} - -fn build_transform_graph(scene: &Scene) -> TransformGraph { - let objects = &scene.objects; - let id_to_idx: HashMap = objects - .iter() - .enumerate() - .filter_map(|(i, o)| o.id.map(|id| (id, i))) - .collect(); - let name_to_idx: HashMap<&str, usize> = objects - .iter() - .enumerate() - .filter_map(|(i, o)| o.name.as_deref().map(|n| (n, i))) - .collect(); - TransformGraph { - parent: objects - .iter() - .map(|o| parent_index(o, &id_to_idx, &name_to_idx)) - .collect(), - local: objects.iter().map(obj_local_xform).collect(), - name: objects.iter().map(|o| o.name.clone()).collect(), - id: objects.iter().map(|o| o.id).collect(), - } -} - -/// Parent world transform for object `idx` (identity if it has no parent). This -/// is what gets applied to a layer, whose OWN local transform is already baked -/// in by `layer_from_object`. -fn parent_world_xform( - idx: usize, - objects: &[SceneObject], - id_to_idx: &HashMap, - name_to_idx: &HashMap<&str, usize>, - memo: &mut [Option], -) -> Xform { - match parent_index(&objects[idx], id_to_idx, name_to_idx) { - Some(p) if p != idx => world_xform(p, objects, id_to_idx, name_to_idx, memo, 0), - _ => Xform::IDENTITY, - } -} - -/// Compose each layer's baked-in local transform with its parent chain so -/// parented objects (clocks, dates, ripples, particle emitters) render at their -/// true world position/orientation/scale instead of their local offset. -fn apply_parent_transforms( - scene: &Scene, - layers: &mut [Layer], - particle_layers: &mut [ParticleLayer], - mesh3d_layers: &mut [Mesh3dLayer], -) { - let objects = &scene.objects; - if objects.is_empty() { - return; - } - let id_to_idx: HashMap = objects - .iter() - .enumerate() - .filter_map(|(i, o)| o.id.map(|id| (id, i))) - .collect(); - let name_to_idx: HashMap<&str, usize> = objects - .iter() - .enumerate() - .filter_map(|(i, o)| o.name.as_deref().map(|n| (n, i))) - .collect(); - let mut memo: Vec> = vec![None; objects.len()]; - - for layer in layers.iter_mut() { - let idx = layer.order_index; - if idx >= objects.len() { - continue; - } - let pw = parent_world_xform(idx, objects, &id_to_idx, &name_to_idx, &mut memo); - if pw.is_identity() { - continue; - } - layer.origin = pw.apply_point(layer.origin); - layer.scale = [ - layer.scale[0] * pw.s[0], - layer.scale[1] * pw.s[1], - layer.scale[2] * pw.s[2], - ]; - // `Layer::angle` is the negated z-roll (screen convention); the raw 3D - // `angles` are un-negated. - layer.angle -= pw.r[2] as f32; - layer.angles = [ - layer.angles[0] + pw.r[0] as f32, - layer.angles[1] + pw.r[1] as f32, - layer.angles[2] + pw.r[2] as f32, - ]; - } - - for pl in particle_layers.iter_mut() { - let idx = pl.order_index; - if idx >= objects.len() { - continue; - } - let pw = parent_world_xform(idx, objects, &id_to_idx, &name_to_idx, &mut memo); - if pw.is_identity() { - continue; - } - pl.origin = pw.apply_point(pl.origin); - } - - // Meshes take the same treatment as image layers — they're ordinary scene - // objects that happen to carry geometry. Without this a parented skybox - // stays at the origin while the camera moves out of it. - for m in mesh3d_layers.iter_mut() { - let idx = m.order_index; - if idx >= objects.len() { - continue; - } - let pw = parent_world_xform(idx, objects, &id_to_idx, &name_to_idx, &mut memo); - if pw.is_identity() { - continue; - } - let o = pw.apply_point([m.origin[0] as f64, m.origin[1] as f64, m.origin[2] as f64]); - m.origin = [o[0] as f32, o[1] as f32, o[2] as f32]; - for i in 0..3 { - m.scale[i] *= pw.s[i] as f32; - m.angles[i] += pw.r[i] as f32; - } - } -} - -fn guess_scene_dimensions(scene: &Scene, layers: &[Layer]) -> (u32, u32) { - // Perspective 3D scenes have no orthogonal projection and their layer - // sizes are world-space, not pixels — inferring pixel dimensions from - // them yields garbage (e.g. 1619x29). Render at a fixed 16:9 target; the - // perspective camera maps world → NDC independently of this resolution. - if scene.is_perspective() { - return (1920, 1080); - } - - // 1. Try orthogonal projection dimensions from camera or general settings - if let Some(cam) = &scene.camera { - if let Some(proj) = &cam.orthogonal_projection { - if let (Some(w), Some(h)) = (proj.width, proj.height) { - if w > 0 && h > 0 { - return (w, h); - } - } - } - } - if let Some(gen) = &scene.general { - if let Some(proj) = &gen.orthogonal_projection { - if let (Some(w), Some(h)) = (proj.width, proj.height) { - if w > 0 && h > 0 { - return (w, h); - } - } - } - } - - // 2. Use the largest layer dimensions - let mut max_w = 0u32; - let mut max_h = 0u32; - for layer in layers { - max_w = max_w.max(layer.image.width()); - max_h = max_h.max(layer.image.height()); - } - if max_w > 0 && max_h > 0 { - return (max_w, max_h); - } - - // 3. Fallback - (1920, 1080) -} - -/// For model/copybackground layers we can't render, create a transparent -/// placeholder so any screen-space effects applied to them have a base to work -/// with. It must be transparent, not opaque grey: these are usually full-screen -/// effect-only overlays (e.g. "Light shafts"), so an opaque base paints a giant -/// grey box over the scene whenever the effect is a SKIP or an additive overlay. -/// ponytail: transparent base; revisit if an effect needs to sample the real -/// scene behind it (backdrop capture — not implemented). -fn placeholder_for(obj: &SceneObject) -> RgbaImage { - let size = obj.parsed_size(); - let w = if size[0] > 0.0 { size[0] as u32 } else { 1920 }; - let h = if size[1] > 0.0 { size[1] as u32 } else { 1080 }; - RgbaImage::from_pixel(w, h, image::Rgba([0u8, 0, 0, 0])) -} - -fn is_special_layer(image_path: &str) -> bool { - let p = image_path.to_lowercase(); - p.contains("projectlayer") || p.contains("composelayer") || p.contains("fullscreenlayer") -} - -/// WE's solid-color model bundles (`models/util/solidlayer.json`, -/// `solid_instance_model_*.json`) reference a "flat" shader that ignores its -/// texture entirely and just outputs `vec4(g_Color, g_Alpha)` — i.e. the -/// object's own color/alpha, no image. A 1×1 white texture reproduces that -/// exactly once tinted by `Layer::color`/`alpha`/`brightness`. -fn is_solid_layer_path(image_path: &str) -> bool { - let p = image_path.to_lowercase(); - p.contains("solidlayer") || p.contains("solid_instance_model") -} - -fn white_pixel() -> RgbaImage { - RgbaImage::from_pixel(1, 1, image::Rgba([255, 255, 255, 255])) -} - -/// WE sizes a solid layer to the full scene when it has no explicit size -/// (`CImage.cpp:199`: `if (solidlayer && size == 0) size = sceneSize`) — but -/// only to give an effect chain a dummy target. A size-less solid layer with -/// no effects is a script/cursor holder ("Player Options" in 2988724897); -/// stretching its opaque white over the scene hides the whole wallpaper, so -/// callers only register those that carry effects. -/// Composition (`composelayer`), fullscreen (`fullscreenlayer`) and -/// projection (`projectlayer`) layers have no image of their own: their -/// texture is the scene already drawn beneath them — the region under the -/// layer for a composition layer, the whole scene otherwise — which their -/// effects then process (audio bars, fisheye, colour grading, blur…). -/// Built with a placeholder image; the live renderer captures the backdrop -/// each frame. Fullscreen and projection layers (and composition layers -/// without a size) are sized to the scene by `fill_solid_layer_sizes`. -fn backdrop_layer_from_object(obj: &SceneObject, path: &str) -> Option { - let p = path.to_lowercase(); - let kind = if p.contains("composelayer") { - Backdrop::Region - } else if p.contains("fullscreenlayer") || p.contains("projectlayer") { - Backdrop::FullScene - } else { - return None; - }; - let mut layer = layer_from_object( - obj, - LoadedImage::single(RgbaImage::from_pixel(1, 1, image::Rgba([0, 0, 0, 0]))), - None, - ); - if kind == Backdrop::FullScene { - layer.size = [0.0, 0.0, 0.0]; - } - layer.backdrop = Some(kind); - Some(layer) -} - -fn fill_solid_layer_sizes(layers: &mut [Layer], solid_indices: &[usize], width: u32, height: u32) { - for &i in solid_indices { - if layers[i].size[0] == 0.0 && layers[i].size[1] == 0.0 { - layers[i].size = [width as f64, height as f64, 0.0]; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn compose_and_fullscreen_objects_become_backdrop_layers() { - let objs: Vec = serde_json::from_str( - r#"[{"image": "models/util/composelayer.json", "size": "200 100"}, - {"image": "models/util/fullscreenlayer.json", "size": "200 100"}, - {"image": "models/util/projectlayer.json"}, - {"image": "models/other.json"}]"#, - ) - .unwrap(); - let a = backdrop_layer_from_object(&objs[0], objs[0].image.as_deref().unwrap()).unwrap(); - assert_eq!(a.backdrop, Some(Backdrop::Region)); - assert_eq!([a.size[0], a.size[1]], [200.0, 100.0]); - let b = backdrop_layer_from_object(&objs[1], objs[1].image.as_deref().unwrap()).unwrap(); - assert_eq!(b.backdrop, Some(Backdrop::FullScene)); - assert_eq!( - [b.size[0], b.size[1]], - [0.0, 0.0], - "sized to the scene later" - ); - assert!(backdrop_layer_from_object(&objs[2], "models/util/projectlayer.json").is_some()); - assert!(backdrop_layer_from_object(&objs[3], "models/other.json").is_none()); - } - - /// Untextured mesh materials are common in real content - /// (`"textures": [null,null,null]`), and their flat `color` is the only - /// thing that keeps the mesh from drawing plain white. - /// `maxwidth`/`maxrows` are only active when their `limit*` checkbox is - /// on. WE keeps the numbers around while the box is unchecked, so applying - /// them unconditionally wrapped text that should run free — 3091474852's - /// day label wrapped at 500px despite `limitwidth: false`. - /// `disablepropagation` severs transform inheritance while leaving the - /// `parent` link intact for grouping/visibility. - #[test] - fn disablepropagation_severs_transform_inheritance() { - let objs: Vec = serde_json::from_str( - r#"[{"id": 1, "origin": "10 0 0"}, - {"id": 2, "origin": "1 0 0", "parent": 1}, - {"id": 3, "origin": "1 0 0", "parent": 1, "disablepropagation": true}]"#, - ) - .expect("valid objects"); - let id_to_idx: HashMap = objs - .iter() - .enumerate() - .filter_map(|(i, o)| o.id.map(|d| (d, i))) - .collect(); - let name_to_idx: HashMap<&str, usize> = HashMap::new(); - assert_eq!(parent_index(&objs[1], &id_to_idx, &name_to_idx), Some(0)); - assert_eq!( - parent_index(&objs[2], &id_to_idx, &name_to_idx), - None, - "disablepropagation must stop transform inheritance" - ); - } - - /// The object-attachment API's `thisLayer.disablepropagation()` (see - /// `engine::script::OBJECT_ATTACHMENT_API_JS`) is this same - /// `disablepropagation` rule, just evaluated live per-frame instead of - /// baked once from scene.json — `TransformGraph::world`'s `disabled` - /// slice must sever object `i`'s *own* inheritance from its parent - /// (matching `parent_index` above exactly), not affect what `i`'s - /// children inherit from `i`. - #[test] - fn live_disablepropagation_severs_only_the_flagged_objects_own_inheritance() { - // 0 (root, moves) -> 1 (child of 0) -> 2 (grandchild, child of 1) - let graph = TransformGraph { - parent: vec![None, Some(0), Some(1)], - local: vec![Xform::IDENTITY; 3], - name: vec![None, None, None], - id: vec![None, None, None], - }; - let locals = vec![ - Xform { - t: [10.0, 0.0, 0.0], - r: [0.0; 3], - s: [1.0; 3], - }, - Xform { - t: [1.0, 0.0, 0.0], - r: [0.0; 3], - s: [1.0; 3], - }, - Xform { - t: [1.0, 0.0, 0.0], - r: [0.0; 3], - s: [1.0; 3], - }, - ]; - - let worlds = graph.world(&locals, &[false, false, false]); - assert_eq!( - worlds[2].t, - [12.0, 0.0, 0.0], - "baseline: full chain composes" - ); - - // Sever object 1's own inheritance from object 0. - let worlds = graph.world(&locals, &[false, true, false]); - assert_eq!(worlds[0].t, [10.0, 0.0, 0.0], "object 0 is untouched"); - assert_eq!( - worlds[1].t, - [1.0, 0.0, 0.0], - "object 1 must stop inheriting object 0's transform" - ); - assert_eq!( - worlds[2].t, - [2.0, 0.0, 0.0], - "object 2 still inherits normally from (now-severed) object 1" - ); - } - - /// `depthtest: "disabled"` on the object overrides the material pass. - #[test] - fn object_depthtest_overrides_material() { - let read = |o: &SceneObject, mat: Option<&str>| -> bool { - o.depthtest - .as_ref() - .and_then(|v| v.as_str()) - .or(mat) - .map(|m| m != "disabled") - .unwrap_or(true) - }; - let plain: SceneObject = serde_json::from_str("{}").expect("valid"); - let off: SceneObject = serde_json::from_str(r#"{"depthtest": "disabled"}"#).expect("valid"); - assert!(read(&plain, None), "default is depth-tested"); - assert!(!read(&plain, Some("disabled")), "material pass applies"); - assert!(!read(&off, Some("enabled")), "object wins over material"); - } - - #[test] - fn wrap_limits_respect_their_gates() { - let obj = |lw: bool| -> SceneObject { - serde_json::from_value(serde_json::json!({ - "text": "SUNDAY", "maxwidth": 500.0, "maxrows": 1, - "limitwidth": lw, "limitrows": lw, - })) - .expect("valid object") - }; - let active = |o: &SceneObject| { - o.maxwidth - .as_ref() - .and_then(crate::engine::scene::parse_value_f32) - .filter(|w| *w > 0.0) - .filter(|_| { - o.limitwidth - .as_ref() - .and_then(crate::engine::scene::parse_value_bool) - .unwrap_or(false) - }) - .is_some() - }; - assert!(active(&obj(true)), "gate on: maxwidth applies"); - assert!(!active(&obj(false)), "gate off: maxwidth must be ignored"); - } - - #[test] - fn material_constant_color_reads_flat_color() { - // `color` wins over the separate (usually white) `Color` tint. - let m: serde_json::Value = serde_json::from_str( - r#"{"textures":[null],"constantshadervalues": - {"Color":"1.0 1.0 1.0","color":"0.6 0.0 0.2"}}"#, - ) - .expect("valid json"); - assert_eq!(pass_constant_color(&m), Some([153, 0, 51])); - assert_eq!(pass_constant_color(&serde_json::json!({})), None); - } - - fn solid_particle_config(color: &str) -> particle::ParticleConfig { - let json = format!( - r#"{{ - "maxcount": 2, - "emitter": [{{"name":"box","rate":1000,"distancemin":"0 0 0","distancemax":"0 0 0"}}], - "initializer": [ - {{"id":1,"name":"lifetimerandom","min":100,"max":100}}, - {{"id":2,"name":"sizerandom","min":300,"max":300}}, - {{"id":3,"name":"velocityrandom","min":"0 0 0","max":"0 0 0"}}, - {{"id":4,"name":"colorrandom","min":"{color}","max":"{color}"}}, - {{"id":5,"name":"alpharandom","min":1,"max":1}} - ] - }}"# - ); - serde_json::from_str(&json).expect("should parse") - } - - /// Particles should interleave with image layers by `order_index` in - /// true scene z-order, not always draw on top of every image — build a - /// scene with [particle(order 0, blue), image(order 1, opaque red), - /// particle(order 2, green)] and confirm the final canvas shows the - /// blue particle fully hidden under the opaque red layer, with the green - /// particle visible on top of it. - #[test] - fn particles_and_images_interleave_by_order_index() { - let scene: Scene = serde_json::from_str("{}").unwrap(); - let red_image = RgbaImage::from_pixel(100, 100, image::Rgba([255, 0, 0, 255])); - - let layers = vec![Layer { - backdrop: None, - local_origin: [0.0; 3], - local_scale: [1.0; 3], - local_angles: [0.0; 3], - name: "red".to_string(), - image: red_image, - extra_frames: Vec::new(), - frame_duration_ms: 0, - origin: [50.0, 50.0, 0.0], - size: [100.0, 100.0, 0.0], - scale: [1.0, 1.0, 1.0], - parallax_depth: [0.0, 0.0], - angle: 0.0, - angles: [0.0, 0.0, 0.0], - blend_mode: 0, - alpha: 1.0, - color: [1.0, 1.0, 1.0], - brightness: 1.0, - copybackground: false, - no_interpolation: true, - clamp_uvs: false, - puppet: None, - video: None, - text_dynamic: None, - order_index: 1, - object_id: None, - visible_base: true, - }]; - - let particle_layers = vec![ - ParticleLayer { - local_origin: [0.0; 3], - name: "blue_particles".to_string(), - origin: [50.0, 50.0, 0.0], - parallax_depth: [0.0, 0.0], - config: solid_particle_config("0 0 255"), - overrides: None, - sprite_texture: None, - additive_blend: false, - order_index: 0, - children: Vec::new(), - }, - ParticleLayer { - local_origin: [0.0; 3], - name: "green_particles".to_string(), - origin: [50.0, 50.0, 0.0], - parallax_depth: [0.0, 0.0], - config: solid_particle_config("0 255 0"), - overrides: None, - sprite_texture: None, - additive_blend: false, - order_index: 2, - children: Vec::new(), - }, - ]; - - let resolved = ResolvedScene { - transform_graph: build_transform_graph(&scene), - width: 100, - height: 100, - layers, - particle_layers, - mesh3d_layers: Vec::new(), - scene, - }; - - let canvas = resolved.render(); - let px = canvas.get_pixel(50, 50); - assert!( - px[2] < 20, - "blue particle (order 0) should be fully hidden under the opaque red layer (order 1), got {px:?}" - ); - assert!( - px[1] > px[0], - "green particle (order 2) should be visible on top of the red layer, got {px:?}" - ); - } - - #[test] - fn parallax_depth_number_broadcasts_to_both_axes() { - let v = serde_json::json!(0.5); - assert_eq!(parse_parallax_depth(&v), [0.5, 0.5]); - } - - #[test] - fn parallax_depth_vec2_string_keeps_axes_independent() { - let v = serde_json::json!("0.5 0.0"); - assert_eq!(parse_parallax_depth(&v), [0.5, 0.0]); - } - - #[test] - fn parallax_depth_user_wrapped_value_unwraps() { - let v = serde_json::json!({"user": "ui_editor_properties_depth", "value": "0.25 0.75"}); - assert_eq!(parse_parallax_depth(&v), [0.25, 0.75]); - } - - #[test] - fn alignment_none_produces_no_offset() { - assert_eq!(alignment_offset(None, [100.0, 200.0]), [0.0, 0.0]); - } - - #[test] - fn alignment_top_shifts_down_by_half_height() { - assert_eq!(alignment_offset(Some("top"), [100.0, 200.0]), [0.0, -100.0]); - } - - #[test] - fn alignment_bottom_shifts_up_by_half_height() { - assert_eq!( - alignment_offset(Some("bottom"), [100.0, 200.0]), - [0.0, 100.0] - ); - } - - #[test] - fn alignment_left_shifts_right_by_half_width() { - assert_eq!(alignment_offset(Some("left"), [100.0, 200.0]), [50.0, 0.0]); - } - - #[test] - fn alignment_right_shifts_left_by_half_width() { - assert_eq!( - alignment_offset(Some("right"), [100.0, 200.0]), - [-50.0, 0.0] - ); - } - - #[test] - fn alignment_combines_vertical_and_horizontal() { - assert_eq!( - alignment_offset(Some("top left"), [100.0, 200.0]), - [50.0, -100.0] - ); - } - - #[test] - fn alignment_top_and_bottom_together_only_honors_top() { - // Matches the reference's if/else-if: mutually exclusive per axis. - assert_eq!( - alignment_offset(Some("top bottom"), [100.0, 200.0]), - [0.0, -100.0] - ); - } - - #[test] - fn alignment_is_case_insensitive() { - assert_eq!(alignment_offset(Some("TOP"), [100.0, 200.0]), [0.0, -100.0]); - } -} diff --git a/src/engine/resource.rs b/src/engine/resource.rs deleted file mode 100644 index a50fd7a..0000000 --- a/src/engine/resource.rs +++ /dev/null @@ -1,201 +0,0 @@ -//! Central GPU resource manager. -//! -//! [`ResourceManager`] owns the wgpu device/queue references and caches all -//! GPU-side textures. Every other module that needs a texture should obtain it -//! through this manager rather than uploading its own copy. - -use anyhow::{Context, Result}; -use image::RgbaImage; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use crate::engine::pkg::Package; -use crate::engine::shaders::loader; -use crate::engine::tex::TexFile; - -pub struct ResourceManager { - device: Arc, - queue: Arc, - sampler: wgpu::Sampler, - dummy_texture: Arc, - texture_cache: Mutex>>, -} - -impl ResourceManager { - /// Create a new `ResourceManager` owning the given device and queue. - /// Creates a bilinear-repeat sampler and a 1×1 white dummy texture. - pub fn new(device: Arc, queue: Arc) -> Self { - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("rm_sampler"), - address_mode_u: wgpu::AddressMode::Repeat, - address_mode_v: wgpu::AddressMode::Repeat, - address_mode_w: wgpu::AddressMode::Repeat, - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - mipmap_filter: wgpu::FilterMode::Linear, - ..Default::default() - }); - let dummy_texture = Arc::new(Self::create_white_1x1(&device, &queue)); - Self { - device, - queue, - sampler, - dummy_texture, - texture_cache: Mutex::new(HashMap::new()), - } - } - - /// Return a cached texture or load it on demand. - /// - /// Resolution order: - /// 1. In-memory cache. - /// 2. `pkg`, if provided — decoded via [`TexFile`]. - /// 3. WE global assets directory (via [`loader::find_we_assets_dir`]). - pub fn get_or_load(&self, key: &str, pkg: Option<&Package>) -> Result> { - { - let cache = self.texture_cache.lock().unwrap(); - if let Some(tex) = cache.get(key) { - return Ok(Arc::clone(tex)); - } - } - - if let Some(package) = pkg { - if let Some(bytes) = package.get(key) { - let img = TexFile::parse(bytes) - .with_context(|| format!("parsing tex from pkg: {key}"))? - .to_rgba() - .with_context(|| format!("decoding RGBA from pkg tex: {key}"))?; - let tex = Arc::new(self.upload_rgba(&img)); - self.texture_cache - .lock() - .unwrap() - .insert(key.to_owned(), Arc::clone(&tex)); - return Ok(tex); - } - } - - let tex = Arc::new( - self.load_from_disk(key) - .with_context(|| format!("loading texture from disk: {key}"))?, - ); - self.texture_cache - .lock() - .unwrap() - .insert(key.to_owned(), Arc::clone(&tex)); - Ok(tex) - } - - /// Store a pre-built texture under `key`. - pub fn store(&self, key: String, tex: Arc) { - self.texture_cache.lock().unwrap().insert(key, tex); - } - - /// Upload an [`RgbaImage`] to the GPU. Not cached — use [`store`] if caching is needed. - pub fn upload_rgba(&self, img: &RgbaImage) -> wgpu::Texture { - let (w, h) = img.dimensions(); - let tex = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("rm_rgba_tex"), - size: wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &tex, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - img.as_raw(), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(w * 4), - rows_per_image: Some(h), - }, - wgpu::Extent3d { - width: w, - height: h, - depth_or_array_layers: 1, - }, - ); - tex - } - - /// Upload multiple animation frames. Returns Vec in frame order. Not cached. - pub fn upload_frames(&self, frames: Vec) -> Vec> { - frames - .iter() - .map(|img| Arc::new(self.upload_rgba(img))) - .collect() - } - - pub fn device(&self) -> &wgpu::Device { - &self.device - } - pub fn queue(&self) -> &wgpu::Queue { - &self.queue - } - pub fn sampler(&self) -> &wgpu::Sampler { - &self.sampler - } - - /// 1×1 opaque-white texture for unbound texture slots. - pub fn dummy_texture(&self) -> Arc { - Arc::clone(&self.dummy_texture) - } - - fn create_white_1x1(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::Texture { - let tex = device.create_texture(&wgpu::TextureDescriptor { - label: Some("dummy_white_1x1"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - queue.write_texture( - tex.as_image_copy(), - &[255u8, 255, 255, 255], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4), - rows_per_image: Some(1), - }, - wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - ); - tex - } - - fn load_from_disk(&self, key: &str) -> Result { - let assets_dir = loader::find_we_assets_dir() - .with_context(|| format!("WE assets dir not found while loading '{key}'"))?; - let candidates = [assets_dir.join(key), assets_dir.join(format!("{key}.tex"))]; - let bytes = candidates - .iter() - .find_map(|p| std::fs::read(p).ok()) - .with_context(|| format!("texture '{key}' not found in WE assets dir"))?; - let img = TexFile::parse(&bytes) - .with_context(|| format!("parsing tex '{key}'"))? - .to_rgba() - .with_context(|| format!("decoding RGBA '{key}'"))?; - Ok(self.upload_rgba(&img)) - } -} diff --git a/src/engine/shaders/transpiler.rs b/src/engine/shaders/transpiler.rs deleted file mode 100644 index 9b58e05..0000000 --- a/src/engine/shaders/transpiler.rs +++ /dev/null @@ -1,3199 +0,0 @@ -use crate::engine::model::ShaderModel; -use anyhow::{Context, Result}; -use std::collections::HashMap; - -pub(crate) const WE_COMMON_H: &str = "\ -#define M_PI 3.14159265359\n\ -#define M_PI_HALF 1.57079632679\n\ -#define M_PI_2 6.28318530718\n\ -#define SQRT_2 1.41421356237\n\ -#define SQRT_3 1.73205080756\n\ -float greyscale(vec3 c){return dot(c,vec3(0.299,0.587,0.114));}\n\ -vec3 hsv2rgb(vec3 c){vec4 K=vec4(1.0,2.0/3.0,1.0/3.0,3.0);vec3 p=abs(fract(c.xxx+K.xyz)*6.0-K.www);return c.z*mix(K.xxx,clamp(p-K.xxx,0.0,1.0),c.y);}\n\ -vec3 rgb2hsv(vec3 RGB){vec4 P=(RGB.g, - pub glsl_type: String, - pub default: [f32; 4], -} - -#[derive(Debug)] -pub struct TranslatedShader { - pub wgsl: String, - pub vert_wgsl: Option, - pub uniform_keys: Vec, - pub texture_count: usize, - /// Vertex attributes (glsl type, name) in declaration order when the real - /// WE vertex shader was translated; empty for the synthetic-VS path. - pub attributes: Vec<(String, String)>, - /// True when the shader reads `g_AudioSpectrum*` — the renderer only - /// starts audio capture for scenes that actually react to it. - pub uses_audio: bool, -} - -/// Collect non-sampler `uniform` declarations (type, name) in source order. -/// The macro name of a `#define NAME ...` line, if this line is one. -fn redefined_macro(trimmed_line: &str) -> Option<&str> { - let rest = trimmed_line.strip_prefix("#define")?; - if !rest.starts_with([' ', '\t']) { - return None; - } - let name = rest.trim_start(); - let end = name - .find(|c: char| !(c.is_alphanumeric() || c == '_')) - .unwrap_or(name.len()); - (end > 0).then(|| &name[..end]) -} - -fn collect_scalar_uniforms(src: &str) -> Vec<(String, String)> { - let mut scalars = Vec::new(); - for line in src.lines() { - let decl = line.trim().split("//").next().unwrap_or("").trim(); - let tok: Vec<&str> = decl.split_whitespace().collect(); - if tok.len() >= 3 && tok[0] == "uniform" { - let ty = tok[1]; - if !ty.starts_with("sampler") && !ty.is_empty() { - let name = tok[2].trim_end_matches(';'); - // Array uniforms (audio spectra etc.) can't be packed by the - // renderer's flat UBO writer; skip them (they read as zero). - if !name.contains('[') { - scalars.push((ty.to_string(), name.to_string())); - } - } - } - } - scalars -} - -/// True if every `name[...]` access in `src` has a plain non-negative-integer -/// literal inside the brackets (no variables/expressions). -fn all_indices_are_literal(src: &str, name: &str) -> bool { - let pattern = format!("{name}["); - let mut search_from = 0; - while let Some(rel) = src[search_from..].find(pattern.as_str()) { - let start = search_from + rel + pattern.len(); - let Some(end_rel) = src[start..].find(']') else { - return false; // unterminated — be conservative - }; - let content = src[start..start + end_rel].trim(); - if content.parse::().is_err() { - return false; - } - search_from = start + end_rel + 1; - } - true -} - -/// Finds the balanced-bracket span starting at `open_pos` (which must point -/// at an `open` byte), skipping bracket-like bytes inside `//` line comments. -/// Returns `(span including both brackets, index just past the closer)`. -fn extract_balanced(s: &str, open_pos: usize, open: u8, close: u8) -> Option<(&str, usize)> { - let bytes = s.as_bytes(); - if bytes.get(open_pos).copied() != Some(open) { - return None; - } - let mut depth = 0i32; - let mut i = open_pos; - let mut in_line_comment = false; - while i < bytes.len() { - let b = bytes[i]; - if in_line_comment { - if b == b'\n' { - in_line_comment = false; - } - i += 1; - continue; - } - if b == b'/' && bytes.get(i + 1) == Some(&b'/') { - in_line_comment = true; - i += 2; - continue; - } - if b == open { - depth += 1; - } else if b == close { - depth -= 1; - if depth == 0 { - return Some((&s[open_pos..=i], i + 1)); - } - } - i += 1; - } - None -} - -/// Parses a `for` header's three `;`-separated clauses into -/// `(loop_var_name, iteration_count)`, only recognizing the exact shape real -/// WE shaders use: `int NAME = 0; NAME < N; NAME++` (or `++NAME`, or `<=`). -/// Returns `None` for anything else — callers must leave the input untouched -/// rather than guess. -fn parse_simple_for_header(header_inner: &str) -> Option<(String, usize)> { - let clauses: Vec<&str> = header_inner.split(';').collect(); - if clauses.len() != 3 { - return None; - } - let init = clauses[0].trim(); - let init = init - .strip_prefix("int ") - .or_else(|| init.strip_prefix("uint "))?; - let (name, start_val) = init.split_once('=')?; - let name = name.trim(); - if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') { - return None; - } - if start_val.trim().parse::() != Ok(0) { - return None; // only handle the universal `= 0` starting point - } - - let cond = clauses[1].trim(); - let (cmp_name, op, end_str) = if let Some((n, v)) = cond.split_once("<=") { - (n.trim(), "<=", v.trim()) - } else if let Some((n, v)) = cond.split_once('<') { - (n.trim(), "<", v.trim()) - } else { - return None; - }; - if cmp_name != name { - return None; - } - let end_val: i64 = end_str.parse().ok()?; - let count = match op { - "<" => end_val, - "<=" => end_val + 1, - _ => return None, - }; - if count <= 0 || count > 64 { - return None; // sanity bound — real shaders use small fixed kernels - } - - let incr = clauses[2].trim(); - if incr != format!("{name}++") && incr != format!("++{name}") { - return None; - } - - Some((name.to_string(), count as usize)) -} - -/// Unrolls simple, fixed-bound `for (int i = 0; i < N; i++) { BODY }` loops -/// into N literal copies of `BODY` with `i` replaced by each literal index. -/// -/// Needed alongside `unroll_array_varyings`: naga's GLSL frontend rejects -/// array-typed varyings outright, and that pass only unrolls *literally* -/// indexed arrays — but some real shaders (e.g. `blur_downsample4.frag`) read -/// a fixed-size array varying through exactly this kind of loop -/// (`for (i...) texSample2D(tex, v_TexCoord[i])`). Unrolling the loop first -/// turns `v_TexCoord[i]` into `v_TexCoord[0]`, `v_TexCoord[1]`, ... so the -/// array-varying pass can then unroll the declaration too, and the shader -/// translates instead of being skipped. -/// -/// Only ever touches text that matches this exact shape; anything else (a -/// different loop form, a non-zero start, a non-literal bound, nested loops -/// it can't balance) is left completely untouched — no partial rewrites. -pub fn unroll_simple_for_loops(src: &str) -> String { - let mut out = String::with_capacity(src.len()); - let mut rest = src; - loop { - let Some(for_rel) = rest.find("for") else { - out.push_str(rest); - break; - }; - let before_ok = for_rel == 0 || !is_word_char(rest.as_bytes()[for_rel - 1]); - let after_rel = for_rel + 3; - let after_ok = rest - .as_bytes() - .get(after_rel) - .is_none_or(|&b| !is_word_char(b)); - if !before_ok || !after_ok { - out.push_str(&rest[..after_rel]); - rest = &rest[after_rel..]; - continue; - } - - let paren_start = - after_rel + rest[after_rel..].len() - rest[after_rel..].trim_start().len(); - if rest.as_bytes().get(paren_start).copied() != Some(b'(') { - out.push_str(&rest[..after_rel]); - rest = &rest[after_rel..]; - continue; - } - let Some((header, header_end)) = extract_balanced(rest, paren_start, b'(', b')') else { - out.push_str(&rest[..after_rel]); - rest = &rest[after_rel..]; - continue; - }; - let Some((name, count)) = parse_simple_for_header(&header[1..header.len() - 1]) else { - out.push_str(&rest[..header_end]); - rest = &rest[header_end..]; - continue; - }; - - let after_header = &rest[header_end..]; - let body_open_rel = after_header.len() - after_header.trim_start().len(); - let body_open_abs = header_end + body_open_rel; - if rest.as_bytes().get(body_open_abs).copied() != Some(b'{') { - out.push_str(&rest[..header_end]); - rest = &rest[header_end..]; - continue; - } - let Some((body, body_end)) = extract_balanced(rest, body_open_abs, b'{', b'}') else { - out.push_str(&rest[..header_end]); - rest = &rest[header_end..]; - continue; - }; - let body_inner = &body[1..body.len() - 1]; - - out.push_str(&rest[..for_rel]); - for i in 0..count { - // Each iteration gets its own `{}` scope, matching the original - // loop body's scoping — otherwise a variable declared inside the - // loop (e.g. `vec4 sample = ...`) becomes a flat redeclaration - // once concatenated N times into the same enclosing scope. - out.push_str("{\n"); - out.push_str(&rename_reserved_word(body_inner, &name, &i.to_string())); - out.push_str("\n}\n"); - } - rest = &rest[body_end..]; - } - out -} - -/// Splits a function-call argument list on *top-level* commas (not inside -/// nested parens), returning each argument's `(text, start_offset)` within -/// `args`. -fn split_top_level_args(args: &str) -> Vec<(&str, usize)> { - let bytes = args.as_bytes(); - let mut depth = 0i32; - let mut start = 0usize; - let mut out = Vec::new(); - for (i, &b) in bytes.iter().enumerate() { - match b { - b'(' | b'[' => depth += 1, - b')' | b']' => depth -= 1, - b',' if depth == 0 => { - out.push((&args[start..i], start)); - start = i + 1; - } - _ => {} - } - } - out.push((&args[start..], start)); - out -} - -/// True if `s` (already trimmed) is a bare numeric literal: an optional `-`, -/// digits, and an optional `.` plus more digits — no identifiers, swizzles, -/// or function calls. -fn is_bare_number(s: &str) -> bool { - if s.is_empty() { - return false; - } - let s = s.strip_prefix('-').unwrap_or(s); - if s.is_empty() { - return false; - } - let mut seen_digit = false; - let mut seen_dot = false; - for c in s.chars() { - match c { - '0'..='9' => seen_digit = true, - '.' if !seen_dot => seen_dot = true, - _ => return false, - } - } - seen_digit -} - -fn ensure_float_literal(trimmed: &str) -> String { - if trimmed.contains('.') { - trimmed.to_string() - } else { - format!("{trimmed}.0") - } -} - -/// Legacy NVIDIA GLSL compilers tolerated `max`/`min` calls with a bare -/// numeric literal as the *first* argument and a vector expression second -/// (e.g. nitro.frag's `max(0, albedo.rgb)`), including when the literal was -/// an integer where a float is needed. Strict Vulkan GLSL (naga/shaderc) -/// requires both: the literal to be a genuine float, *and* the vector -/// argument first — GLSL only defines `genType max(genType x, float y)`, not -/// the reverse. `max`/`min` are commutative, so swapping is always safe (not -/// just when the second argument turns out to be a vector). `clamp`'s -/// numeric arguments are only ever coerced to float, never reordered, since -/// `clamp(x, minVal, maxVal)` isn't commutative. -pub fn coerce_int_literal_builtin_args(src: &str) -> String { - const COMMUTATIVE_FUNCS: &[&str] = &["max(", "min("]; - const OTHER_FUNCS: &[&str] = &["clamp("]; - let mut out = String::with_capacity(src.len()); - let mut rest = src; - 'outer: loop { - let mut best: Option<(usize, usize, bool)> = None; // (rel_pos, fn_len, commutative) - for f in COMMUTATIVE_FUNCS { - if let Some(rel) = rest.find(f) { - if best.is_none_or(|(bp, ..)| rel < bp) { - best = Some((rel, f.len(), true)); - } - } - } - for f in OTHER_FUNCS { - if let Some(rel) = rest.find(f) { - if best.is_none_or(|(bp, ..)| rel < bp) { - best = Some((rel, f.len(), false)); - } - } - } - let Some((rel, flen, commutative)) = best else { - out.push_str(rest); - break 'outer; - }; - let open_pos = rel + flen - 1; // index of the '(' - out.push_str(&rest[..open_pos]); - let Some((call, call_end)) = extract_balanced(rest, open_pos, b'(', b')') else { - out.push_str(&rest[open_pos..open_pos + 1]); - rest = &rest[open_pos + 1..]; - continue; - }; - let inner = &call[1..call.len() - 1]; - let args = split_top_level_args(inner); - let trimmed: Vec<&str> = args.iter().map(|(a, _)| a.trim()).collect(); - - let mut fixed_args: Vec = trimmed.iter().map(|t| t.to_string()).collect(); - for t in fixed_args.iter_mut() { - if is_bare_number(t) { - *t = ensure_float_literal(t); - } - } - // Only max/min, only the exact 2-arg "literal first, non-literal - // second" shape — swap so the literal ends up in the (required) - // second position. - if commutative - && fixed_args.len() == 2 - && is_bare_number(trimmed[0]) - && !is_bare_number(trimmed[1]) - { - fixed_args.swap(0, 1); - } - - out.push('('); - out.push_str(&fixed_args.join(", ")); - out.push(')'); - rest = &rest[call_end..]; - } - out -} - -/// WE's shader compiler tolerates truncating a wider vector to match a -/// narrower swizzled assignment target — e.g. `albedo.rgb = mix(albedo, -/// newAlbedo, mask)` where `albedo` is `vec4` but the assignment (and -/// `newAlbedo`) are `vec3`. Strict GLSL (naga/shaderc) requires `mix()`'s -/// first two arguments to have the same component count and rejects this. -/// When an assignment's LHS is `IDENT.SWIZZLE` and the RHS is a `mix(...)` -/// call whose first argument is the same bare `IDENT`, apply the LHS's -/// swizzle to that argument too: a no-op when the types already matched, and -/// exactly the truncation WE's compiler performed implicitly otherwise. -pub fn coerce_swizzle_mismatched_mix_arg(src: &str) -> String { - fn is_swizzle(s: &str) -> bool { - (1..=4).contains(&s.len()) - && (s.bytes().all(|b| b"xyzw".contains(&b)) || s.bytes().all(|b| b"rgba".contains(&b))) - } - fn is_ident(s: &str) -> bool { - !s.is_empty() - && s.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') - && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') - } - - let mut out = String::with_capacity(src.len()); - let mut rest = src; - loop { - let Some(rel) = rest.find("mix(") else { - out.push_str(rest); - break; - }; - // Skip matches that are just the tail of a longer identifier (e.g. a - // hypothetical `remix(`). - if rel > 0 { - let prev = rest.as_bytes()[rel - 1]; - if prev.is_ascii_alphanumeric() || prev == b'_' { - out.push_str(&rest[..rel + 4]); - rest = &rest[rel + 4..]; - continue; - } - } - let open_pos = rel + 3; // index of '(' in "mix(" - let Some((call, call_end)) = extract_balanced(rest, open_pos, b'(', b')') else { - out.push_str(&rest[..open_pos + 1]); - rest = &rest[open_pos + 1..]; - continue; - }; - - // Find the assignment this call is the RHS of: walk back over - // whitespace and a trailing '=' immediately preceding "mix(". - let before_trim = rest[..rel].trim_end(); - let Some(lhs_and_stmt) = before_trim.strip_suffix('=') else { - out.push_str(&rest[..call_end]); - rest = &rest[call_end..]; - continue; - }; - // '==' is a comparison, not an assignment — reject if another '=' - // immediately precedes. - if lhs_and_stmt.ends_with('=') { - out.push_str(&rest[..call_end]); - rest = &rest[call_end..]; - continue; - } - let lhs = lhs_and_stmt.trim_end(); - let stmt_start = lhs.rfind(['\n', ';', '{', '}']).map(|i| i + 1).unwrap_or(0); - let lhs_expr = lhs[stmt_start..].trim(); - let Some((base, swizzle)) = lhs_expr.split_once('.') else { - out.push_str(&rest[..call_end]); - rest = &rest[call_end..]; - continue; - }; - if !is_ident(base) || !is_swizzle(swizzle) { - out.push_str(&rest[..call_end]); - rest = &rest[call_end..]; - continue; - } - - let inner = &call[1..call.len() - 1]; - let args = split_top_level_args(inner); - if args.is_empty() || args[0].0.trim() != base { - out.push_str(&rest[..call_end]); - rest = &rest[call_end..]; - continue; - } - - out.push_str(&rest[..open_pos + 1]); - let mut fixed: Vec = args.iter().map(|(a, _)| a.trim().to_string()).collect(); - fixed[0] = format!("{base}.{swizzle}"); - out.push_str(&fixed.join(", ")); - out.push(')'); - rest = &rest[call_end..]; - } - out -} - -pub fn unroll_array_varyings(src: &str) -> String { - // name -> (qualifier, type, count) - let mut arrays: Vec<(String, String, String, usize)> = Vec::new(); - for line in src.lines() { - let t = line.trim(); - let (qualifier, rest) = if let Some(r) = t.strip_prefix("varying ") { - ("varying", r) - } else if let Some(r) = t.strip_prefix("attribute ") { - ("attribute", r) - } else { - continue; - }; - let rest = rest.split("//").next().unwrap_or(rest).trim(); - let rest = rest.trim_end_matches(';').trim(); - let mut parts = rest.splitn(2, char::is_whitespace); - let Some(ty) = parts.next() else { continue }; - let Some(name_and_dim) = parts.next().map(str::trim) else { - continue; - }; - let Some(bracket) = name_and_dim.find('[') else { - continue; - }; - let name = &name_and_dim[..bracket]; - let Some(close) = name_and_dim[bracket + 1..].find(']') else { - continue; - }; - let dim_str = &name_and_dim[bracket + 1..bracket + 1 + close]; - let Ok(n) = dim_str.parse::() else { - continue; - }; - if n == 0 { - continue; - } - arrays.push((name.to_string(), qualifier.to_string(), ty.to_string(), n)); - } - - if arrays.is_empty() { - return src.to_string(); - } - - // WE shaders often declare the same array varying multiple times at - // different sizes under mutually-exclusive `#if COMBO==N` branches (e.g. - // godrays_gaussian.frag's `v_TexCoord[13|7|3]` per KERNEL quality level). - // Since this pass runs before `#if` resolution, all branches are still - // literally present; collapse same-named declarations to one, sized to - // the largest variant seen (extra unused varyings are harmless). - let mut merged: Vec<(String, String, String, usize)> = Vec::new(); - for (name, qualifier, ty, n) in arrays { - if let Some(existing) = merged.iter_mut().find(|(en, ..)| *en == name) { - existing.3 = existing.3.max(n); - } else { - merged.push((name, qualifier, ty, n)); - } - } - // Only unroll names indexed *exclusively* by literal integers everywhere - // in the shader. Some real shaders (e.g. blur_downsample4.frag) loop over - // the array with a variable index (`for (i...) tex[i]`); rewriting just - // the literal-looking declaration while leaving `tex[i]` untouched would - // unroll the declaration out from under a body reference that's still - // there, producing "undeclared identifier" instead of a clean bail-out. - // Leaving such names alone lets the existing "array varying not - // supported" check below reject the shader honestly instead. - let arrays: Vec<_> = merged - .into_iter() - .filter(|(name, ..)| all_indices_are_literal(src, name)) - .collect(); - - // Re-extract this line's declared array name (if any) the same way as the - // collection pass, so the declaration line is matched precisely instead - // of by fragile substring checks against the qualifier/type prefix. - let declared_name = |t: &str| -> Option { - let rest = t - .strip_prefix("varying ") - .or_else(|| t.strip_prefix("attribute "))?; - let rest = rest.split("//").next().unwrap_or(rest).trim(); - let rest = rest.trim_end_matches(';').trim(); - let mut parts = rest.splitn(2, char::is_whitespace); - parts.next()?; - let name_and_dim = parts.next()?.trim(); - let bracket = name_and_dim.find('[')?; - Some(name_and_dim[..bracket].to_string()) - }; - - let mut emitted: Vec = Vec::new(); - let mut out = String::with_capacity(src.len()); - for line in src.lines() { - let t = line.trim(); - if let Some(decl_name) = declared_name(t) { - if emitted.contains(&decl_name) { - // Same name already declared (a smaller #if/#else variant) — - // drop this line rather than redeclare it. - continue; - } - if let Some((name, qualifier, ty, n)) = arrays.iter().find(|(n, ..)| *n == decl_name) { - for i in 0..*n { - out.push_str(&format!("{qualifier} {ty} {name}_{i};\n")); - } - emitted.push(decl_name); - continue; - } - } - let mut rewritten = line.to_string(); - for (name, _, _, n) in &arrays { - for i in 0..*n { - rewritten = rewritten.replace(&format!("{name}[{i}]"), &format!("{name}_{i}")); - } - } - out.push_str(&rewritten); - out.push('\n'); - } - out -} - -pub fn translate(model: &ShaderModel) -> Result { - translate_full(model, None) -} - -/// Translate the fragment shader and, when `vert_glsl` is given, the real WE -/// vertex shader. Both stages share one UBO layout (union of their scalar -/// uniforms, fragment's first) at group(1) binding(0), and varying locations -/// are matched by name so the naga-generated interfaces line up. -#[tracing::instrument(target = "shader", level = "trace", skip_all, fields(shader = %model.name, real_vs = vert_glsl.is_some()))] -pub fn translate_full(model: &ShaderModel, vert_glsl: Option<&str>) -> Result { - tracing::trace!(target: "shader", "translating GLSL → WGSL"); - - // Unroll literal-indexed array varyings (`varying vec2 v[7]` → `v_0`, - // `v_1`, ...) before anything else touches the source — naga's SPIR-V - // reader rejects array-typed entry-point I/O outright, so this has to - // happen up front. `unroll_array_varyings` was already fully - // implemented and tested (see the tests below) but was never actually - // wired into this function — real content that uses this exact pattern - // (godrays_gaussian.frag's `v_TexCoord[13|7|3]` per KERNEL combo, its - // own motivating comment) failed to transpile as a result. Rebinding - // `model` to an owned, patched clone means every function below that - // takes `&ShaderModel` sees the unrolled source with no signature - // changes needed. - let mut model = model.clone(); - model.frag_glsl = unroll_array_varyings(&model.frag_glsl); - let vert_unrolled = vert_glsl.map(unroll_array_varyings); - let vert_glsl = vert_unrolled.as_deref(); - let model = &model; - - // Shaders that still have an array varying at this point use a - // variable index somewhere in the body (a `for` loop reading `tex[i]`) - // — `unroll_array_varyings` deliberately leaves those alone rather than - // unrolling a declaration out from under a reference that's still - // there (see its own doc comment), so this honestly rejects them: naga's - // SPIR-V reader rejects array-typed entry-point I/O either way. - for line in model.frag_glsl.lines() { - let t = line.trim(); - if t.starts_with("varying ") || t.starts_with("attribute ") { - let tok: Vec<&str> = t.split_whitespace().collect(); - if tok.len() >= 3 { - let name_raw = t - .split("//") - .next() - .unwrap_or(t) - .split_whitespace() - .nth(2) - .unwrap_or("") - .trim_end_matches(';'); - if name_raw.contains('[') { - anyhow::bail!( - "array varying '{}' not supported by naga — skipping shader '{}'", - name_raw, - model.name - ); - } - } - } - } - - // Some workshop shaders declare a varying `vec2` in the vertex stage - // but `vec4` in the fragment (WE's own compiler links them anyway) — - // upgrade the vertex side so our strict pipeline's interface matches - // (the reference does the same, ShaderUnit.cpp - // applyLinkedVaryingCompatibility). - let vert_patched: Option = - vert_glsl.map(|v| harmonize_varying_widths(v, &model.frag_glsl)); - let vert_glsl = vert_patched.as_deref(); - - // Union scalar-uniform list: fragment's declarations first (their order - // defines the front of the UBO), then vertex-only ones appended. Both - // stages emit the SAME block so std140 offsets agree. - let frag_scalars = collect_scalar_uniforms(&model.frag_glsl); - let vert_only_scalars: Vec<(String, String)> = vert_glsl - .map(|v| { - collect_scalar_uniforms(v) - .into_iter() - .filter(|(_, name)| !frag_scalars.iter().any(|(_, f)| f == name)) - .collect() - }) - .unwrap_or_default(); - - let glsl = preprocess_frag(model, &vert_only_scalars); - let spv = glsl_to_spirv_with_repairs(&glsl, naga::ShaderStage::Fragment) - .context("GLSL→SPIR-V compilation failed")?; - let wgsl = spirv_to_wgsl(&spv).context("SPIR-V→WGSL conversion failed")?; - - // glsl_name → (material_key, typed default) from `// {"material": ...}` - // annotations, collected from BOTH stages (fragment wins on conflicts). - let mut annotated: std::collections::HashMap, [f32; 4])> = - std::collections::HashMap::new(); - if let Some(v) = vert_glsl { - for meta in crate::engine::shaders::uniform_meta::parse_uniform_metadata(v) { - annotated.insert( - meta.uniform_name.clone(), - ( - meta.material_key - .unwrap_or_else(|| meta.uniform_name.clone()), - meta.label.clone(), - meta.default_value - .as_ref() - .map(json_default_vec4) - .unwrap_or([0.0; 4]), - ), - ); - } - } - for u in &model.value_uniforms { - annotated.insert( - u.glsl_name.clone(), - (u.material_key.clone(), u.label.clone(), u.default.as_vec4()), - ); - } - - // The UBO contains all union scalars in emission order. We store the GLSL - // type so the renderer can compute proper std140 layout/padding. - let uniform_keys: Vec = frag_scalars - .iter() - .chain(vert_only_scalars.iter()) - .map(|(ty, name)| { - let (key, label, default) = annotated - .get(name) - .cloned() - .unwrap_or_else(|| (name.clone(), None, [0.0; 4])); - UniformEntry { - key, - label, - glsl_type: ty.clone(), - default, - } - }) - .collect(); - - let inputs = frag_inputs(&model.frag_glsl); - - // Prefer the real WE vertex shader: it computes scrolled/scaled UVs, wave - // phases, blur offsets etc. Fall back to a synthetic passthrough VS when - // translation fails (the caller falls back again if the pipeline fails). - let mut attributes: Vec<(String, String)> = Vec::new(); - let mut vert_wgsl: Option = None; - if let Some(v) = vert_glsl { - let union_scalars: Vec<(String, String)> = frag_scalars - .iter() - .chain(vert_only_scalars.iter()) - .cloned() - .collect(); - match preprocess_vert_matched(v, &union_scalars, &inputs, &model.effective_combos()) { - Ok((vsrc, attrs)) => { - match glsl_to_spirv_with_repairs(&vsrc, naga::ShaderStage::Vertex) - .and_then(|spv| spirv_to_wgsl(&spv)) - { - Ok(w) => { - vert_wgsl = Some(w); - attributes = attrs; - } - Err(e) => tracing::warn!( - target: "shader", - "'{}': real VS translation failed ({e}); using synthetic VS", - model.name - ), - } - } - Err(e) => tracing::warn!( - target: "shader", - "'{}': real VS preprocess failed ({e}); using synthetic VS", - model.name - ), - } - } - - // Synthetic fallback: outputs match the fragment shader's declared varyings. - // vs_we_effect always outputs (vec4 v_TexCoord, vec2 v_Scroll) which causes - // a pipeline validation panic when the frag expects different types. - if vert_wgsl.is_none() && needs_custom_vs(&inputs) { - let vert_glsl = synthetic_vs_glsl(&inputs); - vert_wgsl = glsl_to_spirv(&vert_glsl, naga::ShaderStage::Vertex) - .and_then(|spv| spirv_to_wgsl(&spv)) - .ok(); - } - - Ok(TranslatedShader { - wgsl, - vert_wgsl, - uniform_keys, - texture_count: model.texture_slots.len(), - attributes, - uses_audio: model.frag_glsl.contains("AudioSpectrum"), - }) -} - -/// Convert an annotation `default` JSON value into a vec4-shaped default. -fn json_default_vec4(val: &serde_json::Value) -> [f32; 4] { - let mut out = [0.0f32; 4]; - match val { - serde_json::Value::Number(n) => { - let f = n.as_f64().unwrap_or(0.0) as f32; - out = [f; 4]; - } - serde_json::Value::String(s) => { - for (i, p) in s.split_whitespace().take(4).enumerate() { - out[i] = p.parse().unwrap_or(0.0); - } - } - serde_json::Value::Array(a) => { - for (i, p) in a.iter().take(4).enumerate() { - out[i] = p.as_f64().unwrap_or(0.0) as f32; - } - } - _ => {} - } - out -} - -// ── GLSL → SPIR-V via shaderc ──────────────────────────────────────────────── -// -// shaderc wraps glslangValidator as a linkable library — no external binary -// required, works identically on Linux (Vulkan) and macOS (Metal via wgpu). - -/// Port of the reference's `applyLinkedVaryingCompatibility` -/// (ShaderUnit.cpp:379-415): for every `varying vec4 NAME;` the fragment -/// declares where the vertex declares `varying vec2 NAME;`, upgrade the -/// vertex declaration to vec4 and rewrite the vertex's whole-variable -/// assignments (`NAME = expr;` at statement start — swizzled stores like -/// `NAME.xy = ...` are already type-correct and left alone) to -/// `NAME = vec4(expr, 0.0, 1.0);`. WE's own compiler links such mismatched -/// interfaces silently; strict Vulkan pipelines reject them. -fn harmonize_varying_widths(vert: &str, frag: &str) -> String { - // Fragment-side vec4 varyings. - let mut vec4_names: Vec = Vec::new(); - for line in frag.lines() { - let decl = line.trim().split("//").next().unwrap_or("").trim(); - let tok: Vec<&str> = decl.split_whitespace().collect(); - if tok.len() >= 3 && tok[0] == "varying" && tok[1] == "vec4" { - let name = tok[2].trim_end_matches(';'); - if !name.contains('[') { - vec4_names.push(name.to_string()); - } - } - } - if vec4_names.is_empty() { - return vert.to_string(); - } - - let mut out: Vec = Vec::new(); - let mut upgraded: Vec = Vec::new(); - for line in vert.lines() { - let decl = line.trim().split("//").next().unwrap_or("").trim(); - let tok: Vec<&str> = decl.split_whitespace().collect(); - if tok.len() >= 3 && tok[0] == "varying" && tok[1] == "vec2" { - let name = tok[2].trim_end_matches(';'); - if vec4_names.iter().any(|n| n == name) { - out.push(format!("varying vec4 {name};")); - upgraded.push(name.to_string()); - continue; - } - } - out.push(line.to_string()); - } - if upgraded.is_empty() { - return vert.to_string(); - } - - // Rewrite whole-variable assignments of upgraded varyings. - for line in &mut out { - let trimmed = line.trim_start(); - let indent_len = line.len() - trimmed.len(); - for name in &upgraded { - let Some(rest) = trimmed.strip_prefix(name.as_str()) else { - continue; - }; - let rest = rest.trim_start(); - // Plain `=` only (not `.xy =`, `==`, `+=`); expression runs to - // the trailing `;`. - let Some(expr_and_tail) = rest.strip_prefix('=') else { - continue; - }; - if expr_and_tail.starts_with('=') { - continue; - } - let Some(semi) = expr_and_tail.find(';') else { - continue; - }; - let expr = expr_and_tail[..semi].trim(); - let tail = &expr_and_tail[semi + 1..]; - *line = format!( - "{}{name} = vec4({expr}, 0.0, 1.0);{tail}", - &line[..indent_len] - ); - break; - } - } - out.join("\n") -} - -/// `glsl_to_spirv` with a bounded repair loop for HLSL-style implicit -/// conversions that strict GLSL rejects but Wallpaper Engine's own (D3D) -/// compiler accepts silently — e.g. `float p = vec2Expr;` (truncates to .x), -/// `int i = -floatVar;`, or a scalar assigned to a vec3. shaderc's error -/// messages carry the line number and both types, which is exactly enough -/// to wrap the right-hand side in the coercion HLSL would have applied, -/// then recompile. Bails out (returning the latest error) once a round -/// fixes nothing or after a few rounds, so pathological shaders still fail -/// cleanly instead of looping. -fn glsl_to_spirv_with_repairs(glsl: &str, stage: naga::ShaderStage) -> Result> { - // shaderc reports line numbers in the *generated* source, which nothing - // else ever prints — WP_DEBUG_DUMP_GLSL=1 makes those numbers usable. - let dump = |src: &str| { - if std::env::var("WP_DEBUG_DUMP_GLSL").is_err() { - return; - } - let path = format!( - "/tmp/glsl_fail_{:x}.{}", - src.len() as u64 ^ src.as_bytes().iter().map(|b| *b as u64).sum::(), - if stage == naga::ShaderStage::Vertex { - "vert" - } else { - "frag" - } - ); - let _ = std::fs::write(&path, src); - tracing::warn!("dumped failing GLSL to {path}"); - }; - let mut src: Option = None; - let mut last_err = match glsl_to_spirv(glsl, stage) { - Ok(spv) => return Ok(spv), - Err(e) => { - dump(glsl); - e - } - }; - for _ in 0..5 { - let current = src.as_deref().unwrap_or(glsl); - let err = format!("{last_err:#}"); - // Apply both repairs against the same error text: `float % int` becomes - // `mod()` and HLSL-implicit assignment conversions get their casts. - // Either may fix a different error the same round. - let mut patched = repair_float_modulo(current, &err); - for repair in [ - repair_assignment_conversions, - repair_bool_conditions, - repair_input_assignment, - repair_bool_operands, - repair_call_arg_widths, - repair_operand_widths, - ] { - let so_far = patched.as_deref().unwrap_or(current); - if let Some(p) = repair(so_far, &err) { - patched = Some(p); - } - } - let Some(patched) = patched else { - return Err(last_err); - }; - match glsl_to_spirv(&patched, stage) { - Ok(spv) => return Ok(spv), - Err(e) => last_err = e, - } - src = Some(patched); - } - Err(last_err) -} - -/// Best-effort classification of a shaderc type description ("temp highp -/// 2-component vector of float", "const float", "temp highp int", ...). -fn parse_shaderc_type(desc: &str) -> Option<(&'static str, u8)> { - if let Some(pos) = desc.find("-component vector of float") { - let n: u8 = desc[..pos].split_whitespace().last()?.parse().ok()?; - return Some(("vec", n)); - } - if desc.ends_with("float") { - return Some(("float", 1)); - } - if desc.ends_with("uint") { - return Some(("uint", 1)); - } - if desc.ends_with("int") { - return Some(("int", 1)); - } - if desc.ends_with("bool") { - return Some(("bool", 1)); - } - None -} - -/// Rewrite every `a % b` on `line` to `mod(a, b)`. HLSL's `%` accepts float -/// operands (D3D `fmod` semantics); strict GLSL's `%` is integer-only, so WE -/// shaders that write `float % N` (e.g. simple_audio_bars' bar bucketing) are -/// rejected. `mod()` is GLSL's float remainder and int→float promotion makes -/// the second operand fit. Returns `None` when the line has no modulo. -fn rewrite_modulo_on_line(line: &str) -> Option { - let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'.'; - let mut out = line.to_string(); - // Re-scan from the start after each rewrite (indices shift). - loop { - let bytes = out.as_bytes(); - // Find a modulo `%` that isn't the compound-assign `%=`. - let Some(pct) = - (0..bytes.len()).find(|&i| bytes[i] == b'%' && bytes.get(i + 1) != Some(&b'=')) - else { - break; - }; - - // Left operand: skip whitespace, then take a parenthesized group (plus - // any function name in front of it) or a bare identifier/number. - let mut le = pct; - while le > 0 && bytes[le - 1].is_ascii_whitespace() { - le -= 1; - } - let ls = if le > 0 && (bytes[le - 1] == b')' || bytes[le - 1] == b']') { - let (open, close) = if bytes[le - 1] == b')' { - (b'(', b')') - } else { - (b'[', b']') - }; - let mut depth = 0i32; - let mut k = le - 1; - loop { - if bytes[k] == close { - depth += 1; - } else if bytes[k] == open { - depth -= 1; - if depth == 0 { - break; - } - } - if k == 0 { - break; - } - k -= 1; - } - let mut s = k; - while s > 0 && is_ident(bytes[s - 1]) { - s -= 1; - } - s - } else { - let mut s = le; - while s > 0 && is_ident(bytes[s - 1]) { - s -= 1; - } - s - }; - - // Right operand: skip whitespace, then a parenthesized group or a - // bare identifier/number optionally followed by a call's parens. - let mut rs = pct + 1; - while rs < bytes.len() && bytes[rs].is_ascii_whitespace() { - rs += 1; - } - let re = if rs < bytes.len() && (bytes[rs] == b'(' || bytes[rs] == b'[') { - let close = if bytes[rs] == b'(' { b')' } else { b']' }; - let open = bytes[rs]; - let mut depth = 0i32; - let mut k = rs; - while k < bytes.len() { - if bytes[k] == open { - depth += 1; - } else if bytes[k] == close { - depth -= 1; - if depth == 0 { - k += 1; - break; - } - } - k += 1; - } - k - } else { - let mut k = rs; - while k < bytes.len() && is_ident(bytes[k]) { - k += 1; - } - if k < bytes.len() && bytes[k] == b'(' { - let mut depth = 0i32; - while k < bytes.len() { - if bytes[k] == b'(' { - depth += 1; - } else if bytes[k] == b')' { - depth -= 1; - if depth == 0 { - k += 1; - break; - } - } - k += 1; - } - } - k - }; - - // Bail if either operand came out empty (malformed `%`) — leave as-is. - if ls >= le || rs >= re { - break; - } - let left = out[ls..le].trim().to_string(); - let right = out[rs..re].trim().to_string(); - out = format!("{}mod({left}, {right}){}", &out[..ls], &out[re..]); - } - (out != line).then_some(out) -} - -/// Parse `shader.frag:N: error: '%' : wrong operand types` messages and rewrite -/// the flagged line's modulo(s) to `mod()`. Returns `None` when nothing matched. -fn repair_float_modulo(glsl: &str, err_text: &str) -> Option { - let mut lines: Vec = glsl.lines().map(str::to_string).collect(); - let mut repaired = false; - for msg in err_text.lines() { - if !msg.contains("'%'") || !msg.contains("operand") { - continue; - } - let Some(rest) = msg - .split_once("shader.frag:") - .or_else(|| msg.split_once("shader.vert:")) - .map(|(_, r)| r) - else { - continue; - }; - let Some((line_str, _)) = rest.split_once(':') else { - continue; - }; - let Ok(line_no) = line_str.trim().parse::() else { - continue; - }; - if let Some(line) = lines.get_mut(line_no.saturating_sub(1)) { - if let Some(patched) = rewrite_modulo_on_line(line) { - *line = patched; - repaired = true; - } - } - } - repaired.then(|| lines.join("\n")) -} - -/// Parse `shader.frag:N: error: '=' : cannot convert from ' X' to ' Y'` -/// messages and wrap the offending line's assignment RHS in the conversion -/// HLSL would apply implicitly. Returns `None` when nothing was repaired. -fn repair_assignment_conversions(glsl: &str, err_text: &str) -> Option { - let mut lines: Vec = glsl.lines().map(str::to_string).collect(); - let mut repaired = false; - - for msg in err_text.lines() { - let Some(rest) = msg - .split_once("shader.frag:") - .or_else(|| msg.split_once("shader.vert:")) - .map(|(_, r)| r) - else { - continue; - }; - let Some((line_str, detail)) = rest.split_once(':') else { - continue; - }; - let Ok(line_no) = line_str.trim().parse::() else { - continue; - }; - let Some(conv) = detail.split("cannot convert from '").nth(1) else { - continue; - }; - // detail shape: ` ' to ' '` - let mut parts = conv.split('\''); - let from_desc = parts.next().unwrap_or("").trim(); - let to_desc = parts.nth(1).unwrap_or("").trim(); - let (Some(from), Some(to)) = (parse_shaderc_type(from_desc), parse_shaderc_type(to_desc)) - else { - continue; - }; - - // Pick the wrapper HLSL's implicit conversion corresponds to. - let wrap: (String, String) = match (from, to) { - // vecN -> scalar: HLSL truncation warning, takes the leading - // component(s). - (("vec", _), ("float", _)) => ("(".into(), ").x".into()), - (("vec", m), ("vec", n)) if m > n => { - ("(".into(), format!(").{}", &"xyzw"[..n as usize])) - } - // scalar -> vecN: HLSL splats. - (("float", _) | ("int", _) | (("bool", _)), ("vec", n)) => { - (format!("vec{n}("), ")".into()) - } - // bool -> number: HLSL's true/false are 1/0 (simple_audio_bars - // assigns a comparison straight into an int bar index). - (("bool", _), ("float", _)) => ("float(".into(), ")".into()), - (("bool", _), ("int", _)) => ("int(".into(), ")".into()), - (("bool", _), ("uint", _)) => ("uint(".into(), ")".into()), - // float -> int/uint: HLSL truncates toward zero, same as GLSL's - // explicit constructor. - (("float", _), ("int", _)) => ("int(".into(), ")".into()), - (("float", _), ("uint", _)) => ("uint(".into(), ")".into()), - _ => continue, - }; - - let Some(line) = lines.get_mut(line_no.saturating_sub(1)) else { - continue; - }; - if let Some(patched) = wrap_assignment_rhs(line, &wrap.0, &wrap.1) { - *line = patched; - repaired = true; - } - } - - repaired.then(|| lines.join("\n")) -} - -/// Declared widths of everything named in a generated shader: input varyings, -/// UBO members and plain local declarations. Feeds the error-driven repairs -/// below, which run on the final GLSL text and so have no access to the -/// preprocessor's own tracking. -fn declared_widths(glsl: &str) -> HashMap { - let mut map = HashMap::new(); - for line in glsl.lines() { - let t = line.trim_start(); - // "layout(location=0) in vec4 v_TexCoord;" / " vec2 u_rOffset;" / - // " vec4 timer = texSample2D(...);" - let decl = t.rsplit(')').next().unwrap_or(t).trim(); - let mut tok = decl.split_whitespace(); - let (Some(first), Some(second)) = (tok.next(), tok.next()) else { - continue; - }; - let (ty, name) = if first == "in" || first == "out" || first == "uniform" { - match tok.next() { - Some(n) => (second, n), - None => continue, - } - } else { - (first, second) - }; - let Some(w) = vec_width(ty).or(matches!(ty, "float" | "int").then_some(1)) else { - continue; - }; - let name = name - .trim_end_matches([';', ',']) - .split('=') - .next() - .unwrap_or("") - .trim(); - if !name.is_empty() && name.chars().all(|c| is_word_char(c as u8)) { - map.insert(name.to_string(), w); - } - } - map -} - -/// Line numbers shaderc reported for messages containing `needle`. -fn error_lines(err_text: &str, needle: &str) -> Vec { - err_text - .lines() - .filter(|m| m.contains(needle)) - .filter_map(|m| { - let rest = m - .split_once("shader.frag:") - .or_else(|| m.split_once("shader.vert:"))? - .1; - rest.split(':').next()?.trim().parse::().ok() - }) - .collect() -} - -/// `v_TexCoord += ...` — chromatic_aberration.frag writes to its own varying, -/// which WE's compiler allows and GLSL does not ("l-value required ... can't -/// modify shader input"). Rename the input and give `main()` a mutable copy -/// under the original name, so the body compiles unchanged. -fn repair_input_assignment(glsl: &str, err_text: &str) -> Option { - let mut out = glsl.to_string(); - let mut repaired = false; - for msg in err_text.lines() { - if !msg.contains("can't modify shader input") { - continue; - } - let Some(name) = msg - .split('"') - .nth(1) - .filter(|n| !n.is_empty() && n.chars().all(|c| is_word_char(c as u8))) - else { - continue; - }; - let alias = format!("_wp_in_{name}"); - if out.contains(&alias) { - continue; - } - // Find the input declaration to learn its type, and rename it there. - let Some(decl) = out.lines().find(|l| { - let t = l.trim_start(); - t.starts_with("layout(") && t.contains(" in ") && t.ends_with(&format!(" {name};")) - }) else { - continue; - }; - let ty = decl.split_whitespace().nth(2)?.to_string(); - let renamed = decl.replace(&format!(" {name};"), &format!(" {alias};")); - out = out.replace(decl, &renamed); - // Shadow it with a local copy at the top of main(). - let main_brace = out - .find("void main") - .and_then(|p| out[p..].find('{').map(|i| p + i + 1))?; - out.insert_str(main_brace, &format!("\n\t{ty} {name} = {alias};")); - repaired = true; - } - repaired.then_some(out) -} - -/// `depth *= (depth < limit) * 6.0` — bokeh_blur.frag does arithmetic on a -/// comparison. HLSL reads true as 1.0; GLSL refuses. Wrap the parenthesised -/// comparison in `float(...)`. -fn repair_bool_operands(glsl: &str, err_text: &str) -> Option { - let mut lines: Vec = glsl.lines().map(str::to_string).collect(); - let mut repaired = false; - for line_no in error_lines(err_text, "operand of type ' temp bool'") { - let Some(line) = lines.get_mut(line_no.saturating_sub(1)) else { - continue; - }; - let mut patched = line.clone(); - let mut from = 0; - while let Some(rel) = patched[from..].find('(') { - let open = from + rel; - let Some((group, after)) = extract_balanced(&patched, open, b'(', b')') else { - break; - }; - let inner = &group[1..group.len() - 1]; - let is_comparison = ["<", ">", "<=", ">=", "==", "!="] - .iter() - .any(|op| inner.contains(op)) - && !inner.contains("float("); - if is_comparison && !patched[..open].ends_with("float") { - let replacement = format!("float{group}"); - patched = format!("{}{replacement}{}", &patched[..open], &patched[after..]); - from = open + replacement.len(); - repaired = true; - continue; - } - from = open + 1; - } - *line = patched; - } - repaired.then(|| lines.join("\n")) -} - -/// `rotateVec2(v_TexCoord, ...)` where the varying is a vec4 — usually because -/// `harmonize_varying_widths` widened the fragment's declaration to match the -/// vertex stage. Coerce each argument to the callee's declared parameter type. -fn repair_call_arg_widths(glsl: &str, err_text: &str) -> Option { - let widths = declared_widths(glsl); - let mut lines: Vec = glsl.lines().map(str::to_string).collect(); - let mut repaired = false; - for msg in err_text.lines() { - if !msg.contains("no matching overloaded function found") { - continue; - } - let Some(func) = msg.split('\'').nth(1) else { - continue; - }; - let Some(line_no) = error_lines(msg, "no matching overloaded function") - .first() - .copied() - else { - continue; - }; - // The callee's parameter widths, from its definition in this source. - let Some(params) = glsl.lines().find_map(|l| { - let t = l.trim_start(); - let rest = t.split_once(&format!(" {func}("))?.1; - let end = rest.find(')')?; - Some( - rest[..end] - .split(',') - .filter_map(|p| { - let ty = p.split_whitespace().next()?; - vec_width(ty).or(matches!(ty, "float" | "int").then_some(1)) - }) - .collect::>(), - ) - }) else { - continue; - }; - let Some(line) = lines.get_mut(line_no.saturating_sub(1)) else { - continue; - }; - let Some(call) = line.find(&format!("{func}(")) else { - continue; - }; - let open = call + func.len(); - let Some((group, after)) = extract_balanced(line, open, b'(', b')') else { - continue; - }; - let args: Vec = split_top_level_args(&group[1..group.len() - 1]) - .iter() - .map(|(a, _)| a.trim().to_string()) - .collect(); - if args.len() != params.len() { - continue; - } - let coerced: Vec = args - .iter() - .zip(¶ms) - .map(|(arg, &want)| match widths.get(arg.as_str()) { - Some(&have) if have > want && want >= 2 => { - format!("{arg}{}", swizzle_prefix(want)) - } - _ => arg.clone(), - }) - .collect(); - if coerced == args { - continue; - } - *line = format!( - "{}{func}({}){}", - &line[..call], - coerced.join(", "), - &line[after..] - ); - repaired = true; - } - repaired.then(|| lines.join("\n")) -} - -/// `u_rOffset * timer` where one is a vec2 and the other a vec4 — HLSL -/// truncates the wider side, GLSL rejects the pair. Swizzle the wider -/// identifiers on the offending line down to the narrower width. -fn repair_operand_widths(glsl: &str, err_text: &str) -> Option { - let widths = declared_widths(glsl); - let mut lines: Vec = glsl.lines().map(str::to_string).collect(); - let mut repaired = false; - for msg in err_text.lines() { - if !msg.contains("wrong operand types") { - continue; - } - let mut sides = msg.split("operand of type").skip(1).filter_map(|s| { - let desc = s.split('\'').nth(1)?; - parse_shaderc_type(desc.trim()).map(|(_, n)| n) - }); - let (Some(a), Some(b)) = (sides.next(), sides.next()) else { - continue; - }; - let (wide, narrow) = (a.max(b), a.min(b)); - if wide == narrow || narrow < 2 { - continue; - } - let Some(line_no) = error_lines(msg, "wrong operand types").first().copied() else { - continue; - }; - let Some(line) = lines.get_mut(line_no.saturating_sub(1)) else { - continue; - }; - let patched = truncate_wide_identifiers_of(line, wide, narrow, &widths); - if patched != *line { - *line = patched; - repaired = true; - } - } - repaired.then(|| lines.join("\n")) -} - -/// Swizzle every bare identifier of exactly `wide` components down to -/// `narrow`. Unlike `truncate_wide_identifiers` this runs on a whole -/// statement (call arguments included) and only when shaderc already -/// rejected that line, so it cannot narrow a call that was fine. -fn truncate_wide_identifiers_of( - line: &str, - wide: u8, - narrow: u8, - widths: &HashMap, -) -> String { - let mut out = String::with_capacity(line.len()); - let bytes = line.as_bytes(); - let mut prev_word: Option = None; - let mut i = 0; - while i < bytes.len() { - if !is_word_char(bytes[i]) - || (i > 0 && (is_word_char(bytes[i - 1]) || bytes[i - 1] == b'.')) - { - out.push(bytes[i] as char); - i += 1; - continue; - } - let start = i; - while i < bytes.len() && is_word_char(bytes[i]) { - i += 1; - } - let ident = &line[start..i]; - out.push_str(ident); - let next = bytes.get(i).copied().unwrap_or(b' '); - // `vec4 rValue = ...` declares rValue — swizzling the declared name - // is a syntax error, and the declaration's own width is not the bug. - let is_declaration = matches!( - prev_word.as_deref(), - Some("vec2" | "vec3" | "vec4" | "float" | "int" | "uint" | "bool") - ); - if !is_declaration - && next != b'.' - && next != b'(' - && next != b'[' - && widths.get(ident) == Some(&wide) - { - out.push_str(swizzle_prefix(narrow)); - } - prev_word = Some(ident.to_string()); - } - out -} - -/// `if (u_userInvertDepthMap)` where the uniform is a float — HLSL treats -/// nonzero as true, strict GLSL wants a bool ("boolean expression expected"). -/// Wrap the condition in `bool(...)`. Returns `None` when nothing was repaired. -fn repair_bool_conditions(glsl: &str, err_text: &str) -> Option { - let mut lines: Vec = glsl.lines().map(str::to_string).collect(); - let mut repaired = false; - for msg in err_text.lines() { - if !msg.contains("boolean expression expected") { - continue; - } - let Some(rest) = msg - .split_once("shader.frag:") - .or_else(|| msg.split_once("shader.vert:")) - .map(|(_, r)| r) - else { - continue; - }; - let Ok(line_no) = rest.split(':').next().unwrap_or("").trim().parse::() else { - continue; - }; - let Some(line) = lines.get_mut(line_no.saturating_sub(1)) else { - continue; - }; - for kw in ["if", "while"] { - let Some(kw_pos) = line - .find(&format!("{kw}(")) - .or_else(|| line.find(&format!("{kw} ("))) - else { - continue; - }; - let Some(open) = line[kw_pos..].find('(').map(|i| kw_pos + i) else { - continue; - }; - // extract_balanced yields the parens too, and `after` is the index - // just past the closing one. - let Some((parens, after)) = extract_balanced(line, open, b'(', b')') else { - continue; - }; - let cond = parens[1..parens.len() - 1].trim(); - if cond.is_empty() || cond.contains("bool(") { - continue; - } - *line = format!("{}(bool({cond})){}", &line[..open], &line[after..]); - repaired = true; - break; - } - } - repaired.then(|| lines.join("\n")) -} - -/// Wrap the right-hand side of the first plain assignment on `line` in -/// `prefix`/`suffix`. The RHS runs from the `=` to the first `;` or `,` at -/// paren depth zero relative to the RHS start (so a `for (int i = x; ...)` -/// init terminates at its own `;`). Compound (`+=`) and comparison -/// (`==`/`<=`/...) operators are not assignments and are skipped. -fn wrap_assignment_rhs(line: &str, prefix: &str, suffix: &str) -> Option { - let bytes = line.as_bytes(); - let mut eq = None; - for (i, &b) in bytes.iter().enumerate() { - if b != b'=' { - continue; - } - let prev = if i > 0 { bytes[i - 1] } else { b' ' }; - let next = bytes.get(i + 1).copied().unwrap_or(b' '); - // Skip comparisons (`==`, `!=`, `<=`, `>=`). Compound assignments - // (`+= -= *= /=`) ARE valid targets: `int x *= floatExpr` needs the - // RHS wrapped just like a plain `int x = floatExpr` does. - if matches!(prev, b'=' | b'!' | b'<' | b'>') || next == b'=' { - continue; - } - eq = Some(i); - break; - } - let eq = eq?; - - let rhs_start = eq + 1; - let mut depth = 0i32; - let mut rhs_end = line.len(); - for (i, ch) in line[rhs_start..].char_indices() { - match ch { - '(' | '[' => depth += 1, - ')' | ']' => depth -= 1, - ';' | ',' if depth <= 0 => { - rhs_end = rhs_start + i; - break; - } - _ => {} - } - } - let rhs = line[rhs_start..rhs_end].trim(); - if rhs.is_empty() { - return None; - } - if rhs.starts_with(prefix.trim_end_matches('(')) && rhs.ends_with(suffix) { - // Already wrapped on an earlier repair round — move on to the next - // assignment on this line. simple_audio_bars.frag puts two of them on - // one line ("barLeft *= isLeftChannel; barRight *= isRightChannel;") - // and both need the same cast. - let rest = wrap_assignment_rhs(&line[rhs_end..], prefix, suffix)?; - return Some(format!("{}{rest}", &line[..rhs_end])); - } - Some(format!( - "{} {prefix}{rhs}{suffix}{}", - &line[..rhs_start], - &line[rhs_end..] - )) -} - -fn glsl_to_spirv(glsl: &str, stage: naga::ShaderStage) -> Result> { - let (kind, filename) = match stage { - naga::ShaderStage::Fragment => (shaderc::ShaderKind::Fragment, "shader.frag"), - naga::ShaderStage::Vertex => (shaderc::ShaderKind::Vertex, "shader.vert"), - _ => (shaderc::ShaderKind::Compute, "shader.comp"), - }; - - let compiler = shaderc::Compiler::new().context("failed to create shaderc compiler")?; - let mut options = shaderc::CompileOptions::new().context("failed to create shaderc options")?; - // Target Vulkan SPIR-V — required for naga's spv-in reader. - // Our preprocessor already emits Vulkan GLSL 4.50 with explicit set/binding/location qualifiers. - options.set_target_env( - shaderc::TargetEnv::Vulkan, - shaderc::EnvVersion::Vulkan1_1 as u32, - ); - options.set_source_language(shaderc::SourceLanguage::GLSL); - options.set_optimization_level(shaderc::OptimizationLevel::Performance); - - let artifact = compiler - .compile_into_spirv(glsl, kind, filename, "main", Some(&options)) - .context("shaderc GLSL→SPIR-V compilation failed")?; - - Ok(artifact.as_binary_u8().to_vec()) -} - -// ── SPIR-V → WGSL via naga ─────────────────────────────────────────────────── - -fn spirv_to_wgsl(spv: &[u8]) -> Result { - let module = naga::front::spv::parse_u8_slice( - spv, - &naga::front::spv::Options { - adjust_coordinate_space: true, - ..Default::default() - }, - ) - .context("naga SPIR-V parse failed")?; - - let info = naga::valid::Validator::new( - naga::valid::ValidationFlags::all(), - naga::valid::Capabilities::all(), - ) - .validate(&module) - .context("naga validation failed")?; - - naga::back::wgsl::write_string(&module, &info, naga::back::wgsl::WriterFlags::empty()) - .context("naga WGSL output failed") -} - -// ── Preprocessors ───────────────────────────────────────────────────────────── -// -// Convert WE-style GLSL (OpenGL 2.x) to Vulkan-compatible GLSL 4.50 with -// separate texture2D + sampler bindings (naga's SPIR-V reader doesn't support -// combined sampler2D loads via OpLoad; it requires OpSampledImage from separate parts). -// -// Binding layout matches our GPU renderer's bind groups: -// Group 0, binding 0 : texture 0 (source / framebuffer) -// Group 0, binding 1 : shared sampler -// Group 0, binding 2 : (buffer — unused by dynamic shader, reserved) -// Group 0, binding 3 : texture 1 -// Group 0, binding 4 : texture 2 -// Group 0, binding 5 : texture 3 -// Group 1, binding 0 : effect UBO (scalar uniforms) - -fn vec_width(ty: &str) -> Option { - match ty { - "vec2" => Some(2), - "vec3" => Some(3), - "vec4" => Some(4), - _ => None, - } -} - -fn swizzle_prefix(n: u8) -> &'static str { - match n { - 1 => ".x", - 2 => ".xy", - 3 => ".xyz", - 4 => ".xyzw", - _ => "", - } -} - -const TEX_SAMPLE_CALLS: &[&str] = &[ - "texSample2DLod(", - "texSample2D(", - "texture2DLod(", - "textureSample2D(", - "texture2D(", - "textureCube(", - "texture(", -]; - -/// Legacy NVIDIA GLSL compilers silently truncated the wider operand when -/// vector widths mismatched in an assignment or binary op (e.g. assigning a -/// vec4 texture sample to a `vec3` without a `.rgb` swizzle, or subtracting a -/// `vec2` from a `vec3` varying). Workshop shaders authored and only ever -/// tested on NVIDIA rely on this; naga/shaderc's stricter frontend rejects it -/// outright. `var_width` is updated in place as declarations are seen so the -/// caller can process a shader body line by line. -/// Rename functions the shader defines under a name that is already a GLSL -/// builtin or one of our HLSL-compat macros. `crt_scan_line.frag` ships its -/// own `float mod(float, float)`, which glslang rejects (the builtin's -/// parameters are `highp`, the shader's are not); `lerp`/`saturate` would -/// expand through the macro into an even stranger error. -fn rename_shadowed_builtins(src: &str) -> String { - const SHADOWABLE: &[&str] = &["mod", "lerp", "saturate", "frac", "mul", "atan2", "log10"]; - let mut out = src.to_string(); - for name in SHADOWABLE { - let defined = src.lines().any(|l| { - let t = l.trim_start(); - let Some(rest) = t.split_once(&format!(" {name}(")) else { - return false; - }; - // A definition starts the line with just a return type — `return - // frac(x);` also has one leading word, so match real types only. - matches!( - rest.0, - "float" - | "vec2" - | "vec3" - | "vec4" - | "int" - | "uint" - | "bool" - | "void" - | "mat2" - | "mat3" - | "mat4" - ) - }); - if defined { - out = rename_reserved_word(&out, name, &format!("_wp_{name}")); - } - } - out -} - -/// Swizzle every bare identifier in `expr` that is known to be wider than -/// `w` down to `w` components (`v_TexCoord` → `v_TexCoord.xy`). Identifiers -/// that already carry a swizzle, are being called, or are struct/array -/// accesses are left alone. -fn truncate_wide_identifiers(expr: &str, w: u8, var_width: &HashMap) -> String { - let mut out = String::with_capacity(expr.len()); - let bytes = expr.as_bytes(); - // A call's arguments are typed by its signature, not by the declaration - // being assigned to: narrowing `get_noise(v_TexCoord)` to `.xyz` breaks the - // call it was fine in. Only bare arithmetic operands get truncated. - let mut call_depth = 0usize; - let mut last_word_end: Option = None; - let mut i = 0; - while i < bytes.len() { - if !is_word_char(bytes[i]) - || (i > 0 && (is_word_char(bytes[i - 1]) || bytes[i - 1] == b'.')) - { - match bytes[i] { - b'(' => { - let is_call = last_word_end == Some(i); - if is_call || call_depth > 0 { - call_depth += 1; - } - } - b')' => call_depth = call_depth.saturating_sub(1), - _ => {} - } - if bytes[i] != b' ' { - last_word_end = None; - } - out.push(bytes[i] as char); - i += 1; - continue; - } - let start = i; - while i < bytes.len() && is_word_char(bytes[i]) { - i += 1; - } - let ident = &expr[start..i]; - out.push_str(ident); - last_word_end = Some(i); - let next = bytes.get(i).copied().unwrap_or(b' '); - if call_depth == 0 && next != b'.' && next != b'(' && next != b'[' { - if let Some(&iw) = var_width.get(ident) { - if iw > w { - out.push_str(swizzle_prefix(w)); - } - } - } - } - out -} - -/// `timer.xyz` where `timer` is a `float` — lens_flare_sun.frag does this and -/// WE's compiler treats it as a splat. Strict GLSL calls it a swizzle out of -/// range, so rewrite it to `vec3(timer)`. -fn splat_scalar_swizzles(line: &str, var_width: &HashMap) -> String { - let mut out = String::with_capacity(line.len()); - let bytes = line.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if !is_word_char(bytes[i]) - || (i > 0 && (is_word_char(bytes[i - 1]) || bytes[i - 1] == b'.')) - { - out.push(bytes[i] as char); - i += 1; - continue; - } - let start = i; - while i < bytes.len() && is_word_char(bytes[i]) { - i += 1; - } - let ident = &line[start..i]; - // A swizzle of 2-4 components on something declared `float`. - let swz_start = i + 1; - let swz_end = if bytes.get(i) == Some(&b'.') { - let mut j = swz_start; - while j < bytes.len() && b"xyzwrgba".contains(&bytes[j]) { - j += 1; - } - j - } else { - swz_start - }; - let n = swz_end.saturating_sub(swz_start); - if (2..=4).contains(&n) - && var_width.get(ident) == Some(&1) - && !bytes.get(swz_end).is_some_and(|b| is_word_char(*b)) - { - out.push_str(&format!("vec{n}({ident})")); - i = swz_end; - continue; - } - out.push_str(ident); - } - out -} - -fn coerce_vector_widths(line: &str, var_width: &mut HashMap) -> String { - let indent_len = line.len() - line.trim_start().len(); - let indent = &line[..indent_len]; - let trimmed = line.trim_start(); - let trimmed = trimmed.strip_suffix('\r').unwrap_or(trimmed); - - // `vecN name = ;` — track the declared width, and if is an - // unswizzled texture sample (always vec4) narrower than N, truncate it. - // Scalars are tracked too — only so a `.xyz` on one can be splatted below. - if let Some(rest) = trimmed.strip_prefix("float ") { - if let Some(eq) = rest.find('=') { - let name = rest[..eq].trim(); - if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') { - var_width.insert(name.to_string(), 1); - } - } - } - let mut declared: Option<(u8, &str)> = None; - for w in [2u8, 3u8] { - let prefix = format!("vec{w} "); - if let Some(rest) = trimmed.strip_prefix(prefix.as_str()) { - if let Some(eq_pos) = rest.find('=') { - let name = rest[..eq_pos].trim(); - if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') { - declared = Some((w, name)); - } - } - } - } - if let Some((w, name)) = declared { - var_width.insert(name.to_string(), w); - if let Some(body) = trimmed.strip_suffix(';') { - if let Some(eq_pos) = body.find('=') { - let rhs = body[eq_pos + 1..].trim(); - if TEX_SAMPLE_CALLS.iter().any(|c| rhs.starts_with(c)) - && rhs.ends_with(')') - && w < 4 - { - let lhs = &body[..=eq_pos]; - return format!("{indent}{lhs} {rhs}{};", swizzle_prefix(w)); - } - // Wider operands inside a narrower declaration's expression: - // clipping_mask.frag computes `vec2 uv = (v_TexCoord * 2.0 - - // 1.0 - texScaleCenter) / ...` with a vec4 varying. HLSL - // truncates the wide side with a warning; GLSL rejects it. - let narrowed = truncate_wide_identifiers(rhs, w, var_width); - if narrowed != rhs { - let lhs = &body[..=eq_pos]; - return format!("{indent}{lhs} {narrowed};"); - } - } - } - } - - // Plain reassignment `name = ;` to an already-known narrower var. - if declared.is_none() { - if let Some(body) = trimmed.strip_suffix(';') { - if let Some(eq_pos) = body.find('=') { - let (lhs, rhs) = (body[..eq_pos].trim(), body[eq_pos + 1..].trim()); - if lhs.chars().all(|c| c.is_alphanumeric() || c == '_') { - if let Some(&w) = var_width.get(lhs) { - if w < 4 - && TEX_SAMPLE_CALLS.iter().any(|c| rhs.starts_with(c)) - && rhs.ends_with(')') - { - return format!("{indent}{lhs} = {rhs}{};", swizzle_prefix(w)); - } - } - } - } - } - } - - // Binary arithmetic against a `CASTn(...)` broadcast: `wide_var - CASTn(x)` where - // wide_var's known width exceeds n. Truncate wide_var's trailing components. - let mut out = splat_scalar_swizzles(trimmed, var_width); - for cast_w in [2u8, 3u8] { - let marker = format!("CAST{cast_w}("); - let mut search_from = 0; - while let Some(rel) = out[search_from..].find(marker.as_str()) { - let cast_pos = search_from + rel; - // Walk left over whitespace, then the binary operator, then whitespace, - // to find the end of the preceding operand. - let mut i = cast_pos; - let bytes = out.as_bytes(); - while i > 0 && bytes[i - 1] == b' ' { - i -= 1; - } - if i == 0 || !matches!(bytes[i - 1], b'-' | b'+' | b'*' | b'/') { - search_from = cast_pos + marker.len(); - continue; - } - i -= 1; // consume operator - while i > 0 && bytes[i - 1] == b' ' { - i -= 1; - } - let operand_end = i; - let mut start = i; - while start > 0 - && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') - { - start -= 1; - } - let operand = &out[start..operand_end]; - if let Some(&w) = var_width.get(operand) { - if w > cast_w && start > 0 && bytes[start - 1] != b'.' { - let swz = swizzle_prefix(cast_w); - out = format!("{}{}{}", &out[..operand_end], swz, &out[operand_end..]); - search_from = operand_end + swz.len() + marker.len(); - continue; - } - } - search_from = cast_pos + marker.len(); - } - } - format!("{indent}{out}") -} - -fn preprocess_frag(model: &ShaderModel, extra_scalars: &[(String, String)]) -> String { - let src = &rename_shadowed_builtins(&model.frag_glsl); - - // First pass: collect declarations in order - let mut sampler_names: Vec = Vec::new(); // g_Texture0, g_Texture1, ... - let mut scalars: Vec<(String, String)> = Vec::new(); // (type, name) → UBO - let mut inputs: Vec<(String, String)> = Vec::new(); // varyings - let mut zero_arrays: Vec<(String, usize)> = Vec::new(); // float name[N] → const zeros - - for line in src.lines() { - let t = line.trim(); - let decl = t.split("//").next().unwrap_or(t).trim(); - let tok: Vec<&str> = decl.split_whitespace().collect(); - if tok.len() >= 3 && tok[0] == "uniform" { - let ty = tok[1]; - let name = tok[2].trim_end_matches(';'); - if ty.starts_with("sampler") { - sampler_names.push(name.to_string()); - } else if !ty.is_empty() && !name.contains('[') { - scalars.push((ty.to_string(), name.to_string())); - } else if ty == "float" { - // Float-array uniforms (`uniform float g_AudioSpectrum64Left[64];`) - // don't fit the 16-byte-per-scalar UBO model, so they're - // re-emitted below as zero-filled const arrays instead of - // silently vanishing (which left the identifiers undeclared - // and failed the whole compile). The only such uniforms in - // practice are WE's audio-spectrum bands, and zero is exactly - // what they hold when no audio is playing — the right output - // for an engine with no audio-capture pipeline (yet). - if let Some((base, rest)) = name.split_once('[') { - if let Ok(n) = rest.trim_end_matches(']').parse::() { - if n > 0 && !zero_arrays.iter().any(|(b, _)| b == base) { - zero_arrays.push((base.to_string(), n)); - } - } - } - } - } else if t.starts_with("varying ") || t.starts_with("attribute ") { - if tok.len() >= 3 { - let ty = tok[1]; - let name_raw = decl - .split_whitespace() - .nth(2) - .unwrap_or("") - .trim_end_matches(';'); - // Skip array varyings (e.g. `varying vec2 v_TexCoord[7]`) — naga cannot - // represent arrays as entry-point I/O; the shader would fail at SPIR-V parse. - if name_raw.contains('[') { - continue; - } - inputs.push((ty.to_string(), name_raw.to_string())); - } - } - } - - // Texture binding indices: g_Texture0 → 0, g_TextureN → N+2 (3, 4, 5, ...). - // - // The binding MUST come from the `N` in the uniform's own name, not from - // its position in the declaration scan: the scan reads the raw source - // and ignores `#if` blocks, so a conditionally-declared sampler that the - // compile strips (e.g. shine_combine.frag declares `g_Texture2` inside - // `#if COPYBG` *above* g_Texture0/g_Texture1) would otherwise shift every - // later sampler's binding by one — the shader then samples neighbouring - // slots (or the white 1×1 dummy) instead of its real inputs. WE itself - // keys texture units off the name's index (CPass binds unit N to - // g_TextureN), so the name is the authoritative slot. - let tex_binding = |i: usize, name: &str| -> u32 { - let n = name - .strip_prefix("g_Texture") - .and_then(|rest| rest.parse::().ok()) - .unwrap_or(i); - match n { - 0 => 0, - n => 2 + n as u32, - } // 0→0, 1→3, 2→4, 3→5 - }; - - let mut out = String::new(); - out.push_str("#version 450\n"); - out.push_str("#define frac(x) fract(x)\n"); - out.push_str("#define saturate(x) clamp((x), 0.0, 1.0)\n"); - out.push_str("#define CAST2(x) vec2(x)\n"); - out.push_str("#define CAST3(x) vec3(x)\n"); - out.push_str("#define CAST4(x) vec4(x)\n"); - // Use separate texture2D + sampler for all texture sample macros so naga sees - // OpSampledImage (from constructor) rather than OpLoad of a combined sampler2D. - out.push_str("#define texSample2D(s, uv) texture(sampler2D(s, _wp_sampler), (uv).xy)\n"); - out.push_str( - "#define texSample2DLod(s, uv, lod) textureLod(sampler2D(s, _wp_sampler), (uv).xy, lod)\n", - ); - out.push_str("#define textureSample2D(s, uv) texture(sampler2D(s, _wp_sampler), (uv).xy)\n"); - out.push_str("#define texture2D(s, uv) texture(sampler2D(s, _wp_sampler), (uv).xy)\n"); - out.push_str( - "#define texture2DLod(s, uv, lod) textureLod(sampler2D(s, _wp_sampler), (uv).xy, lod)\n", - ); - out.push_str("#define textureCube(s, uv) texture(samplerCube(s, _wp_sampler), (uv).xyz)\n"); - out.push_str("#define lerp(a, b, t) mix(a, b, t)\n"); - out.push_str("#define mul(a, b) ((b) * (a))\n"); - out.push_str(&combo_defines(&model.effective_combos())); - // HLSL type aliases used pervasively in WE shaders. - // These must come BEFORE any inlined shader body (common.h etc. are inlined by the loader). - // We do NOT emit our WE_COMMON_H / WE_COMMON_BLENDING_H function bodies here because - // the loader already inlines the real WE common.h content into the source, and emitting - // our copies would cause "redefinition" errors in shaderc. - out.push_str("#define float2 vec2\n"); - out.push_str("#define float3 vec3\n"); - out.push_str("#define float4 vec4\n"); - out.push_str("#define int2 ivec2\n"); - out.push_str("#define int3 ivec3\n"); - out.push_str("#define int4 ivec4\n"); - out.push_str("#define CAST3X3(x) mat3(x)\n"); - out.push_str("#define atan2(y,x) atan(y,x)\n"); - out.push_str("#define fmod(x,y) ((x)-(y)*trunc((x)/(y)))\n"); - out.push_str("#define ddx dFdx\n"); - out.push_str("#define ddy(x) dFdy(-(x))\n"); - out.push_str("#define log10(x) (log2(x)*0.301029995663981)\n"); - // Constants sometimes needed by shaders that don't include common.h - out.push_str("#ifndef M_PI\n#define M_PI 3.14159265359\n#endif\n"); - out.push_str("#ifndef M_PI_2\n#define M_PI_2 6.28318530718\n#endif\n"); - - // HLSL's pow() broadcasts scalar arguments to the other side's vector - // width; strict GLSL only defines pow(genType, genType). Real workshop - // shaders (e.g. color_grading.frag's `pow(vec3, float)`) rely on the - // lenient form, so every body call site is renamed to these overloads — - // the same-shape ones are passthroughs, so legal calls are unaffected. - out.push_str(concat!( - "float _wp_pow(float a, float b) { return pow(a, b); }\n", - "vec2 _wp_pow(vec2 a, vec2 b) { return pow(a, b); }\n", - "vec3 _wp_pow(vec3 a, vec3 b) { return pow(a, b); }\n", - "vec4 _wp_pow(vec4 a, vec4 b) { return pow(a, b); }\n", - "vec2 _wp_pow(vec2 a, float b) { return pow(a, vec2(b)); }\n", - "vec3 _wp_pow(vec3 a, float b) { return pow(a, vec3(b)); }\n", - "vec4 _wp_pow(vec4 a, float b) { return pow(a, vec4(b)); }\n", - "vec2 _wp_pow(float a, vec2 b) { return pow(vec2(a), b); }\n", - "vec3 _wp_pow(float a, vec3 b) { return pow(vec3(a), b); }\n", - "vec4 _wp_pow(float a, vec4 b) { return pow(vec4(a), b); }\n", - )); - - // Separate texture2D declarations with matching bindings - for (i, name) in sampler_names.iter().enumerate() { - let b = tex_binding(i, name); - out.push_str(&format!( - "layout(set=0, binding={b}) uniform texture2D {name};\n" - )); - } - // Shared sampler at binding 1 - if !sampler_names.is_empty() { - out.push_str("layout(set=0, binding=1) uniform sampler _wp_sampler;\n"); - } - // UBO for scalars at group 1 binding 0 (matches effect_bgl). The block is - // the union of fragment + vertex scalars (fragment first) so both stages - // agree on std140 offsets. - if !scalars.is_empty() || !extra_scalars.is_empty() { - out.push_str("layout(set=1, binding=0) uniform WEUniforms {\n"); - for (ty, name) in scalars.iter().chain(extra_scalars.iter()) { - out.push_str(&format!(" {ty} {name};\n")); - } - out.push_str("};\n"); - } - // Audio-spectrum arrays (`g_AudioSpectrum{16,32,64}{Left,Right}`) become a - // live read-only STORAGE buffer (std430) at set=1 binding=1 filled from FFT - // each frame. The full canonical set is always declared in a fixed order - // (so element offsets are deterministic regardless of which arrays this - // shader happens to reference); the renderer always binds the buffer, and - // shaders that read none of it simply ignore the binding. - let uses_audio = zero_arrays - .iter() - .any(|(name, _)| name.contains("AudioSpectrum")); - if uses_audio { - // std430 storage: `float[N]` packs at 4-byte stride (naga-valid), - // unlike a std140 uniform member's WGSL-illegal 16-byte array stride. - out.push_str("layout(set=1, binding=1, std430) readonly buffer WEAudio {\n"); - for (name, n) in [ - ("g_AudioSpectrum16Left", 16), - ("g_AudioSpectrum16Right", 16), - ("g_AudioSpectrum32Left", 32), - ("g_AudioSpectrum32Right", 32), - ("g_AudioSpectrum64Left", 64), - ("g_AudioSpectrum64Right", 64), - ] { - out.push_str(&format!(" float {name}[{n}];\n")); - } - out.push_str("};\n"); - } - // Any remaining (non-audio) float-array uniforms stay zero-filled consts. - for (name, n) in &zero_arrays { - if name.contains("AudioSpectrum") { - continue; - } - let zeros = vec!["0.0"; *n].join(", "); - out.push_str(&format!("const float {name}[{n}] = float[{n}]({zeros});\n")); - } - // Input varyings with explicit locations - for (i, (ty, name)) in inputs.iter().enumerate() { - out.push_str(&format!("layout(location={i}) in {ty} {name};\n")); - } - out.push_str("layout(location=0) out vec4 fragColor;\n"); - - // Seed known vector widths (varyings + uniforms) for the truncation-coercion - // pass below; it grows this map with local declarations as it walks the body. - let mut var_width: HashMap = HashMap::new(); - for (ty, name) in inputs.iter().chain(scalars.iter()) { - if let Some(w) = vec_width(ty) { - var_width.insert(name.clone(), w); - } - } - - // Emit shader body (skip declarations already emitted above) - for line in src.lines() { - let t = line.trim_start(); - if t.starts_with("// [COMBO]") || t.starts_with("#version") || t.starts_with("precision ") { - continue; - } - let decl = t.split("//").next().unwrap_or(t).trim(); - let first = decl.split_whitespace().next().unwrap_or(""); - if first == "varying" || first == "attribute" || first == "uniform" { - continue; - } - - // Skip #include directives — included content is inlined in the header. - if t.starts_with("#include ") { - continue; - } - - // A shader that spells a prelude macro differently (M_PI as - // 3.1415926535897932384626433832795, say) is a hard glslang error, not - // a warning. Let the shader's own definition win, like HLSL does. - if let Some(name) = redefined_macro(t) { - out.push_str(&format!("#undef {name}\n")); - } - - // Replace #require LightingV1 with a no-op stub (same as linux-wallpaperengine). - if t.starts_with("#require LightingV1") { - out.push_str( - "vec3 PerformLighting_V1(vec3 worldPos, vec3 albedo, vec3 normal, vec3 viewDir,\n", - ); - out.push_str( - " vec3 specularTint, vec3 baseReflectance, float roughness, float metallic)\n", - ); - out.push_str("{ return vec3(0.0); }\n"); - continue; - } - - let coerced = coerce_vector_widths(line, &mut var_width); - let renamed = rename_reserved_word(&coerced, "sample", "_wp_s"); - // Route pow through the scalar-broadcasting overload shims above. - let renamed = rename_reserved_word(&renamed, "pow", "_wp_pow"); - let l = renamed - .replace("gl_FragColor", "fragColor") - .replace("gl_FragData[0]", "fragColor"); - out.push_str(&l); - out.push('\n'); - } - out -} - -/// Preprocess a real WE vertex shader into Vulkan GLSL 4.50. -/// -/// - Emits the SAME `WEUniforms` block as the fragment stage (union scalar -/// list, set=1 binding=0) so std140 offsets agree across stages. -/// - Assigns each varying the location of the same-named fragment input; -/// fragment inputs the VS never writes are declared and zero-initialized at -/// the top of `main()` (wgpu requires every FS input to have a VS output). -/// - Returns the processed source plus the attribute list (type, name) in -/// declaration order for vertex-buffer layout construction. -fn preprocess_vert_matched( - glsl: &str, - union_scalars: &[(String, String)], - frag_inputs: &[(String, String)], - combos: &HashMap, -) -> Result<(String, Vec<(String, String)>)> { - // Vertex shaders that sample textures need image bindings we don't - // provide in the vertex stage; let the synthetic fallback handle those. - if glsl.contains("texSample2D") || glsl.contains("texture2D") { - anyhow::bail!("vertex shader samples textures"); - } - - let mut out = String::new(); - out.push_str("#version 450\n"); - out.push_str("#define frac(x) fract(x)\n"); - out.push_str("#define saturate(x) clamp((x), 0.0, 1.0)\n"); - out.push_str("#define CAST2(x) vec2(x)\n"); - out.push_str("#define CAST3(x) vec3(x)\n"); - out.push_str("#define CAST4(x) vec4(x)\n"); - out.push_str("#define lerp(a, b, t) mix(a, b, t)\n"); - out.push_str("#define mul(a, b) ((b) * (a))\n"); - out.push_str(&combo_defines(combos)); - out.push_str("#define float2 vec2\n"); - out.push_str("#define float3 vec3\n"); - out.push_str("#define float4 vec4\n"); - out.push_str("#define int2 ivec2\n"); - out.push_str("#define int3 ivec3\n"); - out.push_str("#define int4 ivec4\n"); - out.push_str("#define CAST3X3(x) mat3(x)\n"); - out.push_str("#define atan2(y,x) atan(y,x)\n"); - out.push_str("#define fmod(x,y) ((x)-(y)*trunc((x)/(y)))\n"); - out.push_str("#define log10(x) (log2(x)*0.301029995663981)\n"); - out.push_str("#ifndef M_PI\n#define M_PI 3.14159265359\n#endif\n"); - out.push_str("#ifndef M_PI_2\n#define M_PI_2 6.28318530718\n#endif\n"); - - if !union_scalars.is_empty() { - out.push_str("layout(set=1, binding=0) uniform WEUniforms {\n"); - for (ty, name) in union_scalars { - out.push_str(&format!(" {ty} {name};\n")); - } - out.push_str("};\n"); - } - - // Fragment-input locations by name; varyings must line up with them. - let frag_loc: HashMap<&str, usize> = frag_inputs - .iter() - .enumerate() - .map(|(i, (_, name))| (name.as_str(), i)) - .collect(); - let mut next_free_loc = frag_inputs.len(); - - let mut attributes: Vec<(String, String)> = Vec::new(); - let mut declared_varyings: Vec = Vec::new(); - let mut body = String::new(); - - for line in glsl.lines() { - let t = line.trim_start(); - if t.starts_with("#version") - || t.starts_with("precision ") - || t.starts_with("// [COMBO]") - || t.starts_with("#include ") - { - continue; - } - // Shader-local redefinition of a prelude macro wins (see frag stage). - if let Some(name) = redefined_macro(t) { - body.push_str(&format!("#undef {name}\n")); - } - let decl = t.split("//").next().unwrap_or(t).trim(); - let tok: Vec<&str> = decl.split_whitespace().collect(); - if tok.len() >= 3 && tok[0] == "attribute" { - let ty = tok[1].to_string(); - let name = tok[2].trim_end_matches(';').to_string(); - out.push_str(&format!( - "layout(location={}) in {ty} {name};\n", - attributes.len() - )); - attributes.push((ty, name)); - continue; - } - if tok.len() >= 3 && tok[0] == "varying" { - let ty = tok[1].to_string(); - let name = tok[2].trim_end_matches(';').to_string(); - if name.contains('[') { - anyhow::bail!("array varying '{name}' not supported"); - } - let loc = frag_loc.get(name.as_str()).copied().unwrap_or_else(|| { - let l = next_free_loc; - next_free_loc += 1; - l - }); - out.push_str(&format!("layout(location={loc}) out {ty} {name};\n")); - declared_varyings.push(name); - continue; - } - if tok.first() == Some(&"uniform") { - continue; // already in the shared UBO - } - body.push_str(line); - body.push('\n'); - } - - // Fragment inputs the vertex shader never declares: emit + zero-init so - // the wgpu interface check passes. - let mut missing_inits = String::new(); - for (ty, name) in frag_inputs { - if !declared_varyings.iter().any(|v| v == name) { - out.push_str(&format!( - "layout(location={}) out {ty} {name};\n", - frag_loc[name.as_str()] - )); - missing_inits.push_str(&format!(" {name} = {ty}(0.0);\n")); - } - } - if !missing_inits.is_empty() { - // Insert right after the opening brace of main(). - let main_pos = body - .find("void main") - .context("vertex shader has no main()")?; - let brace = body[main_pos..] - .find('{') - .map(|i| main_pos + i + 1) - .context("vertex shader main() has no body")?; - body.insert_str(brace, &format!("\n{missing_inits}")); - } - - out.push_str(&body); - Ok((out, attributes)) -} - -// ── Synthetic vertex shader helpers ────────────────────────────────────────── - -/// Extract (type, name) pairs from fragment `varying` declarations (array varyings excluded). -fn frag_inputs(frag_glsl: &str) -> Vec<(String, String)> { - let mut inputs = Vec::new(); - for line in frag_glsl.lines() { - let t = line.trim(); - if !t.starts_with("varying ") && !t.starts_with("attribute ") { - continue; - } - let tok: Vec<&str> = t.split_whitespace().collect(); - if tok.len() < 3 { - continue; - } - let name_raw = t - .split("//") - .next() - .unwrap_or(t) - .split_whitespace() - .nth(2) - .unwrap_or("") - .trim_end_matches(';'); - if name_raw.contains('[') { - continue; - } // array varyings already rejected upstream - inputs.push((tok[1].to_string(), name_raw.to_string())); - } - inputs -} - -/// Return true when the fragment shader's varyings differ from the vs_we_effect interface -/// (vec4 v_TexCoord @ loc 0, vec2 v_Scroll @ loc 1). A mismatch causes a wgpu panic. -fn needs_custom_vs(inputs: &[(String, String)]) -> bool { - match inputs { - [] => false, - [(t0, _)] => t0 != "vec4", - [(t0, _), (t1, _)] => t0 != "vec4" || t1 != "vec2", - _ => true, // more than 2 varyings → vs_we_effect definitely can't match - } -} - -/// Build a Vulkan GLSL 4.50 vertex shader that outputs every declared varying as a -/// simple UV-derived value. No uniforms — this shader works with any bind group layout. -fn synthetic_vs_glsl(inputs: &[(String, String)]) -> String { - let mut out = String::new(); - out.push_str("#version 450\n"); - for (i, (ty, name)) in inputs.iter().enumerate() { - out.push_str(&format!("layout(location={i}) out {ty} {name};\n")); - } - out.push_str("void main() {\n"); - out.push_str(" int vi = gl_VertexIndex;\n"); - out.push_str(" float x = float(vi & 1) * 4.0 - 1.0;\n"); - out.push_str(" float y = float(vi >> 1) * 4.0 - 1.0;\n"); - out.push_str(" gl_Position = vec4(x, y, 0.0, 1.0);\n"); - out.push_str(" float u = (x + 1.0) * 0.5;\n"); - // naga's SPIR-V frontend runs with adjust_coordinate_space=true, which - // negates gl_Position.y. UVs must be derived from the POST-flip screen - // position, so v maps y=-1 (screen top after flip) → 0. - out.push_str(" float v = (y + 1.0) * 0.5;\n"); - for (ty, name) in inputs { - let val = match ty.as_str() { - "float" => "u".to_string(), - "vec2" => "vec2(u, v)".to_string(), - "vec3" => "vec3(u, v, 0.0)".to_string(), - "vec4" => "vec4(u, v, u, v)".to_string(), - _ => format!("{ty}(0.0)"), - }; - out.push_str(&format!(" {name} = {val};\n")); - } - out.push_str("}\n"); - out -} - -pub fn combo_defines(combos: &HashMap) -> String { - combos - .iter() - .map(|(name, value)| format!("#define {} {}\n", name, value)) - .collect() -} - -// Replace a reserved keyword with `replacement` only at word boundaries (i.e., not when -// adjacent to alphanumeric chars or underscore). Used to rename `sample` → `_wp_s`. -fn rename_reserved_word(src: &str, keyword: &str, replacement: &str) -> String { - let klen = keyword.len(); - let sbytes = src.as_bytes(); - let kbytes = keyword.as_bytes(); - let mut out = String::with_capacity(src.len()); - let mut i = 0usize; - while i < sbytes.len() { - if sbytes[i..].starts_with(kbytes) { - let before_ok = i == 0 || !is_word_char(sbytes[i - 1]); - let after_ok = i + klen >= sbytes.len() || !is_word_char(sbytes[i + klen]); - if before_ok && after_ok { - out.push_str(replacement); - i += klen; - continue; - } - } - out.push(sbytes[i] as char); - i += 1; - } - out -} - -#[inline] -fn is_word_char(b: u8) -> bool { - b.is_ascii_alphanumeric() || b == b'_' -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Research check (not permanent coverage — depends on an untracked, - /// locally-downloaded Workshop sample that won't exist in CI): does the - /// real "godrays" effect's GLSL, exactly as authored by Valve/WE and - /// shipped in a real wallpaper, transpile through the *fully generic* - /// effect pipeline with zero wp-engine-specific code? See - /// ~/Applications/ghidra-dump/REPORT.md's volumetric-lighting follow-up - /// for why this matters: godrays is what real content actually uses for - /// light shafts, not the Light-object-driven `castvolumetrics` system. - #[test] - fn real_godrays_effect_transpiles_through_generic_pipeline() { - let wallpaper_dir = - std::path::Path::new("/home/tupretiw/wp-engine/analysis/orig_1137423685"); - if !wallpaper_dir.exists() { - eprintln!("SKIP: analysis/ sample not present locally"); - return; - } - // No real WE assets dir in this sandbox — forces the embedded - // WE_COMMON_H/WE_COMMON_BLENDING_H fallback in loader.rs, exactly - // the path a Linux/macOS install without the Steam client hits too. - let resolver = super::super::resolver::AssetResolver::new(Some(wallpaper_dir), None); - - let cases: &[(&str, &str, &[(&str, i32)])] = &[ - ( - "effects/godrays_downsample2", - "effects/godrays_downsample2", - &[], - ), - ( - "effects/godrays_cast", - "effects/godrays_cast", - &[("CASTER", 0), ("SAMPLES", 0)], - ), - ( - "effects/godrays_gaussian_x", - "effects/godrays_gaussian", - &[("KERNEL", 1), ("VERTICAL", 0)], - ), - ( - "effects/godrays_gaussian_y", - "effects/godrays_gaussian", - &[("KERNEL", 1), ("VERTICAL", 1)], - ), - ( - "effects/godrays_combine", - "effects/godrays_combine", - &[("BLENDMODE", 9)], - ), - ]; - - let mut failures = Vec::new(); - for (label, shader_name, combo_pairs) in cases { - let (frag_glsl, vert_glsl) = match super::super::loader::load_glsl_shader_with_resolver( - &resolver, - shader_name, - None, - ) { - Ok(pair) => pair, - Err(e) => { - failures.push(format!("{label}: load failed: {e:#}")); - continue; - } - }; - let combos: std::collections::HashMap = combo_pairs - .iter() - .map(|(k, v)| (k.to_string(), *v)) - .collect(); - let model = crate::engine::model::ShaderModel::from_resolved_glsl( - label.to_string(), - frag_glsl, - combos, - crate::engine::model::WEBlending::Normal, - ); - if let Err(e) = translate_full(&model, Some(&vert_glsl)) { - failures.push(format!("{label}: {e:#}")); - } - } - assert!( - failures.is_empty(), - "godrays passes failed to transpile:\n{}", - failures.join("\n") - ); - } - - #[test] - fn unrolls_literal_indexed_array_varying() { - let src = "varying vec2 v_TexCoord[4];\nvarying vec2 v_TexCoordBase;\n\ - void main() {\n\ - \tv_TexCoord[0] = a;\n\tv_TexCoord[1] = b;\n\ - \tv_TexCoord[2] = c;\n\tv_TexCoord[3] = d;\n}\n"; - let out = unroll_array_varyings(src); - assert!(out.contains("varying vec2 v_TexCoord_0;")); - assert!(out.contains("varying vec2 v_TexCoord_1;")); - assert!(out.contains("varying vec2 v_TexCoord_2;")); - assert!(out.contains("varying vec2 v_TexCoord_3;")); - assert!(out.contains("varying vec2 v_TexCoordBase;")); - assert!(!out.contains('[')); - assert!(out.contains("v_TexCoord_0 = a;")); - assert!(out.contains("v_TexCoord_3 = d;")); - } - - #[test] - fn leaves_source_without_array_varyings_untouched() { - let src = "varying vec2 v_TexCoord;\nvoid main() { gl_FragColor = vec4(1.0); }\n"; - assert_eq!(unroll_array_varyings(src), src); - } - - #[test] - fn leaves_dynamically_sized_arrays_alone() { - let src = "varying vec2 v_TexCoord[LIGHTS_POINT];\n"; - let out = unroll_array_varyings(src); - assert_eq!(out, src); - } - - /// blur_downsample4.frag: a *fixed*-size array (`[4]`) but indexed by a - /// loop variable (`v_TexCoord[i]`), not literals. Unrolling only the - /// declaration here previously left `v_TexCoord[i]` referring to a name - /// that no longer existed ("undeclared identifier"), instead of leaving - /// the whole thing untouched for the honest "array varying not - /// supported" bail-out. - #[test] - fn leaves_loop_indexed_fixed_size_arrays_alone() { - let src = "varying vec2 v_TexCoord[4];\n\ - void main() {\n\ - \tfor (int i = 0; i < 4; ++i) {\n\ - \t\tvec4 s = texSample2D(g_Texture0, v_TexCoord[i]);\n\ - \t}\n}\n"; - let out = unroll_array_varyings(src); - assert_eq!(out, src); - } - - #[test] - fn unroll_simple_for_loops_expands_fixed_bound_loop() { - let src = "void main() {\n\tfor (int i = 0; i < 3; ++i) {\n\t\tx += arr[i];\n\t}\n}\n"; - let out = unroll_simple_for_loops(src); - assert!(!out.contains("for (")); - assert!(out.contains("x += arr[0];")); - assert!(out.contains("x += arr[1];")); - assert!(out.contains("x += arr[2];")); - assert!(!out.contains("arr[3]")); - } - - #[test] - fn unroll_simple_for_loops_handles_post_increment_and_less_equal() { - let src = "for (int i = 0; i <= 2; i++) { y += a[i]; }"; - let out = unroll_simple_for_loops(src); - assert!(out.contains("y += a[0];")); - assert!(out.contains("y += a[1];")); - assert!(out.contains("y += a[2];")); - assert!(!out.contains("a[3]")); - } - - #[test] - fn unroll_simple_for_loops_leaves_non_zero_start_untouched() { - let src = "for (int i = 1; i < 4; ++i) { x += arr[i]; }"; - assert_eq!(unroll_simple_for_loops(src), src); - } - - #[test] - fn unroll_simple_for_loops_leaves_non_literal_bound_untouched() { - let src = "for (int i = 0; i < N; ++i) { x += arr[i]; }"; - assert_eq!(unroll_simple_for_loops(src), src); - } - - #[test] - fn unroll_simple_for_loops_leaves_mismatched_loop_var_untouched() { - let src = "for (int i = 0; j < 4; ++i) { x += arr[i]; }"; - assert_eq!(unroll_simple_for_loops(src), src); - } - - #[test] - fn unroll_simple_for_loops_ignores_braces_inside_line_comments() { - let src = "for (int i = 0; i < 2; ++i) {\n\t// a stray brace: }\n\tx += arr[i];\n}\n"; - let out = unroll_simple_for_loops(src); - assert!(!out.contains("for (")); - assert!(out.contains("x += arr[0];")); - assert!(out.contains("x += arr[1];")); - } - - /// The exact real-world shape from blur_downsample4.frag: loop-unroll - /// then array-varying-unroll together must fully eliminate the array. - #[test] - fn loop_then_array_unroll_fully_resolves_real_world_pattern() { - let src = "varying vec2 v_TexCoord[4];\n\ - uniform sampler2D g_Texture0;\n\ - void main() {\n\ - \tfloat weight = 0.0;\n\ - \tvec4 result = CAST4(0.0);\n\ - \tfor (int i = 0; i < 4; ++i)\n\ - \t{\n\ - \t\tvec4 sample = texSample2D(g_Texture0, v_TexCoord[i]);\n\ - \t\tresult += sample * sample.a;\n\ - \t\tweight += sample.a;\n\ - \t}\n\ - \tgl_FragColor.rgb = result.rgb / max(0.001, weight);\n\ - }\n"; - let loop_unrolled = unroll_simple_for_loops(src); - let fully_unrolled = unroll_array_varyings(&loop_unrolled); - assert!(!fully_unrolled.contains('[')); - assert!(fully_unrolled.contains("varying vec2 v_TexCoord_0;")); - assert!(fully_unrolled.contains("varying vec2 v_TexCoord_3;")); - assert!(fully_unrolled.contains("v_TexCoord_0")); - assert!(fully_unrolled.contains("v_TexCoord_3")); - } - - /// The exact real-world case (nitro.frag): `max(0, albedo.rgb)` is wrong - /// two ways — GLSL only defines `genType max(genType, float)` (vector - /// first), and the literal must be a genuine float. Both need fixing. - #[test] - fn coerce_int_literal_fixes_bare_integer_and_swaps_order_in_max_call() { - let src = "gl_FragColor = vec4(max(0, albedo.rgb), albedo.a);"; - let out = coerce_int_literal_builtin_args(src); - assert_eq!(out, "gl_FragColor = vec4(max(albedo.rgb, 0.0), albedo.a);"); - } - - #[test] - fn coerce_int_literal_handles_min_and_clamp_and_negative() { - assert_eq!(coerce_int_literal_builtin_args("min(0, x)"), "min(x, 0.0)"); - assert_eq!( - coerce_int_literal_builtin_args("clamp(x, 0, 1)"), - "clamp(x, 0.0, 1.0)" - ); - assert_eq!( - coerce_int_literal_builtin_args("max(-1, x)"), - "max(x, -1.0)" - ); - } - - /// A literal that's already a float still needs reordering if it's in - /// the (invalid) first position — order matters independently of type. - /// `clamp`'s args are never reordered (not commutative), and calls that - /// aren't max/min/clamp at all are left alone entirely. - #[test] - fn coerce_int_literal_reorders_already_float_literal_and_ignores_other_calls() { - assert_eq!( - coerce_int_literal_builtin_args("max(0.0, x)"), - "max(x, 0.0)" - ); - assert_eq!( - coerce_int_literal_builtin_args("clamp(y, a, b)"), - "clamp(y, a, b)" - ); - assert_eq!(coerce_int_literal_builtin_args("foo(0, 1)"), "foo(0, 1)"); - } - - #[test] - fn coerce_int_literal_leaves_correctly_ordered_call_untouched() { - assert_eq!( - coerce_int_literal_builtin_args("max(albedo.rgb, 0.0)"), - "max(albedo.rgb, 0.0)" - ); - } - - #[test] - fn coerce_int_literal_handles_nested_calls() { - let src = "max(0, texture(tex, uv).rgb)"; - assert_eq!( - coerce_int_literal_builtin_args(src), - "max(texture(tex, uv).rgb, 0.0)" - ); - } - - #[test] - fn coerce_swizzle_mix_fixes_real_shift_hue_pattern() { - let src = "albedo.rgb = mix(albedo, newAlbedo, mask);"; - assert_eq!( - coerce_swizzle_mismatched_mix_arg(src), - "albedo.rgb = mix(albedo.rgb, newAlbedo, mask);" - ); - } - - #[test] - fn coerce_swizzle_mix_leaves_already_matching_types_untouched() { - let src = "color.xyz = mix(color.xyz, other, t);"; - assert_eq!(coerce_swizzle_mismatched_mix_arg(src), src); - } - - #[test] - fn coerce_swizzle_mix_ignores_unrelated_mix_calls() { - let src = "vec3 c = mix(a, b, t);"; - assert_eq!(coerce_swizzle_mismatched_mix_arg(src), src); - } - - #[test] - fn coerce_swizzle_mix_ignores_comparison_not_assignment() { - let src = "if (foo.rgb == mix(foo, bar, t)) { }"; - assert_eq!(coerce_swizzle_mismatched_mix_arg(src), src); - } - - #[test] - fn coerce_swizzle_mix_ignores_different_base_identifier() { - let src = "albedo.rgb = mix(other, newAlbedo, mask);"; - assert_eq!(coerce_swizzle_mismatched_mix_arg(src), src); - } - - /// godrays_gaussian.frag declares the same varying at three different - /// sizes under mutually-exclusive `#if KERNEL==N` branches; only one - /// (max-sized) declaration block should survive, with no duplicates. - #[test] - fn merges_same_name_declared_at_multiple_sizes_across_if_branches() { - let src = "#if KERNEL == 0\nvarying vec2 v_TexCoord[13];\n#endif\n\ - #if KERNEL == 1\nvarying vec2 v_TexCoord[7];\n#endif\n\ - #if KERNEL == 2\nvarying vec2 v_TexCoord[3];\n#endif\n\ - void main() {\n\ - #if KERNEL == 2\n\tvec2 a = v_TexCoord[0] + v_TexCoord[2];\n#endif\n}\n"; - let out = unroll_array_varyings(src); - assert_eq!(out.matches("varying vec2 v_TexCoord_0;").count(), 1); - assert_eq!(out.matches("varying vec2 v_TexCoord_12;").count(), 1); - assert!(!out.contains("v_TexCoord_13")); - assert!(out.contains("v_TexCoord_0 + v_TexCoord_2")); - assert!(!out.contains('[')); - } - - #[test] - fn shadowed_builtin_definitions_are_renamed_but_calls_are_not() { - // crt_scan_line.frag defines its own mod(); glslang rejects the - // precision-qualifier clash with the builtin. - let src = "float mod(float x, float y) {\n return x - y * floor(x/y);\n}\nfloat f() { return mod(a, b); }\n"; - let out = rename_shadowed_builtins(src); - assert!(out.contains("float _wp_mod(float x"), "{out}"); - assert!(out.contains("return _wp_mod(a, b)"), "{out}"); - // A shader that only CALLS frac keeps the macro name. - let call_only = "float f() {\n return frac(x);\n}\n"; - assert_eq!(rename_shadowed_builtins(call_only), call_only); - } - - #[test] - fn coerce_leaves_call_arguments_and_splats_scalar_swizzles() { - let mut w = HashMap::new(); - w.insert("v_TexCoord".to_string(), 4u8); - // contrast_based_sharpness: get_noise(vec4) must keep its vec4 argument. - let out = coerce_vector_widths("vec3 noise_color = get_noise(v_TexCoord);", &mut w); - assert!(!out.contains("v_TexCoord."), "{out}"); - // lens_flare_sun: `.xyz` on a float is a splat in WE's compiler. - let mut w2 = HashMap::new(); - let _ = coerce_vector_widths("float timer = sin(g_Time);", &mut w2); - let out2 = coerce_vector_widths("vec3 c = rotation + timer.xyz;", &mut w2); - assert!(out2.contains("vec3(timer)"), "{out2}"); - } - - #[test] - fn coerce_truncates_wide_varying_inside_narrow_declaration() { - // clipping_mask.frag: vec4 varying driving vec2 math (HLSL truncates). - let mut w = HashMap::new(); - w.insert("v_TexCoord".to_string(), 4u8); - let out = coerce_vector_widths( - "vec2 uvTex = ((v_TexCoord * 2.0 - 1.0 - texScaleCenter) / u_scale + 1.0) / 2.0;", - &mut w, - ); - assert!(out.contains("v_TexCoord.xy"), "{out}"); - // Already-swizzled and unknown identifiers stay untouched. - let out2 = coerce_vector_widths("vec2 b = v_TexCoord.zw + something;", &mut w); - assert!(!out2.contains(".zw.xy"), "{out2}"); - } - - #[test] - fn coerce_truncates_unswizzled_texture_sample_to_declared_width() { - let mut vw = HashMap::new(); - let out = coerce_vector_widths( - "vec3 albedo = texSample2D(g_Texture0, v_TexCoord.xy);", - &mut vw, - ); - assert_eq!( - out, - "vec3 albedo = texSample2D(g_Texture0, v_TexCoord.xy).xyz;" - ); - assert_eq!(vw.get("albedo"), Some(&3)); - } - - #[test] - fn coerce_truncates_plain_reassignment_using_tracked_width() { - let mut vw = HashMap::new(); - vw.insert("albedo".to_string(), 3u8); - let out = coerce_vector_widths("albedo = texSample2D(g_Texture0, uv);", &mut vw); - assert_eq!(out, "albedo = texSample2D(g_Texture0, uv).xyz;"); - } - - #[test] - fn coerce_truncates_wide_varying_against_cast_broadcast() { - let mut vw = HashMap::new(); - vw.insert("v_TexCoord".to_string(), 3u8); - let out = coerce_vector_widths( - "float scale = pow(length(abs(v_TexCoord - CAST2(u_offset)) * 1.0), 3.0);", - &mut vw, - ); - assert_eq!( - out, - "float scale = pow(length(abs(v_TexCoord.xy - CAST2(u_offset)) * 1.0), 3.0);" - ); - } - - #[test] - fn coerce_leaves_matching_widths_untouched() { - let mut vw = HashMap::new(); - vw.insert("v_TexCoord".to_string(), 2u8); - let line = "float scale = length(v_TexCoord - CAST2(u_offset));"; - assert_eq!(coerce_vector_widths(line, &mut vw), line); - } - - #[test] - fn coerce_leaves_swizzled_texture_sample_untouched() { - let mut vw = HashMap::new(); - let line = "vec3 albedo = texSample2D(g_Texture0, uv).rgb;"; - assert_eq!(coerce_vector_widths(line, &mut vw), line); - } - - #[test] - fn coerce_is_idempotent_on_already_truncated_operand() { - let mut vw = HashMap::new(); - vw.insert("v_TexCoord".to_string(), 3u8); - let once = coerce_vector_widths( - "float scale = length(v_TexCoord - CAST2(u_offset));", - &mut vw, - ); - let twice = coerce_vector_widths(&once, &mut vw); - assert_eq!(once, twice); - } - - /// The reference's applyLinkedVaryingCompatibility: a vertex-side - /// `varying vec2` matching a fragment-side `varying vec4` gets upgraded, - /// with whole-variable assignments wrapped in vec4(expr, 0.0, 1.0); - /// swizzled stores and unrelated varyings stay untouched. - #[test] - fn harmonize_upgrades_mismatched_varying_and_assignments() { - let vert = "varying vec2 v_TexCoord;\nvarying vec2 v_Other;\nvoid main() {\n\tv_TexCoord = a_TexCoord * 2.0;\n\tv_TexCoord.xy = a_TexCoord;\n\tv_Other = a_TexCoord;\n}\n"; - let frag = "varying vec4 v_TexCoord;\nvarying vec2 v_Other;\nvoid main() { gl_FragColor = v_TexCoord; }\n"; - let out = harmonize_varying_widths(vert, frag); - assert!(out.contains("varying vec4 v_TexCoord;")); - assert!(out.contains("v_TexCoord = vec4(a_TexCoord * 2.0, 0.0, 1.0);")); - assert!( - out.contains("v_TexCoord.xy = a_TexCoord;"), - "swizzled store untouched" - ); - assert!( - out.contains("varying vec2 v_Other;"), - "matching widths untouched" - ); - assert!(out.contains("v_Other = a_TexCoord;")); - } - - #[test] - fn harmonize_leaves_matched_interfaces_alone() { - let vert = "varying vec2 v_TexCoord;\nvoid main() { v_TexCoord = a_TexCoord; }\n"; - let frag = "varying vec2 v_TexCoord;\nvoid main() { gl_FragColor = vec4(v_TexCoord, 0.0, 1.0); }\n"; - assert_eq!(harmonize_varying_widths(vert, frag), vert); - } - - #[test] - fn wrap_assignment_rhs_handles_for_loop_init() { - // The RHS must stop at the for-init's own `;`, and `<=` must not be - // mistaken for an assignment (blur_gaussian.frag's exact shape). - let line = "for (int i = -iterations; i <= iterations; i++)"; - let out = wrap_assignment_rhs(line, "int(", ")").unwrap(); - assert_eq!(out, "for (int i = int(-iterations); i <= iterations; i++)"); - } - - #[test] - fn wrap_assignment_rhs_skips_comparisons_but_wraps_compound_assign() { - // Comparisons are never assignment targets. - assert!(wrap_assignment_rhs("if (a == b) return;", "(", ").x").is_none()); - // Compound assignments ARE targets: `int x *= floatExpr` needs the RHS - // coerced just like a plain assignment (simple_audio_bars' `bar *= step`). - assert_eq!( - wrap_assignment_rhs("\tbarLeft *= step(a, b);", "int(", ")").as_deref(), - Some("\tbarLeft *= int(step(a, b));") - ); - } - - #[test] - fn repair_patches_lines_named_in_shaderc_errors() { - let glsl = "float pointer = g_PointerPosition.xy * u_pointerSpeed;\n\ - int i = -iterations;\n\ - vec3 c = 0.5;"; - let errs = "shader.frag:1: error: '=' : cannot convert from ' temp highp 2-component vector of float' to ' temp highp float'\n\ - shader.frag:2: error: '=' : cannot convert from ' temp highp float' to ' temp highp int'\n\ - shader.frag:3: error: '=' : cannot convert from ' const float' to ' temp highp 3-component vector of float'"; - let out = repair_assignment_conversions(glsl, errs).expect("should repair"); - assert!(out.contains("float pointer = (g_PointerPosition.xy * u_pointerSpeed).x;")); - assert!(out.contains("int i = int(-iterations);")); - assert!(out.contains("vec3 c = vec3(0.5);")); - } - - /// End-to-end: a shader with the HLSL-leniency patterns from real - /// workshop content (lens_flare_sun's vec2→float, blur_gaussian's - /// float→int loop bound) must compile after the repair loop. - #[test] - fn repairs_implicit_conversions_through_real_compile() { - let glsl = "#version 450\n\ - layout(location=0) out vec4 fragColor;\n\ - void main() {\n\ - \tvec2 v = vec2(1.5, 2.5);\n\ - \tfloat pointer = v * 2.0;\n\ - \tfloat iterations = 3.0;\n\ - \tfloat acc = 0.0;\n\ - \tfor (int i = -iterations; i <= iterations; i++) { acc += float(i) + pointer; }\n\ - \tfragColor = vec4(acc);\n\ - }\n"; - assert!(glsl_to_spirv(glsl, naga::ShaderStage::Fragment).is_err()); - glsl_to_spirv_with_repairs(glsl, naga::ShaderStage::Fragment) - .expect("repair loop should make this compile"); - } - - /// depth_map.frag branches on a float uniform; HLSL calls nonzero true. - #[test] - fn bool_condition_repair_wraps_float_conditions() { - let glsl = "#version 450\n\ - layout(location=0) out vec4 fragColor;\n\ - void main() {\n\ - \tfloat flag = 1.0;\n\ - \tfloat d = 0.25;\n\ - \tif(flag){ d = 1.0 - d; }\n\ - \tfragColor = vec4(d);\n\ - }\n"; - assert!(glsl_to_spirv(glsl, naga::ShaderStage::Fragment).is_err()); - glsl_to_spirv_with_repairs(glsl, naga::ShaderStage::Fragment) - .expect("repair loop should make this compile"); - } - - /// `pow(vec3, float)` (color_grading.frag's shape) is illegal in strict - /// GLSL; the `_wp_pow` overload shims must let it translate. - #[test] - fn pow_scalar_broadcast_translates() { - let src = "uniform sampler2D g_Texture0;\n\ - varying vec2 v_TexCoord;\n\ - void main() {\n\ - \tvec3 c = pow(texSample2D(g_Texture0, v_TexCoord).rgb, 2.2);\n\ - \tgl_FragColor = vec4(c, 1.0);\n\ - }\n"; - let model = ShaderModel::from_resolved_glsl( - "pow_test".to_string(), - src.to_string(), - HashMap::new(), - crate::engine::model::shader_model::WEBlending::Normal, - ); - translate(&model).expect("pow(vec3, float) should translate via _wp_pow shims"); - } - - /// The embedded common_perspective.h body (squareToQuad) must be valid - /// GLSL: a vertex shader shaped like the `perspective` effect's vert — - /// `inverse(squareToQuad(p0..p3)) * vec3(uv,1)` — compiles with it inlined. - /// Without the body squareToQuad is undefined and this fails, which is the - /// exact macOS case the embedded-header fallback repairs. - #[test] - fn embedded_perspective_header_compiles() { - let glsl = format!( - "#version 450\n\ - layout(location=0) in vec3 a_Position;\n\ - layout(location=1) in vec2 a_TexCoord;\n\ - layout(location=0) out vec3 v_TexCoord;\n\ - {WE_COMMON_PERSPECTIVE_H}\n\ - void main() {{\n\ - \tmat3 xform = inverse(squareToQuad(vec2(0.0), vec2(1.0, 0.0), vec2(1.0), vec2(0.0, 1.0)));\n\ - \tv_TexCoord = xform * vec3(a_TexCoord, 1.0);\n\ - \tgl_Position = vec4(a_Position, 1.0);\n\ - }}\n" - ); - glsl_to_spirv_with_repairs(&glsl, naga::ShaderStage::Vertex) - .expect("perspective vert with embedded squareToQuad should compile"); - } - - #[test] - fn int_assigned_float_step_repairs() { - let glsl = "#version 450\n\ - layout(location=0) out vec4 fragColor;\n\ - void main() {\n\ - \tfloat a = 0.3; float b = 0.7;\n\ - \tint barLeft = step(a, b);\n\ - \tfragColor = vec4(float(barLeft));\n\ - }\n"; - assert!(glsl_to_spirv(glsl, naga::ShaderStage::Fragment).is_err()); - glsl_to_spirv_with_repairs(glsl, naga::ShaderStage::Fragment) - .expect("int = step(float) should repair via int() wrap"); - } - - #[test] - fn rewrite_modulo_handles_real_audio_bar_patterns() { - // simple_audio_bars.frag's two shapes. - assert_eq!( - rewrite_modulo_on_line("\tuint barFreq1 = frequency % RESOLUTION;").as_deref(), - Some("\tuint barFreq1 = mod(frequency, RESOLUTION);") - ); - assert_eq!( - rewrite_modulo_on_line("\tuint barFreq2 = (barFreq1 + 1) % RESOLUTION;").as_deref(), - Some("\tuint barFreq2 = mod((barFreq1 + 1), RESOLUTION);") - ); - } - - #[test] - fn rewrite_modulo_leaves_compound_assign_and_plain_lines() { - assert_eq!(rewrite_modulo_on_line("\tx %= 4;"), None); - assert_eq!(rewrite_modulo_on_line("\tfloat y = a + b;"), None); - } - - /// End-to-end: a `float % int` shader (int-only `%` in strict GLSL) must - /// compile through the repair loop, mirroring simple_audio_bars. - #[test] - fn float_modulo_repairs_through_real_compile() { - let glsl = "#version 450\n\ - layout(location=0) out vec4 fragColor;\n\ - void main() {\n\ - \tfloat frequency = 6.5;\n\ - \tuint bar = frequency % 4;\n\ - \tfragColor = vec4(float(bar));\n\ - }\n"; - assert!(glsl_to_spirv(glsl, naga::ShaderStage::Fragment).is_err()); - glsl_to_spirv_with_repairs(glsl, naga::ShaderStage::Fragment) - .expect("float % int + float->uint assign should repair"); - } - - /// A shader reading `g_AudioSpectrum64Left` must translate to WGSL with the - /// array backed by a live uniform binding (not a zero `const`), and the - /// result must flag `uses_audio` so the renderer starts capture. - #[test] - fn audio_spectrum_becomes_live_uniform() { - let src = "uniform sampler2D g_Texture0;\n\ - uniform float g_AudioSpectrum64Left[64];\n\ - varying vec2 v_TexCoord;\n\ - void main() {\n\ - \tint i = int(v_TexCoord.x * 64.0);\n\ - \tfloat amp = g_AudioSpectrum64Left[i];\n\ - \tgl_FragColor = vec4(amp, amp, amp, 1.0);\n\ - }\n"; - let model = ShaderModel::from_resolved_glsl( - "audio_test".to_string(), - src.to_string(), - HashMap::new(), - crate::engine::model::shader_model::WEBlending::Normal, - ); - let t = translate(&model).expect("audio spectrum shader should translate"); - assert!( - t.uses_audio, - "uses_audio must be set for g_AudioSpectrum reads" - ); - // The array is now a bound uniform member, so the emitted WGSL must not - // contain a zero-const fallback for it. - assert!( - !t.wgsl.contains("array(0.0"), - "audio array must not be a zero const:\n{}", - t.wgsl - ); - } -} diff --git a/src/engine/camera3d.rs b/src/engine/sim/camera3d.rs similarity index 98% rename from src/engine/camera3d.rs rename to src/engine/sim/camera3d.rs index 341e883..2b2c306 100644 --- a/src/engine/camera3d.rs +++ b/src/engine/sim/camera3d.rs @@ -7,7 +7,7 @@ //! particle-space projections in CParticle.cpp) so image layers placed at 3D //! origins can be projected to screen space. -use crate::engine::scene::Scene; +use crate::engine::model::scene::Scene; /// Column-major 4x4, `m[col][row]` — glm's layout, so it uploads to a WGSL /// `mat4x4` as-is. @@ -48,7 +48,7 @@ fn normalize(v: [f32; 3]) -> [f32; 3] { } /// Right-handed lookAt, identical to `glm::lookAt`. `pub(crate)` so -/// `engine::shadow` can build light-space view matrices with the same math. +/// `engine::gpu::shadow` can build light-space view matrices with the same math. pub(crate) fn look_at(eye: [f32; 3], center: [f32; 3], up: [f32; 3]) -> Mat4 { let f = normalize(sub(center, eye)); let s = normalize(cross(f, up)); @@ -95,16 +95,16 @@ impl PerspectiveCamera { let g = scene.general.as_ref(); let fov_deg = g .and_then(|g| g.fov.as_ref()) - .and_then(crate::engine::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .unwrap_or(50.0); let near = g .and_then(|g| g.nearz.as_ref()) - .and_then(crate::engine::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .unwrap_or(0.1) .max(1e-4); let far = g .and_then(|g| g.farz.as_ref()) - .and_then(crate::engine::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .unwrap_or(10000.0) .max(near * 2.0); @@ -223,7 +223,7 @@ impl PerspectiveCamera { /// Remaps GL's clip-space z range ([-1,1]) to wgpu's ([0,1]) by /// premultiplying with the standard `diag(1,1,0.5,1)`-plus-translation fix -/// matrix. `pub(crate)` so `engine::shadow` can build shadow-map +/// matrix. `pub(crate)` so `engine::gpu::shadow` can build shadow-map /// view-projections in the same depth convention as the main camera. pub(crate) fn gl_to_wgpu_depth(m: &Mat4) -> Mat4 { let mut fix = identity(); diff --git a/src/engine/camera_dynamics.rs b/src/engine/sim/camera_dynamics.rs similarity index 98% rename from src/engine/camera_dynamics.rs rename to src/engine/sim/camera_dynamics.rs index 8de1f36..57a6ebd 100644 --- a/src/engine/camera_dynamics.rs +++ b/src/engine/sim/camera_dynamics.rs @@ -5,7 +5,7 @@ //! `camerashake{,amplitude,roughness,speed}`, `camerafade`, and //! `cameraparallax{,amount,delay,mouseinfluence}` (see WallpaperParser.cpp). -use crate::engine::scene::{parse_value_bool, parse_value_f32, Scene}; +use crate::engine::model::scene::{parse_value_bool, parse_value_f32, Scene}; /// How long the camera-fade ramp lasts after the scene starts, in seconds. const FADE_DURATION_SECS: f32 = 1.2; diff --git a/src/engine/sim/mod.rs b/src/engine/sim/mod.rs new file mode 100644 index 0000000..2e3847b --- /dev/null +++ b/src/engine/sim/mod.rs @@ -0,0 +1,10 @@ +//! What changes over time: particles, keyframed timelines, SceneScript, +//! camera motion and puppet animation. + +pub mod camera3d; +pub mod camera_dynamics; +pub mod noise; +pub mod particle; +pub mod puppet; +pub mod script; +pub mod timeline; diff --git a/src/engine/noise.rs b/src/engine/sim/noise.rs similarity index 100% rename from src/engine/noise.rs rename to src/engine/sim/noise.rs diff --git a/src/engine/sim/particle/build.rs b/src/engine/sim/particle/build.rs new file mode 100644 index 0000000..5502cca --- /dev/null +++ b/src/engine/sim/particle/build.rs @@ -0,0 +1,672 @@ +//! Building a particle system from its config: reading every emitter, +//! initializer, operator and renderer entry into the runtime parameters. + +use super::*; + +impl ParticleSystem { + /// Build a running particle system for one scene object. + /// + /// `spawn_center` is the object's own scene position, already converted to + /// canvas pixel coordinates (see `model::resolve`'s WE-origin-to-pixel + /// conversion) — a WE emitter's own `origin` field is a *local* offset + /// from the object's position, not an absolute canvas coordinate. + pub fn from_config( + config: &ParticleConfig, + spawn_center: [f32; 2], + overrides: Option<&InstanceOverride>, + ) -> Self { + let emitters = config + .emitter + .iter() + .map(|e| { + let local_origin = parse_f32_vec3(e.origin.as_deref()); + // Reference default (1, 1, 0) — a zero default would scale + // every spawn offset to nothing for emitters that omit the + // field, collapsing them to a point at the origin. + let directions = e + .directions + .as_deref() + .map(|d| parse_f32_vec3(Some(d))) + .unwrap_or([1.0, 1.0, 0.0]); + let sign = parse_f32_vec3(e.sign.as_deref()); + let is_sphere = e.name == "sphererandom"; + + // Sphere radii are scalars, box extents "x y z" vectors — + // accept either shape for both (splat scalars). + let dist = |v: &Option, default: f32| -> [f32; 3] { + v.as_ref() + .and_then(|v| { + value_as_vec3(v).or_else(|| value_as_f32(v).map(|f| [f, f, f])) + }) + .unwrap_or([default, default, default]) + }; + let distance_min = dist(&e.distancemin, 0.0); + // Reference default 256 for both emitter kinds. + let distance_max = dist(&e.distancemax, 256.0); + let speed_min = e.speedmin.as_ref().and_then(value_as_f32).unwrap_or(0.0); + let speed_max = e.speedmax.as_ref().and_then(value_as_f32).unwrap_or(0.0); + + EmitterState { + id: e.id.unwrap_or(0), + rate: e.rate.unwrap_or(10.0) as f32, + origin: [ + spawn_center[0] + local_origin[0], + // The emitter's local origin is authored y-up + // (CParticle.cpp negates it in both createBoxEmitter + // and createSphereEmitter): "0 768 0" means 768 + // ABOVE the object — e.g. rainperspective's spawn + // band sits above the frame so drops fall through + // it, not 768 below (which pushed the whole band + // off-screen on 1080p scenes and rain never showed). + spawn_center[1] - local_origin[1], + local_origin[2], + ], + directions, + sign, + is_sphere, + distance_min, + distance_max, + speed_min, + speed_max, + accumulator: 0.0, + burst: e.instantaneous.unwrap_or(0.0).max(0.0) as u32, + burst_done: false, + duration: e.duration.map(|d| d as f32), + audio: AudioResponse::from_emitter(e), + } + }) + .collect(); + + // `instanceoverride.count` scales the pool before the default kicks + // in (CParticle.cpp: `adjustedMaxCount = maxCount * countMultiplier; + // maxParticles = adjusted > 0 ? adjusted : DEFAULT`). + let count_mult = overrides.and_then(|o| o.count).unwrap_or(1.0); + let max_count = match config.maxcount { + Some(n) if n > 0 => match (n as f64 * count_mult) as usize { + 0 => 500, + adjusted => adjusted, + }, + _ => 500, + }; + + // Initializer-absent defaults are the reference's exact spawn values + // (CParticle.cpp: `p.lifetime = 1.0`, `p.size = 20.0` before + // initializers run) — size passes through the sizerandom /2, so the + // range default is 40. + let (life_min, life_max) = + scalar_range_from_initializers(&config.initializer, "lifetimerandom", 1.0, 1.0); + let (size_min, size_max) = + scalar_range_from_initializers(&config.initializer, "sizerandom", 40.0, 40.0); + let life_exp = initializer_exponent(&config.initializer, "lifetimerandom"); + let size_exp = initializer_exponent(&config.initializer, "sizerandom"); + let (alpha_min, alpha_max) = + scalar_range_from_initializers(&config.initializer, "alpharandom", 1.0, 1.0); + // Particle vectors are authored y-up; this simulation is y-down, so + // y flips on the way in (CParticle.cpp does the same for velocity, + // gravity and turbulent directions, as it does for emitter origins). + let flip_y = |v: [f32; 3]| [v[0], -v[1], v[2]]; + let velocity_range = vec3_range_from_initializers( + &config.initializer, + "velocityrandom", + [-32.0; 3], + [32.0; 3], + ); + let (velocity_min, velocity_max) = match velocity_range { + Some((min, max)) => (Some(flip_y(min)), Some(flip_y(max))), + None => (None, None), + }; + let (color_min, color_max) = + vec3_range_from_initializers(&config.initializer, "colorrandom", [0.0; 3], [255.0; 3]) + .unwrap_or(([255.0; 3], [255.0; 3])); + + let movement_op = config.operator.iter().find(|op| op.name == "movement"); + let gravity = movement_op + .and_then(|op| op.gravity.as_deref()) + .map(|s| parse_f32_vec3(Some(s))) + .map(|v| [v[0], -v[1]]) + .unwrap_or([0.0, 0.0]); + // `movement.drag`: per-second velocity decay (CParticle.cpp + // createMovementOperator) — without it, operators that keep adding + // velocity every frame (turbulence, vortex) grow unbounded instead + // of settling into the drifting equilibrium real presets tune for. + let movement_drag = movement_op.and_then(|op| op.drag).unwrap_or(0.0) as f32; + + let color_override = overrides + .and_then(|o| o.color.as_deref()) + .map(|s| parse_f32_vec3(Some(s))) + .map(|v| [v[0] as u8, v[1] as u8, v[2] as u8]); + + let alphafade = config + .operator + .iter() + .find(|op| op.name == "alphafade") + .map(|op| { + ( + op.fadeintime.unwrap_or(0.0) as f32, + op.fadeouttime.unwrap_or(1.0) as f32, + ) + }); + let fade_range_op = |name: &str| -> Option<(f32, f32, f32, f32)> { + config.operator.iter().find(|op| op.name == name).map(|op| { + ( + op.starttime.unwrap_or(0.0) as f32, + op.endtime.unwrap_or(1.0) as f32, + op.startvalue.as_ref().and_then(value_as_f32).unwrap_or(0.0), + op.endvalue.as_ref().and_then(value_as_f32).unwrap_or(1.0), + ) + }) + }; + let sizechange = fade_range_op("sizechange"); + let alphachange = fade_range_op("alphachange"); + let colorchange = config + .operator + .iter() + .find(|op| op.name == "colorchange") + .map(|op| { + ( + op.starttime.unwrap_or(0.0) as f32, + op.endtime.unwrap_or(1.0) as f32, + op.startvalue + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([1.0; 3]), + op.endvalue + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([1.0; 3]), + ) + }); + let oscillate_op = |name: &str| -> Option { + config + .operator + .iter() + .find(|op| op.name == name) + .map(OscillateParams::from_operator) + }; + let oscillate_alpha = oscillate_op("oscillatealpha"); + let oscillate_size = oscillate_op("oscillatesize"); + let oscillate_position = oscillate_op("oscillateposition"); + + // Positional indexing (declaration order), not `id`-keyed slot + // placement — real presets declare control points in id order, and + // consumers (e.g. `controlpointattract`) reference them by a plain + // array index anyway. + let control_points: Vec<[f32; 3]> = config + .controlpoint + .iter() + .map(|cp| { + let mut offset = cp + .offset + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([0.0; 3]); + // `angles.z` rotates the offset about the object origin. + if let Some(az) = cp + .angles + .as_ref() + .and_then(value_as_vec3) + .map(|a| a[2]) + .filter(|z| z.abs() > 1e-6) + { + let (s, c) = az.sin_cos(); + offset = [ + offset[0] * c - offset[1] * s, + offset[0] * s + offset[1] * c, + offset[2], + ]; + } + [ + spawn_center[0] + offset[0], + spawn_center[1] + offset[1], + offset[2], + ] + }) + .collect(); + + let control_point_attract = config + .operator + .iter() + .find(|op| op.name == "controlpointattract") + .map(|op| { + let idx = op.controlpoint.unwrap_or(0).max(0) as usize; + let origin = op + .origin + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([0.0; 3]); + let scale = op.scale.unwrap_or(100.0) as f32; + let threshold = op.threshold.unwrap_or(1000.0) as f32; + (idx, origin, scale, threshold) + }); + + let rope_renderer = config.renderer.iter().find(|r| { + r.get("name") + .and_then(|n| n.as_str()) + .map(|n| n == "rope" || n == "ropetrail") + .unwrap_or(false) + }); + let rope_mode = rope_renderer.is_some(); + let rope_subdivision = rope_renderer + .and_then(|r| r.get("subdivision")) + .and_then(|v| v.as_u64()) + .unwrap_or(1) + .max(1) as usize; + // Rope UV controls (ObjectParser::parseParticleRenderer defaults: + // uvscale 1, uvscrolling false, uvsmoothing true). `uvscale` divides + // the rope's UV length so values > 1 tile the texture along it. + let json_bool = |v: &serde_json::Value| v.as_bool().or_else(|| v.as_u64().map(|n| n != 0)); + let rope_uv_scale = rope_renderer + .and_then(|r| r.get("uvscale")) + .and_then(|v| v.as_f64()) + .map(|v| v as f32) + .filter(|v| *v > 0.0) + .unwrap_or(1.0); + let rope_uv_scrolling = rope_renderer + .and_then(|r| r.get("uvscrolling")) + .and_then(json_bool) + .unwrap_or(false); + let rope_uv_smoothing = rope_renderer + .and_then(|r| r.get("uvsmoothing")) + .and_then(json_bool) + .unwrap_or(true); + + // `spritetrail` renderer: each particle draws as a quad stretched + // along its velocity by `clamp(|v| * length, minlength, maxlength)` + // (genericparticle.vert's TRAILRENDERER `ComputeParticleTrailTangents`; + // parser defaults from ObjectParser::parseParticleRenderer). + let sprite_trail = config + .renderer + .iter() + .find(|r| r.get("name").and_then(|n| n.as_str()) == Some("spritetrail")) + .map(|r| { + let f = |key: &str, default: f32| -> f32 { + r.get(key) + .and_then(|v| v.as_f64()) + .map(|v| v as f32) + .unwrap_or(default) + }; + SpriteTrailParams { + length: f("length", 0.05), + max_length: f("maxlength", 10.0), + min_length: f("minlength", 0.0), + } + }); + + // `angularvelocityrandom`'s min/max are vec3 (reference default + // `(0,0,-5)`/`(0,0,5)`) — we only model the z-axis (screen-space) + // component, matching how `rotation.z` is the only axis our flat + // 2D renderer can express. + let (angular_velocity_min, angular_velocity_max) = config + .initializer + .iter() + .find(|init| init.name == "angularvelocityrandom") + .map(|init| { + let min = init + .min + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([0.0, 0.0, -5.0]); + let max = init + .max + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([0.0, 0.0, 5.0]); + (min[2], max[2]) + }) + .unwrap_or((0.0, 0.0)); + + let angular_op = config + .operator + .iter() + .find(|op| op.name == "angularmovement"); + let angular_drag = angular_op.and_then(|op| op.drag).unwrap_or(0.0) as f32; + let angular_force = angular_op + .and_then(|op| op.force.as_ref()) + .and_then(value_as_vec3) + .map(|v| v[2]) + .unwrap_or(0.0); + + // `rotationrandom`: z component of the random vec3 (our renderer is + // flat 2D, same reduction as `angularvelocityrandom`). Reference + // defaults: min (0,0,0), max (0,0,2pi). + let rotation_range = config + .initializer + .iter() + .find(|init| init.name == "rotationrandom") + .map(|init| { + let min = init + .min + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([0.0; 3]); + let max = init.max.as_ref().and_then(value_as_vec3).unwrap_or([ + 0.0, + 0.0, + std::f32::consts::TAU, + ]); + (min[2], max[2]) + }); + + let turbulent_velocity = config + .initializer + .iter() + .filter(|init| init.name == "turbulentvelocityrandom") + .map(|init| TurbulentVelocityParams { + speed_min: init + .speedmin + .as_ref() + .and_then(value_as_f32) + .unwrap_or(100.0), + speed_max: init + .speedmax + .as_ref() + .and_then(value_as_f32) + .unwrap_or(250.0), + offset: init.offset.unwrap_or(0.0) as f32, + scale: init.scale.unwrap_or(1.0) as f32, + forward: init + .forward + .as_ref() + .and_then(value_as_vec3) + .map(flip_y) + .unwrap_or([0.0, -1.0, 0.0]), + timescale: init.timescale.unwrap_or(1.0) as f32, + phase_min: init.phasemin.unwrap_or(0.0) as f32, + phase_max: init.phasemax.unwrap_or(0.1) as f32, + right: init + .right + .as_ref() + .and_then(value_as_vec3) + .map(flip_y) + .unwrap_or([0.0, 0.0, 1.0]), + }) + .collect(); + + // `turbulence`: phase and speed are randomized once per operator + // instance (the reference draws them in the factory, not per + // particle or per frame). + let turbulence = config + .operator + .iter() + .find(|op| op.name == "turbulence") + .map(|op| { + let phase_min = op.phasemin.unwrap_or(0.0) as f32; + let phase_max = op.phasemax.unwrap_or(0.0) as f32; + let speed_min = op.speedmin.unwrap_or(500.0) as f32; + let speed_max = op.speedmax.unwrap_or(1000.0) as f32; + TurbulenceParams { + noise_scale: op.scale.unwrap_or(0.005) as f32 * 2.0, + timescale: op.timescale.unwrap_or(0.01) as f32, + mask: op + .mask + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([1.0, 1.0, 0.0]), + phase: phase_min + fastrand::f32() * (phase_max - phase_min).max(0.0), + speed: speed_min + fastrand::f32() * (speed_max - speed_min).max(0.0), + } + }); + + let map_sequence = config + .initializer + .iter() + .find(|init| init.name == "mapsequencearoundcontrolpoint") + .map(|init| MapSequenceParams { + control_point: init.controlpoint.unwrap_or(0).max(0) as usize, + count: (init.count.unwrap_or(1.0) as u32).max(1), + speed_min: init + .speedmin + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([0.0; 3]), + speed_max: init + .speedmax + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([100.0; 3]), + }); + + let map_seq_between = config + .initializer + .iter() + .find(|init| init.name == "mapsequencebetweencontrolpoints") + .map(|init| MapSequenceBetweenParams { + count: (init.count.unwrap_or(2.0) as u32).max(2), + mirror: init.limitbehavior.as_deref() == Some("mirror"), + }); + + let vortex = config + .operator + .iter() + .find(|op| op.name == "vortex" || op.name == "vortex_v2") + .map(|op| { + let flags = op.flags.as_ref().and_then(|v| v.as_i64()).unwrap_or(0); + VortexParams { + control_point: op.controlpoint.unwrap_or(0), + infinite_axis: flags & 1 != 0, + maintain_distance: flags & 2 != 0, + ring_shape: flags & 4 != 0, + axis: op + .axis + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([0.0, 0.0, 1.0]), + offset: op + .offset + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([0.0; 3]), + distance_inner: op.distanceinner.unwrap_or(500.0) as f32, + distance_outer: op.distanceouter.unwrap_or(650.0) as f32, + speed_inner: op.speedinner.unwrap_or(2500.0) as f32, + speed_outer: op.speedouter.unwrap_or(0.0) as f32, + center_force: op.centerforce.unwrap_or(1.0) as f32, + ring_radius: op.ringradius.unwrap_or(300.0) as f32, + ring_width: op.ringwidth.unwrap_or(50.0) as f32, + ring_pull_distance: op.ringpulldistance.unwrap_or(50.0) as f32, + ring_pull_force: op.ringpullforce.unwrap_or(10.0) as f32, + } + }); + + let remap: Vec = config + .operator + .iter() + .filter(|op| op.name == "remapvalue") + .map(|op| { + let range = |v: &Option, d: f32| { + v.as_ref() + .and_then(value_as_vec3) + .or_else(|| v.as_ref().and_then(value_as_f32).map(|s| [s, s, s])) + .unwrap_or([d, d, d]) + }; + let scalar = |v: &Option, d: f32| { + v.as_ref().and_then(value_as_f32).unwrap_or(d) + }; + let input = RemapField::parse(op.input.as_deref()); + // No `transformfunction` key at all: noise-generator default + // when there's no `input` field either (the classic, + // real-content-validated case, `rain_screen.json`); + // passthrough when an `input` field IS given, so setting + // `input` alone uses the field's value directly instead of + // silently overriding it with noise. Confirmed against the + // retail binary's own `alphafade`-preset builder + // (Follow-up (x)), which pairs `input=lifetimefraction` with + // an *explicit* `transformfunction=none` — this default + // reproduces that pairing even when a wallpaper's own config + // omits the (redundant) explicit "none". + let transform = match op.transformfunction.as_deref() { + Some(s) => RemapTransform::parse(s), + None if input == RemapField::None => RemapTransform::Simplex, + None => RemapTransform::None, + }; + RemapParams { + input, + input_component: RemapComponent::parse(op.inputcomponent.as_deref()), + output: RemapOutput::parse(op.output.as_deref()), + output_component: RemapComponent::parse(op.outputcomponent.as_deref()), + operation: RemapOp::parse(op.operation.as_deref()), + transform, + in_min: scalar(&op.inputrangemin, 0.0), + in_max: scalar(&op.inputrangemax, 1.0), + out_min: range(&op.outputrangemin, 0.0), + out_max: range(&op.outputrangemax, 0.0), + input_scale: op.transforminputscale.unwrap_or(1.0) as f32, + } + }) + .collect(); + + // `collisionsphere`/`collisionbox`/`collisionbounds`/`collisionquad`/ + // `collisionplane` — see the `Operator`/`CollisionParams` field docs + // for the "no C++ reference" caveat. Anchored at `controlpoint` (+ + // `offset`), same static-resolve-once convention as + // `controlpointattract`/`vortex`. + const COLLISION_NAMES: [&str; 5] = [ + "collisionsphere", + "collisionbox", + "collisionbounds", + "collisionquad", + "collisionplane", + ]; + let collisions: Vec = config + .operator + .iter() + .filter(|op| COLLISION_NAMES.contains(&op.name.as_str())) + .map(|op| { + let cp_idx = op.controlpoint.unwrap_or(0).max(0) as usize; + let cp = control_points.get(cp_idx).copied().unwrap_or([0.0; 3]); + let offset = op + .offset + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([0.0; 3]); + let center = [cp[0] + offset[0], cp[1] + offset[1]]; + let shape = match op.name.as_str() { + "collisionsphere" => CollisionShape::Sphere { + radius: op.radius.unwrap_or(100.0) as f32, + }, + "collisionplane" => { + // Default normal (0,1) is "up" in our y-down space + // (screen-up); `angles.z` rotates it, same convention + // `mapsequencearoundcontrolpoint`-adjacent offset + // rotation already uses elsewhere in this module. + let az = op + .angles + .as_ref() + .and_then(value_as_vec3) + .map(|a| a[2]) + .unwrap_or(0.0); + let (s, c) = az.sin_cos(); + CollisionShape::Plane { normal: [-s, c] } + } + // collisionbox/collisionbounds/collisionquad + _ => { + let size = op + .size + .as_ref() + .and_then(value_as_vec3) + .unwrap_or([200.0, 200.0, 0.0]); + CollisionShape::Box { + half_extent: [size[0] * 0.5, size[1] * 0.5], + } + } + }; + CollisionParams { + shape, + center, + behavior: CollisionBehavior::parse(op.collisionbehavior.as_deref()), + bounce_factor: op.bouncefactor.unwrap_or(1.0) as f32, + } + }) + .collect(); + + // `boids`: standard separation/alignment/cohesion flocking. + let boids = config + .operator + .iter() + .find(|op| op.name == "boids") + .map(|op| BoidsParams { + separation_threshold: op.separationthreshold.unwrap_or(50.0) as f32, + neighbor_threshold: op.neighborthreshold.unwrap_or(150.0) as f32, + max_speed: op.maxspeed.unwrap_or(500.0) as f32, + separation_factor: op.separationfactor.unwrap_or(1.0) as f32, + alignment_factor: op.alignmentfactor.unwrap_or(1.0) as f32, + cohesion_factor: op.cohesionfactor.unwrap_or(1.0) as f32, + variable_strength: op.variablestrength.unwrap_or(0.0) != 0.0, + }); + + Self { + particles: Vec::with_capacity(max_count), + emitters, + max_count, + life_min, + life_max, + size_min, + size_max, + life_exp, + size_exp, + alpha_min, + alpha_max, + velocity_min, + velocity_max, + color_min, + color_max, + gravity, + movement_drag, + alphafade, + sizechange, + alphachange, + colorchange, + oscillate_alpha, + oscillate_size, + oscillate_position, + alpha_mult: overrides.and_then(|o| o.alpha).unwrap_or(1.0) as f32, + lifetime_mult: overrides.and_then(|o| o.lifetime).unwrap_or(1.0) as f32, + colorn_mult: overrides + .and_then(|o| o.colorn.as_ref()) + .and_then(value_as_vec3_pub) + .unwrap_or([1.0, 1.0, 1.0]), + size_mult: overrides.and_then(|o| o.size).unwrap_or(1.0) as f32, + speed_mult: overrides.and_then(|o| o.speed).unwrap_or(1.0) as f32, + rate_mult: overrides.and_then(|o| o.rate).unwrap_or(1.0) as f32, + color_override, + control_points, + control_point_attract, + rope_mode, + rope_subdivision, + rope_uv_scale, + rope_uv_scrolling, + rope_uv_smoothing, + shell_3d: config.flags.unwrap_or(0) & 4 != 0, + next_particle_id: 0, + children: Vec::new(), + angular_velocity_min, + angular_velocity_max, + angular_force, + angular_drag, + sprite_frame_count: 0, + sprite_duration: 0.0, + animation_mode: AnimationMode::from_config(config.animationmode.as_deref()), + sequence_multiplier: config + .sequencemultiplier + .map(|v| v as f32) + .filter(|v| *v > 0.0) + .unwrap_or(1.0), + rotation_range, + sprite_trail, + map_sequence, + map_sequence_index: 0, + map_seq_between, + map_seq_between_index: 0, + turbulent_velocity, + turbulence, + vortex, + remap, + collisions, + boids, + time: 0.0, + start_time: config.starttime.unwrap_or(0.0).max(0.0) as f32, + scene_force: [0.0, 0.0], + audio_bands: [0.0; 16], + } + } +} diff --git a/src/engine/sim/particle/config.rs b/src/engine/sim/particle/config.rs new file mode 100644 index 0000000..4a6a21d --- /dev/null +++ b/src/engine/sim/particle/config.rs @@ -0,0 +1,623 @@ +//! A particle system's JSON configuration (`particles/*.json`, with the +//! instance overrides a scene object applies) and the helpers that read its +//! loosely typed values. + +use super::*; + +// Numeric/count fields use `Option` rather than bare `T` with +// `#[serde(default)]`: real presets frequently write an explicit JSON `null` +// for unused fields (e.g. `"flags": null`), and `#[serde(default)]` only +// fills in *missing* keys — it still rejects an explicit `null` against a +// non-Option numeric type. +#[derive(Debug, Clone, Deserialize)] +pub struct ParticleConfig { + #[serde(default)] + pub emitter: Vec, + #[serde(default)] + pub maxcount: Option, + #[serde(default)] + pub initializer: Vec, + #[serde(default)] + pub operator: Vec, + #[serde(default)] + pub renderer: Vec, + #[serde(default)] + pub flags: Option, + /// Real JSON key is singular `"controlpoint"` (an array), referenced by + /// index (not `id`-keyed lookup at use-site) from operators/emitters like + /// `controlpointattract` (CParticle.cpp/ObjectParser.cpp). + #[serde(default, deserialize_with = "null_entries_as_default")] + pub controlpoint: Vec, + /// Delay in seconds before this system begins emitting. Present on nearly + /// every real preset (610/610) so a burst ramps in instead of all + /// particles appearing at frame 0. + #[serde(default)] + pub starttime: Option, + /// Path to the material JSON providing this system's sprite texture + /// (e.g. `"materials/presets/water_faucet.json"`). Resolved by callers + /// (this module stays asset-agnostic) and passed into `render_onto`. + #[serde(default)] + pub material: Option, + /// How a particle's sprite-sheet frame advances over time: `"once"` + /// (play through over the particle's lifetime), `"randomframe"` (pick + /// one fixed random frame at spawn), or anything else/absent (loop + /// continuously) — CParticle.cpp's animation-frame update. + #[serde(default)] + pub animationmode: Option, + /// Playback speed multiplier for sprite-sheet animation; `<= 0` (or + /// absent) means 1.0, matching the reference's `sequenceMultiplier > 0 + /// ? sequenceMultiplier : 1.0`. + #[serde(default)] + pub sequencemultiplier: Option, + /// Nested child particle presets (`"children"` array; often JSON `null`). + /// Loaded/resolved by the caller (this module stays asset-agnostic) and + /// attached via [`ParticleSystem::add_child`]. + #[serde(default)] + pub children: Option>, +} + +/// One `children` entry: a nested particle preset attached to this system. +/// `type` values seen in real content: absent/`"static"` (a sub-system +/// running continuously at the parent object's origin), `"eventfollow"` +/// (an instance per parent particle, tracking it), `"eventspawn"` / +/// `"eventdeath"` (a burst where a parent particle appears/dies). +#[derive(Debug, Clone, Deserialize)] +pub struct ChildRef { + pub name: String, + #[serde(rename = "type", default)] + pub child_type: Option, + #[serde(default)] + pub probability: Option, + #[serde(default)] + pub maxcount: Option, +} + +/// One resolved particle sprite: every sprite-sheet frame (already sliced +/// out of the `.tex` atlas by `TexFile::to_rgba_frames`) plus the total +/// loop duration in seconds (sum of each frame's `frametime`, 0 for a +/// single-frame/non-animated sprite), plus the material's +/// `ui_editor_properties_overbright` brightness multiplier +/// (genericparticle.frag's `color.rgb *= g_Overbright`, default 1.0). +#[derive(Clone)] +pub struct ParticleSprite { + pub frames: Vec, + pub duration: f32, + pub overbright: f32, +} + +impl ParticleSprite { + pub fn single(image: RgbaImage) -> Self { + Self { + frames: vec![image], + duration: 0.0, + overbright: 1.0, + } + } +} + +/// Parsed `animationmode` (defaults to `Loop` for anything unrecognized or +/// absent, matching the reference's `else` fallback). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub(super) enum AnimationMode { + #[default] + Loop, + Once, + RandomFrame, +} + +impl AnimationMode { + pub(super) fn from_config(mode: Option<&str>) -> Self { + match mode { + Some("once") => Self::Once, + Some("randomframe") => Self::RandomFrame, + _ => Self::Loop, + } + } +} + +/// Real presets pad their `controlpoint` array with literal `null`s (unused +/// slots). Serde rejects the whole preset over one, so nulls become defaults — +/// the array is indexed positionally, so the padding has to stay in place. +pub(super) fn null_entries_as_default<'de, D, T>(d: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de> + Default, +{ + Ok(Vec::>::deserialize(d)? + .into_iter() + .map(Option::unwrap_or_default) + .collect()) +} + +#[derive(Default, Debug, Clone, Deserialize)] +pub struct ControlPointConfig { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub offset: Option, + #[serde(default)] + pub flags: Option, + /// Orientation of the control point's local frame (z used in 2D): rotates + /// the `offset` about the object origin so oriented control points land in + /// the right place. + #[serde(default)] + pub angles: Option, + /// `locktopointer`: the control point tracks the mouse cursor. Parsed; + /// we have no live pointer input, so it resolves to its static offset. + #[serde(default)] + pub locktopointer: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Emitter { + #[serde(default)] + pub name: String, + /// Emission rate per second. `None` (absent) takes the reference's + /// default of 10; an explicit `0` stays 0 (instantaneous-only emitters). + #[serde(default)] + pub rate: Option, + #[serde(default)] + pub origin: Option, + #[serde(default)] + pub directions: Option, + /// A scalar radius for sphere emitters, but a `"x y z"` box-extent vector + /// for box emitters (e.g. `"distancemax": "1024 512 0"`). + #[serde(default)] + pub distancemin: Option, + #[serde(default)] + pub distancemax: Option, + /// Optional emitter-driven radial speed range (rare; most presets set + /// velocity via initializers). + #[serde(default)] + pub speedmin: Option, + #[serde(default)] + pub speedmax: Option, + #[serde(default)] + pub sign: Option, + #[serde(default)] + pub id: Option, + /// One-shot burst count: this many particles are emitted in a single frame + /// when the emitter first activates (at `starttime`), then normal `rate` + /// emission continues. `0`/absent = purely rate-driven. + #[serde(default)] + pub instantaneous: Option, + /// Emitter lifetime in seconds: after `starttime + duration`, this emitter + /// stops emitting. Absent = emits forever. + #[serde(default)] + pub duration: Option, + /// Presence of `audioprocessingmode`/`bounds` makes emission react to + /// captured audio; the rest shape the response (see `AudioResponse`). + #[serde(default)] + pub audioprocessingmode: Option, + #[serde(default)] + pub audioprocessingbounds: Option, + #[serde(default)] + pub audioprocessingexponent: Option, + #[serde(default)] + pub audioprocessingfrequencystart: Option, + #[serde(default)] + pub audioprocessingfrequencyend: Option, +} + +/// An emitter's audio response, with the defaults and clamping of WE's own +/// parser (wallpaper64.exe `FUN_1401c1e20`): `exponent` 2.0, `bounds` +/// "0.8 1.0", bands `0..=1` of the 16-band spectrum, both clamped to 15 and +/// swapped if reversed. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct AudioResponse { + pub(super) exponent: f32, + pub(super) bounds: [f32; 2], + pub(super) bands: [usize; 2], +} + +impl AudioResponse { + pub(super) fn from_emitter(e: &Emitter) -> Option { + if e.audioprocessingmode.is_none() && e.audioprocessingbounds.is_none() { + return None; + } + let band = |v: &Option, d: usize| { + v.as_ref() + .and_then(|v| v.as_u64()) + .map_or(d, |b| b as usize) + .min(15) + }; + let (a, b) = ( + band(&e.audioprocessingfrequencystart, 0), + band(&e.audioprocessingfrequencyend, 1), + ); + let bounds = match &e.audioprocessingbounds { + Some(serde_json::Value::String(s)) => { + let p: Vec = s + .split_whitespace() + .filter_map(|x| x.parse().ok()) + .collect(); + [ + p.first().copied().unwrap_or(0.0), + p.get(1).copied().unwrap_or(0.0), + ] + } + Some(v) => value_as_f32(v).map_or([0.8, 1.0], |x| [x, x]), + None => [0.8, 1.0], + }; + Some(Self { + exponent: e + .audioprocessingexponent + .as_ref() + .and_then(value_as_f32) + .unwrap_or(2.0), + bounds, + bands: [a.min(b), a.max(b)], + }) + } + + /// Rate multiplier for one 16-band spectrum (L/R averaged). Per WE's + /// emitter docs the emitter is "only active when audio is playing" and + /// `bounds` are the volume levels where the response starts and stops, + /// so silence -> 0 and the rate ramps from `bounds[0]` to `bounds[1]`. + // ponytail: WE's runtime consumer isn't decompiled; the exponent-then- + // bounds order follows the docs' wording, and `mode` (left/right/center) + // is treated only as an on-switch. Revisit if the consumer is recovered. + pub(super) fn gain(&self, bands16: &[f32; 16]) -> f32 { + let s = &bands16[self.bands[0]..=self.bands[1]]; + let level = (s.iter().sum::() / s.len() as f32) + .clamp(0.0, 1.0) + .powf(self.exponent); + let [lo, hi] = self.bounds; + if hi <= lo { + return if level >= lo { 1.0 } else { 0.0 }; + } + ((level - lo) / (hi - lo)).clamp(0.0, 1.0) + } +} + +/// `min`/`max` are untyped because real presets mix plain numbers +/// (`lifetimerandom`, `sizerandom`, `alpharandom`) with `"r g b"`/`"x y z"` +/// vector strings (`velocityrandom`, `colorrandom`, `angularvelocityrandom`) — +/// declaring these as `f64` makes serde reject any config using a vector +/// initializer. +#[derive(Debug, Clone, Deserialize)] +pub struct Initializer { + #[serde(default)] + pub name: String, + #[serde(default)] + pub max: Option, + #[serde(default)] + pub min: Option, + /// Biases the uniform `random(min,max)` toward `min` (exponent > 1) or + /// `max` (< 1): the sample `t` is raised to this power before the lerp. + /// Absent = 1.0 (uniform). + #[serde(default)] + pub exponent: Option, + /// `turbulentvelocityrandom` (CParticle.cpp + /// createTurbulentVelocityRandomInitializer): a curl-noise-directed + /// spawn velocity. `forward`/`right` are `"x y z"` vector strings; + /// `offset` here is the scalar tilt angle around `right`, unrelated to + /// an emitter's positional offset. `speedmin`/`speedmax` are untyped + /// because turbulentvelocityrandom uses plain numbers but + /// `mapsequencearoundcontrolpoint` uses `"x y z"` vector strings. + #[serde(default)] + pub speedmin: Option, + #[serde(default)] + pub speedmax: Option, + /// `mapsequencearoundcontrolpoint`: spawn the Nth particle at the + /// control point, launched at angle `(N % count) / count * 2pi`. + #[serde(default)] + pub controlpoint: Option, + #[serde(default)] + pub count: Option, + /// `mapsequencebetweencontrolpoints`: `"mirror"` ping-pongs the + /// sequence along the segment instead of wrapping. + #[serde(default)] + pub limitbehavior: Option, + #[serde(default)] + pub offset: Option, + #[serde(default)] + pub scale: Option, + #[serde(default)] + pub forward: Option, + #[serde(default)] + pub timescale: Option, + #[serde(default)] + pub phasemin: Option, + #[serde(default)] + pub phasemax: Option, + #[serde(default)] + pub right: Option, +} + +#[derive(Debug, Default, Clone, Deserialize)] +pub struct Operator { + #[serde(default)] + pub name: String, + /// The real field on a WE "movement" operator (a constant acceleration + /// vector), not a generic scalar. + #[serde(default)] + pub gravity: Option, + /// `alphafade`: fade in over `[0, fadeintime]`, hold, fade out over + /// `[fadeouttime, 1]` of normalized lifetime (CParticle.cpp createAlphaFadeOperator). + #[serde(default)] + pub fadeintime: Option, + #[serde(default)] + pub fadeouttime: Option, + /// `sizechange`/`alphachange`/`colorchange`: linear ramp from `startvalue` + /// to `endvalue` over normalized-lifetime `[starttime, endtime]` + /// (CParticle.cpp's generic `fadeValue`). `startvalue`/`endvalue` are + /// untyped because `sizechange`/`alphachange` use a plain number but + /// `colorchange` uses an `"r g b"` vector string (e.g. `"1 0.75 0"`). + #[serde(default)] + pub starttime: Option, + #[serde(default)] + pub endtime: Option, + #[serde(default)] + pub startvalue: Option, + #[serde(default)] + pub endvalue: Option, + /// `oscillatealpha`/`oscillatesize`/`oscillateposition`: per-particle + /// random frequency/scale/phase, resampled once at spawn. + #[serde(default)] + pub frequencymin: Option, + #[serde(default)] + pub frequencymax: Option, + #[serde(default)] + pub scalemin: Option, + #[serde(default)] + pub scalemax: Option, + #[serde(default)] + pub phasemin: Option, + #[serde(default)] + pub phasemax: Option, + /// `controlpointattract`: pulls particles toward `controlpoint` (index + /// into the particle config's `controlpoint` array) whenever they're + /// within `threshold/2` of it (CParticle.cpp createControlPointAttractOperator). + #[serde(default)] + pub controlpoint: Option, + #[serde(default)] + pub origin: Option, + #[serde(default)] + pub scale: Option, + #[serde(default)] + pub threshold: Option, + /// `angularmovement`: integrates `rotation.z` from `angularvelocityrandom`'s + /// sampled `angular_velocity`, itself accelerated by `force.z` and decayed + /// by `drag` each step (CParticle.cpp createAngularMovementOperator). + #[serde(default)] + pub drag: Option, + #[serde(default)] + pub force: Option, + /// `turbulence` (CParticle.cpp createTurbulenceOperator): curl-noise + /// acceleration. Shares `scale`/`phasemin`/`phasemax` above. + #[serde(default)] + pub speedmin: Option, + #[serde(default)] + pub speedmax: Option, + #[serde(default)] + pub timescale: Option, + #[serde(default)] + pub mask: Option, + /// `vortex` (CParticle.cpp createVortexOperator): tangential spin around + /// a control point. `flags`: 1 = infinite axis, 2 = maintain distance, + /// 4 = ring shape. + #[serde(default)] + pub flags: Option, + #[serde(default)] + pub axis: Option, + #[serde(default)] + pub offset: Option, + #[serde(default)] + pub distanceinner: Option, + #[serde(default)] + pub distanceouter: Option, + #[serde(default)] + pub speedinner: Option, + #[serde(default)] + pub speedouter: Option, + #[serde(default)] + pub centerforce: Option, + #[serde(default)] + pub ringradius: Option, + #[serde(default)] + pub ringwidth: Option, + #[serde(default)] + pub ringpulldistance: Option, + #[serde(default)] + pub ringpullforce: Option, + /// `remapvalue` (CParticle createValueRemapOperator, real retail-binary + /// dispatch tables recovered in the Ghidra report's Follow-up (x)): + /// reads `input` (a named particle field, or noise sampled at the + /// particle position/time when unset), shapes it through + /// `transformfunction`, remaps `[inputrangemin, inputrangemax]` into + /// `[outputrangemin, outputrangemax]`, and combines it into `output` via + /// `operation`. `outputrangemin`/`max` are `"x y z"` vec3 for + /// vector-valued outputs (velocity/position/color), scalar otherwise. + #[serde(default)] + pub input: Option, + #[serde(default)] + pub inputcomponent: Option, + #[serde(default)] + pub output: Option, + #[serde(default)] + pub outputcomponent: Option, + #[serde(default)] + pub operation: Option, + #[serde(default)] + pub inputrangemin: Option, + #[serde(default)] + pub inputrangemax: Option, + #[serde(default)] + pub outputrangemin: Option, + #[serde(default)] + pub outputrangemax: Option, + #[serde(default)] + pub transformfunction: Option, + #[serde(default)] + pub transforminputscale: Option, + /// `collisionsphere`/`collisionbox`/`collisionbounds`/`collisionquad`/ + /// `collisionplane` (real operator names, Ghidra `strings.txt` around + /// `140490008`, alongside `boids`/`collisionbehavior`/`bouncefactor` — + /// no C++ reference implements any of these, so field semantics below + /// are a reasoned reconstruction, not byte-verified). A sphere/box/ + /// bounds/quad collider's half-size or radius; box/bounds/quad read + /// `size`'s x/y, sphere reads `radius`. + #[serde(default)] + pub radius: Option, + #[serde(default)] + pub size: Option, + /// `slide` (zero the into-surface velocity component), `bounce` + /// (reflect it, scaled by `bouncefactor`), or `delete` (kill the + /// particle on contact). Defaults to `bounce`, the most common + /// particle-collision behavior in comparable engines. + #[serde(default)] + pub collisionbehavior: Option, + #[serde(default)] + pub bouncefactor: Option, + /// `boids` flocking rules (separation/alignment/cohesion) — real field + /// names from the same table (`variablestrength`, `separationthreshold`, + /// `neighborthreshold`, `maxspeed`, `separationfactor`, + /// `alignmentfactor`, `cohesionfactor`). + #[serde(default)] + pub separationthreshold: Option, + #[serde(default)] + pub neighborthreshold: Option, + #[serde(default)] + pub maxspeed: Option, + #[serde(default)] + pub separationfactor: Option, + #[serde(default)] + pub alignmentfactor: Option, + #[serde(default)] + pub cohesionfactor: Option, + #[serde(default)] + pub variablestrength: Option, + /// Plane-collider orientation (reuses the same "z-axis rotates a default + /// normal" convention `controlpoint`/vortex offsets already use). + #[serde(default)] + pub angles: Option, +} + +/// Scene.json's per-instance `instanceoverride` on a particle object — +/// overrides the referenced preset's rate/size/speed/alpha/color. +#[derive(Debug, Default, Clone, Deserialize)] +pub struct InstanceOverride { + pub alpha: Option, + pub color: Option, + pub rate: Option, + pub size: Option, + pub speed: Option, + /// Multiplier on the preset's `maxcount` pool size + /// (CParticle.cpp: `adjustedMaxCount = maxCount * count`). + pub count: Option, + /// Multiplier on every spawned particle's lifetime — the reference + /// applies it both in `createLifetimeRandomInitializer` and the + /// default-spawn path, so a single multiply at spawn covers both. + pub lifetime: Option, + /// Per-channel color multiplier ("r g b", 0–1 floats) applied to the + /// `colorrandom` result (CParticle.cpp:715-719). Distinct from `color`. + pub colorn: Option, + /// `false` disables the whole system (ObjectParser default true). + pub enabled: Option, + /// Remaining override keys — notably `controlpointN` ("x y z" strings, + /// absolute scene coordinates) that reposition a preset's control + /// points per instance (e.g. the discharge preset's arc endpoint). + #[serde(flatten)] + pub extra: std::collections::HashMap, +} + +/// Initializer names are matched exactly — they're fixed strings in the +/// reference's ObjectParser, and substring matching let +/// `turbulentvelocityrandom`/`angularvelocityrandom` shadow +/// `velocityrandom` (or `angularmovement` shadow `movement`) depending on +/// declaration order. +/// The `exponent` (distribution bias) of a named initializer, or 1.0 (uniform). +pub(super) fn initializer_exponent(initializers: &[Initializer], name: &str) -> f32 { + initializers + .iter() + .find(|i| i.name == name) + .and_then(|i| i.exponent) + .map(|e| e as f32) + .filter(|e| *e > 0.0) + .unwrap_or(1.0) +} + +pub(super) fn scalar_range_from_initializers( + initializers: &[Initializer], + name: &str, + default_min: f32, + default_max: f32, +) -> (f32, f32) { + let Some(init) = initializers.iter().find(|init| init.name == name) else { + return (default_min, default_max); + }; + ( + init.min + .as_ref() + .and_then(value_as_f32) + .unwrap_or(default_min), + init.max + .as_ref() + .and_then(value_as_f32) + .unwrap_or(default_max), + ) +} + +/// Returns `Some((min, max))` when the named initializer exists and both +/// bounds parse as a 3-component vector (e.g. `velocityrandom`/`colorrandom`). +/// Exact-name matching for the same reason as `scalar_range_from_initializers`. +/// The (min, max) of initializer `name`, each falling back to WE's default +/// when omitted (ObjectParser.cpp: `colorrandom` black..white, +/// `velocityrandom` -32..32). `None` when there is no such initializer. +pub(super) fn vec3_range_from_initializers( + initializers: &[Initializer], + name: &str, + default_min: [f32; 3], + default_max: [f32; 3], +) -> Option<([f32; 3], [f32; 3])> { + let init = initializers.iter().find(|init| init.name == name)?; + let min = init + .min + .as_ref() + .and_then(value_as_vec3) + .unwrap_or(default_min); + let max = init + .max + .as_ref() + .and_then(value_as_vec3) + .unwrap_or(default_max); + Some((min, max)) +} + +pub(super) fn value_as_f32(v: &serde_json::Value) -> Option { + v.as_f64().map(|f| f as f32) +} + +/// Public alias for callers outside this module (e.g. model::resolve's +/// control-point override plumbing). +pub fn value_as_vec3_pub(v: &serde_json::Value) -> Option<[f32; 3]> { + value_as_vec3(v) +} + +pub(super) fn value_as_vec3(v: &serde_json::Value) -> Option<[f32; 3]> { + match v { + serde_json::Value::String(s) => Some(parse_f32_vec3(Some(s))), + serde_json::Value::Array(arr) => { + let parts: Vec = arr + .iter() + .filter_map(|x| x.as_f64().map(|f| f as f32)) + .collect(); + (parts.len() >= 3).then(|| [parts[0], parts[1], parts[2]]) + } + _ => None, + } +} + +pub(super) fn parse_f32_vec3(s: Option<&str>) -> [f32; 3] { + let Some(s) = s else { return [0.0; 3] }; + let parts: Vec = s + .split_whitespace() + .filter_map(|p| p.parse().ok()) + .collect(); + [ + parts.first().copied().unwrap_or(0.0), + parts.get(1).copied().unwrap_or(0.0), + parts.get(2).copied().unwrap_or(0.0), + ] +} diff --git a/src/engine/sim/particle/draw.rs b/src/engine/sim/particle/draw.rs new file mode 100644 index 0000000..8c68d5f --- /dev/null +++ b/src/engine/sim/particle/draw.rs @@ -0,0 +1,946 @@ +//! Turning particles into pixels: the CPU rasterizer (textured sprites, +//! blending, sampling), rope/trail geometry, and the vertices the GPU +//! renderer draws. + +use super::*; + +impl ParticleSystem { + /// Total pixel coverage the rasterizer would write for one frame: the + /// sum of every particle's quad area, each clipped to the bounding-box + /// area. Overdraw — many huge overlapping sprites — makes this far + /// larger than the bounding box itself, and it is the number a raster + /// budget must divide, not the buffer size. + pub fn coverage(&self, bbox_area: f32) -> f32 { + self.particles + .iter() + .map(|p| { + let side = (p.size * 2.0).max(1.0); + (side * side).min(bbox_area) + }) + .sum() + } + + /// Bounding box (`min_x, min_y, max_x, max_y`) over all alive particles' + /// `position ± size` (with a small margin for the soft-falloff glow + /// radius) — `None` when nothing is alive, so callers can skip a wasted + /// raster/upload entirely. Lets `render_onto` be called with a canvas + /// sized to just this box instead of the full scene. + pub fn bounds(&self) -> Option<(f32, f32, f32, f32)> { + let mut min_x = f32::MAX; + let mut min_y = f32::MAX; + let mut max_x = f32::MIN; + let mut max_y = f32::MIN; + for p in &self.particles { + // `p.size` is a radius (see `sizerandom`'s halving comment) for + // both the circle and textured-quad draws; pad by a couple of + // pixels for the soft falloff's antialiasing. + let half = p.size.max(1.0) + 2.0; + min_x = min_x.min(p.x - half); + min_y = min_y.min(p.y - half); + max_x = max_x.max(p.x + half); + max_y = max_y.max(p.y + half); + } + (!self.particles.is_empty()).then_some((min_x, min_y, max_x, max_y)) + } + + /// `origin` shifts every particle's canvas-space position by `-origin` — + /// lets callers raster into a canvas sized to just a system's bounding + /// box (see `bounds()`) instead of the full scene. Existing full-canvas + /// callers pass `[0.0, 0.0]`. + pub fn render_onto( + &self, + canvas: &mut RgbaImage, + sprite: Option<&ParticleSprite>, + origin: [f32; 2], + ) { + self.render_onto_blended(canvas, sprite, origin, false); + } + + /// `render_onto` with the material's blending mode: `additive` layers + /// accumulate premultiplied contributions (the reference renders each + /// particle quad with `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`, CPass.cpp), + /// so overlapping particles brighten each other instead of the newer + /// one occluding the older via "over" compositing. + pub fn render_onto_blended( + &self, + canvas: &mut RgbaImage, + sprite: Option<&ParticleSprite>, + origin: [f32; 2], + additive: bool, + ) { + self.render_onto_scaled(canvas, sprite, origin, additive, 1.0); + } + + /// `render_onto_blended` with all positions/sizes multiplied by `scale` + /// — lets a caller rasterize into a smaller-than-scene buffer that the + /// GPU compositor then stretches back up. Soft, huge sprites (smoke + /// clouds, rain sheets — radii in the hundreds of pixels) are the ones + /// that need this, and they lose nothing visible to the upscale, while + /// the CPU rasterization cost drops with scale². + pub fn render_onto_scaled( + &self, + canvas: &mut RgbaImage, + sprite: Option<&ParticleSprite>, + origin: [f32; 2], + additive: bool, + scale: f32, + ) { + if self.rope_mode { + self.render_rope_onto(canvas, sprite, origin, additive, scale); + self.render_children_onto(canvas, origin, scale); + return; + } + + let w = canvas.width() as i32; + let h = canvas.height() as i32; + + for p in &self.particles { + // Authored alpha straight through — the reference draws + // `v_Color.a = p.alpha` with no damping factor. + let alpha = (p.alpha * 255.0).clamp(0.0, 255.0) as u8; + if alpha == 0 { + continue; + } + let sz = (p.size * scale) as i32; + if sz <= 0 { + continue; + } + let px_pos = (p.x - origin[0]) * scale; + let py_pos = (p.y - origin[1]) * scale; + + if let Some(sprite) = sprite { + // `p.frame` is only ever set (in `step`) when the sprite has + // more than one frame; a single-frame sprite always draws + // frame 0 regardless of its (unused, still -1.0) value. + // + // Cross-fade: the fractional part of `p.frame` blends toward + // the NEXT frame, clamped at the last frame (the real + // engine's ComputeSpriteFrame uses + // `nextFrame = min(numFrames - 1, currentFrame + 1)` — no + // wrap, in any animation mode). RandomFrame assigns integer + // frames, so its fraction is 0 and no second sample happens. + let (frame_idx, next_frame) = if sprite.frames.len() > 1 { + let f = p.frame.max(0.0); + let a = (f as usize).min(sprite.frames.len() - 1); + let b = (a + 1).min(sprite.frames.len() - 1); + let blend = f.fract(); + if b != a && blend > 1.0 / 255.0 { + (a, Some((&sprite.frames[b], blend))) + } else { + (a, None) + } + } else { + (0, None) + }; + let tex = &sprite.frames[frame_idx]; + // `spritetrail`: align the quad's V axis with the particle's + // velocity and stretch it by clamp(|v|*length, min, max) — + // genericparticle.vert's TRAILRENDERER path, where `up` = + // normalized velocity scaled by that clamp and texture-top + // (v=0) points along it. + let (half_w, half_h, rotation) = match self.sprite_trail { + Some(t) => { + let vel_len = (p.vx * p.vx + p.vy * p.vy).sqrt(); + let trail = (vel_len * t.length) + .clamp(t.min_length.min(t.max_length), t.max_length) + .max(0.05); + let angle = if vel_len > 0.0001 { + p.vy.atan2(p.vx) + std::f32::consts::FRAC_PI_2 + } else { + p.rotation + }; + (p.size * scale, p.size * trail * scale, angle) + } + None => (p.size * scale, p.size * scale, p.rotation), + }; + draw_textured_particle( + canvas, + px_pos, + py_pos, + half_w, + half_h, + rotation, + p.color, + alpha, + tex, + next_frame, + sprite.overbright, + additive, + scale < 0.9, + ); + continue; + } + + let cx = px_pos as i32; + let cy = py_pos as i32; + + let sz2 = (sz * sz) as f32; + for dy in -sz..=sz { + for dx in -sz..=sz { + let d2 = (dx * dx + dy * dy) as f32; + if d2 > sz2 { + continue; + } + let px = cx + dx; + let py = cy + dy; + if px < 0 || py < 0 || px >= w || py >= h { + continue; + } + // Soft radial falloff — a flat-alpha disc reads as a harsh, + // hard-edged blob, especially for large ambient sprites + // (fog/light shafts) that are meant to be soft glows. + let t = 1.0 - (d2 / sz2).sqrt(); + let falloff = t * t; + let src_a = (alpha as f32 / 255.0) * falloff; + let src_c = [ + p.color[0] as f32 / 255.0, + p.color[1] as f32 / 255.0, + p.color[2] as f32 / 255.0, + ]; + let dst = canvas.get_pixel_mut(px as u32, py as u32); + blend_pixel(dst, src_c, src_a, additive); + } + } + } + + self.render_children_onto(canvas, origin, scale); + } + + /// Draws every child instance over the parent's output, each with its + /// own material sprite and blending. + pub(super) fn render_children_onto( + &self, + canvas: &mut RgbaImage, + origin: [f32; 2], + scale: f32, + ) { + for child in &self.children { + for inst in &child.instances { + inst.system.render_onto_scaled( + canvas, + child.sprite.as_ref(), + origin, + child.additive, + scale, + ); + } + } + } + + /// Draws a connected ribbon through living particles (oldest-first, since + /// `step()`'s retain preserves order and new spawns are appended) instead + /// of independent circles. Position is interpolated with the reference's + /// exact Catmull-Rom formula (CParticle.cpp renderRope); size/color/alpha + /// are linearly interpolated per sub-segment, and the material texture is + /// stretched across the ribbon: U spans the width (the beam profile that + /// makes a 100px-wide rope read as a hairline filament), V runs along the + /// rope as `(trailPosition + uvY) / (trailLength - 1)` with `uvscale` + /// tiling, optional arc-length smoothing, and 1-cycle/sec scrolling — + /// CParticle::renderRope's exact vertex semantics, evaluated per-pixel + /// here instead of in its rope shader. + pub(super) fn render_rope_onto( + &self, + canvas: &mut RgbaImage, + sprite: Option<&ParticleSprite>, + origin: [f32; 2], + additive: bool, + scale: f32, + ) { + let Some(geo) = self.build_rope_geometry(origin, scale) else { + return; + }; + let tex = sprite.and_then(|s| s.frames.first()); + let overbright = sprite.map(|s| s.overbright).unwrap_or(1.0); + for &(i, v_a, v_b) in &geo.quads { + let (a, b) = (&geo.points[i], &geo.points[i + 1]); + fill_rope_segment( + canvas, a.pos, b.pos, a.normal, b.normal, a.size, b.size, a.color, b.color, + a.alpha, b.alpha, additive, tex, overbright, v_a, v_b, + ); + } + } + + /// Builds the rope's shared geometry — Catmull-Rom subpoints, per-joint + /// central-difference normals, and per-quad V ranges — consumed by both + /// the CPU scanline fill and the GPU vertex emitter. + pub(super) fn build_rope_geometry(&self, origin: [f32; 2], scale: f32) -> Option { + let n = self.particles.len(); + if n < 2 { + return None; + } + + let subdiv = self.rope_subdivision; + let mut subpoints = Vec::with_capacity((n - 1) * subdiv + 1); + for i in 0..(n - 1) { + let idx0 = i.saturating_sub(1); + let p0 = &self.particles[idx0]; + let p1 = &self.particles[i]; + let p2 = &self.particles[i + 1]; + let idx3 = (i + 2).min(n - 1); + let p3 = &self.particles[idx3]; + + for step in 0..subdiv { + let t = step as f32 / subdiv as f32; + let pos = + catmull_rom_vec2([p0.x, p0.y], [p1.x, p1.y], [p2.x, p2.y], [p3.x, p3.y], t); + subpoints.push(RopePoint { + pos: [(pos[0] - origin[0]) * scale, (pos[1] - origin[1]) * scale], + size: lerp_f32(p1.size, p2.size, t) * scale, + alpha: lerp_f32(p1.alpha, p2.alpha, t), + color: [ + lerp_f32(p1.color[0] as f32, p2.color[0] as f32, t) as u8, + lerp_f32(p1.color[1] as f32, p2.color[1] as f32, t) as u8, + lerp_f32(p1.color[2] as f32, p2.color[2] as f32, t) as u8, + ], + normal: [0.0, 1.0], + }); + } + } + let last = &self.particles[n - 1]; + subpoints.push(RopePoint { + pos: [(last.x - origin[0]) * scale, (last.y - origin[1]) * scale], + size: last.size * scale, + color: last.color, + alpha: last.alpha, + normal: [0.0, 1.0], + }); + + // V coordinates along the rope — CParticle::renderRope's exact + // semantics: each sub-segment quad consumes 1/(trailLength-1) of UV + // space, `uvscale` divides the usable length (values > 1 tile the + // texture), `uvsmoothing` redistributes V by arc length (only when + // all lifetimes match and scrolling is off), and `uvscrolling` + // shifts one full UV cycle per second. + let total_sub = (subpoints.len() - 1) as f32; + let usable_len = (total_sub / self.rope_uv_scale).max(f32::EPSILON); + let use_smoothing = + self.rope_uv_smoothing && self.life_min == self.life_max && !self.rope_uv_scrolling; + let mut cumulative_arc = Vec::new(); + let mut total_arc = 0.0f32; + if use_smoothing { + cumulative_arc = Vec::with_capacity(subpoints.len()); + cumulative_arc.push(0.0); + for pair in subpoints.windows(2) { + let (dx, dy) = ( + pair[1].pos[0] - pair[0].pos[0], + pair[1].pos[1] - pair[0].pos[1], + ); + total_arc += (dx * dx + dy * dy).sqrt(); + cumulative_arc.push(total_arc); + } + } + let scroll_offset = if self.rope_uv_scrolling { + (self.time % 10000.0) * usable_len + } else { + 0.0 + }; + + // Per-joint width directions — genericropeparticle.vert's + // `trailRightStart = cross(eye, end − prev)` / `trailRightEnd = + // cross(eye, after − start)`: the perpendicular of the CENTRAL- + // DIFFERENCE tangent at each joint, so consecutive quads share + // their edge exactly and the ribbon reads as connected trapezoids + // instead of independently-angled facets. In screen space + // `cross((0,0,1), t)` is the 90° rotate `(−t.y, t.x)`. + let joint_normal = |i: usize| -> [f32; 2] { + let prev = &subpoints[i.saturating_sub(1)]; + let next = &subpoints[(i + 1).min(subpoints.len() - 1)]; + let t = [next.pos[0] - prev.pos[0], next.pos[1] - prev.pos[1]]; + let len = (t[0] * t[0] + t[1] * t[1]).sqrt(); + if len < 0.0001 { + [0.0, 1.0] + } else { + [-t[1] / len, t[0] / len] + } + }; + let normals: Vec<[f32; 2]> = (0..subpoints.len()).map(joint_normal).collect(); + for (i, n) in normals.into_iter().enumerate() { + subpoints[i].normal = n; + } + + let quads = (0..subpoints.len() - 1) + .map(|s| { + let trail_position = if use_smoothing && total_arc > 0.0 { + cumulative_arc[s] / total_arc * total_sub + } else { + s as f32 + } + scroll_offset; + ( + s, + trail_position / usable_len, + (trail_position + 1.0) / usable_len, + ) + }) + .collect(); + + Some(RopeGeometry { + points: subpoints, + quads, + }) + } + + /// Appends pre-transformed triangle-list vertices for the GPU particle + /// pipeline: 6 per sprite quad (rotation/spritetrail applied here, the + /// same math as the CPU textured draw) or 6 per rope sub-quad. Positions + /// are absolute scene pixels; the vertex shader converts to NDC. + /// `frame_count` is the sprite's frame count (0/1 = static frame 0). + pub fn emit_gpu_vertices(&self, out: &mut Vec, frame_count: usize) { + if self.rope_mode { + self.emit_rope_gpu_vertices(out); + return; + } + for p in &self.particles { + if p.alpha <= 0.0 || p.size <= 0.0 { + continue; + } + // Frame/cross-fade selection — mirrors the CPU textured draw. + let (frame, blend) = if frame_count > 1 { + let f = p.frame.max(0.0); + let a = (f as usize).min(frame_count - 1); + let b = (a + 1).min(frame_count - 1); + let fract = f.fract(); + if b != a && fract > 1.0 / 255.0 { + (a as f32, fract) + } else { + (a as f32, 0.0) + } + } else { + (0.0, 0.0) + }; + let (half_w, half_h, rotation) = match self.sprite_trail { + Some(t) => { + let vel_len = (p.vx * p.vx + p.vy * p.vy).sqrt(); + let trail = (vel_len * t.length) + .clamp(t.min_length.min(t.max_length), t.max_length) + .max(0.05); + let angle = if vel_len > 0.0001 { + p.vy.atan2(p.vx) + std::f32::consts::FRAC_PI_2 + } else { + p.rotation + }; + (p.size, p.size * trail, angle) + } + None => (p.size, p.size, p.rotation), + }; + let (c, sn) = (rotation.cos(), rotation.sin()); + let color = [ + p.color[0] as f32 / 255.0, + p.color[1] as f32 / 255.0, + p.color[2] as f32 / 255.0, + p.alpha.clamp(0.0, 1.0), + ]; + let corner = |lx: f32, ly: f32, u: f32, v: f32| GpuVertex { + pos: [p.x + lx * c - ly * sn, p.y + lx * sn + ly * c], + uv: [u, v], + color, + frame_blend: [frame, blend, 0.0, 0.0], + }; + let tl = corner(-half_w, -half_h, 0.0, 0.0); + let tr = corner(half_w, -half_h, 1.0, 0.0); + let br = corner(half_w, half_h, 1.0, 1.0); + let bl = corner(-half_w, half_h, 0.0, 1.0); + out.extend_from_slice(&[tl, tr, br, br, bl, tl]); + } + } + + pub(super) fn emit_rope_gpu_vertices(&self, out: &mut Vec) { + let Some(geo) = self.build_rope_geometry([0.0, 0.0], 1.0) else { + return; + }; + for &(i, v_a, v_b) in &geo.quads { + let (a, b) = (&geo.points[i], &geo.points[i + 1]); + // Same convexity guard as fill_rope_segment: keep both joint + // normals on the same side when the chain doubles back. + let nb = if a.normal[0] * b.normal[0] + a.normal[1] * b.normal[1] < 0.0 { + [-b.normal[0], -b.normal[1]] + } else { + b.normal + }; + let (ha, hb) = (a.size.max(0.5), b.size.max(0.5)); + let col = |p: &RopePoint| { + [ + p.color[0] as f32 / 255.0, + p.color[1] as f32 / 255.0, + p.color[2] as f32 / 255.0, + p.alpha.clamp(0.0, 1.0), + ] + }; + let (ca, cb) = (col(a), col(b)); + let vert = |pos: [f32; 2], uv: [f32; 2], color: [f32; 4]| GpuVertex { + pos, + uv, + color, + frame_blend: [0.0, 0.0, 0.0, 0.0], + }; + // U convention matches fill_rope_segment: +normal side = u 1. + let a1 = vert( + [a.pos[0] + a.normal[0] * ha, a.pos[1] + a.normal[1] * ha], + [1.0, v_a], + ca, + ); + let b1 = vert( + [b.pos[0] + nb[0] * hb, b.pos[1] + nb[1] * hb], + [1.0, v_b], + cb, + ); + let b0 = vert( + [b.pos[0] - nb[0] * hb, b.pos[1] - nb[1] * hb], + [0.0, v_b], + cb, + ); + let a0 = vert( + [a.pos[0] - a.normal[0] * ha, a.pos[1] - a.normal[1] * ha], + [0.0, v_a], + ca, + ); + out.extend_from_slice(&[a1, b1, b0, b0, a0, a1]); + } + } + + /// Visits every live child instance with its child index (the GPU path + /// keys uploaded textures by child, since instances spawn at runtime) + /// and its material blending. + pub fn visit_gpu_children(&self, f: &mut dyn FnMut(&ParticleSystem, usize, bool)) { + for (child_idx, child) in self.children.iter().enumerate() { + for inst in &child.instances { + f(&inst.system, child_idx, child.additive); + } + } + } + + /// Build-time child info for GPU texture upload: each child's sprite and + /// whether its preset uses a rope renderer. + pub fn child_sprite_info(&self) -> Vec<(Option<&ParticleSprite>, bool)> { + self.children + .iter() + .map(|c| { + let rope = c.config.renderer.iter().any(|r| { + r.get("name") + .and_then(|n| n.as_str()) + .map(|n| n == "rope" || n == "ropetrail") + .unwrap_or(false) + }); + (c.sprite.as_ref(), rope) + }) + .collect() + } + + pub fn is_rope(&self) -> bool { + self.rope_mode + } +} + +/// One point along a rope's subdivided spine (see `build_rope_geometry`). +pub(super) struct RopePoint { + pub(super) pos: [f32; 2], + pub(super) size: f32, + pub(super) color: [u8; 3], + pub(super) alpha: f32, + /// Per-joint central-difference perpendicular (width direction). + pub(super) normal: [f32; 2], +} + +/// Shared rope geometry: spine points and per-quad `(start_index, v_a, v_b)`. +pub(super) struct RopeGeometry { + pub(super) points: Vec, + pub(super) quads: Vec<(usize, f32, f32)>, +} + +/// One pre-transformed vertex for the GPU particle pipeline (triangle list, +/// read from a storage buffer by `vs_particles`). 48 bytes, std430-compatible. +#[derive(Clone, Copy, Debug, Default)] +pub struct GpuVertex { + /// Absolute scene pixels (y-down). + pub pos: [f32; 2], + pub uv: [f32; 2], + /// rgb 0-1, a = particle alpha. + pub color: [f32; 4], + /// x = sprite-sheet frame, y = cross-fade weight toward frame+1. + pub frame_blend: [f32; 4], +} + +impl GpuVertex { + /// Flattens vertices to the byte layout `vs_particles` reads. + pub fn as_bytes(verts: &[GpuVertex]) -> Vec { + let mut out = Vec::with_capacity(verts.len() * 48); + for v in verts { + for f in v + .pos + .iter() + .chain(v.uv.iter()) + .chain(v.color.iter()) + .chain(v.frame_blend.iter()) + { + out.extend_from_slice(&f.to_le_bytes()); + } + } + out + } +} + +/// Reference's exact Catmull-Rom formula (CParticle.cpp:2124-2130), applied +/// per-axis. +pub(super) fn catmull_rom_vec2( + p0: [f32; 2], + p1: [f32; 2], + p2: [f32; 2], + p3: [f32; 2], + t: f32, +) -> [f32; 2] { + let t2 = t * t; + let t3 = t2 * t; + let mut out = [0.0; 2]; + for axis in 0..2 { + let (p0, p1, p2, p3) = (p0[axis], p1[axis], p2[axis], p3[axis]); + out[axis] = 0.5 + * ((2.0 * p1) + + (-p0 + p2) * t + + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); + } + out +} + +/// Rasterizes one ribbon quad between two consecutive sub-points: offsets +/// each endpoint by its half-width along its own JOINT normal (the central- +/// difference perpendicular computed by the caller), producing edge-sharing +/// trapezoids exactly like genericropeparticle.vert's +/// `position + mix(trailRightStart, trailRightEnd, uvY) * (uvX*2−1)` — where +/// `trailRight* = normalize(cross(eye, tangent)) * size`, i.e. the half-width +/// IS the particle size (spawn already halved `sizerandom`, so `p.size` is a +/// radius). With a material texture, each pixel samples it at (U = signed +/// offset across the width, V = lerp(v_a, v_b) along the rope, wrapping for +/// tiled `uvscale`) and modulates the particle color/alpha — the beam strip's +/// transparent edges are what make a wide rope render as a thin filament. +/// Without a texture (material failed to resolve), falls back to a flat fill. +#[allow(clippy::too_many_arguments)] +pub(super) fn fill_rope_segment( + canvas: &mut RgbaImage, + a: [f32; 2], + b: [f32; 2], + normal_a: [f32; 2], + normal_b: [f32; 2], + size_a: f32, + size_b: f32, + color_a: [u8; 3], + color_b: [u8; 3], + alpha_a: f32, + alpha_b: f32, + additive: bool, + tex: Option<&RgbaImage>, + overbright: f32, + v_a: f32, + v_b: f32, +) { + let dir = [b[0] - a[0], b[1] - a[1]]; + let len = (dir[0] * dir[0] + dir[1] * dir[1]).sqrt(); + if len < 0.0001 { + return; + } + let dir = [dir[0] / len, dir[1] / len]; + let (half_a, half_b) = (size_a.max(0.5), size_b.max(0.5)); + + // Keep both joint normals on the same side so the quad stays convex — + // a chain doubling back >90° would otherwise produce a bowtie that the + // point-in-quad test rejects entirely (the GPU draws such quads as two + // crossing triangles; a same-side flip is the closest scanline analog). + let normal_b = if normal_a[0] * normal_b[0] + normal_a[1] * normal_b[1] < 0.0 { + [-normal_b[0], -normal_b[1]] + } else { + normal_b + }; + + let corners = [ + [a[0] + normal_a[0] * half_a, a[1] + normal_a[1] * half_a], + [b[0] + normal_b[0] * half_b, b[1] + normal_b[1] * half_b], + [b[0] - normal_b[0] * half_b, b[1] - normal_b[1] * half_b], + [a[0] - normal_a[0] * half_a, a[1] - normal_a[1] * half_a], + ]; + + let min_x = corners + .iter() + .map(|c| c[0]) + .fold(f32::MAX, f32::min) + .floor() + .max(0.0) as i32; + let max_x = corners + .iter() + .map(|c| c[0]) + .fold(f32::MIN, f32::max) + .ceil() + .min(canvas.width() as f32 - 1.0) as i32; + let min_y = corners + .iter() + .map(|c| c[1]) + .fold(f32::MAX, f32::min) + .floor() + .max(0.0) as i32; + let max_y = corners + .iter() + .map(|c| c[1]) + .fold(f32::MIN, f32::max) + .ceil() + .min(canvas.height() as f32 - 1.0) as i32; + if min_x > max_x || min_y > max_y { + return; + } + + let w = canvas.width() as i32; + let h = canvas.height() as i32; + for py in min_y..=max_y { + for px in min_x..=max_x { + if px < 0 || py < 0 || px >= w || py >= h { + continue; + } + let point = [px as f32 + 0.5, py as f32 + 0.5]; + if !point_in_convex_quad(point, &corners) { + continue; + } + // Blend color/alpha by which endpoint the pixel is nearer along + // the segment (a cheap stand-in for true per-pixel interpolation). + let seg_t = ((point[0] - a[0]) * dir[0] + (point[1] - a[1]) * dir[1]) / len; + let seg_t = seg_t.clamp(0.0, 1.0); + + if let Some(tex) = tex { + // Signed offset from the centerline along the interpolated + // joint normal, in [-half, +half] at this point's width. + let center = [lerp_f32(a[0], b[0], seg_t), lerp_f32(a[1], b[1], seg_t)]; + let nl = [ + lerp_f32(normal_a[0], normal_b[0], seg_t), + lerp_f32(normal_a[1], normal_b[1], seg_t), + ]; + let nl_len = (nl[0] * nl[0] + nl[1] * nl[1]).sqrt().max(0.0001); + let d_perp = + ((point[0] - center[0]) * nl[0] + (point[1] - center[1]) * nl[1]) / nl_len; + let half_t = lerp_f32(half_a, half_b, seg_t).max(0.5); + let u = (0.5 + 0.5 * (d_perp / half_t)).clamp(0.0, 1.0); + let v = lerp_f32(v_a, v_b, seg_t).rem_euclid(1.0); + let sample = sample_bilinear(tex, u, v); + let src_a = + lerp_f32(alpha_a, alpha_b, seg_t).clamp(0.0, 1.0) * (sample[3] as f32 / 255.0); + if src_a <= 0.0 { + continue; + } + let src_c = [ + (sample[0] as f32 / 255.0) + * (lerp_f32(color_a[0] as f32, color_b[0] as f32, seg_t) / 255.0) + * overbright, + (sample[1] as f32 / 255.0) + * (lerp_f32(color_a[1] as f32, color_b[1] as f32, seg_t) / 255.0) + * overbright, + (sample[2] as f32 / 255.0) + * (lerp_f32(color_a[2] as f32, color_b[2] as f32, seg_t) / 255.0) + * overbright, + ]; + let dst = canvas.get_pixel_mut(px as u32, py as u32); + blend_pixel(dst, src_c, src_a, additive); + continue; + } + + let alpha = (lerp_f32(alpha_a, alpha_b, seg_t) * 180.0).clamp(0.0, 255.0) as u8; + if alpha == 0 { + continue; + } + let color = [ + lerp_f32(color_a[0] as f32, color_b[0] as f32, seg_t) as u8, + lerp_f32(color_a[1] as f32, color_b[1] as f32, seg_t) as u8, + lerp_f32(color_a[2] as f32, color_b[2] as f32, seg_t) as u8, + ]; + + let src_a = alpha as f32 / 255.0; + let src_c = [ + color[0] as f32 / 255.0, + color[1] as f32 / 255.0, + color[2] as f32 / 255.0, + ]; + let dst = canvas.get_pixel_mut(px as u32, py as u32); + blend_pixel(dst, src_c, src_a, additive); + } + } +} + +/// Point-in-convex-quad test via consistent cross-product sign across all +/// four edges. `corners` must be wound consistently (either all-CW or +/// all-CCW) — true by construction here (two parallel perpendicular offsets). +pub(super) fn point_in_convex_quad(point: [f32; 2], corners: &[[f32; 2]; 4]) -> bool { + let mut sign = 0.0f32; + for i in 0..4 { + let a = corners[i]; + let b = corners[(i + 1) % 4]; + let edge = [b[0] - a[0], b[1] - a[1]]; + let to_point = [point[0] - a[0], point[1] - a[1]]; + let cross = edge[0] * to_point[1] - edge[1] * to_point[0]; + if i == 0 { + sign = cross; + } else if cross * sign < 0.0 { + return false; + } + } + true +} + +/// Draws one particle as a rotated, textured quad: inverse-rotates each +/// destination pixel in the quad's bounding box back into texture space, +/// bilinear-samples, and composites tinted-by-`color`/scaled-by-`alpha_byte` +/// over the canvas. The reference hands rotation+size to an external, +/// proprietary vertex shader to build the quad (not present in the +/// open-source reference repo) — this CPU inverse-mapping is our own design +/// for the same visual effect. +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_textured_particle( + canvas: &mut RgbaImage, + cx: f32, + cy: f32, + half_w: f32, + half_h: f32, + rotation: f32, + color: [u8; 3], + alpha_byte: u8, + tex: &RgbaImage, + next_frame: Option<(&RgbaImage, f32)>, + overbright: f32, + additive: bool, + nearest: bool, +) { + // `p.size` is already a radius, not a diameter (see `sizerandom`'s + // comment on the halving done at spawn) — matches the flat-color circle + // draw's `sz = p.size` convention, so a particle looks the same size + // whether or not it has a material/texture. The two half-extents differ + // only for `spritetrail` quads (stretched along velocity). + let half_w = half_w.max(0.5); + let half_h = half_h.max(0.5); + let cos_r = rotation.cos(); + let sin_r = rotation.sin(); + + // Bounding box of the rotated quad: the diagonal half-extent covers any + // rotation angle. + let diag = (half_w * half_w + half_h * half_h).sqrt(); + let w = canvas.width() as i32; + let h = canvas.height() as i32; + let min_x = ((cx - diag).floor() as i32).max(0); + let max_x = ((cx + diag).ceil() as i32).min(w - 1); + let min_y = ((cy - diag).floor() as i32).max(0); + let max_y = ((cy + diag).ceil() as i32).min(h - 1); + + for py in min_y..=max_y { + for px in min_x..=max_x { + let dx = px as f32 + 0.5 - cx; + let dy = py as f32 + 0.5 - cy; + // Inverse-rotate the destination offset back into the quad's own + // (unrotated) local space. + let lx = dx * cos_r + dy * sin_r; + let ly = -dx * sin_r + dy * cos_r; + if lx < -half_w || lx > half_w || ly < -half_h || ly > half_h { + continue; + } + let u = (lx + half_w) / (2.0 * half_w); + let v = (ly + half_h) / (2.0 * half_h); + let mut sample = if nearest { + sample_nearest(tex, u, v) + } else { + sample_bilinear(tex, u, v) + }; + // Spritesheet cross-fade: mix toward the next frame's sample + // (genericparticle.frag's SPRITESHEETBLEND path). + if let Some((tex_b, blend)) = next_frame { + let b = if nearest { + sample_nearest(tex_b, u, v) + } else { + sample_bilinear(tex_b, u, v) + }; + for c in 0..4 { + sample[c] = (sample[c] as f32 + (b[c] as f32 - sample[c] as f32) * blend) + .clamp(0.0, 255.0) as u8; + } + } + let src_a = (sample[3] as f32 / 255.0) * (alpha_byte as f32 / 255.0); + if src_a <= 0.0 { + continue; + } + // genericparticle.frag: `color.rgb *= g_Overbright` after the + // texture × vertex-color multiply. blend_pixel clamps, so + // overbright > 1 saturates exactly like the GPU's clamped u8 target. + let src_c = [ + (sample[0] as f32 / 255.0) * (color[0] as f32 / 255.0) * overbright, + (sample[1] as f32 / 255.0) * (color[1] as f32 / 255.0) * overbright, + (sample[2] as f32 / 255.0) * (color[2] as f32 / 255.0) * overbright, + ]; + let dst = canvas.get_pixel_mut(px as u32, py as u32); + blend_pixel(dst, src_c, src_a, additive); + } + } +} + +/// Composites one source sample into a canvas pixel. +/// +/// Normal/translucent layers use standard "over" compositing (not a plain +/// lerp): the destination isn't always opaque — the GPU path renders +/// particles into their own transparent scene-sized buffer before +/// compositing it as a texture, so overlapping particles must accumulate +/// alpha correctly rather than assuming a fully-opaque backdrop. +/// +/// Additive layers accumulate premultiplied contributions instead — the +/// reference draws every particle quad with +/// `glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE, GL_SRC_ALPHA, GL_ONE)` +/// (CPass.cpp), i.e. `dst += src * src_a` — so overlapping glows brighten +/// each other rather than the newest quad occluding the ones below. The +/// buffer then holds already-premultiplied color, which the GPU composite's +/// pure-add mode adds to the scene without re-weighting by alpha. +pub(super) fn blend_pixel(dst: &mut image::Rgba, src_c: [f32; 3], src_a: f32, additive: bool) { + if additive { + for i in 0..3 { + let dst_c = dst[i] as f32 / 255.0; + dst[i] = ((dst_c + src_c[i] * src_a) * 255.0).clamp(0.0, 255.0) as u8; + } + let dst_a = dst[3] as f32 / 255.0; + dst[3] = ((dst_a + src_a * src_a) * 255.0).clamp(0.0, 255.0) as u8; + return; + } + let dst_a = dst[3] as f32 / 255.0; + let out_a = src_a + dst_a * (1.0 - src_a); + if out_a > 0.0 { + for i in 0..3 { + let dst_c = dst[i] as f32 / 255.0; + let out_c = (src_c[i] * src_a + dst_c * dst_a * (1.0 - src_a)) / out_a; + dst[i] = (out_c * 255.0).clamp(0.0, 255.0) as u8; + } + } + dst[3] = (out_a * 255.0).clamp(0.0, 255.0) as u8; +} + +/// Point sampling — used when the raster target is itself downscaled (the +/// GPU upscale blurs anyway), saving the 4-tap filter per pixel. +pub(super) fn sample_nearest(tex: &RgbaImage, u: f32, v: f32) -> [u8; 4] { + let (tw, th) = (tex.width(), tex.height()); + if tw == 0 || th == 0 { + return [0; 4]; + } + let x = ((u.clamp(0.0, 1.0) * (tw as f32 - 1.0)) + 0.5) as u32; + let y = ((v.clamp(0.0, 1.0) * (th as f32 - 1.0)) + 0.5) as u32; + tex.get_pixel(x.min(tw - 1), y.min(th - 1)).0 +} + +pub(super) fn sample_bilinear(tex: &RgbaImage, u: f32, v: f32) -> [u8; 4] { + let (tw, th) = (tex.width(), tex.height()); + let fx = (u.clamp(0.0, 1.0) * (tw as f32 - 1.0).max(0.0)).max(0.0); + let fy = (v.clamp(0.0, 1.0) * (th as f32 - 1.0).max(0.0)).max(0.0); + let x0 = fx.floor() as u32; + let y0 = fy.floor() as u32; + let x1 = (x0 + 1).min(tw - 1); + let y1 = (y0 + 1).min(th - 1); + let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); + + let p00 = tex.get_pixel(x0, y0); + let p10 = tex.get_pixel(x1, y0); + let p01 = tex.get_pixel(x0, y1); + let p11 = tex.get_pixel(x1, y1); + + let mut out = [0u8; 4]; + for i in 0..4 { + let top = p00[i] as f32 * (1.0 - tx) + p10[i] as f32 * tx; + let bottom = p01[i] as f32 * (1.0 - tx) + p11[i] as f32 * tx; + out[i] = (top * (1.0 - ty) + bottom * ty).clamp(0.0, 255.0) as u8; + } + out +} diff --git a/src/engine/sim/particle/mod.rs b/src/engine/sim/particle/mod.rs new file mode 100644 index 0000000..31fbbb4 --- /dev/null +++ b/src/engine/sim/particle/mod.rs @@ -0,0 +1,310 @@ +//! Wallpaper Engine particle systems: a [`ParticleSystem`] built from a +//! [`ParticleConfig`], stepped every frame, and drawn on the CPU or handed to +//! the GPU renderer as vertices. +//! +//! This file holds the runtime state types; the rest is split by role: +//! - `config`: the JSON config types and value-parsing helpers +//! - `build`: reading a config into a system +//! - `step`: the per-frame simulation and its inputs +//! - `operators`: operator parameters and their math +//! - `draw`: CPU drawing, rope geometry, GPU vertices + +use image::RgbaImage; +use serde::Deserialize; + +mod config; +pub use config::*; + +mod operators; +use operators::*; + +mod draw; +pub use draw::*; + +mod build; + +mod step; + +/// Per-particle oscillator state for `oscillatealpha`/`oscillatesize`: a +/// random frequency/scale/phase drawn once at spawn (CParticle.cpp captures +/// these lazily on first use, but since we never re-use particle slots +/// across configs, drawing them at spawn is equivalent). +#[derive(Clone, Copy)] +struct Oscillator1D { + frequency: f32, + scale: f32, + phase: f32, +} + +/// Same idea as `Oscillator1D` but per-axis for `oscillateposition`, which +/// integrates a velocity (derivative of `scale*cos(w*t+phase)`) into position +/// each frame rather than overwriting an absolute value. +#[derive(Clone, Copy)] +struct OscillatorPos { + frequency: [f32; 2], + scale: [f32; 2], + phase: [f32; 2], +} + +#[derive(Clone)] +struct Particle { + /// Stable identity across `retain` compaction — child `eventfollow` + /// instances track their parent particle by this, not by index. + id: u64, + x: f32, + y: f32, + vx: f32, + vy: f32, + life: f32, + max_life: f32, + size: f32, + alpha: f32, + color: [u8; 3], + /// Alpha/size/color as set by the initializers at spawn — `alphafade`/ + /// `sizechange`/`colorchange` scale *this*, not the live (already-modified) + /// value. `initial_color` is 0..1 (matching `colorchange`'s multiplier). + initial_alpha: f32, + initial_size: f32, + initial_color: [f32; 3], + osc_alpha: Option, + osc_size: Option, + osc_pos: Option, + /// Screen-space (Z-axis) rotation angle, radians. Only visually relevant + /// once a sprite texture is drawn (`render_onto`'s textured path) — a + /// flat-color circle is rotation-invariant. + rotation: f32, + angular_velocity: f32, + /// Current sprite-sheet frame (fractional, truncated at draw time); + /// `-1.0` at spawn means "not yet assigned" (only meaningful for + /// `AnimationMode::RandomFrame`, which assigns it once and freezes it). + frame: f32, +} + +pub struct ParticleSystem { + particles: Vec, + emitters: Vec, + max_count: usize, + life_min: f32, + life_max: f32, + size_min: f32, + size_max: f32, + /// `exponent` distribution bias for lifetime/size random sampling (1.0 = uniform). + life_exp: f32, + size_exp: f32, + /// `alpharandom` initializer range (CParticle.cpp's `AlphaRandomInitializer`): + /// defaults to `(1.0, 1.0)` — a no-op — when the config has no such + /// initializer at all, matching `ParticleInstance::alpha`'s `1.0f` default. + alpha_min: f32, + alpha_max: f32, + velocity_min: Option<[f32; 3]>, + velocity_max: Option<[f32; 3]>, + color_min: [f32; 3], + color_max: [f32; 3], + gravity: [f32; 2], + /// Scene-global constant acceleration (`general.gravity*` + `wind*`), + /// added to every particle's velocity each step. Set post-construction. + scene_force: [f32; 2], + /// Latest 16-band spectrum (L/R averaged), set per frame; audio-reactive + /// emitters scale their emission rate by it. + audio_bands: [f32; 16], + /// `movement.drag`: velocity decays by `1 - drag*dt` (clamped at 0) + /// each step, matching the reference's reversal-preventing clamp. + movement_drag: f32, + /// `alphafade`: (fadeintime, fadeouttime), normalized-lifetime fractions. + alphafade: Option<(f32, f32)>, + /// `sizechange`/`alphachange`: (starttime, endtime, startvalue, endvalue). + sizechange: Option<(f32, f32, f32, f32)>, + alphachange: Option<(f32, f32, f32, f32)>, + /// `colorchange`: (starttime, endtime, start_color, end_color), colors in + /// 0..1 (unlike `colorrandom`'s 0..255 — matches the real presets, e.g. + /// `"startvalue": "1 0.75 0"`). + colorchange: Option<(f32, f32, [f32; 3], [f32; 3])>, + oscillate_alpha: Option, + oscillate_size: Option, + oscillate_position: Option, + alpha_mult: f32, + size_mult: f32, + speed_mult: f32, + rate_mult: f32, + /// `instanceoverride.lifetime` multiplier on spawned lifetimes. + lifetime_mult: f32, + /// `instanceoverride.colorn` per-channel multiplier (0-1) on the + /// `colorrandom` result. + colorn_mult: [f32; 3], + color_override: Option<[u8; 3]>, + /// Resolved once at construction: `spawn_center + offset` per control + /// point, indexed positionally (falls back to declaration order when a + /// config omits `id`). Nested parent-space transforms and mouse-linked + /// (`locktopointer`)/world-space (`flags & 2`) control points are not + /// modeled — same simplification already accepted for emitter origins. + control_points: Vec<[f32; 3]>, + /// `controlpointattract`: (control point index, origin offset, scale, threshold). + control_point_attract: Option<(usize, [f32; 3], f32, f32)>, + /// True when `config.renderer` names a `"rope"`/`"ropetrail"` renderer — + /// draws a connected ribbon through living particles instead of + /// independent circles (CParticle::renderRope). + rope_mode: bool, + rope_subdivision: usize, + /// Rope UV controls (`uvscale`/`uvscrolling`/`uvsmoothing` renderer keys). + rope_uv_scale: f32, + rope_uv_scrolling: bool, + rope_uv_smoothing: bool, + /// `angularvelocityrandom` initializer range (z-axis component only). + angular_velocity_min: f32, + angular_velocity_max: f32, + /// `angularmovement` operator (z-axis `force` component, plus `drag`). + angular_force: f32, + angular_drag: f32, + /// Sprite-sheet animation: how many frames the resolved sprite has (0 if + /// no sprite, or a plain single-frame one) and its total loop duration + /// in seconds — set post-construction via `set_sprite_frames` once the + /// caller has actually resolved the sprite texture (this module stays + /// asset-agnostic, same convention as `material`/`render_onto`). + sprite_frame_count: usize, + sprite_duration: f32, + animation_mode: AnimationMode, + sequence_multiplier: f32, + /// `rotationrandom` initializer range (z-axis component, radians) — + /// `None` leaves spawn rotation at 0, matching the reference's absent- + /// initializer default. + rotation_range: Option<(f32, f32)>, + /// `spritetrail` renderer parameters. + sprite_trail: Option, + /// `mapsequencearoundcontrolpoint` initializer + its running sequence + /// counter (shared across spawns, wraps at `count`). + map_sequence: Option, + map_sequence_index: u32, + /// `mapsequencebetweencontrolpoints` initializer + its counter. + map_seq_between: Option, + map_seq_between_index: u32, + /// `turbulentvelocityrandom` initializers; each adds its own velocity. + turbulent_velocity: Vec, + /// `turbulence` operator parameters. + turbulence: Option, + /// `vortex` operator parameters. + vortex: Option, + /// `remapvalue` operator parameters — a `Vec` since real content + /// (`particles/presets/rain_screen.json`) attaches more than one (one + /// targeting `velocity`, one targeting `speed`) and both must run; a + /// single `Option` here used to silently drop every instance after the + /// first. + remap: Vec, + /// `collisionsphere`/`collisionbox`/`collisionbounds`/`collisionquad`/ + /// `collisionplane` operators — every one attached, since a system can + /// have more than one collider (e.g. a floor plane plus a wall box). + collisions: Vec, + /// `boids` operator parameters. + boids: Option, + /// Simulation clock (sum of `step` dts) — drives the time-scrolled + /// noise fields, the reference's `m_time`/`currentTime`. + time: f32, + /// `starttime`: emission is suppressed until the clock reaches this. + start_time: f32, + /// Preset `flags & 4` (perspective particles): sphere emitters use the + /// 3D spherical-shell spawn distribution instead of the 2D disk. + shell_3d: bool, + /// Monotonic source for `Particle::id`. + next_particle_id: u64, + /// Attached child presets (see [`ChildRef`]); each holds its own live + /// instances, stepped and drawn with this system. + children: Vec, +} + +/// How a child preset binds to its parent (see [`ChildRef`]). +#[derive(Debug, Clone, Copy, PartialEq)] +enum ChildType { + Static, + Follow, + Spawn, + Death, +} + +/// A resolved child preset plus its live instances. The child's material +/// sprite/blending are its own (a glow child can be additive over a +/// normal-blended parent); both stages draw into the same canvas. +pub struct ChildSystem { + child_type: ChildType, + probability: f32, + max_instances: usize, + config: ParticleConfig, + sprite: Option, + additive: bool, + instances: Vec, +} + +struct ChildInstance { + /// `Some` while following a live parent particle (`eventfollow`); + /// cleared when the parent dies so the instance stops emitting and + /// retires once its remaining particles expire. + parent_id: Option, + /// Where this instance's emitters currently sit (parent particle + /// position for `eventfollow`, event position for bursts). + last_center: [f32; 2], + /// Burst instances (`eventspawn`/`eventdeath`) stop emitting at this + /// system-time cutoff — the preset itself has no end marker. + emit_until: Option, + /// `static` children run for the parent system's whole life and are + /// never retired, even while momentarily empty between spawns. + persistent: bool, + system: ParticleSystem, +} + +struct EmitterState { + id: u32, + rate: f32, + origin: [f32; 3], + directions: [f32; 3], + sign: [f32; 3], + /// True for `sphererandom` (annulus/shell spawn), false for box. + is_sphere: bool, + /// Spawn-offset range: sphere radius in `[0]` (min/max annulus), or the + /// box emitter's per-axis half-extents. CParticle.cpp spawns particles + /// randomly WITHIN this volume, scaled per-axis by `directions` — + /// distancemax is a POSITION range, never a velocity. + distance_min: [f32; 3], + distance_max: [f32; 3], + /// Optional emitter-driven radial speed (`speedmin`/`speedmax` JSON + /// fields — distinct from distance!). 0 = the reference's default: + /// emitters set no velocity, initializers do. + speed_min: f32, + speed_max: f32, + accumulator: f32, + /// `instantaneous` one-shot burst count, emitted once when the emitter + /// activates; `burst_done` tracks that it's fired. + burst: u32, + burst_done: bool, + /// `duration`: emitter stops after `start_time + duration` (None = forever). + duration: Option, + /// Set when the emitter has `audioprocessing*` — its rate follows the + /// audio spectrum. + audio: Option, +} + +impl ParticleSystem {} + +fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} + +// Minimal vec3 math for the turbulent/vortex ports (the reference uses glm; +// pulling in a linear-algebra crate for five one-liners isn't worth it). + +fn lerp_u8(min: f32, max: f32) -> u8 { + (min + fastrand::f32() * (max - min)).clamp(0.0, 255.0) as u8 +} + +/// Linear ramp from `start_val` to `end_val` over `[start, end]` of `t`, +/// clamped flat outside that range. Matches `Maths::fadeValue` exactly. +fn fade_value(t: f32, start: f32, end: f32, start_val: f32, end_val: f32) -> f32 { + if t <= start { + start_val + } else if t >= end { + end_val + } else { + let f = (t - start) / (end - start); + start_val + f * (end_val - start_val) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/engine/sim/particle/operators.rs b/src/engine/sim/particle/operators.rs new file mode 100644 index 0000000..1eba7a6 --- /dev/null +++ b/src/engine/sim/particle/operators.rs @@ -0,0 +1,711 @@ +//! Operator parameters parsed from the config (oscillation, turbulence, +//! vortex, collision, boids, remap) and the vector math the operators use. + +use super::*; + +/// Parsed `oscillatealpha`/`oscillatesize`/`oscillateposition` operator +/// config — random ranges each spawned particle draws its own values from. +#[derive(Clone, Copy)] +pub(super) struct OscillateParams { + pub(super) freq_min: f32, + pub(super) freq_max: f32, + pub(super) scale_min: f32, + pub(super) scale_max: f32, + pub(super) phase_min: f32, + pub(super) phase_max: f32, +} + +impl OscillateParams { + pub(super) fn from_operator(op: &Operator) -> Self { + let freq_min = op.frequencymin.unwrap_or(0.0) as f32; + let freq_max = op.frequencymax.unwrap_or(freq_min as f64) as f32; + let scale_min = op.scalemin.or(op.scalemax).unwrap_or(0.0) as f32; + let scale_max = op.scalemax.or(op.scalemin).unwrap_or(0.0) as f32; + let phase_min = op.phasemin.unwrap_or(0.0) as f32; + let phase_max = op.phasemax.unwrap_or(0.0) as f32; + Self { + freq_min, + freq_max, + scale_min, + scale_max, + phase_min, + phase_max, + } + } + + /// CParticle.cpp always draws phase from `[phaseMin, phaseMax + 2*PI]` — + /// even with no phase fields at all (both 0), this yields a fully random + /// phase in `[0, 2*PI]`, which is what real presets (lacking phase + /// fields entirely) actually rely on. + pub(super) fn sample(&self) -> Oscillator1D { + Oscillator1D { + frequency: self.freq_min + fastrand::f32() * (self.freq_max - self.freq_min), + scale: self.scale_min + fastrand::f32() * (self.scale_max - self.scale_min), + phase: self.phase_min + + fastrand::f32() * (self.phase_max + std::f32::consts::TAU - self.phase_min), + } + } +} + +/// `turbulentvelocityrandom` (CParticle.cpp): spawn velocity picked from a +/// curl-noise field, angle-limited around `forward`. +#[derive(Clone, Copy)] +pub(super) struct TurbulentVelocityParams { + pub(super) speed_min: f32, + pub(super) speed_max: f32, + /// Tilt angle (radians) around `right`. + pub(super) offset: f32, + /// Direction cone: `< 2` limits deviation from `forward` to + /// `scale/2 * pi`. + pub(super) scale: f32, + pub(super) forward: [f32; 3], + pub(super) right: [f32; 3], + pub(super) timescale: f32, + pub(super) phase_min: f32, + pub(super) phase_max: f32, +} + +/// `turbulence` operator (CParticle.cpp): curl-noise acceleration. `phase` +/// and `speed` are drawn once per operator instance, not per particle. +#[derive(Clone, Copy)] +pub(super) struct TurbulenceParams { + pub(super) noise_scale: f32, + pub(super) timescale: f32, + pub(super) mask: [f32; 3], + pub(super) phase: f32, + pub(super) speed: f32, +} + +/// `spritetrail` renderer: velocity-aligned, velocity-stretched sprite quad. +#[derive(Clone, Copy)] +pub(super) struct SpriteTrailParams { + pub(super) length: f32, + pub(super) max_length: f32, + pub(super) min_length: f32, +} + +/// `mapsequencebetweencontrolpoints` initializer: spawns successive +/// particles at evenly spaced points along the segment from control point +/// 0 to control point 1 (`count` slots). `limitbehavior: "mirror"` +/// ping-pongs the sequence; anything else wraps. Neither the C++ reference +/// nor its parser implements this one — semantics derived from real +/// content (the `discharge` preset draws a rope through the chain, +/// yielding a lightning arc between the two anchors). +#[derive(Clone, Copy)] +pub(super) struct MapSequenceBetweenParams { + pub(super) count: u32, + pub(super) mirror: bool, +} + +/// `mapsequencearoundcontrolpoint` initializer (CParticle.cpp): spawns each +/// particle at the control point with a velocity rotated by the next angle +/// in an evenly-divided circle (`sequence / count * 2pi`). +#[derive(Clone, Copy)] +pub(super) struct MapSequenceParams { + pub(super) control_point: usize, + pub(super) count: u32, + pub(super) speed_min: [f32; 3], + pub(super) speed_max: [f32; 3], +} + +/// `vortex` operator (CParticle.cpp): tangential spin around a control +/// point, with optional ring shape and center attraction. +#[derive(Clone, Copy)] +pub(super) struct VortexParams { + pub(super) control_point: i64, + pub(super) infinite_axis: bool, + pub(super) maintain_distance: bool, + pub(super) ring_shape: bool, + pub(super) axis: [f32; 3], + pub(super) offset: [f32; 3], + pub(super) distance_inner: f32, + pub(super) distance_outer: f32, + pub(super) speed_inner: f32, + pub(super) speed_outer: f32, + pub(super) center_force: f32, + pub(super) ring_radius: f32, + pub(super) ring_width: f32, + pub(super) ring_pull_distance: f32, + pub(super) ring_pull_force: f32, +} + +/// Collider shape for a `collisionsphere`/`collisionbox`/`collisionbounds`/ +/// `collisionquad`/`collisionplane` operator (see the `Operator` field docs +/// for the "no C++ reference, reasoned reconstruction" caveat). `Box` also +/// covers `collisionbounds`/`collisionquad` — this 2D engine has no distinct +/// notion of "world bounds" vs. "an oriented flat quad" vs. "a box", so all +/// three collapse to the same axis-aligned-rectangle test. +#[derive(Clone, Copy)] +pub(super) enum CollisionShape { + Sphere { + radius: f32, + }, + Box { + half_extent: [f32; 2], + }, + /// `normal` points away from the solid side (into free space). + Plane { + normal: [f32; 2], + }, +} + +/// `collisionbehavior`: what happens to a particle that penetrates a +/// collider this frame. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum CollisionBehavior { + Bounce, + Slide, + Delete, +} + +impl CollisionBehavior { + pub(super) fn parse(s: Option<&str>) -> Self { + match s { + Some("slide") => Self::Slide, + Some("delete") => Self::Delete, + _ => Self::Bounce, + } + } +} + +/// One resolved collider: anchored at a `controlpoint` (+ `offset`), same +/// static-at-construction simplification `vortex`/`controlpointattract` +/// already accept for control points. +#[derive(Clone, Copy)] +pub(super) struct CollisionParams { + pub(super) shape: CollisionShape, + pub(super) center: [f32; 2], + pub(super) behavior: CollisionBehavior, + pub(super) bounce_factor: f32, +} + +/// `boids` operator: standard separation/alignment/cohesion flocking rules. +/// Real field names recovered from the binary (see the `Operator` field +/// docs); the flocking algorithm itself is the industry-standard +/// three-rule formulation, not independently verified against WE's own +/// (unrecoverable) per-pixel/per-particle GPU dispatch. +#[derive(Clone, Copy)] +pub(super) struct BoidsParams { + pub(super) separation_threshold: f32, + pub(super) neighbor_threshold: f32, + pub(super) max_speed: f32, + pub(super) separation_factor: f32, + pub(super) alignment_factor: f32, + pub(super) cohesion_factor: f32, + /// `variablestrength != 0`: jitter each particle's flocking-force + /// magnitude by a deterministic per-particle hash of its id, so a flock + /// doesn't move as one mechanically uniform block. + pub(super) variable_strength: bool, +} + +/// `remapvalue`'s `input`/`output` field selector. Real, named fields +/// recovered directly from the retail binary's own dispatch table (Ghidra +/// report Follow-up (x)); only the subset this engine already tracks +/// per-particle state for is implemented. The rest +/// (`distancetocontrolpoint`/`positionbetweentwocontrolpoints`/`runtime`/ +/// `timeofday`/`particlesystemtime`/`layertime`/`controlpoint`/ +/// `deltatocontrolpoint`/`directiontocontrolpoint`/`layerorigin`) are real +/// names too, but need control-point/scene-clock plumbing this operator +/// doesn't have access to and no cached real content (271 Workshop items +/// checked) exercises any of them — falling through to `None` (noise mode) +/// rather than silently misreading a wrong field. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(super) enum RemapField { + /// No `input` configured: sample noise at the particle position/time — + /// the original, real-content-validated behavior, unchanged. + None, + LifetimeFraction, + MaxLifetime, + Size, + Opacity, + Speed, + Rotation, + AngularSpeed, + Color, + Position, + Velocity, +} + +impl RemapField { + pub(super) fn parse(s: Option<&str>) -> Self { + match s { + Some("lifetimefraction") => Self::LifetimeFraction, + Some("maxlifetime") => Self::MaxLifetime, + Some("size") => Self::Size, + Some("opacity") => Self::Opacity, + Some("speed") => Self::Speed, + Some("rotation") => Self::Rotation, + Some("angularspeed") => Self::AngularSpeed, + Some("color") => Self::Color, + Some("position") => Self::Position, + Some("velocity") => Self::Velocity, + _ => Self::None, + } + } +} + +/// `remapvalue`'s `output` field — same real dispatch table as +/// [`RemapField`], restricted to fields this operator can write back to. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(super) enum RemapOutput { + Velocity, + Speed, + Size, + Opacity, + Rotation, + AngularSpeed, + Color, + Position, +} + +impl RemapOutput { + pub(super) fn parse(s: Option<&str>) -> Self { + match s { + Some("speed") => Self::Speed, + Some("size") => Self::Size, + Some("opacity") => Self::Opacity, + Some("rotation") => Self::Rotation, + Some("angularspeed") => Self::AngularSpeed, + Some("color") => Self::Color, + Some("position") => Self::Position, + // "velocity" and anything unrecognized: existing default. + _ => Self::Velocity, + } + } +} + +/// `remapvalue`'s `operation` — how the remapped value combines with the +/// output field's current value. `Multiply` is corroborated directly: the +/// retail binary's own `alphafade`-family default-preset builders +/// (`FUN_1401bfbb0`/`FUN_1401bc4b0`, Follow-up (x)) construct exactly +/// `operation=multiply, input=lifetimefraction` to fade a field by a +/// lifetime-driven factor — the same shape as this engine's own +/// (independently, already-validated) `alphafade` — so "multiply the +/// current value" is a corroborated reading, not a guess. `Add`/`Subtract` +/// follow the same current-value-combine pattern; no real cached content +/// exercises them to cross-check against. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(super) enum RemapOp { + /// Default: replace the output field outright (the original, + /// real-content-validated `remapvalue` behavior). + Remap, + Multiply, + Add, + Subtract, +} + +impl RemapOp { + pub(super) fn parse(s: Option<&str>) -> Self { + match s { + Some("multiply") => Self::Multiply, + Some("add") => Self::Add, + Some("subtract") => Self::Subtract, + _ => Self::Remap, + } + } + + pub(super) fn combine(self, current: f32, remapped: f32) -> f32 { + match self { + Self::Remap => remapped, + Self::Multiply => current * remapped, + Self::Add => current + remapped, + Self::Subtract => current - remapped, + } + } +} + +/// `remapvalue`'s `transformfunction` — shapes the normalized `[0,1]` input +/// before it's remapped into the output range. The retail binary gave up +/// only the *names* (Follow-up (x)), not formulas: `Sine`/`Square`/ +/// `Triangle` are the standard one-period constructions for each name, an +/// honest best-effort reading rather than a verified port. `Saw` is a +/// linear ramp over a single non-wrapping `[0,1]` pass, which makes it +/// indistinguishable here from `None` — documented, not hidden. +/// `Simplex`/`Fbm` are unchanged from before: real, position/time-driven +/// noise sampling, validated against real content +/// (`particles/presets/rain_screen.json`). +#[derive(Clone, Copy, PartialEq, Debug)] +pub(super) enum RemapTransform { + None, + Sine, + Square, + Saw, + Triangle, + Simplex, + Fbm, +} + +impl RemapTransform { + /// Parses an explicit, non-empty `transformfunction` value. Whether an + /// *absent* key defaults to `Simplex` (noise generator) or `None` + /// (passthrough) depends on whether `input` was also given — see the + /// call site in `ParticleSystem::new` — so that's handled there, not + /// here. + pub(super) fn parse(s: &str) -> Self { + match s { + "none" => Self::None, + "sine" => Self::Sine, + "square" => Self::Square, + "saw" => Self::Saw, + "triangle" => Self::Triangle, + "fbmnoise" => Self::Fbm, + // "simplexnoise" and anything unrecognized. + _ => Self::Simplex, + } + } + + pub(super) fn is_noise(self) -> bool { + matches!(self, Self::Simplex | Self::Fbm) + } + + /// Shape an already-clamped `[0,1]` value. Noise variants are handled + /// separately (via position/time sampling, not this function). + pub(super) fn shape(self, t: f32) -> f32 { + match self { + Self::None | Self::Saw | Self::Simplex | Self::Fbm => t, + Self::Sine => 0.5 + 0.5 * (t * std::f32::consts::TAU).sin(), + Self::Square => { + if t < 0.5 { + 0.0 + } else { + 1.0 + } + } + Self::Triangle => 1.0 - (2.0 * t - 1.0).abs(), + } + } +} + +/// `remapvalue`'s `inputcomponent`/`outputcomponent` — real, named +/// selectors recovered from the retail binary (Follow-up (x)). +#[derive(Clone, Copy, PartialEq, Debug)] +pub(super) enum RemapComponent { + All, + X, + Y, + Z, + Sum, + Average, + Max, + Min, +} + +impl RemapComponent { + pub(super) fn parse(s: Option<&str>) -> Self { + match s { + Some("x") => Self::X, + Some("y") => Self::Y, + Some("z") => Self::Z, + Some("sum") => Self::Sum, + Some("average") => Self::Average, + Some("max") => Self::Max, + Some("min") => Self::Min, + _ => Self::All, + } + } + + /// Collapse a (2- or 3-axis) vector to one scalar, for reading a + /// vector-valued input field. `All`/`Average` both average — a vector + /// input must collapse to one scalar for the remap math regardless, and + /// averaging is the least-arbitrary choice among these (stays in the + /// field's native range, unlike `Sum`, which scales with axis count). + pub(super) fn reduce(self, v: [f32; 3], axes: usize) -> f32 { + let axes = axes.min(3); + match self { + Self::X => v[0], + Self::Y => v[1], + Self::Z => v[2], + Self::Sum => v[..axes].iter().sum(), + Self::Max => v[..axes].iter().cloned().fold(f32::MIN, f32::max), + Self::Min => v[..axes].iter().cloned().fold(f32::MAX, f32::min), + Self::All | Self::Average => v[..axes].iter().sum::() / axes as f32, + } + } +} + +/// `remapvalue`: reads `input` (a named particle field, or noise sampled at +/// the particle position when unset), shapes it, remaps it into +/// `[out_min, out_max]`, and combines it into `output` via `operation`. See +/// [`RemapField`]/[`RemapOutput`]/[`RemapOp`]/[`RemapTransform`] for what's +/// grounded in the retail binary vs. an honest best-effort reading. +#[derive(Clone, Copy)] +pub(super) struct RemapParams { + pub(super) input: RemapField, + pub(super) input_component: RemapComponent, + pub(super) output: RemapOutput, + pub(super) output_component: RemapComponent, + pub(super) operation: RemapOp, + pub(super) transform: RemapTransform, + pub(super) in_min: f32, + pub(super) in_max: f32, + pub(super) out_min: [f32; 3], + pub(super) out_max: [f32; 3], + pub(super) input_scale: f32, +} + +impl RemapParams { + /// Read the configured input field as a single scalar (vector fields + /// collapse via `input_component`). Only meaningful when `input != + /// RemapField::None` — noise mode is handled entirely in `sample01`. + pub(super) fn read_input(&self, p: &Particle, lifetime_pos: f32, max_life: f32) -> f32 { + match self.input { + RemapField::None => 0.0, + RemapField::LifetimeFraction => lifetime_pos, + RemapField::MaxLifetime => max_life, + RemapField::Size => p.size, + RemapField::Opacity => p.alpha, + RemapField::Speed => (p.vx * p.vx + p.vy * p.vy).sqrt(), + RemapField::Rotation => p.rotation, + RemapField::AngularSpeed => p.angular_velocity, + RemapField::Color => self.input_component.reduce( + [ + p.color[0] as f32 / 255.0, + p.color[1] as f32 / 255.0, + p.color[2] as f32 / 255.0, + ], + 3, + ), + RemapField::Position => self.input_component.reduce([p.x, p.y, 0.0], 2), + RemapField::Velocity => self.input_component.reduce([p.vx, p.vy, 0.0], 2), + } + } + + /// The shaped `[0,1]` value to lerp into `[out_min, out_max]`. + /// `axis_seed` only matters in noise mode (a different seed per output + /// axis, exactly the original x/y-decorrelation this operator always + /// used); field mode ignores it, which is what gives a scalar field's + /// single value its "broadcast to every output axis" behavior. + pub(super) fn sample01( + &self, + axis_seed: f64, + p: &Particle, + time: f32, + lifetime_pos: f32, + max_life: f32, + ) -> f32 { + if self.input == RemapField::None || self.transform.is_noise() { + use crate::engine::sim::noise::fbm_noise; + let s = self.input_scale; + let (x, y, t) = ((p.x * s) as f64, (p.y * s) as f64, time as f64); + let octaves = if self.transform == RemapTransform::Fbm { + 4 + } else { + 1 + }; + let n = fbm_noise(x + axis_seed, y, t, octaves); + ((n * 0.5 + 0.5) as f32).clamp(0.0, 1.0) // [-1,1] → [0,1] + } else { + let raw = self.read_input(p, lifetime_pos, max_life); + let range = (self.in_max - self.in_min).abs().max(1e-6); + let normalized = ((raw - self.in_min) / range).clamp(0.0, 1.0); + self.transform.shape(normalized) + } + } + + /// Apply this operator to a 2- or 3-axis vector field (`velocity`/ + /// `position`/`color`), honoring `output_component` (writes only that + /// one axis, leaving the rest of `current` untouched) or every in-range + /// axis when it's `All`. Distinct per-axis noise seeds match the + /// original x/y-decorrelated noise sampling exactly; field-mode inputs + /// naturally broadcast the same value to every written axis instead + /// (`sample01` ignores the seed outside noise mode). + pub(super) fn apply_vector( + &self, + p: &Particle, + time: f32, + lifetime_pos: f32, + current: [f32; 3], + axes: usize, + ) -> [f32; 3] { + let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t; + let seeds = [0.0f64, 13.7, 27.4]; + let mut out = current; + let apply_axis = |axis: usize, out: &mut [f32; 3]| { + let t = self.sample01(seeds[axis], p, time, lifetime_pos, p.max_life); + let remapped = lerp(self.out_min[axis], self.out_max[axis], t); + out[axis] = self.operation.combine(current[axis], remapped); + }; + match self.output_component { + RemapComponent::X => apply_axis(0, &mut out), + RemapComponent::Y if axes >= 2 => apply_axis(1, &mut out), + RemapComponent::Z if axes >= 3 => apply_axis(2, &mut out), + _ => { + for axis in 0..axes { + apply_axis(axis, &mut out); + } + } + } + out + } +} + +/// `boids` operator: O(n^2) separation/alignment/cohesion, using each +/// particle's position/velocity as of the start of the frame (the caller +/// snapshots `self.particles` before any per-particle mutation happens this +/// step). Fine for the particle counts real presets use (`maxcount` is +/// typically in the hundreds, not thousands). +pub(super) fn compute_boids_accel(particles: &[Particle], b: &BoidsParams) -> Vec<[f32; 2]> { + let n = particles.len(); + let mut accel = vec![[0.0f32; 2]; n]; + for i in 0..n { + let pi = &particles[i]; + let mut separation = [0.0f32; 2]; + let mut sep_count: u32 = 0; + let mut avg_vel = [0.0f32; 2]; + let mut avg_pos = [0.0f32; 2]; + let mut neighbor_count: u32 = 0; + for (j, pj) in particles.iter().enumerate() { + if i == j { + continue; + } + let d = [pi.x - pj.x, pi.y - pj.y]; + let dist = (d[0] * d[0] + d[1] * d[1]).sqrt(); + if dist > 0.0001 && dist < b.separation_threshold { + let f = 1.0 / dist; + separation[0] += d[0] * f; + separation[1] += d[1] * f; + sep_count += 1; + } + if dist < b.neighbor_threshold { + avg_vel[0] += pj.vx; + avg_vel[1] += pj.vy; + avg_pos[0] += pj.x; + avg_pos[1] += pj.y; + neighbor_count += 1; + } + } + let mut a = [0.0f32; 2]; + if sep_count > 0 { + let n = sep_count as f32; + a[0] += separation[0] / n * b.separation_factor; + a[1] += separation[1] / n * b.separation_factor; + } + if neighbor_count > 0 { + let n = neighbor_count as f32; + a[0] += (avg_vel[0] / n - pi.vx) * b.alignment_factor; + a[1] += (avg_vel[1] / n - pi.vy) * b.alignment_factor; + a[0] += (avg_pos[0] / n - pi.x) * b.cohesion_factor; + a[1] += (avg_pos[1] / n - pi.y) * b.cohesion_factor; + } + accel[i] = a; + } + accel +} + +/// Apply one collider's contact response to a particle already moved this +/// frame — see `CollisionShape`/`CollisionBehavior` docs for the "no C++ +/// reference" caveat on exact semantics. +pub(super) fn resolve_collision(p: &mut Particle, c: &CollisionParams) { + // `normal` points from the collider's solid surface toward free space; + // `penetration` is how far past the surface the particle currently is + // (<= 0.0 means "not colliding", skipped by the caller's-eye-view check + // below in each shape branch). + let (normal, penetration): ([f32; 2], f32) = match c.shape { + CollisionShape::Sphere { radius } => { + let d = [p.x - c.center[0], p.y - c.center[1]]; + let dist = (d[0] * d[0] + d[1] * d[1]).sqrt(); + if dist >= radius || dist < 1e-5 { + return; + } + ([d[0] / dist, d[1] / dist], radius - dist) + } + CollisionShape::Box { half_extent } => { + let d = [p.x - c.center[0], p.y - c.center[1]]; + if d[0].abs() >= half_extent[0] || d[1].abs() >= half_extent[1] { + return; + } + let px = half_extent[0] - d[0].abs(); + let py = half_extent[1] - d[1].abs(); + if px < py { + ([d[0].signum(), 0.0], px) + } else { + ([0.0, d[1].signum()], py) + } + } + CollisionShape::Plane { normal } => { + let d = [p.x - c.center[0], p.y - c.center[1]]; + let signed = d[0] * normal[0] + d[1] * normal[1]; + if signed >= 0.0 { + return; + } + (normal, -signed) + } + }; + + match c.behavior { + CollisionBehavior::Delete => { + p.life = -1.0; + } + CollisionBehavior::Slide => { + p.x += normal[0] * penetration; + p.y += normal[1] * penetration; + let vn = p.vx * normal[0] + p.vy * normal[1]; + if vn < 0.0 { + p.vx -= normal[0] * vn; + p.vy -= normal[1] * vn; + } + } + CollisionBehavior::Bounce => { + p.x += normal[0] * penetration; + p.y += normal[1] * penetration; + let vn = p.vx * normal[0] + p.vy * normal[1]; + if vn < 0.0 { + let restitution = 1.0 + c.bounce_factor; + p.vx -= normal[0] * vn * restitution; + p.vy -= normal[1] * vn * restitution; + } + } + } +} + +/// Wraps an angle to `[-pi, pi]`, matching `createAngularMovementOperator`'s +/// per-axis wrap (CParticle.cpp). +pub(super) fn wrap_angle(a: f32) -> f32 { + let mut a = a % std::f32::consts::TAU; + if a > std::f32::consts::PI { + a -= std::f32::consts::TAU; + } else if a < -std::f32::consts::PI { + a += std::f32::consts::TAU; + } + a +} + +pub(super) fn vec3_dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +pub(super) fn vec3_cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +pub(super) fn vec3_length(v: [f32; 3]) -> f32 { + vec3_dot(v, v).sqrt() +} + +/// Normalize, or return `fallback` for a (near-)zero vector — the +/// reference's recurring `length > 0.0001 ? normalize(v) : default` guard. +pub(super) fn vec3_normalize_or(v: [f32; 3], fallback: [f32; 3]) -> [f32; 3] { + let len = vec3_length(v); + if len > 0.0001 { + [v[0] / len, v[1] / len, v[2] / len] + } else { + fallback + } +} + +/// Rotate `v` by `angle` radians around unit-length `axis` (Rodrigues' +/// formula — equivalent to the reference's `glm::rotate` mat3 use). +pub(super) fn vec3_rotate_axis(v: [f32; 3], axis: [f32; 3], angle: f32) -> [f32; 3] { + let (sin, cos) = angle.sin_cos(); + let cross = vec3_cross(axis, v); + let dot = vec3_dot(axis, v) * (1.0 - cos); + [ + v[0] * cos + cross[0] * sin + axis[0] * dot, + v[1] * cos + cross[1] * sin + axis[1] * dot, + v[2] * cos + cross[2] * sin + axis[2] * dot, + ] +} diff --git a/src/engine/sim/particle/step.rs b/src/engine/sim/particle/step.rs new file mode 100644 index 0000000..84a76de --- /dev/null +++ b/src/engine/sim/particle/step.rs @@ -0,0 +1,898 @@ +//! Simulation: advancing a system by one frame (emission, initializers, +//! operators, ageing) and its child systems, plus the inputs that steer it +//! (scene forces, audio bands, instance values, origin shifts). + +use super::*; + +impl ParticleSystem { + /// Change one `instanceoverride` value live (SceneScript + /// `thisLayer.instance. = …`). `count` (the pool size) only applies + /// at load and is ignored here. + pub fn set_instance_value(&mut self, key: &str, value: &serde_json::Value) { + let num = || match value { + serde_json::Value::Number(n) => n.as_f64().map(|v| v as f32), + serde_json::Value::String(s) => s.trim().parse().ok(), + _ => None, + }; + let vec3 = || -> Option<[f32; 3]> { + match value { + serde_json::Value::Array(a) if a.len() >= 3 => { + Some(std::array::from_fn(|i| a[i].as_f64().unwrap_or(0.0) as f32)) + } + other => value_as_vec3_pub(other), + } + }; + match key { + "alpha" => self.alpha_mult = num().unwrap_or(self.alpha_mult), + "rate" => self.rate_mult = num().unwrap_or(self.rate_mult).max(0.0), + "size" => self.size_mult = num().unwrap_or(self.size_mult), + "speed" => self.speed_mult = num().unwrap_or(self.speed_mult), + "lifetime" => self.lifetime_mult = num().unwrap_or(self.lifetime_mult), + "colorn" => self.colorn_mult = vec3().unwrap_or(self.colorn_mult), + "color" => { + if let Some(c) = vec3() { + self.color_override = Some(c.map(|v| v.clamp(0.0, 255.0) as u8)); + } + } + _ => {} + } + } + + /// Apply the scene-global constant force (`general.gravity*`/`wind*`). + pub fn set_scene_force(&mut self, force: [f32; 2]) { + self.scene_force = force; + } + + /// Set the 16-band spectrum (~[0,1] per band) driving audio-reactive emitters. + pub fn set_audio_bands(&mut self, bands: [f32; 16]) { + self.audio_bands = bands; + } + + /// Tell the system its resolved sprite's frame count/loop duration, so + /// `step()` can advance each particle's `frame` — called once by the + /// caller right after resolving the sprite (this module itself never + /// loads textures). A no-op frame count of 0/1 leaves every particle's + /// `frame` at its spawn sentinel, matching `render_onto`'s "always frame + /// 0" behavior for non-animated sprites. + pub fn set_sprite_frames(&mut self, frame_count: usize, duration: f32) { + self.sprite_frame_count = frame_count; + self.sprite_duration = duration; + } + + pub fn step(&mut self, dt: f32) { + self.time += dt; + // `boids`: needs every particle's position/velocity as of the START + // of this frame (not yet mutated by this frame's own movement), so + // it's computed as a separate read-only pass before the main + // per-particle loop mutates anything. + let boids_accel = self + .boids + .as_ref() + .map(|b| compute_boids_accel(&self.particles, b)); + for (particle_index, p) in self.particles.iter_mut().enumerate() { + // `movement` operator (CParticle.cpp createMovementOperator): + // position integrates the CURRENT velocity first, and only then + // do this frame's forces modify velocity (for the next frame) — + // gravity scaled by the instance speed override, then drag decay + // clamped so `drag*dt > 1` stops the particle instead of + // reversing it. + p.x += p.vx * dt; + p.y += p.vy * dt; + p.vx += (self.gravity[0] * self.speed_mult + self.scene_force[0]) * dt; + p.vy += (self.gravity[1] * self.speed_mult + self.scene_force[1]) * dt; + let drag_factor = (1.0 - self.movement_drag * dt).max(0.0); + p.vx *= drag_factor; + p.vy *= drag_factor; + + // `turbulence` operator (CParticle.cpp createTurbulenceOperator): + // normalized curl-noise direction, sampled at a time-scrolled, + // scaled particle position, masked per axis, added to velocity. + if let Some(t) = self.turbulence { + if t.speed > 0.0001 { + let noise_pos = [ + (p.x + t.phase + t.timescale * self.time) * t.noise_scale, + p.y * t.noise_scale, + 0.0, + ]; + let curl = crate::engine::sim::noise::curl_noise(noise_pos); + let len = vec3_length(curl); + if len > 0.0001 { + let s = t.speed / len * dt * self.speed_mult; + p.vx += curl[0] * t.mask[0] * s; + p.vy += curl[1] * t.mask[1] * s; + } + } + } + + // `vortex` operator (CParticle.cpp createVortexOperator). + if let Some(v) = self.vortex { + let center_base = self + .control_points + .get(v.control_point.max(0) as usize) + .copied() + .unwrap_or([0.0; 3]); + let center = [ + center_base[0] + v.offset[0], + center_base[1] + v.offset[1], + center_base[2] + v.offset[2], + ]; + // The reference computes tangents in its y-up sim space and + // projects y-up; our space is y-down (a reflection, det -1), + // under which cross products flip sign: mirror the axis's y + // and negate the resulting tangent to reproduce the same + // on-screen spin direction. + let axis = vec3_normalize_or([v.axis[0], -v.axis[1], v.axis[2]], [0.0, 0.0, 1.0]); + let to_particle = [p.x - center[0], p.y - center[1], -center[2]]; + + // Infinite axis: cylinder shape (project out the axis + // component); else full 3D distance (sphere shape). + let radial = if v.infinite_axis { + let axial = vec3_dot(to_particle, axis); + [ + to_particle[0] - axis[0] * axial, + to_particle[1] - axis[1] * axial, + to_particle[2] - axis[2] * axial, + ] + } else { + to_particle + }; + let distance = vec3_length(radial); + + let tangent = vec3_cross(axis, radial); + let tangent_len = vec3_length(tangent); + if tangent_len > 0.001 { + // Negated: see the reflection note on `axis` above. + let tangent = [ + -tangent[0] / tangent_len, + -tangent[1] / tangent_len, + -tangent[2] / tangent_len, + ]; + + let mut speed = 0.0; + let mut radial_force = [0.0f32; 3]; + if v.ring_shape { + let ring_inner = v.ring_radius - v.ring_width * 0.5; + let ring_outer = v.ring_radius + v.ring_width * 0.5; + if distance < ring_inner { + // Hollow center: no spin. + } else if distance <= ring_outer { + let t = (distance - ring_inner) / v.ring_width; + speed = v.speed_inner + (v.speed_outer - v.speed_inner) * t; + } else if distance <= ring_outer + v.ring_pull_distance { + let pull_t = (distance - ring_outer) / v.ring_pull_distance; + speed = v.speed_outer * (1.0 - pull_t); + if distance > 0.001 { + let toward = vec3_normalize_or(radial, [0.0; 3]); + let f = -v.ring_pull_force * pull_t; + radial_force = [toward[0] * f, toward[1] * f, toward[2] * f]; + } + } + } else { + let dis_mid = v.distance_outer - v.distance_inner + 0.1; + speed = if dis_mid < 0.0 || distance < v.distance_inner { + v.speed_inner + } else if distance > v.distance_outer { + v.speed_outer + } else { + let t = (distance - v.distance_inner) / dis_mid; + v.speed_inner + (v.speed_outer - v.speed_inner) * t + }; + } + + let k = dt * self.speed_mult; + p.vx += (tangent[0] * speed + radial_force[0]) * k; + p.vy += (tangent[1] * speed + radial_force[1]) * k; + if v.maintain_distance && distance > 0.001 { + let toward = vec3_normalize_or(radial, [0.0; 3]); + p.vx -= toward[0] * v.center_force * k; + p.vy -= toward[1] * v.center_force * k; + } + } + } + + // `boids` operator: apply this particle's precomputed + // separation/alignment/cohesion acceleration, then cap speed. + if let (Some(accel), Some(b)) = (&boids_accel, &self.boids) { + let [ax, ay] = accel[particle_index]; + let strength = if b.variable_strength { + // Deterministic per-particle jitter in [0.5, 1.5] from a + // cheap integer hash of the particle's stable id — avoids + // adding a new per-particle RNG-state field for a value + // that only needs to be stable, not independently random + // each frame. + let h = p.id.wrapping_mul(2654435761); + 0.5 + ((h >> 8) & 0xFFFF) as f32 / 65535.0 + } else { + 1.0 + }; + let k = dt * self.speed_mult * strength; + p.vx += ax * k; + p.vy += ay * k; + if b.max_speed > 0.0 { + let speed2 = p.vx * p.vx + p.vy * p.vy; + if speed2 > b.max_speed * b.max_speed { + let s = b.max_speed / speed2.sqrt(); + p.vx *= s; + p.vy *= s; + } + } + } + + // `remapvalue` targeting `velocity`/`speed`: runs here (before + // this frame's velocity is used for next frame's position + // integration), matching the original, real-content-validated + // timing exactly. Fields targeting other outputs run later, + // after alpha/size/color are computed (see below) so + // `remapvalue` can override them. + let lifetime_pos_pre = if p.max_life > 0.0 { + ((p.max_life - p.life) / p.max_life).clamp(0.0, 1.0) + } else { + 1.0 + }; + for r in &self.remap { + match r.output { + RemapOutput::Velocity => { + let [vx, vy, _] = + r.apply_vector(p, self.time, lifetime_pos_pre, [p.vx, p.vy, 0.0], 2); + p.vx = vx; + p.vy = vy; + } + RemapOutput::Speed => { + let t = r.sample01(0.0, p, self.time, lifetime_pos_pre, p.max_life); + let target = r.out_min[0] + (r.out_max[0] - r.out_min[0]) * t; + let mag = (p.vx * p.vx + p.vy * p.vy).sqrt(); + let sp = r.operation.combine(mag, target); + if mag > 1e-4 { + let k = sp / mag; + p.vx *= k; + p.vy *= k; + } + } + _ => {} + } + } + + // Position-oscillate integrates a velocity (derivative of + // scale*cos(w*t+phase)) into position each frame, matching + // CParticle.cpp's createOscillatePositionOperator exactly. + if let Some(osc) = &p.osc_pos { + let age = p.max_life - p.life; + for axis in 0..2 { + let w = osc.frequency[axis]; + let d = -osc.scale[axis] * w * (w * age + osc.phase[axis]).sin() * dt; + if axis == 0 { + p.x += d; + } else { + p.y += d; + } + } + } + + if let Some((idx, origin, scale, threshold)) = self.control_point_attract { + if let Some(cp) = self.control_points.get(idx) { + let center = [cp[0] + origin[0], cp[1] + origin[1]]; + let to_center = [center[0] - p.x, center[1] - p.y]; + let distance = + (to_center[0] * to_center[0] + to_center[1] * to_center[1]).sqrt(); + let radius = threshold / 2.0; + if distance > 0.001 && distance < radius { + let force = scale * dt * self.speed_mult; + p.vx += (to_center[0] / distance) * force; + p.vy += (to_center[1] / distance) * force; + } + } + } + + // `collisionsphere`/`collisionbox`/`collisionbounds`/ + // `collisionquad`/`collisionplane`: checked last among the + // motion-affecting operators, against this frame's final + // position, so a collider reacts to where the particle actually + // ended up (matching how a physics response is conventionally + // the last word on a frame's motion). + for collider in &self.collisions { + resolve_collision(p, collider); + } + + p.life -= dt; + + // Reference order (CParticle.cpp createAngularMovementOperator): + // integrate rotation from the *current* angular velocity first, + // then accelerate angular velocity, then decay it by drag. + p.rotation += p.angular_velocity * dt; + p.angular_velocity += self.angular_force * dt; + let drag_factor = (1.0 - self.angular_drag * dt).max(0.0); + p.angular_velocity *= drag_factor; + p.rotation = wrap_angle(p.rotation); + + let age = p.max_life - p.life; + let lifetime_pos = if p.max_life > 0.0 { + (age / p.max_life).clamp(0.0, 1.0) + } else { + 1.0 + }; + + // Sprite-sheet frame advance (CParticle.cpp's "Update animation + // frames"): only meaningful once the caller has told us the + // resolved sprite actually has more than one frame. + if self.sprite_frame_count > 1 { + let frame_count = self.sprite_frame_count as f32; + match self.animation_mode { + AnimationMode::RandomFrame => { + if p.frame < 0.0 { + p.frame = (fastrand::f32() * frame_count) + .floor() + .min(frame_count - 1.0); + } + } + AnimationMode::Once => { + p.frame = (lifetime_pos * frame_count * self.sequence_multiplier) + .min(frame_count - 1.0); + } + AnimationMode::Loop => { + p.frame = if self.sprite_duration > 0.0 { + let time_in_cycle = + (age * self.sequence_multiplier) % self.sprite_duration; + let cycle_pos = time_in_cycle / self.sprite_duration; + (cycle_pos * frame_count) % frame_count + } else { + (lifetime_pos * frame_count * self.sequence_multiplier) % frame_count + }; + } + } + } + + // Alpha: alphafade takes precedence (its very common fade-in/hold/ + // fade-out shape), then a generic alphachange ramp, else fall back + // to a plain linear death-fade so particles don't hard-cut when a + // config has no alpha-affecting operator at all. + let mut alpha = if let Some((fade_in, fade_out)) = self.alphafade { + let fade = if lifetime_pos <= fade_in { + fade_value(lifetime_pos, 0.0, fade_in, 0.0, 1.0) + } else if lifetime_pos > fade_out { + 1.0 - fade_value(lifetime_pos, fade_out, 1.0, 0.0, 1.0) + } else { + 1.0 + }; + p.initial_alpha * fade + } else if let Some((start, end, sv, ev)) = self.alphachange { + p.initial_alpha * fade_value(lifetime_pos, start, end, sv, ev) + } else { + p.initial_alpha * (p.life / p.max_life).max(0.0) + }; + + let mut size = if let Some((start, end, sv, ev)) = self.sizechange { + p.initial_size * fade_value(lifetime_pos, start, end, sv, ev) + } else { + p.size + }; + + if let Some(osc) = &p.osc_alpha { + let cos_val = ((osc.frequency * age + osc.phase).cos() + 1.0) * 0.5; + let mult = self + .oscillate_alpha + .map(|o| o.scale_min + (o.scale_max - o.scale_min) * cos_val) + .unwrap_or(1.0); + alpha *= mult; + } + if let Some(osc) = &p.osc_size { + let cos_val = ((osc.frequency * age + osc.phase).cos() + 1.0) * 0.5; + let mult = self + .oscillate_size + .map(|o| o.scale_min + (o.scale_max - o.scale_min) * cos_val) + .unwrap_or(1.0); + size *= mult; + } + + if let Some((start, end, start_color, end_color)) = self.colorchange { + let mult = [ + fade_value(lifetime_pos, start, end, start_color[0], end_color[0]), + fade_value(lifetime_pos, start, end, start_color[1], end_color[1]), + fade_value(lifetime_pos, start, end, start_color[2], end_color[2]), + ]; + p.color = [ + (p.initial_color[0] * mult[0] * 255.0).clamp(0.0, 255.0) as u8, + (p.initial_color[1] * mult[1] * 255.0).clamp(0.0, 255.0) as u8, + (p.initial_color[2] * mult[2] * 255.0).clamp(0.0, 255.0) as u8, + ]; + } + + // `remapvalue` targeting anything other than velocity/speed + // (handled above, before position integration needs it): runs + // last, after alphafade/sizechange/colorchange/oscillate, so an + // author who explicitly attaches remapvalue to one of these + // fields gets it as the final, overriding value. + for r in &self.remap { + match r.output { + RemapOutput::Velocity | RemapOutput::Speed => {} + RemapOutput::Opacity => { + let t = r.sample01(0.0, p, self.time, lifetime_pos, p.max_life); + let remapped = r.out_min[0] + (r.out_max[0] - r.out_min[0]) * t; + alpha = r.operation.combine(alpha, remapped); + } + RemapOutput::Size => { + let t = r.sample01(0.0, p, self.time, lifetime_pos, p.max_life); + let remapped = r.out_min[0] + (r.out_max[0] - r.out_min[0]) * t; + size = r.operation.combine(size, remapped); + } + RemapOutput::Rotation => { + let t = r.sample01(0.0, p, self.time, lifetime_pos, p.max_life); + let remapped = r.out_min[0] + (r.out_max[0] - r.out_min[0]) * t; + p.rotation = r.operation.combine(p.rotation, remapped); + } + RemapOutput::AngularSpeed => { + let t = r.sample01(0.0, p, self.time, lifetime_pos, p.max_life); + let remapped = r.out_min[0] + (r.out_max[0] - r.out_min[0]) * t; + p.angular_velocity = r.operation.combine(p.angular_velocity, remapped); + } + RemapOutput::Color => { + let current = [ + p.color[0] as f32 / 255.0, + p.color[1] as f32 / 255.0, + p.color[2] as f32 / 255.0, + ]; + let out = r.apply_vector(p, self.time, lifetime_pos, current, 3); + p.color = [ + (out[0] * 255.0).clamp(0.0, 255.0) as u8, + (out[1] * 255.0).clamp(0.0, 255.0) as u8, + (out[2] * 255.0).clamp(0.0, 255.0) as u8, + ]; + } + RemapOutput::Position => { + let [x, y, _] = + r.apply_vector(p, self.time, lifetime_pos, [p.x, p.y, 0.0], 2); + p.x = x; + p.y = y; + } + } + } + + // `self.alpha_mult` (the instance-override multiplier) is already + // baked into `p.initial_alpha` at spawn, matching the + // reference's `p.alpha = random(min,max) * override` — applying + // it again here would square it whenever an override isn't 1.0. + p.alpha = alpha; + p.size = size; + } + + // Child-system event capture: deaths before the retain, spawns as + // they're pushed below (only bothering when children are attached). + let mut died: Vec<(u64, [f32; 2])> = Vec::new(); + let mut spawned: Vec<(u64, [f32; 2])> = Vec::new(); + if !self.children.is_empty() { + died.extend( + self.particles + .iter() + .filter(|p| p.life <= 0.0) + .map(|p| (p.id, [p.x, p.y])), + ); + } + self.particles.retain(|p| p.life > 0.0); + + // Hold off emission until `starttime` — existing particles still + // simulate and die, the burst just ramps in from zero at start_time. + let emitting = self.time >= self.start_time; + let since_start = self.time - self.start_time; + for emitter in &mut self.emitters { + // Per-emitter `duration` cutoff (after start_time). + let expired = emitter.duration.is_some_and(|d| since_start > d); + if !emitting || expired { + continue; + } + // One-shot burst: seed the accumulator with the full count once, so + // the spawn loop below emits it in this frame (capped by max_count). + if !emitter.burst_done { + emitter.accumulator += emitter.burst as f32; + emitter.burst_done = true; + } + let audio_gain = emitter.audio.map_or(1.0, |a| a.gain(&self.audio_bands)); + emitter.accumulator += emitter.rate * self.rate_mult * audio_gain * dt; + while emitter.accumulator >= 1.0 && self.particles.len() < self.max_count { + emitter.accumulator -= 1.0; + // `createLifetimeRandomInitializer`: `p.lifetime = + // random(min, max) * lifetimeOverride`. `exponent` biases the + // uniform sample toward one end before the lerp. + let life = (self.life_min + + fastrand::f32().powf(self.life_exp) + * (self.life_max - self.life_min).max(0.0)) + * self.lifetime_mult; + // Reference halves the sizerandom range (CParticle.cpp: `size = (min + + // t*(max-min)) * override / 2.0`) — the initializer's value is a + // diameter, not a radius. + let size = (self.size_min + + fastrand::f32().powf(self.size_exp) + * (self.size_max - self.size_min).max(0.0)) + * self.size_mult + / 2.0; + + let ox = emitter.origin[0]; + let oy = emitter.origin[1]; + + // Spawn offset — CParticle.cpp's create{Box,Sphere}Emitter: + // a random point WITHIN the emitter volume, scaled per-axis + // by `directions` (the emitter never encodes velocity in + // its distances; rainperspective's 1024 radius is a spawn + // band, which our old model collapsed into a ±15px column + // at the emitter origin while misreading the radius as a + // fallback speed). + let mut offset = if emitter.is_sphere { + let (rmin, rmax) = (emitter.distance_min[0], emitter.distance_max[0]); + if self.shell_3d { + // flags & 4 (perspective): 3D spherical shell, + // cbrt-uniform radius; our 2D sim keeps xy. + let theta = fastrand::f32() * std::f32::consts::TAU; + let cos_t = fastrand::f32() * 2.0 - 1.0; + let sin_t = (1.0 - cos_t * cos_t).sqrt(); + let r = + (rmin.powi(3) + fastrand::f32() * (rmax.powi(3) - rmin.powi(3))).cbrt(); + [sin_t * theta.cos() * r, sin_t * theta.sin() * r] + } else { + // 2D disk/annulus, sqrt-uniform for even area density. + let angle = fastrand::f32() * std::f32::consts::TAU; + let r2 = rmin * rmin + fastrand::f32() * (rmax * rmax - rmin * rmin); + let r = r2.max(0.0).sqrt(); + [angle.cos() * r, angle.sin() * r] + } + } else { + // Box: per-axis distance in [min, max], random sign flip. + let mut o = [0.0f32; 2]; + for (axis, out) in o.iter_mut().enumerate() { + let d = emitter.distance_min[axis] + + fastrand::f32() + * (emitter.distance_max[axis] - emitter.distance_min[axis]); + *out = if fastrand::f32() < 0.5 { -d } else { d }; + } + o + }; + offset[0] *= emitter.directions[0]; + offset[1] *= emitter.directions[1]; + // `sign`: 1 forces positive, -1 negative, 0 leaves both. + for axis in 0..2 { + if emitter.sign[axis] >= 1.0 { + offset[axis] = offset[axis].abs(); + } else if emitter.sign[axis] <= -1.0 { + offset[axis] = -offset[axis].abs(); + } + } + let mut spawn_x = ox + offset[0]; + let mut spawn_y = oy + offset[1]; + + // Velocity: velocityrandom initializer first; else the + // emitter's own optional radial speed (`speedmin`/`speedmax` + // fields, pointing outward from the offset); else zero — + // "Emitter does not set velocity - initializers handle that". + let (mut vx, mut vy) = + if let (Some(min), Some(max)) = (self.velocity_min, self.velocity_max) { + ( + (min[0] + fastrand::f32() * (max[0] - min[0])) * self.speed_mult, + (min[1] + fastrand::f32() * (max[1] - min[1])) * self.speed_mult, + ) + } else if emitter.speed_max > 0.0 || emitter.speed_min != 0.0 { + let len = (offset[0] * offset[0] + offset[1] * offset[1]).sqrt(); + let dir = if len > 0.0 { + [offset[0] / len, offset[1] / len] + } else { + [0.0, 1.0] + }; + let speed = (emitter.speed_min + + fastrand::f32() * (emitter.speed_max - emitter.speed_min)) + * self.speed_mult; + (dir[0] * speed, dir[1] * speed) + } else { + (0.0, 0.0) + }; + + // `mapsequencebetweencontrolpoints`: the Nth spawn sits at + // the Nth slot along the cp0->cp1 segment (mirror = + // ping-pong); velocityrandom still jitters it afterwards, + // which is what shapes the discharge preset's lightning. + if let Some(ms) = self.map_seq_between { + let last = ms.count - 1; + let idx = self.map_seq_between_index; + self.map_seq_between_index = self.map_seq_between_index.wrapping_add(1); + let slot = if ms.mirror { + let period = 2 * last; + let k = idx % period.max(1); + if k <= last { + k + } else { + period - k + } + } else { + idx % ms.count + }; + let t = slot as f32 / last as f32; + if let (Some(a), Some(b)) = + (self.control_points.first(), self.control_points.get(1)) + { + spawn_x = a[0] + (b[0] - a[0]) * t; + spawn_y = a[1] + (b[1] - a[1]) * t; + } + } + + // `mapsequencearoundcontrolpoint` (CParticle.cpp): spawn at + // the control point, launching successive particles at + // evenly-spaced angles around a circle (the initializer + // *assigns* position/velocity, unlike the additive ones). + if let Some(ms) = self.map_sequence { + let angle = + (self.map_sequence_index as f32 / ms.count as f32) * std::f32::consts::TAU; + self.map_sequence_index = (self.map_sequence_index + 1) % ms.count; + if let Some(cp) = self.control_points.get(ms.control_point) { + spawn_x = cp[0]; + spawn_y = cp[1]; + } + let sx = ms.speed_min[0] + + fastrand::f32() * (ms.speed_max[0] - ms.speed_min[0]).max(0.0); + let sy = ms.speed_min[1] + + fastrand::f32() * (ms.speed_max[1] - ms.speed_min[1]).max(0.0); + let (sin, cos) = angle.sin_cos(); + // The reference flips y, then rotates by + // (cos*x + sin*y, -sin*x + cos*y) (column-major + // glm::mat3); with the flip folded in that is the + // conjugate rotation followed by a y flip. + vx = (cos * sx - sin * sy) * self.speed_mult; + vy = -(sin * sx + cos * sy) * self.speed_mult; + } + + // `turbulentvelocityrandom` (CParticle.cpp): curl-noise + // spawn velocity, angle-limited around `forward`, added on + // top of any plain `velocityrandom` contribution. + for &t in &self.turbulent_velocity { + let forward = vec3_normalize_or(t.forward, [0.0, -1.0, 0.0]); + let right = vec3_normalize_or(t.right, [1.0, 0.0, 0.0]); + let speed = + t.speed_min + fastrand::f32() * (t.speed_max - t.speed_min).max(0.0); + let phase = + t.phase_min + fastrand::f32() * (t.phase_max - t.phase_min).max(0.0); + let time_shift = self.time * t.timescale; + let sample = [ + spawn_x * 0.1 + time_shift + phase, + spawn_y * 0.1 + time_shift + phase * 0.7, + time_shift + phase * 1.3, + ]; + let curl = crate::engine::sim::noise::curl_noise(sample); + let mut dir = vec3_normalize_or(curl, forward); + + // `scale` < 2 limits how far the direction may deviate + // from `forward` (normalized angle, max = scale/2); 0 + // means straight along `forward`. + if t.scale <= 0.0 { + dir = forward; + } else if t.scale < 2.0 { + let cos_angle = vec3_dot(dir, forward).clamp(-1.0, 1.0); + let angle = cos_angle.acos() / std::f32::consts::PI; + let max_angle = t.scale / 2.0; + if angle > max_angle { + let axis = vec3_cross(dir, forward); + if vec3_length(axis) > 0.0001 { + let axis = vec3_normalize_or(axis, [0.0, 0.0, 1.0]); + let rot = (angle - max_angle) * std::f32::consts::PI; + dir = vec3_rotate_axis(dir, axis, rot); + } + } + } + + // `offset` tilts the result around `right`. + if t.offset.abs() > 0.0001 { + dir = vec3_rotate_axis(dir, right, -t.offset); + } + + // 2D particles: project onto the XY plane and + // renormalize (the reference's `flags & 4 == 0` branch — + // our renderer is always orthographic/2D). + dir[2] = 0.0; + let dir = vec3_normalize_or(dir, forward); + + vx += dir[0] * speed * self.speed_mult; + vy += dir[1] * speed * self.speed_mult; + } + + // `rotationrandom`: uniform in [min.z, max.z], scaled by the + // instance speed override (the reference multiplies rotation + // by `speedOverride` too, odd as that reads). + let spawn_rotation = self + .rotation_range + .map(|(min, max)| { + (min + fastrand::f32() * (max - min).max(0.0)) * self.speed_mult + }) + .unwrap_or(0.0); + + // `createColorRandomInitializer`: `p.color = randomVec3(min, + // max) * colorOverride` — colorn is a 0-1 per-channel tint. + let color = self.color_override.unwrap_or_else(|| { + [ + (lerp_u8(self.color_min[0], self.color_max[0]) as f32 * self.colorn_mult[0]) + .clamp(0.0, 255.0) as u8, + (lerp_u8(self.color_min[1], self.color_max[1]) as f32 * self.colorn_mult[1]) + .clamp(0.0, 255.0) as u8, + (lerp_u8(self.color_min[2], self.color_max[2]) as f32 * self.colorn_mult[2]) + .clamp(0.0, 255.0) as u8, + ] + }); + + // `alpharandom`: CParticle.cpp's `p.alpha = random(min, max) * + // override` — a plain multiply (unlike sizerandom, not halved). + let spawn_alpha = (self.alpha_min + + fastrand::f32() * (self.alpha_max - self.alpha_min).max(0.0)) + * self.alpha_mult; + + let particle_id = self.next_particle_id; + self.next_particle_id += 1; + if !self.children.is_empty() { + spawned.push((particle_id, [spawn_x, spawn_y])); + } + self.particles.push(Particle { + id: particle_id, + x: spawn_x, + y: spawn_y, + vx, + vy, + life, + max_life: life, + size, + alpha: spawn_alpha, + color, + initial_alpha: spawn_alpha, + initial_size: size, + initial_color: [ + color[0] as f32 / 255.0, + color[1] as f32 / 255.0, + color[2] as f32 / 255.0, + ], + osc_alpha: self.oscillate_alpha.map(|o| o.sample()), + osc_size: self.oscillate_size.map(|o| o.sample()), + osc_pos: self.oscillate_position.map(|o| { + let a = o.sample(); + let b = o.sample(); + OscillatorPos { + frequency: [a.frequency, b.frequency], + scale: [a.scale, b.scale], + phase: [a.phase, b.phase], + } + }), + rotation: spawn_rotation, + angular_velocity: self.angular_velocity_min + + fastrand::f32() * (self.angular_velocity_max - self.angular_velocity_min), + frame: -1.0, + }); + } + } + + if !self.children.is_empty() { + self.step_children(dt, &spawned, &died); + } + } + + /// Attaches a resolved child preset (see [`ChildRef`]). `static` + /// children get their single persistent instance immediately, at the + /// same spawn center the parent was built with. + pub fn add_child( + &mut self, + config: ParticleConfig, + sprite: Option, + additive: bool, + child_ref: &ChildRef, + spawn_center: [f32; 2], + ) { + let child_type = match child_ref.child_type.as_deref() { + Some("eventfollow") => ChildType::Follow, + Some("eventspawn") => ChildType::Spawn, + Some("eventdeath") => ChildType::Death, + // Absent or "static": a continuously-running sub-system. + _ => ChildType::Static, + }; + let mut child = ChildSystem { + child_type, + probability: child_ref.probability.unwrap_or(1.0) as f32, + max_instances: child_ref.maxcount.unwrap_or(16).max(1) as usize, + config, + sprite, + additive, + instances: Vec::new(), + }; + if child_type == ChildType::Static { + let mut system = ParticleSystem::from_config(&child.config, spawn_center, None); + if let Some(s) = &child.sprite { + system.set_sprite_frames(s.frames.len(), s.duration); + } + child.instances.push(ChildInstance { + parent_id: None, + last_center: spawn_center, + emit_until: None, + persistent: true, + system, + }); + } + self.children.push(child); + } + + /// Spawns/updates/retires child instances for this frame's parent + /// spawn/death events. No counterpart in the C++ reference (it ignores + /// `children` entirely); semantics derived from real preset content. + pub(super) fn step_children( + &mut self, + dt: f32, + spawned: &[(u64, [f32; 2])], + died: &[(u64, [f32; 2])], + ) { + // Burst instances stop emitting after this long — the child preset + // itself has no end marker, and a one-shot puff is the authored + // intent of eventspawn/eventdeath (vs. a permanent stream). + const BURST_EMIT_SECS: f32 = 0.1; + + let mut children = std::mem::take(&mut self.children); + for child in &mut children { + let events: &[(u64, [f32; 2])] = match child.child_type { + ChildType::Static => &[], + ChildType::Follow | ChildType::Spawn => spawned, + ChildType::Death => died, + }; + for &(pid, pos) in events { + if child.instances.len() >= child.max_instances { + break; + } + if fastrand::f32() > child.probability { + continue; + } + let mut system = ParticleSystem::from_config(&child.config, pos, None); + if let Some(s) = &child.sprite { + system.set_sprite_frames(s.frames.len(), s.duration); + } + let follows = child.child_type == ChildType::Follow; + child.instances.push(ChildInstance { + parent_id: follows.then_some(pid), + last_center: pos, + emit_until: (!follows).then_some(BURST_EMIT_SECS), + persistent: false, + system, + }); + } + + for inst in &mut child.instances { + if let Some(pid) = inst.parent_id { + if let Some(p) = self.particles.iter().find(|p| p.id == pid) { + let delta = [p.x - inst.last_center[0], p.y - inst.last_center[1]]; + if delta != [0.0, 0.0] { + inst.system.shift_origin(delta); + inst.last_center = [p.x, p.y]; + } + } else { + // Parent died: stop emitting, let the remainder live out. + inst.parent_id = None; + inst.system.stop_emitting(); + } + } + if let Some(cutoff) = inst.emit_until { + if inst.system.time >= cutoff { + inst.system.stop_emitting(); + } + } + inst.system.step(dt); + } + + child.instances.retain(|inst| { + inst.persistent || inst.parent_id.is_some() || !inst.system.particles.is_empty() + }); + } + self.children = children; + } + + /// Moves every emitter origin and resolved control point by `delta` — + /// how an `eventfollow` child tracks its parent particle. + pub(super) fn shift_origin(&mut self, delta: [f32; 2]) { + for emitter in &mut self.emitters { + emitter.origin[0] += delta[0]; + emitter.origin[1] += delta[1]; + } + for cp in &mut self.control_points { + cp[0] += delta[0]; + cp[1] += delta[1]; + } + } + + pub(super) fn stop_emitting(&mut self) { + for emitter in &mut self.emitters { + emitter.rate = 0.0; + } + } +} diff --git a/src/engine/sim/particle/tests.rs b/src/engine/sim/particle/tests.rs new file mode 100644 index 0000000..7a7f533 --- /dev/null +++ b/src/engine/sim/particle/tests.rs @@ -0,0 +1,1517 @@ +use super::*; + +/// Real preset fields (velocityrandom, colorrandom, angularvelocityrandom) +/// use `"x y z"` vector strings for min/max, not numbers — the old +/// `Option` typing rejected these outright. +#[test] +fn parses_real_preset_with_vector_initializers() { + let json = r#"{ + "maxcount": 20, + "emitter": [{"name":"sphererandom","rate":1.5,"distancemin":0,"distancemax":512}], + "initializer": [ + {"id":2,"name":"lifetimerandom","min":3,"max":5}, + {"id":3,"name":"sizerandom","min":1000,"max":2200}, + {"id":4,"name":"velocityrandom","min":"25 0 0","max":"80 0 0"}, + {"id":5,"name":"colorrandom","min":"255 255 255","max":"255 255 255"} + ], + "operator": [{"id":9,"name":"movement","gravity":"0 0 0"}], + "renderer": [{"id":1,"name":"sprite"}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let sys = ParticleSystem::from_config(&config, [100.0, 200.0], None); + assert_eq!(sys.velocity_min, Some([25.0, 0.0, 0.0])); + assert_eq!(sys.velocity_max, Some([80.0, 0.0, 0.0])); + assert_eq!(sys.color_min, [255.0, 255.0, 255.0]); +} + +#[test] +fn scalar_initializer_without_vector_fields_still_parses() { + let json = r#"{ + "maxcount": 16, + "emitter": [{"name":"box","rate":1}], + "initializer": [{"id":2,"name":"lifetimerandom","min":1,"max":1}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + assert_eq!(sys.life_min, 1.0); + assert_eq!(sys.life_max, 1.0); + assert!(sys.velocity_min.is_none()); +} + +#[test] +fn instance_override_sets_color_and_multipliers() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"box","rate":1}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let overrides = InstanceOverride { + alpha: Some(0.5), + color: Some("192 192 192".to_string()), + rate: Some(2.0), + ..Default::default() + }; + let sys = ParticleSystem::from_config(&config, [0.0, 0.0], Some(&overrides)); + assert_eq!(sys.color_override, Some([192, 192, 192])); + assert_eq!(sys.alpha_mult, 0.5); + assert_eq!(sys.rate_mult, 2.0); +} + +/// `count` scales the pool, `lifetime` scales spawned lifetimes, and +/// `colorn` tints the colorrandom result — the multiplier semantics of +/// CParticle.cpp (lines 59, 755-765, 712-720). +#[test] +fn instance_override_count_lifetime_colorn() { + let json = r#"{ + "maxcount": 100, + "emitter": [{"name":"box","rate":1000}], + "initializer": [ + {"id":1,"name":"lifetimerandom","min":2,"max":2}, + {"id":2,"name":"colorrandom","min":"200 100 50","max":"200 100 50"} + ] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let overrides = InstanceOverride { + count: Some(0.5), + lifetime: Some(2.0), + colorn: Some(serde_json::json!("0.5 1 1")), + ..Default::default() + }; + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], Some(&overrides)); + assert_eq!(sys.max_count, 50, "count 0.5 should halve maxcount 100"); + sys.step(0.01); + let p = &sys.particles[0]; + assert_eq!(p.life, 4.0, "lifetime 2.0 should double the 2s lifetime"); + assert_eq!( + p.color, + [100, 100, 50], + "colorn should tint the red channel only" + ); +} + +/// `enabled: false` in an instanceoverride must drop the whole system at +/// the layer level (checked in model::resolve/cpu::animated, config-level here). +#[test] +fn instance_override_enabled_parses() { + let o: InstanceOverride = + serde_json::from_str(r#"{"enabled": false, "id": 3}"#).expect("should parse"); + assert_eq!(o.enabled.as_ref().and_then(|v| v.as_bool()), Some(false)); +} + +/// Emitter local origins are authored y-up (CParticle.cpp negates the y +/// in both emitter constructors): "10 20 0" sits 20 ABOVE the object in +/// screen space — rainperspective's "0 768 0" spawn band must end up +/// above the frame, not below it. +#[test] +fn emitter_origin_is_local_offset_from_spawn_center() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"box","rate":1,"origin":"10 20 0"}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let sys = ParticleSystem::from_config(&config, [100.0, 200.0], None); + assert_eq!(sys.emitters[0].origin[0], 110.0); + assert_eq!(sys.emitters[0].origin[1], 180.0); +} + +#[test] +fn movement_operator_gravity_field_parses() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"box","rate":1}], + "operator": [{"id":1,"name":"movement","gravity":"0 -50 0"}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + // Authored y-up: "0 -50 0" pulls down the screen, +y in this + // y-down simulation. + assert_eq!(sys.gravity, [0.0, 50.0]); +} + +#[test] +fn fade_value_clamps_outside_range_and_lerps_inside() { + assert_eq!(fade_value(0.0, 0.3, 0.7, 0.0, 1.0), 0.0); + assert_eq!(fade_value(0.3, 0.3, 0.7, 0.0, 1.0), 0.0); + assert_eq!(fade_value(0.5, 0.3, 0.7, 0.0, 1.0), 0.5); + assert_eq!(fade_value(0.7, 0.3, 0.7, 0.0, 1.0), 1.0); + assert_eq!(fade_value(1.0, 0.3, 0.7, 0.0, 1.0), 1.0); +} + +/// A real `alphafade` preset (fog1.json): fadeintime=0.5 means alpha +/// should be 0 at spawn, ~full partway through the ramp, and full for +/// the rest of the particle's life (no fadeouttime = holds at 1.0). +#[test] +fn alphafade_ramps_in_then_holds() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"box","rate":1}], + "initializer": [{"id":1,"name":"alpharandom","min":1,"max":1}], + "operator": [{"id":1,"name":"alphafade","fadeintime":0.5}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + // Force-spawn one particle with a known, fixed lifetime. + sys.life_min = 10.0; + sys.life_max = 10.0; + sys.emitters[0].rate = 1000.0; // spawn immediately + sys.step(0.001); // this step only spawns; the fade math runs on existing particles first + assert_eq!(sys.particles.len(), 1); + + // Just after spawn (age≈0), fully faded in from 0. + sys.step(0.001); + assert!(sys.particles[0].alpha < 0.1); + + // Halfway through the fade-in window (age=2.5s of 10s life → lifetime_pos=0.25, + // half of fadeintime=0.5): alpha should be roughly half. + sys.step(2.499); + let mid = sys.particles[0].alpha; + assert!((0.3..0.7).contains(&mid), "expected ~0.5, got {mid}"); + + // Well past the fade-in window, alpha should be at full (no fadeouttime set → holds at 1.0). + sys.step(5.0); + assert!(sys.particles[0].alpha > 0.9); +} + +/// A real `sizechange` preset (magic_pulse.json): startvalue=0, endvalue=2, +/// endtime=1 (normalized) — size should start near 0 and grow past the +/// initializer-sampled size as lifetime_pos approaches endtime. +#[test] +fn sizechange_scales_initial_size_over_lifetime() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"box","rate":1}], + "initializer": [{"id":1,"name":"sizerandom","min":100,"max":100}], + "operator": [{"id":1,"name":"sizechange","startvalue":0,"endvalue":2,"endtime":1}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.life_min = 10.0; + sys.life_max = 10.0; + sys.emitters[0].rate = 1000.0; + sys.step(0.001); // this step only spawns; the fade math runs on existing particles first + assert_eq!(sys.particles.len(), 1); + let initial_size = sys.particles[0].initial_size; + sys.step(0.001); + assert!(sys.particles[0].size < initial_size * 0.1); + + // lifetime_pos=1.0 reached at age=10s (endtime=1 normalized == full life here). + sys.step(9.99); + assert!((sys.particles[0].size - initial_size * 2.0).abs() < initial_size * 0.1); +} + +/// A real `colorchange` preset (torch.json): "r g b" 0..1 vector strings +/// for startvalue/endvalue — these previously broke parsing entirely +/// (Operator.startvalue/endvalue were typed as bare f64). +#[test] +fn colorchange_parses_and_multiplies_initial_color_over_lifetime() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"box","rate":1}], + "initializer": [{"id":1,"name":"colorrandom","min":"255 255 255","max":"255 255 255"}], + "operator": [{"id":1,"name":"colorchange","startvalue":"1 0.75 0","endvalue":"1 0 0","endtime":1}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.life_min = 10.0; + sys.life_max = 10.0; + sys.emitters[0].rate = 1000.0; + sys.step(0.001); // spawns the particle; fade math runs on existing particles first + assert_eq!(sys.particles.len(), 1); + + sys.step(0.001); // age≈0 → at startvalue (1, 0.75, 0) * white + let c0 = sys.particles[0].color; + assert_eq!(c0[0], 255); + assert!((c0[1] as i32 - 191).abs() <= 2); // 0.75*255≈191 + assert_eq!(c0[2], 0); + + sys.step(9.99); // age≈10 → lifetime_pos=1.0 → at endvalue (1, 0, 0) * white + let c1 = sys.particles[0].color; + assert_eq!(c1, [255, 0, 0]); +} + +/// A real `controlpointattract` preset pulls particles within `threshold/2` +/// of a control point toward it, and leaves particles outside that radius +/// untouched (CParticle.cpp createControlPointAttractOperator). +#[test] +fn controlpointattract_pulls_nearby_particles_toward_control_point() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"box","rate":1}], + "controlpoint": [{"id":0,"offset":"200 0 0"}], + "operator": [{"id":1,"name":"controlpointattract","controlpoint":0,"scale":500,"threshold":1000}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + // spawn_center [0,0] + control point offset "200 0 0" -> control point at [200, 0]. + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + assert_eq!(sys.control_points, vec![[200.0, 0.0, 0.0]]); + + sys.life_min = 10.0; + sys.life_max = 10.0; + sys.emitters[0].rate = 1000.0; + sys.step(0.001); // spawns the particle at/near [0,0] + assert_eq!(sys.particles.len(), 1); + let start_dist = ((sys.particles[0].x - 200.0).powi(2) + sys.particles[0].y.powi(2)).sqrt(); + + for _ in 0..10 { + sys.step(0.05); + } + let end_dist = ((sys.particles[0].x - 200.0).powi(2) + sys.particles[0].y.powi(2)).sqrt(); + assert!( + end_dist < start_dist, + "expected particle to be pulled toward control point: start={start_dist} end={end_dist}" + ); +} + +/// A particle outside the attract radius (`threshold/2`) should be left +/// alone entirely. +#[test] +fn controlpointattract_ignores_particles_outside_threshold() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"box","rate":1}], + "controlpoint": [{"id":0,"offset":"5000 0 0"}], + "operator": [{"id":1,"name":"controlpointattract","controlpoint":0,"scale":500,"threshold":10}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.life_min = 10.0; + sys.life_max = 10.0; + sys.emitters[0].rate = 1000.0; + sys.step(0.001); + assert_eq!(sys.particles.len(), 1); + // Capture the emitter-assigned spawn velocity (unrelated to the + // control point), then confirm a step leaves it untouched since the + // particle is well outside the attract radius. + let (vx0, vy0) = (sys.particles[0].vx, sys.particles[0].vy); + sys.step(0.05); + assert_eq!(sys.particles[0].vx, vx0); + assert_eq!(sys.particles[0].vy, vy0); +} + +/// A `rope`/`ropetrail` renderer should draw a connected ribbon through +/// living particles rather than isolated circles — verify pixels are +/// filled *between* particle centers, not just at them. +#[test] +fn rope_renderer_fills_ribbon_between_particles() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"box","rate":1}], + "initializer": [{"id":1,"name":"sizerandom","min":40,"max":40}], + "renderer": [{"id":1,"name":"rope","subdivision":4}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + assert!(sys.rope_mode); + assert_eq!(sys.rope_subdivision, 4); + + // Manually place three particles in a horizontal line, all alive. + let mut sys = sys; + for (i, x) in [0.0f32, 50.0, 100.0].into_iter().enumerate() { + sys.particles.push(Particle { + id: 0, + x, + y: 100.0, + vx: 0.0, + vy: 0.0, + life: 5.0, + max_life: 5.0, + size: 20.0, + alpha: 1.0, + color: [255, 0, 0], + initial_alpha: 1.0, + initial_size: 20.0, + initial_color: [1.0, 0.0, 0.0], + osc_alpha: None, + osc_size: None, + osc_pos: None, + rotation: 0.0, + angular_velocity: 0.0, + frame: -1.0, + }); + let _ = i; + } + + let mut canvas = RgbaImage::new(200, 200); + sys.render_onto(&mut canvas, None, [0.0, 0.0]); + + // Midpoint between the first two particles (25, 100) should be filled + // — a plain per-particle circle draw (radius 10) would leave this gap + // empty since it's 25px from the nearest particle center. + let mid_pixel = canvas.get_pixel(25, 100); + assert!( + mid_pixel[3] > 0, + "expected ribbon fill at midpoint, got {mid_pixel:?}" + ); +} + +/// A rope with a material texture must sample it across the ribbon width +/// (U = perpendicular offset): a beam strip that's transparent except for +/// its center column should render as a thin filament along the rope's +/// spine, leaving the outer ribbon area untouched — the fix for fat +/// solid lightning bolts (discharge presets) and giant trail wedges. +#[test] +fn rope_renderer_samples_texture_across_width() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"box","rate":1}], + "renderer": [{"id":1,"name":"rope","subdivision":1}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + for x in [0.0f32, 50.0, 100.0] { + sys.particles.push(make_particle(x, 100.0, 20.0, 0.0)); + } + + // Beam strip: transparent except the 3 center columns (white, opaque). + let mut beam = RgbaImage::new(33, 4); + for y in 0..4 { + for x in 15..=17 { + beam.put_pixel(x, y, image::Rgba([255, 255, 255, 255])); + } + } + let sprite = ParticleSprite { + frames: vec![beam], + duration: 0.0, + overbright: 1.0, + }; + + let mut canvas = RgbaImage::new(200, 200); + sys.render_onto(&mut canvas, Some(&sprite), [0.0, 0.0]); + + // The rope spine (u = 0.5 → the white center column) is filled… + let spine = canvas.get_pixel(25, 100); + assert!( + spine[3] > 0, + "expected filament on rope spine, got {spine:?}" + ); + // …but the ribbon area off-center (still inside the 10px half-width + // the old flat fill covered) samples the transparent texture edge. + let edge = canvas.get_pixel(25, 92); + assert_eq!(edge[3], 0, "expected transparent ribbon edge, got {edge:?}"); +} + +fn child_ref(child_type: Option<&str>, probability: f64) -> ChildRef { + ChildRef { + name: "child.json".into(), + child_type: child_type.map(str::to_string), + probability: Some(probability), + maxcount: None, + } +} + +fn simple_child_config() -> ParticleConfig { + serde_json::from_str( + r#"{ + "maxcount": 10, + "emitter": [{"name":"box","rate":1000}], + "initializer": [{"id":1,"name":"lifetimerandom","min":5,"max":5}] + }"#, + ) + .expect("child config should parse") +} + +/// An `eventfollow` child spawns one instance per parent particle and +/// keeps its emitters glued to the parent as it moves. +#[test] +fn child_eventfollow_tracks_parent_particle() { + let json = r#"{ + "maxcount": 1, + "emitter": [{"name":"box","rate":1000}], + "initializer": [ + {"id":1,"name":"lifetimerandom","min":10,"max":10}, + {"id":2,"name":"velocityrandom","min":"100 0 0","max":"100 0 0"} + ] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.add_child( + simple_child_config(), + None, + false, + &child_ref(Some("eventfollow"), 1.0), + [0.0, 0.0], + ); + + sys.step(0.01); // spawns the parent + its follow instance + assert_eq!(sys.particles.len(), 1); + assert_eq!(sys.children[0].instances.len(), 1); + + sys.step(1.0); // parent moves +100x; instance must follow + let parent = [sys.particles[0].x, sys.particles[0].y]; + let inst = &sys.children[0].instances[0]; + assert_eq!(inst.last_center, parent, "instance should track the parent"); + assert!( + !inst.system.particles.is_empty(), + "follow instance should be emitting its own particles" + ); +} + +/// Probability 0 must never attach an instance; a dead parent stops the +/// instance's emitters and lets it retire once its particles expire. +#[test] +fn child_probability_and_parent_death() { + let json = r#"{ + "maxcount": 1, + "emitter": [{"name":"box","rate":1000}], + "initializer": [{"id":1,"name":"lifetimerandom","min":0.05,"max":0.05}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + + let mut never = ParticleSystem::from_config(&config, [0.0, 0.0], None); + never.add_child( + simple_child_config(), + None, + false, + &child_ref(Some("eventfollow"), 0.0), + [0.0, 0.0], + ); + never.step(0.01); + assert!(never.children[0].instances.is_empty()); + + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.add_child( + simple_child_config(), + None, + false, + &child_ref(Some("eventfollow"), 1.0), + [0.0, 0.0], + ); + sys.step(0.01); + assert_eq!(sys.children[0].instances.len(), 1); + sys.step(0.1); // parent (life 0.05) dies + let inst = &sys.children[0].instances[0]; + assert!( + inst.parent_id.is_none(), + "dead parent should detach the instance" + ); + assert!( + inst.system.emitters.iter().all(|e| e.rate == 0.0), + "detached instance should stop emitting" + ); +} + +/// A type-less (static) child gets one persistent instance at the parent +/// object's origin that emits continuously, surviving empty stretches. +#[test] +fn child_static_runs_continuously() { + let json = r#"{ + "maxcount": 1, + "emitter": [{"name":"box","rate":0}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [40.0, 40.0], None); + sys.add_child( + simple_child_config(), + None, + false, + &child_ref(None, 1.0), + [40.0, 40.0], + ); + assert_eq!( + sys.children[0].instances.len(), + 1, + "static child instantiates immediately" + ); + for _ in 0..10 { + sys.step(0.05); + } + assert!(!sys.children[0].instances[0].system.particles.is_empty()); + assert!(sys.children[0].instances[0].persistent); +} + +fn make_particle(x: f32, y: f32, size: f32, rotation: f32) -> Particle { + Particle { + id: 0, + x, + y, + vx: 0.0, + vy: 0.0, + life: 5.0, + max_life: 5.0, + size, + alpha: 1.0, + color: [255, 255, 255], + initial_alpha: 1.0, + initial_size: size, + initial_color: [1.0, 1.0, 1.0], + osc_alpha: None, + osc_size: None, + osc_pos: None, + rotation, + angular_velocity: 0.0, + frame: -1.0, + } +} + +/// A textured particle's rotation should actually affect what's drawn — +/// sampling the same canvas location at two different rotation angles +/// must produce different colors, proving the quad (and the texture +/// sampled onto it) is genuinely rotated, not just passed through. +#[test] +fn textured_particle_rotation_changes_sampled_footprint() { + // 8x8 sprite: left half red, right half blue, fully opaque. + let mut tex = RgbaImage::new(8, 8); + for y in 0..8 { + for x in 0..8 { + let color = if x < 4 { + [255, 0, 0, 255] + } else { + [0, 0, 255, 255] + }; + tex.put_pixel(x, y, image::Rgba(color)); + } + } + + let config: ParticleConfig = + serde_json::from_str(r#"{"maxcount":1,"emitter":[{"name":"box","rate":1}]}"#) + .expect("should parse"); + + let sprite = ParticleSprite::single(tex.clone()); + let render_at = |rotation: f32| -> image::Rgba { + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.particles + .push(make_particle(50.0, 50.0, 20.0, rotation)); + let mut canvas = RgbaImage::new(100, 100); + sys.render_onto(&mut canvas, Some(&sprite), [0.0, 0.0]); + *canvas.get_pixel(44, 50) + }; + + let unrotated = render_at(0.0); + let rotated = render_at(std::f32::consts::FRAC_PI_2); + assert_ne!( + unrotated, rotated, + "expected rotation to change the sampled footprint at a fixed canvas point" + ); +} + +/// `rotationrandom` with a degenerate `[min.z, max.z]` range must spawn +/// every particle at exactly that rotation (CParticle.cpp +/// createRotationRandomInitializer, z-axis reduction); absent, spawn +/// rotation stays 0. +#[test] +fn rotationrandom_sets_spawn_rotation() { + let json = r#"{ + "maxcount": 4, + "emitter": [{"name":"box","rate":1000}], + "initializer": [{"id":1,"name":"rotationrandom","min":"0 0 1","max":"0 0 1"}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.step(0.001); + assert!((sys.particles[0].rotation - 1.0).abs() < 1e-4); + + let plain: ParticleConfig = + serde_json::from_str(r#"{"maxcount":1,"emitter":[{"name":"box","rate":1000}]}"#) + .expect("should parse"); + let mut sys = ParticleSystem::from_config(&plain, [0.0, 0.0], None); + sys.step(0.001); + assert_eq!(sys.particles[0].rotation, 0.0); +} + +/// `turbulentvelocityrandom` adds a curl-noise-directed velocity of +/// magnitude in `[speedmin, speedmax]` on top of the plain velocity — +/// with a zero base velocity, the spawn speed must equal the turbulent +/// speed exactly (the direction is noise, the magnitude is not). +#[test] +fn turbulentvelocityrandom_sets_spawn_speed_magnitude() { + let json = r#"{ + "maxcount": 8, + "emitter": [{"name":"box","rate":1000}], + "initializer": [ + {"id":1,"name":"velocityrandom","min":"0 0 0","max":"0 0 0"}, + {"id":2,"name":"turbulentvelocityrandom","speedmin":100,"speedmax":100} + ] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.step(0.001); + assert!(!sys.particles.is_empty()); + for p in &sys.particles { + let speed = (p.vx * p.vx + p.vy * p.vy).sqrt(); + assert!( + (speed - 100.0).abs() < 0.5, + "expected turbulent spawn speed 100, got {speed}" + ); + } +} + +/// The `turbulence` operator must push otherwise-motionless particles +/// around (curl-noise acceleration, CParticle.cpp +/// createTurbulenceOperator). +#[test] +fn turbulence_operator_accelerates_particles() { + let json = r#"{ + "maxcount": 4, + "emitter": [{"name":"box","rate":1000}], + "initializer": [{"id":1,"name":"velocityrandom","min":"0 0 0","max":"0 0 0"}], + "operator": [{"id":1,"name":"turbulence","speedmin":500,"speedmax":500,"scale":0.01}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [10.0, 20.0], None); + for _ in 0..10 { + sys.step(1.0 / 30.0); + } + let moving = sys + .particles + .iter() + .any(|p| (p.vx * p.vx + p.vy * p.vy).sqrt() > 1.0); + assert!(moving, "turbulence should have accelerated particles"); +} + +/// `vortex` spins particles tangentially around its control point: a +/// particle at +X from the center with a +Z axis must gain -Y velocity +/// in our y-down space (the reference's y-up cross(axis, radial), +/// mirrored — see the reflection note in `step`), scaled by speedinner +/// inside distanceinner. +#[test] +fn vortex_operator_adds_tangential_velocity() { + let json = r#"{ + "maxcount": 4, + "emitter": [{"name":"box","rate":0}], + "controlpoint": [{"id":0,"offset":"0 0 0"}], + "operator": [{"id":1,"name":"vortex","controlpoint":0, + "distanceinner":500,"distanceouter":650, + "speedinner":100,"speedouter":0}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.particles.push(make_particle(10.0, 0.0, 5.0, 0.0)); + sys.step(0.1); + let p = &sys.particles[0]; + assert!( + p.vy < -0.5, + "expected tangential -Y velocity from vortex, got vy={}", + p.vy + ); + assert!( + p.vx.abs() < 1e-3, + "no radial force expected, got vx={}", + p.vx + ); +} + +/// `collisionsphere` with the default `bounce` behavior: a particle that +/// has penetrated the sphere gets pushed back to the surface and its +/// into-surface velocity component reflected (scaled by `1+bouncefactor`). +#[test] +fn collisionsphere_bounces_particle_off_surface() { + let json = r#"{ + "maxcount": 1, + "emitter": [{"name":"box","rate":0}], + "controlpoint": [{"id":0,"offset":"0 0 0"}], + "operator": [{"id":1,"name":"collisionsphere","controlpoint":0,"radius":50}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + let mut p = make_particle(10.0, 0.0, 5.0, 0.0); + p.vx = -50.0; + sys.particles.push(p); + sys.step(0.1); + let p = &sys.particles[0]; + assert!( + (p.x - 50.0).abs() < 1e-3, + "expected pushed to surface, x={}", + p.x + ); + assert!( + p.vx > 0.0, + "expected reflected outward velocity, vx={}", + p.vx + ); +} + +/// `collisionbox` with `slide`: the into-wall velocity component is +/// zeroed (not reflected), and the particle is pushed back to the wall. +#[test] +fn collisionbox_slide_zeroes_into_wall_velocity() { + let json = r#"{ + "maxcount": 1, + "emitter": [{"name":"box","rate":0}], + "controlpoint": [{"id":0,"offset":"0 0 0"}], + "operator": [{"id":1,"name":"collisionbox","controlpoint":0, + "size":"100 100 0","collisionbehavior":"slide"}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + let mut p = make_particle(60.0, 0.0, 5.0, 0.0); + p.vx = -100.0; + sys.particles.push(p); + sys.step(0.15); + let p = &sys.particles[0]; + assert!( + (p.x - 50.0).abs() < 1e-3, + "expected pushed to wall, x={}", + p.x + ); + assert!( + p.vx.abs() < 1e-3, + "expected into-wall velocity zeroed, vx={}", + p.vx + ); +} + +/// `collisionplane` with `delete`: a particle on the solid side of the +/// plane is killed outright (removed by `step`'s own retain). +#[test] +fn collisionplane_delete_removes_particle() { + let json = r#"{ + "maxcount": 1, + "emitter": [{"name":"box","rate":0}], + "controlpoint": [{"id":0,"offset":"0 0 0"}], + "operator": [{"id":1,"name":"collisionplane","controlpoint":0, + "collisionbehavior":"delete"}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.particles.push(make_particle(0.0, -10.0, 5.0, 0.0)); + sys.step(0.1); + assert!( + sys.particles.is_empty(), + "particle should be deleted on contact" + ); +} + +/// Audio response: WE's parser defaults, band clamp/swap, and the gain +/// ramp runs between the `bounds` volume levels over the selected bands. +#[test] +fn audio_response_defaults_bands_and_bounds() { + let em = |j: &str| serde_json::from_str::(j).unwrap(); + assert_eq!(AudioResponse::from_emitter(&em("{}")), None); + let d = AudioResponse::from_emitter(&em(r#"{"audioprocessingmode": 1}"#)).unwrap(); + assert_eq!((d.exponent, d.bounds, d.bands), (2.0, [0.8, 1.0], [0, 1])); + let r = AudioResponse::from_emitter(&em( + r#"{"audioprocessingbounds": "0.2 1.5", "audioprocessingexponent": 1, + "audioprocessingfrequencystart": 40, "audioprocessingfrequencyend": 6}"#, + )) + .unwrap(); + assert_eq!(r.bands, [6, 15]); + let mut bands = [0.0; 16]; + assert_eq!(r.gain(&bands), 0.0); // silence: emitter inactive + bands[0] = 1.0; // outside 6..=15: no effect + assert_eq!(r.gain(&bands), 0.0); + bands[6..].fill(0.85); + assert!((r.gain(&bands) - 0.5).abs() < 1e-6); // (0.85-0.2)/(1.5-0.2) + bands[6..].fill(1.0); + assert!((r.gain(&bands) - 0.8 / 1.3).abs() < 1e-6); +} + +/// `boids` cohesion pulls two distant particles toward each other. +#[test] +fn boids_cohesion_pulls_particles_together() { + let json = r#"{ + "maxcount": 2, + "emitter": [{"name":"box","rate":0}], + "operator": [{"id":1,"name":"boids","neighborthreshold":500, + "separationthreshold":1,"cohesionfactor":1.0,"alignmentfactor":0}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.particles.push(make_particle(-100.0, 0.0, 5.0, 0.0)); + sys.particles.push(make_particle(100.0, 0.0, 5.0, 0.0)); + sys.step(0.1); + assert!( + sys.particles[0].vx > 1.0, + "left particle should move right, vx={}", + sys.particles[0].vx + ); + assert!( + sys.particles[1].vx < -1.0, + "right particle should move left, vx={}", + sys.particles[1].vx + ); +} + +/// `boids` separation pushes two nearby particles apart. +#[test] +fn boids_separation_pushes_particles_apart() { + let json = r#"{ + "maxcount": 2, + "emitter": [{"name":"box","rate":0}], + "operator": [{"id":1,"name":"boids","separationthreshold":50, + "neighborthreshold":1,"separationfactor":1.0}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.particles.push(make_particle(-2.0, 0.0, 5.0, 0.0)); + sys.particles.push(make_particle(2.0, 0.0, 5.0, 0.0)); + sys.step(0.1); + assert!( + sys.particles[0].vx < 0.0, + "left particle should move further left, vx={}", + sys.particles[0].vx + ); + assert!( + sys.particles[1].vx > 0.0, + "right particle should move further right, vx={}", + sys.particles[1].vx + ); +} + +/// `movement.drag` decays velocity by `1 - drag*dt` per step, clamped +/// at zero so an over-large `drag*dt` stops the particle instead of +/// reversing it (CParticle.cpp createMovementOperator). +#[test] +fn movement_drag_decays_velocity_without_reversal() { + let json = r#"{ + "maxcount": 1, + "emitter": [{"name":"box","rate":0}], + "operator": [{"id":1,"name":"movement","gravity":"0 0 0","drag":4.0}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + let mut p = make_particle(50.0, 50.0, 5.0, 0.0); + p.vx = 100.0; + sys.particles.push(p); + + sys.step(0.1); // drag*dt = 0.4 -> vx *= 0.6 + assert!((sys.particles[0].vx - 60.0).abs() < 1e-3); + + sys.step(0.5); // drag*dt = 2.0 -> clamps to 0, must not reverse + assert_eq!(sys.particles[0].vx, 0.0); +} + +/// `mapsequencebetweencontrolpoints` walks spawn positions along the +/// cp0->cp1 segment; `limitbehavior: "mirror"` ping-pongs (0, 1/3, 2/3, +/// 1, 2/3, 1/3, 0, ...) instead of wrapping. +#[test] +fn mapsequence_between_walks_segment_with_mirror() { + let json = r#"{ + "maxcount": 8, + "emitter": [{"name":"box","rate":100000}], + "controlpoint": [{"id":0,"offset":"0 0 0"},{"id":1,"offset":"90 0 0"}], + "initializer": [ + {"id":1,"name":"velocityrandom","min":"0 0 0","max":"0 0 0"}, + {"id":2,"name":"mapsequencebetweencontrolpoints","count":4,"limitbehavior":"mirror"} + ] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.step(0.001); + assert!(sys.particles.len() >= 7, "expected at least 7 spawns"); + let xs: Vec = sys.particles.iter().take(7).map(|p| p.x).collect(); + let expect = [0.0, 30.0, 60.0, 90.0, 60.0, 30.0, 0.0]; + for (i, (&x, &e)) in xs.iter().zip(expect.iter()).enumerate() { + assert!((x - e).abs() < 1e-3, "slot {i}: expected x={e}, got {x}"); + } +} + +/// `mapsequencearoundcontrolpoint` spawns at the control point and +/// launches successive particles at evenly-divided angles: with +/// `count: 4` and a +X-only speed, consecutive spawn velocities must +/// point right/down-rotated/left/up-rotated (the reference's clockwise +/// mat3), all with the same magnitude. +#[test] +fn mapsequence_spawns_at_control_point_with_rotating_velocity() { + let json = r#"{ + "maxcount": 4, + "emitter": [{"name":"box","rate":100000}], + "controlpoint": [{"id":0,"offset":"50 60 0"}], + "initializer": [{"id":1,"name":"mapsequencearoundcontrolpoint", + "controlpoint":0,"count":4, + "speedmin":"80 0 0","speedmax":"80 0 0"}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.step(0.001); + assert!(sys.particles.len() >= 4, "expected at least 4 spawns"); + for p in &sys.particles { + assert_eq!((p.x, p.y), (50.0, 60.0), "must spawn at the control point"); + let speed = (p.vx * p.vx + p.vy * p.vy).sqrt(); + assert!( + (speed - 80.0).abs() < 1e-3, + "speed must stay 80, got {speed}" + ); + } + // First two spawns: angle 0 (+X) then 2pi/4 = 90deg rotated. + assert!((sys.particles[0].vx - 80.0).abs() < 1e-3); + assert!(sys.particles[0].vy.abs() < 1e-3); + assert!(sys.particles[1].vx.abs() < 1e-3); + assert!(sys.particles[1].vy.abs() > 79.0); +} + +fn two_frame_sprite() -> ParticleSprite { + ParticleSprite { + frames: vec![ + RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])), + RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 255, 255])), + ], + duration: 1.0, + overbright: 1.0, + } +} + +fn render_center_with_frame(frame: f32) -> image::Rgba { + let config: ParticleConfig = + serde_json::from_str(r#"{"maxcount":1,"emitter":[{"name":"box","rate":0}]}"#) + .expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + let mut p = make_particle(20.0, 20.0, 8.0, 0.0); + p.frame = frame; + sys.particles.push(p); + let sprite = two_frame_sprite(); + let mut canvas = RgbaImage::new(40, 40); + sys.render_onto(&mut canvas, Some(&sprite), [0.0, 0.0]); + *canvas.get_pixel(20, 20) +} + +/// A fractional frame position cross-fades between the two adjacent +/// frames (genericparticle.frag's SPRITESHEETBLEND): halfway between a +/// red and a blue frame must paint ~50/50 purple, not hard-switch. +#[test] +fn fractional_frame_cross_fades_adjacent_frames() { + let px = render_center_with_frame(0.5); + assert!(px[0] > 100 && px[0] < 155, "red ~half, got {px:?}"); + assert!(px[2] > 100 && px[2] < 155, "blue ~half, got {px:?}"); + assert_eq!(px[1], 0); +} + +/// The next frame is clamped at the last frame (ComputeSpriteFrame's +/// `min(numFrames - 1, currentFrame + 1)`) — a fraction past the final +/// frame must NOT wrap-blend back toward frame 0. +#[test] +fn cross_fade_clamps_at_last_frame_without_wrapping() { + let px = render_center_with_frame(1.5); + assert_eq!(px[0], 0, "no red from a wrap to frame 0: {px:?}"); + assert!(px[2] > 200, "pure last frame, got {px:?}"); +} + +/// Integer frame positions (RandomFrame assigns whole frames) take the +/// single-sample path — exactly one frame's color, no blending. +#[test] +fn integer_frame_draws_single_frame_unblended() { + let px = render_center_with_frame(1.0); + assert_eq!(px[0], 0, "{px:?}"); + assert!(px[2] > 200, "{px:?}"); + let px0 = render_center_with_frame(0.0); + assert!(px0[0] > 200, "{px0:?}"); + assert_eq!(px0[2], 0, "{px0:?}"); +} + +/// `spritetrail` stretches the quad along the particle's velocity: +/// a fast horizontal particle must paint a footprint wider than tall +/// (its quad's long axis lies along X), unlike the square default. +#[test] +fn spritetrail_stretches_quad_along_velocity() { + let json = r#"{ + "maxcount": 1, + "emitter": [{"name":"box","rate":0}], + "renderer": [{"name":"spritetrail","length":1.0,"maxlength":10}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + let mut p = make_particle(50.0, 50.0, 5.0, 0.0); + p.vx = 8.0; // trail = clamp(8 * 1.0, 0, 10) = 8 -> half-height 40 along X + let mut sys = sys; + sys.particles.push(p); + + let tex = RgbaImage::from_pixel(4, 4, image::Rgba([255, 255, 255, 255])); + let sprite = ParticleSprite::single(tex); + let mut canvas = RgbaImage::new(100, 100); + sys.render_onto(&mut canvas, Some(&sprite), [0.0, 0.0]); + + let painted_x = canvas.get_pixel(80, 50)[3] > 0; // 30px right of center + let painted_y = canvas.get_pixel(50, 80)[3] > 0; // 30px below center + assert!(painted_x, "trail should extend along +X (velocity)"); + assert!(!painted_y, "trail should stay narrow across velocity"); +} + +/// Name matching must be exact: `turbulentvelocityrandom` declared +/// before `velocityrandom` (and `angularmovement` before `movement`) +/// must not shadow them — the old substring matcher (`contains`) +/// read the turbulent initializer's fields as the plain velocity +/// range and the angular operator's (absent) gravity as movement's. +/// An omitted `min`/`max` takes WE's default instead of discarding the +/// initializer: 3598808038's vapor sets only `colorrandom.max = 0 0 0` +/// and must come out black, not the no-initializer white. +#[test] +fn partial_vec3_initializers_use_we_defaults() { + let json = r#"{ + "maxcount": 4, + "emitter": [{"name":"box","rate":1}], + "initializer": [ + {"id":1,"name":"colorrandom","max":"0 0 0"}, + {"id":2,"name":"velocityrandom","min":"0 10 0"} + ] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + assert_eq!((sys.color_min, sys.color_max), ([0.0; 3], [0.0; 3])); + assert_eq!(sys.velocity_min, Some([0.0, -10.0, 0.0])); + assert_eq!(sys.velocity_max, Some([32.0, -32.0, 32.0])); +} + +#[test] +fn similarly_named_initializers_do_not_shadow_each_other() { + let json = r#"{ + "maxcount": 4, + "emitter": [{"name":"box","rate":1000}], + "initializer": [ + {"id":1,"name":"turbulentvelocityrandom","speedmin":500,"speedmax":900}, + {"id":2,"name":"velocityrandom","min":"3 3 0","max":"3 3 0"} + ], + "operator": [ + {"id":1,"name":"angularmovement","drag":0.5}, + {"id":2,"name":"movement","gravity":"0 -7 0"} + ] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + // Authored y-up vectors flip into this y-down simulation. Real + // content settles the direction: snow, rain and leaf presets fall + // with -y velocity, embers rise with +y gravity. + assert_eq!(sys.velocity_min, Some([3.0, -3.0, 0.0])); + assert_eq!(sys.velocity_max, Some([3.0, -3.0, 0.0])); + assert_eq!(sys.gravity[1], 7.0); +} + +/// A config with no `alpharandom` initializer at all must stay a no-op +/// (full alpha, matching `ParticleInstance::alpha`'s `1.0` default) — +/// the common case, and the one that must not regress. +#[test] +fn no_alpharandom_initializer_defaults_to_full_alpha() { + let config: ParticleConfig = + serde_json::from_str(r#"{"maxcount":1,"emitter":[{"name":"box","rate":1000}]}"#) + .expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.step(0.001); + assert_eq!(sys.particles[0].initial_alpha, 1.0); +} + +/// `alpharandom` (e.g. fog1.json's `"min":0.15,"max":0.2`) must actually +/// constrain spawn alpha to that range instead of always spawning at +/// full opacity — this was silently ignored before. +#[test] +fn alpharandom_constrains_spawn_alpha_to_its_range() { + let json = r#"{ + "maxcount": 20, + "emitter": [{"name":"box","rate":1000}], + "initializer": [{"id":1,"name":"alpharandom","min":0.15,"max":0.2}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.step(0.05); // spawn several at once (rate 1000 * 0.05s = 50, capped at maxcount) + assert!(!sys.particles.is_empty()); + for p in &sys.particles { + assert!( + (0.15..=0.2).contains(&p.initial_alpha), + "expected initial_alpha in [0.15, 0.2], got {}", + p.initial_alpha + ); + assert_eq!( + p.alpha, p.initial_alpha, + "no alphafade — alpha should equal initial_alpha before any fade operator runs" + ); + } +} + +/// An instance-override alpha multiplier must apply exactly once (not +/// squared) even when combined with `alpharandom`. +#[test] +fn alpha_override_multiplier_applies_once_not_twice() { + let json = r#"{ + "maxcount": 1, + "emitter": [{"name":"box","rate":1000}], + "initializer": [{"id":1,"name":"alpharandom","min":1.0,"max":1.0}] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let overrides = InstanceOverride { + alpha: Some(0.5), + ..Default::default() + }; + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], Some(&overrides)); + sys.step(0.01); + assert_eq!(sys.particles[0].initial_alpha, 0.5); + sys.step(0.01); + // No alphafade/alphachange configured, so the plain lifetime-based + // death-fade fallback (`initial_alpha * life/max_life`) applies — + // barely distinguishable from 0.5 this soon after spawn, but not + // bit-exact. The point of this assertion is that it's nowhere near + // 0.5*0.5=0.25 (the double-multiply bug), not exact equality. + assert!( + (sys.particles[0].alpha - 0.5).abs() < 0.01, + "override should apply once (~0.5), not 0.5*0.5=0.25; got {}", + sys.particles[0].alpha + ); +} + +/// Default (no `animationmode`) sprite-sheet playback loops continuously +/// based on lifetime position: at half life with 4 frames, the particle +/// should be sitting on frame 2. +#[test] +fn sprite_frame_loops_by_lifetime_position() { + let config: ParticleConfig = + serde_json::from_str(r#"{"maxcount":1,"emitter":[{"name":"box","rate":1000}]}"#) + .expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.life_min = 10.0; + sys.life_max = 10.0; + sys.set_sprite_frames(4, 0.0); + sys.step(0.001); // spawn + assert_eq!(sys.particles.len(), 1); + sys.step(5.0); // half of a 10s life + assert!((sys.particles[0].frame - 2.0).abs() < 0.01); +} + +/// `animationmode: "once"` plays through the sheet across the particle's +/// lifetime and clamps at the last frame instead of wrapping. +#[test] +fn sprite_frame_clamps_at_last_frame_when_animation_mode_once() { + let config: ParticleConfig = serde_json::from_str( + r#"{"maxcount":1,"animationmode":"once","emitter":[{"name":"box","rate":1000}]}"#, + ) + .expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.life_min = 10.0; + sys.life_max = 10.0; + sys.set_sprite_frames(4, 0.0); + sys.step(0.001); // spawn + sys.step(9.99); // nearly the whole 10s life + assert_eq!(sys.particles[0].frame, 3.0); +} + +/// `animationmode: "randomframe"` assigns one frame at spawn and freezes +/// it — a second `step()` must not change it. +#[test] +fn sprite_random_frame_stays_fixed_after_assignment() { + let config: ParticleConfig = serde_json::from_str( + r#"{"maxcount":1,"animationmode":"randomframe","emitter":[{"name":"box","rate":1000}]}"#, + ) + .expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.life_min = 10.0; + sys.life_max = 10.0; + sys.set_sprite_frames(5, 0.0); + sys.step(0.001); // spawn (frame assignment lags by one step, like other operators) + sys.step(0.001); // assigns a random frame + let frame = sys.particles[0].frame; + assert!((0.0..5.0).contains(&frame)); + sys.step(1.0); + assert_eq!(sys.particles[0].frame, frame); +} + +/// A sprite with only one frame should never advance `p.frame` at all — +/// `render_onto` always draws frame 0 for it regardless. +#[test] +fn single_frame_sprite_leaves_frame_at_spawn_sentinel() { + let config: ParticleConfig = + serde_json::from_str(r#"{"maxcount":1,"emitter":[{"name":"box","rate":1000}]}"#) + .expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.life_min = 10.0; + sys.life_max = 10.0; + sys.set_sprite_frames(1, 0.0); + sys.step(0.001); + sys.step(5.0); + assert_eq!(sys.particles[0].frame, -1.0); +} + +#[test] +fn bounds_is_none_when_no_particles_alive() { + let config: ParticleConfig = + serde_json::from_str(r#"{"maxcount":5,"emitter":[{"name":"box","rate":0}]}"#) + .expect("should parse"); + let sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + assert!(sys.bounds().is_none()); +} + +#[test] +fn bounds_covers_alive_particle_extents() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"box","rate":1,"distancemax":"0 0 0"}], + "initializer": [ + {"id":1,"name":"lifetimerandom","min":10,"max":10}, + {"id":2,"name":"sizerandom","min":20,"max":20}, + {"id":3,"name":"velocityrandom","min":"0 0 0","max":"0 0 0"} + ] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [100.0, 100.0], None); + sys.emitters[0].rate = 1000.0; + sys.step(0.001); + assert_eq!(sys.particles.len(), 1); + + let (min_x, min_y, max_x, max_y) = sys.bounds().expect("should have bounds"); + // Particle spawned at (100,100) (spread disabled via distancemax=0). + // `sizerandom`'s value is halved at spawn (diameter → radius, see + // the comment on that halving elsewhere in this file), so a + // `sizerandom` of 20 gives an actual radius of 10; `bounds()` adds a + // couple of pixels of margin on top of that. + assert!(min_x < 100.0 - 9.0 && min_x > 100.0 - 16.0, "min_x={min_x}"); + assert!(max_x > 100.0 + 9.0 && max_x < 100.0 + 16.0, "max_x={max_x}"); + assert!(min_y < 100.0 - 9.0 && min_y > 100.0 - 16.0, "min_y={min_y}"); + assert!(max_y > 100.0 + 9.0 && max_y < 100.0 + 16.0, "max_y={max_y}"); +} + +/// `render_onto`'s `origin` param should shift particle canvas positions, +/// letting a caller raster into a canvas sized to just the bbox instead +/// of the full scene. +#[test] +fn render_onto_origin_shifts_particle_positions() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"box","rate":1}], + "initializer": [ + {"id":1,"name":"lifetimerandom","min":10,"max":10}, + {"id":2,"name":"sizerandom","min":10,"max":10}, + {"id":3,"name":"velocityrandom","min":"0 0 0","max":"0 0 0"}, + {"id":4,"name":"alpharandom","min":1,"max":1} + ] + }"#; + let config: ParticleConfig = serde_json::from_str(json).expect("should parse"); + let mut sys = ParticleSystem::from_config(&config, [50.0, 50.0], None); + sys.emitters[0].rate = 1000.0; + sys.step(0.001); + assert_eq!(sys.particles.len(), 1); + + // A canvas the same size as the bbox, with origin shifted to the + // bbox's top-left, should show the particle rendered near its + // center rather than clipped at (0,0). + let (min_x, min_y, max_x, max_y) = sys.bounds().unwrap(); + let w = (max_x - min_x).ceil() as u32; + let h = (max_y - min_y).ceil() as u32; + let mut canvas = RgbaImage::new(w, h); + sys.render_onto(&mut canvas, None, [min_x, min_y]); + + let cx = (w / 2).min(w - 1); + let cy = (h / 2).min(h - 1); + assert!( + canvas.get_pixel(cx, cy)[3] > 0, + "expected particle to render near the shifted canvas center" + ); +} + +#[test] +fn oscillate_params_defaults_scale_min_from_scale_max_when_absent() { + let op = Operator { + name: "oscillatealpha".to_string(), + frequencymin: Some(0.5), + frequencymax: Some(1.0), + scalemax: Some(10.0), + ..Default::default() + }; + let params = OscillateParams::from_operator(&op); + assert_eq!(params.scale_min, 10.0); + assert_eq!(params.scale_max, 10.0); + assert_eq!(params.freq_min, 0.5); + assert_eq!(params.freq_max, 1.0); +} + +#[test] +fn oscillate_sample_phase_covers_full_circle_when_unspecified() { + let op = Operator { + name: "oscillatealpha".to_string(), + frequencymin: Some(1.0), + frequencymax: Some(1.0), + scalemin: Some(1.0), + scalemax: Some(1.0), + ..Default::default() + }; + let params = OscillateParams::from_operator(&op); + for _ in 0..50 { + let s = params.sample(); + assert!((0.0..std::f32::consts::TAU).contains(&s.phase)); + } +} + +#[test] +fn remapvalue_sets_velocity_within_output_range() { + let json = r#"{ + "maxcount": 20, + "emitter": [{"name":"boxrandom","rate":1000,"distancemin":"0 0 0","distancemax":"0 0 0"}], + "initializer": [ + {"id":1,"name":"lifetimerandom","min":100,"max":100}, + {"id":2,"name":"sizerandom","min":10,"max":10} + ], + "operator": [ + {"id":1,"name":"remapvalue","output":"velocity", + "outputrangemin":"-100 -200 0","outputrangemax":"100 -1200 1", + "transformfunction":"simplexnoise","transforminputscale":10} + ] + }"#; + let config: ParticleConfig = serde_json::from_str(json).unwrap(); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + for _ in 0..10 { + sys.step(1.0 / 30.0); + } + assert!(!sys.particles.is_empty(), "should have spawned particles"); + for p in &sys.particles { + // Velocity is set (then position-integrated) into the remapped + // range each frame; check it lands within bounds (+ a small margin + // for the integration/other operators). + assert!( + (-101.0..=101.0).contains(&p.vx), + "vx {} out of remapped x-range", + p.vx + ); + assert!( + (-1201.0..=-199.0).contains(&p.vy), + "vy {} out of remapped y-range", + p.vy + ); + } +} + +/// Real content (`particles/presets/rain_screen.json`, workshop item +/// `3564529588`) attaches *two* `remapvalue` operators — one targeting +/// `velocity`, one `speed` — and both must take effect. The old +/// `Option` silently dropped every instance after the +/// first; this is the regression test for the `Vec` fix. +#[test] +fn multiple_remapvalue_operators_all_apply() { + let json = r#"{ + "maxcount": 20, + "emitter": [{"name":"boxrandom","rate":1000,"distancemin":"0 0 0","distancemax":"0 0 0"}], + "initializer": [ + {"id":1,"name":"lifetimerandom","min":100,"max":100} + ], + "operator": [ + {"id":1,"name":"remapvalue","output":"velocity", + "outputrangemin":"5 5 0","outputrangemax":"5 5 0", + "transformfunction":"simplexnoise","transforminputscale":10}, + {"id":2,"name":"remapvalue","output":"speed", + "outputrangemin":"50","outputrangemax":"50", + "transformfunction":"fbmnoise","transforminputscale":8} + ] + }"#; + let config: ParticleConfig = serde_json::from_str(json).unwrap(); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + // Particles spawn at the *end* of `step()`, after the per-particle + // update loop that applies `remapvalue` — a freshly-spawned particle + // hasn't had it applied yet on the frame it's born (same reasoning + // `remapvalue_sets_velocity_within_output_range` above steps + // multiple times for), so step twice before checking. + sys.step(1.0 / 30.0); + sys.step(1.0 / 30.0); + assert!(!sys.particles.is_empty(), "should have spawned particles"); + for p in &sys.particles { + // A degenerate [5,5]x[5,5] range collapses velocity's own remap + // to a fixed heading; the speed operator then rescales its + // magnitude to 50 — only observable if *both* operators ran. + let mag = (p.vx * p.vx + p.vy * p.vy).sqrt(); + assert!((mag - 50.0).abs() < 0.5, "speed {mag} not rescaled to ~50"); + } +} + +/// The general field-to-field form: `input=lifetimefraction, +/// operation=multiply, output=opacity` is exactly the shape the retail +/// binary's own `alphafade`-preset builders construct internally +/// (Ghidra report Follow-up (x), `FUN_1401bfbb0`/`FUN_1401bc4b0`) — a +/// lifetime-driven fade-out multiplier. Checks opacity decreases +/// monotonically as the particle ages toward its declared output range. +#[test] +fn remapvalue_multiply_by_lifetimefraction_fades_opacity() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"boxrandom","rate":1000,"distancemin":"0 0 0","distancemax":"0 0 0"}], + "initializer": [ + {"id":1,"name":"lifetimerandom","min":30,"max":30}, + {"id":2,"name":"alpharandom","min":1,"max":1} + ], + "operator": [ + {"id":1,"name":"remapvalue","input":"lifetimefraction", + "operation":"multiply","output":"opacity", + "inputrangemin":0,"inputrangemax":1, + "outputrangemin":1,"outputrangemax":0, + "transformfunction":"none"} + ] + }"#; + let config: ParticleConfig = serde_json::from_str(json).unwrap(); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + sys.step(1.0 / 30.0); + assert!(!sys.particles.is_empty(), "should have spawned particles"); + let alpha_early = sys.particles[0].alpha; + for _ in 0..20 { + sys.step(1.0 / 30.0); + } + assert!(!sys.particles.is_empty(), "particle should still be alive"); + let alpha_late = sys.particles[0].alpha; + assert!( + alpha_late < alpha_early, + "opacity should fade as lifetimefraction rises toward 1 (early={alpha_early}, late={alpha_late})" + ); +} + +/// `transformfunction:"none"` with a real `input` field is a straight +/// remap (default `operation`), no shaping — the simplest, most literal +/// case of the general form, targeting `size` this time. +#[test] +fn remapvalue_remaps_size_from_maxlifetime() { + let json = r#"{ + "maxcount": 5, + "emitter": [{"name":"boxrandom","rate":1000,"distancemin":"0 0 0","distancemax":"0 0 0"}], + "initializer": [ + {"id":1,"name":"lifetimerandom","min":40,"max":40}, + {"id":2,"name":"sizerandom","min":1,"max":1} + ], + "operator": [ + {"id":1,"name":"remapvalue","input":"maxlifetime", + "output":"size","transformfunction":"none", + "inputrangemin":0,"inputrangemax":40, + "outputrangemin":0,"outputrangemax":200} + ] + }"#; + let config: ParticleConfig = serde_json::from_str(json).unwrap(); + let mut sys = ParticleSystem::from_config(&config, [0.0, 0.0], None); + // See the comment in `multiple_remapvalue_operators_all_apply`: a + // freshly-spawned particle hasn't had `remapvalue` applied yet on + // its spawn frame. + sys.step(1.0 / 30.0); + sys.step(1.0 / 30.0); + assert!(!sys.particles.is_empty(), "should have spawned particles"); + for p in &sys.particles { + // maxlifetime=40 sits at the top of [0,40] -> size should land + // at (or very near) the top of [0,200], overriding sizerandom's + // spawn value of 1. + assert!( + (p.size - 200.0).abs() < 1.0, + "size {} not remapped from maxlifetime", + p.size + ); + } +} + +mod colorn_probe { + use super::*; + + #[test] + fn colorn_override_tints_the_spawn_color() { + let preset = r#"{ + "maxcount": 8, + "emitter": [{"name": "sphererandom", "id": 1, "rate": 100, "distancemax": "1 1 1"}], + "initializer": [ + {"id": 4, "max": "255 255 255", "min": "255 255 255", "name": "colorrandom"}, + {"id": 3, "max": 10, "min": 10, "name": "sizerandom"}, + {"id": 2, "max": 1, "min": 1, "name": "lifetimerandom"} + ], + "operator": [] + }"#; + let cfg: ParticleConfig = serde_json::from_str(preset).expect("preset parses"); + let ov: InstanceOverride = + serde_json::from_str(r#"{"colorn": "0.180 0.408 0.773", "id": null, "lifetime": 2.0}"#) + .expect("override parses"); + assert_eq!( + ov.colorn.as_ref().and_then(value_as_vec3_pub), + Some([0.18, 0.408, 0.773]) + ); + let mut sys = ParticleSystem::from_config(&cfg, [0.0, 0.0], Some(&ov)); + for _ in 0..20 { + sys.step(0.05); + } + let p = sys.particles.first().expect("something spawned"); + // `colorrandom` is pinned to white (min == max) so the tint is the only + // thing acting on the colour, and every spawned particle is identical. + // With a random base this assertion was only true for most samples: a + // blue draw near the low end of [47,255] tints to ~36, which is *below* + // the fixed red of 255*0.180 ≈ 46, so the suite failed at random. + let expected = [255.0 * 0.180, 255.0 * 0.408, 255.0 * 0.773]; + for (channel, want) in expected.iter().enumerate() { + let got = p.color[channel] as f32; + assert!( + (got - want).abs() <= 2.0, + "channel {channel} is {got}, expected ~{want}: {:?}", + p.color + ); + } + } +} diff --git a/src/engine/puppet.rs b/src/engine/sim/puppet.rs similarity index 100% rename from src/engine/puppet.rs rename to src/engine/sim/puppet.rs diff --git a/src/engine/scenescript_runtime.js b/src/engine/sim/scenescript_runtime.js similarity index 100% rename from src/engine/scenescript_runtime.js rename to src/engine/sim/scenescript_runtime.js diff --git a/src/engine/script.rs b/src/engine/sim/script.rs similarity index 100% rename from src/engine/script.rs rename to src/engine/sim/script.rs diff --git a/src/engine/timeline.rs b/src/engine/sim/timeline.rs similarity index 100% rename from src/engine/timeline.rs rename to src/engine/sim/timeline.rs diff --git a/src/engine/assets.rs b/src/formats/assets.rs similarity index 97% rename from src/engine/assets.rs rename to src/formats/assets.rs index ed41823..3b1018b 100644 --- a/src/engine/assets.rs +++ b/src/formats/assets.rs @@ -3,8 +3,8 @@ use image::RgbaImage; use serde::de::DeserializeOwned; use std::path::{Path, PathBuf}; -use super::pkg::Package; -use super::tex::TexFile; +use crate::formats::pkg::Package; +use crate::formats::tex::TexFile; /// Wallpaper asset resolver. /// @@ -53,7 +53,7 @@ impl AssetStore { root: root.to_path_buf(), dependency, packages, - assets_dir: super::shaders::loader::find_we_assets_dir(), + assets_dir: crate::engine::gpu::shaders::loader::find_we_assets_dir(), }) } diff --git a/src/formats/mod.rs b/src/formats/mod.rs new file mode 100644 index 0000000..d1f9d8d --- /dev/null +++ b/src/formats/mod.rs @@ -0,0 +1,7 @@ +//! Wallpaper Engine's file formats: `.pkg` archives, `.tex` textures, and +//! the asset store that looks files up in a wallpaper's folder, its +//! `scene.pkg`, and the WE install's shared assets. + +pub mod assets; +pub mod pkg; +pub mod tex; diff --git a/src/engine/pkg.rs b/src/formats/pkg.rs similarity index 100% rename from src/engine/pkg.rs rename to src/formats/pkg.rs diff --git a/src/engine/tex.rs b/src/formats/tex.rs similarity index 100% rename from src/engine/tex.rs rename to src/formats/tex.rs diff --git a/src/lib.rs b/src/lib.rs index 7fac5f7..b25270b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,13 @@ pub mod application; +pub mod content; #[cfg(target_os = "linux")] pub mod daemon; pub mod engine; +pub mod formats; pub mod logging; pub mod platform; -pub mod render; pub mod schedule; +pub mod services; pub mod settings; pub mod ui; pub mod workshop; diff --git a/src/logging.rs b/src/logging.rs index 88ff3bd..0d2a2ea 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -11,7 +11,7 @@ //! //! 1. `RUST_LOG` — if set, it wins outright and uses the full //! [`EnvFilter`](tracing_subscriber::EnvFilter) syntax, e.g. -//! `RUST_LOG=wp_engine::engine::effect=trace,wp_engine=info`. +//! `RUST_LOG=wp_engine::engine::cpu::effect=trace,wp_engine=info`. //! 2. `-v` flags on the CLI — mapped to a level for the `wp_engine` crate, //! with noisy dependencies (wgpu, naga, …) held back unless `-vvv`. //! diff --git a/src/main.rs b/src/main.rs index 68a114c..6521fed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,1334 +1,8 @@ -use anyhow::{anyhow, Context, Result}; -use clap::{Parser, Subcommand}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use wp_engine::application::{ApplicationContext, WallpaperApplication}; -use wp_engine::workshop::{self, Wallpaper}; -use wp_engine::{engine, platform, ui}; +//! The `wp-engine` binary: the desktop app when run without arguments, +//! otherwise the command line in [`cli`]. -#[derive(Parser)] -#[command( - name = "wp-engine", - about = "Wallpaper Engine client for Wayland", - long_about = "Browse and apply Steam Workshop wallpapers on Wayland, X11, and macOS.\n\ - Run without arguments to open the graphical interface." -)] -struct Cli { - #[command(subcommand)] - command: Option, - - /// Increase logging verbosity: -v (debug), -vv (trace), -vvv (+ deps). - /// `RUST_LOG` overrides this if set. - #[arg( - short, - long, - action = clap::ArgAction::Count, - global = true, - )] - verbose: u8, -} - -#[derive(Subcommand)] -enum Command { - /// List available Workshop wallpapers (CLI) - List { - #[arg(long, help = "Filter by type: video, scene, web, application")] - r#type: Option, - }, - /// Apply a wallpaper by Steam Workshop ID (via the background daemon) - Set { - id: String, - /// Override a user property: NAME=VALUE (repeatable; bare NAME = true) - #[arg(long = "set-property", value_name = "NAME=VALUE")] - properties: Vec, - /// Render quality for this run: Ultra, High, Medium, or Low - #[arg(long)] - quality: Option, - /// Persist these --set-property/--quality overrides for next time (see `config`) - #[arg(long)] - save: bool, - /// Render in this process until Ctrl-C instead of handing the - /// wallpaper to the background daemon - #[arg(long)] - foreground: bool, - }, - /// Apply a scene directory, video, image, or HTML file (via the background daemon) - SetFile { - path: PathBuf, - /// Override a user property: NAME=VALUE (repeatable; bare NAME = true) - #[arg(long = "set-property", value_name = "NAME=VALUE")] - properties: Vec, - /// Render quality for this run: Ultra, High, Medium, or Low - #[arg(long)] - quality: Option, - /// Persist these --set-property/--quality overrides for next time (see `config`) - #[arg(long)] - save: bool, - /// Render in this process until Ctrl-C instead of handing the - /// wallpaper to the background daemon - #[arg(long)] - foreground: bool, - }, - /// List the user-configurable properties of a Workshop item - ListProperties { id: String }, - /// Show metadata for a Workshop item (CLI) - Info { id: String }, - /// List available GPU adapters (PAL probe) - Probe, - /// Inspect (and optionally extract) a Wallpaper Engine PKG archive - PkgInfo { - path: PathBuf, - /// Dump all contained files to this directory - #[arg(long)] - dump: Option, - }, - /// Inspect a Wallpaper Engine .tex texture file - TexInfo { - path: PathBuf, - /// Save decoded texture as PNG - #[arg(long)] - save: Option, - }, - /// Render a scene wallpaper to a PNG file (for debugging) - RenderScene { - /// Workshop ID or directory path - id_or_path: String, - /// Output PNG path - #[arg(long, default_value = "scene_output.png")] - output: PathBuf, - /// Override a user property: NAME=VALUE (repeatable; bare NAME = true) - #[arg(long = "set-property", value_name = "NAME=VALUE")] - properties: Vec, - }, - /// Render a video, image or web wallpaper's frame to a PNG at a given - /// size (for debugging) - RenderFrame { - /// Workshop ID, directory, or file - id_or_path: String, - #[arg(long, default_value = "frame_output.png")] - output: PathBuf, - #[arg(long, default_value_t = 1920)] - width: u32, - #[arg(long, default_value_t = 1080)] - height: u32, - /// Frame rate handed to web pages - #[arg(long, default_value_t = 30)] - fps: u32, - /// Keep rendering this long before saving the latest frame - #[arg(long, default_value_t = 2.0)] - seconds: f32, - /// Override a user property: NAME=VALUE (repeatable) - #[arg(long = "set-property", value_name = "NAME=VALUE")] - properties: Vec, - }, - /// Preview an animated scene wallpaper in a window (for testing animation) - PreviewScene { - /// Workshop ID or directory path - id_or_path: String, - /// Window width - #[arg(long, default_value_t = 960)] - width: u32, - /// Window height - #[arg(long, default_value_t = 540)] - height: u32, - }, - /// Test whether a scene wallpaper animates (headless — no window required) - TestScene { - /// Workshop ID or directory path - id_or_path: String, - /// Number of frames to collect (at 30fps, 60 = 2 seconds) - #[arg(long, default_value_t = 60)] - frames: usize, - }, - /// Manage persisted settings: per-wallpaper property overrides, screen - /// assignments, and playlists (~/.config/wp-engine/settings.json) - Config { - #[command(subcommand)] - action: ConfigAction, - }, - /// Run using persisted settings — see `config` to set them up first - Run { - /// Render in this process until Ctrl-C instead of handing the - /// wallpaper to the background daemon - #[arg(long)] - foreground: bool, - }, - /// Run the single-instance wallpaper daemon in the foreground (it is - /// started automatically in the background when needed; run it directly - /// for systemd/autostart or to watch its log) - Daemon, - /// Show what the background daemon is displaying - Status, - /// Remove the wallpaper and stop the background daemon - Stop, - /// Control playlist playback in the background daemon - Playlist { - #[command(subcommand)] - action: PlaylistAction, - }, - /// Start `wp-engine run` at login: on, off, or show the current state - Autostart { - #[arg(value_parser = ["on", "off", "status"], default_value = "status")] - state: String, - }, -} - -#[derive(Subcommand)] -enum PlaylistAction { - /// Start playing a saved playlist (see `config set-playlist`) - Play { name: String }, - /// Skip to the next item - Next, - /// Go back to the previous item - Previous, - /// Stop rotating, keeping the current wallpaper - Stop, -} - -#[derive(Subcommand)] -enum ConfigAction { - /// Print the full persisted settings file - Show, - /// Persist a property override for a wallpaper without launching it - SetProperty { - id: String, - /// NAME=VALUE (repeatable; bare NAME = true) - #[arg(required = true, value_name = "NAME=VALUE")] - properties: Vec, - }, - /// Remove a persisted property override - UnsetProperty { id: String, name: String }, - /// Assign a wallpaper to a named screen/output - SetScreen { screen: String, id: String }, - /// Remove a screen's saved wallpaper assignment - UnsetScreen { screen: String }, - /// Set the fallback wallpaper `wp-engine run` uses with no screen assigned - SetDefault { id: String }, - /// Persist a render-quality override for a wallpaper: Ultra, High, Medium, or Low - SetQuality { id: String, quality: String }, - /// Remove a persisted render-quality override - UnsetQuality { id: String }, - /// Define or replace a playlist - SetPlaylist { - name: String, - /// Workshop IDs or paths, in playback order - #[arg(required = true)] - items: Vec, - #[arg(long, default_value_t = 60)] - delay: u32, - /// timer (every --delay minutes), daytime (each item at its --times - /// entry), dayofweek (item N on weekday N, Monday first), or login - /// (next item each time playback starts) - #[arg(long, default_value = "timer")] - mode: String, - /// sequential or random - #[arg(long, default_value = "sequential")] - order: String, - /// daytime mode: comma-separated start times, one per item ("07:00,19:30") - #[arg(long, value_delimiter = ',')] - times: Vec, - }, - /// Make `wp-engine run` play this playlist (omit NAME to clear) - SetActivePlaylist { name: Option }, - /// Remove a playlist - RemovePlaylist { name: String }, - /// Set what happens while a condition holds: CONDITION is focus, - /// maximized, fullscreen, audio, battery or lock; ACTION is run, mute, - /// pause or stop (pause and free memory) - SetPlayback { condition: String, action: String }, - /// Cap the frame rate of animated wallpapers (1-240) - SetFps { fps: u32 }, - /// How video, image and web wallpapers fit the screen: fill (crop, - /// default), fit (black bars), stretch, or center - SetScaling { mode: String }, - /// How one wallpaper covers several monitors: per-screen (default; see - /// set-screen), span (stretched across all of them) or clone (the same - /// picture on each) - SetLayout { layout: String }, -} +mod cli; fn main() { - // CEF launches its renderer/GPU/utility processes by re-executing THIS - // binary with `--type=...`. That must be handled before anything else — - // before clap, which would reject the flag, and before any thread starts. - // Returns `Some` only in a child process, which must then exit at once. - if let Some(code) = wp_engine::render::web::subprocess_main() { - std::process::exit(code); - } - - let cli = Cli::parse(); - wp_engine::logging::init(cli.verbose); - #[cfg(target_os = "linux")] - wp_engine::daemon::set_spawn_verbosity(cli.verbose); - tracing::debug!(target: "cli", verbosity = cli.verbose, "wp-engine starting"); - - let result = match cli.command { - // No subcommand → open GUI - None => run_ui(), - Some(Command::List { r#type }) => cmd_list(r#type), - Some(Command::Set { - id, - properties, - quality, - save, - foreground, - }) => cmd_set(&id, properties, quality, save, foreground), - Some(Command::SetFile { - path, - properties, - quality, - save, - foreground, - }) => cmd_set_file(&path, properties, quality, save, foreground), - Some(Command::ListProperties { id }) => cmd_list_properties(&id), - Some(Command::Info { id }) => cmd_info(&id), - Some(Command::Probe) => cmd_probe(), - Some(Command::PkgInfo { path, dump }) => cmd_pkg_info(&path, dump.as_deref()), - Some(Command::TexInfo { path, save }) => cmd_tex_info(&path, save.as_deref()), - Some(Command::RenderScene { - id_or_path, - output, - properties, - }) => cmd_render_scene(&id_or_path, &output, properties), - Some(Command::PreviewScene { - id_or_path, - width, - height, - }) => cmd_preview_scene(&id_or_path, width, height), - Some(Command::TestScene { id_or_path, frames }) => cmd_test_scene(&id_or_path, frames), - Some(Command::RenderFrame { - id_or_path, - output, - width, - height, - fps, - seconds, - properties, - }) => cmd_render_frame( - &id_or_path, - &output, - (width, height), - fps, - seconds, - properties, - ), - Some(Command::Config { action }) => cmd_config(action), - Some(Command::Run { foreground }) => cmd_run(foreground), - Some(Command::Daemon) => cmd_daemon(), - Some(Command::Status) => cmd_status(), - Some(Command::Stop) => cmd_stop(), - Some(Command::Playlist { action }) => cmd_playlist(action), - Some(Command::Autostart { state }) => cmd_autostart(&state), - }; - - if let Err(e) = result { - eprintln!("error: {e:#}"); - std::process::exit(1); - } -} - -// ── GUI ─────────────────────────────────────────────────────────────────────── - -fn run_ui() -> Result<()> { - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("wp-engine") - .with_inner_size([1100.0, 700.0]) - .with_min_inner_size([800.0, 500.0]), - ..Default::default() - }; - - eframe::run_native( - "wp-engine", - options, - Box::new(|cc| Ok(Box::new(ui::WpApp::new(cc)))), - ) - .map_err(|e| anyhow!("GUI error: {e}")) -} - -// ── CLI commands ────────────────────────────────────────────────────────────── - -fn cmd_list(type_filter: Option) -> Result<()> { - let wallpapers = workshop::scan_wallpapers(); - - if wallpapers.is_empty() { - println!("No Wallpaper Engine Workshop items found."); - println!(); - println!( - "Expected path: ~/.local/share/Steam/steamapps/workshop/content/{}/", - workshop::WALLPAPER_ENGINE_APP_ID - ); - return Ok(()); - } - - let items: Vec<&Wallpaper> = wallpapers - .iter() - .filter(|w| { - type_filter - .as_deref() - .map(|f| w.wallpaper_type().to_string() == f) - .unwrap_or(true) - }) - .collect(); - - println!("{:<20} {:<12} {}", "ID", "Type", "Title"); - println!("{}", "─".repeat(64)); - for w in &items { - println!( - "{:<20} {:<12} {}", - w.workshop_id, - w.wallpaper_type(), - w.title() - ); - } - println!("\n{} wallpaper(s)", items.len()); - Ok(()) -} - -fn cmd_info(id: &str) -> Result<()> { - let w = workshop::find_by_id(id).ok_or_else(|| anyhow!("workshop item '{id}' not found"))?; - - println!("ID: {}", w.workshop_id); - println!("Title: {}", w.title()); - println!("Type: {}", w.wallpaper_type()); - println!("Path: {}", w.path.display()); - if let Some(f) = w.wallpaper_file() { - println!( - "File: {} {}", - f.display(), - if f.exists() { "" } else { "(MISSING)" } - ); - } - if let Some(tags) = &w.project.tags { - if !tags.is_empty() { - println!("Tags: {}", tags.join(", ")); - } - } - if let Some(rating) = &w.project.contentrating { - if !rating.is_empty() { - println!("Rating: {rating}"); - } - } - if let Some(description) = &w.project.description { - if !description.is_empty() { - println!("Description: {}", description.replace('\n', " ")); - } - } - Ok(()) -} - -fn cmd_set( - id: &str, - properties: Vec, - quality: Option, - save: bool, - foreground: bool, -) -> Result<()> { - let w = workshop::find_by_id(id).ok_or_else(|| anyhow!("workshop item '{id}' not found"))?; - - println!("Loading: {}", w.path.display()); - println!("Applying \"{}\" to all outputs...", w.title()); - - let mut context = ApplicationContext::new(w.path.clone()); - apply_saved_settings(&mut context, id, &properties, quality.as_deref(), save)?; - launch(context, Some(w.title().to_string()), foreground) -} - -/// Installs property overrides and a quality level on `context` in priority -/// order (saved < CLI flags — `--set-property`/`--quality` — later write -/// wins), and — when `save` is set and there's anything new to save — -/// persists the CLI-provided values under `id_or_path` (via `settings:: -/// normalize_key`) for next time. -fn apply_saved_settings( - context: &mut ApplicationContext, - id_or_path: &str, - cli_properties: &[String], - cli_quality: Option<&str>, - save: bool, -) -> Result<()> { - let mut settings = wp_engine::settings::WpSettings::load(); - let saved = settings.property_overrides(id_or_path); - if !saved.is_empty() { - println!( - "Loaded {} saved property override(s) from {}", - saved.len(), - wp_engine::settings::settings_path().display() - ); - } - context.properties.extend(saved); - context.add_property_args(cli_properties); - - let saved_quality = settings.quality_override(id_or_path); - let cli_quality_parsed = cli_quality - .map(|s| { - wp_engine::platform::RenderQuality::parse(s).ok_or_else(|| { - anyhow!("invalid --quality '{s}' (expected Ultra, High, Medium, or Low)") - }) - }) - .transpose()?; - if let Some(q) = cli_quality_parsed.or(saved_quality) { - if cli_quality_parsed.is_none() { - println!("Loaded saved quality: {}", q.label()); - } - context.settings.lock().unwrap().quality = q; - } - - if save { - let mut saved_anything = false; - for arg in cli_properties { - let (name, value) = engine::properties::parse_property_arg(arg); - settings.set_property(id_or_path, &name, &value); - saved_anything = true; - } - if !cli_properties.is_empty() { - println!( - "Saved {} property override(s) for next time", - cli_properties.len() - ); - } - if let Some(q) = cli_quality_parsed { - settings.set_quality(id_or_path, q); - println!("Saved quality override for next time: {}", q.label()); - saved_anything = true; - } - if saved_anything { - settings.save()?; - } - } - Ok(()) -} - -fn cmd_list_properties(id: &str) -> Result<()> { - let w = workshop::find_by_id(id).ok_or_else(|| anyhow!("workshop item '{id}' not found"))?; - let all_props = engine::properties::list_properties(&w.path)?; - if all_props.is_empty() { - println!( - "\"{}\" declares no user-configurable properties.", - w.title() - ); - return Ok(()); - } - // Decorative declarations (HTML separators/section headers — real - // content ships plenty of these) aren't things a user can actually - // set; listing them as if they were is misleading, not just noisy. - let (settable, decorative): (Vec<_>, Vec<_>) = - all_props.into_iter().partition(|p| p.is_settable()); - - if settable.is_empty() { - println!( - "\"{}\" declares no settable properties ({} decorative label(s) only).", - w.title(), - decorative.len() - ); - return Ok(()); - } - - println!("{:<28} {:<8} {:<24} {}", "Name", "Type", "Default", "Label"); - println!("{}", "─".repeat(80)); - for p in &settable { - println!("{:<28} {:<8} {:<24} {}", p.name, p.kind, p.value, p.text); - if p.kind == "combo" && !p.options.is_empty() { - let choices = p - .options - .iter() - .map(|(value, label)| format!("{value}={label}")) - .collect::>() - .join(", "); - println!("{:<28} {:<8} choices: {choices}", "", ""); - } else if p.kind == "slider" { - if let (Some(min), Some(max)) = (p.min, p.max) { - let step = p.step.map(|s| format!(", step {s}")).unwrap_or_default(); - println!("{:<28} {:<8} range: {min}..={max}{step}", "", ""); - } - } - } - if !decorative.is_empty() { - println!( - "\n({} decorative label(s) not shown — no real value to set)", - decorative.len() - ); - } - println!("\nOverride with: wp-engine set {id} --set-property NAME=VALUE"); - Ok(()) -} - -fn cmd_config(action: ConfigAction) -> Result<()> { - use wp_engine::settings::{Playlist, WpSettings}; - - let mut settings = WpSettings::load(); - // Property edits to show live on a running daemon once saved. - let mut live: Option<(String, Vec)> = None; - - match action { - ConfigAction::Show => { - let path = wp_engine::settings::settings_path(); - println!("Settings file: {}", path.display()); - if !path.exists() { - println!("(nothing saved yet)"); - return Ok(()); - } - println!("{}", serde_json::to_string_pretty(&settings)?); - return Ok(()); - } - ConfigAction::SetProperty { id, properties } => { - let mut names = Vec::new(); - for arg in &properties { - let (name, value) = engine::properties::parse_property_arg(arg); - println!("{id}: {name} = {value}"); - settings.set_property(&id, &name, &value); - names.push(name); - } - live = Some((id, names)); - } - ConfigAction::UnsetProperty { id, name } => { - if settings.unset_property(&id, &name) { - println!("Removed {id}: {name}"); - live = Some((id, vec![name])); - } else { - println!("No saved override for {id}: {name}"); - return Ok(()); - } - } - ConfigAction::SetScreen { screen, id } => { - settings.set_screen(&screen, &id); - println!("{screen} -> {id}"); - } - ConfigAction::UnsetScreen { screen } => { - if settings.unset_screen(&screen) { - println!("Removed screen assignment: {screen}"); - } else { - println!("No saved assignment for screen: {screen}"); - return Ok(()); - } - } - ConfigAction::SetDefault { id } => { - settings.set_default_background(&id); - println!("Default background -> {id}"); - } - ConfigAction::SetQuality { id, quality } => { - let q = platform::RenderQuality::parse(&quality).ok_or_else(|| { - anyhow!("invalid quality '{quality}' (expected Ultra, High, Medium, or Low)") - })?; - settings.set_quality(&id, q); - println!("{id}: quality = {}", q.label()); - } - ConfigAction::UnsetQuality { id } => { - if settings.unset_quality(&id) { - println!("Removed quality override for {id}"); - } else { - println!("No saved quality override for {id}"); - return Ok(()); - } - } - ConfigAction::SetPlaylist { - name, - items, - delay, - mode, - order, - times, - } => { - if !["timer", "daytime", "dayofweek", "login"].contains(&mode.as_str()) { - return Err(anyhow!( - "invalid mode '{mode}' (expected timer, daytime, dayofweek or login)" - )); - } - if !["sequential", "random"].contains(&order.as_str()) { - return Err(anyhow!( - "invalid order '{order}' (expected sequential or random)" - )); - } - if mode == "daytime" { - if times.len() != items.len() { - return Err(anyhow!("daytime mode needs one --times entry per item")); - } - if let Some(bad) = times - .iter() - .find(|t| wp_engine::schedule::parse_time_of_day(t).is_none()) - { - return Err(anyhow!("invalid time '{bad}' (expected HH:MM)")); - } - } - println!("Playlist '{name}': {} item(s)", items.len()); - settings.playlists.insert( - name, - Playlist { - items, - delay_minutes: delay, - mode, - order, - times, - }, - ); - } - ConfigAction::SetActivePlaylist { name } => { - if let Some(n) = &name { - if !settings.playlists.contains_key(n) { - return Err(anyhow!("no playlist named '{n}'")); - } - println!("`wp-engine run` plays playlist '{n}'"); - } else { - println!("No active playlist"); - } - settings.active_playlist = name; - } - ConfigAction::SetPlayback { condition, action } => { - let a = wp_engine::engine::playback_gate::PlaybackAction::parse(&action).ok_or_else( - || anyhow!("invalid action '{action}' (expected run, mute, pause or stop)"), - )?; - if !settings.playback.set(&condition, a) { - return Err(anyhow!( - "invalid condition '{condition}' (expected focus, maximized, fullscreen, audio, battery or lock)" - )); - } - println!("While {condition}: {}", a.label()); - } - ConfigAction::SetScaling { mode } => { - let m = wp_engine::render::ScaleMode::parse(&mode).ok_or_else(|| { - anyhow!("invalid mode '{mode}' (expected fill, fit, stretch or center)") - })?; - settings.scaling = Some(m); - println!("Scaling: {}", m.label()); - } - ConfigAction::SetLayout { layout } => { - let l = wp_engine::render::ScreenLayout::parse(&layout).ok_or_else(|| { - anyhow!("invalid layout '{layout}' (expected per-screen, span or clone)") - })?; - settings.layout = Some(l); - println!( - "Layout: {} (applies the next time a wallpaper is set)", - l.label() - ); - } - ConfigAction::SetFps { fps } => { - if !(1..=240).contains(&fps) { - return Err(anyhow!("fps must be between 1 and 240")); - } - settings.fps = Some(fps); - println!("Frame-rate cap: {fps} fps"); - } - ConfigAction::RemovePlaylist { name } => { - if settings.playlists.remove(&name).is_some() { - println!("Removed playlist: {name}"); - } else { - println!("No such playlist: {name}"); - return Ok(()); - } - } - } - - settings.save()?; - if let Some((wallpaper, names)) = live.filter(|_| wp_engine::daemon::is_running()) { - let _ = wp_engine::daemon::request(&wp_engine::daemon::Request::PropertiesChanged { - wallpaper, - names, - }); - } - Ok(()) -} - -/// A workshop ID or a filesystem path, resolved to a real path on disk — -/// the same "ID first, then treat it as a path" rule `test-scene`/ -/// `render-scene` already use. -fn resolve_id_or_path(id_or_path: &str) -> Result { - if let Some(w) = workshop::find_by_id(id_or_path) { - return Ok(w.path); - } - let path = PathBuf::from(id_or_path); - if path.exists() { - Ok(path) - } else { - Err(anyhow!( - "not a directory and workshop item '{id_or_path}' not found" - )) - } -} - -/// [`resolve_id_or_path`] for the scene tools: a preset item resolves to the -/// item it builds on, with the preset's values registered as overrides. -fn cmd_autostart(state: &str) -> Result<()> { - if state != "status" { - wp_engine::settings::set_autostart(state == "on")?; - } - if wp_engine::settings::autostart_enabled() { - let path = wp_engine::settings::autostart_path(); - println!("Autostart on ({})", path.display()); - } else { - println!("Autostart off"); - } - Ok(()) -} - -fn resolve_scene_dir(id_or_path: &str) -> Result { - let dir = resolve_id_or_path(id_or_path)?; - wp_engine::render::WallpaperContent::preset_base_dir(&dir) -} - -/// Runs using persisted settings (`wp-engine config` to set them up first): -/// every `screens` entry gets its own independent wallpaper *and* its own -/// independent render quality on its own output (real per-screen rendering -/// — see `render::ScreenContent`/`render::ScreenSettings`); any output with -/// no specific entry falls back to the first configured screen's wallpaper, -/// else `default_background`, else the first playlist's first item. -/// -/// Honest scope note: property overrides are still process-global (see -/// `engine::properties`), so only the *default* background's saved -/// overrides are applied — a per-screen wallpaper's own saved overrides -/// aren't isolated from each other yet if two different screens' wallpapers -/// happen to declare a property with the same name. With an active -/// playlist (`config set-active-playlist`), the daemon plays that instead. -fn cmd_run(foreground: bool) -> Result<()> { - let settings = wp_engine::settings::WpSettings::load(); - - #[cfg(target_os = "linux")] - if let (Some(name), false) = (&settings.active_playlist, foreground) { - cmd_playlist(PlaylistAction::Play { name: name.clone() })?; - println!("Playing playlist '{name}' (background daemon)."); - return Ok(()); - } - - let default_id_or_path = settings - .screens - .values() - .next() - .or(settings.default_background.as_ref()) - .or_else(|| { - settings - .playlists - .values() - .next() - .and_then(|p| p.items.first()) - }) - .ok_or_else(|| { - anyhow!( - "no saved wallpaper — use `wp-engine config set-screen`/`set-default`/\ - `set-playlist` first, or `wp-engine set ` directly" - ) - })? - .clone(); - let default_path = resolve_id_or_path(&default_id_or_path)?; - - println!("Default background: {default_id_or_path}"); - let mut context = ApplicationContext::new(default_path); - for (screen, id_or_path) in &settings.screens { - match resolve_id_or_path(id_or_path) { - Ok(path) => { - print!("Screen '{screen}': {id_or_path}"); - context.screens.insert(screen.clone(), path); - // Independent per-screen quality — looked up by the - // *original* id-or-path string (not the resolved path - // above): that's the same key `settings::normalize_key` - // would derive from it, and using the resolved path here - // instead would silently miss every workshop-ID-keyed - // saved override (a real bug this comment exists to head - // off — `normalize_key("2821407073")` and - // `normalize_key("/path/to/2821407073")` are NOT the same - // key). - if let Some(q) = settings.quality_override(id_or_path) { - println!(" (quality: {})", q.label()); - context.screen_settings.insert( - screen.clone(), - Arc::new(Mutex::new(wp_engine::render::RenderSettings { - quality: q, - fps: context.settings.lock().unwrap().fps, - ..Default::default() - })), - ); - } else { - println!(); - } - } - Err(e) => println!("Screen '{screen}': {id_or_path} — {e} (skipping)"), - } - } - - apply_saved_settings(&mut context, &default_id_or_path, &[], None, false)?; - launch(context, Some(default_id_or_path), foreground) -} - -/// Show `context`'s wallpaper: by default hand it to the single background -/// daemon (starting it if needed) and return, replacing whatever it was -/// showing; with `foreground`, render in this process until Ctrl-C. -/// -/// macOS always renders in the foreground — its backend needs the process -/// main thread, and its GUI launches wallpapers as `set-file` children. -fn launch(context: ApplicationContext, title: Option, foreground: bool) -> Result<()> { - #[cfg(target_os = "linux")] - { - use wp_engine::daemon::{self, ApplySpec, Request}; - if !foreground { - daemon::request_or_spawn(&Request::Apply(ApplySpec::from_context(&context, title)))?; - println!("Wallpaper active (background daemon). `wp-engine stop` removes it."); - return Ok(()); - } - if daemon::is_running() { - eprintln!( - "warning: a wp-engine daemon is also running — both will draw the wallpaper \ - (`wp-engine stop` stops it)" - ); - } - } - let _ = (&title, foreground); - let mut app = WallpaperApplication::new(context); - app.setup()?; - println!("Wallpaper active. Press Ctrl-C to exit."); - app.show() -} - -#[cfg(target_os = "linux")] -fn cmd_daemon() -> Result<()> { - wp_engine::daemon::run_daemon() -} - -#[cfg(target_os = "linux")] -fn cmd_status() -> Result<()> { - use wp_engine::daemon::{self, Request, Response}; - if !daemon::is_running() { - println!("Daemon: not running"); - return Ok(()); - } - let Response::Status(status) = daemon::request(&Request::Status)? else { - return Err(anyhow!("unexpected daemon reply to status")); - }; - println!("Daemon: running (pid {})", status.pid); - match status.active { - Some(active) => { - if let Some(title) = &active.title { - println!("Wallpaper: {title}"); - } - println!("Path: {}", active.background.display()); - println!("Quality: {}", active.quality); - } - None => println!("Wallpaper: none"), - } - if let Some((name, index, count)) = status.playlist { - println!("Playlist: {name} (item {} of {count})", index + 1); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn cmd_stop() -> Result<()> { - use wp_engine::daemon::{self, Request}; - if !daemon::is_running() { - println!("Daemon: not running"); - return Ok(()); - } - daemon::request(&Request::Shutdown)?; - println!("Daemon stopped."); - Ok(()) -} - -#[cfg(not(target_os = "linux"))] -fn cmd_daemon() -> Result<()> { - Err(anyhow!("the background daemon is Linux-only")) -} - -#[cfg(target_os = "linux")] -fn cmd_playlist(action: PlaylistAction) -> Result<()> { - use wp_engine::daemon::{self, Request, Response}; - let req = match &action { - PlaylistAction::Play { name } => Request::PlayPlaylist { name: name.clone() }, - PlaylistAction::Next => Request::NextItem, - PlaylistAction::Previous => Request::PreviousItem, - PlaylistAction::Stop => Request::StopPlaylist, - }; - let reply = if matches!(action, PlaylistAction::Play { .. }) { - daemon::request_or_spawn(&req)? - } else { - daemon::request(&req)? - }; - if let Response::Error { message } = reply { - return Err(anyhow!(message)); - } - println!("OK"); - Ok(()) -} - -#[cfg(not(target_os = "linux"))] -fn cmd_playlist(_action: PlaylistAction) -> Result<()> { - cmd_daemon() -} - -#[cfg(not(target_os = "linux"))] -fn cmd_status() -> Result<()> { - cmd_daemon() -} - -#[cfg(not(target_os = "linux"))] -fn cmd_stop() -> Result<()> { - cmd_daemon() -} - -fn cmd_probe() -> Result<()> { - // ── 1. List all visible adapters ───────────────────────────────────────── - let adapters = platform::probe_adapters(); - if adapters.is_empty() { - println!("No GPU adapters found."); - return Ok(()); - } - println!( - "{:<40} {:<10} {:<12} {}", - "Name", "Backend", "Type", "PCI IDs" - ); - println!("{}", "─".repeat(74)); - for a in &adapters { - println!( - "{:<40} {:<10} {:<12} {:04x}:{:04x}", - a.name, a.backend, a.device_type, a.vendor_id, a.device_id - ); - } - println!("\n{} adapter(s) found\n", adapters.len()); - let outputs = platform::display::output_names(); - if !outputs.is_empty() { - println!( - "Screens (for `config set-screen`): {}\n", - outputs.join(", ") - ); - } - - // ── 2. Open the best device to confirm driver access works ─────────────── - print!("Opening best device... "); - match platform::open_device() { - Ok(dev) => println!( - "OK → {} ({}, {})", - dev.info.name, dev.info.backend, dev.info.device_type - ), - Err(e) => println!("FAILED: {e}"), - } - - Ok(()) -} - -fn cmd_set_file( - path: &PathBuf, - properties: Vec, - quality: Option, - save: bool, - foreground: bool, -) -> Result<()> { - if !path.exists() { - return Err(anyhow!("file not found: {}", path.display())); - } - println!("Loading: {}", path.display()); - println!("Applying to all outputs…"); - - let mut context = ApplicationContext::new(path.clone()); - let key = path.display().to_string(); - apply_saved_settings(&mut context, &key, &properties, quality.as_deref(), save)?; - let title = path.file_name().map(|n| n.to_string_lossy().into_owned()); - launch(context, title, foreground) -} - -fn cmd_pkg_info(path: &std::path::Path, dump: Option<&std::path::Path>) -> Result<()> { - let pkg = engine::Package::from_file(path)?; - - println!("Files : {}", pkg.len()); - println!("Empty : {}", pkg.is_empty()); - println!( - "Scene : {}", - if pkg.contains("scene.json") { - "yes" - } else { - "no" - } - ); - println!(); - - // Collect and sort paths for a stable, readable table. - let mut paths: Vec<&str> = pkg.paths().collect(); - paths.sort_unstable(); - - for p in &paths { - let size_kb = pkg.get(p).map(|b| b.len()).unwrap_or(0) / 1024; - println!("{size_kb:>6} KB {p}"); - } - - if let Some(dump_dir) = dump { - std::fs::create_dir_all(dump_dir) - .with_context(|| format!("failed to create dump directory: {}", dump_dir.display()))?; - - let mut count = 0usize; - for p in &paths { - if let Some(bytes) = pkg.get(p) { - let dest = dump_dir.join(p); - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent).with_context(|| { - format!("failed to create directory: {}", parent.display()) - })?; - } - std::fs::write(&dest, bytes) - .with_context(|| format!("failed to write: {}", dest.display()))?; - count += 1; - } - } - println!("\nExtracted {count} files to {}", dump_dir.display()); - } - - Ok(()) -} - -fn cmd_tex_info(path: &std::path::Path, save: Option<&std::path::Path>) -> Result<()> { - let data = std::fs::read(path)?; - let tex = engine::TexFile::parse(&data)?; - println!("Format: {:?}", tex.format()); - println!("Image size: {}x{}", tex.image_width, tex.image_height); - println!("Texture size: {}x{}", tex.texture_width, tex.texture_height); - println!("Flags: 0x{:x}", tex.flags()); - println!( - "Frames: {} (animated: {})", - tex.frames().len(), - tex.is_animated() - ); - if let Some(out) = save { - let img = tex.to_rgba()?; - img.save(out)?; - println!("Saved to {}", out.display()); - } - Ok(()) -} - -fn cmd_render_scene( - id_or_path: &str, - output: &std::path::Path, - properties: Vec, -) -> Result<()> { - let overrides = properties - .iter() - .map(|p| engine::properties::parse_property_arg(p)) - .collect(); - engine::properties::set_global_overrides(overrides); - - let dir = resolve_scene_dir(id_or_path)?; - println!("Loading scene from {}...", dir.display()); - let graph = engine::SceneGraph::from_directory(&dir)?; - println!("Scene graph: {}", graph.stats().summary()); - - let scene = engine::ResolvedScene::from_directory(&dir)?; - println!( - "Rendering {}x{} with {} layers...", - scene.width, - scene.height, - scene.layers.len() - ); - for layer in scene.layers.iter().take(3) { - println!( - "Layer: {} origin={:.1},{:.1},{:.1} size={:.1},{:.1},{:.1} parallax={:.3},{:.3}", - if layer.name.is_empty() { - "" - } else { - &layer.name - }, - layer.origin[0], - layer.origin[1], - layer.origin[2], - layer.size[0], - layer.size[1], - layer.size[2], - layer.parallax_depth[0], - layer.parallax_depth[1], - ); - } - let img = scene.render(); - img.save(output)?; - println!("Saved to {}", output.display()); - Ok(()) -} - -fn cmd_render_frame( - id_or_path: &str, - output: &std::path::Path, - size: (u32, u32), - fps: u32, - seconds: f32, - properties: Vec, -) -> Result<()> { - engine::properties::set_global_overrides( - properties - .iter() - .map(|p| engine::properties::parse_property_arg(p)) - .collect(), - ); - let path = resolve_id_or_path(id_or_path)?; - let content = wp_engine::render::WallpaperContent::from_any_path(&path)?; - let mut source = wp_engine::render::FrameSource::from_content_with( - content, - wp_engine::render::web::WebOptions { size, fps }, - )?; - let until = std::time::Instant::now() + std::time::Duration::from_secs_f32(seconds.max(0.0)); - while std::time::Instant::now() < until { - source.try_advance(); - std::thread::sleep(std::time::Duration::from_millis(10)); - } - let frame = source.current_frame(); - frame.save(output)?; - println!( - "Saved {}x{} frame to {}", - frame.width(), - frame.height(), - output.display() - ); - Ok(()) -} - -fn cmd_test_scene(id_or_path: &str, num_frames: usize) -> Result<()> { - use std::sync::{mpsc::sync_channel, Arc}; - - let dir = resolve_scene_dir(id_or_path)?; - - println!( - "GPU scene animation test: collecting {num_frames} frames from {}", - dir.display() - ); - let (tx, rx) = sync_channel::>(2); - let render_dir = dir.clone(); - let handle = std::thread::spawn(move || { - if let Err(e) = engine::gpu_renderer::gpu_scene_render_loop(&render_dir, &tx, 30.0) { - tracing::error!(target: "render", "gpu scene error: {e}"); - } - }); - - let mut collected = Vec::with_capacity(num_frames); - for i in 0..num_frames { - match rx.recv() { - Ok(frame) => { - if i == 0 || i == num_frames / 2 || i == num_frames - 1 { - eprintln!(" frame {i}: {}x{}", frame.width(), frame.height()); - } - collected.push(frame); - } - Err(_) => { - eprintln!(" channel closed after {} frames", collected.len()); - break; - } - } - } - - // Drop the receiver so the render loop's next `send` fails and it returns, - // then join it: letting the process exit while it's still mid-frame (still - // holding the wgpu device/queue) can crash the driver on shutdown. - drop(rx); - let _ = handle.join(); - - // Headless GPU-frame capture for debugging "renders weirdly" reports - // (the only way to see a perspective/live-path frame off-screen). - if let Ok(path) = std::env::var("WP_DEBUG_DUMP_FRAME") { - if let Some(f) = collected.last() { - let _ = f.save(&path); - eprintln!(" saved last frame to {path}"); - } - } - - if collected.len() < 2 { - anyhow::bail!("not enough frames collected (got {})", collected.len()); - } - - // Compare frame[0] against every other frame; take the maximum change found. - // Using max rather than first-vs-last avoids false negatives when a periodic - // effect's endpoints happen to land near the same phase. - let first = collected.first().unwrap(); - let first_bytes = first.as_raw(); - let total_pixels = (first.width() * first.height()) as usize; - - fn count_changed(a: &[u8], b: &[u8]) -> usize { - a.chunks(4) - .zip(b.chunks(4)) - .filter(|(a, b)| { - let dr = (a[0] as i32 - b[0] as i32).abs(); - let dg = (a[1] as i32 - b[1] as i32).abs(); - let db = (a[2] as i32 - b[2] as i32).abs(); - dr + dg + db > 6 - }) - .count() - } - - let (best_frame, changed_pixels) = collected - .iter() - .enumerate() - .skip(1) - .map(|(i, f)| (i, count_changed(first_bytes, f.as_raw()))) - .max_by_key(|(_, n)| *n) - .unwrap_or((0, 0)); - - let pct = changed_pixels as f64 / total_pixels as f64 * 100.0; - println!( - "\nCollected {} frames at 30fps (~{:.1}s)", - collected.len(), - collected.len() as f64 / 30.0 - ); - println!("Changed pixels (frame[0] vs frame[{best_frame}]): {changed_pixels}/{total_pixels} ({pct:.1}%)"); - - if pct > 0.1 { - println!("PASS: scene IS animating ({pct:.1}% of pixels changed)"); - } else { - println!( - "FAIL: scene appears static (only {pct:.2}% changed — likely effect/rendering bug)" - ); - } - - // Save first and best-changed frames for visual inspection - let last = collected.last().unwrap(); - first.save("/tmp/wp_frame_first.png")?; - last.save("/tmp/wp_frame_last.png")?; - println!("Saved /tmp/wp_frame_first.png and /tmp/wp_frame_last.png for visual comparison"); - - Ok(()) -} - -fn cmd_preview_scene(id_or_path: &str, width: u32, height: u32) -> Result<()> { - use image::imageops::FilterType; - use minifb::{Key, Window, WindowOptions}; - use std::sync::{mpsc::sync_channel, Arc}; - - let dir = resolve_scene_dir(id_or_path)?; - - println!("Loading scene from {}...", dir.display()); - let (tx, rx) = sync_channel::>(2); - let render_dir = dir.clone(); - let handle = std::thread::spawn(move || { - if let Err(e) = engine::gpu_renderer::gpu_scene_render_loop(&render_dir, &tx, 30.0) { - tracing::error!(target: "render", "gpu scene error: {e}"); - } - }); - - println!("Waiting for first frame..."); - let mut current = rx.recv()?; - println!( - "First frame received — opening {}x{} preview window. Press Esc to quit.", - width, height - ); - - let mut window = Window::new( - "wp-engine preview", - width as usize, - height as usize, - WindowOptions::default(), - )?; - - while window.is_open() && !window.is_key_down(Key::Escape) { - if let Ok(frame) = rx.try_recv() { - current = frame; - } - - let scaled = image::imageops::resize(&*current, width, height, FilterType::Nearest); - let buf: Vec = scaled - .pixels() - .map(|p| { - let [r, g, b, _a] = p.0; - (r as u32) << 16 | (g as u32) << 8 | b as u32 - }) - .collect(); - - window - .update_with_buffer(&buf, width as usize, height as usize) - .unwrap_or(()); - std::thread::sleep(std::time::Duration::from_millis(16)); - } - - // See cmd_test_scene: join the render thread before exiting so it isn't - // killed mid-frame while still holding the wgpu device/queue. - drop(rx); - let _ = handle.join(); - - Ok(()) + cli::main(); } diff --git a/src/platform/display.rs b/src/platform/display.rs index 716d3e5..61c1617 100644 --- a/src/platform/display.rs +++ b/src/platform/display.rs @@ -1,7 +1,7 @@ use anyhow::Result; -use crate::render::ScreenContent; -use crate::render::ScreenSettings; +use crate::content::ScreenContent; +use crate::content::ScreenSettings; // ── Private inner trait ─────────────────────────────────────────────────────── diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index e08e9fd..c767888 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -33,8 +33,8 @@ use winit::window::{Window, WindowId}; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; use super::GpuDevice; -use crate::engine::gpu_renderer::GpuSceneInstance; -use crate::render::{ScreenContent, ScreenSettings, WallpaperContent}; +use crate::content::{ScreenContent, ScreenSettings, WallpaperContent}; +use crate::engine::gpu::renderer::GpuSceneInstance; // ── Platform implementation ─────────────────────────────────────────────────── diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 7d0173f..6ab18dd 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,11 +1,9 @@ -pub mod audio; pub mod device; pub mod display; pub mod gpu; #[cfg(target_os = "macos")] pub mod macos; pub mod platform_info; -pub mod power; pub mod scaler; #[cfg(target_os = "linux")] pub mod wayland; diff --git a/src/platform/scaler.rs b/src/platform/scaler.rs index e49a019..3ce0156 100644 --- a/src/platform/scaler.rs +++ b/src/platform/scaler.rs @@ -153,9 +153,9 @@ impl GpuScaler { dst_w: u32, dst_h: u32, quality: RenderQuality, - mode: crate::render::ScaleMode, + mode: crate::content::ScaleMode, ) -> Vec { - let full = crate::render::settings::FULL_REGION; + let full = crate::content::settings::FULL_REGION; self.scale_region(src, dst_w, dst_h, quality, mode, (dst_w, dst_h), full) } @@ -168,7 +168,7 @@ impl GpuScaler { dst_w: u32, dst_h: u32, quality: RenderQuality, - mode: crate::render::ScaleMode, + mode: crate::content::ScaleMode, canvas: (u32, u32), region: [f32; 4], ) -> Vec { @@ -400,7 +400,7 @@ mod tests { 8, 8, RenderQuality::Ultra, - crate::render::ScaleMode::Fit, + crate::content::ScaleMode::Fit, ); let px = |x: usize, y: usize| &out[(y * 8 + x) * 4..(y * 8 + x) * 4 + 4]; assert_eq!(px(4, 0), &[0, 0, 0, 255], "top bar is black"); diff --git a/src/platform/wayland/mod.rs b/src/platform/wayland/mod.rs index 97bf6cc..5d8ff6b 100644 --- a/src/platform/wayland/mod.rs +++ b/src/platform/wayland/mod.rs @@ -41,26 +41,22 @@ use wayland_client::{ }; // Manually dispatched (no smithay-client-toolkit delegate exists for this // protocol) — playback-pause's fullscreen/maximized-app detection, see -// `engine::playback_gate` (Ghidra report Follow-up (ff)). +// `services::playback_gate` (Ghidra report Follow-up (ff)). use wayland_protocols_wlr::foreign_toplevel::v1::client::{ zwlr_foreign_toplevel_handle_v1::{self, ZwlrForeignToplevelHandleV1}, zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}, }; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; -use crate::engine::playback_gate::PlaybackAction; +use crate::services::playback_gate::PlaybackAction; use crate::{ - engine::gpu_renderer::GpuSceneInstance, + content::session::{ + pending_property_edits, ContentRenderer, PlaybackMonitor, WindowState, PAUSE_CHECK_INTERVAL, + }, + content::{FrameSource, RenderSettings, ScreenContent, ScreenSettings}, platform, - render::{FrameSource, RenderSettings, ScreenContent, ScreenSettings, WallpaperContent}, }; -/// How often to re-check playback-pause conditions (battery, session lock, -/// fullscreen/maximized app) — see `engine::playback_gate`. Real I/O (sysfs -/// reads, a D-Bus call), so this stays well below frame rate rather than -/// adding that cost to every tick — matches `platform::x11`'s own interval. -const PAUSE_CHECK_INTERVAL: Duration = Duration::from_millis(750); - // ── Platform implementation ─────────────────────────────────────────────────── pub(super) struct WaylandPlatform; @@ -118,55 +114,6 @@ fn spawn_wayland_wallpaper( // ── Content renderer ────────────────────────────────────────────────────────── -/// How the wallpaper content produces pixels. -enum ContentRenderer { - /// CPU frames (static images, videos, and the scene fallback loop); - /// presented through SHM buffers. - Frames(FrameSource), - /// GPU-rendered scene sharing our device — presented directly into the - /// wgpu surface (no readback) when the compositor allows, with an RGBA - /// readback + SHM fallback otherwise. - Scene(Box), -} - -impl ContentRenderer { - fn is_animated(&self) -> bool { - match self { - ContentRenderer::Frames(fs) => fs.is_animated(), - ContentRenderer::Scene(_) => true, - } - } -} - -/// Build the renderer for one output's resolved content. Scenes render on -/// our own device so frames can be presented directly; everything else -/// produces CPU frames. Split out of `wallpaper_loop` so `new_output` can -/// call it per-output instead of once for the whole process — the core of -/// per-screen wallpapers: each output gets its own independent -/// `GpuSceneInstance`/`FrameSource`, not a shared one. -fn build_content_renderer( - content: WallpaperContent, - device: wgpu::Device, - queue: wgpu::Queue, - allow_gpu_surface: bool, - web: crate::render::web::WebOptions, -) -> Result { - Ok(match content { - WallpaperContent::Scene { dir } if allow_gpu_surface => { - match GpuSceneInstance::with_device(device, queue, &dir) { - Ok(instance) => ContentRenderer::Scene(Box::new(instance)), - Err(e) => { - tracing::warn!(target: "wallpaper", "GPU scene init failed ({e}); using frame-loop fallback"); - ContentRenderer::Frames(FrameSource::from_content(WallpaperContent::Scene { - dir, - })?) - } - } - } - other => ContentRenderer::Frames(FrameSource::from_content_with(other, web)?), - }) -} - /// A surface's renderer. Span and clone layouts give every surface the /// same one, so it renders once per frame and each surface presents its /// part. @@ -300,7 +247,7 @@ struct WallpaperState { queue: wgpu::Queue, display_ptr: *mut c_void, allow_gpu_surface: bool, - // ── Playback-pause (engine::playback_gate, Ghidra report Follow-up (ff)) ── + // ── Playback-pause (services::playback_gate, Ghidra report Follow-up (ff)) ── /// `None` when the compositor doesn't advertise `zwlr_layer_shell_v1`'s /// sibling `zwlr_foreign_toplevel_manager_v1` at all — GNOME/Mutter /// notably don't (it's a wlroots-ecosystem protocol: Sway/Hyprland/river @@ -317,18 +264,11 @@ struct WallpaperState { /// Per-toplevel state from `zwlr_foreign_toplevel_handle_v1` events, /// keyed by the handle's own object id. foreign_toplevels: HashMap, - policy: crate::engine::playback_gate::PlaybackPolicy, - /// MPRIS watcher for the "another application is playing audio" - /// condition; only started when the policy acts on it. - media: Option, - media_playing: bool, + playback: PlaybackMonitor, /// Renderers were released by a playback stop. stopped: bool, /// For scheduling frame-rate-capped draws. loop_handle: Option>, - lock_watcher: Option, - playback_state: crate::engine::playback_gate::PlaybackState, - last_pause_check: Instant, /// Last known pause state, so the watchdog timer (`tick_pause_watchdog`) /// can tell "just resumed" apart from "still paused"/"still active" — /// see that method's doc for why detecting the *edge* matters here. @@ -453,8 +393,8 @@ impl WallpaperState { /// otherwise its whole self. fn view_of(&self, idx: usize) -> ((u32, u32), [f32; 4]) { let s = &self.surfaces[idx]; - let own = ((s.width, s.height), crate::render::settings::FULL_REGION); - if self.content.layout != crate::render::ScreenLayout::Span { + let own = ((s.width, s.height), crate::content::settings::FULL_REGION); + if self.content.layout != crate::content::ScreenLayout::Span { return own; } match s.rect { @@ -467,36 +407,34 @@ impl WallpaperState { if self.surfaces[idx].width == 0 || self.surfaces[idx].height == 0 { return; } - // Playback policy (`engine::playback_gate`): when it halts + // Playback policy (`services::playback_gate`): when it halts // rendering, skip the draw and — just as importantly — don't // request another frame callback, so the surface holds its last // frame and the compositor stops nudging us every vblank. // `tick_pause_watchdog` notices the condition clearing and restarts // the callback chain. let action = self.playback_action(Instant::now()); - crate::engine::audio::set_muted(action >= PlaybackAction::Mute); + crate::services::audio::set_muted(action >= PlaybackAction::Mute); if action.halts() { if !self.paused { - self.notify_web(crate::render::web::WebInputEvent::Paused(true)); + self.notify_web(crate::content::web::WebInputEvent::Paused(true)); } self.paused = true; return; } if self.paused { - self.notify_web(crate::render::web::WebInputEvent::Paused(false)); + self.notify_web(crate::content::web::WebInputEvent::Paused(false)); } self.paused = false; // A changed frame-rate cap reaches web pages too. let fps = self.surfaces[idx].settings.lock().unwrap().fps; if self.surfaces[idx].web_fps != fps { self.surfaces[idx].web_fps = fps; - if let ContentRenderer::Frames(fs) = &self.surfaces[idx].renderer.borrow().content { - if let Some(tx) = fs.web_input() { - let _ = tx.try_send(crate::render::web::WebInputEvent::Fps(fps)); - } + if let Some(tx) = self.surfaces[idx].renderer.borrow().content.web_input() { + let _ = tx.try_send(crate::content::web::WebInputEvent::Fps(fps)); } } - crate::engine::audio::set_master_volume(self.settings.default.lock().unwrap().volume); + crate::services::audio::set_master_volume(self.settings.default.lock().unwrap().volume); self.apply_property_edits(idx); // Frame-rate cap: a callback that arrives early waits on a timer. @@ -652,7 +590,7 @@ impl WallpaperState { // Scenes fill the screen, as on the direct GPU path; the fit modes // are for video, image and web content. let mode = match &self.surfaces[idx].renderer.borrow().content { - ContentRenderer::Scene(_) => crate::render::ScaleMode::Fill, + ContentRenderer::Scene(_) => crate::content::ScaleMode::Fill, ContentRenderer::Frames(_) => scaling, }; let (span, region) = self.view_of(idx); @@ -781,7 +719,6 @@ fn wallpaper_loop( .bind::(&qh, 1..=3, ()) .ok(); - let policy = crate::engine::playback_gate::PlaybackPolicy::load(); let mut state = WallpaperState { registry_state, output_state, @@ -804,18 +741,9 @@ fn wallpaper_loop( allow_gpu_surface, foreign_toplevel_manager, foreign_toplevels: HashMap::new(), - policy, - media: if policy.watches_audio() { - crate::engine::media::MediaWatcher::start() - } else { - None - }, - media_playing: false, + playback: PlaybackMonitor::new(), stopped: false, loop_handle: None, - lock_watcher: platform::power::LockWatcher::start(), - playback_state: crate::engine::playback_gate::PlaybackState::default(), - last_pause_check: Instant::now() - PAUSE_CHECK_INTERVAL, paused: false, }; @@ -838,7 +766,7 @@ fn wallpaper_loop( .insert(event_loop.handle()) .map_err(|e| anyhow!("WaylandSource insert failed: {e}"))?; - // Playback-pause watchdog (`engine::playback_gate`, Ghidra report + // Playback-pause watchdog (`services::playback_gate`, Ghidra report // Follow-up (ff)): `draw_at` stops requesting new frame callbacks once // paused (see its own doc comment), so nothing would ever notice the // pause clearing without this independent timer. @@ -933,14 +861,14 @@ impl OutputHandler for WallpaperState { let info = self.output_state.info(&output); let output_name = info.as_ref().and_then(|info| info.name.clone()); // A web page renders at the output's native mode. - let web = crate::render::web::WebOptions { + let web = crate::content::web::WebOptions { size: info .as_ref() .and_then(|i| i.modes.iter().find(|m| m.current).map(|m| m.dimensions)) .map(|(w, h)| (w.max(1) as u32, h.max(1) as u32)) .unwrap_or(( - crate::render::web::WEB_WIDTH, - crate::render::web::WEB_HEIGHT, + crate::content::web::WEB_WIDTH, + crate::content::web::WEB_HEIGHT, )), fps: self .settings @@ -956,11 +884,11 @@ impl OutputHandler for WallpaperState { .is_some_and(|name| self.content.by_output.contains_key(name)); // Span and clone: every output shows the first one's renderer. // ponytail: a spanned web page renders at the first output's size. - let existing = (self.content.layout != crate::render::ScreenLayout::PerScreen) + let existing = (self.content.layout != crate::content::ScreenLayout::PerScreen) .then(|| self.surfaces.first().map(|s| Rc::clone(&s.renderer))) .flatten(); let renderer = match existing.map(Ok).unwrap_or_else(|| { - build_content_renderer( + ContentRenderer::build( resolved, self.device.clone(), self.queue.clone(), @@ -984,8 +912,8 @@ impl OutputHandler for WallpaperState { target: "wallpaper", "output {output_name:?}: {} wallpaper", match self.content.layout { - crate::render::ScreenLayout::PerScreen if per_screen => "per-screen", - crate::render::ScreenLayout::PerScreen => "default", + crate::content::ScreenLayout::PerScreen if per_screen => "per-screen", + crate::content::ScreenLayout::PerScreen => "default", l => l.label(), } ); @@ -1279,13 +1207,13 @@ impl PointerHandler for WallpaperState { ContentRenderer::Frames(fs) => { let Some(tx) = fs.web_input() else { continue }; let event = match (button, wheel) { - (None, Some((dx, dy))) => crate::render::web::WebInputEvent::Wheel { + (None, Some((dx, dy))) => crate::content::web::WebInputEvent::Wheel { x_norm: norm[0], y_norm: norm[1], dx, dy, }, - (None, None) => crate::render::web::WebInputEvent::MouseMove { + (None, None) => crate::content::web::WebInputEvent::MouseMove { x_norm: norm[0], y_norm: norm[1], }, @@ -1293,7 +1221,7 @@ impl PointerHandler for WallpaperState { let Some(button) = web_mouse_button(code) else { continue; }; - crate::render::web::WebInputEvent::MouseButton { + crate::content::web::WebInputEvent::MouseButton { x_norm: norm[0], y_norm: norm[1], button, @@ -1311,11 +1239,11 @@ impl PointerHandler for WallpaperState { /// Linux evdev button code → the two buttons `WebInputEvent` (and the C++ /// reference) actually forward — `None` for anything else (middle click, /// side buttons), silently dropped rather than guessed at. -fn web_mouse_button(code: u32) -> Option { +fn web_mouse_button(code: u32) -> Option { match code { - BTN_LEFT => Some(crate::render::web::WebMouseButton::Left), - BTN_RIGHT => Some(crate::render::web::WebMouseButton::Right), - BTN_MIDDLE => Some(crate::render::web::WebMouseButton::Middle), + BTN_LEFT => Some(crate::content::web::WebMouseButton::Left), + BTN_RIGHT => Some(crate::content::web::WebMouseButton::Right), + BTN_MIDDLE => Some(crate::content::web::WebMouseButton::Middle), _ => None, } } @@ -1411,7 +1339,7 @@ delegate_registry!(WallpaperState); // ── Playback-pause: zwlr_foreign_toplevel_management (manual dispatch — no // smithay-client-toolkit delegate exists for this protocol) ─────────────────── -// See `engine::playback_gate`'s module doc (Ghidra report Follow-up (ff)). +// See `services::playback_gate`'s module doc (Ghidra report Follow-up (ff)). impl Dispatch for WallpaperState { fn event( @@ -1481,48 +1409,28 @@ impl Dispatch for WallpaperState { } } -impl WallpaperState { - /// `(fullscreen, maximized)` — any currently-*activated* toplevel - /// reporting that state, mirroring `platform::x11`'s own "check the - /// active window" heuristic (not "some window somewhere is fullscreen," - /// which could be a minimized/background one the user can't even see). - fn foreign_toplevel_pause_state(&self) -> (bool, bool) { - let fullscreen = self - .foreign_toplevels - .values() - .any(|t| t.activated && t.fullscreen); - let maximized = self - .foreign_toplevels - .values() - .any(|t| t.activated && t.maximized); - (fullscreen, maximized) - } +/// Window state for the playback rules: fullscreen/maximized when an +/// *activated* toplevel reports it, mirroring `platform::x11`'s own "check the +/// active window" heuristic (not "some window somewhere is fullscreen," +/// which could be a minimized/background one the user can't even see). +fn window_state<'a>(toplevels: impl Iterator) -> WindowState { + toplevels + .filter(|t| t.activated) + .fold(WindowState::default(), |w, t| WindowState { + fullscreen: w.fullscreen || t.fullscreen, + maximized: w.maximized || t.maximized, + focused: true, + }) +} +impl WallpaperState { /// Re-checks the playback conditions when `PAUSE_CHECK_INTERVAL` has /// elapsed and returns the action the policy calls for, releasing or /// rebuilding the renderers on a stop edge. fn playback_action(&mut self, now: Instant) -> PlaybackAction { - if now.duration_since(self.last_pause_check) >= PAUSE_CHECK_INTERVAL { - self.last_pause_check = now; - let (fullscreen_app, maximized_app) = self.foreign_toplevel_pause_state(); - while let Some(info) = self.media.as_ref().and_then(|m| m.try_recv()) { - self.media_playing = - info.playback_state == crate::engine::media::PlaybackState::Playing; - } - self.playback_state = crate::engine::playback_gate::PlaybackState { - on_battery: platform::power::is_on_battery(), - locked_or_sleeping: self - .lock_watcher - .as_mut() - .map(|w| w.is_locked()) - .unwrap_or(false), - fullscreen_app, - maximized_app, - focused_app: self.foreign_toplevels.values().any(|t| t.activated), - other_audio: self.media_playing, - }; - } - let action = crate::engine::playback_gate::decide(&self.policy, &self.playback_state); + let action = self + .playback + .action(now, || window_state(self.foreign_toplevels.values())); if action == PlaybackAction::Stop && !self.stopped { self.stopped = true; for s in &mut self.surfaces { @@ -1539,7 +1447,7 @@ impl WallpaperState { tracing::info!(target: "wallpaper", "playback stopped; renderers released"); } else if action != PlaybackAction::Stop && self.stopped { self.stopped = false; - let one_renderer = self.content.layout != crate::render::ScreenLayout::PerScreen; + let one_renderer = self.content.layout != crate::content::ScreenLayout::PerScreen; for i in 0..self.surfaces.len() { self.surfaces[i].gpu_failed = false; // Span and clone surfaces share surface 0's renderer. @@ -1549,14 +1457,14 @@ impl WallpaperState { let resolved = self .content .resolve(self.surfaces[i].output_name.as_deref()); - let web = crate::render::web::WebOptions { + let web = crate::content::web::WebOptions { size: ( self.surfaces[i].width.max(1), self.surfaces[i].height.max(1), ), fps: self.surfaces[i].settings.lock().unwrap().fps, }; - match build_content_renderer( + match ContentRenderer::build( resolved, self.device.clone(), self.queue.clone(), @@ -1577,60 +1485,42 @@ impl WallpaperState { /// and clone layouts surface 0 owns the shared renderer. fn apply_property_edits(&mut self, idx: usize) { let s = &mut self.surfaces[idx]; - let total = s.settings.lock().unwrap().property_edits.len(); - if total == s.props_seen { - return; - } let resolved = self.content.resolve(s.output_name.as_deref()); - let Some(dir) = resolved.project_dir().map(std::path::Path::to_path_buf) else { - s.props_seen = total; + let Some((dir, names)) = pending_property_edits(&s.settings, &mut s.props_seen, &resolved) + else { return; }; - let edits = s - .settings - .lock() - .unwrap() - .property_edits_since(&mut s.props_seen, &dir); - if edits.is_empty() - || (idx > 0 && self.content.layout != crate::render::ScreenLayout::PerScreen) + if idx > 0 && self.content.layout != crate::content::ScreenLayout::PerScreen { + return; + } + if s.renderer + .borrow_mut() + .content + .apply_properties(&dir, &names) { return; } - let rebuild = match &mut s.renderer.borrow_mut().content { - ContentRenderer::Scene(scene) => !scene.set_user_properties(&edits), - ContentRenderer::Frames(fs) => { - if let Some(tx) = fs.web_input() { - let json = crate::render::web::user_properties_subset(&dir, &edits); - let _ = tx.try_send(crate::render::web::WebInputEvent::Properties(json)); - } - false - } + let web = crate::content::web::WebOptions { + size: (s.width.max(1), s.height.max(1)), + fps: s.settings.lock().unwrap().fps, }; - if rebuild { - let web = crate::render::web::WebOptions { - size: (s.width.max(1), s.height.max(1)), - fps: s.settings.lock().unwrap().fps, - }; - match build_content_renderer( - resolved, - self.device.clone(), - self.queue.clone(), - self.allow_gpu_surface, - web, - ) { - Ok(r) => s.renderer.borrow_mut().content = r, - Err(e) => tracing::error!(target: "wallpaper", "reloading output {idx}: {e}"), - } + match ContentRenderer::build( + resolved, + self.device.clone(), + self.queue.clone(), + self.allow_gpu_surface, + web, + ) { + Ok(r) => s.renderer.borrow_mut().content = r, + Err(e) => tracing::error!(target: "wallpaper", "reloading output {idx}: {e}"), } } /// Send `event` to every output showing a web wallpaper. - fn notify_web(&self, event: crate::render::web::WebInputEvent) { + fn notify_web(&self, event: crate::content::web::WebInputEvent) { for s in &self.surfaces { - if let ContentRenderer::Frames(fs) = &s.renderer.borrow().content { - if let Some(tx) = fs.web_input() { - let _ = tx.try_send(event.clone()); - } + if let Some(tx) = s.renderer.borrow().content.web_input() { + let _ = tx.try_send(event.clone()); } } } @@ -1642,12 +1532,12 @@ impl WallpaperState { fn tick_pause_watchdog(&mut self) { let was_paused = self.paused; let action = self.playback_action(Instant::now()); - crate::engine::audio::set_muted(action >= PlaybackAction::Mute); + crate::services::audio::set_muted(action >= PlaybackAction::Mute); if !was_paused && action.halts() { - self.notify_web(crate::render::web::WebInputEvent::Paused(true)); + self.notify_web(crate::content::web::WebInputEvent::Paused(true)); } if was_paused && !action.halts() { - self.notify_web(crate::render::web::WebInputEvent::Paused(false)); + self.notify_web(crate::content::web::WebInputEvent::Paused(false)); self.paused = false; for idx in 0..self.surfaces.len() { if self.surfaces[idx].renderer.borrow().content.is_animated() { @@ -1662,7 +1552,28 @@ impl WallpaperState { #[cfg(test)] mod tests { - use super::{pointer_norm, span_region, web_mouse_button, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT}; + use super::{ + pointer_norm, span_region, web_mouse_button, window_state, ForeignToplevelState, BTN_LEFT, + BTN_MIDDLE, BTN_RIGHT, + }; + + /// Only the active window counts: a fullscreen window in the background + /// must not pause the wallpaper. + #[test] + fn window_state_follows_the_active_toplevel() { + let t = |activated, fullscreen, maximized| ForeignToplevelState { + activated, + fullscreen, + maximized, + pending: None, + }; + let w = window_state([t(false, true, true), t(true, false, false)].iter()); + assert!(w.focused && !w.fullscreen && !w.maximized); + let w = window_state([t(true, true, false)].iter()); + assert!(w.focused && w.fullscreen && !w.maximized); + let w = window_state([t(false, true, true)].iter()); + assert!(!w.focused && !w.fullscreen); + } #[test] fn span_regions_split_the_bounding_box() { @@ -1699,7 +1610,7 @@ mod tests { /// rather than a guessed-at mapping. #[test] fn web_mouse_button_maps_left_middle_and_right() { - use crate::render::web::WebMouseButton; + use crate::content::web::WebMouseButton; assert_eq!(web_mouse_button(BTN_LEFT), Some(WebMouseButton::Left)); assert_eq!(web_mouse_button(BTN_RIGHT), Some(WebMouseButton::Right)); assert_eq!(web_mouse_button(BTN_MIDDLE), Some(WebMouseButton::Middle)); diff --git a/src/platform/x11/mod.rs b/src/platform/x11/mod.rs index ffb0ea3..328d51a 100644 --- a/src/platform/x11/mod.rs +++ b/src/platform/x11/mod.rs @@ -31,21 +31,15 @@ use x11rb::protocol::xproto::{ use x11rb::protocol::{shape, xfixes::ConnectionExt as _}; use x11rb::rust_connection::RustConnection; -use crate::engine::playback_gate::PlaybackAction; +use crate::services::playback_gate::PlaybackAction; use x11rb::wrapper::ConnectionExt as _; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; -use crate::engine::gpu_renderer::GpuSceneInstance; +use crate::content::session::{ + pending_property_edits, ContentRenderer, PlaybackMonitor, WindowState, PAUSE_CHECK_INTERVAL, +}; +use crate::content::{FrameSource, RenderSettings, ScreenContent, ScreenSettings}; use crate::platform; -use crate::render::{FrameSource, RenderSettings, ScreenContent, ScreenSettings, WallpaperContent}; - -/// Frames per second for animated content. Matches `FrameSource`'s own target. - -/// How often to re-check playback-pause conditions (battery, session lock, -/// fullscreen/maximized app) — see `engine::playback_gate`. Real I/O (sysfs -/// reads, an EWMH round trip, a D-Bus call), so this stays well below frame -/// rate rather than adding that cost to every tick. -const PAUSE_CHECK_INTERVAL: Duration = Duration::from_millis(750); // ── Platform implementation ─────────────────────────────────────────────────── @@ -214,34 +208,6 @@ fn discover_outputs( } } -// ── Content ─────────────────────────────────────────────────────────────────── - -/// How the wallpaper produces pixels. Both arms end in a CPU RGBA frame — -/// the root pixmap has no GPU-surface equivalent. -enum ContentRenderer { - Frames(FrameSource), - Scene(Box), -} - -impl ContentRenderer { - fn is_animated(&self) -> bool { - match self { - ContentRenderer::Frames(fs) => fs.is_animated(), - ContentRenderer::Scene(_) => true, - } - } - - fn next_frame(&mut self) -> Result> { - match self { - ContentRenderer::Frames(fs) => { - fs.try_advance(); - Ok(Arc::clone(fs.current_frame())) - } - ContentRenderer::Scene(instance) => Ok(Arc::new(instance.render_rgba()?)), - } - } -} - // ── PutImage ────────────────────────────────────────────────────────────────── /// Bytes of fixed overhead in a `PutImage` request (24-byte header, plus slack @@ -302,7 +268,7 @@ fn put_image_chunked( } // ── Fullscreen/maximized-app detection (playback-pause) ──────────────────────── -// EWMH's `_NET_ACTIVE_WINDOW` + `_NET_WM_STATE` — see `engine::playback_gate`'s +// EWMH's `_NET_ACTIVE_WINDOW` + `_NET_WM_STATE` — see `services::playback_gate`'s // module doc (Ghidra report Follow-up (ff)) for where `playbackfullscreen`/ // `playbackmaximized` come from. @@ -317,15 +283,6 @@ struct EwmhAtoms { type_dock: Atom, } -/// The active window's state, for the playback policy. -#[derive(Debug, Default, Clone, Copy)] -struct ActiveWindow { - fullscreen: bool, - maximized: bool, - /// An application window (not the desktop or a panel) is active. - focused: bool, -} - impl EwmhAtoms { fn intern(conn: &impl Connection) -> Result { let atom = @@ -346,22 +303,22 @@ impl EwmhAtoms { /// when there's no active window, the window manager doesn't publish /// one, or a request fails — a WM without EWMH support degrades to /// "never pauses" rather than erroring the render loop. - fn check(&self, conn: &impl Connection, root: Window) -> ActiveWindow { + fn check(&self, conn: &impl Connection, root: Window) -> WindowState { let Some(active) = read_window32(conn, root, self.active_window) else { - return ActiveWindow::default(); + return WindowState::default(); }; if active == 0 || active == root { - return ActiveWindow::default(); + return WindowState::default(); } let types = read_atoms32(conn, active, self.window_type).unwrap_or_default(); let focused = !types.contains(&self.type_desktop) && !types.contains(&self.type_dock); let Some(states) = read_atoms32(conn, active, self.wm_state) else { - return ActiveWindow { + return WindowState { focused, ..Default::default() }; }; - ActiveWindow { + WindowState { fullscreen: states.contains(&self.fullscreen), maximized: states.contains(&self.maximized_vert) && states.contains(&self.maximized_horz), @@ -490,39 +447,13 @@ fn make_click_through(conn: &RustConnection, win: Window) -> Result<()> { // ── Main loop ───────────────────────────────────────────────────────────────── -/// Build the renderer for one output's resolved content — the X11 twin of -/// the Wayland backend's `build_content_renderer`. Split out so each output -/// gets its own independent `GpuSceneInstance`/`FrameSource` instead of one -/// shared across the whole root pixmap. -fn build_content_renderer( - content: WallpaperContent, - device: wgpu::Device, - queue: wgpu::Queue, - web: crate::render::web::WebOptions, -) -> Result { - Ok(match content { - WallpaperContent::Scene { dir } => { - match GpuSceneInstance::with_device(device, queue, &dir) { - Ok(instance) => ContentRenderer::Scene(Box::new(instance)), - Err(e) => { - tracing::warn!(target: "wallpaper", "GPU scene init failed ({e}); using frame-loop fallback"); - ContentRenderer::Frames(FrameSource::from_content(WallpaperContent::Scene { - dir, - })?) - } - } - } - other => ContentRenderer::Frames(FrameSource::from_content_with(other, web)?), - }) -} - /// A web wallpaper's page size and frame rate for one output. fn web_options( rect: OutputRect, settings: &ScreenSettings, name: Option<&str>, -) -> crate::render::web::WebOptions { - crate::render::web::WebOptions { +) -> crate::content::web::WebOptions { + crate::content::web::WebOptions { size: (rect.width as u32, rect.height as u32), fps: settings.resolve(name).lock().unwrap().fps, } @@ -574,7 +505,7 @@ fn wallpaper_loop( // Span: one renderer over the whole root window, which already is the // bounding box of every monitor. ponytail: clone still runs one // renderer per monitor here; share one if the duplicate work matters. - let named_outputs = if content.layout == crate::render::ScreenLayout::Span { + let named_outputs = if content.layout == crate::content::ScreenLayout::Span { vec![NamedOutput { name: None, rect: OutputRect { @@ -601,7 +532,7 @@ fn wallpaper_loop( .is_some_and(|n| content.by_output.contains_key(n)); let resolved = content.resolve(named.name.as_deref()); let web = web_options(named.rect, &settings, named.name.as_deref()); - match build_content_renderer(resolved, device.clone(), queue.clone(), web) { + match ContentRenderer::build(resolved, device.clone(), queue.clone(), true, web) { Ok(renderer) => { tracing::info!( target: "wallpaper", @@ -646,20 +577,14 @@ fn wallpaper_loop( let prop_root = conn.intern_atom(false, b"_XROOTPMAP_ID")?.reply()?.atom; let prop_esetroot = conn.intern_atom(false, b"ESETROOT_PMAP_ID")?.reply()?.atom; - // Playback-pause conditions (`engine::playback_gate` — Ghidra report + // Playback-pause conditions (`services::playback_gate` — Ghidra report // Follow-up (ff)). `ewmh` degrades to "never fullscreen/maximized" on // any WM without `_NET_ACTIVE_WINDOW`/`_NET_WM_STATE` support (its own // `check`'s doc comment); battery/lock are checked on the same // `PAUSE_CHECK_INTERVAL` throttle rather than every frame, since both // involve real I/O (sysfs reads, a D-Bus round trip) a 30fps loop // shouldn't pay for every tick. - let policy = crate::engine::playback_gate::PlaybackPolicy::load(); - let media = if policy.watches_audio() { - crate::engine::media::MediaWatcher::start() - } else { - None - }; - let mut media_playing = false; + let mut playback = PlaybackMonitor::new(); let mut stopped = false; let ewmh = EwmhAtoms::intern(&conn)?; let desktop_window = if use_desktop_window(&conn, root, ewmh.type_desktop) { @@ -675,9 +600,6 @@ fn wallpaper_loop( tracing::info!(target: "wallpaper", "X11: showing the wallpaper on the root window") } } - let mut lock_watcher = platform::power::LockWatcher::start(); - let mut playback_state = crate::engine::playback_gate::PlaybackState::default(); - let mut last_pause_check = Instant::now() - PAUSE_CHECK_INTERVAL; // Any output still animating keeps the whole loop going; a genuinely // static desktop (every output static) holds the pixmap and sleeps. @@ -689,37 +611,17 @@ fn wallpaper_loop( } let started = Instant::now(); - if started.duration_since(last_pause_check) >= PAUSE_CHECK_INTERVAL { - last_pause_check = started; - let active = ewmh.check(&conn, root); - while let Some(info) = media.as_ref().and_then(|m| m.try_recv()) { - media_playing = info.playback_state == crate::engine::media::PlaybackState::Playing; - } - playback_state = crate::engine::playback_gate::PlaybackState { - on_battery: platform::power::is_on_battery(), - locked_or_sleeping: lock_watcher - .as_mut() - .map(|w| w.is_locked()) - .unwrap_or(false), - fullscreen_app: active.fullscreen, - maximized_app: active.maximized, - focused_app: active.focused, - other_audio: media_playing, - }; - } - let action = crate::engine::playback_gate::decide(&policy, &playback_state); + let action = playback.action(started, || ewmh.check(&conn, root)); let paused = action.halts(); // Tell web pages about pause edges and frame-rate changes. for output in &mut outputs { let fps = output.settings.lock().unwrap().fps; - if let ContentRenderer::Frames(fs) = &output.renderer { - if let Some(tx) = fs.web_input() { - if paused != output.web_paused { - let _ = tx.try_send(crate::render::web::WebInputEvent::Paused(paused)); - } - if fps != output.web_fps { - let _ = tx.try_send(crate::render::web::WebInputEvent::Fps(fps)); - } + if let Some(tx) = output.renderer.web_input() { + if paused != output.web_paused { + let _ = tx.try_send(crate::content::web::WebInputEvent::Paused(paused)); + } + if fps != output.web_fps { + let _ = tx.try_send(crate::content::web::WebInputEvent::Fps(fps)); } } output.web_paused = paused; @@ -728,35 +630,15 @@ fn wallpaper_loop( // Live user-property edits for whatever each output shows: scenes // apply them in place (or rebuild), web pages get applyUserProperties. for output in &mut outputs { - if output.settings.lock().unwrap().property_edits.len() == output.props_seen { - continue; - } let resolved = content.resolve(output.named.name.as_deref()); - let Some(dir) = resolved.project_dir().map(std::path::Path::to_path_buf) else { - output.props_seen = output.settings.lock().unwrap().property_edits.len(); + let Some((dir, names)) = + pending_property_edits(&output.settings, &mut output.props_seen, &resolved) + else { continue; }; - let edits = output - .settings - .lock() - .unwrap() - .property_edits_since(&mut output.props_seen, &dir); - if edits.is_empty() { - continue; - } - let rebuild = match &mut output.renderer { - ContentRenderer::Scene(scene) => !scene.set_user_properties(&edits), - ContentRenderer::Frames(fs) => { - if let Some(tx) = fs.web_input() { - let json = crate::render::web::user_properties_subset(&dir, &edits); - let _ = tx.try_send(crate::render::web::WebInputEvent::Properties(json)); - } - false - } - }; - if rebuild { + if !output.renderer.apply_properties(&dir, &names) { let web = web_options(output.named.rect, &settings, output.named.name.as_deref()); - match build_content_renderer(resolved, device.clone(), queue.clone(), web) { + match ContentRenderer::build(resolved, device.clone(), queue.clone(), true, web) { Ok(r) => output.renderer = r, Err(e) => { tracing::error!(target: "wallpaper", "reloading output {:?}: {e}", output.named.name) @@ -764,8 +646,8 @@ fn wallpaper_loop( } } } - crate::engine::audio::set_muted(action >= PlaybackAction::Mute); - crate::engine::audio::set_master_volume(settings.default.lock().unwrap().volume); + crate::services::audio::set_muted(action >= PlaybackAction::Mute); + crate::services::audio::set_master_volume(settings.default.lock().unwrap().volume); // Stop: drop each output's renderer (scene GPU resources, decoder // threads), keeping its last frame on the pixmap; rebuild on resume. @@ -782,7 +664,7 @@ fn wallpaper_loop( for output in &mut outputs { let resolved = content.resolve(output.named.name.as_deref()); let web = web_options(output.named.rect, &settings, output.named.name.as_deref()); - match build_content_renderer(resolved, device.clone(), queue.clone(), web) { + match ContentRenderer::build(resolved, device.clone(), queue.clone(), true, web) { Ok(r) => output.renderer = r, Err(e) => { tracing::error!(target: "wallpaper", "resuming output {:?}: {e}", output.named.name) @@ -801,7 +683,7 @@ fn wallpaper_loop( // Scenes size themselves; the fit modes are for video, image // and web content. let mode = match &output.renderer { - ContentRenderer::Scene(_) => crate::render::ScaleMode::Stretch, + ContentRenderer::Scene(_) => crate::content::ScaleMode::Stretch, ContentRenderer::Frames(_) => scaling, }; let frame = output.renderer.next_frame()?; @@ -901,7 +783,7 @@ fn wallpaper_loop( // per-frame `CWeb::updateMouse` poll, not just // Wayland's event model adapted here. let Some(tx) = fs.web_input() else { continue }; - let _ = tx.try_send(crate::render::web::WebInputEvent::MouseMove { + let _ = tx.try_send(crate::content::web::WebInputEvent::MouseMove { x_norm: norm[0], y_norm: norm[1], }); @@ -911,19 +793,19 @@ fn wallpaper_loop( let right_now = mask & u16::from(KeyButMask::BUTTON3) != 0; if left_now != output.left_down { output.left_down = left_now; - let _ = tx.try_send(crate::render::web::WebInputEvent::MouseButton { + let _ = tx.try_send(crate::content::web::WebInputEvent::MouseButton { x_norm: norm[0], y_norm: norm[1], - button: crate::render::web::WebMouseButton::Left, + button: crate::content::web::WebMouseButton::Left, pressed: left_now, }); } if right_now != output.right_down { output.right_down = right_now; - let _ = tx.try_send(crate::render::web::WebInputEvent::MouseButton { + let _ = tx.try_send(crate::content::web::WebInputEvent::MouseButton { x_norm: norm[0], y_norm: norm[1], - button: crate::render::web::WebMouseButton::Right, + button: crate::content::web::WebMouseButton::Right, pressed: right_now, }); } diff --git a/src/render/mod.rs b/src/render/mod.rs deleted file mode 100644 index 0401031..0000000 --- a/src/render/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub mod content; -pub mod ffmpeg; -pub mod frame; -pub mod settings; -pub mod web; - -pub use content::{ScreenContent, ScreenLayout, WallpaperContent}; -pub use frame::FrameSource; -pub use settings::{RenderSettings, ScaleMode, ScreenSettings}; diff --git a/src/engine/audio.rs b/src/services/audio.rs similarity index 99% rename from src/engine/audio.rs rename to src/services/audio.rs index c6c4b31..b6db342 100644 --- a/src/engine/audio.rs +++ b/src/services/audio.rs @@ -240,10 +240,10 @@ fn push_samples( /// Prefer a desktop-output monitor (loopback) input; else the default input. /// Device selection lives in the platform layer — "capture what the speakers /// are playing" is spelled differently on every OS. See -/// [`crate::platform::audio`]. +/// [`crate::services::audio_devices`]. fn pick_input_device(host: &cpal::Host) -> Option { - let (device, source) = crate::platform::audio::pick_capture_device(host)?; - if source != crate::platform::audio::CaptureSource::Loopback { + let (device, source) = crate::services::audio_devices::pick_capture_device(host)?; + if source != crate::services::audio_devices::CaptureSource::Loopback { tracing::info!( target: "audio", "no desktop-loopback device found; falling back to {source:?} — the spectrum will follow that input, not desktop audio" diff --git a/src/platform/audio.rs b/src/services/audio_devices.rs similarity index 98% rename from src/platform/audio.rs rename to src/services/audio_devices.rs index ef61f65..4c9453b 100644 --- a/src/platform/audio.rs +++ b/src/services/audio_devices.rs @@ -1,7 +1,7 @@ //! Platform-specific capture-device selection for the audio-reactive path. //! //! Capture, FFT and the `g_AudioSpectrum*` uniforms are all cross-platform and -//! live in [`crate::engine::audio`] on top of cpal. The one genuinely +//! live in [`crate::services::audio`] on top of cpal. The one genuinely //! OS-dependent decision is *which* device carries desktop output, because //! "record what the speakers are playing" is spelled differently everywhere: //! @@ -58,7 +58,7 @@ pub enum CaptureSource { /// /// Global because the renderer builds its `AudioCapture` deep inside a scene /// load on its own thread, with no path to thread a setting through — the same -/// reason `engine::properties` keeps its overrides this way. +/// reason `workshop::properties` keeps its overrides this way. static PREFERRED: std::sync::RwLock> = std::sync::RwLock::new(None); /// Override automatic selection. Pass `None` to go back to automatic. @@ -131,7 +131,7 @@ pub fn wallpaper_uses_audio(dir: &std::path::Path) -> bool { let json = std::fs::read_to_string(dir.join("scene.json")) .ok() .or_else(|| { - let pkg = crate::engine::pkg::Package::from_file(&dir.join("scene.pkg")).ok()?; + let pkg = crate::formats::pkg::Package::from_file(&dir.join("scene.pkg")).ok()?; pkg.get("scene.json") .map(|b| String::from_utf8_lossy(b).into_owned()) }); diff --git a/src/engine/media.rs b/src/services/media.rs similarity index 99% rename from src/engine/media.rs rename to src/services/media.rs index f98be4c..d29b24d 100644 --- a/src/engine/media.rs +++ b/src/services/media.rs @@ -265,7 +265,7 @@ mod tests { } /// Real-bus smoke test, not run by default (needs a live session bus — - /// `cargo test --lib -- --ignored engine::media`). Verifies the actual + /// `cargo test --lib -- --ignored services::media`). Verifies the actual /// D-Bus round trip (connect, ListNames, filter, poll) doesn't error, /// against whatever's really running — no MPRIS player was available to /// install in the sandbox this was written in, so this only proves the diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..d36f03d --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,10 @@ +//! What wallpapers react to on the desktop: captured audio, the media +//! playing elsewhere, battery and lock state, and the pause rules built on +//! them. + +pub mod audio; +pub mod audio_devices; +#[cfg(target_os = "linux")] +pub mod media; +pub mod playback_gate; +pub mod power; diff --git a/src/engine/playback_gate.rs b/src/services/playback_gate.rs similarity index 99% rename from src/engine/playback_gate.rs rename to src/services/playback_gate.rs index 4b32124..246c248 100644 --- a/src/engine/playback_gate.rs +++ b/src/services/playback_gate.rs @@ -10,7 +10,7 @@ //! //! This module is pure decision logic; the platform backends fill in //! [`PlaybackState`] (EWMH / `zwlr_foreign_toplevel_management` for window -//! state, `platform::power` for battery and lock, MPRIS for other media). +//! state, `services::power` for battery and lock, MPRIS for other media). use serde::{Deserialize, Serialize}; diff --git a/src/platform/power.rs b/src/services/power.rs similarity index 97% rename from src/platform/power.rs rename to src/services/power.rs index a2acbba..bdb87a5 100644 --- a/src/platform/power.rs +++ b/src/services/power.rs @@ -1,4 +1,4 @@ -//! Battery and session-lock detection for `engine::playback_gate`'s +//! Battery and session-lock detection for `services::playback_gate`'s //! `on_battery`/`on_lock_or_sleep` conditions — see that module's doc for //! where these come from (the Ghidra report's Follow-up (ff)). @@ -87,10 +87,10 @@ fn macos_on_battery() -> Option { } /// Owns the background polling thread; drop to stop it (mirrors -/// `engine::media::MediaWatcher`'s exact lifetime/polling convention). +/// `services::media::MediaWatcher`'s exact lifetime/polling convention). /// Linux-only — session lock state is a freedesktop.org logind convention /// with no macOS equivalent this codebase reaches for elsewhere either -/// (see `engine::media`'s own "Linux-only" precedent). +/// (see `services::media`'s own "Linux-only" precedent). #[cfg(target_os = "linux")] pub struct LockWatcher { rx: Receiver, diff --git a/src/settings.rs b/src/settings.rs index af64f7d..29fe6a0 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -53,19 +53,19 @@ pub struct WpSettings { /// What happens while another application is focused, maximized, /// fullscreen or playing audio, on battery, or while locked. #[serde(default)] - pub playback: crate::engine::playback_gate::PlaybackPolicy, + pub playback: crate::services::playback_gate::PlaybackPolicy, /// Frame-rate cap for animated wallpapers; `None` = the default (30). #[serde(default)] pub fps: Option, /// How video, image and web wallpapers fit the screen; `None` = fill. #[serde(default)] - pub scaling: Option, + pub scaling: Option, /// The playlist `wp-engine run` plays, if any. #[serde(default)] pub active_playlist: Option, /// Per-screen, span or clone; `None` = per-screen. #[serde(default)] - pub layout: Option, + pub layout: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/ui/apply.rs b/src/ui/apply.rs new file mode 100644 index 0000000..ba1632f --- /dev/null +++ b/src/ui/apply.rs @@ -0,0 +1,289 @@ +//! Showing what was picked: applying a wallpaper to the chosen display or +//! the saved setup (through the background daemon on Linux, in-process +//! elsewhere), Cancel's revert, and the daemon link that forwards live +//! setting changes. + +use super::*; + +impl WpApp { + /// Show a file or folder that isn't a Workshop item on every display. + pub(super) fn apply_path(&mut self, path: PathBuf, display_title: String) { + let properties = self.saved.property_overrides(&path.to_string_lossy()); + #[cfg(target_os = "linux")] + self.apply_via_daemon(path, HashMap::new(), display_title, properties); + #[cfg(not(target_os = "linux"))] + match WallpaperContent::from_path(&path) { + Err(e) => self.status = StatusMsg::err(format!("Load failed: {e}")), + Ok(content) => self.apply_content(content, display_title, properties), + } + } + + /// WE's click-to-apply: show wallpaper `idx` on the chosen display (or + /// all of them) and remember it, as `wp-engine config` would. + pub(super) fn apply_wallpaper(&mut self, idx: usize) { + if let Err(reason) = can_apply(&self.wallpapers[idx]) { + self.status = StatusMsg::err(reason); + return; + } + let id = self.wallpapers[idx].workshop_id.clone(); + match self.target_screen.clone() { + Some(screen) => { + self.saved.screens.insert(screen, id); + } + None => { + self.saved.default_background = Some(id); + self.saved.screens.clear(); + } + } + self.save_settings(); + let title = self.wallpapers[idx].title().to_string(); + #[cfg(target_os = "linux")] + self.apply_saved(title, self.wallpapers[idx].path.clone()); + #[cfg(not(target_os = "linux"))] + { + let properties = self + .saved + .property_overrides(&self.wallpapers[idx].path.to_string_lossy()); + match WallpaperContent::from_wallpaper(&self.wallpapers[idx]) { + Err(e) => self.status = StatusMsg::err(e.to_string()), + Ok(content) => self.apply_content(content, title, properties), + } + } + } + + /// Show the saved default and per-display wallpapers; `fallback` stands + /// in for a missing default. + #[cfg(target_os = "linux")] + pub(super) fn apply_saved(&mut self, title: String, fallback: PathBuf) { + let path_of = |id: &str| { + self.wallpapers + .iter() + .find(|w| w.workshop_id == id) + .map(|w| w.path.clone()) + .or_else(|| workshop::find_item_dir(id, None)) + }; + let background = self + .saved + .default_background + .as_deref() + .and_then(path_of) + .unwrap_or(fallback); + let screens = self + .saved + .screens + .iter() + .filter_map(|(screen, id)| Some((screen.clone(), path_of(id)?))) + .collect(); + let properties = self.saved.property_overrides(&background.to_string_lossy()); + self.apply_via_daemon(background, screens, title, properties); + } + + #[cfg(not(target_os = "linux"))] + pub(super) fn apply_content( + &mut self, + content: WallpaperContent, + display_title: String, + properties: HashMap, + ) { + crate::workshop::properties::set_global_overrides(properties); + let old_renderer = self.renderer.take(); + let settings = Arc::clone(&self.settings); + let (tx, rx) = mpsc::channel(); + + std::thread::spawn(move || { + if let Some(old) = old_renderer { + old.stop(); + } + let result = detect_platform() + .spawn_wallpaper( + crate::content::ScreenContent::single(content), + crate::content::ScreenSettings::single(settings), + ) + .map(|handle| (Some(handle), display_title.clone())) + .map_err(|e| format!("{e:#}")); + let _ = tx.send(result); + }); + + self.pending_apply = Some(rx); + self.status = StatusMsg::ok("Applying..."); + } + + /// Hand the wallpaper to the background daemon (starting it if needed), + /// so it keeps running after this window closes. + #[cfg(target_os = "linux")] + pub(super) fn apply_via_daemon( + &mut self, + path: PathBuf, + screens: HashMap, + display_title: String, + properties: HashMap, + ) { + use crate::daemon::{self, ApplySpec, Request}; + let spec = { + let s = self.settings.lock().unwrap(); + ApplySpec { + background: path, + screens, + properties, + quality: Some(s.quality.label().to_string()), + volume: Some(s.volume), + title: Some(display_title.clone()), + ..Default::default() + } + }; + let (tx, rx) = mpsc::channel(); + + std::thread::spawn(move || { + let device = crate::services::audio_devices::preferred_device(); + let result = daemon::request_or_spawn(&Request::SetAudioDevice { device }) + .and_then(|_| daemon::request(&Request::Apply(spec))) + .map(|_| (None, display_title)) + .map_err(|e| format!("{e:#}")); + let _ = tx.send(result); + }); + + self.pending_apply = Some(rx); + self.status = StatusMsg::ok("Applying..."); + } + + /// Remove the wallpaper the daemon is showing (the daemon stays up). + #[cfg(target_os = "linux")] + pub(super) fn clear_wallpaper(&mut self) { + std::thread::spawn(|| { + if let Err(e) = crate::daemon::request(&crate::daemon::Request::Clear) { + tracing::warn!(target: "app", "clearing wallpaper failed: {e:#}"); + } + }); + self.active_title = None; + self.status = StatusMsg::ok("Wallpaper removed"); + } + + /// WE's Cancel: restore the settings and wallpaper from when the window + /// opened, then close. + pub(super) fn cancel(&mut self, ctx: &egui::Context) { + if self.saved == self.initial_saved { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + } + self.saved = self.initial_saved.clone(); + self.save_settings(); + #[cfg(target_os = "linux")] + match self.initial.clone() { + Some((path, title)) => { + self.apply_saved(title, path); + self.close_after_apply = true; + return; + } + None => self.clear_wallpaper(), + } + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + + #[cfg(target_os = "linux")] + pub(super) fn poll_initial_status(&mut self) { + let Some(rx) = &self.initial_status else { + return; + }; + match rx.try_recv() { + Ok(status) => { + if let Some(active) = status.active { + let title = active + .title + .unwrap_or_else(|| active.background.display().to_string()); + self.active_title = Some(title.clone()); + self.initial = Some((active.background, title)); + if let Some(q) = RenderQuality::parse(&active.quality) { + self.settings.lock().unwrap().quality = q; + } + } + self.initial_status = None; + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => self.initial_status = None, + } + } + + pub(super) fn poll_pending_apply(&mut self, ctx: &egui::Context) { + let Some(rx) = &self.pending_apply else { + return; + }; + let done = match rx.try_recv() { + Ok(Ok((handle, title))) => { + if let Some(handle) = handle { + self.renderer = Some(handle); + } + self.active_title = Some(title.clone()); + self.status = StatusMsg::ok(format!("Applied \"{title}\"")); + true + } + Ok(Err(e)) => { + self.status = StatusMsg::err(e); + true + } + Err(mpsc::TryRecvError::Empty) => false, + Err(mpsc::TryRecvError::Disconnected) => { + self.status = StatusMsg::err("Apply thread crashed"); + true + } + }; + if done { + self.pending_apply = None; + if self.close_after_apply { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } +} + +/// A worker thread that forwards live setting changes to the daemon, so a +/// dragged slider never blocks the UI on the socket (or on an apply the +/// daemon is still busy with). Changes are dropped when no daemon runs: +/// the next apply carries the current values anyway. +#[cfg(target_os = "linux")] +pub(super) struct DaemonLink { + tx: mpsc::Sender, +} + +#[cfg(target_os = "linux")] +impl DaemonLink { + pub(super) fn new() -> Self { + use crate::daemon::{self, Request}; + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || { + while let Ok(first) = rx.recv() { + // Coalesce a burst (slider drag) down to the latest of each kind. + let mut latest = HashMap::new(); + for req in std::iter::once(first).chain(rx.try_iter()) { + latest.insert(std::mem::discriminant(&req), req); + } + if !daemon::is_running() { + continue; + } + for req in latest.into_values() { + if let Err(e) = daemon::request(&req) { + tracing::warn!(target: "app", "daemon update failed: {e:#}"); + } + } + } + }); + Self { tx } + } + + pub(super) fn send(&self, req: crate::daemon::Request) { + let _ = self.tx.send(req); + } +} + +/// Why `w` can't be shown here, if it can't. +pub(super) fn can_apply(w: &Wallpaper) -> Result<(), &'static str> { + let file_ok = || w.wallpaper_file().is_some_and(|p| p.exists()); + match w.wallpaper_type() { + WallpaperType::Scene | WallpaperType::Preset => Ok(()), + WallpaperType::Application => Err("Application wallpapers only run on Windows"), + WallpaperType::Web if !crate::content::web::is_supported() => { + Err("Web wallpapers need a build with --features web") + } + _ if file_ok() => Ok(()), + _ => Err("The wallpaper's file is missing"), + } +} diff --git a/src/ui/browse.rs b/src/ui/browse.rs new file mode 100644 index 0000000..8fff8b3 --- /dev/null +++ b/src/ui/browse.rs @@ -0,0 +1,362 @@ +//! The browse view: source tabs, search, filters and sorting over the +//! thumbnail grid. + +use super::*; + +impl WpApp { + pub(super) fn rebuild_filter(&mut self) { + let q = self.search.to_lowercase(); + let mut shown: Vec = (0..self.wallpapers.len()) + .filter(|&i| { + let w = &self.wallpapers[i]; + let text_ok = q.is_empty() + || w.title().to_lowercase().contains(&q) + || w.project + .tags + .iter() + .flatten() + .any(|t| t.to_lowercase() == q); + text_ok && self.filters.matches(w) + }) + .collect(); + let title = |i: &usize| self.wallpapers[*i].title().to_lowercase(); + match self.sort { + SortKey::Name => shown.sort_by_cached_key(title), + SortKey::Type => shown.sort_by_cached_key(|i| { + (type_name(self.wallpapers[*i].wallpaper_type()), title(i)) + }), + // Newest first, like WE. + SortKey::Installed => shown.sort_by_key(|&i| std::cmp::Reverse(self.installed[i])), + } + if self.descending { + shown.reverse(); + } + self.filtered = shown; + } + + /// Source tabs with Displays/Settings, then search, filter and sort. + pub(super) fn render_header(&mut self, ui: &mut egui::Ui, skin: Skin) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (label, active) in [ + ("Installed", true), + ("Discover", false), + ("Workshop", false), + ] { + if source_tab(ui, label, active, skin).clicked() && !active { + open_external(WORKSHOP_URL); + } + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 6.0; + if ui.button("⚙ Settings").clicked() { + self.show_settings = !self.show_settings; + // `wp-engine config` may have changed it meanwhile. + self.saved = crate::settings::WpSettings::load(); + self.autostart = crate::settings::autostart_enabled(); + } + let display = self.target_screen.as_deref().unwrap_or("All displays"); + if ui + .button(format!("🖵 Displays: {display}")) + .on_hover_text("Choose the display clicked wallpapers go to") + .clicked() + { + self.show_displays = true; + } + }); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + let r = ui.add( + egui::TextEdit::singleline(&mut self.search) + .desired_width(260.0) + .hint_text("🔍 Search"), + ); + let mut changed = r.changed(); + let filter_btn = egui::Button::new("☰ Filter results") + .selected(self.show_filter || self.filters.is_active()); + if ui.add(filter_btn).clicked() { + self.show_filter = !self.show_filter; + } + if self.filtered.len() != self.wallpapers.len() { + ui.label( + egui::RichText::new(format!( + "({} of {})", + self.filtered.len(), + self.wallpapers.len() + )) + .color(TEXT_MUTED), + ); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let arrow = if self.descending { "⬇" } else { "⬆" }; + if ui.button(arrow).on_hover_text("Sort direction").clicked() { + self.descending = !self.descending; + changed = true; + } + egui::ComboBox::from_id_salt("sort") + .selected_text(self.sort.label()) + .width(140.0) + .show_ui(ui, |ui| { + for key in SortKey::ALL { + changed |= ui + .selectable_value(&mut self.sort, key, key.label()) + .changed(); + } + }); + }); + if changed { + self.rebuild_filter(); + } + }); + } + + /// WE's "Filter results" panel: type, age rating and tags. + pub(super) fn render_filters(&mut self, ui: &mut egui::Ui) { + let mut changed = false; + egui::ScrollArea::vertical().show(ui, |ui| { + if ui.button("Reset Filters").clicked() { + self.filters = Filters::default(); + changed = true; + } + ui.add_space(6.0); + ui.strong("Type"); + for t in TYPES { + changed |= filter_box(ui, &mut self.filters.hidden_types, t, t); + } + ui.add_space(6.0); + ui.strong("Age rating"); + for r in AGE_RATINGS { + changed |= filter_box(ui, &mut self.filters.hidden_ratings, r, r); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.strong("Tags"); + if ui.small_button("All").clicked() { + self.filters.hidden_tags.clear(); + changed = true; + } + if ui.small_button("None").clicked() { + self.filters.hidden_tags = self.all_tags.iter().cloned().collect(); + changed = true; + } + }); + for tag in self.all_tags.clone() { + changed |= filter_box(ui, &mut self.filters.hidden_tags, tag.clone(), &tag); + } + }); + if changed { + self.rebuild_filter(); + } + } + + pub(super) fn render_grid(&mut self, ui: &mut egui::Ui, skin: Skin) { + if self.wallpapers.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(80.0); + ui.label( + egui::RichText::new("No Workshop wallpapers found") + .size(18.0) + .color(TEXT_MUTED), + ); + ui.add_space(10.0); + ui.label( + egui::RichText::new( + "Install Wallpaper Engine on Steam and subscribe to wallpapers,\n\ + or use \"Open Wallpaper\" below to show any file.", + ) + .color(TEXT_MUTED), + ); + }); + return; + } + if self.filtered.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(95.0); + ui.label( + egui::RichText::new( + "No wallpapers found for your search.\nExpand or reset the filters.", + ) + .size(18.0) + .color(TEXT_MUTED), + ); + }); + return; + } + + // As many columns as fit at up to `TILE`, stretched to fill the row. + let width = ui.available_width(); + let cols = (((width + TILE_GAP) / (TILE + TILE_GAP)).ceil() as usize).max(1); + let tile = (width - TILE_GAP * (cols - 1) as f32) / cols as f32; + let rows = self.filtered.len().div_ceil(cols); + let filtered = self.filtered.clone(); + egui::ScrollArea::vertical().auto_shrink(false).show_rows( + ui, + tile + TILE_GAP, + rows, + |ui, range| { + for row in range { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = TILE_GAP; + for &idx in filtered.iter().skip(row * cols).take(cols) { + self.grid_tile(ui, idx, tile, skin); + } + }); + ui.add_space(TILE_GAP); + } + }, + ); + } + + pub(super) fn grid_tile(&mut self, ui: &mut egui::Ui, idx: usize, size: f32, skin: Skin) { + let id = self.wallpapers[idx].workshop_id.clone(); + let thumb = self.thumbnails.get(&id).cloned(); + if thumb.is_none() { + self.enqueue_thumb(idx); + } + let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); + if ui.is_rect_visible(rect) { + draw_tile( + ui, + rect, + &resp, + &self.wallpapers[idx], + thumb.as_ref(), + self.selected == Some(idx), + skin, + ); + } + let in_playlist = self.playlist_items().contains(&id); + resp.context_menu(|ui| { + let label = if in_playlist { + "Remove from playlist" + } else { + "Add to playlist" + }; + if ui.button(label).clicked() { + self.toggle_in_playlist(&id); + ui.close(); + } + if id.bytes().all(|b| b.is_ascii_digit()) && ui.button("Open Workshop page").clicked() { + open_external(&workshop_item_url(&id)); + ui.close(); + } + if ui.button("Open folder").clicked() { + open_external(&self.wallpapers[idx].path.to_string_lossy()); + ui.close(); + } + }); + if resp.clicked() { + self.selected = Some(idx); + self.apply_wallpaper(idx); + } + } +} + +pub(super) fn source_tab( + ui: &mut egui::Ui, + label: &str, + active: bool, + skin: Skin, +) -> egui::Response { + let (fill, text) = if active { + (skin.accent, Color32::WHITE) + } else { + (skin.panel, skin.text) + }; + ui.add( + egui::Button::new(egui::RichText::new(label).size(15.0).color(text)) + .fill(fill) + .stroke(Stroke::new(1.0, skin.accent)) + .corner_radius(CornerRadius { + nw: 3, + ne: 3, + sw: 0, + se: 0, + }) + .min_size(vec2(96.0, 28.0)), + ) +} + +/// A ticked-by-default filter checkbox backed by a set of unticked keys. +pub(super) fn filter_box( + ui: &mut egui::Ui, + hidden: &mut HashSet, + key: K, + text: &str, +) -> bool { + let mut on = !hidden.contains(&key); + if !ui.checkbox(&mut on, text).changed() { + return false; + } + if on { + hidden.remove(&key); + } else { + hidden.insert(key); + } + true +} + +pub(super) fn draw_tile( + ui: &egui::Ui, + rect: Rect, + resp: &egui::Response, + w: &Wallpaper, + thumb: Option<&egui::TextureHandle>, + selected: bool, + skin: Skin, +) { + let painter = ui.painter_at(rect); + painter.rect_filled(rect, CornerRadius::same(2), skin.tile); + match thumb { + Some(tex) => { + painter.image(tex.id(), rect, full_uv(), Color32::WHITE); + } + None => { + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + "⏳", + FontId::proportional(22.0), + TEXT_MUTED, + ); + } + } + if resp.hovered() { + painter.rect_filled(rect, CornerRadius::same(2), Color32::from_white_alpha(24)); + } + + // Title over a dark band along the bottom, as in WE's grid. + let band = Rect::from_min_max(pos2(rect.min.x, rect.max.y - 24.0), rect.max); + painter.rect_filled(band, CornerRadius::ZERO, Color32::from_black_alpha(170)); + let mut job = egui::text::LayoutJob::simple_singleline( + w.title().to_string(), + FontId::proportional(12.0), + Color32::WHITE, + ); + job.wrap = egui::text::TextWrapping::truncate_at_width(rect.width() - 10.0); + painter.galley( + pos2(band.min.x + 5.0, band.min.y + 5.0), + painter.layout_job(job), + Color32::WHITE, + ); + + // Type marker in the top corner for everything that isn't a scene. + if !matches!(w.wallpaper_type(), WallpaperType::Scene) { + let label = type_name(w.wallpaper_type()).to_uppercase(); + let galley = painter.layout_no_wrap(label, FontId::proportional(9.0), Color32::WHITE); + let size = galley.size() + vec2(8.0, 4.0); + let badge = Rect::from_min_size(pos2(rect.max.x - size.x - 4.0, rect.min.y + 4.0), size); + painter.rect_filled(badge, CornerRadius::same(3), type_color(w.wallpaper_type())); + painter.galley(badge.min + vec2(4.0, 2.0), galley, Color32::WHITE); + } + + if selected { + painter.rect_stroke( + rect, + CornerRadius::same(2), + Stroke::new(3.0, skin.accent), + egui::StrokeKind::Inside, + ); + } +} diff --git a/src/ui/config.rs b/src/ui/config.rs deleted file mode 100644 index 24d5234..0000000 --- a/src/ui/config.rs +++ /dev/null @@ -1,731 +0,0 @@ -//! Saved-settings UI: the selected wallpaper's properties, the playlist -//! bar's Configure popup, and the settings window (playback rules, frame -//! rate, quality, scaling, audio, autostart). Everything here edits -//! `settings.json`, the same file `wp-engine config` writes. - -use super::{StatusMsg, WpApp, DANGER, TEXT_MUTED}; -use crate::engine::playback_gate::PlaybackAction; -use crate::engine::properties::SceneProperty; -use crate::platform::RenderQuality; -use crate::render::ScaleMode; -use crate::settings::{Playlist, WpSettings}; -use serde_json::Value; - -/// The selected wallpaper's declared properties, loaded once per selection, -/// in WE's display order (headings included). -pub(super) struct PropertyEditor { - pub idx: usize, - pub props: Vec, - /// Which rows their `condition` currently shows; refreshed on each edit. - pub visible: Vec, -} - -/// The settings window's tabs, after WE's own settings view. -#[derive(Debug, Clone, Copy, PartialEq)] -pub(super) enum SettingsTab { - Performance, - General, - About, -} - -/// A new playlist with the saved format's defaults. -pub(super) fn empty_playlist() -> Playlist { - serde_json::from_str(r#"{"items":[]}"#).expect("playlist defaults") -} - -const PLAYBACK_CONDITIONS: [(&str, &str); 6] = [ - ("focus", "Another app is focused"), - ("maximized", "Another app is maximized"), - ("fullscreen", "Another app is fullscreen"), - ("audio", "Another app is playing audio"), - ("battery", "On battery"), - ("lock", "Session locked"), -]; - -const SCALE_MODES: [ScaleMode; 4] = [ - ScaleMode::Fill, - ScaleMode::Fit, - ScaleMode::Stretch, - ScaleMode::Center, -]; - -const PLAYLIST_MODES: [(&str, &str); 4] = [ - ("timer", "Every N minutes"), - ("daytime", "At times of day"), - ("dayofweek", "By day of week"), - ("login", "Next one each login"), -]; - -/// `text` without the HTML (tags and common entities) real content uses. -fn plain_text(text: &str) -> String { - let mut out = String::new(); - let mut in_tag = false; - for c in text.chars() { - match c { - '<' => in_tag = true, - '>' => in_tag = false, - _ if !in_tag => out.push(c), - _ => {} - } - } - let out = out - .replace(" ", " ") - .replace(" ", " ") - .replace("&", "&"); - out.trim_matches(|c: char| c.is_whitespace() || c == '—' || c == '-') - .to_string() -} - -/// A property's label, falling back to its name when the text is empty or -/// an untranslated `ui_` key. -fn label(p: &SceneProperty) -> String { - let out = plain_text(&p.text); - if out.is_empty() || out.starts_with("ui_") { - p.name.replace('_', " ") - } else { - out.to_string() - } -} - -/// Ask the desktop for a file or folder via zenity or kdialog. -// ponytail: shells out instead of a file-dialog crate; use the XDG portal -// (zbus is already a dependency) if neither tool is common enough. -pub(super) fn pick_path(directory: bool) -> Option { - let zenity: &[&str] = if directory { - &["--file-selection", "--directory"] - } else { - &["--file-selection"] - }; - let kdialog: &[&str] = if directory { - &["--getexistingdirectory"] - } else { - &["--getopenfilename"] - }; - [("zenity", zenity), ("kdialog", kdialog)] - .iter() - .find_map(|(cmd, args)| { - let out = std::process::Command::new(cmd).args(*args).output().ok()?; - let path = String::from_utf8(out.stdout).ok()?.trim().to_string(); - (out.status.success() && !path.is_empty()).then_some(path) - }) -} - -/// A declared value in the string form overrides are saved in. -fn value_text(v: &Value) -> String { - match v { - Value::String(s) => s.clone(), - Value::Bool(b) => if *b { "1" } else { "0" }.to_string(), - other => other.to_string(), - } -} - -fn parse_color(s: &str) -> [f32; 3] { - let mut c = [0.0; 3]; - for (slot, v) in c.iter_mut().zip(s.split_whitespace()) { - *slot = v.parse::().unwrap_or(0.0).clamp(0.0, 1.0); - } - c -} - -impl WpApp { - pub(super) fn save_settings(&mut self) { - if let Err(e) = self.saved.save() { - self.status = StatusMsg::err(format!("Saving settings failed: {e:#}")); - } - } - - /// Send a one-off request to the daemon, starting it if needed. - #[cfg(target_os = "linux")] - pub(super) fn daemon_request(&mut self, req: crate::daemon::Request) { - std::thread::spawn(move || { - if let Err(e) = crate::daemon::request_or_spawn(&req) { - tracing::warn!(target: "app", "daemon request failed: {e:#}"); - } - }); - } - - pub(super) fn title_of(&self, id: &str) -> String { - self.wallpapers - .iter() - .find(|w| w.workshop_id == id) - .map(|w| w.title().to_string()) - .unwrap_or_else(|| id.to_string()) - } - - // ── Detail panel ───────────────────────────────────────────────────────── - - /// The selected wallpaper's user properties, as WE's two-column list. - /// Changes are saved at once and shown live where it is displayed. - pub(super) fn render_properties(&mut self, ui: &mut egui::Ui, idx: usize) { - if self.prop_editor.as_ref().map(|e| e.idx) != Some(idx) { - let props = crate::engine::properties::list_properties(&self.wallpapers[idx].path) - .unwrap_or_default(); - self.prop_editor = Some(PropertyEditor { - idx, - props, - visible: Vec::new(), - }); - } - let mut editor = self.prop_editor.take().unwrap(); - if editor.visible.len() != editor.props.len() { - editor.visible = self.visible_rows(idx, &editor.props); - } - let settable = editor.props.iter().filter(|p| p.is_settable()).count(); - ui.horizontal(|ui| { - ui.heading("Properties"); - if settable > 0 { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .button(egui::RichText::new("Reset").color(DANGER)) - .on_hover_text("Back to the wallpaper's own defaults") - .clicked() - { - self.reset_properties(idx, &editor.props); - editor.visible.clear(); - } - }); - } - }); - ui.separator(); - if settable == 0 { - ui.label(egui::RichText::new("This wallpaper has no properties.").color(TEXT_MUTED)); - } else { - egui::Grid::new(("properties", idx)) - .num_columns(2) - .spacing([12.0, 8.0]) - .show(ui, |ui| { - if self.property_rows(ui, idx, &editor.props, &editor.visible) { - editor.visible.clear(); - } - }); - } - self.prop_editor = Some(editor); - } - - /// Drop every saved override of wallpaper `idx`. - fn reset_properties(&mut self, idx: usize, props: &[SceneProperty]) { - let key = self.wallpapers[idx].workshop_id.clone(); - let names: Vec = props - .iter() - .filter(|p| self.saved.unset_property(&key, &p.name)) - .map(|p| p.name.clone()) - .collect(); - if names.is_empty() { - return; - } - self.save_settings(); - #[cfg(target_os = "linux")] - self.daemon.send(crate::daemon::Request::PropertiesChanged { - wallpaper: key, - names, - }); - } - - /// Each row's visibility under the current saved values. - fn visible_rows(&self, idx: usize, props: &[SceneProperty]) -> Vec { - let saved = self - .saved - .property_overrides(&self.wallpapers[idx].workshop_id); - let values = props - .iter() - .filter_map(|p| { - let raw = saved.get(&p.name)?; - Some(( - p.name.clone(), - crate::engine::properties::convert_override(p, raw), - )) - }) - .collect(); - crate::engine::properties::visible_rows(props, &values) - } - - /// Draws the rows WE would show; returns whether a value was changed. - fn property_rows( - &mut self, - ui: &mut egui::Ui, - idx: usize, - props: &[SceneProperty], - visible: &[bool], - ) -> bool { - let key = self.wallpapers[idx].workshop_id.clone(); - let mut changed = false; - for (p, _) in props.iter().zip(visible).filter(|(_, v)| **v) { - if !p.is_settable() { - // `text`/`group`/untyped rows are headings and spacers. - let text = plain_text(&p.text); - if text.is_empty() || text.starts_with("ui_") { - ui.add_space(4.0); - } else if p.kind == "group" { - ui.strong(text); - } else { - ui.label(egui::RichText::new(text).color(TEXT_MUTED)); - } - ui.end_row(); - continue; - } - let saved = self - .saved - .properties - .get(&key) - .and_then(|m| m.get(&p.name)) - .cloned(); - let current = saved.clone().unwrap_or_else(|| value_text(&p.value)); - let mut commit: Option = None; - ui.label(label(p)); - ui.horizontal(|ui| { - match p.kind.as_str() { - "bool" => { - let mut on = matches!(current.as_str(), "1" | "true"); - if ui.checkbox(&mut on, "").changed() { - commit = Some(if on { "1" } else { "0" }.into()); - } - } - "slider" => { - let mut v = current.parse::().unwrap_or(0.0); - let range = p.min.unwrap_or(0.0)..=p.max.unwrap_or(1.0); - let mut slider = egui::Slider::new(&mut v, range); - if let Some(step) = p.step.filter(|s| *s > 0.0) { - slider = slider.step_by(step); - } - let r = ui.add(slider); - if r.drag_stopped() || (r.changed() && !r.dragged()) { - commit = Some(v.to_string()); - } - } - "color" => { - let mut c = parse_color(¤t); - if ui.color_edit_button_rgb(&mut c).changed() { - commit = Some(format!("{} {} {}", c[0], c[1], c[2])); - } - } - "combo" => { - let shown = p - .options - .iter() - .find(|(v, _)| *v == current) - .map(|(_, l)| l.clone()) - .unwrap_or_else(|| current.clone()); - egui::ComboBox::from_id_salt(("prop", &p.name)) - .selected_text(shown) - .show_ui(ui, |ui| { - for (v, l) in &p.options { - if ui.selectable_label(*v == current, l).clicked() { - commit = Some(v.clone()); - } - } - }); - } - // textinput, file, directory, scenetexture: free text, with - // a picker for the path kinds. - _ => { - if matches!(p.kind.as_str(), "file" | "directory" | "scenetexture") - && ui.small_button("📂").on_hover_text("Browse…").clicked() - { - commit = pick_path(p.kind == "directory"); - } - let id = ui.make_persistent_id(("prop_text", &p.name)); - let mut text = ui - .data_mut(|d| d.get_temp::(id)) - .unwrap_or_else(|| current.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(150.0)); - if r.lost_focus() && text != current { - commit = Some(text.clone()); - } - if r.has_focus() { - ui.data_mut(|d| d.insert_temp(id, text)); - } else { - ui.data_mut(|d| d.remove::(id)); - } - } - } - if saved.is_some() - && ui - .small_button("↺") - .on_hover_text("Reset to default") - .clicked() - { - commit = Some(String::new()); - } - }); - ui.end_row(); - if let Some(value) = commit { - if value.is_empty() { - self.saved.unset_property(&key, &p.name); - } else { - self.saved.set_property(&key, &p.name, &value); - } - self.save_settings(); - // Show it live on whichever screens display this wallpaper. - #[cfg(target_os = "linux")] - self.daemon.send(crate::daemon::Request::PropertiesChanged { - wallpaper: key.clone(), - names: vec![p.name.clone()], - }); - changed = true; - } - } - changed - } - - // ── Settings window ────────────────────────────────────────────────────── - - pub(super) fn render_settings_window(&mut self, ctx: &egui::Context) { - let mut open = self.show_settings; - egui::Window::new("Settings") - .open(&mut open) - .default_width(480.0) - .vscroll(true) - .show(ctx, |ui| { - ui.horizontal(|ui| { - for (tab, text) in [ - (SettingsTab::Performance, "Performance"), - (SettingsTab::General, "General"), - (SettingsTab::About, "About"), - ] { - ui.selectable_value(&mut self.settings_tab, tab, text); - } - }); - ui.separator(); - match self.settings_tab { - SettingsTab::Performance => { - self.playback_settings(ui); - ui.separator(); - self.quality_settings(ui); - } - SettingsTab::General => self.general_settings(ui), - SettingsTab::About => about(ui), - } - }); - self.show_settings = open; - } - - fn quality_settings(&mut self, ui: &mut egui::Ui) { - ui.heading("Quality"); - let mut quality_changed = false; - { - let mut s = self.settings.lock().unwrap(); - egui::ComboBox::from_label("Render quality") - .selected_text(s.quality.label()) - .show_ui(ui, |ui| { - for q in RenderQuality::ALL { - quality_changed |= - ui.selectable_value(&mut s.quality, q, q.label()).changed(); - } - }); - #[cfg(target_os = "linux")] - if quality_changed { - self.daemon.send(crate::daemon::Request::SetQuality { - quality: s.quality.label().to_string(), - }); - } - } - let _ = quality_changed; - let mut fps = self - .saved - .fps - .unwrap_or(crate::render::settings::DEFAULT_FPS); - let r = ui.add(egui::Slider::new(&mut fps, 1..=240).text("Frame-rate cap")); - if r.changed() { - self.saved.fps = Some(fps); - self.settings.lock().unwrap().fps = fps; - #[cfg(target_os = "linux")] - self.daemon.send(crate::daemon::Request::SetFps { fps }); - } - if r.drag_stopped() || (r.changed() && !r.dragged()) { - self.save_settings(); - } - } - - fn general_settings(&mut self, ui: &mut egui::Ui) { - ui.heading("General"); - let mut autostart = self.autostart; - if ui - .checkbox(&mut autostart, "Start with my session") - .on_hover_text("Runs `wp-engine run` at login (XDG autostart)") - .changed() - { - match crate::settings::set_autostart(autostart) { - Ok(()) => self.autostart = autostart, - Err(e) => self.status = StatusMsg::err(format!("Autostart: {e:#}")), - } - } - if ui - .button("▶ Apply saved setup") - .on_hover_text( - "What `wp-engine run` shows: the login playlist, else the saved displays", - ) - .clicked() - { - self.run_saved_setup(); - } - ui.add_space(6.0); - ui.heading("Audio"); - { - let mut s = self.settings.lock().unwrap(); - let volume_changed = ui - .add( - egui::Slider::new(&mut s.volume, 0.0..=1.0) - .text("Volume") - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)), - ) - .changed(); - #[cfg(target_os = "linux")] - if volume_changed { - self.daemon - .send(crate::daemon::Request::SetVolume { volume: s.volume }); - } - let _ = volume_changed; - } - let devices = self - .audio_devices - .get_or_insert_with(crate::platform::audio::list_audio_outputs); - let current = devices - .get(self.audio_choice) - .map(|d| d.label.clone()) - .unwrap_or_else(|| "Automatic".to_string()); - let mut changed = None; - egui::ComboBox::from_label("Audio-reactive wallpapers listen to") - .selected_text(current) - .width(240.0) - .show_ui(ui, |ui| { - for (i, d) in devices.iter().enumerate() { - if ui - .selectable_label(self.audio_choice == i, &d.label) - .clicked() - { - changed = Some((i, d.device.clone())); - } - } - }); - if let Some((i, device)) = changed { - self.audio_choice = i; - crate::platform::audio::set_preferred_device(device); - self.status = StatusMsg::ok("Audio source set — re-apply the wallpaper to use it"); - } - ui.add_space(6.0); - ui.heading("Display"); - let mut scaling = self.saved.scaling.unwrap_or_default(); - let before = scaling; - egui::ComboBox::from_label("Video, image and web scaling") - .selected_text(scaling.label()) - .show_ui(ui, |ui| { - for m in SCALE_MODES { - ui.selectable_value(&mut scaling, m, m.label()); - } - }); - if scaling != before { - self.saved.scaling = Some(scaling); - self.settings.lock().unwrap().scaling = scaling; - self.save_settings(); - self.status = StatusMsg::ok("Scaling saved — re-apply the wallpaper to use it"); - } - } - - fn playback_settings(&mut self, ui: &mut egui::Ui) { - ui.heading("Playback"); - let mut changed = false; - egui::Grid::new("playback_rules") - .num_columns(2) - .show(ui, |ui| { - for (key, text) in PLAYBACK_CONDITIONS { - ui.label(text); - let mut action = playback_action(&self.saved, key); - egui::ComboBox::from_id_salt(("playback", key)) - .selected_text(action.label()) - .show_ui(ui, |ui| { - for a in PlaybackAction::ALL { - changed |= ui.selectable_value(&mut action, a, a.label()).changed(); - } - }); - self.saved.playback.set(key, action); - ui.end_row(); - } - }); - if changed { - self.save_settings(); - self.status = StatusMsg::ok("Playback rule saved — applies to the next wallpaper"); - } - ui.label( - egui::RichText::new("stop = pause and free the renderer's memory") - .size(11.0) - .color(TEXT_MUTED), - ); - } - - /// What `wp-engine run` does: the active playlist, else the saved - /// screens and default. - fn run_saved_setup(&mut self) { - match std::env::current_exe() - .and_then(|exe| std::process::Command::new(exe).arg("run").spawn()) - { - Ok(mut child) => { - std::thread::spawn(move || child.wait()); - self.active_title = Some("saved setup".into()); - self.status = StatusMsg::ok("Applying saved setup…"); - } - Err(e) => self.status = StatusMsg::err(format!("Couldn't run saved setup: {e}")), - } - } - - /// The playlist bar's Configure popup: rotation, order, timing, - /// whether it plays at login, and deleting it. - pub(super) fn playlist_config(&mut self, ui: &mut egui::Ui) { - let name = self.playlist.clone(); - let Some(p) = self.saved.playlists.get(&name) else { - return; - }; - let titles: Vec = p.items.iter().map(|id| self.title_of(id)).collect(); - let mut changed = playlist_editor( - ui, - &name, - self.saved.playlists.get_mut(&name).unwrap(), - &titles, - ); - ui.separator(); - let mut at_login = self.saved.active_playlist.as_deref() == Some(name.as_str()); - if ui - .checkbox(&mut at_login, "Play at login") - .on_hover_text("`wp-engine run` plays this playlist") - .changed() - { - self.saved.active_playlist = at_login.then(|| name.clone()); - changed = true; - } - if ui - .button(egui::RichText::new("🗑 Delete playlist").color(DANGER)) - .clicked() - { - self.saved.playlists.remove(&name); - if self.saved.active_playlist.as_deref() == Some(name.as_str()) { - self.saved.active_playlist = None; - } - changed = true; - ui.close(); - } - if changed { - self.save_settings(); - } - } -} - -fn about(ui: &mut egui::Ui) { - ui.heading("wp-engine"); - ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); - ui.label("A Wallpaper Engine client for Linux and macOS."); - ui.hyperlink("https://github.com/code-wolf-byte/open-wallpaper-engine"); - ui.label( - egui::RichText::new( - "Scene rendering builds on Almamu's linux-wallpaperengine. \ - Wallpaper Engine is a product of Kristjan Skutta; this project is unaffiliated.", - ) - .size(11.0) - .color(TEXT_MUTED), - ); -} - -fn playback_action(s: &WpSettings, key: &str) -> PlaybackAction { - let p = &s.playback; - match key { - "focus" => p.other_focused, - "maximized" => p.other_maximized, - "fullscreen" => p.other_fullscreen, - "audio" => p.other_audio, - "battery" => p.on_battery, - _ => p.locked, - } -} - -/// Mode, order, timing and items of one playlist. Returns whether it changed. -fn playlist_editor(ui: &mut egui::Ui, name: &str, p: &mut Playlist, titles: &[String]) -> bool { - let before = p.clone(); - egui::ComboBox::from_id_salt(("mode", name)) - .selected_text( - PLAYLIST_MODES - .iter() - .find(|(m, _)| *m == p.mode) - .map_or(p.mode.as_str(), |(_, l)| l), - ) - .show_ui(ui, |ui| { - for (m, l) in PLAYLIST_MODES { - ui.selectable_value(&mut p.mode, m.to_string(), l); - } - }); - ui.horizontal(|ui| { - ui.selectable_value(&mut p.order, "sequential".into(), "In order"); - ui.selectable_value(&mut p.order, "random".into(), "Shuffle"); - if p.mode == "timer" { - ui.add( - egui::DragValue::new(&mut p.delay_minutes) - .range(1..=10_080) - .suffix(" min"), - ); - } - }); - let daytime = p.mode == "daytime"; - if daytime { - p.times.resize(p.items.len(), String::new()); - } - let mut remove = None; - let mut raise = None; - for (i, title) in titles.iter().enumerate() { - ui.horizontal(|ui| { - if ui.small_button("✖").clicked() { - remove = Some(i); - } - if i > 0 && ui.small_button("⬆").clicked() { - raise = Some(i); - } - if daytime { - let t = &mut p.times[i]; - let bad = !t.is_empty() && crate::schedule::parse_time_of_day(t).is_none(); - ui.add( - egui::TextEdit::singleline(t) - .desired_width(48.0) - .hint_text("HH:MM") - .text_color_opt(bad.then_some(egui::Color32::LIGHT_RED)), - ); - } else if p.mode == "dayofweek" { - const DAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; - ui.label(DAYS[i % 7]); - } - ui.label(super::truncate(title, 48)).on_hover_text(title); - }); - } - if let Some(i) = raise { - p.items.swap(i, i - 1); - if p.times.len() > i { - p.times.swap(i, i - 1); - } - } - if let Some(i) = remove { - p.items.remove(i); - if p.times.len() > i { - p.times.remove(i); - } - } - *p != before -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn labels_drop_markup_and_fall_back_to_the_name() { - let mut p = SceneProperty { - name: "add_wave".into(), - kind: "bool".into(), - text: "Add Wave —————".into(), - value: Value::Bool(true), - options: Vec::new(), - min: None, - max: None, - ..Default::default() - }; - assert_eq!(label(&p), "Add Wave"); - p.text = "ui_browse_properties_scheme_color".into(); - assert_eq!(label(&p), "add wave"); - assert_eq!(value_text(&p.value), "1"); - assert_eq!(parse_color("1 0.5 2"), [1.0, 0.5, 1.0]); - } -} diff --git a/src/ui/details.rs b/src/ui/details.rs new file mode 100644 index 0000000..de1c551 --- /dev/null +++ b/src/ui/details.rs @@ -0,0 +1,115 @@ +//! The details panel: the selected wallpaper's preview, information, +//! actions and properties. + +use super::*; + +impl WpApp { + /// The right-hand column: preview, info, actions and properties. + pub(super) fn render_details_panel(&mut self, ctx: &egui::Context, skin: Skin) { + egui::SidePanel::right("details") + .exact_width(DETAILS_W) + .resizable(false) + .frame( + egui::Frame::default() + .fill(skin.panel) + .inner_margin(egui::Margin::same(14)), + ) + .show(ctx, |ui| match self.selected { + Some(idx) => self.render_details(ui, idx, skin), + None => { + ui.vertical_centered(|ui| { + ui.add_space(100.0); + ui.label( + egui::RichText::new("Please select a wallpaper") + .size(16.0) + .color(TEXT_MUTED), + ); + }); + } + }); + } + + pub(super) fn render_details(&mut self, ui: &mut egui::Ui, idx: usize, skin: Skin) { + let w = &self.wallpapers[idx]; + let title = w.title().to_string(); + let wtype = w.wallpaper_type().clone(); + let tags = w.project.tags.clone().unwrap_or_default(); + let rating = age_rating(w); + let id = w.workshop_id.clone(); + let path = w.path.clone(); + let blocker = can_apply(w).err(); + let thumb = self.thumbnails.get(&id).cloned(); + if thumb.is_none() { + self.enqueue_thumb(idx); + } + + egui::ScrollArea::vertical().show(ui, |ui| { + // Preview, square like the thumbnails it comes from. + let side = ui.available_width().min(260.0); + ui.vertical_centered(|ui| { + let (rect, _) = ui.allocate_exact_size(vec2(side, side), Sense::hover()); + let p = ui.painter_at(rect); + p.rect_filled(rect, CornerRadius::same(4), skin.tile); + match &thumb { + Some(tex) => { + p.image(tex.id(), rect, full_uv(), Color32::WHITE); + } + None => { + p.text( + rect.center(), + egui::Align2::CENTER_CENTER, + "No preview", + FontId::proportional(13.0), + TEXT_MUTED, + ); + } + } + }); + ui.add_space(8.0); + ui.label(egui::RichText::new(&title).size(18.0).color(skin.text)); + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + pill(ui, type_name(&wtype), type_color(&wtype), Color32::WHITE); + pill(ui, rating, skin.pill, skin.text); + for tag in &tags { + pill(ui, tag, skin.pill, skin.text); + } + }); + if let Some(reason) = blocker { + ui.add_space(4.0); + ui.label(egui::RichText::new(reason).color(DANGER)); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if id.bytes().all(|b| b.is_ascii_digit()) && ui.button("🌐 Workshop page").clicked() + { + open_external(&workshop_item_url(&id)); + } + if ui.button("📂 Open folder").clicked() { + open_external(&path.to_string_lossy()); + } + let in_playlist = self.playlist_items().contains(&id); + let label = if in_playlist { + "➖ Playlist" + } else { + "➕ Playlist" + }; + if ui.button(label).clicked() { + self.toggle_in_playlist(&id); + } + }); + ui.add_space(10.0); + self.render_properties(ui, idx); + }); + } +} + +pub(super) fn pill(ui: &mut egui::Ui, text: &str, fill: Color32, color: Color32) { + egui::Frame::default() + .fill(fill) + .corner_radius(CornerRadius::same(12)) + .inner_margin(egui::Margin::symmetric(8, 2)) + .show(ui, |ui| { + ui.label(egui::RichText::new(text).size(12.0).color(color)); + }); +} diff --git a/src/ui/displays.rs b/src/ui/displays.rs new file mode 100644 index 0000000..d784921 --- /dev/null +++ b/src/ui/displays.rs @@ -0,0 +1,134 @@ +//! The Choose Display dialog: which display clicked wallpapers go to, the +//! multi-display layout, and what each display shows. + +use super::*; + +impl WpApp { + /// WE's "Choose Display": where clicked wallpapers go, the multi-monitor + /// layout, and what each display shows. + pub(super) fn render_displays(&mut self, ctx: &egui::Context, skin: Skin) { + if !self.show_displays { + return; + } + let outputs = self + .outputs + .get_or_insert_with(crate::platform::display::output_names) + .clone(); + let modal = egui::Modal::new(egui::Id::new("displays")).show(ctx, |ui| { + ui.set_width(560.0); + ui.heading("Choose Display"); + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + let all = self.saved.default_background.clone(); + self.display_box(ui, None, "All displays", all, skin); + for out in &outputs { + let shown = self + .saved + .screens + .get(out) + .or(self.saved.default_background.as_ref()) + .cloned(); + self.display_box(ui, Some(out.clone()), out, shown, skin); + } + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label("Several displays:"); + let mut layout = self.saved.layout.unwrap_or_default(); + let before = layout; + for (l, text) in [ + (crate::content::ScreenLayout::PerScreen, "Separate"), + (crate::content::ScreenLayout::Span, "Stretch"), + (crate::content::ScreenLayout::Clone, "Clone"), + ] { + ui.selectable_value(&mut layout, l, text); + } + if layout != before { + self.saved.layout = Some(layout); + self.save_settings(); + #[cfg(target_os = "linux")] + if let Some(id) = self.saved.default_background.clone() { + let fallback = self + .index_of(&id) + .map(|i| self.wallpapers[i].path.clone()) + .unwrap_or_default(); + self.apply_saved(self.title_of(&id), fallback); + } + } + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui + .add(egui::Button::new( + egui::RichText::new("Remove Wallpaper").color(DANGER), + )) + .clicked() + { + match self.target_screen.clone() { + Some(screen) => { + self.saved.screens.remove(&screen); + } + None => { + self.saved.default_background = None; + self.saved.screens.clear(); + #[cfg(target_os = "linux")] + self.clear_wallpaper(); + } + } + self.save_settings(); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("Close").clicked() { + ui.close(); + } + }); + }); + }); + if modal.should_close() { + self.show_displays = false; + } + } + + /// One display in the "Choose Display" dialog; click to target it. + pub(super) fn display_box( + &mut self, + ui: &mut egui::Ui, + screen: Option, + name: &str, + shown: Option, + skin: Skin, + ) { + let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 120.0), Sense::click()); + let p = ui.painter_at(rect); + let screen_rect = Rect::from_min_size(rect.min, vec2(170.0, 96.0)); + p.rect_filled(screen_rect, CornerRadius::same(4), skin.tile); + if let Some(idx) = shown.as_deref().and_then(|id| self.index_of(id)) { + self.enqueue_thumb(idx); + if let Some(tex) = self.thumbnails.get(&self.wallpapers[idx].workshop_id) { + // The square thumbnail, cropped to the screen's 16:9. + let uv = Rect::from_min_max(pos2(0.0, 0.22), pos2(1.0, 0.78)); + p.image(tex.id(), screen_rect, uv, Color32::WHITE); + } + } + let selected = self.target_screen == screen; + p.rect_stroke( + screen_rect, + CornerRadius::same(4), + Stroke::new( + if selected { 3.0 } else { 1.0 }, + if selected { skin.accent } else { TEXT_MUTED }, + ), + egui::StrokeKind::Inside, + ); + p.text( + pos2(rect.center().x, rect.bottom() - 10.0), + egui::Align2::CENTER_CENTER, + name, + FontId::proportional(13.0), + skin.text, + ); + if resp.clicked() { + self.target_screen = screen; + } + } +} diff --git a/src/ui/footer.rs b/src/ui/footer.rs new file mode 100644 index 0000000..a87d79e --- /dev/null +++ b/src/ui/footer.rs @@ -0,0 +1,72 @@ +//! The footer: Open Wallpaper, what is showing, and OK / Cancel. + +use super::*; + +impl WpApp { + /// Open file/folder on the left, what's showing in the middle, OK and + /// Cancel on the right. + pub(super) fn render_footer(&mut self, ctx: &egui::Context, skin: Skin) { + egui::TopBottomPanel::bottom("footer") + .frame( + egui::Frame::default() + .fill(skin.panel) + .inner_margin(egui::Margin::symmetric(12, 8)), + ) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.menu_button("📂 Open Wallpaper", |ui| { + if ui + .button("Open offline wallpaper (scene folder)…") + .clicked() + { + ui.close(); + if let Some(p) = pick_path(true) { + self.open_path(PathBuf::from(p)); + } + } + if ui.button("Open video, image or web page…").clicked() { + ui.close(); + if let Some(p) = pick_path(false) { + self.open_path(PathBuf::from(p)); + } + } + }); + ui.separator(); + if let Some(title) = self.active_title.clone() { + ui.label(egui::RichText::new(format!("▶ {title}")).color(skin.accent)); + #[cfg(target_os = "linux")] + if ui.small_button("■ Remove").clicked() { + self.clear_wallpaper(); + } + } + let color = if self.status.error { + DANGER + } else { + TEXT_MUTED + }; + ui.label(egui::RichText::new(&self.status.text).color(color)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("Cancel").clicked() { + self.cancel(ctx); + } + let ok = egui::Button::new( + egui::RichText::new(" OK ").color(Color32::WHITE), + ) + .fill(skin.accent); + if ui.add(ok).clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + }); + }); + } + + pub(super) fn open_path(&mut self, path: PathBuf) { + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + self.apply_path(path, name); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 6bd668d..26ad264 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -5,12 +5,33 @@ //! and an "Open Wallpaper" / OK / Cancel footer. As in WE, clicking a //! wallpaper applies it to the chosen display straight away; Cancel puts //! back what was showing (and saved) when the window opened. - +//! +//! This file holds the app state, the frame loop and shared helpers; each +//! part of the window has its own file: `browse` (tabs, search, filters, +//! grid), `details` with `properties`, `playlist`, `displays`, `footer`, +//! `settings_window`, plus `apply` (showing and reverting wallpapers) and +//! `thumbnails`. + +use crate::content::RenderSettings; use crate::platform::{RenderQuality, WallpaperHandle}; -use crate::render::RenderSettings; use crate::workshop::{self, Wallpaper, WallpaperType}; +mod apply; +use apply::*; + +mod thumbnails; + +mod browse; + +mod details; + +mod playlist; + +mod displays; + +mod footer; + #[cfg(not(target_os = "linux"))] -use crate::{platform::display::detect_platform, render::WallpaperContent}; +use crate::{content::WallpaperContent, platform::display::detect_platform}; use egui::{pos2, vec2, Align, Color32, CornerRadius, FontId, Layout, Rect, Sense, Stroke}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -18,7 +39,8 @@ use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::time::SystemTime; -mod config; +mod properties; +mod settings_window; // ── Layout ──────────────────────────────────────────────────────────────────── @@ -116,15 +138,15 @@ pub struct WpApp { pending_apply: Option>, /// Cached speaker list for the audio picker, built on first use — /// enumerating shells out to `pactl`, so not every frame. - audio_devices: Option>, + audio_devices: Option>, /// Index into `audio_devices`; 0 is "Automatic". audio_choice: usize, /// `settings.json`, edited by the property editor and settings window. saved: crate::settings::WpSettings, - prop_editor: Option, + prop_editor: Option, show_settings: bool, - settings_tab: config::SettingsTab, + settings_tab: settings_window::SettingsTab, show_displays: bool, /// The display clicking a wallpaper applies to; `None` = all of them. target_screen: Option, @@ -139,45 +161,6 @@ pub struct WpApp { /// `None` handle when the daemon owns the renderer. type ApplyResult = Result<(Option, String), String>; -/// A worker thread that forwards live setting changes to the daemon, so a -/// dragged slider never blocks the UI on the socket (or on an apply the -/// daemon is still busy with). Changes are dropped when no daemon runs: -/// the next apply carries the current values anyway. -#[cfg(target_os = "linux")] -struct DaemonLink { - tx: mpsc::Sender, -} - -#[cfg(target_os = "linux")] -impl DaemonLink { - fn new() -> Self { - use crate::daemon::{self, Request}; - let (tx, rx) = mpsc::channel::(); - std::thread::spawn(move || { - while let Ok(first) = rx.recv() { - // Coalesce a burst (slider drag) down to the latest of each kind. - let mut latest = HashMap::new(); - for req in std::iter::once(first).chain(rx.try_iter()) { - latest.insert(std::mem::discriminant(&req), req); - } - if !daemon::is_running() { - continue; - } - for req in latest.into_values() { - if let Err(e) = daemon::request(&req) { - tracing::warn!(target: "app", "daemon update failed: {e:#}"); - } - } - } - }); - Self { tx } - } - - fn send(&self, req: crate::daemon::Request) { - let _ = self.tx.send(req); - } -} - /// The filter panel's unticked boxes; a wallpaper shows unless it is /// excluded by type or rating, or every one of its tags is unticked. #[derive(Default)] @@ -315,7 +298,7 @@ impl WpApp { close_after_apply: false, status, settings: Arc::new(Mutex::new(RenderSettings { - fps: saved.fps.unwrap_or(crate::render::settings::DEFAULT_FPS), + fps: saved.fps.unwrap_or(crate::content::settings::DEFAULT_FPS), scaling: saved.scaling.unwrap_or_default(), ..Default::default() })), @@ -325,7 +308,7 @@ impl WpApp { saved, prop_editor: None, show_settings: false, - settings_tab: config::SettingsTab::Performance, + settings_tab: settings_window::SettingsTab::Performance, show_displays: false, target_screen: None, outputs: None, @@ -339,324 +322,10 @@ impl WpApp { // ── Filter helpers ──────────────────────────────────────────────────────── - fn rebuild_filter(&mut self) { - let q = self.search.to_lowercase(); - let mut shown: Vec = (0..self.wallpapers.len()) - .filter(|&i| { - let w = &self.wallpapers[i]; - let text_ok = q.is_empty() - || w.title().to_lowercase().contains(&q) - || w.project - .tags - .iter() - .flatten() - .any(|t| t.to_lowercase() == q); - text_ok && self.filters.matches(w) - }) - .collect(); - let title = |i: &usize| self.wallpapers[*i].title().to_lowercase(); - match self.sort { - SortKey::Name => shown.sort_by_cached_key(title), - SortKey::Type => shown.sort_by_cached_key(|i| { - (type_name(self.wallpapers[*i].wallpaper_type()), title(i)) - }), - // Newest first, like WE. - SortKey::Installed => shown.sort_by_key(|&i| std::cmp::Reverse(self.installed[i])), - } - if self.descending { - shown.reverse(); - } - self.filtered = shown; - } - // ── Thumbnail loading ───────────────────────────────────────────────────── - fn enqueue_thumb(&mut self, idx: usize) { - let id = &self.wallpapers[idx].workshop_id; - if !self.thumbnails.contains_key(id) && !self.thumb_queue.contains(&idx) { - self.thumb_queue.push(idx); - } - } - - fn flush_thumb_queue(&mut self, ctx: &egui::Context) { - // Load at most 4 thumbnails per frame to keep frame times smooth - let batch: Vec = self - .thumb_queue - .drain(..self.thumb_queue.len().min(4)) - .collect(); - for idx in batch { - self.load_thumb(idx, ctx); - } - } - - fn load_thumb(&mut self, idx: usize, ctx: &egui::Context) { - let w = &self.wallpapers[idx]; - let key = w.workshop_id.clone(); - if self.thumbnails.contains_key(&key) { - return; - } - - // Try preview file first, then look for any image in the directory - let path = w.preview_file().or_else(|| { - std::fs::read_dir(&w.path).ok()?.find_map(|e| { - let p = e.ok()?.path(); - let ext = p.extension()?.to_str()?.to_lowercase(); - matches!(ext.as_str(), "jpg" | "jpeg" | "png").then_some(p) - }) - }); - - if let Some(p) = path { - if let Ok(img) = image::open(&p) { - // Square tiles, centre-cropped like WE's grid. - let img = img.into_rgba8(); - let side = img.width().min(img.height()); - let square = image::imageops::crop_imm( - &img, - (img.width() - side) / 2, - (img.height() - side) / 2, - side, - side, - ) - .to_image(); - let thumb = image::imageops::thumbnail(&square, 256, 256); - let size = [thumb.width() as usize, thumb.height() as usize]; - let ci = egui::ColorImage::from_rgba_unmultiplied(size, &thumb.into_raw()); - let handle = ctx.load_texture(&key, ci, egui::TextureOptions::LINEAR); - self.thumbnails.insert(key, handle); - } - } - } - // ── Wallpaper application ───────────────────────────────────────────────── - /// Show a file or folder that isn't a Workshop item on every display. - fn apply_path(&mut self, path: PathBuf, display_title: String) { - let properties = self.saved.property_overrides(&path.to_string_lossy()); - #[cfg(target_os = "linux")] - self.apply_via_daemon(path, HashMap::new(), display_title, properties); - #[cfg(not(target_os = "linux"))] - match WallpaperContent::from_path(&path) { - Err(e) => self.status = StatusMsg::err(format!("Load failed: {e}")), - Ok(content) => self.apply_content(content, display_title, properties), - } - } - - /// WE's click-to-apply: show wallpaper `idx` on the chosen display (or - /// all of them) and remember it, as `wp-engine config` would. - fn apply_wallpaper(&mut self, idx: usize) { - if let Err(reason) = can_apply(&self.wallpapers[idx]) { - self.status = StatusMsg::err(reason); - return; - } - let id = self.wallpapers[idx].workshop_id.clone(); - match self.target_screen.clone() { - Some(screen) => { - self.saved.screens.insert(screen, id); - } - None => { - self.saved.default_background = Some(id); - self.saved.screens.clear(); - } - } - self.save_settings(); - let title = self.wallpapers[idx].title().to_string(); - #[cfg(target_os = "linux")] - self.apply_saved(title, self.wallpapers[idx].path.clone()); - #[cfg(not(target_os = "linux"))] - { - let properties = self - .saved - .property_overrides(&self.wallpapers[idx].path.to_string_lossy()); - match WallpaperContent::from_wallpaper(&self.wallpapers[idx]) { - Err(e) => self.status = StatusMsg::err(e.to_string()), - Ok(content) => self.apply_content(content, title, properties), - } - } - } - - /// Show the saved default and per-display wallpapers; `fallback` stands - /// in for a missing default. - #[cfg(target_os = "linux")] - fn apply_saved(&mut self, title: String, fallback: PathBuf) { - let path_of = |id: &str| { - self.wallpapers - .iter() - .find(|w| w.workshop_id == id) - .map(|w| w.path.clone()) - .or_else(|| workshop::find_item_dir(id, None)) - }; - let background = self - .saved - .default_background - .as_deref() - .and_then(path_of) - .unwrap_or(fallback); - let screens = self - .saved - .screens - .iter() - .filter_map(|(screen, id)| Some((screen.clone(), path_of(id)?))) - .collect(); - let properties = self.saved.property_overrides(&background.to_string_lossy()); - self.apply_via_daemon(background, screens, title, properties); - } - - #[cfg(not(target_os = "linux"))] - fn apply_content( - &mut self, - content: WallpaperContent, - display_title: String, - properties: HashMap, - ) { - crate::engine::properties::set_global_overrides(properties); - let old_renderer = self.renderer.take(); - let settings = Arc::clone(&self.settings); - let (tx, rx) = mpsc::channel(); - - std::thread::spawn(move || { - if let Some(old) = old_renderer { - old.stop(); - } - let result = detect_platform() - .spawn_wallpaper( - crate::render::ScreenContent::single(content), - crate::render::ScreenSettings::single(settings), - ) - .map(|handle| (Some(handle), display_title.clone())) - .map_err(|e| format!("{e:#}")); - let _ = tx.send(result); - }); - - self.pending_apply = Some(rx); - self.status = StatusMsg::ok("Applying..."); - } - - /// Hand the wallpaper to the background daemon (starting it if needed), - /// so it keeps running after this window closes. - #[cfg(target_os = "linux")] - fn apply_via_daemon( - &mut self, - path: PathBuf, - screens: HashMap, - display_title: String, - properties: HashMap, - ) { - use crate::daemon::{self, ApplySpec, Request}; - let spec = { - let s = self.settings.lock().unwrap(); - ApplySpec { - background: path, - screens, - properties, - quality: Some(s.quality.label().to_string()), - volume: Some(s.volume), - title: Some(display_title.clone()), - ..Default::default() - } - }; - let (tx, rx) = mpsc::channel(); - - std::thread::spawn(move || { - let device = crate::platform::audio::preferred_device(); - let result = daemon::request_or_spawn(&Request::SetAudioDevice { device }) - .and_then(|_| daemon::request(&Request::Apply(spec))) - .map(|_| (None, display_title)) - .map_err(|e| format!("{e:#}")); - let _ = tx.send(result); - }); - - self.pending_apply = Some(rx); - self.status = StatusMsg::ok("Applying..."); - } - - /// Remove the wallpaper the daemon is showing (the daemon stays up). - #[cfg(target_os = "linux")] - fn clear_wallpaper(&mut self) { - std::thread::spawn(|| { - if let Err(e) = crate::daemon::request(&crate::daemon::Request::Clear) { - tracing::warn!(target: "app", "clearing wallpaper failed: {e:#}"); - } - }); - self.active_title = None; - self.status = StatusMsg::ok("Wallpaper removed"); - } - - /// WE's Cancel: restore the settings and wallpaper from when the window - /// opened, then close. - fn cancel(&mut self, ctx: &egui::Context) { - if self.saved == self.initial_saved { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - return; - } - self.saved = self.initial_saved.clone(); - self.save_settings(); - #[cfg(target_os = "linux")] - match self.initial.clone() { - Some((path, title)) => { - self.apply_saved(title, path); - self.close_after_apply = true; - return; - } - None => self.clear_wallpaper(), - } - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - - #[cfg(target_os = "linux")] - fn poll_initial_status(&mut self) { - let Some(rx) = &self.initial_status else { - return; - }; - match rx.try_recv() { - Ok(status) => { - if let Some(active) = status.active { - let title = active - .title - .unwrap_or_else(|| active.background.display().to_string()); - self.active_title = Some(title.clone()); - self.initial = Some((active.background, title)); - if let Some(q) = RenderQuality::parse(&active.quality) { - self.settings.lock().unwrap().quality = q; - } - } - self.initial_status = None; - } - Err(mpsc::TryRecvError::Empty) => {} - Err(mpsc::TryRecvError::Disconnected) => self.initial_status = None, - } - } - - fn poll_pending_apply(&mut self, ctx: &egui::Context) { - let Some(rx) = &self.pending_apply else { - return; - }; - let done = match rx.try_recv() { - Ok(Ok((handle, title))) => { - if let Some(handle) = handle { - self.renderer = Some(handle); - } - self.active_title = Some(title.clone()); - self.status = StatusMsg::ok(format!("Applied \"{title}\"")); - true - } - Ok(Err(e)) => { - self.status = StatusMsg::err(e); - true - } - Err(mpsc::TryRecvError::Empty) => false, - Err(mpsc::TryRecvError::Disconnected) => { - self.status = StatusMsg::err("Apply thread crashed"); - true - } - }; - if done { - self.pending_apply = None; - if self.close_after_apply { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - } - } - /// Workshop item path for `id`, for the displays dialog and playlist. fn index_of(&self, id: &str) -> Option { self.wallpapers.iter().position(|w| w.workshop_id == id) @@ -721,800 +390,17 @@ impl eframe::App for WpApp { // ── Panels ──────────────────────────────────────────────────────────────────── impl WpApp { - /// Source tabs with Displays/Settings, then search, filter and sort. - fn render_header(&mut self, ui: &mut egui::Ui, skin: Skin) { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - for (label, active) in [ - ("Installed", true), - ("Discover", false), - ("Workshop", false), - ] { - if source_tab(ui, label, active, skin).clicked() && !active { - open_external(WORKSHOP_URL); - } - } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.spacing_mut().item_spacing.x = 6.0; - if ui.button("⚙ Settings").clicked() { - self.show_settings = !self.show_settings; - // `wp-engine config` may have changed it meanwhile. - self.saved = crate::settings::WpSettings::load(); - self.autostart = crate::settings::autostart_enabled(); - } - let display = self.target_screen.as_deref().unwrap_or("All displays"); - if ui - .button(format!("🖵 Displays: {display}")) - .on_hover_text("Choose the display clicked wallpapers go to") - .clicked() - { - self.show_displays = true; - } - }); - }); - ui.add_space(6.0); - ui.horizontal(|ui| { - let r = ui.add( - egui::TextEdit::singleline(&mut self.search) - .desired_width(260.0) - .hint_text("🔍 Search"), - ); - let mut changed = r.changed(); - let filter_btn = egui::Button::new("☰ Filter results") - .selected(self.show_filter || self.filters.is_active()); - if ui.add(filter_btn).clicked() { - self.show_filter = !self.show_filter; - } - if self.filtered.len() != self.wallpapers.len() { - ui.label( - egui::RichText::new(format!( - "({} of {})", - self.filtered.len(), - self.wallpapers.len() - )) - .color(TEXT_MUTED), - ); - } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let arrow = if self.descending { "⬇" } else { "⬆" }; - if ui.button(arrow).on_hover_text("Sort direction").clicked() { - self.descending = !self.descending; - changed = true; - } - egui::ComboBox::from_id_salt("sort") - .selected_text(self.sort.label()) - .width(140.0) - .show_ui(ui, |ui| { - for key in SortKey::ALL { - changed |= ui - .selectable_value(&mut self.sort, key, key.label()) - .changed(); - } - }); - }); - if changed { - self.rebuild_filter(); - } - }); - } - - /// WE's "Filter results" panel: type, age rating and tags. - fn render_filters(&mut self, ui: &mut egui::Ui) { - let mut changed = false; - egui::ScrollArea::vertical().show(ui, |ui| { - if ui.button("Reset Filters").clicked() { - self.filters = Filters::default(); - changed = true; - } - ui.add_space(6.0); - ui.strong("Type"); - for t in TYPES { - changed |= filter_box(ui, &mut self.filters.hidden_types, t, t); - } - ui.add_space(6.0); - ui.strong("Age rating"); - for r in AGE_RATINGS { - changed |= filter_box(ui, &mut self.filters.hidden_ratings, r, r); - } - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.strong("Tags"); - if ui.small_button("All").clicked() { - self.filters.hidden_tags.clear(); - changed = true; - } - if ui.small_button("None").clicked() { - self.filters.hidden_tags = self.all_tags.iter().cloned().collect(); - changed = true; - } - }); - for tag in self.all_tags.clone() { - changed |= filter_box(ui, &mut self.filters.hidden_tags, tag.clone(), &tag); - } - }); - if changed { - self.rebuild_filter(); - } - } - - fn render_grid(&mut self, ui: &mut egui::Ui, skin: Skin) { - if self.wallpapers.is_empty() { - ui.vertical_centered(|ui| { - ui.add_space(80.0); - ui.label( - egui::RichText::new("No Workshop wallpapers found") - .size(18.0) - .color(TEXT_MUTED), - ); - ui.add_space(10.0); - ui.label( - egui::RichText::new( - "Install Wallpaper Engine on Steam and subscribe to wallpapers,\n\ - or use \"Open Wallpaper\" below to show any file.", - ) - .color(TEXT_MUTED), - ); - }); - return; - } - if self.filtered.is_empty() { - ui.vertical_centered(|ui| { - ui.add_space(95.0); - ui.label( - egui::RichText::new( - "No wallpapers found for your search.\nExpand or reset the filters.", - ) - .size(18.0) - .color(TEXT_MUTED), - ); - }); - return; - } - - // As many columns as fit at up to `TILE`, stretched to fill the row. - let width = ui.available_width(); - let cols = (((width + TILE_GAP) / (TILE + TILE_GAP)).ceil() as usize).max(1); - let tile = (width - TILE_GAP * (cols - 1) as f32) / cols as f32; - let rows = self.filtered.len().div_ceil(cols); - let filtered = self.filtered.clone(); - egui::ScrollArea::vertical().auto_shrink(false).show_rows( - ui, - tile + TILE_GAP, - rows, - |ui, range| { - for row in range { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = TILE_GAP; - for &idx in filtered.iter().skip(row * cols).take(cols) { - self.grid_tile(ui, idx, tile, skin); - } - }); - ui.add_space(TILE_GAP); - } - }, - ); - } - - fn grid_tile(&mut self, ui: &mut egui::Ui, idx: usize, size: f32, skin: Skin) { - let id = self.wallpapers[idx].workshop_id.clone(); - let thumb = self.thumbnails.get(&id).cloned(); - if thumb.is_none() { - self.enqueue_thumb(idx); - } - let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); - if ui.is_rect_visible(rect) { - draw_tile( - ui, - rect, - &resp, - &self.wallpapers[idx], - thumb.as_ref(), - self.selected == Some(idx), - skin, - ); - } - let in_playlist = self.playlist_items().contains(&id); - resp.context_menu(|ui| { - let label = if in_playlist { - "Remove from playlist" - } else { - "Add to playlist" - }; - if ui.button(label).clicked() { - self.toggle_in_playlist(&id); - ui.close(); - } - if id.bytes().all(|b| b.is_ascii_digit()) && ui.button("Open Workshop page").clicked() { - open_external(&workshop_item_url(&id)); - ui.close(); - } - if ui.button("Open folder").clicked() { - open_external(&self.wallpapers[idx].path.to_string_lossy()); - ui.close(); - } - }); - if resp.clicked() { - self.selected = Some(idx); - self.apply_wallpaper(idx); - } - } - - /// The right-hand column: preview, info, actions and properties. - fn render_details_panel(&mut self, ctx: &egui::Context, skin: Skin) { - egui::SidePanel::right("details") - .exact_width(DETAILS_W) - .resizable(false) - .frame( - egui::Frame::default() - .fill(skin.panel) - .inner_margin(egui::Margin::same(14)), - ) - .show(ctx, |ui| match self.selected { - Some(idx) => self.render_details(ui, idx, skin), - None => { - ui.vertical_centered(|ui| { - ui.add_space(100.0); - ui.label( - egui::RichText::new("Please select a wallpaper") - .size(16.0) - .color(TEXT_MUTED), - ); - }); - } - }); - } - - fn render_details(&mut self, ui: &mut egui::Ui, idx: usize, skin: Skin) { - let w = &self.wallpapers[idx]; - let title = w.title().to_string(); - let wtype = w.wallpaper_type().clone(); - let tags = w.project.tags.clone().unwrap_or_default(); - let rating = age_rating(w); - let id = w.workshop_id.clone(); - let path = w.path.clone(); - let blocker = can_apply(w).err(); - let thumb = self.thumbnails.get(&id).cloned(); - if thumb.is_none() { - self.enqueue_thumb(idx); - } - - egui::ScrollArea::vertical().show(ui, |ui| { - // Preview, square like the thumbnails it comes from. - let side = ui.available_width().min(260.0); - ui.vertical_centered(|ui| { - let (rect, _) = ui.allocate_exact_size(vec2(side, side), Sense::hover()); - let p = ui.painter_at(rect); - p.rect_filled(rect, CornerRadius::same(4), skin.tile); - match &thumb { - Some(tex) => { - p.image(tex.id(), rect, full_uv(), Color32::WHITE); - } - None => { - p.text( - rect.center(), - egui::Align2::CENTER_CENTER, - "No preview", - FontId::proportional(13.0), - TEXT_MUTED, - ); - } - } - }); - ui.add_space(8.0); - ui.label(egui::RichText::new(&title).size(18.0).color(skin.text)); - ui.add_space(4.0); - ui.horizontal_wrapped(|ui| { - pill(ui, type_name(&wtype), type_color(&wtype), Color32::WHITE); - pill(ui, rating, skin.pill, skin.text); - for tag in &tags { - pill(ui, tag, skin.pill, skin.text); - } - }); - if let Some(reason) = blocker { - ui.add_space(4.0); - ui.label(egui::RichText::new(reason).color(DANGER)); - } - ui.add_space(8.0); - ui.horizontal(|ui| { - if id.bytes().all(|b| b.is_ascii_digit()) && ui.button("🌐 Workshop page").clicked() - { - open_external(&workshop_item_url(&id)); - } - if ui.button("📂 Open folder").clicked() { - open_external(&path.to_string_lossy()); - } - let in_playlist = self.playlist_items().contains(&id); - let label = if in_playlist { - "➖ Playlist" - } else { - "➕ Playlist" - }; - if ui.button(label).clicked() { - self.toggle_in_playlist(&id); - } - }); - ui.add_space(10.0); - self.render_properties(ui, idx); - }); - } - - /// Under the grid: the playlist for the chosen display, as in WE. - fn render_playlist_bar(&mut self, ui: &mut egui::Ui, skin: Skin) { - ui.separator(); - ui.horizontal(|ui| { - ui.strong("Playlist"); - ui.menu_button(format!("📋 {}", self.playlist), |ui| { - let names: Vec = self.saved.playlists.keys().cloned().collect(); - for name in names { - if ui.selectable_label(name == self.playlist, &name).clicked() { - self.playlist = name; - ui.close(); - } - } - ui.separator(); - ui.add( - egui::TextEdit::singleline(&mut self.new_playlist) - .hint_text("New playlist…") - .desired_width(140.0), - ); - let name = self.new_playlist.trim().to_string(); - if ui - .add_enabled(!name.is_empty(), egui::Button::new("Save current as")) - .clicked() - { - let copy = self - .saved - .playlists - .get(&self.playlist) - .cloned() - .unwrap_or_else(config::empty_playlist); - self.saved.playlists.insert(name.clone(), copy); - self.playlist = name; - self.new_playlist.clear(); - self.save_settings(); - ui.close(); - } - }); - let has_items = !self.playlist_items().is_empty(); - ui.add_enabled_ui(has_items, |ui| { - ui.menu_button("Configure", |ui| self.playlist_config(ui)); - #[cfg(target_os = "linux")] - { - use crate::daemon::Request; - if ui.button("▶ Play").clicked() { - self.daemon_request(Request::PlayPlaylist { - name: self.playlist.clone(), - }); - self.active_title = Some(format!("playlist '{}'", self.playlist)); - } - if ui.button("⏭").on_hover_text("Next").clicked() { - self.daemon_request(Request::NextItem); - } - if ui.button("■").on_hover_text("Stop rotating").clicked() { - self.daemon_request(Request::StopPlaylist); - } - } - if ui.button("Clear").clicked() { - if let Some(p) = self.saved.playlists.get_mut(&self.playlist) { - p.items.clear(); - p.times.clear(); - } - self.save_settings(); - } - }); - if let Some(idx) = self.selected { - let id = self.wallpapers[idx].workshop_id.clone(); - let label = if self.playlist_items().contains(&id) { - "Remove wallpaper" - } else { - "Add wallpaper" - }; - if ui.button(label).clicked() { - self.toggle_in_playlist(&id); - } - } - }); - let items = self.playlist_items(); - if items.is_empty() { - ui.label( - egui::RichText::new( - "Right-click a wallpaper or use \"Add wallpaper\" to build a playlist.", - ) - .size(11.0) - .color(TEXT_MUTED), - ); - return; - } - egui::ScrollArea::horizontal().show(ui, |ui| { - ui.horizontal(|ui| { - for id in items { - let idx = self.index_of(&id); - if let Some(idx) = idx { - self.enqueue_thumb(idx); - } - let (rect, resp) = ui - .allocate_exact_size(vec2(PLAYLIST_THUMB, PLAYLIST_THUMB), Sense::click()); - let p = ui.painter_at(rect); - p.rect_filled(rect, CornerRadius::same(3), skin.tile); - if let Some(tex) = self.thumbnails.get(&id) { - p.image(tex.id(), rect, full_uv(), Color32::WHITE); - } - if idx.is_some() && idx == self.selected { - p.rect_stroke( - rect, - CornerRadius::same(3), - Stroke::new(2.0, skin.accent), - egui::StrokeKind::Inside, - ); - } - let resp = resp.on_hover_text(self.title_of(&id)); - if resp.clicked() { - self.selected = idx; - } - } - }); - }); - } - - /// Open file/folder on the left, what's showing in the middle, OK and - /// Cancel on the right. - fn render_footer(&mut self, ctx: &egui::Context, skin: Skin) { - egui::TopBottomPanel::bottom("footer") - .frame( - egui::Frame::default() - .fill(skin.panel) - .inner_margin(egui::Margin::symmetric(12, 8)), - ) - .show(ctx, |ui| { - ui.horizontal(|ui| { - ui.menu_button("📂 Open Wallpaper", |ui| { - if ui - .button("Open offline wallpaper (scene folder)…") - .clicked() - { - ui.close(); - if let Some(p) = config::pick_path(true) { - self.open_path(PathBuf::from(p)); - } - } - if ui.button("Open video, image or web page…").clicked() { - ui.close(); - if let Some(p) = config::pick_path(false) { - self.open_path(PathBuf::from(p)); - } - } - }); - ui.separator(); - if let Some(title) = self.active_title.clone() { - ui.label(egui::RichText::new(format!("▶ {title}")).color(skin.accent)); - #[cfg(target_os = "linux")] - if ui.small_button("■ Remove").clicked() { - self.clear_wallpaper(); - } - } - let color = if self.status.error { - DANGER - } else { - TEXT_MUTED - }; - ui.label(egui::RichText::new(&self.status.text).color(color)); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.button("Cancel").clicked() { - self.cancel(ctx); - } - let ok = egui::Button::new( - egui::RichText::new(" OK ").color(Color32::WHITE), - ) - .fill(skin.accent); - if ui.add(ok).clicked() { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - }); - }); - }); - } - - fn open_path(&mut self, path: PathBuf) { - let name = path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - self.apply_path(path, name); - } - - /// WE's "Choose Display": where clicked wallpapers go, the multi-monitor - /// layout, and what each display shows. - fn render_displays(&mut self, ctx: &egui::Context, skin: Skin) { - if !self.show_displays { - return; - } - let outputs = self - .outputs - .get_or_insert_with(crate::platform::display::output_names) - .clone(); - let modal = egui::Modal::new(egui::Id::new("displays")).show(ctx, |ui| { - ui.set_width(560.0); - ui.heading("Choose Display"); - ui.add_space(8.0); - ui.horizontal_wrapped(|ui| { - let all = self.saved.default_background.clone(); - self.display_box(ui, None, "All displays", all, skin); - for out in &outputs { - let shown = self - .saved - .screens - .get(out) - .or(self.saved.default_background.as_ref()) - .cloned(); - self.display_box(ui, Some(out.clone()), out, shown, skin); - } - }); - ui.add_space(8.0); - ui.horizontal(|ui| { - ui.label("Several displays:"); - let mut layout = self.saved.layout.unwrap_or_default(); - let before = layout; - for (l, text) in [ - (crate::render::ScreenLayout::PerScreen, "Separate"), - (crate::render::ScreenLayout::Span, "Stretch"), - (crate::render::ScreenLayout::Clone, "Clone"), - ] { - ui.selectable_value(&mut layout, l, text); - } - if layout != before { - self.saved.layout = Some(layout); - self.save_settings(); - #[cfg(target_os = "linux")] - if let Some(id) = self.saved.default_background.clone() { - let fallback = self - .index_of(&id) - .map(|i| self.wallpapers[i].path.clone()) - .unwrap_or_default(); - self.apply_saved(self.title_of(&id), fallback); - } - } - }); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui - .add(egui::Button::new( - egui::RichText::new("Remove Wallpaper").color(DANGER), - )) - .clicked() - { - match self.target_screen.clone() { - Some(screen) => { - self.saved.screens.remove(&screen); - } - None => { - self.saved.default_background = None; - self.saved.screens.clear(); - #[cfg(target_os = "linux")] - self.clear_wallpaper(); - } - } - self.save_settings(); - } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.button("Close").clicked() { - ui.close(); - } - }); - }); - }); - if modal.should_close() { - self.show_displays = false; - } - } - - /// One display in the "Choose Display" dialog; click to target it. - fn display_box( - &mut self, - ui: &mut egui::Ui, - screen: Option, - name: &str, - shown: Option, - skin: Skin, - ) { - let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 120.0), Sense::click()); - let p = ui.painter_at(rect); - let screen_rect = Rect::from_min_size(rect.min, vec2(170.0, 96.0)); - p.rect_filled(screen_rect, CornerRadius::same(4), skin.tile); - if let Some(idx) = shown.as_deref().and_then(|id| self.index_of(id)) { - self.enqueue_thumb(idx); - if let Some(tex) = self.thumbnails.get(&self.wallpapers[idx].workshop_id) { - // The square thumbnail, cropped to the screen's 16:9. - let uv = Rect::from_min_max(pos2(0.0, 0.22), pos2(1.0, 0.78)); - p.image(tex.id(), screen_rect, uv, Color32::WHITE); - } - } - let selected = self.target_screen == screen; - p.rect_stroke( - screen_rect, - CornerRadius::same(4), - Stroke::new( - if selected { 3.0 } else { 1.0 }, - if selected { skin.accent } else { TEXT_MUTED }, - ), - egui::StrokeKind::Inside, - ); - p.text( - pos2(rect.center().x, rect.bottom() - 10.0), - egui::Align2::CENTER_CENTER, - name, - FontId::proportional(13.0), - skin.text, - ); - if resp.clicked() { - self.target_screen = screen; - } - } - // ── Playlist helpers ────────────────────────────────────────────────────── - - fn playlist_items(&self) -> Vec { - self.saved - .playlists - .get(&self.playlist) - .map(|p| p.items.clone()) - .unwrap_or_default() - } - - fn toggle_in_playlist(&mut self, id: &str) { - let p = self - .saved - .playlists - .entry(self.playlist.clone()) - .or_insert_with(config::empty_playlist); - match p.items.iter().position(|i| i == id) { - Some(i) => { - p.items.remove(i); - if p.times.len() > i { - p.times.remove(i); - } - } - None => p.items.push(id.to_string()), - } - self.save_settings(); - } } // ── Drawing ─────────────────────────────────────────────────────────────────── -fn source_tab(ui: &mut egui::Ui, label: &str, active: bool, skin: Skin) -> egui::Response { - let (fill, text) = if active { - (skin.accent, Color32::WHITE) - } else { - (skin.panel, skin.text) - }; - ui.add( - egui::Button::new(egui::RichText::new(label).size(15.0).color(text)) - .fill(fill) - .stroke(Stroke::new(1.0, skin.accent)) - .corner_radius(CornerRadius { - nw: 3, - ne: 3, - sw: 0, - se: 0, - }) - .min_size(vec2(96.0, 28.0)), - ) -} - -/// A ticked-by-default filter checkbox backed by a set of unticked keys. -fn filter_box( - ui: &mut egui::Ui, - hidden: &mut HashSet, - key: K, - text: &str, -) -> bool { - let mut on = !hidden.contains(&key); - if !ui.checkbox(&mut on, text).changed() { - return false; - } - if on { - hidden.remove(&key); - } else { - hidden.insert(key); - } - true -} - -fn pill(ui: &mut egui::Ui, text: &str, fill: Color32, color: Color32) { - egui::Frame::default() - .fill(fill) - .corner_radius(CornerRadius::same(12)) - .inner_margin(egui::Margin::symmetric(8, 2)) - .show(ui, |ui| { - ui.label(egui::RichText::new(text).size(12.0).color(color)); - }); -} - -fn draw_tile( - ui: &egui::Ui, - rect: Rect, - resp: &egui::Response, - w: &Wallpaper, - thumb: Option<&egui::TextureHandle>, - selected: bool, - skin: Skin, -) { - let painter = ui.painter_at(rect); - painter.rect_filled(rect, CornerRadius::same(2), skin.tile); - match thumb { - Some(tex) => { - painter.image(tex.id(), rect, full_uv(), Color32::WHITE); - } - None => { - painter.text( - rect.center(), - egui::Align2::CENTER_CENTER, - "⏳", - FontId::proportional(22.0), - TEXT_MUTED, - ); - } - } - if resp.hovered() { - painter.rect_filled(rect, CornerRadius::same(2), Color32::from_white_alpha(24)); - } - - // Title over a dark band along the bottom, as in WE's grid. - let band = Rect::from_min_max(pos2(rect.min.x, rect.max.y - 24.0), rect.max); - painter.rect_filled(band, CornerRadius::ZERO, Color32::from_black_alpha(170)); - let mut job = egui::text::LayoutJob::simple_singleline( - w.title().to_string(), - FontId::proportional(12.0), - Color32::WHITE, - ); - job.wrap = egui::text::TextWrapping::truncate_at_width(rect.width() - 10.0); - painter.galley( - pos2(band.min.x + 5.0, band.min.y + 5.0), - painter.layout_job(job), - Color32::WHITE, - ); - - // Type marker in the top corner for everything that isn't a scene. - if !matches!(w.wallpaper_type(), WallpaperType::Scene) { - let label = type_name(w.wallpaper_type()).to_uppercase(); - let galley = painter.layout_no_wrap(label, FontId::proportional(9.0), Color32::WHITE); - let size = galley.size() + vec2(8.0, 4.0); - let badge = Rect::from_min_size(pos2(rect.max.x - size.x - 4.0, rect.min.y + 4.0), size); - painter.rect_filled(badge, CornerRadius::same(3), type_color(w.wallpaper_type())); - painter.galley(badge.min + vec2(4.0, 2.0), galley, Color32::WHITE); - } - - if selected { - painter.rect_stroke( - rect, - CornerRadius::same(2), - Stroke::new(3.0, skin.accent), - egui::StrokeKind::Inside, - ); - } -} - // ── Utilities ───────────────────────────────────────────────────────────────── fn full_uv() -> Rect { Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) } -/// Why `w` can't be shown here, if it can't. -fn can_apply(w: &Wallpaper) -> Result<(), &'static str> { - let file_ok = || w.wallpaper_file().is_some_and(|p| p.exists()); - match w.wallpaper_type() { - WallpaperType::Scene | WallpaperType::Preset => Ok(()), - WallpaperType::Application => Err("Application wallpapers only run on Windows"), - WallpaperType::Web if !crate::render::web::is_supported() => { - Err("Web wallpapers need a build with --features web") - } - _ if file_ok() => Ok(()), - _ => Err("The wallpaper's file is missing"), - } -} - fn age_rating(w: &Wallpaper) -> &'static str { match w.project.contentrating.as_deref() { Some("Mature") => "Mature", @@ -1567,6 +453,55 @@ fn truncate(s: &str, max: usize) -> &str { } } +impl WpApp { + pub(super) fn save_settings(&mut self) { + if let Err(e) = self.saved.save() { + self.status = StatusMsg::err(format!("Saving settings failed: {e:#}")); + } + } + + /// Send a one-off request to the daemon, starting it if needed. + #[cfg(target_os = "linux")] + pub(super) fn daemon_request(&mut self, req: crate::daemon::Request) { + std::thread::spawn(move || { + if let Err(e) = crate::daemon::request_or_spawn(&req) { + tracing::warn!(target: "app", "daemon request failed: {e:#}"); + } + }); + } + + pub(super) fn title_of(&self, id: &str) -> String { + self.wallpapers + .iter() + .find(|w| w.workshop_id == id) + .map(|w| w.title().to_string()) + .unwrap_or_else(|| id.to_string()) + } +} + +/// Ask the desktop for a file or folder via zenity or kdialog. +// ponytail: shells out instead of a file-dialog crate; use the XDG portal +// (zbus is already a dependency) if neither tool is common enough. +pub(super) fn pick_path(directory: bool) -> Option { + let zenity: &[&str] = if directory { + &["--file-selection", "--directory"] + } else { + &["--file-selection"] + }; + let kdialog: &[&str] = if directory { + &["--getexistingdirectory"] + } else { + &["--getopenfilename"] + }; + [("zenity", zenity), ("kdialog", kdialog)] + .iter() + .find_map(|(cmd, args)| { + let out = std::process::Command::new(cmd).args(*args).output().ok()?; + let path = String::from_utf8(out.stdout).ok()?.trim().to_string(); + (out.status.success() && !path.is_empty()).then_some(path) + }) +} + /// Wallpaper Engine's light and dark skins, following the system theme. fn setup_theme(ctx: &egui::Context) { for (theme, skin, mut v) in [ diff --git a/src/ui/playlist.rs b/src/ui/playlist.rs new file mode 100644 index 0000000..f4196d6 --- /dev/null +++ b/src/ui/playlist.rs @@ -0,0 +1,276 @@ +//! The playlist bar under the grid: choosing, saving, playing and editing +//! the current playlist. + +use super::*; +use crate::settings::Playlist; + +impl WpApp { + /// Under the grid: the playlist for the chosen display, as in WE. + pub(super) fn render_playlist_bar(&mut self, ui: &mut egui::Ui, skin: Skin) { + ui.separator(); + ui.horizontal(|ui| { + ui.strong("Playlist"); + ui.menu_button(format!("📋 {}", self.playlist), |ui| { + let names: Vec = self.saved.playlists.keys().cloned().collect(); + for name in names { + if ui.selectable_label(name == self.playlist, &name).clicked() { + self.playlist = name; + ui.close(); + } + } + ui.separator(); + ui.add( + egui::TextEdit::singleline(&mut self.new_playlist) + .hint_text("New playlist…") + .desired_width(140.0), + ); + let name = self.new_playlist.trim().to_string(); + if ui + .add_enabled(!name.is_empty(), egui::Button::new("Save current as")) + .clicked() + { + let copy = self + .saved + .playlists + .get(&self.playlist) + .cloned() + .unwrap_or_else(empty_playlist); + self.saved.playlists.insert(name.clone(), copy); + self.playlist = name; + self.new_playlist.clear(); + self.save_settings(); + ui.close(); + } + }); + let has_items = !self.playlist_items().is_empty(); + ui.add_enabled_ui(has_items, |ui| { + ui.menu_button("Configure", |ui| self.playlist_config(ui)); + #[cfg(target_os = "linux")] + { + use crate::daemon::Request; + if ui.button("▶ Play").clicked() { + self.daemon_request(Request::PlayPlaylist { + name: self.playlist.clone(), + }); + self.active_title = Some(format!("playlist '{}'", self.playlist)); + } + if ui.button("⏭").on_hover_text("Next").clicked() { + self.daemon_request(Request::NextItem); + } + if ui.button("■").on_hover_text("Stop rotating").clicked() { + self.daemon_request(Request::StopPlaylist); + } + } + if ui.button("Clear").clicked() { + if let Some(p) = self.saved.playlists.get_mut(&self.playlist) { + p.items.clear(); + p.times.clear(); + } + self.save_settings(); + } + }); + if let Some(idx) = self.selected { + let id = self.wallpapers[idx].workshop_id.clone(); + let label = if self.playlist_items().contains(&id) { + "Remove wallpaper" + } else { + "Add wallpaper" + }; + if ui.button(label).clicked() { + self.toggle_in_playlist(&id); + } + } + }); + let items = self.playlist_items(); + if items.is_empty() { + ui.label( + egui::RichText::new( + "Right-click a wallpaper or use \"Add wallpaper\" to build a playlist.", + ) + .size(11.0) + .color(TEXT_MUTED), + ); + return; + } + egui::ScrollArea::horizontal().show(ui, |ui| { + ui.horizontal(|ui| { + for id in items { + let idx = self.index_of(&id); + if let Some(idx) = idx { + self.enqueue_thumb(idx); + } + let (rect, resp) = ui + .allocate_exact_size(vec2(PLAYLIST_THUMB, PLAYLIST_THUMB), Sense::click()); + let p = ui.painter_at(rect); + p.rect_filled(rect, CornerRadius::same(3), skin.tile); + if let Some(tex) = self.thumbnails.get(&id) { + p.image(tex.id(), rect, full_uv(), Color32::WHITE); + } + if idx.is_some() && idx == self.selected { + p.rect_stroke( + rect, + CornerRadius::same(3), + Stroke::new(2.0, skin.accent), + egui::StrokeKind::Inside, + ); + } + let resp = resp.on_hover_text(self.title_of(&id)); + if resp.clicked() { + self.selected = idx; + } + } + }); + }); + } + + pub(super) fn playlist_items(&self) -> Vec { + self.saved + .playlists + .get(&self.playlist) + .map(|p| p.items.clone()) + .unwrap_or_default() + } + + pub(super) fn toggle_in_playlist(&mut self, id: &str) { + let p = self + .saved + .playlists + .entry(self.playlist.clone()) + .or_insert_with(empty_playlist); + match p.items.iter().position(|i| i == id) { + Some(i) => { + p.items.remove(i); + if p.times.len() > i { + p.times.remove(i); + } + } + None => p.items.push(id.to_string()), + } + self.save_settings(); + } +} + +impl WpApp { + /// The playlist bar's Configure popup: rotation, order, timing, + /// whether it plays at login, and deleting it. + pub(super) fn playlist_config(&mut self, ui: &mut egui::Ui) { + let name = self.playlist.clone(); + let Some(p) = self.saved.playlists.get(&name) else { + return; + }; + let titles: Vec = p.items.iter().map(|id| self.title_of(id)).collect(); + let mut changed = playlist_editor( + ui, + &name, + self.saved.playlists.get_mut(&name).unwrap(), + &titles, + ); + ui.separator(); + let mut at_login = self.saved.active_playlist.as_deref() == Some(name.as_str()); + if ui + .checkbox(&mut at_login, "Play at login") + .on_hover_text("`wp-engine run` plays this playlist") + .changed() + { + self.saved.active_playlist = at_login.then(|| name.clone()); + changed = true; + } + if ui + .button(egui::RichText::new("🗑 Delete playlist").color(DANGER)) + .clicked() + { + self.saved.playlists.remove(&name); + if self.saved.active_playlist.as_deref() == Some(name.as_str()) { + self.saved.active_playlist = None; + } + changed = true; + ui.close(); + } + if changed { + self.save_settings(); + } + } +} + +/// A new playlist with the saved format's defaults. +pub(super) fn empty_playlist() -> Playlist { + serde_json::from_str(r#"{"items":[]}"#).expect("playlist defaults") +} + +const PLAYLIST_MODES: [(&str, &str); 4] = [ + ("timer", "Every N minutes"), + ("daytime", "At times of day"), + ("dayofweek", "By day of week"), + ("login", "Next one each login"), +]; + +/// Mode, order, timing and items of one playlist. Returns whether it changed. +fn playlist_editor(ui: &mut egui::Ui, name: &str, p: &mut Playlist, titles: &[String]) -> bool { + let before = p.clone(); + egui::ComboBox::from_id_salt(("mode", name)) + .selected_text( + PLAYLIST_MODES + .iter() + .find(|(m, _)| *m == p.mode) + .map_or(p.mode.as_str(), |(_, l)| l), + ) + .show_ui(ui, |ui| { + for (m, l) in PLAYLIST_MODES { + ui.selectable_value(&mut p.mode, m.to_string(), l); + } + }); + ui.horizontal(|ui| { + ui.selectable_value(&mut p.order, "sequential".into(), "In order"); + ui.selectable_value(&mut p.order, "random".into(), "Shuffle"); + if p.mode == "timer" { + ui.add( + egui::DragValue::new(&mut p.delay_minutes) + .range(1..=10_080) + .suffix(" min"), + ); + } + }); + let daytime = p.mode == "daytime"; + if daytime { + p.times.resize(p.items.len(), String::new()); + } + let mut remove = None; + let mut raise = None; + for (i, title) in titles.iter().enumerate() { + ui.horizontal(|ui| { + if ui.small_button("✖").clicked() { + remove = Some(i); + } + if i > 0 && ui.small_button("⬆").clicked() { + raise = Some(i); + } + if daytime { + let t = &mut p.times[i]; + let bad = !t.is_empty() && crate::schedule::parse_time_of_day(t).is_none(); + ui.add( + egui::TextEdit::singleline(t) + .desired_width(48.0) + .hint_text("HH:MM") + .text_color_opt(bad.then_some(egui::Color32::LIGHT_RED)), + ); + } else if p.mode == "dayofweek" { + const DAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + ui.label(DAYS[i % 7]); + } + ui.label(super::truncate(title, 48)).on_hover_text(title); + }); + } + if let Some(i) = raise { + p.items.swap(i, i - 1); + if p.times.len() > i { + p.times.swap(i, i - 1); + } + } + if let Some(i) = remove { + p.items.remove(i); + if p.times.len() > i { + p.times.remove(i); + } + } + *p != before +} diff --git a/src/ui/properties.rs b/src/ui/properties.rs new file mode 100644 index 0000000..a611140 --- /dev/null +++ b/src/ui/properties.rs @@ -0,0 +1,304 @@ +//! The property editor in the details panel: a wallpaper's user properties +//! in Wallpaper Engine's order and wording, edited live and saved to +//! settings.json. + +use super::pick_path; +use super::{WpApp, DANGER, TEXT_MUTED}; +use crate::workshop::properties::SceneProperty; +use serde_json::Value; + +impl WpApp { + /// The selected wallpaper's user properties, as WE's two-column list. + /// Changes are saved at once and shown live where it is displayed. + pub(super) fn render_properties(&mut self, ui: &mut egui::Ui, idx: usize) { + if self.prop_editor.as_ref().map(|e| e.idx) != Some(idx) { + let props = crate::workshop::properties::list_properties(&self.wallpapers[idx].path) + .unwrap_or_default(); + self.prop_editor = Some(PropertyEditor { + idx, + props, + visible: Vec::new(), + }); + } + let mut editor = self.prop_editor.take().unwrap(); + if editor.visible.len() != editor.props.len() { + editor.visible = self.visible_rows(idx, &editor.props); + } + let settable = editor.props.iter().filter(|p| p.is_settable()).count(); + ui.horizontal(|ui| { + ui.heading("Properties"); + if settable > 0 { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .button(egui::RichText::new("Reset").color(DANGER)) + .on_hover_text("Back to the wallpaper's own defaults") + .clicked() + { + self.reset_properties(idx, &editor.props); + editor.visible.clear(); + } + }); + } + }); + ui.separator(); + if settable == 0 { + ui.label(egui::RichText::new("This wallpaper has no properties.").color(TEXT_MUTED)); + } else { + egui::Grid::new(("properties", idx)) + .num_columns(2) + .spacing([12.0, 8.0]) + .show(ui, |ui| { + if self.property_rows(ui, idx, &editor.props, &editor.visible) { + editor.visible.clear(); + } + }); + } + self.prop_editor = Some(editor); + } + + /// Drop every saved override of wallpaper `idx`. + pub(super) fn reset_properties(&mut self, idx: usize, props: &[SceneProperty]) { + let key = self.wallpapers[idx].workshop_id.clone(); + let names: Vec = props + .iter() + .filter(|p| self.saved.unset_property(&key, &p.name)) + .map(|p| p.name.clone()) + .collect(); + if names.is_empty() { + return; + } + self.save_settings(); + #[cfg(target_os = "linux")] + self.daemon.send(crate::daemon::Request::PropertiesChanged { + wallpaper: key, + names, + }); + } + + /// Each row's visibility under the current saved values. + pub(super) fn visible_rows(&self, idx: usize, props: &[SceneProperty]) -> Vec { + let saved = self + .saved + .property_overrides(&self.wallpapers[idx].workshop_id); + let values = props + .iter() + .filter_map(|p| { + let raw = saved.get(&p.name)?; + Some(( + p.name.clone(), + crate::workshop::properties::convert_override(p, raw), + )) + }) + .collect(); + crate::workshop::properties::visible_rows(props, &values) + } + + /// Draws the rows WE would show; returns whether a value was changed. + pub(super) fn property_rows( + &mut self, + ui: &mut egui::Ui, + idx: usize, + props: &[SceneProperty], + visible: &[bool], + ) -> bool { + let key = self.wallpapers[idx].workshop_id.clone(); + let mut changed = false; + for (p, _) in props.iter().zip(visible).filter(|(_, v)| **v) { + if !p.is_settable() { + // `text`/`group`/untyped rows are headings and spacers. + let text = plain_text(&p.text); + if text.is_empty() || text.starts_with("ui_") { + ui.add_space(4.0); + } else if p.kind == "group" { + ui.strong(text); + } else { + ui.label(egui::RichText::new(text).color(TEXT_MUTED)); + } + ui.end_row(); + continue; + } + let saved = self + .saved + .properties + .get(&key) + .and_then(|m| m.get(&p.name)) + .cloned(); + let current = saved.clone().unwrap_or_else(|| value_text(&p.value)); + let mut commit: Option = None; + ui.label(label(p)); + ui.horizontal(|ui| { + match p.kind.as_str() { + "bool" => { + let mut on = matches!(current.as_str(), "1" | "true"); + if ui.checkbox(&mut on, "").changed() { + commit = Some(if on { "1" } else { "0" }.into()); + } + } + "slider" => { + let mut v = current.parse::().unwrap_or(0.0); + let range = p.min.unwrap_or(0.0)..=p.max.unwrap_or(1.0); + let mut slider = egui::Slider::new(&mut v, range); + if let Some(step) = p.step.filter(|s| *s > 0.0) { + slider = slider.step_by(step); + } + let r = ui.add(slider); + if r.drag_stopped() || (r.changed() && !r.dragged()) { + commit = Some(v.to_string()); + } + } + "color" => { + let mut c = parse_color(¤t); + if ui.color_edit_button_rgb(&mut c).changed() { + commit = Some(format!("{} {} {}", c[0], c[1], c[2])); + } + } + "combo" => { + let shown = p + .options + .iter() + .find(|(v, _)| *v == current) + .map(|(_, l)| l.clone()) + .unwrap_or_else(|| current.clone()); + egui::ComboBox::from_id_salt(("prop", &p.name)) + .selected_text(shown) + .show_ui(ui, |ui| { + for (v, l) in &p.options { + if ui.selectable_label(*v == current, l).clicked() { + commit = Some(v.clone()); + } + } + }); + } + // textinput, file, directory, scenetexture: free text, with + // a picker for the path kinds. + _ => { + if matches!(p.kind.as_str(), "file" | "directory" | "scenetexture") + && ui.small_button("📂").on_hover_text("Browse…").clicked() + { + commit = pick_path(p.kind == "directory"); + } + let id = ui.make_persistent_id(("prop_text", &p.name)); + let mut text = ui + .data_mut(|d| d.get_temp::(id)) + .unwrap_or_else(|| current.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(150.0)); + if r.lost_focus() && text != current { + commit = Some(text.clone()); + } + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(id, text)); + } else { + ui.data_mut(|d| d.remove::(id)); + } + } + } + if saved.is_some() + && ui + .small_button("↺") + .on_hover_text("Reset to default") + .clicked() + { + commit = Some(String::new()); + } + }); + ui.end_row(); + if let Some(value) = commit { + if value.is_empty() { + self.saved.unset_property(&key, &p.name); + } else { + self.saved.set_property(&key, &p.name, &value); + } + self.save_settings(); + // Show it live on whichever screens display this wallpaper. + #[cfg(target_os = "linux")] + self.daemon.send(crate::daemon::Request::PropertiesChanged { + wallpaper: key.clone(), + names: vec![p.name.clone()], + }); + changed = true; + } + } + changed + } +} + +/// The selected wallpaper's declared properties, loaded once per selection, +/// in WE's display order (headings included). +pub(super) struct PropertyEditor { + pub idx: usize, + pub props: Vec, + /// Which rows their `condition` currently shows; refreshed on each edit. + pub visible: Vec, +} + +/// `text` without the HTML (tags and common entities) real content uses. +pub(super) fn plain_text(text: &str) -> String { + let mut out = String::new(); + let mut in_tag = false; + for c in text.chars() { + match c { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => out.push(c), + _ => {} + } + } + let out = out + .replace(" ", " ") + .replace(" ", " ") + .replace("&", "&"); + out.trim_matches(|c: char| c.is_whitespace() || c == '—' || c == '-') + .to_string() +} + +/// A property's label, falling back to its name when the text is empty or +/// an untranslated `ui_` key. +pub(super) fn label(p: &SceneProperty) -> String { + let out = plain_text(&p.text); + if out.is_empty() || out.starts_with("ui_") { + p.name.replace('_', " ") + } else { + out.to_string() + } +} + +/// A declared value in the string form overrides are saved in. +pub(super) fn value_text(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Bool(b) => if *b { "1" } else { "0" }.to_string(), + other => other.to_string(), + } +} + +pub(super) fn parse_color(s: &str) -> [f32; 3] { + let mut c = [0.0; 3]; + for (slot, v) in c.iter_mut().zip(s.split_whitespace()) { + *slot = v.parse::().unwrap_or(0.0).clamp(0.0, 1.0); + } + c +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn labels_drop_markup_and_fall_back_to_the_name() { + let mut p = SceneProperty { + name: "add_wave".into(), + kind: "bool".into(), + text: "Add Wave —————".into(), + value: Value::Bool(true), + options: Vec::new(), + min: None, + max: None, + ..Default::default() + }; + assert_eq!(label(&p), "Add Wave"); + p.text = "ui_browse_properties_scheme_color".into(); + assert_eq!(label(&p), "add wave"); + assert_eq!(value_text(&p.value), "1"); + assert_eq!(parse_color("1 0.5 2"), [1.0, 0.5, 1.0]); + } +} diff --git a/src/ui/settings_window.rs b/src/ui/settings_window.rs new file mode 100644 index 0000000..2011718 --- /dev/null +++ b/src/ui/settings_window.rs @@ -0,0 +1,264 @@ +//! The settings window: playback rules, frame rate, quality, scaling, audio +//! and autostart. Everything here edits `settings.json`, the same file +//! `wp-engine config` writes. + +use super::{StatusMsg, WpApp, TEXT_MUTED}; +use crate::content::ScaleMode; +use crate::platform::RenderQuality; +use crate::services::playback_gate::PlaybackAction; +use crate::settings::WpSettings; + +/// The settings window's tabs, after WE's own settings view. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) enum SettingsTab { + Performance, + General, + About, +} + +const PLAYBACK_CONDITIONS: [(&str, &str); 6] = [ + ("focus", "Another app is focused"), + ("maximized", "Another app is maximized"), + ("fullscreen", "Another app is fullscreen"), + ("audio", "Another app is playing audio"), + ("battery", "On battery"), + ("lock", "Session locked"), +]; + +const SCALE_MODES: [ScaleMode; 4] = [ + ScaleMode::Fill, + ScaleMode::Fit, + ScaleMode::Stretch, + ScaleMode::Center, +]; + +impl WpApp { + // ── Detail panel ───────────────────────────────────────────────────────── + + // ── Settings window ────────────────────────────────────────────────────── + + pub(super) fn render_settings_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_settings; + egui::Window::new("Settings") + .open(&mut open) + .default_width(480.0) + .vscroll(true) + .show(ctx, |ui| { + ui.horizontal(|ui| { + for (tab, text) in [ + (SettingsTab::Performance, "Performance"), + (SettingsTab::General, "General"), + (SettingsTab::About, "About"), + ] { + ui.selectable_value(&mut self.settings_tab, tab, text); + } + }); + ui.separator(); + match self.settings_tab { + SettingsTab::Performance => { + self.playback_settings(ui); + ui.separator(); + self.quality_settings(ui); + } + SettingsTab::General => self.general_settings(ui), + SettingsTab::About => about(ui), + } + }); + self.show_settings = open; + } + + fn quality_settings(&mut self, ui: &mut egui::Ui) { + ui.heading("Quality"); + let mut quality_changed = false; + { + let mut s = self.settings.lock().unwrap(); + egui::ComboBox::from_label("Render quality") + .selected_text(s.quality.label()) + .show_ui(ui, |ui| { + for q in RenderQuality::ALL { + quality_changed |= + ui.selectable_value(&mut s.quality, q, q.label()).changed(); + } + }); + #[cfg(target_os = "linux")] + if quality_changed { + self.daemon.send(crate::daemon::Request::SetQuality { + quality: s.quality.label().to_string(), + }); + } + } + let _ = quality_changed; + let mut fps = self + .saved + .fps + .unwrap_or(crate::content::settings::DEFAULT_FPS); + let r = ui.add(egui::Slider::new(&mut fps, 1..=240).text("Frame-rate cap")); + if r.changed() { + self.saved.fps = Some(fps); + self.settings.lock().unwrap().fps = fps; + #[cfg(target_os = "linux")] + self.daemon.send(crate::daemon::Request::SetFps { fps }); + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + self.save_settings(); + } + } + + fn general_settings(&mut self, ui: &mut egui::Ui) { + ui.heading("General"); + let mut autostart = self.autostart; + if ui + .checkbox(&mut autostart, "Start with my session") + .on_hover_text("Runs `wp-engine run` at login (XDG autostart)") + .changed() + { + match crate::settings::set_autostart(autostart) { + Ok(()) => self.autostart = autostart, + Err(e) => self.status = StatusMsg::err(format!("Autostart: {e:#}")), + } + } + if ui + .button("▶ Apply saved setup") + .on_hover_text( + "What `wp-engine run` shows: the login playlist, else the saved displays", + ) + .clicked() + { + self.run_saved_setup(); + } + ui.add_space(6.0); + ui.heading("Audio"); + { + let mut s = self.settings.lock().unwrap(); + let volume_changed = ui + .add( + egui::Slider::new(&mut s.volume, 0.0..=1.0) + .text("Volume") + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)), + ) + .changed(); + #[cfg(target_os = "linux")] + if volume_changed { + self.daemon + .send(crate::daemon::Request::SetVolume { volume: s.volume }); + } + let _ = volume_changed; + } + let devices = self + .audio_devices + .get_or_insert_with(crate::services::audio_devices::list_audio_outputs); + let current = devices + .get(self.audio_choice) + .map(|d| d.label.clone()) + .unwrap_or_else(|| "Automatic".to_string()); + let mut changed = None; + egui::ComboBox::from_label("Audio-reactive wallpapers listen to") + .selected_text(current) + .width(240.0) + .show_ui(ui, |ui| { + for (i, d) in devices.iter().enumerate() { + if ui + .selectable_label(self.audio_choice == i, &d.label) + .clicked() + { + changed = Some((i, d.device.clone())); + } + } + }); + if let Some((i, device)) = changed { + self.audio_choice = i; + crate::services::audio_devices::set_preferred_device(device); + self.status = StatusMsg::ok("Audio source set — re-apply the wallpaper to use it"); + } + ui.add_space(6.0); + ui.heading("Display"); + let mut scaling = self.saved.scaling.unwrap_or_default(); + let before = scaling; + egui::ComboBox::from_label("Video, image and web scaling") + .selected_text(scaling.label()) + .show_ui(ui, |ui| { + for m in SCALE_MODES { + ui.selectable_value(&mut scaling, m, m.label()); + } + }); + if scaling != before { + self.saved.scaling = Some(scaling); + self.settings.lock().unwrap().scaling = scaling; + self.save_settings(); + self.status = StatusMsg::ok("Scaling saved — re-apply the wallpaper to use it"); + } + } + + fn playback_settings(&mut self, ui: &mut egui::Ui) { + ui.heading("Playback"); + let mut changed = false; + egui::Grid::new("playback_rules") + .num_columns(2) + .show(ui, |ui| { + for (key, text) in PLAYBACK_CONDITIONS { + ui.label(text); + let mut action = playback_action(&self.saved, key); + egui::ComboBox::from_id_salt(("playback", key)) + .selected_text(action.label()) + .show_ui(ui, |ui| { + for a in PlaybackAction::ALL { + changed |= ui.selectable_value(&mut action, a, a.label()).changed(); + } + }); + self.saved.playback.set(key, action); + ui.end_row(); + } + }); + if changed { + self.save_settings(); + self.status = StatusMsg::ok("Playback rule saved — applies to the next wallpaper"); + } + ui.label( + egui::RichText::new("stop = pause and free the renderer's memory") + .size(11.0) + .color(TEXT_MUTED), + ); + } + + /// What `wp-engine run` does: the active playlist, else the saved + /// screens and default. + fn run_saved_setup(&mut self) { + match std::env::current_exe() + .and_then(|exe| std::process::Command::new(exe).arg("run").spawn()) + { + Ok(mut child) => { + std::thread::spawn(move || child.wait()); + self.active_title = Some("saved setup".into()); + self.status = StatusMsg::ok("Applying saved setup…"); + } + Err(e) => self.status = StatusMsg::err(format!("Couldn't run saved setup: {e}")), + } + } +} + +fn about(ui: &mut egui::Ui) { + ui.heading("wp-engine"); + ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); + ui.label("A Wallpaper Engine client for Linux and macOS."); + ui.hyperlink("https://github.com/code-wolf-byte/open-wallpaper-engine"); + ui.label( + egui::RichText::new( + "Scene rendering builds on Almamu's linux-wallpaperengine. \ + Wallpaper Engine is a product of Kristjan Skutta; this project is unaffiliated.", + ) + .size(11.0) + .color(TEXT_MUTED), + ); +} + +fn playback_action(s: &WpSettings, key: &str) -> PlaybackAction { + let p = &s.playback; + match key { + "focus" => p.other_focused, + "maximized" => p.other_maximized, + "fullscreen" => p.other_fullscreen, + "audio" => p.other_audio, + "battery" => p.on_battery, + _ => p.locked, + } +} diff --git a/src/ui/thumbnails.rs b/src/ui/thumbnails.rs new file mode 100644 index 0000000..b121825 --- /dev/null +++ b/src/ui/thumbnails.rs @@ -0,0 +1,62 @@ +//! Thumbnail loading: square, centre-cropped previews decoded a few per frame +//! so scrolling stays smooth. + +use super::*; + +impl WpApp { + pub(super) fn enqueue_thumb(&mut self, idx: usize) { + let id = &self.wallpapers[idx].workshop_id; + if !self.thumbnails.contains_key(id) && !self.thumb_queue.contains(&idx) { + self.thumb_queue.push(idx); + } + } + + pub(super) fn flush_thumb_queue(&mut self, ctx: &egui::Context) { + // Load at most 4 thumbnails per frame to keep frame times smooth + let batch: Vec = self + .thumb_queue + .drain(..self.thumb_queue.len().min(4)) + .collect(); + for idx in batch { + self.load_thumb(idx, ctx); + } + } + + pub(super) fn load_thumb(&mut self, idx: usize, ctx: &egui::Context) { + let w = &self.wallpapers[idx]; + let key = w.workshop_id.clone(); + if self.thumbnails.contains_key(&key) { + return; + } + + // Try preview file first, then look for any image in the directory + let path = w.preview_file().or_else(|| { + std::fs::read_dir(&w.path).ok()?.find_map(|e| { + let p = e.ok()?.path(); + let ext = p.extension()?.to_str()?.to_lowercase(); + matches!(ext.as_str(), "jpg" | "jpeg" | "png").then_some(p) + }) + }); + + if let Some(p) = path { + if let Ok(img) = image::open(&p) { + // Square tiles, centre-cropped like WE's grid. + let img = img.into_rgba8(); + let side = img.width().min(img.height()); + let square = image::imageops::crop_imm( + &img, + (img.width() - side) / 2, + (img.height() - side) / 2, + side, + side, + ) + .to_image(); + let thumb = image::imageops::thumbnail(&square, 256, 256); + let size = [thumb.width() as usize, thumb.height() as usize]; + let ci = egui::ColorImage::from_rgba_unmultiplied(size, &thumb.into_raw()); + let handle = ctx.load_texture(&key, ci, egui::TextureOptions::LINEAR); + self.thumbnails.insert(key, handle); + } + } + } +} diff --git a/src/workshop/mod.rs b/src/workshop/mod.rs index 3bc13fe..3d41342 100644 --- a/src/workshop/mod.rs +++ b/src/workshop/mod.rs @@ -1,3 +1,4 @@ +pub mod properties; use anyhow::{Context, Result}; use serde::Deserialize; use std::path::{Path, PathBuf}; diff --git a/src/engine/properties.rs b/src/workshop/properties.rs similarity index 100% rename from src/engine/properties.rs rename to src/workshop/properties.rs diff --git a/tests/camera.rs b/tests/camera.rs deleted file mode 100644 index dae480e..0000000 --- a/tests/camera.rs +++ /dev/null @@ -1,101 +0,0 @@ -use wp_engine::engine::scene::{Camera, General, OrthogonalProjection, Scene}; -use wp_engine::engine::SceneCamera; - -fn scene_with_camera_proj(w: u32, h: u32) -> Scene { - Scene { - camera: Some(Camera { - center: None, - eye: None, - up: None, - parallax_amount: None, - orthogonal_projection: Some(OrthogonalProjection { - auto: None, - width: Some(w), - height: Some(h), - }), - }), - general: None, - objects: vec![], - } -} - -fn scene_with_general_proj(w: u32, h: u32) -> Scene { - Scene { - camera: None, - general: Some(General { - orthogonal_projection: Some(OrthogonalProjection { - auto: None, - width: Some(w), - height: Some(h), - }), - ..Default::default() - }), - objects: vec![], - } -} - -fn empty_scene() -> Scene { - Scene { - camera: None, - general: None, - objects: vec![], - } -} - -#[test] -fn from_scene_uses_camera_projection() { - let cam = SceneCamera::from_scene(&scene_with_camera_proj(1920, 1080), (800, 600)); - assert_eq!(cam.width, 1920.0); - assert_eq!(cam.height, 1080.0); -} - -#[test] -fn from_scene_falls_back_to_general() { - let cam = SceneCamera::from_scene(&scene_with_general_proj(2560, 1440), (800, 600)); - assert_eq!(cam.width, 2560.0); - assert_eq!(cam.height, 1440.0); -} - -#[test] -fn from_scene_falls_back_to_fallback_wh() { - let cam = SceneCamera::from_scene(&empty_scene(), (1280, 720)); - assert_eq!(cam.width, 1280.0); - assert_eq!(cam.height, 720.0); -} - -#[test] -fn object_to_quad_centered_full_screen() { - // WE origins are absolute scene coordinates: a fullscreen object's center - // is (w/2, h/2), not (0, 0). - let cam = SceneCamera::new(1920.0, 1080.0); - let (offset, size) = cam.object_to_quad([960.0, 540.0, 0.0], [1920.0, 1080.0]); - assert!((offset[0]).abs() < 1e-6); - assert!((offset[1]).abs() < 1e-6); - assert!((size[0] - 1.0).abs() < 1e-6); - assert!((size[1] - 1.0).abs() < 1e-6); -} - -#[test] -fn object_to_quad_y_axis_points_up() { - // An object near the top of the scene (large Y in WE coords) must land - // near the top of the screen (small offset_norm[1]). - let cam = SceneCamera::new(1000.0, 1000.0); - let (offset, _) = cam.object_to_quad([500.0, 900.0, 0.0], [100.0, 100.0]); - assert!( - offset[1] < 0.1, - "high WE Y should map near screen top, got {}", - offset[1] - ); -} - -#[test] -fn mvp_matrix_column_major() { - let cam = SceneCamera::new(1920.0, 1080.0); - let m = cam.mvp_matrix(); - assert!((m[0] - 2.0 / 1920.0).abs() < 1e-7); - assert!((m[5] - -2.0 / 1080.0).abs() < 1e-7); - assert_eq!(m[10], 1.0); - assert_eq!(m[12], -1.0); - assert_eq!(m[13], 1.0); - assert_eq!(m[15], 1.0); -} diff --git a/tests/effect.rs b/tests/effect.rs index 53c27fc..c1a47a6 100644 --- a/tests/effect.rs +++ b/tests/effect.rs @@ -1,4 +1,4 @@ -use wp_engine::engine::effect::{HandwrittenEffectFallback, SceneEffect}; +use wp_engine::engine::cpu::effect::{HandwrittenEffectFallback, SceneEffect}; #[test] fn recognizes_scroll_and_spin_fallbacks() { diff --git a/tests/properties.rs b/tests/properties.rs index 44a71ae..7ed8c97 100644 --- a/tests/properties.rs +++ b/tests/properties.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; -use wp_engine::engine::camera_dynamics::CameraDynamics; -use wp_engine::engine::properties::{parse_property_arg, SceneProperties}; -use wp_engine::engine::scene::Scene; +use wp_engine::engine::model::scene::Scene; +use wp_engine::engine::sim::camera_dynamics::CameraDynamics; +use wp_engine::workshop::properties::{parse_property_arg, SceneProperties}; fn props_from_project(project: serde_json::Value, overrides: &[(&str, &str)]) -> SceneProperties { // Build via a temp dir so the public loader path is exercised.