-
Notifications
You must be signed in to change notification settings - Fork 90
MDX Rendering
This document describes MDX rendering for both SD and HD modes. The MDX format is fairly complex to load and render. For a binary specification of MDX you can refer to this excellent description by GhostWolf (Note that it is not up-to-date for MDX version 1100 found in WC3 1.33).
The rendering mode (SD/HD) of a model can differ between
- V900, V1000: geosets
- V1100: layers
Thus, it is possible to have a model that is partially SD and partially HD such as the following ogre (image by Retera):
(v900/v1000) If a material has a (valid?) shader then it is rendered with that shader, otherwise it is rendered in SD mode. (v1100) If a layer has the hd flag set, it will be rendered in HD mode, otherwise it is rendered in SD mode.
Some rendering implementations use an alpha of 0.75 for a geoset as a cutoff where they hide the geoset entirely, but this is not correct. Geoset alphas do not have a cutoff.
Every material can have any number of layers each with their own blendmode and properties such as TwoSided. Only the first layer is taken into account when sorting for transparency. So if the blendmode for layer 0 is either None or Transparent then the material is considered opaque. If the blendmode is any of the others such as Blend or Additive then it is considered transparent.
Afaict the only way to draw SD geosets is doing a draw call for every single layer in the geoset its material.
In HD mode only the first 6 layers are used for actual rendering. They have to be in the right order which is
- Diffuse
- Normal
- ORM
- Emissive
- Teamcolor
- Environment
Except for layer 0, all blendmodes, static alphas and any other properties seem to be ignored. It seems that HD mode layer 0 supports all the flags such as TwoSided and NoDepthTest.
To efficiently render HD materials you should have a shader that takes in the 6 layer textures instead of doing 6 individual draw calls for every layer.
The new MDX version no longer separates the different PBR textures into layers. Every layer now has a list of textures (with id and optionally KMTF) instead of just one texture ID per layer. An SD layer will still only refer to a single texture. This allows HD models to have multiple layers per geoset like SD geosets do. The textures in the layer textures array are still in the following order:
- Diffuse
- Normal
- ORM
- Emissive
- Teamcolor
- Environment
The diffuse textures are simple S3TC DXT1/DXT5 textures and your image loading library should easily handle that. It is also possible to directly upload the texture data to the GPU without decompression as most GPUs can use the compressed data natively. The relevant OpenGL function for this is glCompressedTexImage2D
The normal maps are two channel red green 3Dc/DXN/BC5 textures. Again you can upload these directly to the GPU without decompression. Using the model tangents you can then construct a TBN matrix which you multiply with the light direction in the vertex shader to get the tangent light direction. vInstance being your mat4 with the object its translation/rotation/scale.
mat3 model = mat3(vInstance);
vec3 T = normalize(model * vTangent.xyz);
vec3 N = normalize(model * vNormal);
vec3 B = cross(N, T) * vTangent.w; // to fix handedness
mat3 TBN = transpose(mat3(T, B, N));
tangent_light_direction = normalize(TBN * light_direction);Then in the fragment shader you sample the normal texture and reconstruct the third (blue) component using the fact that the 3D vector they form should have a total length of 1.
vec2 texel = texture(normal, UV).rg;
vec3 normal = vec3(texel, sqrt(1.f - texel.x * texel.x - texel.y * texel.y));Then you can dot product this with the tangent light direction to get the final contribution. Note that we invert the tangent_light_direction. We add some ambient light because otherwise we would get totally dark sides.
float lambert = clamp(dot(normal, -tangent_light_direction), 0.f, 1.f);
color.rgb *= clamp(lambert + 0.1, 0.f, 1.f);The orm map has three channels which are occlusion, roughness and metalness. Occlusion seems to be unused by the game at this time, while the other two channels are probably used like in standard OpenGL ORM maps.
Regular emission map, just gets added to the color value
A replaceable texture supposed to represent teamcolor. Since its a solid color you don't need to use a texture per se and can just use a uniform or pass it in a buffer.
A texture used for the reflective and shiny stuff when the ORM texture has metalness.
- Does lighting apply to transparent layers?
- Does the editor always play the stand animation and does it loop it even when it's set to non_looping
The most basic rendering of MDXs is doing something like
for each model in models {
for each geoset in model.geosets {
for each layer in geoset.layer {
glDrawElements()
}
}
}So if you have 2000 identical models (e.g. trees) with 5 layers then you would have 5 * 2000 = 10.000 draw calls. Doing that is slow because the GPU driver has to do a lot of work on each draw call and the driver runs on the CPU. While it is doing CPU work your GPU is essentially doing nothing. That's time it instead could have been rendering. Now we were already doing instancing per unique geoset layer. That's when instead of a draw call per geoset per layer, we do something like
unique_models = models.deduplicate()
for each model in unique_models {
for each geoset in model.geosets {
for each layer in geoset.layer {
// Draw the layer for 2000 models at once
glDrawElementsInstanced()
}
}
}So now with 2000 identical models (e.g. trees) with 5 layers we only do 5 draw calls! Except that of course you have many different units/doodads/destructibles/whatnot in your map so if you have 3000 unique models with 5 layers each we are still doing 15.000 draw calls.
So the next improvement is using glMultiDrawElementsIndirect. It allows us to send a list of commands and internally it is basically doing a for each draw_call but because it is inside the driver it can do some smart stuff so the CPU driver overhead is lower.
One limiting factor is that we can't willy nilly do one glMultiDrawElementsIndirect for everything because there is some state that it doesn't allow us to set inside of it. E.g. how transparent pixels should blend, whether to cull backfaces, whether to do a depth test.
So we create a list of draw calls, sort them so that the ones with identical state are together and then do a couple glMultiDrawElementsIndirect draw calls. Setting the required state before each of them.
This reduces the total number of draw calls on my test map from 7330 to 1387, which is a massive reduction. We can potentially reduce that more but it is complex
There are only 6 draw calls for opaque meshes (non-transparent), but the remaining 1381 draw calls are for transparent meshes because you have to draw them in the right order so the blending of transparent pixels looks right. Right now it sorts the transparent meshes on distance to the camera and then groups them based on the required draw state. This does lead to more draw calls than strictly necessary because transparent meshes that don't overlap don't need to be ordered. It could probably be maybe 100 draw calls. To do that, we would have to write some way to know which transparent meshes do need to be strictly ordered. It's a bit more involved though, and rendering performance might right now already be good enough for even the biggest WC3 maps