Repository: godotengine/godot (language: cpp)

Issue:
Physics bodies can collide with tile seams
### Tested versions

4.3 dev4 and earlier

### System information

Windows 10.0.19045 - Vulkan (Forward+) - dedicated NVIDIA GeForce GTX 1060 (NVIDIA; 31.0.15.4633) - Intel(R) Core(TM) i7-7700HQ CPU @ 2.80GHz (8 Threads)

### Issue description

There is an issue with TileMap where a body can sometimes detect a wall collision on a completely flat surface. This causes bugs like projectile bouncing backwards instead of being reflected or characters turning at random point as if they hit something. Unfortunately the bug is very random - it depends on body shape, position and movement velocity. Sometimes I can reproduce it reliably in one very specific spot, but some time later it can fix itself and happen elsewhere.

Since it's so random, I didn't report it until now, because I though the issue is fixed multiple times. Apparently it's not and I just have another of these "reliable" cases.

Bug in action. Notice how the orange enemy turns for no reason, while the other enemy (which has a different shape) walks normally:

https://github.com/godotengine/godot/assets/2223172/edea97f4-b459-41f1-a118-baf0804ce943

Here I replaced the TileMap with a flat StaticBody2D and moved the TileMap below:

https://github.com/godotengine/godot/assets/2223172/a82c8e8a-7149-416a-a76b-d264132fe569

I suspect that it's caused by the fact that the TileMap is composed of multiple collision shapes and due to some physics glitches it's possible to hit corner of a tile. Related to #84163 and I assume solution would be merging the tiles into polygons (AFAIK navigation already does that).

### Steps to reproduce

1. Use TileMap
2. It might happen

### Minimal reproduction project (MRP)

The bug is too random to create a reliable MRP.


Discussion / hints:
Ghost collision is a thing in all physics engines and always caused by using bad shape layouts.

Other physics engines already lost their teeth on this topic and ended up doing sub-optimal workarounds or not bothering at all.

Box2D and its extra "ghost vertices" definition "fix" that only works with one-sided surfaces.
https://box2d.org/posts/2020/06/ghost-collisions/.
Jolt physics giving you a friendly "good luck my naive friend".
https://jrouwe.github.io/JoltPhysics/  (search for "Ghost Collisions" header line)
Havok Physics giving you a "welding" option, which means "bye, bye performance" option.
https://docs.unity3d.com/Packages/com.havok.physics@1.0/manual/collision-detection.html

There is a practical reason why all those legacy pixel games did not use physics polygons per cell like the TileMap does and instead  used a simplified collision bit mask per grid cell.
> Ghost collision is a thing in all physics engines and always caused by using bad shape layouts.
> 
> Other physics engines already lost their teeth on this topic and ended up doing sub-optimal workarounds or not bothering at all.
> 
> Box2D and its extra "ghost vertices" definition "fix" that only works with one-sided surfaces. https://box2d.org/posts/2020/06/ghost-collisions/. Jolt physics giving you a friendly "good luck my naive friend". https://jrouwe.github.io/JoltPhysics/ (search for "Ghost Collisions" header line) Havok Physics giving you a "welding" option, which means "bye, bye performance" option. https://docs.unity3d.com/Packages/com.havok.physics@1.0/manual/collision-detection.html
> 
> There is a practical reason why all those legacy pixel games did not use physics polygons per cell like the TileMap does and instead used a simplified collision bit mask per grid cell.

This also is and has been problematic for Gridmap in G4 3D, but wasn't present in G3. My shapes and setups between my two projects are identical, but G4 struggles with very very basic collision tasks (sliding along a wall cuts your movement speed; approaching a corner shoots you into the stratosphere).
@smix8 I remember you once mentioned that navigation already merges the tile polygons into bigger ones. I think that's the solution, as merged polygons don't have seams, and using one big polygon is more performant than using hundreds of small shapes.

I did a quick test in GDScript and generating super polygon(s) for a TileMap with thousands of tiles takes 1 second (though the generator uses single layer for now):

https://github.com/godotengine/godot/assets/2223172/d7c0298f-7e09-4171-83f6-5236c30f9f72

I think doing it in C++ automatically for each layer would be very feasible. Though I imagine there are countless edge cases where invalid polygons could be generated.

EDIT:
I integrated it in the game (I bake polygons in editor when saving scene and in game I just make static bodies instead of tilemap colliders). As expected, the bug mentioned in this issue is gone and it also fixes #84163 So I guess it's a viable workaround until the problem is resolved on the engine side.

<details>
 <summary>The merging script if anyone is interested</summary>

```GDScript
@tool
extends RefCounted

const neighs := [TileSet.CELL_NEIGHBOR_RIGHT_SIDE, TileSet.CELL_NEIGHBOR_BOTTOM_SIDE, TileSet.CELL_NEIGHBOR_LEFT_SIDE, TileSet.CELL_NEIGHBOR_TOP_SIDE]

var tile_map: TileMap
var layer: int

var tile_data: TileData
var map: Dictionary#[Vector2i, bool]

var polygons: Array[PackedVector2Array]

func make():
	for cell in tile_map.get_used_cells(layer):
		if not is_valid_cell(cell):
			continue
		
		flood(cell)

func is_valid_cell(cell: Vector2i) -> bool:
	if map.get(cell):
		return false
	
	tile_data = tile_map.get_cell_tile_data(layer, cell)
	if not tile_data or tile_data.get_collision_polygons_count(0) == 0:
		return false
	
	return true

func flood(base: Vector2i):
	var polygon: PackedVector2Array
	var to_merge: Array[Vector2i]
	to_merge.append(base)
	
	while not to_merge.is_empty():
		var cell: Vector2i = to_merge.pop_back()
		tile_data = tile_map.get_cell_tile_data(layer, cell)
		
		var poly := tile_data.get_collision_polygon_points(0, 0)
		var off := (Vector2(cell) + Vector2.ONE * 0.5) * Vector2(tile_map.tile_set.tile_size)
		for i in poly.size():
			poly[i] = poly[i] + off
		
		if polygon.is_empty():
			polygon = poly
		else:
			polygon = Geometry2D.merge_polygons(polygon, poly).front()
		map[cell] = true
		
		for i in neighs:
			var cell2 := tile_map.get_neighbor_cell(cell, i)
			if map.get(cell2):
				continue
			
			if not is_valid_cell(cell2):
				continue
			
			to_merge.append(cell2)
	
	polygons.append(polygon)
```
Usage:
```GDScript
var polygon_merger = load("the above script.gd").new()
polygon_merger.tile_map = $TileMap
polygon_merger.layer = 0

polygon_merger.make()

var polygons = polygon_merger.polygons
```
</details>
IIRC, the 2D variety of this behavior has been around since at least the 2.x days; this issue may be a duplicate of https://github.com/godotengine/godot/issues/72372 (and https://github.com/godotengine/godot/issues/47148 and others).
Another tilemap baker tool is at https://github.com/popcar2/GodotTilemapBaker

I forget where I first learned of another workaround for 2D metroidvanias/sidescrollers with gravity: you can use two CollisionShape2Ds - one for the "real" shape, and another just for a SeparationRayShape2D.

![2024-03-26 09_09_41-player tscn - bigmode jam game - Godot Engine](https://github.com/godotengine/godot/assets/24925338/a3146510-8293-4ce5-8ae9-25ebcb35ae3c)
@KoBeWi
Yes as mentioned in the other issue removing the tile-factor from the TileMap, how silly it may sound, is the solution to most of the TileMap performance and quality problems. At least it should move to using larger update units instead of just single cells.

The NavigationRegion2D merges TileMapLayer0 navigation polygons and collision shapes together when baked. The TileMap itself does not for its build-in navigation.

Yes there are many edge cases with polygon merging because as soon as overlap is caused it triggers hole calculation. The more complex a layout (e.g. polygon "holes") the more chances that an outline is interpreted wrong and flips the entire polygon outline stack in the wrong direction. If you only append a polygon neighbor one by one that risk is minimized. If you start to stack other layers on top with maybe collision "holes" things can get ugly very quickly.

I also discussed this multiple times with groud and I think the biggest hurdle (at least from my view) is creating a user interface to control all this bake and update behavior. Every user uses the TileMap layers differently and has different shapes in use. Also as soon as you start merging things to larger units you also make small tile runtime changes more difficult and costly.
Well my script merges neighboring tiles using flood fill and it's fast enough (even in GDScript). I think each layer could have a separate body with polygons (with them being separate nodes it's the only way now), and we could also make separate bodies for physics layers. The problem are tiles with multiple polygons. You can't merge them using my naive method, because it assumes every next tile polygon will be adjacent to the previous one (which works for my use-case, but it's not sufficient).

Is the NavigationRegion2D any more smart?
> Is the NavigationRegion2D any more smart?

The navigation mesh baking collects all the outlines and merges them together in a single operation before doing the same for collision shapes and then intersecting both. It uses the Clipper2 polytree for the result that can deal with polygon holes and nesting because it keeps an entire tree with the parent-child outline relations.

If you only add one polygon after another and the polygons have holes you will run into many issues that is why the navigation mesh baking does it all at once. This can not be done with the Geometry2D class as it has only the single polygon functions exposed. It also uses the old Clipper1 lib that is slower and less robust. Reminds me that the Geometry2D bool op functions should be upgrade at some point to use Clipper2.
I wanna add to this discussion what I ended up doing in the [Godot Rapier plugin](https://github.com/appsinacup/godot-rapier-physics) to fix RigidBodies2D (and3D) ghost collisions when interacting with TileMaps(and polygons and any other type):

![ghost_collisions](https://github.com/user-attachments/assets/0e938aa9-7add-4543-ba84-8627f837750b)

The idea is from http://briansemrau.github.io/dealing-with-ghost-collisions/. If the movement direction interacts with something that completely opposes it and the intersection distance is small enough, I disregard that contact point and eliminate it.

Fix patch:
diff --git a/core/math/geometry_2d.cpp b/core/math/geometry_2d.cpp
index 376d5d0b43be..9c8a771de318 100644
--- a/core/math/geometry_2d.cpp
+++ b/core/math/geometry_2d.cpp
@@ -38,17 +38,63 @@
 const int clipper_precision = 5; // Based on CMP_EPSILON.
 const double clipper_scale = Math::pow(10.0, clipper_precision);
 
-Vector<Vector<Vector2>> Geometry2D::decompose_polygon_in_convex(const Vector<Point2> &polygon) {
+void Geometry2D::merge_many_polygons(const Vector<Vector<Vector2>> &p_polygons, Vector<Vector<Vector2>> &r_out_polygons, Vector<Vector<Vector2>> &r_out_holes) {
+	using namespace Clipper2Lib;
+
+	PathsD subjects;
+	for (const Vector<Vector2> &polygon : p_polygons) {
+		PathD path(polygon.size());
+		for (int i = 0; i < polygon.size(); i++) {
+			const Vector2 &point = polygon[i];
+			path[i] = PointD(point.x, point.y);
+		}
+		subjects.push_back(path);
+	}
+
+	PathsD solution = Union(subjects, FillRule::NonZero);
+	solution = SimplifyPaths(solution, 0.01);
+
+	r_out_polygons.clear();
+	r_out_holes.clear();
+	for (PathsD::size_type i = 0; i < solution.size(); ++i) {
+		PathD &path = solution[i];
+
+		Vector<Point2> output_polygon;
+		output_polygon.resize(path.size());
+		for (PathsD::size_type j = 0; j < path.size(); ++j) {
+			output_polygon.set(j, Vector2(static_cast<real_t>(path[j].x), static_cast<real_t>(path[j].y)));
+		}
+		if (IsPositive(path)) {
+			r_out_polygons.push_back(output_polygon);
+		} else {
+			r_out_holes.push_back(output_polygon);
+		}
+	}
+}
+
+Vector<Vector<Vector2>> Geometry2D::decompose_many_polygons_in_convex(const Vector<Vector<Point2>> &p_polygons, const Vector<Vector<Point2>> &p_holes) {
 	Vector<Vector<Vector2>> decomp;
 	List<TPPLPoly> in_poly, out_poly;
 
-	TPPLPoly inp;
-	inp.Init(polygon.size());
-	for (int i = 0; i < polygon.size(); i++) {
-		inp.GetPoint(i) = polygon[i];
+	for (const Vector<Vector2> &polygon : p_polygons) {
+		TPPLPoly inp;
+		inp.Init(polygon.size());
+		for (int i = 0; i < polygon.size(); i++) {
+			inp.GetPoint(i) = polygon[i];
+		}
+		inp.SetOrientation(TPPL_ORIENTATION_CCW);
+		in_poly.push_back(inp);
+	}
+	for (const Vector<Vector2> &polygon : p_holes) {
+		TPPLPoly inp;
+		inp.Init(polygon.size());
+		for (int i = 0; i < polygon.size(); i++) {
+			inp.GetPoint(i) = polygon[i];
+		}
+		inp.SetOrientation(TPPL_ORIENTATION_CW);
+		inp.SetHole(true);
+		in_poly.push_back(inp);
 	}
-	inp.SetOrientation(TPPL_ORIENTATION_CCW);
-	in_poly.push_back(inp);
 	TPPLPartition tpart;
 	if (tpart.ConvexPartition_HM(&in_poly, &out_poly) == 0) { // Failed.
 		ERR_PRINT("Convex decomposing failed!");
@@ -72,6 +118,10 @@ Vector<Vector<Vector2>> Geometry2D::decompose_polygon_in_convex(const Vector<Poi
 	return decomp;
 }
 
+Vector<Vector<Vector2>> Geometry2D::decompose_polygon_in_convex(const Vector<Point2> &p_polygon) {
+	return Geometry2D::decompose_many_polygons_in_convex({ p_polygon }, {});
+}
+
 struct _AtlasWorkRect {
 	Size2i s;
 	Point2i p;
diff --git a/core/math/geometry_2d.h b/core/math/geometry_2d.h
index abd395d8dfbb..05e1626f2c97 100644
--- a/core/math/geometry_2d.h
+++ b/core/math/geometry_2d.h
@@ -489,7 +489,10 @@ class Geometry2D {
 		return points;
 	}
 
-	static Vector<Vector<Vector2>> decompose_polygon_in_convex(const Vector<Point2> &polygon);
+	static void merge_many_polygons(const Vector<Vector<Point2>> &p_polygons, Vector<Vector<Vector2>> &r_out_polygons, Vector<Vector<Vector2>> &r_out_holes);
+	static Vector<Vector<Vector2>> decompose_many_polygons_in_convex(const Vector<Vector<Point2>> &p_polygons, const Vector<Vector<Point2>> &p_holes);
+
+	static Vector<Vector<Vector2>> decompose_polygon_in_convex(const Vector<Point2> &p_polygon);
 
 	static void make_atlas(const Vector<Size2i> &p_rects, Vector<Point2i> &r_result, Size2i &r_size);
 	static Vector<Vector3i> partial_pack_rects(const Vector<Vector2i> &p_sizes, const Size2i &p_atlas_size);
diff --git a/doc/classes/TileMapLayer.xml b/doc/classes/TileMapLayer.xml
index b17d3899ef3d..674c8e2ab652 100644
--- a/doc/classes/TileMapLayer.xml
+++ b/doc/classes/TileMapLayer.xml
@@ -114,7 +114,7 @@
 			<return type="Vector2i" />
 			<param index="0" name="body" type="RID" />
 			<description>
-				Returns the coordinates of the tile for given physics body [RID]. Such an [RID] can be retrieved from [method KinematicCollision2D.get_collider_rid], when colliding with a tile.
+				Returns the coordinates of the physics quadrant (see [member physics_quadrant_size]) for given physics body [RID]. Such an [RID] can be retrieved from [method KinematicCollision2D.get_collider_rid], when colliding with a tile.
 			</description>
 		</method>
 		<method name="get_navigation_map" qualifiers="const">
@@ -310,8 +310,13 @@
 		<member name="occlusion_enabled" type="bool" setter="set_occlusion_enabled" getter="is_occlusion_enabled" default="true">
 			Enable or disable light occlusion.
 		</member>
+		<member name="physics_quadrant_size" type="int" setter="set_physics_quadrant_size" getter="get_physics_quadrant_size" default="16">
+			The [TileMapLayer]'s physics quadrant size. Within a physics quadrant, cells with similar physics properties are grouped together and their collision shapes get merged. [member physics_quadrant_size] defines the length of a square's side, in the map's coordinate system, that forms the quadrant. Thus, the default quadrant size groups together [code]16 * 16 = 256[/code] tiles.
+			[b]Note:[/b] As quadrants are created according to the map's coordinate system, the quadrant's "square shape" might not look like square in the [TileMapLayer]'s local coordinate system.
+			[b]Note:[/b] This impacts the value returned by [method get_coords_for_body_rid].
+		</member>
 		<member name="rendering_quadrant_size" type="int" setter="set_rendering_quadrant_size" getter="get_rendering_quadrant_size" default="16">
-			The [TileMapLayer]'s quadrant size. A quadrant is a group of tiles to be drawn together on a single canvas item, for optimization purposes. [member rendering_quadrant_size] defines the length of a square's side, in the map's coordinate system, that forms the quadrant. Thus, the default quadrant size groups together [code]16 * 16 = 256[/code] tiles.
+			The [TileMapLayer]'s rendering quadrant size. A quadrant is a group of tiles to be drawn together on a single canvas item, for optimization purposes. [member rendering_quadrant_size] defines the length of a square's side, in the map's coordinate system, that forms the quadrant. Thus, the default quadrant size groups together [code]16 * 16 = 256[/code] tiles.
 			The quadrant size does not apply on a Y-sorted [TileMapLayer], as tiles are grouped by Y position instead in that case.
 			[b]Note:[/b] As quadrants are created according to the map's coordinate system, the quadrant's "square shape" might not look like square in the [TileMapLayer]'s local coordinate system.
 		</member>
diff --git a/scene/2d/tile_map_layer.cpp b/scene/2d/tile_map_layer.cpp
index 56896233ec16..6e918a2ccea7 100644
--- a/scene/2d/tile_map_layer.cpp
+++ b/scene/2d/tile_map_layer.cpp
@@ -31,6 +31,7 @@
 #include "tile_map_layer.h"
 
 #include "core/io/marshalls.h"
+#include "core/math/geometry_2d.h"
 #include "scene/2d/tile_map.h"
 #include "scene/gui/control.h"
 #include "scene/resources/2d/navigation_mesh_source_geometry_data_2d.h"
@@ -40,16 +41,16 @@
 Callable TileMapLayer::_navmesh_source_geometry_parsing_callback;
 RID TileMapLayer::_navmesh_source_geometry_parser;
 
+Vector2i TileMapLayer::_coords_to_quadrant_coords(const Vector2i &p_coords, const int p_quadrant_size) const {
+	return Vector2i(
+			p_coords.x > 0 ? p_coords.x / p_quadrant_size : (p_coords.x - (p_quadrant_size - 1)) / p_quadrant_size,
+			p_coords.y > 0 ? p_coords.y / p_quadrant_size : (p_coords.y - (p_quadrant_size - 1)) / p_quadrant_size);
+}
+
 #ifdef DEBUG_ENABLED
 /////////////////////////////// Debug //////////////////////////////////////////
 constexpr int TILE_MAP_DEBUG_QUADRANT_SIZE = 16;
 
-Vector2i TileMapLayer::_coords_to_debug_quadrant_coords(const Vector2i &p_coords) const {
-	return Vector2i(
-			p_coords.x > 0 ? p_coords.x / TILE_MAP_DEBUG_QUADRANT_SIZE : (p_coords.x - (TILE_MAP_DEBUG_QUADRANT_SIZE - 1)) / TILE_MAP_DEBUG_QUADRANT_SIZE,
-			p_coords.y > 0 ? p_coords.y / TILE_MAP_DEBUG_QUADRANT_SIZE : (p_coords.y - (TILE_MAP_DEBUG_QUADRANT_SIZE - 1)) / TILE_MAP_DEBUG_QUADRANT_SIZE);
-}
-
 void TileMapLayer::_debug_update(bool p_force_cleanup) {
 	RenderingServer *rs = RenderingServer::get_singleton();
 
@@ -78,106 +79,84 @@ void TileMapLayer::_debug_update(bool p_force_cleanup) {
 		}
 	}
 
-	// List all debug quadrants to update, creating new ones if needed.
-	SelfList<DebugQuadrant>::List dirty_debug_quadrant_list;
-
+	// List all debug quadrants to update.
+	HashSet<Vector2i> quadrants_to_updates;
 	if (_debug_was_cleaned_up || anything_changed) {
 		// Update all cells.
 		for (KeyValue<Vector2i, CellData> &kv : tile_map_layer_data) {
 			CellData &cell_data = kv.value;
-			_debug_quadrants_update_cell(cell_data, dirty_debug_quadrant_list);
+			quadrants_to_updates.insert(_coords_to_quadrant_coords(cell_data.coords, TILE_MAP_DEBUG_QUADRANT_SIZE));
+			// Physics quadrants are drawn from their origin.
+			Vector2i physics_quadrant_origin = _coords_to_quadrant_coords(cell_data.coords, physics_quadrant_size) * physics_quadrant_size;
+			quadrants_to_updates.insert(_coords_to_quadrant_coords(physics_quadrant_origin, TILE_MAP_DEBUG_QUADRANT_SIZE));
 		}
 	} else {
 		// Update dirty cells.
 		for (SelfList<CellData> *cell_data_list_element = dirty.cell_list.first(); cell_data_list_element; cell_data_list_element = cell_data_list_element->next()) {
 			CellData &cell_data = *cell_data_list_element->self();
-			_debug_quadrants_update_cell(cell_data, dirty_debug_quadrant_list);
+			quadrants_to_updates.insert(_coords_to_quadrant_coords(cell_data.coords, TILE_MAP_DEBUG_QUADRANT_SIZE));
+			// Physics quadrants are drawn from their origin.
+			Vector2i physics_quadrant_origin = _coords_to_quadrant_coords(cell_data.coords, physics_quadrant_size) * physics_quadrant_size;
+			quadrants_to_updates.insert(_coords_to_quadrant_coords(physics_quadrant_origin, TILE_MAP_DEBUG_QUADRANT_SIZE));
 		}
 	}
 
 	// Update those quadrants.
 	bool needs_set_not_interpolated = is_inside_tree() && get_tree()->is_physics_interpolation_enabled() && !is_physics_interpolated();
-	for (SelfList<DebugQuadrant> *quadrant_list_element = dirty_debug_quadrant_list.first(); quadrant_list_element;) {
-		SelfList<DebugQuadrant> *next_quadrant_list_element = quadrant_list_element->next(); // "Hack" to clear the list while iterating.
+	for (const Vector2i &quadrant_coords : quadrants_to_updates) {
+		if (!debug_quadrant_map.has(quadrant_coords)) {
+			// Create a new quadrant and add it to the quadrant map.
+			Ref<DebugQuadrant> new_quadrant;
+			new_quadrant.instantiate();
+			new_quadrant->quadrant_coords = quadrant_coords;
+			debug_quadrant_map[quadrant_coords] = new_quadrant;
+		}
 
-		DebugQuadrant &debug_quadrant = *quadrant_list_element->self();
+		Ref<DebugQuadrant> debug_quadrant = debug_quadrant_map[quadrant_coords];
 
-		// Check if the quadrant has a tile.
-		bool has_a_tile = false;
-		RID &ci = debug_quadrant.canvas_item;
-		for (SelfList<CellData> *cell_data_list_element = debug_quadrant.cells.first(); cell_data_list_element; cell_data_list_element = cell_data_list_element->next()) {
-			CellData &cell_data = *cell_data_list_element->self();
-			if (cell_data.cell.source_id != TileSet::INVALID_SOURCE) {
-				has_a_tile = true;
-				break;
+		// Update the quadrant's canvas item.
+		RID &ci = debug_quadrant->canvas_item;
+		if (ci.is_valid()) {
+			rs->canvas_item_clear(ci);
+		} else {
+			ci = rs->canvas_item_create();
+			if (needs_set_not_interpolated) {
+				rs->canvas_item_set_interpolated(ci, false);
 			}
+			rs->canvas_item_set_z_index(ci, RS::CANVAS_ITEM_Z_MAX - 1);
+			rs->canvas_item_set_parent(ci, get_canvas_item());
 		}
+		const Vector2 quadrant_pos = tile_set->map_to_local(debug_quadrant->quadrant_coords * TILE_MAP_DEBUG_QUADRANT_SIZE);
+		Transform2D xform(0, quadrant_pos);
+		rs->canvas_item_set_transform(ci, xform);
 
-		if (has_a_tile) {
-			// Update the quadrant.
-			if (ci.is_valid()) {
-				rs->canvas_item_clear(ci);
-			} else {
-				ci = rs->canvas_item_create();
-				if (needs_set_not_interpolated) {
-					rs->canvas_item_set_interpolated(ci, false);
-				}
-				rs->canvas_item_set_z_index(ci, RS::CANVAS_ITEM_Z_MAX - 1);
-				rs->canvas_item_set_parent(ci, get_canvas_item());
-			}
-
-			const Vector2 quadrant_pos = tile_set->map_to_local(debug_quadrant.quadrant_coords * TILE_MAP_DEBUG_QUADRANT_SIZE);
-			Transform2D xform(0, quadrant_pos);
-			rs->canvas_item_set_transform(ci, xform);
+		// Draw physics.
+		_physics_draw_quadrant_debug(ci, *debug_quadrant.ptr());
 
-			for (SelfList<CellData> *cell_data_list_element = debug_quadrant.cells.first(); cell_data_list_element; cell_data_list_element = cell_data_list_element->next()) {
-				CellData &cell_data = *cell_data_list_element->self();
-				if (cell_data.cell.source_id != TileSet::INVALID_SOURCE) {
-					_rendering_draw_cell_debug(ci, quadrant_pos, cell_data);
-					_physics_draw_cell_debug(ci, quadrant_pos, cell_data);
-					_navigation_draw_cell_debug(ci, quadrant_pos, cell_data);
-					_scenes_draw_cell_debug(ci, quadrant_pos, cell_data);
-				}
+		// Draw debug info.
+		for (SelfList<CellData> *cell_data_list_element = debug_quadrant->cells.first(); cell_data_list_element; cell_data_list_element = cell_data_list_element->next()) {
+			CellData &cell_data = *cell_data_list_element->self();
+			if (cell_data.cell.source_id != TileSet::INVALID_SOURCE) {
+				_rendering_draw_cell_debug(ci, quadrant_pos, cell_data);
+				_navigation_draw_cell_debug(ci, quadrant_pos, cell_data);
+				_scenes_draw_cell_debug(ci, quadrant_pos, cell_data);
+				debug_quadrant->drawn_to = true;
 			}
-		} else {
+		}
+
+		// Free the quadrants that were not drawn to.
+		if (!debug_quadrant->drawn_to) {
 			// Free the quadrant.
 			if (ci.is_valid()) {
 				rs->free(ci);
 			}
-			quadrant_list_element->remove_from_list();
-			debug_quadrant_map.erase(debug_quadrant.quadrant_coords);
+			debug_quadrant_map.erase(quadrant_coords);
 		}
-
-		quadrant_list_element = next_quadrant_list_element;
 	}
 
-	dirty_debug_quadrant_list.clear();
-
 	_debug_was_cleaned_up = false;
 }
 
-void TileMapLayer::_debug_quadrants_update_cell(CellData &r_cell_data, SelfList<DebugQuadrant>::List &r_dirty_debug_quadrant_list) {
-	Vector2i quadrant_coords = _coords_to_debug_quadrant_coords(r_cell_data.coords);
-
-	if (!debug_quadrant_map.has(quadrant_coords)) {
-		// Create a new quadrant and add it to the quadrant map.
-		Ref<DebugQuadrant> new_quadrant;
-		new_quadrant.instantiate();
-		new_quadrant->quadrant_coords = quadrant_coords;
-		debug_quadrant_map[quadrant_coords] = new_quadrant;
-	}
-
-	// Add the cell to its quadrant, if it is not already in there.
-	Ref<DebugQuadrant> &debug_quadrant = debug_quadrant_map[quadrant_coords];
-	if (!r_cell_data.debug_quadrant_list_element.in_list()) {
-		debug_quadrant->cells.add(&r_cell_data.debug_quadrant_list_element);
-	}
-
-	// Mark the quadrant as dirty.
-	if (!debug_quadrant->dirty_quadrant_list_element.in_list()) {
-		r_dirty_debug_quadrant_list.add(&debug_quadrant->dirty_quadrant_list_element);
-	}
-}
 #endif // DEBUG_ENABLED
 
 /////////////////////////////// Rendering //////////////////////////////////////
@@ -499,9 +478,7 @@ void TileMapLayer::_rendering_quadrants_update_cell(CellData &r_cell_data, SelfL
 			const Vector2i &coords = r_cell_data.coords;
 
 			// Rounding down, instead of simply rounding towards zero (truncating).
-			quadrant_coords = Vector2i(
-					coords.x > 0 ? coords.x / rendering_quadrant_size : (coords.x - (rendering_quadrant_size - 1)) / rendering_quadrant_size,
-					coords.y > 0 ? coords.y / rendering_quadrant_size : (coords.y - (rendering_quadrant_size - 1)) / rendering_quadrant_size);
+			quadrant_coords = _coords_to_quadrant_coords(coords, rendering_quadrant_size);
 			canvas_items_position = tile_set->map_to_local(rendering_quadrant_size * quadrant_coords);
 		}
 
@@ -702,203 +679,332 @@ void TileMapLayer::_rendering_draw_cell_debug(const RID &p_canvas_item, const Ve
 /////////////////////////////// Physics //////////////////////////////////////
 
 void TileMapLayer::_physics_update(bool p_force_cleanup) {
+	PhysicsServer2D *ps = PhysicsServer2D::get_singleton();
+
 	// Check if we should cleanup everything.
 	bool forced_cleanup = p_force_cleanup || !enabled || !collision_enabled || !is_inside_tree() || tile_set.is_null();
-	if (forced_cleanup) {
-		// Clean everything.
-		for (KeyValue<Vector2i, CellData> &kv : tile_map_layer_data) {
-			_physics_clear_cell(kv.value);
+
+	// ----------- Quadrants processing -----------
+
+	// List all physics quadrants to update, creating new ones if needed.
+	SelfList<PhysicsQuadrant>::List dirty_physics_quadrant_list;
+
+	// Check if anything changed that might change the quadrant shape.
+	// If so, recreate everything.
+	bool quadrant_shape_changed = dirty.flags[DIRTY_FLAGS_TILE_SET] || dirty.flags[DIRTY_FLAGS_LAYER_PHYSICS_QUADRANT_SIZE];
+
+	// Free all quadrants.
+	if (forced_cleanup || quadrant_shape_changed) {
+		for (const KeyValue<Vector2i, Ref<PhysicsQuadrant>> &kv : physics_quadrant_map) {
+			// Clear bodies.
+			for (KeyValue<PhysicsQuadrant::PhysicsBodyKey, PhysicsQuadrant::PhysicsBodyValue> &kvbody : kv.value->bodies) {
+				if (kvbody.value.body.is_valid()) {
+					bodies_coords.erase(kvbody.value.body);
+					ps->free(kvbody.value.body);
+				}
+			}
+			kv.value->bodies.clear();
+			kv.value->cells.clear();
 		}
-	} else {
-		if (_physics_was_cleaned_up || dirty.flags[DIRTY_FLAGS_TILE_SET] || dirty.flags[DIRTY_FLAGS_LAYER_USE_KINEMATIC_BODIES] || dirty.flags[DIRTY_FLAGS_LAYER_IN_TREE]) {
+		physics_quadrant_map.clear();
+		_physics_was_cleaned_up = true;
+	}
+
+	if (!forced_cleanup) {
+		RID space = get_world_2d()->get_space();
+		Transform2D gl_transform = get_global_transform();
+
+		// List all quadrants to update, recreating them if needed.
+		if (dirty.flags[DIRTY_FLAGS_LAYER_IN_TREE] || _physics_was_cleaned_up) {
 			// Update all cells.
 			for (KeyValue<Vector2i, CellData> &kv : tile_map_layer_data) {
-				_physics_update_cell(kv.value);
+				CellData &cell_data = kv.value;
+				_physics_quadrants_update_cell(cell_data, dirty_physics_quadrant_list);
 			}
 		} else {
 			// Update dirty cells.
 			for (SelfList<CellData> *cell_data_list_element = dirty.cell_list.first(); cell_data_list_element; cell_data_list_element = cell_data_list_element->next()) {
 				CellData &cell_data = *cell_data_list_element->self();
-				_physics_update_cell(cell_data);
+				_physics_quadrants_update_cell(cell_data, dirty_physics_quadrant_list);
 			}
 		}
-	}
-
-	// -----------
-	// Mark the physics state as up to date.
-	_physics_was_cleaned_up = forced_cleanup;
-}
 
-void TileMapLayer::_physics_notification(int p_what) {
-	Transform2D gl_transform = get_global_transform();
-	PhysicsServer2D *ps = PhysicsServer2D::get_singleton();
-
-	switch (p_what) {
-		case NOTIFICATION_TRANSFORM_CHANGED:
-			// Move the collisison shapes along with the TileMap.
-			if (is_inside_tree() && tile_set.is_valid()) {
-				for (KeyValue<Vector2i, CellData> &kv : tile_map_layer_data) {
-					const CellData &cell_data = kv.value;
-
-					for (RID body : cell_data.bodies) {
-						if (body.is_valid()) {
-							Transform2D xform(0, tile_set->map_to_local(kv.key));
-							xform = gl_transform * xform;
-							ps->body_set_state(body, PhysicsServer2D::BODY_STATE_TRANSFORM, xform);
-						}
-					}
-				}
-			}
-			break;
-		case NOTIFICATION_ENTER_TREE:
-			// Changes in the tree may cause the space to change (e.g. when reparenting to a SubViewport).
-			if (is_inside_tree()) {
-				RID space = get_world_2d()->get_space();
+		// Update all dirty quadrants.
+		for (SelfList<PhysicsQuadrant> *quadrant_list_element = dirty_physics_quadrant_list.first(); quadrant_list_element;) {
+			SelfList<PhysicsQuadrant> *next_quadrant_list_element = quadrant_list_element->next(); // "Hack" to clear the list while iterating.
 
-				for (KeyValue<Vector2i, CellData> &kv : tile_map_layer_data) {
-					const CellData &cell_data = kv.value;
+			const Ref<PhysicsQuadrant> &physics_quadrant = quadrant_list_element->self();
 
-					for (RID body : cell_data.bodies) {
-						if (body.is_valid()) {
-							ps->body_set_space(body, space);
-						}
-					}
+			// Check if the quadrant has a tile.
+			bool has_a_tile = false;
+			for (SelfList<CellData> *cell_data_list_element = physics_quadrant->cells.first(); cell_data_list_element; cell_data_list_element = cell_data_list_element->next()) {
+				CellData &cell_data = *cell_data_list_element->self();
+				if (cell_data.cell.source_id != TileSet::INVALID_SOURCE) {
+					has_a_tile = true;
+					break;
 				}
 			}
-	}
-}
-
-void TileMapLayer::_physics_clear_cell(CellData &r_cell_data) {
-	PhysicsServer2D *ps = PhysicsServer2D::get_singleton();
-
-	// Clear bodies.
-	for (RID body : r_cell_data.bodies) {
-		if (body.is_valid()) {
-			bodies_coords.erase(body);
-			ps->free(body);
-		}
-	}
-	r_cell_data.bodies.clear();
-}
 
-void TileMapLayer::_physics_update_cell(CellData &r_cell_data) {
-	Transform2D gl_transform = get_global_transform();
-	RID space = get_world_2d()->get_space();
-	PhysicsServer2D *ps = PhysicsServer2D::get_singleton();
-
-	// Recreate bodies and shapes.
-	TileMapCell &c = r_cell_data.cell;
-
-	TileSetSource *source;
-	if (tile_set->has_source(c.source_id)) {
-		source = *tile_set->get_source(c.source_id);
-
-		if (source->has_tile(c.get_atlas_coords()) && source->has_alternative_tile(c.get_atlas_coords(), c.alternative_tile)) {
-			TileSetAtlasSource *atlas_source = Object::cast_to<TileSetAtlasSource>(source);
-			if (atlas_source) {
-				const TileData *tile_data;
-				if (r_cell_data.runtime_tile_data_cache) {
-					tile_data = r_cell_data.runtime_tile_data_cache;
-				} else {
-					tile_data = atlas_source->get_tile_data(c.get_atlas_coords(), c.alternative_tile);
-				}
-
-				// Transform flags.
-				bool flip_h = (c.alternative_tile & TileSetAtlasSource::TRANSFORM_FLIP_H);
-				bool flip_v = (c.alternative_tile & TileSetAtlasSource::TRANSFORM_FLIP_V);
-				bool transpose = (c.alternative_tile & TileSetAtlasSource::TRANSFORM_TRANSPOSE);
+			if (has_a_tile) {
+				// Process the quadrant.
 
-				// Free unused bodies then resize the bodies array.
-				for (uint32_t i = tile_set->get_physics_layers_count(); i < r_cell_data.bodies.size(); i++) {
-					RID &body = r_cell_data.bodies[i];
+				// First, clear the quadrant bodies.
+				for (KeyValue<PhysicsQuadrant::PhysicsBodyKey, PhysicsQuadrant::PhysicsBodyValue> &kvbody : physics_quadrant->bodies) {
+					RID &body = kvbody.value.body;
 					if (body.is_valid()) {
 						bodies_coords.erase(body);
 						ps->free(body);
 						body = RID();
 					}
 				}
-				r_cell_data.bodies.resize(tile_set->get_physics_layers_count());
+				physics_quadrant->bodies.clear();
+				physics_quadrant->shapes.clear();
+
+				// Quadrant origin
+				Vector2 quadrant_origin = tile_set->map_to_local(physics_quadrant->quadrant_coords);
 
+				// Recreate the quadrant bodies.
 				for (uint32_t tile_set_physics_layer = 0; tile_set_physics_layer < (uint32_t)tile_set->get_physics_layers_count(); tile_set_physics_layer++) {
 					Ref<PhysicsMaterial> physics_material = tile_set->get_physics_layer_physics_material(tile_set_physics_layer);
 					uint32_t physics_layer = tile_set->get_physics_layer_collision_layer(tile_set_physics_layer);
 					uint32_t physics_mask = tile_set->get_physics_layer_collision_mask(tile_set_physics_layer);
-					real_t physics_priority = tile_set->get_physics_layer_collision_priority(tile_set_physics_layer);
-
-					RID body = r_cell_data.bodies[tile_set_physics_layer];
-					if (tile_data->get_collision_polygons_count(tile_set_physics_layer) == 0) {
-						// No body needed, free it if it exists.
-						if (body.is_valid()) {
-							bodies_coords.erase(body);
-							ps->free(body);
-						}
-						body = RID();
-					} else {
-						// Create or update the body.
-						if (!body.is_valid()) {
-							body = ps->body_create();
-						}
-						bodies_coords[body] = r_cell_data.coords;
-						ps->body_set_mode(body, use_kinematic_bodies ? PhysicsServer2D::BODY_MODE_KINEMATIC : PhysicsServer2D::BODY_MODE_STATIC);
-						ps->body_set_space(body, space);
 
-						Transform2D xform;
-						xform.set_origin(tile_set->map_to_local(r_cell_data.coords));
-						xform = gl_transform * xform;
-						ps->body_set_state(body, PhysicsServer2D::BODY_STATE_TRANSFORM, xform);
+					// Merge polygons together for each quadrant.
+					for (SelfList<CellData> *cell_data_quadrant_list_element = physics_quadrant->cells.first(); cell_data_quadrant_list_element; cell_data_quadrant_list_element = cell_data_quadrant_list_element->next()) {
+						CellData &cell_data = *cell_data_quadrant_list_element->self();
 
-						ps->body_attach_object_instance_id(body, tile_map_node ? tile_map_node->get_instance_id() : get_instance_id());
-						ps->body_set_collision_layer(body, physics_layer);
-						ps->body_set_collision_mask(body, physics_mask);
-						ps->body_set_collision_priority(body, physics_priority);
-						ps->body_set_pickable(body, false);
-						ps->body_set_state(body, PhysicsServer2D::BODY_STATE_LINEAR_VELOCITY, tile_data->get_constant_linear_velocity(tile_set_physics_layer));
-						ps->body_set_state(body, PhysicsServer2D::BODY_STATE_ANGULAR_VELOCITY, tile_data->get_constant_angular_velocity(tile_set_physics_layer));
-
-						if (physics_material.is_null()) {
-							ps->body_set_param(body, PhysicsServer2D::BODY_PARAM_BOUNCE, 0);
-							ps->body_set_param(body, PhysicsServer2D::BODY_PARAM_FRICTION, 1);
+						TileSetAtlasSource *atlas_source = Object::cast_to<TileSetAtlasSource>(*tile_set->get_source(cell_data.cell.source_id));
+
+						// Get the tile data.
+						const TileData *tile_data;
+						if (cell_data.runtime_tile_data_cache) {
+							tile_data = cell_data.runtime_tile_data_cache;
 						} else {
-							ps->body_set_param(body, PhysicsServer2D::BODY_PARAM_BOUNCE, physics_material->computed_bounce());
-							ps->body_set_param(body, PhysicsServer2D::BODY_PARAM_FRICTION, physics_material->computed_friction());
+							tile_data = atlas_source->get_tile_data(cell_data.cell.get_atlas_coords(), cell_data.cell.alternative_tile);
 						}
 
-						// Clear body's shape if needed.
-						ps->body_clear_shapes(body);
+						// Transform flags.
+						bool flip_h = (cell_data.cell.alternative_tile & TileSetAtlasSource::TRANSFORM_FLIP_H);
+						bool flip_v = (cell_data.cell.alternative_tile & TileSetAtlasSource::TRANSFORM_FLIP_V);
+						bool transpose = (cell_data.cell.alternative_tile & TileSetAtlasSource::TRANSFORM_TRANSPOSE);
+
+						Vector2 linear_velocity = tile_data->get_constant_linear_velocity(tile_set_physics_layer);
+						real_t angular_velocity = tile_data->get_constant_angular_velocity(tile_set_physics_layer);
 
-						// Add the shapes to the body.
-						int body_shape_index = 0;
+						// Setup polygons for merge.
 						for (int polygon_index = 0; polygon_index < tile_data->get_collision_polygons_count(tile_set_physics_layer); polygon_index++) {
 							// Iterate over the polygons.
-							bool one_way_collision = tile_data->is_collision_polygon_one_way(tile_set_physics_layer, polygon_index);
-							float one_way_collision_margin = tile_data->get_collision_polygon_one_way_margin(tile_set_physics_layer, polygon_index);
 							int shapes_count = tile_data->get_collision_polygon_shapes_count(tile_set_physics_layer, polygon_index);
+
+							// Check if we need to create a new body.
+							PhysicsQuadrant::PhysicsBodyKey physics_body_key;
+							physics_body_key.physics_layer = tile_set_physics_layer;
+							physics_body_key.linear_velocity = linear_velocity;
+							physics_body_key.angular_velocity = angular_velocity;
+							physics_body_key.one_way_collision = tile_data->is_collision_polygon_one_way(tile_set_physics_layer, polygon_index);
+							physics_body_key.one_way_collision_margin = tile_data->get_collision_polygon_one_way_margin(tile_set_physics_layer, polygon_index);
+
+							if (!physics_quadrant->bodies.has(physics_body_key)) {
+								RID body = ps->body_create();
+								physics_quadrant->bodies[physics_body_key] = {};
+								physics_quadrant->bodies[physics_body_key].body = body;
+								bodies_coords[body] = physics_quadrant->quadrant_coords;
+
+								// Create or update the body.
+								ps->body_set_mode(body, use_kinematic_bodies ? PhysicsServer2D::BODY_MODE_KINEMATIC : PhysicsServer2D::BODY_MODE_STATIC);
+								ps->body_set_space(body, space);
+
+								Transform2D xform;
+								xform.set_origin(quadrant_origin);
+								xform = gl_transform * xform;
+								ps->body_set_state(body, PhysicsServer2D::BODY_STATE_TRANSFORM, xform);
+
+								ps->body_attach_object_instance_id(body, tile_map_node ? tile_map_node->get_instance_id() : get_instance_id());
+								ps->body_set_collision_layer(body, physics_layer);
+								ps->body_set_collision_mask(body, physics_mask);
+								ps->body_set_pickable(body, false);
+								ps->body_set_state(body, PhysicsServer2D::BODY_STATE_LINEAR_VELOCITY, linear_velocity);
+								ps->body_set_state(body, PhysicsServer2D::BODY_STATE_ANGULAR_VELOCITY, angular_velocity);
+
+								if (!physics_material.is_valid()) {
+									ps->body_set_param(body, PhysicsServer2D::BODY_PARAM_BOUNCE, 0);
+									ps->body_set_param(body, PhysicsServer2D::BODY_PARAM_FRICTION, 1);
+								} else {
+									ps->body_set_param(body, PhysicsServer2D::BODY_PARAM_BOUNCE, physics_material->computed_bounce());
+									ps->body_set_param(body, PhysicsServer2D::BODY_PARAM_FRICTION, physics_material->computed_friction());
+								}
+							}
+
 							for (int shape_index = 0; shape_index < shapes_count; shape_index++) {
-								// Add decomposed convex shapes.
 								Ref<ConvexPolygonShape2D> shape = tile_data->get_collision_polygon_shape(tile_set_physics_layer, polygon_index, shape_index, flip_h, flip_v, transpose);
-								ps->body_add_shape(body, shape->get_rid());
-								ps->body_set_shape_as_one_way_collision(body, body_shape_index, one_way_collision, one_way_collision_margin);
 
-								body_shape_index++;
+								// Translate the polygon.
+								Vector<Vector2> convex_polygon = shape->get_points();
+								for (int i = 0; i < convex_polygon.size(); i++) {
+									convex_polygon.set(i, convex_polygon[i] + tile_set->map_to_local(cell_data.coords) - quadrant_origin);
+								}
+
+								physics_quadrant->bodies[physics_body_key].polygons.push_back(convex_polygon);
 							}
 						}
 					}
+				}
 
-					// Set the body again.
-					r_cell_data.bodies[tile_set_physics_layer] = body;
+				// Iterate over the bodies
+				for (const KeyValue<PhysicsQuadrant::PhysicsBodyKey, PhysicsQuadrant::PhysicsBodyValue> &kvbody : physics_quadrant->bodies) {
+					// Actually merge the polygons.
+					Vector<Vector<Vector2>> out_polygons;
+					Vector<Vector<Vector2>> out_holes;
+					Geometry2D::merge_many_polygons(kvbody.value.polygons, out_polygons, out_holes);
+					// Create shapes for each polygon.
+					int body_shape_index = 0;
+					Vector<Vector<Vector2>> convex_polygons = Geometry2D::decompose_many_polygons_in_convex(out_polygons, out_holes);
+					for (Vector<Vector2> &convex_polygon : convex_polygons) {
+						Ref<ConvexPolygonShape2D> shape;
+						shape.instantiate();
+						shape->set_points(convex_polygon);
+						ps->body_add_shape(kvbody.value.body, shape->get_rid());
+						ps->body_set_shape_as_one_way_collision(kvbody.value.body, body_shape_index, kvbody.key.one_way_collision, kvbody.key.one_way_collision_margin);
+						physics_quadrant->shapes.push_back(shape);
+						body_shape_index++;
+					}
 				}
+			} else {
+				// Free the quadrant.
+				for (KeyValue<PhysicsQuadrant::PhysicsBodyKey, PhysicsQuadrant::PhysicsBodyValue> &kv : physics_quadrant->bodies) {
+					RID &body = kv.value.body;
+					if (body.is_valid()) {
+						bodies_coords.erase(body);
+						ps->free(body);
+					}
+				}
+				physics_quadrant->bodies.clear();
+				physics_quadrant->cells.clear();
+				physics_quadrant_map.erase(physics_quadrant->quadrant_coords);
+			}
 
-				return;
+			quadrant_list_element = next_quadrant_list_element;
+		}
+
+		dirty_physics_quadrant_list.clear();
+
+		// Updates on physics changes.
+		if (dirty.flags[DIRTY_FLAGS_LAYER_USE_KINEMATIC_BODIES]) {
+			for (KeyValue<Vector2i, Ref<PhysicsQuadrant>> &kv : physics_quadrant_map) {
+				Ref<PhysicsQuadrant> &physics_quadrant = kv.value;
+				for (const KeyValue<PhysicsQuadrant::PhysicsBodyKey, PhysicsQuadrant::PhysicsBodyValue> &kvbody : physics_quadrant->bodies) {
+					ps->body_set_mode(kvbody.value.body, use_kinematic_bodies ? PhysicsServer2D::BODY_MODE_KINEMATIC : PhysicsServer2D::BODY_MODE_STATIC);
+				}
 			}
 		}
 	}
 
-	// If we did not return earlier, clear the cell.
-	_physics_clear_cell(r_cell_data);
+	// -----------
+	// Mark the physics state as up to date.
+	_physics_was_cleaned_up = forced_cleanup || !occlusion_enabled;
+}
+
+void TileMapLayer::_physics_quadrants_update_cell(CellData &r_cell_data, SelfList<PhysicsQuadrant>::List &r_dirty_physics_quadrant_list) {
+	// Check if the cell is valid and retrieve its y_sort_origin.
+	bool is_valid = false;
+	TileSetSource *source;
+	if (tile_set->has_source(r_cell_data.cell.source_id)) {
+		source = *tile_set->get_source(r_cell_data.cell.source_id);
+		TileSetAtlasSource *atlas_source = Object::cast_to<TileSetAtlasSource>(source);
+		if (atlas_source && atlas_source->has_tile(r_cell_data.cell.get_atlas_coords()) && atlas_source->has_alternative_tile(r_cell_data.cell.get_atlas_coords(), r_cell_data.cell.alternative_tile)) {
+			is_valid = true;
+		}
+	}
+
+	if (is_valid) {
+		// Get the quadrant coords.
+		const Vector2i &coords = r_cell_data.coords;
+		Vector2i quadrant_coords = _coords_to_quadrant_coords(coords, physics_quadrant_size);
+
+		Ref<PhysicsQuadrant> physics_quadrant;
+		if (physics_quadrant_map.has(quadrant_coords)) {
+			// Reuse existing physics quadrant.
+			physics_quadrant = physics_quadrant_map[quadrant_coords];
+		} else {
+			// Create a new physics quadrant.
+			physics_quadrant.instantiate();
+			physics_quadrant->quadrant_coords = quadrant_coords;
+			physics_quadrant_map[quadrant_coords] = physics_quadrant;
+		}
+
+		// Mark the old quadrant as dirty (if it exists).
+		if (r_cell_data.physics_quadrant.is_valid()) {
+			if (!r_cell_data.physics_quadrant->dirty_quadrant_list_element.in_list()) {
+				r_dirty_physics_quadrant_list.add(&r_cell_data.physics_quadrant->dirty_quadrant_list_element);
+			}
+		}
+
+		// Remove the cell from that quadrant.
+		if (r_cell_data.physics_quadrant_list_element.in_list()) {
+			r_cell_data.physics_quadrant_list_element.remove_from_list();
+		}
+
+		// Add the cell to its new quadrant.
+		r_cell_data.physics_quadrant = physics_quadrant;
+		r_cell_data.physics_quadrant->cells.add(&r_cell_data.physics_quadrant_list_element);
+
+		// Add the new quadrant to the dirty quadrant list.
+		if (!physics_quadrant->dirty_quadrant_list_element.in_list()) {
+			r_dirty_physics_quadrant_list.add(&physics_quadrant->dirty_quadrant_list_element);
+		}
+	} else {
+		Ref<PhysicsQuadrant> physics_quadrant = r_cell_data.physics_quadrant;
+
+		// Remove the cell from its quadrant.
+		r_cell_data.physics_quadrant = Ref<PhysicsQuadrant>();
+		if (r_cell_data.physics_quadrant_list_element.in_list()) {
+			physics_quadrant->cells.remove(&r_cell_data.physics_quadrant_list_element);
+		}
+
+		if (physics_quadrant.is_valid()) {
+			// Add the quadrant to the dirty quadrant list.
+			if (!physics_quadrant->dirty_quadrant_list_element.in_list()) {
+				r_dirty_physics_quadrant_list.add(&physics_quadrant->dirty_quadrant_list_element);
+			}
+		}
+	}
+}
+
+void TileMapLayer::_physics_notification(int p_what) {
+	Transform2D gl_transform = get_global_transform();
+	PhysicsServer2D *ps = PhysicsServer2D::get_singleton();
+
+	switch (p_what) {
+		case NOTIFICATION_TRANSFORM_CHANGED:
+			// Move the collisison shapes along with the TileMap.
+			if (is_inside_tree() && tile_set.is_valid()) {
+				for (KeyValue<Vector2i, Ref<PhysicsQuadrant>> &kv : physics_quadrant_map) {
+					for (const KeyValue<PhysicsQuadrant::PhysicsBodyKey, PhysicsQuadrant::PhysicsBodyValue> &kvbody : kv.value->bodies) {
+						const RID &body = kvbody.value.body;
+						Transform2D xform(0, tile_set->map_to_local(kv.key));
+						xform = gl_transform * xform;
+						ps->body_set_state(body, PhysicsServer2D::BODY_STATE_TRANSFORM, xform);
+					}
+				}
+			}
+			break;
+		case NOTIFICATION_ENTER_TREE:
+			// Changes in the tree may cause the space to change (e.g. when reparenting to a SubViewport).
+			if (is_inside_tree()) {
+				RID space = get_world_2d()->get_space();
+
+				for (KeyValue<Vector2i, Ref<PhysicsQuadrant>> &kv : physics_quadrant_map) {
+					for (const KeyValue<PhysicsQuadrant::PhysicsBodyKey, PhysicsQuadrant::PhysicsBodyValue> &kvbody : kv.value->bodies) {
+						const RID &body = kvbody.value.body;
+						ps->body_set_space(body, space);
+					}
+				}
+			}
+	}
 }
 
 #ifdef DEBUG_ENABLED
-void TileMapLayer::_physics_draw_cell_debug(const RID &p_canvas_item, const Vector2 &p_quadrant_pos, const CellData &r_cell_data) {
+void TileMapLayer::_physics_draw_quadrant_debug(const RID &p_canvas_item, DebugQuadrant &r_debug_quadrant) {
 	// Draw the debug collision shapes.
 	ERR_FAIL_COND(tile_set.is_null());
 
@@ -929,25 +1035,97 @@ void TileMapLayer::_physics_draw_cell_debug(const RID &p_canvas_item, const Vect
 	Vector<Color> color;
 	color.push_back(debug_collision_color);
 
-	Transform2D quadrant_to_local(0, p_quadrant_pos);
-	Transform2D global_to_quadrant = (get_global_transform() * quadrant_to_local).affine_inverse();
-
-	for (RID body : r_cell_data.bodies) {
-		if (body.is_valid()) {
-			Transform2D body_to_quadrant = global_to_quadrant * Transform2D(ps->body_get_state(body, PhysicsServer2D::BODY_STATE_TRANSFORM));
-			rs->canvas_item_add_set_transform(p_canvas_item, body_to_quadrant);
-			for (int shape_index = 0; shape_index < ps->body_get_shape_count(body); shape_index++) {
-				const RID &shape = ps->body_get_shape(body, shape_index);
-				const PhysicsServer2D::ShapeType &type = ps->shape_get_type(shape);
-				if (type == PhysicsServer2D::SHAPE_CONVEX_POLYGON) {
-					rs->canvas_item_add_polygon(p_canvas_item, ps->shape_get_data(shape), color);
-				} else {
-					WARN_PRINT("Wrong shape type for a tile, should be SHAPE_CONVEX_POLYGON.");
+	RandomPCG rand;
+	rand.seed(hash_murmur3_one_real(r_debug_quadrant.quadrant_coords.y, hash_murmur3_one_real(r_debug_quadrant.quadrant_coords.x)));
+
+	// Create a mesh.
+	if (!r_debug_quadrant.physics_mesh.is_valid()) {
+		r_debug_quadrant.physics_mesh = rs->mesh_create();
+	}
+	rs->mesh_clear(r_debug_quadrant.physics_mesh);
+
+	// Redraw all shapes from the physics quadrants
+	Rect2i covered_cell_area = Rect2i(r_debug_quadrant.quadrant_coords * TILE_MAP_DEBUG_QUADRANT_SIZE, Vector2i(TILE_MAP_DEBUG_QUADRANT_SIZE, TILE_MAP_DEBUG_QUADRANT_SIZE));
+	Vector2i first_physics_quadrant_coords = _coords_to_quadrant_coords(covered_cell_area.get_position() - Vector2i(1, 1), physics_quadrant_size) + Vector2i(1, 1);
+	Vector2i last_physics_quadrant_coords = _coords_to_quadrant_coords(covered_cell_area.get_end() - Vector2i(1, 1), physics_quadrant_size) + Vector2i(1, 1);
+
+	// Arrays to generate a mesh.
+	for (int x = first_physics_quadrant_coords.x; x < last_physics_quadrant_coords.x; x++) {
+		for (int y = first_physics_quadrant_coords.y; y < last_physics_quadrant_coords.y; y++) {
+			const Vector2i physics_quadrant_coords = Vector2i(x, y);
+			if (!physics_quadrant_map.has(physics_quadrant_coords)) {
+				continue;
+			}
+			r_debug_quadrant.drawn_to = true;
+			Ref<PhysicsQuadrant> physics_quadrant = physics_quadrant_map[physics_quadrant_coords];
+
+			const Vector2 debug_quadrant_pos = tile_set->map_to_local(r_debug_quadrant.quadrant_coords * TILE_MAP_DEBUG_QUADRANT_SIZE);
+			Transform2D global_to_debug_quadrant = (get_global_transform() * Transform2D(0, debug_quadrant_pos)).affine_inverse();
+
+			for (const KeyValue<PhysicsQuadrant::PhysicsBodyKey, PhysicsQuadrant::PhysicsBodyValue> &kvbody : physics_quadrant->bodies) {
+				const RID &body = kvbody.value.body;
+				Transform2D body_to_quadrant = global_to_debug_quadrant * Transform2D(ps->body_get_state(body, PhysicsServer2D::BODY_STATE_TRANSFORM));
+
+				Color random_variation_color;
+				random_variation_color.set_hsv(
+						debug_collision_color.get_h() + rand.random(-1.0, 1.0) * 0.05,
+						debug_collision_color.get_s(),
+						debug_collision_color.get_v() + rand.random(-1.0, 1.0) * 0.1,
+						debug_collision_color.a);
+
+				int shape_count = ps->body_get_shape_count(body);
+				for (int shape_index = 0; shape_index < shape_count; shape_index++) {
+					const RID &shape = ps->body_get_shape(body, shape_index);
+					const Transform2D &shape_xform = ps->body_get_shape_transform(body, shape_index);
+					const PhysicsServer2D::ShapeType &type = ps->shape_get_type(shape);
+
+					if (type == PhysicsServer2D::SHAPE_CONVEX_POLYGON) {
+						PackedVector2Array outline = ps->shape_get_data(shape);
+
+						PackedVector2Array vertex_array;
+						vertex_array.resize(outline.size() + 1);
+
+						PackedColorArray face_color_array;
+						face_color_array.resize(outline.size() + 1);
+						PackedInt32Array face_index_array;
+						face_index_array.resize((outline.size() - 2) * 3);
+
+						PackedColorArray line_color_array;
+						line_color_array.resize(outline.size() + 1);
+
+						for (int i = 0; i < outline.size() + 1; i++) {
+							Vector2 vertex = (body_to_quadrant * shape_xform).xform(outline[i % outline.size()]);
+							vertex_array.write[i] = vertex;
+							face_color_array.write[i] = random_variation_color;
+							line_color_array.write[i] = random_variation_color.lightened(0.2);
+						}
+						for (int i = 0; i < outline.size() - 2; i++) {
+							face_index_array.write[i * 3] = 0;
+							face_index_array.write[i * 3 + 1] = i + 1;
+							face_index_array.write[i * 3 + 2] = i + 2;
+						}
+
+						Array face_mesh_array;
+						face_mesh_array.resize(Mesh::ARRAY_MAX);
+						face_mesh_array[Mesh::ARRAY_VERTEX] = vertex_array;
+						face_mesh_array[Mesh::ARRAY_INDEX] = face_index_array;
+						face_mesh_array[Mesh::ARRAY_COLOR] = face_color_array;
+						rs->mesh_add_surface_from_arrays(r_debug_quadrant.physics_mesh, RS::PRIMITIVE_TRIANGLES, face_mesh_array, Array(), Dictionary(), RS::ARRAY_FLAG_USE_2D_VERTICES);
+
+						Array line_mesh_array;
+						line_mesh_array.resize(Mesh::ARRAY_MAX);
+						line_mesh_array[Mesh::ARRAY_VERTEX] = vertex_array;
+						line_mesh_array[Mesh::ARRAY_COLOR] = line_color_array;
+
+						rs->mesh_add_surface_from_arrays(r_debug_quadrant.physics_mesh, RS::PRIMITIVE_LINE_STRIP, line_mesh_array, Array(), Dictionary(), RS::ARRAY_FLAG_USE_2D_VERTICES);
+					} else {
+						WARN_PRINT("Wrong shape type for a tile, should be SHAPE_CONVEX_POLYGON.");
+					}
 				}
 			}
-			rs->canvas_item_add_set_transform(p_canvas_item, Transform2D());
 		}
 	}
+	rs->canvas_item_add_mesh(p_canvas_item, r_debug_quadrant.physics_mesh, Transform2D());
 }
 #endif // DEBUG_ENABLED
 
@@ -1870,6 +2048,8 @@ void TileMapLayer::_bind_methods() {
 	ClassDB::bind_method(D_METHOD("is_using_kinematic_bodies"), &TileMapLayer::is_using_kinematic_bodies);
 	ClassDB::bind_method(D_METHOD("set_collision_visibility_mode", "visibility_mode"), &TileMapLayer::set_collision_visibility_mode);
 	ClassDB::bind_method(D_METHOD("get_collision_visibility_mode"), &TileMapLayer::get_collision_visibility_mode);
+	ClassDB::bind_method(D_METHOD("set_physics_quadrant_size", "size"), &TileMapLayer::set_physics_quadrant_size);
+	ClassDB::bind_method(D_METHOD("get_physics_quadrant_size"), &TileMapLayer::get_physics_quadrant_size);
 
 	ClassDB::bind_method(D_METHOD("set_occlusion_enabled", "enabled"), &TileMapLayer::set_occlusion_enabled);
 	ClassDB::bind_method(D_METHOD("is_occlusion_enabled"), &TileMapLayer::is_occlusion_enabled);
@@ -1898,6 +2078,7 @@ void TileMapLayer::_bind_methods() {
 	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "collision_enabled"), "set_collision_enabled", "is_collision_enabled");
 	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_kinematic_bodies"), "set_use_kinematic_bodies", "is_using_kinematic_bodies");
 	ADD_PROPERTY(PropertyInfo(Variant::INT, "collision_visibility_mode", PROPERTY_HINT_ENUM, "Default,Force Show,Force Hide"), "set_collision_visibility_mode", "get_collision_visibility_mode");
+	ADD_PROPERTY(PropertyInfo(Variant::INT, "physics_quadrant_size"), "set_physics_quadrant_size", "get_physics_quadrant_size");
 	ADD_GROUP("Navigation", "");
 	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "navigation_enabled"), "set_navigation_enabled", "is_navigation_enabled");
 	ADD_PROPERTY(PropertyInfo(Variant::INT, "navigation_visibility_mode", PROPERTY_HINT_ENUM, "Default,Force Show,Force Hide"), "set_navigation_visibility_mode", "get_navigation_visibility_mode");
@@ -3008,6 +3189,22 @@ TileMapLayer::DebugVisibilityMode TileMapLayer::get_collision_visibility_mode()
 	return collision_visibility_mode;
 }
 
+void TileMapLayer::set_physics_quadrant_size(int p_size) {
+	if (physics_quadrant_size == p_size) {
+		return;
+	}
+	ERR_FAIL_COND_MSG(p_size < 1, "Physics quandrant size cannot be smaller than 1.");
+	physics_quadrant_size = p_size;
+
+	dirty.flags[DIRTY_FLAGS_LAYER_PHYSICS_QUADRANT_SIZE] = true;
+	_queue_internal_update();
+	emit_signal(CoreStringName(changed));
+}
+
+int TileMapLayer::get_physics_quadrant_size() const {
+	return physics_quadrant_size;
+}
+
 void TileMapLayer::set_occlusion_enabled(bool p_enabled) {
 	if (occlusion_enabled == p_enabled) {
 		return;
diff --git a/scene/2d/tile_map_layer.h b/scene/2d/tile_map_layer.h
index e91a558ffa11..35728ba6f78a 100644
--- a/scene/2d/tile_map_layer.h
+++ b/scene/2d/tile_map_layer.h
@@ -98,21 +98,20 @@ class TerrainConstraint {
 class DebugQuadrant;
 #endif // DEBUG_ENABLED
 class RenderingQuadrant;
+class PhysicsQuadrant;
 
 struct CellData {
 	Vector2i coords;
 	TileMapCell cell;
 
-	// Debug.
-	SelfList<CellData> debug_quadrant_list_element;
-
 	// Rendering.
 	Ref<RenderingQuadrant> rendering_quadrant;
 	SelfList<CellData> rendering_quadrant_list_element;
 	LocalVector<LocalVector<RID>> occluders;
 
 	// Physics.
-	LocalVector<RID> bodies;
+	Ref<PhysicsQuadrant> physics_quadrant;
+	SelfList<CellData> physics_quadrant_list_element;
 
 	// Navigation.
 	LocalVector<RID> navigation_regions;
@@ -135,28 +134,26 @@ struct CellData {
 		coords = p_other.coords;
 		cell = p_other.cell;
 		occluders = p_other.occluders;
-		bodies = p_other.bodies;
 		navigation_regions = p_other.navigation_regions;
 		scene = p_other.scene;
 		runtime_tile_data_cache = p_other.runtime_tile_data_cache;
 	}
 
 	CellData(const CellData &p_other) :
-			debug_quadrant_list_element(this),
 			rendering_quadrant_list_element(this),
+			physics_quadrant_list_element(this),
 			dirty_list_element(this) {
 		coords = p_other.coords;
 		cell = p_other.cell;
 		occluders = p_other.occluders;
-		bodies = p_other.bodies;
 		navigation_regions = p_other.navigation_regions;
 		scene = p_other.scene;
 		runtime_tile_data_cache = p_other.runtime_tile_data_cache;
 	}
 
 	CellData() :
-			debug_quadrant_list_element(this),
 			rendering_quadrant_list_element(this),
+			physics_quadrant_list_element(this),
 			dirty_list_element(this) {
 	}
 };
@@ -177,6 +174,11 @@ class DebugQuadrant : public RefCounted {
 	SelfList<CellData>::List cells;
 	RID canvas_item;
 
+	RID physics_mesh;
+
+	// Used to deleted unused quadrants.
+	bool drawn_to = false;
+
 	SelfList<DebugQuadrant> dirty_quadrant_list_element;
 
 	DebugQuadrant() :
@@ -220,6 +222,88 @@ class RenderingQuadrant : public RefCounted {
 	}
 };
 
+class PhysicsQuadrant : public RefCounted {
+	GDCLASS(PhysicsQuadrant, RefCounted);
+
+public:
+	struct PhysicsBodyKey {
+		int physics_layer = 0;
+		Vector2 linear_velocity;
+		real_t angular_velocity = 0.0;
+		bool one_way_collision = false;
+		real_t one_way_collision_margin = 0.0;
+
+		bool operator<(const PhysicsBodyKey &p_other) const {
+			if (physics_layer == p_other.physics_layer) {
+				if (linear_velocity == p_other.linear_velocity) {
+					if (angular_velocity == p_other.angular_velocity) {
+						if (one_way_collision == p_other.one_way_collision) {
+							return one_way_collision_margin < p_other.one_way_collision_margin;
+						}
+						return one_way_collision < p_other.one_way_collision;
+					}
+					return angular_velocity < p_other.angular_velocity;
+				}
+				return linear_velocity < p_other.linear_velocity;
+			}
+			return physics_layer < p_other.physics_layer;
+		}
+
+		bool operator!=(const PhysicsBodyKey &p_other) const {
+			return !this->operator==(p_other);
+		}
+		bool operator==(const PhysicsBodyKey &p_other) const {
+			return physics_layer == p_other.physics_layer &&
+					linear_velocity == p_other.linear_velocity &&
+					angular_velocity == p_other.angular_velocity &&
+					one_way_collision == p_other.one_way_collision &&
+					one_way_collision_margin == p_other.one_way_collision_margin;
+		}
+	};
+
+	struct PhysicsBodyKeyHasher {
+		static uint32_t hash(const PhysicsBodyKey &p_hash) {
+			uint32_t h = hash_murmur3_one_32(p_hash.physics_layer);
+			h = hash_murmur3_one_real(p_hash.linear_velocity.x);
+			h = hash_murmur3_one_real(p_hash.linear_velocity.y, h);
+			h = hash_murmur3_one_real(p_hash.angular_velocity, h);
+			return h;
+		}
+	};
+
+	struct PhysicsBodyValue {
+		RID body;
+		Vector<Vector<Vector2>> polygons;
+	};
+
+	struct CoordsWorldComparator {
+		_ALWAYS_INLINE_ bool operator()(const Vector2 &p_a, const Vector2 &p_b) const {
+			// We sort the cells by their local coords, as it is needed by rendering.
+			if (p_a.y == p_b.y) {
+				return p_a.x > p_b.x;
+			} else {
+				return p_a.y < p_b.y;
+			}
+		}
+	};
+
+	Vector2i quadrant_coords;
+	SelfList<CellData>::List cells;
+
+	HashMap<PhysicsBodyKey, PhysicsBodyValue, PhysicsBodyKeyHasher> bodies;
+	LocalVector<Ref<ConvexPolygonShape2D>> shapes;
+
+	SelfList<PhysicsQuadrant> dirty_quadrant_list_element;
+
+	PhysicsQuadrant() :
+			dirty_quadrant_list_element(this) {
+	}
+
+	~PhysicsQuadrant() {
+		cells.clear();
+	}
+};
+
 class TileMapLayer : public Node2D {
 	GDCLASS(TileMapLayer, Node2D);
 
@@ -254,6 +338,7 @@ class TileMapLayer : public Node2D {
 		DIRTY_FLAGS_LAYER_RENDERING_QUADRANT_SIZE,
 		DIRTY_FLAGS_LAYER_COLLISION_ENABLED,
 		DIRTY_FLAGS_LAYER_USE_KINEMATIC_BODIES,
+		DIRTY_FLAGS_LAYER_PHYSICS_QUADRANT_SIZE,
 		DIRTY_FLAGS_LAYER_COLLISION_VISIBILITY_MODE,
 		DIRTY_FLAGS_LAYER_OCCLUSION_ENABLED,
 		DIRTY_FLAGS_LAYER_NAVIGATION_ENABLED,
@@ -288,6 +373,7 @@ class TileMapLayer : public Node2D {
 
 	bool collision_enabled = true;
 	bool use_kinematic_bodies = false;
+	int physics_quadrant_size = 16;
 	DebugVisibilityMode collision_visibility_mode = DEBUG_VISIBILITY_MODE_DEFAULT;
 
 	bool occlusion_enabled = true;
@@ -324,13 +410,16 @@ class TileMapLayer : public Node2D {
 	void _clear_runtime_update_tile_data_for_cell(CellData &r_cell_data);
 	void _update_cells_callback(bool p_force_cleanup);
 
+	// Coords to quadrant coords
+	Vector2i _coords_to_quadrant_coords(const Vector2i &p_coords, const int p_quadrant_size) const;
+
 	// Per-system methods.
 #ifdef DEBUG_ENABLED
 	HashMap<Vector2i, Ref<DebugQuadrant>> debug_quadrant_map;
-	Vector2i _coords_to_debug_quadrant_coords(const Vector2i &p_coords) const;
 	bool _debug_was_cleaned_up = false;
 	void _debug_update(bool p_force_cleanup);
-	void _debug_quadrants_update_cell(CellData &r_cell_data, SelfList<DebugQuadrant>::List &r_dirty_debug_quadrant_list);
+	void _debug_quadrants_update_cell(CellData &r_cell_data);
+	void _get_debug_quadrant_for_cell(const Vector2i &p_coords);
 #endif // DEBUG_ENABLED
 
 	HashMap<Vector2i, Ref<RenderingQuadrant>> rendering_quadrant_map;
@@ -344,14 +433,16 @@ class TileMapLayer : public Node2D {
 	void _rendering_draw_cell_debug(const RID &p_canvas_item, const Vector2 &p_quadrant_pos, const CellData &r_cell_data);
 #endif // DEBUG_ENABLED
 
+	HashMap<Vector2i, Ref<PhysicsQuadrant>> physics_quadrant_map;
 	HashMap<RID, Vector2i> bodies_coords; // Mapping for RID to coords.
 	bool _physics_was_cleaned_up = false;
 	void _physics_update(bool p_force_cleanup);
 	void _physics_notification(int p_what);
+	void _physics_quadrants_update_cell(CellData &r_cell_data, SelfList<PhysicsQuadrant>::List &r_dirty_physics_quadrant_list);
 	void _physics_clear_cell(CellData &r_cell_data);
 	void _physics_update_cell(CellData &r_cell_data);
 #ifdef DEBUG_ENABLED
-	void _physics_draw_cell_debug(const RID &p_canvas_item, const Vector2 &p_quadrant_pos, const CellData &r_cell_data);
+	void _physics_draw_quadrant_debug(const RID &p_canvas_item, DebugQuadrant &r_debug_quadrant);
 #endif // DEBUG_ENABLED
 
 	bool _navigation_was_cleaned_up = false;
@@ -502,6 +593,8 @@ class TileMapLayer : public Node2D {
 	bool is_using_kinematic_bodies() const;
 	void set_collision_visibility_mode(DebugVisibilityMode p_show_collision);
 	DebugVisibilityMode get_collision_visibility_mode() const;
+	void set_physics_quadrant_size(int p_size);
+	int get_physics_quadrant_size() const;
 
 	void set_occlusion_enabled(bool p_enabled);
 	bool is_occlusion_enabled() const;


Test patch: