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    /// Set the camera pose (position + right/down/forward basis) the
101    /// next [`render`](Self::render) uses.
102    pub fn set_camera(&mut self, camera: Camera) {
103        self.camera = camera;
104    }
105
106    /// The current camera pose (a copy — mutate via
107    /// [`set_camera`](Self::set_camera)).
108    #[must_use]
109    pub fn camera(&self) -> Camera {
110        self.camera
111    }
112
113    /// Override the sky / background colour. Bytes are `0xAARRGGBB`
114    /// where `AA` is the voxlap-style "brightness" channel (`0x80` is
115    /// "normal" intensity, matching the engine's other surface
116    /// colours).
117    pub fn set_sky_color(&mut self, color: u32) {
118        self.sky_color = color;
119    }
120
121    /// The current sky / background colour, `0xAARRGGBB` with the
122    /// voxlap-style brightness byte (see
123    /// [`set_sky_color`](Self::set_sky_color)).
124    #[must_use]
125    pub fn sky_color(&self) -> u32 {
126        self.sky_color
127    }
128
129    /// Configure fog. `max_scan_dist <= 0` disables fog. Otherwise
130    /// pixels at the maximum scan distance blend fully to
131    /// `fog_color` (low 24 bits — alpha byte ignored). Voxlap's
132    /// `vx5.maxscandist`-based foglut is rebuilt downstream by
133    /// `ScanScratch::set_fog`.
134    pub fn set_fog(&mut self, color: u32, max_scan_dist: i32) {
135        self.fog_color = color;
136        self.fog_max_scan_dist = max_scan_dist.max(0);
137    }
138
139    /// The fog colour set by [`set_fog`](Self::set_fog). Only the low
140    /// 24 RGB bits are blended toward; the alpha byte is ignored.
141    #[must_use]
142    pub fn fog_color(&self) -> u32 {
143        self.fog_color
144    }
145
146    /// Distance (PREC-scaled cells, voxlap's `vx5.maxscandist`) at
147    /// which fog reaches full opacity. `0` ⇒ fog disabled.
148    #[must_use]
149    pub fn fog_max_scan_dist(&self) -> i32 {
150        self.fog_max_scan_dist
151    }
152
153    /// Voxlap's `setsideshades(top, bot, left, right, up, down)`
154    /// — per-side voxel darkening intensities. Each `i8` is stamped
155    /// onto the high byte of `gcsub[2..7]` (downstream by
156    /// `ScanScratch::set_side_shades`). Pass `(0,…,0)` to disable
157    /// (the oracle baseline); positive values like 15 / 31 give the
158    /// directional darkening typical of voxlap's classic games.
159    pub fn set_side_shades(&mut self, top: i8, bot: i8, left: i8, right: i8, up: i8, down: i8) {
160        self.side_shades = [top, bot, left, right, up, down];
161    }
162
163    /// The per-side darkening intensities in
164    /// `[top, bot, left, right, up, down]` order (see
165    /// [`set_side_shades`](Self::set_side_shades)). All-zero ⇒ shading
166    /// off.
167    #[must_use]
168    pub fn side_shades(&self) -> [i8; 6] {
169        self.side_shades
170    }
171
172    /// Sprite material colour — packed BGRA bytes, voxlap's
173    /// `vx5.kv6col`. R/G/B equal triggers `update_reflects`'s
174    /// nolighta fast path.
175    pub fn set_kv6col(&mut self, color: u32) {
176        self.kv6col = color;
177    }
178
179    /// The current sprite material colour — voxlap's `vx5.kv6col`
180    /// (see [`set_kv6col`](Self::set_kv6col)). Defaults to
181    /// [`DEFAULT_KV6COL`].
182    #[must_use]
183    pub fn kv6col(&self) -> u32 {
184        self.kv6col
185    }
186
187    /// Sprite lighting mode — voxlap's `vx5.lightmode`. 0 / 1 →
188    /// directional tint; 2 → point-light shading from
189    /// [`Engine::lights`]. Other values clamp to 2 in voxlap.
190    pub fn set_lightmode(&mut self, mode: u32) {
191        self.lightmode = mode;
192    }
193
194    /// The current sprite lighting mode — voxlap's `vx5.lightmode`
195    /// (see [`set_lightmode`](Self::set_lightmode) for the values).
196    #[must_use]
197    pub fn lightmode(&self) -> u32 {
198        self.lightmode
199    }
200
201    /// Append a light source. No upper bound enforced here —
202    /// voxlap's `MAXLIGHTS` (16) is the practical limit, but the
203    /// rendering math just iterates whatever's in the slice.
204    pub fn add_light(&mut self, light: LightSrc) {
205        self.lights.push(light);
206    }
207
208    /// Remove every light source — voxlap's `vx5.numlights = 0`.
209    /// Typical per-frame pattern: clear, then re-[`add_light`]
210    /// whatever is active this frame.
211    ///
212    /// [`add_light`]: Self::add_light
213    pub fn clear_lights(&mut self) {
214        self.lights.clear();
215    }
216
217    /// The active point lights, in [`add_light`](Self::add_light)
218    /// insertion order (voxlap's `vx5.lightsrc[0..numlights]`).
219    #[must_use]
220    pub fn lights(&self) -> &[LightSrc] {
221        &self.lights
222    }
223
224    /// Set the sky texture used by the textured-`startsky` path.
225    /// `None` reverts to the cheap solid-fill default.
226    pub fn set_sky(&mut self, sky: Option<Sky>) {
227        self.sky = sky;
228    }
229
230    /// The current sky texture, if one is loaded (see
231    /// [`set_sky`](Self::set_sky)). `None` ⇒ the solid-fill sky path.
232    #[must_use]
233    pub fn sky(&self) -> Option<&Sky> {
234        self.sky.as_ref()
235    }
236
237    /// Render one frame into the caller-owned ARGB framebuffer.
238    ///
239    /// `pixels` is a row-major u32 buffer; `pitch_pixels` is the row
240    /// stride in u32 elements (which equals `width` for a tightly-
241    /// packed buffer, but may be larger when the host is e.g. an SDL2
242    /// streaming texture with per-row padding).
243    ///
244    /// R3 is a stub that fills the visible region with [`sky_color`].
245    /// R4 replaces this with the real raycaster.
246    ///
247    /// # Panics
248    ///
249    /// Panics if `pixels.len() < (height as usize) * (pitch_pixels as
250    /// usize)` or if `width > pitch_pixels` — i.e. when the buffer
251    /// would not contain `height` rows of `pitch_pixels` u32 each, or
252    /// when the visible width would overflow each row.
253    ///
254    /// [`sky_color`]: Self::sky_color
255    pub fn render(&mut self, pixels: &mut [u32], width: u32, height: u32, pitch_pixels: u32) {
256        assert!(
257            width <= pitch_pixels,
258            "render: width {width} > pitch_pixels {pitch_pixels}"
259        );
260        let w = width as usize;
261        let h = height as usize;
262        let stride = pitch_pixels as usize;
263        assert!(
264            pixels.len() >= h * stride,
265            "render: buffer too small ({} pixels) for {h} × {stride}",
266            pixels.len(),
267        );
268        for y in 0..h {
269            let row_start = y * stride;
270            pixels[row_start..row_start + w].fill(self.sky_color);
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn render_fills_with_sky_color() {
281        let mut e = Engine::new();
282        e.set_sky_color(0xdead_beef);
283        let mut buf = vec![0u32; 64 * 32];
284        e.render(&mut buf, 64, 32, 64);
285        assert!(buf.iter().all(|&p| p == 0xdead_beef));
286    }
287
288    #[test]
289    fn render_respects_pitch() {
290        // Buffer wider than the visible rectangle — the trailing slack
291        // per row must be left untouched.
292        let mut e = Engine::new();
293        e.set_sky_color(0x1234_5678);
294        let stride: u32 = 80;
295        let width: u32 = 64;
296        let height: u32 = 32;
297        let mut buf = vec![0u32; (stride as usize) * (height as usize)];
298        e.render(&mut buf, width, height, stride);
299        for y in 0..height as usize {
300            let row = &buf[y * stride as usize..(y + 1) * stride as usize];
301            assert!(row[..width as usize].iter().all(|&p| p == 0x1234_5678));
302            assert!(row[width as usize..].iter().all(|&p| p == 0));
303        }
304    }
305
306    #[test]
307    fn fog_defaults_disabled() {
308        let e = Engine::new();
309        assert_eq!(e.fog_color(), 0);
310        assert_eq!(e.fog_max_scan_dist(), 0);
311    }
312
313    #[test]
314    fn set_fog_stores_color_and_distance() {
315        let mut e = Engine::new();
316        e.set_fog(0xFF_AA_BB_CC, 1024);
317        assert_eq!(e.fog_color(), 0xFF_AA_BB_CC);
318        assert_eq!(e.fog_max_scan_dist(), 1024);
319    }
320
321    #[test]
322    fn set_fog_clamps_negative_distance_to_zero() {
323        let mut e = Engine::new();
324        e.set_fog(0xFF, -100);
325        assert_eq!(e.fog_max_scan_dist(), 0);
326    }
327
328    #[test]
329    fn camera_default_matches_oracle_placeholders() {
330        // The Camera::default values must match what voxlaptest's
331        // oracle.c writes into the .vxl header so a default-built
332        // Engine + a freshly-loaded oracle.vxl agree on the starting
333        // pose.
334        let cam = Engine::new().camera();
335        let bits = |a: [f64; 3]| a.map(f64::to_bits);
336        assert_eq!(bits(cam.pos), bits([1024.0, 1024.0, 128.0]));
337        assert_eq!(bits(cam.right), bits([1.0, 0.0, 0.0]));
338        assert_eq!(bits(cam.down), bits([0.0, 0.0, 1.0]));
339        assert_eq!(bits(cam.forward), bits([0.0, 1.0, 0.0]));
340    }
341}