《影子冒险》—— 生成角色影子的包围盒
前言
这段时间在忙个人微信小游戏的开发,正好碰到个有意思的问题,需要计算出角色在马路上的投影的包围盒,用来判断角色的影子是否与汽车发生了碰撞。在这篇博文中记录分析一下生成投影包围盒的过程,如果你已经看到这篇博文,证明我的微信小游戏已经顺利上线了,它的名字叫《影子冒险》,欢迎大家来玩哦。
平行光光线投影公式
因为我的游戏目前只存在平行光,所以我这里着重介绍一下平行光的光线投影公式,用于计算出角色在地面上的投影。不过不会立刻介绍,会先解释一下基础的概念,希望大家耐心往下看。
参数化直线方程
在了解投影公式前,我们先了解下参数化直线方程,参数化直线方程是描述直线的一种数学方式,通过引入一个标量参数(通常用 t 表示)来表示直线上任意一点的位置。其核心思想是将直线视为点的连续运动轨迹,用参数 t 控制点的位置变化。
- 基本形式

- 几何意义

- 分量化表示
将方程分解为坐标分量:

每个坐标分量都独立地随 t 线性变化。
投影公式
光线投影公式的详细解释
在Unity中计算物体沿平行光方向投影到地面的位置,核心在于理解 光线与平面的交点计算。
- 基本几何概念
- 光线(Light Ray):由物体顶点沿平行光方向无限延伸的直线。
- 地面平面(Ground Plane):假设为水平面,方程一般为 y = groundHeight。
- 参数化直线方程

- 平面方程联立求解

- 投影点坐标计算

坐标计算出后,需要分3种情况讨论下:
- 光线平行于地面(Ly = 0):无法产生有效投影,需跳过计算
- 光线朝上照射(Ly > 0):仅当 Cy < groundHeight 时有效
- 光线朝下照射(Ly < 0):仅当 Cy > groundHeight 时有效
代码实现
foreach (Vector3 corner in worldCorners)
{
// 计算参数t
float t = (groundHeight - corner.y) / lightDir.y;
// 计算投影点坐标
Vector3 proj = new Vector3(
corner.x + lightDir.x * t, // X分量
groundHeight, // Y固定为地面高度
corner.z + lightDir.z * t // Z分量
);
// 更新包围盒边界
minX = Mathf.Min(minX, proj.x);
maxX = Mathf.Max(maxX, proj.x);
minZ = Mathf.Min(minZ, proj.z);
maxZ = Mathf.Max(maxZ, proj.z);
}
效果展示

完整代码
最后贴下完整代码分享给大家,有需要的可以自取
using UnityEngine;
namespace XiaYun.Core
{
[ExecuteInEditMode]
public class ShadowProjector : MonoBehaviour
{
[Header("Core Settings")]
public BoxCollider targetCollider;
public BoxCollider shadowCollider;
public Light directionalLight;
public float groundHeight = 0f;
public float shadowHeight = 0.5f;
[Header("Visualization")]
public Color boundsColor = new Color(0, 1, 0, 0.7f);
[Range(1f, 5f)] public float lineThickness = 2f;
public bool showInPlayMode = true;
private Bounds currentBounds;
void Update()
{
if (ShouldSkipUpdate()) return;
CalculateProjection();
}
private bool ShouldSkipUpdate()
{
if (targetCollider == null || directionalLight == null)
{
Debug.LogWarning("Essential components not assigned!");
return true;
}
if (Mathf.Approximately(directionalLight.transform.forward.y, 0))
{
Debug.LogWarning("Light direction is parallel to ground!");
return true;
}
return false;
}
private void CalculateProjection()
{
Vector3 lightDir = directionalLight.transform.forward.normalized;
Vector3[] corners = GetWorldSpaceCorners(targetCollider);
CalculateProjectedBounds(corners, lightDir, out Vector3 center, out Vector3 size);
if (shadowCollider != null)
{
shadowCollider.center = center;
shadowCollider.size = size;
currentBounds = shadowCollider.bounds;
}
}
private Vector3[] GetWorldSpaceCorners(BoxCollider collider)
{
Vector3[] corners = new Vector3[8];
Bounds b = collider.bounds;
corners[0] = new Vector3(b.min.x, b.min.y, b.min.z);
corners[1] = new Vector3(b.min.x, b.min.y, b.max.z);
corners[2] = new Vector3(b.max.x, b.min.y, b.min.z);
corners[3] = new Vector3(b.max.x, b.min.y, b.max.z);
corners[4] = new Vector3(b.min.x, b.max.y, b.min.z);
corners[5] = new Vector3(b.min.x, b.max.y, b.max.z);
corners[6] = new Vector3(b.max.x, b.max.y, b.min.z);
corners[7] = new Vector3(b.max.x, b.max.y, b.max.z);
return corners;
}
private void CalculateProjectedBounds(Vector3[] worldCorners, Vector3 lightDir, out Vector3 center, out Vector3 size)
{
float minX = float.MaxValue, maxX = float.MinValue;
float minZ = float.MaxValue, maxZ = float.MinValue;
foreach (Vector3 corner in worldCorners)
{
float t = (groundHeight - corner.y) / lightDir.y;
Vector3 proj = new Vector3(
corner.x + lightDir.x * t,
groundHeight,
corner.z + lightDir.z * t
);
minX = Mathf.Min(minX, proj.x);
maxX = Mathf.Max(maxX, proj.x);
minZ = Mathf.Min(minZ, proj.z);
maxZ = Mathf.Max(maxZ, proj.z);
}
center = new Vector3((minX + maxX) / 2, groundHeight, (minZ + maxZ) / 2);
size = new Vector3(maxX - minX, shadowHeight, maxZ - minZ);
}
void OnDrawGizmos()
{
if (!showInPlayMode && Application.isPlaying) return;
if (shadowCollider == null) return;
DrawBounds(currentBounds);
}
void DrawBounds(Bounds bounds)
{
Gizmos.color = boundsColor;
Gizmos.matrix = Matrix4x4.identity;
Vector3 center = bounds.center;
Vector3 size = bounds.size;
Vector3 frontTopLeft = center + new Vector3(-size.x / 2, size.y / 2, size.z / 2);
Vector3 frontTopRight = center + new Vector3(size.x / 2, size.y / 2, size.z / 2);
Vector3 frontBottomLeft = center + new Vector3(-size.x / 2, -size.y / 2, size.z / 2);
Vector3 frontBottomRight = center + new Vector3(size.x / 2, -size.y / 2, size.z / 2);
Vector3 backTopLeft = center + new Vector3(-size.x / 2, size.y / 2, -size.z / 2);
Vector3 backTopRight = center + new Vector3(size.x / 2, size.y / 2, -size.z / 2);
Vector3 backBottomLeft = center + new Vector3(-size.x / 2, -size.y / 2, -size.z / 2);
Vector3 backBottomRight = center + new Vector3(size.x / 2, -size.y / 2, -size.z / 2);
DrawThickLine(frontTopLeft, frontTopRight);
DrawThickLine(frontTopRight, frontBottomRight);
DrawThickLine(frontBottomRight, frontBottomLeft);
DrawThickLine(frontBottomLeft, frontTopLeft);
DrawThickLine(backTopLeft, backTopRight);
DrawThickLine(backTopRight, backBottomRight);
DrawThickLine(backBottomRight, backBottomLeft);
DrawThickLine(backBottomLeft, backTopLeft);
DrawThickLine(frontTopLeft, backTopLeft);
DrawThickLine(frontTopRight, backTopRight);
DrawThickLine(frontBottomRight, backBottomRight);
DrawThickLine(frontBottomLeft, backBottomLeft);
}
void DrawThickLine(Vector3 start, Vector3 end)
{
#if UNITY_EDITOR
Camera sceneCam = UnityEditor.SceneView.currentDrawingSceneView?.camera;
if (sceneCam == null) return;
Vector3 dir = (end - start).normalized;
Vector3 camForward = sceneCam.transform.forward;
Vector3 offset = Vector3.Cross(dir, camForward).normalized * lineThickness * 0.005f;
Gizmos.DrawLine(start + offset, end + offset);
Gizmos.DrawLine(start - offset, end - offset);
Gizmos.DrawLine(start + offset, start - offset);
Gizmos.DrawLine(end + offset, end - offset);
#endif
}
}
}

浙公网安备 33010602011771号