|
| 1 | +--- |
| 2 | +title: Animation 3D |
| 3 | +category: Amenities |
| 4 | +categoryindex: 5 |
| 5 | +index: 23 |
| 6 | +--- |
| 7 | + |
| 8 | +# Animation 3D (Skeletal Animation) |
| 9 | + |
| 10 | +Mibo provides a three-tier 3D skeletal animation system in `Mibo.Animation`. It supports per-model CPU skinning, shared-mesh GPU skinning, and animation blending via `UpdateModelAnimationEx`. |
| 11 | + |
| 12 | +## Core Types |
| 13 | + |
| 14 | +| Type | Purpose | |
| 15 | +| ---- | ------- | |
| 16 | +| `Animation3DClips` | Shared clip set loaded from `ModelAnimation[]` — name/index lookup | |
| 17 | +| `Animation3DState` | Per-entity playback state (current frame, blend, speed, loop) | |
| 18 | +| `AnimatedMesh` | Shared mesh + inverse bind pose for GPU skinning | |
| 19 | + |
| 20 | +## Quick Start |
| 21 | + |
| 22 | +```fsharp |
| 23 | +open Mibo.Animation |
| 24 | +
|
| 25 | +// 1. Load model and animations (at init time) |
| 26 | +let model = assets.Model "character.glb" |
| 27 | +let anims = assets.ModelAnimations "character.glb" |
| 28 | +let clips = Animation3DClips.fromModelAnimations anims |
| 29 | +
|
| 30 | +// 2. Create per-entity animation state |
| 31 | +let anim = Animation3DState.create model clips "idle" 60.0f |
| 32 | +
|
| 33 | +// 3. Update each frame (in your animation system) |
| 34 | +let anim = anim |> Animation3DState.update deltaTime |
| 35 | +
|
| 36 | +// 4. Apply and render (in your view) |
| 37 | +Animation3DState.applyToModel anim |
| 38 | +buffer |> Draw3D.drawModel anim.Model transform |
| 39 | +``` |
| 40 | + |
| 41 | +## Three API Tiers |
| 42 | + |
| 43 | +### Tier 1 — Data Extraction (`Animation3DClips`) |
| 44 | + |
| 45 | +Load and query animation clips. No GPU, no model mutation. |
| 46 | + |
| 47 | +```fsharp |
| 48 | +let anims = assets.ModelAnimations "character.glb" |
| 49 | +let clips = Animation3DClips.fromModelAnimations anims |
| 50 | +
|
| 51 | +let names = Animation3DClips.names clips // [|"idle"; "walk"; "jump"|] |
| 52 | +let count = Animation3DClips.count clips // 3 |
| 53 | +let idx = Animation3DClips.tryGetClipIndex "walk" clips // ValueSome 1 |
| 54 | +``` |
| 55 | + |
| 56 | +### Tier 2 — GPU Skinning (`AnimatedMesh`) |
| 57 | + |
| 58 | +Share one mesh across many entities. Each entity computes its own bone matrices. |
| 59 | + |
| 60 | +```fsharp |
| 61 | +let mesh = AnimatedMesh.fromModel model // load once, share |
| 62 | +
|
| 63 | +// Per-entity (lightweight — just matrix math) |
| 64 | +let bones = AnimatedMesh.computeBoneMatrices clip frame mesh |
| 65 | +
|
| 66 | +// Render — GPU does the skinning |
| 67 | +buffer |> Draw3D.drawSkinnedMesh mesh.Mesh transform material bones |
| 68 | +``` |
| 69 | + |
| 70 | +### Tier 3 — Per-Model CPU Skinning (`Animation3DState`) |
| 71 | + |
| 72 | +Simplest API. Each entity owns its own `Model` copy. Raylib's `UpdateModelAnimation` handles skinning on the CPU. |
| 73 | + |
| 74 | +```fsharp |
| 75 | +let anim = Animation3DState.create model clips "idle" 60.0f |
| 76 | +let anim = anim |> Animation3DState.update dt |
| 77 | +Animation3DState.applyToModel anim // mutates model's bone matrices |
| 78 | +buffer |> Draw3D.drawModel anim.Model transform |
| 79 | +``` |
| 80 | + |
| 81 | +### When to Use Which |
| 82 | + |
| 83 | +| Scenario | Tier | Why | |
| 84 | +|----------|------|-----| |
| 85 | +| 1–5 animated characters | Tier 3 | Simple, no shader changes | |
| 86 | +| 10+ animated enemies | Tier 2 | Share mesh, GPU skinning | |
| 87 | +| Hundreds of units (RTS) | Tier 2 + instancing | `DrawMeshInstanced` with skinning | |
| 88 | + |
| 89 | +## Animation3DClips API |
| 90 | + |
| 91 | +### Loading |
| 92 | + |
| 93 | +```fsharp |
| 94 | +let anims = assets.ModelAnimations "character.glb" |
| 95 | +let clips = Animation3DClips.fromModelAnimations anims |
| 96 | +``` |
| 97 | + |
| 98 | +The `ModelAnimations` asset method loads all skeletal animations from a glb/gltf/iqm file. Returns an empty array if the model has no animations. |
| 99 | + |
| 100 | +### Discovery |
| 101 | + |
| 102 | +```fsharp |
| 103 | +let names = Animation3DClips.names clips // [|"idle"; "walk"; "jump"|] |
| 104 | +let count = Animation3DClips.count clips // 3 |
| 105 | +let empty = Animation3DClips.isEmpty clips // false |
| 106 | +let idx = Animation3DClips.tryGetClipIndex "walk" clips // ValueSome 1 |
| 107 | +``` |
| 108 | + |
| 109 | +## Animation3DState API |
| 110 | + |
| 111 | +### Creation |
| 112 | + |
| 113 | +```fsharp |
| 114 | +// Start on a named clip |
| 115 | +let anim = Animation3DState.create model clips "idle" 60.0f |
| 116 | +
|
| 117 | +// Start on a clip index (zero string allocation) |
| 118 | +let anim = Animation3DState.createByIndex model clips 0 60.0f |
| 119 | +
|
| 120 | +// Default to index 0 if name not found |
| 121 | +let anim = Animation3DState.create model clips "nonexistent" 60.0f |
| 122 | +``` |
| 123 | + |
| 124 | +The `fps` parameter controls playback speed. It is divided by 60 internally (raylib's default keyframe rate) to produce a speed multiplier. |
| 125 | + |
| 126 | +### Playback Control |
| 127 | + |
| 128 | +```fsharp |
| 129 | +// Switch animation (resets frame, cancels blend) |
| 130 | +let anim = anim |> Animation3DState.play "walk" |
| 131 | +
|
| 132 | +// Switch by index (zero string allocation) |
| 133 | +let anim = anim |> Animation3DState.playByIndex 1 |
| 134 | +
|
| 135 | +// Switch only if not already playing |
| 136 | +let anim = anim |> Animation3DState.playIfNot "walk" |
| 137 | +
|
| 138 | +// Restart current animation |
| 139 | +let anim = anim |> Animation3DState.restart |
| 140 | +``` |
| 141 | + |
| 142 | +### Blending |
| 143 | + |
| 144 | +Crossfade between two animations using `UpdateModelAnimationEx`: |
| 145 | + |
| 146 | +```fsharp |
| 147 | +// Blend from current to "walk" over 0.2 seconds |
| 148 | +let anim = anim |> Animation3DState.blendTo "walk" 0.2f |
| 149 | +
|
| 150 | +// Or by index |
| 151 | +let anim = anim |> Animation3DState.blendToByIndex 1 0.2f |
| 152 | +
|
| 153 | +// Check blend state |
| 154 | +let blending = Animation3DState.isBlending anim // true during blend |
| 155 | +``` |
| 156 | + |
| 157 | +`blendTo` is idempotent — calling it repeatedly with the same target does not restart the blend. When the blend completes, the target animation becomes the current animation. |
| 158 | + |
| 159 | +### Update |
| 160 | + |
| 161 | +```fsharp |
| 162 | +let anim = anim |> Animation3DState.update deltaTime |
| 163 | +``` |
| 164 | + |
| 165 | +Advances the current frame (and blend target frame if blending). Respects `Loop` and `Speed` settings. Does not apply to the model — use `applyToModel` after. |
| 166 | + |
| 167 | +### Apply to Model |
| 168 | + |
| 169 | +```fsharp |
| 170 | +Animation3DState.applyToModel anim |
| 171 | +``` |
| 172 | + |
| 173 | +Calls `Raylib.UpdateModelAnimation` (or `UpdateModelAnimationEx` when blending) which mutates the model's bone matrices. Must be called before rendering with `DrawModel`. |
| 174 | + |
| 175 | +### Query |
| 176 | + |
| 177 | +```fsharp |
| 178 | +let finished = Animation3DState.isFinished anim |
| 179 | +let playing = Animation3DState.isPlaying "walk" anim |
| 180 | +let name = Animation3DState.currentClipName anim |
| 181 | +let dur = Animation3DState.duration anim |
| 182 | +``` |
| 183 | + |
| 184 | +### Configuration |
| 185 | + |
| 186 | +```fsharp |
| 187 | +let anim = anim |> Animation3DState.withSpeed 0.5f // half speed |
| 188 | +let anim = anim |> Animation3DState.withLoop false // don't loop |
| 189 | +``` |
| 190 | + |
| 191 | +## GPU Skinning (AnimatedMesh) |
| 192 | + |
| 193 | +For scenarios with many animated entities sharing the same mesh. |
| 194 | + |
| 195 | +### Loading |
| 196 | + |
| 197 | +```fsharp |
| 198 | +let mesh = AnimatedMesh.fromModel model |
| 199 | +// Returns ValueNone if model has no bones |
| 200 | +``` |
| 201 | + |
| 202 | +### Computing Bone Matrices |
| 203 | + |
| 204 | +```fsharp |
| 205 | +let clip = clips.Clips[clipIndex] |
| 206 | +let bones = AnimatedMesh.computeBoneMatrices clip frame mesh |
| 207 | +// Returns Matrix4x4[] — pure math, no model mutation |
| 208 | +``` |
| 209 | + |
| 210 | +The algorithm matches raylib's `UpdateModelAnimation`: |
| 211 | +1. Interpolate keyframes (lerp for translation/scale, slerp for rotation) |
| 212 | +2. Build TRS matrices for bind pose and current pose |
| 213 | +3. Multiply: `boneMatrices[i] = inverse(bindPose) * currentPose` |
| 214 | + |
| 215 | +### Rendering |
| 216 | + |
| 217 | +```fsharp |
| 218 | +buffer |> Draw3D.drawSkinnedMesh mesh.Mesh transform material bones |
| 219 | +``` |
| 220 | + |
| 221 | +The shader receives bone matrices as a `boneMatrices[128]` uniform and applies skinning on the GPU via `vertexBoneIndices` / `vertexBoneWeights` vertex attributes. |
| 222 | + |
| 223 | +## Integration with MVU |
| 224 | + |
| 225 | +Animation state lives in your Elmish model. Update in a system, apply in the view: |
| 226 | + |
| 227 | +```fsharp |
| 228 | +// Types.fs |
| 229 | +type GameModel() = |
| 230 | + member val PlayerAnim = Unchecked.defaultof<Animation3DState> with get, set |
| 231 | +
|
| 232 | +// Systems.fs |
| 233 | +let animationSystem dt model = |
| 234 | + let targetAnim = if not model.IsGrounded then "jump" elif isMoving then "walk" else "idle" |
| 235 | + model.PlayerAnim <- model.PlayerAnim |> Animation3DState.blendTo targetAnim 0.15f |> Animation3DState.update dt |
| 236 | + struct (model, Cmd.none) |
| 237 | +
|
| 238 | +// View.fs |
| 239 | +Animation3DState.applyToModel model.PlayerAnim |
| 240 | +buffer |> Draw3D.drawModel model.PlayerAnim.Model transform |
| 241 | +``` |
| 242 | + |
| 243 | +## Model Format |
| 244 | + |
| 245 | +Raylib supports **glTF/GLB** and **IQM** for skeletal animation. GLB is recommended — it bundles geometry, textures, and animation data in a single file. |
| 246 | + |
| 247 | +Animations are loaded from the model file via `assets.ModelAnimations`. The animation names come from the file's embedded animation names (e.g., "idle", "walk", "jump" in a Kenney character model). |
| 248 | + |
| 249 | +## Performance Tips |
| 250 | + |
| 251 | +1. **Resolve clip names once at init**: Use `tryGetClipIndex` + `playByIndex` to avoid string lookups in the hot path |
| 252 | +2. **Share Animation3DClips**: Create clips once, reuse across all entities using the same model |
| 253 | +3. **Tier 2 for many entities**: Use `AnimatedMesh` + `computeBoneMatrices` + `DrawSkinnedMesh` to avoid per-entity model copies |
| 254 | +4. **Blend duration**: Keep blend durations short (0.1–0.3s) to minimize double-animation overhead |
| 255 | + |
| 256 | +## See Also |
| 257 | + |
| 258 | +- [Animation (2D)](animation.html) |
| 259 | +- [Rendering 3D](graphics3d/overview.html) |
| 260 | +- [Materials](graphics3d/materials.html) |
0 commit comments