Skip to content

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.

crate.scene.json
{
  "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
        }
      ]
    }
  ]
}
Kotlin
onReady {
    world.scene {
        entity("crate") {
            transform(y = 0.5f)
            meshRenderer(requireMesh("cube"), requireMaterial("lit-shadow"), CullMode.Back)
            with(PbrMaterial(metallic = 0.1f, roughness = 0.8f))
        }
    }
}
  1. In the Hierarchy, click Add entity and choose 3D Primitives: Cube (or Sphere, Plane). Studio saves it as a mesh_renderer with mesh cube and material lit-shadow.
  2. 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.

Kotlin
/** 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.

Kotlin
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.

Kotlin
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 MeshBounds is skipped when its bounds are outside the camera's view. An entity without it is always drawn.
  • Occlusion culling. An entity with an Occluder hides any entity with MeshBounds that 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 an Occluder.
Kotlin
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.

{ "component": "pbr_material", "roughness": 1, "litWhenAdditive": true }

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