The guard-band clip test: implemented and unit-tested ahead of the camera
1aa94ee moved the camera hunt behind a PCSX2 session, because no two savestates on
hand are consecutive frames. But the camera is only part of this item -- the test
itself is a pure function over a camera and a frustum, so it can be written and
tested now, and then drop straight in when the camera is found rather than being
written under time pressure later.
New gc_formats::guard_band implements docs/plan/tfrag_rac1.md 3b.2 steps 1-7,
returning Cull, Normal or ClipAtLod0. Seven unit tests: directly ahead, behind
camera, beyond far, straddling the side planes, all-corners-outside-one-plane, the
integer sqrt, and a rotation.
This is not speculative plumbing. The inputs are already parsed and verified:
TfragGeometry::bsphere, clip_corners (8 corners, median 1.000x the bounding radius
from their centroid) and draw_mode (matching the reference table).
WHAT THE FIRST VERSION GOT WRONG
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 test caught it at once: a patch whose corners were beyond
far in one axis and the side plane in another returned Cull where it should be
ClipAtLod0. Fixed by carrying a per-corner 4-bit mask of which planes it is
outside and testing each plane across all corners.
That is what the guard band is for. A patch outside the frustum but inside the
guard band must still draw, via the clipping program, so objects partly off-screen
do not pop. Collapsing the two turns "4x guard band" into "immediate cull", and
the symptom is objects vanishing 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, not that the rule is what the game does. Two scales are my
reading of the fixed-point convention and are NOT established: tan = 1024 for 1.0,
and the clip-cube scale of 64. Both are flagged in the module doc so they are not
mistaken for measured facts.
293 passing with the disc and savestate present, clippy clean.
This commit is contained in:
@@ -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<u8> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
+8
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user