引擎设计跟踪(九.14.2g) 将GNUMake集成到Visual Studio
最近在做纹理压缩工具, 以及数据包的生成.
shader编译已经在vs工程里面了, 使用custom build tool, build命令是调用BladeShaderComplier, 并且每个文件对应一个输出, vs会自动检查工程里面文件的依赖, 这样很方便.
纹理压缩如果也要放在visual studio里面, 可以用build event或者custom build step来做, 但是build dependency很难处理, 比如每个原始贴图对应一张目标贴图, 如果像编译shader那样, 把原始贴图全部加入到工程里面, 倒是可以解决, 但是原始贴图是随着美术制作不停增加的, 要美术把文件加入到vs工程里面感觉不太可行, 即使是程序员自己添加, 手动添加还是太繁琐, 除非写工具来生成vs project...
纹理压缩最好的方式是递归遍历源文件夹下所有文件并处理所有文件, 同时可以检查依赖的.
尝试了使用custom build, 用batch/cmd来处理纹理压缩, 遍历贴图目录处理所有文件, 依赖检查用时间戳比较, cmd只能比较字符串, 所以重新排列了文件字符串, 按年月日,AM/PM时间来排序,然后比较, 但是bat实在是太慢, 而且不同区域/语言,和版本(win7,win8)的系统获取的时间格式也不太一样, 觉得使用batch太麻烦, 毕竟batch不是shell, 功能太弱了.
所以考虑使用makefile来做. 首先看了微软的NMAKE的文档, 发现功能过于简单, 不能很方便的完成工作. 索性使用GNUmake, 这个make工具绝对可以胜任.
配置步骤:
1.首先下载cygwin, 安装时选择添加了make. 装完只把需要的文件放到svn/trunk/bin/tools/cygwin下面, 文件虽然有点多(绝大多数是shell命令), 但是只占用了33M的空间, 这个绝对值得拥有. 列一下我复制的那些文件:
/bin
/dev
/etc
/home
/lib
/tmp
/usr/share/terminfo
其中/usr/share/terminfo是用来做终端识别的, 这样手动进入cygwin bash可以处理backspace键, 为了手动调试用的.其中/bin占用了30M.其他的文件都很小可以忽略.
2.和编译shader文件一样, 把makefile当做vs的工程文件, 并把整个工程类型指定为Utility
3.把makefile加入到工程里面, 并把文件属性改为Custom build Tool
因为project内有文件指定了custom build type, 这个时候工程里面有了这个配置选项. 不填每个文件的Custom Build Tool, 而是切换到project, 在project scope配置custom build tool.这样对于工程内的所有文件, 都应用这一规则.
其中, Output是输出文件, 必须指定, 否则这个规则不会生效. %(Filename)是对应工程内的每个文件的(逐个文件). 但是跟shader不同的是, makefile没有对应的输出, 真正的输出是make产生的n个动态文件, 所以这里填了一个不存在, 也不可能生成的文件:$(OutDir)%(Filename).noexist , 这样vs在每次build检查时,发现目标不存在都会去执行build, 真正的依赖检查是在make中去做.
4.build command line, 就是调用cygwin的make来处理makefile:
1 set Platform=$(Platform) 2 set Format=$(TargetExt) 3 set Path=.;$(SolutionDir)..\..\Bin\Tools\Release_Win32;$(SolutionDir)\..\..\Bin\Tools\cygwin\bin 4 set CHERE_INVOKING=1 5 set MAILCHECK= 6 cd %(RootDir)%(Directory) 7 8 REM set path to unix style, not in makefile becase makefile must be compatible in no cygwin env 9 set OutDir= 10 for /F %%o in ( 'cygpath -u -p -a "$(OutDir)"' ) do ( 11 set OutDir=%%o 12 ) 13 14 $(SolutionDir)\..\..\Bin\Tools\cygwin\bin\bash --login -c "make -j $(NUMBER_OF_PROCESSORS) -r -R -s -f %(Filename)%(Extension)"
需要注意几点:
- 设置的环境变量: Platform, Format, OutDir 是配合makefile工作的,跟具体的makefile相关;
- 设置Path的原因是cygwin的bash需要执行的shell命令, ...\cywin\bin就是/bin路径, 另外加上blade的工具路径即BladeTexCompressor所在的路径. 这个Path路径使用windows风格, cygwin启动后会将它自动转换为冒号分隔的格式.
- MAILCHECK是禁止邮件检查, 根据cygwin的文档可以加速启动, 这个其实没什么用, 因为我复制的是最小运行集合,应该没有类似的功能.
- CHERE_INVOKING是直接切换到当前工作路径, cygwin bash的默认login会切换到/home/%user%路径, 当使用了CHERE_INVOKING, 会留在当前路径, 即调用cygwin之前所在的路径
- OutDir是配置VS的路径, 这样通过配置VS工程, 就可以改变makefile的输出. 不过为了makefile的兼容, 把OutDir转换为*nix风格路径即/开头的路径, 这个转换可以放在makefile里面做, 但是为了makefile的最大兼容, 最好不在makefile里面调用cygpath, 因为cygpath只有cygwin才有. 在调用makfile之前转换好路径, 这样makefile本身就可以不经修改在*nix上执行. cygpath 的输入路径, 不管有没有空格, 都一定要用""括起来, 否则转换的结果不对.
- %(RootDir)%(Directory)是对应每个文件的绝对路径. 注意%()的VS变量都是逐个针对单个文件而变化的, $()的VS变量是固定的变量
- 通过bash -c来直接调用make, 不过需要注意的是make的参数要跟make一起用""括起来, 否则会被视为bash的参数
- 关于parallel build, 既然make -j 已经可以处理, 就省去了对每个tool写并行逻辑的繁琐.
做完以上工作, 就可以开心的写makefile了.
1 #!/usr/bin/env make -f 2 3 #GNU makefile for textures compression 4 5 ifndef OutDir 6 $(error $$(OutDir) not defined.) 7 endif 8 9 ifndef Format 10 $(error $$(Format) not defined.) 11 endif 12 13 ifndef Platform 14 $(error $$(Platform) not defined.) 15 endif 16 17 ifndef SUBDIRS 18 $(error $$(SUBDIRS) not defined.) 19 endif 20 21 ifndef MIPMAPS 22 MIPMAPS = -1 23 endif 24 25 ############################################################################################## 26 # env setup 27 ############################################################################################## 28 29 VPATH = $(shell find . -type d) 30 COMMA :=, 31 32 SOURCETYPELIST := tga,bmp,png,dds 33 NORMALDIR := /normal/ 34 35 TC = BladeTexCompressor 36 TCFLAGS = --target=$(Platform) --format=$(Format) --mipmaps=$(MIPMAPS) #--verbose --filter=$(SOURCETYPELIST) 37 38 ############################################################################################## 39 # source files 40 ############################################################################################## 41 42 SOURCE_EXTENSION = $(subst $(COMMA), ,$(SOURCETYPELIST)) 43 44 FINDFLAGS = -name $(foreach i,$(SOURCE_EXTENSION), "*.$(i)" -or -name) "" 45 SOURCEFILES = $(shell find $(SUBDIRS) $(FINDFLAGS) ) 46 SOURCEFILES += $(shell find -maxdepth 1 $(FINDFLAGS) ) 47 48 TARGETFILES = $(addsuffix .$(Format),$(basename $(SOURCEFILES))) 49 TARGETFILES := $(addprefix $(OutDir),$(TARGETFILES)) 50 51 ############################################################################################## 52 # rules 53 ############################################################################################## 54 55 all: $(TARGETFILES) 56 57 58 define compress_rule 59 $(OutDir)%.$(Format) : %.$1 60 @echo $$(TCFLAGS) $$< $$(subst $$(NORMALDIR),--normalmap,$$(findstring $$(NORMALDIR),$$<)) --output=$$(OutDir)$$(dir $$<) 61 @$$(TC) $$(TCFLAGS) $$< $$(subst $$(NORMALDIR),--normalmap,$$(findstring $$(NORMALDIR),$$<)) --output=$$(OutDir)$$(dir $$<) 62 endef 63 64 $(foreach EXT,$(SOURCE_EXTENSION),$(eval $(call compress_rule,$(EXT))))
可以看到makefile在处理每个文件的时候都会把命令行参数打出来, 显示到VS的Output.这么做的原因是如果报错或者崩溃, 可以把改命令行复制到VS的调试选项直接调试工具.
另外对于法线贴图的压缩, BladeTexCompressor有--normalmap选项. 现在的处理方式是把法线贴图放到normal文件夹, 这样mekefie检测到路径中包含/normal/, 就会自动加上--normalmap选项.
比如terrain/normal/ 和model/normal/ 下面的所有贴图都会被压缩成法线贴图.
纹理压缩工具遇到的问题:
1.makefile传进的路径是/开头的unix路径, 而且C:\ 被映射成了/cygdrive/c/. 所以工具里面要兼容处理这种情况, 转成C:\并使用Blade的IArchive/IStream来加载. 这部分实际做的时候是放在ResourceManager里面处理cygwin的路径了, 只在windows下处理. 这么做的原因是对于所有工具都可以用了, 而且改动最小. 最好的是放在tool级别, blade所有的工具都使用了ToolApplication这个基类, 放在ToolApplication里面是最好的. 如果有时间会改一下.
2.blade的tool跟editor或者用户app一样, 都有配置文件, 默认是在../Config路径下. 如果启动路径是tool/bin即工具所在的路径, ../Config可以加载, 但是如果在别的路径调用(这种情况tool用的比较多) ../Config就不对了, 所以需要处理. 在IPlatfomManager里面添加了getProcessImagePath(), 即进程镜像文件所在的路径(windows下的GetModulePath() ), 并注册到ResourceManager中,作为app:/, 这样如果请求路径为相对路径, 但不存在(cwd:/), 会尝试app:/ 这样blade内置的路径又多了一个, 现在为: media:/, memory:/ , cwd:/, app:/ 另外应用程序层还会注册一个plugins:/ 主要是为了某些情况下插件路径的切换, 比如Android系统的.so路径问题, 另外为了方便也加入了configs:/ 路径.
3.为了考虑tool的用户配置, tool 也支持--config参数, 来启动GUI配置(虽然目前从来没有用过...), 所以tool application会加载UI插件. 而现在的UI插件包含了编辑器的UI和Splash等所有模块, 虽然不用到的话, 只加载也没有问题, 但是发现VS里F7 build的时候, VS的焦点一直在切换和闪烁. 本来以为是batch或者make的问题, 但是单独F5运行BladeTexCompressor, 也会有很高概率出现cmd窗口失去焦点然后又获取焦点的情况. 最后发现是UI插件每次都会创建MFC的Splash窗口,虽然创建后立即隐藏, 但是确定是他导致了cmd窗口焦点丢失. 修改resource中的dialog属性可以解决, 不过对于tool来说, 根本不需要splash, 所以做了简单的模式判断(line 12):
1 ////////////////////////////////////////////////////////////////////////// 2 void BladeUIPlugin::install() 3 { 4 #if BLADE_PLATFORM == BLADE_PLATFORM_WINDOWS 5 RegisterEditorUI(); 6 7 if( Factory<IStartupOutput>::getSingleton().getNumRegiteredClasses() == 0 ) 8 RegisterSingleton(SplashOutput, IStartupOutput); 9 else 10 NameRegisterSingleton(SplashOutput, IStartupOutput, BTString("Win32MFCStartupOutput")); 11 12 //avoid console window focus blink for tools 13 if( IEnvironmentManager::getSingleton().getVariable( ConstDef::EnvString::WORKING_MODE) != BTString("tool") ) 14 { 15 AFX_MANAGE_STATE( ::AfxGetStaticModuleState() ); 16 CSplashWindow* splash = BLADE_NEW CSplashWindow(NULL); 17 splash->Create(CSplashWindow::IDD, NULL); 18 splash->ShowWindow(SW_HIDE); 19 SplashOutput::getSingleton().initialize(splash); 20 } 21 22 if( Factory<IMenuManager>::getSingleton().getNumRegiteredClasses() == 0 ) 23 RegisterSingleton(MenuManager,IMenuManager); 24 else 25 NameRegisterSingleton(MenuManager,IMenuManager,BTString("Win32MFCMenuManager")); 26 27 if( Factory<IIconManager>::getSingleton().getNumRegiteredClasses() == 0 ) 28 RegisterSingleton(IconManager,IIconManager); 29 else 30 NameRegisterSingleton(IconManager,IIconManager,BTString("Win32MFCIconManager")); 31 IConfigManager::getSingleton().setConfigDialog(&DialogProxy); 32 33 NameRegisterFactory(EditorChildWindow, IEditorWindow, IEditorWindow::DEFAULT_TYPE); 34 #endif 35 }
备忘: working mode是由ToolApplication设置为"tool"的, EditorApplication会将它设置为"editor", GameApplication会设置为"game".
4. parallel build (make -j) 的问题, 由于tool application在退出的时候会去写配置, 这样在多个进程同时运行的时候, 某个进程正在写配置, 另一个进程在读配置, 就会有冲突. 这个也很很好解决. 因为之前设计的思路是, 如果有 --config参数, 配置完以后, 程序是会直接退出不会运行的. 所以在命令行有--config(启动配置GUI)的时候, ToolApplication才会去写配置.
压缩贴图格式的选择:
windows下使用BCn压缩, 文件格式为dds, android/ios下使用ETC2/EAC压缩, 文件格式为ktx. 选择文件格式的原则是, 可以方便的借助三方工具预览文件, 这样的通用性更好, 如果用自定义文件格式, 那么预览不方便, 因为现在没有时间为blade写专门的texture viewer.
另外, 对于纹理默认压缩为3通道贴图( BC1 或者 ETC2 RGB), 如果原始贴图有alpha, 会给出警告并压缩为4通道. (BC3或者ETC2 RGBA)
如果输入是1通道, 则给出警告并压缩为单(R)通道. (BC4 或者 R11_EAC)
法线贴图的格式, 对于DX用BC5, GLES用RG11_EAC.两种格式都是2通道RG, 质量差不多, 都是高质量压缩, 比DXT5nm好. BC5的两个通道都是一样的压缩质量, 跟DXT5nm(DXT5 swizzle)的alpha通道压缩质量一样.
对于法线贴图, 如果输入是1通道(bump map), 会给出警告并转换为tangent space normal map (这个功能之前做过并在runtime用过).
添加一个KTX文件格式说明: https://www.khronos.org/opengles/sdk/tools/KTX/file_format_spec/
文件打包:
使用blade的BPK打包.之前的BPK写的很简单, 只处理简单的情况, 即把单个文件夹(--data=../Data)全部打包, 其中../Data是相对于executable的路径. 现在已经加入了append模式, 这样可以选择性的追加某些文件夹.另外遇到一个reallocation的bug, 因为用到了数组和指针, 在append模式下, 会动态扩展数组的容量, 所以之前的指针变无效了.这个之前考虑的过于简单, 虽然BPK模块同时用于runtime和离线工具, 但是runtime是根据包信息直接reserve空间没有re-allocation, 而且离线模式也不支持append模式, 可以预计算数组大小. 所以直接用了指针, 但现在遇到问题了.目前做了简单的hard fix (line 14), 以后有时间用index替换指针.
1 inline BPK_ENTRY* BPKData::addFolder(BPK_ENTRY* parent, const TString& name, uint32 attrib, const FILE_TIME& time, const HSTREAM& package, bool bWriteTail) 2 { 3 assert( parent != NULL ); 4 BPK_ENTRY* existing = this->findSubEntry(parent, name); 5 if( existing != NULL ) 6 { 7 //assert(false); 8 return NULL; 9 } 10 11 if( mEntryCount >= mCapacity ) 12 { 13 assert( mEntryCount == mCapacity ); 14 //note: entries maybe relocated, so parent changes 15 index_t idx = parent - &mEntries[0]; 16 this->reserveEntries(mEntryCount+(mEntryCount+1)/2); 17 parent = mEntries + idx; 18 } 19 ... 20 }
数据路径:
贴图之前放在../Data/image下面作为最终数据, 现在放在source目录内部, 作为源文件.
之前的shader会直接生成到../Data/material/shader下面. 所以win32/x64/Android的生成数据会相互覆盖. 避免相互覆盖可以生成到不同的文件夹, 但是直接打包的话也不能把不同平台的所有文件都打包.
现在的文件存放路径如下:
../Data/DataPlatform/image/dx_gl 存放dds
../Data/DataPlatform/image/gles 存放ktx
../Data/DataPlatform/shader/dx9 存放dx9 shader
../Data/DataPlatform/shader/gles3 存放gles3 shader
以后可能会有dx11/dx12 shader等等
然后makefile中根据平台把对应的贴图和shader, append到BPK中的/image和/shader中去.
原始资源配置文件的image路径对应的是media:/image, 跟打包后的包文件对应(media:/对应包文件), 这样的资源配置文件对于所有平台都一样, 唯一的缺点是只能读取包文件, 不能读本地文件, 如果想在windows下读取本地文件, 那么需要修改资源配置文件的路径, 切换到../Data/DataPlatform下.
DataPlatform专门存放平台相关的数据, 目前有image(texture)和shader, 其他的文件都应该是跨平台的. --至于为什么叫image而不叫texture, 只是考虑到image更general, 对于所有类型的应用都合适的名字, 而不仅仅是3d应用和游戏..
之前的贴图文件的保存, 直接写在ImageBase里, ImageBase::loadDDS, ImageBase::saveDDS() 这样. 现在由于加了ktx, 并且考虑到以后的可能扩展, 单独写了IImageFile,负责读写贴图, DDS的代码直接挪过去,不用修改. 通过命令行的参数--format=dds/ktx, 把格式字符串作为工厂类型直接创建出IImageFile instance来读写IImage. 这样文件格式的处理也变得流程化了.
下面是打包的makefile:
#!/usr/bin/env make -f #GNU makefile for package generation ifndef OutDir $(error $$(OutDir) not defined.) endif ifndef OutFile $(error $$(OutFile) not defined.) endif ifndef Platform $(error $$(Platform) not defined.) endif ############################################################################################## # env setup ############################################################################################## #VPATH = $(shell find . -type d) #pwd: Bin\Data SOURCE_PLATFORM_ROOT = Data_Platform SOURCE_FILES = $(shell find ./ -type d -name "*" -maxdepth 1) SOURCE_FILES := $(subst $(SOURCE_PLATFORM_ROOT),,$(SOURCE_FILES)) SOURCE_FILES := $(filter-out ./,$(SOURCE_FILES)) #sub folders in SOURCE_PLATFORM_ROOT DataFolders_Win32 = image/dx_gl shader/dx9 DataFolders_x64 = $(DataFolders_Win32) DataFolders_Android = image/gles shader/gles3 DataFolders_iOS = $(DataFolders_Android) #check if new platform added ifndef DataFolders_$(Platform) $(error $$(DataFolders_$(Platform)) not defined.) endif PACK = BPK PACKFLAGS = ############################################################################################## # source files ############################################################################################## #use by dependency only PLATFORM_DIRS = $(DataFolders_$(Platform)) ROOT_FILES = $(shell find -maxdepth 1 -type f -name "*") FIND_PLATFORM_FOLDERS = $(addprefix ./$(SOURCE_PLATFORM_ROOT)/,$(PLATFORM_DIRS)) $(SOURCE_FILES) ALLFILES = $(shell find $(FIND_PLATFORM_FOLDERS) -type f -name "*") ALLFILES += $(ROOT_FILES) SOURCE_FILES += $(ROOT_FILES) ############################################################################################## # rules ############################################################################################## all : $(OutDir)$(OutFile) $(OutDir)$(OutFile) : $(ALLFILES) @echo $(PACKFLAGS) $(SOURCE_FILES) --output=$@ @$(PACK) $(PACKFLAGS) $(SOURCE_FILES) --output=$@ @$(foreach SUBDIR,$(PLATFORM_DIRS), echo $(PACKFLAFGS) $(SOURCE_PLATFORM_ROOT)/$(SUBDIR) --append --destpath=/$(dir $(SUBDIR)) --output=$@; ) @$(foreach SUBDIR,$(PLATFORM_DIRS), $(PACK) $(PACKFLAFGS) $(SOURCE_PLATFORM_ROOT)/$(SUBDIR) --append --destpath=/$(dir $(SUBDIR)) --output=$@; )
最后关于ETC2纹理压缩:
使用的是etcpack来压缩和解压. 本来想用PVRTexTool的, 但它只有压缩功能, 而per block的解压和压缩功能runtime也有用到, 所以只能加入etcpack的三方代码来处理.
虽然imagemanager提供了格式转换, 但是现在有了离线压缩, runtime现在应该不会用到了.
对于ETC/ETC2 他们都是4x4块压缩, 具体算法还没有时间看, 简单看了ETC2的pdf和代码, ETC2除了ETC的压缩模式以外, 还引入了H mode, T mode, Planar mode, 对于这些情况的颜色分布, 做单独的压缩模式处理, 以提高精度. 由于模式的选择是per-block的, 所以如果每个block都是ETC1 mode的时候, 贴图就是ETC1贴图, 所以ETC2可以兼容ETC1.
然而ETC2的压缩算法, 是尝试性的, 即对每个mode都做压缩, 然后解压出color, 再跟原始color比较计算误差, 选择误差最小的mode, 这导致压缩过程异常缓慢.比如目前测试的结果, 300张贴图, 压缩为dds, 在我的台式机i7 4核8线程下make -j 8, 需要20秒钟, 如果压缩为ETC2的ktx, 则需要2分钟.
如果在我的老笔记本T6670 双核的笔记本上跑make -j2, dds需要2分钟, ktx需要15分钟.
经过以上的处理, Win32/x64/Android都可以在Visual Studio里面一键编译出shader/texture和最终的数据包了.其中Win32和x64使用的是同一份数据包.以后Android和iOS以及Android64和iOS64都会用同一份数据包.