Unity 二叉树装箱算法

在这里插入图片描述
开发中碰到的需求:在滚动视图中添加不同尺寸的图片,并尽量保持紧密排列,如上图所示
在Asset Store中发现插件 Optimized ScrollView Adapter 已经实现了这个功能,这里截取其中的关键代码做演示

原理

插件使用了二叉树装箱算法(Binary Tree Bin Packing Algorithm)
在这里插入图片描述
当我们在整个区域内添加一个黄色方块后,可以把整个区域分成3个矩形:黄色矩形,矩形A,矩形B
这里有两种划分方法,一种是矩形A与黄色方块等高,一种是矩形B与黄色方块等宽,如图所示,在代码中设计一些规则来决定使用哪种划分方式
在这里插入图片描述
然后就可以构建二叉树,记录整个空间的划分,根节点Root记录第一个黄色方块的位置,大小等信息,它的两个节点分别记录右侧区域和下面区域的信息,当添加第二个方块时,检查A,B那个区域能放下,如果放在区域A下,则按照上面的方式再次划分,区域A记录第二个方块位置,大小信息,它的两个子节点记录两个新划分的区域,这样递归下去

下面是这部分算法的代码

using System;
using System.Collections.Generic;

/// <summary>
/// Heavily modified version of https://github.com/cariquitanmac/2D-Bin-Pack-Binary-Search,
/// which is a C# implementation of Jakes Gordon Binary Tree Algorithm for 2D Bin Packing https://github.com/jakesgordon/bin-packing/
/// <para>All rights go to the original author.</para>
/// </summary>
public class Packer2DBox
{
	List<Box> _Boxes;
	Node _RootNode;
	double _Spacing;
	bool _AlternatingStrategyBiggerRightNode;
	bool _AlternatingOtherStrategyBiggerRightNode;
	double _ContainerWidth;
	double _ContainerHeight;
	// 分割节点时的策略
	NodeChoosingStrategy _ChoosingStrategy;

	public Packer2DBox(double containerWidth, double containerHeight, double spacing)
	{
		this._ContainerWidth = containerWidth;
		this._ContainerHeight = containerHeight;
		this._Spacing = spacing;
	}

	public void Pack(List<Box> boxes, bool sort, NodeChoosingStrategy choosingStrategy, out double totalWidth, out double totalHeight)
	{
		_ChoosingStrategy = choosingStrategy;

		_AlternatingStrategyBiggerRightNode = _ChoosingStrategy == NodeChoosingStrategy.ALTERNATING_START_WITH_RIGHT;

		_RootNode = new Node(0d, 0d) { height = _ContainerHeight, width = _ContainerWidth };
		_Boxes = boxes;
		if (sort)
		{
			// Biggest boxes first with maxside, then secondarily by volume 
			// More info: https://codeincomplete.com/posts/bin-packing/
			_Boxes.Sort((a, b) =>
			{
				var aMax = System.Math.Max(a.width, a.height);
				var bMax = System.Math.Max(b.width, b.height);

				if (aMax != bMax)
					return (int)(bMax - aMax);

				return (int)(b.volume - a.volume);
			});
		}

		totalWidth = 0f;
		totalHeight = 0f;
		foreach (var box in _Boxes)
		{
			Node node = null;
			FindNode(_RootNode, box.width, box.height, ref node);

			if (node != null)
			{
				// Split rectangles
				box.position = SplitNode(node, box.width, box.height);

				double width = box.position.x + box.width;
				if (width > totalWidth)
					totalWidth = width;

				double height = box.position.y + box.height;
				if (height > totalHeight)
					totalHeight = height;
			}
		}
	}

	void FindNode(Node rootNode, double boxWidth, double boxHeight, ref Node node)
	{
		if (rootNode.isOccupied)
		{
			FindNode(rootNode.rightNode, boxWidth, boxHeight, ref node);
			FindNode(rootNode.bottomNode, boxWidth, boxHeight, ref node);
		}
		else if (boxWidth <= rootNode.width && boxHeight <= rootNode.height)
		{
			 if (node == null || rootNode.distFromOrigin < node.distFromOrigin)
				node = rootNode;
		}
	}

	/// <summary>
	/// 分割节点,有两种分割方法,按策略选择
	/// 方法1.右侧节点与填入的box一样高
	/// 方法2.下面节点与填入的box一样宽
	/// </summary>
	Node SplitNode(Node node, double boxWidth, double boxHeight)
	{
		node.isOccupied = true;

		double rightNodeFullWidth = node.width - (boxWidth + _Spacing);
		double rightNodeFullHeight = node.height;

		double bottomNodeFullWidth = node.width;
		double bottomNodeFullHeight = node.height - (boxHeight + _Spacing);

		bool biggerRightNode;

		var localStrategy = _ChoosingStrategy;
		if (localStrategy == NodeChoosingStrategy.MAX_VOLUME)
		{
			// 比较box面积,如果分割后右侧节点的面积更大,就按照方法1分割
			double rightVolume = rightNodeFullWidth * rightNodeFullHeight;
			double bottomVolume = bottomNodeFullWidth * bottomNodeFullHeight;

			if (rightVolume == bottomVolume)
			{
				// In case of equality, alternate between what we chose the last time
				biggerRightNode = _AlternatingOtherStrategyBiggerRightNode;
				_AlternatingOtherStrategyBiggerRightNode = !_AlternatingOtherStrategyBiggerRightNode;
			}
			else
			{
				biggerRightNode = rightVolume > bottomVolume;
			}
		}
		else if (localStrategy == NodeChoosingStrategy.MAX_SIDE)
		{
			// 比较两个节点的最大边长,边长大的优先
			double rightMaxSide = Math.Max(rightNodeFullWidth, rightNodeFullHeight);
			double bottomMaxSide = Math.Max(bottomNodeFullWidth, bottomNodeFullHeight);

			if (rightMaxSide == bottomMaxSide)
			{
				// In case of equality, alternate between what we chose the last time
				biggerRightNode = _AlternatingOtherStrategyBiggerRightNode;
				_AlternatingOtherStrategyBiggerRightNode = !_AlternatingOtherStrategyBiggerRightNode;
			}
			else
			{
				biggerRightNode = rightMaxSide > bottomMaxSide;
			}
		}
		else if (localStrategy == NodeChoosingStrategy.MAX_SUM)
		{
			// 比较两个节点的边长和,结果大的优先
			double rightSum = rightNodeFullWidth + rightNodeFullHeight;
			double bottomSum = bottomNodeFullWidth + bottomNodeFullHeight;

			if (rightSum == bottomSum)
			{
				// In case of equality, alternate between what we chose the last time
				biggerRightNode = _AlternatingOtherStrategyBiggerRightNode;
				_AlternatingOtherStrategyBiggerRightNode = !_AlternatingOtherStrategyBiggerRightNode;
			}
			else
			{
				biggerRightNode = rightSum > bottomSum;
			}
		}
		else
		{
			if (_ChoosingStrategy == NodeChoosingStrategy.RIGHT)
				biggerRightNode = true;
			else if (_ChoosingStrategy == NodeChoosingStrategy.BOTTOM)
				biggerRightNode = false;
			else
			{
				// Alternating
				biggerRightNode = _AlternatingStrategyBiggerRightNode;
				_AlternatingStrategyBiggerRightNode = !_AlternatingStrategyBiggerRightNode;
			}
		}

		node.rightNode = new Node(node.x + (boxWidth + _Spacing), node.y)
		{
			depth = node.depth + 1,
			width = rightNodeFullWidth,
			height = biggerRightNode ? node.height : boxHeight
		};
		node.bottomNode = new Node(node.x, node.y + (boxHeight + _Spacing))
		{
			depth = node.depth + 1,
			width = biggerRightNode ? boxWidth : node.width,
			height = bottomNodeFullHeight
		};

		return node;
	}
	
	public class Node
	{
		public int depth;
		public Node rightNode;
		public Node bottomNode;
		// 节点左上角坐标
		public double x;
		public double y;
		public double width;
		public double height;
		readonly public double distFromOrigin;
		// 节点被占用,也就是被分割了
		public bool isOccupied;
		
		public Node(double x, double y)
		{
			this.x = x;
			this.y = y;
			distFromOrigin = Math.Sqrt(x * x + y * y);
		}
	}
	
	public class Box
	{
		public double height;
		public double width;
		public double volume;
		public Node position;
		
		public Box(double width, double height)
		{
			this.width = width;
			this.height = height;
			volume = width * height;
		}
	}
	
	/// <summary>Note expanding choices, in order of success rate</summary>
	public enum NodeChoosingStrategy
	{
		MAX_VOLUME,
		MAX_SUM,
		MAX_SIDE,
		ALTERNATING_START_WITH_BOTTOM,
		RIGHT,
		BOTTOM,
		ALTERNATING_START_WITH_RIGHT, // same as BOTTOM, in 99% of cases
		COUNT_
	}
}

作者设计了几种划分空间的策略,如根据区域面积,边长和,最大边长等

还需要一个布局脚本管理所有的子节点

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// Layout class to arrange children elements in a circular grid format. Circular in the . Items will try to occupy as much space as possible.
/// </summary>
public class PackedGridLayoutGroup : LayoutGroup
{
	[SerializeField] protected float m_ForcedSpacing = 0f;

	[Tooltip("The specified axis will have the 'Preferred' size set based on children")]
	[SerializeField] protected AxisOrNone m_childrenControlSize = AxisOrNone.Vertical;

	/// <summary>
	/// 执行每个策略算一个pass,然后对比不同策略的空间填充率,填充率要求不高设置为1就行
	/// </summary>
	[Tooltip("Set to as many as possible, if the FPS allows")]
	[Range(1, (int)Packer2DBox.NodeChoosingStrategy.COUNT_)]
	[SerializeField] protected int m_numPasses = (int)Packer2DBox.NodeChoosingStrategy.COUNT_;

	/// <summary>
	/// The spacing to use between layout elements in the grid on both axes. 
	/// The spacing is created by shrinking the childrens' sizes rather than actually adding spaces.
	/// If you want true spacing, consider modifying the children themselves to also include some padding inside them
	/// </summary>
	public float ForcedSpacing { get { return m_ForcedSpacing; } set { SetProperty(ref m_ForcedSpacing, value); } }

	/// <summary>
	/// The specified axis will have the 'Preferred' size set based on children
	/// </summary>
	public AxisOrNone ChildrenControlSize { get { return m_childrenControlSize; } set { SetProperty(ref m_childrenControlSize, value); } }

	protected PackedGridLayoutGroup() { }

#if UNITY_EDITOR
	protected override void OnValidate()
	{
		base.OnValidate();
	}
#endif
	
	public override void CalculateLayoutInputHorizontal()
	{
		base.CalculateLayoutInputHorizontal();

		float minWidthToSet;
		float preferredWidthToSet;
		if (m_childrenControlSize == AxisOrNone.Horizontal)
		{
			float width, _;
			GetChildSetups(out width, out _);

			minWidthToSet = preferredWidthToSet = width + padding.horizontal;
		}
		else
		{
			minWidthToSet = minWidth;
			preferredWidthToSet = preferredWidth;
		}

		SetLayoutInputForAxis(minWidthToSet, preferredWidthToSet, -1, 0);
	}

	public override void CalculateLayoutInputVertical()
	{
		float minHeightToSet;
		float preferredHeightToSet;
		if (m_childrenControlSize == AxisOrNone.Vertical)
		{
			float _, height;
			GetChildSetups(out _, out height);

			minHeightToSet = preferredHeightToSet = height + padding.vertical;
		}
		else
		{
			minHeightToSet = minHeight;
			preferredHeightToSet = preferredHeight;
		}

		SetLayoutInputForAxis(minHeightToSet, preferredHeightToSet, -1, 1);
	}

	public override void SetLayoutHorizontal()
	{
		LayoutChildren(true, false);
	}

	public override void SetLayoutVertical()
	{
		LayoutChildren(false, true);
	}
	
	void GetInsetAndEdges(float chWidth, float chHeight, out RectTransform.Edge xInsetEdge, out RectTransform.Edge yInsetEdge, out float xAddInset, out float yAddInset)
	{
		var gridRect = rectTransform.rect;

		xInsetEdge = RectTransform.Edge.Left;
		yInsetEdge = RectTransform.Edge.Top;
		xAddInset = 0f;
		yAddInset = 0f;
		if (m_childrenControlSize != AxisOrNone.Horizontal)
		{
			switch (childAlignment)
			{
				case TextAnchor.UpperLeft:
				case TextAnchor.LowerLeft:
				case TextAnchor.MiddleLeft:
					xInsetEdge = RectTransform.Edge.Left;
					xAddInset = padding.left;
					break;

				case TextAnchor.UpperRight:
				case TextAnchor.LowerRight:
				case TextAnchor.MiddleRight:
					xInsetEdge = RectTransform.Edge.Right;
					xAddInset = padding.right;
					break;

				case TextAnchor.UpperCenter:
				case TextAnchor.MiddleCenter:
				case TextAnchor.LowerCenter:
					xAddInset = Mathf.Max((gridRect.width - chWidth), padding.horizontal) / 2f;
					break;
			}
		}

		if (m_childrenControlSize != AxisOrNone.Vertical)
		{
			switch (childAlignment)
			{
				case TextAnchor.UpperLeft:
				case TextAnchor.UpperCenter:
				case TextAnchor.UpperRight:
					yInsetEdge = RectTransform.Edge.Top;
					yAddInset = padding.top;
					break;

				case TextAnchor.LowerLeft:
				case TextAnchor.LowerCenter:
				case TextAnchor.LowerRight:
					yInsetEdge = RectTransform.Edge.Bottom;
					yAddInset = padding.bottom;
					break;

				case TextAnchor.MiddleLeft:
				case TextAnchor.MiddleCenter:
				case TextAnchor.MiddleRight:
					yAddInset = Mathf.Max((gridRect.height - chHeight), padding.vertical) / 2f;
					break;
			}
		}
	}

	void LayoutChildren(bool hor, bool vert)
	{
		float chWidth, chHeight;
		var setups = GetChildSetups(out chWidth, out chHeight);

		RectTransform.Edge xInsetEdge, yInsetEdge;
		float xAddInset, yAddInset;
		GetInsetAndEdges(chWidth, chHeight, out xInsetEdge, out yInsetEdge, out xAddInset, out yAddInset);

		foreach (var child in setups)
		{
			var c = child as ChildSetup;
			if (c.box.position == null)
				continue;

			if (hor)
				c.child.SetInsetAndSizeFromParentEdge(xInsetEdge, (float)c.box.position.x + xAddInset, (float)c.box.width - ForcedSpacing);

			if (vert)
				c.child.SetInsetAndSizeFromParentEdge(yInsetEdge, (float)c.box.position.y + yAddInset, (float)c.box.height - ForcedSpacing);
		}
	}

	List<ChildSetup> GetChildSetups(out float width, out float height)
	{
		var list = new List<ChildSetup>(rectChildren.Count);
		foreach (var child in rectChildren)
		{
			float chWidth = LayoutUtility.GetPreferredSize(child, 0);
			float chHeight = LayoutUtility.GetPreferredSize(child, 1);
			list.Add(new ChildSetup(chWidth, chHeight, child));
		}

		float availableWidth, availableHeight;
		if (m_childrenControlSize == AxisOrNone.Horizontal)
		{
			availableWidth = float.MaxValue;
			availableHeight = rectTransform.rect.height - padding.vertical;
		}
		else if (m_childrenControlSize == AxisOrNone.Vertical)
		{
			availableWidth = rectTransform.rect.width - padding.horizontal;
			availableHeight = float.MaxValue;
		}
		else
		{
			availableHeight = rectTransform.rect.height - padding.vertical;
			availableWidth = rectTransform.rect.width - padding.horizontal;
		}
		
		// Spacing usually creates mode big empty spaces where no item can fit
		float spacingToUse = 0f;
		var packer = new Packer2DBox(availableWidth, availableHeight, spacingToUse);

		int maxStrategiesToUse = m_numPasses;
		List<Packer2DBox.Box>[] boxesPerStrategy = new List<Packer2DBox.Box>[maxStrategiesToUse];
		int[] nullPositionsPerStrategy = new int[maxStrategiesToUse];
		double[] totalWidthsPerStrategy = new double[maxStrategiesToUse];
		double[] totalHeightsPerStrategy = new double[maxStrategiesToUse];

		int iBest = -1;
		bool copyBoxesForFinalResult = maxStrategiesToUse > 1;
		for (int i = 0; i < maxStrategiesToUse; i++)
		{
			var boxesThisPass = i == 0 ? list.ConvertAll(c => c.box) : list.ConvertAll(c => { c.ReinitBox(); return c.box; });
			double totalWidthThisPass;
			double totalHeightThisPass;
			packer.Pack(boxesThisPass, false, (Packer2DBox.NodeChoosingStrategy)i, out totalWidthThisPass, out totalHeightThisPass);
			int thisPassNullPositions = 0;
			for (int j = 0; j < boxesThisPass.Count; j++)
			{
				var b = boxesThisPass[j];
				if (b.position == null)
					++thisPassNullPositions;
			}

			boxesPerStrategy[i] = boxesThisPass;
			nullPositionsPerStrategy[i] = thisPassNullPositions;
			totalWidthsPerStrategy[i] = totalWidthThisPass;
			totalHeightsPerStrategy[i] = totalHeightThisPass;

			if (iBest == -1)
			{
				iBest = i;

				if (thisPassNullPositions == 0)
				{
					// Boxes won't be overridden by next strategies, so no need to copy them
					copyBoxesForFinalResult = false;

					// First pass is Packer2DBox.NodeChoosingStrategy.MAX_VOLUME and all boxes were fit => there's no better strategy
					break;
				}

				continue;
			}

			if (thisPassNullPositions < nullPositionsPerStrategy[iBest])
			{
				iBest = i;
				continue;
			}

			if (thisPassNullPositions > nullPositionsPerStrategy[iBest])
				continue;

			if (totalWidthThisPass * totalHeightThisPass < totalWidthsPerStrategy[i] * totalHeightsPerStrategy[i])
			{
				iBest = i;
				continue;
			}
		}

		if (copyBoxesForFinalResult)
		{
			var bestBoxes = boxesPerStrategy[iBest];
			for (int i = 0; i < list.Count; i++)
				list[i].box = bestBoxes[i];
		}

		width = (float)totalWidthsPerStrategy[iBest];
		height = (float)totalHeightsPerStrategy[iBest];

		return list;
	}
	
	public enum AxisOrNone
	{
		Horizontal = RectTransform.Axis.Horizontal,
		Vertical = RectTransform.Axis.Vertical,
		None
	}
	
	class ChildSetup
	{
		public RectTransform child;
		public Packer2DBox.Box box;
		
		public ChildSetup(double width, double height, RectTransform child)
		{
			this.child = child;

			box = new Packer2DBox.Box(width, height);
		}
		
		public void ReinitBox() { box = new Packer2DBox.Box(box.width, box.height); }
	}
}

实现

在这里插入图片描述
新建一个Scroll View,在Content节点上添加布局脚本,注意Content的区域大小需要根据所有子节点占用宽高做调整,布局脚本PackedGridLayoutGroup 中的 GetChildSetups 方法可以计算填充后所有子对象占用的高度和宽度
Forced Spacing 控制子对象的间距,它是通过缩小子对象的宽高实现的
Num Passes 执行每个分割策略算一个pass,然后对比不同策略的空间填充率,填充率要求不高设置为1就行

在这里插入图片描述
Image作为模板,添加Layout Element组件,使用Preferred Width和Preferred Height控制宽高

using UnityEngine;
using UnityEngine.UI;
using Random = UnityEngine.Random;

public class PackDemo : MonoBehaviour
{
    public Image template;
    // Scroll View 下的content节点
    public Transform content;
    // 生成数量
    public int generateCount;

    private int lastCount;
    
    void Start()
    {
        lastCount = generateCount;
        GenerateCell();
    }

    void Update()
    {
        if (generateCount != lastCount)
        {
            lastCount = generateCount;
            GenerateCell();
        }
    }

    private void GenerateCell()
    {
        for (int i = 0; i < content.childCount; ++i)
        {
            var tf = content.GetChild(i);
            DestroyImmediate(tf.gameObject);
        }
        
        for (int i = 0; i < generateCount; ++i)
        {
            var go = Instantiate(template, content);
            go.gameObject.SetActive(true);
            go.color = new Color(Random.Range(0, 1f), Random.Range(0, 1f), Random.Range(0, 1f));
            var layout = go.GetComponent<LayoutElement>();
            layout.preferredWidth = Random.Range(1, 5) * 100;
            layout.preferredHeight = Random.Range(1, 5) * 100;
        }
    }
}

这里写了一个简单的脚本进行测试,会在Content节点下生成一定数量的Image,并随机颜色和大小

运行效果
在这里插入图片描述

优化

在脚本 PackedGridLayoutGroup 的 GetChildSetups 方法中会比较不同分割策略并选出最好的一个策略,但是一般情况下使用第一个面积分割策略就可以了,这里简化了源码来减少new对象

	public List<ChildSetup> GetChildSetups(out float width, out float height)
	{
		var list = new List<ChildSetup>(rectChildren.Count);
		foreach (var child in rectChildren)
		{
			float chWidth = LayoutUtility.GetPreferredSize(child, 0);
			float chHeight = LayoutUtility.GetPreferredSize(child, 1);
			list.Add(new ChildSetup(chWidth, chHeight, child));
		}

		float availableWidth, availableHeight;
		if (m_childrenControlSize == AxisOrNone.Horizontal)
		{
			availableWidth = float.MaxValue;
			availableHeight = rectTransform.rect.height - padding.vertical;
		}
		else if (m_childrenControlSize == AxisOrNone.Vertical)
		{
			availableWidth = rectTransform.rect.width - padding.horizontal;
			availableHeight = float.MaxValue;
		}
		else
		{
			availableHeight = rectTransform.rect.height - padding.vertical;
			availableWidth = rectTransform.rect.width - padding.horizontal;
		}
		
		// Spacing usually creates mode big empty spaces where no item can fit
		float spacingToUse = 0f;
		var packer = new Packer2DBox(availableWidth, availableHeight, spacingToUse);
		
		# region 修改部分,这里简化源码,只使用 MAX_VOLUME 策略,减少new对象
		var boxesThisPass = list.ConvertAll(c => c.box);
		packer.Pack(boxesThisPass, false, Packer2DBox.NodeChoosingStrategy.MAX_VOLUME, out var totalWidthThisPass, out var totalHeightThisPass);
		width = (float)totalWidthThisPass;
		height = (float)totalHeightThisPass;
		#endregion

		return list;
	}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值