告别卡顿!Three.js百万面模型用GPU秒速拾取物体并计算交点

2024-06-21 开发技术 180 次阅读 0 次点赞
本文介绍了在Three.js中利用GPU选取物体并计算交点位置的技术,解决了传统Raycaster在大模型(如40万面)场景下的性能瓶颈。核心思想是为每个模型分配独立颜色并渲染到离屏缓冲区,通过读取鼠标位置像素颜色识别物体;交点计算则通过渲染深度信息并反算世界坐标实现。文章详细说明了选取材质创建、颜色分配、深度着色器编码、像素读取及世界坐标反推的完整流程,并给出了顶点与片元着色器代码。该技术适用于鼠标Hover、模型预览、测量工具等高性能交互场景,能在一帧内完成选取和交点计算。

在三维场景开发中,物体选取和交点计算是最常见的交互需求之一。通常我们会使用three.js自带的光线投射器(Raycaster)来实现:

var raycaster = new THREE.Raycaster();
var mouse = new THREE.Vector2();

function onMouseMove(event) {
    mouse.x = event.clientX / window.innerWidth * 2 - 1;
    mouse.y = - (event.clientY / window.innerHeight) * 2 + 1;
}

function pick() {
    raycaster.setFromCamera(mouse, camera);
    var intersects = raycaster.intersectObjects(scene.children);
}

这种方法虽然简单直观,但存在一个致命问题:当模型面数很大时(比如40万个面),遍历所有三角面元计算交点会非常慢,严重影响用户体验。

GPU选取物体的原理

使用GPU选取物体可以完美解决这个问题。无论场景和模型有多大,都能在一帧内获取到鼠标所在点的物体和交点位置。

核心思想很简单:

  1. 创建选取材质,为场景中每个模型分配不同的颜色
  2. 读取鼠标位置的颜色值,根据颜色判断选中的物体

具体实现

1. 创建选取材质

遍历场景,为每个Mesh创建独立的着色器材质,分配不同的颜色值:

let maxHexColor = 1;

scene.traverseVisible(n => {
    if (!(n instanceof THREE.Mesh)) return;
    
    n.oldMaterial = n.material;
    if (n.pickMaterial) {
        n.material = n.pickMaterial;
        return;
    }
    
    let material = new THREE.ShaderMaterial({
        vertexShader: PickVertexShader,
        fragmentShader: PickFragmentShader,
        uniforms: {
            pickColor: { value: new THREE.Color(maxHexColor) }
        }
    });
    
    n.pickColor = maxHexColor;
    maxHexColor++;
    n.material = n.pickMaterial = material;
});

顶点着色器 PickVertexShader

void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

片元着色器 PickFragmentShader

uniform vec3 pickColor;

void main() {
    gl_FragColor = vec4(pickColor, 1.0);
}

2. 读取像素颜色并识别物体

将场景绘制到WebGLRenderTarget上,读取鼠标位置的颜色:

let renderTarget = new THREE.WebGLRenderTarget(width, height);
let pixel = new Uint8Array(4);

renderer.setRenderTarget(renderTarget);
renderer.clear();
renderer.render(scene, camera);
renderer.readRenderTargetPixels(renderTarget, offsetX, height - offsetY, 1, 1, pixel);

// 还原材质并获取选中物体
const currentColor = pixel[0] * 0xffff + pixel[1] * 0xff + pixel[2];

let selected = null;
scene.traverseVisible(n => {
    if (!(n instanceof THREE.Mesh)) return;
    
    if (n.pickMaterial && n.pickColor === currentColor) {
        selected = n;
    }
    
    if (n.oldMaterial) {
        n.material = n.oldMaterial;
        delete n.oldMaterial;
    }
});

说明offsetXoffsetY是鼠标坐标,height是画布高度。readRenderTargetPixels读取鼠标所在位置(offsetX, height - offsetY)的颜色,pixel是Uint8Array(4),分别保存RGBA四个通道,取值范围0~255。

使用GPU获取交点位置

获取交点位置的方法同样巧妙,核心思路是渲染深度信息并反算世界坐标。

1. 创建深度着色器材质

将场景深度信息编码为RGB值,渲染到WebGLRenderTarget:

const depthMaterial = new THREE.ShaderMaterial({
    vertexShader: DepthVertexShader,
    fragmentShader: DepthFragmentShader,
    uniforms: {
        far: { value: camera.far }
    }
});

顶点着色器 DepthVertexShader

precision highp float;

uniform float far;
varying float depth;

void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    depth = gl_Position.z / far;
}

片元着色器 DepthFragmentShader

precision highp float;

varying float depth;

void main() {
    float hex = abs(depth) * 16777215.0; // 0xffffff
    
    float r = floor(hex / 65535.0);
    float g = floor((hex - r * 65535.0) / 255.0);
    float b = floor(hex - r * 65535.0 - g * 255.0);
    float a = sign(depth) >= 0.0 ? 1.0 : 0.0;
    
    gl_FragColor = vec4(r / 255.0, g / 255.0, b / 255.0, a);
}

重要说明

  • gl_Position.z相机空间中的线性深度,范围从cameraNear到cameraFar
  • 除以far将其转换到0~1范围,便于作为颜色输出
  • 使用RGB三个分量输出深度值,提高精度
  • 不能使用屏幕空间深度(透视投影后是非线性的)

2. 读取并解密深度值

let renderTarget = new THREE.WebGLRenderTarget(width, height);
let pixel = new Uint8Array(4);

scene.overrideMaterial = this.depthMaterial;
renderer.setRenderTarget(renderTarget);
renderer.clear();
renderer.render(scene, camera);
renderer.readRenderTargetPixels(renderTarget, offsetX, height - offsetY, 1, 1, pixel);

if (pixel[2] !== 0 || pixel[1] !== 0 || pixel[0] !== 0) {
    let hex = (pixel[0] * 65535 + pixel[1] * 255 + pixel[2]) / 0xffffff;
    if (pixel[3] === 0) hex = -hex;
    cameraDepth = -hex * camera.far;
}

3. 计算交点世界坐标

let nearPosition = new THREE.Vector3();
let farPosition = new THREE.Vector3();
let world = new THREE.Vector3();

const deviceX = offsetX / width * 2 - 1;
const deviceY = - offsetY / height * 2 + 1;

// 计算近点和远点
nearPosition.set(deviceX, deviceY, 1);
nearPosition.applyMatrix4(camera.projectionMatrixInverse);

farPosition.set(deviceX, deviceY, -1);
farPosition.applyMatrix4(camera.projectionMatrixInverse);

// 线性插值
const t = (cameraDepth - nearPosition.z) / (farPosition.z - nearPosition.z);

world.set(
    nearPosition.x + (farPosition.x - nearPosition.x) * t,
    nearPosition.y + (farPosition.y - nearPosition.y) * t,
    cameraDepth
);
world.applyMatrix4(camera.matrixWorld);

实际应用场景

GPU选取和交点计算技术适用于对性能要求较高的场景:

  • 鼠标Hover效果 - 实时高亮显示鼠标下的模型
  • 模型放置预览 - 模型随鼠标移动实时预览放置效果
  • 测量工具 - 距离测量、面积测量时实时计算并预览
  • 大场景交互 - 场景和模型面数巨大时,避免光线投射法的性能问题

下图展示了使用GPU选取物体实现的Hover效果(黄色半透明)和选中效果(红色边框):

Three.js使用GPU选取物体

three.js投影运算速查

理解投影运算有助于掌握上述代码:

  • modelViewMatrix = camera.matrixWorldInverse * object.matrixWorld
  • viewMatrix = camera.matrixWorldInverse
  • modelMatrix = object.matrixWorld
  • project = applyMatrix4(camera.matrixWorldInverse).applyMatrix4(camera.projectionMatrix)
  • unproject = applyMatrix4(camera.projectionMatrixInverse).applyMatrix4(camera.matrixWorld)
  • gl_Position = projectionMatrix * modelViewMatrix * position

参考资料

OpenGL中使用着色器绘制深度值:https://stackoverflow.com/questions/6408851/draw-the-depth-value-in-opengl-using-shaders

在glsl中获取真实的片元着色器深度值:https://gamedev.stackexchange.com/questions/93055/getting-the-real-fragment-depth-in-glsl

最后更新于2小时前
本文由人工编写,AI优化,转载请注明原文地址: 告别卡顿!Three.js百万面模型用GPU秒速拾取物体并计算交点

评论 (0)

登录 后发表评论

暂无评论,快来发表第一条评论吧!