Three.js 点云矩形框选:屏幕空间投影法

用 Three.js 显示点云(THREE.Points)后,做”鼠标拖矩形框选一批点”这类交互,标准做法是屏幕空间投影——把每个 3D 点 project 到 2D 屏幕,判是否落在鼠标矩形里。

为什么不用 Raycaster

Three.js 的 Raycaster 主要针对射线,判”鼠标下选中哪个物体”用得多。但做矩形框选用它效率极差——要么在 CPU 上做 AABB,要么写 GPU shader,两条路都比”直接投影”复杂得多。

生产上标注工具、CAD、自动驾驶点云可视化基本都用屏幕空间法。

整体流程

鼠标 mousedown → 记录 (startX, startY)
mousemove → 更新矩形 DOM
mouseup   → 遍历 points.geometry
             ├─ 每个点 → applyMatrix4(worldMatrix)
             ├─       → .project(camera) 转 NDC
             ├─       → NDC → 屏幕坐标
             └─ 判断是否落在矩形内

完整最小实现

先做一个 DOM 矩形跟着鼠标:

const box = document.createElement("div");
box.style.cssText = `
  position: fixed;
  border: 1px solid #00aaff;
  background: rgba(0,170,255,0.1);
  pointer-events: none;
  display: none;
`;
document.body.appendChild(box);

let startX = 0, startY = 0, endX = 0, endY = 0, dragging = false;

window.addEventListener("mousedown", e => {
  dragging = true;
  startX = e.clientX;
  startY = e.clientY;
  Object.assign(box.style, {
    left: startX + "px",
    top:  startY + "px",
    width:  "0px",
    height: "0px",
    display: "block",
  });
});

window.addEventListener("mousemove", e => {
  if (!dragging) return;
  endX = e.clientX;
  endY = e.clientY;

  Object.assign(box.style, {
    left:  Math.min(startX, endX) + "px",
    top:   Math.min(startY, endY) + "px",
    width:  Math.abs(endX - startX) + "px",
    height: Math.abs(endY - startY) + "px",
  });
});

window.addEventListener("mouseup", () => {
  dragging = false;
  box.style.display = "none";
  selectPoints();
});

框选核心

function selectPoints() {
  const minX = Math.min(startX, endX);
  const maxX = Math.max(startX, endX);
  const minY = Math.min(startY, endY);
  const maxY = Math.max(startY, endY);

  const attr = pointCloud.geometry.attributes.position;
  const vec = new THREE.Vector3();
  const selected = [];

  pointCloud.updateMatrixWorld();

  for (let i = 0; i < attr.count; i++) {
    vec.fromBufferAttribute(attr, i);
    vec.applyMatrix4(pointCloud.matrixWorld);   // 本地 → 世界
    vec.project(camera);                         // 世界 → NDC (-1 ~ 1)

    // NDC → 屏幕
    const sx = (vec.x * 0.5 + 0.5) * window.innerWidth;
    const sy = (-vec.y * 0.5 + 0.5) * window.innerHeight;

    if (sx >= minX && sx <= maxX && sy >= minY && sy <= maxY) {
      selected.push(i);
    }
  }

  console.log("选中", selected.length, "个点");
  highlight(selected);
}

高亮选中的点

改颜色最简单的做法:给点云加一个 color attribute。

const geo = pointCloud.geometry;
if (!geo.attributes.color) {
  const colors = new Float32Array(geo.attributes.position.count * 3);
  colors.fill(1);   // 全白
  geo.setAttribute("color", new THREE.BufferAttribute(colors, 3));
  pointCloud.material.vertexColors = true;
}

function highlight(indices) {
  const colors = geo.attributes.color;
  // 先全部还原
  for (let i = 0; i < colors.count; i++) colors.setXYZ(i, 1, 1, 1);
  // 选中的染红
  for (const i of indices) colors.setXYZ(i, 1, 0, 0);
  colors.needsUpdate = true;
}

剔除相机背后的点

vec.project(camera)相机背后的点 z 会 > 1,也可能被误判进屏幕矩形。加一道过滤:

if (vec.z < -1 || vec.z > 1) continue;   // 相机视锥体外

性能

十万点级别这套办法很流畅(一次 mouseup 内做完)。到一百万点就要考虑:

  • 用 Worker 并发做投影
  • GPU picking:把点索引编码到颜色渲染到 offscreen canvas,读像素判索引
  • 空间分区(Octree/KDTree)预筛选

一般标注场景十万级足够。

一句话总结

点云框选 = 每个点 project 到屏幕 + 矩形内包含判定。做完投影后加”背后点剔除”,用 color attribute 高亮,是最直接可用的方案。