Last active
March 3, 2026 09:40
-
-
Save ChrisTJie/8966478adde337f21e45fd10afafbe22 to your computer and use it in GitHub Desktop.
基於 Splines 的程序化建築生成工具(Procedural Building Generator)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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