X Tutup
Skip to content

Fix TileMapLayer tiles displaying incorrectly with y_sort based on visibility layer#110550

Merged
Repiteo merged 1 commit intogodotengine:masterfrom
WesleyClements:master
Sep 23, 2025
Merged

Fix TileMapLayer tiles displaying incorrectly with y_sort based on visibility layer#110550
Repiteo merged 1 commit intogodotengine:masterfrom
WesleyClements:master

Conversation

@WesleyClements
Copy link
Contributor

@WesleyClements WesleyClements commented Sep 16, 2025

Resolves #75206

These changes address an oversight of the y sorting logic that made it possible for children of CanvasItems that should be culled by canvas_cull_mask to still be rendered.

For example, if:

  • CanvasItem A had y_sort_enabled set to true and visibility_layer set to 1
  • CanvasItem B was a child of A, had y_sort_enabled set to true, and visibility_layer set to 2
  • CanvasItem C was a child of B and had visibility_layer set to 1

CanvasItem C would still render in any and all viewports with layer 1 enabled.

This was due to the fact that the child_items array included all children, filtered only by visible. Since each element in child_items is then checked to be culled individually, the parent's visibility_layer is completely disregarded.

To resolve this, I added two new parameters to RendererCanvasCull::_collect_ysort_children, r_ysort_children_count and p_canvas_cull_mask. If an item matches (while visible) the p_canvas_cull_mask, it is added to child_items as before. If it doesn't match (while visible), it skips adding itself to the array and subtracts itself and it's ysort_children_count (if it has any) from the length of the array, r_ysort_children_count. To avoid having to call RendererCanvasCull::_count_ysort_children again to determine the size of the branch being subtracted from the array length, I changed RendererCanvasCull::_count_ysort_children to cache the computed ysort_children_count all the way down the tree.

While this solution does not resolve the fact that child_items is being allocated to be too large, it works as intended.

This fixes the bug with TileMaps/TileMapLayers because each tile is added as a child CanvasItem with visibility_layer set to the default of 0xffffffff. Thus, if the TileMapLayer and it's parent both have y_sort_enabled set to true but differ in passing the p_canvas_cull_mask check, the TileMapLayers children will still be rendered on viewports matching the parent's visibility_layer.

@WesleyClements WesleyClements requested a review from a team as a code owner September 16, 2025 05:34
@WesleyClements WesleyClements changed the title Fixed TileMap tiles displaying incorrectly based on visibility layer Fixed TileMapLayer tiles displaying incorrectly with y_sort based on visibility layer Sep 16, 2025
@AThousandShips AThousandShips changed the title Fixed TileMapLayer tiles displaying incorrectly with y_sort based on visibility layer Fix TileMapLayer tiles displaying incorrectly with y_sort based on visibility layer Sep 16, 2025
@WesleyClements
Copy link
Contributor Author

ysort_test.zip
Here is the project I used to verify the bug and test the fix.

@AThousandShips AThousandShips added this to the 4.6 milestone Sep 17, 2025
@AThousandShips AThousandShips added cherrypick:4.4 Considered for cherry-picking into a future 4.4.x release cherrypick:4.5 Considered for cherry-picking into a future 4.5.x release labels Sep 17, 2025
@kleonc
Copy link
Member

kleonc commented Sep 17, 2025

To resolve this, I used the fact that the offset in the child_items array between any given CanvasItem and it's next sibling is ysort_children_count + 1 to skip all child CanvasItems if their parent is culled due to visibility_layer.

Such offsets are valid only while counting/gathering the items to be y-sorted. After all the gathered items are actually y-sorted these previously calculated offsets are no longer relevant. Currently in this PR the skipping is applied after the sorting, aka the solution is incorrect.

This PR in action:

godot.windows.editor.x86_64_YNB8FU6pLi.mp4

I think what should be sufficient is extending these to include visibility layer check:

if (child_items[i]->visible) {

if (child_items[i]->visible) {

and marking y-sorting as dirty on changing given item's visibility layer, just like it's done on changing the visibility:

void RendererCanvasCull::canvas_item_set_visibility_layer(RID p_item, uint32_t p_visibility_layer) {
Item *canvas_item = canvas_item_owner.get_or_null(p_item);
ERR_FAIL_NULL(canvas_item);
canvas_item->visibility_layer = p_visibility_layer;
}

void RendererCanvasCull::canvas_item_set_visible(RID p_item, bool p_visible) {
Item *canvas_item = canvas_item_owner.get_or_null(p_item);
ERR_FAIL_NULL(canvas_item);
canvas_item->visible = p_visible;
_mark_ysort_dirty(canvas_item);
}

This should make the relevant items (and their children/subtrees) be not even included in child_items/y-sorting.

@WesleyClements
Copy link
Contributor Author

WesleyClements commented Sep 17, 2025

@kleonc Great minds think alike! This was the first solution I tried! The problem is that if the tree is used by multiple viewports (as in they share World2Ds) with different cull masks, then the cached ysort_children_count will only be valid for one of them which causes the others to crash the game. This solution was a misguided keep the caching behavior but it seems it is incomplete. It would be simple enough just filter by visibility_layer and do away with the caching. I don't know which is best but I'll implement the suggestion. Regardless, marking ysort as dirty in canvas_item_set_visibility_layer should happen.

Edit:
Thank you for your patience as I figure this out

@kleonc
Copy link
Member

kleonc commented Sep 17, 2025

The problem is that if the tree is used by multiple viewports (as in they share World2Ds) with different cull masks, then the cached ysort_children_count will only be valid for one of them which causes the others to crash the game.

Oh, I haven't considered that indeed. I see the issue, you're absolutely right about it potentially crashing, thus I think such solution is not acceptable.

A solution I can think of which should work fine with multiple viewports sharing World2D would be to add another cached value for y-sorting to the item, specifically cumulated visibility layer (bitwise and-ed with the y-sorted item's parents (only the parents within the y-sorted hierarchy need to be accounted though)). Something like:

diff --git a/servers/rendering/renderer_canvas_cull.cpp b/servers/rendering/renderer_canvas_cull.cpp
index 3b91bace7e..f0b635363d 100644
--- a/servers/rendering/renderer_canvas_cull.cpp
+++ b/servers/rendering/renderer_canvas_cull.cpp
@@ -132,6 +132,7 @@ void RendererCanvasCull::_collect_ysort_children(RendererCanvasCull::Item *p_can
 			child_items[i]->ysort_modulate = p_modulate;
 			child_items[i]->ysort_index = r_index;
 			child_items[i]->ysort_parent_abs_z_index = p_z;
+			child_items[i]->ysort_visibility_layer = p_canvas_item->ysort_visibility_layer & child_items[i]->visibility_layer;
 
 			if (!child_items[i]->repeat_source) {
 				child_items[i]->repeat_size = p_canvas_item->repeat_size;
@@ -437,6 +438,7 @@ void RendererCanvasCull::_cull_canvas_item(Item *p_canvas_item, const Transform2
 			ci->ysort_modulate = Color(1, 1, 1, 1) / ci->modulate;
 			ci->ysort_index = 0;
 			ci->ysort_parent_abs_z_index = parent_z;
+			ci->ysort_visibility_layer = ci->visibility_layer;
 			child_items[0] = ci;
 			int i = 1;
 			_collect_ysort_children(ci, p_material_owner, Color(1, 1, 1, 1), child_items, i, p_z);
@@ -445,7 +447,9 @@ void RendererCanvasCull::_cull_canvas_item(Item *p_canvas_item, const Transform2
 			sorter.sort(child_items, child_item_count);
 
 			for (i = 0; i < child_item_count; i++) {
-				_cull_canvas_item(child_items[i], final_xform * child_items[i]->ysort_xform, p_clip_rect, modulate * child_items[i]->ysort_modulate, child_items[i]->ysort_parent_abs_z_index, r_z_list, r_z_last_list, (Item *)ci->final_clip_owner, (Item *)child_items[i]->material_owner, true, p_canvas_cull_mask, child_items[i]->repeat_size, child_items[i]->repeat_times, child_items[i]->repeat_source_item);
+				if (child_items[i]->ysort_visibility_layer & p_canvas_cull_mask) {
+					_cull_canvas_item(child_items[i], final_xform * child_items[i]->ysort_xform, p_clip_rect, modulate * child_items[i]->ysort_modulate, child_items[i]->ysort_parent_abs_z_index, r_z_list, r_z_last_list, (Item *)ci->final_clip_owner, (Item *)child_items[i]->material_owner, true, p_canvas_cull_mask, child_items[i]->repeat_size, child_items[i]->repeat_times, child_items[i]->repeat_source_item);
+				}
 			}
 		} else {
 			RendererCanvasRender::Item *canvas_group_from = nullptr;
diff --git a/servers/rendering/renderer_canvas_cull.h b/servers/rendering/renderer_canvas_cull.h
index 1f01bbce11..072e06b680 100644
--- a/servers/rendering/renderer_canvas_cull.h
+++ b/servers/rendering/renderer_canvas_cull.h
@@ -57,6 +57,7 @@ public:
 		Transform2D ysort_xform; // Relative to y-sorted subtree's root item (identity for such root). Its `origin.y` is used for sorting.
 		int ysort_index;
 		int ysort_parent_abs_z_index; // Absolute Z index of parent. Only populated and used when y-sorting.
+		uint32_t ysort_visibility_layer;
 		uint32_t visibility_layer = 0xffffffff;
 
 		Vector<Item *> child_items;

Regardless, marking ysort as dirty in canvas_item_set_visibility_layer should happen.

Note that _mark_ysort_dirty actually only resets the item count so, in this new suggestion of mine, I don't think it's needed in canvas_item_set_visibility_layer, as changing item's visibility layer doesn't affect whether the item is included in y-sorting (child_items array), so changing it doesn't affect the item count, thus no re-counting is needed.

@WesleyClements
Copy link
Contributor Author

WesleyClements commented Sep 17, 2025

@kleonc I have implemented it I believe. I do have some questions on this implementation. Even though we'd be adding ysort_visibility_layer to the Item struct, it's not being cached between runs of _cull_canvas_item, right? It's being cached in the sense that we as saving the value for when the Items are being looped over after it's sorted. Is it justifiable to add another int for every Item if the value is just going to be generated again? If it isn't my first thought would be to change the type of child_item when y_sorting to be an array of structs

struct YSortItem {
  Item* item;
  uint32_t cumulative_visibility_layer;
}

But, this does mean approximately double the amount allocated per _cull_canvas_item
In my previous attempt, I was trying to stay away from adding static memory overhead but that may be a misguided optimization.

Edit:
Never mind. I thought about it more and the implementation is more complicated and the extra allocation is a huge negative as well.

@WesleyClements
Copy link
Contributor Author

WesleyClements commented Sep 21, 2025

@kleonc I've thought about this a bit more and figured a way to solve the bug that doesn't add more properties to Item, doesn't add more allocation overhead, and prevents sorting items that won't be rendered.

diff --git a/servers/rendering/renderer_canvas_cull.cpp b/servers/rendering/renderer_canvas_cull.cpp
index 3b91bace7e..42ba93c0f2 100644
--- a/servers/rendering/renderer_canvas_cull.cpp
+++ b/servers/rendering/renderer_canvas_cull.cpp
@@ -107,50 +107,57 @@ void RendererCanvasCull::_render_canvas_item_tree(RID p_to_render_target, Canvas
        }
 }
 
-void RendererCanvasCull::_collect_ysort_children(RendererCanvasCull::Item *p_canvas_item, RendererCanvasCull::Item *p_material_owner, const Color &p_modulate, RendererCanvasCull::Item **r_items, int &r_index, int p_z) {
+void RendererCanvasCull::_collect_ysort_children(RendererCanvasCull::Item *p_canvas_item, RendererCanvasCull::Item *p_material_owner, const Color &p_modulate, RendererCanvasCull::Item **r_items, int &r_index, int &r_ysort_children_count, int p_z, uint32_t p_canvas_cull_mask) {
        int child_item_count = p_canvas_item->child_items.size();
        RendererCanvasCull::Item **child_items = p_canvas_item->child_items.ptrw();
        for (int i = 0; i < child_item_count; i++) {
                if (child_items[i]->visible) {
-                       // To y-sort according to the item's final position, physics interpolation
-                       // and transform snapping need to be applied before y-sorting.
-                       Transform2D child_xform;
-                       if (!_interpolation_data.interpolation_enabled || !child_items[i]->interpolated) {
-                               child_xform = child_items[i]->xform_curr;
-                       } else {
-                               real_t f = Engine::get_singleton()->get_physics_interpolation_fraction();
-                               TransformInterpolator::interpolate_transform_2d(child_items[i]->xform_prev, child_items[i]->xform_curr, child_xform, f);
-                       }
+                       if (child_items[i]->visibility_layer & p_canvas_cull_mask) {
+                               // To y-sort according to the item's final position, physics interpolation
+                               // and transform snapping need to be applied before y-sorting.
+                               Transform2D child_xform;
+                               if (!_interpolation_data.interpolation_enabled || !child_items[i]->interpolated) {
+                                       child_xform = child_items[i]->xform_curr;
+                               } else {
+                                       real_t f = Engine::get_singleton()->get_physics_interpolation_fraction();
+                                       TransformInterpolator::interpolate_transform_2d(child_items[i]->xform_prev, child_items[i]->xform_curr, child_xform, f);
+                               }
 
-                       if (snapping_2d_transforms_to_pixel) {
-                               child_xform.columns[2] = (child_xform.columns[2] + Point2(0.5, 0.5)).floor();
-                       }
+                               if (snapping_2d_transforms_to_pixel) {
+                                       child_xform.columns[2] = (child_xform.columns[2] + Point2(0.5, 0.5)).floor();
+                               }
 
-                       r_items[r_index] = child_items[i];
-                       child_items[i]->ysort_xform = p_canvas_item->ysort_xform * child_xform;
-                       child_items[i]->material_owner = child_items[i]->use_parent_material ? p_material_owner : nullptr;
-                       child_items[i]->ysort_modulate = p_modulate;
-                       child_items[i]->ysort_index = r_index;
-                       child_items[i]->ysort_parent_abs_z_index = p_z;
-
-                       if (!child_items[i]->repeat_source) {
-                               child_items[i]->repeat_size = p_canvas_item->repeat_size;
-                               child_items[i]->repeat_times = p_canvas_item->repeat_times;
-                               child_items[i]->repeat_source_item = p_canvas_item->repeat_source_item;
-                       }
+                               r_items[r_index] = child_items[i];
+                               child_items[i]->ysort_xform = p_canvas_item->ysort_xform * child_xform;
+                               child_items[i]->material_owner = child_items[i]->use_parent_material ? p_material_owner : nullptr;
+                               child_items[i]->ysort_modulate = p_modulate;
+                               child_items[i]->ysort_index = r_index;
+                               child_items[i]->ysort_parent_abs_z_index = p_z;
+
+                               if (!child_items[i]->repeat_source) {
+                                       child_items[i]->repeat_size = p_canvas_item->repeat_size;
+                                       child_items[i]->repeat_times = p_canvas_item->repeat_times;
+                                       child_items[i]->repeat_source_item = p_canvas_item->repeat_source_item;
+                               }
 
-                       // Y sorted canvas items are flattened into r_items. Calculate their absolute z index to use when rendering r_items.
-                       int abs_z = 0;
-                       if (child_items[i]->z_relative) {
-                               abs_z = CLAMP(p_z + child_items[i]->z_index, RS::CANVAS_ITEM_Z_MIN, RS::CANVAS_ITEM_Z_MAX);
-                       } else {
-                               abs_z = child_items[i]->z_index;
-                       }
+                               // Y sorted canvas items are flattened into r_items. Calculate their absolute z index to use when rendering r_items.
+                               int abs_z = 0;
+                               if (child_items[i]->z_relative) {
+                                       abs_z = CLAMP(p_z + child_items[i]->z_index, RS::CANVAS_ITEM_Z_MIN, RS::CANVAS_ITEM_Z_MAX);
+                               } else {
+                                       abs_z = child_items[i]->z_index;
+                               }
 
-                       r_index++;
+                               r_index++;
 
-                       if (child_items[i]->sort_y) {
-                               _collect_ysort_children(child_items[i], child_items[i]->use_parent_material ? p_material_owner : child_items[i], p_modulate * child_items[i]->modulate, r_items, r_index, abs_z);
+                               if (child_items[i]->sort_y) {
+                                       _collect_ysort_children(child_items[i], child_items[i]->use_parent_material ? p_material_owner : child_items[i], p_modulate * child_items[i]->modulate, r_items, r_index,
 r_ysort_children_count, abs_z, p_canvas_cull_mask);
+                               }
+                       } else {
+                               r_ysort_children_count--;
+                               if (child_items[i]->sort_y) {
+                                       r_ysort_children_count -= child_items[i]->ysort_children_count;
+                               }
                        }
                }
        }
@@ -164,7 +171,10 @@ int RendererCanvasCull::_count_ysort_children(RendererCanvasCull::Item *p_canvas
                if (child_items[i]->visible) {
                        ysort_children_count++;
                        if (child_items[i]->sort_y) {
-                               ysort_children_count += _count_ysort_children(child_items[i]);
+                               if (child_items[i]->ysort_children_count == -1) {
+                                       child_items[i]->ysort_children_count = _count_ysort_children(child_items[i]);
+                               }
+                               ysort_children_count += child_items[i]->ysort_children_count;
                        }
                }
        }
@@ -439,7 +449,7 @@ void RendererCanvasCull::_cull_canvas_item(Item *p_canvas_item, const Transform2
                        ci->ysort_parent_abs_z_index = parent_z;
                        child_items[0] = ci;
                        int i = 1;
-                       _collect_ysort_children(ci, p_material_owner, Color(1, 1, 1, 1), child_items, i, p_z);
+                       _collect_ysort_children(ci, p_material_owner, Color(1, 1, 1, 1), child_items, i, child_item_count, p_z, p_canvas_cull_mask);
 
                        SortArray<Item *, ItemYSort> sorter;
                        sorter.sort(child_items, child_item_count);
diff --git a/servers/rendering/renderer_canvas_cull.h b/servers/rendering/renderer_canvas_cull.h
index 1f01bbce11..c83fa1fb66 100644
--- a/servers/rendering/renderer_canvas_cull.h
+++ b/servers/rendering/renderer_canvas_cull.h
@@ -207,7 +207,7 @@ private:
        void _render_canvas_item_tree(RID p_to_render_target, Canvas::ChildItem *p_child_items, int p_child_item_count, const Transform2D &p_transform, const Rect2 &p_clip_rect, const Color &p_modulate, Render
erCanvasRender::Light *p_lights, RendererCanvasRender::Light *p_directional_lights, RS::CanvasItemTextureFilter p_default_filter, RS::CanvasItemTextureRepeat p_default_repeat, bool p_snap_2d_vertices_to_pixel,
 uint32_t p_canvas_cull_mask, RenderingMethod::RenderInfo *r_render_info = nullptr);
        void _cull_canvas_item(Item *p_canvas_item, const Transform2D &p_parent_xform, const Rect2 &p_clip_rect, const Color &p_modulate, int p_z, RendererCanvasRender::Item **r_z_list, RendererCanvasRender::I
tem **r_z_last_list, Item *p_canvas_clip, Item *p_material_owner, bool p_is_already_y_sorted, uint32_t p_canvas_cull_mask, const Point2 &p_repeat_size, int p_repeat_times, RendererCanvasRender::Item *p_repeat_
source_item);
 
-       void _collect_ysort_children(RendererCanvasCull::Item *p_canvas_item, RendererCanvasCull::Item *p_material_owner, const Color &p_modulate, RendererCanvasCull::Item **r_items, int &r_index, int p_z);
+       void _collect_ysort_children(RendererCanvasCull::Item *p_canvas_item, RendererCanvasCull::Item *p_material_owner, const Color &p_modulate, RendererCanvasCull::Item **r_items, int &r_index, int &r_ysort
_children_count, int p_z, uint32_t p_canvas_cull_mask);
        int _count_ysort_children(RendererCanvasCull::Item *p_canvas_item);
        void _mark_ysort_dirty(RendererCanvasCull::Item *ysort_owner);

Effectively when collecting the children, if an Item doesn't pass the p_canvas_cull_mask check, we skip adding it to the array and subtract the size of that branch from the total length of the array. I think the main benefit of this approach is that the skipped Items won't be sorted.

@kleonc
Copy link
Member

kleonc commented Sep 22, 2025

@WesleyClements Actually after rethinking my previous suggestion was incorrect in the first place, specifically combining the layers and-wise. E.g. when we have y-sorted:

- Item (layers: 1)
  - Child (layers: 2)

Then such child would never be rendered, as and-wise combined layer for the child would be 0. But it should be rendered if the cull mask includes both layer 1 and layer 2 (that's how it works without y-sorting).

I've thought about this a bit more and figured a way to solve the bug that doesn't add more properties to Item, doesn't add more allocation overhead, and prevents sorting items that won't be rendered.

... and it doesn't have the layer checking issue I've just mentioned above. :)

Effectively when collecting the children, if an Item doesn't pass the p_canvas_cull_mask check, we skip adding it to the array and subtract the size of that branch from the total length of the array. I think the main benefit of this approach is that the skipped Items won't be sorted.

All makes perfect sense to me, good job!

The optimization in _count_ysort_children also looks good to me, as when ysort_children_count != 1 it should be always up to date. Probably we could also add && ysort_owner->ysort_children_count != -1 to the loop condition here:

void RendererCanvasCull::_mark_ysort_dirty(RendererCanvasCull::Item *ysort_owner) {
do {
ysort_owner->ysort_children_count = -1;
ysort_owner = canvas_item_owner.owns(ysort_owner->parent) ? canvas_item_owner.get_or_null(ysort_owner->parent) : nullptr;
} while (ysort_owner && ysort_owner->sort_y);
}

Copy link
Member

@kleonc kleonc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Fixes the issue, and should work fine for rendering the same canvas/World2D for Viewports with different cull masks.

@WesleyClements BTW if possible, you could update the PR description (first comment) as it's quite outdated currently.

@Repiteo Repiteo merged commit 75f0f3d into godotengine:master Sep 23, 2025
39 of 40 checks passed
@Repiteo
Copy link
Contributor

Repiteo commented Sep 23, 2025

Thanks! Congratulations on your first merged contribution! 🎉

@akien-mga akien-mga changed the title Fix TileMapLayer tiles displaying incorrectly with y_sort based on visibility layer Fix TileMapLayer tiles displaying incorrectly with y_sort based on visibility layer Jan 8, 2026
@akien-mga
Copy link
Member

Cherry-picked for 4.5.2.

@akien-mga akien-mga removed cherrypick:4.4 Considered for cherry-picking into a future 4.4.x release cherrypick:4.5 Considered for cherry-picking into a future 4.5.x release labels Jan 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SubViewport & TileMap doesn't respect Visibility Layer / Canvas Cull Mask when Y Sort is enabled.

5 participants

X Tutup