├── 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); --------------------------------------------------------------------------------