前言
在 Unity 开发中,我们经常需要可视化查看模型的边界包围盒(Bounds) ,用于调试碰撞、模型尺寸、场景布局等场景。原生的 Gizmos 只能在编辑器中显示,运行时无法看到;而手动绘制线条又会遇到线宽随距离缩放、适配相机正交 / 透视模式等问题。
今天给大家分享一个运行时可动态显示、支持屏幕固定线宽、自动适配所有子物体的包围盒绘制工具,开箱即用,完美解决上述痛点!
工具核心特性
- 运行时动态显示:支持运行时开关包围盒显示,不依赖编辑器 Gizmos
- 屏幕固定线宽:无论物体远近,线条在屏幕上的宽度始终一致,视觉更舒适
- 自动适配子物体:自动计算根节点下所有子物体的总包围盒
- 双相机兼容:同时支持正交相机(2D)和透视相机(3D)
- 可配置样式:自定义线条颜色、最小 / 最大世界宽度、是否包含隐藏物体
- 性能优化:预创建 LineRenderer,动态更新位置,无 GC 开销
完整代码
直接新建 C# 脚本,命名为DrawObjectBoundsWire,粘贴以下代码:
cs
using UnityEngine;
using System.Collections.Generic;
/// <summary>绘制对象边界线</summary>
public class DrawObjectBoundsWire : MonoBehaviour
{
[Header("Target")]
public Transform targetRoot;
[Header("Line Style")]
public Color lineColor = new Color(5f / 255f, 122f / 255f, 246f / 255f, 1f);
public bool includeInactive = false; // 默认不计算未激活对象
[Header("Visibility")]
public bool showLines = false;
[Header("Constant Screen Width")]
public Camera renderCamera;
public float lineWidthPixels = 4f;
public float minWorldWidth = 0.02f;
public float maxWorldWidth = 10f;
private readonly List<LineRenderer> edgeLines = new List<LineRenderer>(12);
private static readonly int[,] EdgeIndex = new int[,]
{
{0,1},{1,2},{2,3},{3,0},
{4,5},{5,6},{6,7},{7,4},
{0,4},{1,5},{2,6},{3,7}
};
private void Start()
{
if (!Application.isPlaying) return;
EnsureLines();
Refresh();
}
private void LateUpdate()
{
if (!Application.isPlaying) return;
Refresh();
}
private void OnDisable()
{
SetLinesActive(false);
}
public void ShowLines()
{
showLines = true;
Refresh();
}
public void HideLines()
{
showLines = false;
SetLinesActive(false);
}
public void ToggleLines()
{
showLines = !showLines;
if (showLines) Refresh();
else SetLinesActive(false);
}
public void Refresh()
{
if (!Application.isPlaying) return;
if (!showLines)
{
SetLinesActive(false);
return;
}
if (targetRoot == null) targetRoot = transform;
if (renderCamera == null) renderCamera = Camera.main;
EnsureLines();
if (!TryGetLocalBounds(targetRoot, out Bounds localBounds))
{
SetLinesActive(false);
return;
}
SetLinesActive(true);
Vector3[] corners = GetCorners(localBounds);
for (int i = 0; i < 12; i++)
{
int a = EdgeIndex[i, 0];
int b = EdgeIndex[i, 1];
Vector3 p0 = corners[a];
Vector3 p1 = corners[b];
var lr = edgeLines[i];
lr.SetPosition(0, p0);
lr.SetPosition(1, p1);
float worldWidth = GetWorldWidthForPixels((p0 + p1) * 0.5f, lineWidthPixels);
lr.widthMultiplier = worldWidth;
lr.startColor = lineColor;
lr.endColor = lineColor;
}
}
private float GetWorldWidthForPixels(Vector3 localPoint, float pixels)
{
if (renderCamera == null) return 0.01f;
Vector3 worldPoint = transform.TransformPoint(localPoint);
float unitsPerPixel;
if (renderCamera.orthographic)
{
unitsPerPixel = (2f * renderCamera.orthographicSize) / Screen.height;
}
else
{
float dist = Vector3.Dot(worldPoint - renderCamera.transform.position, renderCamera.transform.forward);
dist = Mathf.Max(0.01f, dist);
float worldHeight = 2f * dist * Mathf.Tan(renderCamera.fieldOfView * 0.5f * Mathf.Deg2Rad);
unitsPerPixel = worldHeight / Screen.height;
}
float w = pixels * unitsPerPixel;
if (maxWorldWidth < minWorldWidth) maxWorldWidth = minWorldWidth;
return Mathf.Clamp(w, minWorldWidth, maxWorldWidth);
}
private void EnsureLines()
{
edgeLines.Clear();
for (int i = 0; i < 12; i++)
{
Transform t = transform.Find("Edge_" + i);
LineRenderer lr;
if (t != null)
{
lr = t.GetComponent<LineRenderer>();
if (lr == null) lr = t.gameObject.AddComponent<LineRenderer>();
}
else
{
GameObject child = new GameObject("Edge_" + i);
child.transform.SetParent(transform, false);
lr = child.AddComponent<LineRenderer>();
}
SetupLine(lr);
edgeLines.Add(lr);
}
CleanupExtraEdges();
}
private void CleanupExtraEdges()
{
var keep = new HashSet<Transform>();
for (int i = 0; i < edgeLines.Count; i++)
if (edgeLines[i] != null) keep.Add(edgeLines[i].transform);
var toDelete = new List<GameObject>();
for (int i = 0; i < transform.childCount; i++)
{
Transform c = transform.GetChild(i);
if (!c.name.StartsWith("Edge_")) continue;
if (!keep.Contains(c)) toDelete.Add(c.gameObject);
}
for (int i = 0; i < toDelete.Count; i++)
Destroy(toDelete[i]);
}
private void SetupLine(LineRenderer lr)
{
lr.useWorldSpace = false;
lr.loop = false;
lr.positionCount = 2;
lr.numCapVertices = 0;
lr.numCornerVertices = 0;
if (lr.sharedMaterial == null)
lr.sharedMaterial = new Material(Shader.Find("Sprites/Default"));
}
private void SetLinesActive(bool active)
{
for (int i = 0; i < edgeLines.Count; i++)
{
if (edgeLines[i] != null && edgeLines[i].gameObject.activeSelf != active)
edgeLines[i].gameObject.SetActive(active);
}
}
private bool TryGetLocalBounds(Transform root, out Bounds localBounds)
{
bool hasAny = false;
localBounds = new Bounds(Vector3.zero, Vector3.zero);
Renderer[] renderers = root.GetComponentsInChildren<Renderer>(true); // 先拿全量,后面手动过滤
Matrix4x4 worldToLocal = root.worldToLocalMatrix;
foreach (Renderer r in renderers)
{
// 排除我们自己绘制的线条
if (r.transform.IsChildOf(transform) && r.GetComponent<LineRenderer>() != null) continue;
// 跳过禁用的Renderer组件
if (!r.enabled) continue;
// includeInactive=false 时,不计算未激活对象
if (!includeInactive && !r.gameObject.activeInHierarchy) continue;
Bounds wb = r.bounds;
Vector3 c = wb.center;
Vector3 e = wb.extents;
Vector3[] ws = new Vector3[8];
ws[0] = c + new Vector3(-e.x, -e.y, -e.z);
ws[1] = c + new Vector3(e.x, -e.y, -e.z);
ws[2] = c + new Vector3(e.x, -e.y, e.z);
ws[3] = c + new Vector3(-e.x, -e.y, e.z);
ws[4] = c + new Vector3(-e.x, e.y, -e.z);
ws[5] = c + new Vector3(e.x, e.y, -e.z);
ws[6] = c + new Vector3(e.x, e.y, e.z);
ws[7] = c + new Vector3(-e.x, e.y, e.z);
for (int i = 0; i < 8; i++)
{
Vector3 p = worldToLocal.MultiplyPoint3x4(ws[i]);
if (!hasAny)
{
localBounds = new Bounds(p, Vector3.zero);
hasAny = true;
}
else
{
localBounds.Encapsulate(p);
}
}
}
return hasAny;
}
private Vector3[] GetCorners(Bounds b)
{
Vector3 c = b.center;
Vector3 e = b.extents;
return new Vector3[]
{
c + new Vector3(-e.x, -e.y, -e.z),
c + new Vector3( e.x, -e.y, -e.z),
c + new Vector3( e.x, -e.y, e.z),
c + new Vector3(-e.x, -e.y, e.z),
c + new Vector3(-e.x, e.y, -e.z),
c + new Vector3( e.x, e.y, -e.z),
c + new Vector3( e.x, e.y, e.z),
c + new Vector3(-e.x, e.y, e.z)
};
}
}
使用教程(超简单)
1. 挂载脚本
将脚本挂载到需要显示包围盒的物体(或空物体)上。
2. 参数配置
面板参数一目了然,按需调整即可:
- Target Root:目标根节点,为空则使用自身,会自动计算所有子物体的总包围盒
- Line Color:线条颜色,默认蓝色
- Include Inactive:是否包含未激活的子物体
- Show Lines:运行时是否显示包围盒
- Render Camera:渲染相机,自动绑定主相机
- Line Width Pixels:线条屏幕像素宽度(固定宽度核心参数)
- Min/Max World Width:限制世界空间线宽范围
3. 运行测试
点击 Unity 运行按钮,勾选Show Lines,即可看到物体的包围盒实时显示!
4. 代码调用(进阶)
支持外部脚本动态控制,无需手动操作面板:
cs
// 获取组件
DrawObjectBoundsWire boundsDrawer = GetComponent<DrawObjectBoundsWire>();
// 显示包围盒
boundsDrawer.ShowLines();
// 隐藏包围盒
boundsDrawer.HideLines();
// 切换显示状态
boundsDrawer.ToggleLines();
核心原理讲解
1. 包围盒计算
- 通过
GetComponentsInChildren<Renderer>()获取所有子物体渲染器 - 遍历每个渲染器的世界包围盒,转换为根节点本地坐标
- 使用
Bounds.Encapsulate()合并所有顶点,得到总包围盒
2. 屏幕固定线宽
这是工具的核心亮点:
- 正交相机 :根据相机正交尺寸和屏幕高度计算
单位/像素比例 - 透视相机:根据物体到相机的距离、相机 FOV 计算世界高度,再换算比例
- 最终将屏幕像素宽度转换为世界宽度,保证线条视觉大小一致
3. 性能优化
- 预创建 12 个 LineRenderer 子物体,运行时仅更新位置,无频繁创建销毁
- 无 GC alloc,适合长时间运行
- 自动清理多余物体,避免场景冗余
适用场景
- 游戏调试:查看模型实际碰撞范围、模型尺寸
- 编辑器工具:运行时可视化编辑场景物体布局
- 教学演示:直观展示 Unity 包围盒原理
- AR/VR 开发:实时查看虚拟物体边界