Cannon拖车自主移动异常:3D卡车模拟器技术排查
3D卡车模拟器拖车自主移动异常排查
问题背景
场景细节
- 地面完全平坦且水平
- 车轮摩擦参数设置:
frictionSlip: 1.4 - 未对拖车施加任何外力
效果对比
预期效果:拖车保持静止状态
实际情况:拖车沿Z轴自主移动(可通过坐标面板观察位置变化)
关键发现:仅当拖车两端对称布置车轴时,移动现象停止
核心疑问
请问我忽略了什么导致该异常?
最小复现代码
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x87ceeb); 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.shadowMap.enabled = true; document.body.appendChild(renderer.domElement); const infoBox = document.createElement("div"); infoBox.className = "fixed top-4 left-4 bg-black bg-opacity-50 text-white p-4 rounded-lg font-mono z-10"; document.body.appendChild(infoBox); const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); directionalLight.position.set(20, 30, 20); directionalLight.castShadow = true; directionalLight.shadow.camera.top = 20; directionalLight.shadow.camera.bottom = -20; directionalLight.shadow.camera.left = -20; directionalLight.shadow.camera.right = 20; scene.add(directionalLight); const world = new CANNON.World(); world.gravity.set(0, -9.82, 0); world.broadphase = new CANNON.SAPBroadphase(world); world.solver.iterations = 10; const groundGeometry = new THREE.PlaneGeometry(100, 100); const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x4a5d45 }); const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial); groundMesh.rotation.x = -Math.PI / 2; groundMesh.receiveShadow = true; scene.add(groundMesh); const groundBody = new CANNON.Body({ mass: 0, shape: new CANNON.Box(new CANNON.Vec3(50, 0.1, 50)), material: new CANNON.Material("groundMaterial") }); groundBody.position.y = -0.1; world.addBody(groundBody); const cones = []; const conePositions = [ { x: -1.75, z: -4 }, { x: 1.75, z: -4 }, { x: -1.75, z: 4 }, { x: 1.75, z: 4 } ]; function createCone(x, z) { const coneHeight = 0.8; const coneRadius = 0.3; const coneGeometry = new THREE.ConeGeometry(coneRadius, coneHeight, 16); const coneMaterial = new THREE.MeshStandardMaterial({ color: 0xff4500 }); const coneMesh = new THREE.Mesh(coneGeometry, coneMaterial); coneMesh.position.set(x, coneHeight / 2, z); coneMesh.castShadow = true; scene.add(coneMesh); const coneShape = new CANNON.Cylinder(coneRadius, coneRadius, coneHeight, 16); const coneBody = new CANNON.Body({ mass: 1, shape: coneShape }); coneBody.position.copy(coneMesh.position); world.addBody(coneBody); cones.push({ mesh: coneMesh, body: coneBody }); } conePositions.forEach((pos) => createCone(pos.x, pos.z)); /******************************************************************************* * Trailer *******************************************************************************/ const vehicleLength = 7; const chassisShape = new CANNON.Box(new CANNON.Vec3(1, 0.5, vehicleLength / 2)); const chassisBody = new CANNON.Body({ mass: 3000 }); chassisBody.addShape(chassisShape); // These are the legs of the trailer const legWidth = 0.5; const legShape = new CANNON.Box(new CANNON.Vec3(1, legWidth, legWidth)); chassisBody.addShape( legShape, new CANNON.Vec3(0, -legWidth / 2, vehicleLength / 2) ); chassisBody.position.set(0, 2, 0); const vehicle = new CANNON.RaycastVehicle({ chassisBody: chassisBody, indexRightAxis: 0, indexUpAxis: 1, indexForwardAxis: 2 }); vehicle.addToWorld(world); const chassisGeometry = new THREE.BoxGeometry(2.4, 1, vehicleLength); const chassisMaterial = new THREE.MeshStandardMaterial({ color: 0xcc0000 }); const chassisMesh = new THREE.Mesh(chassisGeometry, chassisMaterial); chassisMesh.castShadow = true; scene.add(chassisMesh); const radius = 0.52; const wheelOptions = { radius: radius, directionLocal: new CANNON.Vec3(0, -1, 0), suspensionStiffness: 30, suspensionRestLength: 0.3, frictionSlip: 1.4, dampingRelaxation: 2.3, dampingCompression: 4.4, maxSuspensionForce: 100000, rollInfluence: 0.01, axleLocal: new CANNON.Vec3(-1, 0, 0), chassisConnectionPointLocal: new CANNON.Vec3(), maxSuspensionTravel: 0.3 }; // wheelOptions.chassisConnectionPointLocal.set(1.1, 0, 2.0); vehicle.addWheel(wheelOptions); // wheelOptions.chassisConnectionPointLocal.set(-1.1, 0, 2.0); vehicle.addWheel(wheelOptions); wheelOptions.chassisConnectionPointLocal.set(1.1, 0, -2.5); vehicle.addWheel(wheelOptions); wheelOptions.chassisConnectionPointLocal.set(-1.1, 0, -2.5); vehicle.addWheel(wheelOptions); /************************************************************************************************/ // Comment this part wheelOptions.chassisConnectionPointLocal.set(1.1, 0, -1.3); vehicle.addWheel(wheelOptions); wheelOptions.chassisConnectionPointLocal.set(-1.1, 0, -1.3); vehicle.addWheel(wheelOptions); /************************************************************************************************/ const wheelMeshes = []; vehicle.wheelInfos.forEach(() => { const wheelGeometry = new THREE.CylinderGeometry(radius, radius, 0.4, 32); const wheelMaterial = new THREE.MeshStandardMaterial({ color: 0x1a1a1a }); const wheelMesh = new THREE.Mesh(wheelGeometry, wheelMaterial); const wheel = new THREE.Group(); wheel.add(wheelMesh); wheelMesh.rotateZ(Math.PI / 2); scene.add(wheel); wheelMeshes.push(wheel); }); let isMouseDown = false; let lastMouseX = null; let lastMouseY = null; let cameraTheta = Math.PI / 2; let cameraPhi = Math.PI / 3; const cameraRadius = 18; document.addEventListener("mousedown", (event) => { isMouseDown = true; lastMouseX = event.clientX; lastMouseY = event.clientY; }); document.addEventListener("mouseup", () => (isMouseDown = false)); document.addEventListener("mousemove", (event) => { if (!isMouseDown) return; const deltaX = event.clientX - lastMouseX; const deltaY = event.clientY - lastMouseY; lastMouseX = event.clientX; lastMouseY = event.clientY; cameraTheta -= deltaX * 0.005; cameraPhi -= deltaY * 0.005; cameraPhi = Math.max(0.1, Math.min(Math.PI / 2.2, cameraPhi)); }); const clock = new THREE.Clock(); function animate() { requestAnimationFrame(animate); const deltaTime = clock.getDelta(); world.step(1 / 60, deltaTime); chassisMesh.position.copy(chassisBody.position); chassisMesh.quaternion.copy(chassisBody.quaternion); for (let i = 0; i < vehicle.wheelInfos.length; i++) { vehicle.updateWheelTransform(i); wheelMeshes[i].position.copy(vehicle.wheelInfos[i].worldTransform.position); wheelMeshes[i].quaternion.copy( vehicle.wheelInfos[i].worldTransform.quaternion ); } const truckPosition = chassisMesh.position; const offsetX = cameraRadius * Math.sin(cameraPhi) * Math.sin(cameraTheta); const offsetY = cameraRadius * Math.cos(cameraPhi); const offsetZ = cameraRadius * Math.sin(cameraPhi) * Math.cos(cameraTheta); const cameraTargetPosition = new THREE.Vector3( truckPosition.x + offsetX, truckPosition.y + offsetY, truckPosition.z + offsetZ ); camera.position.lerp(cameraTargetPosition, 0.15); camera.lookAt(truckPosition); // Update info box const pos = chassisMesh.position; const forward = new THREE.Vector3(0, 0, -1).applyQuaternion( chassisMesh.quaternion ); let angle = Math.atan2(forward.z, forward.x) * (180 / Math.PI); const positionText = `Position:<br>X:${pos.x.toFixed(2)}<br>Y:${pos.y.toFixed( 2 )}<br>Z:${pos.z.toFixed(2)}`; const angleText = `Angle (X): ${angle.toFixed(2)}°`; infoBox.innerHTML = `${positionText}<br>${angleText}`; renderer.render(scene, camera); } window.addEventListener("resize", () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); animate();
问题分析与解决方案
核心原因
拖车自主移动的本质是非对称车轴布置引发的悬挂力矩失衡,叠加物理引擎的数值求解误差积累:
- 悬挂力矩失衡:当前车轴全部集中在拖车后半段(Z=-2.5、Z=-1.3),而拖车重心因底盘和附加腿结构位于更靠前的位置。每个车轮的悬挂力用于平衡重力,但由于力的作用点不在重心正下方,会对底盘产生微小的旋转力矩,这些力矩无法通过对称反作用力抵消,导致底盘出现旋转趋势。
- 数值误差积累:Cannon.js的接触求解器无法做到绝对精确,微小的旋转趋势会让车轮与地面产生极细微的相对滑动。摩擦力试图阻止滑动,但非对称受力分布会产生沿Z轴的净力,随着时间推移积累为明显的自主移动。
当车轴对称布置时,前后悬挂的力矩相互抵消,数值误差也被对称结构平均,因此拖车保持静止。
修复方案
方案1:对称布置车轴
取消注释前端的两个车轴代码,让前后车轴对称分布,直接抵消悬挂力矩:
wheelOptions.chassisConnectionPointLocal.set(1.1, 0, 2.0); vehicle.addWheel(wheelOptions); wheelOptions.chassisConnectionPointLocal.set(-1.1, 0, 2.0); vehicle.addWheel(wheelOptions);
方案2:匹配重心与车轴位置
如果不需要前端车轴,重新计算拖车重心,将所有车轴布置在重心正下方(或对称分布在重心两侧),确保悬挂力的合力通过重心,避免产生旋转力矩。可通过调整chassisBody的形状位置或添加额外质量分布修正重心。
内容的提问来源于stack exchange,提问作者Rickson Guidolini
相关产品推荐
相关产品推荐

