Design Hub

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

    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
    • RE: 从Unity迁移到Godot再到入坟

      👍本地化/国际化

      Internationalizing Games

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

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

      👍内置的主题解决方案

      GUI Skinning

      很不错,挖个坑之后来填(咕咕)

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

      👍选择性暂停游戏内容

      这个需求很常见,例如暂停游戏时菜单UI不暂停,但在Unity中只能自行实现或使用插件。Godot可以为每个节点指定不同的处理模式,或者说运行模式,这里的“处理”指的是_process、_physics_process等函数的执行,对应Unity中的Update、FixedUpdate。当游戏暂停时,不同处理模式下的节点会有不同的行为:

      • Inherit 继承父节点的设置
      • Pausable 在游戏暂停时被暂停
      • WhenPaused 在游戏暂停时运行
      • Always 不论游戏是否暂停都运行
      • Disabled 不运行

      另外,_process、_physics_process也可以通过set_process与set_physics_process手动开启关闭。

      暂停游戏和处理模式

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

      🤔资源管理

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

      Make Unique

      加载

      打包

      与Unity Addressables对比

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

      🤔内存管理

      好消息:GDScript没有GC,所以不会有GC工作引起掉帧的问题。

      坏消息:用的是引用计数和手动释放,不小心可能会内存泄漏。

      不过比起Unity中的谈GC色变,Godot的内存管理可以说轻松很多,引擎已经完成了大部分工作,需要注意的是避免引用循环:

      my_class.gd

      class_name MyClass
      extends RefCounted
      
      var other
      

      test.gd

      extends Node
      
      func _ready():
          var a = MyClass.new()
          var b = MyClass.new()
      
          a.other = b
          b.other = a
          
      	# !内存泄漏了
      

      Godot中大部分类型都继承自这两个类:Object、RefCounted 。Object需要手动管理内存,即一个Object被实例化之后,它会一直保持在内存中,直到它的free方法被调用;RefCounted继承自Object,加入了引用计数,即一个RefCounted被实例化之后,如果它不再被其他地方引用,就会自动回收。在上面的例子中,a、b两个RefCounted互相引用,永远无法回收,导致内存泄漏。

      那么我们经常用的节点Node是否会有这个问题呢?Node继承自Object,除了free还有queue_free方法可以将其从内存中释放,当free或queue_free被调用后,节点及其所有子节点都会被删除,所以节点与子节点互相引用是没有问题的。

      但仍然有一种情况会导致节点的内存泄漏:

      extends Node
      
      const SomeScene = preload("res://scenes/some_scene.tscn")
      
      func _ready():
      	var node = SomeScene.instantiate()
      	# 缺少add_child(node)
      

      由于node没有被添加到场景树,它变成了一个“孤儿节点”(Orphan node),即使游戏退出也无法得到释放,导致内存泄漏。运行中是否产生Orphan node可以在编辑器底部 Debugger -> Monitors -> Object -> Orphan Nodes中查看。

      资源的泄露暂时没有遇到,之后如果有再来补充。

      没有GC的设计好吗?

      GC的主要目的是自动回收不再使用的内存,将开发者从繁琐且容易出错的手动内存管理中解放出来。而因为GC工作会导致掉帧而彻底不用GC,是否有点因噎废食了呢?事实上并没有,Godot中使用最频繁的是节点与资源,资源继承自RefCounted,引用计数会将它们处理好;节点需要手动释放,但场景树接手了这一工作,开发者只需要注意释放的时机即可,所以大部分情况下开发者花费在内存管理上的工作并不多。

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

      🤔GDScript vs C#

      Pasted image 20240423134821.png

      reddit上这张梗图虽然搞笑但也有点佛家三重境界的味道。

      Godot中GDScript和C#各有优缺点:

      GDScript优点

      • 简单易上手,文档详细,相关教程多,使用占比最高,社区庞大
      • 随引擎第一时间更新,与引擎的集成度最高
      • 支持导出到所有平台
      • 无需编译,支持热重载

      GDScript缺点

      • 综合性能比C#差
      • 动态类型语言消耗脑力多一些,在重构、编写规模庞大的项目时相对弱势

      C#优点

      • 拥有.NET生态,海量的第三方库
      • 综合性能比GDScript好
      • 静态类型语言,结合Rider、VSCode等工具可以大幅降低脑力消耗,让精力集中到具体的业务开发上
      • 异步编程支持完善
      • CoreCLR GC完爆Unity的Bohem GC

      C#缺点

      • 目前(2024/04)无法导出到WebGL平台
      • 导出包体大很多,主要多了.NET相关dll(目前没有看到对裁剪的支持也没去尝试过)
      • 无法从C#直接调用GDExtension
      • Godot相关的C#开源库比GDScript少

      所以选择哪种语言需要综合个人和项目情况考虑,避开无法接受的短板。

      对我个人来说,GDScript编写起来更快也更费精神,代码提示不全、重构经常有遗漏都会带来额外的工作,但无需编译、热重载、引擎集成度高这些优势都是我需要的;C#则是在Unity、.NET中使用了多年的老朋友,但Godot C#包体大、缺少WebGL支持也是无法忽视的问题。

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

      👍按确定顺序执行的事件函数

      Unity中,如果不在Project Settings -> Script Execution Order指定执行顺序,则脚本的执行顺序是不确定的,例如在一个场景中存在A、B两个脚本,A的Awake、Start、Update等事件函数可能比B先执行,也可能后执行。这种不确定性削弱了开发者对程序的控制,除了会导致踩坑之外,一些需求实现起来也变得更加繁琐。

      在Godot中,绝大部分操作都是按场景树顺序执行,个人认为这个设计是非常简洁与巧妙的,简单概括就是“从上往下执行,_ready函数特例”:

      Pasted image 20240422101732.png

      大部分函数如_init、_enter_tree、_process 等都是从上往下执行,_ready 函数是一个特例,当一个节点的所有子节点的_ready都执行完后,该节点的_ready才会执行。这是符合直觉的:只有一棵树的所有子节点都准备好了,才能说这棵树准备好了。

      Godot的另一个符合直觉的设计是,Autoload的节点会优先进入场景树,例如有一个App节点,负责管理一些全局的内容,游戏中的各个模块都要依赖到它:

      Pasted image 20240422103323.png

      而现在有一个这样的场景:

      Pasted image 20240422102235.png

      那么运行之后的场景树是这样的,App会成为场景树根节点下的第一个节点:

      Pasted image 20240422103648.png

      此时各函数的执行顺序:

      • _init、_enter_tree、_process等:App - SceneRoot - A - B - C - D - E
      • _ready:App - B - C - A - E - D - SceneRoot

      在这种情况下,可以在App节点的_ready做一些全局的初始化操作,随后场景中各节点的_ready才会执行。并且由于App是Autoload,所有场景执行的时候它都会被添加到节点树,所以各个场景单独运行时都可以依赖到App节点,这与Godot的运行当前场景功能也非常契合。

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

      🤔“万能”的节点与场景

      引用Godot作者对于Unity与Godot概念上对应关系的解释:

      实体 → 节点
      组件 → 节点
      场景设置 → 节点
      导航 → 节点
      光照贴图 → 节点
      视口 → 节点
      行为 → 节点+脚本
      预制体 → 场景
      场景组合 → 场景
      ScriptableObject → 资源

      几乎所有内容都是节点、场景或资源...Unity中很多非常复杂的子系统,在Godot中它们表达得更加自然和直观。

      从Unity转到Godot时,一个常见的错觉便是把节点当作GameObject,但稍微使用便会发现,节点上只能“挂载”一个脚本,似乎有点不太对劲。如果硬要将两者的概念对应起来,那么在Untiy中带有多个Component的GameObject,在Godot中则是带有多个子节点的节点:

      Pasted image 20240423120106.png

      Pasted image 20240423120245.png

      这便是节点身兼多职的一个体现,Unity中Component直接挂载在GameObject上,Inspector中列出当前GameObject上的所有Component;Godot中节点作为子节点来充当Component的角色,Inspector中从上到下列出当前节点所继承的类型。

      这也是为什么节点上无法“挂载”多个脚本,因为脚本并不对应Unity中的Component,Godot中的脚本更多是节点功能的扩展,不像Unity中脚本都继承自MonoBehavior,Godot脚本必须继承某个特定的节点类型,因为它的目的就是为这个类型的节点增加功能和行为。这种继承设计的一个好处是,节点只会拥有它所继承类型的功能而不会多出额外的不需要的功能,比如某个节点压根用不着Transform,也不需要在场景中显示,那不继承Node3D或Node2D就行。

      这种设计有哪些弊端?

      首先场景树的复杂程度会不可避免地增加,一个根节点下会有作为子节点的节点和作为组件的节点,同时子节点还会有它的组件子节点,这棵树会变得很大,所以Godot也鼓励尽可能将它们细分,都制作成场景(预制体),再作为子场景实例化到父场景中,并且利用多页签同时编辑多个场景、单独调试运行某个场景来提升开发效率。

      Pasted image 20240423223350.png

      另一点则是脚本继承限制了根节点的类型,例如我希望在游戏中有一个基类Character来作为游戏中的各种角色,其他类型的角色都继承于它:

      Pasted image 20240423131537.png

      而在制作预制体时,这些角色的根节点可能是CharacterBody3D(会动的),可能是StaticBody3D(静止的),也可能是Node3D(物理无关的),如果Character要作为根节点,那它只能继承于Node3D,而继承自Character的PlayerCharacter、EnemyCharacter等就失去了直接使用CharacterBody3D、StaticBody3D等节点的能力。

      当然有很多种办法可以间接解决这个问题,例如获取自身节点后转换成指定类型(较为丑陋):

      class_name PlayerCharacter
      extends Character
        
      var character_body: CharacterBody3D
      
      
      func _ready():
          var self_node = get_parent().get_node("%s" % name)
          if self_node is CharacterBody3D:
              character_body = self_node as CharacterBody3D
      

      或者改变思路,重新划分职责,既然根节点没法做到一致,就将Character用作子节点,根节点再用其他类去处理。毕竟用一个引擎的最好方式就是用符合它的设计思路来做开发,而不是头铁强行做它不擅长的事。

      由于Inspector中也是按照继承关系依次显示当前节点继承的类型,并且默认将分组折叠,导致像调整Transform这种在Unity中很方便的操作,到了Godot中需要往下翻几层才行:

      Pasted image 20240427122347.png

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

      作为一个较为新生的引擎,Godot包含了不少现代化的设计理念,提供了许多开箱即用的功能,但也仍有许多不足之处。本文主要记录从Unity迁移到Godot时,常见功能的实现、需要注意的坑、引擎之间的对比、开发思维上的转变,以及引擎设计上值得深入探讨的部分等,长期随缘乱序更新,如有错误欢迎指出。

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

      Unity 打包后TileMap的碰撞体无法动态更新

      游戏中有修改地形功能时(例如创造或炸毁地块)需要在运行时更新TileMap,在编辑器中碰撞体可以随之更新,而打包后却无法更新,这种情况需要在对应Sprite导入设置中勾选Read/Write Enabled选项,如果是图集,则勾选图集的该选项:
      813d9b41-72f8-4738-ba16-0e4bf7991391-image.png

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