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