Better Living Through Materials
Hi, I’m Paul Geerts and I’m one of the engineers on League’s Render Strike Team. A while back my buddy Tony posted an article about how League of Legends currently builds a single frame of the action. Today, I’d like to dive into a component of the rendering pipeline that we’re actively improving: materials. I’ll discuss some of the changes we’ve made and why they’re important for players and developers alike.
League of Legacy
As Tony mentioned in the previous article, we need a variety of information in order to draw a champion to the screen. In the deep dark past of League, that information lived in INI files, but luckily, that’s no longer the case. Still, it’s a useful method of illustrating how we authored characters back in the day. This is what an INI file looked like:
[MeshSkin]
ChampionSkinID=1000
ChampionSkinName=Annie
Skeleton=annie.skl
SimpleSkin=annie.skn
Texture=annie_base_CM.dds
SkinScale=1
SelfIllumination=0.7
The “annie.skn” file contains binary data for a champion’s mesh, triangles, vertices, and UV information while the "annie.skl" file contains the skeleton, a.k.a. the hierarchy of bones our animators can control to make Annie auto-attack, dance, or drop a hot Tibbers on your face.
A texture is the usual nightmare fuel obtained by dismembering and squashing flat your favorite champion:
In the previous system, when our code (C++ in this case) decided it was time to draw Annie (or any champion), we had something like the line below to inform the graphics driver that it should use the texture in slot 0 the next time we draw something.
r3dRenderer->SetTexture(mMesh->GetSkinDiffuseTexture(), 0);
Over time, we added a few other simple features to change how champions appeared in game - maybe you like your Zac nice and shiny?
if (mMesh->mUseReflections)
{
r3dRenderer->SetTexture(mMesh->GetReflectionMap(), 2);
r3dRenderer->SetTexture(mMesh->GetGlossMap(), 11);
}
// there is also stuff like this.
r3dRenderer->SetPixelShaderConstantF(27, outlineMod, 1);
The reflection map went in slot 2, and the gloss went in slot 11. These corresponded to numbered texture units in the pixel shader. But why 2 and 11? I wish I had an answer, but the reasons are lost to antiquity. These arbitrary “magic numbers” made engineers confused and afraid. It was difficult to find out where in code we would have to change that mysterious 11 if we changed the shader.
So whenever we wanted to draw Annie, we executed a big block of champion-specific code that stuffed parameters into your GPU. This means that if we wanted to experiment with some new hotness character effect, we were knee-deep in that C++ code, probably trying to figure out if anyone was using slot 7 for anything.
Anything beyond “simple shiny” - take Project Ashe’s cape for example - was handled via our particle system, which was more flexible anyway.
As a result, the champion rendering pipeline itself stayed simple, but that didn’t mean it was without problems. By leaning on the particle system, we introduced much more overhead than necessary. And remember those scary twos and elevens? We were tired of being scared.
We realized that what we had wasn't a sustainable way of managing our render state as the game grew, so a while back we marked this style of code as the “legacy” code path. We all agreed that a time would come where we removed this legacy code and updated the system. That time has arrived!
Better Living Through Materials
At its heart, a material system is just a consistent way to specify every piece of state we need to stuff into the GPU before we render a frame.
We actually wrote the core of our material system during the Summoner's Rift Update project a few years ago. Since then, it's been happily talking to your GPU every time you take to the Rift. We're now in the process of taking that technology wider and removing the legacy code path for champions. Here’s a snippet from the material system where we set up a simple material that renders a diffuse-only character such as Annie:
// Ask the material registry to give us a material
mMaterial = materialRegistry->CreateMaterial(materialName, true);
// Add a “technique” and add a “pass” to that technique (explained below)
Renderer::Material::Technique& normalTech = mMaterial >AddTechnique("normal");
Renderer::Material::Pass& pass = normalTech.AddPass();
// Tell the pass which vertex and pixel shaders to use
pass.mVertexShaderFilename = “CharacterVertexShader.vs_2_0”;
pass.mPixelShaderFilename = “CharacterPixelShader.ps_2_0”;
// Enable depth testing and backface culling for this pass
pass.mRenderStates.renderStates = X3D_RS_DEPTHTEST | X3D_RS_CULLFACE;
// a “sampler” includes a texture and any extra info we need to render it
std::vector<Renderer::Material::ShaderSampler> samplers;
// Make a sampler entry for the diffuse
Renderer::Material::ShaderSampler diffuseSampler;
diffuseSampler.samplerName = "DIFFUSE_MAP";
// Set the texture
diffuseSampler.texture = mMesh->GetSkinDiffuseTexture();
// Add it to the list of samplers
samplers.emplace_back(diffuseSampler);
// Tell the material about our samplers
mMaterial->SetSamplers(samplers);
mMaterial->SetDefaultTechnique(normalTech);
// Init the material - this sets up internals for efficient rendering later
mMaterial->Init();
We’ve created a single bundle of render state for a given champion. Drawing with this material is now as simple as:
mMaterial->BeginActiveTechniquePass(0); // start the first pass of technique
Notice we’re referring to textures and parameters by name rather than by magic number. The texture still has to end up in a numbered slot for the GPU to use it, but that’s all handled internally and we never need to worry about memorizing twos and elevens anymore.
This has a number of advantages over the legacy system:
-
Determining what to draw has been pushed to material creation time, which reduces the complexity of the render loop itself and better utilizes system cache.
-
We can focus on optimizing a single code path rather than many in different systems.
-
Learning our material system once allows Rioters to quickly and easily jump between various code bases.
-
Searching for “DIFFUSE_MAP” (for example) is exactly 932 times easier than searching for 0.
We can also do things like clone a material and change the texture while leaving everything else untouched. This further reduces the complexity of the code needed to set up GPU state. A large part of our work has been translating these legacy code paths to create materials from the data we already have. In fact, since patch 7.7, we’ve been doing this for all of our champions behind the scenes.
Anatomy of a Material
A material is composed of some number of techniques, and each one of those contains at least one pass. A technique corresponds to a particular method of rendering and a pass corresponds to a single draw call of the geometry.
Renderer::Material::Technique& normalTech = mMaterial >AddTechnique("normal");
Renderer::Material::Pass& pass = normalTech.AddPass();
In the code block from before, we use the “normal” technique, but other examples might be a “depth only” technique for shadow maps, a “z pre-pass”, or some kind of game-specific special effect (e.g. thermal vision mode).
Nowadays pixel shaders can do a lot of work in a single pass, but occasionally it makes sense to split things up into multiple sequential draw calls of the same object to achieve a single technique. Each pass includes all the state required - vertex and pixel shaders, blend modes, depth test/write, backface culling, alpha testing, stencil modes, etc.
Currently, our character materials include 2 techniques: “normal” and “transition.” As part of this work, we discovered that we had to re-write how we deal with transition effects, such as when Cassiopeia turns you to stone.
Previously, we would blend the character’s diffuse texture with the stone texture and shove that resulting blended texture into slot 0.
There are a few problems with that approach - firstly, not every material actually needs or wants a diffuse texture. For example, if we made a Literal Glass Cannon marksman, we might not want a diffuse texture at all, as glass can be simulated with just a tint color and some specular highlights. Secondly, even if the material did have a diffuse texture, replacing it might only alter some part of the visuals, and leave others alone.
Now we have a technique called “transition” - whenever a transition effect is playing, we use a different shader that controls the blending of the material with the target texture. This way the shader code has complete control over how the transition occurs. For example, the shader code specifies what texture coordinates to use.
There are a few other benefits as well. Previously, when generating a blended texture, we’d do it at the full resolution of the source texture, even if the character was tiny on screen. Doing it in the pixel shader means we only pay the blending cost for visible pixels.
In order to make this feasible from a performance standpoint, we also had to optimize the transition calculations, as a lot of the work done in the pixel shader for the quad blending approach was constant for all the pixels. Now we sample the transition ramp control texture on the CPU once, making it a lot more efficient in the pixel shader.
But wait, there’s more!
So far, the new material system has made Riot engineers’ jobs easier and allowed the game to run a little faster on players’ machines. But there’s another really good reason why we want all GPU state bundled into a material: the definition can reside entirely in data instead of code.
For our new champions, Xayah and Rakan, we wanted to capture the feeling of iridescent bird feathers. Because everything is now being drawn using materials, we can create a champion-specific material entirely in data for any custom effects we need. Being able to create these data-driven materials obviously requires tooling, so we made some:
Because this is built on top of our Game Data Server infrastructure, we get a pile of really nice features for free. In addition to the Riot Editor 3D viewport, any changes to a material appear instantly in a running game. This gives our artists a really tight feedback loop, letting them Find The Awesome faster.
We’re happy with the capability we’ve unlocked for our artists in terms of data-driven materials. However, it doesn’t mean we’re going to turn every champ into a bump-mapped subsurface scattered hyper-realistic version of themselves. League’s art style has always been more painterly than photo-real, and ultimately, anything we do needs to serve gameplay clarity rather than distract from it. In addition, using arbitrarily complex materials can have significant impact on framerate. We always want to make sure League looks great and runs great, regardless of a player’s hardware.
Keep an eye out for new and interesting effects in the future, and if you have any questions or comments, please share them below!