glTF models, LOD, instancing, and procedural meshes.
Models & meshes
A model is 3D art brought into the game as an asset. This page explains how to import one, how MeshRenderer draws it (including level-of-detail variants), how to instance many copies, and how to build geometry in code.
What a model is
A model is a bundle of triangles, optional skeleton, and optional animation clips produced by a 3D art tool. DemiEngine reads that bundle as a Model3D asset and a MeshRenderer component draws it at an entity’s transform.
Models exist so characters, props, and stations can be authored once in a DCC tool and reused everywhere: same asset, many placed instances, one import pipeline.
Importing a model
Author renderable models in glTF or GLB. The asset importer also recognizes OBJ, IQM, and M3D, but the current 3D renderer loads Model3D geometry through its glTF/GLB loaders; convert other source formats to glTF/GLB for this workflow. The glTF loaders draw triangle primitives; line and point primitives are skipped.
demi asset import games/skyforge/source/drone.glb \
--project games/skyforge --id asset://models/drone \
--preset animated_character
- The first path is the source file produced by your art tool.
--idgives the model a stable asset URI; the importer writes its*.asset.jsonmanifest under the project'sassets/directory.--preset animated_characterenables animation import. The defaultstatic_proppreset does not import animations.- The importer writes
settings.model_import. Runtime clip names come from animation names in the glTF/GLB; the optionalsettings.animations.clipslist is manifest metadata and does not enable import or rename glTF clips. - Validate the project with
demi validate games/skyforgeto check its references.
An animated asset can also record clip metadata in its manifest:
{
"settings": {
"animations": {
"clips": [
{ "name": "Idle", "skeleton": "Armature" },
{ "name": "Fly", "skeleton": "Armature" }
]
}
}
}
clipsrecords names for manifest validation. The glTF/GLB animation names supply the actual runtime clips; keep metadata names aligned with them.skeletonrecords a skeleton name for validation. Listed clips in one asset must not name different skeletons. Animated LOD variants need matching runtime clip names and durations for the renderer to switch models.
MeshRenderer
{
"MeshRenderer": {
"model": "asset://models/drone",
"medium_lod_model": "asset://models/drone_med",
"medium_lod_distance": 25,
"low_lod_model": "asset://models/drone_low",
"low_lod_distance": 60
}
}
| Field | Meaning |
|---|---|
model | The full-detail Model3D asset URI to draw. |
medium_lod_model | Optional mid-distance model. Used when the camera is far enough that the full mesh is wasted. |
medium_lod_distance | Camera distance in world units where the medium model starts. Zero disables this threshold. |
low_lod_model | Optional far-distance model. |
low_lod_distance | Camera distance in world units where the low model starts. Zero disables this threshold. |
Level of detail (LOD) keeps a deck full of crates cheap: near ones use model, distant ones use the reduced meshes. LOD variants are ordinary authored models — build them in your art tool with the same proportions and give them the same clip names and durations so animation keeps working when the swap happens. Animated LOD stays on the full-detail model during authored layers, tracked transition progress, or procedural bone overrides.
Skeletal playback is controlled by AnimationPlayer3D plus per-entity visual update settings:
{
"AnimationPlayer3D": {
"clip_name": "Fly",
"loop": true,
"visual_update_rate": 30,
"visual_update_distance": 40
}
}
clip_namenames an imported glTF/GLB animation clip.looprepeats the clip.visual_update_rateis how many times per second distant poses refresh. Lower rates save work on background crowds.visual_update_distanceis the distance beyond which poses are held rather than refreshed every frame.
On Vulkan, supported rigs use GPU skinning automatically; unsupported rigs use CPU skinning. The GPU path supports up to 128 referenced skin/node matrices across multiple skins and rigid parts, with up to four influences per vertex. It requires the built-in material and valid authored normals; procedural bone segments, authored layers, and tracked transition progress use the CPU path. Those fallback cases do not imply visual layer or transition pose blending. OpenGL/OpenGLES also use CPU skinning. Set DEMI_GPU_SKINNING=0 before launch to force the CPU path while profiling.
Clip switching and blend rules live in State machines.
MeshInstances3D
A station corridor with two hundred identical pipes can use one entity with MeshRenderer and MeshInstances3D. The latter draws labeled, render-only copies of the entity's shared static mesh.
{
"MeshRenderer": { "model": "asset://models/pipe" },
"MeshInstances3D": {
"transforms": {
"pipe_a": { "position": [0, 0, 0] },
"pipe_b": { "position": [2, 0, 0] },
"pipe_c": { "position": [4, 0, 0], "rotation": [0, 0.4, 0], "scale": [1, 1, 1] }
}
}
}
MeshRenderer.modelsupplies the shared model;MeshInstances3D.transformsmaps nonempty stable labels to local transforms.- Each transform accepts
position, Eulerrotationin radians, andscale. These are composed with the owning entity's transform. Omitted fields use identity values; no scale axis may be zero.
Use it for static repeated props. Copies have no individual entities, colliders, or scripts, and the component cannot share an entity with AnimationPlayer3D. Use separate entities when each copy needs animation or physics.
World text
WorldText3D places a text label in the world — docking bay numbers, cargo tags, callouts over objectives. It behaves like a mesh for placement and depth, and takes its string and style from authored fields.
Procedural meshes
Some geometry is not worth exporting from a tool: debug hulls, generated floors, voxel caves. The demi.mesh.procedural module builds a mesh at runtime and attaches it to an entity.
local Proc = require("demi.mesh.procedural")
local mesh = Proc.create(4)
mesh:add_quad(0, 1, 0,
0, 0, 0, 0, 0, 1, 0, 0, 1, 0,
1, 0, 1, 1, 1, 0, 0, 1, 0, 1)
local applied = Proc.apply(entity_id, mesh)
| Call | Arguments | Returns |
|---|---|---|
create | optional vertex capacity | A new empty mesh builder handle. |
clear | on the builder | Nothing. Empties accumulated geometry. |
reserve | on the builder, vertex count | Nothing. Preallocates space for large builds. |
add_vertex | position, normal, UV components | Nothing. Appends one vertex. |
add_quad | one normal and four positions/UVs | Nothing. Appends a quad face. |
add_voxel_blocks | blocks, occupancy, tile mapping, atlas columns, occupancy stride | Nothing. Appends blocky voxel geometry. |
add_voxel_heightfield | height and block layers, rocky columns, tile mapping, atlas columns, chunk and section dimensions, fill depth | Nothing. Appends a heightfield surface. |
apply | entity id, builder, optional texture/material/render layer | Boolean success. Uploads built geometry for the entity. |
Build, then apply. A cleared builder can be refilled and reapplied without creating a new handle.
Voxel worlds
For sectioned editable block volumes, use demi.voxel.world. Scripts set or erase sections, then call build_section_mesh and Proc.apply for the sections they want to draw. This is an explicit section-meshing API, not automatic world streaming or terrain excavation.
Picking the right tool
- Character or hero prop with animation: glTF
Model3D+MeshRenderer+AnimationPlayer3D. - Hundreds of identical bolts:
MeshInstances3D. - Generated floor or debug shape:
demi.mesh.procedural. - Editable block volume:
demi.voxel.world. - Label in space:
WorldText3D.
Characters that walk the world use CharacterController3D. Cameras and lighting that make models readable are covered in Lights & camera. Breakable props build on the same model setup — see Destruction.