第6章 次世代场景构建实战:《暗黑王朝》光影环境的全流程制作

在《暗黑王朝》这样一款写实风格的射击手游中,场景的视觉品质直接决定了游戏的沉浸感。玩家踏入游戏世界的第一眼,看到的不应该是生硬的几何体和简陋的贴图,而应该是一个有温度、有氛围、光影交错的真实空间。要实现这样的效果,仅仅依靠模型和贴图是远远不够的,必须引入完整的光影烘焙技术。本章将从最基础的模型制作规范开始,逐步深入到Unity的光照贴图烘焙、天空盒制作、摄影机特效等完整流程,为读者呈现一套完整的次世代场景构建方案。

6.1 移动端场景模型的制作规范与优化策略

6.1.1 手机游戏场景的建模思维转变

在PC或主机平台上,开发者可以相对自由地使用高面数模型和复杂的着色器,因为玩家拥有强大的GPU和充足的内存。但在移动平台上,一切都必须精打细算。一个在PC上运行流畅的场景,放到手机上可能连10帧都跑不到。

移动端场景建模的第一个核心原则是:用最少的面数传达最多的信息。这不是简单地减少模型细节,而是通过巧妙的资产设计、纹理运用和LOD技术,让玩家在视觉上感受到丰富的细节,而GPU实际处理的数据量却很小。

根据行业实践,单个角色的面数建议控制在5000-15000面之间,单个场景的总体面数不宜超过10万面。对于中低端设备,这个数字还需要进一步压缩。在《暗黑王朝》的开发中,我们为不同重要性的物体制定了严格的面数预算:

  • 主角模型:8000-10000面(包括武器)
  • 精英敌人:5000-6000面
  • 普通敌人:2500-3000面
  • 主要建筑:1500-2000面
  • 装饰物(路灯、箱子等):200-500面
  • 远景建筑物:500-800面(配合LOD可降至200面)

第二个核心原则是:纹理尺寸不是越大越好。2K纹理在PC上可能刚刚好,但在手机上会导致严重的内存压力。对于移动设备,主纹理建议使用1024x1024或2048x2048,次要物体使用512x512,远处的物体甚至可以降到256x256。同时必须开启Mipmap,让远处的物体自动使用低分辨率纹理,既节省性能又避免闪烁。

6.1.2 从参考图到建筑模型的完整流程

在《暗黑王朝》的第一个关卡中,玩家需要穿越一座被遗弃的研究站。这座建筑的设计融合了工业风格和科幻元素,需要从零开始构建。

建模的第一步是收集参考图。我们搜集了真实的工业建筑照片、科幻电影的场景截图、甚至是一些废弃工厂的实拍素材。将这些图片导入到三维软件(如Blender或3ds Max)的参考图视口中,开始进行基础建模。

以下是一个典型建筑的建模步骤:

  1. 主体结构:从一个立方体开始,通过挤出(Extrude)和倒角(Bevel)操作建立建筑的主体框架。主体面数控制在300面以内。

  2. 细节添加:在主体上添加窗户、门框、管道、通风口等细节。这些细节不能直接建模在主体上,而应该作为单独的部件,以便后续的纹理映射和LOD处理。

  3. 模块化设计:将建筑分解为可复用的模块——墙段、柱子、窗户组件、门组件等。这种模块化设计不仅提高了建模效率,也便于在Unity中进行组合和布局。

  4. UV展开:为模型展开UV,确保纹理空间得到充分利用。对于建筑模型,通常采用投影映射的方式,将不同方向的面对应到纹理的不同区域。

  5. AO烘焙:在三维软件中烘焙环境光遮蔽贴图,作为后续PBR材质的辅助。

在建模过程中,需要特别注意面片的走向。移动端的GPU对三角面的处理效率很高,但三角面之间的排列方式会影响渲染效果。尽量避免出现狭长的三角面,这种面在光照计算中容易产生异常。

6.1.3 多个建筑之间的视觉关联与布局

单个建筑制作完成后,需要将它们组合成一个完整的场景。在这个过程中,需要考虑建筑之间的视觉关联——它们不应该像是随意摆放在地面上的独立物体,而应该构成一个有逻辑、有故事的空间。

在《暗黑王朝》的研究站场景中,我们采用了"中心辐射式"的布局:

  • 核心建筑:中央主楼是场景的视觉中心,体量最大,细节最丰富
  • 附属建筑:围绕主楼分布着仓库、宿舍、岗亭等小型建筑
  • 连接元素:通过走廊、管道、围栏等元素将各个建筑连接起来
  • 环境叙事:通过倒塌的围墙、废弃的车辆、散落的箱子等元素,暗示这里曾经发生过什么

这种布局不仅让场景看起来更加真实,也为玩家提供了清晰的视觉引导。在游戏中,玩家会自然地朝着视觉中心移动,这为关卡设计提供了天然的路径。

在Unity中摆放建筑时,可以利用网格对齐功能,确保建筑之间保持合理的间距和角度。同时,要避免建筑之间出现穿模或过于拥挤的情况。

6.1.4 场景构架的元素构成

一个完整的游戏场景不仅仅包含建筑,还需要多种元素的配合:

地形:地面是场景的基础。对于移动平台,地形应该使用网格而非Unity的地形系统,因为后者会产生大量的顶点和绘制调用。可以使用一个简单的平面,在上面放置道路、草地、碎石等平面装饰物。

植被:树木和草丛是营造氛围的重要元素,但也是最消耗性能的部分。对于移动平台,建议使用面片交叉(Cross Plane)的方式制作树木和草丛,每个植物由两个互相垂直的面片组成,既节省面数又能保持较好的视觉效果。

道具:散布在场景中的小道具——箱子、油桶、路牌、路灯等——能够增加场景的丰富度。这些道具应该设计为可复用的预制体,通过不同的摆放组合产生变化。

特效:粒子特效可以增加场景的生动感,例如飘落的树叶、飞扬的尘土、闪烁的灯光等。但粒子特效对CPU的消耗较大,需要严格控制数量和范围。

在《暗黑王朝》中,我们将场景元素按照重要性划分为三个层级:

  • 核心元素:玩家必然经过的区域、主要建筑——高面数、高分辨率纹理
  • 次要元素:玩家可能看到但不会近距离接触的区域——中等面数、中等纹理
  • 背景元素:远景、天空盒等——低面数、低分辨率纹理,或仅使用2D贴图

6.1.5 面数分配与贴图大小的平衡艺术

面数和贴图大小是移动端优化的两大核心指标,它们之间存在微妙的平衡关系。高面数低纹理可以得到清晰的几何轮廓,但纹理细节不足;低面数高纹理可以获得丰富的表面细节,但几何轮廓会显得生硬。

在《暗黑王朝》中,我们遵循以下原则:

  1. 近景物体重纹理,远景物体重轮廓:玩家近距离观察的物体,使用较低的精度加上高分辨率纹理;远距离看到的物体,使用简化的轮廓和低分辨率纹理。

  2. 法线贴图替代几何细节:在建筑表面使用法线贴图模拟铆钉、焊缝、砖缝等细节,而不是实际建模这些细节。一个法线贴图只需要几个像素的代价,却能产生相当于数万面的视觉效果。

  3. 共用纹理集:将多个相关物体的纹理合并到一张大纹理中,减少材质数量和绘制调用。例如,所有金属材质的物体共用一张金属纹理集,所有木头材质的物体共用一张木纹纹理集。

  4. 按距离分层LOD:为每个重要物体创建2-3个LOD级别,当物体距离变远时,自动切换为更低面数的版本。LOD0(最近)使用完整面数,LOD1(中等距离)删除40%的面数,LOD2(远距离)删除70%的面数。

以下是一个LOD组的配置示例:

using UnityEngine;

namespace DarkOrder.Optimization
{
    /// <summary>
    /// LOD组自动配置工具
    /// </summary>
    [RequireComponent(typeof(LODGroup))]
    public class LODAutoConfig : MonoBehaviour
    {
        [Header("LOD设置")]
        [SerializeField] 
        private float[] m_LODPercentages = { 0.5f, 0.3f, 0.15f }; // LOD级别占原始面数的百分比
        
        [SerializeField] 
        private float[] m_LODScreenRelativeTransitionHeights = { 0.3f, 0.15f, 0.05f };
        
        [SerializeField] 
        private Renderer[] m_Renderers;
        
        private void Start()
        {
            ConfigureLODs();
        }
        
        [ContextMenu("配置LOD")]
        private void ConfigureLODs()
        {
            if (m_Renderers == null || m_Renderers.Length == 0)
            {
                m_Renderers = GetComponentsInChildren<Renderer>();
            }
            
            // 创建LOD组
            LODGroup lodGroup = GetComponent<LODGroup>();
            
            // 创建LOD数组
            LOD[] lods = new LOD[m_LODPercentages.Length + 1]; // +1 表示剔除(Culled)
            
            // 配置每个LOD级别
            for (int i = 0; i < m_LODPercentages.Length; i++)
            {
                // 这里应该根据百分比实际简化模型,但运行时无法简化
                // 实际项目中需要在编辑器预处理阶段生成简化模型
                lods[i] = new LOD(
                    m_LODScreenRelativeTransitionHeights[i],
                    m_Renderers
                );
            }
            
            // 最后一个LOD级别为剔除
            lods[lods.Length - 1] = new LOD(0f, new Renderer[0]);
            
            lodGroup.SetLODs(lods);
            lodGroup.RecalculateBounds();
            
            Debug.Log($"LOD已配置,共 {lods.Length} 个级别");
        }
    }
}

6.2 模型导入Unity的规范化流程

6.2.1 模型的分类管理与命名规范

当所有模型在三维软件中制作完成后,需要导入到Unity中进行组装。在这个过程中,建立清晰的分类管理和命名规范至关重要。

《暗黑王朝》的项目结构采用以下组织方式:

Assets/
├── Art/
│   ├── Characters/
│   │   ├── Player/
│   │   │   ├── Models/
│   │   │   ├── Textures/
│   │   │   ├── Materials/
│   │   │   └── Animations/
│   │   └── Enemies/
│   │       ├── EnemyTypeA/
│   │       └── EnemyTypeB/
│   ├── Environment/
│   │   ├── Buildings/
│   │   ├── Props/
│   │   ├── Terrain/
│   │   └── Skybox/
│   └── Effects/
├── Scripts/
├── Prefabs/
├── Scenes/
└── Audio/

命名规范采用"类型_名称_用途"的格式:

  • 模型文件mdl_ResearchStation_Main.fbx
  • 纹理文件tex_ResearchStation_Base_Albedo.png
  • 材质文件mat_ResearchStation_Main
  • 预制体pre_ResearchStation_Complete

6.2.2 动画模型与静态模型的导入设置差异

在导入模型时,需要根据模型类型选择不同的导入设置。Unity的模型导入器提供了丰富的选项,合理的设置可以显著优化运行时性能。

动画模型的导入设置:

// 此代码仅为说明,实际需要在导入器中手动设置或使用AssetPostProcessor
  • Rig选项卡:Animation Type设置为Humanoid(角色)或Generic(非人型),Avatar Definition设置为Create From This Model
  • Animation选项卡:根据需要导入的动画剪辑,设置循环时间、循环姿势等
  • Mesh压缩:设置为Medium或High,减少内存占用

静态模型的导入设置:

  • Model选项卡:取消选择Import Animation,如果不需要导入灯光和摄像机也一并取消
  • Mesh压缩:可以设置为High,因为静态模型对顶点精度的要求相对较低
  • Generate Colliders:根据需要选择,如果模型需要物理碰撞则开启
  • Lightmap UVs:必须开启,因为后续需要烘焙光照贴图

以下是一个模型导入后处理器的示例,用于自动设置不同类型模型的导入选项:

#if UNITY_EDITOR
using UnityEngine;
using UnityEditor;

namespace DarkOrder.EditorTools
{
    /// <summary>
    /// 模型导入后处理器 - 自动设置导入选项
    /// </summary>
    public class ModelImportProcessor : AssetPostprocessor
    {
        private void OnPreprocessModel()
        {
            ModelImporter importer = (ModelImporter)assetImporter;
            
            // 根据文件路径判断模型类型
            if (assetPath.Contains("Characters"))
            {
                // 角色模型设置
                ConfigureCharacterModel(importer);
            }
            else if (assetPath.Contains("Environment"))
            {
                // 环境模型设置
                ConfigureEnvironmentModel(importer);
            }
            else if (assetPath.Contains("Props"))
            {
                // 道具模型设置
                ConfigurePropModel(importer);
            }
        }
        
        private void ConfigureCharacterModel(ModelImporter importer)
        {
            importer.importAnimation = true;
            importer.animationCompression = ModelImporterAnimationCompression.KeyframeReduction;
            importer.importBlendShapes = true;
            importer.meshCompression = ModelImporterMeshCompression.Medium;
            importer.generateSecondaryUV = true; // 生成光照贴图UV
            importer.animationRotationError = 0.5f;
            importer.animationPositionError = 0.5f;
            
            Debug.Log($"角色模型导入设置完成: {assetPath}");
        }
        
        private void ConfigureEnvironmentModel(ModelImporter importer)
        {
            importer.importAnimation = false;
            importer.importCameras = false;
            importer.importLights = false;
            importer.meshCompression = ModelImporterMeshCompression.High;
            importer.generateSecondaryUV = true;
            
            Debug.Log($"环境模型导入设置完成: {assetPath}");
        }
        
        private void ConfigurePropModel(ModelImporter importer)
        {
            importer.importAnimation = false;
            importer.meshCompression = ModelImporterMeshCompression.Medium;
            importer.generateSecondaryUV = true;
            
            Debug.Log($"道具模型导入设置完成: {assetPath}");
        }
        
        private void OnPostprocessModel(GameObject root)
        {
            // 导入后处理:添加必要的组件
            if (assetPath.Contains("Environment") || assetPath.Contains("Props"))
            {
                // 为静态模型添加Static标志
                SetStaticRecursive(root.transform, true);
            }
        }
        
        private void SetStaticRecursive(Transform transform, bool isStatic)
        {
            transform.gameObject.isStatic = isStatic;
            
            foreach (Transform child in transform)
            {
                SetStaticRecursive(child, isStatic);
            }
        }
    }
}
#endif

6.2.3 动画专用模型的合并策略

对于带有动画的角色模型,通常会将模型和动画分开处理:模型作为一个FBX导入,动画作为独立的FBX文件或同一FBX中的多个动作。这种分离式管理有助于减少资源冗余。

在《暗黑王朝》中,每个角色类型都有一个基础模型FBX,包含角色的网格和骨骼。动画剪辑则存储在以动作命名的独立FBX文件中,或者使用Unity的动画系统直接导入。

合并动画模型的原则是:相同骨骼结构的模型可以共用动画。这意味着我们可以创建一个基础骨骼结构,所有同类角色都使用这个骨骼,然后为每个角色创建不同的外观模型。这样,一套动画就可以复用于多个角色,大大减少动画文件的数量。

6.2.4 固定模型的合并优化

对于不会移动的静态模型(建筑、地形、大型道具),可以合并它们以减少绘制调用。Unity的静态批处理功能可以自动合并标记为Static且使用相同材质的物体。

以下是静态批处理的最佳实践:

  1. 合并材质:尽量让多个静态物体使用同一张材质。可以通过纹理集(Texture Atlas)技术,将多个物体的纹理合并到一张大纹理上。

  2. 标记Static:在Inspector中勾选物体的Static复选框,或在代码中设置gameObject.isStatic = true

  3. 按材质分组:如果无法避免使用多种材质,至少确保相同材质的物体可以被批处理。

  4. 避免动态物体穿插:静态批处理的物体不能移动,如果它们和动态物体穿插,可能会导致渲染顺序问题。

6.2.5 光照贴图UV通道的生成

光照贴图烘焙需要模型拥有第二套UV通道,即UV2。这可以在三维建模软件中手动展开,也可以在Unity导入时自动生成。

自动生成光照贴图UV的设置如下:

  • 在模型导入器的Model选项卡中,勾选Generate Lightmap UVs
  • 设置以下参数:
    • Hard Angle:硬边角度,超过这个角度的面会被分开
    • Pack Margin:UV岛之间的间距
    • Angle ErrorArea Error:控制UV展开的质量

以下是一个通过代码检查模型是否有有效UV2的脚本:

using UnityEngine;
using UnityEditor;

namespace DarkOrder.EditorTools
{
    /// <summary>
    /// UV2检查器
    /// </summary>
    public class UV2Checker : EditorWindow
    {
        [MenuItem("Tools/检查UV2通道")]
        public static void ShowWindow()
        {
            GetWindow<UV2Checker>("UV2检查器");
        }
        
        private Vector2 m_ScrollPosition;
        
        private void OnGUI()
        {
            GUILayout.Label("检查选中模型的UV2通道", EditorStyles.boldLabel);
            
            m_ScrollPosition = EditorGUILayout.BeginScrollView(m_ScrollPosition);
            
            GameObject[] selectedObjects = Selection.gameObjects;
            
            foreach (GameObject obj in selectedObjects)
            {
                CheckGameObjectUV2(obj);
            }
            
            EditorGUILayout.EndScrollView();
            
            if (GUILayout.Button("修复选中模型的UV2"))
            {
                FixUV2ForSelected();
            }
        }
        
        private void CheckGameObjectUV2(GameObject obj)
        {
            EditorGUILayout.BeginVertical("box");
            EditorGUILayout.LabelField(obj.name, EditorStyles.boldLabel);
            
            MeshFilter[] meshFilters = obj.GetComponentsInChildren<MeshFilter>();
            SkinnedMeshRenderer[] skinnedRenderers = obj.GetComponentsInChildren<SkinnedMeshRenderer>();
            
            EditorGUI.indentLevel++;
            
            foreach (MeshFilter mf in meshFilters)
            {
                CheckMeshUV2(mf.sharedMesh, mf.gameObject.name);
            }
            
            foreach (SkinnedMeshRenderer smr in skinnedRenderers)
            {
                CheckMeshUV2(smr.sharedMesh, smr.gameObject.name);
            }
            
            EditorGUI.indentLevel--;
            EditorGUILayout.EndVertical();
        }
        
        private void CheckMeshUV2(Mesh mesh, string objectName)
        {
            if (mesh == null) return;
            
            bool hasUV2 = mesh.uv2 != null && mesh.uv2.Length > 0;
            
            GUI.color = hasUV2 ? Color.green : Color.red;
            EditorGUILayout.LabelField($"{objectName}: {(hasUV2 ? "✓ 有UV2" : "✗ 无UV2")}");
            GUI.color = Color.white;
        }
        
        private void FixUV2ForSelected()
        {
            foreach (GameObject obj in Selection.gameObjects)
            {
                string assetPath = AssetDatabase.GetAssetPath(obj);
                
                if (string.IsNullOrEmpty(assetPath))
                {
                    Debug.LogWarning($"跳过 {obj.name}: 不是资源文件");
                    continue;
                }
                
                ModelImporter importer = AssetImporter.GetAtPath(assetPath) as ModelImporter;
                if (importer != null)
                {
                    importer.generateSecondaryUV = true;
                    importer.secondaryUVHardAngle = 80f;
                    importer.secondaryUVPackMargin = 4f;
                    importer.SaveAndReimport();
                    
                    Debug.Log($"已为 {assetPath} 生成UV2");
                }
            }
            
            AssetDatabase.Refresh();
        }
    }
}

6.2.6 模型的统一导出规范

从三维软件导出模型时,需要遵循统一的规范,以确保在Unity中的一致性:

  • 轴方向:Unity使用Y轴向上,因此导出时应确保模型朝向正确。如果使用3ds Max,需要设置Z轴向上并旋转-90度,或直接在Unity导入器中调整。
  • 单位:统一使用米作为单位,确保不同模型导入后比例一致。
  • 缩放:导出时应用所有变换,确保缩放值为1,1,1。
  • 材质:选择嵌入材质或导出材质文件,但最终在Unity中都会重新指定材质,因此可以简化处理。

6.3 Unity中的光影环境构建

6.3.1 模型的导入与场景组装

当所有模型都准备好并导出为FBX后,接下来需要在Unity中进行场景组装。

步骤1:导入模型 - 将FBX文件拖入Project窗口的对应文件夹。Unity会自动生成模型文件和对应的材质球(如果有嵌入材质)。

步骤2:创建预制体 - 将模型从Project窗口拖入场景,调整位置、旋转、缩放,然后拖回Project窗口创建预制体。这样可以保留模型的变换信息,方便后续复用。

步骤3:布置场景 - 使用预制体在场景中进行布局。可以利用Unity的网格对齐功能(按住Ctrl移动)确保物体之间的精确对齐。

步骤4:添加碰撞体 - 为玩家可以站立、碰撞的物体添加碰撞体。对于复杂模型,可以使用多个基础碰撞体组合,而不是使用模型的网格碰撞体,后者性能较差。

6.3.2 基础灯光系统的搭建策略

在《暗黑王朝》中,我们采用主光源+辅助光源+环境光的组合方式来营造场景氛围。

主光源:使用一盏平行光(Directional Light)作为主光源,模拟太阳或月亮。这盏光源负责产生主要的明暗关系和阴影。对于移动平台,主光源应该是唯一开启阴影的光源。

辅助光源:使用点光源(Point Light)或聚光灯(Spot Light)补充局部光照。这些光源通常关闭阴影,仅用于照亮特定区域。

环境光:通过Lighting窗口中的Environment Lighting设置环境光照。可以使用天空盒的颜色作为环境光,或自定义环境光颜色和强度。

在Lighting窗口中进行基础设置:

  • Skybox Material:指定天空盒材质
  • Sun Source:指定主光源
  • Environment Lighting:设置环境光来源和强度
  • Environment Reflections:设置反射探针的来源

以下是一个自动设置场景光照的脚本:

using UnityEngine;

namespace DarkOrder.Lighting
{
    /// <summary>
    /// 场景光照初始化器
    /// </summary>
    [ExecuteAlways]
    public class SceneLightingInitializer : MonoBehaviour
    {
        [Header("主光源设置")]
        [SerializeField] 
        private Light m_MainLight;
        
        [SerializeField] 
        private Color m_LightColor = Color.white;
        
        [SerializeField] 
        private float m_Intensity = 1f;
        
        [SerializeField] 
        private float m_Longitude = -30f; // 经度
        
        [SerializeField] 
        private float m_Latitude = 60f; // 纬度
        
        [Header("环境设置")]
        [SerializeField] 
        private Material m_SkyboxMaterial;
        
        [SerializeField] 
        private Color m_AmbientColor = new Color(0.2f, 0.2f, 0.2f);
        
        [SerializeField] 
        private float m_AmbientIntensity = 1f;
        
        private void Awake()
        {
            InitializeLighting();
        }
        
        [ContextMenu("初始化光照")]
        public void InitializeLighting()
        {
            // 创建或获取主光源
            if (m_MainLight == null)
            {
                m_MainLight = FindObjectOfType<Light>();
                
                if (m_MainLight == null)
                {
                    GameObject lightObj = new GameObject("Main Directional Light");
                    lightObj.transform.SetParent(transform);
                    m_MainLight = lightObj.AddComponent<Light>();
                    m_MainLight.type = LightType.Directional;
                }
            }
            
            // 设置主光源
            m_MainLight.color = m_LightColor;
            m_MainLight.intensity = m_Intensity;
            
            // 计算平行光方向(基于经纬度)
            float radianLat = m_Latitude * Mathf.Deg2Rad;
            float radianLon = m_Longitude * Mathf.Deg2Rad;
            
            Vector3 direction = new Vector3(
                Mathf.Cos(radianLat) * Mathf.Sin(radianLon),
                Mathf.Sin(radianLat),
                Mathf.Cos(radianLat) * Mathf.Cos(radianLon)
            );
            
            m_MainLight.transform.rotation = Quaternion.LookRotation(-direction);
            
            // 设置阴影
            m_MainLight.shadows = LightShadows.Soft;
            m_MainLight.shadowStrength = 0.8f;
            m_MainLight.shadowResolution = LightShadowResolution.Medium;
            
            // 设置天空盒
            if (m_SkyboxMaterial != null)
            {
                RenderSettings.skybox = m_SkyboxMaterial;
            }
            
            // 设置环境光
            RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat;
            RenderSettings.ambientLight = m_AmbientColor * m_AmbientIntensity;
            
            Debug.Log("场景光照初始化完成");
        }
    }
}

6.4 光照贴图烘焙的完整流程

6.4.1 烘焙前的准备工作

光照贴图烘焙(Lightmapping)是将静态光照信息预先计算并存储在纹理中的过程。烘焙后的场景不再需要实时光照计算,可以大幅提高运行效率。

在开始烘焙前,需要完成以下准备工作:

  1. 标记静态物体:所有参与烘焙的物体必须标记为Contribute GI(在Static下拉菜单中选择)。这些物体会接收烘焙光照,也会影响其他物体的光照。

  2. 检查UV2:确保所有参与烘焙的物体都有正确的UV2通道。UV2应该没有重叠,且充分利用纹理空间。

  3. 设置光照贴图参数:在每个物体的Mesh Renderer组件中,可以设置:

    • Scale In Lightmap:控制该物体在光照贴图中的分辨率倍数
    • Lightmap Parameters:高级参数,控制采样率等
  4. 布置光照探针:对于动态物体,需要放置光照探针(Light Probes),让动态物体能够接收静态环境的光照信息。

6.4.2 烘焙参数的精细设置

Unity提供了两种光照贴图烘焙器:渐进式光照贴图(Progressive Lightmapper)和Enlighten。在2021.3版本中,渐进式光照贴图是默认选项,它基于路径追踪算法,能够产生更高质量的间接光照。

在Lighting窗口的Lightmapping Settings中,关键参数包括:

Lightmapper:选择Progressive GPU(如果显卡支持)可以获得最快的烘焙速度。

Direct Samples / Indirect Samples:控制直接光照和间接光照的采样数。较高的采样数可以降低噪点,但会显著增加烘焙时间。在测试阶段可以使用较低的采样数(如32/128),最终烘焙时再提高(如128/512)。

Bounces:光线反弹次数。对于室外场景,2-3次通常足够;对于室内场景,可能需要3-4次。

Lightmap Resolution:光照贴图的分辨率,单位是"texels per unit"。这个参数控制每个世界单位对应多少纹素。对于主要场景,通常设置为20-40;对于细节区域,可以提高到50-60。

Lightmap Padding:不同物体的光照贴图之间的间距。过小可能导致漏光,过大会浪费纹理空间。

Compress Lightmaps:移动平台必须开启,可以减少内存占用。

以下是一个通过脚本批量设置光照贴图参数的工具:

using UnityEngine;
using UnityEditor;

namespace DarkOrder.EditorTools
{
    /// <summary>
    /// 光照贴图批量设置工具
    /// </summary>
    public class LightmapBatchSetter : EditorWindow
    {
        private float m_ScaleInLightmap = 1f;
        private float m_Priority = 1f;
        
        [MenuItem("Tools/批量设置光照贴图参数")]
        public static void ShowWindow()
        {
            GetWindow<LightmapBatchSetter>("光照贴图参数");
        }
        
        private void OnGUI()
        {
            GUILayout.Label("批量设置选中物体的光照贴图参数", EditorStyles.boldLabel);
            
            m_ScaleInLightmap = EditorGUILayout.FloatField("Scale In Lightmap", m_ScaleInLightmap);
            m_Priority = EditorGUILayout.FloatField("Priority", m_Priority);
            
            if (GUILayout.Button("应用到选中物体"))
            {
                ApplyToSelected();
            }
            
            if (GUILayout.Button("按类型自动计算Scale"))
            {
                AutoCalculateScale();
            }
        }
        
        private void ApplyToSelected()
        {
            GameObject[] selectedObjects = Selection.gameObjects;
            int count = 0;
            
            foreach (GameObject obj in selectedObjects)
            {
                MeshRenderer[] renderers = obj.GetComponentsInChildren<MeshRenderer>();
                
                foreach (MeshRenderer renderer in renderers)
                {
                    renderer.scaleInLightmap = m_ScaleInLightmap;
                    renderer.receiveGI = ReceiveGI.Lightmaps;
                    count++;
                }
            }
            
            Debug.Log($"已更新 {count} 个MeshRenderer的光照贴图参数");
        }
        
        private void AutoCalculateScale()
        {
            GameObject[] selectedObjects = Selection.gameObjects;
            int count = 0;
            
            foreach (GameObject obj in selectedObjects)
            {
                MeshRenderer[] renderers = obj.GetComponentsInChildren<MeshRenderer>();
                
                foreach (MeshRenderer renderer in renderers)
                {
                    // 根据物体在屏幕上的大小自动计算Scale
                    // 大物体使用较高的Scale,小物体使用较低的Scale
                    Bounds bounds = renderer.bounds;
                    float size = bounds.size.magnitude;
                    
                    float scale = Mathf.Clamp(size * 0.5f, 0.2f, 2f);
                    renderer.scaleInLightmap = scale;
                    count++;
                }
            }
            
            Debug.Log($"已自动计算 {count} 个物体的Scale");
        }
    }
}

6.4.3 开始烘焙与问题排查

当所有参数设置完成后,点击Lighting窗口底部的Generate Lighting按钮开始烘焙。烘焙时间取决于场景复杂度和参数设置,可能从几分钟到几小时不等。

常见问题及解决方案:

漏光:UV岛之间的间距不够,导致光照信息串扰。可以增加UV Pack Margin或提高Lightmap Padding。

锯齿阴影:光照贴图分辨率不足。可以提高Lightmap Resolution,或为重要物体单独增加Scale In Lightmap。

间接光照噪点:采样数不足。可以增加Indirect Samples,或开启Denoising选项。

烘焙时间过长:场景过于复杂,或采样数过高。可以先用低采样数测试,或者分区域烘焙。

6.4.4 效果灯光的补充

完全静态的场景虽然性能好,但会显得缺乏生气。在《暗黑王朝》中,我们在烘焙基础上添加了一些效果灯光:

  • 动态闪烁灯光:如破损的荧光灯、火灾的光照等
  • 武器开火闪光:短暂的点光源,照亮周围环境
  • 手电筒:玩家可控的聚光灯

这些动态灯光数量很少(通常1-2盏),但能显著增强场景的生动感。

以下是一个闪烁灯光效果的实现:

using UnityEngine;

namespace DarkOrder.Lighting
{
    /// <summary>
    /// 闪烁灯光效果
    /// </summary>
    [RequireComponent(typeof(Light))]
    public class FlickeringLight : MonoBehaviour
    {
        [Header("闪烁参数")]
        [SerializeField] 
        private float m_MinIntensity = 0.5f;
        
        [SerializeField] 
        private float m_MaxIntensity = 1.5f;
        
        [SerializeField] 
        private float m_FlickerSpeed = 1f;
        
        [Header("随机性")]
        [SerializeField] 
        private float m_Randomness = 0.3f;
        
        [SerializeField] 
        private float m_ChangeSpeed = 5f;
        
        private Light m_Light;
        private float m_BaseIntensity;
        private float m_TargetIntensity;
        private float m_NoiseOffset;
        
        private void Awake()
        {
            m_Light = GetComponent<Light>();
            m_BaseIntensity = m_Light.intensity;
            m_TargetIntensity = m_BaseIntensity;
            m_NoiseOffset = Random.Range(0f, 100f);
        }
        
        private void Update()
        {
            // 使用Perlin噪声生成平滑的随机值
            float noise = Mathf.PerlinNoise(m_NoiseOffset + Time.time * m_FlickerSpeed, 0.5f);
            
            // 映射到强度范围
            float targetIntensity = Mathf.Lerp(m_MinIntensity, m_MaxIntensity, noise);
            
            // 添加随机性
            targetIntensity += Random.Range(-m_Randomness, m_Randomness);
            
            // 平滑过渡
            m_Light.intensity = Mathf.Lerp(
                m_Light.intensity, 
                targetIntensity, 
                Time.deltaTime * m_ChangeSpeed
            );
        }
    }
}

6.4.5 Unity显示等级的调节

在移动平台上,需要根据设备性能动态调整画质设置。Unity的Quality Settings中可以定义多个质量等级,并在运行时切换。

在《暗黑王朝》中,我们定义了三个质量等级:

  • 低端设备:关闭阴影,关闭抗锯齿,纹理质量减半,关闭后处理
  • 中端设备:开启烘焙阴影,2倍抗锯齿,开启基础后处理
  • 高端设备:开启高质量烘焙阴影,4倍抗锯齿,开启所有后处理

以下是一个运行时根据设备性能自动切换质量等级的脚本:

using UnityEngine;

namespace DarkOrder.Optimization
{
    /// <summary>
    /// 自动画质适配器
    /// </summary>
    public class QualityAutoAdapter : MonoBehaviour
    {
        [Header("性能阈值")]
        [SerializeField] 
        private int m_LowEndMemoryMB = 2048;
        
        [SerializeField] 
        private int m_MidEndMemoryMB = 4096;
        
        [Header("质量等级索引")]
        [SerializeField] 
        private int m_LowQualityIndex = 0;
        
        [SerializeField] 
        private int m_MidQualityIndex = 1;
        
        [SerializeField] 
        private int m_HighQualityIndex = 2;
        
        private void Start()
        {
            int systemMemory = SystemInfo.systemMemorySize;
            
            if (systemMemory <= m_LowEndMemoryMB)
            {
                SetQualityLevel(m_LowQualityIndex, "低端");
            }
            else if (systemMemory <= m_MidEndMemoryMB)
            {
                SetQualityLevel(m_MidQualityIndex, "中端");
            }
            else
            {
                SetQualityLevel(m_HighQualityIndex, "高端");
            }
            
            // 同时根据GPU性能调整
            AdjustForGPU();
        }
        
        private void SetQualityLevel(int index, string levelName)
        {
            QualitySettings.SetQualityLevel(index, true);
            Debug.Log($"画质已设置为: {levelName} - {QualitySettings.names[index]}");
            
            // 保存选择
            PlayerPrefs.SetInt("UserQuality", index);
        }
        
        private void AdjustForGPU()
        {
            string gpuName = SystemInfo.graphicsDeviceName.ToLower();
            
            // 某些特定GPU可能需要额外优化
            if (gpuName.Contains("mali") && SystemInfo.graphicsDeviceVersion.StartsWith("OpenGL ES 2"))
            {
                // 老旧Mali GPU,强制使用低端设置
                QualitySettings.SetQualityLevel(m_LowQualityIndex, true);
                Debug.Log("检测到老旧Mali GPU,已强制使用低端画质");
            }
            
            // 设置帧率目标
            Application.targetFrameRate = 30; // 移动平台30帧足够流畅,可以省电
        }
    }
}

6.5 天空盒的制作与应用

6.5.1 天空盒的类型与选择

天空盒(Skybox)是包围整个场景的视觉效果,给玩家一种远处有广阔世界的错觉。在Unity中,天空盒本质上是一个材质,通过特殊的着色器渲染。

Unity支持几种类型的天空盒:

6-Sided天空盒:由六张纹理组成,分别对应前后左右上下六个方向。这种类型最常用,适合大多数室外场景。

Cubemap天空盒:使用立方体贴图,可以看作是6-Sided的变体,但存储在特殊的纹理格式中。

Panoramic天空盒:使用单张2:1比例的等距柱状图(equirectangular)纹理,适合360度全景照片。

Procedural天空盒:由Unity的着色器程序化生成,可以动态调整颜色、云层等参数。

在《暗黑王朝》中,我们为不同关卡设计了不同的天空盒:

  • 第一关(黄昏研究站):使用6-Sided天空盒,橙红色的晚霞
  • 第二关(夜晚废墟):使用程序化天空盒,深蓝色背景配星星
  • 第三关(地下设施):没有天空盒,而是使用封闭的洞穴环境

6.5.2 天空盒材质的创建与配置

创建6-Sided天空盒的步骤:

  1. 准备六张纹理,确保它们无缝连接
  2. 在Project窗口右键,Create > Material
  3. 将Shader设置为 Skybox > 6 Sided
  4. 将六张纹理拖入对应的纹理槽中
  5. 调整Tint Color、Exposure、Rotation等参数

程序化天空盒的设置更为灵活:

  1. 创建新材质,Shader选择 Skybox > Procedural
  2. 设置Sun Size、Atmosphere Thickness、Sky Tint等参数
  3. 可以通过代码动态调整这些参数,实现昼夜变化

以下是一个动态控制程序化天空盒的脚本:

using UnityEngine;

namespace DarkOrder.Environment
{
    /// <summary>
    /// 动态天空盒控制器
    /// </summary>
    public class DynamicSkyboxController : MonoBehaviour
    {
        [Header("天空盒材质")]
        [SerializeField] 
        private Material m_SkyboxMaterial;
        
        [Header("昼夜循环")]
        [SerializeField] 
        private bool m_EnableDayNightCycle = true;
        
        [SerializeField] 
        private float m_CycleDuration = 120f; // 完整周期秒数
        
        [SerializeField] 
        private Gradient m_SkyTintGradient;
        
        [SerializeField] 
        private Gradient m_GroundTintGradient;
        
        [Header("时间控制")]
        [SerializeField] 
        [Range(0, 24)]
        private float m_CurrentHour = 12f;
        
        private float m_TimeOfDay; // 0-1
        
        private void Start()
        {
            if (m_SkyboxMaterial == null)
            {
                m_SkyboxMaterial = RenderSettings.skybox;
            }
            
            if (m_SkyboxMaterial == null || !m_SkyboxMaterial.shader.name.Contains("Procedural"))
            {
                Debug.LogWarning("需要程序化天空盒材质");
                enabled = false;
            }
        }
        
        private void Update()
        {
            if (m_EnableDayNightCycle)
            {
                // 更新一天中的时间
                m_TimeOfDay += Time.deltaTime / m_CycleDuration;
                if (m_TimeOfDay > 1f)
                {
                    m_TimeOfDay -= 1f;
                }
                
                m_CurrentHour = m_TimeOfDay * 24f;
            }
            
            UpdateSkyboxParameters();
        }
        
        private void UpdateSkyboxParameters()
        {
            if (m_SkyboxMaterial == null) return;
            
            // 计算太阳高度(0 = 午夜,0.5 = 正午,1 = 午夜)
            float sunHeight = Mathf.Sin((m_TimeOfDay - 0.25f) * Mathf.PI * 2);
            
            // 调整曝光和大气厚度
            float exposure = Mathf.Lerp(0.5f, 1.3f, Mathf.Max(0, sunHeight));
            m_SkyboxMaterial.SetFloat("_Exposure", exposure);
            
            float atmosphere = Mathf.Lerp(1.8f, 1.2f, Mathf.Max(0, sunHeight));
            m_SkyboxMaterial.SetFloat("_AtmosphereThickness", atmosphere);
            
            // 调整颜色
            Color skyTint = m_SkyTintGradient.Evaluate(m_TimeOfDay);
            m_SkyboxMaterial.SetColor("_SkyTint", skyTint);
            
            Color groundTint = m_GroundTintGradient.Evaluate(m_TimeOfDay);
            m_SkyboxMaterial.SetColor("_GroundColor", groundTint);
            
            // 调整太阳大小和强度
            float sunSize = Mathf.Lerp(0.02f, 0.05f, Mathf.Max(0, sunHeight));
            m_SkyboxMaterial.SetFloat("_SunSize", sunSize);
            
            float sunStrength = Mathf.Lerp(0.5f, 1.2f, Mathf.Max(0, sunHeight));
            m_SkyboxMaterial.SetFloat("_SunStrength", sunStrength);
        }
        
        /// <summary>
        /// 设置具体时间(小时)
        /// </summary>
        public void SetTimeOfDay(float hour)
        {
            m_CurrentHour = Mathf.Clamp(hour, 0, 24);
            m_TimeOfDay = m_CurrentHour / 24f;
            m_EnableDayNightCycle = false;
            UpdateSkyboxParameters();
        }
    }
}

6.5.3 天空盒与场景的融合

天空盒虽然是背景,但它应该与场景的光照、雾效等元素协调一致,共同营造统一的氛围。

雾效配合:将雾的颜色设置为与天空盒的地平线颜色相近,可以实现场景与天空盒的无缝过渡。在Lighting窗口中可以设置雾的颜色、密度、模式等。

环境光配合:天空盒的颜色会影响环境光的颜色。在Lighting窗口的Environment Lighting中,可以选择Source为Skybox,让环境光自动从天空盒采样。

反射配合:场景中的金属物体应该反射天空盒的颜色。通过放置反射探针(Reflection Probe),可以捕获天空盒信息并用于PBR材质的反射计算。

以下是一个雾效设置脚本:

using UnityEngine;

namespace DarkOrder.Environment
{
    /// <summary>
    /// 动态雾效控制器
    /// </summary>
    [ExecuteAlways]
    public class FogController : MonoBehaviour
    {
        [Header("雾效设置")]
        [SerializeField] 
        private bool m_EnableFog = true;
        
        [SerializeField] 
        private FogMode m_FogMode = FogMode.ExponentialSquared;
        
        [SerializeField] 
        private Color m_FogColor = Color.gray;
        
        [SerializeField] 
        private float m_FogDensity = 0.01f;
        
        [SerializeField] 
        private float m_FogStartDistance = 10f;
        
        [SerializeField] 
        private float m_FogEndDistance = 50f;
        
        [Header("与天空盒联动")]
        [SerializeField] 
        private bool m_MatchSkyboxColor = false;
        
        [SerializeField] 
        private Material m_SkyboxMaterial;
        
        private void Update()
        {
            RenderSettings.fog = m_EnableFog;
            RenderSettings.fogMode = m_FogMode;
            RenderSettings.fogDensity = m_FogDensity;
            RenderSettings.fogStartDistance = m_FogStartDistance;
            RenderSettings.fogEndDistance = m_FogEndDistance;
            
            if (m_MatchSkyboxColor && m_SkyboxMaterial != null)
            {
                // 从天空盒采样地平线颜色
                if (m_SkyboxMaterial.HasProperty("_SkyTint"))
                {
                    Color skyTint = m_SkyboxMaterial.GetColor("_SkyTint");
                    RenderSettings.fogColor = Color.Lerp(skyTint, Color.gray, 0.3f);
                }
            }
            else
            {
                RenderSettings.fogColor = m_FogColor;
            }
        }
    }
}

6.6 灯光与摄影机特效

6.6.1 太阳特效的模拟

在室外场景中,太阳是最重要的光源。除了提供照明外,太阳本身的视觉效果也值得精心设计。

在《暗黑王朝》中,我们使用以下几种方法来增强太阳效果:

方法一:镜头光晕(Lens Flare) - 在主光源方向放置Lens Flare组件,模拟镜头对着强光时产生的光晕效果。需要导入Lens Flare预设,并调整其颜色、亮度、位置。

方法二:体积光(Volumetric Lighting) - 使用点光源或聚光灯配合体积雾,模拟光线穿过空气的效果。在移动平台上需要谨慎使用,因为体积光对性能消耗较大。

方法三:Shader特效 - 编写简单的屏幕后处理Shader,在太阳方向添加光晕。

以下是一个简化的镜头光晕控制器:

using UnityEngine;

namespace DarkOrder.Effects
{
    /// <summary>
    /// 镜头光晕控制器
    /// </summary>
    [RequireComponent(typeof(LensFlare))]
    public class LensFlareController : MonoBehaviour
    {
        [Header("跟随目标")]
        [SerializeField] 
        private Transform m_TargetLight; // 主光源
        
        [Header("可见性控制")]
        [SerializeField] 
        private Camera m_MainCamera;
        
        [SerializeField] 
        private float m_MinDotProduct = 0.3f; // 相机方向和光方向的最小点积
        
        [SerializeField] 
        private LayerMask m_OcclusionLayer; // 遮挡检测层
        
        private LensFlare m_LensFlare;
        
        private void Awake()
        {
            m_LensFlare = GetComponent<LensFlare>();
            
            if (m_MainCamera == null)
            {
                m_MainCamera = Camera.main;
            }
            
            if (m_TargetLight == null)
            {
                Light mainLight = FindObjectOfType<Light>();
                if (mainLight != null && mainLight.type == LightType.Directional)
                {
                    m_TargetLight = mainLight.transform;
                }
            }
        }
        
        private void Update()
        {
            if (m_TargetLight == null || m_MainCamera == null) return;
            
            // 计算相机到光源的方向
            Vector3 lightDirection = -m_TargetLight.forward;
            Vector3 cameraForward = m_MainCamera.transform.forward;
            
            // 计算点积,判断光源是否在相机前方
            float dot = Vector3.Dot(cameraForward, lightDirection);
            
            if (dot > m_MinDotProduct)
            {
                // 检测是否有遮挡
                RaycastHit hit;
                Vector3 cameraPos = m_MainCamera.transform.position;
                
                if (Physics.Raycast(cameraPos, lightDirection, out hit, 1000f, m_OcclusionLayer))
                {
                    // 有遮挡,减弱光晕
                    m_LensFlare.brightness = Mathf.Lerp(m_LensFlare.brightness, 0f, Time.deltaTime * 5f);
                }
                else
                {
                    // 无遮挡,根据点积调整亮度
                    float brightness = Mathf.InverseLerp(m_MinDotProduct, 1f, dot);
                    m_LensFlare.brightness = Mathf.Lerp(m_LensFlare.brightness, brightness, Time.deltaTime * 5f);
                }
            }
            else
            {
                // 光源不在视野内
                m_LensFlare.brightness = Mathf.Lerp(m_LensFlare.brightness, 0f, Time.deltaTime * 5f);
            }
            
            // 使光晕始终面向相机
            transform.position = m_TargetLight.position;
            transform.LookAt(m_MainCamera.transform);
        }
    }
}

6.6.2 摄影机特效的合理使用

Unity的Post Processing Stack提供了丰富的后期特效,能够显著提升画面品质。但在移动平台上,必须谨慎选择特效组合,避免性能开销过大。

在《暗黑王朝》中,我们根据不同设备性能采用不同的后处理方案:

基础方案(所有设备)

  • 色调映射(Tonemapping):使用ACES或中性模式,提升画面色彩表现
  • 抗锯齿(Anti-aliasing):使用FXAA或TAA,消耗较低

进阶方案(中高端设备)

  • 环境光遮蔽(Ambient Occlusion):使用SSAO或Scalable AO,增强物体之间的接触阴影
  • 泛光(Bloom):为亮部添加光晕效果
  • 景深(Depth of Field):在过场动画中使用

高级方案(高端设备)

  • 体积光照(Volumetric Lighting)
  • 屏幕空间反射(Screen Space Reflections)
  • 动态模糊(Motion Blur)

以下是一个根据质量等级动态配置后处理的脚本:

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

namespace DarkOrder.Effects
{
    /// <summary>
    /// 后处理配置器
    /// </summary>
    public class PostProcessingConfigurator : MonoBehaviour
    {
        [Header("后处理体积")]
        [SerializeField] 
        private Volume m_Volume;
        
        [Header("特效组件")]
        private Tonemapping m_Tonemapping;
        private Bloom m_Bloom;
        private AmbientOcclusion m_AmbientOcclusion;
        private DepthOfField m_DepthOfField;
        private Vignette m_Vignette;
        private ColorAdjustments m_ColorAdjustments;
        
        private void Start()
        {
            if (m_Volume == null)
            {
                m_Volume = GetComponent<Volume>();
            }
            
            if (m_Volume == null) return;
            
            // 获取或添加特效组件
            m_Volume.profile.TryGet(out m_Tonemapping);
            m_Volume.profile.TryGet(out m_Bloom);
            m_Volume.profile.TryGet(out m_AmbientOcclusion);
            m_Volume.profile.TryGet(out m_DepthOfField);
            m_Volume.profile.TryGet(out m_Vignette);
            m_Volume.profile.TryGet(out m_ColorAdjustments);
            
            // 根据质量等级配置
            ConfigureForQualityLevel();
        }
        
        private void ConfigureForQualityLevel()
        {
            int qualityLevel = QualitySettings.GetQualityLevel();
            
            // 所有等级都启用的基本特效
            if (m_Tonemapping != null)
            {
                m_Tonemapping.active = true;
                m_Tonemapping.mode.value = TonemappingMode.Neutral;
            }
            
            if (m_ColorAdjustments != null)
            {
                m_ColorAdjustments.active = true;
            }
            
            // 根据等级配置
            switch (qualityLevel)
            {
                case 0: // 低端
                    ConfigureLowEnd();
                    break;
                    
                case 1: // 中端
                    ConfigureMidEnd();
                    break;
                    
                case 2: // 高端
                    ConfigureHighEnd();
                    break;
            }
        }
        
        private void ConfigureLowEnd()
        {
            if (m_Bloom != null) m_Bloom.active = false;
            if (m_AmbientOcclusion != null) m_AmbientOcclusion.active = false;
            if (m_DepthOfField != null) m_DepthOfField.active = false;
            if (m_Vignette != null) m_Vignette.active = true; // 暗角消耗低,可以开启
            
            // 降低色调映射质量
            if (m_Tonemapping != null)
            {
                m_Tonemapping.mode.value = TonemappingMode.Neutral;
            }
            
            Debug.Log("低端后处理配置完成");
        }
        
        private void ConfigureMidEnd()
        {
            if (m_Bloom != null)
            {
                m_Bloom.active = true;
                m_Bloom.intensity.value = 0.8f;
                m_Bloom.threshold.value = 1.1f;
            }
            
            if (m_AmbientOcclusion != null)
            {
                m_AmbientOcclusion.active = true;
                m_AmbientOcclusion.quality.value = AmbientOcclusionQuality.Medium;
            }
            
            if (m_DepthOfField != null) m_DepthOfField.active = false;
            if (m_Vignette != null) m_Vignette.active = true;
            
            Debug.Log("中端后处理配置完成");
        }
        
        private void ConfigureHighEnd()
        {
            if (m_Bloom != null)
            {
                m_Bloom.active = true;
                m_Bloom.intensity.value = 1.2f;
                m_Bloom.threshold.value = 0.9f;
            }
            
            if (m_AmbientOcclusion != null)
            {
                m_AmbientOcclusion.active = true;
                m_AmbientOcclusion.quality.value = AmbientOcclusionQuality.High;
            }
            
            if (m_DepthOfField != null)
            {
                m_DepthOfField.active = true;
                m_DepthOfField.quality.value = DepthOfFieldQuality.Medium;
            }
            
            if (m_Vignette != null) m_Vignette.active = true;
            
            Debug.Log("高端后处理配置完成");
        }
    }
}

6.7 移动端场景制作的核心原则

6.7.1 模型面数的终极控制

经过《暗黑王朝》的实战开发,我们总结出移动端模型面数控制的几条铁律:

铁律一:能用的面才是有意义的面。那些隐藏在墙壁后面的、玩家永远看不到的几何体,应该被彻底删除。在建模时就要考虑相机的视野,只有玩家可能看到的部分才需要建模。

铁律二:用纹理替代几何。一个法线贴图可以替代数千个三角面。在制作建筑时,先思考能否用法线贴图表达细节,只有必须的轮廓才用几何建模。

铁律三:LOD不是选项,是必需。每个重要物体都必须有至少两个LOD级别。LOD0用于近距离,LOD1用于中距离,LOD2用于远距离,超过LOD2的距离直接剔除。

铁律四:共用资源。相同的部件(窗户、柱子、箱子)应该使用同一个模型多次实例化,而不是复制出多个独立模型。利用Unity的预制体系统和GPU Instancing技术。

以下是一个运行时监控面数的工具:

using UnityEngine;
using UnityEngine.UI;

namespace DarkOrder.Diagnostics
{
    /// <summary>
    /// 面数监控器
    /// </summary>
    public class TriangleCounter : MonoBehaviour
    {
        [SerializeField] 
        private Text m_DisplayText;
        
        [SerializeField] 
        private float m_UpdateInterval = 0.5f;
        
        private int m_TotalTriangles;
        private float m_Timer;
        
        private void Update()
        {
            m_Timer += Time.deltaTime;
            
            if (m_Timer >= m_UpdateInterval)
            {
                CountTriangles();
                m_Timer = 0f;
            }
        }
        
        private void CountTriangles()
        {
            m_TotalTriangles = 0;
            
            MeshFilter[] meshFilters = FindObjectsOfType<MeshFilter>();
            foreach (MeshFilter mf in meshFilters)
            {
                if (mf.sharedMesh != null && mf.gameObject.activeInHierarchy)
                {
                    m_TotalTriangles += mf.sharedMesh.triangles.Length / 3;
                }
            }
            
            SkinnedMeshRenderer[] skinnedRenderers = FindObjectsOfType<SkinnedMeshRenderer>();
            foreach (SkinnedMeshRenderer smr in skinnedRenderers)
            {
                if (smr.sharedMesh != null && smr.gameObject.activeInHierarchy)
                {
                    m_TotalTriangles += smr.sharedMesh.triangles.Length / 3;
                }
            }
            
            if (m_DisplayText != null)
            {
                m_DisplayText.text = $"三角面: {m_TotalTriangles}";
                
                // 根据面数显示警告颜色
                if (m_TotalTriangles > 100000)
                {
                    m_DisplayText.color = Color.red;
                }
                else if (m_TotalTriangles > 80000)
                {
                    m_DisplayText.color = Color.yellow;
                }
                else
                {
                    m_DisplayText.color = Color.green;
                }
            }
        }
    }
}

6.7.2 贴图大小与利用率的最大化

贴图是移动平台上最大的内存消耗源之一。优化贴图使用是内存管理的关键。

原则一:选择合适的压缩格式。Android平台应使用ETC2或ASTC格式,iOS平台应使用PVRTC或ASTC格式。对于不支持ETC2的旧设备,需要准备回退方案。

原则二:纹理尺寸是2的幂。纹理尺寸必须是2的幂(如256、512、1024),否则无法生成Mipmap,也无法进行纹理压缩。

原则三:善用纹理集。将多个小纹理合并到一张大纹理中,可以显著减少材质数量和绘制调用。纹理集的布局需要合理规划,确保不同物体能够正确映射到对应的区域。

原则四:Mipmap必须开启。Mipmap让远处物体使用低分辨率纹理,既节省性能又避免闪烁。虽然会增加约33%的纹理内存,但带来的性能提升远超这个代价。

以下是一个纹理分析工具:

#if UNITY_EDITOR
using UnityEngine;
using UnityEditor;

namespace DarkOrder.EditorTools
{
    /// <summary>
    /// 纹理分析器
    /// </summary>
    public class TextureAnalyzer : EditorWindow
    {
        [MenuItem("Tools/纹理分析器")]
        public static void ShowWindow()
        {
            GetWindow<TextureAnalyzer>("纹理分析器");
        }
        
        private Vector2 m_ScrollPosition;
        
        private void OnGUI()
        {
            GUILayout.Label("项目纹理分析", EditorStyles.boldLabel);
            
            if (GUILayout.Button("分析所有纹理"))
            {
                AnalyzeAllTextures();
            }
            
            m_ScrollPosition = EditorGUILayout.BeginScrollView(m_ScrollPosition);
            
            // 显示分析结果
            if (m_Results != null)
            {
                foreach (string result in m_Results)
                {
                    EditorGUILayout.LabelField(result);
                }
            }
            
            EditorGUILayout.EndScrollView();
        }
        
        private List<string> m_Results = new List<string>();
        
        private void AnalyzeAllTextures()
        {
            m_Results.Clear();
            
            string[] guids = AssetDatabase.FindAssets("t:texture");
            int totalMemory = 0;
            int nonPowerOfTwoCount = 0;
            int noMipmapCount = 0;
            
            foreach (string guid in guids)
            {
                string path = AssetDatabase.GUIDToAssetPath(guid);
                TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter;
                Texture2D texture = AssetDatabase.LoadAssetAtPath<Texture2D>(path);
                
                if (importer == null || texture == null) continue;
                
                // 检查尺寸是否为2的幂
                bool isPowerOfTwo = (texture.width & (texture.width - 1)) == 0 && 
                                    (texture.height & (texture.height - 1)) == 0;
                
                if (!isPowerOfTwo)
                {
                    m_Results.Add($"[警告] 非2的幂纹理: {path} ({texture.width}x{texture.height})");
                    nonPowerOfTwoCount++;
                }
                
                // 检查是否开启Mipmap
                if (!importer.mipmapEnabled && texture.width > 64 && texture.height > 64)
                {
                    m_Results.Add($"[建议] 未开启Mipmap: {path}");
                    noMipmapCount++;
                }
                
                // 估算内存占用
                int memory = texture.width * texture.height * 4 / 1024 / 1024;
                totalMemory += memory;
            }
            
            m_Results.Insert(0, $"总纹理内存估计: {totalMemory} MB");
            m_Results.Insert(1, $"非2的幂纹理数量: {nonPowerOfTwoCount}");
            m_Results.Insert(2, $"未开启Mipmap纹理数量: {noMipmapCount}");
            
            AssetDatabase.Refresh();
        }
    }
}
#endif

6.8 本章小结

本章围绕《暗黑王朝》的场景构建流程,系统讲解了从模型制作、导入优化、光影烘焙到后处理特效的完整知识体系。

在模型制作阶段,我们强调了移动平台的特殊性:面数控制、纹理大小优化、模块化设计。通过合理的面数分配和纹理使用,可以在有限的硬件资源内创造出丰富的视觉效果。

在模型导入阶段,分类管理和规范化设置是提高效率的关键。通过自定义导入后处理器,可以自动为不同类型的模型应用合适的设置,避免手动操作的遗漏和错误。

光照贴图烘焙是移动场景的核心技术。通过将静态光照预计算并存储在纹理中,可以在运行时获得高品质的光照效果,同时避免实时光照的性能开销。本章详细讲解了烘焙前的准备工作、参数设置、常见问题排查等内容。

天空盒和摄影机特效是提升画面品质的锦上添花。通过动态天空盒控制、雾效配合、后处理特效的合理使用,可以让场景呈现出丰富的情感和氛围。

最后,我们总结了移动端场景制作的几条铁律:面数控制、纹理优化、LOD使用、资源共用。这些原则来自于《暗黑王朝》的实际开发经验,也是任何移动游戏开发都必须遵循的基本准则。

通过本章的学习,读者应该能够建立起移动端场景构建的完整知识体系,理解从模型到最终场景的每一个环节的技术要点,并能够在实际项目中灵活应用这些技术,创造出既美观又流畅的游戏世界。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐