因为一些基础的数学问题,前前后后一共研究了四五天,今天终于有些眉目了,记录下来备忘。
一、火炬之光场景配置文件分析
火炬之光的场景涉及到几个部分:1、资源文件,包含基础的模型、粒子、怪物等等。我们暂时只看模型,就是一个一个的mesh文件,同时几乎每个模型都有对应的缩略图文件(xxxthumb.jpg)和碰撞体文件(xxxcollision.mesh)。 2、Tileset配置,这个是一个dat文件,例如catacomb.dat,里面包含了几千个PIECE,而piece则对应实际的资源文件以及一些编辑器相关的参数配置,比如对齐量。piece与资源可以是一对多的关系,这个是随机场景生成用到的。 3、layout,这个在layouts文件夹下面,就是实际场景的配置。可以使用Guts编辑器打开layout文件来查看场景。配置文件里面[BASEOBJECT]就是实际场景中的一个元素,最常见的就是通过GUID指定一个RoomPiece也就是Tileset中的一个元素。
解析这个配置文件没有什么难度,顺着每行遍历下去一行行解析就可以获取到每个元素的内容了。
二、Unity自动加载场景
我们先不考虑动态场景生成,直接选择一个layout配置文件,然后对应的创建物件。核心代码如下:
[MenuItem("Tools/导入场景(测试)")] static void LoadTestScene() { Dictionary<string, LevelTileSet> allPieces = LoadAllPiece(); LevelLayout layout = new LevelLayout(); layout.ParseFile("Assets/Model/Map/layouts/testroom/1x1single_room_a/testroom.layout"); BuildLayout(allPieces, layout.allTiles); } static void BuildLayout(Dictionary<string, LevelTileSet> allPieces, List<LevelLayoutTile> allTiles) { foreach (var item in allTiles) { foreach (var tileset in allPieces) { if (item.pieceGuid == null || item.pieceGuid.Length == 0) { continue; } LevelTilePiece piece = tileset.Value.SearchPiece(item.pieceGuid); if (piece != null && piece.filePath.Count > 0) { string assetPath = GetRealFilePath(piece.filePath[0]); Debug.Log(piece.filePath[0] + " " + assetPath); //break; GameObject obj = AssetDatabase.LoadAssetAtPath(assetPath, typeof(GameObject)) as GameObject; Debug.Log(obj); GameObject obj2 = Instantiate(obj) as GameObject; obj2.transform.position = new Vector3(item.position.x, item.position.y, item.position.z * -1); float angleX = -Mathf.Asin(item.rotationForward.y) / Mathf.PI * 180; float angleY = Mathf.Atan2(item.rotationForward.x, -item.rotationForward.z) / Mathf.PI * 180; float angleZ = Mathf.Atan2(item.rotationRight.y, item.rotationUp.y) / Mathf.PI * 180; Debug.Log(string.Format ("rforward:{0} rright:{1} rup:{2} ax:{3} ay:{4} az:{5}", item.rotationForward, item.rotationRight, item.rotationUp, angleX, angleY, angleZ)); obj2.transform.rotation = Quaternion.Euler(new Vector3(angleX, angleY, angleZ)); obj2.transform.localScale = new Vector3(item.scale.x, item.scale.y, item.scale.z); } } } }
这里直接实例化一个fbx模型,在实际应用中我们应该先创建好prefab,然后实例化prefab,这样无论是在优化的角度,还是工程的角度都是有帮助的。
三、加载火炬之光的资源:
关于如何从火炬之光导出资源我之前写过一篇文章,但是年代久远,后面针对实际问题做了几次更新。比如写个脚本进行批量的转换;预先判断xml文件的存在,并且不是转换完一个文件就删除,这个可以大大提高批量转换的速度;无动画的文件可以使用最新的blender来导出,这个倒没有明显的好处,不过由于不熟悉blender的api,而blender的api在2.5版本做了大量面目全非的修改,所以两个版本的导出脚本或功能脚本无法相互转换。下面是我现在使用的脚本:
auto_covert_mesh.py
import glob,sys,os
BLENDER = r"E:\MyProj\blender-2.49b-windows\blender";
DUMMY_BLEND = r"E:\MyProj\unity3d\arpg36\ARPG\Tool\dummy.blend"
CONVERT_SCRIPT = r"E:\MyProj\unity3d\arpg36\ARPG\Tool\convert_mesh.py"
BLENDER259 = r"E:\MyProj\blender-2.71-windows64\blender";
DUMMY_BLEND259 = r"E:\MyProj\unity3d\arpg36\ARPG\Tool\dummy.blend"
CONVERT_SCRIPT259 = r"E:\MyProj\unity3d\arpg36\ARPG\Tool\convert_mesh_259.py"
def convert_path(path, animation):
for root, dirs, files in os.walk(path):
for dir in dirs:
strDir = os.path.join(root, dir);
#print(strDir);
for file in files:
file = file.lower();
strFile = os.path.join(root, file);
#print(strFile);
if strFile.find(".mesh") != -1 and strFile.find(".meta") == -1 and strFile.find(".xml") == -1:
output = strFile.replace(".mesh", ".fbx");
if not os.path.exists(output):
print("--------------" + strFile);
if animation:
os.system("{0} -b {1} -P {2} -- {3}".format(BLENDER, DUMMY_BLEND, CONVERT_SCRIPT, strFile));
else:
os.system("{0} -b {1} -P {2} -- {3}".format(BLENDER259, DUMMY_BLEND259, CONVERT_SCRIPT259, strFile));
#return
for root, dirs, files in os.walk(path):
for file in files:
file = file.lower();
if file.find(".mesh") != -1 or file.find(".skeleton") != -1 or file.find(".xml") != -1 or file.find(".material") != -1 or file.find(".adm") != -1:
strFile = os.path.join(root, file);
os.remove(strFile);
convert_path(r"E:\Backup\MEDIA_png\levelsets\props\test", False);
import Blender
import bpy
import sys
import os,glob
sys.path.append(r"E:\MyProj\blender-2.49b-windows\.blender\scripts\torchlight");
sys.path.append(r"E:\MyProj\blender-2.49b-windows\.blender\scripts");
import importTL,export_fbx
def ImportMesh(file):
print file;
scn = bpy.data.scenes.active
#Scene.Unlink(scn);
importTL.ImportOgre(file);
file = file.lower();
output = file.replace(".mesh", ".fbx");
export_fbx.fbx_default_setting();
export_fbx.fbx_write(output);
return True;
ImportMesh(sys.argv[6]);
##########################################################
# Custom Blender -> Unity Pipeline
# http://www.mimimi-productions.com, 2014
# Version: 1.9
# Only for Blender 2.58 and newer
#
# Thanks to kastoria, jonim8or and Freezy for their support!
# Special thanks to Sebastian hagish Dorda for implementing the sort methods.
# http://www.blenderartists.org
##########################################################
# Fixes the -90 degree (x-axis) problem for Unity.
# Artists and coders simply work as they should.
# -> No more custom rotation-fixes in Unity or Blender.
##########################################################
# HISTORY
# 1.9, CLEANUP -- removed support for old Blender versions, only support 2.58 and newer
# 1.8, FIX -- applies transforms in order (parents prior childs)
# 1.7, FIX -- shows hidden objects prior importing
# 1.6b, FIX -- Apply mirror modifiers before rotating anything else
# 1.6a, FIX -- deselect all objects, otherwise deleting wrong objects when using UNITY_EXPORT flag
# 1.6, FEATURE -- support UNITY_EXPORT flag --> set via MiBlender-Tools or e.g.: bpy.data.objects['enemy_small']['UNITY_EXPORT'] = False
# 1.6, FIX -- now import empties
# 1.5, FIX -- make all objects single user, otherwise instances can't be imported
# 1.4, FIX -- show all layers, necessary for rotation fix
# 1.3, FIX -- location constraints are now deactivated (due to rotation prior to export)
# 1.2, FIX -- apply rotation worked only on selection! (thx jonim8or)
# 1.1, FIX -- object mode doesn't need to be set in file anymore
##########################################################
# TO DO
# ISSUE -- do not use empties as parents (rotation can't be applied to them!)
# ISSUE -- transform animations are missing because we are not exporting the default take --> thus only bone-animations are working?!
# ISSUE -- LIMIT_LOCATION animation constraint is forbidden! Will be muted and not work in engine (anim might look different compared to Blender)
# 2.0, FEATURE -- support UNITY_EXPORT_DEFAULT_TAKE --> can export no-bone-animations to Unity
##########################################################
import bpy
import sys
import os,glob
import os
import time
import math # math.pi
from mathutils import Vector, Matrix
from functools import cmp_to_key
sys.path.append(r"E:\MyProj\blender-2.71-windows64\2.71\scripts\addons\torchlight");
sys.path.append(r"E:\MyProj\blender-2.71-windows64\2.71\scripts\addons\io_scene_fbx");
import TLImport,export_fbx
OGRE_XML_CONVERTER = r"E:\MyProj\unity3d\arpg36\OgreCommandLineTools_1.7.2\OgreXmlConverter.exe"
def ImportMesh(file):
print(file);
TLImport.load(None, bpy.context, file, OGRE_XML_CONVERTER, False);
# SORTING HELPERS (sort list of objects, parents prior to children)
# root object -> 0, first child -> 1, ...
def myDepth(o):
if o == None:
return 0
if o.parent == None:
return 0
else:
return 1 + myDepth(o.parent)
# compare: parent prior child
def myDepthCompare(a,b):
da = myDepth(a)
db = myDepth(b)
if da < db:
return -1
elif da > db:
return 1
else:
return 0
# Operator HELPER
class FakeOp:
def report(self, tp, msg):
print("%s: %s" % (tp, msg))
# Rotation matrix of -90 around the X-axis
matPatch = Matrix.Rotation(-math.pi / 2.0, 4, 'X')
# deselect everything to close edit / pose mode etc.
bpy.context.scene.objects.active = None
# activate all 20 layers
for i in range(0, 20):
bpy.data.scenes[0].layers[i] = True;
# show all root objects
for obj in bpy.data.objects:
obj.hide = False;
# make single user (otherwise import fails on instances!) --> no instance anymore
bpy.ops.object.make_single_user(type='ALL', object=True, obdata=True)
# prepare rotation-sensitive data
# a) deactivate animation constraints
# b) apply mirror modifiers
for obj in bpy.data.objects:
# only posed objects
if obj.pose is not None:
# check constraints for all bones
for pBone in obj.pose.bones:
for constraint in pBone.constraints:
# for now only deactivate limit_location
if constraint.type == 'LIMIT_LOCATION':
constraint.mute = True
# need to activate current object to apply modifiers
bpy.context.scene.objects.active = obj
for modifier in obj.modifiers:
# if you want to delete only UV_project modifiers
if modifier.type == 'MIRROR':
bpy.ops.object.modifier_apply(apply_as='DATA', modifier=modifier.name)
# deselect again, deterministic behaviour!
bpy.context.scene.objects.active = None
# Iterate the objects in the file, only root level and rotate them
for obj in bpy.data.objects:
if obj.parent != None:
continue
obj.matrix_world = matPatch * obj.matrix_world
# deselect everything to make behaviour deterministic -- instead of "export selected" we use the UNITY_EXPORT flag
for obj in bpy.data.objects:
obj.select = False;
# apply all(!) transforms
# parent prior child
for obj in sorted(bpy.data.objects, key=cmp_to_key(myDepthCompare)):
obj.select = True;
# delete objects with UNITY_EXPORT flag
# if flag not saved, then assume True
if obj.get('UNITY_EXPORT', True) == False:
bpy.ops.object.delete()
# apply transform if not deleted
else:
bpy.ops.object.transform_apply(rotation=True)
# deselect again
obj.select = False;
file = file.lower();
output = file.replace(".mesh", ".fbx");
export_fbx.save(None, bpy.context, filepath=output, global_matrix=None, use_selection=False, object_types={'ARMATURE', 'EMPTY', 'MESH'}, use_mesh_modifiers=True, use_armature_deform_only=True, use_anim=True, use_anim_optimize=False,use_anim_action_all=True, batch_mode='OFF', use_default_take=False);
return True;
ImportMesh(sys.argv[6])
四、遇到的一些困难和问题
1、Z轴向上还是Y轴向上。 Blender和3DMax都是Z轴向上的,而Unity3D则是Y轴向上。 其他的一些软件比如Maya默认是Y轴但是可以自行设定;Unreal貌似是Z轴向上。 具体哪个轴向上并没有绝对的好或者不好,不过不同动画制作软件和游戏引擎之间的不兼容确实会让人感到恶心。 大多数情况下Blender或者3DMax的导出插件会帮我们做好这个调整,来保证软件和引擎中的视觉效果是一致的。但是Blender做的还不够好,它的fbx导出里面有设置全局的旋转矩阵,在2.4版本的导出插件里面,我们可以直接设置RotX-90,在最新的2.7版本里面则是选择Forward方向和Y方向对应哪个轴,无论哪个在代码中的本质都是设置一个Global Matrix。 不过这样做的问题是导出的物体在Unity里面显示的是x轴旋转-90,虽然视觉效果是一致的,但是由于这个旋转轴的设置导致我们实际代码操作时会有一些不爽。如果你同时导出Camera和Lamp,则会有一个父节点,进行旋转的是实际的模型,而父节点则不进行旋转,正因如此早些时候并没有发现这个问题,因为一切看起来都是正确的。 在上面的convert_mesh_259里面有一大坨代码用来修正这个问题,也就是我们直接在Blender里面旋转模型,然后不设置旋转矩阵,这样Blender和Unity就完全对的上了。 这应该算是一个比较完善的解决方案。
2、旋转矩阵还是欧拉角。 火炬之光的配置文件使用了三个方向的向量来表示转向(Orientation),分别是Forward(对应z轴) Right(对应x轴)和Up(对应y轴)。这三个方向的向量共同组成了一个旋转矩阵(这个矩阵是根据绕每个轴的欧拉角旋转矩阵按照固定顺序相乘得来,矩阵应该按照x y z的顺序来排,然后按照z x y的顺序相乘),假定按z轴旋转z角度,按x轴旋转x角度,按y轴旋转y角度,则矩阵公式为(其中Cz代表cos(z) Sz代表sin(z)):
Cz*Cy + Sx*Sy*Sz Sz*Cx -Sy*Cz+Sz*Sx*Cy--------------Right--->X
-Sz*Cy+Cz*Sx*Sy Cz*Cx Sy*Sz+Sx*Cy*Cz--------------Up------->Y
Cx*Sy -Sx Cx*Cy---------------------------Forward->Z
对应到火炬之光中的配置则是:
Right.x Right.y Right.z
Up.x Up.y Up.z
Forward.x Forward.y Forward.z
它们的默认值是:
1 0 0
0 1 0
0 0 1
我们已经知道了Right.x/y/z Up.x/y/z Forward.x/y/z,这些也就是火炬之光的编辑器中对应的三个向量,求解上面的矩阵可以得到:
x = -sin(Forward.y)
y = atan2(Forward.x, Forward.z)
z = atan2(Right.y, Up.y)
这样我们就可以根据火炬之光的配置得到Unity需要的欧拉角(transform.rotation)。如果配置中某一个轴没有配置,则取默认值。
3、左手坐标系还是右手坐标系。
火炬之光是右手坐标系,默认z轴正方向向外。 Unity是左手坐标系,默认z轴正方向向里。 现在涉及到一个右手坐标系到左手坐标系的转换。这里分平移矩阵的转换和旋转矩阵的转换。平移矩阵很好办,直接把z坐标取反就可以了。旋转矩阵搜索了半天也没有一个明了的答案,这里我直接把Forward.z取反,然后代到上面的公式中求得一个新的z,貌似结果是OK的。
结合2、3两点,就是最上面第二大项中的具体代码实现。
五、后续问题的整理
1、灯光。实现了模型的加载只是最基础的一步。如果要达到火炬之光的效果还需要很多细节的调整,比如shader看情况要重写,而不是使用默认的Diffuse。灯光也是很重要的一部分,火炬之光2和暗黑3的整体效果就是靠灯光来烘托出来的,这里说的效果并不是实时阴影、光线追踪之类的高级渲染技术,而是最基础的灯光的明暗、色调、范围的设置。如果这些设置好了,就可以获得非常舒服的体验。 在手机上面主要使用LightingMap来对场景进行烘焙,这样可以达到理想的效果和效率。
2、粒子效果。 一个游戏炫不炫主要就看光效,这里不讨论次时代大作,而是在设备受限或者资金受限或者人员能力受限的情况下,什么是最能抓住人眼球的东西。 光效做的好了,一个简单的动作都可以设计出非常酷的技能,一个简单的武器模型配上一个发光的粒子光效就可以变成一把极品传承装备,即便人物做的差一些,配上个环绕的雷电光效也可以冒充雷神。 粒子效果最主要的应用场景有这么几个:技能、武器的发光效果、场景中的火焰等环境效果、喷血尸体爆炸等特殊表现。 火炬之光把两点做到极致就获得了非常棒的打击感,一个是受击的光效、屏幕震动、音效的配合,一个是怪物死亡后的炸飞、炸裂等表现。
不过说了这么多,粒子效果却无法复用,悲剧。 如果技术够强,可以考虑整体移植粒子系统,类似的工作Xffector已经做了。 但是考虑到技术难度和工作量,这个只是理论上可行。
3、动态生成场景。 原本我以为火炬之光的场景动态生成是非常高级的技术,不过后来研究了下,它只是使用非常简单的设置就达到了动态场景生成的目的。 首先每个[PIECE]中都可以包含多个模型,这些模型在生成的时候会随机进行选择,这样场景中的细节每次看都会不一样。然后针对同一个关卡区域设计出很多不同的场景,场景的样式可以千变万化,只要满足最基础的任务设置、场景链接的设置、出口入口的设置符合一定规则就可以了。 在生成整个关卡的时候会根据layout chunk的规则随机选择合适的区域块(就是我们上面实现的加载的一个layout)共同组成一个大的场景。