KILN notation reference
Units are metres. +Y is up, a model faces +Z (its front), +X is its left, and it stands on y = 0.
Angles are degrees. Colours are names (or keys) from the project palette (PAGAN’s .pal.yaml format); "#rrggbb"
works but warns (W104).
Project
kiln.toml settings (kiln config show)
palettes/main.pal.yaml colours (PAGAN format): models use their names
models/<name>.kiln.yaml one model per file (name = file name)
styles/<name>.style.toml own styles; styles/<name>/inspiration/*.png|jpg reference images
decals/ imports/ images for decals; imported .glb/.gltf files
BRIEF.md STYLE.md what the game needs; how it looks (+ changelog)
# kiln.toml
default_style = "soft_toy" # kiln style list
default_palette = "main"
output_dir = "build" # inside the Godot project when export has "godot"
preview_dir = "previews"
export = ["glb", "godot"]
style_version = 1
variant_glbs = true # false: no .glb per variant; Godot gets <name>.<variant>.tscn recolouring the base glb
merge_rigid = false # true: a rigid skin is one skinned mesh (one draw call; every vertex 100% on its part's bone;
# separate parts stay nodes; hide a part by scaling its bone to 0); manifest merged_rigid
vertex_colors = false # true: colours in the vertices, one surface per material preset (fewer draw calls)
atlas = false # true: one material per model from small palette textures (1 surface, 2 with decals)
atlas_ao = false # true or a strength 0..1 (with atlas): ambient occlusion baked into the vertex colours
godot_bonemap = false # true: humanoids get models/<name>.bonemap.tres in their glb's .import; Godot RENAMES
# their bones to SkeletonProfileHumanoid's (see Humanoids)
[lint]
disable = ["W106"]
Model file
name: pilot # = file name (pilot.kiln.yaml)
description: bean crew member in a pilot cap
class: character # size class: picks the triangle budget (style [budgets]) and size checks
tags: [crew]
status: draft # draft | approved
style_version: 1
style: soft_toy # optional: else kiln.toml default_style
palette: main # optional: else default_palette
budget: 6000 # optional: triangles (overrides the style's budget for the class)
lod: [2500, 800] # optional: extra lower-detail .glb files
origin: center # optional: the .glb's origin: base (bottom centre of the box) | center (spinning debris) | [x, y, z]
# (default: model space as written; manifest `origin` = that point, other manifest values stay model space)
node_prefix: Knight_ # optional: mesh nodes are Knight_<id> children of the part nodes (bones keep the ids;
# a soft skin's mesh is Knight_mesh); manifest parts get mesh_node
animation_library: soldier_anims # optional: play that clip carrier's animations (its glb has them; this one none)
animations_only: true # optional: this model is a clip carrier for others' animation_library
parts: [...] # the shapes (below)
rig: {...} # optional: bones (below)
skin: soft # none (static) | rigid | soft; default rigid when there is a rig
union: whole # rigid skins: whole | per_bone (each bone its own closed surface: joints can't tear)
animations: {...} # optional
variants: # optional: recoloured copies (<name>.<variant>.glb, or swap tables: variant_glbs)
copilot: { swap: { red: green } }
lint: { disable: [W106] }
Model families
A model can extends: <base> a template: true model in the same folder (templates are checked but not built):
soldiers sharing one body, trees sharing one trunk.
# models/soldier.kiln.yaml
name: soldier
template: true
class: character
parts: [...] # body, arms, hat (id: hat)
rig: {...}
animations: {...}
# models/archer.kiln.yaml
name: archer
extends: soldier
remove: [hat] # base parts with these ids (or mirror_ids) are left out
parts: [...] # added after the base's parts
variants: { blue: { swap: { red: blue } } }
The base’s parts come first, then the model’s own; rig, animations and variants merge by name (the model’s
win); every other field is inherited unless the model sets it, except its identity (name, description, tags, status,
style_version, template). Templates can extend templates.
Size classes in the built-in styles: prop, character, hero, creature, vehicle, machine, building, nature (and
default for anything else). Size sanity (W108, largest side): character 0.2-4 m, prop 0.02-6 m, building 1-200 m…
Parts
Each part is one shape plus options. Parts are combined in order: each one joins (or cuts) what came before.
parts:
- capsule: { from: [0, 0.3, 0], to: [0, 0.75, 0], radius: 0.3 } # the shape and its parameters
id: body # optional: which bone/node it belongs to (default: root)
color: red # palette colour name
material: plastic # a style material preset (default: default)
blend: 0.04 # smooth join radius with what's before it (0 = crisp seam)
Shapes
Every shape takes at: [x, y, z] (its centre; default [0, 0, 0]) and rotate: [x, y, z] (degrees, applied x
then y then z, about its centre).
| shape | parameters |
|---|---|
sphere |
radius |
ellipsoid |
radii: [x, y, z] |
box |
size: [x, y, z], round (edge radius; size kept) |
cylinder |
radius, height (along y), round, sides (8: an octagonal prism, a flat side facing +z) |
cone |
radius (bottom), radius_top (0 = point), height (along y), sides (4: a pyramid) |
capsule |
from: [x, y, z], to: [x, y, z], radius, radius_to (taper) |
tube |
points: [[x, y, z], ...] (2+), radius, radius_to: ropes, branches, rails, tails |
torus |
radius (ring), thickness (tube radius); ring in the xz plane (rotate for others) |
lathe |
profile: [[r, y], ...] spun around y: vases, bottles, bells, caps, chess pieces, round towers. The profile is a closed outline in the (radius, height) plane: go up the outside and come back down the axis (points with r = 0), e.g. a cap [[0, 1.06], [0.2, 1.02], [0.3, 0.9], [0.3, 0.8], [0, 0.8]]. Corners are rounded (smooth: true, default); smooth: false keeps them sharp |
extrude |
points: [[x, z], ...] outline seen from above (3+ different points; repeats and a closing point are dropped), height (along y), round, holes: [[[x, z], ...], ...] (outlines cut through: letters O, A, B; round rounds their edges too; hard parts: hole edges stay sharp), offset (grow the outline by metres, < 0 shrinks: inset face plates). Or path: "M 0 0 L 1 0 Q 1 1 0 1 Z" (SVG path data: M L H V C S Q T A Z, absolute and relative; y runs down onto z, so rotate: [90, 0, 0] stands it up reading right from the front) with path_scale (metres per unit: 0.001 for a 1000-unit font em) and path_tolerance (0.002 m): every subpath is an outline, even-odd (counters are holes, separate shapes are islands). Or text: "OPEN" in font: fonts/sign.ttf (.ttf/.otf, relative to the project folder) with text_size (metres per em, 0.1), text_align (left, center, right: about x = 0), letter_spacing (metres), line_spacing (ems, 1.2; \n starts a line), font_axes (a variable font’s instance: { wght: 900 }; default: its default instance): first baseline at z = 0, glyphs toward -z; no kerning or shaping. Glyphs fill like fonts do (nonzero): overlapping contours join instead of cutting holes. Check the font’s licence before shipping its shapes. Shields, signs, floor plans, stars, letters |
wedge |
size: [x, y, z]: a roof (ridge along x at the top) |
sides (3-64) also works on lathe and torus, and on sphere, ellipsoid, capsule and tube for hard parts.
For capsule and tube, the points are in the part’s frame (moved by at, turned by rotate). Everything else in a
model (decal at, rig pivots, poses’ offsets) is in model space.
Part options
| option | meaning |
|---|---|
id |
bone/node name (lowercase). Parts sharing an id move together. No id = root. |
color |
palette colour name (required for adding parts) |
material |
style preset: default, plastic, paint, fabric, skin, leather, rubber, wood, stone, metal, gold, leaf, glass, glow |
blend |
smooth-union radius in metres with everything before it (cuts/intersects: smooth edge) |
cut: true |
remove this shape from everything before it (holes, slits, windows, eye sockets) |
intersect: true |
keep only what’s inside this shape (trim) |
mirror: x |
also on the other side of 0 (x, y, z or a list); same id |
mirror_id: arm_r |
with mirror: x: the copy is a separate part with this id (rig entries are mirrored too) |
array: { count: 4, step: [1, 0, 0] } |
repeat in a row |
array: { count: 8, around: y, center: [0, 0, 0], angle: 360 } |
repeat in a ring (around may be any direction [x, y, z]: a ring on a tilted wheel) |
shell: 0.02 |
hollow, walls this thick (centred on the surface) |
shell: { thickness: 0.02, open: -y } |
a wall inside the surface (the outside stays put), one side cut open at the shape’s centre: helmets, bowls, pots, shields with a visible rim (side: both centres it) |
fit: head or fit: { to: head, gap: 0.01 } |
with shell: the wall wraps the parts with that id, gap away from them, wherever this part’s own shape reaches (its shape is the coverage: a sphere around the head, open: -y and placed low enough to cover the cheeks). Helmets that sit on the head instead of perching on it; cut a face opening after it (before the head, so the cut doesn’t take the face) |
twist: 90 |
degrees per metre about the shape’s y axis |
noise: { amount: 0.01, scale: 0.2, seed: 3 } |
lumpy surface (rocks, bark, clay): amount in metres, scale = bump size |
pattern: {...} |
a second colour painted on the part (below) |
decal: {...} |
an image on the part (below) |
loose: true |
this part may float free (flames, bees, sparks, rubble): W103 doesn’t count it |
blend: none |
a hard part, joined crisply (below) |
hard: true / false |
make this part hard (exact polygons) or smooth, whatever the style says |
chamfer: 0.02 |
hard parts: one flat band cut along every edge (box, wedge, cylinder, cone, extrude) |
bevel: 0.02 |
hard parts: rounded edges in segments: 1-4 flat bands (default 2); where an extrude outline is thinner than about twice the bevel (font serifs, hairlines) it flattens there to fit |
keep: true |
this detail survives every reduction (LODs) as its own small mesh, sitting in the surface around it. Crisp (blend 0) parts smaller than 2% of the model are kept anyway (rivets, pupils, studs); keep: false opts out |
aim: [[x, y, z], [x, y, z]] |
points the shape’s own y axis from the first point to the second and puts it halfway (instead of at); a shape with a height that doesn’t give one gets the distance. { from, to, roll: 30 } spins it about that line; rotate turns it in its own frame first. Arrows, spears, struts, beams at any angle |
attr: { heat: 0.9 } |
named numbers a shader reads per vertex (heat, opacity, wind weight); faces of parts without it get 0. Each becomes a custom glTF attribute _HEAT (Blender, three.js; Godot 4.7 drops custom attributes), and the first name (alphabetical) also goes in COLOR_0’s alpha (Godot: COLOR.a; white RGB unless vertex_colors). Manifest attributes |
uv: cylindrical |
texture coordinates projected from the part’s shape, in its own frame (they follow rotate, mirror, arrays): cylindrical (u around, v along the axis over the part’s box), spherical, box (per face, metres / the box’s largest side), along (tubes and capsules: v = distance along the path, 0..1). { project, axis: y, scale: [1, 4] }. Written to TEXCOORD_0 (Godot UV), or TEXCOORD_1 (UV2) when the atlas or a decal uses the first; manifest uv: {set, parts}. Other faces get (0, 0) |
Order matters: details that sit on a surface come after it. A part completely inside later parts shows nothing (W101). A cut removes material from what came before it, and paint follows: a door placed after the cut that made its archway is painted as the door, not as the wall. Parts thinner than about two mesh cells break up (W102): resolution comes from the style (soft_toy: 8 mm for a ~1 m model, coarser for bigger models).
Hard surface
Smooth parts are sampled from the distance field and reduced to the budget: soft clay-toy shapes. Hard parts are
made as exact polygons instead: designed facets, crisp creases, octagonal wheels, planks, beams, armour plates, the
hand-built low-poly look (Kenney, KayKit). A part is hard when it asks (sides, chamfer, bevel, blend: none or
hard: true), or always with a style’s hard_surface = "always" (low_poly), except the body of a soft-skinned
character (its joints bend by blending) and parts with twist or noise. Hard parts join each other exactly
(union, cut, intersect, in order); every later cut or intersect, of any shape, applies to them too. They sit in
smooth parts without blending. A box’s round is a chamfer on a hard part. Round hard shapes get the style’s
hard_sides (12 by default; low_poly 8) unless they say sides. When hard parts would take most of the budget
(LODs), bevels become chamfers, then go, then round shapes lose sides. Flat faces stay flat; in smooth styles bevel
bands shade round and designed edges stay crisp.
- box: { at: [0, 0.3, 0], size: [0.6, 0.6, 0.6] } # a crate
color: wood
chamfer: 0.03
- cylinder: { at: [0.7, 0.26, 0.9], radius: 0.26, height: 0.1, sides: 8, rotate: [0, 0, 90] } # a wheel
color: wood_dark
Hardware
Written like a shape, each stands for several ordinary parts (hard by default); the part’s other fields (colour, material, id, mirror, …) go to each.
| hardware | meaning |
|---|---|
rivets: { count: 8, along: [[x, y, z], ...], size: 0.03 } |
rivets evenly along a path (model space, ends included); or around: { center, radius, axis: y } in a ring |
band: { fit: body, at: [0, 0.12, 0], width: 0.05, thickness: 0.012, axis: y } |
an iron band round an earlier part’s surface at at (or radius: instead of fit): barrels, doors, posts |
planks: { size: [1, 2, 0.08], count: 5, gap: 0.015, axis: x, jitter: 0.05, seed: 1 } |
boards side by side along axis with gaps, chamfered; jitter shortens each by up to that share of its length (with at, rotate) |
bolt: { at: [x, y, z], radius: 0.02, length: 0.02 } |
a hex bolt head along y (rotate it) |
slit: { at: [x, y, z], size: [0.3, 0.04, 0.3] } |
a cut: visor and arrow slits |
Patterns
pattern: { type: stripes, color: gold, size: 0.06, width: 0.3, axis: y }
# several: a list, painted in order (each over the last); frame: part = axes and `at` in the part's own frame
# (they follow rotate/aim): pattern: [{ type: stripes, ..., frame: part }, { type: band, ..., frame: part }]
stripes: bands acrossaxis, one everysizemetres,width= fraction in the second colour.band: one band of thicknesssizecentred atat(one number: metres alongaxis, in model space): belts, trims, hat bands. E.g.{ type: band, color: brown, size: 0.06, at: 0.4, axis: y }= a belt 0.4 m above the ground.checker: checkssizemetres across.dots: dots on a gridsizemetres apart, diameterwidthx size.
Decals
decal: { image: decals/logo.png, axis: z, at: [0, 0.6, 0.3], size: [0.2, 0.1] }
Projects an image (PNG with transparency, e.g. made with PAGAN) onto the faces of this part that face axis
(x, -x, y, -y, z, -z), centred at at (model space, on or near the surface), size = width, height in metres. It
shows on that side only (a flag needs a second decal with axis: -z for its back). Transparent pixels show the part’s colour.
Small images (<= 256 px) are sampled crisp (pixel art).
Rig and skin
rig:
leg_l: { pivot: [0.12, 0.26, 0] } # joint position; parent defaults to root
arm_l: { pivot: [0.3, 0.6, 0], parent: root }
head: { pivot: [0, 0.8, 0], meta: { hitzone: head, seat: [0, 1.56, 0] } }
helmet: { parent: head, meta: { detachable: true }, separate: true }
arm: { pivot: [0, 1.6, 0], sockets: { tip: [0, 3.9, -0.2], load: [0, 3.6, -0.5] } }
skin: soft
- Every part id is a bone/node.
pivotis where it turns (hip, shoulder, hinge, axle); default: its box centre. - A rig entry for
arm_lalso rigs itsmirror_idtwin (pivot mirrored; parent mapped to its twin if any). skin: rigid: each id is a separate mesh node that moves as one piece. By default all parts are one surface, cut where bones meet: fine while joints stay near their bind pose.union: per_bonemakes each bone’s parts their own closed surface; neighbouring bones overlap instead (a few hidden triangles), so turning a joint far (a ragdoll, an arm along the chest) never opens a hole. Blends between parts of different bones are then lost.skin: soft: one skinned mesh; weights come from each part’s distance, so blended joints bend smoothly.separate: truemakes a part its own closed, rigid mesh (detachable things): it may overlap the body (a helmet on a head) without fusing, and removing it leaves no hole.under: { color: hair }(withseparate: true) paints the body where the part covers it (hair under a helmet;material,gap: how far from it still counts, 0.02 m), seen once it falls off. Check withkiln preview <model> --detach.revealed: [arm_l]: a part shown only once one of those is gone (a stump cap at the shoulder, under a detachable arm). It is its own piece (separate), hidden by KILN’s .tscn scenes,kiln_revealed_byin its node’s extras andrevealed_byin the manifest’s part: the game shows it when it removes the arm. Mirrored twins are revealed by the twins (arm_r). Previews (and--vs) leave it out like the game does;--detachshows it under the part taken off.fold: true(withparent): while none of itsrevealedparts is in the model, the part folds into its parent: its parts take the parent’s id (and blend with it) and the entry goes, as if it had no id of its own; with one there, it’s an ordinary revealed part. Withoutrevealedit always folds: the library heads rig their hairhair: { parent: head, fold: true }(onehead, as before the hair had an id), and a model opts in to hiding it under a helmet withhair: { parent: head, revealed: [helmet], fold: true }(see Template library).gib: true(orgib: [upperarm_l, lowerarm_l, hand_l]): the part comes off as a piece of its own. Besides the model,kiln buildwritesmodels/<name>.gib.<id>.glb: that part (and, withtrue, every part hanging from it) meshed on its own, so it’s closed where it met the body; static, at rest, in the model’s frame (same origin as the model’s glb), plain materials. Manifest:gibs: { <id>: { glb, parts } }. Mirrored twins get theirs. Pair it with arevealedstump cap on the body.meta: plain values or flat lists (points:[x, y, z], model space) for game code: Godot node metadata (get_meta("extras"); on soft skins, bone metadata:skeleton.get_bone_meta(i, "extras")) and the manifest. Mirrored twins get the same meta (not mirrored).sockets: named points on the part (model space, as built): a muzzle, a hand slot, a trebuchet’s load. Each is an empty child node<id>_<name>that moves with the part (Godot: a Node3D under a BoneAttachment3D on soft skins), and the manifest gives its position and its offset from the pivot. Mirrored twins get mirrored sockets.
Template library
kiln library lists KILN’s own templates: bodies (body_slim, body_stocky, body_big: standard joints, layered
clothes, gloves, boots, a grip socket per hand), heads (head_young, head_old, head_bearded, head_stern,
head_goofy, head_female), helmets (helmet_kettle, helmet_nasal, helmet_barbute, helmet_great,
helmet_sallet, helmet_crown: detachable, fitted to whatever head they go on) and armour (armour_breastplate,
armour_pauldrons, armour_gauntlets, armour_cape, armour_hood: shells fitted to the body). Combine them:
name: sergeant
class: character
extends: [body_stocky, head_bearded, helmet_nasal, armour_cape] # in order; a project template of the same name wins
variants: { blue: { swap: { cloth_red: cloth_blue } } }
animations:
walk: { import: imports/Rig_Medium_MovementBasic.glb, clip: Walking_A }
or kiln new model sergeant --extends body_stocky,head_bearded,helmet_nasal,armour_cape. Their colours are the
library palette people (found without copying; copy it into palettes/ to change it). extends takes a list (bases
combine in order); cut: <id> cuts only that id (a helmet’s face opening, not the face); fit: { to: [ids] } wraps
several parts. Fitted shells are their own meshes.
The heads’ hair is the id hair, folded into head (fold: true, see Rig and skin), so helmets fit over it and
follow its lumps. To hide it under the helmet instead (as KayKit’s helmets replace the hair), add to the model:
rig:
hair: { parent: head, revealed: [helmet], fold: true } # hair hidden until the helmet (id helmet) comes off
The hair is then its own piece, hidden by the scenes and previews until the helmet comes off, and the helmet fits the
skull (to: head). Without a helmet it folds back into head. Leave it out for a crown (worn over the hair).
Humanoids and clip libraries
skeleton: humanoid names a character’s parts after the standard joints: hips, spine, chest, head,
upperarm_l, lowerarm_l, hand_l, upperleg_l, lowerleg_l, foot_l (and _r, usually by mirror_id;
optional toes_l/_r). Parents come from the standard hierarchy and pivots default to the joints (limbs at the end of
their longest capsule/cylinder/cone/tube nearest the parent, else of their box; hands, feet and toes where the limb
above them ends, on their own box: wrist, ankle; spine, chest, head at their bottom); give rig: pivots where that guess is off. Skin defaults to
soft. The manifest maps each joint to its KayKit, Mixamo and Godot SkeletonProfileHumanoid name.
Then any CC0 clip library on the same joints (KayKit Character Animations’ Rig_Medium_*.glb: Idle_A, Walking_A,
Running_A, Death_A, Hit_A, Throw, Jump_*, … ~80 clips) plays on the character, retargeted when it’s built:
animations:
walk: { import: imports/Rig_Medium_MovementBasic.glb, clip: Walking_A }
death: { import: imports/Rig_Medium_General.glb, clip: Death_A, loop: false, fps: 30, speed: 1 }
Each bone points where the clip’s bone points (the clip’s rest pose may be a T-pose; yours can be anything), bone
lengths stay yours, and the hips move by the clip’s motion scaled to your hip height. Check with --pose <name>.
Clips made for another body type (KayKit’s are chibi: splayed arms, deep knee bends on an adult body) can be toned per
animation: retarget: { keep: { upperarm: 0.35, lowerleg: 0.5 }, offset: { upperarm_l: [0, 0, 12] } }. keep is the
share of each joint’s turn away from rest it keeps (0 = held at rest, 1 = as the clip; a base name like upperarm
covers both sides; children follow), offset a fixed turn in degrees (model axes) added on top.
godot_bonemap = true (kiln.toml, off by default; output inside a Godot project) writes models/<name>.bonemap.tres
for each humanoid with a skeleton in its glb (soft skins; rigid ones with merge_rigid), clip carriers included: a
BoneMap on SkeletonProfileHumanoid from the manifest’s names (root -> Root, hips -> Hips, upperleg_l ->
LeftUpperLeg, …), and points the glb’s .import at it (_subresources -> PATH:Skeleton3D ->
retarget/bone_map; the rest of the sidecar stays). Godot then retargets on import: any humanoid clip made for the
profile (Mixamo, Godot’s own retargeted libraries) plays on the character with no renaming in the game. The glb itself
doesn’t change, but in Godot the bones take the profile’s names (Hips, LeftUpperLeg; unmapped bones keep
theirs), the skeleton node becomes %GeneralSkeleton, and clip tracks follow: node paths, find_bone("hips") and
BoneAttachment3D names in game code must use the new names (manifest humanoid has them; the entry says
godot_bonemap). Use it for a new game or one sharing clips with other humanoids; leave it off where code already
names KILN’s bones. Turning it off removes the files and the .import setting again (Godot reimports with KILN’s
names).
Clips on any skeleton (joint map)
A creature that isn’t a humanoid (a quadruped, a bird) plays a clip library made for another armature through a map from its part ids (bones) to the clip’s joints:
skin: rigid
rig: { body: { pivot: [0, 0.5, 0] }, neck: { pivot: [0, 0.55, 0.3], parent: body }, head: { parent: neck }, ... }
skeleton:
map:
body: Torso2
neck: { joint: Neck1, child: head } # child: the bone whose direction aims this one
head: Head
fl_up_l: { joint: FrontUpperLeg.L, child: fl_low_l }
fl_low_l: FrontLowerLeg.L
tail1: { joint: Tail1, child: tail2 }
tail2: Tail4
root_motion: Torso # the clip joint whose movement moves the model
animations:
walk: { import: imports/Horse.glb, clip: Walk, retarget: { keep: { tail1: 0.5 } } }
Each mapped bone turns by its joint’s world-space turn from the clip’s rest. A bone with a child (another mapped
bone below it in the rig) is first turned so it points where the clip’s joint points to the child’s joint (as each
humanoid limb is), so a creature built in its own rest pose plays the clip; without child it keeps its parent’s
aim. Unmapped bones follow their parent (a saddle on the body); unmapped clip joints are ignored (IK and pole
bones). The map changes nothing else: parents, pivots and skin come from rig and skin as usual, so give the
creature a hierarchy (parent:) and a skin (rigid or soft). retarget: { keep, offset } works per mapped bone
(by id, or a base name for _l/_r pairs).
root_motion names the clip joint whose movement moves the model: its movement, scaled by the moving bone’s rest
height over the joint’s rest height (a joint at ground level: unscaled), moves the bone mapped to that joint, or,
when it isn’t mapped (Torso above), the bone mapped to its nearest mapped joint below it (body: Torso2). Without
root_motion nothing moves: the clip plays in place, rotations only (the body’s bob and travel are dropped).
skeleton: humanoid uses the standard table and takes no map (skeleton: { ... } is for every other skeleton).
Findings (E007): a map key that isn’t a part id (or is root), a child that isn’t a mapped bone below it; (E008)
a mapped joint or root_motion the clip file doesn’t have (with the nearest names). Check with --pose <animation>.
Attachments for CC0 characters
A helmet, shield or banner made around a CC0 character (a KayKit Knight) instead of a KILN body:
fit_to: { file: imports/Knight.glb, bone: head, meshes: [Knight_Head] } # meshes: which ones to fit (default all)
parts:
- sphere: { at: [0, 1.62, 0], radius: 0.8 } # the coverage: where the helmet reaches
shell: { thickness: 0.04, open: -y }
fit: { to: target, gap: 0.015 } # wraps the character's own head, 1.5 cm off it
The model is made in the character file’s space (its units); the glb is exported in the bone’s space (root node
<model>_on_<bone>), so the game hangs it on that bone (Godot: a BoneAttachment3D); the manifest says attach: { file, bone }. W113 warns when it sinks into the character. kiln preview <model> --fit shows it on the character and the
character without it.
Effects
A burst (an explosion’s debris, sparks, puffs of smoke) as other models thrown out over time. KILN doesn’t play
effects: kiln build writes models/<name>.effect.json (manifest effect) with every particle already rolled from
the seed, so an engine-side player shows exactly what kiln preview <model> --effect shows.
The preview plays it in 8 evenly spaced frames; --at 0,0.1,0.25,0.5,1 (seconds) gives a contact sheet at those
times instead (tune particle sizes there). Each piece’s fade is drawn as ordered dithering (a reversed envelope shows
as pieces that appear instead of vanish) and a floor as a thin grid.
effect:
seed: 3 # same seed, same particles
duration: 1.2 # seconds (default: the last particle's end); loop: false
emit:
- model: spark # another model in the project (its glb; `origin: center` so it spins about its middle)
count: 12
at: [0, 0.5, 0] # spawn point, spread: 0.1 (a ball's radius)
delay: [0, 0.2] # seconds, a number or [lo, hi]; life: [0.5, 1.0]
velocity: { min: [-0.5, 1, -0.5], max: [0.5, 2, 0.5] } # m/s, or one [x, y, z]
burst: [1, 2] # m/s outward, any direction (added to velocity)
gravity: 9.8 # m/s^2 down, or [x, y, z]; drag: 1.5 (1/s)
spin: [-90, 90] # degrees/s about a random axis
scale: { 0: 0.2, 0.2: 1, 1: 0 } # over life (0..1), linear between keys; or one number
heat: { 0: 1, 1: 0 } # for the game's shader (e.g. into the attr it reads)
fade: { 0.8: 1, 1: 0 } # opacity for the game (1 = solid); previews dither it
bounce: 0.3 # with a floor: vertical speed kept at a hit (0 = stops dead); friction: 0.6 (horizontal)
settle: true # with a floor: rest once slower than 0.2 m/s on it (no motion, no spin)
floor: 0 # optional: a height (effect frame) debris bounces on and settles on, instead of falling through
The JSON: format, duration, loop, seed, motion (the formula), emitters (model, glb, godot_scene,
origin, gravity vector, drag, envelopes as [life fraction, value] pairs) and particles (emitter, delay,
life, at, velocity, axis, spin; with a floor: hits, each [age, position, velocity] where the motion
starts again from that position and velocity, [0, 0, 0] = resting there without spin). Position at age = t - delay
(alive while 0 <= age <= life):
at + g/k*age + (velocity - g/k)*(1 - exp(-k*age))/k with drag k > 0, at + velocity*age + g*age^2/2 with k = 0.
E011 when an emitted model doesn’t exist.
Terrains
A model with terrain: instead of parts: is a heightfield (one height per x, z) with up to four splat layers of
palette colours: ground for a level. Only name, description, tags, status, style_version, class (default
terrain), style, palette and lint go with it. The map is centred on the origin (x and z from -size/2 to
+size/2), heights in metres. Design point: 256 x 256 m at 0.5 m cells (builds in ~2 s, ~120 MB).
name: valley
terrain:
size: [256, 256] # metres [x, z] (or one number); a whole number of cells and of chunks
cell: 0.5 # grid spacing (m); up to 2048 x 2048 cells
chunk: 32 # mesh tile size (m): a whole number of cell x 2^(number of LODs), at most 250 cells
lod: [64, 160] # camera distances (m) where LOD 1, 2, ... take over (each halves the grid); [] = none
skirt: 1.5 # optional: how far chunk edges hang down (default: from the LODs' height error)
collision: 1 # HeightMapShape3D sample every 1, 2, 4 or 8 cells; false = none
seed: 3 # noise seed; base: 0 (the height everything starts from)
shapes: # applied in order to the running height H
- noise: { scale: 70, height: 3, octaves: 5 } # roughness: 0.5, ridged: false, seed: 0 (added to the terrain's)
- hill: { at: [-70, -50], radius: 55, height: 22 } # radius m or [x, z] (an oval), rotate: degrees
- ridge: { points: [[-128, 90], [0, 100], [128, 80]], width: 35, height: 20 } # a smooth curve through the points
- plateau: { at: [70, -55], radius: 26, edge: 10, height: 14, blend: max, smooth: 3 } # flat top, sloping rim
- hill: { at: [40, 35], radius: 28, height: 5, blend: subtract } # a hollow
- path: { points: [[-128, 10], [0, 0], [128, 5]], width: 5, edge: 5, depth: 0.25 } # smooth: 12 (m along it)
splat: # 1-4 layers; the first is everywhere, each later one painted over where its rules hold
- grass: { color: grass }
- dirt: { color: dirt, path: { within: 3.5, fade: 1.5 }, noise: { scale: 6, cover: 0.85 } }
- rock: { color: rock, slope: { above: 28, fade: 6 } }
- sand: { color: sand, height: { below: -1.5, fade: 1 } }
Each shape has a weight p (1 at its core, a smooth falloff to 0 at its edge: a hill’s radius, a ridge’s width from
its line, a plateau’s edge past its radius) and a blend: add (default; H += height x p), subtract, max /
min (raise / lower towards the level height; smooth: m of crease rounding) or set (to the level). Noise only
adds or subtracts (about +-height). A path flattens a band (width, banks edge) to the ground’s own height smoothed
along it (smooth m), sunk by depth: roads that cut through hills and bridge dips. Layer rules multiply:
slope: { above, below, fade } degrees, height: { above, below, fade } metres, path: { within, fade } metres from a
path’s centre line, noise: { scale, cover (0-1), fade, seed } patches; strength (0-1) caps the layer.
Output: models/<name>.glb (LOD 0) and .lod<k>.glb: a root node with chunk_<ix>_<iz> nodes at the chunks’
centres, each a regular grid mesh (POSITION, NORMAL, COLOR_0 = the blended colours, TEXCOORD_0 = the splat map) with a
skirt; models/<name>.splat.png (one pixel per grid point, top = -z; R, G, B = layers 2-4, layer 1 = the rest); with
“godot”, models/<name>.tscn: the LOD glbs (each chunk shown between its LOD’s distances, 4 m overlap), a splat shader
blending the palette colours (the style’s toon bands if it has them) and a StaticBody3D with a HeightMapShape3D
(inline, scaled by the sample spacing). Godot imports the chunk glbs without its own LODs. Manifest: the usual fields
plus terrain (size, cell, samples, chunk, chunks, lod_distances, skirt, height_range, splat,
layers with channel, collision (samples, spacing), probes: [x, z, height] points --verify-godot checks
the collision at). kiln preview <terrain>: from above (splat colours, lit) and from four sides. Lint: W116 a layer
covers < 0.5% of the map, W117 a shape is off the map.
Tools (foliage scattering, props on the ground) read the heightfield in Python:
kiln.terrain.load(project, "valley") -> Heightfield with height(x, z), normal(x, z), slope(x, z) (degrees),
splat(x, z) (weights in layer_names order), distance_to_path(x, z), distance(points) (y - height: the
heightfield as a distance field) and bounds(); arrays in, arrays out, bilinear between grid points.
Scatters
A model with scatter: instead of parts: places other models many times over a surface: grass, flowers, rocks and
trees on a terrain, moss on a boulder, pebbles on a flat area. Only name, description, tags, status,
style_version, class (default scatter), style, palette and lint go with it. KILN rolls every instance at
build time (Python’s random.Random, one stream per place entry: stable across machines, and editing one entry leaves
the others where they were).
name: meadow
scatter:
onto: valley # a terrain, another model (its top surface, seen from above) or flat (needs size)
size: [64, 64] # optional: the area [x, z] in metres (or one number), centred on the origin;
# default: the whole terrain / the model's footprint
seed: 7
chunk: 32 # instances are grouped in cells this size (one MultiMesh each: culling, LODs)
place: # in order: later entries keep their distance from earlier ones (spacing)
- model: tree # a model, name.variant, or a list picked evenly: [flower, flower.pink]
density: 0.003 # candidates per m^2 of area (seen from above), before rules and spacing
spacing: 9 # at least this far (m) from every instance placed before (the larger spacing of the two)
scale: [0.8, 1.3] # a factor or [lo, hi]
rotate: 360 # yaw jitter in degrees (360 = any way round, the default; 0 = none; or [lo, hi])
tilt: 5 # a random lean up to this many degrees
align: 0 # 0 upright .. 1 up along the surface's normal
sink: 0.15 # metres into the surface (hides bases on slopes)
tint: { color: sand, amount: 0.4, shade: 0.15 } # or a number: shade only
slope: { below: 22 } # degrees, { above, below, fade } as terrain splats
height: { above: 0 } # metres
noise: { scale: 20, cover: 0.6 } # patches (clumps of flowers)
splat: grass # a terrain's layer (or a list): kept as often as their summed weight there
path: { beyond: 6 } # a terrain's paths: away from (beyond) or near (within) their centre lines, m
lod: [70] # camera distances where the model's lod1, lod2, ... take over (needs its lod: budgets)
visible: 120 # hidden beyond this camera distance (m); 0 = always
How it places: density x the area candidates, uniform over the area; a candidate is dropped where there’s no surface
(off a model, or under an overhang), else kept with the probability its rules give (multiplied; each a smooth ramp
over its fade), then dropped if it is closer than spacing to an instance placed before it (entries with spacing 0
skip that check). An instance stands its model’s origin (its feet) on the surface, sink lower; it leans align of
the way to the surface’s normal, then by a random tilt, turns by rotate about its up and is scaled. tint makes
an instance colour (linear RGB) that multiplies the model’s colours: white leaning towards color by up to amount,
darkened by up to shade. Scatter models made of one mesh (no rig, no skin); keep their budgets small (grass ~100
triangles, trees ~1500 with a lod:).
The scatter’s frame is the surface’s: a terrain’s map, a model’s exported frame (its glb’s: model space minus its
origin), or a flat area centred on the origin at y = 0. Put the scatter scene under the terrain or model.
Output: models/<name>.scatter.json: format, seed, onto (kind: flat, terrain or model; model), area
(min, max x, z), chunk and groups, one per entry and model (node, place, model, variant, glb, lods,
godot_scene, count, lod_distances, visible, chunks (node, first, count: ranges of instances),
instances (each [x, y, z, ax, ay, az, bx, by, bz, cx, cy, cz]: the model’s origin and its x, y, z axes, scale
included) and, with a tint, colors (linear RGB per instance)). With “godot”, models/<name>.tscn: a Node3D per group
holding a MultiMeshInstance3D per chunk (transforms and colours inline); its inline script gives each MultiMesh the
model’s mesh with the materials the model’s own scene sets up (the style’s look; tinted groups let instance colours
through: vertex_color_use_as_albedo, the toon shader’s COLOR), adds <chunk>_lod<k> copies with the model’s LOD glbs
and sets the visibility ranges (2 m overlap). Manifest: class, style, scatter (the JSON), onto, instances
(per group), godot_scene; no glb. --verify-godot checks every chunk and LOD against the JSON (instance counts,
each transform times where the mesh sits in its model, colours, materials, distances). kiln preview <scatter>: from
above, from two sides and close up where the most instances are (the whole-area views draw every n-th instance of the
costliest groups beyond 200 000 triangles). Lint: E015 (an unknown or unsuitable model or variant, more lod
distances than the model has LODs, splat/path without a terrain, an unknown layer, more than 1 000 000 candidates
in an entry or 200 000 instances in a scatter), W118 an entry places more than 50 000 instances, W119 an entry places
none.
Animations
Names are letters, digits, _ or -, any case (imported clips can keep their library names: Idle_A); two names
that differ only in case are an error.
animations:
wave:
fps: 8
loop: false # default true (Godot loops it: glTF name "<name>-loop")
interpolation: linear # linear | step
poses: # one per frame; a missing part = its rest pose
- {}
- { arm_r: { rotate: [0, 0, -120] } }
- { arm_r: { rotate: [0, 0, -150] }, root: { offset: [0, 0.02, 0] } }
walk: { fps: 12, cycle: { type: waddle, legs: [leg_l, leg_r], arms: [arm_l, arm_r] } }
carry: # a cycle plus poses: poses repeat over the cycle's frames, and a part named in a
fps: 12 # pose follows the pose instead of the cycle (walking legs + arms holding a crate)
cycle: { type: walk, legs: [leg_l, leg_r], arms: [] }
poses: [{ arm_l: { rotate: [-70, 0, 0] }, arm_r: { rotate: [-70, 0, 0] } }]
Pose fields per part: offset: [x, y, z] (metres), rotate: [x, y, z] (degrees, about the pivot), scale: s or
[x, y, z] (squash and stretch). Signs: positive x tips the part’s top towards the front (+Z), so a hanging leg or arm
swings backwards with positive x and forwards with negative x; a drawbridge hinged at its bottom lowers forwards with
positive x. About z: negative raises a right arm (-X side), positive raises a left arm. About y: positive turns
left (counter-clockwise seen from above).
Cycles (8 frames unless frames:):
| type | options (defaults) |
|---|---|
walk |
legs [leg_l, leg_r], arms [arm_l, arm_r], body root, swing 30, arm_swing 25, bounce 0.02 |
waddle |
as walk + roll 8 (side-to-side), swing 18 |
hop |
part root, height 0.2, squash 0.1 |
bob |
part, amount 0.05 |
spin |
part, axis y, direction 1 |
swing |
part, axis y, angle 20 |
breathe |
part, amount 0.03 |
wave |
arms (the last one waves), raise 150, angle 20 |
chain |
parts [ids in order from the root out; default: every id in file order], axis y, angle 15, phase 0.15: a travelling wave for flags, tails, tentacles, ropes |
Imported models
name: oak
class: nature
source: { file: imports/tree_oak.glb, licence: CC0-1.0, author: Kenney, url: https://kenney.nl }
process: { height: 4.0, recolor: auto, rotate: [0, 90, 0] } # or scale: 1.0; recolor keep = file's colours
Static meshes only. Colours snap to the palette (OKLab nearest), the style’s materials and budget apply, the model
stands on y = 0, and the build writes CREDITS.md. Files the model refers to (Textures/colormap.png in Kenney kits)
are copied with it by kiln import, keeping their folders; a missing one is an error, not a white model.
.fbx, .blend, .obj (optional, needs Blender 4.x: blender on PATH or BLENDER=/path/to/blender; kiln doctor
shows which). Blender exports the file as a glb (+Y up, modifiers applied, skins, animations, the active vertex
colours, textures as they come), which then imports like any glb. Two ways in:
kiln import Knight.fbx --licence CC0-1.0 ...converts once and writesimports/Knight.glb(the original isn’t copied); the model names the glb, so the project builds anywhere without Blender. Preferred.source.file, an animation’simport:andfit_to.filemay name the .fbx/.blend/.obj directly: every load then needs Blender, and the glb is cached in.kiln-cache/blender/<key>/<name>.glb(key: the file’s sha256, an .obj’s .mtl and texture maps, the Blender version, the export script version), so only the first build runs it. Credits name the original file.
No Blender, or one older than 4: E010 (E008 for an animation import:) saying how to fix it; a failed conversion
shows the end of Blender’s output. FBX clip names carry Blender’s armature prefixes (a Wave action came out as
Rig|Rig|Wave); a wrong clip: lists the real names.
CC0 packs (kiln pack)
KILN ships recipes for CC0 packs (kiln pack list): Kenney (kenney-mini-dungeon), KayKit (kaykit-adventurers,
kaykit-furniture-bits, kaykit-city-builder-bits, kaykit-dungeon-remastered) and Quaternius
(quaternius-modular-scifi-megakit). A recipe pins the archive’s URL and sha256; mirroring checks both and that the
pack’s licence file says CC0.
kiln pack add kaykit-adventurers # the only command that downloads: into ~/.cache/kiln/packs/<pack>@<version>/
kiln pack search knight --json # file name and tags (the pack's own + its folders); triangles, size, bytes
kiln pack search chair --pack kaykit-furniture-bits --lineup # + previews/pack-search.png: the hits at one scale
kiln pack import kaykit-adventurers knight --height 1.8 --class character
Mirrors are per user, shared between games ($XDG_CACHE_HOME/kiln/packs; KILN_PACK_CACHE overrides). Search terms
must all match, ranked whole name > a word of the name > part of it > a tag. kiln pack import <pack>[@version] <path|name> copies the model and the files it refers to into imports/<pack>/, writes the model (as kiln import:
source: with CC0-1.0, the pack’s author and page) and records every copied file in kiln.lock (JSON: path ->
pack, version, url, sha256, licence, file in the pack). Commit kiln.lock and imports/; the cache stays out of the
repo. A recipe file works too: kiln pack add my-pack.toml (fields name, version, url, sha256, licence =
CC0-1.0 only, author, homepage, strip leading folders, models folder, licence_file, textures shared folder,
tags).
With a kiln.lock, lint and build check it (E014 errors fail the build): a listed file whose sha256 changed, an
entry whose licence isn’t CC0-1.0, a model whose source.licence differs from its file’s entry; a listed file that’s
gone is W115. Files without an entry (local kiln import) aren’t checked; no kiln.lock, no checks. CREDITS.md and
the manifest’s source name the pack (pack: [email protected]) for locked files.
Styles
# styles/sky_pirates.style.toml
description = "Chunky toy airships, warm sunset light"
extends = "soft_toy" # soft_toy | low_poly | toon | clay | diorama | another project style
notes = """Shapes, proportions, colours, materials, lighting in words (agents follow this)."""
[shape]
smooth = true # false = faceted (flat normals)
sharp_angle = 50 # smooth styles: edges sharper than this stay crisp
resolution = 0.008 # metres per mesh cell for a ~1 m model (finer = rounder, slower)
[budgets] # triangles per class
character = 8000
default = 4000
[materials] # roughness, metallic (0..1), emission (glow strength), rim (soft edge light)
plastic = { roughness = 0.42 }
[look] # the Godot look kit and preview lighting
shader = "standard" # standard | toon
toon_bands = 3
toon_softness = 0.0 # toon: 0 = hard band steps; up to 1 = each edge blends over a whole band (0.2-0.4: soft cel)
toon_shadow_color = "" # toon: "" = shadows only darken; "#rrggbb" or a palette colour = the sun's missing light in
# that colour (warm/cool shading; a light colour tints more; per model with its palette)
toon_specular = 0.0 # toon: one hard highlight blob, strength 0..1 (size grows and strength fades with roughness)
toon_shadows = "soft" # toon: soft = Godot's received shadows; band = they drop into the darkest band (Godot only)
outline = 0.0 # metres (toon outlines), 0 = none; pixels with outline_space = "pixels" (e.g. 2)
outline_color = "#1a1420" # or "darken": each part's own colour, darkened
outline_space = "metres" # metres: a shell that thins with distance; pixels: constant thickness on screen
tonemapper = "filmic" # linear | filmic | aces | agx
exposure = 1.0
saturation = 1.15
contrast = 1.0
ssao = true
glow = true
ambient = 0.9
sky_top = "#4f8fe0"
sky_horizon = "#bcd7f2"
ground = "#a9c48f"
sun_pitch = -48.0 # degrees (negative = from above)
sun_yaw = 30.0
sun_energy = 1.1
sun_color = "#fff6e8"
shadows = true
fog = 0.0
fog_color = "#c8d8e8"
rim_light = 0.0 # an added fresnel rim on every surface (Godot: a next pass; previews too): 0.5-1 reads on bright skies
rim_light_color = "#ffffff"
rim_light_power = 3.0 # higher: thinner rim
Every toon key is off by default (today’s plain cel look). Wind Waker / Sable-style: toon_softness = 0.2,
toon_shadow_color a cool mid tone, toon_specular = 0.6 on glossy materials, toon_shadows = "band",
outline_space = "pixels" with outline = 2 and outline_color = "darken". Previews follow every key except
toon_shadows (the software renderer has no cast shadows; kiln preview --engine shows them).
Lint
Errors: E000 usage/config, E001 file/YAML, E002 unknown field, E003 bad value, E004 unknown colour, E005 unknown
material, E006 style problem, E007 rig problem, E008 animation problem, E009 nothing to mesh, E010 import file
problem, E011 decal image missing, E012 extends problem (no such model, not a template, a loop, unknown remove id),
E013 the built mesh has open edges (cracks), edges with more than two faces or sheets touching at a vertex (a KILN
bug; report it), E014 kiln.lock problem (a pack-imported file changed, a licence that isn’t CC0-1.0), E015 scatter problem
(an unknown or unsuitable model to place or stand on, too many instances).
Warnings: W101 part has no visible surface, W102 part thinner than the mesh resolution, W103 separate pieces,
W104 colour not in the palette, W106 not standing on y = 0, W107 made under an older style_version, W108 size
unusual for the class, W109 animated part without a pivot, W110 well over the triangle budget (patterns cost
triangles: every stripe border is cut into the mesh; prefer few, large patterns, or model details as parts), W111 a
part lost when reducing to the triangle budget (raise budget:), W112 a LOD well above its target (from kiln build),
W113 a part pokes through a shell (eyes bigger than a visor slot, a head through a helmet: keep it inside or outside).
W114 (from kiln build) joints sit elsewhere than in the model’s animation_library. W115 kiln.lock lists a file
that isn’t in the project. W116 a terrain’s splat layer covers < 0.5% of the map, W117 a
terrain’s height shape is off the map. W118 a scatter entry places more than 50 000 instances, W119 one places none.
Disable in kiln.toml [lint] disable or per model lint: { disable: [...] }. Lint meshes models in parallel
(KILN_JOBS processes, default: every core) and caches them for build and previews.
Commands
kiln init [dir] --style soft_toy · kiln new model <name> --template bean|prop|empty --class C ·
kiln lint [models] · kiln preview <model> [--closeup ID|NUMBER] [--pose ANIM] [--vs] [--detach] [--fit] [--effect [--at T,T,...]] [--engine] [--variant V] ·
kiln lineup [models] · kiln gallery · kiln build [models] [--verify-godot] (named models only: the others keep their last output) · kiln status ·
kiln mark <models> --approved|--draft [--current] · kiln style list|show|new|bump ·
kiln config show|get|set · kiln import <file> --licence L (.glb/.gltf; .fbx/.blend/.obj with Blender 4.x) · kiln pack list|add|search|import · kiln credits · kiln doctor ·
kiln skill install · kiln mcp. Every command takes --json (before or after the command). --closeup takes a part
id (root = parts without one) or a part’s number in the file (--closeup 3).
kiln preview <model> --vs <ref.glb|ref.gltf> [--ref-height METRES]: the model against a reference mesh (a CC0
pack item, kept as it is: its own colours and normals) at one scale (same pixels per metre, both on y = 0, centred
on each other), in the style’s lighting: previews/<model>/vs_<ref>.png with paired turntable rows (reference above
the model; from below, eye level, above), the top fifth (head region) close up, and the silhouette difference per
view, and both at gameplay distance (30 m and 60 m at a 60 degree vertical FOV on a 1080 px screen, the style’s
outline if it has one, upscaled nearest; an orthographic render at that scale). --ref-height scales the reference to that height (default: its own units). Metrics (printed, and in --json
under compare): iou (mean silhouette IoU of 8 views: front, front-left, left, back-left at +10 degrees, front and
left at +50, top, below at -30; 1.0 = same outline), iou_min, iou_views, triangle_ratio (model / reference),
and for model and reference: triangles, surfaces (glTF primitives drawn = draw calls; for the model what
kiln build writes), materials, colors (distinct base colours on faces; a textured reference counts each face’s
sampled colour), facet_regularity (share of surface area in triangles with an edge neighbour facing the same way
within 2 degrees: flat designed facets near 1, smooth or noisily decimated surfaces near 0), open_edges (cracks),
size (metres). For restyles (where proportions change on purpose):
band_iou (mean of band_ious: silhouette IoU per body band in 4 side views at 0 degrees, each band cropped, scaled to
one height and centred, so a big head on a short body still matches; bands = humanoid (legs/torso/head cut at
the hips and head joints: a skeleton: humanoid model against a reference with head and hips nodes) or thirds
(bottom/middle/top)), mass / mass_ref (visual mass: silhouette area / its bounding box, mean of the 8 views)
and mass_ratio (model / reference), screen_px (height in pixels at 30 m and 60 m, model and reference). All
deterministic. Without a path, --vs shows the style’s inspiration images.
Build output
<output_dir>/models/<name>.glb (+ .<variant>.glb, .lod<N>.glb), with “godot”: models/<name>.tscn (glb +
style materials) and looks/<style>.tscn (WorldEnvironment + Sun), manifest.json, CREDITS.md (imports).
Manifest per model: glb, godot_scene, class, style, status, triangles, bounds and size (metres),
skin, parts (parent, pivot, meta, separate, node_path: glTF node names from the root, aabb: the part’s box
in model space, rest pose, for hit zones), animations (gltf_name, fps, frames, loop, interpolation, duration),
variants (swap, glb unless variant_glbs = false, godot_scene), lods, colors, source (imports), effect,
humanoid (each joint’s KayKit, Mixamo, Godot name), godot_bonemap (with godot_bonemap = true: the BoneMap file);
parts also have sockets, animations a start (the parts their first frame moves, e.g. an arm built level but
cocked when the animation starts). With vertex_colors = true, a game loading the .glb itself must set
vertex_color_use_as_albedo = true on the imported materials (KILN’s .tscn scenes do it); variants are then swap
tables for the game (or variant_glbs = true). KILN’s .tscn scripts share their materials between instances and
leave any surface override the game set before add_child alone.
Palette atlas (atlas = true; not with vertex_colors): every (colour, material preset) pair gets a 4x4-pixel
cell in models/<name>.palette.png (colours), <name>.material.png (glTF metallicRoughness: G = roughness,
B = metallic), <name>.glow.png (emission, only when a preset glows) and, with “godot”, <name>.rim.png (Godot rim
light: R = rim, G = tint). The glb references them by URI (next to it) and every face’s UVs sit on its cell’s centre;
samplers are NEAREST without mipmaps. One material for the whole model: 1 surface per mesh (2 where a decal is), soft
or rigid skins alike (rigid parts are nodes sharing the material). A variant is a texture swap:
<name>.<variant>.palette.png (+ .glow.png), same cells, same mesh; its Godot scene is the base glb with the
variant’s textures (variant glbs are still written unless variant_glbs = false). Manifest per model: atlas
(palette, material, glow, rim, size, cell, cells: color, material, uv, pixel; ao, ao_strength), variants
also palette (+ glow): to recolour at runtime, write other colours into a copy of the palette at the cells’
pixels. atlas_ao = true bakes ambient occlusion from the model’s distance field (imported models: from the mesh’s
dents) into COLOR_0 (a number sets its strength: the darkest corner is x (1 - strength); true = 0.65), a grey multiplier glTF viewers apply; in Godot only KILN’s .tscn scenes use it
(vertex_color_use_as_albedo). Previews don’t show it. When the output is inside a Godot project (with “godot” or
not), KILN writes the palette textures’ .import settings (lossless, no mipmaps, no VRAM compression on first 3D use: 4x4 cells stay exact); settings Godot adds
later (uid, imported files) are kept.
Shared clips: animations_only: true makes a model a clip carrier (its animations are what counts; give it the
same rig as the models that use it); animation_library: <carrier> on another model plays the carrier’s animations
(every part id its clips move must be in the model, E008 otherwise; other ids may differ, e.g. a helmet on one loadout:
they follow their parents, and tracks for carrier parts the model lacks are left out; shared joints more than 1 cm from
the carrier’s: W114, since a clip’s position tracks put bones where the carrier has them; same skeleton). That model’s glb carries no clips; previews and the manifest’s animations still
list them, plus animation_library (model, glb). Inside a Godot project the carrier’s glb is imported as an
AnimationLibrary (no .tscn); add it to each model’s AnimationPlayer.
kiln build --verify-godot checks everything in a throwaway copy of the output (nothing else in the game’s project is
imported or touched). Attachments (fit_to) are hung on their bone of a copy of the character through a
BoneAttachment3D and must land where they were made.
Other engines: everything but the .tscn files, .import sidecars and <name>.rim.png is plain glTF + JSON. Notes per
engine (axes, scale, vertex colours, palette texture filtering, skins, clips, LODs, approximating the look):
https://github.com/lukasgolino/KILN/blob/main/docs/engines.md
In Godot:
var pilot: Node3D = load("res://assets/kiln/models/pilot.tscn").instantiate()
add_child(pilot)
pilot.get_node("AnimationPlayer").play("walk")
add_child(load("res://assets/kiln/looks/soft_toy.tscn").instantiate()) # once per scene: the style's light