Skip to content

Infinite ground plane using GLSL shaders

Martin edited this page Oct 21, 2018 · 2 revisions

A simple ground plane is used by almost all modeling programs. The simplest approach uses a single polygon passed through to represent a ground plane, with a texture applied to add a sense of scale.

In this tutorial, we will explore an alternative method using a single vieport quad. Compared to the trivial implementation, this approach does not require the scale of the scene (and of the ground polygon) to be determined beforehand, effectively synthesizing an "infinite" ground plane.

A background fragment shader

Following the initial steps of the Skybox tutorial, we will start with a basic shader setup, utilizing the render/vertex_data/background node. Instantiating all relevant nodes, and connecting them accordingly, leads to the following setup:

alt text

Currently, the plane is in the world space, and it is white. Let's add a simple checker pattern to allow us to see what is going on in our scene. First, let's replace the vertex shader with a shader that passes through the P attribute unchanged:

#version 130

in vec3 P;                     // position attr from the vbo

uniform mat4 iProjection;      // projection matrix
uniform mat4 iModelView;       // modelview matrix

out vec3 vertexPosition;       // vertex position for the fragment shader

void main() {
	vertexPosition = P.xyz;
	gl_Position = iProjection * iModelView * vec4(P, 1);
}

We can then use round() function to produce a simple checkerboard patter in the fragment shader:

#version 130

out vec4 color;

in vec3 vertexPosition;

void main() {
	float c = (
		int(round(vertexPosition.x * 5.0)) +
		int(round(vertexPosition.y * 5.0))
	) % 2;

	color = vec4(vec3(c/2.0 + 0.3), 1);
}

Which adds a checkerboard pattern to our plane:

alt text

Finally, to make our shader into a "background shader", we need to alter the vertex shader to remove any modelview or projective transformations, effectively moving our plane into clip coordinates:

#version 130

in vec3 P;                     // position attr from the vbo
out vec3 vertexPosition;       // vertex position for the fragment shader

void main() {
	vertexPosition = P.xyz;
	gl_Position = vec4(P, 1);
}

The resulting checkerboard image covers the whole camera view, and does not react to camera movement.

alt text

Ground plane intersection

The render/vertex_data/background_shader node provides three per-vertex data - P is the world-space position; the iNearPositionVert and iFarPositionVert provide the same information, but transformed using gluUnProject() function, effectively remapping the near and far planes from camera space to world space (the Skybox tutorial contains more details about this).

By using these in the fragment shader, we obtain per-pixel world space position values on the near and far plane (Pnear and Pfar respectively). We can then describe a per-pixel camera ray using a parametric equation of a line with parameter t:

The ray intersects the ground when Y component of ray R equals zero:

Which leads to:

When t > 0, the ray intersected the ground in front of the camera - the ground should be rendered. When t ≤ 0, the ground intersection would be behind the camera - the ray is aiming towards the sky.

In practice, we need to first modify the vertex shader to pass through the near and far plane positions:

#version 130

in vec3 P;

in vec3 iNearPositionVert;
in vec3 iFarPositionVert;

out vec3 vertexPosition;
out vec3 near;
out vec3 far;

void main() {
	vertexPosition = P;
	near = iNearPositionVert;
	far = iFarPositionVert;

	gl_Position = vec4(P, 1);
}

Using the near and far values, we can then compute t, and use it to distinguish the intersection with the ground:

#version 130

out vec4 color;

in vec3 vertexPosition;
in vec3 near;
in vec3 far;

void main() {
	float c = (
		int(round(vertexPosition.x * 5.0)) +
		int(round(vertexPosition.y * 5.0))
	) % 2;

	float t = -near.y / (far.y-near.y);

	color = vec4(vec3(c/2.0 + 0.3), 1);
	color = color * float(t > 0);
}

This leads to our fragment shader showing the grid pattern only when we're intersecting with the ground:

alt text

Ground plane checkerboard

Using parameter t computed in the previous section, we can compute the 3D position of the ground intersection using the original equation of a line:

This allows us to compute Rx and Rz, which can then be used to create checkerboard pattern placed on the ground:

#version 130

out vec4 color;

in vec3 vertexPosition;
in vec3 near;
in vec3 far;

void main() {
	float t = -near.y / (far.y-near.y);

	vec3 R = near + t * (far-near);
	float c = (
		int(round(R.x * 5.0)) +
		int(round(R.z * 5.0))
	) % 2;

	color = vec4(vec3(c/2.0 + 0.3), 1) * float(t > 0);
}

This leads to checkerboard pattern on the ground, stretching all the way to infinity:

alt text

We can improve on the pattern by adding multiple resolutions:

#version 130

out vec4 color;

in vec3 vertexPosition;
in vec3 near;
in vec3 far;

float checkerboard(vec2 R, float scale) {
	return float((
		int(floor(R.x / scale)) +
		int(floor(R.y / scale))
	) % 2);
}

void main() {
	float t = -near.y / (far.y-near.y);

	vec3 R = near + t * (far-near);

	float c =
		checkerboard(R.xz, 1) * 0.3 +
		checkerboard(R.xz, 10) * 0.2 +
		checkerboard(R.xz, 100) * 0.1 +
		0.1;

	color = vec4(vec3(c/2.0 + 0.3), 1) * float(t > 0);
}

Leading to an infinite ground with multiple resolution of grids:

alt text

Spotlight

Unfortunately, this simple approach leads to severe aliasing artifacts. A simple way to address these is to fade the pattern to zero, creating a simple "spotlight" effect:

#version 130

out vec4 color;

in vec3 vertexPosition;
in vec3 near;
in vec3 far;

float checkerboard(vec2 R, float scale) {
	return float((
		int(floor(R.x / scale)) +
		int(floor(R.y / scale))
	) % 2);
}

void main() {
	float t = -near.y / (far.y-near.y);

	vec3 R = near + t * (far-near);

	float c =
		checkerboard(R.xz, 1) * 0.3 +
		checkerboard(R.xz, 10) * 0.2 +
		checkerboard(R.xz, 100) * 0.1 +
		0.1;
	c = c * float(t > 0);

	float spotlight = min(1.0, 1.5 - 0.02*length(R.xz));

	color = vec4(vec3(c*spotlight), 1);
}

Leading to a procedural infinite ground plane result with the "spotlight" effect:

alt text

Depth handling

To test how this setup behaves with multiple object in the scene, let's create a teapot via opengl/simple toolbar item:

alt text

It looks like the teapot is fully visible, even though at least half of it should be hidden by the ground!

To fix this, we need out fragment shader to output a correct per-fragment depth value using gl_FragDepth function, instead of relying on the fixed-functionality "early" depth test. The depth value needs to be represented in clip coordinates, and converted to the range contained in the gl_DepthRange uniform:

// computes Z-buffer depth value, and converts the range.
float computeDepth(vec3 pos) {
	// get the clip-space coordinates
	vec4 clip_space_pos = iProjection * iModelView * vec4(pos.xyz, 1.0);

	// get the depth value in normalized device coordinates
	float clip_space_depth = clip_space_pos.z / clip_space_pos.w;

	// and compute the range based on gl_DepthRange settings (not necessary with default settings, but left for completeness)
	float far = gl_DepthRange.far;
	float near = gl_DepthRange.near;

	float depth = (((far-near) * clip_space_depth) + near + far) / 2.0;

	// and return the result
	return depth;
}

For further explanation, please have a look at this stack overflow post.

Plugging this to the fragment shader source:

#version 130

out vec4 color;

in vec3 vertexPosition;
in vec3 near;
in vec3 far;

uniform mat4 iProjection;
uniform mat4 iModelView;

float checkerboard(vec2 R, float scale) {
	return float((
		int(floor(R.x / scale)) +
		int(floor(R.y / scale))
	) % 2);
}

float computeDepth(vec3 pos) {
	vec4 clip_space_pos = iProjection * iModelView * vec4(pos.xyz, 1.0);
	float clip_space_depth = clip_space_pos.z / clip_space_pos.w;

	float far = gl_DepthRange.far;
	float near = gl_DepthRange.near;

	float depth = (((far-near) * clip_space_depth) + near + far) / 2.0;

	return depth;
}

void main() {
	float t = -near.y / (far.y-near.y);

	vec3 R = near + t * (far-near);

	float c =
		checkerboard(R.xz, 1) * 0.3 +
		checkerboard(R.xz, 10) * 0.2 +
		checkerboard(R.xz, 100) * 0.1 +
		0.1;
	c = c * float(t > 0);

	float spotlight = min(1.0, 1.5 - 0.02*length(R.xz));

	color = vec4(vec3(c*spotlight), 1);

	gl_FragDepth = computeDepth(R);
}

This leads to a setup with correct depth handling, with our teapot half-submerged in the ground plane:

alt text