//! The Graphics Synthesizer's own rules, which are not the GPU's. //! //! The GS is a fixed-function part with its own fixed-point formats and its own //! rounding. Reproducing R&C2's look means reproducing these, not "whatever //! wgpu does": //! //! * mip selection is `round(log2(on-screen texels / texels in the image))`, //! clamped to the mip chain -- not a hardware mip bias and not nearest //! sampling; //! * **alpha `0x80` is 1.0**, because alpha is 7-bit. Treating `0x80` as "half" //! is the single most common way to get PS2 translucency wrong; //! * the sampler wraps **CLAMP**, so anything outside 0..1 lands on the edge //! texel rather than repeating it. //! //! Nothing here touches wgpu: these are the rules the renderer must feed it. /// Alpha is 7-bit, so `0x80` is fully opaque rather than half-transparent. /// /// Every byte in this codebase's alpha fields -- moby alpha, the `0x80` default /// the decomp's `MobyInstance` records at `+0x23` -- is in this range. pub const ALPHA_ONE: u8 = 0x80; /// Expand a GS alpha byte to the 0..255 range a shader expects. /// /// `0x80` and above is the **opaque** end, and it is reached by masking off the top /// bit -- `0x80 & 0x7F` is zero, which is precisely why `0x80` reads as "half" to /// anything that treats the byte as 8-bit unsigned. Below `0x80` the value is 7-bit /// and scales onto 1..255, so `0x7F` is opaque too and no non-zero alpha vanishes. #[must_use] pub fn alpha_to_8(v: u8) -> u8 { if v >= ALPHA_ONE { return 255; } if v == 0 { return 0; } // scale 0..127 onto 1..255 so that 127 -> 255 (((v as u32) * 255 + 63) / 127) as u8 } /// A GS texture coordinate: signed 1/4096, as the hardware stores it. pub type TexCoord = i16; /// Wrap a normalised coordinate the way the GS sampler does: CLAMP. /// /// GS coordinate wrap is `(mode, clamp, repeat, mirror)` and this project loads it /// with **clamp** for ordinary textures. `u == 1.0` is the far edge, not the start /// of the next tile, so a coordinate of exactly 1.0 stays at 1.0. #[must_use] pub fn clamp01(v: f32) -> f32 { if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) } } /// The GS's mip selection: `round(log2(on-screen / in-image))`. /// /// `on_screen` is how many texels the primitive covers on screen and `in_image` /// is the resolution of the mip level being offered -- so a texture drawn at its /// native size selects level 0, and one drawn at a quarter size selects level 2. /// The result is clamped into the mip chain rather than left to run off the end. /// /// Round-half-away-from-zero, which is what the GS's internal rounding does; using /// `f32::round` on the log2 directly would put a power of two one level too high /// for every other case. #[must_use] pub fn mip_level(on_screen: f32, in_image: f32, max_level: u32) -> i32 { if !(on_screen > 0.0) || !(in_image > 0.0) || !on_screen.is_finite() || !in_image.is_finite() { return 0; } let raw = (in_image / on_screen).log2(); // round half away from zero let rounded = if raw >= 0.0 { (raw + 0.5).floor() } else { (raw - 0.5).ceil() }; (rounded as i64).clamp(0, max_level as i64) as i32 } #[cfg(test)] mod tests { use super::*; #[test] fn alpha_80_is_fully_opaque() { assert_eq!(alpha_to_8(0x80), 255, "0x80 must not read as half"); assert_eq!(alpha_to_8(0x00), 0); assert_eq!( alpha_to_8(0x7F), 255, "the top of the 7-bit range is opaque" ); assert_eq!(alpha_to_8(0x01) > 0, true, "the low end must not vanish"); assert_eq!( alpha_to_8(0xFF), 255, "values above the range clamp to opaque" ); } #[test] fn mip_level_is_zero_at_native_size() { assert_eq!(mip_level(64.0, 64.0, 8), 0); assert_eq!(mip_level(128.0, 128.0, 8), 0); } #[test] fn mip_level_steps_by_one_per_halving() { // quarter size -> two levels down assert_eq!(mip_level(16.0, 64.0, 8), 2); assert_eq!(mip_level(8.0, 64.0, 8), 3); assert_eq!(mip_level(32.0, 64.0, 8), 1); } #[test] fn mip_level_clamps_to_the_chain() { assert_eq!(mip_level(1.0, 64.0, 8), 6); assert_eq!(mip_level(0.25, 64.0, 8), 8, "clamped at the top level"); assert_eq!(mip_level(1024.0, 64.0, 8), 0, "clamped at the bottom"); } #[test] fn degenerate_ratios_pick_level_zero_rather_than_panicking() { for (a, b) in [ (0.0, 64.0), (64.0, 0.0), (f32::NAN, 64.0), (f32::INFINITY, 64.0), ] { assert_eq!(mip_level(a, b, 8), 0, "({a}, {b}) should fall back to 0"); } } #[test] fn clamp_keeps_the_far_edge() { assert_eq!(clamp01(1.0), 1.0, "1.0 is the edge, not a wrap to 0"); assert_eq!(clamp01(1.5), 1.0); assert_eq!(clamp01(-0.5), 0.0); assert_eq!(clamp01(f32::NAN), 0.0); } }