From 9e877d7849e51c4aaca88d4f6b7f7880b3aafc25 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 05:57:59 -0700 Subject: [PATCH 001/117] refactor: remove unused engine/camera.rs SceneCamera was re-exported but never used; the GPU renderer computes its own projection. Its only caller was tests/camera.rs, removed with it. --- src/engine/camera.rs | 70 ------------------------------ src/engine/mod.rs | 2 - tests/camera.rs | 101 ------------------------------------------- 3 files changed, 173 deletions(-) delete mode 100644 src/engine/camera.rs delete mode 100644 tests/camera.rs 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/mod.rs b/src/engine/mod.rs index 943bc38..c93b4f4 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -8,7 +8,6 @@ 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; @@ -39,7 +38,6 @@ pub mod text; pub mod timeline; // Convenience re-exports. -pub use camera::SceneCamera; pub use camera_dynamics::{CameraDynamics, CameraFrameDynamics}; pub use fbo::{RenderTarget, RenderTargetPool}; pub use graph::SceneGraph; 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); -} From 38079122b8abea6759c6c786fc71955ed959e066 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 05:58:21 -0700 Subject: [PATCH 002/117] refactor: remove unused engine/pass.rs ScenePass and UniformValue were re-exported but never used; the GPU renderer has its own pass runtime (EffectPassRuntime). --- src/engine/mod.rs | 2 -- src/engine/pass.rs | 70 ---------------------------------------------- 2 files changed, 72 deletions(-) delete mode 100644 src/engine/pass.rs diff --git a/src/engine/mod.rs b/src/engine/mod.rs index c93b4f4..34c3606 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -22,7 +22,6 @@ pub mod mesh3d; pub mod model; pub mod noise; pub mod particle; -pub mod pass; pub mod pkg; pub mod playback_gate; pub mod properties; @@ -41,7 +40,6 @@ pub mod timeline; 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; 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, - } - } -} From 5e413a898d998f9ff823f3765adc0288d91d3998 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 05:58:42 -0700 Subject: [PATCH 003/117] refactor: remove unused engine/resource.rs ResourceManager's only user was the already-removed engine/pass.rs. --- src/engine/mod.rs | 2 - src/engine/resource.rs | 201 ----------------------------------------- 2 files changed, 203 deletions(-) delete mode 100644 src/engine/resource.rs diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 34c3606..49c058b 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -27,7 +27,6 @@ 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; @@ -43,6 +42,5 @@ pub use graph::SceneGraph; pub use pkg::Package; pub use properties::SceneProperties; pub use render::ResolvedScene; -pub use resource::ResourceManager; pub use scene::Scene; pub use tex::TexFile; 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)) - } -} From ea1a3b8e53e4f132433453ba0505d97772405140 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 05:58:51 -0700 Subject: [PATCH 004/117] refactor: move scan_wallpapers to src/bin src/bin/ is where Cargo expects extra binaries; src/tests/ read like a test directory. The binary keeps its scan-wallpapers name. --- Cargo.toml | 2 +- src/{tests => bin}/scan_wallpapers.rs | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{tests => bin}/scan_wallpapers.rs (100%) 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/tests/scan_wallpapers.rs b/src/bin/scan_wallpapers.rs similarity index 100% rename from src/tests/scan_wallpapers.rs rename to src/bin/scan_wallpapers.rs From c66b0461db3b7200a21833f12491d10a62fa1b13 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 05:59:40 -0700 Subject: [PATCH 005/117] refactor: move particle tests into engine/particle/tests.rs particle.rs becomes engine/particle/mod.rs; its two inline test modules (1,500 lines) move unchanged to tests.rs, colorn_probe as a submodule. --- src/engine/{particle.rs => particle/mod.rs} | 1521 +------------------ src/engine/particle/tests.rs | 1517 ++++++++++++++++++ 2 files changed, 1518 insertions(+), 1520 deletions(-) rename src/engine/{particle.rs => particle/mod.rs} (72%) create mode 100644 src/engine/particle/tests.rs diff --git a/src/engine/particle.rs b/src/engine/particle/mod.rs similarity index 72% rename from src/engine/particle.rs rename to src/engine/particle/mod.rs index 0412857..ce6d9b8 100644 --- a/src/engine/particle.rs +++ b/src/engine/particle/mod.rs @@ -4088,1523 +4088,4 @@ fn parse_f32_vec3(s: Option<&str>) -> [f32; 3] { } #[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 - ); - } - } -} +mod tests; diff --git a/src/engine/particle/tests.rs b/src/engine/particle/tests.rs new file mode 100644 index 0000000..5f350c2 --- /dev/null +++ b/src/engine/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 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 + ); + } +} + +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 + ); + } + } +} From 4a163508376e872142b557a8a0fad895c8ac12b8 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:00:23 -0700 Subject: [PATCH 006/117] refactor: move gpu_renderer tests into engine/gpu_renderer/tests.rs gpu_renderer.rs becomes engine/gpu_renderer/mod.rs (include_str! paths gain ../); its two inline test modules (530 lines) move to tests.rs, with hit_tests nested as before. --- .../{gpu_renderer.rs => gpu_renderer/mod.rs} | 535 +----------------- src/engine/gpu_renderer/tests.rs | 527 +++++++++++++++++ 2 files changed, 529 insertions(+), 533 deletions(-) rename src/engine/{gpu_renderer.rs => gpu_renderer/mod.rs} (92%) create mode 100644 src/engine/gpu_renderer/tests.rs diff --git a/src/engine/gpu_renderer.rs b/src/engine/gpu_renderer/mod.rs similarity index 92% rename from src/engine/gpu_renderer.rs rename to src/engine/gpu_renderer/mod.rs index 62718c5..d1ffcff 100644 --- a/src/engine/gpu_renderer.rs +++ b/src/engine/gpu_renderer/mod.rs @@ -28,7 +28,7 @@ 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"); +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). @@ -6642,181 +6642,6 @@ fn point_in_convex_quad(p: [f32; 2], q: &[[f32; 2]; 4]) -> bool { 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, @@ -7060,360 +6885,4 @@ fn build_script_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)); - } -} +mod tests; diff --git a/src/engine/gpu_renderer/tests.rs b/src/engine/gpu_renderer/tests.rs new file mode 100644 index 0000000..0b30de8 --- /dev/null +++ b/src/engine/gpu_renderer/tests.rs @@ -0,0 +1,527 @@ +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)); +} + +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" + ); + } +} From 82c6f84bc2642010abf666d17001c41a5d43246e Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:00:42 -0700 Subject: [PATCH 007/117] refactor: move transpiler tests into engine/shaders/transpiler/tests.rs transpiler.rs becomes engine/shaders/transpiler/mod.rs; its inline test module (640 lines) moves unchanged to tests.rs. --- .../{transpiler.rs => transpiler/mod.rs} | 645 +----------------- src/engine/shaders/transpiler/tests.rs | 642 +++++++++++++++++ 2 files changed, 643 insertions(+), 644 deletions(-) rename src/engine/shaders/{transpiler.rs => transpiler/mod.rs} (79%) create mode 100644 src/engine/shaders/transpiler/tests.rs diff --git a/src/engine/shaders/transpiler.rs b/src/engine/shaders/transpiler/mod.rs similarity index 79% rename from src/engine/shaders/transpiler.rs rename to src/engine/shaders/transpiler/mod.rs index 9b58e05..454fa2d 100644 --- a/src/engine/shaders/transpiler.rs +++ b/src/engine/shaders/transpiler/mod.rs @@ -2553,647 +2553,4 @@ fn is_word_char(b: u8) -> bool { } #[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 - ); - } -} +mod tests; diff --git a/src/engine/shaders/transpiler/tests.rs b/src/engine/shaders/transpiler/tests.rs new file mode 100644 index 0000000..128a9dd --- /dev/null +++ b/src/engine/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 + ); +} From 4d00d4865a14747add2a63b5186d5b4bad3cb7e6 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:00:59 -0700 Subject: [PATCH 008/117] refactor: move scene-resolver tests into engine/render/tests.rs engine/render.rs becomes engine/render/mod.rs; its inline test module (350 lines) moves unchanged to tests.rs. --- src/engine/{render.rs => render/mod.rs} | 348 +----------------------- src/engine/render/tests.rs | 345 +++++++++++++++++++++++ 2 files changed, 346 insertions(+), 347 deletions(-) rename src/engine/{render.rs => render/mod.rs} (89%) create mode 100644 src/engine/render/tests.rs diff --git a/src/engine/render.rs b/src/engine/render/mod.rs similarity index 89% rename from src/engine/render.rs rename to src/engine/render/mod.rs index 3395a05..d44efeb 100644 --- a/src/engine/render.rs +++ b/src/engine/render/mod.rs @@ -2644,350 +2644,4 @@ fn fill_solid_layer_sizes(layers: &mut [Layer], solid_indices: &[usize], width: } #[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]); - } -} +mod tests; diff --git a/src/engine/render/tests.rs b/src/engine/render/tests.rs new file mode 100644 index 0000000..72e5eff --- /dev/null +++ b/src/engine/render/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::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]); +} From d3426a30492c8ae6a13cb98e6941bfeebb72d306 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:02:42 -0700 Subject: [PATCH 009/117] refactor: add render/session.rs for per-output logic shared by backends ContentRenderer and its constructor, applying live property edits, and a PlaybackMonitor owning the pause policy, media and lock watchers and the re-check interval. Wayland and X11 each carry a copy of this today; they switch over in the next commits. --- src/render/mod.rs | 3 + src/render/session.rs | 230 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 src/render/session.rs diff --git a/src/render/mod.rs b/src/render/mod.rs index 0401031..99141e2 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -2,6 +2,9 @@ pub mod content; pub mod ffmpeg; 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 web; pub use content::{ScreenContent, ScreenLayout, WallpaperContent}; diff --git a/src/render/session.rs b/src/render/session.rs new file mode 100644 index 0000000..0958642 --- /dev/null +++ b/src/render/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 +//! (`engine::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::engine::media::{MediaWatcher, PlaybackState as MediaState}; +use crate::engine::playback_gate::{self, PlaybackAction, PlaybackPolicy, PlaybackState}; +use crate::platform::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::render::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); + } +} From d70469378b8a38b2084ea5dbb6d1b628e7d7b06c Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:04:35 -0700 Subject: [PATCH 010/117] refactor: Wayland backend uses the shared render::session Drops its own ContentRenderer, renderer constructor, pause-condition polling and property-edit plumbing (about 130 lines) for the shared module; window states now come from a small window_state() over the foreign-toplevel handles, with a test. --- src/platform/wayland/mod.rs | 233 +++++++++++------------------------- 1 file changed, 72 insertions(+), 161 deletions(-) diff --git a/src/platform/wayland/mod.rs b/src/platform/wayland/mod.rs index 97bf6cc..d50f3c8 100644 --- a/src/platform/wayland/mod.rs +++ b/src/platform/wayland/mod.rs @@ -50,17 +50,13 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{ use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; use crate::engine::playback_gate::PlaybackAction; use crate::{ - engine::gpu_renderer::GpuSceneInstance, platform, - render::{FrameSource, RenderSettings, ScreenContent, ScreenSettings, WallpaperContent}, + render::session::{ + pending_property_edits, ContentRenderer, PlaybackMonitor, WindowState, PAUSE_CHECK_INTERVAL, + }, + render::{FrameSource, RenderSettings, ScreenContent, ScreenSettings}, }; -/// 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. @@ -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. @@ -490,10 +430,8 @@ impl WallpaperState { 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::render::web::WebInputEvent::Fps(fps)); } } crate::engine::audio::set_master_volume(self.settings.default.lock().unwrap().volume); @@ -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, }; @@ -960,7 +888,7 @@ impl OutputHandler for WallpaperState { .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(), @@ -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 { @@ -1556,7 +1464,7 @@ impl WallpaperState { ), 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::render::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::render::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) { 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()); } } } @@ -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() { From c0dfa393ae03d484936fadec70e2cf1dca36a9a3 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:05:53 -0700 Subject: [PATCH 011/117] refactor: X11 backend uses the shared render::session Drops its own ContentRenderer, renderer constructor, pause-condition polling and property-edit plumbing for the shared module; the EWMH check now returns the shared WindowState instead of a private ActiveWindow. --- src/platform/x11/mod.rs | 166 ++++++---------------------------------- 1 file changed, 24 insertions(+), 142 deletions(-) diff --git a/src/platform/x11/mod.rs b/src/platform/x11/mod.rs index ffb0ea3..dca90e9 100644 --- a/src/platform/x11/mod.rs +++ b/src/platform/x11/mod.rs @@ -35,17 +35,11 @@ use crate::engine::playback_gate::PlaybackAction; use x11rb::wrapper::ConnectionExt as _; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; -use crate::engine::gpu_renderer::GpuSceneInstance; 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); +use crate::render::session::{ + pending_property_edits, ContentRenderer, PlaybackMonitor, WindowState, PAUSE_CHECK_INTERVAL, +}; +use crate::render::{FrameSource, RenderSettings, ScreenContent, ScreenSettings}; // ── 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 @@ -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,32 +447,6 @@ 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, @@ -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", @@ -653,13 +584,7 @@ fn wallpaper_loop( // `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::render::web::WebInputEvent::Paused(paused)); + } + if fps != output.web_fps { + let _ = tx.try_send(crate::render::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) @@ -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) From b3c261e654060c3f5ae57624732c1c996a85407a Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:07:43 -0700 Subject: [PATCH 012/117] refactor: move engine/fbo.rs to engine/gpu/fbo.rs First file of the new engine::gpu group (how the scene is drawn). --- src/engine/{ => gpu}/fbo.rs | 0 src/engine/gpu/mod.rs | 4 ++++ src/engine/gpu_renderer/mod.rs | 4 ++-- src/engine/mod.rs | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) rename src/engine/{ => gpu}/fbo.rs (100%) create mode 100644 src/engine/gpu/mod.rs 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/gpu/mod.rs b/src/engine/gpu/mod.rs new file mode 100644 index 0000000..cafc02b --- /dev/null +++ b/src/engine/gpu/mod.rs @@ -0,0 +1,4 @@ +//! How the scene is drawn on the GPU: render targets, blend modes, lighting, +//! shadows and text rasterization. + +pub mod fbo; diff --git a/src/engine/gpu_renderer/mod.rs b/src/engine/gpu_renderer/mod.rs index d1ffcff..a7a3222 100644 --- a/src/engine/gpu_renderer/mod.rs +++ b/src/engine/gpu_renderer/mod.rs @@ -21,7 +21,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use crate::engine::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; -use crate::engine::fbo::RenderTargetPool; +use crate::engine::gpu::fbo::RenderTargetPool; use crate::engine::model::{ShaderModel, WEBlending}; use crate::engine::particle; use crate::engine::scene::{parse_value_bool, parse_value_f32}; @@ -1122,7 +1122,7 @@ impl GpuSceneRenderer { pub fn upload_texture(&self, img: &RgbaImage) -> wgpu::Texture { let (fw, fh) = - crate::engine::fbo::fit_texture_limit(&self.device, img.width(), img.height()); + crate::engine::gpu::fbo::fit_texture_limit(&self.device, img.width(), img.height()); let downscaled; let img = if (fw, fh) != img.dimensions() { tracing::warn!( diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 49c058b..00d8aa8 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -11,7 +11,7 @@ pub mod blend; pub mod camera3d; pub mod camera_dynamics; pub mod effect; -pub mod fbo; +pub mod gpu; pub mod gpu_renderer; pub mod graph; pub mod lighting; @@ -36,8 +36,8 @@ pub mod text; pub mod timeline; // Convenience re-exports. +pub use crate::engine::gpu::fbo::{RenderTarget, RenderTargetPool}; pub use camera_dynamics::{CameraDynamics, CameraFrameDynamics}; -pub use fbo::{RenderTarget, RenderTargetPool}; pub use graph::SceneGraph; pub use pkg::Package; pub use properties::SceneProperties; From fce441241ea1d13500feb483680e59f2ea403e04 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:08:07 -0700 Subject: [PATCH 013/117] refactor: move engine/blend.rs to engine/gpu/blend.rs --- src/engine/{ => gpu}/blend.rs | 0 src/engine/gpu/mod.rs | 1 + src/engine/mod.rs | 1 - src/engine/render/mod.rs | 8 ++++++-- 4 files changed, 7 insertions(+), 3 deletions(-) rename src/engine/{ => gpu}/blend.rs (100%) diff --git a/src/engine/blend.rs b/src/engine/gpu/blend.rs similarity index 100% rename from src/engine/blend.rs rename to src/engine/gpu/blend.rs diff --git a/src/engine/gpu/mod.rs b/src/engine/gpu/mod.rs index cafc02b..042499c 100644 --- a/src/engine/gpu/mod.rs +++ b/src/engine/gpu/mod.rs @@ -1,4 +1,5 @@ //! How the scene is drawn on the GPU: render targets, blend modes, lighting, //! shadows and text rasterization. +pub mod blend; pub mod fbo; diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 00d8aa8..10e6fe3 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -7,7 +7,6 @@ pub mod animated; pub mod assets; pub mod audio; -pub mod blend; pub mod camera3d; pub mod camera_dynamics; pub mod effect; diff --git a/src/engine/render/mod.rs b/src/engine/render/mod.rs index d44efeb..4b396fb 100644 --- a/src/engine/render/mod.rs +++ b/src/engine/render/mod.rs @@ -761,8 +761,12 @@ impl ResolvedScene { 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); + 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; From 3f54cc845cbd9de67650b7b18f71e5e3473e886f Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:08:30 -0700 Subject: [PATCH 014/117] refactor: move engine/lighting.rs to engine/gpu/lighting.rs --- src/engine/{ => gpu}/lighting.rs | 0 src/engine/gpu/mod.rs | 1 + src/engine/gpu_renderer/mod.rs | 26 +++++++++++++------------- src/engine/mod.rs | 1 - src/engine/scene.rs | 32 ++++++++++++++++---------------- 5 files changed, 30 insertions(+), 30 deletions(-) rename src/engine/{ => gpu}/lighting.rs (100%) diff --git a/src/engine/lighting.rs b/src/engine/gpu/lighting.rs similarity index 100% rename from src/engine/lighting.rs rename to src/engine/gpu/lighting.rs diff --git a/src/engine/gpu/mod.rs b/src/engine/gpu/mod.rs index 042499c..ab5e84e 100644 --- a/src/engine/gpu/mod.rs +++ b/src/engine/gpu/mod.rs @@ -3,3 +3,4 @@ pub mod blend; pub mod fbo; +pub mod lighting; diff --git a/src/engine/gpu_renderer/mod.rs b/src/engine/gpu_renderer/mod.rs index a7a3222..fa73273 100644 --- a/src/engine/gpu_renderer/mod.rs +++ b/src/engine/gpu_renderer/mod.rs @@ -2134,10 +2134,10 @@ fn mesh3d_lighting_bytes( 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 { .. } + 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); @@ -2189,7 +2189,7 @@ fn mesh3d_lighting_bytes( .unwrap_or(0.0) }; match light { - crate::engine::lighting::Light::Point { + crate::engine::gpu::lighting::Light::Point { origin, color, intensity, @@ -2201,7 +2201,7 @@ fn mesh3d_lighting_bytes( // spot_slot stays [0,0,0,0] — zero-length direction reads as // "not a spot" in `mesh3d_spot_factor`. } - crate::engine::lighting::Light::Spot { + crate::engine::gpu::lighting::Light::Spot { origin, direction, exponent, @@ -2218,7 +2218,7 @@ fn mesh3d_lighting_bytes( let vd = cam.to_view_direction(*direction); *spot_slot = [vd[0], vd[1], vd[2], *exponent]; } - crate::engine::lighting::Light::Directional { + crate::engine::gpu::lighting::Light::Directional { direction, color, intensity, @@ -2240,7 +2240,7 @@ fn mesh3d_lighting_bytes( // 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 { + crate::engine::gpu::lighting::Light::Tube { origin_a, origin_b, color, @@ -2722,7 +2722,7 @@ impl GpuSceneInstance { // 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; + use crate::engine::gpu::lighting::Light; let (center, radius) = crate::engine::shadow::scene_bounds(&resolved.mesh3d_layers); lights .iter() @@ -2937,7 +2937,7 @@ impl GpuSceneInstance { renderer: &GpuSceneRenderer, cam: &crate::engine::camera3d::PerspectiveCamera, lights: &[( - crate::engine::lighting::Light, + crate::engine::gpu::lighting::Light, bool, Option<(f32, f32)>, Option, @@ -2946,7 +2946,7 @@ impl GpuSceneInstance { shadow_slot_data: &[(crate::engine::camera3d::Mat4, [f32; 4])], shadow_atlas_view: &wgpu::TextureView, ) -> Option { - use crate::engine::lighting::Light; + use crate::engine::gpu::lighting::Light; let (idx, origin, color_intensity, density, exponent) = lights .iter() @@ -3072,7 +3072,7 @@ impl GpuSceneInstance { renderer: &GpuSceneRenderer, mesh3d_layers: &[crate::engine::render::Mesh3dLayer], lights: &[( - crate::engine::lighting::Light, + crate::engine::gpu::lighting::Light, bool, Option<(f32, f32)>, Option, @@ -3082,7 +3082,7 @@ impl GpuSceneInstance { Vec>, Vec<(crate::engine::camera3d::Mat4, [f32; 4])>, ) { - use crate::engine::lighting::Light; + use crate::engine::gpu::lighting::Light; use crate::engine::shadow; let mut shadow_slots: Vec> = vec![None; lights.len()]; diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 10e6fe3..753bda9 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -13,7 +13,6 @@ pub mod effect; pub mod gpu; pub mod gpu_renderer; pub mod graph; -pub mod lighting; pub mod material; #[cfg(target_os = "linux")] pub mod media; diff --git a/src/engine/scene.rs b/src/engine/scene.rs index 387042a..c5063d5 100644 --- a/src/engine/scene.rs +++ b/src/engine/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, @@ -96,7 +96,7 @@ impl Scene { 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 +119,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 +130,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, @@ -309,8 +309,8 @@ impl General { /// Returns `None` when both distance and height fog density are 0 / unset /// (fog off). The caller (render.rs / gpu_renderer.rs) 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() @@ -1211,7 +1211,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 +1241,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 +1257,7 @@ mod tests { .unwrap(); assert!(matches!( scene.lights()[0].0, - crate::engine::lighting::Light::Point { .. } + crate::engine::gpu::lighting::Light::Point { .. } )); } @@ -1277,7 +1277,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 +1300,7 @@ mod tests { .unwrap(); assert!(matches!( scene.lights()[0].0, - crate::engine::lighting::Light::Spot { .. } + crate::engine::gpu::lighting::Light::Spot { .. } )); } @@ -1320,11 +1320,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 +1410,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 +1432,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); From 0e4dba1f115227e7cc3181a85ac17e3a07d66353 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:08:53 -0700 Subject: [PATCH 015/117] refactor: move engine/shadow.rs to engine/gpu/shadow.rs --- src/engine/gpu/mod.rs | 1 + src/engine/{ => gpu}/shadow.rs | 0 src/engine/gpu_renderer/mod.rs | 9 +++++---- src/engine/mod.rs | 1 - 4 files changed, 6 insertions(+), 5 deletions(-) rename src/engine/{ => gpu}/shadow.rs (100%) diff --git a/src/engine/gpu/mod.rs b/src/engine/gpu/mod.rs index ab5e84e..9aee2d8 100644 --- a/src/engine/gpu/mod.rs +++ b/src/engine/gpu/mod.rs @@ -4,3 +4,4 @@ pub mod blend; pub mod fbo; pub mod lighting; +pub mod shadow; diff --git a/src/engine/shadow.rs b/src/engine/gpu/shadow.rs similarity index 100% rename from src/engine/shadow.rs rename to src/engine/gpu/shadow.rs diff --git a/src/engine/gpu_renderer/mod.rs b/src/engine/gpu_renderer/mod.rs index fa73273..89b0211 100644 --- a/src/engine/gpu_renderer/mod.rs +++ b/src/engine/gpu_renderer/mod.rs @@ -2044,7 +2044,7 @@ 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; +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 @@ -2723,7 +2723,8 @@ impl GpuSceneInstance { // this sandbox's cache is zero either way. let cookie_data = { use crate::engine::gpu::lighting::Light; - let (center, radius) = crate::engine::shadow::scene_bounds(&resolved.mesh3d_layers); + let (center, radius) = + crate::engine::gpu::shadow::scene_bounds(&resolved.mesh3d_layers); lights .iter() .enumerate() @@ -2739,7 +2740,7 @@ impl GpuSceneInstance { return None; }; let image = super::render::resolve_static_texture(Some(dir), None, path)?; - let vp = crate::engine::shadow::spot_light_view_proj( + let vp = crate::engine::gpu::shadow::spot_light_view_proj( *origin, *direction, *outer_cone_degrees, @@ -3083,7 +3084,7 @@ impl GpuSceneInstance { Vec<(crate::engine::camera3d::Mat4, [f32; 4])>, ) { use crate::engine::gpu::lighting::Light; - use crate::engine::shadow; + use crate::engine::gpu::shadow; let mut shadow_slots: Vec> = vec![None; lights.len()]; diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 753bda9..023b7cb 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -28,7 +28,6 @@ pub mod render; pub mod scene; pub mod script; pub mod shaders; -pub mod shadow; pub mod tex; pub mod text; pub mod timeline; From 41ad9cc002620e5ad24c5a1928af303939f85768 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:09:45 -0700 Subject: [PATCH 016/117] refactor: move engine/text.rs to engine/gpu/text.rs --- src/engine/gpu/mod.rs | 1 + src/engine/{ => gpu}/text.rs | 2 +- src/engine/gpu_renderer/mod.rs | 4 +++- src/engine/mod.rs | 1 - src/engine/render/mod.rs | 8 ++++---- 5 files changed, 9 insertions(+), 7 deletions(-) rename src/engine/{ => gpu}/text.rs (99%) diff --git a/src/engine/gpu/mod.rs b/src/engine/gpu/mod.rs index 9aee2d8..0b23bfe 100644 --- a/src/engine/gpu/mod.rs +++ b/src/engine/gpu/mod.rs @@ -5,3 +5,4 @@ pub mod blend; pub mod fbo; pub mod lighting; pub mod shadow; +pub mod text; 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..2c81a5a 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::engine::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/mod.rs b/src/engine/gpu_renderer/mod.rs index 89b0211..aeed546 100644 --- a/src/engine/gpu_renderer/mod.rs +++ b/src/engine/gpu_renderer/mod.rs @@ -4080,7 +4080,9 @@ impl GpuSceneInstance { continue; } let started = Instant::now(); - if let Some(img) = crate::engine::text::rasterize(&td.font_data, text, td.point_size) { + 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(); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 023b7cb..b2f18b1 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -29,7 +29,6 @@ pub mod scene; pub mod script; pub mod shaders; pub mod tex; -pub mod text; pub mod timeline; // Convenience re-exports. diff --git a/src/engine/render/mod.rs b/src/engine/render/mod.rs index 4b396fb..c5ab083 100644 --- a/src/engine/render/mod.rs +++ b/src/engine/render/mod.rs @@ -1421,7 +1421,7 @@ fn text_layer_from_object( point_size * compensate } .clamp(1.0, 1024.0); - let font_data = super::text::resolve_font_data(obj.font.as_deref(), dir, pkg)?; + 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| { @@ -1444,7 +1444,7 @@ fn text_layer_from_object( .filter(|_| gate(&obj.limitrows)) .unwrap_or(0.0) .max(0.0) as usize; - super::text::wrap_text( + crate::engine::gpu::text::wrap_text( &font_data, &text_str, raster_px, @@ -1455,7 +1455,7 @@ fn text_layer_from_object( } None => text_str, }; - let image = super::text::rasterize(&font_data, &text_str, raster_px)?; + 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` @@ -1500,7 +1500,7 @@ fn text_layer_from_object( .unwrap_or(0.0) .max(0.0) as u32; ( - super::text::with_background(&image, text_color, bg, pad), + crate::engine::gpu::text::with_background(&image, text_color, bg, pad), true, ) } else { From a485b568127364f6a7e03507f73deb8a3d82fb4e Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:10:30 -0700 Subject: [PATCH 017/117] refactor: move engine/shaders/ to engine/gpu/shaders/ The shader loader and GLSL->WGSL translator, moved as one module without content changes; the renderer's include_str! path follows. --- src/engine/assets.rs | 2 +- src/engine/gpu/mod.rs | 1 + src/engine/{ => gpu}/shaders/compiler.rs | 0 src/engine/{ => gpu}/shaders/effect_def.rs | 0 src/engine/{ => gpu}/shaders/gpu_shaders.wgsl | 0 src/engine/{ => gpu}/shaders/loader.rs | 0 src/engine/{ => gpu}/shaders/mod.rs | 0 src/engine/{ => gpu}/shaders/particle_def.rs | 0 src/engine/{ => gpu}/shaders/resolver.rs | 0 src/engine/{ => gpu}/shaders/transpiler/mod.rs | 2 +- src/engine/{ => gpu}/shaders/transpiler/tests.rs | 0 src/engine/{ => gpu}/shaders/uniform_meta.rs | 0 src/engine/{ => gpu}/shaders/util_textures.rs | 0 src/engine/gpu_renderer/mod.rs | 6 +++--- src/engine/mod.rs | 1 - src/engine/model/shader_model.rs | 2 +- src/engine/render/mod.rs | 2 +- 17 files changed, 8 insertions(+), 8 deletions(-) rename src/engine/{ => gpu}/shaders/compiler.rs (100%) rename src/engine/{ => gpu}/shaders/effect_def.rs (100%) rename src/engine/{ => gpu}/shaders/gpu_shaders.wgsl (100%) rename src/engine/{ => gpu}/shaders/loader.rs (100%) rename src/engine/{ => gpu}/shaders/mod.rs (100%) rename src/engine/{ => gpu}/shaders/particle_def.rs (100%) rename src/engine/{ => gpu}/shaders/resolver.rs (100%) rename src/engine/{ => gpu}/shaders/transpiler/mod.rs (99%) rename src/engine/{ => gpu}/shaders/transpiler/tests.rs (100%) rename src/engine/{ => gpu}/shaders/uniform_meta.rs (100%) rename src/engine/{ => gpu}/shaders/util_textures.rs (100%) diff --git a/src/engine/assets.rs b/src/engine/assets.rs index ed41823..b70ac92 100644 --- a/src/engine/assets.rs +++ b/src/engine/assets.rs @@ -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/engine/gpu/mod.rs b/src/engine/gpu/mod.rs index 0b23bfe..c8cdc5b 100644 --- a/src/engine/gpu/mod.rs +++ b/src/engine/gpu/mod.rs @@ -4,5 +4,6 @@ pub mod blend; pub mod fbo; pub mod lighting; +pub mod shaders; pub mod shadow; pub mod text; 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/gpu_shaders.wgsl b/src/engine/gpu/shaders/gpu_shaders.wgsl similarity index 100% rename from src/engine/shaders/gpu_shaders.wgsl rename to src/engine/gpu/shaders/gpu_shaders.wgsl 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 100% rename from src/engine/shaders/resolver.rs rename to src/engine/gpu/shaders/resolver.rs diff --git a/src/engine/shaders/transpiler/mod.rs b/src/engine/gpu/shaders/transpiler/mod.rs similarity index 99% rename from src/engine/shaders/transpiler/mod.rs rename to src/engine/gpu/shaders/transpiler/mod.rs index 454fa2d..96bb247 100644 --- a/src/engine/shaders/transpiler/mod.rs +++ b/src/engine/gpu/shaders/transpiler/mod.rs @@ -782,7 +782,7 @@ pub fn translate_full(model: &ShaderModel, vert_glsl: Option<&str>) -> Result, [f32; 4])> = std::collections::HashMap::new(); if let Some(v) = vert_glsl { - for meta in crate::engine::shaders::uniform_meta::parse_uniform_metadata(v) { + for meta in crate::engine::gpu::shaders::uniform_meta::parse_uniform_metadata(v) { annotated.insert( meta.uniform_name.clone(), ( diff --git a/src/engine/shaders/transpiler/tests.rs b/src/engine/gpu/shaders/transpiler/tests.rs similarity index 100% rename from src/engine/shaders/transpiler/tests.rs rename to src/engine/gpu/shaders/transpiler/tests.rs 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 100% rename from src/engine/shaders/util_textures.rs rename to src/engine/gpu/shaders/util_textures.rs diff --git a/src/engine/gpu_renderer/mod.rs b/src/engine/gpu_renderer/mod.rs index aeed546..1003535 100644 --- a/src/engine/gpu_renderer/mod.rs +++ b/src/engine/gpu_renderer/mod.rs @@ -22,13 +22,13 @@ use std::time::{Duration, Instant}; use crate::engine::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; use crate::engine::gpu::fbo::RenderTargetPool; +use crate::engine::gpu::shaders::{effect_def, loader, resolver, transpiler}; 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"); +const SHADER_SRC: &str = include_str!("../gpu/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). @@ -5823,7 +5823,7 @@ fn load_effect_instance( // 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) + crate::engine::gpu::shaders::uniform_meta::parse_uniform_metadata(&frag_glsl) .into_iter() .filter(|m| m.uniform_type == "sampler2D") .map(|m| m.combo) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index b2f18b1..25ea61e 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -27,7 +27,6 @@ pub mod puppet; pub mod render; pub mod scene; pub mod script; -pub mod shaders; pub mod tex; pub mod timeline; 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/render/mod.rs b/src/engine/render/mod.rs index c5ab083..ba5222a 100644 --- a/src/engine/render/mod.rs +++ b/src/engine/render/mod.rs @@ -1854,7 +1854,7 @@ pub(crate) fn resolve_static_texture( /// 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()?; + let dir = crate::engine::gpu::shaders::loader::find_we_assets_dir()?; std::fs::read(dir.join(rel_path)).ok() } From 00ba4159fe1744b3b9156450c3f3519039f700ff Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:11:20 -0700 Subject: [PATCH 018/117] refactor: move engine/gpu_renderer/ to engine/gpu/renderer/ The scene renderer (mod.rs + tests.rs) moves as one module, unchanged; gpu_shaders.wgsl, which only it uses, moves in beside it. --- src/engine/gpu/mod.rs | 1 + src/engine/gpu/{shaders => renderer}/gpu_shaders.wgsl | 0 src/engine/{gpu_renderer => gpu/renderer}/mod.rs | 7 ++++--- src/engine/{gpu_renderer => gpu/renderer}/tests.rs | 0 src/engine/mod.rs | 1 - src/main.rs | 4 ++-- src/platform/macos/mod.rs | 2 +- src/render/frame.rs | 2 +- src/render/session.rs | 2 +- 9 files changed, 10 insertions(+), 9 deletions(-) rename src/engine/gpu/{shaders => renderer}/gpu_shaders.wgsl (100%) rename src/engine/{gpu_renderer => gpu/renderer}/mod.rs (99%) rename src/engine/{gpu_renderer => gpu/renderer}/tests.rs (100%) diff --git a/src/engine/gpu/mod.rs b/src/engine/gpu/mod.rs index c8cdc5b..8d1a13e 100644 --- a/src/engine/gpu/mod.rs +++ b/src/engine/gpu/mod.rs @@ -4,6 +4,7 @@ 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/shaders/gpu_shaders.wgsl b/src/engine/gpu/renderer/gpu_shaders.wgsl similarity index 100% rename from src/engine/gpu/shaders/gpu_shaders.wgsl rename to src/engine/gpu/renderer/gpu_shaders.wgsl diff --git a/src/engine/gpu_renderer/mod.rs b/src/engine/gpu/renderer/mod.rs similarity index 99% rename from src/engine/gpu_renderer/mod.rs rename to src/engine/gpu/renderer/mod.rs index 1003535..fc68849 100644 --- a/src/engine/gpu_renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -28,7 +28,7 @@ use crate::engine::particle; use crate::engine::scene::{parse_value_bool, parse_value_f32}; use crate::platform::GpuDevice; -const SHADER_SRC: &str = include_str!("../gpu/shaders/gpu_shaders.wgsl"); +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). @@ -2329,7 +2329,7 @@ impl GpuSceneInstance { } fn build(mut renderer: GpuSceneRenderer, dir: &Path) -> Result { - let resolved = super::render::ResolvedScene::from_directory(dir)?; + let resolved = crate::engine::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) @@ -2739,7 +2739,8 @@ impl GpuSceneInstance { else { return None; }; - let image = super::render::resolve_static_texture(Some(dir), None, path)?; + let image = + crate::engine::render::resolve_static_texture(Some(dir), None, path)?; let vp = crate::engine::gpu::shadow::spot_light_view_proj( *origin, *direction, diff --git a/src/engine/gpu_renderer/tests.rs b/src/engine/gpu/renderer/tests.rs similarity index 100% rename from src/engine/gpu_renderer/tests.rs rename to src/engine/gpu/renderer/tests.rs diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 25ea61e..fb9886e 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -11,7 +11,6 @@ pub mod camera3d; pub mod camera_dynamics; pub mod effect; pub mod gpu; -pub mod gpu_renderer; pub mod graph; pub mod material; #[cfg(target_os = "linux")] diff --git a/src/main.rs b/src/main.rs index 68a114c..1f87e88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1183,7 +1183,7 @@ fn cmd_test_scene(id_or_path: &str, num_frames: usize) -> Result<()> { 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) { + if let Err(e) = engine::gpu::renderer::gpu_scene_render_loop(&render_dir, &tx, 30.0) { tracing::error!(target: "render", "gpu scene error: {e}"); } }); @@ -1286,7 +1286,7 @@ fn cmd_preview_scene(id_or_path: &str, width: u32, height: u32) -> Result<()> { 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) { + if let Err(e) = engine::gpu::renderer::gpu_scene_render_loop(&render_dir, &tx, 30.0) { tracing::error!(target: "render", "gpu scene error: {e}"); } }); diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index e08e9fd..c749e62 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -33,7 +33,7 @@ use winit::window::{Window, WindowId}; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; use super::GpuDevice; -use crate::engine::gpu_renderer::GpuSceneInstance; +use crate::engine::gpu::renderer::GpuSceneInstance; use crate::render::{ScreenContent, ScreenSettings, WallpaperContent}; // ── Platform implementation ─────────────────────────────────────────────────── diff --git a/src/render/frame.rs b/src/render/frame.rs index c933d10..bfc7d7b 100644 --- a/src/render/frame.rs +++ b/src/render/frame.rs @@ -52,7 +52,7 @@ 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(())) => {} diff --git a/src/render/session.rs b/src/render/session.rs index 0958642..8451474 100644 --- a/src/render/session.rs +++ b/src/render/session.rs @@ -14,7 +14,7 @@ use anyhow::Result; use super::web::{WebInputEvent, WebOptions}; use super::{FrameSource, RenderSettings, WallpaperContent}; -use crate::engine::gpu_renderer::GpuSceneInstance; +use crate::engine::gpu::renderer::GpuSceneInstance; use crate::engine::media::{MediaWatcher, PlaybackState as MediaState}; use crate::engine::playback_gate::{self, PlaybackAction, PlaybackPolicy, PlaybackState}; use crate::platform::power::{self, LockWatcher}; From b2e26b432d35204e0815e8d4f68bd0046fd98e79 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:11:47 -0700 Subject: [PATCH 019/117] refactor: move engine/noise.rs to engine/sim/noise.rs --- src/engine/mod.rs | 2 +- src/engine/particle/mod.rs | 6 +++--- src/engine/sim/mod.rs | 4 ++++ src/engine/{ => sim}/noise.rs | 0 4 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 src/engine/sim/mod.rs rename src/engine/{ => sim}/noise.rs (100%) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index fb9886e..3b0e370 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -17,7 +17,6 @@ pub mod material; pub mod media; pub mod mesh3d; pub mod model; -pub mod noise; pub mod particle; pub mod pkg; pub mod playback_gate; @@ -26,6 +25,7 @@ pub mod puppet; pub mod render; pub mod scene; pub mod script; +pub mod sim; pub mod tex; pub mod timeline; diff --git a/src/engine/particle/mod.rs b/src/engine/particle/mod.rs index ce6d9b8..6d80fcf 100644 --- a/src/engine/particle/mod.rs +++ b/src/engine/particle/mod.rs @@ -1224,7 +1224,7 @@ impl RemapParams { max_life: f32, ) -> f32 { if self.input == RemapField::None || self.transform.is_noise() { - use crate::engine::noise::fbm_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 { @@ -2066,7 +2066,7 @@ impl ParticleSystem { p.y * t.noise_scale, 0.0, ]; - let curl = crate::engine::noise::curl_noise(noise_pos); + 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; @@ -2622,7 +2622,7 @@ impl ParticleSystem { spawn_y * 0.1 + time_shift + phase * 0.7, time_shift + phase * 1.3, ]; - let curl = crate::engine::noise::curl_noise(sample); + 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 diff --git a/src/engine/sim/mod.rs b/src/engine/sim/mod.rs new file mode 100644 index 0000000..779bed7 --- /dev/null +++ b/src/engine/sim/mod.rs @@ -0,0 +1,4 @@ +//! What changes over time: particles, keyframed timelines, SceneScript, +//! camera motion and puppet animation. + +pub mod noise; 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 From 3adc40fa70fbd9589100d4c18048f13cb93f58b9 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:12:12 -0700 Subject: [PATCH 020/117] refactor: move engine/timeline.rs to engine/sim/timeline.rs --- src/engine/gpu/renderer/mod.rs | 4 ++-- src/engine/mod.rs | 1 - src/engine/sim/mod.rs | 1 + src/engine/{ => sim}/timeline.rs | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename src/engine/{ => sim}/timeline.rs (100%) diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index fc68849..1bca2b3 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -6654,7 +6654,7 @@ struct TimelineEntry { key: String, /// Base value, which transform keyframes are offsets from. base: Vec, - anim: crate::engine::timeline::PropertyAnimation, + anim: crate::engine::sim::timeline::PropertyAnimation, /// Last value applied, to skip re-applying an idle animation. last: Option>, } @@ -6664,7 +6664,7 @@ fn build_timelines( raw_scene: &serde_json::Value, graph: &crate::engine::render::TransformGraph, ) -> Vec { - use crate::engine::timeline::PropertyAnimation; + 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() { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 3b0e370..b8ee115 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -27,7 +27,6 @@ pub mod scene; pub mod script; pub mod sim; pub mod tex; -pub mod timeline; // Convenience re-exports. pub use crate::engine::gpu::fbo::{RenderTarget, RenderTargetPool}; diff --git a/src/engine/sim/mod.rs b/src/engine/sim/mod.rs index 779bed7..a4002af 100644 --- a/src/engine/sim/mod.rs +++ b/src/engine/sim/mod.rs @@ -2,3 +2,4 @@ //! camera motion and puppet animation. pub mod noise; +pub mod timeline; 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 From 6b9c827b32a445364d41125f6ccc1d52ad06795d Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:12:36 -0700 Subject: [PATCH 021/117] refactor: move engine/camera_dynamics.rs to engine/sim/camera_dynamics.rs --- src/engine/gpu/renderer/mod.rs | 2 +- src/engine/mod.rs | 3 +-- src/engine/{ => sim}/camera_dynamics.rs | 0 src/engine/sim/mod.rs | 1 + tests/properties.rs | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename src/engine/{ => sim}/camera_dynamics.rs (100%) diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index 1bca2b3..a7f2b15 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -20,12 +20,12 @@ use std::sync::mpsc::SyncSender; use std::sync::Arc; use std::time::{Duration, Instant}; -use crate::engine::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; use crate::engine::gpu::fbo::RenderTargetPool; use crate::engine::gpu::shaders::{effect_def, loader, resolver, transpiler}; use crate::engine::model::{ShaderModel, WEBlending}; use crate::engine::particle; use crate::engine::scene::{parse_value_bool, parse_value_f32}; +use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; use crate::platform::GpuDevice; const SHADER_SRC: &str = include_str!("gpu_shaders.wgsl"); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index b8ee115..3b9c610 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -8,7 +8,6 @@ pub mod animated; pub mod assets; pub mod audio; pub mod camera3d; -pub mod camera_dynamics; pub mod effect; pub mod gpu; pub mod graph; @@ -30,7 +29,7 @@ pub mod tex; // Convenience re-exports. pub use crate::engine::gpu::fbo::{RenderTarget, RenderTargetPool}; -pub use camera_dynamics::{CameraDynamics, CameraFrameDynamics}; +pub use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; pub use graph::SceneGraph; pub use pkg::Package; pub use properties::SceneProperties; diff --git a/src/engine/camera_dynamics.rs b/src/engine/sim/camera_dynamics.rs similarity index 100% rename from src/engine/camera_dynamics.rs rename to src/engine/sim/camera_dynamics.rs diff --git a/src/engine/sim/mod.rs b/src/engine/sim/mod.rs index a4002af..e83d006 100644 --- a/src/engine/sim/mod.rs +++ b/src/engine/sim/mod.rs @@ -1,5 +1,6 @@ //! What changes over time: particles, keyframed timelines, SceneScript, //! camera motion and puppet animation. +pub mod camera_dynamics; pub mod noise; pub mod timeline; diff --git a/tests/properties.rs b/tests/properties.rs index 44a71ae..d065c90 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::sim::camera_dynamics::CameraDynamics; fn props_from_project(project: serde_json::Value, overrides: &[(&str, &str)]) -> SceneProperties { // Build via a temp dir so the public loader path is exercised. From 75e4622775e2f2e2aadcaebe3de0c9b1c8dbf2f3 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:12:57 -0700 Subject: [PATCH 022/117] refactor: move engine/camera3d.rs to engine/sim/camera3d.rs --- src/engine/gpu/renderer/mod.rs | 51 +++++++++++++++++--------------- src/engine/gpu/renderer/tests.rs | 21 +++++++------ src/engine/gpu/shadow.rs | 2 +- src/engine/mod.rs | 1 - src/engine/scene.rs | 3 +- src/engine/{ => sim}/camera3d.rs | 0 src/engine/sim/mod.rs | 1 + 7 files changed, 43 insertions(+), 36 deletions(-) rename src/engine/{ => sim}/camera3d.rs (100%) diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index a7f2b15..c956866 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -1920,7 +1920,7 @@ pub struct GpuSceneInstance { 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, + 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 @@ -1944,7 +1944,7 @@ fn quad_world_corners(center: [f32; 3], angles: [f32; 3], size_px: [f64; 2]) -> 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); + 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]] }) } @@ -1960,7 +1960,7 @@ fn quad_world_corners(center: [f32; 3], angles: [f32; 3], size_px: [f64; 2]) -> /// 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, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, center: [f32; 3], angles: [f32; 3], size_px: [f64; 2], @@ -2004,7 +2004,7 @@ fn project_quad_ndc( /// full perspective-correct texture mapping is a further, separate /// improvement. fn project_quad_corners( - cam: &crate::engine::camera3d::PerspectiveCamera, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, center: [f32; 3], angles: [f32; 3], size_px: [f64; 2], @@ -2062,7 +2062,7 @@ const MESH3D_LIGHTING_BYTES_LEN: usize = /// 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, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, origin: [f32; 3], angles: [f32; 3], scale: [f32; 3], @@ -2072,7 +2072,7 @@ fn mesh3d_transform_bytes( cam.mvp(origin, angles, scale), cam.model_view(origin, angles, scale), cam.normal_view(angles, scale), - crate::engine::camera3d::model_matrix(origin, angles, scale), + crate::engine::sim::camera3d::model_matrix(origin, angles, scale), ] { for col in mat.iter() { for f in col { @@ -2107,10 +2107,10 @@ fn mesh3d_transform_bytes( /// `cookie_view_proj` field. fn mesh3d_lighting_bytes( scene: &crate::engine::scene::Scene, - cam: &crate::engine::camera3d::PerspectiveCamera, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, shadow_slots: &[Option], - shadow_slot_data: &[(crate::engine::camera3d::Mat4, [f32; 4])], - cookie: Option<(usize, crate::engine::camera3d::Mat4)>, + 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(); @@ -2286,7 +2286,7 @@ fn mesh3d_lighting_bytes( let vp = shadow_slot_data .get(slot) .map(|(vp, _)| *vp) - .unwrap_or_else(crate::engine::camera3d::identity); + .unwrap_or_else(crate::engine::sim::camera3d::identity); for col in vp.iter() { push4(*col); } @@ -2300,7 +2300,7 @@ fn mesh3d_lighting_bytes( } let cookie_view_proj = cookie .map(|(_, vp)| vp) - .unwrap_or_else(crate::engine::camera3d::identity); + .unwrap_or_else(crate::engine::sim::camera3d::identity); for col in cookie_view_proj.iter() { push4(*col); } @@ -2428,7 +2428,7 @@ impl GpuSceneInstance { // 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( + let camera3d = crate::engine::sim::camera3d::PerspectiveCamera::from_scene( &resolved.scene, w as f32 / h as f32, ); @@ -2937,7 +2937,7 @@ impl GpuSceneInstance { /// procedural per-light cone/box mesh — see the report's scoping note). fn build_volumetrics( renderer: &GpuSceneRenderer, - cam: &crate::engine::camera3d::PerspectiveCamera, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, lights: &[( crate::engine::gpu::lighting::Light, bool, @@ -2945,7 +2945,7 @@ impl GpuSceneInstance { Option, )], shadow_slots: &[Option], - shadow_slot_data: &[(crate::engine::camera3d::Mat4, [f32; 4])], + shadow_slot_data: &[(crate::engine::sim::camera3d::Mat4, [f32; 4])], shadow_atlas_view: &wgpu::TextureView, ) -> Option { use crate::engine::gpu::lighting::Light; @@ -2994,9 +2994,9 @@ impl GpuSceneInstance { .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)); + .unwrap_or((crate::engine::sim::camera3d::identity(), [0.0; 4], 0.0)); - let inv_view_proj = crate::engine::camera3d::mat4_inverse(&cam.view_proj_raw()); + 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). @@ -3004,7 +3004,7 @@ impl GpuSceneInstance { 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| { + 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()); @@ -3082,7 +3082,7 @@ impl GpuSceneInstance { ) -> ( wgpu::Texture, Vec>, - Vec<(crate::engine::camera3d::Mat4, [f32; 4])>, + Vec<(crate::engine::sim::camera3d::Mat4, [f32; 4])>, ) { use crate::engine::gpu::lighting::Light; use crate::engine::gpu::shadow; @@ -3095,7 +3095,7 @@ impl GpuSceneInstance { // 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 + let shadow_casters: Vec<(usize, crate::engine::sim::camera3d::Mat4)> = lights .iter() .enumerate() .filter_map(|(i, (light, casts_shadow, _volumetrics, _))| { @@ -3230,9 +3230,12 @@ impl GpuSceneInstance { 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 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 { @@ -3301,7 +3304,7 @@ impl GpuSceneInstance { /// both static in real content, so this runs once at load. fn build_mesh3d( renderer: &GpuSceneRenderer, - cam: &crate::engine::camera3d::PerspectiveCamera, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, layer: &crate::engine::render::Mesh3dLayer, lighting_ubo: &wgpu::Buffer, shadow_atlas_view: &wgpu::TextureView, @@ -6522,7 +6525,7 @@ fn gpu_layer_from( w: u32, h: u32, zoom: f32, - camera3d: Option<&crate::engine::camera3d::PerspectiveCamera>, + camera3d: Option<&crate::engine::sim::camera3d::PerspectiveCamera>, authored_origin: [f64; 3], ) -> SceneLayerGpu { let all_frames: Vec<&RgbaImage> = std::iter::once(&l.image) diff --git a/src/engine/gpu/renderer/tests.rs b/src/engine/gpu/renderer/tests.rs index 0b30de8..91679e3 100644 --- a/src/engine/gpu/renderer/tests.rs +++ b/src/engine/gpu/renderer/tests.rs @@ -17,13 +17,13 @@ fn gpu_shaders_wgsl_parses_and_validates() { .expect("gpu_shaders.wgsl failed validation"); } -fn quad3d_test_camera() -> crate::engine::camera3d::PerspectiveCamera { +fn quad3d_test_camera() -> crate::engine::sim::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() + crate::engine::sim::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).unwrap() } /// A camera-facing quad (no rotation, centered on the view axis) must @@ -94,7 +94,7 @@ fn mesh3d_lighting_bytes_are_uniform_sized_and_match_const() { 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 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); @@ -104,7 +104,10 @@ fn mesh3d_lighting_bytes_are_uniform_sized_and_match_const() { // 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 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); } @@ -120,9 +123,9 @@ fn mesh3d_lighting_bytes_encodes_cookie_flag_and_matrix() { 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 cam = crate::engine::sim::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).unwrap(); - let mut vp = crate::engine::camera3d::identity(); + 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))); @@ -156,7 +159,7 @@ fn mesh3d_lighting_bytes_encodes_spot_direction() { 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 cam = crate::engine::sim::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"}, @@ -205,7 +208,7 @@ fn mesh3d_lighting_bytes_encodes_directional_sentinel_and_direction() { 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 cam = crate::engine::sim::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"}, @@ -253,7 +256,7 @@ fn mesh3d_lighting_bytes_encodes_tube_sentinel_and_endpoints() { 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 cam = crate::engine::sim::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"}, diff --git a/src/engine/gpu/shadow.rs b/src/engine/gpu/shadow.rs index 3b1e15c..4bab412 100644 --- a/src/engine/gpu/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::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/mod.rs b/src/engine/mod.rs index 3b9c610..ca8142a 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -7,7 +7,6 @@ pub mod animated; pub mod assets; pub mod audio; -pub mod camera3d; pub mod effect; pub mod gpu; pub mod graph; diff --git a/src/engine/scene.rs b/src/engine/scene.rs index c5063d5..436a276 100644 --- a/src/engine/scene.rs +++ b/src/engine/scene.rs @@ -91,7 +91,8 @@ 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(); diff --git a/src/engine/camera3d.rs b/src/engine/sim/camera3d.rs similarity index 100% rename from src/engine/camera3d.rs rename to src/engine/sim/camera3d.rs diff --git a/src/engine/sim/mod.rs b/src/engine/sim/mod.rs index e83d006..8ea9c98 100644 --- a/src/engine/sim/mod.rs +++ b/src/engine/sim/mod.rs @@ -1,6 +1,7 @@ //! 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 timeline; From 118d783abab6cd20873cf82aa6718d228057f9f2 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:13:22 -0700 Subject: [PATCH 023/117] refactor: move engine/puppet.rs to engine/sim/puppet.rs --- src/engine/gpu/renderer/mod.rs | 2 +- src/engine/mod.rs | 1 - src/engine/render/mod.rs | 14 +++++++------- src/engine/sim/mod.rs | 1 + src/engine/{ => sim}/puppet.rs | 0 5 files changed, 9 insertions(+), 9 deletions(-) rename src/engine/{ => sim}/puppet.rs (100%) diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index c956866..adc9180 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -1641,7 +1641,7 @@ struct SceneLayerGpu { /// 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>, + 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 diff --git a/src/engine/mod.rs b/src/engine/mod.rs index ca8142a..26ded7d 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -19,7 +19,6 @@ pub mod particle; pub mod pkg; pub mod playback_gate; pub mod properties; -pub mod puppet; pub mod render; pub mod scene; pub mod script; diff --git a/src/engine/render/mod.rs b/src/engine/render/mod.rs index ba5222a..354793b 100644 --- a/src/engine/render/mod.rs +++ b/src/engine/render/mod.rs @@ -179,7 +179,7 @@ pub struct Layer { 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>, + 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>>, @@ -212,7 +212,7 @@ struct LoadedImage { /// 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>, + 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>>, @@ -965,7 +965,7 @@ fn layer_from_object( if let Some(rt) = std::sync::Arc::get_mut(rt) { let n = rt.model.animations.len(); if n > 0 { - let layers: Vec = entries + let layers: Vec = entries .iter() .enumerate() // `visible` is often a user-property binding @@ -996,7 +996,7 @@ fn layer_from_object( i.min(n - 1) } }); - crate::engine::puppet::AnimLayer { + 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, @@ -2111,7 +2111,7 @@ fn apply_puppet_mesh( 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 { + 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); }; @@ -2126,7 +2126,7 @@ fn apply_puppet_mesh( // 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 { + let runtime = crate::engine::sim::puppet::PuppetRuntime { model, atlas, layers: Vec::new(), @@ -2134,7 +2134,7 @@ fn apply_puppet_mesh( let assembled = if runtime.model.has_animation() { runtime.render_at(0.0, runtime.atlas.width(), runtime.atlas.height()) } else { - crate::engine::puppet::rasterize( + crate::engine::sim::puppet::rasterize( &runtime.model.mesh, &runtime.atlas, runtime.atlas.width(), diff --git a/src/engine/sim/mod.rs b/src/engine/sim/mod.rs index 8ea9c98..866a3e1 100644 --- a/src/engine/sim/mod.rs +++ b/src/engine/sim/mod.rs @@ -4,4 +4,5 @@ pub mod camera3d; pub mod camera_dynamics; pub mod noise; +pub mod puppet; pub mod timeline; 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 From 2fad43faff79cef0b4faf1a84d24b7cb4d8f4400 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:13:44 -0700 Subject: [PATCH 024/117] refactor: move engine/script.rs to engine/sim/script.rs --- src/engine/gpu/renderer/mod.rs | 30 ++++++++++----------- src/engine/mod.rs | 1 - src/engine/render/mod.rs | 19 ++++++++----- src/engine/sim/mod.rs | 1 + src/engine/{ => sim}/scenescript_runtime.js | 0 src/engine/{ => sim}/script.rs | 0 6 files changed, 28 insertions(+), 23 deletions(-) rename src/engine/{ => sim}/scenescript_runtime.js (100%) rename src/engine/{ => sim}/script.rs (100%) diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index adc9180..780f1bb 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -1837,7 +1837,7 @@ pub struct GpuSceneInstance { 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, + 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` @@ -2914,7 +2914,7 @@ impl GpuSceneInstance { instance.effect_enabled = vec![true; instance.scene_effects.len()]; instance.cursor_targets = instance .scripts - .objects_with_handlers(crate::engine::script::CURSOR_EVENTS); + .objects_with_handlers(crate::engine::sim::script::CURSOR_EVENTS); Ok(instance) } @@ -3431,7 +3431,7 @@ impl GpuSceneInstance { /// Advance keyframe animations and apply the properties they move. fn tick_timelines(&mut self, delta: f32) { - use crate::engine::script::{ObjectChange, ScriptChanges}; + use crate::engine::sim::script::{ObjectChange, ScriptChanges}; if self.timelines.is_empty() { return; } @@ -3566,7 +3566,7 @@ impl GpuSceneInstance { ); self.dispatch_cursor_events(); self.scripts - .frame(time, crate::engine::script::local_time_of_day(), delta); + .frame(time, crate::engine::sim::script::local_time_of_day(), delta); let changes = self.scripts.collect(); self.apply_script_changes(changes); self.flush_text(); @@ -3663,8 +3663,8 @@ impl GpuSceneInstance { /// 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; + 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; @@ -3831,7 +3831,7 @@ impl GpuSceneInstance { if diffs.is_empty() { continue; } - let mut change = crate::engine::script::ObjectChange::default(); + 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(".")); @@ -3840,7 +3840,7 @@ impl GpuSceneInstance { } objects.push((i, change)); } - self.apply_script_changes(crate::engine::script::ScriptChanges { + self.apply_script_changes(crate::engine::sim::script::ScriptChanges { objects, commands: Vec::new(), }); @@ -3856,7 +3856,7 @@ impl GpuSceneInstance { object: usize, path: &[String], v: &serde_json::Value, - c: &mut crate::engine::script::ObjectChange, + c: &mut crate::engine::sim::script::ObjectChange, ) -> Option<()> { use crate::engine::scene::{parse_value_bool, parse_value_f32, parse_value_vec3}; // `{"user": …, "value": X}` bindings resolve to their `value`. @@ -5492,7 +5492,7 @@ fn named_fbo_key(instance_idx: usize, fbo_name: &str) -> String { /// 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() + crate::engine::sim::script::local_time_of_day() } #[allow(clippy::too_many_arguments)] @@ -6737,9 +6737,9 @@ fn build_script_host( graph: &crate::engine::render::TransformGraph, canvas: [u32; 2], raw_scene: &serde_json::Value, -) -> crate::engine::script::ScriptHost { +) -> crate::engine::sim::script::ScriptHost { use crate::engine::scene::{parse_value_f32, parse_value_vec3}; - use crate::engine::script::{ObjectInit, ScriptHost}; + use crate::engine::sim::script::{ObjectInit, ScriptHost}; let mut host = ScriptHost::new(); if scene.objects.iter().all(|o| o.scripts().is_empty()) { @@ -6837,7 +6837,7 @@ fn build_script_host( ("volume", obj.volume.as_ref()), ]; for (prop, value) in props { - if let Some((script, authored)) = crate::engine::script::scripted(value) { + if let Some((script, authored)) = crate::engine::sim::script::scripted(value) { host.attach(i, Some(prop), script, authored); } } @@ -6873,7 +6873,7 @@ fn build_script_host( } } for (key, value) in extras { - if let Some((script, authored)) = crate::engine::script::scripted(Some(value)) { + if let Some((script, authored)) = crate::engine::sim::script::scripted(Some(value)) { let initial = value .get("value") .cloned() @@ -6881,7 +6881,7 @@ fn build_script_host( host.declare_property( i, &key, - crate::engine::script::value_kind(&initial), + crate::engine::sim::script::value_kind(&initial), &initial, ); host.attach(i, Some(&key), script, authored); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 26ded7d..fdf83d5 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -21,7 +21,6 @@ pub mod playback_gate; pub mod properties; pub mod render; pub mod scene; -pub mod script; pub mod sim; pub mod tex; diff --git a/src/engine/render/mod.rs b/src/engine/render/mod.rs index 354793b..6754386 100644 --- a/src/engine/render/mod.rs +++ b/src/engine/render/mod.rs @@ -871,13 +871,13 @@ fn select_spritesheet_frame(obj: &SceneObject, loaded: &mut LoadedImage) { .and_then(|v| v.get("script")) .and_then(|s| s.as_str()) .and_then(|script| { - let init = crate::engine::script::ObjectInit { + let init = crate::engine::sim::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 + crate::engine::sim::script::evaluate_once("visible", script, &init, None).frame }) .unwrap_or(0) .min(loaded.sprite_frames.len() as u32 - 1); @@ -1368,7 +1368,7 @@ fn text_layer_from_object( (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 { + let init = crate::engine::sim::script::ObjectInit { name: obj.name.clone(), id: obj.id, visible: true, @@ -1376,10 +1376,15 @@ fn text_layer_from_object( 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) + 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, diff --git a/src/engine/sim/mod.rs b/src/engine/sim/mod.rs index 866a3e1..1832641 100644 --- a/src/engine/sim/mod.rs +++ b/src/engine/sim/mod.rs @@ -5,4 +5,5 @@ pub mod camera3d; pub mod camera_dynamics; pub mod noise; pub mod puppet; +pub mod script; pub mod timeline; 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 From fb1875d8035dad2431acb915801f55b9697b5c3e Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:14:09 -0700 Subject: [PATCH 025/117] refactor: move engine/particle/ to engine/sim/particle/ --- src/engine/animated.rs | 4 +++- src/engine/gpu/renderer/mod.rs | 2 +- src/engine/mod.rs | 1 - src/engine/render/mod.rs | 2 +- src/engine/sim/mod.rs | 1 + src/engine/{ => sim}/particle/mod.rs | 0 src/engine/{ => sim}/particle/tests.rs | 0 7 files changed, 6 insertions(+), 4 deletions(-) rename src/engine/{ => sim}/particle/mod.rs (100%) rename src/engine/{ => sim}/particle/tests.rs (100%) diff --git a/src/engine/animated.rs b/src/engine/animated.rs index 45f7e3d..31b1e6e 100644 --- a/src/engine/animated.rs +++ b/src/engine/animated.rs @@ -7,10 +7,12 @@ 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::sim::particle::{ + InstanceOverride, ParticleConfig, ParticleSprite, ParticleSystem, +}; struct SceneAnimState { /// Paired with each layer's `order_index` (its position in diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index 780f1bb..c9e4f64 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -23,9 +23,9 @@ 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::{ShaderModel, WEBlending}; -use crate::engine::particle; use crate::engine::scene::{parse_value_bool, parse_value_f32}; use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; +use crate::engine::sim::particle; use crate::platform::GpuDevice; const SHADER_SRC: &str = include_str!("gpu_shaders.wgsl"); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index fdf83d5..0e36f21 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -15,7 +15,6 @@ pub mod material; pub mod media; pub mod mesh3d; pub mod model; -pub mod particle; pub mod pkg; pub mod playback_gate; pub mod properties; diff --git a/src/engine/render/mod.rs b/src/engine/render/mod.rs index 6754386..f554a11 100644 --- a/src/engine/render/mod.rs +++ b/src/engine/render/mod.rs @@ -3,11 +3,11 @@ 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; +use crate::engine::sim::particle; /// Parse scene.json, first resolving `{"user": ...}` references against the /// wallpaper's project.json properties (plus any `--set-property` overrides). diff --git a/src/engine/sim/mod.rs b/src/engine/sim/mod.rs index 1832641..2e3847b 100644 --- a/src/engine/sim/mod.rs +++ b/src/engine/sim/mod.rs @@ -4,6 +4,7 @@ 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/particle/mod.rs b/src/engine/sim/particle/mod.rs similarity index 100% rename from src/engine/particle/mod.rs rename to src/engine/sim/particle/mod.rs diff --git a/src/engine/particle/tests.rs b/src/engine/sim/particle/tests.rs similarity index 100% rename from src/engine/particle/tests.rs rename to src/engine/sim/particle/tests.rs From a0fb422e4b2272165bbe36d41e65f555607505b6 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:14:44 -0700 Subject: [PATCH 026/117] refactor: move engine/scene.rs to engine/model/scene.rs --- src/engine/animated.rs | 4 ++-- src/engine/gpu/renderer/mod.rs | 29 +++++++++++++++-------------- src/engine/gpu/renderer/tests.rs | 18 +++++++++--------- src/engine/graph.rs | 2 +- src/engine/mod.rs | 3 +-- src/engine/model/mod.rs | 1 + src/engine/model/parser.rs | 2 +- src/engine/{ => model}/scene.rs | 0 src/engine/render/mod.rs | 22 +++++++++++----------- src/engine/render/tests.rs | 4 ++-- src/engine/sim/camera3d.rs | 8 ++++---- src/engine/sim/camera_dynamics.rs | 2 +- tests/properties.rs | 2 +- 13 files changed, 49 insertions(+), 48 deletions(-) rename src/engine/{ => model}/scene.rs (100%) diff --git a/src/engine/animated.rs b/src/engine/animated.rs index 31b1e6e..59fb96e 100644 --- a/src/engine/animated.rs +++ b/src/engine/animated.rs @@ -8,8 +8,8 @@ use std::time::{Duration, Instant}; use super::assets::AssetStore; use super::effect::SceneEffect; use super::render::ResolvedScene; -use super::scene::SceneObject; use super::tex::TexFile; +use crate::engine::model::scene::SceneObject; use crate::engine::sim::particle::{ InstanceOverride, ParticleConfig, ParticleSprite, ParticleSystem, }; @@ -74,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. diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index c9e4f64..aa0a6ec 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -22,8 +22,8 @@ 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::scene::{parse_value_bool, parse_value_f32}; use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; use crate::engine::sim::particle; use crate::platform::GpuDevice; @@ -1752,7 +1752,7 @@ pub(crate) struct SceneSound { /// `startsilent` is set. fn start_scene_sound( dir: &Path, - obj: &crate::engine::scene::SceneObject, + obj: &crate::engine::model::scene::SceneObject, ) -> Option { let sound = obj.sound.as_ref()?; let start_silent = obj @@ -1766,7 +1766,7 @@ fn start_scene_sound( let volume = obj .volume .as_ref() - .and_then(crate::engine::scene::parse_value_f32) + .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"); @@ -2106,7 +2106,7 @@ fn mesh3d_transform_bytes( /// `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, + 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])], @@ -3858,7 +3858,7 @@ impl GpuSceneInstance { v: &serde_json::Value, c: &mut crate::engine::sim::script::ObjectChange, ) -> Option<()> { - use crate::engine::scene::{parse_value_bool, parse_value_f32, parse_value_vec3}; + 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)); @@ -3987,13 +3987,14 @@ impl GpuSceneInstance { 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; - } - }; + 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 { @@ -6733,12 +6734,12 @@ fn build_timelines( /// transform graph use), then one instance per scripted property. fn build_script_host( dir: &Path, - scene: &crate::engine::scene::Scene, + scene: &crate::engine::model::scene::Scene, graph: &crate::engine::render::TransformGraph, canvas: [u32; 2], raw_scene: &serde_json::Value, ) -> crate::engine::sim::script::ScriptHost { - use crate::engine::scene::{parse_value_f32, parse_value_vec3}; + use crate::engine::model::scene::{parse_value_f32, parse_value_vec3}; use crate::engine::sim::script::{ObjectInit, ScriptHost}; let mut host = ScriptHost::new(); diff --git a/src/engine/gpu/renderer/tests.rs b/src/engine/gpu/renderer/tests.rs index 91679e3..e83f74d 100644 --- a/src/engine/gpu/renderer/tests.rs +++ b/src/engine/gpu/renderer/tests.rs @@ -18,7 +18,7 @@ fn gpu_shaders_wgsl_parses_and_validates() { } fn quad3d_test_camera() -> crate::engine::sim::camera3d::PerspectiveCamera { - let scene: crate::engine::scene::Scene = serde_json::from_str( + 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}}"#, ) @@ -90,7 +90,7 @@ fn project_quad_corners_none_when_behind_camera() { #[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( + 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(); @@ -119,7 +119,7 @@ fn mesh3d_lighting_bytes_are_uniform_sized_and_match_const() { /// 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( + 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(); @@ -155,13 +155,13 @@ fn mesh3d_lighting_bytes_encodes_cookie_flag_and_matrix() { /// `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( + 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::scene::Scene = serde_json::from_str( + 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": [ @@ -204,13 +204,13 @@ fn mesh3d_lighting_bytes_encodes_spot_direction() { /// 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( + 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::scene::Scene = serde_json::from_str( + 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": [ @@ -252,13 +252,13 @@ fn mesh3d_lighting_bytes_encodes_directional_sentinel_and_direction() { /// 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( + 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::scene::Scene = serde_json::from_str( + 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": [ diff --git a/src/engine/graph.rs b/src/engine/graph.rs index faa8363..de0c147 100644 --- a/src/engine/graph.rs +++ b/src/engine/graph.rs @@ -3,7 +3,7 @@ use std::path::Path; use super::assets::AssetStore; use super::material::{EffectDefinition, Model}; -use super::scene::{Scene, SceneObject}; +use crate::engine::model::scene::{Scene, SceneObject}; pub struct SceneGraph { pub assets: AssetStore, diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 0e36f21..413ed9d 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -19,16 +19,15 @@ pub mod pkg; pub mod playback_gate; pub mod properties; pub mod render; -pub mod scene; pub mod sim; pub mod tex; // Convenience re-exports. pub use crate::engine::gpu::fbo::{RenderTarget, RenderTargetPool}; +pub use crate::engine::model::scene::Scene; pub use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; pub use graph::SceneGraph; pub use pkg::Package; pub use properties::SceneProperties; pub use render::ResolvedScene; -pub use scene::Scene; pub use tex::TexFile; diff --git a/src/engine/model/mod.rs b/src/engine/model/mod.rs index e64bce4..c6a53e4 100644 --- a/src/engine/model/mod.rs +++ b/src/engine/model/mod.rs @@ -2,6 +2,7 @@ pub mod dynamic_value; pub mod objects; pub mod parser; pub mod property; +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/scene.rs b/src/engine/model/scene.rs similarity index 100% rename from src/engine/scene.rs rename to src/engine/model/scene.rs diff --git a/src/engine/render/mod.rs b/src/engine/render/mod.rs index f554a11..b20dcc2 100644 --- a/src/engine/render/mod.rs +++ b/src/engine/render/mod.rs @@ -5,8 +5,8 @@ use std::path::Path; use super::pkg::Package; use super::properties::SceneProperties; -use super::scene::{Scene, SceneObject}; use super::tex::TexFile; +use crate::engine::model::scene::{Scene, SceneObject}; use crate::engine::sim::particle; /// Parse scene.json, first resolving `{"user": ...}` references against the @@ -972,7 +972,7 @@ fn layer_from_object( // (`{"user": "head", "value": false}`), not a bare bool. .filter(|(_, e)| { e.get("visible") - .and_then(crate::engine::scene::parse_value_bool) + .and_then(crate::engine::model::scene::parse_value_bool) .unwrap_or(true) }) .map(|(i, e)| { @@ -1431,13 +1431,13 @@ fn text_layer_from_object( // and cap at `maxrows`, if authored. let gate = |v: &Option| { v.as_ref() - .and_then(crate::engine::scene::parse_value_bool) + .and_then(crate::engine::model::scene::parse_value_bool) .unwrap_or(false) }; let text_str = match obj .maxwidth .as_ref() - .and_then(crate::engine::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .filter(|w| *w > 0.0) .filter(|_| gate(&obj.limitwidth)) { @@ -1445,7 +1445,7 @@ fn text_layer_from_object( let rows = obj .maxrows .as_ref() - .and_then(crate::engine::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .filter(|_| gate(&obj.limitrows)) .unwrap_or(0.0) .max(0.0) as usize; @@ -1478,18 +1478,18 @@ fn text_layer_from_object( let text_color = obj .color .as_ref() - .and_then(|v| crate::engine::scene::parse_value_vec3(v)) + .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::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .unwrap_or(1.0); let bg = obj .backgroundcolor .as_ref() - .and_then(|v| crate::engine::scene::parse_value_vec3(v)) + .and_then(|v| crate::engine::model::scene::parse_value_vec3(v)) .map(|c| { [ c[0] as f32 * bright, @@ -1501,7 +1501,7 @@ fn text_layer_from_object( let pad = obj .padding .as_ref() - .and_then(crate::engine::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .unwrap_or(0.0) .max(0.0) as u32; ( @@ -2286,7 +2286,7 @@ pub fn obj_local_xform(obj: &SceneObject) -> Xform { let s = obj .scale .as_ref() - .and_then(crate::engine::scene::parse_value_vec3) + .and_then(crate::engine::model::scene::parse_value_vec3) .unwrap_or([1.0, 1.0, 1.0]); Xform { t, r, s } } @@ -2305,7 +2305,7 @@ fn parent_index( if obj .disablepropagation .as_ref() - .and_then(crate::engine::scene::parse_value_bool) + .and_then(crate::engine::model::scene::parse_value_bool) .unwrap_or(false) { return None; diff --git a/src/engine/render/tests.rs b/src/engine/render/tests.rs index 72e5eff..758ad42 100644 --- a/src/engine/render/tests.rs +++ b/src/engine/render/tests.rs @@ -140,12 +140,12 @@ fn wrap_limits_respect_their_gates() { let active = |o: &SceneObject| { o.maxwidth .as_ref() - .and_then(crate::engine::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .filter(|w| *w > 0.0) .filter(|_| { o.limitwidth .as_ref() - .and_then(crate::engine::scene::parse_value_bool) + .and_then(crate::engine::model::scene::parse_value_bool) .unwrap_or(false) }) .is_some() diff --git a/src/engine/sim/camera3d.rs b/src/engine/sim/camera3d.rs index 341e883..c6716fc 100644 --- a/src/engine/sim/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. @@ -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); diff --git a/src/engine/sim/camera_dynamics.rs b/src/engine/sim/camera_dynamics.rs index 8de1f36..57a6ebd 100644 --- a/src/engine/sim/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/tests/properties.rs b/tests/properties.rs index d065c90..c69e663 100644 --- a/tests/properties.rs +++ b/tests/properties.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; +use wp_engine::engine::model::scene::Scene; use wp_engine::engine::properties::{parse_property_arg, SceneProperties}; -use wp_engine::engine::scene::Scene; use wp_engine::engine::sim::camera_dynamics::CameraDynamics; fn props_from_project(project: serde_json::Value, overrides: &[(&str, &str)]) -> SceneProperties { From 2c4525293b55285aa478e464072e2bb08b351207 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:15:02 -0700 Subject: [PATCH 027/117] refactor: move engine/mesh3d.rs to engine/model/mesh3d.rs --- src/engine/gpu/renderer/mod.rs | 2 +- src/engine/mod.rs | 1 - src/engine/{ => model}/mesh3d.rs | 0 src/engine/model/mod.rs | 1 + src/engine/render/mod.rs | 4 ++-- 5 files changed, 4 insertions(+), 4 deletions(-) rename src/engine/{ => model}/mesh3d.rs (100%) diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index aa0a6ec..9b8cb1a 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -3273,7 +3273,7 @@ impl GpuSceneInstance { /// upload can't simply be reused from a `Mesh3dGpu` built later. fn upload_mesh3d_geometry( renderer: &GpuSceneRenderer, - mesh: &crate::engine::mesh3d::Mesh3d, + 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) { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 413ed9d..d7e63eb 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -13,7 +13,6 @@ pub mod graph; pub mod material; #[cfg(target_os = "linux")] pub mod media; -pub mod mesh3d; pub mod model; pub mod pkg; pub mod playback_gate; 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 c6a53e4..13e8eda 100644 --- a/src/engine/model/mod.rs +++ b/src/engine/model/mod.rs @@ -1,4 +1,5 @@ pub mod dynamic_value; +pub mod mesh3d; pub mod objects; pub mod parser; pub mod property; diff --git a/src/engine/render/mod.rs b/src/engine/render/mod.rs index b20dcc2..53c16d7 100644 --- a/src/engine/render/mod.rs +++ b/src/engine/render/mod.rs @@ -47,7 +47,7 @@ pub struct ResolvedScene { /// a flat quad — spheres, skyboxes and cylinders can't be billboarded. pub struct Mesh3dLayer { pub name: String, - pub mesh: crate::engine::mesh3d::Mesh3d, + 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). @@ -1570,7 +1570,7 @@ fn mesh3d_layer_from_object( // 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 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())) From 5004a9e6e753621739be9fdba37ecb9bf263b748 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:15:27 -0700 Subject: [PATCH 028/117] refactor: move engine/render/ to engine/model/resolve/ engine::render resolved scene.json into layers; as engine::model::resolve it no longer shares a name with the top-level render module. --- src/engine/animated.rs | 2 +- src/engine/gpu/renderer/mod.rs | 48 +++++++++++-------- src/engine/gpu/renderer/tests.rs | 2 +- src/engine/gpu/shadow.rs | 2 +- src/engine/mod.rs | 3 +- src/engine/model/mod.rs | 1 + src/engine/{render => model/resolve}/mod.rs | 6 +-- src/engine/{render => model/resolve}/tests.rs | 0 8 files changed, 35 insertions(+), 29 deletions(-) rename src/engine/{render => model/resolve}/mod.rs (99%) rename src/engine/{render => model/resolve}/tests.rs (100%) diff --git a/src/engine/animated.rs b/src/engine/animated.rs index 59fb96e..88c1509 100644 --- a/src/engine/animated.rs +++ b/src/engine/animated.rs @@ -7,8 +7,8 @@ use std::time::{Duration, Instant}; use super::assets::AssetStore; use super::effect::SceneEffect; -use super::render::ResolvedScene; use super::tex::TexFile; +use crate::engine::model::resolve::ResolvedScene; use crate::engine::model::scene::SceneObject; use crate::engine::sim::particle::{ InstanceOverride, ParticleConfig, ParticleSprite, ParticleSystem, diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index 9b8cb1a..45be013 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -1647,14 +1647,15 @@ struct SceneLayerGpu { /// 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>>, + 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, + text_dynamic: Option, color: [f32; 3], brightness: f32, frame_duration_ms: u32, @@ -1690,7 +1691,7 @@ struct SceneLayerGpu { text_raster_cost: Duration, /// Composition/fullscreen layer: `frames[0]` is refilled each frame /// with the scene drawn beneath it (see `capture_backdrop`). - backdrop: Option, + backdrop: Option, /// Spritesheet frame a script picked (`getTextureAnimation().setFrame`), /// overriding the timed animation. script_frame: Option, @@ -1922,7 +1923,7 @@ pub struct GpuSceneInstance { /// 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, + 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. @@ -1931,7 +1932,7 @@ pub struct GpuSceneInstance { /// `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, + current_locals: Vec, } /// One static 3D mesh, uploaded once: geometry never changes, and its MVP is @@ -2329,7 +2330,7 @@ impl GpuSceneInstance { } fn build(mut renderer: GpuSceneRenderer, dir: &Path) -> Result { - let resolved = crate::engine::render::ResolvedScene::from_directory(dir)?; + 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::engine::assets::AssetStore::from_directory(dir) @@ -2739,8 +2740,11 @@ impl GpuSceneInstance { else { return None; }; - let image = - crate::engine::render::resolve_static_texture(Some(dir), None, path)?; + 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, @@ -3072,7 +3076,7 @@ impl GpuSceneInstance { /// 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], + mesh3d_layers: &[crate::engine::model::resolve::Mesh3dLayer], lights: &[( crate::engine::gpu::lighting::Light, bool, @@ -3305,7 +3309,7 @@ impl GpuSceneInstance { fn build_mesh3d( renderer: &GpuSceneRenderer, cam: &crate::engine::sim::camera3d::PerspectiveCamera, - layer: &crate::engine::render::Mesh3dLayer, + layer: &crate::engine::model::resolve::Mesh3dLayer, lighting_ubo: &wgpu::Buffer, shadow_atlas_view: &wgpu::TextureView, cookie_view: &wgpu::TextureView, @@ -3972,7 +3976,7 @@ impl GpuSceneInstance { /// 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; + 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; @@ -4008,14 +4012,16 @@ impl GpuSceneInstance { 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 { + 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::render::obj_local_xform(&obj); + 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(); @@ -4123,7 +4129,7 @@ impl GpuSceneInstance { /// 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; + 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. @@ -6522,7 +6528,7 @@ fn make_effect_params(name: &str, time: f32, vals: &ShaderVals) -> Vec { /// the object's scene.json origin, before alignment. fn gpu_layer_from( renderer: &GpuSceneRenderer, - l: &crate::engine::render::Layer, + l: &crate::engine::model::resolve::Layer, w: u32, h: u32, zoom: f32, @@ -6666,7 +6672,7 @@ struct TimelineEntry { /// Collect every animated property from the raw scene.json objects. fn build_timelines( raw_scene: &serde_json::Value, - graph: &crate::engine::render::TransformGraph, + graph: &crate::engine::model::resolve::TransformGraph, ) -> Vec { use crate::engine::sim::timeline::PropertyAnimation; let mut out = Vec::new(); @@ -6710,7 +6716,7 @@ fn build_timelines( .local .get(i) .copied() - .unwrap_or(crate::engine::render::Xform::IDENTITY); + .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(), @@ -6735,7 +6741,7 @@ fn build_timelines( fn build_script_host( dir: &Path, scene: &crate::engine::model::scene::Scene, - graph: &crate::engine::render::TransformGraph, + graph: &crate::engine::model::resolve::TransformGraph, canvas: [u32; 2], raw_scene: &serde_json::Value, ) -> crate::engine::sim::script::ScriptHost { @@ -6776,7 +6782,7 @@ fn build_script_host( .local .get(i) .copied() - .unwrap_or(crate::engine::render::Xform::IDENTITY); + .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) => { diff --git a/src/engine/gpu/renderer/tests.rs b/src/engine/gpu/renderer/tests.rs index e83f74d..f108656 100644 --- a/src/engine/gpu/renderer/tests.rs +++ b/src/engine/gpu/renderer/tests.rs @@ -381,7 +381,7 @@ mod hit_tests { }; 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 resolved = crate::engine::model::resolve::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()) diff --git a/src/engine/gpu/shadow.rs b/src/engine/gpu/shadow.rs index 4bab412..89ffdaa 100644 --- a/src/engine/gpu/shadow.rs +++ b/src/engine/gpu/shadow.rs @@ -15,7 +15,7 @@ //! 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::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 diff --git a/src/engine/mod.rs b/src/engine/mod.rs index d7e63eb..268cc2f 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -17,16 +17,15 @@ pub mod model; pub mod pkg; pub mod playback_gate; pub mod properties; -pub mod render; pub mod sim; pub mod tex; // Convenience re-exports. pub use crate::engine::gpu::fbo::{RenderTarget, RenderTargetPool}; +pub use crate::engine::model::resolve::ResolvedScene; pub use crate::engine::model::scene::Scene; pub use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; pub use graph::SceneGraph; pub use pkg::Package; pub use properties::SceneProperties; -pub use render::ResolvedScene; pub use tex::TexFile; diff --git a/src/engine/model/mod.rs b/src/engine/model/mod.rs index 13e8eda..0324bd2 100644 --- a/src/engine/model/mod.rs +++ b/src/engine/model/mod.rs @@ -3,6 +3,7 @@ pub mod mesh3d; pub mod objects; pub mod parser; pub mod property; +pub mod resolve; pub mod scene; pub mod shader_model; diff --git a/src/engine/render/mod.rs b/src/engine/model/resolve/mod.rs similarity index 99% rename from src/engine/render/mod.rs rename to src/engine/model/resolve/mod.rs index 53c16d7..51bd3df 100644 --- a/src/engine/render/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -3,11 +3,11 @@ use image::RgbaImage; use std::collections::HashMap; use std::path::Path; -use super::pkg::Package; -use super::properties::SceneProperties; -use super::tex::TexFile; use crate::engine::model::scene::{Scene, SceneObject}; +use crate::engine::pkg::Package; +use crate::engine::properties::SceneProperties; use crate::engine::sim::particle; +use crate::engine::tex::TexFile; /// Parse scene.json, first resolving `{"user": ...}` references against the /// wallpaper's project.json properties (plus any `--set-property` overrides). diff --git a/src/engine/render/tests.rs b/src/engine/model/resolve/tests.rs similarity index 100% rename from src/engine/render/tests.rs rename to src/engine/model/resolve/tests.rs From 2d14066ea5e9fc0a96b39ab47490da4b6455b1c1 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:15:53 -0700 Subject: [PATCH 029/117] refactor: move engine/animated.rs to engine/cpu/animated.rs --- src/engine/{ => cpu}/animated.rs | 6 +++--- src/engine/cpu/mod.rs | 4 ++++ src/engine/mod.rs | 2 +- src/render/frame.rs | 12 ++++++------ 4 files changed, 14 insertions(+), 10 deletions(-) rename src/engine/{ => cpu}/animated.rs (98%) create mode 100644 src/engine/cpu/mod.rs diff --git a/src/engine/animated.rs b/src/engine/cpu/animated.rs similarity index 98% rename from src/engine/animated.rs rename to src/engine/cpu/animated.rs index 88c1509..3524761 100644 --- a/src/engine/animated.rs +++ b/src/engine/cpu/animated.rs @@ -5,14 +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::tex::TexFile; +use crate::engine::assets::AssetStore; +use crate::engine::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::engine::tex::TexFile; struct SceneAnimState { /// Paired with each layer's `order_index` (its position in diff --git a/src/engine/cpu/mod.rs b/src/engine/cpu/mod.rs new file mode 100644 index 0000000..237a179 --- /dev/null +++ b/src/engine/cpu/mod.rs @@ -0,0 +1,4 @@ +//! The CPU scene renderer: the fallback when the GPU path fails to start. +//! Production rendering is `engine::gpu::renderer`. + +pub mod animated; diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 268cc2f..0f70c3e 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -4,9 +4,9 @@ //! backend-neutral [`graph::SceneGraph`] feeds the CPU compositor and //! diagnostics. -pub mod animated; pub mod assets; pub mod audio; +pub mod cpu; pub mod effect; pub mod gpu; pub mod graph; diff --git a/src/render/frame.rs b/src/render/frame.rs index bfc7d7b..3520c0a 100644 --- a/src/render/frame.rs +++ b/src/render/frame.rs @@ -58,9 +58,9 @@ impl FrameSource { 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}"); } } From a9290b799e1a749f705097e5444feca1dce32413 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:16:18 -0700 Subject: [PATCH 030/117] refactor: move engine/effect.rs to engine/cpu/effect.rs --- src/engine/cpu/animated.rs | 2 +- src/engine/{ => cpu}/effect.rs | 0 src/engine/cpu/mod.rs | 1 + src/engine/mod.rs | 1 - src/engine/model/resolve/mod.rs | 2 +- src/logging.rs | 2 +- tests/effect.rs | 2 +- 7 files changed, 5 insertions(+), 5 deletions(-) rename src/engine/{ => cpu}/effect.rs (100%) diff --git a/src/engine/cpu/animated.rs b/src/engine/cpu/animated.rs index 3524761..9aae07a 100644 --- a/src/engine/cpu/animated.rs +++ b/src/engine/cpu/animated.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use crate::engine::assets::AssetStore; -use crate::engine::effect::SceneEffect; +use crate::engine::cpu::effect::SceneEffect; use crate::engine::model::resolve::ResolvedScene; use crate::engine::model::scene::SceneObject; use crate::engine::sim::particle::{ 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/cpu/mod.rs b/src/engine/cpu/mod.rs index 237a179..de3e4a3 100644 --- a/src/engine/cpu/mod.rs +++ b/src/engine/cpu/mod.rs @@ -2,3 +2,4 @@ //! Production rendering is `engine::gpu::renderer`. pub mod animated; +pub mod effect; diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 0f70c3e..79f0386 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -7,7 +7,6 @@ pub mod assets; pub mod audio; pub mod cpu; -pub mod effect; pub mod gpu; pub mod graph; pub mod material; diff --git a/src/engine/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs index 51bd3df..33ce7fb 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -1957,7 +1957,7 @@ fn pass_constant_color(pass: &serde_json::Value) -> Option<[u8; 3]> { .get("color") .or_else(|| consts.get("Color"))? .as_str()?; - let c = crate::engine::effect::parse_color(s); + 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, 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/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() { From 1ac8af5b80a022f2622daf564dfdd0053b1ec046 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:16:36 -0700 Subject: [PATCH 031/117] refactor: move engine/graph.rs to engine/cpu/graph.rs --- src/engine/{ => cpu}/graph.rs | 9 ++++++--- src/engine/cpu/mod.rs | 1 + src/engine/mod.rs | 3 +-- 3 files changed, 8 insertions(+), 5 deletions(-) rename src/engine/{ => cpu}/graph.rs (98%) diff --git a/src/engine/graph.rs b/src/engine/cpu/graph.rs similarity index 98% rename from src/engine/graph.rs rename to src/engine/cpu/graph.rs index de0c147..2b1221a 100644 --- a/src/engine/graph.rs +++ b/src/engine/cpu/graph.rs @@ -1,8 +1,8 @@ use anyhow::Result; use std::path::Path; -use super::assets::AssetStore; -use super::material::{EffectDefinition, Model}; +use crate::engine::assets::AssetStore; +use crate::engine::material::{EffectDefinition, Model}; use crate::engine::model::scene::{Scene, SceneObject}; pub struct SceneGraph { @@ -305,7 +305,10 @@ impl SceneGraphStats { } } -fn collect_material_stats(material: &super::material::Material, stats: &mut SceneGraphStats) { +fn collect_material_stats( + material: &crate::engine::material::Material, + stats: &mut SceneGraphStats, +) { stats.material_passes += material.passes.len(); for pass in &material.passes { diff --git a/src/engine/cpu/mod.rs b/src/engine/cpu/mod.rs index de3e4a3..15114e8 100644 --- a/src/engine/cpu/mod.rs +++ b/src/engine/cpu/mod.rs @@ -3,3 +3,4 @@ pub mod animated; pub mod effect; +pub mod graph; diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 79f0386..76103b2 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -8,7 +8,6 @@ pub mod assets; pub mod audio; pub mod cpu; pub mod gpu; -pub mod graph; pub mod material; #[cfg(target_os = "linux")] pub mod media; @@ -20,11 +19,11 @@ pub mod sim; pub mod tex; // Convenience re-exports. +pub use crate::engine::cpu::graph::SceneGraph; pub use crate::engine::gpu::fbo::{RenderTarget, RenderTargetPool}; pub use crate::engine::model::resolve::ResolvedScene; pub use crate::engine::model::scene::Scene; pub use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; -pub use graph::SceneGraph; pub use pkg::Package; pub use properties::SceneProperties; pub use tex::TexFile; From 6758f50914e3f74b2c3e6f0de3dbf50c5c4d8eb9 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:17:00 -0700 Subject: [PATCH 032/117] refactor: move engine/material.rs to engine/cpu/material.rs --- src/engine/cpu/graph.rs | 4 ++-- src/engine/{ => cpu}/material.rs | 2 +- src/engine/cpu/mod.rs | 1 + src/engine/mod.rs | 1 - 4 files changed, 4 insertions(+), 4 deletions(-) rename src/engine/{ => cpu}/material.rs (99%) diff --git a/src/engine/cpu/graph.rs b/src/engine/cpu/graph.rs index 2b1221a..482399e 100644 --- a/src/engine/cpu/graph.rs +++ b/src/engine/cpu/graph.rs @@ -2,7 +2,7 @@ use anyhow::Result; use std::path::Path; use crate::engine::assets::AssetStore; -use crate::engine::material::{EffectDefinition, Model}; +use crate::engine::cpu::material::{EffectDefinition, Model}; use crate::engine::model::scene::{Scene, SceneObject}; pub struct SceneGraph { @@ -306,7 +306,7 @@ impl SceneGraphStats { } fn collect_material_stats( - material: &crate::engine::material::Material, + material: &crate::engine::cpu::material::Material, stats: &mut SceneGraphStats, ) { stats.material_passes += material.passes.len(); 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..111bd2f 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::engine::assets::AssetStore; pub type TextureMap = BTreeMap; pub type ComboMap = BTreeMap; diff --git a/src/engine/cpu/mod.rs b/src/engine/cpu/mod.rs index 15114e8..25b7f77 100644 --- a/src/engine/cpu/mod.rs +++ b/src/engine/cpu/mod.rs @@ -4,3 +4,4 @@ pub mod animated; pub mod effect; pub mod graph; +pub mod material; diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 76103b2..9cfd2a4 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -8,7 +8,6 @@ pub mod assets; pub mod audio; pub mod cpu; pub mod gpu; -pub mod material; #[cfg(target_os = "linux")] pub mod media; pub mod model; From 9bd17e5b5858e691d9c6c8ac9084a0a03798ee37 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:17:38 -0700 Subject: [PATCH 033/117] refactor: move engine/pkg.rs to formats/pkg.rs --- src/engine/assets.rs | 2 +- src/engine/gpu/renderer/mod.rs | 4 ++-- src/engine/gpu/shaders/resolver.rs | 2 +- src/engine/gpu/text.rs | 2 +- src/engine/mod.rs | 3 +-- src/engine/model/resolve/mod.rs | 2 +- src/formats/mod.rs | 5 +++++ src/{engine => formats}/pkg.rs | 0 src/lib.rs | 1 + src/platform/audio.rs | 2 +- 10 files changed, 14 insertions(+), 9 deletions(-) create mode 100644 src/formats/mod.rs rename src/{engine => formats}/pkg.rs (100%) diff --git a/src/engine/assets.rs b/src/engine/assets.rs index b70ac92..beab42c 100644 --- a/src/engine/assets.rs +++ b/src/engine/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; /// Wallpaper asset resolver. /// diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index 45be013..edbf687 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -1874,7 +1874,7 @@ pub struct GpuSceneInstance { sort_count: u32, /// For `thisScene.createLayer`: the scene directory and its package. scene_dir: std::path::PathBuf, - scene_pkg: Option, + 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`. @@ -4010,7 +4010,7 @@ impl GpuSceneInstance { } if self.scene_pkg.is_none() { self.scene_pkg = - crate::engine::pkg::Package::from_file(&self.scene_dir.join("scene.pkg")).ok(); + 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, diff --git a/src/engine/gpu/shaders/resolver.rs b/src/engine/gpu/shaders/resolver.rs index 1e89104..b2fd241 100644 --- a/src/engine/gpu/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/text.rs b/src/engine/gpu/text.rs index 2c81a5a..8787bf9 100644 --- a/src/engine/gpu/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<&crate::engine::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/mod.rs b/src/engine/mod.rs index 9cfd2a4..e9c9731 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -11,7 +11,6 @@ pub mod gpu; #[cfg(target_os = "linux")] pub mod media; pub mod model; -pub mod pkg; pub mod playback_gate; pub mod properties; pub mod sim; @@ -23,6 +22,6 @@ pub use crate::engine::gpu::fbo::{RenderTarget, RenderTargetPool}; pub use crate::engine::model::resolve::ResolvedScene; pub use crate::engine::model::scene::Scene; pub use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; -pub use pkg::Package; +pub use crate::formats::pkg::Package; pub use properties::SceneProperties; pub use tex::TexFile; diff --git a/src/engine/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs index 33ce7fb..bb0eedf 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -4,10 +4,10 @@ use std::collections::HashMap; use std::path::Path; use crate::engine::model::scene::{Scene, SceneObject}; -use crate::engine::pkg::Package; use crate::engine::properties::SceneProperties; use crate::engine::sim::particle; use crate::engine::tex::TexFile; +use crate::formats::pkg::Package; /// Parse scene.json, first resolving `{"user": ...}` references against the /// wallpaper's project.json properties (plus any `--set-property` overrides). diff --git a/src/formats/mod.rs b/src/formats/mod.rs new file mode 100644 index 0000000..332b146 --- /dev/null +++ b/src/formats/mod.rs @@ -0,0 +1,5 @@ +//! 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 pkg; 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/lib.rs b/src/lib.rs index 7fac5f7..b8262ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod application; #[cfg(target_os = "linux")] pub mod daemon; pub mod engine; +pub mod formats; pub mod logging; pub mod platform; pub mod render; diff --git a/src/platform/audio.rs b/src/platform/audio.rs index ef61f65..c6644f9 100644 --- a/src/platform/audio.rs +++ b/src/platform/audio.rs @@ -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()) }); From a14d9f23e843eb4509ae18a60e727e62d1ca73df Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:18:03 -0700 Subject: [PATCH 034/117] refactor: move engine/tex.rs to formats/tex.rs --- src/engine/assets.rs | 2 +- src/engine/cpu/animated.rs | 2 +- src/engine/gpu/renderer/mod.rs | 6 +++--- src/engine/gpu/shaders/util_textures.rs | 2 +- src/engine/mod.rs | 3 +-- src/engine/model/resolve/mod.rs | 2 +- src/formats/mod.rs | 1 + src/{engine => formats}/tex.rs | 0 8 files changed, 9 insertions(+), 9 deletions(-) rename src/{engine => formats}/tex.rs (100%) diff --git a/src/engine/assets.rs b/src/engine/assets.rs index beab42c..3b1018b 100644 --- a/src/engine/assets.rs +++ b/src/engine/assets.rs @@ -3,8 +3,8 @@ use image::RgbaImage; use serde::de::DeserializeOwned; use std::path::{Path, PathBuf}; -use super::tex::TexFile; use crate::formats::pkg::Package; +use crate::formats::tex::TexFile; /// Wallpaper asset resolver. /// diff --git a/src/engine/cpu/animated.rs b/src/engine/cpu/animated.rs index 9aae07a..1adf4c5 100644 --- a/src/engine/cpu/animated.rs +++ b/src/engine/cpu/animated.rs @@ -12,7 +12,7 @@ use crate::engine::model::scene::SceneObject; use crate::engine::sim::particle::{ InstanceOverride, ParticleConfig, ParticleSprite, ParticleSystem, }; -use crate::engine::tex::TexFile; +use crate::formats::tex::TexFile; struct SceneAnimState { /// Paired with each layer's `order_index` (its position in diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index edbf687..d432420 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -5614,7 +5614,7 @@ fn load_effect_instance( 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) + crate::formats::tex::TexFile::parse(&bytes) .ok()? .to_rgba() .ok() @@ -5628,7 +5628,7 @@ fn load_effect_instance( 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) + crate::formats::tex::TexFile::parse(&bytes) .ok()? .to_rgba() .ok() @@ -5981,7 +5981,7 @@ fn load_effect_instance( 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) + crate::formats::tex::TexFile::parse(&bytes) .ok()? .to_rgba() .ok() diff --git a/src/engine/gpu/shaders/util_textures.rs b/src/engine/gpu/shaders/util_textures.rs index ae01466..cf09726 100644 --- a/src/engine/gpu/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/mod.rs b/src/engine/mod.rs index e9c9731..f17ddda 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -14,7 +14,6 @@ pub mod model; pub mod playback_gate; pub mod properties; pub mod sim; -pub mod tex; // Convenience re-exports. pub use crate::engine::cpu::graph::SceneGraph; @@ -23,5 +22,5 @@ pub use crate::engine::model::resolve::ResolvedScene; pub use crate::engine::model::scene::Scene; pub use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; pub use crate::formats::pkg::Package; +pub use crate::formats::tex::TexFile; pub use properties::SceneProperties; -pub use tex::TexFile; diff --git a/src/engine/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs index bb0eedf..98e81e0 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -6,8 +6,8 @@ use std::path::Path; use crate::engine::model::scene::{Scene, SceneObject}; use crate::engine::properties::SceneProperties; use crate::engine::sim::particle; -use crate::engine::tex::TexFile; use crate::formats::pkg::Package; +use crate::formats::tex::TexFile; /// Parse scene.json, first resolving `{"user": ...}` references against the /// wallpaper's project.json properties (plus any `--set-property` overrides). diff --git a/src/formats/mod.rs b/src/formats/mod.rs index 332b146..f4ec5af 100644 --- a/src/formats/mod.rs +++ b/src/formats/mod.rs @@ -3,3 +3,4 @@ //! `scene.pkg`, and the WE install's shared assets. pub mod pkg; +pub mod tex; 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 From 651ae777320dadbffaa4a5def96af59e0ee3fa56 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:18:29 -0700 Subject: [PATCH 035/117] refactor: move engine/assets.rs to formats/assets.rs --- src/engine/cpu/animated.rs | 2 +- src/engine/cpu/graph.rs | 2 +- src/engine/cpu/material.rs | 2 +- src/engine/gpu/renderer/mod.rs | 2 +- src/engine/gpu/renderer/tests.rs | 2 +- src/engine/mod.rs | 1 - src/{engine => formats}/assets.rs | 0 src/formats/mod.rs | 1 + 8 files changed, 6 insertions(+), 6 deletions(-) rename src/{engine => formats}/assets.rs (100%) diff --git a/src/engine/cpu/animated.rs b/src/engine/cpu/animated.rs index 1adf4c5..1754ffa 100644 --- a/src/engine/cpu/animated.rs +++ b/src/engine/cpu/animated.rs @@ -5,13 +5,13 @@ use std::sync::mpsc::SyncSender; use std::sync::Arc; use std::time::{Duration, Instant}; -use crate::engine::assets::AssetStore; 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 { diff --git a/src/engine/cpu/graph.rs b/src/engine/cpu/graph.rs index 482399e..f050410 100644 --- a/src/engine/cpu/graph.rs +++ b/src/engine/cpu/graph.rs @@ -1,9 +1,9 @@ use anyhow::Result; use std::path::Path; -use crate::engine::assets::AssetStore; 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, diff --git a/src/engine/cpu/material.rs b/src/engine/cpu/material.rs index 111bd2f..7518af7 100644 --- a/src/engine/cpu/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 crate::engine::assets::AssetStore; +use crate::formats::assets::AssetStore; pub type TextureMap = BTreeMap; pub type ComboMap = BTreeMap; diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index d432420..c0fdf76 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -2333,7 +2333,7 @@ impl GpuSceneInstance { 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::engine::assets::AssetStore::from_directory(dir) + 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()) diff --git a/src/engine/gpu/renderer/tests.rs b/src/engine/gpu/renderer/tests.rs index f108656..decd304 100644 --- a/src/engine/gpu/renderer/tests.rs +++ b/src/engine/gpu/renderer/tests.rs @@ -382,7 +382,7 @@ mod hit_tests { 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::engine::assets::AssetStore::from_directory(&dir) + 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()) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index f17ddda..3924553 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -4,7 +4,6 @@ //! backend-neutral [`graph::SceneGraph`] feeds the CPU compositor and //! diagnostics. -pub mod assets; pub mod audio; pub mod cpu; pub mod gpu; diff --git a/src/engine/assets.rs b/src/formats/assets.rs similarity index 100% rename from src/engine/assets.rs rename to src/formats/assets.rs diff --git a/src/formats/mod.rs b/src/formats/mod.rs index f4ec5af..d1f9d8d 100644 --- a/src/formats/mod.rs +++ b/src/formats/mod.rs @@ -2,5 +2,6 @@ //! 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; From 851c102faebff675cf00025a71e8f389e268da2b Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:19:09 -0700 Subject: [PATCH 036/117] refactor: move engine.rs to audio.rs --- src/application/application_context.rs | 2 +- src/application/mod.rs | 2 +- src/{engine => audio}/audio.rs | 0 src/{engine => audio}/cpu/animated.rs | 10 +- src/{engine => audio}/cpu/effect.rs | 0 src/{engine => audio}/cpu/graph.rs | 6 +- src/{engine => audio}/cpu/material.rs | 0 src/{engine => audio}/cpu/mod.rs | 0 src/{engine => audio}/gpu/blend.rs | 0 src/{engine => audio}/gpu/fbo.rs | 0 src/{engine => audio}/gpu/lighting.rs | 0 src/{engine => audio}/gpu/mod.rs | 0 .../gpu/renderer/gpu_shaders.wgsl | 0 src/{engine => audio}/gpu/renderer/mod.rs | 228 +++++++++--------- src/{engine => audio}/gpu/renderer/tests.rs | 38 +-- src/{engine => audio}/gpu/shaders/compiler.rs | 0 .../gpu/shaders/effect_def.rs | 0 src/{engine => audio}/gpu/shaders/loader.rs | 0 src/{engine => audio}/gpu/shaders/mod.rs | 0 .../gpu/shaders/particle_def.rs | 0 src/{engine => audio}/gpu/shaders/resolver.rs | 0 .../gpu/shaders/transpiler/mod.rs | 4 +- .../gpu/shaders/transpiler/tests.rs | 8 +- .../gpu/shaders/uniform_meta.rs | 0 .../gpu/shaders/util_textures.rs | 0 src/{engine => audio}/gpu/shadow.rs | 4 +- src/{engine => audio}/gpu/text.rs | 0 src/{engine => audio}/media.rs | 0 src/{engine => audio}/mod.rs | 10 +- src/{engine => audio}/model/dynamic_value.rs | 0 src/{engine => audio}/model/mesh3d.rs | 0 src/{engine => audio}/model/mod.rs | 0 src/{engine => audio}/model/objects.rs | 0 src/{engine => audio}/model/parser.rs | 2 +- src/{engine => audio}/model/property.rs | 0 src/{engine => audio}/model/resolve/mod.rs | 88 +++---- src/{engine => audio}/model/resolve/tests.rs | 4 +- src/{engine => audio}/model/scene.rs | 34 +-- src/{engine => audio}/model/shader_model.rs | 2 +- src/{engine => audio}/playback_gate.rs | 0 src/{engine => audio}/properties.rs | 0 src/{engine => audio}/sim/camera3d.rs | 8 +- src/{engine => audio}/sim/camera_dynamics.rs | 2 +- src/{engine => audio}/sim/mod.rs | 0 src/{engine => audio}/sim/noise.rs | 0 src/{engine => audio}/sim/particle/mod.rs | 6 +- src/{engine => audio}/sim/particle/tests.rs | 0 src/{engine => audio}/sim/puppet.rs | 0 .../sim/scenescript_runtime.js | 0 src/{engine => audio}/sim/script.rs | 0 src/{engine => audio}/sim/timeline.rs | 0 src/bin/scan_wallpapers.rs | 9 +- src/formats/assets.rs | 2 +- src/lib.rs | 2 +- src/logging.rs | 2 +- src/main.rs | 66 ++--- src/platform/audio.rs | 2 +- src/platform/macos/mod.rs | 2 +- src/platform/wayland/mod.rs | 8 +- src/platform/x11/mod.rs | 6 +- src/render/content.rs | 2 +- src/render/frame.rs | 10 +- src/render/session.rs | 6 +- src/render/web.rs | 14 +- src/settings.rs | 2 +- src/ui/config.rs | 10 +- src/ui/mod.rs | 2 +- tests/effect.rs | 2 +- tests/properties.rs | 10 +- 69 files changed, 308 insertions(+), 307 deletions(-) rename src/{engine => audio}/audio.rs (100%) rename src/{engine => audio}/cpu/animated.rs (97%) rename src/{engine => audio}/cpu/effect.rs (100%) rename src/{engine => audio}/cpu/graph.rs (98%) rename src/{engine => audio}/cpu/material.rs (100%) rename src/{engine => audio}/cpu/mod.rs (100%) rename src/{engine => audio}/gpu/blend.rs (100%) rename src/{engine => audio}/gpu/fbo.rs (100%) rename src/{engine => audio}/gpu/lighting.rs (100%) rename src/{engine => audio}/gpu/mod.rs (100%) rename src/{engine => audio}/gpu/renderer/gpu_shaders.wgsl (100%) rename src/{engine => audio}/gpu/renderer/mod.rs (97%) rename src/{engine => audio}/gpu/renderer/tests.rs (92%) rename src/{engine => audio}/gpu/shaders/compiler.rs (100%) rename src/{engine => audio}/gpu/shaders/effect_def.rs (100%) rename src/{engine => audio}/gpu/shaders/loader.rs (100%) rename src/{engine => audio}/gpu/shaders/mod.rs (100%) rename src/{engine => audio}/gpu/shaders/particle_def.rs (100%) rename src/{engine => audio}/gpu/shaders/resolver.rs (100%) rename src/{engine => audio}/gpu/shaders/transpiler/mod.rs (99%) rename src/{engine => audio}/gpu/shaders/transpiler/tests.rs (98%) rename src/{engine => audio}/gpu/shaders/uniform_meta.rs (100%) rename src/{engine => audio}/gpu/shaders/util_textures.rs (100%) rename src/{engine => audio}/gpu/shadow.rs (99%) rename src/{engine => audio}/gpu/text.rs (100%) rename src/{engine => audio}/media.rs (100%) rename src/{engine => audio}/mod.rs (63%) rename src/{engine => audio}/model/dynamic_value.rs (100%) rename src/{engine => audio}/model/mesh3d.rs (100%) rename src/{engine => audio}/model/mod.rs (100%) rename src/{engine => audio}/model/objects.rs (100%) rename src/{engine => audio}/model/parser.rs (99%) rename src/{engine => audio}/model/property.rs (100%) rename src/{engine => audio}/model/resolve/mod.rs (97%) rename src/{engine => audio}/model/resolve/tests.rs (98%) rename src/{engine => audio}/model/scene.rs (98%) rename src/{engine => audio}/model/shader_model.rs (99%) rename src/{engine => audio}/playback_gate.rs (100%) rename src/{engine => audio}/properties.rs (100%) rename src/{engine => audio}/sim/camera3d.rs (98%) rename src/{engine => audio}/sim/camera_dynamics.rs (98%) rename src/{engine => audio}/sim/mod.rs (100%) rename src/{engine => audio}/sim/noise.rs (100%) rename src/{engine => audio}/sim/particle/mod.rs (99%) rename src/{engine => audio}/sim/particle/tests.rs (100%) rename src/{engine => audio}/sim/puppet.rs (100%) rename src/{engine => audio}/sim/scenescript_runtime.js (100%) rename src/{engine => audio}/sim/script.rs (100%) rename src/{engine => audio}/sim/timeline.rs (100%) diff --git a/src/application/application_context.rs b/src/application/application_context.rs index 226ce24..a700654 100644 --- a/src/application/application_context.rs +++ b/src/application/application_context.rs @@ -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::audio::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..d122cbd 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -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::audio::properties::set_overrides_for( &context.background, context.properties.clone(), ); diff --git a/src/engine/audio.rs b/src/audio/audio.rs similarity index 100% rename from src/engine/audio.rs rename to src/audio/audio.rs diff --git a/src/engine/cpu/animated.rs b/src/audio/cpu/animated.rs similarity index 97% rename from src/engine/cpu/animated.rs rename to src/audio/cpu/animated.rs index 1754ffa..a30a1c1 100644 --- a/src/engine/cpu/animated.rs +++ b/src/audio/cpu/animated.rs @@ -5,10 +5,10 @@ use std::sync::mpsc::SyncSender; use std::sync::Arc; use std::time::{Duration, Instant}; -use crate::engine::cpu::effect::SceneEffect; -use crate::engine::model::resolve::ResolvedScene; -use crate::engine::model::scene::SceneObject; -use crate::engine::sim::particle::{ +use crate::audio::cpu::effect::SceneEffect; +use crate::audio::model::resolve::ResolvedScene; +use crate::audio::model::scene::SceneObject; +use crate::audio::sim::particle::{ InstanceOverride, ParticleConfig, ParticleSprite, ParticleSystem, }; use crate::formats::assets::AssetStore; @@ -74,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) = crate::engine::model::scene::Scene::from_json(&scene_json) { + if let Ok(scene) = crate::audio::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. diff --git a/src/engine/cpu/effect.rs b/src/audio/cpu/effect.rs similarity index 100% rename from src/engine/cpu/effect.rs rename to src/audio/cpu/effect.rs diff --git a/src/engine/cpu/graph.rs b/src/audio/cpu/graph.rs similarity index 98% rename from src/engine/cpu/graph.rs rename to src/audio/cpu/graph.rs index f050410..cf75e76 100644 --- a/src/engine/cpu/graph.rs +++ b/src/audio/cpu/graph.rs @@ -1,8 +1,8 @@ use anyhow::Result; use std::path::Path; -use crate::engine::cpu::material::{EffectDefinition, Model}; -use crate::engine::model::scene::{Scene, SceneObject}; +use crate::audio::cpu::material::{EffectDefinition, Model}; +use crate::audio::model::scene::{Scene, SceneObject}; use crate::formats::assets::AssetStore; pub struct SceneGraph { @@ -306,7 +306,7 @@ impl SceneGraphStats { } fn collect_material_stats( - material: &crate::engine::cpu::material::Material, + material: &crate::audio::cpu::material::Material, stats: &mut SceneGraphStats, ) { stats.material_passes += material.passes.len(); diff --git a/src/engine/cpu/material.rs b/src/audio/cpu/material.rs similarity index 100% rename from src/engine/cpu/material.rs rename to src/audio/cpu/material.rs diff --git a/src/engine/cpu/mod.rs b/src/audio/cpu/mod.rs similarity index 100% rename from src/engine/cpu/mod.rs rename to src/audio/cpu/mod.rs diff --git a/src/engine/gpu/blend.rs b/src/audio/gpu/blend.rs similarity index 100% rename from src/engine/gpu/blend.rs rename to src/audio/gpu/blend.rs diff --git a/src/engine/gpu/fbo.rs b/src/audio/gpu/fbo.rs similarity index 100% rename from src/engine/gpu/fbo.rs rename to src/audio/gpu/fbo.rs diff --git a/src/engine/gpu/lighting.rs b/src/audio/gpu/lighting.rs similarity index 100% rename from src/engine/gpu/lighting.rs rename to src/audio/gpu/lighting.rs diff --git a/src/engine/gpu/mod.rs b/src/audio/gpu/mod.rs similarity index 100% rename from src/engine/gpu/mod.rs rename to src/audio/gpu/mod.rs diff --git a/src/engine/gpu/renderer/gpu_shaders.wgsl b/src/audio/gpu/renderer/gpu_shaders.wgsl similarity index 100% rename from src/engine/gpu/renderer/gpu_shaders.wgsl rename to src/audio/gpu/renderer/gpu_shaders.wgsl diff --git a/src/engine/gpu/renderer/mod.rs b/src/audio/gpu/renderer/mod.rs similarity index 97% rename from src/engine/gpu/renderer/mod.rs rename to src/audio/gpu/renderer/mod.rs index c0fdf76..a8105d4 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/audio/gpu/renderer/mod.rs @@ -20,12 +20,12 @@ 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::audio::gpu::fbo::RenderTargetPool; +use crate::audio::gpu::shaders::{effect_def, loader, resolver, transpiler}; +use crate::audio::model::scene::{parse_value_bool, parse_value_f32}; +use crate::audio::model::{ShaderModel, WEBlending}; +use crate::audio::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; +use crate::audio::sim::particle; use crate::platform::GpuDevice; const SHADER_SRC: &str = include_str!("gpu_shaders.wgsl"); @@ -240,7 +240,7 @@ impl GpuSceneRenderer { let audio_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("audio_spectrum"), - size: crate::engine::audio::UNIFORM_BYTES as u64, + size: crate::audio::audio::UNIFORM_BYTES as u64, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -1122,7 +1122,7 @@ impl GpuSceneRenderer { 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()); + crate::audio::gpu::fbo::fit_texture_limit(&self.device, img.width(), img.height()); let downscaled; let img = if (fw, fh) != img.dimensions() { tracing::warn!( @@ -1641,21 +1641,20 @@ struct SceneLayerGpu { /// 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>, + 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>>, + 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, + text_dynamic: Option, color: [f32; 3], brightness: f32, frame_duration_ms: u32, @@ -1691,7 +1690,7 @@ struct SceneLayerGpu { text_raster_cost: Duration, /// Composition/fullscreen layer: `frames[0]` is refilled each frame /// with the scene drawn beneath it (see `capture_backdrop`). - backdrop: Option, + backdrop: Option, /// Spritesheet frame a script picked (`getTextureAnimation().setFrame`), /// overriding the timed animation. script_frame: Option, @@ -1746,15 +1745,15 @@ struct ParticleGpuAssets { 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, + pub playback: crate::audio::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::model::scene::SceneObject, -) -> Option { + obj: &crate::audio::model::scene::SceneObject, +) -> Option { let sound = obj.sound.as_ref()?; let start_silent = obj .startsilent @@ -1767,7 +1766,7 @@ fn start_scene_sound( let volume = obj .volume .as_ref() - .and_then(crate::engine::model::scene::parse_value_f32) + .and_then(crate::audio::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"); @@ -1780,7 +1779,7 @@ fn start_scene_sound( _ => Vec::new(), }; files.into_iter().find_map(|f| { - crate::engine::audio::SoundPlayback::start(&dir.join(&f), volume, looping, !start_silent) + crate::audio::audio::SoundPlayback::start(&dir.join(&f), volume, looping, !start_silent) }) } @@ -1838,7 +1837,7 @@ pub struct GpuSceneInstance { 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, + scripts: crate::audio::sim::script::ScriptHost, /// What live user-property edits diff against. live_props: LiveProps, /// Per-object visibility as scripts leave it, indexed like `scene.objects` @@ -1878,7 +1877,7 @@ pub struct GpuSceneInstance { /// 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, + audio_capture: Option, /// Live playback streams for `sound` objects; kept alive for the scene's /// lifetime (dropping stops the audio). sounds: Vec, @@ -1890,7 +1889,7 @@ pub struct GpuSceneInstance { /// 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, + 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. @@ -1921,9 +1920,9 @@ pub struct GpuSceneInstance { 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, + camera3d: Option, /// Parent chain per object, for recomposing transforms scripts change. - transform_graph: crate::engine::model::resolve::TransformGraph, + transform_graph: crate::audio::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. @@ -1932,7 +1931,7 @@ pub struct GpuSceneInstance { /// `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, + current_locals: Vec, } /// One static 3D mesh, uploaded once: geometry never changes, and its MVP is @@ -1945,7 +1944,7 @@ fn quad_world_corners(center: [f32; 3], angles: [f32; 3], size_px: [f64; 2]) -> 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); + let off = crate::audio::sim::camera3d::rotate_euler([cx, cy, 0.0], angles); [center[0] + off[0], center[1] + off[1], center[2] + off[2]] }) } @@ -1961,7 +1960,7 @@ fn quad_world_corners(center: [f32; 3], angles: [f32; 3], size_px: [f64; 2]) -> /// 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, + cam: &crate::audio::sim::camera3d::PerspectiveCamera, center: [f32; 3], angles: [f32; 3], size_px: [f64; 2], @@ -2005,7 +2004,7 @@ fn project_quad_ndc( /// full perspective-correct texture mapping is a further, separate /// improvement. fn project_quad_corners( - cam: &crate::engine::sim::camera3d::PerspectiveCamera, + cam: &crate::audio::sim::camera3d::PerspectiveCamera, center: [f32; 3], angles: [f32; 3], size_px: [f64; 2], @@ -2045,7 +2044,7 @@ 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::gpu::shadow::MAX_SHADOW_LIGHTS; +const MESH3D_MAX_SHADOW_LIGHTS: usize = crate::audio::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 @@ -2063,7 +2062,7 @@ const MESH3D_LIGHTING_BYTES_LEN: usize = /// 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::sim::camera3d::PerspectiveCamera, + cam: &crate::audio::sim::camera3d::PerspectiveCamera, origin: [f32; 3], angles: [f32; 3], scale: [f32; 3], @@ -2073,7 +2072,7 @@ fn mesh3d_transform_bytes( 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), + crate::audio::sim::camera3d::model_matrix(origin, angles, scale), ] { for col in mat.iter() { for f in col { @@ -2107,11 +2106,11 @@ fn mesh3d_transform_bytes( /// `flags.y` (as `light_index + 1`, `0.0` = none) and the trailing /// `cookie_view_proj` field. fn mesh3d_lighting_bytes( - scene: &crate::engine::model::scene::Scene, - cam: &crate::engine::sim::camera3d::PerspectiveCamera, + scene: &crate::audio::model::scene::Scene, + cam: &crate::audio::sim::camera3d::PerspectiveCamera, shadow_slots: &[Option], - shadow_slot_data: &[(crate::engine::sim::camera3d::Mat4, [f32; 4])], - cookie: Option<(usize, crate::engine::sim::camera3d::Mat4)>, + shadow_slot_data: &[(crate::audio::sim::camera3d::Mat4, [f32; 4])], + cookie: Option<(usize, crate::audio::sim::camera3d::Mat4)>, ) -> Vec { let lights = scene.lights(); let general = scene.general.as_ref(); @@ -2135,10 +2134,10 @@ fn mesh3d_lighting_bytes( 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 { .. } + crate::audio::gpu::lighting::Light::Point { .. } + | crate::audio::gpu::lighting::Light::Spot { .. } + | crate::audio::gpu::lighting::Light::Directional { .. } + | crate::audio::gpu::lighting::Light::Tube { .. } ) }); let cookie_light_flag = cookie.map(|(idx, _)| (idx + 1) as f32).unwrap_or(0.0); @@ -2190,7 +2189,7 @@ fn mesh3d_lighting_bytes( .unwrap_or(0.0) }; match light { - crate::engine::gpu::lighting::Light::Point { + crate::audio::gpu::lighting::Light::Point { origin, color, intensity, @@ -2202,7 +2201,7 @@ fn mesh3d_lighting_bytes( // 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 { + crate::audio::gpu::lighting::Light::Spot { origin, direction, exponent, @@ -2219,7 +2218,7 @@ fn mesh3d_lighting_bytes( let vd = cam.to_view_direction(*direction); *spot_slot = [vd[0], vd[1], vd[2], *exponent]; } - crate::engine::gpu::lighting::Light::Directional { + crate::audio::gpu::lighting::Light::Directional { direction, color, intensity, @@ -2241,7 +2240,7 @@ fn mesh3d_lighting_bytes( // 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 { + crate::audio::gpu::lighting::Light::Tube { origin_a, origin_b, color, @@ -2287,7 +2286,7 @@ fn mesh3d_lighting_bytes( let vp = shadow_slot_data .get(slot) .map(|(vp, _)| *vp) - .unwrap_or_else(crate::engine::sim::camera3d::identity); + .unwrap_or_else(crate::audio::sim::camera3d::identity); for col in vp.iter() { push4(*col); } @@ -2301,7 +2300,7 @@ fn mesh3d_lighting_bytes( } let cookie_view_proj = cookie .map(|(_, vp)| vp) - .unwrap_or_else(crate::engine::sim::camera3d::identity); + .unwrap_or_else(crate::audio::sim::camera3d::identity); for col in cookie_view_proj.iter() { push4(*col); } @@ -2330,7 +2329,7 @@ impl GpuSceneInstance { } fn build(mut renderer: GpuSceneRenderer, dir: &Path) -> Result { - let resolved = crate::engine::model::resolve::ResolvedScene::from_directory(dir)?; + let resolved = crate::audio::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) @@ -2341,7 +2340,7 @@ impl GpuSceneInstance { let w = resolved.width; let h = resolved.height; - let scene_model = crate::engine::model::scene_to_model(&resolved.scene)?; + let scene_model = crate::audio::model::scene_to_model(&resolved.scene)?; let general = resolved.scene.general.as_ref(); let clear_color: [f64; 3] = general @@ -2429,7 +2428,7 @@ impl GpuSceneInstance { // 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( + let camera3d = crate::audio::sim::camera3d::PerspectiveCamera::from_scene( &resolved.scene, w as f32 / h as f32, ); @@ -2463,7 +2462,7 @@ impl GpuSceneInstance { 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() + crate::audio::audio::AudioCapture::start() } else { None }; @@ -2723,9 +2722,8 @@ impl GpuSceneInstance { // 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); + use crate::audio::gpu::lighting::Light; + let (center, radius) = crate::audio::gpu::shadow::scene_bounds(&resolved.mesh3d_layers); lights .iter() .enumerate() @@ -2740,12 +2738,12 @@ impl GpuSceneInstance { else { return None; }; - let image = crate::engine::model::resolve::resolve_static_texture( + let image = crate::audio::model::resolve::resolve_static_texture( Some(dir), None, path, )?; - let vp = crate::engine::gpu::shadow::spot_light_view_proj( + let vp = crate::audio::gpu::shadow::spot_light_view_proj( *origin, *direction, *outer_cone_degrees, @@ -2901,7 +2899,7 @@ impl GpuSceneInstance { audio_capture, sounds, #[cfg(target_os = "linux")] - media: crate::engine::media::MediaWatcher::start(), + media: crate::audio::media::MediaWatcher::start(), perspective: camera3d.is_some(), mesh3d, mesh3d_depth, @@ -2918,7 +2916,7 @@ impl GpuSceneInstance { instance.effect_enabled = vec![true; instance.scene_effects.len()]; instance.cursor_targets = instance .scripts - .objects_with_handlers(crate::engine::sim::script::CURSOR_EVENTS); + .objects_with_handlers(crate::audio::sim::script::CURSOR_EVENTS); Ok(instance) } @@ -2941,18 +2939,18 @@ impl GpuSceneInstance { /// procedural per-light cone/box mesh — see the report's scoping note). fn build_volumetrics( renderer: &GpuSceneRenderer, - cam: &crate::engine::sim::camera3d::PerspectiveCamera, + cam: &crate::audio::sim::camera3d::PerspectiveCamera, lights: &[( - crate::engine::gpu::lighting::Light, + crate::audio::gpu::lighting::Light, bool, Option<(f32, f32)>, Option, )], shadow_slots: &[Option], - shadow_slot_data: &[(crate::engine::sim::camera3d::Mat4, [f32; 4])], + shadow_slot_data: &[(crate::audio::sim::camera3d::Mat4, [f32; 4])], shadow_atlas_view: &wgpu::TextureView, ) -> Option { - use crate::engine::gpu::lighting::Light; + use crate::audio::gpu::lighting::Light; let (idx, origin, color_intensity, density, exponent) = lights .iter() @@ -2998,9 +2996,9 @@ impl GpuSceneInstance { .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)); + .unwrap_or((crate::audio::sim::camera3d::identity(), [0.0; 4], 0.0)); - let inv_view_proj = crate::engine::sim::camera3d::mat4_inverse(&cam.view_proj_raw()); + let inv_view_proj = crate::audio::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). @@ -3008,7 +3006,7 @@ impl GpuSceneInstance { 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| { + let mut push_mat = |m: &crate::audio::sim::camera3d::Mat4| { for col in m.iter() { for f in col { bytes.extend_from_slice(&f.to_le_bytes()); @@ -3076,9 +3074,9 @@ impl GpuSceneInstance { /// and the per-slot `(view_proj, uv_rect)` data for `mesh3d_lighting_bytes`. fn build_shadow_atlas( renderer: &GpuSceneRenderer, - mesh3d_layers: &[crate::engine::model::resolve::Mesh3dLayer], + mesh3d_layers: &[crate::audio::model::resolve::Mesh3dLayer], lights: &[( - crate::engine::gpu::lighting::Light, + crate::audio::gpu::lighting::Light, bool, Option<(f32, f32)>, Option, @@ -3086,10 +3084,10 @@ impl GpuSceneInstance { ) -> ( wgpu::Texture, Vec>, - Vec<(crate::engine::sim::camera3d::Mat4, [f32; 4])>, + Vec<(crate::audio::sim::camera3d::Mat4, [f32; 4])>, ) { - use crate::engine::gpu::lighting::Light; - use crate::engine::gpu::shadow; + use crate::audio::gpu::lighting::Light; + use crate::audio::gpu::shadow; let mut shadow_slots: Vec> = vec![None; lights.len()]; @@ -3099,7 +3097,7 @@ impl GpuSceneInstance { // 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 + let shadow_casters: Vec<(usize, crate::audio::sim::camera3d::Mat4)> = lights .iter() .enumerate() .filter_map(|(i, (light, casts_shadow, _volumetrics, _))| { @@ -3234,12 +3232,12 @@ impl GpuSceneInstance { 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( + let model = crate::audio::sim::camera3d::model_matrix( layer.origin, layer.angles, layer.scale, ); - let light_mvp = crate::engine::sim::camera3d::mat4_mul(&view_proj, &model); + let light_mvp = crate::audio::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 { @@ -3277,7 +3275,7 @@ impl GpuSceneInstance { /// upload can't simply be reused from a `Mesh3dGpu` built later. fn upload_mesh3d_geometry( renderer: &GpuSceneRenderer, - mesh: &crate::engine::model::mesh3d::Mesh3d, + mesh: &crate::audio::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) { @@ -3308,8 +3306,8 @@ impl GpuSceneInstance { /// both static in real content, so this runs once at load. fn build_mesh3d( renderer: &GpuSceneRenderer, - cam: &crate::engine::sim::camera3d::PerspectiveCamera, - layer: &crate::engine::model::resolve::Mesh3dLayer, + cam: &crate::audio::sim::camera3d::PerspectiveCamera, + layer: &crate::audio::model::resolve::Mesh3dLayer, lighting_ubo: &wgpu::Buffer, shadow_atlas_view: &wgpu::TextureView, cookie_view: &wgpu::TextureView, @@ -3435,7 +3433,7 @@ impl GpuSceneInstance { /// Advance keyframe animations and apply the properties they move. fn tick_timelines(&mut self, delta: f32) { - use crate::engine::sim::script::{ObjectChange, ScriptChanges}; + use crate::audio::sim::script::{ObjectChange, ScriptChanges}; if self.timelines.is_empty() { return; } @@ -3534,7 +3532,7 @@ impl GpuSceneInstance { } #[cfg(target_os = "linux")] if let Some(info) = self.media.as_ref().and_then(|w| w.try_recv()) { - use crate::engine::media::PlaybackState; + use crate::audio::media::PlaybackState; self.scripts.set_media( info.available, match info.playback_state { @@ -3570,7 +3568,7 @@ impl GpuSceneInstance { ); self.dispatch_cursor_events(); self.scripts - .frame(time, crate::engine::sim::script::local_time_of_day(), delta); + .frame(time, crate::audio::sim::script::local_time_of_day(), delta); let changes = self.scripts.collect(); self.apply_script_changes(changes); self.flush_text(); @@ -3580,7 +3578,7 @@ impl GpuSceneInstance { 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(); + self.audio_capture = crate::audio::audio::AudioCapture::start(); } } } @@ -3667,8 +3665,8 @@ impl GpuSceneInstance { /// 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::sim::script::ScriptChanges) { - use crate::engine::sim::script::ScriptCommand; + fn apply_script_changes(&mut self, changes: crate::audio::sim::script::ScriptChanges) { + use crate::audio::sim::script::ScriptCommand; let n = self.current_locals.len(); let mut moved = vec![false; n]; let mut visibility_changed = false; @@ -3787,7 +3785,7 @@ impl GpuSceneInstance { /// [`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); + crate::audio::properties::SceneProperties::from_project_dir(&self.live_props.dir); self.apply_user_properties(props, names) } @@ -3798,7 +3796,7 @@ impl GpuSceneInstance { /// built at load, a field scripts can't set). fn apply_user_properties( &mut self, - props: crate::engine::properties::SceneProperties, + props: crate::audio::properties::SceneProperties, names: &[String], ) -> bool { let lp = &mut self.live_props; @@ -3835,7 +3833,7 @@ impl GpuSceneInstance { if diffs.is_empty() { continue; } - let mut change = crate::engine::sim::script::ObjectChange::default(); + let mut change = crate::audio::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(".")); @@ -3844,7 +3842,7 @@ impl GpuSceneInstance { } objects.push((i, change)); } - self.apply_script_changes(crate::engine::sim::script::ScriptChanges { + self.apply_script_changes(crate::audio::sim::script::ScriptChanges { objects, commands: Vec::new(), }); @@ -3860,9 +3858,9 @@ impl GpuSceneInstance { object: usize, path: &[String], v: &serde_json::Value, - c: &mut crate::engine::sim::script::ObjectChange, + c: &mut crate::audio::sim::script::ObjectChange, ) -> Option<()> { - use crate::engine::model::scene::{parse_value_bool, parse_value_f32, parse_value_vec3}; + use crate::audio::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)); @@ -3976,7 +3974,7 @@ impl GpuSceneInstance { /// 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::model::resolve::Xform; + use crate::audio::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; @@ -3991,7 +3989,7 @@ impl GpuSceneInstance { let len = self.draw_rank.len(); self.draw_rank.extend((len..n).map(|i| i as f64)); } - let obj: crate::engine::model::scene::SceneObject = + let obj: crate::audio::model::scene::SceneObject = match serde_json::from_value(config.clone()) { Ok(o) => o, Err(e) => { @@ -4012,7 +4010,7 @@ impl GpuSceneInstance { 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( + let Some(mut layer) = crate::audio::model::resolve::runtime_layer( &self.scene_dir, self.scene_pkg.as_ref(), &obj, @@ -4021,7 +4019,7 @@ impl GpuSceneInstance { return; }; layer.order_index = index; - let local = crate::engine::model::resolve::obj_local_xform(&obj); + let local = crate::audio::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(); @@ -4046,7 +4044,7 @@ impl GpuSceneInstance { } /// Sound controls belonging to scene object `index`. - fn sounds_of(&self, index: usize) -> Vec> { + fn sounds_of(&self, index: usize) -> Vec> { self.sounds .iter() .filter(|s| s.object == index) @@ -4092,7 +4090,7 @@ impl GpuSceneInstance { } let started = Instant::now(); if let Some(img) = - crate::engine::gpu::text::rasterize(&td.font_data, text, td.point_size) + crate::audio::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); @@ -4129,7 +4127,7 @@ impl GpuSceneInstance { /// 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::model::resolve::Xform; + use crate::audio::model::resolve::Xform; let n = self.current_locals.len(); let parent = self.transform_graph.parent.clone(); // affected[i]: object i or an ancestor moved. @@ -5499,7 +5497,7 @@ fn named_fbo_key(instance_idx: usize, fbo_name: &str) -> String { /// 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::sim::script::local_time_of_day() + crate::audio::sim::script::local_time_of_day() } #[allow(clippy::too_many_arguments)] @@ -5834,7 +5832,7 @@ fn load_effect_instance( // 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) + crate::audio::gpu::shaders::uniform_meta::parse_uniform_metadata(&frag_glsl) .into_iter() .filter(|m| m.uniform_type == "sampler2D") .map(|m| m.combo) @@ -6081,13 +6079,13 @@ pub fn gpu_scene_render_loop( struct LiveProps { dir: std::path::PathBuf, raw: serde_json::Value, - props: crate::engine::properties::SceneProperties, + props: crate::audio::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 props = crate::audio::properties::SceneProperties::from_project_dir(dir); let mut resolved = raw.clone(); props.resolve_scene_json(&mut resolved); Self { @@ -6149,7 +6147,7 @@ fn insert_value_components(map: &mut ShaderVals, key: &str, value: &serde_json:: .replace('_', "") .to_lowercase() }; - let animated = crate::engine::model::json_to_animated(value); + let animated = crate::audio::model::json_to_animated(value); insert_animated_components(map, key, &alias(key), &animated); } @@ -6157,7 +6155,7 @@ fn insert_animated_components( map: &mut ShaderVals, key: &str, alias: &str, - v: &crate::engine::model::AnimatedValue, + v: &crate::audio::model::AnimatedValue, ) { if let Some(f) = v.as_float() { map.insert(key.to_string(), f); @@ -6182,7 +6180,7 @@ fn insert_animated_components( } } -fn collect_effects(scene: &crate::engine::model::SceneModel) -> Vec { +fn collect_effects(scene: &crate::audio::model::SceneModel) -> Vec { let alias = |k: &str| -> String { k.strip_prefix("ui_editor_properties_") .unwrap_or(k) @@ -6528,11 +6526,11 @@ fn make_effect_params(name: &str, time: f32, vals: &ShaderVals) -> Vec { /// the object's scene.json origin, before alignment. fn gpu_layer_from( renderer: &GpuSceneRenderer, - l: &crate::engine::model::resolve::Layer, + l: &crate::audio::model::resolve::Layer, w: u32, h: u32, zoom: f32, - camera3d: Option<&crate::engine::sim::camera3d::PerspectiveCamera>, + camera3d: Option<&crate::audio::sim::camera3d::PerspectiveCamera>, authored_origin: [f64; 3], ) -> SceneLayerGpu { let all_frames: Vec<&RgbaImage> = std::iter::once(&l.image) @@ -6664,7 +6662,7 @@ struct TimelineEntry { key: String, /// Base value, which transform keyframes are offsets from. base: Vec, - anim: crate::engine::sim::timeline::PropertyAnimation, + anim: crate::audio::sim::timeline::PropertyAnimation, /// Last value applied, to skip re-applying an idle animation. last: Option>, } @@ -6672,9 +6670,9 @@ struct TimelineEntry { /// Collect every animated property from the raw scene.json objects. fn build_timelines( raw_scene: &serde_json::Value, - graph: &crate::engine::model::resolve::TransformGraph, + graph: &crate::audio::model::resolve::TransformGraph, ) -> Vec { - use crate::engine::sim::timeline::PropertyAnimation; + use crate::audio::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() { @@ -6716,7 +6714,7 @@ fn build_timelines( .local .get(i) .copied() - .unwrap_or(crate::engine::model::resolve::Xform::IDENTITY); + .unwrap_or(crate::audio::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(), @@ -6740,13 +6738,13 @@ fn build_timelines( /// transform graph use), then one instance per scripted property. fn build_script_host( dir: &Path, - scene: &crate::engine::model::scene::Scene, - graph: &crate::engine::model::resolve::TransformGraph, + scene: &crate::audio::model::scene::Scene, + graph: &crate::audio::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}; +) -> crate::audio::sim::script::ScriptHost { + use crate::audio::model::scene::{parse_value_f32, parse_value_vec3}; + use crate::audio::sim::script::{ObjectInit, ScriptHost}; let mut host = ScriptHost::new(); if scene.objects.iter().all(|o| o.scripts().is_empty()) { @@ -6766,7 +6764,7 @@ fn build_script_host( } let user_props: serde_json::Map = - crate::engine::properties::SceneProperties::from_project_dir(dir) + crate::audio::properties::SceneProperties::from_project_dir(dir) .iter() .map(|p| (p.name.clone(), p.value.clone())) .collect(); @@ -6782,7 +6780,7 @@ fn build_script_host( .local .get(i) .copied() - .unwrap_or(crate::engine::model::resolve::Xform::IDENTITY); + .unwrap_or(crate::audio::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) => { @@ -6844,7 +6842,7 @@ fn build_script_host( ("volume", obj.volume.as_ref()), ]; for (prop, value) in props { - if let Some((script, authored)) = crate::engine::sim::script::scripted(value) { + if let Some((script, authored)) = crate::audio::sim::script::scripted(value) { host.attach(i, Some(prop), script, authored); } } @@ -6880,7 +6878,7 @@ fn build_script_host( } } for (key, value) in extras { - if let Some((script, authored)) = crate::engine::sim::script::scripted(Some(value)) { + if let Some((script, authored)) = crate::audio::sim::script::scripted(Some(value)) { let initial = value .get("value") .cloned() @@ -6888,7 +6886,7 @@ fn build_script_host( host.declare_property( i, &key, - crate::engine::sim::script::value_kind(&initial), + crate::audio::sim::script::value_kind(&initial), &initial, ); host.attach(i, Some(&key), script, authored); diff --git a/src/engine/gpu/renderer/tests.rs b/src/audio/gpu/renderer/tests.rs similarity index 92% rename from src/engine/gpu/renderer/tests.rs rename to src/audio/gpu/renderer/tests.rs index decd304..cb19e01 100644 --- a/src/engine/gpu/renderer/tests.rs +++ b/src/audio/gpu/renderer/tests.rs @@ -17,13 +17,13 @@ fn gpu_shaders_wgsl_parses_and_validates() { .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( +fn quad3d_test_camera() -> crate::audio::sim::camera3d::PerspectiveCamera { + let scene: crate::audio::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() + crate::audio::sim::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).unwrap() } /// A camera-facing quad (no rotation, centered on the view axis) must @@ -90,11 +90,11 @@ fn project_quad_corners_none_when_behind_camera() { #[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( + let scene: crate::audio::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 cam = crate::audio::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); @@ -105,7 +105,7 @@ fn mesh3d_lighting_bytes_are_uniform_sized_and_match_const() { // slots are actually in use. let shadow_slots = vec![Some(0)]; let shadow_slot_data = vec![( - crate::engine::sim::camera3d::identity(), + crate::audio::sim::camera3d::identity(), [0.0, 0.0, 1.0, 1.0], )]; let bytes = mesh3d_lighting_bytes(&scene, &cam, &shadow_slots, &shadow_slot_data, None); @@ -119,13 +119,13 @@ fn mesh3d_lighting_bytes_are_uniform_sized_and_match_const() { /// 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( + let scene: crate::audio::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 cam = crate::audio::sim::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).unwrap(); - let mut vp = crate::engine::sim::camera3d::identity(); + let mut vp = crate::audio::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))); @@ -155,13 +155,13 @@ fn mesh3d_lighting_bytes_encodes_cookie_flag_and_matrix() { /// `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( + let cam_scene: crate::audio::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 cam = crate::audio::sim::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); - let scene: crate::engine::model::scene::Scene = serde_json::from_str( + let scene: crate::audio::model::scene::Scene = serde_json::from_str( r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}, "objects": [ @@ -204,13 +204,13 @@ fn mesh3d_lighting_bytes_encodes_spot_direction() { /// 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( + let cam_scene: crate::audio::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 cam = crate::audio::sim::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); - let scene: crate::engine::model::scene::Scene = serde_json::from_str( + let scene: crate::audio::model::scene::Scene = serde_json::from_str( r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}, "objects": [ @@ -252,13 +252,13 @@ fn mesh3d_lighting_bytes_encodes_directional_sentinel_and_direction() { /// 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( + let cam_scene: crate::audio::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 cam = crate::audio::sim::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); - let scene: crate::engine::model::scene::Scene = serde_json::from_str( + let scene: crate::audio::model::scene::Scene = serde_json::from_str( r#"{"camera": {"eye": "0 0 10", "center": "0 0 0"}, "general": {"orthogonalprojection": null}, "objects": [ @@ -381,7 +381,7 @@ mod hit_tests { }; 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 resolved = crate::audio::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()) diff --git a/src/engine/gpu/shaders/compiler.rs b/src/audio/gpu/shaders/compiler.rs similarity index 100% rename from src/engine/gpu/shaders/compiler.rs rename to src/audio/gpu/shaders/compiler.rs diff --git a/src/engine/gpu/shaders/effect_def.rs b/src/audio/gpu/shaders/effect_def.rs similarity index 100% rename from src/engine/gpu/shaders/effect_def.rs rename to src/audio/gpu/shaders/effect_def.rs diff --git a/src/engine/gpu/shaders/loader.rs b/src/audio/gpu/shaders/loader.rs similarity index 100% rename from src/engine/gpu/shaders/loader.rs rename to src/audio/gpu/shaders/loader.rs diff --git a/src/engine/gpu/shaders/mod.rs b/src/audio/gpu/shaders/mod.rs similarity index 100% rename from src/engine/gpu/shaders/mod.rs rename to src/audio/gpu/shaders/mod.rs diff --git a/src/engine/gpu/shaders/particle_def.rs b/src/audio/gpu/shaders/particle_def.rs similarity index 100% rename from src/engine/gpu/shaders/particle_def.rs rename to src/audio/gpu/shaders/particle_def.rs diff --git a/src/engine/gpu/shaders/resolver.rs b/src/audio/gpu/shaders/resolver.rs similarity index 100% rename from src/engine/gpu/shaders/resolver.rs rename to src/audio/gpu/shaders/resolver.rs diff --git a/src/engine/gpu/shaders/transpiler/mod.rs b/src/audio/gpu/shaders/transpiler/mod.rs similarity index 99% rename from src/engine/gpu/shaders/transpiler/mod.rs rename to src/audio/gpu/shaders/transpiler/mod.rs index 96bb247..791e4b8 100644 --- a/src/engine/gpu/shaders/transpiler/mod.rs +++ b/src/audio/gpu/shaders/transpiler/mod.rs @@ -1,4 +1,4 @@ -use crate::engine::model::ShaderModel; +use crate::audio::model::ShaderModel; use anyhow::{Context, Result}; use std::collections::HashMap; @@ -782,7 +782,7 @@ pub fn translate_full(model: &ShaderModel, vert_glsl: Option<&str>) -> Result, [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) { + for meta in crate::audio::gpu::shaders::uniform_meta::parse_uniform_metadata(v) { annotated.insert( meta.uniform_name.clone(), ( diff --git a/src/engine/gpu/shaders/transpiler/tests.rs b/src/audio/gpu/shaders/transpiler/tests.rs similarity index 98% rename from src/engine/gpu/shaders/transpiler/tests.rs rename to src/audio/gpu/shaders/transpiler/tests.rs index 128a9dd..e99f169 100644 --- a/src/engine/gpu/shaders/transpiler/tests.rs +++ b/src/audio/gpu/shaders/transpiler/tests.rs @@ -65,11 +65,11 @@ fn real_godrays_effect_transpiles_through_generic_pipeline() { .iter() .map(|(k, v)| (k.to_string(), *v)) .collect(); - let model = crate::engine::model::ShaderModel::from_resolved_glsl( + let model = crate::audio::model::ShaderModel::from_resolved_glsl( label.to_string(), frag_glsl, combos, - crate::engine::model::WEBlending::Normal, + crate::audio::model::WEBlending::Normal, ); if let Err(e) = translate_full(&model, Some(&vert_glsl)) { failures.push(format!("{label}: {e:#}")); @@ -531,7 +531,7 @@ fn pow_scalar_broadcast_translates() { "pow_test".to_string(), src.to_string(), HashMap::new(), - crate::engine::model::shader_model::WEBlending::Normal, + crate::audio::model::shader_model::WEBlending::Normal, ); translate(&model).expect("pow(vec3, float) should translate via _wp_pow shims"); } @@ -625,7 +625,7 @@ fn audio_spectrum_becomes_live_uniform() { "audio_test".to_string(), src.to_string(), HashMap::new(), - crate::engine::model::shader_model::WEBlending::Normal, + crate::audio::model::shader_model::WEBlending::Normal, ); let t = translate(&model).expect("audio spectrum shader should translate"); assert!( diff --git a/src/engine/gpu/shaders/uniform_meta.rs b/src/audio/gpu/shaders/uniform_meta.rs similarity index 100% rename from src/engine/gpu/shaders/uniform_meta.rs rename to src/audio/gpu/shaders/uniform_meta.rs diff --git a/src/engine/gpu/shaders/util_textures.rs b/src/audio/gpu/shaders/util_textures.rs similarity index 100% rename from src/engine/gpu/shaders/util_textures.rs rename to src/audio/gpu/shaders/util_textures.rs diff --git a/src/engine/gpu/shadow.rs b/src/audio/gpu/shadow.rs similarity index 99% rename from src/engine/gpu/shadow.rs rename to src/audio/gpu/shadow.rs index 89ffdaa..d9bccf9 100644 --- a/src/engine/gpu/shadow.rs +++ b/src/audio/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::model::resolve::Mesh3dLayer; -use crate::engine::sim::camera3d::{self, Mat4}; +use crate::audio::model::resolve::Mesh3dLayer; +use crate::audio::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/gpu/text.rs b/src/audio/gpu/text.rs similarity index 100% rename from src/engine/gpu/text.rs rename to src/audio/gpu/text.rs diff --git a/src/engine/media.rs b/src/audio/media.rs similarity index 100% rename from src/engine/media.rs rename to src/audio/media.rs diff --git a/src/engine/mod.rs b/src/audio/mod.rs similarity index 63% rename from src/engine/mod.rs rename to src/audio/mod.rs index 3924553..fbdae4b 100644 --- a/src/engine/mod.rs +++ b/src/audio/mod.rs @@ -15,11 +15,11 @@ pub mod properties; pub mod sim; // Convenience re-exports. -pub use crate::engine::cpu::graph::SceneGraph; -pub use crate::engine::gpu::fbo::{RenderTarget, RenderTargetPool}; -pub use crate::engine::model::resolve::ResolvedScene; -pub use crate::engine::model::scene::Scene; -pub use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; +pub use crate::audio::cpu::graph::SceneGraph; +pub use crate::audio::gpu::fbo::{RenderTarget, RenderTargetPool}; +pub use crate::audio::model::resolve::ResolvedScene; +pub use crate::audio::model::scene::Scene; +pub use crate::audio::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; pub use crate::formats::pkg::Package; pub use crate::formats::tex::TexFile; pub use properties::SceneProperties; diff --git a/src/engine/model/dynamic_value.rs b/src/audio/model/dynamic_value.rs similarity index 100% rename from src/engine/model/dynamic_value.rs rename to src/audio/model/dynamic_value.rs diff --git a/src/engine/model/mesh3d.rs b/src/audio/model/mesh3d.rs similarity index 100% rename from src/engine/model/mesh3d.rs rename to src/audio/model/mesh3d.rs diff --git a/src/engine/model/mod.rs b/src/audio/model/mod.rs similarity index 100% rename from src/engine/model/mod.rs rename to src/audio/model/mod.rs diff --git a/src/engine/model/objects.rs b/src/audio/model/objects.rs similarity index 100% rename from src/engine/model/objects.rs rename to src/audio/model/objects.rs diff --git a/src/engine/model/parser.rs b/src/audio/model/parser.rs similarity index 99% rename from src/engine/model/parser.rs rename to src/audio/model/parser.rs index 3fa500c..9952f1c 100644 --- a/src/engine/model/parser.rs +++ b/src/audio/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::model::scene; +use crate::audio::model::scene; use anyhow::Result; use std::collections::HashMap; diff --git a/src/engine/model/property.rs b/src/audio/model/property.rs similarity index 100% rename from src/engine/model/property.rs rename to src/audio/model/property.rs diff --git a/src/engine/model/resolve/mod.rs b/src/audio/model/resolve/mod.rs similarity index 97% rename from src/engine/model/resolve/mod.rs rename to src/audio/model/resolve/mod.rs index 98e81e0..4e2813d 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/audio/model/resolve/mod.rs @@ -3,9 +3,9 @@ use image::RgbaImage; use std::collections::HashMap; use std::path::Path; -use crate::engine::model::scene::{Scene, SceneObject}; -use crate::engine::properties::SceneProperties; -use crate::engine::sim::particle; +use crate::audio::model::scene::{Scene, SceneObject}; +use crate::audio::properties::SceneProperties; +use crate::audio::sim::particle; use crate::formats::pkg::Package; use crate::formats::tex::TexFile; @@ -47,7 +47,7 @@ pub struct ResolvedScene { /// a flat quad — spheres, skyboxes and cylinders can't be billboarded. pub struct Mesh3dLayer { pub name: String, - pub mesh: crate::engine::model::mesh3d::Mesh3d, + pub mesh: crate::audio::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). @@ -179,7 +179,7 @@ pub struct Layer { 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>, + 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>>, @@ -212,7 +212,7 @@ struct LoadedImage { /// 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>, + 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>>, @@ -761,7 +761,7 @@ impl ResolvedScene { dst[1] as f32 / 255.0, dst[2] as f32 / 255.0, ]; - let out = crate::engine::gpu::blend::apply_blending( + let out = crate::audio::gpu::blend::apply_blending( layer.blend_mode, dest_rgb, src_rgb, @@ -814,8 +814,8 @@ impl ResolvedScene { /// 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 { + use crate::audio::model::DynamicValue; + match crate::audio::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], @@ -871,13 +871,13 @@ fn select_spritesheet_frame(obj: &SceneObject, loaded: &mut LoadedImage) { .and_then(|v| v.get("script")) .and_then(|s| s.as_str()) .and_then(|script| { - let init = crate::engine::sim::script::ObjectInit { + let init = crate::audio::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 + crate::audio::sim::script::evaluate_once("visible", script, &init, None).frame }) .unwrap_or(0) .min(loaded.sprite_frames.len() as u32 - 1); @@ -965,14 +965,14 @@ fn layer_from_object( if let Some(rt) = std::sync::Arc::get_mut(rt) { let n = rt.model.animations.len(); if n > 0 { - let layers: Vec = entries + 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) + .and_then(crate::audio::model::scene::parse_value_bool) .unwrap_or(true) }) .map(|(i, e)| { @@ -996,7 +996,7 @@ fn layer_from_object( i.min(n - 1) } }); - crate::engine::sim::puppet::AnimLayer { + crate::audio::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, @@ -1020,7 +1020,7 @@ fn layer_from_object( let angles_animated = obj .angles .as_ref() - .map(crate::engine::model::json_to_animated); + .map(crate::audio::model::json_to_animated); let angles3 = angles_animated .as_ref() .and_then(|v| v.as_vec3()) @@ -1030,7 +1030,7 @@ fn layer_from_object( let scale_animated = obj .scale .as_ref() - .map(crate::engine::model::json_to_animated); + .map(crate::audio::model::json_to_animated); let scale = scale_animated .as_ref() .and_then(|v| v.as_vec3()) @@ -1039,7 +1039,7 @@ fn layer_from_object( let alpha_animated = obj .alpha .as_ref() - .map(crate::engine::model::json_to_animated); + .map(crate::audio::model::json_to_animated); let alpha = alpha_animated .as_ref() .and_then(|v| v.as_float()) @@ -1047,13 +1047,13 @@ fn layer_from_object( let color = obj .color .as_ref() - .map(crate::engine::model::json_to_animated) + .map(crate::audio::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) + .map(crate::audio::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 @@ -1368,7 +1368,7 @@ fn text_layer_from_object( (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 { + let init = crate::audio::sim::script::ObjectInit { name: obj.name.clone(), id: obj.id, visible: true, @@ -1376,7 +1376,7 @@ fn text_layer_from_object( text: Some(static_str.clone()), ..Default::default() }; - crate::engine::sim::script::evaluate_once( + crate::audio::sim::script::evaluate_once( "text", script, &init, @@ -1403,7 +1403,7 @@ fn text_layer_from_object( let scale = obj .scale .as_ref() - .and_then(|v| crate::engine::model::json_to_animated(v).as_vec3()) + .and_then(|v| crate::audio::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 @@ -1426,18 +1426,18 @@ fn text_layer_from_object( point_size * compensate } .clamp(1.0, 1024.0); - let font_data = crate::engine::gpu::text::resolve_font_data(obj.font.as_deref(), dir, pkg)?; + let font_data = crate::audio::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) + .and_then(crate::audio::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) + .and_then(crate::audio::model::scene::parse_value_f32) .filter(|w| *w > 0.0) .filter(|_| gate(&obj.limitwidth)) { @@ -1445,11 +1445,11 @@ fn text_layer_from_object( let rows = obj .maxrows .as_ref() - .and_then(crate::engine::model::scene::parse_value_f32) + .and_then(crate::audio::model::scene::parse_value_f32) .filter(|_| gate(&obj.limitrows)) .unwrap_or(0.0) .max(0.0) as usize; - crate::engine::gpu::text::wrap_text( + crate::audio::gpu::text::wrap_text( &font_data, &text_str, raster_px, @@ -1460,7 +1460,7 @@ fn text_layer_from_object( } None => text_str, }; - let image = crate::engine::gpu::text::rasterize(&font_data, &text_str, raster_px)?; + let image = crate::audio::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` @@ -1478,18 +1478,18 @@ fn text_layer_from_object( let text_color = obj .color .as_ref() - .and_then(|v| crate::engine::model::scene::parse_value_vec3(v)) + .and_then(|v| crate::audio::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) + .and_then(crate::audio::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)) + .and_then(|v| crate::audio::model::scene::parse_value_vec3(v)) .map(|c| { [ c[0] as f32 * bright, @@ -1501,11 +1501,11 @@ fn text_layer_from_object( let pad = obj .padding .as_ref() - .and_then(crate::engine::model::scene::parse_value_f32) + .and_then(crate::audio::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), + crate::audio::gpu::text::with_background(&image, text_color, bg, pad), true, ) } else { @@ -1570,7 +1570,7 @@ fn mesh3d_layer_from_object( // 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 mesh = crate::audio::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())) @@ -1605,12 +1605,12 @@ fn mesh3d_layer_from_object( let scale = obj .scale .as_ref() - .and_then(|v| crate::engine::model::json_to_animated(v).as_vec3()) + .and_then(|v| crate::audio::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()) + .and_then(|v| crate::audio::model::json_to_animated(v).as_vec3()) .unwrap_or([0.0, 0.0, 0.0]); let o = obj.parsed_origin(); @@ -1859,7 +1859,7 @@ pub(crate) fn resolve_static_texture( /// 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::gpu::shaders::loader::find_we_assets_dir()?; + let dir = crate::audio::gpu::shaders::loader::find_we_assets_dir()?; std::fs::read(dir.join(rel_path)).ok() } @@ -1957,7 +1957,7 @@ fn pass_constant_color(pass: &serde_json::Value) -> Option<[u8; 3]> { .get("color") .or_else(|| consts.get("Color"))? .as_str()?; - let c = crate::engine::cpu::effect::parse_color(s); + let c = crate::audio::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, @@ -2116,7 +2116,7 @@ fn apply_puppet_mesh( 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 { + let Some(model) = crate::audio::sim::puppet::parse_model(&mdl_bytes) else { tracing::warn!(target: "scene", "puppet mesh '{puppet_path}' unparsable — drawing raw atlas"); return plain(atlas); }; @@ -2131,7 +2131,7 @@ fn apply_puppet_mesh( // 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 { + let runtime = crate::audio::sim::puppet::PuppetRuntime { model, atlas, layers: Vec::new(), @@ -2139,7 +2139,7 @@ fn apply_puppet_mesh( let assembled = if runtime.model.has_animation() { runtime.render_at(0.0, runtime.atlas.width(), runtime.atlas.height()) } else { - crate::engine::sim::puppet::rasterize( + crate::audio::sim::puppet::rasterize( &runtime.model.mesh, &runtime.atlas, runtime.atlas.width(), @@ -2279,14 +2279,14 @@ pub fn obj_local_xform(obj: &SceneObject) -> Xform { let r = obj .angles .as_ref() - .map(crate::engine::model::json_to_animated) + .map(crate::audio::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) + .and_then(crate::audio::model::scene::parse_value_vec3) .unwrap_or([1.0, 1.0, 1.0]); Xform { t, r, s } } @@ -2305,7 +2305,7 @@ fn parent_index( if obj .disablepropagation .as_ref() - .and_then(crate::engine::model::scene::parse_value_bool) + .and_then(crate::audio::model::scene::parse_value_bool) .unwrap_or(false) { return None; diff --git a/src/engine/model/resolve/tests.rs b/src/audio/model/resolve/tests.rs similarity index 98% rename from src/engine/model/resolve/tests.rs rename to src/audio/model/resolve/tests.rs index 758ad42..8788e6a 100644 --- a/src/engine/model/resolve/tests.rs +++ b/src/audio/model/resolve/tests.rs @@ -140,12 +140,12 @@ fn wrap_limits_respect_their_gates() { let active = |o: &SceneObject| { o.maxwidth .as_ref() - .and_then(crate::engine::model::scene::parse_value_f32) + .and_then(crate::audio::model::scene::parse_value_f32) .filter(|w| *w > 0.0) .filter(|_| { o.limitwidth .as_ref() - .and_then(crate::engine::model::scene::parse_value_bool) + .and_then(crate::audio::model::scene::parse_value_bool) .unwrap_or(false) }) .is_some() diff --git a/src/engine/model/scene.rs b/src/audio/model/scene.rs similarity index 98% rename from src/engine/model/scene.rs rename to src/audio/model/scene.rs index 436a276..7a137bc 100644 --- a/src/engine/model/scene.rs +++ b/src/audio/model/scene.rs @@ -61,7 +61,7 @@ impl Scene { pub fn lights( &self, ) -> Vec<( - crate::engine::gpu::lighting::Light, + crate::audio::gpu::lighting::Light, bool, Option<(f32, f32)>, Option, @@ -92,12 +92,12 @@ impl Scene { .map(|a| [a[0] as f32, a[1] as f32, a[2] as f32]) .unwrap_or([0.0; 3]); let direction = - || crate::engine::sim::camera3d::rotate_euler([0.0, 0.0, -1.0], angles); + || crate::audio::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::gpu::lighting::Light::Directional { + crate::audio::gpu::lighting::Light::Directional { direction: direction(), color, intensity, @@ -120,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::gpu::lighting::Light::Spot { + crate::audio::gpu::lighting::Light::Spot { origin, direction: direction(), exponent, @@ -131,14 +131,14 @@ impl Scene { } } else if matches!(o.light_type(), LightType::Tube) { let (origin_a, origin_b) = o.tube_endpoints(); - crate::engine::gpu::lighting::Light::Tube { + crate::audio::gpu::lighting::Light::Tube { origin_a, origin_b, color, intensity, } } else { - crate::engine::gpu::lighting::Light::Point { + crate::audio::gpu::lighting::Light::Point { origin, color, intensity, @@ -310,8 +310,8 @@ impl General { /// Returns `None` when both distance and height fog density are 0 / unset /// (fog off). The caller (render.rs / gpu_renderer.rs) should only run /// the fog pass when this returns `Some`. - pub fn fog(&self) -> Option { - use crate::engine::gpu::lighting::Fog; + pub fn fog(&self) -> Option { + use crate::audio::gpu::lighting::Fog; let d = self .fog_distance .as_ref() @@ -1212,7 +1212,7 @@ mod tests { let lights = scene.lights(); assert_eq!(lights.len(), 1); match &lights[0].0 { - crate::engine::gpu::lighting::Light::Spot { + crate::audio::gpu::lighting::Light::Spot { origin, intensity, exponent, @@ -1242,7 +1242,7 @@ mod tests { assert_eq!(lights.len(), 1); assert!(matches!( lights[0].0, - crate::engine::gpu::lighting::Light::Point { .. } + crate::audio::gpu::lighting::Light::Point { .. } )); } @@ -1258,7 +1258,7 @@ mod tests { .unwrap(); assert!(matches!( scene.lights()[0].0, - crate::engine::gpu::lighting::Light::Point { .. } + crate::audio::gpu::lighting::Light::Point { .. } )); } @@ -1278,7 +1278,7 @@ mod tests { let lights = scene.lights(); assert_eq!(lights.len(), 1); match &lights[0].0 { - crate::engine::gpu::lighting::Light::Directional { + crate::audio::gpu::lighting::Light::Directional { direction, intensity, .. @@ -1301,7 +1301,7 @@ mod tests { .unwrap(); assert!(matches!( scene.lights()[0].0, - crate::engine::gpu::lighting::Light::Spot { .. } + crate::audio::gpu::lighting::Light::Spot { .. } )); } @@ -1321,11 +1321,11 @@ mod tests { assert_eq!(lights.len(), 2); assert!(matches!( lights[0].0, - crate::engine::gpu::lighting::Light::Point { .. } + crate::audio::gpu::lighting::Light::Point { .. } )); assert!(matches!( lights[1].0, - crate::engine::gpu::lighting::Light::Point { .. } + crate::audio::gpu::lighting::Light::Point { .. } )); } @@ -1411,7 +1411,7 @@ mod tests { let lights = scene.lights(); assert_eq!(lights.len(), 1); match &lights[0].0 { - crate::engine::gpu::lighting::Light::Tube { + crate::audio::gpu::lighting::Light::Tube { origin_a, origin_b, intensity, @@ -1433,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::gpu::lighting::Light::Tube { + crate::audio::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/audio/model/shader_model.rs similarity index 99% rename from src/engine/model/shader_model.rs rename to src/audio/model/shader_model.rs index 457eded..d0fa3ca 100644 --- a/src/engine/model/shader_model.rs +++ b/src/audio/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::gpu::shaders::uniform_meta::parse_uniform_metadata; +use crate::audio::gpu::shaders::uniform_meta::parse_uniform_metadata; use std::collections::HashMap; #[derive(Debug, Clone, PartialEq)] diff --git a/src/engine/playback_gate.rs b/src/audio/playback_gate.rs similarity index 100% rename from src/engine/playback_gate.rs rename to src/audio/playback_gate.rs diff --git a/src/engine/properties.rs b/src/audio/properties.rs similarity index 100% rename from src/engine/properties.rs rename to src/audio/properties.rs diff --git a/src/engine/sim/camera3d.rs b/src/audio/sim/camera3d.rs similarity index 98% rename from src/engine/sim/camera3d.rs rename to src/audio/sim/camera3d.rs index c6716fc..31339ec 100644 --- a/src/engine/sim/camera3d.rs +++ b/src/audio/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::model::scene::Scene; +use crate::audio::model::scene::Scene; /// Column-major 4x4, `m[col][row]` — glm's layout, so it uploads to a WGSL /// `mat4x4` as-is. @@ -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::model::scene::parse_value_f32) + .and_then(crate::audio::model::scene::parse_value_f32) .unwrap_or(50.0); let near = g .and_then(|g| g.nearz.as_ref()) - .and_then(crate::engine::model::scene::parse_value_f32) + .and_then(crate::audio::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::model::scene::parse_value_f32) + .and_then(crate::audio::model::scene::parse_value_f32) .unwrap_or(10000.0) .max(near * 2.0); diff --git a/src/engine/sim/camera_dynamics.rs b/src/audio/sim/camera_dynamics.rs similarity index 98% rename from src/engine/sim/camera_dynamics.rs rename to src/audio/sim/camera_dynamics.rs index 57a6ebd..22e59b3 100644 --- a/src/engine/sim/camera_dynamics.rs +++ b/src/audio/sim/camera_dynamics.rs @@ -5,7 +5,7 @@ //! `camerashake{,amplitude,roughness,speed}`, `camerafade`, and //! `cameraparallax{,amount,delay,mouseinfluence}` (see WallpaperParser.cpp). -use crate::engine::model::scene::{parse_value_bool, parse_value_f32, Scene}; +use crate::audio::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/audio/sim/mod.rs similarity index 100% rename from src/engine/sim/mod.rs rename to src/audio/sim/mod.rs diff --git a/src/engine/sim/noise.rs b/src/audio/sim/noise.rs similarity index 100% rename from src/engine/sim/noise.rs rename to src/audio/sim/noise.rs diff --git a/src/engine/sim/particle/mod.rs b/src/audio/sim/particle/mod.rs similarity index 99% rename from src/engine/sim/particle/mod.rs rename to src/audio/sim/particle/mod.rs index 6d80fcf..a817a53 100644 --- a/src/engine/sim/particle/mod.rs +++ b/src/audio/sim/particle/mod.rs @@ -1224,7 +1224,7 @@ impl RemapParams { max_life: f32, ) -> f32 { if self.input == RemapField::None || self.transform.is_noise() { - use crate::engine::sim::noise::fbm_noise; + use crate::audio::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 { @@ -2066,7 +2066,7 @@ impl ParticleSystem { p.y * t.noise_scale, 0.0, ]; - let curl = crate::engine::sim::noise::curl_noise(noise_pos); + let curl = crate::audio::sim::noise::curl_noise(noise_pos); let len = vec3_length(curl); if len > 0.0001 { let s = t.speed / len * dt * self.speed_mult; @@ -2622,7 +2622,7 @@ impl ParticleSystem { spawn_y * 0.1 + time_shift + phase * 0.7, time_shift + phase * 1.3, ]; - let curl = crate::engine::sim::noise::curl_noise(sample); + let curl = crate::audio::sim::noise::curl_noise(sample); let mut dir = vec3_normalize_or(curl, forward); // `scale` < 2 limits how far the direction may deviate diff --git a/src/engine/sim/particle/tests.rs b/src/audio/sim/particle/tests.rs similarity index 100% rename from src/engine/sim/particle/tests.rs rename to src/audio/sim/particle/tests.rs diff --git a/src/engine/sim/puppet.rs b/src/audio/sim/puppet.rs similarity index 100% rename from src/engine/sim/puppet.rs rename to src/audio/sim/puppet.rs diff --git a/src/engine/sim/scenescript_runtime.js b/src/audio/sim/scenescript_runtime.js similarity index 100% rename from src/engine/sim/scenescript_runtime.js rename to src/audio/sim/scenescript_runtime.js diff --git a/src/engine/sim/script.rs b/src/audio/sim/script.rs similarity index 100% rename from src/engine/sim/script.rs rename to src/audio/sim/script.rs diff --git a/src/engine/sim/timeline.rs b/src/audio/sim/timeline.rs similarity index 100% rename from src/engine/sim/timeline.rs rename to src/audio/sim/timeline.rs diff --git a/src/bin/scan_wallpapers.rs b/src/bin/scan_wallpapers.rs index b260bd4..72f12ac 100644 --- a/src/bin/scan_wallpapers.rs +++ b/src/bin/scan_wallpapers.rs @@ -44,7 +44,7 @@ fn run_with_timeout(binary: &str, wp_path: &str, timeout: Duration) -> String { let mut child = match child { Ok(c) => c, - Err(e) => return format!("FAIL: failed to spawn wp-engine: {e}"), + Err(e) => return format!("FAIL: failed to spawn wp-audio: {e}"), }; let deadline = Instant::now() + timeout; @@ -181,12 +181,15 @@ fn main() -> Result<(), Box> { .unwrap_or_else(|| { let mut p = std::env::current_exe().unwrap(); p.pop(); - p.push("wp-engine"); + p.push("wp-audio"); p }); if !wp_engine.exists() { - eprintln!("wp-engine binary not found at {}. Run `cargo build` first or pass the path as argv[1].", wp_engine.display()); + eprintln!( + "wp-audio binary not found at {}. Run `cargo build` first or pass the path as argv[1].", + wp_engine.display() + ); std::process::exit(1); } diff --git a/src/formats/assets.rs b/src/formats/assets.rs index 3b1018b..85d1d00 100644 --- a/src/formats/assets.rs +++ b/src/formats/assets.rs @@ -53,7 +53,7 @@ impl AssetStore { root: root.to_path_buf(), dependency, packages, - assets_dir: crate::engine::gpu::shaders::loader::find_we_assets_dir(), + assets_dir: crate::audio::gpu::shaders::loader::find_we_assets_dir(), }) } diff --git a/src/lib.rs b/src/lib.rs index b8262ba..241bd38 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ pub mod application; +pub mod audio; #[cfg(target_os = "linux")] pub mod daemon; -pub mod engine; pub mod formats; pub mod logging; pub mod platform; diff --git a/src/logging.rs b/src/logging.rs index 0d2a2ea..c39fbbf 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::cpu::effect=trace,wp_engine=info`. +//! `RUST_LOG=wp_engine::audio::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 1f87e88..cac2144 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,11 +4,11 @@ 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}; +use wp_engine::{audio, platform, ui}; #[derive(Parser)] #[command( - name = "wp-engine", + name = "wp-audio", 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." @@ -141,7 +141,7 @@ enum Command { frames: usize, }, /// Manage persisted settings: per-wallpaper property overrides, screen - /// assignments, and playlists (~/.config/wp-engine/settings.json) + /// assignments, and playlists (~/.config/wp-audio/settings.json) Config { #[command(subcommand)] action: ConfigAction, @@ -166,7 +166,7 @@ enum Command { #[command(subcommand)] action: PlaylistAction, }, - /// Start `wp-engine run` at login: on, off, or show the current state + /// Start `wp-audio run` at login: on, off, or show the current state Autostart { #[arg(value_parser = ["on", "off", "status"], default_value = "status")] state: String, @@ -202,7 +202,7 @@ enum ConfigAction { 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 + /// Set the fallback wallpaper `wp-audio 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 }, @@ -228,7 +228,7 @@ enum ConfigAction { #[arg(long, value_delimiter = ',')] times: Vec, }, - /// Make `wp-engine run` play this playlist (omit NAME to clear) + /// Make `wp-audio run` play this playlist (omit NAME to clear) SetActivePlaylist { name: Option }, /// Remove a playlist RemovePlaylist { name: String }, @@ -260,7 +260,7 @@ fn main() { 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"); + tracing::debug!(target: "cli", verbosity = cli.verbose, "wp-audio starting"); let result = match cli.command { // No subcommand → open GUI @@ -332,14 +332,14 @@ fn main() { fn run_ui() -> Result<()> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_title("wp-engine") + .with_title("wp-audio") .with_inner_size([1100.0, 700.0]) .with_min_inner_size([800.0, 500.0]), ..Default::default() }; eframe::run_native( - "wp-engine", + "wp-audio", options, Box::new(|cc| Ok(Box::new(ui::WpApp::new(cc)))), ) @@ -476,7 +476,7 @@ fn apply_saved_settings( if save { let mut saved_anything = false; for arg in cli_properties { - let (name, value) = engine::properties::parse_property_arg(arg); + let (name, value) = audio::properties::parse_property_arg(arg); settings.set_property(id_or_path, &name, &value); saved_anything = true; } @@ -500,7 +500,7 @@ fn apply_saved_settings( 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)?; + let all_props = audio::properties::list_properties(&w.path)?; if all_props.is_empty() { println!( "\"{}\" declares no user-configurable properties.", @@ -548,7 +548,7 @@ fn cmd_list_properties(id: &str) -> Result<()> { decorative.len() ); } - println!("\nOverride with: wp-engine set {id} --set-property NAME=VALUE"); + println!("\nOverride with: wp-audio set {id} --set-property NAME=VALUE"); Ok(()) } @@ -573,7 +573,7 @@ fn cmd_config(action: ConfigAction) -> Result<()> { ConfigAction::SetProperty { id, properties } => { let mut names = Vec::new(); for arg in &properties { - let (name, value) = engine::properties::parse_property_arg(arg); + let (name, value) = audio::properties::parse_property_arg(arg); println!("{id}: {name} = {value}"); settings.set_property(&id, &name, &value); names.push(name); @@ -666,14 +666,14 @@ fn cmd_config(action: ConfigAction) -> Result<()> { if !settings.playlists.contains_key(n) { return Err(anyhow!("no playlist named '{n}'")); } - println!("`wp-engine run` plays playlist '{n}'"); + println!("`wp-audio 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( + let a = wp_engine::audio::playback_gate::PlaybackAction::parse(&action).ok_or_else( || anyhow!("invalid action '{action}' (expected run, mute, pause or stop)"), )?; if !settings.playback.set(&condition, a) { @@ -764,7 +764,7 @@ fn resolve_scene_dir(id_or_path: &str) -> Result { wp_engine::render::WallpaperContent::preset_base_dir(&dir) } -/// Runs using persisted settings (`wp-engine config` to set them up first): +/// Runs using persisted settings (`wp-audio 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 @@ -772,7 +772,7 @@ fn resolve_scene_dir(id_or_path: &str) -> Result { /// 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 +/// `audio::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 @@ -801,8 +801,8 @@ fn cmd_run(foreground: bool) -> Result<()> { }) .ok_or_else(|| { anyhow!( - "no saved wallpaper — use `wp-engine config set-screen`/`set-default`/\ - `set-playlist` first, or `wp-engine set ` directly" + "no saved wallpaper — use `wp-audio config set-screen`/`set-default`/\ + `set-playlist` first, or `wp-audio set ` directly" ) })? .clone(); @@ -858,13 +858,13 @@ fn launch(context: ApplicationContext, title: Option, foreground: bool) 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."); + println!("Wallpaper active (background daemon). `wp-audio 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)" + "warning: a wp-audio daemon is also running — both will draw the wallpaper \ + (`wp-audio stop` stops it)" ); } } @@ -1021,7 +1021,7 @@ fn cmd_set_file( } fn cmd_pkg_info(path: &std::path::Path, dump: Option<&std::path::Path>) -> Result<()> { - let pkg = engine::Package::from_file(path)?; + let pkg = audio::Package::from_file(path)?; println!("Files : {}", pkg.len()); println!("Empty : {}", pkg.is_empty()); @@ -1070,7 +1070,7 @@ fn cmd_pkg_info(path: &std::path::Path, dump: Option<&std::path::Path>) -> Resul 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)?; + let tex = audio::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); @@ -1095,16 +1095,16 @@ fn cmd_render_scene( ) -> Result<()> { let overrides = properties .iter() - .map(|p| engine::properties::parse_property_arg(p)) + .map(|p| audio::properties::parse_property_arg(p)) .collect(); - engine::properties::set_global_overrides(overrides); + audio::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)?; + let graph = audio::SceneGraph::from_directory(&dir)?; println!("Scene graph: {}", graph.stats().summary()); - let scene = engine::ResolvedScene::from_directory(&dir)?; + let scene = audio::ResolvedScene::from_directory(&dir)?; println!( "Rendering {}x{} with {} layers...", scene.width, @@ -1143,10 +1143,10 @@ fn cmd_render_frame( seconds: f32, properties: Vec, ) -> Result<()> { - engine::properties::set_global_overrides( + audio::properties::set_global_overrides( properties .iter() - .map(|p| engine::properties::parse_property_arg(p)) + .map(|p| audio::properties::parse_property_arg(p)) .collect(), ); let path = resolve_id_or_path(id_or_path)?; @@ -1183,7 +1183,7 @@ fn cmd_test_scene(id_or_path: &str, num_frames: usize) -> Result<()> { 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) { + if let Err(e) = audio::gpu::renderer::gpu_scene_render_loop(&render_dir, &tx, 30.0) { tracing::error!(target: "render", "gpu scene error: {e}"); } }); @@ -1286,7 +1286,7 @@ fn cmd_preview_scene(id_or_path: &str, width: u32, height: u32) -> Result<()> { 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) { + if let Err(e) = audio::gpu::renderer::gpu_scene_render_loop(&render_dir, &tx, 30.0) { tracing::error!(target: "render", "gpu scene error: {e}"); } }); @@ -1299,7 +1299,7 @@ fn cmd_preview_scene(id_or_path: &str, width: u32, height: u32) -> Result<()> { ); let mut window = Window::new( - "wp-engine preview", + "wp-audio preview", width as usize, height as usize, WindowOptions::default(), diff --git a/src/platform/audio.rs b/src/platform/audio.rs index c6644f9..d4c35fe 100644 --- a/src/platform/audio.rs +++ b/src/platform/audio.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::audio::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: //! diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index c749e62..c81dad6 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -33,7 +33,7 @@ use winit::window::{Window, WindowId}; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; use super::GpuDevice; -use crate::engine::gpu::renderer::GpuSceneInstance; +use crate::audio::gpu::renderer::GpuSceneInstance; use crate::render::{ScreenContent, ScreenSettings, WallpaperContent}; // ── Platform implementation ─────────────────────────────────────────────────── diff --git a/src/platform/wayland/mod.rs b/src/platform/wayland/mod.rs index d50f3c8..6b7a383 100644 --- a/src/platform/wayland/mod.rs +++ b/src/platform/wayland/mod.rs @@ -48,7 +48,7 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{ }; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; -use crate::engine::playback_gate::PlaybackAction; +use crate::audio::playback_gate::PlaybackAction; use crate::{ platform, render::session::{ @@ -414,7 +414,7 @@ impl WallpaperState { // `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::audio::audio::set_muted(action >= PlaybackAction::Mute); if action.halts() { if !self.paused { self.notify_web(crate::render::web::WebInputEvent::Paused(true)); @@ -434,7 +434,7 @@ impl WallpaperState { let _ = tx.try_send(crate::render::web::WebInputEvent::Fps(fps)); } } - crate::engine::audio::set_master_volume(self.settings.default.lock().unwrap().volume); + crate::audio::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. @@ -1532,7 +1532,7 @@ 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::audio::audio::set_muted(action >= PlaybackAction::Mute); if !was_paused && action.halts() { self.notify_web(crate::render::web::WebInputEvent::Paused(true)); } diff --git a/src/platform/x11/mod.rs b/src/platform/x11/mod.rs index dca90e9..05ca890 100644 --- a/src/platform/x11/mod.rs +++ b/src/platform/x11/mod.rs @@ -31,7 +31,7 @@ use x11rb::protocol::xproto::{ use x11rb::protocol::{shape, xfixes::ConnectionExt as _}; use x11rb::rust_connection::RustConnection; -use crate::engine::playback_gate::PlaybackAction; +use crate::audio::playback_gate::PlaybackAction; use x11rb::wrapper::ConnectionExt as _; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; @@ -646,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::audio::audio::set_muted(action >= PlaybackAction::Mute); + crate::audio::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. diff --git a/src/render/content.rs b/src/render/content.rs index 2a001b5..cfd3b51 100644 --- a/src/render/content.rs +++ b/src/render/content.rs @@ -140,7 +140,7 @@ impl WallpaperContent { ) })?; if let Some(preset) = &project.preset { - crate::engine::properties::register_preset(&parent, dir, preset); + crate::audio::properties::register_preset(&parent, dir, preset); } tracing::info!(target: "content", "preset {} → base item {}", dir.display(), parent.display()); Ok(parent) diff --git a/src/render/frame.rs b/src/render/frame.rs index 3520c0a..6ec70ef 100644 --- a/src/render/frame.rs +++ b/src/render/frame.rs @@ -25,7 +25,7 @@ pub enum FrameSource { /// 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,13 +52,13 @@ 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::audio::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::cpu::animated::scene_render_loop( + if let Err(e2) = crate::audio::cpu::animated::scene_render_loop( &dir, &tx, target_fps, ) { tracing::error!(target: "render", "CPU scene render error: {e2}"); @@ -71,7 +71,7 @@ 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::cpu::animated::scene_render_loop( + if let Err(e2) = crate::audio::cpu::animated::scene_render_loop( &dir, &tx, target_fps, ) { tracing::error!(target: "render", "CPU scene render error: {e2}"); @@ -106,7 +106,7 @@ impl FrameSource { } WallpaperContent::Video { path } => { let (tx, rx) = sync_channel::>(2); - let audio = crate::engine::audio::StreamingAudio::start(&path); + let audio = crate::audio::audio::StreamingAudio::start(&path); thread::spawn(move || { if let Err(e) = super::ffmpeg::video_decode_loop(&path, &tx) { diff --git a/src/render/session.rs b/src/render/session.rs index 8451474..f9ad763 100644 --- a/src/render/session.rs +++ b/src/render/session.rs @@ -14,9 +14,9 @@ use anyhow::Result; use super::web::{WebInputEvent, WebOptions}; use super::{FrameSource, RenderSettings, WallpaperContent}; -use crate::engine::gpu::renderer::GpuSceneInstance; -use crate::engine::media::{MediaWatcher, PlaybackState as MediaState}; -use crate::engine::playback_gate::{self, PlaybackAction, PlaybackPolicy, PlaybackState}; +use crate::audio::gpu::renderer::GpuSceneInstance; +use crate::audio::media::{MediaWatcher, PlaybackState as MediaState}; +use crate::audio::playback_gate::{self, PlaybackAction, PlaybackPolicy, PlaybackState}; use crate::platform::power::{self, LockWatcher}; /// How often the playback conditions are re-checked. Each check is real I/O diff --git a/src/render/web.rs b/src/render/web.rs index e027f90..c3c3b01 100644 --- a/src/render/web.rs +++ b/src/render/web.rs @@ -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`. #[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::audio::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::audio::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::audio::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::audio::media::MediaInfo) -> String { let json_str = |s: &str| serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()); let art_url = info .art_url @@ -912,7 +912,7 @@ mod imp { return String::new(); }; - let resolved = crate::engine::properties::SceneProperties::from_project_dir(dir); + let resolved = crate::audio::properties::SceneProperties::from_project_dir(dir); for (name, decl) in props.iter_mut() { if let Some(value) = resolved.get(name) { decl["value"] = value.clone(); diff --git a/src/settings.rs b/src/settings.rs index af64f7d..74fe36a 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -53,7 +53,7 @@ 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::audio::playback_gate::PlaybackPolicy, /// Frame-rate cap for animated wallpapers; `None` = the default (30). #[serde(default)] pub fps: Option, diff --git a/src/ui/config.rs b/src/ui/config.rs index 24d5234..6a3f2b5 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -4,8 +4,8 @@ //! `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::audio::playback_gate::PlaybackAction; +use crate::audio::properties::SceneProperty; use crate::platform::RenderQuality; use crate::render::ScaleMode; use crate::settings::{Playlist, WpSettings}; @@ -158,7 +158,7 @@ impl WpApp { /// 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) + let props = crate::audio::properties::list_properties(&self.wallpapers[idx].path) .unwrap_or_default(); self.prop_editor = Some(PropertyEditor { idx, @@ -232,11 +232,11 @@ impl WpApp { let raw = saved.get(&p.name)?; Some(( p.name.clone(), - crate::engine::properties::convert_override(p, raw), + crate::audio::properties::convert_override(p, raw), )) }) .collect(); - crate::engine::properties::visible_rows(props, &values) + crate::audio::properties::visible_rows(props, &values) } /// Draws the rows WE would show; returns whether a value was changed. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 6bd668d..cd20a71 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -508,7 +508,7 @@ impl WpApp { display_title: String, properties: HashMap, ) { - crate::engine::properties::set_global_overrides(properties); + crate::audio::properties::set_global_overrides(properties); let old_renderer = self.renderer.take(); let settings = Arc::clone(&self.settings); let (tx, rx) = mpsc::channel(); diff --git a/tests/effect.rs b/tests/effect.rs index c1a47a6..5a905d5 100644 --- a/tests/effect.rs +++ b/tests/effect.rs @@ -1,4 +1,4 @@ -use wp_engine::engine::cpu::effect::{HandwrittenEffectFallback, SceneEffect}; +use wp_engine::audio::cpu::effect::{HandwrittenEffectFallback, SceneEffect}; #[test] fn recognizes_scroll_and_spin_fallbacks() { diff --git a/tests/properties.rs b/tests/properties.rs index c69e663..d792dbd 100644 --- a/tests/properties.rs +++ b/tests/properties.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; -use wp_engine::engine::model::scene::Scene; -use wp_engine::engine::properties::{parse_property_arg, SceneProperties}; -use wp_engine::engine::sim::camera_dynamics::CameraDynamics; +use wp_engine::audio::model::scene::Scene; +use wp_engine::audio::properties::{parse_property_arg, SceneProperties}; +use wp_engine::audio::sim::camera_dynamics::CameraDynamics; fn props_from_project(project: serde_json::Value, overrides: &[(&str, &str)]) -> SceneProperties { // Build via a temp dir so the public loader path is exercised. @@ -177,7 +177,7 @@ fn static_scene_has_inactive_dynamics() { // Regression test for the copybackground bug: objects flagged // `copybackground` still carry a real image and must load it (the reference -// engine ignores the flag). Uses a real workshop scene when available. +// audio ignores the flag). Uses a real workshop scene when available. #[test] fn copybackground_objects_load_their_image() { let dir = std::path::PathBuf::from(std::env::var("HOME").unwrap()) @@ -185,7 +185,7 @@ fn copybackground_objects_load_their_image() { if !dir.exists() { return; // fixture not installed on this machine } - let resolved = wp_engine::engine::ResolvedScene::from_directory(&dir).unwrap(); + let resolved = wp_engine::audio::ResolvedScene::from_directory(&dir).unwrap(); let layer = resolved .layers .iter() From 1d25149b2c33f57eed93261c3433e397940d6340 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:19:39 -0700 Subject: [PATCH 037/117] Revert "refactor: move engine.rs to audio.rs" This reverts commit 851c102faebff675cf00025a71e8f389e268da2b. --- src/application/application_context.rs | 2 +- src/application/mod.rs | 2 +- src/bin/scan_wallpapers.rs | 9 +- src/{audio => engine}/audio.rs | 0 src/{audio => engine}/cpu/animated.rs | 10 +- src/{audio => engine}/cpu/effect.rs | 0 src/{audio => engine}/cpu/graph.rs | 6 +- src/{audio => engine}/cpu/material.rs | 0 src/{audio => engine}/cpu/mod.rs | 0 src/{audio => engine}/gpu/blend.rs | 0 src/{audio => engine}/gpu/fbo.rs | 0 src/{audio => engine}/gpu/lighting.rs | 0 src/{audio => engine}/gpu/mod.rs | 0 .../gpu/renderer/gpu_shaders.wgsl | 0 src/{audio => engine}/gpu/renderer/mod.rs | 228 +++++++++--------- src/{audio => engine}/gpu/renderer/tests.rs | 38 +-- src/{audio => engine}/gpu/shaders/compiler.rs | 0 .../gpu/shaders/effect_def.rs | 0 src/{audio => engine}/gpu/shaders/loader.rs | 0 src/{audio => engine}/gpu/shaders/mod.rs | 0 .../gpu/shaders/particle_def.rs | 0 src/{audio => engine}/gpu/shaders/resolver.rs | 0 .../gpu/shaders/transpiler/mod.rs | 4 +- .../gpu/shaders/transpiler/tests.rs | 8 +- .../gpu/shaders/uniform_meta.rs | 0 .../gpu/shaders/util_textures.rs | 0 src/{audio => engine}/gpu/shadow.rs | 4 +- src/{audio => engine}/gpu/text.rs | 0 src/{audio => engine}/media.rs | 0 src/{audio => engine}/mod.rs | 10 +- src/{audio => engine}/model/dynamic_value.rs | 0 src/{audio => engine}/model/mesh3d.rs | 0 src/{audio => engine}/model/mod.rs | 0 src/{audio => engine}/model/objects.rs | 0 src/{audio => engine}/model/parser.rs | 2 +- src/{audio => engine}/model/property.rs | 0 src/{audio => engine}/model/resolve/mod.rs | 88 +++---- src/{audio => engine}/model/resolve/tests.rs | 4 +- src/{audio => engine}/model/scene.rs | 34 +-- src/{audio => engine}/model/shader_model.rs | 2 +- src/{audio => engine}/playback_gate.rs | 0 src/{audio => engine}/properties.rs | 0 src/{audio => engine}/sim/camera3d.rs | 8 +- src/{audio => engine}/sim/camera_dynamics.rs | 2 +- src/{audio => engine}/sim/mod.rs | 0 src/{audio => engine}/sim/noise.rs | 0 src/{audio => engine}/sim/particle/mod.rs | 6 +- src/{audio => engine}/sim/particle/tests.rs | 0 src/{audio => engine}/sim/puppet.rs | 0 .../sim/scenescript_runtime.js | 0 src/{audio => engine}/sim/script.rs | 0 src/{audio => engine}/sim/timeline.rs | 0 src/formats/assets.rs | 2 +- src/lib.rs | 2 +- src/logging.rs | 2 +- src/main.rs | 66 ++--- src/platform/audio.rs | 2 +- src/platform/macos/mod.rs | 2 +- src/platform/wayland/mod.rs | 8 +- src/platform/x11/mod.rs | 6 +- src/render/content.rs | 2 +- src/render/frame.rs | 10 +- src/render/session.rs | 6 +- src/render/web.rs | 14 +- src/settings.rs | 2 +- src/ui/config.rs | 10 +- src/ui/mod.rs | 2 +- tests/effect.rs | 2 +- tests/properties.rs | 10 +- 69 files changed, 307 insertions(+), 308 deletions(-) rename src/{audio => engine}/audio.rs (100%) rename src/{audio => engine}/cpu/animated.rs (97%) rename src/{audio => engine}/cpu/effect.rs (100%) rename src/{audio => engine}/cpu/graph.rs (98%) rename src/{audio => engine}/cpu/material.rs (100%) rename src/{audio => engine}/cpu/mod.rs (100%) rename src/{audio => engine}/gpu/blend.rs (100%) rename src/{audio => engine}/gpu/fbo.rs (100%) rename src/{audio => engine}/gpu/lighting.rs (100%) rename src/{audio => engine}/gpu/mod.rs (100%) rename src/{audio => engine}/gpu/renderer/gpu_shaders.wgsl (100%) rename src/{audio => engine}/gpu/renderer/mod.rs (97%) rename src/{audio => engine}/gpu/renderer/tests.rs (92%) rename src/{audio => engine}/gpu/shaders/compiler.rs (100%) rename src/{audio => engine}/gpu/shaders/effect_def.rs (100%) rename src/{audio => engine}/gpu/shaders/loader.rs (100%) rename src/{audio => engine}/gpu/shaders/mod.rs (100%) rename src/{audio => engine}/gpu/shaders/particle_def.rs (100%) rename src/{audio => engine}/gpu/shaders/resolver.rs (100%) rename src/{audio => engine}/gpu/shaders/transpiler/mod.rs (99%) rename src/{audio => engine}/gpu/shaders/transpiler/tests.rs (98%) rename src/{audio => engine}/gpu/shaders/uniform_meta.rs (100%) rename src/{audio => engine}/gpu/shaders/util_textures.rs (100%) rename src/{audio => engine}/gpu/shadow.rs (99%) rename src/{audio => engine}/gpu/text.rs (100%) rename src/{audio => engine}/media.rs (100%) rename src/{audio => engine}/mod.rs (63%) rename src/{audio => engine}/model/dynamic_value.rs (100%) rename src/{audio => engine}/model/mesh3d.rs (100%) rename src/{audio => engine}/model/mod.rs (100%) rename src/{audio => engine}/model/objects.rs (100%) rename src/{audio => engine}/model/parser.rs (99%) rename src/{audio => engine}/model/property.rs (100%) rename src/{audio => engine}/model/resolve/mod.rs (97%) rename src/{audio => engine}/model/resolve/tests.rs (98%) rename src/{audio => engine}/model/scene.rs (98%) rename src/{audio => engine}/model/shader_model.rs (99%) rename src/{audio => engine}/playback_gate.rs (100%) rename src/{audio => engine}/properties.rs (100%) rename src/{audio => engine}/sim/camera3d.rs (98%) rename src/{audio => engine}/sim/camera_dynamics.rs (98%) rename src/{audio => engine}/sim/mod.rs (100%) rename src/{audio => engine}/sim/noise.rs (100%) rename src/{audio => engine}/sim/particle/mod.rs (99%) rename src/{audio => engine}/sim/particle/tests.rs (100%) rename src/{audio => engine}/sim/puppet.rs (100%) rename src/{audio => engine}/sim/scenescript_runtime.js (100%) rename src/{audio => engine}/sim/script.rs (100%) rename src/{audio => engine}/sim/timeline.rs (100%) diff --git a/src/application/application_context.rs b/src/application/application_context.rs index a700654..226ce24 100644 --- a/src/application/application_context.rs +++ b/src/application/application_context.rs @@ -83,7 +83,7 @@ impl ApplicationContext { S: AsRef, { for arg in args { - let (name, value) = crate::audio::properties::parse_property_arg(arg.as_ref()); + let (name, value) = crate::engine::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 d122cbd..7111b7c 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -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::audio::properties::set_overrides_for( + crate::engine::properties::set_overrides_for( &context.background, context.properties.clone(), ); diff --git a/src/bin/scan_wallpapers.rs b/src/bin/scan_wallpapers.rs index 72f12ac..b260bd4 100644 --- a/src/bin/scan_wallpapers.rs +++ b/src/bin/scan_wallpapers.rs @@ -44,7 +44,7 @@ fn run_with_timeout(binary: &str, wp_path: &str, timeout: Duration) -> String { let mut child = match child { Ok(c) => c, - Err(e) => return format!("FAIL: failed to spawn wp-audio: {e}"), + Err(e) => return format!("FAIL: failed to spawn wp-engine: {e}"), }; let deadline = Instant::now() + timeout; @@ -181,15 +181,12 @@ fn main() -> Result<(), Box> { .unwrap_or_else(|| { let mut p = std::env::current_exe().unwrap(); p.pop(); - p.push("wp-audio"); + p.push("wp-engine"); p }); if !wp_engine.exists() { - eprintln!( - "wp-audio binary not found at {}. Run `cargo build` first or pass the path as argv[1].", - wp_engine.display() - ); + eprintln!("wp-engine binary not found at {}. Run `cargo build` first or pass the path as argv[1].", wp_engine.display()); std::process::exit(1); } diff --git a/src/audio/audio.rs b/src/engine/audio.rs similarity index 100% rename from src/audio/audio.rs rename to src/engine/audio.rs diff --git a/src/audio/cpu/animated.rs b/src/engine/cpu/animated.rs similarity index 97% rename from src/audio/cpu/animated.rs rename to src/engine/cpu/animated.rs index a30a1c1..1754ffa 100644 --- a/src/audio/cpu/animated.rs +++ b/src/engine/cpu/animated.rs @@ -5,10 +5,10 @@ use std::sync::mpsc::SyncSender; use std::sync::Arc; use std::time::{Duration, Instant}; -use crate::audio::cpu::effect::SceneEffect; -use crate::audio::model::resolve::ResolvedScene; -use crate::audio::model::scene::SceneObject; -use crate::audio::sim::particle::{ +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; @@ -74,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) = crate::audio::model::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. diff --git a/src/audio/cpu/effect.rs b/src/engine/cpu/effect.rs similarity index 100% rename from src/audio/cpu/effect.rs rename to src/engine/cpu/effect.rs diff --git a/src/audio/cpu/graph.rs b/src/engine/cpu/graph.rs similarity index 98% rename from src/audio/cpu/graph.rs rename to src/engine/cpu/graph.rs index cf75e76..f050410 100644 --- a/src/audio/cpu/graph.rs +++ b/src/engine/cpu/graph.rs @@ -1,8 +1,8 @@ use anyhow::Result; use std::path::Path; -use crate::audio::cpu::material::{EffectDefinition, Model}; -use crate::audio::model::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 { @@ -306,7 +306,7 @@ impl SceneGraphStats { } fn collect_material_stats( - material: &crate::audio::cpu::material::Material, + material: &crate::engine::cpu::material::Material, stats: &mut SceneGraphStats, ) { stats.material_passes += material.passes.len(); diff --git a/src/audio/cpu/material.rs b/src/engine/cpu/material.rs similarity index 100% rename from src/audio/cpu/material.rs rename to src/engine/cpu/material.rs diff --git a/src/audio/cpu/mod.rs b/src/engine/cpu/mod.rs similarity index 100% rename from src/audio/cpu/mod.rs rename to src/engine/cpu/mod.rs diff --git a/src/audio/gpu/blend.rs b/src/engine/gpu/blend.rs similarity index 100% rename from src/audio/gpu/blend.rs rename to src/engine/gpu/blend.rs diff --git a/src/audio/gpu/fbo.rs b/src/engine/gpu/fbo.rs similarity index 100% rename from src/audio/gpu/fbo.rs rename to src/engine/gpu/fbo.rs diff --git a/src/audio/gpu/lighting.rs b/src/engine/gpu/lighting.rs similarity index 100% rename from src/audio/gpu/lighting.rs rename to src/engine/gpu/lighting.rs diff --git a/src/audio/gpu/mod.rs b/src/engine/gpu/mod.rs similarity index 100% rename from src/audio/gpu/mod.rs rename to src/engine/gpu/mod.rs diff --git a/src/audio/gpu/renderer/gpu_shaders.wgsl b/src/engine/gpu/renderer/gpu_shaders.wgsl similarity index 100% rename from src/audio/gpu/renderer/gpu_shaders.wgsl rename to src/engine/gpu/renderer/gpu_shaders.wgsl diff --git a/src/audio/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs similarity index 97% rename from src/audio/gpu/renderer/mod.rs rename to src/engine/gpu/renderer/mod.rs index a8105d4..c0fdf76 100644 --- a/src/audio/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -20,12 +20,12 @@ use std::sync::mpsc::SyncSender; use std::sync::Arc; use std::time::{Duration, Instant}; -use crate::audio::gpu::fbo::RenderTargetPool; -use crate::audio::gpu::shaders::{effect_def, loader, resolver, transpiler}; -use crate::audio::model::scene::{parse_value_bool, parse_value_f32}; -use crate::audio::model::{ShaderModel, WEBlending}; -use crate::audio::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; -use crate::audio::sim::particle; +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; const SHADER_SRC: &str = include_str!("gpu_shaders.wgsl"); @@ -240,7 +240,7 @@ impl GpuSceneRenderer { let audio_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("audio_spectrum"), - size: crate::audio::audio::UNIFORM_BYTES as u64, + size: crate::engine::audio::UNIFORM_BYTES as u64, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -1122,7 +1122,7 @@ impl GpuSceneRenderer { pub fn upload_texture(&self, img: &RgbaImage) -> wgpu::Texture { let (fw, fh) = - crate::audio::gpu::fbo::fit_texture_limit(&self.device, img.width(), img.height()); + crate::engine::gpu::fbo::fit_texture_limit(&self.device, img.width(), img.height()); let downscaled; let img = if (fw, fh) != img.dimensions() { tracing::warn!( @@ -1641,20 +1641,21 @@ struct SceneLayerGpu { /// 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>, + 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>>, + 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, + text_dynamic: Option, color: [f32; 3], brightness: f32, frame_duration_ms: u32, @@ -1690,7 +1691,7 @@ struct SceneLayerGpu { text_raster_cost: Duration, /// Composition/fullscreen layer: `frames[0]` is refilled each frame /// with the scene drawn beneath it (see `capture_backdrop`). - backdrop: Option, + backdrop: Option, /// Spritesheet frame a script picked (`getTextureAnimation().setFrame`), /// overriding the timed animation. script_frame: Option, @@ -1745,15 +1746,15 @@ struct ParticleGpuAssets { pub(crate) struct SceneSound { /// Index of the owning object in `scene.objects` (or a script-created one). pub object: usize, - pub playback: crate::audio::audio::SoundPlayback, + 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::audio::model::scene::SceneObject, -) -> Option { + obj: &crate::engine::model::scene::SceneObject, +) -> Option { let sound = obj.sound.as_ref()?; let start_silent = obj .startsilent @@ -1766,7 +1767,7 @@ fn start_scene_sound( let volume = obj .volume .as_ref() - .and_then(crate::audio::model::scene::parse_value_f32) + .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"); @@ -1779,7 +1780,7 @@ fn start_scene_sound( _ => Vec::new(), }; files.into_iter().find_map(|f| { - crate::audio::audio::SoundPlayback::start(&dir.join(&f), volume, looping, !start_silent) + crate::engine::audio::SoundPlayback::start(&dir.join(&f), volume, looping, !start_silent) }) } @@ -1837,7 +1838,7 @@ pub struct GpuSceneInstance { 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::audio::sim::script::ScriptHost, + 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` @@ -1877,7 +1878,7 @@ pub struct GpuSceneInstance { /// 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, + audio_capture: Option, /// Live playback streams for `sound` objects; kept alive for the scene's /// lifetime (dropping stops the audio). sounds: Vec, @@ -1889,7 +1890,7 @@ pub struct GpuSceneInstance { /// 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, + 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. @@ -1920,9 +1921,9 @@ pub struct GpuSceneInstance { 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, + camera3d: Option, /// Parent chain per object, for recomposing transforms scripts change. - transform_graph: crate::audio::model::resolve::TransformGraph, + 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. @@ -1931,7 +1932,7 @@ pub struct GpuSceneInstance { /// `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, + current_locals: Vec, } /// One static 3D mesh, uploaded once: geometry never changes, and its MVP is @@ -1944,7 +1945,7 @@ fn quad_world_corners(center: [f32; 3], angles: [f32; 3], size_px: [f64; 2]) -> 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::audio::sim::camera3d::rotate_euler([cx, cy, 0.0], angles); + 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]] }) } @@ -1960,7 +1961,7 @@ fn quad_world_corners(center: [f32; 3], angles: [f32; 3], size_px: [f64; 2]) -> /// 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::audio::sim::camera3d::PerspectiveCamera, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, center: [f32; 3], angles: [f32; 3], size_px: [f64; 2], @@ -2004,7 +2005,7 @@ fn project_quad_ndc( /// full perspective-correct texture mapping is a further, separate /// improvement. fn project_quad_corners( - cam: &crate::audio::sim::camera3d::PerspectiveCamera, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, center: [f32; 3], angles: [f32; 3], size_px: [f64; 2], @@ -2044,7 +2045,7 @@ 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::audio::gpu::shadow::MAX_SHADOW_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 @@ -2062,7 +2063,7 @@ const MESH3D_LIGHTING_BYTES_LEN: usize = /// 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::audio::sim::camera3d::PerspectiveCamera, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, origin: [f32; 3], angles: [f32; 3], scale: [f32; 3], @@ -2072,7 +2073,7 @@ fn mesh3d_transform_bytes( cam.mvp(origin, angles, scale), cam.model_view(origin, angles, scale), cam.normal_view(angles, scale), - crate::audio::sim::camera3d::model_matrix(origin, angles, scale), + crate::engine::sim::camera3d::model_matrix(origin, angles, scale), ] { for col in mat.iter() { for f in col { @@ -2106,11 +2107,11 @@ fn mesh3d_transform_bytes( /// `flags.y` (as `light_index + 1`, `0.0` = none) and the trailing /// `cookie_view_proj` field. fn mesh3d_lighting_bytes( - scene: &crate::audio::model::scene::Scene, - cam: &crate::audio::sim::camera3d::PerspectiveCamera, + scene: &crate::engine::model::scene::Scene, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, shadow_slots: &[Option], - shadow_slot_data: &[(crate::audio::sim::camera3d::Mat4, [f32; 4])], - cookie: Option<(usize, crate::audio::sim::camera3d::Mat4)>, + 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(); @@ -2134,10 +2135,10 @@ fn mesh3d_lighting_bytes( let has_renderable_light = lights.iter().any(|(l, ..)| { matches!( l, - crate::audio::gpu::lighting::Light::Point { .. } - | crate::audio::gpu::lighting::Light::Spot { .. } - | crate::audio::gpu::lighting::Light::Directional { .. } - | crate::audio::gpu::lighting::Light::Tube { .. } + 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); @@ -2189,7 +2190,7 @@ fn mesh3d_lighting_bytes( .unwrap_or(0.0) }; match light { - crate::audio::gpu::lighting::Light::Point { + crate::engine::gpu::lighting::Light::Point { origin, color, intensity, @@ -2201,7 +2202,7 @@ fn mesh3d_lighting_bytes( // spot_slot stays [0,0,0,0] — zero-length direction reads as // "not a spot" in `mesh3d_spot_factor`. } - crate::audio::gpu::lighting::Light::Spot { + crate::engine::gpu::lighting::Light::Spot { origin, direction, exponent, @@ -2218,7 +2219,7 @@ fn mesh3d_lighting_bytes( let vd = cam.to_view_direction(*direction); *spot_slot = [vd[0], vd[1], vd[2], *exponent]; } - crate::audio::gpu::lighting::Light::Directional { + crate::engine::gpu::lighting::Light::Directional { direction, color, intensity, @@ -2240,7 +2241,7 @@ fn mesh3d_lighting_bytes( // spot_slot stays [0,0,0,0] — mesh3d_spot_factor's own // "not a spot" check already no-ops for a directional light. } - crate::audio::gpu::lighting::Light::Tube { + crate::engine::gpu::lighting::Light::Tube { origin_a, origin_b, color, @@ -2286,7 +2287,7 @@ fn mesh3d_lighting_bytes( let vp = shadow_slot_data .get(slot) .map(|(vp, _)| *vp) - .unwrap_or_else(crate::audio::sim::camera3d::identity); + .unwrap_or_else(crate::engine::sim::camera3d::identity); for col in vp.iter() { push4(*col); } @@ -2300,7 +2301,7 @@ fn mesh3d_lighting_bytes( } let cookie_view_proj = cookie .map(|(_, vp)| vp) - .unwrap_or_else(crate::audio::sim::camera3d::identity); + .unwrap_or_else(crate::engine::sim::camera3d::identity); for col in cookie_view_proj.iter() { push4(*col); } @@ -2329,7 +2330,7 @@ impl GpuSceneInstance { } fn build(mut renderer: GpuSceneRenderer, dir: &Path) -> Result { - let resolved = crate::audio::model::resolve::ResolvedScene::from_directory(dir)?; + 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) @@ -2340,7 +2341,7 @@ impl GpuSceneInstance { let w = resolved.width; let h = resolved.height; - let scene_model = crate::audio::model::scene_to_model(&resolved.scene)?; + let scene_model = crate::engine::model::scene_to_model(&resolved.scene)?; let general = resolved.scene.general.as_ref(); let clear_color: [f64; 3] = general @@ -2428,7 +2429,7 @@ impl GpuSceneInstance { // Perspective (3D) scenes project layer quads through the scene // camera instead of mapping pixel origins to NDC directly. - let camera3d = crate::audio::sim::camera3d::PerspectiveCamera::from_scene( + let camera3d = crate::engine::sim::camera3d::PerspectiveCamera::from_scene( &resolved.scene, w as f32 / h as f32, ); @@ -2462,7 +2463,7 @@ impl GpuSceneInstance { let uses_audio = particles_use_audio || effect_runtimes.iter().flatten().any(|r| r.uses_audio); let audio_capture = if uses_audio { - crate::audio::audio::AudioCapture::start() + crate::engine::audio::AudioCapture::start() } else { None }; @@ -2722,8 +2723,9 @@ impl GpuSceneInstance { // plumbed into `build`, and every real `usecookie` occurrence in // this sandbox's cache is zero either way. let cookie_data = { - use crate::audio::gpu::lighting::Light; - let (center, radius) = crate::audio::gpu::shadow::scene_bounds(&resolved.mesh3d_layers); + use crate::engine::gpu::lighting::Light; + let (center, radius) = + crate::engine::gpu::shadow::scene_bounds(&resolved.mesh3d_layers); lights .iter() .enumerate() @@ -2738,12 +2740,12 @@ impl GpuSceneInstance { else { return None; }; - let image = crate::audio::model::resolve::resolve_static_texture( + let image = crate::engine::model::resolve::resolve_static_texture( Some(dir), None, path, )?; - let vp = crate::audio::gpu::shadow::spot_light_view_proj( + let vp = crate::engine::gpu::shadow::spot_light_view_proj( *origin, *direction, *outer_cone_degrees, @@ -2899,7 +2901,7 @@ impl GpuSceneInstance { audio_capture, sounds, #[cfg(target_os = "linux")] - media: crate::audio::media::MediaWatcher::start(), + media: crate::engine::media::MediaWatcher::start(), perspective: camera3d.is_some(), mesh3d, mesh3d_depth, @@ -2916,7 +2918,7 @@ impl GpuSceneInstance { instance.effect_enabled = vec![true; instance.scene_effects.len()]; instance.cursor_targets = instance .scripts - .objects_with_handlers(crate::audio::sim::script::CURSOR_EVENTS); + .objects_with_handlers(crate::engine::sim::script::CURSOR_EVENTS); Ok(instance) } @@ -2939,18 +2941,18 @@ impl GpuSceneInstance { /// procedural per-light cone/box mesh — see the report's scoping note). fn build_volumetrics( renderer: &GpuSceneRenderer, - cam: &crate::audio::sim::camera3d::PerspectiveCamera, + cam: &crate::engine::sim::camera3d::PerspectiveCamera, lights: &[( - crate::audio::gpu::lighting::Light, + crate::engine::gpu::lighting::Light, bool, Option<(f32, f32)>, Option, )], shadow_slots: &[Option], - shadow_slot_data: &[(crate::audio::sim::camera3d::Mat4, [f32; 4])], + shadow_slot_data: &[(crate::engine::sim::camera3d::Mat4, [f32; 4])], shadow_atlas_view: &wgpu::TextureView, ) -> Option { - use crate::audio::gpu::lighting::Light; + use crate::engine::gpu::lighting::Light; let (idx, origin, color_intensity, density, exponent) = lights .iter() @@ -2996,9 +2998,9 @@ impl GpuSceneInstance { .flatten() .and_then(|slot| shadow_slot_data.get(slot)) .map(|(vp, rect)| (*vp, *rect, 1.0f32)) - .unwrap_or((crate::audio::sim::camera3d::identity(), [0.0; 4], 0.0)); + .unwrap_or((crate::engine::sim::camera3d::identity(), [0.0; 4], 0.0)); - let inv_view_proj = crate::audio::sim::camera3d::mat4_inverse(&cam.view_proj_raw()); + 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). @@ -3006,7 +3008,7 @@ impl GpuSceneInstance { const MAX_RANGE: f32 = 40.0; let mut bytes = Vec::with_capacity(4 * 16 * 4 + 16 * 5); - let mut push_mat = |m: &crate::audio::sim::camera3d::Mat4| { + 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()); @@ -3074,9 +3076,9 @@ impl GpuSceneInstance { /// and the per-slot `(view_proj, uv_rect)` data for `mesh3d_lighting_bytes`. fn build_shadow_atlas( renderer: &GpuSceneRenderer, - mesh3d_layers: &[crate::audio::model::resolve::Mesh3dLayer], + mesh3d_layers: &[crate::engine::model::resolve::Mesh3dLayer], lights: &[( - crate::audio::gpu::lighting::Light, + crate::engine::gpu::lighting::Light, bool, Option<(f32, f32)>, Option, @@ -3084,10 +3086,10 @@ impl GpuSceneInstance { ) -> ( wgpu::Texture, Vec>, - Vec<(crate::audio::sim::camera3d::Mat4, [f32; 4])>, + Vec<(crate::engine::sim::camera3d::Mat4, [f32; 4])>, ) { - use crate::audio::gpu::lighting::Light; - use crate::audio::gpu::shadow; + use crate::engine::gpu::lighting::Light; + use crate::engine::gpu::shadow; let mut shadow_slots: Vec> = vec![None; lights.len()]; @@ -3097,7 +3099,7 @@ impl GpuSceneInstance { // 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::audio::sim::camera3d::Mat4)> = lights + let shadow_casters: Vec<(usize, crate::engine::sim::camera3d::Mat4)> = lights .iter() .enumerate() .filter_map(|(i, (light, casts_shadow, _volumetrics, _))| { @@ -3232,12 +3234,12 @@ impl GpuSceneInstance { 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::audio::sim::camera3d::model_matrix( + let model = crate::engine::sim::camera3d::model_matrix( layer.origin, layer.angles, layer.scale, ); - let light_mvp = crate::audio::sim::camera3d::mat4_mul(&view_proj, &model); + 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 { @@ -3275,7 +3277,7 @@ impl GpuSceneInstance { /// upload can't simply be reused from a `Mesh3dGpu` built later. fn upload_mesh3d_geometry( renderer: &GpuSceneRenderer, - mesh: &crate::audio::model::mesh3d::Mesh3d, + 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) { @@ -3306,8 +3308,8 @@ impl GpuSceneInstance { /// both static in real content, so this runs once at load. fn build_mesh3d( renderer: &GpuSceneRenderer, - cam: &crate::audio::sim::camera3d::PerspectiveCamera, - layer: &crate::audio::model::resolve::Mesh3dLayer, + 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, @@ -3433,7 +3435,7 @@ impl GpuSceneInstance { /// Advance keyframe animations and apply the properties they move. fn tick_timelines(&mut self, delta: f32) { - use crate::audio::sim::script::{ObjectChange, ScriptChanges}; + use crate::engine::sim::script::{ObjectChange, ScriptChanges}; if self.timelines.is_empty() { return; } @@ -3532,7 +3534,7 @@ impl GpuSceneInstance { } #[cfg(target_os = "linux")] if let Some(info) = self.media.as_ref().and_then(|w| w.try_recv()) { - use crate::audio::media::PlaybackState; + use crate::engine::media::PlaybackState; self.scripts.set_media( info.available, match info.playback_state { @@ -3568,7 +3570,7 @@ impl GpuSceneInstance { ); self.dispatch_cursor_events(); self.scripts - .frame(time, crate::audio::sim::script::local_time_of_day(), delta); + .frame(time, crate::engine::sim::script::local_time_of_day(), delta); let changes = self.scripts.collect(); self.apply_script_changes(changes); self.flush_text(); @@ -3578,7 +3580,7 @@ impl GpuSceneInstance { if !self.scripts_want_audio && self.scripts.wants_audio() { self.scripts_want_audio = true; if self.audio_capture.is_none() { - self.audio_capture = crate::audio::audio::AudioCapture::start(); + self.audio_capture = crate::engine::audio::AudioCapture::start(); } } } @@ -3665,8 +3667,8 @@ impl GpuSceneInstance { /// 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::audio::sim::script::ScriptChanges) { - use crate::audio::sim::script::ScriptCommand; + 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; @@ -3785,7 +3787,7 @@ impl GpuSceneInstance { /// [`Self::apply_user_properties`]). pub fn set_user_properties(&mut self, names: &[String]) -> bool { let props = - crate::audio::properties::SceneProperties::from_project_dir(&self.live_props.dir); + crate::engine::properties::SceneProperties::from_project_dir(&self.live_props.dir); self.apply_user_properties(props, names) } @@ -3796,7 +3798,7 @@ impl GpuSceneInstance { /// built at load, a field scripts can't set). fn apply_user_properties( &mut self, - props: crate::audio::properties::SceneProperties, + props: crate::engine::properties::SceneProperties, names: &[String], ) -> bool { let lp = &mut self.live_props; @@ -3833,7 +3835,7 @@ impl GpuSceneInstance { if diffs.is_empty() { continue; } - let mut change = crate::audio::sim::script::ObjectChange::default(); + 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(".")); @@ -3842,7 +3844,7 @@ impl GpuSceneInstance { } objects.push((i, change)); } - self.apply_script_changes(crate::audio::sim::script::ScriptChanges { + self.apply_script_changes(crate::engine::sim::script::ScriptChanges { objects, commands: Vec::new(), }); @@ -3858,9 +3860,9 @@ impl GpuSceneInstance { object: usize, path: &[String], v: &serde_json::Value, - c: &mut crate::audio::sim::script::ObjectChange, + c: &mut crate::engine::sim::script::ObjectChange, ) -> Option<()> { - use crate::audio::model::scene::{parse_value_bool, parse_value_f32, parse_value_vec3}; + 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)); @@ -3974,7 +3976,7 @@ impl GpuSceneInstance { /// 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::audio::model::resolve::Xform; + 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; @@ -3989,7 +3991,7 @@ impl GpuSceneInstance { let len = self.draw_rank.len(); self.draw_rank.extend((len..n).map(|i| i as f64)); } - let obj: crate::audio::model::scene::SceneObject = + let obj: crate::engine::model::scene::SceneObject = match serde_json::from_value(config.clone()) { Ok(o) => o, Err(e) => { @@ -4010,7 +4012,7 @@ impl GpuSceneInstance { self.scene_pkg = crate::formats::pkg::Package::from_file(&self.scene_dir.join("scene.pkg")).ok(); } - let Some(mut layer) = crate::audio::model::resolve::runtime_layer( + let Some(mut layer) = crate::engine::model::resolve::runtime_layer( &self.scene_dir, self.scene_pkg.as_ref(), &obj, @@ -4019,7 +4021,7 @@ impl GpuSceneInstance { return; }; layer.order_index = index; - let local = crate::audio::model::resolve::obj_local_xform(&obj); + 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(); @@ -4044,7 +4046,7 @@ impl GpuSceneInstance { } /// Sound controls belonging to scene object `index`. - fn sounds_of(&self, index: usize) -> Vec> { + fn sounds_of(&self, index: usize) -> Vec> { self.sounds .iter() .filter(|s| s.object == index) @@ -4090,7 +4092,7 @@ impl GpuSceneInstance { } let started = Instant::now(); if let Some(img) = - crate::audio::gpu::text::rasterize(&td.font_data, text, td.point_size) + 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); @@ -4127,7 +4129,7 @@ impl GpuSceneInstance { /// the layers and meshes they move: those whose own object or any /// ancestor `moved`. fn apply_object_transforms(&mut self, moved: &[bool]) { - use crate::audio::model::resolve::Xform; + 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. @@ -5497,7 +5499,7 @@ fn named_fbo_key(instance_idx: usize, fbo_name: &str) -> String { /// 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::audio::sim::script::local_time_of_day() + crate::engine::sim::script::local_time_of_day() } #[allow(clippy::too_many_arguments)] @@ -5832,7 +5834,7 @@ fn load_effect_instance( // 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::audio::gpu::shaders::uniform_meta::parse_uniform_metadata(&frag_glsl) + crate::engine::gpu::shaders::uniform_meta::parse_uniform_metadata(&frag_glsl) .into_iter() .filter(|m| m.uniform_type == "sampler2D") .map(|m| m.combo) @@ -6079,13 +6081,13 @@ pub fn gpu_scene_render_loop( struct LiveProps { dir: std::path::PathBuf, raw: serde_json::Value, - props: crate::audio::properties::SceneProperties, + props: crate::engine::properties::SceneProperties, resolved: serde_json::Value, } impl LiveProps { fn new(dir: &Path, raw: &serde_json::Value) -> Self { - let props = crate::audio::properties::SceneProperties::from_project_dir(dir); + let props = crate::engine::properties::SceneProperties::from_project_dir(dir); let mut resolved = raw.clone(); props.resolve_scene_json(&mut resolved); Self { @@ -6147,7 +6149,7 @@ fn insert_value_components(map: &mut ShaderVals, key: &str, value: &serde_json:: .replace('_', "") .to_lowercase() }; - let animated = crate::audio::model::json_to_animated(value); + let animated = crate::engine::model::json_to_animated(value); insert_animated_components(map, key, &alias(key), &animated); } @@ -6155,7 +6157,7 @@ fn insert_animated_components( map: &mut ShaderVals, key: &str, alias: &str, - v: &crate::audio::model::AnimatedValue, + v: &crate::engine::model::AnimatedValue, ) { if let Some(f) = v.as_float() { map.insert(key.to_string(), f); @@ -6180,7 +6182,7 @@ fn insert_animated_components( } } -fn collect_effects(scene: &crate::audio::model::SceneModel) -> Vec { +fn collect_effects(scene: &crate::engine::model::SceneModel) -> Vec { let alias = |k: &str| -> String { k.strip_prefix("ui_editor_properties_") .unwrap_or(k) @@ -6526,11 +6528,11 @@ fn make_effect_params(name: &str, time: f32, vals: &ShaderVals) -> Vec { /// the object's scene.json origin, before alignment. fn gpu_layer_from( renderer: &GpuSceneRenderer, - l: &crate::audio::model::resolve::Layer, + l: &crate::engine::model::resolve::Layer, w: u32, h: u32, zoom: f32, - camera3d: Option<&crate::audio::sim::camera3d::PerspectiveCamera>, + camera3d: Option<&crate::engine::sim::camera3d::PerspectiveCamera>, authored_origin: [f64; 3], ) -> SceneLayerGpu { let all_frames: Vec<&RgbaImage> = std::iter::once(&l.image) @@ -6662,7 +6664,7 @@ struct TimelineEntry { key: String, /// Base value, which transform keyframes are offsets from. base: Vec, - anim: crate::audio::sim::timeline::PropertyAnimation, + anim: crate::engine::sim::timeline::PropertyAnimation, /// Last value applied, to skip re-applying an idle animation. last: Option>, } @@ -6670,9 +6672,9 @@ struct TimelineEntry { /// Collect every animated property from the raw scene.json objects. fn build_timelines( raw_scene: &serde_json::Value, - graph: &crate::audio::model::resolve::TransformGraph, + graph: &crate::engine::model::resolve::TransformGraph, ) -> Vec { - use crate::audio::sim::timeline::PropertyAnimation; + 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() { @@ -6714,7 +6716,7 @@ fn build_timelines( .local .get(i) .copied() - .unwrap_or(crate::audio::model::resolve::Xform::IDENTITY); + .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(), @@ -6738,13 +6740,13 @@ fn build_timelines( /// transform graph use), then one instance per scripted property. fn build_script_host( dir: &Path, - scene: &crate::audio::model::scene::Scene, - graph: &crate::audio::model::resolve::TransformGraph, + scene: &crate::engine::model::scene::Scene, + graph: &crate::engine::model::resolve::TransformGraph, canvas: [u32; 2], raw_scene: &serde_json::Value, -) -> crate::audio::sim::script::ScriptHost { - use crate::audio::model::scene::{parse_value_f32, parse_value_vec3}; - use crate::audio::sim::script::{ObjectInit, ScriptHost}; +) -> 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()) { @@ -6764,7 +6766,7 @@ fn build_script_host( } let user_props: serde_json::Map = - crate::audio::properties::SceneProperties::from_project_dir(dir) + crate::engine::properties::SceneProperties::from_project_dir(dir) .iter() .map(|p| (p.name.clone(), p.value.clone())) .collect(); @@ -6780,7 +6782,7 @@ fn build_script_host( .local .get(i) .copied() - .unwrap_or(crate::audio::model::resolve::Xform::IDENTITY); + .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) => { @@ -6842,7 +6844,7 @@ fn build_script_host( ("volume", obj.volume.as_ref()), ]; for (prop, value) in props { - if let Some((script, authored)) = crate::audio::sim::script::scripted(value) { + if let Some((script, authored)) = crate::engine::sim::script::scripted(value) { host.attach(i, Some(prop), script, authored); } } @@ -6878,7 +6880,7 @@ fn build_script_host( } } for (key, value) in extras { - if let Some((script, authored)) = crate::audio::sim::script::scripted(Some(value)) { + if let Some((script, authored)) = crate::engine::sim::script::scripted(Some(value)) { let initial = value .get("value") .cloned() @@ -6886,7 +6888,7 @@ fn build_script_host( host.declare_property( i, &key, - crate::audio::sim::script::value_kind(&initial), + crate::engine::sim::script::value_kind(&initial), &initial, ); host.attach(i, Some(&key), script, authored); diff --git a/src/audio/gpu/renderer/tests.rs b/src/engine/gpu/renderer/tests.rs similarity index 92% rename from src/audio/gpu/renderer/tests.rs rename to src/engine/gpu/renderer/tests.rs index cb19e01..decd304 100644 --- a/src/audio/gpu/renderer/tests.rs +++ b/src/engine/gpu/renderer/tests.rs @@ -17,13 +17,13 @@ fn gpu_shaders_wgsl_parses_and_validates() { .expect("gpu_shaders.wgsl failed validation"); } -fn quad3d_test_camera() -> crate::audio::sim::camera3d::PerspectiveCamera { - let scene: crate::audio::model::scene::Scene = serde_json::from_str( +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::audio::sim::camera3d::PerspectiveCamera::from_scene(&scene, 1.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 @@ -90,11 +90,11 @@ fn project_quad_corners_none_when_behind_camera() { #[test] fn mesh3d_lighting_bytes_are_uniform_sized_and_match_const() { assert_eq!(MESH3D_LIGHTING_BYTES_LEN % 16, 0); - let scene: crate::audio::model::scene::Scene = serde_json::from_str( + 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::audio::sim::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).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); @@ -105,7 +105,7 @@ fn mesh3d_lighting_bytes_are_uniform_sized_and_match_const() { // slots are actually in use. let shadow_slots = vec![Some(0)]; let shadow_slot_data = vec![( - crate::audio::sim::camera3d::identity(), + 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); @@ -119,13 +119,13 @@ fn mesh3d_lighting_bytes_are_uniform_sized_and_match_const() { /// see `MESH3D_LIGHTING_BYTES_LEN`'s own layout doc). #[test] fn mesh3d_lighting_bytes_encodes_cookie_flag_and_matrix() { - let scene: crate::audio::model::scene::Scene = serde_json::from_str( + 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::audio::sim::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).unwrap(); + let cam = crate::engine::sim::camera3d::PerspectiveCamera::from_scene(&scene, 1.0).unwrap(); - let mut vp = crate::audio::sim::camera3d::identity(); + 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))); @@ -155,13 +155,13 @@ fn mesh3d_lighting_bytes_encodes_cookie_flag_and_matrix() { /// `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::audio::model::scene::Scene = serde_json::from_str( + 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::audio::sim::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); + let cam = crate::engine::sim::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); - let scene: crate::audio::model::scene::Scene = serde_json::from_str( + 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": [ @@ -204,13 +204,13 @@ fn mesh3d_lighting_bytes_encodes_spot_direction() { /// 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::audio::model::scene::Scene = serde_json::from_str( + 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::audio::sim::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); + let cam = crate::engine::sim::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); - let scene: crate::audio::model::scene::Scene = serde_json::from_str( + 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": [ @@ -252,13 +252,13 @@ fn mesh3d_lighting_bytes_encodes_directional_sentinel_and_direction() { /// not be unit-length like a spot's. #[test] fn mesh3d_lighting_bytes_encodes_tube_sentinel_and_endpoints() { - let cam_scene: crate::audio::model::scene::Scene = serde_json::from_str( + 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::audio::sim::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); + let cam = crate::engine::sim::camera3d::PerspectiveCamera::from_scene(&cam_scene, 1.0).unwrap(); - let scene: crate::audio::model::scene::Scene = serde_json::from_str( + 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": [ @@ -381,7 +381,7 @@ mod hit_tests { }; let dir = crate::workshop::find_item_dir(&target, None) .unwrap_or_else(|| std::path::PathBuf::from(&target)); - let resolved = crate::audio::model::resolve::ResolvedScene::from_directory(&dir).unwrap(); + 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()) diff --git a/src/audio/gpu/shaders/compiler.rs b/src/engine/gpu/shaders/compiler.rs similarity index 100% rename from src/audio/gpu/shaders/compiler.rs rename to src/engine/gpu/shaders/compiler.rs diff --git a/src/audio/gpu/shaders/effect_def.rs b/src/engine/gpu/shaders/effect_def.rs similarity index 100% rename from src/audio/gpu/shaders/effect_def.rs rename to src/engine/gpu/shaders/effect_def.rs diff --git a/src/audio/gpu/shaders/loader.rs b/src/engine/gpu/shaders/loader.rs similarity index 100% rename from src/audio/gpu/shaders/loader.rs rename to src/engine/gpu/shaders/loader.rs diff --git a/src/audio/gpu/shaders/mod.rs b/src/engine/gpu/shaders/mod.rs similarity index 100% rename from src/audio/gpu/shaders/mod.rs rename to src/engine/gpu/shaders/mod.rs diff --git a/src/audio/gpu/shaders/particle_def.rs b/src/engine/gpu/shaders/particle_def.rs similarity index 100% rename from src/audio/gpu/shaders/particle_def.rs rename to src/engine/gpu/shaders/particle_def.rs diff --git a/src/audio/gpu/shaders/resolver.rs b/src/engine/gpu/shaders/resolver.rs similarity index 100% rename from src/audio/gpu/shaders/resolver.rs rename to src/engine/gpu/shaders/resolver.rs diff --git a/src/audio/gpu/shaders/transpiler/mod.rs b/src/engine/gpu/shaders/transpiler/mod.rs similarity index 99% rename from src/audio/gpu/shaders/transpiler/mod.rs rename to src/engine/gpu/shaders/transpiler/mod.rs index 791e4b8..96bb247 100644 --- a/src/audio/gpu/shaders/transpiler/mod.rs +++ b/src/engine/gpu/shaders/transpiler/mod.rs @@ -1,4 +1,4 @@ -use crate::audio::model::ShaderModel; +use crate::engine::model::ShaderModel; use anyhow::{Context, Result}; use std::collections::HashMap; @@ -782,7 +782,7 @@ pub fn translate_full(model: &ShaderModel, vert_glsl: Option<&str>) -> Result, [f32; 4])> = std::collections::HashMap::new(); if let Some(v) = vert_glsl { - for meta in crate::audio::gpu::shaders::uniform_meta::parse_uniform_metadata(v) { + for meta in crate::engine::gpu::shaders::uniform_meta::parse_uniform_metadata(v) { annotated.insert( meta.uniform_name.clone(), ( diff --git a/src/audio/gpu/shaders/transpiler/tests.rs b/src/engine/gpu/shaders/transpiler/tests.rs similarity index 98% rename from src/audio/gpu/shaders/transpiler/tests.rs rename to src/engine/gpu/shaders/transpiler/tests.rs index e99f169..128a9dd 100644 --- a/src/audio/gpu/shaders/transpiler/tests.rs +++ b/src/engine/gpu/shaders/transpiler/tests.rs @@ -65,11 +65,11 @@ fn real_godrays_effect_transpiles_through_generic_pipeline() { .iter() .map(|(k, v)| (k.to_string(), *v)) .collect(); - let model = crate::audio::model::ShaderModel::from_resolved_glsl( + let model = crate::engine::model::ShaderModel::from_resolved_glsl( label.to_string(), frag_glsl, combos, - crate::audio::model::WEBlending::Normal, + crate::engine::model::WEBlending::Normal, ); if let Err(e) = translate_full(&model, Some(&vert_glsl)) { failures.push(format!("{label}: {e:#}")); @@ -531,7 +531,7 @@ fn pow_scalar_broadcast_translates() { "pow_test".to_string(), src.to_string(), HashMap::new(), - crate::audio::model::shader_model::WEBlending::Normal, + crate::engine::model::shader_model::WEBlending::Normal, ); translate(&model).expect("pow(vec3, float) should translate via _wp_pow shims"); } @@ -625,7 +625,7 @@ fn audio_spectrum_becomes_live_uniform() { "audio_test".to_string(), src.to_string(), HashMap::new(), - crate::audio::model::shader_model::WEBlending::Normal, + crate::engine::model::shader_model::WEBlending::Normal, ); let t = translate(&model).expect("audio spectrum shader should translate"); assert!( diff --git a/src/audio/gpu/shaders/uniform_meta.rs b/src/engine/gpu/shaders/uniform_meta.rs similarity index 100% rename from src/audio/gpu/shaders/uniform_meta.rs rename to src/engine/gpu/shaders/uniform_meta.rs diff --git a/src/audio/gpu/shaders/util_textures.rs b/src/engine/gpu/shaders/util_textures.rs similarity index 100% rename from src/audio/gpu/shaders/util_textures.rs rename to src/engine/gpu/shaders/util_textures.rs diff --git a/src/audio/gpu/shadow.rs b/src/engine/gpu/shadow.rs similarity index 99% rename from src/audio/gpu/shadow.rs rename to src/engine/gpu/shadow.rs index d9bccf9..89ffdaa 100644 --- a/src/audio/gpu/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::audio::model::resolve::Mesh3dLayer; -use crate::audio::sim::camera3d::{self, Mat4}; +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/audio/gpu/text.rs b/src/engine/gpu/text.rs similarity index 100% rename from src/audio/gpu/text.rs rename to src/engine/gpu/text.rs diff --git a/src/audio/media.rs b/src/engine/media.rs similarity index 100% rename from src/audio/media.rs rename to src/engine/media.rs diff --git a/src/audio/mod.rs b/src/engine/mod.rs similarity index 63% rename from src/audio/mod.rs rename to src/engine/mod.rs index fbdae4b..3924553 100644 --- a/src/audio/mod.rs +++ b/src/engine/mod.rs @@ -15,11 +15,11 @@ pub mod properties; pub mod sim; // Convenience re-exports. -pub use crate::audio::cpu::graph::SceneGraph; -pub use crate::audio::gpu::fbo::{RenderTarget, RenderTargetPool}; -pub use crate::audio::model::resolve::ResolvedScene; -pub use crate::audio::model::scene::Scene; -pub use crate::audio::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; +pub use crate::engine::cpu::graph::SceneGraph; +pub use crate::engine::gpu::fbo::{RenderTarget, RenderTargetPool}; +pub use crate::engine::model::resolve::ResolvedScene; +pub use crate::engine::model::scene::Scene; +pub use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; pub use crate::formats::pkg::Package; pub use crate::formats::tex::TexFile; pub use properties::SceneProperties; diff --git a/src/audio/model/dynamic_value.rs b/src/engine/model/dynamic_value.rs similarity index 100% rename from src/audio/model/dynamic_value.rs rename to src/engine/model/dynamic_value.rs diff --git a/src/audio/model/mesh3d.rs b/src/engine/model/mesh3d.rs similarity index 100% rename from src/audio/model/mesh3d.rs rename to src/engine/model/mesh3d.rs diff --git a/src/audio/model/mod.rs b/src/engine/model/mod.rs similarity index 100% rename from src/audio/model/mod.rs rename to src/engine/model/mod.rs diff --git a/src/audio/model/objects.rs b/src/engine/model/objects.rs similarity index 100% rename from src/audio/model/objects.rs rename to src/engine/model/objects.rs diff --git a/src/audio/model/parser.rs b/src/engine/model/parser.rs similarity index 99% rename from src/audio/model/parser.rs rename to src/engine/model/parser.rs index 9952f1c..3fa500c 100644 --- a/src/audio/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::audio::model::scene; +use crate::engine::model::scene; use anyhow::Result; use std::collections::HashMap; diff --git a/src/audio/model/property.rs b/src/engine/model/property.rs similarity index 100% rename from src/audio/model/property.rs rename to src/engine/model/property.rs diff --git a/src/audio/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs similarity index 97% rename from src/audio/model/resolve/mod.rs rename to src/engine/model/resolve/mod.rs index 4e2813d..98e81e0 100644 --- a/src/audio/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -3,9 +3,9 @@ use image::RgbaImage; use std::collections::HashMap; use std::path::Path; -use crate::audio::model::scene::{Scene, SceneObject}; -use crate::audio::properties::SceneProperties; -use crate::audio::sim::particle; +use crate::engine::model::scene::{Scene, SceneObject}; +use crate::engine::properties::SceneProperties; +use crate::engine::sim::particle; use crate::formats::pkg::Package; use crate::formats::tex::TexFile; @@ -47,7 +47,7 @@ pub struct ResolvedScene { /// a flat quad — spheres, skyboxes and cylinders can't be billboarded. pub struct Mesh3dLayer { pub name: String, - pub mesh: crate::audio::model::mesh3d::Mesh3d, + 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). @@ -179,7 +179,7 @@ pub struct Layer { 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>, + 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>>, @@ -212,7 +212,7 @@ struct LoadedImage { /// 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>, + 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>>, @@ -761,7 +761,7 @@ impl ResolvedScene { dst[1] as f32 / 255.0, dst[2] as f32 / 255.0, ]; - let out = crate::audio::gpu::blend::apply_blending( + let out = crate::engine::gpu::blend::apply_blending( layer.blend_mode, dest_rgb, src_rgb, @@ -814,8 +814,8 @@ impl ResolvedScene { /// 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::audio::model::DynamicValue; - match crate::audio::model::json_to_animated(v).value { + 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], @@ -871,13 +871,13 @@ fn select_spritesheet_frame(obj: &SceneObject, loaded: &mut LoadedImage) { .and_then(|v| v.get("script")) .and_then(|s| s.as_str()) .and_then(|script| { - let init = crate::audio::sim::script::ObjectInit { + let init = crate::engine::sim::script::ObjectInit { visible: true, alpha: 1.0, frame_count: loaded.sprite_frames.len() as u32, ..Default::default() }; - crate::audio::sim::script::evaluate_once("visible", script, &init, None).frame + crate::engine::sim::script::evaluate_once("visible", script, &init, None).frame }) .unwrap_or(0) .min(loaded.sprite_frames.len() as u32 - 1); @@ -965,14 +965,14 @@ fn layer_from_object( if let Some(rt) = std::sync::Arc::get_mut(rt) { let n = rt.model.animations.len(); if n > 0 { - let layers: Vec = entries + 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::audio::model::scene::parse_value_bool) + .and_then(crate::engine::model::scene::parse_value_bool) .unwrap_or(true) }) .map(|(i, e)| { @@ -996,7 +996,7 @@ fn layer_from_object( i.min(n - 1) } }); - crate::audio::sim::puppet::AnimLayer { + 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, @@ -1020,7 +1020,7 @@ fn layer_from_object( let angles_animated = obj .angles .as_ref() - .map(crate::audio::model::json_to_animated); + .map(crate::engine::model::json_to_animated); let angles3 = angles_animated .as_ref() .and_then(|v| v.as_vec3()) @@ -1030,7 +1030,7 @@ fn layer_from_object( let scale_animated = obj .scale .as_ref() - .map(crate::audio::model::json_to_animated); + .map(crate::engine::model::json_to_animated); let scale = scale_animated .as_ref() .and_then(|v| v.as_vec3()) @@ -1039,7 +1039,7 @@ fn layer_from_object( let alpha_animated = obj .alpha .as_ref() - .map(crate::audio::model::json_to_animated); + .map(crate::engine::model::json_to_animated); let alpha = alpha_animated .as_ref() .and_then(|v| v.as_float()) @@ -1047,13 +1047,13 @@ fn layer_from_object( let color = obj .color .as_ref() - .map(crate::audio::model::json_to_animated) + .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::audio::model::json_to_animated) + .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 @@ -1368,7 +1368,7 @@ fn text_layer_from_object( (Some(script), _) => { // One-shot load-time evaluation so the layer rasterizes real // content (and sizes itself correctly) from frame 0. - let init = crate::audio::sim::script::ObjectInit { + let init = crate::engine::sim::script::ObjectInit { name: obj.name.clone(), id: obj.id, visible: true, @@ -1376,7 +1376,7 @@ fn text_layer_from_object( text: Some(static_str.clone()), ..Default::default() }; - crate::audio::sim::script::evaluate_once( + crate::engine::sim::script::evaluate_once( "text", script, &init, @@ -1403,7 +1403,7 @@ fn text_layer_from_object( let scale = obj .scale .as_ref() - .and_then(|v| crate::audio::model::json_to_animated(v).as_vec3()) + .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 @@ -1426,18 +1426,18 @@ fn text_layer_from_object( point_size * compensate } .clamp(1.0, 1024.0); - let font_data = crate::audio::gpu::text::resolve_font_data(obj.font.as_deref(), dir, pkg)?; + 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::audio::model::scene::parse_value_bool) + .and_then(crate::engine::model::scene::parse_value_bool) .unwrap_or(false) }; let text_str = match obj .maxwidth .as_ref() - .and_then(crate::audio::model::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .filter(|w| *w > 0.0) .filter(|_| gate(&obj.limitwidth)) { @@ -1445,11 +1445,11 @@ fn text_layer_from_object( let rows = obj .maxrows .as_ref() - .and_then(crate::audio::model::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .filter(|_| gate(&obj.limitrows)) .unwrap_or(0.0) .max(0.0) as usize; - crate::audio::gpu::text::wrap_text( + crate::engine::gpu::text::wrap_text( &font_data, &text_str, raster_px, @@ -1460,7 +1460,7 @@ fn text_layer_from_object( } None => text_str, }; - let image = crate::audio::gpu::text::rasterize(&font_data, &text_str, raster_px)?; + 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` @@ -1478,18 +1478,18 @@ fn text_layer_from_object( let text_color = obj .color .as_ref() - .and_then(|v| crate::audio::model::scene::parse_value_vec3(v)) + .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::audio::model::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .unwrap_or(1.0); let bg = obj .backgroundcolor .as_ref() - .and_then(|v| crate::audio::model::scene::parse_value_vec3(v)) + .and_then(|v| crate::engine::model::scene::parse_value_vec3(v)) .map(|c| { [ c[0] as f32 * bright, @@ -1501,11 +1501,11 @@ fn text_layer_from_object( let pad = obj .padding .as_ref() - .and_then(crate::audio::model::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .unwrap_or(0.0) .max(0.0) as u32; ( - crate::audio::gpu::text::with_background(&image, text_color, bg, pad), + crate::engine::gpu::text::with_background(&image, text_color, bg, pad), true, ) } else { @@ -1570,7 +1570,7 @@ fn mesh3d_layer_from_object( // 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::audio::model::mesh3d::parse(&bytes)?; + 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())) @@ -1605,12 +1605,12 @@ fn mesh3d_layer_from_object( let scale = obj .scale .as_ref() - .and_then(|v| crate::audio::model::json_to_animated(v).as_vec3()) + .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::audio::model::json_to_animated(v).as_vec3()) + .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(); @@ -1859,7 +1859,7 @@ pub(crate) fn resolve_static_texture( /// 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::audio::gpu::shaders::loader::find_we_assets_dir()?; + let dir = crate::engine::gpu::shaders::loader::find_we_assets_dir()?; std::fs::read(dir.join(rel_path)).ok() } @@ -1957,7 +1957,7 @@ fn pass_constant_color(pass: &serde_json::Value) -> Option<[u8; 3]> { .get("color") .or_else(|| consts.get("Color"))? .as_str()?; - let c = crate::audio::cpu::effect::parse_color(s); + 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, @@ -2116,7 +2116,7 @@ fn apply_puppet_mesh( tracing::warn!(target: "scene", "puppet mesh '{puppet_path}' not found — drawing raw atlas"); return plain(atlas); }; - let Some(model) = crate::audio::sim::puppet::parse_model(&mdl_bytes) else { + 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); }; @@ -2131,7 +2131,7 @@ fn apply_puppet_mesh( // 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::audio::sim::puppet::PuppetRuntime { + let runtime = crate::engine::sim::puppet::PuppetRuntime { model, atlas, layers: Vec::new(), @@ -2139,7 +2139,7 @@ fn apply_puppet_mesh( let assembled = if runtime.model.has_animation() { runtime.render_at(0.0, runtime.atlas.width(), runtime.atlas.height()) } else { - crate::audio::sim::puppet::rasterize( + crate::engine::sim::puppet::rasterize( &runtime.model.mesh, &runtime.atlas, runtime.atlas.width(), @@ -2279,14 +2279,14 @@ pub fn obj_local_xform(obj: &SceneObject) -> Xform { let r = obj .angles .as_ref() - .map(crate::audio::model::json_to_animated) + .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::audio::model::scene::parse_value_vec3) + .and_then(crate::engine::model::scene::parse_value_vec3) .unwrap_or([1.0, 1.0, 1.0]); Xform { t, r, s } } @@ -2305,7 +2305,7 @@ fn parent_index( if obj .disablepropagation .as_ref() - .and_then(crate::audio::model::scene::parse_value_bool) + .and_then(crate::engine::model::scene::parse_value_bool) .unwrap_or(false) { return None; diff --git a/src/audio/model/resolve/tests.rs b/src/engine/model/resolve/tests.rs similarity index 98% rename from src/audio/model/resolve/tests.rs rename to src/engine/model/resolve/tests.rs index 8788e6a..758ad42 100644 --- a/src/audio/model/resolve/tests.rs +++ b/src/engine/model/resolve/tests.rs @@ -140,12 +140,12 @@ fn wrap_limits_respect_their_gates() { let active = |o: &SceneObject| { o.maxwidth .as_ref() - .and_then(crate::audio::model::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .filter(|w| *w > 0.0) .filter(|_| { o.limitwidth .as_ref() - .and_then(crate::audio::model::scene::parse_value_bool) + .and_then(crate::engine::model::scene::parse_value_bool) .unwrap_or(false) }) .is_some() diff --git a/src/audio/model/scene.rs b/src/engine/model/scene.rs similarity index 98% rename from src/audio/model/scene.rs rename to src/engine/model/scene.rs index 7a137bc..436a276 100644 --- a/src/audio/model/scene.rs +++ b/src/engine/model/scene.rs @@ -61,7 +61,7 @@ impl Scene { pub fn lights( &self, ) -> Vec<( - crate::audio::gpu::lighting::Light, + crate::engine::gpu::lighting::Light, bool, Option<(f32, f32)>, Option, @@ -92,12 +92,12 @@ impl Scene { .map(|a| [a[0] as f32, a[1] as f32, a[2] as f32]) .unwrap_or([0.0; 3]); let direction = - || crate::audio::sim::camera3d::rotate_euler([0.0, 0.0, -1.0], angles); + || 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::audio::gpu::lighting::Light::Directional { + crate::engine::gpu::lighting::Light::Directional { direction: direction(), color, intensity, @@ -120,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::audio::gpu::lighting::Light::Spot { + crate::engine::gpu::lighting::Light::Spot { origin, direction: direction(), exponent, @@ -131,14 +131,14 @@ impl Scene { } } else if matches!(o.light_type(), LightType::Tube) { let (origin_a, origin_b) = o.tube_endpoints(); - crate::audio::gpu::lighting::Light::Tube { + crate::engine::gpu::lighting::Light::Tube { origin_a, origin_b, color, intensity, } } else { - crate::audio::gpu::lighting::Light::Point { + crate::engine::gpu::lighting::Light::Point { origin, color, intensity, @@ -310,8 +310,8 @@ impl General { /// Returns `None` when both distance and height fog density are 0 / unset /// (fog off). The caller (render.rs / gpu_renderer.rs) should only run /// the fog pass when this returns `Some`. - pub fn fog(&self) -> Option { - use crate::audio::gpu::lighting::Fog; + pub fn fog(&self) -> Option { + use crate::engine::gpu::lighting::Fog; let d = self .fog_distance .as_ref() @@ -1212,7 +1212,7 @@ mod tests { let lights = scene.lights(); assert_eq!(lights.len(), 1); match &lights[0].0 { - crate::audio::gpu::lighting::Light::Spot { + crate::engine::gpu::lighting::Light::Spot { origin, intensity, exponent, @@ -1242,7 +1242,7 @@ mod tests { assert_eq!(lights.len(), 1); assert!(matches!( lights[0].0, - crate::audio::gpu::lighting::Light::Point { .. } + crate::engine::gpu::lighting::Light::Point { .. } )); } @@ -1258,7 +1258,7 @@ mod tests { .unwrap(); assert!(matches!( scene.lights()[0].0, - crate::audio::gpu::lighting::Light::Point { .. } + crate::engine::gpu::lighting::Light::Point { .. } )); } @@ -1278,7 +1278,7 @@ mod tests { let lights = scene.lights(); assert_eq!(lights.len(), 1); match &lights[0].0 { - crate::audio::gpu::lighting::Light::Directional { + crate::engine::gpu::lighting::Light::Directional { direction, intensity, .. @@ -1301,7 +1301,7 @@ mod tests { .unwrap(); assert!(matches!( scene.lights()[0].0, - crate::audio::gpu::lighting::Light::Spot { .. } + crate::engine::gpu::lighting::Light::Spot { .. } )); } @@ -1321,11 +1321,11 @@ mod tests { assert_eq!(lights.len(), 2); assert!(matches!( lights[0].0, - crate::audio::gpu::lighting::Light::Point { .. } + crate::engine::gpu::lighting::Light::Point { .. } )); assert!(matches!( lights[1].0, - crate::audio::gpu::lighting::Light::Point { .. } + crate::engine::gpu::lighting::Light::Point { .. } )); } @@ -1411,7 +1411,7 @@ mod tests { let lights = scene.lights(); assert_eq!(lights.len(), 1); match &lights[0].0 { - crate::audio::gpu::lighting::Light::Tube { + crate::engine::gpu::lighting::Light::Tube { origin_a, origin_b, intensity, @@ -1433,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::audio::gpu::lighting::Light::Tube { + crate::engine::gpu::lighting::Light::Tube { origin_a, origin_b, .. } => { assert_eq!(origin_a, origin_b); diff --git a/src/audio/model/shader_model.rs b/src/engine/model/shader_model.rs similarity index 99% rename from src/audio/model/shader_model.rs rename to src/engine/model/shader_model.rs index d0fa3ca..457eded 100644 --- a/src/audio/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::audio::gpu::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/audio/playback_gate.rs b/src/engine/playback_gate.rs similarity index 100% rename from src/audio/playback_gate.rs rename to src/engine/playback_gate.rs diff --git a/src/audio/properties.rs b/src/engine/properties.rs similarity index 100% rename from src/audio/properties.rs rename to src/engine/properties.rs diff --git a/src/audio/sim/camera3d.rs b/src/engine/sim/camera3d.rs similarity index 98% rename from src/audio/sim/camera3d.rs rename to src/engine/sim/camera3d.rs index 31339ec..c6716fc 100644 --- a/src/audio/sim/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::audio::model::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. @@ -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::audio::model::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::audio::model::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::audio::model::scene::parse_value_f32) + .and_then(crate::engine::model::scene::parse_value_f32) .unwrap_or(10000.0) .max(near * 2.0); diff --git a/src/audio/sim/camera_dynamics.rs b/src/engine/sim/camera_dynamics.rs similarity index 98% rename from src/audio/sim/camera_dynamics.rs rename to src/engine/sim/camera_dynamics.rs index 22e59b3..57a6ebd 100644 --- a/src/audio/sim/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::audio::model::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/audio/sim/mod.rs b/src/engine/sim/mod.rs similarity index 100% rename from src/audio/sim/mod.rs rename to src/engine/sim/mod.rs diff --git a/src/audio/sim/noise.rs b/src/engine/sim/noise.rs similarity index 100% rename from src/audio/sim/noise.rs rename to src/engine/sim/noise.rs diff --git a/src/audio/sim/particle/mod.rs b/src/engine/sim/particle/mod.rs similarity index 99% rename from src/audio/sim/particle/mod.rs rename to src/engine/sim/particle/mod.rs index a817a53..6d80fcf 100644 --- a/src/audio/sim/particle/mod.rs +++ b/src/engine/sim/particle/mod.rs @@ -1224,7 +1224,7 @@ impl RemapParams { max_life: f32, ) -> f32 { if self.input == RemapField::None || self.transform.is_noise() { - use crate::audio::sim::noise::fbm_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 { @@ -2066,7 +2066,7 @@ impl ParticleSystem { p.y * t.noise_scale, 0.0, ]; - let curl = crate::audio::sim::noise::curl_noise(noise_pos); + 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; @@ -2622,7 +2622,7 @@ impl ParticleSystem { spawn_y * 0.1 + time_shift + phase * 0.7, time_shift + phase * 1.3, ]; - let curl = crate::audio::sim::noise::curl_noise(sample); + 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 diff --git a/src/audio/sim/particle/tests.rs b/src/engine/sim/particle/tests.rs similarity index 100% rename from src/audio/sim/particle/tests.rs rename to src/engine/sim/particle/tests.rs diff --git a/src/audio/sim/puppet.rs b/src/engine/sim/puppet.rs similarity index 100% rename from src/audio/sim/puppet.rs rename to src/engine/sim/puppet.rs diff --git a/src/audio/sim/scenescript_runtime.js b/src/engine/sim/scenescript_runtime.js similarity index 100% rename from src/audio/sim/scenescript_runtime.js rename to src/engine/sim/scenescript_runtime.js diff --git a/src/audio/sim/script.rs b/src/engine/sim/script.rs similarity index 100% rename from src/audio/sim/script.rs rename to src/engine/sim/script.rs diff --git a/src/audio/sim/timeline.rs b/src/engine/sim/timeline.rs similarity index 100% rename from src/audio/sim/timeline.rs rename to src/engine/sim/timeline.rs diff --git a/src/formats/assets.rs b/src/formats/assets.rs index 85d1d00..3b1018b 100644 --- a/src/formats/assets.rs +++ b/src/formats/assets.rs @@ -53,7 +53,7 @@ impl AssetStore { root: root.to_path_buf(), dependency, packages, - assets_dir: crate::audio::gpu::shaders::loader::find_we_assets_dir(), + assets_dir: crate::engine::gpu::shaders::loader::find_we_assets_dir(), }) } diff --git a/src/lib.rs b/src/lib.rs index 241bd38..b8262ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ pub mod application; -pub mod audio; #[cfg(target_os = "linux")] pub mod daemon; +pub mod engine; pub mod formats; pub mod logging; pub mod platform; diff --git a/src/logging.rs b/src/logging.rs index c39fbbf..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::audio::cpu::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 cac2144..1f87e88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,11 +4,11 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use wp_engine::application::{ApplicationContext, WallpaperApplication}; use wp_engine::workshop::{self, Wallpaper}; -use wp_engine::{audio, platform, ui}; +use wp_engine::{engine, platform, ui}; #[derive(Parser)] #[command( - name = "wp-audio", + 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." @@ -141,7 +141,7 @@ enum Command { frames: usize, }, /// Manage persisted settings: per-wallpaper property overrides, screen - /// assignments, and playlists (~/.config/wp-audio/settings.json) + /// assignments, and playlists (~/.config/wp-engine/settings.json) Config { #[command(subcommand)] action: ConfigAction, @@ -166,7 +166,7 @@ enum Command { #[command(subcommand)] action: PlaylistAction, }, - /// Start `wp-audio run` at login: on, off, or show the current state + /// 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, @@ -202,7 +202,7 @@ enum ConfigAction { SetScreen { screen: String, id: String }, /// Remove a screen's saved wallpaper assignment UnsetScreen { screen: String }, - /// Set the fallback wallpaper `wp-audio run` uses with no screen assigned + /// 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 }, @@ -228,7 +228,7 @@ enum ConfigAction { #[arg(long, value_delimiter = ',')] times: Vec, }, - /// Make `wp-audio run` play this playlist (omit NAME to clear) + /// Make `wp-engine run` play this playlist (omit NAME to clear) SetActivePlaylist { name: Option }, /// Remove a playlist RemovePlaylist { name: String }, @@ -260,7 +260,7 @@ fn main() { 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-audio starting"); + tracing::debug!(target: "cli", verbosity = cli.verbose, "wp-engine starting"); let result = match cli.command { // No subcommand → open GUI @@ -332,14 +332,14 @@ fn main() { fn run_ui() -> Result<()> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_title("wp-audio") + .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-audio", + "wp-engine", options, Box::new(|cc| Ok(Box::new(ui::WpApp::new(cc)))), ) @@ -476,7 +476,7 @@ fn apply_saved_settings( if save { let mut saved_anything = false; for arg in cli_properties { - let (name, value) = audio::properties::parse_property_arg(arg); + let (name, value) = engine::properties::parse_property_arg(arg); settings.set_property(id_or_path, &name, &value); saved_anything = true; } @@ -500,7 +500,7 @@ fn apply_saved_settings( 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 = audio::properties::list_properties(&w.path)?; + let all_props = engine::properties::list_properties(&w.path)?; if all_props.is_empty() { println!( "\"{}\" declares no user-configurable properties.", @@ -548,7 +548,7 @@ fn cmd_list_properties(id: &str) -> Result<()> { decorative.len() ); } - println!("\nOverride with: wp-audio set {id} --set-property NAME=VALUE"); + println!("\nOverride with: wp-engine set {id} --set-property NAME=VALUE"); Ok(()) } @@ -573,7 +573,7 @@ fn cmd_config(action: ConfigAction) -> Result<()> { ConfigAction::SetProperty { id, properties } => { let mut names = Vec::new(); for arg in &properties { - let (name, value) = audio::properties::parse_property_arg(arg); + let (name, value) = engine::properties::parse_property_arg(arg); println!("{id}: {name} = {value}"); settings.set_property(&id, &name, &value); names.push(name); @@ -666,14 +666,14 @@ fn cmd_config(action: ConfigAction) -> Result<()> { if !settings.playlists.contains_key(n) { return Err(anyhow!("no playlist named '{n}'")); } - println!("`wp-audio run` plays playlist '{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::audio::playback_gate::PlaybackAction::parse(&action).ok_or_else( + 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) { @@ -764,7 +764,7 @@ fn resolve_scene_dir(id_or_path: &str) -> Result { wp_engine::render::WallpaperContent::preset_base_dir(&dir) } -/// Runs using persisted settings (`wp-audio config` to set them up first): +/// 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 @@ -772,7 +772,7 @@ fn resolve_scene_dir(id_or_path: &str) -> Result { /// else `default_background`, else the first playlist's first item. /// /// Honest scope note: property overrides are still process-global (see -/// `audio::properties`), so only the *default* background's saved +/// `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 @@ -801,8 +801,8 @@ fn cmd_run(foreground: bool) -> Result<()> { }) .ok_or_else(|| { anyhow!( - "no saved wallpaper — use `wp-audio config set-screen`/`set-default`/\ - `set-playlist` first, or `wp-audio set ` directly" + "no saved wallpaper — use `wp-engine config set-screen`/`set-default`/\ + `set-playlist` first, or `wp-engine set ` directly" ) })? .clone(); @@ -858,13 +858,13 @@ fn launch(context: ApplicationContext, title: Option, foreground: bool) 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-audio stop` removes it."); + println!("Wallpaper active (background daemon). `wp-engine stop` removes it."); return Ok(()); } if daemon::is_running() { eprintln!( - "warning: a wp-audio daemon is also running — both will draw the wallpaper \ - (`wp-audio stop` stops it)" + "warning: a wp-engine daemon is also running — both will draw the wallpaper \ + (`wp-engine stop` stops it)" ); } } @@ -1021,7 +1021,7 @@ fn cmd_set_file( } fn cmd_pkg_info(path: &std::path::Path, dump: Option<&std::path::Path>) -> Result<()> { - let pkg = audio::Package::from_file(path)?; + let pkg = engine::Package::from_file(path)?; println!("Files : {}", pkg.len()); println!("Empty : {}", pkg.is_empty()); @@ -1070,7 +1070,7 @@ fn cmd_pkg_info(path: &std::path::Path, dump: Option<&std::path::Path>) -> Resul fn cmd_tex_info(path: &std::path::Path, save: Option<&std::path::Path>) -> Result<()> { let data = std::fs::read(path)?; - let tex = audio::TexFile::parse(&data)?; + 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); @@ -1095,16 +1095,16 @@ fn cmd_render_scene( ) -> Result<()> { let overrides = properties .iter() - .map(|p| audio::properties::parse_property_arg(p)) + .map(|p| engine::properties::parse_property_arg(p)) .collect(); - audio::properties::set_global_overrides(overrides); + engine::properties::set_global_overrides(overrides); let dir = resolve_scene_dir(id_or_path)?; println!("Loading scene from {}...", dir.display()); - let graph = audio::SceneGraph::from_directory(&dir)?; + let graph = engine::SceneGraph::from_directory(&dir)?; println!("Scene graph: {}", graph.stats().summary()); - let scene = audio::ResolvedScene::from_directory(&dir)?; + let scene = engine::ResolvedScene::from_directory(&dir)?; println!( "Rendering {}x{} with {} layers...", scene.width, @@ -1143,10 +1143,10 @@ fn cmd_render_frame( seconds: f32, properties: Vec, ) -> Result<()> { - audio::properties::set_global_overrides( + engine::properties::set_global_overrides( properties .iter() - .map(|p| audio::properties::parse_property_arg(p)) + .map(|p| engine::properties::parse_property_arg(p)) .collect(), ); let path = resolve_id_or_path(id_or_path)?; @@ -1183,7 +1183,7 @@ fn cmd_test_scene(id_or_path: &str, num_frames: usize) -> Result<()> { let (tx, rx) = sync_channel::>(2); let render_dir = dir.clone(); let handle = std::thread::spawn(move || { - if let Err(e) = audio::gpu::renderer::gpu_scene_render_loop(&render_dir, &tx, 30.0) { + if let Err(e) = engine::gpu::renderer::gpu_scene_render_loop(&render_dir, &tx, 30.0) { tracing::error!(target: "render", "gpu scene error: {e}"); } }); @@ -1286,7 +1286,7 @@ fn cmd_preview_scene(id_or_path: &str, width: u32, height: u32) -> Result<()> { let (tx, rx) = sync_channel::>(2); let render_dir = dir.clone(); let handle = std::thread::spawn(move || { - if let Err(e) = audio::gpu::renderer::gpu_scene_render_loop(&render_dir, &tx, 30.0) { + if let Err(e) = engine::gpu::renderer::gpu_scene_render_loop(&render_dir, &tx, 30.0) { tracing::error!(target: "render", "gpu scene error: {e}"); } }); @@ -1299,7 +1299,7 @@ fn cmd_preview_scene(id_or_path: &str, width: u32, height: u32) -> Result<()> { ); let mut window = Window::new( - "wp-audio preview", + "wp-engine preview", width as usize, height as usize, WindowOptions::default(), diff --git a/src/platform/audio.rs b/src/platform/audio.rs index d4c35fe..c6644f9 100644 --- a/src/platform/audio.rs +++ b/src/platform/audio.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::audio::audio`] on top of cpal. The one genuinely +//! live in [`crate::engine::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: //! diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index c81dad6..c749e62 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -33,7 +33,7 @@ use winit::window::{Window, WindowId}; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; use super::GpuDevice; -use crate::audio::gpu::renderer::GpuSceneInstance; +use crate::engine::gpu::renderer::GpuSceneInstance; use crate::render::{ScreenContent, ScreenSettings, WallpaperContent}; // ── Platform implementation ─────────────────────────────────────────────────── diff --git a/src/platform/wayland/mod.rs b/src/platform/wayland/mod.rs index 6b7a383..d50f3c8 100644 --- a/src/platform/wayland/mod.rs +++ b/src/platform/wayland/mod.rs @@ -48,7 +48,7 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{ }; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; -use crate::audio::playback_gate::PlaybackAction; +use crate::engine::playback_gate::PlaybackAction; use crate::{ platform, render::session::{ @@ -414,7 +414,7 @@ impl WallpaperState { // `tick_pause_watchdog` notices the condition clearing and restarts // the callback chain. let action = self.playback_action(Instant::now()); - crate::audio::audio::set_muted(action >= PlaybackAction::Mute); + crate::engine::audio::set_muted(action >= PlaybackAction::Mute); if action.halts() { if !self.paused { self.notify_web(crate::render::web::WebInputEvent::Paused(true)); @@ -434,7 +434,7 @@ impl WallpaperState { let _ = tx.try_send(crate::render::web::WebInputEvent::Fps(fps)); } } - crate::audio::audio::set_master_volume(self.settings.default.lock().unwrap().volume); + crate::engine::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. @@ -1532,7 +1532,7 @@ impl WallpaperState { fn tick_pause_watchdog(&mut self) { let was_paused = self.paused; let action = self.playback_action(Instant::now()); - crate::audio::audio::set_muted(action >= PlaybackAction::Mute); + crate::engine::audio::set_muted(action >= PlaybackAction::Mute); if !was_paused && action.halts() { self.notify_web(crate::render::web::WebInputEvent::Paused(true)); } diff --git a/src/platform/x11/mod.rs b/src/platform/x11/mod.rs index 05ca890..dca90e9 100644 --- a/src/platform/x11/mod.rs +++ b/src/platform/x11/mod.rs @@ -31,7 +31,7 @@ use x11rb::protocol::xproto::{ use x11rb::protocol::{shape, xfixes::ConnectionExt as _}; use x11rb::rust_connection::RustConnection; -use crate::audio::playback_gate::PlaybackAction; +use crate::engine::playback_gate::PlaybackAction; use x11rb::wrapper::ConnectionExt as _; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; @@ -646,8 +646,8 @@ fn wallpaper_loop( } } } - crate::audio::audio::set_muted(action >= PlaybackAction::Mute); - crate::audio::audio::set_master_volume(settings.default.lock().unwrap().volume); + crate::engine::audio::set_muted(action >= PlaybackAction::Mute); + crate::engine::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. diff --git a/src/render/content.rs b/src/render/content.rs index cfd3b51..2a001b5 100644 --- a/src/render/content.rs +++ b/src/render/content.rs @@ -140,7 +140,7 @@ impl WallpaperContent { ) })?; if let Some(preset) = &project.preset { - crate::audio::properties::register_preset(&parent, dir, preset); + crate::engine::properties::register_preset(&parent, dir, preset); } tracing::info!(target: "content", "preset {} → base item {}", dir.display(), parent.display()); Ok(parent) diff --git a/src/render/frame.rs b/src/render/frame.rs index 6ec70ef..3520c0a 100644 --- a/src/render/frame.rs +++ b/src/render/frame.rs @@ -25,7 +25,7 @@ pub enum FrameSource { /// 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,13 +52,13 @@ impl FrameSource { thread::spawn(move || { let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - crate::audio::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::audio::cpu::animated::scene_render_loop( + 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,7 +71,7 @@ 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::audio::cpu::animated::scene_render_loop( + 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,7 +106,7 @@ impl FrameSource { } WallpaperContent::Video { path } => { let (tx, rx) = sync_channel::>(2); - let audio = crate::audio::audio::StreamingAudio::start(&path); + let audio = crate::engine::audio::StreamingAudio::start(&path); thread::spawn(move || { if let Err(e) = super::ffmpeg::video_decode_loop(&path, &tx) { diff --git a/src/render/session.rs b/src/render/session.rs index f9ad763..8451474 100644 --- a/src/render/session.rs +++ b/src/render/session.rs @@ -14,9 +14,9 @@ use anyhow::Result; use super::web::{WebInputEvent, WebOptions}; use super::{FrameSource, RenderSettings, WallpaperContent}; -use crate::audio::gpu::renderer::GpuSceneInstance; -use crate::audio::media::{MediaWatcher, PlaybackState as MediaState}; -use crate::audio::playback_gate::{self, PlaybackAction, PlaybackPolicy, PlaybackState}; +use crate::engine::gpu::renderer::GpuSceneInstance; +use crate::engine::media::{MediaWatcher, PlaybackState as MediaState}; +use crate::engine::playback_gate::{self, PlaybackAction, PlaybackPolicy, PlaybackState}; use crate::platform::power::{self, LockWatcher}; /// How often the playback conditions are re-checked. Each check is real I/O diff --git a/src/render/web.rs b/src/render/web.rs index c3c3b01..e027f90 100644 --- a/src/render/web.rs +++ b/src/render/web.rs @@ -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`. #[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::audio::audio::AudioCapture::start(); + audio = crate::engine::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::audio::media::MediaWatcher::start(); + media = crate::engine::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::audio::audio::AudioSpectrum) -> String { + fn audio_push_script(spectrum: &crate::engine::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::audio::media::MediaInfo) -> String { + fn media_push_script(info: &crate::engine::media::MediaInfo) -> String { let json_str = |s: &str| serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()); let art_url = info .art_url @@ -912,7 +912,7 @@ mod imp { return String::new(); }; - let resolved = crate::audio::properties::SceneProperties::from_project_dir(dir); + let resolved = crate::engine::properties::SceneProperties::from_project_dir(dir); for (name, decl) in props.iter_mut() { if let Some(value) = resolved.get(name) { decl["value"] = value.clone(); diff --git a/src/settings.rs b/src/settings.rs index 74fe36a..af64f7d 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -53,7 +53,7 @@ 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::audio::playback_gate::PlaybackPolicy, + pub playback: crate::engine::playback_gate::PlaybackPolicy, /// Frame-rate cap for animated wallpapers; `None` = the default (30). #[serde(default)] pub fps: Option, diff --git a/src/ui/config.rs b/src/ui/config.rs index 6a3f2b5..24d5234 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -4,8 +4,8 @@ //! `settings.json`, the same file `wp-engine config` writes. use super::{StatusMsg, WpApp, DANGER, TEXT_MUTED}; -use crate::audio::playback_gate::PlaybackAction; -use crate::audio::properties::SceneProperty; +use crate::engine::playback_gate::PlaybackAction; +use crate::engine::properties::SceneProperty; use crate::platform::RenderQuality; use crate::render::ScaleMode; use crate::settings::{Playlist, WpSettings}; @@ -158,7 +158,7 @@ impl WpApp { /// 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::audio::properties::list_properties(&self.wallpapers[idx].path) + let props = crate::engine::properties::list_properties(&self.wallpapers[idx].path) .unwrap_or_default(); self.prop_editor = Some(PropertyEditor { idx, @@ -232,11 +232,11 @@ impl WpApp { let raw = saved.get(&p.name)?; Some(( p.name.clone(), - crate::audio::properties::convert_override(p, raw), + crate::engine::properties::convert_override(p, raw), )) }) .collect(); - crate::audio::properties::visible_rows(props, &values) + crate::engine::properties::visible_rows(props, &values) } /// Draws the rows WE would show; returns whether a value was changed. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index cd20a71..6bd668d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -508,7 +508,7 @@ impl WpApp { display_title: String, properties: HashMap, ) { - crate::audio::properties::set_global_overrides(properties); + crate::engine::properties::set_global_overrides(properties); let old_renderer = self.renderer.take(); let settings = Arc::clone(&self.settings); let (tx, rx) = mpsc::channel(); diff --git a/tests/effect.rs b/tests/effect.rs index 5a905d5..c1a47a6 100644 --- a/tests/effect.rs +++ b/tests/effect.rs @@ -1,4 +1,4 @@ -use wp_engine::audio::cpu::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 d792dbd..c69e663 100644 --- a/tests/properties.rs +++ b/tests/properties.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; -use wp_engine::audio::model::scene::Scene; -use wp_engine::audio::properties::{parse_property_arg, SceneProperties}; -use wp_engine::audio::sim::camera_dynamics::CameraDynamics; +use wp_engine::engine::model::scene::Scene; +use wp_engine::engine::properties::{parse_property_arg, SceneProperties}; +use wp_engine::engine::sim::camera_dynamics::CameraDynamics; fn props_from_project(project: serde_json::Value, overrides: &[(&str, &str)]) -> SceneProperties { // Build via a temp dir so the public loader path is exercised. @@ -177,7 +177,7 @@ fn static_scene_has_inactive_dynamics() { // Regression test for the copybackground bug: objects flagged // `copybackground` still carry a real image and must load it (the reference -// audio ignores the flag). Uses a real workshop scene when available. +// engine ignores the flag). Uses a real workshop scene when available. #[test] fn copybackground_objects_load_their_image() { let dir = std::path::PathBuf::from(std::env::var("HOME").unwrap()) @@ -185,7 +185,7 @@ fn copybackground_objects_load_their_image() { if !dir.exists() { return; // fixture not installed on this machine } - let resolved = wp_engine::audio::ResolvedScene::from_directory(&dir).unwrap(); + let resolved = wp_engine::engine::ResolvedScene::from_directory(&dir).unwrap(); let layer = resolved .layers .iter() From 91174360e19946a216c31a6fdc9a1aa931245ca4 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:20:22 -0700 Subject: [PATCH 038/117] refactor: move engine/audio.rs to services/audio.rs --- src/engine/gpu/renderer/mod.rs | 16 ++++++++-------- src/engine/mod.rs | 1 - src/lib.rs | 1 + src/platform/audio.rs | 2 +- src/platform/wayland/mod.rs | 6 +++--- src/platform/x11/mod.rs | 4 ++-- src/render/frame.rs | 4 ++-- src/render/web.rs | 6 +++--- src/{engine => services}/audio.rs | 0 src/services/mod.rs | 5 +++++ 10 files changed, 25 insertions(+), 20 deletions(-) rename src/{engine => services}/audio.rs (100%) create mode 100644 src/services/mod.rs diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index c0fdf76..9b89464 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -240,7 +240,7 @@ impl GpuSceneRenderer { let audio_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("audio_spectrum"), - size: crate::engine::audio::UNIFORM_BYTES as u64, + size: crate::services::audio::UNIFORM_BYTES as u64, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -1746,7 +1746,7 @@ struct ParticleGpuAssets { 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, + pub playback: crate::services::audio::SoundPlayback, } /// Start a `sound` object's first decodable file, held silent when @@ -1754,7 +1754,7 @@ pub(crate) struct SceneSound { fn start_scene_sound( dir: &Path, obj: &crate::engine::model::scene::SceneObject, -) -> Option { +) -> Option { let sound = obj.sound.as_ref()?; let start_silent = obj .startsilent @@ -1780,7 +1780,7 @@ fn start_scene_sound( _ => Vec::new(), }; files.into_iter().find_map(|f| { - crate::engine::audio::SoundPlayback::start(&dir.join(&f), volume, looping, !start_silent) + crate::services::audio::SoundPlayback::start(&dir.join(&f), volume, looping, !start_silent) }) } @@ -1878,7 +1878,7 @@ pub struct GpuSceneInstance { /// 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, + audio_capture: Option, /// Live playback streams for `sound` objects; kept alive for the scene's /// lifetime (dropping stops the audio). sounds: Vec, @@ -2463,7 +2463,7 @@ impl GpuSceneInstance { 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() + crate::services::audio::AudioCapture::start() } else { None }; @@ -3580,7 +3580,7 @@ impl GpuSceneInstance { 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(); + self.audio_capture = crate::services::audio::AudioCapture::start(); } } } @@ -4046,7 +4046,7 @@ impl GpuSceneInstance { } /// Sound controls belonging to scene object `index`. - fn sounds_of(&self, index: usize) -> Vec> { + fn sounds_of(&self, index: usize) -> Vec> { self.sounds .iter() .filter(|s| s.object == index) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 3924553..8ba6169 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -4,7 +4,6 @@ //! backend-neutral [`graph::SceneGraph`] feeds the CPU compositor and //! diagnostics. -pub mod audio; pub mod cpu; pub mod gpu; #[cfg(target_os = "linux")] diff --git a/src/lib.rs b/src/lib.rs index b8262ba..6c47df5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ 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/platform/audio.rs b/src/platform/audio.rs index c6644f9..11ee32d 100644 --- a/src/platform/audio.rs +++ b/src/platform/audio.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: //! diff --git a/src/platform/wayland/mod.rs b/src/platform/wayland/mod.rs index d50f3c8..f261e6e 100644 --- a/src/platform/wayland/mod.rs +++ b/src/platform/wayland/mod.rs @@ -414,7 +414,7 @@ impl WallpaperState { // `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)); @@ -434,7 +434,7 @@ impl WallpaperState { let _ = tx.try_send(crate::render::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. @@ -1532,7 +1532,7 @@ 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)); } diff --git a/src/platform/x11/mod.rs b/src/platform/x11/mod.rs index dca90e9..534bb8d 100644 --- a/src/platform/x11/mod.rs +++ b/src/platform/x11/mod.rs @@ -646,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. diff --git a/src/render/frame.rs b/src/render/frame.rs index 3520c0a..1a78b45 100644 --- a/src/render/frame.rs +++ b/src/render/frame.rs @@ -25,7 +25,7 @@ pub enum FrameSource { /// 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, }, } @@ -106,7 +106,7 @@ 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) { diff --git a/src/render/web.rs b/src/render/web.rs index e027f90..a03644a 100644 --- a/src/render/web.rs +++ b/src/render/web.rs @@ -566,7 +566,7 @@ 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`. #[cfg(target_os = "linux")] @@ -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", @@ -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 diff --git a/src/engine/audio.rs b/src/services/audio.rs similarity index 100% rename from src/engine/audio.rs rename to src/services/audio.rs diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..ed3702f --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,5 @@ +//! 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; From 70ead0cdb4fc4dea4e5a25d6ff1e385fc45df23a Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:20:49 -0700 Subject: [PATCH 039/117] refactor: move platform/audio.rs to services/audio_devices.rs --- src/daemon/server.rs | 2 +- src/platform/mod.rs | 1 - src/services/audio.rs | 6 +++--- src/{platform/audio.rs => services/audio_devices.rs} | 0 src/services/mod.rs | 1 + src/ui/config.rs | 4 ++-- src/ui/mod.rs | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) rename src/{platform/audio.rs => services/audio_devices.rs} (100%) diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 2c979f1..ac19992 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -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 } => { diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 7d0173f..c56e6e6 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,4 +1,3 @@ -pub mod audio; pub mod device; pub mod display; pub mod gpu; diff --git a/src/services/audio.rs b/src/services/audio.rs index c6c4b31..b6db342 100644 --- a/src/services/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 100% rename from src/platform/audio.rs rename to src/services/audio_devices.rs diff --git a/src/services/mod.rs b/src/services/mod.rs index ed3702f..ffd5b40 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -3,3 +3,4 @@ //! them. pub mod audio; +pub mod audio_devices; diff --git a/src/ui/config.rs b/src/ui/config.rs index 24d5234..4881bed 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -478,7 +478,7 @@ impl WpApp { } let devices = self .audio_devices - .get_or_insert_with(crate::platform::audio::list_audio_outputs); + .get_or_insert_with(crate::services::audio_devices::list_audio_outputs); let current = devices .get(self.audio_choice) .map(|d| d.label.clone()) @@ -499,7 +499,7 @@ impl WpApp { }); if let Some((i, device)) = changed { self.audio_choice = i; - crate::platform::audio::set_preferred_device(device); + 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); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 6bd668d..7baa3d5 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -116,7 +116,7 @@ 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, @@ -557,7 +557,7 @@ impl WpApp { let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { - let device = crate::platform::audio::preferred_device(); + 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)) From 0b8b7e8dba35e4dec7055db15de7d114fbd04e18 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:21:14 -0700 Subject: [PATCH 040/117] refactor: move engine/media.rs to services/media.rs --- src/engine/gpu/renderer/mod.rs | 6 +++--- src/engine/mod.rs | 2 -- src/render/session.rs | 2 +- src/render/web.rs | 6 +++--- src/{engine => services}/media.rs | 0 src/services/mod.rs | 2 ++ 6 files changed, 9 insertions(+), 9 deletions(-) rename src/{engine => services}/media.rs (100%) diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index 9b89464..4c43ef0 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -1890,7 +1890,7 @@ pub struct GpuSceneInstance { /// 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, + 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. @@ -2901,7 +2901,7 @@ impl GpuSceneInstance { audio_capture, sounds, #[cfg(target_os = "linux")] - media: crate::engine::media::MediaWatcher::start(), + media: crate::services::media::MediaWatcher::start(), perspective: camera3d.is_some(), mesh3d, mesh3d_depth, @@ -3534,7 +3534,7 @@ impl GpuSceneInstance { } #[cfg(target_os = "linux")] if let Some(info) = self.media.as_ref().and_then(|w| w.try_recv()) { - use crate::engine::media::PlaybackState; + use crate::services::media::PlaybackState; self.scripts.set_media( info.available, match info.playback_state { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 8ba6169..23e3e93 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -6,8 +6,6 @@ pub mod cpu; pub mod gpu; -#[cfg(target_os = "linux")] -pub mod media; pub mod model; pub mod playback_gate; pub mod properties; diff --git a/src/render/session.rs b/src/render/session.rs index 8451474..53e037c 100644 --- a/src/render/session.rs +++ b/src/render/session.rs @@ -15,9 +15,9 @@ use anyhow::Result; use super::web::{WebInputEvent, WebOptions}; use super::{FrameSource, RenderSettings, WallpaperContent}; use crate::engine::gpu::renderer::GpuSceneInstance; -use crate::engine::media::{MediaWatcher, PlaybackState as MediaState}; use crate::engine::playback_gate::{self, PlaybackAction, PlaybackPolicy, PlaybackState}; use crate::platform::power::{self, LockWatcher}; +use crate::services::media::{MediaWatcher, PlaybackState as MediaState}; /// 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 diff --git a/src/render/web.rs b/src/render/web.rs index a03644a..b422662 100644 --- a/src/render/web.rs +++ b/src/render/web.rs @@ -570,7 +570,7 @@ mod imp { let mut last_audio_push = Instant::now(); // MPRIS is a freedesktop.org/Linux-only surface — see `engine::media`. #[cfg(target_os = "linux")] - let mut media: Option = None; + let mut media: Option = None; loop { match rx.try_recv() { @@ -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", @@ -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 diff --git a/src/engine/media.rs b/src/services/media.rs similarity index 100% rename from src/engine/media.rs rename to src/services/media.rs diff --git a/src/services/mod.rs b/src/services/mod.rs index ffd5b40..4bac016 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -4,3 +4,5 @@ pub mod audio; pub mod audio_devices; +#[cfg(target_os = "linux")] +pub mod media; From a3f26031b9fcf90198f34a40ae96623668d416d4 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:21:33 -0700 Subject: [PATCH 041/117] refactor: move platform/power.rs to services/power.rs --- src/platform/mod.rs | 1 - src/render/session.rs | 2 +- src/services/mod.rs | 1 + src/{platform => services}/power.rs | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename src/{platform => services}/power.rs (100%) diff --git a/src/platform/mod.rs b/src/platform/mod.rs index c56e6e6..6ab18dd 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -4,7 +4,6 @@ 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/render/session.rs b/src/render/session.rs index 53e037c..1b6d496 100644 --- a/src/render/session.rs +++ b/src/render/session.rs @@ -16,8 +16,8 @@ use super::web::{WebInputEvent, WebOptions}; use super::{FrameSource, RenderSettings, WallpaperContent}; use crate::engine::gpu::renderer::GpuSceneInstance; use crate::engine::playback_gate::{self, PlaybackAction, PlaybackPolicy, PlaybackState}; -use crate::platform::power::{self, LockWatcher}; use crate::services::media::{MediaWatcher, PlaybackState as MediaState}; +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 diff --git a/src/services/mod.rs b/src/services/mod.rs index 4bac016..54cb261 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -6,3 +6,4 @@ pub mod audio; pub mod audio_devices; #[cfg(target_os = "linux")] pub mod media; +pub mod power; diff --git a/src/platform/power.rs b/src/services/power.rs similarity index 100% rename from src/platform/power.rs rename to src/services/power.rs From 7befcaa9de7d27eeb107ecf1497d21f298343f96 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:21:56 -0700 Subject: [PATCH 042/117] refactor: move engine/playback_gate.rs to services/playback_gate.rs --- src/engine/mod.rs | 1 - src/main.rs | 2 +- src/platform/wayland/mod.rs | 2 +- src/platform/x11/mod.rs | 2 +- src/render/session.rs | 2 +- src/services/mod.rs | 1 + src/{engine => services}/playback_gate.rs | 0 src/settings.rs | 2 +- src/ui/config.rs | 2 +- 9 files changed, 7 insertions(+), 7 deletions(-) rename src/{engine => services}/playback_gate.rs (100%) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 23e3e93..c2d481d 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -7,7 +7,6 @@ pub mod cpu; pub mod gpu; pub mod model; -pub mod playback_gate; pub mod properties; pub mod sim; diff --git a/src/main.rs b/src/main.rs index 1f87e88..d444764 100644 --- a/src/main.rs +++ b/src/main.rs @@ -673,7 +673,7 @@ fn cmd_config(action: ConfigAction) -> Result<()> { settings.active_playlist = name; } ConfigAction::SetPlayback { condition, action } => { - let a = wp_engine::engine::playback_gate::PlaybackAction::parse(&action).ok_or_else( + 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) { diff --git a/src/platform/wayland/mod.rs b/src/platform/wayland/mod.rs index f261e6e..99db7d9 100644 --- a/src/platform/wayland/mod.rs +++ b/src/platform/wayland/mod.rs @@ -48,7 +48,7 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{ }; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; -use crate::engine::playback_gate::PlaybackAction; +use crate::services::playback_gate::PlaybackAction; use crate::{ platform, render::session::{ diff --git a/src/platform/x11/mod.rs b/src/platform/x11/mod.rs index 534bb8d..15df03a 100644 --- a/src/platform/x11/mod.rs +++ b/src/platform/x11/mod.rs @@ -31,7 +31,7 @@ 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}; diff --git a/src/render/session.rs b/src/render/session.rs index 1b6d496..109fec0 100644 --- a/src/render/session.rs +++ b/src/render/session.rs @@ -15,8 +15,8 @@ use anyhow::Result; use super::web::{WebInputEvent, WebOptions}; use super::{FrameSource, RenderSettings, WallpaperContent}; use crate::engine::gpu::renderer::GpuSceneInstance; -use crate::engine::playback_gate::{self, PlaybackAction, PlaybackPolicy, PlaybackState}; 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 diff --git a/src/services/mod.rs b/src/services/mod.rs index 54cb261..d36f03d 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -6,4 +6,5 @@ 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 100% rename from src/engine/playback_gate.rs rename to src/services/playback_gate.rs diff --git a/src/settings.rs b/src/settings.rs index af64f7d..da7bebb 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -53,7 +53,7 @@ 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, diff --git a/src/ui/config.rs b/src/ui/config.rs index 4881bed..23b00d3 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -4,10 +4,10 @@ //! `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::services::playback_gate::PlaybackAction; use crate::settings::{Playlist, WpSettings}; use serde_json::Value; From 2be6038bf1166e93bd6123cd8ac60e7722c8832b Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:22:27 -0700 Subject: [PATCH 043/117] refactor: move engine/properties.rs to workshop/properties.rs User properties are declared in a Workshop item's project.json, which the workshop module already parses. --- src/application/application_context.rs | 2 +- src/application/mod.rs | 2 +- src/engine/gpu/renderer/mod.rs | 10 +++++----- src/engine/mod.rs | 3 +-- src/engine/model/resolve/mod.rs | 2 +- src/main.rs | 16 ++++++++-------- src/render/content.rs | 2 +- src/render/web.rs | 2 +- src/ui/config.rs | 8 ++++---- src/ui/mod.rs | 2 +- src/workshop/mod.rs | 1 + src/{engine => workshop}/properties.rs | 0 tests/properties.rs | 2 +- 13 files changed, 26 insertions(+), 26 deletions(-) rename src/{engine => workshop}/properties.rs (100%) diff --git a/src/application/application_context.rs b/src/application/application_context.rs index 226ce24..7fc0782 100644 --- a/src/application/application_context.rs +++ b/src/application/application_context.rs @@ -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..91fae65 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -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/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index 4c43ef0..e727a94 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -3787,7 +3787,7 @@ impl GpuSceneInstance { /// [`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); + crate::workshop::properties::SceneProperties::from_project_dir(&self.live_props.dir); self.apply_user_properties(props, names) } @@ -3798,7 +3798,7 @@ impl GpuSceneInstance { /// built at load, a field scripts can't set). fn apply_user_properties( &mut self, - props: crate::engine::properties::SceneProperties, + props: crate::workshop::properties::SceneProperties, names: &[String], ) -> bool { let lp = &mut self.live_props; @@ -6081,13 +6081,13 @@ pub fn gpu_scene_render_loop( struct LiveProps { dir: std::path::PathBuf, raw: serde_json::Value, - props: crate::engine::properties::SceneProperties, + props: crate::workshop::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 props = crate::workshop::properties::SceneProperties::from_project_dir(dir); let mut resolved = raw.clone(); props.resolve_scene_json(&mut resolved); Self { @@ -6766,7 +6766,7 @@ fn build_script_host( } let user_props: serde_json::Map = - crate::engine::properties::SceneProperties::from_project_dir(dir) + crate::workshop::properties::SceneProperties::from_project_dir(dir) .iter() .map(|p| (p.name.clone(), p.value.clone())) .collect(); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index c2d481d..0ff277a 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -7,7 +7,6 @@ pub mod cpu; pub mod gpu; pub mod model; -pub mod properties; pub mod sim; // Convenience re-exports. @@ -18,4 +17,4 @@ pub use crate::engine::model::scene::Scene; pub use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; pub use crate::formats::pkg::Package; pub use crate::formats::tex::TexFile; -pub use properties::SceneProperties; +pub use crate::workshop::properties::SceneProperties; diff --git a/src/engine/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs index 98e81e0..c5d2cc7 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -4,10 +4,10 @@ use std::collections::HashMap; use std::path::Path; use crate::engine::model::scene::{Scene, SceneObject}; -use crate::engine::properties::SceneProperties; use crate::engine::sim::particle; use crate::formats::pkg::Package; use crate::formats::tex::TexFile; +use crate::workshop::properties::SceneProperties; /// Parse scene.json, first resolving `{"user": ...}` references against the /// wallpaper's project.json properties (plus any `--set-property` overrides). diff --git a/src/main.rs b/src/main.rs index d444764..ac66903 100644 --- a/src/main.rs +++ b/src/main.rs @@ -476,7 +476,7 @@ fn apply_saved_settings( if save { let mut saved_anything = false; for arg in cli_properties { - let (name, value) = engine::properties::parse_property_arg(arg); + let (name, value) = workshop::properties::parse_property_arg(arg); settings.set_property(id_or_path, &name, &value); saved_anything = true; } @@ -500,7 +500,7 @@ fn apply_saved_settings( 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)?; + let all_props = workshop::properties::list_properties(&w.path)?; if all_props.is_empty() { println!( "\"{}\" declares no user-configurable properties.", @@ -573,7 +573,7 @@ fn cmd_config(action: ConfigAction) -> Result<()> { ConfigAction::SetProperty { id, properties } => { let mut names = Vec::new(); for arg in &properties { - let (name, value) = engine::properties::parse_property_arg(arg); + let (name, value) = workshop::properties::parse_property_arg(arg); println!("{id}: {name} = {value}"); settings.set_property(&id, &name, &value); names.push(name); @@ -772,7 +772,7 @@ fn resolve_scene_dir(id_or_path: &str) -> Result { /// 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 +/// `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 @@ -1095,9 +1095,9 @@ fn cmd_render_scene( ) -> Result<()> { let overrides = properties .iter() - .map(|p| engine::properties::parse_property_arg(p)) + .map(|p| workshop::properties::parse_property_arg(p)) .collect(); - engine::properties::set_global_overrides(overrides); + workshop::properties::set_global_overrides(overrides); let dir = resolve_scene_dir(id_or_path)?; println!("Loading scene from {}...", dir.display()); @@ -1143,10 +1143,10 @@ fn cmd_render_frame( seconds: f32, properties: Vec, ) -> Result<()> { - engine::properties::set_global_overrides( + workshop::properties::set_global_overrides( properties .iter() - .map(|p| engine::properties::parse_property_arg(p)) + .map(|p| workshop::properties::parse_property_arg(p)) .collect(), ); let path = resolve_id_or_path(id_or_path)?; diff --git a/src/render/content.rs b/src/render/content.rs index 2a001b5..2452008 100644 --- a/src/render/content.rs +++ b/src/render/content.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/render/web.rs index b422662..8616ae5 100644 --- a/src/render/web.rs +++ b/src/render/web.rs @@ -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(); diff --git a/src/ui/config.rs b/src/ui/config.rs index 23b00d3..324a104 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -4,11 +4,11 @@ //! `settings.json`, the same file `wp-engine config` writes. use super::{StatusMsg, WpApp, DANGER, TEXT_MUTED}; -use crate::engine::properties::SceneProperty; use crate::platform::RenderQuality; use crate::render::ScaleMode; use crate::services::playback_gate::PlaybackAction; use crate::settings::{Playlist, WpSettings}; +use crate::workshop::properties::SceneProperty; use serde_json::Value; /// The selected wallpaper's declared properties, loaded once per selection, @@ -158,7 +158,7 @@ impl WpApp { /// 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) + let props = crate::workshop::properties::list_properties(&self.wallpapers[idx].path) .unwrap_or_default(); self.prop_editor = Some(PropertyEditor { idx, @@ -232,11 +232,11 @@ impl WpApp { let raw = saved.get(&p.name)?; Some(( p.name.clone(), - crate::engine::properties::convert_override(p, raw), + crate::workshop::properties::convert_override(p, raw), )) }) .collect(); - crate::engine::properties::visible_rows(props, &values) + crate::workshop::properties::visible_rows(props, &values) } /// Draws the rows WE would show; returns whether a value was changed. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7baa3d5..1c5acbe 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -508,7 +508,7 @@ impl WpApp { display_title: String, properties: HashMap, ) { - crate::engine::properties::set_global_overrides(properties); + crate::workshop::properties::set_global_overrides(properties); let old_renderer = self.renderer.take(); let settings = Arc::clone(&self.settings); let (tx, rx) = mpsc::channel(); 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/properties.rs b/tests/properties.rs index c69e663..7ed8c97 100644 --- a/tests/properties.rs +++ b/tests/properties.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use wp_engine::engine::model::scene::Scene; -use wp_engine::engine::properties::{parse_property_arg, SceneProperties}; 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. From dd858101af01b659adffde4c0c16258146725d58 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:23:01 -0700 Subject: [PATCH 044/117] refactor: describe engine's four groups in engine/mod.rs, trim re-exports The module doc names old paths; it now maps model/sim/gpu/cpu. Re-exports that nothing used (RenderTarget, RenderTargetPool, Scene, CameraDynamics, CameraFrameDynamics, SceneProperties) go, and so do Package and TexFile, which aren't engine types; main.rs names them through formats instead. --- src/engine/mod.rs | 27 ++++++++++++++------------- src/main.rs | 6 +++--- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 0ff277a..e475b27 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1,20 +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 cpu; pub mod gpu; pub mod model; pub mod sim; -// Convenience re-exports. -pub use crate::engine::cpu::graph::SceneGraph; -pub use crate::engine::gpu::fbo::{RenderTarget, RenderTargetPool}; -pub use crate::engine::model::resolve::ResolvedScene; -pub use crate::engine::model::scene::Scene; -pub use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; -pub use crate::formats::pkg::Package; -pub use crate::formats::tex::TexFile; -pub use crate::workshop::properties::SceneProperties; +pub use cpu::graph::SceneGraph; +pub use model::resolve::ResolvedScene; diff --git a/src/main.rs b/src/main.rs index ac66903..0e424ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ 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}; +use wp_engine::{engine, formats, platform, ui}; #[derive(Parser)] #[command( @@ -1021,7 +1021,7 @@ fn cmd_set_file( } fn cmd_pkg_info(path: &std::path::Path, dump: Option<&std::path::Path>) -> Result<()> { - let pkg = engine::Package::from_file(path)?; + let pkg = formats::pkg::Package::from_file(path)?; println!("Files : {}", pkg.len()); println!("Empty : {}", pkg.is_empty()); @@ -1070,7 +1070,7 @@ fn cmd_pkg_info(path: &std::path::Path, dump: Option<&std::path::Path>) -> Resul 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)?; + 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); From f4a5efa2f9f578a6889c9d42bf7a46311c145141 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:24:24 -0700 Subject: [PATCH 045/117] refactor: rename the top-level render/ module to content/ render/ held wallpaper content (scene/video/web/image), its frame sources, render settings and the per-output session, while the actual rendering lives in engine::gpu. Moved as one module without content changes; also fixes the macOS-only import in ui. --- src/application/application_context.rs | 4 +- src/application/mod.rs | 2 +- src/{render => content}/content.rs | 0 src/{render => content}/ffmpeg.rs | 0 src/{render => content}/frame.rs | 0 src/{render => content}/mod.rs | 0 src/{render => content}/session.rs | 2 +- src/{render => content}/settings.rs | 2 +- src/{render => content}/web.rs | 2 +- src/daemon/protocol.rs | 4 +- src/daemon/server.rs | 2 +- src/engine/gpu/renderer/mod.rs | 4 +- src/engine/model/resolve/mod.rs | 8 ++-- src/lib.rs | 2 +- src/main.rs | 18 ++++---- src/platform/display.rs | 4 +- src/platform/macos/mod.rs | 2 +- src/platform/scaler.rs | 8 ++-- src/platform/wayland/mod.rs | 60 +++++++++++++------------- src/platform/x11/mod.rs | 28 ++++++------ src/settings.rs | 4 +- src/ui/config.rs | 4 +- src/ui/mod.rs | 18 ++++---- 23 files changed, 89 insertions(+), 89 deletions(-) rename src/{render => content}/content.rs (100%) rename src/{render => content}/ffmpeg.rs (100%) rename src/{render => content}/frame.rs (100%) rename src/{render => content}/mod.rs (100%) rename src/{render => content}/session.rs (99%) rename src/{render => content}/settings.rs (99%) rename src/{render => content}/web.rs (99%) diff --git a/src/application/application_context.rs b/src/application/application_context.rs index 7fc0782..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, diff --git a/src/application/mod.rs b/src/application/mod.rs index 91fae65..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; diff --git a/src/render/content.rs b/src/content/content.rs similarity index 100% rename from src/render/content.rs rename to src/content/content.rs diff --git a/src/render/ffmpeg.rs b/src/content/ffmpeg.rs similarity index 100% rename from src/render/ffmpeg.rs rename to src/content/ffmpeg.rs diff --git a/src/render/frame.rs b/src/content/frame.rs similarity index 100% rename from src/render/frame.rs rename to src/content/frame.rs diff --git a/src/render/mod.rs b/src/content/mod.rs similarity index 100% rename from src/render/mod.rs rename to src/content/mod.rs diff --git a/src/render/session.rs b/src/content/session.rs similarity index 99% rename from src/render/session.rs rename to src/content/session.rs index 109fec0..c084761 100644 --- a/src/render/session.rs +++ b/src/content/session.rs @@ -192,7 +192,7 @@ impl PlaybackMonitor { #[cfg(test)] mod tests { use super::*; - use crate::render::settings::PropertyEdit; + use crate::content::settings::PropertyEdit; #[test] fn pending_edits_pick_this_wallpaper_and_advance() { diff --git a/src/render/settings.rs b/src/content/settings.rs similarity index 99% rename from src/render/settings.rs rename to src/content/settings.rs index 1b28be8..9c7c2ba 100644 --- a/src/render/settings.rs +++ b/src/content/settings.rs @@ -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/web.rs b/src/content/web.rs similarity index 99% rename from src/render/web.rs rename to src/content/web.rs index 8616ae5..f8b23da 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, } } } 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 ac19992..c59ae88 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 }); } } } diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index e727a94..7413d35 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -5433,7 +5433,7 @@ impl GpuSceneInstance { format: wgpu::TextureFormat, ) { self.render(); - let full = crate::render::settings::FULL_REGION; + let full = crate::content::settings::FULL_REGION; self.present_region(view, format, (view_w, view_h), full); } @@ -5447,7 +5447,7 @@ impl GpuSceneInstance { canvas: (u32, u32), region: [f32; 4], ) { - let (uv_scale, uv_offset) = crate::render::ScaleMode::Fill.uv_transform_region( + let (uv_scale, uv_offset) = crate::content::ScaleMode::Fill.uv_transform_region( (self.width, self.height), canvas, region, diff --git a/src/engine/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs index c5d2cc7..1b1f93d 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -1715,7 +1715,7 @@ fn start_video_stream(bytes: &[u8]) -> Result<(RgbaImage, VideoLayerStream)> { )); 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) { + let first = match crate::content::ffmpeg::decode_first_frame(&tmp) { Ok(img) => img, Err(e) => { let _ = std::fs::remove_file(&tmp); @@ -1726,7 +1726,7 @@ fn start_video_stream(bytes: &[u8]) -> Result<(RgbaImage, VideoLayerStream)> { 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) { + if let Err(e) = crate::content::ffmpeg::video_decode_loop(&decode_path, &tx) { eprintln!( "embedded video decoder error for '{}': {e}", decode_path.display() @@ -1757,7 +1757,7 @@ fn load_texture_from_dir(dir: &Path, image_path: &str) -> Result { // 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); + return crate::content::ffmpeg::decode_first_frame(&full_path).map(LoadedImage::single); } // Try as .tex first @@ -1823,7 +1823,7 @@ fn load_texture_from_pkg(pkg: &Package, image_path: &str) -> Result )); std::fs::write(&tmp, data) .with_context(|| format!("writing video temp file: {}", tmp.display()))?; - let result = crate::render::ffmpeg::decode_first_frame(&tmp); + let result = crate::content::ffmpeg::decode_first_frame(&tmp); let _ = std::fs::remove_file(&tmp); return result.map(LoadedImage::single); } diff --git a/src/lib.rs b/src/lib.rs index 6c47df5..b25270b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,11 @@ 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; diff --git a/src/main.rs b/src/main.rs index 0e424ca..50ca912 100644 --- a/src/main.rs +++ b/src/main.rs @@ -252,7 +252,7 @@ fn main() { // 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() { + if let Some(code) = wp_engine::content::web::subprocess_main() { std::process::exit(code); } @@ -684,14 +684,14 @@ fn cmd_config(action: ConfigAction) -> Result<()> { println!("While {condition}: {}", a.label()); } ConfigAction::SetScaling { mode } => { - let m = wp_engine::render::ScaleMode::parse(&mode).ok_or_else(|| { + 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::render::ScreenLayout::parse(&layout).ok_or_else(|| { + 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); @@ -761,13 +761,13 @@ fn cmd_autostart(state: &str) -> Result<()> { 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) + wp_engine::content::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 +/// — 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. /// @@ -828,7 +828,7 @@ fn cmd_run(foreground: bool) -> Result<()> { println!(" (quality: {})", q.label()); context.screen_settings.insert( screen.clone(), - Arc::new(Mutex::new(wp_engine::render::RenderSettings { + Arc::new(Mutex::new(wp_engine::content::RenderSettings { quality: q, fps: context.settings.lock().unwrap().fps, ..Default::default() @@ -1150,10 +1150,10 @@ fn cmd_render_frame( .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( + let content = wp_engine::content::WallpaperContent::from_any_path(&path)?; + let mut source = wp_engine::content::FrameSource::from_content_with( content, - wp_engine::render::web::WebOptions { size, fps }, + 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 { 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 c749e62..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::content::{ScreenContent, ScreenSettings, WallpaperContent}; use crate::engine::gpu::renderer::GpuSceneInstance; -use crate::render::{ScreenContent, ScreenSettings, WallpaperContent}; // ── Platform implementation ─────────────────────────────────────────────────── 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 99db7d9..cf4fac6 100644 --- a/src/platform/wayland/mod.rs +++ b/src/platform/wayland/mod.rs @@ -50,11 +50,11 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{ use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; use crate::services::playback_gate::PlaybackAction; use crate::{ - platform, - render::session::{ + content::session::{ pending_property_edits, ContentRenderer, PlaybackMonitor, WindowState, PAUSE_CHECK_INTERVAL, }, - render::{FrameSource, RenderSettings, ScreenContent, ScreenSettings}, + content::{FrameSource, RenderSettings, ScreenContent, ScreenSettings}, + platform, }; // ── Platform implementation ─────────────────────────────────────────────────── @@ -393,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 { @@ -417,13 +417,13 @@ impl WallpaperState { 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. @@ -431,7 +431,7 @@ impl WallpaperState { if self.surfaces[idx].web_fps != fps { self.surfaces[idx].web_fps = fps; if let Some(tx) = self.surfaces[idx].renderer.borrow().content.web_input() { - let _ = tx.try_send(crate::render::web::WebInputEvent::Fps(fps)); + let _ = tx.try_send(crate::content::web::WebInputEvent::Fps(fps)); } } crate::services::audio::set_master_volume(self.settings.default.lock().unwrap().volume); @@ -590,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); @@ -861,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 @@ -884,7 +884,7 @@ 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(|| { @@ -912,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(), } ); @@ -1207,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], }, @@ -1221,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, @@ -1239,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, } } @@ -1447,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. @@ -1457,7 +1457,7 @@ 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), @@ -1490,7 +1490,7 @@ impl WallpaperState { else { return; }; - if idx > 0 && self.content.layout != crate::render::ScreenLayout::PerScreen { + if idx > 0 && self.content.layout != crate::content::ScreenLayout::PerScreen { return; } if s.renderer @@ -1500,7 +1500,7 @@ impl WallpaperState { { return; } - let web = crate::render::web::WebOptions { + let web = crate::content::web::WebOptions { size: (s.width.max(1), s.height.max(1)), fps: s.settings.lock().unwrap().fps, }; @@ -1517,7 +1517,7 @@ impl WallpaperState { } /// 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 Some(tx) = s.renderer.borrow().content.web_input() { let _ = tx.try_send(event.clone()); @@ -1534,10 +1534,10 @@ impl WallpaperState { let action = self.playback_action(Instant::now()); 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() { @@ -1610,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 15df03a..b0b7226 100644 --- a/src/platform/x11/mod.rs +++ b/src/platform/x11/mod.rs @@ -35,11 +35,11 @@ use crate::services::playback_gate::PlaybackAction; use x11rb::wrapper::ConnectionExt as _; use super::display::{DisplayPlatform, WallpaperHandle, WallpaperHandleInner}; -use crate::platform; -use crate::render::session::{ +use crate::content::session::{ pending_property_edits, ContentRenderer, PlaybackMonitor, WindowState, PAUSE_CHECK_INTERVAL, }; -use crate::render::{FrameSource, RenderSettings, ScreenContent, ScreenSettings}; +use crate::content::{FrameSource, RenderSettings, ScreenContent, ScreenSettings}; +use crate::platform; // ── Platform implementation ─────────────────────────────────────────────────── @@ -452,8 +452,8 @@ 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, } @@ -505,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 { @@ -618,10 +618,10 @@ fn wallpaper_loop( let fps = output.settings.lock().unwrap().fps; if let Some(tx) = output.renderer.web_input() { if paused != output.web_paused { - let _ = tx.try_send(crate::render::web::WebInputEvent::Paused(paused)); + let _ = tx.try_send(crate::content::web::WebInputEvent::Paused(paused)); } if fps != output.web_fps { - let _ = tx.try_send(crate::render::web::WebInputEvent::Fps(fps)); + let _ = tx.try_send(crate::content::web::WebInputEvent::Fps(fps)); } } output.web_paused = paused; @@ -683,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()?; @@ -783,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], }); @@ -793,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/settings.rs b/src/settings.rs index da7bebb..29fe6a0 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -59,13 +59,13 @@ pub struct WpSettings { 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/config.rs b/src/ui/config.rs index 324a104..540ed9a 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -4,8 +4,8 @@ //! `settings.json`, the same file `wp-engine config` writes. use super::{StatusMsg, WpApp, DANGER, TEXT_MUTED}; +use crate::content::ScaleMode; use crate::platform::RenderQuality; -use crate::render::ScaleMode; use crate::services::playback_gate::PlaybackAction; use crate::settings::{Playlist, WpSettings}; use crate::workshop::properties::SceneProperty; @@ -423,7 +423,7 @@ impl WpApp { let mut fps = self .saved .fps - .unwrap_or(crate::render::settings::DEFAULT_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); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1c5acbe..edb005e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,11 +6,11 @@ //! wallpaper applies it to the chosen display straight away; Cancel puts //! back what was showing (and saved) when the window opened. +use crate::content::RenderSettings; use crate::platform::{RenderQuality, WallpaperHandle}; -use crate::render::RenderSettings; use crate::workshop::{self, Wallpaper, WallpaperType}; #[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; @@ -315,7 +315,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() })), @@ -519,8 +519,8 @@ impl WpApp { } let result = detect_platform() .spawn_wallpaper( - crate::render::ScreenContent::single(content), - crate::render::ScreenSettings::single(settings), + crate::content::ScreenContent::single(content), + crate::content::ScreenSettings::single(settings), ) .map(|handle| (Some(handle), display_title.clone())) .map_err(|e| format!("{e:#}")); @@ -1256,9 +1256,9 @@ impl WpApp { 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"), + (crate::content::ScreenLayout::PerScreen, "Separate"), + (crate::content::ScreenLayout::Span, "Stretch"), + (crate::content::ScreenLayout::Clone, "Clone"), ] { ui.selectable_value(&mut layout, l, text); } @@ -1507,7 +1507,7 @@ fn can_apply(w: &Wallpaper) -> Result<(), &'static str> { 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() => { + WallpaperType::Web if !crate::content::web::is_supported() => { Err("Web wallpapers need a build with --features web") } _ if file_ok() => Ok(()), From 504185a94bf4becae56398e613bceda5bd29163f Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:24:58 -0700 Subject: [PATCH 046/117] refactor: rename content/content.rs to content/wallpaper.rs --- src/content/frame.rs | 2 +- src/content/mod.rs | 4 ++-- src/content/{content.rs => wallpaper.rs} | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename src/content/{content.rs => wallpaper.rs} (100%) diff --git a/src/content/frame.rs b/src/content/frame.rs index 1a78b45..838d16b 100644 --- a/src/content/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. /// diff --git a/src/content/mod.rs b/src/content/mod.rs index 99141e2..906efaf 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -1,12 +1,12 @@ -pub mod content; pub mod ffmpeg; 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 wallpaper; pub mod web; -pub use content::{ScreenContent, ScreenLayout, WallpaperContent}; pub use frame::FrameSource; pub use settings::{RenderSettings, ScaleMode, ScreenSettings}; +pub use wallpaper::{ScreenContent, ScreenLayout, WallpaperContent}; diff --git a/src/content/content.rs b/src/content/wallpaper.rs similarity index 100% rename from src/content/content.rs rename to src/content/wallpaper.rs From f353644cde25f366a267df77f282b5f1ee9536ca Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:25:17 -0700 Subject: [PATCH 047/117] refactor: rename content/ffmpeg.rs to content/video.rs --- src/content/frame.rs | 2 +- src/content/mod.rs | 2 +- src/content/{ffmpeg.rs => video.rs} | 0 src/engine/model/resolve/mod.rs | 8 ++++---- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/content/{ffmpeg.rs => video.rs} (100%) diff --git a/src/content/frame.rs b/src/content/frame.rs index 838d16b..8d32949 100644 --- a/src/content/frame.rs +++ b/src/content/frame.rs @@ -109,7 +109,7 @@ impl FrameSource { 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 index 906efaf..d8e78d6 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -1,9 +1,9 @@ -pub mod ffmpeg; 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; diff --git a/src/content/ffmpeg.rs b/src/content/video.rs similarity index 100% rename from src/content/ffmpeg.rs rename to src/content/video.rs diff --git a/src/engine/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs index 1b1f93d..8dcf454 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -1715,7 +1715,7 @@ fn start_video_stream(bytes: &[u8]) -> Result<(RgbaImage, VideoLayerStream)> { )); std::fs::write(&tmp, bytes) .with_context(|| format!("writing embedded video temp file: {}", tmp.display()))?; - let first = match crate::content::ffmpeg::decode_first_frame(&tmp) { + let first = match crate::content::video::decode_first_frame(&tmp) { Ok(img) => img, Err(e) => { let _ = std::fs::remove_file(&tmp); @@ -1726,7 +1726,7 @@ fn start_video_stream(bytes: &[u8]) -> Result<(RgbaImage, VideoLayerStream)> { let decode_path = tmp.clone(); std::thread::spawn(move || { // Exits when the receiver (VideoLayerStream) is dropped. - if let Err(e) = crate::content::ffmpeg::video_decode_loop(&decode_path, &tx) { + if let Err(e) = crate::content::video::video_decode_loop(&decode_path, &tx) { eprintln!( "embedded video decoder error for '{}': {e}", decode_path.display() @@ -1757,7 +1757,7 @@ fn load_texture_from_dir(dir: &Path, image_path: &str) -> Result { // Video file — extract first frame as static thumbnail if is_video_path(image_path) && full_path.exists() { - return crate::content::ffmpeg::decode_first_frame(&full_path).map(LoadedImage::single); + return crate::content::video::decode_first_frame(&full_path).map(LoadedImage::single); } // Try as .tex first @@ -1823,7 +1823,7 @@ fn load_texture_from_pkg(pkg: &Package, image_path: &str) -> Result )); std::fs::write(&tmp, data) .with_context(|| format!("writing video temp file: {}", tmp.display()))?; - let result = crate::content::ffmpeg::decode_first_frame(&tmp); + let result = crate::content::video::decode_first_frame(&tmp); let _ = std::fs::remove_file(&tmp); return result.map(LoadedImage::single); } From 3bc48720f538f8d11190835898e8030cb49ec990 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:16 -0700 Subject: [PATCH 048/117] docs: update module paths in content/frame.rs comments --- src/content/frame.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/frame.rs b/src/content/frame.rs index 8d32949..0e044c8 100644 --- a/src/content/frame.rs +++ b/src/content/frame.rs @@ -20,7 +20,7 @@ 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>, From 350c8b4983097611775f49eab6e965b47512e8b8 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:19 -0700 Subject: [PATCH 049/117] docs: update module paths in content/session.rs comments --- src/content/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/session.rs b/src/content/session.rs index c084761..0ab926f 100644 --- a/src/content/session.rs +++ b/src/content/session.rs @@ -1,7 +1,7 @@ //! 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 -//! (`engine::playback_gate`) whether to run, mute, pause or stop. The +//! (`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. From 27580c34b895ff81cbb93b8b9f26aea96de5df12 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:21 -0700 Subject: [PATCH 050/117] docs: update module paths in content/settings.rs comments --- src/content/settings.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/settings.rs b/src/content/settings.rs index 9c7c2ba..e6bdc45 100644 --- a/src/content/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, From e24d933d99ec5d4476011149d292f77b935c166c Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:24 -0700 Subject: [PATCH 051/117] docs: update module paths in content/web.rs comments --- src/content/web.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/content/web.rs b/src/content/web.rs index f8b23da..4fd62f7 100644 --- a/src/content/web.rs +++ b/src/content/web.rs @@ -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 @@ -568,7 +568,7 @@ mod imp { let mut general = String::new(); 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; @@ -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. @@ -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") From 4c173e0969710860fc3dffe3db35be1e7c182477 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:26 -0700 Subject: [PATCH 052/117] docs: update module paths in engine/cpu/animated.rs comments --- src/engine/cpu/animated.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/engine/cpu/animated.rs b/src/engine/cpu/animated.rs index 1754ffa..f4a423f 100644 --- a/src/engine/cpu/animated.rs +++ b/src/engine/cpu/animated.rs @@ -43,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 @@ -194,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]; @@ -229,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()?; From 9ea85e184cb152c8e982d6864765e498587b31f5 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:29 -0700 Subject: [PATCH 053/117] docs: update module paths in engine/gpu/blend.rs comments --- src/engine/gpu/blend.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/engine/gpu/blend.rs b/src/engine/gpu/blend.rs index 00fbfd1..73c046d 100644 --- a/src/engine/gpu/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. From bce8f85fd3f00872335ec10be06f125118cefdfd Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:31 -0700 Subject: [PATCH 054/117] docs: update module paths in engine/gpu/lighting.rs comments --- src/engine/gpu/lighting.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engine/gpu/lighting.rs b/src/engine/gpu/lighting.rs index 7f2ffc5..647a0e7 100644 --- a/src/engine/gpu/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. From 71e1cf9f5f7e6f2f473afe9e9065dbfec0be3985 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:33 -0700 Subject: [PATCH 055/117] docs: update module paths in engine/gpu/renderer/mod.rs comments --- src/engine/gpu/renderer/mod.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index 7413d35..238cc33 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -103,7 +103,7 @@ pub struct GpuSceneRenderer { /// 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 + // Shadow-caster depth pass: see `engine::gpu::shadow` and // `GpuSceneInstance::build_shadow_atlas`. shadow_pass_bgl: wgpu::BindGroupLayout, mesh3d_shadow_pipeline: wgpu::RenderPipeline, @@ -508,7 +508,7 @@ impl GpuSceneRenderer { count: None, }, // Shared shadow atlas (one texture, every shadow-casting - // light's tile packed into it — see `engine::shadow`) plus + // light's tile packed into it — see `engine::gpu::shadow`) plus // its comparison sampler, for `textureSampleCompareLevel` PCF // lookups in `fs_mesh3d`. wgpu::BindGroupLayoutEntry { @@ -608,7 +608,7 @@ impl GpuSceneRenderer { // 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 + // `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. @@ -1882,7 +1882,7 @@ pub struct GpuSceneInstance { /// 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 + /// 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 @@ -2042,7 +2042,7 @@ struct Mesh3dGpu { /// 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` +/// 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; @@ -2212,7 +2212,7 @@ fn mesh3d_lighting_bytes( } => { 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 + // 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]; @@ -2483,7 +2483,7 @@ impl GpuSceneInstance { // 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`. + // needs it — see `engine::model::scene::SceneObject::volumetrics_params`. let lights = resolved.scene.lights(); let has_volumetrics = lights.iter().any(|(_, _, v, _)| v.is_some()); @@ -2684,7 +2684,7 @@ impl GpuSceneInstance { 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 + // `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() { @@ -3063,7 +3063,7 @@ impl GpuSceneInstance { /// 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`), + /// `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 @@ -5056,7 +5056,7 @@ impl GpuSceneInstance { /// 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. + /// (model::resolve/cpu::animated) share the same underlying rasterizer. fn draw_particle_layer_cpu_raster( &mut self, encoder: &mut wgpu::CommandEncoder, From 438e2c0e132547e143788c708295723f3ba4b815 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:37 -0700 Subject: [PATCH 056/117] docs: update module paths in engine/model/resolve/mod.rs comments --- src/engine/model/resolve/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/engine/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs index 8dcf454..d1271c9 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -43,7 +43,7 @@ pub struct ResolvedScene { } /// A scene object whose `model` field references a static 3D mesh. Rendered as -/// real geometry through the perspective camera (see `engine::mesh3d`), not 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, @@ -1345,7 +1345,7 @@ fn extract_text_string(v: &serde_json::Value) -> Option { } /// Build a text object's rendered bitmap as a normal `Layer` (see -/// `engine::text` module docs for why: no scripting engine means text never +/// `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). fn text_layer_from_object( @@ -1838,7 +1838,7 @@ fn load_texture_from_pkg(pkg: &Package, image_path: &str) -> Result /// 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 +/// `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( @@ -2092,7 +2092,7 @@ fn resolve_model_chain_dir(dir: &Path, json_path: &str) -> Result { /// 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 +/// `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 From eeecfbeb2820a747f4258481cd4c0083bdf72d94 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:39 -0700 Subject: [PATCH 057/117] docs: update module paths in engine/model/resolve/tests.rs comments --- src/engine/model/resolve/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/engine/model/resolve/tests.rs b/src/engine/model/resolve/tests.rs index 758ad42..b6da099 100644 --- a/src/engine/model/resolve/tests.rs +++ b/src/engine/model/resolve/tests.rs @@ -55,7 +55,7 @@ fn disablepropagation_severs_transform_inheritance() { } /// The object-attachment API's `thisLayer.disablepropagation()` (see -/// `engine::script::OBJECT_ATTACHMENT_API_JS`) is this same +/// `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 From a24a4708205b8d9248434e98a6477e8c524d81e2 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:41 -0700 Subject: [PATCH 058/117] docs: update module paths in engine/model/scene.rs comments --- src/engine/model/scene.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/engine/model/scene.rs b/src/engine/model/scene.rs index 436a276..f99c6c5 100644 --- a/src/engine/model/scene.rs +++ b/src/engine/model/scene.rs @@ -308,7 +308,7 @@ 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::gpu::lighting::Fog; @@ -607,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, } @@ -624,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])> { @@ -669,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 @@ -700,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 @@ -762,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, From 047d79d028d7d7125ff2a82ac9bd60d689aec9d2 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:43 -0700 Subject: [PATCH 059/117] docs: update module paths in engine/sim/camera3d.rs comments --- src/engine/sim/camera3d.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engine/sim/camera3d.rs b/src/engine/sim/camera3d.rs index c6716fc..2b2c306 100644 --- a/src/engine/sim/camera3d.rs +++ b/src/engine/sim/camera3d.rs @@ -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)); @@ -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(); From 51871c60f3382a2e9c50b4782a175d8657048cec Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:46 -0700 Subject: [PATCH 060/117] docs: update module paths in engine/sim/particle/mod.rs comments --- src/engine/sim/particle/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engine/sim/particle/mod.rs b/src/engine/sim/particle/mod.rs index 6d80fcf..525d87b 100644 --- a/src/engine/sim/particle/mod.rs +++ b/src/engine/sim/particle/mod.rs @@ -1314,7 +1314,7 @@ 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 + /// 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( @@ -4054,7 +4054,7 @@ 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 +/// 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) From be12c882b96113671bcdd6f7f867d515dc3b45f1 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:48 -0700 Subject: [PATCH 061/117] docs: update module paths in engine/sim/particle/tests.rs comments --- src/engine/sim/particle/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/engine/sim/particle/tests.rs b/src/engine/sim/particle/tests.rs index 5f350c2..7a7f533 100644 --- a/src/engine/sim/particle/tests.rs +++ b/src/engine/sim/particle/tests.rs @@ -90,7 +90,7 @@ fn instance_override_count_lifetime_colorn() { } /// `enabled: false` in an instanceoverride must drop the whole system at -/// the layer level (checked in render.rs/animated.rs, config-level here). +/// the layer level (checked in model::resolve/cpu::animated, config-level here). #[test] fn instance_override_enabled_parses() { let o: InstanceOverride = From bc030fb9cbcc977a04c09ea10071a929d8a0c626 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:51 -0700 Subject: [PATCH 062/117] docs: update module paths in platform/wayland/mod.rs comments --- src/platform/wayland/mod.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/platform/wayland/mod.rs b/src/platform/wayland/mod.rs index cf4fac6..5d8ff6b 100644 --- a/src/platform/wayland/mod.rs +++ b/src/platform/wayland/mod.rs @@ -41,7 +41,7 @@ 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}, @@ -247,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 @@ -407,7 +407,7 @@ 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. @@ -766,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. @@ -1339,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( From 0aeb509a802586493886f929a56e6135b1cc2894 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:53 -0700 Subject: [PATCH 063/117] docs: update module paths in platform/x11/mod.rs comments --- src/platform/x11/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/x11/mod.rs b/src/platform/x11/mod.rs index b0b7226..328d51a 100644 --- a/src/platform/x11/mod.rs +++ b/src/platform/x11/mod.rs @@ -268,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. @@ -577,7 +577,7 @@ 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 From 8e98bbb066292ae3eb164ecf84129cc039633b0b Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:55 -0700 Subject: [PATCH 064/117] docs: update module paths in services/audio_devices.rs comments --- src/services/audio_devices.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/audio_devices.rs b/src/services/audio_devices.rs index 11ee32d..4c9453b 100644 --- a/src/services/audio_devices.rs +++ b/src/services/audio_devices.rs @@ -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. From 6e89ae338cb44f6cede82932e031e60fc18e2bc0 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:26:58 -0700 Subject: [PATCH 065/117] docs: update module paths in services/media.rs comments --- src/services/media.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/media.rs b/src/services/media.rs index f98be4c..d29b24d 100644 --- a/src/services/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 From 732b88808332fff15bc51949f0dbbdcf219c972f Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:27:01 -0700 Subject: [PATCH 066/117] docs: update module paths in services/playback_gate.rs comments --- src/services/playback_gate.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/playback_gate.rs b/src/services/playback_gate.rs index 4b32124..246c248 100644 --- a/src/services/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}; From fd920471c409d0db3750920b6e42cc4e7f8e7114 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:27:04 -0700 Subject: [PATCH 067/117] docs: update module paths in services/power.rs comments --- src/services/power.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/power.rs b/src/services/power.rs index a2acbba..bdb87a5 100644 --- a/src/services/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, From 208d95ab47fe0f33784de8b54a8155cd6b52e635 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:27:20 -0700 Subject: [PATCH 068/117] fix: scan-wallpapers writes its report to target/ It wrote src/tests/errors.txt, a folder that no longer exists since the binary moved to src/bin/, so every run would fail at the end. --- src/bin/scan_wallpapers.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bin/scan_wallpapers.rs b/src/bin/scan_wallpapers.rs index b260bd4..743c3fb 100644 --- a/src/bin/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 { From ad5b0715284fbea2c029d2e21507a7e658bdfb1d Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:27:44 -0700 Subject: [PATCH 069/117] docs: describe engine/model/ in its mod.rs --- src/engine/model/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/engine/model/mod.rs b/src/engine/model/mod.rs index 0324bd2..ddd569e 100644 --- a/src/engine/model/mod.rs +++ b/src/engine/model/mod.rs @@ -1,3 +1,7 @@ +//! 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; From 62916406629d080a4b5157eeeefdf1280fffd3ef Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:28:01 -0700 Subject: [PATCH 070/117] docs: describe content/ in its mod.rs --- src/content/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/content/mod.rs b/src/content/mod.rs index d8e78d6..c7500a1 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -1,3 +1,8 @@ +//! 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. From c186978a4803be83e4e879e194806746240e4aee Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:28:47 -0700 Subject: [PATCH 071/117] docs: map the new source layout in ARCHITECTURE.md Adds a source-layout overview and updates every stage's paths to the regrouped modules; the X11 section now describes the desktop window, and the pause-rules paragraph names services:: and the real PlaybackPolicy. --- ARCHITECTURE.md | 69 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fdcc0bd..1ee6e36 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,12 +8,34 @@ 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, bin/ the wp-engine CLI and 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 +``` + +## 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 +44,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 +58,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 +70,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 +91,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 +109,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 +126,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 +141,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 +152,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 +184,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 +202,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 +237,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 +245,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. From c7c7364ed798e8e28e29fa5e2d84676d4c0f17d6 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:30:47 -0700 Subject: [PATCH 072/117] refactor: split effect uniform packing out of the renderer into params.rs Moves ShaderVals and the std140 parameter builders (340 lines) unchanged into engine/gpu/renderer/params.rs; the renderer and the new file import each other's items with globs, so no call site changes. --- src/engine/gpu/renderer/mod.rs | 338 +---------------------------- src/engine/gpu/renderer/params.rs | 340 ++++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+), 335 deletions(-) create mode 100644 src/engine/gpu/renderer/params.rs diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index 238cc33..11cab7e 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -28,6 +28,9 @@ use crate::engine::sim::camera_dynamics::{CameraDynamics, CameraFrameDynamics}; use crate::engine::sim::particle; use crate::platform::GpuDevice; +mod params; +use params::*; + const SHADER_SRC: &str = include_str!("gpu_shaders.wgsl"); /// Per-layer ping-pong buffer key in the FBO pool (each layer's effect chain @@ -5496,12 +5499,6 @@ 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::sim::script::local_time_of_day() -} - #[allow(clippy::too_many_arguments)] fn composite_params( opacity: f32, @@ -6136,52 +6133,6 @@ fn changed_leaves( } } -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_") @@ -6241,289 +6192,6 @@ fn collect_effects(scene: &crate::engine::model::SceneModel) -> Vec 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( 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], + } +} From 664bad63dfeaa47ab3232fe775c5daf5daf2d602 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:31:59 -0700 Subject: [PATCH 073/117] refactor: split GpuSceneRenderer's impl into renderer/pipelines.rs The device-level half of the renderer (samplers, bind group layouts, pipeline cache, pass-recording helpers; 1,450 lines) moves unchanged. The struct stays in mod.rs with the other types; its private methods become pub(super) so the rest of the renderer can still call them. --- src/engine/gpu/renderer/mod.rs | 1397 +------------------------ src/engine/gpu/renderer/pipelines.rs | 1405 ++++++++++++++++++++++++++ 2 files changed, 1408 insertions(+), 1394 deletions(-) create mode 100644 src/engine/gpu/renderer/pipelines.rs diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index 11cab7e..de44526 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -31,6 +31,9 @@ use crate::platform::GpuDevice; mod params; use params::*; +mod pipelines; +use pipelines::*; + const SHADER_SRC: &str = include_str!("gpu_shaders.wgsl"); /// Per-layer ping-pong buffer key in the FBO pool (each layer's effect chain @@ -124,1402 +127,8 @@ pub struct GpuSceneRenderer { /// 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::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 - } - - 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::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: &[], - }) - } - - 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). 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), + } +} From 4b2ee99f2e57b80337e57966e65f98853dc8e618 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:32:30 -0700 Subject: [PATCH 074/117] refactor: split scene lights out of the renderer into renderer/lights.rs Shadow atlas, volumetric light shafts (build + per-frame record) and the mesh transform/lighting uniform packing (720 lines) move unchanged. --- src/engine/gpu/renderer/lights.rs | 723 ++++++++++++++++++++++++++++++ src/engine/gpu/renderer/mod.rs | 719 +---------------------------- 2 files changed, 726 insertions(+), 716 deletions(-) create mode 100644 src/engine/gpu/renderer/lights.rs 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/mod.rs b/src/engine/gpu/renderer/mod.rs index de44526..47fd66b 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -34,6 +34,9 @@ use params::*; mod pipelines; use pipelines::*; +mod lights; +use lights::*; + const SHADER_SRC: &str = include_str!("gpu_shaders.wgsl"); /// Per-layer ping-pong buffer key in the FBO pool (each layer's effect chain @@ -668,258 +671,6 @@ const MESH3D_MAX_SHADOW_LIGHTS: usize = crate::engine::gpu::shadow::MAX_SHADOW_L 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::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. -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 -} - impl GpuSceneInstance { /// Open a scene from a wallpaper directory with a freshly opened GPU device. pub fn open(dir: &Path) -> Result { @@ -1534,352 +1285,6 @@ impl GpuSceneInstance { 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::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`. - 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) - } - /// 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 @@ -3798,124 +3203,6 @@ impl GpuSceneInstance { ); } - /// 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(); From 07703e215640dcdf0f49519677870e68930cea91 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:33:05 -0700 Subject: [PATCH 075/117] refactor: split per-frame drawing out of the renderer into renderer/draw.rs render() and the image/particle/mesh layer passes, bloom, the backdrop copy, readback and surface presentation (1,200 lines) move unchanged. --- src/engine/gpu/renderer/draw.rs | 1219 +++++++++++++++++++++++++++++++ src/engine/gpu/renderer/mod.rs | 1210 +----------------------------- 2 files changed, 1222 insertions(+), 1207 deletions(-) create mode 100644 src/engine/gpu/renderer/draw.rs 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/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index 47fd66b..c60edfa 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -37,6 +37,9 @@ use pipelines::*; mod lights; use lights::*; +mod draw; +use draw::*; + const SHADER_SRC: &str = include_str!("gpu_shaders.wgsl"); /// Per-layer ping-pong buffer key in the FBO pool (each layer's effect chain @@ -1402,54 +1405,6 @@ 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. - 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::sim::script::{ObjectChange, ScriptChanges}; @@ -2258,65 +2213,6 @@ impl GpuSceneInstance { } } - /// 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) } @@ -2338,1112 +2234,12 @@ impl GpuSceneInstance { (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 - /// (model::resolve/cpu::animated) 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, - &[], - ); - } - - 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())); - } } fn named_fbo_key(instance_idx: usize, fbo_name: &str) -> String { format!("fx{instance_idx}:{fbo_name}") } -#[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 From 622f10c1543e710d504a25c9eaf28a094bbcf658 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:34:20 -0700 Subject: [PATCH 076/117] refactor: split live scene updates out of the renderer into renderer/live.rs Timelines, SceneScript ticks and script-made changes, cursor events and hit tests, live property edits, script-created objects, text refresh and per-frame transforms (1,150 lines) move unchanged. The LiveProps and TimelineEntry structs stay in mod.rs with the other types. --- src/engine/gpu/renderer/live.rs | 1152 +++++++++++++++++++++++++++++++ src/engine/gpu/renderer/mod.rs | 1144 +----------------------------- 2 files changed, 1162 insertions(+), 1134 deletions(-) create mode 100644 src/engine/gpu/renderer/live.rs 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 index c60edfa..fd090a7 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -40,6 +40,9 @@ use lights::*; mod draw; use draw::*; +mod live; +use live::*; + const SHADER_SRC: &str = include_str!("gpu_shaders.wgsl"); /// Per-layer ping-pong buffer key in the FBO pool (each layer's effect chain @@ -1405,814 +1408,6 @@ impl GpuSceneInstance { } } - /// Advance keyframe animations and apply the properties they move. - 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. - 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::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. - 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::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). - 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. - 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...`. - 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::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`. - 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::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. - 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::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); - } - } - } - } - pub fn size(&self) -> (u32, u32) { (self.width, self.height) } @@ -2224,16 +1419,6 @@ impl GpuSceneInstance { 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, - ]; - } } fn named_fbo_key(instance_idx: usize, fbo_name: &str) -> String { @@ -2765,66 +1950,6 @@ pub fn gpu_scene_render_loop( } } -/// 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, -} - -impl LiveProps { - 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. -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())), - } -} - fn collect_effects(scene: &crate::engine::model::SceneModel) -> Vec { let alias = |k: &str| -> String { k.strip_prefix("ui_editor_properties_") @@ -2987,33 +2112,13 @@ fn gpu_layer_from( } } -/// 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 +/// 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. @@ -3029,234 +2134,5 @@ struct TimelineEntry { last: Option>, } -/// Collect every animated property from the raw scene.json objects. -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. -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 -} - #[cfg(test)] mod tests; From d6e12a3c9b26523c5b16726af6257f3e98c5d3b7 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:34:42 -0700 Subject: [PATCH 077/117] test: deflake the daemon lock test Re-taking the lock right after dropping it failed now and then: a process another test spawns at that moment shares the open lock file from fork until exec (close-on-exec only applies at exec), holding the flock. The re-acquire now retries for up to a second. --- src/daemon/server.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/daemon/server.rs b/src/daemon/server.rs index c59ae88..d837cb6 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -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); } } From 73eeb093a7b6b4977e2f517f3d3dc2e2de6507df Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:35:19 -0700 Subject: [PATCH 078/117] refactor: split scene construction out of the renderer into renderer/build.rs GpuSceneInstance::build, the 3D mesh setup, effect-runtime loading and layer/sound construction (1,400 lines) move unchanged; mod.rs keeps the types, constants and constructors (740 lines). --- src/engine/gpu/renderer/build.rs | 1404 ++++++++++++++++++++++++++++++ src/engine/gpu/renderer/mod.rs | 1400 +---------------------------- 2 files changed, 1407 insertions(+), 1397 deletions(-) create mode 100644 src/engine/gpu/renderer/build.rs 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/mod.rs b/src/engine/gpu/renderer/mod.rs index fd090a7..57a0367 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -43,6 +43,9 @@ 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 @@ -370,41 +373,6 @@ pub(crate) struct SceneSound { pub playback: crate::services::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::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) - }) -} - pub struct GpuSceneInstance { renderer: GpuSceneRenderer, layers: Vec, @@ -698,716 +666,6 @@ impl GpuSceneInstance { Self::build(renderer, dir) } - 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. - 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. - 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), - } - } - pub fn size(&self) -> (u32, u32) { (self.width, self.height) } @@ -1425,496 +683,6 @@ fn named_fbo_key(instance_idx: usize, fbo_name: &str) -> String { format!("fx{instance_idx}:{fbo_name}") } -/// 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::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, - }) -} - /// 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))] @@ -1950,168 +718,6 @@ pub fn gpu_scene_render_loop( } } -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. -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, - } -} - /// The unresolved scene.json, the wallpaper's user properties, and the scene /// as last resolved against them. struct LiveProps { From 0add8dec2dd4d23d515712e258e41f83a7f9e991 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:35:36 -0700 Subject: [PATCH 079/117] docs: map the renderer's files in engine/gpu/renderer/mod.rs --- src/engine/gpu/renderer/mod.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/engine/gpu/renderer/mod.rs b/src/engine/gpu/renderer/mod.rs index 57a0367..acd1fc2 100644 --- a/src/engine/gpu/renderer/mod.rs +++ b/src/engine/gpu/renderer/mod.rs @@ -11,6 +11,17 @@ //! - 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; From 86dccd95ce09eecfaccbdb1febf5e9591e3cb8bc Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:37:58 -0700 Subject: [PATCH 080/117] refactor: split particle config types into engine/sim/particle/config.rs The JSON config structs, instance overrides, audio response and the value-parsing helpers (620 lines) move unchanged and stay reachable as particle::* through a re-export. AudioResponse's fields become pub(super) so the particle tests still read them. --- src/engine/sim/particle/config.rs | 623 ++++++++++++++++++++++++++++++ src/engine/sim/particle/mod.rs | 619 +---------------------------- 2 files changed, 625 insertions(+), 617 deletions(-) create mode 100644 src/engine/sim/particle/config.rs 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/mod.rs b/src/engine/sim/particle/mod.rs index 525d87b..ba590c7 100644 --- a/src/engine/sim/particle/mod.rs +++ b/src/engine/sim/particle/mod.rs @@ -1,523 +1,8 @@ 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, -} +mod config; +pub use config::*; /// Per-particle oscillator state for `oscillatealpha`/`oscillatesize`: a /// random frequency/scale/phase drawn once at spawn (CParticle.cpp captures @@ -3987,105 +3472,5 @@ fn fade_value(t: f32, start: f32, end: f32, start_val: f32, end_val: f32) -> f32 } } -/// 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. model::resolve'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; From 6469a33241daf54c3cb4913675af45cfa7fc9a18 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:38:43 -0700 Subject: [PATCH 081/117] refactor: split particle operator parameters into particle/operators.rs The oscillate/turbulence/vortex/collision/boids/remap parameter types, the boids and collision steps, and the vec3 helpers (710 lines) move unchanged. Their fields become pub(super): visible across particle/, as they were before. --- src/engine/sim/particle/mod.rs | 710 +------------------------- src/engine/sim/particle/operators.rs | 711 +++++++++++++++++++++++++++ 2 files changed, 714 insertions(+), 707 deletions(-) create mode 100644 src/engine/sim/particle/operators.rs diff --git a/src/engine/sim/particle/mod.rs b/src/engine/sim/particle/mod.rs index ba590c7..b6e20d0 100644 --- a/src/engine/sim/particle/mod.rs +++ b/src/engine/sim/particle/mod.rs @@ -4,6 +4,9 @@ use serde::Deserialize; mod config; pub use config::*; +mod operators; +pub use operators::*; + /// 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 @@ -59,50 +62,6 @@ struct Particle { 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, @@ -272,498 +231,6 @@ struct ChildInstance { 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::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). - 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, @@ -3115,180 +2582,9 @@ fn point_in_convex_quad(point: [f32; 2], corners: &[[f32; 2]; 4]) -> bool { 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` 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, + ] +} From 7b5ac054700fc4eb22746577f8eed997bdc5ca46 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:39:22 -0700 Subject: [PATCH 082/117] refactor: split particle drawing into engine/sim/particle/draw.rs The CPU rasterizer, rope/trail geometry and GPU vertex emission (950 lines) move unchanged; GpuVertex stays reachable as particle::GpuVertex. --- src/engine/sim/particle/draw.rs | 946 ++++++++++++++++++++++++++++++++ src/engine/sim/particle/mod.rs | 931 +------------------------------ 2 files changed, 949 insertions(+), 928 deletions(-) create mode 100644 src/engine/sim/particle/draw.rs 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 index b6e20d0..9aaa2dd 100644 --- a/src/engine/sim/particle/mod.rs +++ b/src/engine/sim/particle/mod.rs @@ -7,6 +7,9 @@ pub use config::*; mod operators; pub use operators::*; +mod draw; +pub use draw::*; + /// 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 @@ -1814,943 +1817,15 @@ impl ParticleSystem { 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 -} - // 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). -/// 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 } From 10b7dd969c2704967ecce9b27519a98e764b718b Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:39:47 -0700 Subject: [PATCH 083/117] refactor: split ParticleSystem::from_config into engine/sim/particle/build.rs The 660-line constructor that reads a config into runtime parameters moves unchanged. --- src/engine/sim/particle/build.rs | 672 +++++++++++++++++++++++++++++++ src/engine/sim/particle/mod.rs | 669 +----------------------------- 2 files changed, 675 insertions(+), 666 deletions(-) create mode 100644 src/engine/sim/particle/build.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/mod.rs b/src/engine/sim/particle/mod.rs index 9aaa2dd..62e4d71 100644 --- a/src/engine/sim/particle/mod.rs +++ b/src/engine/sim/particle/mod.rs @@ -10,6 +10,9 @@ pub use operators::*; mod draw; pub use draw::*; +mod build; +pub use build::*; + /// 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 @@ -266,672 +269,6 @@ struct EmitterState { } 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], - } - } - /// Change one `instanceoverride` value live (SceneScript /// `thisLayer.instance. = …`). `count` (the pool size) only applies /// at load and is ignored here. From acf4ef2a459daf0c0b9e10f8d46dd7125b7cacf5 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:40:12 -0700 Subject: [PATCH 084/117] refactor: split particle simulation into engine/sim/particle/step.rs step() and the child-system, origin, emission and input setters (880 lines) move unchanged; mod.rs keeps the core state types. --- src/engine/sim/particle/mod.rs | 891 +------------------------------ src/engine/sim/particle/step.rs | 898 ++++++++++++++++++++++++++++++++ 2 files changed, 902 insertions(+), 887 deletions(-) create mode 100644 src/engine/sim/particle/step.rs diff --git a/src/engine/sim/particle/mod.rs b/src/engine/sim/particle/mod.rs index 62e4d71..bfddad3 100644 --- a/src/engine/sim/particle/mod.rs +++ b/src/engine/sim/particle/mod.rs @@ -13,6 +13,9 @@ pub use draw::*; mod build; pub use build::*; +mod step; +pub use 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 @@ -268,893 +271,7 @@ struct EmitterState { audio: Option, } -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. - 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; - } - } -} +impl ParticleSystem {} fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t 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; + } + } +} From dd25be3e8e9433bed7fc006971d6986cdc550a2e Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:40:38 -0700 Subject: [PATCH 085/117] docs: map the particle module's files in engine/sim/particle/mod.rs --- src/engine/sim/particle/mod.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/engine/sim/particle/mod.rs b/src/engine/sim/particle/mod.rs index bfddad3..d8efdfd 100644 --- a/src/engine/sim/particle/mod.rs +++ b/src/engine/sim/particle/mod.rs @@ -1,3 +1,14 @@ +//! 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; From ecacb7590f022ec4c1242c3b1c73bd20616aa705 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:41:15 -0700 Subject: [PATCH 086/117] refactor: drop needless glob imports in engine/sim/particle/mod.rs build.rs and step.rs only add impl blocks, so importing them does nothing, and operators has no public items to re-export; this removes the three unused-import warnings the split introduced. --- src/engine/sim/particle/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/engine/sim/particle/mod.rs b/src/engine/sim/particle/mod.rs index d8efdfd..31fbbb4 100644 --- a/src/engine/sim/particle/mod.rs +++ b/src/engine/sim/particle/mod.rs @@ -16,16 +16,14 @@ mod config; pub use config::*; mod operators; -pub use operators::*; +use operators::*; mod draw; pub use draw::*; mod build; -pub use build::*; mod step; -pub use step::*; /// Per-particle oscillator state for `oscillatealpha`/`oscillatesize`: a /// random frequency/scale/phase drawn once at spawn (CParticle.cpp captures From 927a0a3d3a30952393ee04c5d639d8d753774ca1 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:42:15 -0700 Subject: [PATCH 087/117] refactor: split GLSL source rewrites into transpiler/rewrite.rs Loop unrolling, varying expansion, literal and vector-width coercions and built-in renames (930 lines) move unchanged; the public ones stay reachable as transpiler::*. --- src/engine/gpu/shaders/transpiler/mod.rs | 953 +----------------- src/engine/gpu/shaders/transpiler/rewrite.rs | 965 +++++++++++++++++++ 2 files changed, 968 insertions(+), 950 deletions(-) create mode 100644 src/engine/gpu/shaders/transpiler/rewrite.rs diff --git a/src/engine/gpu/shaders/transpiler/mod.rs b/src/engine/gpu/shaders/transpiler/mod.rs index 96bb247..b7b8363 100644 --- a/src/engine/gpu/shaders/transpiler/mod.rs +++ b/src/engine/gpu/shaders/transpiler/mod.rs @@ -2,6 +2,9 @@ use crate::engine::model::ShaderModel; use anyhow::{Context, Result}; use std::collections::HashMap; +mod rewrite; +pub use rewrite::*; + pub(crate) const WE_COMMON_H: &str = "\ #define M_PI 3.14159265359\n\ #define M_PI_HALF 1.57079632679\n\ @@ -136,561 +139,6 @@ pub struct TranslatedShader { 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) } @@ -908,82 +356,6 @@ fn json_default_vec4(val: &serde_json::Value) -> [f32; 4] { // 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), @@ -1758,296 +1130,6 @@ fn spirv_to_wgsl(spv: &[u8]) -> Result { // 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); @@ -2523,34 +1605,5 @@ pub fn combo_defines(combos: &HashMap) -> String { .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; 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'_' +} From 787fa92065d7a3a37f95886993deb090b7376938 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:42:44 -0700 Subject: [PATCH 088/117] refactor: split error-driven shader repairs into transpiler/repair.rs glsl_to_spirv_with_repairs, the repair passes keyed on shaderc's errors, and the SPIR-V/WGSL steps (770 lines) move unchanged. --- src/engine/gpu/shaders/transpiler/mod.rs | 760 +------------------ src/engine/gpu/shaders/transpiler/repair.rs | 763 ++++++++++++++++++++ 2 files changed, 766 insertions(+), 757 deletions(-) create mode 100644 src/engine/gpu/shaders/transpiler/repair.rs diff --git a/src/engine/gpu/shaders/transpiler/mod.rs b/src/engine/gpu/shaders/transpiler/mod.rs index b7b8363..4c7c524 100644 --- a/src/engine/gpu/shaders/transpiler/mod.rs +++ b/src/engine/gpu/shaders/transpiler/mod.rs @@ -5,6 +5,9 @@ use std::collections::HashMap; mod rewrite; pub use rewrite::*; +mod repair; +use repair::*; + pub(crate) const WE_COMMON_H: &str = "\ #define M_PI 3.14159265359\n\ #define M_PI_HALF 1.57079632679\n\ @@ -356,765 +359,8 @@ fn json_default_vec4(val: &serde_json::Value) -> [f32; 4] { // shaderc wraps glslangValidator as a linkable library — no external binary // required, works identically on Linux (Vulkan) and macOS (Metal via wgpu). -/// `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 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") +} From b7b67315a71d903f9aa6ae4d9d37504c8e302cca Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:43:08 -0700 Subject: [PATCH 089/117] refactor: split shader source assembly into transpiler/preprocess.rs Building the fragment/vertex GLSL from a shader model, combo defines and the synthetic vertex shader (480 lines) move unchanged; mod.rs keeps the headers, result types and translate(). --- src/engine/gpu/shaders/transpiler/mod.rs | 476 +---------------- .../gpu/shaders/transpiler/preprocess.rs | 479 ++++++++++++++++++ 2 files changed, 482 insertions(+), 473 deletions(-) create mode 100644 src/engine/gpu/shaders/transpiler/preprocess.rs diff --git a/src/engine/gpu/shaders/transpiler/mod.rs b/src/engine/gpu/shaders/transpiler/mod.rs index 4c7c524..1d7c9be 100644 --- a/src/engine/gpu/shaders/transpiler/mod.rs +++ b/src/engine/gpu/shaders/transpiler/mod.rs @@ -8,6 +8,9 @@ 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\ @@ -376,480 +379,7 @@ fn json_default_vec4(val: &serde_json::Value) -> [f32; 4] { // Group 0, binding 5 : texture 3 // Group 1, binding 0 : effect UBO (scalar uniforms) -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() -} - #[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() +} From 901423534bafedbbf816f76133e10c4815c9c519 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:43:33 -0700 Subject: [PATCH 090/117] docs: map the transpiler's files in engine/gpu/shaders/transpiler/mod.rs --- src/engine/gpu/shaders/transpiler/mod.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/engine/gpu/shaders/transpiler/mod.rs b/src/engine/gpu/shaders/transpiler/mod.rs index 1d7c9be..00280d3 100644 --- a/src/engine/gpu/shaders/transpiler/mod.rs +++ b/src/engine/gpu/shaders/transpiler/mod.rs @@ -1,3 +1,11 @@ +//! 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; From ab6e37bdbca612fe7fab1845d6e59aa3254f3eba Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:44:24 -0700 Subject: [PATCH 091/117] refactor: split texture loading into engine/model/resolve/textures.rs Loading layer textures from the folder, scene.pkg and global assets, model/material/texture chains and embedded-video streams (560 lines) move unchanged; LoadedImage's fields become pub(super). --- src/engine/model/resolve/mod.rs | 548 +------------------------- src/engine/model/resolve/textures.rs | 560 +++++++++++++++++++++++++++ 2 files changed, 563 insertions(+), 545 deletions(-) create mode 100644 src/engine/model/resolve/textures.rs diff --git a/src/engine/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs index d1271c9..c4fcc3e 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -9,6 +9,9 @@ use crate::formats::pkg::Package; use crate::formats::tex::TexFile; use crate::workshop::properties::SceneProperties; +mod textures; +pub use textures::*; + /// 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 { @@ -201,81 +204,6 @@ pub struct Layer { 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. /// @@ -913,38 +841,6 @@ pub fn runtime_layer(dir: &Path, pkg: Option<&Package>, obj: &SceneObject) -> Op 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, @@ -1139,31 +1035,6 @@ fn layer_from_object( } } -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() } @@ -1671,425 +1542,12 @@ fn light_layer_from_object(obj: &SceneObject) -> Option { 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::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. -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::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}") -} - -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. -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. -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::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. -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::sim::puppet`), and keep the parsed model + atlas around as a 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) +} From 254384e3eaf029939122e2947eb3325e70ee9e5b Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:44:55 -0700 Subject: [PATCH 092/117] refactor: split object transforms into engine/model/resolve/transform.rs Xform, the parent transform graph and world-transform resolution (320 lines) move unchanged and stay reachable through resolve::*. --- src/engine/model/resolve/mod.rs | 321 +------------------------ src/engine/model/resolve/transform.rs | 322 ++++++++++++++++++++++++++ 2 files changed, 325 insertions(+), 318 deletions(-) create mode 100644 src/engine/model/resolve/transform.rs diff --git a/src/engine/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs index c4fcc3e..6d8419c 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -12,6 +12,9 @@ use crate::workshop::properties::SceneProperties; mod textures; pub use textures::*; +mod transform; +pub use transform::*; + /// 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 { @@ -1672,324 +1675,6 @@ fn resolve_particle_sprite_dir( // 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::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. -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. -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 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; + } + } +} From 25ed80942200ae6f74adc1d111b76d8a46e3fc5d Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:45:26 -0700 Subject: [PATCH 093/117] refactor: split layer builders into engine/model/resolve/layers.rs The object-to-layer builders (image, particle, text, mesh, light, backdrop) and their placement helpers (1,040 lines) move unchanged. --- src/engine/model/resolve/layers.rs | 1042 ++++++++++++++++++++++++++++ src/engine/model/resolve/mod.rs | 1031 +-------------------------- 2 files changed, 1045 insertions(+), 1028 deletions(-) create mode 100644 src/engine/model/resolve/layers.rs 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 index 6d8419c..9955d96 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -15,6 +15,9 @@ 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 { @@ -742,920 +745,12 @@ impl ResolvedScene { } } -/// 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::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)) -} - -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(), - } -} - -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::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). -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. -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). -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) -} - impl Drop for VideoLayerStream { fn drop(&mut self) { let _ = std::fs::remove_file(&self.path); } } -/// 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. -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. -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) // @@ -1675,125 +770,5 @@ fn resolve_particle_sprite_dir( // angles approximately). // --------------------------------------------------------------------------- -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; From ed3d3f5ded46559724a32d7f62002dc804693e4a Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:46:22 -0700 Subject: [PATCH 094/117] refactor: move the CPU compositor into engine/cpu/compose.rs ResolvedScene::render and draw_image_layer (180 lines) composite layers on the CPU for render-scene and the fallback path; they now sit with the rest of the CPU renderer instead of in model::resolve. Moved unchanged as an impl block in the new module. --- src/engine/cpu/compose.rs | 186 ++++++++++++++++++++++++++++++++ src/engine/cpu/mod.rs | 1 + src/engine/model/resolve/mod.rs | 176 ------------------------------ 3 files changed, 187 insertions(+), 176 deletions(-) create mode 100644 src/engine/cpu/compose.rs 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/cpu/mod.rs b/src/engine/cpu/mod.rs index 25b7f77..cd89836 100644 --- a/src/engine/cpu/mod.rs +++ b/src/engine/cpu/mod.rs @@ -2,6 +2,7 @@ //! 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/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs index 9955d96..47703f7 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -567,182 +567,6 @@ impl ResolvedScene { 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::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)); - } - } - } - } } impl Drop for VideoLayerStream { From c7ca376f34a14d6ac5d586a095846767247eb739 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:47:10 -0700 Subject: [PATCH 095/117] docs: map the resolver's files in engine/model/resolve/mod.rs --- src/engine/model/resolve/mod.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/engine/model/resolve/mod.rs b/src/engine/model/resolve/mod.rs index 47703f7..58e7c01 100644 --- a/src/engine/model/resolve/mod.rs +++ b/src/engine/model/resolve/mod.rs @@ -1,3 +1,14 @@ +//! 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; From 6d0c6cd0063776198a4eebea3f3c78eee5b91204 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:47:54 -0700 Subject: [PATCH 096/117] refactor: move the CLI out of main.rs into cli/mod.rs main.rs becomes a three-line entry point; the argument types, dispatch and commands move unchanged to src/cli/mod.rs (with main made pub), so they can be split by area next. --- src/cli/mod.rs | 1337 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1334 +---------------------------------------------- 2 files changed, 1341 insertions(+), 1330 deletions(-) create mode 100644 src/cli/mod.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..ac70eb5 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,1337 @@ +//! 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}; + +#[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 ────────────────────────────────────────────────────────────── + +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) = 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(()) +} + +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(()) +} + +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(()) +} + +/// 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::content::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 `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. +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. +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 = 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(()) +} + +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(()) +} + +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(()) +} + +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(()) +} + +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(()) +} diff --git a/src/main.rs b/src/main.rs index 50ca912..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, formats, 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::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 ────────────────────────────────────────────────────────────── - -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) = 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(()) -} - -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(()) -} - -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(()) -} - -/// 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::content::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 `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. -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. -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 = 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(()) -} - -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(()) -} - -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(()) -} - -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(()) -} - -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(); } From a3967c9dd6ee96b511df14a9194d1026b74ac28a Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:48:26 -0700 Subject: [PATCH 097/117] refactor: split the CLI's library commands into cli/library.rs --- src/cli/library.rs | 126 +++++++++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 126 ++------------------------------------------- 2 files changed, 129 insertions(+), 123 deletions(-) create mode 100644 src/cli/library.rs 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 index ac70eb5..6499b9c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -9,6 +9,9 @@ use wp_engine::application::{ApplicationContext, WallpaperApplication}; use wp_engine::workshop::{self, Wallpaper}; use wp_engine::{engine, formats, platform, ui}; +mod library; +use library::*; + #[derive(Parser)] #[command( name = "wp-engine", @@ -351,75 +354,6 @@ fn run_ui() -> Result<()> { // ── 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, @@ -501,60 +435,6 @@ fn apply_saved_settings( 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 = 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(()) -} - fn cmd_config(action: ConfigAction) -> Result<()> { use wp_engine::settings::{Playlist, WpSettings}; From 8a5c2bcb547bb280d612389a2c70d2c29a8f2b53 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:48:43 -0700 Subject: [PATCH 098/117] refactor: split the CLI's apply commands into cli/apply.rs --- src/cli/apply.rs | 219 +++++++++++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 215 +--------------------------------------------- 2 files changed, 222 insertions(+), 212 deletions(-) create mode 100644 src/cli/apply.rs diff --git a/src/cli/apply.rs b/src/cli/apply.rs new file mode 100644 index 0000000..204e103 --- /dev/null +++ b/src/cli/apply.rs @@ -0,0 +1,219 @@ +//! 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/mod.rs b/src/cli/mod.rs index 6499b9c..86d4d67 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -12,6 +12,9 @@ use wp_engine::{engine, formats, platform, ui}; mod library; use library::*; +mod apply; +use apply::*; + #[derive(Parser)] #[command( name = "wp-engine", @@ -354,87 +357,6 @@ fn run_ui() -> Result<()> { // ── CLI commands ────────────────────────────────────────────────────────────── -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) = 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(()) -} - fn cmd_config(action: ConfigAction) -> Result<()> { use wp_engine::settings::{Playlist, WpSettings}; @@ -647,117 +569,6 @@ fn resolve_scene_dir(id_or_path: &str) -> Result { wp_engine::content::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 `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. -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. -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() @@ -883,26 +694,6 @@ fn cmd_probe() -> Result<()> { 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 = formats::pkg::Package::from_file(path)?; From 29d6fe970ace9286c7304bdef767489792c55406 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:48:57 -0700 Subject: [PATCH 099/117] refactor: split the CLI's config commands into cli/config.rs --- src/cli/config.rs | 193 ++++++++++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 193 +--------------------------------------------- 2 files changed, 196 insertions(+), 190 deletions(-) create mode 100644 src/cli/config.rs 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/mod.rs b/src/cli/mod.rs index 86d4d67..38dd832 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -15,6 +15,9 @@ use library::*; mod apply; use apply::*; +mod config; +use config::*; + #[derive(Parser)] #[command( name = "wp-engine", @@ -357,181 +360,6 @@ fn run_ui() -> Result<()> { // ── CLI commands ────────────────────────────────────────────────────────────── -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(()) -} - /// 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. @@ -549,21 +377,6 @@ fn resolve_id_or_path(id_or_path: &str) -> Result { } } -/// [`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::content::WallpaperContent::preset_base_dir(&dir) From 8c7c3ccc931fde67e8626c29dcd859cb424daeaa Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:49:13 -0700 Subject: [PATCH 100/117] refactor: split the CLI's daemon commands into cli/daemon.rs --- src/cli/daemon.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 88 ++--------------------------------------------- 2 files changed, 91 insertions(+), 85 deletions(-) create mode 100644 src/cli/daemon.rs diff --git a/src/cli/daemon.rs b/src/cli/daemon.rs new file mode 100644 index 0000000..dd389fe --- /dev/null +++ b/src/cli/daemon.rs @@ -0,0 +1,88 @@ +//! 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/mod.rs b/src/cli/mod.rs index 38dd832..672ba3e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -18,6 +18,9 @@ use apply::*; mod config; use config::*; +mod daemon; +use daemon::*; + #[derive(Parser)] #[command( name = "wp-engine", @@ -382,91 +385,6 @@ fn resolve_scene_dir(id_or_path: &str) -> Result { wp_engine::content::WallpaperContent::preset_base_dir(&dir) } -#[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(); From 8d272d926568038de8c3263f845b641bb6f6b646 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:49:29 -0700 Subject: [PATCH 101/117] refactor: split the CLI's debug commands into cli/debug.rs --- src/cli/debug.rs | 356 +++++++++++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 356 +---------------------------------------------- 2 files changed, 359 insertions(+), 353 deletions(-) create mode 100644 src/cli/debug.rs diff --git a/src/cli/debug.rs b/src/cli/debug.rs new file mode 100644 index 0000000..210ba0a --- /dev/null +++ b/src/cli/debug.rs @@ -0,0 +1,356 @@ +//! 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/mod.rs b/src/cli/mod.rs index 672ba3e..ee76574 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -21,6 +21,9 @@ use config::*; mod daemon; use daemon::*; +mod debug; +use debug::*; + #[derive(Parser)] #[command( name = "wp-engine", @@ -384,356 +387,3 @@ 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) } - -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_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(()) -} - -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(()) -} - -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(()) -} - -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(()) -} - -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(()) -} From 60da3ff05a74095c8e3611eb76d9bdcff099350d Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:49:45 -0700 Subject: [PATCH 102/117] docs: wrap the module doc in cli/apply.rs --- src/cli/apply.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/apply.rs b/src/cli/apply.rs index 204e103..defa8a6 100644 --- a/src/cli/apply.rs +++ b/src/cli/apply.rs @@ -1,4 +1,5 @@ -//! Showing wallpapers: `set`, `set-file` and `run`, and the saved settings they apply. +//! Showing wallpapers: `set`, `set-file` and `run`, and the saved settings they +//! apply. use super::*; From 75e635834e07001af062b0586749b7bd3252dff8 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:49:48 -0700 Subject: [PATCH 103/117] docs: wrap the module doc in cli/daemon.rs --- src/cli/daemon.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/daemon.rs b/src/cli/daemon.rs index dd389fe..8cee5b5 100644 --- a/src/cli/daemon.rs +++ b/src/cli/daemon.rs @@ -1,4 +1,5 @@ -//! Talking to the background daemon: `daemon`, `status`, `stop` and `playlist` (Linux; elsewhere they report the daemon is unavailable). +//! Talking to the background daemon: `daemon`, `status`, `stop` and `playlist` +//! (Linux; elsewhere they report the daemon is unavailable). use super::*; From ef939f0a0f79277b1353084dccca4d3c1647cbfb Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:49:51 -0700 Subject: [PATCH 104/117] docs: wrap the module doc in cli/debug.rs --- src/cli/debug.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/debug.rs b/src/cli/debug.rs index 210ba0a..2f06fce 100644 --- a/src/cli/debug.rs +++ b/src/cli/debug.rs @@ -1,4 +1,5 @@ -//! Inspection and debugging tools: `probe`, `pkg-info`, `tex-info`, `render-scene`, `render-frame`, `test-scene` and `preview-scene`. +//! Inspection and debugging tools: `probe`, `pkg-info`, `tex-info`, `render- +//! scene`, `render-frame`, `test-scene` and `preview-scene`. use super::*; From f48ca56284273b1155ed5543d0f6f318cfd0482a Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:51:01 -0700 Subject: [PATCH 105/117] refactor: split the GUI's apply code into ui/apply.rs --- src/ui/apply.rs | 289 ++++++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 284 +---------------------------------------------- 2 files changed, 292 insertions(+), 281 deletions(-) create mode 100644 src/ui/apply.rs 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/mod.rs b/src/ui/mod.rs index edb005e..ebe575f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -9,6 +9,9 @@ use crate::content::RenderSettings; use crate::platform::{RenderQuality, WallpaperHandle}; use crate::workshop::{self, Wallpaper, WallpaperType}; +mod apply; +use apply::*; + #[cfg(not(target_os = "linux"))] use crate::{content::WallpaperContent, platform::display::detect_platform}; use egui::{pos2, vec2, Align, Color32, CornerRadius, FontId, Layout, Rect, Sense, Stroke}; @@ -139,45 +142,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)] @@ -429,234 +393,6 @@ impl WpApp { // ── 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::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")] - 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")] - 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) @@ -1501,20 +1237,6 @@ 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::content::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", From 2434b4e1b2201d5e974e702ce41164e02f4e9068 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:52:32 -0700 Subject: [PATCH 106/117] refactor: split the GUI's thumbnails code into ui/thumbnails.rs --- src/ui/mod.rs | 58 ++--------------------------------------- src/ui/thumbnails.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 56 deletions(-) create mode 100644 src/ui/thumbnails.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ebe575f..e948b59 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -12,6 +12,8 @@ use crate::workshop::{self, Wallpaper, WallpaperType}; mod apply; use apply::*; +mod thumbnails; + #[cfg(not(target_os = "linux"))] use crate::{content::WallpaperContent, platform::display::detect_platform}; use egui::{pos2, vec2, Align, Color32, CornerRadius, FontId, Layout, Rect, Sense, Stroke}; @@ -335,62 +337,6 @@ impl WpApp { // ── 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 ───────────────────────────────────────────────── /// Workshop item path for `id`, for the displays dialog and playlist. 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); + } + } + } +} From 4cd0e586bb5ba4f547867e670a68d1699806d730 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:53:06 -0700 Subject: [PATCH 107/117] refactor: split the GUI's browse code into ui/browse.rs --- src/ui/browse.rs | 362 +++++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 353 +-------------------------------------------- 2 files changed, 364 insertions(+), 351 deletions(-) create mode 100644 src/ui/browse.rs 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/mod.rs b/src/ui/mod.rs index e948b59..2c6e8b0 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,6 +14,8 @@ use apply::*; mod thumbnails; +mod browse; + #[cfg(not(target_os = "linux"))] use crate::{content::WallpaperContent, platform::display::detect_platform}; use egui::{pos2, vec2, Align, Color32, CornerRadius, FontId, Layout, Rect, Sense, Stroke}; @@ -305,36 +307,6 @@ 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 ───────────────────────────────────────────────────── // ── Wallpaper application ───────────────────────────────────────────────── @@ -403,224 +375,6 @@ 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") @@ -1064,45 +818,6 @@ impl WpApp { // ── 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) @@ -1113,70 +828,6 @@ fn pill(ui: &mut egui::Ui, text: &str, fill: Color32, color: Color32) { }); } -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 { From 1c20a89cae1e01a9c33a82cae272f3eebdd4eee5 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:53:27 -0700 Subject: [PATCH 108/117] refactor: split the GUI's details code into ui/details.rs --- src/ui/details.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 111 +------------------------------------------- 2 files changed, 117 insertions(+), 109 deletions(-) create mode 100644 src/ui/details.rs 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/mod.rs b/src/ui/mod.rs index 2c6e8b0..ec062de 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -16,6 +16,8 @@ mod thumbnails; mod browse; +mod details; + #[cfg(not(target_os = "linux"))] use crate::{content::WallpaperContent, platform::display::detect_platform}; use egui::{pos2, vec2, Align, Color32, CornerRadius, FontId, Layout, Rect, Sense, Stroke}; @@ -375,105 +377,6 @@ impl eframe::App for WpApp { // ── Panels ──────────────────────────────────────────────────────────────────── impl WpApp { - /// 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(); @@ -818,16 +721,6 @@ impl WpApp { // ── Drawing ─────────────────────────────────────────────────────────────────── -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)); - }); -} - // ── Utilities ───────────────────────────────────────────────────────────────── fn full_uv() -> Rect { From 2643627b07e4ade25bb8e5da22f722a4f7384a1c Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:53:47 -0700 Subject: [PATCH 109/117] refactor: split the GUI's playlist code into ui/playlist.rs --- src/ui/mod.rs | 146 +------------------------------------------ src/ui/playlist.rs | 150 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 144 deletions(-) create mode 100644 src/ui/playlist.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ec062de..ed9437f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -18,6 +18,8 @@ mod browse; mod details; +mod playlist; + #[cfg(not(target_os = "linux"))] use crate::{content::WallpaperContent, platform::display::detect_platform}; use egui::{pos2, vec2, Align, Color32, CornerRadius, FontId, Layout, Rect, Sense, Stroke}; @@ -377,124 +379,6 @@ impl eframe::App for WpApp { // ── Panels ──────────────────────────────────────────────────────────────────── impl WpApp { - /// 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) { @@ -691,32 +575,6 @@ impl WpApp { } // ── 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 ─────────────────────────────────────────────────────────────────── diff --git a/src/ui/playlist.rs b/src/ui/playlist.rs new file mode 100644 index 0000000..cfe3643 --- /dev/null +++ b/src/ui/playlist.rs @@ -0,0 +1,150 @@ +//! The playlist bar under the grid: choosing, saving, playing and editing +//! the current playlist. + +use super::*; + +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(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; + } + } + }); + }); + } + + 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(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(); + } +} From 51947396211dc1c914766d5d5e64d1c2aa7db67f Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:54:09 -0700 Subject: [PATCH 110/117] refactor: split the GUI's displays code into ui/displays.rs --- src/ui/displays.rs | 134 +++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 130 +------------------------------------------ 2 files changed, 136 insertions(+), 128 deletions(-) create mode 100644 src/ui/displays.rs 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/mod.rs b/src/ui/mod.rs index ed9437f..0f01cc9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -20,6 +20,8 @@ mod details; mod playlist; +mod displays; + #[cfg(not(target_os = "linux"))] use crate::{content::WallpaperContent, platform::display::detect_platform}; use egui::{pos2, vec2, Align, Color32, CornerRadius, FontId, Layout, Rect, Sense, Stroke}; @@ -446,134 +448,6 @@ impl WpApp { 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::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. - 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 ────────────────────────────────────────────────────── } From a3d544d98b4e029644ab260a63233ac47cd14d47 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:54:29 -0700 Subject: [PATCH 111/117] refactor: split the GUI's footer code into ui/footer.rs --- src/ui/footer.rs | 72 ++++++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 69 ++-------------------------------------------- 2 files changed, 74 insertions(+), 67 deletions(-) create mode 100644 src/ui/footer.rs diff --git a/src/ui/footer.rs b/src/ui/footer.rs new file mode 100644 index 0000000..7c86362 --- /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) = 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); + } + }); + }); + }); + } + + 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 0f01cc9..c29407c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -22,6 +22,8 @@ mod playlist; mod displays; +mod footer; + #[cfg(not(target_os = "linux"))] use crate::{content::WallpaperContent, platform::display::detect_platform}; use egui::{pos2, vec2, Align, Color32, CornerRadius, FontId, Layout, Rect, Sense, Stroke}; @@ -381,73 +383,6 @@ impl eframe::App for WpApp { // ── Panels ──────────────────────────────────────────────────────────────────── impl WpApp { - /// 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); - } - // ── Playlist helpers ────────────────────────────────────────────────────── } From 9516f71f88d1f22f864bebc4183acc9eac7dafd2 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:55:19 -0700 Subject: [PATCH 112/117] refactor: split the property editor out of ui/config.rs into ui/properties.rs The details panel's property editor and its label/value helpers (300 lines, with their test) move unchanged; config.rs keeps the settings window and playlist configuration. --- src/ui/config.rs | 304 +------------------------------------------ src/ui/mod.rs | 3 +- src/ui/properties.rs | 304 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+), 301 deletions(-) create mode 100644 src/ui/properties.rs diff --git a/src/ui/config.rs b/src/ui/config.rs index 540ed9a..e48eb22 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -1,24 +1,13 @@ -//! 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. +//! Saved-settings UI: 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::content::ScaleMode; use crate::platform::RenderQuality; use crate::services::playback_gate::PlaybackAction; use crate::settings::{Playlist, WpSettings}; -use crate::workshop::properties::SceneProperty; -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)] @@ -56,37 +45,6 @@ const PLAYLIST_MODES: [(&str, &str); 4] = [ ("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. @@ -110,23 +68,6 @@ pub(super) fn pick_path(directory: bool) -> Option { }) } -/// 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() { @@ -154,219 +95,6 @@ impl WpApp { // ── 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::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`. - 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::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. - 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) { @@ -705,27 +433,3 @@ fn playlist_editor(ui: &mut egui::Ui, name: &str, p: &mut Playlist, titles: &[St } *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/mod.rs b/src/ui/mod.rs index c29407c..d6a0fd8 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -34,6 +34,7 @@ use std::sync::{Arc, Mutex}; use std::time::SystemTime; mod config; +mod properties; // ── Layout ──────────────────────────────────────────────────────────────────── @@ -137,7 +138,7 @@ pub struct WpApp { /// `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, show_displays: bool, diff --git a/src/ui/properties.rs b/src/ui/properties.rs new file mode 100644 index 0000000..bac3f73 --- /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::config::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]); + } +} From 32dcfac592eb50d87f6d90f3b0da98daaf962a8f Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:56:03 -0700 Subject: [PATCH 113/117] refactor: move the playlist Configure popup from ui/config.rs to ui/playlist.rs playlist_config, playlist_editor, the playlist modes and empty_playlist (120 lines) move unchanged next to the playlist bar that opens them. --- src/ui/config.rs | 134 ++------------------------------------------- src/ui/playlist.rs | 130 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 131 deletions(-) diff --git a/src/ui/config.rs b/src/ui/config.rs index e48eb22..3896bc5 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -1,13 +1,12 @@ -//! Saved-settings UI: 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. +//! The settings window (playback rules, frame rate, quality, scaling, audio, +//! autostart) and the GUI's shared settings helpers. Everything here edits +//! `settings.json`, the same file `wp-engine config` writes. -use super::{StatusMsg, WpApp, DANGER, TEXT_MUTED}; +use super::{StatusMsg, WpApp, TEXT_MUTED}; use crate::content::ScaleMode; use crate::platform::RenderQuality; use crate::services::playback_gate::PlaybackAction; -use crate::settings::{Playlist, WpSettings}; +use crate::settings::WpSettings; /// The settings window's tabs, after WE's own settings view. #[derive(Debug, Clone, Copy, PartialEq)] @@ -17,11 +16,6 @@ pub(super) enum SettingsTab { 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"), @@ -38,13 +32,6 @@ const SCALE_MODES: [ScaleMode; 4] = [ 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"), -]; - /// 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. @@ -294,46 +281,6 @@ impl WpApp { 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) { @@ -362,74 +309,3 @@ fn playback_action(s: &WpSettings, key: &str) -> PlaybackAction { _ => 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 -} diff --git a/src/ui/playlist.rs b/src/ui/playlist.rs index cfe3643..f4196d6 100644 --- a/src/ui/playlist.rs +++ b/src/ui/playlist.rs @@ -2,6 +2,7 @@ //! the current playlist. use super::*; +use crate::settings::Playlist; impl WpApp { /// Under the grid: the playlist for the chosen display, as in WE. @@ -33,7 +34,7 @@ impl WpApp { .playlists .get(&self.playlist) .cloned() - .unwrap_or_else(config::empty_playlist); + .unwrap_or_else(empty_playlist); self.saved.playlists.insert(name.clone(), copy); self.playlist = name; self.new_playlist.clear(); @@ -135,7 +136,7 @@ impl WpApp { .saved .playlists .entry(self.playlist.clone()) - .or_insert_with(config::empty_playlist); + .or_insert_with(empty_playlist); match p.items.iter().position(|i| i == id) { Some(i) => { p.items.remove(i); @@ -148,3 +149,128 @@ impl WpApp { 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 +} From 81777c675b3013c979003533d594731f10b63184 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:56:44 -0700 Subject: [PATCH 114/117] refactor: move the GUI's shared helpers from ui/config.rs to ui/mod.rs save_settings, daemon_request, title_of and the pick_path file dialog are used across the GUI's panels, so they live with the app state now; config.rs is left with only the settings window. --- src/ui/config.rs | 53 +++----------------------------------------- src/ui/footer.rs | 4 ++-- src/ui/mod.rs | 49 ++++++++++++++++++++++++++++++++++++++++ src/ui/properties.rs | 2 +- 4 files changed, 55 insertions(+), 53 deletions(-) diff --git a/src/ui/config.rs b/src/ui/config.rs index 3896bc5..2011718 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -1,6 +1,6 @@ -//! The settings window (playback rules, frame rate, quality, scaling, audio, -//! autostart) and the GUI's shared settings helpers. Everything here edits -//! `settings.json`, the same file `wp-engine config` writes. +//! 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; @@ -32,54 +32,7 @@ const SCALE_MODES: [ScaleMode; 4] = [ ScaleMode::Center, ]; -/// 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) - }) -} - 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 ───────────────────────────────────────────────────────── // ── Settings window ────────────────────────────────────────────────────── diff --git a/src/ui/footer.rs b/src/ui/footer.rs index 7c86362..a87d79e 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -20,13 +20,13 @@ impl WpApp { .clicked() { ui.close(); - if let Some(p) = config::pick_path(true) { + 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) = config::pick_path(false) { + if let Some(p) = pick_path(false) { self.open_path(PathBuf::from(p)); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d6a0fd8..2292a78 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -447,6 +447,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/properties.rs b/src/ui/properties.rs index bac3f73..a611140 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -2,7 +2,7 @@ //! in Wallpaper Engine's order and wording, edited live and saved to //! settings.json. -use super::config::pick_path; +use super::pick_path; use super::{WpApp, DANGER, TEXT_MUTED}; use crate::workshop::properties::SceneProperty; use serde_json::Value; From a574c64a3c7edc8ca0e4cadca651bc71787b0fd2 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:57:14 -0700 Subject: [PATCH 115/117] refactor: rename ui/config.rs to ui/settings_window.rs It holds only the settings window now; "config" read like configuration loading and clashed with the CLI's config command. --- src/ui/mod.rs | 6 +++--- src/ui/{config.rs => settings_window.rs} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename src/ui/{config.rs => settings_window.rs} (100%) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2292a78..7cf6948 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -33,8 +33,8 @@ use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::time::SystemTime; -mod config; mod properties; +mod settings_window; // ── Layout ──────────────────────────────────────────────────────────────────── @@ -140,7 +140,7 @@ pub struct WpApp { saved: crate::settings::WpSettings, 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, @@ -302,7 +302,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, diff --git a/src/ui/config.rs b/src/ui/settings_window.rs similarity index 100% rename from src/ui/config.rs rename to src/ui/settings_window.rs From 85647a7bb38803dbe532ae82fa2e6d65dd5fb238 Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:58:22 -0700 Subject: [PATCH 116/117] docs: map the GUI's files in ui/mod.rs --- src/ui/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7cf6948..26ad264 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -5,6 +5,12 @@ //! 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}; From ee9320048739b292a2a751d44f009a6d88b6bc8e Mon Sep 17 00:00:00 2001 From: Tanay Upreti Date: Sun, 18 Oct 2026 06:58:25 -0700 Subject: [PATCH 117/117] docs: add cli/ and the folder-module convention to ARCHITECTURE.md --- ARCHITECTURE.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1ee6e36..f60eeda 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,7 +12,8 @@ stage can be inspected on its own when a wallpaper renders wrongly. ```text src/ - main.rs, bin/ the wp-engine CLI and the scan-wallpapers tool + 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 @@ -25,9 +26,15 @@ src/ 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 + 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