简述A*的实现以及优化方向:(已经清楚这一部分的同学可以跳过)
A*算法是一种启发式算法,它是一种根据已知信息去合理的,有规律的猜测下一步,直至构建完整的路径(或者是遇见死路强制结束算法)
A*算法是怎么出现的
当我们把世界抽象成一个二维网格,这样,世界就成为了N*N个点,起点和终点也抽象为了这个二维网格上的某两个点
接下来将所有的障碍设置成红色的点(不可通过的),所有的可行点设置为白色的点(可通过),一个寻找最短路径的算法题就抽象出来了。(中间蓝色的点为中心,方便确立中心位置)

(此时只需要看红白点)
假设我们是一个笨蛋,不清楚任何的算法,只知道白色的点可行,红色的点不可行
那么最初,我们在起点的时候,会有8个点可以去检查是否可行(越界全当不可通过的红点处理),检查方式为8邻域是否是白点,如果是白点,那么我们就有可能会选择它,所以我们把它加入列表(开启列表)作为备选;对于红点,我们无法到达,所以直接不看。
那么剩下几个可行的点如何选择呢?我们的目的是最短路径,此时我们可以把这个最短路径(f = g + h)划分为实际走过的(当前点到起点) + 可能要走的(预估的,即当前点到终点),我们把这个路程称为“代价”。
实际走过的部分,对于四邻域,代价为 1 * 两水平(垂直)相邻点距离,对于对角邻域,代价为 1.4 * 两水平(垂直)相邻点距离(注:此处的1.4为四舍五入后的根号2),这一部分的代价是明确确定的,没有异议的,剩下一部分为预估的,因为我们没有确凿的证据保证我们能准确计算出剩下的代价,于是我们可以用两种“猜”的方式:欧式距离,曼哈顿距离。为什么我们可以去猜?
我们“乐观”的认为未来的路线是直线(欧式距离)或者是前后左右水平的线(曼哈顿距离),但实际的路径代价大概率是比我们想象的要“悲观”(因为会有弯路),所以它可以保证不破坏我们未来路径的最优性——“乐观”的路径如果代价都很大,那么这条路径实际代价会更大;“乐观”的路径如果代价很小,那么它就有可能真实的代价也很小,所以这个合理的猜测可以作为我们的预估代价依据
想了这么多,目前我们一步也没走,但我们通过f = g + h计算出了我们8邻域可行点各自的代价,依照目的,我们应该往代价最小的白点走一步。因为我们选择了这个点,所以要把它从开启列表(候选点列表)中移除并加入关闭列表(已走点列表)——为了返回我们的最短路径,我们是要记录我们走过的点的,因此我们想到了把走过的点加入到关闭列表里
很好我们已经走出第一步了,接下来类似的思路,但是我们要考虑的更多了,是否是已经走过的点,如果是,我们坚决不能采纳,这相当于绕回来了,与目的地的距离没变,代价反而变多了,所以是万万不可取的;是否是开启列表里的点,如果是,我们依旧不能采纳,因为如果走到这个点,这个点的预估代价没变,实际代价只增不减,所以不选——我们最初就能选,为什么要绕个大弯再去选它呢?
所以,我们把筛选后的可行点再次计算代价并加入开启列表(候选列表),接下来我们继续从开启列表选择代价(路径)最小的点,此时可能出现两种情况——1.代价最小的点在当前点的8邻域,2.代价最小的点在上一个或者上几个点的8邻域附近。有可能会有读者有疑问,这个时候怎么办,解决方法就是,依旧,选择代价最小的点,因为我们目的为找到最短路径,起点到a点再到终点的总代价比起点到b点再到终点小,就说明这个路径是更短的,我们没有理由不选择它,有读者追问,那么如何返回完整的最短路径列表?我们只需要在计算c点八邻域各个筛选后的点时加上父节点为c点时就可以了,在返回路径的时候不断递归链表就能找到路径。此时我们就可以不断循环这个步骤,直至找到一个点与终点重合,我们设置好终点的父节点后,就可以通过父节点的链表得到这个最优路径。当然,若开启列表为空时依旧没有找到终点,不用怀疑,当前是死路,我们无法到达终点。
A*算法的优化方向
从计算上:
ECS并行处理,将寻路任务分配到多个线程或使用 Unity 的 Job System(此处不做说明,待作者学习完毕后补充)
从空间结构上:
对于大型地图来说,存在大量空旷或连续障碍区域,如果我们依旧是采用二维数组去遍历八邻域的方式,未免效率低下,因此,为了加速查询,我们可以把二维空间切割成四个子空间,对于每个子空间,如果全为单一类型(可通过或不可通过中的一种)则无需划分,若为复合类型(同时拥有可通过和不可通过的点),则继续划分,直至完全划分或到达最大深度。

示例图(应为QuadTree,Oct为八叉树)

实机图
此时主播想说的是,理想很丰富,现实很骨感,在实际应用时,作者依旧是碰壁了很多次。具体请看下文A*算法优化代码四叉树部分
A*寻路的代码实现(基础版)
分为:绘制(编辑器阶段),制图(为A*管理器提供操作对象),寻点(将起点(Vector3)和终点(Vector3)贴合到离散的点上并找到对应的数据结构(节点类)),寻路,返回路径,移动。这几个部分
Node类:
[Serializable]
public class Node
{
//初始时固定的
public Vector3 worldPosition;
public bool walkable;
[NonSerialized] public List<Node> neighbors;
//每次使用a*时计算的
[NonSerialized] public Node parent;
public float gCost;
public float hCost;
public float fCost;
public Node(Vector3 _worldPos, bool _walkable)
{
worldPosition = _worldPos;
walkable = _walkable;
neighbors = new List<Node>();
}
}
初始化:
public void Init()
{
PathfindingGridVisualizer pathfindingGridVisualizer = GameObject.Find("Main Camera").GetComponent<PathfindingGridVisualizer>();
pathfindingGridVisualizer.UseWay(out List<List<Node>> np, out List<QuadTreeNode> le);
nodeMap = np;
leaves = le;
BuildIndex();
this.isUseQuadTree = pathfindingGridVisualizer.isUseQuadTree;
InitMap();
interval = (nodeMap[0][0].worldPosition - nodeMap[0][1].worldPosition).magnitude;
}
初始化地图函数:
//初始化地图
public void InitMap()
{
if(!isUseQuadTree)
{
openList.Clear();
closeList.Clear();
}
else
{
openListAboutQuadTree.Clear();
closeListAboutQuadTree.Clear();
}
}
绘制(编辑器阶段)
绘制脚本:
参数:网格大小,网格半径,检测半径(检测是否有障碍),地图初始坐标,各种格子绘制的颜色,检测的障碍物Layer层级
[Header("Grid Settings")]
public float gridSize = 1f; // 网格大小
public int gridRadius = 20; // 网格半径(单位:格子数)
public float checkRadius = 0.1f; // 检测半径
public Vector3 startPosition;
[Header("Visualization")]
public Color walkableColor = Color.white;
public Color obstacleColor = Color.red;
public Color centerColor = Color.cyan;
[Header("Layer Mask")]
public LayerMask obstacleMask; // 障碍物检测的层级
绘制函数:
private void OnDrawGizmos()
{
if (!Application.isEditor) return;
Vector3 center = startPosition;
// 绘制中心点(特殊颜色)
Gizmos.color = centerColor;
Gizmos.DrawSphere(center, checkRadius * 1.5f);
// 计算网格范围
int totalSize = gridRadius * 2 + 1;
for (int x = -gridRadius; x <= gridRadius; x++)
{
for (int z = -gridRadius; z <= gridRadius; z++)
{
Vector3 pos = center + new Vector3(x * gridSize, 0, z * gridSize);
// 检测障碍物
bool isObstacle = Physics.CheckSphere(pos, checkRadius, obstacleMask);
// 设置颜色
Gizmos.color = isObstacle ? obstacleColor : walkableColor;
// 绘制点
Gizmos.DrawSphere(pos, checkRadius);
// 可选:绘制网格线
}
}
}
示例图:

此时我们只是在编辑器页面有了可视化二维数组,但我们还没有真正应用到A*当中。
制图(为A*管理器提供操作对象)
// 获取可行走点列表(可用于实际寻路)
private List<List<Node>> GetWalkablePoints()
{
Vector3 center = startPosition;
int diameter = 2 * gridRadius + 1; // 网格的边长(如 gridRadius=2 -> 5x5)
List<List<Node>> points = new List<List<Node>>(diameter);
// 初始化每一行
for (int i = 0; i < diameter; i++)
{
points.Add(new List<Node>(diameter));
}
// 填充节点
for (int x = -gridRadius; x <= gridRadius; x++)
{
for (int z = -gridRadius; z <= gridRadius; z++)
{
Vector3 pos = center + new Vector3(x * gridSize, 0, z * gridSize);
bool isWalkable = !Physics.CheckSphere(pos, checkRadius, obstacleMask);
Node node = new Node(pos, isWalkable);
// 将负坐标转换为正索引(x + gridRadius)
int rowIndex = x + gridRadius;
//int colIndex = z + gridRadius;
points[rowIndex].Add(node);
}
}
return points;
}
寻点(将起点(Vector3)和终点(Vector3)贴合到离散的点上并找到对应的数据结构(节点类))
public List<Vector3> GetPathAboutAStar(Vector3 start,Vector3 end)
{
InitMap();
start.y = nodeMap[0][0].worldPosition.y;
end.y = nodeMap[0][0].worldPosition.y;
List<Vector3> path = new List<Vector3>();
Node s = null;
Node e = null;
for (int i = 0;i< nodeMap.Count; i++)
{
s ??= nodeMap[i].Find(p => Vector3.Distance(p.worldPosition, start) < 0.71 && p.walkable);//保证在点与点距离为1时百分百能找到贴合的点
e ??= nodeMap[i].Find(p => Vector3.Distance(p.worldPosition, end) < 0.71 && p.walkable);
if (s != null && e != null) break;
}
if (s == null)
{
Debug.Log("找不到贴合的点");
//检查点的间隔以及是否附近有可行的点
return null;
}
if (e == null)
{
Debug.Log("目标点脱离A*范围");
return null;
}
if(FindPath(s, e))
{
return GetFinalPath(s, e);
}
return null;
}
寻路(遍历邻域的方式为建立二维列表前用字典以Node为键,索引为值构建字典)
private bool FindPath(Node start, Node end)
{
openList.Add(start);//按代价大小排序,最小的在前
Node currentNode = null;
while (openList.Count > 0)
{
currentNode = openList[0];
openList.RemoveAt(0);
closeList.Add(currentNode);
//找到其相邻的可加入的节点加入开启列表
var (i, j) = _indexMap[currentNode.worldPosition];
for(int a = -1; a < 2; a++)
{
for(int b = -1;b < 2; b++)
{
if (a == 0 && b == 0)//排除自己
{
continue;
}
if(i + a < 0 || i + a >= nodeMap.Count || j + b < 0 || j + b >= nodeMap[0].Count)
{
continue;//越界点不要
}
//当前点
Node theNode = nodeMap[i + a][j + b];
if (!theNode.walkable)
{
continue;//阻挡点不要
}
if (openList.Find(p => p.worldPosition == theNode.worldPosition)!=null || closeList.Find(p => p.worldPosition == theNode.worldPosition) != null)
{
continue;//在开启列表和关闭列表中也不要
}
//设置父节点并计算寻路消耗
theNode.parent = currentNode;
float d = 1 * interval;
if (a != 0 && b != 0)
{
d = 1.4f * interval;
}
float g = theNode.parent.gCost + d;
float h1 = Mathf.Abs(theNode.worldPosition.x - end.worldPosition.x);
float h2 = Mathf.Abs(theNode.worldPosition.z - end.worldPosition.z);
float h = h1 + h2;
float f = g + h;
theNode.gCost = g;
theNode.hCost = h;
theNode.fCost = f;
//加入开启列表
openList.Add(theNode);
if (theNode.worldPosition == end.worldPosition)
{
end.parent = currentNode;
return true; // 找到路径
}
}
}
if (openList.Count == 0)
{
Debug.Log("此路不通");
return false;
}
//排序,f大的排在后面
openList.Sort((node1, node2) =>
{
return node1.fCost >= node2.fCost ? 1 : -1;
});
//把消耗最小的f的节点放入关闭列表并且移除
}
return true;
}
返回路径
public List<Vector3> GetFinalPath(Node start, Node end)
{
List<Vector3> path = new List<Vector3>();
Node currentNode = end;
while (currentNode != start)
{
path.Add(currentNode.worldPosition); // 将当前节点添加到路径列表
currentNode = currentNode.parent; // 回溯到父节点
}
path.Add(start.worldPosition); // 添加起点
path.Reverse(); // 将路径反转,确保从起点到终点
return path;
}
移动
绘制路径
void OnDrawGizmos()
{
if (nodes.Count < 2) return;
Gizmos.color = Color.blue;
for (int i = 0; i < nodes.Count - 1; i++)
{
Gizmos.DrawLine(nodes[i], nodes[i + 1]);
}
}
移动脚本(在HFSM追击状态追击函数内部)
// 判断是否已经到达终点
if (hFSMController.currentPointIndex < hFSMController.nodes.Count)
{
// 获取当前目标点
Vector3 targetPoint = hFSMController.nodes[hFSMController.currentPointIndex];
// 检查是否到达当前路径点(先检查再移动,否则会出现到达点时抽出一帧为翻转朝向的情况
while (hFSMController.nodes != null && hFSMController.nodes.Count > 0 && Vector3.Distance(characterObject.transform.position, targetPoint) < 0.5f)
{
hFSMController.currentPointIndex++;
hFSMController.currentPointIndex %= hFSMController.nodes.Count; // 确保索引在合法范围内
targetPoint = hFSMController.nodes[hFSMController.currentPointIndex];
}
MoveTowardsPoint(targetPoint);
private void MoveTowardsPoint(Vector3 targetPoint)
{
// 计算朝向目标点的方向
Vector3 direction = (targetPoint - characterObject.transform.position).normalized;
Vector3 velocity = direction * moveSpeed;
// 使用物理力推动角色
characterObject.GetComponent<Rigidbody>().linearVelocity = new Vector3(velocity.x, characterObject.GetComponent<Rigidbody>().linearVelocity.y, velocity.z);
}
A*寻路的四叉树优化(进阶版)
分为:绘制(编辑器阶段),制图(为A*管理器提供操作对象),寻点(将起点(Vector3)和终点(Vector3)贴合到离散的点上并找到对应的数据结构(节点类)),寻路,返回路径,移动。这几个部分,初始化见上文,此处不再赘述
QuadTreeNode类:
public class QuadTreeNode
{
public QuadTreeNode pathparent; //回溯寻找路径时的父节点
public QuadTreeNode parent; // 父节点
public Bounds bounds; // 节点边界(Unity的Bounds类)
public QuadTreeNode[] children; // 4个子节点
public bool isLeaf = false; // 是否为叶节点
public bool isWalkable; // 叶节点专用:是否可通过
public Vector3 center; // 区域中心坐标
public int depth; // 节点深度(根节点为0)
public List<QuadTreeNode> neighbors;
public float gCost;
public float hCost;
public float fCost;
public QuadTreeNode(QuadTreeNode parent,Bounds bounds, int depth)
{
this.parent = parent;
this.bounds = bounds;
this.center = bounds.center;
this.depth = depth;
}
}
绘制(编辑器阶段)
绘制脚本:参数:树,叶子结点列表,是否启用四叉树(在Inspector提供开关,可动态支持调整搜索方式为四叉树还是二维列表),当前是否启用了四叉树(检测用)
public QuadTreeNode root;
public List<QuadTreeNode> leaves = new List<QuadTreeNode>();
[Header("启用四叉树搜索(true)")]
public bool isUseQuadTree;
private bool currentUseQuadTree;
private void Awake()
{
currentUseQuadTree = isUseQuadTree;
}
private void OnDrawGizmos()
{
if (isUseQuadTree)
{
if (root != null)
{
DrawNode(root);
}
}
绘制脚本(部分,仅展示与四叉树相关)
private void DrawNode(QuadTreeNode node)
{
if (node == null) return;
// 设置颜色
if (node.isLeaf)
{
Gizmos.color = node.isWalkable ? Color.green : Color.red;
}
else
{
Gizmos.color = Color.yellow;
}
// 画出边界(线框立方体)
Gizmos.DrawWireCube(node.bounds.center, node.bounds.size);
// 如果有子节点,递归画
if (node.children != null)
{
foreach (var child in node.children)
{
DrawNode(child);
}
}
}
递归树并绘制叶子节点为红色(不可通过),绿色(可通过),非叶子节点为黄色

实机图
制图(为A*管理器提供操作对象)
/// <summary>
/// 初始化并获取二维列表和树的叶子结点列表
/// </summary>
/// <param name="nodes"></param>
/// <param name="quadTreeNodes"></param>
public void UseWay(out List<List<Node>> nodes,out List<QuadTreeNode> quadTreeNodes)
{
nodes = null;
quadTreeNodes = null;
root = BuildQuadTree(root, new Bounds(startPosition, new Vector3(2 * gridRadius + 1, 1, 2 * gridRadius + 1)), 5);
if (root != null)
{
leaves.Clear();
CollectLeaves(root);//提取出所有叶子结点
BuildNeighbors(leaves);//构建邻接点
quadTreeNodes = leaves;
}
nodes = GetWalkablePoints();
if(quadTreeNodes == null)
{
Debug.LogWarning("树构建失败");
}
if(nodes == null)
{
Debug.LogWarning("二维列表构建失败");
}
}
制图阶段相较二维数组更加复杂,首先要构建树,然后要提取出所有叶子结点(我们操作的部分就是叶子结点,和非叶子结点无关),最后为叶子结点构建邻接表用于a*寻路时遍历相邻元素
递归建树:
private QuadTreeNode BuildQuadTree(QuadTreeNode parent,Bounds bounds, int maxDepth, int depth = 0)
{
QuadTreeNode node = new QuadTreeNode(parent,bounds, depth);
// 判断该区域是否完全可行走
bool allWalkable = CheckAreaWalkable(bounds, out bool allBlocked);
if (allWalkable || allBlocked || depth >= maxDepth)
{
node.isLeaf = true;
node.isWalkable = allWalkable;
return node;
}
// 否则细分四个子节点
node.children = new QuadTreeNode[4];
Vector3 childSize = bounds.size / 2f; // 子区域大小
Vector3 half = childSize / 2f; // 子区域一半尺寸
Vector3 center = bounds.center;
// 四个象限(在 XZ 平面)
node.children[0] = BuildQuadTree(node, new Bounds(center + new Vector3(-half.x, 0, half.z), childSize), maxDepth, depth + 1); // 西北
node.children[1] = BuildQuadTree(node, new Bounds(center + new Vector3(half.x, 0, half.z), childSize), maxDepth, depth + 1); // 东北
node.children[2] = BuildQuadTree(node, new Bounds(center + new Vector3(-half.x, 0, -half.z), childSize), maxDepth, depth + 1); // 西南
node.children[3] = BuildQuadTree(node, new Bounds(center + new Vector3(half.x, 0, -half.z), childSize), maxDepth, depth + 1); // 东南
return node;
}
建立好后递归提取叶子结点:
void CollectLeaves(QuadTreeNode node)
{
if (node.isLeaf)
{
leaves.Add(node);
}
else if (node.children != null)
{
foreach (var child in node.children)
CollectLeaves(child);
}
}
递归建立叶子结点的邻接点列表:
void BuildNeighbors(List<QuadTreeNode> leaves)
{
foreach (var node in leaves)
{
node.neighbors = new List<QuadTreeNode>();
foreach (var other in leaves)
{
if (node == other) continue;
if (AreNeighbors(node.bounds, other.bounds))
{
node.neighbors.Add(other);
}
}
}
}
判断相邻函数:
// 判断两个叶子是否相邻
bool AreNeighbors(Bounds a, Bounds b)
{
// 判断XZ平面接触
bool xOverlap = (a.max.x >= b.min.x && a.min.x <= b.max.x);
bool zOverlap = (a.max.z >= b.min.z && a.min.z <= b.max.z);
bool xTouch = Mathf.Approximately(a.max.x, b.min.x) || Mathf.Approximately(a.min.x, b.max.x);
bool zTouch = Mathf.Approximately(a.max.z, b.min.z) || Mathf.Approximately(a.min.z, b.max.z);
// 必须在 X 或 Z 上接触,并且在另一个方向上有重叠
return (xTouch && zOverlap) || (zTouch && xOverlap);
}
寻点(将起点(Vector3)和终点(Vector3)贴合到离散的叶子结点上并找到对应的数据结构(树节点类))
public List<Vector3> QuadTreeAStar(Vector3 start, Vector3 end)
{
InitMap();
start.y = leaves[0].center.y;
end.y = leaves[0].center.y;
List<Vector3> path = new List<Vector3>();
QuadTreeNode s = null;
QuadTreeNode e = null;
for (int i = 0; i < leaves.Count; i++)
{
if (s == null && leaves[i].bounds.Contains(start))
{
s = leaves[i];
}
if (e == null && leaves[i].bounds.Contains(end))
{
e = leaves[i];
}
if (s != null && e != null)
{
break;
}
}
if (s == null)
{
Debug.Log("找不到贴合的点");
//检查点的间隔以及是否附近有可行的点
return null;
}
if (e == null)
{
Debug.Log("目标点脱离A*范围");
return null;
}
if (FindPathAboutQuadTree(s, e))
{
return GetFinalPathAboutQuadTree(s, e);
}
return null;
}
寻路(遍历邻域方式为使用其成员变量neighbors(上文已构建))
private bool FindPathAboutQuadTree(QuadTreeNode start, QuadTreeNode end)
{
openListAboutQuadTree.Add(start);//按代价大小排序,最小的在前
QuadTreeNode currentNode = null;
while (openListAboutQuadTree.Count > 0)
{
currentNode = openListAboutQuadTree[0];
openListAboutQuadTree.RemoveAt(0);
closeListAboutQuadTree.Add(currentNode);
foreach (var child in currentNode.neighbors)
{
if (!child.isWalkable)
{
continue;//阻挡点不要
}
if (openListAboutQuadTree.Find(p => p.bounds == child.bounds) != null || closeListAboutQuadTree.Find(p => p.bounds == child.bounds) != null)
{
continue;//在开启列表和关闭列表中也不要
}
child.pathparent = currentNode;
float g = child.pathparent.gCost + (child.pathparent.center - child.center).magnitude;
float h1 = Mathf.Abs(child.center.x - end.center.x);
float h2 = Mathf.Abs(child.center.z - end.center.z);
float h = h1 + h2;
float f = g + h;
child.gCost = g;
child.hCost = h;
child.fCost = f;
openListAboutQuadTree.Add(child);
if (child.bounds == end.bounds)
{
child.pathparent = currentNode;
return true; // 找到路径
}
}
if (openListAboutQuadTree.Count == 0)
{
Debug.Log("此路不通");
return false;
}
//排序,f大的排在后面
openListAboutQuadTree.Sort((node1, node2) =>
{
return node1.fCost >= node2.fCost ? 1 : -1;
});
//把消耗最小的f的节点放入关闭列表并且移除
}
return true;
}
返回路径(此处使用的是pathParent而不是parent,parent是构建树时和构建邻接表时使用的)
public List<Vector3> GetFinalPathAboutQuadTree(QuadTreeNode start, QuadTreeNode end)
{
List<Vector3> path = new List<Vector3>();
QuadTreeNode currentNode = end;
int i = 0;
while (currentNode != start)
{
i++;
path.Add(currentNode.center); // 将当前节点添加到路径列表
currentNode = currentNode.pathparent; // 回溯到父节点
}
path.Add(start.center); // 添加起点
path.Reverse(); // 将路径反转,确保从起点到终点
return path;
}
移动:相同的代码不在赘述,此处仅解释固定时刻刷新(重新寻路)的协程处理
private IEnumerator DelayedPathCalculation()
{
yield return null; // 等待一帧,确保其他脚本初始化完成
if (AStarManager.Instance == null || PlayerManager.Instance == null)
{
Debug.LogError("依赖管理器未初始化!");
yield break;
}
while (true)
{
Vector3 startPos = transform.position;
Vector3 endPos = PlayerManager.Instance.player.transform.position;
List<Vector3> path = null;
if (pathfindingGridVisualizer.isUseQuadTree)
{
path = AStarManager.Instance.QuadTreeAStar(startPos, endPos);
}
else
{
path = AStarManager.Instance.GetPathAboutAStar(startPos, endPos);
}
if (path == null)
{
Debug.LogWarning("未找到路径!");
}
else
{
nodes = path;
nodes[0] = startPos;
nodes[nodes.Count - 1] = endPos;
currentPointIndex = 0;
}
yield return new WaitForSecondsRealtime(2f);
}
}
作者算法依旧还有很多改进的地方,比如对开启列表的最小堆(优先队列)排序优化,没有做动态地形设计(此方法若用于动态地形则会因为重复构建树导致FPS下降严重),仅考虑了二维等。但总体思路是正确且可行的,感谢亲爱的读者能看到这里,如果作者后续有能力,会把ECS的优化补上,感谢观看!
640

被折叠的 条评论
为什么被折叠?



