War3ArtTools是Blizzard官方发布的制作War3 Mod的工具集,虽然其模型导出工具只支持Max4,不过我们的目的也不是为了拿它来给War3做模型。通过War3ArtTools附带的文档,了解War3制作的一些技术细节,也是很不错的。
其实在差不多三年前历时很短的一段3D开发经历里就参考过这玩意,对照War3ArtTools的工具集及相关功能,实现了一套我们需要的美术制作工具,包括模型导出插件、专用材质编辑插件、模型预览插件,不过当时还未涉及到粒子(Partical)与带子(Ribbon)。
闲话少说,让我们来看看War3ArtTools里空间都有哪些东西吧。
这是War3ArtTools的工具集列表,当然除此之外还有一篇pdf文档加几个max model和tga texture samples。应该来说,有了这套工具,再加上War3本身强大的Editor,完全可以做出一个全新的Mod来。
War3ModelExp.dle 模型导出工具
War3bmtls.dlt 材质编辑工具
War3Preview.dlu 模型预览工具
War3UserProp.dlu 自定义属性编辑工具
War3BlizardPart.dlo 粒子编辑工具
War3Ribbon.dlo 带子编辑工具
要想在Max中为War3制作模型,首先第一步要确定的是比例尺问题。文档中也是一开始就说明了,War3中一个单位等于Max中一年inch(也就是0.0254米),一个农民的身高是70个单位(也就是1.778米),这样看来也是按照现实比例来进行设计的。然后约定了最高的建筑物大约为300个单位(约等于7.62米),一个寻路块(Pathing Cell)宽度为32单位,而一个地形块(Terrain Cell)宽度为128单位。
另外在Max中做好的模型原点就是导出的对象的原点,编辑器和游戏中会始终以原点位置来摆放对象。所以一般情况下我们会把原点设在人物的脚底下,建筑物的话也就要设在地板上。
然后是模型的初始朝向,War3要求在Max中制作时,前视图中的模型应该正对着你。
使用工具集中的预览工具,可以随时在制作的过程中看到游戏中的效果,这个工具对美术来说相当方便,不需要先导出,再启动游戏编辑器加载导出的模型。而且即使这样做了,有时候游戏编辑器中看到的效果与最终的游戏效果也还是有差异。这是War3ArtTools的模型预览工具的外观:
对贴图和材质的要求
贴图必须使用Diffuse Color Map Channal, 只能使用24bit或32bit的tga文件,文件大小必须是2的整数次幂,最大支持512 * 512的贴图,长宽(或宽长)比例最大不能超过8:1。
在贴图的alpha通道上可以绘制团队颜色(Team Color),或者为模型创建透明区域。白色(1)为完全不透明,黑色(0)为完全透明。
材质类型只支持自定义的Warcraft III类型和混合材质类型。一个Geometry只支持一张材质,但可以使用组合材质来实现多层效果。
在材质工具的参数设置中也做了一些规则限制,比如某些参数必须使用哪些项,等等。然后还有一些War3自定义的材质属性,用来实现游戏特殊需求,比如Replaceable Texture, Unshadered, No Depth Set, No Depth Test, 2-Sided, UnFogged, UnSelectable等等。
制作动画序列
War3使用Max Track View中的”Note track” Key来定义动画序列的相关属性,比如长度、时间、是不是循环播放、动画出现的概率等等,一个3ds max文件包含了该模型所有的动画序列。比如一个”Note track” key可能是这样的:
“Stand – 2”
rantity 3
上面的”Stand – 2”就是动画名,War3ArtTools对动画名也做了一定的规则限制。动画名由一个或多个由空格分隔的单词构成,如果有多个部分,则必须用引号将动画名括起来。完整的动画名包括主名和次名,比如”Stand Ready”。
引擎内部有一套动画名称匹配规则,用来选择最合适的动画进行播放。比如一个对象进入攻击行为,这时要播放攻击动画,在两次攻击动画中间会有一个暂停,这时引擎会查询这个模型是否有”Stand Ready”动画序列,如果有就播放,如果没有则会回退到”Stand”动画序列,引擎内的动画规则包括各种各样可能的动画组合。
“Note track”的参数中有一项Move Speed,定义了unit的移动速度。一般的对象移动速度在250~400个单位之间,也就是6.35~10.16米之间,也是人的正常跑步速度。这个参数在War3中只是给预览工具用的,游戏中不会使用这个数据。
移动速度的调整关系到unit移动时是否会出现滑步,这个速度与动画播放速度之间要协调好。比如一个unit,根据其模型的大小基本上可以确定这个模型每跨出一步所移动的距离,也就是步长,假设为x,这样在给定的移动速度s之下,便可以计算出一秒内需要跨出s/x步,这个步数包括了左右两只脚的步数。然后再根据动画播放帧速率,在Max中默认为30帧每秒,便可以计算出一个跨步动作需要在几帧之内播完,也就是动画的播放速度应该有多快。
以后在游戏过程中如果想要加快或减慢unit的移动速度,不仅是加减其位移的变化速度,还要让动画播放速度也做相应比例的改变,也就是让这个unit的动画在一秒内不是播放30帧,这样来避免出现滑步现象。
然后还有一个Rarity参数。当相同的动画名称出现多个时,可以用此数值来表示该动画出现的概率。也就是一个休闲站立动作美术可能做了好几种,程序在播放的时候会随机选择一种来播,随机选择的依据就从这个Rarity参数来。
War3使用的MDX模型因为实现的比较早,所以对动作制作方面的限制比较多,一些较新的技术都不能使用,比如IK与bipped动画。
另外每个unit必须有两个骨骼:bone_head和bone_chest,War3编辑器会用到这两个骨骼,类似的,turreted buildings必须有bone_turret骨骼。
Position/Rotation/Scale控制器必须使用Bezier, Linear或TCB,并且Rotation控制器两个关键帧之间的角度差必须小于90度。
挂载点设置
挂载点制作时就是绑定了一个box在骨骼上,这个box不需要设置材质,但需要在自定义属性编辑面板上标注为Attachment Point。这些box不会被渲染,在它们上也不应该有动画数据。
与骨骼类似,挂载点也有一些是必须定义的,如下所示:
相对于目前越来越复杂的MMO来说,War3的挂载点信息还是比较少的。
模型的优化
War3ArtTools定义了一个表格,指导模型制作者对各种类型的模型其面数,贴图大小,骨骼数和带动画的Geoset数量做了规定。
其他
每个unit模型可以带一个头像模型,只需要在名称后加一个_Portrait即可,另外头像模型上必须带一个摄像机。
小物件可以成组,这样在War3编辑器中刷小物件的时候可以随机的刷出各种物件来,只要在命名时将其名称设为一样,同时在后面加上数字即可,如ModelName0.mdx, ModelName1.mdx, ……
动画列表
如前面所说,War3中有一套动画替换规则,每个unit和building也都定义了一些动画名,有些是必须要有的,有些是可选的,美术在制作的时候必须要有的可以先做,可选动画可以在后期慢慢加入,下表是几个动画名及其描述:
可替换的贴图ID
TeamColor
用于显示团队颜色,通常情况下,一个”underpainting”贴图被应用于模型的一部分或者整个模型上,并且这个贴图被设置为Team Color,然后模型的Skin再被附加一层带alpha“空洞”的贴图,通过这些“空洞”把下面的Team Color显示出来。
Team Glow
用Billboard实现的,可以让英雄单位或英雄所带的武器发光的一种贴图方式。
Trees
被标记为Tree的可替换贴图在游戏中会被替换为适合当前tileset的Tree贴图。
注:以上内容大部分未经验证,属于个人理解,小心被误导
最近一直在试图把魔兽3的mdx文件转为Ogre Mesh,学习一下基础的3D编程。Ogre Mesh的导出在很久之前也曾试图做过,并且还把WOW的m2模型以及WMO模型导入到了Max中,但是只做到了骨架的导入,动画数据始终出不来,于是放弃。
这次依然是碰到这里的问题,导出静态的Mesh很快就完成,包括模型与材质,代码也比较简单。
// 模型数据
bool ModelLoaderMdx::loadGeosets(Ogre::MeshPtr model, MdxDataStreamPtr dataStream, int size)
{
unsigned int index = 0;
while(size > 0)
{
int geosetSize = dataStream->read<int>();
size -= geosetSize;
Ogre::String meshName = m_modelName + Ogre::String("_sub_") + Ogre::StringConverter::toString(index++);
Ogre::SubMesh* subMesh = model->createSubMesh(meshName);
// 硬件缓冲编号
// 分别为顶点坐标 法线 贴图坐标
#define HARDWARE_BUFFER_SOURCE_VERTEX 0
#define HARDWARE_BUFFER_SOURCE_NORMAL 1
#define HARDWARE_BUFFER_SOURCE_TEXPOS 2
//
// 顶点数据
//
if(!expectTag(dataStream, 'VRTX'))
{
OGRE_EXCEPT(Ogre::Exception::ERR_INTERNAL_ERROR, "Expect VRTX data for geoset",
"ModelLoaderMdx::loadGeosets");
}
unsigned int vertexCount = dataStream->read<unsigned int>();
// 不使用共享顶点数据
// 每个SubMesh都创建自己的VertexData
subMesh->useSharedVertices = false;
subMesh->vertexData = OGRE_NEW Ogre::VertexData();
subMesh->vertexData->vertexStart = 0;
subMesh->vertexData->vertexCount = vertexCount;
subMesh->vertexData->vertexDeclaration->addElement(
HARDWARE_BUFFER_SOURCE_VERTEX, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION);
size_t vertexSize = sizeof(float) * 3;
assert(subMesh->vertexData->vertexDeclaration->getVertexSize(HARDWARE_BUFFER_SOURCE_VERTEX) == vertexSize);
if (subMesh->vertexData->vertexDeclaration->getVertexSize(HARDWARE_BUFFER_SOURCE_VERTEX) != vertexSize)
{
OGRE_EXCEPT(Ogre::Exception::ERR_INTERNAL_ERROR, "VertexSize error",
"ModelLoaderMdx::loadGeoset");
}
Ogre::HardwareVertexBufferSharedPtr vertexBuffer;
vertexBuffer = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer(
vertexSize,
vertexCount,
Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY);
void* vertexBufferData = vertexBuffer->lock(Ogre::HardwareBuffer::HBL_DISCARD);
unsigned int vertexBufferDataPos = 0;
for (unsigned int i = 0; i < vertexCount; ++i)
{
Ogre::Vector3 data(
dataStream->read<float>(),
dataStream->read<float>(),
dataStream->read<float>()
);
transformCoord(data);
memcpy((char*)vertexBufferData + vertexBufferDataPos, &data, sizeof(Ogre::Vector3));
vertexBufferDataPos += sizeof(Ogre::Vector3);
}
vertexBuffer->unlock();
subMesh->vertexData->vertexBufferBinding->setBinding(HARDWARE_BUFFER_SOURCE_VERTEX, vertexBuffer);
//
// 法线数据
//
if(!expectTag(dataStream, 'NRMS'))
{
OGRE_EXCEPT(Ogre::Exception::ERR_INTERNAL_ERROR, "Expect NRMS data for material",
"ModelLoaderMdx::loadGeosets");
}
unsigned int normalCount = dataStream->read<unsigned int>();
if(normalCount != vertexCount)
{
std::stringstream stream;
stream << "Normal count mismatch, " << normalCount << " normals for " << vertexCount << " vertices)!";
OGRE_EXCEPT(Ogre::Exception::ERR_INTERNAL_ERROR, stream.str(),
"ModelLoaderMdx::loadGeoset");
}
subMesh->vertexData->vertexDeclaration->addElement(
HARDWARE_BUFFER_SOURCE_NORMAL, 0, Ogre::VET_FLOAT3, Ogre::VES_NORMAL);
size_t normalSize = sizeof(float) * 3;
assert(subMesh->vertexData->vertexDeclaration->getVertexSize(HARDWARE_BUFFER_SOURCE_NORMAL) == normalSize);
if (subMesh->vertexData->vertexDeclaration->getVertexSize(HARDWARE_BUFFER_SOURCE_NORMAL) != normalSize)
{
OGRE_EXCEPT(Ogre::Exception::ERR_INTERNAL_ERROR, "NormalSize error",
"ModelLoaderMdx::loadGeoset");
}
Ogre::HardwareVertexBufferSharedPtr normalBuffer;
normalBuffer = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer(
normalSize,
normalCount,
Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY);
void* normalBufferData = normalBuffer->lock(Ogre::HardwareBuffer::HBL_DISCARD);
unsigned int normalBufferDataPos = 0;
for (unsigned int i = 0; i < normalCount; ++i)
{
Ogre::Vector3 data(
dataStream->read<float>(),
dataStream->read<float>(),
dataStream->read<float>()
);
transformCoord(data);
memcpy((char*)normalBufferData + normalBufferDataPos, &data, sizeof(Ogre::Vector3));
normalBufferDataPos += sizeof(Ogre::Vector3);
}
normalBuffer->unlock();
subMesh->vertexData->vertexBufferBinding->setBinding(HARDWARE_BUFFER_SOURCE_NORMAL, normalBuffer);
…………
//
// 顶点索引
//
if(!expectTag(dataStream, 'PVTX'))
{
OGRE_EXCEPT(Ogre::Exception::ERR_INTERNAL_ERROR, "Expect PVTX data for geoset",
"ModelLoaderMdx::loadGeosets");
}
unsigned int indexCount = dataStream->read<unsigned int>();
assert(totalIndexCount == indexCount);
if (totalIndexCount != indexCount)
{
std::stringstream stream;
stream << "indexCount is " << indexCount << ", but totalIndexCount for all faces is " << totalIndexCount;
OGRE_EXCEPT(Ogre::Exception::ERR_INTERNAL_ERROR, stream.str(),
"ModelLoaderMdx::loadGeoset");
}
subMesh->indexData->indexStart = 0;
subMesh->indexData->indexCount = indexCount;
Ogre::HardwareIndexBufferSharedPtr indexBuffer;
indexBuffer = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer(
Ogre::HardwareIndexBuffer::IT_16BIT,
indexCount,
Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY);
void* indexBufferData = indexBuffer->lock(Ogre::HardwareBuffer::HBL_DISCARD);
dataStream->read(indexBufferData, indexCount * sizeof(unsigned short));
// 三角形反转, 将原来的反面朝外
unsigned short* tmpData = (unsigned short*)indexBufferData;
for (unsigned int i = 0; i < indexCount; i += 3)
{
unsigned short tmp = tmpData[i + 1];
tmpData[i + 1] = tmpData[i + 2];
tmpData[i + 2] = tmp;
}
indexBuffer->unlock();
subMesh->indexData->indexBuffer = indexBuffer;
subMesh->operationType = Ogre::RenderOperation::OT_TRIANGLE_LIST;
…………
// 材质ID
unsigned int materialID = dataStream->read<unsigned int>();
Ogre::String materialName = m_modelName + Ogre::String("_") + boost::lexical_cast<Ogre::String>(materialID);
subMesh->setMaterialName(materialName);
…………
//
// 贴图坐标
//
if(!expectTag(dataStream, 'UVBS'))
{
OGRE_EXCEPT(Ogre::Exception::ERR_INTERNAL_ERROR, "Expect UVBS data for geoset",
"ModelLoaderMdx::loadGeosets");
}
unsigned int texturePositionCount = dataStream->read<unsigned int>();
if(texturePositionCount != vertexCount)
{
std::stringstream stream;
stream << "Texture position count mismatch, " << texturePositionCount << " texture positions for " << vertexCount << " vertices)!";
OGRE_EXCEPT(Ogre::Exception::ERR_INTERNAL_ERROR, stream.str(),
"ModelLoaderMdx::loadGeoset");
}
// TextureCoord Data
subMesh->vertexData->vertexDeclaration->addElement(
HARDWARE_BUFFER_SOURCE_TEXPOS, 0, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES);
size_t texPosSize = sizeof(float) * 2;
assert(subMesh->vertexData->vertexDeclaration->getVertexSize(HARDWARE_BUFFER_SOURCE_TEXPOS) == texPosSize);
if (subMesh->vertexData->vertexDeclaration->getVertexSize(HARDWARE_BUFFER_SOURCE_TEXPOS) != texPosSize)
{
OGRE_EXCEPT(Ogre::Exception::ERR_INTERNAL_ERROR, "TexturePositionSize error",
"ModelLoaderMdx::loadGeoset");
}
Ogre::HardwareVertexBufferSharedPtr texPosBuffer;
texPosBuffer = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer(
texPosSize,
texturePositionCount,
Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY);
void* texPosBufferData = texPosBuffer->lock(Ogre::HardwareBuffer::HBL_DISCARD);
dataStream->read(texPosBufferData, texPosSize * texturePositionCount);
texPosBuffer->unlock();
subMesh->vertexData->vertexBufferBinding->setBinding(HARDWARE_BUFFER_SOURCE_TEXPOS, texPosBuffer);
}
return true;
}
但是到了动画数据这里问题又出来了,而且mdx模型与m2模型还有些差别,mdx模型中没有骨骼数据,只有max中最简单的三种变换数据,Ogre中只有MorphAnimation能够实现此种动画。
Ogre的MorphAnimation的第一个KeyFrame必须带有完整的顶点信息,而mdx模型中旋转、缩放与位移是分开的,也就是一个KeyFrame上可能只有一种变换,或者多种。而实际上在max中制作动画的时候这三种变换也是独立开的,Ogre的论坛上找到一篇讨论有人提到了这个问题,可惜制作者的回复是MorphAnimation只实现到这样……
也确实,现在除了一些小物件的动画外,主角,怪物的动画都用skeleton了,也许MorphAnimation就快退出历史的舞台,在Ogre中看不到了,也不能指望会有什么改进。
继续实现之,那就只能在有KeyFrame的地方把三种变换都计算一次,然后取得最终变换后的位置数据,也就是做人工的动画帧采样。在War3EditorSource的基础上做了些修改,终于,一帧帧的动画计算出来了。
不过问题依然还有很多,比如mdx模型,尤其是怪物和角色模型中大量用到了ReplacableTexture,这些需要通过读配置文件来获取可用的贴图,另外模型上附带的粒子特效、纹理动画等都还没有导出,看看这个没有贴图的攻击中的蝎子,前面的路仍然很远。
让对象动起来有两步:
1。在场景中移动对象
2。让对象播放动画
我们一步步来实现这个目标。
移动对象需要用到UnityScript,不过这里我们只是想要简单的移动,拿“Script Tutorial”中的例子稍改改就行:
function Update () {
transform.position.x+= 0.02;
}
在Unity3D中运行看看效果,我们将这个脚本绑定到一个球上,球开始向x轴方向移动了。
接下来再看看如何播放动画。
手册上说,如果想用max来做动画,必须将其导出为fbx文件。
没经验就是没经验,我装的3ds max9,从Autodesk网站上下载了个FBX插件For max 2009,结果死活加载不了,最后想想弄个低版本的插件试试,结果进去一找,怎么还有个For max9的?敢情max9跟max2009不是一回事啊!而且,这max9都已经这么老了,中间还隔了个max2008,再加上最新的max2010……
在max中做了个简单的球,和几帧简单的动画,导出到Unity3D中,需要手动添加动画,效果如下:
结合上面的移动脚本,最后试运行了下,球开始边转边跑了 :)
还有几个问题:
1。当在max做多个动画时,在Unity3D中添加进来,第二个动画的播放位置有问题,不管摆到哪儿,播动画的时候这个GameObject都会跑到场景原点去。
2。手册中说只支持Animation与Bone Based Animation。不会做动画,不知道max的骨骼动画导出来是怎样,另外,biped动画是否能导出?需要试验一下。
-------------------
费了半天劲找比例尺,原来是max中单位设置不正确。
方法:Customize – Unit Setup – System Unit Setup
用cm做单位,这样做一个 100 * 100 * 100 单位的box放到Unity3D中正好占据一个Unity3D单位。
在手册的某处也说到了,只是不太明显:Unity’s physics system expects 1 meter in the game world to be 1 unit in the imported file.
所以,也根本用不着我来假设一个Unity3D单位等于现实世界中的一米……
Unity3D手册中介绍了两种地形制作方法:
一、在SceneView中使用height tools直接绘制
二、使用外部工具制作的heightmaps
直接绘制地形很简单,不过只适合小面积地图的制作,对于真实游戏项目来说,这样拉地形实在太复杂,一般我们都会使用外部工具,比如PS,比如max来制作高度图,然后导出为一张灰度图,在引擎中将其转换为地形。
Unity3D也支持了这种做法,即导入HeightMap的方式,不过对HeightMap的格式有一个限定,必须是16bit的RAW格式灰度图,但是除此之外手册中再没有更多的描述。
没关系,Unity3D提供了将地形导出为HeightMap的方法,我们可以做一张小地图将其导出来,看一看就知道了。
如下图所示,将地形长宽高都设定为2个单位,地形精度设定为33,这个数值是能够设置的最小值了。这样就表示在一个单位内会有17个高度值,即16条边。然后把这个地形导出为16bit Raw格式文件。
按照上面的数据,这个raw文件将会由33 * 33个16bit数据构成,所以文件大小应为 16 * 16 * 2 = 2178字节。导出来的文件也确实如此,证明我们的推断是正确的。
注意这里的Heightmap Resolution一定是2的n次幂加1,至于为什么会这样,找一个介绍HeightMap的文档看一下就明白了。
既然验证了我们的推断是正确的,那试着在PS中创建一张HeightMap放到Unity3D中看看。我们创建的HeightMap大小为129 * 129象素,如果我们让一个Unity3D单位由4个象素点构成,那么地图大小则为 (129 – 1) / 4 = 32,即32 * 32,高度值不需要太大,高为12就够了。
导入到Unity3D中后刷上一层Texture,再种上几棵树,最终的效果看上去是这样:
还不错,其实我没这么好的艺术细胞,在PS里摆弄了半天后,还是决定到网上去找一张现成的HeightMap (囧)
好了,场景制作应该不会有大问题了,下一步,看看怎么放两个会动的东西进去吧。
Unity3D官方给的Island示例效果确实很震撼,再加上其与web集成的特性让我饶有兴趣的想要试一试。
场景制作的第一步,我们需要先确定比例尺。简略地浏览了一遍手册,没有找到关于用max制作模型的细节描述,只好自己手动制作来找比例尺了。方法很简单,在max中导出一个box放到Unity3D场景中观察其大小,这样就可以看出来max单位与Unity3D单位的比例关系。
最后的结果是,40个单位的box正好占据一个Unity3D单位的范围,如图所示:
在Unity3D中,默认的First Person Controller高度为2个单位,我们可以假定一个Unity3D单位相当于现实高度1米,一个人也就是两米左右,当然,实际制作时可以把人的高度调低一点。
用这个比例尺来设计场景及物件大小,试着在场景中摆一张一平方米大小的小桌子,和一个10米高的柱子,来看看比例效果。
对应到max中桌子的大小就是40 * 1 = 40个单位,柱子的高度为40 * 10 = 400个单位。
用程序员的脑子来控制鼠标制作max模型还真是别扭,半天弄出来几个立方块,贴上了两张图,只有两个字:难看!
没有办法,从别的游戏中“偷”了一棵树来装点一下,模型导出用到了这里的工具,很强大的工具 :)
最终的效果看起来还比较正常,如下图:
下一步,看看怎么生成地形吧。