Tworzenie gry w C# z użyciem silnika Ogre - cz.6

12.05.2011 - Mateusz Osowski
TrudnośćTrudność
W tym artykule poruszymy bardzo ważną kwestię - modelowanie stanów postaci. Dotychczas posługiwaliśmy się prostym automatem skończonym, lecz w świetle przyszłej implementacji sztucznej inteligencji ręczne zarządzanie przejściami staje się niewygodne. Spróbujemy nowego spojrzenia na ten problem i poszukamy bardziej skalowalnego rozwiązania. W ramach polepszania modelu stanów dodamy też upłynnianie animacji.

[Część 1] [Część 2] [Część 3] [Część 4] [Część 5] [Część 6]
[Część 7]

Mieszacz animacji

Dotychczas nasze oczy męczone były przez natychmiastowo zmieniające się animacje. Temu fatalnemu efektowi jesteśmy jednak w stanie zaradzić. Ogre umożliwia nadawanie animacjom określonych wag i odtwarzanie wielu animacji w jednej chwili. Nie poprzestaniemy jednak na upłynnianiu przejść między animacjami. Zauważmy, że w pewnych sytuacjach może zaistnieć potrzeba użycia dwóch animacji jednocześnie z pełnymi wagami, lecz na różnych częściach szkieletu. Załóżmy na przykład, że chcemy w trakcie biegu rozpocząć zamach mieczem, aby równocześnie z dobiegnięciem do celu zadać cios. Chcielibyśmy więc jednocześnie używać animacji biegu i od określonego miejsca w czasie użyć dodatkowo animacji machnięcia. Programiści Ogre wprowadzili dość niedawno funkcje rozwiązujące nasz problem - maski animacji. Pozostaje je dobrze wykorzystać w naszym silniku gry.

Chcemy, by nasz system animacji posiadał możliwość odtwarzania różnych animacji na różnych częściach ciała, jednocześnie zapewniając płynne przejścia między stanami. Podzielimy więc szkielet postaci na dwie części:

Dolna część składa się z kości nóg, a także kości-korzenia, od której będzie zależeć położenie postaci w animacji. Cała reszta kości przypada na górną część.

Plan działania prezentuje się następująco:

  • Anim - Ta klasa opakuje nam ogrowy AnimationState. Takie opakowanie będzie przydatne - ułatwi podmienianie obiektów AnimationState w przypadku zmiany animowanego obiektu Entity, na przykład gdy potraktujemy zmienianą zbroję jako animowany obiekt.
  • AnimBlendGroup - Ta klasa będzie odpowiadać za grupę animacji związanych z częścią szkieletu. Upłynnianie będzie się odbywać właśnie w tej klasie.
  • AnimBlender - Klasa zarządzająca wszystkimi animacjami i grupami. Oprócz tego, wyposażymy ją w listę powiązań i zbiorów animacji. Powiązania będą dbać o to, by animacje, od których tego oczekujemy były ze sobą zsynchronizowane w czasie (np. animacja chodu tułowia i animacja chodu nóg). Zbiory zaś będą łączyć ze sobą po jednej animacji z każdej grupy.

Pisanie zaczniemy od najmniejszej z nich.

1
2
3
4
5
6
7
public class Anim
{
  public string StateName;        
  public AnimationState State;
  public bool Loop;
  Entity Entity;
  public AnimBlendGroup AffectedGroup;    
Anim.cs


Pole StateName pozwoli nam odczytać stan animacji bieżącego obiektu do pola State. Animowany obiekt będzie zapamiętany w polu Entity. Ponadto zachowamy referencję do grupy, do której należeć będzie animacja.

1
2
3
4
5
  public Anim(string stateName, bool loop)
  {
    StateName = stateName;
    Loop = loop;            
  }   
Anim.cs


Konstruktor będzie od nas wymagać podania nazwy animacji.

1
2
3
4
5
6
7
  public void GetAnimationState(Entity entity)
  {
    Entity = entity;
    State = Entity.GetAnimationState(StateName);
    State.Loop = Loop;
    State.Weight = 0;            
  }
Anim.cs


Metoda ta będzie wywoływana w momencie przypisania animowanego obiektu, lub też po jego zmianie w upłynniaczu.

Zanim napiszemy kolejną metodę, utwórzmy klasę AnimBlendGroup:

1
2
3
4
5
6
public class AnimBlendGroup
{
  List<Anim> Anims;  
  AnimBlender Blender;
  public Anim Target;
  public string[] BlendMask;
AnimBlendGroup.cs


Potrzebna nam będzie lista animacji należąca do grupy (Anims), aktualna animacja docelowa (Target), referencja do AnimBlender, w którym będziemy przechowywać parametry upłynniania i animacji. Istotnym elementem jest też lista nazw kości, które należą do grupy (BlendMask).

1
2
3
4
5
  public AnimBlendGroup(AnimBlender blender)
  {
    Anims = new List<Anim>();
    Blender = blender;
  }
AnimBlendGroup.cs


Konstruktor wypełnia tylko niezbędne pola.

1
2
3
4
5
6
  public void AddAnim(string stateName)
  {
    Anim anim = Blender.AllAnims[stateName];
    Anims.Add(anim);
    anim.AffectedGroup = this;
  }
AnimBlendGroup.cs


Dodając animację do grupy podajemy jej nazwę (ustaloną przy eksportowaniu szkieletu). Wypełniamy też pole referencyjne animacji AffectedGroup. Posiadając tę informację, możemy wrócić do klasy Anim i dopisać do niej ostatnią metodę:

1
2
3
4
5
6
7
8
  public void AddBlendMask()
  {                        
    if (State.HasBlendMask == false)
      State.CreateBlendMask(Entity.Skeleton.NumBones, 0);
 
    foreach (string maskEntry in AffectedGroup.BlendMask)
      State.SetBlendMaskEntry(Entity.Skeleton.GetBone(maskEntry).Handle, 1);            
  }
Anim.cs


Będzie ona mogła być wywołana jedynie po zainicjowaniu pól Entity i AffectedGroup. Tworzymy maskę mieszania, jeżeli nie istnieje. Maska pozwala ustalić wpływ animacji na każdą z kości z osobna. Maska zostaje zainicjowana wartościami 0 (zerowy wpływ). W pętli (6) nanosimy wagę 1 (100% wpływ) na wszystkie kości z maski grupy, do której należy animacja.

Dokończmy klasę AnimBlendGroup:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void Update()
{
  float blendOut = 0;
 
  foreach (Anim anim in Anims)
  {
    if (anim != Target)
    {
      if (anim.State.Enabled)
      {
        float weightOut = System.Math.Min(Blender.BlendSpeed, anim.State.Weight);
        blendOut += weightOut;
        anim.State.Weight -= weightOut;
        if (anim.State.Weight <= 0.0f)                        
          anim.State.Enabled = false;   
      }
    }                
    if (anim.State.Enabled)
      anim.State.AddTime(Engine.FixedTimeStep * Blender.TimeScale);
  }
AnimBlendGroup.cs


Update() jest kluczową metodą naszego mieszacza. Podczas upłynniania animacji ważne jest, by wagi wszystkich aktywnych animacji sumowały się do jedynki. Jeżeli bowiem sumowałyby się do wartości mniejszej lub większej, przekształcenie nakładane przez animacje byłoby odpowiednio niekompletne lub nadmiarowe. Z tego powodu zapamiętujemy sumę wag "odprowadzonych" z aktywnych, nie będących docelowymi animacji. Pilnujemy, by wagi nie były ujemne (11).

1
2
3
4
5
6
7
8
  
  if (Target != null && Target.State.Weight < 1.0f)
  {
    Target.State.Weight += blendOut;
    if (Target.State.Weight > 1.0f)
      Target.State.Weight = 1.0f;
  }
}
AnimBlendGroup.cs


Wpompowujemy wcześniej odprowadzone wagi do wagi animacji docelowej pilnując przy tym, by nie przekroczyła ona 1.

Teraz zrobimy klasę główną - AnimBlender:

1
2
3
4
5
6
7
8
9
public class AnimBlender
{
  public Dictionary<string, AnimBlendGroup> Groups;
  public Dictionary<string, Anim> AllAnims;
  public Dictionary<string, string[]> AnimSets;
  public List<Anim[]> Links;
  public float BlendSpeed = 0.05f;
  public float TimeScale = 1.0f;
  public string DefaultAnimSetName;
AnimBlender.cs


Potrzebować będziemy słowników na grupy (Groups), wszystkie animacje wszystkich grup (AllAnims) i zestawy animacji (AnimSests). Oprócz tego potrzebujemy listę powiązań animacji (Links). Pole DefaultAnimSetName zawierać będzie nazwę zestawu animacji, którego wagi przyjmą wartość 1 zaraz po przypisaniu obiektu Entity do upłynniacza. Jest to konieczne ze względu na założenie poczynione wcześniej - suma aktywnych wag w każdej z grup ma się sumować do 1. BlendSpeed określać będzie jak szybko odprowadzane będą wagi z aktywnych animacji.

1
2
3
4
5
6
7
  public AnimBlender()
  {
    Groups = new Dictionary<string, AnimBlendGroup>();
    AllAnims = new Dictionary<string, Anim>();
    AnimSets = new Dictionary<string, string[]>();
    Links = new List<Anim[]>();
  }
AnimBlender.cs


Konstruktor tworzy potrzebne kolekcje.

1
2
3
4
5
  public void AddGroup(string name, params string[] mask)
  {
    Groups.Add(name, new AnimBlendGroup(this));
    Groups[name].BlendMask = mask;
  }
AnimBlender.cs


Dodając grupę będziemy musieli określić jej nazwę oraz podać listę nazw kości, które ma ta grupa objąć. Słowo kluczowe params przerobi listę argumentów na zwyczajną tablicę.

1
2
3
4
5
6
7
8
9
  public void RegisterAnim(string stateName, bool loop = true)
  {
    AllAnims.Add(stateName, new Anim(stateName, loop));
  }
  
  public void RegisterSet(string setName, params string[] setParts)
  {
    AnimSets.Add(setName, setParts);
  }
AnimBlender.cs


Te metody uproszczą zapis rejestrowania nowych animacji i ich zbiorów w kolekcjach.

1
2
3
4
5
6
7
  public void RegisterLink(params string[] links)
  {
    Anim[] anims = new Anim[links.Length];
    for (int i = 0; i < links.Length; i++)
      anims[i] = AllAnims[links[i]];
    Links.Add(anims);
  }
AnimBlender.cs


Rejestrując animacje, które chcemy ze sobą zsynchronizować podajemy jedynie ich nazwy. Metoda dodaje do zbioru powiązań listę samych referencji.

1
2
3
4
5
6
7
8
9
10
11
  public void SetEntity(Entity entity)
  {
    entity.Skeleton.BlendMode = SkeletonAnimationBlendMode.ANIMBLEND_CUMULATIVE;
    foreach (Anim anim in AllAnims.Values)
    {
      anim.GetAnimationState(entity);
      anim.AddBlendMask();
    }
    foreach (string anim in AnimSets[DefaultAnimSetName])
      AllAnims[anim].State.Weight = 1.0f;
  }
AnimBlender.cs


Bardzo ważna metoda. To ona łączy mieszacz z animowanym obiektem. Ustawienie w szkielecie trybu mieszania na ANIMBLEND_CUMULATIVE sprawi, że niezależne animacje w różnych grupach nie będą sobie przeszkadzać. Pierwsza pętla pobiera referncje na Ogrowy obiekt AnimationState i ustala maski. Ostatnia pętla inicjuje domyślną animację wagami 1.

1
2
3
4
5
6
  public void SetTarget(string targetName)
  {
    Anim target = AllAnims[targetName];
    target.State.Enabled = true;
    target.AffectedGroup.Target = target;
  }
AnimBlender.cs


Ta metoda włącza i ustawia daną animację jako docelową w swojej grupie.

1
2
3
4
5
  public void SetAnimSet(string animSetName)
  {
    foreach (string animName in AnimSets[animSetName])
    SetTarget(animName);
  }
AnimBlender.cs


Podobnie jak poprzednia metoda, lecz ustawia cały zestaw animacji jako cel (przypomnijmy, że zestaw animacji to zbiór animacji pokrywający wszystkie grupy animacji).

1
2
3
4
  public float AnimPhase(string name)
  {
    return AllAnims[name].State.TimePosition / AllAnims[name].State.Length;
  }
AnimBlender.cs


Za pomocą tej metody dowiemy się, jak zaawansowane jest odtwarzanie danej animacji, w skali od 0 do 1.

1
2
3
4
  public float AnimSetPhase(string name)
  {
    return AnimPhase(AnimSets[name][0]);
  }
AnimBlender.cs


Metoda o znaczeniu podobnym do poprzedniej. W przypadku zestawów animacji uznajemy, że pierwsza animacja w zestawie będzie tą pozwalającą określić zaawansowanie odtwarzania. Przykładowo, dla zestawu animacji uderzania podczas biegu jako pierwszą będziemy musieli podać animację uderzania należącą do górnej grupy, dopiero potem animację biegu. Dzięki temu ustaleniu upraszczamy kod - unikniamy konieczności szukania niezapętlonej animacji w zestawie.

1
2
3
4
5
  public void ResetAnimSet(string name)
  {
    foreach (string animName in AnimSets[name])
    AllAnims[animName].State.TimePosition = 0;
  }
AnimBlender.cs


Ta metoda jest bardzo przydatna - szybko zresetuje cały zestaw animacji.

1
2
3
4
5
6
7
8
9
10
  public void Update()
  {
    foreach (AnimBlendGroup group in Groups.Values)
      group.Update();
 
    foreach (Anim[] link in Links)
      foreach (Anim anim in link)
        anim.State.TimePosition = link[0].State.TimePosition;
  }
}
AnimBlender.cs


Ta, już ostatnia metoda aktualizuje mieszanie w każdej grupie i synchroznizuje pozycje w czasie powiązanych animacji. Uznajemy, że pierwsza z powiązanych animacji narzuca swoją pozycję.

Musimy teraz skonfigurować przykładowy mieszacz dla naszego modelu postaci. Korzystamy z szybkości kompilacji C# i zamiast pisać, parsować pliki konfiguracyjne stworzymy klasę konfiguracyjną:

1
2
3
4
5
6
7
8
9
10
11
12
public class CharacterAnimBlender : AnimBlender
{
  public static string[] MaskLegs = 
    { "Main", "Leg1.L", "Leg1.R", "Leg2.L", "Leg2.R", "Leg3.L", "Leg3.R", 
    "Foot.L", "Foot.R",  "SwordSheath" };
  public static string[] MaskTorso =
    { "Spine1", "Spine2", "Spine3", "Arm1.L", "Arm1.R", "Arm2.L", "Arm2.R", 
    "Arm3.L", "Arm3.R", "Arm4.L", "Arm4.R", "Hand1.L", "Hand1.R", "Hand2.L", 
    "Hand2.R", "Hand3.L", "Hand3.R", "Hand4.L", "Hand4.R", "Thumb1.L", 
    "Thumb1.R", "Thumb2.L", "Thumb2.R", "Thumb3.L", "Thumb3.R", "Thumb1.L",
    "Neck", "HeadFront", "Head", "Jaw", "Mouth.L", "Mouth.R", 
    "LongswordSheath", "Sword" };      
CharacterAnimBlender.cs


Tworząc maski grup ważne jest, by nie przeoczyć żadnej z kości i uważać, by żadna z nich nie wystąpiła w dwóch grupach jednocześnie.

1
2
3
4
5
6
7
8
9
10
11
12
  public CharacterAnimBlender()
  : base()
  {
    AddGroup("Legs", MaskLegs);
    AddGroup("Torso", MaskTorso);
 
    RegisterAnim("IdleLegs");
    RegisterAnim("IdleTorso");
    RegisterAnim("WalkLegs");
    RegisterAnim("WalkTorso");            
    RegisterAnim("PickItemDownLegs", false);
    RegisterAnim("PickItemDownTorso", false);
CharacterAnimBlender.cs


Rejestrujemy grupy i nazwy animacji, których oczekiwać będziemy od szkieletu animowanej postaci. Ze względu na podział szkieletu na grupy tworzymy wersje animacji dla każdej z grup.

1
2
3
4
5
6
  Groups["Legs"].AddAnim("IdleLegs");
  Groups["Torso"].AddAnim("IdleTorso");
  Groups["Legs"].AddAnim("PickItemDownLegs");
  Groups["Torso"].AddAnim("PickItemDownTorso");            
  Groups["Legs"].AddAnim("WalkLegs");
  Groups["Torso"].AddAnim("WalkTorso");
CharacterAnimBlender.cs


Przydzielamy animacje do odpowiednich grup.

1
2
3
  RegisterSet("Idle", "IdleLegs", "IdleTorso" );
  RegisterSet("Walk", "WalkLegs", "WalkTorso" );            
  RegisterSet("PickItemDown", "PickItemDownLegs", "PickItemDownTorso" );
CharacterAnimBlender.cs


W tej chwili interesują nas trzy zestawy. Nie korzystamy jeszcze z możliwości użycia jednej animacji w wielu zestawach

1
2
3
4
5
  RegisterLink( "WalkLegs", "WalkTorso" );
  RegisterLink( "IdleLegs", "IdleTorso" );
  
  DefaultAnimSetName = "Idle";
}
CharacterAnimBlender.cs


Zsynchronizujemy ze sobą animacje dla nóg i dla torsu i ustalamy domyślną animację - spoczynek.

Odwiedźmy teraz klasę Character i wykorzystajmy nasz upłynniacz:

1
2
3
4
5
6
7
8
9
10
11
 
  ...
  CharacterAnimBlender AnimBlender;
  ...
  public Character(...)
  {
    ...
    AnimBledner = new CharacterAnimBlender();
    AnimBlender.SetEntity(Entity);
  }
  ...
Character.cs


Dopisujemy pole i inicjujemy je po stworzeniu obiektu Entity.

1
2
3
4
public override void Update()
{
  ObjectSensor.SetPositionOrientation(SensorNode._getDerivedPosition(), Node.Orientation);
  AnimBlender.Update();
Character.cs, Update()


W metodzie Update() dodajemy aktualizację mieszacza.

Wszystkie stany zmieniamy na wzór:

1
2
3
4
5
6
...
    case CharacterState.IDLE:
      Velocity = Vector3.ZERO;
      AnimBlender.SetAnimSet("Idle");
      break;
...
Character.cs, Update()


Porównania czasu poprawiamy wg. wzoru:

1
2
3
...
if (AnimBlender.AnimSetPhase("PickItemDown") >= 0.5f)
...
Character.cs


Resetowanie animacji odbywać się będzie przez metodę:

1
2
3
...
AnimBlender.ResetAnimSet("PickItemDown");
...
Character.cs, TryPick()


Jak widać, obsługa animacji z punktu widzenia klasy postaci stała się bardzo prosta. Animacje przełączają się płynnie - cel został osiągnięty. Rozwiązanie zdaje się sprawdzać.

Nowy model i szkielet

Aktualny kod źródłowy

5
Twoja ocena: Brak Ocena: 5 (2 ocen)

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com