Skip to main content

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}