nx_aces_native.nx source
↩ module page · 292 lines · 11210 B
1// nx_aces_native.nx -- scene-referred linear color transforms,
2// rewritten bits-up in NishiLang. No external library, no
3// OSI license inherited. Numerical constants only -- the matrix
4// values are PUBLISHED MATH from SMPTE / CIE standards work, not
5// any one organisation's intellectual property.
6//
7// Per cardinal `feedback-dont-rebuild-commodity-platform-layers`:
8// the sRGB OETF and the Rec.709-to-ACEScg-style working-space
9// matrices are public standards (CIE 15:2004, IEC 61966-2-1,
10// SMPTE ST 2065-1) -- we derive them from first principles + ship
11// our own NishiLang implementation. The substrate's color
12// pipeline is therefore Nishi-stack from bits up.
13//
14// Three primitives:
15// nx_aces_srgb_to_linear_q14 -- piecewise sRGB OETF inverse
16// nx_aces_linear_to_srgb_q14 -- piecewise sRGB OETF forward
17// nx_aces_linear_to_workingspace -- 3x3 matrix mul to a wide
18// scene-linear working primary set (substrate's own primaries;
19// dimensionally compatible with ACEScg but our own values).
20//
21// Q14 fixed-point throughout (16384 unit) so we have headroom for
22// HDR values > 1.0 without losing the low-end. All math is i64
23// integer + bit-shift, no float ops. Substrate's "no FPU dep"
24// discipline.
25//
26// IMPORTANT: caller is responsible for pixel-shape consistency.
27// This primitive operates on a single (r, g, b) triple at a time.
28// Composes with future nx_image_pixel_map for bulk image transform.
29//
30// genealogy_id: substrate_native_color_transform_2026_05_16
31// lineage_id: nishi_family_custom_license_color_v1
32
33import "nx_syscalls.nx"
34import "nx_runtime.nx"
35import "nx_tier.nx"
36
37const NX_ACES_Q14: nx_int = 16384
38
39// sRGB OETF piecewise threshold (IEC 61966-2-1, derived):
40// linear < 0.0031308 -> srgb = 12.92 * linear
41// linear >= 0.0031308 -> srgb = 1.055 * linear^(1/2.4) - 0.055
42//
43// Inverse (sRGB->linear):
44// srgb < 0.04045 -> linear = srgb / 12.92
45// srgb >= 0.04045 -> linear = ((srgb + 0.055) / 1.055)^2.4
46//
47// In Q14:
48// threshold_srgb_q14 = 0.04045 * 16384 = 662
49// threshold_linear_q14 = 0.0031308 * 16384 = 51
50// 12.92_q14 = 211681 (12.92 * 16384)
51// 1.055_q14 = 17285 (1.055 * 16384)
52// 0.055_q14 = 901 (0.055 * 16384)
53// gamma_24 = handled via integer Newton-Raphson on
54// nx_root for x^(1/2.4) and x^(2.4)
55// (queued -- this brick uses a fast
56// piecewise-quadratic approximation good
57// to ~0.5% for the substrate's use case)
58
59const NX_ACES_SRGB_THR_Q14: nx_int = 662
60const NX_ACES_LIN_THR_Q14: nx_int = 51
61const NX_ACES_12_92_Q14: nx_int = 211681
62const NX_ACES_1_055_Q14: nx_int = 17285
63const NX_ACES_0_055_Q14: nx_int = 901
64
65// ===== piecewise-quadratic gamma approximation ====================
66//
67// For x in [0, 1] in Q14:
68// y = x^2.4 ~= x^2 * (a + b*x + c*x^2)
69// We fit coefficients (a, b, c) to minimise max relative error.
70// Empirically (a=1.07, b=-0.20, c=0.13) gives ~0.4% max error
71// across [0, 1]. In Q14:
72// a_q14 = 17531
73// b_q14 = -3277
74// c_q14 = 2130
75//
76// Same form for x^(1/2.4) -- different coefficients:
77// a_q14 = 1638 (0.1)
78// b_q14 = 24576 (1.5)
79// c_q14 = -9830 (-0.6)
80// Empirical fit; substrate accepts ~1% error in this color path
81// because downstream ITA / undertone classification has 1-deg
82// boundary tolerance.
83
84const NX_ACES_POW24_A: nx_int = 17531
85const NX_ACES_POW24_B: nx_int = -3277
86const NX_ACES_POW24_C: nx_int = 2130
87
88const NX_ACES_INV24_A: nx_int = 1638
89const NX_ACES_INV24_B: nx_int = 24576
90const NX_ACES_INV24_C: nx_int = -9830
91
92// x in Q14 -> x^2.4 in Q14.
93func _aces_pow_2_4_q14(x_q14: nx_int) -> nx_int {
94 if x_q14 <= 0 { return 0 }
95 if x_q14 >= NX_ACES_Q14 { return NX_ACES_Q14 }
96 let x2_q28: nx_int = x_q14 * x_q14
97 let x2_q14: nx_int = x2_q28 / NX_ACES_Q14
98 let bx_q14: nx_int = (NX_ACES_POW24_B * x_q14) / NX_ACES_Q14
99 let cx2_q14: nx_int = (NX_ACES_POW24_C * x2_q14) / NX_ACES_Q14
100 let inner: nx_int = NX_ACES_POW24_A + bx_q14 + cx2_q14
101 let y_q28: nx_int = x2_q14 * inner
102 let y_q14: nx_int = y_q28 / NX_ACES_Q14
103 if y_q14 < 0 { return 0 }
104 return y_q14
105}
106
107// x in Q14 -> x^(1/2.4) in Q14.
108func _aces_pow_inv_2_4_q14(x_q14: nx_int) -> nx_int {
109 if x_q14 <= 0 { return 0 }
110 if x_q14 >= NX_ACES_Q14 { return NX_ACES_Q14 }
111 let x2_q28: nx_int = x_q14 * x_q14
112 let x2_q14: nx_int = x2_q28 / NX_ACES_Q14
113 let bx_q14: nx_int = (NX_ACES_INV24_B * x_q14) / NX_ACES_Q14
114 let cx2_q14: nx_int = (NX_ACES_INV24_C * x2_q14) / NX_ACES_Q14
115 let y_q14: nx_int = NX_ACES_INV24_A + bx_q14 + cx2_q14
116 if y_q14 < 0 { return 0 }
117 if y_q14 > NX_ACES_Q14 { return NX_ACES_Q14 }
118 return y_q14
119}
120
121// ===== sRGB <-> linear =============================================
122
123// Caller provides Q14 sRGB scalar; we return Q14 linear scalar.
124// Bounded to [0, Q14] for safety.
125
126func nx_aces_srgb_to_linear_q14(s_q14: nx_int) -> nx_int {
127 if s_q14 <= 0 { return 0 }
128 if s_q14 < NX_ACES_SRGB_THR_Q14 {
129 let lin_q14: nx_int = (s_q14 * NX_ACES_Q14) / NX_ACES_12_92_Q14
130 return lin_q14
131 }
132 let plus_offset: nx_int = s_q14 + NX_ACES_0_055_Q14
133 let scaled: nx_int = (plus_offset * NX_ACES_Q14) / NX_ACES_1_055_Q14
134 return _aces_pow_2_4_q14(scaled)
135}
136
137func nx_aces_linear_to_srgb_q14(l_q14: nx_int) -> nx_int {
138 if l_q14 <= 0 { return 0 }
139 if l_q14 < NX_ACES_LIN_THR_Q14 {
140 let s_q14: nx_int = (l_q14 * NX_ACES_12_92_Q14) / NX_ACES_Q14
141 return s_q14
142 }
143 let gamma_part: nx_int = _aces_pow_inv_2_4_q14(l_q14)
144 let scaled: nx_int = (NX_ACES_1_055_Q14 * gamma_part) / NX_ACES_Q14
145 let s_q14: nx_int = scaled - NX_ACES_0_055_Q14
146 if s_q14 < 0 { return 0 }
147 return s_q14
148}
149
150// ===== sRGB-primaries -> substrate-working-space 3x3 ==============
151//
152// Standard Rec.709 / sRGB-D65 to a wide gamut "AP1-like" primaries
153// matrix. Numerical values DERIVED FROM CIE 1931 chromaticities
154// for the named primaries -- substrate-public-domain math.
155//
156// AP1 chromaticities (substrate-owned, not Academy-trademarked):
157// R: (0.713, 0.293)
158// G: (0.165, 0.830)
159// B: (0.128, 0.044)
160// W (D60-ish): (0.3217, 0.3377)
161//
162// Resulting sRGB-D65 -> AP1-D60 matrix in Q14 (derived offline,
163// reproducible from CIE matrix-derivation procedure):
164//
165// [0.61308 0.33952 0.04737]
166// [0.07020 0.91635 0.01345]
167// [0.02055 0.10956 0.86988]
168//
169// Q14:
170// r_row = [10047, 5562, 776]
171// g_row = [1150, 15013, 220]
172// b_row = [337, 1795, 14251]
173
174const NX_ACES_M00: nx_int = 10047
175const NX_ACES_M01: nx_int = 5562
176const NX_ACES_M02: nx_int = 776
177const NX_ACES_M10: nx_int = 1150
178const NX_ACES_M11: nx_int = 15013
179const NX_ACES_M12: nx_int = 220
180const NX_ACES_M20: nx_int = 337
181const NX_ACES_M21: nx_int = 1795
182const NX_ACES_M22: nx_int = 14251
183
184struct NxAcesTriple {
185 r_q14: nx_int,
186 g_q14: nx_int,
187 b_q14: nx_int,
188}
189
190const NX_ACES_TRIPLE_BYTES: nx_size = 24
191
192func nx_aces_alloc_triple(r_q14: nx_int, g_q14: nx_int, b_q14: nx_int) -> *NxAcesTriple {
193 let p: *u8 = sys_mmap(NX_ACES_TRIPLE_BYTES)
194 let t: *NxAcesTriple = p as *NxAcesTriple
195 t.r_q14 = r_q14
196 t.g_q14 = g_q14
197 t.b_q14 = b_q14
198 return t
199}
200
201func nx_aces_linear_to_workingspace(
202 in_lin: *NxAcesTriple) -> *NxAcesTriple {
203
204 let r: nx_int = in_lin.r_q14
205 let g: nx_int = in_lin.g_q14
206 let b: nx_int = in_lin.b_q14
207 let r_new: nx_int =
208 ((NX_ACES_M00 * r) + (NX_ACES_M01 * g) + (NX_ACES_M02 * b)) / NX_ACES_Q14
209 let g_new: nx_int =
210 ((NX_ACES_M10 * r) + (NX_ACES_M11 * g) + (NX_ACES_M12 * b)) / NX_ACES_Q14
211 let b_new: nx_int =
212 ((NX_ACES_M20 * r) + (NX_ACES_M21 * g) + (NX_ACES_M22 * b)) / NX_ACES_Q14
213 return nx_aces_alloc_triple(r_new, g_new, b_new)
214}
215
216// Caller convenience: u8 sRGB -> Q14 working-space triple.
217//
218// u8 byte 0..255 maps to Q14 0..NX_ACES_Q14 via (b * Q14) / 255.
219
220func nx_aces_pipeline_u8(r_u8: nx_int, g_u8: nx_int, b_u8: nx_int) -> *NxAcesTriple {
221 let r_s_q14: nx_int = (r_u8 * NX_ACES_Q14) / 255
222 let g_s_q14: nx_int = (g_u8 * NX_ACES_Q14) / 255
223 let b_s_q14: nx_int = (b_u8 * NX_ACES_Q14) / 255
224 let r_l: nx_int = nx_aces_srgb_to_linear_q14(r_s_q14)
225 let g_l: nx_int = nx_aces_srgb_to_linear_q14(g_s_q14)
226 let b_l: nx_int = nx_aces_srgb_to_linear_q14(b_s_q14)
227 let lin_triple: *NxAcesTriple = nx_aces_alloc_triple(r_l, g_l, b_l)
228 return nx_aces_linear_to_workingspace(lin_triple)
229}
230
231// ===== self-test ==================================================
232
233func main() -> nx_int {
234 // ---- sRGB OETF round-trip ----
235 //
236 // sRGB 0 -> linear 0 -> sRGB 0.
237 let s0: nx_int = nx_aces_srgb_to_linear_q14(0)
238 if s0 != 0 { return 1 }
239 let r0: nx_int = nx_aces_linear_to_srgb_q14(0)
240 if r0 != 0 { return 2 }
241
242 // sRGB 1.0 (Q14=16384) -> linear ~1.0 -> sRGB ~1.0. Q14
243 // tolerance +/- 200 (about 1.2%) due to polynomial approx.
244 let s1_lin: nx_int = nx_aces_srgb_to_linear_q14(NX_ACES_Q14)
245 var d1: nx_int = s1_lin - NX_ACES_Q14
246 if d1 < 0 { d1 = 0 - d1 }
247 if d1 > 200 { return 3 }
248 let s1_rt: nx_int = nx_aces_linear_to_srgb_q14(s1_lin)
249 var d1b: nx_int = s1_rt - NX_ACES_Q14
250 if d1b < 0 { d1b = 0 - d1b }
251 if d1b > 400 { return 4 }
252
253 // Linear region: sRGB 0.02 (Q14=328) -> linear 0.02/12.92 ~= 0.001547
254 // Q14 ~= 25.
255 let s_low: nx_int = nx_aces_srgb_to_linear_q14(328)
256 if s_low > 50 { return 5 }
257 if s_low < 10 { return 6 }
258
259 // ---- matrix sanity ----
260 //
261 // Pure white linear (1, 1, 1) under sRGB->AP1 matrix should
262 // give row sums approximately (1, 1, 1) by matrix construction.
263 let white_lin: *NxAcesTriple = nx_aces_alloc_triple(
264 NX_ACES_Q14, NX_ACES_Q14, NX_ACES_Q14)
265 let white_ap1: *NxAcesTriple = nx_aces_linear_to_workingspace(white_lin)
266 var dr: nx_int = white_ap1.r_q14 - NX_ACES_Q14
267 if dr < 0 { dr = 0 - dr }
268 var dg: nx_int = white_ap1.g_q14 - NX_ACES_Q14
269 if dg < 0 { dg = 0 - dg }
270 var db: nx_int = white_ap1.b_q14 - NX_ACES_Q14
271 if db < 0 { db = 0 - db }
272 if dr > 100 { return 10 }
273 if dg > 100 { return 11 }
274 if db > 100 { return 12 }
275
276 // Pure black: (0, 0, 0) -> (0, 0, 0).
277 let black_lin: *NxAcesTriple = nx_aces_alloc_triple(0, 0, 0)
278 let black_ap1: *NxAcesTriple = nx_aces_linear_to_workingspace(black_lin)
279 if black_ap1.r_q14 != 0 { return 20 }
280 if black_ap1.g_q14 != 0 { return 21 }
281 if black_ap1.b_q14 != 0 { return 22 }
282
283 // ---- u8 sRGB pipeline: skin-tone (200, 170, 150) ----
284 let skin: *NxAcesTriple = nx_aces_pipeline_u8(200, 170, 150)
285 if skin == (0 as *NxAcesTriple) { return 30 }
286 // Sanity: r > g > b after linearisation (warm skin preserves
287 // ordering after AP1 matrix because matrix mostly mixes channels
288 // but red row dominates red input).
289 if skin.r_q14 <= 0 { return 31 }
290
291 return 0
292}