Skip to content

Instantly share code, notes, and snippets.

@markusfisch
Last active August 12, 2024 21:06
Show Gist options
  • Save markusfisch/6442297 to your computer and use it in GitHub Desktop.
Save markusfisch/6442297 to your computer and use it in GitHub Desktop.
bash script to build a texture atlas with a little help from ImageMagick

mkatlas

BASH script to build a texture atlas for small/medium web sites/games. Requires ImageMagick.

Usage

Basic

Just run

$ ./mkatlas img/*

which will create atlas.png and outputs the corresponding sprite regions in JSON format:

var atlas={
first:{x:298,y:0,w:35,h:22},
second:{x:254,y:0,w:44,h:33},
...
}

Advanced

Alternatively you may use just markers in your JavaScript to insert the frame data in place. This way you wouldn't have to have a extra atlas object and wouldn't need to do any additional mapping. For example:

var obj = {
	name: "Jim",
	frame: {/*first*/x:0,y:0,w:0,h:0},
	live: 100 };

This file may be patched with patchatlas like this:

$ ./mkatlas img/* | ./patchatlas index.html

Switches

There are a few environment variables for fine tuning. You may simply put them before invocation, e.g.:

$ ATLAS=atlas.jpg ./mkatlas img/*

ATLAS

File name (and image format) of atlas image.

MAX_SIZE

Maximum width/height of atlas. Default is 2048. By default, the atlas is trimmed to the smallest possible size. MAX_SIZE just gives a maximum boundary.

MIN_SIZE

Minimum width/height of atlas. Default is 0. Use it to force a certain size, e.g. to have dimensions with a power of 2 (for OpenGL/WebGL).

MARGIN

Margin around each sprite. Default is 0.

EXPAND

If margin is greater than 0, then expand sprites whose file names match the expression in EXPAND by mirroring their edges, e.g.:

$ MARGIN=2 EXPAND='tile_*' ./mkatlas img/*

This is useful for textures that will be used with OpenGL/WebGL's CLAMP_TO_EDGE.

#!/usr/bin/env bash
# Compose images into atlas
atlas_cache_compose() {
local W H
W=$(< "$CACHE/width")
H=$(< "$CACHE/height")
# MIN_SIZE is not a misspelling
# shellcheck disable=SC2153
(( W < MIN_SIZE )) && W=$MIN_SIZE
# shellcheck disable=SC2153
(( H < MIN_SIZE )) && H=$MIN_SIZE
# don't quote $CACHE/args because this should be word splitted
# shellcheck disable=SC2046
[ -f "$CACHE/args" ] && convert \
-size "${W}x${H}" \
xc:transparent \
$(< "$CACHE/args") \
-strip \
"${ATLAS:-atlas.png}"
}
# Print JSON and build arguments for convert
atlas_cache_summarize() {
echo "var atlas={"
find "$CACHE" -type f -name image | while read -r
do
local X Y W H FILE
read -r X Y W H FILE < "$REPLY"
echo "$FILE -geometry +$X+$Y -composite" >> "$CACHE/args"
if (( MARGIN > 0 ))
then
(( X += MARGIN ))
(( Y += MARGIN ))
(( W -= MARGIN*2 ))
(( H -= MARGIN*2 ))
fi
local NAME=${FILE##*/}
echo "${NAME%.*}:{x:$X,y:$Y,w:$W,h:$H},"
done
echo "};"
}
# Insert image, packing algorithm from
# http://www.blackpawn.com/texts/lightmaps/default.html
#
# @param 1 - image width
# @param 2 - image height
# @param 3 - image file
atlas_cache_insert() {
[ -f "$CACHE/candidates" ] || return 1
local INDEX TARGET
# INDEX is unused but required to consume the first argument
# shellcheck disable=SC2034
while read -r INDEX TARGET
do
break
done <<< "$(sort "$CACHE/candidates")"
[ -f "$TARGET/rect" ] || return 1
local X Y W H
read -r X Y W H < "$TARGET/rect"
if (( W < $1 )) || (( H < $2 ))
then
return 1
fi
mkdir "$TARGET/child0" "$TARGET/child1" || return $?
local RW=$(( W-$1 ))
local RH=$(( H-$2 ))
if (( RW > RH ))
then
# +-------+---+
# | image | |
# +-------+ |
# | | r |
# | b | |
# | | |
# +-------+---+
echo $(( X+$1 )) "$Y" "$RW" "$H" > "$TARGET/child0/rect"
echo "$X" $(( Y+$2 )) "$1" "$RH" > "$TARGET/child1/rect"
else
# +-------+---+
# | image | r |
# +-------+---+
# | |
# | b |
# | |
# +-----------+
echo $(( X+$1 )) "$Y" "$RW" "$2" > "$TARGET/child0/rect"
echo "$X" $(( Y+$2 )) "$W" "$RH" > "$TARGET/child1/rect"
fi
local RIGHT=$(( X+$1 ))
(( RIGHT > $(< "$CACHE/width") )) && echo "$RIGHT" > "$CACHE/width"
local BOTTOM=$(( Y+$2 ))
(( BOTTOM > $(< "$CACHE/height") )) && echo "$BOTTOM" > "$CACHE/height"
echo "$X" "$Y" "$1" "$2" "$3" > "$TARGET/image"
}
# Find possible candidates and give them a sort index
#
# @param 1 - image width
# @param 2 - image height
# @param 3 - image file
atlas_cache_find_nodes() {
if [ -d "$NODE/child0" ]
then
NODE="$NODE/child0" atlas_cache_find_nodes "$1" "$2" "$3"
NODE="$NODE/child1" atlas_cache_find_nodes "$1" "$2" "$3"
return
fi
[ -f "$NODE/rect" ] || return 1
local X Y W H
read -r X Y W H < "$NODE/rect"
if (( W < $1 )) || (( H < $2 ))
then
return
fi
local MAX_WIDTH
MAX_WIDTH=$(< "$CACHE/width")
local MAX_HEIGHT
MAX_HEIGHT=$(< "$CACHE/height")
local RIGHT=$(( X+$1 ))
local BOTTOM=$(( Y+$2 ))
(( RIGHT > MAX_WIDTH )) && MAX_WIDTH=$RIGHT
(( BOTTOM > MAX_HEIGHT )) && MAX_HEIGHT=$BOTTOM
printf '%08d %s\n' \
$(( MAX_WIDTH+MAX_HEIGHT )) \
"$NODE" >> "$CACHE/candidates"
}
# Sort files and insert them into the atlas
atlas_cache_compile() {
local NODE=$CACHE
local W H FILE
# MAX is unused but required to consume the sorting index
# shellcheck disable=SC2034
while read -r W H FILE
do
[ "$FILE" ] || continue
printf '%08d %d %d %s\n' $(( W+H )) "$W" "$H" "$FILE"
done | sort -r | while read -r MAX W H FILE
do
rm -f "$CACHE/candidates"
if ! atlas_cache_find_nodes "$W" "$H" "$FILE" ||
! atlas_cache_insert "$W" "$H" "$FILE"
then
echo "error: cannot insert $FILE" >&2
return 1
fi
done
}
# Read 'width height file' from standard input and create atlas
atlas_create_from_list() {
local CACHE
CACHE=$(mktemp -d "${0##*/}.XXXXXXXXXX") || return $?
local MAX_SIZE=${MAX_SIZE:-2048}
echo 0 0 "$MAX_SIZE" "$MAX_SIZE" > "$CACHE/rect"
echo 0 > "$CACHE/width"
echo 0 > "$CACHE/height"
atlas_cache_compile &&
atlas_cache_summarize &&
atlas_cache_compose
rm -rf "$CACHE"
}
# Create texture atlas from given image files
#
# @param ... - image files
atlas_create() {
local INKSCAPE=${INKSCAPE:-$(which inkscape)}
local TMPDIR
TMPDIR=$(mktemp -d "${0##*/}.XXXXXXXXXX") || return $?
# prepare source files
local SRC
for SRC
do
local COPY="$TMPDIR/${SRC##*/}" BORDER=
case ${SRC##*.} in
svg)
COPY="${COPY%.*}.png"
# use inkscape if available
[ "$INKSCAPE" ] &&
$INKSCAPE "$SRC" \
-z -e "$COPY" &>/dev/null &&
SRC=${COPY}
;;
esac
if (( MARGIN > 0 ))
then
# title_* should do glob matching
# shellcheck disable=SC2053
if [[ ${SRC##*/} == ${EXPAND:-tile_*} ]]
then
local W
W=$(identify -format '%w,%h' "$SRC")
H=$(( MARGIN*2+${W#*,} ))
W=$(( MARGIN*2+${W%,*} ))
BORDER="-set option:distort:viewport \
${W}x${H}-${MARGIN}-${MARGIN} \
-virtual-pixel Mirror \
-filter point \
-distort SRT 0 \
+repage"
else
BORDER="-border $MARGIN"
fi
fi
# BORDER cannot be quoted because it will result in an
# empty argument which makes convert stall
# shellcheck disable=SC2086
convert \
-background none \
"$SRC" \
-bordercolor none -border 3x3 \
-trim \
$BORDER \
"$COPY"
done
identify -format '%w %h %d/%f\n' "$TMPDIR/"* | atlas_create_from_list
rm -rf "$TMPDIR"
}
readonly MARGIN=${MARGIN:-0}
if [ "${BASH_SOURCE[0]}" == "$0" ]
then
atlas_create "$@"
fi
#!/usr/bin/env bash
# Patch given JavaScript files with JSON read from stdin
#
# @param ... - JavaScript files to patch
atlas_patch() {
(( $# < 1 )) && {
echo "usage: ${0##*/} FILE..."
return 1
}
while read -r
do
case $REPLY in
*':{'*)
;;
*)
continue
;;
esac
local NAME=${REPLY%%:\{*}
[ "$NAME" ] || continue
local FRAME=${REPLY#*\{}
local PATCH="s^{/\\*${NAME}\\*/[\"xywh:0-9,]*}^{/*${NAME}*/${FRAME%,}^g"
local TMP=".${0##*/}-$$.tmp"
local SRC
for SRC
do
sed -e "$PATCH" < "$SRC" > "$TMP" && mv "$TMP" "$SRC"
done
done
}
if [ "${BASH_SOURCE[0]}" == "$0" ]
then
atlas_patch "$@"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment