Unity 实用工具:动态绘制物体边界包围盒(支持屏幕固定线宽)

前言

在 Unity 开发中,我们经常需要可视化查看模型的边界包围盒(Bounds) ,用于调试碰撞、模型尺寸、场景布局等场景。原生的 Gizmos 只能在编辑器中显示,运行时无法看到;而手动绘制线条又会遇到线宽随距离缩放、适配相机正交 / 透视模式等问题。

今天给大家分享一个运行时可动态显示、支持屏幕固定线宽、自动适配所有子物体的包围盒绘制工具,开箱即用,完美解决上述痛点!

工具核心特性

  1. 运行时动态显示:支持运行时开关包围盒显示,不依赖编辑器 Gizmos
  2. 屏幕固定线宽:无论物体远近,线条在屏幕上的宽度始终一致,视觉更舒适
  3. 自动适配子物体:自动计算根节点下所有子物体的总包围盒
  4. 双相机兼容:同时支持正交相机(2D)和透视相机(3D)
  5. 可配置样式:自定义线条颜色、最小 / 最大世界宽度、是否包含隐藏物体
  6. 性能优化:预创建 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,适合长时间运行
  • 自动清理多余物体,避免场景冗余

适用场景

  1. 游戏调试:查看模型实际碰撞范围、模型尺寸
  2. 编辑器工具:运行时可视化编辑场景物体布局
  3. 教学演示:直观展示 Unity 包围盒原理
  4. AR/VR 开发:实时查看虚拟物体边界
相关推荐
张老师带你学2 小时前
Unity 食物 农产品相关
科技·游戏·unity·游戏引擎·模型
mxwin2 小时前
Unity Custom Interpolators与半透明阴影的原理与实战
unity·游戏引擎·shader
晴夏。2 小时前
UE5第三人称模板实现及相关引擎源码分析
unity·ue5·游戏引擎·ue
HAPPY酷2 小时前
解决 Unreal Engine 编译报错 MSB4018:三个核心排查方向
游戏引擎·虚幻
晴夏。6 小时前
UE原生MovementBase实现分析
游戏引擎·ue·3c
天人合一peng7 小时前
Unity工程发布hololens需安装, MRTK安装
unity·游戏引擎·hololens
weixin_409383128 小时前
godot 调用class方法得用实例 不能用脚本引用
游戏引擎·godot
风酥糖8 小时前
Godot游戏练习01-第32节-国际化
游戏·游戏引擎·godot
魔士于安9 小时前
Unity类似博物馆场景
前端·unity·游戏引擎·贴图·模型