Files
Going-Commando/crates/gc-engine/src/level.rs
T
ReaperOfVeriod b8b4a1a3d3 Consolidate: tidy level.rs, correct stale docs, pin hardware models
No behaviour change. This is the pass over the last stretch of work to
make sure the accumulated result hangs together rather than only each
commit in isolation.

Code. lit_color had grown a `let _ = (depth, lod_distances)` and a dead
branch from repeated edits. The directional pass genuinely does not
depend on distance, so the parameters are gone rather than discarded,
with a note that the point lights -- not implemented -- will need them.
lit_at folded into lit_color, which had become a wrapper around it.
Checked for leftover scaffolding: only two env vars remain, both real
(GC_ENGINE_SHOT and GC_ENGINE_DEBUG_BOUNDS); GC_ENGINE_UNLIT,
GC_ENGINE_DEBUG_COLOR and GC_ENGINE_ABL were never actually written,
because those edits silently failed to apply against rustfmt'd source.

AGENTS.md. The crate table did not say that gc-engine owns hardware
models. ps2.rs is the Emotion Engine's FPU and the lighting pass runs
on it, so the boundary is now stated: gc-formats decodes inputs,
gc-engine reproduces how the hardware computes with them. That is the
distinction rule 2 depends on, and it was implicit.

Docs. Three claims had been overtaken by what we learned and were
actively misleading:

* docs/formats/level-wad.md still said gameplay "does not decode with
  the WAD decoder". It does; it is a WAD stream.
* docs/plan/level-assets.md carried a whole section, "a compression we
  have not identified", with a ruled-out list including "bit order",
  "header length" and "the WAD format at all". All three were ruled out
  against the wrong input. Replaced, with the failure mode kept: a
  uniform failure across 27 inputs was read as "the decoder is wrong"
  when it meant "the data was handed over uncompressed", and the
  positive control -- decompress and look -- was never run.
* The same file still said the tfrag "record framing is not recovered".
  True, but it was a false lead that has since been superseded by the
  VIF1 framing. Kept as a recorded wrong turn rather than deleted.

Also refreshed "Still open" to name what is actually outstanding:
point lights, the GS register set, the guard-band clip test, the
unread core_index class tables, and -- stated plainly -- that lighting
magnitudes are unverified because there is still nothing to compare
against.

Verified after the changes: 148 tests pass, clippy and fmt clean, all
5,213 extracted files match the hash table, and level 0 still loads at
26,758 triangles with the same LOD split and fully lit.
2026-10-10 03:37:03 +02:00

699 lines
24 KiB
Rust

//! Turning a level's parsed tfrags into something the renderer can draw.
//!
//! Nothing here parses bytes; `gc-formats` has done that. This is the join
//! between the format layer and the renderer: it applies LOD selection and
//! morphing for a given camera, then groups triangles by the texture they
//! sample so each texture is one draw rather than one per triangle.
//!
//! # Coordinates
//!
//! The PS2 world is **Z-up** and 1024 raw units to the world unit. Bevy is
//! Y-up and metres, so `(x, y, z) -> (x, z, -y)`. The sign flip on the third
//! axis is what keeps the level from coming out mirrored: it turns the PS2's
//! right-handed frame into Bevy's left-handed one rather than negating a
//! determinant.
//!
//! # Depth
//!
//! The game measures view-space depth, this uses distance from the camera.
//! They agree for a camera looking down -Z with no roll, which is all a
//! fly-camera needs; anything else should move the projection into
//! `gc-formats::tfrag::draw_mode`'s caller rather than being fudged here.
use std::collections::BTreeMap;
use gc_formats::level_core::{texture_table, LevelCoreHeader};
use gc_formats::texture::{base_image, Texture};
use gc_formats::tfrag::{
parse_tfrag, tfrag_lod_link, tfrag_triangles, TfragAdGifs, TfragDrawMode, TfragGeometry,
TfragHeader, TfragsBlock,
};
use gc_formats::tfrag_light::{LightBank, NormalTable};
/// Raw position units per world unit.
pub const UNITS_PER_UNIT: f32 = 1024.0;
/// A level's geometry, decoded and ready to draw.
pub struct LevelGeometry {
/// Draw batches keyed by level texture index.
pub batches: BTreeMap<usize, TriangleBatch>,
/// One decoded image per texture actually referenced.
pub textures: BTreeMap<usize, Texture>,
pub patches: Vec<PatchBounds>,
pub lod_distances: [f32; 3],
pub lod_thresholds_raw: [i32; 3],
/// Patches that drew nothing, and why they were skipped.
pub stats: LevelStats,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct LevelStats {
pub patches: usize,
pub drawn: usize,
pub skipped_unparsed: usize,
pub triangles: usize,
pub vertices: usize,
/// Vertices shaded by `LightTfrags`. Below `vertices` means the level fell
/// back to the tfrag's stored colours, which is a different picture and
/// should be visible in the log rather than discovered by eye.
pub lit_vertices: usize,
}
pub struct PatchBounds {
pub centre: [f32; 3],
pub radius: f32,
pub base_only: bool,
pub mode: TfragDrawMode,
pub depth: f32,
}
/// One draw batch: every triangle sampling a single texture.
pub struct TriangleBatch {
pub positions: Vec<[f32; 3]>,
pub uvs: Vec<[f32; 2]>,
pub colors: Vec<[f32; 4]>,
/// Surface normals, from the patch's own light records.
pub normals: Vec<[f32; 3]>,
/// Three vertex indices per triangle.
pub indices: Vec<u32>,
/// The ad-gif in force per triangle, parallel to `indices`.
pub ad_gifs: Vec<TfragAdGifs>,
}
/// Everything a level's tfrags need from the unpacked disc.
pub struct LevelSource<'a> {
pub core_data: &'a [u8],
pub core_index: &'a [u8],
pub gs_ram: &'a [u8],
/// The level overlay, for the normal table. Optional: without it the level
/// still builds, just without normals.
pub overlay: Option<&'a [u8]>,
/// The level's `gameplay` extent, for the light bank. Optional.
pub gameplay: Option<&'a [u8]>,
}
/// Where a level's terrain sits: the centre of its patch bounding spheres and
/// a radius that contains them.
///
/// The camera has to start here rather than at the origin. Level geometry is
/// absolutely positioned, not centred, and level 0's terrain spans x 96..434
/// and y -36..472 in world units -- far enough that a camera at the origin
/// puts every patch beyond the LOD switch distance and the whole level draws
/// at its coarsest.
pub fn level_bounds(src: &LevelSource<'_>) -> anyhow::Result<([f32; 3], f32)> {
let region = |off: usize| -> usize {
i32::from_le_bytes(src.core_index[off..off + 4].try_into().unwrap()).max(0) as usize
};
let start = region(0x08);
let end = [0x0c_usize, 0x10, 0x14]
.iter()
.map(|&o| region(o))
.filter(|&v| v > 0)
.min()
.unwrap_or(src.core_data.len());
let block = &src.core_data[start..end];
let header = TfragsBlock::parse(block)?;
let mut lo = [f32::INFINITY; 3];
let mut hi = [f32::NEG_INFINITY; 3];
let mut n = 0usize;
for i in 0..header.tfrag_count as usize {
let Ok(th) = header.header(block, i) else {
continue;
};
let c = to_world([th.bsphere[0], th.bsphere[1], th.bsphere[2]]);
let r = th.bsphere[3] / UNITS_PER_UNIT;
for k in 0..3 {
lo[k] = lo[k].min(c[k] - r);
hi[k] = hi[k].max(c[k] + r);
}
n += 1;
}
if n == 0 {
anyhow::bail!("level has no patches");
}
let centre = [
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
];
let radius = (0..3)
.map(|k| ((hi[k] - lo[k]) / 2.0).powi(2))
.sum::<f32>()
.sqrt();
Ok((centre, radius))
}
/// PS2 world units to Bevy metres, Z-up to Y-up.
pub fn to_world(p: [f32; 3]) -> [f32; 3] {
[
p[0] / UNITS_PER_UNIT,
p[2] / UNITS_PER_UNIT,
-p[1] / UNITS_PER_UNIT,
]
}
/// World position of a vertex-info entry in raw PS2 units, before the axis
/// swap.
///
/// Morphing has to happen in this space: mixing a converted parent with an
/// unconverted child silently scales the blend by 1024.
pub fn raw_position(g: &TfragGeometry, vinfo: usize) -> [f32; 3] {
let local = g.positions[g.position_index(vinfo)];
[
(g.origin[0] + local[0]) as f32,
(g.origin[1] + local[1]) as f32,
(g.origin[2] + local[2]) as f32,
]
}
/// World position of a vertex-info entry, in Bevy metres.
pub fn world(g: &TfragGeometry, vinfo: usize) -> [f32; 3] {
to_world(raw_position(g, vinfo))
}
/// Texture coordinate of a vertex-info entry, as 0..1. The on-disc field is
/// signed 1/4096 fixed point.
pub fn uv(g: &TfragGeometry, vinfo: usize) -> [f32; 2] {
let v = g.vertex_info.get(vinfo).copied().unwrap_or_default();
[v.s as f32 / 4096.0, v.t as f32 / 4096.0]
}
/// A vertex as the GPU wants it, after LOD morphing and collapse.
pub struct ResolvedVertex {
pub position: [f32; 3],
pub uv: [f32; 2],
/// The patch's stored per-vertex colour.
///
/// Indexed by *position*, not by vertex-info: several vertex-info entries
/// can share a position, and the colour array is sized `vert_count`, which
/// is a position count. This is the game's base colour before lighting
/// multiplies into it, so on its own it is the unlit albedo.
pub color: [f32; 4],
}
/// The surface normal of a vertex's position, in Bevy space.
///
/// Taken from the patch's per-vertex light record, which stores an azimuth and
/// elevation index into the overlay's sine table rather than an angle. Morphing
/// does not rotate the normal -- the game lerps positions and leaves lighting
/// to its own pass -- so this is the vertex's own normal regardless of blend.
pub fn vertex_normal(g: &TfragGeometry, vinfo: usize, table: Option<&NormalTable>) -> [f32; 3] {
let Some(table) = table else {
return [0.0, 1.0, 0.0];
};
let i = g.position_index(vinfo);
let Some(light) = g.lights.get(i) else {
return [0.0, 1.0, 0.0];
};
let n = table.normal(light.azimuth_index(), light.elevation_index());
// Same Z-up to Y-up swap as positions, or the lighting comes out sideways.
[n[0], n[2], -n[1]]
}
/// A vertex's colour after the lighting pass, and whether it was lit at all.
///
/// Lighting runs over *positions*, not vertex-info entries: a common entry and
/// a LOD entry can share a position, and the light record is indexed by
/// position, so every entry resolves through the same lookup.
///
/// Takes no distance. The directional pass does not depend on it; the point
/// lights, which are not implemented yet, will.
///
/// Falls back to the tfrag's stored colour when there is no usable light bank.
/// That is a real difference from the game, so the caller reports how many
/// vertices were actually lit rather than quietly showing stored colours as
/// though they were lit.
fn lit_color(
g: &TfragGeometry,
vinfo: usize,
stored: [f32; 4],
lighting: Option<(&NormalTable, &LightBank)>,
) -> ([f32; 4], bool) {
let Some((table, bank)) = lighting.filter(|(_, b)| !b.sets.is_empty()) else {
return (stored, false);
};
let i = g.position_index(vinfo);
let Some(light) = g.lights.get(i) else {
return ([1.0, 1.0, 1.0, 1.0], true);
};
let n = crate::tfrag_light::vertex_normal_bits(
table,
light.azimuth_index(),
light.elevation_index(),
);
match crate::tfrag_light::light_vertex(bank, n, light) {
Some(c) => (
[
c[0] as f32 / 255.0,
c[1] as f32 / 255.0,
c[2] as f32 / 255.0,
1.0,
],
true,
),
// The light set this vertex names does not exist. Count it as unlit
// rather than inventing a colour.
None => (stored, false),
}
}
/// The stored colour of a vertex's position, or opaque white if absent.
pub fn vertex_color(g: &TfragGeometry, vinfo: usize) -> [f32; 4] {
let i = g.position_index(vinfo);
match g.rgba.get(i) {
// The disc's 0x80-is-1.0 alpha convention, same as the texture CLUT.
Some(c) => {
let a = if c.a < 0x80 { c.a as f32 / 128.0 } else { 1.0 };
[
c.r as f32 / 255.0,
c.g as f32 / 255.0,
c.b as f32 / 255.0,
a,
]
}
None => [1.0, 1.0, 1.0, 1.0],
}
}
/// Resolve one vertex-info entry at a given view depth.
///
/// Two things happen here and they are not the same. **Morphing** blends the
/// vertex toward the midpoint of its two parents across its tier's distance
/// band. **Collapse** replaces the vertex outright -- position *and* UV -- with
/// its first parent once it is past the band's far edge, which is what stops
/// distant terrain from shimmering. Morphing is a blend; collapse is a cut.
pub fn resolve_vertex(
g: &TfragGeometry,
vinfo: usize,
depth: f32,
lod_distances: [f32; 3],
collapse: bool,
) -> ResolvedVertex {
let Some(link) = tfrag_lod_link(g, vinfo) else {
return ResolvedVertex {
position: world(g, vinfo),
uv: uv(g, vinfo),
color: vertex_color(g, vinfo),
};
};
let limit = link.tier.collapse_distance(lod_distances);
if collapse && depth >= limit {
return ResolvedVertex {
position: world(g, link.parent1_vinfo),
uv: uv(g, link.parent1_vinfo),
// Collapse replaces the vertex outright, colour included.
color: vertex_color(g, link.parent1_vinfo),
};
}
if !link.morphs {
return ResolvedVertex {
position: world(g, vinfo),
uv: uv(g, vinfo),
color: vertex_color(g, vinfo),
};
}
let u = link.tier.weight(depth, lod_distances);
if u <= 0.0 {
return ResolvedVertex {
position: world(g, vinfo),
uv: uv(g, vinfo),
color: vertex_color(g, vinfo),
};
}
let p1 = raw_position(g, link.parent1_vinfo);
let p2 = g.positions[link.parent2_position];
let mid = [
(p1[0] + p2[0] as f32) / 2.0,
(p1[1] + p2[1] as f32) / 2.0,
(p1[2] + p2[2] as f32) / 2.0,
];
let here = raw_position(g, vinfo);
let blended = [
here[0] + (mid[0] - here[0]) * u,
here[1] + (mid[1] - here[1]) * u,
here[2] + (mid[2] - here[2]) * u,
];
// UVs and colours are not morphed: the blend is positional only, and the
// game keeps the high-detail texture coordinate and colour.
ResolvedVertex {
position: to_world(blended),
uv: uv(g, vinfo),
color: vertex_color(g, vinfo),
}
}
/// Whether a draw mode collapses vertices past their tier's limit rather than
/// only morphing them.
pub fn mode_collapses(mode: TfragDrawMode) -> bool {
matches!(
mode,
TfragDrawMode::Lod1Collapse | TfragDrawMode::Lod0Collapse | TfragDrawMode::Lod2
)
}
/// Decode every patch in a level's tfrags block, as seen from `camera`.
pub fn build_level(src: &LevelSource<'_>, camera: [f32; 3]) -> anyhow::Result<LevelGeometry> {
let normals = src.overlay.and_then(|o| NormalTable::from_overlay(o).ok());
let bank = src.gameplay.and_then(|g| LightBank::parse(g).ok());
let lighting = normals.as_ref().zip(bank.as_ref());
let ch = LevelCoreHeader::parse(src.core_index)?;
let table = texture_table(src.core_index, ch.tfrag_textures)?;
let region = |off: usize| -> usize {
i32::from_le_bytes(src.core_index[off..off + 4].try_into().unwrap()).max(0) as usize
};
let start = region(0x08);
let end = [0x0c_usize, 0x10, 0x14]
.iter()
.map(|&o| region(o))
.filter(|&v| v > 0)
.min()
.unwrap_or(src.core_data.len());
if start >= end {
anyhow::bail!("tfrags region is empty");
}
let block = &src.core_data[start..end];
let header = TfragsBlock::parse(block)?;
let header_off = header.table_offset as usize;
let thresholds = header.lod_thresholds_raw();
let lod_distances = header.lod_distances();
let textures_base = ch.textures_base.max(0) as usize;
let texture_block = src.core_data.get(textures_base..).ok_or_else(|| {
anyhow::anyhow!("textures base {textures_base} is past the end of core data")
})?;
let mut stats = LevelStats {
patches: header.tfrag_count as usize,
..Default::default()
};
let mut patches = Vec::with_capacity(header.tfrag_count as usize);
let mut batches: BTreeMap<usize, TriangleBatch> = BTreeMap::new();
for i in 0..header.tfrag_count as usize {
let th: TfragHeader = match header.header(block, i) {
Ok(h) => h,
Err(_) => {
stats.skipped_unparsed += 1;
continue;
}
};
let data_base = header_off.wrapping_add(th.data as usize);
if data_base >= block.len() {
stats.skipped_unparsed += 1;
continue;
}
let g = parse_tfrag(&block[data_base..], &th)?;
let centre = to_world([th.bsphere[0], th.bsphere[1], th.bsphere[2]]);
let radius = th.bsphere[3] / UNITS_PER_UNIT;
let depth = distance(camera, centre).max(0.0);
let mode = g.draw_mode(depth * UNITS_PER_UNIT, thresholds);
let collapse = mode_collapses(mode);
let lod = mode.lod();
let tris = tfrag_triangles(&g, lod)?;
let limit = g.lod_vinfo_count(lod);
let mut drawn_here = 0usize;
for t in tris {
let vi = [t.a as usize, t.b as usize, t.c as usize];
if vi.iter().any(|&v| v >= limit) {
continue;
}
let Some(ad) = g.ad_gifs.get(t.ad_gif as usize) else {
continue;
};
let batch = batches
.entry(ad.texture_index())
.or_insert_with(|| TriangleBatch {
positions: Vec::new(),
uvs: Vec::new(),
colors: Vec::new(),
normals: Vec::new(),
indices: Vec::new(),
ad_gifs: Vec::new(),
});
let base = batch.positions.len() as u32;
for v in vi {
let r = resolve_vertex(&g, v, depth, lod_distances, collapse);
batch.positions.push(r.position);
batch.uvs.push(r.uv);
let (colour, was_lit) = lit_color(&g, v, r.color, lighting);
if was_lit {
stats.lit_vertices += 1;
}
batch.colors.push(colour);
batch.normals.push(vertex_normal(&g, v, normals.as_ref()));
}
batch.indices.extend_from_slice(&[base, base + 1, base + 2]);
batch.ad_gifs.push(*ad);
drawn_here += 1;
}
stats.triangles += drawn_here;
if drawn_here > 0 {
stats.drawn += 1;
}
patches.push(PatchBounds {
centre,
radius,
base_only: th.base_only != 0,
mode,
depth,
});
}
let mut textures = BTreeMap::new();
for &tex in batches.keys() {
if let Some(e) = table.get(tex) {
if let Ok(img) = base_image(texture_block, src.gs_ram, e) {
if let Ok(t) = img.decode() {
textures.insert(tex, t);
}
}
}
}
for b in batches.values() {
stats.vertices += b.positions.len();
}
// The vertex bounds are the check that matters: positions are i16 offsets
// from a per-patch origin, so a wrong origin collapses the level to a
// +-32 unit blob around the world origin while every parser test still
// passes. Print the real extent to confirm the level is where the patch
// bounding spheres say it is.
if std::env::var_os("GC_ENGINE_DEBUG_BOUNDS").is_some() {
let mut lo = [f32::INFINITY; 3];
let mut hi = [f32::NEG_INFINITY; 3];
for b in batches.values() {
for p in &b.positions {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
}
eprintln!("vertex bounds lo {lo:?} hi {hi:?}");
}
Ok(LevelGeometry {
batches,
textures,
patches,
lod_distances,
lod_thresholds_raw: thresholds,
stats,
})
}
impl LevelGeometry {
pub fn triangle_count(&self) -> usize {
self.batches.values().map(|b| b.indices.len() / 3).sum()
}
}
fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_axis_swap_keeps_up_up() {
// PS2 +Z is up, so it must land on Bevy +Y.
let w = to_world([0.0, 0.0, 2048.0]);
assert_eq!(w, [0.0, 2.0, 0.0]);
// PS2 +X is right, so it stays right.
assert_eq!(to_world([1024.0, 0.0, 0.0])[0], 1.0);
// PS2 +Y is forward, which is Bevy -Z.
assert_eq!(to_world([0.0, 1024.0, 0.0])[2], -1.0);
}
#[test]
fn only_collapse_modes_collapse() {
assert!(mode_collapses(TfragDrawMode::Lod2));
assert!(mode_collapses(TfragDrawMode::Lod0Collapse));
assert!(mode_collapses(TfragDrawMode::Lod1Collapse));
assert!(!mode_collapses(TfragDrawMode::Lod0));
assert!(!mode_collapses(TfragDrawMode::Lod0Morph));
assert!(!mode_collapses(TfragDrawMode::Lod1Morph));
}
#[test]
fn a_common_vertex_does_not_move() {
let mut g = TfragGeometry {
origin: [0, 0, 0, 0],
positions: vec![[1024, 0, 0], [0, 2048, 0]],
vertex_info: vec![
gc_formats::tfrag::TfragVertexInfo {
s: 0,
t: 0,
parent: 0,
vertex: 0,
},
gc_formats::tfrag::TfragVertexInfo {
s: 4096,
t: 2048,
parent: 0,
vertex: 2,
},
],
..TfragGeometry::default()
};
let v = resolve_vertex(&g, 0, 999.0, [192.0, 128.0, 64.0], true);
assert_eq!(v.position, [1.0, 0.0, 0.0]);
assert_eq!(v.uv, [0.0, 0.0]);
// UVs are 1/4096 fixed point.
assert_eq!(uv(&g, 1), [1.0, 0.5]);
g.origin = [0, 0, 0, 0];
}
#[test]
fn morphing_blends_toward_the_parent_midpoint() {
// One common vertex at the origin, one LOD-0 vertex far away whose two
// parents are both the common one, so the midpoint is the common one.
let mut g = TfragGeometry {
positions: vec![[0, 0, 0], [4096, 0, 0]],
vertex_info: vec![
gc_formats::tfrag::TfragVertexInfo {
s: 0,
t: 0,
parent: 0,
vertex: 0,
},
gc_formats::tfrag::TfragVertexInfo {
s: 0,
t: 0,
parent: 0,
vertex: 2,
},
],
vinfo_tier: [1, 0, 1],
vu_positions_lod_0_count: 1,
parent_indices_lod0: vec![0],
..TfragGeometry::default()
};
g.origin = [0, 0, 0, 0];
// Fully morphed: the vertex sits on the midpoint of its two parents.
let full = resolve_vertex(&g, 1, 200.0, [192.0, 128.0, 64.0], false);
assert!(
(full.position[0] - 0.0).abs() < 1e-4,
"got {:?}",
full.position
);
// Not morphed: it stays where it is.
let none = resolve_vertex(&g, 1, 0.0, [192.0, 128.0, 64.0], false);
assert!(
(none.position[0] - 4.0).abs() < 1e-4,
"got {:?}",
none.position
);
// Collapsed: replaced outright by the first parent.
let cut = resolve_vertex(&g, 1, 200.0, [192.0, 128.0, 64.0], true);
assert!(
(cut.position[0] - 0.0).abs() < 1e-4,
"got {:?}",
cut.position
);
}
}
#[cfg(test)]
mod normal_tests {
use super::*;
use gc_formats::tfrag::TfragLight;
use gc_formats::tfrag_light::NormalTable;
fn table() -> NormalTable {
// 256 entries of (cos, sin) at i * pi / 128, as the overlay holds them.
let mut cos_sin = Vec::with_capacity(256);
for i in 0..256usize {
let a = i as f64 * std::f64::consts::PI / 128.0;
cos_sin.push([(a.cos() as f32).to_bits(), (a.sin() as f32).to_bits()]);
}
NormalTable { cos_sin }
}
fn geometry_with_light(azimuth: i16, elevation: i16) -> TfragGeometry {
TfragGeometry {
positions: vec![[0, 0, 0]],
vertex_info: vec![gc_formats::tfrag::TfragVertexInfo {
s: 0,
t: 0,
parent: 0,
vertex: 0,
}],
lights: vec![TfragLight {
unknown_0: 0,
intensity: 0,
azimuth: azimuth as i8,
elevation: elevation as i8,
color: 0,
select: 0,
}],
..TfragGeometry::default()
}
}
#[test]
fn the_light_record_indexes_the_table() {
// Azimuth 0, elevation 0 is -X in the table, which becomes -X in Bevy.
let g = geometry_with_light(0, 0);
let n = vertex_normal(&g, 0, Some(&table()));
assert!((n[0] + 1.0).abs() < 1e-5, "{n:?}");
assert!(n[1].abs() < 1e-5 && n[2].abs() < 1e-5, "{n:?}");
}
#[test]
fn normals_get_the_same_axis_swap_as_positions() {
// Elevation 64 is pi/2, so the table gives a normal along Z, negated.
let g = geometry_with_light(0, 64);
let n = vertex_normal(&g, 0, Some(&table()));
// PS2 (0, 0, -1) must land on Bevy -Y.
assert!(n[0].abs() < 1e-5 && n[2].abs() < 1e-5, "{n:?}");
assert!((n[1] + 1.0).abs() < 1e-5, "{n:?}");
}
#[test]
fn an_unsigned_index_is_used_even_when_the_field_reads_negative() {
// 200 as an i8 is -56, but it indexes table entry 200, not 56.
let g = geometry_with_light(-56, 0);
let from_field = vertex_normal(&g, 0, Some(&table()));
let mut g2 = g.clone();
g2.lights[0].azimuth = -56;
assert_eq!(from_field, vertex_normal(&g2, 0, Some(&table())));
}
#[test]
fn missing_normals_fall_back_rather_than_panicking() {
let g = geometry_with_light(0, 0);
assert_eq!(vertex_normal(&g, 0, None), [0.0, 1.0, 0.0]);
let empty = TfragGeometry::default();
assert_eq!(vertex_normal(&empty, 7, Some(&table())), [0.0, 1.0, 0.0]);
}
}