Bläddra i källkod

[Terrain] Unit tests for terrain world component settings (#10831)

* Unit tests for the Terrain World Component

Signed-off-by: Mike Balfour <[email protected]>

* Add unit tests for world min/max and query resolutions.
In the process, I discovered that surface weight queries weren't using the surface data query resolution or the sampler type. Fixed!

Signed-off-by: Mike Balfour <[email protected]>

* Test query resolutions vs all the sampler types.

Signed-off-by: Mike Balfour <[email protected]>

* Add ToString to surface tag for better python output.
This fixes some warnings in our terrain python automated tests because they tried to print SurfaceTag but didn't have a ToString method.

Signed-off-by: Mike Balfour <[email protected]>

* Add a new "AabbContains2DMaxExclusive".
This is useful for doing min-inclusive-max-exclusive AABB checks, so that we always have a single owner for points that fall on aligned edges of adjacent AABBs.

Signed-off-by: Mike Balfour <[email protected]>

* Added more comments and logging to make it easier to see what's happening in this test.

Signed-off-by: Mike Balfour <[email protected]>

* Remove query offset, it's producing less accurate results.
This might have been needed in the past, but it's currently offsetting the detail materials unnecessarily.

Signed-off-by: Mike Balfour <[email protected]>

* Changed the "CLAMP" mode to round, and area selection to use max-exclusive.
The CLAMP mode guarantees grid alignment, but there's no good reason it always rounded down. It produces better results by rounding to nearest grid point instead.
The AABBs for "best area" selection have been changed to use min-inclusive-max-exclusive so that adjacent AABBs that share an edge have one clear owner for the points on the edge instead of sharing ownership and relying solely on priority.

Signed-off-by: Mike Balfour <[email protected]>

* Fix unit tests to match clamp->round and AABB max-exclusive changes.

Signed-off-by: Mike Balfour <[email protected]>

* PR feedback - switch to templated type.

Signed-off-by: Mike Balfour <[email protected]>

* Another set of fixes.
* Fixes height (and terrainExists) interpolation when not all 4 points exist in the interpolation.
* Fixes the "Use Ground Plane" flag on the terrain layer spawner.
* Fixes min-inclusive-max-exclusive check to GetHeightSynchronous to make it behave the same as GetHeightsSynchronous
* Fixes more of the unit tests due to the other fixes

Signed-off-by: Mike Balfour <[email protected]>

* More unit test fixes.

Signed-off-by: Mike Balfour <[email protected]>

* Fixed the remaining unit tests

Signed-off-by: Mike Balfour <[email protected]>

* PR feedback

Signed-off-by: Mike Balfour <[email protected]>

* Fixes for the existsIndex lookup and terrainExists flag on suface point queries.

Signed-off-by: Mike Balfour <[email protected]>

* Adjusted the test to account for fixes:
- Terrain now excludes points on the max edge of an AABB
- Points that aren't in a spawner are generated with 'terrain_hole' tags. To make the test logic easier and more consistent, it has been switched to use inclusive tags instead of exclusive tags, because exclusive tags were still allowing points on terrain holes to spawn.

Signed-off-by: Mike Balfour <[email protected]>

* Fix build failure.

Signed-off-by: Mike Balfour <[email protected]>
Mike Balfour 3 år sedan
förälder
incheckning
013fba8b59

+ 51 - 24
AutomatedTesting/Gem/PythonTests/Terrain/EditorScripts/TerrainSystem_VegetationSpawnsOnTerrainSurfaces.py

@@ -46,9 +46,10 @@ def TerrainSystem_VegetationSpawnsOnTerrainSurfaces():
     Summary:
     Load an empty level, 
     Create two entities with constant gradient components with different values.
-    Create two entities with TerrainLayerSpawners
-    Create an entity to spawn vegetation
-    Ensure that vegetation spawns at the correct heights
+    Create two non-overlapping entities with TerrainLayerSpawners in adjacent 20 m x 20 m boxes. 
+      Each spawner has a different constant height.
+    Create an entity to spawn vegetation in a 20 m x 20 m boxes where 10 m overlaps the first spawner, and 10 m overlaps the second.
+    Ensure that vegetation spawns at the correct heights.
     Add a VegetationSurfaceMaskFilter and ensure it responds correctly to surface changes.
     :return: None
     """
@@ -140,21 +141,43 @@ def TerrainSystem_VegetationSpawnsOnTerrainSurfaces():
 
     # Move view so that the entities are visible.
     general.set_current_view_position(17.0, -66.0, 41.0)
-    general.set_current_view_rotation(-15, 0, 0)
+    general.set_current_view_rotation(-15.0, 0.0, 0.0)
 
     # Expected item counts under conditions to be tested.
-    # By default, vegetation spawns at a density of 20 items per 16 meters, 
-    # so in a 20m square, there should be around 25 ^ 2 items depending on whether area edges are included.
-    # In this case there are 26 ^ 2 items.
-    expected_surface_tag_excluded_item_count = 338
-    expected_no_exclusions_item_count = 676
+    # By default, vegetation spawns at a density of 20 items per 16 meters, so in a 20m square, there should 
+    # be (20 m * (20/16)) ^ 2 , or 25 ^ 2 instances. However, there's a final spawn point that lands directly on the max edge 
+    # of the 20 m box so vegetation actually spawns 26 ^ 2 = 676 instances. This is how many instances we expect with no filtering.
+    expected_no_filtering_item_count = 676
+    
+    # When filtering to the 'terrain' tag, any points that fall on non-existent terrain (a hole) get filtered out.
+    # The terrain excludes the max edges of the AABB for a spawner, so points that fall on or interpolate to the max edge will get
+    # filtered out. The setup for this level is two adjacent terrain spawners that cover an area of (-10, 0) to (30, 20),
+    # and the vegetation area covers (0, 0) to (20, 20). Any points that fall on max edge (20) in the Y direction get filtered out
+    # because that's the max edge of the terrain, but points on the max edge (20) in the X direction do NOT get filtered out because
+    # there is still more terrain past that point. Therefore, instead of 26 * 26 instances, we have 26 * 25 = 650 instances.
+    expected_terrain_included_item_count = 650
+
+    # When filtering to the 'test_tag2' tag, we're only keeping points in the vegetation area of (0, 0) to (20, 20) that come from the
+    # first terrain spawner, which is (-10, 0) to (10, 20). The overlap box is (0, 0) to (10, 20), which contains 
+    # (10 m * (20/16)) = 12.5 points in X, but since we can't have half points, it only contains 12 points
+    # In Y, we have (20 m * (20/16)) = 25 points. Both X and Y would have an additional point on the max boundary, but since the terrain
+    # spawner ends on both max boundaries, those are excluded and aren't a part of the spawner's surface points.
+    # So the expected count is 12 * 25 = 300 instances.
+    expected_tag2_included_item_count = 300
+
+    # When filtering to the 'test_tag3' tag, we're only keeping points in the vegetation area of (0, 0) to (20, 20) that come from the
+    # second terrain spawner, which is (10, 0) to (30, 20). The overlap box is (10, 0) to (20, 20), which again contains 
+    # 12.5 points in X and 25 points in Y. Due to where the boundaries start, the half point *is* contained in this box, so 
+    # X has 13 points. Also, the max X boundary isn't the max spawner boundary, so it also has a 14th point. Y is still 25 points.
+    # The expected count is 14 * 25 = 350 instances.
+    expected_tag3_included_item_count = 350
 
     # Wait for the vegetation to spawn
-    helper.wait_for_condition(lambda: vegetation.VegetationSpawnerRequestBus(bus.Event, "GetAreaProductCount", vegetation_entity.id) == expected_no_exclusions_item_count, 5.0)
+    helper.wait_for_condition(lambda: vegetation.VegetationSpawnerRequestBus(bus.Event, "GetAreaProductCount", vegetation_entity.id) == expected_no_filtering_item_count, 5.0)
 
     # Check the spawn count is correct.
     item_count = vegetation.VegetationSpawnerRequestBus(bus.Event, "GetAreaProductCount", vegetation_entity.id)
-    Report.result(VegetationTests.unfiltered_vegetation_count_correct, item_count == expected_no_exclusions_item_count)
+    Report.result(VegetationTests.unfiltered_vegetation_count_correct, item_count == expected_no_filtering_item_count)
 
     test_aabb = math.Aabb_CreateFromMinMax(math.Vector3(-10.0, -10.0, 0.0), math.Vector3(30.0, 10.0, box_height))
 
@@ -169,34 +192,38 @@ def TerrainSystem_VegetationSpawnsOnTerrainSurfaces():
     terrain_entity_1.get_set_test(3, "Configuration|Gradient to Surface Mappings|[0]|Surface Tag", surface_data.SurfaceTag("test_tag2"))
     terrain_entity_2.get_set_test(3, "Configuration|Gradient to Surface Mappings|[0]|Surface Tag", surface_data.SurfaceTag("test_tag3"))
 
-    # Give the VegetationSurfaceFilter an exclusion list, set it to exclude test_tag2 which should remove all the lower items which are in terrain_entity_1.
-    vegetation_entity.get_set_test(3, "Configuration|Exclusion|Surface Tags", [surface_data.SurfaceTag()])
-    vegetation_entity.get_set_test(3, "Configuration|Exclusion|Surface Tags|[0]", surface_data.SurfaceTag("test_tag2"))
+    # Give the VegetationSurfaceFilter an inclusion list, set it to include test_tag3 which should
+    # include only the instances on the upper terrain_entity_2.
+    vegetation_entity.get_set_test(3, "Configuration|Inclusion|Surface Tags", [surface_data.SurfaceTag()])
+    vegetation_entity.get_set_test(3, "Configuration|Inclusion|Surface Tags|[0]", surface_data.SurfaceTag("test_tag3"))
   
     # Wait for the vegetation to respawn and check z values.
-    helper.wait_for_condition(lambda: vegetation.VegetationSpawnerRequestBus(bus.Event, "GetAreaProductCount", vegetation_entity.id) == expected_surface_tag_excluded_item_count, 5.0)
+    helper.wait_for_condition(lambda: vegetation.VegetationSpawnerRequestBus(bus.Event, "GetAreaProductCount", vegetation_entity.id) == expected_tag3_included_item_count, 5.0)
 
     item_count = vegetation.VegetationSpawnerRequestBus(bus.Event, "GetAreaProductCount", vegetation_entity.id)
-    Report.result(VegetationTests.testTag2_excluded_vegetation_count_correct, item_count == expected_surface_tag_excluded_item_count)
+    Report.result(VegetationTests.testTag2_excluded_vegetation_count_correct, item_count == expected_tag3_included_item_count)
+    Report.info(f"Found item count for testTag3 inclusion: {item_count}")
 
     highest_z, lowest_z = FindHighestAndLowestZValuesInArea(test_aabb)
 
     Report.result(VegetationTests.testTag2_excluded_vegetation_z_correct, lowest_z > box_height * gradient_value_1)
 
-    # Clear the filter and ensure vegetation respawns.
-    vegetation_entity.get_set_test(3, "Configuration|Exclusion|Surface Tags|[0]", surface_data.SurfaceTag("invalid"))
-    helper.wait_for_condition(lambda: vegetation.VegetationSpawnerRequestBus(bus.Event, "GetAreaProductCount", vegetation_entity.id) == expected_no_exclusions_item_count, 5.0)
+    # Set the filter to 'terrain' and ensure that vegetation spawns across both layers
+    vegetation_entity.get_set_test(3, "Configuration|Inclusion|Surface Tags|[0]", surface_data.SurfaceTag("terrain"))
+    helper.wait_for_condition(lambda: vegetation.VegetationSpawnerRequestBus(bus.Event, "GetAreaProductCount", vegetation_entity.id) == expected_terrain_included_item_count, 5.0)
 
     item_count = vegetation.VegetationSpawnerRequestBus(bus.Event, "GetAreaProductCount", vegetation_entity.id)
-    Report.result(VegetationTests.cleared_exclusion_vegetation_count_correct, item_count == expected_no_exclusions_item_count)
+    Report.result(VegetationTests.cleared_exclusion_vegetation_count_correct, item_count == expected_terrain_included_item_count)
+    Report.info(f"Found item count for cleared exclusion: {item_count}")
 
-    # Exclude test_tag3 to exclude the higher items in terrain_entity_2 and recheck.
-    vegetation_entity.get_set_test(3, "Configuration|Exclusion|Surface Tags|[0]", surface_data.SurfaceTag("test_tag3"))
+    # Include test_tag2 so that only the instances on the lower terrain_entity_1 should be spawned.
+    vegetation_entity.get_set_test(3, "Configuration|Inclusion|Surface Tags|[0]", surface_data.SurfaceTag("test_tag2"))
 
-    helper.wait_for_condition(lambda: vegetation.VegetationSpawnerRequestBus(bus.Event, "GetAreaProductCount", vegetation_entity.id) == expected_surface_tag_excluded_item_count, 5.0)
+    helper.wait_for_condition(lambda: vegetation.VegetationSpawnerRequestBus(bus.Event, "GetAreaProductCount", vegetation_entity.id) == expected_tag2_included_item_count, 5.0)
 
     item_count = vegetation.VegetationSpawnerRequestBus(bus.Event, "GetAreaProductCount", vegetation_entity.id)
-    Report.result(VegetationTests.testTag3_excluded_vegetation_count_correct, item_count == expected_surface_tag_excluded_item_count)
+    Report.result(VegetationTests.testTag3_excluded_vegetation_count_correct, item_count == expected_tag2_included_item_count)
+    Report.info(f"Found item count for testTag2 inclusion: {item_count}")
 
     highest_z, lowest_z = FindHighestAndLowestZValuesInArea(test_aabb)
 

+ 8 - 4
Gems/SurfaceData/Code/Include/SurfaceData/Utility/SurfaceDataUtility.h

@@ -129,19 +129,23 @@ namespace SurfaceData
     }
 
     // Utility method to compare an AABB and a point for overlapping XY coordinates while ignoring the Z coordinates.
-    AZ_INLINE bool AabbContains2D(const AZ::Aabb& box, const AZ::Vector2& point)
+    template<typename VectorType>
+    AZ_INLINE bool AabbContains2D(const AZ::Aabb& box, const VectorType& point)
     {
         return box.GetMin().GetX() <= point.GetX() &&
                box.GetMin().GetY() <= point.GetY() &&
                box.GetMax().GetX() >= point.GetX() &&
                box.GetMax().GetY() >= point.GetY();
     }
+
     // Utility method to compare an AABB and a point for overlapping XY coordinates while ignoring the Z coordinates.
-    AZ_INLINE bool AabbContains2D(const AZ::Aabb& box, const AZ::Vector3& point)
+    // This method includes points that land on the min edge but excludes points that land on the max edge.
+    template<typename VectorType>
+    AZ_INLINE bool AabbContains2DMaxExclusive(const AZ::Aabb& box, const VectorType& point)
     {
         return box.GetMin().GetX() <= point.GetX() &&
                box.GetMin().GetY() <= point.GetY() &&
-               box.GetMax().GetX() >= point.GetX() &&
-               box.GetMax().GetY() >= point.GetY();
+               box.GetMax().GetX() > point.GetX() &&
+               box.GetMax().GetY() > point.GetY();
     }
 }

+ 7 - 0
Gems/SurfaceData/Code/Source/SurfaceTag.cpp

@@ -82,6 +82,13 @@ namespace SurfaceData
                 ->Method("SetTag", &SurfaceTag::SetTag)
                 ->Method("Equal", &SurfaceTag::operator==)
                 ->Attribute(AZ::Script::Attributes::Operator, AZ::Script::Attributes::OperatorType::Equal)
+                ->Method(
+                    "ToString",
+                    [](const SurfaceTag& tag) -> AZStd::string
+                    {
+                        return tag.GetDisplayName();
+                    })
+                    ->Attribute(AZ::Script::Attributes::Operator, AZ::Script::Attributes::OperatorType::ToString)
                 ;
         }
     }

+ 1 - 2
Gems/Terrain/Code/Source/TerrainRenderer/TerrainDetailMaterialManager.cpp

@@ -954,7 +954,6 @@ namespace Terrain
         };
             
         AZ::Vector2 stepSize(m_detailTextureScale);
-        AZ::Aabb offsetWorldAabb = worldUpdateAabb.GetTranslated(AZ::Vector3(m_detailTextureScale * 0.5f)); // offset by half a pixel
 
         AZStd::binary_semaphore wait;
 
@@ -967,7 +966,7 @@ namespace Terrain
             wait.release();
         };
 
-        AzFramework::Terrain::TerrainQueryRegion queryRegion(offsetWorldAabb.GetMin(), width, height, stepSize);
+        AzFramework::Terrain::TerrainQueryRegion queryRegion(worldUpdateAabb.GetMin(), width, height, stepSize);
         AzFramework::Terrain::TerrainDataRequestBus::Broadcast(
             &AzFramework::Terrain::TerrainDataRequests::QueryRegionAsync,
             queryRegion,

+ 198 - 70
Gems/Terrain/Code/Source/TerrainSystem/TerrainSystem.cpp

@@ -190,7 +190,7 @@ float TerrainSystem::GetTerrainSurfaceDataQueryResolution() const
     return m_currentSettings.m_surfaceDataQueryResolution;
 }
 
-void TerrainSystem::ClampPosition(float x, float y, AZ::Vector2& outPosition, AZ::Vector2& normalizedDelta) const
+void TerrainSystem::ClampPosition(float x, float y, float queryResolution, AZ::Vector2& outPosition, AZ::Vector2& normalizedDelta)
 {
     // Given an input position, clamp the values to our terrain grid, where it will always go to the terrain grid point
     // at a lower value, whether positive or negative.  Ex: 3.3 -> 3, -3.3 -> -4
@@ -198,14 +198,79 @@ void TerrainSystem::ClampPosition(float x, float y, AZ::Vector2& outPosition, AZ
 
     // Scale the position by the query resolution, so that integer values represent exact steps on the grid,
     // and fractional values are the amount in-between each grid point, in the range [0-1).
-    AZ::Vector2 normalizedPosition = AZ::Vector2(x, y) / m_currentSettings.m_heightQueryResolution;
+    AZ::Vector2 normalizedPosition = AZ::Vector2(x, y) / queryResolution;
     normalizedDelta = AZ::Vector2(
         normalizedPosition.GetX() - floor(normalizedPosition.GetX()), normalizedPosition.GetY() - floor(normalizedPosition.GetY()));
 
     // Remove the fractional part, then scale back down into world space.
-    outPosition = (normalizedPosition - normalizedDelta) * m_currentSettings.m_heightQueryResolution;
+    outPosition = (normalizedPosition - normalizedDelta) * queryResolution;
 }
 
+void TerrainSystem::RoundPosition(float x, float y, float queryResolution, AZ::Vector2& outPosition)
+{
+    // Given an input position, clamp the values to our terrain grid, where it will always go to the nearest terrain grid point
+    // whether positive or negative.  Ex: 3.3 -> 3, 3.6 -> 4, -3.3 -> -3, -3.6 -> -4
+
+    // Scale the position by the query resolution, so that integer values represent exact steps on the grid,
+    // and fractional values are the amount in-between each grid point, in the range [0-1).
+    AZ::Vector2 normalizedPosition = AZ::Vector2(x, y) / queryResolution;
+
+    // Round the fractional part, then scale back down into world space.
+    // Note that we use "floor(pos + 0.5f)" instead of round() because round() will round to the nearest even integer (banker's rounding)
+    // on the 0.5 points instead of to the nearest integer biased away from 0 (symmetric arithmetic rounding), which is what we want.
+    // "floor(pos + 0.5f)" will round 1.5 -> 2, 2.5 -> 3, -1.5 -> -2, -2.5 -> -3, etc. 
+    // (i.e. don't use: outPosition = normalizedPosition.GetRound() * queryResolution;)
+    outPosition = (normalizedPosition + AZ::Vector2(0.5f)).GetFloor() * queryResolution;
+}
+
+void TerrainSystem::InterpolateHeights(const AZStd::array<float,4>& heights, const AZStd::array<bool,4>& exists,
+    float lerpX, float lerpY, float& outHeight, bool& outExists)
+{
+    // When interpolating between 4 height points, we also need to take the existence of the 4 points into account.
+    // The logic below uses a precomputed lookup table to determine how to interpolate the points in each combination of existence.
+    // The final "terrain exists" flag gets computed based on the existence of the corner that's closest to the interpolated point.
+
+    uint8_t indexLookup = (exists[3] << 3) | (exists[2] << 2) | (exists[1] << 1) | (exists[0] << 0);
+
+    constexpr uint8_t heightIndices[16][4] =
+    {
+                        // x0y0 x1y0 x0y1 x1y1  output
+        { 0, 0, 0, 0 }, // F    F    F    F     x0y0
+        { 0, 0, 0, 0 }, // T    F    F    F     x0y0
+        { 1, 1, 1, 1 }, // F    T    F    F     x1y0
+        { 0, 1, 0, 1 }, // T    T    F    F     lerp(x0y0, x1y0)
+        { 2, 2, 2, 2 }, // F    F    T    F     x0y1
+        { 0, 0, 2, 2 }, // T    F    T    F     lerp(x0y0, x0y1)
+        { 1, 1, 2, 2 }, // F    T    T    F     lerp(x1y0, x0y1)
+        { 0, 1, 2, 2 }, // T    T    T    F     lerp(lerp(x0y0, x1y0), x0y1)
+
+        { 3, 3, 3, 3 }, // F    F    F    T     x1y1
+        { 0, 0, 3, 3 }, // T    F    F    T     lerp(x0y0, x1y1)
+        { 1, 1, 3, 3 }, // F    T    F    T     lerp(x1y0, x1y1)
+        { 0, 1, 3, 3 }, // T    T    F    T     lerp(lerp(x0y0, x1y0), x1y1)
+        { 2, 3, 2, 3 }, // F    F    T    T     lerp(x0y1, x1y1)
+        { 0, 0, 2, 3 }, // T    F    T    T     lerp(x0y0, lerp(x0y1, x1y1))
+        { 1, 1, 2, 2 }, // F    T    T    T     lerp(x1y0, lerp(x0y1, x1y1))
+        { 0, 1, 2, 3 }, // T    T    T    T     lerp(lerp(x0y0, x1y0), lerp(x0y1, x1y1))
+    };
+
+    const float heightX0Y0 = heights[heightIndices[indexLookup][0]];
+    const float heightX1Y0 = heights[heightIndices[indexLookup][1]];
+    const float heightX0Y1 = heights[heightIndices[indexLookup][2]];
+    const float heightX1Y1 = heights[heightIndices[indexLookup][3]];
+
+    const float heightXY0 = AZ::Lerp(heightX0Y0, heightX1Y0, lerpX);
+    const float heightXY1 = AZ::Lerp(heightX0Y1, heightX1Y1, lerpX);
+    outHeight = AZ::Lerp(heightXY0, heightXY1, lerpY);
+
+    // "Terrain exists" is set based on the existance of the nearest vertex to the point,
+    // which is determined by which 1/4 of the quad the point falls in. We can determine that based on
+    // which side of 0.5 our lerp X and Y values land on.
+    uint8_t existsIndex = ((lerpY >= 0.5f) << 1) | (lerpX >= 0.5f);
+    outExists = exists[existsIndex];
+}
+
+
 bool TerrainSystem::InWorldBounds(float x, float y) const
 {
     const float zTestValue = m_currentSettings.m_worldBounds.GetMin().GetZ();
@@ -219,7 +284,7 @@ bool TerrainSystem::InWorldBounds(float x, float y) const
 
 // Generate positions to be queried based on the sampler type.
 void TerrainSystem::GenerateQueryPositions(const AZStd::span<const AZ::Vector3>& inPositions,
-    AZStd::vector<AZ::Vector3>& outPositions,
+    AZStd::vector<AZ::Vector3>& outPositions, float queryResolution,
     Sampler sampler) const
 {
     AZ_PROFILE_FUNCTION(Terrain);
@@ -235,9 +300,8 @@ void TerrainSystem::GenerateQueryPositions(const AZStd::span<const AZ::Vector3>&
                 {
                     AZ::Vector2 normalizedDelta;
                     AZ::Vector2 pos0;
-                    ClampPosition(position.GetX(), position.GetY(), pos0, normalizedDelta);
-                    const AZ::Vector2 pos1(
-                        pos0.GetX() + m_currentSettings.m_heightQueryResolution, pos0.GetY() + m_currentSettings.m_heightQueryResolution);
+                    ClampPosition(position.GetX(), position.GetY(), queryResolution, pos0, normalizedDelta);
+                    const AZ::Vector2 pos1(pos0.GetX() + queryResolution, pos0.GetY() + queryResolution);
                     outPositions.emplace_back(AZ::Vector3(pos0.GetX(), pos0.GetY(), minHeight));
                     outPositions.emplace_back(AZ::Vector3(pos1.GetX(), pos0.GetY(), minHeight));
                     outPositions.emplace_back(AZ::Vector3(pos0.GetX(), pos1.GetY(), minHeight));
@@ -257,9 +321,8 @@ void TerrainSystem::GenerateQueryPositions(const AZStd::span<const AZ::Vector3>&
             break;
         case AzFramework::Terrain::TerrainDataRequests::Sampler::CLAMP:
             {
-                AZ::Vector2 normalizedDelta;
                 AZ::Vector2 clampedPosition;
-                ClampPosition(position.GetX(), position.GetY(), clampedPosition, normalizedDelta);
+                RoundPosition(position.GetX(), position.GetY(), queryResolution, clampedPosition);
                 outPositions.emplace_back(AZ::Vector3(clampedPosition.GetX(), clampedPosition.GetY(), minHeight));
             }
             break;
@@ -380,9 +443,11 @@ void TerrainSystem::GetHeightsSynchronous(const AZStd::span<const AZ::Vector3>&
     outPositions.reserve(inPositions.size() * indexStepSize);
     outTerrainExists.resize(inPositions.size() * indexStepSize);
 
-    GenerateQueryPositions(inPositions, outPositions, sampler);
+    const float queryResolution = m_currentSettings.m_heightQueryResolution;
+
+    GenerateQueryPositions(inPositions, outPositions, queryResolution, sampler);
 
-    auto callback = []([[maybe_unused]] const AZStd::span<const AZ::Vector3> inPositions,
+    auto callback = [this]([[maybe_unused]] const AZStd::span<const AZ::Vector3> inPositions,
                         AZStd::span<AZ::Vector3> outPositions,
                         AZStd::span<bool> outTerrainExists,
                         [[maybe_unused]] AZStd::span<AzFramework::SurfaceData::SurfaceTagWeightList> outSurfaceWeights,
@@ -392,6 +457,23 @@ void TerrainSystem::GetHeightsSynchronous(const AZStd::span<const AZ::Vector3>&
                                 "The sizes of the terrain exists list and in/out positions list should match.");
                             Terrain::TerrainAreaHeightRequestBus::Event(areaId, &Terrain::TerrainAreaHeightRequestBus::Events::GetHeights,
                                 outPositions, outTerrainExists);
+
+                            // If the area has "use ground plane" checked, make sure any points that fall in the area that didn't
+                            // return data are filled in with the area's minimum height.
+                            const auto& area = m_registeredAreas.find(areaId);
+                            if ((area != m_registeredAreas.end()) && area->second.m_useGroundPlane)
+                            {
+                                const float areaMin = area->second.m_areaBounds.GetMin().GetZ();
+
+                                for (size_t index = 0; index < outPositions.size(); index++)
+                                {
+                                    if (!outTerrainExists[index])
+                                    {
+                                        outTerrainExists[index] = true;
+                                        outPositions[index].SetZ(areaMin);
+                                    }
+                                }
+                            }
                         };
 
     // This will be unused for heights. It's fine if it's empty.
@@ -408,15 +490,18 @@ void TerrainSystem::GetHeightsSynchronous(const AZStd::span<const AZ::Vector3>&
                 // We now need to compute the final height after all the bulk queries are done.
                 AZ::Vector2 normalizedDelta;
                 AZ::Vector2 clampedPosition;
-                ClampPosition(inPositions[i].GetX(), inPositions[i].GetY(), clampedPosition, normalizedDelta);
-                const float heightX0Y0 = outPositions[iteratorIndex].GetZ();
-                const float heightX1Y0 = outPositions[iteratorIndex + 1].GetZ();
-                const float heightX0Y1 = outPositions[iteratorIndex + 2].GetZ();
-                const float heightX1Y1 = outPositions[iteratorIndex + 3].GetZ();
-                const float heightXY0 = AZ::Lerp(heightX0Y0, heightX1Y0, normalizedDelta.GetX());
-                const float heightXY1 = AZ::Lerp(heightX0Y1, heightX1Y1, normalizedDelta.GetX());
-                heights[i] = AZ::Lerp(heightXY0, heightXY1, normalizedDelta.GetY());
-                terrainExists[i] = outTerrainExists[iteratorIndex];
+                ClampPosition(inPositions[i].GetX(), inPositions[i].GetY(), queryResolution, clampedPosition, normalizedDelta);
+                AZStd::array<float,4> queriedHeights = { outPositions[iteratorIndex].GetZ(),
+                                     outPositions[iteratorIndex + 1].GetZ(),
+                                     outPositions[iteratorIndex + 2].GetZ(),
+                                     outPositions[iteratorIndex + 3].GetZ() };
+                AZStd::array<bool, 4> queriedExistsFlags = { outTerrainExists[iteratorIndex],
+                    outTerrainExists[iteratorIndex + 1],
+                    outTerrainExists[iteratorIndex + 2],
+                    outTerrainExists[iteratorIndex + 3]};
+
+                InterpolateHeights(queriedHeights, queriedExistsFlags,
+                    normalizedDelta.GetX(), normalizedDelta.GetY(), heights[i], terrainExists[i]);
             }
             break;
         case AzFramework::Terrain::TerrainDataRequests::Sampler::CLAMP:
@@ -442,12 +527,14 @@ float TerrainSystem::GetHeightSynchronous(float x, float y, Sampler sampler, boo
         if (terrainExistsPtr)
         {
             *terrainExistsPtr = terrainExists;
-            return height;
         }
+        return height;
     }
 
     AZStd::shared_lock<AZStd::shared_mutex> lock(m_areaMutex);
 
+    const float queryResolution = m_currentSettings.m_heightQueryResolution;
+
     switch (sampler)
     {
     // Get the value at the requested location, using the terrain grid to bilinear filter between sample grid points.
@@ -458,25 +545,24 @@ float TerrainSystem::GetHeightSynchronous(float x, float y, Sampler sampler, boo
             // Ex: (3.3, 4.4) would have a pos0 of (3, 4), a pos1 of (4, 5), and a delta of (0.3, 0.4).
             AZ::Vector2 normalizedDelta;
             AZ::Vector2 pos0;
-            ClampPosition(x, y, pos0, normalizedDelta);
-            const AZ::Vector2 pos1 = pos0 + AZ::Vector2(m_currentSettings.m_heightQueryResolution);
-
-            const float heightX0Y0 = GetTerrainAreaHeight(pos0.GetX(), pos0.GetY(), terrainExists);
-            const float heightX1Y0 = GetTerrainAreaHeight(pos1.GetX(), pos0.GetY(), terrainExists);
-            const float heightX0Y1 = GetTerrainAreaHeight(pos0.GetX(), pos1.GetY(), terrainExists);
-            const float heightX1Y1 = GetTerrainAreaHeight(pos1.GetX(), pos1.GetY(), terrainExists);
-            const float heightXY0 = AZ::Lerp(heightX0Y0, heightX1Y0, normalizedDelta.GetX());
-            const float heightXY1 = AZ::Lerp(heightX0Y1, heightX1Y1, normalizedDelta.GetX());
-            height = AZ::Lerp(heightXY0, heightXY1, normalizedDelta.GetY());
+            ClampPosition(x, y, queryResolution, pos0, normalizedDelta);
+            const AZ::Vector2 pos1 = pos0 + AZ::Vector2(queryResolution);
+
+            AZStd::array<bool,4> exists = { false, false, false, false };
+            const AZStd::array<float, 4> queriedHeights = { GetTerrainAreaHeight(pos0.GetX(), pos0.GetY(), exists[0]),
+                                                            GetTerrainAreaHeight(pos1.GetX(), pos0.GetY(), exists[1]),
+                                                            GetTerrainAreaHeight(pos0.GetX(), pos1.GetY(), exists[2]),
+                                                            GetTerrainAreaHeight(pos1.GetX(), pos1.GetY(), exists[3]) };
+
+            InterpolateHeights(queriedHeights, exists, normalizedDelta.GetX(), normalizedDelta.GetY(), height, terrainExists);
         }
         break;
 
     //! Clamp the input point to the terrain sample grid, then get the height at the given grid location.
     case AzFramework::Terrain::TerrainDataRequests::Sampler::CLAMP:
         {
-            AZ::Vector2 normalizedDelta;
             AZ::Vector2 clampedPosition;
-            ClampPosition(x, y, clampedPosition, normalizedDelta);
+            RoundPosition(x, y, queryResolution, clampedPosition);
 
             height = GetTerrainAreaHeight(clampedPosition.GetX(), clampedPosition.GetY(), terrainExists);
         }
@@ -513,7 +599,7 @@ float TerrainSystem::GetTerrainAreaHeight(float x, float y, bool& terrainExists)
     {
         const float areaMin = areaData.m_areaBounds.GetMin().GetZ();
         inPosition.SetZ(areaMin);
-        if (areaData.m_areaBounds.Contains(inPosition))
+        if (SurfaceData::AabbContains2DMaxExclusive(areaData.m_areaBounds, inPosition))
         {
             AZ::Vector3 outPosition;
             Terrain::TerrainAreaHeightRequestBus::Event(
@@ -603,7 +689,7 @@ void TerrainSystem::GetNormalsSynchronous(const AZStd::span<const AZ::Vector3>&
 
         // This needs better logic for handling cases where some points exist and some don't, but for now we'll say that if
         // any of the four points exist, then the terrain exists.
-        terrainExists[i] = exists[iteratorIndex] || exists[iteratorIndex + 1] || exists [iteratorIndex + 2] || exists[iteratorIndex + 3];
+        terrainExists[i] = exists[iteratorIndex] || exists[iteratorIndex + 1] || exists[iteratorIndex + 2] || exists[iteratorIndex + 3];
     }
 }
 
@@ -623,29 +709,29 @@ AZ::Vector3 TerrainSystem::GetNormalSynchronous(float x, float y, Sampler sample
             return outNormal;
         }
     }
-    float range = m_currentSettings.m_heightQueryResolution / 2.0f;
-    const AZ::Vector2 left (x - range, y);
-    const AZ::Vector2 right(x + range, y);
-    const AZ::Vector2 up   (x, y - range);
-    const AZ::Vector2 down (x, y + range);
 
-    bool terrainExists1 = false;
-    bool terrainExists2 = false;
-    bool terrainExists3 = false;
-    bool terrainExists4 = false;
+    const float range = m_currentSettings.m_heightQueryResolution / 2.0f;
+    AZStd::array<AZ::Vector3, 4> directionVectors = { AZ::Vector3(x, y - range, 0.0f),
+                                                      AZ::Vector3(x - range, y, 0.0f),
+                                                      AZ::Vector3(x + range, y, 0.0f),
+                                                      AZ::Vector3(x, y + range, 0.0f) };
+
+    AZStd::array<float, 4> heights = { 0.0f, 0.0f, 0.0f, 0.0f };
+    AZStd::array<bool, 4> exists = { false, false, false, false };
+    GetHeightsSynchronous(directionVectors, sampler, heights, exists);
 
-    AZ::Vector3 v1(up.GetX(), up.GetY(), GetHeightSynchronous(up.GetX(), up.GetY(), sampler, &terrainExists1));
-    AZ::Vector3 v2(left.GetX(), left.GetY(), GetHeightSynchronous(left.GetX(), left.GetY(), sampler, &terrainExists2));
-    AZ::Vector3 v3(right.GetX(), right.GetY(), GetHeightSynchronous(right.GetX(), right.GetY(), sampler, &terrainExists3));
-    AZ::Vector3 v4(down.GetX(), down.GetY(), GetHeightSynchronous(down.GetX(), down.GetY(), sampler, &terrainExists4));
+    directionVectors[0].SetZ(heights[0]);
+    directionVectors[1].SetZ(heights[1]);
+    directionVectors[2].SetZ(heights[2]);
+    directionVectors[3].SetZ(heights[3]);
 
-    outNormal = (v3 - v2).Cross(v4 - v1).GetNormalized();
+    outNormal = (directionVectors[2] - directionVectors[1]).Cross(directionVectors[3] - directionVectors[0]).GetNormalized();
 
     if (terrainExistsPtr)
     {
         // This needs better logic for handling cases where some points exist and some don't, but for now we'll say that if
         // any of the four points exist, then the terrain exists.
-        *terrainExistsPtr = terrainExists1 || terrainExists2 || terrainExists3 || terrainExists4;
+        *terrainExistsPtr = exists[0] || exists[1] || exists[2] || exists[3];
     }
 
     return outNormal;
@@ -713,9 +799,12 @@ void TerrainSystem::GetSurfacePoint(
     Sampler sampler,
     bool* terrainExistsPtr) const
 {
+    // Query normals before heights because the height query produces better results for the terrainExists flag for a given point,
+    // so we want to prefer keeping the results from the height query if we end up querying both.
+    // (Ideally at some point they will produce identical results)
+    outSurfacePoint.m_normal = GetNormalSynchronous(inPosition.GetX(), inPosition.GetY(), sampler, terrainExistsPtr);
     outSurfacePoint.m_position = inPosition;
     outSurfacePoint.m_position.SetZ(GetHeightSynchronous(inPosition.GetX(), inPosition.GetY(), sampler, terrainExistsPtr));
-    outSurfacePoint.m_normal = GetNormalSynchronous(inPosition.GetX(), inPosition.GetY(), sampler, nullptr);
     GetSurfaceWeights(inPosition, outSurfacePoint.m_surfaceTags, sampler, nullptr);
 }
 
@@ -897,7 +986,8 @@ AZ::EntityId TerrainSystem::FindBestAreaEntityAtPosition(const AZ::Vector3& posi
     // The areas are sorted into priority order: the first area that contains inPosition is the most suitable.
     for (const auto& [areaId, areaData] : m_registeredAreas)
     {
-        if (SurfaceData::AabbContains2D(areaData.m_areaBounds, position))
+        // We use min-inclusive-max-exclusive so that two spawners with a shared edge will have a single owner for that edge.
+        if (SurfaceData::AabbContains2DMaxExclusive(areaData.m_areaBounds, position))
         {
             bounds = areaData.m_areaBounds;
             return areaId;
@@ -909,7 +999,7 @@ AZ::EntityId TerrainSystem::FindBestAreaEntityAtPosition(const AZ::Vector3& posi
 
 void TerrainSystem::GetOrderedSurfaceWeightsFromList(
     const AZStd::span<const AZ::Vector3>& inPositions,
-    [[maybe_unused]] Sampler sampler,
+    Sampler sampler,
     AZStd::span<AzFramework::SurfaceData::SurfaceTagWeightList> outSurfaceWeightsList,
     AZStd::span<bool> terrainExists) const
 {
@@ -921,6 +1011,14 @@ void TerrainSystem::GetOrderedSurfaceWeightsFromList(
         GetHeightsSynchronous(inPositions, AzFramework::Terrain::TerrainDataRequests::Sampler::EXACT, heights, terrainExists);
     }
 
+    // queryPositions contains the modified positions based on our sampler type. For surface queries, we don't currently perform bilinear
+    // interpolation of any results, so our query position size will always match our input size.
+    AZStd::vector<AZ::Vector3> queryPositions;
+    queryPositions.reserve(inPositions.size());
+    const float queryResolution = m_currentSettings.m_surfaceDataQueryResolution;
+    Sampler querySampler = (sampler == Sampler::EXACT) ? Sampler::EXACT : Sampler::CLAMP;
+    GenerateQueryPositions(inPositions, queryPositions, queryResolution, querySampler);
+
     auto callback = [](const AZStd::span<const AZ::Vector3> inPositions,
                         [[maybe_unused]] AZStd::span<AZ::Vector3> outPositions,
                         [[maybe_unused]] AZStd::span<bool> outTerrainExists,
@@ -943,21 +1041,18 @@ void TerrainSystem::GetOrderedSurfaceWeightsFromList(
     
     // This will be unused for surface weights. It's fine if it's empty.
     AZStd::vector<AZ::Vector3> outPositions;
-    MakeBulkQueries(inPositions, outPositions, terrainExists, outSurfaceWeightsList, callback);
+    MakeBulkQueries(queryPositions, outPositions, terrainExists, outSurfaceWeightsList, callback);
 }
 
 void TerrainSystem::GetOrderedSurfaceWeights(
     const float x,
     const float y,
-    [[maybe_unused]] Sampler sampler,
+    Sampler sampler,
     AzFramework::SurfaceData::SurfaceTagWeightList& outSurfaceWeights,
     bool* terrainExistsPtr) const
 {
     AZStd::shared_lock<AZStd::shared_mutex> lock(m_areaMutex);
 
-    AZ::Aabb bounds;
-    AZ::EntityId bestAreaId = FindBestAreaEntityAtPosition(AZ::Vector3(x, y, 0.0f), bounds);
-
     if (terrainExistsPtr)
     {
         GetHeightFromFloats(x, y, AzFramework::Terrain::TerrainDataRequests::Sampler::EXACT, terrainExistsPtr);
@@ -965,13 +1060,40 @@ void TerrainSystem::GetOrderedSurfaceWeights(
 
     outSurfaceWeights.clear();
 
+    const float queryResolution = m_currentSettings.m_surfaceDataQueryResolution;
+
+    AZ::Vector3 inPosition;
+
+    switch (sampler)
+    {
+    // Both bilinear and clamp samplers will clamp the input position to the surface data query grid and get the surface data there.
+    // At some point we might want to consider interpolation of surface weights for the bilinear case, but it's unclear if that's
+    // actually a desired outcome.
+    case AzFramework::Terrain::TerrainDataRequests::Sampler::BILINEAR:
+        [[fallthrough]];
+    case AzFramework::Terrain::TerrainDataRequests::Sampler::CLAMP:
+        {
+            AZ::Vector2 clampedPosition;
+            RoundPosition(x, y, queryResolution, clampedPosition);
+            inPosition = AZ::Vector3(clampedPosition);
+        }
+        break;
+    //! Directly get the value at the location, regardless of terrain sample grid density.
+    case AzFramework::Terrain::TerrainDataRequests::Sampler::EXACT:
+        [[fallthrough]];
+    default:
+        inPosition = AZ::Vector3(x, y, 0.0f);
+        break;
+    }
+
+    AZ::Aabb bounds;
+    AZ::EntityId bestAreaId = FindBestAreaEntityAtPosition(inPosition, bounds);
+
     if (!bestAreaId.IsValid())
     {
         return;
     }
 
-    const AZ::Vector3 inPosition = AZ::Vector3(x, y, 0.0f);
-
     // Get all the surfaces with weights at the given point.
     Terrain::TerrainAreaSurfaceRequestBus::Event(
         bestAreaId, &Terrain::TerrainAreaSurfaceRequestBus::Events::GetSurfaceWeights, inPosition, outSurfaceWeights);
@@ -1036,16 +1158,19 @@ void TerrainSystem::QueryList(
     AZStd::vector<AZ::Vector3> normals;
     AZStd::vector<AzFramework::SurfaceData::SurfaceTagWeightList> surfaceWeights;
 
-    if (requestedData & TerrainDataMask::Heights)
-    {
-        heights.resize(inPositions.size());
-        GetHeightsSynchronous(inPositions, sampler, heights, terrainExists);
-    }
+    // Query normals before heights because the height query produces better results for the terrainExists flag for a given point,
+    // so we want to prefer keeping the results from the height query if we end up querying both.
+    // (Ideally at some point they will produce identical results)
     if (requestedData & TerrainDataMask::Normals)
     {
         normals.resize(inPositions.size());
         GetNormalsSynchronous(inPositions, sampler, normals, terrainExists);
     }
+    if (requestedData & TerrainDataMask::Heights)
+    {
+        heights.resize(inPositions.size());
+        GetHeightsSynchronous(inPositions, sampler, heights, terrainExists);
+    }
     if (requestedData & TerrainDataMask::SurfaceData)
     {
         // We can potentially skip an extra call to GetHeights if we already
@@ -1217,16 +1342,19 @@ void TerrainSystem::QueryRegionInternal(
     AZStd::vector<AZ::Vector3> normals;
     AZStd::vector<AzFramework::SurfaceData::SurfaceTagWeightList> surfaceWeights;
 
-    if (requestedData & TerrainDataMask::Heights)
-    {
-        heights.resize(inPositions.size());
-        GetHeightsSynchronous(inPositions, sampler, heights, terrainExists);
-    }
+    // Query normals before heights because the height query produces better results for the terrainExists flag for a given point,
+    // so we want to prefer keeping the results from the height query if we end up querying both.
+    // (Ideally at some point they will produce identical results)
     if (requestedData & TerrainDataMask::Normals)
     {
         normals.resize(inPositions.size());
         GetNormalsSynchronous(inPositions, sampler, normals, terrainExists);
     }
+    if (requestedData & TerrainDataMask::Heights)
+    {
+        heights.resize(inPositions.size());
+        GetHeightsSynchronous(inPositions, sampler, heights, terrainExists);
+    }
     if (requestedData & TerrainDataMask::SurfaceData)
     {
         // We can potentially skip an extra call to GetHeights if we already

+ 5 - 2
Gems/Terrain/Code/Source/TerrainSystem/TerrainSystem.h

@@ -215,7 +215,10 @@ namespace Terrain
             Sampler sampler = Sampler::DEFAULT,
             AZStd::shared_ptr<AzFramework::Terrain::QueryAsyncParams> params = nullptr) const;
 
-        void ClampPosition(float x, float y, AZ::Vector2& outPosition, AZ::Vector2& normalizedDelta) const;
+        static void ClampPosition(float x, float y, float queryResolution, AZ::Vector2& outPosition, AZ::Vector2& normalizedDelta);
+        static void RoundPosition(float x, float y, float queryResolution, AZ::Vector2& outPosition);
+        static void InterpolateHeights(const AZStd::array<float, 4>& heights, const AZStd::array<bool, 4>& exists,
+            float lerpX, float lerpY, float& outHeight, bool& outExists);
         bool InWorldBounds(float x, float y) const;
 
         AZ::EntityId FindBestAreaEntityAtPosition(const AZ::Vector3& position, AZ::Aabb& bounds) const;
@@ -255,7 +258,7 @@ namespace Terrain
             AZStd::span<AzFramework::SurfaceData::SurfaceTagWeightList> outSurfaceWieghts,
             BulkQueriesCallback queryCallback) const;
         void GenerateQueryPositions(const AZStd::span<const AZ::Vector3>& inPositions, 
-            AZStd::vector<AZ::Vector3>& outPositions,
+            AZStd::vector<AZ::Vector3>& outPositions, float queryResolution,
             Sampler sampler) const;
         AZStd::vector<AZ::Vector3> GenerateInputPositionsFromRegion(
             const AzFramework::Terrain::TerrainQueryRegion& queryRegion) const;

+ 116 - 69
Gems/Terrain/Code/Tests/TerrainBulkQueryTests.cpp

@@ -32,20 +32,20 @@ namespace UnitTest::TerrainTest
             const AZ::Vector2& inputQueryStepSize,
             AzFramework::Terrain::TerrainDataRequests::Sampler sampler,
             AZStd::vector<AZ::Vector3>& queryPositions,
-            AZStd::vector<AZ::Vector3>& resultPositions)
+            AZStd::vector<AZ::Vector3>& resultPositions,
+            AZStd::vector<bool>& resultExistsFlags)
         {
             queryPositions.clear();
             resultPositions.clear();
+            resultExistsFlags.clear();
 
-            auto perPositionCallback = [&queryPositions, &resultPositions](
+            auto perPositionCallback = [&queryPositions, &resultPositions, &resultExistsFlags](
                 [[maybe_unused]] size_t xIndex, [[maybe_unused]] size_t yIndex,
                 const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
             {
                 queryPositions.emplace_back(surfacePoint.m_position.GetX(), surfacePoint.m_position.GetY(), 0.0f);
                 resultPositions.emplace_back(surfacePoint.m_position);
-
-                // For these unit tests, we expect every point queried to have valid terrain data.
-                EXPECT_TRUE(terrainExists);
+                resultExistsFlags.emplace_back(terrainExists);
             };
 
             AzFramework::Terrain::TerrainQueryRegion queryRegion =
@@ -58,7 +58,8 @@ namespace UnitTest::TerrainTest
         }
 
         // Compare two sets of output data and verify that they match.
-        void ComparePositionData(const AZStd::vector<AZ::Vector3>& baselineValues, const AZStd::vector<AZ::Vector3>& comparisonValues)
+        void ComparePositionData(const AZStd::vector<AZ::Vector3>& baselineValues, const AZStd::vector<bool>& baselineExistsFlags,
+            const AZStd::vector<AZ::Vector3>& comparisonValues, const AZStd::vector<bool>& comparisonExistsFlags)
         {
             // Verify that we have the same quantity of results in both sets.
             ASSERT_EQ(baselineValues.size(), comparisonValues.size());
@@ -66,14 +67,15 @@ namespace UnitTest::TerrainTest
             // Verify that every value is found exactly once in each set. The two sets might not have the values in the same order though,
             // so we need to search for each value, verify it's found, and verify that it hadn't previously been found.
             AZStd::vector<bool> matchFound(baselineValues.size(), false);
-            for (auto& comparisonValue : comparisonValues)
+            for (size_t comparisonIndex = 0; comparisonIndex < comparisonValues.size(); comparisonIndex++)
             {
-                auto foundValue = AZStd::find(baselineValues.begin(), baselineValues.end(), comparisonValue);
+                auto foundValue = AZStd::find(baselineValues.begin(), baselineValues.end(), comparisonValues[comparisonIndex]);
                 EXPECT_NE(foundValue, baselineValues.end());
                 if (foundValue != baselineValues.end())
                 {
                     size_t foundIndex = foundValue - baselineValues.begin();
                     EXPECT_FALSE(matchFound[foundIndex]);
+                    EXPECT_EQ(baselineExistsFlags[foundIndex], comparisonExistsFlags[comparisonIndex]);
                     matchFound[foundIndex] = true;
                 }
             }
@@ -85,20 +87,20 @@ namespace UnitTest::TerrainTest
             const AZ::Vector2& inputQueryStepSize,
             AzFramework::Terrain::TerrainDataRequests::Sampler sampler,
             AZStd::vector<AZ::Vector3>& queryPositions,
-            AZStd::vector<AZ::Vector3>& resultNormals)
+            AZStd::vector<AZ::Vector3>& resultNormals,
+            AZStd::vector<bool>& resultExistsFlags)
         {
             queryPositions.clear();
             resultNormals.clear();
+            resultExistsFlags.clear();
 
-            auto perPositionCallback = [&queryPositions, &resultNormals](
+            auto perPositionCallback = [&queryPositions, &resultNormals, &resultExistsFlags](
                 [[maybe_unused]] size_t xIndex, [[maybe_unused]] size_t yIndex,
                 const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
             {
                 queryPositions.emplace_back(surfacePoint.m_position.GetX(), surfacePoint.m_position.GetY(), 0.0f);
                 resultNormals.emplace_back(surfacePoint.m_normal);
-
-                // For these unit tests, we expect every point queried to have valid terrain data.
-                EXPECT_TRUE(terrainExists);
+                resultExistsFlags.emplace_back(terrainExists);
             };
 
             AzFramework::Terrain::TerrainQueryRegion queryRegion =
@@ -114,8 +116,10 @@ namespace UnitTest::TerrainTest
         void CompareNormalData(
             const AZStd::vector<AZ::Vector3>& baselineQueryPositions,
             const AZStd::vector<AZ::Vector3>& baselineValues,
+            const AZStd::vector<bool>& baselineExistsFlags,
             const AZStd::vector<AZ::Vector3>& comparisonQueryPositions,
-            const AZStd::vector<AZ::Vector3>& comparisonValues)
+            const AZStd::vector<AZ::Vector3>& comparisonValues,
+            const AZStd::vector<bool>& comparisonExistsFlags)
         {
             // Verify that we have the same quantity of results in both sets.
             ASSERT_EQ(baselineValues.size(), comparisonValues.size());
@@ -134,6 +138,7 @@ namespace UnitTest::TerrainTest
                     size_t foundIndex = foundPosition - baselineQueryPositions.begin();
                     EXPECT_FALSE(matchFound[foundIndex]);
                     EXPECT_EQ(baselineValues[foundIndex], comparisonValues[comparisonIndex]);
+                    EXPECT_EQ(baselineExistsFlags[foundIndex], comparisonExistsFlags[comparisonIndex]);
                     matchFound[foundIndex] = true;
                 }
             }
@@ -205,20 +210,20 @@ namespace UnitTest::TerrainTest
             const AZ::Vector2& inputQueryStepSize,
             AzFramework::Terrain::TerrainDataRequests::Sampler sampler,
             AZStd::vector<AZ::Vector3>& queryPositions,
-            AZStd::vector<AzFramework::SurfaceData::SurfacePoint>& resultPoints)
+            AZStd::vector<AzFramework::SurfaceData::SurfacePoint>& resultPoints,
+            AZStd::vector<bool>& resultExistsFlags)
         {
             queryPositions.clear();
             resultPoints.clear();
+            resultExistsFlags.clear();
 
-            auto perPositionCallback = [&queryPositions, &resultPoints](
+            auto perPositionCallback = [&queryPositions, &resultPoints, &resultExistsFlags](
                 [[maybe_unused]] size_t xIndex, [[maybe_unused]] size_t yIndex,
                 const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
             {
                 queryPositions.emplace_back(surfacePoint.m_position.GetX(), surfacePoint.m_position.GetY(), 0.0f);
                 resultPoints.emplace_back(surfacePoint);
-
-                // For these unit tests, we expect every point queried to have valid terrain data.
-                EXPECT_TRUE(terrainExists);
+                resultExistsFlags.emplace_back(terrainExists);
             };
 
             AzFramework::Terrain::TerrainQueryRegion queryRegion =
@@ -233,7 +238,9 @@ namespace UnitTest::TerrainTest
         // Compare two sets of output data and verify that they match.
         void CompareSurfacePointData(
             const AZStd::vector<AzFramework::SurfaceData::SurfacePoint>& baselineValues,
-            const AZStd::vector<AzFramework::SurfaceData::SurfacePoint>& comparisonValues)
+            const AZStd::vector<bool>& baselineExistsFlags,
+            const AZStd::vector<AzFramework::SurfaceData::SurfacePoint>& comparisonValues,
+            const AZStd::vector<bool>& comparisonExistsFlags)
         {
             // Verify that we have the same quantity of results in both sets.
             ASSERT_EQ(baselineValues.size(), comparisonValues.size());
@@ -241,8 +248,10 @@ namespace UnitTest::TerrainTest
             // Verify that every value is found exactly once in each set. The two sets might not have the values in the same order though,
             // so we need to search for each value, verify it's found, and verify that it hadn't previously been found.
             AZStd::vector<bool> matchFound(baselineValues.size(), false);
-            for (auto& comparisonValue : comparisonValues)
+            for (size_t comparisonIndex = 0; comparisonIndex < comparisonValues.size(); comparisonIndex++)
             {
+                const auto& comparisonValue = comparisonValues[comparisonIndex];
+
                 auto foundValue = AZStd::find_if(
                     baselineValues.begin(), baselineValues.end(),
                     [&comparisonValue](const AzFramework::SurfaceData::SurfacePoint& baselineValue) -> bool
@@ -254,8 +263,14 @@ namespace UnitTest::TerrainTest
                 EXPECT_NE(foundValue, baselineValues.end());
                 if (foundValue != baselineValues.end())
                 {
-                    EXPECT_FALSE(matchFound[foundValue - baselineValues.begin()]);
-                    matchFound[foundValue - baselineValues.begin()] = true;
+                    size_t foundIndex = foundValue - baselineValues.begin();
+                    EXPECT_FALSE(matchFound[foundIndex]);
+                    EXPECT_EQ(baselineExistsFlags[foundIndex], comparisonExistsFlags[comparisonIndex]);
+                    matchFound[foundIndex] = true;
+                    if (baselineExistsFlags[foundIndex] != comparisonExistsFlags[comparisonIndex])
+                    {
+                        matchFound[foundIndex] = true;
+                    }
                 }
             }
         }
@@ -309,17 +324,19 @@ namespace UnitTest::TerrainTest
             // Gather all our initial results from calling Process*FromRegion
             AZStd::vector<AZ::Vector3> queryPositions;
             AZStd::vector<AZ::Vector3> baselineResultPositions;
-            GenerateBaselineHeightData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPositions);
+            AZStd::vector<bool> baselineExistsFlags;
+            GenerateBaselineHeightData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPositions, baselineExistsFlags);
             ASSERT_EQ(queryPositions.size(), ExpectedResultCount);
 
             // Gather results from Process*FromList
             AZStd::vector<AZ::Vector3> comparisonResultPositions;
+            AZStd::vector<bool> comparisonExistsFlags;
 
-            auto listPositionCallback =
-                [&comparisonResultPositions](const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
+            auto listPositionCallback = [&comparisonResultPositions, &comparisonExistsFlags]
+                (const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
             {
                 comparisonResultPositions.emplace_back(surfacePoint.m_position);
-                EXPECT_TRUE(terrainExists);
+                comparisonExistsFlags.emplace_back(terrainExists);
             };
 
             AzFramework::Terrain::TerrainDataRequestBus::Broadcast(
@@ -327,7 +344,7 @@ namespace UnitTest::TerrainTest
                 queryPositions, AzFramework::Terrain::TerrainDataRequests::TerrainDataMask::Heights, listPositionCallback, sampler);
 
             // Compare the results
-            ComparePositionData(baselineResultPositions, comparisonResultPositions);
+            ComparePositionData(baselineResultPositions, baselineExistsFlags, comparisonResultPositions, comparisonExistsFlags);
         }
 
         DestroyTestTerrainSystem();
@@ -344,11 +361,13 @@ namespace UnitTest::TerrainTest
             // Gather all our initial results from calling Process*FromRegion
             AZStd::vector<AZ::Vector3> queryPositions;
             AZStd::vector<AZ::Vector3> baselineResultPositions;
-            GenerateBaselineHeightData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPositions);
+            AZStd::vector<bool> baselineExistsFlags;
+            GenerateBaselineHeightData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPositions, baselineExistsFlags);
             ASSERT_EQ(queryPositions.size(), ExpectedResultCount);
 
             // Gather results from Get*
             AZStd::vector<AZ::Vector3> comparisonResultPositions;
+            AZStd::vector<bool> comparisonExistsFlags;
             float worldMinZ = TerrainWorldBounds.GetMin().GetZ();
             for (auto& position : queryPositions)
             {
@@ -358,11 +377,11 @@ namespace UnitTest::TerrainTest
                     terrainHeight, &AzFramework::Terrain::TerrainDataRequests::GetHeight, position, sampler, &terrainExists);
 
                 comparisonResultPositions.emplace_back(position.GetX(), position.GetY(), terrainHeight);
-                EXPECT_TRUE(terrainExists);
+                comparisonExistsFlags.emplace_back(terrainExists);
             }
 
             // Compare the results
-            ComparePositionData(baselineResultPositions, comparisonResultPositions);
+            ComparePositionData(baselineResultPositions, baselineExistsFlags, comparisonResultPositions, comparisonExistsFlags);
         }
 
         DestroyTestTerrainSystem();
@@ -379,21 +398,23 @@ namespace UnitTest::TerrainTest
             // Gather all our initial results from calling Process*FromRegion
             AZStd::vector<AZ::Vector3> queryPositions;
             AZStd::vector<AZ::Vector3> baselineResultPositions;
-            GenerateBaselineHeightData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPositions);
+            AZStd::vector<bool> baselineExistsFlags;
+            GenerateBaselineHeightData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPositions, baselineExistsFlags);
             ASSERT_EQ(queryPositions.size(), ExpectedResultCount);
 
             // Gather results from Process*FromRegionAsync
             AZStd::vector<AZ::Vector3> comparisonResultPositions;
+            AZStd::vector<bool> comparisonExistsFlags;
             AZStd::mutex outputMutex;
 
-            auto regionPositionCallback = [&comparisonResultPositions, &outputMutex](
+            auto regionPositionCallback = [&comparisonResultPositions, &comparisonExistsFlags, &outputMutex](
                 [[maybe_unused]] size_t xIndex, [[maybe_unused]] size_t yIndex,
                 const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
             {
                 // Make sure only one thread can add its result at a time.
                 AZStd::scoped_lock lock(outputMutex);
                 comparisonResultPositions.emplace_back(surfacePoint.m_position);
-                EXPECT_TRUE(terrainExists);
+                comparisonExistsFlags.emplace_back(terrainExists);
             };
 
             auto params = CreateTestAsyncParams();
@@ -408,7 +429,7 @@ namespace UnitTest::TerrainTest
             m_queryCompletionEvent.acquire();
 
             // Compare the results
-            ComparePositionData(baselineResultPositions, comparisonResultPositions);
+            ComparePositionData(baselineResultPositions, baselineExistsFlags, comparisonResultPositions, comparisonExistsFlags);
         }
 
         DestroyTestTerrainSystem();
@@ -425,20 +446,22 @@ namespace UnitTest::TerrainTest
             // Gather all our initial results from calling Process*FromRegion
             AZStd::vector<AZ::Vector3> queryPositions;
             AZStd::vector<AZ::Vector3> baselineResultPositions;
-            GenerateBaselineHeightData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPositions);
+            AZStd::vector<bool> baselineExistsFlags;
+            GenerateBaselineHeightData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPositions, baselineExistsFlags);
             ASSERT_EQ(queryPositions.size(), ExpectedResultCount);
 
             // Gather results from Process*FromListAsync
             AZStd::vector<AZ::Vector3> comparisonResultPositions;
+            AZStd::vector<bool> comparisonExistsFlags;
             AZStd::mutex outputMutex;
 
-            auto listPositionCallback = [&comparisonResultPositions, &outputMutex](
+            auto listPositionCallback = [&comparisonResultPositions, &comparisonExistsFlags, &outputMutex](
                 const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
             {
                 // Make sure only one thread can add its result at a time.
                 AZStd::scoped_lock lock(outputMutex);
                 comparisonResultPositions.emplace_back(surfacePoint.m_position);
-                EXPECT_TRUE(terrainExists);
+                comparisonExistsFlags.emplace_back(terrainExists);
             };
 
             auto params = CreateTestAsyncParams();
@@ -452,7 +475,7 @@ namespace UnitTest::TerrainTest
             m_queryCompletionEvent.acquire();
 
             // Compare the results
-            ComparePositionData(baselineResultPositions, comparisonResultPositions);
+            ComparePositionData(baselineResultPositions, baselineExistsFlags, comparisonResultPositions, comparisonExistsFlags);
         }
 
         DestroyTestTerrainSystem();
@@ -472,19 +495,21 @@ namespace UnitTest::TerrainTest
             // Gather all our initial results from calling Process*FromRegion
             AZStd::vector<AZ::Vector3> queryPositions;
             AZStd::vector<AZ::Vector3> baselineResultNormals;
-            GenerateBaselineNormalData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultNormals);
+            AZStd::vector<bool> baselineExistsFlags;
+            GenerateBaselineNormalData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultNormals, baselineExistsFlags);
             ASSERT_EQ(queryPositions.size(), ExpectedResultCount);
 
             // Gather results from Process*FromList
             AZStd::vector<AZ::Vector3> comparisonResultPositions;
             AZStd::vector<AZ::Vector3> comparisonResultNormals;
+            AZStd::vector<bool> comparisonExistsFlags;
 
-            auto listNormalCallback = [&comparisonResultPositions, &comparisonResultNormals](
+            auto listNormalCallback = [&comparisonResultPositions, &comparisonResultNormals, &comparisonExistsFlags](
                 const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
             {
                 comparisonResultPositions.emplace_back(surfacePoint.m_position);
                 comparisonResultNormals.emplace_back(surfacePoint.m_normal);
-                EXPECT_TRUE(terrainExists);
+                comparisonExistsFlags.emplace_back(terrainExists);
             };
 
             AzFramework::Terrain::TerrainDataRequestBus::Broadcast(
@@ -492,7 +517,8 @@ namespace UnitTest::TerrainTest
                 AzFramework::Terrain::TerrainDataRequests::TerrainDataMask::Normals, listNormalCallback, sampler);
 
             // Compare the results
-            CompareNormalData(queryPositions, baselineResultNormals, comparisonResultPositions, comparisonResultNormals);
+            CompareNormalData(queryPositions, baselineResultNormals, baselineExistsFlags,
+                comparisonResultPositions, comparisonResultNormals, comparisonExistsFlags);
         }
 
         DestroyTestTerrainSystem();
@@ -509,11 +535,13 @@ namespace UnitTest::TerrainTest
             // Gather all our initial results from calling Process*FromRegion
             AZStd::vector<AZ::Vector3> queryPositions;
             AZStd::vector<AZ::Vector3> baselineResultNormals;
-            GenerateBaselineNormalData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultNormals);
+            AZStd::vector<bool> baselineExistsFlags;
+            GenerateBaselineNormalData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultNormals, baselineExistsFlags);
             ASSERT_EQ(queryPositions.size(), ExpectedResultCount);
 
             // Gather results from Get*
             AZStd::vector<AZ::Vector3> comparisonResultNormals;
+            AZStd::vector<bool> comparisonExistsFlags;
             for (auto& position : queryPositions)
             {
                 AZ::Vector3 terrainNormal = AZ::Vector3::CreateZero();
@@ -522,11 +550,12 @@ namespace UnitTest::TerrainTest
                     terrainNormal, &AzFramework::Terrain::TerrainDataRequests::GetNormal, position, sampler, &terrainExists);
 
                 comparisonResultNormals.emplace_back(terrainNormal);
-                EXPECT_TRUE(terrainExists);
+                comparisonExistsFlags.emplace_back(terrainExists);
             }
 
             // Compare the results
-            CompareNormalData(queryPositions, baselineResultNormals, queryPositions, comparisonResultNormals);
+            CompareNormalData(queryPositions, baselineResultNormals, baselineExistsFlags,
+                queryPositions, comparisonResultNormals, comparisonExistsFlags);
         }
 
         DestroyTestTerrainSystem();
@@ -543,15 +572,17 @@ namespace UnitTest::TerrainTest
             // Gather all our initial results from calling Process*FromRegion
             AZStd::vector<AZ::Vector3> queryPositions;
             AZStd::vector<AZ::Vector3> baselineResultNormals;
-            GenerateBaselineNormalData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultNormals);
+            AZStd::vector<bool> baselineExistsFlags;
+            GenerateBaselineNormalData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultNormals, baselineExistsFlags);
             ASSERT_EQ(queryPositions.size(), ExpectedResultCount);
 
             // Gather results from Process*FromRegionAsync
             AZStd::vector<AZ::Vector3> comparisonResultPositions;
             AZStd::vector<AZ::Vector3> comparisonResultNormals;
+            AZStd::vector<bool> comparisonExistsFlags;
             AZStd::mutex outputMutex;
 
-            auto regionPositionCallback = [&comparisonResultPositions, &comparisonResultNormals, &outputMutex](
+            auto regionPositionCallback = [&comparisonResultPositions, &comparisonResultNormals, &comparisonExistsFlags, &outputMutex](
                 [[maybe_unused]] size_t xIndex, [[maybe_unused]] size_t yIndex,
                 const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
             {
@@ -559,7 +590,7 @@ namespace UnitTest::TerrainTest
                 AZStd::scoped_lock lock(outputMutex);
                 comparisonResultPositions.emplace_back(surfacePoint.m_position);
                 comparisonResultNormals.emplace_back(surfacePoint.m_normal);
-                EXPECT_TRUE(terrainExists);
+                comparisonExistsFlags.emplace_back(terrainExists);
             };
 
             auto params = CreateTestAsyncParams();
@@ -574,7 +605,8 @@ namespace UnitTest::TerrainTest
             m_queryCompletionEvent.acquire();
 
             // Compare the results
-            CompareNormalData(queryPositions, baselineResultNormals, comparisonResultPositions, comparisonResultNormals);
+            CompareNormalData(queryPositions, baselineResultNormals, baselineExistsFlags,
+                comparisonResultPositions, comparisonResultNormals, comparisonExistsFlags);
         }
 
         DestroyTestTerrainSystem();
@@ -591,22 +623,24 @@ namespace UnitTest::TerrainTest
             // Gather all our initial results from calling Process*FromRegion
             AZStd::vector<AZ::Vector3> queryPositions;
             AZStd::vector<AZ::Vector3> baselineResultNormals;
-            GenerateBaselineNormalData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultNormals);
+            AZStd::vector<bool> baselineExistsFlags;
+            GenerateBaselineNormalData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultNormals, baselineExistsFlags);
             ASSERT_EQ(queryPositions.size(), ExpectedResultCount);
 
             // Gather results from Process*FromListAsync
             AZStd::vector<AZ::Vector3> comparisonResultPositions;
             AZStd::vector<AZ::Vector3> comparisonResultNormals;
+            AZStd::vector<bool> comparisonExistsFlags;
             AZStd::mutex outputMutex;
 
-            auto listPositionCallback = [&comparisonResultPositions, &comparisonResultNormals, &outputMutex](
+            auto listPositionCallback = [&comparisonResultPositions, &comparisonResultNormals, &comparisonExistsFlags, &outputMutex](
                 const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
             {
                 // Make sure only one thread can add its result at a time.
                 AZStd::scoped_lock lock(outputMutex);
                 comparisonResultPositions.emplace_back(surfacePoint.m_position);
                 comparisonResultNormals.emplace_back(surfacePoint.m_normal);
-                EXPECT_TRUE(terrainExists);
+                comparisonExistsFlags.emplace_back(terrainExists);
             };
 
             auto params = CreateTestAsyncParams();
@@ -620,7 +654,8 @@ namespace UnitTest::TerrainTest
             m_queryCompletionEvent.acquire();
 
             // Compare the results
-            CompareNormalData(queryPositions, baselineResultNormals, comparisonResultPositions, comparisonResultNormals);
+            CompareNormalData(queryPositions, baselineResultNormals, baselineExistsFlags,
+                comparisonResultPositions, comparisonResultNormals, comparisonExistsFlags);
         }
 
         DestroyTestTerrainSystem();
@@ -807,17 +842,20 @@ namespace UnitTest::TerrainTest
             // Gather all our initial results from calling Process*FromRegion
             AZStd::vector<AZ::Vector3> queryPositions;
             AZStd::vector<AzFramework::SurfaceData::SurfacePoint> baselineResultPoints;
-            GenerateBaselineSurfacePointData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPoints);
+            AZStd::vector<bool> baselineExistsFlags;
+            GenerateBaselineSurfacePointData(
+                QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPoints, baselineExistsFlags);
             ASSERT_EQ(queryPositions.size(), ExpectedResultCount);
 
             // Gather results from Process*FromList
             AZStd::vector<AzFramework::SurfaceData::SurfacePoint> comparisonResultPoints;
+            AZStd::vector<bool> comparisonExistsFlags;
 
-            auto listPositionCallback = [&comparisonResultPoints](
+            auto listPositionCallback = [&comparisonResultPoints, &comparisonExistsFlags](
                 const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
             {
                 comparisonResultPoints.emplace_back(surfacePoint);
-                EXPECT_TRUE(terrainExists);
+                comparisonExistsFlags.emplace_back(terrainExists);
             };
 
             AzFramework::Terrain::TerrainDataRequestBus::Broadcast(
@@ -825,7 +863,7 @@ namespace UnitTest::TerrainTest
                 AzFramework::Terrain::TerrainDataRequests::TerrainDataMask::All, listPositionCallback, sampler);
 
             // Compare the results
-            CompareSurfacePointData(baselineResultPoints, comparisonResultPoints);
+            CompareSurfacePointData(baselineResultPoints, baselineExistsFlags, comparisonResultPoints, comparisonExistsFlags);
         }
 
         DestroyTestTerrainSystem();
@@ -842,11 +880,14 @@ namespace UnitTest::TerrainTest
             // Gather all our initial results from calling Process*FromRegion
             AZStd::vector<AZ::Vector3> queryPositions;
             AZStd::vector<AzFramework::SurfaceData::SurfacePoint> baselineResultPoints;
-            GenerateBaselineSurfacePointData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPoints);
+            AZStd::vector<bool> baselineExistsFlags;
+            GenerateBaselineSurfacePointData(
+                QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPoints, baselineExistsFlags);
             ASSERT_EQ(queryPositions.size(), ExpectedResultCount);
 
             // Gather results from Get*
             AZStd::vector<AzFramework::SurfaceData::SurfacePoint> comparisonResultPoints;
+            AZStd::vector<bool> comparisonExistsFlags;
             AzFramework::SurfaceData::SurfacePoint surfacePoint;
             for (auto& position : queryPositions)
             {
@@ -855,11 +896,11 @@ namespace UnitTest::TerrainTest
                     &AzFramework::Terrain::TerrainDataRequests::GetSurfacePoint, position, surfacePoint, sampler, &terrainExists);
 
                 comparisonResultPoints.emplace_back(surfacePoint);
-                EXPECT_TRUE(terrainExists);
+                comparisonExistsFlags.emplace_back(terrainExists);
             }
 
             // Compare the results
-            CompareSurfacePointData(baselineResultPoints, comparisonResultPoints);
+            CompareSurfacePointData(baselineResultPoints, baselineExistsFlags, comparisonResultPoints, comparisonExistsFlags);
         }
 
         DestroyTestTerrainSystem();
@@ -876,21 +917,24 @@ namespace UnitTest::TerrainTest
             // Gather all our initial results from calling Process*FromRegion
             AZStd::vector<AZ::Vector3> queryPositions;
             AZStd::vector<AzFramework::SurfaceData::SurfacePoint> baselineResultPoints;
-            GenerateBaselineSurfacePointData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPoints);
+            AZStd::vector<bool> baselineExistsFlags;
+            GenerateBaselineSurfacePointData(
+                QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPoints, baselineExistsFlags);
             ASSERT_EQ(queryPositions.size(), ExpectedResultCount);
 
             // Gather results from Process*FromRegionAsync
             AZStd::vector<AzFramework::SurfaceData::SurfacePoint> comparisonResultPoints;
+            AZStd::vector<bool> comparisonExistsFlags;
             AZStd::mutex outputMutex;
 
-            auto regionPositionCallback = [&comparisonResultPoints, &outputMutex](
+            auto regionPositionCallback = [&comparisonResultPoints, &comparisonExistsFlags, &outputMutex](
                 [[maybe_unused]] size_t xIndex, [[maybe_unused]] size_t yIndex,
                 const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
             {
                 // Make sure only one thread can add its result at a time.
                 AZStd::scoped_lock lock(outputMutex);
                 comparisonResultPoints.emplace_back(surfacePoint);
-                EXPECT_TRUE(terrainExists);
+                comparisonExistsFlags.emplace_back(terrainExists);
             };
 
             auto params = CreateTestAsyncParams();
@@ -905,7 +949,7 @@ namespace UnitTest::TerrainTest
             m_queryCompletionEvent.acquire();
 
             // Compare the results
-            CompareSurfacePointData(baselineResultPoints, comparisonResultPoints);
+            CompareSurfacePointData(baselineResultPoints, baselineExistsFlags, comparisonResultPoints, comparisonExistsFlags);
         }
 
         DestroyTestTerrainSystem();
@@ -922,20 +966,23 @@ namespace UnitTest::TerrainTest
             // Gather all our initial results from calling Process*FromRegion
             AZStd::vector<AZ::Vector3> queryPositions;
             AZStd::vector<AzFramework::SurfaceData::SurfacePoint> baselineResultPoints;
-            GenerateBaselineSurfacePointData(QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPoints);
+            AZStd::vector<bool> baselineExistsFlags;
+            GenerateBaselineSurfacePointData(
+                QueryBounds, QueryStepSize, sampler, queryPositions, baselineResultPoints, baselineExistsFlags);
             ASSERT_EQ(queryPositions.size(), ExpectedResultCount);
 
             // Gather results from Process*FromListAsync
             AZStd::vector<AzFramework::SurfaceData::SurfacePoint> comparisonResultPoints;
+            AZStd::vector<bool> comparisonExistsFlags;
             AZStd::mutex outputMutex;
 
-            auto listPositionCallback = [&comparisonResultPoints, &outputMutex](
+            auto listPositionCallback = [&comparisonResultPoints, &comparisonExistsFlags, &outputMutex](
                 const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists)
             {
                 // Make sure only one thread can add its result at a time.
                 AZStd::scoped_lock lock(outputMutex);
                 comparisonResultPoints.emplace_back(surfacePoint);
-                EXPECT_TRUE(terrainExists);
+                comparisonExistsFlags.emplace_back(terrainExists);
             };
 
             auto params = CreateTestAsyncParams();
@@ -948,7 +995,7 @@ namespace UnitTest::TerrainTest
             m_queryCompletionEvent.acquire();
 
             // Compare the results
-            CompareSurfacePointData(baselineResultPoints, comparisonResultPoints);
+            CompareSurfacePointData(baselineResultPoints, baselineExistsFlags, comparisonResultPoints, comparisonExistsFlags);
         }
 
         DestroyTestTerrainSystem();

+ 309 - 0
Gems/Terrain/Code/Tests/TerrainSystemSettingsTests.cpp

@@ -0,0 +1,309 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#include <AzCore/Component/ComponentApplication.h>
+#include <AzCore/Jobs/JobManagerComponent.h>
+#include <AzCore/Memory/MemoryComponent.h>
+#include <AzCore/std/parallel/semaphore.h>
+
+#include <AzTest/AzTest.h>
+#include <AZTestShared/Math/MathTestHelpers.h>
+
+#include <TerrainSystem/TerrainSystem.h>
+#include <Components/TerrainLayerSpawnerComponent.h>
+
+#include <GradientSignal/Ebuses/MockGradientRequestBus.h>
+#include <Tests/Mocks/Terrain/MockTerrainDataRequestBus.h>
+#include <Terrain/MockTerrainAreaSurfaceRequestBus.h>
+#include <Terrain/MockTerrain.h>
+#include <MockAxisAlignedBoxShapeComponent.h>
+#include <TerrainTestFixtures.h>
+
+namespace UnitTest
+{
+    using ::testing::NiceMock;
+    using ::testing::Return;
+
+    class TerrainSystemSettingsTests
+        : public TerrainBaseFixture
+        , public ::testing::Test
+    {
+    protected:
+        // Defines a structure for defining both an XY position and the expected height for that position.
+        struct HeightTestPoint
+        {
+            AZ::Vector2 m_testLocation = AZ::Vector2::CreateZero();
+            float m_expectedHeight = 0.0f;
+        };
+
+        AZStd::unique_ptr<NiceMock<UnitTest::MockBoxShapeComponentRequests>> m_boxShapeRequests;
+        AZStd::unique_ptr<NiceMock<UnitTest::MockShapeComponentRequests>> m_shapeRequests;
+        AZStd::unique_ptr<NiceMock<UnitTest::MockTerrainAreaHeightRequests>> m_terrainAreaHeightRequests;
+        AZStd::unique_ptr<NiceMock<UnitTest::MockTerrainAreaSurfaceRequestBus>> m_terrainAreaSurfaceRequests;
+
+        void SetUp() override
+        {
+            SetupCoreSystems();
+        }
+
+        void TearDown() override
+        {
+            m_boxShapeRequests.reset();
+            m_shapeRequests.reset();
+            m_terrainAreaHeightRequests.reset();
+            m_terrainAreaSurfaceRequests.reset();
+
+            TearDownCoreSystems();
+        }
+
+        AZStd::unique_ptr<AZ::Entity> CreateAndActivateMockTerrainLayerSpawnerThatReturnsXSquaredAsHeight(const AZ::Aabb& spawnerBox)
+        {
+            // Create the base entity with a mock box shape, Terrain Layer Spawner, and height provider.
+            // Turn off the "use ground plane" setting so that we mark terrain as false anywhere that the spawner doesn't exist.
+            Terrain::TerrainLayerSpawnerConfig config;
+            config.m_useGroundPlane = false;
+
+            auto entity = CreateEntity();
+            entity->CreateComponent<UnitTest::MockAxisAlignedBoxShapeComponent>();
+            entity->CreateComponent<Terrain::TerrainLayerSpawnerComponent>(config);
+
+            m_boxShapeRequests = AZStd::make_unique<NiceMock<UnitTest::MockBoxShapeComponentRequests>>(entity->GetId());
+            m_shapeRequests = AZStd::make_unique<NiceMock<UnitTest::MockShapeComponentRequests>>(entity->GetId());
+
+            // Set up the box shape to return whatever spawnerBox was passed in.
+            ON_CALL(*m_shapeRequests, GetEncompassingAabb).WillByDefault(Return(spawnerBox));
+
+            auto mockHeights = [](const AZ::Vector3& inPosition, AZ::Vector3& outPosition, bool& terrainExists)
+            {
+                // Return a height (Z) that's equal to X^2.
+                outPosition = AZ::Vector3(inPosition.GetX(), inPosition.GetY(), inPosition.GetX() * inPosition.GetX());
+                terrainExists = true;
+            };
+
+            // Set up a mock height provider that returns the X position as the height.
+            m_terrainAreaHeightRequests = AZStd::make_unique<NiceMock<UnitTest::MockTerrainAreaHeightRequests>>(entity->GetId());
+            ON_CALL(*m_terrainAreaHeightRequests, GetHeight).WillByDefault(mockHeights);
+            ON_CALL(*m_terrainAreaHeightRequests, GetHeights)
+                .WillByDefault(
+                    [mockHeights](AZStd::span<AZ::Vector3> inOutPositionList, AZStd::span<bool> terrainExistsList)
+                    {
+                        for (int i = 0; i < inOutPositionList.size(); i++)
+                        {
+                            mockHeights(inOutPositionList[i], inOutPositionList[i], terrainExistsList[i]);
+                        }
+                    });
+
+            ActivateEntity(entity.get());
+            return entity;
+        }
+
+        void SetupSurfaceWeightMocks(AZ::Entity* entity)
+        {
+            auto mockGetSurfaceWeights = [](
+                const AZ::Vector3& position,
+                AzFramework::SurfaceData::SurfaceTagWeightList& surfaceWeights)
+                {
+                    const SurfaceData::SurfaceTag tag1 = SurfaceData::SurfaceTag("tag1");
+
+                    AzFramework::SurfaceData::SurfaceTagWeight tagWeight1;
+                    tagWeight1.m_surfaceType = tag1;
+                    tagWeight1.m_weight = position.GetX() / 100.0f;
+
+                    surfaceWeights.clear();
+                    surfaceWeights.push_back(tagWeight1);
+                };
+
+            m_terrainAreaSurfaceRequests = AZStd::make_unique<NiceMock<UnitTest::MockTerrainAreaSurfaceRequestBus>>(entity->GetId());
+            ON_CALL(*m_terrainAreaSurfaceRequests, GetSurfaceWeights).WillByDefault(mockGetSurfaceWeights);
+            ON_CALL(*m_terrainAreaSurfaceRequests, GetSurfaceWeightsFromList).WillByDefault(
+                [mockGetSurfaceWeights](
+                    AZStd::span<const AZ::Vector3> inPositionList,
+                    AZStd::span<AzFramework::SurfaceData::SurfaceTagWeightList> outSurfaceWeightsList)
+                {
+                    for (size_t i = 0; i < inPositionList.size(); i++)
+                    {
+                        mockGetSurfaceWeights(inPositionList[i], outSurfaceWeightsList[i]);
+                    }
+                }
+            );
+        }
+    };
+
+    TEST_F(TerrainSystemSettingsTests, TerrainWorldMinMaxClampsHeightData)
+    {
+        // Verify that any height data returned from a terrain layer spawner is clamped to the world min/max settings.
+
+        // Create a mock terrain layer spawner that uses a box of (0,0,0) - (20,20,20) and generates a height equal to the X value squared.
+        // The world min/max will be set to 5 and 15, so we'll verify the heights are always between 5 and 15.
+
+        const AZ::Aabb spawnerBox = AZ::Aabb::CreateFromMinMaxValues(0.0f, 0.0f, 0.0f, 20.0f, 20.0f, 20.0f);
+        auto entity = CreateAndActivateMockTerrainLayerSpawnerThatReturnsXSquaredAsHeight(spawnerBox);
+
+        // Create and activate the terrain system with world height min/max of 5 and 15.
+        const float queryResolution = 1.0f;
+        const AZ::Aabb terrainWorldBounds = AZ::Aabb::CreateFromMinMaxValues(0.0f, 0.0f, 5.0f, 20.0f, 20.0f, 15.0f);
+        auto terrainSystem = CreateAndActivateTerrainSystem(queryResolution, terrainWorldBounds);
+
+        // Test a set of points from (0,0) - (20,20). If the world min/max clamp is working, we should always get 5 <= height <= 15.
+        for (float x = 0.0f; x <= 20.0f; x += queryResolution)
+        {
+            AZ::Vector3 position(x, x, 0.0f);
+            bool heightQueryTerrainExists = false;
+            float height =
+                terrainSystem->GetHeight(position, AzFramework::Terrain::TerrainDataRequests::Sampler::DEFAULT, &heightQueryTerrainExists);
+
+            // Verify all the heights are between 5 and 15.
+            EXPECT_GE(height, terrainWorldBounds.GetMin().GetZ());
+            EXPECT_LE(height, terrainWorldBounds.GetMax().GetZ());
+        }
+    }
+
+    TEST_F(TerrainSystemSettingsTests, TerrainHeightQueryResolutionAffectsHeightQueries)
+    {
+        // Verify that the terrain height query resolution setting affects height queries. We'll verify this by
+        // setting the height query resolution to 10 and querying a set of positions from 0 - 20 that return
+        // the X^2 value as the height.
+        // If the height query resolution is working, when we use the CLAMP sampler, queries for X=0-9 should return 0, and
+        // queries for X=10-19 should return 100.
+        // When we use the EXACT sampler, the query resolution should be ignored and we should get back X^2.
+        // When we use the BILINEAR sampler, queries for X=0-9 should return values from 0^2-10^2, and X=10-19 should return
+        // values from 10^2-20^2. 
+
+        // Create a mock terrain layer spawner that uses a box of (0,0,0) - (30,30,1000) and generates
+        // a height equal to the X value squared. (We set the max height high enough to allow for the X^2 values without clamping)
+        const AZ::Aabb spawnerBox = AZ::Aabb::CreateFromMinMaxValues(0.0f, 0.0f, 0.0f, 30.0f, 30.0f, 1000.0f);
+        auto entity = CreateAndActivateMockTerrainLayerSpawnerThatReturnsXSquaredAsHeight(spawnerBox);
+
+        // Create and activate the terrain system with a world bounds that matches the spawner box, and a query resolution of 10.
+        const float queryResolution = 10.0f;
+        auto terrainSystem = CreateAndActivateTerrainSystem(queryResolution, spawnerBox);
+
+        for (auto sampler : { AzFramework::Terrain::TerrainDataRequests::Sampler::BILINEAR,
+                              AzFramework::Terrain::TerrainDataRequests::Sampler::CLAMP,
+                              AzFramework::Terrain::TerrainDataRequests::Sampler::EXACT })
+        {
+            // Test a set of points from (0,0) - (20,20). We stop at 20 so that we don't test interpolation with points that don't
+            // exist on the max boundary edge of 30.
+            for (float x = 0.0f; x < 20.0f; x += 1.0f)
+            {
+                AZ::Vector3 position(x, x, 0.0f);
+                bool terrainExists = false;
+                float height =
+                    terrainSystem->GetHeight(position, sampler, &terrainExists);
+
+                switch (sampler)
+                {
+                case AzFramework::Terrain::TerrainDataRequests::Sampler::BILINEAR:
+                    if (x < 10.0f)
+                    {
+                        // Values from 0-10 should linearly interpolate from 0^2 to 10^2
+                        EXPECT_NEAR(height, AZStd::lerp(0.0f, 100.0f, x / queryResolution), 0.001f);
+                    }
+                    else
+                    {
+                        // Values from 10-19 should linearly interpolate from 10^2 to 20^2
+                        EXPECT_NEAR(height, AZStd::lerp(100.0f, 400.0f, (x - queryResolution) / queryResolution), 0.001f);
+                    }
+                    EXPECT_TRUE(terrainExists);
+                    break;
+                case AzFramework::Terrain::TerrainDataRequests::Sampler::CLAMP:
+                    // X values from 0-4 should round to X=0 and return 0, X values from 5-14 should round to X=10 and return 10^2,
+                    // and X values from 15-19 should round up to X=20 and return 20^2.
+                    if (x < 5.0f)
+                    {
+                        EXPECT_EQ(height, 0.0f);
+                        EXPECT_TRUE(terrainExists);
+                    }
+                    else if (x < 15.0f)
+                    {
+                        EXPECT_EQ(height, 100.0f);
+                        EXPECT_TRUE(terrainExists);
+                    }
+                    else
+                    {
+                        EXPECT_EQ(height, 400.0f);
+                    }
+                    break;
+                case AzFramework::Terrain::TerrainDataRequests::Sampler::EXACT:
+                    // All query points should return X^2
+                    EXPECT_EQ(height, x*x);
+                    EXPECT_TRUE(terrainExists);
+                    break;
+                }
+            }
+        }
+    }
+
+    TEST_F(TerrainSystemSettingsTests, TerrainSurfaceQueryResolutionAffectsSurfaceQueries)
+    {
+        // Verify that the terrain surface query resolution setting affects surface queries. We'll verify this by
+        // setting the surface query resolution to 10 and querying a set of positions from 0 - 20 that return
+        // the X value / 100 as the surface weight.
+        // If the surface query resolution is working, when we use the CLAMP sampler, queries for X=0-9 should return (0/100), and
+        // queries for X=10-19 should return (10/100).
+        // When we use the EXACT sampler, the query resolution should be ignored and we should get back (X/100).
+        // When we use the BILINEAR sampler, we should get back the same results as the CLAMP sampler, because currently the two
+        // are interpreted the same way for surface queries.
+
+        // Create a mock terrain layer spawner that uses a box of (0,0,0) - (30,30,30).
+        const AZ::Aabb spawnerBox = AZ::Aabb::CreateFromMinMaxValues(0.0f, 0.0f, 0.0f, 30.0f, 30.0f, 30.0f);
+        auto entity = CreateAndActivateMockTerrainLayerSpawnerThatReturnsXSquaredAsHeight(spawnerBox);
+        // Set up the surface weight mocks that will return X/100 as the surface weight.
+        SetupSurfaceWeightMocks(entity.get());
+
+        // Create and activate the terrain system with a world bounds that matches the spawner box, and a query resolution of 10.
+        const float heightQueryResolution = 1.0f;
+        const float surfaceQueryResolution = 10.0f;
+        auto terrainSystem = CreateAndActivateTerrainSystem(heightQueryResolution, surfaceQueryResolution, spawnerBox);
+
+        for (auto sampler : { AzFramework::Terrain::TerrainDataRequests::Sampler::BILINEAR,
+                              AzFramework::Terrain::TerrainDataRequests::Sampler::CLAMP,
+                              AzFramework::Terrain::TerrainDataRequests::Sampler::EXACT })
+        {
+            // Test a set of points from (0,0) - (20,20). We stop at 20 instead of 30 so that we aren't testing what happens when
+            // a query point doesn't exist.
+            for (float x = 0.0f; x < 20.0f; x += 1.0f)
+            {
+                AZ::Vector3 position(x, x, 0.0f);
+                bool terrainExists = false;
+                AzFramework::SurfaceData::SurfaceTagWeight weight =
+                    terrainSystem->GetMaxSurfaceWeight(position, sampler, &terrainExists);
+
+                switch (sampler)
+                {
+                    case AzFramework::Terrain::TerrainDataRequests::Sampler::BILINEAR:
+                        [[fallthrough]];
+                    case AzFramework::Terrain::TerrainDataRequests::Sampler::CLAMP:
+                        // For both BILINEAR and CLAMP:
+                        // X values from 0-4 should round to X=0 and return (0/100), X values from 5-14 should round to X=10
+                        // and return (10/100), and X values from 15-19 should round up to X=20 and return (20/100).
+                        // and X values from 15-19 should round up to X=20 and return 20^2.
+                        if (x < 5.0f)
+                        {
+                            EXPECT_NEAR(weight.m_weight, 0.0f, 0.001f);
+                        }
+                        else if (x < 15.0f)
+                        {
+                            EXPECT_NEAR(weight.m_weight, 0.1f, 0.001f);
+                        }
+                        else
+                        {
+                            EXPECT_NEAR(weight.m_weight, 0.2f, 0.001f);
+                        }
+                        break;
+                        break;
+                    case AzFramework::Terrain::TerrainDataRequests::Sampler::EXACT:
+                        // For EXACT, queries should just return x/100 and ignore the query resolution.
+                        EXPECT_NEAR(weight.m_weight, x / 100.0f, 0.001f);
+                        break;
+                }
+            }
+        }
+    }
+
+} // namespace UnitTest

+ 23 - 13
Gems/Terrain/Code/Tests/TerrainSystemTest.cpp

@@ -22,6 +22,7 @@
 #include <Terrain/MockTerrain.h>
 #include <MockAxisAlignedBoxShapeComponent.h>
 #include <TerrainTestFixtures.h>
+#include <SurfaceData/Utility/SurfaceDataUtility.h>
 
 using ::testing::AtLeast;
 using ::testing::FloatNear;
@@ -268,7 +269,7 @@ namespace UnitTest
     TEST_F(TerrainSystemTest, TerrainExistsOnlyWithinTerrainLayerSpawnerBounds)
     {
         // Verify that the presence of a TerrainLayerSpawner causes terrain to exist in (and *only* in) the box where the
-        // TerrainLayerSpawner is defined.
+        // TerrainLayerSpawner is defined. The box is min-inclusive-max-exclusive, so points should *not* exist on the max edge of the box.
 
         // The terrain system should only query Heights from the TerrainAreaHeightRequest bus within the
         // TerrainLayerSpawner region, and so those values should only get returned from GetHeight for queries inside that region.
@@ -305,7 +306,10 @@ namespace UnitTest
                 bool isHole = terrainSystem->GetIsHoleFromFloats(
                     position.GetX(), position.GetY(), AzFramework::Terrain::TerrainDataRequests::Sampler::EXACT);
 
-                if (spawnerBox.Contains(AZ::Vector3(position.GetX(), position.GetY(), spawnerBox.GetMin().GetZ())))
+                // Verify that the point either should or shouldn't appear on the box, taking min-inclusive-max-exclusive box ranges
+                // into account.
+                if (SurfaceData::AabbContains2DMaxExclusive(spawnerBox,
+                    AZ::Vector3(position.GetX(), position.GetY(), spawnerBox.GetMin().GetZ())))
                 {
                     EXPECT_TRUE(heightQueryTerrainExists);
                     EXPECT_FALSE(isHole);
@@ -408,10 +412,10 @@ namespace UnitTest
             { AZ::Vector2(0.3f, 0.3f), 0.5f }, // Should return a height of 0.25 + 0.25
             { AZ::Vector2(2.8f, 2.8f), 5.5f }, // Should return a height of 2.75 + 2.75
             { AZ::Vector2(5.5f, 5.5f), 11.0f }, // Should return a height of 5.50 + 5.50
-            { AZ::Vector2(7.7f, 7.7f), 15.0f }, // Should return a height of 7.50 + 7.50
+            { AZ::Vector2(7.7f, 7.7f), 15.5f }, // Should return a height of 7.75 + 7.75
 
-            { AZ::Vector2(-0.3f, -0.3f), -1.0f }, // Should return a height of -0.50 + -0.50
-            { AZ::Vector2(-2.8f, -2.8f), -6.0f }, // Should return a height of -3.00 + -3.00
+            { AZ::Vector2(-0.3f, -0.3f), -0.5f }, // Should return a height of -0.25 + -0.25
+            { AZ::Vector2(-2.8f, -2.8f), -5.5f }, // Should return a height of -2.75 + -2.75
             { AZ::Vector2(-5.5f, -5.5f), -11.0f }, // Should return a height of -5.50 + -5.50
             { AZ::Vector2(-7.7f, -7.7f), -15.5f } // Should return a height of -7.75 + -7.75
         };
@@ -503,10 +507,10 @@ namespace UnitTest
             // should *still* be X + Y assuming the points were sampled correctly from the grid points.
 
             { AZ::Vector2(3.25f, 5.25f), 8.5f }, // Should return a height of 3.25 + 5.25
-            { AZ::Vector2(7.71f, 9.74f), 17.45f }, // Should return a height of 7.71 + 9.74
+            { AZ::Vector2(7.71f, 8.74f), 16.45f }, // Should return a height of 7.71 + 9.74
 
             { AZ::Vector2(-3.25f, -5.25f), -8.5f }, // Should return a height of -3.25 + -5.25
-            { AZ::Vector2(-7.71f, -9.74f), -17.45f }, // Should return a height of -7.71 + -9.74
+            { AZ::Vector2(-7.71f, -8.74f), -16.45f }, // Should return a height of -7.71 + -9.74
         };
 
         // Loop through every test point and validate it.
@@ -522,6 +526,7 @@ namespace UnitTest
             // Verify that our height query returned the bilinear filtered result we expect.
             constexpr float epsilon = 0.0001f;
             EXPECT_NEAR(height, expectedHeight, epsilon);
+            EXPECT_TRUE(heightQueryTerrainExists);
         }
     }
 
@@ -531,7 +536,7 @@ namespace UnitTest
 
         auto terrainSystem = CreateAndActivateTerrainSystem();
 
-        const AZ::Aabb aabb = AZ::Aabb::CreateFromMinMax(AZ::Vector3::CreateZero(), AZ::Vector3::CreateOne());
+        const AZ::Aabb aabb = AZ::Aabb::CreateFromMinMax(AZ::Vector3(0.0f), AZ::Vector3(2.0f));
         auto entity = CreateAndActivateMockTerrainLayerSpawner(
             aabb,
             [](AZ::Vector3& position, bool& terrainExists)
@@ -583,7 +588,7 @@ namespace UnitTest
     {
         auto terrainSystem = CreateAndActivateTerrainSystem();
 
-        const AZ::Aabb aabb = AZ::Aabb::CreateFromMinMax(AZ::Vector3::CreateZero(), AZ::Vector3::CreateOne());
+        const AZ::Aabb aabb = AZ::Aabb::CreateFromMinMax(AZ::Vector3(0.0f), AZ::Vector3(2.0f));
         auto entity = CreateAndActivateMockTerrainLayerSpawner(
             aabb,
             [](AZ::Vector3& position, bool& terrainExists)
@@ -690,7 +695,9 @@ namespace UnitTest
             // should *still* be X + Y assuming the points were sampled correctly from the grid points.
 
             { AZ::Vector2(3.25f, 5.25f), 8.5f }, // Should return a height of 3.25 + 5.25
-            { AZ::Vector2(7.71f, 9.74f), 17.45f }, // Should return a height of 7.71 + 9.74
+            { AZ::Vector2(7.71f, 8.74f), 16.45f }, // Should return a height of 7.71 + 8.74
+            // We don't test any points > 9.0f because our AABB is max-exclusive, and would query grid points that don't exist for use as
+            // a part of the interpolation. We'll test those cases separately, as they're more complex.
 
             { AZ::Vector2(-3.25f, -5.25f), -8.5f }, // Should return a height of -3.25 + -5.25
             { AZ::Vector2(-7.71f, -9.74f), -17.45f }, // Should return a height of -7.71 + -9.74
@@ -748,6 +755,8 @@ namespace UnitTest
         // Create and activate the terrain system with our testing defaults for world bounds, and a query resolution at 1 meter intervals.
         auto terrainSystem = CreateAndActivateTerrainSystem(frequencyMeters);
 
+        // Note that we keep our test points in the range -9.5 to +8.5. Any value outside that range would use points that don't exist
+        // in the calculation of the normals, which is more complex and can get tested separately.
         const NormalTestPoint testPoints[] = {
 
             { AZ::Vector2(0.0f, 0.0f), AZ::Vector3(-0.5773f, -0.5773f, 0.5773f) },
@@ -778,13 +787,13 @@ namespace UnitTest
             { AZ::Vector2(-2.25f, -4.0f), AZ::Vector3(-0.5773f, -0.5773f, 0.5773f) },
 
             { AZ::Vector2(3.25f, 5.25f), AZ::Vector3(-0.5773f, -0.5773f, 0.5773f) },
-            { AZ::Vector2(7.71f, 9.74f), AZ::Vector3(-0.0292f, 0.9991f, 0.0292f) },
+            { AZ::Vector2(7.71f, 7.74f), AZ::Vector3(-0.5773f, -0.5773f, 0.5773f) },
 
             { AZ::Vector2(-3.25f, -5.25f), AZ::Vector3(-0.5773f, -0.5773f, 0.5773f) },
-            { AZ::Vector2(-7.71f, -9.74f), AZ::Vector3(-0.0366f, -0.9986f, 0.0366f) },
+            { AZ::Vector2(-7.71f, -7.74f), AZ::Vector3(-0.5773f, -0.5773f, 0.5773f) },
         };
 
-        auto perPositionCallback = [&testPoints](const AzFramework::SurfaceData::SurfacePoint& surfacePoint, [[maybe_unused]] bool terrainExists){
+        auto perPositionCallback = [&testPoints](const AzFramework::SurfaceData::SurfacePoint& surfacePoint, bool terrainExists){
             bool found = false;
             for (auto& testPoint : testPoints)
             {
@@ -794,6 +803,7 @@ namespace UnitTest
                     EXPECT_NEAR(surfacePoint.m_normal.GetX(), testPoint.m_expectedNormal.GetX(), epsilon);
                     EXPECT_NEAR(surfacePoint.m_normal.GetY(), testPoint.m_expectedNormal.GetY(), epsilon);
                     EXPECT_NEAR(surfacePoint.m_normal.GetZ(), testPoint.m_expectedNormal.GetZ(), epsilon);
+                    EXPECT_TRUE(terrainExists);
                     found = true;
                     break;
                 }

+ 52 - 1
Gems/Terrain/Code/Tests/TerrainTestFixtures.cpp

@@ -193,11 +193,21 @@ namespace UnitTest
     // on a test-by-test basis.
     AZStd::unique_ptr<Terrain::TerrainSystem> TerrainBaseFixture::CreateAndActivateTerrainSystem(
         float queryResolution, AZ::Aabb worldBounds) const
+    {
+        const float defaultSurfaceQueryResolution = 1.0f;
+        return CreateAndActivateTerrainSystem(queryResolution, defaultSurfaceQueryResolution, worldBounds);
+    }
+
+    // Create a terrain system with reasonable defaults for testing, but with the ability to override the defaults
+    // on a test-by-test basis.
+    AZStd::unique_ptr<Terrain::TerrainSystem> TerrainBaseFixture::CreateAndActivateTerrainSystem(
+        float heightQueryResolution, float surfaceQueryResolution, AZ::Aabb worldBounds) const
     {
         // Create the terrain system and give it one tick to fully initialize itself.
         auto terrainSystem = AZStd::make_unique<Terrain::TerrainSystem>();
         terrainSystem->SetTerrainAabb(worldBounds);
-        terrainSystem->SetTerrainHeightQueryResolution(queryResolution);
+        terrainSystem->SetTerrainHeightQueryResolution(heightQueryResolution);
+        terrainSystem->SetTerrainSurfaceDataQueryResolution(surfaceQueryResolution);
         terrainSystem->Activate();
         AZ::TickBus::Broadcast(&AZ::TickBus::Events::OnTick, 0.f, AZ::ScriptTimePoint{});
         return terrainSystem;
@@ -381,5 +391,46 @@ namespace UnitTest
         m_terrainSystem = CreateAndActivateTerrainSystem(queryResolution, worldBounds);
     }
 
+    TerrainSystemTestFixture::TerrainSystemTestFixture()
+        : m_restoreFileIO(m_fileIOMock)
+    {
+        // Install Mock File IO, since the ShaderMetricsSystem inside of Atom's RPISystem will try to read/write a file.
+        AZ::IO::MockFileIOBase::InstallDefaultReturns(m_fileIOMock);
+    }
+
+    void TerrainSystemTestFixture::SetUp()
+    {
+        UnitTest::TerrainTestFixture::SetUp();
+
+        // Create a system entity with a SceneSystemComponent for Atom and a TerrainSystemComponent for the TerrainWorldComponent.
+        // However, we don't initialize and activate it until *after* the RPI system is initialized, since the TerrainSystemComponent
+        // relies on the RPI.
+        m_systemEntity = CreateEntity();
+        m_systemEntity->CreateComponent<AzFramework::SceneSystemComponent>();
+        m_systemEntity->CreateComponent<Terrain::TerrainSystemComponent>();
+
+        // Create a stub RHI for use by Atom
+        m_rhiFactory.reset(aznew UnitTest::StubRHI::Factory());
+
+        // Create the Atom RPISystem
+        AZ::RPI::RPISystemDescriptor rpiSystemDescriptor;
+        m_rpiSystem = AZStd::make_unique<AZ::RPI::RPISystem>();
+        m_rpiSystem->Initialize(rpiSystemDescriptor);
+
+        // Now that the RPISystem is activated, activate the system entity.
+        m_systemEntity->Init();
+        m_systemEntity->Activate();
+    }
+
+    void TerrainSystemTestFixture::TearDown()
+    {
+        m_rpiSystem->Shutdown();
+        m_rpiSystem = nullptr;
+        m_rhiFactory = nullptr;
+
+        m_systemEntity.reset();
+
+        UnitTest::TerrainTestFixture::TearDown();
+    }
 }
 

+ 30 - 0
Gems/Terrain/Code/Tests/TerrainTestFixtures.h

@@ -11,6 +11,13 @@
 #include <TerrainSystem/TerrainSystem.h>
 #include <Components/TerrainSurfaceGradientListComponent.h>
 
+#include <Atom/RPI.Public/RPISystem.h>
+#include <Common/RHI/Factory.h>
+#include <Common/RHI/Stubs.h>
+
+#include <AzCore/UnitTest/Mocks/MockFileIOBase.h>
+#include <Tests/FileIOBaseTestTypes.h>
+
 namespace UnitTest
 {
     // The Terrain unit tests need to use the GemTestEnvironment to load LmbrCentral, SurfaceData, and GradientSignal Gems so that these
@@ -84,6 +91,8 @@ namespace UnitTest
         AZStd::unique_ptr<Terrain::TerrainSystem> CreateAndActivateTerrainSystem(
             float queryResolution = 1.0f,
             AZ::Aabb worldBounds = AZ::Aabb::CreateFromMinMax(AZ::Vector3(-128.0f), AZ::Vector3(128.0f))) const;
+        AZStd::unique_ptr<Terrain::TerrainSystem> CreateAndActivateTerrainSystem(
+            float heightQueryResolution, float surfaceQueryResolution, AZ::Aabb worldBounds) const;
 
         void CreateTestTerrainSystem(const AZ::Aabb& worldBounds, float queryResolution, uint32_t numSurfaces);
         void CreateTestTerrainSystemWithSurfaceGradients(const AZ::Aabb& worldBounds, float queryResolution);
@@ -113,6 +122,27 @@ namespace UnitTest
         }
     };
 
+    // This test fixture initializes and destroys both the Atom RPI and the Terrain System Component as a part of setup and teardown.
+    // It's useful for creating unit tests that use or test the terrain level components.
+    class TerrainSystemTestFixture : public UnitTest::TerrainTestFixture
+    {
+    protected:
+        TerrainSystemTestFixture();
+
+        void SetUp() override;
+        void TearDown() override;
+
+    private:
+        AZStd::unique_ptr<UnitTest::StubRHI::Factory> m_rhiFactory;
+        AZStd::unique_ptr<AZ::RPI::RPISystem> m_rpiSystem;
+
+        UnitTest::SetRestoreFileIOBaseRAII m_restoreFileIO;
+        ::testing::NiceMock<AZ::IO::MockFileIOBase> m_fileIOMock;
+
+        AZStd::unique_ptr<AZ::Entity> m_systemEntity;
+    };
+
+
 #ifdef HAVE_BENCHMARK
     class TerrainBenchmarkFixture
         : public TerrainBaseFixture

+ 79 - 5
Gems/Terrain/Code/Tests/TerrainWorldComponentTests.cpp

@@ -7,24 +7,98 @@
  */
 
 #include <Components/TerrainWorldComponent.h>
+#include <Components/TerrainSystemComponent.h>
 
 #include <AzTest/AzTest.h>
 
+#include <Tests/Mocks/Terrain/MockTerrainDataRequestBus.h>
+#include <Terrain/MockTerrain.h>
 #include <TerrainTestFixtures.h>
 
 class TerrainWorldComponentTest
-    : public UnitTest::TerrainTestFixture
+    : public UnitTest::TerrainSystemTestFixture
 {
+protected:
+    AZStd::unique_ptr<AZ::Entity> CreateAndActivateTerrainWorldComponent(const Terrain::TerrainWorldConfig& config)
+    {
+        auto entity = CreateEntity();
+        entity->CreateComponent<Terrain::TerrainWorldComponent>(config);
+        ActivateEntity(entity.get());
+
+        // Run for one tick so that the terrain system has a chance to refresh all of its settings.
+        AZ::TickBus::Broadcast(&AZ::TickBus::Events::OnTick, 0.f, AZ::ScriptTimePoint{});
+        return entity;
+    }
 };
 
 TEST_F(TerrainWorldComponentTest, ComponentActivatesSuccessfully)
 {
-    auto entity = CreateEntity();
+    auto entity = CreateAndActivateTerrainWorldComponent(Terrain::TerrainWorldConfig());
+    EXPECT_EQ(entity->GetState(), AZ::Entity::State::Active);
 
-    entity->CreateComponent<Terrain::TerrainWorldComponent>();
+    entity.reset();
+}
 
-    ActivateEntity(entity.get());
-    EXPECT_EQ(entity->GetState(), AZ::Entity::State::Active);
+TEST_F(TerrainWorldComponentTest, ComponentCreatesAndActivatesTerrainSystem)
+{
+    // Verify that activation of the Terrain World component causes the Terrain System to get created/activated,
+    // and deactivation of the Terrain World component causes the Terrain System to get destroyed/deactivated.
+
+    using ::testing::NiceMock;
+    using ::testing::AtLeast;
+
+    NiceMock<UnitTest::MockTerrainDataNotificationListener> mockTerrainListener;
+    EXPECT_CALL(mockTerrainListener, OnTerrainDataCreateBegin()).Times(AtLeast(1));
+    EXPECT_CALL(mockTerrainListener, OnTerrainDataCreateEnd()).Times(AtLeast(1));
+    EXPECT_CALL(mockTerrainListener, OnTerrainDataDestroyBegin()).Times(AtLeast(1));
+    EXPECT_CALL(mockTerrainListener, OnTerrainDataDestroyEnd()).Times(AtLeast(1));
+
+    auto entity = CreateAndActivateTerrainWorldComponent(Terrain::TerrainWorldConfig());
+    entity.reset();
+}
+
+TEST_F(TerrainWorldComponentTest, WorldMinAndMaxAffectTerrainSystem)
+{
+    // Verify that the Z component of the Terrain World Component's World Min and World Max set the Terrain System's min/max.
+    // (We aren't testing the XY components because those will eventually get removed)
+
+    Terrain::TerrainWorldConfig config;
+    config.m_worldMin = AZ::Vector3(0.0f, 0.0f, -345.0f);
+    config.m_worldMax = AZ::Vector3(1024.0f, 1024.0f, 678.0f);
+
+    auto entity = CreateAndActivateTerrainWorldComponent(config);
+
+    AZ::Aabb worldBounds = AZ::Aabb::CreateNull();
+    AzFramework::Terrain::TerrainDataRequestBus::BroadcastResult(
+        worldBounds, &AzFramework::Terrain::TerrainDataRequestBus::Events::GetTerrainAabb);
+
+    EXPECT_NEAR(config.m_worldMin.GetZ(), worldBounds.GetMin().GetZ(), 0.001f);
+    EXPECT_NEAR(config.m_worldMax.GetZ(), worldBounds.GetMax().GetZ(), 0.001f);
+
+    entity.reset();
+}
+
+TEST_F(TerrainWorldComponentTest, QueryResolutionsAffectTerrainSystem)
+{
+    // Verify that the Height Query Resolution and Surface Data Query Resolution on the Terrain World Component set the query
+    // resolutions in the Terrain System.
+
+    Terrain::TerrainWorldConfig config;
+    config.m_heightQueryResolution = 123.0f;
+    config.m_surfaceDataQueryResolution = 456.0f;
+
+    auto entity = CreateAndActivateTerrainWorldComponent(config);
+
+    float heightQueryResolution = 0.0f;
+    AzFramework::Terrain::TerrainDataRequestBus::BroadcastResult(
+        heightQueryResolution, &AzFramework::Terrain::TerrainDataRequestBus::Events::GetTerrainHeightQueryResolution);
+
+    float surfaceQueryResolution = 0.0f;
+    AzFramework::Terrain::TerrainDataRequestBus::BroadcastResult(
+        surfaceQueryResolution, &AzFramework::Terrain::TerrainDataRequestBus::Events::GetTerrainSurfaceDataQueryResolution);
+
+    EXPECT_NEAR(config.m_heightQueryResolution, heightQueryResolution, 0.001f);
+    EXPECT_NEAR(config.m_surfaceDataQueryResolution, surfaceQueryResolution, 0.001f);
 
     entity.reset();
 }

+ 1 - 54
Gems/Terrain/Code/Tests/TerrainWorldRendererComponentTests.cpp

@@ -9,66 +9,13 @@
 #include <Components/TerrainWorldComponent.h>
 #include <Components/TerrainWorldRendererComponent.h>
 
-#include <Atom/RPI.Public/RPISystem.h>
-#include <Common/RHI/Stubs.h>
-#include <Common/RHI/Factory.h>
-#include <AzFramework/Scene/SceneSystemComponent.h>
-
-#include <AzCore/UnitTest/Mocks/MockFileIOBase.h>
-#include <Tests/FileIOBaseTestTypes.h>
-
 #include <AzTest/AzTest.h>
 
 #include <TerrainTestFixtures.h>
 
 class TerrainWorldRendererComponentTest
-    : public UnitTest::TerrainTestFixture
+    : public UnitTest::TerrainSystemTestFixture
 {
-protected:
-    TerrainWorldRendererComponentTest()
-        : m_restoreFileIO(m_fileIOMock)
-    {
-        // Install Mock File IO, since the ShaderMetricsSystem inside of Atom's RPISystem will try to read/write a file.
-        AZ::IO::MockFileIOBase::InstallDefaultReturns(m_fileIOMock);
-    }
-
-    void SetUp() override
-    {
-        UnitTest::TerrainTestFixture::SetUp();
-
-        // Create the SceneSystemComponent for use by Atom
-        m_sceneSystemEntity = CreateEntity();
-        m_sceneSystemEntity->CreateComponent<AzFramework::SceneSystemComponent>();
-        ActivateEntity(m_sceneSystemEntity.get());
-
-        // Create a stub RHI for use by Atom
-        m_rhiFactory.reset(aznew UnitTest::StubRHI::Factory());
-
-        // Create the Atom RPISystem
-        AZ::RPI::RPISystemDescriptor rpiSystemDescriptor;
-        m_rpiSystem = AZStd::make_unique<AZ::RPI::RPISystem>();
-        m_rpiSystem->Initialize(rpiSystemDescriptor);
-    }
-
-    void TearDown() override
-    {
-        m_rpiSystem->Shutdown();
-        m_rpiSystem = nullptr;
-        m_rhiFactory = nullptr;
-
-        m_sceneSystemEntity.reset();
-
-        UnitTest::TerrainTestFixture::TearDown();
-    }
-
-private:
-    AZStd::unique_ptr<UnitTest::StubRHI::Factory> m_rhiFactory;
-    AZStd::unique_ptr<AZ::RPI::RPISystem> m_rpiSystem;
-
-    UnitTest::SetRestoreFileIOBaseRAII m_restoreFileIO;
-    ::testing::NiceMock<AZ::IO::MockFileIOBase> m_fileIOMock;
-
-    AZStd::unique_ptr<AZ::Entity> m_sceneSystemEntity;
 };
 
 TEST_F(TerrainWorldRendererComponentTest, ComponentActivatesSuccessfully)

+ 1 - 0
Gems/Terrain/Code/terrain_tests_files.cmake

@@ -18,6 +18,7 @@ set(FILES
     Tests/TerrainSurfaceGradientListTests.cpp
     Tests/TerrainSystemBenchmarks.cpp
     Tests/TerrainSystemTest.cpp
+    Tests/TerrainSystemSettingsTests.cpp
     Tests/TerrainWorldComponentTests.cpp
     Tests/TerrainWorldDebuggerComponentTests.cpp
     Tests/TerrainWorldRendererComponentTests.cpp