Посмотрите живую демонстрацию здесь: https://martisor.vercel.app/

Пройдя долгий и сложный путь изучения Three.js по учебникам Бруно Саймона, я с гордостью представляю плоды своего труда — мой первый проект на Three.js! Создание этой анимации было немалым подвигом, так почему бы не поделиться ею с другими, которые могут решить ту же задачу?

Чтобы подготовить почву, я начну этот рассказ с его вывода. Во-первых, вы увидите, что я построил, и, в конце концов, поймете мой первоначальный интерес к созданию сердца и лука с помощью Three.js.

Для тех, кто не знаком с Three.js

Ваш «Hello World» в Three.js

Прежде чем приступить к программированию, необходимо запустить сценарий Hello World, чтобы убедиться, что ваш код выполняется правильно и приводит к соответствующему результату. Лично для меня (в данном случае) это означает создание белого куба на черном холсте с помощью Three.js — действие, которое проверяет правильность положения моей камеры и масштаба куба для просмотра. Это простое упражнение служит подтверждением правильных настроек перед тем, как я перейду к более сложным проектам.

import * as THREE from "three";

// Scene
const scene = new THREE.Scene();

// Canvas
const canvas = document.querySelector("canvas.webgl");

// Cube
const cube = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 0xffffff }));
scene.add(cube);

// Sizes
const sizes = {
 width: window.innerWidth,
 height: window.innerHeight,
};

window.addEventListener("resize", () => {
 // Update sizes
 sizes.width = window.innerWidth;
 sizes.height = window.innerHeight;

 // Camera
 camera.aspect = sizes.width / sizes.height;
 camera.updateProjectionMatrix();

 // Update renderer
 renderer.setSize(sizes.width, sizes.height);
 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

//Camera
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height);
camera.position.z = 3;
scene.add(camera);

// Renderer
const renderer = new THREE.WebGLRenderer({
 canvas: canvas,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.render(scene, camera);

// If you are new to Three.js, try and understand what do you need each row from this code.
// windows.addEventListener will help you see the cube even if you minimize your page.

Примечание. Документ HTML и CSS можно найти в моем репозитории GitHub.

Теперь давайте придадим этому блоку больше гибкости, введя контроль, чтобы вы могли наблюдать форму, выходящую за рамки стандартного квадрата:

import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

//Camera
// ...

// Controls
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;

// Renderer
// ...

// Animate
const tick = () => {
 // Update controls
 controls.update();

 // Render
 renderer.render(scene, camera);

 // Call tick again on the next frame
 window.requestAnimationFrame(tick);
};

tick();

// You can play with it: change the cube's color, size
// Or you can change the camera position just to see how the things work in Three.js

Добавление графического интерфейса

Кроме того, мы можем повеселиться с кубом прямо с главной страницы.

Добавление графического интерфейса может помочь сделать ваше приложение Three.js более интерактивным, удобным для пользователя и визуально привлекательным. Это также может упростить процесс отладки и тестирования вашего приложения, предоставляя простой способ просмотра и изменения различных свойств объектов в сцене.

Я использовал lil-gui для своего проекта, но вы можете изучить и другие альтернативы.

import * as dat from "lil-gui";

// Add GUI
const gui = new dat.GUI();
// Cube
// ... 

// Add debugging after you created the cube
gui.add(cube.position, "x").name("cubeX").min(-20).max(20).step(0.0001);
gui.add(cube.position, "y").name("cubeY").min(-20).max(20).step(0.0001);
gui.add(cube.position, "z").name("cubeZ").min(-20).max(20).step(0.0001);

// Now you can play with the cube from the website page

На данный момент, имея рабочий куб и графический интерфейс, я чувствую, что моя среда готова для создания искусства.

Начнем с формы сердца.

Сначала я был убежден, что формировать форму сердца для меня слишком сложно. Однако это оказалось намного проще, чем ожидалось, при использовании документации Three.js, которая содержала все необходимые детали и инструкции о том, как его создать.

Обновите свой скрипт, заменив куб Сердцем.

Не забудьте удалить поле из графического интерфейса в это время. Мы можем персонализировать его впоследствии для нашего сердечного объекта.

// Heart Object
const heartX = -25;
const heartY = -25;
const heartShape = new THREE.Shape();
heartShape.moveTo(25 + heartX, 25 + heartY);
heartShape.bezierCurveTo(25 + heartX, 25 + heartY, 20 + heartX, 0 + heartY, 0 + heartX, 0 + heartY);
heartShape.bezierCurveTo(-30 + heartX, 0 + heartY, -30 + heartX, 35 + heartY, -30 + heartX, 35 + heartY);
heartShape.bezierCurveTo(-30 + heartX, 55 + heartY, -10 + heartX, 77 + heartY, 25 + heartX, 95 + heartY);
heartShape.bezierCurveTo(60 + heartX, 77 + heartY, 80 + heartX, 55 + heartY, 80 + heartX, 35 + heartY);
heartShape.bezierCurveTo(80 + heartX, 35 + heartY, 80 + heartX, 0 + heartY, 50 + heartX, 0 + heartY);
heartShape.bezierCurveTo(35 + heartX, 0 + heartY, 25 + heartX, 25 + heartY, 25 + heartX, 25 + heartY);

const extrudeSettings = {
 depth: 8,
 bevelEnabled: true,
 bevelSegments: 2,
 steps: 2,
 bevelSize: 1,
 bevelThickness: 1,
};

const materialRed = new THREE.MeshBasicMaterial({
 color: 0xffffff,
});

const geometryHeart = new THREE.ExtrudeGeometry(heartShape, extrudeSettings);
const meshHeart = new THREE.Mesh(geometryHeart, materialRed);

meshHeart.scale.set(0.01, 0.01, 0.01);

scene.add(meshHeart);

Я скорректировал объект и отцентрировал его, добавив heartX и heartY к исходной формуле.

Если вы хотите повернуть его, просто добавьте:

meshHeart.rotation.set(0, 0, Math.PI);

// Or you can play with it and customize it's rotation settings.
meshHeart.rotation.set(0, 0, Math.PI - Math.PI / 8);

// Now you can adjust GUI for heartObject
gui.add(meshHeart.position, "x").name("X").min(-20).max(20).step(0.0001);
gui.add(meshHeart.position, "y").name("Y").min(-20).max(20).step(0.0001);
gui.add(meshHeart.position, "z").name("Z").min(-20).max(20).step(0.0001);

// Also, you can play with rotation settings
gui.add(meshHeart.rotation, "x").name("rotateX").min(-20).max(20).step(0.0001);
gui.add(meshHeart.rotation, "y").name("rotateY").min(-20).max(20).step(0.0001);
gui.add(meshHeart.rotation, "z").name("rotateX").min(-20).max(20).step(0.0001);

Создайте форму лука

Несмотря на все мои усилия, мне не удалось найти в Интернете формулу построения формы банта (подобной той, что используется в качестве украшения). К счастью, поскольку луки по своей природе очень симметричны, вскоре я понял, как сделать их самостоятельно.

Сначала я разделил лук на несколько частей:

Начнем с петель лука

// Define the control points of the curve that defines the shape of the loop
const curve = new THREE.CatmullRomCurve3([
 new THREE.Vector3(0, 0, 0),
 new THREE.Vector3(1, 0.5, 0),
 new THREE.Vector3(1.5, 0, 0),
 new THREE.Vector3(1, -0.5, 0),
 new THREE.Vector3(0, 0, 0),
]);

// Define the radius and number of segments of the tube geometry
const radius = 0.01;
const segments = 64;

// Create a tube geometry along the curve with the specified radius and segments
const loopGeometryLeft = new THREE.TubeGeometry(curve, segments, radius);

// Create a mesh from the tube geometry with a material
const bowMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
const loopMeshLeft = new THREE.Mesh(loopGeometryLeft, bowMaterial);

scene.add(loopMeshLeft);

С контрольными точками кривой вы можете настроить свою петлю так, чтобы она была тугой или свободной по желанию. Проявите творческий подход и создайте идеальную петлю для любого применения!

Для создания зеркальной петли у нас есть два варианта — либо настроить координаты вектора по оси X вручную, либо придумать для этого функцию.

// Mirror the curve by negating the x-coordinates of its control points for the left bow
const mirroredPoints = [];
curve.points.forEach(function (point) {
 mirroredPoints.push(new THREE.Vector3(-point.x, point.y, point.z));
});
const mirroredCurve = new THREE.CatmullRomCurve3(mirroredPoints);

// Create a tube geometry along the mirrored curve with the specified radius and segments
const loopGeometryRight = new THREE.TubeGeometry(mirroredCurve, segments, radius);

// Create a mesh from the mirrored tube geometry with a material
const loopMeshRight = new THREE.Mesh(loopGeometryRight, bowMaterial);

scene.add(loopMeshRight);

Фантастическая работа! (про себя говорю). Я уверен, что вы уже запустили свою программу Three.js, чтобы увидеть потрясающий результат, и она работает без сбоев. В случае возникновения каких-либо проблем, пожалуйста, оставьте мне сообщение, чтобы вместе мы могли решить эту проблему.

Перейдем к лентам.

const ribbonCurve = new THREE.CatmullRomCurve3([
 new THREE.Vector3(0, 0, 0),
 new THREE.Vector3(1, -1.5, 0),
 // new THREE.Vector3(1, -1.7, 0),
]);

// Create tube geometries along the ribbon curves with the specified radius and segments
const ribbonGeometryLeft = new THREE.TubeGeometry(ribbonCurve, segments, radius * 0.5);

// Create meshes from the ribbon geometries with the bow material
const ribbonMeshLeft = new THREE.Mesh(ribbonGeometryLeft, bowMaterial);

scene.add(ribbonMeshLeft);

Вы также можете настроить ленту. Удалив комментарии из третьей пары векторов, вы можете добавить своей ленте уникальный «хвост». Кроме того, попробуйте добавить еще один вектор в качестве индивидуального штриха, чтобы придать ему более индивидуальный вид!

Для левой ленты применим аналогичное обоснование — разработаем алгоритм, который будет инвертировать координаты по оси X.

// Mirror the curve by negating the x-coordinates of its control points
const mirroredRibbonCurve = [];
ribbonCurve.points.forEach(function (point) {
 mirroredRibbonCurve.push(new THREE.Vector3(-point.x, point.y, point.z));
});
const mirroredRibbon = new THREE.CatmullRomCurve3(mirroredRibbonCurve);

// Create tube geometries along the ribbon curves with the specified radius and segments
const ribbonGeometryRight = new THREE.TubeGeometry(mirroredRibbon, segments, radius * 0.5);

// Create meshes from the ribbon geometries with the bow material
const ribbonMeshRight = new THREE.Mesh(ribbonGeometryRight, bowMaterial);

scene.add(ribbonMeshRight);

Все, что требуется сейчас, — это сформировать группу BowGroup путем слияния loopMeshLeft, loopMeshRight, ленточного MeshLeft и ленточного MeshRight.

const bowGroup = new THREE.Group();

// Substituite in your code where you have scene.add() with bowGroup.add()
// The at the end you can add your group to the scene

scene.add(bowGroup);

Вуаля!

Я надеюсь, что вы дошли до этого момента без каких-либо ошибок и что ваш конечный продукт — это 3D-лук и 3D-сердце, созданные с помощью Three.js!

А Мэрцишор?

Теперь позвольте мне представить значение Мэрцишор.

Мэрцишор — традиционный румынский праздник, отмечаемый 1 марта. Слово «мэрцишор» происходит от румынского слова «martie», что означает март. Этот праздник связан с приходом весны и отмечается вручением и получением небольших подарков, обычно в виде плетеных красных и белых ниток.

Нить Мэрцишор традиционно изготавливается из двух переплетенных нитей, белой и красной, которые символизируют приход весны и обновление жизни. Нить обычно носят в виде броши, прикалывают к одежде или завязывают на запястье и носят весь март.

Этой весной ничто так не говорит «Я люблю тебя», как Мэрцишор, сделанный из двух сердечек и бантика белого и красного цветов. Символизируя единство, это идеальный способ выразить свои чувства.

Также в конце марта нить Мэрцишор обычно снимают и привязывают к ветке плодового или цветущего дерева, например вишни, яблони или груши. Это делается в надежде, что дерево будет благословлено плодородным годом и что владелец Мэрцишора будет здоровым и сильным в течение всего года.

Чтобы объединить все объекты, созданные выше, я создал еще одну группу специально для Мэрцишора.

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import * as dat from "lil-gui";

// Debug
const gui = new dat.GUI();

// Scene
const scene = new THREE.Scene();

// Canvas
const canvas = document.querySelector("canvas.webgl");

// Heart group
const heartGroup = new THREE.Group();

// Heart Object
const heartX = -25;
const heartY = -25;
const heartShape = new THREE.Shape();
heartShape.moveTo(25 + heartX, 25 + heartY);
heartShape.bezierCurveTo(25 + heartX, 25 + heartY, 20 + heartX, 0 + heartY, 0 + heartX, 0 + heartY);
heartShape.bezierCurveTo(-30 + heartX, 0 + heartY, -30 + heartX, 35 + heartY, -30 + heartX, 35 + heartY);
heartShape.bezierCurveTo(-30 + heartX, 55 + heartY, -10 + heartX, 77 + heartY, 25 + heartX, 95 + heartY);
heartShape.bezierCurveTo(60 + heartX, 77 + heartY, 80 + heartX, 55 + heartY, 80 + heartX, 35 + heartY);
heartShape.bezierCurveTo(80 + heartX, 35 + heartY, 80 + heartX, 0 + heartY, 50 + heartX, 0 + heartY);
heartShape.bezierCurveTo(35 + heartX, 0 + heartY, 25 + heartX, 25 + heartY, 25 + heartX, 25 + heartY);

const extrudeSettings = {
 depth: 8,
 bevelEnabled: true,
 bevelSegments: 2,
 steps: 2,
 bevelSize: 1,
 bevelThickness: 1,
};
const white = "#ffffff";
const red = "#ff0000";
const redMaterial = new THREE.MeshBasicMaterial({
 color: red,
});

const whiteMaterial = new THREE.MeshBasicMaterial({
 color: white,
});
const heartGeometryRight = new THREE.ExtrudeGeometry(heartShape, extrudeSettings);
const hearMeshRight = new THREE.Mesh(heartGeometryRight, redMaterial);

const heartGeometryLeft = new THREE.ExtrudeGeometry(heartShape, extrudeSettings);
const hearMeshLeft = new THREE.Mesh(heartGeometryLeft, whiteMaterial);

hearMeshRight.scale.set(0.01, 0.01, 0.01);
hearMeshRight.rotation.set(0, 0, Math.PI - Math.PI / 8);
hearMeshLeft.scale.set(0.01, 0.01, 0.01);
hearMeshLeft.position.set(-1.66, -0.35, 0);
hearMeshLeft.rotation.set(0, 0, Math.PI + Math.PI / 7);

heartGroup.add(hearMeshRight, hearMeshLeft);
heartGroup.position.set(1, -1.7, 0);

scene.add(heartGroup);

// Gui
gui.add(heartGroup.position, "x").min(-10).max(10).step(0.01).name("heartX");
gui.add(heartGroup.position, "y").min(-10).max(10).step(0.01).name("heartY");
gui.add(heartGroup.position, "z").min(-10).max(10).step(0.01).name("heartZ");

// Bow group
const bowGroup = new THREE.Group();

// Define the control points of the curve that defines the shape of the bow
const loopCurve = new THREE.CatmullRomCurve3([
 new THREE.Vector3(0, 0, 0),
 new THREE.Vector3(1, 0.5, 0),
 new THREE.Vector3(1.5, 0, 0),
 new THREE.Vector3(1, -0.5, 0),
 new THREE.Vector3(0, 0, 0),
]);

// Define the radius and number of segments of the tube geometry
const radius = 0.03;
const segments = 64;

// Create a tube geometry along the curve with the specified radius and segments
const loopGeometryRight = new THREE.TubeGeometry(loopCurve, segments, radius);

// Create a mesh from the tube geometry with a material
const bowMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 });
const meshLoopRight = new THREE.Mesh(loopGeometryRight, bowMaterial);

// Mirror the curve by negating the x-coordinates of its control points for the left bow
const mirroredPoints = [];
loopCurve.points.forEach(function (point) {
 mirroredPoints.push(new THREE.Vector3(-point.x, point.y, point.z));
});
const mirroredCurve = new THREE.CatmullRomCurve3(mirroredPoints);

// Create a tube geometry along the mirrored curve with the specified radius and segments
const loopGeometryLeft = new THREE.TubeGeometry(mirroredCurve, segments, radius);

// Create a mesh from the mirrored tube geometry with a material
const meshLoopLeft = new THREE.Mesh(loopGeometryLeft, bowMaterial);

const ribbonCurve = new THREE.CatmullRomCurve3([
 new THREE.Vector3(0, 0, 0),
 new THREE.Vector3(1, -1.5, 0),
 new THREE.Vector3(1, -1.7, 0),
 // new THREE.Vector3(2 * bowx, -0.2 * bowx, 0),
]);

// Mirror the curve by negating the x-coordinates of its control points
const mirroredRibbonCurve = [];
ribbonCurve.points.forEach(function (point) {
 if (point.x != 0) {
  mirroredRibbonCurve.push(new THREE.Vector3(-point.x + 0.3, point.y - 0.3, point.z));
 } else {
  mirroredRibbonCurve.push(new THREE.Vector3(point.x, point.y, point.z));
 }
});
const mirroredRibbon = new THREE.CatmullRomCurve3(mirroredRibbonCurve);

// Create tube geometries along the ribbon curves with the specified radius and segments
const ribbonGeometryRight = new THREE.TubeGeometry(ribbonCurve, segments, radius * 0.5);
const ribbonGeometryLeft = new THREE.TubeGeometry(mirroredRibbon, segments, radius * 0.5);
// Create meshes from the ribbon geometries with the bow material
const ribbonMeshRight = new THREE.Mesh(ribbonGeometryRight, bowMaterial);
const ribbonMeshLeft = new THREE.Mesh(ribbonGeometryLeft, bowMaterial);

bowGroup.add(meshLoopRight);
bowGroup.add(meshLoopLeft);
bowGroup.add(ribbonMeshLeft);
bowGroup.add(ribbonMeshRight);
scene.add(bowGroup);

// Sizes
const sizes = {
 width: window.innerWidth,
 height: window.innerHeight,
};

window.addEventListener("resize", () => {
 // Update sizes
 sizes.width = window.innerWidth;
 sizes.height = window.innerHeight;

 // Update camera
 camera.aspect = sizes.width / sizes.height;
 camera.updateProjectionMatrix();

 // Update renderer
 renderer.setSize(sizes.width, sizes.height);
 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

// Camera
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100);
camera.position.z = 3;
scene.add(camera);

// Controls
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;

// Renderer
const renderer = new THREE.WebGLRenderer({
 canvas: canvas,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

// Animate
const tick = () => {
 // Update controls
 controls.update();

 // Render
 renderer.render(scene, camera);

 // Call tick again on the next frame
 window.requestAnimationFrame(tick);
};

tick();

Вместо традиционного объектно-ориентированного программирования я решил применить функциональный подход при рефакторинге своего кода.

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import * as dat from "lil-gui";

const createHeart = (heartShape, material, extrudeSettings, isLeft, scale) => {
 let geometry = new THREE.ExtrudeGeometry(heartShape, extrudeSettings);
 let mesh = new THREE.Mesh(geometry, material);

 if (isLeft) {
  mesh.position.set(-1.67, -0.35, 0);
  mesh.rotation.set(0, 0, Math.PI + Math.PI / 7);
 } else {
  mesh.position.set(0, 0, 0);
  mesh.rotation.set(0, 0, Math.PI - Math.PI / 8);
 }

 mesh.scale.set(scale, scale, scale);

 return mesh;
};

const createBow = (loopCurve, ribbonCurve, material, radius, segments) => {
 const bowGroup = new THREE.Group();

 // Create a tube geometry along the curve with the specified radius and segments
 const loopGeometryRight = new THREE.TubeGeometry(loopCurve, segments, radius);

 // Create a mesh from the tube geometry with a material
 const loopMeshRight = new THREE.Mesh(loopGeometryRight, material);

 // Mirror the curve by negating the x-coordinates of its control points for the left bow
 const mirroredPoints = [];
 loopCurve.points.forEach((point) => {
  mirroredPoints.push(new THREE.Vector3(-point.x, point.y, point.z));
 });
 const mirroredCurve = new THREE.CatmullRomCurve3(mirroredPoints);

 // Create a tube geometry along the mirrored curve with the specified radius and segments
 const loopGeometryLeft = new THREE.TubeGeometry(mirroredCurve, segments, radius);

 // Create a mesh from the mirrored tube geometry with a material
 const loopMeshLeft = new THREE.Mesh(loopGeometryLeft, material);

 // Mirror the curve by negating the x-coordinates of its control points
 const mirroredRibbonCurve = [];
 ribbonCurve.points.forEach((point) => {
  if (point.x != 0) {
   // Here I am trying to move the points a bit, so that the left ribbon is quite different from the right one
   mirroredRibbonCurve.push(new THREE.Vector3(-point.x + 0.3, point.y - 0.3, point.z));
  } else {
   mirroredRibbonCurve.push(new THREE.Vector3(point.x, point.y, point.z));
  }
 });
 const mirroredRibbon = new THREE.CatmullRomCurve3(mirroredRibbonCurve);

 // Create tube geometries along the ribbon curves with the specified radius and segments
 const ribbonGeometryRight = new THREE.TubeGeometry(ribbonCurve, segments, radius * 0.5);
 const ribbonGeometryLeft = new THREE.TubeGeometry(mirroredRibbon, segments, radius * 0.5);

 // Create meshes from the ribbon geometries with the bow material
 const ribbonMeshRight = new THREE.Mesh(ribbonGeometryRight, material);
 const ribbonMeshLeft = new THREE.Mesh(ribbonGeometryLeft, material);

 bowGroup.add(loopMeshRight);
 bowGroup.add(loopMeshLeft);
 bowGroup.add(ribbonMeshRight);
 bowGroup.add(ribbonMeshLeft);

 return bowGroup;
};

const createMartisor = (bow, heartLeft, heartRight) => {
 const heartGroup = new THREE.Group();
 heartGroup.add(heartLeft);
 heartGroup.add(heartRight);
 heartGroup.position.set(1, -1.65, -0.02);

 const martisorGroup = new THREE.Group();
 martisorGroup.add(bow);
 martisorGroup.add(heartGroup);
 martisorGroup.position.set(0, 0, 0);

 return martisorGroup;
};

// Animate
const tick = (controls, renderer, scene, camera) => {
 // Update controls
 controls.update();

 // Render
 renderer.render(scene, camera);

 // Call tick again on the next frame
 window.requestAnimationFrame(() => tick(controls, renderer, scene, camera));
};

const main = () => {
 // Debug
 const gui = new dat.GUI();

 // Scene
 const scene = new THREE.Scene();

 // Canvas
 const canvas = document.querySelector("canvas.webgl");

 // Materials and other variables
 const white = "#ffffff";
 const red = "#ff0000";
 const redMaterial = new THREE.MeshBasicMaterial({
  color: red,
 });
 const whiteMaterial = new THREE.MeshBasicMaterial({
  color: white,
 });

 // Heart Object
 const heartX = -25;
 const heartY = -25;
 const heartShape = new THREE.Shape();
 heartShape.moveTo(25 + heartX, 25 + heartY);
 heartShape.bezierCurveTo(25 + heartX, 25 + heartY, 20 + heartX, 0 + heartY, 0 + heartX, 0 + heartY);
 heartShape.bezierCurveTo(-30 + heartX, 0 + heartY, -30 + heartX, 35 + heartY, -30 + heartX, 35 + heartY);
 heartShape.bezierCurveTo(-30 + heartX, 55 + heartY, -10 + heartX, 77 + heartY, 25 + heartX, 95 + heartY);
 heartShape.bezierCurveTo(60 + heartX, 77 + heartY, 80 + heartX, 55 + heartY, 80 + heartX, 35 + heartY);
 heartShape.bezierCurveTo(80 + heartX, 35 + heartY, 80 + heartX, 0 + heartY, 50 + heartX, 0 + heartY);
 heartShape.bezierCurveTo(35 + heartX, 0 + heartY, 25 + heartX, 25 + heartY, 25 + heartX, 25 + heartY);

 const extrudeSettings = {
  depth: 8,
  bevelEnabled: true,
  bevelSegments: 2,
  steps: 2,
  bevelSize: 1,
  bevelThickness: 1,
 };

 const heartRight = createHeart(heartShape, redMaterial, extrudeSettings, false, 0.01);

 const heartLeft = createHeart(heartShape, whiteMaterial, extrudeSettings, true, 0.01);

 // Define the control points of the curve that defines the shape of the bow
 const loopCurve = new THREE.CatmullRomCurve3([
  new THREE.Vector3(0, 0, 0),
  new THREE.Vector3(1, 0.5, 0),
  new THREE.Vector3(1.5, 0, 0),
  new THREE.Vector3(1, -0.5, 0),
  new THREE.Vector3(0, 0, 0),
 ]);

 // Define the radius and number of segments of the tube geometry
 const radius = 0.03;
 const segments = 64;

 // Create a mesh from the tube geometry with a material
 const materialBow = new THREE.MeshBasicMaterial({ color: 0xff0000 });

 const ribbonCurve = new THREE.CatmullRomCurve3([
  new THREE.Vector3(0, 0, 0),
  new THREE.Vector3(1, -1.5, 0),
  new THREE.Vector3(1, -1.7, 0),
  // new THREE.Vector3(2 * bowx, -0.2 * bowx, 0),
 ]);

 const bowGroup = createBow(loopCurve, ribbonCurve, materialBow, radius, segments);
 // scene.add(bowGroup);

 const martisor = createMartisor(bowGroup, heartRight, heartLeft);
 scene.add(martisor);

 // Sizes
 const sizes = {
  width: window.innerWidth,
  height: window.innerHeight,
 };

 window.addEventListener("resize", () => {
  // Update sizes
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;

  // Update camera
  camera.aspect = sizes.width / sizes.height;
  camera.updateProjectionMatrix();

  // Update renderer
  renderer.setSize(sizes.width, sizes.height);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
 });
 // Camera
 const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100);
 camera.position.z = 3;
 scene.add(camera);

 // Controls
 const controls = new OrbitControls(camera, canvas);
 controls.enableDamping = true;

 // Renderer
 const renderer = new THREE.WebGLRenderer({
  canvas: canvas,
 });
 renderer.setSize(sizes.width, sizes.height);
 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

 tick(controls, renderer, scene, camera);
};

main();

В конце концов, я создал множество групп martisor (martisoare — форма множественного числа на румынском языке) и позволил им уплыть в небытие.

const spawnMartisoare = (martisor, scene, count) => {
 const martisoare = [];

 for (let i = 0; i < count * 3; i++) {
  const martisorClone = martisor.clone();
  let x = (Math.random() - 0.5) * 100;
  let y = (Math.random() - 0.5) * 100;
  let z = (Math.random() - 0.5) * 50;
  martisorClone.position.set(x, y, z);
  martisorClone.rotation.set(0, (Math.random() * Math.PI) / 6, (Math.random() * Math.PI) / 4);
  scene.add(martisorClone);

  martisoare.push({
   shape: martisorClone,
   x: Math.random(),
   y: Math.random(),
   z: Math.random(),
  });
 }

 return martisoare;
};

Чтобы запустить анимацию, вам также необходимо изменить функцию тика.

const tick = (controls, renderer, scene, camera, martisoare) => {
 // Update controls
 controls.update();

 // Update martisoare
 const speed = 0.01;
 martisoare.forEach((el) => {
  el.shape.rotation.x += el.x * speed;
  el.shape.rotation.y += el.y * 1.5 * speed;
  el.shape.rotation.z += el.z * 2.5 * speed;
 });

 // Render
 renderer.render(scene, camera);

 // Call tick again on the next frame
 window.requestAnimationFrame(() => tick(controls, renderer, scene, camera, martisoare));
};

Тада-а-а-а-а-ам! 🎇 Я очень рад возможности поделиться с вами конечным продуктом; Я изменил цвет фона и вдумчиво создал различные оттенки красного для ваших любимых сердечек. Вот! Все эти удивительные объекты плавают в гармонии — до нашего следующего совместного путешествия!

❤️ https://martisor.vercel.app/

🤍 https://github.com/Welnic/martisor

После того, как я завершил этот проект, стало ясно, что весь процесс был чудесным путешествием и невероятно полезным усилием. Поскольку это моя первая работа, будьте осторожны с критикой, если вы знакомы с Three.js; тем не менее, я был бы рад получить любые советы или подсказки о том, как внести улучшения! Кроме того, если вам понравилось то, что вы здесь увидели, не стесняйтесь, дайте мне знать о ваших идеях для новых дополнений — я всегда ищу возможности улучшить свои навыки анимации.

P.S. Я создал этот 3D-мэрцишор с любовью для кого-то особенного для меня, и теперь вы тоже можете украсить день человека из Румынии, подарив ему свой собственный мраморный мрамор — трехмерный или сделанный вручную! Вы точно произведете впечатление! 🤍❤️