Video game asset pipeline with CMake

Daniele's picture

Any video game (or generally any multimedia production) needs an asset pipeline: an automatic way to post-process source assets to build final versions that suit the needs of the game engine avoiding recurrent and error-prone manual work from artists. In this post I will show how this has been achieved, in our current production, using CMake.

Since the argument can quickly become complicated I will assume a good knowledge of CMake, giving only some brief examples.

Update 2010-09-19: for integration with XCode read Video game asset pipeline with CMake: XCode integration.

Directory layout

The first decision to make is about the directory layout of source assets. A good naming scheme can help everyone to understand why one file has been stored in a particular directory and what the asset pipeline will does with it.

Our current pipeline uses a scheme similar to this one:

<project>/
	assets/
		audio/
			sfx/
			tracks/
		graphics/
			backgrounds/
		intermediate/
			weaponIcons/

Assets are processed in function of the directory in which they are stored, hiding to the artists the transformations needed by the game engine:

  • all audio files in audio/sfx/ will be re-sampled at 11025 Hz, converted to 16 bits, mono, little-endian linear PCM format and finally stored in a CAF container;
  • all audio files in audio/tracks/ will be re-sampled at 44100 Hz and converted to stereo MP3 format;
  • all graphics files in graphics/backgrounds/ will be checked to have size 480x320 and converted in PVR format using 4 bpp, with perceptual error compression.

As our pipeline is not fully automated (e.g. the rendering of 3D models is not currently automatically managed), we use an intermediate/ directory to organize where artists have to save the results of their "intermediate" work; the asset pipeline will pick those files and will produce the needed outputs. E.g. intermediate/weaponIcons/ contains images (rendered from 3D models) that will be centered in a canvas of size 64x64 and finally stored as PVR.

A sample CMakeLists.txt

And now this is why you're reading this article, an example of intermediate/weaponIcons/CMakeLists.txt file:

# List of processed files.
set(SrcFiles
	missileIcon.png
	shockwaveIcon.png
	laserIcon.png
	mineIcon.png
)
 
# Include needed tools.
include(TextureTool)
include(GraphicsMagick)
 
# Check for unused files.
file(GLOB AllFiles RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} *.png)
list(REMOVE_ITEM AllFiles ${SrcFiles})
if (AllFiles)
	message(WARNING "Unused files: ${AllFiles}")
endif()
 
# Check sizes.
checkImageProperties(
	CHECK WIDTH EQ 48
	CHECK HEIGHT EQ 48
	FILES ${SrcFiles}
)
 
# Crop to 64x64.
graphicsMagick(WeaponIcons64
	COMMAND convert
	ARGUMENTS -bordercolor transparent -border 32x32 -gravity center -crop 64x64+0+0
	FILES ${SrcFiles}
	OUTPUT_FILES SrcFiles64
)
 
# Convert to PVR.
textureTool(WeaponIconsPVR
	ALL
	OUTPUT_DIR ${OUTPUT_DIR}
	FORMAT pvr
	ENCODER pvrtc
	ERROR perceptual
	BITS 4
	FILES ${SrcFiles64}
)

When processed, CMake will do the following:

  1. reports unused files;
  2. checks dimensions of processed images;
  3. centers the images in a larger canvas with power of 2 dimensions;
  4. converts the resized images to PVR format (storing them in a pre-defined directory, more on this later).

There are several beauties of this solution:

  • the invoked toolchain can be complex, but the CMake script remains simple;
  • artists must simply save the assets in the right directory;
  • when a new image has to be used by the game, the programmer/designer can simply add it to SrcFiles variable (in this way - still - unused images will not be processed and deployed; if your game is data-driven, you can use a GLOB solution, setting SrcFiles in the same way AllFiles is set).

But to implement this script you have to define your custom functions to setup the needed toolchains.

A sample toolchain for image processing

After the funny part, I will show you some scripts needed to setup the image processing toolchain, as used by the above example, based on GraphicsMagick.

GraphicsMagick.cmake

This is the most important file: it defines the functions that allow us to compose our image processing toolchains and it's the file included with include(GraphicsMagick) in all CMakeLists.txt files that need to elaborate images.

In this example it defines 2 functions: graphicsMagick() simply invokes one of the GraphickMagick commands (as convert or mogrify) applying the requested transformation to a set of input images; checkImageProperties() uses GraphicsMagick's identify command to retrieve informations about a set of input images, checking for a set of constraints over them.

find_package(GraphicsMagick REQUIRED)
 
include(MakeTargetDirs)
include(ParseArguments)
 
 
 
# graphicsMagick(target
#	[ALL]
#	COMMAND <cmd>
#	[ARGUMENTS <arg1> ... <argN>]
#	[OUTPUT_DIR <dir>]
#	[FORMAT <ext>]
#	FILES <source1> ... <sourceN>
#	[OUTPUT_FILES <outputVar>]
# )
function(graphicsMagick Target)
	PARSE_ARGUMENTS(
		ARGS
		"COMMAND;ARGUMENTS;OUTPUT_DIR;FORMAT;FILES;OUTPUT_FILES"
		"ALL"
		${ARGN}
	)
 
	# Argument flags.
 
	if (ARGS_ALL)
		set(ARGS_ALL "ALL")
	else()
		set(ARGS_ALL)
	endif()
 
	# Argument options.
 
	if (NOT ARGS_COMMAND)
		message(FATAL_ERROR "'COMMAND' argument is mandatory")
	endif()
 
	if (ARGS_OUTPUT_DIR)
		file(TO_CMAKE_PATH ${ARGS_OUTPUT_DIR} ARGS_OUTPUT_DIR)
	endif()
 
	foreach(SrcFile ${ARGS_FILES})
		set(DstFile ${SrcFile})
 
		# Change file suffix
		if (ARGS_FORMAT)
			get_filename_component(SrcExt ${SrcFile} EXT)
			string(REPLACE ${SrcExt} ".${ARGS_FORMAT}" DstFile ${SrcFile})
		endif()
 
		# Set destination filename to absolute.
		if (ARGS_OUTPUT_DIR)
			get_filename_component(DstFile ${DstFile} NAME)
			set(DstFile ${ARGS_OUTPUT_DIR}/${DstFile})
		else()
			set(DstFile ${CMAKE_CURRENT_BINARY_DIR}/${DstFile})
		endif()
 
		get_filename_component(SrcFileFull ${SrcFile} ABSOLUTE)
 
		add_custom_command(
			OUTPUT ${DstFile}
			COMMAND ${GRAPHICSMAGICK_EXECUTABLE} ${ARGS_COMMAND} ${SrcFileFull} ${ARGS_ARGUMENTS} ${DstFile}
			DEPENDS ${SrcFile}
		)
 
		# Command needs existence of target directories.
		makeTargetDirs(${DstFile})
 
		list(APPEND DstFiles ${DstFile})
	endforeach()
 
	if(ARGS_OUTPUT_FILES)
		set(${ARGS_OUTPUT_FILES} ${DstFiles} PARENT_SCOPE)
	endif()
 
	add_custom_target(${Target} ${ARGS_ALL} DEPENDS ${DstFiles})
endfunction()
 
 
 
# checkImageProperties(
#	CHECK <property> <operand> <value>
#	[CHECK ...]
#	FILES <file1> ... <fileN>
# )
#
# <property> can be: WIDTH, HEIGHT.
# <operand> can be: EQ, NE, LT, LE, GT, GE.
function(checkImageProperties)
	PARSE_ARGUMENTS(
		ARGS
		"CHECK;FILES"
		""
		${ARGN}
	)
 
	set(InOps
"EQ"	"NE"	"LT"	"LE"		"GT"		"GE"
	)
	set(ExecOps
"EQUAL"	"EQUAL"	"LESS"	"GREATER"	"GREATER"	"LESS"
	)
	set(NotOps
FALSE	TRUE	FALSE	TRUE		FALSE		TRUE
	)
 
	foreach (Img ${ARGS_FILES})
		get_filename_component(SrcFileFull ${Img} ABSOLUTE)
		execute_process(
			COMMAND gm identify -format "%w;%h" ${SrcFileFull}
			OUTPUT_VARIABLE ImgProps
			OUTPUT_STRIP_TRAILING_WHITESPACE
		)
 
		list(GET ImgProps 0 ImgProp_WIDTH)
		list(GET ImgProps 1 ImgProp_HEIGHT)
 
		list(LENGTH ARGS_CHECK NumCheckItems)
		math(EXPR LastCheckItemsIdx "${NumCheckItems} - 1")
 
		foreach(CheckIdx RANGE 0 ${LastCheckItemsIdx} 3)
			set(CheckPropIdx ${CheckIdx})
			math(EXPR CheckOpIdx "${CheckIdx} + 1")
			math(EXPR CheckValIdx "${CheckIdx} + 2")
 
			list(GET ARGS_CHECK ${CheckPropIdx} CheckProp)
			list(GET ARGS_CHECK ${CheckOpIdx} CheckOp)
			list(GET ARGS_CHECK ${CheckValIdx} CheckVal)
 
			if(NOT DEFINED ImgProp_${CheckProp})
				message(FATAL_ERROR "Invalid property '${CheckProp}'")
			endif()
			list(FIND InOps ${CheckOp} InOpIdx)
			if(InOpIdx EQUAL -1)
				message(FATAL_ERROR "Invalid operand '${CheckOp}'")
			endif()
 
			list(GET ExecOps ${InOpIdx} ExecOp)
			list(GET NotOps ${InOpIdx} NotOp)
			set(NotOpNeg "NOT")
			if(NotOp)
				set(NotOpNeg)
			endif()
 
			if (${NotOpNeg} ImgProp_${CheckProp} ${ExecOp} ${CheckVal})
				message(SEND_ERROR "Image \"${SrcFileFull}\" don't satisfy \"${CheckProp} ${CheckOp} ${CheckVal}\" constraint.")
			endif()
		endforeach()
	endforeach()
endfunction()

I will not enter in implementation details, but to run the above code two more files are needed that define two little utility functions: makeTargetDirs() and PARSE_ARGUMENTS().

MakeTargetDirs.cmake

makeTargetDirs() ensures that needed output directories exist when CMake will run commands that have to save files in them.

# makeTargetDirs(<file1> ... <fileN>)
function(makeTargetDirs)
	foreach(TargetFile ${ARGV})
		get_filename_component(TargetAbsPath ${TargetFile} PATH)
		if(TargetAbsPath)
			if(NOT IS_ABSOLUTE ${TargetAbsPath})
				set(TargetAbsPath ${CMAKE_CURRENT_BINARY_DIR}/${TargetAbsPath})
			endif()
			if(NOT EXISTS ${TargetAbsPath})
				file(MAKE_DIRECTORY ${TargetAbsPath})
			endif()
		endif()
	endforeach()
endfunction()

ParseArguments.cmake

PARSE_ARGUMENTS() is the same one at CMake wiki, it helps in parsing arguments.

MACRO(PARSE_ARGUMENTS prefix arg_names option_names)
  SET(DEFAULT_ARGS)
  FOREACH(arg_name ${arg_names})    
    SET(${prefix}_${arg_name})
  ENDFOREACH(arg_name)
  FOREACH(option ${option_names})
    SET(${prefix}_${option} FALSE)
  ENDFOREACH(option)
 
  SET(current_arg_name DEFAULT_ARGS)
  SET(current_arg_list)
  FOREACH(arg ${ARGN})            
    SET(larg_names ${arg_names})    
    LIST(FIND larg_names "${arg}" is_arg_name)                   
    IF (is_arg_name GREATER -1)
      SET(${prefix}_${current_arg_name} ${current_arg_list})
      SET(current_arg_name ${arg})
      SET(current_arg_list)
    ELSE (is_arg_name GREATER -1)
      SET(loption_names ${option_names})    
      LIST(FIND loption_names "${arg}" is_option)            
      IF (is_option GREATER -1)
	     SET(${prefix}_${arg} TRUE)
      ELSE (is_option GREATER -1)
	     SET(current_arg_list ${current_arg_list} ${arg})
      ENDIF (is_option GREATER -1)
    ENDIF (is_arg_name GREATER -1)
  ENDFOREACH(arg)
  SET(${prefix}_${current_arg_name} ${current_arg_list})
ENDMACRO(PARSE_ARGUMENTS)

FindGraphicsMagick.cmake

For each used tool we define a CMake Find script:

mark_as_advanced(GRAPHICSMAGICK_EXECUTABLE)
find_program(
	GRAPHICSMAGICK_EXECUTABLE
	gm
	DOC "Path to GraphicsMagick 'gm' command"
)
 
INCLUDE(FindPackageHandleStandardArgs)
FIND_PACKAGE_HANDLE_STANDARD_ARGS(
	GraphicsMagick DEFAULT_MSG GRAPHICSMAGICK_EXECUTABLE
)
 
set(GRAPHICSMAGICK_CONVERT_EXECUTABLE ${GRAPHICSMAGICK_EXECUTABLE} convert
	CACHE FILEPATH "Path to GraphicsMagick's 'convert' executable")
mark_as_advanced(
	GRAPHICSMAGICK_CONVERT_EXECUTABLE
)

FindPackageHandleStandardArgs.cmake is part of the standard CMake distribution.

Final remarks

Notes on the root CMakeLists.txt

In the assets/ directory we store our main CMakeLists.txt file:

  1. cmake_minimum_required(VERSION 2.6)
  2.  
  3. get_filename_component(SelfDir "${CMAKE_CURRENT_LIST_FILE}" PATH)
  4. set(CMAKE_MODULE_PATH "${CMAKE_MODULE_PATH}" "${SelfDir}/../build")
  5.  
  6. project(Assets NONE)
  7.  
  8. set(OUTPUT_DIR ${CMAKE_BINARY_DIR}/output)
  9.  
  10. add_subdirectory(audio)
  11. add_subdirectory(graphics)
  12. add_subdirectory(intermediate)

Line 6 is important if your pipeline doesn't need to build custom tools: you have to specify NONE as language to avoid CMake to start automatic platform/compiler detection. This can be an issue if you run your asset pipeline in a dedicated machine where no compilers are installed.

Line 8 sets the OUTPUT_DIR variable to point to a pre-defined directory that will contains all processed assets. This is useful when you are integrating this pipeline with XCode, because you have to copy the assets in the application bundle after XCode have built it. Maybe you could play with the install target of CMake, but as you have to further post-process some files when installing on iPhone device (e.g. to pre-multiply alpha channel of PNG files), I have found simpler this approach. Another advantage of this method is that CMake will catches duplicated file names: if two distinct input files will produce the same output file name, CMake will raise an error.

Running the asset pipeline

Besides the ability of CMake to create projects for most used IDEs, you can also create normal Makefiles. If you have chosen tools that can work concurrently, you can invoke make with the -j argument to build your assets taking full advantage of your multi-core machines.

From my experience, having an automated asset pipeline is a really important feature: besides speeding up the daily workflow, it avoids a lot of troubles catching soon simple errors (as commits of images with wrong dimensions) or simplifying later changes of formats (as the need to under-sample all SFXs due memory limits). I hope you have found some of these informations useful to implement your own asset pipeline.