本文最后更新于1 天前,其中的信息可能已经过时,如有错误请留言
材质是紫色的
原因:Shader 丢失 / 不兼容
解决方法:
- 选中材质:Shader → Universal Render Pipeline/Lit
https://www.youtube.com/watch?v=PBqafb_PwRA
https://www.youtube.com/playlist?list=PLrMEhC9sAD1zprGu_lphl3cQSS3uFIXA9
https://www.youtube.com/watch?v=XpG3YqUkCTY
https://www.youtube.com/watch?v=PBqafb_PwRA
Placer Tool along the spline
用途:创建一个脚本轻松的沿着物体放置对象
- Hierachy – Create – spline

首先引用spline曲线搭配Spline引用
如果物体的+Z方向是斜着的,是因为面本身就是斜着的,朝向面的法向量方向
如果想统一保持垂直分布就启用USE GLOBAL UP
左右偏移check

using UnityEngine;
using UnityEngine.Splines;
using System.Collections.Generic;
using Unity.Mathematics;
[ExecuteInEditMode] // 让脚本在编辑器模式下也能运行,不必进入 Play 模式
public class SplinePlacer : MonoBehaviour
{
[Header("运行控制")]
public bool run; // 勾选后执行一次摆放逻辑,执行完会自动改回 false
public bool deleteObjects; // 勾选后立刻删除当前脚本记录的所有已摆放物体
public bool keepObjects; // 勾选后清空记录列表,但场景里的物体会保留下来
[Header("基础引用")]
public SplineContainer spline; // 要沿着它进行摆放的样条曲线
[Header("预制体设置")]
public List<GameObject> prefabVariants = new();
// 可用于摆放的预制体列表
// 如果只放 1 个预制体,就始终生成它
// 如果放多个,就会随机挑一个生成
public int prefabMaxSize;
// 最大尺寸
// 在固定尺寸模式下:它会被当成“物体间距”的参考值
// 在可变尺寸模式下:它表示单个物体允许扩展到的最大长度
public int prefabMinSize;
// 最小尺寸
// 仅在可变尺寸模式下更重要,决定最小步进单位
public bool fixedSize = true;
// true = 固定尺寸摆放
// false = 可变尺寸摆放(会根据曲线弯曲程度自动拉长/缩短物体)
[Header("可变尺寸模式参数")]
public float angleDeviance = 3f;
// 当曲线方向变化超过这个角度时,就认为不能继续把同一个物体拉长了
// 只在“可变尺寸模式”下生效
// 值越小,物体越容易被切成更短的小段
// 值越大,物体越容易被拉得更长
[Header("朝向与高度")]
public bool useGlobalUp = false;
// 是否强制使用世界坐标的 Y 轴作为“向上方向”
// true:物体更偏向水平摆放,适合地面道路、围栏等
// false:使用样条自身的 up 方向,适合跟随曲面、翻转轨道等情况
public bool useTransformYAsHeight = false;
// 是否忽略样条采样点本身的 y 高度
// true:统一使用 spline 物体自身 Transform 的 y 值作为高度
// false:使用样条实际采样点的 y 值
[Header("调试信息(只读观察用)")]
[SerializeField] private float totalLength; // 当前样条总长度
[SerializeField] private float stepSize; // 归一化步长(0~1 之间)
[SerializeField] private int totalObjects; // 当前总共摆放了多少个物体
private List<GameObject> placedObjects = new();
// 用来记录“本脚本生成过的物体”
// 这样后续可以删除、重建,或者选择把它们留在场景里
// 每帧都会执行
// 因为有 ExecuteInEditMode,所以在编辑器里也会不断调用
void Update()
{
// 手动触发执行
if (run)
{
if (fixedSize)
PlaceObjectsFixedSize(); // 固定尺寸摆放
else
PlaceObjectsVariableSize(); // 可变尺寸摆放
run = false; // 执行一次后自动关闭,防止每帧重复生成
}
// 删除当前记录里的所有已生成物体
if (deleteObjects)
{
foreach (GameObject obj in placedObjects)
DestroyImmediate(obj);
deleteObjects = false;
}
// 只清空记录,不删场景中的物体
// 相当于“这些物体以后不归这个脚本管了”
if (keepObjects)
{
placedObjects.Clear();
keepObjects = false; // 建议这里顺手复位,避免一直重复清空
}
}
/// <summary>
/// 固定尺寸摆放:
/// 思路很直接——按固定步长沿样条往前走,
/// 每一段中点放一个物体,所有物体尺寸基本一致。
/// </summary>
void PlaceObjectsFixedSize()
{
totalLength = spline.CalculateLength(); // 先计算整条样条的总长度
float step = totalLength / prefabMaxSize;
// 计算可以分成多少段
// 例如:总长 100,prefabMaxSize = 5,则 step = 20
// 意思是大概会放 20 个物体
stepSize = 1 / step;
// 样条 Evaluate 使用的是 0~1 的归一化参数
// 所以要把“按长度算出来的步长”转成 0~1 范围的步长
// 重新生成前,先把上一次摆的全部删掉
foreach (GameObject obj in placedObjects)
DestroyImmediate(obj);
placedObjects.Clear();
float t = 0f; // 当前沿样条采样的位置(0~1)
while (t < 1f)
{
// 起点信息
float3 startPos; // 起点世界坐标
float3 startTang; // 起点切线方向(曲线前进方向)
float3 startUp; // 起点向上方向
// 中点信息
float3 midPos;
float3 midTang;
float3 midUp;
// 终点信息
float3 endPos;
float3 endTang;
float3 endUp;
// 获取当前 t 位置的样条信息
spline.Evaluate(t, out startPos, out startTang, out startUp);
// 确保下一段没有越界
if (t + stepSize <= 1f)
{
spline.Evaluate(t + stepSize, out endPos, out endTang, out endUp);
spline.Evaluate(t + (stepSize / 2f), out midPos, out midTang, out midUp);
}
else
{
break;
}
// 用起点和终点的中间位置作为新物体的摆放位置
Vector3 worldSpaceMiddle = Vector3.Lerp(startPos, endPos, 0.5f);
GameObject newObject = null;
// 是否强制高度使用 spline 物体自身的 Y 值
if (useTransformYAsHeight)
{
worldSpaceMiddle.y = spline.gameObject.transform.position.y;
}
// 生成物体:多个预制体时随机选一个,只有一个时直接生成
if (prefabVariants.Count > 1)
{
int randomIndex = UnityEngine.Random.Range(0, prefabVariants.Count);
newObject = Instantiate(prefabVariants[randomIndex], worldSpaceMiddle, Quaternion.identity);
}
else
{
newObject = Instantiate(prefabVariants[0], worldSpaceMiddle, Quaternion.identity);
}
// 设置朝向
if (useGlobalUp)
{
// 使用世界 Y 轴作为向上方向
// 这里把方向压平到 XZ 平面,适合地面上的道路/围栏
Vector3 flattenedDirection = new Vector3(endPos.x - startPos.x, 0f, endPos.z - startPos.z).normalized;
newObject.transform.rotation = Quaternion.LookRotation(
Quaternion.AngleAxis(90f, Vector3.up) * flattenedDirection,
Vector3.up
);
}
else
{
// 使用样条中点的切线和 up 方向
// 这样物体会更贴合样条本身的空间朝向
newObject.transform.rotation = Quaternion.LookRotation(
Quaternion.AngleAxis(90f, midUp) * midTang,
midUp
);
}
// 记录生成物体,方便后续统一删除或保留
placedObjects.Add(newObject);
// 前进到下一段
t += stepSize;
}
totalObjects = placedObjects.Count; // 记录生成数量
}
/// <summary>
/// 可变尺寸摆放:
/// 从最小步长开始尝试往前“拼接”,
/// 只要曲线弯折还不大,就继续延长当前物体;
/// 一旦弯得太厉害,就停止延长,生成一个刚好适配这段曲线长度的物体。
/// </summary>
void PlaceObjectsVariableSize()
{
// 重新生成前,先清空旧物体
foreach (GameObject wall in placedObjects)
DestroyImmediate(wall);
placedObjects.Clear();
// 先计算样条总长度,再根据最小尺寸得到基础步长
totalLength = spline.CalculateLength();
float step = totalLength / prefabMinSize;
stepSize = 1 / step;
// 这个值表示:最大尺寸大约等于多少个最小尺寸单位
// 例如 max=6, min=2,那么 stepsBetweenMinMax = 3
int stepsBetweenMinMax = Mathf.RoundToInt(prefabMaxSize / prefabMinSize);
float objScale = prefabMinSize; // 先给一个初始值,后面会被实际长度覆盖
// while 循环的控制变量
float t = 0f; // 当前起点参数
int breakCounter = 0; // 紧急保险,防止死循环
bool killLoop = false; // 对闭合样条做收尾时使用
while (t < 1f && !killLoop)
{
// 紧急中断,防止某些特殊参数导致无限循环
breakCounter++;
if (breakCounter > 10000) break;
// 当前段起点信息
float3 startPos;
float3 startTang;
float3 startUp;
// 当前段最终决定的终点信息
float3 endPos = new();
float3 endTang;
float3 endUp;
spline.Evaluate(t, out startPos, out startTang, out startUp);
int scaleFactor = 0; // 目前代码里没有实际参与计算,更多像是调试残留变量
float startT = t; // 当前物体的起始参数位置
float endT = t + stepSize; // 当前物体的结束参数位置,后面会逐步修正
// 尝试把一个物体从最小长度开始不断往前延长
for (int i = 1; i < stepsBetweenMinMax; i++)
{
float tempT = t + stepSize * i;
// 如果是闭合样条,超出 1 就回到 0
if (tempT > 1f && spline.Spline.Closed)
tempT = 0f;
scaleFactor += 1;
float3 currentPos;
float3 currentTang;
float3 currentUp;
spline.Evaluate(tempT, out currentPos, out currentTang, out currentUp);
// 如果闭合样条已经绕回起点,说明到头了,结束整个流程
if (tempT == 0f)
{
endPos = currentPos;
endTang = currentTang;
endUp = currentUp;
killLoop = true;
break;
}
// 第一段至少先给一个有效终点,避免后面没有 endPos
if (i == 1)
{
endPos = currentPos;
endTang = currentTang;
endUp = currentUp;
}
// 比较起点切线和当前位置切线的夹角
// 如果弯曲超过允许范围,就停止延长当前物体
if (Vector3.Angle(startTang, currentTang) > angleDeviance && i != 1)
{
t = tempT - stepSize; // 下次从上一个安全位置继续
endT = tempT - stepSize; // 当前物体终点停在上一个安全位置
break;
}
else
{
// 还没超角度,说明这段可以继续延长
endPos = currentPos;
endTang = currentTang;
endUp = currentUp;
// 如果已经到达允许的最大尺寸,就停止延长
if (i == stepsBetweenMinMax - 1)
{
t = tempT;
endT = tempT;
break;
}
}
}
// 创建当前这一个物体
GameObject obj = null;
if (prefabVariants.Count > 1)
{
int randomIndex = UnityEngine.Random.Range(0, prefabVariants.Count);
obj = Instantiate(prefabVariants[randomIndex]);
}
else
{
obj = Instantiate(prefabVariants[0]);
}
placedObjects.Add(obj);
// 计算这一段的中点
// 位置上使用中点,是为了让物体正好居中覆盖 start 到 end 这一整段
Vector3 start = startPos;
Vector3 end = endPos;
Vector3 midpoint = Vector3.Lerp(start, end, 0.5f);
float midT = (startT + endT) / 2;
float3 midPos;
float3 midTang;
float3 midUp;
spline.Evaluate(midT, out midPos, out midTang, out midUp);
// 设置位置
if (useTransformYAsHeight)
{
// 高度固定为 spline 对象自身的 y
obj.transform.position = new Vector3(midpoint.x, spline.transform.position.y, midpoint.z);
}
else
{
// 高度跟随样条中点
obj.transform.position = midPos;
}
// 设置朝向
if (useGlobalUp)
{
Vector3 flattenedDirection = new Vector3(endPos.x - startPos.x, 0f, endPos.z - startPos.z).normalized;
obj.transform.rotation = Quaternion.LookRotation(
Quaternion.AngleAxis(90f, Vector3.up) * flattenedDirection,
Vector3.up
);
}
else
{
obj.transform.rotation = Quaternion.LookRotation(
Quaternion.AngleAxis(90f, midUp) * midTang,
midUp
);
}
// 设置缩放
// 这里把物体 X 轴长度拉成 start 到 end 的距离
// 也就是说:这个脚本默认你的预制体“长度方向”是本地 X 轴
objScale = Vector3.Distance(start, end);
obj.transform.localScale = new Vector3(objScale, 1, 1);
}
totalObjects = placedObjects.Count;
}
}
using UnityEngine;
using UnityEngine.Splines;
using System.Collections.Generic;
using Unity.Mathematics;
[ExecuteInEditMode]
public class SplinePlacer : MonoBehaviour
{
public bool run;
public bool deleteObjects;
public bool keepObjects;
public SplineContainer spline;
public List<GameObject> prefabVariants = new();
public int prefabMaxSize;
public int prefabMinSize;
public bool fixedSize = true;
public float angleDeviance = 3f;
public bool useGlobalUp = false;
public bool useTransformYAsHeight = false;
public float lateralOffset = 0f;
// 左右偏移量
// 正数:往右
// 负数:往左
// 0:不偏移
[SerializeField] private float totalLength;
[SerializeField] private float stepSize;
[SerializeField] private int totalObjects;
private List<GameObject> placedObjects = new();
void Update()
{
if (run)
{
if (fixedSize) PlaceObjectsFixedSize();
else PlaceObjectsVariableSize();
run = false;
}
if (deleteObjects)
{
foreach (GameObject obj in placedObjects) DestroyImmediate(obj);
deleteObjects = false;
}
if (keepObjects)
{
placedObjects.Clear();
keepObjects = false;
}
}
private Vector3 GetRightDirection(Vector3 forward, Vector3 up)
{
Vector3 right = Vector3.Cross(up.normalized, forward.normalized);
if (right.sqrMagnitude < 0.0001f)
return Vector3.zero;
return right.normalized;
}
void PlaceObjectsFixedSize()
{
totalLength = spline.CalculateLength();
float step = totalLength / prefabMaxSize;
stepSize = 1 / step;
foreach (GameObject obj in placedObjects) DestroyImmediate(obj);
placedObjects.Clear();
float t = 0f;
while (t < 1f)
{
float3 startPos;
float3 startTang;
float3 startUp;
float3 midPos;
float3 midTang;
float3 midUp;
float3 endPos;
float3 endTang;
float3 endUp;
spline.Evaluate(t, out startPos, out startTang, out startUp);
if (t + stepSize <= 1f)
{
spline.Evaluate(t + stepSize, out endPos, out endTang, out endUp);
spline.Evaluate(t + (stepSize / 2f), out midPos, out midTang, out midUp);
}
else break;
Vector3 worldSpaceMiddle = Vector3.Lerp(startPos, endPos, 0.5f);
if (useTransformYAsHeight)
{
worldSpaceMiddle.y = spline.gameObject.transform.position.y;
}
Vector3 forwardDir;
Vector3 upDir;
if (useGlobalUp)
{
forwardDir = new Vector3(endPos.x - startPos.x, 0f, endPos.z - startPos.z).normalized;
upDir = Vector3.up;
}
else
{
forwardDir = ((Vector3)midTang).normalized;
upDir = ((Vector3)midUp).normalized;
}
worldSpaceMiddle += GetRightDirection(forwardDir, upDir) * lateralOffset;
GameObject newObject = null;
if (prefabVariants.Count > 1)
{
int randomIndex = UnityEngine.Random.Range(0, prefabVariants.Count);
newObject = Instantiate(prefabVariants[randomIndex], worldSpaceMiddle, Quaternion.identity);
}
else
{
newObject = Instantiate(prefabVariants[0], worldSpaceMiddle, Quaternion.identity);
}
if (useGlobalUp)
{
Vector3 flattenedDirection = new Vector3(endPos.x - startPos.x, 0f, endPos.z - startPos.z).normalized;
newObject.transform.rotation = Quaternion.LookRotation(
Quaternion.AngleAxis(90f, Vector3.up) * flattenedDirection,
Vector3.up
);
}
else
{
newObject.transform.rotation = Quaternion.LookRotation(
Quaternion.AngleAxis(90f, midUp) * midTang,
midUp
);
}
placedObjects.Add(newObject);
t += stepSize;
}
totalObjects = placedObjects.Count;
}
void PlaceObjectsVariableSize()
{
foreach (GameObject wall in placedObjects) DestroyImmediate(wall);
placedObjects.Clear();
totalLength = spline.CalculateLength();
float step = totalLength / prefabMinSize;
stepSize = 1 / step;
int stepsBetweenMinMax = Mathf.RoundToInt(prefabMaxSize / prefabMinSize);
float objScale = prefabMinSize;
float t = 0f;
int breakCounter = 0;
bool killLoop = false;
while (t < 1f && !killLoop)
{
breakCounter++;
if (breakCounter > 10000) break;
float3 startPos;
float3 startTang;
float3 startUp;
float3 endPos = new();
float3 endTang;
float3 endUp;
spline.Evaluate(t, out startPos, out startTang, out startUp);
float startT = t;
float endT = t + stepSize;
for (int i = 1; i < stepsBetweenMinMax; i++)
{
float tempT = t + stepSize * i;
if (tempT > 1f && spline.Spline.Closed) tempT = 0f;
float3 currentPos;
float3 currentTang;
float3 currentUp;
spline.Evaluate(tempT, out currentPos, out currentTang, out currentUp);
if (tempT == 0f)
{
endPos = currentPos;
endTang = currentTang;
endUp = currentUp;
killLoop = true;
break;
}
if (i == 1)
{
endPos = currentPos;
endTang = currentTang;
endUp = currentUp;
}
if (Vector3.Angle(startTang, currentTang) > angleDeviance && i != 1)
{
t = tempT - stepSize;
endT = tempT - stepSize;
break;
}
else
{
endPos = currentPos;
endTang = currentTang;
endUp = currentUp;
if (i == stepsBetweenMinMax - 1)
{
t = tempT;
endT = tempT;
break;
}
}
}
GameObject obj = null;
if (prefabVariants.Count > 1)
{
int randomIndex = UnityEngine.Random.Range(0, prefabVariants.Count);
obj = Instantiate(prefabVariants[randomIndex]);
}
else
{
obj = Instantiate(prefabVariants[0]);
}
placedObjects.Add(obj);
Vector3 start = startPos;
Vector3 end = endPos;
Vector3 midpoint = Vector3.Lerp(start, end, 0.5f);
float midT = (startT + endT) / 2;
float3 midPos;
float3 midTang;
float3 midUp;
spline.Evaluate(midT, out midPos, out midTang, out midUp);
Vector3 basePosition;
if (useTransformYAsHeight)
{
basePosition = new Vector3(midpoint.x, spline.transform.position.y, midpoint.z);
}
else
{
basePosition = midPos;
}
Vector3 forwardDir;
Vector3 upDir;
if (useGlobalUp)
{
forwardDir = new Vector3(endPos.x - startPos.x, 0f, endPos.z - startPos.z).normalized;
upDir = Vector3.up;
}
else
{
forwardDir = ((Vector3)midTang).normalized;
upDir = ((Vector3)midUp).normalized;
}
obj.transform.position = basePosition + GetRightDirection(forwardDir, upDir) * lateralOffset;
if (useGlobalUp)
{
Vector3 flattenedDirection = new Vector3(endPos.x - startPos.x, 0f, endPos.z - startPos.z).normalized;
obj.transform.rotation = Quaternion.LookRotation(
Quaternion.AngleAxis(90f, Vector3.up) * flattenedDirection,
Vector3.up
);
}
else
{
obj.transform.rotation = Quaternion.LookRotation(
Quaternion.AngleAxis(90f, midUp) * midTang,
midUp
);
}
objScale = Vector3.Distance(start, end);
obj.transform.localScale = new Vector3(objScale, 1, 1);
}
totalObjects = placedObjects.Count;
}
}






