UE 面试题库 · 整理版

第14章 3D数学面试题

本章共收录 32 道题

一、向量基础

1.1 请解释 FVector 的 Normalize 和 GetSafeNormal 区别,以及 GetUnsafeNormal 的使用场景

★★ 中等

考点: 向量归一化、零向量处理、UE API 设计

参考答案:

  1. 归一化将向量缩放为单位向量:Â = A / |A|,|A| = √(Ax² + Ay² + Az²);
  2. 三种方式的区别:
    • Normalize():原地修改并返回旧长度,零向量会产生 NaN,最快;
    • GetSafeNormal(Tolerance):返回新向量不修改原向量,零向量返回零向量(默认容差 KINDA_SMALL_NUMBER),稍慢;
    • GetUnsafeNormal():返回新向量不修改原向量,零向量会产生 NaN,最快。
  3. 使用建议:
    • 性能敏感的热循环(Tick)中,若能确保向量非零,用 GetUnsafeNormal() 提性能;
    • 一般推荐 GetSafeNormal(),防止 NaN 传播污染后续计算;
    • 也可先算 Size() 判断 > KINDA_SMALL_NUMBER 再除以长度,避免二次开方。
  4. 代码示例:
FVector A(3.f, 4.f, 0.f);
float OldLen = A.Normalize();          // 原地修改,返回 5.0f,A=(0.6,0.8,0)
FVector C = A.GetSafeNormal();         // 返回新向量
FVector D = FVector::ZeroVector.GetSafeNormal(); // (0,0,0),安全
// FVector::ZeroVector.GetUnsafeNormal(); // (NaN,NaN,NaN),危险!

FVector Dir = Target - Source;
float Dist = Dir.Size();
if (Dist > KINDA_SMALL_NUMBER) Dir /= Dist;  // 先判零再归一化,避免二次开方
  1. 易错点:GetUnsafeNormal 产生的 NaN 会沿碰撞、动画等计算链传播导致异常;IsNormalized() 可用于断言检查;比较距离时用 SizeSquared() 避免开方。

1.2 请解释 FVector 的运算符重载和性能考量,Size() vs SizeSquared() 何时用哪个?

★★ 中等

考点: FVector 运算符、开方运算代价、性能优化

参考答案:

  1. Size() = √(Ax² + Ay² + Az²) 需要一次开方(现代 CPU 上约是乘法的 10-20 倍代价);SizeSquared() = Ax² + Ay² + Az² 只需乘加;
  2. 核心原则:只在需要实际距离值时用 Size(),比较距离大小时永远用 SizeSquared();
  3. 常用运算符重载:+ - * 分量运算;| 点积;^ 叉积;== 带容差的近似比较;
  4. 代码示例:
float D1 = (B - A).Size();
float D2 = (B - A).SizeSquared();           // 与 D1*D1 等价
if ((B - A).SizeSquared() < Radius * Radius) { }   // 避免开方
if (FVector::DistSquared(A, B) < FMath::Square(Radius)) { }  // 距离平方
float Dot = A | B;        // 点积
FVector Cross = A ^ B;    // 叉积
  1. 优化技巧:批量距离判断时把 Radius * Radius 提出循环预计算;FMath::InvSqrt() 提供快速近似倒数平方根;FVector 在某些场景使用 SSE/SIMD 指令。

二、点积与叉积

2.1 向量点积的几何意义是什么?在 UE 中如何用点积判断角色是否在视野范围内?

★★ 中等

考点: 点积几何含义、FVector::DotProduct、方向判断

参考答案:

  1. 点积公式:A·B = |A||B|cosθ;两个向量都单位化后 cosθ = ·B̂;
  2. 几何含义:> 0 夹角 < 90° 大致同向,= 0 正交,< 0 夹角 > 90° 大致反向;
  3. 视野判断:视角方向与"到目标方向"的点积 ≥ cos(半FOV) 即在视野内(比较 cos 值而非角度);
  4. 代码示例:
bool IsInView(const FVector& ViewerLoc, const FVector& ViewerForward,
              const FVector& TargetLoc, float HalfFOVDeg)
{
    FVector ToTarget = (TargetLoc - ViewerLoc).GetSafeNormal();
    float Dot = FVector::DotProduct(ViewerForward, ToTarget);
    return Dot >= FMath::Cos(FMath::DegreesToRadians(HalfFOVDeg));
}
  1. 延伸:Lambert 漫反射 I = max(0, N̂·L̂) 用于光照;FMath::Acos 可从点积反算夹角;点积分配律 A·(B+C) = A·B + A·C;点积也用于背面剔除(Back-face Culling)。

2.2 叉积的几何意义是什么?如何用叉积判断目标在玩家的左侧还是右侧?

★★ 中等

考点: 叉积方向、左右定则、FVector::CrossProduct

参考答案:

  1. 叉积结果是向量,垂直于 A、B 所在平面,方向由左右手定则确定;|A×B| = |A||B|sinθ = 以 A、B 为边的平行四边形面积;
  2. 反交换律:A×B = -(B×A);
  3. 左右判断:前向量 × 到目标方向,结果的 Z 分量符号指示左右;更简单做法是"右向量 · 到目标方向";
  4. 注意 UE 是左手坐标系(Z 朝上),叉积与旋转正方向约定与右手系不同;
  5. 代码示例:
bool IsOnRight(const FVector& ViewerRight, const FVector& ToTarget)
{
    return FVector::DotProduct(ViewerRight, ToTarget.GetSafeNormal()) > 0.f;
}
// 或使用叉积(左手系中 Z 分量为正表示右侧)
FVector Cross = FVector::CrossProduct(ViewerForward, ToTargetDir);
return Cross.Z >= 0.f;
  1. 易错点:叉积结果不一定是单位向量,需归一化后再判方向;2D 伪叉积 A×B = Ax·By - Ay·Bx(标量)用于 2D 方向判断;叉积也用于计算三角形法线。

三、向量投影

3.1 什么是向量投影?如何用投影计算角色在斜坡上的移动分量?

★★★ 困难

考点: 标量投影、向量投影公式、斜坡移动

参考答案:

  1. 标量投影(长度):comp_B(A) = A·B / |B|;
  2. 向量投影:proj_B(A) = (A·B / |B|²)·B;B 单位化时简化为 proj(A) = (A·B̂)B̂;
  3. 向量拒绝(垂直分量):rej_B(A) = A - proj_B(A);
  4. 斜坡移动:表面方向 = 移动方向 − 法线方向分量(即 VectorPlaneProject),重力分解同理;
  5. 代码示例:
// 移动方向投影到斜坡平面
FVector SurfaceDir = FVector::VectorPlaneProject(MoveDir, SlopeNormal);
SurfaceDir.Normalize();
// 手动实现:总方向 - 法线分量 = 表面分量
FVector NormalComp = SlopeNormal * FVector::DotProduct(MoveDir, SlopeNormal);
FVector SurfaceDir2 = (MoveDir - NormalComp).GetSafeNormal();
// 斜坡重力分解:沿法线的压力 + 沿坡面的下滑力
FVector Gravity(0.f, 0.f, -980.f);
FVector NormalForce = SlopeNormal * FVector::DotProduct(Gravity, SlopeNormal);
FVector SlopeGravity = Gravity - NormalForce;  // 沿斜坡向下的力
  1. 易错点:区分标量投影与向量投影;FVector::ProjectOnTo(B 任意长)与 FVector::ProjectOnToNormal(B 单位长)等价;正交分解即 Gram-Schmidt 正交化的基础。

3.2 如何用向量运算实现简单的 2D 寻路避障?请用 FVector2D 实现。

★★★ 困难

考点: 2D 向量、排斥力模型、距离计算

参考答案:

  1. 简单方案用排斥力模型:目标方向产生吸引速度,近处障碍物产生排斥力,F_avoid = Σ D̂i / |Di|²(距离越近力越大);
  2. 合成速度 = 吸引速度 + 排斥力,再归一化/钳制长度;
  3. FVector2D 提供完整的 2D 向量运算(Size / GetSafeNormal / Normalize),避免误用 FVector 的 3D 语义;
  4. 代码示例(核心逻辑):
FVector2D Avoid(const FVector2D& Pos, const FVector2D& Target,
                const TArray<FVector2D>& Obstacles, float R, float Strength)
{
    FVector2D Desired = (Target - Pos).GetSafeNormal();
    FVector2D Force = FVector2D::ZeroVector;
    for (const auto& O : Obstacles)
    {
        FVector2D ToO = O - Pos;
        float D = ToO.Size();
        if (D < R * 3.f)   // 在影响范围内
            Force += (Pos - O).GetSafeNormal() * (Strength / FMath::Max(D * D, 1.f));
    }
    FVector2D Vel = Desired + Force;
    if (Vel.SizeSquared() > 1.f) Vel.Normalize();
    return Vel;
}
  1. 进阶方案:RVO(Reciprocal Velocity Obstacle);UE 导航系统基于 Detour / Recast 导航网格,实际项目通常用 UNavigationSystemV1 而非手写。

四、矩阵与齐次坐标

4.1 什么是齐次坐标?为什么 3D 图形变换需要使用 4x4 矩阵?

★★★ 困难

考点: 齐次坐标原理、平移矩阵、仿射变换

参考答案:

  1. 3x3 矩阵只能表示线性变换(旋转、缩放),无法表示平移;平移需要额外的向量加法,无法统一成纯矩阵乘法;
  2. 引入齐次坐标后,(x, y, z, w) 对应 3D 点 (x/w, y/w, z/w):
    • 点:w = 1,可以被平移;
    • 方向向量:w = 0,不受平移影响。
  3. 4x4 平移矩阵把平移放入第四列,使"先缩放 → 再旋转 → 最后平移"(TRS 顺序)能用单个矩阵连乘表达,为 MVP 管线提供统一形式;
  4. 关键区别:TransformPosition(点,受平移)vs TransformVector(方向,忽略平移);
  5. 代码示例:
FMatrix TRS = FScaleMatrix(Scale) * FRotationMatrix(Rot) * FTranslationMatrix(T);
FVector4 P = TRS.TransformFVector4(FVector4(Point, 1.0f)); // 变换点
FVector D = TRS.TransformVector(Direction);                 // 变换方向,平移被忽略
  1. 延伸:透视除法(w ≠ 1 时除以 w,来自透视投影);仿射变换 = 线性变换 + 平移;UE 的 FMatrix 为行主序(Row-Major),与 OpenGL 的列主序不同。

4.2 请解释 MVP 变换管线,从模型空间到屏幕空间经历了哪些变换?

★★★ 困难

考点: Model-View-Projection 管线、坐标空间变换链、NDC

参考答案:

  1. 完整链路:模型空间 --M--> 世界空间 --V--> 观察空间 --P--> 裁剪空间 --÷w--> NDC --Viewport--> 屏幕空间;
  2. 数学式:Clip = P × V × M × Local;
  3. M 由 Actor 的 Transform 得到;V 是相机世界变换的逆矩阵;P 为透视或正交投影矩阵;
  4. 裁剪空间的 w 分量来自透视投影(保存 -z),透视除法把视锥体映射到 NDC [-1,1]³,再经 Viewport 变换到像素坐标(Y 轴方向在不同平台可能翻转);
  5. 代码示例(关键步骤):
FMatrix MVP = ModelMatrix * ViewMatrix * ProjMatrix;
FVector4 Clip = MVP.TransformFVector4(FVector4(WorldPos, 1.f));
FVector NDC(Clip.X / Clip.W, Clip.Y / Clip.W, Clip.Z / Clip.W);  // 透视除法
float ScreenX = (NDC.X * 0.5f + 0.5f) * ViewportWidth;
float ScreenY = (1.f - (NDC.Y * 0.5f + 0.5f)) * ViewportHeight;  // Y 轴翻转
  1. 延伸:UE 裁剪空间使用 Reverse-Z(近平面 w=0、远平面 w=1)提高深度精度;FSceneView 封装完整的 View 与 Projection 矩阵;世界↔屏幕双向转换可用 UGameplayStatics::ProjectWorldToScreen / DeprojectScreenToWorld。

4.3 透视投影矩阵是如何构造的?为什么远处的物体看起来更小?

★★★ 困难

考点: 透视投影原理、视锥体、FOV、宽高比

参考答案:

  1. 核心是相似三角形:相机空间点 (x, y, z)(z 为负,相机看向 -Z)在近裁剪面 n 上投影为 x_proj = n·x / (-z)、y_proj = n·y / (-z);
  2. 矩阵把 FOV、宽高比、近远平面编码进来,同时把 -z 写入 w 分量,使透视除法以深度为分母 → 远处坐标被压缩得更小,形成近大远小;
  3. 透视矩阵结构:
P = [ 1/(aspect·tan(FOV/2))  0           0         0    ]
    [ 0       1/tan(FOV/2)    0           0    ]
    [ 0       0            f/(f-n)    -fn/(f-n) ]
    [ 0       0             1           0    ]
  1. 关键点:w = -z(保存原始深度);透视除法后相同 X 坐标在远处得到更小的 NDC 值;
  2. UE 内置 FPerspectiveMatrix(HalfFOVRad, Aspect, Near, Far);UE 使用 Reverse-Z 提高深度缓冲精度;正交投影不做"除以深度",物体大小不随距离变化。

4.4 视图矩阵(View Matrix)是如何构造的?LookAt 函数的数学原理是什么?

★★★ 困难

考点: 视图矩阵推导、LookAt 算法、相机变换

参考答案:

  1. 视图矩阵 = 相机世界变换的逆矩阵:V = T_cam⁻¹,本质是把世界"移到相机前方";
  2. LookAt 用三个基向量构造相机坐标系:
    • 前 f̂ = normalize(Target - Eye);
    • 右 r̂ = normalize(f̂ × up)(叉积顺序与坐标系相关);
    • 上 û = r̂ × f̂。
  3. 视图矩阵前三行为 R/U/F 基向量,第四行前三列为 -r̂·Eye、-û·Eye、-f̂·Eye(平移取负);
  4. UE 为左手系:前 +X、上 +Z,与 OpenGL(Y 朝上)约定不同;
  5. 代码示例:
FMatrix LookAtMatrix(const FVector& Eye, const FVector& Target, const FVector& Up)
{
    FVector F = (Target - Eye).GetSafeNormal();
    FVector R = FVector::CrossProduct(Up, F).GetSafeNormal();
    FVector U = FVector::CrossProduct(F, R);
    FMatrix M = FMatrix::Identity;
    M.M[0][0] = R.X; M.M[0][1] = R.Y; M.M[0][2] = R.Z;
    M.M[1][0] = U.X; M.M[1][1] = U.Y; M.M[1][2] = U.Z;
    M.M[2][0] = F.X; M.M[2][1] = F.Y; M.M[2][2] = F.Z;
    M.M[3][0] = -FVector::DotProduct(R, Eye);
    M.M[3][1] = -FVector::DotProduct(U, Eye);
    M.M[3][2] = -FVector::DotProduct(F, Eye);
    return M;
}
// UE 获取视图矩阵:Camera->GetComponentTransform().ToMatrixWithScale().Inverse()
  1. 易错点:视图矩阵与相机世界矩阵互为逆;FMatrix::Inverse 有性能开销,频繁调用应缓存;FSceneView::ViewMatrices 含完整 View/Projection;Billboard(广告牌)原理即用视图矩阵的基向量。

五、四元数与旋转

5.1 什么是万向锁(Gimbal Lock)?为什么四元数能解决这个问题?

★★★ 困难

考点: 万向锁原理、欧拉角缺陷、四元数优势

参考答案:

  1. 万向锁:当 Pitch 达到 ±90° 时,Yaw 与 Roll 的旋转轴重合,丢失一个自由度;数学上 R = Rz(α)·Ry(±90°)·Rx(γ) = Rz(α∓γ),两个角度合并、无法独立控制;
  2. 四元数 q = w + xi + yj + zk 用 4 个参数(约束 |q| = 1)直接表示绕任意轴旋转:q = cos(θ/2) + sin(θ/2)(xî + yĵ + zk̂),不依赖旋转轴的分解顺序,故无万向锁;
  3. 四元数绕任意轴旋转叠加(乘法)可避免欧拉角的自由度丢失;
  4. 代码示例:
FQuat Q = FQuat(FVector(1, 1, 0).GetSafeNormal(), FMath::DegreesToRadians(60.f)); // 绕任意轴
FQuat Combined = Q2 * Q1;   // 旋转叠加,无万向锁
  1. 注意:四元数也有数值精度问题(需定期归一化);q 与 -q 表示同一旋转(双覆盖性);FRotator 因人类可读、UI 友好仍在 UE 中广泛使用,需要时转四元数。

5.2 Slerp 和 Nlerp 有什么区别?在 UE 中何时用哪个?

★★★ 困难

考点: 四元数插值、球面线性插值、归一化线性插值

参考答案:

  1. Nlerp:q = normalize((1-t)·q1 + t·q2),先线性插值再归一化,代价低;
  2. Slerp:q = sin((1-t)θ)/sinθ · q1 + sin(tθ)/sinθ · q2,θ = acos(q1·q2),角速度恒定;
  3. 对比:
    • Slerp:恒定角速度、代价高(三角函数)、大角度旋转正确;
    • Nlerp:中间略快、代价低、小角度时几乎等同 Slerp、大角度有误差。
  4. 使用建议:小角度(如 <10°)用 Nlerp 足够且更快;大角度(如相机旋转)用 Slerp 更平滑;插值前保证 Dot ≥ 0(翻转负 q 走最短弧);
  5. 代码示例:
FQuat R = FQuat::Slerp(Start, End, t);   // UE 内置
// 手动 Nlerp:分量 Lerp 后 GetNormalized()
FQuat N = FQuat(FMath::Lerp(A.X, B.X, t), ..., FMath::Lerp(A.W, B.W, t)).GetNormalized();
// UE: FMath::QInterpTo 内部使用 Slerp;动画混合中小角度可用 Nlerp 优化

5.3 FQuat 和 FRotator 如何互转?各自的优缺点是什么?

★★ 中等

考点: 欧拉角与四元数转换、UE API

参考答案:

  1. 互转:FQuat(Rotator) / Rotator.Quaternion();Quat.Rotator() / FRotator(Quat);
  2. FRotator 优点:人类可读、编辑器友好、方便调试;缺点:有万向锁、分量 Lerp 会走长弧/穿 360° 回绕;
  3. FQuat 优点:插值与组合无万向锁、绕任意轴旋转自然、旋转向量更快;缺点:不可读、需维护归一化;
  4. UE 旋转顺序约定:Yaw(Z) → Pitch(Y) → Roll(X);
  5. 最佳实践:存储与计算用 FQuat,UI 显示与编辑器交互用 FRotator(FTransform 内部旋转即用 FQuat 存储);
  6. 代码示例:
FQuat Q = FRotator(30.f, 60.f, 10.f).Quaternion();
FRotator R = Q.Rotator();
FQuat FromAxis = FQuat(FVector(0, 0, 1), FMath::DegreesToRadians(45.f)); // 绕轴旋转
FQuat Combined = Q2 * Q1;   // 先 Q1 后 Q2
  1. 易错点:四元数插值前要确保 Dot ≥ 0;FQuat 的双覆盖性;欧拉角 + 运算符会处理组合但可读性易错。

5.4 如何用四元数实现相机的轨道旋转(Orbit Camera)?

★★★ 困难

考点: 四元数旋转组合、相机控制、弧度计算

参考答案:

  1. 轨道相机 = 焦点 + 旋转后的固定距离偏移:CameraPos = Focus + q_orbit · Offset;
  2. 用两个角度构造四元数:水平角 Azimuth(绕世界 Z 轴)+ 垂直角 Elevation(绕局部轴),组合为 q = q_azimuth · q_elevation(注意顺序);
  3. Elevation 需 Clamp 限制范围(如 -80°~80°),防止相机穿地或翻转;
  4. 代码示例(核心):
FQuat Azq = FQuat(FVector::UpVector, FMath::DegreesToRadians(Azimuth));
FQuat Elq = FQuat(FVector::RightVector, FMath::DegreesToRadians(Elevation));
FQuat Orbit = Azq * Elq;
FVector CamPos = Focus + Orbit.RotateVector(FVector(-Distance, 0, 0));
SetActorLocation(CamPos);
SetActorRotation((Focus - CamPos).Rotation());   // 看向焦点
// 平滑跟随:FQuat::Slerp(Current, Target, FMath::Clamp(DeltaTime * Speed, 0.f, 1.f))
  1. 延伸:Spring Arm 组件的碰撞检测;第三人称相机 Lag(延迟跟随);用 Slerp 平滑插值避免相机跳变。

5.5 四元数的乘法顺序如何理解?为什么 Q2 * Q1 是"先 Q1 后 Q2"?

★★★ 困难

考点: 四元数乘法、旋转组合顺序、左乘与右乘

参考答案:

  1. 四元数作用于向量为 qvq⁻¹;向量 v 先被 Q1 旋转、再被 Q2 旋转:v' = Q2(Q1·v·Q1⁻¹)Q2⁻¹ = (Q2·Q1)·v·(Q2·Q1)⁻¹,所以组合四元数为 Q2*Q1,从右向左读;
  2. 记忆法:"Q2 * Q1 表示先 Q1 后 Q2";
  3. 四元数乘法不满足交换律(q1q2 ≠ q2q1),满足结合律((q1q2)q3 = q1(q2q3));
  4. 与矩阵的差异:UE 的 FMatrix 为行主序,M1 * M2 是先 M1 后 M2,与数学教材的列主序约定相反——阅读 UE 矩阵乘法顺序时需注意;
  5. 代码示例:
FQuat Combined = Q2 * Q1;                     // 先 Q1 后 Q2
FVector Step1 = Q1.RotateVector(v);
FVector Step2 = Q2.RotateVector(Step1);       // == Combined.RotateVector(v)

5.6 如何计算两个朝向之间的最短旋转四元数?

★★★ 困难

考点: 四元数差、最短路径、角距离

参考答案:

  1. 相对旋转(增量):Δq = q2 · q1⁻¹(从 q1 到 q2 的旋转差);
  2. 最短路径:利用双覆盖性,若 Dot(q1, q2) < 0 则将 q2 翻转为 -q2 再插值/求差,走短弧;
  3. 代码示例:
FQuat Shortest(const FQuat& From, const FQuat& To)
{
    FQuat T = To;
    if (FQuat::Dot(From, T) < 0.f) T = -T;   // 翻转,走短弧
    return T * From.Inverse();                // 相对旋转四元数
}
// 角距离(弧度):From.AngularDistance(To)
// 逐步转向:Alpha = Clamp(Speed*DeltaTime / AngleDegrees, 0, 1) → Slerp(Current, Target, Alpha)
  1. 从 Δq 提取轴角:θ = 2·acos(w),轴 = (x, y, z) / sin(θ/2)(sin 接近 0 时无旋转或 180° 特判);
  2. 延伸:FQuat::FindBetweenVectors 计算两方向之间的旋转;FQuat::Error / ErrorAutoNormalize 衡量四元数误差;Slerp 在 q 与 -q 附近有数值稳定性问题。

六、碰撞检测数学

6.1 请解释 AABB 碰撞检测的原理和实现。

★★ 中等

考点: 轴对齐包围盒、重叠检测、UE API

参考答案:

  1. AABB(Axis-Aligned Bounding Box)由 Min/Max 两点定义:AABB = [Min, Max];
  2. 重叠条件 = 在三个轴上同时都有重叠:(AMin.x ≤ BMax.x && AMax.x ≥ BMin.x) 且 Y 且 Z;
  3. 代码示例:
bool Overlap(const FVector& AMin, const FVector& AMax,
             const FVector& BMin, const FVector& BMax)
{
    if (AMax.X < BMin.X || AMin.X > BMax.X) return false;
    if (AMax.Y < BMin.Y || AMin.Y > BMax.Y) return false;
    if (AMax.Z < BMin.Z || AMin.Z > BMax.Z) return false;
    return true;
}
// UE: BoxA.Intersect(BoxB)
// 从 Actor 获取:Actor->GetActorBounds(true, Origin, Extent) → FBox(Origin-Extent, Origin+Extent)
  1. 两阶段检测:宽相位(Broad Phase,BVH / 空间哈希 / Sweep and Prune 快速筛出候选对)+ 窄相位(Narrow Phase,精确检测);
  2. Swept AABB(连续碰撞检测):把 A 按速度扫掠,等价于"膨胀 B 的 AABB 后做射线检测"(Slab Method)。

6.2 什么是 Slab Method?如何用它实现光线与 AABB 的求交?

★★★ 困难

考点: 光线求交、Slab Method、tmin/tmax

参考答案:

  1. 光线方程 P(t) = O + t·D;Slab Method 把 AABB 视为三对平行平板(Slab)的交集;
  2. 每轴计算进入/离开时间:t1 = (min - O)/D、t2 = (max - O)/D,取 tEnter = min(t1,t2)、tExit = max(t1,t2);
  3. 合并三轴:t_enter = max(tx_min, ty_min, tz_min),t_exit = min(tx_max, ty_max, tz_max);
  4. 相交条件:t_enter ≤ t_exit && t_exit ≥ 0;
  5. 代码示例:
bool RayAABB(const FVector& O, const FVector& D, const FBox& Box, float& TMin, float& TMax)
{
    FVector InvD(1.f / D.X, 1.f / D.Y, 1.f / D.Z);
    FVector T1 = (Box.Min - O) * InvD;
    FVector T2 = (Box.Max - O) * InvD;
    FVector MinT(FMath::Min(T1.X, T2.X), FMath::Min(T1.Y, T2.Y), FMath::Min(T1.Z, T2.Z));
    FVector MaxT(FMath::Max(T1.X, T2.X), FMath::Max(T1.Y, T2.Y), FMath::Max(T1.Z, T2.Z));
    TMin = FMath::Max3(MinT.X, MinT.Y, MinT.Z);
    TMax = FMath::Min3(MaxT.X, MaxT.Y, MaxT.Z);
    return TMin <= TMax && TMax >= 0.f;
}
  1. 易错点:方向分量接近 0 时需特殊处理(先判断起点是否在该平板内);t < 0 表示交点在光线背后;入口法线 = 命中轴的相反方向。UE 内置 FMath::LineBoxIntersection;该方法也是光线追踪 BVH 遍历的基础。

6.3 OBB 碰撞检测如何实现?SAT 分离轴定理的原理是什么?

★★★ 困难

考点: OBB、分离轴定理(SAT)、投影测试

参考答案:

  1. SAT(分离轴定理):两个凸体不重叠 ⇔ 存在一条轴使两者在该轴上的投影不重叠;
  2. 对两个 OBB 需要测试 15 条轴:A 的 3 个面法线 + B 的 3 个面法线 + 3×3 个边叉积(最多 9);
  3. 判定:每条轴上,两 OBB 投影区间中心距 > 半径和即分离;最小重叠轴即碰撞法线方向、重叠量即穿透深度;
  4. 代码示例(核心):
struct FOBB { FVector Center; FVector Axes[3]; FVector HalfExtent; };
// 投影半径 = Σ |dot(HalfExtent_i · Axes_i, N)|
float ProjectionRadius(const FOBB& Box, const FVector& N);
bool TestAxis(const FOBB& A, const FOBB& B, const FVector& Axis) {
    if (Axis.IsNearlyZero()) return true;
    FVector N = Axis.GetSafeNormal();
    float PA = ProjectionRadius(A, N), PB = ProjectionRadius(B, N);
    float Dist = FMath::Abs(FVector::DotProduct(B.Center - A.Center, N));
    return (PA + PB) >= Dist;   // 分离则返回 false
}
// 依次测试 A 三轴、B 三轴、9 个叉积轴,全部通过才算碰撞
  1. 追问要点:SAT 仅适用于凸体(凹体需分解为凸体);为什么不需要测试自身边叉积(平行或已被面法线覆盖);如何获取碰撞法线与穿透深度(最小重叠轴)。UE 物理引擎用 PhysX/Chaos 做精确碰撞检测。

6.4 如何实现光线与球体的求交?

★★★ 困难

考点: 光线-球体求交、二次方程求解

参考答案:

  1. 球面方程 |P - C|² = r²,代入光线 P(t) = O + t·D,令 L = O - C 得二次方程: (D·D)t² + 2(L·D)t + (L·L - r²) = 0;
  2. 判别式 Δ = b² - 4ac:< 0 无交点、= 0 相切、> 0 两个交点;
  3. 归一化方向时 a = 1 可简化;取最近有效 t ≥ 0 的根作为命中点,法线 N = (Hit - C) / r;
  4. 代码示例:
FVector L = O - C;
float A = D | D, B = 2.f * (L | D), C = (L | L) - r * r;
float Disc = B * B - 4.f * A * C;
if (Disc < 0.f) return false;
float SqrtDisc = FMath::Sqrt(Disc);
float t0 = (-B - SqrtDisc) / (2.f * A);
float t1 = (-B + SqrtDisc) / (2.f * A);
float t = (t0 >= 0.f) ? t0 : t1;   // t<0 表示球体在光线背后
FVector Hit = O + D * t;
  1. 延伸:实际项目通常先用 AABB 粗筛再做球体精确检测;光线-Capsule 求交用于角色碰撞体;UE 拾取常用 FCollisionQueryParams + FHitResult。

6.5 如何用数学方法判断一个点是否在三角形内部?

★★★ 困难

考点: 重心坐标、面积法、点-三角形测试

参考答案:

  1. 重心坐标法:P = uA + vB + wC,u + v + w = 1 且 u, v, w ≥ 0 时 P 在内部;重心坐标还能用于插值 UV、法线、颜色等顶点属性;
  2. 面积法:Area(ABC) = Area(PAB) + Area(PBC) + Area(PCA);
  3. 叉积同侧法(2D):P 对三条边做 2D 伪叉积,结果同号则在内部;
  4. 代码示例(2D 重心坐标):
FVector2D V0 = B - A, V1 = C - A, V2 = P - A;
float Denom = V0.X * V1.Y - V1.X * V0.Y;
if (FMath::Abs(Denom) < KINDA_SMALL_NUMBER) return false;  // 退化三角形
float U = (V2.X * V1.Y - V1.X * V2.Y) / Denom;
float V = (V0.X * V2.Y - V2.X * V0.Y) / Denom;
float W = 1.f - U - V;
return U >= 0.f && V >= 0.f && W >= 0.f;
  1. 3D 版本:先投影到面积最大的主平面再降维成 2D 测试,或做三个子三角形法线同向测试;UE 有 FMath::ComputeBaryCentric2D;应用:光线-三角形求交(Moller-Trumbore)、顶点着色器插值。

6.6 请解释 GJK 算法的基本思想,它和 SAT 有什么区别?

★★★ 困难

考点: GJK 算法原理、Minkowski 差、单纯形

参考答案:

  1. 核心思想:两个凸体 A、B 重叠 ⇔ Minkowski 差 A ⊖ B = {a - b} 包含原点;
  2. 算法流程:用 Support 函数取某方向上的最远点,迭代构造 Minkowski 差上的单纯形(2D:点→线段→三角形;3D:点→线段→三角形→四面体),每步判断原点是否被包含,不包含则更新搜索方向/缩小单纯形;
  3. 与 SAT 的区别:GJK 更通用(任意凸体、任意维度)、通常迭代次数少更快;SAT 直观易实现、测试轴固定(2D 3 轴 / 3D 15 轴)但需配合旋转矩阵;
  4. 代码要点:
FVector2D Support(A, B, D) { return Furthest(A, D) - Furthest(B, -D); }
// 主循环:加入新点 → Dot(NewPoint, Direction) < 0 则不相交 → UpdateSimplex
// UpdateSimplex:线段 → 判 AO 与 AB 的关系更新方向或移除点;
//               三角形 → 原点在三角形内则相交,否则缩小单纯形
  1. 延伸:EPA(Expanding Polytope Algorithm)从 GJK 的单纯形扩展得到穿透深度与碰撞法线;Box2D、Bullet、PhysX 均使用 GJK。

七、插值与曲线

7.1 Lerp 的数学原理是什么?为什么 Lerp 不适合用于旋转插值?

★★ 中等

考点: 线性插值、旋转插值的陷阱

参考答案:

  1. Lerp(A, B, t) = A + t(B - A) = (1-t)A + tB,t ∈ [0, 1];
  2. 不适合旋转的两个原因:
    • 回绕问题:欧拉角 350° 与 10° 实际只差 20°,直接分量 Lerp 会绕 340° 长弧;
    • 万向锁问题:Pitch 穿过 ±90° 时自由度丢失,分量插值结果错误。
  3. 正确做法:旋转用 FQuat::Slerp 或 UE 的 FMath::QInterpTo(四元数)、FMath::RInterpTo(内部处理角度回绕);
  4. Lerp 适用场景:位置、颜色、标量、缩放的插值;
  5. 代码示例:
// 欧拉角回绕修正(手动)
float DeltaYaw = End.Yaw - Start.Yaw;
if (DeltaYaw > 180.f)  DeltaYaw -= 360.f;
if (DeltaYaw < -180.f) DeltaYaw += 360.f;
// 推荐
FRotator R = FMath::RInterpTo(Cur, Target, DeltaTime, Speed);
FQuat Q = FMath::QInterpTo(CurQuat, TargetQuat, DeltaTime, Speed);
  1. 延伸:FMath::LerpStable 数值更稳定;双线性插值用于纹理采样;三次 Hermite 插值用于曲线。

7.2 请解释贝塞尔曲线(Bézier Curve)的数学原理和 UE 中的实现。

★★★ 困难

考点: 贝塞尔曲线、De Casteljau 算法、曲线参数化

参考答案:

  1. 三次贝塞尔(4 个控制点):B(t) = (1-t)³P0 + 3(1-t)²t·P1 + 3(1-t)t²·P2 + t³·P3;通用形式为 Bernstein 多项式 B(t) = Σ C(n,i)(1-t)^(n-i)·t^i·Pi;
  2. De Casteljau 算法:逐层线性插值 Lerp(Pi, Pi+1, t) 递归得到曲线点,数值更稳定;
  3. 切线(导数):B'(t) = 3(1-t)²(P1-P0) + 6(1-t)t(P2-P1) + 3t²(P3-P2);
  4. 代码示例:
FVector CubicBezier(const FVector& P0, const FVector& P1,
                    const FVector& P2, const FVector& P3, float t)
{
    float u = 1.f - t;
    return u*u*u*P0 + 3.f*u*u*t*P1 + 3.f*u*t*t*P2 + t*t*t*P3;
}
// UE: USplineComponent 使用样条(含贝塞尔模式);FMath::CubicInterp 为 Hermite 插值
  1. 特性:贝塞尔不经过中间控制点(被控制点"吸引");应用:弹道预测、相机路径、UI 动画;追问可讨论 B-Spline 与 NURBS。

7.3 Catmull-Rom 样条的数学原理是什么?它和贝塞尔曲线有什么区别?

★★★ 困难

考点: Catmull-Rom 样条、经过控制点、切线计算

参考答案:

  1. Catmull-Rom 是 Hermite 插值的一种,曲线经过所有控制点(与贝塞尔的关键区别);
  2. 对连续四点 P0-P3 在 P1-P2 段插值:C(t) = ½[(−P0+3P1−3P2+P3)t³ + (2P0−5P1+4P2−P3)t² + (−P0+P2)t + 2P1];
  3. 切线由前后点差决定:C'(t) = (P_{i+1} − P_{i−1}) / 2,可用 Hermite 插值 FMath::CubicInterp(P1, Tangent1, P2, Tangent2, t) 等价实现;
  4. 代码示例:
FVector CatmullRom(const FVector& P0, const FVector& P1,
                   const FVector& P2, const FVector& P3, float t)
{
    float t2 = t * t, t3 = t2 * t;
    return 0.5f * ((2.f * P1) + (-P0 + P2) * t
        + (2.f*P0 - 5.f*P1 + 4.f*P2 - P3) * t2
        + (-P0 + 3.f*P1 - 3.f*P2 + P3) * t3);
}
  1. 选择建议:相机路径、过场动画用 Catmull-Rom(路径点需精确经过);UI 动画、路径编辑用贝塞尔(控制点像"磁铁")。延伸:B-Spline、NURBS、USplineComponent 的样条类型选择。

7.4 如何实现平滑的缓动函数(Easing Function)?请列举常见的缓动类型。

★★★ 困难

考点: 缓动函数、EaseIn/EaseOut/EaseInOut、平滑曲线

参考答案:

  1. 缓动把线性输入 t ∈ [0, 1] 映射为非线性输出,制造自然的动画手感:EaseIn(慢起快停)、EaseOut(快起慢停)、EaseInOut(两头慢中间快);
  2. 常见类型:二次/三次幂函数、正弦、指数(Expo)、圆形(Circular)、回弹(Bounce)、弹性(Elastic);
  3. 代码示例:
float EaseInQuad(float t)   { return t * t; }
float EaseOutQuad(float t)  { return t * (2.f - t); }
float EaseInOutSine(float t){ return -0.5f * (FMath::Cos(PI * t) - 1.f); }
float SmoothStep(float t)   { return t * t * (3.f - 2.f * t); }  // 首尾导数归零
// UE: FMath::InterpEaseIn/Out/InOut、FMath::SmoothStep、FMath::InterpCircularIn 等
  1. SmoothStep = 3t² − 2t³ 是三次多项式,首尾导数归零,比二次缓动更平滑;
  2. 追问点:CSS cubic-bezier 用三次贝塞尔反求 t(牛顿迭代求解 X(t) = target);UE UCurveFloat 资产可可视化编辑缓动;缓动在 Animation Blueprint 中也有应用。

7.5 如何实现等速沿曲线移动(Arc-Length Parameterization)?

★★★ 困难

考点: 弧长参数化、速度均匀性、查找表

参考答案:

  1. 问题:贝塞尔参数 t 均匀分布 ≠ 弧长均匀分布,直接按 t 移动会在弯曲处快、平直处慢;
  2. 思路:s(t) = ∫₀ᵗ |C'(τ)| dτ 难以解析求解,用采样查找表近似:均匀采样曲线点、累积相邻点距离构成累积弧长表,再二分查找目标弧长对应的 t 并线性插值;
  3. 代码要点:
// 构建累积弧长表(如 100 采样点)
for (int32 i = 0; i <= NumSamples; i++) {
    float T = (float)i / NumSamples;
    FVector P = EvaluateBezier(T);
    Cum.Add(Cum.Last() + FVector::Dist(Prev, P));
    Prev = P;
}
// 弧长比例 → 参数 t:二分查找 Cum 中 Target = ratio*Total 的位置,再对两采样点对应的 t 做 Lerp
float T = FindTForArcLength(ratio);
FVector Pos = EvaluateBezier(T);
  1. 追问:查找表精度与性能的权衡;弯曲处自适应加密采样;UE USplineComponent::GetLocationAtDistanceAlongSpline 内部已做弧长参数化。

八、坐标系与空间转换

8.1 左手坐标系和右手坐标系有什么区别?UE 使用哪种?

★★ 中等

考点: 坐标系定义、叉积方向、UE 坐标系约定

参考答案:

  1. 左右手定则决定 Z 轴方向、叉积方向与旋转正方向:
    • 右手系(OpenGL、Maya、Blender):X×Y = Z,逆时针为正;
    • 左手系(DirectX、UE、3ds Max):X×Y = Z,顺时针为正。
  2. UE 使用左手坐标系:X 前、Y 右、Z 上;FVector::ForwardVector=(1,0,0)、RightVector=(0,1,0)、UpVector=(0,0,1);
  3. FRotator 约定:Pitch 绕 Y 轴(抬头/低头)、Yaw 绕 Z 轴(左右转)、Roll 绕 X 轴(翻滚);
  4. 代码示例:
FVector Cross = FVector::CrossProduct(FVector::ForwardVector, FVector::RightVector);
// Cross = (0, 0, 1),符合左手定则方向
FRotator Yaw(0.f, 90.f, 0.f);                 // 顺时针向右转 90°
FVector F = Yaw.RotateVector(FVector::ForwardVector);  // (0,1,0) 指向右
  1. 易错点:从右手系 DCC(如 Blender)导入模型轴会翻转;跨引擎网络同步、着色器坐标变换时需做坐标系转换;UE 的 Z 朝上 ≈ OpenGL 的 Y 朝上。

8.2 世界空间、局部空间、相机空间之间如何转换?

★★★ 困难

考点: 空间变换链、FTransform、逆矩阵

参考答案:

  1. 链路:局部空间 --ActorTransform--> 世界空间 --ViewMatrix--> 相机空间;
  2. 用 FTransform 互转(内部高效求逆,比手动 Matrix.Inverse() 更优):TransformPosition / InverseTransformPosition;
  3. 区别:TransformPosition(点,受平移影响)vs TransformVector(向量,不受平移)vs TransformRotation;
  4. 屏幕相关:UGameplayStatics::ProjectWorldToScreen(世界→屏幕)、DeprojectScreenToWorld(屏幕→世界,产生射线);
  5. 代码示例:
FTransform T = Actor->GetActorTransform();
FVector World = T.TransformPosition(Local);        // 局部 → 世界
FVector Local = T.InverseTransformPosition(World); // 世界 → 局部
UGameplayStatics::ProjectWorldToScreen(PC, WorldPos, ScreenPos);  // 世界 → 屏幕
  1. 延伸:多级空间(世界→Actor→组件→骨骼),如 SkelComp->GetBoneTransform(TEXT("hand_r"));GetComponentTransform 是组合父级后的最终世界变换。

8.3 UE5 的大世界坐标(LWC)双精度是如何实现的?为什么需要双精度?

★★★ 困难

考点: LWC 原理、浮点精度、双精度实现

参考答案:

  1. float 约 7 位有效数字,坐标超过约 10km(10000 单位)时毫米级精度丢失:100000.0f + 0.001f == 100000.0f,导致物体抖动(Jittering)、碰撞检测失效、动画不平滑;
  2. UE5 LWC(Large World Coordinates)三重策略:
    • FVector 底层改用 double 存储世界坐标;
    • 渲染用相对坐标:渲染时把顶点转换到相机相对坐标(float)再进入管线;
    • 维护世界原点偏移(World Origin),必要时平移原点避免相对距离过大。
  3. 代码要点:
// 渲染时转换为相对相机的 float 坐标
FVector3f RenderPos(FVector(WorldPos - WorldOriginOffset));  // double 减法保留精度
// 物理/逻辑计算全程用 double,保留毫米级小增量精度
  1. 追问:原点重置时需同步更新物理、导航网格、渲染代理的坐标偏移;网络同步的 LWC 处理;与其他引擎 64 位坐标方案的对比。

8.4 如何将 2D 屏幕坐标转换为 3D 世界坐标(反投影)?

★★★ 困难

考点: 反投影、射线投射、深度重建

参考答案:

  1. 屏幕坐标丢失深度信息,反投影结果是一条射线而非单点;链路:Screen →(逆 Viewport)→ NDC →(逆 Projection)→ 观察空间 →(逆 View)→ 世界空间;
  2. UE 封装:UGameplayStatics::DeprojectScreenToWorld(PC, ScreenPos, WorldLoc, WorldDir),再 LineTraceSingleByChannel 求交点即鼠标拾取;
  3. 手动反投影关键步骤:
float NDX = ScreenPos.X / ViewWidth * 2.f - 1.f;
float NDY = 1.f - (ScreenPos.Y / ViewHeight) * 2.f;  // Y 轴翻转
FVector4 Clip(NDX, NDY, 0.f, 1.f);
FVector4 View = InvProj.TransformFVector4(Clip);
View /= View.W;                                       // 透视除法
FVector World = InvView.TransformPosition(FVector(View));
  1. UE 使用 Reverse-Z(深度 0=远平面、1=近平面),从深度缓冲重建世界坐标时注意深度含义;
  2. 实际常用:PC->GetHitResultUnderCursor(ECC_Visibility, false, HitResult) 直接获取鼠标下的 Actor;应用:鼠标拾取、UI 与 3D 交互、AR/VR 叠加。

8.5 如何计算两个物体在世界空间中的相对速度和相对位置?

★★ 中等

考点: 相对运动、速度合成、参考系变换

参考答案:

  1. 相对位置 r_rel = rB − rA;相对速度 v_rel = vB − vA(在 A 的参考系中观察 B 的运动);
  2. 接近速度 = 相对速度在相对位置方向上的投影:v_close = v_rel · dirToB(负值表示远离);
  3. 拦截时间/位置(假设 B 匀速):t = |r_rel| / v_close,Intercept = PosB + VelB·t;
  4. 碰撞预测:把两球相对运动看成"射线 vs 膨胀球",解二次方程 |r_rel + t·v_rel|² = (rA+rB)²,取最早的正时间根;
  5. 代码示例:
FVector RelPos = PosB - PosA;
FVector RelVel = VelB - VelA;
float Close = FVector::DotProduct(RelVel, RelPos.GetSafeNormal());  // 接近速度
// 碰撞预测:a = v_rel·v_rel, b = 2·(r_rel·v_rel), c = r_rel·r_rel − R², Δ ≥ 0 时取最近正根
  1. 应用:导弹追踪(纯追踪 vs 比例导航)、AI 避障、威胁评估(前碰撞预警)、UNavigationSystemV1 路径预测。