uber / h3

Hexagonal hierarchical geospatial indexing system
https://h3geo.org
Apache License 2.0
4.9k stars 466 forks source link

h3ToGeo performance and the significance of the Digit 1 -> 15 in the H3 index. #425

Open douglasg14b opened 3 years ago

douglasg14b commented 3 years ago

I've been exploring this library for use in a location-based game, and it looks like a solid fit, but I would like to get some thoughts from the experts of this lib.


1. What is the significance of the Digit 1 -> 15 in the H3 index

From the information here: https://h3geo.org/docs/core-library/h3indexing

I thought that they might represent the parent cells? What is the significance of those digits in the index itself? The explanations in the docs didn't entirely convey that to someone with my less technical background.

2. H3 Performance

I'm essentially using H3 to manage a logical grid for a game with a resolution of 13 (12 hexes where just too large unfortunately). The nature of this necessitates a significant amount of kRing evaluations from players, then getting the lat/long for every hex within via h3ToGeo, and then generating some noise with the coords (To generate terrain features over the hexagons). I'm evaluating kRing with a radius of 50-210.

The majority of CPU time is spent on h3ToGeo and on noise generation. I can reduce the noise generation cost by caching generated values, but I may not be able to reduce the costs of h3ToGeo if I'm using lat/long as my lookup mechanism. When iterating of a few hundred thousand hexes h3ToGeo becomes quite expensive. (eg: ~4ms for kRing, and ~160ms for h3ToGeo)

My question here: Is there a way to increase h3ToGeo performance for batches of H3 Indexes, as opposed to getting them one at a time? Is there redundant work that is occurring for each one that may only need to be done once for a batch? If so, would it be reasonable to ask for that as a feature?

RichardVasquez commented 3 years ago

1. Digit significance

I'm going to use this as a reference point for the following explanation:

--------####0______1__2__3__4__5__6__7__8__9__A__B__C__D__E__F__

These represent the 64 bits in an H3 index, which you might see in your code as 576988517884755967 or 801dfffffffffff, or some other representation. Either way, it's 64 bits that provide the information you need.

The - bits we can ignore for this part.

The # bits indicate what resolution we're at, 0000 => resolution 0 (largest cells) to 1111 => resolution 15 (smallest cells). This in turn will indicate how far we're going to follow the remaining trail of bits.

The bits that start at 0_... indicate the actual base segment on the earth called a face. It'll be a pentagon or a hexagon, and there are 122 of them, with 12 of them being hexagons. See image at https://eng.uber.com/h3/

The following may seem a bit pedantic. My apologies in advance.

So if you're at resolution 0, you're at one of the biggest hexagaons/pentagons partitioning the earth in H3. Bow you want to find a smaller partition. Let's start following the bits. I'll ignore the resolution bits for now as I go on, and I'm going to just use a hexagon starting point.

So we're at a hexagon, and we're looking for a smaller hexagon. We have a choice of 7 hexagons. One that's effectively in the center of the the hexagon we're currently examining, and 6 others, for a total of 7.

Here's a couple of important pieces to understand.

  1. For efficient packing, each resolution is rotated back or forth some number of degrees, so as such, we need another way to keep track of how the hexes at each resolution are related to each other instead of "north" or "12 degrees counter clockwise" or "theta 0.2".

  2. This is done using the ijk axis system shown in the page you linked to. If you treat the axes of (i,j,k) as having the values (4, 2, 1), and assuming you're looking only at the next resolution and the only the hexagons immediately at the next resolution contained within your current resolution, you can map out all 7 hexagons.

    • Another way of putting it. Let's say you're an equilateral triangle with one eye on the center of each side looking out. You've named your eyes "I", "J", and "K". Someone places a box near you.
    • The question becomes "Which eyes can see the box?", You'll have one or two eyes able to see the box. You won't have a situation where all three eyes can see the box.
    • As such, your answers would be "K", "J", "J and K", "I", "I and K", "I and J", or which correspond to "1, 2, 3, 4, 5, 6", which you can map to the diagram on the page you linked to.

So with this unique identification process, this is roughly how it would go, mapping an arbitrary area on Earth.

(Referring back to the text line I provided above)

Bits starting with 0___ would indicate which of 122 faces you're starting to look at on the Earth.

Taking the next bits labeled 1__, let's say the bits are 000, that means you would be looking for the hexagon at the very center contained within the resolution 0 face you're starting from.

Let's take a few more resolutions.

Using the 2__ bits, your location isn't near the center anymore. you can map it out using the IJK axes and turn that into the next 3 bit number.

Going down to the 3 bits, you map the location you're looking for within the appropriate hexagon (I think it's about 60 km edge length), and it's close enough for your measurement purposes. At that point, the remaining 4 to F bits should all be 7, and the #### would indicate the H3 value is mapping out to resolution 3 starting at Face 0____ and following the child cells 1_ in relation to center of 0____ then to cell 2 in relation cell 1 and finally at cell 3 in relation to cell 2 and ending.

I... I didn't expect to be writing that much.

Any questions or requests for clarifications?

RichardVasquez commented 3 years ago

2. H3 Performance

I'm kind of curious. Are you using the h3ToGeo filter application or the library function void h3ToGeoBoundary(H3Index h3, GeoBoundary* gb)? You're likely going to get some performance boost if you're using the latter. Even so, it takes a little work to take those arbitrary directions I mentioned in my above answer and turn them into real meat space geographical coordinates.

Also, kRings are just (he says jokingly) a bunch of integer based math and bit fiddling and some conditionals. It's not as simple as XOR, but it's pretty straightforward, and in a pinch, I've built viable kRings from scratch on paper under controlled conditions. No way I could do an h3ToGeoboundary doing such other than maybe a resolution 0.

I'm also building a game using H3, and it's a real time mobile app, with the primary resolution being 12. I have some res9 and some res14 as well, but the latter is lower frequency, so I just compute them on the client as needed.

However, the res 12 information is heavily dynamic and can be affected by player actions with a potential longevity of days, so I'm storing things on the server side as a cache using Postgres, PostGis, and h3-pg

In my case, I have map tiles with a unique ID and known geographical locations for their corners. If a player sees/enters that tile for the first time, I have a stored procedure that does a polyfill of that tile with res12 cells.

Other operations may be performed server side as the above mentioned player actions may have affected the game state for that tile, even if a player hasn't ever been there (it's complicated). The final result of my res12 contents are sent to the player which then performs the h3ToGeo calculations and caches them while the player is within a certain distance of the tile, along with update polls and TTL garbage collection.

Maybe that can help on your side?

douglasg14b commented 3 years ago

First, thank you for the explanations and for writing this out. It's very helpful to have more insight into the workings and decisions around this lib. These sorts of lectures are invaluable to gaining a better understanding and are worthy of documentation!


Digits

Sorry if I wasn't clear, I'm referring to the last 45 bits of the index marked as Digit 1,2,3...15on that page. For instance at res 13 what is the significance of the res 12, 11, 10 ... 1 3-bit chunks. If I understand your explanation correctly then each of those 3-bit sections (That refer to resolution 1 -> 15) are mapping to the hexagon that we are contained in level-by-level? So at Res 11, the A__ bits indicate the hexagon we are in 1 level up, and 9__ indicates the hexagon that A__ is contained din 1 more level up...etc all the way up to the base cell.

Bits for levels lower than the current are going to be represented as f or 111 in binary and can just be discarded for indexing purposes?

Does that seem correct?

My objective with that was to explore more intelligent indexing and caching mechanisms. Though if I use a spatial DB like PostGIS, then it's mostly a moot point for the purpose of indexing. Though I can still utilize it for maintaining a more performant client-side cache.

h3ToGeo Perf

Are you using the h3ToGeo filter application or the library function void h3ToGeoBoundary(H3Index h3, GeoBoundary* gb)?

I'm using the h3ToGeo filter application. The purpose of which is to identify each cell's center lat/long that I then use as an input for noise generation (Currently this lat/long, stored as two 32 bit numbers, is then used as the index identify those values, rather than using the H3 Index). The vertices themselves are currently sorted out client-side, though caching those in a DB could prove valuable on the performance front as client-side (mobile) evaluation of boundaries is rather expensive when rendering 100k+ indexes. However that is an optimization problem for another time.

Either way, getting the lat/long coords of these hexes is rather pricey.

Game side of things

Interesting that you're making a mobile game that uses H3 as well! What sort of game if you don't mind sharing?

I imagine you've already solved many problems that I'm just starting to recognize, I have a few questions if you are up for answering them.

I'll start with my use case: A player may 'scan' a region around them to get back terrain features. These are features granular to the res13 hex grid (As opposed to a square grid). This is essentially a kRIng with a radius up to 210. This involves generating or retrieving this data and sending it back to the client in good time, and in a way that can support a significant number of requests of this type. I aim to permanently store this generated information so I don't have to redo expensive noise generation over the same indexes. The H3 hexagonal grid IS the grid system for this game, which is a shared world between all players. Of course, players can also effect individual hexagons, but this is pretty easy to handle in comparison to the wide-scale 'scanning'.

Actually, that's really the only significant question I have! Any insight is definitely appreciated

isaacbrodsky commented 3 years ago

Sorry if I wasn't clear, I'm referring to the last 45 bits of the index marked as Digit 1,2,3...15on that page. For instance at res 13 what is the significance of the res 12, 11, 10 ... 1 3-bit chunks. If I understand your explanation correctly then each of those 3-bit sections (That refer to resolution 1 -> 15) are mapping to the hexagon that we are contained in level-by-level? So at Res 11, the A__ bits indicate the hexagon we are in 1 level up, and 9__ indicates the hexagon that A__ is contained din 1 more level up...etc all the way up to the base cell.

That is correct. Each 3 bit field refers to which cell (hexagon) contains the indexed cell at that resolution. Put another way, starting at the resolution 0 cell, which subdivisions of that cell represent the indexed cell.

Bits for levels lower than the current are going to be represented as f or 111 in binary and can just be discarded for indexing purposes?

They will get rendered as f when there are a bunch of remaining unused digits, but it should be noted that the indexing digit bit fields do not align with the hexadecimal characters. The indexing algorithms do not use those fields, instead we set them to a known value so that indexes can be easily compared. (Some other algorithms like kRing may have issues if those digits are not set to 0b111 because of comparisons between indexes)

Does that seem correct?

I think your understanding is correct.

I'm using the h3ToGeo filter application. The purpose of which is to identify each cell's center lat/long that I then use as an input for noise generation (Currently this lat/long, stored as two 32 bit numbers, is then used as the index identify those values, rather than using the H3 Index). The vertices themselves are currently sorted out client-side, though caching those in a DB could prove valuable on the performance front as client-side (mobile) evaluation of boundaries is rather expensive when rendering 100k+ indexes. However that is an optimization problem for another time.

I would suggest using a binding to another language/database rather than the h3ToGeo filter if possible. I would expect the filter application to have a much higher overhead in terms of the serialization cost needed to send values in and out of the application. What language/database are you using?

I would suggest seeing if it's possible to change your algorithm or rendering to need to render 100k+ indexes in a batch as it sounds like you need to do currently. I don't think you would be able to represent that many indexes on screen at once on mobile.

Interesting that you're making a mobile game that uses H3 as well! What sort of game if you don't mind sharing?

+1 always great to see gaming uses of H3 :)

RichardVasquez commented 3 years ago

h3ToGeo Perf

Are you using the h3ToGeo filter application or the library function void h3ToGeoBoundary(H3Index h3, GeoBoundary* gb)?

I'm using the h3ToGeo filter application. The purpose of which is to identify each cell's center lat/long that I then use as an input for noise generation (Currently this lat/long, stored as two 32 bit numbers, is then used as the index identify those values, rather than using the H3 Index). The vertices themselves are currently sorted out client-side, though caching those in a DB could prove valuable on the performance front as client-side (mobile) evaluation of boundaries is rather expensive when rendering 100k+ indexes. However that is an optimization problem for another time.

Like @isaacbrodsky said above, I'd recommend using a binding. Using an external application for spot checking or non time sensitive batch operations is useful, but when you're dealing with real time, you're having to deal with the program loading itself into memory, appropriating its stack/heap, then unloading itself, and as your numbers earlier showed, h3ToGeo runs longer than kRing.

Interesting that you're making a mobile game that uses H3 as well! What sort of game if you don't mind sharing?

Um. It's mobile, it uses H3, and it's real time. That, and it's a multi player territory war game type is about all I want to get into it right now.

I'll start with my use case: A player may 'scan' a region around them to get back terrain features. These are features granular to the res13 hex grid (As opposed to a square grid). This is essentially a kRIng with a radius up to 210. This involves generating or retrieving this data and sending it back to the client in good time, and in a way that can support a significant number of requests of this type. I aim to permanently store this generated information so I don't have to redo expensive noise generation over the same indexes. The H3 hexagonal grid IS the grid system for this game, which is a shared world between all players. Of course, players can also effect individual hexagons, but this is pretty easy to handle in comparison to the wide-scale 'scanning'.

13 is high. It's 7 times the 12 that I'm using, and there are times I wish I could justify going down to 10. As a matter of scale though, 12 is good, as it's a close match to the minimum distance between POI in Pokemon Go.

  • Would you consider PostGIS or a typical flat table approach to storing information related to each hexagon (permanently) for the purpose of large lookups? Eg. Information on all hexagons in a kRIng radius of 150 from some index.
  • Is there some advantage to using a spatial DB for this purpose?
  • For instance, changing the approach and providing the hexagonal grid as vector tiles, as opposed to querying for the data separately and generated GeoJSON for the map?

Hm. This is kind of hard to answer without telling too much about my game. Let me try, though.

Currently when a player is in a real area, my mapping library will pull in 3x3 tiles (with roads, buildings, etc.) to display on their device. That's automatic.

I know the locations of the tile corners geographically. I then send queries for each tile to my server.

If the server hasn't been aware of that tile before, it does a polyfill of the tile to get any hexagon indexes that might now be in play. It also stores the indexes for possible later use.

Another query is done joining with the active hex table to see if there's hexes that need to be rendered. Here's the thing, those hexes might exist without knowing what tile they belong to until someone actually visits the tile. Here's how it works:

Now let's say the player leaves the area, and comes back a few hours later. The server already knows the tile, so skips the polyfill and just does the query again and sends the data again, which might be something new due to other events happening in the game, or might be the same as the last time.

===

The majority of my data is plain RDBM based data. I use PostGIS since the h3 binding requires it. About the only spatial queries that I use with it are for querying against tile contents.

I also use the PostGIS h3 bindings for placing an area of effect. Say I use cell ####, and it has a radius of 100 hexes, I'll place the h3 indexes along with their distance using HexRange from cell #### and indicate that their source of effect is ####, but then I'll just store it a regular row that would look something like this:

| ThisCell @@@@ | SourceCell #### | Distance |

There's other tables, joins, and queries, but the majority of it is plain SQL. It's just when I CRUD locations/areas that the PostGIS/h3 comes into play

sahrk commented 3 years ago

Sorry if I wasn't clear, I'm referring to the last 45 bits of the index marked as Digit 1,2,3...15on that page. For instance at res 13 what is the significance of the res 12, 11, 10 ... 1 3-bit chunks. If I understand your explanation correctly then each of those 3-bit sections (That refer to resolution 1 -> 15) are mapping to the hexagon that we are contained in level-by-level? So at Res 11, the A__ bits indicate the hexagon we are in 1 level up, and 9__ indicates the hexagon that A__ is contained din 1 more level up...etc all the way up to the base cell.

That is correct. Each 3 bit field refers to which cell (hexagon) contains the indexed cell at that resolution. Put another way, starting at the resolution 0 cell, which subdivisions of that cell represent the indexed cell.

It might be worth clarifying that each coarser resolution digit represents the hierarchical indexing parent of a cell, which only "contains" the cell if it is the center child. Uber chose to treat the indexing descendants of a cell as a fuzzy version of that cell, and that works for their use case. But you can't assume that if a point lies in a cell at some resolution it will necessarily lie within the coarser indexing ancestors of that cell.

Bits for levels lower than the current are going to be represented as f or 111 in binary and can just be discarded for indexing purposes?

They will get rendered as f when there are a bunch of remaining unused digits, but it should be noted that the indexing digit bit fields do not align with the hexadecimal characters.

A quick hack to see the 15 resolution digits of an H3 index is to print it out in octal: printf("%llo", h3Index)

douglasg14b commented 3 years ago

Sorry for the long delay in response, was a very busy week! Thank you all for your comments, this is all very informative.


@isaacbrodsky

I would suggest using a binding to another language/database rather than the h3ToGeo filter if possible.

I misunderstood the question, I am using a binding (C# in this case) not calling the actual program itself!

I would suggest seeing if it's possible to change your algorithm or rendering to need to render 100k+ indexes in a batch as it sounds like you need to do currently. I don't think you would be able to represent that many indexes on screen at once on mobile.

Surprisingly, once rendered, this displays perfectly fine (I'm using MapBox Gl JS) on mobile. I've already experimented to figure out how mobile performance plays out, and the actual generation & initial rendering of the GeoJSON is the most expensive part. After that it's 60fps with even 200k+ hexagons!!

+1 always great to see gaming uses of H3 :)

It's a godsend for geolocation based games that need a hexagonal grid!


@RichardVasquez

Um. It's mobile, it uses H3, and it's real time. That, and it's a multi player territory war game type is about all I want to get into it right now.

Fair enough, I'm working on a multiplayer tycoon-like game. Mind if I ask what platform you're building this on (ie. Unity, Web-based, other?) I started with Unity since I already have familiarity with it, and I'm primarily a C# dev,. I eventually dropped it for web app + cordova since the UI bits as well as Mapbox was easier to use (And the performance is better, funny enough).

13 is high. It's 7 times the 12 that I'm using, and there are times I wish I could justify going down to 10. As a matter of scale though, 12 is good, as it's a close match to the minimum distance between POI in Pokemon Go.

I initially settled in on 13, but I just tried out 12 for the last week. The performance is, of course, significantly better, but the hexagon sizes are just too large for my use-case. Things get over-crowded fast in any particular area with just a handful of players due to how much real-world space each hex takes up. I'll have to keep prototyping to figure out if the performance issues are insurmountable for me.

Currently when a player is in a real area, my mapping library will pull in 3x3 tiles (with roads, buildings, etc.) to display on their device. That's automatic. I know the locations of the tile corners geographically. I then send queries for each tile to my server.

Same, in my case it's Mapbox.

Oh, so you're using your backed as a tile server for serving up H3 data? I assume the location of the tile corners change based on zoom level though. In that case, you're computing the GeoJSON server-side and returning actual generated vector tiles, or are you sending something else to the client & your mapping lib (ie. an array of indexes the client then handles the generation of)?

I quite like the idea of serving up the hexagons based on the tiles the map is loading, it might save load-time on the client when it comes to first-render (Adding in a ton of GeoJSON in one go is sloowwwww on mobile). Though it might be more expensive (on battery) overall.

Lots of great information here, thanks!!


@sahrk

It might be worth clarifying that each coarser resolution digit represents the hierarchical indexing parent of a cell, which only "contains" the cell if it is the center child. Uber chose to treat the indexing descendants of a cell as a fuzzy version of that cell, and that works for their use case. But you can't assume that if a point lies in a cell at some resolution it will necessarily lie within the coarser indexing ancestors of that cell.

To be clear, this refers to the actual location, not the logical hierarchy right? As far as the index goes, the lower resolution parents will not change as you traverse up (ie. res 12 -> 11 -> 10 -> 9 ...etc)? Since the compress and decompress functions rely on this behavior (If I understand them correctly).


DB Indexing

So, from what I've learned here:

If the H3 indexes are consistent in that manner, then it seems reasonable that the resolutions could be used as trees for indexing purposes. So instead of say, retrieving 50k hexes from a table by key, they could be retrieved using the output of compress. Each tree level corresponds to the res level, the maximum depth is the res the game uses (ie. 12 || 13), and each node has 7 children (representing the 3 bits of that res in the index). It appears that this would be an effective way to get stored values for all hexes in a kring, to avoid re-generating them, without needing to perform any h3ToGeo up front. I haven't touched my DSA's for a while, hope the jist is correct...

Alternatively it could be spatially managed with PostGIS (Haven't touched this yet, will have to play around for a while). Which would probably make retrieving them as map tiles a more efficient process since I could query based off of lat/long bounding boxes for the requested tiles. Though generating vector tiles on the fly might be kinda of expensive to do server-side... Wonder if there is a utility/tool that makes that (And storing/caching the generated tiles) straight forward.

I'll play around and prototype.

Again, thank you for the comments. This is all very helpful.

RichardVasquez commented 3 years ago

Mind if I ask what platform you're building this on (ie. Unity, Web-based, other?)

I'm building this in C# with Unity, and I'm using an asset called Go-Map that pulls in tiles from MapBox along with my h3net translation of H3 into C#.

Oh, so you're using your backed as a tile server for serving up H3 data?

Not quite. I get my tiles from MapBox. I added some functionality to Go-Map that allows me to determine the corners of the tile as its pulled from MapBox. I then send a query to my backend with the tile ID and the corners. I could add an extra layer by just asking if my backend knows about the tile ID, but I decided the extra bandwidth was acceptable.

Step 0: The backend checks the tile ID. If it's already known, it ignores the coordinate information and goes to Step 2.

Step 1: The Tile ID is stored, and the coordinates are mapped to a PostGIS polygon, and a polyfill at res 12 is performed for that tile and stored with the Tile ID being a key for those H3 cells.

Step 2: A query is performed against the polyfill cells (which may have already been determined prior to step 0) for that tile, along with the state cells mentioned earlier. A chunk of data is then collated by the backend and sent back to the client.

Step 3: The client merges the data to create the local hex data for the current polling, including geoboundary information, and hexagons and state are updated for that tile.

I assume the location of the tile corners change based on zoom level though.

True, but I don't deal with changing the zoom level in game. I do some camera manipulation if needed, but my overall scale is just fine for my game needs.

In that case, you're computing the GeoJSON server-side

No, too much work for my server. Using h3net, my client does the hexagon drawing using the GeoBoundary information for that specific H3Index. That way I can assign shaders and perform operations on the H3Index cell as it's its own entity within the game.

dfellis commented 3 years ago

One thing that I have been wondering about in this whole discussion is: why are you calling geoToH3 in a hot loop in the first place?

If you're trying to get hexagons to render on the screen, shoving the coordinates of the viewport into polyfill should be more efficient.

If you're converting known locations to indexes, doing that once in a database of POIs makes much more sense.

I can't imagine it's the user location itself since the GPS does not update frequently enough for that to be a problem.

If it's clicks in an RTS-like game, I can see that, but there you can invert the behavior by storing the results of the viewport polyfill (mapped to individual geometries) into an R-Tree and do the point-in-poly that way, or layer a pixel-like grid on top of that and only compute on the "pixels" that intersect the geometry. That last one fast enough even for Javascript: https://www.npmjs.com/package/in-n-out

douglasg14b commented 3 years ago

@dfellis I'm primarily using h3ToGeo, not geoToH3 (I use that here and there, like the users location) . Without revealing too much, a game mechanic allows players to see 'stuff' within a certain area around them when they perform an action. What is displayed is procedurally generated, and I'm using the center lat/long of each hexagon as the input values for that noise generation.

In this case, almost every hexagon is of interest as each may have terrain features associated with it. My initial goal was to see if there was a cheaper way to get the center lat/long coords of a chunk of indexes rather than calling h3ToGeo for each one individually.

RichardVasquez commented 3 years ago

@douglasg14b In that case, I'd likely recommend doing something similar to what I do.

You've got a tile. Even if you're changing zooms, you're still going to have a tile. It has corners, and can thus be polyfilled. If you change zoom levels, there's functions to find children/parent of a particular hexagon if needed, or polyfill at the appropriate resolution for that new tile's dimensions. Store the resulting hexes in your table, perhaps with tile corners, ID, resolution, or whatever identifying info is needed to make sure you can collect the correct hexes.

In my game, I have ~770 hexes per tile, and each hex could have an unknown amount of effects affecting it from 5 or 6 tiles away that are off screen. It's doable.

sahrk commented 3 years ago

It might be worth clarifying that each coarser resolution digit represents the hierarchical indexing parent of a cell, which only "contains" the cell if it is the center child. Uber chose to treat the indexing descendants of a cell as a fuzzy version of that cell, and that works for their use case. But you can't assume that if a point lies in a cell at some resolution it will necessarily lie within the coarser indexing ancestors of that cell.

To be clear, this refers to the actual location, not the logical hierarchy right? As far as the index goes, the lower resolution parents will not change as you traverse up (ie. res 12 -> 11 -> 10 -> 9 ...etc)?

Correct. Every H3 cell, at every resolution, has a unique and unchanging hierarchical string of digits associated with it. But if you're converting a point location into H3, the string of digits can be completely different (indicating different coarser resolution indexing parents) depending on the H3 resolution you choose to convert into.

dfellis commented 3 years ago

@dfellis I'm primarily using h3ToGeo, not geoToH3 (I use that here and there, like the users location) . Without revealing too much, a game mechanic allows players to see 'stuff' within a certain area around them when they perform an action. What is displayed is procedurally generated, and I'm using the center lat/long of each hexagon as the input values for that noise generation.

In this case, almost every hexagon is of interest as each may have terrain features associated with it. My initial goal was to see if there was a cheaper way to get the center lat/long coords of a chunk of indexes rather than calling h3ToGeo for each one individually.

Then I might recommend using the experimentalH3ToLocalIj and experimentalLocalIjtoH3 functions? They're experimental because they still have issues around pentagons, but if you're at res 12 and bound to land (that the users wouldn't go into oceans in the game) then you can use that to convert to a very simple IJ coordinate system very similar to the Axial Coordinates describe by Redblobgames.

You can compute the quasi-random location of the "stuff" around the player in that grid then convert the values back to H3 indexes to use elsewhere in your game.

Alternatively, you could use the kRing or hexRange functions to get concentric rings of H3 indexes surrounding the H3 index the player is centered on and not worry about pentagons, though your randomizing function would need to work on an (r, theta) style coordinate setup (with r being the concentric rings and theta being how far along you are in each ring, with that itself being a function of r, but uniform with respect to "percentage of the way through each ring's array of H3Indexes" where 0% == 0 radians and 100% == 2pi rads).

This is assuming that you're generating the stuff and its location on-demand. If it's really in fixed locations across users and across time, then pre-computing the index on the server like @RichardVasquez mentions makes the most sense.