3
\$\begingroup\$

I'm facing some issues understanding this article about octahedral impostors.

More specifically I quite don't understand how one can map the hemi-octahedron (subdivided) vertices to texture coordinates.

Quote from the article :

"For those not familiar with octahedra, they are a convenient way to convert between 2D and 3D space, or vice versa."

This two images help visualize the transformation:

Animated example of transformation

Here we can see an animated transition from a tesselated quad mesh to a hemisphere or a full sphere.

Imposter layout showing a view associated with each vertex

Here we see a view of the model to be imitated associated with each vertex of the mesh, giving coverage of viewpoints around the hemisphere: side views around the perimeter of the square, and top-down views near the center.

I don't understand the math used to perform this mapping from the square domain of the quad to the hemisphere or sphere of directions around the model.

This article also says that we can easily map 3D to 2D uvs, without explaining how or why. I found a few research papers about octahedron and normals compression but it is quite a different problem and honestly I didn't really get the grasp of them.

Could someone explain to me how to map a vertex expressed in 3D (ie impostor camera position) from the octahedron to a plain 2D texture?

The goal is to be able to cast a ray to the octahedron and map the hit point coordinates to 2D uvs, or find the nearest vertex and render the texture associated with it.

\$\endgroup\$
3
\$\begingroup\$

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:

Diagram showing the mapping from uv to position xy

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:

Diagram of mapping from xy position back to UV

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.

\$\endgroup\$
  • \$\begingroup\$ I accepted your answer because it is very clear and well detailed. There is only one part I quite don't get yet. When you map the unit quad (UV) to the inner diamond |x| + |y| <= 1. I don't quite understand the operations you use when you set the float3 position. They are probably related to the equation but can't figure it out. The rest is crystal clear, could you just detail a bit more on that ? \$\endgroup\$ – Wafflesys Mar 31 at 11:56

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.