Skip to content

Instantly share code, notes, and snippets.

@ChrisTJie
Last active March 3, 2026 09:40
Show Gist options
  • Select an option

  • Save ChrisTJie/8966478adde337f21e45fd10afafbe22 to your computer and use it in GitHub Desktop.

Select an option

Save ChrisTJie/8966478adde337f21e45fd10afafbe22 to your computer and use it in GitHub Desktop.
基於 Splines 的程序化建築生成工具(Procedural Building Generator)
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Splines;
using Unity.Mathematics;
#if UNITY_EDITOR
using UnityEditor;
#endif
/// <summary>
/// 基於 Unity Splines 的程序化建築生成器。
/// 具備編輯器即時預覽、防抖效能保護,以及完整的 Undo/Redo (復原/重做) 支援。
/// </summary>
[ExecuteAlways] // 允許腳本在編輯器未播放狀態下執行,以支援即時預覽
[RequireComponent(typeof(SplineContainer))]
public class SplineRandomFiller : MonoBehaviour
{
[Header("生成設定 (Generation Settings)")]
[Tooltip("請放入所有可用的建築物 Prefabs。每個 Prefab 最外層必須帶有 BoxCollider 作為實體佔地邊界。")]
public List<GameObject> buildingPool;
[Tooltip("建築物之間的額外安全間距 (單位:公尺)。可微調以避免轉角處穿模。")]
public float padding = 0f;
[Tooltip("固定隨機種子 (Random Seed)。鎖定此數值可確保每次生成的建築款式與順序完全一致,避免拖曳時畫面閃爍。")]
public int randomSeed = 0;
[Header("即時預覽 (Real-time Preview)")]
[Tooltip("開啟後,拖曳曲線控制點時將自動觸發重新生成。")]
public bool autoUpdate = true;
[Tooltip("防抖延遲(秒)。拖曳曲線時的更新間隔,數值越大越節省 CPU 效能。")]
[Range(0.1f, 1f)]
public float updateInterval = 0.3f;
[Header("微調與偏移 (Local Space Offsets)")]
[Tooltip("位置偏移:X(右側/左側), Y(上方/下方), Z(前方/後方)。相對於曲線切線方向移動。")]
public Vector3 positionOffset = Vector3.zero;
[Tooltip("旋轉偏移:X(俯仰), Y(偏航), Z(翻滾)。用於校正模型的預設朝向。")]
public Vector3 rotationOffset = Vector3.zero;
// 快取 SplineContainer,避免在迴圈或高頻更新中重複呼叫 GetComponent
private SplineContainer splineContainer;
#if UNITY_EDITOR
// --- 編輯器狀態變數 ---
private bool isDirty = false;
private double lastUpdateTime = 0;
/// <summary>
/// 當腳本被啟用或掛載時觸發。負責訂閱全域的 Spline 變更事件。
/// </summary>
private void OnEnable()
{
splineContainer = GetComponent<SplineContainer>();
Spline.Changed += OnSplineModified;
EditorApplication.update += EditorUpdate;
}
/// <summary>
/// 當腳本被關閉或銷毀時觸發。
/// 【關鍵】:必須在此解除事件訂閱,否則會造成編輯器記憶體洩漏 (Memory Leak)。
/// </summary>
private void OnDisable()
{
Spline.Changed -= OnSplineModified;
EditorApplication.update -= EditorUpdate;
}
/// <summary>
/// 攔截 Unity Splines 的底層變更事件。
/// </summary>
private void OnSplineModified(Spline spline, int knotIndex, SplineModification modification)
{
if (!autoUpdate || splineContainer == null) return;
// 驗證被觸動的曲線是否屬於當前物件
bool isOurs = false;
foreach (var s in splineContainer.Splines)
{
if (s == spline) { isOurs = true; break; }
}
// 標記為「髒數據 (Dirty)」,等待防抖計時器放行
if (isOurs) isDirty = true;
}
/// <summary>
/// 編輯器的每幀更新迴圈。負責執行防抖 (Debounce) 邏輯。
/// </summary>
private void EditorUpdate()
{
// 若處於 Dirty 狀態,且距離上次生成的時間超過設定的安全間隔
if (isDirty && (EditorApplication.timeSinceStartup - lastUpdateTime > updateInterval))
{
isDirty = false;
lastUpdateTime = EditorApplication.timeSinceStartup;
GenerateRandomLayout();
}
}
#endif
/// <summary>
/// 核心生成邏輯。可在 Inspector 右鍵選單手動觸發。
/// </summary>
[ContextMenu("隨機填滿所有曲線 (Fill All Splines)")]
public void GenerateRandomLayout()
{
if (splineContainer == null) splineContainer = GetComponent<SplineContainer>();
if (buildingPool == null || buildingPool.Count == 0)
{
Debug.LogWarning("[SplineFiller] 建築物庫為空,生成程序已終止。");
return;
}
// 鎖定隨機狀態,確保相同 Seed 產出相同結果
UnityEngine.Random.InitState(randomSeed);
#if UNITY_EDITOR
// 註冊即將發生的整體狀態變更,讓使用者可以一鍵 Ctrl+Z 復原整個生成動作
Undo.RegisterCompleteObjectUndo(transform, "Generate Spline Buildings");
#endif
// 1. 清理舊有生成的實體 (從後往前遍歷以防索引越界)
for (int i = transform.childCount - 1; i >= 0; i--)
{
GameObject child = transform.GetChild(i).gameObject;
#if UNITY_EDITOR
// 使用支援 Undo 的銷毀 API
Undo.DestroyObjectImmediate(child);
#else
DestroyImmediate(child);
#endif
}
// 2. 歷遍所有子曲線並分配空間
int splineCount = splineContainer.Splines.Count;
for (int splineIndex = 0; splineIndex < splineCount; splineIndex++)
{
FillSingleSpline(splineIndex);
}
}
/// <summary>
/// 沿著單一曲線執行貪婪填滿 (Greedy Fill) 演算法。
/// </summary>
private void FillSingleSpline(int splineIndex)
{
float totalSplineLength = splineContainer.CalculateLength(splineIndex);
float currentDistance = 0f;
int safetyCounter = 0; // 防死迴圈機制
while (safetyCounter < 1000)
{
safetyCounter++;
// 隨機抽取建築物
int randomIndex = UnityEngine.Random.Range(0, buildingPool.Count);
GameObject selectedPrefab = buildingPool[randomIndex];
// 讀取實體長度
float buildingLengthZ = GetColliderZLength(selectedPrefab);
if (buildingLengthZ <= 0) break;
// 空間不足則中斷該曲線的生成
if (currentDistance + buildingLengthZ > totalSplineLength) break;
// 計算目標中心點並轉化為曲線參數 t
float targetCenterDistance = currentDistance + (buildingLengthZ / 2f);
float t = math.clamp(targetCenterDistance / totalSplineLength, 0f, 1f);
// 取樣曲線幾何資料
splineContainer.Evaluate(splineIndex, t, out float3 pos, out float3 tangent, out float3 upVector);
// 計算區域座標轉換矩陣
Vector3 forward = ((Vector3)tangent).normalized;
Vector3 up = ((Vector3)upVector).normalized;
Vector3 right = Vector3.Cross(up, forward).normalized;
// 套用偏移與旋轉
Vector3 localOffset = (right * positionOffset.x) + (up * positionOffset.y) + (forward * positionOffset.z);
Vector3 finalPosition = (Vector3)pos + localOffset;
Quaternion baseRotation = Quaternion.LookRotation(forward, up);
Quaternion finalRotation = baseRotation * Quaternion.Euler(rotationOffset);
GameObject building = null;
#if UNITY_EDITOR
// 安全實例化並保留 Prefab 連結
if (PrefabUtility.GetPrefabAssetType(selectedPrefab) != PrefabAssetType.NotAPrefab)
{
building = (GameObject)PrefabUtility.InstantiatePrefab(selectedPrefab, transform);
building.transform.position = finalPosition;
building.transform.rotation = finalRotation;
// 將新生成的物件註冊進 Undo 歷史紀錄
Undo.RegisterCreatedObjectUndo(building, "Generate Spline Buildings");
}
else
{
building = Instantiate(selectedPrefab, finalPosition, finalRotation, transform);
Undo.RegisterCreatedObjectUndo(building, "Generate Spline Buildings");
}
#else
building = Instantiate(selectedPrefab, finalPosition, finalRotation, transform);
#endif
// 推進距離游標
currentDistance += buildingLengthZ + padding;
}
}
/// <summary>
/// 透過 BoxCollider 獲取物件的物理深度。
/// </summary>
private float GetColliderZLength(GameObject prefab)
{
if (prefab == null) return 0f;
BoxCollider box = prefab.GetComponent<BoxCollider>();
if (box != null)
{
return box.size.z * prefab.transform.localScale.z;
}
else
{
Debug.LogError($"[SplineFiller] 致命錯誤:建築 '{prefab.name}' 缺少 BoxCollider!");
return 0f;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment