WebGL中获取鼠标点击天空盒3D坐标的技术咨询
嘿,这个需求我刚好折腾过,原生WebGL做天空盒点击确实得自己手动搞射线求交,其实没你想的那么复杂,咱们一步步拆解来做:
天空盒本质是一个以相机为中心的超大立方体(相机在立方体内部),我们的核心目标就是从相机原点([0,0,0])发射一条穿过鼠标点击位置的射线,计算这条射线和天空盒六个面的交点——因为射线是向外发射的,肯定会命中其中一个面,这个交点就是你要的精确点击位置。
第一步:把鼠标屏幕坐标转成标准化设备坐标(NDC)
首先得把鼠标的像素坐标转换成WebGL能识别的标准化坐标(x、y范围都是[-1, 1]),注意画布的y轴是向下的,WebGL的y轴是向上的,所以要做翻转:
function getNDC(mouseX, mouseY, canvas) { const rect = canvas.getBoundingClientRect(); // 先转成画布相对坐标 const xRel = (mouseX - rect.left) / rect.width; const yRel = (mouseY - rect.top) / rect.height; // 转成NDC并翻转y轴 return { x: xRel * 2 - 1, y: 1 - yRel * 2 }; }
第二步:计算射线的世界空间方向向量
你的相机是可以拖拽环视的,肯定有存储相机旋转的变量(比如欧拉角、四元数)或者直接维护了viewMatrix(视图矩阵)。我们需要把NDC点通过逆投影矩阵和逆视图矩阵转换成世界空间的射线方向:
// 假设你已经有mat4/vec3/vec4的基础矩阵向量运算方法(自己实现或者轻量实现就行) function getRayDirection(ndc, projectionMatrix, viewMatrix) { // 计算逆投影矩阵:把NDC转成视图空间 const invProj = mat4.invert(mat4.create(), projectionMatrix); // NDC点转视图空间齐次坐标(z设为-1,因为相机默认看向-z方向) const viewSpacePos = vec4.transformMat4(vec4.create(), [ndc.x, ndc.y, -1, 1], invProj); // 转成视图空间方向向量(除以w分量变成非齐次坐标) const viewDir = vec3.normalize(vec3.create(), [ viewSpacePos[0]/viewSpacePos[3], viewSpacePos[1]/viewSpacePos[3], viewSpacePos[2]/viewSpacePos[3] ]); // 逆视图矩阵:把视图空间方向转成世界空间方向 const invView = mat4.invert(mat4.create(), viewMatrix); const worldDir = vec3.transformMat4(vec3.create(), viewDir, invView); return vec3.normalize(vec3.create(), worldDir); }
因为相机在原点,所以射线的起点就是[0,0,0],方向就是上面算出的worldDir。
第三步:射线与天空盒六个面求交
假设你的天空盒是边长为skyboxSize的立方体,中心在原点,每个面到原点的距离是R = skyboxSize / 2(比如设skyboxSize = 2000,R就是1000,足够大就行)。六个面的平面方程很简单,我们用射线参数方程P(t) = t * direction(因为起点是原点)来计算交点:
function getSkyboxIntersection(rayDir, skyboxSize) { const R = skyboxSize / 2; let minT = Infinity; let hitFace = null; let hitPos = null; // 检查左面(x=-R) if (rayDir.x !== 0) { const t = (-R) / rayDir.x; if (t > 0 && t < minT) { const y = rayDir.y * t; const z = rayDir.z * t; if (y >= -R && y <= R && z >= -R && z <= R) { minT = t; hitFace = 'left'; hitPos = [-R, y, z]; } } } // 检查右面(x=R) if (rayDir.x !== 0) { const t = R / rayDir.x; if (t > 0 && t < minT) { const y = rayDir.y * t; const z = rayDir.z * t; if (y >= -R && y <= R && z >= -R && z <= R) { minT = t; hitFace = 'right'; hitPos = [R, y, z]; } } } // 检查下面(y=-R) if (rayDir.y !== 0) { const t = (-R) / rayDir.y; if (t > 0 && t < minT) { const x = rayDir.x * t; const z = rayDir.z * t; if (x >= -R && x <= R && z >= -R && z <= R) { minT = t; hitFace = 'bottom'; hitPos = [x, -R, z]; } } } // 检查上面(y=R) if (rayDir.y !== 0) { const t = R / rayDir.y; if (t > 0 && t < minT) { const x = rayDir.x * t; const z = rayDir.z * t; if (x >= -R && x <= R && z >= -R && z <= R) { minT = t; hitFace = 'top'; hitPos = [x, R, z]; } } } // 检查正面(z=-R) if (rayDir.z !== 0) { const t = (-R) / rayDir.z; if (t > 0 && t < minT) { const x = rayDir.x * t; const y = rayDir.y * t; if (x >= -R && x <= R && y >= -R && y <= R) { minT = t; hitFace = 'front'; hitPos = [x, y, -R]; } } } // 检查背面(z=R) if (rayDir.z !== 0) { const t = R / rayDir.z; if (t > 0 && t < minT) { const x = rayDir.x * t; const y = rayDir.y * t; if (x >= -R && x <= R && y >= -R && y <= R) { minT = t; hitFace = 'back'; hitPos = [x, y, R]; } } } return { hitFace, hitPos }; }
这里我们取最小的正t值对应的面——因为t是射线的参数,越小说明交点离相机越近,也就是实际点击的面。
第四步:把3D交点转成屏幕坐标放置标注
拿到hitPos后,需要把这个3D世界坐标转成屏幕像素坐标,才能把HTML标注放在正确位置:
function worldToScreen(worldPos, projectionMatrix, viewMatrix, canvas) { // 世界坐标转裁剪空间 const clipPos = vec4.transformMat4(vec4.create(), [...worldPos, 1], mat4.multiply(mat4.create(), projectionMatrix, viewMatrix)); // 转成NDC const ndcX = clipPos[0] / clipPos[3]; const ndcY = clipPos[1] / clipPos[3]; // NDC转屏幕坐标 const rect = canvas.getBoundingClientRect(); const screenX = (ndcX + 1) / 2 * rect.width + rect.left; const screenY = (1 - ndcY) / 2 * rect.height + rect.top; return { x: screenX, y: screenY }; }
然后创建HTML标注时,记得设置position: absolute,并且用transform: translate(-50%, -50%)让标注中心对准点击位置,避免偏移。
第五步:同步标注与视角转动
当你拖拽相机旋转时,只需要重新调用worldToScreen计算每个标注的屏幕坐标,更新HTML元素的left和top即可。如果某个标注的3D坐标超出了相机视野(比如转换后的NDC z值不在[-1,1]之间),可以直接隐藏它。
几个小提示
- 矩阵运算:如果不想用第三方库,可以自己实现简单的矩阵逆、乘法函数——比如透视投影矩阵的逆有固定公式,比通用逆矩阵计算更高效。
- 天空盒大小:不用太精确,只要足够大(比如2000、10000)就行,因为我们只关心交点的方向,大小不影响相对位置。
- 相机旋转:确保每次拖拽后都更新了
viewMatrix,这样射线方向和屏幕坐标的计算才会准确。
这样一套流程下来,就能完美实现你要的点击天空盒放置随视角移动的标注功能啦!
内容的提问来源于stack exchange,提问作者Poyr23

