Let's start by going from the [0,1]x[0,1] UV domain to a complete unit octahedron, running from -1 to 1 on each axis:
float3 UVtoOctahedron(float2 uv) { // Unpack the 0...1 range to the -1...1 unit square. float3 position = float3(2.0f * (uv - 0.5f), 0); // "Lift" the middle of the square to +1 z, and let it fall off linearly // to z = 0 along the Manhattan metric diamond (absolute.x + absolute.y == 1), // and to z = -1 at the corners where position.x and .y are both = +-1. float2 absolute = abs(position.xy); position.z = 1.0f - absolute.x - absolute.y; // "Tuck in" the corners by reflecting the xy position along the line y = 1 - x // (in quadrant 1), and its mirrored image in the other quadrants. if(position.z < 0) { position.xy = sign(position.xy) * float2(1.0f - absolute.y, 1.0f - absolute.x); } return position; }
We can of course normalize the position to "puff it out" to a unit sphere.
Now that we have the full unit octahedron, it's simple to get just the top pyramid: we just map our UV quad to just the inner diamond \$|x| + |y| \leq 1\$, then do the same lift as before:

Here the origin of our UV space sits at the bottom of the unit diamond (0, -1). Moving a distance of 1 along the U direction brings us up & right (+1, +1) to arrive at the right corner. And moving a distance of 1 along the V direction takes us up & left (-1, +1). I fold the addition of the origin, U, and V components into the calculation of position below:
float3 UVtoPyramid(float2 uv) { float3 position = float3( 0.0f + (uv.x - uv.y), -1.0f + (uv.x + uv.y), 0.0f ); float2 absolute = abs(position.xy); position.z = 1.0f - absolute.x - absolute.y; // No need for the final "tuck in" fold since we're skipping the bottom half. return position; }
And again, this can be normalized to "puff out" to a rounded hemisphere.
Now, mapping from a 3D direction to 2D is just a matter of inverting the operation. Let's say we have a (unit) vector pointing out from our imposter position toward the viewer. To place that in UV space we can...
float2 OctahedronUV(float3 direction) { float3 octant = sign(direction); // Scale the vector so |x| + |y| + |z| = 1 (surface of octahedron). float sum = dot(direction, octant); float3 octahedron = direction / sum; // "Untuck" the corners using the same reflection across the diagonal as before. // (A reflection is its own inverse transformation). if(octahedron.z < 0) { float3 absolute = abs(octahedron); octahedron.xy = octant.xy * float2(1.0f - absolute.y, 1.0f - absolute.x); } return octahedron.xy * 0.5f + 0.5f; }
And again, chopping down to just the top pyramid if we have only a hemisphere to work with...
float2 PyramidUV(float3 direction) { float3 octant = sign(direction); float sum = dot(direction, octant); float3 octahedron = direction / sum; return 0.5f * float2( 1.0f + octahedron.x + octahedron.y, 1.0f + octahedron.y - octahedron.x ); }
Here our last step is to transform our xy space so its inner unit diamond maps to the [0,1]x[0,1] UV square, and the unused triangles hang off the edges, like so:

Here the xy origin maps to the center of the UV space (0.5, 0.5), and moving a distance of 1 along either the x or y moves us 0.5 along each of U & V.
Now that you have your point in UV space, you can round it to your desired increments to snap to the closest grid intersection.