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}