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/// One animation sequence inside the sheet.
50#[derive(Debug, Clone, PartialEq)]
51pub struct AtlasAnimation {
52    pub frames: Vec<String>,
53    pub fps: f32,
54    pub looping: bool,
55}
56
57/// A parsed sheet: its texture file plus named frames and animations.
58#[derive(Debug, Clone, PartialEq)]
59pub struct Atlas {
60    pub texture: String,
61    pub size: (u32, u32),
62    pub frames: BTreeMap<String, AtlasFrame>,
63    pub animations: BTreeMap<String, AtlasAnimation>,
64}
65
66impl Atlas {
67    /// Frame lookup that names the alternatives instead of failing silently.
68    pub fn frame(&self, name: &str) -> Result<AtlasFrame> {
69        self.frames.get(name).copied().ok_or_else(|| {
70            anyhow::anyhow!(
71                "atlas {:?} has no frame {name:?} (available: {})",
72                self.texture,
73                if self.frames.is_empty() {
74                    "none".to_string()
75                } else {
76                    self.names().join(", ")
77                }
78            )
79        })
80    }
81
82    pub fn names(&self) -> Vec<&str> {
83        self.frames.keys().map(String::as_str).collect()
84    }
85
86    /// Animation lookup that names the alternatives instead of failing silently.
87    pub fn animation(&self, name: &str) -> Result<&AtlasAnimation> {
88        self.animations.get(name).ok_or_else(|| {
89            anyhow::anyhow!(
90                "atlas {:?} has no animation {name:?} (available: {})",
91                self.texture,
92                if self.animations.is_empty() {
93                    "none".to_string()
94                } else {
95                    self.animation_names().join(", ")
96                }
97            )
98        })
99    }
100
101    pub fn animation_names(&self) -> Vec<&str> {
102        self.animations.keys().map(String::as_str).collect()
103    }
104}
105
106#[derive(Debug, Deserialize)]
107struct RawAtlas {
108    texture: String,
109    size: [u32; 2],
110    frames: BTreeMap<String, RawFrame>,
111    #[serde(default)]
112    animations: BTreeMap<String, RawAnimation>,
113}
114
115#[derive(Debug, Deserialize)]
116struct RawAnimation {
117    frames: Vec<String>,
118    #[serde(default = "default_fps")]
119    fps: f32,
120    #[serde(default = "default_looping", rename = "loop", alias = "looping")]
121    looping: bool,
122}
123
124fn default_fps() -> f32 {
125    10.0
126}
127
128fn default_looping() -> bool {
129    true
130}
131
132#[derive(Debug, Deserialize)]
133struct RawFrame {
134    x: u32,
135    y: u32,
136    w: u32,
137    h: u32,
138}
139
140impl From<RawFrame> for AtlasFrame {
141    fn from(raw: RawFrame) -> Self {
142        AtlasFrame {
143            x: raw.x,
144            y: raw.y,
145            w: raw.w,
146            h: raw.h,
147        }
148    }
149}
150
151/// Parse atlas JSON. Empty sheets, zero sizes, and frames outside the sheet
152/// are errors — a bad atlas must never reach the GPU as silent garbage.
153pub fn parse_atlas(text: &str) -> Result<Atlas> {
154    let raw: RawAtlas =
155        serde_json::from_str(text).context("atlas is not valid JSON (texture/size/frames)")?;
156    if raw.texture.trim().is_empty() {
157        anyhow::bail!("atlas has an empty `texture`");
158    }
159    if raw.size[0] == 0 || raw.size[1] == 0 {
160        anyhow::bail!("atlas size {:?} must be positive", raw.size);
161    }
162    if raw.frames.is_empty() {
163        anyhow::bail!("atlas {:?} declares no frames", raw.texture);
164    }
165    let sheet = (raw.size[0], raw.size[1]);
166    let mut frames = BTreeMap::new();
167    for (name, frame) in raw.frames {
168        let frame = AtlasFrame::from(frame);
169        if !frame.fits(sheet) {
170            anyhow::bail!(
171                "atlas frame {name:?} ({},{} {}x{}) does not fit the {sheet:?} sheet",
172                frame.x,
173                frame.y,
174                frame.w,
175                frame.h
176            );
177        }
178        frames.insert(name, frame);
179    }
180    let mut animations = BTreeMap::new();
181    let available_frames = if frames.is_empty() {
182        "none".to_string()
183    } else {
184        frames
185            .keys()
186            .map(String::as_str)
187            .collect::<Vec<_>>()
188            .join(", ")
189    };
190    for (name, anim) in raw.animations {
191        if anim.frames.is_empty() {
192            anyhow::bail!("atlas animation {name:?} declares no frames");
193        }
194        if anim.fps <= 0.0 || !anim.fps.is_finite() {
195            anyhow::bail!(
196                "atlas animation {name:?} fps ({}) must be positive",
197                anim.fps
198            );
199        }
200        for frame_name in &anim.frames {
201            if !frames.contains_key(frame_name) {
202                anyhow::bail!(
203                    "atlas animation {name:?} references nonexistent frame {frame_name:?} (available: {available_frames})"
204                );
205            }
206        }
207        animations.insert(
208            name,
209            AtlasAnimation {
210                frames: anim.frames,
211                fps: anim.fps,
212                looping: anim.looping,
213            },
214        );
215    }
216    Ok(Atlas {
217        texture: raw.texture,
218        size: sheet,
219        frames,
220        animations,
221    })
222}
223
224pub fn load_atlas(path: &Path) -> Result<Atlas> {
225    let text = std::fs::read_to_string(path)
226        .with_context(|| format!("cannot read atlas {}", path.display()))?;
227    parse_atlas(&text).with_context(|| format!("cannot parse atlas {}", path.display()))
228}
229
230/// Sidecar path for a sheet: `sheet.png` -> `sheet.atlas.json` beside it.
231pub fn sidecar_for(sheet: &Path) -> PathBuf {
232    let stem = sheet
233        .file_stem()
234        .and_then(|s| s.to_str())
235        .filter(|s| !s.is_empty())
236        .unwrap_or("sheet");
237    sheet
238        .parent()
239        .unwrap_or_else(|| Path::new(""))
240        .join(format!("{stem}.{SIDECAR_EXT}"))
241}
242
243/// The sheet a sidecar describes: `sheet.atlas.json` -> `sheet.png`.
244pub fn sheet_for_sidecar(sidecar: &Path) -> PathBuf {
245    let name = sidecar
246        .file_name()
247        .and_then(|s| s.to_str())
248        .unwrap_or_default();
249    let stem = name
250        .strip_suffix(&format!(".{SIDECAR_EXT}"))
251        .unwrap_or(name);
252    sidecar
253        .parent()
254        .unwrap_or_else(|| Path::new(""))
255        .join(format!("{stem}.png"))
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    const SHEET: &str = r#"{
263        "texture": "sheet.png",
264        "size": [64, 32],
265        "frames": {
266            "player": { "x": 0, "y": 0, "w": 32, "h": 32 },
267            "enemy": { "x": 32, "y": 0, "w": 32, "h": 32 },
268            "coin": { "x": 0, "y": 16, "w": 16, "h": 16 }
269        }
270    }"#;
271
272    #[test]
273    fn parses_named_frames() {
274        let atlas = parse_atlas(SHEET).unwrap();
275        assert_eq!(atlas.texture, "sheet.png");
276        assert_eq!(atlas.size, (64, 32));
277        assert_eq!(atlas.frames.len(), 3);
278        assert_eq!(
279            atlas.frame("player").unwrap(),
280            AtlasFrame {
281                x: 0,
282                y: 0,
283                w: 32,
284                h: 32
285            }
286        );
287        assert_eq!(
288            atlas.frame("enemy").unwrap(),
289            AtlasFrame {
290                x: 32,
291                y: 0,
292                w: 32,
293                h: 32
294            }
295        );
296        assert_eq!(atlas.names(), vec!["coin", "enemy", "player"]);
297    }
298
299    #[test]
300    fn missing_frame_names_the_alternatives() {
301        let atlas = parse_atlas(SHEET).unwrap();
302        let err = atlas.frame("ghost").unwrap_err().to_string();
303        assert!(err.contains("no frame \"ghost\""), "got: {err}");
304        assert!(err.contains("player"), "got: {err}");
305    }
306
307    #[test]
308    fn uv_math_maps_pixels_to_normalized_rects() {
309        let atlas = parse_atlas(SHEET).unwrap();
310        let player = atlas.frame("player").unwrap().uv(atlas.size);
311        assert_eq!(player, [0.0, 0.0, 0.5, 1.0]);
312        let enemy = atlas.frame("enemy").unwrap().uv(atlas.size);
313        assert_eq!(enemy, [0.5, 0.0, 1.0, 1.0]);
314        let coin = atlas.frame("coin").unwrap().uv(atlas.size);
315        assert_eq!(coin, [0.0, 0.5, 0.25, 1.0]);
316    }
317
318    #[test]
319    fn bad_atlases_fail_loudly() {
320        assert!(parse_atlas("not json").is_err());
321        assert!(parse_atlas(r#"{"texture":"","size":[1,1],"frames":{}}"#).is_err());
322        assert!(parse_atlas(r#"{"texture":"a.png","size":[0,8],"frames":{}}"#).is_err());
323        assert!(parse_atlas(r#"{"texture":"a.png","size":[8,8],"frames":{}}"#).is_err());
324        let out_of_bounds = r#"{"texture":"a.png","size":[8,8],
325            "frames":{"big":{"x":4,"y":4,"w":8,"h":8}}}"#;
326        let err = parse_atlas(out_of_bounds).unwrap_err().to_string();
327        assert!(err.contains("does not fit"), "got: {err}");
328    }
329
330    #[test]
331    fn sidecar_and_sheet_paths_round_trip() {
332        let sheet = Path::new("/game/assets/sheet.png");
333        let sidecar = sidecar_for(sheet);
334        assert_eq!(sidecar, PathBuf::from("/game/assets/sheet.atlas.json"));
335        assert_eq!(sheet_for_sidecar(&sidecar), sheet);
336    }
337
338    #[test]
339    fn parses_animations_and_validates_frames() {
340        let json = r#"{
341            "texture": "sheet.png",
342            "size": [64, 32],
343            "frames": {
344                "f1": { "x": 0, "y": 0, "w": 32, "h": 32 },
345                "f2": { "x": 32, "y": 0, "w": 32, "h": 32 }
346            },
347            "animations": {
348                "walk": {
349                    "frames": ["f1", "f2"],
350                    "fps": 12.0,
351                    "loop": true
352                },
353                "jump": {
354                    "frames": ["f1"],
355                    "fps": 8.0,
356                    "loop": false
357                }
358            }
359        }"#;
360        let atlas = parse_atlas(json).unwrap();
361        assert_eq!(atlas.animation_names(), vec!["jump", "walk"]);
362        let walk = atlas.animation("walk").unwrap();
363        assert_eq!(walk.frames, vec!["f1", "f2"]);
364        assert_eq!(walk.fps, 12.0);
365        assert!(walk.looping);
366
367        let jump = atlas.animation("jump").unwrap();
368        assert_eq!(jump.frames, vec!["f1"]);
369        assert_eq!(jump.fps, 8.0);
370        assert!(!jump.looping);
371
372        let err = atlas.animation("idle").unwrap_err().to_string();
373        assert!(err.contains("no animation \"idle\""), "got: {err}");
374        assert!(err.contains("walk"), "got: {err}");
375    }
376
377    #[test]
378    fn invalid_animations_fail_loudly() {
379        let missing_frame = r#"{
380            "texture": "sheet.png",
381            "size": [64, 32],
382            "frames": { "f1": { "x": 0, "y": 0, "w": 32, "h": 32 } },
383            "animations": { "walk": { "frames": ["ghost"], "fps": 10.0 } }
384        }"#;
385        let err = parse_atlas(missing_frame).unwrap_err().to_string();
386        assert!(err.contains("nonexistent frame \"ghost\""), "got: {err}");
387        assert!(err.contains("f1"), "got: {err}");
388
389        let empty_frames = r#"{
390            "texture": "sheet.png",
391            "size": [64, 32],
392            "frames": { "f1": { "x": 0, "y": 0, "w": 32, "h": 32 } },
393            "animations": { "walk": { "frames": [], "fps": 10.0 } }
394        }"#;
395        let err = parse_atlas(empty_frames).unwrap_err().to_string();
396        assert!(err.contains("declares no frames"), "got: {err}");
397
398        let zero_fps = r#"{
399            "texture": "sheet.png",
400            "size": [64, 32],
401            "frames": { "f1": { "x": 0, "y": 0, "w": 32, "h": 32 } },
402            "animations": { "walk": { "frames": ["f1"], "fps": 0.0 } }
403        }"#;
404        let err = parse_atlas(zero_fps).unwrap_err().to_string();
405        assert!(err.contains("must be positive"), "got: {err}");
406    }
407}