快速成长中的比利时游戏公司Triangle Factory使用Unity制作了数款VR多人游戏,包括Hyper Dash和最新的Breachers。他们使用了Cinemachine、Unity Profiler、Game Server Hosting、Matchmaker、Voice Chat(Vivox)和Friends为玩家打造了一款全身心投入的体验。
在这篇博客里,Jel Sadones,关卡设计负责人/技术美术,和开发负责人Pieter Vantorre将与我们分享"Blender到Unity"的生产管线,以及VR战术FPS游戏Breachers的制作方式。
Unity在过去十多年来一直是我们首选的引擎和开发环境,而在环境建模和设计上多年来我们已经尝试过许多工作流。期间我们试过ProBuilder(仍是我们爱用的原型制作工具)这类引擎内的建模工具,也试过从其他建模软件导入做好的预制件来组装场景。至于目前的项目,我们选择在Blender里建立关卡模型,依赖Unity的AssetPostprocessor把资产导入到项目中。
这里,我们希望与大家分享下团队为何选择这种流程,它怎样支撑起快速的设计迭代。
在2021年,我们发布了首款大型VR游戏,Hyper Dash,一个快节奏的5v5竞技场射击游戏。在2019年开始开发这款游戏时,很多人都很熟悉我们采用的“Blender到Unity”流程:用Blender建立模型,导出为FBX文件,再导入到Unity。整个手动流程包含以下几步:
这种流程很适合小点的项目,但会随项目变大而愈发繁琐。在开始计划下个游戏的开发时,改进工作流已经不可避免。
Breachers带有复杂的关卡布局、微妙的游戏机制和更多的技术系统,以及更加高级的图形质量,其目标平台为最新一代的独立VR硬件。从复杂性看,它比Hyper Dash走得更远,这点很快体现在了工作流程里。
在制作原型阶段,我们仍大量依靠预制件来保存动态对象,比如窗户挡板。这些挡板会在热身阶段阻隔内部与外部的视线,防止队伍双方看到对方。
在测试原型时,我们经常要四处变更窗户的位置来改进游戏玩法,于是也经常需要到Blender里修改模型并将它重新导出到Unity,之后就需要手动摆放挡板来契合修改后的窗口。就这样,很多个小时都花在了四处观察Scene视图、手动检查和改正模型上。即便如此,我们在多次游戏测试期间还是会找出不对劲的地方。
很明显,这种流程没法让我们快速在测试期间更新地图设计,无论是内部测试还是alpha公测。我们本想在公测期间让玩家们免费试玩一张地图来收集反馈。用户反馈当然非常宝贵,但随反馈而来的人工实施过程并不让人期待。
基于预制件流程的另一个缺点在于性能。我们的主要目标平台是便携的独立VR头显,同时又想尽可能地提高画质,因此我们希望尽可能地榨取每一点性能。
用预制件组装关卡并没有直接在建模软件里创建一片连续的模型网格来的高效。就算把两个墙的组件拼在一起,贴合处也依旧有看不到的面。只要用预制件,关卡里就会有许多看不见的面(或位于对象底部,或和墙贴在一起),它们仍会占用宝贵的光照贴图空间。在一整个关卡里,这些小缺陷会积少成多,浪费不少性能,降低图像质量。
最后一个问题是某些看起来不相关的修改一旦应用到Blender的原模型上就会产生问题,比如重命名某个对象。随着游戏或关卡不断完善,资产一般都需要重新组织,取些更清晰、连贯的名称。但在Blender里重新命名对象再导出很容易(毫无征兆地)破坏Unity里的覆写和附加内容,导致回滚。
在下方简单例子里,通风栅预制件会喷吐烟雾。我们的艺术家在导入模型网格后添加了一个烟雾粒子系统作为子对象,并加上了表面类型组件来标明对象是个金属物品。
如果在Blender里重命名模型,便会发生以下这种情况:
重命名后的模型再导入Unity时,引擎无法根据名称来找到模型网格,便会从预制件里移除它。被移除对象的子对象会被转交给预制件的根对象,脚本会被移除,新对象需要由我们手动清理。
Breachers原型制作阶段结束后,我们在2022年初开始准备进入生产阶段,美术和开发团队则聚在了一起讨论怎样解决这些问题。我们讨论了理想资产管线所应具备的几个特征,以支持Breachers快速、灵活的迭代:
正如上方提到的,我们的主要目标是在Blender里精确地可视化游戏——不仅要正确地反映Unity里的成果,还要反映游戏机制的建立方式。Breachers的游戏玩法不仅基于关卡布局,还基于动态对象(比如可突入的墙壁)和其他不可见的元素(比如音量和碰撞体)。我们希望在设计阶段就暴露出这些信息,并将其精确地搬到Unity。
自定义属性是整个流程的关键之一,主要在Blender里添加。这些属性会经由FBX文件导入Unity,用于查看或运行自定义逻辑。
这让流程更加灵活、稳定。这些属性会一直保留在对象身上,让我们能重新组织和命名关卡内容,不必担心造成破坏或失去同步。
Unity有一个强大的AssetPostprocessor,可以在导入时对资产做出修改。我们正是用了这个在导入时解读自定义属性并执行操作。
自定义的属性PrefabLink会告诉Unity导入的Blender对象应当被替换成哪个已存在的预制件,同时保留模型的变换。我们可以在Blender里摆放动态对象,在导入Unity后继续使用预制件。上方Blender场景里的窗户挡板就是个很好的例子。
关于表面的定义对Breachers也非常重要。行走在金属阶梯上的声音必须不同于水泥地,子弹穿过木材的声音需要不同于穿过金属。每种表面在接受冲击时会有不同的效果。在Unity里一件件地标注道具地表面类型会耗费大量时间,于是我们同样在Blender里为模型碰撞体设定相应地自定义属性。
另一个重要的优化设定是Unity的静态标记。正确地设立标记可以极大地影响可见度遮挡、光照烘焙和批处理。我们用Blender的自定义属性为关卡的每一部分设好了标记,包括可重复使用的道具,将这些信息导入到了Unity。
最后,我们要分享的是碰撞体的建立方式。Unity有一个简单却高效的系统能自动为带有_LOD0、_LOD1等后缀的模型检测其所处的细节级别。受此启发,我们同样为碰撞体创建了一个类似的系统:通过在名称里加入_BoxCollider或_NoCollision后缀来确定从Blender导出的模型网格是否要替换成碰撞体。
下方是我们LevelSetupPostprocessor的一段代码,它会读取自定义属性,并为每个导入的对象分配对应的静态标记:
public class LevelSetupPostprocessor : AssetPostprocessor
{
// Dictionary of each object that is using a custom property.
private readonly Dictionary<string, (string[], object[])> _userPropertyMap = new ();
// List of all the custom properties we support
private static readonly string[] SupportedPropNames = new []
{
"Surface",
"Layer",
"PrefabLink",
"Collision",
"StaticFlags",
"LightmapScale",
"LightMeshPreset"
};
// Unity Event from AssetPostprocessor
// Called for each object in the model
private void OnPostprocessGameObjectWithUserProperties(GameObject go, string[] propNames, object[] values)
{
// Check if the custom properties contain any that we are interested in and add them to the dictionary.
if (SupportedPropNames.Select(x => x.ToLowerInvariant()).Intersect(propNames.Select(x => x.ToLowerInvariant())).Any())
{
_userPropertyMap.Add(go.name, (propNames, values));
}
}
// Unity Event from AssetPostprocessor
private void OnPostprocessModel(GameObject model)
{
// For each of the discovered custom properties,
// find the corresponding gameobject in the Model Prefab Variant
// and apply the appropriate logic
for(int i = _userPropertyMap.Count -1; i >= 0; i--)
{
var kvp = _userPropertyMap.ElementAt(i);
GameObject go = FindGameObjectInHierarchy(model, kvp.Key); // searches the model's children by name
string[] propNames = kvp.Value.Item1;
object[] values = kvp.Value.Item2;
for(int j = 0; j < propNames.Length; j++)
{
object value = values[j];
switch (propNames[j])
{
case "staticflags":
HandleStaticFlags(go, value);
break;
// ...
}
}
}
}
// Applies StaticFlags on the object based on custom properties from Blender
private void HandleStaticFlags(GameObject go, object value)
{
string[] staticFlags = value.ToString().Split(',');
StaticEditorFlags activeFlags = 0;
for(int i = 0; i < staticFlags.Length; ++i)
{
string flag = staticFlags[i].ToLower().Trim();
switch (flag)
{
case "batching static":
activeFlags |= StaticEditorFlags.BatchingStatic;
break;
// ...
default:
LogWarning($"Unknown static flag {flag} detected when importing {go.name}", go);
break;
}
}
GameObjectUtility.SetStaticEditorFlags(go, activeFlags);
}
}
要让所有这些流畅运行,我们还需要在Blender做些工作。
自定义属性不容易被发现,每次还要艺术家手动输入,这种体验并不是很好。依靠手动输入也很容易出错,使得Blender流程带来的优势化为乌有。抛弃预制件工作流也让我们没法利用预制件的优点,比如建立一个对象库来浏览和挑选对象。幸好Blender也和Unity一样非常灵活、可以扩展。
预制件库问题可以用Blender 3.2的资产库(Asset Libraries)解决。新系统类似于Unity的预制件系统:你可以将资产保存为单独的文件,然后导入到场景,文件上的修改也会自动反映到场景中。另外,所有自定义属性或碰撞体都会正确地应用到每个资产的实例上。
我们为Blender写了一个内部插件来用更清晰的界面设置自定义属性。我只需选中对象、点击按钮就行,不再需要手动输入。
我们还使用了开源的Bundle Exporter插件来一键导出所有FBX文件。我们修改了插件使之能导出自定义属性,并更新了UI用于满足我们快速导出的需要。
建立Breachers的关卡设计流程最初耗费了大量时间,但我们一直相信这是正确的选择。更别说,其实还挺好玩!
从最初alpha测试的简单布局到几个月后的最终发布,关卡的迭代一直都非常迅速、轻松。我们移除了不少设计师与艺术家肩上的重担,并且跳过了程序员将部分权力转交给了他们。
Unity和Blender之间流畅的整合过程也为我们留下了深刻印象,我们相信正是这种集成让Breachers成为我们乐于与世界分享且引以自豪的游戏。
谢谢你看到这里,祝玩得开心!
Is this article helpful for you?
Thank you for your feedback!