用 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 高亮,是最直接可用的方案。
