Камера и пикинг
Шаг 3 3D-трека. Сцена собрана, лиса бегает — но смотреть на всё это можно только с той точки, куда ты поставил камеру, и потрогать в сцене нечего. Этот шаг про две вещи, которые превращают картинку в приложение: камерой управляет палец, и палец попадает в объекты.
Обе задачи решаются одним и тем же: камера — обычный узел, а тач-события в 3D устроены так же, как в UI, только с одним новым понятием — луч.
Облететь сцену пальцем
Классическая камера-орбита: она сидит на сфере вокруг цели, а палец крутит два угла — yaw (вокруг вертикали) и pitch (высота над горизонтом). Драг ловим через touchstart + ev.track() — тот же API, что у перетаскивания в UI-треке:
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 }))
const wood = Material.lit({ color: "#b5854b", roughness: 0.8 })
scene.add(
Mesh.box({ material: wood, size: 0.8, position: [0.6, 0.4, 0.4], castShadows: true }),
Mesh.box({ material: wood, size: 0.55, position: [0.6, 1.075, 0.4], eulerAngles: [0, 30, 0], castShadows: true }),
Mesh.box({ material: wood, size: 0.6, position: [-0.7, 0.3, 1.1], eulerAngles: [0, -20, 0], castShadows: true }),
)
// Орбита: камера сидит на сфере вокруг цели. yaw — поворот вокруг вертикали,
// pitch — высота над горизонтом, radius — дистанция. Углы тут в радианах: это чистая математика
const target = [0, 0.5, 0]
let yaw = 0.6, pitch = 0.5, radius = 6
function placeCamera() {
scene.camera.position = [
target[0] + radius * Math.cos(pitch) * Math.sin(yaw),
target[1] + radius * Math.sin(pitch),
target[2] + radius * Math.cos(pitch) * Math.cos(yaw),
]
scene.camera.lookAt(target)
}
placeCamera()
// Палец лёг на сцену: запоминаем углы в момент касания и прибавляем к ним смещение пальца
scene.addEventListener("touchstart", ev => {
const yaw0 = yaw, pitch0 = pitch
ev.track({
claim: true,
onMove: p => {
yaw = yaw0 - (p.clientX - ev.clientX) * 0.01
pitch = Mathf.clamp(pitch0 + (p.clientY - ev.clientY) * 0.01, 0.1, 1.4)
placeCamera()
},
})
})
// Приблизить/отдалить — обычные кнопки в HUD, меняющие radius
const zoom = (label: string, step: number) =>
UIButton(UIText(label).style({ color: "#fff", fontSize: 18, fontWeight: 700 }))
.style({ bgColor: "#ffffff22", width: 40, height: 40, borderRadius: 12, alignItems: "center", justifyContent: "center", $pressed: { opacity: 0.6 } })
.onClick(() => { radius = Mathf.clamp(radius + step, 3, 12); placeCamera() })
const hud = UIWidget(
UIText("Потяни, чтобы облететь").style({ fontSize: 13, color: "#c7ccd6" }),
UIRow(zoom("−", 1), zoom("+", -1)).style({ gap: 8 }),
).style({ top: "max(safe-top, 16px)", left: 16, gap: 10 })
hud.attachTo(scene)
scene.open()
hud.show()- Камера — узел, а не режиссёр.
scene.camera.positionиlookAt(point)— единственное, что нужно; никакого «контроллера камеры» в SDK нет и не надо. Ставь позицию сам — из орбиты, из позиции героя, из чего угодно. - Считай позицию, а не крути камеру. Соблазн сделать
camera.eulerAngles.y += …заканчивается сползанием и гимбл-локом. Держи в переменных состояние (yaw / pitch / radius) и каждый раз пересчитывай позицию с нуля — это и чинится проще, и сохраняется в одну строчку. - Драг — это
touchstart+ev.track(), ровно как в UI. Углы, снятые в момент касания (yaw0,pitch0), плюс смещение от точки касания (p.clientX - ev.clientX) — не путай сp.deltaX: тот даёт сдвиг между соседними событиями, а не от начала жеста. claim: trueговорит: этот жест мой. В гайде сцена лежит внутри страницы, которая тоже хочет скроллиться пальцем, — безclaimпрокрутка отберёт палец и придётonCancel.Mathf.clamp(pitch, 0.1, 1.4)не даёт камере нырнуть под пол и перевернуться в зените — почти всегда нужен именно clamp, а не «свободная» камера.- Тач в 3D приходит в логических px — те же координаты, что у
ev.clientXв UI. Пригодится через абзац.
Ещё есть линза: camera.fov (вертикальный угол в градусах, по умолчанию 60), camera.near / camera.far — дальность видимости. Полный список — в справочнике Camera.
Попасть пальцем в объект
Меш сам по себе для пальца прозрачный: движок рисует его, но не знает, где у него границы. Чтобы по объекту можно было тапнуть, ему нужен аспект Shape — тело для попаданий. {} снимает коробку прямо с меша:
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, 3.5, 5]
scene.camera.lookAt([0, 0.4, 0])
const wood = Material.lit({ color: "#b5854b", roughness: 0.8 })
const picked = Material.lit({ color: "#FF4032", roughness: 0.8 })
// Shape делает узел «осязаемым» для пальца — без него тап проходит сквозь меш.
// {} — коробка по размеру самого меша
const crates = [[-1.4, 0.3], [0.2, -0.6], [1.5, 0.5]].map(([x, z], i) =>
Mesh.box({ material: wood, size: 0.8, position: [x, 0.4, z], eulerAngles: [0, x * 25, 0], castShadows: true, name: `crate ${i + 1}` })
.aspect(Shape, {}),
)
scene.add(...crates)
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()
// Слушатель на узле — когда реакция принадлежит самому объекту
const taps = crates.map(() => 0)
crates.forEach((crate, i) => crate.addEventListener("click", () => taps[i]++))
// Слушатель на сцене видит каждый тап: ev.target — узел с Shape под пальцем или null.
// Срабатывают оба: сначала узел, потом сцена
let current: Mesh | null = null
scene.addEventListener("click", ev => {
current?.setMaterial(wood)
current = ev.target as Mesh | null
current?.setMaterial(picked)
label.text = current ? `выбран ${current.name} · тапов по нему: ${taps[crates.indexOf(current)]}` : "мимо — выбор снят"
})- Нет
Shape— нет попаданий. Это самая частая причина «почему не работает клик»: узел рисуется, но тела для пикинга у него нет, и тап уходит в сцену сev.target === null. - Два места, где слушать.
node.addEventListener("click", …)— реакция принадлежит объекту;scene.addEventListener("click", …)— видит каждый тап, включая промахи, и знает, во что попали (ev.target). Промах ловится только сценой — вот вся разница. Shapeбез физики — это «снимок». ГолыйShapeсоздаёт статическое тело для попаданий в момент подключения: если узел потом поедет, тело останется на месте. Для движущегося объекта дай ему настоящее тело — следующий шаг.{}— коробка по мешу, но можно и точнее:{ sphere: 0.4 },{ capsule: { halfHeight: 0.5, radius: 0.3 } },{ box: [0.4, 0.4, 0.4] }. У коробки — полуразмеры:box: [0.5, 0.5, 0.5]вокруг куба 1×1×1. И это мировые единицы:scaleузла на них не влияет.raycast: falseвShapeубирает узел из пикинга, оставляя его физическое поведение, — так помечают декор, который не должен перехватывать тапы.- Событие
touchstartработает на узлах точно так же и умеетev.track()— это готовое «схватил объект и потащил».
Луч и плоскость
Тап даёт точку на экране, а мир — трёхмерный: одной точке экрана соответствует целый луч, уходящий вглубь сцены. camera.getRay(x, y) строит этот луч, а дальше есть два способа спросить, во что он попал: Physics.raycast — по телам, Plane.intersectRay — по математической плоскости (пол, стена, стол), у которой нет ни меша, ни тела:
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, 4.5, 6]
scene.camera.lookAt([0, 0, -0.5])
const wood = Material.lit({ color: "#b5854b", roughness: 0.8 })
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()
// Пол — математическая плоскость y = 0 (не тело и не меш): луч из камеры пересекаем с ней
const ground = new Plane([0, 1, 0], [0, 0, 0])
let count = 0
scene.addEventListener("click", ev => {
// Луч из камеры через точку экрана — в логических px, как ev.clientX / ev.clientY
const ray = scene.camera.getRay(ev.clientX, ev.clientY)
// 1. Спрашиваем физику: луч попал в существующий ящик? Убираем его
const hit = Physics.raycast(ray.origin, ray.dir, 100)
if (hit?.node) {
scene.remove(hit.node)
hit.node.destroy()
label.text = `ящиков: ${--count}`
return
}
// 2. Иначе — пересечение с полом: t — параметр вдоль луча, точку даёт getPoint(t)
const t = ground.intersectRay(ray)
if (t === null) return
const p = ray.getPoint(t)
scene.add(
Mesh.box({ material: wood, size: 0.6, position: [p.x, 0.3, p.z], eulerAngles: [0, Math.random() * 90, 0], castShadows: true })
.aspect(Shape, {}),
)
label.text = `ящиков: ${++count}`
})Порядок в обработчике не случайный: сначала «попали в объект?», и только если нет — «куда на полу?». Так один тап делает разное в зависимости от того, что под пальцем, — и это стандартная схема для «поставить / выбрать / убрать».
Plane— это математика, а не объект сцены.new Plane([0, 1, 0], [0, 0, 0])— бесконечная плоскость через начало координат нормалью вверх. Она ничего не рисует, ни с чем не сталкивается и стоит ноль: идеально для «куда на полу ткнули».intersectRayвозвращаетt, а не точку.t— это параметр вдоль луча; точку даётray.getPoint(t).nullозначает «луч параллелен плоскости или смотрит от неё».Physics.raycast(origin, dir, maxDist)находит ближайшее тело сShape(в том числе голое, для пикинга) и возвращает{ node, point, normal, fraction }илиnull.normal— нормаль поверхности в точке попадания: по ней ставят декали, ориентируют объекты по стене, отражают снаряды.ev.targetпротивraycast— одно и то же попадание, разными путями. Событие удобнее, когда «что-то тапнули»; явный raycast — когда луч нужен не из тапа (взгляд героя, прицел, проверка «видит ли враг игрока») или когда нужныpointиnormal.destroy()— единственный способ убрать узел насовсем; после него узел непригоден.scene.remove(node)только убирает из сцены.
Ограничение по дистанции у raycast — третий аргумент (по умолчанию 1000). fraction считается от него, так что расстояние до попадания = fraction × maxDist × |dir|; если нужны метры, нормализуй dir.
Камера, которая следит
Камера, жёстко привязанная к герою, дёргается и укачивает. Приём, который решает это в одну строку, — плавное догоняние: каждый кадр камера сдвигается не в желаемую точку, а на долю расстояния до неё:
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: 40, receiveShadows: true }))
const wood = Material.lit({ color: "#b5854b", roughness: 0.8 })
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Mathf.TAU
scene.add(Mesh.box({ material: wood, size: 0.6, position: [Math.cos(a) * 5, 0.3, Math.sin(a) * 5], eulerAngles: [0, i * 20, 0], castShadows: true }))
}
scene.camera.position = [0, 4, 8]
scene.camera.lookAt([0, 0, 0])
const follow = signal(true)
const chip = UIButton(UIText(() => follow.value ? "камера: следит" : "камера: стоит").style({ color: "#fff", fontSize: 13, fontWeight: 700 }))
.style({ bgColor: "#ffffff22", borderRadius: 999, px: 12, py: 7, $pressed: { opacity: 0.6 } })
.onClick(() => follow.value = !follow.value)
const hud = UIWidget(chip).style({ top: "max(safe-top, 16px)", left: 16 })
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)
fox.anim.play("run", { loop: true })
let camPos = scene.camera.position
let t = 0
setLoop(dt => {
// Лиса бежит по кругу радиусом 3
t += dt * 0.7
fox.position = [Math.cos(t) * 3, 0, Math.sin(t) * 3]
fox.eulerAngles = [0, -t * RAD2DEG, 0]
if (!follow.value) return
// Желаемая точка — позади лисы и выше. Камера не прыгает в неё, а догоняет:
// доля пути за кадр, которая не зависит от FPS
const behind = fox.position.add([Math.sin(t) * 3, 2, -Math.cos(t) * 3])
camPos = camPos.lerp(behind, 1 - Math.pow(0.02, dt))
scene.camera.position = camPos
scene.camera.lookAt(fox.position.add([0, 0.4, 0]))
})
}
main()1 - Math.pow(k, dt)— это правильныйlerpпо времени. Наивноеlerp(target, 0.1)каждый кадр даёт разную скорость на 60 и на 120 FPS; в этой формеk— доля оставшегося расстояния за секунду (0.02 ≈ «за секунду сокращаем разрыв в 50 раз»), и картинка одинаковая на любом железе.- Догоняет позиция, а не взгляд.
lookAtсчитается от уже сглаженной позиции — этого достаточно; сглаживать ещё и точку взгляда стоит только для медленных кинематографичных камер. - Векторы — значения.
fox.position.add([...])возвращает новыйVec3, исходный не меняется, — поэтому так удобно считать «позиция плюс смещение» прямо в аргументе. Изменить узел можно только присваиванием обратно (node.position = …) или скалярно (node.x = …). - Смещение
[sin, 2, -cos]держит камеру за спиной бегущей по кругу лисы. В настоящей игре его берут от разворота героя:hero.position.sub(hero.forward.scale(4)).add([0, 2, 0])—forwardу любого узла уже есть. - Тот же приём — база для «камера в машине», «камера за плечом» и «камера, которая мягко возвращается в дефолт, когда её отпустили».
Экран целиком
Собираем всё: двор, лиса, орбита пальцем, тап по земле — цель для лисы, тап по ящику — выбор. Камера при этом мягко держит лису в центре — то есть орбита и слежение работают одновременно:
// Двор + лиса: потяни — камера облетает лису, тапни по земле — лиса бежит туда, тапни по ящику — он подсветится.
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
}
const wood = lit("#b5854b", 0.8), picked = lit("#FF4032", 0.8)
const Crate = (x: number, z: number) =>
Mesh.box({ material: wood, size: 0.8, position: [x, 0.4, z], eulerAngles: [0, x * 30, 0], castShadows: true }).aspect(Shape, {})
scene.add(Tree(-3.2, -1.4, 2), Tree(3, -2, 1.6), Tree(-2.6, 2.6, 1.3), Tree(3.2, 2.2, 2.2), Crate(2, 1), Crate(-2.5, -0.8), Crate(1, -2.5))
const hint = UIText("Загружаю лису…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(hint).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)
// Орбита вокруг цели; сама цель плавно едет за лисой
let yaw = 0.6, pitch = 0.5
let target = Vec3.from([0, 0.5, 0])
function placeCamera() {
scene.camera.position = target.add([7 * Math.cos(pitch) * Math.sin(yaw), 7 * Math.sin(pitch), 7 * Math.cos(pitch) * Math.cos(yaw)])
scene.camera.lookAt(target)
}
scene.addEventListener("touchstart", ev => {
const yaw0 = yaw, pitch0 = pitch
ev.track({ claim: true, onMove: p => {
yaw = yaw0 - (p.clientX - ev.clientX) * 0.01
pitch = Mathf.clamp(pitch0 + (p.clientY - ev.clientY) * 0.01, 0.15, 1.4)
} })
})
placeCamera()
scene.open()
hud.show()
// Тап: по ящику — выбор, по земле — цель для лисы
const ground = new Plane([0, 1, 0], [0, 0, 0])
let goal: Vec3 | null = null, selected: Mesh | null = null
scene.addEventListener("click", ev => {
if (ev.target) { selected?.setMaterial(wood); selected = ev.target as Mesh; selected.setMaterial(picked); return }
const ray = scene.camera.getRay(ev.clientX, ev.clientY)
const t = ground.intersectRay(ray)
if (t !== null) { const p = ray.getPoint(t); goal = Vec3.from([p.x, 0, p.z]) }
})
async function main() {
const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
fox.castShadows = true
fox.eulerAngles = [0, 90, 0]
scene.add(fox)
fox.anim.play("idle", { loop: true })
hint.text = "Тапни по земле — лиса побежит. Потяни — камера облетит"
let running = false
setLoop(dt => {
if (goal) {
const to = goal.sub(fox.position)
if (to.length() < 0.15) { goal = null; running = false; fox.anim.play("idle", { loop: true }) }
else {
if (!running) { running = true; fox.anim.play("run", { loop: true }) }
fox.eulerAngles = [0, Math.atan2(to.x, to.z) * RAD2DEG, 0] // модель смотрит вдоль своей оси +Z
fox.position = fox.position.add(to.normalize().scale(3 * dt))
}
}
target = target.lerp(fox.position.add([0, 0.5, 0]), 1 - Math.pow(0.01, dt))
placeCamera()
})
}
main()Три независимых куска состояния — углы орбиты, цель камеры и цель лисы — и один setLoop, который сводит их в кадр. Так и растут 3D-сцены: не «одна большая функция update», а несколько маленьких кусочков состояния, каждый со своим правилом.
Разворот лисы считается через Math.atan2(to.x, to.z): atan2 даёт угол в радианах, RAD2DEG переводит в градусы, которых ждёт eulerAngles. Порядок аргументов зависит от того, какой осью модель смотрит вперёд: наша лиса — вдоль +Z, поэтому (x, z); для модели, смотрящей вдоль +X, было бы (-z, x). Это проверяется один раз, на глаз.
Что ты узнал
- Камера — узел:
position+lookAt; держи в переменных состояние (yaw / pitch / radius) и пересчитывай позицию, а не крути камеру. - Драг по сцене —
scene.addEventListener("touchstart")+ev.track({ claim: true }); смещение считай от точки касания,delta*— это между событиями. - Без аспекта
Shapeузел невидим для пальца; слушатель на узле — про сам объект, слушатель на сцене — про каждый тап, включая промахи (ev.target === null). camera.getRay(x, y)→Physics.raycast(...)для тел илиplane.intersectRay(ray)+ray.getPoint(t)для пола.- Плавная камера —
lerpс долей1 - Math.pow(k, dt), независимой от FPS.
Справочник: Camera · Ray, Plane & Noise · Физика и формы · Тач-события · Node.
Дальше: Физика — ящики начинают падать, а лиса — ходить ногами.