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.
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:
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 aUIScreencan'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:
shadowsQualityon the sun (0 = none, 3 = softest),castShadowson whatever throws them,receiveShadowson whatever shows them. Miss one and there's no shadow. Mesh.planestands upright by default, facing the camera. A floor isnormal: [0, 1, 0]. All primitives are unit-sized (a 1×1×1 box, a sphere 1 across): size them withsizeon a box orscaleon any node.- The sun's
directionis 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:
positionplaces 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):
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 domesh.material = Material.lit(...)in a loop: mutate the one you already have. - A texture is
Material.lit({ map: await Texture.load(asset("./crate.jpg")) }); acolornext to amaptints it. Loading assets is the next step's topic. - 3D colors are hex strings (
"#ff4032","#ff403280"with alpha) or ints like0xff4032.rgba(...)and CSS names are not parsed and silently come out black. setLoop(dt => …)is the frame loop,dtin seconds. Scale anything that moves by it. It's the same loop as in the UI track — here it's the main tool.
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:
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.scalereturn copies:tree.position.x = 3silently does nothing. Usetree.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: falsegives the faceted low-poly look..scale()and.translate()mutate the buffers — do it beforeMesh.from(geo), after that the geometry is on the GPU.- One
Materialfor 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.
// 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
Sceneis the 3D engine's screen: one active at a time,open()/close(), and a HUD over it is aUIWidget.- Light = the default IBL + one
Light.sun(); shadows needshadowsQuality+castShadows+receiveShadows. Mesh.box/sphere/cylinder/planeare unit primitives; a floor is aplanewithnormal: [0, 1, 0].Material.lit(roughness/metallic) andMaterial.unlit; a material is a shared object — mutate it.Nodebuilds hierarchy withadd, transforms come back as copies,Geometryis edited beforeMesh.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.