在我见过的大多数项目中,开发者经常要做很多重复性和易出错的任务,特别是在整合新的美术资产时。开发者在配置一个角色时往往需要多次拖放资产到引用字段、勾选选框、按各种按钮:比如将模型骨架设为人形(Humanoid)、禁用SDF纹理的sRGB、将法线贴图设置为“法线贴图”,以及将UI纹理设为精灵(sprite)。在这个过程里,你不仅会浪费宝贵的时间,还仍有可能在关键步骤上出错。
因此,我将用两篇文章带你了解改善工作流程的黑科技,让你的下一个项目运作得更流畅。为了进一步的演示,我制作了一个类似于RTS(即时战略)游戏的简单原型,游戏队伍里的单位会自动攻击敌人的建筑及其他单位。每一条编程黑科技都能改进工作流的一个方面,无论是纹理抑或模型。
下面是游戏的原型:
开发者之所以要在导入时配置这么多细微设置,主要原因很简单:Unity不知道资产的使用方式,因此也不清楚资产的最佳设置是什么。弄清楚这些最佳设置是我们自动化资产导入的首个问题。
要解决这个问题,最简单的办法是坚持具体的命名惯例和文件夹结构,从中得知资产的用途及与其他资产的关系,它包括:
它的问题在于其单向性。即资产的路径可以显示文件的用途,但资产本身的信息无法用于反推文件路径。资产的某些部分如果想要自动完成设置,那取得资产(比如角色的材质)的文件路径是很重要的。尽管我们能借助严格的命名规则来推断路径,但这样做很容易出错。即便你把规则背得滚瓜烂熟,错别字也容易让人扶额。
该问题的一个解决方法是使用标签。你可以用一份编辑器脚本来解析资产的路径并分配相应的标签。我们可以很容易地推断出自动生成的资产标签。甚至可以用AssetDatabase.FindAssets,按标签查找资产。
如果你想自动化该流程,AssetPostprocessor类可以帮到你。AssetPostprocessor在Unity导入资产时会接收各种信息。这其中就有OnPostprocessAllAssets,该方法会在Unity完成导入时被调用。它能提供所有资产的路径,让你有机会处理这些信息。你可以编写一个类似下方的简单方法来进行处理:
private static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets,
string[] movedAssets, string[] movedFromAssetPaths)
{
foreach (var asset in importedAssets)
ProcessAssetLabels(asset);
foreach (var asset in movedAssets)
ProcessAssetLabels(asset);
}
我们需要关注游戏原型的资产导入表——不仅要抓取新的资产,还要记录移动过的资产。一旦路径发生变化,标签也需要更新。
要创建标签,你得解析路径并搜寻相关的文件夹、名称前缀和后缀,以及扩展名。在标签生成后,将其合并成一条字符串再设为资产的标签。
要分配标签,你可以用AssetDatabase.LoadAssetAtPath加载资产,再用AssetDatabase.SetLabels分配标签。
var obj = AssetDatabase.LoadAssetAtPath<Object>(assetPath);
if (obj)
{
if (labels.Count == 0)
{
AssetDatabase.ClearLabels(obj);
return;
}
var oldLabels = AssetDatabase.GetLabels(obj);
var labelsArray = new string[] { string.Join('-', labels) };
if (HaveLabelsChanged(oldLabels, labelsArray))
{
AssetDatabase.SetLabels(obj, labelsArray);
}
}
记住,标签一定得在改动发生之后才能重新设置。标签的每次设置都会触发资产的重新导入,所以非必要请勿随意修改。
如果你检查后觉得仍有必要,那就乖乖地等待资产重新导入吧:首次导入时分配的标签还会保存到.meta文件里,因此也会储存到版本控制系统中。资产的重新导入只会在文件被重命名或移动时触发。
在上述步骤完成后,所有的资产都像下边这样被自动贴上标签。
每张纹理在导入项目时通常需要调整各种设置。它是一张普通纹理?法线图?还是张精灵图?颜色是线性的还是sRGB?如果你想修改资产导入器的设置,可以再次利用AssetPostprocessor。
这时,你需要利用纹理导入前调用的OnPreprocessTexture消息。借此来修改导入器的设置。
在为每张纹理选择正确的设置时,我们需要首先验证纹理的类型——这也正是标签成为关键第一步的原因。
有了这些信息,你可以写一份简单的TexturePreprocessor脚本:
private void OnPreprocessTexture()
{
var labels = AssetDatabase.GetLabels(assetImporter);
// 我们只想影响资产
if (labels.Length == 0 || !labels[0].Contains(AssetClassifier.ART_LABEL))
return;
// 获取导入器
var textureImporter = assetImporter as TextureImporter;
if (!textureImporter)
return;
我们必须只在带有“美术标签”(ART_LABEL)的纹理上运行代码。然后你能让导入器引用资产文件并进行各种设置——比如先从纹理尺寸开始。
AssetPostprocessor带有的上下文属性可提供目标平台的信息。你可以借此为特定平台作修改,比如为移动端设置较低的纹理分辨率。
// 设置纹理大小,比如android使用的纹理要小一些
if (context.selectedBuildTarget == BuildTarget.iOS || context.selectedBuildTarget == BuildTarget.Android)
textureImporter.maxTextureSize = 256;
else
textureImporter.maxTextureSize = 512;
接着检查标签,看纹理是否为UI纹理,并进行相应的设置:
// UI纹理是一个特例
if (labels[0].Contains(AssetClassifier.UI_LABEL))
{
textureImporter.textureType = TextureImporterType.Sprite;
textureImporter.sRGBTexture = true;
return;
}
对于其余的纹理,将值设为默认值。值得注意的是,Albedo是唯一能启用sRGB的纹理:
// 我们所有的纹理都是标准纹理,但如果我们使用法线,可以在这里设置它
textureImporter.textureType = TextureImporterType.Default;
textureImporter.textureShape = TextureImporterShape.Texture2D;
// 将它们设置为可读以便在编辑器中处理
textureImporter.isReadable = true;
// 只有 albedo 文理是 sRGB
var texName = Path.GetFileNameWithoutExtension(assetPath);
textureImporter.sRGBTexture = labels[0].Contains(AssetClassifier.BASE_COLOR_LABEL)
}
在上述脚本的帮助下,新纹理被拖放到编辑器内时会自动应用正确的设置。
“通道打包”是指通过将不同通道上的纹理打包成一份。这种常见的做法有很多优点。它可以用红色通道的值表示金属度,用绿色通道的值表示平滑度。
不过,将所有的纹理组成一张纹理需要美术团队做一些额外工作。如果打包过程因某种原因(比如着色器要换)需要修改,那用于原着色器的纹理也得推倒重做。
可以看到,这里有改进的余地。我喜欢的通道打包方法是创建一个引用“原始”纹理的特殊资产,再生成一个打包好的纹理用在材质上。
首先,我新建一种带有特定扩展名的填充(dummy)文件,再用Scripted Importer导入资产,同时完成主要的打包工作。以下是它的工作方式:
要实施该方法,请创建一个ScriptableObject用于保存生成的纹理及导入结果。这里,我把该类称为TexturePack。
创建对象后,你可以开始声明导入类,并加上ScriptedImporterAttribute来写明导入器的版本和扩展名。
[ScriptedImporter(0, PathHelpers.TEXTURE_PACK_EXTENSION)]
public class TexturePackImporter : ScriptedImporter
{
…
}
在导入程序里声明要用到的字段。这些字段会像MonoBehaviours和ScriptableObjects一样出现在Inspector窗口里。
public LazyLoadReference<Texture2D> albedo;
public LazyLoadReference<Texture2D> playerMap;
public LazyLoadReference<Texture2D> metallic;
public LazyLoadReference<Texture2D> smoothness;
设置好参数后,在其上新建纹理。注意,此时我们已经在Preprocessor(详见上一节)里将isReadable设为了True。
在该原型里,你能看到两张纹理:Albedo纹理,它用RGB表示反射率,用Alpha表示颜色的透明度;Mask纹理,它包括红色通道上的金属度和绿色通道上的平滑度。
这部分可能超出了本文的范畴,不过我们应该看看怎样组合Albedo和遮罩纹理。首先,检查纹理是否设置完毕,若已经设好,则获取其颜色数据。然后使用AssetImportContext.DependsOnArtifact将纹理设为依赖项。之前提到,如果任何纹理发生了变化,导入器会强制重新生成对象。
public Texture2D CombineAlbedoPlayer(AssetImportContext ctx)
{
Color32[] albedoPixels = new Color32[0];
bool albedoPresent = albedo.isSet;
if (albedoPresent)
{
ctx.DependsOnArtifact(AssetDatabase.GetAssetPath(albedo.asset));
albedoPixels = albedo.asset.GetPixels32();
}
Color32[] playerPixels = new Color32[0];
bool playerPresent = playerMap.isSet;
if (playerPresent)
{
ctx.DependsOnArtifact(AssetDatabase.GetAssetPath(playerMap.asset));
playerPixels = playerMap.asset.GetPixels32();
}
if (!albedoPresent && !playerPresent)
return null;
这时,你还需要新建一张纹理。为此,你需要从上一节编写的TexturePreprocessor里获取纹理尺寸,使其满足尺寸的预设限制:
var size = TexturePreProcessor.GetMaxSizeForTarget(ctx.selectedBuildTarget);
var newTexture = new Texture2D(size, size, TextureFormat.RGBA32, true, false);
var pixels = new Color32[size * size];
接着,填入新纹理的所有数据。这部分可以借助Jobs和Burst大规模优化(需要另写文章涵盖这一内容)。这里我们将使用一个简单的循环:
Color32 tmp = new Color32();
Color32 white = new Color32(255, 255, 255, 255);
for (int i = 0; i < pixels.Length; ++i)
{
var color = albedoPresent ? albedoPixels[i] : white;
var alpha = playerPresent ? playerPixels[i] : white;
tmp.r = color.r;
tmp.g = color.g;
tmp.b = color.b;
tmp.a = alpha.r;
pixels[i] = tmp;
}
在纹理中设置以下数据:
newTexture.SetPixels32(pixels);
// 将更改应用于mipmap
newTexture.Apply(true, false);
// 压缩纹理
newTexture.Compress(true);
// 设为不可读
newTexture.Apply(true, true);
newTexture.name = "AlbedoPlayer";
// 返回结果
return newTexture;
现在,你可以以类似的方式编写另一张纹理的生成方法。在准备好后,创建导入器的主输出对象。这里,我们只创建保存结果的ScriptableObject、生成纹理,再用AssetImportContext设置导入器的成果。
在编写导入程序时,所有生成的资产必须用AssetImportContext.AddObjectToAsset注册才能出现在项目窗口中。用AssetImportContext.SetMainObject选择一个主资产。代码应该是以下这样:
public override void OnImportAsset(AssetImportContext ctx)
{
var result = ScriptableObject.CreateInstance<TexturePack>();
result.albedoPlayer = CombineAlbedoPlayer(ctx);
if (result.albedoPlayer)
ctx.AddObjectToAsset("albedoPlayer", result.albedoPlayer);
result.mask = CombineMask(ctx);
if (result.mask)
ctx.AddObjectToAsset("mask", result.mask);
ctx.AddObjectToAsset("result", result);
ctx.SetMainObject(result);
}
剩下要做的是创建替身资产。这些自定义资产并不支持CreateAssetMenu 属性。你必须手动编写一个菜单选项。
使用MenuItem属性为Assets/Create资产菜单选项指定完整路径。使用ProjectWindowUtil.CreateAssetWithContent来实现资产创建,让用户生成带有指定内容的文件,并为其输入名称。它看起来像这样:
[MenuItem("Assets/Create/Texture Pack", priority = 0)]
private static void CreateAsset()
{
最后,创建打包好通道的纹理。
大多数项目都会用到自定义着色器。它们有时被用于额外的效果,如敌人被打败时的淡出溶解效果,有时则用于实现一种自定义艺术风格,如卡通着色器。无论是哪种情况,Unity都是用默认着色器来新建材质,你需要修改后才能使用自定义着色器。
在这个例子中,游戏单位的着色器有两个额外功能:溶解效果和设定玩家颜色(原型中的红色和蓝色)。在项目中应用这些功能时,你必须保证所有的建筑和单位都用着对的着色器。
要验证一项资产是否符合某项要求(这里是指是否用了正确的着色器),你还可以用另一个实用的类:AssetModificationProcessor。特别是AssetModificationProcessor.OnWillSaveAssets会在Unity即将把资产写入磁盘时发出通知。你可以借此检查资产设置是否正确,在保存前将其改正。
此外,如果检测出的问题无法自动修复,你可以“告诉”Unity不要保存资产。要做到这点,请参考下方的OnWillSaveAssets方法:
private static string[] OnWillSaveAssets(string[] paths)
{
foreach (string path in paths)
{
ProcessMaterial(path);
}
// 如果不想保存,移除列表的资产路径
return paths;
}
在处理资产时,你需要检查它们是否是材质、是否带有正确的标签。如果它们与下方代码相匹配,就代表资产用着正确的着色器:
private static void ProcessMaterial(string path)
{
var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
// 检查是否为材质
if (!mat)
return;
// 检查是building还是unit
var labels = AssetDatabase.GetLabels(mat);
if (labels.Length == 0 || !(labels[0].Contains(AssetClassifier.UNIT_LABEL)
|| labels[0].Contains(AssetClassifier.BUILDING_LABEL)))
return;
if (mat.shader.name != UNIT_SHADER_NAME)
{
mat.shader = Shader.Find(UNIT_SHADER_NAME);
}
}
这里的方便之处在于,每次资产创建时这段代码就会被调用,意味着新材质也能用上对的着色器。
我们还发布了Material Variant(材质变体)作为Unity 2022的新功能。材质变体为多个单位创建材质时非常有用。你可以根据一份基础材质派生出每个单位的材质,覆盖想修改的字段(如纹理)并继承其余的属性。这样一来材质默认就有坚实的设置,还可以根据需要进行更新。
导入动画与导入纹理类似,也有许多需要配置的设置,部分配置也可以自动化。
Unity默认会导入所有FBX(.fbx)文件的材质。在动画上,材质往往会保存在项目中或模型网格的FBX文件里。动画FBX文件的材质同样会出现在项目的材质搜索框,为你搜索材质平添不少的干扰,所以你可以禁用它们。
配置动画骨架基本与纹理相同,就是在Humanoid和Generic之间进行选择,或者指派一个精心配置好的Avatar(化身)。在处理动画时,你要用的是AssetPostprocessor.OnPreprocessModel消息。所有的FBX文件都会调用这段消息,所以你首先得区分动画FBX文件和模型FBX文件。
不过有了前边设好的标签,这一步应该不会太复杂。这个方法的开头与纹理的方法很相似:
private void OnPreprocessModel()
{
// 我们只想影响动画
var labels = AssetDatabase.GetLabels(assetImporter);
if (labels.Length == 0 || !labels[0].Contains(AssetClassifier.ANIMATION_LABEL))
return;
// 获取导入器
var modelImporter = assetImporter as ModelImporter;
if (!modelImporter)
return;
// 我们需要动画
modelImporter.importAnimation = true;
// 我们不想要任何材质
modelImporter.materialImportMode = ModelImporterMaterialImportMode.None;
接着,你需要找到对应的动画资产,取得模型网格FBX文件里的骨架。你可以再用标签来找到资产。在我的原型里,动画文件都带有以“animation”结尾的标签,而模型网格则带有以“model”结尾的标签。你只需简单地替换下关键字就能获取模型的标签了。一旦取得了标签,使用AssetDatabase.FindAssets的“l:label-name”找到资产。
在访问其他资产时,你还需要考虑其他一些问题:在导入过程中,该方法可能会在Avatar尚未被导入时被调用。这时,LoadAssetAtPath只返回null,你将无法设定Avatar。要解决这个问题,你可以给Avatar的路径设置一个依赖项。一旦替身被导入,动画也会再次被导入,让你将能够进行设置。
所有这些放到代码中是这样的:
// 尝试获取avatar
var avatarLabel = labels[0].Replace(AssetClassifier.ANIMATION_LABEL, AssetClassifier.MODEL_LABEL);
var possibleModels = AssetDatabase.FindAssets("l:" + avatarLabel);
Avatar avatar = null;
if (possibleModels.Length > 0)
{
var avatarPath = AssetDatabase.GUIDToAssetPath(possibleModels[0]);
avatar = AssetDatabase.LoadAssetAtPath<Avatar>(avatarPath);
if (!avatar)
context.DependsOnArtifact(avatarPath);
}
modelImporter.animationType = ModelImporterAnimationType.Generic;
modelImporter.sourceAvatar = avatar;
modelImporter.avatarSetup = ModelImporterAvatarSetup.CopyFromOther;
现在你可以把动画拖到对应的文件夹里,模型网格在导入时就会自动设置完成。但如果动画在导入时没有可用的替身,那项目在创建完成后也无法获取Avatar。你反而需要在创建后手动重新导入动画。右键单击动画的文件夹并选择Reimport即可。
下方是视频演示:
参照与前边完全相同的想法,你同样能设置要使用的模型。这时,你可以用AssetPostrocessor.OnPreprocessModel来设置模型的导入器设置。
在我的原型里,我已经将导入器设为不生成材质(我将使用项目中的材质),并检查模型是一个单位还是一个建筑(同样通过验证标签)。如果是单位则生成一个替身,而因为建筑没有动画,所以不必为其生成替身。
在你自己的项目里,你可能需要在导入模型时设置材质和动画器(Animator)(及其他想加的东西)。这样一来导入器生成的Prefab就可以立即投入使用。
要做到这一点,你可以用AssetPostprocessor.OnPostprocessModel方法。该方法会在模型导入完成后被调用。它接收生成的预制件作为参数,让我们可以随意修改这个预制件。
在原型里,同寻找动画的替身一样,我通过匹配标签找到了材质和Animation Controller。我能像正常游戏一样在预制件的渲染器和动画器上设置材质和控制器。
然后将模型放到项目里,随时在场景中取用。不过我们没有设置任何与游戏相关的组件,这部分将在第二篇博客中讨论。
有了这些高级编程技巧,你就有更充足的准备来制作游戏了。请继续关注Tech from the Trenches的下一篇文章,届时我们将讨论平衡游戏数据等更多方面的黑科技。
如果你想讨论本文,或分享一些读后感,欢迎访问我们的Scripting论坛。你也可以在Twitter上联系我:@CaballolD。