LeCodesdocs

3D-физика

Физика на Jolt как аспекты узла. Shape — геометрия коллизии; в паре с Physics она даёт твёрдое тело, с Trigger — сенсорную зону, с CharacterController — кинематическую капсулу игрока. Shape сам по себе уже делает узел кликабельным указателем (click / touchstart).

Обзор

TypeScript
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

TypeScript
Physics.supported   // статик-булево — есть ли у этой сборки хоста 3D-физика

На сборке без физики каждый API этой страницы молча делает no-op: аспекты прикрепляются без ошибки, id остаётся 0, velocity читается [0,0,0], рейкасты промахиваются, события не срабатывают. Оберни зависящий от физики геймплей в Physics.supported.

Настройка мира (статически)

TypeScript
Physics.configure(config?: {
  gravity?: Vec3Like        // мировые единицы/с², Y вверх (вниз отрицательный); по умолчанию [0, -9.81, 0]
  maxBodies?: number        // ёмкость мира; по умолчанию 4096
}): void

Physics.interpolation = true   // статик get/set; по умолчанию включено
  • configure() должен выполниться до существования любого телаmaxBodies меняет размер мира и после игнорируется.
  • interpolation сглаживает отрисованные трансформы тел между фиксированными шагами по 60 Гц. Выключи его, чтобы сэкономить покадровые записи трансформа при множестве движущихся тел; тогда они движутся дискретными шагами.

Shape — геометрия коллизии (и кликабельность)

TypeScript
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, всё равно умноженным на мировой масштаб узла).

Note

явные размеры — мировые полу-экстенты, не масштабируются узлом — 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).

Note

тело только-для-пикинга — статический снимок на момент прикрепления. Для движущегося кликабельного объекта дай ему тело Physics (которое следует за симуляцией) — не полагайся на голое тело-пикер.

Physics — твёрдое тело

TypeScript
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'

TypeScript
node.addEventListener('enter', (other: Node) => { /* контакт / перекрытие начались */ })
node.addEventListener('exit',  (other: Node) => { /* контакт / перекрытие закончились  */ })

Срабатывают на обоих узлах контакта (твёрдое-твёрдое) или перекрытия (тело-триггер), каждый получает другой узел как аргумент. Узлу нужен Shape плюс тело Physics или Trigger.

Trigger — сенсорная зона

TypeScript
node.aspect(Trigger)          // требует Shape; без опций

node.trigger.id               // нативный id тела; 0 = нет поддержки физики
node.trigger.moveTo(p): this  // переместить зону в мировую точку

Создаёт статическое сенсорное тело из Shape узла: физические тела, перекрывающие его, эмитят 'enter' / 'exit' узла вместо столкновения. Триггер никогда не блокирует движение и остаётся кликабельным указателем.

TypeScript
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.

TypeScript
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, поэтому ввод, заданный в этом кадре, потребляется шагом этого кадра.

TypeScript
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()
})
Note

контроллер сталкивается с твёрдыми телами, но проходит сквозь триггеры, и (v1) сам не детектируется триггерами и не кликабелен указателем, пока активен.

Рейкасты

TypeScript
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 для выбора по тапу:

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

Подводные камни

TypeScript
// ✗ порядок прикрепления — 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] })   // или {} для авто-подгонки

Смотрите также