Personagens modulares de sprites e suas animações

Esta postagem do blog é inteiramente dedicada ao meu sistema de animação de personagens, é preenchida com dicas úteis e trechos de código.

Nos últimos dois meses, eu criei até 9 novas ações de jogadores (coisas engraçadas como bloquear com um escudo, esquivar-se de um salto e armas), 17 novos itens vestíveis, 3 conjuntos de armaduras (chapa, seda e couro) e 6 tipos de penteados. Também terminei de criar toda a automação e ferramentas, para que tudo já esteja em uso no jogo. No artigo, vou contar como consegui isso!


Espero que esta informação seja útil e prove que não é necessário ser um gênio para criar independentemente essas ferramentas / automação.

Breve descrição


Inicialmente, eu queria verificar se é possível combinar sprites sobrepostos uns aos outros com animadores sincronizados para criar um personagem modular com penteados, equipamentos e itens de vestuário substituíveis. É possível combinar animação pixel desenhada à mão com um personagem verdadeiramente personalizável.

É claro que essas funções são ativamente usadas em jogos 3D e 2D com sprites pré-renderizados ou em jogos 2D com animação esquelética, mas até onde eu sei, não existem muitos jogos que combinem animação criada manualmente e personagens modulares (geralmente porque o processo acaba sendo monótono demais).


Eu descobri esse GIF antigo do meu primeiro mês com o Unity. De fato, este sprite modular foi um dos meus primeiros experimentos em desenvolvimento de jogos!

Criei um protótipo usando o sistema de animação Unity e adicionei uma camisa, um par de calças, um penteado e três itens para testar o conceito. Isso exigiu 26 animações separadas.

Naquele momento, criei toda a minha animação no Photoshop e não me incomodei com a automação do processo, por isso foi muito chato. Então pensei: “Então, a idéia básica funcionou, depois adicionarei novas animações e equipamentos.” Descobriu-se que "mais tarde" é alguns anos depois.

Em março deste ano, desenhei uma grande quantidade de armaduras (veja meu post anterior) e notei como esse processo pode ser mais conveniente. Continuei a adiar a implementação, porque, mesmo com a automação, eu estava nervoso que nada funcionaria.

Eu esperava ter que abandonar a personalização do personagem e criar o único personagem principal, como na maioria dos jogos com animação manual. Mas eu tinha um plano de ação e estava na hora de verificar se eu poderia derrotar esse monstro!



Spoiler: Tudo ficou ótimo. Abaixo vou revelar meus segredos ***

Sistema modular de sprite


I. Conheça seus limites


Anteriormente, conduzi muitos testes de arte e controle de tempo para descobrir quanto tempo esse trabalho pode levar e se um nível semelhante de qualidade será possível para mim.

Anotei todas as minhas idéias para animação, reuni-as em uma planilha e organizei-as de acordo com vários critérios, como utilidade, beleza e uso repetido. Para minha surpresa, o primeiro desta lista foi a animação do item (poções, bombas, facas, machados, bola).

Eu criei uma pontuação numérica para cada animação e abandonei tudo com baixo desempenho. Inicialmente, planejei criar 6 conjuntos de armaduras, mas rapidamente percebi que era demais e joguei fora três tipos.

O aspecto do controle do tempo acabou sendo muito importante, e eu recomendo usá-lo para responder a perguntas como: "Quantos inimigos posso criar no jogo?". Depois de apenas alguns testes, consegui extrapolar uma estimativa bastante precisa. Com mais trabalho em animações, continuei acompanhando o tempo e revisando minhas expectativas.

Vou compartilhar uma cópia do meu diário de trabalho nos últimos dois meses. Observe que esse tempo é um acréscimo ao meu trabalho habitual, onde passo 30 horas por semana:

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

II Alterando a paleta para um futuro melhor


Usando as cores no design do sprite com sabedoria, você pode desenhar um sprite e criar muitas variações diferentes alterando a paleta. Você pode alterar não apenas as cores, mas também criar vários elementos ativados e desativados (por exemplo, substituindo cores por transparência).

Cada conjunto de armadura tem 3 variações e, misturando as partes superior e inferior, você pode obter muitas combinações. Eu planejo implementar um sistema no qual você pode coletar um conjunto de armadura para a aparência do personagem e outro para as características dele (como em Terraria).


No processo, fiquei agradavelmente surpreendido pelas curiosas combinações descobertas. Se você conectar a parte superior da placa com um fundo de seda, poderá obter algo no estilo de um mago de guerra.

É melhor alterar as paletas usando as cores que codificam o valor no sprite, para que você possa usá-las posteriormente para encontrar a cor real da paleta. Sei que estou simplificando um pouco, então aqui está um vídeo para você começar:


Não explicarei tudo em detalhes, mas, em vez disso, falarei sobre maneiras de implementar essa técnica no Unity, e seus prós e contras.

1. Pesquise a textura para cada paleta


Esta é a melhor estratégia para criar variações de inimigos, planos de fundo e tudo o que muitos sprites têm na mesma paleta / material. Diferentes materiais não podem ser agrupados em lotes, mesmo que eles usem o mesmo sprite / atlas. Trabalhar com texturas é bastante doloroso, mas você pode alterar as paletas em tempo real, substituindo materiais usando SpriteRenderer.sharedMaterial.SetTexture ou MaterialPropertyBlock se precisar de paletas diferentes para cada instância do material. Aqui está um exemplo de uma função de fragmento de sombreador:

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. Matriz de cores


Decidi tomar essa decisão porque precisava substituir as paletas toda vez que a aparência do personagem mudar (por exemplo, ao colocar itens) e criar algumas paletas dinamicamente (para exibir as cores de cabelo e pele escolhidas pelo jogador). Pareceu-me que em tempo de execução e no editor para esses fins, seria muito mais fácil trabalhar com matrizes.

Código:

 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; } 

Apresentei minhas paletas como um tipo ScriptableObject e usei a ferramenta MonoBehaviour para editá-las. Tendo trabalhado por muito tempo na edição de paletas no processo de criação de animações no Aseprite, percebi as ferramentas necessárias e escrevi esses scripts de acordo. Se você deseja escrever sua própria ferramenta para editar paletas, aqui estão algumas funções que eu definitivamente recomendo implementar:

- Atualizando paletas em vários materiais ao editar cores para exibir alterações em tempo real.

- Atribuindo nomes e alterando a ordem das cores na paleta (use o campo para armazenar o índice de cores, não sua ordem na matriz).

- Selecione e edite várias cores de cada vez. (Dica: você pode copiar e colar os campos Cor no Unity: basta clicar em uma cor, copiar, clicar em outra cor, colar - agora eles são os mesmos!)

- Aplique cor de sobreposição em toda a paleta

- Paleta de registros em textura

3. Uma única textura de pesquisa para todas as paletas


Se você deseja alternar paletas dinamicamente, mas ao mesmo tempo precisar fazer o lote para reduzir o número de chamadas de empate, poderá usar esta técnica. Pode ser útil para plataformas móveis, mas usá-lo é bastante inconveniente.

Em primeiro lugar, você precisará embalar todas as paletas em uma textura grande. Em seguida, use a cor especificada no componente SpriteRenderer (cor do vértice AKA) para determinar a linha a ser lida da textura da paleta no shader. Ou seja, a paleta desse sprite é controlada pelo SpriteRenderer.color. A cor do vértice é a única propriedade SpriteRenderer que pode ser alterada sem quebrar a condição (desde que todos os materiais sejam iguais).

Na maioria dos casos, é melhor usar o canal alfa para controlar o índice, porque você provavelmente não precisará de muitos sprites com transparência diferente.

Código:

 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; } 


As maravilhas de substituir paletas e camadas de sprite. Tantas combinações.

III Automatize tudo e use as ferramentas certas.


Para implementar essa função, a automação era absolutamente necessária, porque, como resultado, recebi cerca de 300 animações e milhares de sprites.

Meu primeiro passo foi criar um exportador para o Aseprite para gerenciar meu esquema de camadas de sprite insano usando uma interface de linha de comando conveniente. Este é apenas um script perl que ignora todas as camadas e rótulos no meu arquivo Aseprite e exporta imagens em um diretório específico e estrutura de nomes para que eu possa lê-las mais tarde.

Então eu escrevi um importador para o Unity. O Aseprite exibe um arquivo JSON conveniente com dados do quadro, para que você possa criar recursos de animação programaticamente. Manipular o JSON Aseprite e escrever esse tipo de dados acabou sendo bastante tedioso, então eu os trago aqui. Você pode carregá-los facilmente no Unity usando JsonUtility.FromJson <AespriteData>, lembre-se de executar o Aseprite com a opção --format 'json-array'.

Código:

 [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; } 

No lado da Unity, tive sérios problemas em dois lugares: carregar / fatiar uma folha de sprite e criar um clipe de animação. Um exemplo claro me ajudaria muito, então aqui está um trecho de código do meu importador para que você não sofra tanto:

Código:

 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(); ; 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(); 

Se você ainda não o fez, acredite: é muito fácil começar a criar suas próprias ferramentas. O truque mais fácil é colocar um GameObject em cena com o MonoBehaviour anexado a ele, que possui o atributo [ExecuteInEditMode]. Adicione um botão e você está pronto para a batalha! Lembre-se de que suas ferramentas pessoais não precisam ter boa aparência, elas podem ser puramente utilitárias.

Código:

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

Ao trabalhar com sprites, é bastante fácil automatizar tarefas padrão (por exemplo, criar texturas de paleta ou substituir cores em lotes em vários arquivos de sprite). Aqui está um exemplo do qual você pode começar a aprender como alterar seus sprites.

Código:

 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); ); 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); 

Como superei as oportunidades da Mecanim: Reclamação


Com o tempo, o protótipo do sistema de sprite modular que eu criei usando o Mecanim se tornou o maior problema ao atualizar o Unity, porque a API estava constantemente mudando e era pouco documentada. No caso de uma máquina de estado simples, seria aconselhável consultar o status de cada clipe ou alterar clipes em tempo de execução. Mas não! Por razões de desempenho, o Unity cria clipes em seu estado e nos obriga a usar um sistema de redefinição desajeitado para alterá-los.

O próprio Mecanim não é um sistema tão ruim, mas parece-me que ele não consegue perceber sua principal característica declarada - a simplicidade. A idéia dos desenvolvedores era substituir o que parecia complicado e doloroso (script) por algo simples (máquina de estado visual). No entanto:

- Qualquer máquina de estado finito não trivial se transforma rapidamente em uma rede selvagem de nós e conexões, cuja lógica está espalhada por diferentes camadas.

- Casos de uso simples são prejudicados por requisitos generalizados do sistema. Para reproduzir uma ou duas animações, você precisa criar um novo controlador e atribuir estados / transições. Obviamente, há um desperdício excessivo de recursos.

- É engraçado que, como resultado, você ainda precise escrever um código, porque, para que a máquina de estado faça algo interessante, você precisa de um script que chame Animator.SetBool e métodos similares.

- Para uso múltiplo da máquina de estado com outros clipes, é necessário duplicá-lo e substituí-los manualmente. No futuro, você terá que fazer alterações em vários lugares.

- Se você deseja alterar o que está em um estado em tempo de execução, você tem problemas. A solução é uma API ruim ou um gráfico maluco com um nó para cada animação possível.


A história de como os desenvolvedores do Firewatch entraram no inferno dos scripts visuais . O engraçado é que, quando o palestrante mostra os exemplos mais simples, ele ainda parece louco. Os espectadores literalmente gemem às 12:41 . Adicione enormes custos de manutenção e você entenderá por que eu não gosto muito desse sistema.

Muitos desses problemas não são culpa dos desenvolvedores do Mecanim, mas simplesmente o resultado natural de idéias incompatíveis: você não pode criar um sistema comum e ao mesmo tempo simples, e descrever a lógica usando imagens é mais difícil do que apenas palavras / símbolos (alguém se lembra dos fluxogramas UML?) . Lembrei-me de um fragmento do relatório de Zack McClendon na Practice NYC 2018 e, se tiver tempo, recomendo que assista ao vídeo inteiro!

No entanto, eu descobri. O script visual é sempre censurado por nerds agressivos do tipo "escreva seu próprio mecanismo" que não compreendem as necessidades do artista. Além disso, não se pode negar que a maior parte do código parece um jargão técnico incompreensível.

Se você já é um pouco programador e faz jogos com sprites, pode precisar pensar duas vezes. Quando comecei, tinha certeza de que nunca poderia escrever algo relacionado ao mecanismo melhor do que os desenvolvedores do Unity.

E você sabe o que? Aconteceu que o animador do sprite é apenas um script que altera o sprite após um número especificado de segundos. Seja como for, eu ainda tinha que escrever o meu. Desde então, adicionei eventos de animação e outras funções ao meu projeto específico, mas a versão básica que escrevi em meio dia abrange 90% das minhas necessidades. Consiste em apenas 120 linhas e pode ser baixado gratuitamente a partir daqui: https://pastebin.com/m9Lfmd94 . Obrigado por ler meu artigo. Até breve!

Source: https://habr.com/ru/post/pt468991/


All Articles