├── assets
├── rad-grad.png
└── black-n-shiney.jpg
├── README.md
├── index.html
├── LICENSE
├── src
├── getLayer.js
└── getBody.js
└── index.js
/assets/rad-grad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bobbyroe/Metaballs-with-Physics/HEAD/assets/rad-grad.png
--------------------------------------------------------------------------------
/assets/black-n-shiney.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bobbyroe/Metaballs-with-Physics/HEAD/assets/black-n-shiney.jpg
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Learn Three.js: Metaballs with Physics
2 |
3 | Let's dive into mesmerizing metaballs using Three.js, with physics via Rapier. Whether you're looking for a mesmerizing hero feature on your website or want to expand your 3D web skills, this video will guide you step-by-step in building this wild and wonderful effect.
4 |
5 | Watch the tutorial on [YouTube](https://youtu.be/jPbOKwXqdn8)
6 |
7 | Also, fork and create something cool!
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Metaballs with Physics
7 |
12 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Bobby Roe
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/src/getLayer.js:
--------------------------------------------------------------------------------
1 | import * as THREE from "three";
2 |
3 | const loader = new THREE.TextureLoader();
4 |
5 | function getSprite({ hasFog, color, opacity, path, pos, size }) {
6 | const spriteMat = new THREE.SpriteMaterial({
7 | color,
8 | fog: hasFog,
9 | map: loader.load(path),
10 | transparent: true,
11 | opacity,
12 | });
13 | spriteMat.color.offsetHSL(0, 0, Math.random() * 0.2 - 0.1);
14 | const sprite = new THREE.Sprite(spriteMat);
15 | sprite.position.set(pos.x, -pos.y, pos.z);
16 | size += Math.random() - 0.5;
17 | sprite.scale.set(size, size, size);
18 | sprite.material.rotation = 0;
19 | return sprite;
20 | }
21 |
22 | function getLayer({
23 | hasFog = true,
24 | hue = 0.0,
25 | numSprites = 10,
26 | opacity = 1,
27 | path = "./assets/rad-grad.png",
28 | radius = 1,
29 | sat = 0.5,
30 | size = 1,
31 | z = 0,
32 | }) {
33 | const layerGroup = new THREE.Group();
34 | for (let i = 0; i < numSprites; i += 1) {
35 | let angle = (i / numSprites) * Math.PI * 2;
36 | const pos = new THREE.Vector3(
37 | Math.cos(angle) * Math.random() * radius,
38 | Math.sin(angle) * Math.random() * radius,
39 | z + Math.random()
40 | );
41 | const length = new THREE.Vector3(pos.x, pos.y, 0).length();
42 | // const hue = 0.0; // (0.9 - (radius - length) / radius) * 1;
43 |
44 | let color = new THREE.Color().setHSL(hue, 1, sat);
45 | const sprite = getSprite({ hasFog, color, opacity, path, pos, size });
46 | layerGroup.add(sprite);
47 | }
48 | return layerGroup;
49 | }
50 | export default getLayer;
--------------------------------------------------------------------------------
/src/getBody.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 |
3 | const sceneMiddle = new THREE.Vector3(0, 0, 0);
4 | const metaOffset = new THREE.Vector3(0.5, 0.5, 0.5);
5 |
6 | function getBody({ debug = false, RAPIER, world }) {
7 | const size = 0.2;
8 | const range = 3;
9 | const density = 0.5;
10 | let x = Math.random() * range - range * 0.5;
11 | let y = Math.random() * range - range * 0.5 + 3;
12 | let z = Math.random() * range - range * 0.5;
13 | // Create a dynamic rigid-body.
14 | let rigidBodyDesc = RAPIER.RigidBodyDesc.dynamic()
15 | .setTranslation(x, y, z)
16 | .setLinearDamping(2);
17 | let rigid = world.createRigidBody(rigidBodyDesc);
18 | let colliderDesc = RAPIER.ColliderDesc.ball(size).setDensity(density);
19 | world.createCollider(colliderDesc, rigid);
20 |
21 | const color = new THREE.Color().setHSL(Math.random(), 1, 0.5);
22 |
23 | let mesh;
24 | if (debug === true) {
25 | const geometry = new THREE.IcosahedronGeometry(size, 3);
26 | const material = new THREE.MeshBasicMaterial({
27 | color,
28 | });
29 | mesh = new THREE.Mesh(geometry, material);
30 | }
31 |
32 | function update() {
33 | rigid.resetForces(true);
34 | let { x, y, z } = rigid.translation();
35 | let pos = new THREE.Vector3(x, y, z);
36 | let dir = pos.clone().sub(sceneMiddle).normalize();
37 | rigid.addForce(dir.multiplyScalar(-0.5), true);
38 | if ( debug === true) {
39 | mesh.position.copy(pos);
40 | }
41 | pos.multiplyScalar(0.1).add(metaOffset);
42 | return pos;
43 | }
44 | return { color, mesh, rigid, update };
45 | }
46 |
47 | export { getBody };
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import * as THREE from "three";
2 | import getLayer from "./src/getLayer.js";
3 | import { getBody } from "./src/getBody.js";
4 | import RAPIER from 'https://cdn.skypack.dev/@dimforge/rapier3d-compat@0.11.2';
5 | import { MarchingCubes } from "jsm/objects/MarchingCubes.js";
6 |
7 | const w = window.innerWidth;
8 | const h = window.innerHeight;
9 | const scene = new THREE.Scene();
10 | const camera = new THREE.PerspectiveCamera(75, w / h, 0.1, 1000);
11 | camera.position.z = 5;
12 | const renderer = new THREE.WebGLRenderer();
13 | renderer.setSize(w, h);
14 | document.body.appendChild(renderer.domElement);
15 |
16 | let mousePos = new THREE.Vector2();
17 | const textureLoader = new THREE.TextureLoader();
18 |
19 | // initialize RAPIER
20 | await RAPIER.init();
21 | let gravity = { x: 0, y: 0, z: 0 };
22 | let world = new RAPIER.World(gravity);
23 |
24 | const numBodies = 20;
25 | const bodies = [];
26 | const debugBodies = false;
27 | for (let i = 0; i < numBodies; i++) {
28 | const body = getBody({debug: debugBodies, RAPIER, world});
29 | bodies.push(body);
30 | if (debugBodies) {
31 | scene.add(body.mesh);
32 | }
33 | }
34 |
35 | // MOUSE RIGID BODY
36 | const matcap = textureLoader.load("./assets/black-n-shiney.jpg");
37 | let bodyDesc = RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(0, 0, 0)
38 | let mouseRigid = world.createRigidBody(bodyDesc);
39 | let dynamicCollider = RAPIER.ColliderDesc.ball(0.5);
40 | world.createCollider(dynamicCollider, mouseRigid);
41 |
42 | const geometry = new THREE.IcosahedronGeometry(0.35, 3);
43 | const material = new THREE.MeshMatcapMaterial({
44 | matcap
45 | });
46 | const mouseMesh = new THREE.Mesh(geometry, material);
47 | mouseMesh.userData = {
48 | update() {
49 | mouseRigid.setTranslation({ x: mousePos.x * 4, y: mousePos.y * 4, z: 0 });
50 | let { x, y, z } = mouseRigid.translation();
51 | mouseMesh.position.set(x, y, z);
52 | }
53 | };
54 | scene.add(mouseMesh);
55 |
56 | // METABALLS
57 | const metaMat = new THREE.MeshMatcapMaterial({
58 | matcap,
59 | vertexColors: true,
60 | // transparent: true, // debug
61 | // opacity: 0.8,
62 | });
63 | const metaballs = new MarchingCubes(
64 | 96, // resolution,
65 | metaMat,
66 | true, // enableUVs
67 | true, // enableColors
68 | 90000 // max poly count
69 | );
70 | metaballs.scale.setScalar(5);
71 | metaballs.isolation = 1000;
72 | metaballs.userData = {
73 | update() {
74 | metaballs.reset();
75 | const strength = 0.5; // size-y
76 | const subtract = 10; // lightness
77 | bodies.forEach((b) => {
78 | const { x, y, z } = b.update();
79 | metaballs.addBall(x, y, z, strength, subtract, b.color.getHex());
80 | });
81 | metaballs.update();
82 | }
83 | };
84 | scene.add(metaballs);
85 |
86 | const gradientBackground = getLayer({
87 | hue: 0.6,
88 | numSprites: 8,
89 | opacity: 0.2,
90 | radius: 10,
91 | size: 24,
92 | z: -10.5,
93 | });
94 | scene.add(gradientBackground);
95 |
96 | function animate() {
97 | requestAnimationFrame(animate);
98 | world.step();
99 | mouseMesh.userData.update();
100 | metaballs.userData.update();
101 | renderer.render(scene, camera);
102 | }
103 |
104 | animate();
105 |
106 | function handleWindowResize() {
107 | camera.aspect = window.innerWidth / window.innerHeight;
108 | camera.updateProjectionMatrix();
109 | renderer.setSize(window.innerWidth, window.innerHeight);
110 | }
111 | window.addEventListener('resize', handleWindowResize, false);
112 |
113 | function handleMouseMove(evt) {
114 | mousePos.x = (evt.clientX / window.innerWidth) * 2 - 1;
115 | mousePos.y = - (evt.clientY / window.innerHeight) * 2 + 1;
116 | }
117 | window.addEventListener('mousemove', handleMouseMove, false);
--------------------------------------------------------------------------------