yum/3ner
A toon shader for Unity's BIRP.
git clone https://git.yummers.dev/yum/3ner
072fa89
master
1#!/usr/bin/env -S uv run --script 2# /// script 3# requires-python = ">=3.10" 4# dependencies = [] 5# /// 6 7from __future__ import annotations 8 9import math 10import struct 11import sys 12import zlib 13from pathlib import Path 14 15 16PIXELS_PER_BAYER_CELL = 16 17TOTAL_DOT_AREA = 0.5 18SDF_RADIUS_SCALE = 2.4 19BINARY_THRESHOLD = 2.0 / 3.0 20BASE_BAYER_POINTS = ( 21 (0.0, 0.0), 22 (0.5, 0.5), 23 (0.5, 0.0), 24 (0.0, 0.5), 25) 26 27 28def main(argv: list[str]) -> int: 29 if len(argv) != 2: 30 print(f"usage: {Path(argv[0]).name} N", file=sys.stderr) 31 return 2 32 33 try: 34 order = int(argv[1]) 35 except ValueError: 36 print("N must be an integer.", file=sys.stderr) 37 return 2 38 39 if order < 1 or order & (order - 1): 40 print("N must be a positive power of two.", file=sys.stderr) 41 return 2 42 43 output_dir = Path.cwd() / f"SSFD_{order}x{order}" 44 output_dir.mkdir(parents=True, exist_ok=True) 45 clear_previous_outputs(output_dir) 46 47 size = order * PIXELS_PER_BAYER_CELL 48 points = build_bayer_points(order) 49 min_dist_sq = [math.inf] * (size * size) 50 sample_x = [((x + 0.5) / size) for x in range(size)] 51 sample_y = [((y + 0.5) / size) for y in range(size)] 52 53 for layer_index, point in enumerate(points, start=1): 54 update_min_distances(min_dist_sq, sample_x, sample_y, size, point) 55 write_layer(output_dir, min_dist_sq, size, layer_index) 56 57 print(f"Wrote {len(points)} textures to {output_dir}") 58 return 0 59 60 61def clear_previous_outputs(output_dir: Path) -> None: 62 for pattern in ( 63 "dots_L*.png", 64 "dots_L*-sdf.png", 65 "dots_L*.png.meta", 66 "dots_L*-sdf.png.meta", 67 ): 68 for path in output_dir.glob(pattern): 69 if path.is_file(): 70 path.unlink() 71 72 73def build_bayer_points(order: int) -> list[tuple[float, float]]: 74 if order == 1: 75 return [(0.0, 0.0)] 76 77 recursion = int(math.log2(order)) 78 points = list(BASE_BAYER_POINTS) 79 for r in range(recursion - 1): 80 count = len(points) 81 offset = 0.5 ** (r + 1) 82 for i in range(1, 4): 83 dx, dy = BASE_BAYER_POINTS[i] 84 for j in range(count): 85 px, py = points[j] 86 points.append((px + dx * offset, py + dy * offset)) 87 return points 88 89 90def update_min_distances( 91 min_dist_sq: list[float], 92 sample_x: list[float], 93 sample_y: list[float], 94 size: int, 95 point: tuple[float, float], 96) -> None: 97 px, py = point 98 for y, sy in enumerate(sample_y): 99 dy = wrap_delta(sy - py) 100 dy_sq = dy * dy 101 row_offset = y * size 102 for x, sx in enumerate(sample_x): 103 dx = wrap_delta(sx - px) 104 dist_sq = dx * dx + dy_sq 105 idx = row_offset + x 106 if dist_sq < min_dist_sq[idx]: 107 min_dist_sq[idx] = dist_sq 108 109 110def write_layer(output_dir: Path, min_dist_sq: list[float], size: int, layer_index: int) -> None: 111 dot_radius = math.sqrt((TOTAL_DOT_AREA / layer_index) / math.pi) 112 scale = dot_radius * SDF_RADIUS_SCALE 113 sdf_pixels = bytearray() 114 binary_pixels = bytearray() 115 116 for dist_sq in min_dist_sq: 117 dist = math.sqrt(dist_sq) 118 value = clamp01(1.0 - dist / scale) 119 sdf_byte = round(value * 255.0) 120 sdf_pixels.append(sdf_byte) 121 binary_pixels.append(255 if value >= BINARY_THRESHOLD else 0) 122 123 write_grayscale_png(output_dir / f"dots_L{layer_index}-sdf.png", size, size, sdf_pixels) 124 write_grayscale_png(output_dir / f"dots_L{layer_index}.png", size, size, binary_pixels) 125 126 127def wrap_delta(value: float) -> float: 128 return (value + 0.5) % 1.0 - 0.5 129 130 131def clamp01(value: float) -> float: 132 if value <= 0.0: 133 return 0.0 134 if value >= 1.0: 135 return 1.0 136 return value 137 138 139def write_grayscale_png(path: Path, width: int, height: int, pixels: bytearray) -> None: 140 raw = bytearray() 141 stride = width 142 for y in range(height): 143 start = y * stride 144 raw.append(0) 145 raw.extend(pixels[start : start + stride]) 146 147 compressed = zlib.compress(bytes(raw), level=9) 148 with path.open("wb") as handle: 149 handle.write(b"\x89PNG\r\n\x1a\n") 150 handle.write(make_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0))) 151 handle.write(make_chunk(b"IDAT", compressed)) 152 handle.write(make_chunk(b"IEND", b"")) 153 154 155def make_chunk(chunk_type: bytes, payload: bytes) -> bytes: 156 return ( 157 struct.pack(">I", len(payload)) 158 + chunk_type 159 + payload 160 + struct.pack(">I", zlib.crc32(chunk_type + payload) & 0xFFFFFFFF) 161 ) 162 163 164if __name__ == "__main__": 165 raise SystemExit(main(sys.argv))