529 lines
13 KiB
Odin
529 lines
13 KiB
Odin
package main
|
||
|
||
import "core:bytes"
|
||
import "core:c"
|
||
import "core:encoding/json"
|
||
import "core:fmt"
|
||
import "core:image"
|
||
import "core:image/png"
|
||
import "core:math"
|
||
import "core:os"
|
||
import "core:path/filepath"
|
||
import "core:slice"
|
||
import "core:strconv"
|
||
import "core:strings"
|
||
import stbi "vendor:stb/image"
|
||
import stbrp "vendor:stb/rect_pack"
|
||
|
||
// Authoring-side clip block from manifest.json
|
||
Clip_Manifest :: struct {
|
||
folder: string,
|
||
fps: f32,
|
||
loop: bool,
|
||
}
|
||
|
||
// Authoring-side character manifest
|
||
Manifest :: struct {
|
||
id: string,
|
||
pixels_per_unit: f32,
|
||
pivot: [2]f32,
|
||
clips: map[string]Clip_Manifest,
|
||
}
|
||
|
||
// Runtime frame description written to *.char.json
|
||
Frame_Def :: struct {
|
||
rect: [4]int, // x, y, w, h in atlas pixels
|
||
source_size: [2]int,
|
||
trim_offset: [2]int,
|
||
}
|
||
|
||
// Runtime clip description
|
||
Clip_Def :: struct {
|
||
loop: bool,
|
||
fps: f32,
|
||
frames: []Frame_Def,
|
||
}
|
||
|
||
// Runtime character package metadata
|
||
Char_Def :: struct {
|
||
version: int,
|
||
id: string,
|
||
atlas: string, // filename relative to this .char.json
|
||
pixels_per_unit: f32,
|
||
pivot: [2]f32,
|
||
clips: map[string]Clip_Def,
|
||
}
|
||
|
||
// Packed atlas pixels + per-frame rects (indexed like the combined frames list)
|
||
Atlas :: struct {
|
||
pixels: []u8,
|
||
width: int,
|
||
height: int,
|
||
rects: [][4]int,
|
||
source_sizes: [][2]int,
|
||
trim_offsets: [][2]int,
|
||
}
|
||
|
||
// Where one clip's frames sit inside the packed atlas frame list
|
||
Clip_Range :: struct {
|
||
name: string,
|
||
clip: Clip_Manifest,
|
||
start: int, // index into the combined frames / atlas rects
|
||
count: int,
|
||
}
|
||
|
||
main :: proc() {
|
||
if len(os.args) < 3 {
|
||
fmt.eprintfln("usage: assetbake <character dir> <output dir>")
|
||
os.exit(1)
|
||
}
|
||
bake_character(os.args[1], os.args[2])
|
||
}
|
||
|
||
// bake_character is the full pipeline for one character folder.
|
||
bake_character :: proc(in_dir, out_dir: string) {
|
||
manifest := load_manifest(in_dir)
|
||
if len(manifest.clips) == 0 {
|
||
fmt.eprintfln("manifest has no clips")
|
||
os.exit(1)
|
||
}
|
||
|
||
// Load every clip from the manifest into one frame list (deterministic clip order)
|
||
frames, ranges := load_all_clips(in_dir, manifest)
|
||
defer destroy_frames(frames)
|
||
defer delete(ranges)
|
||
|
||
// Clips may differ in size (idle vs walk); pack with a real rectangle packer
|
||
atlas := pack_atlas(frames)
|
||
defer delete(atlas.pixels)
|
||
defer delete(atlas.rects)
|
||
defer delete(atlas.source_sizes)
|
||
defer delete(atlas.trim_offsets)
|
||
|
||
ensure_dir(out_dir)
|
||
|
||
atlas_name := fmt.tprintf("%s.atlas.png", manifest.id)
|
||
write_atlas_png(out_dir, atlas_name, atlas)
|
||
|
||
char_def := build_char_def(manifest, ranges, atlas_name, atlas)
|
||
defer destroy_char_def(char_def)
|
||
|
||
write_char_json(out_dir, fmt.tprintf("%s.char.json", manifest.id), char_def)
|
||
fmt.printfln("baked %s (%d clips, %d frames)", manifest.id, len(ranges), len(frames))
|
||
}
|
||
|
||
// load_manifest reads content/.../manifest.json into a Manifest.
|
||
load_manifest :: proc(character_dir: string) -> Manifest {
|
||
path, join_err := filepath.join({character_dir, "manifest.json"})
|
||
if join_err != nil {
|
||
fmt.eprintfln("join failed: %v", join_err)
|
||
os.exit(1)
|
||
}
|
||
defer delete(path)
|
||
|
||
data, read_err := os.read_entire_file(path, context.allocator)
|
||
if read_err != nil {
|
||
fmt.eprintfln("failed to read %s: %v", path, read_err)
|
||
os.exit(1)
|
||
}
|
||
defer delete(data)
|
||
|
||
manifest: Manifest
|
||
if err := json.unmarshal(data, &manifest); err != nil {
|
||
fmt.eprintfln("json unmarshal failed: %v", err)
|
||
os.exit(1)
|
||
}
|
||
return manifest
|
||
}
|
||
|
||
// load_all_clips walks every manifest clip (names sorted), loads PNGs, and records atlas ranges.
|
||
// Within a clip, all frames must share one size. Different clips may use different sizes.
|
||
// Caller owns frames (destroy_frames) and ranges (delete).
|
||
load_all_clips :: proc(character_dir: string, manifest: Manifest) -> ([]^image.Image, []Clip_Range) {
|
||
clip_names := sorted_clip_names(manifest)
|
||
defer delete(clip_names)
|
||
|
||
loaded: [dynamic]^image.Image
|
||
ranges := make([]Clip_Range, len(clip_names))
|
||
|
||
for name, i in clip_names {
|
||
clip := manifest.clips[name]
|
||
paths := list_sorted_frame_paths(character_dir, clip.folder)
|
||
defer delete_paths(paths)
|
||
|
||
start := len(loaded)
|
||
for path in paths {
|
||
append(&loaded, load_frame(path))
|
||
}
|
||
|
||
// Enforce uniform size inside this clip only
|
||
validate_clip_frame_sizes(name, loaded[start:])
|
||
|
||
ranges[i] = Clip_Range {
|
||
name = name,
|
||
clip = clip,
|
||
start = start,
|
||
count = len(paths),
|
||
}
|
||
fw := loaded[start].width
|
||
fh := loaded[start].height
|
||
fmt.printfln(
|
||
"clip %s: %d frames %dx%d (atlas slots %d..%d)",
|
||
name,
|
||
len(paths),
|
||
fw,
|
||
fh,
|
||
start,
|
||
start + len(paths) - 1,
|
||
)
|
||
}
|
||
|
||
if len(loaded) == 0 {
|
||
fmt.eprintfln("no frames loaded")
|
||
os.exit(1)
|
||
}
|
||
|
||
frames := make([]^image.Image, len(loaded))
|
||
copy(frames, loaded[:])
|
||
delete(loaded)
|
||
|
||
fmt.printfln("loaded %d frames total across %d clips", len(frames), len(ranges))
|
||
return frames, ranges
|
||
}
|
||
|
||
// validate_clip_frame_sizes requires every frame in one clip to match the first frame's size.
|
||
validate_clip_frame_sizes :: proc(clip_name: string, frames: []^image.Image) {
|
||
if len(frames) == 0 {
|
||
return
|
||
}
|
||
fw := frames[0].width
|
||
fh := frames[0].height
|
||
for frame, i in frames[1:] {
|
||
if frame.width != fw || frame.height != fh {
|
||
fmt.eprintfln(
|
||
"clip %s: frame size mismatch at %d: got %dx%d, expected %dx%d",
|
||
clip_name,
|
||
i + 1,
|
||
frame.width,
|
||
frame.height,
|
||
fw,
|
||
fh,
|
||
)
|
||
os.exit(1)
|
||
}
|
||
}
|
||
}
|
||
|
||
// sorted_clip_names returns manifest clip keys in alphabetical order for stable bakes.
|
||
sorted_clip_names :: proc(manifest: Manifest) -> []string {
|
||
names := make([]string, len(manifest.clips))
|
||
i := 0
|
||
for name in manifest.clips {
|
||
names[i] = name // keys owned by the map; do not delete individually
|
||
i += 1
|
||
}
|
||
slice.sort(names)
|
||
return names
|
||
}
|
||
|
||
// list_sorted_frame_paths globs *.png under folder and sorts by trailing frame number.
|
||
// Caller owns the returned paths (use delete_paths).
|
||
list_sorted_frame_paths :: proc(character_dir, folder: string) -> []string {
|
||
clip_dir, clip_join_err := filepath.join({character_dir, folder})
|
||
if clip_join_err != nil {
|
||
fmt.eprintfln("join failed: %v", clip_join_err)
|
||
os.exit(1)
|
||
}
|
||
defer delete(clip_dir)
|
||
|
||
pattern, pattern_err := filepath.join({clip_dir, "*.png"})
|
||
if pattern_err != nil {
|
||
fmt.eprintfln("pattern join failed: %v", pattern_err)
|
||
os.exit(1)
|
||
}
|
||
defer delete(pattern)
|
||
|
||
matches, glob_err := filepath.glob(pattern)
|
||
if glob_err != nil {
|
||
fmt.eprintfln("glob failed: %v", glob_err)
|
||
os.exit(1)
|
||
}
|
||
if len(matches) == 0 {
|
||
fmt.eprintfln("no pngs in %s", clip_dir)
|
||
os.exit(1)
|
||
}
|
||
|
||
// Sort by trailing frame number so _10 comes after _9, not after _1
|
||
slice.sort_by(matches, proc(a, b: string) -> bool {
|
||
return frame_index(a) < frame_index(b)
|
||
})
|
||
return matches
|
||
}
|
||
|
||
delete_paths :: proc(paths: []string) {
|
||
for p in paths {
|
||
delete(p)
|
||
}
|
||
delete(paths)
|
||
}
|
||
|
||
load_frame :: proc(path: string) -> ^image.Image {
|
||
img, err := png.load_from_file(path)
|
||
if err != nil {
|
||
fmt.eprintfln("image load failed (%s): %v", path, err)
|
||
os.exit(1)
|
||
}
|
||
if img.channels != 4 {
|
||
fmt.eprintfln("expected RGBA for %s, got %d channels", path, img.channels)
|
||
os.exit(1)
|
||
}
|
||
return img
|
||
}
|
||
|
||
destroy_frames :: proc(frames: []^image.Image) {
|
||
for f in frames {
|
||
image.destroy(f)
|
||
}
|
||
delete(frames)
|
||
}
|
||
|
||
opaque_bounds_rgba :: proc(pixels: []u8, iw, ih: int, alpha_min: u8 = 1) -> (x, y, w, h: int) {
|
||
min_x, min_y := iw, ih
|
||
max_x, max_y := -1, -1
|
||
|
||
for py in 0 ..< ih {
|
||
for px in 0 ..< iw {
|
||
i := (py * iw + px) * 4
|
||
if i + 3 >= len(pixels) do continue
|
||
a := pixels[i + 3]
|
||
if a >= alpha_min {
|
||
if px < min_x do min_x = px
|
||
if py < min_y do min_y = py
|
||
if px > max_x do max_x = px
|
||
if py > max_y do max_y = py
|
||
}
|
||
}
|
||
}
|
||
|
||
if max_x < min_x {
|
||
return 0, 0, 1, 1
|
||
}
|
||
return min_x, min_y, max_x - min_x + 1, max_y - min_y + 1
|
||
}
|
||
|
||
opaque_bounds :: proc(img: ^image.Image, alpha_min: u8 = 1) -> (x, y, w, h: int) {
|
||
iw, ih := int(img.width), int(img.height)
|
||
pixels := bytes.buffer_to_bytes(&img.pixels)
|
||
return opaque_bounds_rgba(pixels, iw, ih, alpha_min)
|
||
}
|
||
|
||
pack_atlas :: proc(frames: []^image.Image) -> Atlas {
|
||
PADDING :: 1
|
||
|
||
pack_rects := make([]stbrp.Rect, len(frames))
|
||
defer delete(pack_rects)
|
||
|
||
trims := make([][4]int, len(frames))
|
||
defer delete(trims)
|
||
source_sizes := make([][2]int, len(frames))
|
||
trim_offsets := make([][2]int, len(frames))
|
||
|
||
total_area := 0
|
||
max_w := 0
|
||
max_h := 0
|
||
for frame, i in frames {
|
||
tx, ty, tw, th := opaque_bounds(frame)
|
||
trims[i] = {tx, ty, tw, th}
|
||
source_sizes[i] = {int(frame.width), int(frame.height)}
|
||
trim_offsets[i] = {tx, ty}
|
||
|
||
w := tw + PADDING
|
||
h := th + PADDING
|
||
pack_rects[i] = stbrp.Rect {
|
||
id = c.int(i),
|
||
w = stbrp.Coord(w),
|
||
h = stbrp.Coord(h),
|
||
}
|
||
total_area += w * h
|
||
max_w = max(max_w, w)
|
||
max_h = max(max_h, h)
|
||
}
|
||
|
||
side := max(max_w, max_h, int(math.ceil(math.sqrt(f64(total_area)))))
|
||
atlas_w, atlas_h := 0, 0
|
||
packed := false
|
||
for attempt in 0 ..< 16 {
|
||
atlas_w = side
|
||
atlas_h = side
|
||
if try_pack_rects(atlas_w, atlas_h, pack_rects) {
|
||
packed = true
|
||
break
|
||
}
|
||
side = max(side + 64, int(math.ceil(f64(side) * 1.25)))
|
||
_ = attempt
|
||
}
|
||
if !packed {
|
||
fmt.eprintfln("failed to pack %d frames into an atlas", len(frames))
|
||
os.exit(1)
|
||
}
|
||
|
||
pixels := make([]u8, atlas_w * atlas_h * 4)
|
||
rects := make([][4]int, len(frames))
|
||
|
||
for pr in pack_rects {
|
||
if !pr.was_packed {
|
||
fmt.eprintfln("frame %d was not packed", pr.id)
|
||
os.exit(1)
|
||
}
|
||
idx := int(pr.id)
|
||
frame := frames[idx]
|
||
trim := trims[idx]
|
||
tx, ty, tw, th := trim[0], trim[1], trim[2], trim[3]
|
||
dst_x := int(pr.x)
|
||
dst_y := int(pr.y)
|
||
|
||
src := bytes.buffer_to_bytes(&frame.pixels)
|
||
src_w := int(frame.width)
|
||
for y in 0 ..< th {
|
||
src_row := src[((ty + y) * src_w + tx) * 4:][:tw * 4]
|
||
dst_i := ((dst_y + y) * atlas_w + dst_x) * 4
|
||
copy(pixels[dst_i:], src_row)
|
||
}
|
||
|
||
rects[idx] = {dst_x, dst_y, tw, th}
|
||
}
|
||
|
||
fmt.printfln("packed atlas %dx%d", atlas_w, atlas_h)
|
||
return Atlas {
|
||
pixels = pixels,
|
||
width = atlas_w,
|
||
height = atlas_h,
|
||
rects = rects,
|
||
source_sizes = source_sizes,
|
||
trim_offsets = trim_offsets,
|
||
}
|
||
}
|
||
|
||
// try_pack_rects attempts to place all rects into a width×height bin.
|
||
try_pack_rects :: proc(width, height: int, rects: []stbrp.Rect) -> bool {
|
||
nodes := make([]stbrp.Node, width)
|
||
defer delete(nodes)
|
||
|
||
ctx: stbrp.Context
|
||
stbrp.init_target(&ctx, c.int(width), c.int(height), raw_data(nodes), c.int(len(nodes)))
|
||
stbrp.setup_allow_out_of_mem(&ctx, true)
|
||
|
||
return stbrp.pack_rects(&ctx, raw_data(rects), c.int(len(rects))) != 0
|
||
}
|
||
|
||
ensure_dir :: proc(path: string) {
|
||
if err := os.make_directory_all(path); err != nil && err != .Exist {
|
||
fmt.eprintfln("failed to create output dir %s: %v", path, err)
|
||
os.exit(1)
|
||
}
|
||
}
|
||
|
||
// write_atlas_png writes atlas pixels to out_dir/atlas_name via stb_image_write.
|
||
write_atlas_png :: proc(out_dir, atlas_name: string, atlas: Atlas) {
|
||
atlas_path, atlas_join_err := filepath.join({out_dir, atlas_name})
|
||
if atlas_join_err != nil {
|
||
fmt.eprintfln("atlas path join failed: %v", atlas_join_err)
|
||
os.exit(1)
|
||
}
|
||
defer delete(atlas_path)
|
||
|
||
atlas_cpath := strings.clone_to_cstring(atlas_path)
|
||
defer delete(atlas_cpath)
|
||
|
||
if stbi.write_png(
|
||
atlas_cpath,
|
||
i32(atlas.width),
|
||
i32(atlas.height),
|
||
4,
|
||
raw_data(atlas.pixels),
|
||
i32(atlas.width * 4),
|
||
) == 0 {
|
||
fmt.eprintfln("failed to write atlas: %s", atlas_path)
|
||
os.exit(1)
|
||
}
|
||
fmt.printfln("wrote %s (%dx%d)", atlas_path, atlas.width, atlas.height)
|
||
}
|
||
|
||
// build_char_def builds runtime metadata for every baked clip using packed rects.
|
||
build_char_def :: proc(
|
||
manifest: Manifest,
|
||
ranges: []Clip_Range,
|
||
atlas_name: string,
|
||
atlas: Atlas,
|
||
) -> Char_Def {
|
||
char_clips := make(map[string]Clip_Def)
|
||
|
||
for range in ranges {
|
||
frame_defs := make([]Frame_Def, range.count)
|
||
for i in 0 ..< range.count {
|
||
slot := range.start + i
|
||
r := atlas.rects[slot]
|
||
frame_defs[i] = Frame_Def {
|
||
rect = r,
|
||
source_size = atlas.source_sizes[slot],
|
||
trim_offset = atlas.trim_offsets[slot],
|
||
}
|
||
}
|
||
|
||
char_clips[range.name] = Clip_Def {
|
||
loop = range.clip.loop,
|
||
fps = range.clip.fps,
|
||
frames = frame_defs,
|
||
}
|
||
}
|
||
|
||
return Char_Def {
|
||
version = 1,
|
||
id = manifest.id,
|
||
atlas = atlas_name,
|
||
pixels_per_unit = manifest.pixels_per_unit,
|
||
pivot = manifest.pivot,
|
||
clips = char_clips,
|
||
}
|
||
}
|
||
|
||
destroy_char_def :: proc(char_def: Char_Def) {
|
||
for _, clip in char_def.clips {
|
||
delete(clip.frames)
|
||
}
|
||
delete(char_def.clips)
|
||
}
|
||
|
||
write_char_json :: proc(out_dir, char_name: string, char_def: Char_Def) {
|
||
char_json, marshal_err := json.marshal(char_def, {pretty = true, use_spaces = true})
|
||
if marshal_err != nil {
|
||
fmt.eprintfln("json marshal failed: %v", marshal_err)
|
||
os.exit(1)
|
||
}
|
||
defer delete(char_json)
|
||
|
||
char_path, char_join_err := filepath.join({out_dir, char_name})
|
||
if char_join_err != nil {
|
||
fmt.eprintfln("char path join failed: %v", char_join_err)
|
||
os.exit(1)
|
||
}
|
||
defer delete(char_path)
|
||
|
||
if err := os.write_entire_file(char_path, char_json); err != nil {
|
||
fmt.eprintfln("failed to write %s: %v", char_path, err)
|
||
os.exit(1)
|
||
}
|
||
fmt.printfln("wrote %s", char_path)
|
||
}
|
||
|
||
// frame_index pulls the trailing number from names like skeleton-Idle_17.png
|
||
frame_index :: proc(path: string) -> int {
|
||
name := filepath.stem(path)
|
||
idx := strings.last_index_byte(name, '_')
|
||
if idx < 0 {
|
||
return 0
|
||
}
|
||
n, _ := strconv.parse_int(name[idx + 1:])
|
||
return n
|
||
}
|