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

psx_gpu/
material.rs

1//! PS1 texture material state.
2//!
3//! A material here is intentionally small: the GPU still sees plain
4//! GP0 packets, but callers can group the pieces that decide how a
5//! textured primitive samples and blends: CLUT word, tpage word,
6//! tint, raw-texture flag, dither flag, and semi-transparency mode.
7
8use psx_hw::gpu::{gp0, pack_color};
9
10/// PS1 semi-transparency mode.
11///
12/// The four non-opaque variants map directly to GP0(E1) / tpage
13/// bits 5..6. `Opaque` means the primitive command's semi-transparent
14/// bit is kept clear, so the destination pixel is overwritten.
15#[derive(Copy, Clone, Debug, PartialEq, Eq)]
16pub enum BlendMode {
17    /// Overwrite destination pixels.
18    Opaque,
19    /// `(background + foreground) / 2`.
20    Average,
21    /// `background + foreground`, clamped per channel.
22    Add,
23    /// `background - foreground`, clamped per channel.
24    Subtract,
25    /// `background + foreground / 4`, clamped per channel.
26    AddQuarter,
27}
28
29/// GP0(E2) texture-window state.
30///
31/// Mask and offset values are stored in the hardware's 8-texel units.
32/// `TextureWindow::NONE` is a no-op window. Non-zero masks let a
33/// primitive repeat a sub-rectangle of the active tpage without
34/// physically duplicating texels in VRAM.
35#[derive(Copy, Clone, Debug, PartialEq, Eq)]
36pub struct TextureWindow {
37    mask_x: u8,
38    mask_y: u8,
39    offset_x: u8,
40    offset_y: u8,
41}
42
43impl TextureWindow {
44    /// No texture window.
45    pub const NONE: Self = Self::new(0, 0, 0, 0);
46
47    /// Build from raw GP0(E2) fields. Each value must fit in 5 bits.
48    pub const fn new(mask_x: u8, mask_y: u8, offset_x: u8, offset_y: u8) -> Self {
49        assert!(mask_x < 32, "texture-window mask_x must be < 32");
50        assert!(mask_y < 32, "texture-window mask_y must be < 32");
51        assert!(offset_x < 32, "texture-window offset_x must be < 32");
52        assert!(offset_y < 32, "texture-window offset_y must be < 32");
53        Self {
54            mask_x,
55            mask_y,
56            offset_x,
57            offset_y,
58        }
59    }
60
61    /// Build a window for a power-of-two tile.
62    ///
63    /// `origin_*` and `size_*` are in texels. Each origin must align to its
64    /// tile size because GP0(E2) replaces the coordinate bits selected by the
65    /// mask. The complete tile must fit in the active 256x256 texture page.
66    pub const fn power_of_two_tile(origin_x: u8, origin_y: u8, size_x: u8, size_y: u8) -> Self {
67        assert!(
68            size_x >= 8 && size_x.is_power_of_two(),
69            "texture-window width must be a power of two >= 8"
70        );
71        assert!(
72            size_y >= 8 && size_y.is_power_of_two(),
73            "texture-window height must be a power of two >= 8"
74        );
75        assert!(
76            origin_x.is_multiple_of(size_x),
77            "texture-window origin_x must align to its width"
78        );
79        assert!(
80            origin_y.is_multiple_of(size_y),
81            "texture-window origin_y must align to its height"
82        );
83        assert!(size_x <= 128, "texture-window width must fit GP0(E2)");
84        assert!(size_y <= 128, "texture-window height must fit GP0(E2)");
85        assert!(
86            origin_x as u16 + size_x as u16 <= 256,
87            "texture window must fit the texture-page width"
88        );
89        assert!(
90            origin_y as u16 + size_y as u16 <= 256,
91            "texture window must fit the texture-page height"
92        );
93        let mask_x = ((!((size_x as u16) - 1)) & 0x00FF) as u8;
94        let mask_y = ((!((size_y as u16) - 1)) & 0x00FF) as u8;
95        Self::new(mask_x / 8, mask_y / 8, origin_x / 8, origin_y / 8)
96    }
97
98    /// Encoded GP0(E2) word.
99    pub const fn word(self) -> u32 {
100        gp0::tex_window(
101            self.mask_x as u32,
102            self.mask_y as u32,
103            self.offset_x as u32,
104            self.offset_y as u32,
105        )
106    }
107
108    /// Texture-page origin selected by this window, in texels.
109    ///
110    /// Power-of-two atlas placements use the raw offset fields as their
111    /// aligned page-local origin. Exposing it lets a cooker-proven surface
112    /// address the same texels directly and omit GP0(E2) from its packets.
113    pub const fn origin_texels(self) -> [u8; 2] {
114        [self.offset_x * 8, self.offset_y * 8]
115    }
116}
117
118impl BlendMode {
119    /// Decode the two tpage semi-transparency bits.
120    ///
121    /// Hardware tpage bits do not encode "opaque"; the primitive
122    /// opcode decides whether blending is active. This therefore
123    /// returns one of the four blending variants.
124    #[doc(alias = "tpage")]
125    pub const fn from_texture_page_bits(bits: u8) -> Self {
126        match bits & 0x3 {
127            0 => Self::Average,
128            1 => Self::Add,
129            2 => Self::Subtract,
130            _ => Self::AddQuarter,
131        }
132    }
133
134    /// Renamed to [`from_texture_page_bits`](Self::from_texture_page_bits).
135    #[deprecated(note = "renamed to `from_texture_page_bits`")]
136    #[inline(always)]
137    pub const fn from_tpage_bits(bits: u8) -> Self {
138        Self::from_texture_page_bits(bits)
139    }
140
141    /// Encode as GP0(E1) / tpage bits 5..6.
142    ///
143    /// `Opaque` uses the common average-blend encoding because the
144    /// primitive semi-transparent bit will be clear.
145    #[doc(alias = "tpage")]
146    pub const fn texture_page_bits(self) -> u8 {
147        match self {
148            Self::Opaque | Self::Average => 0,
149            Self::Add => 1,
150            Self::Subtract => 2,
151            Self::AddQuarter => 3,
152        }
153    }
154
155    /// True when a primitive using this material should set its
156    /// semi-transparent command bit.
157    pub const fn is_translucent(self) -> bool {
158        !matches!(self, Self::Opaque)
159    }
160}
161
162/// Textured primitive material.
163///
164/// `clut_word` is the packed CLUT handle used by indexed textures.
165/// `tpage_word` is the packed tpage word normally produced by
166/// `TexturePage::uv_word(0)`. The material rewrites the tpage blend
167/// bits from [`BlendMode`] so one base tpage can be reused across
168/// opaque and translucent variants.
169#[derive(Copy, Clone, Debug, PartialEq, Eq)]
170pub struct TextureMaterial {
171    clut_word: u16,
172    tpage_word: u16,
173    texture_window: TextureWindow,
174    tint: (u8, u8, u8),
175    blend_mode: BlendMode,
176    raw_texture: bool,
177    dither: bool,
178}
179
180/// Prepacked material words for flat textured triangle packets.
181///
182/// Animated model render paths can emit hundreds of textured triangles with
183/// one resolved atlas material. Keeping the invariant GP0 words together avoids
184/// rebuilding texture-window, command, CLUT, and tpage state for every face.
185#[derive(Copy, Clone, Debug, PartialEq, Eq)]
186pub struct TexturedPacketMaterial {
187    /// GP0(E2) texture-window word.
188    pub tex_window_word: u32,
189    /// Flat textured triangle command word.
190    pub color_command_word: u32,
191    /// CLUT word shifted into the high half of vertex-0 UV.
192    pub clut_high_word: u32,
193    /// TexturePage word shifted into the high half of vertex-1 UV.
194    pub tpage_high_word: u32,
195}
196
197impl TexturedPacketMaterial {
198    /// Build prepacked packet words from a texture material.
199    pub const fn from_texture(material: TextureMaterial) -> Self {
200        Self {
201            tex_window_word: material.texture_window_word(),
202            color_command_word: material.flat_textured_polygon_header(false),
203            clut_high_word: (material.clut_word() as u32) << 16,
204            tpage_high_word: (material.texture_page_word() as u32) << 16,
205        }
206    }
207
208    /// True when packets using this material need the transparent render layer.
209    pub const fn is_translucent(self) -> bool {
210        self.color_command_word & (1 << 25) != 0
211    }
212
213    /// Return packet words selecting a consecutive 16-entry 4bpp CLUT bank.
214    pub const fn with_clut_bank(mut self, bank: u8) -> Self {
215        self.clut_high_word = self.clut_high_word.wrapping_add(((bank & 3) as u32) << 16);
216        self
217    }
218}
219
220/// Prepacked material words for textured Gouraud triangle packets.
221///
222/// Hot cached-room paths emit many triangles with the same material.
223/// Keeping these invariant words beside the resolved material avoids
224/// rebuilding GP0 texture-window, command, CLUT, and tpage state for
225/// every primitive packet.
226#[derive(Copy, Clone, Debug, PartialEq, Eq)]
227pub struct TexturedGouraudPacketMaterial {
228    /// GP0(E2) texture-window word.
229    pub tex_window_word: u32,
230    /// Textured Gouraud triangle command word without vertex-0 RGB.
231    pub color0_command_word: u32,
232    /// CLUT word shifted into the high half of vertex-0 UV.
233    pub clut_high_word: u32,
234    /// TexturePage word shifted into the high half of vertex-1 UV.
235    pub tpage_high_word: u32,
236}
237
238impl TexturedGouraudPacketMaterial {
239    /// Build prepacked Gouraud packet words from a texture material.
240    pub const fn from_texture(material: TextureMaterial) -> Self {
241        Self {
242            tex_window_word: material.texture_window_word(),
243            color0_command_word: material.textured_polygon_command(true, false),
244            clut_high_word: (material.clut_word() as u32) << 16,
245            tpage_high_word: (material.texture_page_word() as u32) << 16,
246        }
247    }
248
249    /// True when packets using this material need the transparent render layer.
250    pub const fn is_translucent(self) -> bool {
251        self.color0_command_word & (1 << 25) != 0
252    }
253}
254
255impl TextureMaterial {
256    /// Build an opaque, unmodulated texture material.
257    pub const fn new(clut_word: u16, tpage_word: u16) -> Self {
258        Self::opaque(clut_word, tpage_word, (0x80, 0x80, 0x80))
259    }
260
261    /// Build an opaque texture material with a flat tint.
262    pub const fn opaque(clut_word: u16, tpage_word: u16, tint: (u8, u8, u8)) -> Self {
263        Self {
264            clut_word,
265            tpage_word,
266            texture_window: TextureWindow::NONE,
267            tint,
268            blend_mode: BlendMode::Opaque,
269            raw_texture: false,
270            dither: false,
271        }
272    }
273
274    /// Build a translucent texture material with a flat tint.
275    ///
276    /// Textured primitives still follow PS1 texel rules: the
277    /// primitive command enables transparency, and sampled texels
278    /// with bit 15 set actually blend through the selected mode.
279    pub const fn blended(
280        clut_word: u16,
281        tpage_word: u16,
282        tint: (u8, u8, u8),
283        blend_mode: BlendMode,
284    ) -> Self {
285        Self {
286            clut_word,
287            tpage_word,
288            texture_window: TextureWindow::NONE,
289            tint,
290            blend_mode,
291            raw_texture: false,
292            dither: false,
293        }
294    }
295
296    /// Return a copy using `blend_mode`.
297    ///
298    /// For a **textured** primitive this only selects the blending equation.
299    /// Which texels actually blend is decided per texel by bit 15 of the
300    /// palette entry (`psx_vram::Color555::with_stp`); a CLUT without that bit
301    /// draws fully opaque whatever is set here, and nothing reports it. For an
302    /// untextured primitive the mode applies to the whole polygon.
303    pub const fn with_blend_mode(mut self, blend_mode: BlendMode) -> Self {
304        self.blend_mode = blend_mode;
305        self
306    }
307
308    /// Return a copy using `tint`.
309    pub const fn with_tint(mut self, tint: (u8, u8, u8)) -> Self {
310        self.tint = tint;
311        self
312    }
313
314    /// Return a copy selecting a consecutive 16-entry 4bpp CLUT bank.
315    pub const fn with_clut_bank(mut self, bank: u8) -> Self {
316        self.clut_word = self.clut_word.wrapping_add((bank & 3) as u16);
317        self
318    }
319
320    /// Return a copy using `texture_window`.
321    pub const fn with_texture_window(mut self, texture_window: TextureWindow) -> Self {
322        self.texture_window = texture_window;
323        self
324    }
325
326    /// Return a copy with raw texture modulation enabled or disabled.
327    ///
328    /// Raw-texture mode bypasses RGB tint modulation. It is useful for
329    /// palettes or direct textures that already carry final colors.
330    pub const fn with_raw_texture(mut self, raw_texture: bool) -> Self {
331        self.raw_texture = raw_texture;
332        self
333    }
334
335    /// Return a copy with draw-mode dithering enabled or disabled.
336    pub const fn with_dither(mut self, dither: bool) -> Self {
337        self.dither = dither;
338        self
339    }
340
341    /// Packed CLUT word for the first textured UV word.
342    pub const fn clut_word(self) -> u16 {
343        self.clut_word
344    }
345
346    /// Packed tpage word for textured polygon UV words.
347    ///
348    /// The material owns the tpage blend and dither bits; all other
349    /// tpage address/depth bits are preserved from the original word.
350    #[doc(alias = "tpage")]
351    #[doc(alias = "getTPage")]
352    pub const fn texture_page_word(self) -> u16 {
353        let blend_bits = (self.blend_mode.texture_page_bits() as u16) << 5;
354        let dither_bit = (self.dither as u16) << 9;
355        (self.tpage_word & !(0x0060 | 0x0200)) | blend_bits | dither_bit
356    }
357
358    /// GP0(E2) texture-window word for this material.
359    pub const fn texture_window_word(self) -> u32 {
360        self.texture_window.word()
361    }
362
363    /// Flat tint used by non-Gouraud textured primitives.
364    pub const fn tint(self) -> (u8, u8, u8) {
365        self.tint
366    }
367
368    /// Active blend mode.
369    pub const fn blend_mode(self) -> BlendMode {
370        self.blend_mode
371    }
372
373    /// True when the primitive command should set its semi-transparent bit.
374    pub const fn is_translucent(self) -> bool {
375        self.blend_mode.is_translucent()
376    }
377
378    /// True when the primitive command should set its raw-texture bit.
379    pub const fn is_raw_texture(self) -> bool {
380        self.raw_texture
381    }
382
383    /// True when the material asks GP0(E1) / primitive tpage state for dithering.
384    pub const fn is_dithered(self) -> bool {
385        self.dither
386    }
387
388    /// Textured polygon command bits without the low RGB payload.
389    pub const fn textured_polygon_command(self, gouraud: bool, quad: bool) -> u32 {
390        gp0::polygon_opcode(gouraud, quad, true, self.is_translucent(), self.raw_texture)
391    }
392
393    /// Prepacked words for textured Gouraud triangle packet emission.
394    pub const fn textured_gouraud_packet_material(self) -> TexturedGouraudPacketMaterial {
395        TexturedGouraudPacketMaterial::from_texture(self)
396    }
397
398    /// Prepacked words for flat textured triangle packet emission.
399    pub const fn textured_packet_material(self) -> TexturedPacketMaterial {
400        TexturedPacketMaterial::from_texture(self)
401    }
402
403    /// Textured polygon header with the material's flat tint.
404    pub const fn flat_textured_polygon_header(self, quad: bool) -> u32 {
405        let (r, g, b) = self.tint;
406        self.textured_polygon_command(false, quad) | pack_color(r, g, b)
407    }
408
409    /// Textured rectangle command with the material's flat tint.
410    pub const fn textured_rect_header(self) -> u32 {
411        let (r, g, b) = self.tint;
412        psx_hw::gpu::packet::TEXTURED_RECT
413            | ((self.is_translucent() as u32) << 25)
414            | ((self.raw_texture as u32) << 24)
415            | pack_color(r, g, b)
416    }
417
418    /// GP0(E1) draw-mode word implied by this material.
419    ///
420    /// Textured rectangles read their tpage from this state. Textured
421    /// polygons embed the tpage word directly, but applying the same
422    /// material state before drawing keeps subsequent sprite draws in
423    /// sync.
424    pub const fn draw_mode_word(self) -> u32 {
425        let tpage = self.texture_page_word();
426        gp0::draw_mode(
427            (tpage & 0x0F) as u32,
428            ((tpage >> 4) & 1) as u32,
429            self.blend_mode.texture_page_bits() as u32,
430            ((tpage >> 7) & 0x3) as u32,
431            self.dither,
432            true,
433        )
434    }
435
436    /// Apply this material's tpage, blend, depth, dither, and texture-window state.
437    #[deprecated(note = "use `Gpu::set_draw_mode`")]
438    pub fn apply_draw_mode(self) {
439        crate::gpu::set_draw_mode(self);
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::{BlendMode, TextureMaterial, TextureWindow};
446
447    #[test]
448    fn blend_mode_decodes_tpage_bits() {
449        assert_eq!(BlendMode::from_texture_page_bits(0), BlendMode::Average);
450        assert_eq!(BlendMode::from_texture_page_bits(1), BlendMode::Add);
451        assert_eq!(BlendMode::from_texture_page_bits(2), BlendMode::Subtract);
452        assert_eq!(BlendMode::from_texture_page_bits(3), BlendMode::AddQuarter);
453        assert_eq!(BlendMode::from_texture_page_bits(4), BlendMode::Average);
454    }
455
456    #[test]
457    #[should_panic(expected = "texture-window origin_x must align to its width")]
458    fn power_of_two_window_rejects_an_origin_only_aligned_to_eight_texels() {
459        let _ = TextureWindow::power_of_two_tile(8, 0, 32, 32);
460    }
461
462    #[test]
463    fn material_encodes_blend_dither_and_command_bits() {
464        let material =
465            TextureMaterial::blended(0x1234, 0x018F, (0x80, 0x40, 0x20), BlendMode::AddQuarter)
466                .with_texture_window(super::TextureWindow::power_of_two_tile(64, 64, 64, 64))
467                .with_raw_texture(true)
468                .with_dither(true);
469
470        assert_eq!(material.clut_word(), 0x1234);
471        assert_eq!((material.texture_page_word() >> 5) & 0x3, 3);
472        assert_eq!((material.texture_page_word() >> 9) & 0x1, 1);
473        assert_eq!(material.texture_window_word(), 0xE204_2318);
474        assert_eq!(material.texture_window.origin_texels(), [64, 64]);
475        assert_eq!((material.textured_rect_header() >> 24) & 0xFF, 0x67);
476        assert_eq!(
477            (material.flat_textured_polygon_header(true) >> 24) & 0xFF,
478            0x2F
479        );
480    }
481
482    #[test]
483    fn gouraud_packet_material_is_compact_and_tracks_translucency() {
484        let opaque = TextureMaterial::opaque(0x1234, 0x018f, (0x80, 0x80, 0x80))
485            .textured_gouraud_packet_material();
486        let translucent =
487            TextureMaterial::blended(0x1234, 0x018f, (0x80, 0x80, 0x80), BlendMode::Average)
488                .textured_gouraud_packet_material();
489
490        assert_eq!(
491            core::mem::size_of::<super::TexturedGouraudPacketMaterial>(),
492            16
493        );
494        assert!(!opaque.is_translucent());
495        assert!(translucent.is_translucent());
496    }
497}