You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

将向量投影到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后,相机位置/旋转是渐进式更新的,每帧的相机状态存在微小差异,投影结果也会随之波动。

修复步骤

  1. 使用内置投影方法:用renderer.projectVector()替代手动计算,该方法内部做了精度优化,避免重复矩阵运算。
  2. 复用向量对象:避免每帧克隆Vector3,减少内存开销与计算误差。
  3. 保留原始浮点值:取消toFixed(2)截断,直接使用原始浮点值,让浏览器CSS引擎处理亚像素定位。
  4. 同步相机状态:确保在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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.12 05:31:24