Veduta

Model format — model/1

A model is a mesh described as a list of parts: primitives (boxes, cylinders, spheres, planes, extrusions, lathed profiles), free polygons (mesh) and mirror copies. The compiler turns the parts into one triangle mesh with normals, UVs, one material slot per part and a pivot. Parts are joined by concatenation only (no boolean operations): overlapping parts simply overlap. A model can have a skeleton: bones that parts follow, moved by animations (see Skeleton and Animations). It can also import a glTF file exported by Blender: its meshes, armature and actions (see Import).

File: assets/models/<name>.vmodel. The model name is the file name without .vmodel; it must be 1–64 characters of a-z, 0-9, _, -, starting with a letter or digit. A folder under assets/models/ works too (assets/models/enemies/bat.vmodel): the name is still the file name, so it must be unique across the folders.

Units and axes: lengths are meters, angles are degrees. Coordinates are right-handed, +Y up, +X right, +Z towards the viewer of a camera looking down −Z.

Minimal example

{
  "veduta": "model/1",
  "parts": [
    { "shape": "box", "size": [1, 1, 1], "position": [0, 0.5, 0], "material": "crate_wood" }
  ]
}

Top-level fields

FieldTypeDefaultMeaning
vedutastringrequiredMust be "model/1".
namestringfile nameOptional. When present it must equal the file name's model name.
unitsstring"m"Only "m" (meters) exists.
pivotstring"origin"origin, center or bottom-center (see Pivot).
smooth_angle_degnumber30In [0, 180]. Faces meeting at an angle ≤ this are shaded smoothly (see Normals).
symmetrystring"""" (none), x, y or z: asks inspect to check that the model is mirror-symmetric across the plane perpendicular to that axis (issue MESH_ASYMMETRIC). It does not change the geometry.
triangle_budgetinteger20000In [1, 1000000]. inspect reports MESH_TRIANGLE_BUDGET above it. The compiler never refuses a model for exceeding it.
lodarray[]Up to 4 levels of detail, nearest first (see Levels of detail).
draw_distancenumbernoneIn (0, 100000] meters, farther than the last lod level: beyond it the model is not drawn at all.
partsarrayrequiredAt least one part object (see below), unless the model has import.
bonesarray[]The skeleton: up to 128 bones, parents before children (see Skeleton).
animationsobject{}Up to 64 animations of the bones by name (see Animations).
importobjectnoneA glTF file whose meshes, armature and animations the model adds (see Import). With it, parts may be left out.

Parts

Every part is an object with a shape field. Each shape accepts only its own fields plus the common fields; **any other field is an error, even when it is null, 0 or false**. Part indices (used by mirror and in error messages and reports) start at 0.

Common fields (every shape except mirror)

FieldTypeDefaultMeaning
position[x, y, z][0, 0, 0]Translation in meters.
rotation_deg[x, y, z][0, 0, 0]Euler angles in degrees, applied Z first, then X, then Y (R = Ry·Rx·Rz), the same convention as scene entities.
scale[x, y, z][1, 1, 1]Per-axis scale; every component must be non-zero. Negative components mirror the part; the compiler keeps its faces pointing outwards.
materialstringnoneMaterial name (assets/materials/<name>.vmat). A part without a material is drawn with the entity's material (or the default white material).
uvstringper shapebox, planar, cylindrical or spherical (see UV mapping). Defaults: box → box, plane → planar, cylinder → cylindrical, sphere → spherical, extrude → box, lathe → cylindrical, mesh → box.
flip_normalsbooleanfalseReverses the winding and the normals so the part faces inwards (for example the inside of a room or a sky dome).
bonestringnoneThe bone of the skeleton the part follows (see Skeleton).

A part is built in its local space (the shape centered at the local origin as described below), then scaled, rotated, and translated: p' = R·(scale ⊙ p) + position.

box

FieldTypeDefaultMeaning
size[x, y, z]requiredFull extents in meters, each > 0.

A box centered at the origin spanning ±size/2. 12 triangles, 24 vertices (4 per face), always sharp edges unless smooth_angle_deg ≥ 90.

cylinder

FieldTypeDefaultMeaning
radiusnumberrequired> 0.
heightnumberrequired> 0.
segmentsinteger163–256 sides around the Y axis.

Axis along Y from y = −height/2 to +height/2, closed by flat caps. The first side edge is at +Z. 4 × segments triangles. At the default 30° smoothing a cylinder with 12 or more segments has a smooth side (adjacent sides meet at 360°/segments); caps stay sharp.

sphere

FieldTypeDefaultMeaning
radiusnumberrequired> 0.
segmentsinteger163–256 divisions around the Y axis.
ringsinteger82–128 latitude bands from pole to pole.

A UV sphere centered at the origin with poles at (0, ±radius, 0). 2 × segments × (rings − 1) triangles (triangle fans at the poles, quads elsewhere). Smooth when 360°/segments and 180°/rings are both ≤ smooth_angle_deg (true for the defaults).

plane

FieldTypeDefaultMeaning
size[x, z]requiredExtents along X and Z, each > 0.

A rectangle in the XZ plane (y = 0) centered at the origin, facing +Y. It is single-sided: with the default back-face culling it is invisible from below. Use a material with "cull": "none" or a second, flipped plane for two sides. 2 triangles.

extrude

FieldTypeDefaultMeaning
profile[[x, y], …]requiredA polygon in the XY plane, 3–1024 points.
depthnumberrequired> 0.

The profile is extruded along Z from z = −depth/2 to z = +depth/2, with a flat cap at each end (the front cap faces +Z) and one rectangular wall per profile edge. Profile coordinates are used as given (not re-centered). Rules for the profile:

Caps are triangulated by ear clipping. 2 × (points − 2) + 2 × points triangles.

lathe

FieldTypeDefaultMeaning
profile[[r, y], …]required2–1024 points; r ≥ 0 is the distance from the Y axis, y the height.
segmentsinteger163–256 divisions around the Y axis.

The profile is revolved around the Y axis (the first division is at +Z). There are no implicit caps: the profile is the whole surface. To make a closed solid, start and end the profile on the axis (r = 0), for example [[0, 0], [0.5, 0], [0.4, 1], [0, 1.2]], or end it on its first point to make a ring ([[1, 0], [1.5, 0], [1.5, 1], [1, 1], [1, 0]]).

mesh

FieldTypeDefaultMeaning
vertices[[x, y, z], …]requiredThe corners, 3–65536 points in the part's local space.
faces[[i, j, k, …], …]required1–65536 polygons, each a list of 3–1024 indices into vertices (from 0).

Free polygons: any shape the primitives cannot make — a low-poly head, a gem, a ramp, a character's torso. Each face lists its corners counter-clockwise seen from the side it faces (outside a closed shape); a vertex can be used by any number of faces. A face with more than three corners may be concave but must be a simple polygon: it is projected on the plane its normal (Newell's area vector) is most perpendicular to and cut by ear clipping into corners − 2 triangles. Corners need not be exactly coplanar, but a face that folds strongly is better split. Consecutive corners of a face must be different points; a face with no area (corners on a line, or a bow tie crossing itself) and a face whose projection crosses itself are errors. Normals are smoothed like every part (smooth_angle_deg): a cube written as six quads keeps sharp edges.

{
  "veduta": "model/1",
  "pivot": "bottom-center",
  "parts": [
    { "shape": "mesh", "material": "stone",
      "vertices": [[-0.5, 0, 0.5], [0.5, 0, 0.5], [0.5, 0, -0.5], [-0.5, 0, -0.5], [0, 0.8, 0]],
      "faces": [[3, 2, 1, 0], [0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]] }
  ]
}

A pyramid: a square base seen from below and four triangles meeting at the top.

mirror

FieldTypeDefaultMeaning
axisstringrequiredx, y or z.
ofintegerrequiredIndex of an earlier part (any shape, including another mirror).
materialstringsource'sOverrides the material of the copy.
bonestringsource'sThe bone the copy follows (a mirrored left arm follows arm_r).

Copies the final geometry of part of (after its own scale, rotation and translation) and reflects it across the model-space plane through the origin perpendicular to axis ("x" reflects across the plane x = 0, before the pivot is applied). The winding is reversed so the copy faces outwards like its source. UVs are copied, so a texture appears mirrored. The copy inherits the source's uv mode, flip_normals (a copy of a flipped part is flipped too) and bone. No other fields are allowed: position, rotation_deg, scale, uv and flip_normals are errors on a mirror. To mirror a part in place, build it on one side of the plane and mirror it: both halves meet exactly.

Levels of detail

Far away a model covers a few pixels, so it can be drawn with fewer triangles, or not at all. Each lod level is an object:

FieldTypeDefaultMeaning
distancenumberrequiredMeters from which the level is drawn, in (0, 100000], each level farther than the one before.
modelstringnoneAnother model drawn at this level (its base mesh, with its own materials). Without it the level is this model rebuilt with fewer segments.

A level without model number k (1 for the first level) rebuilds every cylinder, sphere and lathe with segments halved k times and a sphere's rings halved k times, rounding to the nearest integer and never below 3 segments and 2 rings; boxes, planes and extrusions keep their triangles. The level has the same parts, materials and pivot offset as the model. Example: segments: 16 draws 16, then 8, 4, 3 segments.

The distance a level is chosen by is measured every frame, per entity, from the camera to the nearest point of the entity's drawn bounds, as seen through a 60° lens: a perspective camera's distance is scaled by tan(fov/2) / tan(30°), so zooming in (a smaller fov_deg) keeps detail farther away; an orthographic camera counts every entity at 0.866 × size, the distance at which a 60° lens sees size meters, so zooming out drops detail everywhere at once. draw_distance uses the same measure. A world's ground and flora use the same rules (world topic).

{
  "veduta": "model/1",
  "pivot": "bottom-center",
  "lod": [ { "distance": 15 }, { "distance": 35, "model": "tree_far" } ],
  "draw_distance": 80,
  "parts": [
    { "shape": "cylinder", "radius": 0.15, "height": 1.2, "segments": 8, "position": [0, 0.6, 0], "material": "bark" },
    { "shape": "sphere", "radius": 0.7, "segments": 12, "rings": 6, "position": [0, 1.6, 0], "material": "leaf" }
  ]
}

Beyond 15 m the trunk has 4 segments and the crown 6 segments and 3 rings (40 triangles instead of 152), beyond 35 m it is tree_far, beyond 80 m it is not drawn. inspect model reports the triangles of every level (lod_triangles) and a level that saves nothing (MESH_LOD_NO_GAIN).

Skeleton

A skeleton makes a character (or a door, a crane, a snake) move by parts: each bone is a joint, each part that names a bone moves rigidly with it, and animations turn and move the bones. Parts are built in the rest pose, as always; the bones only say where the joints are.

Each bone of bones is an object:

FieldTypeDefaultMeaning
namestringrequiredUnique within the model (the asset name rules: a-z, 0-9, _, -).
parentstringnoneA bone listed earlier; without one the bone is a root.
position[x, y, z][0, 0, 0]The joint in model space, in the rest pose, like part positions (before the pivot; the pivot moves bones with the mesh).

A bone's frame is its joint with the model's axes in the rest pose. A bone's pose is relative to its rest: a rotation about its joint, an offset of its joint and a scale about it, all in its parent's frame, so a child bone and its parts go along when the parent turns (the hand follows the forearm, which follows the upper arm). Parts without bone never move. The engine draws a part that follows bone b with its entity's matrix times b's skin matrix, so a pose costs no triangle and no vertex upload: the same mesh is drawn part by part, as without a skeleton.

Rules of thumb: put each joint where the limb turns (a shoulder at the top of the arm, a knee between thigh and shin), build limbs hanging in the rest pose, name sides _l and _r and build one side and mirror it with "bone" naming the other side's bone. An entity can hold another at a bone (a sword in a hand, a hat on a head): scene bone (docs scene) and Lua bone (docs lua).

Animations

animations maps a name to an animation: tracks of keys per bone. Entities play them by name like a texture's clips: scene anim, Lua e.anim = "walk" or e:play("walk", 0.2) (with a cross-fade of 0.2 s).

FieldTypeDefaultMeaning
tracksobjectrequiredBone name → list of keys (at least one), by increasing t.
lengthnumberlast key's tSeconds, at least the last key's t.
loopbooleanfalseStarts over at length. Needs a length above 0.
nextstringnoneAnother animation of the model played when this one ends (not with loop). Without it the last pose holds and Lua's anim_done becomes true.

Each key:

FieldTypeDefaultMeaning
tnumberrequiredSeconds from the start, ≥ 0, after the key before.
rotation_deg[x, y, z]not setThe bone's rotation about its joint, Euler degrees as rotation_deg everywhere (R = Ry·Rx·Rz).
position[x, y, z]not setThe joint's offset from its rest place, meters in the parent's frame.
scale[x, y, z]not setScale about the joint; no component 0.
easestringlinearHow the key moves to the next key setting the same channel: linear, step (holds, then jumps) or smooth (slow at both ends).

A key sets at least one of rotation_deg, position and scale; each of these channels is interpolated between the keys that set it (rotations along the shortest arc, so keys closer than 180° apart), independently of the others. A channel no key sets stays at rest, and a bone without a track stays at rest. Before its first key a channel holds that key, after its last it holds the last — except in a looping animation, where it goes from its last key back to its first, reached again at length: a walk cycle with keys at 0 and 0.4 and length 0.8 swings there and back without repeating the first key.

{
  "veduta": "model/1",
  "name": "walker",
  "pivot": "bottom-center",
  "bones": [
    { "name": "hips", "position": [0, 0.9, 0] },
    { "name": "leg_l", "parent": "hips", "position": [0.12, 0.9, 0] },
    { "name": "leg_r", "parent": "hips", "position": [-0.12, 0.9, 0] }
  ],
  "parts": [
    { "shape": "box", "size": [0.4, 0.6, 0.25], "position": [0, 1.2, 0], "material": "shirt", "bone": "hips" },
    { "shape": "box", "size": [0.14, 0.9, 0.14], "position": [0.12, 0.45, 0], "material": "pants", "bone": "leg_l" },
    { "shape": "mirror", "axis": "x", "of": 1, "bone": "leg_r" }
  ],
  "animations": {
    "walk": { "loop": true, "length": 0.8, "tracks": {
      "leg_l": [ { "t": 0, "rotation_deg": [30, 0, 0] }, { "t": 0.4, "rotation_deg": [-30, 0, 0] } ],
      "leg_r": [ { "t": 0, "rotation_deg": [-30, 0, 0] }, { "t": 0.4, "rotation_deg": [30, 0, 0] } ],
      "hips":  [ { "t": 0, "position": [0, 0, 0], "ease": "smooth" }, { "t": 0.2, "position": [0, 0.04, 0], "ease": "smooth" },
                 { "t": 0.4, "position": [0, 0, 0], "ease": "smooth" }, { "t": 0.6, "position": [0, 0.04, 0], "ease": "smooth" } ] } },
    "jump": { "next": "walk", "tracks": {
      "hips": [ { "t": 0, "position": [0, 0, 0] }, { "t": 0.25, "position": [0, 0.5, 0], "ease": "smooth" }, { "t": 0.5, "position": [0, 0, 0] } ] } }
  }
}

The engine counts an animation's time in ticks: the tick an animation starts shows its time 0, each later tick 1 / tick_rate s more. The pose moves the drawn parts and the entity's drawn bounds (culling); its collision bounds (AABB, hitbox, overlaps) stay those of the rest pose, so a swinging arm does not change what the entity touches. render --tick and scenarios see poses as the game shows them; inspect model lists the bones and the animations.

Import

import brings what Blender (or any tool writing glTF 2.0) exports: veduta import FILE (the MCP import tool) copies the file into the assets, writes a material asset per material and this field for you. Exporting again from Blender to the same file updates the model at the next cook: its geometry, bones and animations are read from the file every time.

FieldTypeDefaultMeaning
filestringrequiredA .glb (one file) or .gltf (JSON with its .bin and images beside it) relative to the assets directory, forward slashes, for example "models/src/hero.glb".
rotation_deg[x, y, z][0, 0, 0]Turns everything imported about the origin. [0, 180, 0] makes Blender's front (its −Y, the front view) the model's front (−Z).
scalenumber1Scales everything imported (Blender's units are meters).
materialsobject{}The file's material name → the material asset its faces use. A material not listed is used by its name made valid ("Skin" → skin, "Material.001" → material_001).
looparray of strings[]Imported animations that loop.
nextobject{}Imported animation → the one played when it ends.

What the file becomes:

Import errors name the file: import.file: "models/src/hero.glb": …. inspect model shows the result, the poses sheet its animations.

{
  "veduta": "model/1",
  "pivot": "bottom-center",
  "import": {
    "file": "models/src/knight.glb",
    "rotation_deg": [0, 180, 0],
    "materials": { "Skin": "knight_skin", "Metal": "knight_sword" },
    "loop": ["walk"],
    "next": { "wave": "walk" }
  }
}

Winding and orientation

Every triangle is counter-clockwise seen from the side it faces: its outward normal is cross(b − a, c − a). All closed shapes (box, cylinder, sphere, extrude, closed lathe) are watertight and face outwards, whatever the sign of scale, unless flip_normals is set.

Normals

Normals are computed per part from its final triangles; parts never smooth into each other. Around each vertex position, triangles that share an edge there and whose face normals differ by at most smooth_angle_deg (+0.001° tolerance) form a smoothing group (groups chain through such edges). A corner's normal is the average of its group's face normals, each weighted by the triangle's angle at that corner. So with the default 30°: a 16×8 sphere and a cylinder side with ≥ 12 segments are smooth, box edges and cylinder caps are sharp. smooth_angle_deg: 0 gives flat shading; 180 smooths everything except faces that fold back onto each other. Vertices are shared within a part only when position, normal and UV are all identical; otherwise they are split.

UV mapping

UVs are in meters, computed from the part's geometry after scale but before rotation and translation: 1 UV unit = 1 m, so a tiling texture repeats every meter, stays attached to the part when it moves or turns, and scaling a part covers more texture instead of stretching it. v grows downwards (like image rows), so textures appear upright on vertical faces.

Notation: p = (x, y, z) a corner in that scaled local space; min, max the part's bounding box in the same space; θ = atan2(x, z), the angle around +Y (0 at +Z, growing towards +X, ±180° at −Z); ρ = √(x² + z²), the distance from the Y axis; r = |p|, the distance from the part origin; φ = acos(y / r), the angle from +Y.

**box** — each triangle is projected along the axis of its normal's largest component (ties prefer Y, then Z, then X):

Face normaluv
+Xmax.z − zmax.y − y
−Xz − min.zmax.y − y
+Yx − min.xz − min.z
−Yx − min.xmax.z − z
+Zx − min.xmax.y − y
−Zmax.x − xmax.y − y

Every face's top-left corner, seen from outside, is at (0, 0) and u grows to the right (top faces are seen from above with −Z up, bottom faces from below with +Z up). Example: a box of size [2, 1, 1] has u in [0, 2] and v in [0, 1] on its front (+Z) face, (0, 0) at its top-left corner (−1, 0.5, 0.5).

**planar** — the whole part is projected along the axis of its smallest bounding-box extent (ties prefer Y, then Z, then X) with the +Y, +Z or +X row of the table above. A plane therefore uses u = x − min.x, v = z − min.z.

**cylindrical** — triangles whose normal is mostly vertical (largest component Y, e.g. caps) use the ±Y rows of the box table; all others use u = (θ + 180°)·ρ with θ in radians, v = max.y − y. On a cylinder of radius R, u runs from 0 at the back (−Z) around to 2πR, and is πR at the front (+Z).

**spherical** — u = (θ + 180°)·r, v = φ·r (θ, φ in radians). On a sphere of radius R, v is 0 at the top pole and πR at the bottom pole, and u runs from 0 to 2πR.

For cylindrical and spherical, θ is unwrapped per triangle so no triangle straddles the seam, which lies exactly at −Z; corners on the Y axis (poles, cone tips) take the angle of their triangle's center. A texture repeating every meter only matches across the seam when the circumference is a whole number of meters, so keep the −Z side at the back of the model.

Pivot

After all parts are built the compiler takes the bounding box of every vertex and moves the whole mesh so that the pivot point lands on the origin:

pivotPivot point
origin(0, 0, 0): the mesh is not moved.
centerthe bounding box center.
bottom-center(center x, min y, center z): the model stands on y = 0, centered on the Y axis.

The applied translation (minus the pivot point) is reported as pivot_offset. Mirror planes are evaluated before the pivot is applied.

What the compiler produces

Compilation is deterministic: the same source always produces the same bytes.

Errors

Decoding is strict. Unknown fields (typos), wrong JSON types and duplicate keys are errors. Validation then reports every problem at once, each with the file, line, column and JSON path of the offending value. For example, crate.vmodel containing

{
  "veduta": "model/1",
  "parts": [
    { "shape": "box", "size": [1, 1, 1], "radius": 0.5 },
    { "shape": "cylinder", "radius": 0.2, "height": 1, "segments": 2 },
    { "shape": "mirror", "axis": "x", "of": 7 }
  ]
}

reports

crate.vmodel:4:52: parts[0].radius: not used by shape box
crate.vmodel:5:68: parts[1].segments: 2 out of range [3, 256]
crate.vmodel:6:45: parts[2].of: 7 is not the index of an earlier part (want 0..1)

Checks: required fields present; numbers finite; sizes, radii, heights and depths > 0; counts in range (an explicit 0 is out of range, not "default"); scale components non-zero; enum values from the lists above; material names valid; fields that the shape does not use absent; profiles valid as described per shape; of refers to an earlier part; name, when present, equal to the file name.

Limits

ItemRange
segments (cylinder, sphere, lathe)3–256, default 16
rings (sphere)2–128, default 8
profile points (extrude, lathe)extrude 3–1024, lathe 2–1024
smooth_angle_deg0–180, default 30
triangle_budget1–1000000, default 20000
lod levels0–4; distance and draw_distance in (0, 100000] m
mesh vertices / faces3–65536 / 1–65536, 3–1024 corners per face
bones0–128
animations0–64, 1–1024 keys per track

Full example

The crate of the specification, with a material on the box only:

{
  "veduta": "model/1",
  "name": "crate",
  "units": "m",
  "pivot": "bottom-center",
  "smooth_angle_deg": 30,
  "parts": [
    { "shape": "box", "size": [1, 1, 1], "position": [0, 0.5, 0], "material": "crate_wood" },
    { "shape": "cylinder", "radius": 0.1, "height": 1.2, "segments": 12, "position": [0.6, 0.6, 0] },
    { "shape": "sphere", "radius": 0.3, "segments": 16, "rings": 8 },
    { "shape": "plane", "size": [10, 10] },
    { "shape": "extrude", "profile": [[0,0],[1,0],[1,2],[0,2]], "depth": 0.5 },
    { "shape": "lathe", "profile": [[0,0],[0.5,0],[0.4,1],[0,1.2]], "segments": 16 },
    { "shape": "mirror", "axis": "x", "of": 1 }
  ]
}

A symmetric figure built from one side and mirrored:

{
  "veduta": "model/1",
  "name": "robot",
  "pivot": "bottom-center",
  "symmetry": "x",
  "parts": [
    { "shape": "box", "size": [0.5, 0.6, 0.4], "position": [0, 0.3, 0], "material": "body" },
    { "shape": "cylinder", "radius": 0.07, "height": 0.7, "segments": 12,
      "position": [0.45, 0.55, 0], "rotation_deg": [0, 0, -50], "material": "arm" },
    { "shape": "mirror", "axis": "x", "of": 1 },
    { "shape": "sphere", "radius": 0.12, "segments": 12, "rings": 6,
      "position": [0.72, 0.8, 0.1], "material": "hand" },
    { "shape": "mirror", "axis": "x", "of": 3 },
    { "shape": "lathe", "profile": [[0, 0.6], [0.18, 0.6], [0.16, 0.8], [0, 0.85]], "segments": 16,
      "material": "head" }
  ]
}