code wiki / (root) / nx_vertex_pipeline.nx

nx_vertex_pipeline.nx source

↩ module page · 280 lines · 12646 B

1// nx_vertex_pipeline.nx -- world-space vertex -> screen-space vertex. 2// 3// The missing glue between mesh-emitter output and rasterizer input. 4// nx_voxel_mesh (game-engine) emits world-space vertices in Q14. 5// nx_raster_triangle (this repo) consumes screen-space vertices in 6// pixel coords + Q14 depth. This primitive bridges them: applies an 7// MVP matrix, perspective divide, viewport mapping. 8// 9// Generic substrate -- any 3D pipeline (CAD viewer, scientific 10// visualisation, voxel game, raymarched fractal-explorer) needs 11// this transform. Lives in nishi-core per the placement cardinal. 12// 13// Vertex layout in both input and output (matches nx_voxel_mesh + 14// nx_raster_triangle exactly so the buffers compose directly): 15// verts[i*4 + 0] = x in Q14 16// verts[i*4 + 1] = y in Q14 17// verts[i*4 + 2] = z in Q14 18// verts[i*4 + 3] = packed_color (u32 RGBA in low 32 bits) 19// 20// On output: 21// x_pixel = (ndc_x + 1) * viewport_w / 2 22// y_pixel = (1 - ndc_y) * viewport_h / 2 (Y flipped for screen) 23// z_depth = (ndc_z + 1) * Q / 2 (Q14, in [0, Q]) 24// color unchanged (per-vertex passthrough) 25// 26// Perspective-divide guard: vertices behind the camera (w <= 0) get 27// flagged via out-of-screen pixel coords (-1, -1) so the rasterizer's 28// existing bounding-box clip silently drops them. Per cardinal 29// `feedback-honest-perf-verdict`, a proper near-plane clip is the v2 30// follow-up (currently named in the loss audit below). 31// 32// Loss audit: 33// - Perspective divide is integer-division of two Q14 values to a 34// Q14 result. Loses 14 fractional bits per step. Acceptable for 35// voxel-block-aligned geometry where pixel-edge precision is 36// chunky by design. 37// - Viewport map uses integer division at the final stage; sub-pixel 38// centroid drift up to 0.5 pixel. 39// - W <= 0 vertices are coarse-dropped (no Sutherland-Hodgman clip). 40// Triangles spanning the near plane will have wrong rasterization. 41// V2 queues nx_clip_sutherland_hodgman primitive. 42// 43// genealogy_id: blinn_1968_screen_coord + sutherland_hodgman_1974_clip + 44// akenine_moller_2018_rtr_chapter_4 45// lineage_id: nx_vertex_pipeline_mvp_q14_v1 46 47// nx_safety_envelope: 48// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 49// sil_target: SIL1 50// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 51// verdict: NOT_YET_EVALUATED 52 53import "nx_syscalls.nx" 54import "nx_tier.nx" 55 56// ===== Q14 ========================================================== 57const NX_VP_Q: nx_int = 16384 58 59// Vertex stride in i64 (matches mesh + raster). 60const NX_VP_VERT_STRIDE: nx_int = 4 61 62// Vertex field offsets. 63const NX_VP_OFF_X: nx_int = 0 64const NX_VP_OFF_Y: nx_int = 1 65const NX_VP_OFF_Z: nx_int = 2 66const NX_VP_OFF_COLOR: nx_int = 3 67 68// "Off-screen" sentinel for behind-camera vertices. Pixel coord 69// well outside any reasonable viewport so the rasterizer's existing 70// bounding-box clip drops them silently. 71const NX_VP_OFFSCREEN_X: nx_int = -100000 72const NX_VP_OFFSCREEN_Y: nx_int = -100000 73 74// ===== Internal: 4x4 * vec4 with per-row w-divide path ============= 75// Stand-alone version (we don't want to import nx_camera_q14 here -- 76// the matrix-mul format is identical, but keeping this primitive 77// independent keeps the dependency graph simple). Operates on a 78// vec4 in (x, y, z, w) order. 79// 80// Result is the 4-component clip-space vector BEFORE perspective 81// divide. 82func _vp_mat4_mul_vec4(m: *i64, x: nx_int, y: nx_int, z: nx_int, w: nx_int, out: *i64) { 83 out[0] = (m[0] * x + m[1] * y + m[2] * z + m[3] * w) / NX_VP_Q 84 out[1] = (m[4] * x + m[5] * y + m[6] * z + m[7] * w) / NX_VP_Q 85 out[2] = (m[8] * x + m[9] * y + m[10] * z + m[11] * w) / NX_VP_Q 86 out[3] = (m[12] * x + m[13] * y + m[14] * z + m[15] * w) / NX_VP_Q 87} 88 89// ===== Public: transform a single vertex through MVP =============== 90// Writes (x_pixel, y_pixel, z_depth, color) to out_v4 (4 i64). 91// 92// Behind-camera vertices (w <= 0 after MVP) emit the off-screen 93// sentinel so the rasterizer drops them. 94func nx_vertex_pipeline_transform_one( 95 mvp: *i64, 96 viewport_w: nx_int, 97 viewport_h: nx_int, 98 in_x: nx_int, 99 in_y: nx_int, 100 in_z: nx_int, 101 in_color: nx_int, 102 out_v4: *i64 103) { 104 let clip: *i64 = (sys_mmap(4 * NX_SIZEOF_NX_INT)) as *i64 105 // Input vertex is treated as homogeneous (x, y, z, w=Q). 106 _vp_mat4_mul_vec4(mvp, in_x, in_y, in_z, NX_VP_Q, clip) 107 let cx: nx_int = clip[0] 108 let cy: nx_int = clip[1] 109 let cz: nx_int = clip[2] 110 let cw: nx_int = clip[3] 111 112 // Behind-camera guard. 113 if cw <= 0 { 114 out_v4[NX_VP_OFF_X] = NX_VP_OFFSCREEN_X 115 out_v4[NX_VP_OFF_Y] = NX_VP_OFFSCREEN_Y 116 out_v4[NX_VP_OFF_Z] = NX_VP_Q 117 out_v4[NX_VP_OFF_COLOR] = in_color 118 return 119 } 120 121 // Perspective divide: ndc = clip / w (each component, Q14). 122 let ndc_x: nx_int = cx * NX_VP_Q / cw 123 let ndc_y: nx_int = cy * NX_VP_Q / cw 124 let ndc_z: nx_int = cz * NX_VP_Q / cw 125 126 // Viewport map. NDC range is [-Q, +Q] for in-frustum points. 127 // x_pixel = (ndc_x + Q) * viewport_w / (2*Q) 128 // y_pixel = (Q - ndc_y) * viewport_h / (2*Q) (Y flip) 129 // z_depth = (ndc_z + Q) / 2 (Q14, [0, Q]) 130 let x_pixel: nx_int = (ndc_x + NX_VP_Q) * viewport_w / (2 * NX_VP_Q) 131 let y_pixel: nx_int = (NX_VP_Q - ndc_y) * viewport_h / (2 * NX_VP_Q) 132 let z_depth: nx_int = (ndc_z + NX_VP_Q) / 2 133 134 out_v4[NX_VP_OFF_X] = x_pixel 135 out_v4[NX_VP_OFF_Y] = y_pixel 136 out_v4[NX_VP_OFF_Z] = z_depth 137 out_v4[NX_VP_OFF_COLOR] = in_color 138} 139 140// ===== Public: transform a vertex buffer =========================== 141// Reads N vertices from in_verts (mesh-format, 4 i64 each); writes N 142// vertices to out_verts (raster-format, 4 i64 each). Returns N 143// (every vertex is written; off-screen ones use the sentinel). 144// 145// in_verts and out_verts may not alias. 146func nx_vertex_pipeline_transform( 147 in_verts: *i64, 148 n_verts: nx_int, 149 mvp: *i64, 150 viewport_w: nx_int, 151 viewport_h: nx_int, 152 out_verts: *i64 153) -> nx_int { 154 var i: nx_int = 0 155 while i < n_verts { 156 let base: nx_int = i * NX_VP_VERT_STRIDE 157 let x: nx_int = in_verts[base + NX_VP_OFF_X] 158 let y: nx_int = in_verts[base + NX_VP_OFF_Y] 159 let z: nx_int = in_verts[base + NX_VP_OFF_Z] 160 let c: nx_int = in_verts[base + NX_VP_OFF_COLOR] 161 let out_ptr: *i64 = (out_verts as i64 + base * NX_SIZEOF_NX_INT) as *i64 162 nx_vertex_pipeline_transform_one(mvp, viewport_w, viewport_h, x, y, z, c, out_ptr) 163 i = i + 1 164 } 165 return n_verts 166} 167 168// ===== Self-test ==================================================== 169func main() -> i64 { 170 let q: nx_int = NX_VP_Q 171 172 // Identity MVP (so transform is identity except for the projection 173 // viewport map). 174 let identity: *i64 = (sys_mmap(16 * NX_SIZEOF_NX_INT)) as *i64 175 var i: nx_int = 0 176 while i < 16 { identity[i] = 0; i = i + 1 } 177 identity[0] = q 178 identity[5] = q 179 identity[10] = q 180 identity[15] = q 181 182 let w: nx_int = 640 183 let h: nx_int = 360 184 185 let out: *i64 = (sys_mmap(NX_VP_VERT_STRIDE * NX_SIZEOF_NX_INT)) as *i64 186 187 // T1: Origin (0,0,0,w=Q) under identity -> NDC (0,0,0) -> centre 188 // of viewport (320, 180). Depth (0+Q)/2 = Q/2 = 8192. 189 nx_vertex_pipeline_transform_one(identity, w, h, 0, 0, 0, 0xFF0000FF, out) 190 if out[NX_VP_OFF_X] != 320 { return __syscall(93, 1, 0, 0, 0, 0, 0) } 191 if out[NX_VP_OFF_Y] != 180 { return __syscall(93, 2, 0, 0, 0, 0, 0) } 192 if out[NX_VP_OFF_Z] != q / 2 { return __syscall(93, 3, 0, 0, 0, 0, 0) } 193 if out[NX_VP_OFF_COLOR] != 0xFF0000FF { return __syscall(93, 4, 0, 0, 0, 0, 0) } 194 195 // T2: NDC right edge (x=Q, y=0, z=0) maps to (640, 180). Use 196 // input (Q, 0, 0); identity perspective treats w=Q, so ndc = clip. 197 nx_vertex_pipeline_transform_one(identity, w, h, q, 0, 0, 0, out) 198 if out[NX_VP_OFF_X] != w { return __syscall(93, 10, 0, 0, 0, 0, 0) } 199 if out[NX_VP_OFF_Y] != 180 { return __syscall(93, 11, 0, 0, 0, 0, 0) } 200 201 // T3: NDC top (x=0, y=Q, z=0) maps to (320, 0) due to Y flip. 202 nx_vertex_pipeline_transform_one(identity, w, h, 0, q, 0, 0, out) 203 if out[NX_VP_OFF_X] != 320 { return __syscall(93, 20, 0, 0, 0, 0, 0) } 204 if out[NX_VP_OFF_Y] != 0 { return __syscall(93, 21, 0, 0, 0, 0, 0) } 205 206 // T4: NDC bottom (x=0, y=-Q, z=0) maps to (320, 360). 207 nx_vertex_pipeline_transform_one(identity, w, h, 0, 0 - q, 0, 0, out) 208 if out[NX_VP_OFF_X] != 320 { return __syscall(93, 30, 0, 0, 0, 0, 0) } 209 if out[NX_VP_OFF_Y] != h { return __syscall(93, 31, 0, 0, 0, 0, 0) } 210 211 // T5: NDC corner (-Q, -Q, -Q) -> (0, 360) with depth 0 (near). 212 nx_vertex_pipeline_transform_one(identity, w, h, 0 - q, 0 - q, 0 - q, 0, out) 213 if out[NX_VP_OFF_X] != 0 { return __syscall(93, 40, 0, 0, 0, 0, 0) } 214 if out[NX_VP_OFF_Y] != h { return __syscall(93, 41, 0, 0, 0, 0, 0) } 215 if out[NX_VP_OFF_Z] != 0 { return __syscall(93, 42, 0, 0, 0, 0, 0) } 216 // NDC corner (+Q, +Q, +Q) -> (640, 0) with depth Q (far). 217 nx_vertex_pipeline_transform_one(identity, w, h, q, q, q, 0, out) 218 if out[NX_VP_OFF_X] != w { return __syscall(93, 43, 0, 0, 0, 0, 0) } 219 if out[NX_VP_OFF_Y] != 0 { return __syscall(93, 44, 0, 0, 0, 0, 0) } 220 if out[NX_VP_OFF_Z] != q { return __syscall(93, 45, 0, 0, 0, 0, 0) } 221 222 // T6: Behind-camera vertex (using a matrix that negates w). 223 // Construct a matrix where the w-row is (0, 0, 0, -Q) -- maps 224 // homogeneous w=Q to clip-w = -Q, which is behind the camera. 225 let m_neg_w: *i64 = (sys_mmap(16 * NX_SIZEOF_NX_INT)) as *i64 226 var j: nx_int = 0 227 while j < 16 { m_neg_w[j] = 0; j = j + 1 } 228 m_neg_w[0] = q 229 m_neg_w[5] = q 230 m_neg_w[10] = q 231 m_neg_w[15] = 0 - q 232 nx_vertex_pipeline_transform_one(m_neg_w, w, h, 0, 0, 0, 0xABCDEF01, out) 233 if out[NX_VP_OFF_X] != NX_VP_OFFSCREEN_X { return __syscall(93, 50, 0, 0, 0, 0, 0) } 234 if out[NX_VP_OFF_Y] != NX_VP_OFFSCREEN_Y { return __syscall(93, 51, 0, 0, 0, 0, 0) } 235 // Color preserved even for off-screen. 236 if out[NX_VP_OFF_COLOR] != 0xABCDEF01 { return __syscall(93, 52, 0, 0, 0, 0, 0) } 237 238 // T7: Buffer transform. Build 3 input vertices (a triangle), 239 // call the buffer entry, check each output matches the per-vertex 240 // entry. 241 let in_buf: *i64 = (sys_mmap(3 * NX_VP_VERT_STRIDE * NX_SIZEOF_NX_INT)) as *i64 242 let out_buf: *i64 = (sys_mmap(3 * NX_VP_VERT_STRIDE * NX_SIZEOF_NX_INT)) as *i64 243 // v0 = origin, v1 = right, v2 = top. 244 in_buf[0] = 0; in_buf[1] = 0; in_buf[2] = 0; in_buf[3] = 0x11111111 245 in_buf[4] = q; in_buf[5] = 0; in_buf[6] = 0; in_buf[7] = 0x22222222 246 in_buf[8] = 0; in_buf[9] = q; in_buf[10] = 0; in_buf[11] = 0x33333333 247 let n: nx_int = nx_vertex_pipeline_transform(in_buf, 3, identity, w, h, out_buf) 248 if n != 3 { return __syscall(93, 60, 0, 0, 0, 0, 0) } 249 // v0 -> (320, 180, Q/2, 0x11111111) 250 if out_buf[0] != 320 { return __syscall(93, 61, 0, 0, 0, 0, 0) } 251 if out_buf[1] != 180 { return __syscall(93, 62, 0, 0, 0, 0, 0) } 252 if out_buf[3] != 0x11111111 { return __syscall(93, 63, 0, 0, 0, 0, 0) } 253 // v1 -> (640, 180, ..., 0x22222222) 254 if out_buf[4] != 640 { return __syscall(93, 64, 0, 0, 0, 0, 0) } 255 if out_buf[5] != 180 { return __syscall(93, 65, 0, 0, 0, 0, 0) } 256 if out_buf[7] != 0x22222222 { return __syscall(93, 66, 0, 0, 0, 0, 0) } 257 // v2 -> (320, 0, ..., 0x33333333) 258 if out_buf[8] != 320 { return __syscall(93, 67, 0, 0, 0, 0, 0) } 259 if out_buf[9] != 0 { return __syscall(93, 68, 0, 0, 0, 0, 0) } 260 if out_buf[11] != 0x33333333 { return __syscall(93, 69, 0, 0, 0, 0, 0) } 261 262 // T8: Perspective divide. Vertex at clip = (Q, 0, 0, 2Q) -> 263 // ndc = (0.5, 0, 0). Construct a matrix whose only effect is to 264 // double w: w-row = (0, 0, 0, 2Q). With input vertex (Q,0,0,1): 265 // clip = (Q, 0, 0, 2Q) -> ndc.x = 0.5 266 // x_pixel = (0.5*Q + Q) * w / (2Q) = (3Q/2) * w / (2Q) = 3w/4 = 480 267 let m_double_w: *i64 = (sys_mmap(16 * NX_SIZEOF_NX_INT)) as *i64 268 var k: nx_int = 0 269 while k < 16 { m_double_w[k] = 0; k = k + 1 } 270 m_double_w[0] = q 271 m_double_w[5] = q 272 m_double_w[10] = q 273 m_double_w[15] = 2 * q 274 nx_vertex_pipeline_transform_one(m_double_w, w, h, q, 0, 0, 0, out) 275 // Allow +/- 1 pixel for integer rounding. 276 if out[NX_VP_OFF_X] < 479 { return __syscall(93, 80, 0, 0, 0, 0, 0) } 277 if out[NX_VP_OFF_X] > 481 { return __syscall(93, 81, 0, 0, 0, 0, 0) } 278 279 return 0 280}