Chris3606 / GoRogue

.NET Standard roguelike library in C#. Features many algorithms and data structures pertinent to roguelike/2D game developers, specifically designed to be minimally intrusive upon the developer's architecture.
MIT License
485 stars 31 forks source link

Support Translation of Coordinates Spaces for Algorithms #281

Open Ven0maus opened 1 year ago

Ven0maus commented 1 year ago

I had some trouble getting the supplied AStar algorithm to properly work with a chunked grid. Eg. An entity coordinate was outside of the specified width/height of the internal array used in AStar algorithm. Seems like the pathfinding is always fixed to be positions between 0 and width*height. It would always return no path, since it could not map a world position into the array.

I made an implementation based on this here where you specify the entity and its view range: https://github.com/Venom0us/Mordred/blob/main/Mordred/Entities/PathFinding.cs (The entity could be changed by a positionGetter)

I don't know if it's worth it to include something similar? It basically translates the world position onto the internal grid and vice versa. The only downside is that the pathfinder is only good to be used by that specific entity. (as the entitity(or positionGetter) would always represent the center of the internal array at all times)

Chris3606 commented 1 year ago

Hi,

Yep, I think I see what you're saying.

Summary/TLDR

Background

The issue you're encountering here is, as you alluded to, inherit in the current implementation of the grid view system, which virtually all GoRogue algorithms (including pathing) use. To be clear on the design considerations that led to this; the grid view system (intentionally) limits itself to defining a coordinate space of (0, 0) to (Width - 1, Height - 1), inclusive, for a number of reasons.

Arguably, the most compelling reason is because it enables significant optimizations to the algorithm, when compared to a structure where the width and height is not known. For example, when Contains, rather than enumeration, is the primary operation needed on a set of points, using a BitArrayView the size of the grid to represent a set of positions is many times faster than using a HashSet<Point>, even when using an optimized hashing algorithm.

In general, this is not a limitation because either a grid is of a defined size, or the area on which an algorithm should operate is of a defined size, and both cases can be represented by grid views; however it does involve some manual grid-view creation in cases like the one you've cited, and there may be room for improvement on that front.

The other notable reason, is that it avoids over-complicating the interface. An algorithm needs needs to know little more than how to retrieve a value for a given (local) coordinate to function; therefore, the interface implementing more than that could reduce the number of use cases for which it is intuitively compatible.

Nonetheless, I do see a use case for coordinate-space translated grid views, which I believe can be supported while adhering to the intent of the above design decisions. Viewport<T> attempts to implement some concept of translating coordinate schemes, but implements primarily the concept of translating a larger grid view to a smaller one which can be positioned on the large one (like you might a top-down camera). This does not easily support all use cases, primarily for two reasons:

Solution Design Considerations

The most compelling case for adding some sort of support for coordinate-space translated coordinates (eg translating a global coordinate to an local x/y normalized to (0, 0) -> (Width - 1, Height - 1)) is that providing this functionality, as you learned, currently requires wrapping the algorithms themselves. This is because, if the algorithms themselves are not aware of the fact that there is some sort of coordinate-space translation going on, they can't perform any coordinate translation on what a user passes in, or the result they hand back to the user. Your use case is a great example of this; if a user is performing coordinate translation in order to retrieve a grid view for AStar, they probably want to still pass global coordinates to the ShortestPath function, and have the algorithm perform the translation if needed.

The flip side, is that users who are not performing the translation don't need any feature related to doing so. Therefore, in the ideal case, we want to avoid negatively impacting aspects of the design for those users as much as reasonable. These include:

In particular, these considerations rule out some otherwise obvious solutions. One thing that, ideally, we do not want to do, is have the user specify a Func used to perform coordinate translation when creating an AStar or other algorithm instance; first because it complicates the API (although this is solved via the use of a default argument), and more importantly because it is very likely to impact performance, even for users that don't need to perform coordinate translation. Most algorithms, to include FOV and AStar, access the value of various positions in their grid view frequently; therefore the overhead of an additional function call can actually matter from a performance perspective. Func is particularly impactful here (compared to a virtual function or other method of specifying code to run) because, the JIT compiler in the dotnet runtime is not particularly good at optimizing Func calls; namely, it seems to almost never be able to inline them (even when the equivalent virtual function sometimes can be inlined). This is why, in more recent versions of GoRogue, internal structures such as GameFramework.Map go out of their way to avoid the use of LambdaGridView (opting to go for custom interface implementations instead); because it does, in some cases, impact performance in a meaningful way.

Proposal 1

In line with these considerations, I would propose something in the neighborhood of the following changes. Any code listed here is purely conceptual in nature; names, syntax, and even details of the API likely need to be tweaked, etc:

  1. Add an interface similar to the following to the primitives library:

    // Represents grid views that are translating between coordinate spaces.
    // Any algorithm which gets specified a grid view that implements this may
    // assume that there is a global coordinate space which is different than the one
    // they use, and that their public interface should operate in terms of the global
    // coordinate space.
    public interface ICoordSpaceTranslatingGridView<T> : IGridView<T>
    {
    public Point GlobalToLocalPosition(Point globalPos);
    public Point LocalToGlobalPosition(Point localPos);
    }
  2. Modify Viewport<T> and similar classes to implement this interface, something like:

    
    public class Viewport<T> : ICoordSpaceTranslatingGridView<T>
    {
    public Point GlobalToLocalPosition(Point globalPos) => globalPos - ViewArea.Position;
    public Point LocalToGlobalPosition(Point localPos) => globalPos + localPos;

/ Existing implementation here / }


3. Modify algorithms to be aware of if their viewport implements this interface, something like:
```CS
public class AStar
{
    public Path? ShortestPath(Point start, Point end, bool assumeEndpointsWalkable = true)
    {
        if (WalkabilityView is ICoordSpaceTranslatingGridView<bool> csView)
        {
            start = csView.GlobalToLocalPosition(start);
            end = csView.GlobalToLocalPosition(end);
        }

        // Modifications to the Path structure are also required, but omitted here for brevity
        CallCurrentShortestPathAlgo(start, end, assumeEndpointsWalkable);
    }
    /* Existing implementation here */
}

Solution Analysis

The above proposal tries to reach a solution which is as generic as possible, while preserving any possible performance attributes for cases where translation is not necessary. It attempts to do so while incurring as few breaking changes as possible, and in a way that is portable to nearly any algorithm.

The Good

The Bad

Proposal 2 coming soon...

Chris3606 commented 1 year ago

Proposal 2

The following is an attempt to simplify the API of proposal 1 without losing many of the benefits.

  1. Create an interface (possibly in the primitives library):

    public interface ICoordinateSpaceTranslator
    {
    public Point GlobalToLocalPosition(Point position);
    public Point LocalToGlobalPosition(Point position);
    }

    Additionally, probably provide concrete implementations of this interface that fit common scenarios.

  2. Modify algorithms to take one of these as a parameter, for example:

    public class AStar
    {
    public AStar(IGridView<bool> walkabilityView, Distance distanceMeasurement, ICoordinateSpaceTranslator? coordinateSpaceTranslator = null)
            : this(walkabilityView, distanceMeasurement, null, null, 1.0)
        {
            CoordinateSpaceTranslator = coordinateSpaceTranslator;
        }
    /* Similar modifications to other constructors */
    }
  3. Modify the algorithms to use the coordinate space translator, if it exists, for example:

    public class AStar
    {
    public Path? ShortestPath(Point start, Point end, bool assumeEndpointsWalkable = true)
    {
        if (CoordinateSpaceTranslator != null)
        {
            start = CoordinateSpaceTranslator.GlobalToLocalPosition(start);
            end = CoordinateSpaceTranslator.GlobalToLocalPosition(end);
        }
    
        // Modifications to the Path structure are also required, but omitted here for brevity
        CallCurrentShortestPathAlgo(start, end, assumeEndpointsWalkable);
    }
    /* Existing implementation here */
    }

Solution Analysis

The above solution tries to allow algorithms to be aware of coordinate translation taking place, in a generic way that provides similar benefits to the first proposal. However, this is an attempt to simplify the API and make it more obvious for new users, and less likely to result in subtle bugs.

The Good

The Bad

Chris3606 commented 1 year ago

Overall, I advocate for proposal 2. It seems to offer the feature via the least complicated API and little to no performance tradeoffs. Furthermore, the convenience of using Func or lambdas can still be obtained in this method, for users who are OK with the performance hit, because a LambdaCoordinateSpaceTranslator can be provided.

As for the primary disadvantage (decoupling from the grid view); although this could create undesired behavior, it can also be a feature; for example, perhaps it does make sense to use a Viewport<T> without a translator, if entities all defined their own coordinate schemes in some case.

Chris3606 commented 1 year ago

One additional challenge (with either proposal) is how to handle algorithms like FOV, which expose their results as a grid view. Because the results are a grid view, they are implicitly in the local coordinate space. This is unique, compared to AStar, where the Path can simply be returned in global coordinates and no additional translation is ever required.

Since the results are persistently exposed to the user in a grid view (which uses the algorithm's local coordinate space), there are two unique challenges:

The first issue implies that simply exposing the results as a grid view is insufficient, since a grid view defines a coordinate space from (0, 0) to (Width - 1, Height - 1); eg. a local coordinate space. Some additional translation layer must therefore exist to allow a user to retrieve values from the result view with positions defined in their "global" coordinate space.

The second issue implies that there are restrictions on when a coordinate translator should record/update offsets used in the calculation. Consider a case where a coordinate space translation is based upon an entity's position in a global coordinate space. The translation might be defined as localPosition = globalPosition - entitysUpperLeftViewportPosition. This definition, with no other constraints, functions properly for AStar, where the translation of the result happens as a part of the algorithm; however for FOV, it could cause an issue. If, for example, FOV is calculated, then the entity moves, then a result is accessed, the translator must still define its translation based on the entitysUpperLeftViewportPosition value that existed when the calculation occurred, which is not the current one. Arguably, this situation is undesireable anyway for FOV, because if the entity moves the FOV probably should have recalculated in order to be accurate. Nonetheless, this is a situation the current proposal's interface would allow, and there may be coordinate space definitions and/or situations for which this is non-apparent.

Proposal 2.1

In order to properly support the above scenarios, the following changes to proposal 2 could be made:

  1. Define the ICoordinateSpaceTranslator interface to the following:

    public interface ICoordinateSpaceTranslator
    {
    public Point GlobalToLocalPosition(Point position);
    public Point LocalToGlobalPosition(Point position);
    public Point UpdateSpaceDefinition(); // New function
    }
  2. Algorithms in their "calculate" function should call UpdateSpaceDefinition() before calculating.

  3. An interface should be added something to this effect:

    public interface IGridViewTranslator<T>
    {
    IGridView<T> GridView {get; }
    
    T this[Point globalPos];
    T this[int x, int y]; // Same as above
    T this[int index]; // Forwards to GridView directly
    }
  4. ResultView or similar views in algorithms would consist of a structure like this when translation is involved:

    public class GridViewTranslator<T> : IGridViewTranslator<T>
    {
    public IGridView<T> GridView {get; }
    public ICoordinateSpaceTranslator Translator {get;}
    
    public T this[Point globalPos] => LocalGridView[Translator.GlobalToLocalPosition(globalPos)];
    public T this[int globalX, globalY] => this[new Point(globalX, globalY)];
    public T this[int index] => LocalGridView[index];
    
    /* Define Positions iterator to iterate over global positions */
    }

In cases where no translation is involved, the grid view could instead be a dummy implementation of IGridViewTranslator<T> that doesn't actually perform any translation (the hope being the compiler is able to optimize this fairly well).

The Good

The Bad

Chris3606 commented 11 months ago

Note that a branch of the primitives library exists which mostly provides a proof of concept. It still needs to be tested in actual GoRogue algorithms and the results benchmarked against the non-translating counterparts.