Creating a Romantic Animated Heart with Three.js – 2D Particle Heart and 3D Model Heart
This article walks through building a visually striking heart animation using Three.js, covering the mathematical generation of 2D particle hearts, their canvas rendering and animation, and then extending the effect to a 3D heart model with scene, camera, lighting, GLTF loading, and GSAP-driven motion.
Preface
Hello, I'm Xiao Bao. After a short break I returned to share a series of Three.js tutorials, starting with a romantic heart animation inspired by a popular drama scene.
Li Xun Romantic Heart – Implementation
The goal is to reproduce the "Li Xun Romantic Heart" effect using canvas. The heart is built from multiple layers (outer contour, outline, inner part) and rendered with particles.
Implementation Analysis
The heart consists of several concentric layers.
Particles are used to draw the shape.
The animation of the heart is the challenging part.
Heart Generation
In the front‑end, we use the canvas API. The lineTo method can draw any shape, but determining the points of a heart curve requires mathematics.
Using the classic polar equation for a heart shape, we generate points with the following function:
// scale is the magnification factor
// width and height are the canvas dimensions
function generatorHeart(t, scale = 11.6) {
let x = 16 * Math.sin(t) ** 3;
let y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
x = x * scale + width / 2;
y = y * scale + height / 2;
return new Point(x, y);
}We generate 360 points over the interval [0, 2π] :
const hearts = [];
for (let i = 0; i < 360; i++) {
hearts.push(generatorHeart(2 * Math.PI * (i / 360)));
}To draw the heart we connect the points with lineTo :
function drawHeart2(context, points) {
context.beginPath();
points.forEach(point => {
context.strokeStyle = "#00ffff";
context.lineTo(point.x, point.y);
context.stroke();
});
context.closePath();
}For a particle effect we replace the line drawing with small circles using arc and fill :
function drawHeart(context, points) {
points.forEach(point => {
context.beginPath();
context.fillStyle = "#00ffff";
context.arc(point.x, point.y, point.size, 0, Math.PI * 2);
context.fill();
context.closePath();
});
}Overall Structure
A Point class stores x, y, size for each particle:
class Point {
constructor(x, y, size) {
this.x = x;
this.y = y;
this.size = size;
}
}The Heart class aggregates particles, computes per‑frame positions, and stores them in allHearts . The position calculation uses a force based on distance to the canvas centre and adds random jitter, while the animation curve is driven by Math.sin and a custom curve function.
class Heart {
constructor(particles, generateFrame) {
this.particles = particles;
this.generateFrame = generateFrame;
this.boardHeart = [];
this.middleHeart = [];
this.centerHeart = [];
this.allHearts = [];
this.initHeart();
for (let i = 0; i < generateFrame; i++) {
this.calcFrame(i);
}
}
// ... (initialisation and calcFrame omitted for brevity)
}Animation is performed with requestAnimationFrame :
let k = 0;
(function animateloop() {
k = (k + 1) % 80;
if (k % 4 === 0) {
render(k / 4);
}
requestAnimationFrame(animateloop);
})();3D Heart
We now switch to a simple 3D implementation using Three.js (Vite + Vue3). The steps are:
Basic Setup
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.01, 10000);
camera.position.set(0, 0, 300);
scene.add(camera);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.setPixelRatio(window.devicePixelRatio);Lighting
Multiple directional lights and an ambient light are added to achieve a balanced illumination:
const light1 = new THREE.DirectionalLight(0x333333, 1);
light1.position.set(0, 0, 20);
scene.add(light1);
// ... (light2 … light10 omitted for brevity)Model Loading
The heart model is loaded with GLTFLoader (the actual file path is kept private):
const loader = new GLTFLoader();
loader.setMeshoptDecoder(MeshoptDecoder);
loader.load("/model/heart.glb", gltf => {
const heart = gltf.scene;
scene.add(heart);
});Animation with GSAP
GSAP provides a continuous rotation and a bouncing motion:
gsap.to(heart.rotation, {
y: Math.PI * 2,
duration: 6,
repeat: -1,
});
gsap.to(heart.position, {
y: 0.8,
duration: 1,
yoyo: true,
repeat: -1,
});Comparison
Li Xun Romantic Heart
3D Heart
Implementation Difficulty
Higher, involves math formulas
Lower
Coolness
Very flashy
Moderate (3D adds depth)
Complex Parts
Formulas, canvas drawing
Finding a suitable model, lighting
Flexibility
Can be freely customized
Limited by the model
Conclusion
Both the 2D particle heart and the 3D heart have been implemented. The 2D version is more mathematically intensive, while the 3D version is easier once a model is available. Both serve as practical introductions to Three.js for front‑end developers.
Continue exploring Three.js to create more sophisticated 3D effects.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.