Three.js Tutorial

3D Point Cloud — Step by Step

Learn how to render, color, and interact with point clouds in Three.js using incremental, focused steps.

Overview

This in‑depth Three.js tutorial shows how to visualize and interact with 3D point clouds in the browser using WebGL. You’ll learn how to construct point buffers, apply per‑point colors, add camera controls, and implement hover picking. The examples are small, composable, and production‑ready.

By the end you’ll understand the building blocks behind LiDAR and photogrammetry viewers and be able to render large XYZ datasets efficiently with THREE.BufferGeometry and THREE.PointsMaterial.

  • Understand point cloud representation in JavaScript (typed arrays, buffer attributes).
  • Color points by attributes (height, intensity, classification).
  • Navigate scenes with OrbitControls and tune point appearance.
  • Implement hover picking and discuss scalable picking strategies (k‑d tree, GPU).

Prerequisites

  • Basic JavaScript and DOM.
  • Familiarity with Three.js scene/camera/renderer lifecycle.
  • Optional: knowledge of typed arrays and color spaces.

Data Model

A point cloud is a set of XYZ coordinates (and often extra attributes like RGB color, intensity, normals, and classification). We store these as typed arrays and attach them to a THREE.BufferGeometry:

const count = N
const positions = new Float32Array(count * 3) // [x0, y0, z0, x1, y1, z1, ...]
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

// Optional per-vertex color (linear RGB 0..1)
const colors = new Float32Array(count * 3)
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))

Memory estimate: positions need count * 3 * 4 bytes (float32), colors add another count * 3 * 4 bytes. One million points with positions+colors ≈ 24 MB.

Step 1 — Setup scene and render random point cloud

Initialize a minimal Three.js app: a scene, a perspective camera, and a WebGL renderer mounted into the page. Then allocate a typed array of length N * 3 for XYZ positions, fill it with random coordinates inside a cube, and attach it to a THREE.BufferGeometry as a position attribute. Finally, render it as THREE.Points with a basic PointsMaterial. The points are rotated slowly so you can perceive depth.

JavaScript (plain)

import * as THREE from 'three'


const width = 960

const height = 540

const scene = new THREE.Scene()

const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000)

camera.position.set(0, 0, 5)

const renderer = new THREE.WebGLRenderer({ antialias: true })

renderer.setSize(width, height)

document.querySelector('#app').appendChild(renderer.domElement)


const count = 5000

const positions = new Float32Array(count * 3)

for (let i = 0; i < count; i++) {

  const i3 = i * 3

  positions[i3 + 0] = (Math.random() - 0.5) * 4

  positions[i3 + 1] = (Math.random() - 0.5) * 4

  positions[i3 + 2] = (Math.random() - 0.5) * 4

}

const geometry = new THREE.BufferGeometry()

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

const material = new THREE.PointsMaterial({ color: 0x66ccff, size: 0.03, sizeAttenuation: true })

const points = new THREE.Points(geometry, material)

scene.add(points)


function animate () {

  requestAnimationFrame(animate)

  points.rotation.y += 0.002

  renderer.render(scene, camera)

}

animate()

Step 2 — Color points by height (vertex colors)

Give each point a color by adding a color attribute (RGB triplets) alongside position. Here we map the Y coordinate to a 0..1 range and convert that to a blue→cyan gradient using Color.setHSL. Enable vertexColors: true on the material to use the per-vertex colors.

JavaScript (plain)

import * as THREE from 'three'


const width = 960, height = 540

const scene = new THREE.Scene()

const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000)

camera.position.z = 5

const renderer = new THREE.WebGLRenderer({ antialias: true })

renderer.setSize(width, height)

document.querySelector('#app').appendChild(renderer.domElement)


const count = 6000

const positions = new Float32Array(count * 3)

const colors = new Float32Array(count * 3)

const color = new THREE.Color()

for (let i = 0; i < count; i++) {

  const i3 = i * 3

  const x = (Math.random() - 0.5) * 4

  const y = (Math.random() - 0.5) * 4

  const z = (Math.random() - 0.5) * 4

  positions[i3 + 0] = x

  positions[i3 + 1] = y

  positions[i3 + 2] = z

  const t = THREE.MathUtils.clamp((y + 2) / 4, 0, 1)

  color.setHSL(0.6 * (1 - t), 0.8, 0.5)

  colors[i3 + 0] = color.r

  colors[i3 + 1] = color.g

  colors[i3 + 2] = color.b

}

const geometry = new THREE.BufferGeometry()

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))

const material = new THREE.PointsMaterial({ size: 0.035, sizeAttenuation: true, vertexColors: true })

const points = new THREE.Points(geometry, material)

scene.add(points)


function animate () {

  requestAnimationFrame(animate)

  points.rotation.y += 0.002

  points.rotation.x += 0.0008

  renderer.render(scene, camera)

}

animate()

Step 3 — OrbitControls and point size

Add OrbitControls to rotate, pan, and zoom the camera. Tune the point appearance with PointsMaterial.size and sizeAttenuation. In this step you can use the [ and ] keys to decrease/increase the point size at runtime for quick visual feedback.

JavaScript (plain)

import * as THREE from 'three'

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'


const width = 960, height = 540

const scene = new THREE.Scene()

const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000)

camera.position.z = 5

const renderer = new THREE.WebGLRenderer({ antialias: true })

renderer.setSize(width, height)

document.querySelector('#app').appendChild(renderer.domElement)

const controls = new OrbitControls(camera, renderer.domElement)


const count = 7000

const positions = new Float32Array(count * 3)

for (let i = 0; i < count; i++) {

  const i3 = i * 3

  positions[i3 + 0] = (Math.random() - 0.5) * 6

  positions[i3 + 1] = (Math.random() - 0.5) * 6

  positions[i3 + 2] = (Math.random() - 0.5) * 6

}

const geometry = new THREE.BufferGeometry()

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

const material = new THREE.PointsMaterial({ color: 0xffffff, size: 0.025, sizeAttenuation: true })

const points = new THREE.Points(geometry, material)

scene.add(points)


window.addEventListener('keydown', (e) => {

  if (e.key === ']') material.size = Math.max(0.001, material.size + 0.005)

  if (e.key === '[') material.size = Math.max(0.001, material.size - 0.005)

})


function animate () {

  requestAnimationFrame(animate)

  renderer.render(scene, camera)

}

animate()

Step 4 — Picking: highlight hovered point

Implement a simple hover highlighter: project each point to clip space and compute the squared distance to the mouse (also in clip space). The nearest point is recolored each frame. This naive O(n) approach is clear to read; for large clouds, use spatial indices (k-d tree) or GPU-based picking.

JavaScript (plain)

import * as THREE from 'three'

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'


const width = 960, height = 540

const scene = new THREE.Scene()

const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000)

camera.position.z = 6

const renderer = new THREE.WebGLRenderer({ antialias: true })

renderer.setSize(width, height)

document.querySelector('#app').appendChild(renderer.domElement)

const controls = new OrbitControls(camera, renderer.domElement)


const count = 5000

const positions = new Float32Array(count * 3)

const colors = new Float32Array(count * 3)

const white = new THREE.Color(0xffffff)

for (let i = 0; i < count; i++) {

  const i3 = i * 3

  positions[i3 + 0] = (Math.random() - 0.5) * 6

  positions[i3 + 1] = (Math.random() - 0.5) * 6

  positions[i3 + 2] = (Math.random() - 0.5) * 6

  colors[i3 + 0] = white.r

  colors[i3 + 1] = white.g

  colors[i3 + 2] = white.b

}

const geometry = new THREE.BufferGeometry()

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))

const material = new THREE.PointsMaterial({ size: 0.03, sizeAttenuation: true, vertexColors: true })

const points = new THREE.Points(geometry, material)

scene.add(points)


const mouse = new THREE.Vector2(0, 0)

const highlight = new THREE.Color(0xff5555)

const original = new THREE.Color(0xffffff)

let highlightIndex = -1

renderer.domElement.addEventListener('mousemove', (e) => {

  const rect = renderer.domElement.getBoundingClientRect()

  mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1

  mouse.y = -(((e.clientY - rect.top) / rect.height) * 2 - 1)

})


function animate () {

  requestAnimationFrame(animate)

  const pos = geometry.getAttribute('position')

  const col = geometry.getAttribute('color')

  const v = new THREE.Vector3()

  const projected = new THREE.Vector3()

  let nearest = -1

  let minDist = Infinity

  if (highlightIndex >= 0) {

    const i3 = highlightIndex * 3

    col.array[i3 + 0] = original.r

    col.array[i3 + 1] = original.g

    col.array[i3 + 2] = original.b

  }

  for (let i = 0; i < pos.count; i++) {

    v.fromBufferAttribute(pos, i)

    projected.copy(v).project(camera)

    const dx = projected.x - mouse.x

    const dy = projected.y - mouse.y

    const d2 = dx * dx + dy * dy

    if (d2 < minDist) { minDist = d2; nearest = i }

  }

  if (nearest >= 0) {

    const i3 = nearest * 3

    col.array[i3 + 0] = highlight.r

    col.array[i3 + 1] = highlight.g

    col.array[i3 + 2] = highlight.b

    col.needsUpdate = true

    highlightIndex = nearest

  }

  renderer.render(scene, camera)

}

animate()

Tip: For large clouds consider spatial indices (k‑d tree) or GPU picking. This example uses a naive nearest‑point search for clarity.

Step 5 — Multi‑colored Spiral (parametric)

Generate a parametric spiral by increasing the radius with angle and adding a gentle vertical wave. Map the hue to the angle around the spiral for a vivid rainbow gradient. This creates a dynamic, multi‑colored point cloud reminiscent of a swirling galaxy.

You can tweak the number of turns, growth rate, wave amplitude/frequency, point count and size using the on‑canvas controls. Toggle animation to make the spiral “breathe” (wave amplitude oscillation) and to shift hues over time for a lively effect.

  • Parametric curve: r(θ) = r0 + k·θ, positions (x, y, z) = (r cos θ, wave(θ), r sin θ).
  • Color: hue = θ / (turns·2π)Color.setHSL(hue, 0.8, 0.55).
  • Thickness: add small random jitter to radius and height for organic density.

JavaScript (plain)

import * as THREE from 'three'

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'


const width = 960, height = 540

const scene = new THREE.Scene()

const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000)

camera.position.set(0, 0.6, 5)

const renderer = new THREE.WebGLRenderer({ antialias: true })

renderer.setSize(width, height)

document.querySelector('#app').appendChild(renderer.domElement)

const controls = new OrbitControls(camera, renderer.domElement)


const turns = 6

const totalTheta = turns * Math.PI * 2

const r0 = 0.4, rK = 0.12

const jitterR = 0.08, jitterY = 0.04

const waveAmp = 0.25, waveFreq = 3.2

const count = 14000

const positions = new Float32Array(count * 3)

const colors = new Float32Array(count * 3)

const color = new THREE.Color()

for (let i = 0; i < count; i++) {

  const t = i / (count - 1)

  const theta = t * totalTheta

  const baseR = r0 + rK * theta

  const r = baseR + (Math.random() - 0.5) * 0.08

  const x = r * Math.cos(theta)

  const z = r * Math.sin(theta)

  const y = 0.25 * Math.sin(theta * 3.2) + (Math.random() - 0.5) * 0.04

  const i3 = i * 3

  positions[i3 + 0] = x; positions[i3 + 1] = y; positions[i3 + 2] = z

  const hue = (theta % (Math.PI * 2)) / (Math.PI * 2)

  color.setHSL(hue, 0.85, 0.55)

  colors[i3 + 0] = color.r; colors[i3 + 1] = color.g; colors[i3 + 2] = color.b

}

const geometry = new THREE.BufferGeometry()

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))

const material = new THREE.PointsMaterial({ size: 0.02, sizeAttenuation: true, vertexColors: true })

const points = new THREE.Points(geometry, material)

scene.add(points)

function animate () {

  requestAnimationFrame(animate)

  points.rotation.y += 0.003

  renderer.render(scene, camera)

}

animate()

Performance Tips

  • Typed arrays only: Use Float32Array with BufferAttribute. Mutate in place; set attribute.needsUpdate = true only when needed.
  • Point size and density: Prefer sizeAttenuation. Very tiny sizes can alias; use point sprites (alpha‑mapped discs) for round dots.
  • Renderer caps: GPUs limit gl_PointSize. Stick to 1–10 px for broad compatibility.
  • Frustum culling: One THREE.Points = one draw call. For huge datasets, split into tiles and cull per tile.
  • Precision management: Normalize/center data near the origin to reduce z‑fighting and precision errors.
  • Picking at scale: O(n) is fine to ~50–200k. Above that, use a spatial index (k‑d tree/BVH) or GPU ID buffer.

Round sprite material example:

const sprite = document.createElement('canvas'); sprite.width = sprite.height = 64
const ctx = sprite.getContext('2d'); ctx.clearRect(0,0,64,64)
ctx.fillStyle = 'white'; ctx.beginPath(); ctx.arc(32,32,28,0,Math.PI*2); ctx.fill()
const tex = new THREE.CanvasTexture(sprite)
const material = new THREE.PointsMaterial({ size: 0.05, map: tex, alphaTest: 0.5, transparent: true, vertexColors: true, sizeAttenuation: true })

Next Steps

  • Load real data: Parse PLY/PCD/LAZ or GLTF point primitives; map attributes to colors.
  • Attribute coloring: Switch palettes (height, intensity, classes) via a GUI.
  • Normals and shading: Render discs with custom shaders using gl_PointCoord and normal‑based lighting.
  • Tiling + LOD: Stream tiles, fade by distance, keep frame times stable.

Loading a PLY file with vertex colors:

import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader.js'
const loader = new PLYLoader()
loader.load('/data/cloud.ply', (geometry) => {
  const hasColor = !!geometry.getAttribute('color')
  const mat = new THREE.PointsMaterial({ size: 0.03, vertexColors: hasColor })
  scene.add(new THREE.Points(geometry, mat))
})

Troubleshooting

  • Blank screen: Ensure points are within the camera frustum; verify canvas size and renderer mount.
  • Invisible points: Increase PointsMaterial.size or temporarily disable sizeAttenuation.
  • Color issues: Convert 0–255 byte colors to 0..1 floats; avoid sRGB confusion in custom shaders.
  • Precision artifacts: Recenter data near the origin; adjust near/far planes.
  • Slow hover picking: Debounce pointer events or switch to GPU ID buffer for large clouds.

FAQ

How many points can Three.js render? Millions are possible on desktops; tune attributes, tiling, and LOD for mobile.

How do I color by intensity? Upload a scalar attribute and map it to RGB on CPU or via a 1D LUT texture in a shader.

How do I load PLY/PCD? Use the example loaders (PLYLoader, PCDLoader) and set vertexColors if available.

Can I do GPU picking? Render an ID buffer offscreen and read the pixel under the cursor to get the point index.