nx_view_transform_lib.nx source
↩ module page · 258 lines · 12494 B
1// nx_view_transform_lib.nx -- THE ONE OWNER OF THE ESTATE VIEW TRANSFORM (GR23, 2026-08-28).
2// license_tier: ORIGINAL No hw writes (Rule 26).
3//
4// WHY THIS EXISTS. The shipped beach page carries the value-structure grade INLINED FOUR TIMES
5// (world FS, figures, hair, WGSL) as a hand-typed .94*(x/.94)^1.45, derived offline in Python and
6// reproducible by nothing. Operator directive 2026-08-28: "we dont want webgl2 and other 3rd party
7// unless we are recreating ourselves the capabilities". So the curve is DERIVED HERE, in NishiLang,
8// from recorded measurements, and BOTH execution surfaces are emitted from ONE generated LUT so
9// they cannot diverge. AgX and ACES are ORACLES we check ourselves against, never a dependency --
10// borrow the rulers, not the runtime. Neither appears in this file as code.
11//
12// NOTHING BELOW IS TYPED FROM A CALCULATOR. The only numbers are (a) the measurements, declared as
13// data with their provenance, and (b) values DERIVED at runtime. The exponent is computed, not
14// typed; the LUT size is searched, not chosen.
15//
16// THE MEASUREMENT (PIL over the operator own 20:19 frame vs the reference photographs, 2026-08-28):
17// saturation is NOT the gap (S mean .29 against a reference band .27-.32, lift x1.01);
18// the gaps are HUE MONOCULTURE and HIGH-KEY MIDS (V p50 .79 against a reference band .59-.66).
19//
20// THE CURVE. A p95-anchored power pivot: out = k*(x/k)^g, k the frame own p95 so the white point
21// is an exact fixed point. g is fixed by requiring the median to move a declared fraction of the way
22// from where it is to the reference band near edge:
23// m_tgt = m_src - (m_src - band_hi) * num/den
24// g = ln(m_tgt/k) / ln(m_src/k)
25// HALF-way (num/den = 1/2) is the declared choice, because sand content legitimately differs from
26// the reference scenes. MEASURED RESIDUAL: this yields g = 1.4939, while the shipped page carries a
27// hand-typed 1.45, which moves the median 45.7% -- not the "half" its own comment claims. A constant
28// that has drifted from the rule written beside it is exactly what a generator removes.
29//
30// THE APPLICATION IS LUMINANCE-SCALED, AND THAT IS THE FIX, NOT A REFINEMENT. Applying a power
31// per-channel raises saturation BY CONSTRUCTION: with r = min/max, HSV S = 1 - r becomes 1 - r^g,
32// and g > 1 forces r^g < r, so S rises for every non-grey pixel, monotonically in g. Measured on the
33// live frame the shipped per-channel form drove S mean .313 -> .411, i.e. OUT of the .27-.32 band it
34// was told saturation already sat inside. Scaling the triple by the ratio the curve applies to its
35// LUMINANCE leaves min/max untouched, so S is preserved EXACTLY, for every pixel, by construction.
36// Measured: V p50 .729 -> .616 (inside .59-.66) with S mean .313 -> .313 (unchanged). Both bands held.
37//
38// DECLARED EXPOSURE, NOT HIDDEN. The curve crushes deep shadow: over inputs 0..0.10, 26 distinct
39// 8-bit levels collapse to 10 (38.5% survive), and 5 of 256 input levels map above 1.0 and clip.
40// That is invisible on a noon beach and is where a night or interior frame will show banding. It is
41// a property of the pivot shape, stated here so no reader has to rediscover it.
42//
43// SCOPE HONESTY -- THIS ORGAN DOES NOT SATISFY GR14. GR14 wants lighting math in linear with the
44// display encode applied exactly ONCE at output. The page sqrt-encodes inside sky()/skin/hair and
45// then grades on top, so the curve still runs over display-encoded values. This generator makes the
46// curve derived, single-sourced and chroma-safe; it does NOT move the encode. GR14 stays open.
47//
48// WHO OWNS DIALECT SYNTAX -- DECLARED, NOT HIDDEN. nx_shader_ir.nx is THE estate answer to "one
49// source, N dialect backends", and this organ deliberately does NOT become a second one. What is
50// genuinely new here is the DERIVATION and the LUT; the numbers are emitted ONCE by
51// vt_emit_lut_body and vt_emit_luma_triple, so the two dialects below cannot carry different data
52// -- which is the whole of GR23 "byte-identical LUT from one generator". The three lines of dialect
53// syntax wrapped around that array are a STOPGAP: nx_shader_ir is at rung 1 (the fullscreen-triangle
54// vertex stage) and does not yet cover a fragment-stage function, so expressing vt() through the IR
55// is the NEXT RUNG and is named here rather than quietly hand-maintained forever.
56//
57// usage: nx_view_transform derive | lut | glsl | wgsl | selftest
58// exit: 0 ok | 1 refuse | 2 usage
59import "nx_syscalls.nx"
60import "nx_fixq30_lib.nx"
61
62// ---- DECLARED MEASUREMENTS (micro units; provenance in the header) ----
63const VT_ANCHOR_MICRO: i64 = 940000 // k: p95 of the operator frame -> the fixed point
64const VT_SRC_MEDIAN_MICRO: i64 = 790000 // V p50 measured on that frame
65const VT_REF_HI_MICRO: i64 = 660000 // reference band, upper edge
66const VT_REF_LO_MICRO: i64 = 590000 // reference band, lower edge
67const VT_APPROACH_NUM: i64 = 1 // move HALF of the way to the band near edge
68const VT_APPROACH_DEN: i64 = 2
69// Rec.709 luma weights, the published coefficients, as micro data
70const VT_LUMA_R_MICRO: i64 = 212600
71const VT_LUMA_G_MICRO: i64 = 715200
72const VT_LUMA_B_MICRO: i64 = 72200
73// LUT sizing tolerance: half an 8-bit code. DERIVED as a ratio, not typed as a decimal.
74const VT_CODE_LEVELS: i64 = 255
75const VT_LUT_MIN_N: i64 = 2
76const VT_LUT_MAX_N: i64 = 4096 // a search that has not converged here is being misused
77const VT_LUT_PROBES: i64 = 16 // interior probes per interval when measuring interp error
78const VT_SLOT_BYTES: i64 = 8
79const VT_RGB_SLOTS: i64 = 3
80
81func vt_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
82func vt_puts(s: *u8) -> i64 { sys_write(1, s, vt_slen(s)); return 0 }
83
84func vt_pn(v: i64) -> i64 {
85 if v == 0 { sys_write(1, "0" as *u8, 1); return 0 }
86 var m: i64 = v
87 if m < 0 { sys_write(1, "-" as *u8, 1); m = 0 - m }
88 let t: *u8 = sys_mmap(32)
89 var k: i64 = 0
90 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
91 let o: *u8 = sys_mmap(32)
92 var i: i64 = 0
93 while i < k { o[i] = t[k - 1 - i]; i = i + 1 }
94 sys_write(1, o, k)
95 return 0
96}
97
98// print a Q30 value as a fixed 6-place decimal (micro), sign-correct
99func vt_pq(x: i64) -> i64 {
100 var m: i64 = fq_to_micro(x)
101 if m < 0 { sys_write(1, "-" as *u8, 1); m = 0 - m }
102 vt_pn(m / FQ_MICRO)
103 sys_write(1, "." as *u8, 1)
104 var d: i64 = FQ_MICRO / 10
105 while d > 0 { vt_pn((m / d) % 10); d = d / 10 }
106 return 0
107}
108
109// ---- THE DERIVATION ----
110// m_tgt = m_src - (m_src - band_hi) * num/den
111func vt_target_median() -> i64 {
112 let src: i64 = fq_from_micro(VT_SRC_MEDIAN_MICRO)
113 let hi: i64 = fq_from_micro(VT_REF_HI_MICRO)
114 return src - ((src - hi) * VT_APPROACH_NUM) / VT_APPROACH_DEN
115}
116
117// g = ln(m_tgt/k) / ln(m_src/k) -- both logs are of values in (0,1), so both are negative
118func vt_exponent(ctx: *i64) -> i64 {
119 let k: i64 = fq_from_micro(VT_ANCHOR_MICRO)
120 let src: i64 = fq_from_micro(VT_SRC_MEDIAN_MICRO)
121 let tgt: i64 = vt_target_median()
122 return fq_div(fq_ln(ctx, fq_div(tgt, k)), fq_ln(ctx, fq_div(src, k)))
123}
124
125// the curve itself: out = k*(x/k)^g, clamped at unity (the top of the display range)
126func vt_curve(ctx: *i64, g: i64, x: i64) -> i64 {
127 if x <= 0 { return 0 }
128 let k: i64 = fq_from_micro(VT_ANCHOR_MICRO)
129 let y: i64 = fq_mul(k, fq_pow(ctx, fq_div(x, k), g))
130 if y > FQ_ONE { return FQ_ONE }
131 return y
132}
133
134// ---- LUT SIZE, SEARCHED NOT CHOSEN ----
135// The smallest power-of-two interval count whose worst linear-interpolation error over the whole
136// domain stays under half an 8-bit code. Curvature is highest near zero, so the search measures
137// interior probes of every interval rather than assuming where the worst case lives.
138func vt_lut_worst_err(ctx: *i64, g: i64, n: i64) -> i64 {
139 var worst: i64 = 0
140 var i: i64 = 0
141 while i < n {
142 let x0: i64 = (i * FQ_ONE) / n
143 let x1: i64 = ((i + 1) * FQ_ONE) / n
144 let y0: i64 = vt_curve(ctx, g, x0)
145 let y1: i64 = vt_curve(ctx, g, x1)
146 var s: i64 = 1
147 while s < VT_LUT_PROBES {
148 let x: i64 = x0 + ((x1 - x0) * s) / VT_LUT_PROBES
149 let lin: i64 = y0 + ((y1 - y0) * (x - x0)) / (x1 - x0)
150 var e: i64 = lin - vt_curve(ctx, g, x)
151 if e < 0 { e = 0 - e }
152 if e > worst { worst = e }
153 s = s + 1
154 }
155 i = i + 1
156 }
157 return worst
158}
159
160func vt_lut_size(ctx: *i64, g: i64) -> i64 {
161 let tol: i64 = FQ_ONE / (VT_CODE_LEVELS * FQ_TWO) // half an 8-bit code, as a Q30 ratio
162 var n: i64 = VT_LUT_MIN_N
163 while n <= VT_LUT_MAX_N {
164 if vt_lut_worst_err(ctx, g, n) < tol { return n }
165 n = n * FQ_TWO
166 }
167 return 0 // 0 = did not converge; the caller must refuse
168}
169
170func vt_lut_fill(ctx: *i64, g: i64, out: *i64, n: i64) -> i64 {
171 var i: i64 = 0
172 while i <= n { out[i] = vt_curve(ctx, g, (i * FQ_ONE) / n); i = i + 1 }
173 return 0
174}
175
176// ---- THE APPLICATION: luminance-scaled, chroma ratio preserved EXACTLY ----
177func vt_luma(r: i64, g: i64, b: i64) -> i64 {
178 return fq_mul(fq_from_micro(VT_LUMA_R_MICRO), r)
179 + fq_mul(fq_from_micro(VT_LUMA_G_MICRO), g)
180 + fq_mul(fq_from_micro(VT_LUMA_B_MICRO), b)
181}
182
183// rgb[0..2] in, rgb[0..2] out (in place). Scaling by the ratio the curve applies to luminance
184// leaves min/max -- and therefore HSV saturation -- untouched.
185func vt_apply(ctx: *i64, gx: i64, rgb: *i64) -> i64 {
186 let y: i64 = vt_luma(rgb[0], rgb[1], rgb[2])
187 if y <= 0 { return 0 }
188 let s: i64 = fq_div(vt_curve(ctx, gx, y), y)
189 var i: i64 = 0
190 while i < VT_RGB_SLOTS {
191 var v: i64 = fq_mul(rgb[i], s)
192 if v > FQ_ONE { v = FQ_ONE }
193 rgb[i] = v
194 i = i + 1
195 }
196 return 0
197}
198
199// ---- EMISSION: one LUT, two dialects, generated from the same array ----
200func vt_emit_lut_body(lut: *i64, n: i64) -> i64 {
201 var i: i64 = 0
202 while i <= n {
203 if i > 0 { vt_puts("," as *u8) }
204 vt_pq(lut[i])
205 i = i + 1
206 }
207 return 0
208}
209
210func vt_emit_luma_triple() -> i64 {
211 vt_pq(fq_from_micro(VT_LUMA_R_MICRO)); vt_puts("," as *u8)
212 vt_pq(fq_from_micro(VT_LUMA_G_MICRO)); vt_puts("," as *u8)
213 vt_pq(fq_from_micro(VT_LUMA_B_MICRO))
214 return 0
215}
216
217func vt_emit(ctx: *i64, wgsl: i64) -> i64 {
218 let g: i64 = vt_exponent(ctx)
219 let n: i64 = vt_lut_size(ctx, g)
220 if n == 0 { vt_puts("VT-REFUSE lut-search-did-not-converge\n" as *u8); return 1 }
221 let lut: *i64 = sys_mmap((n + 1) * VT_SLOT_BYTES) as *i64
222 vt_lut_fill(ctx, g, lut, n)
223 if wgsl == 1 {
224 vt_puts("const VT_N:i32=" as *u8); vt_pn(n); vt_puts(";\n" as *u8)
225 vt_puts("const VT_L=array<f32," as *u8); vt_pn(n + 1); vt_puts(">(" as *u8)
226 vt_emit_lut_body(lut, n)
227 vt_puts(");\n" as *u8)
228 vt_puts("fn vt(c:vec3f)->vec3f{let y=dot(c,vec3f(" as *u8)
229 vt_emit_luma_triple()
230 vt_puts("));if(y<=0.0){return c;}let t=clamp(y,0.0,1.0)*f32(VT_N);let i=i32(floor(t));" as *u8)
231 vt_puts("let f=t-floor(t);let a=VT_L[min(i,VT_N)];let b=VT_L[min(i+1,VT_N)];" as *u8)
232 vt_puts("return min(c*((a+(b-a)*f)/y),vec3f(1.0));}\n" as *u8)
233 return 0
234 }
235 vt_puts("const int VT_N=" as *u8); vt_pn(n); vt_puts(";\n" as *u8)
236 vt_puts("const float VT_L[" as *u8); vt_pn(n + 1); vt_puts("]=float[](" as *u8)
237 vt_emit_lut_body(lut, n)
238 vt_puts(");\n" as *u8)
239 vt_puts("vec3 vt(vec3 c){float y=dot(c,vec3(" as *u8)
240 vt_emit_luma_triple()
241 vt_puts("));if(y<=0.)return c;float t=clamp(y,0.,1.)*float(VT_N);int i=int(floor(t));" as *u8)
242 vt_puts("float f=t-floor(t);float a=VT_L[min(i,VT_N)];float b=VT_L[min(i+1,VT_N)];" as *u8)
243 vt_puts("return min(c*((a+(b-a)*f)/y),vec3(1.));}\n" as *u8)
244 return 0
245}
246
247func vt_streq(a: *u8, b: *u8) -> i64 {
248 var i: i64 = 0
249 while a[i] != (0 as u8) { if a[i] != b[i] { return 0 } i = i + 1 }
250 if b[i] != (0 as u8) { return 0 }
251 return 1
252}
253
254// SPLIT AS A LIB (2026-08-28): the CLI nx_view_transform.nx and the gate nx_view_transform_gate.nx
255// both import this file. A module carrying a main() cannot be imported (double-main), and a gate
256// that cannot import its subject is reduced to forking a deployed binary and parsing text -- so the
257// derivation lives here, where BOTH the tool and its gate call the same functions rather than two
258// copies that must agree by discipline.