yum-archive/2ner

A toon shader for Unity's BIRP.

git clone https://git.yummers.dev/yum-archive/2ner

yumUpdate DFG LUTs, drop cloth LUT6aea3d8

master
6.2 KiB190 linesraw
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.9"
4# dependencies = [
5#     "numpy",
6#     "openexr",
7# ]
8# ///
9"""
10Generate a DFG LUT (Look-Up Table) for PBR split-sum approximation.
11
12This computes the pre-integrated BRDF for the GGX microfacet model,
13storing scale and bias factors for the Fresnel term.
14
15Output: DFG LUT as an EXR file with RG channels (scale, bias).
16"""
17
18import numpy as np
19
20try:
21    import OpenEXR
22    import Imath
23    HAS_OPENEXR = True
24except ImportError:
25    HAS_OPENEXR = False
26
27
28def generate_hammersley_sequence(n):
29    """Pre-compute Hammersley 2D sequence for n samples."""
30    i = np.arange(n, dtype=np.uint32)
31
32    # Reverse bits for radical inverse
33    v = i.copy()
34    v = ((v >> 1) & 0x55555555) | ((v & 0x55555555) << 1)
35    v = ((v >> 2) & 0x33333333) | ((v & 0x33333333) << 2)
36    v = ((v >> 4) & 0x0F0F0F0F) | ((v & 0x0F0F0F0F) << 4)
37    v = ((v >> 8) & 0x00FF00FF) | ((v & 0x00FF00FF) << 8)
38    v = (v >> 16) | (v << 16)
39
40    e1 = i.astype(np.float32) / n
41    e2 = v.astype(np.float64) / 0x100000000
42
43    return e1, e2.astype(np.float32)
44
45
46def generate_dfg_lut(width=64, height=32, num_samples=512):
47    """
48    Generate the full DFG LUT (vectorized).
49
50    Compatible with HLSL: float2 dfg_uv = float2(NoV, roughness);
51    X axis (U): NdotV (0 to 1)
52    Y axis (V): roughness (0 to 1)
53    """
54    # Pre-compute Hammersley sequence
55    e1, e2 = generate_hammersley_sequence(num_samples)
56    phi = 2.0 * np.pi * e1
57    cos_phi = np.cos(phi)
58    sin_phi = np.sin(phi)
59
60    # Create coordinate grids matching HLSL UV layout
61    x = np.arange(width, dtype=np.float32)
62    y = np.arange(height, dtype=np.float32)
63    ndotv_arr = (x + 0.5) / width      # shape: (width,) - U axis
64    roughness = (y + 0.5) / height     # shape: (height,) - V axis
65
66    # Pre-compute roughness terms
67    m = roughness * roughness
68    m2 = m * m                         # shape: (height,)
69
70    lut = np.zeros((height, width, 2), dtype=np.float32)
71
72    for yi, (rough, rough_m2) in enumerate(zip(roughness, m2)):
73        # GGX importance sampling - vectorized over samples and NdotV
74        # cos_theta shape: (width, num_samples)
75        denom = 1.0 + (rough_m2 - 1.0) * e2[np.newaxis, :]
76        cos_theta = np.sqrt((1.0 - e2[np.newaxis, :]) / denom)
77        sin_theta = np.sqrt(1.0 - cos_theta * cos_theta)
78
79        # Half vector in tangent space
80        hx = sin_theta * cos_phi[np.newaxis, :]
81        hy = sin_theta * sin_phi[np.newaxis, :]
82        hz = cos_theta
83
84        # View vector in tangent space (varies per column)
85        ndotv = ndotv_arr[:, np.newaxis]  # shape: (width, 1)
86        vx = np.sqrt(1.0 - ndotv * ndotv)
87        vz = ndotv
88
89        # V dot H
90        vdh = vx * hx + vz * hz
91
92        # Light vector (reflect view around half)
93        lx = 2.0 * vdh * hx - vx
94        lz = 2.0 * vdh * hz - vz
95
96        ndotl = np.maximum(lz, 0.0)
97        ndoth = np.maximum(hz, 0.0)
98        vdoth = np.maximum(vdh, 0.0)
99
100        # Visibility function (Smith GGX correlated)
101        vis_v = ndotl * np.sqrt(ndotv * (ndotv - ndotv * rough_m2) + rough_m2)
102        vis_l = ndotv * np.sqrt(ndotl * (ndotl - ndotl * rough_m2) + rough_m2)
103        vis = 0.5 / (vis_v + vis_l + 1e-8)
104
105        # Compute contribution
106        ndotl_vis_pdf = ndotl * vis * (4.0 * vdoth / (ndoth + 1e-8))
107        fresnel = (1.0 - vdoth) ** 5
108
109        # Mask invalid samples
110        mask = ndotl > 0.0
111        scale_contrib = np.where(mask, ndotl_vis_pdf * (1.0 - fresnel), 0.0)
112        bias_contrib = np.where(mask, ndotl_vis_pdf * fresnel, 0.0)
113
114        # Sum over samples
115        scale = np.sum(scale_contrib, axis=1) / num_samples
116        bias = np.sum(bias_contrib, axis=1) / num_samples
117
118        # Filament-compatible layout:
119        # R = bias (F0-independent term)
120        # G = scale + bias (reflectance when F0 = 1)
121        # Used as: lerp(dfg.x, dfg.y, f0) = bias + f0 * scale
122        lut[yi, :, 0] = bias
123        lut[yi, :, 1] = scale + bias
124
125        print(f"\rGenerating DFG LUT: {(yi + 1) / height * 100:.1f}%", end="", flush=True)
126
127    print()
128    # Flip vertically so V=0 (top) is high roughness, V=1 (bottom) is low roughness
129    return np.flipud(lut)
130
131
132def save_exr(filename, lut):
133    """Save the DFG LUT as an EXR file."""
134    if not HAS_OPENEXR:
135        raise ImportError("OpenEXR module not available. Install with: pip install OpenEXR")
136
137    height, width = lut.shape[:2]
138
139    header = OpenEXR.Header(width, height)
140    header['channels'] = {
141        'R': Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT)),
142        'G': Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT)),
143    }
144
145    r_channel = lut[:, :, 0].astype(np.float32).tobytes()
146    g_channel = lut[:, :, 1].astype(np.float32).tobytes()
147
148    exr = OpenEXR.OutputFile(filename, header)
149    exr.writePixels({'R': r_channel, 'G': g_channel})
150    exr.close()
151
152
153def save_npy(filename, lut):
154    """Save the DFG LUT as a numpy file (fallback)."""
155    np.save(filename, lut)
156
157
158def main():
159    import argparse
160
161    parser = argparse.ArgumentParser(description="Generate DFG LUT for PBR rendering")
162    parser.add_argument("-o", "--output", default="dfg_lut.exr", help="Output filename (default: dfg_lut.exr)")
163    parser.add_argument("-W", "--width", type=int, default=64, help="LUT width (NdotV axis, default: 64)")
164    parser.add_argument("-H", "--height", type=int, default=32, help="LUT height (roughness axis, default: 32)")
165    parser.add_argument("-s", "--samples", type=int, default=512, help="Number of samples per texel (default: 512)")
166    args = parser.parse_args()
167
168    print(f"Generating {args.width}x{args.height} DFG LUT with {args.samples} samples per texel...")
169    lut = generate_dfg_lut(args.width, args.height, args.samples)
170
171    output = args.output
172    if output.endswith(".exr"):
173        if HAS_OPENEXR:
174            save_exr(output, lut)
175            print(f"Saved EXR: {output}")
176        else:
177            output = output.replace(".exr", ".npy")
178            print("Warning: OpenEXR not available. Install with: pip install OpenEXR")
179            save_npy(output, lut)
180            print(f"Saved NumPy array instead: {output}")
181    elif output.endswith(".npy"):
182        save_npy(output, lut)
183        print(f"Saved NumPy array: {output}")
184    else:
185        save_exr(output, lut)
186        print(f"Saved: {output}")
187
188
189if __name__ == "__main__":
190    main()