yum/3ner

A toon shader for Unity's BIRP.

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

yumAdd letter grid animation86438c8

master
13.5 KiB421 linesraw
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()