Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_gte/
lighting.rs

1//! Up-to-3-directional-light rig, rendered via the GTE's
2//! hardware lighting pipeline (`NCCS` / `NCDS` / `NCS` ops).
3//!
4//! # Model
5//!
6//! Each [`Light`] is a *directional* light -- a direction vector
7//! + an RGB intensity. The PSX GTE evaluates lighting as:
8//!
9//! ```text
10//!   IR   = LLM * V0                         (N dot each light direction)
11//!   IR   = [BK] * 0x1000 + LCM * IR         (mix light colours + ambient)
12//!   MAC  = IR * RGBC                        (modulate by material/vertex colour)
13//!   RGB2 = MAC                              (push to RGB FIFO, readable as u32)
14//! ```
15//!
16//! - `LLM` (light direction matrix, 3×3): each **row** is one
17//!   light's direction vector.
18//! - `LCM` (light colour matrix, 3×3): each **column** is one
19//!   light's RGB contribution (so `LCM * IR` mixes light colours
20//!   weighted by the corresponding dot-product magnitudes).
21//! - `BK` (background colour, 3×i32): ambient term added
22//!   before the material multiply.
23//! - `RGBC` (data reg 6): per-vertex material / palette tint --
24//!   the GTE multiplies the lit colour by this before emitting.
25//!
26//! # Object-local vs world-space lights
27//!
28//! The GTE operates on normals in their native mesh space
29//! (typically object-local). Lights packed into LLM therefore
30//! also need to be in object-local space for the dot products to
31//! come out right. Use [`LightRig::for_object`] once per object
32//! per frame to pre-rotate world-space lights into its local
33//! frame.
34//!
35//! # Cost
36//!
37//! - Rig upload (`load`): ~20 CTC2 writes per frame (cheap).
38//! - Per-object rotate: 9 integer multiplies × 3 lights = 27 mul
39//!   + 9 adds on the CPU -- still cheap next to projection cost.
40//! - Per vertex: 2 MTC2 + 1 RTPS + 2 MTC2 + 1 MTC2 + 1 NCCS + 3
41//!   MFC2 ≈ 10 GTE ops, ~30-40 cycles total.
42//! - Batched projection: `project_lit_triangle` uses RTPT for three
43//!   positions, then NCCS per normal/material. This keeps per-vertex
44//!   lighting identical while replacing three RTPS ops with one RTPT.
45//!   If the three material tints match, it also uses NCCT to batch
46//!   the three NCCS lighting ops.
47
48#![allow(clippy::needless_range_loop)]
49
50use crate::math::{Mat3I16, Vec3I16, Vec3I32};
51use crate::scene;
52use crate::{ops, read_data, write_data};
53
54/// A single directional light in some reference frame (caller's
55/// choice of world / object / eye space).
56#[derive(Copy, Clone, Debug)]
57pub struct Light {
58    /// Direction FROM the surface TOWARD the light source. Q3.12
59    /// unit vector; if you computed a "light-to-surface" direction
60    /// instead, negate it before storing here.
61    pub direction: Vec3I16,
62    /// Per-channel intensity in Q3.12 (`0x1000` = full intensity,
63    /// `0x0800` = half, `0x2000` = double / over-bright). The GTE
64    /// clamps out-of-range values at the MAC stage.
65    pub colour: (i16, i16, i16),
66}
67
68impl Light {
69    /// Convenience constructor for a "warm-white at 75%" feel --
70    /// useful while prototyping light rigs.
71    pub const fn warm_white_at(direction: Vec3I16) -> Self {
72        Self {
73            direction,
74            colour: (0x0C00, 0x0A00, 0x0800),
75        }
76    }
77
78    /// "Cool fill" preset -- bluish, dimmer, useful as a
79    /// secondary / rim light.
80    pub const fn cool_fill_at(direction: Vec3I16) -> Self {
81        Self {
82            direction,
83            colour: (0x0400, 0x0600, 0x0A00),
84        }
85    }
86
87    /// Null light -- contributes nothing. Use as a filler for
88    /// unused slots in a rig.
89    pub const OFF: Self = Self {
90        direction: Vec3I16::ZERO,
91        colour: (0, 0, 0),
92    };
93}
94
95/// A rig of up to **3** directional lights -- the maximum the
96/// PSX GTE supports natively.
97#[derive(Copy, Clone, Debug)]
98pub struct LightRig {
99    /// Three light slots; unused slots should be [`Light::OFF`].
100    pub lights: [Light; 3],
101    /// Ambient term added before material multiply. Q3.12 per
102    /// channel in an `i32` (GTE's BK register is 20-bit signed).
103    pub ambient: (i32, i32, i32),
104}
105
106impl LightRig {
107    /// Empty rig: no lights, black ambient.
108    pub const OFF: Self = Self {
109        lights: [Light::OFF; 3],
110        ambient: (0, 0, 0),
111    };
112
113    /// Build a rig from 3 lights + an ambient term.
114    pub const fn new(lights: [Light; 3], ambient: (i32, i32, i32)) -> Self {
115        Self { lights, ambient }
116    }
117
118    /// Pack this rig into the GTE's control registers:
119    /// - LLM ← rows are light directions
120    /// - LCM ← columns are light RGB contributions
121    /// - BK ← ambient
122    ///
123    /// Call once per object (or once per frame if lights are
124    /// in a universal frame). Any subsequent [`NCCS`][ops::light_color_single]
125    /// / [`NCS`][ops::light_single] / [`NCDS`][ops::light_color_depth_single] reads these.
126    pub fn load(&self) {
127        let [l0, l1, l2] = self.lights;
128
129        // LLM: rows = light directions.
130        let llm = Mat3I16 {
131            m: [
132                [l0.direction.x, l0.direction.y, l0.direction.z],
133                [l1.direction.x, l1.direction.y, l1.direction.z],
134                [l2.direction.x, l2.direction.y, l2.direction.z],
135            ],
136        };
137        scene::load_light_matrix(&llm);
138
139        // LCM: `LCM * IR` mixes light colours weighted by the
140        // dot-product intensities. IR[i] = light-i's dot product,
141        // so LCM's COLUMN i must be light-i's RGB triple. In our
142        // row-major layout that means m[channel][light].
143        let lcm = Mat3I16 {
144            m: [
145                [l0.colour.0, l1.colour.0, l2.colour.0], // R row
146                [l0.colour.1, l1.colour.1, l2.colour.1], // G row
147                [l0.colour.2, l1.colour.2, l2.colour.2], // B row
148            ],
149        };
150        scene::load_light_color_matrix(&lcm);
151
152        // BK: ambient term.
153        scene::load_background_color(Vec3I32::new(self.ambient.0, self.ambient.1, self.ambient.2));
154    }
155
156    /// Return a new rig with every light's direction rotated into
157    /// the frame in which `rotation` is the object's local → world
158    /// transform. I.e., if you pass the same `Mat3I16` you pass to
159    /// [`scene::load_rotation`] for RTPS, the returned rig is in
160    /// object-local space -- correct for feeding LLM when lighting
161    /// mesh vertices that are themselves in local space.
162    ///
163    /// Math: world_vert = R × local_vert, so local = R⁻¹ × world,
164    /// and since R is a rotation, R⁻¹ = Rᵀ. We compute
165    /// `local_dir = Rᵀ × world_dir` component-wise.
166    pub fn for_object(&self, rotation: &Mat3I16) -> LightRig {
167        let rotate = |dir: Vec3I16| {
168            // R^T * dir: the dot of `dir` with each COLUMN of R.
169            let mut out = [0i32; 3];
170            for i in 0..3 {
171                out[i] = ((rotation.m[0][i] as i32) * (dir.x as i32)
172                    + (rotation.m[1][i] as i32) * (dir.y as i32)
173                    + (rotation.m[2][i] as i32) * (dir.z as i32))
174                    >> 12;
175            }
176            Vec3I16::new(
177                out[0].clamp(i16::MIN as i32, i16::MAX as i32) as i16,
178                out[1].clamp(i16::MIN as i32, i16::MAX as i32) as i16,
179                out[2].clamp(i16::MIN as i32, i16::MAX as i32) as i16,
180            )
181        };
182        let rot_light = |l: &Light| Light {
183            direction: rotate(l.direction),
184            colour: l.colour,
185        };
186        LightRig {
187            lights: [
188                rot_light(&self.lights[0]),
189                rot_light(&self.lights[1]),
190                rot_light(&self.lights[2]),
191            ],
192            ambient: self.ambient,
193        }
194    }
195
196    /// Return a new rig with every light's direction rotated by
197    /// `rotation` (direct `R × dir`, not the transpose).
198    ///
199    /// Used to animate world-space lights over time -- e.g. an
200    /// orbiting key light. Compose this BEFORE [`for_object`] so
201    /// the per-object transpose applies on top of the animation.
202    pub fn rotated(&self, rotation: &Mat3I16) -> LightRig {
203        let rotate = |dir: Vec3I16| {
204            // R × dir: standard row-major matrix-vector product.
205            let mut out = [0i32; 3];
206            for i in 0..3 {
207                out[i] = ((rotation.m[i][0] as i32) * (dir.x as i32)
208                    + (rotation.m[i][1] as i32) * (dir.y as i32)
209                    + (rotation.m[i][2] as i32) * (dir.z as i32))
210                    >> 12;
211            }
212            Vec3I16::new(
213                out[0].clamp(i16::MIN as i32, i16::MAX as i32) as i16,
214                out[1].clamp(i16::MIN as i32, i16::MAX as i32) as i16,
215                out[2].clamp(i16::MIN as i32, i16::MAX as i32) as i16,
216            )
217        };
218        let rot_light = |l: &Light| Light {
219            direction: rotate(l.direction),
220            colour: l.colour,
221        };
222        LightRig {
223            lights: [
224                rot_light(&self.lights[0]),
225                rot_light(&self.lights[1]),
226                rot_light(&self.lights[2]),
227            ],
228            ambient: self.ambient,
229        }
230    }
231}
232
233/// Result of [`project_lit`]: screen-space vertex + its computed
234/// lit colour.
235#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
236#[repr(C)]
237pub struct ProjectedLit {
238    /// Screen-space X, post-projection.
239    pub sx: i16,
240    /// Screen-space Y.
241    pub sy: i16,
242    /// Depth (MAC3 after divide), 0..=0xFFFF.
243    pub sz: u16,
244    /// GTE-computed RGB after lighting + material modulation.
245    pub r: u8,
246    /// Lit green.
247    pub g: u8,
248    /// Lit blue.
249    pub b: u8,
250}
251
252/// Result of [`project_triangle_fogged`]: three projected + lit +
253/// fogged vertices, plus the AVSZ3 OT-slot key and the hardware-
254/// NCLIP back-face flag.
255#[derive(Copy, Clone, Debug, Default)]
256#[repr(C)]
257pub struct FoggedTri {
258    /// The three vertices in draw-order, each carrying its own
259    /// projected position and lit+fogged colour.
260    pub verts: [ProjectedLit; 3],
261    /// AVSZ3 result -- ZSF3-weighted average of the three projected
262    /// Z values. Commercial games scale this into an OT slot so
263    /// nearer triangles sort in front.
264    pub otz: u16,
265    /// `true` if MAC0 from NCLIP came out positive -- the triangle
266    /// is front-facing and should be drawn. `false` = back-face,
267    /// skip.
268    pub front_facing: bool,
269}
270
271/// Run the full PS1-commercial triangle pipeline with **per-vertex
272/// fog**. Three interleaved RTPS + NCDS pairs give each vertex its
273/// own `IR0` (its own depth-cue weight), then `NCLIP` + `AVSZ3`
274/// finish the bookkeeping.
275///
276/// # Why per-vertex, not batched
277///
278/// `RTPT + NCDT` is faster (4 GTE ops vs 8) but produces **uniform**
279/// fog across each triangle -- `RTPT` only writes `IR0` for the last
280/// vertex, and `NCDT` then reuses that single `IR0` for each
281/// vertex's stage-3 blend. The visible result is stepped shading:
282/// every triangle has one flat fog tint, and seams between
283/// triangles are obvious.
284///
285/// `3× RTPS + 3× NCDS` sets `IR0` per vertex, so `NCDS` blends
286/// each vertex's material toward `FC` by that vertex's own depth
287/// weight. `GP0 0x34` Gouraud-interpolates the three vertex tints
288/// across the triangle, giving a smooth per-pixel gradient with
289/// no inter-triangle seam.
290///
291/// Cost is about 2× the GTE work per triangle vs the batched path.
292/// Still well under budget for PSX-scale scenes (~100 tris/frame).
293///
294/// # Pipeline
295///
296/// For each vertex `i = 0, 1, 2`:
297///   1. Load `verts[i]` into V0.
298///   2. `RTPS` -- projects V0, pushes SXY + SZ FIFOs, sets `IR0`
299///      from DQA/DQB × the perspective divisor.
300///   3. Load `normals[i]` into V0 (overwriting the position --
301///      `RTPS` already consumed it).
302///   4. `NCDS` -- computes lit + fogged colour using the current
303///      `IR0`, pushes RGB FIFO.
304///
305/// After the loop:
306///   5. `NCLIP` -- reads SXY0/1/2 from the FIFO, flags back-face.
307///   6. `AVSZ3` -- reads SZ1/2/3, computes the OT-slot key.
308///
309/// `NCDS` doesn't touch the SXY or SZ FIFOs, so they still hold
310/// the three per-vertex projection results when NCLIP and AVSZ3
311/// run at the end.
312///
313/// # Prerequisites
314/// All scene state must be loaded by the caller:
315/// - Rotation matrix ([`scene::load_rotation`])
316/// - Translation ([`scene::load_translation`])
317/// - Projection plane + screen offset
318///   ([`scene::set_projection_plane`], [`scene::set_screen_offset`])
319/// - Light rig ([`LightRig::load`], `for_object` applied)
320/// - Far colour ([`scene::load_far_color`])
321/// - Depth-cue coefficients ([`scene::set_depth_cue`])
322/// - AVSZ weights ([`scene::set_average_z_weights`])
323pub fn project_triangle_fogged(
324    verts: [Vec3I16; 3],
325    normals: [Vec3I16; 3],
326    material: (u8, u8, u8),
327) -> FoggedTri {
328    // Material goes into RGBC once -- NCDS reads RGBC on every call
329    // and it doesn't change per-vertex.
330    let rgbc = (material.0 as u32) | ((material.1 as u32) << 8) | ((material.2 as u32) << 16);
331    write_data!(6, rgbc);
332
333    // Per-vertex RTPS → NCDS, interleaved so each vertex's NCDS
334    // uses the IR0 just written by its RTPS. Collect results
335    // one at a time to keep the RGB-FIFO semantics straightforward
336    // (NCDS pushes newest → slot 2; we read slot 2 immediately).
337    let mut verts_out = [ProjectedLit::default(); 3];
338    let mut screen = [(0i16, 0i16); 3];
339    for i in 0..3 {
340        // --- RTPS: project verts[i] ---
341        write_data!(0, verts[i].xy_packed());
342        write_data!(1, verts[i].z_packed());
343        // SAFETY: V0 loaded; scene setup is caller-supplied.
344        unsafe { ops::project_single() };
345        let sxy = read_data!(14); // SXY2 (latest) -- SXY has no result-read latency
346        let sz = read_data!(19) as u16; // SZ3 (latest)
347        screen[i] = (sxy as i16, (sxy >> 16) as i16);
348
349        // --- NCDS: lit + fogged colour for normals[i], using the
350        // IR0 just written by the RTPS above. ---
351        write_data!(0, normals[i].xy_packed());
352        write_data!(1, normals[i].z_packed());
353        // SAFETY: V0 holds the normal; RGBC is loaded; scene
354        // matrices / DQA / DQB / FC / IR0 come from the caller.
355        unsafe { ops::light_color_depth_single() };
356        let rgb = read_data!(22); // RGB2 (latest)
357
358        verts_out[i] = unpack_projected(sxy, sz, rgb);
359    }
360
361    // --- Back-face cull in software from the projected SXY. GTE NCLIP's
362    // MAC0 read-back is STALE on real hardware (the result-read hazard, see
363    // scene::screen_area), which mis-culls and drops faces on silicon
364    // while looking fine on emulators. The i32 cross product is exact and
365    // hazard-free; SXY itself has no read latency, so `screen` is correct. ---
366    let front_facing = crate::scene::screen_area(screen) > 0;
367
368    // --- AVSZ3: OT key from SZ1/2/3, weighted by ZSF3. ---
369    // SAFETY: three RTPS calls populated the SZ FIFO.
370    unsafe { ops::average_z3() };
371    let otz = read_data!(7) as u16;
372
373    FoggedTri {
374        verts: verts_out,
375        otz,
376        front_facing,
377    }
378}
379
380/// Shared unpack -- turns raw SXY / SZ / RGB register reads into a
381/// [`ProjectedLit`]. Factored out so both the per-vertex and batch
382/// paths agree on byte/field layout.
383#[inline]
384fn unpack_projected(sxy: u32, sz: u16, rgb: u32) -> ProjectedLit {
385    ProjectedLit {
386        sx: sxy as i16,
387        sy: (sxy >> 16) as i16,
388        sz,
389        r: (rgb & 0xFF) as u8,
390        g: ((rgb >> 8) & 0xFF) as u8,
391        b: ((rgb >> 16) & 0xFF) as u8,
392    }
393}
394
395#[inline]
396fn rgbc_word(material: (u8, u8, u8)) -> u32 {
397    (material.0 as u32) | ((material.1 as u32) << 8) | ((material.2 as u32) << 16)
398}
399
400/// Project three vertices via RTPT, then compute each vertex's lit
401/// colour with NCCS.
402///
403/// This is the visual-preserving batched path for Gouraud meshes:
404/// projection work shares RTPT's setup cost, but lighting still runs
405/// once per vertex so different normals and material tints produce the
406/// same RGB values as three [`project_lit`] calls.
407///
408/// Prerequisites are the same as [`project_lit`]:
409/// rotation/translation/projection registers and the light rig must
410/// already be loaded.
411pub fn project_lit_triangle(
412    verts: [Vec3I16; 3],
413    normals: [Vec3I16; 3],
414    materials: [(u8, u8, u8); 3],
415) -> [ProjectedLit; 3] {
416    write_data!(0, verts[0].xy_packed());
417    write_data!(1, verts[0].z_packed());
418    write_data!(2, verts[1].xy_packed());
419    write_data!(3, verts[1].z_packed());
420    write_data!(4, verts[2].xy_packed());
421    write_data!(5, verts[2].z_packed());
422    // SAFETY: V0, V1, and V2 are loaded; scene setup is caller-supplied.
423    unsafe { ops::project_triple() };
424
425    let sxy = [read_data!(12), read_data!(13), read_data!(14)];
426    let sz = [
427        read_data!(17) as u16,
428        read_data!(18) as u16,
429        read_data!(19) as u16,
430    ];
431
432    if materials[0] == materials[1] && materials[1] == materials[2] {
433        write_data!(0, normals[0].xy_packed());
434        write_data!(1, normals[0].z_packed());
435        write_data!(2, normals[1].xy_packed());
436        write_data!(3, normals[1].z_packed());
437        write_data!(4, normals[2].xy_packed());
438        write_data!(5, normals[2].z_packed());
439        write_data!(6, rgbc_word(materials[0]));
440        // SAFETY: V0, V1, V2 hold normals and RGBC is common to all
441        // three vertices. NCCT is equivalent to NCCS for V0/V1/V2.
442        unsafe { ops::light_color_triple() };
443        let rgb = [read_data!(20), read_data!(21), read_data!(22)];
444        return [
445            unpack_projected(sxy[0], sz[0], rgb[0]),
446            unpack_projected(sxy[1], sz[1], rgb[1]),
447            unpack_projected(sxy[2], sz[2], rgb[2]),
448        ];
449    }
450
451    let mut out = [ProjectedLit::default(); 3];
452    let mut i = 0;
453    while i < 3 {
454        write_data!(0, normals[i].xy_packed());
455        write_data!(1, normals[i].z_packed());
456        write_data!(6, rgbc_word(materials[i]));
457        // SAFETY: V0 holds the normal, RGBC holds the material;
458        // LLM/LCM/BK were loaded by the caller's light rig.
459        unsafe { ops::light_color_single() };
460        out[i] = unpack_projected(sxy[i], sz[i], read_data!(22));
461        i += 1;
462    }
463
464    out
465}
466
467/// Project a vertex AND compute its lit colour in one call.
468///
469/// Does RTPS on `vert` + NCCS on `normal`, with a per-vertex
470/// `material` RGB loaded into RGBC (data reg 6) to modulate the
471/// GTE's lit output. Pass `(128, 128, 128)` as material for
472/// "unmodulated" behaviour -- the lit colour comes through as the
473/// light rig + ambient dictate.
474///
475/// Prerequisites (caller's responsibility, as with
476/// [`scene::project_vertex`]):
477///
478/// - Rotation matrix loaded ([`scene::load_rotation`])
479/// - Translation loaded ([`scene::load_translation`])
480/// - Projection plane / screen offset set
481///   ([`scene::set_projection_plane`], [`scene::set_screen_offset`])
482/// - Light rig loaded ([`LightRig::load`], with `for_object`
483///   applied so lights are in the same frame as the normal)
484pub fn project_lit(vert: Vec3I16, normal: Vec3I16, material: (u8, u8, u8)) -> ProjectedLit {
485    // --- Position: RTPS ---
486    write_data!(0, vert.xy_packed());
487    write_data!(1, vert.z_packed());
488    // SAFETY: V0 loaded; scene setup is caller's responsibility.
489    unsafe { ops::project_single() };
490    let sxy = read_data!(14); // SXY2 (packed xy)
491    let sz = read_data!(19) as u16; // SZ3
492
493    // --- Lighting: NCCS ---
494    write_data!(0, normal.xy_packed());
495    write_data!(1, normal.z_packed());
496    // RGBC layout (data reg 6): 0x00CC_BBGG_RR -- low 8 bits R,
497    // next 8 G, next 8 B, top 8 "CODE" (GPU command byte, used by
498    // some prim ops; 0 for our purposes).
499    write_data!(6, rgbc_word(material));
500    // SAFETY: V0 holds the normal, RGBC holds the material;
501    // LLM/LCM/BK were loaded via `LightRig::load`.
502    unsafe { ops::light_color_single() };
503    // Read lit colour from RGB2 (data reg 22). Same 0x00BB_GGRR
504    // layout as RGBC.
505    let lit = read_data!(22);
506
507    ProjectedLit {
508        sx: sxy as i16,
509        sy: (sxy >> 16) as i16,
510        sz,
511        r: (lit & 0xFF) as u8,
512        g: ((lit >> 8) & 0xFF) as u8,
513        b: ((lit >> 16) & 0xFF) as u8,
514    }
515}
516
517#[cfg(all(test, not(target_arch = "mips")))]
518mod host_smoke {
519    use super::*;
520    use crate::host;
521    use crate::math::{Mat3I16, Vec3I16, Vec3I32};
522    use crate::scene;
523
524    fn install_scene() {
525        scene::load_rotation(&Mat3I16::IDENTITY);
526        scene::load_translation(Vec3I32::new(0, 0, 0));
527        scene::set_screen_offset(160 << 16, 120 << 16);
528        scene::set_projection_plane(280);
529        LightRig::new(
530            [
531                Light {
532                    direction: Vec3I16::new(0, 0, 0x1000),
533                    colour: (0x0C00, 0x0800, 0x0400),
534                },
535                Light {
536                    direction: Vec3I16::new(0x0800, 0, 0x0800),
537                    colour: (0x0400, 0x0800, 0x0C00),
538                },
539                Light::OFF,
540            ],
541            (0x0100, 0x0100, 0x0100),
542        )
543        .load();
544    }
545
546    #[test]
547    fn batched_lit_triangle_matches_three_project_lit_calls() {
548        let verts = [
549            Vec3I16::new(-256, -128, 1024),
550            Vec3I16::new(256, -96, 1200),
551            Vec3I16::new(0, 256, 1400),
552        ];
553        let normals = [
554            Vec3I16::new(0, 0, 0x1000),
555            Vec3I16::new(0x0400, 0, 0x0F00),
556            Vec3I16::new(-0x0300, 0x0400, 0x0E00),
557        ];
558        let materials = [(128, 96, 64), (64, 128, 96), (96, 64, 128)];
559
560        host::reset();
561        install_scene();
562        let batch = project_lit_triangle(verts, normals, materials);
563
564        host::reset();
565        install_scene();
566        let separate = [
567            project_lit(verts[0], normals[0], materials[0]),
568            project_lit(verts[1], normals[1], materials[1]),
569            project_lit(verts[2], normals[2], materials[2]),
570        ];
571
572        assert_eq!(batch, separate);
573    }
574
575    #[test]
576    fn batched_lit_triangle_shared_material_uses_same_outputs() {
577        let verts = [
578            Vec3I16::new(-192, 64, 900),
579            Vec3I16::new(64, -160, 1150),
580            Vec3I16::new(224, 192, 1500),
581        ];
582        let normals = [
583            Vec3I16::new(0, 0, 0x1000),
584            Vec3I16::new(0x0200, 0x0200, 0x0F00),
585            Vec3I16::new(-0x0200, 0x0300, 0x0F00),
586        ];
587        let materials = [(112, 144, 96); 3];
588
589        host::reset();
590        install_scene();
591        let batch = project_lit_triangle(verts, normals, materials);
592
593        host::reset();
594        install_scene();
595        let separate = [
596            project_lit(verts[0], normals[0], materials[0]),
597            project_lit(verts[1], normals[1], materials[1]),
598            project_lit(verts[2], normals[2], materials[2]),
599        ];
600
601        assert_eq!(batch, separate);
602    }
603}