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}