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}