搜索Unity

在移动设备上使用光源烘焙预制件

以及在低端手机上达到60fps的其他简单窍门

上次更新时间:2019年1月

本页内容简介:MetalPop Games软件工程师Michelle Martin解释了他们如何针对一系列移动设备优化他们的全新移动策略游戏Galactic Colonies,从而覆盖最多的潜在玩家。

MetalPop Games面临的挑战是:玩家可能在低端设备上建设巨大的城市,但又不能降低帧率或使设备过热。了解他们如何在良好的视觉效果和稳定的性能之间找到平衡。

低端设备上的城市建设

尽管现在的移动设备功能强大,但要以稳定的帧率运行美观的大型游戏环境仍然十分困难。在较旧的移动设备上,运行大型3d环境并且达到稳定的60fps帧率可能挑战极大。

作为开发者,我们可以瞄准高端手机,并假设大多数玩家都拥有足够好的硬件来顺畅地运行我们的游戏。但是这会将大量潜在玩家排除在外,因为很多人仍在使用旧款设备。如果可以避免,您并不想放弃所有这些潜在客户。

在我们的游戏《银河殖民地》中,玩家可以殖民外来行星,并通过大量的单个建筑建造巨大的殖民地。虽然较小的殖民地可能只有十几座建筑,但较大的殖民地往往拥有数百座建筑。

以下是我们开始构建管线时的目标清单:

  • 我们希望创建涵盖大量建筑的地图
  • 我们希望能在较便宜和/或较旧的移动设备上快速运行游戏
  • 我们希望创建精美的灯光和阴影效果
  • 我们希望构建简单且可维护的生产管线
移动游戏中的光照:常见挑战

游戏中光照良好对于3D模型的显示效果非常关键。在Unity中当然非常简单:设置亮度和动态光源就行了。如果您需要关注性能,只需烘焙所有光源,并且通过后期处理栈添加一些SSAO及其他养眼的位。就这样了,出货!

在移动游戏中设置光源通常需要很多技巧和解决方法。例如,除非您是面向高端设备,否则最好不要使用任何后期处理效果。同样,充满动态光源的大型场景也会大幅降低帧率。

实时光照的成本非常高,哪怕是台式机也是如此。而移动设备的资源更加有限,根本无法承载您想要的所有出色功能。

营救时的光照烘焙

在移动设备上,资源非常有限,您不希望场景中太多不必要的花俏光源耗尽用户的电池。

如果总是将设备推到硬件的极限,手机会过热,为了保护自己,它会自动减速。这时您通常要开始烘焙每个不需要投射实时阴影的光源。

Unity光源烘焙预制件用于制作移动策略游戏MetalPopGames Galactic Colonies

(具有大量建筑的Galactic Colonies中的常见关卡)

我们对此并没有决定权,因为我们的游戏世界是由玩家实时构建的。事实上,新的区域不断被发现,更多的建筑在建立,现有建筑在升级,阻止了任何类型的高效光源烘焙。

坚持不懈

光源烘焙过程将预先计算(静态)场景的高光和阴影区域,并将信息存储在光照贴图中。

此过程将指示渲染器将模型的哪些地方变亮或变暗,从而创建光源的视觉效果。

这种方式的渲染速度非常快,因为所有成本高昂而缓慢的光源计算已在线下完成,在运行时,渲染器(着色器)只需在纹理中查询结果。

这里的权衡是您必须提供一些能够增大构建大小的额外光照贴图纹理,并且在运行时需要一些额外的纹理内存。

您还要关闭一些空间,因为您的网格需要光照贴图UV和增大位。

但整体而言,您的速度会大幅提升。

Unity光源烘焙预制件用于制作移动策略游戏MetalPopGames

(具有和没有光照贴图的建筑)

不过,如果是玩家能够不断改变的动态世界,只点击烘焙按钮是没有用的。

在为高度模块化的场景烘焙光源时,会面临许多问题。

烘焙光源预制件从未如此容易

首先,Unity中的光源烘焙数据会存储并直接与场景数据关联。如果您有个别关卡和预构建的场景,并且只有少量动态对象,那么这不是问题。您可以预先烘焙光照。

这在动态创建关卡时显然不行。在城市建设游戏中,世界不是预建的,基本上是玩家即时动态装配的,在哪里建什么完全由玩家决定。这通常是在玩家决定建设的地方通过实例化预制件来完成。

此问题唯一的解决方案是将所有相关的光源烘焙数据存储在预制内中,而非场景中。

遗憾的是,没有简单的方法将要使用的光照贴图数据及其坐标和比例复制到预制件。

为烘焙光源预制件构建管线

获取可靠管线处理光源烘焙预制件的最佳方法是在不同的单独场景(实际上是多个场景)中创建预制件,然后在需要时将它们加载到主游戏中。

每个模块化部分进行光源烘焙,然后在需要时加载到游戏中。

进一步分析光源烘焙在Unity中的运行过程会发现,渲染光源烘焙的网格实际上只是向其应用另一个纹理,并且使该网格变亮或变暗(有时是彩色化)一个位。您只需要光照贴图纹理和UV坐标 - 两者由Unity在光源烘焙过程中创建。

在光源烘焙过程中,Unity会为个别网格创建一组新的UV坐标(指向光照贴图纹理)以及偏移和缩放。每次重新烘焙光源都会改变这些坐标。

如何利用UV通道

要开发此问题的解决方案,了解UV通道如何运行以及如何最佳利用它们很有帮助。

每个网格都可有多个UV坐标集(在Unity中称为UV通道)。在大多数情况下,一组UV就够了,因为不同的纹理(漫射、反射、凹凸等)都会将信息存储在图像的相同位置。

但当对象共享某个纹理(如光照贴图)并且需要在一个大型纹理中查找特定位置的信息时,通常没有办法添加另一组用于此共享纹理的UV。

使用多个UV坐标的弊端在于它们会耗用额外的内存。如果使用的UV是两组而不是一组,则每一个网格的顶点都有两倍的UV坐标量。对于每个顶点,将会额外存储两个浮点数,在渲染时也会将它们上传到GPU。

创建预制件

Unity使用常规光照烘焙功能生成坐标和光照贴图。引擎会将光照贴图的UV坐标写入模型的第二个UV通道。请务必注意,此时无法使用主要UV坐标集,因为模型需要展开。

想象一个对其每张幻灯片使用相同纹理的方框:该方框的个别幻灯片都有相同的UV坐标,因为它们重复使用相同的纹理。但这对光照贴图的对象行不通,因为方框的每边都会分别被光源和阴影命中。每边在包含其个别光照数据的光照贴图都需要自己的空间。因此需要一组新的UV。

因此,要设置新的光源烘焙预制件,我们只需要存储纹理及其坐标,以免它们丢失,然后将其复制到预制件。

在光源烘焙完成后,我们运行经过场景中所有网格的脚本,将应用了偏移和缩放值的UV坐标写入网格的实际UV2通道。

修改网格的代码非常简单:

Mesh meshToModify = GetComponent().sharedMesh;
Vector4 lightmapOffsetAndScale = GetComponent().lightmapScaleOffset;

Vector2[] modifiedUV2s = meshToModify.uv2;
for (int i = 0; i < meshToModify.uv2.Length; i++)
{
    modifiedUV2s[i] = new Vector2(meshToModify.uv2[i].x * lightmapOffsetAndScale.x +
    lightmapOffsetAndScale.z, meshToModify.uv2[i].y * lightmapOffsetAndScale.y +
    lightmapOffsetAndScale.w);
}
meshToModify.uv2 = modifiedUV2s;

更具体一点:操作的只是网格副本,而非原件,因为我们在烘焙过程中会进一步优化网格。

副本会自动生成,保存到预制件,并为其分配自定义着色器的新材质和新建的光照贴图。原始网格保持不动,光源烘焙的预制件可立即使用。

自定义光照贴图着色器

因此工作流程非常简单。要更新图形的样式和外观,只需打开适当的场景,修改到满意为止,点击并开始自动烘焙和复制过程。当此过程完成时便大功告成,游戏将开始使用更新的预制件和更新了光照的网格。

实际光照贴图纹理由自定义着色器添加,它在渲染期间将光照贴图作为第二个光源纹理应用到模型。

着色器非常简短,除了应用色彩和光照贴图之外,还会计算简单的伪反射/光泽效果。

Shader "Custom/LightmappedPrefabWithSpec"
{
  Properties
  {
    _MainTex("Base (RGB)", 2D) = "white" {}
    _Lightmap("Lightmap", 2D) = "white" {}
    _Specmap("Specmap", 2D) = "white" {}
    _SpecularAtt("Glossiness", Range(0.1, 2)) = 0.5
    _SpecularAmt("Specular", Range(0, 1)) = 0.5
  }

  SubShader
  {
    Tags{ "Queue" = "Geometry+1" }
    Pass
    {
      CGPROGRAM

      // Defining the name of the vertex shader
      #pragma vertex vert

      // Defining the name of the fragment shader
      #pragma fragment frag


      // Include some common helper functions,
      // specifically UnityObjectToClipPos and DecodeLightmap.
      #include "UnityCG.cginc"

      // Color Diffuse Map
      sampler2D _MainTex;
      // Tiling/Offset for _MainTex, used by TRANSFORM_TEX in vertex shader
      float4 _MainTex_ST;


      // Lightmap (created via Unity Lightbaking)
      sampler2D _Lightmap;
      // Tiling/Offset for _Lightmap, used by TRANSFORM_TEX in vertex shader
      float4 _Lightmap_ST;

      // Grayscale Map indicating which parts of the models have specular
      // Note: _Specmap_ST is not needed, as this map is using the same
      // UVs as for the _MainTex.
      sampler2D _Specmap;

      // This is the vertex shader input: position, UV0, UV1, normal
      // UV1 (= second UV channel) needed for the lightmap texture coordinates
      struct appdata
      {
        float4 vertex   : POSITION;
        float2 texcoord : TEXCOORD0;
        float2 texcoord1: TEXCOORD1;
        float3 normal: NORMAL;
      };

      // This is the data passed from the vertex to fragment shader
      struct v2f
      {
        float4 pos  : SV_POSITION; // position of the pixel
        float2 txuv : TEXCOORD0; // for accessing the diffuse color map
        float2 lmuv : TEXCOORD1; // for accessing the light map
        float3 normalDir : TEXCOORD2; // for fake specular
      };

      // This is the vertext shader, doing nothing special at all.
      // Most notably it is calculating the surface normal, because that
      // is needed for the fake specular lighting in the fragment shader.
      v2f vert(appdata v)
      {
        v2f o;
        o.pos = UnityObjectToClipPos(v.vertex);
        o.txuv = TRANSFORM_TEX(v.texcoord.xy, _MainTex); // using _MainTex_ST
        o.lmuv = TRANSFORM_TEX(v.texcoord1.xy, _Lightmap); // using _Lightmap_ST

        // Calculating the normal of the vertex for the fragment shader
        float4x4 modelMatrixInverse = unity_WorldToObject;
        o.normalDir = normalize(mul(float4(v.normal, 0.0), modelMatrixInverse).xyz);

        return o;
      }

      uniform float _SpecularAtt;
      uniform float _SpecularAmt;

      // Fragment Shader
      half4 frag(v2f i) : COLOR
      {
        // Reading color directly from the diffuse texture, using first UV channel
        half4 col = tex2D(_MainTex, i.txuv.xy);
        // Reading specular (on/off) value from spec map texture
        half4 specVal = tex2D(_Specmap, i.txuv.xy);
        // Reading lightmap value from the lightmap texture
        half4 lm = tex2D(_Lightmap, i.lmuv.xy);

        // Fake specular light angle calculation with a hard-coded light direction
        half3 th = normalize(half3(0, 1, -0.25));
        float spec = max(0, dot(i.normalDir, th));

        // Adjusting by overall specular amount and glossyness (material parameters)
        spec = _SpecularAmt * pow(spec, 40.0 * _SpecularAtt);
        // We're just using red value of the specular texture, like a grayscale map,
        // although technically spec could be colored.
        // Example: float3 specCol = specVal * spec;
        spec = spec * specVal.r;

        // Calculating the final color of the pixel by bringing it all together
        col.rgb = min(half4(1,1,1,1), col.rgb * DecodeLightmap(lm) + col.rgb * spec);
        return col;
      }
      ENDCG
    }
  }
  Fallback "Diffuse"
}

根据上述情况使用着色器的材质设置图像如下:

Unity光源烘焙预制件用于制作移动策略游戏材质设置MetalPopGames

(使用自定义着色器的材质设置)

设置和静态批处理

在本例中,有四个全部设置了预置件的不同场景。我们的游戏具有不同的生物群区,如热带、冰川、沙漠等,我们对场景进行了相应的分离。在您的游戏中,根据您的要求,场景数可能不同。

要用于指定场景的所有预制件共享一个光照贴图。这意味着除了只共享一种材质的预制件之外,还有一种额外的纹理。

因此,我们能够将所有模型渲染成静态,只需要一个绘制调用便可批量渲染几乎整个世界。

Unity光源烘焙预制件用于制作移动策略游戏烘焙级别MetalPopGames

(Unity编辑器中的烘焙级别)

其中设置了所有瓦片/建筑的光源烘焙场景具有额外的光源,可用于创建局部高光区域。您可以按需要在设置场景中放置多个光源,因为它们无论如何都会烧掉。

烘焙过程在自定义UI对话中处理,所有必要的步骤都要完成。它将确保:

  • 将合适的材质分配至所有网格
  • 隐藏整个过程中所有无需烘焙的物件
  • 合并或烘焙网格
  • 复制紫外光谱并创建预制件
  • 正确地命名所有内容,并从版本控制系统中找到所需文件

Unity光源烘焙预制件用于制作移动策略游戏自定义检视面板MetalPopGames

(简易工作流程的自定义检视面板)

正常命名的预制件在网格外部创建,因此游戏代码可以加载并直接使用。元数据文件在此过程中也可更改,以免丢失预制件网格的参考。

此工作流程可让我们按照需要调整建筑,以我们喜欢的方式照亮它们,然后让脚本处理一切。

当我们切换回主场景并运行游戏时,调整就会生效 - 无需手动参与或其他更新。

伪光源和动态位

100%光照预烘焙的场景有一个明显的缺点:很难有任何动态的对象或运动。投射阴影的任何对象都需要实时光源和阴影计算,当然,我们都想避免。但如果没有任何移动的对象,3D环境就像静态的,没有活力。

我们愿意承受一些限制,因为我们的首要优先任务是实现好的视觉效果和快速渲染。要创建充满活力、动态的殖民空间或城市,不需要实际移动全部对象。大多数对象并不一定需要阴影,或至少不会注意到没有阴影的情况。

在我们的案例中,我们先将所有城市构建块拆分为两个单独的预制件:一个静态部分,其中包含大多数顶部、网格的所有复杂位;一个动态部分,其中包含最少的顶点。

预制件的动态部分是放在静态部分上面的动画位。它们未进行任何光源烘焙,我们使用非常快速、简单的光照着色器来创建亮度,以动态点亮对象。

Shader "Custom/FakeLighting"
{
  Properties
  {
    _Color ("Color", Color) = (1,1,1,1)
    _Brightness ("Brightness", Range(0,1)) = 0.4
    _MainTex ("Albedo (RGB)", 2D) = "white" {}
  }

  SubShader
  {
    Tags { "RenderType" = "Opaque" }
    LOD 200
    Pass
    {

      CGPROGRAM

      // Define name of vertex shader
      #pragma vertex vert
      // Define name of fragment shader
      #pragma fragment frag

      // Include some common helper functions, such as UnityObjectToClipPos
      #include "UnityCG.cginc"

      float4 _Color;
      float _Brightness;

      // Color Diffuse Map
      sampler2D _MainTex;
      // Tiling/Offset for _MainTex, used by TRANSFORM_TEX in vertex shader
      float4 _MainTex_ST;

      // This is the vertex shader input: position, UV0, UV1, normal
      struct appdata
      {
        float4 vertex   : POSITION;
        float2 texcoord : TEXCOORD0;
        float3 normal: NORMAL;
      };

      // This is the data passed from the vertex to fragment shader
      struct v2f
      {
        float4 pos  : SV_POSITION;
        float2 txuv : TEXCOORD0;
        float3 normalDir : TEXCOORD2;
      };

      // This is the vertex shader
      v2f vert(appdata v)
      {
        v2f o;
        o.pos = UnityObjectToClipPos(v.vertex);
        o.txuv = TRANSFORM_TEX(v.texcoord.xy,_MainTex);

        // Calculating normal so it can be used for fake lighting
        // in the fragment shader
        float4x4 modelMatrixInverse = unity_WorldToObject;
        o.normalDir = normalize(mul(float4(v.normal, 0.0), modelMatrixInverse).xyz);

        return o;
      }

      // This is the fragment shader
      half4 frag(v2f i) : COLOR
      {
        // Reading color from diffuse texture
        half4 col = tex2D(_MainTex, i.txuv.xy);

        // Using hard-coded light direction for fake lighting
        half3 th = normalize(half3(0.25, 1, -0.25));
        // Using hard-coded light direction for fake specular
        // This matches the value inside the LightmappedPrefabWithSpec shader
        half3 sth = normalize(half3(0, 1, -0.25));

        // Fake lighting
        float lightVal = max(0, dot (i.normalDir, th));
        float lightScale = 0.75;
        lightVal = lightVal * lightScale;

        // Fake spec
        float spec = max(0, dot(i.normalDir, sth));
        float specScale = 0.65;
        float specAtt = 0.65;
        spec = specScale * pow (spec, 40.0 * specAtt);

        // Add in a general brightness (similar to ambient/gamma) and then
        // calculate the final color of the pixel
        col.rgb = min(half4(1,1,1,1), col.rgb * _Brightness +
                      col.rgb * lightVal * _Color + col.rgb * spec);
        return col;
      }

      ENDCG
    }
  }
  FallBack "Diffuse"
}

对象也没有阴影,或者我们创建伪阴影作为动态位的一部分。大多数表面是平面,因此在我们的案例中没有大的障碍。

Unity光源烘焙预制件用于制作移动策略游戏Galactic Colonies MetalPopGames

(使用其他动态对象构建)

动态位没有阴影,但很少被注意到,除非您特意去看。动态预制件的光照也是完全伪造的 – 根本没有实时光照。

我们采用的第一条简便捷径是将光源(太阳)位置硬编码到伪光照着色器。它是着色器确定需要查找并从世界动态填充的阴影。

使用常量肯定比使用动态值要快。这让我们获得网格的基本光照、光亮面和阴暗面。

镜面/光泽

为了让事物更加闪亮,我们为动态和静态对象的着色器添加了伪镜面/光泽计算。镜面反射有助于创建金属外观,还有助于传递曲面的曲率。

由于镜面高亮是反射形式,因此需要互为相关的摄像机角度与光源才可正确计算。当摄像机移动或旋转时,镜面会变动。任何着色器计算都需要访问摄像机位置和场景中的每个光源。

但是,在我们的游戏中,我们只有一个有兴趣用于镜面的光源:太阳光。在我们的案例中,太阳光永远不会移动,可视为定向光源。只使用一个光源时,只需考虑固定位置和其入射角,因此可大幅简化着色器。

更有利的是,我们在Galactic Colonies中的摄像机像大多数城市建设游戏一样,采用自上而下的视图显示场景。摄像机可以倾斜一点、放大和缩小,但不能围绕上方向轴旋转。

整体而言,它总是从上面查看环境。为伪造简单的镜面外观,我们假定摄像机完全固定,并且摄像机与光源之间的角度始终不变。

这样我们可以再次将常量值硬编码到着色器中,获得简单的镜面/光泽效果。

Unity光源烘焙预制件用于制作移动策略游戏Galactic Colonies MetalPopGames

(伪镜面/光泽效果)

对镜面使用固定角在技术上当然不正确,但实际上,只要摄像机角度不改变很多,是不可能真正识别出差异的。在玩家看来,场景仍然是正确的,这就是实时光照的重点。

实时视频游戏中的环境光照主要是并且始终是视觉上显示正确,而不是从物理上的实际正确模拟。

由于几乎所有网格都共享一种材质,因此我们在镜面纹理贴图中添加了来自光照贴图和顶点的大量细节,以指示着色器何时何地应用镜面值以及应用强度。使用主要UV通道访问纹理,因此不需要其他坐标集。还因为其中没有很多细节,所以分辨率很低,几乎不占用什么空间。

对于一些顶点较少的小动态位,我们甚至可以利用Unity的自动动态批处理,进一步加快渲染速度。

光源烘焙预制件上面的光源烘焙预制件

所有烘焙的阴影有时可能产生新问题,特别是在使用高度模块化的建筑时。在一个玩家可以建仓库的游戏中,会在实际建筑上显示仓库中存放的货物类型。

这会造成问题,因为我们在光源烘焙对象上还有光源烘焙对象。光源烘焙-感知!

我们使用另一个简单的技巧解决了问题:

  • 必须在平坦的表面添加额外对象,并使用与基础建筑相匹配的特定灰色
  • 作为交换,我们可以在一个更小的平面上烘培对象,再将其放置在一个有轻微偏移的区域的顶部
  • 灯光、高光、彩色光和阴影全部烘焙成瓦片

Unity光源烘焙预制件用于制作移动策略游戏Galactic Colonies MetalPopGames

(烘焙对象使用蓝色光并投射阴影)

简单高效的光源烘焙 – 如果您可以接受限制

以这种方式构建和烘焙预制件可以获得包含数百幢建筑的巨大地图,同时保持超低绘制调用数。我们的整个游戏世界或多或少只用一种材质渲染,我们面对的状态是:UI使用的绘制调用多于游戏世界。Unity必须渲染的不同材质越少,游戏的性能就越佳。

这让我们有很大的空间在游戏世界中添加更多事物,如粒子、天气影响及其他吸睛的元素。

这样,即便是使用旧设备的玩家,也能够在保持稳定60 fps的同时建造拥有数百栋建筑的大城市。

Unity光源烘焙预制件用于制作移动策略游戏Galactic Colonies MetalPopGames

(一个由玩家使用烘焙光源预制件建造的领地)

随附适合您的游戏的功能

我们的解决方案是对游戏进行定制,以渲染拥有繁忙都市的大行星。我们选择了对我们有用的东西。采用有限的摄像机角度以及场景中的单一光源,从而大大降低了着色器的复杂性。此外,还允许我们在照明动态对象和以固定角度计算镜面反射方面走些捷径。我们减少了动态对象的数量,并静态地批量处理其余的对象。

当然,我们的简单多边形和仅含色彩的视觉风格,也让很多事情变得更为容易,因为这允许我们在几乎所有的对象之间共享材料,从而让静态和动态批处理成为可能。

您很可能无法将这里描述的所有解决方案原封不动地转移到您的游戏中,不过这样也没有关系,这个本来就不一定能实现。它们是起点,您可以在其基础上找到优化自己游戏的最佳方法。找到您游戏可以接受的限制和约束,并利用它们让您的游戏更快地运行。

更多资源

您喜欢本文吗?请告诉我们!

喜欢。继续发送 还行。有待改进
明白了

我们使用Cookie来确保在我们的网站上为您提供最佳体验。有关更多信息,请访问我们的Cookie政策页面