MDX Limo
Tactical Guide: 3D Point Cloud Head with Interactive Particles

Tactical Guide: 3D Point Cloud Head with Interactive Particles

A step-by-step implementation playbook for the Betawise-style particle head hero effect. Every section is a self-contained decision with code you can copy directly.


Architecture Overview

1┌─────────────────────────────────────────────────────┐ 2│ GLTF Head Model (.glb) │ 3│ └─▶ MeshSurfaceSampler (60K–100K points) │ 4│ └─▶ BufferGeometry (positions, normals, │ 5│ colors, sizes, randoms) │ 6│ └─▶ THREE.Points + ShaderMaterial │ 7│ ├─ Vertex Shader (noise, repel, │ 8│ │ dissolve, size attenuation) │ 9│ └─ Fragment Shader (soft circle, │ 10│ radial gradient, edge glow) │ 11│ │ 12│ Mouse ─▶ Raycaster ─▶ hit plane ─▶ uMouseWorld │ 13│ Smooth lerp ─▶ group.rotation (macro follow) │ 14│ │ 15│ EffectComposer │ 16│ ├─ RenderPass │ 17│ ├─ UnrealBloomPass (strength 1.2, threshold 0.1) │ 18│ └─ OutputPass │ 19└─────────────────────────────────────────────────────┘

Phase 1 — Model Preparation

Decision: Model source

OptionProsConsWhen to use
Sketchfab CC0 headReady to go, high qualityMay need cleanupQuick prototyping
Custom Blender sculptExact shape controlTime investmentBrand-specific look
iPhone TrueDepth scanYour actual faceNoisy mesh, needs retopoPersonal branding
Procedural (code)Zero dependenciesNot realisticDemos, fallbacks

Blender prep checklist

11. Merge all parts → single watertight mesh 22. Remove interior geometry (eyes, teeth, tongue) 33. Target 2K–10K polygons (decimate if needed) 44. Apply all transforms: Ctrl+A → All Transforms 55. Center origin: Right-click → Set Origin → Origin to Geometry 66. Scale so head fits in ~2 unit bounding box 77. Export: File → Export → glTF 2.0 (.glb) 8 ☑ Apply Modifiers 9 ☑ +Y Up 10 ☐ No materials/textures needed

Target file size: 200–500KB for the .glb.


Phase 2 — Surface Sampling

Core technique: MeshSurfaceSampler

This is the critical step that converts a mesh into uniformly distributed particles. Unlike extracting raw vertices (which gives you uneven density matching polygon distribution), the sampler interpolates across triangle faces weighted by area.

1import { MeshSurfaceSampler } from 'three/addons/math/MeshSurfaceSampler.js'; 2 3// After loading GLTF: 4const sampler = new MeshSurfaceSampler(headMesh) 5 .setWeightAttribute(null) // null = area-weighted (default, what you want) 6 .build(); // O(n) build, O(log n) per sample 7 8const PARTICLE_COUNT = 60000; 9const positions = new Float32Array(PARTICLE_COUNT * 3); 10const normals = new Float32Array(PARTICLE_COUNT * 3); 11const tempPos = new THREE.Vector3(); 12const tempNorm = new THREE.Vector3(); 13 14for (let i = 0; i < PARTICLE_COUNT; i++) { 15 sampler.sample(tempPos, tempNorm); 16 positions[i * 3] = tempPos.x; 17 positions[i * 3 + 1] = tempPos.y; 18 positions[i * 3 + 2] = tempPos.z; 19 normals[i * 3] = tempNorm.x; 20 normals[i * 3 + 1] = tempNorm.y; 21 normals[i * 3 + 2] = tempNorm.z; 22}

Why not just use mesh.geometry.attributes.position?

Direct vertex extraction gives you exactly as many particles as the mesh has vertices. A 5K-poly head = 5K particles (too sparse). You'd need a 60K-poly mesh for 60K particles, which is wasteful. The sampler decouples particle count from mesh complexity.

Custom per-particle attributes

Every visual behavior is driven by attributes uploaded once at init:

1const geometry = new THREE.BufferGeometry(); 2geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); 3geometry.setAttribute('aNormal', new THREE.BufferAttribute(normals, 3)); 4 5// Per-particle variation 6const sizes = new Float32Array(PARTICLE_COUNT); 7const randoms = new Float32Array(PARTICLE_COUNT); 8const colors = new Float32Array(PARTICLE_COUNT * 3); 9 10const palette = [ 11 new THREE.Color('#4fc3f7'), // cyan 12 new THREE.Color('#81d4fa'), // light cyan 13 new THREE.Color('#b3e5fc'), // pale blue 14 new THREE.Color('#e1f5fe'), // near-white blue 15 new THREE.Color('#ffffff'), // white (hotspots) 16]; 17 18for (let i = 0; i < PARTICLE_COUNT; i++) { 19 sizes[i] = Math.random() * 1.5 + 0.5; // 0.5–2.0 range 20 randoms[i] = Math.random(); // unique seed per particle 21 22 const c = palette[Math.floor(Math.random() * palette.length)]; 23 colors[i * 3] = c.r; 24 colors[i * 3 + 1] = c.g; 25 colors[i * 3 + 2] = c.b; 26} 27 28geometry.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1)); 29geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1)); 30geometry.setAttribute('aColor', new THREE.BufferAttribute(colors, 3));

Key insight: aRandom is the most useful attribute. It seeds per-particle variation for twinkle speed, breathing phase, dissolve threshold, and noise offset — all from a single float.


Phase 3 — Shader Material

Material configuration

1const material = new THREE.ShaderMaterial({ 2 uniforms: { 3 uTime: { value: 0 }, 4 uPointSize: { value: 4.0 }, 5 uPixelRatio: { value: renderer.getPixelRatio() }, 6 uMouseWorld: { value: new THREE.Vector3() }, 7 uMouseRadius: { value: 1.5 }, 8 uDissolve: { value: 0.0 }, // 0 = solid, 1 = fully dissolved 9 }, 10 vertexShader, 11 fragmentShader, 12 transparent: true, 13 blending: THREE.AdditiveBlending, // ← essential for glow 14 depthWrite: false, // ← particles don't occlude each other 15 depthTest: true, // ← still respect scene depth 16});

Why these settings matter

SettingValueWhy
blendingAdditiveBlendingOverlapping particles sum colors → natural bright spots at dense areas
depthWritefalsePrevents z-fighting flickering between particles
depthTesttrueParticles still render behind/in-front of other scene objects
transparenttrueRequired for alpha < 1.0 to work

Vertex shader: the 5 layers

The vertex shader applies effects in order. Each is independent and can be toggled:

1void main() { 2 vec3 pos = position; 3 4 // LAYER 1: Idle breathing (subtle surface-normal oscillation) 5 pos += aNormal * sin(uTime * 0.5 + aRandom * 6.2831) * 0.008; 6 7 // LAYER 2: Noise scatter (drives dissolve at periphery) 8 float distFromCenter = length(pos); 9 float scatterMask = smoothstep(0.5, 1.2, distFromCenter); 10 vec3 noiseDir = vec3( 11 snoise(pos * 1.2 + vec3(0.0, 0.0, uTime * 0.25)), 12 snoise(pos * 1.2 + vec3(100.0, 0.0, uTime * 0.25)), 13 snoise(pos * 1.2 + vec3(0.0, 100.0, uTime * 0.25)) 14 ); 15 pos += noiseDir * 0.9 * scatterMask * uDissolve; 16 17 // LAYER 3: Mouse repulsion 18 float mouseDist = distance(pos, uMouseWorld); 19 float repulsion = 1.0 - smoothstep(0.0, uMouseRadius, mouseDist); 20 vec3 repelDir = normalize(pos - uMouseWorld + 0.001); 21 pos += repelDir * repulsion * 0.6; 22 23 // LAYER 4: Projection + size attenuation 24 vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); 25 gl_Position = projectionMatrix * mvPosition; 26 gl_PointSize = uPointSize * aSize * uPixelRatio / -mvPosition.z; 27 28 // LAYER 5: Color + alpha computation 29 // (white core → cyan mid → teal edge, twinkle, depth fade) 30}

Fragment shader: soft circular particles

1void main() { 2 // gl_PointCoord = UV within the point sprite (0,0 top-left → 1,1 bottom-right) 3 float dist = length(gl_PointCoord - 0.5); 4 if (dist > 0.5) discard; // circular mask 5 6 // Dissolve: discard particles below noise threshold 7 float dissolveVal = vNoise * 0.5 + 0.5; 8 if (dissolveVal < uDissolve * 0.85) discard; 9 10 // Soft radial falloff (the "glow" per particle) 11 float softCircle = 1.0 - smoothstep(0.0, 0.5, dist); 12 13 // White center → colored edge within each particle 14 vec3 finalColor = mix(vec3(1.0), vColor, smoothstep(0.0, 0.35, dist)); 15 16 // Pre-multiplied alpha output (works with additive blending) 17 float alpha = softCircle * vAlpha; 18 gl_FragColor = vec4(finalColor * alpha, alpha); 19}

Phase 4 — Mouse Interaction

Two-layer system

Macro (entire head follows cursor) + Micro (individual particles repel from cursor).

1// ── Setup ── 2const mouse = new THREE.Vector2(); 3const smoothMouse = new THREE.Vector2(); 4const mouseWorld = new THREE.Vector3(); 5const raycaster = new THREE.Raycaster(); 6 7// Invisible plane at z=0 for raycasting mouse into world space 8const hitPlane = new THREE.Mesh( 9 new THREE.PlaneGeometry(20, 20), 10 new THREE.MeshBasicMaterial({ visible: false }) 11); 12scene.add(hitPlane); 13 14// Wrap particles in a Group for macro rotation 15const group = new THREE.Group(); 16group.add(particles); 17scene.add(group); 18 19// ── Event listener ── 20window.addEventListener('mousemove', (e) => { 21 mouse.x = (e.clientX / innerWidth) * 2 - 1; 22 mouse.y = -(e.clientY / innerHeight) * 2 + 1; 23 24 raycaster.setFromCamera(mouse, camera); 25 const hits = raycaster.intersectObject(hitPlane); 26 if (hits.length) mouseWorld.copy(hits[0].point); 27}); 28 29// ── In render loop ── 30function animate() { 31 const dt = clock.getDelta(); 32 33 // Frame-rate independent smooth follow 34 // damping=5 is a good default. Lower=dreamier, higher=snappier. 35 smoothMouse.x += (mouse.x - smoothMouse.x) * 5 * dt; 36 smoothMouse.y += (mouse.y - smoothMouse.y) * 5 * dt; 37 38 // MACRO: rotate entire group 39 group.rotation.y = smoothMouse.x * 0.35; 40 group.rotation.x = smoothMouse.y * 0.2; 41 42 // MICRO: pass world-space mouse to shader for per-particle repulsion 43 material.uniforms.uMouseWorld.value.copy(mouseWorld); 44}

Why frame-rate independent lerp?

smoothMouse += (target - smoothMouse) * factor * deltaTime

Without * deltaTime, the smoothing behaves differently on 60Hz vs 144Hz monitors. The factor (5 in our case) represents roughly "how many times per second does the value close 63% of the gap." This makes the motion feel identical regardless of frame rate.

Advanced: Touch texture technique

For richer interaction (trails, multi-touch, pressure), draw mouse positions onto an offscreen canvas with alpha fade, then pass as a texture uniform:

1const touchCanvas = document.createElement('canvas'); 2touchCanvas.width = touchCanvas.height = 256; 3const tCtx = touchCanvas.getContext('2d'); 4const touchTexture = new THREE.CanvasTexture(touchCanvas); 5 6function updateTouchTexture() { 7 // Fade previous frame (creates trail) 8 tCtx.fillStyle = 'rgba(0, 0, 0, 0.05)'; 9 tCtx.fillRect(0, 0, 256, 256); 10 11 // Draw current mouse position as bright spot 12 const u = (mouse.x * 0.5 + 0.5) * 256; 13 const v = (1 - (mouse.y * 0.5 + 0.5)) * 256; 14 const gradient = tCtx.createRadialGradient(u, v, 0, u, v, 30); 15 gradient.addColorStop(0, 'rgba(255,255,255,0.8)'); 16 gradient.addColorStop(1, 'rgba(0,0,0,0)'); 17 tCtx.fillStyle = gradient; 18 tCtx.fillRect(u - 30, v - 30, 60, 60); 19 20 touchTexture.needsUpdate = true; 21} 22// Call updateTouchTexture() in your render loop 23// In vertex shader: sample this texture using UV projection of particle position

Phase 5 — Bloom Post-Processing

Standard setup with EffectComposer

1import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; 2import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; 3import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; 4import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; 5 6const composer = new EffectComposer(renderer); 7composer.addPass(new RenderPass(scene, camera)); 8 9const bloom = new UnrealBloomPass( 10 new THREE.Vector2(innerWidth, innerHeight), 11 1.2, // strength — glow intensity (0.8–2.0 sweet spot) 12 0.6, // radius — halo spread (0.3–0.8) 13 0.1 // threshold — MUST be low for additive particles 14); 15composer.addPass(bloom); 16composer.addPass(new OutputPass()); // gamma correction 17 18// In render loop: composer.render() instead of renderer.render()

Selective bloom (when you have UI elements)

Bloom bleeds into everything. If you have sharp text or buttons, use the two-pass technique:

1// 1. Tag particle objects to bloom layer 2const BLOOM_LAYER = 1; 3particles.layers.enable(BLOOM_LAYER); 4 5// 2. Render bloom-only pass (black out non-bloom objects) 6const darkMaterial = new THREE.MeshBasicMaterial({ color: 0x000000 }); 7const materials = {}; 8 9function darkenNonBloomed(obj) { 10 if (obj.isMesh && !obj.layers.test(bloomLayer)) { 11 materials[obj.uuid] = obj.material; 12 obj.material = darkMaterial; 13 } 14} 15 16function restoreMaterials(obj) { 17 if (materials[obj.uuid]) { 18 obj.material = materials[obj.uuid]; 19 delete materials[obj.uuid]; 20 } 21} 22 23// In render loop: 24scene.traverse(darkenNonBloomed); 25bloomComposer.render(); 26scene.traverse(restoreMaterials); 27finalComposer.render(); // composites bloom + base

Performance: half-res bloom

1// Render bloom at half resolution — nearly 2× faster 2composer.setSize(innerWidth / 2, innerHeight / 2); 3// Reset on window resize

Alternative: pmndrs/postprocessing

The pmndrs/postprocessing library automatically merges compatible effect passes into fewer draw calls. Measurably faster than Three.js's built-in EffectComposer for multi-pass pipelines:

1import { EffectComposer, BloomEffect, RenderPass } from 'postprocessing'; 2 3const composer = new EffectComposer(renderer); 4composer.addPass(new RenderPass(scene, camera)); 5composer.addPass(new EffectPass(camera, new BloomEffect({ 6 intensity: 1.2, 7 luminanceThreshold: 0.1, 8 radius: 0.6, 9})));

Phase 6 — Dissolve Effect

The dissolve is noise-threshold discard in the fragment shader, driven by a single uniform:

1// Fragment shader 2float dissolveVal = vNoise * 0.5 + 0.5; // remap -1..1 → 0..1 3if (dissolveVal < uDissolve * 0.85) discard; 4 5// Edge glow at the dissolve boundary 6float edgeGlow = 1.0 - smoothstep(0.0, 0.12, dissolveVal - uDissolve * 0.85); 7finalColor += edgeGlow * vec3(0.2, 0.7, 1.0) * 0.5;

Animating dissolve with GSAP

1import gsap from 'gsap'; 2 3// Dissolve in on scroll or trigger 4gsap.to(material.uniforms.uDissolve, { 5 value: 1.0, 6 duration: 2.5, 7 ease: 'power2.inOut', 8}); 9 10// Reverse 11gsap.to(material.uniforms.uDissolve, { 12 value: 0.0, 13 duration: 2.0, 14 ease: 'power3.out', 15});

Paired with vertex displacement

In the vertex shader, scatter particles outward as they dissolve:

1// Particles drift outward along noise direction as dissolve increases 2pos += noiseDir * 0.9 * scatterMask * uDissolve; 3 4// Shrink dissolving particles 5gl_PointSize *= mix(1.0, 0.2, scatterMask * uDissolve);

Phase 7 — Performance Optimization

The golden rule

All animation lives in the vertex shader. Buffer attributes are uploaded once. Only uniforms update per frame.

1❌ BAD: for(i=0; i<60000; i++) positions[i*3] += velocity[i]; // 60K CPU ops/frame 2✅ GOOD: uniform float uTime; pos += aNormal * sin(uTime); // GPU parallel

Performance tiers

TechniqueParticle ceiling @60fpsUse when
CPU attribute updates10K–30KPrototyping only
GPU vertex shader100K–500KProduction hero sections
GPGPU / FBO ping-pong500K–2MComplex physics simulations
WebGPU compute1M–10M+Future-proof, cutting edge

Practical checklist

1☑ renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) 2☑ Pre-allocate all Float32Arrays at init 3☑ Reuse THREE.Vector3() temps — never allocate in render loop 4☑ geometry.computeBoundingSphere() after setting positions 5☑ particles.frustumCulled = false (if GPU animation extends bounds) 6☑ Replace shader if/else with mix() and step() 7☑ Use mediump on mobile (2× faster than highp on most mobile GPUs) 8☑ Clamp deltaTime: Math.min(dt, 0.05) to prevent spiral on tab-switch 9☑ Half-res bloom: composer.setSize(w/2, h/2)

Mobile strategy

1const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent); 2const PARTICLE_COUNT = isMobile ? 25000 : 60000; 3 4// In shaders, use: 5// precision mediump float; ← on mobile 6// precision highp float; ← on desktop

GPGPU for 500K+ particles

When vertex-shader-only animation isn't enough (e.g., you need inter-particle forces or persistent velocity), use GPUComputationRenderer:

1import { GPUComputationRenderer } from 'three/addons/misc/GPUComputationRenderer.js'; 2 3// Texture dimensions: 512×512 = 262,144 particles 4const SIZE = 512; 5const gpuCompute = new GPUComputationRenderer(SIZE, SIZE, renderer); 6 7// Position texture (RGBA float: xyz = position, w = life) 8const posTexture = gpuCompute.createTexture(); 9// Fill initial positions from MeshSurfaceSampler... 10 11const posVar = gpuCompute.addVariable('texturePosition', simulationShader, posTexture); 12posVar.wrapS = posVar.wrapT = THREE.RepeatWrapping; 13gpuCompute.init(); 14 15// Each frame: 16gpuCompute.compute(); 17// Read result into particle material: 18material.uniforms.uPositionTexture.value = 19 gpuCompute.getCurrentRenderTarget(posVar).texture;

Library Decision Matrix

For the Betawise-style effect specifically:

LibraryCan reproduce it?EffortCustomizationPerformance
Custom Three.js + GLSL✅ 100%2–3 daysUnlimitedExcellent
React Three Fiber + custom✅ 100%2–3 daysUnlimitedExcellent (thin wrapper)
R3FPointsFX✅ ~85%HoursModerateGood
three.quarks⚠️ ~70%HoursGood (VFX toolkit)Good
tsParticles / particles.js❌ No

When to use what

Custom ShaderMaterial → Production hero section where the particle effect IS the brand identity. Full control over every pixel. This is what Betawise uses.

R3FPointsFX (github.com/VedantSG123/R3FPointsFX) → React project, need particle morphing between shapes, acceptable to have a slightly more generic look. Accepts GLTF meshes directly.

three.quarks (github.com/Alchemist0823/three.quarks) → Need a full VFX toolkit with emission shapes, color/size over lifetime, force fields, trails. Has R3F wrapper. More game-engine oriented.

tsParticles → Only for 2D decorative backgrounds (snow, confetti, connecting dots). Cannot do 3D model-based particle systems. Performance ceiling ~10K particles.


Complete File Structure

1project/ 2├── public/ 3│ └── models/ 4│ └── head.glb ← Your prepared head model 5├── src/ 6│ ├── shaders/ 7│ │ ├── particles.vert.glsl ← Vertex shader (noise + repel + dissolve) 8│ │ └── particles.frag.glsl ← Fragment shader (soft circle + glow) 9│ ├── ParticleHead.js ← Sampling + geometry + material setup 10│ ├── MouseTracker.js ← Raycaster + smooth lerp 11│ ├── PostProcessing.js ← Bloom composer setup 12│ └── main.js ← Scene init + render loop 13├── package.json 14└── vite.config.js ← Vite handles GLSL imports via vite-plugin-glsl

Vite setup for GLSL imports

1// vite.config.js 2import glsl from 'vite-plugin-glsl'; 3 4export default { 5 plugins: [glsl()], 6};
1// Then in your JS: 2import vertexShader from './shaders/particles.vert.glsl'; 3import fragmentShader from './shaders/particles.frag.glsl';

Key Resources (bookmarkable)

Tutorials (in order of relevance):

  1. Codrops: "Dreamy Particle Effect with Three.js and GPGPU" — closest to the Betawise effect
  2. Codrops: "Surface Sampling in Three.js" — definitive MeshSurfaceSampler guide
  3. Codrops: "Interactive Particles with Three.js" — touch texture interaction
  4. Codrops: "Dissolve Effect with Shaders and Particles" — noise-based dissolve
  5. Maxime Heckel: "Magical World of Particles with R3F and Shaders" — R3F + FBO deep dive

Reference implementations:

  • CodePen: "WebGL Particle Head" by Robert Bue — classic OBJ→particles demo
  • GitHub: Kshitij978/Three.js-Point-cloud-morphing-effect — morphing between shapes
  • GitHub: VedantSG123/R3FPointsFX — R3F component with auto particle generation

Shader resources:

  • Patricio Gonzalez Vivo's GLSL noise gist — copy-paste noise functions
  • The Book of Shaders (thebookofshaders.com) — noise and SDF fundamentals
  • Shadertoy — search "point cloud" or "particle" for technique inspiration

Performance:

  • Three.js Journey: "GPGPU Flow Field Particles" lesson
  • Nicolas Barradeau: "FBO Particles" blog post — foundational GPGPU explanation
Tactical Guide: 3D Point Cloud Head with Interactive Particles | MDX Limo