Animatie in Bevy: Het Grote Overzicht

Stel: je hebt zojuist een geanimeerd 3D-personage als .glb-bestand gedownload van een website met gratis assets. Je hebt een basis-Bevy-applicatie draaien en nu wil je je personage in je app plaatsen. Dat lijkt eenvoudig; er is waarschijnlijk wel een functie voor. Je zoekt het basisvoorbeeld 'Animated Mesh' op de website van Bevy op... en het wordt al snel duidelijk dat zaken niet zo simpel zijn als je had gehoopt. Er is veel configuratie nodig en er zijn animatie-gerelateerde types waarvan het exacte doel niet direct duidelijk is. Je kunt de voorbeelden wellicht redelijk snel aanpassen aan je behoeften, maar het vormen van een mentaal model van hoe animatie in Bevy werkt, enkel op basis van die voorbeelden, vereist serieus nadenken.

Dit artikel is wat ik had gewild toen ik een paar weken geleden probeerde animatie in Bevy te begrijpen. Ik begin met het stap voor stap opbouwen van een mentaal model om basisanimaties in Bevy te kunnen beheren. Daarna loop ik met je door het officiële voorbeeld, waarbij ik uitleg hoe dit aansluit op dat mentale model en je help bij methoden die je wellicht nog niet eerder bent tegengekomen.

Als je bekend bent met de basis van Bevy's ECS (Entity Component System), dan is dit artikel voor jou bedoeld.

De eerste intuïtie

Laten we Bevy en ECS even vergeten en abstract nadenken over wat er nodig is om een geanimeerd 3D-model te laten bewegen. Je hebt twee ingrediënten nodig:

  1. Een gespawnd 3D-model (of een referentie daarnaar),
  2. Een animatie die afgespeeld moet worden (of een referentie daarnaar).

In eerste instantie zou je verwachten dat het afspelen van een animatie er ongeveer zo uitziet:

mymodel.playanimation(my_animation);

Dit is onze eerste aanname van het mentale model.

Dit ligt eigenlijk redelijk in de buurt van hoe een werkelijke Bevy-functie werkt, namelijk de play-methode van het AnimationPlayer-type. Dat zou het geval zijn als self daadwerkelijk naar een 3D-model verwees en het animatie-argument daadwerkelijk naar een animatie. Op dit moment is dat echter niet direct duidelijk. Laten we gedetailleerder kijken naar hoe deze twee ingrediënten in Bevy worden gerepresenteerd, zodat we onze intuïtie kunnen verzoenen met de werking van de play-functie.

AnimationPlayer: Een manier om animaties van een 3D-model te besturen

Stel dat je een 3D-model spawnt uit een .glb-bestand. Ideaal gezien zou je naar dit model willen verwijzen met de ID van de gespawnde entity. Echter, 3D-modellen worden over het algemeen niet als één enkele entity gespawnd, maar als een hiërarchie van entities.

Om je model te animeren, moet je het op een andere manier benaderen. Bevy heeft hiervoor een mechanisme: het AnimationPlayer-type. AnimationPlayer is een Component die automatisch wordt toegevoegd aan een Entity — ergens in de entity-hiërarchie die correspondeert met een animeerbaar 3D-model — op het moment dat dat model wordt gespawnd.

Zodra je toegang hebt tot een AnimationPlayer, kun je deze opdracht geven om een animatie af te spelen of te pauzeren, of de huidige animatie opvragen. Maar dat gaat ervan uit dat je überhaupt animaties hebt. Laten we daar nu naar kijken.

AnimationGraph: Een manier om animaties op te slaan en te combineren

De manier waarop animaties in Bevy worden gerepresenteerd is enigszins complex. Wat je doorgaans manipuleert is namelijk geen enkele animatie, maar instanties van een datastructuur die meerdere animaties tegelijk kan opslaan en met elkaar kan combineren. Deze structuur is de AnimationGraph.

Ik zal hier niet ingaan op de details van hoe AnimationGraphs worden gebruikt; zodra je comfortabel bent met eenvoudige animaties, kun je meer leren over algemene animatiegrafieken in het Animation Graph voorbeeld. Voor nu richten we ons op scenario's waarin onze grafiek slechts één animatie bevat waar we geïnteresseerd in zijn. In die gevallen bestaan de gegevens die je animatie identificeren uit:

  • (Een referentie naar) een AnimationGraph.
  • Een identifier voor waar je animatie zich precies in de grafiek bevindt — dat is waar het NodeIndex-type voor dient.

Alles samenbrengen

Stel dat je een AnimationPlayer hebt, evenals een AnimationGraph en een NodeIndex. Zo verbind je deze om je animatie af te spelen:

  1. Voeg een referentie naar je AnimationGraph toe als component aan dezelfde entity als je AnimationPlayer. Dat is precies waar de AnimationGraphHandle voor is: deze bevat een Handle naar een AnimationGraph en implementeert de Component-trait.
  2. Roep myanimationplayer.play(mynodeindex); aan.

Dat is alles! Bij die aanroep weet myanimationplayer dat hij moet zoeken naar de AnimationGraph die een component is op dezelfde entity als hijzelf. Hij zoekt vervolgens naar de animatie met de index NodeIndex in die grafiek en animeert het model waartoe hij behoort.

Een wandeling door de voorbeeldcode

In deze sectie loop ik door het Animated Mesh voorbeeld van de officiële Bevy-voorbeelden en leg ik uit hoe dit relateert aan bovenstaande uitleg.

De eerste regels zijn standaard Bevy-code. Let vooral op dat we het systeem setupmeshand_animation toevoegen, waar de animatielogica staat. Dit systeem draait bij het opstarten. De eerste stap is het extraheren van animatiegegevens uit het .glb-bestand:

let (graph, index) = AnimationGraph::from_clip(
    asset_server.load(GltfAssetLabel::Animation(2).from_asset(GLTF_PATH)),
);

Er is wat boilerplate-code nodig om de animatieclip te laden. Let op de volgende punten:

  • De GltfAssetLabel::Animation constructor neemt een integer die verwijst naar de animatie in het .glb-bestand. Als je de exacte structuur van je bestand niet kent, moet je gissen: probeer getallen zoals 0, 1 of 2 en kijk of je animatie verschijnt.
  • De from_clip-methode retourneert zowel een AnimationGraph als een NodeIndex in die grafiek — precies de gegevens die je nodig hebt om naar een animatie te verwijzen.

Vervolgens voegen we de grafiek toe aan de asset store en bewaren we een Handle hiervan:

let graph_handle = graphs.add(graph);

We laden ook ons mesh. Ook hier is wat boilerplate bij betrokken:

let mesh_scene = WorldAssetRoot(asset_server.load(GltfAssetLabel::Scene(0).from_asset(GLTF_PATH)));

Het is voldoende om te weten dat de meshscene die je krijgt direct gespawnd kan worden met de spawn-methode. In dit voorbeeld wordt het voor het gemak gebundeld met een custom component, animationto_play (van het type AnimationToPlay). Deze bevat de twee stukken animatiedata die we nodig hebben: de animation graph handle en de graph index:

commands.spawn((animation_to_play, mesh_scene))

Daarna dragen we de controle over aan het playanimationwhen_ready systeem zodra de zaken correct zijn gespawnd:

.observe(play_animation_when_ready);

Omdat dit wordt aangeroepen op het resultaat van een spawn-call, injecteert deze observe-call de gespawnde entity in het systeem. De root-entity van onze mesh-scene kan dus worden benaderd in playanimationwhenready via het entity-veld van het sceneready-argument.

De resterende taken zijn iets minder direct. We moeten:

  1. De entity lokaliseren die de AnimationPlayer-component heeft.
  2. Een handle naar onze animation graph als component aan die entity toevoegen.
  3. De animation player opdracht geven de gewenste animatie af te spelen.

Bij stap 1 is er een probleem: we hebben onze mesh-scene geladen uit een .glb-bestand, wat een hiërarchie van entities spawnt. De animation player is gekoppeld aan een entity in die hiërarchie, maar we weten niet welke. In plaats van te gissen naar de structuur van het GLTF-bestand, bezoeken we handmatig alle afstammelingen van de mesh-scene root en controleren we of ze een AnimationPlayer-component hebben.

We beginnen met het ophalen van de root-entity van onze mesh-scene hiërarchie. Deze is geïnjecteerd in het systeem als het entity-veld van het scene_ready-argument. Deze entity moet een component van het type AnimationToPlay hebben, dus we kunnen deze direct ophalen:

if let Ok(animation_to_play) = animations_to_play.get(scene_ready.entity) {

(Let op: verwar animationstoplay, een Query voor componenten van het type AnimationToPlay, niet met animationtoplay, de daadwerkelijke data die we ophalen via de .get() methode).

Omdat de scene root niet noodzakelijkerwijs de plek is waar de AnimationPlayer leeft, doorlopen we alle entities in de scene (alle afstammelingen van de root-entity) om te kijken wie de component bezit:

for child in children.iter_descendants(scene_ready.entity) {
    if let Ok(mut player) = players.get_mut(child) {

(Ter info: iter_descendants is een methode die je aanroept op een Query<&Children> om alle entiteiten te itereren die afstammen van het opgegeven argument).

Nu we de entity met de animation player hebben gevonden, kunnen we de animation graph aan die entity toevoegen:

Eerst halen we de EntityCommands op die horen bij de animation player entity:

commands.entity(child)

En vervolgens voegen we de handle naar de animation graph toe als component:

.insert(AnimationGraphHandle(animation_to_play.graph_handle.clone()));

Ten slotte kunnen we de player opdracht geven de animatie te starten op de index die gespecificeerd is in animationtoplay, en instellen dat deze moet loopen:

player.play(animation_to_play.index).repeat();