<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[游戏开发]]></title><description><![CDATA[你说的这个东西它是不是不改，你得立字据]]></description><link>http://designhub.top/category/8</link><generator>RSS for Node</generator><lastBuildDate>Tue, 08 Sep 2026 05:54:10 GMT</lastBuildDate><atom:link href="http://designhub.top/category/8.rss" rel="self" type="application/rss+xml"/><pubDate>Fri, 30 May 2025 02:46:14 GMT</pubDate><ttl>60</ttl><item><title><![CDATA[Godot 2D光照 - 物体自身阴影遮挡设置]]></title><description><![CDATA[<p dir="auto">记录一些在Godot中使用2D光照时遇到的问题和解决方法，本篇笔记包含以下内容：</p>
<ul>
<li>控制物体是否受自身阴影影响的不同设置方法</li>
<li>理解光照相关的图层和各种Mask的作用</li>
</ul>
<p dir="auto">在Unity中，用来投射阴影的<a href="https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@14.0/manual/2DShadows.html" rel="nofollow ugc">Shadow Caster 2D组件</a>有一个<code>Self Shadows</code>属性用来控制阴影是否影响物体自身。</p>
<p dir="auto"><img src="/assets/uploads/files/1748572975839-pasted-image-20250504183337.png" alt="Pasted image 20250504183337.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">Godot的光照遮挡器<code>LightOccluder2D</code>节点和<code>TileSet</code>里都没有类似的选项，默认都是影响自身。例如下图中方块物体和黑色墙体都被自身投射出的阴影遮挡了。</p>
<p dir="auto"><img src="/assets/uploads/files/1748572985466-pasted-image-20250504220112.png" alt="Pasted image 20250504220112.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">关于这个问题<a href="https://docs.godotengine.org/en/stable/tutorials/2d/2d_lights_and_shadows.html#occluder-draw-order" rel="nofollow ugc">官方文档</a>有这样一段说明：</p>
<blockquote>
<p dir="auto"><strong>LightOccluder2D 遵循常规的 2D 绘图顺序</strong>。这对于 2D 灯光而言非常重要，因为可以用来控制遮挡器是否应该遮挡精灵本身。</p>
<p dir="auto">如果 LightOccluder2D 节点是精灵的<em>同级节点</em>，并且场景树中的遮挡器被放在精灵的下方，会遮挡住精灵本身。</p>
<p dir="auto">如果 LightOccluder2D 节点是一个精灵的子节点，如果在 LightOccluder2D 节点中禁用了 <strong>Show Behind Parent</strong>（显示在父级之后）这个遮挡器将遮挡住精灵本身（该选项默认禁用）。</p>
</blockquote>
<p dir="auto">真的有用吗？以下是在4.4版本中测试的情况，分别是遮挡器位于精灵下方、上方、作为精灵的子节点并启用<code>Show Behind Parent</code>。</p>
<p dir="auto"><img src="/assets/uploads/files/1748572993958-pasted-image-20250520085233.png" alt="Pasted image 20250520085233.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">不难发现完全没有效果，就算有用这个方法也没法用于<code>TileSet</code>。</p>
<p dir="auto">所以这里总结一些比较普遍的解决方法，顺便介绍2D光照中各种Mask的作用与对应关系。前两种方法来自Catlike Coding的<a href="https://catlikecoding.com/godot/true-top-down-2d/4-light-and-shadow/" rel="nofollow ugc">教程</a>，不同的方法各有优缺点，在效果细节上也有差别。</p>
<h1>方法一 设置Cull Mode</h1>
<p dir="auto">可以实现“物体不被自身阴影遮挡，可被其他物体的阴影遮挡”的效果。</p>
<p dir="auto">将<code>LightOccluder2D</code>节点的<code>Cull Mode</code>属性值设置为<code>ClockWise</code>或<code>CounterClockWise</code>，取决于顶点顺序，可以两个都试一下看哪个有效果。规律是如果顶点按逆时针排列则设置<code>ClockWise</code>，反之<code>CounterClockWise</code>，正好跟顶点顺序反过来。这个设置控制了遮挡形状是从内部还是外部投射阴影。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573003603-pasted-image-20250504180335.png" alt="Pasted image 20250504180335.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">如果编辑器中Sprite被遮挡，可以调整<code>Sprite2D</code>和<code>LightOccluder2D</code>在场景树中的顺序，实际运行是没有区别的。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573009448-pasted-image-20250504175706.png" alt="Pasted image 20250504175706.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">这样就做到了物体不被自身阴影遮挡，会被其他物体和墙体的阴影遮挡。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573015330-pasted-image-20250504180238.png" alt="Pasted image 20250504180238.png" class=" img-responsive img-markdown" /></p>
<p dir="auto"><code>TileSet</code>同样可以这样设置，选择<code>TileMapLayer</code>节点并打开编辑器底部的<code>TileSet</code>面板，按图中步骤操作即可。可以发现<code>TileSet</code>使用了类似的逻辑实现。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573024789-pasted-image-20250504181841.png" alt="Pasted image 20250504181841.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">设置后墙体会变得和物体一样，不受自身投射出阴影的影响，会被其他墙体和物体的阴影遮挡。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573030556-pasted-image-20250504182412.png" alt="Pasted image 20250504182412.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">但效果并不理想，视觉上墙体应该是一个整体，而实际上每一块墙体都是单独的遮挡器，互相之间会遮挡，导致看起来很奇怪，而且数量较多的情况下对性能也会有影响。</p>
<p dir="auto">教程里的目标效果是墙体不需要被照亮，即默认被自身阴影遮挡，但如果墙体需要照亮，这样设置满足不了要求，必须将墙体的遮挡器合并，类似这样：</p>
<p dir="auto"><img src="/assets/uploads/files/1748573035075-pasted-image-20250520113037.png" alt="Pasted image 20250520113037.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">很可惜目前Godot没有提供直接合并遮挡器的功能，上面是手动创建的，不适合大型复杂场景或者需要动态修改的情况，在<a href="https://github.com/godotengine/godot-proposals/discussions/7934" rel="nofollow ugc">这个提案</a>里有提到可以使用2D导航/碰撞烘焙来实现，之后尝试如果可行再来补充。</p>
<h1>方法二 使用两个光源</h1>
<p dir="auto">可以实现“物体不被自身阴影遮挡，不被其他物体的阴影遮挡，会被墙体的阴影遮挡，墙体被自身阴影遮挡”的效果。</p>
<p dir="auto">回退方法一中的所有修改，回到初始状态。把场景中的光源复制一份。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573040967-pasted-image-20250504221607.png" alt="Pasted image 20250504221607.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">将光源2的<code>Range Item Cull Mask</code>属性改为2，同时把<code>Shadow Item Cull Mask</code>属性也改为2。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573045903-pasted-image-20250504222054.png" alt="Pasted image 20250504222054.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">这一步意味着这个光源专门用来照亮<code>Light Mask</code>属性为2的物体，同时只会对<code>Occluder Light Mask</code>属性为2的物体投射出阴影。</p>
<p dir="auto">这一堆Mask看着有些头痛，总之先按步骤操作，之后会详细介绍每个Mask的作用和它们之间的关系，为什么这样设置就能有效果。</p>
<p dir="auto">然后将物体Sprite的<code>Light Mask</code>属性设置为2，<code>LightOccluder2D</code>节点保持原样不需要调整。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573051748-pasted-image-20250504220330.png" alt="Pasted image 20250504220330.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">这一步让物体的Sprite将只会受到光源2的影响，呈现出的效果是物体不再被自身阴影遮挡，也不会被其他物体的阴影遮挡。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573056217-pasted-image-20250504223727.png" alt="Pasted image 20250504223727.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">可以发现此时物体也不会被墙体投射出的阴影遮挡，如果需要则再做一项设置，在墙体<code>TileMapLayer</code>使用的<code>TileSet</code>中，将<code>Rendering</code> -&gt; <code>Occlusion Layers</code>下墙体Tile使用的遮挡图层的<code>Light Mask</code>改为1与2同时点亮。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573079060-pasted-image-20250504224117.png" alt="Pasted image 20250504224117.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">这一步让墙体在接受到光源1、光源2的光照时都投射出阴影，通过光源2投射出的阴影将会覆盖在物体上。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573084260-pasted-image-20250504225120.png" alt="Pasted image 20250504225120.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">这种方法缺点很明显，需要双倍数量的光源，对性能有影响，适合低分辨率的像素游戏和光源较少的情况。</p>
<p dir="auto">如果只需要控制物体阴影自身遮挡，对物体和墙体之间的阴影遮挡没有要求，又不想使用两个光源，这种情况可以通过设置Mask来实现。</p>
<h1>理解各种Mask的作用</h1>
<p dir="auto">官方文档中有时将Mask翻译为“遮罩”，有时不翻译。个人觉得“掩码”更贴切一些，类似计算机网络里的“子网掩码（Subnet Mask）”，都是用来做位运算。</p>
<p dir="auto">2D节点和UI控件节点都继承自<code>CanvasItem</code>节点，在<code>CanvasItem</code>中有<code>Light Mask</code>和<code>Visibility Layer</code>两个属性。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573089778-pasted-image-20250513104628.png" alt="Pasted image 20250513104628.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">它们对应项目设置里2D Render的图层，共有20个，对应Mask中的20位。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573101770-pasted-image-20250513104945.png" alt="Pasted image 20250513104945.png" class=" img-responsive img-markdown" /></p>
<h2>是否渲染：Visibility Layer 与 Canvas Cull Mask</h2>
<p dir="auto"><code>Visibility Layer</code>决定了物体位于哪个/哪些渲染图层，在视口<code>Viewport</code>中有一个与之对应的属性<code>Canvas Cull Mask</code>，用来控制这个视口渲染哪些图层，默认全部点亮，即渲染所有图层。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573107853-pasted-image-20250513105709.png" alt="Pasted image 20250513105709.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">在运行时我们的场景会被放在一个名为root的窗口<code>Window</code>节点下，而<code>Window</code>正是继承自<code>Viewport</code>，也就是说默认有一个渲染所有图层的视口。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573113378-pasted-image-20250513110220.png" alt="Pasted image 20250513110220.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">例如物体A的<code>Visibility Layer</code>点亮图层1，物体B的<code>Visibility Layer</code>点亮图层2、3、5，视口的<code>Canvas Cull Mask</code>点亮图层3、6。</p>
<p dir="auto">用视口的<code>Canvas Cull Mask</code>值分别与物体的<code>Visibility Layer</code>做与运算，结果不为0时表示需要渲染：</p>
<p dir="auto">物体A是否渲染 = 0000 0000 0000 0000 0001 &amp; 0000 0000 0000 0010 0100 = 0000 0000 0000 0000 0000 = 不渲染</p>
<p dir="auto">物体B是否渲染 = 0000 0000 0000 0001 0110 &amp; 0000 0000 0000 0010 0100 = 0000 0000 0000 0000 0100 = 渲染</p>
<blockquote>
<p dir="auto">注意物体是否渲染还会受到其父物体的影响，如果父物体不渲染，子物体也不会渲染，但可以勾选<code>CanvasItem</code>的<code>Top Level</code>属性来取消这个限制。</p>
</blockquote>
<blockquote>
<p dir="auto">Godot原生支持多窗口，有基于这个特性开发独特玩法的游戏，例如《Windowkill》，这点是Unity做不到的。</p>
</blockquote>
<h2>是否计算光照：Light Mask 与 Range Item Cull Mask</h2>
<p dir="auto">类似地，<code>CanvasItem</code>中的<code>Light Mask</code>属性用来设置物体属于哪个/哪些光照图层，在2D光源<code>Light2D</code>中的<code>Range</code>组下有一个<code>Item Cull Mask</code>与之对应，用来控制这个光源在哪些图层上计算光照。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573119745-pasted-image-20250513154420.png" alt="Pasted image 20250513154420.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">例如物体A、B都在光源范围内，物体A的<code>Light Mask</code>点亮图层1，物体B的<code>Light Mask</code>点亮图层2、3、5，光源的<code>Range Item Cull Mask</code>点亮图层3、6，那么只有物体B会被照亮。</p>
<h2>是否计算阴影：Occluder Light Mask 与 Light Mask 与 Shadow Item Cull Mask</h2>
<p dir="auto">阴影有一些不同，它有三个属性参与控制，其实也很好理解，因为有投射出阴影的物体和接收阴影的物体，加上光源就是三个。</p>
<p dir="auto">对于投射阴影的物体，<code>LightOccluder2D</code>的<code>Occluder Light Mask</code>属性用来设置遮挡器属于哪个/哪些阴影图层；TileSet则是<code>Rendering</code> -&gt; <code>Occlusion Layers</code>里图层项的<code>Light Mask</code>属性。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573126168-pasted-image-20250513155829.png" alt="Pasted image 20250513155829.png" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="/assets/uploads/files/1748573131099-pasted-image-20250513160009.png" alt="Pasted image 20250513160009.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">对于接收阴影的物体，还是<code>CanvasItem</code>中的<code>Light Mask</code>属性，也就是说它既决定了物体的光照图层，也决定了阴影图层。</p>
<p dir="auto">在2D光源<code>Light2D</code>中的<code>Shadow</code>组下的<code>Item Cull Mask</code>与它们对应，用来控制这个光源在哪些图层上计算阴影。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573136059-pasted-image-20250513160656.png" alt="Pasted image 20250513160656.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">例如物体A的<code>Light Mask</code>为1，物体B的<code>Light Mask</code>为2，遮挡器的<code>Occluder Light Mask</code>为3，光源的<code>Range Item Cull Mask</code>为1、2，<code>Shadow Item Cull Mask</code>为1、3，那么物体A、B会被照亮，遮挡器投射出的阴影会影响物体A，不影响物体B。</p>
<h1>方法三 划分图层</h1>
<p dir="auto">可以实现“单独控制某个图层的物体是否被同图层物体的阴影遮挡”的效果，但对于不同图层物体之间的阴影遮挡不好控制。</p>
<p dir="auto">将场景中的物体和遮挡器划分到不同的图层，例如地板、墙体、墙体遮挡器、物体、物体遮挡器，可以在项目设置中给图层命名。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573145801-pasted-image-20250513161405.png" alt="Pasted image 20250513161405.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">接着将各个物体的<code>Light Mask</code>属性都设置到对应的图层，遮挡器的<code>Occluder Light Mask</code>也一样。</p>
<p dir="auto">假设要实现物体和墙体都不被自身阴影遮挡，那么地板、墙体、物体图层都可以被照亮，即光源的<code>Range Item Cull Mask</code>点亮1、2、4图层；地板需要接收阴影，墙体、物体不需要接收阴影，墙体遮挡器、物体遮挡器需要投射阴影，即光源的<code>Shadow Item Cull Mask</code>点亮1、3、5图层。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573151582-pasted-image-20250513162023.png" alt="Pasted image 20250513162023.png" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="/assets/uploads/files/1748573159018-pasted-image-20250513164014.png" alt="Pasted image 20250513164014.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">在此基础上，如果要做到方法二的最终效果，物体被墙体的阴影遮挡，这种情况还是得使用两个光源。</p>
<p dir="auto">将光源复制一份，光源1不照亮物体物体，阴影和之前一样；光源2只需要照亮物体，墙体遮挡器投射阴影，物体接收阴影。</p>
<p dir="auto"><img src="/assets/uploads/files/1748573164264-pasted-image-20250513165735.png" alt="Pasted image 20250513165735.png" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="/assets/uploads/files/1748573167890-pasted-image-20250513165910.png" alt="Pasted image 20250513165910.png" class=" img-responsive img-markdown" /></p>
<blockquote>
<p dir="auto">参考与素材来源：<a href="https://catlikecoding.com/godot/true-top-down-2d/" rel="nofollow ugc">True Top-Down 2D</a> by <a href="%5Bcatlikecoding.com%5D(https://catlikecoding.com/)">Catlike Coding</a></p>
</blockquote>
]]></description><link>http://designhub.top/topic/76/godot-2d光照-物体自身阴影遮挡设置</link><guid isPermaLink="true">http://designhub.top/topic/76/godot-2d光照-物体自身阴影遮挡设置</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 30 May 2025 02:46:14 GMT</pubDate></item><item><title><![CDATA[Godot基于深度纹理的3D物体外轮廓描边]]></title><description><![CDATA[<p dir="auto"><img src="/assets/uploads/files/1744972889999-pasted-image-20250418170632.png" alt="Pasted image 20250418170632.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">记录一下Godot中基于深度纹理的3D物体外轮廓描边效果的实现过程，这种描边适合用来高亮单个物体或多个物体的组合，同时不会占用物体内部空间，在较远的距离、较粗的描边情况下也有不错的效果。</p>
<p dir="auto">实现方式基本参考<a href="https://forum.godotengine.org/t/how-to-render-an-outline-around-3d-objects/66412/9" rel="nofollow ugc">这篇帖子</a>，有部分修改，帖子中只给了最终的结果，没有说明具体实现步骤和原理，本篇笔记将对此做一个较为详细的拆解，修复一些问题，同时记录踩到的坑。</p>
<p dir="auto">整体思路：</p>
<ol>
<li>将需要描边的物体放在一个单独的图层，使用视口与后处理着色器将它们渲染到一张描边深度纹理</li>
<li>主相机下同样使用后处理着色器，读取描边深度纹理，与当前深度纹理作比较来检测边缘并描边</li>
</ol>
<blockquote>
<p dir="auto">当前版本Godot 4.4。阅读需要一些着色器基础，如果对Godot中的着色器不太了解，可以先阅读<a href="https://docs.godotengine.org/zh-cn/4.x/tutorials/shaders/introduction_to_shaders.html" rel="nofollow ugc">着色器简介</a>、<a href="https://docs.godotengine.org/zh-cn/4.x/tutorials/shaders/your_first_shader/" rel="nofollow ugc">你的第一个着色器</a>。</p>
</blockquote>
<h1>渲染描边深度纹理</h1>
<h2>准备场景</h2>
<p dir="auto">首先搭一个测试场景，用<code>MeshInstance3D</code>显示一些简单的图元网格，例如方块、球体之类，物体之间有一些前后遮挡方便测试描边效果。</p>
<p dir="auto"><img src="/assets/uploads/files/1744972953282-pasted-image-20250418101841.png" alt="Pasted image 20250418101841.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">图中绿色的方块和粉色的甜甜圈是需要描边的物体，在它们的Inspector中，将Layers修改到另一个单独的图层，图层编号随意，不是默认的1就行，这里修改为11。之后如果要在运行时动态开启/关闭物体的描边，只需要在脚本中修改物体所在的图层，layers属性值为1时关闭，为 1 &lt;&lt; 10 时开启。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973029314-pasted-image-20250418102910.png" alt="Pasted image 20250418102910.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">注意先不要往场景里添加任何<code>WorldEnvironment</code>，会影响描边的显示，后面会说明不影响描边的世界环境如何设置。</p>
<h2>深度纹理</h2>
<p dir="auto">深度纹理是一张包含画面中像素点与相机的距离信息的纹理，Godot中离相机越近，深度值越接近于1，反之越远越接近于0。这里通过视口来渲染一张自定义的用于描边的深度纹理。</p>
<p dir="auto">在场景根节点下添加一个<code>SubViewport</code>节点，其下添加一个相机，相机下再添加一个<code>MeshInstance3D</code>节点，并重命名。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973035560-pasted-image-20250418105031.png" alt="Pasted image 20250418105031.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">OutlineSubViewport就是渲染描边物体图层的视口，描边深度纹理从这个视口获取，OutlineCamera做具体的渲染操作，OutlineDepth用于显示一个全屏四边形，它上面将会有一个后处理着色器用于获取并处理当前视口的深度纹理。</p>
<h3>视口</h3>
<p dir="auto">OutlineSubViewport节点的Inspector中，勾选Transparent BG、取消勾选Handle Input Locally、勾选Use HDR 2D。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973088130-pasted-image-20250418105313.png" alt="Pasted image 20250418105313.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">然后在OutlineSubViewport节点上挂载一个脚本，作用是在窗口大小变化时改变自身大小与窗口大小一致：</p>
<p dir="auto"><strong>viewport_fitter.gd</strong></p>
<pre><code class="language-python">extends SubViewport


func _ready() -&gt; void:
	_match_root_viewport()
	get_tree().get_root().size_changed.connect(_match_root_viewport)


func _match_root_viewport() -&gt; void:
	size = get_tree().get_root().size 
</code></pre>
<h3>相机</h3>
<p dir="auto">在OutlineCamera的Inspector中，将Cull Mask修改为11与12，11是之前设置的描边物体所在的图层，12则是处理描边深度纹理的全屏四边形所在的图层。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973096132-pasted-image-20250418150634.png" alt="Pasted image 20250418150634.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">其他的参数如FOV、角度位置等等按情况调整，但需要跟之后的主相机保持一致。</p>
<h3>全屏四边形</h3>
<p dir="auto">将OutlineDepth节点放在相机前方1米左右的位置。在它的Inspector中，Mesh属性下创建一个新的Quad Mesh，将Size改为2x2，勾选Flip Faces。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973103952-pasted-image-20250418111918.png" alt="Pasted image 20250418111918.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">Godot中裁剪空间左下角坐标为(-1, -1)，右上角坐标为(1, 1)，所以面片的大小设置为2x2。</p>
<p dir="auto">在FileSystem中创建一个新的Resource，类型为Shader，命名为outline_depth.gdshader，在<code>vertex</code>函数中将当前顶点坐标赋值给<code>POSITION</code>输出，<code>POSITION</code>被写入后将会覆盖裁剪空间下的最终顶点位置，从而使这个面片覆盖全屏。</p>
<p dir="auto"><strong>outline_depth.gdshader</strong></p>
<pre><code class="language-c">shader_type spatial;
// 设置渲染模式：禁用剔除、不计算光照、禁用阴影、禁用雾
render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled;

void vertex() {
  POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}
</code></pre>
<blockquote>
<p dir="auto">从Godot 4.3开始<a href="https://godotengine.org/article/introducing-reverse-z/" rel="nofollow ugc">改为使用反向Z(Reversed-Z)的深度缓冲</a>，即近处深度为1.0远处为0.0，所以这里的w分量填1.0。反向Z的优势可以参考<a href="https://zhuanlan.zhihu.com/p/75517534" rel="nofollow ugc">这篇文章</a></p>
</blockquote>
<p dir="auto">在OutlineDepth的Inspector中，Meterial Override属性下新建一个ShaderMaterial，Shader属性设置为刚才创建的outline_depth.gdshader，并将Render Priority改为-1让它靠后渲染。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973164835-pasted-image-20250418135440.png" alt="Pasted image 20250418135440.png" class=" img-responsive img-markdown" /></p>
<blockquote>
<p dir="auto">按照<a href="https://docs.godotengine.org/zh-cn/4.x/tutorials/shaders/advanced_postprocessing.html" rel="nofollow ugc">官方文档-高级后处理</a>的说法，OutlineDepth是相机的子节点，所以它在运行时不会被裁剪。如果希望在编辑器中也能看到效果，可以给Extra Cull Margin属性设置一个非常大的值，例如16384.0，但实测有个副作用是会导致场景里的Gizmos显示不正常。</p>
</blockquote>
<p dir="auto">同时将图层设置为12。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973173963-pasted-image-20250418150751.png" alt="Pasted image 20250418150751.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">继续完善outline_depth.gdshader，在<code>fragment</code>函数中采样当前片元的深度纹理，给它加上一个较小的值0.00001，这样在后续做边缘检测时方便做深度值比较。将处理后的深度值乘以一个较大的值2048，整数部分放在r通道，小数部分放在g通道，这样可以保持精度，将这个颜色赋值给<code>ALBEDO</code>输出。</p>
<p dir="auto"><strong>outline_depth.gdshader</strong></p>
<pre><code class="language-c">shader_type spatial;
render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled;

uniform sampler2D depth_tex : hint_depth_texture, repeat_disable, filter_nearest;

void vertex() {
	POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}

void fragment() {
	float depth = texture(depth_tex, SCREEN_UV).r + 0.00001;
	ALBEDO = vec3(floor(depth * 2048.0), fract(depth * 2048.0), 0.0);
}

</code></pre>
<p dir="auto">这一步之后OutlineSubViewport的Inspector中可以看到预览效果。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973179495-pasted-image-20250418140520.png" alt="Pasted image 20250418140520.png" class=" img-responsive img-markdown" /></p>
<h1>描边</h1>
<h2>主相机</h2>
<p dir="auto">有了描边深度纹理之后开始做主相机的描边显示，在场景根节点下添加一个<code>Node3D</code>作为主相机的根节点，在其下添加一个相机，相机之下添加一个<code>RemoteTransform3D</code>节点与一个<code>MeshInstance3D</code>节点。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973185099-pasted-image-20250418141806.png" alt="Pasted image 20250418141806.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">主相机渲染除OutlineDepth外的所有图层，即取消勾选图层12。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973189168-pasted-image-20250418170614.png" alt="Pasted image 20250418170614.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">FOV和位置视情况调整，但要和描边相机保持一致。</p>
<p dir="auto"><code>RemoteTransform3D</code>节点用来将描边相机和主相机的变换做同步，这样不管主相机的位置、旋转如何变化，描边相机都会一同变化。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973192890-pasted-image-20250418142406.png" alt="Pasted image 20250418142406.png" class=" img-responsive img-markdown" /></p>
<blockquote>
<p dir="auto">Godot里这种贴心小功能比较多，虽然很简单也可以自己实现，但有开箱即用的多少可以省点时间。</p>
</blockquote>
<h2>全屏四边形</h2>
<p dir="auto">Outline节点和上面的OutlineDepth节点一样，都是全屏四边形，区别在于着色器不同、所在的图层不同。先依葫芦画瓢做好全屏四边形，接下来编写描边的着色器。</p>
<p dir="auto">在FileSystem中创建一个新的Resource，类型为Shader，命名为outline.gdshader，先定义一些参数和变量。</p>
<p dir="auto"><strong>outline.gdshader</strong></p>
<pre><code class="language-c">shader_type spatial;
// 设置渲染模式：禁用剔除、不计算光照、禁用阴影、禁用雾
render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled;

// 描边宽度
uniform int outline_width = 2;
// 物体内部高亮颜色，与物体颜色做透明度混合
uniform vec4 inner_color : source_color = vec4(1.0, 1.0, 1.0, 0.2);
// 物体描边颜色
uniform vec4 outline_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
// 描边深度纹理
uniform sampler2D outline_depth_tex : repeat_disable;

// 当前的深度纹理
uniform sampler2D depth_tex : hint_depth_texture, repeat_disable, filter_nearest;
// 屏幕纹理
uniform sampler2D screen_tex : hint_screen_texture, repeat_disable, filter_nearest;

void vertex() {
	POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}

void fragment() {
	// ...
}
</code></pre>
<p dir="auto">将outline.gdshader设置到Material Override中，图层保持默认的1。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973207302-pasted-image-20250418144236.png" alt="Pasted image 20250418144236.png" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="/assets/uploads/files/1744973210815-pasted-image-20250418144313.png" alt="Pasted image 20250418144313.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">可以临时在<code>fragment</code>函数中给<code>ALBEDO</code>赋值一个颜色看是否正常。</p>
<pre><code class="language-c">void fragment() {
	ALBEDO = vec3(0.2, 0.8, 0.9);
}
</code></pre>
<p dir="auto">此时运行如果看到的不是纯色而是场景内容，说明Outline节点不在相机视角内或与相机重合，调整它的位置到相机前方，同时也检查一下OutlineDepth节点的位置是否正确。</p>
<h2>读取描边深度纹理</h2>
<p dir="auto">接下来给着色器的描边深度纹理参数赋值，点击Outline Depth Text属性，选择New ViewportTexture新建一个视口纹理，这时会有提示我们要先勾选Resource下的Local to Scene，勾选后再次创建，弹出的对话框中选择OutlineSubViewport，可以看到纹理预览。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973217883-pasted-image-20250418144941.png" alt="Pasted image 20250418144941.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">前面在outline_depth.gdshader中，我们将深度值乘以了2048，然后把整数和小数部分分别放到了rg通道，这里用相反的操作还原深度值。</p>
<p dir="auto"><strong>outline.gdshader</strong></p>
<pre><code class="language-c">// ...
void fragment() {
	// 采样描边深度纹理
	vec4 outline_depth_color = texture(outline_depth_tex, SCREEN_UV);
	// 还原深度值
	float outline_depth = outline_depth_color.r / 2048.0 + outline_depth_color.g / 2048.0;
	// 临时显示
	ALBEDO = vec3(outline_depth);
}
</code></pre>
<p dir="auto">运行后不出意外的话是这样，说明成功读取到了描边深度纹理：</p>
<p dir="auto"><img src="/assets/uploads/files/1744973225292-pasted-image-20250418152325.png" alt="Pasted image 20250418152325.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">同样可以在outline.gdshader中采样当前的深度纹理<code>depth_tex</code>并显示，看看它长什么样：</p>
<p dir="auto"><img src="/assets/uploads/files/1744973230045-pasted-image-20250418152457.png" alt="Pasted image 20250418152457.png" class=" img-responsive img-markdown" /></p>
<h2>内部检测</h2>
<p dir="auto">在当前的深度纹理中，每个片元的深度信息大致可以这样表示，浅绿色物体是需要描边的物体，浅紫色物体是不需要描边的物体，由于它在浅绿色物体前方，所以它的深度值更大，而背景的深度值则是0。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973235444-pasted-image-20250418160041.png" alt="Pasted image 20250418160041.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">在描边深度纹理中，我们给深度值加了一个很小的值0.00001，它的深度信息是这样：</p>
<p dir="auto"><img src="/assets/uploads/files/1744973238819-pasted-image-20250418160140.png" alt="Pasted image 20250418160140.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">如果当前深度值小于描边纹理中的深度值，并且屏幕像素的透明度大于0，则说明该片元在物体内部，可以混合内部颜色让物体内部高亮。</p>
<p dir="auto"><strong>outline.gdshader</strong></p>
<pre><code class="language-c">// ...
void fragment() {
	vec2 screen_uv = SCREEN_UV;
	// 采样描边深度纹理
	vec4 outline_depth_color = texture(outline_depth_tex, screen_uv);
	// 还原深度值
	float outline_depth = outline_depth_color.r / 2048.0 + outline_depth_color.g / 2048.0;
	// 采样当前深度纹理
	float depth = texture(depth_tex, screen_uv).r;
	// 屏幕颜色
	vec4 screen_color = texture(screen_tex, screen_uv);
	
	// 是否处于描边物体内部
	bool is_inner = depth &lt; outline_depth &amp;&amp; screen_color.a &gt; 0.0;
	
	// 混合内部高亮颜色
	screen_color.rgb = mix(screen_color.rgb, inner_color.rgb, is_inner ? inner_color.a : 0.0);
	// 输出
	ALBEDO = screen_color.rgb;
}
</code></pre>
<p dir="auto">临时调整一下内部颜色，运行可以看到效果：</p>
<p dir="auto"><img src="/assets/uploads/files/1744973248242-pasted-image-20250418161338.png" alt="Pasted image 20250418161338.png" class=" img-responsive img-markdown" /></p>
<h2>边缘检测</h2>
<p dir="auto">由于要实现的是外轮廓描边，对于物体内部跳过检测。对于物体外部，按描边宽度检测当前片元周围是否存在描边物体，例如描边宽度为2，分别看周围距离为2的一圈、距离为1的一圈片元内是否存在描边物体，如果存在则当前片元是描边的一部分，输出描边颜色。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973252130-pasted-image-20250418162736.png" alt="Pasted image 20250418162736.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">所有片元运算一遍后：</p>
<p dir="auto"><img src="/assets/uploads/files/1744973255359-pasted-image-20250418163124.png" alt="Pasted image 20250418163124.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">根据这个思路完善着色器代码：</p>
<p dir="auto"><strong>outline.gdshader</strong></p>
<pre><code class="language-c">// ...
void fragment() {
	vec2 screen_uv = SCREEN_UV;
	// 采样描边深度纹理
	vec4 outline_depth_color = texture(outline_depth_tex, screen_uv);
	// 还原深度值
	float outline_depth = outline_depth_color.r / 2048.0 + outline_depth_color.g / 2048.0;
	// 采样当前深度纹理
	float depth = texture(depth_tex, screen_uv).r;
	// 屏幕颜色
	vec4 screen_color = texture(screen_tex, screen_uv);
	
	// 是否处于描边物体内部
	bool is_inner = depth &lt; outline_depth &amp;&amp; screen_color.a &gt; 0.0;
	// 是否处于描边上
	bool is_outline = false;
	if (!is_inner) {
		// 计算纹素大小
		vec2 texel_size = 1.0 / vec2(VIEWPORT_SIZE.xy);
		// 以当前位置为中心，判断周围的点是否存在描边物体，如果存在则提前跳出循环
		for (int x = -outline_width; x &lt;= outline_width &amp;&amp; !is_outline; ++x) {
			for (int y = -outline_width; y &lt;= outline_width &amp;&amp; !is_outline; ++y) {
				if (y == 0 &amp;&amp; x == 0) { 
					continue; 
				}
				// 周围点的uv
				vec2 neighbor_uv = screen_uv - vec2(texel_size.x * float(x), texel_size.y * float(y));
				// 如果该点的屏幕颜色为透明则跳过
				if (texture(screen_tex, neighbor_uv).a &lt;= 0.0) {
					continue; 
				}
				// 用同样的逻辑（是否处于物体内部）来判断
				float neighbor_depth = texture(depth_tex, neighbor_uv).r;
				vec4 neighbor_outline_depth_color = texture(outline_depth_tex, neighbor_uv);
				float neighbor_outline_depth = neighbor_outline_depth_color.r / 2048.0 + neighbor_outline_depth_color.g / 2048.0;
				is_outline = neighbor_depth &lt; neighbor_outline_depth;
			}
		}
	}
	
	// 混合内部高亮颜色
	screen_color.rgb = mix(screen_color.rgb, inner_color.rgb, is_inner ? inner_color.a : 0.0);
	// 混合描边颜色并输出
	ALBEDO = mix(screen_color.rgb, outline_color.rgb, is_outline ? outline_color.a : 0.0);
}
</code></pre>
<p dir="auto">到这里描边便完成了，运行效果：</p>
<p dir="auto"><img src="/assets/uploads/files/1744973263521-pasted-image-20250418164442.png" alt="Pasted image 20250418164442.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">描边方法不是唯一的，这里的方法也不一定是最好的，总之只要能获取到描边深度纹理，之后的做法就多种多样了。</p>
<h1>一些坑</h1>
<h2>世界环境</h2>
<p dir="auto">前面提到先不要往场景里添加<code>WorldEnvironment</code>节点，会影响描边的显示，正确的方法是添加到主相机的Environment属性上。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973271185-pasted-image-20250418170410.png" alt="Pasted image 20250418170410.png" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="/assets/uploads/files/1744973278307-pasted-image-20250418170632.png" alt="Pasted image 20250418170632.png" class=" img-responsive img-markdown" /></p>
<h2>导入的模型</h2>
<p dir="auto">对于外部导入的模型，需要注意其<code>MeshInstance3D</code>路径，双击模型文件打开导入设置查看。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973283055-pasted-image-20250418170944.png" alt="Pasted image 20250418170944.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">可以编写脚本，在编辑器内填写路径，获取到<code>MeshInstance3D</code>节点控制它的图层。</p>
<pre><code class="language-python">extends Node3D

@export var outline_enabled: bool
@export var mesh_path: NodePath

var mesh: MeshInstance3D

func _ready() -&gt; void:
    mesh = get_node(mesh_path)
    toggle_outline(outline_enabled)


func toggle_outline(is_enabled: bool):
    outline_enabled = is_enabled
    if is_enabled:
        mesh.layers = 1 &lt;&lt; 10
    else:
        mesh.layers = 1
</code></pre>
<p dir="auto"><img src="/assets/uploads/files/1744973287897-pasted-image-20250418171600.png" alt="Pasted image 20250418171600.png" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="/assets/uploads/files/1744973292640-pasted-image-20250418171605.png" alt="Pasted image 20250418171605.png" class=" img-responsive img-markdown" /></p>
<h2>景深模糊</h2>
<p dir="auto">相机开启景深模糊的情况下，如果描边物体后方有被模糊的物体或背景，描边也会被模糊。</p>
<p dir="auto"><img src="/assets/uploads/files/1744973296973-pasted-image-20250418172624.png" alt="Pasted image 20250418172624.png" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="/assets/uploads/files/1744973300736-pasted-image-20250418172957.png" alt="Pasted image 20250418172957.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">猜测原因是景深模糊位于内置后处理中（未确认），比描边着色器后执行，先描边再模糊导致描边也被模糊。</p>
<p dir="auto">一种解决思路是，把描边处理放到内置后处理之后执行，先模糊再描边。在Godot中，也有类似于Unity URP的RendererFeature的功能，叫做<a href="https://docs.godotengine.org/zh-cn/4.x/tutorials/rendering/compositor.html" rel="nofollow ugc">合成器</a>，支持在渲染管线的不同阶段插入额外逻辑，但遗憾的是目前似乎不支持插入到内置后处理之后(<a href="https://docs.godotengine.org/en/4.4/classes/class_compositoreffect.html#enum-compositoreffect-effectcallbacktype" rel="nofollow ugc">CompositorEffect.EffectCallbackType</a>)，和URP的<a href="https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@12.0/api/UnityEngine.Rendering.Universal.RenderPassEvent.html" rel="nofollow ugc">RenderPassEvent</a>相比少了很多插入点，另外考虑到时间关系就没有去尝试了。</p>
<p dir="auto">另一种思路是，描边被模糊，说明它所处片元的深度被景深模糊判断在了需要模糊的区间内，从上图中也可以看出，被模糊的部分的深度值都较小。那么只要修改这些地方的深度，让它们等于物体内部的深度，就不会被模糊了。</p>
<p dir="auto">按这个思路对outline.gdshader进行修改，首先在渲染模式里加上一条<code>depth_draw_always</code>让它总是写入深度：</p>
<p dir="auto"><strong>outline.gdshader</strong></p>
<pre><code class="language-c">shader_type spatial;
// 设置渲染模式：禁用剔除、不计算光照、禁用阴影、禁用雾、总是写入深度
render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled, depth_draw_always;

// ...
</code></pre>
<p dir="auto">在<code>fragment</code>函数中，先写入当前深度到<code>DEPTH</code>，注意<code>DEPTH</code>一旦被写入，函数中的所有判断分支都需要确保<code>DEPTH</code>被写入：</p>
<pre><code class="language-c">// ...
void fragment() {
	// ...	
	// 是否处于描边物体内部
	bool is_inner = depth &lt; outline_depth &amp;&amp; screen_color.a &gt; 0.0;
	// 是否处于描边上
	bool is_outline = false;
	// 写入深度
	DEPTH = depth;
	// ...
}
</code></pre>
<p dir="auto">在是否为描边的判断中，如果当前是描边，并且当前深度要小于物体内部深度，则将当前深度改为物体内部深度：</p>
<pre><code class="language-c">// ...
void fragment() {
	//...
	// 写入深度
	DEPTH = depth;
	if (!is_inner) {
		// ...
		// 以当前位置为中心，判断周围的点是否存在描边物体，如果存在则提前跳出循环
		for (int x = -outline_width; x &lt;= outline_width &amp;&amp; !is_outline; ++x) {
			for (int y = -outline_width; y &lt;= outline_width &amp;&amp; !is_outline; ++y) {
				//...
				is_outline = neighbor_depth &lt; neighbor_outline_depth;
				DEPTH = is_outline &amp;&amp; depth &lt; neighbor_depth ? neighbor_depth : depth;
			}
		}
	}
	//...
}
</code></pre>
<p dir="auto">描边不再被模糊了，但有些地方还是不太完美，之后有时间再完善了：</p>
<p dir="auto"><img src="/assets/uploads/files/1744973310049-pasted-image-20250418180201.png" alt="Pasted image 20250418180201.png" class=" img-responsive img-markdown" /></p>
<h2>其他未解决问题</h2>
<p dir="auto">以下问题由于暂时没有相关需求，所以暂时没有处理，如果您知道解决方法，或者有更好的实现方式，欢迎在评论区留言：</p>
<ul>
<li>半透明物体的描边</li>
<li>开启TAA时描边会抖动</li>
<li>只测试了Windows平台下使用Forward+渲染器的情况，其他平台未测试</li>
</ul>
<h1>完整代码</h1>
<p dir="auto">不包含景深模糊的处理。</p>
<p dir="auto"><strong>outline_depth.gdshader</strong></p>
<pre><code class="language-c">shader_type spatial;
render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled, depth_draw_never;

uniform sampler2D depth_tex : hint_depth_texture, repeat_disable, filter_nearest;

void vertex() {
	POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}

void fragment() {
	float depth = texture(depth_tex, SCREEN_UV).r + 0.00001;
	ALBEDO = vec3(floor(depth * 2048.0), fract(depth * 2048.0), 0.0);
}
</code></pre>
<p dir="auto"><strong>outline.gdshader</strong></p>
<pre><code class="language-c">shader_type spatial;
// 设置渲染模式：禁用剔除、不计算光照、禁用阴影、禁用雾
render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled;

// 描边宽度
uniform int outline_width = 2;
// 物体内部高亮颜色，与物体颜色做透明度混合
uniform vec4 inner_color : source_color = vec4(1.0, 1.0, 1.0, 0.2);
// 物体描边颜色
uniform vec4 outline_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
// 描边深度纹理
uniform sampler2D outline_depth_tex : repeat_disable;

// 当前的深度纹理
uniform sampler2D depth_tex : hint_depth_texture, repeat_disable, filter_nearest;
// 屏幕纹理
uniform sampler2D screen_tex : hint_screen_texture, repeat_disable, filter_nearest;

void vertex() {
	POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}

void fragment() {
	vec2 screen_uv = SCREEN_UV;
	// 采样描边深度纹理
	vec4 outline_depth_color = texture(outline_depth_tex, screen_uv);
	// 还原深度值
	float outline_depth = outline_depth_color.r / 2048.0 + outline_depth_color.g / 2048.0;
	// 采样当前深度纹理
	float depth = texture(depth_tex, screen_uv).r;
	// 屏幕颜色
	vec4 screen_color = texture(screen_tex, screen_uv);
	
	// 是否处于描边物体内部
	bool is_inner = depth &lt; outline_depth &amp;&amp; screen_color.a &gt; 0.0;
	// 是否处于描边上
	bool is_outline = false;
	if (!is_inner) {
		// 计算纹素大小
		vec2 texel_size = 1.0 / vec2(VIEWPORT_SIZE.xy);
		// 以当前位置为中心，判断周围的点是否存在描边物体，如果存在则提前跳出循环
		for (int x = -outline_width; x &lt;= outline_width &amp;&amp; !is_outline; ++x) {
			for (int y = -outline_width; y &lt;= outline_width &amp;&amp; !is_outline; ++y) {
				if (y == 0 &amp;&amp; x == 0) { 
					continue; 
				}
				// 周围点的uv
				vec2 neighbor_uv = screen_uv - vec2(texel_size.x * float(x), texel_size.y * float(y));
				// 如果该点的屏幕颜色为透明则跳过
				if (texture(screen_tex, neighbor_uv).a &lt;= 0.0) {
					continue; 
				}
				// 用同样的逻辑（是否处于物体内部）来判断
				float neighbor_depth = texture(depth_tex, neighbor_uv).r;
				vec4 neighbor_outline_depth_color = texture(outline_depth_tex, neighbor_uv);
				float neighbor_outline_depth = neighbor_outline_depth_color.r / 2048.0 + neighbor_outline_depth_color.g / 2048.0;
				is_outline = neighbor_depth &lt; neighbor_outline_depth;
			}
		}
	}
	
	// 混合内部高亮颜色
	screen_color.rgb = mix(screen_color.rgb, inner_color.rgb, is_inner ? inner_color.a : 0.0);
	// 混合描边颜色并输出
	ALBEDO = mix(screen_color.rgb, outline_color.rgb, is_outline ? outline_color.a : 0.0);
}
</code></pre>
]]></description><link>http://designhub.top/topic/75/godot基于深度纹理的3d物体外轮廓描边</link><guid isPermaLink="true">http://designhub.top/topic/75/godot基于深度纹理的3d物体外轮廓描边</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 18 Apr 2025 10:48:37 GMT</pubDate></item><item><title><![CDATA[【Unity】2D像素游戏中让UI与世界的像素大小保持一致]]></title><description><![CDATA[<p dir="auto">在使用Unity制作2D像素游戏时，经常会遇到Canvas中的Image与世界中的Sprite大小不一致的情况，即使是同一素材也会有差别：</p>
<p dir="auto"><img src="/assets/uploads/files/1728119128280-pasted-image-20241005122322.png" alt="Pasted image 20241005122322.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">特别是对于像素游戏，这会导致画面中的逻辑像素大小不统一，影响观感。</p>
<p dir="auto">由于Unity使用了不同的方式来处理它们，首先要了解它们的大小是如何计算的。</p>
<h2>Pixels Per Unit</h2>
<p dir="auto">图片导入选项中的Pixels Per Unit（以下简称PPU），表示多少个实际像素为1个Unity单位。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119195005-pasted-image-20241005120656-resized.png" alt="Pasted image 20241005120656.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">例如PPU设置为16，则一张16×16像素的图片在世界中的大小为1×1个单位，一张32×8像素的图片在世界中的大小为2×0.5个单位。</p>
<p dir="auto">一张16×16像素的图片Unit.png作为Sprite在世界中显示的情况：</p>
<p dir="auto"><img src="/assets/uploads/files/1728119207940-pasted-image-20241005120801.png" alt="Pasted image 20241005120801.png" class=" img-responsive img-markdown" /></p>
<h2>相机大小</h2>
<p dir="auto">正交相机的Size表示半个屏幕/窗口高度中能显示多少Unity单位的内容。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119235549-pasted-image-20241005120814-resized.png" alt="Pasted image 20241005120814.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">例如Size设置为3，则屏幕纵向有3×2=6个单位，图中每个地块大小为1个单位，纵向显示了6个地块。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119254935-pasted-image-20241005121934.png" alt="Pasted image 20241005121934.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">在Size不变的情况下，无论显示分辨率、宽高比如何变化，纵向显示的内容总是不变的，都是6个地块。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119270488-pasted-image-20241005120842.png" alt="Pasted image 20241005120842.png" class=" img-responsive img-markdown" /></p>
<h2>Canvas的Reference Pixels Per Unit与缩放模式</h2>
<p dir="auto">新建一个Canvas，Canvas Scaler中有一个Reference Pixels Per Unit参数（以下简称RPPU），默认为100。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119282902-pasted-image-20241005121333.png" alt="Pasted image 20241005121333.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">此时将上面的Unit.png图片作为Image放到UI中，会发现与世界中Sprite的大小有差别。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119292361-pasted-image-20241005122322.png" alt="Pasted image 20241005122322.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">和世界中的Sprite不同，Canvas中不直接使用Unity单位，而是适用于Canvas的像素大小。在没有缩放的情况下，</p>
<p dir="auto">Canvas像素大小 = 图片像素大小 / (PPU / RPPU)</p>
<p dir="auto">例如这里的Unit.png，它的大小为16×16像素，PPU为16，Canvas Scaler的RPPU为100，则它在Canvas中的像素大小为100。</p>
<p dir="auto">需要注意的是，Canva像素大小并不是实际显示的像素大小，它受Canvas Scaler的UI Scale Mode（缩放模式）以及其他缩放参数影响。</p>
<p dir="auto">当UI Scale Mode设置为Constant Pixel Size（固定像素大小）时，无论显示分辨率、宽高比如何变化，图片的大小都不变。Scale Factor（缩放因子）参数影响图片的缩放倍率，例如Scale Factor为1时，Image的显示大小始终固定为100×100，为2时，固定为200×200。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119310265-pasted-image-20241005124142.png" alt="Pasted image 20241005124142.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">当UI Scale Mode设置为Scale With Screen Size（随屏幕大小缩放）时，图片的显示大小受显示分辨率、Reference Resolution（参考分辨率），和Match（宽高匹配）影响。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119320896-pasted-image-20241005124337.png" alt="Pasted image 20241005124337.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">听起来很复杂，实际上可以理解成是另一种Constant Pixel Size模式，但Scale Factor会根据某些规则自动变化，缩放适应不同分辨率和宽高比的屏幕。</p>
<p dir="auto">例如显示分辨率为1920×1080，Reference Resolution也是1920×1080，此时可以认为内置的缩放因子为1，Image的显示大小为100×100；</p>
<p dir="auto">当显示分辨率变为1680×720（21:9），Reference Resolution依然是1920×1080，当Match参数拉到Height端为1时，此时的缩放因子为720÷1080=2/3，Image的显示大小变为66×66；当Match参数拉到Width端为0时，缩放因子为1680÷1920=7/8，Image的显示大小变为87×87。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119327755-pasted-image-20241005131259.png" alt="Pasted image 20241005131259.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">还有一种缩放模式Constant Physical Size（固定物理大小）个人从来没用过，就不讨论了。</p>
<h2>统一大小</h2>
<p dir="auto">以结果来看，最终需要统一的是图片在世界和UI中的实际显示大小，例如一张16×16的图片，如果在UI中作为Image显示的实际像素大小是100×100，那么它在世界中作为Sprite的实际像素大小也应该是100×100，反之亦然，要么调整UI相关参数让它匹配世界，要么调整世界相关参数让它匹配UI。在此基础上，还要确保不同显示分辨率和宽高比下的缩放情况，以及处理相机自身的缩放。</p>
<p dir="auto">由于不同项目情况不同，这里只介绍一种思路，只要清楚了原理其他都是相通的。</p>
<p dir="auto">首先确定并统一所有图片素材的PPU，像素素材在制作过程中通常会有一个参考大小，例如8×8、16×16、32×32，不同参考大小可表现的细节也不同。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119333618-pasted-image-20241005152315.png" alt="Pasted image 20241005152315.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">使用这个参考大小作为PPU是一个不错的选择，例如素材中每个地块的大小为16×16，PPU也设置为16，1个地块刚好为1个Unity单位。</p>
<p dir="auto">然后确定一个设计分辨率，也就是以此分辨率为基准，其他分辨率都在它基础上缩放，例如确定设计分辨率为1920×1080，将显示分辨率和Canvas Scaler的参考分辨率都调整为这个值，此时 设计分辨率 = 实际显示分辨率。</p>
<p dir="auto">接下来有两种选择，调整UI适配相机，或调整相机以适配UI。</p>
<h3>调整UI适配相机</h3>
<p dir="auto">这种方式适合一屏显示的内容有固定要求的情况，例如要求屏幕中必须显示32×18个地块，那么相机的大小不能变化。</p>
<p dir="auto">根据相机大小一节，屏幕纵向共有相机Size×2个Unity单位，用设计分辨率高度除以它，可以得到1Unity单位对应的实际像素大小，即：</p>
<p dir="auto">屏幕高度(Unity单位) = 相机Size * 2</p>
<p dir="auto">屏幕高度(像素) = 设计分辨率高度</p>
<p dir="auto">1Unity单位对应像素 = 屏幕高度(像素) / 屏幕高度(Unity单位)</p>
<p dir="auto">然后根据图片的PPU，可以计算出图片中的1个像素，实际显示在屏幕上是多少像素，也就是像素比率(Pixel Ratio)：</p>
<p dir="auto">像素比率 = 1Unity单位对应像素 / PPU</p>
<p dir="auto">举个例子，当设计分辨率为1920×1080，相机的Size设置为6，图片大小16×16，PPU为16，此时的像素比率：</p>
<p dir="auto">像素比率 = 1080÷(6×2)÷16 = 5.625</p>
<p dir="auto">也就是图片素材的1个像素，显示在屏幕上为5.625个像素，因为是设计分辨率，有小数是正常的，实际显示时Unity会帮我们处理好。</p>
<p dir="auto">得到的像素比率有什么用呢？它可以用来确定UI中图片的大小，如果UI的显示也能符合这个像素比率，那么就做到UI和世界显示大小一致了。</p>
<p dir="auto">将UI Scale Mode改为Scale With Screen Size，Reference Resolution设置为设计分辨率，Match调整为Height为1。当显示分辨率与设计分辨率一致时，Canvas不会有缩放。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119345396-pasted-image-20241005124337.png" alt="Pasted image 20241005124337.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">根据Canvas一节，在没有缩放的情况下：</p>
<p dir="auto">实际显示像素大小 = Canvas像素大小 = 图片像素大小 / (PPU / RPPU)</p>
<p dir="auto">由此可以得出UI的像素比率：</p>
<p dir="auto">UI像素比率 = 图片像素大小 / 实际显示像素大小 = RPPU / PPU</p>
<p dir="auto">为了让UI的像素比率和世界的像素比率一致，我们唯一还能调整的只有RPPU。按上面的例子，当像素比率为5.625时，RPPU应该调整为16×5.625=90，调整后需要点击Image的Set Native Size重置大小。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119356378-pasted-image-20241005162448.png" alt="Pasted image 20241005162448.png" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="/assets/uploads/files/1728119361591-pasted-image-20241005162655.png" alt="Pasted image 20241005162655.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">调整好后，由于Canvas按高度缩放，相机也是按高度缩放，所以在不同分辨率和宽高比下都能保持一致：</p>
<p dir="auto"><img src="/assets/uploads/files/1728119366234-pasted-image-20241005163006.png" alt="Pasted image 20241005163006.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">如果项目不符合这种情况，例如需要按宽度缩放，可以试着按类似的原理调整相机大小和UI缩放。</p>
<h3>调整相机适配UI</h3>
<p dir="auto">了解了上面的方法后，另一种情况依葫芦画瓢就行，先计算出UI的像素比率，然后调整相机大小匹配这个比率。</p>
<p dir="auto">另外在使用Pixel Perfect Camera的情况下，相机大小不可直接调整，Pixel Perfect Camera会根据当前各项参数计算出像素比率（图中为实际显示像素 : 图片素材像素），按照这个值调整UI的像素比率即可。</p>
<p dir="auto"><img src="/assets/uploads/files/1728119371468-pasted-image-20241005165519.png" alt="Pasted image 20241005165519.png" class=" img-responsive img-markdown" /></p>
]]></description><link>http://designhub.top/topic/74/unity-2d像素游戏中让ui与世界的像素大小保持一致</link><guid isPermaLink="true">http://designhub.top/topic/74/unity-2d像素游戏中让ui与世界的像素大小保持一致</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Sat, 05 Oct 2024 09:09:52 GMT</pubDate></item><item><title><![CDATA[从Unity迁移到Godot再到入坟]]></title><description><![CDATA[👍节点元数据
<p dir="auto">非常方便但容易被忽视的功能。之后来填</p>
]]></description><link>http://designhub.top/topic/71/从unity迁移到godot再到入坟</link><guid isPermaLink="true">http://designhub.top/topic/71/从unity迁移到godot再到入坟</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Thu, 02 May 2024 02:10:58 GMT</pubDate></item><item><title><![CDATA[自用C#代码规范]]></title><description><![CDATA[<p dir="auto">在没有代码规范的情况下，一个多人合作的项目中很可能会出现多种代码风格，就像是一栋房子的装修中同时出现中式古典风格、现代简约风格、欧式奢华风格以及亚马逊原始风格。抛开美观不谈，这对项目成员间的协作以及后续维护会造成较大的阻碍，所以统一的代码规范是必要的。</p>
<p dir="auto">本规范综合参考<a href="https://docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/c_sharp_style_guide.html" rel="nofollow ugc">Godot C# Style Guide</a>、微软的<a href="https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/identifier-names" rel="nofollow ugc">C# Coding Style</a>与<a href="https://github.com/thomasjacobsen-unity/Unity-Code-Style-Guide" rel="nofollow ugc">Unity Code Style Guide</a>，可作为Godot、Unity、.NET等C#项目代码规范。</p>
<p dir="auto">文中未特别说明部分优先遵循<a href="https://docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/c_sharp_style_guide.html" rel="nofollow ugc">Godot C# Style Guide</a>，其次遵循微软的<a href="https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/identifier-names" rel="nofollow ugc">C# Coding Style</a>。</p>
<h2>格式</h2>
<p dir="auto">本地文件中的换行符在提交到git后应转换为LF，而不是CRLF或CR，一般情况下git默认开启此功能。</p>
<p dir="auto">使用UTF-8无BOM编码，如果使用Visual Studio需要注意设置。</p>
<p dir="auto">使用4个空格作为tab键缩进，一般这是Unity、VSCode、Rider的默认设置，Godot需要在<code>Editor Settings -&gt; Text Editor -&gt; Behavior -&gt; Indent</code>中修改。</p>
<p dir="auto">花括号换行使用Allman风格，而非K&amp;R风格：</p>
<pre><code class="language-C#">while (x == y) 
{
    DoSomething();
    DoSomethingElse();
}

if (x &gt; 0)
{
    DoSomething();
}
</code></pre>
<p dir="auto">内容只有一行时，可省略花括号：</p>
<pre><code class="language-C#">while (x == y) 
    DoSomething();

if (x &gt; 0)
    DoSomething();
</code></pre>
<p dir="auto">属性的get/set方法，以及方法体较为简单时，可写成一行：</p>
<pre><code class="language-C#">public interface MyInterface
{
    int MyProperty { get; set; }
}

public class MyClass : ParentClass
{
    public int Value
    {
        get { return 0; }
        set
        {
            ArrayValue = new [] {value};
        }
    }

    public int Foo() { return GetSomeValue(); }

    public void Bar() =&gt; DoSomething();
}
</code></pre>
<p dir="auto">字段、属性、方法之间的空行不要超过2行。</p>
<p dir="auto">方法内语句之间的空行不要超过2行。</p>
<p dir="auto">保持代码紧凑易阅读，不要无意义空行。</p>
<h2>命名</h2>
<h3>基础</h3>
<p dir="auto">C#代码文件使用PascalCase命名，例如<code>MyClass.cs</code>。</p>
<p dir="auto">不使用默认命名空间，即必须指定类所在的命名空间，命名空间使用PascalCase。</p>
<pre><code class="language-C#">namespace Game 
{
    public class MyClass
    {
    }
}
</code></pre>
<p dir="auto">C# 10.0以上可使用文件范围的命名空间。</p>
<pre><code class="language-C#">namespace Game;

public class MyClass
{
}
</code></pre>
<p dir="auto">命名空间与文件夹结构尽量保持一致，例如<code>namespace Game.Combat.Characters</code>，则其文件夹结构为<code>Game/Combat/Characters</code>。</p>
<p dir="auto">接口使用PascalCase命名，加“I”前缀。</p>
<p dir="auto">接口成员的访问修饰符没有要求，其他地方使用显式访问修饰符。</p>
<pre><code class="language-C#">public interface ITouchable
{
    // 接口成员可省略访问修饰符
    void Interact();
}
</code></pre>
<p dir="auto">枚举使用PascalCase命名，不加任何前缀。</p>
<pre><code class="language-C#">public enum DrinkType
{
    None,
    Soft,
    Hard
}
</code></pre>
<p dir="auto">类与结构体使用PascalCase命名，不加任何前缀，基类可用“BaseXxx”命名但不是硬性要求。</p>
<pre><code class="language-C#">public class SomeClass
{
}
</code></pre>
<p dir="auto">所有常量使用PascalCase命名，无论其访问修饰符是什么，包括局部常量，且<strong>不使用</strong>任何前缀如“k_”。</p>
<pre><code class="language-C#">public class SomeClass
{
    public const float DefaultSpeed = 10f;
    private const string LogTag = "MyClass";
}
</code></pre>
<p dir="auto">所有private字段，无论是否为static，均使用camelCase命名，加下划线前缀，除此之外<strong>不使用</strong>其他前缀如“m_”、“s_”、“t_”等。</p>
<pre><code class="language-C#">public class SomeClass
{
    private static T _instance;
    private static readonly object _lockObj = new();
    private Vector3 _aimingAt;
    private Vector3 _velocity;
}
</code></pre>
<p dir="auto">非private字段使用PascalCase命名。</p>
<pre><code class="language-C#">public class SomeClass
{
    protected int HitPoints;
    internal int State;
    public string Name;
}
</code></pre>
<p dir="auto">所有属性使用PascalCase命名，无论其访问修饰符是什么。</p>
<pre><code class="language-C#">public class SomeClass
{
    private bool IsAlive =&gt; HitPoints &gt; 0;

    protected float MyProperty { get; set; }

    public float AnotherProperty
    {
        get { return MyProperty; }
    }
}
</code></pre>
<p dir="auto">所有方法使用PascalCase命名。</p>
<pre><code class="language-C#">public class SomeClass
{
    public void MyMethod() 
    {
    }
}
</code></pre>
<p dir="auto">局部变量及方法参数使用camelCase命名，不加任何前缀。局部常量使用PascalCase命名。</p>
<pre><code class="language-C#">public float SomeMethod(float someValue) 
{
    const float Increment = 1.2f;
    var result = someValue + Increment;
    return result;
}
</code></pre>
<h3>可读性</h3>
<p dir="auto">含有三个字母及以上的缩写词时，遵循当前命名约定，例如“APIHandler”在PascalCase时写作“ApiHandler”，camelCase时写作“apiHandler”。</p>
<p dir="auto">两个字母的缩写词为特例，例如“UIUtil”在PascalCase时写作“UIUtil”，camelCase时写作“uiUtil”，仅限首字母缩写词，例如“Id”不是首字母缩写。</p>
<p dir="auto">命名应该尽量表达清晰，尽量达到自解释，缩写不要影响可读性，例如：</p>
<pre><code class="language-C#">FindNearbyEnemy()?.Damage(weaponDamage); √

FindNode()?.Change(wpnDmg); ×
</code></pre>
<p dir="auto"><code>bool</code>变量<strong>不加</strong>任何固定的前缀，例如“b”。但推荐使用“is”、“has”等词来表明其含义，例如“isDead”, “isWalking”, "hasDamageMultiplier"。</p>
<p dir="auto">尽量使用动词短语为事件命名，用动词时态区分事件发生的时机。事件不加任何前缀或后缀。</p>
<pre><code class="language-C#">public event Action OpeningDoor;    // 开门事件发生之前
public event Action DoorOpened;     // 开门事件发生之后
</code></pre>
<p dir="auto">事件的接收方法以“On事件名”命名。</p>
<pre><code class="language-C#">public void OnOpeningDoor() 
{
}

public void OnDoorOpened() 
{
}
</code></pre>
<p dir="auto">非特殊情况不使用拼音命名。</p>
<p dir="auto">避免拼写错误，很多时候拼写错误会造成一些让人一时摸不着头脑的Bug（例如JSON序列化、服务端传值错误等等），建议开启编辑器的拼写检查功能。</p>
<h2>示例</h2>
<pre><code class="language-C#">namespace MyGame;

// 类与结构体使用PascalCase命名，不加任何前缀
public class MyClass&lt;T, R&gt; : Parent&lt;T, R&gt; where T : class, new()
{
    // 所有常量使用PascalCase命名
    public const float DefaultSpeed = 10f;
    private const string LogTag = "MyClass";

    // 所有private字段使用camelCase命名，加下划线前缀
    // 使用显式访问修饰符，不省略private
    private static T _instance;
    private static readonly object _lockObj = new();
    private Vector3 _aimingAt;
    private Vector3 _velocity;

    // 非private字段使用PascalCase命名
    protected int HitPoints;
    internal int State;
    public string Name;

    // 所有属性使用PascalCase命名
    private bool IsAlive =&gt; HitPoints &gt; 0;

    protected float MyProperty { get; set; }

    public float AnotherProperty
    {
        get { return MyProperty; }
    }

    public static T Instance
    {
        get
        {
            if (null == _instance)
            {
                lock (_lockObj)
                {
                    _instance ??= new T();
                }
            }
            return _instance;
        }
    }

    // 所有方法使用PascalCase命名
    public void MyMethod()
    {
        int[] values = {1, 2, 3, 4};
        int sum = 0;

        for (int i = 0; i &lt; values.Length; i++)
        {
            switch (i)
            {
                case 3: return;
                default:
                    sum += i &gt; 2 ? 0 : 1;
                    break;
            }
        }

        i += (int)MyProperty;
    }

    // 使用显式访问修饰符，不省略private
    private int MyMethod2() 
    {
        return 0;    
    }
}
</code></pre>
]]></description><link>http://designhub.top/topic/70/自用c-代码规范</link><guid isPermaLink="true">http://designhub.top/topic/70/自用c-代码规范</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Mon, 15 Apr 2024 07:32:04 GMT</pubDate></item><item><title><![CDATA[【Unity】一些个人心得]]></title><description><![CDATA[<p dir="auto">没发生碰撞检测的常见原因：<br />
rigidbody没得<br />
onCollisionEnter（2D）写错了</p>
<p dir="auto">动画移动错误的一些原因<br />
应用了动画根运动</p>
]]></description><link>http://designhub.top/topic/63/unity-一些个人心得</link><guid isPermaLink="true">http://designhub.top/topic/63/unity-一些个人心得</guid><dc:creator><![CDATA[GShion]]></dc:creator><pubDate>Fri, 08 Dec 2023 04:50:20 GMT</pubDate></item><item><title><![CDATA[这几年开发思维上的一些转变]]></title><description><![CDATA[<p dir="auto">一些个人的想法和流水账。</p>
<h1>菜鸟的思考</h1>
<p dir="auto">刚工作做Android APP的时候，可以说完全是一个菜鸟，做了几年后，摸到了一些架构方面的皮毛，也算稍微有些成长。</p>
<p dir="auto">那时候对程序设计的一些认知，大概是知道整个软件系统是需要分层的，以便于解耦和复用。就APP来说，通常会分成通用基础库、通用业务层、应用层（即最终的APP）。通用基础库、通用业务层都包含若干个模块，项目需要用到哪些，就把对应的模块拿来拼装组合，应用层再根据项目需求做定制，一个项目做完后，再看看有哪些东西可以沉淀到底层，整合进去。</p>
<p dir="auto">得益于包管理工具Maven和Gradle，这些底层模块都可以打包发布到私库上，APP中一行代码就能引用，使用和管理都很方便。</p>
<p dir="auto">遇到的问题是，项目上做二次开发的人员经常来给研发反馈，“这个功能我想改一下，但是被你的代码\视图限制了，改不了”、“你写的功能完全满足不了需求，我只能重写”、“我用不着这个功能，能不能去掉”、“我想直接改你的源码，能不能把源码发我一份”、“你底层怎么有Bug，会不会写代码”...</p>
<p dir="auto">总的来说都可以归于程序设计能力不扎实，对业务理解不透彻，以及妄图写出一套万能的业务层来解决未来所有的项目需求。按照开放封闭原则，软件实体应该对扩展开放，对修改封闭，现在想想，当时写的底层模块只想着要涵盖哪些业务，而忽略了扩展性。需求总是会变的，一个灵活易扩展的底层才是解决问题的正确思路。</p>
<p dir="auto">对第三方库可以说是非常依赖，能用现成的绝不自己造轮子，比如网络请求、数据持久化、异步操作、依赖注入、图片加载等等，开新项目的第一件事就是在Gradle脚本里加上一大堆依赖项，至于对包大小的影响？基本上是无人关心，反正这种基础库也不会大到哪里去。</p>
<p dir="auto">在当时（甚至现在）的APP开发中这是一个很常见的现象，Google下场推出Jetpack全家桶之后，也只是把这些库换成官方的了而已。面试中也经常考查对第三方库的熟悉程度。我自己负责过一段时间面试，对候选人基本上也是问这些，没办法，俺就这水平，更高深的也问不出来了。</p>
<p dir="auto">好处自然是很明显的，省去了自己鼓捣轮子的时间，降低了开发门槛，今天学会这一套，明天就能来我司上班。这也是当时培训班越来越火爆的原因之一，只要会用就能干活，至于理论基础是否扎实，写出来的东西性能如何，很多情况下都不在乎。</p>
<p dir="auto">但是如果抱有“我会用就行了，没必要搞懂原理”这样不求甚解的想法，自己的水平就很难提升了。特别是现在这个面试造火箭入职拧螺丝的环境下，很容易被别人卷死。</p>
<p dir="auto">但我那时在优化方面也只是了解皮毛，只知道尽量避免内存泄漏，减少APP启动时的耗时操作，垃圾回收器怎么运作的知道个大概。资源这块只知道尽可能复用，尽量让美术切9图。</p>
<p dir="auto">至于产品设计上没有学到太多，可能是没有遇到靠谱的产品经理。</p>
<p dir="auto">项目管理方面，作为技术负责人，首先应该制定并执行好开发标准，确保团队中所有人写出来的代码、做出来的东西都是同一种风格，这样做对于开发和维护都有很大的帮助。有些标准甚至都不需要自己从零开始制定，直接在一些大厂公开的标准上修改即可，像之前我们用的是阿里的开发标准。</p>
<p dir="auto">同时在开发方面还要有全局的掌控，例如团队中每个人做什么、怎么安排更合理、每个人负责模块的业务逻辑、大致实现方式（程序概要设计、详细设计）、哪些东西需要与别的部门对接、开发进度如何等等。</p>
<p dir="auto">很可惜我并不是一个合格的技术负责人，对待组员太宽松，不怎么追进度，不会带动积极性。很多时候组员反馈太难的做不了的东西，都是拉过来自己做，而不是教给他们做的方法，确实这样可能会更快更好，但是长期下来自己累得要死，自己的水平越来越高，其他人的水平却没有得到提升，进入了恶性循环。当然跟当时其他人的工作态度也有一定的关系，随着几个大佬的离开，剩下的大部分人都是养老心态，难的不做新东西不学，教了也不太愿意听，这种环境下还是早点跑路为妙。后来在只有几个人的创业团队里，这些问题几乎不存在了。</p>
<h1>一些思维转变</h1>
<p dir="auto">再后来因为一些原因，逐渐放弃Android转向Unity。踩过一些坑后意识到，性能这块要花的功夫比以前要多得多，特别是移动端，代码真不是随便写写就行的，一不小心写出来的东西就没法跑了。</p>
<h2>性能相关</h2>
<p dir="auto">最基础的性能相关知识是必须要具备的，由于Unity的GC方式较为落后（现在已经有计划在新版本迁移到CoreCLR GC），稍有经验的Unity程序员都是谈GC色变——GC工作时很容易引起掉帧。所以像内存分配、值类型引用类型、拆装箱等等一定要搞清楚。对于哪些操作是“昂贵的”要有了解，比如常见的GetCompoent、GameObject.Find、反射调用等等。这样在写下代码之前，会有认知“这样写会增加GC负担，得换种方式”、“这个操作比较耗时，不能搞得太频繁”，从而写出高质量的代码，而不是在功能写完后才发现运行疯狂掉帧、内存暴涨，这时再优化就很困难了。</p>
<p dir="auto">开发中对目标平台要有清晰的认知，了解各平台的限制。比如只准备上PC，那不在乎上面那段内容都没太大关系，代码写得再垃圾都是能勉强一战的。如果目标平台是移动端，除了代码上要注意，还要多多在真机上运行测试，编辑器能跑不代表真机能跑，很多疑难杂症都只会在真机上出现。如果目标平台是WebGL（小游戏），资源加载一定要用异步，不能使用线程等等。</p>
<p dir="auto">如果是从PC端开发转向移动端开发，就要特别注意，已经见过一些不太好的例子：</p>
<ul>
<li>
<p dir="auto">状态机框架，使用反射来调用每个状态的生命周期函数，这种做法确实会更方便和解耦，但反射的性能是很差的，在移动端的表现不会太好，这时不能图自己用起来方便，应该改成直接调用或事件驱动；</p>
</li>
<li>
<p dir="auto">事件系统，每种事件都用class定义，每当发送事件时就要new一个class，增加GC工作量，为什么不试试神奇的struct呢。</p>
</li>
<li>
<p dir="auto">属性系统，设计上属性类中包含int、float等字段，均为不同类型下的属性值，为了方便，设值函数这样写：</p>
</li>
</ul>
<pre><code class="language-C#">public void SetValue(object obj)
{
    if (obj is Int32)
    {
        intValue = (int) obj;
    }
    else if (obj is Single)
    {
        floatValue = (float) obj;
    }
}
</code></pre>
<p dir="auto">导致调用时发生装箱和拆箱。</p>
<p dir="auto">上面三个例子，都是很底层的部分，在游戏中会频繁用到，底层的性能不做好，整个游戏的性能也就无从谈起了。</p>
<p dir="auto">不仅是自己造的东西，第三方库也要谨慎使用。刚接触Unity的时候，我的思路还跟搞Android时差不多，有现成的为什么不用现成的呢，AssetStore上不是有一大堆吗？所以比较热衷于学习各种插件。但后来逐渐认识到，用的前提是这东西适用，比如它能用在哪些平台上？性能如何，是否有针对移动端优化？引入这个库会增加多少包大小？扩展性和兼容性如何，能在它基础上做改造吗？所以现在发现之前买过的一些插件反而用不上了，很多时候还是得自己造。</p>
<p dir="auto">资源这块，在移动端上则是能省则省。UI图片能切9图的一定要切，能染色的就不要重复做多种颜色的素材，如果美术给出的素材不合理，程序一定要提出，而不能说美术给啥我就用啥。</p>
<p dir="auto">在不影响功能的情况下，能压缩的图片一定要尽量压缩，导入选项里顺手调整一下压缩选项，或者做个导入预设。对于较大的图片或图集，压缩后能节省十多MB的运行内存甚至更多。</p>
<p dir="auto">对于图集，也不是无脑打就行了，想想打图集的目的：节省存储空间、减少Draw Call，减少Draw Call这一点可以说是牺牲了部分内存占用换来的，加载图集中的一张图，整个图集都要加载到内存。所以打图集时应该是把可能会同时渲染的图片打在一起，同时要避免这张图集过大，用时加载，不用时卸载以释放内存。见过一些不好的例子，把不同功能模块用到的图片资源打在一起，导致刚进游戏就加载了多张很大的图集，内存占用飙升，这在小游戏平台是很致命的。</p>
<p dir="auto">这些都只是偏基础的，其他的像渲染、美术这些方面的经验还不多，过几年再来补充吧。</p>
<h2>单例模式</h2>
<p dir="auto">主要在于单例的生命周期管理。在Android中，用到的更多是依赖注入，要用到某个类的实例，注入一下就好了，不用太关心它何时创建何时销毁。Unity虽然也有依赖注入框架（VContainer之类），但目前个人用的不多。在Unity中使用单例遇到了哪些问题呢？一般来说，最传统也是最常见的单例写法大概是这样的：</p>
<pre><code class="language-C#">public class Singleton&lt;T&gt; where T : class, new()
{
    private static T _instance;
    
    private static readonly object LockObj = new();
    
    public static T Instance
    {
        get
        {
            if (null == _instance)
            {
                lock (LockObj)
                {
                    _instance ??= new T();
                }
            }
            return _instance;
        }
    }
    
}
</code></pre>
<p dir="auto">MonoBehaviour单例大概是这样的：</p>
<pre><code class="language-C#">public abstract class SingletonBehaviour&lt;T&gt; : MonoBehaviour where T : MonoBehaviour
{
    public static T Instance { get; private set; }

    [Tooltip("保持不销毁")]
    public bool dontDestroyOnLoad = true;

    protected virtual void Awake()
    {
        if (Instance != null)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this.GetComponent&lt;T&gt;();
        if (dontDestroyOnLoad)
            gameObject.DontDestroyOnLoad();
    }

}
</code></pre>
<p dir="auto">自动创建型的MonoBehaviour单例大概是这样的：</p>
<pre><code class="language-C#">public abstract class SingletonAutoBehaviour&lt;T&gt; : MonoBehaviour where T : MonoBehaviour
{
    protected static T _instance;
    
    public static T Instance
    {
        get
        {
            if (_instance == null)
            {
                GameObject go = new GameObject();
                go.name = typeof(T).ToString();
                _instance = go.AddComponent&lt;T&gt;();
            }
            return _instance;
        }
    }
    
}
</code></pre>
<p dir="auto">当功能逐渐复杂时，整个游戏系统中通常会有许多单例，它们在许多地方都被调用。如果自动创建型单例（第一种和第三种）使用得很多的话，将会面临一个问题：每个单例到底是啥时候创建的？</p>
<p dir="auto">你可能会说，“打个断点看一下不就知道了”，“这个问题很重要吗？”。确实，重点不在于单例是什么时候创建的，而在于单例的创建顺序是混乱的，这种情况下无法控制谁先创建谁后创建，很可能会扎堆创建，有些单例的创建可能还非常耗时。</p>
<p dir="auto">这将引起一些致命的问题：单帧内初始化对象过多、耗时操作过多导致卡顿、刚进入游戏时就初始化了一些还用不着的东西、加载了一些还用不着的资源、拖慢游戏的启动速度等等。</p>
<p dir="auto">相应的，单例的销毁也缺少统一的管理，切换场景时，一堆dontDestroyOnLoad的单例依然保留，假如需要退出到开始界面重新游戏，这一堆单例的状态都需要重置，稍有遗漏就很容易出现问题。</p>
<p dir="auto">所以在新的框架中，我逐渐抛弃了这种“野生”的单例，改为统一管理。</p>
<h2>模块间解耦合</h2>
<p dir="auto">“高内聚低耦合”的口号谁都会喊，但真要做到却没那么简单。记得还是个菜鸟的时候，我说出过“要改UI层，那逻辑层也不得不改啊”这种话，现在看来是非常搞笑的。像Android开发中经常可以看到MVC、MVP、MVVM、MVI这些架构思想，目的之一就是降低耦合度。</p>
<p dir="auto">解耦的最大好处，就是在面对改动时可以不用“牵一发而动全身”，修改甚至删除掉某一个模块，对其他模块不会产生很大的影响。新手程序常犯的错误就是模块间严重互相依赖，比如直接在UI中写业务逻辑，业务逻辑反过来又需要UI。还见过一些不好的例子，整个游戏必须要等待所有UI初始化完才能正常游戏，UI一旦被销毁，游戏的业务逻辑都没法正常跑。</p>
<p dir="auto">这些问题不仅仅是解耦方面，在职责划分方面也没有做好，违反了单一职责原则，UI层就应该只做UI的事情，图一时省事直接把业务逻辑写在UI中，只会给后续的扩展与维护带来更大的麻烦。</p>
<p dir="auto">那么有哪些东西需要解耦？</p>
<p dir="auto">从整个软件系统来看，首先分层是有必要的，至少通用的基础库需要分出来，可以用Assembly Definition把各个模块隔离开，例如：</p>
<pre><code>|-- CommonLib  基础库模块目录
|   |-- CommonLib.asmdef
|   |-- 代码文件...
|-- Framework  框架层模块目录
|   |-- Framework.asmdef
|   |-- 代码文件...
|-- Game  游戏应用层模块目录
|   |-- Game.asmdef
|   |-- 代码文件...
`-- Editor  游戏编辑扩展模块目录
    |-- Editor.asmdef
    |-- 代码文件...
</code></pre>
<p dir="auto">这里Game依赖Framework，Framework依赖CommonLib，由于程序集的划分，低层模块将无法引用到高层模块，只会存在高层到低层的单向引用。在具体实现中，高层模块还可以不直接依赖低层模块中的具体实现，而是依赖抽象类与接口，进一步降低耦合。</p>
<p dir="auto">在这基础上，继续划分成更细的维度（拆分出更多的模块）。回到有哪些东西需要解耦这个问题，个人的看法是，逻辑部分需要与表现部分解耦，比如Gameplay中有Gameplay的逻辑与表现，UI有UI的逻辑与表现（不知道Gameplay这个词是否恰当，总之这里是指与UI无关的玩法层面的东西）。</p>
<p dir="auto">例如玩家角色与怪物战斗，玩家的各项属性值受到其穿戴装备的影响，战斗中各种数值的计算发生在Gameplay逻辑层，而玩家角色与怪物的显示在Gameplay表现层；</p>
<p dir="auto">UI上打开背包，有哪些装备要显示、装备的各种属性、是否有可升级的装备等等，这些是从Gameplay逻辑层获取的，这些装备具体按哪种方式显示、点击之后有哪些交互，这些是在UI逻辑层处理的，而装备的实际显示、图标大小、属性文字、按钮摆放、交互动画等等，这些是在UI表现层的。</p>
<p dir="auto">在这种划分下，逻辑层和表现层各司其职，这样就杜绝了上面提到的“在UI中写业务逻辑”的情况。</p>
<p dir="auto">实际开发中，需要根据根据具体项目情况决定解耦的程度，比如Gameplay的逻辑和表现可能关联紧密，不是那么地好拆分，而UI逻辑和UI表现在相对简单的情况下，拆分可能会多出许多代码，增加工作量，这种情况下可以把Gameplay逻辑与表现和业务逻辑统一看作逻辑层，UI的逻辑与表现统一看作表现层。</p>
<p dir="auto">那么这样逻辑层和表现层之间是否完全解耦了呢？有一种方法可以检验解耦是否做到位，把表现层删除，如果逻辑层不需要做太大调整甚至不调整就能正常跑，那就说明做对了。其实这也是很常见的需求，比如省电模式、息屏挂机，就是把表现层关掉，但不会影响到逻辑层的运行。</p>
<p dir="auto">但由于逻辑层与表现层之间必然存在交互，交互过程可能还是会产生耦合，比较常见的交互实现方式一般有这些：</p>
<ol>
<li>逻辑层模块与表现层模块互相依赖，通过函数的直接调用来实现双方的交互。</li>
<li>逻辑层模块与表现层模块依赖对方的抽象层，通过调用抽象类或接口来实现双方的交互。</li>
</ol>
<p dir="auto">1这种方式是最简便但耦合度也是最高的，存在直接的依赖，如果其中一方发生变化，另一方很可能要同步修改；2则使用抽象类和接口进行解耦，但缺点是需要维护更多的抽象类和接口，增加了开发难度。</p>
<p dir="auto">个人认为，相较于上面两种方式，使用事件驱动的模块间交互可以更加优雅地解决问题。</p>
<h3>事件驱动下的解耦合</h3>
<p dir="auto">这里的事件系统必须要具备一个重要特性：事件的接收者只需要订阅事件并做对该事件的处理，而不需要关心事件有没有人发送、发送者是谁；相应的，事件的发送者只需要在合适的时机发出事件，而不需要关心事件有没有人接收、接收者是谁。这个特性实现起来很简单，就不详细说明了。</p>
<p dir="auto">在这个特性下，解耦的目标便自然达到了。逻辑层和表现层不再需要互相依赖，也不再需要依赖对方的抽象层，它们只需要依赖一个或多个事件定义层，事件定义层中只需要定义逻辑层与表现层中需要的事件、以及事件所引用到的数据结构（甚至数据结构可以放到通用的模型层），并且事件可以在多个功能中复用、组合，相对于上面的方式2，需要维护的内容大大减少。</p>
<p dir="auto">并且由于发送者和接收者互不关心、互相没有感知，上面提到的“删掉表现层，逻辑层照常运行”的需求也自然而然地实现了——表现层被删除后，逻辑层不再接收到来自表现层的事件，带来的影响只是缺少了来自表现层的输入，逻辑层发往表现层的事件没有人接收了，逻辑层自身不会受到影响。</p>
<p dir="auto">由于事件广播发出，逻辑层发出的事件可以被表现层的多处接收，逻辑层发出一个事件，表现层多处都可以做处理，从而解决“由于某些页面数据未刷新而导致显示不一致”的问题。</p>
<p dir="auto">举个例子，背包功能需要能在背包一级页面显示背包内所有物品的简略信息（品质、图标、数量、等级等），点击某物品弹出二级页面（对话框形式）展示物品详情，点击二级页面中的强化按钮可以升级物品。</p>
<p dir="auto">按上面的模式来设计：</p>
<ul>
<li>
<p dir="auto">数据模型层</p>
<ul>
<li>背包物品相关数据模型定义</li>
</ul>
</li>
<li>
<p dir="auto">事件层</p>
<ul>
<li>请求获取背包物品事件</li>
<li>请求获取物品详情事件</li>
<li>请求升级物品事件</li>
<li>物品发生变化事件</li>
</ul>
</li>
<li>
<p dir="auto">逻辑层</p>
<ul>
<li>背包逻辑，实现对背包内物品的管理与物品升级</li>
</ul>
</li>
<li>
<p dir="auto">表现层</p>
<ul>
<li>背包一级页面，以网格形式展示背包物品，展示每个物品的品质、图标、数量、等级</li>
<li>背包二级页面，展示物品详情，监听强化按钮的点击事件</li>
</ul>
</li>
</ul>
<p dir="auto">其中事件层依赖数据模型层，逻辑层依赖事件层与数据模型层，表现层依赖事件层与数据模型层。</p>
<p dir="auto">此时背包展示到升级物品、刷新页面的流程：</p>
<ul>
<li>
<p dir="auto">背包逻辑初始化时，监听 “请求获取背包物品事件”、“请求获取物品详情事件”、“请求升级物品事件”。</p>
</li>
<li>
<p dir="auto">背包一级页面开启时，发出 “请求获取背包物品事件”，背包逻辑收到该事件后，返回当前背包内物品数据（这个的具体实现方式有很多，例如通过回调返回、通过委托返回、另外发出事件返回等）。背包一级页面收到该数据后，执行自身的展示逻辑将物品显示，随后监听 “物品发生变化事件”。</p>
</li>
<li>
<p dir="auto">同理背包二级页面开启时，发出 “请求获取物品详情事件” 获取物品详情并展示，随后也监听 “物品发生变化事件”。</p>
</li>
<li>
<p dir="auto">背包二级页面的强化按钮被点击时，发出 “请求升级物品事件”，背包逻辑收到该事件后，检查当前是否符合升级条件，执行物品强化升级逻辑，随后发出 “物品发生变化事件”，将物品变化的相关信息广播出去。</p>
</li>
<li>
<p dir="auto">背包一级页面与背包二级页面将同时收到 “物品发生变化事件”，根据其中包含的物品变化信息刷新自身页面。</p>
</li>
</ul>
<p dir="auto">由于充分解耦，一个功能的逻辑层和表现层可以拆分给不同的开发人员，双方只要约定好数据和事件格式，就可以开始各自的开发，最后进行对接。开发过程中逻辑层和表现层都可以独立测试，而不需要等待对方开发完毕。</p>
<p dir="auto">这种模式的缺点是不可避免地会定义大量的事件，对于事件的分类管理以及后续维护的要求较高；在设计时需要避免出现死循环，例如A事件的接收者发出了B事件，B事件的接收者收到后又发出了A事件；由于事件的频繁使用，事件系统的实现一定要做到高效、零GC。</p>
]]></description><link>http://designhub.top/topic/62/这几年开发思维上的一些转变</link><guid isPermaLink="true">http://designhub.top/topic/62/这几年开发思维上的一些转变</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 01 Dec 2023 12:04:47 GMT</pubDate></item><item><title><![CDATA[Godot创始人对从Unity迁移的答疑解惑]]></title><description><![CDATA[<p dir="auto"><img src="/assets/uploads/files/1694919972098-pasted-image-20230916233228.png" alt="Pasted image 20230916233228.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">自从9月12日Unity公布了新的收费模式后，一部分开发者开始转向免费开源的Godot引擎。为了让开发者少走一些弯路，Godot创始人Juan Linietsky这几天发布了一系列推文，针对引擎各方面做了一些答疑，有些东西是官方文档没有提到的，更偏向于“在引擎作者眼中这个引擎应该怎么用”，值得翻译记录一下。</p>
<p dir="auto">包含以下内容：</p>
<ul>
<li>概念上的对应关系</li>
<li>场景树理念与Unity的区别</li>
<li>GDScript还是C#</li>
<li>如何应对高性能需求</li>
<li>Servers API的使用（绕过场景系统直接使用底层API以达到极致性能）</li>
</ul>
<h2>概念上的对应关系</h2>
<p dir="auto">实体：节点<br />
组件：节点<br />
场景设置：节点<br />
导航：节点<br />
光照贴图：节点<br />
视口：节点<br />
行为：节点+脚本<br />
预制体：场景<br />
场景组合：场景<br />
ScriptableObject：资源</p>
<p dir="auto">几乎所有内容都是节点、场景或资源...Unity中很多非常复杂的子系统，在Godot中它们表达得更加自然和直观。</p>
<p dir="auto">因此，我建议新用户首先熟悉 Godot 的工作原理及其背后的价值是什么。我理解（想尽快迁移到Godot的）这种冲动，但仅仅是把Godot当成Unity一样来转换游戏工程，很可能会导致很多痛点。</p>
<h2>对于场景树的建议</h2>
<p dir="auto">我能给那些从 Unity 转向 Godot 的人的最好建议是：<br />
你必须将Godot的“场景树”想象为“一棵不包含任何实体的组件树”。这有两个特殊点：</p>
<ul>
<li>场景一目了然，更加清晰</li>
<li>组合更加灵活</li>
</ul>
<p dir="auto"><img src="/assets/uploads/files/1694920060637-pasted-image-20230916231009.png" alt="Pasted image 20230916231009.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">你不需要在每个节点上挂多个脚本来实现某种行为，只需要添加更多子节点，子节点上再挂脚本即可。<br />
此外，预制体或场景组合，在概念上是并不存在的。你可以简单地在其他地方实例化或继承任何场景。你还可以让它们的实例变得可编辑并做一些本地更改。</p>
<p dir="auto"><img src="/assets/uploads/files/1694920109119-pasted-image-20230916231239.png" alt="Pasted image 20230916231239.png" class=" img-responsive img-markdown" /></p>
<blockquote>
<p dir="auto">译注：Godot中，一个节点上只能挂一个脚本，相比Unity中脚本是作为组件挂载到物体上，Godot中的脚本更像是节点功能的扩展</p>
</blockquote>
<p dir="auto">最后，要理解每个场景没有“全局设置”的概念，场景只是节点。 Unity 中属于场景的事物，例如光照贴图、导航、环境等，在Godot 中仍然只是节点，允许根据需要混合和匹配任何内容。</p>
<p dir="auto"><img src="/assets/uploads/files/1694920086696-pasted-image-20230916231744.png" alt="Pasted image 20230916231744.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">即使你在 Godot 中加载场景，引擎也会将其放置在一个“根”节点下。什么是根节点？一个窗口节点！没错，如果你想在游戏中使用多个窗口，请实例化更多窗口并将子节点放入其中。</p>
<p dir="auto"><img src="/assets/uploads/files/1694920145738-pasted-image-20230916231823.png" alt="Pasted image 20230916231823.png" class=" img-responsive img-markdown" /></p>
<blockquote>
<p dir="auto">个人认为这个是Godot非常有特色的地方，甚至有人做出了可以在多个窗口之间跳跃的2D平台类游戏</p>
</blockquote>
<p dir="auto">这些事情需要一些时间才能理解，但最终发生的事情是 Godot 让你彻底颠覆游戏开发的过程：</p>
<ul>
<li>在 Unity 中，你用代码设计游戏，并使用编辑器作为一个工具</li>
<li>在 Godot 中，你在编辑器中设计游戏，然后添加代码..</li>
</ul>
<p dir="auto">由于 GDScript 实际上是编辑器紧密集成的一部分（并且该语言是深度集成的），因此使用它进行开发的体验甚至比使用在单独的 IDE 中编辑代码的引擎更加流畅。这就是大多数 Godot 用户更喜欢它的原因。</p>
<p dir="auto"><img src="/assets/uploads/files/1694920161731-pasted-image-20230916232231.png" alt="Pasted image 20230916232231.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">这些概念上的差异有助于大多数 Godot 用户提高工作效率。你很可能注意到一些 Godot 项目，甚至是那些没有太多使用该引擎经验的用户制作的项目，在今年过去的大型游戏大赛（GMTK、Ludum Dare 等）中取得了不错的成绩。</p>
<p dir="auto">所以再次强调，在尝试从 Unity 1:1 转换你的游戏之前，在移植项目之前，请花点时间采取行动并充分理解 Godot 的设计哲学。我最不希望的是 Unity 用户在尝试过程中受到伤害并获得糟糕的体验。</p>
<h2>关于脚本语言GDScript</h2>
<p dir="auto">为什么 GDScript 存在？在 Godot 的背景下，有两个主要原因是用户的首选：</p>
<ul>
<li>快速迭代</li>
<li>深度融合</li>
</ul>
<p dir="auto"><img src="/assets/uploads/files/1694920171909-pasted-image-20230917085947.png" alt="Pasted image 20230917085947.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">快速迭代： 当你忙于开发游戏时，代码部分不会妨碍你：</p>
<ul>
<li>无构建时间，即时运行</li>
<li>集成编辑器，快速打开附加到节点的脚本</li>
<li>立即重新加载正在运行的游戏中的更改（热重载）</li>
<li>同时调试游戏数据和代码..</li>
<li>没有GC..</li>
</ul>
<blockquote>
<p dir="auto">GDScript使用引用计数，而不是垃圾回收器：<a href="https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_basics.html#memory-management" rel="nofollow ugc">内存管理</a> 。根据<a href="https://docs.godotengine.org/en/stable/about/faq.html#what-were-the-motivations-behind-creating-gdscript" rel="nofollow ugc">官方文档介绍</a>，是为了避免GC工作时引起的卡顿和不必要的大量内存占用。</p>
</blockquote>
<p dir="auto">深度集成：</p>
<ul>
<li>大量与引擎紧密相关的特性：节点路径语法、onready、IDE中的可视化连接、预加载关键字等等</li>
<li>与引擎共享数据模型，允许零消耗的查看变量、序列化、网络化等</li>
<li>将变量暴露给编辑器无需胶水层</li>
</ul>
<p dir="auto">节点路径语法：</p>
<p dir="auto">例如有一个这样的场景：</p>
<p dir="auto"><img src="/assets/uploads/files/1694920190137-pasted-image-20230917091252.png" alt="Pasted image 20230917091252.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">使用<code>$</code>加上路径即可获取子节点，或者将节点拖入到代码编辑器中自动生成代码：</p>
<pre><code class="language-Python">var col = $CharacterBody2D/CollisionShape2D
</code></pre>
<p dir="auto">集成甚至更加深入:</p>
<ul>
<li>代码自动完成可以自动提示游戏数据（节点路径，文件路径，动画名称，对象中的实时数据等）</li>
<li>可以从编辑器拖入各种东西到代码中，自动生成相关代码（节点路径、文件路径、属性等）</li>
<li>内置的代码编辑器和检查器</li>
</ul>
<p dir="auto">总而言之，Godot 用户更喜欢使用 GDScript，因为这种针对引擎量身定制的深层次集成。就像虚幻中的蓝图。 所以，Godot 并不是打算让 C# 成为二等公民，C# 已经尽可能地集成了（但没办法达到GDScript的高集成度）。</p>
<blockquote>
<p dir="auto">个人看法和建议：</p>
<p dir="auto">虽然个人更熟悉C#，但在上手Godot的过程中依然是先使用了GDScript，包括参加Game Jam、制作一些个人项目，都是使用GDScript。如果你之前使用过Python或Lua，那么GDScript是非常容易上手的，给它一些尝试，说不定它是你的菜。</p>
<p dir="auto">正如上面所说，GDScript与引擎的集成度是最高的，对开发效率有不错的提升，相比C#在开发中没法享受到高集成度带来的便利（例如拖拽节点生成代码、可视化信号连接等等）。</p>
<p dir="auto">性能上根据社区做的测评，C#是比GDScript快的，如果非常注重性能，C#是更好的选择；如果想在GDScript中提升性能，那么使用静态类型，避免使用动态类型。</p>
<p dir="auto">如果对强类型有要求，或者希望代码尽可能与引擎解耦，这种情况下GDScript可能不是好的选择，虽然支持静态类型，但它本质上还是动态类型语言，不支持接口（只有鸭子类型），没有很强的类型检查；与引擎集成度高也导致它更难与引擎解耦。</p>
<p dir="auto">Godot 3.x LTS 版本下，C#支持全平台打包，在目前最新版Godot 4.1中，C#<strong>不支持</strong>移动端和WebGL打包。这是由于3.x 使用的是Mono，而现在Mono已废弃，<a href="http://4.xn--x-qd6bo1d9yuhpd.Net" rel="nofollow ugc">4.x版本改用.Net</a>，没有IL2CPP那样的魔法加持，需要等待微软官方做移动端和WebGL的支持，预计时间是今年底。</p>
</blockquote>
<h2>多种开发语言问题</h2>
<p dir="auto">我从 Unity 开发人员那里看到的一个持续的担忧是，由于 Godot 设计为支持多种语言（不仅仅是 C#），这将导致插件碎片化。 Godot 使用通用语言适配器 API，因此目标是你可以使用任何语言的任何插件。</p>
<p dir="auto"><img src="/assets/uploads/files/1694920224699-pasted-image-20230917103844.png" alt="Pasted image 20230917103844.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">虽然这仍然不是 100% 完善（现在 GDScript 可以使用 C# 和 C++ 插件，C# 只能使用 C++ 插件，但两者都不能使用 GDScript），在架构上已经可以实现这一点， Godot 中的所有语言都使用相同的引擎 API。</p>
<p dir="auto">老实说，我希望大多数复杂的附加组件都用 GDExtension (C++/Rust) 或 C# 编写。 借助他们都已经使用的通用 Godot 语言 API 适配器，应该很容易实现互通。</p>
<p dir="auto">好奇这是如何运作的？基本上，Godot 4 使用 C 中公开的 API 适配器：</p>
<p dir="auto"><a href="https://github.com/godotengine/godot/blob/master/core/extension/gdextension_interface.h" rel="nofollow ugc">https://github.com/godotengine/godot/blob/master/core/extension/gdextension_interface.h</a></p>
<p dir="auto">C 非常高效，这个星球上的每种语言编程都可以与它交互。此外，C 是 ABI 稳定的，确保当前和未来的互操作。</p>
<p dir="auto">顺便说一下，在编写或使用插件时，这对你来说是完全透明的，你将在文档中看到 API，并且只需从你喜欢的语言中使用它即可。</p>
<h2>性能问题</h2>
<p dir="auto">我从迁移到Godot的Unity用户那里看到的另一个常见主题是，Godot是如何处理类似用Burst/ECS编写的代码的？ 这是以不同的哲学方式处理的，节点没问题，但是如果处理数万个节点，性能就会受到影响，那么该怎么办？</p>
<p dir="auto">在 Godot 中，有两种方法可以获得“更快”的性能，特别是对于大量实体：</p>
<ul>
<li>用底层语言重写脚本（C++/Rust/等）</li>
<li>使用Servers API</li>
</ul>
<p dir="auto"><img src="/assets/uploads/files/1694920231369-pasted-image-20230917102602.png" alt="Pasted image 20230917102602.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">正如我之前提到的，Godot 使用通用语言适配器 API。这意味着，如果你有一个带有脚本的节点并且需要对其进行优化，你可以简单地用更快的语言（C++、Rust，甚至 C#）重写它，并且对于其余代码来说它将是透明的。</p>
<p dir="auto">一般来说，需要优化的部分很小（比如永远不会超过整个游戏的 5%），其余部分 GDScript 可以很好地处理。 但如前所述，如果使用节点，在处理数以万计的实体时可能会遇到问题，这种情况下应该使用Servers API。</p>
<p dir="auto">Godot 提供了两个抽象层：场景层(Scene)和服务层(Servers)。场景及其节点是一个高级的、非常灵活的抽象。但Godot中所有的底层操作都是在服务层完成的。在Godot中，你可以轻松绕过场景层，直接使用服务层。</p>
<p dir="auto">使用底层语言和Servers API，你可以获得最大性能，并且仍然保留使用 Godot 的所有可移植性和易用性优势，同时该代码可以与游戏的所有高级代码顺利交互。</p>
<blockquote>
<p dir="auto">Godot 4.x支持Compute Shader，但这里没有提到太多</p>
</blockquote>
<blockquote>
<p dir="auto">下面是对Servers API文档的部分翻译</p>
</blockquote>
<h2>使用Servers优化性能</h2>
<p dir="auto"><a href="https://docs.godotengine.org/en/stable/tutorials/performance/using_servers.html" rel="nofollow ugc">使用Servers优化性能</a></p>
<p dir="auto">就像大多数引擎那样，Godot的场景系统使用节点与资源来简化项目内容的组织和资产的管理，以此制作复杂的游戏。但是显然，这样做有以下缺点：</p>
<ul>
<li>这又会导致一层额外的复杂性</li>
<li>性能比直接使用简单 API 时要低</li>
<li>不可能使用多个线程来控制它们</li>
<li>需要更多的内存</li>
</ul>
<p dir="auto">大部分情况下，这并不是问题(Godot 进行了非常多的优化，大多数操作都使用信号处理，因此不需要做轮询)。尽管如此，有些情况还是不能满足要求，例如，每帧需要处理数以万计的实体可能会达到性能瓶颈。</p>
<p dir="auto">Godot最有趣的设计决策之一是整个场景系统是可选的。虽然目前还不能将其单独提出来，但是在运行时可以完全绕过它。</p>
<blockquote>
<p dir="auto">与Unity类比，就像是绕过Renderer组件，直接调用底层API渲染</p>
</blockquote>
<p dir="auto">在核心部分，Godot 使用了服务层的概念。它们是用于控制渲染、物理、声音等的非常底层的 API。场景系统是建立在他们之上，并直接使用他们。最常见的服务层有:</p>
<ul>
<li><a href="https://docs.godotengine.org/en/stable/classes/class_renderingserver.html#class-renderingserver" rel="nofollow ugc">RenderingServer</a>: 处理图形相关</li>
<li><a href="https://docs.godotengine.org/en/stable/classes/class_physicsserver3d.html#class-physicsserver3d" rel="nofollow ugc">PhysicsServer3D</a>: 处理3D物理相关</li>
<li><a href="https://docs.godotengine.org/en/stable/classes/class_physicsserver2d.html#class-physicsserver2d" rel="nofollow ugc">PhysicsServer2D</a>: 处理2D物理相关</li>
<li><a href="https://docs.godotengine.org/en/stable/classes/class_audioserver.html#class-audioserver" rel="nofollow ugc">AudioServer</a>: 处理音频相关</li>
</ul>
<p dir="auto">查看它们的API可以发现，提供的函数都是Godot允许你做的所有事情的底层实现。</p>
<p dir="auto">使用服务层的关键是理解资源 ID (RID)对象。这些是服务器实现的不透明句柄。它们是手动分配和释放的。服务器中的几乎每个函数都需要 RID 来访问实际资源。</p>
<p dir="auto">大多数 Godot 节点和资源都在内部包含了来自服务层的这些 RID，可以通过不同的函数获得它们。事实上，继承 Resource 的任何内容都可以直接强制转换为 RID，然后可以将资源作为 RID 传递给服务层 API。但是，并非所有资源都包含 RID（在这种情况下，RID 将是空的）。</p>
<p dir="auto">下面是一些使用Servers API的示例：</p>
<h3>创建Sprite</h3>
<pre><code class="language-python">extends Node2D

# RenderingServer需要维持一个纹理引用
var texture

func _ready():
    # 创建一个CanvasItem
    var ci_rid = RenderingServer.canvas_item_create()
    # 设置当前节点为父节点
    RenderingServer.canvas_item_set_parent(ci_rid, get_canvas_item())
    # 将纹理画在CanvasItem上
    texture = load("res://my_texture.png")
    # 使用RenderingServer添加到渲染
    RenderingServer.canvas_item_add_texture_rect(ci_rid, Rect2(texture.get_size() / 2, texture.get_size()), texture)
    # 旋转45°，变换位置
    var xform = Transform2D().rotated(deg_to_rad(45)).translated(Vector2(20, 30))
    RenderingServer.canvas_item_set_transform(ci_rid, xform)
</code></pre>
<p dir="auto">Canvas Item API允许你往Canvas上画东西，一旦添加，它们便无法修改，需要清除然后再次添加（变换位置、旋转不需要清除）。</p>
<p dir="auto">通过这个函数来清除：</p>
<pre><code class="language-python">RenderingServer.canvas_item_clear(ci_rid)
</code></pre>
<blockquote>
<p dir="auto">用过SFML可能会对这种方式比较熟悉（但又有一些不同），SFML中几乎每帧都需要调用draw和clear</p>
</blockquote>
<h3>创建Mesh</h3>
<pre><code class="language-python">extends Node3D

# RenderingServer需要维持一个网格引用
var mesh

func _ready():
    # 创建一个3D实例.
    var instance = RenderingServer.instance_create()
    # 设置scenario，这样这个实例才会出现当前世界中
    var scenario = get_world_3d().scenario
    RenderingServer.instance_set_scenario(instance, scenario)
    # 添加网格
    mesh = load("res://mymesh.obj")
    RenderingServer.instance_set_base(instance, mesh)
    # 移动网格
    var xform = Transform3D(Basis(), Vector3(20, 100, 0))
    RenderingServer.instance_set_transform(instance, xform)
</code></pre>
]]></description><link>http://designhub.top/topic/56/godot创始人对从unity迁移的答疑解惑</link><guid isPermaLink="true">http://designhub.top/topic/56/godot创始人对从unity迁移的答疑解惑</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Sun, 17 Sep 2023 03:10:50 GMT</pubDate></item><item><title><![CDATA[[Unity]小游戏转换优化笔记]]></title><description><![CDATA[23.09.08
<p dir="auto">已经...没有什么运行性能方面的优化了...</p>
<p dir="auto">如果在项目之初就已经确定目标平台是移动端或小游戏，在基础框架选型、造轮子、后续的开发中都注意了上面提到的各种问题，每个开发人员都有对性能优化的基础理解，那么只要客户端逻辑不是非常重度，相信性能都不会太差；</p>
<p dir="auto">但如果项目初期没有做这方面的考量，使用的基础框架未针对目标平台优化，后续开发时较少关注性能优化（或者没有认知），写完功能后缺乏真机环境下测试，连最核心的游戏逻辑单独拎出来都没法在云测试上跑到及格分，这种情况只能说是无力回天，还是多花点时间重构吧</p>
]]></description><link>http://designhub.top/topic/51/unity-小游戏转换优化笔记</link><guid isPermaLink="true">http://designhub.top/topic/51/unity-小游戏转换优化笔记</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Sat, 05 Aug 2023 03:54:44 GMT</pubDate></item><item><title><![CDATA[【unity】如何制作一个打飞机游戏]]></title><description><![CDATA[<h1>占坑</h1>
<ul>
<li>
<p dir="auto">移动控制 ok</p>
<ul>
<li>高低速 ok</li>
<li>碰撞点显隐 ok</li>
</ul>
</li>
<li>
<p dir="auto">波次组生成 ok</p>
<ul>
<li>波次生成 ok</li>
<li>敌机移动 ok
<ul>
<li>贝塞尔曲线 ok</li>
<li>贝塞尔曲线的切线（敌机面向曲线移动） ok</li>
<li>折线移动且面向折线方向移动</li>
<li>完成曲线后重新回到起点 ok</li>
<li>分段速度设置 ok（暂不兼容速度曲线）</li>
<li>速度曲线 ok（废弃方法）</li>
<li>分段直线取均速（by团子）</li>
</ul>
</li>
</ul>
</li>
<li>
<p dir="auto">子弹</p>
<ul>
<li>碰撞</li>
<li>子弹列表（嵌套子弹等、没做占坑）</li>
</ul>
</li>
<li>
<p dir="auto">玩家管理</p>
<ul>
<li>重新生成玩家</li>
<li>玩家无敌时间
<ul>
<li>无敌盾</li>
</ul>
</li>
<li>根据威力变化玩家子弹</li>
</ul>
</li>
<li>
<p dir="auto">玩家技能</p>
<ul>
<li>全屏炸弹</li>
</ul>
</li>
<li>
<p dir="auto">特效音效管理</p>
</li>
<li>
<p dir="auto">关卡管理</p>
<ul>
<li>开始结束游戏</li>
</ul>
</li>
<li>
<p dir="auto">阶段成果：玩家移动，波次生成及敌机曲线移动<br />
<a href="/assets/uploads/files/1688301877104-88stg-move-amp-wave.unitypackage">88STG-move&amp;wave.unitypackage</a></p>
</li>
</ul>
]]></description><link>http://designhub.top/topic/49/unity-如何制作一个打飞机游戏</link><guid isPermaLink="true">http://designhub.top/topic/49/unity-如何制作一个打飞机游戏</guid><dc:creator><![CDATA[GShion]]></dc:creator><pubDate>Sat, 24 Jun 2023 18:05:06 GMT</pubDate></item><item><title><![CDATA[一个开发杂记贴]]></title><description><![CDATA[Unity 打包后TileMap的碰撞体无法动态更新
<p dir="auto">游戏中有修改地形功能时（例如创造或炸毁地块）需要在运行时更新TileMap，在编辑器中碰撞体可以随之更新，而打包后却无法更新，这种情况需要在对应Sprite导入设置中勾选Read/Write Enabled选项，如果是图集，则勾选图集的该选项：<br />
813d9b41-72f8-4738-ba16-0e4bf7991391-image.png</p>
]]></description><link>http://designhub.top/topic/30/一个开发杂记贴</link><guid isPermaLink="true">http://designhub.top/topic/30/一个开发杂记贴</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 24 Mar 2023 05:50:59 GMT</pubDate></item><item><title><![CDATA[[Unity]时间控制插件Chronos的基本使用与原理分析]]></title><description><![CDATA[<p dir="auto">时间控制在游戏中是一类常见的功能，例如菜单里的暂停、倍速，再如《武士 零》中的慢动作、倒带等时间系能力。最近初步尝试了一款时间控制插件Chronos，网上相关的中文资料比较少，不知道会不会踩坑，总之记录一下使用笔记，并结合源码对其原理做些简单的分析。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/01/UB9Pdm1w6Zq4JeA.gif" alt class=" img-responsive img-markdown" /><br />
<img src="https://s2.loli.net/2023/03/01/MbYh1u4HwTiA7eQ.gif" alt class=" img-responsive img-markdown" /><br />
<img src="https://s2.loli.net/2023/03/01/uabTm4dgkvP3INW.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">与《武士 零》中可以预知未来、操控时间的药物“柯罗诺斯”一样，这款插件也以古希腊神话中的时间神命名，通过它可以控制游戏中的时间流速，实现倍速、暂停、时光倒流，同时它还提供针对单个物体、一组物体和指定区域内的时间控制。</p>
<p dir="auto">从插件描述可以看出，虽然不能预知未来，但只要不是太复杂的时间控制功能，它基本都可以实现。</p>
<p dir="auto">插件从2020年6月开始永久免费，在Asset Store下载并在Unity中导入即可：<br />
<a href="https://assetstore.unity.com/packages/tools/particles-effects/chronos-31225" rel="nofollow ugc">https://assetstore.unity.com/packages/tools/particles-effects/chronos-31225</a></p>
<h1>设计理念</h1>
<p dir="auto">在使用之前，先来了解它的设计理念。假设现在要做一个正经的塔防游戏“今夜圆饼”，游戏有如下要求：</p>
<ol>
<li>游戏时间与用户界面时间互不影响，游戏可以暂停、倍速，而用户界面始终保持正常速度。</li>
<li>每类物体（敌人、防御塔、玩家角色）的时间统一受游戏时间影响并且可以单独调整，它们之间互不影响。</li>
<li>每个物体的时间也可以单独调整，比如某种环境效果，让区域内的敌我单位加/减速。</li>
<li>时间流速改变时，动画、粒子效果等的速度要一同变化。</li>
</ol>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/XumiNExRdJPy9Vl.png" alt class=" img-responsive img-markdown" /><br />
<img src="https://s2.loli.net/2023/03/03/ElZhtU6DYsoXMQ2.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">针对上述需求，Chronos给出了这样的结构：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/MUyJgrCFip9c768.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><strong>Timekeeper</strong></p>
<p dir="auto">作为根节点，管理场景中的所有时钟，每个场景中只需要一个Timekeeper，是单例模式。</p>
<p dir="auto"><strong>Global Clock</strong></p>
<p dir="auto">管理一组物体的时间，根节点下分为了Root与Interface两个时钟，Root用来管理游戏时间，Interface用来管理用户界面时间。Root时钟下又有Enemies、Turrets和Player时钟，Root时钟的流速改变时，这三个时钟都会随之改变。</p>
<p dir="auto"><strong>Local Clock</strong></p>
<p dir="auto">与Global Clock类似，Local Clock用来管理单个物体的时间，对于玩家角色只存在一个的情况，Local Clock更为合适。</p>
<p dir="auto"><strong>Timeline</strong></p>
<p dir="auto">可以理解为时钟的具体实施者，Timeline组件挂在各个游戏物体上，改变它们的时间流速。</p>
<p dir="auto"><strong>Area Clock</strong></p>
<p dir="auto">改变区域内物体的时间流速，比如宣传图里的时间结界。</p>
<p dir="auto">以上就是Chronos的核心组件，使用时添加好对应的组件就行了，比如这里的正经塔防游戏引入Chronos的步骤：</p>
<ol>
<li>创建一个空物体，添加Timekeeper组件。<br />
<img src="https://s2.loli.net/2023/03/03/HOs6fVbFjWzBv1y.png" alt class=" img-responsive img-markdown" /></li>
<li>在同一物体上继续添加Global Clock组件，设置好它们的Key与父子关系。<br />
<img src="https://s2.loli.net/2023/03/03/lXSg3YuTjrvLdE9.png" alt class=" img-responsive img-markdown" /></li>
<li>如果有玩家角色（萝卜之类的），在玩家物体上添加Local Clock组件。</li>
<li>给防御塔、敌人、玩家角色物体添加Timeline组件，设置好对应的时钟。<br />
<img src="https://s2.loli.net/2023/03/03/pbvuzaEAXNZOTQj.png" alt class=" img-responsive img-markdown" /></li>
<li>在脚本中获取对应的时钟，改变它的timeScale来调整时间流速。</li>
</ol>
<pre><code class="language-C#">clock.localTimeScale = value;
</code></pre>
<p dir="auto">运行效果（并不是塔防）：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/01/RuzoM6h3DyWqT24.gif" alt class=" img-responsive img-markdown" /><br />
<img src="https://s2.loli.net/2023/03/01/4JDy5AeXQ2N6anh.gif" alt class=" img-responsive img-markdown" /><br />
<img src="https://s2.loli.net/2023/03/01/T5MxguraRD4ZwBv.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">这样就搞定了，是不是很简单，那么本篇笔记就到这里，最后祝您身体健康，再见。</p>
<h1>深入使用</h1>
<p dir="auto">上面是<a href="https://ludiq.io/chronos/tutorial" rel="nofollow ugc">官方教程</a>中介绍的使用步骤，在项目中引入这个插件确实很简单，但个人更关心这些问题：</p>
<ol>
<li>插件的作用范围，它支持哪些组件，如何配合使用，有哪些限制。</li>
<li>对于自己的脚本以及不支持的组件，如何进行扩展。</li>
<li>插件的实现原理，性能如何。</li>
</ol>
<h2>作用范围</h2>
<p dir="auto">从自带的示例可以看出，Chronos支持的Unity组件有Rigidbody、Animator、Nav Mesh Agent、Particle System、Audio Source等，在源码中可以看到它已适配的组件：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/UYKCuFqzVMLO4ga.png" alt class=" img-responsive img-markdown" /></p>
<h2>扩展</h2>
<h3>Timeline</h3>
<p dir="auto">在自己的脚本中引入时间控制，最基础的方法就是使用Timeline代替Unity的Time，比如原来使用Time.deltaTime，改为Timeline.deltaTime：</p>
<pre><code class="language-C#">private void Start()
{
    timeline = GetComponent&lt;Timeline&gt;();
}

private void Update()
{
    if (targetingCounter &gt; 0)
    {
        // targetingCounter -= Time.deltaTime;
        targetingCounter -= timeline.deltaTime;
        ...
    }
}
</code></pre>
<p dir="auto">如果用到了内置组件，同样用Timeline中的对应组件代替，比如rigidbody改为Timeline.rigidbody：</p>
<pre><code class="language-C#">// rb.velocity = targetDirection * targetSpeed;
timeline.rigidbody.velocity = targetDirection * targetSpeed;
</code></pre>
<p dir="auto">完整的替换表：</p>
<p dir="auto"><a href="https://ludiq.io/chronos/manual/migration" rel="nofollow ugc">https://ludiq.io/chronos/manual/migration</a></p>
<h3>Occurrence</h3>
<p dir="auto">不远处有个怪物生成点（比如刷怪笼）正在不停地刷怪，一大波僵尸正向我袭来，然后我灵机一动将敌方的时间流速改为了负二倍速，刷怪笼和一大波僵尸的时间都被逆转，它们回到了原来的位置——但仅限于此，僵尸并不会随着时光倒流而消失。</p>
<p dir="auto">为什么呢？Chronos会按设置的时间间隔，不断记录物体的各种信息，在时光倒流时进行回溯，但它并不会记录什么时候物体被生成，什么时候需要被销毁，这部分工作需要我们自己完成。</p>
<p dir="auto">为此Chronos提供了一个叫Occurrence的工具，通过Timeline调用，结构长这样：</p>
<pre><code class="language-C#">timeline.Do
(
    true, // 是否可重复执行
    delegate() // 前向操作
    {
        // 生成物体并返回它
    },
    delegate(object transfer) // 逆向操作
    {
        // 销毁对应的物体
    } 
);
</code></pre>
<p dir="auto">有点电影《信条》的感觉，这里前向操作与逆向操作是成对的，时间正常流转时执行前向操作，时间倒流时执行对应的逆向操作。</p>
<p dir="auto">使用示例：</p>
<pre><code class="language-C#">private void Start()
{
    timeline = GetComponent&lt;Timeline&gt;();
    StartCoroutine(Spawn());
}
private IEnumerator Spawn()
{
    while (true)
    {
        timeline.Do(
            true, // 允许重复执行
            () =&gt;
            {
                // 前向操作
                if (num &gt;= 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) =&gt;
            {
                // 逆向操作
                if (gameObject != null)
                {
                    Destroy(gameObject);
                    num--;
                }
            });
        yield return new WaitForSeconds(spawnInterval);
    }
}
</code></pre>
<p dir="auto">使用Timeline与Occurrence，可以满足一些简单的需求，对于更复杂的情况，比如物体数值与状态的记录与回溯，则可能需要做一套完整的适配。目前个人项目需求比较简单，暂时没到这一步，之后如果有做相关的扩展再来补充。</p>
<h2>一些坑</h2>
<p dir="auto">Chronos并不是万能的，官网列出了它的限制：</p>
<p dir="auto"><a href="https://ludiq.io/chronos/manual/limitations" rel="nofollow ugc">https://ludiq.io/chronos/manual/limitations</a></p>
<p dir="auto">如果粒子系统需要支持时间回溯，其中的限制可能影响较大：</p>
<ul>
<li>低速(小于0.25倍速)时粒子系统可能会卡顿。</li>
<li>粒子系统的模拟空间只能是本地。</li>
<li>不支持粒子的碰撞检测。</li>
</ul>
<p dir="auto">这些问题主要是由粒子系统的Simulate方法引起的，后面的原理分析中会提到。可以说大部分限制的原因都来自引擎底层，看了下插件的最后更新时间，这些问题多半是不会修复了。</p>
<p dir="auto">除了上面提到的限制，个人在使用过程中也遇到了一些问题：</p>
<ol>
<li>粒子系统如果勾选了Play On Awake，运行时会报错：</li>
</ol>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/TZY6i7Dk5RryVxb.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">这是由于Chronos在初始化时会将粒子系统的随机种子改为固定的值，而此时粒子已经开始播放了，Unity不支持这个操作所以报错。可以取消勾选Play On Awake，初始化完后再调用播放，注意是通过timeline的particleSystem来播放，不能直接调用。</p>
<pre><code class="language-C#">timeline.particleSystem.Play();
</code></pre>
<ol start="2">
<li>
<p dir="auto">并不能直接影响Shader中的时间速度，需要另外适配。</p>
</li>
<li>
<p dir="auto">如果有时间回溯功能，在倒带时需要注意屏蔽玩家控制，避免引起冲突，比如角色控制脚本中，仅在timeScale为正时开启玩家控制。</p>
</li>
</ol>
<h1>原理分析</h1>
<p dir="auto">只是粗略看了一下源码，可能会有一些分析得不对的地方。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/Y1SDW5iKyeoLtcn.jpg" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">Timekeeper、Global Clock的树形结构以及对Timeline的管理比较好理解，这部分就不看了，更值得关注的是Timeline是如何控制组件的时间流速的，这里从Timeline的源码开始阅读。</p>
<p dir="auto">在Timeline的父类TimelineEffector中，定义了一堆它已适配的组件类，每个类与Unity的内置组件相对应：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/297RAiXSTjHsr3I.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">这些XXXTimeline均继承自ComponentTimeline类，实现IComponentTimeline接口，虽然这里也命名为组件，但它们不继承Unity的Component，仅持有对应Unity组件的引用，可以看作是Unity组件的一层包装。</p>
<p dir="auto">在Awake中，调用CacheComponents方法，获取当前物体上挂载的Unity组件，将其包装成对应的Timeline组件，初始化并存入components列表中：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/5JKRenEDpmxCQ6v.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">注意这里的注释，如果运行过程中添加或移除了物体上相关的Unity组件，则需要重新调用一次这个方法。</p>
<p dir="auto">Timeline组件的初始化方法Initialize中只有一个CopyProperties的调用：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/LJKXSTEwbDq4mBt.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">CopyProperties的实现因组件而异，通常其中会记录一些与时间相关的参数，例如Animator组件记录播放速度、Audio Source组件记录音高。</p>
<p dir="auto">初始化中针对一些组件有特殊的处理逻辑，比如Rigidbody与Transform，具体可以看源码，这里就不过多介绍了。</p>
<p dir="auto">Start或OnEnable中，Timeline将应用对应时钟的时间流速timescale，并调用所有组件的AdjustProperties方法：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/5peuhJaM6Vnj821.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">AdjustProperties中应用时间流速，例如Animator组件调整播放速度、Nav Mesh Agent组件调整移动速度与转向速度、粒子系统调整simulationSpeed等等。</p>
<p dir="auto">常用的事件函数中调用所有组件的对应事件函数：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/NUq5mywMK7hsGjb.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">对大部分内置组件来说，光是调整速度还无法做到时间倒流的效果，Chronos对不同的组件采用了不同的解决方法。</p>
<p dir="auto"><strong>Animator</strong></p>
<p dir="auto">Animator是相对简单的一个，将它的播放速度设置为负数就可以倒放了。在时间正常流转时，调用Animator的StartRecording录制，在时间倒流时，倒放之前的录制结果。</p>
<p dir="auto"><strong>Transform、Rigidbody</strong></p>
<p dir="auto">对于Transform，Chronos用了一个自定义的RecorderTimeline组件，RigidbodyTimeline组件同样继承于它。时间正常流转时，按设置的录制间隔将物体的位置、旋转等信息(缩放默认被注释了，需要可以自己打开)录制成Snapshot并缓存起来，在时间倒流时逐个应用这些Snapshot。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/cZBtJEikbeG6AoH.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><strong>Particle System</strong></p>
<p dir="auto">根据是否要支持时间回溯，Chronos将粒子系统组件分为两个，NonRewindableParticleSystemTimeline与RewindableParticleSystemTimeline。</p>
<p dir="auto">不可回溯的实现很简单，根据Timeline的timeScale调整粒子系统的simulationSpeed即可；</p>
<p dir="auto">可回溯的粒子系统是通过<a href="https://docs.unity3d.com/2020.3/Documentation/ScriptReference/ParticleSystem.Simulate.html" rel="nofollow ugc">Simulate</a>方法来实现的，Simulate方法可以让粒子系统立即达到到指定时间点的状态。时间正常流转时，记录粒子系统的播放状态（启用、禁用、播放、暂停），在倒带时还原这些播放状态。</p>
<p dir="auto">Simulate也带来了上面提到的问题：</p>
<ol>
<li>低速卡顿，<a href="https://fogbugz.unity3d.com/default.asp?694191_dso514lin4rf5vbg" rel="nofollow ugc">这个问题</a>Unity从2015年到现在都没修复，但个人测试感觉不太明显，处于可接受的范围。</li>
<li>不断调用导致模拟空间不断更新，所以粒子的模拟空间仅限本地。</li>
<li>不支持粒子的碰撞检测。</li>
</ol>
<p dir="auto"><strong>总结</strong></p>
<p dir="auto">从源码中可以得知，Chronos初始化时需要对游戏物体上的Unity组件做一层包装，常用的事件函数（Start、OnEnable、FixedUpdate、Update、OnDisable）中会遍历所有包装组件并调用相关方法，在Timeline的Update中还包含对Occurrence的处理等等。如果要支持时间回溯，在游戏运行时需要对某些组件的状态进行录制，不同组件占用的内存空间不同。</p>
<p dir="auto">如果自己的脚本需要完全接入Chronos，可以像上面哪些组件一样，继承ComponentTimeline，并加入到Timeline的初始化过程中。</p>
<p dir="auto">对于各组件中的录制功能，Timeline提供了统一的参数配置，可以调整录制间隔与录制的最大时长，并会给出预计消耗的内存：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/i3PWmwdHfD8OYvF.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">如果游戏不需要时间回溯功能，那么可以取消勾选Rewindable以节省性能。</p>
<p dir="auto">具体的性能测试还没有做，大概率鸽了。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/9FWgYvf5ExtzmjH.jpg" alt class=" img-responsive img-markdown" /></p>
]]></description><link>http://designhub.top/topic/29/unity-时间控制插件chronos的基本使用与原理分析</link><guid isPermaLink="true">http://designhub.top/topic/29/unity-时间控制插件chronos的基本使用与原理分析</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 24 Mar 2023 05:29:28 GMT</pubDate></item><item><title><![CDATA[[Unity]🌿🌿🌿🌿🌿🌿]]></title><description><![CDATA[<p dir="auto"><img src="https://s2.loli.net/2023/03/04/nAC21MHB5fdJrDK.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">本篇笔记包含草的常见制作方式介绍，以及对GitHub上一个教科书级仓库的分析与学习。</p>
<p dir="auto">接上篇大批量物体渲染学习笔记，老是拿一堆方块做实验实在是太无聊了，所以在做遮挡剔除之前不如先来生草吧。</p>
<p dir="auto">草的实现方式多种多样，网上也有很多相关文章教程，个人了解的有星形结构、广告牌、几何着色器等，每种方式各有优缺点。</p>
<h1>星形结构</h1>
<p dir="auto">星形结构是相对便宜的一种方式，为了确保在不同观察角度下都能有较密集的效果，这种草的模型通常做成若干个面片相互穿插的样式，模型的顶点数较少，性能开销相对较小；缺点是从草的上方往下看时容易穿帮，可以通过增加插片面数改善。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/86HWajeOqcSbifv.jpg" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">不同LOD下的模型，越近面数越多，细节也越丰富：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/97kuwnVaQ1dABce.jpg" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">使用商店中草模型的实现效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/tzhJ5ZGxASuTnYX.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">对于这种草的批量渲染，在大批量物体渲染学习笔记（二）中已经介绍过了，只需依葫芦画瓢修改草的Shader、配置好Renderer的各项参数即可：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/gQm7Up1uIjFRx5n.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">LOD暂时没实现，以后慢慢填坑。</p>
<p dir="auto">关于星形结构，更详细的介绍可以参考《GPU 精粹》：</p>
<p dir="auto"><a href="https://developer.nvidia.com/gpugems/gpugems/part-i-natural-effects/chapter-7-rendering-countless-blades-waving-grass" rel="nofollow ugc">https://developer.nvidia.com/gpugems/gpugems/part-i-natural-effects/chapter-7-rendering-countless-blades-waving-grass</a></p>
<h1>广告牌</h1>
<p dir="auto">很多人在初学Unity时应该就接触到了广告牌(Billboarding)技术，粒子系统的默认渲染模式就是它。与星形结构相比，广告牌的思路更加直接：既然要兼顾每个角度的效果，那么干脆一直朝着相机。运用这种思路，让每颗草在渲染时都始终朝着相机方向，不同观察角度下都能有很好的效果。</p>
<p dir="auto">GitHub上ColinLeung-NiloCat大神的这个仓库演示了通用渲染管线下广告牌草的实现：</p>
<p dir="auto"><a href="https://github.com/ColinLeung-NiloCat/UnityURP-MobileDrawMeshInstancedIndirectExample" rel="nofollow ugc">https://github.com/ColinLeung-NiloCat/UnityURP-MobileDrawMeshInstancedIndirectExample</a></p>
<p dir="auto">它的运行效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/IY6szR2BHilQerX.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">具体有多牛就不吹了，可以去看简介，这里主要记录对这个仓库的学习过程，根据个人需求有一些改动，但思路是差不多的。</p>
<p dir="auto">整体实现思路：</p>
<ol>
<li>在一个区域内，生成并记录大量草的位置。</li>
<li>在CPU侧对草进行粗粒度的剔除。</li>
<li>在GPU侧对草进行细粒度的剔除。</li>
<li>使用Graphics.DrawMeshInstancedIndirect渲染。</li>
<li>加一些特技。</li>
</ol>
<p dir="auto">虽说是广告牌草，但广告牌的实现并不是重点（因为比较简单），大批量渲染中如何优化性能更为重要，这里先侧重介绍仓库中的优化技巧，也就是上面提到的剔除步骤。</p>
<h2>位置生成</h2>
<p dir="auto">生成方式多种多样，可以是随机生成，也可以是通过刷草工具手刷。我的实现方式是在一个区域内，以一定的密度（分辨率）对区域内的点采样柏林噪声，判断是否符合植被生成条件（采样值超过阈值），符合则记录当前点的坐标，并通过射线取得高度。使用这种方法生成的树木：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/7gQIV3XEqmMkfPd.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">当然最简单的做法，直接在区域内随机就行了，只做demo可以不用搞这么花里胡哨。</p>
<h2>视锥剔除</h2>
<p dir="auto">原仓库中，视锥剔除分为两步：粗粒度的CPU侧剔除与细粒度的GPU侧剔除。大致思路：</p>
<ol>
<li>将整个渲染区域在逻辑上分为大小均等、顺序排列的若干块，每块包含若干颗草。</li>
<li>先对每个分块的包围盒做视锥剔除，记录下可见的分块，这一步在CPU侧进行。</li>
<li>再对可见分块中的草做视锥剔除，得到最终需要渲染的草，这一步在GPU侧进行。</li>
</ol>
<p dir="auto">这种方式有效地减轻了GPU的压力，并且通过调整分块的大小，可以让剔除工作更侧重于CPU或GPU。</p>
<h3>分块</h3>
<p dir="auto">原仓库的分块并没有考虑到草的y轴，即只适用于平面情况，所以我重写了这部分。整个分块逻辑总结为一张图：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/2ej3g4y7EFBRxu8.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">图中为xz平面，RenderBounds为渲染区域，Cell为分块，里面看起来像草的东西是草，数字是它们在数组中的下标，某些分块中可能存在没有草的情况。下面结合代码解释其具体含义。</p>
<p dir="auto">首先需要获得所有草的位置与渲染区域（RenderBounds），同时为了兼容先前的星形结构，定义了这样一个渲染父类：</p>
<p dir="auto"><strong>ObjectRenderer.cs</strong></p>
<pre><code class="language-C#">public abstract class ObjectRenderer : MonoBehaviour
{
    public abstract void SetRenderBounds(Vector3 min, Vector3 max);
    
    public virtual void UpdatePositions(List&lt;Vector3&gt; list) { }
}
</code></pre>
<p dir="auto">这两个方法分别让外部传入渲染区域的边界（最小点与最大点）及更新要渲染的物体的位置。上一步中已经准备好了渲染区域，在外部调用SetRenderBounds方法设置；已经生成好了所有草的位置，则调用UpdatePositions将它们传入。</p>
<p dir="auto">一般来说广告牌草只用到位置，星形草如果要旋转自身以贴合地面，则可以使用另一个方法来更新变换矩阵：</p>
<pre><code class="language-C#">public virtual void UpdateTransforms(List&lt;Matrix4x4&gt; list) { }
</code></pre>
<p dir="auto">新建一个GrassRenderer继承它，并实现这两个方法：</p>
<p dir="auto"><strong>GrassRenderer.cs</strong></p>
<pre><code class="language-C#">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);
    }
}
</code></pre>
<p dir="auto">renderBounds为渲染区域，根据设定的分块大小将其划分，计算出分块（cell）数量。</p>
<p dir="auto">然后准备分块，定义分块结构体：</p>
<pre><code class="language-C#">private struct Cell
{
    // 分块的包围盒
    public Bounds bounds;   
    // 分块中第一个物体在allPositions中的下标
    public int index; 
    // 分块中的物体数量  
    public int count;   
}
</code></pre>
<p dir="auto">这里的index对应上面图中Cell中第一颗草的编号，count对应分块中草的数量。</p>
<p dir="auto">在UpdatePositions方法中，外部传入了所有草的位置，将每个位置放到其所在分块中：</p>
<p dir="auto"><strong>GrassRenderer.cs</strong></p>
<pre><code class="language-C#">// 所有分块
private Cell[] cells;
// 排序后的所有草位置
private Vector3[] allPositions;

public override void UpdatePositions(List&lt;Vector3&gt; list)
{
    // ①将所有物体分块
    // positionsInCell用来存放每个分块中包含的物体位置下标
    List&lt;int&gt;[] positionsInCell = new List&lt;int&gt;[cellCount.x * cellCount.z];
    for (int i = 0; i &lt; 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&lt;int&gt;();
        positionsInCell[index].Add(i);
    }
    ...
}
</code></pre>
<p dir="auto">然后按将所有位置排序成上图所示，并初始化所有分块数据：</p>
<p dir="auto"><strong>GrassRenderer.cs</strong></p>
<pre><code class="language-C#">public override void UpdatePositions(List&lt;Vector3&gt; list)
{
    // ①将所有物体分块
    ...
    // ②按分块重新排序
    // 排好序后的所有物体位置存放到新的数组中
    allPositions = new Vector3[list.Count];
    // 分块数组存放所有分块数据
    cells = new Cell[cellCount.x * cellCount.z];
    for (int i = 0, index = 0; i &lt; 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&rsqb;&rsqb;, Vector3.zero);
            for (int j = 0; j &lt; positions.Count; j++, index++)
            {
                allPositions[index] = list[positions[j&rsqb;&rsqb;;
                bounds.Encapsulate(list[positions[j&rsqb;&rsqb;);
            }
            cells[i].bounds = bounds;
        }
    }
    // ③更新Buffer
    UpdateBuffers();
}
</code></pre>
<p dir="auto">方法中最后一步是熟悉的更新Buffer，和之前笔记中的差不多，就不重复说明了，之后文章末尾会更新完整代码地址。</p>
<h3>粗粒度剔除</h3>
<p dir="auto">分好块后，就可以在每一帧进行剔除工作了。Cell中记录了它的包围盒信息，使用Unity提供的API进行AABB测试，判断分块是否在视野内：</p>
<p dir="auto"><strong>GrassRenderer.cs</strong></p>
<pre><code class="language-C#">private List&lt;int&gt; visibleCells = new List&lt;int&gt;();
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 &lt; cells.Length; i++)
    {
        if (cells[i].count == 0)
            continue;
        if (GeometryUtility.TestPlanesAABB(cameraFrustumPlanes, 
            cells[i].bounds))
            visibleCells.Add(i);
    }
}
</code></pre>
<h3>细粒度剔除</h3>
<p dir="auto">得到当前可见的分块后，使用ComputeShader对可见分块中的每颗草做视锥剔除。上一篇笔记中提到，像草这种较小的物体，可以通过对它在裁剪空间下的齐次坐标进行判断来做剔除，这里采用的也是这种方法：</p>
<p dir="auto"><strong>GrassCulling.compute</strong></p>
<pre><code class="language-C#">#pragma kernel CSMain

float4x4 _VPMatrix; // VP矩阵
float _MaxDrawDistance; // 最大绘制距离
uint _StartOffset; // 物体位置的起始下标
StructuredBuffer&lt;float3&gt; _AllPositionsBuffer;
AppendStructuredBuffer&lt;uint&gt; _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 &lt;= absPosCS.w
        &amp;&amp; absPosCS.y &lt;= absPosCS.w * 1.5
        &amp;&amp; absPosCS.x &lt;= absPosCS.w * 1.1
        &amp;&amp; absPosCS.w &lt;= _MaxDrawDistance)
        _VisibleIDsBuffer.Append(id.x + _StartOffset);
}
</code></pre>
<p dir="auto">剔除是按分块进行的，所以需要_StartOffset来指定当前是对哪个分块内的草做剔除，对应上面Cell结构体中的index。</p>
<p dir="auto">调用ComputeShader得到剔除后的草并渲染:</p>
<p dir="auto"><strong>GrassRenderer.cs</strong></p>
<pre><code class="language-C#">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 &lt; visibleCells.Count; i++)
    {
        var cell = cells[visibleCells[i&rsqb;&rsqb;;
        var startOffset = cell.index;
        var jobLength = cell.count;
        // 如果下一个可见分块在内存上是连续的，则合并处理
        while ((i &lt; visibleCells.Count - 1)
                &amp;&amp; (visibleCells[i + 1] == visibleCells[i] + 1))
        {
            jobLength += cells[visibleCells[i&rsqb;&rsqb;.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);
}
</code></pre>
<p dir="auto">在Scene面板中可以看到明显的分块效果，白线是相机的视锥体：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/XiYoeSlmTsW3NU1.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">这一篇先写到这里，由于原仓库的草Shader有许多硬编码与Magic number，牙口不好啃起来有些吃力，下一篇（如果有）来学习草的Shader、交互与风吹草浪。</p>
<p dir="auto">关于开头提到的几何着色器(Geometry Shader)方式，推荐一篇文章：</p>
<p dir="auto"><a href="https://roystan.net/articles/grass-shader" rel="nofollow ugc">https://roystan.net/articles/grass-shader</a></p>
<p dir="auto">大致的思路是利用几何着色器生成草叶，通过曲面细分着色器丰富草的密度。</p>
]]></description><link>http://designhub.top/topic/28/unity</link><guid isPermaLink="true">http://designhub.top/topic/28/unity</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 24 Mar 2023 05:28:50 GMT</pubDate></item><item><title><![CDATA[[Unity]简易传送门效果]]></title><description><![CDATA[<p dir="auto">使用ShaderGraph连连看与粒子系统制作一个简易的传送门效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/01/zBVEkWNtceH98iy.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">整体思路：</p>
<ol>
<li>通过对纹理的扭曲与旋转，并挖空中间与边缘部分，实现传送门图形</li>
<li>加上Bloom后处理、粒子与点光源</li>
</ol>
<h1>图形部分</h1>
<p dir="auto">项目安装并配置好通用渲染管线（URP），新建一个Lit Shader Graph:</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/Pf9Xsqw4OuazD6F.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">我使用的URP版本为10.6.0，暂时没升级到新版本，不过只要思路一致，新版本的操作应该也差不了太多。</p>
<p dir="auto">打开新建的ShaderGraph，传送门需要透明显示，并且前后两面都要渲染，在Graph Settings修改这两个配置项：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/C8Mkp4FEURZ3nhe.png" alt class=" img-responsive img-markdown" /></p>
<h2>旋转与扭曲</h2>
<p dir="auto">先来做纹理的旋转与扭曲。依然是用Voronoi节点，其他的纹理也可以，好看就行。Twirl节点可以将纹理旋转扭曲，Strength参数决定了扭曲的力度：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/5ENaVZIr43K9Ud7.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">通过调整Twirl的Offset参数可以让它动起来，新建一个TwirlSpeed属性，值先随便填个0.2，与Time节点的值相乘连入Offset，顺便把其他要在外部调整的属性一并创建好，并在Node Settings中设置好它们的值：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/IQ18LuFYyhprNUv.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/01/t9Bmc3XHNDjKe1s.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">观察可以发现，这样的旋转扭曲在视觉上并不是统一往一个方向流动。个人觉得这个效果不错，但还不是非常OK，需要让整个图像真正地旋转起来。</p>
<p dir="auto">新建一个Rotate节点，并新建一个名为RotateSpeed的Float属性（值为2），将其与Time相乘，然后连到Rotate节点的Rotation参数中，之后Rotate节点的输出连到Twirl节点的UV输入：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/01/k71ejL93WcmlouM.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">这部分就完成了，将TwirlSpeed临时调为0，可以更清楚地看到旋转效果。</p>
<h2>遮罩</h2>
<p dir="auto">接下来挖空中间与边缘部分，需要用到一张纹理做遮罩。新建一个名为Mask的Texture2D属性，设置默认值为自带的Default-Particle纹理：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/oJmfH2xzLQwvY7F.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">用Sample Texture 2D节点对其采样，并用Voronoi的输出减去采样结果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/1Ppys3biL8MtEBX.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">中间出现了空洞，但不够明显，对Mask的采样结果做进一步处理，先乘一个正数扩大中心，再用指数函数减小边缘：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/Ks1qtP3B4CYMARk.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">如果希望这部分可以在外部调整，可以将两个Float变量转换为属性。</p>
<p dir="auto">在挖空边缘之前，先来控制一下图形中纹理的溶解程度。新建一个Dissolve的Float属性（值为2），将Subtract节点的输出做一个Saturate，然后用指数函数控制溶解：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/JTtf1jbWRHImq5C.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">因为之前做了减操作，可能会出现负数，Saturate用来将输入限制在0到1范围内，避免Power节点出错。</p>
<p dir="auto">溶解这一步不是很有必要（我后来又把Dissolve改回1了），如果希望能调整条纹的溶解程度以实现不同的效果可以加上。</p>
<p dir="auto">接下来挖空边缘，依然是利用Mask，将采样结果与另一个正数相乘，让它变得大一些：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/okbXgVvm1MN7AzR.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">再与纹理部分相乘：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/SVLHWrtGOwiQYea.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">边缘挖空也完成了。</p>
<h2>收尾</h2>
<p dir="auto">最后染色并查看效果，新建一个Color属性，模式设置为HDR，这样才能闪闪发亮，调一个喜欢的颜色（如果太亮了就把强度调低点）：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/PNO3sCi64cfWJB5.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">混合颜色并输出到Fragment：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/oBOQyVxhrckdI6C.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">以这个ShaderGraph新建一个材质：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/zEADjb1FVkmY7Nq.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">场景中新建Global Volume，开启Bloom后处理：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/SyELnzGFDfb1u2K.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">相机的后处理也记得开一下：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/a4EoXuLgjPAev9f.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">场景中新建一个Quad，拖入材质可以看到效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/01/ylqOrHXNt1BDa5s.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">如果没在动的话，把Always Refresh勾上：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/qEPf9rAdxWUYZhl.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">调整后的各项属性值：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/Qld4kKpCtfBxEXO.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">可以尝试不同的属性值，以达到不同的效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/01/3nfCtXkQ9TiGIKx.gif" alt class=" img-responsive img-markdown" /><br />
<img src="https://s2.loli.net/2023/03/01/DfZTFUnJPkoX1AQ.gif" alt class=" img-responsive img-markdown" /><br />
<img src="https://s2.loli.net/2023/03/01/MtpFKqTDjxVhmr5.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">完整的ShaderGraph:</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/hcye5oGJDAdEjCq.png" alt class=" img-responsive img-markdown" /></p>
<h1>加一些特技</h1>
<p dir="auto">粒子效果基本照抄油管上印度小哥的那个教程，根据个人需求有一些修改。</p>
<h2>环绕粒子</h2>
<p dir="auto">单独的环绕粒子效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/01/yNUK3VJdDwuPREB.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">环绕粒子需要发光，新建一个粒子材质，Shader选择Universal Render Pipeline/Particles/Unlit，按如下配置：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/YEOCmDg12IvzLdj.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">在Quad下新建一个Particle System，Renderer中拖入刚才创建的粒子材质，其他大致这样配置：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/MyQWc2H9jUqD8pe.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">粒子的配置比较随意，根据个人喜好可以有不同的配法。这里为了让粒子环绕中心旋转，用的是甜甜圈形状，如果运动要更随机一些，可以再加上Noise。</p>
<h2>背景阴影</h2>
<p dir="auto"><img src="https://s2.loli.net/2023/03/01/2GxjJLT6rsYkBbz.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">这个感觉可加可不加，比较简单就不说明了。</p>
<p dir="auto">最终效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/01/zBVEkWNtceH98iy.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">一些传送门的模型会给出特效所在的Mesh，这种情况下只要将传送门材质拖上去，适当调整粒子的形状即可。另外记得关闭阴影投射，避免影子穿帮：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/apyWq5DFMYmuzdh.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/01/LAJb2TRrkxuwigI.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">项目中用到的素材：</p>
<p dir="auto"><a href="https://skfb.ly/6YKz6" rel="nofollow ugc">Portal</a> by Vetech82</p>
<p dir="auto"><a href="https://skfb.ly/ooOoC" rel="nofollow ugc">Sand portal</a> by Yarelon</p>
<p dir="auto">一个题外话，mac上如果出现ShaderGraph编辑界面卡顿掉帧的情况，可以尝试在Player设置中临时使用OpenGLCore图形API并重启编辑器，ShaderGraph会变得很丝滑，但缺点是运行游戏时可能会死机。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/6U5eiRwdxtqDGl4.png" alt class=" img-responsive img-markdown" /></p>
]]></description><link>http://designhub.top/topic/27/unity-简易传送门效果</link><guid isPermaLink="true">http://designhub.top/topic/27/unity-简易传送门效果</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 24 Mar 2023 05:27:47 GMT</pubDate></item><item><title><![CDATA[[Unity]大批量物体渲染学习笔记（二）]]></title><description><![CDATA[<p dir="auto"><img src="https://s2.loli.net/2023/03/04/cZtPSJY6IKsjuMo.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">上一篇使用Graphics.DrawMeshInstancedIndirect实现了基本的物体渲染，但还没有做剔除，相机视野外的物体也会被渲染，造成性能上的浪费。这一篇来做剔除方案中常见的视锥剔除，并顺便实现物体的旋转与缩放。</p>
<p dir="auto">简单来说，视锥剔除就是判断物体是否在当前相机的视锥体内，排除掉完全在视锥体外的物体，仅渲染视锥体内的物体，减少不必要的消耗。需要强调的是，只有在使用类似DrawMeshInstancedIndirect这样的API做渲染时，才需要自己做剔除工作，用自带的Renderer组件渲染物体时Unity会帮我们做这些处理。</p>
<p dir="auto">关于视锥剔除如何实现以及为什么要用ComputeShader做视锥剔除，推荐一篇文章：</p>
<p dir="auto"><a href="https://zhuanlan.zhihu.com/p/376801370" rel="nofollow ugc">Unity中使用ComputeShader做视锥剔除（View Frustum Culling）</a></p>
<p dir="auto">大佬的文章讲得很详细，包括视锥剔除的原理、ComputeShader如何使用、如何根据物体的包围盒进行剔除等等，可以说是保姆级教学了。</p>
<p dir="auto">所以这里仅仅记录个人的实现和踩坑过程，由于需要顺便实现物体的旋转与缩放，会有一些不同之处。最终效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/Nri5heLEIHYBbGt.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">整体思路并不复杂，每一帧我们需要做这些事：</p>
<ol>
<li>获取当前相机视锥的六个面的定义，即平面方程Ax+By+Cz+D=0，可以自己计算，也可以通过API获取。</li>
<li>获取每个物体的包围盒，通常包围盒的大小可以是一个定值，判断时再根据物体当前变换（平移、旋转、缩放）计算包围盒八个点的实际坐标。</li>
<li>把上面的东西扔到CoumputeShader里计算，判断哪些物体在视锥体内，返回这些物体的instanceID。</li>
<li>根据返回的instanceID渲染，而不是渲染全部物体，这样渲染出来的就是剔除后的结果了。</li>
</ol>
<p dir="auto">除了上面文章中提到的方法之外，还有另一种剔除方法，即将包围盒顶点坐标换到裁剪空间下，判断每个点的齐次坐标的x、y、z是否在[-w, w]区间（DirectX则判断是否在[0, w]），如果八个点都不在视锥体内，则将其剔除。这个方法的计算量比上面的方法少一些，但缺点也很明显，如果当前相机正紧贴着一堵超大的墙，比如主角正在面壁思过，那么此时墙体包围盒的八个点都不在视锥体内，就会被剔除掉，所以这种方法适合较小的物体，本篇笔记中也会顺便实现这种方法。</p>
<h2>ComputeShader</h2>
<p dir="auto">先来实现第一种剔除方法，整个剔除过程基本上是围绕着ComputeShader进行，搞清楚输入与输出后，可以编写ComputeShader了：</p>
<p dir="auto"><strong>FrustumCulling.compute</strong></p>
<pre><code class="language-C#">#pragma kernel CSMain

float4 _FrustumPlanes[6];   // 视锥体的六个面
float3 _BoundMin;   // 物体包围盒最小点
float3 _BoundMax;   // 物体包围盒最大点
StructuredBuffer&lt;float4x4&gt; _AllMatricesBuffer;   // 所有物体的复合变换矩阵
AppendStructuredBuffer&lt;uint&gt; _VisibleIDsBuffer;  // 可见物体实例ID

bool IsOutsideThePlane(float4 plane, float3 position)
{
    return dot(plane.xyz, position) + plane.w &gt; 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 &lt; 6; i++)
    {
        for (int j = 0; j &lt; 8; j++)
        {
            float3 p = boundPoints[j].xyz;
            if (!IsOutsideThePlane(_FrustumPlanes[i], p))
                break;
            if (j == 7)
                return;
        }
    }
    
    _VisibleIDsBuffer.Append(id.x);
}
</code></pre>
<p dir="auto">基本照抄大佬文章中的ComputeShader，不同的是，这里使用了一个_VisibleIDsBuffer，如果物体被判断为可见，则将实例ID追加到其中，在随后的渲染Shader中，同样会使用它获取可见的物体的实例ID。</p>
<p dir="auto">ComputeShader中的变量值将在C#侧传入，CSMain函数也将在C#侧调用。</p>
<h2>输入与调用</h2>
<p dir="auto">接下来在C#侧把ComputeShader所需的数据准备好并调用。复制上一篇中的ExampleClass.cs，改名为FrustumCullingRenderer.cs，加上所需要的字段：</p>
<p dir="auto"><strong>FrustumCullingRenderer.cs</strong></p>
<pre><code class="language-C#">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];
    ...
</code></pre>
<p dir="auto">其中物体包围盒与ComputeShader在编辑器里设置，比如默认的Cube是1个单位大小，那么包围盒可以这样设置：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/Su9r6TWDMItX4hQ.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">世界空间下的包围盒坐标的计算还需要用到物体的复合变换矩阵（平移矩阵、旋转矩阵、缩放矩阵），上一篇中的positionBuffer在这里升级成了allMatricesBuffer，在ComputeShader中，我们用这个矩阵将物体包围盒的顶点坐标由模型空间转换到世界空间下。</p>
<p dir="auto">在ComputeShader中计算完剔除后，需要将所有可见物体的实例ID返回，这里使用visibleIDsBuffer来接收。</p>
<p dir="auto">修改UpdateBuffers方法，给allMatricesBuffer与visibleIDsBuffer赋值：</p>
<pre><code class="language-C#">void UpdateBuffers()
{
    // 不需要更新时返回
    ...
    // 规范subMeshIndex
    ...
    // 修改：物体位置 改为 物体复合变换矩阵
    allMatricesBuffer?.Release();
    allMatricesBuffer = new ComputeBuffer(instanceCount, sizeof(float) * 16);   // float4x4
    Matrix4x4[] trs = new Matrix4x4[instanceCount];
    for (int i = 0; i &lt; 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
    ...
</code></pre>
<p dir="auto">可以注意到，visibleIDsBuffer需要指定类型为ComputeBufferType.Append，表示在Shader中可以对它追加值，对应ComputeShader中的<a href="https://docs.microsoft.com/en-us/windows/win32/direct3dhlsl/sm5-object-appendstructuredbuffer" rel="nofollow ugc">AppendStructuredBuffer</a>类型，之后可见物体的实例ID将被追加到其中。</p>
<p dir="auto">随后把上面提到的东西都扔到CoumputeShader里：</p>
<pre><code class="language-C#">    ...
    // ComputeShader
    cullingComputeShader.SetVector("_BoundMin", objectBoundMin);
    cullingComputeShader.SetVector("_BoundMax", objectBoundMax);
    cullingComputeShader.SetBuffer(kernel, "_AllMatricesBuffer", allMatricesBuffer);
    cullingComputeShader.SetBuffer(kernel, "_VisibleIDsBuffer", visibleIDsBuffer);
    ...
</code></pre>
<p dir="auto">至此UpdateBuffers方法就修改完了，ComputeShader中还差最后一样东西——视锥的六个面，通常情况下相机的位置是会频繁发生变化的，所以在Update中来获取它们。</p>
<p dir="auto">这里直接用Unity提供的API获取：</p>
<pre><code class="language-C#">void Update()
{
    // 更新Buffer
    UpdateBuffers();
    // 方向键改变绘制数量
    ...
    // 视锥剔除
    GeometryUtility.CalculateFrustumPlanes(Camera.main,cameraFrustumPlanes);
    for (int i = 0; i &lt; cameraFrustumPlanes.Length; i++)
    {
        var normal = -cameraFrustumPlanes[i].normal;
        frustumPlanes[i] = new Vector4(normal.x, normal.y, normal.z, -cameraFrustumPlanes[i].distance);
    }
    ...
</code></pre>
<p dir="auto">GeometryUtility.CalculateFrustumPlanes可以计算指定相机的视锥面，需要注意的是，这样获取到的六个面是正面朝内的，而ComputeShader中是按照正面朝外计算的。要么修改ComputeShader，要么将六个面反向一下，这里选择反向，即改变法线与距离的符号，最终传入ComputeShader的是Vector4类型，放在frustumPlanes中。</p>
<p dir="auto">将视锥平面传入ComputeShader，可以调用计算了：</p>
<pre><code class="language-C#">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));
    // 渲染
    ...
}
</code></pre>
<p dir="auto">有三点需要注意：</p>
<ol>
<li>调用ComputeShader前，必须要使用visibleIDsBuffer.SetCounterValue(0)将计数器置为0，因为在ComputeShader中会不断将可见物体的实例ID追加到visibleIDsBuffer，如果不置为0，那电脑很可能就爆炸了（惨痛的教训）。</li>
<li>必须要使用ComputeBuffer.CopyCount将visibleIDsBuffer的长度写入到argsBuffer里，因为最终渲染用的还是argsBuffer。</li>
<li>如果ComputeShader同时在多个地方使用，比如渲染花花草草，那么需要用Instantiate方法将其分别实例化，每种物体的渲染各用一个实例。</li>
</ol>
<p dir="auto">至此C#部分修改完毕，最后修改渲染用的Shader。</p>
<h2>Shader</h2>
<p dir="auto">剔除相关的修改已经完成，Shader中没有多少要改的，只需要将_AllMatricesBuffer和_VisibleIDsBuffer加上并使用就行。复制上一篇的Shader，改名为InstancedCulling.shader，修改HLSLINCLUDE中的Buffer变量：</p>
<pre><code class="language-C#">HLSLINCLUDE
...
CBUFFER_START(UnityPerMaterial)
...
// 修改：所有物体的复合变换矩阵
StructuredBuffer&lt;float4x4&gt; _AllMatricesBuffer;
// 新增：可见物体实例ID
StructuredBuffer&lt;uint&gt; _VisibleIDsBuffer;
CBUFFER_END
...
ENDHLSL
</code></pre>
<p dir="auto">在顶点函数中使用：</p>
<pre><code class="language-C#">Varyings Vertex(Attributes IN, uint instanceID : SV_InstanceID)
{
    Varyings OUT;
    // 修改：顶点坐标转换到世界空间
    #if SHADER_TARGET &gt;= 45
    // float4 data = positionBuffer[instanceID];
    float4x4 data = _AllMatricesBuffer[_VisibleIDsBuffer[instanceID&rsqb;&rsqb;;
    #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;
}
</code></pre>
<p dir="auto">可以对比注释掉的部分，我们现在通过_VisibleIDsBuffer[instanceID]拿到剔除后的实例ID，再通过实例ID在_AllMatricesBuffer获取到物体的复合变换矩阵，用于将顶点坐标从模型空间转换到世界空间。</p>
<p dir="auto">由于加入了物体旋转，世界空间下的法线也需要用这个矩阵转换，需要注意的是这里的写法仅适用于统一缩放，即x、y、z的缩放都相同的情况，如果是非统一缩放，使用变换矩阵*法线会得到错误的结果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/IvCqWcglKhUAMxf.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">(图片来源：《Unity Shader入门精要》)</p>
<p dir="auto">这种情况下需要求得变换矩阵的逆矩阵，使用法线*逆矩阵得到正确结果，代码网上有很多，这里就不贴了。</p>
<p dir="auto">两个Pass都这样修改一下，运行效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/OAfkh7gKCx1be94.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/LId6pPFWqviVyZG.gif" alt class=" img-responsive img-markdown" /></p>
<h2>另一种剔除方法</h2>
<p dir="auto">来实现开头说的另一种剔除方法，通过对裁剪空间下的包围盒顶点齐次坐标进行判断来剔除。在ComputeShader中，只要将包围盒的八个点坐标转换到裁剪空间下再进行判断就行了，为了将坐标转换到裁剪空间，我们需要向ComputeShader传入当前的VP（观察投影）矩阵。</p>
<p dir="auto"><strong>FrustumCulling.compute</strong></p>
<pre><code class="language-C#">#pragma kernel CSMain

// float4 _FrustumPlanes[6];
float4x4 _MatrixVP; // 修改：观察投影矩阵
float3 _BoundMin;   // 物体包围盒最小点
float3 _BoundMax;   // 物体包围盒最大点
StructuredBuffer&lt;float4x4&gt; _AllMatricesBuffer;   // 所有物体的复合变换矩阵
AppendStructuredBuffer&lt;uint&gt; _VisibleIDsBuffer;  // 可见物体实例ID

bool IsInClipSpace(float4 coord)
{
    return -coord.w &lt;= coord.x &amp;&amp; coord.x &lt;= coord.w
        &amp;&amp; -coord.w &lt;= coord.y &amp;&amp; coord.y &lt;= coord.w
        &amp;&amp; -coord.w &lt;= coord.z &amp;&amp; coord.z &lt;= 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 &lt; 8; i++)
    {
        if (IsInClipSpace(boundPoints[i]))
        {
            isIn = true;
            break;
        }
    }

    if (isIn)
        _VisibleIDsBuffer.Append(id.x);
}
</code></pre>
<p dir="auto">得到MVP矩阵，将八个顶点转换到裁剪空间，如果有一个顶点在裁剪空间内，则视物体为可见。IsInClipSpace中是OpenGL的判断方式（-w~w），如果是DirectX则要判断是否在0~w之间。</p>
<p dir="auto">在C#中获取VP矩阵并传入ComputeShader即可:</p>
<p dir="auto"><strong>FrustumCullingRenderer.cs</strong></p>
<pre><code class="language-C#">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);
}
</code></pre>
<p dir="auto">这里让相机的两个矩阵相乘得出VP矩阵，更规范一点的做法应该是这样：</p>
<pre><code class="language-C#">var matrixVP = GL.GetGPUProjectionMatrix(Camera.main.projectionMatrix, false) * Camera.main.worldToCameraMatrix;
</code></pre>
<p dir="auto">这么做的原因在<a href="https://docs.unity3d.com/ScriptReference/Camera-projectionMatrix.html" rel="nofollow ugc">官方文档</a>中有提到：</p>
<blockquote>
<p dir="auto">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.</p>
</blockquote>
<blockquote>
<p dir="auto">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.</p>
</blockquote>
<p dir="auto">大概是说Unity中投影矩阵遵循OpenGL传统，但实际的运行平台不一定是OpenGL，要获得与平台匹配的投影矩阵，需要使用GL.GetGPUProjectionMatrix。<br />
我这里选择不用它，因为用了的话，ComputeShader中就要针对各API情况分别判断了，个人认为这一步不是很有必要，但没实际测试过，如有错误欢迎指出。</p>
<p dir="auto">得到的效果与第一种方法一致：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/MmPH7zbIeLCs9Vk.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><a href="https://gist.github.com/pmisu/ce962f558b7ecb63be73bea3b406cafc" rel="nofollow ugc">Demo代码地址</a></p>
<p dir="auto">至此视锥剔除基本完成了，当然有些细节还没有优化到位，下一篇先来做遮挡剔除。不过最近学习（摸鱼）的时间越来越少，也不知道要到猴年马月了。</p>
]]></description><link>http://designhub.top/topic/26/unity-大批量物体渲染学习笔记-二</link><guid isPermaLink="true">http://designhub.top/topic/26/unity-大批量物体渲染学习笔记-二</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 24 Mar 2023 05:27:12 GMT</pubDate></item><item><title><![CDATA[[Unity]大批量物体渲染学习笔记（一）]]></title><description><![CDATA[<p dir="auto"><img src="https://s2.loli.net/2023/03/04/FfkKa1dc3q4wHJS.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">最近摸鱼的时候打算对demo里的大面积草地做一些优化，顺便记一些笔记备忘。标题的大量物体特指场景中的重复物体，比如大片的草地、树林等等，它们数量极多，如果直接用GameObject的形式实现，电脑多半是要爆炸的。关于大量物体渲染网上已经有很多文章介绍，这里仅记录在通用渲染管线（URP）下的学习与实现过程，算是比较基础的部分，如有错误欢迎指正。</p>
<h2>怎么做</h2>
<p dir="auto">方案有很多，先来试试常用的GPU Instancing，用到的核心的API为<a href="https://docs.unity3d.com/ScriptReference/Graphics.DrawMeshInstancedIndirect.html" rel="nofollow ugc">Graphics.DrawMeshInstancedIndirect</a>，绘制部分基本围绕它展开。</p>
<p dir="auto">它还有个好兄弟<a href="https://docs.unity3d.com/ScriptReference/Graphics.DrawMeshInstanced.html" rel="nofollow ugc">Graphics.DrawMeshInstanced</a>，它们都能批量绘制网格，区别在于，好兄弟需要在每一帧将数据从CPU提交至GPU，单个批次有着1023的实例数量限制；而DrawMeshInstancedIndirect可以在GPU侧缓存数据，并且单个批次没有数量限制。</p>
<p dir="auto">在使用这个API时，Unity不会帮我们做视锥剔除与遮挡剔除，如果用它绘制十万颗草，不论草是否在视野内，都会被一视同仁统统绘制，也就是说剔除工作需要我们自己完成。</p>
<h2>官方示例</h2>
<p dir="auto">先按照官方示例写个Hello world，在<a href="https://docs.unity3d.com/ScriptReference/Graphics.DrawMeshInstancedIndirect.html" rel="nofollow ugc">文档</a>中，官方十分贴心地给出了绘制部分的代码，复制粘贴就能运行的那种。运行效果长这样，在场景中一口气绘制了十万个方块：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/G5xRmAdKufcSv2F.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">这里要在URP下实现，C#部分基本不需要改动，Shader部分需要重新写，顺便把阴影投射也加上，最终效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/8Re7IbFU4Wjoi5Z.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">大致流程：</p>
<ol>
<li>准备好物体的网格、材质，以及渲染用到的数据，比如十万份方块的位置与大小。</li>
<li>将数据设置到GPU缓冲区。</li>
<li>调用DrawMeshInstancedIndirect渲染。</li>
</ol>
<h3>C#部分</h3>
<p dir="auto">先看C#部分的一些变量：</p>
<p dir="auto"><strong>ExampleClass.cs</strong></p>
<pre><code class="language-C#">public int instanceCount = 100000;
public Mesh instanceMesh;
public Material instanceMaterial;
public int subMeshIndex = 0;
</code></pre>
<p dir="auto">分别是要绘制的物体数量、网格与材质，使用时在编辑器里赋值，像这样：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/V5nCHyDAbF2uE3S.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">再看其他的一些变量：</p>
<pre><code class="language-C#">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 };
</code></pre>
<p dir="auto">这里定义了两个ComputeBuffer，利用它们可以将数据传至GPU侧，positionBuffer用来存放所有物体的位置，argsBuffer则是DrawMeshInstancedIndirect绘制需要用到的参数，各项参数通过args变量存放。</p>
<p dir="auto">Update中的逻辑很简单，必要时更新ComputeBuffer，然后渲染：</p>
<pre><code class="language-C#">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);
}
</code></pre>
<p dir="auto">这一堆参数看得眼花，argsBuffer怎么赋值我们也还不清楚，所以先来看看UpdateBuffers中是怎样更新这些ComputeBuffer的：</p>
<pre><code class="language-C#">void UpdateBuffers()
{
    // 不需要更新时返回
    if ((cachedInstanceCount == instanceCount 
        || cachedSubMeshIndex != subMeshIndex)
        &amp;&amp; argsBuffer != null)
        return;
    // 规范subMeshIndex
    if (instanceMesh != null)
        subMeshIndex = Mathf.Clamp(subMeshIndex, 0, 
            instanceMesh.subMeshCount - 1);
    ...
</code></pre>
<p dir="auto">没啥好说的，接下来是对positionBuffer的初始化：</p>
<pre><code class="language-C#">    ...
    // 初始化位置Buffer
    if (positionBuffer != null)
        positionBuffer.Release();
    positionBuffer = new ComputeBuffer(instanceCount, sizeof(float) * 4);
    Vector4[] positions = new Vector4[instanceCount];
    for (int i = 0; i &lt; 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);
</code></pre>
<p dir="auto">可以看到ComputerBuffer的构造方法中需要指定数量与单个数据占用空间大小，这里物体的位置为Vector4类型，在Shader中对应float4，xyz分量存放坐标，w分量存放大小。之后为每个物体随机设置位置与大小，然后通过ComputerBuffer的SetData方法设置数据，最后设置到材质中，那么大致可以这样认为，经过这一步，每个物体的位置数据已经向GPU侧提交了。</p>
<p dir="auto">然后是对argsBuffer的初始化：</p>
<pre><code class="language-C#">    // 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;
}
</code></pre>
<p dir="auto">这个也没啥好说的，总之挨个赋对应的值就完事了（敷衍），通过设置instanceCount，argsBuffer将决定有多少实例会被渲染。</p>
<p dir="auto">回过头来看Update，基本上可以理解DrawMeshInstancedIndirect各个参数的意义了：</p>
<pre><code class="language-C#">void Update()
{
    ...
    // 渲染
    Bounds renderBounds = new Bounds(Vector3.zero, 
        new Vector3(100.0f, 100.0f, 100.0f));
    Graphics.DrawMeshInstancedIndirect(instanceMesh, subMeshIndex, 
        instanceMaterial, renderBounds, argsBuffer);
}
</code></pre>
<p dir="auto">我们需要传入绘制的网格(instanceMesh)、指定的子网格(subMeshIndex)、什么材质(instanceMaterial)、渲染的范围(renderBounds)，以及argsBuffer。</p>
<p dir="auto">可以发现并不需要传positionBuffer，因为它早在上一步就被设置到材质中了，只要物体的数量或者位置没有发生改变，就不需要再变动positionBuffer。这样Update中基本不存在耗时操作，虽然要绘制的实例数量很多，但只有在数据有变动时才要做循环。</p>
<h3>Shader部分</h3>
<p dir="auto">在C#部分，包含每个物体位置的positionBuffer已经设置到了材质中，那么在Shader中我们主要关心的是如何获取这些位置数据，官方给出的Shader中，可以看到positionBuffer的声明：</p>
<pre><code class="language-C">#if SHADER_TARGET &gt;= 45
    StructuredBuffer&lt;float4&gt; positionBuffer;
#endif
</code></pre>
<p dir="auto"><a href="https://docs.microsoft.com/en-us/windows/win32/direct3dhlsl/sm5-object-structuredbuffer" rel="nofollow ugc">StructuredBuffer</a>在Shader中是只读的，它将接收从C#传递过来的位置数据，需要注意这里的SHADER_TARGET &gt;= 45，说明这个功能最低支持的编译目标级别为4.5，即OpenGL ES 3.1。</p>
<blockquote>
<p dir="auto">关于Shader的编译目标级别可以参考<a href="https://docs.unity3d.com/Manual/SL-ShaderCompileTargets.html" rel="nofollow ugc">官方文档</a>。</p>
</blockquote>
<blockquote>
<p dir="auto"><a href="(https://zhuanlan.zhihu.com/p/358719772)">这篇文章</a>介绍了DrawMeshInstancedIndirect在真机上的兼容情况。</p>
</blockquote>
<p dir="auto">在顶点函数中使用positionBuffer：</p>
<pre><code class="language-C#">v2f vert (appdata_full v, uint instanceID : SV_InstanceID)
{
#if SHADER_TARGET &gt;= 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;
    ...
</code></pre>
<p dir="auto">通过SV_InstanceID语义获取当前的实例id，使用instanceID作为下标，就能从positionBuffer中获取到实例的位置数据了。这里的rotate2D函数让物体平行于xz面绕y轴旋转，旋转速度由物体大小决定；由于不存在其他变换，世界空间下的顶点坐标就等于模型空间下的坐标加上传入的坐标。</p>
<p dir="auto">了解Shader中都要做些什么后，可以依葫芦画瓢来写URP下的Shader了，这里也像官方示例中那样，实现物体公转、基础光照、阴影接收与自带雾效，再加上阴影投射。</p>
<p dir="auto">新建一个Shader：</p>
<p dir="auto"><strong>InstancedShader.shader</strong></p>
<pre><code class="language-C#">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
        }
    }
}
</code></pre>
<p dir="auto">定义需要用到的属性，纹理、颜色、高光反射系数与高光反射颜色：</p>
<p dir="auto"><strong>①</strong></p>
<pre><code class="language-C#">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)
}
</code></pre>
<p dir="auto">HLSLINCLUDE中放一些通用的代码，比如包含URP的一些库，通用的属性与函数等：</p>
<p dir="auto"><strong>②</strong></p>
<pre><code class="language-C#">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 &gt;= 45
StructuredBuffer&lt;float4&gt; 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
</code></pre>
<p dir="auto">positionBuffer需要和其他属性一样放在cbuffer块中。</p>
<p dir="auto">在UniversalForward Pass中计算光照、物体公转、雾效等等，需要加上相关的预处理指令：</p>
<p dir="auto"><strong>③</strong></p>
<pre><code class="language-C">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
...
</code></pre>
<p dir="auto">虽然加了额外光源关键字，但这里只计算了主光源。</p>
<p dir="auto">采用逐像素光照，雾效因子随便找个空位放一下，这里与法线放一起：</p>
<p dir="auto"><strong>③</strong></p>
<pre><code class="language-C++">...
#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;
};
...
</code></pre>
<p dir="auto">顶点函数：</p>
<p dir="auto"><strong>③</strong></p>
<pre><code class="language-C#">...
Varyings Vertex(Attributes IN, uint instanceID : SV_InstanceID)
{
    Varyings OUT;

    // 旋转与坐标变换
    #if SHADER_TARGET &gt;= 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;
}
...
</code></pre>
<p dir="auto">与示例中一样，根据传入的位置数据，计算出世界空间下的顶点坐标与裁剪空间下的顶点坐标。雾效因子使用ComputeFogFactor函数计算，与世界空间下的法线放在同一个变量中。</p>
<p dir="auto">片元函数：</p>
<p dir="auto"><strong>③</strong></p>
<pre><code class="language-C#">...
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
</code></pre>
<p dir="auto">获取带阴影衰减的主光源、计算漫反射、高光、环境光，最后混合雾效。</p>
<p dir="auto">至于ShadowCaster Pass就偷懒直接照抄ShadowCasterPass.hlsl中的代码，加上位置变换：</p>
<p dir="auto"><strong>④</strong></p>
<pre><code class="language-C#">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 &gt;= 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
</code></pre>
<p dir="auto">由于不需要Alpha裁剪，片元函数中直接省略掉了这一步。</p>
<p dir="auto">运行结果与官方示例差不多，有了阴影后看起更加自然：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/EzvmDbtYPpfJ5Z6.gif" alt class=" img-responsive img-markdown" /><br />
<img src="https://s2.loli.net/2023/03/04/8Re7IbFU4Wjoi5Z.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">如果是ShaderGraph连连看玩家，可以参考这个Gist:<a href="https://gist.github.com/ArieLeo/d7e6bc5485caa9ba99cd3a59d0f53404" rel="nofollow ugc">DrawMeshInstancedIndirect with ShaderGraph and URP</a>，小编亲自试了一下，发现效果还不错，敏感肌也能用：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/pKxBXDe56RGn8O2.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">到现在相当于把官方示例抄了一遍，仅实现了物体位置数据的传递，没有自身旋转和真正意义上的缩放，实际的草地或树林肯定没有这么规整；另外也还没有做剔除，视野内外的物体都会被渲染，白白消耗了性能。</p>
<p dir="auto">下一篇来实现物体的旋转、缩放，并用ComputeShader做视锥剔除。</p>
]]></description><link>http://designhub.top/topic/25/unity-大批量物体渲染学习笔记-一</link><guid isPermaLink="true">http://designhub.top/topic/25/unity-大批量物体渲染学习笔记-一</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 24 Mar 2023 05:26:34 GMT</pubDate></item><item><title><![CDATA[[Unity]带照明效果的2D激光束]]></title><description><![CDATA[<p dir="auto"><img src="https://s2.loli.net/2023/03/04/JZuF9TpSnVjDzaC.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">2D中激光束算是比较常见了，实现起来也较为简单，但为了让它能真正达到照明的效果还是得花些功夫，记录一下实现过程。思路基本参考油管上一个印度小哥的教程，有一些修改，小哥的连连看部分略显复杂，我简化了一下；小哥没有做照明效果，这里额外进行实现。</p>
<p dir="auto">视频在比比汗丽丽上有人搬运，不过机翻质量比较糟糕:<br />
<a href="https://www.bilibili.com/video/BV1pM4y1L7C6" rel="nofollow ugc">BV1pM4y1L7C6</a></p>
<p dir="auto">整体思路：</p>
<ul>
<li>使用Line Renderer制作激光束，用ShaderGraph制作激光的Shader。</li>
<li>使用类型为Freeform的Light 2D实现光照，通过代码动态修改光照形状。</li>
<li>加一些特技。</li>
</ul>
<h1>准备</h1>
<p dir="auto">要使用ShaderGraph做Shader，那么先得把它装好，这里使用统一渲染管线（URP），可以直接新建一个URP项目，也可以在Package Manager里安装然后配置。如果不知道URP是啥、不清楚要怎么配置，推荐看一下麦扣的保姆级教学：</p>
<p dir="auto"><a href="https://www.bilibili.com/video/BV1t54y1d7DW" rel="nofollow ugc">https://www.bilibili.com/video/BV1t54y1d7DW</a></p>
<h1>激光部分</h1>
<h2>材质</h2>
<p dir="auto">先进行连连看环节，制作激光的材质。新建一个Sprite Unlit Shader Graph，取名Laser。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/bqDyBUEl94pV5oa.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">激光的图形部分，对Voronoi节点在x轴上稍微拉伸，并且让它随时间在x轴上偏移，这样看起来会有一种电流的感觉：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/zeYqCarFQlVE4pu.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">什么是Voronoi？它是一种程序生成的纹理，节点输入UV可以控制对这张纹理的采样，这里自定义了两个参数，Scale用来调节这张纹理的缩放，Speed乘以时间控制纹理的移动。这里也可以根据需要使用其他的噪声纹理，好看就行。</p>
<p dir="auto">激光的边缘部分需要有柔和渐变，按照下图的效果，只要让颜色在y轴方向由0渐变到1再渐变到0即可，用sin函数可以达到这个效果，再用指数函数控制边缘的厚度，即pow(sin(uv.y * PI), Edge)：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/lSPOawWIV4pA8gt.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">将两者相乘，再与自定义的颜色参数Color混合得到最终效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/y7JHUsA3GuZkLXn.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">颜色模式为HDR，在Bloom后处理下会有不错的效果；另外个人觉得有透明度更好些，所以顺便连接了Alpha。</p>
<p dir="auto">完整的ShaderGraph：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/RgCcJYBtnQ1vzKi.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/UVXljGOmdSBLPCR.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">以这个Shader新建一个材质Laser，调整各项参数:</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/K54WfhajBZbnV2q.png" alt class=" img-responsive img-markdown" /></p>
<h2>Line Renderer</h2>
<p dir="auto">场景中新建一个名为Laser的物体，添加Line Renderer组件，拖入刚才的Laser材质；Texture Mode改为Tile，避免不同激光长度下拉伸不一致。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/aIJYRVn7kH26mQS.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">可以顺便在场景中新建一个Volume，开启Bloom后处理：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/Bgl7hCR31zNp8iG.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/BXWzZQaIy4cGmkR.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">临时修改一下Line Renderer的Positions，场景中可以看到效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/xkZ8iLyHw263pEN.png" alt class=" img-responsive img-markdown" /></p>
<h2>交互</h2>
<p dir="auto">接下来让它可以随着角色施法而改变位置，这里角色使用的是Asset Store里的一个小魔女素材，自带骨骼动画和控制脚本。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/yBs7viuD4Rag3b6.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">如果对制作2D骨骼动画不太了解，推荐看看麦扣的这套骨骼动画教程：</p>
<p dir="auto"><a href="https://www.bilibili.com/video/BV1w7411F7qb" rel="nofollow ugc">https://www.bilibili.com/video/BV1w7411F7qb</a></p>
<p dir="auto">期望效果是玩家点击鼠标左键，角色举起法杖，随后启用激光的LineRenderer组件，并设置它的起点与终点，向鼠标方向发射。编写激光脚本，并挂在Laser物体下：</p>
<p dir="auto"><strong>Laser2D.cs</strong></p>
<pre><code class="language-C#">[RequireComponent(typeof(LineRenderer))]
public class Laser2D : MonoBehaviour
{
    LineRenderer line;

    void Awake()
    {
        line = GetComponent&lt;LineRenderer&gt;();
        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);
    }
}
</code></pre>
<p dir="auto">之后将在角色控制脚本中调用这些方法。</p>
<p dir="auto">激光发射需要一个发射起始点，找到法杖的骨骼，在法杖头上添加发射点FirePoint：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/oHvPEDlyVTgIajJ.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">做一个施法的骨骼动画，并在最后一帧添加动画事件，触发角色脚本中的<code>OnCastAnim</code>方法：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/jxPbSdCkT7gJGZI.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">修改原有的角色控制脚本SimplePlayerController.cs，加入施法相关代码：</p>
<p dir="auto"><strong>PlayerController.cs</strong></p>
<pre><code class="language-C#">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);
    }
}
</code></pre>
<p dir="auto">这部分比较简单就不详细说明了，具体可以看注释，总之就是先这样这样，然后再那样那样。</p>
<p dir="auto">Laser物体放到角色之下，为各个变量赋好值，可以看到初步效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/FXU2joxEGKNQ1VY.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/qCILzgmvti39yr5.gif" alt class=" img-responsive img-markdown" /></p>
<h1>光照部分</h1>
<p dir="auto">在后处理Bloom效果下，这道激光看起来熠熠生辉，然而它并不能照亮周围的物体与场景，为了让激光具有照明效果，还需要添加光源。</p>
<h2>动态修改光照形状</h2>
<p dir="auto">给Laser物体添加一个Light 2D脚本，Light Type为Freeform，点击Edit Shape按钮可以编辑它的形状：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/nS8fR5JvLQd7to2.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/tAKOdZFDW8b7xIY.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">由于激光的形状会不断变化，固定的形状不能满足要求，因此需要在代码中根据激光当前的形状动态地修改Light 2D的形状。</p>
<h3>SetShapePath</h3>
<p dir="auto">然而翻了一下API文档，Unity似乎并没有打算将形状属性开放给开发者修改，唯一和形状相关的属性只有一个shapePath，只允许get：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/tdCJUBOhSkw915o.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">更新：URP13.0(Unity2022.1)版本后，新增了官方的SetShapePath方法，不需要再用反射去设置形状了：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/e5w9Ri1VuYZXscl.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">与此同时还在用2020版本的我：<br />
<img src="https://s2.loli.net/2023/03/03/pVy5bEzScIDa3HF.jpg" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">如果你用的是新版，那么使用官方提供的SetShapePath就行，可以直接跳到下一节；如果你汗我一样还在用低版本，那么不妨看看我的解决思路，其实也不复杂。</p>
<p dir="auto">去Light 2D的源码中找找蛛丝马迹，在Light2DShape.cs中可以看到，shapePath被定义在Light2D的一个部分类中：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/kSb5nq1yZp6Bx4o.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">m_ShapePath在Light2D.cs中的UpdateMesh方法中被使用：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/3ZbpLmawkFNUYGD.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">可以看到当光照类型为Freeform时，它将根据m_ShapePath更新光照的mesh。</p>
<p dir="auto">继续阅读源码可知，UpdateMesh方法在Awake、光照类型改变、Falloff改变、多边形光形状改变及Cookie的Sprite改变时会被调用，而光照类型为Freeform时形状改变的情况下不会被调用，这意味着更改m_ShapePath后，必须要手动调用UpdateMesh方法，否则光照的形状不会被更新。</p>
<p dir="auto">回到Laser2D.cs，编写一个SetShapePath方法，通过它来更新Light 2D的形状：</p>
<p dir="auto"><strong>Laser2D.cs</strong></p>
<pre><code class="language-C#">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);
}
</code></pre>
<p dir="auto">通过反射获取到m_ShapePath，设值之后再调用UpdateMesh方法。但有一点需要注意，反射的性能是相对较差的，除非不得已最好不要频繁调用。</p>
<h3>构造形状并设置</h3>
<p dir="auto">继续编写，加入根据起点与终点更改Light2D形状的处理：</p>
<p dir="auto"><strong>Laser2D.cs</strong></p>
<pre><code class="language-C#">[RequireComponent(typeof(LineRenderer))]
public class Laser2D : MonoBehaviour
{
    [Tooltip("光照半径")]
    public float lightRadius = .5f;

    LineRenderer line;
    Light2D lit;

    void Awake()
    {
        line = GetComponent&lt;LineRenderer&gt;();
        lit = GetComponent&lt;Light2D&gt;();
        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);
        }
    }
    ...
}
</code></pre>
<p dir="auto">这里将起点和终点转化为本地坐标（Light 2D形状使用本地坐标），分别给它们加、减一个方向相对于激光方向垂直向上、模长为光照半径的向量<code>localUp</code>，计算出四个顶点，且按逆时针顺序排列，形成一个矩形，运行可以看到初步效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/3ipx7UJZ46bagsu.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">白色粗框为动态生成的形状，白色细框为Light 2D根据形状自动生成的Falloff区域。</p>
<h2>完善形状</h2>
<p dir="auto">光是一个矩形还是难看了些，光照的边角看起来相当突兀。再给两边加上半圆，形成一个类似胶囊的形状。</p>
<p dir="auto">画圆本质上是画多边形，先定义好圆的顶点数量：</p>
<p dir="auto"><strong>Laser2D.cs</strong></p>
<pre><code class="language-C#">[RequireComponent(typeof(LineRenderer))]
public class Laser2D : MonoBehaviour
{

    [Tooltip("圆的顶点数")]
    public int circleVertices = 10;
    ...
</code></pre>
<p dir="auto">删去原有构造矩形代码，改为构造胶囊形状：</p>
<p dir="auto"><strong>Laser2D.cs</strong></p>
<pre><code class="language-C#">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 &lt; 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 &lt; circleVertices / 2; i++)
        {
            theta += deltaAngle;
            path[++index] = localEnd + new Vector3(lightRadius * Mathf.Cos(theta), lightRadius * Mathf.Sin(theta), 0);
        }

        SetShapePath(lit, path);
    }
}
</code></pre>
<p dir="auto">简单的初中数学，就不过多解释了，效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/4UwldykIEaPHSrX.png" alt class=" img-responsive img-markdown" /></p>
<h2>处理翻转</h2>
<p dir="auto">当她转身朝向另一面时，光照显示会有错误：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/tGa7bV1wRm5HIL8.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">原因是翻转后光照Mesh的顶点顺序错误，此时需要逆序一下，继续修改形状生成部分，加入对翻转的处理：</p>
<p dir="auto"><strong>Laser2D.cs</strong></p>
<pre><code class="language-C#">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 &lt; 0)
        {
            deltaAngle = -deltaAngle;
            axisAngleOffset = -axisAngleOffset;
        }
        // 当前圆上顶点对应角度
        ...
        // 起点处的半圆
        ...
        // 终点处的半圆
        ...
        // 处理翻转情况，将所有顶点倒序
        if (transform.lossyScale.x &lt; 0)
            System.Array.Reverse(path);
        SetShapePath(lit, path);
    }
}
</code></pre>
<p dir="auto">修复后：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/zqdZyPuAX1mo2pj.gif" alt class=" img-responsive img-markdown" /></p>
<h1>加一些特技</h1>
<p dir="auto">按小哥教程中的做法，加上一些粒子效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/it2zeyFdUXf6Qo7.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">至此就基本完成了，还可以继续完善如发射时的粒子爆发、亮度变化、激光颜色设置等等。</p>
<p dir="auto">Demo项目地址：</p>
<p dir="auto"><a href="https://github.com/pmisu/2D-Laser" rel="nofollow ugc">https://github.com/pmisu/2D-Laser</a></p>
<p dir="auto">项目中用到的素材：</p>
<p dir="auto"><a href="https://assetstore.unity.com/packages/2d/characters/cute-2d-girl-wizard-155796" rel="nofollow ugc">Cute 2D Girl - Wizard</a> by <a href="https://assetstore.unity.com/publishers/45049" rel="nofollow ugc">ClearSky</a></p>
<p dir="auto"><a href="https://maaot.itch.io/2d-browncave-assets" rel="nofollow ugc">2D DarkCave Assets</a> by <a href="https://maaot.itch.io/" rel="nofollow ugc">Maaot</a></p>
]]></description><link>http://designhub.top/topic/24/unity-带照明效果的2d激光束</link><guid isPermaLink="true">http://designhub.top/topic/24/unity-带照明效果的2d激光束</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 24 Mar 2023 05:25:39 GMT</pubDate></item><item><title><![CDATA[[Unity]为了更好用的后处理——扩展URP后处理踩坑记录]]></title><description><![CDATA[<p dir="auto"><img src="https://s2.loli.net/2023/03/04/R6lHZLy2MceD7zE.png" alt="扩展URP后处理踩坑记录" class=" img-responsive img-markdown" /></p>
<h1>更新(2023.2.16)</h1>
<p dir="auto">已初步适配至Unity 2021 URP 12.1.x版本，仓库地址：<a href="https://github.com/PamisuMyon/pamisu-kit-unity" rel="nofollow ugc">pamisu-kit-unity</a>，测试场景为<code>Assets/Examples/CustomPostProcessing/Scenes/</code> 中的<code>CustomPP3D</code> 与 <code>CustomPP2D</code>。<br />
依然是本篇文章中的实现思路，只是稍微修改了后处理效果渲染的相关RT。<br />
<img src="https://s2.loli.net/2023/03/04/a1t4UGYAIQcwfFe.gif" alt="自定义后处理效果-3D" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/HQzSBvZ4b5upscU.png" alt="自定义后处理效果-2D" class=" img-responsive img-markdown" /></p>
<p dir="auto">由于时间有限，没有修改得很完善，也没有充分测试，只测试了打包PC端的情况，并且大部分后处理组件的插入点都在<code>RenderPassEvent.AfterRenderingPostProcessing</code>。如果有不正确的地方欢迎指出。</p>
<hr />
<h1>原文</h1>
<p dir="auto">在目前2020.x版本，URP下的自定义后处理依然是通过Renderer Feature来实现，比起以前的PPSV2麻烦了不少，看着隔壁HDRP的提供的自定义后处理组件，孩子都快馋哭了。既然官方暂时没有提供，那么就自己先造一个解馋，对标HDRP的自定义后处理，目标效果是只需简单继承，就能添加自定义后处理组件。编写过程中踩了不少坑，但对URP的源码也有了初步的了解。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/O2oUndIVJ3LGwY8.gif" alt="效果" class=" img-responsive img-markdown" /><br />
<img src="https://s2.loli.net/2023/03/04/CIRraPc6fOgGoTV.gif" alt="效果" class=" img-responsive img-markdown" /><br />
<img src="https://s2.loli.net/2023/03/04/gxy3ACnkl9FRmBZ.png" alt="Volume组件" class=" img-responsive img-markdown" /></p>
<p dir="auto">实现过程：</p>
<ul>
<li>封装自定义后处理组件基类，负责提供渲染方法、插入点设置等，并显示组件到Volume的Add Override菜单中。</li>
<li>实现后处理Renderer Feature，获取所有自定义组件，根据它们的插入点分配到不同的Render Pass。</li>
<li>实现后处理Render Pass，管理并调用自定义组件的渲染方法。</li>
<li>适配2D场景下的自定义后处理。</li>
</ul>
<p dir="auto">类关系：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/2gh7QDt39rp14JH.png" alt class=" img-responsive img-markdown" /></p>
<h1>后处理组件基类</h1>
<p dir="auto">首先要确保自定义的后处理组件能显示在Volume的Add Override菜单中，阅读源码可知，让组件出现在这个菜单中并没有什么神奇之处，只需继承<code>VolumeComponent</code>类并且添加<code>VolumeComponentMenu</code>特性即可，而VolumeComponent本质上是一个ScriptableObject。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/DybpV1Ji4ngZ5Td.png" alt="Volueme的Add Override菜单" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/fFdlb9gyJO1UHZs.png" alt="Bloom.cs" class=" img-responsive img-markdown" /></p>
<p dir="auto">那么就可以定义一个<code>CustomVolumeComponent</code>作为我们所有自定义后处理组件的基类：</p>
<p dir="auto"><strong>CustomVolumeComponent.cs</strong></p>
<pre><code class="language-C#">public abstract class CustomVolumeComponent : VolumeComponent, IPostProcessComponent, IDisposable
{
    ...
}
</code></pre>
<p dir="auto">通常希望后处理在渲染过程中能有不同的插入点，这里先提供三个插入点，天空渲染之后、内置后处理之前、内置后处理之后：</p>
<pre><code class="language-C#">/// 后处理插入位置
public enum CustomPostProcessInjectionPoint
{
    AfterOpaqueAndSky, BeforePostProcess, AfterPostProcess
}
</code></pre>
<p dir="auto">在同一个插入点可能会存在多个后处理组件，所以还需要一个排序编号来确定谁先谁后：</p>
<pre><code class="language-C#">public abstract class CustomVolumeComponent : VolumeComponent, IPostProcessComponent, IDisposable
{
    /// 在InjectionPoint中的渲染顺序
    public virtual int OrderInPass =&gt; 0;

    /// 插入位置
    public virtual CustomPostProcessInjectionPoint InjectionPoint =&gt; CustomPostProcessInjectionPoint.AfterPostProcess;
}
</code></pre>
<p dir="auto">然后定义一个初始化方法与渲染方法，渲染方法中，将CommandBuffer、RenderingData、渲染源与目标都传入：</p>
<pre><code class="language-C#">/// 初始化，将在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() =&gt; false;
#endregion
</code></pre>
<p dir="auto">最后是<code>IDisposable</code>接口的方法，由于渲染可能需要临时生成材质，在这里将它们释放：</p>
<pre><code class="language-C#">#region IDisposable
public void Dispose()
{
    Dispose(true);
    GC.SuppressFinalize(this);
}

/// 释放资源
public virtual void Dispose(bool disposing) {}
#endregion
</code></pre>
<p dir="auto">后处理组件基类就完成了，随便写个类继承一下它，Volume菜单中已经可以看到组件了：</p>
<p dir="auto"><strong>TestVolumeComponent.cs</strong></p>
<pre><code class="language-C#">[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()
    {
    }
}
</code></pre>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/vbAPHe9sRq2hInE.png" alt class=" img-responsive img-markdown" /></p>
<h1>Renderer Feature与Render Pass</h1>
<p dir="auto">好看吗？就让你们看看，不卖。URP并不会调用它的渲染方法（毕竟本来就没有），这部分需要自己实现，所以还是得祭出Renderer Feature。</p>
<p dir="auto">官方示例中，一个Renderer Feature对应一个自定义后处理效果，各个后处理相互独立，好处是灵活自由易调整；坏处也在此，相互独立意味着每个效果都可能要开临时RT，耗费资源比双缓冲互换要多，并且Renderer Feature在Renderer Data下，相对于场景中的Volume来说在代码中调用起来反而没那么方便。</p>
<p dir="auto">那么这里的思路便是将所有相同插入点的后处理组件放到同一个Render Pass下渲染，这样就可以做到双缓冲交换，又保持了Volume的优势。</p>
<h2>获取自定义后处理组件</h2>
<p dir="auto">先来写Render Pass，在里面定义好刚才写的自定义组件列表、Profiling所需变量，还有渲染源、目标与可能会用到的临时RT：</p>
<p dir="auto"><strong>CustomPostProcessRenderPass.cs</strong></p>
<pre><code class="language-C#">public class CustomPostProcessRenderPass : ScriptableRenderPass
{
    List&lt;CustomVolumeComponent&gt; volumeComponents;   // 所有自定义后处理组件
    List&lt;int&gt; activeComponents; // 当前可用的组件下标

    string profilerTag;
    List&lt;ProfilingSampler&gt; profilingSamplers; // 每个组件对应的ProfilingSampler

    RenderTargetHandle source;  // 当前源与目标
    RenderTargetHandle destination;
    RenderTargetHandle tempRT0; // 临时RT
    RenderTargetHandle tempRT1;

    /// &lt;param name="profilerTag"&gt;Profiler标识&lt;/param&gt;
    /// &lt;param name="volumeComponents"&gt;属于该RendererPass的后处理组件&lt;/param&gt;
    public CustomPostProcessRenderPass(string profilerTag, List&lt;CustomVolumeComponent&gt; volumeComponents)
    {
        this.profilerTag = profilerTag;
        this.volumeComponents = volumeComponents;
        activeComponents = new List&lt;int&gt;(volumeComponents.Count);
        profilingSamplers = volumeComponents.Select(c =&gt; new ProfilingSampler(c.ToString())).ToList();

        tempRT0.Init("_TemporaryRenderTexture0");
        tempRT1.Init("_TemporaryRenderTexture1");
    }

    ...
}
</code></pre>
<p dir="auto">构造方法中接收这个Render Pass的Profiler标识与后处理组件列表，以每个组件的名称作为它们渲染时的Profiling标识。</p>
<p dir="auto">Renderer Feature中，定义三个插入点对应的Render Pass，以及所有自定义组件列表，还有一个用于后处理之后的的RenderTargetHandle，这个变量之后会介绍：</p>
<p dir="auto"><strong>CustomPostProcessRendererFeature.cs</strong></p>
<pre><code class="language-C#">/// &lt;summary&gt;
/// 自定义后处理Renderer Feature
/// &lt;/summary&gt;
public class CustomPostProcessRendererFeature : ScriptableRendererFeature
{
    // 不同插入点的render pass
    CustomPostProcessRenderPass afterOpaqueAndSky;
    CustomPostProcessRenderPass beforePostProcess;
    CustomPostProcessRenderPass afterPostProcess;

    // 所有自定义的VolumeComponent
    List&lt;CustomVolumeComponent&gt; components;

    // 用于after PostProcess的render target
    RenderTargetHandle afterPostProcessTexture;
    ...
}
</code></pre>
<p dir="auto">那么要如何拿到所有自定义后处理组件，这些组件是一开始就存在，还是必须要从菜单中添加之后才存在？暂且蒙在鼓里。<br />
通常可以通过<code>VolumeManager.instance.stack.GetComponent</code>方法来获取到VolumeComponent，那么去看看VolumeStack的源码：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/5lnW2jdGzpEKAPa.png" alt="VolumeStack.cs" class=" img-responsive img-markdown" /></p>
<p dir="auto">它用一个字典存放了所有的VolumeComponent，并且在<code>Reload</code>方法中根据<code>baseTypes</code>参数创建了它们，遗憾的是这是个internal变量。再看VolumeMangager中，<code>CreateStack</code>方法与<code>CheckStack</code>方法对<code>Reload</code>方法进行了调用：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/RN69WALrZ2tQXd8.png" alt="VolumeManager.cs" class=" img-responsive img-markdown" /></p>
<p dir="auto">在<code>ReloadBaseTypes</code>中对<code>baseComponentTypes</code>进行了赋值，可以发现它包含了所有VolumeComponent的非抽象子类类型：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/TWONJVZfwu4ev9g.png" alt="VolumeManager.cs" class=" img-responsive img-markdown" /></p>
<p dir="auto">看到这里可以得出结论，所有后处理组件的实例一开始便存在于默认的VolumeStack中，不管它们是否从菜单中添加。并且万幸的是，<code>baseComponentTypes</code>是一个public变量，这样就不需要通过粗暴手段来获取了。</p>
<p dir="auto">接着编写CustomPostProcessRendererFeature的<code>Create</code>方法，在这里获取到所有的自定义后处理组件，并且将它们根据各自的插入点分类并排好序，放入到对应的Render Pass中：</p>
<p dir="auto"><strong>CustomPostProcessRendererFeature.cs</strong></p>
<pre><code class="language-C#">// 初始化Feature资源，每当序列化发生时都会调用
public override void Create()
{
    // 从VolumeManager获取所有自定义的VolumeComponent
    var stack = VolumeManager.instance.stack;
    components = VolumeManager.instance.baseComponentTypes
        .Where(t =&gt; t.IsSubclassOf(typeof(CustomVolumeComponent)) &amp;&amp; stack.GetComponent(t) != null)
        .Select(t =&gt; stack.GetComponent(t) as CustomVolumeComponent)
        .ToList();

    // 初始化不同插入点的render pass
    var afterOpaqueAndSkyComponents = components
        .Where(c =&gt; c.InjectionPoint == CustomPostProcessInjectionPoint.AfterOpaqueAndSky)
        .OrderBy(c =&gt; c.OrderInPass)
        .ToList();
    afterOpaqueAndSky = new CustomPostProcessRenderPass("Custom PostProcess after Opaque and Sky", afterOpaqueAndSkyComponents);
    afterOpaqueAndSky.renderPassEvent = RenderPassEvent.AfterRenderingOpaques;

    var beforePostProcessComponents = components
        .Where(c =&gt; c.InjectionPoint == CustomPostProcessInjectionPoint.BeforePostProcess)
        .OrderBy(c =&gt; c.OrderInPass)
        .ToList();
    beforePostProcess = new CustomPostProcessRenderPass("Custom PostProcess before PostProcess", beforePostProcessComponents);
    beforePostProcess.renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;

    var afterPostProcessComponents = components
        .Where(c =&gt; c.InjectionPoint == CustomPostProcessInjectionPoint.AfterPostProcess)
        .OrderBy(c =&gt; 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");
}
</code></pre>
<p dir="auto">依次设置每个Render Pass的renderPassEvent，对于AfterPostProcess插入点，renderPassEvent为<code>AfterRendering</code>而不是<code>AfterRenderingPostProcessing</code>，原因是如果插入到<code>AfterRenderingPostProcessing</code>，无法确保渲染输入源为<code>_AfterPostProcessTexture</code>，查看两种情况下的帧调试器：</p>
<p dir="auto">插入到AfterRenderingPostProcess：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/snU9eHQcCmuqgdW.png" alt="插入到AfterRenderingPostProcess" class=" img-responsive img-markdown" /></p>
<p dir="auto">插入到AfterRendering：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/FhuDoJgsiflTnUq.png" alt="插入到AfterRendering" class=" img-responsive img-markdown" /></p>
<p dir="auto">对比二者，可以发现插入点之前的<code>Render PostProcessing Effects</code>的RenderTarget会不一样，并且在插入到AfterRendering的情况下，还会多出一个FinalBlit，而FinalBlit的输入源正是<code>_AfterPostProcessTexture</code>：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/rNRy3ZfAiq28Cop.png" alt="FinalBlit" class=" img-responsive img-markdown" /></p>
<p dir="auto">所以定义<code>afterPostProcessTexture</code>变量的目的便是为了能获取到<code>_AfterPostProcessTexture</code>，并再次渲染到它。</p>
<p dir="auto">现在已经拿到了所有自定义后处理组件，下一步就可以开始初始化它们了。在这之前，记得重写<code>Dispose</code>方法做好资源释放，避免临时创建的材质漏得到处都是：</p>
<p dir="auto"><strong>CustomPostProcessRendererFeature.cs</strong></p>
<pre><code class="language-C#">protected override void Dispose(bool disposing)
{
    base.Dispose(disposing);
    if (disposing &amp;&amp; components != null)
    {
        foreach(var item in components)
        {
            item.Dispose();
        }
    }
}
</code></pre>
<h2>初始化</h2>
<p dir="auto">上面在CustomPostProcessRenderPass中定义了一个变量<code>activeComponents</code>来存储当前可用的的后处理组件，在Render Feature的<code>AddRenderPasses</code>中，需要先判断Render Pass中是否有组件处于激活状态，如果没有一个组件激活，那么就没必要添加这个Render Pass，这里调用先前在组件中定义好的Setup方法初始化，随后调用IsActive判断其是否处于激活状态：</p>
<p dir="auto"><strong>CustomPostProcessRenderPass.cs</strong></p>
<pre><code class="language-C#">/// &lt;summary&gt;
/// 设置后处理组件
/// &lt;/summary&gt;
/// &lt;returns&gt;是否存在有效组件&lt;/returns&gt;
public bool SetupComponents()
{
    activeComponents.Clear();
    for (int i = 0; i &lt; volumeComponents.Count; i++)
    {
        volumeComponents[i].Setup();
        if (volumeComponents[i].IsActive())
        {
            activeComponents.Add(i);
        }
    }
    return activeComponents.Count != 0;
}
</code></pre>
<p dir="auto">当一个Render Pass中有处于激活状态的组件时，说明它行，很有精神，可以加入到队列中，那么需要设置它的渲染源与目标：</p>
<p dir="auto"><strong>CustomPostProcessRenderPass.cs</strong></p>
<pre><code class="language-C#">/// &lt;summary&gt;
/// 设置渲染源和渲染目标
/// &lt;/summary&gt;
public void Setup(RenderTargetHandle source, RenderTargetHandle destination)
{
    this.source = source;
    this.destination = destination;
}
</code></pre>
<p dir="auto">之后在CustomPostProcessRendererFeature的<code>AddRenderPasses</code>方法中调用这两个方法，通过则将Render Pass添加：</p>
<p dir="auto"><strong>CustomPostProcessRendererFeature.cs</strong></p>
<pre><code class="language-C#">// 你可以在这里将一个或多个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);
        }
    }
}
</code></pre>
<p dir="auto">至此Renderer Feature类中的所有代码就写完了，接下来继续在Render Pass中实现渲染。</p>
<h2>执行渲染</h2>
<p dir="auto">编写Render Pass中渲染执行的方法<code>Execute</code>：</p>
<pre><code class="language-C#">// 你可以在这里实现渲染逻辑。
// 使用&lt;c&gt;ScriptableRenderContext&lt;/c&gt;来执行绘图命令或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 &amp;&amp; !destination.HasInternalRenderTargetId())
    {
        cmd.GetTemporaryRT(destination.id, descriptor);
    }

    // 执行每个组件的Render方法
    // 如果只有一个组件，则直接source -&gt; 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 &lt; 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);
}
</code></pre>
<p dir="auto">这里如果写得再简洁一些应该是可以只需要source和destination两个变量就行。需要注意某些情况下<code>_AfterPostProcessTexture</code>可能不存在，所以添加了手动获取RT的处理。如果不做这一步可能会出现Warning：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/nDb6pjkX3y4dstQ.png" alt="找不到_AfterPostProcessTexture" class=" img-responsive img-markdown" /></p>
<p dir="auto">到这里Renderer Feature与Render Pass就全部编写完成，接下来使用一下看看实际效果。</p>
<h1>使用一下看看实际效果</h1>
<p dir="auto">以官方示例中的卡通描边效果为例，先从把示例中的SobelFilter.shader窃过来，将Shader名称改为"Hidden/PostProcess/SobleFilter"，然后编写后处理组件SobelFilter类：</p>
<p dir="auto"><strong>SobelFilter.cs</strong></p>
<pre><code class="language-C#">[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 =&gt; CustomPostProcessInjectionPoint.AfterOpaqueAndSky;

    public override void Setup()
    {
        if (material == null)
            material = CoreUtils.CreateEngineMaterial(shaderName);
    }

    public override bool IsActive() =&gt; material != null &amp;&amp; lineThickness.value &gt; 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);
    }
}
</code></pre>
<p dir="auto">使用CoreUtils.CreateEngineMaterial来从Shader创建材质，在Dispose中销毁它。Render方法中的cmd.Blit之后可以考虑换成CoreUtils.DrawFullScreen画全屏三角形。</p>
<p dir="auto">需要注意的是，IsActive方法最好要在组件无效时返回false，避免组件未激活时仍然执行了渲染，原因之前提到过，无论组件是否添加到Volume菜单中或是否勾选，VolumeManager总是会初始化所有的VolumeComponent。</p>
<p dir="auto">而CoreUtils.CreateEngineMaterial(shaderName)内部依然是调用Shader.Find方法来查找Shader：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/AWDtBQwgq5sjyxm.png" alt="CoreUtils.cs" class=" img-responsive img-markdown" /></p>
<p dir="auto">添加Render Feature：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/9PfnqSOJ7DAQzvL.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">在Volume中添加并启用Sobel Filter：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/UF9ILNdTA6HutPE.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/v5k2Ib74UhjCqnl.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/46MNPyfVz9JdOYS.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">加入更多后处理组件，这里使用连连看简单连了一个条纹故障和一个RGB分离故障，它们的插入点都是内置后处理之后：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/LTC9JzQrxmZiqYf.png" alt="条纹故障" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/Rrd7gh2TotyzY89.png" alt="RGB分离" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/ArJz2bmnIu4TNEi.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">效果：</p>
<p dir="auto"></p>
<h1>应用到2D</h1>
<p dir="auto"><s>由于目前2D Renderer还不支持Renderer Feature，只好采取一个妥协的办法。首先新建一个Forward Renderer添加到Renderer List中：</s></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/T3rvtdJUSnXaNPy.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><s>场景中新建一个相机，Render Type改为Overlay，Renderer选择刚才创建的Forward Renderer，并开启Post Processing：</s></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/bmcYtlEXxA1Vvph.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><s>添加到主相机的Stack上，主相机关闭Post Processing：</s></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/IN4Z7O2g6spMFH8.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">URP 12.1.x中已经有了对2D的Renderer Feature支持，所以只需要添加自定义的Renderer Feature即可，其他使用方式和3D一致。</p>
<p dir="auto">到这里对URP后处理的扩展就基本完成了，当然包括渲染在内还有很多地方可以继续完善，比如进一步优化双缓冲、全屏三角形、同一组件支持多个插入点等等。</p>
<blockquote>
<p dir="auto">对于编辑器中运行有效果，但打包后没有效果的情况，可能的原因是Shader文件在打包时被剔除了，这种情况只要确保Shader文件被包含或者可被加载即可（添加到Always Included Shaders、放到Resources、从AB加载等等）。</p>
</blockquote>
<blockquote>
<p dir="auto">用到的素材：<a href="https://mattwalkden.itch.io/free-space-runner-pack" rel="nofollow ugc">Free Space Runner Pack</a> &amp; <a href="https://mattwalkden.itch.io/lunar-battle-pack" rel="nofollow ugc">Free Lunar Battle Pack</a> by <a href="https://mattwalkden.itch.io/" rel="nofollow ugc">MattWalkden</a></p>
</blockquote>
]]></description><link>http://designhub.top/topic/23/unity-为了更好用的后处理-扩展urp后处理踩坑记录</link><guid isPermaLink="true">http://designhub.top/topic/23/unity-为了更好用的后处理-扩展urp后处理踩坑记录</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 24 Mar 2023 05:23:42 GMT</pubDate></item><item><title><![CDATA[[Unity]2D颜料泼溅效果]]></title><description><![CDATA[<p dir="auto"><img src="https://s2.loli.net/2023/03/03/FSQgmWDJrBaCcy6.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">做一个类似于《INK》的2D颜料泼溅效果</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/oDi7mKQVRrY1v9S.jpg" alt="INK" class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/Xq9JdOYzWkxi5ct.gif" alt="实现效果" class=" img-responsive img-markdown" /></p>
<h2>表面与颜料</h2>
<p dir="auto">利用模板测试，让颜料污渍能在物体表面上重叠显示且不超出物体轮廓。在物体表面的Shader中，总是通过模板测试并替换参考值，同时裁剪掉透明度为0的像素；颜料污渍的Shader中，如果与参考值相等则通过测试。</p>
<p dir="auto">项目用了URP，这里直接将Sprite-Lit-Default.shader拷贝两份出来修改。</p>
<p dir="auto"><strong>被染色表面Surface-Lit.shader</strong></p>
<p dir="auto">SubShader中加入Stencil配置</p>
<pre><code class="language-C">SubShader
{
    Tags {"Queue" = "Transparent" "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" }

    Stencil
    {
        Ref 2
        Comp Always
        Pass Replace
    }
    ...
</code></pre>
<p dir="auto">透明度裁剪在原来的Sprite-Lit-Default.shader中已经做好了所以不用再写。</p>
<p dir="auto"><strong>颜料污渍Stain-Lit.shader</strong></p>
<pre><code class="language-C">SubShader
{
    Tags {"Queue" = "Transparent" "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" }

    Stencil
    {
        Ref 2
        Comp Equal
    }
    ...
</code></pre>
<p dir="auto">建一个场景测试效果，Tilemap使用Surface-Lit材质，污渍Sprite使用Stain材质，二者在同一个Sorting Layer，Tilemap的Order in Layer需要比污渍Sprite小。</p>
<p dir="auto">Tilemap:</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/f7EReqQKLY6mtF2.png" alt="Tilemap" class=" img-responsive img-markdown" /></p>
<p dir="auto">颜料污渍:</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/fNCEDSUtOW3MQYh.png" alt="颜料污渍" class=" img-responsive img-markdown" /></p>
<p dir="auto">颜料污渍已经可以显示在Tilemap上并且不会超出其轮廓。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/qXpfbn3GNMkcCFH.gif" alt="初步效果" class=" img-responsive img-markdown" /></p>
<h2>喷溅</h2>
<p dir="auto">添加一把玩具水枪，简单写一个发射颜料子弹的逻辑，子弹爆开后围绕当前位置随机生成多个颜料污渍预制体。颜料污渍预制体用一张1像素的白色图片，通过缩放来显示出不同大小的污渍。子弹中设置多种颜色随机应用到子弹与污渍的SpriteRenderer。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/zNTS7keiQ8vLIOA.png" alt="子弹" class=" img-responsive img-markdown" /></p>
<p dir="auto"><code>Stain Radius</code>为喷溅半径，<code>Stain Scale</code>为最大污渍缩放。</p>
<p dir="auto">编写StainGenerator类用来生成颜料污渍：</p>
<pre><code class="language-C#">public class StainGeneratorOld
{
    /// &lt;summary&gt;
    /// 在中心点生成向四周发散的污渍
    /// &lt;/summary&gt;
    /// &lt;param name="prefab"&gt;污渍预制体&lt;/param&gt;
    /// &lt;param name="color"&gt;颜色&lt;/param&gt;
    /// &lt;param name="position"&gt;中心点位置&lt;/param&gt;
    /// &lt;param name="direction"&gt;冲击方向&lt;/param&gt;
    /// &lt;param name="scale"&gt;污渍缩放&lt;/param&gt;
    /// &lt;param name="radius"&gt;污渍分布半径&lt;/param&gt;
    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 &lt; 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 &lt; 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&lt;SpriteRenderer&gt;().color = color;
            }
        }
    }

    public static float RandomNum(float num, float randomness)
    {
        return num + Random.Range(-num * randomness, num * randomness);
    }
}
</code></pre>
<p dir="auto">子弹与地面发生碰撞时调用，传入污渍预制体、颜色、冲击位置、冲击方向、自定义的污渍缩放与喷溅半径：</p>
<pre><code class="language-C#">private void OnCollisionEnter2D(Collision2D other) 
{
    ...
    StainGenerator.Generate(stainPrefab, color, transform.position, transform.right, stainScale, stainRadius);
}
</code></pre>
<p dir="auto">比较简单的实现就完成了，但这种不断生成Sprite的方法将导致场景里分分钟就会多出上千个游戏对象。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/sykMWFG5CuJnvhB.gif" alt class=" img-responsive img-markdown" /></p>
<h2>优化</h2>
<p dir="auto">由于污渍的材质都一样，Unity对它们做了动态合批，但要处理的顶点数量依然没变，这里是否可以通过自定义mesh来优化，暂时没有什么头猪。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/4HLD1fmd9GpgJa7.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">好在对游戏对象的数量优化还是比较简单的，打在同一个位置的颜料污渍将会发生重叠，被遮挡住的污渍是不再需要的。定义一个同一位置最大可叠加层数，在创建新的污渍时，先判断当前位置共叠加了几层，如果超过允许的最大层数，则将最底层的污渍对象回收，再从对象池中取出已回收的污渍对象重复利用。</p>
<p dir="auto">这种做法的缺点是只判断重叠，而不是判断污渍是否被完全覆盖，显示效果上不太好，最大层数设置较低时会出现有些污渍还未被完全覆盖，却依然被回收了的情况。</p>
<p dir="auto">检测重叠可以使用SpriteRenderer中Bounds的Intersects方法，但每次生成都要遍历当前所有污渍对象做判断，感觉过于繁琐。最终还是选择用了Physics2D.OverlapBoxAll，这样要先在污渍预制体中添加BoxCollider2D。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/x3OvWeru59UImAP.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">修改StainGenerator，令它继承MonoBehaviour。叠加层数利用SpriteRenderer的Order in Layer属性实现，假设三个污渍重叠，且它们的Order in Layer分别是2、3、4，如果此时已达到了最大叠加层数，同时有新的污渍即将覆盖它们，则回收Order为2的，其余的Order减1，新的污渍Order设为4，这样依然能保持2、3、4的重叠顺序。</p>
<pre><code class="language-C#">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&lt;GameObject&gt; stainPool;
    // 临时污渍对象列表，用来记录本次生成已处理过的对象，避免重复处理
    List&lt;GameObject&gt; tempStains;

    void Awake() 
    {
        // 初始化单例和列表
        ...
    }  
    ...
}
</code></pre>
<p dir="auto">修改Generate方法，去除多余的参数，仅改动污渍对象生成部分，子弹碰撞中相应修改对其的调用。</p>
<pre><code class="language-C#">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 &lt; stainNum; i++)
        {
            ...
            // var go = Object.Instantiate(prefab);
            var go = GetStain(stainPos, stainScale, dir);   // 替换为GetStain方法
            ...
        }
    }
}
</code></pre>
<p dir="auto">编写GetStain方法。</p>
<pre><code class="language-C#">/// &lt;summary&gt;
/// 获取当前污渍对象，若当前位置发生重叠则调整回收
/// &lt;/summary&gt;
/// &lt;param name="pos"&gt;位置&lt;/param&gt;
/// &lt;param name="scale"&gt;缩放&lt;/param&gt;
/// &lt;param name="dir"&gt;朝向&lt;/param&gt;
/// &lt;returns&gt;&lt;/returns&gt;
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&lt;SpriteRenderer&gt;();
            if (spriteRenderer.sortingOrder &gt; order)
            {
                order = spriteRenderer.sortingOrder;
                // 如果即将超出最大叠加层数则直接快进到处理重叠的污渍
                if (order + 1 &gt; maxOrderInLayer)
                    break;
            }
        }
        if (order + 1 &gt; maxOrderInLayer)
        {
            // 回收最底层的污渍，其余层sortingOrder减1，并标记为已处理，避免被重复处理
            foreach (var item in cols)
            {
                spriteRenderer = item.GetComponent&lt;SpriteRenderer&gt;();
                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&lt;SpriteRenderer&gt;().sortingOrder = order;
    
    return go;
}
</code></pre>
<p dir="auto">脚本挂到场景中，设置相应值：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/r3ZQm8yJVv4R5Lc.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">调整之后：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/zH1xYUA5GBKngbo.gif" alt="调整后" class=" img-responsive img-markdown" /></p>
<p dir="auto">和调整前对比：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/4qm3MTsNyZAX9tp.gif" alt="调整前" class=" img-responsive img-markdown" /></p>
<p dir="auto">可以发现不再生成那么多对象了，帧数也相对稳定，但出现了上面提到的显示问题，将最大叠加层数调高基本可以解决，总之这应该不是最优的做法。</p>
<blockquote>
<p dir="auto">用到的素材：<a href="https://adamatomic.itch.io/cavernas" rel="nofollow ugc">Cavernas by Adam Saltsman</a>、<a href="https://kingkelp.itch.io/8guns" rel="nofollow ugc">8 Guns + Projectiles by KingKelpo</a></p>
</blockquote>
]]></description><link>http://designhub.top/topic/22/unity-2d颜料泼溅效果</link><guid isPermaLink="true">http://designhub.top/topic/22/unity-2d颜料泼溅效果</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 24 Mar 2023 05:22:51 GMT</pubDate></item><item><title><![CDATA[[Unity]第一人称睁眼苏醒效果]]></title><description><![CDATA[<p dir="auto"><img src="https://s2.loli.net/2023/03/03/vAUh7uNXCmjnZwe.png" alt="第一人称睁眼苏醒效果" class=" img-responsive img-markdown" /></p>
<p dir="auto">做一个简单的睁眼苏醒效果，也可用于眨眼、闭眼、昏睡等等：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/KYlG4wTjdVtSmxQ.gif" alt="效果" class=" img-responsive img-markdown" /></p>
<p dir="auto">本篇文章中介绍的思路适用于Built-in渲染管线，如果您需要在URP中实现，可参考我的<a href="http://designhub.top/topic/23/unity-%E4%B8%BA%E4%BA%86%E6%9B%B4%E5%A5%BD%E7%94%A8%E7%9A%84%E5%90%8E%E5%A4%84%E7%90%86-%E6%89%A9%E5%B1%95urp%E5%90%8E%E5%A4%84%E7%90%86%E8%B8%A9%E5%9D%91%E8%AE%B0%E5%BD%95">这篇文章</a>，本篇文章末尾也有URP对应的仓库地址。</p>
<p dir="auto">首先编写一个<strong>AwakeScreenEffect.cs</strong>脚本：</p>
<pre><code class="language-C#">[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 处理...
    }
}
</code></pre>
<p dir="auto">将脚本挂在相机上，接下来编写相应的shader。目标效果中，从闭眼到睁眼的过程用一个进度0~1表示，当进度从0到1时，眼睛逐渐睁开，视野从模糊逐渐变为清晰。</p>
<p dir="auto">创建<strong>AwakeScreenEffect.shader</strong>，_Progress表示当前苏醒进度：</p>
<pre><code class="language-C++">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
}
</code></pre>
<p dir="auto">先写上下眼皮，它们的边界值分别是屏幕中线(0.5)加或减去当前进度乘以0.5，得出边界值后通过step函数对uv.v进行裁剪，大于上眼皮边界、小于下眼皮边界时裁剪值为0，否则为1，最后将它与颜色相乘得出效果：</p>
<pre><code class="language-C++">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;
}
</code></pre>
<p dir="auto"><strong>AwakeScreenEffect.cs</strong>中加入可调节的进度变量，OnRenderImage方法中应用：</p>
<pre><code class="language-C#">...
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);
    }
}
</code></pre>
<p dir="auto">可以看到初步效果:</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/X4Lg5NV2aqQn1pR.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/yWctbnRaxUz63NC.png" alt="眼皮" class=" img-responsive img-markdown" /></p>
<p dir="auto">除非是能人异士，不然大部分人的眼皮都不是这样一条直线，shader中定义一个眼皮弧形的高度值_ArchHeight：</p>
<pre><code class="language-C++">Properties
{
    ...
    _ArchHeight ("Arch Height", Range (0, .5)) = .2
}
</code></pre>
<p dir="auto">用二次函数做出弧度：</p>
<pre><code class="language-C++">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);
    ...
}
</code></pre>
<p dir="auto">上下边界由原来的<code>* .5</code>改为<code>* (.5 + _ArchHeight)</code>，用来调整上下边界随_Progress的变化范围，避免_Progress为1时仍有黑边的情况。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/cbIARes5TpiZnDm.png" alt="眼皮弧度" class=" img-responsive img-markdown" /></p>
<p dir="auto">再加入模糊，模糊效果直接使用了冯乐乐老师的《Unity Shader 入门精要》12.4节中的高斯模糊，新建一个<strong>GaussianBlur.shader</strong>，拷贝代码，确认一下shader和Pass的命名无误：</p>
<pre><code class="language-C++">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"
            ...
        }
    }
    ...
}
</code></pre>
<p dir="auto">在<strong>AwakeScreenEffect.shader</strong>中原有Pass之后使用高斯模糊的两个Pass：</p>
<pre><code class="language-C++">SubShader
{
    ...
    Pass
    {
        ...
    }
    ...
    UsePass "Custom/Gaussian Blur/GAUSSIAN_BLUR_VERTICAL"
    UsePass "Custom/Gaussian Blur/GAUSSIAN_BLUR_HORIZONTAL"
}
</code></pre>
<p dir="auto"><strong>AwakeScreenEffect.cs</strong>加入模糊需要用到的参数：</p>
<pre><code class="language-C#">[Range(0, 4)][Tooltip("模糊迭代次数")]
public int blurIterations = 3;
[Range(.2f, 3f)][Tooltip("每次模糊迭代时的模糊大小扩散")]
public float blurSpread = .6f;
</code></pre>
<p dir="auto">修改OnRenderImage方法，基本和书中的差不多，不同的是没有使用降采样。随着progress从0变为1，blurSize也逐渐变为0。</p>
<pre><code class="language-C#">void OnRenderImage(RenderTexture src, RenderTexture dest) 
{
    Material.SetFloat("_Progress", progress);
    if (progress &lt; 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 &lt; 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);
    }
}
</code></pre>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/cdVes4M6BkxUTXQ.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/e3ZxRUCr8JmlDEI.png" alt="模糊" class=" img-responsive img-markdown" /></p>
<p dir="auto">画面由暗转亮比较简单，<strong>AwakeScreenEffect.shader</strong>让颜色乘以_Progress即可：</p>
<pre><code class="language-C++">fixed4 frag (v2f i) : SV_Target
{
    ...
    col *= _Progress;
    return col;
}
</code></pre>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/uL2ri7UQMHKkDXj.png" alt="变暗" class=" img-responsive img-markdown" /></p>
<p dir="auto">最后用Animator录制一个动画就完成了。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/03/SfbdqVGlWyvhZXw.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">Demo项目地址：<br />
<a href="https://github.com/PamisuMyon/Procedural-Map-Demo" rel="nofollow ugc">Procedural-Map-Demo（Built-in渲染管线）</a><br />
<a href="https://github.com/PamisuMyon/pamisu-kit-unity" rel="nofollow ugc">pamisu-kit-unity（URP自定义后处理）</a></p>
]]></description><link>http://designhub.top/topic/21/unity-第一人称睁眼苏醒效果</link><guid isPermaLink="true">http://designhub.top/topic/21/unity-第一人称睁眼苏醒效果</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 24 Mar 2023 05:22:07 GMT</pubDate></item><item><title><![CDATA[[Unity]拼接地块的随机地图生成]]></title><description><![CDATA[<p dir="auto"><img src="https://s2.loli.net/2023/03/04/drB1w8h9Lu3gDsv.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">一个3D场景中拼接地块的随机地图的尝试过程，个人感觉效果不是非常理想，还有许多优化空间，总之先记录一下。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/4PFqkMu6CzBn17i.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/JYIgFUpQaLNPAlG.gif" alt="效果" class=" img-responsive img-markdown" /></p>
<p dir="auto">虽说是3D场景，但生成的地图块都在同一个平面上，严格来说依然是2D的思路。对于类似3D地牢的生成，有一篇文章介绍：<a href="https://mp.weixin.qq.com/s/3yM-mAAXq_fX5tcy82s0uQ" rel="nofollow ugc">在Unity中程序化生成的地牢环境</a>。</p>
<p dir="auto">整体思路：</p>
<ul>
<li>准备若干个地块，每个地块包含朝向东、西、南、北其中一个或多个的开口</li>
<li>将每个地块做成预制体并烘焙光照贴图</li>
<li>随机生成初始地块，根据地块开口匹配可拼接的地块，不断生成拼接直至完成</li>
</ul>
<p dir="auto">缺点：</p>
<ul>
<li>地块形状不一，很难做出环路</li>
<li>生成算法为逐个生成，而不是先生成一堆随机位置的地块再将它们连接，过程较为繁琐，并且如果发生冲突会有较多的重新尝试次数</li>
</ul>
<h1>地块</h1>
<p dir="auto">在一个场景中搭建好各类地块：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/Mtk7dsWliuNPFGz.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">由于计划要烘焙光照贴图，地块的开口朝向就必须都是固定的，旋转地块、移除墙壁都会导致穿帮。当然也可以设置一些动态的墙壁，需要时通过移除它们来形成开口。</p>
<p dir="auto">上图搭建好的地块中，右侧两列为需要生成的主要地块，拥有2~4个方向的开口；中间四个小地块分为横向与竖向，用来连接主要地块；左上角四个为单开口，在地块生成完毕后，用它们来封闭上空余的开口。</p>
<h2>烘焙</h2>
<p dir="auto">地块大致搭完后将它们都做成预制体，进行烘焙。这里使用插件<a href="https://github.com/nukadelic/Unity-Lightmap-Prefab-Baker" rel="nofollow ugc">Unity Lightmap Prefab Baker</a> ，它将整个场景按当前的光照设置进行烘焙，烘焙出的光照贴图移动到指定文件夹，然后通过挂在预制体下的脚本记录关联的光照贴图，当预制体加载到新场景时将它们关联起来。</p>
<p dir="auto">插件安装好后，每个地块预制体挂上PrefabBaker脚本：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/aBxVwpobQCj4m1W.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">Window -&gt; Prefab Baker 打开面板，调整相应设置后点击烘焙：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/y9bTM2UQqfO4lvg.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">默认的光照贴图放在Assets/Resources/Lightmaps目录下，开发阶段可以暂时放在这里。如果2019版本报"Failed to created asset"错误，可尝试修改Plugins/PrefabBaker/Scripts/EditorUtils.cs，在200行：</p>
<pre><code class="language-C#">// Directory.CreateDirectory( Directory.GetParent( saveTo ).FullName );
// 改为：
Directory.CreateDirectory( Directory.GetParent( saveTo ).Name);
</code></pre>
<p dir="auto">烘焙完成后：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/HrOjJZkRmwl5LT3.png" alt class=" img-responsive img-markdown" /></p>
<h2>拼接准备</h2>
<p dir="auto">为了让地块之间的开口能顺利对接上，地块的每个开口处需要放置一个空对象作为连接点，令两个地块的连接点重合即完成拼接。地块还需要有类型，这里分为了Room(房间，单开口), Corridor(走廊，东西或南北开口), Corner(拐角), TShaped(丁字), Hall(大厅，四面开口)。</p>
<pre><code class="language-C#">// 地块连接点
[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;
}
</code></pre>
<p dir="auto">连接点，Z轴朝向开口方向：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/c1WdCZ2XwArPERx.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">地块预制体：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/2uTeLn3SYMzmWwC.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">拼接时需要能获取到地块的连接点情况，加入相关方法：</p>
<pre><code class="language-C#">public class Cell : MonoBehaviour 
{
    ...
    public void GetAvailableJoints(List&lt;Joint&gt; 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;
    }
}
</code></pre>
<h1>生成</h1>
<p dir="auto">在一个新场景中生成地图，编写<strong>LevelGenerator.cs</strong>脚本：</p>
<pre><code class="language-C#">public class LevelGenerator : MonoBehaviour
{
    [Header("地块")]
    [Tooltip("生成地块总数")]
    public int cellTotalNum;
    [Tooltip("地块预制体")]
    public List&lt;Cell&gt; cellPrefabs;

    // 分类预制体
    List&lt;Cell&gt; roomPrefabs; // 房间（单开口）
    List&lt;Cell&gt; corridorPrefabs; // 走廊（左右、上下开口）
    List&lt;Cell&gt; bigCellPrefabs;  // 其他大地块
    ...
</code></pre>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/nT2HV1bDSWNvAPh.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">先对地块预制体进行分类：</p>
<pre><code class="language-C#">/// &lt;summary&gt;
/// 将地块预制体归类到不同列表
/// &lt;/summary&gt;
void SortCellPrefabs()
{
    roomPrefabs = new List&lt;Cell&gt;();
    corridorPrefabs = new List&lt;Cell&gt;();
    bigCellPrefabs = new List&lt;Cell&gt;();
    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);
    }
}
</code></pre>
<p dir="auto">分好类之后开始生成，编写生成地图的方法GenerateLevel，由于需要多次将满足条件的对象放入列表来随机抽取，事先准备好一些列表避免反复创建：</p>
<pre><code class="language-C#">void GenerateLevel()
{
    List&lt;Cell&gt; cells = new List&lt;Cell&gt;();  // 当前已生成的且连接口未封闭的地块列表
    List&lt;Cell&gt; tempCells = new List&lt;Cell&gt;();    // 临时地块列表，用于随机当前匹配的地块预制体
    List&lt;Joint&gt; tempJoints = new List&lt;Joint&gt;(); // 临时地块连接口列表，用于随机当前地块连接口
    int cellNum; // 当前地块数量（已生成+即将生成）
    ...

</code></pre>
<p dir="auto">先生成一个初始地块，这里选择走廊作为初始的地块，也可用其他的地块类型：</p>
<pre><code class="language-C#">// 生成初始地块
Cell cell = Utils.GetRandom&lt;Cell&gt;(corridorPrefabs);
cell = Instantiate(cell, transform);
cell.transform.position = transform.position;
cells.Add(cell);
cellNum = 1 + cell.GetAvailableJointsCount();   // 已生成1个+即将生成个数
</code></pre>
<p dir="auto">GetRandom方法:</p>
<pre><code class="language-C#">public static T GetRandom&lt;T&gt;(List&lt;T&gt; list)
{
    if (list.Count &lt; 0) 
        return default(T);
    int index = Random.Range(0, list.Count);
    return list[index];
}
</code></pre>
<p dir="auto">需要注意新场景中的光照（主要是平行光）需要和烘焙场景保持一致。</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/EcbhtVKRFXaqAn6.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">当前的算法是逐个拼接生成地块，最后用单开口地块对所有空余的开口进行封闭。为了控制地块数量，当前的地块数量为已生成个数加上即将生成个数。</p>
<p dir="auto">编写循环生成：</p>
<pre><code class="language-C#">// 循环生成
while (cellNum &lt; cellTotalNum)
{
    // yield return new WaitForSeconds(.3f);  // 调试时使用协程，方便观察
    // 随机获取现有未封闭地块，并随机取得其连接口
    cell = Utils.GetRandom&lt;Cell&gt;(cells);
    cell.GetAvailableJoints(tempJoints);
    var currentJoint = Utils.GetRandom&lt;Joint&gt;(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();
}
</code></pre>
<p dir="auto">GenerateMatchingCell方法用于寻找与当前地块连接口匹配的地块预制体，找到之后将它们拼接：</p>
<pre><code class="language-C#">/// &lt;summary&gt;
/// 生成与当前连接口相匹配的地块并连接
/// &lt;/summary&gt;
/// &lt;param name="cellPrefabs"&gt;地块预制体列表&lt;/param&gt;
/// &lt;param name="currentJoint"&gt;当前连接口&lt;/param&gt;
/// &lt;param name="tempCells"&gt;临时地块列表，用于从满足条件的地块中随机抽取&lt;/param&gt;
/// &lt;returns&gt;匹配的地块&lt;/returns&gt;
Cell GenerateMatchingCell(List&lt;Cell&gt; cellPrefabs, Joint currentJoint, List&lt;Cell&gt; 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;
}
</code></pre>
<p dir="auto">要寻找匹配的地块，首先要找到跟当前连接点匹配的连接点类型：</p>
<pre><code class="language-C#">/// &lt;summary&gt;
/// 获取当前连接口匹配的连接口类型
/// &lt;/summary&gt;
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;
}
</code></pre>
<p dir="auto">然后在地块预制体中寻找包含这种连接点类型的地块：</p>
<pre><code class="language-C#">/// &lt;summary&gt;
/// 获取与当前连接口匹配的地块
/// &lt;/summary&gt;
/// &lt;param name="cellPrefabs"&gt;地块预制体列表&lt;/param&gt;
/// &lt;param name="expectedType"&gt;期望连接口类型&lt;/param&gt;
/// &lt;param name="tempCells"&gt;临时地块列表，用于随机&lt;/param&gt;
Cell GetMatchingCell(List&lt;Cell&gt; cellPrefabs, Joint.Type expectedType, List&lt;Cell&gt; tempCells)
{
    tempCells.Clear();
    foreach (var item in cellPrefabs)
    {
        if (item.HasJoint(expectedType))
        {
            tempCells.Add(item);
        }
    }
    if (tempCells.Count == 0)
        return null;
    return Utils.GetRandom&lt;Cell&gt;(tempCells);
}
</code></pre>
<p dir="auto">效果：<br />
<img src="https://s2.loli.net/2023/03/04/R8Wn2s9XAMugOlZ.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">最后GenerateLevel方法中将有空余连接点的地块封口：</p>
<pre><code class="language-C#">// 将剩余地块用单开口地块封闭
foreach (var item in cells)
{
    item.GetAvailableJoints(tempJoints);
    foreach (var joint in tempJoints)
    {
        // yield return new WaitForSeconds(.3f);
        GenerateMatchingCell(roomPrefabs, joint, tempCells);
    }
}
</code></pre>
<p dir="auto">效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/sKwA6DTCirPHB7p.gif" alt class=" img-responsive img-markdown" /></p>
<h1>冲突处理</h1>
<p dir="auto">生成较多地块时，会发生冲突：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/eyaD2Q1mbdgH6GT.gif" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">由于是逐个地块生成，冲突检测只能在要生成下一个地块时进行判断，如果当前连接点前方一段距离内已经存在地块，则该连接点不能用于继续生成，需要封闭；另外，如果连接点前方不存在地块，还需要判断前方的前方、左方、右方是否存在地块，若存在则只能生成开口朝向别处的地块。</p>
<p dir="auto">每个地块添加碰撞体：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/IdqyR1C9w7H3sv8.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto"><strong>LevelGenerator.cs</strong>加入冲突检测相关配置：</p>
<pre><code class="language-C#">[Header("冲突检测")]
[Tooltip("检测距离")]
public float conflictCheckDistance;
[Tooltip("检测box")]
public Vector3 conflictCheckHalfBox;
[Tooltip("检测图层")]
public LayerMask cellLayer;
</code></pre>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/lZ9r6Kc3oBtEv2O.png" alt class=" img-responsive img-markdown" /></p>
<p dir="auto">GenerateLevel方法中：</p>
<pre><code class="language-C#">void GenerateLevel()
{
    ...
    // 不期望的房间连接口，用于避免生成可能会造成冲突的房间
    List&lt;Joint.Type&gt; unwantedJointTypes = new List&lt;Joint.Type&gt;();   
    int cellNum; // 当前地块数量（已生成+即将生成）
    // 生成初始地块
    ...
    // 循环生成
    while (cellNum &lt; cellTotalNum)
    {
        // 随机获取现有未封闭地块，并随机取得其连接口
        ...
        // 检测当前连接口前方是否存在直接冲突，若存在则直接跳过
        // 或其前方的左、前、右是否存在冲突，若存在则存放在不期望的房间连接口列表中
        unwantedJointTypes.Clear();
        if (CheckConflict(currentJoint, unwantedJointTypes))
        {
            Debug.Log("检测到冲突，放弃当前房间");
            continue;
        }
        // 获取与当前连接口匹配的地块
        ...
    ...
</code></pre>
<p dir="auto">CheckConflict方法:</p>
<pre><code class="language-C#">bool CheckConflict(Joint joint, List&lt;Joint.Type&gt; unwantedJointTypes)
{
    // 先检测前方是否有冲突
    var center = joint.transform.position + joint.transform.forward * conflictCheckDistance;
    var cols = Physics.OverlapBox(center, conflictCheckHalfBox, Quaternion.identity, cellLayer, QueryTriggerInteraction.Collide);
    if (cols.Length &gt; 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 &gt; 0)
            unwantedJointTypes.Add(joint.type);
        if (left.Length &gt; 0)
            unwantedJointTypes.Add(joint.GetLocalLeft());
        if (right.Length &gt; 0)
            unwantedJointTypes.Add(joint.GetLocalRight());
        return false;
    }
}
</code></pre>
<p dir="auto">如果没有直接冲突，按照上面的逻辑判断前方的前方、左方、右方是否存在地块，若存在则记录到不期望的连接类型列表，随后由GenerateMatchingCell传入到GetMatchingCell中：</p>
<pre><code class="language-C#">Cell GenerateMatchingCell(List&lt;Cell&gt; cellPrefabs, Joint currentJoint, List&lt;Joint.Type&gt; unwantedJointTypes, List&lt;Cell&gt; tempCells)
{
    ...
    // 根据期望匹配的连接口类型获取合适的地块
    var matchingCell = GetMatchingCell(cellPrefabs, expectedJointType, unwantedJointTypes, tempCells);
    ...
}
</code></pre>
<pre><code class="language-C#">Cell GetMatchingCell(List&lt;Cell&gt; cellPrefabs, Joint.Type expectedType, List&lt;Joint.Type&gt; unwantedTypes, List&lt;Cell&gt; 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);
        }
    }
    ...
}
</code></pre>
<p dir="auto">效果：</p>
<p dir="auto"><img src="https://s2.loli.net/2023/03/04/DWBoxg2KPTe7sQj.gif" alt class=" img-responsive img-markdown" /></p>
]]></description><link>http://designhub.top/topic/20/unity-拼接地块的随机地图生成</link><guid isPermaLink="true">http://designhub.top/topic/20/unity-拼接地块的随机地图生成</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Fri, 24 Mar 2023 05:20:25 GMT</pubDate></item></channel></rss>