Design Hub

    • 注册
    • 登录
    • 搜索
    • 版块
    • 最新

    [Unity]🌿🌿🌿🌿🌿🌿

    游戏开发
    1
    1
    368
    正在加载更多帖子
    • 从旧到新
    • 从新到旧
    • 最多赞同
    回复
    • 在新帖中回复
    登录后回复
    此主题已被删除。只有拥有主题管理权限的用户可以查看。
    • Pamisu
      Pamisu 管理员 最后由 编辑

      本篇笔记包含草的常见制作方式介绍,以及对GitHub上一个教科书级仓库的分析与学习。

      接上篇大批量物体渲染学习笔记,老是拿一堆方块做实验实在是太无聊了,所以在做遮挡剔除之前不如先来生草吧。

      草的实现方式多种多样,网上也有很多相关文章教程,个人了解的有星形结构、广告牌、几何着色器等,每种方式各有优缺点。

      星形结构

      星形结构是相对便宜的一种方式,为了确保在不同观察角度下都能有较密集的效果,这种草的模型通常做成若干个面片相互穿插的样式,模型的顶点数较少,性能开销相对较小;缺点是从草的上方往下看时容易穿帮,可以通过增加插片面数改善。

      不同LOD下的模型,越近面数越多,细节也越丰富:

      使用商店中草模型的实现效果:

      对于这种草的批量渲染,在大批量物体渲染学习笔记(二)中已经介绍过了,只需依葫芦画瓢修改草的Shader、配置好Renderer的各项参数即可:

      LOD暂时没实现,以后慢慢填坑。

      关于星形结构,更详细的介绍可以参考《GPU 精粹》:

      https://developer.nvidia.com/gpugems/gpugems/part-i-natural-effects/chapter-7-rendering-countless-blades-waving-grass

      广告牌

      很多人在初学Unity时应该就接触到了广告牌(Billboarding)技术,粒子系统的默认渲染模式就是它。与星形结构相比,广告牌的思路更加直接:既然要兼顾每个角度的效果,那么干脆一直朝着相机。运用这种思路,让每颗草在渲染时都始终朝着相机方向,不同观察角度下都能有很好的效果。

      GitHub上ColinLeung-NiloCat大神的这个仓库演示了通用渲染管线下广告牌草的实现:

      https://github.com/ColinLeung-NiloCat/UnityURP-MobileDrawMeshInstancedIndirectExample

      它的运行效果:

      具体有多牛就不吹了,可以去看简介,这里主要记录对这个仓库的学习过程,根据个人需求有一些改动,但思路是差不多的。

      整体实现思路:

      1. 在一个区域内,生成并记录大量草的位置。
      2. 在CPU侧对草进行粗粒度的剔除。
      3. 在GPU侧对草进行细粒度的剔除。
      4. 使用Graphics.DrawMeshInstancedIndirect渲染。
      5. 加一些特技。

      虽说是广告牌草,但广告牌的实现并不是重点(因为比较简单),大批量渲染中如何优化性能更为重要,这里先侧重介绍仓库中的优化技巧,也就是上面提到的剔除步骤。

      位置生成

      生成方式多种多样,可以是随机生成,也可以是通过刷草工具手刷。我的实现方式是在一个区域内,以一定的密度(分辨率)对区域内的点采样柏林噪声,判断是否符合植被生成条件(采样值超过阈值),符合则记录当前点的坐标,并通过射线取得高度。使用这种方法生成的树木:

      当然最简单的做法,直接在区域内随机就行了,只做demo可以不用搞这么花里胡哨。

      视锥剔除

      原仓库中,视锥剔除分为两步:粗粒度的CPU侧剔除与细粒度的GPU侧剔除。大致思路:

      1. 将整个渲染区域在逻辑上分为大小均等、顺序排列的若干块,每块包含若干颗草。
      2. 先对每个分块的包围盒做视锥剔除,记录下可见的分块,这一步在CPU侧进行。
      3. 再对可见分块中的草做视锥剔除,得到最终需要渲染的草,这一步在GPU侧进行。

      这种方式有效地减轻了GPU的压力,并且通过调整分块的大小,可以让剔除工作更侧重于CPU或GPU。

      分块

      原仓库的分块并没有考虑到草的y轴,即只适用于平面情况,所以我重写了这部分。整个分块逻辑总结为一张图:

      图中为xz平面,RenderBounds为渲染区域,Cell为分块,里面看起来像草的东西是草,数字是它们在数组中的下标,某些分块中可能存在没有草的情况。下面结合代码解释其具体含义。

      首先需要获得所有草的位置与渲染区域(RenderBounds),同时为了兼容先前的星形结构,定义了这样一个渲染父类:

      ObjectRenderer.cs

      public abstract class ObjectRenderer : MonoBehaviour
      {
          public abstract void SetRenderBounds(Vector3 min, Vector3 max);
          
          public virtual void UpdatePositions(List<Vector3> list) { }
      }
      

      这两个方法分别让外部传入渲染区域的边界(最小点与最大点)及更新要渲染的物体的位置。上一步中已经准备好了渲染区域,在外部调用SetRenderBounds方法设置;已经生成好了所有草的位置,则调用UpdatePositions将它们传入。

      一般来说广告牌草只用到位置,星形草如果要旋转自身以贴合地面,则可以使用另一个方法来更新变换矩阵:

      public virtual void UpdateTransforms(List<Matrix4x4> list) { }
      

      新建一个GrassRenderer继承它,并实现这两个方法:

      GrassRenderer.cs

      public class GrassRanderer : ObjectRenderer 
      {
          [SerializeField]
          private Vector3 cellSize;
      
          private Bounds renderBounds;
          private Vector3Int cellCount;
      
          public override void SetRenderBounds(Vector3 min, Vector3 max)
          {
              // 记录边界,用于渲染
              renderBounds = new Bounds();
              renderBounds.SetMinMax(min, max);
              // 根据渲染区域大小与分块大小计算出分块数量
              cellCount = new Vector3Int();
              cellCount.x = Mathf.CeilToInt(renderBounds.size.x / cellSize.x); 
              cellCount.z = Mathf.CeilToInt(renderBounds.size.z / cellSize.z);
          }
      }
      

      renderBounds为渲染区域,根据设定的分块大小将其划分,计算出分块(cell)数量。

      然后准备分块,定义分块结构体:

      private struct Cell
      {
          // 分块的包围盒
          public Bounds bounds;   
          // 分块中第一个物体在allPositions中的下标
          public int index; 
          // 分块中的物体数量  
          public int count;   
      }
      

      这里的index对应上面图中Cell中第一颗草的编号,count对应分块中草的数量。

      在UpdatePositions方法中,外部传入了所有草的位置,将每个位置放到其所在分块中:

      GrassRenderer.cs

      // 所有分块
      private Cell[] cells;
      // 排序后的所有草位置
      private Vector3[] allPositions;
      
      public override void UpdatePositions(List<Vector3> list)
      {
          // ①将所有物体分块
          // positionsInCell用来存放每个分块中包含的物体位置下标
          List<int>[] positionsInCell = new List<int>[cellCount.x * cellCount.z];
          for (int i = 0; i < list.Count; i++)
          {
              var pos = list[i];
              int x = Mathf.FloorToInt(((pos.x - renderBounds.min.x) 
                  / renderBounds.size.x) * cellCount.x);
              x = Mathf.Min(cellCount.x - 1, x);
              int z = Mathf.FloorToInt(((pos.z - renderBounds.min.z) 
                  / renderBounds.size.z) * cellCount.z);
              z = Mathf.Min(cellCount.z - 1, z);
              var index = x + z * cellCount.x;
              if (positionsInCell[index] == null) 
                  positionsInCell[index] = new List<int>();
              positionsInCell[index].Add(i);
          }
          ...
      }
      

      然后按将所有位置排序成上图所示,并初始化所有分块数据:

      GrassRenderer.cs

      public override void UpdatePositions(List<Vector3> list)
      {
          // ①将所有物体分块
          ...
          // ②按分块重新排序
          // 排好序后的所有物体位置存放到新的数组中
          allPositions = new Vector3[list.Count];
          // 分块数组存放所有分块数据
          cells = new Cell[cellCount.x * cellCount.z];
          for (int i = 0, index = 0; i < positionsInCell.Length; i++)
          {
              cells[i] = new Cell();
              cells[i].index = index;
              cells[i].count = 0;
              var positions = positionsInCell[i];
              if (positions != null)
              {
                  cells[i].count = positions.Count;
                  Bounds bounds = new Bounds(list[positions[0]], Vector3.zero);
                  for (int j = 0; j < positions.Count; j++, index++)
                  {
                      allPositions[index] = list[positions[j]];
                      bounds.Encapsulate(list[positions[j]]);
                  }
                  cells[i].bounds = bounds;
              }
          }
          // ③更新Buffer
          UpdateBuffers();
      }
      

      方法中最后一步是熟悉的更新Buffer,和之前笔记中的差不多,就不重复说明了,之后文章末尾会更新完整代码地址。

      粗粒度剔除

      分好块后,就可以在每一帧进行剔除工作了。Cell中记录了它的包围盒信息,使用Unity提供的API进行AABB测试,判断分块是否在视野内:

      GrassRenderer.cs

      private List<int> visibleCells = new List<int>();
      private Plane[] cameraFrustumPlanes = new Plane[6];
      
      private void LateUpdate()
      {
          if (cells == null || cells.Length == 0)
              return;
          // CPU侧粗粒度剔除
          var cam = Camera.main;
          // 临时改变远裁剪平面,可以控制绘制距离
          float cameraOriginalFarPlane = cam.farClipPlane;
          cam.farClipPlane = maxDrawDistance;
          GeometryUtility.CalculateFrustumPlanes(cam, cameraFrustumPlanes);
          cam.farClipPlane = cameraOriginalFarPlane;
          // AABB测试
          visibleCells.Clear();
          for (int i = 0; i < cells.Length; i++)
          {
              if (cells[i].count == 0)
                  continue;
              if (GeometryUtility.TestPlanesAABB(cameraFrustumPlanes, 
                  cells[i].bounds))
                  visibleCells.Add(i);
          }
      }
      

      细粒度剔除

      得到当前可见的分块后,使用ComputeShader对可见分块中的每颗草做视锥剔除。上一篇笔记中提到,像草这种较小的物体,可以通过对它在裁剪空间下的齐次坐标进行判断来做剔除,这里采用的也是这种方法:

      GrassCulling.compute

      #pragma kernel CSMain
      
      float4x4 _VPMatrix; // VP矩阵
      float _MaxDrawDistance; // 最大绘制距离
      uint _StartOffset; // 物体位置的起始下标
      StructuredBuffer<float3> _AllPositionsBuffer;
      AppendStructuredBuffer<uint> _VisibleIDsBuffer;
      
      [numthreads(64, 1, 1)]
      void CSMain (uint3 id : SV_DispatchThreadID)
      {
          // 世界空间转换至裁剪空间
          float4 absPosCS = abs(mul(_VPMatrix, 
              float4(_AllPositionsBuffer[id.x + _StartOffset], 1.0)));
          // 进行判断,有一些与草大小相关的硬编码
          if (absPosCS.z <= absPosCS.w
              && absPosCS.y <= absPosCS.w * 1.5
              && absPosCS.x <= absPosCS.w * 1.1
              && absPosCS.w <= _MaxDrawDistance)
              _VisibleIDsBuffer.Append(id.x + _StartOffset);
      }
      

      剔除是按分块进行的,所以需要_StartOffset来指定当前是对哪个分块内的草做剔除,对应上面Cell结构体中的index。

      调用ComputeShader得到剔除后的草并渲染:

      GrassRenderer.cs

      private ComputeShader compute;
      
      private void LateUpdate()
      {
          // CPU侧粗粒度剔除
          ...
          
          // GPU侧细粒度剔除
          var matrixVP = cam.projectionMatrix * cam.worldToCameraMatrix;
          visibleIDsBuffer.SetCounterValue(0);
          compute.SetMatrix("_VPMatrix", matrixVP);
          compute.SetFloat("_MaxDrawDistance", maxDrawDistance);
          for (int i = 0; i < visibleCells.Count; i++)
          {
              var cell = cells[visibleCells[i]];
              var startOffset = cell.index;
              var jobLength = cell.count;
              // 如果下一个可见分块在内存上是连续的,则合并处理
              while ((i < visibleCells.Count - 1)
                      && (visibleCells[i + 1] == visibleCells[i] + 1))
              {
                  jobLength += cells[visibleCells[i]].count;
                  i++;
              }
              compute.SetInt("_StartOffset", startOffset);
              compute.Dispatch(kernel, Mathf.CeilToInt(jobLength / 64f), 1, 1);
          }
          ComputeBuffer.CopyCount(visibleIDsBuffer, argsBuffer, sizeof(uint));
          
          // 渲染
          Graphics.DrawMeshInstancedIndirect(GetGrassMeshCache(), 0, 
              instanceMaterial, renderBounds, argsBuffer);
      }
      

      在Scene面板中可以看到明显的分块效果,白线是相机的视锥体:

      这一篇先写到这里,由于原仓库的草Shader有许多硬编码与Magic number,牙口不好啃起来有些吃力,下一篇(如果有)来学习草的Shader、交互与风吹草浪。

      关于开头提到的几何着色器(Geometry Shader)方式,推荐一篇文章:

      https://roystan.net/articles/grass-shader

      大致的思路是利用几何着色器生成草叶,通过曲面细分着色器丰富草的密度。

      1 条回复 最后回复 回复 引用
      • 1 / 1
      • First post
        Last post
      版权所有 ©2023 Design Hub 保留所有权利
      鄂ICP备2021004164号-2