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. An entity carries an LodGroup instead of a MeshRenderer.
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. |
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. Textured pipeline only. |
emissiveFactor |
color | transparent | Multiplies the emissive texture. Textured pipeline only. |
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. |
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.
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 |
Picks one level per frame by camera distance. |
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
PbrMaterial and SkinnedPose fill the same per-draw uniform slot, for different vertex formats.
An entity carries one or the other, not both.
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.
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.