Entity models and animation
Status: Implemented (API 1.9). glTF 2.0 models for entity types (mod types and vanilla types), skinned meshes with up to 4 bone weights per vertex, morph targets, PBR-lite materials, vanilla block and sky light, layered animation with cross-fades, additive layers and bone masks, morph weights, event markers, root motion, bone look-at and bone transforms, all synced from the server to Celestial clients. Models for block entities and items, normal and roughness maps inside shader packs and level-of-detail meshes are Planned.
This guide takes a model from Blender into the game and makes it move: a type with a model, clips on layers, events that deal damage on the right frame, a head that follows the player, and a particle at the tip of a tail. Everything works the same from Lua, C and C++.
Reference: Rendering (blocks, entities, items), Entities, function tables model, render, entity. Custom entity types: Guide: Custom Entities.
1. How it works
- The server decides what plays: a mod calls
e:anim_play("walk")on the server, and every Celestial client with CLight that sees the entity plays the same clip at the same time (players who arrive later get the current state too). The server also loads the model’s skeleton and clips (no meshes or textures), so clip events, root motion and bone positions work there, on a dedicated server too. - Clients draw the model instead of the entity’s vanilla model. Vanilla clients and Celestial clients without your mod see the vanilla fallback of a custom type (or the vanilla model of a vanilla type) and never get a packet they don’t understand.
- The model is drawn by the game’s own entity renderer: light from the blocks and the sky, the red hurt flash, name tags, glowing outlines, fire, the round shadow and shader packs work like on vanilla entities. Skinning and morphing run on the GPU in one compute pass for all models of a frame; without mod models nothing extra runs.
2. Make the model
Export glTF 2.0 (.glb, or .gltf with its files next to it) and put it in your mod’s assets: assets/<mod id>/models/entity/creature.glb is "<mod id>:models/entity/creature.glb".
| Topic | What the loader expects |
|---|---|
| Units and axes | 1 glTF metre = 1 block, +Y up. The model faces +Z (glTF’s forward). A model that faces -Z gets yaw_offset = 180. |
| Meshes | Triangles (strips and fans are converted). Several meshes and materials are fine. Up to 2 million vertices. |
| Skins | One skin per mesh, JOINTS_0 / WEIGHTS_0: 4 bones per vertex. Up to 1024 bones. Meshes on plain nodes (no skin) follow their node. |
| Morph targets | POSITION and NORMAL deltas. Name them: glTF has no names for targets, so Blender’s exporter writes extras.targetNames (shape key names), and so should other tools. Unnamed targets are morph0, morph1, … |
| Materials | Base colour (factor and texture), normal map, metallic-roughness texture (roughness in G, metalness in B), alphaMode (OPAQUE, MASK, BLEND), doubleSided. Textures use TEXCOORD_0. |
| Textures | PNG only (embedded or next to the .gltf). Linear filtering with mipmaps; the mipmaps of the base colour are averaged in linear light, so thin bright details don’t darken in the distance. CL_MODEL_NEAREST (Lua nearest = true) keeps pixel art crisp. |
| Animations | Translation, rotation, scale and morph weights; STEP, LINEAR and CUBICSPLINE. Every glTF animation is a clip, named after the animation. |
| Not supported | Draco and meshopt compression, JPEG / KTX2 / WebP images, cameras, lights. The loader refuses such files with a message that says why. |
In Blender: File → Export → glTF 2.0, format glTF Binary, include Shape Keys and Animation (one action per clip, Group by NLA Track or Actions), no compression.
Clip settings and events. A JSON file next to the model, named <model>_clips.json, adds what glTF can’t say:
{
"clips": {
"walk": { "seconds": 1.0, "loop": true, "events": { "step_left": 0.25, "step_right": 0.75 } },
"attack": { "seconds": 0.6, "loop": false, "events": { "strike": 0.3, "sound:my_mod:creature.bite": 0.25 } }
}
}
seconds overrides the clip length, loop is the clip’s default playback, events are markers by name and time (an array [{ "time": 0.3, "name": "strike" }] works too). Markers can also live in the glTF file (animation extras: { "loop": false, "events": [...] }) or come from code (model:add_event).
Loading is sandboxed: a model only reads files inside its mod’s folder (no .., no absolute paths), every count, offset and index is checked against the file, and broken files are refused instead of crashing the game.
3. Give a type its model
Call cl.render.entity_model on both sides, right after registering the type:
cl.on("lifecycle.register", function()
cl.entities.register{ id = "creature", kind = "mob", fallback = "minecraft:zombie", width = 0.8, height = 1.2 }
local model = cl.render.entity_model("my_mod:creature", "my_mod:models/entity/creature.glb", {
scale = 1.0, -- model units to blocks
yaw_offset = 0, -- 180 for a model that faces -Z
default_clip = "idle" -- every creature loops "idle" until you play something else
})
print(model:get_info().bones, model:clip_length("walk"))
end)
| Option | Means |
|---|---|
scale |
Model units to blocks (0 < scale ≤ 64). |
yaw_offset |
Degrees the model is turned so its front faces where the entity looks. |
offset |
A shift in blocks after the scale, cl.vec(0, 0.1, 0). |
shadow_radius |
The round shadow under the entity (default: the fallback’s). |
default_clip |
A clip every entity of the type loops on layer 0 until you play something. Each entity starts at a different point of the clip, so a herd doesn’t move in step. |
no_shadow, nearest, double_sided, no_death_tilt |
Flags (C: CL_MODEL_NO_SHADOW, CL_MODEL_NEAREST, CL_MODEL_DOUBLE_SIDED, CL_MODEL_NO_DEATH_TILT). Dying entities fall over like vanilla mobs unless no_death_tilt. |
Vanilla types work too, for client mods that restyle a vanilla mob: cl.render.entity_model("minecraft:cow", "my_mod:models/entity/fluffy_cow.glb"). The entity keeps its vanilla behaviour; only its look changes on the clients with your mod. Players keep their skins.
cl.model.load(path) loads a model without binding it (to read its clips and bones).
4. Play clips
e:anim_play("walk") -- layer 0, the clip's own loop setting, 0.2 s fade
e:anim_play("run", { fade = 0.3, speed = 1.4 }) -- cross-fades from walk
e:anim_play("attack", { layer = 2, loop = false }) -- a one-shot on top, then it fades away
e:anim_stop(2, 0.15) -- fade a layer out (no layer: every layer)
| Option | Default | Means |
|---|---|---|
layer |
0 | 0 to 7. Higher layers are applied on top of lower ones. |
loop |
the clip’s | true / false. |
fade |
0.2 | Seconds of cross-fade from the clip that played on the layer (or of fade-in on an empty layer). |
speed |
1 | 0 freezes, negative plays backwards. |
weight |
1 | How strongly the layer applies, 0 to 1. |
additive |
false | Adds the clip’s difference to the rest pose instead of replacing the pose (section 5). |
mask |
all bones | The bones the layer moves, each with its children: { "spine" }. "!tail" leaves a bone out. |
start |
0 | Seconds into the clip. |
root_motion |
false | The root bone’s walk moves the entity (section 8). |
hold |
false | A one-shot keeps its last frame instead of fading out at the end. |
e:anim_clip(layer) is the clip a layer plays ("" when none), e:anim_state(layer) gives playing, time, length, weight, speed, loop and additive.
5. Layers, masks and additive clips
Layers let one creature do several things at once. A typical stack:
e:anim_play("walk") -- layer 0: the body
e:anim_play("look_around", { layer = 1, mask = { "neck" } }) -- layer 1: only the neck and head
e:anim_play("breathe", { layer = 2, additive = true, weight = 0.6 }) -- layer 2: on top of everything
e:anim_layer(1, { weight = 0, fade = 0.5 }) -- later: fade the head layer out
An additive clip is authored as a change from the rest pose (a breathing chest, a flinch, a limp). It is added on top of whatever the layers below did, so the same breathing works while walking, running or standing. e:anim_layer(layer, { weight, fade, additive, mask, speed }) changes a layer without restarting its clip.
6. Morph targets
Clips can animate morph weights (glTF weights tracks). From code, e:set_morph("inhale", 0.7) sets a weight over what the clips say; e:set_morph("inhale", -1) hands it back to the clips. Weights above 1 exaggerate.
7. Events on the right frame
cl.on("entity.anim_event", function(event)
if event.client then
if event.name == "step_left" or event.name == "step_right" then
cl.sound.play_local(event.entity:position(), "my_mod:creature.step", "hostile", 0.6, 1.0, false)
end
elseif event.name == "strike" then
-- the server deals the damage when the claw actually lands
local target = cl.entity.server_players()[1]
if target then target:set_health(target.health - 4) end
end
end, { filter = "my_mod:creature" })
The event fires on the server (client false) and on every Celestial client that shows the entity (client true): sounds and particles belong on the client side, damage on the server side. The payload has entity, level, type, name, clip, layer, time and client. A looping clip fires its markers every cycle; a marker is never fired twice for one pass. The default_clip fires no events until your code plays something on the entity.
8. Root motion
With root_motion = true the horizontal movement of the root bone (the top bone of the skeleton) stops moving the model and moves the entity instead: the server turns it into the entity’s velocity every tick, so collisions, slopes and water behave like any movement. The vertical bob stays in the animation. e:anim_root_motion() is the last tick’s movement in blocks, for mods that move the entity themselves.
e:anim_play("lunge", { root_motion = true, loop = false })
9. Looking at things
e:anim_look_at({ "neck", "head" }, player, 180, { yaw = 70, pitch = 40 }) -- follow a player's eyes
e:anim_look_at({ "head" }, cl.vec(10, 70, 10)) -- look at a position
e:anim_look_stop() -- turn back
The chain is listed parent first; the turn is shared over it, so a neck and a head turn together. speed is in degrees per second, the limits are the total turn left and right (yaw) and up and down (pitch). In C and C++ it is one call with cl_look_at_opts (bones, target, target_entity, speed, max_yaw, max_pitch, weight, enabled).
10. Bones in the world
e:bone_transform("tail_tip") gives where a bone is right now: pos (world position), forward, up and right (unit vectors) and scale. Use it to spawn particles at a mouth, to place a hit box on a swinging arm, or to attach things:
cl.on("entity.tick", function(event)
if event.client then return end
local jaw = event.entity:bone_transform("jaw")
-- a bite reaches 0.6 blocks past the jaw bone
local reach = jaw.pos + jaw.forward * 0.6
for _, p in ipairs(cl.entity.server_players()) do
local d = p:eye_position() - reach
if d.x * d.x + d.y * d.y + d.z * d.z < 0.5 then cl.log("bitten: %s", p.name) end
end
end, { filter = "my_mod:creature" })
On the server the pose is the current tick’s; on a client it is the pose at the current time. Bone positions on the server follow the entity’s yaw; on a client, the rendered body yaw.
11. C and C++
static void on_register(const cl_ev_lifecycle* ev, void* user) {
cl_model_opts mo = CL_MODEL_OPTS_INIT;
mo.default_clip = CL_STR("idle");
cl_model model;
cl_render_entity_model(CL_STR("my_mod:creature"), CL_STR("my_mod:models/entity/creature.glb"), &mo, &model);
}
static void start_walking(cl_entity e) {
cl_anim_play_opts po = CL_ANIM_PLAY_OPTS_INIT;
po.root_motion = true;
cl_entity_anim_play(e, CL_STR("walk"), &po);
}
clight::Entity e = ...;
cl_anim_play_opts po = CL_ANIM_PLAY_OPTS_INIT;
po.layer = 1;
po.additive = true;
po.mask = CL_STR("neck");
if (!e.anim_play("breathe", &po)) clight::log::warn("no breathe clip");
auto tip = e.bone_transform("tail_tip"); // Expected<cl_bone_transform>
12. Shaders, light and speed
- Light: the vanilla entity light (two directional lights, ambient) from the per-pixel normal of the normal map, the block and sky lightmap, fog and the hurt overlay. Roughness adds a soft highlight that the lightmap dims, so caves stay dark. Models without normal or roughness maps use exactly the vanilla entity shading.
- Shader packs draw models through the pack’s entity programs (
gbuffers_entities) and its shadow pass; since API 1.15 normal and roughness maps reach them as LabPBRnormals/specular, and the material’s alpha mode is kept. - Speed: one compute dispatch skins and morphs every visible model of the frame on the GPU (the CPU only samples the clips: microseconds per entity); the models are then ordinary instanced entity draws. Nothing runs and nothing is allocated per frame without mod models. Keep game models lean (a few thousand triangles, under ~100 bones): 50 such creatures cost well under a millisecond.
13. Items, blocks and LOD
The same models also work for items and blocks / block entities, with level-of-detail meshes (MSFT_lod or _lodN names) and the file’s alpha mode (API 1.15): see Item and Block Models. Material variants and per-entity parameters are in Post-processing, Fog, Sky and Camera.