Examples » Model viewer

Scene graph, resource manager and model importing.

Image

In this example we will import 3D scene file and display it interactively on the screen. It covers these new features:

Scene graph

In previous examples we managed our scene manually, because there was only one object. However, as object count increases, it's better to have the objects in scene graph. Scene graph maintains three key things:

  • object hierarchy, simplifying memory management
  • object transformations, implemented using various mathematic structures
  • object features, providing rendering and animation capabilities, collision detection, physics etc.

Basically there is one root Scene object and some Object instances. Each object can have some parent and maintains list of its children and features. This hierarchy is used also to simplify memory management — when destroying any object, all its children and features are recursively destroyed too.

Each particular scene graph uses some transformation implementation, which for each object stores transformation (relative to parent) and provides convenience functions for most used transformations, like translation, rotation and scaling. It is also possible to compute absolute transformation or transformation relative to some arbitrary object in the same scene.

Features are added to objects to make them do something useful. The most common feature, which will be also used in this example, is Drawable. When implemented, it allows the object to be drawn on the screen. Each drawable is a part of some DrawableGroup. This group can be then rendered in one shot using the Camera feature that is attached to some object in the same scene. The object controls camera transformation and the camera feature itself controls projection and aspect ratio correction.

Magnum provides scene graph for both 2D and 3D scenes. Their usage is nearly the same and differs only in obvious ways (e.g. perspective projection is not available in 2D).

See also Using scene graph for more information.

Resource manager

In most scenes it is common that one resource (e.g. mesh, shader or texture) is shared by more than one object. One possible approach might be to manage all available resources in main class and then explicitly pass references to them to relevant objects. Sooner or later it would get out of hand and you end up having the main class unnecessarily large and doing much more work than it should do. Asynchronous loading or managing temporary resources, which are used only part of the time, would be also rather difficult.

ResourceManager allows storing of data of defined set of types under specific keys and retrieving them later. The key is stored as 32- or 64-bit integer and in many cases is hashed from some string. The data can be either persistent during the whole lifetime of resource manager or have limited lifetime, managed either by manual freeing of unused data or automatic reference counting.

When a resource is requested from the manager using ResourceManager::get(), it is wrapped in a Resource class. The wrapper takes care of reference counting (so the resource isn't destroyed while it is still in use) and updates the resource if the manager has a new version. The resources can be added to the manager using ResourceManager::set() and you can specify unique storage policies for each single resource.

Setting up, initializing resource manager and scene graph

As we are importing a complete scene, we need quite a lot of things to handle materials, meshes and textures:

#include <Corrade/PluginManager/Manager.h>
#include <Corrade/Utility/Arguments.h>
#include <Magnum/Mesh.h>
#include <Magnum/PixelFormat.h>
#include <Magnum/ResourceManager.h>
#include <Magnum/GL/Buffer.h>
#include <Magnum/GL/DefaultFramebuffer.h>
#include <Magnum/GL/Mesh.h>
#include <Magnum/GL/Renderer.h>
#include <Magnum/GL/Texture.h>
#include <Magnum/GL/TextureFormat.h>
#include <Magnum/MeshTools/Compile.h>
#include <Magnum/Platform/Sdl2Application.h>
#include <Magnum/SceneGraph/Camera.h>
#include <Magnum/SceneGraph/Drawable.h>
#include <Magnum/SceneGraph/MatrixTransformation3D.h>
#include <Magnum/SceneGraph/Scene.h>
#include <Magnum/Shaders/Phong.h>
#include <Magnum/Trade/AbstractImporter.h>
#include <Magnum/Trade/ImageData.h>
#include <Magnum/Trade/MeshData3D.h>
#include <Magnum/Trade/MeshObjectData3D.h>
#include <Magnum/Trade/PhongMaterialData.h>
#include <Magnum/Trade/SceneData.h>
#include <Magnum/Trade/TextureData.h>

Our resource manager will store vertex and index buffers, meshes, textures and shaders for the whole lifetime of our application. During loading we would need to access also material data, but these will be deleted after everything is loaded. We typedef the manager with all the types for easier usage later.

typedef ResourceManager<GL::Buffer, GL::Mesh, GL::Texture2D, Shaders::Phong, Trade::PhongMaterialData> ViewerResourceManager;

For this example we will use scene graph with MatrixTransformation3D transformation implementation. It is a good default choice, if you don't want to be limited in how you transform the objects, but on the other hand it eats up more memory and is slightly slower than for example DualQuaternionTransformation implementation. We typedef the classes too to save us more typing.

typedef SceneGraph::Object<SceneGraph::MatrixTransformation3D> Object3D;
typedef SceneGraph::Scene<SceneGraph::MatrixTransformation3D> Scene3D;

Our main class contains instance of our resource manager (which needs to exist during lifetime of all other objects, thus it is first), scene, group of all drawables, object holding the camera and the camera feature.

class ViewerExample: public Platform::Application {
    public:
        explicit ViewerExample(const Arguments& arguments);

    private:
        void viewportEvent(const Vector2i& size) override;
        void drawEvent() override;
        void mousePressEvent(MouseEvent& event) override;
        void mouseReleaseEvent(MouseEvent& event) override;
        void mouseMoveEvent(MouseMoveEvent& event) override;
        void mouseScrollEvent(MouseScrollEvent& event) override;

        Vector3 positionOnSphere(const Vector2i& _position) const;

        void addObject(Trade::AbstractImporter& importer, Object3D* parent, UnsignedInt i);

        ViewerResourceManager _resourceManager;

        Scene3D _scene;
        Object3D *_o, *_cameraObject;
        SceneGraph::Camera3D* _camera;
        SceneGraph::DrawableGroup3D _drawables;
        Vector3 _previousPosition;
};

In the constructor we first parse command-line arguments using Corrade::Utility::Arguments. It's quite self-sufficient and also provides a help message.

ViewerExample::ViewerExample(const Arguments& arguments):
    Platform::Application{arguments, Configuration{}.setTitle("Magnum Viewer Example")}
{
    Utility::Arguments args;
    args.addArgument("file").setHelp("file", "file to load")
        .setHelp("Loads and displays 3D scene file (such as OpenGEX or "
                 "COLLADA one) provided on command-line.")
        .parse(arguments.argc, arguments.argv);

Then we populate our resource manager with shaders and fallback empty data for materials, textures or meshes that weren't present in the file or couldn't be loaded for some reason.

    /* Phong shader instances */
    _resourceManager
        .set("color", new Shaders::Phong)
        .set("texture", new Shaders::Phong{Shaders::Phong::Flag::DiffuseTexture});

    using namespace Math::Literals;

    /* Fallback material, texture and mesh in case the data are not present or
       cannot be loaded */
    auto material = new Trade::PhongMaterialData{{}, 50.0f};
    material->ambientColor() = 0x000000_rgbf;
    material->diffuseColor() = 0xe5e5e5_rgbf;
    material->specularColor() = 0xffffff_rgbf;
    _resourceManager
        .setFallback(material)
        .setFallback(new GL::Texture2D)
        .setFallback(new GL::Mesh);

Next we setup the camera and renderer features.

    /* Every scene needs a camera */
    (*(_cameraObject = new Object3D{&_scene}))
        .translate(Vector3::zAxis(5.0f));
    (*(_camera = new SceneGraph::Camera3D{*_cameraObject}))
        .setAspectRatioPolicy(SceneGraph::AspectRatioPolicy::Extend)
        .setProjectionMatrix(Matrix4::perspectiveProjection(35.0_degf, 1.0f, 0.01f, 10.0f))
        .setViewport(GL::defaultFramebuffer.viewport().size());
    GL::Renderer::enable(GL::Renderer::Feature::DepthTest);
    GL::Renderer::enable(GL::Renderer::Feature::FaceCulling);

Importing the data

For scene import we are using the AnySceneImporter plugin, which detects format based or file extension and then redirects the actual loading to plugin dedicated for given format. So, for example, if you open scene.ogex, the opening is done through OpenGexImporter, file named mesh.obj is opened using ObjImporter etc. One thing to note is that each file format has slightly different feature set and thus to support greater range of formats, the viewer application must not rely on higher-level features that might not available in each format (such as material support, string data identifiers, scene hierarchy etc.).

We try load and instantiate the plugin and open the file. If any operation fails, the application simply exits. The manager and importer prints message on any error, so it's not needed to repeat it in application code.

    PluginManager::Manager<Trade::AbstractImporter> manager;
    std::unique_ptr<Trade::AbstractImporter> importer = manager.loadAndInstantiate("AnySceneImporter");
    if(!importer) std::exit(1);

    Debug{} << "Opening file" << args.value("file");

    /* Load file */
    if(!importer->openFile(args.value("file")))
        std::exit(4);

First we import all materials, as that's the least involved operation. We check that the material has proper type and put it into the manager with material ID as the key. The manager stores the keys in unique map for each type, so there won't be any conflicts with other types. We also print some progress information about the import to output.

    /* Load all materials */
    for(UnsignedInt i = 0; i != importer->materialCount(); ++i) {
        Debug{} << "Importing material" << i << importer->materialName(i);

        std::unique_ptr<Trade::AbstractMaterialData> materialData = importer->material(i);
        if(!materialData || materialData->type() != Trade::MaterialType::Phong) {
            Warning{} << "Cannot load material, skipping";
            continue;
        }

        /* Save the material */
        _resourceManager.set(ResourceKey{i}, static_cast<Trade::PhongMaterialData*>(materialData.release()));
    }

Next we load all textures. Most scene importers internally use AnyImageImporter for loading images from external files. It is similar to AnySceneImporter, but specialized for image loading, e.g. if the textures references image.png file, it is opened through PngImporter, texture.jpg through JpegImporter etc. The plugins also have aliases, so for example on platforms that don't have libPNG available, dependency-less StbImageImporter can be transparently used in place of PngImporter without changing anything in the loading code. The ultimate goal is that you can deploy different set of plugins for each platform but still use them in platform-independent way, without worrying about which plugin might be available on what system.

The textures and images are also checked for proper format and then put into resource manager with their ID as a key. We'll use ResourcePolicy::Manual for these so we can free unused ones later when importing is done.

    /* Load all textures */
    for(UnsignedInt i = 0; i != importer->textureCount(); ++i) {
        Debug{} << "Importing texture" << i << importer->textureName(i);

        Containers::Optional<Trade::TextureData> textureData = importer->texture(i);
        if(!textureData || textureData->type() != Trade::TextureData::Type::Texture2D) {
            Warning{} << "Cannot load texture, skipping";
            continue;
        }

        Debug{} << "Importing image" << textureData->image() << importer->image2DName(textureData->image());

        Containers::Optional<Trade::ImageData2D> imageData = importer->image2D(textureData->image());
        if(!imageData || imageData->format() != PixelFormat::RGB8Unorm) {
            Warning{} << "Cannot load texture image, skipping";
            continue;
        }

        /* Configure texture */
        auto texture = new GL::Texture2D;
        texture->setMagnificationFilter(textureData->magnificationFilter())
            .setMinificationFilter(textureData->minificationFilter(), textureData->mipmapFilter())
            .setWrapping(textureData->wrapping().xy())
            .setStorage(1, GL::TextureFormat::RGB8, imageData->size())
            .setSubImage(0, {}, *imageData)
            .generateMipmap();

        /* Save it */
        _resourceManager.set(ResourceKey{i}, texture, ResourceDataState::Final, ResourcePolicy::Manual);
    }

Next thing is loading meshes. Because the models might or might not be textured, the mesh might or might not be indexed etc., the mesh creation procedure would get fairly long-winded. There is a convenience MeshTools::compile() function which examines the data, adds all available vertex attributes to the buffer (normals, texture coordinates...), packs the indices (if any) and then configures the mesh for the Shaders::Generic shader, from which all other stock shaders are derived. This function is useful for exactly this case of importing general meshes; but the lower-level way involving MeshTools::interleave() and MeshTools::compressIndices() as explained in the earlier Primitives example is far more flexible if you want data packing, index optimization and other performance-related stuff. The only case that the following code does not handle are meshes without normals (as is common with files in Stanford/PLY format), they would need to be generated to have the mesh displayed with proper lighting.

We put the compiled mesh and buffers into the manager, using string keys for the buffers, because in most cases we need to save two of them for each mesh ID.

    /* Load all meshes */
    for(UnsignedInt i = 0; i != importer->mesh3DCount(); ++i) {
        Debug{} << "Importing mesh" << i << importer->mesh3DName(i);

        Containers::Optional<Trade::MeshData3D> meshData = importer->mesh3D(i);
        if(!meshData || !meshData->hasNormals() || meshData->primitive() != MeshPrimitive::Triangles) {
            Warning{} << "Cannot load mesh, skipping";
            continue;
        }

        /* Compile the mesh */
        GL::Mesh mesh{NoCreate};
        std::unique_ptr<GL::Buffer> buffer, indexBuffer;
        std::tie(mesh, buffer, indexBuffer) = MeshTools::compile(*meshData, GL::BufferUsage::StaticDraw);

        /* Save things */
        _resourceManager.set(ResourceKey{i}, new GL::Mesh{std::move(mesh)}, ResourceDataState::Final, ResourcePolicy::Manual);
        _resourceManager.set(std::to_string(i) + "-vertices", buffer.release(), ResourceDataState::Final, ResourcePolicy::Manual);
        if(indexBuffer)
            _resourceManager.set(std::to_string(i) + "-indices", indexBuffer.release(), ResourceDataState::Final, ResourcePolicy::Manual);
    }

Last reamining part is to populate the actual scene. We create helper object for easier interaction with the scene, which will be parent of all others:

    /* Default object, parent of all (for manipulation) */
    _o = new Object3D{&_scene};

If the format supports scene hierarchy, we recursively import all objects in the scene, if it doesn't (such as OBJ files), we just add a single object with the first imported mesh and put default color-only material on it.

    /* Load the scene */
    if(importer->defaultScene() != -1) {
        Debug{} << "Adding default scene" << importer->sceneName(importer->defaultScene());

        Containers::Optional<Trade::SceneData> sceneData = importer->scene(importer->defaultScene());
        if(!sceneData) {
            Error{} << "Cannot load scene, exiting";
            return;
        }

        /* Recursively add all children */
        for(UnsignedInt objectId: sceneData->children3D())
            addObject(*importer, _o, objectId);

    /* The format has no scene support, display just the first loaded mesh with
       default material and be done with it */
    } else if(_resourceManager.state<GL::Mesh>(ResourceKey{0}) == ResourceState::Final)
        new ColoredObject{ResourceKey{0}, ResourceKey(-1), _o, &_drawables};

The actual function which adds objects into the scene isn't very complex. It just decides about object type based on material features, sets object transformation and then recursively calls itself for child objects. To make the example short enough, only fully colored or diffuse textured objects are supported, but adding support for specular textures etc. is fairly trivial.

void ViewerExample::addObject(Trade::AbstractImporter& importer, Object3D* parent, UnsignedInt i) {
    Debug{} << "Importing object" << i << importer.object3DName(i);

    Object3D* object = nullptr;
    std::unique_ptr<Trade::ObjectData3D> objectData = importer.object3D(i);
    if(!objectData) {
        Error{} << "Cannot import object, skipping";
        return;
    }

    /* Only meshes for now */
    if(objectData->instanceType() == Trade::ObjectInstanceType3D::Mesh) {
        Int materialId = static_cast<Trade::MeshObjectData3D*>(objectData.get())->material();

        /* Decide what object to add based on material type */
        auto materialData = _resourceManager.get<Trade::PhongMaterialData>(ResourceKey(materialId));

        /* Color-only material */
        if(!materialData->flags()) {
            object = new ColoredObject(ResourceKey(objectData->instance()),
                                       ResourceKey(materialId),
                                       parent, &_drawables);
            object->setTransformation(objectData->transformation());

        /* Diffuse texture material */
        } else if(materialData->flags() == Trade::PhongMaterialData::Flag::DiffuseTexture) {
            object = new TexturedObject(ResourceKey(objectData->instance()),
                                        ResourceKey(materialId),
                                        ResourceKey(materialData->diffuseTexture()),
                                        parent, &_drawables);
            object->setTransformation(objectData->transformation());

        /* No other material types are supported yet */
        } else {
            Warning() << "Texture combination of material"
                      << materialId << importer.materialName(materialId)
                      << "is not supported, using default material instead";

            object = new ColoredObject(ResourceKey(objectData->instance()),
                                       ResourceKey(-1),
                                       parent, &_drawables);
            object->setTransformation(objectData->transformation());
        }
    }

    /* Create parent object for children, if it doesn't already exist */
    if(!object && !objectData->children().empty()) object = new Object3D(parent);

    /* Recursively add children */
    for(std::size_t id: objectData->children())
        addObject(importer, object, id);
}

Back in the constructor, when the import is done, we clear all material data (as they were copied into object instances) and free all unreferenced data by calling ResourceManager::free(). Note that we're not doing that for buffers, as they are referenced only internally from GL::Mesh and thus the manager would free them all, causing dangling memory references and crash later. Better solution might be to filter-out the unused data before actually importing them, but that's out of scope of this example.

    /* Materials were consumed by objects and they are not needed anymore. Also
       free all texture/mesh data that weren't referenced by any object. */
    _resourceManager.setFallback<Trade::PhongMaterialData>(nullptr)
        .clear<Trade::PhongMaterialData>()
        .free<GL::Texture2D>()
        .free<GL::Mesh>();
}

Event handling

Viewport and draw events delegate everything to our camera. The camera does proper aspect ratio correction based on viewport size and draws all objects added to drawable group.

void ViewerExample::viewportEvent(const Vector2i& size) {
    GL::defaultFramebuffer.setViewport({{}, size});
    _camera->setViewport(size);
}

void ViewerExample::drawEvent() {
    GL::defaultFramebuffer.clear(GL::FramebufferClear::Color|GL::FramebufferClear::Depth);
    _camera->draw(_drawables);
    swapBuffers();
}

Lastly there is mouse handling to rotate and zoom the scene around, nothing new to talk about.

void ViewerExample::mousePressEvent(MouseEvent& event) {
    if(event.button() == MouseEvent::Button::Left)
        _previousPosition = positionOnSphere(event.position());
}

void ViewerExample::mouseReleaseEvent(MouseEvent& event) {
    if(event.button() == MouseEvent::Button::Left)
        _previousPosition = Vector3();
}

void ViewerExample::mouseScrollEvent(MouseScrollEvent& event) {
    if(!event.offset().y()) return;

    /* Distance to origin */
    Float distance = _cameraObject->transformation().translation().z();

    /* Move 15% of the distance back or forward */
    distance *= 1 - (event.offset().y() > 0 ? 1/0.85f : 0.85f);
    _cameraObject->translate(Vector3::zAxis(distance));

    redraw();
}

Vector3 ViewerExample::positionOnSphere(const Vector2i& position) const {
    Vector2 positionNormalized = Vector2(position*2)/Vector2(_camera->viewport()) - Vector2(1.0f);

    Float length = positionNormalized.length();
    Vector3 result(length > 1.0f ? Vector3(positionNormalized, 0.0f) : Vector3(positionNormalized, 1.0f - length));
    result.y() *= -1.0f;
    return result.normalized();
}

void ViewerExample::mouseMoveEvent(MouseMoveEvent& event) {
    if(!(event.buttons() & MouseMoveEvent::Button::Left)) return;

    Vector3 currentPosition = positionOnSphere(event.position());

    Vector3 axis = Math::cross(_previousPosition, currentPosition);

    if(_previousPosition.length() < 0.001f || axis.length() < 0.001f) return;

    _o->rotate(Math::angle(_previousPosition, currentPosition), axis.normalized());

    _previousPosition = currentPosition;

    redraw();
}

Drawable objects

As explained above, all objects that want to draw something on the screen must have SceneGraph::Drawable feature attached. The most convenient way is to use multiple inheritance, see Object features for more information. Our objects will need mesh and shader resource along with some material definition. The textured object has additional texture resource.

class ColoredObject: public Object3D, SceneGraph::Drawable3D {
    public:
        explicit ColoredObject(ResourceKey meshId, ResourceKey materialId, Object3D* parent, SceneGraph::DrawableGroup3D* group);

    private:
        void draw(const Matrix4& transformationMatrix, SceneGraph::Camera3D& camera) override;

        Resource<GL::Mesh> _mesh;
        Resource<Shaders::Phong> _shader;
        Vector3 _ambientColor,
            _diffuseColor,
            _specularColor;
        Float _shininess;
};

class TexturedObject: public Object3D, SceneGraph::Drawable3D {
    public:
        explicit TexturedObject(ResourceKey meshId, ResourceKey materialId, ResourceKey diffuseTextureId, Object3D* parent, SceneGraph::DrawableGroup3D* group);

    private:
        void draw(const Matrix4& transformationMatrix, SceneGraph::Camera3D& camera) override;

        Resource<GL::Mesh> _mesh;
        Resource<GL::Texture2D> _diffuseTexture;
        Resource<Shaders::Phong> _shader;
        Vector3 _ambientColor,
            _specularColor;
        Float _shininess;
};

In constructor, these two classes setup parent/children hierarchy and attach drawable to drawable group. They then acquire the resources from the manager.

ColoredObject::ColoredObject(ResourceKey meshId, ResourceKey materialId, Object3D* parent, SceneGraph::DrawableGroup3D* group):
    Object3D{parent}, SceneGraph::Drawable3D{*this, group},
    _mesh{ViewerResourceManager::instance().get<GL::Mesh>(meshId)}, _shader{ViewerResourceManager::instance().get<Shaders::Phong>("color")}
{
    auto material = ViewerResourceManager::instance().get<Trade::PhongMaterialData>(materialId);
    _ambientColor = material->ambientColor();
    _diffuseColor = material->diffuseColor();
    _specularColor = material->specularColor();
    _shininess = material->shininess();
}

TexturedObject::TexturedObject(ResourceKey meshId, ResourceKey materialId, ResourceKey diffuseTextureId, Object3D* parent, SceneGraph::DrawableGroup3D* group):
    Object3D{parent}, SceneGraph::Drawable3D{*this, group},
    _mesh{ViewerResourceManager::instance().get<GL::Mesh>(meshId)}, _diffuseTexture{ViewerResourceManager::instance().get<GL::Texture2D>(diffuseTextureId)}, _shader{ViewerResourceManager::instance().get<Shaders::Phong>("texture")}
{
    auto material = ViewerResourceManager::instance().get<Trade::PhongMaterialData>(materialId);
    _ambientColor = material->ambientColor();
    _specularColor = material->specularColor();
    _shininess = material->shininess();
}

Drawing functions have nothing special, just shader preparation and mesh drawing.

void ColoredObject::draw(const Matrix4& transformationMatrix, SceneGraph::Camera3D& camera) {
    _shader->setAmbientColor(_ambientColor)
        .setDiffuseColor(_diffuseColor)
        .setSpecularColor(_specularColor)
        .setShininess(_shininess)
        .setLightPosition(camera.cameraMatrix().transformPoint({-3.0f, 10.0f, 10.0f}))
        .setTransformationMatrix(transformationMatrix)
        .setNormalMatrix(transformationMatrix.rotation())
        .setProjectionMatrix(camera.projectionMatrix());

    _mesh->draw(*_shader);
}

void TexturedObject::draw(const Matrix4& transformationMatrix, SceneGraph::Camera3D& camera) {
    _shader->setAmbientColor(_ambientColor)
        .setSpecularColor(_specularColor)
        .setShininess(_shininess)
        .setLightPosition(camera.cameraMatrix().transformPoint({-3.0f, 10.0f, 10.0f}))
        .setTransformationMatrix(transformationMatrix)
        .setNormalMatrix(transformationMatrix.rotation())
        .setProjectionMatrix(camera.projectionMatrix())
        .bindDiffuseTexture(*_diffuseTexture);

    _mesh->draw(*_shader);
}

Finally, the main function:

MAGNUM_APPLICATION_MAIN(ViewerExample)

Compilation

Compilation is again nothing special:

find_package(Magnum REQUIRED
    GL
    MeshTools
    Shaders
    SceneGraph
    Trade
    Sdl2Application)

set_directory_properties(PROPERTIES CORRADE_USE_PEDANTIC_FLAGS ON)

add_executable(magnum-viewer ViewerExample.cpp)
target_link_libraries(magnum-viewer PRIVATE
    Magnum::Application
    Magnum::GL
    Magnum::Magnum
    Magnum::MeshTools
    Magnum::SceneGraph
    Magnum::Shaders
    Magnum::Trade)

You can experiment by loading scenes of varying complexity and formats or adding light and camera property import. The full file content is linked below. Full source code is also available in the magnum-examples GitHub repository.

The ports branch contains additional patches for Emscripten support that aren't present in master in order to keep the example code as simple as possible.