将向量投影到NDC时出现XY坐标抖动问题
ThreeJS世界空间向量投影NDC时的抖动问题
我在使用ThreeJS将世界空间向量投影到标准化设备坐标(NDC)时,常规场景下功能正常,但快速平移相机时,投影生成的XY坐标会出现明显抖动。绕原点运动的对象#2抖动不太明显,但位于原点的对象#1,即便相机绕其旋转平移,理论上位置应完全不变,却仍出现抖动。
我最初怀疑是DOM更新速度跟不上rAF回调,但查看对象#1的XY坐标值时,发现确实存在抖动——比如相机正对该向量中心旋转时,X坐标会在323.50左右波动。
示例代码如下:
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/0.146.0/three.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/three@0.146.0/examples/js/controls/OrbitControls.js"></script> <style> body { position: relative; margin: 0; padding: 0; font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; } .overlay { position: absolute; z-index: 2; top: 0; left: 0; display: grid; place-items: center; background-color: #555; width: 48px; height: 48px; border: 4px solid #fff; border-radius: 50px; transform: translate(calc(var(--x, 0) - 50%), calc(var(--y, 0) - 50%)); color: #fff; } </style> <script> const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); document.body.appendChild(renderer.domElement); const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); const cube = new THREE.Mesh(geometry, material); scene.add(cube); const controls = new THREE.OrbitControls(camera, renderer.domElement); camera.position.set(2, 2, 2); controls.enableDamping = true; controls.minPolarAngle = Math.PI * 0.2; controls.maxPolarAngle = Math.PI * 0.45; controls.update(); // NOTE: Append DOM overlays const pointsToProject = [new THREE.Vector3(0, 0, 0), new THREE.Vector3(2,2,-2)]; pointsToProject.forEach((_, i) => { const overlayElement = document.createElement('div'); overlayElement.className = 'overlay'; overlayElement.id = `overlay-${i}`; overlayElement.textContent = i + 1; document.body.appendChild(overlayElement); }); function animate() { requestAnimationFrame(animate); const halfStageWidth = window.innerWidth * 0.5; const halfStageHeight = window.innerHeight * 0.5; pointsToProject.forEach((vector, i) => { const { x, y } = vector.clone().project(camera); const screenX = x * halfStageWidth + halfStageWidth; const screenY = y * halfStageHeight * -1 + halfStageHeight; const overlayElement = document.querySelector(`#overlay-${i}`); if (!overlayElement) return; overlayElement.style.setProperty('--x', `${screenX.toFixed(2)}px`); overlayElement.style.setProperty('--y', `${screenY.toFixed(2)}px`); }); controls.update(); renderer.render(scene, camera); }; animate(); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); </script>
问题原因与修复方案
核心原因
- 浮点精度误差:
Vector3.project()涉及多次矩阵乘法(视图矩阵+投影矩阵),快速操作相机时,矩阵计算的浮点精度误差会被放大,导致屏幕坐标出现微小波动。 - 坐标计算与截断误差:手动计算屏幕坐标时的浮点运算,加上
toFixed(2)的截断处理,会加剧抖动问题。 - 阻尼更新的帧间差异:启用
enableDamping后,相机位置/旋转是渐进式更新的,每帧的相机状态存在微小差异,投影结果也会随之波动。
修复步骤
- 使用内置投影方法:用
renderer.projectVector()替代手动计算,该方法内部做了精度优化,避免重复矩阵运算。 - 复用向量对象:避免每帧克隆
Vector3,减少内存开销与计算误差。 - 保留原始浮点值:取消
toFixed(2)截断,直接使用原始浮点值,让浏览器CSS引擎处理亚像素定位。 - 同步相机状态:确保在
controls.update()之后执行投影计算,使用最新的相机矩阵。
修改后的完整代码
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/0.146.0/three.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/three@0.146.0/examples/js/controls/OrbitControls.js"></script> <style> body { position: relative; margin: 0; padding: 0; font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; } .overlay { position: absolute; z-index: 2; top: 0; left: 0; display: grid; place-items: center; background-color: #555; width: 48px; height: 48px; border: 4px solid #fff; border-radius: 50px; color: #fff; /* 亚像素渲染优化 */ will-change: transform; transform: translate3d(calc(var(--x, 0) - 50%), calc(var(--y, 0) - 50%), 0); } </style> <script> const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); document.body.appendChild(renderer.domElement); const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); const cube = new THREE.Mesh(geometry, material); scene.add(cube); const controls = new THREE.OrbitControls(camera, renderer.domElement); camera.position.set(2, 2, 2); controls.enableDamping = true; controls.minPolarAngle = Math.PI * 0.2; controls.maxPolarAngle = Math.PI * 0.45; controls.update(); // 初始化DOM覆盖元素 const pointsToProject = [new THREE.Vector3(0, 0, 0), new THREE.Vector3(2,2,-2)]; pointsToProject.forEach((_, i) => { const overlayElement = document.createElement('div'); overlayElement.className = 'overlay'; overlayElement.id = `overlay-${i}`; overlayElement.textContent = i + 1; document.body.appendChild(overlayElement); }); // 复用投影向量,避免重复克隆 const tempVector = new THREE.Vector3(); function animate() { requestAnimationFrame(animate); // 先更新控制器,确保相机状态最新 controls.update(); pointsToProject.forEach((vector, i) => { // 使用renderer内置方法做投影,精度更高 tempVector.copy(vector); renderer.projectVector(tempVector, camera); // 计算屏幕坐标,保留原始浮点值 const screenX = (tempVector.x * 0.5 + 0.5) * window.innerWidth; const screenY = (-tempVector.y * 0.5 + 0.5) * window.innerHeight; const overlayElement = document.querySelector(`#overlay-${i}`); if (!overlayElement) return; // 直接设置原始数值,不做截断 overlayElement.style.setProperty('--x', `${screenX}px`); overlayElement.style.setProperty('--y', `${screenY}px`); }); renderer.render(scene, camera); }; animate(); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); </script>
内容的提问来源于stack exchange,提问作者Terry
相关产品推荐
相关产品推荐

