Evolution_07_环境
环境
一、分区
1、在 Scripts 文件夹下新建 Common 文件夹,用于存放通用脚本
2、创建 SpawnRegion.cs(通用分区数据类)
using System;
using UnityEngine;[System.Serializable]
public class SpawnRegion
{[Header("区域位置与大小")]public Vector2 center; // XZ平面的中心坐标public float width = 50f; // X轴方向长度public float length = 50f; // Z轴方向长度[Header("簇生成参数")][Range(5, 30)] public int clusterCount = 10; // 簇的数量[Range(3, 20)] public int minPerCluster = 5; // 每个簇的最小实例数[Range(3, 20)] public int maxPerCluster = 10; // 每个簇的最大实例数// 为不同类型提供默认配置(方便初始化)public static SpawnRegion GetGrassDefault(){return new SpawnRegion{width = 50f,length = 50f,clusterCount = 15,minPerCluster = 10,maxPerCluster = 15};}public static SpawnRegion GetRockDefault(){return new SpawnRegion{width = 50f,length = 50f,clusterCount = 10,minPerCluster = 3,maxPerCluster = 8};}public static SpawnRegion GetRockPileDefault(){return new SpawnRegion{width = 50f,length = 50f,clusterCount = 8,minPerCluster = 2,maxPerCluster = 5};}
}3、创建 SpawnRegionUtility.cs(通用分区工具类)
using UnityEngine;public static class SpawnRegionUtility
{// 生成簇中心(所有脚本共用的逻辑)public static Vector2[] GenerateClusterCenters(SpawnRegion region){Vector2[] centers = new Vector2[region.clusterCount];float cellWidth = region.width / Mathf.Sqrt(region.clusterCount);float cellLength = region.length / Mathf.Sqrt(region.clusterCount);for (int i = 0; i < region.clusterCount; i++){float x = region.center.x + Random.Range(-region.width / 2 + cellWidth / 2,region.width / 2 - cellWidth / 2);float y = region.center.y + Random.Range(-region.length / 2 + cellLength / 2,region.length / 2 - cellLength / 2);centers[i] = new Vector2(x, y);}return centers;}// 绘制Gizmos(所有脚本共用的调试逻辑)public static void DrawRegionGizmos(SpawnRegion region, Color color, bool showClusterCenters = false){Gizmos.color = color;Vector3 center = new Vector3(region.center.x, 0, region.center.y);Vector3 size = new Vector3(region.width, 1, region.length);Gizmos.DrawWireCube(center, size);if (showClusterCenters && Application.isPlaying){Vector2[] clusters = GenerateClusterCenters(region);foreach (Vector2 point in clusters){Gizmos.DrawSphere(new Vector3(point.x, 0, point.y), 0.5f);}}}
}二、地形细节参数
(1) 降低Detail Resolution Per Patch(32→16),减少内存占用

(2) 其他参数

① Tree & Detail Objects 中Detail Distance”(细节对象的可见距离阈值):改为80
② Wind Settings for Grass (On Terrain Data) 中 Speed改为0.4(减少硬件负载与整体性能表现)
三、花草树木
1、模型
(1) 地址
"D:\Unity\Using\花草树木石\Flower.unitypackage"
"D:\Unity\Using\花草树木石\Tree.unitypackage"(2) 导入到 Assets/ImportedAssets/Models
2、花草
(1) Create Empty,命名为GrassSpawner,转移到 Environment下(作为Environment的子物体)
(2) 给 GrassSpawner 添加 Assets/Scripts/Environment/GrassSpawner.cs 组件
using UnityEngine;
using System.Collections;public class GrassSpawner : MonoBehaviour
{[Header("Base Settings")][SerializeField] private Terrain terrain;[SerializeField] private GameObject[] grassPrefabs;[SerializeField] private SpawnRegion[] regions; // 通用分区[Header("Cluster Settings")][SerializeField] private float clusterRadius = 3f;[SerializeField][Range(0f, 1f)] private float mixChance = 0.5f;[SerializeField][Range(1, 5)] private int maxMixedTypes = 3;[Header("Randomization")][SerializeField] private Vector2 scaleRange = new Vector2(0.8f, 1.2f);[SerializeField] private bool randomRotation = true;[Header("Debug")][SerializeField] private bool showGizmos = true;[SerializeField] private bool showClusterCenters = true;// 初始化默认区域(Reset时调用)void Reset(){if (regions == null || regions.Length == 0){regions = new SpawnRegion[] { SpawnRegion.GetGrassDefault() };}}void Start(){StartCoroutine(SpawnCoroutine());}bool ValidateSettings(){if (terrain == null) terrain = Terrain.activeTerrain;if (terrain == null || grassPrefabs == null || grassPrefabs.Length == 0){Debug.LogError("Missing terrain or grass prefabs!");return false;}return true;}IEnumerator SpawnCoroutine(){if (!ValidateSettings()) yield break;GameObject grassParent = new GameObject("Spawned_Grasses");foreach (SpawnRegion region in regions){// 用工具类生成簇中心Vector2[] clusterCenters = SpawnRegionUtility.GenerateClusterCenters(region);foreach (Vector2 center in clusterCenters){int mainType = Random.Range(0, grassPrefabs.Length);int[] mixedTypes = GetMixedTypes(mainType);yield return StartCoroutine(SpawnCluster(center, mainType, mixedTypes,Random.Range(region.minPerCluster, region.maxPerCluster + 1),grassParent.transform));}}}IEnumerator SpawnCluster(Vector2 center, int mainType, int[] mixedTypes, int count, Transform parent){for (int i = 0; i < count; i++){int prefabIndex = (mixedTypes.Length > 0 && Random.value < mixChance) ?mixedTypes[Random.Range(0, mixedTypes.Length)] :mainType;Vector2 randomOffset = Random.insideUnitCircle * clusterRadius;Vector3 worldPos = new Vector3(center.x + randomOffset.x,0,center.y + randomOffset.y);worldPos.y = terrain.SampleHeight(worldPos) + terrain.transform.position.y;CreateGrassInstance(grassPrefabs[prefabIndex], worldPos, parent);if (i % 3 == 0) yield return null;}}int[] GetMixedTypes(int mainType){if (grassPrefabs.Length <= 1 || Random.value > mixChance)return new int[0];int mixCount = Random.Range(1, Mathf.Min(maxMixedTypes, grassPrefabs.Length));System.Collections.Generic.List<int> types = new System.Collections.Generic.List<int>();while (types.Count < mixCount){int type = Random.Range(0, grassPrefabs.Length);if (type != mainType && !types.Contains(type))types.Add(type);}return types.ToArray();}void CreateGrassInstance(GameObject prefab, Vector3 position, Transform parent){Quaternion rotation = randomRotation ?Quaternion.Euler(0, Random.Range(0, 360), 0) :Quaternion.identity;GameObject instance = Instantiate(prefab, position, rotation, parent);instance.transform.localScale = Vector3.one * Random.Range(scaleRange.x, scaleRange.y);}void OnDrawGizmosSelected(){if (!showGizmos) return;foreach (SpawnRegion region in regions){SpawnRegionUtility.DrawRegionGizmos(region, Color.green, showClusterCenters);}}
}(3) 赋值
① Transform

② 预制体

③ 区域


(4) chabrs




(5) 灌木
① Bush_Red
给预制体添加胶囊碰撞体



② Shrub


3、树

修改:

(1) Conifer:
① 设置Static(遮挡体和被遮挡体静态)
② Transform设置

(2) Pine_A、Pine_B、Pine_C、Pine_D
① 设置Static(遮挡体和被遮挡体静态)
② Transform设置




(3) American Elm_Fall-corona
① 在预制体中给各子物体添加胶囊碰撞体





② 注意:不设置Static(遮挡体和被遮挡体静态)
③ Transform设置Position.y改为-0.93

(4) Dab
① 预制体中,添加 碰撞体 组件,球形碰撞体的Center.z改为32


② 设置Static(遮挡体和被遮挡体静态)

③ Transform设置

(5) Cypress_Forest_Desktop
① 设置Static(遮挡体和被遮挡体静态)
② Transform设置

(6) Cherry_shrub_01

四、石子
1、导入模型"D:\Unity\Using\花草树木石\Rock.unitypackage"
2、Create Empty,重命名为"RockSpawner",作为子物体转移到Environment下
3、为 RockSpawner添加 Assets/Scripts/Environment/StoneSpawner.cs
using UnityEngine;
using System.Collections;public class StoneSpawner : MonoBehaviour
{[Header("Base Settings")][SerializeField] private Terrain terrain;[SerializeField] private GameObject[] rockPilePrefabs; // "石子堆"预制体[Header("Spawn Regions")][SerializeField] private SpawnRegion[] regions;[Header("Cluster Settings")][SerializeField] private float clusterRadius = 8f; // 增大簇半径[SerializeField][Range(0f, 1f)] private float mixChance = 0.2f; // 降低混合概率(避免类型过多)[SerializeField][Range(1, 2)] private int maxMixedTypes = 1; // 减少混合类型[Header("Randomization")][SerializeField] private Vector2 scaleRange = new Vector2(0.9f, 1.3f); // 缩小缩放范围[SerializeField] private bool randomRotation = true;[Header("Terrain Alignment")][SerializeField] private bool alignToTerrain = true;[SerializeField] private LayerMask terrainLayerMask = 1; // 默认层[Header("Debug")][SerializeField] private bool showGizmos = true;[SerializeField] private bool showClusterCenters = true;void Reset(){if (terrain == null)terrain = Terrain.activeTerrain;if (regions == null || regions.Length == 0){regions = new SpawnRegion[] {new SpawnRegion {width = 50f,length = 50f,clusterCount = 8,minPerCluster = 2,maxPerCluster = 5}};}}void Start(){StartCoroutine(SpawnCoroutine());}bool ValidateSettings(){if (terrain == null) terrain = Terrain.activeTerrain;if (terrain == null || rockPilePrefabs == null || rockPilePrefabs.Length == 0){Debug.LogError("No valid terrain or rock pile prefabs found!");return false;}return true;}IEnumerator SpawnCoroutine(){if (!ValidateSettings()) yield break;GameObject rockParent = new GameObject("Spawned_RockPiles");rockParent.transform.parent = transform;foreach (SpawnRegion region in regions){if (region == null) continue; // 跳过空区域Vector2[] clusterCenters = SpawnRegionUtility.GenerateClusterCenters(region);foreach (Vector2 center in clusterCenters){int mainType = Random.Range(0, rockPilePrefabs.Length);int[] mixedTypes = GetMixedTypes(mainType);yield return StartCoroutine(SpawnCluster(center, mainType, mixedTypes,Random.Range(region.minPerCluster, region.maxPerCluster + 1),rockParent.transform));}}}IEnumerator SpawnCluster(Vector2 center, int mainType, int[] mixedTypes, int count, Transform parent){for (int i = 0; i < count; i++){int prefabIndex = (mixedTypes.Length > 0 && Random.value < mixChance)? mixedTypes[Random.Range(0, mixedTypes.Length)]: mainType;Vector2 randomOffset = Random.insideUnitCircle * clusterRadius;Vector3 worldPos = new Vector3(center.x + randomOffset.x,0,center.y + randomOffset.y);// 获取准确的地形高度worldPos.y = terrain.SampleHeight(worldPos) + terrain.transform.position.y;CreateRockPileInstance(rockPilePrefabs[prefabIndex], worldPos, parent);if (i % 2 == 0) yield return null; // 每生成2个暂停(因预制体较大)}}int[] GetMixedTypes(int mainType){if (rockPilePrefabs.Length <= 1 || Random.value > mixChance)return new int[0];int mixCount = Random.Range(1, Mathf.Min(maxMixedTypes, rockPilePrefabs.Length));System.Collections.Generic.List<int> types = new System.Collections.Generic.List<int>();while (types.Count < mixCount){int type = Random.Range(0, rockPilePrefabs.Length);if (type != mainType && !types.Contains(type))types.Add(type);}return types.ToArray();}void CreateRockPileInstance(GameObject prefab, Vector3 position, Transform parent){Quaternion rotation = randomRotation? Quaternion.Euler(Random.Range(-5f, 5f), // 轻微X轴旋转Random.Range(0, 360f), // Y轴全旋转Random.Range(-5f, 5f) // 轻微Z轴旋转): Quaternion.identity;GameObject instance = Instantiate(prefab, position, rotation, parent);instance.transform.localScale = Vector3.one * Random.Range(scaleRange.x, scaleRange.y);if (alignToTerrain){AlignToTerrain(instance.transform);}}void AlignToTerrain(Transform obj){Vector3 rayOrigin = obj.position + Vector3.up * 10f;RaycastHit hit;if (Physics.Raycast(rayOrigin, Vector3.down, out hit, 20f, terrainLayerMask)){// 贴合地形法线obj.rotation = Quaternion.FromToRotation(Vector3.up, hit.normal) * obj.rotation;// 微调位置确保贴合obj.position = hit.point;}else{// 备用:使用地形高度采样Vector3 terrainPos = obj.position;terrainPos.y = terrain.SampleHeight(obj.position) + terrain.transform.position.y;obj.position = terrainPos;}}void OnDrawGizmosSelected(){if (!showGizmos || regions == null) return;foreach (SpawnRegion region in regions){if (region != null) // 添加空值检查{SpawnRegionUtility.DrawRegionGizmos(region, Color.gray, showClusterCenters);}}}
}



4、石
(1) 向场景添加Rock和Rock1
(2) 原有的碰撞体,勾选 Convex,或直接使用Sphere碰撞体如下:

5、结构:注意,禁用或删除 Particle_Cloud_Atmos_A

五、启用Occlusion Culling
1、标记静态物体
(1) 将Environment下的四个地形标记为Static:Inspector面板右上角勾选 Static

(2) 树木标记如步骤四
2、创建并设置 Occlusion Area
(1) 通过顶部菜单栏:Window > Rendering > Occlusion Culling,切换到Object选项卡

(2) 点击Occlusion Areas,点击弹出的Create New右侧的Occlusion Area,勾选Is View Volume
(3) 点击Hierarchy面板上的Occlusion Area,切换到Inspector面板,设置(覆盖整个地形)

3.3 烘焙遮挡剔除数据
(1) 打开 Occlusion Culling 窗口,切换到 Bake 选项卡
(2) 设置烘焙参数(平衡性能和效果)
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Smallest Occluder | 10 米 | 过滤过小的遮挡物(如小石子、草,减少无效计算) |
| Smallest Hole | 2 米 | 过滤过小的孔洞(如地形缝隙,避免误判为 “可穿透”) |
| Backface Threshold | 95 | 控制背面剔除敏感度(值越高,背面剔除越严格,性能越好) |
(3) 执行烘焙:点击 Bake 按钮
3.4 验证遮挡效果
(1) 切换到 Visualization 选项卡,选择场景中的主摄像机(需确保摄像机有 Camera 组件)
(2) 可视化遮挡效果:点击 Visualize 按钮,在 Scene 视图中
- 绿色区域:当前摄像机可见的范围(未被遮挡)。
- 灰色区域:被遮挡剔除的范围(不会被渲染)
六、天空与光照控制系统
1、天空盒材质
(1) 创建 DynamicSkybox 天空盒材质,Sky Tint :#95B7DD,Ground:#6C80A4

2、昼夜光照变化
(1) 给 Directional Light 添加 Assets/Scripts/Environment/SunManager.cs
using UnityEngine;
using System;[RequireComponent(typeof(Light))]
public class SunManager : MonoBehaviour
{[Header("地理位置设置")][Range(0f, 90f)][SerializeField] private float latitude = 39.9f; // 北京实际纬度[Range(-180f, 180f)][SerializeField] private float longitude = 116.4f;[Header("时间管理器")]private TimeManager timeManager;[Header("光照参数")][Range(0f, 2f)][SerializeField] private float sunIntensity = 1.8f; // 太阳强度(山谷场景适配)[Range(0f, 0.5f)][SerializeField] private float moonIntensity = 0.3f; // 夜晚环境光[Range(0f, 1f)][SerializeField] private float ambientIntensity = 0.3f;[Header("阴影淡出系统")][Range(5f, 30f)][SerializeField] private float shadowFadeStartAltitude = 25f;[Range(0f, 15f)][SerializeField] private float shadowFadeEndAltitude = 8f;[Range(0f, 2f)][SerializeField] private float shadowFadeDuration = 1.2f;[Range(0f, 1f)][SerializeField] private float minShadowStrength = 0.1f;[Header("季节阴影调整")][SerializeField] private bool enableSeasonalShadowAdjustment = true;[Range(-5f, 5f)][SerializeField] private float summerShadowOffset = -2f;[Range(-5f, 5f)][SerializeField] private float winterShadowOffset = 2f;[Header("大气散射")][Range(0f, 0.1f)][SerializeField] private float rayleighScattering = 0.033f;[Range(0f, 0.1f)][SerializeField] private float mieScattering = 0.002f; [Range(0.5f, 5f)][SerializeField] private float atmosphereThickness = 1.0f; [Header("季节调整")][Range(-30f, 0f)][SerializeField] private float winterSunOffset = 5f; // 冬季纬度北移[Range(-30f, 30f)][SerializeField] private float summerSunOffset = -5f; // 夏季纬度南移[Header("天空盒设置")][SerializeField] private Material proceduralSkybox;[Range(0.01f, 0.2f)][SerializeField] private float sunSize = 0.04f;[Range(1f, 20f)][SerializeField] private float sunConvergence = 5f;[Header("时间阶段设置")][Range(10f, 70f)][SerializeField] private float eveningStartAltitude = 60f; [Range(-10f, 5f)][SerializeField] private float sunsetStartAltitude = 10f; [Range(-10f, 0f)][SerializeField] private float nightStartAltitude = -3f;[Header("环境光调整")][Range(0f, 2f)][SerializeField] private float duskAmbientBoost = 1.3f;[Range(0f, 1f)][SerializeField] private float minAmbientAtDusk = 0.5f;[Header("调试选项")][SerializeField] private bool showDebugInfo = false;#region 配置只读属性(外部仅能读取)public float Latitude => latitude;public float Longitude => longitude;public float SunIntensity => sunIntensity;public float MoonIntensity => moonIntensity;public float AmbientIntensity => ambientIntensity;public float ShadowFadeStartAltitude => shadowFadeStartAltitude;public float ShadowFadeEndAltitude => shadowFadeEndAltitude;public float ShadowFadeDuration => shadowFadeDuration;public float MinShadowStrength => minShadowStrength;public bool EnableSeasonalShadowAdjustment => enableSeasonalShadowAdjustment;public float SummerShadowOffset => summerShadowOffset;public float WinterShadowOffset => winterShadowOffset;public float WinterSunOffset => winterSunOffset;public float SummerSunOffset => summerSunOffset;public float RayleighScattering => rayleighScattering;public float MieScattering => mieScattering;public float AtmosphereThickness => atmosphereThickness;public Material ProceduralSkybox => proceduralSkybox;public float SunSize => sunSize;public float SunConvergence => sunConvergence;public float EveningStartAltitude => eveningStartAltitude;public float SunsetStartAltitude => sunsetStartAltitude;public float NightStartAltitude => nightStartAltitude;public float DuskAmbientBoost => duskAmbientBoost;public float MinAmbientAtDusk => minAmbientAtDusk;public bool ShowDebugInfo => showDebugInfo;#endregionpublic float SolarAltitude { get; private set; }public float SolarAzimuth { get; private set; }public bool IsNight { get; private set; }public bool IsEvening { get; private set; }public bool IsSunset { get; private set; }public bool IsDusk { get; private set; }public float CurrentShadowStrength { get; private set; }public bool ShadowsEnabled { get; private set; }private Light sunLight;private float originalLatitude;private bool wasNight;private bool wasEvening;private bool wasSunset;private bool wasDusk;private float lastSolarAltitude = 0f;private float shadowFadeStartTime = 0f;private bool isShadowFading = false;private const float axialTilt = 23.44f;void Start(){sunLight = GetComponent<Light>();originalLatitude = latitude;timeManager = TimeManager.Instance;if (proceduralSkybox == null && RenderSettings.skybox != null){proceduralSkybox = RenderSettings.skybox;}RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Trilight;if (TimeManager.Instance != null){UpdateSeasonalEffects(TimeManager.Instance.CurrentDateTime.Month);UpdateSunPosition();}wasNight = IsNight;wasEvening = false;wasSunset = false;wasDusk = false;lastSolarAltitude = SolarAltitude;ShadowsEnabled = true;CurrentShadowStrength = 1f;UpdateSunVisualSize();}private void OnEnable(){if (this == null) return;if (Singleton<EventCenter>.IsApplicationQuitting()) return;var eventCenter = EventCenter.Instance;if (eventCenter != null){eventCenter.Subscribe<float>(EventNameConst.OnTimeOfDayChanged, OnTimeOfDayChanged);eventCenter.Subscribe<bool>(EventNameConst.OnDayNightCycleChanged, OnDayNightCycleChanged);OnTimeOfDayChanged(0f);}else{Debug.LogError("SunManager: EventCenter.Instance 为空,无法订阅事件");}}private void OnDisable(){if (this == null) return;if (Singleton<EventCenter>.IsApplicationQuitting()) return;var eventCenter = EventCenter.Instance;if (eventCenter != null){eventCenter.Unsubscribe<float>(EventNameConst.OnTimeOfDayChanged, OnTimeOfDayChanged);eventCenter.Unsubscribe<bool>(EventNameConst.OnDayNightCycleChanged, OnDayNightCycleChanged);}}void OnDestroy(){}#region EventCenter 事件处理private void OnTimeOfDayChanged(float currentTime){UpdateSunPosition();UpdateLighting();if (TimeManager.Instance != null && showDebugInfo){var currentDateTime = TimeManager.Instance.CurrentDateTime;if (currentDateTime.Hour == 12 && currentDateTime.Minute == 0){PredictSunriseSunsetTimes(currentDateTime.DayOfYear);LogSeasonalShadowInfo();}}}private void OnDayNightCycleChanged(bool isDay){if (showDebugInfo){Debug.Log($"昼夜切换: {(isDay ? "白天" : "黑夜")}");}if (!isDay && ShadowsEnabled){CheckShadowFadeStart();}}#endregionvoid Update(){CheckTimePhaseTransitions();UpdateShadowFade();}#region 时间管理器事件处理private void OnHourChanged(DateTime dateTime){if (showDebugInfo && dateTime.Hour == 12){PredictSunriseSunsetTimes(dateTime.DayOfYear);LogSeasonalShadowInfo();}}private void OnDayChanged(DateTime dateTime){UpdateSeasonalEffects(dateTime.Month);if (showDebugInfo){PredictSunriseSunsetTimes(dateTime.DayOfYear);}}private void OnSunPositionUpdate(float sunHeight){UpdateSunPosition();UpdateLighting();}#endregion#region 太阳位置计算private void UpdateSunPosition(){if (timeManager == null) return;float currentHour = timeManager.GetTotalHours();int currentDay = timeManager.GetDayOfYear();float solarDeclinationRad = CalculateSolarDeclination(currentDay);float solarTime = CalculateSolarTime(currentHour);float hourAngle = (solarTime - 12f) * 15f;float latRad = latitude * Mathf.Deg2Rad;float sinAltitude = Mathf.Sin(latRad) * Mathf.Sin(solarDeclinationRad) +Mathf.Cos(latRad) * Mathf.Cos(solarDeclinationRad) * Mathf.Cos(hourAngle * Mathf.Deg2Rad);sinAltitude = Mathf.Clamp(sinAltitude, -1f, 1f);SolarAltitude = Mathf.Asin(sinAltitude) * Mathf.Rad2Deg;float cosAzimuth = (Mathf.Sin(solarDeclinationRad) * Mathf.Cos(latRad) -Mathf.Cos(solarDeclinationRad) * Mathf.Sin(latRad) * Mathf.Cos(hourAngle * Mathf.Deg2Rad)) /Mathf.Cos(SolarAltitude * Mathf.Deg2Rad);cosAzimuth = Mathf.Clamp(cosAzimuth, -1f, 1f);SolarAzimuth = Mathf.Acos(cosAzimuth) * Mathf.Rad2Deg;if (hourAngle > 0) SolarAzimuth = 360f - SolarAzimuth;transform.rotation = Quaternion.Euler(SolarAltitude, SolarAzimuth, 0);IsNight = SolarAltitude < nightStartAltitude;CheckShadowFadeStart();// 关键调试日志:实时输出太阳高度(确认是否正常)if (showDebugInfo && (timeManager.CurrentDateTime.Hour >= 14 && timeManager.CurrentDateTime.Hour <= 16)){Debug.Log($"时间={timeManager.CurrentDateTime:HH:mm}, 纬度={latitude:F1}°, " +$"太阳高度={SolarAltitude:F1}°, 光照强度={sunLight.intensity:F2}");}}private float CalculateSolarDeclination(int dayOfYear){float dayAngle = 2f * Mathf.PI * (dayOfYear - 1) / 365f;return axialTilt * Mathf.Sin(2f * Mathf.PI * (284f + dayOfYear) / 365f) * Mathf.Deg2Rad;}private float CalculateSolarTime(float currentHour){float standardTime = currentHour;float timeZoneCorrection = (longitude / 15f) - 8f;standardTime -= timeZoneCorrection;int dayOfYear = timeManager.CurrentDateTime.DayOfYear;float B = (dayOfYear - 81) * 2f * Mathf.PI / 365f;float equationOfTime = 9.87f * Mathf.Sin(2f * B) - 7.53f * Mathf.Cos(B) - 1.5f * Mathf.Sin(B);return standardTime + equationOfTime / 60f;}private void PredictSunriseSunsetTimes(int dayOfYear){float solarDeclinationRad = CalculateSolarDeclination(dayOfYear);float latRad = latitude * Mathf.Deg2Rad;float cosHourAngle = -Mathf.Tan(latRad) * Mathf.Tan(solarDeclinationRad) - 0.0145f;cosHourAngle = Mathf.Clamp(cosHourAngle, -1f, 1f);if (Mathf.Abs(cosHourAngle) <= 1f){float hourAngle = Mathf.Acos(cosHourAngle) * Mathf.Rad2Deg;float sunriseSolarTime = 12f - hourAngle / 15f;float sunsetSolarTime = 12f + hourAngle / 15f;float sunriseLocal = ConvertSolarToLocalTime(sunriseSolarTime, dayOfYear);float sunsetLocal = ConvertSolarToLocalTime(sunsetSolarTime, dayOfYear);Debug.Log($"预测日出时间: {FormatTime(sunriseLocal)}, 日落时间: {FormatTime(sunsetLocal)}");}}private string FormatTime(float time){int hour = Mathf.FloorToInt(time);int minute = Mathf.FloorToInt((time - hour) * 60f);return $"{hour:00}:{minute:00}";}private float ConvertSolarToLocalTime(float solarTime, int dayOfYear){float timeZoneCorrection = (longitude / 15f) - 8f;float B = (dayOfYear - 81) * 2f * Mathf.PI / 365f;float equationOfTime = 9.87f * Mathf.Sin(2f * B) - 7.53f * Mathf.Cos(B) - 1.5f * Mathf.Sin(B);return solarTime - equationOfTime / 60f + timeZoneCorrection;}#endregion#region 阴影淡出系统private void CheckShadowFadeStart(){if (timeManager == null) return;float adjustedFadeStartAltitude = GetSeasonallyAdjustedShadowAltitude(shadowFadeStartAltitude);if (SolarAltitude <= adjustedFadeStartAltitude && !isShadowFading && ShadowsEnabled){StartShadowFade();}if (SolarAltitude > adjustedFadeStartAltitude && isShadowFading){ResetShadowFade();}}private void StartShadowFade(){isShadowFading = true;shadowFadeStartTime = timeManager.GetTotalHours();if (showDebugInfo){Debug.Log($"开始阴影淡出: 时间={timeManager.CurrentDateTime:HH:mm}, " +$"太阳高度={SolarAltitude:F1}°, 预计持续时间={shadowFadeDuration}小时");}}private void ResetShadowFade(){isShadowFading = false;CurrentShadowStrength = 1f;ShadowsEnabled = true;if (sunLight != null){sunLight.shadows = LightShadows.Soft;sunLight.shadowStrength = CurrentShadowStrength;}if (showDebugInfo){Debug.Log($"重置阴影: 太阳重新升高到{shadowFadeStartAltitude:F1}°以上");}}private void UpdateShadowFade(){if (!isShadowFading || timeManager == null) return;float adjustedFadeStartAltitude = GetSeasonallyAdjustedShadowAltitude(shadowFadeStartAltitude);float adjustedFadeEndAltitude = GetSeasonallyAdjustedShadowAltitude(shadowFadeEndAltitude);float timeProgress = CalculateShadowFadeTimeProgress();float altitudeProgress = CalculateShadowFadeAltitudeProgress(adjustedFadeStartAltitude, adjustedFadeEndAltitude);float fadeProgress = Mathf.Max(timeProgress, altitudeProgress);CurrentShadowStrength = Mathf.Lerp(1f, minShadowStrength, fadeProgress);if (sunLight != null){sunLight.shadowStrength = CurrentShadowStrength;if (CurrentShadowStrength <= minShadowStrength + 0.05f){sunLight.shadows = LightShadows.None;ShadowsEnabled = false;}else{sunLight.shadows = LightShadows.Soft;ShadowsEnabled = true;}}if (fadeProgress >= 1f){isShadowFading = false;if (showDebugInfo) Debug.Log("阴影淡出完成");}if (showDebugInfo && timeManager.CurrentDateTime.Minute % 15 == 0 && isShadowFading){Debug.Log($"阴影淡出: 进度={fadeProgress:F2}, 强度={CurrentShadowStrength:F2}, " +$"时间进度={timeProgress:F2}, 高度进度={altitudeProgress:F2}");}}private float CalculateShadowFadeTimeProgress(){float elapsedHours = timeManager.GetTotalHours() - shadowFadeStartTime;return Mathf.Clamp01(elapsedHours / shadowFadeDuration);}private float CalculateShadowFadeAltitudeProgress(float startAlt, float endAlt){if (SolarAltitude <= endAlt) return 1f;if (SolarAltitude >= startAlt) return 0f;return Mathf.Clamp01(1f - (SolarAltitude - endAlt) / (startAlt - endAlt));}private float GetSeasonallyAdjustedShadowAltitude(float baseAltitude){if (!enableSeasonalShadowAdjustment || timeManager == null)return baseAltitude;int month = timeManager.CurrentDateTime.Month;float seasonalOffset = 0f;if (month >= 6 && month <= 8){seasonalOffset = summerShadowOffset;}else if (month == 12 || month <= 2){seasonalOffset = winterShadowOffset;}else if (month == 3 || month == 4 || month == 9 || month == 10){if (month == 3 || month == 4)seasonalOffset = Mathf.Lerp(winterShadowOffset, 0f, (month == 3 ? 0.5f : 1f));elseseasonalOffset = Mathf.Lerp(0f, winterShadowOffset, (month == 9 ? 0.5f : 1f));}return baseAltitude + seasonalOffset;}#endregion#region 时间阶段管理private void CheckTimePhaseTransitions(){IsEvening = false;IsSunset = false;IsDusk = false;if (SolarAltitude < eveningStartAltitude && SolarAltitude > sunsetStartAltitude){IsEvening = true;}else if (SolarAltitude <= sunsetStartAltitude && SolarAltitude > nightStartAltitude){IsSunset = true;}else if (SolarAltitude <= nightStartAltitude && SolarAltitude > -12f){IsDusk = true;}if (IsEvening && !wasEvening){OnEveningStart();}else if (IsSunset && !wasSunset){OnSunsetStart();}else if (IsDusk && !wasDusk){OnDuskStart();}else if (IsNight && !wasNight){OnNightStart();}wasEvening = IsEvening;wasSunset = IsSunset;wasDusk = IsDusk;wasNight = IsNight;lastSolarAltitude = SolarAltitude;}private void OnEveningStart(){if (showDebugInfo) Debug.Log($"傍晚开始: {timeManager.CurrentDateTime:HH:mm}, 太阳高度: {SolarAltitude:F1}°");}private void OnSunsetStart(){if (showDebugInfo) Debug.Log($"日落开始: {timeManager.CurrentDateTime:HH:mm}, 太阳高度: {SolarAltitude:F1}°");}private void OnDuskStart(){if (showDebugInfo) Debug.Log($"黄昏开始: {timeManager.CurrentDateTime:HH:mm}, 太阳高度: {SolarAltitude:F1}°");}private void OnNightStart(){if (showDebugInfo) Debug.Log($"夜晚开始: {timeManager.CurrentDateTime:HH:mm}, 太阳高度: {SolarAltitude:F1}°");}#endregion#region 光照与大气效果private void UpdateLighting(){if (sunLight == null) return;float intensity = CalculateSimpleIntensity();Color lightColor = CalculateSimpleColor();float ambient = CalculateSimpleAmbient();sunLight.intensity = intensity;sunLight.color = lightColor;RenderSettings.ambientIntensity = ambient;UpdateSunVisualSize();UpdateAtmosphericScattering();}private float CalculateSimpleIntensity(){if (IsNight){return 0f;}float baseIntensity = sunIntensity * Mathf.Clamp01(SolarAltitude / 90f * 1.2f);if (IsEvening || IsSunset){float duskFactor = Mathf.Clamp01((SolarAltitude - nightStartAltitude) / (eveningStartAltitude - nightStartAltitude));baseIntensity = Mathf.Max(baseIntensity, sunIntensity * 0.4f * duskFactor);}return baseIntensity;}private Color CalculateSimpleColor(){if (IsNight){return new Color(0.7f, 0.8f, 1f);}Color baseColor = new Color(1f, 0.95f, 0.85f);if (IsEvening){float eveningFactor = Mathf.Clamp01((SolarAltitude - sunsetStartAltitude) / (eveningStartAltitude - sunsetStartAltitude));baseColor = Color.Lerp(new Color(1f, 0.9f, 0.8f), baseColor, eveningFactor);}else if (IsSunset){float sunsetFactor = Mathf.Clamp01((SolarAltitude - nightStartAltitude) / (sunsetStartAltitude - nightStartAltitude));baseColor = Color.Lerp(new Color(1f, 0.85f, 0.75f), new Color(1f, 0.9f, 0.8f), sunsetFactor);}else if (IsDusk){float duskFactor = Mathf.Clamp01((SolarAltitude + 12f) / (nightStartAltitude + 12f));baseColor = Color.Lerp(new Color(0.7f, 0.8f, 1f), new Color(1f, 0.85f, 0.75f), duskFactor);}return baseColor;}private float CalculateSimpleAmbient(){if (IsNight){return moonIntensity; }float baseAmbient = ambientIntensity;float altitudeFactor = Mathf.Clamp01((SolarAltitude + 6f) / 30f);baseAmbient *= altitudeFactor;if (IsEvening || IsSunset || IsDusk){baseAmbient = Mathf.Max(baseAmbient, minAmbientAtDusk);if (IsDusk){baseAmbient *= duskAmbientBoost;}}return baseAmbient;}private void UpdateSunVisualSize(){if (proceduralSkybox == null) return;float visualSize = sunSize;if (SolarAltitude > -5f && SolarAltitude < 10f){float sizeFactor = 1.2f - Mathf.Abs(SolarAltitude - 2.5f) / 7.5f;visualSize = sunSize * Mathf.Clamp(sizeFactor, 1f, 1.5f);}else if (SolarAltitude < -5f){visualSize = 0f;}proceduralSkybox.SetFloat("_SunSize", visualSize);proceduralSkybox.SetFloat("_SunSizeConvergence", sunConvergence);}private void UpdateAtmosphericScattering(){float scatteringIntensity = Mathf.Clamp01(1f - Mathf.Abs(SolarAltitude) / 90f);float rayleighFactor = rayleighScattering * scatteringIntensity;Color skyColor = new Color(0.5f * rayleighFactor, 0.7f * rayleighFactor, 1f * rayleighFactor);float mieFactor = mieScattering * scatteringIntensity;Color horizonColor = skyColor;if (IsEvening){float eveningFactor = Mathf.Clamp01((SolarAltitude - sunsetStartAltitude) / (eveningStartAltitude - sunsetStartAltitude));horizonColor = Color.Lerp(new Color(0.8f, 0.7f, 0.6f), skyColor, eveningFactor);}else if (IsSunset){float sunsetFactor = Mathf.Clamp01((SolarAltitude - nightStartAltitude) / (sunsetStartAltitude - nightStartAltitude));horizonColor = Color.Lerp(new Color(0.7f, 0.6f, 0.5f), new Color(0.8f, 0.7f, 0.6f), sunsetFactor);}else if (IsDusk){float duskFactor = Mathf.Clamp01((SolarAltitude + 12f) / (nightStartAltitude + 12f));horizonColor = Color.Lerp(skyColor, new Color(0.7f, 0.6f, 0.5f), duskFactor);}// 白天/黄昏设置if (SolarAltitude > 0 || IsEvening || IsSunset || IsDusk){RenderSettings.ambientSkyColor = skyColor;RenderSettings.ambientEquatorColor = Color.Lerp(skyColor, horizonColor, 0.5f);RenderSettings.ambientGroundColor = new Color(0.2f, 0.2f, 0.2f); RenderSettings.fog = true;RenderSettings.fogColor = horizonColor;RenderSettings.fogDensity = mieFactor * atmosphereThickness * 0.0005f;}else // 夜晚设置{RenderSettings.fog = false; // 关闭全局雾Color nightSkyColor = new Color(0.08f, 0.15f, 0.3f); // 深蓝色(可调整RGB微调深浅)// 环境光仅作用于天空盒,地面环境光去蓝色RenderSettings.ambientSkyColor = nightSkyColor;RenderSettings.ambientEquatorColor = nightSkyColor * 0.8f;RenderSettings.ambientGroundColor = new Color(0.1f, 0.1f, 0.15f); // 地面弱光// 程序化天空盒单独设置(不影响地形)if (proceduralSkybox != null){proceduralSkybox.SetColor("_SkyColor", nightSkyColor);proceduralSkybox.SetFloat("_SunSize", 0f);proceduralSkybox.SetColor("_HorizonColor", nightSkyColor * 0.7f);proceduralSkybox.SetFloat("_FogDensity", 0.0001f); // 仅天空盒内部弱雾}}}#endregion#region 季节与辅助功能private void UpdateSeasonalEffects(int month){latitude = originalLatitude;if (month >= 11 || month <= 2){latitude += winterSunOffset; // 冬季:纬度+5f(北移)}else if (month >= 5 && month <= 8){latitude += summerSunOffset; // 夏季:纬度-5f(南移)}if (showDebugInfo){Debug.Log($"季节更新: 月份={month}, 调整后纬度={latitude:F1}°");}}private void LogSeasonalShadowInfo(){if (!enableSeasonalShadowAdjustment || timeManager == null) return;float adjustedStart = GetSeasonallyAdjustedShadowAltitude(shadowFadeStartAltitude);float adjustedEnd = GetSeasonallyAdjustedShadowAltitude(shadowFadeEndAltitude);Debug.Log($"季节阴影调整: 淡出起始={adjustedStart:F1}°, 淡出结束={adjustedEnd:F1}°, " +$"月份={timeManager.CurrentDateTime.Month}");}public float SeasonFactor{get{if (timeManager == null) return 0.5f;int month = timeManager.CurrentDateTime.Month;return Mathf.Abs(month - 6) / 6f;}}public float NightFactor{get => Mathf.Clamp01((-SolarAltitude - 6f) / 18f);}public void SetTimeForDebugging(int hour, int minute = 0){if (timeManager == null) return;TimeManager.Instance.SetTime(hour, minute);}public string GetLightingStateInfo(){return $"太阳高度: {SolarAltitude:F1}°, 强度: {sunLight.intensity:F2}, " +$"阴影: {(ShadowsEnabled ? "启用" : "禁用")}, 阴影强度: {CurrentShadowStrength:F2}";}public void StartManualShadowFade(){StartShadowFade();}public void ResetManualShadowFade(){ResetShadowFade();}#endregionprivate void OnDrawGizmosSelected(){if (!Application.isPlaying || timeManager == null) return;if (ShadowsEnabled){Gizmos.color = Color.Lerp(Color.red, Color.green, CurrentShadowStrength);}else{Gizmos.color = Color.gray;}Gizmos.DrawSphere(transform.position + transform.forward * 10f, 0.5f);float adjustedStart = GetSeasonallyAdjustedShadowAltitude(shadowFadeStartAltitude);float adjustedEnd = GetSeasonallyAdjustedShadowAltitude(shadowFadeEndAltitude);Gizmos.color = Color.yellow;Vector3 startPosition = transform.position + Vector3.up * adjustedStart;Gizmos.DrawWireCube(startPosition, new Vector3(3f, 0.1f, 3f));Gizmos.color = Color.red;Vector3 endPosition = transform.position + Vector3.up * adjustedEnd;Gizmos.DrawWireCube(endPosition, new Vector3(3f, 0.1f, 3f));Gizmos.color = Color.white;Gizmos.DrawLine(startPosition, endPosition);}
}(2) 将 DynamicSkybox 材质拖到天空盒设置: Procedural Skybox 字段

(3) 设置 Render Settings

- Window > Rendering > Lighting Settings
- 将
DynamicSkybox拖到 Environment 的 Skybox Material
七、夜间视觉管理
1、可见性控制脚本
(1) 基类 : Assets/Scripts/Environment/BaseParticleController.cs
using UnityEngine;[RequireComponent(typeof(ParticleSystem))]
public abstract class BaseParticleController : MonoBehaviour
{[Header("可见性设置")]public bool visibleAtNight = true;public bool visibleAtDay = false;[Header("过渡时间设置(分钟)")][Tooltip("日出前多少分钟开始消失")]public float disappearBeforeSunrise = 30f;[Tooltip("日落后多少分钟开始出现")]public float appearAfterSunset = 30f;protected ParticleSystem particleSys;protected ParticleSystemRenderer particleRenderer;protected bool isDayTime;protected Vector3 originalPosition;protected float currentSunHeight;protected bool isInTransition;protected virtual void Awake(){particleSys = GetComponent<ParticleSystem>();particleRenderer = GetComponent<ParticleSystemRenderer>();originalPosition = transform.position;SetParticleState(false);}protected virtual void OnEnable(){// 自身实例检查(避免组件已销毁但仍执行)if (this == null) return;// 退出阶段跳过订阅(避免应用退出时获取实例)if (Singleton<TimeManager>.IsApplicationQuitting()) return;if (TimeManager.Instance != null){isDayTime = TimeManager.Instance.IsDayTime;// 订阅事件(绑定)TimeManager.Instance.OnDayNightChange += HandleDayNightChange;TimeManager.Instance.OnSunPositionUpdate += HandleSunPositionUpdate;TimeManager.Instance.OnTimeProgressUpdate += HandleTimeProgressUpdate;// 初始化状态HandleDayNightChange(isDayTime);}else{Debug.LogWarning($"[{gameObject.name}] 未找到 TimeManager 实例!粒子无法响应昼夜变化", this);}}protected virtual void OnDisable(){// 自身实例检查if (this == null) return;// 退出阶段跳过解绑(避免应用退出时获取实例)if (Singleton<TimeManager>.IsApplicationQuitting()) return;if (TimeManager.Instance != null){// 解绑事件(必须和订阅一一对应,避免内存泄漏)TimeManager.Instance.OnDayNightChange -= HandleDayNightChange;TimeManager.Instance.OnSunPositionUpdate -= HandleSunPositionUpdate;TimeManager.Instance.OnTimeProgressUpdate -= HandleTimeProgressUpdate;}}protected virtual void OnDestroy(){// 优先判断是否在退出中,再判断实例是否存在(避免获取实例失败警告)if (Singleton<TimeManager>.IsApplicationQuitting()) return;if (this == null) return;// 双重保险:再次解绑(防止OnDisable因异常未执行)if (TimeManager.Instance != null){TimeManager.Instance.OnDayNightChange -= HandleDayNightChange;TimeManager.Instance.OnSunPositionUpdate -= HandleSunPositionUpdate;TimeManager.Instance.OnTimeProgressUpdate -= HandleTimeProgressUpdate;}}protected virtual void HandleDayNightChange(bool isDayTime){this.isDayTime = isDayTime;UpdateParticleStateBasedOnTime();}protected virtual void HandleSunPositionUpdate(float sunHeight){currentSunHeight = sunHeight;UpdateParticleStateBasedOnTime();}protected virtual void HandleTimeProgressUpdate(float progress){// 子类可以重写这个方法来实现基于时间进度的效果}protected virtual void UpdateParticleStateBasedOnTime(){if (TimeManager.Instance == null) return;float currentHour = TimeManager.Instance.GetTotalHours();float sunriseHour = TimeManager.Instance.SunriseHour;float sunsetHour = TimeManager.Instance.SunsetHour;// 转换为小时float disappearBeforeSunriseHours = disappearBeforeSunrise / 60f;float appearAfterSunsetHours = appearAfterSunset / 60f;bool shouldBeActive = CalculateShouldBeActive(currentHour, sunriseHour, sunsetHour,disappearBeforeSunriseHours, appearAfterSunsetHours);SetParticleState(shouldBeActive);}protected virtual bool CalculateShouldBeActive(float currentHour, float sunriseHour,float sunsetHour, float disappearBeforeSunriseHours, float appearAfterSunsetHours){// 处理跨天的情况if (sunriseHour > sunsetHour){// 日出在第二天if (currentHour >= sunriseHour - disappearBeforeSunriseHours ||currentHour <= sunsetHour + appearAfterSunsetHours){return visibleAtNight;}else if (currentHour > sunsetHour + appearAfterSunsetHours &¤tHour < sunriseHour - disappearBeforeSunriseHours){return visibleAtDay;}}else{// 正常情况if (currentHour >= sunsetHour + appearAfterSunsetHours ||currentHour <= sunriseHour - disappearBeforeSunriseHours){return visibleAtNight;}else if (currentHour > sunriseHour - disappearBeforeSunriseHours &¤tHour < sunsetHour + appearAfterSunsetHours){return visibleAtDay;}}return false;}protected virtual void SetParticleState(bool active){if (active && !particleSys.isPlaying){particleSys.Play();}else if (!active && particleSys.isPlaying){particleSys.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);}if (particleRenderer != null){particleRenderer.enabled = active;}}[ContextMenu("调试:切换粒子状态")]public void ToggleParticles(){SetParticleState(!particleSys.isPlaying);}[ContextMenu("调试:刷新粒子状态")]public void RefreshState(){if (TimeManager.Instance != null){HandleDayNightChange(TimeManager.Instance.IsDayTime);}}
}(2) 视野外不渲染
using UnityEngine;public class ParticleCulling : MonoBehaviour
{[Header("视野剔除设置")][Tooltip("检测间隔(秒)")][SerializeField, Range(0.01f, 0.5f)] private float checkInterval = 0.05f; [Tooltip("边界比例")][SerializeField] private float boundsExpandRatio = 1.2f; // 关键2:扩大检测边界,提前显示[Tooltip("透明度渐变时间(秒)")][SerializeField] private float fadeTime = 0.3f; // 关键3:添加渐变过渡[Tooltip("手动设置粒子边界(基于粒子Shape模块)")]public Bounds manualParticleBounds;private Camera mainCamera;private ParticleSystem particleSys;private ParticleSystemRenderer particleRenderer;private bool lastInViewState;private float lastCheckTime;private Transform cameraTransform;private float currentAlpha; // 渐变用透明度private ParticleSystem.MainModule mainModule; // 粒子主模块(控制透明度)private void Start(){InitializeComponents();// 初始化透明度(默认隐藏)currentAlpha = 0f;UpdateParticleAlpha();}private void InitializeComponents(){particleSys = GetComponent<ParticleSystem>();particleRenderer = GetComponent<ParticleSystemRenderer>();mainModule = particleSys.main; // 获取主模块(控制颜色/透明度)if (particleSys == null){Debug.LogError("ParticleCulling: 未找到 ParticleSystem 组件!", this);enabled = false;return;}if (particleRenderer == null){Debug.LogError("ParticleCulling: 未找到 ParticleSystemRenderer 组件!", this);enabled = false;return;}mainCamera = Camera.main;cameraTransform = mainCamera?.transform;lastInViewState = IsInCameraFrustum();// 初始不暂停粒子,只隐藏透明度(避免启动延迟)particleSys.Play();particleRenderer.enabled = true;}private void Update(){if (Time.time - lastCheckTime >= checkInterval){PerformCullingCheck();lastCheckTime = Time.time;}// 实时更新透明度渐变if (lastInViewState && currentAlpha < 1f){currentAlpha = Mathf.MoveTowards(currentAlpha, 1f, Time.deltaTime / fadeTime);UpdateParticleAlpha();}else if (!lastInViewState && currentAlpha > 0f){currentAlpha = Mathf.MoveTowards(currentAlpha, 0f, Time.deltaTime / fadeTime);UpdateParticleAlpha();}}private void PerformCullingCheck(){if (!EnsureCameraReference()) return;bool isInView = IsInCameraFrustum();if (isInView != lastInViewState){lastInViewState = isInView;}}private bool IsInCameraFrustum(){if (mainCamera == null || particleRenderer == null)return false;// 扩大手动边界(提前检测到视野内,预留渐变时间)Bounds bounds = manualParticleBounds.size != Vector3.zero ? manualParticleBounds : particleRenderer.bounds;bounds.Expand(boundsExpandRatio); // 按比例扩大边界if (bounds.size == Vector3.zero){Vector3 viewportPoint = mainCamera.WorldToViewportPoint(transform.position);return viewportPoint.x >= -0.2 && viewportPoint.x <= 1.2 && // 扩大视口检测范围viewportPoint.y >= -0.2 && viewportPoint.y <= 1.2 &&viewportPoint.z > 0;}Plane[] frustumPlanes = GeometryUtility.CalculateFrustumPlanes(mainCamera);return GeometryUtility.TestPlanesAABB(frustumPlanes, bounds);}// 控制粒子透明度(渐变核心)private void UpdateParticleAlpha(){// 保留粒子原有颜色,只修改透明度Color originalColor = mainModule.startColor.color;originalColor.a = currentAlpha;mainModule.startColor = originalColor;// 透明度为0时,禁用渲染(优化性能)particleRenderer.enabled = currentAlpha > 0.01f;}#region 调试工具(保留)[ContextMenu("刷新视野检测")]public void RefreshCulling(){if (!EnsureCameraReference()) return;bool isInView = IsInCameraFrustum();lastInViewState = isInView;currentAlpha = isInView ? 1f : 0f;UpdateParticleAlpha();}private void OnDrawGizmosSelected(){if (!Application.isPlaying) return;if (particleRenderer != null){Gizmos.color = lastInViewState ? Color.green : Color.red;Bounds bounds = manualParticleBounds.size != Vector3.zero ? manualParticleBounds : particleRenderer.bounds;bounds.Expand(boundsExpandRatio); // 绘制扩大后的边界Gizmos.DrawWireCube(bounds.center, bounds.size);#if UNITY_EDITORUnityEditor.Handles.Label(transform.position, $"视野内: {lastInViewState}\n透明度: {currentAlpha:F2}");
#endif}}#endregion// 其他方法(EnsureCameraReference)private bool EnsureCameraReference(){if (mainCamera != null && cameraTransform != null) return true;mainCamera = Camera.main;cameraTransform = mainCamera?.transform;return mainCamera != null;}
}2、萤火虫
(1) 萤火虫预制体
① 资产来源:"D:\Unity\Using\Fireflies.unitypackage"
② Shader更改
Shader "Universal Render Pipeline/Custom/Firefly" {Properties {_Color ("Color", Color) = (1,1,1,1)[HDR]_EmissionColor ("Emission Color", Color) = (1,1,1,1)_MainTex ("Albedo (RGB)", 2D) = "white" {}_Emission ("Emission Map", 2D) = "white" {}_Normal ("Normal Map", 2D) = "bump" {}_Glossiness ("Smoothness", Range(0,1)) = 0.5_Specular ("Specular", Range(0,1)) = 0.0_Wings ("Wings (RGBA)", 2D) = "white" {}_WingGloss ("Wing Smoothness", Range(0,1)) = 0.5_WingSpecular ("Wing Specular", Range(0,1)) = 0.0}SubShader {Tags {"Queue"="Transparent" "RenderType"="Transparent""RenderPipeline"="UniversalPipeline"}LOD 200Pass {Name "ForwardLit"Tags { "LightMode"="UniversalForward" }Blend SrcAlpha OneMinusSrcAlphaZWrite OffCull OffHLSLPROGRAM#pragma vertex vert#pragma fragment frag#pragma multi_compile _ _MAIN_LIGHT_SHADOWS#pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE#pragma multi_compile_fog#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"struct Attributes {float4 positionOS : POSITION;float2 uv : TEXCOORD0;float3 normalOS : NORMAL;float4 tangentOS : TANGENT;float4 color : COLOR;};struct Varyings {float4 positionHCS : SV_POSITION;float2 uv : TEXCOORD0;float3 normalWS : TEXCOORD1;float4 color : COLOR;float fogFactor : TEXCOORD2;};TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex);TEXTURE2D(_Emission); SAMPLER(sampler_Emission);TEXTURE2D(_Normal); SAMPLER(sampler_Normal);TEXTURE2D(_Wings); SAMPLER(sampler_Wings);CBUFFER_START(UnityPerMaterial)float4 _Color;float4 _EmissionColor;float _Glossiness;float _Specular;float _WingGloss;float _WingSpecular;CBUFFER_ENDVaryings vert(Attributes IN) {Varyings OUT;OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);OUT.uv = IN.uv;OUT.normalWS = TransformObjectToWorldNormal(IN.normalOS);OUT.color = IN.color;OUT.fogFactor = ComputeFogFactor(OUT.positionHCS.z);return OUT;}half4 frag(Varyings IN) : SV_Target {// 采样基础纹理half4 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv) * _Color;half3 emission = SAMPLE_TEXTURE2D(_Emission, sampler_Emission, IN.uv).rgb * _EmissionColor.rgb;half3 normalTS = UnpackNormal(SAMPLE_TEXTURE2D(_Normal, sampler_Normal, IN.uv));// URP光照计算Light mainLight = GetMainLight();float3 lightDir = mainLight.direction;float NdotL = saturate(dot(normalize(IN.normalWS), lightDir));float3 diffuse = mainLight.color * NdotL;// 混合输出half4 finalColor;finalColor.rgb = albedo.rgb * diffuse + emission;finalColor.a = albedo.a * (1 - step(0.5, IN.color.r)); // 透明通道处理// 应用雾效finalColor.rgb = MixFog(finalColor.rgb, IN.fogFactor);return finalColor;}ENDHLSL}}FallBack "Universal Render Pipeline/Simple Lit"
}(2) 添加到场景
① 以Player为父物体,添加 Fireflies 预制体,Position.y改为1.5
② 给场景中的的 Fireflies 添加 Assets/Scripts/Environment/FireflyParticleController.cs
using UnityEngine;public class FireflyParticleController : BaseParticleController
{[Header("萤火虫行为设置")]public float maxDistanceFromPlayer = 20f;public float spawnRadius = 5f;[Tooltip("位置更新间隔(秒)")]public float positionUpdateInterval = 5f; // 增加间隔减少性能开销[Tooltip("最大萤火虫数量限制")]public int maxFireflyCount = 30;private Transform playerTransform;private float lastUpdateTime;private ParticleSystem.EmissionModule emissionModule;private float originalEmissionRate;protected override void Awake(){base.Awake();if (particleSys == null){Debug.LogWarning($"[{gameObject.name}] 未找到ParticleSystem组件!", this);return;}emissionModule = particleSys.emission;originalEmissionRate = emissionModule.rateOverTime.constant;AdjustEmissionRate();}private void Start(){if (Singleton<TimeManager>.IsApplicationQuitting()) return;GameObject player = GameObject.FindGameObjectWithTag("Player");if (player != null){playerTransform = player.transform;}else{Debug.LogWarning($"[{gameObject.name}] 未找到玩家对象!萤火虫将无法跟随玩家", this);}}private void Update(){if (Singleton<TimeManager>.IsApplicationQuitting() || !particleSys.isPlaying || playerTransform == null){return;}if (Time.time - lastUpdateTime >= positionUpdateInterval){UpdateFireflyPosition();lastUpdateTime = Time.time;}}protected override void SetParticleState(bool active){if (this == null || Singleton<TimeManager>.IsApplicationQuitting()) return;if (active && playerTransform != null){if (!particleSys.isPlaying){MoveToPlayerArea();particleSys.Play();}}else{if (particleSys.isPlaying){particleSys.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);}}if (particleRenderer != null){particleRenderer.enabled = active;}}protected override bool CalculateShouldBeActive(float currentHour, float sunriseHour,float sunsetHour, float disappearBeforeSunriseHours, float appearAfterSunsetHours){// 萤火虫只在夜晚显示,遵循基类规则return base.CalculateShouldBeActive(currentHour, sunriseHour, sunsetHour,disappearBeforeSunriseHours, appearAfterSunsetHours);}private void UpdateFireflyPosition(){if (playerTransform == null) return;float distanceToPlayer = Vector3.Distance(transform.position, playerTransform.position);if (distanceToPlayer > maxDistanceFromPlayer){MoveToPlayerArea();}}private void MoveToPlayerArea(){if (playerTransform == null || Singleton<TimeManager>.IsApplicationQuitting()) return;Vector3 randomOffset = new Vector3(Random.Range(-spawnRadius, spawnRadius),Random.Range(0, spawnRadius * 0.5f),Random.Range(-spawnRadius, spawnRadius));transform.position = playerTransform.position + randomOffset;}private void AdjustEmissionRate(){var mainModule = particleSys.main;float particleLifetime = mainModule.startLifetime.constant;float adjustedRate = maxFireflyCount / Mathf.Max(particleLifetime, 1f);emissionModule.rateOverTime = Mathf.Min(adjustedRate, originalEmissionRate);}[ContextMenu("重新定位萤火虫")]public void RepositionFireflies(){if (Singleton<TimeManager>.IsApplicationQuitting()) return;MoveToPlayerArea();}[ContextMenu("重置发射率")]public void ResetEmissionRate(){emissionModule.rateOverTime = originalEmissionRate;}private void OnValidate(){if (!Application.isPlaying || Singleton<TimeManager>.IsApplicationQuitting() || particleSys == null){return;}AdjustEmissionRate();}protected override void OnDestroy(){base.OnDestroy();// 子类清理:释放引用,双重保险停止粒子playerTransform = null;if (particleSys != null && particleSys.isPlaying){particleSys.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);}}
}
3、云层控制
(1) 给三个云层的子物体粒子云添加 ParticleCulling.cs组件
(2) 配置参数

| 云层名称 | manualParticleBounds 配置 | 配置依据说明 |
|---|---|---|
| Particle_Cloud_Atmos_A | Center:(1000, 164.1, -492)Extents:(750, 5, 750) | 1. 父物体位置直接作为中心点(粒子自身 Position 为 (0,0,0));2. 粒子 Shape 是 Box,Scale (1500,10,1500),Extents = Scale/2 |
| Particle_Cloud_Atmos_B | Center:(1409.3, 85.8, -508.4)Extents:(270.65, 2.5, 270.65) | 1. 父物体位置直接作为中心点;2. 粒子 Shape 是 Circle,Radius=54.13 × Scale=5 = 实际半径 270.65(X/Z 方向);3. Y 方向 Scale=5,Extents=5/2=2.5 |
| 第三块云(未命名,按之前配置) | Center:(578.4, 85.8, -508.4)Extents:(270.65, 2.5, 270.65) | 1. 父物体位置直接作为中心点;2. 粒子 Shape 与第二块云一致(Circle + 相同 Scale/Radius),故 Extents 相同 |
(3) 给 Particle_Cloud_Atmos_A的子物体添加 Scripts/Environment/CloudParticleController.cs
using UnityEngine;public class CloudParticleController : MonoBehaviour
{[Header("云层设置")][Tooltip("显示粒子")]public float startHour = 4f;[Tooltip("隐藏粒子")]public float endHour = 19.5f;[Header("透明度变化时间")][Tooltip("透明度增大开始时间")]public float morningFadeStart = 4f;[Tooltip("透明度增大结束时间")]public float morningFadeEnd = 4.1f;[Tooltip("上午透明度变化开始时间")]public float noonFadeStart = 10f;[Tooltip("上午透明度变化结束时间")]public float noonFadeEnd = 10.1f;[Tooltip("透明度下降开始时间")]public float afternoonFadeStart = 10.1f;[Tooltip("透明度下降结束时间")]public float afternoonFadeEnd = 10.2f;[Header("发射率变化时间")][Tooltip("发射率第一阶段变化开始时间")]public float emissionFade1Start = 9f;[Tooltip("发射率第一阶段变化结束时间")]public float emissionFade1End = 9.4f;[Tooltip("发射率第二阶段变化开始时间")]public float emissionFade2Start = 9.4f;[Tooltip("发射率第二阶段变化结束时间")]public float emissionFade2End = 9.5f;[Header("云层外观设置")][Tooltip("最小透明度")]public float minAlpha = 0.01f;[Tooltip("最大透明度")]public float maxAlpha = 0.4f;[Tooltip("最大发射率")]public float maxEmissionRate = 10f;[Tooltip("中等发射率")]public float midEmissionRate = 0.1f;[Header("调试")]public bool showDebugInfo = false;private ParticleSystem particleSys;private ParticleSystemRenderer particleRenderer;private ParticleSystem.EmissionModule emissionModule;private Color originalParticleColor;private float targetEmissionRate;private bool isActive = false;private float lastUpdateTime = -1f;private void Awake(){particleSys = GetComponent<ParticleSystem>();particleRenderer = GetComponent<ParticleSystemRenderer>();emissionModule = particleSys.emission;targetEmissionRate = emissionModule.rateOverTime.constant;// 保存原始颜色var mainModule = particleSys.main;if (mainModule.startColor.mode == ParticleSystemGradientMode.Color){originalParticleColor = mainModule.startColor.color;}// 初始状态:隐藏SetCloudState(false);}private void Start(){if (Singleton<TimeManager>.IsApplicationQuitting()) return;// 初始更新UpdateCloudState();}private void Update(){if (Time.time - lastUpdateTime >= 0.5f){UpdateCloudState();lastUpdateTime = Time.time;}}private void UpdateCloudState(){if (Singleton<TimeManager>.IsApplicationQuitting()){SetCloudState(false); // 退出时强制隐藏粒子return;}if (TimeManager.Instance == null) return;float currentHour = TimeManager.Instance.GetTotalHours();// 检查是否在显示时间内bool shouldBeActive = currentHour >= startHour && currentHour <= endHour;if (shouldBeActive){UpdateCloudAppearance(currentHour);}else{SetCloudState(false);}if (showDebugInfo && shouldBeActive){var mainModule = particleSys.main;float currentAlpha = mainModule.startColor.mode == ParticleSystemGradientMode.Color ?mainModule.startColor.color.a : 0f;float currentEmission = emissionModule.rateOverTime.constant;Debug.Log($"[Cloud] 时间: {currentHour:F2}, 透明度: {currentAlpha:F2}, " +$"发射率: {currentEmission:F2}, 激活: {isActive}, 粒子数: {particleSys.particleCount}");}}private void UpdateCloudAppearance(float currentHour){// 设置云层激活SetCloudState(true);// 更新透明度float alpha = CalculateAlpha(currentHour);UpdateAlpha(alpha);// 更新发射率float emission = CalculateEmission(currentHour);UpdateEmission(emission);}private float CalculateAlpha(float currentHour){// 早上透明度变化:4点到4.1点,从0到0.4if (currentHour >= morningFadeStart && currentHour <= morningFadeEnd){float progress = (currentHour - morningFadeStart) / (morningFadeEnd - morningFadeStart);return Mathf.Lerp(0f, maxAlpha, progress);}// 透明度变化:10点到10.1点,从0.4到0.01if (currentHour >= noonFadeStart && currentHour <= noonFadeEnd){float progress = (currentHour - noonFadeStart) / (noonFadeEnd - noonFadeStart);return Mathf.Lerp(maxAlpha, minAlpha, progress);}// 透明度变化:10.1点到10.2点,从0.01到0if (currentHour >= afternoonFadeStart && currentHour <= afternoonFadeEnd){float progress = (currentHour - afternoonFadeStart) / (afternoonFadeEnd - afternoonFadeStart);return Mathf.Lerp(minAlpha, 0f, progress);}// 其他时间保持当前阶段的透明度if (currentHour < morningFadeStart)return 0f; // 4点前不显示else if (currentHour <= noonFadeStart)return maxAlpha; // 4.1点到10点保持0.4else if (currentHour <= afternoonFadeStart)return minAlpha; // 10.1点前保持0.01elsereturn 0f; // 10.2点后透明度为0}private float CalculateEmission(float currentHour){// 4点到4.1点:发射率从0增加到10if (currentHour >= morningFadeStart && currentHour <= morningFadeEnd){float progress = (currentHour - morningFadeStart) / (morningFadeEnd - morningFadeStart);return Mathf.Lerp(0f, maxEmissionRate, progress);}// 4.1点到9点:保持最大发射率10if (currentHour > morningFadeEnd && currentHour < emissionFade1Start){return maxEmissionRate;}// 9点到9.4点:发射率从10降低到0.1if (currentHour >= emissionFade1Start && currentHour <= emissionFade1End){float progress = (currentHour - emissionFade1Start) / (emissionFade1End - emissionFade1Start);return Mathf.Lerp(maxEmissionRate, midEmissionRate, progress);}// 9.4点到9.5点:发射率从0.1降低到0if (currentHour >= emissionFade2Start && currentHour <= emissionFade2End){float progress = (currentHour - emissionFade2Start) / (emissionFade2End - emissionFade2Start);return Mathf.Lerp(midEmissionRate, 0f, progress);}// 其他时间if (currentHour < morningFadeStart)return 0f; // 4点前不发射else if (currentHour > emissionFade1End && currentHour < emissionFade2Start)return midEmissionRate; // 9.4点到9.5点保持0.1else if (currentHour > emissionFade2End && currentHour <= endHour)return 0f; // 9.5点到19.5点不发射elsereturn 0f; // 其他时间不发射}private void UpdateAlpha(float alpha){var mainModule = particleSys.main;if (mainModule.startColor.mode == ParticleSystemGradientMode.Color){Color color = originalParticleColor;color.a = alpha;mainModule.startColor = color;}}private void UpdateEmission(float emissionRate){emissionModule.rateOverTime = emissionRate;}private void SetCloudState(bool active){if (this == null) return;if (active != isActive){isActive = active;if (particleRenderer != null){particleRenderer.enabled = active;}if (active && !particleSys.isPlaying){particleSys.Play();}else if (!active && particleSys.isPlaying){particleSys.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);}}}// 退出时强制清理粒子protected virtual void OnDestroy(){// 应用退出时跳过(避免获取实例)if (Singleton<TimeManager>.IsApplicationQuitting()) return;// 强制停止粒子,释放资源SetCloudState(false);}[ContextMenu("调试云层状态")]public void DebugCloudState(){if (Singleton<TimeManager>.IsApplicationQuitting()) return;if (TimeManager.Instance == null) return;float currentHour = TimeManager.Instance.GetTotalHours();float alpha = CalculateAlpha(currentHour);float emission = CalculateEmission(currentHour);Debug.Log($"[Cloud Debug] 当前时间: {currentHour:F2}\n" +$"透明度: {alpha:F2}, 发射率: {emission:F2}\n" +$"激活: {isActive}, 粒子数: {particleSys.particleCount}");}[ContextMenu("强制刷新云层")]public void ForceRefreshCloud(){if (Singleton<TimeManager>.IsApplicationQuitting()) return;UpdateCloudState();}
}4、夜晚点光源
(1) Player上右键,Light-Point Light,Position.y = 0.2,调整Range为 5

(2) 给该点光源添加 PlayerFootLightController.cs
using UnityEngine;[RequireComponent(typeof(Light))]
public class PlayerFootLightController : MonoBehaviour
{[Header("光照设置")]public float nightIntensity = 1.5f;public float dayIntensity = 0.2f;public Color nightColor = new Color(0.8f, 0.9f, 1f, 1f); // 淡蓝色public Color dayColor = new Color(1f, 0.9f, 0.7f, 1f); // 暖黄色public bool disableInDay = true; [Header("过渡效果")]public float transitionSpeed = 2f;public float minDistanceFromGround = 0.2f;public float maxDistanceFromGround = 2f;[Header("射线检测设置")]public LayerMask groundLayer; // 手动指定地面层级public float raycastMaxDistance = 5f; // 增大射线长度,适配跳跃private Light footLight;private Transform playerTransform;private bool isDayTime;private float targetIntensity;private Color targetColor;private void Awake(){footLight = GetComponent<Light>();playerTransform = transform.parent;if (playerTransform == null){Debug.LogError("PlayerFootLightController:点光源必须作为玩家对象的子对象!");enabled = false; // 禁用脚本,避免后续空引用return;}// 初始状态(后续Start会根据TimeManager覆盖)footLight.intensity = nightIntensity;footLight.color = nightColor;footLight.enabled = true;// 校验地面层级(编辑器提醒)if (groundLayer.value == 0){Debug.LogWarning("PlayerFootLightController:请在Inspector中指定地面LayerMask!");}}private void Start(){if (TimeManager.Instance != null){isDayTime = TimeManager.Instance.IsDayTime;TimeManager.Instance.OnDayNightChange += HandleDayNightChange;TimeManager.Instance.OnSunPositionUpdate += HandleSunPositionUpdate;UpdateLightState(isDayTime);}else{Debug.LogWarning("PlayerFootLightController:未找到TimeManager实例,光照将保持初始夜间状态!");}}private void OnDestroy(){if (Singleton<TimeManager>.IsApplicationQuitting()) return;if (TimeManager.Instance != null){TimeManager.Instance.OnDayNightChange -= HandleDayNightChange;TimeManager.Instance.OnSunPositionUpdate -= HandleSunPositionUpdate;}}private void Update(){// 平滑过渡footLight.intensity = Mathf.Lerp(footLight.intensity, targetIntensity, Time.deltaTime * transitionSpeed);footLight.color = Color.Lerp(footLight.color, targetColor, Time.deltaTime * transitionSpeed);// 白天禁用光照(可选)if (isDayTime && disableInDay){footLight.enabled = false;}else{footLight.enabled = footLight.intensity > 0.01f; // 强度极低时也禁用,避免浪费性能}UpdateLightPosition();}private void HandleDayNightChange(bool isDayTime){this.isDayTime = isDayTime;UpdateLightState(isDayTime);}private void HandleSunPositionUpdate(float sunHeight){// 校验sunHeight范围(避免异常值)sunHeight = Mathf.Clamp01(sunHeight);float intensityModifier = 1f - sunHeight;targetIntensity = (isDayTime ? dayIntensity : nightIntensity) * intensityModifier;}private void UpdateLightState(bool isDay){if (isDay){targetIntensity = disableInDay ? 0 : dayIntensity; // 白天禁用则强度设为0targetColor = dayColor;}else{targetIntensity = nightIntensity;targetColor = nightColor;}}private void UpdateLightPosition(){if (playerTransform == null) return;Vector3 rayStart = playerTransform.position + Vector3.up * 1f;if (Physics.Raycast(rayStart, Vector3.down, out RaycastHit hit, raycastMaxDistance, groundLayer)){float intensityRatio = Mathf.InverseLerp(0, nightIntensity, targetIntensity);float distanceAboveGround = Mathf.Lerp(minDistanceFromGround, maxDistanceFromGround, intensityRatio);transform.position = hit.point + Vector3.up * distanceAboveGround;}else{// 未检测到地面时,跟随玩家脚下(添加Y偏移,避免陷进地面)transform.position = playerTransform.position + Vector3.up * minDistanceFromGround;}}// 调试方法:手动切换光照状态public void ToggleLight(){isDayTime = !isDayTime;UpdateLightState(isDayTime);}// 调试方法:强制更新状态public void RefreshLight(){if (TimeManager.Instance != null){isDayTime = TimeManager.Instance.IsDayTime;UpdateLightState(isDayTime);}}// 在编辑器中可视化射线检测private void OnDrawGizmosSelected(){if (playerTransform == null) return;// 射线可视化Gizmos.color = Color.yellow;Vector3 rayStart = playerTransform.position + Vector3.up * 1f;Gizmos.DrawLine(rayStart, rayStart + Vector3.down * raycastMaxDistance);// 光照位置可视化if (footLight != null){Gizmos.color = footLight.color;Gizmos.DrawWireSphere(transform.position, footLight.range * 0.1f);}}
}(3) 配置参数

八、星空
(一)基础星星粒子系统
1、发光材质
(1) 新建材质 `StarParticleMat`
(2) Shader选择 `Universal Render Pipeline/Particles/unlit`
(3) 参数设置:
① Surface Type: Transparent
② Blending Mode: Additive
③ Base Map : 选择Default Particle
④ Color Mode: Multiply
⑤ 启用 Emission,Emission Color : 淡蓝色 (0.5,0.7,1.0 #) Intensity(强度)2.0
2、粒子系统
(1) Effects → Particle System, 命名为 ProceduralStars
(2) 层级和Transform


(3) 添加脚本
① 给ProceduralStars 添加 Assets/Scripts/Environment/StarParticleController.cs组件
using UnityEngine;[RequireComponent(typeof(ParticleSystem))]
public class StarParticleController : BaseParticleController
{[Header("星星基础设置")]public bool isConstellation = false;[Range(0, 500)] public float maxEmissionRate = 100f;[Range(0, 1)] public float minBrightness = 0.1f;[Range(0, 5)] public float maxBrightness = 2.0f;public Color starColor = new Color(0.5f, 0.7f, 1.0f);[Header("星座专属设置")][Range(0, 50)] public float constellationMaxRate = 5f;[Range(18, 24)] public int startHour = 18;[Range(0, 6)] public int endHour = 6;[Range(0, 10)] public float fadeDuration = 2f;[Header("太阳高度淡出设置")]public float fadeStartHeight = 0.3f;public float fadeEndHeight = 0.5f;[Header("星光呼吸效果(仅主星星生效)")][Tooltip("呼吸速度(数值越小越慢)")][Range(0.1f, 2f)] public float breathSpeed = 0.5f;[Tooltip("最大亮度倍数(避免过曝)")][Range(1f, 3f)] public float maxBrightnessMultiplier = 1.8f;private Camera mainCamera;[Range(100f, 500f)] public float breathMaxDistance = 300f; // 超过300米不呼吸private ParticleSystem.EmissionModule emission;private Material starMat;private SunManager sunManager;private float targetEmissionRate;private float currentEmissionRate;private float lastBreathTime = 0f;private Color originalStarColor;private bool isBreathEnabled = true; // 控制呼吸效果是否启用protected override void Awake(){base.Awake();if (particleSys == null){Debug.LogError($"[{gameObject.name}] 未找到ParticleSystem组件!", this);enabled = false;return;}emission = particleSys.emission;originalStarColor = starColor;// 初始化材质(用于呼吸效果和亮度控制)if (particleRenderer != null && particleRenderer.material != null){starMat = particleRenderer.material;ConfigureRenderingOrder();}else{Debug.LogWarning($"[{gameObject.name}] 未找到材质,呼吸效果和亮度控制失效!", this);isBreathEnabled = false;}// 初始状态targetEmissionRate = 0;currentEmissionRate = 0;emission.rateOverTime = 0;// 星座不启用呼吸效果(仅主星星生效)if (isConstellation)isBreathEnabled = false;mainCamera = Camera.main;if (mainCamera == null){Debug.LogWarning($"[{gameObject.name}] 未找到主相机,呼吸效果的距离衰减功能失效!", this);}}protected override void OnEnable(){base.OnEnable();sunManager = FindObjectOfType<SunManager>();if (sunManager == null){Debug.LogError($"[{gameObject.name}] 未找到SunManager!星星渐变功能失效", this);}}protected override void OnDestroy(){base.OnDestroy();starMat = null;}private void Update(){// 发射率平滑过渡currentEmissionRate = Mathf.MoveTowards(currentEmissionRate,targetEmissionRate,(maxEmissionRate / fadeDuration) * Time.deltaTime);emission.rateOverTime = currentEmissionRate;// 星座特殊逻辑if (isConstellation && currentEmissionRate <= 0 && particleSys.isPlaying){particleSys.Stop(true, ParticleSystemStopBehavior.StopEmitting);}// 呼吸效果(仅夜晚+主星星+材质存在时生效)UpdateBreathEffect();}// 呼吸效果核心逻辑private void UpdateBreathEffect(){if (Vector3.Distance(transform.position, mainCamera.transform.position) > breathMaxDistance)return;if (Time.time - lastBreathTime < 0.05f) return; // 每0.05秒更新一次lastBreathTime = Time.time;if (!isBreathEnabled || starMat == null || !particleSys.isPlaying || IsDayTime())return;// 计算呼吸因子(0~1周期性波动)float breathFactor = Mathf.PingPong(Time.time * breathSpeed, 1.0f);// 计算最终亮度(基础亮度 + 呼吸波动)float baseEmissionValue = Mathf.Lerp(minBrightness, maxBrightness, GetNightBrightnessFactor());float finalEmissionValue = baseEmissionValue * (1 + (breathFactor * (maxBrightnessMultiplier - 1)));// 应用到材质starMat.SetColor("_EmissionColor", originalStarColor * finalEmissionValue);starMat.EnableKeyword("_EMISSION");}// 获取夜晚亮度因子(结合太阳高度)private float GetNightBrightnessFactor(){if (sunManager == null) return 1f;float fadeFactor = Mathf.Clamp01((sunManager.SolarAltitude - fadeStartHeight) / (fadeEndHeight - fadeStartHeight));return 1f - fadeFactor;}// 判断是否为白天(用于关闭呼吸效果)private bool IsDayTime(){return sunManager != null ? sunManager.SolarAltitude >= fadeEndHeight : false;}protected override void HandleSunPositionUpdate(float sunHeight){base.HandleSunPositionUpdate(sunHeight);if (sunManager == null || starMat == null) return;// 太阳高度淡出逻辑float fadeFactor = Mathf.Clamp01((currentSunHeight - fadeStartHeight) / (fadeEndHeight - fadeStartHeight));float brightnessFactor = 1f - fadeFactor;if (particleRenderer.enabled){// 更新基础亮度(结合呼吸效果的基础值)float emissionValue = Mathf.Lerp(minBrightness, maxBrightness, brightnessFactor);starMat.SetColor("_EmissionColor", originalStarColor * emissionValue);starMat.EnableKeyword("_EMISSION");// 更新粒子透明度var mainModule = particleSys.main;Color particleColor = mainModule.startColor.color;particleColor.a = brightnessFactor;mainModule.startColor = particleColor;}}protected override bool CalculateShouldBeActive(float currentHour, float sunriseHour,float sunsetHour, float disappearBeforeSunriseHours, float appearAfterSunsetHours){if (isConstellation){return currentHour >= startHour || currentHour < endHour;}return base.CalculateShouldBeActive(currentHour, sunriseHour, sunsetHour,disappearBeforeSunriseHours, appearAfterSunsetHours);}protected override void SetParticleState(bool active){base.SetParticleState(active);targetEmissionRate = active ? (isConstellation ? constellationMaxRate : maxEmissionRate) : 0;if (active && particleSys.isPaused) particleSys.Play();}private void ConfigureRenderingOrder(){if (particleRenderer == null || particleRenderer.material == null) return;particleRenderer.material.renderQueue = 3001;particleRenderer.sortingOrder = 1;particleRenderer.material.SetInt("_ZWrite", 0);}[ContextMenu("刷新星星状态")]public void RefreshStarState(){RefreshState();if (sunManager != null){HandleSunPositionUpdate(sunManager.SolarAltitude);}}
}② 给ProceduralStars 添加 ParticleCulling.cs组件,视野外不渲染

3、参数配置
#DAF0FF
#80B2FF → #FFFFFF → #80B2FF ,204 →255 →204
#E6DCF7→ #FFFFFF →#E6DCF7, 200→ 255→200
主要参数
(1) Duration(持续时间):100000 (永远存在)
(2) StartSpeed:0.01 (粒子初始发生时候的速度)
(3) StartSize:0.3-0.5 (粒子初始的大小)
(4) StartColor:淡蓝色 (粒子初始颜色,可以调整加上渐变色 #DAF0FF)
(5) MaxParticles:75
(6) Start Rotation : 30
Emission
(1) RateOverTime:5 (单位时间生成的粒子数)
(2) Bursts
① Time:0(从第几秒开始)
② Count:1【在指定时间点(Time参数设定的时刻)一次性发射的粒子数量】
③ Cycles:1(在一个周期中循环的次数)
Shape
(1) Shape:Cone (发射体积的形状)
(2) Radius:20(形状的圆形半径)
(3) Radius Thickness:0.99(值越接近 1,粒子越贴近球体外表面)
(发射粒子的体积比例。0 表示从形状的外表面发射粒子。值为 1 表示从整个体积发射粒子)
或
Velocity over Lifetime (使粒子缓慢向外扩散,增强空间感)Linear: (0, 0, 0.1),speed:0.01
Inherit Velocity Multiplier:0.2(粒子应该继承的发射器速度的比例)
Color over Lifetime 粒子在其生命周期内的颜色渐变
① 设置:Alpha通道:轻微波动(0.8 → 1 → 0.8)
② 说明:渐变条的左侧点表示粒子寿命的开始,而渐变条的右侧表示粒子寿命的结束
③ 颜色渐变:淡蓝(0.5,0.7,1.0 #80B2FF) → 白(1,1,1) → 淡蓝
勾选 Random Between Two Colors,设置两个接近的颜色范围(如冷白→暖白)
Size over Lifetime
① 设置:选择 Random Between Two Constants 0.8~1.2
② 说明:每个粒子独立变化
或: - 模式: Curve - 曲线: 0.0(0.8) → 0.5(1.2) → 1.0(0.8)
Noise(模拟星光的轻微动态偏移)
① Strength:0.01~0.03(值越高,粒子移动越快和越远。)
② Frequency: 0.5
③ Scroll Speed: 0.1
Lights
① Ratio: 0.3
② Intensity: 0.5-1.0
③ Range: 5.0
RendererRender Mode 选 Billboard
Material 选择上面创建的自发光材质StarParticleMat
(二)添加星座点缀
1、新建子粒子系统
右键 →
Create Empty ChildConstellations,命名为 Constellations添加组件
Particle System
2、特殊参数设置
#C7D9D9 → #CC88FF
#C7A7DC→#FFFFFF → #C7A7DC, 178 → 255 → 178
Size over Lifetime:曲线:0.0(0.5) → 0.3(1.5) → 1.0(0.5)
(1) 主要参数
- Start Size: 0.02-0.04
- Start Color: 选择渐变颜色 #C7D9D9 → #CC88FF
- StartSpeed:0.01
- MaxParticles:50
(2) Emission
- Bursts: Count=5, Interval=0.01
(3) Shape:同父粒子
(4) Renderer
- Sorting Layer: Default
- Order in Layer: 1 (确保在父系统上层)
- 材质使用 `StarParticleMat`
(5) Color over Lifetime:
颜色渐变:紫(0.8,0.5,1.0) → 白 → 紫(C7A7DC), Alpha通道:0.7 → 1.0 → 0.7
(6) Size over Lifetime:曲线:0.0(0.5) → 0.3(1.5) → 1.0(0.5)
4、添加脚本
(1) 给子粒子系统 Constellations 添加 Assets/Scripts/Environment/StarParticleController.cs

勾选 Is Constellation

