Skip to main content

roxlap_core/
engine.rs

1//! The [`Engine`] is the public façade of `roxlap-core`. R3 ships
2//! the API surface with a sky-fill stub; R4 wires in the real
3//! opticast + grouscan rasterizer behind the same call signatures.
4
5use crate::sky::Sky;
6use crate::Camera;
7
8/// Voxlap's `vx5.kv6col` default — mid-grey, equal R/G/B so the
9/// sprite `update_reflects` nolighta optimisation kicks in.
10pub const DEFAULT_KV6COL: u32 = 0x0080_8080;
11
12/// One point light source for sprite (and, eventually, world) lighting.
13/// Mirror of voxlap's `lightsrc_t` (`voxlap5.h`): position, squared
14/// reach radius, and intensity scale. The lighting math reads `r2`
15/// not `r`, matching voxlap's `vx5.lightsrc[i].r2`-keyed range
16/// check.
17#[derive(Debug, Clone, Copy)]
18pub struct LightSrc {
19    /// World-space position.
20    pub pos: [f32; 3],
21    /// Squared influence radius. Voxels / sprites further than
22    /// `sqrt(r2)` from `pos` get no contribution.
23    pub r2: f32,
24    /// Intensity scale — voxlap's `lightsrc_t::sc`. Larger = brighter.
25    pub sc: f32,
26}
27
28/// Voxel engine state.
29#[derive(Debug, Clone)]
30pub struct Engine {
31    camera: Camera,
32    sky_color: u32,
33    fog_color: u32,
34    /// Maximum distance the fog blend interpolates over (PREC-
35    /// scaled cells; voxlap's `vx5.maxscandist`). `0` disables fog.
36    fog_max_scan_dist: i32,
37    /// Per-side darkening intensities — voxlap's
38    /// `setsideshades(top, bot, left, right, up, down)`. Default is
39    /// `[0; 6]` (no shading), matching the oracle. `ScanScratch`
40    /// rebuilds its `gcsub` table from these per frame.
41    side_shades: [i8; 6],
42    /// Sprite material colour — voxlap's `vx5.kv6col`. Default
43    /// `0x80_8080` (mid grey, R==G==B → triggers `update_reflects`'s
44    /// nolighta fast path).
45    kv6col: u32,
46    /// Sprite lighting mode — voxlap's `vx5.lightmode`. 0 / 1 →
47    /// directional surface tint (the cheap nolighta / nolightb
48    /// path); 2 → per-light point-source modulation against
49    /// [`Engine::lights`].
50    lightmode: u32,
51    /// Active point lights. Voxlap's `vx5.lightsrc[]`/`vx5.numlights`.
52    /// Read by sprite `update_reflects` (and, when world voxel
53    /// lighting lands, by `updatelighting`).
54    lights: Vec<LightSrc>,
55    /// Sky texture for the textured-`startsky` path. `None` ⇒
56    /// `phase_startsky` solid-fills with `skycast` (cheap default;
57    /// every oracle pose stays here so its hashes are byte-stable
58    /// independent of any sky a host loads). `Some(sky)` ⇒ the
59    /// rasterizer walks `sky.lng` per ray + samples
60    /// `sky.pixels` per pixel, à la voxlap's `loadsky` path.
61    sky: Option<Sky>,
62}
63
64impl Default for Engine {
65    fn default() -> Self {
66        Self {
67            camera: Camera::default(),
68            // Voxlap-style packed sky blue: brightness bit | 0x87ceeb.
69            sky_color: 0x8087_ceeb,
70            fog_color: 0,
71            fog_max_scan_dist: 0,
72            side_shades: [0; 6],
73            kv6col: DEFAULT_KV6COL,
74            lightmode: 0,
75            lights: Vec::new(),
76            sky: None,
77        }
78    }
79}
80
81impl Engine {
82    /// Construct a new [`Engine`] with default state — voxlap-blue
83    /// sky, no fog, no per-side shading, default kv6 colour, no
84    /// lights, no sky texture.
85    ///
86    /// # Examples
87    ///
88    /// ```
89    /// use roxlap_core::Engine;
90    ///
91    /// let mut engine = Engine::new();
92    /// engine.set_sky_color(0x80aa_ddff);
93    /// assert_eq!(engine.sky_color(), 0x80aa_ddff);
94    /// ```
95    #[must_use]
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    pub fn set_camera(&mut self, camera: Camera) {
101        self.camera = camera;
102    }
103
104    #[must_use]
105    pub fn camera(&self) -> Camera {
106        self.camera
107    }
108
109    /// Override the sky / background colour. Bytes are `0xAARRGGBB`
110    /// where `AA` is the voxlap-style "brightness" channel (`0x80` is
111    /// "normal" intensity, matching the engine's other surface
112    /// colours).
113    pub fn set_sky_color(&mut self, color: u32) {
114        self.sky_color = color;
115    }
116
117    #[must_use]
118    pub fn sky_color(&self) -> u32 {
119        self.sky_color
120    }
121
122    /// Configure fog. `max_scan_dist <= 0` disables fog. Otherwise
123    /// pixels at the maximum scan distance blend fully to
124    /// `fog_color` (low 24 bits — alpha byte ignored). Voxlap's
125    /// `vx5.maxscandist`-based foglut is rebuilt downstream by
126    /// `ScanScratch::set_fog`.
127    pub fn set_fog(&mut self, color: u32, max_scan_dist: i32) {
128        self.fog_color = color;
129        self.fog_max_scan_dist = max_scan_dist.max(0);
130    }
131
132    #[must_use]
133    pub fn fog_color(&self) -> u32 {
134        self.fog_color
135    }
136
137    #[must_use]
138    pub fn fog_max_scan_dist(&self) -> i32 {
139        self.fog_max_scan_dist
140    }
141
142    /// Voxlap's `setsideshades(top, bot, left, right, up, down)`
143    /// — per-side voxel darkening intensities. Each `i8` is stamped
144    /// onto the high byte of `gcsub[2..7]` (downstream by
145    /// `ScanScratch::set_side_shades`). Pass `(0,…,0)` to disable
146    /// (the oracle baseline); positive values like 15 / 31 give the
147    /// directional darkening typical of voxlap's classic games.
148    pub fn set_side_shades(&mut self, top: i8, bot: i8, left: i8, right: i8, up: i8, down: i8) {
149        self.side_shades = [top, bot, left, right, up, down];
150    }
151
152    #[must_use]
153    pub fn side_shades(&self) -> [i8; 6] {
154        self.side_shades
155    }
156
157    /// Sprite material colour — packed BGRA bytes, voxlap's
158    /// `vx5.kv6col`. R/G/B equal triggers `update_reflects`'s
159    /// nolighta fast path.
160    pub fn set_kv6col(&mut self, color: u32) {
161        self.kv6col = color;
162    }
163
164    #[must_use]
165    pub fn kv6col(&self) -> u32 {
166        self.kv6col
167    }
168
169    /// Sprite lighting mode — voxlap's `vx5.lightmode`. 0 / 1 →
170    /// directional tint; 2 → point-light shading from
171    /// [`Engine::lights`]. Other values clamp to 2 in voxlap.
172    pub fn set_lightmode(&mut self, mode: u32) {
173        self.lightmode = mode;
174    }
175
176    #[must_use]
177    pub fn lightmode(&self) -> u32 {
178        self.lightmode
179    }
180
181    /// Append a light source. No upper bound enforced here —
182    /// voxlap's `MAXLIGHTS` (16) is the practical limit, but the
183    /// rendering math just iterates whatever's in the slice.
184    pub fn add_light(&mut self, light: LightSrc) {
185        self.lights.push(light);
186    }
187
188    pub fn clear_lights(&mut self) {
189        self.lights.clear();
190    }
191
192    #[must_use]
193    pub fn lights(&self) -> &[LightSrc] {
194        &self.lights
195    }
196
197    /// Set the sky texture used by the textured-`startsky` path.
198    /// `None` reverts to the cheap solid-fill default.
199    pub fn set_sky(&mut self, sky: Option<Sky>) {
200        self.sky = sky;
201    }
202
203    #[must_use]
204    pub fn sky(&self) -> Option<&Sky> {
205        self.sky.as_ref()
206    }
207
208    /// Render one frame into the caller-owned ARGB framebuffer.
209    ///
210    /// `pixels` is a row-major u32 buffer; `pitch_pixels` is the row
211    /// stride in u32 elements (which equals `width` for a tightly-
212    /// packed buffer, but may be larger when the host is e.g. an SDL2
213    /// streaming texture with per-row padding).
214    ///
215    /// R3 is a stub that fills the visible region with [`sky_color`].
216    /// R4 replaces this with the real raycaster.
217    ///
218    /// # Panics
219    ///
220    /// Panics if `pixels.len() < (height as usize) * (pitch_pixels as
221    /// usize)` or if `width > pitch_pixels` — i.e. when the buffer
222    /// would not contain `height` rows of `pitch_pixels` u32 each, or
223    /// when the visible width would overflow each row.
224    ///
225    /// [`sky_color`]: Self::sky_color
226    pub fn render(&mut self, pixels: &mut [u32], width: u32, height: u32, pitch_pixels: u32) {
227        assert!(
228            width <= pitch_pixels,
229            "render: width {width} > pitch_pixels {pitch_pixels}"
230        );
231        let w = width as usize;
232        let h = height as usize;
233        let stride = pitch_pixels as usize;
234        assert!(
235            pixels.len() >= h * stride,
236            "render: buffer too small ({} pixels) for {h} × {stride}",
237            pixels.len(),
238        );
239        for y in 0..h {
240            let row_start = y * stride;
241            pixels[row_start..row_start + w].fill(self.sky_color);
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn render_fills_with_sky_color() {
252        let mut e = Engine::new();
253        e.set_sky_color(0xdead_beef);
254        let mut buf = vec![0u32; 64 * 32];
255        e.render(&mut buf, 64, 32, 64);
256        assert!(buf.iter().all(|&p| p == 0xdead_beef));
257    }
258
259    #[test]
260    fn render_respects_pitch() {
261        // Buffer wider than the visible rectangle — the trailing slack
262        // per row must be left untouched.
263        let mut e = Engine::new();
264        e.set_sky_color(0x1234_5678);
265        let stride: u32 = 80;
266        let width: u32 = 64;
267        let height: u32 = 32;
268        let mut buf = vec![0u32; (stride as usize) * (height as usize)];
269        e.render(&mut buf, width, height, stride);
270        for y in 0..height as usize {
271            let row = &buf[y * stride as usize..(y + 1) * stride as usize];
272            assert!(row[..width as usize].iter().all(|&p| p == 0x1234_5678));
273            assert!(row[width as usize..].iter().all(|&p| p == 0));
274        }
275    }
276
277    #[test]
278    fn fog_defaults_disabled() {
279        let e = Engine::new();
280        assert_eq!(e.fog_color(), 0);
281        assert_eq!(e.fog_max_scan_dist(), 0);
282    }
283
284    #[test]
285    fn set_fog_stores_color_and_distance() {
286        let mut e = Engine::new();
287        e.set_fog(0xFF_AA_BB_CC, 1024);
288        assert_eq!(e.fog_color(), 0xFF_AA_BB_CC);
289        assert_eq!(e.fog_max_scan_dist(), 1024);
290    }
291
292    #[test]
293    fn set_fog_clamps_negative_distance_to_zero() {
294        let mut e = Engine::new();
295        e.set_fog(0xFF, -100);
296        assert_eq!(e.fog_max_scan_dist(), 0);
297    }
298
299    #[test]
300    fn camera_default_matches_oracle_placeholders() {
301        // The Camera::default values must match what voxlaptest's
302        // oracle.c writes into the .vxl header so a default-built
303        // Engine + a freshly-loaded oracle.vxl agree on the starting
304        // pose.
305        let cam = Engine::new().camera();
306        let bits = |a: [f64; 3]| a.map(f64::to_bits);
307        assert_eq!(bits(cam.pos), bits([1024.0, 1024.0, 128.0]));
308        assert_eq!(bits(cam.right), bits([1.0, 0.0, 0.0]));
309        assert_eq!(bits(cam.down), bits([0.0, 0.0, 1.0]));
310        assert_eq!(bits(cam.forward), bits([0.0, 1.0, 0.0]));
311    }
312}