3D-физика
Физика на Jolt как аспекты узла. Shape — геометрия коллизии; в паре с
Physics она даёт твёрдое тело, с Trigger — сенсорную зону, с CharacterController —
кинематическую капсулу игрока. Shape сам по себе уже делает узел кликабельным указателем
(click / touchstart).
Обзор
Physics.configure({ gravity: [0, -9.81, 0] }) // один раз, до создания тел
const ground = Mesh.plane({ material: mat, normal: [0, 1, 0], scale: 20 })
.aspect(Shape, { box: [10, 0.1, 10] })
.aspect(Physics, { motion: 'static' })
const crate = Mesh.box({ material: mat, position: [0, 4, 0] })
.aspect(Shape, {}) // авто-бокс из меша
.aspect(Physics, { mass: 2 }) // по умолчанию dynamic
crate.physics.applyImpulse([0, 6, 0])
crate.addEventListener('enter', other => console.log('hit', other.name))
scene.add(ground, crate)Гейтинг хоста — Physics.supported
Physics.supported // статик-булево — есть ли у этой сборки хоста 3D-физикаНа сборке без физики каждый API этой страницы молча делает no-op: аспекты прикрепляются без
ошибки, id остаётся 0, velocity читается [0,0,0], рейкасты промахиваются, события не
срабатывают. Оберни зависящий от физики геймплей в Physics.supported.
Настройка мира (статически)
Physics.configure(config?: {
gravity?: Vec3Like // мировые единицы/с², Y вверх (вниз отрицательный); по умолчанию [0, -9.81, 0]
maxBodies?: number // ёмкость мира; по умолчанию 4096
}): void
Physics.interpolation = true // статик get/set; по умолчанию включеноconfigure()должен выполниться до существования любого тела —maxBodiesменяет размер мира и после игнорируется.interpolationсглаживает отрисованные трансформы тел между фиксированными шагами по 60 Гц. Выключи его, чтобы сэкономить покадровые записи трансформа при множестве движущихся тел; тогда они движутся дискретными шагами.
Shape — геометрия коллизии (и кликабельность)
node.aspect(Shape, {
box?: Vec3Like // ПОЛУ-экстенты [hx, hy, hz], мировые единицы
sphere?: number // радиус
cylinder?: { halfHeight: number, radius: number } // выровнен по Y
capsule?: { halfHeight: number, radius: number } // выровнен по Y; halfHeight = цилиндрическая секция
raycast?: boolean // лучи указателя могут его задеть; по умолчанию true
})
// {} = авто: бокс из AABB меша × мировой масштабЧистая геометрия — Shape сам по себе не твёрдый и не сенсор. Выбери ровно один вид или передай
{}, чтобы авто-подогнать бокс к geometry узла (его отрисованный размер, с учётом мирового
масштаба; узлы без геометрии откатываются к полу-экстентам 0.5, всё равно умноженным на мировой
масштаб узла).
явные размеры — мировые полу-экстенты, не масштабируются узлом — box: [0.5, 0.5, 0.5] это
коллайдер 1×1×1 при любом scale узла. Mesh.box() по умолчанию меш 1×1×1, поэтому его
соответствующий коллайдер — box: [0.5, 0.5, 0.5] (или просто {}).
Кликабельность: Shape создаёт лёгкое тело только для пикинга (ни с чем не сталкивается),
поэтому любой узел с формой получает click / touchstart — см.
события указателя. Прикрепление Physics / Trigger заменяет это тело
реальным (по-прежнему кликабельным). Поставь raycast: false, чтобы отказаться от пикинга (это также
прячет его от Physics.raycast).
тело только-для-пикинга — статический снимок на момент прикрепления. Для движущегося
кликабельного объекта дай ему тело Physics (которое следует за симуляцией) — не полагайся на голое
тело-пикер.
Physics — твёрдое тело
node.aspect(Physics, {
motion?: 'static' | 'dynamic' | 'kinematic' // по умолчанию 'dynamic'
mass?: number // кг, только dynamic; по умолчанию 1
})
node.physics.id // нативный id тела Jolt; 0 = не прикреплено / нет поддержки физики
node.physics.velocity // Vec3, мировые единицы/с — get (свежая копия) / set
node.physics.applyImpulse(v): this // мгновенный импульс (кг·м/с), будит тело
node.physics.moveTo(p): this // привести кинематическое тело (или телепорт) к мировой позицииПорядок прикрепления важен: Physics требует, чтобы Shape уже был на узле — прикрепление его
первым бросает "Physics requires a Shape aspect". То же для Trigger и CharacterController.
Физика владеет трансформом dynamic-тела. Не задавай node.position каждый кадр — управляй им
через velocity / applyImpulse; кинематическими телами — через moveTo. Чтение
node.position / node.worldPosition всегда корректно (нативный слой пересинхронизирует). См.
соглашения.
Типы движения: static никогда не двигается (стены, полы); dynamic полностью симулируется
(гравитация, столкновения, масса); kinematic управляется скриптом через moveTo и толкает
динамические тела, сам не толкаясь в ответ.
События контакта — 'enter' / 'exit'
node.addEventListener('enter', (other: Node) => { /* контакт / перекрытие начались */ })
node.addEventListener('exit', (other: Node) => { /* контакт / перекрытие закончились */ })Срабатывают на обоих узлах контакта (твёрдое-твёрдое) или перекрытия (тело-триггер), каждый
получает другой узел как аргумент. Узлу нужен Shape плюс тело Physics или Trigger.
Trigger — сенсорная зона
node.aspect(Trigger) // требует Shape; без опций
node.trigger.id // нативный id тела; 0 = нет поддержки физики
node.trigger.moveTo(p): this // переместить зону в мировую точкуСоздаёт статическое сенсорное тело из Shape узла: физические тела, перекрывающие его, эмитят
'enter' / 'exit' узла вместо столкновения. Триггер никогда не блокирует движение и остаётся
кликабельным указателем.
const goal = Mesh.box({ position: [0, 1, -6] })
.aspect(Shape, { box: [1, 1, 0.2] })
.aspect(Trigger)
goal.addEventListener('enter', other => win(other))CharacterController — кинематический игрок
Jolt CharacterVirtual — контроллер collide-and-slide, не твёрдое тело: без отскоков и
опрокидывания, чёткое управление, встроенный шаг по лестницам и обработка склонов. Требует Shape
(рекомендуется капсула) и не использует Physics.
node.aspect(CharacterController, {
speed?: number // горизонтальная скорость движения, мировые единицы/с; по умолчанию 5
jumpSpeed?: number // скорость отрыва прыжка, мировые единицы/с; по умолчанию 7
gravity?: number // гравитация только для персонажа, мировые единицы/с²; по умолчанию -20 (отдельно от мировой)
maxSlope?: number // макс. проходимый склон в градусах; по умолчанию 45 — задаётся при прикреплении, не позже
})
node.controller.move(x: number, z: number): void // горизонтальное намерение, каждое в [-1, 1]; ЛИПКОЕ — шли 0, чтобы остановиться
node.controller.jump(): void // ставится в очередь; потребляется в следующем кадре, если на земле
node.controller.teleport(x, y, z): this // мировая позиция; сбрасывает вертикальную скорость
node.controller.grounded // булево — стоит на проходимой земле
node.controller.groundState // 0 на земле, 1 крутой склон, 2 касается неопираемого, 3 в воздухе
node.controller.velocity // Vec3, мировые единицы/с (свежая копия)
node.controller.id // нативный id персонажа; 0 = не прикреплён / нет поддержкиТы подаёшь горизонтальное намерение + прыжок; контроллер каждый кадр интегрирует свою гравитацию, а Jolt разрешает столкновения с миром. Он обновляется в фазе аспектов EARLY, поэтому ввод, заданный в этом кадре, потребляется шагом этого кадра.
const hero = Mesh.cylinder({ material: mat, position: [0, 1, 0] })
.aspect(Shape, { capsule: { halfHeight: 0.6, radius: 0.3 } })
.aspect(CharacterController, { speed: 6, jumpSpeed: 8 })
setLoop(() => {
const x = (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0)
const z = (Input.key('KeyS') ? 1 : 0) - (Input.key('KeyW') ? 1 : 0)
hero.controller.move(x, z)
if (Input.key('Space')) hero.controller.jump()
})контроллер сталкивается с твёрдыми телами, но проходит сквозь триггеры, и (v1) сам не детектируется триггерами и не кликабелен указателем, пока активен.
Рейкасты
Physics.raycast(origin: Vec3Like, dir: Vec3Like, maxDist = 1000): RayHit | null
interface RayHit {
node: Node | null // задетый узел (null, если он больше не зарегистрирован)
point: Vec3 // точка попадания в мировом пространстве
normal: Vec3 // нормаль поверхности в мировом пространстве
fraction: number // 0..1 вдоль луча (дистанция попадания = fraction × maxDist × |dir|)
}Задевает ближайшее кликабельное тело (любой Shape с raycast, оставленным true — включая
тела только-для-пикинга и триггеры). Сочетай с camera.getRay для
выбора по тапу:
scene.addEventListener('click', ev => {
const ray = scene.camera.getRay(ev.clientX, ev.clientY)
const hit = Physics.raycast(ray.origin, ray.dir, 100)
if (hit?.node) select(hit.node)
})Подводные камни
// ✗ порядок прикрепления — Physics/Trigger/CharacterController до Shape бросает
node.aspect(Physics).aspect(Shape, {})
// ✓ сначала Shape
node.aspect(Shape, {}).aspect(Physics)
// ✗ запись трансформа динамического тела — им владеет физика
setLoop(() => { crate.y += 0.1 })
// ✓ управляй телом
crate.physics.velocity = [0, 3, 0]
// ✗ полные экстенты там, где ждут полу-экстенты — коллайдер вдвое больше меша
crate.aspect(Shape, { box: [1, 1, 1] }) // коллайдер 2×2×2 вокруг меша-бокса 1×1×1
// ✓
crate.aspect(Shape, { box: [0.5, 0.5, 0.5] }) // или {} для авто-подгонкиСмотрите также
- Аспекты — attach/get/has/removeAspect, фазы
update. - События указателя — что включает тело-пикер
Shape. - Node — трансформ, события.
- Ray, Plane и Noise — лучи камеры для
Physics.raycast. - Соглашения — «физика владеет динамическими трансформами», гейтинг хоста.