Skip to content

Commit fe79de1

Browse files
authored
feat: 3D skeletal animation system with GPU skinning and blending (#5)
* feat: add 3D skeletal animation system with GPU skinning and blending * fix: address PR review — empty clips guards, bounds safety, material cache, bone upload clamp * docs: update changelog with 3D animation system entries
1 parent fced8aa commit fe79de1

12 files changed

Lines changed: 1679 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@
77
- 2D normal map support: `SpriteState.NormalMap` field for per-pixel lighting on lit sprites. `LightContext2D` manages two shader variants (standard and normal-mapped) and switches between them via `BeginShaderMode`. The normal-map shader uses a 2D-compatible Half-Lambert lighting model (`NdotL = max(1.0 + dot(normal.xy, L), 0)`) for correct visual results with 2D light directions.
88
- `LightDraw.litAnimatedSprite` helper for animated sprites with automatic flip handling.
99
- `SpriteState` promoted to top-level type with builder DSL (`create`, `withNormalMap`, `withLayer`, etc.).
10+
- `Animation3DClips` type for loading and querying 3D skeletal animation clips from `ModelAnimation[]`. Supports name-based and index-based lookup.
11+
- `Animation3DState` struct for per-entity 3D animation playback with `play`, `playByIndex`, `playIfNot`, `blendTo`, `blendToByIndex`, `update`, and `applyToModel`. Uses `UpdateModelAnimation` for single-clip playback and `UpdateModelAnimationEx` for crossfade blending.
12+
- `AnimatedMesh` type for shared GPU skinning data — extracts mesh and inverse bind pose from a `Model`. `computeBoneMatrices` performs pure keyframe interpolation (lerp/slerp) and inverse-bind-pose multiplication without mutating the model.
13+
- GPU skinning vertex shaders (`forwardVertexSkinned`, `depthShadowVertexSkinned`) using raylib's `vertexBoneIndices`/`vertexBoneWeights` attributes and `boneMatrices[128]` uniform.
14+
- `ForwardPbrPipeline.DrawSkinnedMesh` now uploads bone matrices and uses the GPU skinning shader (was a CPU skinning placeholder).
15+
- `IAssets.ModelAnimations: path: string -> ModelAnimation[]` for loading skeletal animations from glb/gltf/iqm files.
16+
- 42 unit tests for `Animation3DClips` and `Animation3DState` covering creation, playback, update, blending, and edge cases.
17+
- ThreeDSample: Player character (`character-oobi.glb`) now animates with idle/walk/jump animations and 0.15s crossfade transitions.
1018

1119
### Changed
1220

samples/ThreeDSample/Program.fs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ open Mibo.Elmish
77
open Mibo.Elmish.Graphics2D
88
open Mibo.Elmish.Graphics3D
99
open Mibo.Elmish.Graphics3D.Pipelines
10+
open Mibo.Animation
1011
open Mibo.Input
1112
open ThreeDSample.Constants
1213
open ThreeDSample.Types
@@ -57,6 +58,14 @@ let init(ctx: GameContext) =
5758
let assets = GameContext.getService<IAssets> ctx
5859
model.JumpSound <- assets.Sound("assets/sfx_jump.ogg")
5960

61+
let playerModel = assets.Model(KenneyModels.characterOobi)
62+
model.PlayerModel <- playerModel
63+
64+
let animClips = assets.ModelAnimations(KenneyModels.characterOobi)
65+
let clips = Animation3DClips.fromModelAnimations animClips
66+
model.PlayerAnimClips <- clips
67+
model.PlayerAnim <- Animation3DState.create playerModel clips "idle" 60.0f
68+
6069
let target = spawnPosition + Vector3(0.0f, playerHeight * 0.5f, 0.0f)
6170
model.CameraTarget <- target
6271

samples/ThreeDSample/Systems.fs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ open System.Numerics
66
open Raylib_cs
77
open Mibo.Elmish
88
open Mibo.Elmish.Graphics3D
9+
open Mibo.Animation
910
open Mibo.Input
1011
open Mibo.Layout3D
1112
open ThreeDSample.Constants
@@ -275,6 +276,32 @@ let inline diagnosticsSystem
275276
diag.ParticleCount <- model.Particles.Count
276277
struct (model, Cmd.none)
277278

279+
let inline animationSystem
280+
(dt: float32)
281+
(model: GameModel)
282+
: struct (GameModel * Cmd<Msg>) =
283+
if model.PlayerAnim.Clips.Clips.Length = 0 then
284+
struct (model, Cmd.none)
285+
else
286+
let isMoving =
287+
model.Actions.Held.Contains(GameAction.MoveForward)
288+
|| model.Actions.Held.Contains(GameAction.MoveBackward)
289+
|| model.Actions.Held.Contains(GameAction.MoveLeft)
290+
|| model.Actions.Held.Contains(GameAction.MoveRight)
291+
292+
let targetAnim =
293+
if not model.IsGrounded then "jump"
294+
elif isMoving then "walk"
295+
else "idle"
296+
297+
let anim =
298+
model.PlayerAnim
299+
|> Animation3DState.blendTo targetAnim 0.15f
300+
|> Animation3DState.update dt
301+
302+
model.PlayerAnim <- anim
303+
struct (model, Cmd.none)
304+
278305
let inline lightingSystem
279306
(dt: float32)
280307
(model: GameModel)
@@ -370,6 +397,7 @@ let update (msg: Msg) (model: GameModel) : struct (GameModel * Cmd<Msg>) =
370397
System.start model
371398
|> System.pipeMutable(inputSystem dt)
372399
|> System.pipeMutable(physicsSystem dt)
400+
|> System.pipeMutable(animationSystem dt)
373401
|> System.pipeMutable(chunkSystem dt)
374402
|> System.pipeMutable(particleSystem dt)
375403
|> System.pipeMutable(minimapSystem dt)

samples/ThreeDSample/Types.fs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ open System.Numerics
77
open Raylib_cs
88
open Mibo.Elmish
99
open Mibo.Elmish.Graphics3D
10+
open Mibo.Animation
1011
open Mibo.Input
1112

1213
[<Struct>]
@@ -201,6 +202,11 @@ type GameModel() =
201202
member val Actions: ActionState<GameAction> = ActionState.empty with get, set
202203
member val InputMap: InputMap<GameAction> = InputMap.empty with get, set
203204
member val PlayerModel = Unchecked.defaultof<Model> with get, set
205+
206+
member val PlayerAnimClips =
207+
Unchecked.defaultof<Animation3DClips> with get, set
208+
209+
member val PlayerAnim = Unchecked.defaultof<Animation3DState> with get, set
204210
member val ModelCache = Dictionary<string, Model>() with get, set
205211

206212
member val Chunks =

samples/ThreeDSample/View.fs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ open FSharp.NativeInterop
99
open Raylib_cs
1010
open Mibo.Elmish
1111
open Mibo.Elmish.Graphics3D
12+
open Mibo.Animation
1213
open Mibo.Layout3D
1314
open ThreeDSample.Constants
1415
open ThreeDSample.Types
@@ -143,8 +144,7 @@ let view (ctx: GameContext) (model: GameModel) (buffer: RenderBuffer3D) =
143144
chunk.Grid
144145
buffer
145146

146-
let playerModel =
147-
loadOrGetModel model.ModelCache KenneyModels.characterOobi ctx
147+
let playerModel = model.PlayerAnim.Model
148148

149149
let playerTransform =
150150
let rot = Raymath.MatrixRotateY(model.PlayerFacing)
@@ -158,6 +158,8 @@ let view (ctx: GameContext) (model: GameModel) (buffer: RenderBuffer3D) =
158158

159159
Raymath.MatrixMultiply(rot, trans)
160160

161+
Animation3DState.applyToModel model.PlayerAnim
162+
161163
let p = model.Particles
162164

163165
for i = 0 to p.Count - 1 do

0 commit comments

Comments
 (0)