//! 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 scales onto `0..0x80`, so `0x7F` is 253/255 and not /// fully opaque. That is **not** an accident of this function: `crates/gc-engine` /// already read the same convention inline as `if c.a < 0x80 { c.a / 128.0 } else /// { 1.0 }`, and the first version of this scaled by 127 instead, making `0x7F` /// fully opaque where the renderer had it at 253/255. The renderer was right and /// this was wrong. /// /// Extracting the rule into one place is what surfaced the disagreement, which is /// the argument for doing it even where the code already worked. #[must_use] pub fn alpha_to_8(v: u8) -> u8 { if v >= ALPHA_ONE { return 255; } // scale 0..0x7F onto 0..255, matching the renderer's /128 normalisation (((v as u32) * 255 + 64) / 128) 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 } /// The GS alpha test: compare the fragment's alpha against a reference and /// discard on one side of it. /// /// The PS2's `ATE` register holds an enable, a reference 7-bit value, and an /// `AFAIL` bit that selects *which* side fails. The bit's naming is the trap: it /// reads as "fail if alpha is above the reference", and on the hardware it means /// the opposite -- `AFAIL = 0` discards where alpha is **below** the reference. /// /// Returns true when the fragment survives. #[must_use] pub fn passes_alpha_test(alpha: u8, reference: u8, fail_above: bool) -> bool { // the opaque bit is not part of the comparison, or 0x80 would always pass let a = alpha.min(ALPHA_ONE - 1); let r = reference & 0x7F; if fail_above { a <= r } else { a >= r } } /// The four GS primitive blend modes. /// /// The PS2 names them `IIIB`, `IIIA`, `IIIB`, `IIIC` in its documentation, which /// is both unhelpful and easy to misremember -- `IIIB` names two of them. What /// each actually does is unambiguous, so that is what is here. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum BlendMode { /// No blending: the fragment replaces the destination. The GS's default. #[default] Off, /// Source only. Source, /// Destination only. Destination, /// Source plus destination. SourcePlusDestination, } impl BlendMode { /// Decode the GS primitive type byte. /// /// Types above 3 do not exist on the hardware; they are treated as /// [`BlendMode::Off`] rather than panicking, because the value comes from /// asset data. #[must_use] pub fn from_primitive_type(t: u8) -> BlendMode { match t & 3 { 1 => BlendMode::Source, 2 => BlendMode::Destination, 3 => BlendMode::SourcePlusDestination, _ => BlendMode::Off, } } /// Blend a 0..255 source over a 0..255 destination. /// /// The GS computes in 7-bit and clamps at the top, so this returns the same /// clamping behaviour rather than a true divide-and-rescale that could exceed /// 255. Callers wanting the unclamped average should blend before widening. #[must_use] pub fn blend(self, src: u8, dst: u8) -> u8 { let (s, d) = (src.min(255) as u32, dst.min(255) as u32); let out = match self { BlendMode::Off => s, BlendMode::Source => s, BlendMode::Destination => d, BlendMode::SourcePlusDestination => s + d, }; out.min(255) as u8 } } #[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), 253, "0x7F is 127/128, not fully opaque -- the renderer's convention" ); // 0x40 is exactly the midpoint. The renderer works in floats and leaves // it at 0.5; widening to an integer has to round, and this rounds half // away from zero. assert_eq!( alpha_to_8(0x40), 128, "the midpoint rounds half away from zero" ); 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 alpha_test_discards_on_the_documented_side() { // AFAIL = 0 discards BELOW the reference assert!(passes_alpha_test(0x40, 0x40, false), "equal passes"); assert!(!passes_alpha_test(0x20, 0x40, false), "below fails"); assert!(passes_alpha_test(0x60, 0x40, false), "above passes"); // AFAIL = 1 discards ABOVE it assert!(passes_alpha_test(0x40, 0x40, true)); assert!( passes_alpha_test(0x20, 0x40, true), "below passes when inverted" ); assert!( !passes_alpha_test(0x60, 0x40, true), "above fails when inverted" ); } #[test] fn the_opaque_bit_does_not_satisfy_the_alpha_test() { // 0x80 is opaque, but it must not compare as 128 against a 0x40 // reference, or everything would pass. assert!( !passes_alpha_test(ALPHA_ONE, 0x40, true), "the opaque bit leaked into the comparison" ); } #[test] fn blend_modes_match_their_names() { assert_eq!(BlendMode::from_primitive_type(0), BlendMode::Off); assert_eq!(BlendMode::from_primitive_type(1), BlendMode::Source); assert_eq!(BlendMode::from_primitive_type(2), BlendMode::Destination); assert_eq!( BlendMode::from_primitive_type(3), BlendMode::SourcePlusDestination ); assert_eq!( BlendMode::from_primitive_type(200), BlendMode::Off, "out-of-range is Off" ); assert_eq!(BlendMode::Off.blend(10, 250), 10); assert_eq!(BlendMode::Destination.blend(10, 250), 250); assert_eq!( BlendMode::SourcePlusDestination.blend(200, 200), 255, "clamps at the top" ); } #[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); } }