nx_lighting_quality.nx source
↩ module page · 223 lines · 9118 B
1// nx_lighting_quality.nx -- lighting composition grader.
2//
3// Per honest audit 2026-05-16: lighting quality was unmeasured. This
4// primitive grades a scene's lighting setup on 5 axes adapted from
5// cinematography conventions (three-point lighting + time-of-day
6// appropriateness).
7//
8// AXES (Q14 [0, Q]; higher = better):
9//
10// 0. KEY_FILL_RATIO: key/fill light ratio. Target [2x, 4x] (the
11// classic "1.5-stop key" cinematography rule); outside drops linearly.
12// 1. RIM_PRESENCE: rim_intensity / key_intensity; target [0.3, 0.8].
13// Rim light separates subject from background.
14// 2. SHADOW_DENSITY: 1 - (min_visible / max_visible) brightness range.
15// Target [0.5, 0.85] (high contrast but not crushed blacks).
16// 3. TIME_OF_DAY_APPROPRIATE: sun_zenith_q14 within reasonable bounds
17// for the lighting setup. Penalty for "noon sun with deep shadows"
18// contradictions.
19// 4. COLOR_TEMPERATURE: warm/cool key vs fill complement (Q14
20// representation of K_key / K_fill ratio; target [0.9, 1.6] for
21// natural daylight or [0.6, 1.0] for sunset).
22//
23// EMITS LAYER_VERDICT (kind = NX_LAYER_KIND_COLOR -- closest existing
24// kind; lighting/color belong together).
25//
26// genealogy_id: lowell_2000_cinematography + nathan_1999_film_lighting +
27// three_point_lighting_canon
28// lineage_id: nx_lighting_quality_5axis_v1
29
30// nx_safety_envelope:
31// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
32// sil_target: SIL1
33// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
34// verdict: NOT_YET_EVALUATED
35
36import "nx_syscalls.nx"
37import "nx_tier.nx"
38import "nx_layer_verdict.nx"
39const NX_MAGIC_5500: i64 = 5500
40const NX_MAGIC_5000: i64 = 5000
41
42const NX_LQ_Q: nx_int = 16384
43
44const NX_LQ_AXIS_KEY_FILL: nx_int = 0
45const NX_LQ_AXIS_RIM_PRESENCE: nx_int = 1
46const NX_LQ_AXIS_SHADOW_DENSITY: nx_int = 2
47const NX_LQ_AXIS_TIME_OF_DAY: nx_int = 3
48const NX_LQ_AXIS_COLOR_TEMP: nx_int = 4
49const NX_LQ_AXIS_COUNT: nx_int = 5
50
51func _lq_band_score(val: nx_int, lo_q: nx_int, hi_q: nx_int) -> nx_int {
52 let q: nx_int = NX_LQ_Q
53 if val < 0 { return 0 }
54 if val < lo_q {
55 if lo_q > 0 { return (val * q) / lo_q }
56 return 0
57 }
58 if val <= hi_q { return q }
59 let span: nx_int = hi_q - lo_q
60 if span <= 0 { return 0 }
61 let excess: nx_int = val - hi_q
62 var s: nx_int = q - (excess * q) / span
63 if s < 0 { s = 0 }
64 return s
65}
66
67// ===== Lighting record (8 i64) =====================================
68// Caller fills with intensities + temperatures from their renderer.
69// All Q14 unless noted.
70// rec[0] = key_intensity
71// rec[1] = fill_intensity
72// rec[2] = rim_intensity
73// rec[3] = ambient_intensity (used in shadow_density)
74// rec[4] = sun_zenith_q14 (0 = directly overhead, Q = horizon)
75// rec[5] = key_color_temp_k Q14 Kelvin / 1000 (e.g. 5500 K -> 5500*Q/1000)
76// rec[6] = fill_color_temp_k
77// rec[7] = reserved
78const NX_LIGHTING_REC_STRIDE: nx_int = 8
79const NX_LIGHTING_OFF_KEY: nx_int = 0
80const NX_LIGHTING_OFF_FILL: nx_int = 1
81const NX_LIGHTING_OFF_RIM: nx_int = 2
82const NX_LIGHTING_OFF_AMBIENT: nx_int = 3
83const NX_LIGHTING_OFF_SUN_ZENITH: nx_int = 4
84const NX_LIGHTING_OFF_KEY_TEMP: nx_int = 5
85const NX_LIGHTING_OFF_FILL_TEMP: nx_int = 6
86
87// ===== Axes ========================================================
88func _lq_key_fill_q14(rec: *i64) -> nx_int {
89 let q: nx_int = NX_LQ_Q
90 let k: nx_int = rec[NX_LIGHTING_OFF_KEY]
91 let f: nx_int = rec[NX_LIGHTING_OFF_FILL]
92 if f <= 0 { return 0 }
93 let ratio_q: nx_int = (k * q) / f
94 return _lq_band_score(ratio_q, q * 2, q * 4)
95}
96
97func _lq_rim_q14(rec: *i64) -> nx_int {
98 let q: nx_int = NX_LQ_Q
99 let k: nx_int = rec[NX_LIGHTING_OFF_KEY]
100 let r: nx_int = rec[NX_LIGHTING_OFF_RIM]
101 if k <= 0 { return 0 }
102 let ratio_q: nx_int = (r * q) / k
103 return _lq_band_score(ratio_q, q * 3 / 10, q * 8 / 10)
104}
105
106func _lq_shadow_density_q14(rec: *i64) -> nx_int {
107 let q: nx_int = NX_LQ_Q
108 let k: nx_int = rec[NX_LIGHTING_OFF_KEY]
109 let a: nx_int = rec[NX_LIGHTING_OFF_AMBIENT]
110 if k <= 0 { return 0 }
111 // Shadow density = 1 - ambient/key (the fraction of the scene NOT
112 // covered by ambient fill).
113 let ambient_frac: nx_int = (a * q) / k
114 var density: nx_int = q - ambient_frac
115 if density < 0 { density = 0 }
116 if density > q { density = q }
117 return _lq_band_score(density, q / 2, q * 85 / 100)
118}
119
120func _lq_time_of_day_q14(rec: *i64) -> nx_int {
121 let q: nx_int = NX_LQ_Q
122 let sun_z: nx_int = rec[NX_LIGHTING_OFF_SUN_ZENITH]
123 // Best lighting at golden hour (sun_z ~ 0.75Q) and worst at noon
124 // (sun_z = 0) with no shadows. Bell curve peak at 0.75Q with
125 // tolerance band [0.5Q, 0.95Q] for cinematic angles.
126 if sun_z < 0 { return 0 }
127 if sun_z > q { return 0 }
128 let target: nx_int = q * 75 / 100
129 var dev: nx_int = sun_z - target
130 if dev < 0 { dev = 0 - dev }
131 // Score = max(0, 1 - 2 * dev). At dev=0: Q; at dev=Q/2: 0.
132 var s: nx_int = q - dev * 2
133 if s < 0 { s = 0 }
134 return s
135}
136
137func _lq_color_temp_q14(rec: *i64) -> nx_int {
138 let q: nx_int = NX_LQ_Q
139 let k_temp: nx_int = rec[NX_LIGHTING_OFF_KEY_TEMP]
140 let f_temp: nx_int = rec[NX_LIGHTING_OFF_FILL_TEMP]
141 if f_temp <= 0 { return 0 }
142 let ratio_q: nx_int = (k_temp * q) / f_temp
143 // Two acceptable bands: natural daylight key/fill [0.9, 1.6] OR
144 // sunset key warmer than fill [0.6, 1.0]. Score = max(band1, band2).
145 let s1: nx_int = _lq_band_score(ratio_q, q * 9 / 10, q * 16 / 10)
146 let s2: nx_int = _lq_band_score(ratio_q, q * 6 / 10, q)
147 if s1 > s2 { return s1 }
148 return s2
149}
150
151// ===== Public: lighting grader =====================================
152func nx_lighting_quality_grade(rec: *i64, out_verdict: *i64) {
153 let kf: nx_int = _lq_key_fill_q14(rec)
154 let rp: nx_int = _lq_rim_q14(rec)
155 let sd: nx_int = _lq_shadow_density_q14(rec)
156 let td: nx_int = _lq_time_of_day_q14(rec)
157 let ct: nx_int = _lq_color_temp_q14(rec)
158 nx_layer_verdict_init(out_verdict, NX_LAYER_KIND_COLOR,
159 NX_LQ_AXIS_COUNT, NX_LAYER_REFINE_IMPROVE_READ)
160 out_verdict[NX_LV_OFF_AXIS_0 + NX_LQ_AXIS_KEY_FILL] = kf
161 out_verdict[NX_LV_OFF_AXIS_0 + NX_LQ_AXIS_RIM_PRESENCE] = rp
162 out_verdict[NX_LV_OFF_AXIS_0 + NX_LQ_AXIS_SHADOW_DENSITY] = sd
163 out_verdict[NX_LV_OFF_AXIS_0 + NX_LQ_AXIS_TIME_OF_DAY] = td
164 out_verdict[NX_LV_OFF_AXIS_0 + NX_LQ_AXIS_COLOR_TEMP] = ct
165 nx_layer_verdict_finalize(out_verdict)
166}
167
168// ===== Self-test ====================================================
169func main() -> i64 {
170 let q: nx_int = NX_LQ_Q
171 let verdict: *i64 = (sys_mmap(NX_LV_STRIDE * NX_SIZEOF_NX_INT)) as *i64
172 let rec: *i64 = (sys_mmap(NX_LIGHTING_REC_STRIDE * NX_SIZEOF_NX_INT)) as *i64
173
174 // T1: Classic three-point lighting at golden hour, daylight temps.
175 rec[NX_LIGHTING_OFF_KEY] = 3 * q // key
176 rec[NX_LIGHTING_OFF_FILL] = q // fill (3:1 ratio)
177 rec[NX_LIGHTING_OFF_RIM] = q * 5 / 10 // rim 50% of key
178 rec[NX_LIGHTING_OFF_AMBIENT] = q * 3 / 10 // ambient 30% of key
179 rec[NX_LIGHTING_OFF_SUN_ZENITH] = q * 75 / 100 // golden hour
180 rec[NX_LIGHTING_OFF_KEY_TEMP] = q * NX_MAGIC_5500 / 1000 // 5500K daylight
181 rec[NX_LIGHTING_OFF_FILL_TEMP] = q * NX_MAGIC_5000 / 1000 // 5000K (ratio 1.1)
182 nx_lighting_quality_grade(rec, verdict)
183 if verdict[NX_LV_OFF_KIND] != NX_LAYER_KIND_COLOR {
184 return __syscall(93, 1, 0, 0, 0, 0, 0)
185 }
186 if verdict[NX_LV_OFF_GRADE] < NX_LV_GRADE_B {
187 return __syscall(93, 2, 0, 0, 0, 0, 0)
188 }
189
190 // T2: Flat lighting (key = fill = 1) -> key_fill axis = 0.
191 rec[NX_LIGHTING_OFF_KEY] = q
192 rec[NX_LIGHTING_OFF_FILL] = q
193 nx_lighting_quality_grade(rec, verdict)
194 let kf: nx_int = verdict[NX_LV_OFF_AXIS_0 + NX_LQ_AXIS_KEY_FILL]
195 // ratio = Q, lo_q = 2Q, ramps linearly so kf = Q/2.
196 if kf * 10 >= q * 6 { return __syscall(93, 10, 0, 0, 0, 0, 0) }
197
198 // T3: No rim light -> rim axis = 0.
199 rec[NX_LIGHTING_OFF_KEY] = 3 * q
200 rec[NX_LIGHTING_OFF_RIM] = 0
201 nx_lighting_quality_grade(rec, verdict)
202 if verdict[NX_LV_OFF_AXIS_0 + NX_LQ_AXIS_RIM_PRESENCE] != 0 {
203 return __syscall(93, 20, 0, 0, 0, 0, 0)
204 }
205
206 // T4: Noon lighting (sun_zenith = 0) -> time_of_day axis low
207 // (golden hour target = 0.75Q).
208 rec[NX_LIGHTING_OFF_SUN_ZENITH] = 0
209 nx_lighting_quality_grade(rec, verdict)
210 let td: nx_int = verdict[NX_LV_OFF_AXIS_0 + NX_LQ_AXIS_TIME_OF_DAY]
211 // dev from 0.75Q = 0.75Q; score = q - 2*0.75q = -0.5q -> 0.
212 if td != 0 { return __syscall(93, 30, 0, 0, 0, 0, 0) }
213
214 // T5: Color temp matched -> daylight band Q.
215 rec[NX_LIGHTING_OFF_KEY_TEMP] = q * NX_MAGIC_5500 / 1000
216 rec[NX_LIGHTING_OFF_FILL_TEMP] = q * NX_MAGIC_5500 / 1000 // ratio 1.0
217 nx_lighting_quality_grade(rec, verdict)
218 if verdict[NX_LV_OFF_AXIS_0 + NX_LQ_AXIS_COLOR_TEMP] != q {
219 return __syscall(93, 40, 0, 0, 0, 0, 0)
220 }
221
222 return 0
223}