模块化精灵字符及其动画

这篇博客文章完全致力于我的角色动画系统,其中包含有用的技巧和代码片段。

在过去的两个月中,我创造了多达9种新的玩家动作(例如用盾牌挡住,躲避跳跃和武器等有趣的事情),17种新的可穿戴物品,3套盔甲(板,丝绸和皮革)和6种发型。 我还完成了所有自动化和工具的创建,因此游戏中的所有内容都已投入使用。 在文章中,我将介绍如何实现这一目标!


我希望这些信息是有用的,并证明不必为了独立创建此类工具/自动化而成为天才。

简短说明


最初,我想检查是否可以将重叠的精灵与同步的动画师组合在一起,以创建具有可替换发型,设备和可穿戴物品的模块化角色。 是否可以将手绘像素动画与真正可自定义的角色结合在一起。

当然,此类功能可在带有预渲染精灵的3D和2D游戏中或在具有骨骼动画的2D游戏中积极使用,但据我所知,结合手动创建的动画和模块化角色的游戏并不多(通常是因为该过程实际上是太单调)。


我在Unity的第一个月发掘了这个古老的GIF。 实际上,这个模块化的精灵是我在游戏开发中的第一个实验之一!

我使用Unity动画系统创建了一个原型,然后添加了一件衬衫,一条裤子,一个发型和三个项目来测试这个概念。 这需要26个单独的动画。

那时,我在Photoshop中创建了所有动画,并且不担心流程的自动化,因此非常无聊。 然后我想:“因此,基本构想起作用了,以后我将添加新的动画和设备。” 事实证明,“以后”是几年后。

在今年3月,我绘制了大量装甲的设计(请参阅我的上一篇文章),并注意到如何使此过程更方便。 我继续推迟实施,因为即使有了自动化,我也感到无能为力。

我希望像大多数带有手动动画的游戏一样,我不得不放弃角色的自定义并创建唯一的主要角色。 但是我有一个行动计划,是时候检查我是否可以击败这个怪物了!



剧透:一切都很棒。 下面我将揭示我的***秘密***

模块化精灵系统


一。知道你的界限


以前,我进行了许多艺术和时间控制测试,以查明此类工作需要花费多长时间,以及对我而言是否可以达到类似的质量水平。

我写下了所有有关动画的想法,将它们放到电子表格中,并根据各种标准(例如有用性,美观性和重复使用)进行排列。 令我惊讶的是,该列表上的第一个是该项目的演员表动画(药水,炸弹,刀,斧头,球)。

我为每个动画得出一个数字分数,并放弃了所有性能不佳的东西。 最初,我计划制造6套装甲,但很快意识到装甲过多,并抛出了三种装甲。

事实证明,时间跟踪非常重要,我强烈建议您使用它来回答诸如“我能在游戏中创造多少个敌人?”这样的问题。 经过仅几次测试,我设法推断出了一个相当准确的估计值。 通过动画方面的进一步工作,我继续跟踪时间并修改了自己的期望。

我将分享过去两个月的工作日志。 请注意,这段时间是我每周工作30个小时的日常工作的补充:

https://docs.google.com/spreadsheets/d/1Nbr7lujZTB4pWMsuedVcgBYS6n5V-rHrk1PxeGxr6Ck/edit?usp=sharing

二。 改变调色板,共创美好未来


明智地使用精灵设计中的颜色,您可以绘制一个精灵并通过更改调色板创建许多不同的变化。 您不仅可以更改颜色,还可以创建各种打开和关闭元素(例如,用透明色替换颜色)。

每套装甲都有3种变化,通过混合上部和下部,您可以得到许多组合。 我计划实施一个系统,在该系统中,您可以为角色的外观收集一套盔甲,并为他的特征收集一套盔甲(例如在Terraria中)。


在此过程中,我对发现的好奇组合感到惊喜。 如果将平板顶部与丝绸底部连接起来,则可以得到战争法师风格的东西。

最好使用对精灵中的值进行编码的颜色来更改调色板,以便以后可以使用它们从调色板中查找真实颜色。 我知道我在简化一点,所以这是一个入门视频:


我不会详细解释所有内容,而是会讨论在Unity中实现此技术的方法以及它们的优缺点。

1.搜索每个调色板的纹理


这是在敌人,背景以及所有子画面具有相同调色板/材质的所有地方创建变体的最佳策略。 即使使用相同的Sprite / Atlas,也无法将不同的物料分组。 使用纹理非常麻烦,但是如果您需要为每个材质实例使用不同的调色板,则可以通过使用SpriteRenderer.sharedMaterial.SetTexture或MaterialPropertyBlock替换材质来实时更改调色板。 这是一个着色器片段函数的示例:

sampler2D _MainTex; sampler2D _PaletteTex; float4 _PaletteTex_TexelSize; half4 frag(v2f input) : SV_TARGET { half4 lookup = tex2D(_MainTex, input.uv); half4 color = tex2D(_PaletteTex, half2(lookup.r * (_PaletteTex_TexelSize.x / 0.00390625f), 0.5)); color.a *= lookup.a; return color * input.color; } 

2.颜色数组


我决定做出这个决定是因为每次角色的外观发生变化(例如,穿上物品时)时都需要更换调色板,并动态创建一些调色板(以显示玩家选择的头发和肤色)。 在我看来,出于这些目的,在运行时和在编辑器中使用数组会容易得多。

代码:

 sampler2D _MainTex; half4 _Colors[32]; half4 frag(v2f input) : SV_TARGET { half4 lookup = tex2D(_MainTex, input.uv); half4 color = _Colors[round(lookup.r * 255)]; color.a *= lookup.a; return color * input.color; } 

我将调色板显示为ScriptableObject类型,并使用MonoBehaviour工具对其进行了编辑。 在Aseprite中创建动画的过程中,在编辑调色板上已经工作了很长时间,我意识到自己需要什么工具,并相应地编写了这些脚本。 如果您想编写自己的用于编辑调色板的工具,那么我绝对建议您实现以下一些功能:

-在编辑颜色以实时显示更改时更新各种材质上的调色板。

-分配名称并更改调色板中的颜色顺序(使用该字段存储颜色索引,而不是其在数组中的顺序)。

-一次选择并编辑多种颜色。 (提示:您可以在Unity中复制和粘贴“颜色”字段:只需单击一种颜色,复制,单击另一种颜色,然后粘贴-现在它们是相同的!)

-将覆盖色应用于整个调色板

-记录调色板中的纹理

3.所有调色板的单一搜索纹理


如果您想即时切换调色板,但同时需要进行批量处理以减少绘制调用的次数,则可以使用此技术。 它可能对移动平台有用,但是使用起来很不方便。

首先,您需要将所有调色板打包成一个大纹理。 然后,使用SpriteRenderer组件中指定的颜色(AKA顶点颜色)来确定要从调色板纹理读取到着色器中的线。 也就是说,此Sprite的调色板是通过SpriteRenderer.color控制的。 顶点颜色是唯一可以更改而不破坏条件的SpriteRenderer属性(假设所有材质都相同)。

在大多数情况下,最好使用alpha通道来控制索引,因为您可能不需要一堆具有不同透明度的精灵。

代码:

 sampler2D _MainTex; sampler2D _PaletteTex; float4 _PaletteTex_TexelSize; half4 frag(v2f input) : SV_TARGET { half4 lookup = tex2D(_MainTex, input.uv); half2 paletteUV = half2( lookup.r * _(PaletteTex_TexelSize.x / 0.00390625f), input.color.a * _(PaletteTex_TexelSize.y / 0.00390625f) ) half4 color = tex2D(_PaletteTex, paletteUV); color.a *= lookup.a; color.rgb *= input.color.rgb; return color; } 


替换调色板和精灵图层的奇迹。 这么多组合。

三, 自动化一切并使用正确的工具。


要实现此功能,自动化是绝对必要的,因为结果是我获得了约300个动画和数千个精灵。

我的第一步是为Aseprite创建一个导出器,以使用方便的命令行界面管理我疯狂的Sprite层方案。 这只是一个Perl脚本,它绕过了我的Aseprite文件中的所有层和标签,并将图像导出到特定的目录和名称结构中,以便以后可以读取它们。

然后,我为Unity编写了一个导入器。 Aseprite显示带有框架数据的便捷JSON文件,因此您可以通过编程方式创建动画资产。 处理Aseprite JSON并编写此数据类型非常繁琐,因此我将其引入此处。 您可以使用JsonUtility.FromJson <AespriteData>轻松地将它们加载到Unity中,只需记住使用--format'json-array'选项运行Aseprite。

代码:

 [System.Serializable] public struct AespriteData { [System.Serializable] public struct Size { public int w; public int h; } [System.Serializable] public struct Position { public int x; public int y; public int w; public int h; } [System.Serializable] public struct Frame { public string filename; public Position frame; public bool rotated; public bool trimmed; public Position spriteSourceSize; public Size sourceSize; public int duration; } [System.Serializable] public struct Metadata { public string app; public string version; public string format; public Size size; public string scale; } public Frame[] frames; public Metadata meta; } 

在Unity方面,我在两个地方遇到了严重的问题:加载/切片Sprite表和构建动画剪辑。 一个清晰的示例将对我有很大帮助,因此这是我的导入器的代码片段,因此您不会遭受太大的痛苦:

代码:

 TextureImporter textureImporter = AssetImporter.GetAtPath(spritePath) as TextureImporter; textureImporter.spriteImportMode = SpriteImportMode.Multiple; SpriteMetaData[] spriteMetaData = new SpriteMetaData[aespriteData.frames.Length]; // Slice the spritesheet according to the aesprite data. for (int i = 0; i < aespriteData.frames.Length; i++) { AespriteData.Position spritePosition = aespriteData.frames[i].frame; spriteMetaData[i].name = aespriteData.frames[i].filename; spriteMetaData[i].rect = new Rect(spritePosition.x, spritePosition.y, spritePosition.w, spritePosition.h); spriteMetaData[i].alignment = (int)SpriteAlignment.Custom; // Same as "Pivot" in Sprite Editor. spriteMetaData[i].pivot = new Vector2(0.5f, 0f); // Same as "Custom Pivot" in Sprite Editor. Ignored if alignment isn't "Custom". } textureImporter.spritesheet = spriteMetaData; AssetDatabase.ImportAsset(spritePath, ImportAssetOptions.ForceUpdate); Object[] assets = AssetDatabase.LoadAllAssetsAtPath(spritePath); // The first element in this array is actually a Texture2D (ie the sheet itself). for (int i = 1; i < assets.Length; i++) { sprites[i - 1] = assets[i] as Sprite; } // Create the animation. AnimationClip clip = new AnimationClip(); clip.frameRate = 40f; float frameLength = 1f / clip.frameRate; ObjectReferenceKeyframe[] keyframes = new ObjectReferenceKeyframe[aespriteData.frames.Length + 1]; // One extra keyframe is required at the end to express the last frame's duration. float time = 0f; for (int i = 0; i < keyframes.Length; i++) { bool lastFrame = i == keyframes.Length - 1; ObjectReferenceKeyframe keyframe = new ObjectReferenceKeyframe(); keyframe.value = sprites[lastFrame ? i - 1 : i]; keyframe.time = time - (lastFrame ? frameLength : 0f); keyframes[i] = keyframe; time += lastFrame ? 0f : aespriteData.frames[i].duration / 1000f; } EditorCurveBinding binding = new EditorCurveBinding(); binding.type = typeof(SpriteRenderer); binding.path = ""; binding.propertyName = "m_Sprite"; AnimationUtility.SetObjectReferenceCurve(clip, binding, keyframes); AssetDatabase.CreateAsset(clip, "Assets/Animation/" + name + ".anim"); AssetDatabase.SaveAssets(); 

如果您还没有做过,那就相信我-开始创建自己的工具非常容易。 最简单的技巧是将一个带有Object对象的游戏对象放置在场景中,该对象具有[ExecuteInEditMode]属性。 添加一个按钮,您就可以战斗了! 请记住,您的个人工具不必看起来很好,它们可以纯粹是功利主义的。

代码:

 [ExecuteInEditMode] public class MyCoolTool : MonoBehaviour { public bool button; void Update() { if (button) { button = false; DoThing(); } } } 

使用精灵时,自动执行标准任务非常容易(例如,在多个精灵文件中创建调色板纹理或批量替换颜色)。 这是一个示例,您可以从中开始学习如何更改精灵。

代码:

 string path = "Assets/Whatever/Sprite.png"; Texture2D texture = AssetDatabase.LoadAssetAtPath<Texture2D>(path); TextureImporter textureImporter = AssetImporter.GetAtPath(path) as TextureImporter; if (!textureImporter.isReadable) { textureImporter.isReadable = true; AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); } Color[] pixels = texture.GetPixels(0, 0, texture.width, texture.height); for (int i = 0; i < pixels.Length; i++) { // Do something with the pixels, eg replace one color with another. } texture.SetPixels(pixels); texture.Apply(); textureImporter.isReadable = false; // Make sure textures are marked as un-readable when you're done. There's a performance cost to using readable textures in your project that you should avoid unless you plan to change a sprite at runtime. byte[] bytes = ImageConversion.EncodeToPNG(texture); File.WriteAllBytes(Application.dataPath + path.Substring(6), bytes); AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); 

我如何超越Mecanim机会:投诉


随着时间的流逝,我使用Mecanim创建的原型模块化Sprite系统成为升级Unity时的最大问题,因为API一直在变化并且文档记录不充分。 对于简单的状态机,能够在运行时查询每个剪辑的状态或更改剪辑是合理的。 但是不! 出于性能原因,Unity烘焙了片段的状态,并迫使我们使用笨拙的重新定义系统来更改它们。

Mecanim本身并不是一个糟糕的系统,但在我看来,它无法实现其主要声明的功能-简单性。 开发人员的想法是用简单的东西(可视状态机)代替看起来复杂和痛苦的东西(脚本)。 但是:

-任何非平凡的有限状态机都会迅速变成节点和连接的狂野网,其逻辑分散在不同的层中。

-通用系统要求阻碍了简单的用例。 要播放一个或两个动画,您需要创建一个新的控制器并分配状态/过渡。 当然,这会浪费过多的资源。

-结果很有趣,您仍然必须编写代码,因为为了使状态机执行一些有趣的操作,您需要一个调用Animator.SetBool和类似方法的脚本。

-要使状态机与其他剪辑一起使用,您需要将其复制并手动更换剪辑。 将来,您将不得不在几个地方进行更改。

-如果要在运行时更改状态,则存在问题。 解决方案要么是错误的API,要么是一个疯狂的图形,每个可能的动画只有一个节点。


Firewatch开发人员如何进入可视脚本地狱故事。 有趣的是,当演讲者展示最简单的示例时,它们看起来仍然很疯狂。 观众在12:41吟 加上巨大的维护成本,您将了解为什么我强烈不喜欢该系统。

其中许多问题甚至都不是Mecanim开发人员的错,而仅仅是不兼容想法的自然结果:您无法创建一个通用的同时简单的系统,并且使用图像描述逻辑比仅仅用文字/符号来描述更为困难(有人记得UML流程图吗?) 。 我记得Zack McClendon在2018年纽约实践 》上的报告中的片段,如果有时间,我建议您观看整个视频!

但是,我知道了。 视觉脚本总是受到不了解艺术家需求的激进“编写自己的引擎”书呆子的谴责。 另外,不可否认的是,大多数代码看起来像是难以理解的技术行话。

如果您已经是一位小程序员,并且使用sprite制作游戏,那么您可能需要三思而后行。 当我开始时,我确信我永远不会写出比Unity开发人员更好的引擎相关内容。

你知道吗? 事实证明,精灵动画师只是一个在指定的秒数后更改精灵的脚本。 尽管如此,我仍然必须自己编写。 从那时起,我已经在我的特定项目中添加了动画事件和其他功能,但是我半天编写的基本版本满足了我90%的需求。 它仅包含120行,可以从以下位置免费下载: https : //pastebin.com/m9Lfmd94 。 感谢您阅读我的文章。 待会见!

Source: https://habr.com/ru/post/zh-CN468991/


All Articles