Design Hub

    • 注册
    • 登录
    • 搜索
    • 版块
    • 最新
    1. 主页
    2. Pamisu
    3. 帖子
    • 资料
    • 关注 0
    • 粉丝 0
    • 主题 22
    • 帖子 65
    • 群组 1

    Pamisu 发布的帖子

    • [Unity]带照明效果的2D激光束

      2D中激光束算是比较常见了,实现起来也较为简单,但为了让它能真正达到照明的效果还是得花些功夫,记录一下实现过程。思路基本参考油管上一个印度小哥的教程,有一些修改,小哥的连连看部分略显复杂,我简化了一下;小哥没有做照明效果,这里额外进行实现。

      视频在比比汗丽丽上有人搬运,不过机翻质量比较糟糕:
      BV1pM4y1L7C6

      整体思路:

      • 使用Line Renderer制作激光束,用ShaderGraph制作激光的Shader。
      • 使用类型为Freeform的Light 2D实现光照,通过代码动态修改光照形状。
      • 加一些特技。

      准备

      要使用ShaderGraph做Shader,那么先得把它装好,这里使用统一渲染管线(URP),可以直接新建一个URP项目,也可以在Package Manager里安装然后配置。如果不知道URP是啥、不清楚要怎么配置,推荐看一下麦扣的保姆级教学:

      https://www.bilibili.com/video/BV1t54y1d7DW

      激光部分

      材质

      先进行连连看环节,制作激光的材质。新建一个Sprite Unlit Shader Graph,取名Laser。

      激光的图形部分,对Voronoi节点在x轴上稍微拉伸,并且让它随时间在x轴上偏移,这样看起来会有一种电流的感觉:

      什么是Voronoi?它是一种程序生成的纹理,节点输入UV可以控制对这张纹理的采样,这里自定义了两个参数,Scale用来调节这张纹理的缩放,Speed乘以时间控制纹理的移动。这里也可以根据需要使用其他的噪声纹理,好看就行。

      激光的边缘部分需要有柔和渐变,按照下图的效果,只要让颜色在y轴方向由0渐变到1再渐变到0即可,用sin函数可以达到这个效果,再用指数函数控制边缘的厚度,即pow(sin(uv.y * PI), Edge):

      将两者相乘,再与自定义的颜色参数Color混合得到最终效果:

      颜色模式为HDR,在Bloom后处理下会有不错的效果;另外个人觉得有透明度更好些,所以顺便连接了Alpha。

      完整的ShaderGraph:

      以这个Shader新建一个材质Laser,调整各项参数:

      Line Renderer

      场景中新建一个名为Laser的物体,添加Line Renderer组件,拖入刚才的Laser材质;Texture Mode改为Tile,避免不同激光长度下拉伸不一致。

      可以顺便在场景中新建一个Volume,开启Bloom后处理:

      临时修改一下Line Renderer的Positions,场景中可以看到效果:

      交互

      接下来让它可以随着角色施法而改变位置,这里角色使用的是Asset Store里的一个小魔女素材,自带骨骼动画和控制脚本。

      如果对制作2D骨骼动画不太了解,推荐看看麦扣的这套骨骼动画教程:

      https://www.bilibili.com/video/BV1w7411F7qb

      期望效果是玩家点击鼠标左键,角色举起法杖,随后启用激光的LineRenderer组件,并设置它的起点与终点,向鼠标方向发射。编写激光脚本,并挂在Laser物体下:

      Laser2D.cs

      [RequireComponent(typeof(LineRenderer))]
      public class Laser2D : MonoBehaviour
      {
          LineRenderer line;
      
          void Awake()
          {
              line = GetComponent<LineRenderer>();
              SetEnable(false);
          }
      
          public void SetEnable(bool b)
          {
              line.enabled = b;
          }
      
          public void SetPositions(Vector3 start, Vector3 end)
          {
              line.SetPosition(0, start);
              line.SetPosition(1, end);
          }
      }
      

      之后将在角色控制脚本中调用这些方法。

      激光发射需要一个发射起始点,找到法杖的骨骼,在法杖头上添加发射点FirePoint:

      做一个施法的骨骼动画,并在最后一帧添加动画事件,触发角色脚本中的OnCastAnim方法:

      修改原有的角色控制脚本SimplePlayerController.cs,加入施法相关代码:

      PlayerController.cs

      public class PlayerController : MonoBehaviour
      {
          ...
          public Transform firePoint;
          public Laser2D laser;
          public LayerMask laserBlockLayer;
          ...
          private void Update()
          {
              ...
              if (alive)
              {
                  ...
                  // 发射激光
                  Cast();
                  ...
              }
          }
          ...
          void Cast()
          {
              // 当鼠标左键按下
              if (Input.GetMouseButton(0))
              {
                  // 获取鼠标在世界空间下的坐标
                  var mousePos = Camera.main.ScreenToWorldPoint(
                      Input.mousePosition);
                  // 判断发射方向
                  var direction = mousePos - firePoint.position;
                  // 向无限远处发射射线,找到激光目标点
                  var hit = Physics2D.Raycast(firePoint.position, direction, 
                      float.PositiveInfinity, laserBlockLayer);
                  if (hit)
                  {
                      // 设置位置,播放动画
                      laser.SetPositions(firePoint.position, hit.point);
                      anim.SetBool("isCasting", true);
                  }
              }
              else
              {
                  laser.SetEnable(false);
                  anim.SetBool("isCasting", false);
              }
          }
      
          public void OnCastAnim()
          {
              laser.SetEnable(true);
          }
      }
      

      这部分比较简单就不详细说明了,具体可以看注释,总之就是先这样这样,然后再那样那样。

      Laser物体放到角色之下,为各个变量赋好值,可以看到初步效果:

      光照部分

      在后处理Bloom效果下,这道激光看起来熠熠生辉,然而它并不能照亮周围的物体与场景,为了让激光具有照明效果,还需要添加光源。

      动态修改光照形状

      给Laser物体添加一个Light 2D脚本,Light Type为Freeform,点击Edit Shape按钮可以编辑它的形状:

      由于激光的形状会不断变化,固定的形状不能满足要求,因此需要在代码中根据激光当前的形状动态地修改Light 2D的形状。

      SetShapePath

      然而翻了一下API文档,Unity似乎并没有打算将形状属性开放给开发者修改,唯一和形状相关的属性只有一个shapePath,只允许get:

      更新:URP13.0(Unity2022.1)版本后,新增了官方的SetShapePath方法,不需要再用反射去设置形状了:

      与此同时还在用2020版本的我:

      如果你用的是新版,那么使用官方提供的SetShapePath就行,可以直接跳到下一节;如果你汗我一样还在用低版本,那么不妨看看我的解决思路,其实也不复杂。

      去Light 2D的源码中找找蛛丝马迹,在Light2DShape.cs中可以看到,shapePath被定义在Light2D的一个部分类中:

      m_ShapePath在Light2D.cs中的UpdateMesh方法中被使用:

      可以看到当光照类型为Freeform时,它将根据m_ShapePath更新光照的mesh。

      继续阅读源码可知,UpdateMesh方法在Awake、光照类型改变、Falloff改变、多边形光形状改变及Cookie的Sprite改变时会被调用,而光照类型为Freeform时形状改变的情况下不会被调用,这意味着更改m_ShapePath后,必须要手动调用UpdateMesh方法,否则光照的形状不会被更新。

      回到Laser2D.cs,编写一个SetShapePath方法,通过它来更新Light 2D的形状:

      Laser2D.cs

      void SetShapePath(Light2D light, Vector3[] path)
      {
          var field = light.GetType().GetField("m_ShapePath", 
              BindingFlags.NonPublic | BindingFlags.Instance);
          field?.SetValue(light, path);
          var method = light.GetType().GetMethod("UpdateMesh", 
              BindingFlags.NonPublic | BindingFlags.Instance);
          method?.Invoke(light, null);
      }
      

      通过反射获取到m_ShapePath,设值之后再调用UpdateMesh方法。但有一点需要注意,反射的性能是相对较差的,除非不得已最好不要频繁调用。

      构造形状并设置

      继续编写,加入根据起点与终点更改Light2D形状的处理:

      Laser2D.cs

      [RequireComponent(typeof(LineRenderer))]
      public class Laser2D : MonoBehaviour
      {
          [Tooltip("光照半径")]
          public float lightRadius = .5f;
      
          LineRenderer line;
          Light2D lit;
      
          void Awake()
          {
              line = GetComponent<LineRenderer>();
              lit = GetComponent<Light2D>();
              SetEnable(false);
          }
      
          public void SetEnable(bool b)
          {
              line.enabled = b;
              lit.enabled = b;
          }
      
          public void SetPositions(Vector3 start, Vector3 end)
          {
              line.SetPosition(0, start);
              line.SetPosition(1, end);
              // 更改Light2D形状
              if (start != end)
              {
                  var direction = end - start;
                  var localUp = Vector3.Cross(Vector3.forward, 
                      direction).normalized;
                  localUp = transform.InverseTransformDirection(localUp) 
                      * lightRadius;
                  var localStart = transform.InverseTransformPoint(start);
                  var localEnd = transform.InverseTransformPoint(end);
                  // 构造形状路径
                  var path = new Vector3[]
                  {
                      localStart - localUp,
                      localEnd - localUp,
                      localEnd + localUp,
                      localStart + localUp,
                  };
                  SetShapePath(lit, path);
              }
          }
          ...
      }
      

      这里将起点和终点转化为本地坐标(Light 2D形状使用本地坐标),分别给它们加、减一个方向相对于激光方向垂直向上、模长为光照半径的向量localUp,计算出四个顶点,且按逆时针顺序排列,形成一个矩形,运行可以看到初步效果:

      白色粗框为动态生成的形状,白色细框为Light 2D根据形状自动生成的Falloff区域。

      完善形状

      光是一个矩形还是难看了些,光照的边角看起来相当突兀。再给两边加上半圆,形成一个类似胶囊的形状。

      画圆本质上是画多边形,先定义好圆的顶点数量:

      Laser2D.cs

      [RequireComponent(typeof(LineRenderer))]
      public class Laser2D : MonoBehaviour
      {
      
          [Tooltip("圆的顶点数")]
          public int circleVertices = 10;
          ...
      

      删去原有构造矩形代码,改为构造胶囊形状:

      Laser2D.cs

      public void SetPositions(Vector3 start, Vector3 end)
      {
          ...
          // 更改Light2D形状
          if (start != end)
          {
              var direction = end - start;
              var localUp = Vector3.Cross(Vector3.forward, direction).normalized;
              localUp = transform.InverseTransformDirection(localUp) * lightRadius;
              var localStart = transform.InverseTransformPoint(start);
              var localEnd = transform.InverseTransformPoint(end);
              // 构造形状路径
              Vector3[] path = new Vector3[circleVertices + 2];
              float deltaAngle = 2 * Mathf.PI / circleVertices;
              float axisAngleOffset = Vector2.SignedAngle(Vector2.right, direction);
              // 当前圆上顶点对应角度
              float theta = Mathf.PI / 2 + Mathf.Deg2Rad * axisAngleOffset;
              int index = 0;
              // 起点处的半圆
              path[index] = localStart + localUp;
              for (int i = 0; i < circleVertices / 2; i++)
              {
                  theta += deltaAngle;
                  path[++index] = localStart + new Vector3(lightRadius * Mathf.Cos(theta), lightRadius * Mathf.Sin(theta), 0);
              }
              // 终点处的半圆
              path[++index] = localEnd - localUp;
              for (int i = 0; i < circleVertices / 2; i++)
              {
                  theta += deltaAngle;
                  path[++index] = localEnd + new Vector3(lightRadius * Mathf.Cos(theta), lightRadius * Mathf.Sin(theta), 0);
              }
      
              SetShapePath(lit, path);
          }
      }
      

      简单的初中数学,就不过多解释了,效果:

      处理翻转

      当她转身朝向另一面时,光照显示会有错误:

      原因是翻转后光照Mesh的顶点顺序错误,此时需要逆序一下,继续修改形状生成部分,加入对翻转的处理:

      Laser2D.cs

      public void SetPositions(Vector3 start, Vector3 end)
      {
          ...
          // 更改Light2D形状
          if (start != end)
          {
              ...
              // 构造形状路径
              Vector3[] path = new Vector3[circleVertices + 2];
              float deltaAngle = 2 * Mathf.PI / circleVertices;
              float axisAngleOffset = Vector2.SignedAngle(Vector2.right, direction);
              // 处理翻转情况,改变角度计算方向
              if (transform.lossyScale.x < 0)
              {
                  deltaAngle = -deltaAngle;
                  axisAngleOffset = -axisAngleOffset;
              }
              // 当前圆上顶点对应角度
              ...
              // 起点处的半圆
              ...
              // 终点处的半圆
              ...
              // 处理翻转情况,将所有顶点倒序
              if (transform.lossyScale.x < 0)
                  System.Array.Reverse(path);
              SetShapePath(lit, path);
          }
      }
      

      修复后:

      加一些特技

      按小哥教程中的做法,加上一些粒子效果:

      至此就基本完成了,还可以继续完善如发射时的粒子爆发、亮度变化、激光颜色设置等等。

      Demo项目地址:

      https://github.com/pmisu/2D-Laser

      项目中用到的素材:

      Cute 2D Girl - Wizard by ClearSky

      2D DarkCave Assets by Maaot

      发布在 游戏开发
      Pamisu
      Pamisu
    • [Unity]为了更好用的后处理——扩展URP后处理踩坑记录

      扩展URP后处理踩坑记录

      更新(2023.2.16)

      已初步适配至Unity 2021 URP 12.1.x版本,仓库地址:pamisu-kit-unity,测试场景为Assets/Examples/CustomPostProcessing/Scenes/ 中的CustomPP3D 与 CustomPP2D。
      依然是本篇文章中的实现思路,只是稍微修改了后处理效果渲染的相关RT。
      自定义后处理效果-3D

      自定义后处理效果-2D

      由于时间有限,没有修改得很完善,也没有充分测试,只测试了打包PC端的情况,并且大部分后处理组件的插入点都在RenderPassEvent.AfterRenderingPostProcessing。如果有不正确的地方欢迎指出。


      原文

      在目前2020.x版本,URP下的自定义后处理依然是通过Renderer Feature来实现,比起以前的PPSV2麻烦了不少,看着隔壁HDRP的提供的自定义后处理组件,孩子都快馋哭了。既然官方暂时没有提供,那么就自己先造一个解馋,对标HDRP的自定义后处理,目标效果是只需简单继承,就能添加自定义后处理组件。编写过程中踩了不少坑,但对URP的源码也有了初步的了解。

      效果
      效果
      Volume组件

      实现过程:

      • 封装自定义后处理组件基类,负责提供渲染方法、插入点设置等,并显示组件到Volume的Add Override菜单中。
      • 实现后处理Renderer Feature,获取所有自定义组件,根据它们的插入点分配到不同的Render Pass。
      • 实现后处理Render Pass,管理并调用自定义组件的渲染方法。
      • 适配2D场景下的自定义后处理。

      类关系:

      后处理组件基类

      首先要确保自定义的后处理组件能显示在Volume的Add Override菜单中,阅读源码可知,让组件出现在这个菜单中并没有什么神奇之处,只需继承VolumeComponent类并且添加VolumeComponentMenu特性即可,而VolumeComponent本质上是一个ScriptableObject。

      Volueme的Add Override菜单

      Bloom.cs

      那么就可以定义一个CustomVolumeComponent作为我们所有自定义后处理组件的基类:

      CustomVolumeComponent.cs

      public abstract class CustomVolumeComponent : VolumeComponent, IPostProcessComponent, IDisposable
      {
          ...
      }
      

      通常希望后处理在渲染过程中能有不同的插入点,这里先提供三个插入点,天空渲染之后、内置后处理之前、内置后处理之后:

      /// 后处理插入位置
      public enum CustomPostProcessInjectionPoint
      {
          AfterOpaqueAndSky, BeforePostProcess, AfterPostProcess
      }
      

      在同一个插入点可能会存在多个后处理组件,所以还需要一个排序编号来确定谁先谁后:

      public abstract class CustomVolumeComponent : VolumeComponent, IPostProcessComponent, IDisposable
      {
          /// 在InjectionPoint中的渲染顺序
          public virtual int OrderInPass => 0;
      
          /// 插入位置
          public virtual CustomPostProcessInjectionPoint InjectionPoint => CustomPostProcessInjectionPoint.AfterPostProcess;
      }
      

      然后定义一个初始化方法与渲染方法,渲染方法中,将CommandBuffer、RenderingData、渲染源与目标都传入:

      /// 初始化,将在RenderPass加入队列时调用
      public abstract void Setup();
      
      /// 执行渲染
      public abstract void Render(CommandBuffer cmd, refRenderingData renderingData, RenderTargetIdentifiersource, RenderTargetIdentifier destination);
      
      #region IPostProcessComponent
      /// 返回当前组件是否处于激活状态
      public abstract bool IsActive();
      
      public virtual bool IsTileCompatible() => false;
      #endregion
      

      最后是IDisposable接口的方法,由于渲染可能需要临时生成材质,在这里将它们释放:

      #region IDisposable
      public void Dispose()
      {
          Dispose(true);
          GC.SuppressFinalize(this);
      }
      
      /// 释放资源
      public virtual void Dispose(bool disposing) {}
      #endregion
      

      后处理组件基类就完成了,随便写个类继承一下它,Volume菜单中已经可以看到组件了:

      TestVolumeComponent.cs

      [VolumeComponentMenu("Custom Post-processing/Test Test Test!")]
      public class TestVolumeComponent : CustomVolumeComponent
      {
      
          public ClampedFloatParameter foo = new ClampedFloatParameter(.5f, 0, 1f);
      
          public override bool IsActive()
          {
          }
      
          public override void Render(CommandBuffer cmd, ref RenderingData renderingData, RenderTargetIdentifier source, RenderTargetIdentifier destination)
          {
          }
      
          public override void Setup()
          {
          }
      }
      

      Renderer Feature与Render Pass

      好看吗?就让你们看看,不卖。URP并不会调用它的渲染方法(毕竟本来就没有),这部分需要自己实现,所以还是得祭出Renderer Feature。

      官方示例中,一个Renderer Feature对应一个自定义后处理效果,各个后处理相互独立,好处是灵活自由易调整;坏处也在此,相互独立意味着每个效果都可能要开临时RT,耗费资源比双缓冲互换要多,并且Renderer Feature在Renderer Data下,相对于场景中的Volume来说在代码中调用起来反而没那么方便。

      那么这里的思路便是将所有相同插入点的后处理组件放到同一个Render Pass下渲染,这样就可以做到双缓冲交换,又保持了Volume的优势。

      获取自定义后处理组件

      先来写Render Pass,在里面定义好刚才写的自定义组件列表、Profiling所需变量,还有渲染源、目标与可能会用到的临时RT:

      CustomPostProcessRenderPass.cs

      public class CustomPostProcessRenderPass : ScriptableRenderPass
      {
          List<CustomVolumeComponent> volumeComponents;   // 所有自定义后处理组件
          List<int> activeComponents; // 当前可用的组件下标
      
          string profilerTag;
          List<ProfilingSampler> profilingSamplers; // 每个组件对应的ProfilingSampler
      
          RenderTargetHandle source;  // 当前源与目标
          RenderTargetHandle destination;
          RenderTargetHandle tempRT0; // 临时RT
          RenderTargetHandle tempRT1;
      
          /// <param name="profilerTag">Profiler标识</param>
          /// <param name="volumeComponents">属于该RendererPass的后处理组件</param>
          public CustomPostProcessRenderPass(string profilerTag, List<CustomVolumeComponent> volumeComponents)
          {
              this.profilerTag = profilerTag;
              this.volumeComponents = volumeComponents;
              activeComponents = new List<int>(volumeComponents.Count);
              profilingSamplers = volumeComponents.Select(c => new ProfilingSampler(c.ToString())).ToList();
      
              tempRT0.Init("_TemporaryRenderTexture0");
              tempRT1.Init("_TemporaryRenderTexture1");
          }
      
          ...
      }
      

      构造方法中接收这个Render Pass的Profiler标识与后处理组件列表,以每个组件的名称作为它们渲染时的Profiling标识。

      Renderer Feature中,定义三个插入点对应的Render Pass,以及所有自定义组件列表,还有一个用于后处理之后的的RenderTargetHandle,这个变量之后会介绍:

      CustomPostProcessRendererFeature.cs

      /// <summary>
      /// 自定义后处理Renderer Feature
      /// </summary>
      public class CustomPostProcessRendererFeature : ScriptableRendererFeature
      {
          // 不同插入点的render pass
          CustomPostProcessRenderPass afterOpaqueAndSky;
          CustomPostProcessRenderPass beforePostProcess;
          CustomPostProcessRenderPass afterPostProcess;
      
          // 所有自定义的VolumeComponent
          List<CustomVolumeComponent> components;
      
          // 用于after PostProcess的render target
          RenderTargetHandle afterPostProcessTexture;
          ...
      }
      

      那么要如何拿到所有自定义后处理组件,这些组件是一开始就存在,还是必须要从菜单中添加之后才存在?暂且蒙在鼓里。
      通常可以通过VolumeManager.instance.stack.GetComponent方法来获取到VolumeComponent,那么去看看VolumeStack的源码:

      VolumeStack.cs

      它用一个字典存放了所有的VolumeComponent,并且在Reload方法中根据baseTypes参数创建了它们,遗憾的是这是个internal变量。再看VolumeMangager中,CreateStack方法与CheckStack方法对Reload方法进行了调用:

      VolumeManager.cs

      在ReloadBaseTypes中对baseComponentTypes进行了赋值,可以发现它包含了所有VolumeComponent的非抽象子类类型:

      VolumeManager.cs

      看到这里可以得出结论,所有后处理组件的实例一开始便存在于默认的VolumeStack中,不管它们是否从菜单中添加。并且万幸的是,baseComponentTypes是一个public变量,这样就不需要通过粗暴手段来获取了。

      接着编写CustomPostProcessRendererFeature的Create方法,在这里获取到所有的自定义后处理组件,并且将它们根据各自的插入点分类并排好序,放入到对应的Render Pass中:

      CustomPostProcessRendererFeature.cs

      // 初始化Feature资源,每当序列化发生时都会调用
      public override void Create()
      {
          // 从VolumeManager获取所有自定义的VolumeComponent
          var stack = VolumeManager.instance.stack;
          components = VolumeManager.instance.baseComponentTypes
              .Where(t => t.IsSubclassOf(typeof(CustomVolumeComponent)) && stack.GetComponent(t) != null)
              .Select(t => stack.GetComponent(t) as CustomVolumeComponent)
              .ToList();
      
          // 初始化不同插入点的render pass
          var afterOpaqueAndSkyComponents = components
              .Where(c => c.InjectionPoint == CustomPostProcessInjectionPoint.AfterOpaqueAndSky)
              .OrderBy(c => c.OrderInPass)
              .ToList();
          afterOpaqueAndSky = new CustomPostProcessRenderPass("Custom PostProcess after Opaque and Sky", afterOpaqueAndSkyComponents);
          afterOpaqueAndSky.renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
      
          var beforePostProcessComponents = components
              .Where(c => c.InjectionPoint == CustomPostProcessInjectionPoint.BeforePostProcess)
              .OrderBy(c => c.OrderInPass)
              .ToList();
          beforePostProcess = new CustomPostProcessRenderPass("Custom PostProcess before PostProcess", beforePostProcessComponents);
          beforePostProcess.renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
      
          var afterPostProcessComponents = components
              .Where(c => c.InjectionPoint == CustomPostProcessInjectionPoint.AfterPostProcess)
              .OrderBy(c => c.OrderInPass)
              .ToList();
          afterPostProcess = new CustomPostProcessRenderPass("Custom PostProcess after PostProcess", afterPostProcessComponents);
          // 为了确保输入为_AfterPostProcessTexture,这里插入到AfterRendering而不是AfterRenderingPostProcessing
          afterPostProcess.renderPassEvent = RenderPassEvent.AfterRendering;
      
          // 初始化用于after PostProcess的render target
          afterPostProcessTexture.Init("_AfterPostProcessTexture");
      }
      

      依次设置每个Render Pass的renderPassEvent,对于AfterPostProcess插入点,renderPassEvent为AfterRendering而不是AfterRenderingPostProcessing,原因是如果插入到AfterRenderingPostProcessing,无法确保渲染输入源为_AfterPostProcessTexture,查看两种情况下的帧调试器:

      插入到AfterRenderingPostProcess:

      插入到AfterRenderingPostProcess

      插入到AfterRendering:

      插入到AfterRendering

      对比二者,可以发现插入点之前的Render PostProcessing Effects的RenderTarget会不一样,并且在插入到AfterRendering的情况下,还会多出一个FinalBlit,而FinalBlit的输入源正是_AfterPostProcessTexture:

      FinalBlit

      所以定义afterPostProcessTexture变量的目的便是为了能获取到_AfterPostProcessTexture,并再次渲染到它。

      现在已经拿到了所有自定义后处理组件,下一步就可以开始初始化它们了。在这之前,记得重写Dispose方法做好资源释放,避免临时创建的材质漏得到处都是:

      CustomPostProcessRendererFeature.cs

      protected override void Dispose(bool disposing)
      {
          base.Dispose(disposing);
          if (disposing && components != null)
          {
              foreach(var item in components)
              {
                  item.Dispose();
              }
          }
      }
      

      初始化

      上面在CustomPostProcessRenderPass中定义了一个变量activeComponents来存储当前可用的的后处理组件,在Render Feature的AddRenderPasses中,需要先判断Render Pass中是否有组件处于激活状态,如果没有一个组件激活,那么就没必要添加这个Render Pass,这里调用先前在组件中定义好的Setup方法初始化,随后调用IsActive判断其是否处于激活状态:

      CustomPostProcessRenderPass.cs

      /// <summary>
      /// 设置后处理组件
      /// </summary>
      /// <returns>是否存在有效组件</returns>
      public bool SetupComponents()
      {
          activeComponents.Clear();
          for (int i = 0; i < volumeComponents.Count; i++)
          {
              volumeComponents[i].Setup();
              if (volumeComponents[i].IsActive())
              {
                  activeComponents.Add(i);
              }
          }
          return activeComponents.Count != 0;
      }
      

      当一个Render Pass中有处于激活状态的组件时,说明它行,很有精神,可以加入到队列中,那么需要设置它的渲染源与目标:

      CustomPostProcessRenderPass.cs

      /// <summary>
      /// 设置渲染源和渲染目标
      /// </summary>
      public void Setup(RenderTargetHandle source, RenderTargetHandle destination)
      {
          this.source = source;
          this.destination = destination;
      }
      

      之后在CustomPostProcessRendererFeature的AddRenderPasses方法中调用这两个方法,通过则将Render Pass添加:

      CustomPostProcessRendererFeature.cs

      // 你可以在这里将一个或多个render pass注入到renderer中。
      // 当为每个摄影机设置一次渲染器时,将调用此方法。
      public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
      {
          if (renderingData.cameraData.postProcessEnabled)
          {
              // 为每个render pass设置render target
              var source = new RenderTargetHandle(renderer.cameraColorTarget);
              if (afterOpaqueAndSky.SetupComponents())
              {
                  afterOpaqueAndSky.Setup(source, source);
                  renderer.EnqueuePass(afterOpaqueAndSky);
              }
              if (beforePostProcess.SetupComponents())
              {
                  beforePostProcess.Setup(source, source);
                  renderer.EnqueuePass(beforePostProcess);
              }
              if (afterPostProcess.SetupComponents())
              {
                  // 如果下一个Pass是FinalBlit,则输入与输出均为_AfterPostProcessTexture
                  source = renderingData.cameraData.resolveFinalTarget ? afterPostProcessTexture : source;
                  afterPostProcess.Setup(source, source);
                  renderer.EnqueuePass(afterPostProcess);
              }
          }
      }
      

      至此Renderer Feature类中的所有代码就写完了,接下来继续在Render Pass中实现渲染。

      执行渲染

      编写Render Pass中渲染执行的方法Execute:

      // 你可以在这里实现渲染逻辑。
      // 使用<c>ScriptableRenderContext</c>来执行绘图命令或Command Buffer
      // https://docs.unity3d.com/ScriptReference/Rendering.ScriptableRenderContext.html
      // 你不需要手动调用ScriptableRenderContext.submit,渲染管线会在特定位置调用它。
      public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
      {
          var cmd = CommandBufferPool.Get(profilerTag);
          context.ExecuteCommandBuffer(cmd);
          cmd.Clear();
      
          // 获取Descriptor
          var descriptor = renderingData.cameraData.cameraTargetDescriptor;
          descriptor.msaaSamples = 1;
          descriptor.depthBufferBits = 0;
      
          // 初始化临时RT
          RenderTargetIdentifier buff0, buff1;
          bool rt1Used = false;
          cmd.GetTemporaryRT(tempRT0.id, descriptor);
          buff0 = tempRT0.id;
          // 如果destination没有初始化,则需要获取RT,主要是destinaton为_AfterPostProcessTexture的情况
          if (destination != RenderTargetHandle.CameraTarget && !destination.HasInternalRenderTargetId())
          {
              cmd.GetTemporaryRT(destination.id, descriptor);
          }
      
          // 执行每个组件的Render方法
          // 如果只有一个组件,则直接source -> buff0
          if (activeComponents.Count == 1)
          {
              int index = activeComponents[0];
              using (new ProfilingScope(cmd, profilingSamplers[index]))
              {
                  volumeComponents[index].Render(cmd, ref renderingData, source.Identifier(), buff0);
              }
          }
          else
          {
              // 如果有多个组件,则在两个RT上左右横跳
              cmd.GetTemporaryRT(tempRT1.id, descriptor);
              buff1 = tempRT1.id;
              rt1Used = true;
              Blit(cmd, source.Identifier(), buff0);
              for (int i = 0; i < activeComponents.Count; i++)
              {
                  int index = activeComponents[i];
                  var component = volumeComponents[index];
                  using (new ProfilingScope(cmd, profilingSamplers[index]))
                  {
                      component.Render(cmd, ref renderingData, buff0, buff1);
                  }
                  CoreUtils.Swap(ref buff0, ref buff1);
              }
          }
      
          // 最后blit到destination
          Blit(cmd, buff0, destination.Identifier());
      
          // 释放
          cmd.ReleaseTemporaryRT(tempRT0.id);
          if (rt1Used)
              cmd.ReleaseTemporaryRT(tempRT1.id);
      
          context.ExecuteCommandBuffer(cmd);
          CommandBufferPool.Release(cmd);
      }
      

      这里如果写得再简洁一些应该是可以只需要source和destination两个变量就行。需要注意某些情况下_AfterPostProcessTexture可能不存在,所以添加了手动获取RT的处理。如果不做这一步可能会出现Warning:

      找不到_AfterPostProcessTexture

      到这里Renderer Feature与Render Pass就全部编写完成,接下来使用一下看看实际效果。

      使用一下看看实际效果

      以官方示例中的卡通描边效果为例,先从把示例中的SobelFilter.shader窃过来,将Shader名称改为"Hidden/PostProcess/SobleFilter",然后编写后处理组件SobelFilter类:

      SobelFilter.cs

      [VolumeComponentMenu("Custom Post-processing/Sobel Filter")]
      public class SobelFilter : CustomVolumeComponent
      {
          public ClampedFloatParameter lineThickness = new ClampedFloatParameter(0f, .0005f, .0025f);
          public BoolParameter outLineOnly = new BoolParameter(false);
          public BoolParameter posterize = new BoolParameter(false);
          public IntParameter count = new IntParameter(6);
      
          Material material;
          const string shaderName = "Hidden/PostProcess/SobleFilter";
      
          public override CustomPostProcessInjectionPoint InjectionPoint => CustomPostProcessInjectionPoint.AfterOpaqueAndSky;
      
          public override void Setup()
          {
              if (material == null)
                  material = CoreUtils.CreateEngineMaterial(shaderName);
          }
      
          public override bool IsActive() => material != null && lineThickness.value > 0f;
      
          public override void Render(CommandBuffer cmd, ref RenderingData renderingData, RenderTargetIdentifier source, RenderTargetIdentifier destination)
          {
              if (material == null)
                  return;
      
              material.SetFloat("_Delta", lineThickness.value);
              material.SetInt("_PosterizationCount", count.value);
              if (outLineOnly.value)
                  material.EnableKeyword("RAW_OUTLINE");
              else
                  material.DisableKeyword("RAW_OUTLINE");
              if (posterize.value)
                  material.EnableKeyword("POSTERIZE");
              else
                  material.DisableKeyword("POSTERIZE");
      
              cmd.Blit(source, destination, material);
          }
      
          public override void Dispose(bool disposing)
          {
              base.Dispose(disposing);
              CoreUtils.Destroy(material);
          }
      }
      

      使用CoreUtils.CreateEngineMaterial来从Shader创建材质,在Dispose中销毁它。Render方法中的cmd.Blit之后可以考虑换成CoreUtils.DrawFullScreen画全屏三角形。

      需要注意的是,IsActive方法最好要在组件无效时返回false,避免组件未激活时仍然执行了渲染,原因之前提到过,无论组件是否添加到Volume菜单中或是否勾选,VolumeManager总是会初始化所有的VolumeComponent。

      而CoreUtils.CreateEngineMaterial(shaderName)内部依然是调用Shader.Find方法来查找Shader:

      CoreUtils.cs

      添加Render Feature:

      在Volume中添加并启用Sobel Filter:

      效果:

      加入更多后处理组件,这里使用连连看简单连了一个条纹故障和一个RGB分离故障,它们的插入点都是内置后处理之后:

      条纹故障

      RGB分离

      效果:

      应用到2D

      由于目前2D Renderer还不支持Renderer Feature,只好采取一个妥协的办法。首先新建一个Forward Renderer添加到Renderer List中:

      场景中新建一个相机,Render Type改为Overlay,Renderer选择刚才创建的Forward Renderer,并开启Post Processing:

      添加到主相机的Stack上,主相机关闭Post Processing:

      URP 12.1.x中已经有了对2D的Renderer Feature支持,所以只需要添加自定义的Renderer Feature即可,其他使用方式和3D一致。

      到这里对URP后处理的扩展就基本完成了,当然包括渲染在内还有很多地方可以继续完善,比如进一步优化双缓冲、全屏三角形、同一组件支持多个插入点等等。

      对于编辑器中运行有效果,但打包后没有效果的情况,可能的原因是Shader文件在打包时被剔除了,这种情况只要确保Shader文件被包含或者可被加载即可(添加到Always Included Shaders、放到Resources、从AB加载等等)。

      用到的素材:Free Space Runner Pack & Free Lunar Battle Pack by MattWalkden

      发布在 游戏开发
      Pamisu
      Pamisu
    • [Unity]2D颜料泼溅效果

      做一个类似于《INK》的2D颜料泼溅效果

      INK

      实现效果

      表面与颜料

      利用模板测试,让颜料污渍能在物体表面上重叠显示且不超出物体轮廓。在物体表面的Shader中,总是通过模板测试并替换参考值,同时裁剪掉透明度为0的像素;颜料污渍的Shader中,如果与参考值相等则通过测试。

      项目用了URP,这里直接将Sprite-Lit-Default.shader拷贝两份出来修改。

      被染色表面Surface-Lit.shader

      SubShader中加入Stencil配置

      SubShader
      {
          Tags {"Queue" = "Transparent" "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" }
      
          Stencil
          {
              Ref 2
              Comp Always
              Pass Replace
          }
          ...
      

      透明度裁剪在原来的Sprite-Lit-Default.shader中已经做好了所以不用再写。

      颜料污渍Stain-Lit.shader

      SubShader
      {
          Tags {"Queue" = "Transparent" "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" }
      
          Stencil
          {
              Ref 2
              Comp Equal
          }
          ...
      

      建一个场景测试效果,Tilemap使用Surface-Lit材质,污渍Sprite使用Stain材质,二者在同一个Sorting Layer,Tilemap的Order in Layer需要比污渍Sprite小。

      Tilemap:

      Tilemap

      颜料污渍:

      颜料污渍

      颜料污渍已经可以显示在Tilemap上并且不会超出其轮廓。

      初步效果

      喷溅

      添加一把玩具水枪,简单写一个发射颜料子弹的逻辑,子弹爆开后围绕当前位置随机生成多个颜料污渍预制体。颜料污渍预制体用一张1像素的白色图片,通过缩放来显示出不同大小的污渍。子弹中设置多种颜色随机应用到子弹与污渍的SpriteRenderer。

      子弹

      Stain Radius为喷溅半径,Stain Scale为最大污渍缩放。

      编写StainGenerator类用来生成颜料污渍:

      public class StainGeneratorOld
      {
          /// <summary>
          /// 在中心点生成向四周发散的污渍
          /// </summary>
          /// <param name="prefab">污渍预制体</param>
          /// <param name="color">颜色</param>
          /// <param name="position">中心点位置</param>
          /// <param name="direction">冲击方向</param>
          /// <param name="scale">污渍缩放</param>
          /// <param name="radius">污渍分布半径</param>
          public static void Generate(GameObject prefab, Color color, Vector3 position, Vector3 direction, float scale, float radius)
          {
              // 以position为中心分裂到若干个方向,每个分裂的角度随机
              int splitNum = Random.Range(4, 9);  // 分裂数量随机,数值暂时写死
              Vector3[] splitDirs = new Vector3[splitNum];
              float angleDelta = 360f / splitNum;
              for (int i = 0; i < splitDirs.Length; i++)
              {
                  var lastDir = i == 0? direction : splitDirs[i - 1];
                  var angle = RandomNum(angleDelta, .2f);
                  splitDirs[i] = Quaternion.AngleAxis(angle, Vector3.forward) * lastDir;
              }
      
              // 每个分裂方向生成若干个污渍
              foreach (var dir in splitDirs)
              {
                  int stainNum = Random.Range(3, 6);
                  float stainScale;    // 污渍
                  float radiusDelta = radius / 6f;    // 每个污渍间距
                  Vector3 stainPos = position;    // 污渍位置
                  for (int i = 0; i < stainNum; i++)
                  {
                      stainScale = scale - (i * scale / stainNum);    // 缩放随距离衰减
                      stainPos += dir * RandomNum(radiusDelta, .4f);
                      stainPos += (Vector3) Random.insideUnitCircle * RandomNum(radiusDelta * .2f, radiusDelta * .1f); // 位置随机
                      var go = Object.Instantiate(prefab);
                      go.transform.position = stainPos;
                      go.transform.right = dir;
                      go.transform.localScale = new Vector3(stainScale, stainScale, 1f);
                      go.GetComponent<SpriteRenderer>().color = color;
                  }
              }
          }
      
          public static float RandomNum(float num, float randomness)
          {
              return num + Random.Range(-num * randomness, num * randomness);
          }
      }
      

      子弹与地面发生碰撞时调用,传入污渍预制体、颜色、冲击位置、冲击方向、自定义的污渍缩放与喷溅半径:

      private void OnCollisionEnter2D(Collision2D other) 
      {
          ...
          StainGenerator.Generate(stainPrefab, color, transform.position, transform.right, stainScale, stainRadius);
      }
      

      比较简单的实现就完成了,但这种不断生成Sprite的方法将导致场景里分分钟就会多出上千个游戏对象。

      优化

      由于污渍的材质都一样,Unity对它们做了动态合批,但要处理的顶点数量依然没变,这里是否可以通过自定义mesh来优化,暂时没有什么头猪。

      好在对游戏对象的数量优化还是比较简单的,打在同一个位置的颜料污渍将会发生重叠,被遮挡住的污渍是不再需要的。定义一个同一位置最大可叠加层数,在创建新的污渍时,先判断当前位置共叠加了几层,如果超过允许的最大层数,则将最底层的污渍对象回收,再从对象池中取出已回收的污渍对象重复利用。

      这种做法的缺点是只判断重叠,而不是判断污渍是否被完全覆盖,显示效果上不太好,最大层数设置较低时会出现有些污渍还未被完全覆盖,却依然被回收了的情况。

      检测重叠可以使用SpriteRenderer中Bounds的Intersects方法,但每次生成都要遍历当前所有污渍对象做判断,感觉过于繁琐。最终还是选择用了Physics2D.OverlapBoxAll,这样要先在污渍预制体中添加BoxCollider2D。

      修改StainGenerator,令它继承MonoBehaviour。叠加层数利用SpriteRenderer的Order in Layer属性实现,假设三个污渍重叠,且它们的Order in Layer分别是2、3、4,如果此时已达到了最大叠加层数,同时有新的污渍即将覆盖它们,则回收Order为2的,其余的Order减1,新的污渍Order设为4,这样依然能保持2、3、4的重叠顺序。

      public class StainGenerator : MonoBehaviour
      {
          
          public static StainGenerator Instance { get; private set;}
      
          [Tooltip("污渍Prefab")]
          public GameObject prefab;
          [Tooltip("最小Order in Layer")]
          public int minOrderInLayer;
          [Tooltip("最大Order in Layer")]
          public int maxOrderInLayer;
          [Tooltip("污渍大小")]
          public Vector2 stainSize;
      
          // 污渍对象池
          [SerializeField] List<GameObject> stainPool;
          // 临时污渍对象列表,用来记录本次生成已处理过的对象,避免重复处理
          List<GameObject> tempStains;
      
          void Awake() 
          {
              // 初始化单例和列表
              ...
          }  
          ...
      }
      

      修改Generate方法,去除多余的参数,仅改动污渍对象生成部分,子弹碰撞中相应修改对其的调用。

      public void Generate(Color color, Vector3 position, Vector3 direction, float scale, float radius)
      {
          // 以position为中心分裂到若干个方向,每个分裂的角度随机
          ...
          // 每个分裂方向生成若干个污渍
          tempStains.Clear(); // 每次生成时清空临时列表
          foreach (var dir in splitDirs)
          {
              ...
              for (int i = 0; i < stainNum; i++)
              {
                  ...
                  // var go = Object.Instantiate(prefab);
                  var go = GetStain(stainPos, stainScale, dir);   // 替换为GetStain方法
                  ...
              }
          }
      }
      

      编写GetStain方法。

      /// <summary>
      /// 获取当前污渍对象,若当前位置发生重叠则调整回收
      /// </summary>
      /// <param name="pos">位置</param>
      /// <param name="scale">缩放</param>
      /// <param name="dir">朝向</param>
      /// <returns></returns>
      GameObject GetStain(Vector3 pos, float scale, Vector3 dir)
      {
          int order = minOrderInLayer;   // 当前污渍需要设置的sortingOrder
          var angle = Vector2.SignedAngle(Vector2.right, dir);
          var size = stainSize * scale;   // 实际大小
          var cols = Physics2D.OverlapBoxAll(pos, size, angle, LayerMask.GetMask("Stain"));
          if (cols.Length != 0)
          {
              // 若检测到污渍重叠,获取当前最顶层污渍的sortingOrder
              SpriteRenderer spriteRenderer;
              foreach (var item in cols)
              {
                  spriteRenderer = item.GetComponent<SpriteRenderer>();
                  if (spriteRenderer.sortingOrder > order)
                  {
                      order = spriteRenderer.sortingOrder;
                      // 如果即将超出最大叠加层数则直接快进到处理重叠的污渍
                      if (order + 1 > maxOrderInLayer)
                          break;
                  }
              }
              if (order + 1 > maxOrderInLayer)
              {
                  // 回收最底层的污渍,其余层sortingOrder减1,并标记为已处理,避免被重复处理
                  foreach (var item in cols)
                  {
                      spriteRenderer = item.GetComponent<SpriteRenderer>();
                      if (spriteRenderer.sortingOrder == minOrderInLayer)
                          item.gameObject.SetActive(false);
                      else if (!tempStains.Contains(item.gameObject))
                      {
                          spriteRenderer.sortingOrder--;
                          tempStains.Add(item.gameObject);
                      }
                  }
              }
              order = Mathf.Clamp(order + 1, minOrderInLayer, maxOrderInLayer);
          }
      
          // 简易对象池
          GameObject go = null; 
          foreach (var item in stainPool)
          {
              if (!item.activeInHierarchy)
              {
                  go = item;
                  go.SetActive(true);
                  break;
              }
          }
          if (go == null)
          {
              go = Instantiate(prefab, transform);
              stainPool.Add(go);
          }
          go.GetComponent<SpriteRenderer>().sortingOrder = order;
          
          return go;
      }
      

      脚本挂到场景中,设置相应值:

      调整之后:

      调整后

      和调整前对比:

      调整前

      可以发现不再生成那么多对象了,帧数也相对稳定,但出现了上面提到的显示问题,将最大叠加层数调高基本可以解决,总之这应该不是最优的做法。

      用到的素材:Cavernas by Adam Saltsman、8 Guns + Projectiles by KingKelpo

      发布在 游戏开发
      Pamisu
      Pamisu
    • [Unity]第一人称睁眼苏醒效果

      第一人称睁眼苏醒效果

      做一个简单的睁眼苏醒效果,也可用于眨眼、闭眼、昏睡等等:

      效果

      本篇文章中介绍的思路适用于Built-in渲染管线,如果您需要在URP中实现,可参考我的这篇文章,本篇文章末尾也有URP对应的仓库地址。

      首先编写一个AwakeScreenEffect.cs脚本:

      [ExecuteInEditMode]
      [RequireComponent(typeof(Camera))]
      public class AwakeScreenEffect : MonoBehaviour
      {
          public Shader shader;
      
          [SerializeField]
          Material material;
          Material Material 
          {
              get 
              {
                  if (material == null)
                  {
                      material = new Material(shader);
                      material.hideFlags = HideFlags.DontSave;
                  }
                  return material;
              }
          }
      
          void OnDisable() 
          {
              if (material)
              {
                  DestroyImmediate(material);
              }
          }
      
          void OnRenderImage(RenderTexture src, RenderTexture dest) 
          {
              // TODO 处理...
          }
      }
      

      将脚本挂在相机上,接下来编写相应的shader。目标效果中,从闭眼到睁眼的过程用一个进度0~1表示,当进度从0到1时,眼睛逐渐睁开,视野从模糊逐渐变为清晰。

      创建AwakeScreenEffect.shader,_Progress表示当前苏醒进度:

      Shader "Custom/Awake Screen Effect"
      {
          Properties
          {
              _MainTex ("Texture", 2D) = "white" {}
              _Progress ("Progress", Range(0, 1)) = 1
          }
          SubShader
          {
              ZTest Always ZWrite Off Cull Off
      
              Pass
              {
                  CGPROGRAM
                  #pragma vertex vert
                  #pragma fragment frag
      
                  #include "UnityCG.cginc"
      
                  struct appdata
                  {
                      float4 vertex : POSITION;
                      half2 uv : TEXCOORD0;
                  };
      
                  struct v2f
                  {
                      half2 uv : TEXCOORD0;
                      float4 vertex : SV_POSITION;
                  };
      
                  sampler2D _MainTex;
                  float _Progress;
      
                  v2f vert (appdata v)
                  {
                      v2f o;
                      o.vertex = UnityObjectToClipPos(v.vertex);
                      o.uv = v.uv;
                      return o;
                  }
      
                  fixed4 frag (v2f i) : SV_Target
                  {
                      half2 uv = i.uv;
                      fixed4 col = tex2D(_MainTex, uv);
                      // TODO ...
                      return col;
                  }
                  ENDCG
              }
      
          }
      
          Fallback Off
      }
      

      先写上下眼皮,它们的边界值分别是屏幕中线(0.5)加或减去当前进度乘以0.5,得出边界值后通过step函数对uv.v进行裁剪,大于上眼皮边界、小于下眼皮边界时裁剪值为0,否则为1,最后将它与颜色相乘得出效果:

      fixed4 frag (v2f i) : SV_Target
      {
          half2 uv = i.uv;
          fixed4 col = tex2D(_MainTex, uv);
          // 上眼皮与下眼皮边界
          float upBorder = .5 + _Progress * .5;
          float downBorder = .5 - _Progress * .5;
          // 可视区域
          float visibleV = (1 - step(upBorder, uv.y)) * (step(downBorder, uv.y));
          col *= visibleV;
          return col;
      }
      

      AwakeScreenEffect.cs中加入可调节的进度变量,OnRenderImage方法中应用:

      ...
      public class AwakeScreenEffect : MonoBehaviour
      {
          [Range(0f, 1f)][Tooltip("苏醒进度")]
          public float progress;
          ...
          void OnRenderImage(RenderTexture src, RenderTexture dest) 
          {
              Material.SetFloat("_Progress", progress);
              Graphics.Blit(src, dest, material);
          }
      }
      

      可以看到初步效果:

      眼皮

      除非是能人异士,不然大部分人的眼皮都不是这样一条直线,shader中定义一个眼皮弧形的高度值_ArchHeight:

      Properties
      {
          ...
          _ArchHeight ("Arch Height", Range (0, .5)) = .2
      }
      

      用二次函数做出弧度:

      float _ArchHeight;
      fixed4 frag (v2f i) : SV_Target
      {
          ...
          // 上眼皮与下眼皮边界
          float upBorder = .5 + _Progress * (.5 + _ArchHeight);
          float downBorder = .5 - _Progress * (.5 + _ArchHeight);
          upBorder -=  _ArchHeight * pow(uv.x - .5, 2);
          downBorder += _ArchHeight * pow(uv.x - .5, 2);
          ...
      }
      

      上下边界由原来的* .5改为* (.5 + _ArchHeight),用来调整上下边界随_Progress的变化范围,避免_Progress为1时仍有黑边的情况。

      眼皮弧度

      再加入模糊,模糊效果直接使用了冯乐乐老师的《Unity Shader 入门精要》12.4节中的高斯模糊,新建一个GaussianBlur.shader,拷贝代码,确认一下shader和Pass的命名无误:

      Shader "Custom/Gaussian Blur"
      {
          Properties
          {
              _MainTex ("Texture", 2D) = "white" {}
              _BlurSize ("Blur Size", Float) = 1
          }
          SubShader
          {
              ...
              Pass
              {
                  NAME "GAUSSIAN_BLUR_VERTICAL"
                  ...
              }
              Pass
              {
                  NAME "GAUSSIAN_BLUR_HORIZONTAL"
                  ...
              }
          }
          ...
      }
      

      在AwakeScreenEffect.shader中原有Pass之后使用高斯模糊的两个Pass:

      SubShader
      {
          ...
          Pass
          {
              ...
          }
          ...
          UsePass "Custom/Gaussian Blur/GAUSSIAN_BLUR_VERTICAL"
          UsePass "Custom/Gaussian Blur/GAUSSIAN_BLUR_HORIZONTAL"
      }
      

      AwakeScreenEffect.cs加入模糊需要用到的参数:

      [Range(0, 4)][Tooltip("模糊迭代次数")]
      public int blurIterations = 3;
      [Range(.2f, 3f)][Tooltip("每次模糊迭代时的模糊大小扩散")]
      public float blurSpread = .6f;
      

      修改OnRenderImage方法,基本和书中的差不多,不同的是没有使用降采样。随着progress从0变为1,blurSize也逐渐变为0。

      void OnRenderImage(RenderTexture src, RenderTexture dest) 
      {
          Material.SetFloat("_Progress", progress);
          if (progress < 1)
          {
              // 由于降采样会影响模糊到清晰的连贯性,这里没有使用
              int rtW = src.width;
              int rtH = src.height;
              var buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0);
              buffer0.filterMode = FilterMode.Bilinear;
              Graphics.Blit(src, buffer0, Material, 0);   // 眼皮Pass
              // 模糊
              float blurSize;
              for (int i = 0; i < blurIterations; i++)
              {
                  // 将progress(0~1)映射到blurSize(blurSize~0)
                  blurSize = 1f + i * blurSpread;
                  blurSize = blurSize - blurSize * progress;
                  Material.SetFloat("_BlurSize", blurSize);
                  // 竖直方向的Pass
                  var buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
                  Graphics.Blit(buffer0, buffer1, Material, 1);
                  RenderTexture.ReleaseTemporary(buffer0);
                  // 竖直方向的Pass
                  buffer0 = buffer1;
                  buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
                  Graphics.Blit(buffer0, buffer1, Material, 2);
      
                  RenderTexture.ReleaseTemporary(buffer0);
                  buffer0 = buffer1;
              }
              Graphics.Blit(buffer0, dest);
              RenderTexture.ReleaseTemporary(buffer0);
          }
          else
          {
              // 完全苏醒则无需处理,直接blit
              Graphics.Blit(src, dest);
          }
      }
      

      模糊

      画面由暗转亮比较简单,AwakeScreenEffect.shader让颜色乘以_Progress即可:

      fixed4 frag (v2f i) : SV_Target
      {
          ...
          col *= _Progress;
          return col;
      }
      

      变暗

      最后用Animator录制一个动画就完成了。

      Demo项目地址:
      Procedural-Map-Demo(Built-in渲染管线)
      pamisu-kit-unity(URP自定义后处理)

      发布在 游戏开发
      Pamisu
      Pamisu
    • [Unity]拼接地块的随机地图生成

      一个3D场景中拼接地块的随机地图的尝试过程,个人感觉效果不是非常理想,还有许多优化空间,总之先记录一下。

      效果

      虽说是3D场景,但生成的地图块都在同一个平面上,严格来说依然是2D的思路。对于类似3D地牢的生成,有一篇文章介绍:在Unity中程序化生成的地牢环境。

      整体思路:

      • 准备若干个地块,每个地块包含朝向东、西、南、北其中一个或多个的开口
      • 将每个地块做成预制体并烘焙光照贴图
      • 随机生成初始地块,根据地块开口匹配可拼接的地块,不断生成拼接直至完成

      缺点:

      • 地块形状不一,很难做出环路
      • 生成算法为逐个生成,而不是先生成一堆随机位置的地块再将它们连接,过程较为繁琐,并且如果发生冲突会有较多的重新尝试次数

      地块

      在一个场景中搭建好各类地块:

      由于计划要烘焙光照贴图,地块的开口朝向就必须都是固定的,旋转地块、移除墙壁都会导致穿帮。当然也可以设置一些动态的墙壁,需要时通过移除它们来形成开口。

      上图搭建好的地块中,右侧两列为需要生成的主要地块,拥有2~4个方向的开口;中间四个小地块分为横向与竖向,用来连接主要地块;左上角四个为单开口,在地块生成完毕后,用它们来封闭上空余的开口。

      烘焙

      地块大致搭完后将它们都做成预制体,进行烘焙。这里使用插件Unity Lightmap Prefab Baker ,它将整个场景按当前的光照设置进行烘焙,烘焙出的光照贴图移动到指定文件夹,然后通过挂在预制体下的脚本记录关联的光照贴图,当预制体加载到新场景时将它们关联起来。

      插件安装好后,每个地块预制体挂上PrefabBaker脚本:

      Window -> Prefab Baker 打开面板,调整相应设置后点击烘焙:

      默认的光照贴图放在Assets/Resources/Lightmaps目录下,开发阶段可以暂时放在这里。如果2019版本报"Failed to created asset"错误,可尝试修改Plugins/PrefabBaker/Scripts/EditorUtils.cs,在200行:

      // Directory.CreateDirectory( Directory.GetParent( saveTo ).FullName );
      // 改为:
      Directory.CreateDirectory( Directory.GetParent( saveTo ).Name);
      

      烘焙完成后:

      拼接准备

      为了让地块之间的开口能顺利对接上,地块的每个开口处需要放置一个空对象作为连接点,令两个地块的连接点重合即完成拼接。地块还需要有类型,这里分为了Room(房间,单开口), Corridor(走廊,东西或南北开口), Corner(拐角), TShaped(丁字), Hall(大厅,四面开口)。

      // 地块连接点
      [System.Serializable]
      public class Joint
      {
          public enum Type
          {
              Up, Right, Down, Left
          }
          public Type type;
          public Transform transform;
          public bool isUsed; // 是否已连接
      }
      
      // 地块
      public class Cell : MonoBehaviour 
      {
          public enum Type 
          {
              Room, Corridor, Corner, TShaped, Hall
          }
          public Type type;
          public Joint[] joints;
      }
      

      连接点,Z轴朝向开口方向:

      地块预制体:

      拼接时需要能获取到地块的连接点情况,加入相关方法:

      public class Cell : MonoBehaviour 
      {
          ...
          public void GetAvailableJoints(List<Joint> results)
          {
              results.Clear();
              foreach (var item in joints)
              {
                  if (!item.isUsed)
                      results.Add(item);
              }
          }
      
          public int GetAvailableJointsCount()
          {
              int count = 0;
              foreach (var item in joints)
              {
                  if (!item.isUsed)
                      count++;
              }
              return count;
          }
      
          public Joint GetJoint(Joint.Type jointType)
          {
              foreach (var item in joints)
              {
                  if (item.type == jointType)
                  {
                      return item;
                  }
              }
              return null;
          }
      
          public bool HasJoint(Joint.Type jointType)
          {
              return GetJoint(jointType) != null;
          }
      }
      

      生成

      在一个新场景中生成地图,编写LevelGenerator.cs脚本:

      public class LevelGenerator : MonoBehaviour
      {
          [Header("地块")]
          [Tooltip("生成地块总数")]
          public int cellTotalNum;
          [Tooltip("地块预制体")]
          public List<Cell> cellPrefabs;
      
          // 分类预制体
          List<Cell> roomPrefabs; // 房间(单开口)
          List<Cell> corridorPrefabs; // 走廊(左右、上下开口)
          List<Cell> bigCellPrefabs;  // 其他大地块
          ...
      

      先对地块预制体进行分类:

      /// <summary>
      /// 将地块预制体归类到不同列表
      /// </summary>
      void SortCellPrefabs()
      {
          roomPrefabs = new List<Cell>();
          corridorPrefabs = new List<Cell>();
          bigCellPrefabs = new List<Cell>();
          foreach (var item in cellPrefabs)
          {
              if (item.type == Cell.Type.Room)
                  roomPrefabs.Add(item);
              else if (item.type == Cell.Type.Corridor)
                  corridorPrefabs.Add(item);
              else
                  bigCellPrefabs.Add(item);
          }
      }
      

      分好类之后开始生成,编写生成地图的方法GenerateLevel,由于需要多次将满足条件的对象放入列表来随机抽取,事先准备好一些列表避免反复创建:

      void GenerateLevel()
      {
          List<Cell> cells = new List<Cell>();  // 当前已生成的且连接口未封闭的地块列表
          List<Cell> tempCells = new List<Cell>();    // 临时地块列表,用于随机当前匹配的地块预制体
          List<Joint> tempJoints = new List<Joint>(); // 临时地块连接口列表,用于随机当前地块连接口
          int cellNum; // 当前地块数量(已生成+即将生成)
          ...
      
      

      先生成一个初始地块,这里选择走廊作为初始的地块,也可用其他的地块类型:

      // 生成初始地块
      Cell cell = Utils.GetRandom<Cell>(corridorPrefabs);
      cell = Instantiate(cell, transform);
      cell.transform.position = transform.position;
      cells.Add(cell);
      cellNum = 1 + cell.GetAvailableJointsCount();   // 已生成1个+即将生成个数
      

      GetRandom方法:

      public static T GetRandom<T>(List<T> list)
      {
          if (list.Count < 0) 
              return default(T);
          int index = Random.Range(0, list.Count);
          return list[index];
      }
      

      需要注意新场景中的光照(主要是平行光)需要和烘焙场景保持一致。

      当前的算法是逐个拼接生成地块,最后用单开口地块对所有空余的开口进行封闭。为了控制地块数量,当前的地块数量为已生成个数加上即将生成个数。

      编写循环生成:

      // 循环生成
      while (cellNum < cellTotalNum)
      {
          // yield return new WaitForSeconds(.3f);  // 调试时使用协程,方便观察
          // 随机获取现有未封闭地块,并随机取得其连接口
          cell = Utils.GetRandom<Cell>(cells);
          cell.GetAvailableJoints(tempJoints);
          var currentJoint = Utils.GetRandom<Joint>(tempJoints);
          // 获取与当前连接口匹配的地块
          Cell matchingCell;  
          // 走廊与大地块轮流生成
          if (cell.type != Cell.Type.Corridor)
              matchingCell = GenerateMatchingCell(corridorPrefabs, currentJoint, tempCells);
          else
              matchingCell = GenerateMatchingCell(bigCellPrefabs, currentJoint, tempCells);
          // 没有生成合适的地块则跳过
          if (matchingCell == null)
          {
              Debug.Log("当前连接点没有合适的地块与之连接,跳过");
              continue;
          }
          cells.Add(matchingCell);
          // 若无剩余可用连接口则从列表中移除,更新当前地块数量
          if (cell.GetAvailableJointsCount() == 0)
              cells.Remove(cell);
          cellNum += matchingCell.GetAvailableJointsCount();
      }
      

      GenerateMatchingCell方法用于寻找与当前地块连接口匹配的地块预制体,找到之后将它们拼接:

      /// <summary>
      /// 生成与当前连接口相匹配的地块并连接
      /// </summary>
      /// <param name="cellPrefabs">地块预制体列表</param>
      /// <param name="currentJoint">当前连接口</param>
      /// <param name="tempCells">临时地块列表,用于从满足条件的地块中随机抽取</param>
      /// <returns>匹配的地块</returns>
      Cell GenerateMatchingCell(List<Cell> cellPrefabs, Joint currentJoint, List<Cell> tempCells)
      {
          // 获取期望匹配的连接口类型
          var expectedJointType = GetExpectedJointType(currentJoint.type);
          // 根据期望匹配的连接口类型获取合适的地块
          var matchingCell = GetMatchingCell(cellPrefabs, expectedJointType, tempCells);
          if (matchingCell == null)
              return null;
          matchingCell = Instantiate(matchingCell, transform);
          // 将两个地块的连接口位置重合,计算出生成地块位置
          var matchingJoint = matchingCell.GetJoint(expectedJointType);
          var distance = -matchingJoint.transform.localPosition;
          var cellPosition = currentJoint.transform.position + distance;
          // 设置新地块位置与连接点使用情况
          matchingCell.transform.position = cellPosition;
          currentJoint.isUsed = true;
          matchingJoint.isUsed = true;
          return matchingCell;
      }
      

      要寻找匹配的地块,首先要找到跟当前连接点匹配的连接点类型:

      /// <summary>
      /// 获取当前连接口匹配的连接口类型
      /// </summary>
      Joint.Type GetExpectedJointType(Joint.Type currentType)
      {
          // 上开口连接下开口、左开口连接右开口
          Joint.Type expectedType = default(Joint.Type);
          if (currentType == Joint.Type.Up)
              expectedType = Joint.Type.Down;
          else if (currentType == Joint.Type.Right)
              expectedType = Joint.Type.Left;
          else if (currentType == Joint.Type.Down)
              expectedType = Joint.Type.Up;
          else
              expectedType = Joint.Type.Right;
          return expectedType;
      }
      

      然后在地块预制体中寻找包含这种连接点类型的地块:

      /// <summary>
      /// 获取与当前连接口匹配的地块
      /// </summary>
      /// <param name="cellPrefabs">地块预制体列表</param>
      /// <param name="expectedType">期望连接口类型</param>
      /// <param name="tempCells">临时地块列表,用于随机</param>
      Cell GetMatchingCell(List<Cell> cellPrefabs, Joint.Type expectedType, List<Cell> tempCells)
      {
          tempCells.Clear();
          foreach (var item in cellPrefabs)
          {
              if (item.HasJoint(expectedType))
              {
                  tempCells.Add(item);
              }
          }
          if (tempCells.Count == 0)
              return null;
          return Utils.GetRandom<Cell>(tempCells);
      }
      

      效果:

      最后GenerateLevel方法中将有空余连接点的地块封口:

      // 将剩余地块用单开口地块封闭
      foreach (var item in cells)
      {
          item.GetAvailableJoints(tempJoints);
          foreach (var joint in tempJoints)
          {
              // yield return new WaitForSeconds(.3f);
              GenerateMatchingCell(roomPrefabs, joint, tempCells);
          }
      }
      

      效果:

      冲突处理

      生成较多地块时,会发生冲突:

      由于是逐个地块生成,冲突检测只能在要生成下一个地块时进行判断,如果当前连接点前方一段距离内已经存在地块,则该连接点不能用于继续生成,需要封闭;另外,如果连接点前方不存在地块,还需要判断前方的前方、左方、右方是否存在地块,若存在则只能生成开口朝向别处的地块。

      每个地块添加碰撞体:

      LevelGenerator.cs加入冲突检测相关配置:

      [Header("冲突检测")]
      [Tooltip("检测距离")]
      public float conflictCheckDistance;
      [Tooltip("检测box")]
      public Vector3 conflictCheckHalfBox;
      [Tooltip("检测图层")]
      public LayerMask cellLayer;
      

      GenerateLevel方法中:

      void GenerateLevel()
      {
          ...
          // 不期望的房间连接口,用于避免生成可能会造成冲突的房间
          List<Joint.Type> unwantedJointTypes = new List<Joint.Type>();   
          int cellNum; // 当前地块数量(已生成+即将生成)
          // 生成初始地块
          ...
          // 循环生成
          while (cellNum < cellTotalNum)
          {
              // 随机获取现有未封闭地块,并随机取得其连接口
              ...
              // 检测当前连接口前方是否存在直接冲突,若存在则直接跳过
              // 或其前方的左、前、右是否存在冲突,若存在则存放在不期望的房间连接口列表中
              unwantedJointTypes.Clear();
              if (CheckConflict(currentJoint, unwantedJointTypes))
              {
                  Debug.Log("检测到冲突,放弃当前房间");
                  continue;
              }
              // 获取与当前连接口匹配的地块
              ...
          ...
      

      CheckConflict方法:

      bool CheckConflict(Joint joint, List<Joint.Type> unwantedJointTypes)
      {
          // 先检测前方是否有冲突
          var center = joint.transform.position + joint.transform.forward * conflictCheckDistance;
          var cols = Physics.OverlapBox(center, conflictCheckHalfBox, Quaternion.identity, cellLayer, QueryTriggerInteraction.Collide);
          if (cols.Length > 0)
          {
              // 存在冲突则直接返回
              return true;
          }
          else
          {
              // 没有冲突,再检测相对于前方的前方、左方、右方是否存在冲突
              var distance = 3 * conflictCheckDistance;
              var forward = Physics.OverlapBox(center + joint.transform.forward * distance, conflictCheckHalfBox, Quaternion.identity, cellLayer, QueryTriggerInteraction.Collide);
              var left = Physics.OverlapBox(center - joint.transform.right * distance, conflictCheckHalfBox, Quaternion.identity, cellLayer, QueryTriggerInteraction.Collide);
              var right = Physics.OverlapBox(center + joint.transform.right * distance, conflictCheckHalfBox, Quaternion.identity, cellLayer, QueryTriggerInteraction.Collide);
              // 记录到不期望的连接类型列表
              if (forward.Length > 0)
                  unwantedJointTypes.Add(joint.type);
              if (left.Length > 0)
                  unwantedJointTypes.Add(joint.GetLocalLeft());
              if (right.Length > 0)
                  unwantedJointTypes.Add(joint.GetLocalRight());
              return false;
          }
      }
      

      如果没有直接冲突,按照上面的逻辑判断前方的前方、左方、右方是否存在地块,若存在则记录到不期望的连接类型列表,随后由GenerateMatchingCell传入到GetMatchingCell中:

      Cell GenerateMatchingCell(List<Cell> cellPrefabs, Joint currentJoint, List<Joint.Type> unwantedJointTypes, List<Cell> tempCells)
      {
          ...
          // 根据期望匹配的连接口类型获取合适的地块
          var matchingCell = GetMatchingCell(cellPrefabs, expectedJointType, unwantedJointTypes, tempCells);
          ...
      }
      
      Cell GetMatchingCell(List<Cell> cellPrefabs, Joint.Type expectedType, List<Joint.Type> unwantedTypes, List<Cell> tempCells)
      {
          ...
          foreach (var item in cellPrefabs)
          {
              if (item.HasJoint(expectedType))
              {
                  // 判断是否还包含不期望的连接口
                  bool hasUnwanted = false;
                  if (unwantedTypes != null)
                  {
                      foreach (var unwantedType in unwantedTypes)
                      {
                          if (item.HasJoint(unwantedType))
                          {
                              hasUnwanted = true;
                              break;
                          }
                      }
                  }
                  if (!hasUnwanted)
                      tempCells.Add(item);
              }
          }
          ...
      }
      

      效果:

      发布在 游戏开发
      Pamisu
      Pamisu
    • 1
    • 2
    • 3
    • 4
    • 4 / 4