Модели и анимация
Шаг 2 3D-трека. Примитивы из прошлого шага хороши для сцены, но персонажа из кубов не собрать. Персонаж приходит из GLB — файла, в котором лежат меши, скелет и запечённые клипы анимации. В LeCodes это отдельный вид узла: Model.
Во двор въезжает лиса: загрузим модель, разберёмся с её клипами, размножим её без повторной загрузки и отправим бегать по кругу.
Загрузка модели
Model.load() возвращает промис. И тут первая особенность рантайма: верхнеуровневого await в программе нет — бандл выполняется как обычная функция. Всё, что грузится, заворачивай в async-функцию и вызывай её:
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: 24, receiveShadows: true }))
scene.camera.position = [0, 0.9, 1.9]
scene.camera.lookAt([0, 0.2, 0])
const status = UIText("Загружаю модель…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(status).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)
scene.open()
hud.show()
// Бандл выполняется без верхнеуровневого await — загрузку заворачиваем в async-функцию
async function main() {
const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
fox.castShadows = true
scene.add(fox)
// Что лежит в файле — единственный источник правды про клипы
status.text = fox.anim.clips.map(c => `${c.name} — ${c.duration.toFixed(1)}s`).join("\n")
fox.anim.play("walk", { loop: true })
let t = 0
setLoop(dt => { t += dt; fox.eulerAngles = [0, Math.sin(t * 0.5) * 35, 0] })
}
main()- Сцену открывай сразу, модель добавляй потом. Сцена уже нарисована, пока идёт загрузка, — на экране пусто, но не чёрно, и есть куда написать «Загружаю…». Так же поступают с любым долгим ресурсом.
- Модель — это
Node, просто другого вида:position,scale,eulerAngles,castShadows, события — всё как у меша. Внутренние узлы GLB висят детьми, иmodel.traverse(node => …)обходит их (поnode.nameможно найти нужную кость или деталь). - Проверь масштаб — единицы в GLB какие угодно. Модель приходит в тех единицах, в которых её экспортировали, и часто множитель уже зашит в сам файл (у нашей лисы корневой узел стоит со
scale0.01, поэтому в коде масштаб не трогаем вовсе). Если модель послеaddне видна или занимает полэкрана — это первое, что нужно посмотреть:model.scale = 0.01поверх уже сжатого файла даёт точку в десятую долю миллиметра. Ориентир — метры: взрослая лиса сантиметров семьдесят в холке. - Источник — путь ассета
asset("./fox.glb")в реальном проекте (файл лежит рядом с кодом) или, как здесь, обычныйhttps://-URL. Промис отклоняется при HTTP-ошибке или битом файле — в бою оборачивай вtry/catchи показывай понятное сообщение.
Верхнеуровневый await в main.ts компилируется без ошибки, но программа молча не запускается: и веб-раннер, и десктоп-хост выполняют бандл как функцию. Если экран пустой и в консоли тихо — первым делом ищи await вне функции.
Клипы
Анимации приходят вместе с GLB и живут в аспекте model.anim — он уже прикреплён, создавать его не нужно. clips — список того, что запечено в файле; play() принимает имя или индекс:
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: 24, receiveShadows: true }))
scene.camera.position = [0, 0.9, 1.9]
scene.camera.lookAt([0, 0.2, 0])
const label = UIText("…").style({ fontSize: 13, color: "#c7ccd6" })
const row = UIRow().style({ gap: 8 })
const hud = UIWidget(label, row).style({ top: "max(safe-top, 16px)", left: 16, right: 16, gap: 8 })
hud.attachTo(scene)
scene.open()
hud.show()
async function main() {
const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
fox.castShadows = true
scene.add(fox)
// Чипы строим по тому, что реально лежит в файле, а не по списку имён «из головы»
const chip = (name: string) =>
UIButton(UIText(name).style({ color: "#fff", fontSize: 13, fontWeight: 700 }))
.style({ bgColor: "#ffffff22", borderRadius: 999, px: 12, py: 7, $pressed: { opacity: 0.6 } })
.onClick(() => { fox.anim.play(name, { loop: true }); label.text = `клип: ${name}` })
row.append(...fox.anim.clips.map(c => chip(c.name)))
// Один цикл шага без loop: по его окончании придёт событие completed
fox.anim.play("walk", { loop: false })
label.text = "клип: walk (один раз)"
fox.addEventListener("completed", () => {
label.text = "walk доиграл → idle, в цикле"
fox.anim.play("idle", { loop: true })
})
fox.addEventListener("loopReached", clip => console.log("loop", clip))
}
main()Лиса проходит один цикл шага и переходит в idle — это сделало событие completed. Чипы построены прямо из anim.clips: сколько клипов в файле, столько и кнопок.
play(clip, { loop })— одно и то же и для цикла, и для одиночного проигрывания. Ещё естьanim.speed(множитель скорости),anim.time(перемотка в секундах) иanim.playing = false(пауза).- События приходят на узел, а не на аспект:
model.addEventListener("completed" | "loopReached", clip => …), аргумент — индекс клипа. Послеcompletedаспект сам ставитplaying = false. - Незнакомое имя игнорируется молча:
play("Runn")не бросит ошибку — просто продолжит играть предыдущий клип. Именно поэтомуanim.clips— единственный источник правды: перезалили модель с другими именами, и код с зашитыми строками тихо перестал анимировать.play(0)по индексу застрахован от опечаток, но не от перестановки клипов в файле. - Кроссфейда нет:
play()переключает клип со следующего кадра, жёстко. Для плавных переходов, блендинга и клипов из отдельных файлов есть аспектAnimator— он же нужен для Mixamo-ассетов, где каждая анимация лежит своим GLB.
Кнопки в HUD — обычные UIButton из UI-трека: UIWidget над сценой ведёт себя как любой контейнер, включая императивные append/setContent.
Много экземпляров
Второй персонаж — не второй Model.load. Загруженная модель умеет clone(): копия переиспользует уже разобранный GLB (без повторной загрузки и парсинга) и получает собственное состояние анимации:
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: 30, receiveShadows: true }))
scene.camera.position = [0, 1.8, 3.2]
scene.camera.lookAt([0, 0.2, 0])
const label = UIText("…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(label).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)
scene.open()
hud.show()
async function main() {
const template = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
const foxes = [template, ...Array.from({ length: 4 }, () => template.clone())]
foxes.forEach((fox, i) => {
const a = (i / foxes.length) * Mathf.TAU
fox.position = [Math.cos(a) * 1.1, 0, Math.sin(a) * 1.1]
fox.eulerAngles = [0, -a * RAD2DEG, 0]
fox.castShadows = true
scene.add(fox)
// Состояние анимации у каждой копии своё: свой клип, своя скорость, своя фаза
fox.anim.play(i % 2 ? "walk" : "run", { loop: true })
fox.anim.speed = 0.6 + i * 0.2
fox.anim.time = i * 0.2
})
label.text = `${foxes.length} лис, одна загрузка`
}
main()Пять лис, один сетевой запрос и один разбор файла. Клоны наследуют трансформ оригинала на момент копирования, но дальше живут сами по себе — своя позиция, свой клип, своя скорость и своя фаза (anim.time разводит их, чтобы не шагали синхронно, как рота).
- Для «пула» врагов/снарядов делай так же: одна загрузка на старте, дальше
clone()по мере надобности. ПовторныйModel.loadтого же URL — это лишняя работа и подтормаживание в момент спавна. RAD2DEGиMathf.TAU— глобалы. Углы узлов в градусах, тригонометрия — в радианах; конвертация нужна ровно на границе.- Убирать узел —
node.destroy(); после этого узел непригоден. Часто дешевле спрятать (visible = false) и переиспользовать.
Экран целиком
Возвращаем двор из первого шага и запускаем по нему три лисы: у каждой свой радиус, своя скорость и свой клип. Позиция считается в покадровом цикле, разворот — из того же угла:
// Двор из шага 1 + лисы: одна загрузка GLB, три экземпляра, каждый со своей анимацией.
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 })
scene.add(Mesh.plane({ material: lit("#2f4a35"), normal: [0, 1, 0], scale: 30, 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: lit("#6b4a2b"), radius: 0.12, scale: [1, 0.7, 1], position: [0, 0.35, 0], castShadows: true }),
Mesh.from(crown, { material: lit("#3d8b4f"), castShadows: true }),
)
return tree
}
scene.add(Tree(-1.9, -2.2, 2), Tree(1.9, -2.8, 1.6), Tree(-2, 1.8, 1.3), Tree(2, 1.4, 2.2))
scene.camera.position = [0, 3.2, 5.5]
scene.camera.lookAt([0, 0.3, 0])
const status = UIText("Загружаю лис…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(
UIText("Двор").style({ fontSize: 22, fontWeight: 800, color: "#ffffff" }),
status,
).style({ top: "max(safe-top, 16px)", left: 16, right: 16, gap: 2 })
hud.attachTo(scene)
scene.open()
hud.show()
async function main() {
const template = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
// Каждая лиса бежит по своему кругу; clone() — копия без повторной загрузки
const walkers = [
{ model: template, radius: 1.2, speed: 0.5, clip: "walk" },
{ model: template.clone(), radius: 2, speed: 0.9, clip: "run" },
{ model: template.clone(), radius: 0.6, speed: 0.3, clip: "walk" },
]
walkers.forEach(({ model, clip }, i) => {
model.castShadows = true
scene.add(model)
model.anim.play(clip, { loop: true })
model.anim.speed = 0.9 + i * 0.2
})
status.text = `${walkers.length} лисы · клипы: ${template.anim.clips.map(c => c.name).join(", ")}`
let t = 0
setLoop(dt => {
t += dt
for (const w of walkers) {
const a = t * w.speed
w.model.position = [Math.cos(a) * w.radius, 0, Math.sin(a) * w.radius]
w.model.eulerAngles = [0, -a * RAD2DEG, 0]
}
})
}
main()Скорость шага и скорость клипа связаны только на глаз: anim.speed подобран так, чтобы лапы не «скользили» по земле. Это нормальная ручная настройка — автоматической привязки анимации к перемещению (root motion) у anim нет; она есть у Animator.
Двигать узел присваиванием position можно ровно до тех пор, пока за него не взялась физика — про это следующие шаги.
Что ты узнал
Model.load(url | asset("./file.glb"))— промис; верхнеуровневогоawaitнет, оборачивай вasync function main().- Масштаб модели проверяй по файлу: часто он уже зашит в корневой узел GLB, и лишний
scaleделает модель невидимой. Model— этоNode: трансформ,scaleпод метры,castShadows,traverseпо внутренним узлам.model.anim:clips,play(name | index, { loop }),speed,time,playing; событияcompleted/loopReached— на узле, с индексом клипа.- Незнакомое имя клипа игнорируется молча; кроссфейда нет — для переходов и блендинга есть
Animator. clone()— дешёвая копия загруженной модели с собственным состоянием анимации; так делают пулы.
Справочник: Model & ModelAnimation · Node · Аспекты · Глобалы хоста.
Дальше: Камера и пикинг — облетаем двор пальцем и попадаем по объектам.