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}