Toggle navigation
集客麦麦@谢坤
首页
随笔
首页
>>
创作中心
>>
三维空间坐标映射:基...
三维空间坐标映射:基于矩阵变换的坐标系转换
[TOC] # 三维空间坐标映射:基于矩阵变换的坐标系转换 在三维图形编程中,坐标转换是连接不同空间、实现对象精准映射的核心技术。本文以Three.js为例,从理论到实践,系统讲解如何通过4×4变换矩阵实现不同坐标系间的点映射,同时补充实操细节与易错点,让数学原理落地到代码实现。 ## 一、理论基础:从数学到三维图形 ### 1. 齐次坐标与4×4变换矩阵 三维空间中的平移、旋转、缩放无法仅通过3×3矩阵统一表达(3×3矩阵只能处理线性变换,平移是仿射变换),因此引入**齐次坐标**(将三维点 `(x,y,z)` 扩展为四维 `(x,y,z,1)`),并通过4×4矩阵统一处理所有空间变换: ``` [ R11 R12 R13 Tx ] ← 第一列:x轴方向(旋转/缩放);Tx:x轴平移 [ R21 R22 R23 Ty ] ← 第二列:y轴方向(旋转/缩放);Ty:y轴平移 [ R31 R32 R33 Tz ] ← 第三列:z轴方向(旋转/缩放);Tz:z轴平移 [ 0 0 0 1 ] ← 齐次坐标约束(保证变换一致性) ``` - **旋转/缩放**:由前3×3子矩阵(R)实现,对应坐标轴的方向向量; - **平移**:由最后一列前三个元素(Tx/Ty/Tz)实现; - Three.js中,`Matrix4` 以**列主序**存储(数组长度16,`elements` 属性按列排列),这是实操中极易出错的点。 ### 2. 坐标系映射核心原理 假设存在两个坐标系A(源坐标系)和B(目标坐标系),我们需要找到一个变换矩阵 `T`,使得**坐标系A中的任意点Pₐ,经过T变换后得到坐标系B中的点Pᵦ**,即: $$ P_B = T \times P_A $$ > 注意:Three.js中向量与矩阵的乘法是 `vector.applyMatrix4(matrix)`,等价于 `P = P × M`(行向量 × 矩阵),与数学上“列向量 × 矩阵”的顺序相反,代码实现时需特别注意矩阵乘法顺序。 ### 3. 通过对应点求解变换矩阵 若已知**4个不共面的对应点对**(坐标系A的P₁~P₄,坐标系B的Q₁~Q₄),即可唯一确定变换矩阵 `T`: 1. 构建矩阵 `Mₐ`:将P₁~P₄的齐次坐标作为列,组成4×4矩阵; 2. 构建矩阵 `Mᵦ`:将Q₁~Q₄的齐次坐标作为列,组成4×4矩阵; 3. 变换矩阵满足 `T × Mₐ = Mᵦ`,因此推导得: $$ T=M_B \times M_A^{-1}$$ 其中 `Mₐ⁻¹` 是 `Mₐ` 的逆矩阵(仅当4个点不共面时,`Mₐ` 可逆)。 ## 二、代码实现解析:Three.js落地实践 结合示例代码,拆解从“定义对应点”到“应用变换矩阵”的完整流程: ### 1. 步骤1:定义坐标系的对应点 选择两个立方体(坐标系A/B)的4个角点作为对应点,需保证**点的顺序严格匹配**(如均按“右上前、左上前、左上前、右下后”顺序),否则变换矩阵会失真: ```javascript // 坐标系A(20×20×20立方体)的4个角点(齐次坐标最后一维为1) const pointA1 = new THREE.Vector3(10, 10, 10) const pointA2 = new THREE.Vector3(-10, 10, 10) const pointA3 = new THREE.Vector3(-10, 10, -10) const pointA4 = new THREE.Vector3(10, -10, -10) // 坐标系B(40×40×40立方体)的对应角点 const pointB1 = new THREE.Vector3(20, 20, 20) const pointB2 = new THREE.Vector3(-20, 20, 20) const pointB3 = new THREE.Vector3(-20, 20, -20) const pointB4 = new THREE.Vector3(20, -20, -20) // 构建4×4矩阵的一维数组(列主序) const boxMesh1Corners = [ pointA1.x, pointA2.x, pointA3.x, pointA4.x, // 第一列(4个点的x坐标) pointA1.y, pointA2.y, pointA3.y, pointA4.y, // 第二列(4个点的y坐标) pointA1.z, pointA2.z, pointA3.z, pointA4.z, // 第三列(4个点的z坐标) 1, 1, 1, 1 // 第四列(齐次坐标约束) ] const boxMesh2Corners = [/* 同理构建坐标系B的矩阵数组 */] ``` ### 2. 步骤2:计算变换矩阵 核心是求逆矩阵并按正确顺序做矩阵乘法(Three.js中 `multiplyMatrices(a, b)` 等价于 `this = a × b`): ```javascript function getTransformMatrix(matrixA, matrixB) { const T = new THREE.Matrix4() const matrixAInverse = matrixA.clone().invert() // 求Mₐ的逆矩阵 T.multiplyMatrices(matrixB, matrixAInverse) // T = Mᵦ × Mₐ⁻¹ return T } // 实际调用:将数组转为Matrix4对象 const matrixA = new THREE.Matrix4().fromArray(boxMesh1Corners) const matrixB = new THREE.Matrix4().fromArray(boxMesh2Corners) const transformMatrix = getTransformMatrix(matrixA, matrixB) ``` ### 3. 步骤3:应用变换矩阵映射点 将源坐标系中的点(如小球A的局部坐标)通过变换矩阵映射到目标坐标系: ```javascript function applyTransformMatrix(vector3, matrix) { // 克隆向量避免修改原数据,applyMatrix4等价于 P = P × matrix return vector3.clone().applyMatrix4(matrix) } // 示例:将小球A的坐标映射到小球B const sphereAPos = new THREE.Vector3( sphereMeshA.position.x, sphereMeshA.position.y, sphereMeshA.position.z ) sphereMeshB.position.copy(applyTransformMatrix(sphereAPos, transformMatrix)) ``` ## 三、数学原理深度解析:为什么这样做? ### 1. 为什么需要4个不共面的点? - 三维刚体变换(仅平移+旋转)有6个自由度(3个平移、3个旋转); - 每个对应点提供3个方程(x/y/z坐标匹配),3个点可列9个方程,但存在3个冗余; - 4个不共面的点能提供12个方程,既满足约束,又能排除“共面导致的矩阵不可逆”问题; - 若点共面,`Mₐ` 为奇异矩阵(行列式为0),无法求逆,变换矩阵无解。 ### 2. 矩阵求逆的本质:“反向变换” `Mₐ⁻¹` 表示“从坐标系A转换到标准世界坐标系”的矩阵,`Mᵦ` 表示“从标准世界坐标系转换到坐标系B”的矩阵,两者相乘后: $$ T=M_B \times M_A^{-1}$$ 本质是“先把A的点转回世界坐标系,再转到B的坐标系”,最终实现A→B的直接映射。 ### 3. 齐次坐标的核心价值 - 统一仿射变换:将“平移”纳入矩阵乘法(纯3×3矩阵无法表示平移); - 兼容投影变换:后续扩展到透视投影、正交投影时,齐次坐标可自然支持(最后一维可表示深度/裁剪); - Three.js中,所有 `Vector3` 调用 `applyMatrix4` 时,会自动补全第四维为1,变换后再归一化回三维。 ## 四、实操关键注意事项(避坑指南) 1. **矩阵存储顺序**:Three.js的 `Matrix4` 是**列主序**,而手写矩阵时容易按行主序排列,需注意数组顺序(如示例中 `boxMesh1Corners` 是按列存储4个点的x/y/z); 2. **矩阵乘法顺序**:数学上 `T = Mᵦ × Mₐ⁻¹`,代码中需用 `multiplyMatrices(matrixB, matrixAInverse)`,而非反过来(矩阵乘法不满足交换律); 3. **局部坐标系vs世界坐标系**:示例中小球A/B分别挂载到两个立方体下,其 `position` 是局部坐标,若直接用世界坐标需先通过 `localToWorld` 转换; 4. **逆矩阵有效性**:务必保证4个对应点不共面(如选择立方体的4个非共面角点),否则 `invert()` 会返回无效矩阵; 5. **交互控件的影响**:使用 `TransformControls` 时,需区分“局部空间(local)”和“世界空间(world)”,确保变换的坐标空间与矩阵映射的空间一致。 ## 五、实际应用场景 1. **三维建模/动画**:将模型从局部坐标系映射到场景世界坐标系,或在父子对象间传递坐标; 2. **增强现实(AR)**:将虚拟物体的坐标映射到摄像头/真实世界坐标系,实现虚实对齐; 3. **机器人学**:机械臂各关节坐标系间的坐标转换,计算末端执行器的实际位置; 4. **计算机视觉**:相机标定后,将像素坐标(二维)映射到三维世界坐标,或多相机视角间的坐标转换; 5. **游戏开发**:角色骨骼动画中,子骨骼相对于父骨骼的坐标映射。 ## 六、总结 三维坐标系映射的核心是“通过4个不共面对应点求解4×4变换矩阵”,关键步骤为: 1. 定义源/目标坐标系的4组对应点(齐次坐标); 2. 构建点矩阵并求逆,计算得到A→B的变换矩阵 `T`; 3. 通过 `applyMatrix4` 将源点与 `T` 相乘,得到目标点。 该方法既符合三维图形学的数学逻辑,又能通过Three.js的API高效实现,是连接不同空间、实现精准坐标映射的基础技术。掌握矩阵变换的本质,不仅能解决坐标映射问题,更能理解三维图形系统的底层逻辑。 ### 实例验证: ```js import * as THREE from 'three' import { OrbitControls } from 'three/addons/controls/OrbitControls.js' // 引入变换控件 import { TransformControls } from 'three/addons/controls/TransformControls.js' let camera, scene, renderer let cameraControls // 控制球A,变换球B let sphereMeshA, sphereMeshB // 变换矩阵 let transformMatrix = new THREE.Matrix4() init() animate() // 点位做矩阵计算获取变换后的点位 function applyTransformMatrix(vector3, matrix) { const transformedVector = vector3.clone().applyMatrix4(matrix) return transformedVector } // 转换矩阵计算 function getTransformMatrix(matrixA, matrixB) { const matrix = new THREE.Matrix4() const matrixAInverse = matrixA.clone().invert() matrix.multiplyMatrices(matrixAInverse, matrixB) return matrix } // boxMesh1, boxMesh2 映射 function mapBoxMesh1ToBoxMesh2(boxMesh1, boxMesh2) { const matrixA = new THREE.Matrix4() const matrixB = new THREE.Matrix4() // 获取boxMesh1,20*20四个角坐标 const pointA1 = new THREE.Vector3(10, 10, 10) const pointA2 = new THREE.Vector3(-10, 10, 10) const pointA3 = new THREE.Vector3(-10, 10, -10) const pointA4 = new THREE.Vector3(10, -10, -10) // 重要:boxMesh1Corners 必须是4*4矩阵,最后一列必须是1,顺序为点A1,A2,A3,A4横排 const boxMesh1Corners = [ pointA1.x, pointA2.x, pointA3.x, pointA4.x, pointA1.y, pointA2.y, pointA3.y, pointA4.y, pointA1.z, pointA2.z, pointA3.z, pointA4.z, 1, 1, 1, 1, ] ;[pointA1, pointA2, pointA3, pointA4].forEach((item) => { const referenceSphere = new THREE.Mesh( new THREE.SphereGeometry(1, 16, 16), new THREE.MeshBasicMaterial({ color: 0xff00ff }), ) referenceSphere.position.copy(item) boxMesh1.add(referenceSphere) }) // 获取boxMesh2,40*40四个角坐标 const pointB1 = new THREE.Vector3(20, 20, 20) const pointB2 = new THREE.Vector3(-20, 20, 20) const pointB3 = new THREE.Vector3(-20, 20, -20) const pointB4 = new THREE.Vector3(20, -20, -20) const boxMesh2Corners = [ pointB1.x, pointB2.x, pointB3.x, pointB4.x, pointB1.y, pointB2.y, pointB3.y, pointB4.y, pointB1.z, pointB2.z, pointB3.z, pointB4.z, 1, 1, 1, 1, ] ;[pointB1, pointB2, pointB3, pointB4].forEach((item) => { const referenceSphere = new THREE.Mesh( new THREE.SphereGeometry(1, 16, 16), new THREE.MeshBasicMaterial({ color: 0xffff00 }), ) referenceSphere.position.copy(item) boxMesh2.add(referenceSphere) }) /** * 方法1:通过boxMesh1顶部四个角坐标,计算boxMesh1的变换矩阵 */ // // 矩阵A赋值boxMesh1矩阵 // console.log('boxMesh1矩阵', boxMesh1.matrix.elements) // console.log('boxMesh2矩阵', boxMesh2.matrix.elements) // matrixA.fromArray(boxMesh1.matrix.elements) // // 矩阵B赋值boxMesh2矩阵 // matrixB.fromArray(boxMesh2.matrix.elements) /** * 方法2:通过boxMesh1顶部四个角坐标,计算boxMesh1的变换矩阵 */ // 计算boxMesh1的变换矩阵 const arrA = boxMesh1Corners matrixA.fromArray(arrA) // 矩阵B赋值boxMesh2矩阵 const arrB = boxMesh2Corners matrixB.fromArray(arrB) console.log('映射矩阵arrA:', arrA) console.log('映射矩阵arrB:', arrB) return getTransformMatrix(matrixA, matrixB) } function init() { sceneInit() // 基础材质 const basicMaterial = new THREE.MeshBasicMaterial({ color: 0xff00ee, transparent: true, opacity: 0.3, wireframe: true, }) // 坐标系1 const boxGeo = new THREE.BoxGeometry(20, 20, 20, 3, 3, 3) const boxMat = basicMaterial.clone() const boxMesh1 = new THREE.Mesh(boxGeo, boxMat) boxMesh1.position.set(-20, 10, 0) boxMesh1.add(new THREE.AxesHelper(20)) scene.add(boxMesh1) // 坐标系2 const boxGeo2 = new THREE.BoxGeometry(40, 40, 40, 3, 3, 3) const boxMesh2 = new THREE.Mesh(boxGeo2, boxMat) boxMesh2.material = basicMaterial.clone() boxMesh2.material.color.set(0x00eeff) boxMesh2.rotation.set(0.5, 0.5, 0.5) boxMesh2.position.set(30, 20, 0) boxMesh2.add(new THREE.AxesHelper(20)) scene.add(boxMesh2) // 小球 const sphereGeo = new THREE.BoxGeometry(2, 2, 2, 1, 1, 1) const sphereMat = basicMaterial.clone() sphereMat.color.set(0xff0000) sphereMat.transparent = false sphereMat.wireframe = false // 控制球A,变换球B sphereMeshA = new THREE.Mesh(sphereGeo.clone(), sphereMat.clone()) sphereMeshA.position.set(0, 0, 0) // 球A初始位置,这里的坐标是boxMesh1的局部坐标系 boxMesh1.add(sphereMeshA) sphereMeshB = new THREE.Mesh(sphereGeo.clone(), sphereMat.clone()) sphereMeshB.position.set(0, 0, 0) // 球B初始位置,这里的坐标是boxMesh2的局部坐标系 sphereMeshB.material.color.set(0x0000ff) boxMesh2.add(sphereMeshB) // 第一步:映射boxMesh1到boxMesh2,获取变换矩阵 transformMatrix = mapBoxMesh1ToBoxMesh2(boxMesh1, boxMesh2) console.log('变换矩阵为', transformMatrix) // 添加变换控件到sphereMeshA addTransformControls(sphereMeshA, { scene, camera, renderer, controls: cameraControls, }) // 添加变换控件 const transformControls = new TransformControls(camera, renderer.domElement) transformControls.attach(boxMesh1) } function sceneInit() { const container = document.body // renderer renderer = new THREE.WebGLRenderer({ antialias: true }) renderer.setPixelRatio(window.devicePixelRatio) renderer.setSize(window.innerWidth, window.innerHeight) container.appendChild(renderer.domElement) // scene scene = new THREE.Scene() // 相机 camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 3000) camera.position.set(0, 55, 100) cameraControls = new OrbitControls(camera, renderer.domElement) cameraControls.target.set(0, 20, 0) cameraControls.update() // 添加光源 const ambientLight = new THREE.AmbientLight(0x404040) // 环境光 scene.add(ambientLight) const directionalLight = new THREE.DirectionalLight(0xffffff, 1) // 平行光 directionalLight.position.set(50, 50, 50) directionalLight.castShadow = true // 开启阴影 scene.add(directionalLight) // 添加坐标轴辅助 const axesHelper = new THREE.AxesHelper(50) scene.add(axesHelper) // 网格辅助 const gridHelper = new THREE.GridHelper(100, 100) scene.add(gridHelper) window.addEventListener('resize', onWindowResize) } function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight camera.updateProjectionMatrix() renderer.setSize(window.innerWidth, window.innerHeight) } function animate() { requestAnimationFrame(animate) cameraControls.update() renderer.render(scene, camera) } // 变换控件事件监听 function addTransformControls(model, ctx) { const transformControlsRoot = ctx.scene.getObjectByProperty('isTransformControlsRoot', true) if (transformControlsRoot) { // 已经重复创建 TransformControls,无需重复创建 return } const orbit = ctx.controls const transformControls = new TransformControls(ctx.camera, ctx.renderer.domElement) transformControls.addEventListener('change', render) transformControls.addEventListener('dragging-changed', function (event) { orbit.enabled = !event.value }) transformControls.attach(model) transformControls.setMode('translate') const gizmo = transformControls.getHelper() ctx.scene.add(gizmo) window.addEventListener('keydown', keydownHandler) window.addEventListener('keyup', keyupHandler) function render() { ctx.renderer.render(ctx.scene, ctx.camera) // 第二步,结果:应用sphereMeshA的位置变换到sphereMeshB if (transformMatrix) { const vectorA = new THREE.Vector3( sphereMeshA.position.x, sphereMeshA.position.y, sphereMeshA.position.z, ) console.log('球A局部坐标', vectorA) sphereMeshB.position.copy(applyTransformMatrix(vectorA, transformMatrix)) } } function keydownHandler(event) { switch (event.key) { case 'q': transformControls.setSpace(transformControls.space === 'local' ? 'world' : 'local') break case 'Shift': transformControls.setTranslationSnap(1) transformControls.setRotationSnap(THREE.MathUtils.degToRad(15)) transformControls.setScaleSnap(0.25) break case 'w': transformControls.setMode('translate') break case 'e': transformControls.setMode('rotate') break case 'r': transformControls.setMode('scale') break case '+': case '=': transformControls.setSize(transformControls.size + 0.1) break case '-': case '_': transformControls.setSize(Math.max(transformControls.size - 0.1, 0.1)) break case 'x': transformControls.showX = !transformControls.showX break case 'y': transformControls.showY = !transformControls.showY break case 'z': transformControls.showZ = !transformControls.showZ break case 'Backspace': { // 删除处理 transformControls.detach() ClearEvents() break } case ' ': transformControls.enabled = !transformControls.enabled break case 'Escape': ClearEvents() break } } function keyupHandler(event) { switch (event.key) { case 'Shift': transformControls.setTranslationSnap(null) transformControls.setRotationSnap(null) transformControls.setScaleSnap(null) break } } function ClearEvents() { transformControls.reset() gizmo.removeFromParent() transformControls.detach() transformControls.removeEventListener('change', render) transformControls.removeEventListener('dragging-changed', function (event) { orbit.enabled = !event.value }) window.removeEventListener('keydown', keydownHandler) window.removeEventListener('keyup', keyupHandler) } } ```