Sparrow WebGL Devlog 5: 3D Models
Loading and parsing glTF files to render 3D models
07.06.2023 - 09:55When you hear WebGL, the first thing you think about is 3D models. Using WebGL to develop 2D games is nice and has a few advantages, but at first glance, they look the same as if they were drawn with the HTML5 Canvas API. So after a few weeks of 2D features, it was time to get back into 3 dimensions.
Arguably, the most important 3D feature is rendering 3D models. With rare exceptions like Minecraft, every popular 3D game or application uses 3D models.
There are a lot of file formats used to export and load 3D models. I'm using Wavefront (.obj), Stanford (.ply), Collada (.dae), and a custom binary model format for my C++/OpenGL engine. I could have used some of these for my Sparrow WebGL engine too and saved a lot of work because I already know how these formats work. However, especially for WebGL, the relatively new glTF file format seems to have become the standard, so I wanted to learn how it works and implement it.
glTF
The glTF (GL transmission format) file format is JSON-based, which makes it perfect for JavaScript and WebGL. It comes in two different variants: .gltf and .glb, with .glb being a binary version of .gltf. For now, I'm only working with the non-binary .gltf version, but I'm probably going to add support for .glb later.
In 2017, glTF 2.0 was released which introduced a new PBR-based material system. I'm not sure in what capacity I am going to implement physically-based rendering, but I'm still going to use the 2.0 version. Programming PBR seems like an interesting challenge, but my main goal is game development with non-realistic graphics, so PBR isn't high on my list of priorities.
glTF is a very comprehensive format. Besides just static 3D models, it also supports more complex scene descriptions with multiple objects, animations, materials, textures, vertex skinning, and morph targets. This makes it versatile enough to be the only supported format for my WebGL engine. So far, I have only implemented the features that are required to load static 3D models, but I am going to add more features in the coming weeks. Some parts like morph targets may come even later because I have never used it in a game even though I have experimented with it in OpenGL and WebGL before.
The basic glTF structure: [src]
In the image above, you can see the main structure of a glTF file. In general, the structure is well thought out and makes sense. However, I feel like the system of meshes going to accessors, going to bufferViews, and finally to the buffers is unnecessarily complicated and could have been simplified. I'm sure there is a good reason why there are so many steps in between, but I don't see it so far.
Implementation
Loading files in JavaScript is slightly more complicated than most programming languages because you cannot directly open them. Instead, they have to be fetched with an AJAX request. Luckily, parsing JSON in JavaScript is trivially easy.
However, navigating the glTF structure is a bit more cumbersome. For now, my loader only cares about the first mesh in the file to make it a bit easier, but once you can load one mesh, loading more is easy anyway. As mentioned above, the glTF system where you have to go through multiple layers of primitives, accessors, and bufferViews before you get to the data seems a bit unnecessary. It made it more tedious to get the relevant data to render a model. The data itself is embedded in the glTF file as a base64 encoded string (external binary buffers are also possible, but I haven't added them yet). The buffer has to be converted to a binary ArrayBuffer before it can then be converted to Float32Arrays or Uint16Arrays.
A 3D model rendered in my WebGL engine:
Now we get to one of my biggest issues with glTF: I don't like their naming scheme. A glTF file can have multiple "meshes" and each mesh can have multiple "primitives". For me, a primitive is a triangle, a line, or a point like the glDrawArrays function even mentions in the official documentation (mode: Specifies what kind of primitives to render. [src]). But in glTF, a primitive can be any kind of model, which I would call a mesh. However, in glTF a mesh is a collection of potentially multiple primitives. This is very confusing because it goes against the common usage of some of the keywords in glTF files. In my code, I still use the word mesh to describe a bunch of connected triangles of any shape. For now, I am using the term model for what glTF calls mesh, i.e. a model can have multiple meshes. I'm not really happy with this naming either because I use mesh and model interchangeably to describe a mesh. I need a data structure to represent what glTF calls mesh, but I cannot name it mesh because it's already taken.
In general, the most difficult part about loading glTF files is coming up with good data structures. The easiest would be to use a very similar structure to the one in the file, but this contradicts some of the conventions I like to use in my code. I have considered creating a special data structure for glTF imports, but this would mean that there are two different but very similar classes for certain objects, which is bad code design. I'm not sure how a good solution for this might look, especially when you consider that I am only using the most basic features of glTF at the moment and when I add more it will only get more complicated.
The next feature I'll have to add is better materials that can handle the glTF material system. I have already read the documentation for it and it's a bit different to the way I would normally do things too. I think the thought process behind the glTF mesh and material system is that you should create a new shader for each of them. There is a chance Babylon.js does it this way too (I haven't confirmed whether this is actually the case). This would explain the poor performance I encountered when I used Babylon.js to develop a game for Ludum Dare. The number of models in that game shouldn't have caused any major performance issues, but the game became almost unplayable when more models were added. A lot of draw calls and shader switches per frame would explain this problem.
I would like to have a single shader that is used for all models, but I'm not sure whether this is possible with glTF imports without sacrificing too much. This will be another situation where I have to find a middle ground between supporting as many glTF features as possible while maintaining good performance and a code layout that makes sense.
Smooth 3D models work too:
Even though I have some issues with glTF and especially some of the naming conventions, it's a pretty good format overall. It's widely supported in the industry and most 3D programs like Blender can export glTF files natively. For now, my engine only supports the basic import of static 3D models, but glTF can do a lot more. I would like to support as many features as I can, but I probably have to make some compromises to maintain some of the concepts and design goals of my engine.
by Christian - 07.06.2023 - 09:55
Comments
Comments are disabled