From 6378156e7d819e10778f45119229da25f86adde2 Mon Sep 17 00:00:00 2001 From: Alex Bates Date: Mon, 23 Feb 2026 23:40:19 +0000 Subject: [PATCH] add render modes, geometry modes, and depth sorting to map rendering - Project crate: add GeometryMode and RenderMode enums, texture coords (u/v) on Vertex - Star Rod interop: parse render mode (property 0x5C) and geometry mode flags from map XML - GBI: per-node geometry mode and other_mode_low, Z buffer clearing - Map editor: sort nodes by PM64's RenderTaskBasePriorities table, using three-list bucketing (NEAR/MID/FAR) with per-bucket sort direction for correct opaque and translucent draw order --- Cargo.lock | 1 + Cargo.toml | 1 + crates/kammy/src/app.rs | 2 + crates/kammy/src/editor/map.rs | 369 +++++++++++++++++++++- crates/kammy/src/editor/map/camera.rs | 2 +- crates/kammy/src/tests/map.rs | 4 + crates/pm64/src/gbi.rs | 403 ++++++++++++++++-------- crates/pm64/src/render.rs | 47 +-- crates/project/src/lib.rs | 79 ++++- crates/star_rod_interop/Cargo.toml | 1 + crates/star_rod_interop/src/lib.rs | 378 ++++++++++++++++++++++ crates/star_rod_interop/tests/import.rs | 36 ++- 12 files changed, 1156 insertions(+), 167 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0fd1768..87bbeed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3919,6 +3919,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "star_rod_interop" version = "0.1.0" dependencies = [ + "log", "loro", "loroscope", "project", diff --git a/Cargo.toml b/Cargo.toml index 89f953f..c520d3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ ash = "0.38" raw-window-handle = "0.6" roxmltree = "0.21" rfd = "0.15" +log = "0.4" thiserror = "2" anyhow = "1" diff --git a/crates/kammy/src/app.rs b/crates/kammy/src/app.rs index 55d4971..3001ccf 100644 --- a/crates/kammy/src/app.rs +++ b/crates/kammy/src/app.rs @@ -495,6 +495,8 @@ fn set_vertex(v: &project::Vertex, x: f64, y: f64, z: f64, r: i64, g: i64, b: i6 v.set_x(x); v.set_y(y); v.set_z(z); + v.set_u(0.0); + v.set_v(0.0); let c = v.color(); c.set_r(r); c.set_g(g); diff --git a/crates/kammy/src/editor/map.rs b/crates/kammy/src/editor/map.rs index 846a10e..7d03b45 100644 --- a/crates/kammy/src/editor/map.rs +++ b/crates/kammy/src/editor/map.rs @@ -10,7 +10,7 @@ pub mod camera; -use pm64::gbi::{NodeData, TriangleData, VertexData}; +use pm64::gbi::{self, NodeData, TriangleData, VertexData}; use project::{ModelNode, Project}; use super::{Editor, EditorContext, EditorId}; @@ -69,7 +69,9 @@ impl Editor for MapEditor { ); self.camera.handle_input(&interact_response); - let nodes = extract_nodes(ctx.project, &self.scene_key); + let mut nodes = extract_nodes(ctx.project, &self.scene_key); + sort_nodes_by_render_priority(&mut nodes, self.camera.eye_position()); + let nodes: Vec = nodes.into_iter().map(|(_, n)| n).collect(); #[expect( clippy::cast_possible_truncation, clippy::as_conversions, @@ -92,8 +94,9 @@ impl Editor for MapEditor { } } -/// Extracts all model nodes from the given scene's model tree into plain render data. -fn extract_nodes(project: &Project, scene_key: &str) -> Vec { +/// Extracts all model nodes from the given scene's model tree into plain render data, +/// paired with their sort priority from PM64's `RenderTaskBasePriorities[]` table. +fn extract_nodes(project: &Project, scene_key: &str) -> Vec<(i32, NodeData)> { let Some(scene) = project.scenes().get(scene_key) else { return Vec::new(); }; @@ -106,7 +109,11 @@ fn extract_nodes(project: &Project, scene_key: &str) -> Vec { } /// Recursively collects a node and its children into the output list. -fn collect_node(tree: &loroscope::Tree, id: loro::TreeID, out: &mut Vec) { +fn collect_node( + tree: &loroscope::Tree, + id: loro::TreeID, + out: &mut Vec<(i32, NodeData)>, +) { let Some(node) = tree.get(id) else { return }; let tri_list = node.triangles(); @@ -119,7 +126,21 @@ fn collect_node(tree: &loroscope::Tree, id: loro::TreeID, out: &mut V v2: extract_vertex(&tri.v2()), }); } - out.push(NodeData { triangles }); + let geometry_mode = match node.geometry_mode() { + project::GeometryMode::VertexColor => gbi::GeometryMode::VertexColor, + project::GeometryMode::VertexColorCullBack => gbi::GeometryMode::VertexColorCullBack, + project::GeometryMode::Lit => gbi::GeometryMode::Lit, + project::GeometryMode::LitCullBack => gbi::GeometryMode::LitCullBack, + }; + let render_mode = node.render_mode(); + out.push(( + render_mode_to_sort_priority(&render_mode), + NodeData { + triangles, + geometry_mode, + other_mode_low: render_mode_to_other_mode_low(&render_mode), + }, + )); if let Some(children) = tree.children(id) { for child_id in children { @@ -128,12 +149,73 @@ fn collect_node(tree: &loroscope::Tree, id: loro::TreeID, out: &mut V } } -/// Converts a CRDT vertex accessor into a plain `VertexData`. +/// Returns the RDP `othermode_low` bits for a project render mode preset. +/// +/// Values derived from PM64's `ModelRenderModes[]` table in model.c, using +/// the `RENDER_CLASS_1CYC` display lists. Each value is the full +/// `G_RM_*1 | G_RM_*2` that `gsDPSetRenderMode` would encode. +/// +/// Modes not handled by the 1CYC switch in the game (SHADOW, `PASS_THROUGH`, +/// BEHIND variants, etc.) use their closest equivalent rather than the +/// game's default fallback to `SURFACE_OPA`. +fn render_mode_to_other_mode_low(mode: &project::RenderMode) -> u32 { + match mode { + // G_RM_AA_ZB_OPA_SURF + project::RenderMode::SolidAaZbLayer0 + | project::RenderMode::SurfaceOpa + | project::RenderMode::PassThrough => 0x0055_2078, + // G_RM_ZB_OPA_SURF + project::RenderMode::SurfaceOpaNoAa => 0x0055_2230, + // G_RM_AA_OPA_SURF + project::RenderMode::SurfaceOpaNoZb | project::RenderMode::SurfaceOpaNoZbBehind => { + 0x0055_2048 + } + // G_RM_AA_ZB_OPA_DECAL + project::RenderMode::DecalOpa => 0x0055_2D58, + // G_RM_ZB_OPA_DECAL + project::RenderMode::DecalOpaNoAa => 0x0055_2E10, + // G_RM_AA_ZB_OPA_INTER + project::RenderMode::IntersectingOpa => 0x0055_2478, + // G_RM_AA_ZB_TEX_EDGE + project::RenderMode::Alphatest | project::RenderMode::AlphatestOnesided => 0x0055_3078, + // G_RM_AA_TEX_EDGE + project::RenderMode::AlphatestNoZb | project::RenderMode::AlphatestNoZbBehind => { + 0x0055_3048 + } + // G_RM_AA_ZB_XLU_SURF + project::RenderMode::SurfaceXluLayer1 + | project::RenderMode::SurfaceXluLayer2 + | project::RenderMode::SurfaceXluLayer3 + | project::RenderMode::Shadow => 0x0050_49D8, + // G_RM_ZB_XLU_SURF + project::RenderMode::SurfaceXluNoAa => 0x0050_4A50, + // G_RM_AA_XLU_SURF + project::RenderMode::SurfaceXluNoZb | project::RenderMode::SurfaceXluNoZbBehind => { + 0x0050_41C8 + } + // Custom: AA_EN | Z_CMP | Z_UPD | IM_RD | CLR_ON_CVG | CVG_DST_WRAP | ZMODE_XLU | FORCE_BL + project::RenderMode::SurfaceXluZbZupd | project::RenderMode::SurfaceXluAaZbZupd => { + 0x0050_49F8 + } + // G_RM_AA_ZB_XLU_DECAL + project::RenderMode::DecalXlu | project::RenderMode::DecalXluAhead => 0x0050_4DD8, + // G_RM_ZB_OVL_SURF + project::RenderMode::DecalXluNoAa => 0x0050_4F50, + // G_RM_AA_ZB_XLU_INTER + project::RenderMode::IntersectingXlu => 0x0050_45D8, + // Custom: IM_RD | CVG_DST_SAVE | ZMODE_XLU | FORCE_BL (cloud without Z compare) + project::RenderMode::CloudNoZcmp => 0x0050_4B40, + // G_RM_ZB_CLD_SURF + project::RenderMode::Cloud => 0x0050_4B50, + // G_RM_CLD_SURF + project::RenderMode::CloudNoZb => 0x0050_4340, + } +} + #[expect( clippy::cast_possible_truncation, - clippy::cast_sign_loss, clippy::as_conversions, - reason = "vertex coords are small integers that fit in i16/u8" + reason = "vertex coords are small integers that fit in i16" )] fn extract_vertex(v: &project::Vertex) -> VertexData { let c = v.color(); @@ -141,11 +223,129 @@ fn extract_vertex(v: &project::Vertex) -> VertexData { x: v.x() as i16, y: v.y() as i16, z: v.z() as i16, - r: c.r() as u8, - g: c.g() as u8, - b: c.b() as u8, - a: c.a() as u8, + tc_s: v.u() as i16, + tc_t: v.v() as i16, + r: clamp_color_channel(c.r()), + g: clamp_color_channel(c.g()), + b: clamp_color_channel(c.b()), + a: clamp_color_channel(c.a()), + } +} + +#[expect( + clippy::cast_sign_loss, + clippy::as_conversions, + reason = "value is clamped to 0..=255" +)] +fn clamp_color_channel(value: i64) -> u8 { + if !(0..=255).contains(&value) { + tracing::warn!(value, "color channel outside 0..=255, clamping"); + } + value.clamp(0, 255) as u8 +} + +/// Render-mode sort priority from PM64's `RenderTaskBasePriorities[]` table (model.c). +/// +/// Each render mode maps to a base priority. The effective sort key is +/// `base_priority + camera_distance`, which determines both the rendering +/// pass (NEAR / MID / FAR) and within-pass ordering. +fn render_mode_to_sort_priority(mode: &project::RenderMode) -> i32 { + match mode { + project::RenderMode::SolidAaZbLayer0 => -100_000, + project::RenderMode::SurfaceOpaNoZb + | project::RenderMode::AlphatestNoZb + | project::RenderMode::SurfaceXluNoZb => 0, + project::RenderMode::CloudNoZb => 700_000, + project::RenderMode::SurfaceOpa + | project::RenderMode::SurfaceOpaNoAa + | project::RenderMode::DecalOpa + | project::RenderMode::DecalOpaNoAa + | project::RenderMode::IntersectingOpa + | project::RenderMode::Alphatest + | project::RenderMode::AlphatestOnesided => 1_000_000, + project::RenderMode::SurfaceOpaNoZbBehind => 4_000_000, + project::RenderMode::AlphatestNoZbBehind => 4_250_000, + project::RenderMode::SurfaceXluNoZbBehind | project::RenderMode::CloudNoZcmp => 4_500_000, + project::RenderMode::IntersectingXlu | project::RenderMode::PassThrough => 5_500_000, + project::RenderMode::SurfaceXluLayer3 => 6_000_000, + project::RenderMode::DecalXluAhead | project::RenderMode::Shadow => 6_500_000, + project::RenderMode::DecalXlu | project::RenderMode::DecalXluNoAa => 7_000_000, + project::RenderMode::SurfaceXluLayer2 => 7_500_000, + project::RenderMode::SurfaceXluLayer1 + | project::RenderMode::SurfaceXluNoAa + | project::RenderMode::SurfaceXluZbZupd + | project::RenderMode::SurfaceXluAaZbZupd + | project::RenderMode::Cloud => 8_000_000, + } +} + +/// Sorts nodes by PM64's render task priority system. +/// +/// Mirrors `queue_render_task()` and `execute_render_tasks()` in model.c. +/// Each node's effective sort key is `sort_priority + camera_distance`. +/// Nodes are bucketed into three render passes based on this key: +/// +/// - **NEAR** (key < 800K): background layers, no-ZB modes. Sorted descending. +/// - **MID** (800K–3M): standard opaque geometry. Sorted ascending (front-to-back). +/// - **FAR** (≥ 3M): translucent layers, shadows, behind-geometry. Sorted descending +/// (back-to-front). +/// +/// Rendered in order: NEAR, MID, FAR. +fn sort_nodes_by_render_priority(nodes: &mut [(i32, NodeData)], eye: [f32; 3]) { + nodes.sort_by(|(pri_a, node_a), (pri_b, node_b)| { + let dist_a = node_camera_dist(node_a, eye); + let dist_b = node_camera_dist(node_b, eye); + let eff_a = f64::from(*pri_a) + f64::from(dist_a); + let eff_b = f64::from(*pri_b) + f64::from(dist_b); + let (bucket_a, val_a) = sort_key(eff_a); + let (bucket_b, val_b) = sort_key(eff_b); + bucket_a + .cmp(&bucket_b) + .then_with(|| val_a.total_cmp(&val_b)) + }); +} + +/// Maps an effective distance to a sort key that replicates PM64's three-list +/// sort order within a single comparison. +/// +/// - NEAR (< 800K): bucket 0, descending → negate within bucket +/// - MID (800K–3M): bucket 1, ascending → use as-is +/// - FAR (≥ 3M): bucket 2, descending → negate within bucket +fn sort_key(effective_dist: f64) -> (u8, f64) { + if effective_dist < 800_000.0 { + (0, -effective_dist) + } else if effective_dist < 3_000_000.0 { + (1, effective_dist) + } else { + (2, -effective_dist) + } +} + +/// Euclidean distance from `eye` to the centroid of a node's vertices. +fn node_camera_dist(node: &NodeData, eye: [f32; 3]) -> f32 { + let c = node_centroid(node); + let dx = c[0] - eye[0]; + let dy = c[1] - eye[1]; + let dz = c[2] - eye[2]; + (dx * dx + dy * dy + dz * dz).sqrt() +} + +/// Computes the average position of all vertices in a node. +fn node_centroid(node: &NodeData) -> [f32; 3] { + let mut sum = [0.0f32; 3]; + let mut count = 0.0f32; + for tri in &node.triangles { + for v in [&tri.v0, &tri.v1, &tri.v2] { + sum[0] += f32::from(v.x); + sum[1] += f32::from(v.y); + sum[2] += f32::from(v.z); + count += 1.0; + } + } + if count == 0.0 { + return [0.0; 3]; } + [sum[0] / count, sum[1] / count, sum[2] / count] } /// Paper Mario NTSC VI configuration (osViModeNtscLan1 + osViSetSpecialFeatures). @@ -169,3 +369,146 @@ fn vi_config_ntsc(fb_origin: u32) -> crate::widget::n64_viewport::ViConfig { y_scale: 0x400, } } + +#[cfg(test)] +mod tests { + use super::*; + use pm64::gbi::{GeometryMode, NodeData, TriangleData, VertexData}; + + fn make_node(x: i16, y: i16, z: i16) -> NodeData { + let v = |dx: i16, dy: i16| VertexData { + x: x + dx, + y: y + dy, + z, + tc_s: 0, + tc_t: 0, + r: 255, + g: 0, + b: 0, + a: 255, + }; + NodeData { + triangles: vec![TriangleData { + v0: v(-10, -10), + v1: v(10, -10), + v2: v(0, 10), + }], + geometry_mode: GeometryMode::VertexColor, + other_mode_low: 0x0055_2078, + } + } + + /// Standard opaque priority (1_000_000). + const PRI_OPA: i32 = 1_000_000; + /// XLU layer 1 priority (8_000_000). + const PRI_XLU1: i32 = 8_000_000; + /// XLU layer 2 priority (7_500_000). + const PRI_XLU2: i32 = 7_500_000; + /// Decal XLU priority (7_000_000). + const PRI_DECAL_XLU: i32 = 7_000_000; + + #[test] + fn opaque_before_translucent() { + let mut nodes = vec![ + (PRI_XLU1, make_node(0, 0, 0)), + (PRI_OPA, make_node(0, 0, 0)), + ]; + sort_nodes_by_render_priority(&mut nodes, [0.0, 0.0, 100.0]); + assert_eq!( + nodes[0].0, PRI_OPA, + "opaque (MID) should come before XLU (FAR)" + ); + assert_eq!(nodes[1].0, PRI_XLU1); + } + + #[test] + fn opaque_sorted_front_to_back() { + let mut nodes = vec![ + (PRI_OPA, make_node(0, 0, -500)), // far + (PRI_OPA, make_node(0, 0, 0)), // near + (PRI_OPA, make_node(0, 0, -200)), // mid-distance + ]; + let eye = [0.0, 0.0, 100.0]; + sort_nodes_by_render_priority(&mut nodes, eye); + + let dists: Vec = nodes + .iter() + .map(|(_, n)| node_camera_dist(n, eye)) + .collect(); + assert!( + dists[0] <= dists[1] && dists[1] <= dists[2], + "opaque (MID list, ascending) should be front-to-back: {dists:?}" + ); + } + + #[test] + fn translucent_sorted_back_to_front() { + let mut nodes = vec![ + (PRI_XLU1, make_node(0, 0, 0)), // near + (PRI_XLU1, make_node(0, 0, -500)), // far + (PRI_XLU1, make_node(0, 0, -200)), // mid-distance + ]; + let eye = [0.0, 0.0, 100.0]; + sort_nodes_by_render_priority(&mut nodes, eye); + + let dists: Vec = nodes + .iter() + .map(|(_, n)| node_camera_dist(n, eye)) + .collect(); + assert!( + dists[0] >= dists[1] && dists[1] >= dists[2], + "translucent (FAR list, descending) should be back-to-front: {dists:?}" + ); + } + + #[test] + fn xlu_layers_render_in_priority_order() { + // Different XLU layers at the same distance should sort by priority band. + // FAR list is descending, so highest effective dist (XLU1=8M) comes first, + // then XLU2=7.5M, then DECAL_XLU=7M. + let mut nodes = vec![ + (PRI_DECAL_XLU, make_node(0, 0, 0)), + (PRI_XLU1, make_node(0, 0, 0)), + (PRI_XLU2, make_node(0, 0, 0)), + ]; + sort_nodes_by_render_priority(&mut nodes, [0.0, 0.0, 100.0]); + + assert_eq!(nodes[0].0, PRI_XLU1); + assert_eq!(nodes[1].0, PRI_XLU2); + assert_eq!(nodes[2].0, PRI_DECAL_XLU); + } + + #[test] + fn centroid_averages_vertices() { + let node = make_node(100, 200, 300); + let c = node_centroid(&node); + // Triangle offsets are (-10,-10), (10,-10), (0,10) so + // x-centroid = 100, y-centroid = 200 + (-10-10+10)/3 ≈ 196.67, z = 300 + assert!((c[0] - 100.0).abs() < 0.1); + assert!((c[1] - 196.67).abs() < 0.1); + assert!((c[2] - 300.0).abs() < 0.1); + } + + #[test] + fn empty_node_centroid_is_origin() { + let node = NodeData { + triangles: vec![], + geometry_mode: GeometryMode::VertexColor, + other_mode_low: 0, + }; + assert_eq!(node_centroid(&node), [0.0; 3]); + } + + #[test] + fn sort_key_buckets_correctly() { + // NEAR bucket: effective < 800K + let (bucket, _) = sort_key(500_000.0); + assert_eq!(bucket, 0); + // MID bucket: 800K–3M + let (bucket, _) = sort_key(1_500_000.0); + assert_eq!(bucket, 1); + // FAR bucket: ≥ 3M + let (bucket, _) = sort_key(8_000_000.0); + assert_eq!(bucket, 2); + } +} diff --git a/crates/kammy/src/editor/map/camera.rs b/crates/kammy/src/editor/map/camera.rs index 41e1fbd..a6dba6c 100644 --- a/crates/kammy/src/editor/map/camera.rs +++ b/crates/kammy/src/editor/map/camera.rs @@ -61,7 +61,7 @@ impl OrbitCamera { } } - fn eye_position(&self) -> [f32; 3] { + pub fn eye_position(&self) -> [f32; 3] { let cy = self.yaw.cos(); let sy = self.yaw.sin(); let cp = self.pitch.cos(); diff --git a/crates/kammy/src/tests/map.rs b/crates/kammy/src/tests/map.rs index 13c56ad..cfc6a05 100644 --- a/crates/kammy/src/tests/map.rs +++ b/crates/kammy/src/tests/map.rs @@ -34,6 +34,8 @@ fn red_triangle_nodes() -> Vec { x, y, z, + tc_s: 0, + tc_t: 0, r: 255, g: 0, b: 0, @@ -46,6 +48,8 @@ fn red_triangle_nodes() -> Vec { v1: red(200, -200, 0), v2: red(0, 200, 0), }], + geometry_mode: pm64::gbi::GeometryMode::VertexColor, + other_mode_low: 0x0055_2078, }] } diff --git a/crates/pm64/src/gbi.rs b/crates/pm64/src/gbi.rs index e8ac48a..8801ff8 100644 --- a/crates/pm64/src/gbi.rs +++ b/crates/pm64/src/gbi.rs @@ -7,19 +7,21 @@ //! Converts plain vertex/triangle data into F3DEX2-compatible display list //! commands that the N64 RSP can execute. -/// A vertex with position and color data in N64-native ranges. #[derive(Clone, Debug, PartialEq)] pub struct VertexData { pub x: i16, pub y: i16, pub z: i16, + /// Texture S coordinate (10.5 fixed-point). + pub tc_s: i16, + /// Texture T coordinate (10.5 fixed-point). + pub tc_t: i16, pub r: u8, pub g: u8, pub b: u8, pub a: u8, } -/// A triangle referencing three vertices. #[derive(Clone, Debug, PartialEq)] pub struct TriangleData { pub v0: VertexData, @@ -27,30 +29,49 @@ pub struct TriangleData { pub v2: VertexData, } -/// A model node's geometry in plain (non-CRDT) form. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum GeometryMode { + #[default] + VertexColor, + VertexColorCullBack, + /// RGBA bytes are normals, not colors. + Lit, + /// RGBA bytes are normals, not colors. + LitCullBack, +} + +impl GeometryMode { + pub fn to_flags(self) -> u32 { + match self { + Self::VertexColor => G_SHADE | G_SHADING_SMOOTH, + Self::VertexColorCullBack => G_SHADE | G_SHADING_SMOOTH | G_CULL_BACK, + Self::Lit => G_SHADE | G_SHADING_SMOOTH | G_LIGHTING, + Self::LitCullBack => G_SHADE | G_SHADING_SMOOTH | G_LIGHTING | G_CULL_BACK, + } + } +} + +/// Plain (non-CRDT) model node, ready for GBI reconstruction. #[derive(Clone, Debug, PartialEq)] pub struct NodeData { - /// Triangles belonging to this node. pub triangles: Vec, + pub geometry_mode: GeometryMode, + pub other_mode_low: u32, } -/// N64 camera matrices in s15.16 fixed-point format (64 bytes each). +/// s15.16 fixed-point matrices (64 bytes each). #[derive(Clone, Debug, PartialEq, Eq)] pub struct CameraMatrices { - /// Projection matrix (64 bytes, s15.16 fixed-point). pub projection: [u8; 64], - /// Modelview matrix (64 bytes, s15.16 fixed-point). pub modelview: [u8; 64], } -/// Output of GBI display list reconstruction. #[derive(Clone, Debug)] pub struct GbiOutput { - /// RDP command words (each command is 2 words = 64 bits). + /// Paired command words (each command = 2 words). pub commands: Vec, - /// Packed vertex data to be placed in RDRAM. pub vertex_data: Vec, - /// N64 `Vp` viewport struct (16 bytes, big-endian `i16` fields). + /// N64 `Vp` struct (16 bytes, big-endian). pub viewport_data: [u8; 16], } @@ -69,11 +90,14 @@ const G_MOVEMEM: u32 = 0xDC; const G_MV_VIEWPORT: u32 = 8; const G_FILLRECT: u32 = 0xF6; const G_SETFILLCOLOR: u32 = 0xF7; +const G_SETZIMG: u32 = 0xFE; const G_RDPPIPESYNC: u32 = 0xE7; const G_RDPFULLSYNC: u32 = 0xE9; // Geometry mode flags const G_SHADE: u32 = 0x0000_0004; +const G_LIGHTING: u32 = 0x0002_0000; +const G_CULL_BACK: u32 = 0x0000_0400; const G_SHADING_SMOOTH: u32 = 0x0020_0000; // Matrix flags @@ -90,13 +114,15 @@ const MAX_VERTS_PER_BATCH: usize = 32; /// Packs a single vertex into 16 bytes (N64 Vtx format). /// -/// Layout: `x:i16 y:i16 z:i16 flag:0 tc_s:0 tc_t:0 r:u8 g:u8 b:u8 a:u8` +/// Layout: `x:i16 y:i16 z:i16 flag:0 tc_s:i16 tc_t:i16 r:u8 g:u8 b:u8 a:u8` fn pack_vertex(v: &VertexData) -> [u8; 16] { let mut buf = [0u8; 16]; buf[0..2].copy_from_slice(&v.x.to_be_bytes()); buf[2..4].copy_from_slice(&v.y.to_be_bytes()); buf[4..6].copy_from_slice(&v.z.to_be_bytes()); - // flag[6..8] = 0, tc_s[8..10] = 0, tc_t[10..12] = 0 + // flag[6..8] = 0 + buf[8..10].copy_from_slice(&v.tc_s.to_be_bytes()); + buf[10..12].copy_from_slice(&v.tc_t.to_be_bytes()); buf[12] = v.r; buf[13] = v.g; buf[14] = v.b; @@ -143,165 +169,179 @@ fn pack_viewport(width: u32, height: u32) -> [u8; 16] { vp } +/// RDRAM addresses for GBI display list reconstruction. +#[derive(Clone, Debug)] +pub struct RdramLayout { + pub fb: u32, + pub zb: u32, + pub vertex: u32, + pub projection: u32, + pub modelview: u32, + pub viewport: u32, +} + /// Reconstructs a GBI display list from model nodes and camera matrices. /// /// The output display list sets up the framebuffer, scissor, render modes, /// matrices, then draws all triangles with vertex coloring. -/// -/// # Arguments -/// - `nodes`: Model geometry to render. -/// - `camera`: Camera projection and modelview matrices in N64 format. -/// - `fb_addr`: RDRAM address for the framebuffer. -/// - `vertex_addr`: RDRAM address where vertex data will be placed. -/// - `proj_addr`: RDRAM address of the projection matrix. -/// - `mv_addr`: RDRAM address of the modelview matrix. -/// - `viewport_addr`: RDRAM address where the viewport struct will be placed. -pub fn reconstruct( - nodes: &[NodeData], - _camera: &CameraMatrices, - fb_addr: u32, - vertex_addr: u32, - proj_addr: u32, - mv_addr: u32, - viewport_addr: u32, -) -> GbiOutput { +pub fn reconstruct(nodes: &[NodeData], _camera: &CameraMatrices, rdram: &RdramLayout) -> GbiOutput { let mut commands: Vec = Vec::new(); let mut vertex_data: Vec = Vec::new(); - // Build the N64 Vp struct (scale + translate, each 4 × i16, big-endian) let viewport_data = pack_viewport(FB_WIDTH, FB_HEIGHT); // SetColorImage: RGBA 16-bit, width=320 - // fmt=0 (RGBA), siz=G_IM_SIZ_16b(2), width-1 commands.push((G_SETCOLORIMAGE << 24) | (2 << 19) | (FB_WIDTH - 1)); - commands.push(fb_addr); + commands.push(rdram.fb); - // SetScissor commands.push(G_SETSCISSOR << 24); commands.push(((FB_WIDTH << 2) << 12) | (FB_HEIGHT << 2)); - // Clear the framebuffer with a dark background + // Clear framebuffer (FILL cycle type) commands.push(G_RDPPIPESYNC << 24); commands.push(0); - // SetOtherMode: FILL cycle type (bits 52-53 = 3 → 0x0030_0000 in high word) commands.push((G_RDPSETOTHERMODE << 24) | 0x0030_0000); commands.push(0); - // SetFillColor: dark blue-gray (48, 48, 64) in RGBA5551, duplicated for 32-bit commands.push(G_SETFILLCOLOR << 24); - commands.push(0x3191_3191); - // FillRect: full screen (0,0)-(319,239) in 10.2 fixed-point + commands.push(0x3191_3191); // dark blue-gray in RGBA5551 commands.push((G_FILLRECT << 24) | ((((FB_WIDTH - 1) << 2) << 12) | ((FB_HEIGHT - 1) << 2))); commands.push(0); - // RDP pipe sync before switching to 1-cycle rendering + // Clear Z buffer by temporarily pointing SetColorImage at the Z buffer address commands.push(G_RDPPIPESYNC << 24); commands.push(0); + commands.push((G_SETCOLORIMAGE << 24) | (2 << 19) | (FB_WIDTH - 1)); + commands.push(rdram.zb); + commands.push(G_SETFILLCOLOR << 24); + commands.push(0xFFFC_FFFC); + commands.push((G_FILLRECT << 24) | ((((FB_WIDTH - 1) << 2) << 12) | ((FB_HEIGHT - 1) << 2))); + commands.push(0); - // SetOtherMode: 1-cycle, texture perspective, G_RM_OPA_SURF render mode - commands.push((G_RDPSETOTHERMODE << 24) | 0x0008_0000); - commands.push(0x0F0A_4000); + // Restore color image and set Z image + commands.push(G_RDPPIPESYNC << 24); + commands.push(0); + commands.push((G_SETCOLORIMAGE << 24) | (2 << 19) | (FB_WIDTH - 1)); + commands.push(rdram.fb); + commands.push((G_SETZIMG << 24) | (2 << 19)); + commands.push(rdram.zb); - // SetCombine: G_CC_SHADE — output = vertex shade color - // Encoding: (0-0)*0+SHADE for both RGB and alpha, both cycles. + // 1-cycle mode + commands.push(G_RDPPIPESYNC << 24); + commands.push(0); + + // G_CC_SHADE commands.push(G_SETCOMBINE << 24); commands.push(0x0002_0904); - // Set geometry mode: AND mask = 0 clears all bits, then OR sets shade + smooth - commands.push(G_SETGEOMETRYMODE << 24); - commands.push(G_SHADE | G_SHADING_SMOOTH); - - // gSPViewport — maps clip-space to screen coordinates - // gDma2p encoding: size=(16-1)>>3=1, idx=G_MV_VIEWPORT(8), ofs=0 + // gSPViewport commands.push((G_MOVEMEM << 24) | (1 << 19) | G_MV_VIEWPORT); - commands.push(viewport_addr); + commands.push(rdram.viewport); - // Load projection matrix // F3DEX2's gSPMatrix XORs the push flag: NOPUSH(0) ^ PUSH(1) = 1 let mtx_proj_flags = u32::from(G_MTX_PROJECTION | G_MTX_LOAD | G_MTX_NOPUSH) ^ 1; commands.push((G_MTX << 24) | 0x0038_0000 | mtx_proj_flags); - commands.push(proj_addr); + commands.push(rdram.projection); - // Load modelview matrix let mtx_mv_flags = u32::from(G_MTX_LOAD | G_MTX_NOPUSH) ^ 1; commands.push((G_MTX << 24) | 0x0038_0000 | mtx_mv_flags); - commands.push(mv_addr); - - // Collect all triangles and deduplicate vertices - let all_tris: Vec<&TriangleData> = nodes.iter().flat_map(|n| &n.triangles).collect(); - - let mut unique_verts: Vec = Vec::new(); - let mut tri_indices: Vec<[usize; 3]> = Vec::new(); - - for tri in &all_tris { - let mut indices = [0usize; 3]; - for (vi, v) in [&tri.v0, &tri.v1, &tri.v2].iter().enumerate() { - indices[vi] = if let Some(idx) = unique_verts.iter().position(|uv| uv == *v) { - idx - } else { - unique_verts.push((*v).clone()); - unique_verts.len() - 1 - }; - } - tri_indices.push(indices); - } + commands.push(rdram.modelview); - // Pack vertex data - for v in &unique_verts { - vertex_data.extend_from_slice(&pack_vertex(v)); - } + let mut current_geom_flags: Option = None; + let mut current_other_mode_low: Option = None; + let mut global_vert_offset: usize = 0; - // Emit vertex loads and triangle commands in batches of MAX_VERTS_PER_BATCH - let total_verts = unique_verts.len(); - let mut batch_start = 0; + for node in nodes { + if node.triangles.is_empty() { + continue; + } - while batch_start < total_verts { - let batch_end = (batch_start + MAX_VERTS_PER_BATCH).min(total_verts); - let batch_count = batch_end - batch_start; + let geom_flags = node.geometry_mode.to_flags(); + if current_geom_flags != Some(geom_flags) { + // AND mask = 0 clears all, OR sets new mode + commands.push(G_SETGEOMETRYMODE << 24); + commands.push(geom_flags); + current_geom_flags = Some(geom_flags); + } - // F3DEX2 gSPVertex: word0 = (G_VTX << 24) | (n << 12) | ((v0 + n) << 1) - let n = idx_u32(batch_count); - let v0 = 0u32; - commands.push((G_VTX << 24) | (n << 12) | ((v0 + n) << 1)); - commands.push(vertex_addr + idx_u32(batch_start) * 16); + let other_mode_low = node.other_mode_low; + if current_other_mode_low != Some(other_mode_low) { + commands.push(G_RDPPIPESYNC << 24); + commands.push(0); + commands.push((G_RDPSETOTHERMODE << 24) | 0x0008_0000); + commands.push(other_mode_low); + current_other_mode_low = Some(other_mode_low); + } - // Emit triangles that reference vertices in this batch - let batch_tris: Vec<&[usize; 3]> = tri_indices - .iter() - .filter(|idx| idx.iter().all(|&i| i >= batch_start && i < batch_end)) - .collect(); + let mut unique_verts: Vec = Vec::new(); + let mut tri_indices: Vec<[usize; 3]> = Vec::new(); + + for tri in &node.triangles { + let mut indices = [0usize; 3]; + for (vi, v) in [&tri.v0, &tri.v1, &tri.v2].iter().enumerate() { + indices[vi] = if let Some(idx) = unique_verts.iter().position(|uv| uv == *v) { + idx + } else { + unique_verts.push((*v).clone()); + unique_verts.len() - 1 + }; + } + tri_indices.push(indices); + } - let mut ti = 0; - while ti + 1 < batch_tris.len() { - // gSP2Triangles - let t0 = batch_tris[ti]; - let t1 = batch_tris[ti + 1]; - let (a0, b0, c0) = ( - idx_u32(t0[0] - batch_start), - idx_u32(t0[1] - batch_start), - idx_u32(t0[2] - batch_start), - ); - let (a1, b1, c1) = ( - idx_u32(t1[0] - batch_start), - idx_u32(t1[1] - batch_start), - idx_u32(t1[2] - batch_start), - ); - commands.push((G_TRI2 << 24) | ((a0 * 2) << 16) | ((b0 * 2) << 8) | (c0 * 2)); - commands.push(((a1 * 2) << 16) | ((b1 * 2) << 8) | (c1 * 2)); - ti += 2; + for v in &unique_verts { + vertex_data.extend_from_slice(&pack_vertex(v)); } - if ti < batch_tris.len() { - // gSP1Triangle for the remaining triangle - let t = batch_tris[ti]; - let (a, b, c) = ( - idx_u32(t[0] - batch_start), - idx_u32(t[1] - batch_start), - idx_u32(t[2] - batch_start), - ); - commands.push((G_TRI1 << 24) | ((a * 2) << 16) | ((b * 2) << 8) | (c * 2)); - commands.push(0); + + let total_verts = unique_verts.len(); + let mut batch_start = 0; + + while batch_start < total_verts { + let batch_end = (batch_start + MAX_VERTS_PER_BATCH).min(total_verts); + let batch_count = batch_end - batch_start; + + let n = idx_u32(batch_count); + let v0 = 0u32; + commands.push((G_VTX << 24) | (n << 12) | ((v0 + n) << 1)); + commands.push(rdram.vertex + idx_u32(global_vert_offset + batch_start) * 16); + + let batch_tris: Vec<&[usize; 3]> = tri_indices + .iter() + .filter(|idx| idx.iter().all(|&i| i >= batch_start && i < batch_end)) + .collect(); + + let mut ti = 0; + while ti + 1 < batch_tris.len() { + let t0 = batch_tris[ti]; + let t1 = batch_tris[ti + 1]; + let (a0, b0, c0) = ( + idx_u32(t0[0] - batch_start), + idx_u32(t0[1] - batch_start), + idx_u32(t0[2] - batch_start), + ); + let (a1, b1, c1) = ( + idx_u32(t1[0] - batch_start), + idx_u32(t1[1] - batch_start), + idx_u32(t1[2] - batch_start), + ); + commands.push((G_TRI2 << 24) | ((a0 * 2) << 16) | ((b0 * 2) << 8) | (c0 * 2)); + commands.push(((a1 * 2) << 16) | ((b1 * 2) << 8) | (c1 * 2)); + ti += 2; + } + if ti < batch_tris.len() { + let t = batch_tris[ti]; + let (a, b, c) = ( + idx_u32(t[0] - batch_start), + idx_u32(t[1] - batch_start), + idx_u32(t[2] - batch_start), + ); + commands.push((G_TRI1 << 24) | ((a * 2) << 16) | ((b * 2) << 8) | (c * 2)); + commands.push(0); + } + + batch_start = batch_end; } - batch_start = batch_end; + global_vert_offset += total_verts; } // Full sync + End display list @@ -328,6 +368,8 @@ mod tests { x, y, z, + tc_s: 0, + tc_t: 0, r: 255, g: 0, b: 0, @@ -343,6 +385,8 @@ mod tests { v1: red_vertex(100, 0, 0), v2: red_vertex(50, 100, 0), }], + geometry_mode: GeometryMode::VertexColor, + other_mode_low: 0x0055_2078, }]; let camera = CameraMatrices { @@ -350,7 +394,18 @@ mod tests { modelview: [0u8; 64], }; - let output = reconstruct(&nodes, &camera, 0x100, 0x1000, 0x2000, 0x2040, 0x2080); + let output = reconstruct( + &nodes, + &camera, + &RdramLayout { + fb: 0x100, + zb: 0x26000, + vertex: 0x1000, + projection: 0x2000, + modelview: 0x2040, + viewport: 0x2080, + }, + ); // Should have commands (setup + vertex load + 1 triangle + sync + enddl) assert!(output.commands.len() >= 4); @@ -371,6 +426,8 @@ mod tests { x: -100, y: 200, z: -300, + tc_s: 512, + tc_t: -1024, r: 128, g: 64, b: 32, @@ -384,6 +441,10 @@ mod tests { assert_eq!(x, -100); assert_eq!(y, 200); assert_eq!(z, -300); + let tc_s = i16::from_be_bytes([packed[8], packed[9]]); + let tc_t = i16::from_be_bytes([packed[10], packed[11]]); + assert_eq!(tc_s, 512); + assert_eq!(tc_t, -1024); assert_eq!(packed[12], 128); assert_eq!(packed[13], 64); assert_eq!(packed[14], 32); @@ -403,14 +464,29 @@ mod tests { } }) .collect(); - let nodes = vec![NodeData { triangles }]; + let nodes = vec![NodeData { + triangles, + geometry_mode: GeometryMode::VertexColor, + other_mode_low: 0x0055_2078, + }]; let camera = CameraMatrices { projection: [0u8; 64], modelview: [0u8; 64], }; - let output = reconstruct(&nodes, &camera, 0x100, 0x1000, 0x2000, 0x2040, 0x2080); + let output = reconstruct( + &nodes, + &camera, + &RdramLayout { + fb: 0x100, + zb: 0x26000, + vertex: 0x1000, + projection: 0x2000, + modelview: 0x2040, + viewport: 0x2080, + }, + ); // Should have 120 unique vertices = 120 * 16 = 1920 bytes assert_eq!(output.vertex_data.len(), 1920); @@ -427,4 +503,75 @@ mod tests { "should have multiple vertex batches, got {vtx_count}" ); } + + #[test] + fn per_node_geometry_mode() { + let nodes = vec![ + NodeData { + triangles: vec![TriangleData { + v0: red_vertex(0, 0, 0), + v1: red_vertex(100, 0, 0), + v2: red_vertex(50, 100, 0), + }], + geometry_mode: GeometryMode::VertexColor, + other_mode_low: 0x0055_2078, + }, + NodeData { + triangles: vec![TriangleData { + v0: red_vertex(200, 0, 0), + v1: red_vertex(300, 0, 0), + v2: red_vertex(250, 100, 0), + }], + geometry_mode: GeometryMode::LitCullBack, + other_mode_low: 0x0055_2078, + }, + ]; + + let camera = CameraMatrices { + projection: [0u8; 64], + modelview: [0u8; 64], + }; + + let output = reconstruct( + &nodes, + &camera, + &RdramLayout { + fb: 0x100, + zb: 0x26000, + vertex: 0x1000, + projection: 0x2000, + modelview: 0x2040, + viewport: 0x2080, + }, + ); + + // Should have two SetGeometryMode commands (one per node since modes differ) + let geom_mode_count = output + .commands + .iter() + .step_by(2) + .filter(|&&w| (w >> 24) == G_SETGEOMETRYMODE) + .count(); + assert_eq!( + geom_mode_count, 2, + "should have 2 SetGeometryMode commands, got {geom_mode_count}" + ); + + // The second SetGeometryMode should have G_LIGHTING and G_CULL_BACK flags + let geom_mode_words: Vec = output + .commands + .chunks(2) + .filter(|pair| (pair[0] >> 24) == G_SETGEOMETRYMODE) + .map(|pair| pair[1]) + .collect(); + assert_eq!(geom_mode_words.len(), 2); + assert_eq!(geom_mode_words[0], G_SHADE | G_SHADING_SMOOTH); + assert_eq!( + geom_mode_words[1], + G_SHADE | G_SHADING_SMOOTH | G_LIGHTING | G_CULL_BACK + ); + + // Should have 6 vertices (3 per node, no cross-node dedup) + assert_eq!(output.vertex_data.len(), 96); + } } diff --git a/crates/pm64/src/render.rs b/crates/pm64/src/render.rs index 943ece8..84c7b46 100644 --- a/crates/pm64/src/render.rs +++ b/crates/pm64/src/render.rs @@ -34,6 +34,8 @@ const RSPBOOT: &[u8] = include_bytes!(concat!(env!("PM64_ASSETS_DIR"), "/rspboot // host-side byte offsets (usize). We store them as usize and convert // to u32 when writing to the OSTask fields via `addr_u32()`. const FB_ADDR: usize = 0x0000_0100; +/// Z buffer placed right after framebuffer (320 * 240 * 2 = 0x25800 bytes). +const ZB_ADDR: usize = 0x0002_6000; const F3DEX2_TEXT_ADDR: usize = 0x0010_0000; const F3DEX2_DATA_ADDR: usize = 0x0010_2000; const DL_ADDR: usize = 0x0012_0000; @@ -46,6 +48,15 @@ const DRAM_STACK_ADDR: usize = 0x001C_0000; const RDRAM_SIZE: u32 = 4 * 1024 * 1024; // 4 MB +const RDRAM_LAYOUT: gbi::RdramLayout = gbi::RdramLayout { + fb: addr_u32(FB_ADDR), + zb: addr_u32(ZB_ADDR), + vertex: addr_u32(VERTEX_ADDR), + projection: addr_u32(PROJ_MTX_ADDR), + modelview: addr_u32(MV_MTX_ADDR), + viewport: addr_u32(VIEWPORT_ADDR), +}; + /// Converts a RDRAM address constant (usize) to u32 for the RSP. #[expect( clippy::cast_possible_truncation, @@ -172,15 +183,7 @@ impl Renderer { /// The returned slice borrows from the internal RSP device and is valid /// until the next call to `render`. pub fn render(&mut self, nodes: &[NodeData], camera: &CameraMatrices) -> &[u32] { - let gbi_output = gbi::reconstruct( - nodes, - camera, - addr_u32(FB_ADDR), - addr_u32(VERTEX_ADDR), - addr_u32(PROJ_MTX_ADDR), - addr_u32(MV_MTX_ADDR), - addr_u32(VIEWPORT_ADDR), - ); + let gbi_output = gbi::reconstruct(nodes, camera, &RDRAM_LAYOUT); self.render_gbi(&gbi_output, camera) } @@ -191,15 +194,7 @@ impl Renderer { camera: &CameraMatrices, sink: &mut dyn rsp::RdpSink, ) { - let gbi_output = gbi::reconstruct( - nodes, - camera, - addr_u32(FB_ADDR), - addr_u32(VERTEX_ADDR), - addr_u32(PROJ_MTX_ADDR), - addr_u32(MV_MTX_ADDR), - addr_u32(VIEWPORT_ADDR), - ); + let gbi_output = gbi::reconstruct(nodes, camera, &RDRAM_LAYOUT); self.prepare_frame(&gbi_output, camera); self.device.run_with_sink(sink); } @@ -376,11 +371,15 @@ mod tests { #[test] fn single_red_triangle_produces_rdp_commands() { let nodes = vec![NodeData { + geometry_mode: gbi::GeometryMode::VertexColor, + other_mode_low: 0x0055_2078, triangles: vec![TriangleData { v0: VertexData { x: 0, y: 0, z: 0, + tc_s: 0, + tc_t: 0, r: 255, g: 0, b: 0, @@ -390,6 +389,8 @@ mod tests { x: 100, y: 0, z: 0, + tc_s: 0, + tc_t: 0, r: 255, g: 0, b: 0, @@ -399,6 +400,8 @@ mod tests { x: 50, y: 100, z: 0, + tc_s: 0, + tc_t: 0, r: 255, g: 0, b: 0, @@ -431,11 +434,15 @@ mod tests { } let nodes = vec![NodeData { + geometry_mode: gbi::GeometryMode::VertexColor, + other_mode_low: 0x0055_2078, triangles: vec![TriangleData { v0: VertexData { x: 0, y: 0, z: 0, + tc_s: 0, + tc_t: 0, r: 255, g: 0, b: 0, @@ -445,6 +452,8 @@ mod tests { x: 100, y: 0, z: 0, + tc_s: 0, + tc_t: 0, r: 255, g: 0, b: 0, @@ -454,6 +463,8 @@ mod tests { x: 50, y: 100, z: 0, + tc_s: 0, + tc_t: 0, r: 255, g: 0, b: 0, diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 05ccd60..dbb244d 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -9,7 +9,6 @@ use loroscope::loroscope; -/// An RGBA color with integer channels (0–255 range by convention). #[loroscope] #[derive(Debug)] pub struct ColorRgba { @@ -19,17 +18,17 @@ pub struct ColorRgba { pub a: i64, } -/// A vertex with 3D position and vertex color. #[loroscope] #[derive(Debug)] pub struct Vertex { pub x: f64, pub y: f64, pub z: f64, + pub u: f64, + pub v: f64, pub color: ColorRgba, } -/// A triangle defined by three vertices. #[loroscope] #[derive(Debug)] pub struct Triangle { @@ -38,13 +37,61 @@ pub struct Triangle { pub v2: Vertex, } -/// A named node in the model tree, containing a list of triangles. +/// Controls whether vertex RGBA bytes are colors or normals, and whether backface culling is on. +#[loroscope] +#[derive(Debug)] +#[expect(missing_docs, reason = "loroscope macro doesn't forward variant docs")] +pub enum GeometryMode { + VertexColor, + VertexColorCullBack, + /// RGBA bytes are normals, not colors. + Lit, + /// RGBA bytes are normals, not colors. + LitCullBack, +} + +/// RDP render mode preset. Maps to PM64's Property 0x5C values in Star Rod XML. +#[loroscope] +#[derive(Debug)] +#[expect(missing_docs, reason = "loroscope macro doesn't forward variant docs")] +pub enum RenderMode { + SolidAaZbLayer0, // 0x00 + SurfaceOpa, // 0x01 + SurfaceOpaNoAa, // 0x03 + SurfaceOpaNoZb, // 0x04 + DecalOpa, // 0x05 + DecalOpaNoAa, // 0x07 + IntersectingOpa, // 0x09 + Alphatest, // 0x0D + AlphatestOnesided, // 0x0F + AlphatestNoZb, // 0x10 + SurfaceXluLayer1, // 0x11 + SurfaceXluNoAa, // 0x13 + SurfaceXluNoZb, // 0x14 + SurfaceXluZbZupd, // 0x15 + SurfaceXluLayer2, // 0x16 + DecalXlu, // 0x1A + DecalXluNoAa, // 0x1C + DecalXluAhead, // 0x1E + Shadow, // 0x20 + SurfaceXluLayer3, // 0x22 + IntersectingXlu, // 0x26 + PassThrough, // 0x28 + SurfaceXluAaZbZupd, // 0x29 + SurfaceOpaNoZbBehind, // 0x2A + AlphatestNoZbBehind, // 0x2B + SurfaceXluNoZbBehind, // 0x2C + CloudNoZcmp, // 0x2D + Cloud, // 0x2E + CloudNoZb, // 0x2F +} + #[loroscope] #[derive(Debug)] pub struct ModelNode { - /// Display name of this model node. pub name: String, - /// The triangles that make up this node's geometry. + pub geometry_mode: GeometryMode, + pub render_mode: RenderMode, pub triangles: List, } @@ -173,4 +220,24 @@ mod tests { node.set_name("root"); assert_eq!(scene.model_tree().roots().len(), 1); } + + #[test] + fn geometry_mode_get_set() { + let root = TestRoot::new(); + let tree = root.model_tree(); + let (_id, node) = tree.create_root(); + node.set_name("test"); + + // Default should be VertexColor (first variant) + assert!(matches!(node.geometry_mode(), GeometryMode::VertexColor)); + + node.set_geometry_mode_lit_cull_back(); + assert!(matches!(node.geometry_mode(), GeometryMode::LitCullBack)); + + node.set_geometry_mode_vertex_color_cull_back(); + assert!(matches!( + node.geometry_mode(), + GeometryMode::VertexColorCullBack + )); + } } diff --git a/crates/star_rod_interop/Cargo.toml b/crates/star_rod_interop/Cargo.toml index f708bb8..9b84226 100644 --- a/crates/star_rod_interop/Cargo.toml +++ b/crates/star_rod_interop/Cargo.toml @@ -10,6 +10,7 @@ edition = "2024" [dependencies] project = { path = "../project" } loroscope = { path = "../loroscope" } +log = { workspace = true } loro = { workspace = true } roxmltree = { workspace = true } thiserror = { workspace = true } diff --git a/crates/star_rod_interop/src/lib.rs b/crates/star_rod_interop/src/lib.rs index 47db2a6..0bde9ca 100644 --- a/crates/star_rod_interop/src/lib.rs +++ b/crates/star_rod_interop/src/lib.rs @@ -103,12 +103,22 @@ pub fn import_map_xml(xml: &str, scene: &Scene) -> Result<(), ImportError> { continue; }; + // Parse render mode from → + if let Some(prop_list) = model_el.children().find(|n| n.has_tag_name("PropertyList")) + && let Some(render_mode) = parse_render_mode_property(&prop_list) + { + set_render_mode(&node, &render_mode); + } + // Find → → * let shape_mesh = model_el.children().find(|n| n.has_tag_name("ShapeMesh")); let display_list = shape_mesh.and_then(|sm| sm.children().find(|n| n.has_tag_name("DisplayList"))); if let Some(dl) = display_list { + let geom_mode = parse_geometry_mode(&dl); + set_geometry_mode(&node, &geom_mode); + for batch in dl.children().filter(|n| n.has_tag_name("TriangleBatch")) { parse_triangle_batch(&batch, &node)?; } @@ -144,6 +154,159 @@ fn build_tree_recursive( Ok(()) } +const G_LIGHTING: u32 = 0x0002_0000; +const G_CULL_BACK: u32 = 0x0000_0400; + +/// Parses geometry mode from the last `D9` (`SetGeometryMode`) command in a ``. +/// Format: `D9AAAAAAA,OOOOOOO` where O is the OR bits. Uses the last D9 with OR != 0. +fn parse_geometry_mode(dl: &roxmltree::Node<'_, '_>) -> project::GeometryMode { + let mut or_bits: Option = None; + + for f3dex2 in dl.children().filter(|n| n.has_tag_name("F3DEX2")) { + let Some(cmd) = f3dex2.attribute("cmd") else { + continue; + }; + let Some((word0_str, word1_str)) = cmd.split_once(',') else { + continue; + }; + let Ok(word0) = u32::from_str_radix(word0_str.trim(), 16) else { + continue; + }; + if (word0 >> 24) != 0xD9 { + continue; + } + let Ok(word1) = u32::from_str_radix(word1_str.trim(), 16) else { + continue; + }; + if word1 != 0 { + or_bits = Some(word1); + } + } + + match or_bits { + Some(bits) => { + let has_lighting = bits & G_LIGHTING != 0; + let has_cull_back = bits & G_CULL_BACK != 0; + match (has_lighting, has_cull_back) { + (false, false) => project::GeometryMode::VertexColor, + (false, true) => project::GeometryMode::VertexColorCullBack, + (true, false) => project::GeometryMode::Lit, + (true, true) => project::GeometryMode::LitCullBack, + } + } + None => project::GeometryMode::VertexColor, + } +} + +fn set_geometry_mode(node: &project::ModelNode, mode: &project::GeometryMode) { + match mode { + project::GeometryMode::VertexColor => node.set_geometry_mode_vertex_color(), + project::GeometryMode::VertexColorCullBack => { + node.set_geometry_mode_vertex_color_cull_back(); + } + project::GeometryMode::Lit => node.set_geometry_mode_lit(), + project::GeometryMode::LitCullBack => node.set_geometry_mode_lit_cull_back(), + } +} + +/// Parses `` from a `` into a render mode. +fn parse_render_mode_property(prop_list: &roxmltree::Node<'_, '_>) -> Option { + for prop in prop_list.children().filter(|n| n.has_tag_name("Property")) { + let Some(v) = prop.attribute("v") else { + continue; + }; + let parts: Vec<&str> = v.split(',').collect(); + if parts.len() < 3 { + continue; + } + let Ok(prop_type) = u32::from_str_radix(parts[0].trim(), 16) else { + continue; + }; + if prop_type != 0x5C { + continue; + } + let Ok(value) = u32::from_str_radix(parts[2].trim(), 16) else { + continue; + }; + return match value { + 0x00 => Some(project::RenderMode::SolidAaZbLayer0), + 0x01 => Some(project::RenderMode::SurfaceOpa), + 0x03 => Some(project::RenderMode::SurfaceOpaNoAa), + 0x04 => Some(project::RenderMode::SurfaceOpaNoZb), + 0x05 => Some(project::RenderMode::DecalOpa), + 0x07 => Some(project::RenderMode::DecalOpaNoAa), + 0x09 => Some(project::RenderMode::IntersectingOpa), + 0x0D => Some(project::RenderMode::Alphatest), + 0x0F => Some(project::RenderMode::AlphatestOnesided), + 0x10 => Some(project::RenderMode::AlphatestNoZb), + 0x11 => Some(project::RenderMode::SurfaceXluLayer1), + 0x13 => Some(project::RenderMode::SurfaceXluNoAa), + 0x14 => Some(project::RenderMode::SurfaceXluNoZb), + 0x15 => Some(project::RenderMode::SurfaceXluZbZupd), + 0x16 => Some(project::RenderMode::SurfaceXluLayer2), + 0x1A => Some(project::RenderMode::DecalXlu), + 0x1C => Some(project::RenderMode::DecalXluNoAa), + 0x1E => Some(project::RenderMode::DecalXluAhead), + 0x20 => Some(project::RenderMode::Shadow), + 0x22 => Some(project::RenderMode::SurfaceXluLayer3), + 0x26 => Some(project::RenderMode::IntersectingXlu), + 0x28 => Some(project::RenderMode::PassThrough), + 0x29 => Some(project::RenderMode::SurfaceXluAaZbZupd), + 0x2A => Some(project::RenderMode::SurfaceOpaNoZbBehind), + 0x2B => Some(project::RenderMode::AlphatestNoZbBehind), + 0x2C => Some(project::RenderMode::SurfaceXluNoZbBehind), + 0x2D => Some(project::RenderMode::CloudNoZcmp), + 0x2E => Some(project::RenderMode::Cloud), + 0x2F => Some(project::RenderMode::CloudNoZb), + unknown => { + log::warn!( + "unknown render mode Property 0x5C value: {unknown:#X}, defaulting to Alphatest" + ); + Some(project::RenderMode::Alphatest) + } + }; + } + None +} + +fn set_render_mode(node: &project::ModelNode, mode: &project::RenderMode) { + match mode { + project::RenderMode::SolidAaZbLayer0 => node.set_render_mode_solid_aa_zb_layer0(), + project::RenderMode::SurfaceOpa => node.set_render_mode_surface_opa(), + project::RenderMode::SurfaceOpaNoAa => node.set_render_mode_surface_opa_no_aa(), + project::RenderMode::SurfaceOpaNoZb => node.set_render_mode_surface_opa_no_zb(), + project::RenderMode::DecalOpa => node.set_render_mode_decal_opa(), + project::RenderMode::DecalOpaNoAa => node.set_render_mode_decal_opa_no_aa(), + project::RenderMode::IntersectingOpa => node.set_render_mode_intersecting_opa(), + project::RenderMode::Alphatest => node.set_render_mode_alphatest(), + project::RenderMode::AlphatestOnesided => node.set_render_mode_alphatest_onesided(), + project::RenderMode::AlphatestNoZb => node.set_render_mode_alphatest_no_zb(), + project::RenderMode::SurfaceXluLayer1 => node.set_render_mode_surface_xlu_layer1(), + project::RenderMode::SurfaceXluNoAa => node.set_render_mode_surface_xlu_no_aa(), + project::RenderMode::SurfaceXluNoZb => node.set_render_mode_surface_xlu_no_zb(), + project::RenderMode::SurfaceXluZbZupd => node.set_render_mode_surface_xlu_zb_zupd(), + project::RenderMode::SurfaceXluLayer2 => node.set_render_mode_surface_xlu_layer2(), + project::RenderMode::DecalXlu => node.set_render_mode_decal_xlu(), + project::RenderMode::DecalXluNoAa => node.set_render_mode_decal_xlu_no_aa(), + project::RenderMode::DecalXluAhead => node.set_render_mode_decal_xlu_ahead(), + project::RenderMode::Shadow => node.set_render_mode_shadow(), + project::RenderMode::SurfaceXluLayer3 => node.set_render_mode_surface_xlu_layer3(), + project::RenderMode::IntersectingXlu => node.set_render_mode_intersecting_xlu(), + project::RenderMode::PassThrough => node.set_render_mode_pass_through(), + project::RenderMode::SurfaceXluAaZbZupd => node.set_render_mode_surface_xlu_aa_zb_zupd(), + project::RenderMode::SurfaceOpaNoZbBehind => { + node.set_render_mode_surface_opa_no_zb_behind(); + } + project::RenderMode::AlphatestNoZbBehind => node.set_render_mode_alphatest_no_zb_behind(), + project::RenderMode::SurfaceXluNoZbBehind => { + node.set_render_mode_surface_xlu_no_zb_behind(); + } + project::RenderMode::CloudNoZcmp => node.set_render_mode_cloud_no_zcmp(), + project::RenderMode::Cloud => node.set_render_mode_cloud(), + project::RenderMode::CloudNoZb => node.set_render_mode_cloud_no_zb(), + } +} + /// Parses a `` element and adds its triangles to the node. fn parse_triangle_batch( batch: &roxmltree::Node<'_, '_>, @@ -211,6 +374,8 @@ struct ParsedVertex { x: f64, y: f64, z: f64, + u: f64, + v: f64, r: i64, g: i64, b: i64, @@ -234,6 +399,20 @@ fn parse_vertex_element(el: roxmltree::Node<'_, '_>) -> Result) -> Result + + + + + + + + + + + + + + + + + + + + + + + +"#; + + import_map_xml(xml, &scene).unwrap(); + let tri = scene + .model_tree() + .get(scene.model_tree().roots()[0]) + .unwrap() + .triangles() + .get(0) + .unwrap(); + assert_eq!(tri.v0().u(), 512.0); + assert_eq!(tri.v0().v(), 1024.0); + assert_eq!(tri.v1().u(), -128.0); + assert_eq!(tri.v1().v(), 256.0); + assert_eq!(tri.v2().u(), 0.0); + assert_eq!(tri.v2().v(), 0.0); + } + + #[test] + fn missing_uv_defaults_to_zero() { + let scene = make_scene(); + let xml = r#" + + + + + + + + + + + + + + + + + + + + + + + +"#; + + import_map_xml(xml, &scene).unwrap(); + let tri = scene + .model_tree() + .get(scene.model_tree().roots()[0]) + .unwrap() + .triangles() + .get(0) + .unwrap(); + assert_eq!(tri.v0().u(), 0.0); + assert_eq!(tri.v0().v(), 0.0); + } + + #[test] + fn geometry_mode_from_f3dex2() { + let scene = make_scene(); + let xml = r#" + + + + + + + + + + + + + + + + + + + + + + + + + +"#; + + import_map_xml(xml, &scene).unwrap(); + let node = scene + .model_tree() + .get(scene.model_tree().roots()[0]) + .unwrap(); + // 0x00220400 = G_SHADING_SMOOTH | G_LIGHTING | G_CULL_BACK + assert!(matches!( + node.geometry_mode(), + project::GeometryMode::LitCullBack + )); + } + + #[test] + fn missing_f3dex2_defaults_to_vertex_color() { + let scene = make_scene(); + let xml = r#" + + + + + + + + + + + + + + + + + + + + + + + +"#; + + import_map_xml(xml, &scene).unwrap(); + let node = scene + .model_tree() + .get(scene.model_tree().roots()[0]) + .unwrap(); + assert!(matches!( + node.geometry_mode(), + project::GeometryMode::VertexColor + )); + } + + #[test] + fn render_mode_from_property() { + let scene = make_scene(); + let xml = r#" + + + + + + + + + + + + +"#; + + import_map_xml(xml, &scene).unwrap(); + let node = scene + .model_tree() + .get(scene.model_tree().roots()[0]) + .unwrap(); + assert!(matches!(node.render_mode(), project::RenderMode::DecalXlu)); + } } diff --git a/crates/star_rod_interop/tests/import.rs b/crates/star_rod_interop/tests/import.rs index bb267a3..baa8dc3 100644 --- a/crates/star_rod_interop/tests/import.rs +++ b/crates/star_rod_interop/tests/import.rs @@ -54,9 +54,14 @@ fn import_realistic_map() { + + + + + @@ -74,8 +79,12 @@ fn import_realistic_map() { + + + + @@ -115,10 +124,27 @@ fn import_realistic_map() { assert_eq!(ground.name(), "Ground"); assert_eq!(ground.triangles().len(), 2); - // Spot-check a vertex + // Verify Ground geometry mode (D9 sets G_SHADE | G_SHADING_SMOOTH, no lighting) + assert!(matches!( + ground.geometry_mode(), + project::GeometryMode::VertexColor + )); + + // Verify Ground render mode (Property 0x5C = 0x0D = Alphatest) + assert!(matches!( + ground.render_mode(), + project::RenderMode::Alphatest + )); + + // Spot-check a vertex with UV let tri0 = ground.triangles().get(0).unwrap(); assert_eq!(tri0.v0().x(), -200.0); + assert_eq!(tri0.v0().u(), 0.0); + assert_eq!(tri0.v0().v(), 0.0); assert_eq!(tri0.v0().color().r(), 80); + // Second vertex has uv="0,1024" + assert_eq!(tri0.v1().u(), 0.0); + assert_eq!(tri0.v1().v(), 1024.0); // Check tree subtree: Tree -> [Trunk, Leaves] let tree_node_id = children[2]; @@ -132,6 +158,14 @@ fn import_realistic_map() { assert_eq!(trunk.name(), "Trunk"); assert_eq!(trunk.triangles().len(), 1); + // Trunk has D9 with G_LIGHTING | G_CULL_BACK → LitCullBack + assert!(matches!( + trunk.geometry_mode(), + project::GeometryMode::LitCullBack + )); + // Trunk has Property 0x5C = 0x1A → DecalXlu + assert!(matches!(trunk.render_mode(), project::RenderMode::DecalXlu)); + let leaves = tree.get(tree_children[1]).unwrap(); assert_eq!(leaves.name(), "Leaves"); assert_eq!(leaves.triangles().len(), 0); -- 2.51.2