nothings / stb

stb single-file public domain libraries for C/C++

Home Page:https://twitter.com/nothings

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Relax address stability requirement for `stb_rect_pack`

vittorioromeo opened this issue · comments

Problem:

Currently, stb_rect_pack requires the user to provide contiguous storage for the nodes and a context, e.g.:

constexpr std::size_t maxNodes = 1024u;

stbrp_node    nodes[MaxNodes]{};
stbrp_context context{};

stbrp_init_target(&context, /* width */, /* height */, nodes, maxNodes);

Given this sort of design, it seems like we should be able to deep-copy nodes and context. However, that ends up causing undefined behavior at run-time because stbrp_node and stbrp_context store stbrp_node* that rely on address stability of the nodes.

This makes it necessary to dynamically allocate storage for the rectangle packer if its "owner" needs to be copied/moved around.


Suggested solution:

As the user is already required to provide contiguous storage, why not store indices instead of stbrp_node* internally in stb_rect_pack? E.g.

struct stbrp_node
{
   stbrp_coord  x,y;
-  stbrp_node  *next;
+  int          next_idx;
};

struct stbrp_context
{
   int width;
   int height;
   int align;
   int init_mode;
   int heuristic;
   int num_nodes;
-  stbrp_node *active_head;
-  stbrp_node *free_head;
-  stbrp_node extra[2]; // we allocate two extra nodes so optimal user-node-count is 'width' not 'width+2'
+  int active_head_idx;
+  int free_head_idx;
};

The extra array could be replaced with magic indices (e.g. -1 is full width, -2 is sentinel), or perhaps the user might be requested to allocate two more spaces for the extra nodes while providing the contiguous storage.

Thoughts?

So, first of all, moving things around isn't really a normal operation in C/C++ so I'm not clear what the motivation for this is.

Dynamically allocating node storage on the heap is the expected behavior anyway, and the context would normally only exist for the lifetime of a packing operation.

Anyway I haven't thought about this library in a long time, but offhand I would assume that all that is necessary is to call stbrp_init_target() if you've "moved" things, unless you're moving things in the middle of an ongoing packing operation (which seems like a weird thing to do).

The source code convenience of the library internally being able to use pointer operations--which are less bug-prone than indexing, don't require special cases for the extra nodes, and are self-commenting--seem like they outweigh any plausible public-facing benefit supporting the (to me) unusual operation of relocating the memory being supplied to a C/C++ library.

So, first of all, moving things around isn't really a normal operation in C/C++ so I'm not clear what the motivation for this is.

My use case was a TextureAtlas abstraction for a fork of SFML I am working on. Basically:

class TextureAtlas
{
public:
    TextureAtlas(const TextureAtlas&); // copyable
    TextureAtlas(TextureAtlas&&);      // movable
    // ...    

public:
    Texture    m_texture;    // manages/owns GPU texture, copyable and movable
    RectPacker m_rectPacker; // wrapper around `stb_rect_pack`
};

In my current implementation of RectPacker I allocated some space directly as a data member up to a maximum fixed amount, as I did not want to introduce a dependency on <vector> nor deal with manually resizing heap-allocated buffers.

Some other abstractions which store a TextureAtlas or factory functions for TextureAtlas ended up causing a copy/move of TextureAtlas, which caused undefined behavior as the packer's internal pointers were now pointing to stale memory.

Anyway I haven't thought about this library in a long time, but offhand I would assume that all that is necessary is to call stbrp_init_target

I recall having tried that, but I recall having some issues I cannot clearly remember -- sorry. Perhaps it is worth revisiting.

The source code convenience of the library internally being able to use pointer operations--which are less bug-prone than indexing, don't require special cases for the extra nodes, and are self-commenting--seem like they outweigh any plausible public-facing benefit supporting the (to me) unusual operation of relocating the memory being supplied to a C/C++ library.

I don't personally think that pointer operations are any safer than indexing (or vice versa), as in both cases the programmer is responsible for ensuring that the pointers are not stale and that the indices are in bounds.

To be honest, I think that indexing is superior/safer compared to pointer access whenever possible, as pointers introduce an implicit requirement of address stability (as encountered here), and end up causing issues with resizable containers -- e.g. one of the most common C++ beginner mistakes is storing a pointer to an element in a std::vector, that then gets reallocated somewhere else, invalidating the pointer.

Using indexes prevent these sort of issues in the first place, and I haven't seen any evidence that they are any slower than direct pointer access.

I recommend this great article by @floooh which is relevant to the discussion: "Handles are the better pointers".

We could debate about indices vs pointers all day though, so I respect your opinion if you don't think this change is worthwhile doing.


P.S. reading my reply again, I realized that using a std::vector as the backing storage in RectPacker would cause the same issue if the vector ends up being reallocated, which means I cannot easily create a wrapper around stb_rect_pack that uses an automatically-resizing heap-allocated storage.

If stb_rect_pack used indices internally, that task would be trivial.

So...

a) obviously indices are more bug-prone than pointers because they're not typesafe. C does not support any way to have an array that can only be indexed by a special type to reintroduce type safety, so it's easy to accidentally index an array by an integer that is not a "handle" associated with that array.

b) again, the packer is meant to be a transient object that you use while packing, not something that you keep around for the life of a texture atlas. the packed results are returned in a separate array that has no pointer lifetime issues. storing the packer (rather than the packed results) in a texture atlas is a questionable design choice 99% of the time.

c) Even if stb_rect_pack stored indices internally, you couldn't use std::vector to relocate the array nodes since the context still would be keeping a pointer to the beginning of the array, and you'll have moved the array. The scenario you're imagining doesn't make any sense to me.

d) You are supposed to re-init the packer every time you run a new pack operation. To quote the docs:

You must call this function every time you start packing into a new target.

As far as I can see, this addresses the problem. Any relocation of the contiguous array of nodes requires updating the context to know where this is. The context is an opaque object that you're not supposed to manipulate the innards of, so the only way to update the pointer to the nodes if you move them is by re-initing anyway.

again, the packer is meant to be a transient object that you use while packing

My use case is loading textures one by one into the packer at the moment. I understand that does not provide optimal packing, but I'm working with some existing API limitations I cannot change.

However, this seems to be explicitly supported:

// To pack into another rectangle, you need to call stbrp_init_target
// again. To continue packing into the same rectangle, you can call
// this function again. Calling this multiple times with multiple rect
// arrays will probably produce worse packing results than calling it
// a single time with the full rectangle array, but the option is
// available.

You are supposed to re-init the packer every time you run a new pack operation

Should this work? I'm getting texture corruption with the approach below:

struct RectPacker
{
    std::vector<stbrp_node> m_nodes;
    stbrp_context           m_context;
    int                     m_numPacked;

    RectPacker(Vector2i theSize) : m_nodes(16), m_context{}, m_numPacked{0}
    {   
        stbrp_init_target(&context, 2048, 2048, m_nodes.data(), m_nodes.size());
    }

    Vector2i pack(Vector2i rectSize)
    {
        if (m_numPacked >= m_nodes.size()) // reallocate buffer if needed
        {
            m_nodes.resize(m_nodes.size() * 3 / 2);
            stbrp_init_target(&context, 2048, 2048, m_nodes.data(), m_nodes.size());
        }

        stbrp_rect toPack{0, rectSize.x, rectSize.y};

        const int rc = stbrp_pack_rects(&m_context, &toPack, /* num_rects */ 1);
        assert(rc != 0);

        ++m_numPacked;
        return {toPack.x, toPack.y};
    }
};

Intended usage is repeated calls to pack.

You're not using the library correctly. nodes/num_nodes are internal storage used for keeping track of what parts of the full texture has been used. You are not intended to change it dynamically while packing, any dependence on the number of packed rectangles is an implementation detail not visible to the user. As suggested in the docs, just use width as the number of nodes.

You're not using the library correctly. nodes/num_nodes are internal storage used for keeping track of what parts of the full texture has been used. You are not intended to change it dynamically while packing, any dependence on the number of packed rectangles is an implementation detail not visible to the user. As suggested in the docs, just use width as the number of nodes.

Got it, thanks!