code wiki / (root) / nx_poisson_disk.nx

nx_poisson_disk.nx source

↩ module page · 338 lines · 13285 B

1// nx_poisson_disk.nx -- Bridson 2007 fast Poisson disk sampling. 2// 3// LAYER 2 of the procgen substrate (Perlin = layer 1). Genuinely 4// modern algorithm (2007), patent-clean, O(N). Used by Townscaper- 5// class procedural tools for building placement, Houdini's scatter 6// SOP for prop distribution, agent-spawn placement in zombie-escape 7// city games -- anywhere "looks random AND evenly spaced AND fast" 8// matters more than uniform random scattering (which clumps). 9// 10// Honest deviation note vs canonical Bridson paper (PDF was not 11// directly readable; spec verified from training data; v1 caveat): 12// 1. Initial seed point: RANDOM (fixed in v1.1 -- previously center). 13// 2. Annulus sample radius: drawn LINEARLY in [r, 2r] rather than 14// area-uniform (canonical r' = sqrt(uniform * (4r^2 - r^2) + r^2) 15// requires sqrt; queued for follow-up once nx_sqrt_q10 ships). 16// Substrate still satisfies the min-distance invariant; the bias 17// is a slight inner-ring density skew, not a correctness bug. 18// 19// Algorithm (Bridson 2007 SIGGRAPH sketch): 20// 1. Background grid with cell size r / sqrt(n_dim). Each cell 21// can hold at most one point (because two points within one 22// cell would be < r apart). 23// 2. Start: place one seed point, mark it active. 24// 3. Loop while active non-empty: 25// Pick random active point p. Try k = 30 candidates at 26// distance [r, 2r] from p. For each candidate: 27// - Check in-bounds. 28// - Check no existing point within r (grid neighbors only -- 29// this is what makes Bridson O(N)). 30// - If valid, add point + mark active; break. 31// If all 30 fail, remove p from active. 32// 4. Return collected points. 33// 34// Why this beats Perlin-grid-scatter for city gen: Perlin scatter 35// has clumping (high-noise spots get many points; low-noise spots 36// none). Poisson disk guarantees minimum distance r AND fills 37// space uniformly -- exactly what zombie-spawn / building-plot 38// placement requires. 39// 40// Cross-modal: also gives n-gram-diverse sample selection from a 41// token corpus (treat tokens as 1D positions, r = minimum gap), 42// event-timing scatter for audio onsets, test-case diversity for 43// fuzzers. 44// 45// Qualitative reading per the dual-reading cardinal: a density 46// band classifier maps the actual achieved point-count vs the 47// theoretical maximum into a sealed enum 48// (SPARSE / MODERATE / DENSE / SATURATED). 49// 50// genealogy_id: bridson_2007_poisson_disk + cook_1986_stochastic + 51// lloyd_1982_least_squares_quantization 52// lineage_id: bridson_poisson_disk_q10 53 54// nx_safety_envelope: 55// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 56// sil_target: SIL1 57// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 58// verdict: NOT_YET_EVALUATED 59 60import "nx_syscalls.nx" 61import "nx_tier.nx" 62import "nx_prng.nx" 63 64const NX_PD_Q: nx_int = 1024 65// sqrt(1/2) in Q10: ~ 0.7071 * 1024 = 724. Used for cell size 66// derivation in 2D (Bridson: cell_size = r / sqrt(n_dim)). 67const NX_PD_INV_SQRT_2: nx_int = 724 68const NX_PD_K_CANDIDATES: nx_int = 30 69const NX_PD_N_ANGLES: nx_int = 16 70 71// 16-entry precomputed (cos * Q10, sin * Q10) at angles 0, 22.5, 72// 45, ..., 337.5 degrees. Indexed by random hash % 16. 73const NX_PD_COS_0: nx_int = 1024 74const NX_PD_COS_1: nx_int = 946 75const NX_PD_COS_2: nx_int = 724 76const NX_PD_COS_3: nx_int = 392 77const NX_PD_COS_4: nx_int = 0 78const NX_PD_COS_5: nx_int = -392 79const NX_PD_COS_6: nx_int = -724 80const NX_PD_COS_7: nx_int = -946 81const NX_PD_COS_8: nx_int = -1024 82const NX_PD_COS_9: nx_int = -946 83const NX_PD_COS_10: nx_int = -724 84const NX_PD_COS_11: nx_int = -392 85const NX_PD_COS_12: nx_int = 0 86const NX_PD_COS_13: nx_int = 392 87const NX_PD_COS_14: nx_int = 724 88const NX_PD_COS_15: nx_int = 946 89 90const NX_PD_SIN_0: nx_int = 0 91const NX_PD_SIN_1: nx_int = 392 92const NX_PD_SIN_2: nx_int = 724 93const NX_PD_SIN_3: nx_int = 946 94const NX_PD_SIN_4: nx_int = 1024 95const NX_PD_SIN_5: nx_int = 946 96const NX_PD_SIN_6: nx_int = 724 97const NX_PD_SIN_7: nx_int = 392 98const NX_PD_SIN_8: nx_int = 0 99const NX_PD_SIN_9: nx_int = -392 100const NX_PD_SIN_10: nx_int = -724 101const NX_PD_SIN_11: nx_int = -946 102const NX_PD_SIN_12: nx_int = -1024 103const NX_PD_SIN_13: nx_int = -946 104const NX_PD_SIN_14: nx_int = -724 105const NX_PD_SIN_15: nx_int = -392 106 107// ===== Qualitative density-band classification ========================= 108// 109// Achieved point count vs theoretical maximum (image area / pi r^2 / sqrt(3)). 110// In Q10 we approximate the theoretical max as 111// area * 1024 / (r * r * NX_PD_MAX_FACTOR) where NX_PD_MAX_FACTOR ~ 5.4 112// (Wikipedia: hexagonal packing density 0.9069, so area / (pi r^2 * 0.9069)). 113const NX_PD_MAX_FACTOR_Q10: nx_int = 5530 // ~ 5.4 * Q10 114 115const NX_PD_BAND_SPARSE: nx_int = 0 116const NX_PD_BAND_MODERATE: nx_int = 1 117const NX_PD_BAND_DENSE: nx_int = 2 118const NX_PD_BAND_SATURATED: nx_int = 3 119const NX_PD_N_BANDS: nx_int = 4 120 121func _pd_cos(idx: nx_int) -> nx_int { 122 if idx == 0 { return NX_PD_COS_0 } 123 if idx == 1 { return NX_PD_COS_1 } 124 if idx == 2 { return NX_PD_COS_2 } 125 if idx == 3 { return NX_PD_COS_3 } 126 if idx == 4 { return NX_PD_COS_4 } 127 if idx == 5 { return NX_PD_COS_5 } 128 if idx == 6 { return NX_PD_COS_6 } 129 if idx == 7 { return NX_PD_COS_7 } 130 if idx == 8 { return NX_PD_COS_8 } 131 if idx == 9 { return NX_PD_COS_9 } 132 if idx == 10 { return NX_PD_COS_10 } 133 if idx == 11 { return NX_PD_COS_11 } 134 if idx == 12 { return NX_PD_COS_12 } 135 if idx == 13 { return NX_PD_COS_13 } 136 if idx == 14 { return NX_PD_COS_14 } 137 return NX_PD_COS_15 138} 139 140func _pd_sin(idx: nx_int) -> nx_int { 141 if idx == 0 { return NX_PD_SIN_0 } 142 if idx == 1 { return NX_PD_SIN_1 } 143 if idx == 2 { return NX_PD_SIN_2 } 144 if idx == 3 { return NX_PD_SIN_3 } 145 if idx == 4 { return NX_PD_SIN_4 } 146 if idx == 5 { return NX_PD_SIN_5 } 147 if idx == 6 { return NX_PD_SIN_6 } 148 if idx == 7 { return NX_PD_SIN_7 } 149 if idx == 8 { return NX_PD_SIN_8 } 150 if idx == 9 { return NX_PD_SIN_9 } 151 if idx == 10 { return NX_PD_SIN_10 } 152 if idx == 11 { return NX_PD_SIN_11 } 153 if idx == 12 { return NX_PD_SIN_12 } 154 if idx == 13 { return NX_PD_SIN_13 } 155 if idx == 14 { return NX_PD_SIN_14 } 156 return NX_PD_SIN_15 157} 158 159// Squared distance. Avoids sqrt -- compare squared distances directly. 160func _pd_dist_sq(ax: nx_int, ay: nx_int, bx: nx_int, by: nx_int) -> nx_int { 161 let dx: nx_int = ax - bx 162 let dy: nx_int = ay - by 163 return dx * dx + dy * dy 164} 165 166// Grid neighbor scan: check no existing point within r of (cx, cy). 167// Returns 1 if accepted (no neighbors within r), 0 if rejected. 168// Bridson's O(N) trick: only the 5x5 (or so) cells around the candidate 169// need checking. 170func _pd_accept(cx: nx_int, cy: nx_int, r: nx_int, r_sq: nx_int, 171 grid: *i64, grid_w: nx_int, grid_h: nx_int, cell: nx_int, 172 xs: *i64, ys: *i64) -> nx_int { 173 let gx: nx_int = cx / cell 174 let gy: nx_int = cy / cell 175 // Check 5x5 neighborhood -- safe upper bound for cell = r/sqrt(2). 176 var dy: nx_int = 0 - 2 177 while dy <= 2 { 178 var dx: nx_int = 0 - 2 179 while dx <= 2 { 180 let nx_g: nx_int = gx + dx 181 let ny_g: nx_int = gy + dy 182 if nx_g >= 0 { 183 if nx_g < grid_w { 184 if ny_g >= 0 { 185 if ny_g < grid_h { 186 let idx: nx_int = grid[ny_g * grid_w + nx_g] 187 if idx >= 0 { 188 let d_sq: nx_int = _pd_dist_sq(cx, cy, xs[idx], ys[idx]) 189 if d_sq < r_sq { return 0 } 190 } 191 } 192 } 193 } 194 } 195 dx = dx + 1 196 } 197 dy = dy + 1 198 } 199 return 1 200} 201 202// ===== Public entry ===================================================== 203// 204// Sample N points in a rectangle [0, w) x [0, h) with minimum 205// pairwise distance r. Caller provides out_xs / out_ys buffers 206// sized max_points. Returns actual count. 207// 208// Bridson's algorithm: O(max_points), no sqrt, no f64. 209 210func nx_poisson_disk_sample(seed: nx_int, w: nx_int, h: nx_int, r: nx_int, 211 out_xs: *i64, out_ys: *i64, 212 max_points: nx_int) -> nx_int { 213 if r <= 0 { return 0 } 214 if w <= 0 { return 0 } 215 if h <= 0 { return 0 } 216 if max_points <= 0 { return 0 } 217 218 let r_sq: nx_int = r * r 219 let cell: nx_int = (r * NX_PD_INV_SQRT_2) / NX_PD_Q 220 var cell_eff: nx_int = cell 221 if cell_eff < 1 { cell_eff = 1 } 222 let grid_w: nx_int = w / cell_eff + 1 223 let grid_h: nx_int = h / cell_eff + 1 224 225 let grid: *i64 = (sys_mmap(grid_w * grid_h * NX_SIZEOF_NX_INT)) as *i64 226 var gi: nx_int = 0 227 while gi < grid_w * grid_h { 228 grid[gi] = 0 - 1 229 gi = gi + 1 230 } 231 232 let active: *i64 = (sys_mmap(max_points * NX_SIZEOF_NX_INT)) as *i64 233 var n_active: nx_int = 0 234 235 let prng_state: *i64 = (sys_mmap(NX_SIZEOF_NX_INT)) as *i64 236 nx_prng_init(prng_state, seed) 237 238 // Seed point: canonical Bridson 2007 specifies the initial point 239 // chosen RANDOMLY in the domain (not center). Center-seed gives 240 // visually-similar output but a deterministic-by-seed canonical 241 // implementation places the first point at a PRNG-sampled position. 242 var n_points: nx_int = 0 243 let sx_raw: nx_int = nx_prng_next(prng_state) 244 var sx0: nx_int = sx_raw - (sx_raw / w) * w 245 if sx0 < 0 { sx0 = 0 - sx0 } 246 let sy_raw: nx_int = nx_prng_next(prng_state) 247 var sy0: nx_int = sy_raw - (sy_raw / h) * h 248 if sy0 < 0 { sy0 = 0 - sy0 } 249 out_xs[0] = sx0 250 out_ys[0] = sy0 251 grid[(sy0 / cell_eff) * grid_w + (sx0 / cell_eff)] = 0 252 active[0] = 0 253 n_active = 1 254 n_points = 1 255 256 while n_active > 0 { 257 if n_points >= max_points { return n_points } 258 // Pick a random active index. 259 let pick_raw: nx_int = nx_prng_next(prng_state) 260 var pick: nx_int = pick_raw - (pick_raw / n_active) * n_active 261 if pick < 0 { pick = 0 - pick } 262 let pidx: nx_int = active[pick] 263 let px: nx_int = out_xs[pidx] 264 let py: nx_int = out_ys[pidx] 265 266 var success: nx_int = 0 267 var k: nx_int = 0 268 while k < NX_PD_K_CANDIDATES { 269 let araw: nx_int = nx_prng_next(prng_state) 270 var ai: nx_int = araw - (araw / NX_PD_N_ANGLES) * NX_PD_N_ANGLES 271 if ai < 0 { ai = 0 - ai } 272 let cs: nx_int = _pd_cos(ai) 273 let sn: nx_int = _pd_sin(ai) 274 let rraw: nx_int = nx_prng_next(prng_state) 275 var rr: nx_int = rraw - (rraw / r) * r 276 if rr < 0 { rr = 0 - rr } 277 let radius: nx_int = r + rr // [r, 2r) 278 let cx: nx_int = px + (cs * radius) / NX_PD_Q 279 let cy: nx_int = py + (sn * radius) / NX_PD_Q 280 if cx >= 0 { 281 if cx < w { 282 if cy >= 0 { 283 if cy < h { 284 let ok: nx_int = _pd_accept(cx, cy, r, r_sq, 285 grid, grid_w, grid_h, cell_eff, 286 out_xs, out_ys) 287 if ok == 1 { 288 out_xs[n_points] = cx 289 out_ys[n_points] = cy 290 grid[(cy / cell_eff) * grid_w + (cx / cell_eff)] = n_points 291 active[n_active] = n_points 292 n_active = n_active + 1 293 n_points = n_points + 1 294 success = 1 295 k = NX_PD_K_CANDIDATES // exit inner loop 296 } 297 } 298 } 299 } 300 } 301 k = k + 1 302 } 303 304 if success == 0 { 305 // Remove pidx from active: swap-with-last. 306 active[pick] = active[n_active - 1] 307 n_active = n_active - 1 308 } 309 } 310 311 return n_points 312} 313 314// ===== Qualitative density-band classifier ============================ 315// 316// Maps achieved n_points vs theoretical-max into a sealed-enum band. 317 318func nx_poisson_disk_classify(n_points: nx_int, w: nx_int, h: nx_int, r: nx_int) -> nx_int { 319 if r <= 0 { return NX_PD_BAND_SPARSE } 320 // theoretical_max ~ area / (r^2 * 5.4) in i64 (5.4 ~ pi/sqrt(3) for 321 // hexagonal-packing inverse). 322 let area: nx_int = w * h 323 let denom: nx_int = (r * r * NX_PD_MAX_FACTOR_Q10) / NX_PD_Q 324 if denom <= 0 { return NX_PD_BAND_SPARSE } 325 let theoretical_max: nx_int = area / denom 326 if theoretical_max <= 0 { return NX_PD_BAND_SPARSE } 327 let ratio_q10: nx_int = (n_points * NX_PD_Q) / theoretical_max 328 if ratio_q10 < 256 { return NX_PD_BAND_SPARSE } // < 25% 329 if ratio_q10 < 614 { return NX_PD_BAND_MODERATE } // 25-60% 330 if ratio_q10 < 870 { return NX_PD_BAND_DENSE } // 60-85% 331 return NX_PD_BAND_SATURATED // >= 85% 332} 333 334func nx_poisson_disk_band_is_valid(band: nx_int) -> nx_int { 335 if band < 0 { return 0 } 336 if band >= NX_PD_N_BANDS { return 0 } 337 return 1 338}