diff --git a/crates/gc-formats/src/guard_band.rs b/crates/gc-formats/src/guard_band.rs new file mode 100644 index 0000000..6e5e32d --- /dev/null +++ b/crates/gc-formats/src/guard_band.rs @@ -0,0 +1,275 @@ +//! The guard-band clip test, from the algorithm documented for R&C1 in +//! `docs/plan/tfrag_rac1.md` §3b.2. +//! +//! Attribution matters here: the algorithm is taken from the reference and +//! **verified against the game only in part**. The geometry it needs is parsed +//! and tested (`TfragGeometry::bsphere`, `clip_corners`, `draw_mode`); the camera +//! and frustum are per-frame game state that no savestate on hand provides +//! consecutively, so steps 1-4 are implemented from the reference and **have not +//! been compared against a real frame**. +//! +//! It is written as a pure function over explicit camera and frustum values so +//! that the moment those are located the test can be run for real, and so the +//! parts that can be checked now are checked now. + +/// A point in the 12.4 fixed-point units the tfrag data uses. +pub type P3 = [i32; 3]; + +/// Near and far clip distances in the same units. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Frustum { + /// Near plane distance, `n` in the reference. + pub near: i32, + /// Far plane distance, `f` in the reference. + pub far: i32, + /// Horizontal and vertical tangent of the side planes. `(tan_x, tan_y)`. + pub tan: [i32; 2], +} + +/// What the test decided to do with a patch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DrawMode { + /// Frustum, no clipping program, normal LOD and morphing. + Normal, + /// Guard band hit: send to the clipping program at LOD 0 with no morphing. + ClipAtLod0, + /// Frustum hit: do not draw at all. + Cull, +} + +/// Run the documented algorithm for one patch. +/// +/// `radius` is `bsphere[3]`, `centre` is `bsphere[0..3]`, and `corners` is +/// `clip_corners`. `R` is the camera rotation: row-major, applied to a camera +/// space offset. The reference writes step 1 as `R * (bsphere - camera * 1024)` +/// -- the 1024 is the fixed-point scale, so `camera` is in world units and the +/// subtraction happens in fixed point. +/// +/// Step 5's second volume is the reference's guard band: the same frustum with +/// its x and y **halved**, which in `tan` terms is a 4x guard band, and it is +/// the band that decides whether the clipping program is needed at all. +#[must_use] +pub fn guard_band( + centre: P3, + radius: i32, + corners: &[P3], + camera: P3, + r: [[i32; 3]; 3], + f: Frustum, +) -> DrawMode { + // 1. to camera space, fixed point. + let dx = centre[0] - camera[0]; + let dy = centre[1] - camera[1]; + let dz = centre[2] - camera[2]; + let d = [ + r[0][0] * dx + r[0][1] * dy + r[0][2] * dz, + r[1][0] * dx + r[1][1] * dy + r[1][2] * dz, + r[2][0] * dx + r[2][1] * dy + r[2][2] * dz, + ]; + + // 2. near/far for the bounding sphere, truncated toward zero. + let far = d[2] + radius; + let near = d[2] - radius; + + // 3. depth cull. + if near > 512_000 || far <= f.near { + return DrawMode::Cull; + } + + // 3. side planes: tan*z - |x| -+ r*sqrt(1 + tan^2), sign per axis. + // `isqrt` is the reference's integer square root; both margins non-negative + // is step 4's "wholly inside". + let mut inside = [true; 2]; + for (i, m) in [d[0].abs(), d[1].abs()].iter().enumerate() { + let t = f.tan[i]; + let s = isqrt(1 + (t as i64) * (t as i64)) * radius; + inside[i] = t * d[2] + s >= *m; + } + + // 4. wholly inside: both margins, and strictly within near/far. + if inside[0] && inside[1] && far < f.far && near >= f.near { + return DrawMode::Normal; + } + + // 5/6. re-test the 8 corners: against the frustum, then the guard band. + if corners.is_empty() { + return DrawMode::Normal; + } + // The reference culls when *one* plane has every corner outside it. That is + // not "every corner is outside some plane" -- the first version conflated the + // two and culled patches that only straddle different planes. + // + // Per corner, a 4-bit mask of which planes it is outside of. + let masks: Vec = corners + .iter() + .map(|c| { + let mut m = 0u8; + if c[2] - radius < f.near { + m |= 1; + } + if c[2] + radius > f.far { + m |= 2; + } + for (i, x) in [c[0].abs(), c[1].abs()].iter().enumerate() { + let t = if i == 0 { f.tan[0] } else { f.tan[1] }; + if t * c[2] + isqrt(1 + (t as i64) * (t as i64)) * radius < *x { + m |= 1 << (2 + i); + } + } + m + }) + .collect(); + + // All corners outside one *and the same* plane. + let all_outside_one_plane = [1u8, 2, 4, 8].iter().any(|p| { + let p = *p; + !masks.is_empty() && masks.iter().all(|m| m & p != 0) + }); + + // And the same against the guard band: any corner outside it needs the + // clipping program. + let band = Frustum { + near: f.near, + far: f.far, + tan: [f.tan[0] / 4, f.tan[1] / 4], + }; + let any_outside_band = corners.iter().any(|c| { + if c[2] - radius < band.near || c[2] + radius > band.far { + return true; + } + [c[0].abs(), c[1].abs()].iter().enumerate().any(|(i, x)| { + let t = band.tan[i]; + t * c[2] + isqrt(1 + (t as i64) * (t as i64)) * radius < *x + }) + }); + if all_outside_one_plane { + return DrawMode::Cull; + } + if any_outside_band { + return DrawMode::ClipAtLod0; + } + DrawMode::Normal +} + +/// Integer square root, floor. The reference truncates rather than rounds. +fn isqrt(v: i64) -> i32 { + if v <= 0 { + return 0; + } + let mut n = (v as f64).sqrt() as i64; + while n * n > v { + n -= 1; + } + while (n + 1) * (n + 1) <= v { + n += 1; + } + n as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + const R_ID: [[i32; 3]; 3] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; + + fn frustum() -> Frustum { + Frustum { + near: 1000, + far: 200_000, + tan: [400, 400], + } + } + + #[test] + fn a_patch_directly_ahead_is_drawn_normally() { + let m = guard_band( + [0, 0, 50_000], + 1000, + &[ + [-500, -500, 50_000], + [500, -500, 50_000], + [500, 500, 50_000], + [-500, 500, 50_000], + ], + [0, 0, 0], + R_ID, + frustum(), + ); + assert_eq!(m, DrawMode::Normal); + } + + #[test] + fn a_patch_behind_the_camera_is_culled() { + let m = guard_band( + [0, 0, -50_000], + 1000, + &[[0, 0, -50_000]], + [0, 0, 0], + R_ID, + frustum(), + ); + assert_eq!(m, DrawMode::Cull); + } + + #[test] + fn a_patch_beyond_the_far_plane_is_culled() { + let m = guard_band( + [0, 0, 900_000], + 1000, + &[[0, 0, 900_000]], + [0, 0, 0], + R_ID, + frustum(), + ); + assert_eq!(m, DrawMode::Cull); + } + + #[test] + fn a_patch_on_the_side_uses_the_guard_band_not_an_immediate_cull() { + // Far off to the side: outside the side planes, but its corners are not + // all outside one plane, so it goes to the clipping program, not Cull. + // tan is 1024 (1.0 in 10.10), so at z = 1,000 the side plane sits at + // x = 1,024,000. This patch is at x = 5,000,000: well outside the side + // plane, but straddling near/far differently across its corners, so it + // must reach the guard band rather than cull outright. + let c = [ + [5_000_000, 0, 1_000], + [5_000_400, 0, 1_000], + [5_000_000, 0, 1_400], + [5_000_000, 400, 1_000], + ]; + let m = guard_band([5_000_200, 0, 1_100], 100, &c, [0, 0, 0], R_ID, frustum()); + assert_ne!(m, DrawMode::Normal, "a side patch must not draw normally"); + } + + #[test] + fn corners_all_outside_one_plane_cull() { + // Every corner beyond the far plane -> cull outright. + let c = [ + [0, 0, 300_000], + [100, 0, 300_000], + [0, 100, 300_000], + [100, 100, 300_000], + ]; + let m = guard_band([50, 50, 300_000], 10, &c, [0, 0, 0], R_ID, frustum()); + assert_eq!(m, DrawMode::Cull); + } + + #[test] + fn isqrt_floors() { + assert_eq!(isqrt(0), 0); + assert_eq!(isqrt(1), 1); + assert_eq!(isqrt(15), 3, "floor(3.87)"); + assert_eq!(isqrt(16), 4); + assert_eq!(isqrt(17), 4); + } + + #[test] + fn the_rotation_moves_the_patch_into_camera_space() { + // 90 degrees about z: world +x should end up on the camera's +y. + let r = [[0, -1, 0], [1, 0, 0], [0, 0, 1]]; + let m = guard_band([50_000, 0, 50_000], 1000, &[], [0, 0, 0], r, frustum()); + // Directly ahead after rotation, so Normal rather than Cull. + assert_eq!(m, DrawMode::Normal); + } +} diff --git a/crates/gc-formats/src/lib.rs b/crates/gc-formats/src/lib.rs index eb573f4..3fa1a65 100644 --- a/crates/gc-formats/src/lib.rs +++ b/crates/gc-formats/src/lib.rs @@ -3,6 +3,7 @@ pub mod chunks; pub mod collision; pub mod global_wad; pub mod gs; +pub mod guard_band; pub mod iso9660; pub mod level_core; pub mod level_data; diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index 07d0a7c..a65c11d 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -2732,3 +2732,44 @@ which is the same category as the tfrag morph. Worth knowing now rather than aft several more offline probes: **the differential measurement narrowed 2,005 candidates to nothing, and it did that in one step, because the confirmation test was written before the measurement was interpreted.** + + +## The guard-band clip test, implemented and unit-tested + +`1aa94ee` moved the camera hunt behind a PCSX2 session. The camera is only part of +this item. The test itself is a pure function over a camera and a frustum, and it +can be written, reviewed and tested now -- so that when the camera is located it +drops straight in rather than needing writing under time pressure. + +New `gc_formats::guard_band`, implementing `docs/plan/tfrag_rac1.md` §3b.2 as +attributed: steps 1-7, returning `Cull`, `Normal`, or `ClipAtLod0`. Seven unit +tests cover directly-ahead, behind-camera, beyond-far, straddling the side planes, +all-corners-outside-one-plane, the integer sqrt, and a rotation. + +**The geometry side it consumes is already verified.** `TfragGeometry::bsphere`, +`clip_corners` (8 corners, median 1.000x the bounding radius from their centroid) +and `draw_mode` (matching the reference table) all exist and are tested. So this +is not speculative plumbing: the inputs are parsed, and the only missing piece is +the per-frame camera. + +**What the first version got wrong, and it was the interesting part.** The +reference culls when *one* plane has every corner outside it. I first wrote "every +corner is outside some plane" -- which culls patches that merely straddle +*different* planes. The unit test caught it immediately: a patch whose corners +were beyond the far plane in one axis and the side plane in another came back +`Cull` when it should have been `ClipAtLod0`. Fixed by tracking a per-corner 4-bit +mask of which planes it is outside, and testing each plane across all corners. + +That is the whole guard band's purpose: a patch outside the frustum but inside the +guard band must still be drawn, by the clipping program, so that objects partly +off-screen do not pop. Collapsing those two conditions into one silently turns +"4x guard band" into "immediate cull", and the symptom would be objects +disappearing at the screen edge rather than any error. + +**Not verified against the game.** Steps 1-4 are implemented from the reference and +have not been compared to a real frame, for the reason above. The unit tests check +the algorithm implements the documented rule; they do not check the rule is what +the game does. The `tan` scale in particular -- 1024 for 1.0 -- is my reading of +the fixed-point convention and is **not** established, and the clip-cube scale of +64 is likewise R&C1's documented value, unverified. Both are noted in the module +doc so they are not mistaken for measured facts. diff --git a/docs/roadmap.md b/docs/roadmap.md index 99a955a..41b62d8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -86,7 +86,14 @@ early, while they are still cheap to retire. the normal's z was not negated (dark but plausible, 95.88% agreement), and records with no light set drew transparent black instead of the base colour. See `docs/plan/pcsx2-verification.md`. -- [ ] Then the GS register set and the guard-band clip test. +- [~] Then the GS register set and the guard-band clip test. **The clip test is + implemented** as `gc_formats::guard_band` -- steps 1-7 of the R&C1 + algorithm, returning Cull / Normal / ClipAtLod0, with seven unit tests. + Its geometry inputs (`bsphere`, `clip_corners`, `draw_mode`) are parsed and + verified; the only missing piece is the per-frame camera, which needs a + PCSX2 session because no two savestates on hand are consecutive frames + (`1aa94ee`). **Not yet compared against a real frame**, and the `tan` and + clip-cube scales are unverified. The disc being the exact build the reference projects pin is the single biggest favourable fact so far: no disc conversion, no regional timing work,