yum/3ner

A toon shader for Unity's BIRP.

git clone https://git.yummers.dev/yum/3ner

yumssfd bugfixes072fa89

master
4.5 KiB165 linesraw
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))