Design Hub

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

    Pamisu 发布的帖子

    • RE: 一个开发杂记贴

      Godot 3.5 与 Unity 2021.3.12f1 LTS 打包对比

      02f1ee6f-cbd7-4746-8be2-d86428260803-image.png

      c98f1448-7547-4ea8-9369-c91b00b4cb5c-image.png

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      什么是CBUFFER,为什么要用它

      Unity不为我们提供模型-视角-投影矩阵,因为M和VP矩阵的相乘是可以避免的。除此之外,对于被同一相机中渲染的物体,VP矩阵在每帧可以被重复使用(将坐标从世界空间变换到投影空间)。Unity的shader重复利用这一事实,将这些矩阵放在不同的常量缓存区中。虽然我们将它定义为变量,但它们的数据在绘制单个图形的时间内保持不变,甚至往往保持的更久。VP矩阵可以放在逐帧(per-frame)缓存区,而M矩阵则保存在逐绘制(per-draw)缓存区。

      虽然没有强制要求将这些shader变量放在常量缓存区,但是这么做可以更有效地更改同一缓冲区中的所有数据。至少在对应的图形api支持的时候是这样的。不过OpenGL不支持。

      为了更有效率,我们充分利用常量缓存区。Unity将VP矩阵放在UnityPerFrame缓存区,把M矩阵放在UnityPerDraw缓存区。有很多数据都可以放在这些缓存区中。一个常量缓存区像结构体一样定义,但是使用cbuffer关键字。

      cbuffer UnityPerFrame {
      	float4x4 unity_MatrixVP;
      };
      
      cbuffer UnityPerDraw {
      	float4x4 unity_ObjectToWorld;
      };
      

      因为常量缓存区并不对所有平台有增益,所以Unity的shader依赖宏来确保只在需要时使用它们。用宏CBUFFER_START带一个名字参数来代替cbuffer,以宏CBUFFER_END 来作为缓冲区的结尾。
      这个宏是在核心库中定义的,所以需要先引入核心库。

      #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
      
      CBUFFER_START(UnityPerFrame)
      	float4x4 unity_MatrixVP;
      CBUFFER_END
      
      CBUFFER_START(UnityPerDraw)
      	float4x4 unity_ObjectToWorld;
      CBUFFER_END
      

      那么这一段很好理解了,并不是所有的值都需要在每个物体每次绘制时由CPU提交给GPU,有些数据是可以在一定的时间段共用的,CBUFFER的目的便是将这些值放入常量缓存区以提升性能。而UnityPerFrame UnityPerDraw UnityPerMaterial则代表着不同类型的常量缓存区。

      CBUFFER_START(UnityPerMaterial)
      float4 _MainTex_ST;
      half4 _BaseColor;
      CBUFFER_END
      
      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      URP在iPhone SE与Switch下的渲染设置示例

      作为低端机型的参考配置比较有用。

      fcc28111-b35e-4403-8d2e-310060508210-image.png

      4af47e60-f707-43ba-bcf7-cc9c50b9c2a5-image.png

      1d4da448-092e-442c-86b4-e35ef4a17ab1-image.png

      66677a7a-e624-4f9f-81a6-544e6c5114d6-image.png

      0a2df3ef-5de6-427d-8669-c889a0fa5a71-image.png

      db4a8703-0bf2-4493-a829-6277f6357fe2-image.png

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      Task.Factory.StartNew 和 Task.Run 到底有什么区别?

      https://blog.csdn.net/sD7O95O/article/details/124137932
      简而言之,使用 Task.Factory.StartNew 必须等待 AttachedToParent 任务执行完,而 Task.Run 不必。

      但在Unity客户端中,我推荐使用宇宙第一NB的UniTask,0GC、与引擎更加契合,更适合使用Unity的程序宝宝

      UniTask使用文档

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 现在微信小游戏很火

      Unity小游戏转换工具还是很爽,轻度一点的好好做下优化问题都不大感觉

      发布在 相关资讯
      Pamisu
      Pamisu
    • RE: 现在微信小游戏很火

      是真的牛,随便上网冲个浪都能看到五六个咸鱼之王的广告

      发布在 相关资讯
      Pamisu
      Pamisu
    • RE: 最近好累,一直在加班

      加爆了!但是很充实!

      发布在 综合讨论
      Pamisu
      Pamisu
    • RE: 挖坑不填林诗音

      哈哈哈哈哈哈哈哈

      发布在 综合讨论
      Pamisu
      Pamisu
    • RE: 记一下待完善的部分

      用我的空闲163邮箱配了一下,如果需要改的话可以参考这里

      在设置→邮件中填写邮件地址和发件人:
      894bce9c-71cf-4874-9ed0-fd9c6581df02-image.png

      本来以为这个邮箱地址可以和实际发件的邮箱地址不一样,但是如果不一样的话,发送时会报553错误,不知道是不是只有网易邮箱这样。

      前往163邮箱开启POP3/SMTP服务,开启后会给一个授权码。
      af961048-f760-44bc-9932-c9815be8d66e-image.png

      回到设置,在SMTP服务填写账号和授权码:
      47eac9f7-0a34-454c-a22a-2c52aa18a136-image.png

      然后就可以测试了:
      88eda706-1d47-4c2b-b863-1322118c2158-image.png

      92c2eca6-c9b6-4d3b-943a-4c13fad1fbb8-image.png

      不过如果没有在config.json文件里修改url的话,点击链接是跳转到localhost而非designhub.top,改完之后重启服务就好了。

      发布在 综合讨论
      Pamisu
      Pamisu
    • RE: 记一下待完善的部分

      @GShion 一波三折,配好了,但没有完全配好。。
      现在可以发送邮件,但邮件里的链接是指向localhost,得手动改下配置文件里的url
      待会改好了写个配置总结

      发布在 综合讨论
      Pamisu
      Pamisu
    • 记一下待完善的部分
      • 论坛搜索功能,默认的搜索插件不支持中文,需要安装中文搜索相关的插件,有一些但还没看哪个好能用
      • https证书,有很多免费签发的,但还没看
      • 发送用户激活邮件的相关配置配了...吗
      • 比利比利视频链接解析,嵌入播放器
      • DDOS防御,其实也没啥好防的
      • 数据库备份,简单点就是把data文件夹复制备份一下就好了,不知道有没有内置的备份功能

      暂时想到这些,之后有时间慢慢搞

      发布在 综合讨论
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      Don'tDestroyOnLoad 对象管理

      c0b75675-6a32-4ba2-bdc2-ee2cc2046207-image.png

      709f9350-c259-4c16-bc7f-bb349706b2fb-image.png

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      C#中的值类型与引用类型,堆内存与栈内存

      值类型:数字、布尔、复合(enum、struct)
      引用类型:数组、字符串、类、接口、委托
      值类型在栈中分配、读取速度快、内存自动回收、参数传递时开辟新空间;引用类型在堆中分配、读取速度慢、内存由GC回收、参数传递时传引用

      a8ceacc1-5e09-448c-9a4c-9faff22b8557-image.png

      栈是由高地址向低地址增长。堆是由低地址向高地址增长。
      当栈或堆现有的大小不够用的时候,它将按照图中的增长方向扩大自身的尺寸,直到预留的空间被用完为止。

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      UE中如何以固定角度变化量从四元数A平滑变换到四元数B?

      1. 速度乘以时间得出角度变化量
      2. 两四元数夹角,即总的角度
      3. 变化量除以总量,即为Slerp所需的Alpha

      a7815c11-7c84-4ed5-b4dc-e58df55c27ff-image.png

      Unity中类似的API

      发布在 游戏开发
      Pamisu
      Pamisu
    • 一个开发杂记贴

      总之是游戏开发相关的比较琐碎的一些记录,包括但不限于代码片段、功能实现思路、疑难杂症、ChatGPT问答记录(可能含有欺骗性内容)、学习笔记、运行效果截图、养生指南等等,不断更新中。

      发布在 游戏开发
      Pamisu
      Pamisu
    • [Unity]时间控制插件Chronos的基本使用与原理分析

      时间控制在游戏中是一类常见的功能,例如菜单里的暂停、倍速,再如《武士 零》中的慢动作、倒带等时间系能力。最近初步尝试了一款时间控制插件Chronos,网上相关的中文资料比较少,不知道会不会踩坑,总之记录一下使用笔记,并结合源码对其原理做些简单的分析。



      与《武士 零》中可以预知未来、操控时间的药物“柯罗诺斯”一样,这款插件也以古希腊神话中的时间神命名,通过它可以控制游戏中的时间流速,实现倍速、暂停、时光倒流,同时它还提供针对单个物体、一组物体和指定区域内的时间控制。

      从插件描述可以看出,虽然不能预知未来,但只要不是太复杂的时间控制功能,它基本都可以实现。

      插件从2020年6月开始永久免费,在Asset Store下载并在Unity中导入即可:
      https://assetstore.unity.com/packages/tools/particles-effects/chronos-31225

      设计理念

      在使用之前,先来了解它的设计理念。假设现在要做一个正经的塔防游戏“今夜圆饼”,游戏有如下要求:

      1. 游戏时间与用户界面时间互不影响,游戏可以暂停、倍速,而用户界面始终保持正常速度。
      2. 每类物体(敌人、防御塔、玩家角色)的时间统一受游戏时间影响并且可以单独调整,它们之间互不影响。
      3. 每个物体的时间也可以单独调整,比如某种环境效果,让区域内的敌我单位加/减速。
      4. 时间流速改变时,动画、粒子效果等的速度要一同变化。


      针对上述需求,Chronos给出了这样的结构:

      Timekeeper

      作为根节点,管理场景中的所有时钟,每个场景中只需要一个Timekeeper,是单例模式。

      Global Clock

      管理一组物体的时间,根节点下分为了Root与Interface两个时钟,Root用来管理游戏时间,Interface用来管理用户界面时间。Root时钟下又有Enemies、Turrets和Player时钟,Root时钟的流速改变时,这三个时钟都会随之改变。

      Local Clock

      与Global Clock类似,Local Clock用来管理单个物体的时间,对于玩家角色只存在一个的情况,Local Clock更为合适。

      Timeline

      可以理解为时钟的具体实施者,Timeline组件挂在各个游戏物体上,改变它们的时间流速。

      Area Clock

      改变区域内物体的时间流速,比如宣传图里的时间结界。

      以上就是Chronos的核心组件,使用时添加好对应的组件就行了,比如这里的正经塔防游戏引入Chronos的步骤:

      1. 创建一个空物体,添加Timekeeper组件。
      2. 在同一物体上继续添加Global Clock组件,设置好它们的Key与父子关系。
      3. 如果有玩家角色(萝卜之类的),在玩家物体上添加Local Clock组件。
      4. 给防御塔、敌人、玩家角色物体添加Timeline组件,设置好对应的时钟。
      5. 在脚本中获取对应的时钟,改变它的timeScale来调整时间流速。
      clock.localTimeScale = value;
      

      运行效果(并不是塔防):



      这样就搞定了,是不是很简单,那么本篇笔记就到这里,最后祝您身体健康,再见。

      深入使用

      上面是官方教程中介绍的使用步骤,在项目中引入这个插件确实很简单,但个人更关心这些问题:

      1. 插件的作用范围,它支持哪些组件,如何配合使用,有哪些限制。
      2. 对于自己的脚本以及不支持的组件,如何进行扩展。
      3. 插件的实现原理,性能如何。

      作用范围

      从自带的示例可以看出,Chronos支持的Unity组件有Rigidbody、Animator、Nav Mesh Agent、Particle System、Audio Source等,在源码中可以看到它已适配的组件:

      扩展

      Timeline

      在自己的脚本中引入时间控制,最基础的方法就是使用Timeline代替Unity的Time,比如原来使用Time.deltaTime,改为Timeline.deltaTime:

      private void Start()
      {
          timeline = GetComponent<Timeline>();
      }
      
      private void Update()
      {
          if (targetingCounter > 0)
          {
              // targetingCounter -= Time.deltaTime;
              targetingCounter -= timeline.deltaTime;
              ...
          }
      }
      

      如果用到了内置组件,同样用Timeline中的对应组件代替,比如rigidbody改为Timeline.rigidbody:

      // rb.velocity = targetDirection * targetSpeed;
      timeline.rigidbody.velocity = targetDirection * targetSpeed;
      

      完整的替换表:

      https://ludiq.io/chronos/manual/migration

      Occurrence

      不远处有个怪物生成点(比如刷怪笼)正在不停地刷怪,一大波僵尸正向我袭来,然后我灵机一动将敌方的时间流速改为了负二倍速,刷怪笼和一大波僵尸的时间都被逆转,它们回到了原来的位置——但仅限于此,僵尸并不会随着时光倒流而消失。

      为什么呢?Chronos会按设置的时间间隔,不断记录物体的各种信息,在时光倒流时进行回溯,但它并不会记录什么时候物体被生成,什么时候需要被销毁,这部分工作需要我们自己完成。

      为此Chronos提供了一个叫Occurrence的工具,通过Timeline调用,结构长这样:

      timeline.Do
      (
          true, // 是否可重复执行
          delegate() // 前向操作
          {
              // 生成物体并返回它
          },
          delegate(object transfer) // 逆向操作
          {
              // 销毁对应的物体
          } 
      );
      

      有点电影《信条》的感觉,这里前向操作与逆向操作是成对的,时间正常流转时执行前向操作,时间倒流时执行对应的逆向操作。

      使用示例:

      private void Start()
      {
          timeline = GetComponent<Timeline>();
          StartCoroutine(Spawn());
      }
      private IEnumerator Spawn()
      {
          while (true)
          {
              timeline.Do(
                  true, // 允许重复执行
                  () =>
                  {
                      // 前向操作
                      if (num >= maxNum)
                          return null;
                      var go = Instantiate(prefabs[Random.Range(0, prefabs.Length)]);
                      go.transform.position = spawnPoint.position;
                      go.transform.rotation = spawnPoint.rotation;
                      num++;
                      return go;
                  },
                  (gameObject) =>
                  {
                      // 逆向操作
                      if (gameObject != null)
                      {
                          Destroy(gameObject);
                          num--;
                      }
                  });
              yield return new WaitForSeconds(spawnInterval);
          }
      }
      

      使用Timeline与Occurrence,可以满足一些简单的需求,对于更复杂的情况,比如物体数值与状态的记录与回溯,则可能需要做一套完整的适配。目前个人项目需求比较简单,暂时没到这一步,之后如果有做相关的扩展再来补充。

      一些坑

      Chronos并不是万能的,官网列出了它的限制:

      https://ludiq.io/chronos/manual/limitations

      如果粒子系统需要支持时间回溯,其中的限制可能影响较大:

      • 低速(小于0.25倍速)时粒子系统可能会卡顿。
      • 粒子系统的模拟空间只能是本地。
      • 不支持粒子的碰撞检测。

      这些问题主要是由粒子系统的Simulate方法引起的,后面的原理分析中会提到。可以说大部分限制的原因都来自引擎底层,看了下插件的最后更新时间,这些问题多半是不会修复了。

      除了上面提到的限制,个人在使用过程中也遇到了一些问题:

      1. 粒子系统如果勾选了Play On Awake,运行时会报错:

      这是由于Chronos在初始化时会将粒子系统的随机种子改为固定的值,而此时粒子已经开始播放了,Unity不支持这个操作所以报错。可以取消勾选Play On Awake,初始化完后再调用播放,注意是通过timeline的particleSystem来播放,不能直接调用。

      timeline.particleSystem.Play();
      
      1. 并不能直接影响Shader中的时间速度,需要另外适配。

      2. 如果有时间回溯功能,在倒带时需要注意屏蔽玩家控制,避免引起冲突,比如角色控制脚本中,仅在timeScale为正时开启玩家控制。

      原理分析

      只是粗略看了一下源码,可能会有一些分析得不对的地方。

      Timekeeper、Global Clock的树形结构以及对Timeline的管理比较好理解,这部分就不看了,更值得关注的是Timeline是如何控制组件的时间流速的,这里从Timeline的源码开始阅读。

      在Timeline的父类TimelineEffector中,定义了一堆它已适配的组件类,每个类与Unity的内置组件相对应:

      这些XXXTimeline均继承自ComponentTimeline类,实现IComponentTimeline接口,虽然这里也命名为组件,但它们不继承Unity的Component,仅持有对应Unity组件的引用,可以看作是Unity组件的一层包装。

      在Awake中,调用CacheComponents方法,获取当前物体上挂载的Unity组件,将其包装成对应的Timeline组件,初始化并存入components列表中:

      注意这里的注释,如果运行过程中添加或移除了物体上相关的Unity组件,则需要重新调用一次这个方法。

      Timeline组件的初始化方法Initialize中只有一个CopyProperties的调用:

      CopyProperties的实现因组件而异,通常其中会记录一些与时间相关的参数,例如Animator组件记录播放速度、Audio Source组件记录音高。

      初始化中针对一些组件有特殊的处理逻辑,比如Rigidbody与Transform,具体可以看源码,这里就不过多介绍了。

      Start或OnEnable中,Timeline将应用对应时钟的时间流速timescale,并调用所有组件的AdjustProperties方法:

      AdjustProperties中应用时间流速,例如Animator组件调整播放速度、Nav Mesh Agent组件调整移动速度与转向速度、粒子系统调整simulationSpeed等等。

      常用的事件函数中调用所有组件的对应事件函数:

      对大部分内置组件来说,光是调整速度还无法做到时间倒流的效果,Chronos对不同的组件采用了不同的解决方法。

      Animator

      Animator是相对简单的一个,将它的播放速度设置为负数就可以倒放了。在时间正常流转时,调用Animator的StartRecording录制,在时间倒流时,倒放之前的录制结果。

      Transform、Rigidbody

      对于Transform,Chronos用了一个自定义的RecorderTimeline组件,RigidbodyTimeline组件同样继承于它。时间正常流转时,按设置的录制间隔将物体的位置、旋转等信息(缩放默认被注释了,需要可以自己打开)录制成Snapshot并缓存起来,在时间倒流时逐个应用这些Snapshot。

      Particle System

      根据是否要支持时间回溯,Chronos将粒子系统组件分为两个,NonRewindableParticleSystemTimeline与RewindableParticleSystemTimeline。

      不可回溯的实现很简单,根据Timeline的timeScale调整粒子系统的simulationSpeed即可;

      可回溯的粒子系统是通过Simulate方法来实现的,Simulate方法可以让粒子系统立即达到到指定时间点的状态。时间正常流转时,记录粒子系统的播放状态(启用、禁用、播放、暂停),在倒带时还原这些播放状态。

      Simulate也带来了上面提到的问题:

      1. 低速卡顿,这个问题Unity从2015年到现在都没修复,但个人测试感觉不太明显,处于可接受的范围。
      2. 不断调用导致模拟空间不断更新,所以粒子的模拟空间仅限本地。
      3. 不支持粒子的碰撞检测。

      总结

      从源码中可以得知,Chronos初始化时需要对游戏物体上的Unity组件做一层包装,常用的事件函数(Start、OnEnable、FixedUpdate、Update、OnDisable)中会遍历所有包装组件并调用相关方法,在Timeline的Update中还包含对Occurrence的处理等等。如果要支持时间回溯,在游戏运行时需要对某些组件的状态进行录制,不同组件占用的内存空间不同。

      如果自己的脚本需要完全接入Chronos,可以像上面哪些组件一样,继承ComponentTimeline,并加入到Timeline的初始化过程中。

      对于各组件中的录制功能,Timeline提供了统一的参数配置,可以调整录制间隔与录制的最大时长,并会给出预计消耗的内存:

      如果游戏不需要时间回溯功能,那么可以取消勾选Rewindable以节省性能。

      具体的性能测试还没有做,大概率鸽了。

      发布在 游戏开发
      Pamisu
      Pamisu
    • [Unity]🌿🌿🌿🌿🌿🌿

      本篇笔记包含草的常见制作方式介绍,以及对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

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

      发布在 游戏开发
      Pamisu
      Pamisu
    • [Unity]简易传送门效果

      使用ShaderGraph连连看与粒子系统制作一个简易的传送门效果:

      整体思路:

      1. 通过对纹理的扭曲与旋转,并挖空中间与边缘部分,实现传送门图形
      2. 加上Bloom后处理、粒子与点光源

      图形部分

      项目安装并配置好通用渲染管线(URP),新建一个Lit Shader Graph:

      我使用的URP版本为10.6.0,暂时没升级到新版本,不过只要思路一致,新版本的操作应该也差不了太多。

      打开新建的ShaderGraph,传送门需要透明显示,并且前后两面都要渲染,在Graph Settings修改这两个配置项:

      旋转与扭曲

      先来做纹理的旋转与扭曲。依然是用Voronoi节点,其他的纹理也可以,好看就行。Twirl节点可以将纹理旋转扭曲,Strength参数决定了扭曲的力度:

      通过调整Twirl的Offset参数可以让它动起来,新建一个TwirlSpeed属性,值先随便填个0.2,与Time节点的值相乘连入Offset,顺便把其他要在外部调整的属性一并创建好,并在Node Settings中设置好它们的值:

      观察可以发现,这样的旋转扭曲在视觉上并不是统一往一个方向流动。个人觉得这个效果不错,但还不是非常OK,需要让整个图像真正地旋转起来。

      新建一个Rotate节点,并新建一个名为RotateSpeed的Float属性(值为2),将其与Time相乘,然后连到Rotate节点的Rotation参数中,之后Rotate节点的输出连到Twirl节点的UV输入:

      这部分就完成了,将TwirlSpeed临时调为0,可以更清楚地看到旋转效果。

      遮罩

      接下来挖空中间与边缘部分,需要用到一张纹理做遮罩。新建一个名为Mask的Texture2D属性,设置默认值为自带的Default-Particle纹理:

      用Sample Texture 2D节点对其采样,并用Voronoi的输出减去采样结果:

      中间出现了空洞,但不够明显,对Mask的采样结果做进一步处理,先乘一个正数扩大中心,再用指数函数减小边缘:

      如果希望这部分可以在外部调整,可以将两个Float变量转换为属性。

      在挖空边缘之前,先来控制一下图形中纹理的溶解程度。新建一个Dissolve的Float属性(值为2),将Subtract节点的输出做一个Saturate,然后用指数函数控制溶解:

      因为之前做了减操作,可能会出现负数,Saturate用来将输入限制在0到1范围内,避免Power节点出错。

      溶解这一步不是很有必要(我后来又把Dissolve改回1了),如果希望能调整条纹的溶解程度以实现不同的效果可以加上。

      接下来挖空边缘,依然是利用Mask,将采样结果与另一个正数相乘,让它变得大一些:

      再与纹理部分相乘:

      边缘挖空也完成了。

      收尾

      最后染色并查看效果,新建一个Color属性,模式设置为HDR,这样才能闪闪发亮,调一个喜欢的颜色(如果太亮了就把强度调低点):

      混合颜色并输出到Fragment:

      以这个ShaderGraph新建一个材质:

      场景中新建Global Volume,开启Bloom后处理:

      相机的后处理也记得开一下:

      场景中新建一个Quad,拖入材质可以看到效果:

      如果没在动的话,把Always Refresh勾上:

      调整后的各项属性值:

      可以尝试不同的属性值,以达到不同的效果:



      完整的ShaderGraph:

      加一些特技

      粒子效果基本照抄油管上印度小哥的那个教程,根据个人需求有一些修改。

      环绕粒子

      单独的环绕粒子效果:

      环绕粒子需要发光,新建一个粒子材质,Shader选择Universal Render Pipeline/Particles/Unlit,按如下配置:

      在Quad下新建一个Particle System,Renderer中拖入刚才创建的粒子材质,其他大致这样配置:

      粒子的配置比较随意,根据个人喜好可以有不同的配法。这里为了让粒子环绕中心旋转,用的是甜甜圈形状,如果运动要更随机一些,可以再加上Noise。

      背景阴影

      这个感觉可加可不加,比较简单就不说明了。

      最终效果:

      一些传送门的模型会给出特效所在的Mesh,这种情况下只要将传送门材质拖上去,适当调整粒子的形状即可。另外记得关闭阴影投射,避免影子穿帮:

      项目中用到的素材:

      Portal by Vetech82

      Sand portal by Yarelon

      一个题外话,mac上如果出现ShaderGraph编辑界面卡顿掉帧的情况,可以尝试在Player设置中临时使用OpenGLCore图形API并重启编辑器,ShaderGraph会变得很丝滑,但缺点是运行游戏时可能会死机。

      发布在 游戏开发
      Pamisu
      Pamisu
    • [Unity]大批量物体渲染学习笔记(二)

      上一篇使用Graphics.DrawMeshInstancedIndirect实现了基本的物体渲染,但还没有做剔除,相机视野外的物体也会被渲染,造成性能上的浪费。这一篇来做剔除方案中常见的视锥剔除,并顺便实现物体的旋转与缩放。

      简单来说,视锥剔除就是判断物体是否在当前相机的视锥体内,排除掉完全在视锥体外的物体,仅渲染视锥体内的物体,减少不必要的消耗。需要强调的是,只有在使用类似DrawMeshInstancedIndirect这样的API做渲染时,才需要自己做剔除工作,用自带的Renderer组件渲染物体时Unity会帮我们做这些处理。

      关于视锥剔除如何实现以及为什么要用ComputeShader做视锥剔除,推荐一篇文章:

      Unity中使用ComputeShader做视锥剔除(View Frustum Culling)

      大佬的文章讲得很详细,包括视锥剔除的原理、ComputeShader如何使用、如何根据物体的包围盒进行剔除等等,可以说是保姆级教学了。

      所以这里仅仅记录个人的实现和踩坑过程,由于需要顺便实现物体的旋转与缩放,会有一些不同之处。最终效果:

      整体思路并不复杂,每一帧我们需要做这些事:

      1. 获取当前相机视锥的六个面的定义,即平面方程Ax+By+Cz+D=0,可以自己计算,也可以通过API获取。
      2. 获取每个物体的包围盒,通常包围盒的大小可以是一个定值,判断时再根据物体当前变换(平移、旋转、缩放)计算包围盒八个点的实际坐标。
      3. 把上面的东西扔到CoumputeShader里计算,判断哪些物体在视锥体内,返回这些物体的instanceID。
      4. 根据返回的instanceID渲染,而不是渲染全部物体,这样渲染出来的就是剔除后的结果了。

      除了上面文章中提到的方法之外,还有另一种剔除方法,即将包围盒顶点坐标换到裁剪空间下,判断每个点的齐次坐标的x、y、z是否在[-w, w]区间(DirectX则判断是否在[0, w]),如果八个点都不在视锥体内,则将其剔除。这个方法的计算量比上面的方法少一些,但缺点也很明显,如果当前相机正紧贴着一堵超大的墙,比如主角正在面壁思过,那么此时墙体包围盒的八个点都不在视锥体内,就会被剔除掉,所以这种方法适合较小的物体,本篇笔记中也会顺便实现这种方法。

      ComputeShader

      先来实现第一种剔除方法,整个剔除过程基本上是围绕着ComputeShader进行,搞清楚输入与输出后,可以编写ComputeShader了:

      FrustumCulling.compute

      #pragma kernel CSMain
      
      float4 _FrustumPlanes[6];   // 视锥体的六个面
      float3 _BoundMin;   // 物体包围盒最小点
      float3 _BoundMax;   // 物体包围盒最大点
      StructuredBuffer<float4x4> _AllMatricesBuffer;   // 所有物体的复合变换矩阵
      AppendStructuredBuffer<uint> _VisibleIDsBuffer;  // 可见物体实例ID
      
      bool IsOutsideThePlane(float4 plane, float3 position)
      {
          return dot(plane.xyz, position) + plane.w > 0;
      }
      
      [numthreads(640, 1, 1)]
      void CSMain(uint3 id : SV_DispatchThreadID)
      {
          float4x4 m = _AllMatricesBuffer[id.x];
          float4 boundPoints[8];
          boundPoints[0] = mul(m, float4(_BoundMin, 1));
          boundPoints[1] = mul(m, float4(_BoundMax, 1));
          boundPoints[2] = mul(m, float4(_BoundMax.x, _BoundMax.y, _BoundMin.z, 1));
          boundPoints[3] = mul(m, float4(_BoundMax.x, _BoundMin.y, _BoundMax.z, 1));
          boundPoints[4] = mul(m, float4(_BoundMax.x, _BoundMin.y, _BoundMin.z, 1));
          boundPoints[5] = mul(m, float4(_BoundMin.x, _BoundMax.y, _BoundMax.z, 1));
          boundPoints[6] = mul(m, float4(_BoundMin.x, _BoundMax.y, _BoundMin.z, 1));
          boundPoints[7] = mul(m, float4(_BoundMin.x, _BoundMin.y, _BoundMax.z, 1));
          
          for (int i = 0; i < 6; i++)
          {
              for (int j = 0; j < 8; j++)
              {
                  float3 p = boundPoints[j].xyz;
                  if (!IsOutsideThePlane(_FrustumPlanes[i], p))
                      break;
                  if (j == 7)
                      return;
              }
          }
          
          _VisibleIDsBuffer.Append(id.x);
      }
      

      基本照抄大佬文章中的ComputeShader,不同的是,这里使用了一个_VisibleIDsBuffer,如果物体被判断为可见,则将实例ID追加到其中,在随后的渲染Shader中,同样会使用它获取可见的物体的实例ID。

      ComputeShader中的变量值将在C#侧传入,CSMain函数也将在C#侧调用。

      输入与调用

      接下来在C#侧把ComputeShader所需的数据准备好并调用。复制上一篇中的ExampleClass.cs,改名为FrustumCullingRenderer.cs,加上所需要的字段:

      FrustumCullingRenderer.cs

      public class FrustumCullingRenderer : MonoBehaviour
      {
          public int instanceCount = 100000;
          public Mesh instanceMesh;
          public Material instanceMaterial;
          public int subMeshIndex = 0;
          // 新增:物体包围盒最小点
          public Vector3 objectBoundMin;
          // 新增:物体包围盒最大点
          public Vector3 objectBoundMax;
          // 新增:ComputeShader
          public ComputeShader cullingComputeShader;
          
          int cachedInstanceCount = -1;
          int cachedSubMeshIndex = -1;
          // 新增:ComputeShader中内核函数索引
          int kernel = 0;
          // 修改:原positionBuffer改为物体的复合变换矩阵Buffer
          ComputeBuffer allMatricesBuffer;
          // 新增:当前可见物体的instanceID Buffer   
          ComputeBuffer visibleIDsBuffer;
          ComputeBuffer argsBuffer;
          uint[] args = new uint[5] { 0, 0, 0, 0, 0 };
          // 新增:相机的视锥平面
          Plane[] cameraFrustumPlanes = new Plane[6];
          // 新增:传入ComputeShader的视锥平面  
          Vector4[] frustumPlanes = new Vector4[6];
          ...
      

      其中物体包围盒与ComputeShader在编辑器里设置,比如默认的Cube是1个单位大小,那么包围盒可以这样设置:

      世界空间下的包围盒坐标的计算还需要用到物体的复合变换矩阵(平移矩阵、旋转矩阵、缩放矩阵),上一篇中的positionBuffer在这里升级成了allMatricesBuffer,在ComputeShader中,我们用这个矩阵将物体包围盒的顶点坐标由模型空间转换到世界空间下。

      在ComputeShader中计算完剔除后,需要将所有可见物体的实例ID返回,这里使用visibleIDsBuffer来接收。

      修改UpdateBuffers方法,给allMatricesBuffer与visibleIDsBuffer赋值:

      void UpdateBuffers()
      {
          // 不需要更新时返回
          ...
          // 规范subMeshIndex
          ...
          // 修改:物体位置 改为 物体复合变换矩阵
          allMatricesBuffer?.Release();
          allMatricesBuffer = new ComputeBuffer(instanceCount, sizeof(float) * 16);   // float4x4
          Matrix4x4[] trs = new Matrix4x4[instanceCount];
          for (int i = 0; i < instanceCount; i++)
          {
              // 随机位置
              float angle = Random.Range(0.0f, Mathf.PI * 2.0f);
              float distance = Random.Range(8.0f, 90.0f);
              float height = Random.Range(-5.0f, 5.0f);
              float size = Random.Range(0.05f, 1f);
              var position = new Vector4(Mathf.Sin(angle) * distance, height, Mathf.Cos(angle) * distance, size);
              trs[i] = Matrix4x4.TRS(position, Random.rotationUniform, new Vector3(size, size, size));
          }
          allMatricesBuffer.SetData(trs);
          instanceMaterial.SetBuffer("_AllTRSBuffer", allMatricesBuffer);
          ...
      
          // 新增: 可见实例 Buffer
          visibleIDsBuffer?.Release();
          visibleIDsBuffer = new ComputeBuffer(instanceCount, sizeof(uint), ComputeBufferType.Append);
          instanceMaterial.SetBuffer("_VisibleIDsBuffer", visibleIDsBuffer);
      
          // Indirect args
          ...
      

      可以注意到,visibleIDsBuffer需要指定类型为ComputeBufferType.Append,表示在Shader中可以对它追加值,对应ComputeShader中的AppendStructuredBuffer类型,之后可见物体的实例ID将被追加到其中。

      随后把上面提到的东西都扔到CoumputeShader里:

          ...
          // ComputeShader
          cullingComputeShader.SetVector("_BoundMin", objectBoundMin);
          cullingComputeShader.SetVector("_BoundMax", objectBoundMax);
          cullingComputeShader.SetBuffer(kernel, "_AllMatricesBuffer", allMatricesBuffer);
          cullingComputeShader.SetBuffer(kernel, "_VisibleIDsBuffer", visibleIDsBuffer);
          ...
      

      至此UpdateBuffers方法就修改完了,ComputeShader中还差最后一样东西——视锥的六个面,通常情况下相机的位置是会频繁发生变化的,所以在Update中来获取它们。

      这里直接用Unity提供的API获取:

      void Update()
      {
          // 更新Buffer
          UpdateBuffers();
          // 方向键改变绘制数量
          ...
          // 视锥剔除
          GeometryUtility.CalculateFrustumPlanes(Camera.main,cameraFrustumPlanes);
          for (int i = 0; i < cameraFrustumPlanes.Length; i++)
          {
              var normal = -cameraFrustumPlanes[i].normal;
              frustumPlanes[i] = new Vector4(normal.x, normal.y, normal.z, -cameraFrustumPlanes[i].distance);
          }
          ...
      

      GeometryUtility.CalculateFrustumPlanes可以计算指定相机的视锥面,需要注意的是,这样获取到的六个面是正面朝内的,而ComputeShader中是按照正面朝外计算的。要么修改ComputeShader,要么将六个面反向一下,这里选择反向,即改变法线与距离的符号,最终传入ComputeShader的是Vector4类型,放在frustumPlanes中。

      将视锥平面传入ComputeShader,可以调用计算了:

      void Update()
      {
          // 更新Buffer
          ...
          // 方向键改变绘制数量
          ...
          // 视锥剔除
          ...
          
          visibleIDsBuffer.SetCounterValue(0);
          cullingComputeShader.SetVectorArray("_FrustumPlanes", frustumPlanes);
          cullingComputeShader.Dispatch(kernel, Mathf.CeilToInt(instanceCount / 640f), 1, 1);
          ComputeBuffer.CopyCount(visibleIDsBuffer, argsBuffer, sizeof(uint));
          // 渲染
          ...
      }
      

      有三点需要注意:

      1. 调用ComputeShader前,必须要使用visibleIDsBuffer.SetCounterValue(0)将计数器置为0,因为在ComputeShader中会不断将可见物体的实例ID追加到visibleIDsBuffer,如果不置为0,那电脑很可能就爆炸了(惨痛的教训)。
      2. 必须要使用ComputeBuffer.CopyCount将visibleIDsBuffer的长度写入到argsBuffer里,因为最终渲染用的还是argsBuffer。
      3. 如果ComputeShader同时在多个地方使用,比如渲染花花草草,那么需要用Instantiate方法将其分别实例化,每种物体的渲染各用一个实例。

      至此C#部分修改完毕,最后修改渲染用的Shader。

      Shader

      剔除相关的修改已经完成,Shader中没有多少要改的,只需要将_AllMatricesBuffer和_VisibleIDsBuffer加上并使用就行。复制上一篇的Shader,改名为InstancedCulling.shader,修改HLSLINCLUDE中的Buffer变量:

      HLSLINCLUDE
      ...
      CBUFFER_START(UnityPerMaterial)
      ...
      // 修改:所有物体的复合变换矩阵
      StructuredBuffer<float4x4> _AllMatricesBuffer;
      // 新增:可见物体实例ID
      StructuredBuffer<uint> _VisibleIDsBuffer;
      CBUFFER_END
      ...
      ENDHLSL
      

      在顶点函数中使用:

      Varyings Vertex(Attributes IN, uint instanceID : SV_InstanceID)
      {
          Varyings OUT;
          // 修改:顶点坐标转换到世界空间
          #if SHADER_TARGET >= 45
          // float4 data = positionBuffer[instanceID];
          float4x4 data = _AllMatricesBuffer[_VisibleIDsBuffer[instanceID]];
          #else
          float4x4 data = 0;
          #endif
          // float3 positionWS = mul(mul(unity_ObjectToWorld, data), IN.positionOS).xyz;
          float3 positionWS = mul(data, IN.positionOS).xyz;
          OUT.positionWS = positionWS;
          OUT.positionCS = mul(unity_MatrixVP, float4(positionWS, 1.0));
          OUT.uv = TRANSFORM_TEX(IN.texcoord, _BaseMap);
      
          // 修改:法线转换到世界空间
          // float3 normalWS = TransformObjectToWorldNormal(normalize(mul(data, IN.normalOS)));
          float3 normalWS = normalize(mul(data, float4(IN.normalOS, 0))).xyz;
          float fogFactor = ComputeFogFactor(OUT.positionCS.z);
          OUT.normalWSAndFogFactor = float4(normalWS, fogFactor);
          return OUT;
      }
      

      可以对比注释掉的部分,我们现在通过_VisibleIDsBuffer[instanceID]拿到剔除后的实例ID,再通过实例ID在_AllMatricesBuffer获取到物体的复合变换矩阵,用于将顶点坐标从模型空间转换到世界空间。

      由于加入了物体旋转,世界空间下的法线也需要用这个矩阵转换,需要注意的是这里的写法仅适用于统一缩放,即x、y、z的缩放都相同的情况,如果是非统一缩放,使用变换矩阵*法线会得到错误的结果:

      (图片来源:《Unity Shader入门精要》)

      这种情况下需要求得变换矩阵的逆矩阵,使用法线*逆矩阵得到正确结果,代码网上有很多,这里就不贴了。

      两个Pass都这样修改一下,运行效果:

      另一种剔除方法

      来实现开头说的另一种剔除方法,通过对裁剪空间下的包围盒顶点齐次坐标进行判断来剔除。在ComputeShader中,只要将包围盒的八个点坐标转换到裁剪空间下再进行判断就行了,为了将坐标转换到裁剪空间,我们需要向ComputeShader传入当前的VP(观察投影)矩阵。

      FrustumCulling.compute

      #pragma kernel CSMain
      
      // float4 _FrustumPlanes[6];
      float4x4 _MatrixVP; // 修改:观察投影矩阵
      float3 _BoundMin;   // 物体包围盒最小点
      float3 _BoundMax;   // 物体包围盒最大点
      StructuredBuffer<float4x4> _AllMatricesBuffer;   // 所有物体的复合变换矩阵
      AppendStructuredBuffer<uint> _VisibleIDsBuffer;  // 可见物体实例ID
      
      bool IsInClipSpace(float4 coord)
      {
          return -coord.w <= coord.x && coord.x <= coord.w
              && -coord.w <= coord.y && coord.y <= coord.w
              && -coord.w <= coord.z && coord.z <= coord.w;
      }
      
      [numthreads(640, 1, 1)]
      void CSMain(uint3 id : SV_DispatchThreadID)
      {
          float4x4 mvp = mul(_MatrixVP, _AllMatricesBuffer[id.x]);
          float4 boundPoints[8];
          boundPoints[0] = mul(mvp, float4(_BoundMin, 1));
          boundPoints[1] = mul(mvp, float4(_BoundMax, 1));
          boundPoints[2] = mul(mvp, float4(_BoundMax.x, _BoundMax.y, _BoundMin.z, 1));
          boundPoints[3] = mul(mvp, float4(_BoundMax.x, _BoundMin.y, _BoundMax.z, 1));
          boundPoints[4] = mul(mvp, float4(_BoundMax.x, _BoundMin.y, _BoundMin.z, 1));
          boundPoints[5] = mul(mvp, float4(_BoundMin.x, _BoundMax.y, _BoundMax.z, 1));
          boundPoints[6] = mul(mvp, float4(_BoundMin.x, _BoundMax.y, _BoundMin.z, 1));
          boundPoints[7] = mul(mvp, float4(_BoundMin.x, _BoundMin.y, _BoundMax.z, 1));
          
          bool isIn = false;
          for (int i = 0; i < 8; i++)
          {
              if (IsInClipSpace(boundPoints[i]))
              {
                  isIn = true;
                  break;
              }
          }
      
          if (isIn)
              _VisibleIDsBuffer.Append(id.x);
      }
      

      得到MVP矩阵,将八个顶点转换到裁剪空间,如果有一个顶点在裁剪空间内,则视物体为可见。IsInClipSpace中是OpenGL的判断方式(-w~w),如果是DirectX则要判断是否在0~w之间。

      在C#中获取VP矩阵并传入ComputeShader即可:

      FrustumCullingRenderer.cs

      void Update()
      {
          // 更新Buffer
          ...
          // 方向键改变绘制数量
          ...
          // 视锥剔除
          // 修改:计算观察投影矩阵
          var matrixVP = Camera.main.projectionMatrix * Camera.main.worldToCameraMatrix;
          visibleIDsBuffer.SetCounterValue(0);
          // cullingComputeShader.SetVectorArray("_FrustumPlanes", frustumPlanes);
          cullingComputeShader.SetMatrix("_MatrixVP", matrixVP);
          cullingComputeShader.Dispatch(kernel, Mathf.CeilToInt(instanceCount / 640f), 1, 1);
          ComputeBuffer.CopyCount(visibleIDsBuffer, argsBuffer, sizeof(uint));
          // 渲染
          Bounds renderBounds = new Bounds(Vector3.zero, new Vector3(200.0f, 200.0f, 200.0f));
          Graphics.DrawMeshInstancedIndirect(instanceMesh, subMeshIndex, instanceMaterial, renderBounds, argsBuffer);
      }
      

      这里让相机的两个矩阵相乘得出VP矩阵,更规范一点的做法应该是这样:

      var matrixVP = GL.GetGPUProjectionMatrix(Camera.main.projectionMatrix, false) * Camera.main.worldToCameraMatrix;
      

      这么做的原因在官方文档中有提到:

      Note that projection matrix passed to shaders can be modified depending on platform and other state. If you need to calculate projection matrix for shader use from camera's projection, use GL.GetGPUProjectionMatrix.

      In Unity, projection matrices follow OpenGL convention. However on some platforms they have to be transformed a bit to match the native API requirements. Use this function to calculate how the final projection matrix will be like. The value will match what comes as UNITY_MATRIX_P matrix in a shader.

      大概是说Unity中投影矩阵遵循OpenGL传统,但实际的运行平台不一定是OpenGL,要获得与平台匹配的投影矩阵,需要使用GL.GetGPUProjectionMatrix。
      我这里选择不用它,因为用了的话,ComputeShader中就要针对各API情况分别判断了,个人认为这一步不是很有必要,但没实际测试过,如有错误欢迎指出。

      得到的效果与第一种方法一致:

      Demo代码地址

      至此视锥剔除基本完成了,当然有些细节还没有优化到位,下一篇先来做遮挡剔除。不过最近学习(摸鱼)的时间越来越少,也不知道要到猴年马月了。

      发布在 游戏开发
      Pamisu
      Pamisu
    • [Unity]大批量物体渲染学习笔记(一)

      最近摸鱼的时候打算对demo里的大面积草地做一些优化,顺便记一些笔记备忘。标题的大量物体特指场景中的重复物体,比如大片的草地、树林等等,它们数量极多,如果直接用GameObject的形式实现,电脑多半是要爆炸的。关于大量物体渲染网上已经有很多文章介绍,这里仅记录在通用渲染管线(URP)下的学习与实现过程,算是比较基础的部分,如有错误欢迎指正。

      怎么做

      方案有很多,先来试试常用的GPU Instancing,用到的核心的API为Graphics.DrawMeshInstancedIndirect,绘制部分基本围绕它展开。

      它还有个好兄弟Graphics.DrawMeshInstanced,它们都能批量绘制网格,区别在于,好兄弟需要在每一帧将数据从CPU提交至GPU,单个批次有着1023的实例数量限制;而DrawMeshInstancedIndirect可以在GPU侧缓存数据,并且单个批次没有数量限制。

      在使用这个API时,Unity不会帮我们做视锥剔除与遮挡剔除,如果用它绘制十万颗草,不论草是否在视野内,都会被一视同仁统统绘制,也就是说剔除工作需要我们自己完成。

      官方示例

      先按照官方示例写个Hello world,在文档中,官方十分贴心地给出了绘制部分的代码,复制粘贴就能运行的那种。运行效果长这样,在场景中一口气绘制了十万个方块:

      这里要在URP下实现,C#部分基本不需要改动,Shader部分需要重新写,顺便把阴影投射也加上,最终效果:

      大致流程:

      1. 准备好物体的网格、材质,以及渲染用到的数据,比如十万份方块的位置与大小。
      2. 将数据设置到GPU缓冲区。
      3. 调用DrawMeshInstancedIndirect渲染。

      C#部分

      先看C#部分的一些变量:

      ExampleClass.cs

      public int instanceCount = 100000;
      public Mesh instanceMesh;
      public Material instanceMaterial;
      public int subMeshIndex = 0;
      

      分别是要绘制的物体数量、网格与材质,使用时在编辑器里赋值,像这样:

      再看其他的一些变量:

      private int cachedInstanceCount = -1;
      private int cachedSubMeshIndex = -1;
      private ComputeBuffer positionBuffer;
      private ComputeBuffer argsBuffer;
      private uint[] args = new uint[5] { 0, 0, 0, 0, 0 };
      

      这里定义了两个ComputeBuffer,利用它们可以将数据传至GPU侧,positionBuffer用来存放所有物体的位置,argsBuffer则是DrawMeshInstancedIndirect绘制需要用到的参数,各项参数通过args变量存放。

      Update中的逻辑很简单,必要时更新ComputeBuffer,然后渲染:

      void Update()
      {
          // 更新Buffer
          UpdateBuffers();
          // 方向键改变绘制数量
          ...
          // 渲染
          Bounds renderBounds = new Bounds(Vector3.zero, 
              new Vector3(100.0f, 100.0f, 100.0f));
          Graphics.DrawMeshInstancedIndirect(instanceMesh, subMeshIndex, 
              instanceMaterial, renderBounds, argsBuffer);
      }
      

      这一堆参数看得眼花,argsBuffer怎么赋值我们也还不清楚,所以先来看看UpdateBuffers中是怎样更新这些ComputeBuffer的:

      void UpdateBuffers()
      {
          // 不需要更新时返回
          if ((cachedInstanceCount == instanceCount 
              || cachedSubMeshIndex != subMeshIndex)
              && argsBuffer != null)
              return;
          // 规范subMeshIndex
          if (instanceMesh != null)
              subMeshIndex = Mathf.Clamp(subMeshIndex, 0, 
                  instanceMesh.subMeshCount - 1);
          ...
      

      没啥好说的,接下来是对positionBuffer的初始化:

          ...
          // 初始化位置Buffer
          if (positionBuffer != null)
              positionBuffer.Release();
          positionBuffer = new ComputeBuffer(instanceCount, sizeof(float) * 4);
          Vector4[] positions = new Vector4[instanceCount];
          for (int i = 0; i < instanceCount; i++)
          {
              float angle = Random.Range(0.0f, Mathf.PI * 2.0f);
              float distance = Random.Range(10.0f, 90.0f);
              float height = Random.Range(-5.0f, 5.0f);
              float size = Random.Range(0.05f, 1f);
              positions[i] = new Vector4(Mathf.Sin(angle) * distance, height, 
                  Mathf.Cos(angle) * distance, size);
          }
          positionBuffer.SetData(positions);
          instanceMaterial.SetBuffer("positionBuffer", positionBuffer);
      

      可以看到ComputerBuffer的构造方法中需要指定数量与单个数据占用空间大小,这里物体的位置为Vector4类型,在Shader中对应float4,xyz分量存放坐标,w分量存放大小。之后为每个物体随机设置位置与大小,然后通过ComputerBuffer的SetData方法设置数据,最后设置到材质中,那么大致可以这样认为,经过这一步,每个物体的位置数据已经向GPU侧提交了。

      然后是对argsBuffer的初始化:

          // Indirect args
          if (argsBuffer != null)
              argsBuffer.Release();
          argsBuffer = new ComputeBuffer(1, args.Length * sizeof(uint),
              ComputeBufferType.IndirectArguments);
          if (instanceMesh != null)
          {
              args[0] = (uint)instanceMesh.GetIndexCount(subMeshIndex);
              args[1] = (uint)instanceCount;
              args[2] = (uint)instanceMesh.GetIndexStart(subMeshIndex);
              args[3] = (uint)instanceMesh.GetBaseVertex(subMeshIndex);
          }
          else
          {
              args[0] = args[1] = args[2] = args[3] = 0;
          }
          argsBuffer.SetData(args);
      
          cachedInstanceCount = instanceCount;
          cachedSubMeshIndex = subMeshIndex;
      }
      

      这个也没啥好说的,总之挨个赋对应的值就完事了(敷衍),通过设置instanceCount,argsBuffer将决定有多少实例会被渲染。

      回过头来看Update,基本上可以理解DrawMeshInstancedIndirect各个参数的意义了:

      void Update()
      {
          ...
          // 渲染
          Bounds renderBounds = new Bounds(Vector3.zero, 
              new Vector3(100.0f, 100.0f, 100.0f));
          Graphics.DrawMeshInstancedIndirect(instanceMesh, subMeshIndex, 
              instanceMaterial, renderBounds, argsBuffer);
      }
      

      我们需要传入绘制的网格(instanceMesh)、指定的子网格(subMeshIndex)、什么材质(instanceMaterial)、渲染的范围(renderBounds),以及argsBuffer。

      可以发现并不需要传positionBuffer,因为它早在上一步就被设置到材质中了,只要物体的数量或者位置没有发生改变,就不需要再变动positionBuffer。这样Update中基本不存在耗时操作,虽然要绘制的实例数量很多,但只有在数据有变动时才要做循环。

      Shader部分

      在C#部分,包含每个物体位置的positionBuffer已经设置到了材质中,那么在Shader中我们主要关心的是如何获取这些位置数据,官方给出的Shader中,可以看到positionBuffer的声明:

      #if SHADER_TARGET >= 45
          StructuredBuffer<float4> positionBuffer;
      #endif
      

      StructuredBuffer在Shader中是只读的,它将接收从C#传递过来的位置数据,需要注意这里的SHADER_TARGET >= 45,说明这个功能最低支持的编译目标级别为4.5,即OpenGL ES 3.1。

      关于Shader的编译目标级别可以参考官方文档。

      这篇文章介绍了DrawMeshInstancedIndirect在真机上的兼容情况。

      在顶点函数中使用positionBuffer:

      v2f vert (appdata_full v, uint instanceID : SV_InstanceID)
      {
      #if SHADER_TARGET >= 45
          float4 data = positionBuffer[instanceID];
      #else
          float4 data = 0;
      #endif
          float rotation = data.w * data.w * _Time.x * 0.5f;
          rotate2D(data.xz, rotation);
          float3 localPosition = v.vertex.xyz * data.w;
          float3 worldPosition = data.xyz + localPosition;
          ...
      

      通过SV_InstanceID语义获取当前的实例id,使用instanceID作为下标,就能从positionBuffer中获取到实例的位置数据了。这里的rotate2D函数让物体平行于xz面绕y轴旋转,旋转速度由物体大小决定;由于不存在其他变换,世界空间下的顶点坐标就等于模型空间下的坐标加上传入的坐标。

      了解Shader中都要做些什么后,可以依葫芦画瓢来写URP下的Shader了,这里也像官方示例中那样,实现物体公转、基础光照、阴影接收与自带雾效,再加上阴影投射。

      新建一个Shader:

      InstancedShader.shader

      Shader "Custom/URP/Instanced Shader"
      {
          Properties
          {
              ①...
          }
          SubShader
          {
              Tags
              {
                  "RenderType" = "Opaque"
                  "RenderPipeline" = "UniversalRenderPipeline"
              }
      
              HLSLINCLUDE
              ②...
              ENDHLSL
      
              Pass
              {
                  Tags
                  {
                      "LightMode" = "UniversalForward"
                  }
      
                  HLSLPROGRAM
                  ③...
                  ENDHLSL
              }
      
              Pass
              {
                  Tags
                  {
                      "LightMode" = "ShadowCaster"
                  }
              
                  HLSLPROGRAM
                  ④...
                  ENDHLSL
              }
          }
      }
      

      定义需要用到的属性,纹理、颜色、高光反射系数与高光反射颜色:

      ①

      Properties
      {
          [MainTexture] _BaseMap("Albedo", 2D) = "white" {}
          [MainColor] _BaseColor("Color", Color) = (1,1,1,1)
          _Gloss("Gloss", Range(8, 256)) = 16
          _SpecularColor("Specular Color", Color) = (1,1,1,1)
      }
      

      HLSLINCLUDE中放一些通用的代码,比如包含URP的一些库,通用的属性与函数等:

      ②

      HLSLINCLUDE
      #include "Packages/com.unity.render-pipelines.universal/ShaderLibraryCore.hlsl"
      #include "Packages/com.unity.render-pipelines.universal/ShaderLibraryLighting.hlsl"
      
      CBUFFER_START(UnityPerMaterial)
      float4 _BaseMap_ST;
      half4 _BaseColor;
      half _Gloss;
      half4 _SpecularColor;
      #if SHADER_TARGET >= 45
      StructuredBuffer<float4> positionBuffer;
      #endif
      CBUFFER_END
      
      TEXTURE2D(_BaseMap);
      SAMPLER(sampler_BaseMap);
      
      void rotate2D(inout float2 v, float size)
      {
          float s, c;
          float rotation = size * size * _Time.x * 1.5f;
          sincos(rotation, s, c);
          v = float2(v.x * c - v.y * s, v.x * s + v.y * c);
      }
      ENDHLSL
      

      positionBuffer需要和其他属性一样放在cbuffer块中。

      在UniversalForward Pass中计算光照、物体公转、雾效等等,需要加上相关的预处理指令:

      ③

      HLSLPROGRAM
      #pragma target 4.5
      
      #pragma multi_compile _ _MAIN_LIGHT_SHADOWS
      #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
      #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX_ADDITIONAL_LIGHTS
      #pragma multi_compile _ _ADDITIONAL_LIGHT_SHADOWS
      #pragma multi_compile _ _SHADOWS_SOFT
      #pragma multi_compile_fog
      ...
      

      虽然加了额外光源关键字,但这里只计算了主光源。

      采用逐像素光照,雾效因子随便找个空位放一下,这里与法线放一起:

      ③

      ...
      #pragma vertex Vertex
      #pragma fragment Fragment
      
      struct Attributes
      {
          float4 positionOS : POSITION;
          float3 normalOS : NORMAL;
          float2 texcoord : TEXCOORD0;
      };
      
      struct Varyings
      {
          float4 positionCS : SV_POSITION;
          float2 uv : TEXCOORD0;
          float4 normalWSAndFogFactor : TEXCOORD1;
          float3 positionWS : TEXCOORD2;
      };
      ...
      

      顶点函数:

      ③

      ...
      Varyings Vertex(Attributes IN, uint instanceID : SV_InstanceID)
      {
          Varyings OUT;
      
          // 旋转与坐标变换
          #if SHADER_TARGET >= 45
          float4 data = positionBuffer[instanceID];
          #else
          float4 data = 0;
          #endif
          rotate2D(data.xz, data.w);
          float3 positionWS = data.xyz + IN.positionOS.xyz * data.w;
          OUT.positionWS = positionWS;
      
          OUT.positionCS = mul(unity_MatrixVP, float4(positionWS, 1.0));
          OUT.uv = TRANSFORM_TEX(IN.texcoord, _BaseMap);
          // 法线与雾效因子
          float3 normalWS = TransformObjectToWorldNormal(IN.normalOS);
          float fogFactor = ComputeFogFactor(OUT.positionCS.z);
          OUT.normalWSAndFogFactor = float4(normalWS, fogFactor);
          return OUT;
      }
      ...
      

      与示例中一样,根据传入的位置数据,计算出世界空间下的顶点坐标与裁剪空间下的顶点坐标。雾效因子使用ComputeFogFactor函数计算,与世界空间下的法线放在同一个变量中。

      片元函数:

      ③

      ...
      half4 Fragment(Varyings IN) : SV_Target
      {
          half4 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) 
              * _BaseColor;
      
          // 获取主光源
          Light light = GetMainLight(
                  TransformWorldToShadowCoord(IN.positionWS));
          half3 lighting = light.color * light.distanceAttenuation 
              * light.shadowAttenuation;
      
          // 计算光照
          float3 normalWS = IN.normalWSAndFogFactor.xyz;
          half3 diffuse = saturate(dot(normalWS, light.direction)) * lighting;
          float3 v = normalize(_WorldSpaceCameraPos - IN.positionWS);
          float3 h = normalize(v + light.direction);
          half3 specular = pow(saturate(dot(normalWS, h)), _Gloss) 
              * _SpecularColor.rgb * lighting;
          half3 ambient = SampleSH(normalWS);
      
          half4 color = half4(albedo.rgb * diffuse + specular + ambient, 1.0);
          float fogFactor = IN.normalWSAndFogFactor.w;
          color.rgb = MixFog(color.rgb, fogFactor);
          return color;
      }
      ENDHLSL
      

      获取带阴影衰减的主光源、计算漫反射、高光、环境光,最后混合雾效。

      至于ShadowCaster Pass就偷懒直接照抄ShadowCasterPass.hlsl中的代码,加上位置变换:

      ④

      HLSLPROGRAM
      #pragma target 4.5
      #pragma vertex Vertex
      #pragma fragment Fragment
      
      struct Attributes
      {
          float4 positionOS : POSITION;
          float3 normalOS : NORMAL;
          float2 texcoord : TEXCOORD0;
      };
      
      struct Varyings
      {
          float2 uv : TEXCOORD0;
          float4 positionCS : SV_POSITION;
      };
      
      float3 _LightDirection;
      
      Varyings Vertex(Attributes IN, uint instanceID : SV_InstanceID)
      {
          Varyings OUT;
          #if SHADER_TARGET >= 45
          float4 data = positionBuffer[instanceID];
          #else
          float4 data = 0;
          #endif
          rotate2D(data.xz, data.w);
          float3 positionWS = data.xyz + IN.positionOS.xyz * data.w;
          float3 normalWS = TransformObjectToWorldNormal(IN.normalOS);
          float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS,  
              normalWS, _LightDirection));
          #if UNITY_REVERSED_Z
          positionCS.z = min(positionCS.z, 
              positionCS.w * UNITY_NEAR_CLIP_VALUE);
          #else
          positionCS.z = max(positionCS.z, 
              positionCS.w * UNITY_NEAR_CLIP_VALUE);
          #endif
          OUT.positionCS = positionCS;
          OUT.uv = TRANSFORM_TEX(IN.texcoord, _BaseMap);
          return OUT;
      }
      
      half4 Fragment(Varyings IN) : SV_TARGET
      {
          return 0;
      }
      ENDHLSL
      

      由于不需要Alpha裁剪,片元函数中直接省略掉了这一步。

      运行结果与官方示例差不多,有了阴影后看起更加自然:


      如果是ShaderGraph连连看玩家,可以参考这个Gist:DrawMeshInstancedIndirect with ShaderGraph and URP,小编亲自试了一下,发现效果还不错,敏感肌也能用:

      到现在相当于把官方示例抄了一遍,仅实现了物体位置数据的传递,没有自身旋转和真正意义上的缩放,实际的草地或树林肯定没有这么规整;另外也还没有做剔除,视野内外的物体都会被渲染,白白消耗了性能。

      下一篇来实现物体的旋转、缩放,并用ComputeShader做视锥剔除。

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