roxlap_core/opticast.rs
1//! Per-frame orchestrator — wires the R4.1 builders into a single
2//! `opticast` entry point.
3//!
4//! Port of the top-of-`opticast` execution order in
5//! `voxlap5.c:opticast` (lines 2284..end-of-function), minus the
6//! globals voxlap mutates inline:
7//!
8//! 1. `camera_math::derive` → per-frame f32 basis.
9//! 2. `opticast_prelude::derive_prelude` → integer / fixed-point cache.
10//! 3. `column_walk::camera_column_air_gap` → early-out if the camera
11//! is inside solid voxel material.
12//! 4. `projection::derive_projection` → cx / cy / corner-cut quad.
13//! 5. `ray_step::derive_ray_step` → per-pixel ray-step coefficients.
14//! 6. Four-quadrant scan dispatch (top, right, bottom, left).
15//!
16//! [`OpticastSettings`] bundles the constants the four-quadrant scan
17//! loops need (xres / yres / projection params / mip + scan-dist
18//! controls) so the orchestrator's signature stays compact.
19
20use rayon::prelude::*;
21
22use crate::camera_math;
23use crate::camera_math::CameraState;
24use crate::column_walk;
25use crate::grid_view::GridView;
26use crate::opticast_prelude;
27use crate::opticast_prelude::OpticastPrelude;
28use crate::projection;
29use crate::rasterizer::{Rasterizer, ScratchPool};
30use crate::ray_step;
31use crate::scan_loops::{
32 bottom_quadrant, left_quadrant, right_quadrant, top_quadrant, ScanContext,
33};
34use crate::Camera;
35
36/// Per-frame settings the orchestrator forwards to the builders. Most
37/// fields map 1:1 onto a voxlap global (`vx5.anginc`, `vx5.mipscandist`,
38/// `vx5.maxscandist`) or a `setcamera` argument (`dahx` / `dahy` /
39/// `dahz`). `mip_levels` is voxlap's `gmipnum` — `1` for the oracle
40/// scene.
41///
42/// `y_start..y_end` is the strip-render iteration bound (R12.3).
43/// Default is the full framebuffer (`0..yres`), giving pre-R12.3
44/// full-frame opticast behaviour bit-exactly. Tile / strip callers
45/// set a sub-range to render only that horizontal strip — pass-1
46/// gline ray casts and pass-2 hrend / vrend writes both stay
47/// inside the strip's y-range. The camera projection center stays
48/// in absolute screen coords; only the viewport edges shrink.
49#[derive(Debug, Clone, Copy)]
50pub struct OpticastSettings {
51 pub xres: u32,
52 pub yres: u32,
53 /// First y-row this opticast call renders (inclusive). `0` for
54 /// full-frame.
55 pub y_start: u32,
56 /// One past the last y-row (exclusive). `yres` for full-frame.
57 pub y_end: u32,
58 pub hx: f32,
59 pub hy: f32,
60 pub hz: f32,
61 pub anginc: i32,
62 pub mip_levels: u32,
63 pub mip_scan_dist: i32,
64 pub max_scan_dist: i32,
65}
66
67impl OpticastSettings {
68 /// Default settings for a `width × height` framebuffer with the
69 /// voxlap-oracle convention `(hx, hy, hz) = (w/2, h/2, w/2)` and
70 /// `anginc = 1`, matching `tests/oracle/oracle.c`. Renders the
71 /// full frame (`y_start = 0, y_end = height`).
72 //
73 // `width` / `height` cast to f32 is bounded by realistic screen
74 // sizes (≤ 16M, well within f32's 24-bit mantissa).
75 #[allow(clippy::cast_precision_loss)]
76 #[must_use]
77 pub fn for_oracle_framebuffer(width: u32, height: u32) -> Self {
78 let half_w = (width as f32) * 0.5;
79 let half_h = (height as f32) * 0.5;
80 Self {
81 xres: width,
82 yres: height,
83 y_start: 0,
84 y_end: height,
85 hx: half_w,
86 hy: half_h,
87 hz: half_w,
88 anginc: 1,
89 mip_levels: 1,
90 mip_scan_dist: 4,
91 max_scan_dist: 1024,
92 }
93 }
94
95 /// Restrict this settings struct to the `[y_start, y_end)`
96 /// horizontal strip. Used by the per-strip parallel dispatch
97 /// (R12.3.1) — each strip clones the base settings and clamps
98 /// the y-range. Caller is responsible for ensuring `y_start <
99 /// y_end <= yres`.
100 #[must_use]
101 pub fn with_y_range(mut self, y_start: u32, y_end: u32) -> Self {
102 self.y_start = y_start;
103 self.y_end = y_end;
104 self
105 }
106}
107
108/// Outcome of one [`opticast`] call.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum OpticastOutcome {
111 /// All four quadrants dispatched (some or all may have early-
112 /// outed on their own geometry guards — that is normal).
113 /// Outside-XY cameras (S1.Z negative-index walk) also report
114 /// `Rendered` since they go through the same scan path with
115 /// signed `(cx, cy)` carrying the OOB signal into grouscan.
116 Rendered,
117 /// Camera position lies in solid voxel material. Voxlap returns
118 /// from `opticast` early in this case (no render, screen retains
119 /// previous contents — the host can pre-fill with sky).
120 SkippedCameraInSolid,
121}
122
123/// Drive one frame of opticast. The caller supplies:
124/// - `camera`: pose to render from.
125/// - `settings`: framebuffer + projection + scan-dist constants.
126/// - `grid`: world voxel data wrapped in a [`GridView`] (vsid +
127/// slab buffer + column offsets + mip-base offsets). Today
128/// always represents a single chunk; the multi-chunk variant
129/// lands in S4B.1+ and reuses this same opticast signature.
130///
131/// Whatever real or stub [`Rasterizer`] is plugged in receives the
132/// `gline` / `hrend` / `vrend` calls the four-quadrant scan loops
133/// produce; the [`ScratchPool`]'s slots accumulate the radar /
134/// angstart / lastx / uurend buffers between those calls.
135///
136/// Threading dial lives on the pool:
137/// - `pool.n_threads() == 1` → sequential. The four quadrants run
138/// on the calling thread against `pool.slot_mut(0)`. Pre-R12
139/// shape; the byte-stable golden baseline.
140/// - `pool.n_threads() >= 2` → R12.3.1 per-strip parallel. The
141/// framebuffer's y-range splits into N horizontal strips of
142/// `~yres/N` rows each. Each strip runs its own opticast pass
143/// (4 quadrants) against its own slot from
144/// `pool.slots_mut_slice()`, with [`OpticastSettings::y_start`] /
145/// `y_end` clipped to the strip. Strips run via
146/// `rayon::par_iter_mut`, each with a cloned rasterizer (raw
147/// fb / zb pointers shared, strip-disjoint row writes).
148///
149/// **Byte-stability caveat** (R12.3.1): per-strip rendering produces
150/// different pixel hashes than single-strip. Voxlap's screen-line
151/// interpolation in `gline` parameterises rays by viewport-y bounds
152/// (via the corner-cut quad's grd / dxy); strips have narrower
153/// y-bounds, so the per-strip ray fan discretises slightly
154/// differently. The image is geometrically valid — each pixel still
155/// samples a camera-correct ray — but the 1/N strip discretisation
156/// drifts by a fraction of a voxel from the full-frame
157/// discretisation. For CI, oracle goldens are frozen at
158/// `--threads 1` (single strip = full frame, byte-stable).
159///
160/// `R: Clone + Send + Sync` is required by the parallel branch even
161/// when it doesn't fire — keeping the bound consistent across both
162/// paths means the generic body monomorphizes once. The `Sync` bound
163/// shows up because `rayon::par_iter_mut`'s closure shares `&R` (the
164/// strip-cloning template) across worker threads. Test rasterizers
165/// (`Counts`, `RecordingRasterizer`) derive Clone + auto-Send/Sync
166/// so they satisfy the bound at no runtime cost.
167//
168// Sign convention: voxlap's opticast forwards everything as-is from
169// the static state; here it's all explicit parameters. The clippy
170// arg-count lint is allowed because each parameter pulls its weight
171// (a struct-of-args variant just renames the same data). The
172// xres / yres → i32 casts are bounded by realistic framebuffer
173// dimensions and won't wrap.
174#[allow(
175 clippy::too_many_arguments,
176 clippy::cast_possible_wrap,
177 clippy::similar_names
178)]
179#[must_use]
180pub fn opticast<R: Rasterizer + Clone + Send + Sync>(
181 rasterizer: &mut R,
182 pool: &mut ScratchPool,
183 camera: &Camera,
184 settings: &OpticastSettings,
185 grid: GridView<'_>,
186) -> OpticastOutcome {
187 let cs = camera_math::derive(
188 camera,
189 settings.xres,
190 settings.yres,
191 settings.hx,
192 settings.hy,
193 settings.hz,
194 );
195
196 // S4B.6.d: size gylookup for the grid's full chunk-Z extent.
197 // Non-stacked grids (`chunk_grid: None` or `chunks_z == 1`)
198 // pass `1` and get the pre-S4B.6 (512+4)-per-mip table.
199 let chunks_z = grid.chunk_grid.map_or(1, |cg| cg.chunks_z);
200 let mut prelude = opticast_prelude::derive_prelude(
201 &cs,
202 grid.vsid,
203 settings.mip_levels,
204 settings.mip_scan_dist,
205 settings.max_scan_dist,
206 chunks_z,
207 );
208 // S4B.2.c.2: refine the prelude's chunk-aware fields against
209 // the grid's per-chunk dimension. For single-chunk callers
210 // (`chunk_size_xy == vsid`) this is a no-op for the in-bounds
211 // camera — `li_pos.div_euclid(vsid) == 0` and `rem_euclid ==
212 // li_pos` — keeping the goldens byte-identical. For multi-
213 // chunk callers it splits `li_pos.xy` into
214 // `(camera_chunk_idx.xy, camera_local_xyz.xy)`.
215 opticast_prelude::recompute_camera_chunk(&mut prelude, grid.chunk_size_xy, grid.chunk_size_z);
216 // S4B.2.d: re-evaluate `in_bounds_xy` against the grid's full
217 // XY voxel AABB. For single-chunk callers `aabb_xy()` returns
218 // `([0, 0], [vsid, vsid])` so the recomputed `in_bounds_xy`
219 // matches what `derive_prelude` populated. For multi-chunk
220 // callers, the camera is treated as in-bounds when it lies
221 // anywhere inside the grid's chunk-XY footprint.
222 let (aabb_min, aabb_max) = grid.aabb_xy();
223 opticast_prelude::recompute_in_bounds_xy(&mut prelude, aabb_min, aabb_max);
224
225 // S4B.1: `column_walk::camera_chunk_air_gap` now owns both
226 // branches (in-bounds column lookup + OOB-XY bedrock seed
227 // synthesis). Single-chunk grids run the existing column walk
228 // unchanged; S4B.2 grows the wrapper into a real chunk-grid
229 // lookup without touching this call site.
230 //
231 // OOB rationale (kept here for the read-the-call-site reader):
232 // when the camera sits past `[0, vsid)²` in X or Y, the
233 // synthesised air gap from any representative in-bounds column
234 // would create a fake floor at that column's surface_z — the
235 // renderer paints it as a chunk-edge streak when rays graze
236 // the silhouette. The wrapper returns the bedrock placeholder
237 // `(0, 255, 0)` instead; with `treat_z_max_as_air = true` the
238 // renderer treats it as sky-passable so no false-floor pixels
239 // appear (see `project_oob_xy_chunk_edge_streaking.md`).
240 //
241 // The column-walk DDA still traverses OOB columns as empty
242 // until `(cx, cy)` cross into the world — handled per-step in
243 // grouscan.rs's `phase_after_delete_kept_presync`.
244 let treat_z_max_as_air = pool.slot(0).treat_z_max_as_air;
245 // S4B.6.e: 4th field `seed_chz` is the chunk-z that owns the
246 // returned `vptr_offset`. For the in-camera-chunk case (=
247 // single-chunk or stacked-but-camera-has-real-floor) it equals
248 // `prelude.camera_chunk_idx[2]`. For the cross-chunk look-down
249 // case (= chz=N all-air-bedrock with chz=N+1 below) it points
250 // to the chunk holding the real floor. gline_seed reads it to
251 // route state.column / slab_buf to the right chunk.
252 let (gstartz0, gstartz1, camera_vptr_offset, seed_chz) =
253 match column_walk::camera_chunk_air_gap(grid, &prelude, treat_z_max_as_air) {
254 Some(tuple) => tuple,
255 None => return OpticastOutcome::SkippedCameraInSolid,
256 };
257
258 // Per-frame setup hook needs a `ScanContext` with cy / camera
259 // state populated; build a "setup-only" projection over the
260 // FULL frame y-range so frame_setup sees the same projection
261 // center the strips inherit.
262 let setup_proj = projection::derive_projection_with_y_range(
263 &cs,
264 settings.xres,
265 settings.yres,
266 settings.y_start,
267 settings.y_end,
268 settings.hx,
269 settings.hy,
270 settings.hz,
271 settings.anginc,
272 );
273 let setup_rs = ray_step::derive_ray_step(&cs, setup_proj.cx, setup_proj.cy, settings.hz);
274 let setup_ctx = ScanContext {
275 proj: &setup_proj,
276 rs: &setup_rs,
277 prelude: &prelude,
278 xres: settings.xres as i32,
279 y_start: settings.y_start as i32,
280 y_end: settings.y_end as i32,
281 anginc: settings.anginc,
282 camera_state: &cs,
283 camera_gstartz0: gstartz0,
284 camera_gstartz1: gstartz1,
285 camera_vptr_offset,
286 camera_seed_chunk_z: seed_chz,
287 };
288
289 // Per-frame setup hook — concrete rasterizers (R4.2) cache the
290 // bits of CameraState / RayStep / OpticastPrelude they need for
291 // the per-pixel math. Runs on the calling thread before any
292 // parallel fan-out so subsequent clones inherit the populated
293 // FrameCache. Stub rasterizers ignore via the trait's default
294 // no-op.
295 rasterizer.frame_setup(&setup_ctx);
296
297 let n_strips = pool.n_threads();
298 if n_strips <= 1 {
299 // Sequential — slot 0, full settings. Byte-stable golden
300 // baseline.
301 let scratch = pool.slot_mut(0);
302 top_quadrant(rasterizer, scratch, &setup_ctx);
303 right_quadrant(rasterizer, scratch, &setup_ctx);
304 bottom_quadrant(rasterizer, scratch, &setup_ctx);
305 left_quadrant(rasterizer, scratch, &setup_ctx);
306 } else {
307 // Per-strip parallel (R12.3.1). Slice the y-range into N
308 // strips of `~strip_height` rows each. Each strip runs its
309 // own opticast against its own slot. See
310 // `run_strip_parallel` for the per-strip body.
311 run_strip_parallel(
312 rasterizer,
313 pool,
314 settings,
315 &cs,
316 &prelude,
317 gstartz0,
318 gstartz1,
319 camera_vptr_offset,
320 seed_chz,
321 );
322 }
323
324 OpticastOutcome::Rendered
325}
326
327/// Per-strip parallel body. Splits `[settings.y_start, settings.y_end)`
328/// into `pool.n_threads()` contiguous row strips and runs one
329/// opticast pass per strip via `rayon::par_iter_mut`. Each strip:
330///
331/// * clones `rasterizer` (raw fb / zb pointers in the
332/// [`crate::scalar_rasterizer::RasterTarget`] are `Copy`; the
333/// strip-disjoint row writes make the aliasing safe);
334/// * gets exclusive `&mut ScanScratch` access to one pool slot via
335/// `par_iter_mut`'s borrow split;
336/// * derives its own [`crate::projection::ProjectionRect`] with
337/// wy0 / wy1 clipped to the strip — `gline` and the four scan
338/// loops then auto-clip ray casts and pixel writes;
339/// * runs the four quadrants over its strip.
340//
341// Per-strip projection re-derivation is fast (a handful of f32
342// ops). prelude + camera_state are shared `&` borrows — Sync, no
343// per-strip allocation.
344#[allow(clippy::too_many_arguments)]
345fn run_strip_parallel<R: Rasterizer + Clone + Send + Sync>(
346 rasterizer: &mut R,
347 pool: &mut ScratchPool,
348 settings: &OpticastSettings,
349 cs: &CameraState,
350 prelude: &OpticastPrelude,
351 gstartz0: i32,
352 gstartz1: i32,
353 camera_vptr_offset: usize,
354 camera_seed_chunk_z: i32,
355) {
356 let n_strips = pool.n_threads();
357 let y_start_total = settings.y_start;
358 let y_end_total = settings.y_end;
359 let span = y_end_total.saturating_sub(y_start_total);
360 if span == 0 {
361 return;
362 }
363
364 // `(span + n - 1) / n` → ceiling-divide so trailing rows aren't
365 // dropped on non-divisible splits. Last strip may be smaller.
366 #[allow(clippy::cast_possible_truncation)]
367 let strip_height: u32 = span.div_ceil(n_strips as u32).max(1);
368
369 // Capture borrowed copies for the parallel closure — closure
370 // needs `move` for the cloned rasterizer + slot, but the
371 // shared `&` borrows below are Send + Sync via auto-impl.
372 let rasterizer_template = &*rasterizer;
373 let cs_ref: &CameraState = cs;
374 let prelude_ref: &OpticastPrelude = prelude;
375 let settings_ref: &OpticastSettings = settings;
376
377 let strip_body = |(i, scratch): (usize, &mut crate::rasterizer::ScanScratch)| {
378 #[allow(clippy::cast_possible_truncation)]
379 let strip_y_start = y_start_total.saturating_add((i as u32).saturating_mul(strip_height));
380 let strip_y_end = strip_y_start.saturating_add(strip_height).min(y_end_total);
381 if strip_y_start >= strip_y_end {
382 // Tail strip past the actual y-range — happens when
383 // n_strips > span (e.g., 16 strips on a 12-row span).
384 return;
385 }
386
387 let strip_proj = projection::derive_projection_with_y_range(
388 cs_ref,
389 settings_ref.xres,
390 settings_ref.yres,
391 strip_y_start,
392 strip_y_end,
393 settings_ref.hx,
394 settings_ref.hy,
395 settings_ref.hz,
396 settings_ref.anginc,
397 );
398 let strip_rs =
399 ray_step::derive_ray_step(cs_ref, strip_proj.cx, strip_proj.cy, settings_ref.hz);
400 #[allow(clippy::cast_possible_wrap)]
401 let strip_ctx = ScanContext {
402 proj: &strip_proj,
403 rs: &strip_rs,
404 prelude: prelude_ref,
405 xres: settings_ref.xres as i32,
406 y_start: strip_y_start as i32,
407 y_end: strip_y_end as i32,
408 anginc: settings_ref.anginc,
409 camera_state: cs_ref,
410 camera_gstartz0: gstartz0,
411 camera_gstartz1: gstartz1,
412 camera_vptr_offset,
413 camera_seed_chunk_z,
414 };
415
416 let mut strip_rasterizer: R = rasterizer_template.clone();
417 top_quadrant(&mut strip_rasterizer, scratch, &strip_ctx);
418 right_quadrant(&mut strip_rasterizer, scratch, &strip_ctx);
419 bottom_quadrant(&mut strip_rasterizer, scratch, &strip_ctx);
420 left_quadrant(&mut strip_rasterizer, scratch, &strip_ctx);
421 };
422
423 pool.slots_mut_slice()
424 .par_iter_mut()
425 .enumerate()
426 .for_each(strip_body);
427}
428
429/// Slice `slab_buf` at column `idx`'s byte range (per the
430/// `column_offsets` table). Returns `None` if the index is out of
431/// range or the offsets are malformed (non-monotonic, past the
432/// buffer end). Treated as camera-in-solid by the caller.
433pub(crate) fn camera_column_slice<'a>(
434 slab_buf: &'a [u8],
435 column_offsets: &[u32],
436 idx: u32,
437) -> Option<&'a [u8]> {
438 let i = idx as usize;
439 if i >= column_offsets.len() {
440 return None;
441 }
442 let start = column_offsets[i] as usize;
443 if start >= slab_buf.len() {
444 return None;
445 }
446 // Slice to end-of-buffer; the slab walker self-terminates on
447 // `nextptr == 0`. Using `column_offsets[i + 1]` as the end was
448 // wrong post-edit (voxalloc scatters columns across vbuf, so
449 // adjacent table indices are no longer adjacent in memory).
450 Some(&slab_buf[start..])
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::rasterizer::ScanScratch;
457
458 /// Recording rasterizer that counts the three callback kinds.
459 /// `Clone` so the rasterizer satisfies opticast's `R: Clone +
460 /// Send` bound (R12.2.1).
461 #[derive(Debug, Default, Clone)]
462 struct Counts {
463 gline: u32,
464 hrend: u32,
465 vrend: u32,
466 }
467
468 impl Rasterizer for Counts {
469 fn gline(&mut self, _: &mut ScanScratch, _: u32, _: f32, _: f32, _: f32, _: f32) {
470 self.gline += 1;
471 }
472 fn hrend(&mut self, _: &mut ScanScratch, _: i32, _: i32, _: i32, _: i32, _: i32, _: i32) {
473 self.hrend += 1;
474 }
475 fn vrend(&mut self, _: &mut ScanScratch, _: i32, _: i32, _: i32, _: i32, _: i32) {
476 self.vrend += 1;
477 }
478 }
479
480 /// Single solid slab at z = 200..254. cz < 200 → air gap (0, 200).
481 /// cz inside [200, 254] → in solid → opticast skips.
482 fn solid_slab_z200_to_254() -> Vec<u8> {
483 // Header [nextptr=0, z1=200, z1c=254, dummy=0]. The walker
484 // doesn't read past the header, so no colour bytes needed.
485 vec![0, 200, 254, 0]
486 }
487
488 fn looking_down_camera() -> Camera {
489 Camera {
490 pos: [1024.0, 1024.0, 128.0],
491 right: [1.0, 0.0, 0.0],
492 down: [0.0, 1.0, 0.0],
493 forward: [0.0, 0.0, 1.0],
494 }
495 }
496
497 /// Build a `(slab_buf, column_offsets)` pair where one column —
498 /// `camera_column_index` — holds `column_data`'s bytes and
499 /// every other column is empty. Lets opticast tests target the
500 /// camera column without allocating per-column slab data for
501 /// the full `vsid²` grid.
502 #[allow(clippy::cast_possible_truncation)]
503 fn synthetic_world_with_camera_column(
504 column_data: &[u8],
505 camera_column_index: u32,
506 vsid: u32,
507 ) -> (Vec<u8>, Vec<u32>) {
508 let vsid_sq = (vsid as usize) * (vsid as usize);
509 let len_u32 = column_data.len() as u32;
510 let cam_idx = camera_column_index as usize;
511 let mut column_offsets = vec![0u32; vsid_sq + 1];
512 for offset in &mut column_offsets[(cam_idx + 1)..] {
513 *offset = len_u32;
514 }
515 (column_data.to_vec(), column_offsets)
516 }
517
518 /// `looking_down_camera` at pos = (1024, 1024) with vsid = 2048
519 /// → `column_index` = 1024 * 2048 + 1024 = `2_099_200`.
520 const LOOKING_DOWN_COL_INDEX: u32 = 1024 * 2048 + 1024;
521
522 #[test]
523 fn opticast_dispatches_all_four_quadrants() {
524 let cam = looking_down_camera();
525 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
526 let mut counts = Counts::default();
527 let mut pool = ScratchPool::new(640, 480, 2048);
528 let (slab_buf, column_offsets) = synthetic_world_with_camera_column(
529 &solid_slab_z200_to_254(),
530 LOOKING_DOWN_COL_INDEX,
531 2048,
532 );
533 let mip_base_offsets = [0usize, column_offsets.len()];
534 let grid = GridView::from_parts(2048, &slab_buf, &column_offsets, &mip_base_offsets);
535
536 let outcome = opticast(&mut counts, &mut pool, &cam, &settings, grid);
537
538 assert_eq!(outcome, OpticastOutcome::Rendered);
539 // Looking-down camera: each quadrant fires. gline counts ≈
540 // 2 × x-fan-width + 2 × y-fan-width; positive total.
541 assert!(counts.gline > 0, "expected ≥ 1 gline call");
542 // Top + bottom quadrants both produce hrend; right + left
543 // produce vrend.
544 assert!(counts.hrend > 0, "expected ≥ 1 hrend call");
545 assert!(counts.vrend > 0, "expected ≥ 1 vrend call");
546 }
547
548 #[test]
549 fn opticast_skips_when_camera_in_solid() {
550 // Place the camera inside the solid slab z = 200..254 by
551 // moving pos.z to 220.
552 let mut cam = looking_down_camera();
553 cam.pos[2] = 220.0;
554 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
555 let mut counts = Counts::default();
556 let mut pool = ScratchPool::new(640, 480, 2048);
557 let (slab_buf, column_offsets) = synthetic_world_with_camera_column(
558 &solid_slab_z200_to_254(),
559 LOOKING_DOWN_COL_INDEX,
560 2048,
561 );
562 let mip_base_offsets = [0usize, column_offsets.len()];
563 let grid = GridView::from_parts(2048, &slab_buf, &column_offsets, &mip_base_offsets);
564
565 let outcome = opticast(&mut counts, &mut pool, &cam, &settings, grid);
566
567 assert_eq!(outcome, OpticastOutcome::SkippedCameraInSolid);
568 assert_eq!(counts.gline, 0);
569 assert_eq!(counts.hrend, 0);
570 assert_eq!(counts.vrend, 0);
571 }
572
573 #[test]
574 fn for_oracle_framebuffer_defaults() {
575 let s = OpticastSettings::for_oracle_framebuffer(640, 480);
576 assert_eq!(s.xres, 640);
577 assert_eq!(s.yres, 480);
578 // hx / hy / hz: voxlap-oracle convention.
579 assert!((s.hx - 320.0).abs() < f32::EPSILON);
580 assert!((s.hy - 240.0).abs() < f32::EPSILON);
581 assert!((s.hz - 320.0).abs() < f32::EPSILON);
582 assert_eq!(s.anginc, 1);
583 assert_eq!(s.mip_levels, 1);
584 assert_eq!(s.max_scan_dist, 1024);
585 }
586}