Real stories:How a football-school coach shipped his apps in one week soon

Meshes, materials, light

Step 1 of the 3D track. Over five steps you'll build a yard: a clearing with trees and crates, a fox from a GLB model running across it — you'll drive the camera, hit objects with a finger, switch on physics, and finally stand the same scene on a real table with AR. This step is what a scene is made of: meshes, materials, light and the node hierarchy.

Three conventions that hold everywhere in LeCodes 3D: units are meters, Y is up, angles are degrees. The camera looks down −Z. Every class is a global — nothing to import.

Note

3D examples are heavier than the UI ones: the engine loads once, then scenes swap quickly. In the narrow layout the phone under an example boots on the ▶ button.

A scene, a light, a first mesh

A minimal scene is four moves: create a Scene, add a sun, lay down a floor and an object, place the camera. Then open() — just like a UIScreen:

TypeScript
const scene = new Scene({ skybox: "#1b2233" })

scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))

scene.add(Mesh.plane({
  material: Material.lit({ color: "#2f4a35", roughness: 1 }),
  normal: [0, 1, 0], scale: 20, receiveShadows: true,
}))

scene.add(Mesh.box({
  material: Material.lit({ color: "#b5854b" }),
  position: [0, 0.5, 0], castShadows: true,
}))

scene.camera.position = [3.5, 3, 4]
scene.camera.lookAt([0, 0.5, 0])

scene.open()

What matters here:

  • A scene is the 3D engine's "screen". Only one can be active: open() on a second one closes the first, and a UIScreen can't be opened over a scene — it would cover it. HUDs over a scene are below.
  • Lighting has two parts. Ambient light (IBL) is in every scene by default — that's why objects aren't black without lamps. Light.sun() is the one directional light; it gives surfaces direction and casts shadows. There are no point or spot lights — glows are done with materials.
  • Shadows are an agreement between three sides: shadowsQuality on the sun (0 = none, 3 = softest), castShadows on whatever throws them, receiveShadows on whatever shows them. Miss one and there's no shadow.
  • Mesh.plane stands upright by default, facing the camera. A floor is normal: [0, 1, 0]. All primitives are unit-sized (a 1×1×1 box, a sphere 1 across): size them with size on a box or scale on any node.
  • The sun's direction is where the light travels, not where it hangs. [-1, -2, 0.7] comes from above, from the left and slightly from the front, so the shadow falls toward the viewer.
  • The camera is an ordinary node: position places it, lookAt(point) aims it.

Materials

What a surface looks like is a Material. Two carry most of the work: Material.lit() is physically based (PBR) and responds to light; Material.unlit() is flat color that ignores light entirely. On a lit material two numbers set the character of the surface — roughness (0 = mirror, 1 = matte) and metallic (0 = dielectric, 1 = metal):

TypeScript
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({
  material: Material.lit({ color: "#2f4a35", roughness: 1 }),
  normal: [0, 1, 0], scale: 20, receiveShadows: true,
}))

// Four spheres, four materials. lit is PBR: roughness 0 = mirror, 1 = matte;
// metallic 1 = metal. unlit doesn't respond to light at all.
const looks = [
  Material.lit({ color: "#FF4032", roughness: 0.9 }),
  Material.lit({ color: "#FF4032", roughness: 0.15 }),
  Material.lit({ color: "#c9c9d1", roughness: 0.3, metallic: 1 }),
  Material.unlit({ color: "#FF4032" }),
]
looks.forEach((material, i) => {
  const x = i % 2 ? 0.6 : -0.6, z = i < 2 ? 0.6 : -0.6
  scene.add(Mesh.sphere({ material, radius: 0.4, position: [x, 0.4, z], castShadows: true }))
})

// A material is a live object: change its properties, don't build a new one each frame
const crate = Mesh.box({ material: Material.lit({ color: "#b5854b" }), position: [0, 0.5, -2.4], castShadows: true })
scene.add(crate)
let t = 0
setLoop(dt => {
  t += dt
  crate.material.color = t % 2 < 1 ? "#b5854b" : "#5e9eff"
})

scene.camera.position = [0, 3.2, 4.5]
scene.camera.lookAt([0, 0.3, -0.6])
scene.open()

Matte, glossy, metal and flat unlit — the same red, four different feelings. Note that the unlit sphere still casts a shadow: shadows are a mesh flag, not a material one.

  • A material is an object and a mesh points at it. Hand one material to a dozen meshes and a single material.color = … repaints them all. By the same token, never do mesh.material = Material.lit(...) in a loop: mutate the one you already have.
  • A texture is Material.lit({ map: await Texture.load(asset("./crate.jpg")) }); a color next to a map tints it. Loading assets is the next step's topic.
  • 3D colors are hex strings ("#ff4032", "#ff403280" with alpha) or ints like 0xff4032. rgba(...) and CSS names are not parsed and silently come out black.
  • setLoop(dt => …) is the frame loop, dt in seconds. Scale anything that moves by it. It's the same loop as in the UI track — here it's the main tool.

Full contract: Material, Texture, Light.

Hierarchy & geometry

A tree in the yard isn't one mesh but a small assembly: a cylinder trunk and a cone crown under a shared parent. The parent is an empty Node — move or rotate it and the children come along. The crown gets its shape from Geometry: a primitive can be scaled and shifted before a mesh is built from it, which is how the cone ends up with its pivot at the base:

TypeScript
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({
  material: Material.lit({ color: "#2f4a35", roughness: 1 }),
  normal: [0, 1, 0], scale: 20, receiveShadows: true,
}))

const trunk = Material.lit({ color: "#6b4a2b", roughness: 1 })
const leaves = Material.lit({ color: "#3d8b4f", roughness: 1 })

// A tree is an empty Node "spine" with two children. Move or rotate the spine
// and the children follow. The crown is a cone: a cylinder with radiusTop 0,
// shifted so its base sits on top of the trunk.
function Tree(x: number, z: number, height = 1.6) {
  const tree = new Node()
  tree.position = [x, 0, z]
  const crown = Geometry.cylinder({ radiusTop: 0, radiusBottom: 0.55, edges: 8, smooth: false })
    .scale(1, height, 1)
    .translate(0, height / 2 + 0.6, 0)
  tree.add(
    Mesh.cylinder({ material: trunk, radius: 0.12, scale: [1, 0.7, 1], position: [0, 0.35, 0], castShadows: true }),
    Mesh.from(crown, { material: leaves, castShadows: true }),
  )
  return tree
}

const trees = [Tree(-1.6, 0.4), Tree(1.4, -0.9, 2), Tree(0.2, 1.4, 1.2)]
scene.add(...trees)

// Transforms are copies: node.eulerAngles.y += 10 does nothing.
// Read, compute, assign back — or use the scalar x/y/z setters.
setLoop(dt => {
  for (const tree of trees) tree.eulerAngles = tree.eulerAngles.add([0, 40 * dt, 0])
})

scene.camera.position = [0, 3.5, 7]
scene.camera.lookAt([0, 0.8, 0])
scene.open()
  • Two different "add"s. node.add(child) builds the hierarchy — the child inherits the parent's transform. scene.add(node) registers a node in the world so it draws. Add the assembly's root to the scene; children under it draw on their own.
  • A transform is a value, not a reference. tree.position, tree.eulerAngles, tree.scale return copies: tree.position.x = 3 silently does nothing. Use tree.x = 3 (the scalar accessors) or read → compute → assign, like the loop above. This holds for every node, camera included.
  • Geometry.cylinder({ radiusTop: 0 }) is a cone; smooth: false gives the faceted low-poly look. .scale() and .translate() mutate the buffers — do it before Mesh.from(geo), after that the geometry is on the GPU.
  • One Material for every trunk and one for every crown is exactly right: materials are shared, meshes aren't.

The whole screen

The yard assembled: a clearing, five trees, a stack of crates, a sun with soft shadows, and a camera slowly orbiting the center. The caption on top is a UIWidget — the only way to put UI over a scene.

TypeScript
// The yard: a clearing, trees, crates and a slowly orbiting camera.
// Every next step of the track adds something to it.
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))

const lit = (color: string, roughness = 1) => Material.lit({ color, roughness })
const trunk = lit("#6b4a2b"), leaves = lit("#3d8b4f"), wood = lit("#b5854b", 0.8)

scene.add(Mesh.plane({ material: lit("#2f4a35"), normal: [0, 1, 0], scale: 24, receiveShadows: true }))

function Tree(x: number, z: number, height = 1.6) {
  const tree = new Node()
  tree.position = [x, 0, z]
  const crown = Geometry.cylinder({ radiusTop: 0, radiusBottom: 0.55, edges: 8, smooth: false })
    .scale(1, height, 1).translate(0, height / 2 + 0.6, 0)
  tree.add(
    Mesh.cylinder({ material: trunk, radius: 0.12, scale: [1, 0.7, 1], position: [0, 0.35, 0], castShadows: true }),
    Mesh.from(crown, { material: leaves, castShadows: true }),
  )
  return tree
}
function Crate(x: number, z: number, size = 0.8, lift = 0) {
  return Mesh.box({ material: wood, size, position: [x, lift + size / 2, z], eulerAngles: [0, x * 30, 0], castShadows: true })
}

scene.add(
  Tree(-2.4, -1), Tree(2.6, -1.4, 2), Tree(-1.8, 2.2, 1.3), Tree(2.2, 1.6, 1.4), Tree(0.4, -3, 2.2),
  Crate(0.6, 0.4), Crate(0.6, 0.4, 0.55, 0.8), Crate(-0.6, 1.1, 0.6),
)

// The camera is a Node too: put it on an orbit around the middle of the yard
let angle = 0.6
setLoop(dt => {
  angle += 0.15 * dt
  scene.camera.position = [Math.sin(angle) * 7, 3.5, Math.cos(angle) * 7]
  scene.camera.lookAt([0, 0.6, 0])
})

// A caption over the scene is a UIWidget — a UIScreen would cover the scene
const hud = UIWidget(
  UIText("The yard").style({ fontSize: 22, fontWeight: 800, color: "#ffffff" }),
  UIText("Step 1: meshes, materials, light").style({ fontSize: 13, color: "#c7ccd6" }),
).style({ top: "max(safe-top, 16px)", left: 16, gap: 2 })
hud.attachTo(scene)

scene.open()
hud.show()

hud.attachTo(scene) binds the widget to the scene's layer: it appears and disappears with it; call show() after open(). A widget is positioned absolutely (top/left/right/bottom) and the safe-area keywords work. Inside it goes any UI from the UI track, buttons included — which is exactly what the next step is about.

The Tree() and Crate() factory functions are the same move as HabitRow() in the UI track: a scene is assembled from small named pieces, not from a wall of Mesh.box(...) calls.

What you learned

  • Scene is the 3D engine's screen: one active at a time, open()/close(), and a HUD over it is a UIWidget.
  • Light = the default IBL + one Light.sun(); shadows need shadowsQuality + castShadows + receiveShadows.
  • Mesh.box/sphere/cylinder/plane are unit primitives; a floor is a plane with normal: [0, 1, 0].
  • Material.lit (roughness/metallic) and Material.unlit; a material is a shared object — mutate it.
  • Node builds hierarchy with add, transforms come back as copies, Geometry is edited before Mesh.from.
  • setLoop(dt) is the frame loop, in seconds.

Reference: Scene · Mesh · Geometry · Material · Light · Node · Conventions.

Next: Models & animation — a fox walks into the yard.