yum/3ner
A toon shader for Unity's BIRP.
git clone https://git.yummers.dev/yum/3ner
86438c8
master
1#!/usr/bin/env -S uv run --script 2# /// script 3# requires-python = ">=3.10" 4# dependencies = ["Pillow"] 5# /// 6 7import argparse 8import subprocess 9import os 10import sys 11import json 12from PIL import Image, ImageDraw 13import random 14 15SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) 16MSDF_REPO = "https://github.com/Chlumsky/msdf-atlas-gen.git" 17MSDF_DIR = os.path.join(SCRIPT_DIR, "msdf-atlas-gen") 18MSDF_BUILD_DIR = os.path.join(MSDF_DIR, "build") 19 20def get_msdf_binary(): 21 """Return path to msdf-atlas-gen binary, building from source if needed.""" 22 if sys.platform == "win32": 23 binary = os.path.join(MSDF_BUILD_DIR, "bin", "Debug", "msdf-atlas-gen.exe") 24 else: 25 binary = os.path.join(MSDF_BUILD_DIR, "bin", "msdf-atlas-gen") 26 if os.path.isfile(binary): 27 return binary 28 print("msdf-atlas-gen not found, acquiring from source...") 29 if not os.path.isdir(MSDF_DIR): 30 subprocess.run( 31 ["git", "clone", "--recursive", MSDF_REPO, MSDF_DIR], 32 check=True, 33 ) 34 else: 35 print("Source directory exists, skipping clone.") 36 os.makedirs(MSDF_BUILD_DIR, exist_ok=True) 37 subprocess.run( 38 [ 39 "cmake", "..", 40 "-DCMAKE_BUILD_TYPE=Release", 41 "-DMSDF_ATLAS_USE_VCPKG=OFF", 42 "-DMSDF_ATLAS_USE_SKIA=OFF", 43 "-DMSDF_ATLAS_NO_ARTERY_FONT=ON", 44 ], 45 cwd=MSDF_BUILD_DIR, 46 check=True, 47 ) 48 subprocess.run( 49 ["cmake", "--build", ".", "--config", "Release", "-j", str(os.cpu_count() or 4)], 50 cwd=MSDF_BUILD_DIR, 51 check=True, 52 ) 53 if not os.path.isfile(binary): 54 # Try Release subdir on Windows 55 alt = os.path.join(MSDF_BUILD_DIR, "bin", "Release", "msdf-atlas-gen.exe") 56 if os.path.isfile(alt): 57 return alt 58 raise FileNotFoundError(f"Build succeeded but binary not found at {binary}") 59 return binary 60 61# Define the character ranges we want to include 62CHAR_RANGES = [ 63 (32, 126), # Printable ASCII 64 # Add more ranges here as needed, e.g.: 65 (0x0080, 0x00ff), # latin 66 (0x0100, 0x017f), # latin 67 (0x0180, 0x024f), # latin 68 (0x0250, 0x02af), # ipa extensions 69 (0x0370, 0x03ff), # greek and coptic 70 (0x0400, 0x04ff), # cyrillic 71 (0x0500, 0x052f), # cyrillic supplement 72 #(0x0530, 0x058f), # armenian 73 #(0x0590, 0x05ff), # hebrew 74 #(0x0600, 0x06ff), # arabic 75] 76 77def calculate_grid_size(char_ranges): 78 """Calculate the smallest square grid that fits the highest character code""" 79 max_char = max(end for _, end in char_ranges) 80 grid_size = 1 81 while grid_size * grid_size < max_char: 82 grid_size += 1 83 return grid_size 84 85ATLAS_TYPES = [ 86 "hardmask", # binary image 87 "softmask", # anti-aliased image 88 "sdf", # signed distance field 89 "psdf", # perpendicular distance field 90 "msdf", # multi-channel signed distance field 91 "mtsdf" # combined MSDF and true SDF 92] 93 94def calculate_font_size(resolution, base_size=None): 95 """Calculate the font size based on resolution, scaling from 512 resolution""" 96 if base_size is None: 97 base_size = 32 # Default size at 512 resolution 98 return (base_size * resolution) // 512 99 100def generate_atlas(font_path, resolution, draw_grid=False, type="msdf", font_size=None): 101 """Generates a font atlas using various distance field techniques. 102 103 This function creates a font atlas using msdf-atlas-gen, then rearranges the 104 characters to include gaps for non-printable characters. The output is saved 105 as 'atlas.png' in the current directory. 106 107 Args: 108 font_path (str): Path to the input font file (.ttf/.otf) 109 resolution (int): Width and height of the output atlas in pixels 110 draw_grid (bool, optional): If True, draws red grid lines on the output. 111 Defaults to False. 112 type (str, optional): Atlas type to generate. See ATLAS_TYPES. 113 font_size (int, optional): Base font size in pixels at 512 resolution. 114 Will be scaled for other resolutions. 115 116 Returns: 117 bool: True if atlas generation succeeded, False if an error occurred. 118 119 Raises: 120 subprocess.CalledProcessError: If msdf-atlas-gen fails to execute. 121 """ 122 # Get font name from path 123 font_name = os.path.splitext(os.path.basename(font_path))[0] 124 125 # Calculate grid size based on character ranges 126 grid_size = calculate_grid_size(CHAR_RANGES) 127 cell_size = resolution // grid_size 128 129 # Calculate font size if not specified, scaling from 512 resolution 130 if font_size is None: 131 font_size = calculate_font_size(resolution) 132 else: 133 font_size = calculate_font_size(resolution, font_size) 134 135 # Convert character ranges to command-line format 136 chars_str = ", ".join(f"[{start}, {end}]" for start, end in CHAR_RANGES) 137 138 # Update the output filename to include resolution 139 output_filename = f"atlas_{font_name}_{resolution}_{type}" 140 141 cmd = [ 142 get_msdf_binary(), 143 "-font", font_path, 144 "-type", type, 145 "-format", "png", 146 "-imageout", f"{output_filename}.png", 147 "-size", str(font_size), 148 "-pxrange", "4", 149 "-dimensions", str(resolution), str(resolution), 150 "-chars", chars_str, 151 "-uniformgrid", 152 "-uniformcols", str(grid_size), 153 "-uniformcell", str(cell_size), str(cell_size), 154 "-errorcorrection", "auto-full", 155 "-scanline", 156 #"-angle", "15d", 157 "-edgecoloring", "distance" 158 ] 159 160 try: 161 print("Running msdf-atlas-gen...") 162 print("Command:", end=" ") 163 for arg in cmd: 164 if arg.startswith('-'): 165 print(f"\n {arg}", end=" ") 166 else: 167 print(arg, end=" ") 168 print() 169 result = subprocess.run(cmd, check=True, capture_output=True, text=True) 170 171 # Print the output 172 if result.stdout: 173 print("msdf-atlas-gen output:") 174 print(result.stdout) 175 176 # Rearrange the atlas to include gaps for non-printable characters 177 print("Rearranging atlas...") 178 rearrange_atlas(resolution, cell_size, cell_size, draw_grid, type, font_name) 179 180 # Generate or update Unity meta file 181 generate_unity_meta(output_filename, resolution) 182 return True 183 except subprocess.CalledProcessError as e: 184 print(f"Error generating atlas: {e}") 185 print(f"Error output: {e.stderr}") 186 return False 187 188def draw_grid_lines(image, resolution): 189 """Draw red grid lines on the image""" 190 draw = ImageDraw.Draw(image) 191 grid_size = calculate_grid_size(CHAR_RANGES) 192 193 # Draw vertical lines 194 for x in range(grid_size): 195 line_x = x * resolution // grid_size 196 draw.line([(line_x, 0), (line_x, resolution-1)], fill=(255, 0, 0), width=1) 197 198 # Draw horizontal lines 199 for y in range(grid_size): 200 line_y = y * resolution // grid_size 201 draw.line([(0, line_y), (resolution-1, line_y)], fill=(255, 0, 0), width=1) 202 203 # Draw the final borders 204 draw.line([(resolution-1, 0), (resolution-1, resolution-1)], fill=(255, 0, 0), width=1) 205 draw.line([(0, resolution-1), (resolution-1, resolution-1)], fill=(255, 0, 0), width=1) 206 207def rearrange_atlas(resolution, cell_width, cell_height, draw_grid=False, type="msdf", font_name=""): 208 """Rearrange the atlas to include gaps for non-printable characters""" 209 # Update input and output filenames to match generate_atlas format 210 input_filename = f"atlas_{font_name}_{resolution}_{type}.png" 211 original = Image.open(input_filename) 212 new_atlas = Image.new('RGBA', (resolution, resolution), (0, 0, 0, 255)) 213 214 grid_size = calculate_grid_size(CHAR_RANGES) 215 cell_size = resolution // grid_size 216 217 # Track current position in the source atlas 218 source_index = 0 219 220 # Process each character range 221 for start, end in CHAR_RANGES: 222 for ascii_code in range(start, end + 1): 223 # Calculate source position (original atlas) 224 source_x = (source_index % grid_size) * cell_size 225 source_y = (source_index // grid_size) * cell_size 226 227 # Calculate target position (new atlas) 228 target_x = ((ascii_code + 1) % grid_size) * cell_size 229 target_y = ((ascii_code + 1) // grid_size) * cell_size 230 231 # Extract and paste the glyph 232 glyph = original.crop(( 233 source_x, 234 source_y, 235 source_x + cell_size, 236 source_y + cell_size 237 )) 238 new_atlas.paste(glyph, (target_x, target_y)) 239 source_index += 1 240 241 # Draw the grid lines only if requested 242 if draw_grid: 243 draw_grid_lines(new_atlas, resolution) 244 245 # Calculate actual used dimensions 246 used_resolution = cell_size * grid_size 247 # Crop to used dimensions and resize back to requested resolution 248 used_atlas = new_atlas.crop((0, 0, used_resolution, used_resolution)) 249 final_atlas = used_atlas.resize((resolution, resolution), Image.LANCZOS) 250 251 # Save with the same filename format (no change needed since input/output are the same) 252 final_atlas.save(input_filename) 253 print("Atlas rearranged successfully!") 254 255def generate_unity_meta(basename, resolution): 256 """Generate or update Unity meta file for the atlas texture.""" 257 meta_path = f"{basename}.png.meta" 258 existing_guid = None 259 260 # Try to read existing GUID if meta file exists 261 if os.path.exists(meta_path): 262 with open(meta_path, 'r') as f: 263 for line in f: 264 if 'guid: ' in line: 265 existing_guid = line.split('guid: ')[1].strip() 266 break 267 268 # Generate new GUID if none exists 269 guid = existing_guid or ''.join('%x' % random.randrange(16) for _ in range(32)) 270 271 meta_template = f'''fileFormatVersion: 2 272guid: {guid} 273TextureImporter: 274 internalIDToNameTable: [] 275 externalObjects: {{}} 276 serializedVersion: 12 277 mipmaps: 278 mipMapMode: 0 279 enableMipMap: 1 280 sRGBTexture: 1 281 linearTexture: 0 282 fadeOut: 0 283 borderMipMap: 0 284 mipMapsPreserveCoverage: 0 285 alphaTestReferenceValue: 0.5 286 mipMapFadeDistanceStart: 1 287 mipMapFadeDistanceEnd: 3 288 bumpmap: 289 convertToNormalMap: 0 290 externalNormalMap: 0 291 heightScale: 0.25 292 normalMapFilter: 0 293 flipGreenChannel: 0 294 isReadable: 0 295 streamingMipmaps: 0 296 streamingMipmapsPriority: 0 297 vTOnly: 0 298 ignoreMipmapLimit: 0 299 grayScaleToAlpha: 0 300 generateCubemap: 6 301 cubemapConvolution: 0 302 seamlessCubemap: 0 303 textureFormat: 1 304 maxTextureSize: {resolution} 305 textureSettings: 306 serializedVersion: 2 307 filterMode: 1 308 aniso: 1 309 mipBias: 0 310 wrapU: 0 311 wrapV: 0 312 wrapW: 0 313 nPOTScale: 1 314 lightmap: 0 315 compressionQuality: 50 316 spriteMode: 0 317 spriteExtrude: 1 318 spriteMeshType: 1 319 alignment: 0 320 spritePivot: {{x: 0.5, y: 0.5}} 321 spritePixelsToUnits: 100 322 spriteBorder: {{x: 0, y: 0, z: 0, w: 0}} 323 spriteGenerateFallbackPhysicsShape: 1 324 alphaUsage: 1 325 alphaIsTransparency: 0 326 spriteTessellationDetail: -1 327 textureType: 0 328 textureShape: 1 329 singleChannelComponent: 0 330 flipbookRows: 1 331 flipbookColumns: 1 332 maxTextureSizeSet: 0 333 compressionQualitySet: 0 334 textureFormatSet: 0 335 ignorePngGamma: 0 336 applyGammaDecoding: 0 337 swizzle: 50462976 338 cookieLightType: 0 339 platformSettings: 340 - serializedVersion: 3 341 buildTarget: DefaultTexturePlatform 342 maxTextureSize: {resolution} 343 resizeAlgorithm: 0 344 textureFormat: -1 345 textureCompression: 2 346 compressionQuality: 50 347 crunchedCompression: 0 348 allowsAlphaSplitting: 0 349 overridden: 0 350 ignorePlatformSupport: 0 351 androidETC2FallbackOverride: 0 352 forceMaximumCompressionQuality_BC6H_BC7: 0 353 - serializedVersion: 3 354 buildTarget: Standalone 355 maxTextureSize: {resolution} 356 resizeAlgorithm: 0 357 textureFormat: 3 358 textureCompression: 1 359 compressionQuality: 50 360 crunchedCompression: 0 361 allowsAlphaSplitting: 0 362 overridden: 1 363 ignorePlatformSupport: 0 364 androidETC2FallbackOverride: 0 365 forceMaximumCompressionQuality_BC6H_BC7: 0 366 - serializedVersion: 3 367 buildTarget: Android 368 maxTextureSize: {resolution} 369 resizeAlgorithm: 0 370 textureFormat: -1 371 textureCompression: 1 372 compressionQuality: 50 373 crunchedCompression: 0 374 allowsAlphaSplitting: 0 375 overridden: 0 376 ignorePlatformSupport: 0 377 androidETC2FallbackOverride: 0 378 forceMaximumCompressionQuality_BC6H_BC7: 0 379 spriteSheet: 380 serializedVersion: 2 381 sprites: [] 382 outline: [] 383 physicsShape: [] 384 bones: [] 385 spriteID: 386 internalID: 0 387 vertices: [] 388 indices: 389 edges: [] 390 weights: [] 391 secondaryTextures: [] 392 nameFileIdTable: {{}} 393 mipmapLimitGroupName: 394 pSDRemoveMatte: 0 395 userData: 396 assetBundleName: 397 assetBundleVariant: 398''' 399 400 with open(meta_path, 'w') as f: 401 f.write(meta_template) 402 403def main(): 404 parser = argparse.ArgumentParser(description='Generate a font atlas using msdf-atlas-gen') 405 parser.add_argument('font_path', help='Path to the font file (.ttf/.otf)') 406 parser.add_argument('resolution', type=int, help='Total atlas resolution (width=height)') 407 parser.add_argument('--grid', type=bool, default=False, help='Draw grid lines on the output atlas') 408 parser.add_argument('--type', type=str, default="msdf", choices=ATLAS_TYPES, 409 help='Type of atlas to generate') 410 parser.add_argument('--font-size', type=int, help='Base font size in pixels at 512 resolution. Will be scaled for other resolutions.') 411 args = parser.parse_args() 412 413 # Verify font file exists 414 if not os.path.isfile(args.font_path): 415 print(f"Error: Font file not found at {args.font_path}") 416 return 417 418 generate_atlas(args.font_path, args.resolution, draw_grid=args.grid, type=args.type, font_size=args.font_size) 419 420if __name__ == "__main__": 421 main()