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}