yum/3ner
A toon shader for Unity's BIRP.
git clone https://git.yummers.dev/yum/3ner
69f2735
master
1#!/usr/bin/env python3 2 3# To run with uv: 4# uv run -w OpenEXR -w numba ./make_dfg_lut.py 5 6import argparse 7import math 8import numpy as np 9import OpenEXR 10import Imath 11import numba 12import random 13import concurrent .futures 14import os 15from functools import partial 16 17 18@ numba . njit ( cache = True ) 19def rcp (a ): 20return 1.0 / a 21 22 23@ numba . njit ( cache = True ) 24def lerp (a ,b ,t ): 25return a + (b - a )* t 26 27 28@ numba . njit ( cache = True ) 29def saturate (a ): 30if a < 0.0 :return 0.0 31if a > 1.0 :return 1.0 32return a 33 34 35# Standard BRDF components. 36@ numba . njit ( cache = True ) 37def F_Schlick (LoH ,f0 ,f90 = 1.0 ): 38term = 1.0 - LoH 39term2 = term * term 40term5 = term2 * term2 * term 41return f0 + (f90 - f0 )* term5 42 43 44@ numba . njit ( cache = True ) 45def D_GGX (roughness ,NoH ): 46r2 = roughness * roughness 47NoH2 = NoH * NoH 48NoH4 = NoH2 * NoH2 49k = rcp (NoH2 )- 1.0 50r2_plus_k = r2 + k 51denom = NoH4 * r2_plus_k * r2_plus_k 52return r2 / (denom + 1e-6 ) 53 54 55@ numba . njit ( cache = True ) 56def G_GGXSmith (roughness ,NoL ,NoV ): 57denom = 2.0 * lerp (2.0 * NoL * NoV ,NoL + NoV ,roughness ) 58return rcp (denom + 1e-6 ) 59 60 61# Cloth BRDF components. 62@ numba . njit ( cache = True ) 63def D_Cloth (roughness ,NoH ): 64if roughness < 1e-4 :return 0.0 65r_rcp = rcp (roughness ) 66sin2H = 1.0 - NoH * NoH 67return (2.0 + r_rcp )* pow (sin2H ,r_rcp * 0.5 )/ (2.0 * math .pi ) 68 69 70@ numba . njit ( cache = True ) 71def G_Cloth_L (x ,a ,b ,c ,d ,e ): 72return a / (1.0 + b * pow (x ,c ))+ d * x + e 73 74 75@ numba . njit ( cache = True ) 76def Lambda_Cloth_Raw (roughness ,cos_theta ): 77a0 ,a1 = 25.3245 ,21.5473 78b0 ,b1 = 3.32435 ,3.82987 79c0 ,c1 = 0.16801 ,0.19823 80d0 ,d1 = - 1.27393 ,- 1.97760 81e0 ,e1 = - 4.85967 ,- 4.32054 82 83one_minus_r = 1.0 - roughness 84interp = one_minus_r * one_minus_r 85rough_weight = 1.0 - interp 86 87lambda_val = 0.0 88if cos_theta < 0.5 : 89L0 = G_Cloth_L (cos_theta ,a0 ,b0 ,c0 ,d0 ,e0 ) 90L1 = G_Cloth_L (cos_theta ,a1 ,b1 ,c1 ,d1 ,e1 ) 91L = lerp (L0 ,L1 ,rough_weight ) 92lambda_val = math .exp (L ) 93else : 94L_05_0 = G_Cloth_L (0.5 ,a0 ,b0 ,c0 ,d0 ,e0 ) 95L_05_1 = G_Cloth_L (0.5 ,a1 ,b1 ,c1 ,d1 ,e1 ) 96L_05 = lerp (L_05_0 ,L_05_1 ,rough_weight ) 97 98one_minus_cos = 1.0 - cos_theta 99L_c_0 = G_Cloth_L (one_minus_cos ,a0 ,b0 ,c0 ,d0 ,e0 ) 100L_c_1 = G_Cloth_L (one_minus_cos ,a1 ,b1 ,c1 ,d1 ,e1 ) 101L_c = lerp (L_c_0 ,L_c_1 ,rough_weight ) 102 103lambda_val = math .exp (2.0 * L_05 - L_c ) 104 105return lambda_val 106 107 108@ numba . njit ( cache = True ) 109def Lambda_Cloth_Softened (roughness ,cos_theta ): 110lambda_val = Lambda_Cloth_Raw (roughness ,cos_theta ) 111return pow (lambda_val ,1.0 + 2.0 * pow (1.0 - cos_theta ,8.0 )) 112 113 114@ numba . njit ( cache = True ) 115def V_Cloth_Outgoing (roughness ,NoL ,NoV ): 116# Height-correlated Smith: G2 / (4 * NoL * NoV) 117lambda_l = Lambda_Cloth_Softened (roughness ,NoL ) 118lambda_v = Lambda_Cloth_Raw (roughness ,NoV ) 119return 1.0 / ((1.0 + lambda_l + lambda_v )* 4.0 * NoL * NoV + 1e-6 ) 120 121 122@ numba . njit ( cache = True ) 123def V_Cloth_Incoming (roughness ,NoL ,NoV ): 124lambda_l = Lambda_Cloth_Softened (roughness ,NoL ) 125lambda_v = Lambda_Cloth_Raw (roughness ,NoV ) 126return 1.0 / ((1.0 + lambda_l + lambda_v )* 4.0 * NoL * NoV + 1e-6 ) 127 128 129@ numba . njit ( cache = True ) 130def integrate_brdf_jitted (roughness ,NoV ,num_samples ): 131V_x = math .sqrt (1.0 - NoV * NoV ) 132V_y = 0.0 133V_z = NoV 134 135# R: GGX scale, G: GGX bias, B: cloth outgoing albedo, A: cloth incoming albedo 136std_scale ,std_bias ,cloth_out ,cloth_in = 0.0 ,0.0 ,0.0 ,0.0 137 138for i in range (num_samples ): 139e1 ,e2 = random .random (),random .random () 140 141# Importance sample GGX 142a = roughness 143a2 = a * a 144 145phi = 2.0 * math .pi * e1 146cos_theta = math .sqrt ((1.0 - e2 )/ (1.0 + (a2 - 1.0 )* e2 )) 147sin_theta = math .sqrt (1.0 - cos_theta * cos_theta ) 148 149H_x = math .cos (phi )* sin_theta 150H_y = math .sin (phi )* sin_theta 151H_z = cos_theta 152 153VoH = H_x * V_x + H_y * V_y + H_z * V_z 154if VoH <= 0 :continue 155 156L_x = 2.0 * VoH * H_x - V_x 157L_y = 2.0 * VoH * H_y - V_y 158L_z = 2.0 * VoH * H_z - V_z 159 160NoL = saturate (L_z ) 161NoH = saturate (H_z ) 162NoV_proxy = saturate (V_z ) 163 164if NoL > 0 : 165# --- Standard BRDF --- 166# D cancels between numerator and PDF. 167G = G_GGXSmith (roughness ,NoL ,NoV_proxy ) 168Fc_term = pow (1.0 - VoH ,5.0 ) 169 170# PDF = D_GGX * NoH / (4 * VoH), so (D * G * NoL) / PDF simplifies to: 171common_term = (G * NoL * 4.0 * VoH )/ max (NoH ,1e-5 ) 172 173std_scale += common_term * (1.0 - Fc_term ) 174std_bias += common_term * Fc_term 175 176# --- Cloth BRDF --- 177# Same GGX importance samples, reweighted for cloth D and V. 178if roughness >= 1e-4 : 179D_c = D_Cloth (roughness ,NoH ) 180pdf_ggx = D_GGX (roughness ,NoH )* NoH / (4.0 * VoH + 1e-6 ) 181V_out = V_Cloth_Outgoing (roughness ,NoL ,NoV_proxy ) 182V_in = V_Cloth_Incoming (roughness ,NoV_proxy ,NoL ) 183cloth_out += (D_c * V_out * NoL )/ (pdf_ggx + 1e-6 ) 184cloth_in += (D_c * V_in * NoL )/ (pdf_ggx + 1e-6 ) 185 186inv_n = 1.0 / num_samples 187return std_scale * inv_n ,std_bias * inv_n ,cloth_out * inv_n ,cloth_in * inv_n 188 189 190def calculate_pixel (coords ,resolution ,num_samples ): 191x ,y = coords 192u = (x + 0.5 )/ resolution 193v = (y + 0.5 )/ resolution 194 195NoV = saturate (u ) 196perceptual_roughness = saturate (v ) 197roughness = max (perceptual_roughness * perceptual_roughness ,1e-4 ) 198if NoV < 1e-4 :return x ,y ,0.0 ,0.0 ,0.0 ,0.0 199 200std_scale ,std_bias ,cloth_out ,cloth_in = integrate_brdf_jitted (roughness ,NoV ,num_samples ) 201 202# R: GGX scale, G: GGX bias, B: cloth outgoing albedo, A: cloth incoming albedo 203return x ,y ,std_scale ,std_bias ,cloth_out ,cloth_in 204 205 206def generate_exr (resolution ,output_filename ,num_samples ,num_workers ): 207f"Generating { resolution } x { resolution } EXR ' { output_filename } ' (R=GGX scale, G=GGX bias, B=cloth out, A=cloth in) ( { num_samples } samples/pixel) using { num_workers } workers." ) 208header = OpenEXR .Header (resolution ,resolution ) 209pt = Imath .PixelType (Imath .PixelType .FLOAT ) 210header ['channels' ]= { 211'R' :Imath .Channel (pt ), 212'G' :Imath .Channel (pt ), 213'B' :Imath .Channel (pt ), 214'A' :Imath .Channel (pt ), 215 } 216 217pixel_data = np .zeros ((resolution ,resolution ,4 ),dtype = np .float32 ) 218 219coords_to_process = [(x ,y )for y in range (resolution )for x in range (resolution )] 220worker_func = partial (calculate_pixel ,resolution = resolution ,num_samples = num_samples ) 221 222processed_count = 0 223total_pixels = len (coords_to_process ) 224f"Starting pixel processing..." ) 225 226with concurrent .futures .ProcessPoolExecutor (max_workers = num_workers )as executor : 227futures = {executor .submit (worker_func ,coord ):coord for coord in coords_to_process } 228 229for future in concurrent .futures .as_completed (futures ): 230try : 231x ,y ,r ,g ,b ,a = future .result () 232pixel_data [y ,x ]= (r ,g ,b ,a ) 233except Exception as exc : 234coord = futures [future ] 235f'\nPixel at { coord } generated an exception: { exc } ' ) 236 237processed_count += 1 238f" ...processed { processed_count } / { total_pixels } pixels ( { processed_count / total_pixels :.1% } )" ,end = '\r' ) 239 240f"\nProcessing complete. Writing to { output_filename } ..." ) 241try : 242# Vertically flip to match UV coordinates (0,0 at bottom-left). 243pixel_data = np .flipud (pixel_data ) 244 245exr_file = OpenEXR .OutputFile (output_filename ,header ) 246r_data = pixel_data [:, :,0 ].ravel ().tobytes () 247g_data = pixel_data [:, :,1 ].ravel ().tobytes () 248b_data = pixel_data [:, :,2 ].ravel ().tobytes () 249a_data = pixel_data [:, :,3 ].ravel ().tobytes () 250exr_file .writePixels ({'R' :r_data ,'G' :g_data ,'B' :b_data ,'A' :a_data }) 251exr_file .close () 252f"Successfully generated { output_filename } " ) 253except Exception as e : 254raise RuntimeError (f"Failed to write EXR file ' { output_filename } ': { e } " ) 255 256def main (): 257parser = argparse .ArgumentParser (description = 'Generate packed DFG LUT (R=GGX scale, G=GGX bias, B=cloth out, A=cloth in).' ) 258parser .add_argument ('-r' ,'--resolution' ,type = int ,default = 512 , 259help = 'Resolution of the square EXR image (default: 512)' ) 260parser .add_argument ('-s' ,'--samples' ,type = int ,default = 8192 , 261help = 'Number of samples per pixel for integration (default: 8192)' ) 262parser .add_argument ('-o' ,'--output' ,default = 'dfg.exr' , 263help = 'Output filename (default: dfg.exr)' ) 264parser .add_argument ('-j' ,'--workers' ,type = int ,default = os .cpu_count (), 265help = f'Number of worker processes (default: { os . cpu_count () } )' ) 266 267args = parser .parse_args () 268 269if args .resolution <= 0 : 270"Error: Resolution must be a positive integer" ) 271return 1 272 273try : 274generate_exr (args .resolution ,args .output ,args .samples ,args .workers ) 275except Exception as e : 276f"Error: { e } " ) 277return 1 278 279return 0 280 281if __name__ == '__main__' : 282exit (main ())