<?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[Recently Active Topics]]></title><description><![CDATA[A list of topics that have been active within the past 24 hours]]></description><link>http://designhub.top/recent</link><generator>RSS for Node</generator><lastBuildDate>Sat, 11 Jul 2026 22:58:09 GMT</lastBuildDate><atom:link href="http://designhub.top/recent.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[不开心，加油搞钱吧]]></title><description><![CDATA[<p dir="auto">感觉有点过坏了，立帖为证提升自己</p>
<ul>
<li>银河城</li>
<li>88Plane</li>
<li>文字练习</li>
<li>美术练习</li>
<li>程序练习</li>
</ul>
<p dir="auto">每周结束至少做一个，并且有点成果</p>
]]></description><link>http://designhub.top/topic/73/不开心-加油搞钱吧</link><guid isPermaLink="true">http://designhub.top/topic/73/不开心-加油搞钱吧</guid><dc:creator><![CDATA[GShion]]></dc:creator><pubDate>Sat, 07 Sep 2024 20:46:15 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:44:25 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>Sun, 21 Apr 2024 00:57:35 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>Sun, 10 Mar 2024 11:57:16 GMT</pubDate></item><item><title><![CDATA[NodeBB服务器迁移记录]]></title><description><![CDATA[<p dir="auto">记录一下NodeBB从旧服务器(Windows Server 2016)迁移到新服务器(Ubuntu 22.04)的流程。官方文档只有搭建说明，没有迁移说明，中途踩了一些坑。</p>
<h1>备份旧服务器上的数据</h1>
<p dir="auto">从官方的<a href="https://docs.nodebb.org/configuring/upgrade/" rel="nofollow ugc">升级文档</a>中得知（猜测），需要备份数据库以及NodeBB源码文件夹下的Uploads文件夹，另外还要备份源码文件夹下的configs.json。</p>
<h2>数据库MongoDB</h2>
<p dir="auto">这里使用的是MongoDB，先从MongoDB官网下载命令行工具：<br />
<a href="https://www.mongodb.com/try/download/database-tools" rel="nofollow ugc">https://www.mongodb.com/try/download/database-tools</a></p>
<p dir="auto">使用其中的mongodump备份数据库：<br />
<img src="/assets/uploads/files/1708587265457-pasted-image-20240221124411.png" alt class=" img-responsive img-markdown" /></p>
<pre><code class="language-bash">mongodump -h 数据库服务地址:端口 -d nodebb -o 备份输出路径 -u nodebb
</code></pre>
<p dir="auto">-h 数据库服务的地址和端口，如果是本地127.0.0.1:27017则可以省略</p>
<p dir="auto">-d 数据库名称，NodeBB数据库名称默认为nodebb</p>
<p dir="auto">-o 备份文件输出到哪里</p>
<p dir="auto">-u 数据库用户，NodeBB数据库用户默认为nodebb</p>
<p dir="auto">然后会提示要输入数据库nodebb用户的密码，密码输入正确后会输出备份文件：</p>
<p dir="auto"><img src="/assets/uploads/files/1708587294101-pasted-image-20240221124458.png" alt="Pasted image 20240221124458.png" class=" img-responsive img-markdown" /></p>
<h2>uploads与config.json</h2>
<p dir="auto">uploads文件夹的路径为<code>nodebb源码目录/public/uploads</code>，全部复制出来即可，config.json在根目录下。</p>
<h1>在新服务器上重新搭建</h1>
<p dir="auto">在旧服务器上(Windows Server)，nodebb是直接搭的，由于新服务器系统换成了Ubuntu，所以打算改用Docker搭建。</p>
<h2>Docker</h2>
<p dir="auto">按照<a href="https://docs.docker.com/engine/install/ubuntu/" rel="nofollow ugc">官方的Docker Engine安装说明</a> 来安装就行。</p>
<h2>FTP</h2>
<p dir="auto">搭一个FTP来传输备份的文件，既然装了Docker，那就用Docker来搭，方便很多。</p>
<p dir="auto">在/home下创建一个文件夹用来存放FTP传输的文件：</p>
<pre><code class="language-bash">mkdir -p /home/vsftpd/files
</code></pre>
<p dir="auto">运行FTP容器：</p>
<pre><code class="language-bash">docker run -d -p 20:20 -p 21:21 \
-p 21100-21110:21100-21110 \
-v /home/vsftpd/files:/home/vsftpd \
-e FTP_USER=用户名 \
-e FTP_PASS=密码 \
-e PASV_ADDRESS=服务器公网ip \
-e PASV_MIN_PORT=21100 \
-e PASV_MAX_PORT=21110 \
--name vsftpd \
--restart=always fauria/vsftpd
</code></pre>
<p dir="auto">-d 以守护进程形式运行</p>
<p dir="auto">-p 20:20 -p 21:21 映射容器的20和21端口到宿主机</p>
<p dir="auto">-p 21100-21110:21100-21110 映射容器的21100到21110端口到宿主机，用于被动模式</p>
<p dir="auto">-v /home/vsftpd/files:/home/vsftpd 映射容器内的/home/vsftpd目录到刚才在宿主机中创建的目录</p>
<p dir="auto">-e FTP_USER -e FTP_PASS 用户名密码</p>
<p dir="auto">--name 容器名称</p>
<p dir="auto">--restart=always 容器退出时总是重启容器</p>
<p dir="auto">fauria/vsftpd 镜像名</p>
<p dir="auto">运行起来之后，在阿里云安全组中允许20、21端口以及21100到21110端口的访问：</p>
<p dir="auto"><img src="/assets/uploads/files/1708587328610-pasted-image-20240221124044.png" alt="Pasted image 20240221124044.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">本地使用被动模式连接：</p>
<p dir="auto"><img src="/assets/uploads/files/1708587336258-pasted-image-20240221124158.png" alt="Pasted image 20240221124158.png" class=" img-responsive img-markdown" /></p>
<p dir="auto">连上之后，把uploads文件夹、configs.json传到服务器上，数据库备份文件可以不传。</p>
<h2>MongoDB</h2>
<p dir="auto">理论上来说，可以用Docker Compose一键部署MongoDB和NodeBB，官方仓库中有docker-compose.yml，但这里要部署的NodeBB是2.x版本不是最新的3.x，试了一下没整出来，时间关系就先不搞Docker Compose了。</p>
<p dir="auto">创建一个文件夹用于存放MongoDB数据与配置文件：</p>
<pre><code class="language-bash">mkdir -p /home/nodebb/data/
</code></pre>
<p dir="auto">在这个文件夹下，再创建一个db文件夹用于存放数据，创建一个mongo.conf文件用于数据库配置：</p>
<pre><code class="language-bash">mkdir db
vim mongo.conf
</code></pre>
<p dir="auto">修改mongo.conf的内容：</p>
<pre><code>systemLog:
  destination: file
  path: /var/log/mongodb/mongod.log
  logAppend: true
storage:
  dbPath: /data/db
security:
  authorization: enabled
</code></pre>
<p dir="auto">运行MongoDB容器：</p>
<pre><code class="language-bash">docker run -p 27017:27017 \
-v /home/nodebb/data/db:/data/db \
-v /home/nodebb/data/mongo.conf:/data/configdb/mongo.conf \
--name mongodb \
--restart=always \
-d mongo -f /data/configdb/mongo.conf
</code></pre>
<p dir="auto">-v /home/nodebb/data/db:/data/db 映射数据库目录<br />
-v /home/nodebb/data/mongo.conf:/data/configdb/mongo.conf 映射配置文件<br />
-d mongo -f /data/configdb/mongo.conf 指定配置文件运行</p>
<p dir="auto">MongoDB容器跑起来后，进入到容器内做一些配置：</p>
<pre><code class="language-bash">docker exec -it mongodb mongosh admin
</code></pre>
<pre><code class="language-bash"># 使用管理员数据库
use admin
# 创建管理员账号
db.createUser( { user: "admin", pwd: "管理员密码", roles: [ { role: "root", db: "admin" } ] } )
# 管理员登录
db.auth("admin", "管理员密码")

# 使用nodebb数据库
use nodebb
# 创建nodebb数据库用户
db.createUser( { user: "nodebb", pwd: "密码", roles: [ { role: "readWrite", db: "nodebb" }, { role: "clusterMonitor", db: "admin" } ] } )
# 退出
exit
</code></pre>
<p dir="auto">运行起来之后，如果要临时从本地连接，在阿里云安全组中允许27017端口，用完再关掉（或者用防火墙关）。</p>
<p dir="auto">本地机器可以使用MongoDB Compass检查是否能正常连接，再使用MongoDB命令行工具中的mongorestore恢复数据：</p>
<pre><code class="language-bash">mongorestore -h 数据库服务地址:端口 -d nodebb --dir 备份文件路径 -u nodebb
</code></pre>
<p dir="auto">输入密码后开始恢复，恢复后的数据库：</p>
<p dir="auto"><img src="/assets/uploads/files/1708587353655-pasted-image-20240221131253.png" alt="Pasted image 20240221131253.png" class=" img-responsive img-markdown" /></p>
<h2>NodeBB</h2>
<h3>Docker方式</h3>
<p dir="auto">理论上来说Docker方式是绝对可以的，但这里由于旧服务器上的NodeBB是2.x版本，新服务器如果安装3.x版本会不兼容，看了一下好像没有现成的NodeBB 2.x版本镜像，只能自己构建，最终还是偷懒使用了NodeJS部署方式，之后有空再研究下Docker，或许可以尝试下面的命令：</p>
<pre><code class="language-bash">docker run --name nodebb \
-p 4567:4567 \
-v /home/nodebb/public/uploads:/usr/src/app/public/uploads \
-e URL="域名" \
-e DATABASE="mongo" \
-e DB_HOST="host.docker.internal" \
-e DB_USER="nodebb" \
-e DB_PASSWORD="数据库密码" \
-e DB_PORT="数据库端口" \
-d NodeBB镜像
</code></pre>
<h3>NodeJS方式</h3>
<p dir="auto">先按照<a href="https://docs.nodebb.org/installing/os/ubuntu/" rel="nofollow ugc">官方说明</a>中的<code>Installing Node.js</code>一节把Node.js装好。</p>
<p dir="auto">装好之后，安装git，克隆NodeBB 2.x版本源码：</p>
<pre><code class="language-bash"># 安装git
sudo apt-get install -y git
# 克隆源码
git clone -b v2.x https://github.com/NodeBB/NodeBB.git nodebb 
# 进入到文件夹下
cd nodebb
</code></pre>
<p dir="auto">然后把备份的uploads文件夹和config.json文件覆盖到源码目录下，运行nodebb设置：</p>
<pre><code class="language-bash">./nodebb setup
</code></pre>
<p dir="auto">由于config.json已经有内容，不出意外的话只会进行安装，而不需要重复做设置。</p>
<p dir="auto">然后运行：</p>
<pre><code class="language-bash">./nodebb start
</code></pre>
<p dir="auto">如果运行成功，可以尝试访问服务器ip:4567端口（记得在安全组中开放端口），看网页是否运行正常。</p>
<h2>nginx</h2>
<p dir="auto">最后用nginx将nodebb代理到80端口。</p>
<p dir="auto">首先创建nginx需要用到的目录：</p>
<pre><code class="language-bash">mkdir -p /home/nginx/conf
mkdir -p /home/nginx/log
mkdir -p /home/nginx/html
mkdir -p /home/nginx/ssl
</code></pre>
<p dir="auto">创建一个临时的nginx容器，复制需要的文件：</p>
<pre><code class="language-bash"># 容器中的nginx.conf文件和conf.d文件夹复制到宿主机
# 生成临时容器
docker run --name nginx -p 9001:80 -d nginx
# 将容器nginx.conf文件复制到宿主机
docker cp nginx:/etc/nginx/nginx.conf /home/nginx/conf/nginx.conf
# 将容器conf.d文件夹下内容复制到宿主机
docker cp nginx:/etc/nginx/conf.d /home/nginx/conf/conf.d
# 将容器中的html文件夹复制到宿主机
docker cp nginx:/usr/share/nginx/html /home/nginx/

# 删除正在运行的nginx容器
docker rm -f nginx
</code></pre>
<p dir="auto">修改/home/nginx/conf/conf.d/default.conf：</p>
<pre><code class="language-bash">server {
    listen 80;

    server_name 域名;

    location / {
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $http_host;
        proxy_set_header X-NginX-Proxy true;

        proxy_pass http://127.0.0.1:4567;
        proxy_redirect off;

        # Socket.IO Support
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}
</code></pre>
<p dir="auto">重新运行ngnix容器：</p>
<pre><code class="language-bash">docker run \
-p 443:443 -p 80:80 \
--name nginx \
-v /home/nginx/conf/nginx.conf:/etc/nginx/nginx.conf \
-v /home/nginx/conf/conf.d:/etc/nginx/conf.d \
-v /home/nginx/log:/var/log/nginx \
-v /home/nginx/html:/usr/share/nginx/html \
-v /home/nginx/ssl:/etc/nginx/ssl/ \
-d nginx
</code></pre>
<p dir="auto">在安全组中开放80和443端口，如果访问服务器ip能看到NodeBB页面，则说明ngnix运行正常。</p>
]]></description><link>http://designhub.top/topic/69/nodebb服务器迁移记录</link><guid isPermaLink="true">http://designhub.top/topic/69/nodebb服务器迁移记录</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Sun, 25 Feb 2024 16:00:00 GMT</pubDate></item><item><title><![CDATA[疯狂踩坑之这是一个服务器迁移测试贴]]></title><description><![CDATA[<p dir="auto"><a class="plugin-mentions-user plugin-mentions-a" href="http://designhub.top/uid/2">@金桔柠檬茶</a> 测试测试测试</p>
]]></description><link>http://designhub.top/topic/68/疯狂踩坑之这是一个服务器迁移测试贴</link><guid isPermaLink="true">http://designhub.top/topic/68/疯狂踩坑之这是一个服务器迁移测试贴</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Thu, 22 Feb 2024 01:42:16 GMT</pubDate></item><item><title><![CDATA[关于二次元的一些思考]]></title><description><![CDATA[<p dir="auto">什么是二次元？只要幻想就是二次元？绘画和美术才是二次元？还是必须使用动漫表达技法创作出的内容才是二次元？当我们谈论“二次元”的时候，我们到底在谈论什么？</p>
<p dir="auto">（中略）</p>
<p dir="auto">在思考了很久之后，我认为二次元固然有表现上的特征要素，如漫画风、幻想风等存在，但更能体现二次元核心的内容其实存在于更深层，存在于幻想与现实的边界。</p>
<ul>
<li>
<p dir="auto">我认为，二次元的核心是通过幻想获得超越自身的体验（有时候将自身代入，幻想这个世界确实存在以拒绝现实）</p>
<ul>
<li>
<p dir="auto">在这里解释一下拒绝现实。与仅获得超越自身的体验不同，拒绝现实需要额外的条件，分别是【希望】某个特定的虚幻的世界存在、【认同】某些特定的角色存在于上述世界，【认同】上述角色有着人格存在并且能与世界发生互动，并【幻想】自身也扮演着某个角色，和上述认同存在的角色们发生互动，或是仅仅观察上述角色自身的互动。前三条是基本判定是否为拒绝现实，最后一条是如何拒绝现实（幻想虚幻的世界存在，或是幻想自己于虚幻的世界存在）</p>
</li>
<li>
<p dir="auto">拒绝现实并不含褒贬意味，譬如绝大多数幻想创作时都必须要拒绝现实。唯有“明知这么做会导致坏的结果，且意志尚能克服坏选择，仍然选择了坏选择”的情况下才会含有贬义，也就是一般意义的“逃避现实”。更准确的说，上述“拒绝现实”应该表述为”将自己推离现实以进入想要进入的幻想世界”。</p>
</li>
<li>
<p dir="auto">如果是“仅获得超越自身的体验而不拒绝现实”，那么实际上此人并不属于一般意义上的“二次元”。对他们来说看“二次元”相关内容和周末大家约好一起去看电影没有区别，看完了，为情节与人物回味一段时间，互相锐评两句吹吹比，然后继续该干啥干啥，享受生活。幻想行为被作为他们现实生活的调味品，而非临时逃离现实生活的途径。和一些人天生喜欢吃辣一样、这些人喜欢二次元，只是因为部分二次元的内容正好戳中他的爱好而已。毕竟电影很难拍各种五花八门的幻想情节，比如机器人天天到处乱飞打架的故事，但二次元可以。</p>
<p dir="auto">这也是很多人明明表现出来就是个纯粹的二次元，看番一堆手办一堆海报一堆，但其他人一旦了解这个人的主要生活之后都会说他是个现充的原因。</p>
</li>
<li>
<p dir="auto">但是，如果存在主动的“拒绝现实”，也就是此人真的是一个“二次元”……那么在完全念头通达前，他都会持续地被痛苦所困扰。这份痛苦分为几个阶段。</p>
<ul>
<li>
<p dir="auto">第零阶段是进入幻想并面对美好事物后，认为自身不配与之并列，产生的自惭形秽的痛苦。你进入幻想世界，以主角的身份和各式各样的角色发生交集，体会她们的故事。那么你在对幻想世界产生归属感，并且开始喜欢上这些美好角色的同时，必然存在的退出幻想的流程则会像一根刺一样，提醒你自己并不属于幻想世界。这时候就会产生一个问题：现实的你假如到了幻想世界，真的能成为那个被那些耀眼角色共同喜欢的主角吗？幻想世界的主角（你）具有这样那样的能力与性格，但现实的你则完全比不上（至少是自认比不上）。在纠结于自身的现实身份和幻想身份的情况下，你会被自惭形秽，或是自我厌恶的痛苦所困扰。（当然，如果具有比较强烈的自我则不会……而是会成为中二病）</p>
<p dir="auto">实际上，很多人根本不会进入第零阶段，因为这纯度实在太高了。他们是真的很认真地在考虑自己配不配的问题，即使他们自己都有时候觉得有这样想法的自己实在是太蠢了。但，这一阶段的人非常有趣，也是非常可怕的一点，正是在这种纠结中体现的。</p>
<p dir="auto">会考虑这种问题的人，不管他们嘴上怎么否认，潜意识仍然将幻想的角色当成和他等同的人——“那里一定有一个世界，生活着这样一群角色。她们有自己的喜好，自己的性格，自己的故事。在这个世界里，她们和我一样，都是确实地活着的人。”</p>
<p dir="auto">……即使，这份人格是他自己赋予的。</p>
<p dir="auto">这一阶段的读者拒绝现实的程度不低。他们会把幻想中的角色当成真实的角色一样对待，因此，他们也会受到很多人的不理解，也会说出一些看起来很逆天的发言（比如认真的说你这样对得起XXX吗，或者认真的说我干了XXX事情，对不起XXX了，没脸见她们了）。他们看故事的时候很容易情绪波动大，因为角色的故事对别人来说是故事，对他们来说则带有部分移情的感同身受。他们经常会体会到【他们自认为的】角色的快乐与痛苦，并在现实与幻想的对比中迷茫不定。</p>
</li>
<li>
<p dir="auto">第一阶段是因明悟了幻想世界的虚幻属性，而产生的与现实割裂的痛苦。在反复穿过现实与幻想的边界后，你会意识到幻想终究不是现实，你所爱的角色不会是真实的具有人格的人，她们只存在于幻想世界里。但即便如此，你会仍然希望他们能在幻想世界里永远活着，能够在幻想世界中一直陪伴着你。</p>
<p dir="auto">但是，做不到。</p>
<p dir="auto">角色的鲜活仅仅停留在故事之中，停留在纸面或者动画未结束之前。当你结束阅读的时候，关于这个角色的一切就都结束了。她们不会再多说一句话，多做任何一件事，对你的任何行为作出反应。你只能重头再来一遍，在故事里，按照设定的剧本，用固定的反应，和她们做出这样那样的互动，和角色们再表演一场盛大的傀儡剧。而当故事走到尽头的那一刻，这些角色就被禁锢在了永恒静止的时间之中。像穿越千年，光鲜亮丽却毫无生气的琥珀一样，美丽又永恒……但那不是你想要的。</p>
<p dir="auto">自然，处于这一阶段的你会费尽心力，有意识或无意识地尝试延续这些角色的鲜活，于是你会去找设定集、找二创，让角色继续“活”下去；或者紧紧握住创作还未完结，角色的故事还可能继续的希望，一直等着、等着。但，不管怎样，最终还是要面对幻想只能是幻想的无情现实。</p>
<p dir="auto">所有被认可的幻想世界完全被禁锢在有限的对它的描述上，只有自己的想象才能将其无限，但任何一个将你推出幻想世界的行为都会提醒你这只是你的想象。你终将明悟和你和角色们实际上的联系，并没有你自己想象得那么紧密。即使再怎么爱角色，能抓住的也不过是空中垂下的蛛丝而已。</p>
<p dir="auto">这一阶段的读者虽然承认幻境仅仅是幻境，但他们无法割舍幻境给他们带来的美好……也因此常常感受痛苦。</p>
</li>
<li>
<p dir="auto">第二阶段是了解了创作过程后，无法继续对角色产生纯粹的喜爱，无法接受光鲜亮丽的角色背后的黑暗而产生的痛苦。</p>
<p dir="auto">当你因为难度甚至运气被反复无意义拷打的时候，你很难将为你作战的角色和对游戏本身的怒气区分开。脾气上来的时候，你很容易骂一句“你TM怎么这么脆啊？”或者“你这时候给我不暴击？”。这种行为在PVP里面更为常见，尤其是你和对手采用了极其相似的阵容但你仍然输了的时候。</p>
<p dir="auto">你很快就会发现你的怒气似乎发错了地方。于是你去论坛发帖，猛烈喷了一顿设计师，转头却又说自己的角色还是很可爱的。</p>
<p dir="auto">但仔细想想就会发现这种言论有点奇怪。你喜欢角色，于是设计师创造的好的一面被你拿走，角色变成了只属于你的角色。你讨厌游戏体验，因此设计师就是混蛋，即使这些你喜爱的角色都是他创造的。可是，创造你喜爱的和你讨厌的东西的人，难道不是一个人吗？最少最少，难道不是同一个公司吗？</p>
<p dir="auto">即使上述情况可以用好活烂活分开看来解释，但另一种情况，也就是同一个人创作出的同一个角色，在故事的前后期几乎是两个人：前期人人爱，后期突然就变成了个混蛋。大多数人只会觉得这作者写爆了，是能力不行。但，另一些人就会发表评论，将故事后期的角色切割走，只留下前期的角色。在她们还很可爱的时候，这些角色是我的。在她们“因为作者”变混蛋的时候，这些角色……仍然不属于作者，只是作者故意搞鬼而已。</p>
<p dir="auto">他们为何会如此地执着于将角色的最终解释权握在自己手里这件事呢？为什么一定要将幻想和现实区分得如此泾渭分明呢？</p>
<p dir="auto">当你真正思考创作的时候，立刻就会发现隐藏在你爱、或是爱你的角色的真相。角色存在的本意就是达成作者的目的（为了讨读者喜欢，为了挣钱等），属于“刻意营造的幻想商品”。角色本身并不纯粹。作者为了让你喜爱，为了让你讨论，为了让你阅读，为了让你掏钱。于是，角色必然要讨你喜欢，且只能讨你喜欢。</p>
<p dir="auto">不讨喜的设定，不好看的设计，在一开始就会被废弃掉。即使角色终于能在故事里出现了，创造者们也会根据风向调整各个角色的出场机会。如果一个角色不被大多数人关注，那么很明显，调高他的出场机会后获得的收益是明显比不过人气角色的。所以，在游戏里，这些角色仍然有延续故事的可能，但我们都知道，在商业上，这些角色已经被判死刑了。</p>
<p dir="auto">那为爱发电又如何呢？不好意思，这里才是切割角色的重灾区。为爱发电越纯粹，越意味着一意孤行，越容易整出大多数人不喜欢的活。只要故事还在被创作，你就逃不掉这个命运。</p>
<p dir="auto">这一阶段的读者开始知道“自己喜爱的角色”完全是为了“让你喜爱”而创造出来的产品，你喜欢的事物完全不像你想象的那么纯粹。在她们诞生的那一刻起，便带有最重要的任务：好好在故事里表演，向你展现出最吸引人的特质，让读者尽量喜欢上她们，然后狠狠爆他们的金币。</p>
</li>
<li>
<p dir="auto">在第三阶段中，读者走向了创作者，想要创作关于自己所爱的角色的，只属于自己的故事。不管别人怎么想，我创作的角色对我来说最为纯粹。你会借此离开原作创造出的功利角色的深渊，但随着创作的深入，却反而会慢慢滑向另一个深渊——虚无。</p>
<p dir="auto">刚开始创作的时候很美妙。你会反复观看已有故事，回忆自己与角色相遇时候的感觉，想象着与角色互动时的样子，并在甜蜜的感情驱动下将只属于自己的角色故事写下，期望者更多人看。</p>
<p dir="auto">如果没人看还好。如果不能坚持创作，那么也就到不了第三阶段了。在第二阶段，随着时间推移，很多人会掌握“无视”的技巧，以便让自己能纯粹地享受故事，享受和自己所爱的角色在一起的时光。但如果创作受到了欢迎，你也被鼓励，决定不停创作下去的话，那么有一个困难是必须要克服的：你的素材不是无限的，你很快就会感觉没东西可写了。</p>
<p dir="auto">于是你开始找灵感，看资料，记笔记。看的资料越来越多，笔记越来越长，创作得越来越得心应手。你了解你所爱的角色的萌点，知道什么样的性格最适合，也知道用什么样的情节最能凸显角色。你继续写，继续写，却发现你看的资料记的笔记已经和你的角色完全重合了。你惊觉不对，抬起头，结果看到市面上早就已经被类似的东西所塞满。放一个记忆点，加一个萌点，再配一个能强化上述两点的性格，好了原创角色完成。黑长直，金马尾，眼镜娘，搞笑役，不管是什么只要能让人记住就行。你开始觉得好像其他角色和你爱的角色也没什么不同，除了名字和所在的世界背景也不一样，她们的性格，记忆点，甚至于情节，都是大差不差的。你发现工业流水线上的产物或是功力不够的你自己的产物，全部都是这种复制人一样的东西。只要这么做，就会有人看，只要这么做，就会有人喜欢，只要这么做，就能爆人家的米，引来更多的关注。角色对于你不再是纯粹的角色，而成为了你手中的工具。你技巧运用得越是熟练，你的创作目的性就越明确。</p>
<p dir="auto">然后，你会发现你已经无法纯粹地欣赏一个角色了。你不可避免地带着有色眼镜去看角色。你发现不管大家怎么创作，好看的故事大多数必然有其定式，不按套路来的故事没几个好看的。你想逃开形式的桎梏，想逃开这一切，但你逃不开。你用你的技巧创作的故事受到了一致差评，大家都觉得你的故事落了窠臼。你随手发的癫却被一群人疯狂叫好，交口称赞，而现在的你根本不知道为什么。你觉得故事不是你的，角色不是你的，什么都不是你的。你想要什么就有什么，但你仍然觉得虚无，因为你同时失去了一切。</p>
</li>
<li>
<p dir="auto">第四阶段的你则会更加现实。在明白了工业快餐产出的东西是如此惊人的一致后，你会对原来疯狂喜欢的东西快速失去兴趣，只是会偶尔翻一下，惊讶于我以前为啥居然会喜欢这种玩意。你逐渐对幻想失去兴趣，想要转向于现实，却发现你原本羡慕的，那些看起来非常活跃的交际花们，甚至比你更虚无。你至少知道有趣背后隐藏着的，令人不快的逻辑，而他们只是单纯的消耗着他们自己的生活。你发现他们对任何东西没有一个评价标准，甚至没有一个固定的价值观。抛开事实不谈，喜欢的东西就要全力夸赞，讨厌的东西就是要全力打倒。他们过着嘈杂而无趣的生活，自己却乐在其中。</p>
<p dir="auto">有时候你也会羡慕他们，但冷静下来又会觉得这样的自己很蠢，很丢脸。你又回去看了之前你喜欢的角色们，发现这些角色至少比那些把无聊当作有趣来炫耀的人好得多。你开始又能欣赏一点以前喜欢的角色，但你始终逃不开一个问题：你无法抛开一切，像最开始一样纯粹地喜欢她们了。她们仍然是那么地令人喜爱，丝毫没变，但你变了。你继续玩之前喜欢的游戏，继续写之前常写的文，但这些东西对你来说更像一种习惯。你要这样做只是因为你觉得你也许应该这样做，或是如果你不这样做，也没别的什么好做了。</p>
<p dir="auto">你终于意识到你的空虚不是因为幻想破灭的空虚，而是生活和情感上的空虚。你渴求不一样的生活但你做不到。你的生活一无所有。你过去的日子已经过去了不值一提。你现在的生活一成不变没有人关注。你也不知道你的未来会是什么样。你有过梦想，但现在已经基本放弃了，只是偶尔突然想到，感叹一下“我也有过这样的想法呀”然后把它们按下。</p>
<p dir="auto">你发现不是你选择了二次元而是二次元选择了你。从一开始就只有这一个命运。</p>
<p dir="auto">你变得不爱表达自己，即使你什么都知道。你知道你喜欢故事里的妹妹围着你转是因为你缺少关爱。你知道每次看完震撼人心的故事之后怅然若失的感觉是因为你生活的空虚。你开始附和一些烂梗，在无聊里找乐子。你逐渐能和人打成一片，但更多时候你则是什么也不干，什么也不想。你又开始羡慕之前的那些人，即使他们很蠢，他们的快乐好像也没有作假。他们不是痛苦的苏格拉底而是快乐的猪——你想着，但你连快乐的猪都做不了，你是想做快乐的猪而不得的猪，更加扯淡。</p>
<p dir="auto">最后，你开始接纳和这种空虚。你发现只要能好好欺骗自己，生活总还是能过得下去的罢。你逐渐开始故意忽略掉背后的一切，只为了能重新享受故事本身。你避免去参与任何过于认真的讨论，而号召别人像你一样专心得享受故事，享受幻想世界里和那些你爱着，也爱着你的角色经历的一切。你看严肃二创，也看发癫，你都觉得很乐呵。你认为对故事性还是乐趣性的争论完全没必要。你能理解很多东西，很多人，但你只希望能安静地享受一切，即使这种享受并不纯粹。</p>
<p dir="auto">你觉得你老了。</p>
</li>
</ul>
</li>
<li>
<p dir="auto">在第四阶段之后是什么这点暂且先按下不表，是不是觉得上面几种人非常有二次元的“味儿”？这也是我认为二次元的核心在于拒绝现实的原因。其实第四阶段基本就是那种一团和气的老二次元了，他们不喜欢看的坚决不看一点，喜欢看的也不会多么激动地讨论。好像对一切都不是特别在乎，但有时的意见又好像看得很清楚。</p>
<p dir="auto">写到这里，突然觉得一首诗很应景，暂且附上：</p>
<p dir="auto">少年听雨歌楼上，红烛昏罗帐。</p>
<p dir="auto">壮年听雨客舟中，江阔云低，断雁叫西风。</p>
<p dir="auto">而今听雨僧庐下，鬓已星星也。</p>
<p dir="auto">悲欢离合总无情，一任阶前，点滴到天明。</p>
</li>
<li>
<p dir="auto">那么，第四阶段之后呢？</p>
<p dir="auto">“世界上只有一种真正的英雄主义，那就是在认识生活的真相后依然热爱生活。”</p>
<p dir="auto">你觉得之后会是什么？</p>
<p dir="auto">你所想所做所爱的一切真的没有意义吗？</p>
<p dir="auto">正因为有虚无，所以存在才有意义。如果抛开存在，那么虚无就是完全。</p>
<p dir="auto">你想逃避的生活可能没有你想的那么毫无作用。正因为它以这样的方式存在，因此你才能领略到常人无法获得的风景，获得独属于你的故事。</p>
<p dir="auto">不管对象是谁，是什么，甚至是不是存在，爱本身就是最大的意义。</p>
<p dir="auto">我知道这些角色不存在，也知道她们是为了爆别人的米而创造出来的，还知道她们非常工业化，凹人设凹标签凹这个那个的。但那又怎样？</p>
<p dir="auto">我喜欢这样的角色。换个名字我喜欢，换个背景我还是喜欢，怎么着吧？</p>
<p dir="auto">你要是只想说这堆屁话就赶紧滚边去算了。你的目的到底是为了揭露现实还是为了你那点可怜的优越感啊？</p>
<p dir="auto">老子喜欢——关你屁事？</p>
</li>
</ul>
</li>
</ul>
<p dir="auto">后记</p>
<p dir="auto">起因是看到邮箱贴吧有8u发了个癫……就是纯度非常高的那种，有点现实和幻想区分不清，大致就是“真的爱上了一个不存在的人该怎么办”。我第一反应是“这也太二次元了吧……”，但后来一想，我为什么会觉得这非常二次元？我认为的二次元到底什么？于是就有了上文。</p>
<p dir="auto">你看，真要认真思考的话这份爱非常奇怪，因为任何不存在的角色只有侧写。你可能知道她是个傲娇，性子别扭，责任感强，会帮你处理工作，有时还会训你……你看，这全部是角色的侧面。你所认知的角色本质就是这些侧面的集合体，证据就是以上内容我其实说的是黑猫，哈哈。</p>
<p dir="auto">你知道如果你偷懒的话就会被说，连被说什么话你都能一字不差地复述出来。你当然知道，因为剧情就是这样的。但如果你对她说今晚我们一起去吃超辣火鸡面吧，她会怎么回应你能知道吗？你不知道，因为剧情没有写。你确实可以一遍遍的看剧情来让角色活过来，但在角色的各个侧面之间的缝隙里仍然是什么都没有。</p>
<p dir="auto">所以你爱上的只是戳一下按固定流程复读一句台词的洋娃娃吗？你自然可以脑补出一百个符合设定的回应，但在这一刻，这个角色成为了你的一部分：你希望她这么回应，于是她就会这么回应。这个侧面的她完全由你的心意而动。她是她，她也是你。</p>
<p dir="auto">你爱上的是你的幻想……这其中并没有第二人能插足之处。你其实爱上的是另一个自己，是你喜欢的另一个自己，是你想成为但没能成为的自己，是平行世界穿越而来的，有着你喜欢的样子，想要像你所期望地那样，温柔安抚你的自己。</p>
<p dir="auto">你用你的幻想来逃避现实。在另一个世界，你成为了完全。你可以成为任何你想成为的人，拥有任何你想拥有的感情。</p>
<p dir="auto">你用这种方式和自己对话。你用这种方式治愈你。</p>
<p dir="auto">这大概也是很多人不喜欢二次元的原因，因为看起来真的很傻……</p>
<p dir="auto">不过我要说的是，逃避既不可耻而且还很有用。毕竟站着说话不腰疼的人没办法帮你承担任何东西。人家是嘴爽了，你自己回头一看处理自己生活的还是只有你自己。</p>
<p dir="auto">我的意思不是只要逃避就好。单纯的逃避是一条死路，因为你毕竟没有活在幻想里面也没办法活。逃避是手段不是目的。</p>
<p dir="auto">你过的是你自己的人生。你想做什么就可以做。你可以把角色当故事，可以把角色当作人，可以把角色当你自己。只有你才能为你自己的人生负全责。一件事让你感到不快，你就可以不做。让你开心，你就可以做。权衡情感与得失之后，选择你认为能做到的最好的方法去做就可以了。至于其他人，关他们屁事。</p>
<p dir="auto">“He who has a ‘why’ to live for can bear almost any ‘how’。”</p>
<p dir="auto">没有人是为了逃脱痛苦而活下去的。大家都是为了生活中各种各样美好事物而活下去的。</p>
<p dir="auto">你爱角色，你需要角色，你把角色当成人即使你知道她们不是，你对角色的感情让你感受到被爱——这就已经很好了。如果这是你生活中的色彩，那就不要让别人夺走它。</p>
<p dir="auto">……写的好长，完全成论文了都。干脆直接用大刘的一段话作为结尾好了——</p>
<p dir="auto">“但是我喜欢上了一个不存在的人，她只是我虚构出来的，这难道不是一种病态?”<br />
“不，不是，其实你很幸运，不管她是不是真实存在，能爱就很幸运了。”</p>
]]></description><link>http://designhub.top/topic/67/关于二次元的一些思考</link><guid isPermaLink="true">http://designhub.top/topic/67/关于二次元的一些思考</guid><dc:creator><![CDATA[GShion]]></dc:creator><pubDate>Tue, 30 Jan 2024 16:55:28 GMT</pubDate></item><item><title><![CDATA[【文案基础】总而言之、勇士最后击败了魔王]]></title><description><![CDATA[<p dir="auto"><a href="/assets/uploads/files/1705886596400-%E6%80%BB%E8%80%8C%E8%A8%80%E4%B9%8B-%E5%8B%87%E5%A3%AB%E6%9C%80%E5%90%8E%E5%87%BB%E8%B4%A5%E4%BA%86%E9%AD%94%E7%8E%8B-by-shion.zip">总而言之、勇士最后击败了魔王 by Shion.zip</a><br />
<img src="/assets/uploads/files/1705886625449-4d294811-83b8-441c-825f-a420e78f0f6f-image.png" alt="4d294811-83b8-441c-825f-a420e78f0f6f-image.png" class=" img-responsive img-markdown" /></p>
]]></description><link>http://designhub.top/topic/66/文案基础-总而言之-勇士最后击败了魔王</link><guid isPermaLink="true">http://designhub.top/topic/66/文案基础-总而言之-勇士最后击败了魔王</guid><dc:creator><![CDATA[GShion]]></dc:creator><pubDate>Mon, 22 Jan 2024 01:23:46 GMT</pubDate></item><item><title><![CDATA[现在直播弹幕游戏好像很火]]></title><description><![CDATA[<p dir="auto">就是直播中发弹幕来玩游戏的那种。<br />
……但是，我没懂……我是那种不看直播的人，所以基本可以算完全不了解。</p>
]]></description><link>http://designhub.top/topic/65/现在直播弹幕游戏好像很火</link><guid isPermaLink="true">http://designhub.top/topic/65/现在直播弹幕游戏好像很火</guid><dc:creator><![CDATA[GShion]]></dc:creator><pubDate>Mon, 22 Jan 2024 01:01:45 GMT</pubDate></item><item><title><![CDATA[早知道当策划这么苦逼，我就提桶跑路了！]]></title><description><![CDATA[<p dir="auto">做蛋炒饭的时候最好准备两个蛋，先打一个蛋做炒蛋，要炒得一块一块的，捞出待用。<br />
然后做炒饭，记得和炒蛋一样，炒饭也是要多放油的。然后要多炒一会儿，把水汽蒸出来，让口感不粘，并且让饭粒不容易粘在一起。<br />
再打一个蛋搅散，均匀地浇在炒饭上，趁没有凝固前快速翻搅均匀。这一步是为了上金黄色，并且让饭粒口感更弹。<br />
饭粒表面的蛋液熟了后，加入之前的炒蛋，加盐调味，出锅。<br />
这样做出来的炒饭又好看又好吃，还能吃到大块的蛋。<br />
如果仅用炒好的蛋和饭一起炒，那么饭不会是金黄色。如果仅用蛋液炒，那么饭里只有蛋花，没有一块块的蛋。</p>
]]></description><link>http://designhub.top/topic/11/早知道当策划这么苦逼-我就提桶跑路了</link><guid isPermaLink="true">http://designhub.top/topic/11/早知道当策划这么苦逼-我就提桶跑路了</guid><dc:creator><![CDATA[GShion]]></dc:creator><pubDate>Mon, 22 Jan 2024 01:00:21 GMT</pubDate></item><item><title><![CDATA[【行业知识】策划的发展路径]]></title><description><![CDATA[<h2>前言</h2>
<p dir="auto">游戏策划是设计游戏的人，所以并不是“喜欢玩游戏”就适合成为游戏策划的。只有喜欢玩游戏，喜欢思考游戏为什么要这么做，同时还抱有想要创作游戏给其他人玩的心态的人才适合做游戏策划。</p>
<p dir="auto">游戏策划关注点相对于玩游戏本身，更在于游戏设计得好不好，也就是“要达成什么目的”，“采用什么方法”，“这些方法有什么优缺点”。即使一个策划干的都是非常事务性的工作，也不能放弃对设计的思考。不管是什么策划，都一定要清楚自己在做什么以及为什么这么做。</p>
<h2>游戏策划的基本素质</h2>
<p dir="auto">严格来说，这些基本素质并不是判断一个人是否算是一个好策划的条件——而是判断一个人能不能够做策划的条件。只要算得上是一个策划，那么必然不能缺少以下素质中的任何一个。</p>
<ol>
<li>
<p dir="auto">职业素养</p>
<p dir="auto">自从决定从事这个职业开始，你对自己的定位就不能是“游戏爱好者”，而必须是“职业的游戏设计者”。能力暂且不论，职业二字意味着你不能再随着自己的喜好来对待工作。也许有时候你会觉得派给你的工作很傻，很没意思，不想做，但你至少要把这件事给做到合格。如果上司给了你修改意见，即使你觉得这样做很蠢，那仍然也要做到合格。如果觉得公司不适应或者工作不适应没法待，可以随时离职——但在离职之前，你要对你手上的所有工作负最基本的责任，即尽自己最大努力让它们合格。</p>
</li>
<li>
<p dir="auto">思考设计背后的逻辑</p>
<p dir="auto">无论是在工作中还是自己玩游戏的时候，都要思考游戏为什么要这么设计，有什么优缺点，可以怎么修改。这也是游戏策划和一般玩家最大的不同。玩家知道他喜欢玩哪些游戏，但当他描述游戏好玩在哪里的时候，大多数玩家都仅仅会关注体验层，也就是“我玩的爽的时候，我的感受是什么”。游戏策划则应当想得更深，也就是“游戏做了什么让我有这种感受”。思考“为什么要这样做”是学习游戏设计最快的方法，也是游戏策划不可缺少的素质。</p>
</li>
<li>
<p dir="auto">兼收并蓄，求同存异</p>
<p dir="auto">创作者如果对自己的作品没有自信，那他就不可能成为创作者。但在工作中，一个一意孤行的人不仅处理不好与同事的关系，也有可能导致很严重的后果，甚至导致项目整体停摆（现成的例子，少女前线2……）</p>
<p dir="auto">游戏策划更应如此。不懂得听其他人意见的人，只会做出只有自己喜欢玩的游戏。</p>
<p dir="auto">在工作中，要积极表达意见，但也要包容别人的意见，仔细思考做出取舍，和其他人充分沟通，讨论完成后，听从最应该对此事负责的人（也许是你自己！）的意见行动。</p>
</li>
</ol>
<h2>游戏策划的工作内容</h2>
<p dir="auto">很多人对游戏策划这个职业有一种常见的理解：游戏策划就是满脑子想一些有的没的，大致描述一下，写成文档之后叫程序做，完事！</p>
<p dir="auto">但实际上，策划打交道最多的东西反而不是策划案，而是表格。</p>
<p dir="auto">注意，游戏是由一系列数据构成的。如果没有数据源，那么游戏就没办法称之为游戏。在写完策划案之后，更多的时间将会被用来完善体验与验收游戏表现，很多策划戏称自己为填表工具人就是因此。所以策划应当熟练使用Excel。</p>
<p dir="auto">另外，同样常做的事则是跟进，也就是你需求提完发给程序后，确认并保证程序按时按质做完的整个行为。这也是主催为什么叫主催，是因为真的很能催……</p>
<p dir="auto">整体来说，策划的工作内容就是写策划案，维护表格，以及跟进和汇报进度。</p>
<ol>
<li>
<p dir="auto">策划案</p>
<p dir="auto">说一下最容易出现的误解。策划案实际上是给程序、美术等需求下游人员看的，而不是给策划自己看的。因此，你的策划案至少需要具有可执行性，而不是仅仅你的思路文档。这也是我建议策划都应该学一点程序的原因。你得知道你写成什么样，程序才能知道该怎么做，不过于简略也不过于详细。</p>
<p dir="auto">你写的策划案，其实是程序的工作任务说明书。一定要认真对待它们。</p>
</li>
<li>
<p dir="auto">维护表格</p>
<p dir="auto">合格的策划可以单从表格设计上就看出很多东西，不过作为萌新，只要知道Excel的基本操作、公式以及数据类型就可以了。</p>
<p dir="auto">注意，维护表格的时候千万记得写字段注释，否则的话这表格离了负责人没人能接手得了，或是要花费大量时间和程序对清楚了才能交接。</p>
<p dir="auto">可以把“维护表格”的行为理解为“面向Excel编程”，哈哈。</p>
</li>
<li>
<p dir="auto">跟进以及汇报进度</p>
<p dir="auto">是很多人容易忽略的一点，但其实是策划最核心的技能了。如果你不会跟进，不好好跟进，那你就必然没有任何产出，因为你把是否能产出合格的成品这件事完全赌在了程序一个人身上。程序只有跟你有足够的默契，给你排足够的时间，并且对你的需求有足够的职业素养的情况下才能按时做好功能，就这还不能保证有没有bug。</p>
<p dir="auto">因此，一定要常常跟进进度和汇报进度。</p>
</li>
</ol>
]]></description><link>http://designhub.top/topic/64/行业知识-策划的发展路径</link><guid isPermaLink="true">http://designhub.top/topic/64/行业知识-策划的发展路径</guid><dc:creator><![CDATA[GShion]]></dc:creator><pubDate>Mon, 22 Jan 2024 00:52:54 GMT</pubDate></item><item><title><![CDATA[就要在这里发电！]]></title><description><![CDATA[<p dir="auto">59e0f23c-dc51-46f1-ba28-ec72aa0d406e-image.png</p>
]]></description><link>http://designhub.top/topic/50/就要在这里发电</link><guid isPermaLink="true">http://designhub.top/topic/50/就要在这里发电</guid><dc:creator><![CDATA[Pamisu]]></dc:creator><pubDate>Sat, 16 Dec 2023 05:42:14 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[【项目管理】项目管理杂记]]></title><description><![CDATA[<p dir="auto"><strong>统筹工作优先级问题</strong></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>
</ul>
</li>
<li>
<p dir="auto">不赚钱</p>
<ul>
<li>有没有什么事情会导致这个游戏不赚钱，常见于没商业化，商业化不好，或者游戏数值体验很差的情况。</li>
</ul>
</li>
<li>
<p dir="auto">不好玩</p>
<ul>
<li>基本上就是指的游戏循环有问题，游戏的核心玩法不够有趣，养成不够激励，活动让人没动力参加等。</li>
</ul>
</li>
</ul>
<p dir="auto">以上问题才是统筹工作中的核心问题，不要被细节带偏。</p>
<hr />
<p dir="auto"><strong>合/切版本的问题以及原则</strong></p>
<p dir="auto">版本目的：切出一个1.稳定2.完全可控的版本<br />
其次是尽量进已做的东西，但不能和前两条抵触<br />
最重要的其实是稳定和完全可控，否则的话切版本没什么意义。这就意味着，只有一个东西全部测好了，才能进版本，否则的话就不要进。</p>
<hr />
<p dir="auto"><strong>使用禅道或表格进行工作内容管理的好处与缺点</strong><br />
禅道</p>
<p dir="auto">--好处</p>
<ul>
<li>有较为详细的工作进程以及修改记录，可以进行多次备注，记录均有留档而且不对可读性产生影响</li>
<li>可以方便地找出负责处理的人和负责跟进的人</li>
<li>内容记录支持各种格式</li>
</ul>
<p dir="auto">--坏处</p>
<ul>
<li>相对而言，不那么容易对一类工作进行统筹规划与更新（使用标题/类别/关键词）</li>
<li>每完成一个工单，只能由负责处理的人变更状态，寻找到该工单并立刻即时更新工单这个行为较为繁琐</li>
<li>工单名字不足以描述需求内容时，需要点进去才知道详细内容，工单列表信息完整度低</li>
<li>无法支持特定自然语言筛选，或是支持起来稍有繁琐（需要增加关键词栏目）</li>
</ul>
<p dir="auto">表格</p>
<p dir="auto">--好处</p>
<ul>
<li>可自由定制，一张表格所属的内容全部就是同类内容，不用额外花心思去点开一个个禅道单子看是否有更新</li>
<li>可以方便地复制信息，支持开发端额外的自定义处理</li>
<li>支持特定自然语言筛选</li>
<li>标题与内容在同一个页面，列表信息完整度高，无需额外操作，容易统筹规划</li>
<li>可以由策划变更状态，无需完成每个东西就要进行工单操作</li>
<li>可同时支持禅道</li>
</ul>
<p dir="auto">--坏处</p>
<ul>
<li>工作记录本身与工作记录的过程无法留档</li>
<li>如果同时支持禅道，要附加额外的操作，且禅道信息滞后</li>
</ul>
<p dir="auto">因此，工作中尽量使用禅道，只有在突然要做一批关联性很弱且内容非常多的工作时，才使用表格。此时，策划控制禅道和表格，优先以表格处理内容，但禅道单子不能少。程序看表格完成工作，不看禅道，只听策划说“这个单子可以点解决了”就点解决。最终仍然是走打印出来的纸质禅道单子。</p>
<p dir="auto"><strong>晨会</strong></p>
<ol>
<li>大件事问一下工作中没什么困难</li>
<li>有排期的事情可以问一下能不能按时做完</li>
<li>紧急的事情确认一下状态</li>
<li>同步一下方向与进度相关的信息<br />
其实比较重要的是如果没有特殊事情的话，稍微提振一下士气，或者至少清一下心情和脑子，摆脱起床气和起床迷糊，进入工作状态。不过这个好难……之后有心得再更新吧。</li>
</ol>
<hr />
<p dir="auto"><strong>以清理一批遗留问题，增加游戏的可游玩性（能够当作游戏来玩的程度，而不是乐趣程度）为主要工作内容时的优先级</strong></p>
<p dir="auto">S优先级：</p>
<ul>
<li>
<p dir="auto">正常游戏过程中，固定复现或有一定概率复现的卡死</p>
<ul>
<li>1792 <strong>【1.0.027_2023.07.06】bug：1-10副本退出卡死</strong></li>
</ul>
</li>
</ul>
<p dir="auto">A优先级：</p>
<ul>
<li>
<p dir="auto">非正常游玩或是非常见操作导致的固定复现或是有一定概率复现的卡死</p>
<ul>
<li><strong>1736</strong> <strong>【1.0.027_2023.07.06】bug：新人引导快速点击不同区域，引起卡死</strong></li>
</ul>
</li>
<li>
<p dir="auto">不卡死，但相对于其他游戏像bug的体验，或是确实有这个bug（先不考虑是否h5只能做成这样，除非给出了结论目前这段时间怎么都解决不了）</p>
<ul>
<li><strong>1712</strong> <strong>【1.0.027_2023.07.06】bug：眩晕技能结算异常</strong></li>
</ul>
</li>
<li>
<p dir="auto">正常游戏过程中，小概率复现的卡死（连续10次未复现，或20次测试只复现1次）</p>
<ul>
<li>目前没有这类单子</li>
</ul>
</li>
<li>
<p dir="auto">已经完成的功能，游玩体验理应和参考竞品一样，但是实际上不一样的地方</p>
<ul>
<li><strong>1733</strong> <strong>【1.0.027_2023.07.06】bug：指引光圈太模糊</strong></li>
</ul>
</li>
</ul>
<p dir="auto">B优先级：</p>
<ul>
<li>
<p dir="auto">不像bug但是感觉较差的体验，或是观看感受较差的表现，包括图片，音效，特效，点击反馈等，主要在于是不是感觉体验上这里还没有做任何的反馈优化，或是反馈优化比较差</p>
<ul>
<li>1691 <strong>【1.0.027_2023.07.06】优化：补签次数显示调整</strong></li>
</ul>
</li>
</ul>
<p dir="auto">C优先级：</p>
<ul>
<li>
<p dir="auto">不像bug但是感觉不好的体验，或是观看感受不好的表现，包括图片，音效，特效，点击反馈等，主要在于反馈优化是不是已经有了，感觉能过得去但还能提升</p>
<ul>
<li><strong>1694</strong> <strong>【1.0.027_2023.07.06】优化：技能图标修改</strong></li>
</ul>
</li>
<li>
<p dir="auto">新功能需求</p>
<ul>
<li><strong>1805</strong> <strong>【1.0.027_2023.07.06】需求：嘉年华功能</strong></li>
</ul>
</li>
</ul>
<p dir="auto">就是按照影响程度大的优先做。快速从0分堆到60分这样子。</p>
<hr />
]]></description><link>http://designhub.top/topic/61/项目管理-项目管理杂记</link><guid isPermaLink="true">http://designhub.top/topic/61/项目管理-项目管理杂记</guid><dc:creator><![CDATA[GShion]]></dc:creator><pubDate>Thu, 26 Oct 2023 11:26:56 GMT</pubDate></item><item><title><![CDATA[【项目管理】对问题的讨论以及沟通方式]]></title><description><![CDATA[<h1>沟通方式</h1>
<ol>
<li>
<p dir="auto">逻辑上做不了的才能说这个事情不能做，要表达【从逻辑上就无法实现】。</p>
</li>
<li>
<p dir="auto">逻辑上做得了，但是自身能力可能做不了的时候，要表达【我估计做不来】。</p>
</li>
<li>
<p dir="auto">逻辑上做得了，但是需要其他支持（如时间，资源，人力等）才能做到满足的条件时，要表达【要给xxx支持】。</p>
</li>
<li>
<p dir="auto">逻辑上做得了，但是自己不太想做的时候，要表达【这件事我不太想做】，并说明原因。</p>
</li>
<li>
<p dir="auto">逻辑上做得了，但是这么做会存在xx风险的时候，要表达【这事这么做能做，但是xx风险怎么办】。</p>
</li>
</ol>
<p dir="auto">拒绝【这事我做不了】和【之后这种事情别找我】的表达方式。并且，以上的表述不是推脱事情的借口。</p>
<hr />
<h1>（当这玩意我不想做的时候）该怎么沟通</h1>
<ol>
<li>
<p dir="auto">聚焦于问题</p>
<ol>
<li>任何表达最终都是为了解决这个问题，而不是仅仅表达我不想做</li>
<li>无论是延期解决，交给别人解决还是更改工作内容，讨论出来的结果都是合适的结果，而不能没有任何结果。</li>
</ol>
</li>
<li>
<p dir="auto">事务详情分析</p>
<ol>
<li>
<p dir="auto">这件事情理应是我做还是理应是其他人做？</p>
<ul>
<li>理应是我做：进入2</li>
<li>理应是其他人做：表达这件工作理应给其他人做，并且说明自己当前没有时间做，或者帮助他人的这个工作需要进入排期。</li>
</ul>
</li>
<li>
<p dir="auto">这件事情是当前已经排期的工作吗？</p>
<ul>
<li>已经排期：如果当前已经正在做某项工作，那么询问一下这件事是否紧急到现在立刻做，并进入3。</li>
<li>没有排期：除非确认该工作重要且紧急，不然就要要求该项工作排期到空余时间或者下个版本再做。</li>
</ul>
</li>
<li>
<p dir="auto">这件事情工作量如何？</p>
<ul>
<li>比较大：说明该事情工作量较大，需要的时间比较长，建议考虑整体的排期是否符合情况。</li>
<li>无法确定：说明当前事情工作量无法确定，需要给一段时间预研，完成后才能大致估一个时间。（需要注意的是，此处只是将“完全无法预估”的时间分了段，本质是还是“完全无法预估”，只不过是“先用xx时间做第一步，然后得出初步调查结果。如果预研时间也无法确定，那就先给一天时间看看工作进度，然后再大致得出比较合适的预研时间。在这个阶段其实是策划衡量投入和产出的时候，程序在这一阶段无法完全保证产出。很有可能出现这样一个结果：花1天时间看代码，得出大致看一遍需要7天。大致看了7天之后，没有看出任何问题，可能需要更细致地看——这时候就是策划来决定当前是否有必要投入x人每日x时间去做这一件事情。）</li>
<li>工作量不多，但目前工作比较繁杂：说明这一点，并且建议将该事情排后，或者将目前在做的其他事情排后，或是分出部分活给别人做。</li>
<li>我现在很烦：直接说明这一点，并且说明自己的状态可能不适合将要分派的这个活，不太能保证完成结果。当然，要注意说“自己为什么很烦”和“怎么分配目前的工作，我才能不那么烦”。不要对说这些感到羞耻，但也不要拿这个当作挡箭牌。尤其是，不能一直用这个理由——容易被开。</li>
<li>其他不想做的原因：说明这一点。并和策划沟通看看如何才能解决这个问题，最好带上自己的思考与方法。</li>
</ul>
</li>
</ol>
</li>
<li>
<p dir="auto">以上，本质上都是聚焦于问题：我希望所有人都要有这个素质。胡乱扯皮是一件很简单的事情，但这没有意义，因为问题还是在那。如果聚焦于问题，就算最后扯到“这个问题无法解决”或者其他非常困难的结论，起码我们也知道了问题在那，为什么出现这个问题，谁能为解决问题负责，能负责多少，尽力去做的话这事能做成什么样。这个结论出来了之后，即使事情失败了，起码自己和团队问心无愧。另外，扯皮的目的不是为了“让其他人担责”，而是“让该担责的人担责”（不是“让该被惩罚的人被惩罚”！千万注意这一点）。问题清楚了，来源清楚了，权责清楚了，那么事情就只是一个协调资源，或者无能为力的简单判断了。</p>
</li>
</ol>
]]></description><link>http://designhub.top/topic/60/项目管理-对问题的讨论以及沟通方式</link><guid isPermaLink="true">http://designhub.top/topic/60/项目管理-对问题的讨论以及沟通方式</guid><dc:creator><![CDATA[GShion]]></dc:creator><pubDate>Thu, 26 Oct 2023 10:45:53 GMT</pubDate></item><item><title><![CDATA[【表格配置】与任务相关的活动配置逻辑]]></title><description><![CDATA[<p dir="auto">很明显，这里的活动类型只有一种：“完成XXX任务”的活动。该类活动能够覆盖大多数需要的活动情况。</p>
<h1>1.一个活动对应一组任务</h1>
<p dir="auto">易得需要的资料为id，活动显示所在位置，活动时间，活动类型，活动任务及任务奖励。</p>
<p dir="auto">其中，id、显示所在位置、活动时间、活动类型、引用的任务组和奖励组为一张表，命名为活动表。该表用作具体活动的配置。</p>
<p dir="auto">具体的任务为另外一张表。该表用于活动任务及奖励的配置。</p>
<p dir="auto">达到的目标是可以使用活动类型与任务目标和奖励的组合实现多种多样的任务。</p>
<p dir="auto">例如：<img src="/assets/uploads/files/1698306364263-3620c885-040a-40e9-b417-f5108afdc420-image.png" alt="3620c885-040a-40e9-b417-f5108afdc420-image.png" class=" img-responsive img-markdown" /></p>
<h1>2.一个活动对应多组任务</h1>
<p dir="auto">和1的区别是一个类型对应多组页签。因此活动任务（及奖励）需要有页签的区分，需要增加对应数据。数据可以增加在活动表里，也可以增加在奖励表里，不过推荐放在活动表里。注意，这个地方的意思是【一个】活动有【多个】任务组，也就是任务id只有一个，但是任务组有两个。例如：ID1，名称开服基金，任务内容为&lsqb;&lsqb;开服基金:kfjj1,kfjj2,kfjj3,kfjj4],[全民福利:qmfl1,qmfl2,qmfl3,qmfl4&rsqb;&rsqb;。</p>
<p dir="auto">例如：</p>
<p dir="auto"><img src="/assets/uploads/files/1698306391483-d428d4a2-01d0-4480-9eb1-28f6b8ab0b55-image.png" alt="d428d4a2-01d0-4480-9eb1-28f6b8ab0b55-image.png" class=" img-responsive img-markdown" /></p>
<h1>3.一个大活动，由多个单独的小活动组成，每个小活动对应一组任务</h1>
<p dir="auto">那么现在的活动类就包括了大活动和小活动。很明显大活动是由小活动组成的，因此将它们配在同一张表里就有点逻辑混乱。因此需要新建一张表。</p>
<p dir="auto">不过大活动也是活动，同样属于活动类，因此也需要小活动所需要的字段，只是额外增加了“由哪些id的小活动组成”的新字段。</p>
<p dir="auto">其实这里的表现和上一个有点像，但区别是上一个的逻辑是（活动表）活动是一个id，这里是多个id。这个其实就是正常的多活动，本质上是1的复数形式，同时配了一堆活动，只不过显示在一个地方。</p>
<p dir="auto">例如：</p>
<p dir="auto"><img src="/assets/uploads/files/1698306427284-6b00d7d4-3686-48e9-9e40-f7287707692f-image.png" alt="6b00d7d4-3686-48e9-9e40-f7287707692f-image.png" class=" img-responsive img-markdown" /></p>
<h1>4.一个大活动，由多个单独的小活动组成，每个小活动对应多组任务</h1>
<p dir="auto">综合123即可得出结论。</p>
<p dir="auto">例如：</p>
<p dir="auto"><img src="/assets/uploads/files/1698306435227-6e3ce1ba-b7df-49aa-8edc-355890f93f61-image.png" alt="6e3ce1ba-b7df-49aa-8edc-355890f93f61-image.png" class=" img-responsive img-markdown" /></p>
]]></description><link>http://designhub.top/topic/59/表格配置-与任务相关的活动配置逻辑</link><guid isPermaLink="true">http://designhub.top/topic/59/表格配置-与任务相关的活动配置逻辑</guid><dc:creator><![CDATA[GShion]]></dc:creator><pubDate>Thu, 26 Oct 2023 07:47:17 GMT</pubDate></item></channel></rss>