GS alpha test and blend: all four rules the item names are now present
Finishes the rule layer. Two traps, both of which produce a picture that looks slightly wrong rather than obviously broken: AFAIL READS BACKWARDS. The PS2's alpha-test register holds an enable, a 7-bit reference, and a bit that looks like "fail if alpha is above the reference". On the hardware it means the opposite -- AFAIL = 0 discards where alpha is BELOW the reference. passes_alpha_test takes the side explicitly rather than encoding the register, and both directions are tested so the convention is pinned in code rather than in a comment. THE OPAQUE BIT MUST STAY OUT OF THE COMPARISON. 0x80 is fully opaque, but it is also numerically 128, so a test that compares the raw byte against a 0x40 reference would have every fully-opaque fragment pass every threshold, and nothing would ever be discarded. passes_alpha_test clamps the opaque bit off first, and there is a test that asserts exactly that -- the failure mode here is a scene that renders correctly with the wrong shading, which is not something anyone notices by looking. Blend is the four GS primitive types, decoded from the primitive-type byte and named by what they do rather than by the documentation's IIIB/IIIA/IIIC labels, which name two different modes identically. Out-of-range types decode to Off rather than panicking, because the byte comes from asset data. Addition clamps at 255 rather than rescaling, matching the GS's 7-bit clamp; a caller wanting a true average blends before widening, and the doc says so. That completes the four rules the roadmap names for this item: alpha 0x80 == 1.0 alpha_to_8 CLAMP wrap clamp01 mip selection mip_level blend + alpha test BlendMode, passes_alpha_test Nine unit tests, no wgpu work, and the roadmap item stays [~] rather than [x] -- the rules are the input to the renderer, not the renderer. Worth noting the pattern in both traps: each one produces output that is plausible and slightly-off, with no error anywhere. That is the same shape as the sign inversion in mip_level and the byte-order bug in the anchor search. The tests are the only thing that catches this class, which is the argument for having them even though nothing here needs a GPU.
This commit is contained in:
@@ -80,6 +80,79 @@ pub fn mip_level(on_screen: f32, in_image: f32, max_level: u32) -> i32 {
|
||||
(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::*;
|
||||
@@ -134,6 +207,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
+9
-4
@@ -711,10 +711,15 @@ lookup, and it is the main thing inherited from ReRAC.
|
||||
`0x80` is opaque, and `0x80 & 0x7F == 0` is exactly why it reads as half to
|
||||
an 8-bit reader), `clamp01` (CLAMP wrap, with `1.0` kept at the far edge),
|
||||
and `mip_level` -- `round(log2(image / on_screen))` clamped to the chain,
|
||||
where a *smaller* on-screen size selects a *higher* level. **Blend and
|
||||
alpha-test behaviour remain**, and no wgpu work has started; this is the
|
||||
rule layer the renderer will feed it, done first because it is checkable
|
||||
without a GPU.
|
||||
where a *smaller* on-screen size selects a *higher* level. **All four rules
|
||||
are now there**, plus `passes_alpha_test` and `BlendMode` for the
|
||||
alpha-test and blend halves of the item. Two traps worth naming:
|
||||
the GS's `AFAIL` bit reads as "fail if above the reference" and on the
|
||||
hardware means the opposite -- it discards where alpha is **below**; and the
|
||||
opaque bit must be kept out of the comparison, or `0x80` satisfies every
|
||||
threshold and nothing is ever discarded. **No wgpu work has started** --
|
||||
this is the rule layer the renderer will feed it, done first because it is
|
||||
checkable without a GPU.
|
||||
- [ ] VU0/VU1 skinning and lighting on the PS2 float model
|
||||
- [ ] LOD/morph/cull per `TfragProc` and `TieProc`
|
||||
- [ ] Occlusion culling, fog, shadows
|
||||
|
||||
Reference in New Issue
Block a user