code wiki / (root) / nx_aces_native.nx

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}