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
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
//! Software occlusion query for z-buffer-based culling on no_std embedded systems.
//!
//! Tests axis-aligned bounding boxes (AABBs) against the current depth buffer before
//! submitting expensive rasterization work — inspired by Doom BSP front-to-back ordering.
//!
//! # Example
//! ```ignore
//! use embedded_3dgfx::occlusion::{OcclusionQuery, OcclusionMode, ScreenAabb};
//!
//! let aabb = ScreenAabb::new(10, 10, 30, 30, 1.5);
//! let query = OcclusionQuery::new(OcclusionMode::Conservative);
//! // Pass a slice reference to zbuffer and width
//! let visible = query.is_visible(&aabb, zbuffer, width);
//! ```
/// An axis-aligned bounding box in screen (pixel) space, with a representative depth.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScreenAabb {
/// Left edge (inclusive), in pixels.
pub x_min: i32,
/// Top edge (inclusive), in pixels.
pub y_min: i32,
/// Right edge (inclusive), in pixels.
pub x_max: i32,
/// Bottom edge (inclusive), in pixels.
pub y_max: i32,
/// Representative depth of the AABB's front face (in the same units as the scene).
pub depth: f32,
}
impl ScreenAabb {
/// Constructs a new [`ScreenAabb`].
#[inline]
pub fn new(x_min: i32, y_min: i32, x_max: i32, y_max: i32, depth: f32) -> Self {
Self {
x_min,
y_min,
x_max,
y_max,
depth,
}
}
/// Clips this AABB to the screen rectangle `[0, width) × [0, height)`.
///
/// If the AABB lies entirely outside the screen the returned value will
/// have `x_min > x_max` or `y_min > y_max` (detectable via [`Self::is_empty`]).
#[inline]
pub fn clamp(self, width: i32, height: i32) -> Self {
Self {
x_min: self.x_min.max(0),
y_min: self.y_min.max(0),
x_max: self.x_max.min(width - 1),
y_max: self.y_max.min(height - 1),
depth: self.depth,
}
}
/// Returns `true` when the (possibly clamped) region has zero or negative area.
#[inline]
pub fn is_empty(&self) -> bool {
self.x_min > self.x_max || self.y_min > self.y_max
}
}
/// Controls how many pixels of the AABB are probed against the z-buffer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OcclusionMode {
/// Sample only the four corner pixels of the AABB.
///
/// Very fast (O(1)), but may miss occlusion when the corners happen to be
/// inside already-rasterised geometry while the interior is still visible.
Conservative,
/// Sample every pixel inside the AABB rectangle.
///
/// Exact result, but O(area) — use on small or distant objects.
Accurate,
}
/// Occlusion query tester.
///
/// Call [`is_visible`](OcclusionQuery::is_visible) to test whether any part of
/// a [`ScreenAabb`] is potentially visible given the current z-buffer contents.
#[derive(Debug, Clone, Copy)]
pub struct OcclusionQuery {
/// Sampling strategy used for z-buffer probing.
pub mode: OcclusionMode,
}
/// Convert a floating-point scene depth into a fixed-point `u32` representation.
#[inline(always)]
fn depth_to_fixed(d: f32) -> u32 {
(d * 65536.0) as u32
}
impl OcclusionQuery {
/// Creates a new [`OcclusionQuery`] with the given sampling [`OcclusionMode`].
#[inline]
pub fn new(mode: OcclusionMode) -> Self {
Self { mode }
}
/// Tests whether the [`ScreenAabb`] is potentially visible in the current z-buffer.
///
/// Returns `true` if **any** sampled pixel is either:
/// - unwritten (`zbuffer[idx] == Z_MAX_VALUE`), or
/// - written with a depth value **greater than** the AABB's depth (meaning
/// the AABB is closer to the camera than whatever was already drawn there).
///
/// Returns `false` (fully occluded) only when every sampled pixel already
/// holds a depth value that is closer than (or equal to) the AABB depth.
///
/// # Arguments
/// - `aabb` – Screen-space bounding box to test.
/// - `zbuffer` – Flat row-major depth buffer slice.
/// - `width` – Width of the framebuffer in pixels (stride of `zbuffer`).
pub fn is_visible(&self, aabb: &ScreenAabb, zbuffer: &[crate::ZDepth], width: usize) -> bool {
if aabb.is_empty() {
// A degenerate / fully off-screen AABB is considered not visible.
return false;
}
let aabb_zdepth = crate::to_zdepth(depth_to_fixed(aabb.depth));
match self.mode {
OcclusionMode::Conservative => {
// Probe the four corners only.
let corners = [
(aabb.x_min, aabb.y_min),
(aabb.x_max, aabb.y_min),
(aabb.x_min, aabb.y_max),
(aabb.x_max, aabb.y_max),
];
for (cx, cy) in corners {
if Self::pixel_visible(zbuffer, width, cx, cy, aabb_zdepth) {
return true;
}
}
false
}
OcclusionMode::Accurate => {
// Probe every pixel in the rectangle.
for py in aabb.y_min..=aabb.y_max {
for px in aabb.x_min..=aabb.x_max {
if Self::pixel_visible(zbuffer, width, px, py, aabb_zdepth) {
return true;
}
}
}
false
}
}
}
/// Returns `true` if the pixel at `(px, py)` is potentially visible for an
/// object at `aabb_zdepth`.
///
/// A pixel is visible if:
/// - it has never been written (`zbuffer[idx] == Z_MAX_VALUE`), **or**
/// - the stored depth is *greater than* `aabb_zdepth` (the incoming object
/// is closer than what is already there).
#[inline(always)]
fn pixel_visible(
zbuffer: &[crate::ZDepth],
width: usize,
px: i32,
py: i32,
aabb_zdepth: crate::ZDepth,
) -> bool {
if px < 0 || py < 0 {
return false;
}
let idx = py as usize * width + px as usize;
if idx >= zbuffer.len() {
return false;
}
let stored = zbuffer[idx];
// Unwritten pixel, or AABB is closer than current occupant.
stored == crate::Z_MAX_VALUE || stored > aabb_zdepth
}
}
// ── Statistics ────────────────────────────────────────────────────────────────
/// Lightweight profiling counters for occlusion queries.
///
/// Call [`record`](OcclusionStats::record) after each [`OcclusionQuery::is_visible`]
/// call, then inspect [`cull_ratio`](OcclusionStats::cull_ratio) to measure how
/// effective the culling is.
#[derive(Debug, Clone, Copy, Default)]
pub struct OcclusionStats {
/// Total number of queries submitted.
pub queries: u32,
/// Queries where `is_visible` returned `true`.
pub passed: u32,
/// Queries where `is_visible` returned `false` (culled).
pub culled: u32,
}
impl OcclusionStats {
/// Creates a zeroed [`OcclusionStats`].
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Records the result of one occlusion query.
#[inline]
pub fn record(&mut self, visible: bool) {
self.queries += 1;
if visible {
self.passed += 1;
} else {
self.culled += 1;
}
}
/// Returns the fraction of queries that were culled.
///
/// Returns `0.0` when no queries have been submitted yet.
#[inline]
pub fn cull_ratio(&self) -> f32 {
self.culled as f32 / self.queries.max(1) as f32
}
/// Resets all counters to zero.
#[inline]
pub fn reset(&mut self) {
self.queries = 0;
self.passed = 0;
self.culled = 0;
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
extern crate std;
use super::*;
use crate::{Z_MAX_VALUE, ZDepth};
// ── ScreenAabb::clamp ────────────────────────────────────────────────────
#[test]
fn test_screen_aabb_clamp() {
let aabb = ScreenAabb::new(-5, -10, 50, 60, 1.0);
let clamped = aabb.clamp(40, 30);
assert_eq!(clamped.x_min, 0);
assert_eq!(clamped.y_min, 0);
assert_eq!(clamped.x_max, 39); // width-1
assert_eq!(clamped.y_max, 29); // height-1
assert!(!clamped.is_empty());
// Entirely off-screen → is_empty()
let off_screen = ScreenAabb::new(100, 100, 200, 200, 1.0).clamp(80, 60);
assert!(off_screen.is_empty());
}
// ── OcclusionQuery on an empty z-buffer ──────────────────────────────────
#[test]
fn test_occlusion_empty_zbuffer() {
const W: usize = 64;
const H: usize = 64;
let zbuf: std::vec::Vec<ZDepth> = std::vec![Z_MAX_VALUE; W * H];
let aabb = ScreenAabb::new(10, 10, 20, 20, 5.0);
// Both modes must report visible on a fresh (all-max) z-buffer.
let conservative = OcclusionQuery::new(OcclusionMode::Conservative);
assert!(
conservative.is_visible(&aabb, &zbuf, W),
"Conservative: empty z-buffer must be visible"
);
let accurate = OcclusionQuery::new(OcclusionMode::Accurate);
assert!(
accurate.is_visible(&aabb, &zbuf, W),
"Accurate: empty z-buffer must be visible"
);
}
// ── OcclusionQuery with a fully-written z-buffer ─────────────────────────
#[test]
fn test_occlusion_fully_occluded() {
const W: usize = 64;
const H: usize = 64;
// Fill the z-buffer with depth 0 — the closest possible value.
// Anything submitted at depth 10.0 should be behind this and invisible.
let zbuf: std::vec::Vec<ZDepth> = std::vec![0; W * H];
let aabb = ScreenAabb::new(10, 10, 20, 20, 10.0);
let conservative = OcclusionQuery::new(OcclusionMode::Conservative);
assert!(
!conservative.is_visible(&aabb, &zbuf, W),
"Conservative: should be fully occluded"
);
let accurate = OcclusionQuery::new(OcclusionMode::Accurate);
assert!(
!accurate.is_visible(&aabb, &zbuf, W),
"Accurate: should be fully occluded"
);
}
// ── OcclusionStats ───────────────────────────────────────────────────────
#[test]
fn test_occlusion_stats() {
let mut stats = OcclusionStats::new();
// No queries yet → ratio is 0.
assert_eq!(stats.cull_ratio(), 0.0);
stats.record(true); // passed
stats.record(true); // passed
stats.record(false); // culled
stats.record(false); // culled
assert_eq!(stats.queries, 4);
assert_eq!(stats.passed, 2);
assert_eq!(stats.culled, 2);
assert!((stats.cull_ratio() - 0.5).abs() < 1e-6);
stats.reset();
assert_eq!(stats.queries, 0);
assert_eq!(stats.passed, 0);
assert_eq!(stats.culled, 0);
assert_eq!(stats.cull_ratio(), 0.0);
}
}