Design Hub

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

    Pamisu

    @Pamisu

    管理员

    2
    资料浏览
    65
    帖子
    0
    粉丝
    0
    关注
    注册时间 最后登录

    Pamisu 取消关注 关注
    管理员

    Pamisu 发布的最新帖子

    • Godot 2D光照 - 物体自身阴影遮挡设置

      记录一些在Godot中使用2D光照时遇到的问题和解决方法,本篇笔记包含以下内容:

      • 控制物体是否受自身阴影影响的不同设置方法
      • 理解光照相关的图层和各种Mask的作用

      在Unity中,用来投射阴影的Shadow Caster 2D组件有一个Self Shadows属性用来控制阴影是否影响物体自身。

      Pasted image 20250504183337.png

      Godot的光照遮挡器LightOccluder2D节点和TileSet里都没有类似的选项,默认都是影响自身。例如下图中方块物体和黑色墙体都被自身投射出的阴影遮挡了。

      Pasted image 20250504220112.png

      关于这个问题官方文档有这样一段说明:

      LightOccluder2D 遵循常规的 2D 绘图顺序。这对于 2D 灯光而言非常重要,因为可以用来控制遮挡器是否应该遮挡精灵本身。

      如果 LightOccluder2D 节点是精灵的同级节点,并且场景树中的遮挡器被放在精灵的下方,会遮挡住精灵本身。

      如果 LightOccluder2D 节点是一个精灵的子节点,如果在 LightOccluder2D 节点中禁用了 Show Behind Parent(显示在父级之后)这个遮挡器将遮挡住精灵本身(该选项默认禁用)。

      真的有用吗?以下是在4.4版本中测试的情况,分别是遮挡器位于精灵下方、上方、作为精灵的子节点并启用Show Behind Parent。

      Pasted image 20250520085233.png

      不难发现完全没有效果,就算有用这个方法也没法用于TileSet。

      所以这里总结一些比较普遍的解决方法,顺便介绍2D光照中各种Mask的作用与对应关系。前两种方法来自Catlike Coding的教程,不同的方法各有优缺点,在效果细节上也有差别。

      方法一 设置Cull Mode

      可以实现“物体不被自身阴影遮挡,可被其他物体的阴影遮挡”的效果。

      将LightOccluder2D节点的Cull Mode属性值设置为ClockWise或CounterClockWise,取决于顶点顺序,可以两个都试一下看哪个有效果。规律是如果顶点按逆时针排列则设置ClockWise,反之CounterClockWise,正好跟顶点顺序反过来。这个设置控制了遮挡形状是从内部还是外部投射阴影。

      Pasted image 20250504180335.png

      如果编辑器中Sprite被遮挡,可以调整Sprite2D和LightOccluder2D在场景树中的顺序,实际运行是没有区别的。

      Pasted image 20250504175706.png

      这样就做到了物体不被自身阴影遮挡,会被其他物体和墙体的阴影遮挡。

      Pasted image 20250504180238.png

      TileSet同样可以这样设置,选择TileMapLayer节点并打开编辑器底部的TileSet面板,按图中步骤操作即可。可以发现TileSet使用了类似的逻辑实现。

      Pasted image 20250504181841.png

      设置后墙体会变得和物体一样,不受自身投射出阴影的影响,会被其他墙体和物体的阴影遮挡。

      Pasted image 20250504182412.png

      但效果并不理想,视觉上墙体应该是一个整体,而实际上每一块墙体都是单独的遮挡器,互相之间会遮挡,导致看起来很奇怪,而且数量较多的情况下对性能也会有影响。

      教程里的目标效果是墙体不需要被照亮,即默认被自身阴影遮挡,但如果墙体需要照亮,这样设置满足不了要求,必须将墙体的遮挡器合并,类似这样:

      Pasted image 20250520113037.png

      很可惜目前Godot没有提供直接合并遮挡器的功能,上面是手动创建的,不适合大型复杂场景或者需要动态修改的情况,在这个提案里有提到可以使用2D导航/碰撞烘焙来实现,之后尝试如果可行再来补充。

      方法二 使用两个光源

      可以实现“物体不被自身阴影遮挡,不被其他物体的阴影遮挡,会被墙体的阴影遮挡,墙体被自身阴影遮挡”的效果。

      回退方法一中的所有修改,回到初始状态。把场景中的光源复制一份。

      Pasted image 20250504221607.png

      将光源2的Range Item Cull Mask属性改为2,同时把Shadow Item Cull Mask属性也改为2。

      Pasted image 20250504222054.png

      这一步意味着这个光源专门用来照亮Light Mask属性为2的物体,同时只会对Occluder Light Mask属性为2的物体投射出阴影。

      这一堆Mask看着有些头痛,总之先按步骤操作,之后会详细介绍每个Mask的作用和它们之间的关系,为什么这样设置就能有效果。

      然后将物体Sprite的Light Mask属性设置为2,LightOccluder2D节点保持原样不需要调整。

      Pasted image 20250504220330.png

      这一步让物体的Sprite将只会受到光源2的影响,呈现出的效果是物体不再被自身阴影遮挡,也不会被其他物体的阴影遮挡。

      Pasted image 20250504223727.png

      可以发现此时物体也不会被墙体投射出的阴影遮挡,如果需要则再做一项设置,在墙体TileMapLayer使用的TileSet中,将Rendering -> Occlusion Layers下墙体Tile使用的遮挡图层的Light Mask改为1与2同时点亮。

      Pasted image 20250504224117.png

      这一步让墙体在接受到光源1、光源2的光照时都投射出阴影,通过光源2投射出的阴影将会覆盖在物体上。

      Pasted image 20250504225120.png

      这种方法缺点很明显,需要双倍数量的光源,对性能有影响,适合低分辨率的像素游戏和光源较少的情况。

      如果只需要控制物体阴影自身遮挡,对物体和墙体之间的阴影遮挡没有要求,又不想使用两个光源,这种情况可以通过设置Mask来实现。

      理解各种Mask的作用

      官方文档中有时将Mask翻译为“遮罩”,有时不翻译。个人觉得“掩码”更贴切一些,类似计算机网络里的“子网掩码(Subnet Mask)”,都是用来做位运算。

      2D节点和UI控件节点都继承自CanvasItem节点,在CanvasItem中有Light Mask和Visibility Layer两个属性。

      Pasted image 20250513104628.png

      它们对应项目设置里2D Render的图层,共有20个,对应Mask中的20位。

      Pasted image 20250513104945.png

      是否渲染:Visibility Layer 与 Canvas Cull Mask

      Visibility Layer决定了物体位于哪个/哪些渲染图层,在视口Viewport中有一个与之对应的属性Canvas Cull Mask,用来控制这个视口渲染哪些图层,默认全部点亮,即渲染所有图层。

      Pasted image 20250513105709.png

      在运行时我们的场景会被放在一个名为root的窗口Window节点下,而Window正是继承自Viewport,也就是说默认有一个渲染所有图层的视口。

      Pasted image 20250513110220.png

      例如物体A的Visibility Layer点亮图层1,物体B的Visibility Layer点亮图层2、3、5,视口的Canvas Cull Mask点亮图层3、6。

      用视口的Canvas Cull Mask值分别与物体的Visibility Layer做与运算,结果不为0时表示需要渲染:

      物体A是否渲染 = 0000 0000 0000 0000 0001 & 0000 0000 0000 0010 0100 = 0000 0000 0000 0000 0000 = 不渲染

      物体B是否渲染 = 0000 0000 0000 0001 0110 & 0000 0000 0000 0010 0100 = 0000 0000 0000 0000 0100 = 渲染

      注意物体是否渲染还会受到其父物体的影响,如果父物体不渲染,子物体也不会渲染,但可以勾选CanvasItem的Top Level属性来取消这个限制。

      Godot原生支持多窗口,有基于这个特性开发独特玩法的游戏,例如《Windowkill》,这点是Unity做不到的。

      是否计算光照:Light Mask 与 Range Item Cull Mask

      类似地,CanvasItem中的Light Mask属性用来设置物体属于哪个/哪些光照图层,在2D光源Light2D中的Range组下有一个Item Cull Mask与之对应,用来控制这个光源在哪些图层上计算光照。

      Pasted image 20250513154420.png

      例如物体A、B都在光源范围内,物体A的Light Mask点亮图层1,物体B的Light Mask点亮图层2、3、5,光源的Range Item Cull Mask点亮图层3、6,那么只有物体B会被照亮。

      是否计算阴影:Occluder Light Mask 与 Light Mask 与 Shadow Item Cull Mask

      阴影有一些不同,它有三个属性参与控制,其实也很好理解,因为有投射出阴影的物体和接收阴影的物体,加上光源就是三个。

      对于投射阴影的物体,LightOccluder2D的Occluder Light Mask属性用来设置遮挡器属于哪个/哪些阴影图层;TileSet则是Rendering -> Occlusion Layers里图层项的Light Mask属性。

      Pasted image 20250513155829.png

      Pasted image 20250513160009.png

      对于接收阴影的物体,还是CanvasItem中的Light Mask属性,也就是说它既决定了物体的光照图层,也决定了阴影图层。

      在2D光源Light2D中的Shadow组下的Item Cull Mask与它们对应,用来控制这个光源在哪些图层上计算阴影。

      Pasted image 20250513160656.png

      例如物体A的Light Mask为1,物体B的Light Mask为2,遮挡器的Occluder Light Mask为3,光源的Range Item Cull Mask为1、2,Shadow Item Cull Mask为1、3,那么物体A、B会被照亮,遮挡器投射出的阴影会影响物体A,不影响物体B。

      方法三 划分图层

      可以实现“单独控制某个图层的物体是否被同图层物体的阴影遮挡”的效果,但对于不同图层物体之间的阴影遮挡不好控制。

      将场景中的物体和遮挡器划分到不同的图层,例如地板、墙体、墙体遮挡器、物体、物体遮挡器,可以在项目设置中给图层命名。

      Pasted image 20250513161405.png

      接着将各个物体的Light Mask属性都设置到对应的图层,遮挡器的Occluder Light Mask也一样。

      假设要实现物体和墙体都不被自身阴影遮挡,那么地板、墙体、物体图层都可以被照亮,即光源的Range Item Cull Mask点亮1、2、4图层;地板需要接收阴影,墙体、物体不需要接收阴影,墙体遮挡器、物体遮挡器需要投射阴影,即光源的Shadow Item Cull Mask点亮1、3、5图层。

      Pasted image 20250513162023.png

      Pasted image 20250513164014.png

      在此基础上,如果要做到方法二的最终效果,物体被墙体的阴影遮挡,这种情况还是得使用两个光源。

      将光源复制一份,光源1不照亮物体物体,阴影和之前一样;光源2只需要照亮物体,墙体遮挡器投射阴影,物体接收阴影。

      Pasted image 20250513165735.png

      Pasted image 20250513165910.png

      参考与素材来源:True Top-Down 2D by Catlike Coding

      发布在 游戏开发
      Pamisu
      Pamisu
    • Godot基于深度纹理的3D物体外轮廓描边

      Pasted image 20250418170632.png

      记录一下Godot中基于深度纹理的3D物体外轮廓描边效果的实现过程,这种描边适合用来高亮单个物体或多个物体的组合,同时不会占用物体内部空间,在较远的距离、较粗的描边情况下也有不错的效果。

      实现方式基本参考这篇帖子,有部分修改,帖子中只给了最终的结果,没有说明具体实现步骤和原理,本篇笔记将对此做一个较为详细的拆解,修复一些问题,同时记录踩到的坑。

      整体思路:

      1. 将需要描边的物体放在一个单独的图层,使用视口与后处理着色器将它们渲染到一张描边深度纹理
      2. 主相机下同样使用后处理着色器,读取描边深度纹理,与当前深度纹理作比较来检测边缘并描边

      当前版本Godot 4.4。阅读需要一些着色器基础,如果对Godot中的着色器不太了解,可以先阅读着色器简介、你的第一个着色器。

      渲染描边深度纹理

      准备场景

      首先搭一个测试场景,用MeshInstance3D显示一些简单的图元网格,例如方块、球体之类,物体之间有一些前后遮挡方便测试描边效果。

      Pasted image 20250418101841.png

      图中绿色的方块和粉色的甜甜圈是需要描边的物体,在它们的Inspector中,将Layers修改到另一个单独的图层,图层编号随意,不是默认的1就行,这里修改为11。之后如果要在运行时动态开启/关闭物体的描边,只需要在脚本中修改物体所在的图层,layers属性值为1时关闭,为 1 << 10 时开启。

      Pasted image 20250418102910.png

      注意先不要往场景里添加任何WorldEnvironment,会影响描边的显示,后面会说明不影响描边的世界环境如何设置。

      深度纹理

      深度纹理是一张包含画面中像素点与相机的距离信息的纹理,Godot中离相机越近,深度值越接近于1,反之越远越接近于0。这里通过视口来渲染一张自定义的用于描边的深度纹理。

      在场景根节点下添加一个SubViewport节点,其下添加一个相机,相机下再添加一个MeshInstance3D节点,并重命名。

      Pasted image 20250418105031.png

      OutlineSubViewport就是渲染描边物体图层的视口,描边深度纹理从这个视口获取,OutlineCamera做具体的渲染操作,OutlineDepth用于显示一个全屏四边形,它上面将会有一个后处理着色器用于获取并处理当前视口的深度纹理。

      视口

      OutlineSubViewport节点的Inspector中,勾选Transparent BG、取消勾选Handle Input Locally、勾选Use HDR 2D。

      Pasted image 20250418105313.png

      然后在OutlineSubViewport节点上挂载一个脚本,作用是在窗口大小变化时改变自身大小与窗口大小一致:

      viewport_fitter.gd

      extends SubViewport
      
      
      func _ready() -> void:
      	_match_root_viewport()
      	get_tree().get_root().size_changed.connect(_match_root_viewport)
      
      
      func _match_root_viewport() -> void:
      	size = get_tree().get_root().size 
      

      相机

      在OutlineCamera的Inspector中,将Cull Mask修改为11与12,11是之前设置的描边物体所在的图层,12则是处理描边深度纹理的全屏四边形所在的图层。

      Pasted image 20250418150634.png

      其他的参数如FOV、角度位置等等按情况调整,但需要跟之后的主相机保持一致。

      全屏四边形

      将OutlineDepth节点放在相机前方1米左右的位置。在它的Inspector中,Mesh属性下创建一个新的Quad Mesh,将Size改为2x2,勾选Flip Faces。

      Pasted image 20250418111918.png

      Godot中裁剪空间左下角坐标为(-1, -1),右上角坐标为(1, 1),所以面片的大小设置为2x2。

      在FileSystem中创建一个新的Resource,类型为Shader,命名为outline_depth.gdshader,在vertex函数中将当前顶点坐标赋值给POSITION输出,POSITION被写入后将会覆盖裁剪空间下的最终顶点位置,从而使这个面片覆盖全屏。

      outline_depth.gdshader

      shader_type spatial;
      // 设置渲染模式:禁用剔除、不计算光照、禁用阴影、禁用雾
      render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled;
      
      void vertex() {
        POSITION = vec4(VERTEX.xy, 1.0, 1.0);
      }
      

      从Godot 4.3开始改为使用反向Z(Reversed-Z)的深度缓冲,即近处深度为1.0远处为0.0,所以这里的w分量填1.0。反向Z的优势可以参考这篇文章

      在OutlineDepth的Inspector中,Meterial Override属性下新建一个ShaderMaterial,Shader属性设置为刚才创建的outline_depth.gdshader,并将Render Priority改为-1让它靠后渲染。

      Pasted image 20250418135440.png

      按照官方文档-高级后处理的说法,OutlineDepth是相机的子节点,所以它在运行时不会被裁剪。如果希望在编辑器中也能看到效果,可以给Extra Cull Margin属性设置一个非常大的值,例如16384.0,但实测有个副作用是会导致场景里的Gizmos显示不正常。

      同时将图层设置为12。

      Pasted image 20250418150751.png

      继续完善outline_depth.gdshader,在fragment函数中采样当前片元的深度纹理,给它加上一个较小的值0.00001,这样在后续做边缘检测时方便做深度值比较。将处理后的深度值乘以一个较大的值2048,整数部分放在r通道,小数部分放在g通道,这样可以保持精度,将这个颜色赋值给ALBEDO输出。

      outline_depth.gdshader

      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);
      }
      
      

      这一步之后OutlineSubViewport的Inspector中可以看到预览效果。

      Pasted image 20250418140520.png

      描边

      主相机

      有了描边深度纹理之后开始做主相机的描边显示,在场景根节点下添加一个Node3D作为主相机的根节点,在其下添加一个相机,相机之下添加一个RemoteTransform3D节点与一个MeshInstance3D节点。

      Pasted image 20250418141806.png

      主相机渲染除OutlineDepth外的所有图层,即取消勾选图层12。

      Pasted image 20250418170614.png

      FOV和位置视情况调整,但要和描边相机保持一致。

      RemoteTransform3D节点用来将描边相机和主相机的变换做同步,这样不管主相机的位置、旋转如何变化,描边相机都会一同变化。

      Pasted image 20250418142406.png

      Godot里这种贴心小功能比较多,虽然很简单也可以自己实现,但有开箱即用的多少可以省点时间。

      全屏四边形

      Outline节点和上面的OutlineDepth节点一样,都是全屏四边形,区别在于着色器不同、所在的图层不同。先依葫芦画瓢做好全屏四边形,接下来编写描边的着色器。

      在FileSystem中创建一个新的Resource,类型为Shader,命名为outline.gdshader,先定义一些参数和变量。

      outline.gdshader

      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() {
      	// ...
      }
      

      将outline.gdshader设置到Material Override中,图层保持默认的1。

      Pasted image 20250418144236.png

      Pasted image 20250418144313.png

      可以临时在fragment函数中给ALBEDO赋值一个颜色看是否正常。

      void fragment() {
      	ALBEDO = vec3(0.2, 0.8, 0.9);
      }
      

      此时运行如果看到的不是纯色而是场景内容,说明Outline节点不在相机视角内或与相机重合,调整它的位置到相机前方,同时也检查一下OutlineDepth节点的位置是否正确。

      读取描边深度纹理

      接下来给着色器的描边深度纹理参数赋值,点击Outline Depth Text属性,选择New ViewportTexture新建一个视口纹理,这时会有提示我们要先勾选Resource下的Local to Scene,勾选后再次创建,弹出的对话框中选择OutlineSubViewport,可以看到纹理预览。

      Pasted image 20250418144941.png

      前面在outline_depth.gdshader中,我们将深度值乘以了2048,然后把整数和小数部分分别放到了rg通道,这里用相反的操作还原深度值。

      outline.gdshader

      // ...
      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);
      }
      

      运行后不出意外的话是这样,说明成功读取到了描边深度纹理:

      Pasted image 20250418152325.png

      同样可以在outline.gdshader中采样当前的深度纹理depth_tex并显示,看看它长什么样:

      Pasted image 20250418152457.png

      内部检测

      在当前的深度纹理中,每个片元的深度信息大致可以这样表示,浅绿色物体是需要描边的物体,浅紫色物体是不需要描边的物体,由于它在浅绿色物体前方,所以它的深度值更大,而背景的深度值则是0。

      Pasted image 20250418160041.png

      在描边深度纹理中,我们给深度值加了一个很小的值0.00001,它的深度信息是这样:

      Pasted image 20250418160140.png

      如果当前深度值小于描边纹理中的深度值,并且屏幕像素的透明度大于0,则说明该片元在物体内部,可以混合内部颜色让物体内部高亮。

      outline.gdshader

      // ...
      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 < outline_depth && screen_color.a > 0.0;
      	
      	// 混合内部高亮颜色
      	screen_color.rgb = mix(screen_color.rgb, inner_color.rgb, is_inner ? inner_color.a : 0.0);
      	// 输出
      	ALBEDO = screen_color.rgb;
      }
      

      临时调整一下内部颜色,运行可以看到效果:

      Pasted image 20250418161338.png

      边缘检测

      由于要实现的是外轮廓描边,对于物体内部跳过检测。对于物体外部,按描边宽度检测当前片元周围是否存在描边物体,例如描边宽度为2,分别看周围距离为2的一圈、距离为1的一圈片元内是否存在描边物体,如果存在则当前片元是描边的一部分,输出描边颜色。

      Pasted image 20250418162736.png

      所有片元运算一遍后:

      Pasted image 20250418163124.png

      根据这个思路完善着色器代码:

      outline.gdshader

      // ...
      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 < outline_depth && screen_color.a > 0.0;
      	// 是否处于描边上
      	bool is_outline = false;
      	if (!is_inner) {
      		// 计算纹素大小
      		vec2 texel_size = 1.0 / vec2(VIEWPORT_SIZE.xy);
      		// 以当前位置为中心,判断周围的点是否存在描边物体,如果存在则提前跳出循环
      		for (int x = -outline_width; x <= outline_width && !is_outline; ++x) {
      			for (int y = -outline_width; y <= outline_width && !is_outline; ++y) {
      				if (y == 0 && 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 <= 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 < 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);
      }
      

      到这里描边便完成了,运行效果:

      Pasted image 20250418164442.png

      描边方法不是唯一的,这里的方法也不一定是最好的,总之只要能获取到描边深度纹理,之后的做法就多种多样了。

      一些坑

      世界环境

      前面提到先不要往场景里添加WorldEnvironment节点,会影响描边的显示,正确的方法是添加到主相机的Environment属性上。

      Pasted image 20250418170410.png

      Pasted image 20250418170632.png

      导入的模型

      对于外部导入的模型,需要注意其MeshInstance3D路径,双击模型文件打开导入设置查看。

      Pasted image 20250418170944.png

      可以编写脚本,在编辑器内填写路径,获取到MeshInstance3D节点控制它的图层。

      extends Node3D
      
      @export var outline_enabled: bool
      @export var mesh_path: NodePath
      
      var mesh: MeshInstance3D
      
      func _ready() -> 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 << 10
          else:
              mesh.layers = 1
      

      Pasted image 20250418171600.png

      Pasted image 20250418171605.png

      景深模糊

      相机开启景深模糊的情况下,如果描边物体后方有被模糊的物体或背景,描边也会被模糊。

      Pasted image 20250418172624.png

      Pasted image 20250418172957.png

      猜测原因是景深模糊位于内置后处理中(未确认),比描边着色器后执行,先描边再模糊导致描边也被模糊。

      一种解决思路是,把描边处理放到内置后处理之后执行,先模糊再描边。在Godot中,也有类似于Unity URP的RendererFeature的功能,叫做合成器,支持在渲染管线的不同阶段插入额外逻辑,但遗憾的是目前似乎不支持插入到内置后处理之后(CompositorEffect.EffectCallbackType),和URP的RenderPassEvent相比少了很多插入点,另外考虑到时间关系就没有去尝试了。

      另一种思路是,描边被模糊,说明它所处片元的深度被景深模糊判断在了需要模糊的区间内,从上图中也可以看出,被模糊的部分的深度值都较小。那么只要修改这些地方的深度,让它们等于物体内部的深度,就不会被模糊了。

      按这个思路对outline.gdshader进行修改,首先在渲染模式里加上一条depth_draw_always让它总是写入深度:

      outline.gdshader

      shader_type spatial;
      // 设置渲染模式:禁用剔除、不计算光照、禁用阴影、禁用雾、总是写入深度
      render_mode cull_disabled, unshaded, shadows_disabled, fog_disabled, depth_draw_always;
      
      // ...
      

      在fragment函数中,先写入当前深度到DEPTH,注意DEPTH一旦被写入,函数中的所有判断分支都需要确保DEPTH被写入:

      // ...
      void fragment() {
      	// ...	
      	// 是否处于描边物体内部
      	bool is_inner = depth < outline_depth && screen_color.a > 0.0;
      	// 是否处于描边上
      	bool is_outline = false;
      	// 写入深度
      	DEPTH = depth;
      	// ...
      }
      

      在是否为描边的判断中,如果当前是描边,并且当前深度要小于物体内部深度,则将当前深度改为物体内部深度:

      // ...
      void fragment() {
      	//...
      	// 写入深度
      	DEPTH = depth;
      	if (!is_inner) {
      		// ...
      		// 以当前位置为中心,判断周围的点是否存在描边物体,如果存在则提前跳出循环
      		for (int x = -outline_width; x <= outline_width && !is_outline; ++x) {
      			for (int y = -outline_width; y <= outline_width && !is_outline; ++y) {
      				//...
      				is_outline = neighbor_depth < neighbor_outline_depth;
      				DEPTH = is_outline && depth < neighbor_depth ? neighbor_depth : depth;
      			}
      		}
      	}
      	//...
      }
      

      描边不再被模糊了,但有些地方还是不太完美,之后有时间再完善了:

      Pasted image 20250418180201.png

      其他未解决问题

      以下问题由于暂时没有相关需求,所以暂时没有处理,如果您知道解决方法,或者有更好的实现方式,欢迎在评论区留言:

      • 半透明物体的描边
      • 开启TAA时描边会抖动
      • 只测试了Windows平台下使用Forward+渲染器的情况,其他平台未测试

      完整代码

      不包含景深模糊的处理。

      outline_depth.gdshader

      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);
      }
      

      outline.gdshader

      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 < outline_depth && screen_color.a > 0.0;
      	// 是否处于描边上
      	bool is_outline = false;
      	if (!is_inner) {
      		// 计算纹素大小
      		vec2 texel_size = 1.0 / vec2(VIEWPORT_SIZE.xy);
      		// 以当前位置为中心,判断周围的点是否存在描边物体,如果存在则提前跳出循环
      		for (int x = -outline_width; x <= outline_width && !is_outline; ++x) {
      			for (int y = -outline_width; y <= outline_width && !is_outline; ++y) {
      				if (y == 0 && 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 <= 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 < 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);
      }
      
      发布在 游戏开发
      Pamisu
      Pamisu
    • 【Unity】2D像素游戏中让UI与世界的像素大小保持一致

      在使用Unity制作2D像素游戏时,经常会遇到Canvas中的Image与世界中的Sprite大小不一致的情况,即使是同一素材也会有差别:

      Pasted image 20241005122322.png

      特别是对于像素游戏,这会导致画面中的逻辑像素大小不统一,影响观感。

      由于Unity使用了不同的方式来处理它们,首先要了解它们的大小是如何计算的。

      Pixels Per Unit

      图片导入选项中的Pixels Per Unit(以下简称PPU),表示多少个实际像素为1个Unity单位。

      Pasted image 20241005120656.png

      例如PPU设置为16,则一张16×16像素的图片在世界中的大小为1×1个单位,一张32×8像素的图片在世界中的大小为2×0.5个单位。

      一张16×16像素的图片Unit.png作为Sprite在世界中显示的情况:

      Pasted image 20241005120801.png

      相机大小

      正交相机的Size表示半个屏幕/窗口高度中能显示多少Unity单位的内容。

      Pasted image 20241005120814.png

      例如Size设置为3,则屏幕纵向有3×2=6个单位,图中每个地块大小为1个单位,纵向显示了6个地块。

      Pasted image 20241005121934.png

      在Size不变的情况下,无论显示分辨率、宽高比如何变化,纵向显示的内容总是不变的,都是6个地块。

      Pasted image 20241005120842.png

      Canvas的Reference Pixels Per Unit与缩放模式

      新建一个Canvas,Canvas Scaler中有一个Reference Pixels Per Unit参数(以下简称RPPU),默认为100。

      Pasted image 20241005121333.png

      此时将上面的Unit.png图片作为Image放到UI中,会发现与世界中Sprite的大小有差别。

      Pasted image 20241005122322.png

      和世界中的Sprite不同,Canvas中不直接使用Unity单位,而是适用于Canvas的像素大小。在没有缩放的情况下,

      Canvas像素大小 = 图片像素大小 / (PPU / RPPU)

      例如这里的Unit.png,它的大小为16×16像素,PPU为16,Canvas Scaler的RPPU为100,则它在Canvas中的像素大小为100。

      需要注意的是,Canva像素大小并不是实际显示的像素大小,它受Canvas Scaler的UI Scale Mode(缩放模式)以及其他缩放参数影响。

      当UI Scale Mode设置为Constant Pixel Size(固定像素大小)时,无论显示分辨率、宽高比如何变化,图片的大小都不变。Scale Factor(缩放因子)参数影响图片的缩放倍率,例如Scale Factor为1时,Image的显示大小始终固定为100×100,为2时,固定为200×200。

      Pasted image 20241005124142.png

      当UI Scale Mode设置为Scale With Screen Size(随屏幕大小缩放)时,图片的显示大小受显示分辨率、Reference Resolution(参考分辨率),和Match(宽高匹配)影响。

      Pasted image 20241005124337.png

      听起来很复杂,实际上可以理解成是另一种Constant Pixel Size模式,但Scale Factor会根据某些规则自动变化,缩放适应不同分辨率和宽高比的屏幕。

      例如显示分辨率为1920×1080,Reference Resolution也是1920×1080,此时可以认为内置的缩放因子为1,Image的显示大小为100×100;

      当显示分辨率变为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。

      Pasted image 20241005131259.png

      还有一种缩放模式Constant Physical Size(固定物理大小)个人从来没用过,就不讨论了。

      统一大小

      以结果来看,最终需要统一的是图片在世界和UI中的实际显示大小,例如一张16×16的图片,如果在UI中作为Image显示的实际像素大小是100×100,那么它在世界中作为Sprite的实际像素大小也应该是100×100,反之亦然,要么调整UI相关参数让它匹配世界,要么调整世界相关参数让它匹配UI。在此基础上,还要确保不同显示分辨率和宽高比下的缩放情况,以及处理相机自身的缩放。

      由于不同项目情况不同,这里只介绍一种思路,只要清楚了原理其他都是相通的。

      首先确定并统一所有图片素材的PPU,像素素材在制作过程中通常会有一个参考大小,例如8×8、16×16、32×32,不同参考大小可表现的细节也不同。

      Pasted image 20241005152315.png

      使用这个参考大小作为PPU是一个不错的选择,例如素材中每个地块的大小为16×16,PPU也设置为16,1个地块刚好为1个Unity单位。

      然后确定一个设计分辨率,也就是以此分辨率为基准,其他分辨率都在它基础上缩放,例如确定设计分辨率为1920×1080,将显示分辨率和Canvas Scaler的参考分辨率都调整为这个值,此时 设计分辨率 = 实际显示分辨率。

      接下来有两种选择,调整UI适配相机,或调整相机以适配UI。

      调整UI适配相机

      这种方式适合一屏显示的内容有固定要求的情况,例如要求屏幕中必须显示32×18个地块,那么相机的大小不能变化。

      根据相机大小一节,屏幕纵向共有相机Size×2个Unity单位,用设计分辨率高度除以它,可以得到1Unity单位对应的实际像素大小,即:

      屏幕高度(Unity单位) = 相机Size * 2

      屏幕高度(像素) = 设计分辨率高度

      1Unity单位对应像素 = 屏幕高度(像素) / 屏幕高度(Unity单位)

      然后根据图片的PPU,可以计算出图片中的1个像素,实际显示在屏幕上是多少像素,也就是像素比率(Pixel Ratio):

      像素比率 = 1Unity单位对应像素 / PPU

      举个例子,当设计分辨率为1920×1080,相机的Size设置为6,图片大小16×16,PPU为16,此时的像素比率:

      像素比率 = 1080÷(6×2)÷16 = 5.625

      也就是图片素材的1个像素,显示在屏幕上为5.625个像素,因为是设计分辨率,有小数是正常的,实际显示时Unity会帮我们处理好。

      得到的像素比率有什么用呢?它可以用来确定UI中图片的大小,如果UI的显示也能符合这个像素比率,那么就做到UI和世界显示大小一致了。

      将UI Scale Mode改为Scale With Screen Size,Reference Resolution设置为设计分辨率,Match调整为Height为1。当显示分辨率与设计分辨率一致时,Canvas不会有缩放。

      Pasted image 20241005124337.png

      根据Canvas一节,在没有缩放的情况下:

      实际显示像素大小 = Canvas像素大小 = 图片像素大小 / (PPU / RPPU)

      由此可以得出UI的像素比率:

      UI像素比率 = 图片像素大小 / 实际显示像素大小 = RPPU / PPU

      为了让UI的像素比率和世界的像素比率一致,我们唯一还能调整的只有RPPU。按上面的例子,当像素比率为5.625时,RPPU应该调整为16×5.625=90,调整后需要点击Image的Set Native Size重置大小。

      Pasted image 20241005162448.png

      Pasted image 20241005162655.png

      调整好后,由于Canvas按高度缩放,相机也是按高度缩放,所以在不同分辨率和宽高比下都能保持一致:

      Pasted image 20241005163006.png

      如果项目不符合这种情况,例如需要按宽度缩放,可以试着按类似的原理调整相机大小和UI缩放。

      调整相机适配UI

      了解了上面的方法后,另一种情况依葫芦画瓢就行,先计算出UI的像素比率,然后调整相机大小匹配这个比率。

      另外在使用Pixel Perfect Camera的情况下,相机大小不可直接调整,Pixel Perfect Camera会根据当前各项参数计算出像素比率(图中为实际显示像素 : 图片素材像素),按照这个值调整UI的像素比率即可。

      Pasted image 20241005165519.png

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 从Unity迁移到Godot再到入坟

      👍节点元数据

      非常方便但容易被忽视的功能。之后来填

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 从Unity迁移到Godot再到入坟

      👍统一的开发规范

      GDScript style guide

      C# style guide

      之后来填

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 从Unity迁移到Godot再到入坟

      😔没有内置的3D线条渲染实现

      即Unity中的LineRenderer和TrailRenderer,目前Godot只有Line2D没有Line3D。很多时候人们会感叹“Godot连这功能都有”,那么这个便是”Godot连这功能都没有“。

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 从Unity迁移到Godot再到入坟

      🤔相机正方向与模型正方向

      Model export considerations¶

      很容易踩坑的地方,需要注意。之后来填(咕咕)

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 从Unity迁移到Godot再到入坟

      👍动画树 AnimationTree

      Using AnimationTree

      可能没有UE的动画解决方案强大,但绝对比Unity那个破状态机好用。

      挖个坑,之后来填(咕咕)

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 从Unity迁移到Godot再到入坟

      👍视口、Canvas与坐标

      Viewport and canvas transforms

      挖个坑,之后来填(咕咕)

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 从Unity迁移到Godot再到入坟

      🤔多分辨率支持

      Multiple resolutions

      挖个坑,之后来填(咕咕)

      发布在 游戏开发
      Pamisu
      Pamisu