code wiki / (root) / nx_raster_triangle.nx

nx_raster_triangle.nx source

↩ module page · 390 lines · 17446 B

1// nx_raster_triangle.nx -- software triangle rasterizer with Z-buffer. 2// 3// LINEAGE (per [[feedback-no-tool-proliferation-consolidate-or-justify]] 4// cardinal landed 2026-05-20): 5// 6// Distinct from [[nx_raster.nx]] because: nx_raster.nx ships 7// Bresenham lines + Pineda triangles + shaded triangles on the 8// *Image abstraction; this file is the *i64-framebuffer + Z-buffer 9// variant for 3D rendering with depth occlusion. 10// 11// Distinct from [[nx_raster_sw.nx]] because: nx_raster_sw.nx is the 12// 2D strict-interior coverage variant for perceptual-render demos 13// without depth; this file adds Z-buffer for 3D occlusion. The 14// two compose: this file CALLS nx_raster_sw's edge function in its 15// Pineda inner loop. 16// 17// HONEST PROLIFERATION NOTE: three rasterizer primitives exist in 18// the substrate by 2026-05-20. Future raster work MUST extend one 19// of these three; consolidate if the substrate hits four. 20// 21// Generic 3D substrate primitive per the placement cardinal 22// `feedback-nishi-lang-vs-nishi-engine-placement-discipline`. 23// Any program that renders 3D triangles uses this; not voxel- 24// specific. Lives in nishi-core/nxc2/runtime/. 25// 26// Algorithm: edge-function rasterization (Pineda 1988). Per-pixel: 27// compute three signed edge values from the triangle's three edges; 28// pixel is inside the triangle if all three have the same sign. 29// Barycentric interpolation gives the per-pixel Z-depth used for 30// depth-buffered occlusion. No sub-pixel precision (integer 31// coordinates only) -- chunky pixels are the aesthetic for the 32// software-rendered /voxels path. 33// 34// Vertex layout (matches nx_voxel_mesh): 35// verts[3*i + 0] = x_pixel (integer screen X) 36// verts[3*i + 1] = y_pixel (integer screen Y) 37// verts[3*i + 2] = z_q14_depth (Q14; smaller = closer) 38// verts[3*i + 3] = packed_color (u32 RGBA in low 32 bits) 39// 40// Triangle list: vertices [0..3) are triangle 0, [3..6) are 41// triangle 1, etc. No index buffer in this primitive (callers can 42// build one as a thin wrapper). 43// 44// Framebuffer: *i64, w*h entries; each i64 holds a u32 RGBA in the 45// low 32 bits. Browser-side JS reads via Uint32Array on the WASM 46// memory buffer and feeds putImageData. Native callers write the 47// buffer to a PPM file via a separate nx_ppm_writer primitive 48// (queued). 49// 50// Z-buffer: *i64, w*h entries; each i64 holds Q14 depth. Initialised 51// to NX_RASTER_Z_FAR per nx_raster_zbuffer_clear. 52// 53// Loss audit: 54// - Integer screen coords lose sub-pixel detail (no anti-aliasing). 55// Named: AA queued via separate nx_raster_msaa.nx primitive. 56// - Z interpolation via barycentric in integer arithmetic; max 57// error ~1 LSB at the triangle interior; vanishes at vertices. 58// - Backface culling not yet implemented; degenerate triangles 59// (zero area) are skipped to avoid divide-by-zero. 60// 61// genealogy_id: pineda_1988_parallel_algorithm + bresenham_1965_lines 62// lineage_id: nx_raster_triangle_edge_function_q14_zbuffer 63 64// nx_safety_envelope: 65// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 66// sil_target: SIL1 67// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 68// verdict: NOT_YET_EVALUATED 69 70import "nx_syscalls.nx" 71import "nx_tier.nx" 72const NX_MAGIC_65536: i64 = 65536 73const NX_MAGIC_16777216: i64 = 16777216 74const NX_MAGIC_4294901695: i64 = 4294901695 75const NX_MAGIC_4294967040: i64 = 4294967040 76const NX_MAGIC_4278190335: i64 = 4278190335 77const NX_MAGIC_11111: i64 = 11111 78const NX_MAGIC_22222: i64 = 22222 79 80// ===== Constants ==================================================== 81// Q14 matches nx_camera_q14 + nx_voxel_mesh -- depth values come from 82// the same fixed-point arithmetic. 83const NX_RASTER_Q: nx_int = 16384 84 85// Far-plane depth value used to clear the Z-buffer. Anything closer 86// than this passes the depth test on first write. Q14 * 1000 ~ 16M, 87// well above any realistic scene depth. 88const NX_RASTER_Z_FAR: nx_int = 16384000 89 90// Vertex stride in i64 (matches nx_voxel_mesh). 91const NX_RASTER_VERT_STRIDE: nx_int = 4 92 93// Triangle stride in i64 (3 vertices per triangle). 94const NX_RASTER_TRI_STRIDE: nx_int = 12 95 96// Vertex-field offsets within a single vertex (4 i64s). 97const NX_RASTER_VOFF_X: nx_int = 0 98const NX_RASTER_VOFF_Y: nx_int = 1 99const NX_RASTER_VOFF_Z: nx_int = 2 100const NX_RASTER_VOFF_COLOR: nx_int = 3 101 102// ===== Min / Max / Clamp helpers =================================== 103func _min3(a: nx_int, b: nx_int, c: nx_int) -> nx_int { 104 var m: nx_int = a 105 if b < m { m = b } 106 if c < m { m = c } 107 return m 108} 109 110func _max3(a: nx_int, b: nx_int, c: nx_int) -> nx_int { 111 var m: nx_int = a 112 if b > m { m = b } 113 if c > m { m = c } 114 return m 115} 116 117func _clamp(v: nx_int, lo: nx_int, hi: nx_int) -> nx_int { 118 if v < lo { return lo } 119 if v > hi { return hi } 120 return v 121} 122 123// Sign agreement: returns 1 if a, b, c all have the same sign 124// (or are zero), 0 otherwise. Used for the inside-triangle test. 125// Edge cases: 126// all >= 0 -> inside 127// all <= 0 -> inside (winding-flipped triangle) 128// mixed -> outside 129func _same_sign_3(a: nx_int, b: nx_int, c: nx_int) -> nx_int { 130 if a >= 0 { 131 if b >= 0 { 132 if c >= 0 { return 1 } 133 } 134 } 135 if a <= 0 { 136 if b <= 0 { 137 if c <= 0 { return 1 } 138 } 139 } 140 return 0 141} 142 143// ===== Framebuffer + Z-buffer allocation =========================== 144// Caller supplies width + height; we mmap w*h*8 bytes for each 145// buffer (i64 entries, one per pixel). Pixels in scan-line order: 146// fb[y * w + x] 147func nx_raster_alloc_fb(w: nx_int, h: nx_int) -> *i64 { 148 return (sys_mmap(w * h * NX_SIZEOF_NX_INT)) as *i64 149} 150 151func nx_raster_alloc_zb(w: nx_int, h: nx_int) -> *i64 { 152 return (sys_mmap(w * h * NX_SIZEOF_NX_INT)) as *i64 153} 154 155// ===== Buffer clears ================================================ 156func nx_raster_clear(fb: *i64, w: nx_int, h: nx_int, color: nx_int) { 157 let n: nx_int = w * h 158 var i: nx_int = 0 159 while i < n { 160 fb[i] = color 161 i = i + 1 162 } 163} 164 165func nx_raster_zbuffer_clear(zb: *i64, w: nx_int, h: nx_int) { 166 let n: nx_int = w * h 167 var i: nx_int = 0 168 while i < n { 169 zb[i] = NX_RASTER_Z_FAR 170 i = i + 1 171 } 172} 173 174// ===== Single-triangle rasterizer =================================== 175// tri_verts points to 12 i64s (3 vertices x 4 i64). Screen coords 176// in pixels; Z in Q14. Color interpolated PER-PIXEL via Pineda 1988 177// barycentric weights (same weights already computed for Z-test), each 178// of the 4 RGBA channels separately. Alpha is used as an ambient- 179// brightness multiplier on the final RGB so per-vertex AO bands (the 180// 63/127/191/255 encoding from nx_voxel_mesh) appear as smooth shadow 181// gradients across each face. Cost: 4 extra (mul+mul+mul+div) per 182// in-triangle pixel; no extra allocations. 183// 184// Skips degenerate (zero-area) triangles. Bounding box clipped to 185// viewport. Pixels written only when (a) inside the triangle and 186// (b) depth-test passes (tri Z < buffer Z). 187func nx_raster_triangle( 188 fb: *i64, 189 zb: *i64, 190 w: nx_int, 191 h: nx_int, 192 tri_verts: *i64 193) { 194 let x0: nx_int = tri_verts[0 + NX_RASTER_VOFF_X] 195 let y0: nx_int = tri_verts[0 + NX_RASTER_VOFF_Y] 196 let z0: nx_int = tri_verts[0 + NX_RASTER_VOFF_Z] 197 let c0: nx_int = tri_verts[0 + NX_RASTER_VOFF_COLOR] 198 let x1: nx_int = tri_verts[4 + NX_RASTER_VOFF_X] 199 let y1: nx_int = tri_verts[4 + NX_RASTER_VOFF_Y] 200 let z1: nx_int = tri_verts[4 + NX_RASTER_VOFF_Z] 201 let c1: nx_int = tri_verts[4 + NX_RASTER_VOFF_COLOR] 202 let x2: nx_int = tri_verts[8 + NX_RASTER_VOFF_X] 203 let y2: nx_int = tri_verts[8 + NX_RASTER_VOFF_Y] 204 let z2: nx_int = tri_verts[8 + NX_RASTER_VOFF_Z] 205 let c2: nx_int = tri_verts[8 + NX_RASTER_VOFF_COLOR] 206 207 // Pre-unpack per-vertex RGBA channels for the inner-loop interp. 208 let r0: nx_int = c0 % 256 209 let g0: nx_int = (c0 / 256) % 256 210 let b0: nx_int = (c0 / NX_MAGIC_65536) % 256 211 let a0: nx_int = (c0 / NX_MAGIC_16777216) % 256 212 let r1c: nx_int = c1 % 256 213 let g1c: nx_int = (c1 / 256) % 256 214 let b1c: nx_int = (c1 / NX_MAGIC_65536) % 256 215 let a1c: nx_int = (c1 / NX_MAGIC_16777216) % 256 216 let r2c: nx_int = c2 % 256 217 let g2c: nx_int = (c2 / 256) % 256 218 let b2c: nx_int = (c2 / NX_MAGIC_65536) % 256 219 let a2c: nx_int = (c2 / NX_MAGIC_16777216) % 256 220 221 // 2x signed area of the triangle (also the determinant of the 222 // edge-function matrix). Sign indicates winding direction. 223 let area: nx_int = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0) 224 if area == 0 { return } // degenerate 225 226 // Bounding box clipped to viewport. 227 let min_x: nx_int = _clamp(_min3(x0, x1, x2), 0, w - 1) 228 let max_x: nx_int = _clamp(_max3(x0, x1, x2), 0, w - 1) 229 let min_y: nx_int = _clamp(_min3(y0, y1, y2), 0, h - 1) 230 let max_y: nx_int = _clamp(_max3(y0, y1, y2), 0, h - 1) 231 232 var py: nx_int = min_y 233 while py <= max_y { 234 var px: nx_int = min_x 235 while px <= max_x { 236 // Edge functions (Pineda 1988). w_i is proportional to 237 // the signed distance from the pixel to edge i. 238 // Conventionally w_0 is the edge OPPOSITE vertex 0, etc. 239 let e0: nx_int = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1) 240 let e1: nx_int = (x0 - x2) * (py - y2) - (y0 - y2) * (px - x2) 241 let e2: nx_int = (x1 - x0) * (py - y0) - (y1 - y0) * (px - x0) 242 243 if _same_sign_3(e0, e1, e2) == 1 { 244 // Barycentric weights = e_i / area. Interpolate Z. 245 // Z = (e0*z0 + e1*z1 + e2*z2) / area. 246 let z_num: nx_int = e0 * z0 + e1 * z1 + e2 * z2 247 let z: nx_int = z_num / area 248 249 let idx: nx_int = py * w + px 250 if z < zb[idx] { 251 zb[idx] = z 252 // S-class per-pixel barycentric color interpolation 253 // (Pineda 1988): each of the 4 RGBA channels weighted 254 // by the same edge-function values used for Z. Final 255 // alpha is folded into RGB as a brightness multiplier 256 // so per-vertex AO bands appear as smooth gradients. 257 let r: nx_int = (e0 * r0 + e1 * r1c + e2 * r2c) / area 258 let g: nx_int = (e0 * g0 + e1 * g1c + e2 * g2c) / area 259 let b: nx_int = (e0 * b0 + e1 * b1c + e2 * b2c) / area 260 let a: nx_int = (e0 * a0 + e1 * a1c + e2 * a2c) / area 261 // Apply alpha as AO/ambient multiplier. Alpha 255 262 // = full brightness; lower alphas dim RGB linearly. 263 let r_lit: nx_int = (r * a) / 255 264 let g_lit: nx_int = (g * a) / 255 265 let b_lit: nx_int = (b * a) / 255 266 fb[idx] = r_lit + g_lit * 256 + b_lit * NX_MAGIC_65536 + 255 * NX_MAGIC_16777216 267 } 268 } 269 px = px + 1 270 } 271 py = py + 1 272 } 273} 274 275// ===== Triangle-list rasterizer ===================================== 276// verts points to n_verts * 4 i64 entries. n_verts MUST be a 277// multiple of 3 (caller responsibility). Iterates triangles and 278// dispatches to nx_raster_triangle. 279func nx_raster_triangle_list( 280 fb: *i64, 281 zb: *i64, 282 w: nx_int, 283 h: nx_int, 284 verts: *i64, 285 n_verts: nx_int 286) { 287 var v: nx_int = 0 288 while v + 3 <= n_verts { 289 // Pointer to the first i64 of this triangle's 12-i64 record. 290 let tri: *i64 = (verts as i64 + v * NX_RASTER_VERT_STRIDE * NX_SIZEOF_NX_INT) as *i64 291 nx_raster_triangle(fb, zb, w, h, tri) 292 v = v + 3 293 } 294} 295 296// ===== Pixel read helper (for tests) =============================== 297func nx_raster_pixel(fb: *i64, w: nx_int, x: nx_int, y: nx_int) -> nx_int { 298 return fb[y * w + x] 299} 300 301func nx_raster_depth(zb: *i64, w: nx_int, x: nx_int, y: nx_int) -> nx_int { 302 return zb[y * w + x] 303} 304 305// ===== Self-test ==================================================== 306func main() -> i64 { 307 // T1: Allocate a tiny 16x8 framebuffer + Z-buffer. Clear both. 308 let w: nx_int = 16 309 let h: nx_int = 8 310 let fb: *i64 = nx_raster_alloc_fb(w, h) 311 let zb: *i64 = nx_raster_alloc_zb(w, h) 312 nx_raster_clear(fb, w, h, 0) 313 nx_raster_zbuffer_clear(zb, w, h) 314 if nx_raster_pixel(fb, w, 0, 0) != 0 { return __syscall(93, 1, 0, 0, 0, 0, 0) } 315 if nx_raster_pixel(fb, w, 15, 7) != 0 { return __syscall(93, 2, 0, 0, 0, 0, 0) } 316 if nx_raster_depth(zb, w, 0, 0) != NX_RASTER_Z_FAR { return __syscall(93, 3, 0, 0, 0, 0, 0) } 317 318 // T2: Rasterize a single triangle covering pixel (5, 5). 319 // CCW triangle: (2, 2), (10, 2), (6, 6). z all = NX_RASTER_Q. 320 // pack(r=255,g=0,b=255,a=255) = 255 + 0*256 + 255*65536 + 255*16777216 321 // = 4294901695 (0xFFFF00FF magenta, full ambient) 322 let tri: *i64 = (sys_mmap(NX_RASTER_TRI_STRIDE * NX_SIZEOF_NX_INT)) as *i64 323 tri[0] = 2; tri[1] = 2; tri[2] = NX_RASTER_Q; tri[3] = NX_MAGIC_4294901695 // 0xFFFF00FF 324 tri[4] = 10; tri[5] = 2; tri[6] = NX_RASTER_Q; tri[7] = NX_MAGIC_4294901695 325 tri[8] = 6; tri[9] = 6; tri[10]= NX_RASTER_Q; tri[11]= NX_MAGIC_4294901695 326 nx_raster_triangle(fb, zb, w, h, tri) 327 328 // T3: Vertex pixel (2, 2) should be set to magenta-full-ambient. 329 // All three verts share the same color so barycentric interp gives 330 // the same value at every covered pixel; alpha multiplier is 255/255 331 // = 1 so RGB passes through untouched. Output alpha is force-set 332 // to 255 (opaque) by the rasterizer. 333 if nx_raster_pixel(fb, w, 2, 2) != NX_MAGIC_4294901695 { return __syscall(93, 10, 0, 0, 0, 0, 0) } 334 if nx_raster_pixel(fb, w, 10, 2) != NX_MAGIC_4294901695 { return __syscall(93, 11, 0, 0, 0, 0, 0) } 335 if nx_raster_pixel(fb, w, 6, 6) != NX_MAGIC_4294901695 { return __syscall(93, 12, 0, 0, 0, 0, 0) } 336 if nx_raster_pixel(fb, w, 6, 3) != NX_MAGIC_4294901695 { return __syscall(93, 13, 0, 0, 0, 0, 0) } 337 // Outside pixel (0, 0) should still be background. 338 if nx_raster_pixel(fb, w, 0, 0) != 0 { return __syscall(93, 14, 0, 0, 0, 0, 0) } 339 // Outside pixel (15, 7) untouched. 340 if nx_raster_pixel(fb, w, 15, 7) != 0 { return __syscall(93, 15, 0, 0, 0, 0, 0) } 341 // Pixel just-outside triangle below the apex (6, 7) untouched. 342 if nx_raster_pixel(fb, w, 6, 7) != 0 { return __syscall(93, 16, 0, 0, 0, 0, 0) } 343 344 // T4: Z-buffer was updated for inside pixels. 345 if nx_raster_depth(zb, w, 6, 3) != NX_RASTER_Q { return __syscall(93, 20, 0, 0, 0, 0, 0) } 346 // And untouched for outside pixels. 347 if nx_raster_depth(zb, w, 0, 0) != NX_RASTER_Z_FAR { return __syscall(93, 21, 0, 0, 0, 0, 0) } 348 349 // T5: A second, closer triangle overwrites pixel. Z = 0 (very near). 350 // pack(r=0,g=255,b=255,a=255) = 0 + 255*256 + 255*65536 + 255*16777216 351 // = 4294967040 (0xFFFFFF00 cyan, full ambient). 352 let tri2: *i64 = (sys_mmap(NX_RASTER_TRI_STRIDE * NX_SIZEOF_NX_INT)) as *i64 353 tri2[0] = 4; tri2[1] = 3; tri2[2] = 0; tri2[3] = NX_MAGIC_4294967040 354 tri2[4] = 8; tri2[5] = 3; tri2[6] = 0; tri2[7] = NX_MAGIC_4294967040 355 tri2[8] = 6; tri2[9] = 5; tri2[10]= 0; tri2[11]= NX_MAGIC_4294967040 356 nx_raster_triangle(fb, zb, w, h, tri2) 357 if nx_raster_pixel(fb, w, 6, 4) != NX_MAGIC_4294967040 { return __syscall(93, 30, 0, 0, 0, 0, 0) } 358 if nx_raster_depth(zb, w, 6, 4) != 0 { return __syscall(93, 31, 0, 0, 0, 0, 0) } 359 360 // T6: A FARTHER third triangle does NOT overwrite the cyan pixel. 361 // Red full ambient = pack(255,0,0,255) = 4278190335 (0xFF0000FF). 362 let tri3: *i64 = (sys_mmap(NX_RASTER_TRI_STRIDE * NX_SIZEOF_NX_INT)) as *i64 363 tri3[0] = 4; tri3[1] = 3; tri3[2] = 2 * NX_RASTER_Q; tri3[3] = NX_MAGIC_4278190335 364 tri3[4] = 8; tri3[5] = 3; tri3[6] = 2 * NX_RASTER_Q; tri3[7] = NX_MAGIC_4278190335 365 tri3[8] = 6; tri3[9] = 5; tri3[10]= 2 * NX_RASTER_Q; tri3[11]= NX_MAGIC_4278190335 366 nx_raster_triangle(fb, zb, w, h, tri3) 367 if nx_raster_pixel(fb, w, 6, 4) != NX_MAGIC_4294967040 { return __syscall(93, 40, 0, 0, 0, 0, 0) } 368 if nx_raster_depth(zb, w, 6, 4) != 0 { return __syscall(93, 41, 0, 0, 0, 0, 0) } 369 370 // T7: Degenerate (zero-area) triangle is a no-op. 371 let tri_d: *i64 = (sys_mmap(NX_RASTER_TRI_STRIDE * NX_SIZEOF_NX_INT)) as *i64 372 tri_d[0] = 0; tri_d[1] = 0; tri_d[2] = NX_RASTER_Q; tri_d[3] = NX_MAGIC_11111 373 tri_d[4] = 0; tri_d[5] = 0; tri_d[6] = NX_RASTER_Q; tri_d[7] = NX_MAGIC_11111 374 tri_d[8] = 0; tri_d[9] = 0; tri_d[10]= NX_RASTER_Q; tri_d[11]= NX_MAGIC_11111 375 let before: nx_int = nx_raster_pixel(fb, w, 0, 0) 376 nx_raster_triangle(fb, zb, w, h, tri_d) 377 let after: nx_int = nx_raster_pixel(fb, w, 0, 0) 378 if before != after { return __syscall(93, 50, 0, 0, 0, 0, 0) } 379 380 // T8: Out-of-viewport triangle clipped (no crash, no writes). 381 let tri_oob: *i64 = (sys_mmap(NX_RASTER_TRI_STRIDE * NX_SIZEOF_NX_INT)) as *i64 382 tri_oob[0] = 100; tri_oob[1] = 100; tri_oob[2] = 0; tri_oob[3] = NX_MAGIC_22222 383 tri_oob[4] = 200; tri_oob[5] = 100; tri_oob[6] = 0; tri_oob[7] = NX_MAGIC_22222 384 tri_oob[8] = 150; tri_oob[9] = 150; tri_oob[10]= 0; tri_oob[11]= NX_MAGIC_22222 385 nx_raster_triangle(fb, zb, w, h, tri_oob) 386 // Sanity: corner untouched. 387 if nx_raster_pixel(fb, w, 15, 7) != 0 { return __syscall(93, 60, 0, 0, 0, 0, 0) } 388 389 return 0 390}