GC starts: the GS's own rules, as a module with tests
Picked Phase 3's GS item and did the part that is checkable without a GPU -- the rule layer the renderer will have to feed wgpu. New gc_formats::gs with three of the four rules the roadmap names, each unit-tested. alpha_to_8: 0x80 IS OPAQUE. The obvious implementation of this is let v = value & 0x7F; and it is wrong, because 0x80 & 0x7F is 0 -- which is precisely the mechanism that makes a 7-bit alpha read as "half" to anything treating the byte as 8-bit unsigned. The first version of this function did exactly that and the test caught it. Below 0x80 the value is genuinely 7-bit and scales onto 1..255, so 0x7F is opaque too and no non-zero alpha vanishes. clamp01: CLAMP wrap, with 1.0 kept AT the far edge rather than wrapping to 0. Under repeat sampling an exact 1.0 is a discontinuity; under clamp it is the edge texel, and the game loads its textures that way. mip_level: round(log2(image / on_screen)) clamped to the chain. The ratio is image-over-screen and not the reverse -- a SMALLER on-screen size selects a HIGHER mip level. I had this inverted in the first version, which made mip_level(1.0, 64.0, 8) return 0 where it must return 6, and the test caught that too. Both bugs were the same kind: a plausible implementation of a rule whose sign convention is not obvious, and the only thing standing between them and a commit was the test. Not started: wgpu itself, and blend/alpha-test behaviour. This is the rule layer only, and the roadmap item stays [~] rather than being ticked, because three of four rules and zero of the GPU work is not "done". Two of the four rules the item names were already half-present in the renderer -- moby alpha and the 1/4096 UVs -- so making them explicit here rather than scattered is worth something on its own: 0x80's meaning was previously implicit in however each call site happened to read the byte.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod buf;
|
||||
pub mod chunks;
|
||||
pub mod collision;
|
||||
pub mod global_wad;
|
||||
pub mod gs;
|
||||
pub mod iso9660;
|
||||
pub mod level_core;
|
||||
pub mod level_data;
|
||||
|
||||
+9
-3
@@ -706,9 +706,15 @@ lookup, and it is the main thing inherited from ReRAC.
|
||||
|
||||
**Goal:** draw a frame.
|
||||
|
||||
- [ ] Graphics Synthesizer in wgpu, with the GS's own rules: its
|
||||
`round(log2(z/n) + K)` mip selection, blend and alpha-test behaviour,
|
||||
alpha `0x80` meaning 1.0, CLAMP wrap
|
||||
- [~] Graphics Synthesizer in wgpu, with the GS's own rules. **`gc_formats::gs`
|
||||
now holds three of the four, each with unit tests:** `alpha_to_8` (where
|
||||
`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.
|
||||
- [ ] 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