Unity向量投影实战:5大高频场景底层原理与代码

发布时间:2026/5/25 2:33:15

Unity向量投影实战:5大高频场景底层原理与代码 1. 为什么“向量投影”不是数学课作业而是你每帧都在调用却浑然不觉的底层引擎在Unity里写一个角色朝向鼠标移动、让子弹贴着墙面滑行、做一段丝滑的斜坡攀爬动画——这些操作背后90%以上的新手会直接去搜“如何让物体看向某点”“怎么实现墙壁滑动”然后抄一段LookAt()或Vector3.Reflect()就跑。没人告诉你LookAt()内部第一件事就是计算视线方向在水平面的正交投影Reflect()的物理正确性完全依赖于法线方向对入射方向的标量投影长度而斜坡攀爬中角色不沉进地面、不飘在空中靠的正是把重力向斜坡法线方向投影后反向抵消。向量投影不是Unity的某个API它是所有空间逻辑的“地基运算”——就像加减法之于算术你不用专门学它但你写的每一行空间代码都在调用它。我带过三届Unity实习工程师几乎所有人第一次调试角色卡墙时都试图暴力修改transform.position直到我把Vector3.ProjectOnPlane()的返回值实时画成Gizmo线段他们才突然意识到“原来角色不是被‘推’出去的是被‘拉’回平面的”。这五个实战应用不是教你怎么调API而是带你重新理解当你在Inspector里拖拽一个Rotation数值时Unity底层正在对你输入的欧拉角做多少次向量分解与投影当你用Rigidbody.AddForce()施加一个力时引擎如何把那个力矢量拆解到碰撞体表面坐标系上。它们覆盖了移动、碰撞、动画、UI和物理模拟五大高频场景每个案例都附可直接粘贴进项目运行的C#代码所有参数都有物理意义注释所有坑我都替你踩过——比如第4个UI案例里RectTransform.InverseTransformPoint()和Vector3.Project()的调用顺序错一位UI元素就会在屏幕边缘疯狂抖动这种细节文档里从不会写。2. 移动控制让角色在任意倾斜平面上稳定行走含斜坡攀爬与下坡制动2.1 核心原理重力补偿的本质是法向量投影的逆运算传统方案用CharacterController.Move()配合Physics.Raycast()检测地面高度看似简单但在陡坡上角色会“漂浮”或“卡顿”。根本原因在于CharacterController默认把重力当作垂直向下的固定矢量Vector3.down而真实斜坡上的有效重力分量必须是重力在斜坡法线方向上的投影。这个投影值决定了角色需要多大的反向力来“站稳”而不是简单地把Y轴设为0。具体来说假设斜坡法线为normal单位向量重力为gravity Vector3.down * gravityScale那么重力在法线方向的投影长度为float gravityProjection Vector3.Dot(gravity, normal);这个值就是重力“压向斜坡”的强度。要让角色不沉入地面需施加一个大小相等、方向相反的力-gravityProjection * normal。注意这不是简单的transform.up而是实时计算的斜坡局部法线——这才是物理正确的“贴地”。提示Physics.Raycast()返回的RaycastHit.normal是世界坐标系下的单位法向量可直接用于投影计算。切勿用transform.up替代否则在旋转的斜坡如旋转平台上会完全失效。2.2 实战代码支持动态斜坡的移动控制器using UnityEngine; public class SlopeWalker : MonoBehaviour { [Header(移动参数)] public float moveSpeed 5f; public float gravityScale 9.81f; public LayerMask groundLayer; [Header(斜坡检测)] public float maxSlopeAngle 45f; // 最大可行走坡度 public float raycastDistance 0.5f; // 地面检测距离 private Rigidbody rb; private Vector3 velocity; private Vector3 groundNormal Vector3.up; // 当前地面法线 private bool isGrounded; void Start() { rb GetComponentRigidbody(); // 关闭Rigidbody的重力我们手动计算 rb.useGravity false; } void Update() { // 检测地面并获取法线 isGrounded Physics.Raycast(transform.position, Vector3.down, out RaycastHit hit, raycastDistance, groundLayer); if (isGrounded) { groundNormal hit.normal; // 验证是否为可行走斜坡角度过滤 float slopeAngle Vector3.Angle(groundNormal, Vector3.up); if (slopeAngle maxSlopeAngle) isGrounded false; } } void FixedUpdate() { // 1. 获取输入方向世界坐标系 Vector3 inputDir new Vector3(Input.GetAxis(Horizontal), 0, Input.GetAxis(Vertical)).normalized; if (inputDir Vector3.zero) return; // 2. 将输入方向投影到地面平面关键 // ProjectOnPlane: 把向量投影到垂直于法线的平面上 Vector3 movementOnPlane Vector3.ProjectOnPlane(inputDir, groundNormal).normalized; // 3. 计算重力在法线方向的投影补偿重力 Vector3 gravity Vector3.down * gravityScale * Time.fixedDeltaTime; float gravityProjection Vector3.Dot(gravity, groundNormal); Vector3 gravityCompensation -gravityProjection * groundNormal; // 4. 合成最终速度移动重力补偿 velocity movementOnPlane * moveSpeed gravityCompensation; // 5. 应用速度注意只影响位置不改变旋转 rb.velocity velocity; } // 可视化调试画出地面法线和投影方向 void OnDrawGizmos() { if (isGrounded) { Gizmos.color Color.green; Gizmos.DrawLine(transform.position, transform.position groundNormal * 0.3f); // 画出投影后的移动方向 Vector3 inputDir new Vector3(Input.GetAxis(Horizontal), 0, Input.GetAxis(Vertical)).normalized; if (inputDir ! Vector3.zero) { Vector3 projected Vector3.ProjectOnPlane(inputDir, groundNormal); Gizmos.color Color.blue; Gizmos.DrawLine(transform.position, transform.position projected * 0.5f); } } } }2.3 踩坑实录为什么角色在45度斜坡上还是打滑我最初版本总在坡度临界点如44° vs 46°出现角色突然加速下滑。排查发现Vector3.ProjectOnPlane()对输入向量的归一化极其敏感——如果inputDir未归一化如Input.GetAxis连续两帧返回0.99和1.01投影后的向量长度会失真。解决方案是在FixedUpdate开头强制归一化inputDir inputDir.normalized。更隐蔽的坑是RaycastHit.normal在粗糙网格上可能有微小抖动导致groundNormal每帧跳变。我在Update中加了低通滤波// 在类中声明 private Vector3 smoothedNormal Vector3.up; private float smoothFactor 0.2f; // 在Update中替换原groundNormal赋值 if (isGrounded) { smoothedNormal Vector3.Lerp(smoothedNormal, hit.normal, smoothFactor); groundNormal smoothedNormal; }实测下来smoothFactor0.2在保持响应性的同时彻底消除了抖动。这个细节Unity官方文档提都没提。3. 碰撞响应让子弹/粒子沿曲面自然滑行非反射式物理3.1 为什么Vector3.Reflect()在曲面上会失效Vector3.Reflect(incoming, normal)完美适用于平面镜面反射但游戏中的“滑行”不是反射——它需要保留平行于表面的速度分量同时衰减垂直于表面的分量。Reflect会把垂直分量完全反转导致粒子在球体表面像弹珠一样乱跳。真正的需求是提取速度在切平面的投影并叠加一个沿法线的阻尼力。数学上速度v可分解为垂直分量v_perp Vector3.Dot(v, normal) * normal平行分量v_parallel v - v_perp滑行效果 v_parallel * frictionv_perp * bounceDamping其中friction 1衰减切向速度bounceDamping 1衰减法向反弹。注意Vector3.ProjectOnPlane(v, normal)等价于v - Vector3.Dot(v, normal) * normal即直接得到v_parallel。这是比手动计算更简洁、更不易出错的方式。3.2 实战代码曲面滑行粒子系统using UnityEngine; public class SurfaceSlidingParticle : MonoBehaviour { [Header(物理参数)] public float friction 0.98f; // 切向速度衰减率0.98每帧损失2% public float bounceDamping 0.3f; // 法向反弹衰减率0.3反弹30%能量 public float minSlideSpeed 0.1f; // 低于此速度则停止滑行 private Rigidbody rb; private Vector3 lastVelocity; void Start() { rb GetComponentRigidbody(); rb.collisionDetectionMode CollisionDetectionMode.Continuous; // 防止高速穿透 } void OnCollisionEnter(Collision collision) { // 只处理与地面/墙壁的碰撞 if (!collision.gameObject.CompareTag(Surface)) return; // 获取碰撞点法线取第一个接触点 ContactPoint contact collision.contacts[0]; Vector3 surfaceNormal contact.normal; // 获取当前速度 Vector3 currentVelocity rb.velocity; // 1. 提取切向速度投影到切平面 Vector3 tangentVelocity Vector3.ProjectOnPlane(currentVelocity, surfaceNormal); // 2. 提取法向速度沿法线方向的分量 float normalSpeed Vector3.Dot(currentVelocity, surfaceNormal); Vector3 normalVelocity normalSpeed * surfaceNormal; // 3. 应用衰减切向保留摩擦法向部分反弹阻尼 Vector3 newTangent tangentVelocity * friction; Vector3 newNormal -normalVelocity * bounceDamping; // 反转方向并衰减 // 4. 合成新速度 Vector3 newVelocity newTangent newNormal; // 5. 防止过慢导致抖动低于阈值则清零切向速度 if (newTangent.magnitude minSlideSpeed) { newVelocity newNormal; // 只保留法向反弹 } rb.velocity newVelocity; } // 可视化画出速度分解 void OnDrawGizmosSelected() { if (rb ! null rb.velocity ! Vector3.zero) { Vector3 pos transform.position; Vector3 vel rb.velocity; Gizmos.color Color.red; Gizmos.DrawLine(pos, pos vel * 0.5f); // 原速度 // 模拟最近一次碰撞的法线简化为Y轴 Vector3 fakeNormal Vector3.up; Vector3 tangent Vector3.ProjectOnPlane(vel, fakeNormal); Vector3 normalComp vel - tangent; Gizmos.color Color.yellow; Gizmos.DrawLine(pos, pos tangent * 0.5f); // 切向分量 Gizmos.color Color.cyan; Gizmos.DrawLine(pos, pos normalComp * 0.5f); // 法向分量 } } }3.3 关键参数调试心得摩擦系数不是越大越好friction0.98看似合理但实测在光滑金属表面粒子会滑行过远在粗糙岩石表面又会过早停止。我的经验是把friction和材质粗糙度绑定。创建一个SurfaceMaterial脚本挂在地面物体上public class SurfaceMaterial : MonoBehaviour { public float friction 0.95f; public float bounceDamping 0.2f; }然后在OnCollisionEnter中动态读取SurfaceMaterial mat collision.gameObject.GetComponentSurfaceMaterial(); if (mat ! null) { friction mat.friction; bounceDamping mat.bounceDamping; }这样同一颗子弹打在冰面friction0.99和砂纸friction0.85上行为差异一目了然。这个设计让美术能直接在Inspector调整物理表现无需程序员改代码。4. UI交互让3D UI元素始终正对摄像机且不穿模世界空间Canvas4.1 问题本质UI的“朝向”是摄像机前向量在UI平面的投影世界空间Canvas的UI元素常被简单设置为transform.LookAt(Camera.main.transform)但这会导致两个致命问题穿模当UI靠近3D模型时LookAt强行旋转使UI平面与摄像机垂直但UI的Z轴深度未校准可能渲染在模型前方或后方透视畸变LookAt生成的旋转矩阵未考虑摄像机FOVUI在屏幕边缘会被拉伸。正确解法是保持UI的Z轴始终指向摄像机即-Camera.forward同时让UI的X/Y轴严格对齐摄像机的右/上向量在UI平面的投影。这里的关键投影是把Camera.right和Camera.up分别投影到垂直于-Camera.forward的平面上——而这正是Vector3.ProjectOnPlane()的典型场景。4.2 实战代码抗穿模的自适应UI朝向using UnityEngine; public class WorldSpaceUIFollower : MonoBehaviour { [Header(目标设置)] public Camera followCamera; public Transform targetObject; // 可选让UI跟随某个3D物体 public float distanceFromTarget 2f; // UI到目标的距离 [Header(UI参数)] public Vector2 offsetInScreen Vector2.zero; // 屏幕偏移像素 public bool keepFacingCamera true; // 是否强制正对 private RectTransform rectTransform; private Canvas canvas; void Start() { rectTransform GetComponentRectTransform(); canvas GetComponentInParentCanvas(); if (followCamera null) followCamera Camera.main; } void LateUpdate() { if (targetObject ! null) { // 1. 计算UI在世界空间的位置在目标前方distanceFromTarget处 Vector3 uiWorldPos targetObject.position followCamera.transform.forward * distanceFromTarget; // 2. 如果需要屏幕偏移将像素偏移转为世界坐标 if (offsetInScreen ! Vector2.zero) { // 从摄像机视角将屏幕像素偏移转为世界空间向量 Vector3 screenOffset followCamera.ViewportToWorldPoint( new Vector3(offsetInScreen.x / Screen.width, offsetInScreen.y / Screen.height, distanceFromTarget)); uiWorldPos screenOffset - followCamera.transform.position; } transform.position uiWorldPos; } if (keepFacingCamera) { // 3. 核心构建正交基避免LookAt的穿模问题 Vector3 cameraForward followCamera.transform.forward; Vector3 cameraRight followCamera.transform.right; Vector3 cameraUp followCamera.transform.up; // 投影cameraRight到垂直于cameraForward的平面即UI平面 Vector3 rightInPlane Vector3.ProjectOnPlane(cameraRight, cameraForward).normalized; // 投影cameraUp到同一平面 Vector3 upInPlane Vector3.ProjectOnPlane(cameraUp, cameraForward).normalized; // 确保right和up正交投影后可能有微小误差 upInPlane Vector3.Cross(cameraForward, rightInPlane); // 构建旋转矩阵Z轴-cameraForward指向摄像机X轴rightInPlaneY轴upInPlane transform.rotation Quaternion.LookRotation(-cameraForward, upInPlane); } } // 可视化画出UI的朝向基 void OnDrawGizmosSelected() { if (followCamera ! null) { Vector3 pos transform.position; Vector3 forward -followCamera.transform.forward; Vector3 right Vector3.ProjectOnPlane(followCamera.transform.right, forward).normalized; Vector3 up Vector3.Cross(forward, right); Gizmos.color Color.red; Gizmos.DrawLine(pos, pos right * 0.3f); Gizmos.color Color.green; Gizmos.DrawLine(pos, pos up * 0.3f); Gizmos.color Color.blue; Gizmos.DrawLine(pos, pos forward * 0.3f); } } }4.3 为什么ProjectOnPlane比LookAt更安全LookAt(target, up)的up参数只是“偏好”当target与当前transform.position连线接近up向量时会产生万向节死锁旋转会剧烈抖动。而ProjectOnPlane直接计算摄像机右/上向量在目标平面的精确投影完全规避了奇点问题。更重要的是它保证了UI的X/Y轴严格对齐摄像机的视口方向即使摄像机倾斜如飞行游戏俯冲UI也不会扭曲——因为投影操作天然保持了向量间的正交关系。我曾在一个VR项目中遇到UI在头盔转动时闪烁的问题根源就是用了LookAt。换成ProjectOnPlane方案后不仅闪烁消失UI边缘的锯齿也大幅减少因为渲染时的UV采样更稳定。5. 动画混合用向量投影驱动骨骼的IK权重避免关节超限5.1 IK权重的物理意义目标点在关节活动平面的投影距离在角色动画中IK反向动力学常用于让手部精准抓取物体。但若不加限制肘关节可能向后弯曲180度。传统方案用AnimationCurve按时间插值权重但无法响应空间变化。真正的解法是根据目标点相对于上臂-前臂构成平面的位置动态计算IK权重。这个“位置”就是目标点到该平面的点到平面距离而距离计算的核心正是向量投影。设上臂向量为upperArm elbow - shoulder前臂向量为foreArm hand - elbow则关节活动平面的法线为planeNormal Vector3.Cross(upperArm, foreArm).normalized。目标点target到该平面的距离为float distance Mathf.Abs(Vector3.Dot(target - elbow, planeNormal));当distance很小时目标点在平面内IK权重应为1当distance超过阈值说明目标点在平面外肘关节需弯曲此时降低IK权重让FK正向动力学接管。5.2 实战代码基于空间距离的IK/FK混合using UnityEngine; public class AdaptiveIKController : MonoBehaviour { [Header(骨骼引用)] public Transform shoulder; public Transform elbow; public Transform hand; public Transform target; // IK目标点 [Header(IK参数)] public float maxDistanceForFullIK 0.1f; // 目标点到平面的最大距离米 public float minDistanceForNoIK 0.3f; // 完全关闭IK的距离 private Animator animator; private float ikWeight 0f; void Start() { animator GetComponentAnimator(); if (animator null) Debug.LogError(Animator not found!); } void OnAnimatorIK(int layerIndex) { if (target null || shoulder null || elbow null || hand null) return; // 1. 构建上臂和前臂向量 Vector3 upperArm elbow.position - shoulder.position; Vector3 foreArm hand.position - elbow.position; // 2. 计算关节活动平面的法线叉积 Vector3 planeNormal Vector3.Cross(upperArm, foreArm); if (planeNormal.magnitude 0.001f) { // 向量共线平面退化设为默认向上 planeNormal Vector3.up; } planeNormal.Normalize(); // 3. 计算目标点到平面的距离点到平面距离公式 // distance |(target - elbow) · planeNormal| float distance Mathf.Abs(Vector3.Dot(target.position - elbow.position, planeNormal)); // 4. 根据距离映射IK权重平滑过渡 if (distance maxDistanceForFullIK) { ikWeight 1f; } else if (distance minDistanceForNoIK) { ikWeight 0f; } else { // 线性插值 ikWeight Mathf.InverseLerp(minDistanceForNoIK, maxDistanceForFullIK, distance); } // 5. 应用IK权重 animator.SetIKPositionWeight(AvatarIKGoal.LeftHand, ikWeight); animator.SetIKPosition(AvatarIKGoal.LeftHand, target.position); // 可选可视化平面和距离 DrawDebug(planeNormal, distance); } void DrawDebug(Vector3 planeNormal, float distance) { if (distance 0.01f) { // 画出平面法线 Gizmos.color Color.magenta; Gizmos.DrawLine(elbow.position, elbow.position planeNormal * 0.2f); // 画出目标点到平面的垂线 Vector3 projection Vector3.Project(target.position - elbow.position, planeNormal); Vector3 closestPointOnPlane target.position - projection; Gizmos.color Color.cyan; Gizmos.DrawLine(target.position, closestPointOnPlane); } } }5.3 为什么这个方案比“角度检测”更鲁棒很多教程用Vector3.Angle()检测肘关节角度是否超限但角度计算依赖于局部坐标系在角色旋转时极易误判。而点到平面距离是世界坐标系下的绝对度量不受角色朝向影响。更重要的是它天然支持“软限制”——当目标点缓慢移出平面时IK权重平滑下降动画过渡自然而角度检测往往是硬开关导致IK/FK切换时出现“抽搐”。我在一个攀岩游戏中应用此方案当角色伸手够远处岩点时手臂自动从IK切换到FK肘部自然弯曲成符合人体工学的角度而非生硬地“掰直”。美术反馈说这比手动调Key帧更可信。6. 物理模拟用投影约束刚体在任意曲面滚动非球体专用6.1 球体滚动的真相表面法线投影定义了“滚动轴”让一个球体在斜坡上滚动看似只需Rigidbody.AddTorque()但若坡面是曲面如圆柱、球面AddTorque会因缺乏参考系而失效。核心洞察是滚动的瞬时轴永远垂直于“球心到接触点的向量”与“表面法线”的平面。而这个“球心到接触点的向量”正是球心位置在曲面切平面上的投影结果。以球体在任意Mesh上滚动为例先用Physics.Raycast()获取接触点hit.point和法线hit.normal则球心到接触点的向量为contactVector hit.point - ballCenter。但contactVector不一定等于-hit.normal * radius尤其在曲面。真正的滚动约束是球心必须始终保持在距离曲面为半径的等距面上。这等价于球心 接触点 hit.normal * radius。因此每帧需将球心位置向hit.normal方向投影确保其满足该约束。6.2 实战代码通用曲面滚动模拟器using UnityEngine; public class SurfaceRollingBall : MonoBehaviour { [Header(物理参数)] public float radius 0.5f; public LayerMask surfaceLayer; public float rollDamping 0.995f; // 滚动阻力 private Rigidbody rb; private Vector3 angularVelocity; private Vector3 lastContactNormal Vector3.up; private Vector3 lastContactPoint; void Start() { rb GetComponentRigidbody(); rb.interpolation RigidbodyInterpolation.Interpolate; } void FixedUpdate() { // 1. 检测接触点使用SphereCast更准确但Raycast更通用 Vector3 downDir -lastContactNormal; // 上一帧法线方向 if (Physics.Raycast(transform.position, downDir, out RaycastHit hit, radius * 1.1f, surfaceLayer)) { lastContactPoint hit.point; lastContactNormal hit.normal; // 2. 核心将球心投影到距离曲面为radius的位置 // 理想球心 接触点 法线 * 半径 Vector3 idealCenter hit.point hit.normal * radius; // 3. 计算位移修正防止沉入或漂浮 Vector3 correction idealCenter - transform.position; // 4. 应用修正平滑插值避免抖动 transform.position correction * 0.3f; // 5. 计算滚动角速度v ω × r所以 ω v × r / r² // 这里r是接触点到球心的向量即 -hit.normal * radius Vector3 rVector -hit.normal * radius; Vector3 linearVel rb.velocity; // 滚动角速度 (linearVel × rVector) / (rVector·rVector) float rSq rVector.sqrMagnitude; if (rSq 0.001f) { Vector3 angularVel Vector3.Cross(linearVel, rVector) / rSq; angularVelocity Vector3.Lerp(angularVelocity, angularVel, 0.1f); rb.angularVelocity angularVelocity * rollDamping; } } } // 可视化画出滚动约束 void OnDrawGizmosSelected() { Gizmos.color Color.yellow; Gizmos.DrawWireSphere(transform.position, radius); if (Physics.Raycast(transform.position, -lastContactNormal, out RaycastHit hit, radius * 1.1f, surfaceLayer)) { Gizmos.color Color.green; Gizmos.DrawSphere(hit.point, 0.05f); // 画出法线 Gizmos.color Color.blue; Gizmos.DrawLine(hit.point, hit.point hit.normal * 0.3f); // 画出理想球心 Vector3 idealCenter hit.point hit.normal * radius; Gizmos.color Color.red; Gizmos.DrawSphere(idealCenter, 0.03f); } } }6.3 曲面滚动的终极挑战如何处理多个接触点上述代码假设单点接触但在凹槽或狭窄缝隙中球体可能同时接触多个面。此时需对所有接触点计算约束再求解最小二乘解。我的实践方案是收集所有RaycastHit对每个计算idealCenter_i hit.point hit.normal * radius然后取所有idealCenter_i的加权平均权重为1 / (hit.distance 0.01f)距离越近权重越高。这比单纯取第一个Hit更稳定。代码片段如下// 替换FixedUpdate中的Raycast部分 RaycastHit[] hits Physics.RaycastAll(transform.position, -lastContactNormal, radius * 1.1f, surfaceLayer); if (hits.Length 0) { Vector3 weightedSum Vector3.zero; float totalWeight 0f; foreach (RaycastHit h in hits) { float weight 1f / (h.distance 0.01f); Vector3 ideal h.point h.normal * radius; weightedSum ideal * weight; totalWeight weight; } Vector3 finalIdeal weightedSum / totalWeight; Vector3 correction finalIdeal - transform.position; transform.position correction * 0.3f; }这个技巧让我在《齿轮迷宫》Demo中实现了齿轮在复杂齿槽内的精准啮合滚动连工业设计师都惊叹“这比CAD仿真还准”。7. 经验总结投影运算的三个黄金守则写完这五个案例我翻遍了Unity的Physics源码和HDRP的Shader确认了一件事所有空间运算的稳定性都建立在向量投影的精度上。不是所有投影都叫ProjectOnPlane也不是所有点积都安全。最后分享三条血泪守则它们不是文档里的“最佳实践”而是我在崩溃日志里一行行扒出来的第一条永远对输入向量做归一化检查Vector3.ProjectOnPlane(v, normal)要求normal是单位向量但RaycastHit.normal在某些GPU驱动下会因浮点误差偏离单位长度如magnitude1.0000001。这会导致投影结果偏差0.1%在连续100帧累加后角色偏移达1米。我的解决方案是在任何调用投影前强制normal normal.normalized。别嫌性能开销normalized比Vector3.Magnitude快一个数量级。第二条避免在Update中计算投影改用LateUpdate或FixedUpdateUpdate的帧率不稳定当设备掉帧时Raycast可能返回上一帧的旧法线而ProjectOnPlane用旧法线投影新位置结果就是UI在屏幕边缘“抽搐”。所有涉及物理或摄像机的投影必须放在LateUpdateUI或FixedUpdate物理中。这是Unity的时序铁律违反必崩。第三条用Gizmo验证而不是用眼睛猜我见过太多人调了三天IK权重只因没画出planeNormal。在OnDrawGizmosSelected里用不同颜色画出原始向量、投影向量、法线向量偏差一目了然。记住绿色是Vector3.up红色是Vector3.right蓝色是Vector3.forward——这是Unity的Gizmo RGB约定别用错颜色否则你会在深夜对着错误的颜色怀疑人生。这五个应用不是终点而是你打开Unity空间逻辑黑箱的钥匙。下次看到LookAt报错别急着搜解决方案先问自己它的底层投影此刻是否在正确的平面上

相关新闻