Skip to main content

pite_render/
atlas.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Sprite-sheet atlases: frame metadata from a JSON sidecar plus the UV math
3//! the sprite pipeline needs.
4//!
5//! The frame table lives here because the renderer owns UVs and batches by
6//! texture key — one sheet means one texture, so every frame of it lands in
7//! the same draw call. `pite-assets` reuses [`parse_atlas`] to validate the
8//! same files for `check`.
9
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use serde::Deserialize;
15
16/// Sidecar suffix appended to a sheet's file stem.
17pub const SIDECAR_EXT: &str = "atlas.json";
18
19/// One frame inside the sheet, in pixels.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct AtlasFrame {
22    pub x: u32,
23    pub y: u32,
24    pub w: u32,
25    pub h: u32,
26}
27
28impl AtlasFrame {
29    /// Normalized UV rect `(u0, v0, u1, v1)` inside a sheet of `sheet` pixels.
30    /// `v` grows downward, matching the sprite quad's top-left origin.
31    pub fn uv(&self, sheet: (u32, u32)) -> [f32; 4] {
32        let (sw, sh) = (sheet.0 as f32, sheet.1 as f32);
33        [
34            self.x as f32 / sw,
35            self.y as f32 / sh,
36            (self.x + self.w) as f32 / sw,
37            (self.y + self.h) as f32 / sh,
38        ]
39    }
40
41    fn fits(&self, sheet: (u32, u32)) -> bool {
42        self.w > 0
43            && self.h > 0
44            && self.x.saturating_add(self.w) <= sheet.0
45            && self.y.saturating_add(self.h) <= sheet.1
46    }
47}
48
49/// A parsed sheet: its texture file plus named frames.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Atlas {
52    pub texture: String,
53    pub size: (u32, u32),
54    pub frames: BTreeMap<String, AtlasFrame>,
55}
56
57impl Atlas {
58    /// Frame lookup that names the alternatives instead of failing silently.
59    pub fn frame(&self, name: &str) -> Result<AtlasFrame> {
60        self.frames.get(name).copied().ok_or_else(|| {
61            anyhow::anyhow!(
62                "atlas {:?} has no frame {name:?} (available: {})",
63                self.texture,
64                if self.frames.is_empty() {
65                    "none".to_string()
66                } else {
67                    self.names().join(", ")
68                }
69            )
70        })
71    }
72
73    pub fn names(&self) -> Vec<&str> {
74        self.frames.keys().map(String::as_str).collect()
75    }
76}
77
78#[derive(Debug, Deserialize)]
79struct RawAtlas {
80    texture: String,
81    size: [u32; 2],
82    frames: BTreeMap<String, RawFrame>,
83}
84
85#[derive(Debug, Deserialize)]
86struct RawFrame {
87    x: u32,
88    y: u32,
89    w: u32,
90    h: u32,
91}
92
93impl From<RawFrame> for AtlasFrame {
94    fn from(raw: RawFrame) -> Self {
95        AtlasFrame {
96            x: raw.x,
97            y: raw.y,
98            w: raw.w,
99            h: raw.h,
100        }
101    }
102}
103
104/// Parse atlas JSON. Empty sheets, zero sizes, and frames outside the sheet
105/// are errors — a bad atlas must never reach the GPU as silent garbage.
106pub fn parse_atlas(text: &str) -> Result<Atlas> {
107    let raw: RawAtlas =
108        serde_json::from_str(text).context("atlas is not valid JSON (texture/size/frames)")?;
109    if raw.texture.trim().is_empty() {
110        anyhow::bail!("atlas has an empty `texture`");
111    }
112    if raw.size[0] == 0 || raw.size[1] == 0 {
113        anyhow::bail!("atlas size {:?} must be positive", raw.size);
114    }
115    if raw.frames.is_empty() {
116        anyhow::bail!("atlas {:?} declares no frames", raw.texture);
117    }
118    let sheet = (raw.size[0], raw.size[1]);
119    let mut frames = BTreeMap::new();
120    for (name, frame) in raw.frames {
121        let frame = AtlasFrame::from(frame);
122        if !frame.fits(sheet) {
123            anyhow::bail!(
124                "atlas frame {name:?} ({},{} {}x{}) does not fit the {sheet:?} sheet",
125                frame.x,
126                frame.y,
127                frame.w,
128                frame.h
129            );
130        }
131        frames.insert(name, frame);
132    }
133    Ok(Atlas {
134        texture: raw.texture,
135        size: sheet,
136        frames,
137    })
138}
139
140pub fn load_atlas(path: &Path) -> Result<Atlas> {
141    let text = std::fs::read_to_string(path)
142        .with_context(|| format!("cannot read atlas {}", path.display()))?;
143    parse_atlas(&text).with_context(|| format!("cannot parse atlas {}", path.display()))
144}
145
146/// Sidecar path for a sheet: `sheet.png` -> `sheet.atlas.json` beside it.
147pub fn sidecar_for(sheet: &Path) -> PathBuf {
148    let stem = sheet
149        .file_stem()
150        .and_then(|s| s.to_str())
151        .filter(|s| !s.is_empty())
152        .unwrap_or("sheet");
153    sheet
154        .parent()
155        .unwrap_or_else(|| Path::new(""))
156        .join(format!("{stem}.{SIDECAR_EXT}"))
157}
158
159/// The sheet a sidecar describes: `sheet.atlas.json` -> `sheet.png`.
160pub fn sheet_for_sidecar(sidecar: &Path) -> PathBuf {
161    let name = sidecar
162        .file_name()
163        .and_then(|s| s.to_str())
164        .unwrap_or_default();
165    let stem = name
166        .strip_suffix(&format!(".{SIDECAR_EXT}"))
167        .unwrap_or(name);
168    sidecar
169        .parent()
170        .unwrap_or_else(|| Path::new(""))
171        .join(format!("{stem}.png"))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    const SHEET: &str = r#"{
179        "texture": "sheet.png",
180        "size": [64, 32],
181        "frames": {
182            "player": { "x": 0, "y": 0, "w": 32, "h": 32 },
183            "enemy": { "x": 32, "y": 0, "w": 32, "h": 32 },
184            "coin": { "x": 0, "y": 16, "w": 16, "h": 16 }
185        }
186    }"#;
187
188    #[test]
189    fn parses_named_frames() {
190        let atlas = parse_atlas(SHEET).unwrap();
191        assert_eq!(atlas.texture, "sheet.png");
192        assert_eq!(atlas.size, (64, 32));
193        assert_eq!(atlas.frames.len(), 3);
194        assert_eq!(
195            atlas.frame("player").unwrap(),
196            AtlasFrame {
197                x: 0,
198                y: 0,
199                w: 32,
200                h: 32
201            }
202        );
203        assert_eq!(
204            atlas.frame("enemy").unwrap(),
205            AtlasFrame {
206                x: 32,
207                y: 0,
208                w: 32,
209                h: 32
210            }
211        );
212        assert_eq!(atlas.names(), vec!["coin", "enemy", "player"]);
213    }
214
215    #[test]
216    fn missing_frame_names_the_alternatives() {
217        let atlas = parse_atlas(SHEET).unwrap();
218        let err = atlas.frame("ghost").unwrap_err().to_string();
219        assert!(err.contains("no frame \"ghost\""), "got: {err}");
220        assert!(err.contains("player"), "got: {err}");
221    }
222
223    #[test]
224    fn uv_math_maps_pixels_to_normalized_rects() {
225        let atlas = parse_atlas(SHEET).unwrap();
226        let player = atlas.frame("player").unwrap().uv(atlas.size);
227        assert_eq!(player, [0.0, 0.0, 0.5, 1.0]);
228        let enemy = atlas.frame("enemy").unwrap().uv(atlas.size);
229        assert_eq!(enemy, [0.5, 0.0, 1.0, 1.0]);
230        let coin = atlas.frame("coin").unwrap().uv(atlas.size);
231        assert_eq!(coin, [0.0, 0.5, 0.25, 1.0]);
232    }
233
234    #[test]
235    fn bad_atlases_fail_loudly() {
236        assert!(parse_atlas("not json").is_err());
237        assert!(parse_atlas(r#"{"texture":"","size":[1,1],"frames":{}}"#).is_err());
238        assert!(parse_atlas(r#"{"texture":"a.png","size":[0,8],"frames":{}}"#).is_err());
239        assert!(parse_atlas(r#"{"texture":"a.png","size":[8,8],"frames":{}}"#).is_err());
240        let out_of_bounds = r#"{"texture":"a.png","size":[8,8],
241            "frames":{"big":{"x":4,"y":4,"w":8,"h":8}}}"#;
242        let err = parse_atlas(out_of_bounds).unwrap_err().to_string();
243        assert!(err.contains("does not fit"), "got: {err}");
244    }
245
246    #[test]
247    fn sidecar_and_sheet_paths_round_trip() {
248        let sheet = Path::new("/game/assets/sheet.png");
249        let sidecar = sidecar_for(sheet);
250        assert_eq!(sidecar, PathBuf::from("/game/assets/sheet.atlas.json"));
251        assert_eq!(sheet_for_sidecar(&sidecar), sheet);
252    }
253}