Import rigged 3D models with skeletal animations from Blender into Rive using Luau scripts.
https://x.com/fredberria/status/2016568637310513207?s=20
- Overview
- Quick Start
- Requirements
- Tutorial: Step by Step
- File Structure
- Data Format
- Configuration Reference
- Property Group Reference
- Troubleshooting
- Advanced Topics
This pipeline allows you to:
- Export rigged 3D models from Blender with skeletal animations
- Render them in real-time in Rive using Luau scripts
- Control animations via Rive Inputs (play, pause, switch animations)
- Customize colors and rendering parameters
Key Features:
- Automatic mesh normalization (~200 units)
- Automatic fragmentation for large meshes (~2950 faces per part with flat arrays)
- Multiple animation support with runtime switching
- Full skeletal animation with vertex skinning
- Customizable material colors via Property Group
- Optimized flat array format for vertices and faces (~60% file size reduction)
- Factorized skinning (patterns + index, ~40% smaller)
- Blender MCP integration for AI-assisted conversion
1. Install Blender MCP in your Claude Code environment
2. Open your .blend file in Blender
3. Use Claude Code with the prompt from prompt.md
4. AI extracts geometry, skeleton, and animations automatically
5. Copy generated .luau files to Rive
# 1. In Blender: Configure and run the export script
# Edit MODEL_NAME in blender_to_rive.py, then run with Alt+P
# 2. Optimize data files
python3 convert_flat.py # Flat arrays (~60% smaller)
python3 convert_shared_times.py # Shared time arrays (~10% smaller)
# 3. Copy generated files to your Rive project
# 4. Create your main Node Script based on the example template
# 5. In Rive: Set animationName Input to play animations- Claude Code with Blender MCP installed
- Blender 3.0+ running with MCP connection
- Model with Armature, vertex weights, and Actions
- Blender 3.0+ (tested on 4.x)
- Python 3 (for
convert_flat.pyandconvert_shared_times.pypost-processing) - Model must have:
- Armature (skeleton)
- Vertex weights painted
- At least one Action (animation)
- Rive Editor with Scripting enabled
- Luau runtime support
The Blender MCP allows AI to directly interact with your Blender scene, extracting all necessary data automatically.
- Install Blender MCP in your environment
- Open your
.blendfile in Blender - Ensure MCP connection is active
Copy the prompt from prompt.md and provide your model details:
I want to import a rigged 3D model with skeletal animation from Blender to Rive.
Model details:
- Model name: MyCharacter
- Blender file: character.blend
- Expected animations: Idle, Walk, Run
- Material categories: Skin, Clothing, Hair
Please extract the geometry, skeleton, and animations using Blender MCP.
Claude Code will:
- Analyze your scene with
mcp__blender__get_scene_info - Extract geometry with
mcp__blender__execute_blender_code - Extract skeleton hierarchy and IBMs
- Extract skinning weights
- Extract all animation clips
- Generate ready-to-use
.luaufiles with flat array format
Copy the generated files:
ModelPartAData.luau,ModelPartBData.luau, etc.- Create/adapt the main Node Script
- Copy utility scripts if not already present
Your model needs:
- Armature (skeleton with bones)
- Mesh parented to the armature
- Vertex Groups matching bone names
- Weight painting for smooth deformation
CRITICAL: Verify Armature Scale
1. Select the Armature
2. Press Ctrl+A -> Apply All Transforms
3. Verify in Properties panel: Scale = 1.000, 1.000, 1.000
- Open the Action Editor (Shift+F12)
- Create a New Action for each animation
- Name your actions clearly (e.g., "Swim", "Idle", "Walk")
- Keyframe your bone poses
Note: Animation names will be exported as "Armature|ActionName"
Open blender_to_rive.py and edit:
MODEL_NAME = "YourModel" # Output file prefix
TARGET_SIZE = 200.0 # Normalize to ~200 units
OUTPUT_DIR = "//" # Output directory
MAX_FACES_PER_PART = 2950 # Auto-fragment threshold (flat arrays)- Go to Scripting workspace in Blender
- Click Open and select
blender_to_rive.py - Select your Armature (or Mesh)
- Press Alt+P to run
python3 convert_flat.py # Vertices/faces -> flat arrays (~60% smaller)
python3 convert_shared_times.py # Factorize duplicate times in animations (~10% smaller)convert_flat.py converts vertices/faces from table-of-tables to flat stride-3/stride-4 format. convert_shared_times.py deduplicates identical times arrays shared across channels within each clip.
Copy these files to your Rive project:
YourModelPartAData.luau,YourModelPartBData.luau, etc.Mesh3DUtil.luauSkeletalAnimUtil.luau
project/
├── blender_to_rive.py # Standalone Blender export script
├── convert_flat.py # Convert Part data files to flat arrays (post-export)
├── convert_shared_times.py # Factorize duplicate times in animation files
├── prompt.md # AI prompt for Blender MCP workflow
├── Mesh3DUtil.luau # 3D math (matrices, quaternions, projection)
├── SkeletalAnimUtil.luau # Skeleton building, animation, skinning
├── Model.luau # Main Node Script (rendering + Property Group)
├── ModelPartAData.luau # Generated data: vertices, faces, skeleton, skinning
├── ModelPartBData.luau # Generated data: vertices, faces, skinning
├── ModelAnim1Data.luau # Generated data: animation clips
├── CLAUDE.md # Full technical documentation
└── README.md # This file
Vertices and faces use flat number arrays for ~60% smaller file sizes:
-- Vertices: flat array, stride 3 (x, y, z per vertex)
ModelData.vertices = {
10.825, -18.875, -1.141,
11.296, -19.324, 1.685,
-- ...
}
-- Access vertex i: base = (i - 1) * 3
-- vx = vertices[base + 1], vy = vertices[base + 2], vz = vertices[base + 3]
-- Faces: flat array, stride 4 (v1, v2, v3, category per face)
ModelData.faces = {
4150, 4151, 4152, 1,
4151, 4150, 4153, 1,
-- ...
}
-- Access face fi (0-based): fBase = fi * 4
-- vi1 = faces[fBase + 1], category = faces[fBase + 4]ModelData.skeleton = {
jointCount = 13,
jointParents = { nil, 1, 2, 2, 1, ... },
inverseBindMatrices = { { 16 floats }, ... },
restPose = {
{ translation = {x,y,z}, rotation = {w,x,y,z}, scale = {1,1,1} },
...
},
}
-- Factorized skinning: patterns + per-vertex index
ModelData.skinningPatterns = {
[0] = { j = {0,0,0,0}, w = {1.0,0,0,0} },
[1] = { j = {1,0,0,0}, w = {1.0,0,0,0} },
...
}
ModelData.skinningIndex = { 1, 1, 1, 6, 6, 2, ... } -- Per vertexModelData.animations = {
["Swim"] = {
name = "Swim",
duration = 4.21,
sharedTimes = { -- Factorized time arrays
{0, 0.033, 0.067, ...}, -- Pattern 1
},
channels = {
{ jointIndex = 1, path = "rotation", timeRef = 1, values = {...} },
{ jointIndex = 1, path = "translation", timeRef = 1, values = {...} },
...
},
},
}sharedTimes: unique time arrays shared across channels (generated byconvert_shared_times.py)timeRef: 1-based index intosharedTimes(replaces inlinetimesarrays)
| Setting | Default | Description |
|---|---|---|
MODEL_NAME |
"Model" | Output file prefix |
TARGET_SIZE |
200.0 | Normalize mesh to this size |
OUTPUT_DIR |
"//" | Output directory (// = relative to .blend) |
MAX_FACES_PER_PART |
2950 | Fragment threshold for faces (flat arrays) |
| Setting | Default | Description |
|---|---|---|
FILES |
(list) | Part data files to convert |
BASE_DIR |
script dir | Directory containing .luau files |
Edit the FILES list at the top of convert_flat.py to match your model's Part data files.
| Setting | Default | Description |
|---|---|---|
FILES |
(list) | Animation data files to process |
BASE_DIR |
script dir | Directory containing .luau files |
Edit the FILES list at the top of convert_shared_times.py to match your model's animation data files. The script is idempotent — safe to re-run.
| Input | Type | Default | Description |
|---|---|---|---|
rotationSpeed |
Number | 0 | Auto-rotation speed (0 = disabled) |
baseRotationX |
Number | 0 | Pitch in degrees |
baseRotationY |
Number | 0 | Yaw in degrees |
baseRotationZ |
Number | 0 | Roll in degrees |
joystickPitch |
Number | 0 | Interactive pitch offset |
joystickYaw |
Number | 0 | Interactive yaw offset |
joystickRoll |
Number | 0 | Interactive roll offset |
| Input | Type | Default | Description |
|---|---|---|---|
scale |
Number | 1 | Scale multiplier |
fov |
Number | 800 | Field of view (perspective mode) |
cameraDistance |
Number | 400 | Camera distance (perspective mode) |
usePerspective |
Boolean | false | false = orthographic, true = perspective |
| Input | Type | Default | Description |
|---|---|---|---|
brightness |
Number | 50 | Brightness (0-100, divided by 100) |
faceExpansion |
Number | 0.05 | Expand faces to eliminate gaps |
backfaceCulling |
Boolean | true | Hide back-facing faces |
wireframe |
Boolean | false | Wireframe mode |
| Input | Type | Default | Description |
|---|---|---|---|
animationEnabled |
Boolean | true | Enable/disable animation |
animationName |
String | "" | Animation to play (empty = first available) |
animationSpeed |
Number | 1 | Playback speed multiplier |
| Problem | Cause | Solution |
|---|---|---|
| Model twisted when animated | Coordinate space mismatch | Re-export with posed rest reference |
| Model explodes during animation | Armature scale != 1 | Apply transforms in Blender (Ctrl+A) |
| Body parts scattered | Different reference poses | Use posed rest for ALL exports |
| Some faces missing | Wrong winding order | Check normals in Blender |
| Z-fighting (flickering) | Faces at same depth | Use anti-flickering (depthBias + Z_EPSILON) |
| Problem | Cause | Solution |
|---|---|---|
| Animation doesn't play | Wrong animation name | Check console for available names |
| Animation plays wrong | Multiple actions exported | Set correct animationName |
| Arms in T-pose | matrix_basis reset |
Never reset matrix_basis |
| Body frozen in partial anim | Only some bones keyed | Use animation composition with Idle |
| Animation too fast/slow | Wrong speed | Adjust animationSpeed Input |
| Problem | Cause | Solution |
|---|---|---|
| "Code too complex to typecheck" | Too many vertices per part | Reduce MAX_FACES_PER_PART |
| "Path was modified between draws" | path:reset() in draw() |
Build paths in advance() only |
| Type errors | Untyped data access | Cast through :: any then to :: { number } |
Runtime error on table.create() |
Roblox-only function | Use {} instead (standard Luau) |
nil crash on self.field |
Field missing from constructor | Add field to return function() with initial value |
| Problem | Cause | Solution |
|---|---|---|
| No output files | Script error | Check Blender console for errors |
| Missing animations | Actions not linked | Ensure actions are in Action Editor |
| Wrong vertex count | Modifiers not applied | Apply modifiers before export |
| File still large | Old table-of-tables format | Run convert_flat.py |
With Blender MCP, you can also:
- Modify materials and re-export
- Adjust animations directly
- Create new animation clips
- Extract texture colors from screenshots
You can have multiple 3D models in the same Rive file:
- Export each model separately with different
MODEL_NAME - Create separate Node Scripts for each
- They share the same utility scripts (
Mesh3DUtil.luau,SkeletalAnimUtil.luau)
For smooth transitions between animations:
-- In advance(), blend between two clips
local blendFactor = 0.5 -- 0 = clipA, 1 = clipB
SkelAnim.blendAnimations(skeleton, clipA, clipB, timeA, timeB, blendFactor)For better performance:
- Reduce polygon count in Blender (decimate modifier)
- Use flat arrays (
convert_flat.py) for ~60% smaller data files - Factorize animation times (
convert_shared_times.py) for ~10% smaller animation files - Use orthographic projection (
usePerspective = false) - Keep
brightnesscalculation simple
- Single-Call Rule: ALL static data (skeleton IBMs, normalization bounds, vertices) MUST come from the SAME
execute_blender_codecall. The evaluated mesh is non-deterministic across separate calls (depsgraph state pollution). - Coordinate Space Consistency: ALL data (vertices, IBMs, rest pose, animations) must use the SAME normalized space ("posed rest" via
pose_bone.matrix+ evaluated mesh) - Scale = 1: Always force scale to 1 on bone matrices
- Quaternion Format: Blender exports WXYZ, SkeletalAnimUtil converts to XYZW automatically
- Animation Format: Store FINAL local transforms (ABSOLUTE), not deltas. NEVER reset
matrix_basis. - Atlas UV Sampling: Use QUANT_STEP=0.005 (not 0.02) to preserve distinct color zones
- Flat Arrays: Vertices stride 3, faces stride 4 for ~60% file size reduction
- Rive Restrictions: Type annotations on
table.sort, nopath:reset()indraw(), NOtable.create()(Roblox-only), constructor must mirror ALL type fields
MIT License - Feel free to use in personal and commercial projects.
- Pipeline developed by Fred Berria & Claude
- Inspired by Luigi Rosso's approach (Rive coFounder)
- LERP documentation: https://forge.mograph.life/apps/lerp/