Pulling the 0x80 convention out of the renderer and into gc_formats::gs -- the
side benefit I claimed last commit rather than took -- immediately produced a
contradiction, which is the argument for having done it.
crates/gc-engine/src/level.rs already read it inline:
let a = if c.a < 0x80 { c.a as f32 / 128.0 } else { 1.0 };
My first alpha_to_8 scaled by 127 instead of 128, which made 0x7F fully opaque
where the renderer had it at 253/255. One of them was wrong and it was not the new
one: the disc convention is a 0..0x80 range with 0x80 as the top, so 0x7F is
127/128, not 127/127.
Corrected to match, and the doc now records why -- including that the renderer was
already right, so this is a correction to code I wrote two commits ago rather than
to anyone else's.
One genuine ambiguity remains and it is stated rather than papered over: 0x40 is
exactly the midpoint, and the renderer works in floats where it is exactly 0.5.
Widening to an integer has to round, and this rounds half away from zero giving
128. There is no way to check that against the disc -- it needs a frame, which is
the tfrag morph item's unblock condition -- so it is documented as a choice.
The engine's call site keeps its inline form and now says why: it wants a float,
and the rule has one live site. Converting it would have meant changing working
code for no benefit, which is the opposite of what extracting the rule was for.
Worth noting what actually happened here. Extracting a rule to remove a magic
constant found that two expressions of it disagreed. Neither was obviously wrong
from its own code -- mine looked cleaner, and was not. That is a better argument
for centralising a rule than any amount of saying so.
283 lines
10 KiB
Rust
283 lines
10 KiB
Rust
//! 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);
|
|
}
|
|
}
|