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
| Option | Pros | Cons | When to use |
|---|---|---|---|
| Sketchfab CC0 head | Ready to go, high quality | May need cleanup | Quick prototyping |
| Custom Blender sculpt | Exact shape control | Time investment | Brand-specific look |
| iPhone TrueDepth scan | Your actual face | Noisy mesh, needs retopo | Personal branding |
| Procedural (code) | Zero dependencies | Not realistic | Demos, 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 neededTarget 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
| Setting | Value | Why |
|---|---|---|
blending | AdditiveBlending | Overlapping particles sum colors → natural bright spots at dense areas |
depthWrite | false | Prevents z-fighting flickering between particles |
depthTest | true | Particles still render behind/in-front of other scene objects |
transparent | true | Required 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 positionPhase 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 + basePerformance: half-res bloom
1// Render bloom at half resolution — nearly 2× faster
2composer.setSize(innerWidth / 2, innerHeight / 2);
3// Reset on window resizeAlternative: 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 parallelPerformance tiers
| Technique | Particle ceiling @60fps | Use when |
|---|---|---|
| CPU attribute updates | 10K–30K | Prototyping only |
| GPU vertex shader | 100K–500K | Production hero sections |
| GPGPU / FBO ping-pong | 500K–2M | Complex physics simulations |
| WebGPU compute | 1M–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 desktopGPGPU 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:
| Library | Can reproduce it? | Effort | Customization | Performance |
|---|---|---|---|---|
| Custom Three.js + GLSL | ✅ 100% | 2–3 days | Unlimited | Excellent |
| React Three Fiber + custom | ✅ 100% | 2–3 days | Unlimited | Excellent (thin wrapper) |
| R3FPointsFX | ✅ ~85% | Hours | Moderate | Good |
| three.quarks | ⚠️ ~70% | Hours | Good (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-glslVite 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):
- Codrops: "Dreamy Particle Effect with Three.js and GPGPU" — closest to the Betawise effect
- Codrops: "Surface Sampling in Three.js" — definitive MeshSurfaceSampler guide
- Codrops: "Interactive Particles with Three.js" — touch texture interaction
- Codrops: "Dissolve Effect with Shaders and Particles" — noise-based dissolve
- 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