
В этом руководстве мы проведем вас через процесс создания интерактивного 3D-ролика из игральных костей, используя библиотеки JavaScript Three.js для создания 3D-графики и cannon-es для добавления взаимодействия. Мы начнем с создания кубиков с помощью модифицированной BoxGeometry без использования текстур, шейдеров или внешних 3D-моделей. Затем мы будем использовать cannon-es для включения физики, имитации броска кости и определения стороны приземления.
Оглавление
Кодирование кубиков
Конечно, можно найти готовую модель кубика в интернете или создать ее в Blender, но давайте сделаем ее программно с помощью Three.js. В конце концов, мы же здесь учимся 🙂 .
Геометрия кубика будет основана на THREE.BoxGeometry, но изменена, чтобы скруглить углы коробки и добавить выемки на гранях.
Закругление краев коробки
Скругление углов коробки - довольно распространенная задача, поэтому для нее существует множество решений. Одно из таких решений включено в пакет Three.js: RoundedBoxGeometry расширяет класс BoxGeometry и может быть найден в папке examples/jsm/geometries/.
Класс RoundedBoxGeometry работает для любого размера коробки и предоставляет настраиваемую геометрию с включенным UV и нормалями. Если вам не нужны дополнительные модификации геометрии, RoundedBoxGeometry, скорее всего, будет идеальным решением ”из коробки” (ba dum tss!).
RoundedBoxGeometry позволяет задать количество сегментов для закругленной области, но плоские поверхности всегда строятся с одной парой треугольников.

RoundedBoxGeometryИз-за этого ограничения мы не можем добавить выемки на стороны коробки, поэтому мы создаем собственное решение для скругления граней.
За основу мы берем куб как BoxGeometry с приличным количеством сегментов.
const params = {
segments: 50,
edgeRadius: .07
};
let boxGeometry = new THREE.BoxGeometry(1, 1, 1, params.segments, params.segments, params.segments);

Практически, изменение геометрии означает перебор вершин бокса для доступа к координатам XYZ в массиве boxGeometry.attributes.position. После изменения координат XYZ они могут быть повторно применены к атрибуту position.
function createDiceGeometry() {
let boxGeometry = new THREE.BoxGeometry(1, 1, 1, params.segments, params.segments, params.segments);
const positionAttribute = boxGeometry.attributes.position;
for (let i = 0; i < positionAttribute.count; i++) {
let position = new THREE.Vector3().fromBufferAttribute(positionAttribute, i);
// modify position.x, position.y and position.z
positionAttribute.setXYZ(i, position.x, position.y, position.z);
}
return boxGeometry;
}
Обратите внимание, что мы не кодируем универсальное решение, как RoundedBoxGeometry. Поскольку мы создаем кубическую форму, мы охватываем только случай, когда все стороны коробки равны 1. Мы также не утруждаем себя вычислением UV-координат, поскольку на кубик не нужно накладывать текстуру.
Начнем с выбора координат (положений вершин), которые находятся близко к граням коробки. Поскольку сторона коробки равна 1, мы знаем, что координаты X, Y и Z изменяются от -0,5 до 0,5.
Если все три координаты вершин близки к -0,5 или 0,5, то вершина геометрии близка к вершине коробки (постараемся не путать вершины геометрии, которые мы изменяем, с восемью вершинами коробки, которые мы округляем).

Если только 2 из 3 координат близки к -0,5 или 0,5, геометрическая вершина находится близко к краю коробки. Другие вершины сохраняют исходное положение. Например, если координаты X и Y близки к -0,5 или 0,5, вершина находится близко к краю, параллельному оси Z.

Таким образом, мы выбираем все вершины геометрии, которые должны быть изменены:
function createDiceGeometry() {
// ...
const subCubeHalfSize = .5 - params.edgeRadius;
for (let i = 0; i < positionAttribute.count; i++) {
// ...
if (Math.abs(position.x) > subCubeHalfSize && Math.abs(position.y) > subCubeHalfSize && Math.abs(position.z) > subCubeHalfSize) {
// position is close to box vertex
} else if (Math.abs(position.x) > subCubeHalfSize && Math.abs(position.y) > subCubeHalfSize) {
// position is close to box edge that's parallel to Z axis
} else if (Math.abs(position.x) > subCubeHalfSize && Math.abs(position.z) > subCubeHalfSize) {
// position is close to box edge that's parallel to Y axis
} else if (Math.abs(position.y) > subCubeHalfSize && Math.abs(position.z) > subCubeHalfSize) {
// position is close to box edge that's parallel to X axis
}
// ...
}
// ...
}
Сначала давайте округлим вершины геометрии, которые находятся рядом с вершинами коробки. Мы хотим заменить их исходное положение координатой XYZ, лежащей на сфере, расположенной в углу коробки.

Чтобы преобразовать вектор положения таким образом, мы разбиваем его на две составляющие:
subCube - вектор, указывающий на бокс, радиус которого меньше исходного путем округления.дополнение - остаток вектора положения
for (let i = 0; i < positionAttribute.count; i++) {
const subCubeHalfSize = .5 - params.edgeRadius;
// ...
const subCube = new THREE.Vector3(
Math.sign(position.x),
Math.sign(position.y),
Math.sign(position.z)
).multiplyScalar(subCubeHalfSize);
const addition = new THREE.Vector3().subVectors(position, subCubeEdges);
// ...
}

Исходное положение вершины представляет собой сумму векторов subCube и addition. Мы оставляем subCube без изменений, так как он указывает на центр сферы. Вектор сложения мы нормализуем, чтобы он указывал на сферу с радиусом = 1

… и умножить его на значение радиуса округления, чтобы переместить координату на нужную сферу.

for (let i = 0; i < positionAttribute.count; i++) {
// ...
const subCube = new THREE.Vector3(Math.sign(position.x), Math.sign(position.y), Math.sign(position.z)).multiplyScalar(subCubeHalfSize);
const addition = new THREE.Vector3().subVectors(position, subCube);
if (Math.abs(position.x) > subCubeHalfSize && Math.abs(position.y) > subCubeHalfSize && Math.abs(position.z) > subCubeHalfSize) {
// position is close to box vertex
addition.normalize().multiplyScalar(params.edgeRadius);
position = subCube.add(addition);
}
// ...
}
С помощью приведенного выше кода мы можем обогнуть все вершины куба.

Тот же подход работает для краев коробки. Например, возьмем вершины геометрии, которые находятся рядом с краями бокса параллельно оси Z. Их position.z уже правильная, поэтому нужно изменить только координаты X и Y. Другими словами,
position.z не изменяетсяaddition.z должен быть установлен в ноль перед нормализацией вектора сложения

Повторив это для других осей, мы получим геометрию округлого куба.
let position = new THREE.Vector3().fromBufferAttribute(positionAttribute, i);
const subCube = new THREE.Vector3(Math.sign(position.x), Math.sign(position.y), Math.sign(position.z)).multiplyScalar(subCubeHalfSize);
const addition = new THREE.Vector3().subVectors(position, subCube);
if (Math.abs(position.x) > subCubeHalfSize && Math.abs(position.y) > subCubeHalfSize && Math.abs(position.z) > subCubeHalfSize) {
addition.normalize().multiplyScalar(params.edgeRadius);
position = subCube.add(addition);
} else if (Math.abs(position.x) > subCubeHalfSize && Math.abs(position.y) > subCubeHalfSize) {
addition.z = 0;
addition.normalize().multiplyScalar(params.edgeRadius);
position.x = subCube.x + addition.x;
position.y = subCube.y + addition.y;
} else if (Math.abs(position.x) > subCubeHalfSize && Math.abs(position.z) > subCubeHalfSize) {
addition.y = 0;
addition.normalize().multiplyScalar(params.edgeRadius);
position.x = subCube.x + addition.x;
position.z = subCube.z + addition.z;
} else if (Math.abs(position.y) > subCubeHalfSize && Math.abs(position.z) > subCubeHalfSize) {
addition.x = 0;
addition.normalize().multiplyScalar(params.edgeRadius);
position.y = subCube.y + addition.y;
position.z = subCube.z + addition.z;
}

Обновление нормалей
Часто для обновления нормалей достаточно просто вызвать computeVertexNormals() после модификации вершин геометрии. Метод вычисляет нормаль для каждой вершины путем усреднения нормалей соседних граней (граней, которые разделяют эту вершину). Это очень простой способ сгладить геометрию, если только геометрия не имеет дублирующихся вершин.
Обычно 1+ вершин геометрии располагаются на одной и той же позиции, в основном для поддержания UV и нормальных атрибутов. Например, возьмем THREE.CylinderGeometry. Боковая поверхность имеет шов, который виден на левом рисунке. Есть два набора вершин, расположенных на этой вертикальной линии шва. Дублированные вершины имеют одинаковое положение и одинаковую нормаль, но разные UV-атрибуты. Первый набор вершин связан с гранями слева от линии шва (UV.x = 1), а второй набор вершин связан с гранями справа от шва (UV.x = 0). Дублирование вершин необходимо для правильного обертывания текстуры вокруг стороны и, в случае цилиндра, для поддержки параметраtaLength.
Конечно, THREE.CylinderGeometry идет с правильно рассчитанными нормалями, как вы видите на центральной картинке.

CylinderGeometry with texture (left), original normals (center), automatically recalculated normals (right)Но если мы вызовем cylinder.geometry.computeVertexNormals() (даже без изменения геометрии), нормали станут такими, как показано на правом рисунке. Средние нормали граней отличаются для левого и правого набора дублированных вершин.
const material = new THREE.MeshNormalMaterial({});
const cylinder = new THREE.Mesh(new THREE.CylinderGeometry(1, 1, 2, 9), material);
// here normals are correct (central pic)
cylinder.geometry.computeVertexNormals();
// here normals have a seam (right pic)
THREE.BoxGeometry также имеет дублированные вершины на коробке. Они расположены на гранях коробки. Вот почему мы можем легко наложить текстуры на каждую сторону коробки… и вот почему у нас возникает аналогичная проблема со швами.
На рисунке ниже показаны исходные нормали бокса на измененной геометрии

При наличии дополнительных вершин на гранях коробки функция computeVertexNormals() не дает правильного результата.

Чтобы исправить швы, необходимо удалить все дублирующиеся вершины перед вызовом computeVertexNormals(). Это можно легко сделать с помощью метода mergeVertices(), который предназначен для удаления вершин с одинаковым набором атрибутов. Дублированные вершины имеют атрибуты normal и uv, унаследованные от BoxGeometry, которые мы удаляем. После этого у дублированных вершин остается только атрибут position, и вершины с одинаковой позицией могут быть автоматически объединены.

P.S. Чтобы исправить нормали округлого куба, можно также просто повторно использовать вектор сложения вместо слияния вершин и повторного вычисления нормалей:
const normalAttribute = boxGeometry.attributes.normal;
// ...
normalAttribute.setXYZ(i, addition.x, addition.y, addition.z);
Это простое и элегантное решение, но мы хотим автоматически обновлять нормали после следующего изменения геометрии. Поэтому в сегодняшнем проекте мы используем комбинацию mergeVertices() и computeVertexNormals().
Нанесение насечек по бокам
Следующим шагом будет добавление от одного до шести гладких углублений на стороны куба. Для начала добавим одну в центр верхней стороны. Мы можем выбрать вершины верхней стороны, просто проверив, равно ли значение position.y 0,5. Для выбранных вершин мы уменьшим position.y на высоту выемки.
Сложность заключается в расчете формы выемки. Давайте сначала подумаем о двумерном пространстве и сформируем центрированный симметричный гладкий импульс по координатам XY.
Первый шаг - функция косинуса для волны с пиком в точке x = 0.

Затем превратите его в положительное число, прибавив к Y 1.

Период косинуса равен 2 x π, то есть центральная волна начинается в точке x = -π и заканчивается в точке x = π. Удобнее было бы иметь его от -1 до 1, поэтому мы умножаем x на π.

Поскольку нам нужна только одна волна в центре, мы ограничиваем значение x от -1 до 1.

Отлично! Импульс может быть параметризован переменными PULSE_WIDTH и PULSE_DEPTH, и именно так мы используем его для выемки кубика.
Преобразовать форму в трехмерное пространство довольно просто. Двумерная волна определяет Y как функцию X. Чтобы сделать Y функцией и X, и Z, мы просто перемножим две волны - первую, взятую как функцию X, и вторую, как функцию Z.
const notchWave = (v) => {
v = (1 / params.notchRadius) * v;
v = Math.PI * Math.max(-1, Math.min(1, v));
return params.notchDepth * (Math.cos(v) + 1.);
}
const notch = (pos) => notchWave(pos[0]) * notchWave(pos[1]);
Итак, для верхней стороны мы вычитаем значение notch([position.x, position.z]) из position.y, и аналогично для других сторон коробки. Поскольку наш импульс центрирован в точке (0, 0), мы можем сместить надсечки по боковой поверхности, добавив смещение к аргументам функции notch.
const offset = .23;
if (position.y === .5) {
position.y -= notch([position.x, position.z]);
} else if (position.x === .5) {
position.x -= notch([position.y + offset, position.z + offset]);
position.x -= notch([position.y - offset, position.z - offset]);
} else if (position.z === .5) {
position.z -= notch([position.x - offset, position.y + offset]);
position.z -= notch([position.x, position.y]);
position.z -= notch([position.x + offset, position.y - offset]);
} else if (position.z === -.5) {
position.z += notch([position.x + offset, position.y + offset]);
position.z += notch([position.x + offset, position.y - offset]);
position.z += notch([position.x - offset, position.y + offset]);
position.z += notch([position.x - offset, position.y - offset]);
} else if (position.x === -.5) {
position.x += notch([position.y + offset, position.z + offset]);
position.x += notch([position.y + offset, position.z - offset]);
position.x += notch([position.y, position.z]);
position.x += notch([position.y - offset, position.z + offset]);
position.x += notch([position.y - offset, position.z - offset]);
} else if (position.y === -.5) {
position.y += notch([position.x + offset, position.z + offset]);
position.y += notch([position.x + offset, position.z]);
position.y += notch([position.x + offset, position.z - offset]);
position.y += notch([position.x - offset, position.z + offset]);
position.y += notch([position.x - offset, position.z]);
position.y += notch([position.x - offset, position.z - offset]);
}
Мы вставляем этот код после первого изменения geometryBase.attributes.position и получаем готовую сетку кубиков.

Нанесение цвета
Чтобы окрасить кубик, мы просто применим к нему серый материал MeshStandardMaterial.

Чтобы раскрасить выемки, мы можем сделать простой трюк и разместить шесть плоскостей внутри куба так, чтобы они выходили из выемок.

Мы завершаем работу над кубиками, окрашивая внутренние панели в черный цвет и группируя их с основной сеткой.
function createBoxGeometry() {
let boxGeometry = new THREE.BoxGeometry(1, 1, 1, params.segments, params.segments, params.segments);
// ...
// modify boxGeometry.attributes.position and re-calculate normals
return boxGeometry;
}
function createInnerGeometry() {
// keep the plane size equal to flat surface of cube
const baseGeometry = new THREE.PlaneGeometry(1 - 2 * params.edgeRadius, 1 - 2 * params.edgeRadius);
// place planes a bit behind the box sides
const offset = .48;
// and merge them as we already have BufferGeometryUtils file loaded :)
return BufferGeometryUtils.mergeBufferGeometries([
baseGeometry.clone().translate(0, 0, offset),
baseGeometry.clone().translate(0, 0, -offset),
baseGeometry.clone().rotateX(.5 * Math.PI).translate(0, -offset, 0),
baseGeometry.clone().rotateX(.5 * Math.PI).translate(0, offset, 0),
baseGeometry.clone().rotateY(.5 * Math.PI).translate(-offset, 0, 0),
baseGeometry.clone().rotateY(.5 * Math.PI).translate(offset, 0, 0),
], false);
}
function createDiceMesh() {
const boxMaterialOuter = new THREE.MeshStandardMaterial({
color: 0xeeeeee,
})
const boxMaterialInner = new THREE.MeshStandardMaterial({
color: 0x000000,
roughness: 0,
metalness: 1,
side: THREE.DoubleSide
})
const diceMesh = new THREE.Group();
const innerMesh = new THREE.Mesh(createInnerGeometry(), boxMaterialInner);
const outerMesh = new THREE.Mesh(createBoxGeometry(), boxMaterialOuter);
diceMesh.add(innerMesh, outerMesh);
return diceMesh;
}
Далее мы перейдем к анимации. Обратите внимание, что вторая часть этого урока не зависит от первой и наоборот.
Выбор инструмента анимации
Цитируя любой учебник на эту тему, я должен сказать, что Three.js - это инструмент для рисования 3D-сцен в браузере. Он не включает в себя физический движок или другие встроенные инструменты для работы с анимацией. Чтобы избежать путаницы, система анимации Three.js - это API, который в основном используется для запуска анимации для импортированных моделей. Она не сильно помогает создавать новые переходы (за исключением KeyframeTrack, но он довольно прост).
Чтобы добавить движение и взаимодействие в сцену, нам нужно вычислить значения анимированных свойств для каждого кадра. Анимированным свойством может быть трансформация 3D-объекта, цвет материала или любой другой атрибут экземпляра Three.js.
Вычисление анимированного свойства может быть простым, как увеличение его значения в цикле requestAnimationFrame(), или сложным, как импорт какого-либо игрового движка для вычисления трансформаций.
Характер и сложность перехода определяют выбор инструмента анимации.
- Для базовых условий, линейных переходов и простых смягчений, интерполяций и других тривиальных вычислений обходитесь без дополнительных либ.
- Используйте GSAP для создания цепочки из нескольких анимаций, для обработки пользовательских смягчений, для разработки анимации с прокруткой и других относительно сложных переходов. Это добавит 60-80 кб к вашему приложению.
- Если вам нужно применить силы к объектам и заставить их сталкиваться друг с другом, воспользуйтесь инструментами физики.
Что касается физики, существует множество решений, совместимых с Three.js. В зависимости от проекта, это может быть 3D или только 2D физика, с поддержкой мягких тел или без нее и т.д. Некоторые библиотеки будут включать в себя определенные функции, например, моделирование транспортных средств или ткани. Некоторые лучше совместимы с React, некоторые имеют богатые инструменты отладки и т.д.
В этом учебнике у нас есть только кубики, падающие на поверхность. С точки зрения физики, наши требования таковы:
- Только жесткие тела (форма кубика не будет деформироваться, поэтому нам не нужна поддержка мягких тел)
- Формы объектов Box и Plane
- Обнаружение столкновений, чтобы кубики сталкивались друг с другом и с нижней плоскостью
- Силы, чтобы бросить кубики и позволить им упасть на пол
Технически, это все очень базовые требования, и любая библиотека 3D физики будет работать. Поэтому имеет смысл рассмотреть только:
- минимальный размер
- достойная поддержка (документация, примеры и т.д.)
Cannon-es хорошо подходит. Библиотека в минимальной комплектации добавит всего 40-50 кб к проекту. Это похоже на самый низкий уровень, который можно получить для 3D физики на данный момент. Иногда указывается, что Physijs имеет примерно такой же размер, но на самом деле Physijs - это плагин, который помогает использовать библиотеку ammo.js, поэтому вам всегда нужно загружать оба файла.
Поддержка cannon-es на данный момент в порядке - не так хорошо, как для Three.js или GSAP, но хорошо по сравнению с другими доступными вариантами физики.
Вы можете увидеть, что библиотека называется устаревшей, но это всего лишь путаница между Cannon.js и cannon-es. Первая - это оригинальный инструмент, который не обновлялся с 2016 года. На самом деле, он по-прежнему совместим с современным Three.js. Но что более важно, он был форкнут в 2020 году замечательной командой pmndrs и с тех пор регулярно обновляется как JS-модуль и как React-компонент.
Вы можете найти множество статей и примеров на эту тему. Старый контент все еще может быть очень полезен, хотя идеальной совместимости между различными версиями Cannon.js и cannon-es не существует.
Three.js сцена и физический мир
Основная идея заключается в построении трехмерного физического мира параллельно с трехмерной сценой. Мы добавляем в физический мир все 3D-объекты, которые должны двигаться и взаимодействовать как тела одинаковой формы. После применения физических свойств и сил к физическим телам, мы позволяем движку моделировать физический мир кадр за кадром. На каждом этапе симуляции мы берем трансформации, рассчитанные для физических тел, применяем их к видимым объектам и рендерим (перерисовываем) сцену Three.js.
Вот созданный объект physicsWorld. Чуть позже мы добавим к нему несколько свойств.
let physicsWorld = new CANNON.World({})
Я опускаю часть с созданием сцены Three.js, поскольку это всего лишь базовая установка с парой света и теней. И если вы дошли до этого момента, то, скорее всего, вы уже знакомы с Three.js 🙂 .
Чтобы создать пол, мы добавляем в сцену горизонтальную плоскость. Она не имеет цвета и только получает тени. Затем мы добавляем эту же плоскость в физический мир как статический объект. Она наследует положение и вращение сетки, и поскольку пол статичен, эти значения не будут меняться.
function createFloor() {
// Three.js (visible) object
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(1000, 1000),
new THREE.ShadowMaterial({
opacity: .1
})
)
floor.receiveShadow = true;
floor.position.y = -7;
floor.quaternion.setFromAxisAngle(new THREE.Vector3(-1, 0, 0), Math.PI * .5);
scene.add(floor);
// Cannon-es (physical) object
const floorBody = new CANNON.Body({
type: CANNON.Body.STATIC,
shape: new CANNON.Plane(),
});
floorBody.position.copy(floor.position);
floorBody.quaternion.copy(floor.quaternion);
physicsWorld.addBody(floorBody);
}
Для создания кубиков мы используем функцию createDiceMesh(), описанную выше, без особых изменений. Для нескольких кубиков мы можем клонировать исходный объект для повышения производительности.
const diceArray = []; // to store { mesh, body } for a pair of visible mesh and physical body
diceMesh = createDiceMesh(); // returns dice as a THREE.Group()
for (let i = 0; i < params.numberOfDice; i++) {
diceArray.push(createDice());
}
function createDice() {
const mesh = diceMesh.clone();
scene.add(mesh);
const body = new CANNON.Body({
mass: 1,
shape: new CANNON.Box(new CANNON.Vec3(.5, .5, .5)),
});
physicsWorld.addBody(body);
return {mesh, body};
}
Вы можете заметить, что сетка кубиков основана на THREE.BoxGeometry(1, 1, 1), в то время как CANNON.Box основан на size = 0.5. Так что да, cannon-es box принимает половину размера в качестве аргумента, в то время как Three.js принимает полный размер, и у меня нет объяснения этому ¯*(ツ)*/¯. Вы можете поймать такие вещи с помощью cannon-es-debugger, который генерирует видимые wireframes для физических тел.
Как и пол, сетка кубиков должна иметь такое же положение и вращение, как и тело кубика. Но в отличие от пола, свойства кубиков анимированы. Поэтому мы заботимся о трансформации кубиков вне функции createDice().
Анимирование кубиков
Мы управляем анимацией кубиков с помощью двух функций:
- throwDice(), где положение игральных костей сбрасывается до начальных значений. Функция будет вызываться в любое время пользователем, чтобы бросить игральную кость
- render(), где рассчитывается следующий шаг физического мира, обновляется положение тела кости, и мы копируем его на видимую сетку. Функция выполняется в бесконечном цикле requestAnimationFrame()
function render() {
// recalculate the physics world
physicsWorld.fixedStep();
// apply recalculated values to visible elements
for (const dice of diceArray) {
dice.mesh.position.copy(dice.body.position)
}
// redraw the scene
renderer.render(scene, camera);
requestAnimationFrame(render);
}
function throwDice() {
diceArray.forEach((d, dIdx) => {
d.body.position = new CANNON.Vec3(5, dIdx * 1.5, 0); // the floor is placed at y = -7
d.mesh.position.copy(d.body.position);
});
}
Сейчас у нас есть кубики, висящие над полом. Они не двигаются, так как к ним еще не приложена сила.

Первая сила, которую мы добавляем, - это всемирное тяготение.
function initPhysics() {
physicsWorld = new CANNON.World({
gravity: new CANNON.Vec3(0, -50, 0),
})
}
Обычно гравитация задается как вертикальная сила со значением y = -9.8. Это относится к гравитации Земли (9,807 м/с²) и предполагает, что вы хотите сохранить физику в ”настоящих” единицах СИ: силы в м/с², массы в килограммах, расстояния в метрах и так далее. Это возможно, но гравитация 9.8 имеет смысл только в том случае, если вы сохраняете все остальные свойства мира, объектов и материалов физически корректными. Плюс, в этом случае .fixedStep() следует заменить на функцию .step(), чтобы изменить скорость симуляции на “реальные” секунды.
На практике использование значения гравитации Земли редко бывает необходимым, и добиться даже реалистичного движения можно, просто экспериментируя с различными комбинациями сил, скорости, вращения и массы. Гравитация - это сила, влияющая на все динамические тела. Она не обязана иметь определенное значение или быть направленной вниз.
Для кубиков мы используем вертикальную силу с y = 50. Она заставляет кубики падать на пол.
Чтобы сделать кубики более упругими, мы изучим понятия материала и контактного материала. Материал - это свойство каждого физического тела, а материал контакта - это пушечная сущность, описывающая взаимодействие пары материалов.
Поскольку все кубики являются одинаковыми физическими телами и в сцене нет других динамических объектов, достаточно использовать один материал и один контактный материал. Библиотека cannon-es предоставляет world.defaultMaterial и world.defaultContactMaterial, которые применяются автоматически. Поэтому нет необходимости создавать новые.
Параметры по умолчанию для материала контакта, включая трение и реституцию, уже определены в библиотеке. Хотя трение по умолчанию кажется достаточным, мы увеличим значение реституции с 0 до 0,3, чтобы добиться более упругого эффекта.
function initPhysics() {
// ...
physicsWorld.defaultContactMaterial.restitution = .3;
}
Обновление значения реституции приводит к более живому и энергичному движению кубиков.
Добавление случайного начального вращения к каждой кости обеспечивает более естественное и непредсказуемое движение броска:
function throwDice() {
diceArray.forEach((d, dIdx) => {
// to reset the velocity dice got on the previous throw
d.body.velocity.setZero();
d.body.angularVelocity.setZero();
// set initial position
// ...
// set initial rotation
d.mesh.rotation.set(2 * Math.PI * Math.random(), 0, 2 * Math.PI * Math.random())
d.body.quaternion.copy(d.mesh.quaternion);
});
}
Поскольку вращение кубика, как и его положение, динамично, мы также обновляем кватернион кубика на каждом шаге моделирования.
function render() {
// recalculate the physics world
// ...
// apply recalculated values to visible elements
for (const dice of diceArray) {
dice.mesh.position.copy(dice.body.position);
dice.mesh.quaternion.copy(dice.body.quaternion);
}
// redraw the scene
// ...
}
Сейчас кубики падают вниз под действием силы тяжести. Чтобы бросить их, нам нужно вскоре приложить дополнительную силу. Другими словами, приложить произвольный импульс, который заставит кубик лететь немного вверх и влево.
function throwDice() {
diceArray.forEach((d, dIdx) => {
// reset velocity, set initial position & rotation
// ...
const force = 3 + 5 * Math.random();
d.body.applyImpulse(
new CANNON.Vec3(-force, force, 0)
);
});
}
Без второго аргумента сила applyImpulse() добавляется к центру масс кубика. Но если мы немного сместим его от точки по умолчанию (0, 0, 0), импульс придаст дополнительную угловую скорость и закрутит кубик.
d.body.applyImpulse(
new CANNON.Vec3(-force, force, 0),
new CANNON.Vec3(0, 0, .2) // point of application of force is shifted from the center of mass
);
Вот и все. Кубики бросаются случайно, но в то же время они приземляются в предсказуемой области.
Проверка верхней стороны
Последнее, что нужно сделать, это проверить верхнюю сторону каждого кубика после завершения броска. Помимо добавления таких элементов, как кнопка для броска костей и место для отображения счета, нам необходимо:
- запечатлеть момент неподвижности для каждого броска игральных костей
- проверьте окончательное вращение и получите число верхней стороны
В Cannon-es есть несколько удобных обратных вызовов, которые мы можем использовать здесь: sleepyEvent, sleepEvent и wakeupEvent. После установки опции allowSleep в true для мира физики мы можем получить доступ к этим событиям. Cannon-es отслеживает скорость движения тела и запускает события, связанные со сном, используя sleepSpeedLimit и sleepTimeLimit. Как только скорость становится меньше sleepSpeedLimit, мы получаем событие sleepy, а если состояние sleepy длится дольше sleepTimeLimit, то получаем событие sleep.
function initPhysics() {
physicsWorld = new CANNON.World({
allowSleep: true,
// ...
})
}
function initScene() {
// ...
for (let i = 0; i < params.numberOfDice; i++) {
diceArray.push(createDice());
addDiceEvents(diceArray[i]);
}
// ...
}
function addDiceEvents(dice) {
dice.body.addEventListener('sleep', (e) => {
// ...
});
}
Пределы настраиваются, и мы можем изменить sleepTimeLimit с 1 секунды по умолчанию на 0,1 секунды. Таким образом, у нас есть обратный вызов sleep при условии ”кубик имеет стабильное положение в течение 100 мс подряд”.
function createDice() {
// ...
const body = new CANNON.Body({
// ...
sleepTimeLimit: .1 // change from default 1 sec to 100ms
});
// ...
}
Событие срабатывает, когда бросок кубика завершен. Скорее всего, это означает, что игральная кость лежит на полу или (редко и при большом количестве костей) она устойчиво встала на ребро. В первом случае мы отключаем отслеживание скорости для тела кубика и проверяем конечную ориентацию, чтобы считать счет. Если кубик балансирует на грани, мы сохраняем трекер сна включенным.
function addDiceEvents(dice) {
dice.body.addEventListener('sleep', (e) => {
dice.body.allowSleep = false;
// check the dice rotation
if (lyingOnSide) {
// show result
} else {
// landed on edge => wait to fall on side and fire the event again
dice.body.allowSleep = true;
}
});
}
function throwDice() {
diceArray.forEach((d, dIdx) => {
// ...
// track body velocity again for new throw
d.body.allowSleep = true;
});
}
Чтобы проверить ориентацию кубика, а именно сторону, которая оказалась сверху, мы принимаем вращение за вектор Эйлера и анализируем эйлеровы компоненты.
const euler = new CANNON.Vec3();
dice.body.quaternion.toEuler(euler);
Известно, что
- игральная кость была создана таким образом, чтобы сторона №1 была на Yplus, сторона №6 на Yminus, сторона №2 на Xplus, сторона №5 на Xminus, сторона №3 на Zplus и сторона №4 на Zminus
- поверхность пола перпендикулярна оси Y
- кватернион преобразуется к эйлеровой системе координат в порядке YZX

Вращение, хранящееся в векторе Эйлера, - это набор из трех вращений, которые выполняются в порядке YZX по отношению к локальной системе координат. Таким образом, первое вращение происходит вокруг локальной оси Y (которая совпадает с мировой осью Y), затем вокруг локальной оси Z (которая теперь может отличаться от мировой оси Z), затем локальное вращение X (которое может отличаться от мировой оси X).
Первое вращение по оси Y может быть абсолютно случайным, и кубик все равно будет лежать на полу стороной №1 вверх. Это означает, что нам не нужен euler.y для вычисления результата.
Очевидно, что euler.z и euler.x должны быть кратны π/2, поэтому мы можем перебрать возможные комбинации.
Как уже говорилось, при euler.z = 0 и euler.x = 0 мы получим сторону №1.
При euler.z = 0 вращение куба вокруг локальной оси X на π/2, -π/2 и на -π (-π здесь равно π) приводит к ориентации сторон #4, #3 и #6 соответственно.
При euler.z = 0.5 куб поворачивается вокруг оси Z на π/2, и мы имеем сторону #2 на вершине независимо от поворота по оси X. Фактически, ось X теперь совпадает с исходной осью Y, поэтому мы получили блокировку Gimbal lock.
То же самое для случая поворота Z на -π/2. Ось X снова фиксируется, и мы всегда имеем сторону №5 сверху.
Согласно определению угла Эйлера, второе вращение (Z-вращение с порядком YZX) охватывает только диапазон π (в то время как первое и третье вращения имеют диапазон 2 x π). Другими словами, Z-вращение определено только между -π/2 и π/2, так что все варианты углов кубика покрыты.
const eps = .1;
let isZero = (angle) => Math.abs(angle) < eps;
let isHalfPi = (angle) => Math.abs(angle - .5 * Math.PI) < eps;
let isMinusHalfPi = (angle) => Math.abs(.5 * Math.PI + angle) < eps;
let isPiOrMinusPi = (angle) => (Math.abs(Math.PI - angle) < eps || Math.abs(Math.PI + angle) < eps);
if (isZero(euler.z)) {
if (isZero(euler.x)) {
showRollResults(1);
} else if (isHalfPi(euler.x)) {
showRollResults(4);
} else if (isMinusHalfPi(euler.x)) {
showRollResults(3);
} else if (isPiOrMinusPi(euler.x)) {
showRollResults(6);
} else {
// landed on edge => wait to fall on side and fire the event again
dice.body.allowSleep = true;
}
} else if (isHalfPi(euler.z)) {
showRollResults(2);
} else if (isMinusHalfPi(euler.z)) {
showRollResults(5);
} else {
// landed on edge => wait to fall on side and fire the event again
dice.body.allowSleep = true;
}
В этом учебнике мы рассмотрели использование Three.js и cannon-es для создания динамичного и интерактивного ролика для игры в кости. Манипулируя 3D-формами, экспериментируя с различными комбинациями сил и настраивая свойства физических тел, мы смогли создать реалистичную и увлекательную симуляцию. Элементы пользовательского интерфейса и дополнительный код для этого проекта можно найти в сопроводительном репозитории. Мы рекомендуем вам загрузить проект и поэкспериментировать с различными настройками и параметрами, чтобы углубить свое понимание и навыки в анимации и физическом моделировании. Получайте удовольствие!
Check out the final demo and see the full code in the GitHub repo. 🎲 🤘