Реальные истории:Как тренер футбольной школы создал приложения за 1 неделю скоро

Меши, материалы, свет

Первый шаг 3D-трека. За пять шагов ты соберёшь двор: поляну с деревьями и ящиками, по которой бегает лиса из GLB-модели, — научишься управлять камерой и попадать пальцем в объекты, включишь физику и в конце поставишь ту же сцену на стол через AR. Этот шаг — про то, из чего сцена состоит: меши, материалы, свет и иерархия узлов.

Три договорённости, которые действуют во всём 3D LeCodes: единицы — метры, Y — вверх, углы — в градусах. Камера смотрит вдоль −Z. Все классы — глобалы, импортировать нечего.

Заметка

3D-примеры тяжелее UI-шных: движок грузится один раз, дальше сцены сменяются быстро. В узкой раскладке телефон под примером запускается по кнопке ▶.

Сцена, свет, первый меш

Минимальная сцена — четыре действия: создать Scene, добавить солнце, положить пол и предмет, поставить камеру. И open() — как у 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()

Что здесь важно:

  • Сцена — это «экран» 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 — металл):

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,
}))

// Четыре шара, четыре материала. У 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-треке, — здесь он главный инструмент.

Полный контракт: Material, Texture, Light.

Иерархия и геометрия

Дерево во дворе — не один меш, а маленькая сборка: ствол-цилиндр и крона-конус под общим родителем. Родитель — пустой Node: сдвинул или повернул его — поехали все дети. А форму крона получает из Geometry: примитив можно масштабировать и сдвигать до того, как из него сделан меш, — так у конуса появляется «пивот» в основании:

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 })

// Дерево — пустой 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 поверх сцены.

TypeScript
// Двор: поляна, деревья, ящики и медленно кружащая камера.
// Каждый следующий шаг трека добавляет сюда что-то своё.
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.