Coercing Assimp into reading OBJ PBR materials

These days, I’m trying to import 3D models exported as Wavefront OBJ files in my Vulkan renderer. In order to achieve that effortlessly (kinda), I’m using the very good Assimp (pun not intended) library.

It kinda works well if you stick to a « traditional Blinn-Phong » lighting pipeline (also known as « prehistoric lighting » 😉 ), with ambient/diffuse/specular components and so on. Everything is there. Things become trickier if you try to bring PBR into the mix, with roughness, metallic and ambient occlusion maps for example.

What parameters should you use in Assimp to read those values ? What texture type should be used in order to import those maps ? Turns out it’s not as easy as one would think, because support on both the Wavefront OBJ specification and Assimp is just not there.

So I had to cheat a little, let me explain how !

First of all, a quick tour of all the links that were helpful in this research :

But first, a somewhat lengthy introduction

As you probably already now if you’re reading this, Wavefront OBJ is a plain text file format used to describe 3D geometry. Despite what the name maybe implies, there may be more than a single object in an OBJ file, actually, it can store whole scenes in it ! That’s pretty powerful.

But wait, there’s more ! The file format also allows you to specify material parameters that a rendering application should use in order to control the visual appearance of the object and make sure it stays the same from one app to another (say, from Blender to a game engine for example).

These material parameters are all stored in dedicated files usually accompanying the obj file, called MTL files (with .mtl extension). They use their own syntax to describe a wide range of properties that you can use to achieve the exact same visual effect than the artist creating the original mesh was expecting.

I’m making a custom renderer, and I found it trickier than expected to author shaders that actually honor all the requirements of the material file because

  • first, there are so many of them !
  • second, the entanglement of a lot of responsibilities for the code doing the actual import makes it kind of a conundrum.

Because according to me, you typically have three options :

  1. You have pre-made, custom shaders, and you only want to extract the parameters your shader can possibly be interested in (this is my approach). You can mix and match every single set of possible features, but the problem is that you get a lot of permutations as the number of features increases.
  2. You rely entirely on the obj file to dictate the shader parameters, which in turn means you have to use some procedural shader generation technology to create code that will match the material requirements (that’s hard !)
  3. You have some kind of « uber shader » that can manage absolutely ALL the possible parameters you could possibly want to use in your application. It quickly becomes unwieldy as the number of features increase, and you probably have to create something like the second point to manage « uber shader creation » and procedural enabling/disabling of features.

So there is no silver bullet. In the software pipeline, whom should have the responsibility of shader creation and management ? The asset importer or some other form of shader manager ? At the end of the day, you decide. Assimp is just a tool. I decided to go with the first option, which means I have a kind of flexible shader system that I can plug « modules » to, so I only use the parameters that are specified. It also means my Assimp importer is coerced into only importing specific parameters. Two-sidedness or index of refraction for example are not managed. Perhaps later.

Extracting material parameters

Material parameters are actually not that hard to get with Assimp. It’s pretty straightforward and maps pretty well with what can be found in the MTL file specification. For example, let’s say I have this mtl file :

newmtl Scene
Ns 225.000000
Ka 1.000000 1.000000 1.000000
Kd 0.800000 0.800000 0.800000
Ks 0.500000 0.500000 0.500000
Ke 1.0 2.0 3.0
Ni 1.450000
d 0.500000

So in this example, we have reflectivity coefficient parameters.

  • Ambient is Ka
  • Diffuse is Kd
  • Specular is Ks
  • Emissive is Ke.
  • You can also see that we have :
  • Ns, a [0;1000] ranged value which is the specular exponent (also known as « shininess » in traditional Blinn-Phong fashion)
  • d, a [0;1] ranged value that stands for « dissolve », also known as opacity. A value of 1 means fully opaque.
  • Finally, but I’m not using it right now, you also have Ni, which is a knight’s battlecry the index of refraction for light refractive materials (perhaps later).

There are a lot more, and everything is very well described in the MTL specification linked earlier. But I actually never intended to manage them all. Managing these will be enough work for now.

Let us read some sample code to extract those values (this is C++) :

It’s pretty straightforward. Accessing a material parameter with Assimp is done by using a set of Assimp macros always starting by AI_MATKEY_, and then following what you want. Everything is described in the Assimp Material System doc. (The online documentation is, at the time of writing, actually out-of-date — 2012! — since most aiColor3D parameters became aiColor4D, but that’s an easy fix).

Then, it’s up to you to use the proper getter function depending on what is the type of the parameter (int, float, vec4…). Here, I used a lambda to factorize initialization of different vec4 parameters using the same logic. assimpMat is an aiMaterial pointer retrieved from the aiScene array of materials

The careful reader will notice a funny detail : why the « auto… » variadic parameter pack in the lambda ?! The reason is that contrary to what you could expect, the Assimp macros actually expand to a set of 3 comma-separated parameters, so it’s not a single value. I could also have just written the types in plain code. But since their types are always the same anyway, it’s not going to generate code bloat, because it will reuse the same lambda every time.

The logic is exactly the same for, say, single float parameters :

Extracting material textures

Texture maps are trickier because, contrary to value parameters, there aren’t actually that much map types « officially supported » by the original OBJ file format. Moreover, none of the more « modern » lighting map types (roughness, metallic, AO) are explicitly supported. That’s why some of them get « piggybacked » (as we’ll see) to support map types we actually want. And Assimp sometimes gets in the way by doing weird stuff too.

First, let’s get the obvious ones out of the way :

  • The diffuse map (also known as albedo in PBR parlance) will be specified in map_Kd
  • The specular map will be specified in map_Ks
  • The emissive map will be specified in map_Ke.

And… that’s mostly it. The rest is workarounds or kind of hacks to make the system work the way we want :

Normal maps

Welcome to the land of confusion.

The obj file format does not officially have a map type for tangent-space normal maps (the famously « blue-ish » textures that you find pretty much everywhere nowadays). However, it has support for bump maps, which are kind of the ancestor of normal maps, with the keyword « bump » (Assimp also supports map_Bump).

For some reasons that I don’t fully understand (not being much of a 3D artist myself), the normal map of an object is often exported in the obj file as a bump map by DCC tools, which makes it a kind of de facto standard to actually read the bump map of a material as a normal map. But, be aware of two things :

  • on the Assimp side of things, a normal map imported as a bump map will actually have a texture type of aiTextureType_HEIGHT
  • Assimp manages the (unofficial) keyword « map_Kn » that will read the texture with the correct type aiTextureType_NORMALS. Thus, use map_Kn whenever you can with Assimp.

I’m not using height maps so far in my application, but I’m thinking about going with a logic like :

  • When I find an aiTextureType_NORMALS map : This is the normal map
  • When I find an aiTextureType_HEIGHT map : if there is no aiTextureType_NORMALS map and the model vertices have normals : this is actually a normal map
  • else : this is a height map.

So what if we wanna have both a height map and normal mapping, or a height map with normals, but without normal mapping ? Well sometimes you cannot have your cake and eat it too… Using map_Kn will probably be necessary at some point, which is kind of a pain if DCC tools aren’t using it by default.

Roughness, metallic and AO

The confusion continues. OBJ file format actually does not have any map type for roughness, metallic and AO maps, which are standard in a PBR pipeline.

The reason why is that the OBJ file format was designed when Blinn-Phong lighting was still hot stuff and PBR was not a thing yet (at least in real-time rendering). So all the terminology stayed the « Blinn-Phong » one (ambient, diffuse, specular). However, you have to consider that roughness, metallic and AO actually achieve the same thing than specular, glossiness, and ambient lighting, respectively. Albeit a bit differently (in a more complicated way, basically).

So, in short, it’s confusing, but not that hard. Here is a suggestion : When working in a PBR workflow, consider the diffuse map (map_Kd) as your albedo, the specular map as your roughness, and the ambient map as your AO.

But then, what about metallic? Or put another way, what about glossiness, to use the terms of yesteryear ? Turns out that even in the old days, OBJ didn’t have a particular type of map for glossiness either so we’re kind of on our own here. You will also see some workflows or models that just don’t come with a metallic map and who just pick a metallic value based on the roughness one because, roughly —AH!!— speaking, glossiness should be the inverse of the roughness; some others provide both for more artistic freedom or enhanced fidelity of metallic surfaces. Finally, remember that some workflows, like Unreal Engine’s, actually allows you to control all three of specular, roughness and metallic, so there is really a wide range of options and not « one true way » to do this kind of thing.

So reading the Assimp code I actually stumbled on what it calls the « specularity texture » , codenamed map_ns in the MTL file. But technically speaking, it’s the equivalent of the Ns float parameter (shininess exponent) as a map file. It comes under the Assimp texture type aiTextureType_SHININESS, which I think is not entirely proper for a metallic map.

Metalness describes how much a surface is metallic, and basically, the more metallic it is, the more light is going to be perfectly reflected by it (like a mirror). So at first it made perfect sense for me to use the « refl » keyword in the MTL file, used to specify reflection maps. No dice : Assimp does not manage it for OBJ files as it deems it rarely used :

Lol !

So, like I said, we are kind of on our own here. My plan is to basically interpret those maps depending on the workflow intended :

  • Blinn-Phong or any other kind of legacy lighting workflow : use the Ka/Kd/Ks/ns maps for actual ambiant/diffuse/specular/shininess maps, respectively
  • PBR workflow : use Ka/Kd/Ks/ns maps for AO, albedo, roughness, metalness, respectively

Then, how should I know if I should use a PBR workflow or not ? Again, it depends. Either you force it in your application (only manage PBR for example), or you have to have some way to detect what workflow does this material want to use.

For example, one idea is to retrieve the illumination model used in the file, an integer coded under the keyword « illum » in MTL file format. The spec describes extensively what each value is supposed to be doing.

It can be retrieved with Assimp by getting the integer value of the AI_MATKEY_SHADING_MODEL parameter. But unfortunately, the MTL spec doesn’t exactly match the more general-purpose shading model enum values of Assimp (the aiShadingMode enum). Which is why, even if you retrieve this key, prepare yourself to not get the same value as the one written in the .mtl file.

So you basically don’t have a choice : you need to come up with some sort of sensible mapping between the effect originally intended by the MTL spec, and the interpretation made by Assimp (the value you actually get).

I personally think I’m not going to honor either of them, and just go with my own values (like, I can read the shading model, if it returns 1, it will be Blinn, if it’s 2, it will be PBR, and so on). In the end, just sticking to a single workflow for the entirety of your application is of course the simplest course of action, but it’s also pretty limiting. A last option, finally, is to just completely ignore the material data altogether and just roll your own separately from the obj file.

Conclusion

I hope this « clarification » was useful to you, reader. Bear in mind that I absolutely ain’t a 3D modeler or artist, but more of a programmer, so there may be subtleties in authoring those 3D objects that may pass me by. Don’t hesitate to leave a comment if you think I got something wrong or wrote something stupid !

I would also not go as far as to call myself an Assimp master because there are some things I didn’t figure out yet. Take multi-texturing for example : Assimp actually manages multiple textures per texture type, which means you can have two diffuse textures, in theory, so that one part of a mesh can be textured by one image and another part by the second image, given you provide a second set of UV for each vertex. It’s a use case I’ve actually seen in real life. Unfortunately, I don’t know how to do that with Assimp, at least using the OBJ file format. If you respecify « map_Kd » in a material for example, it just overwrites the first one, where it should maybe add a second one instead. It doesn’t really matter to me anyway, as that’s very rare in video games, and can always be programmed « on the side », without having to rely on Assimp.

At the end of the day, all is well and good, but you have to wonder why should we still bother with the Wavefront OBJ file format at all in mid-2021. It’s a kinda old and flawed file format, surprisingly complex despite its apparent ease of use (because it’s plain text), barely standardized (or in very organic, vendor-specific ways), which makes it hard for both asset import libraries and 3D rendering programmers to manage all the weird edge cases.

Well, the answer is in the question : it is a still very widely used file format that can be handy to work with since it’s plain text. And until a better candidate takes its place, we kinda have to make do with what we have…

https://i.pinimg.com/originals/0d/42/f7/0d42f7e7da9443ccaea1e93bd452d083.jpg


2 réflexions sur « Coercing Assimp into reading OBJ PBR materials »

  1. Hi there, thanks a lot for your article – basically the only text I found on this topic. I’m a bit lost with implementing PBR together with Assimp, to be honest. My 3D model just looks off when I try to texture it.
    Have you had any experience trying this out with the Learnopengl tutorial on PBR? https://learnopengl.com/PBR/Lighting
    That’s what I’m struggling with – is there any source code of yours I could look into?
    I’d also like to know which version of Assimp you are using, since there might be a bug interfering with my current v5.0.1 (https://github.com/assimp/assimp/issues/2754).
    Thanks in advance! Laura

    1. Hello,
      yes, been there, done that.
      I had a problem with the latest Assimp too, same issue. Just downgraded to the latest previous major version since I don’t use any feature of 5.0 and it worked fine. I think someone in the issue managed to make it work, but I never tried.

      Correct lighting of an imported model can be tricky, because of some exports tricks made on specific models that may require special logic. The nanosuit provided by learnOpenGL is an example, I think the normal map is inverted or something similar IIRC. And it’s very easy to make a typo in all this complicated PBR code.

      I would advise to read thoroughly the comment sections on the articles of LearnOpenGL as that’s where people usually post their bugs and a lot of answers have been given there.

      I reproduced the PBR examples from learn OpenGL (with the spheres) with my homemade RHI, but never tried to light an imported OBJ model with it.
      It’s very messy and I didn’t have time to clean up yet, but it was working as I remember it.

      https://github.com/Scylardor/Monocle2/blob/master/Sandbox/source/PBR.cpp
      The associated shaders are all here, look for the « pbr_… » ones if you’re interested.
      https://github.com/Scylardor/Monocle2/tree/master/source/Graphics/Resources/shaders/OpenGL

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.