Меши, материалы, свет
Первый шаг 3D-трека. За пять шагов ты соберёшь двор: поляну с деревьями и ящиками, по которой бегает лиса из GLB-модели, — научишься управлять камерой и попадать пальцем в объекты, включишь физику и в конце поставишь ту же сцену на стол через AR. Этот шаг — про то, из чего сцена состоит: меши, материалы, свет и иерархия узлов.
Три договорённости, которые действуют во всём 3D LeCodes: единицы — метры, Y — вверх, углы — в градусах. Камера смотрит вдоль −Z. Все классы — глобалы, импортировать нечего.
3D-примеры тяжелее UI-шных: движок грузится один раз, дальше сцены сменяются быстро. В узкой раскладке телефон под примером запускается по кнопке ▶.
Сцена, свет, первый меш
Минимальная сцена — четыре действия: создать Scene, добавить солнце, положить пол и предмет, поставить камеру. И open() — как у 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()Что здесь важно:
- Сцена — это «экран» 3D-движка. Активной может быть только одна:
open()второй закроет первую, аUIScreenповерх сцены открыть нельзя — он её накроет. Про HUD над сценой — ниже. - Освещение состоит из двух частей. Рассеянный свет (IBL) есть в каждой сцене по умолчанию — поэтому предметы не чёрные даже без ламп.
Light.sun()— единственный источник направленного света; он даёт объём и тени. Точечных и прожекторных ламп нет — глоу и «свечение» делают материалами. - Тени — договор трёх сторон:
shadowsQualityу солнца (0 — без теней, 3 — самые мягкие),castShadowsна том, кто отбрасывает, иreceiveShadowsна том, кто принимает. Забудешь любую — тени не будет. Mesh.planeпо умолчанию стоит вертикально, лицом к камере. Пол — этоnormal: [0, 1, 0]. Все примитивы единичного размера (куб 1×1×1, шар диаметром 1): размер задаётsizeу куба илиscaleу любого узла.directionу солнца — куда летит свет, а не где оно висит.[-1, -2, 0.7]— свет падает сверху, слева и чуть спереди, поэтому тень ложится к зрителю.- Камера — обычный узел:
positionставит её,lookAt(point)разворачивает.
Материалы
Как выглядит поверхность, решает Material. Их два основных: Material.lit() — физически корректный (PBR), реагирует на свет; Material.unlit() — плоский цвет, свет игнорирует. У lit два числа задают характер поверхности — roughness (0 — зеркало, 1 — мат) и metallic (0 — диэлектрик, 1 — металл):
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,
}))
// Четыре шара, четыре материала. У lit — PBR: roughness 0 = зеркало, 1 = мат;
// metallic 1 = металл. unlit не реагирует на свет вообще.
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 }))
})
// Материал — живой объект: меняй его свойства, а не создавай новый каждый кадр
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()Матовый, глянцевый, металл и плоский unlit — тот же красный, четыре разных ощущения. Заметь, что unlit-шар всё равно отбрасывает тень: тени — свойство меша, а не материала.
- Материал — объект, а меш на него ссылается. Один материал можно раздать десятку мешей — и перекрасить их все одной записью
material.color = …. Ровно так же не стоит делатьmesh.material = Material.lit(...)в цикле: меняй свойства того, что уже есть. - Текстура —
Material.lit({ map: await Texture.load(asset("./crate.jpg")) });colorрядом сmapподкрашивает её. Про загрузку ассетов — в следующем шаге. - Цвета в 3D — hex-строки (
"#ff4032","#ff403280"с альфой) или числа0xff4032.rgba(...)и имена цветов не разбираются и молча дают чёрный. setLoop(dt => …)— покадровый цикл,dtв секундах. Всё, что движется, умножай наdt. Это тот же цикл, что и в UI-треке, — здесь он главный инструмент.
Иерархия и геометрия
Дерево во дворе — не один меш, а маленькая сборка: ствол-цилиндр и крона-конус под общим родителем. Родитель — пустой Node: сдвинул или повернул его — поехали все дети. А форму крона получает из Geometry: примитив можно масштабировать и сдвигать до того, как из него сделан меш, — так у конуса появляется «пивот» в основании:
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 })
// Дерево — пустой Node-«стержень» с двумя детьми. Двигаешь и вращаешь стержень —
// дети едут с ним. Крона — конус: цилиндр с radiusTop 0, сдвинутый так,
// чтобы его основание стояло на макушке ствола.
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)
// Трансформы — копии: node.eulerAngles.y += 10 ничего не сделает.
// Читай, считай, присваивай обратно — или используй скалярные x/y/z.
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()- Два разных «add».
node.add(child)строит иерархию — ребёнок наследует трансформ родителя.scene.add(node)регистрирует узел в мире, чтобы он рисовался. В сцену добавляют корень сборки; дети под ним рисуются сами. - Трансформ — значение, а не ссылка.
tree.position,tree.eulerAngles,tree.scaleвозвращают копии:tree.position.x = 3молча ничего не делает. Правильно —tree.x = 3(скалярные аксессоры) или «прочитал → посчитал → присвоил», как в цикле выше. Это касается всех узлов, включая камеру. Geometry.cylinder({ radiusTop: 0 })— конус;smooth: falseдаёт гранёный low-poly вид..scale()и.translate()меняют буферы — делай это доMesh.from(geo), потом геометрия уже загружена в GPU.- Один и тот же
Materialу всех стволов и всех крон — так и надо: материалы разделяются, меши — нет.
Экран целиком
Собираем двор: поляна, пять деревьев, стопка ящиков, солнце с мягкими тенями и камера, которая медленно кружит вокруг центра. Подпись сверху — UIWidget: единственный способ положить UI поверх сцены.
// Двор: поляна, деревья, ящики и медленно кружащая камера.
// Каждый следующий шаг трека добавляет сюда что-то своё.
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),
)
// Камера — тоже Node: ставим её на орбиту вокруг центра двора
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])
})
// Подпись над сценой — UIWidget: UIScreen открывать нельзя, он закроет сцену
const hud = UIWidget(
UIText("Двор").style({ fontSize: 22, fontWeight: 800, color: "#ffffff" }),
UIText("Шаг 1: меши, материалы, свет").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) привязывает виджет к слою сцены: он появляется и исчезает вместе с ней; show() — после open(). Позиционируется виджет абсолютно (top/left/right/bottom), ключевые слова безопасных зон работают. Внутри — любые UI-элементы из UI-трека, включая кнопки, — ими мы и займёмся в следующем шаге.
Функции-фабрики Tree() и Crate() — тот же приём, что HabitRow() в UI-треке: сцена собирается из маленьких именованных кусков, а не из простыни Mesh.box(...).
Что ты узнал
Scene— экран 3D-движка: одна активная,open()/close(), HUD поверх — толькоUIWidget.- Свет = IBL по умолчанию + одно
Light.sun(); тени требуютshadowsQuality+castShadows+receiveShadows. Mesh.box/sphere/cylinder/plane— единичные примитивы; пол —planeсnormal: [0, 1, 0].Material.lit(roughness/metallic) иMaterial.unlit; материал — разделяемый объект, меняй его свойства.Node— иерархия черезadd, трансформы — копии,Geometryправится доMesh.from.setLoop(dt)— покадровый цикл в секундах.
Справочник: Scene · Mesh · Geometry · Material · Light · Node · Соглашения.
Дальше: Модели и анимация — во двор приходит лиса из GLB.