Meshes and materials
Draw geometry: one mesh per entity with a surface material, many copies in one draw call, detail levels that swap with distance, and the culling that skips what the camera cannot see.
component: mesh_renderer
component: pbr_material
Vulkan
WebGPU
Desktop · Android · iOS · Web
A scene document and the scene DSL are two ways to write the same ECS components. The draw
components live in awake:scene:scene3d; RenderSystem3D turns them into draws for whichever
backend runs the app.
Draw a mesh
A mesh_renderer pairs a mesh with a material. A pbr_material next to it sets how metallic and
rough the surface looks.
{
"version": 1,
"nodes": [
{
"name": "crate",
"transform": { "position": { "x": 0.0, "y": 0.5, "z": 0.0 } },
"components": [
{
"component": "mesh_renderer",
"mesh": "cube",
"material": "lit-shadow",
"cullMode": "Back"
},
{
"component": "pbr_material",
"metallic": 0.1,
"roughness": 0.8
}
]
}
]
}
- In the Hierarchy, click Add entity and choose 3D Primitives: Cube (or Sphere,
Plane). Studio saves it as a
mesh_rendererwith meshcubeand materiallit-shadow. - In the Mesh Renderer section of the Inspector, set Visible, Transparent and Cull.
Studio's Add component menu does not offer PBR Material. When an entity already has one, its Inspector section edits Metallic and Roughness.
Both forms build equal components.
Name the meshes and materials
In a scene document, mesh and material are names. The app's assets { } block says what each
name builds, and a name nobody registered fails when the scene loads.
/** Registers the names `mesh_renderer` uses: a mesh called "cube" and a material called "lit-shadow". */
fun SceneAppDsl.crateAssets() {
assets {
mesh("cube", generate { cube(size = 1f, colored = true) })
material("lit-shadow") { renderer.createMaterial(LitShadowUniformLayout) }
}
}
builtInSceneAssets() from awake:project:runtime registers the names AwakeKt Studio uses: the
meshes cube, sphere, ground and plane, and the material lit-shadow. A mesh name ending in
.gltf or .glb resolves through the glTF resolver; see glTF models.
In the scene DSL you can also build meshes and materials yourself and pass them to meshRenderer,
as long as the render plan has a pipeline for the mesh's vertex format.
Draw many copies
InstancedMeshRenderer draws one mesh many times in a single draw call. Each matrix is a full
world transform; the entity's own transform is not applied.
world.scene {
entity("crates") {
// One draw call; each matrix places one copy in world space.
with(InstancedMeshRenderer(cube, lit, List(25) { i -> Mat4().translate(i % 5 * 2f, 0f, i / 5 * 2f) }))
}
}
The render plan needs a PipelineKey.Instanced pipeline for this; see
Render plans and shaders.
Swap detail with distance
An LodGroup holds one LodLevel per detail level. Each frame the renderer picks a level by the
entity's distance from the camera, or by its size on screen. An entity carries an LodGroup
instead of a MeshRenderer, or next to one, which then draws the chosen level with its own
material. To cook levels into your models and decide per entity which keep their detail, see
Level of detail.
world.scene {
entity("tower") {
transform(x = -4f)
// Finest level first; past the last threshold the coarsest still draws.
with(LodGroup(listOf(LodLevel(detailed, lit, maxDistance = 20f), LodLevel(coarse, lit, maxDistance = 60f))))
with(MeshBounds(Aabb(Vec3f(-0.5f, -0.5f, -0.5f), Vec3f(0.5f, 0.5f, 0.5f))))
}
}
Cull what the camera cannot see
- Frustum culling. An entity with
MeshBoundsis skipped when its bounds are outside the camera's view. An entity without it is always drawn. - Occlusion culling. An entity with an
Occluderhides any entity withMeshBoundsthat lies entirely inside its screen rectangle and farther from the camera. One occluder must cover the whole entity; several partly covering it do not count. It runs only when at least one entity has anOccluder.
world.scene {
entity("wall") {
transform(z = 3f, sx = 6f, sy = 3f, sz = 0.2f)
meshRenderer(cube, lit, CullMode.Back)
with(Occluder(Aabb(Vec3f(-0.5f, -0.5f, -0.5f), Vec3f(0.5f, 0.5f, 0.5f))))
}
}
Both boxes are in the entity's local space, before its transform.
Properties
mesh_renderer:
| Property | Type | Default | What it does |
|---|---|---|---|
mesh |
string | required | The mesh name, resolved by the app's assets { }. |
material |
string | required | The material name, resolved the same way. |
cullMode |
None · Back · Front |
None |
Which triangle faces to skip. Back suits a solid, correctly wound mesh. |
transparent |
boolean | false |
Draws in the transparent pass: blended by the material's alpha, sorted back to front, no depth write. |
additive |
boolean | false |
With transparent, adds its colour to what is behind it instead of covering it: glows, fire, light shafts. A textured mesh then draws unlit, so its black texels add nothing. |
castsShadows |
boolean | true |
Draws the mesh into the shadow maps, in view or off screen. false for a decal, a ground card, a small prop or distant filler; the mesh still draws and still receives shadows. A transparent mesh never casts. |
The ECS MeshRenderer also has visible (default true; false skips the draw) and
vertexAnimation, a shader-defined vertex effect that is off at zero. Neither is saved in a scene
document.
pbr_material:
| Property | Type | Default | What it does |
|---|---|---|---|
metallic |
number, 0 to 1 | 0 |
0 is a dielectric, 1 is metal. |
roughness |
number, 0 to 1 | 0.5 |
0 is smooth, 1 is rough. |
baseColorFactor |
color | white | Multiplies the base colour texture or vertex colour. Textured and skinned pipelines. |
emissiveFactor |
color | transparent | Emitted light, multiplied by the emissive texture when textured. Textured and skinned pipelines. |
alphaMode |
Opaque · Masked |
Opaque |
Masked discards pixels whose alpha is below alphaCutoff. |
alphaCutoff |
number, 0 to 1 | 0.5 |
The Masked threshold. |
litWhenAdditive |
boolean | false |
Keeps an additive textured mesh lit. Textured pipeline only. |
unlit |
boolean | false |
Draws baseColorFactor * texture * vertexColor as stored, skipping lighting, tone mapping and fog. Textured and skinned pipelines. |
An additive textured mesh is light added to the scene, so by default it is not shaded: it adds its
base and emissive colour, the same in sun and shadow, which suits fire, sparks and glows. For the
rare surface that should still take the scene's light, set litWhenAdditive. It then adds the colour
the sun, its shadows, the ambient and point lights give it, as an opaque surface of that material
shows. It is still added, never covering what is behind it, and fog fades it out rather than tinting
it, because the fog is already behind it. A black texel adds nothing unless a light's specular
highlight lands on it.
An unlit material skips lighting, tone mapping and fog on textured (Textured, InstancedTextured)
and single-draw skinned (Skinned, SkinnedTextured) meshes, writing baseColorFactor * texture *
vertexColor as stored while keeping alphaMode masking and output encoding. Use it for hand-painted
or pre-baked textures and character skin parts whose colours are already authored in display space.
texture_animation, a component of its own beside the material, plays the texture as a frame sheet
and scrolls it. columns and rows (default 1) describe the sheet, read left to right, then top to
bottom. frameCount (0 means every cell from firstFrame on) and framesPerSecond (0 holds the
first frame of the run) play it. scrollU and scrollV scroll the UVs, in UV units per second.
firstFrame is the cell a run starts at, counted from 0 in that reading order. One sheet can hold
several animations, an idle and a walk, and each entity plays its own:
{ "component": "texture_animation", "columns": 8, "rows": 4, "firstFrame": 8, "frameCount": 8, "framesPerSecond": 12 }
That plays the second row of an eight-column sheet, cells 8 to 15, twelve frames a second. With
framesPerSecond 0 it shows firstFrame and nothing else, which is how an entity shows one chosen cell.
texture_clips cuts a sheet into named clips and plays one of them, for a sprite that changes what it
is doing. A clip is a run of cells (firstFrame, frameCount), a rate (framesPerSecond) and
whether it repeats (loop):
{
"component": "texture_clips", "columns": 8, "rows": 4, "clip": "idle",
"clips": {
"idle": { "firstFrame": 0, "frameCount": 4, "framesPerSecond": 6 },
"walk": { "firstFrame": 8, "frameCount": 8, "framesPerSecond": 12 },
"attack": { "firstFrame": 16, "frameCount": 6, "framesPerSecond": 16, "loop": false }
}
}
texture_animation is played by the GPU on the renderer's clock. These clips are stepped on the CPU by
the scene's own clock, so a game can choose among them and a paused game holds still. From a system,
world.get<TextureClips>(entity)?.play("walk") switches clip. Asking for the clip that is already
playing does nothing, so the line can run every frame. A clip with loop false holds its last cell
and TextureClips.isFinished is true from then. clip is the one that plays when the scene loads;
left out, the first listed plays. Give an entity texture_clips or texture_animation, not both.
Kotlin-only components:
| Component | Fields | What it does |
|---|---|---|
InstancedMeshRenderer |
mesh, material, transforms: List<Mat4> |
Draws mesh once per matrix, in one draw call. |
LodGroup |
levels: List<LodLevel>, hysteresis = 0.1, castsShadows = true |
Picks one level per frame by camera distance. castsShadows works as on mesh_renderer. |
LodLevel |
mesh, material, maxDistance |
Draws while the entity is at most maxDistance away. |
MeshBounds |
localBounds: Aabb |
Lets the entity be frustum-culled. |
Occluder |
localBounds: Aabb |
Hides MeshBounds entities it fully covers on screen. |
How it works
RenderSystem3D is one of the three systems every scene gets by default. Each frame it finds the
primary camera, collects MeshRenderer, InstancedMeshRenderer, LodGroup, particle and skinned
components, culls them, and hands the draws to the renderer. A mesh draws through the render plan's
pipeline for its vertex format: the primary pipeline for the primary's own format, the plan's
scenePipelines for the others. A format with no pipeline is not drawn.
metallic and roughness mean two things. The primary lit pipeline uses them as the surface's only
values. The textured pipeline multiplies them into its metallic-roughness texture, as glTF's
metallicFactor and roughnessFactor do.
LodGroup levels must be sorted finest first. The first level whose maxDistance covers the
distance draws; past the last threshold, the coarsest level still draws, so LOD never hides an
entity. hysteresis is a band, as a fraction of each threshold, in which the level already showing
keeps showing, so a camera near a threshold does not flicker between levels.
A scene DSL mesh is never frustum-culled on its own
When a scene document's mesh_renderer loads, the entity also gets MeshBounds from the
mesh's bounds, if the backend reports them. meshRenderer() in the scene DSL does not add
MeshBounds, so add it yourself to let the entity be culled.
One LodGroup per entity
An LodGroup remembers which level it last drew. Entities that share one instance share that
memory. Give each entity its own.
Instancing is for static meshes
InstancedMeshRenderer has no per-instance joint palette, so a skinned mesh cannot be instanced
with it. Use InstancedSkinnedMeshRenderer; see Animation.
PbrMaterial and skinning
A single-draw skinned entity (Skinned, SkinnedTextured) carries both SkinnedPose and
PbrMaterial: RenderSystem3D packs baseColorFactor, emissiveFactor, alphaMode,
alphaCutoff and unlit alongside the joint palette. metallic, roughness and
litWhenAdditive apply to static meshes.
Debugging
RenderDiagnostics holds the last frame's counters: frustumCulled, occluded,
submittedDrawCalls, submittedInstances, and unresolvedDrawCalls (draws the backend could not
match to a pipeline or resource). RenderSystem3D also exposes lastFrustumCulledCount and
lastOccludedCount.
Find the draws that do not fold
Copies of one mesh and material that differ only in placement fold into one instanced draw on
their own, with no InstancedMeshRenderer. Set RenderDiagnostics.drawStatsEnabled = true, or
turn on the scene runtime's perf stats (F2, perfStatsEnabled), and RenderDiagnostics.drawStats
holds the last planned frame's SceneDrawStats:
- draws per pass:
opaqueDrawsandtransparentDrawsin the main pass,shadowDrawsacrossshadowPasses(shadowPassDraws(index)for each cascade or point-shadow face),maskDraws; - what folded:
foldedDrawsinstanced draws carryingfoldedCopiesscene draws; - what did not:
misses(reason)for eachInstanceFoldMiss.
| Reason | What it means | What folds it |
|---|---|---|
SingleCopy |
Nothing else draws its mesh. | Nothing to fold. |
Blended |
Copies drawn transparent. | Blended draws keep their order; none fold. |
Masked |
Alpha-masked copies of a format with no instanced pipeline. | An instanced pipeline for the format. |
VertexFormat |
Copies of a format with no instanced pipeline. | A PipelineKey.InstancedFormat pipeline. |
CullMode |
The format instances, but not with this cull mode. | Front-culled copies never fold. |
DistinctMaterial |
Copies of the mesh, each with its own material. | One material name per glTF material. |
DistinctUniforms |
Same mesh and material, different factors or state. | Equal PbrMaterial factors, or one shared instance. |
SkinnedNotInstanced |
Posed skinned copies. | InstancedSkinnedMeshRenderer. |
Counts are of draws after folding: one instanced draw of 400 copies is one draw. The renderer's own
frameStats is the backend's total across every pass, image passes and UI included. Counting is
off by default and costs nothing while it is off.
See also
- Lights and shadows for lighting what you draw.
- glTF models for meshes and materials from model files.
- Render plans and shaders for the pipelines each vertex format needs.