Hello guys and welcome to the 27th tutorial of my OpenGL4 series! This tutorial is being written during the Easter holidays, which I would normally spent with rest of my family, but because Corona is still raging out there as of April 2021 and we are in kind of a lockdown with restricted options for travel, I have plenty of time to write an article about the occlusion query. So let's go !
Occlusion query is a simple in nature, yet very powerful technique to determine if an object is visible in the scene and thus if it's worth rendering it whatsoever. When you have complex geometry with many triangles, but it's not visible by camera and present in the scene at all, why would we bother rendering it and wasting precious computational resources? And here occlusion query comes to rescue and helps us to easily determine, if the object is visible or not!
The main idea is to render object's occluder - a simple object like bounding box, that isn't expensive to render, but covers the whole object. We don't actually render the occluder so that it's visible, we only ask OpenGL - if I render this occluder (bounding box), would it be visible in the scene? Even if it only occupies one single pixel? And that's the main idea - you ask OpenGL, how many pixels would be affected during this rendering. If more than zero, there is a high chance that object that is being occluded is visible and thus we should better render it. By the way, the word for object being occluded (complex geometry object) is occludee. I guess . English isn't my native language, but I have gut feeling that it's the right word .
As usual, I try to wrap this functionality into higher level classes, so let's have a look at my OcclusionQuery class .
Below you can see the header file with class and the methods that the class provides:
class OcclusionQuery
{
public:
OcclusionQuery();
~OcclusionQuery();
/**
* Begins occlusion query. Until the query is ended, samples
* that pass the rendering pipeline are counted.
*/
void beginQuery() const;
/**
* Ends occlusion query and caches the result - number
* of samples that passed the rendering pipeline.
*/
void endQuery();
/**
* Gets number of samples that have passed the rendering pipeline.
*/
GLint getNumSamplesPassed() const;
/**
* Helper method that returns if any samples have passed the rendering pipeline.
*/
bool anySamplesPassed() const;
private:
GLuint queryID_{ 0 }; // OpenGL query object ID
GLint samplesPassed_{ 0 }; // Number of samples passed in last query
};
As you see, it provides 4 functions. I even left the code documentation for better understading, but I will still explain methods here in more detail:
Now let's discuss how exactly is it implemented. It's actually pretty simple - first of all, we have to create a query object, that can be used to perfom occlusion query. This is done in the constructor of OcclusionQuery class:
OcclusionQuery::OcclusionQuery()
{
glGenQueries(1, &queryID_);
std::cout << "Created occlusion query with ID " << queryID_ << std::endl;
}
Now that we have query object, we can implement begin/end of occlusion query. These functions utilize following three OpenGL functions:
Maybe you remember that exactly those functions were also used in the 025.) Transform Feedback Particle System tutorial, but there we queried number of primitives written during the transform feedback instead .
Putting everything together in the code, our methods look like this:
void OcclusionQuery::beginQuery() const
{
glBeginQuery(GL_SAMPLES_PASSED, queryID_);
}
void OcclusionQuery::endQuery()
{
glEndQuery(GL_SAMPLES_PASSED);
glGetQueryObjectiv(queryID_, GL_QUERY_RESULT, &samplesPassed_);
}
GLint OcclusionQuery::getNumSamplesPassed() const
{
return samplesPassed_;
}
bool OcclusionQuery::anySamplesPassed() const
{
return samplesPassed_ > 0;
}
As you might see, the endQuery function not only ends the query, but also retrieves query result, that is how many pixels were affected during the query. The result is cached into the samplesPassed_ member variable and the last two getter functions just work with that variable and its value .
And that's the OcclusionQuery class! Let's see now, how exactly can we utilize it!
In this tutorial, I have decided to create an additional class called ObjectsWithOccluderManager, which as the name suggests manages objects with occluders . I won't show whole code of this class here, I will just explain main idea that is implemented. This class generates new objects - torus, sphere or cylinder - at random positions in the world. These objects then fly up to the sky and eventually are destroyed, when they fly too high up. The occlusion query is here implemented in two passes - in first pass, objects are just updated (new ones are generated, their position is updated) and the occlusion query is performed. That means that only the occluders - bounding boxes - are rendered and the result of the query is stored for each object - simple boolean information if the object is visible .
What's very important when rendering occluders is, that we should disable writing to the color buffer and depth buffer during the process. We just really want to know how many samples (pixels) have passed through the whole rendering pipeline, but we don't want to render anything at all actually! This is done using the glDepthMask and glColorMask pair of functions. The code that utilizes occlusion query with disabling of writing to color buffer and depth buffer follows:
void ObjectsWithOccludersManager::updateAndPerformOcclusionQuery(float deltaTime)
{
if (timePassedSinceLastGeneration_ > GENERATE_OBJECT_EVERY_SECONDS)
{
timePassedSinceLastGeneration_ -= GENERATE_OBJECT_EVERY_SECONDS;
const auto randomIndex = Random::nextInt(static_cast(meshes_.size()));
const ObjectWithOccluder object{ Random::getRandomVectorFromRectangleXZ(glm::vec3(-150.0f, -10.0f, -150.0f), glm::vec3(150.0f, -10.0f, 150.0f)), meshes_[randomIndex].get(), occlusionBoxSizes_[randomIndex], true };
objects_.push_back(object);
}
timePassedSinceLastGeneration_ += deltaTime;
const auto& mm = MatrixManager::getInstance();
auto& singleColorShaderProgram = ShaderProgramManager::getInstance().getShaderProgram("single-color");
singleColorShaderProgram.useProgram();
singleColorShaderProgram[ShaderConstants::projectionMatrix()] = mm.getProjectionMatrix();
singleColorShaderProgram[ShaderConstants::viewMatrix()] = mm.getViewMatrix();
singleColorShaderProgram[ShaderConstants::color()] = glm::vec4(1.0f, 0.0f, 0.0f, 0.4f);
glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);
glDepthMask(GL_FALSE);
size_t i = 0;
numVisibleObjects_ = 0;
while (i < objects_.size())
{
auto& object = objects_[i];
if (object.position.y > 100.0f)
{
std::swap(objects_[i], objects_[objects_.size() - 1]);
objects_.pop_back();
continue;
}
object.position.y += 10.0f * deltaTime;
auto modelMatrix = glm::translate(glm::mat4(1.0f), object.position);
modelMatrix = glm::scale(modelMatrix, object.occlusionBoxSize);
singleColorShaderProgram[ShaderConstants::modelMatrix()] = modelMatrix;
occlusionQuery_->beginQuery();
occluderCube_->render();
occlusionQuery_->endQuery();
object.isVisible = occlusionQuery_->anySamplesPassed();
if (object.isVisible)
{
numVisibleObjects_++;
}
i++;
}
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
glDepthMask(GL_TRUE);
}
Notice, that the occluders are rendered by simplest means possible - simple shader program that outputs single color, so that no extra computation is done. In the second pass, objects are rendered, but only those, that are visible and have passed the occlusion query! This spares us some computational power .
void ObjectsWithOccludersManager::renderAllVisibleObjects()
{
const auto& tm = TextureManager::getInstance();
auto& mainProgram = ShaderProgramManager::getInstance().getShaderProgram("main");
mainProgram.useProgram();
for (auto& object : objects_)
{
if (!object.isVisible)
{
continue;
}
auto modelMatrix = glm::translate(glm::mat4(1.0f), object.position);
mainProgram.setModelAndNormalMatrix(modelMatrix);
if (dynamic_cast(object.meshPtr))
{
dimMaterial_.setUniform(mainProgram, ShaderConstants::material());
tm.getTexture("crate").bind();
}
else if (dynamic_cast(object.meshPtr))
{
shinyMaterial_.setUniform(mainProgram, ShaderConstants::material());
tm.getTexture("white_marble").bind();
}
else if (dynamic_cast(object.meshPtr))
{
shinyMaterial_.setUniform(mainProgram, ShaderConstants::material());
tm.getTexture("scifi_metal").bind();
}
object.meshPtr->render();
}
}
Keep in mind however, that occlusion query isn't free either! It also costs us some computational power and in general, such queries are not as performant as I thought they are. So as with everything, occlusion query should be used wisely, then it can really bring some added value . There are also some other techniques for detection of non-visible objects, I will definitely cover them one day in some tutorial (e.g. frustum culling).
And here you can see the results of today's effort:
The red boxes shown in the picture are just rendered occluders. You can switch it off of course, but it does help to actually understand the idea of rendering occluder first and rendering the object only if its occluder is visible .
I think this was again one of the easier tutorials, so next time I should come with something more complex. Until then, enjoy this and I really hope that you have learned a thing or two from this tutorial .
Download 3.61 MB (561 downloads)