The Skyquad

A skyquad is to a quad what a skybox is to a box. That is, a skyquad is a method of rendering a sky by sending just a single quad down the pipeline. The skyquad as a concept is useful not only in OpenGL, but also in ray tracing, where all we have is a bunch of rays passing through an image plane. In fact, I derived the skyquad from the more general ray equation of my path tracer, so I will start with that first.

The Ray Equation

In a ray tracer, we have a camera and a window into the world - the to-be-rendered-image - and the goal is to determine the ray passing through each of the pixels of the image. This is depicted in the following figure:

The problem of determining a ray through a given pixel can be summarised as follows, using the same notation as in the figure:

Input:

  • $\vec{u}$, $\vec{r}$, and $\vec{f}$ - the camera's up, right, and forward vectors, respectively.
  • $\vec{o}$ - the camera's position.
  • fovy - the camera's vertical field of view angle.
  • $(w,h)$ - the width and height of the image, respectively.
  • $(x,y)$ - the coordinates of the target point in the image, ranging from $(0,0)$ to $(w,h)$.

Output:

  • The ray $r$ originating at $\vec{o}$ and passing through $(x,y)$.

In addition, we will use the following conventions:

  • $(0,0)$ corresponds to the bottom-left corner of the image.
  • $(w,h)$ corresponds to the top-right corner of the image.

Note that $(x,y)$ need not correspond with the integer coordinates of a pixel; it can be any real point on the image plane.

Determining the distance to the image plane

The first step is determining the distance $d$ from the camera to the image plane. The following figure offers a simplified view of the problem for this particular purpose:

From the figure, we can see that:

Solving for $d$ yields:

Deriving the ray equation

Next, from the first figure, we can observe that the center of the image is determined by:

To reach out the target point $(x,y)$ from $\vec{c}$, we offset $\vec{c}$ by a delta. We push $\vec{c}$ $d_x$ units along the camera's right vector $\vec{r}$, and $d_y$ units along the camera's up vector $\vec{u}$:

Deriving $d_x$ and $d_y$ is straightforward. When $x=0$, $d_x=-w/2$, and when $x=w$, $d_x=w/2$. The case for $d_y$ is similar. This results in:

The vector from the camera position $\vec{o}$ to the point $(x,y)$ on the image plane is then given by:

Finally, the ray equation is $r = \vec{o} + \lambda\vec{t}$, where $\vec{t}$ is conveniently defined as $\vec{v}$ normalised: $\vec{t} = \frac{\vec{v}}{||\vec{v}||}$.

The Skyquad in OpenGL

To draw the skyquad, we render a quad directly in NDC space [1], with $z=1$ to force it to appear in the background. For each pixel in the quad, we compute the ray originating from the camera and passing through that pixel. We then use the ray's direction to sample a cubemap texture holding the skybox data, and the fetched texel determines the colour value that we finally assign to that pixel.

To simplify our calculations, we compute the ray direction in NDC space and then apply the viewport's aspect ratio $r$ and the camera's rotation matrix $R$ to transform it into world space. NDC space has many interesting properties that make our life remarkably easier:

  • $\vec{u} = (0,1,0)$, $\vec{r} = (1,0,0)$, and $\vec{f} = (0,0,-1)$, assuming a right-handed coordinate system.
  • $\vec{o} = \vec{0}$
  • $d$ = 1 (we set $z=1$ for the quad that we render)
  • $(w,h) = (1,1)$

With the above properties in mind, the distance to the image plane $d$ becomes:

The ray direction vector in NDC space boils down to:

Next, apply the viewport's aspect ratio $r$ to properly scale the ray:

And finally, normalise the vector and apply the camera's rotation matrix $R$ to obtain the direction in world space:

To render the skyquad, we send a 2d quad with coordinates ranging from $(-1,-1)$ to $(1,1)$ down the pipeline with the shader program that follows. Note that we need not compute a ray per pixel; instead, we compute a ray for every vertex of the quad and let the pipeline interpolate them:

Vertex shader

uniform mat3 Rotation;
uniform float fovy;
uniform float aspect;

layout (location = 0) in vec2 Position;

out vec3 Ray;

vec3 skyRay (vec2 Texcoord)
{
    float d = 0.5 / tan(fovy/2.0);
    return normalize(vec3((Texcoord.x - 0.5) * aspect, Texcoord.y - 0.5, -d));
}

void main ()
{
    Ray = Rotation * skyRay(Position*0.5 + 0.5); // map from [-1,1] to [0,1]
    gl_Position = vec4(Position, 0.0, 1.0);
}

Fragment shader

uniform samplerCube tex;

in vec3 Ray;

layout (location = 0) out vec4 Colour;

void main ()
{
    vec3 R = normalize(Ray);
    Colour = vec4(pow(texture(tex, R).rgb, vec3(1.0/2.2)), 1.0);
}

Certainly a lot of maths for such a simple shader! Fortunately they did pay off, and we have a general solution that works for both ray tracing and OpenGL.

And as always, the mandatory screenshot:

[1] Technically it is clip space, but since we set $w=1$ in the shader then the perspective division leaves the coordinates unchanged.