Skip to content

Implementing streamed tiles system for big georeferenced images #205

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: master
Choose a base branch
from

Conversation

Fletterio
Copy link
Contributor

@Fletterio Fletterio commented Jul 24, 2025

62_CAD/main.cpp Outdated
tiledGridParams.imageID = 6996;
tiledGridParams.geoReferencedImage = bigTiledGrid;

DrawResourcesFiller::StreamedImageManager tiledGridManager(std::move(tiledGridParams));
Copy link
Contributor

Choose a reason for hiding this comment

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

Seems reasonable to have a manager that knows everything about the image placement and info, useful for tile calculation.
Just keep in mind that we want to separate the idea of a "loader" and "tileCaclulator".
One actually does loads from disks or wherever, the other calculates which tiles and which mip levels are needed.
One has access to image data, One just knows the resolution and basic information
One doesn't have info about image placement in the worldspace and the view projection, one does.
One is loader, one is tile calculator or SteamedImageManager or whatever you want to call it

cachedImageRecord->state = ImageState::CREATED_AND_MEMORY_BOUND;
cachedImageRecord->lastUsedFrameIndex = currentFrameIndex; // there was an eviction + auto-submit, we need to update AGAIN
cachedImageRecord->allocationOffset = allocResults.allocationOffset;
cachedImageRecord->allocationSize = allocResults.allocationSize;
cachedImageRecord->gpuImageView = allocResults.gpuImageView;
cachedImageRecord->staticCPUImage = nullptr;
cachedImageRecord->staticCPUImage = manager.georeferencedImageParams.geoReferencedImage;
Copy link
Contributor

Choose a reason for hiding this comment

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

this should stay nullptr, georeferenced images should have nothing to do with staticCPUImage. the cacheImageRecord only holds the status of the gpu allocated georef image.
then you just copy your regions with buffers into it.

Copy link
Contributor

Choose a reason for hiding this comment

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

i.e. the cpu image should only be part of the loader and not the draw resources filler OR the tile calculator

@@ -879,11 +878,21 @@ void DrawResourcesFiller::addGeoreferencedImage(image_id imageID, const Georefer
return;
}

// Generate upload data
auto uploadData = manager.generateTileUploadData(NDCToWorld);
Copy link
Contributor

Choose a reason for hiding this comment

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

Correct/Good decision to calculate the tiling and which sections to load here in addGeoreferencedImage

@@ -631,20 +631,19 @@ bool DrawResourcesFiller::ensureMultipleStaticImagesAvailability(std::span<Stati
return true;
}

bool DrawResourcesFiller::ensureGeoreferencedImageAvailability_AllocateIfNeeded(image_id imageID, const GeoreferencedImageParams& params, SIntendedSubmitInfo& intendedNextSubmit)
bool DrawResourcesFiller::ensureGeoreferencedImageAvailability_AllocateIfNeeded(StreamedImageManager& manager, SIntendedSubmitInfo& intendedNextSubmit)
Copy link
Contributor

@Erfan-Ahmadi Erfan-Ahmadi Jul 30, 2025

Choose a reason for hiding this comment

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

Ok so I have a few requests about this.
This function should only care about the GPU allocation and the image itself and decide which resolution to create it with. (i.e. decide to go full res or streamed with tiles)

And you can know these with only 1. OriginalImageResolution 2. ViewportResolution 3. TileSize and nothing more

I don't see any need for the manager to go through here.
If this function is doing more than just figuring out what resolution to allocate the image in the vram with, then it's a sign something is wrong.


then when you're in addGeorefImage function, you can look up the cached image and see if it was decided to be "Stremead" or "FullRes" depending on that, you either request loading the whole shit from the loader OR you request tiles (through your tile manager or whatever)

{
outImageParams.extent = { georeferencedImageParams.imageExtents.x, georeferencedImageParams.imageExtents.y, 1u };
}
else
{
// TODO: Better Logic, area around the view, etc...
outImageParams.extent = { georeferencedImageParams.viewportExtents.x, georeferencedImageParams.viewportExtents.y, 1u };
// Pad sides to multiple of tileSize. Even after rounding up, we might still need to add an extra tile to cover both sides.
Copy link
Contributor

Choose a reason for hiding this comment

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

continuing from https://github.com/Devsh-Graphics-Programming/Nabla-Examples-and-Tests/pull/205/files#r2242153374
I believe these calculations is better if done inside addGeorefImage function


float2 corner = float2(bool2(vertexIdx & 0x1u, vertexIdx >> 1));
float2 uv = corner; // non-dilated
const bool2 corner = bool2(vertexIdx & 0x1u, vertexIdx & 0x2u);
Copy link
Contributor

Choose a reason for hiding this comment

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

vertexIdx >> 1 makes more sense than vertexIdx & 0x2u and cast to bool imo

@@ -120,6 +122,117 @@ struct DrawResourcesFiller
geometryInfo.getAlignedStorageSize();
}
};

// Used to load pieces of an ECW from disk - currently just emulated
struct ImageLoader
Copy link
Contributor

@Erfan-Ahmadi Erfan-Ahmadi Aug 18, 2025

Choose a reason for hiding this comment

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

ImageLoader is User space (n4ce) code, and for examples, somewhere in main.cpp
So here you have multiple options, here are 2:

  1. The whole thing is a single load function, just register a lambda, same as setGlyphMSDFTextureFunction and setHatchFillMSDFTextureFunction

I have a hunch the ImageLoader might have more than a load function, it may have functions that give info about the georeferenced image that are embedded in ECW/GeoTiff:

  1. Make the Abstract IGeoreferencedImageLoader here with pure virtual functions, then registering a class to DrawResourcesFiller

std::vector<std::vector<bool>> currentMappedRegionOccupancy = {};
// Converts a point (z = 1) in worldspace to UV coordinates in image space (origin shifted to topleft of the image)
float64_t2x3 world2UV = {};
ImageLoader loader;
Copy link
Contributor

Choose a reason for hiding this comment

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

The loader should be part of DrawResourcesFiller, here is why:

  1. n4ce will have only 1 global georeferenced loader that handles georeferenced images. we don't want to specialize loader per image
  2. In Cache&Replay mode, we won't go through the usual ensureGeoreferencedImageAvailability_AllocateIfNeeded --> addGeoreferencedImage. and actually no drawresources that add objects will be called, it will go into replay mode where everything is already cached. BUT for ECWs it's a little more complicated the tiles need constant check for updates since they depend on where the camera/viewport is; (other kinds of objects such as lines, curves, static images, are just there in the cache and they don't depend on the viewport)
    If you're curious about the replaying, see createReplayCache and pushAllUploads.
    For ECWs (unlike line work) we need to check all the georeferenced images, potentially load new tiles and update their data, we will do that with the already registered loader.

};

// @brief Used to load tiles into VRAM, keep track of loaded tiles, determine how they get sampled etc.
struct StreamedImageManager
Copy link
Contributor

@Erfan-Ahmadi Erfan-Ahmadi Aug 18, 2025

Choose a reason for hiding this comment

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

Looking at this, it seems like StreamedImageManager is partly functionality to compute tiles and partly a cache for the georeferenced image since it's used to store things like currentMappedRegionOccupancy

This all makes sense, BUT :

  1. it shouldn't be user's job to keep this thing alive and give you the correct streamedImageManager.
  2. what if we internally evicted the georeferenced image due to vram pressure, but we get old tile residency info and cache from the user
  3. It makes more sense if each imageId is matched with a StreamedImageManager internally in DrawResources filler, and user doesn't pass this to our functions
    How might we do that?

Well we already have a CachedImageRecord for our static and georeferenced images. (see CachedImageRecord* cachedImageRecord = imagesCache->insert in ensureGeoreferencedImageAvailability_AllocateIfNeeded)

What you need to do is just store it there as a new member variable, it will be invalid for static images.

also it doesn't "manage" the image per-se, it computes tiles to load and stores residency info.

here is what I suggest, feel free to play around with the idea:

  • rename to GeoreferencedImageStreamingState and only store context/cache requred
  • Move the functions that load and compute tiles to DrawResourcesFiller as protected member function. (all the functions will take the State/Context struct for computation)

This make more sense to me because this will become just a struct that holds important info we need to cache for streamed images, and drawResourcesFiller is the class which actually "manages/caches/evicts" georeferenced images.

constexpr static float64_t3 bottomRightViewportNDCH = float64_t3(1.0, 1.0, 1.0);

// Measured in tile coordinates in the image, and the mip level the tiles correspond to
struct TileLatticeAlignedObb
Copy link
Contributor

Choose a reason for hiding this comment

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

nice job using the word Lattice but don't use fancy names when we can use plain language. (imagine 4-5 years later somebody trying to read the code and wants to understant it as soon as possble to make edits)
It's just a TileAlignedOBB, it's not even an OBB in image space
you could just name this TexelRange or something.

  • Simple is always better

@@ -351,7 +464,7 @@ struct DrawResourcesFiller
void addImageObject(image_id imageID, const OrientedBoundingBox2D& obb, SIntendedSubmitInfo& intendedNextSubmit);

// This function must be called immediately after `addStaticImage` for the same imageID.
void addGeoreferencedImage(image_id imageID, const GeoreferencedImageParams& params, SIntendedSubmitInfo& intendedNextSubmit);
void addGeoreferencedImage(StreamedImageManager& manager, const float64_t3x3& NDCToWorld, uint32_t2 viewportExtents, SIntendedSubmitInfo& intendedNextSubmit);
Copy link
Contributor

Choose a reason for hiding this comment

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

If/When you apply:; https://github.com/Devsh-Graphics-Programming/Nabla-Examples-and-Tests/pull/205/files#r2281633149

No need to take StreamedImageManager, it will be cached and created (if not cached already) internally.
You can just take imageId and the other params here

continue;

auto tile = loader.load(uint32_t2(tileX * TileSize, tileY * TileSize), uint32_t2(TileSize, TileSize));
Copy link
Contributor

Choose a reason for hiding this comment

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

seems like you only need to use the buffer result of the load, just make the loader return an ICPUBuffer instead of ICPUImage and use that insread of all the null resource cpu buffer creation stuff.

system::ILogger* m_logger = {};
video::IPhysicalDevice* m_physicalDevice = {};
// We're going to fake it in the example so it's easier to work with, but the interface remains
core::vector<smart_refctd_ptr<ICPUImage>> mipLevels = {};
Copy link
Contributor

Choose a reason for hiding this comment

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

Unurgent + Unimportant (but I have to ask):
an ICPUImage can have mip levels inside it.
and why load images separately? I think .exr can hold multiple mip levels by itself and our loader can handle that.

@@ -494,10 +664,11 @@ class ComputerAidedDesign final : public nbl::examples::SimpleWindowedApplicatio

// Static Image Sampler
{
constexpr auto wrapMode = mode == ExampleMode::CASE_12 ? IGPUSampler::E_TEXTURE_CLAMP::ETC_REPEAT : IGPUSampler::E_TEXTURE_CLAMP::ETC_MIRROR;
Copy link
Contributor

Choose a reason for hiding this comment

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

no need for this if it didn't mess up other examples

cachedImageRecord->georeferencedImageState = GeoreferencedImageStreamingState::create(std::move(params));

// This is because gpu image is square
cachedImageRecord->georeferencedImageState->maxResidentTiles = cachedImageRecord->gpuImageView->getCreationParameters().image->getCreationParameters().extent.width / GeoreferencedImageTileSize;
Copy link
Contributor

Choose a reason for hiding this comment

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

This is assuming width = height (due to rotation)?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants