This is more technical and extended version of my previous article on the subject.
A bit of background
Since 2021 I have been working on HYPE — a motion graphics content creation tool, which is based on Unreal Engine 4 (UE4). One of the very basic and missing features of the engine was a resolution independent (aka ‘vector’) calligraphic correct 3D text as the product was supposed to provide a text quality similar to software such as Adobe After Effects.
HYPE targets video advertising sector, where graphic quality of text is extremely important. Unfortunately, text rendering component (not GUI but one you can drop into the level — 3d text) in UE4 is a naïve implementation of SDF atlas text rendering techniques, with all the shortcomings the technique brings with it, such as quality degradation on zoom-ins, rounded corners of the glyphs make this sort of text completely unfit for use with professional grade advertising content.
UE4 has also Text3D — a runtime tessellated 3D text available by enabling Epic’s ‘Text3D’ experimental plugin. It is volumetric by default and doesn’t always generate mesh topology correctly for complex fonts. Additionally, as the tessellation happens on CPU, this solution may slow down rendering noticeably for large amount of dynamic text.
Therefore, I felt that I had to bring a third-party solution, whereas the natural choice was Eric Lengyel’s Slug Library, which I had a chance to use for high quality text rendering on different projects in the past. Slug is SOTA, high performance text rendering library, which is compatible with all the industry standard low level graphics APIs. It also comes with built-in text shaping engine, which is normally required to layout glyphs and lines of text according to the calligraphic rules and full Unicode support. One would have to use a third-party solutions just for that part, such as Harfbuzz . Additionally, Slug allows rendering of SVG graphics, per-glyph transforms, range styling (like html text style tags) etc. All these make it a Swiss-knife solution for text rendering feature in any possible scenario.
I had to figure out if I could use the existing UE mesh setup infrastructure and abstain from needless modification of the engine’s code. Slug vertex layout interleaves several vertex attributes in one stream of data. It also expects some uniforms to be passed into vertex shader and a couple of data texture samplers bound to the fragment shader. I checked UE4 built-in FLocalVertexFactory which under the hood uses FDynamicMeshVertex, which has the following layout:
In the shaders, the above structure is reflected by FVertexFactoryInput struct which is defined in LocalVertexFactory.ush. You can notice that the shader version contains more fields, some of which are optional, guarded by preprocessor definitions. That’s how Unreal makes it possible to reuse the same vertex buffer for different types of meshes.
Slug’s vertex layout looks something like this*:
*All the Slug related code in this article is a pseudo code, which doesn’t depict the source code 1:1 due to Slug’s EULA.
Clearly, the problem was that FDynamicMeshVertex didn’t provide me with all the fields I needed. FColor field was the only attribute which I could reuse. On the vertex shader side, I could have solved that by adding Slug related macro define and put all the above attributes in it. However .cpp interface of the LocalVertexFactory presented a problem since it couldn’t use an arbitrary vertex buffer layout. Trying to adjust the existing interface to our needs would mean substantial modifications to the source code of the engine in the areas such as LocalVertexFactory, DynamicMeshBuilder and probably some more regions which are sensitive to changes and lack proper documentation. As result, I decided to implement custom vertex factory (VF). The good news are that custom VF doesn’t require messing with the engine sources. The API was designed to be extensible and the whole thing can be implemented in UE4 plugin.
Available learning resources and how I approached it.
No official documentation exists on how to extend VFs. All you can find on Epic’s doc pages is a list of classes and properties with very minimal comments. I was able to find a blog posts written by Matt Hoffman, and another one by Khammassi Ayoub. Both are pouring an overwhelming amount of information about UE’s rendering system and vertex factories. However, Hoffman’s article is outdated for 4.27 as many APIs underwent significant changes. Also, both articles give an example of a partial customization of the VFs, stuff like adding a custom shading model and custom code to the built-in vertex and pixel shaders. In my case, after reading both postings, I still had no clue how to approach the task technically. I was missing a “step-by-step by example” tutorial which would guide me implementing my custom vertex factory from scratch. I started looking into UE4 sources for examples of custom vertex factories such as LocalVertexFactory and TextRenderComponent. I also received some help and guidance on how to add a custom shading model from Epic’s technical support staff on UDN. Here I will try to explain, how to create a custom vertex factory including custom vertex data and shading model. I won’t go into detailed description of everything since the posts I mentioned above are still a good reference to understand the architecture and the related functionality in general. You should refer those. Also, describing the whole implementation in every detail would result in very long and boring article as this is had been a quite daunting task. Once you go through implementing VF by yourself, a lot of info in those articles becomes clearer and help understanding better many important details, which are hard to grasp when you are mastering this whole thing for the first time.
RenderSceneProxy and RenderComponent
These two always go together. SceneProxy just mirrors data from the related primitive component to the rendering thread. To make my custom version of these two, I used TextRenderComponent.cpp as a reference, since most of what happens there is similar to what I had to do for Slug. The font cache was replaced by my own font asset type which contains Slug font imported data. The other important functions which I had to tweak a bit were:
CalcBounds(const FTransform& LocalToWorld) const;
Here mesh bounds are calculated. And it is important to get those right, otherwise the actor starts flickering and disappearing in the viewport when it is still in view due to failing visibility test that is being run by UE4 under the hood.
Returns object matrix to the render thread. If no manual adjustment of the transform is required, it should return UPrimitiveComponent::GetRenderMatrix();
GetDynamicMeshElements(const TArray<const FSceneView*>& Views,const FSceneViewFamily& ViewFamily, uint32 VisibilityMap, FMeshElementCollector& Collector) const ;
functions are responsible for issuing primitive drawing commands. My understanding of the difference between the two is based on the personal gotchas, so feel free to correct me if I am wrong. The first one is called when geometric data hasn’t been cached. For example, rendering of particles, visual helpers and updated vertex buffers triggers these functions. On the other hand, DrawStaticElements renders cached primitives such as static meshes. A user can force using of any of these (if the asset data type allows), by setting related flags inside GetViewRelevance(const FSceneView* View)
For example, FTextRenderSceneProxy configures it as follows:
So, if a user interacts with the primitive in viewport, or visualizes its bounds/collisions then dynamic drawing is forced to update the frame with dynamic objects, otherwise cached mesh data is being drawn. Both methods must be implemented. Most of the code inside of these functions should be the same as is in TextRenderComponent, but here is one important detail: once you have to pass into shader program custom uniforms the following code must be added to GetDynamicMeshElements():
I don’t completely understand the internals of this setup, but generally speaking, once UE is aware of a custom uniform buffer in my custom VS, it makes it imperative that the user configures DynamicPrimitiveUniformBuffer when issuing render command. Note that the above code is missing from
void FTextRenderSceneProxy::GetDynamicMeshElements() ;
If you wonder what happens if you skip this setup, here is the error UE throws in my case:
TBasePassVSFPrecomputedVolumetricLightmapLightingPolicy expected a primitive uniform buffer but none was set on BatchElement.PrimitiveUniformBuffer or BatchElement.PrimitiveUniformBufferResource
This part summarizes the most essential areas of the SceneProxy and PrimitiveComponent you should hack to adjust to your specific use case.
Buffers and factories
How do we prepare and upload vertex data to GPU in UE4? Let’s take a closer look at the TexRenderComponent’s FTextRenderSceneProxy. Along with other members the class has these:
This is our low-level interface to GPU. In my case, I had to write custom version of FStaticMeshVertexBuffers and implement a custom VertexFactory. FDynamicMeshIndexBuffer16 can be reused, as index data is universally equal and consists of an array of uint16_t. At the high-level vertex data upload entry point is in
Here I generate Slug text related vertex data and pass it down the pipeline along with textures which in my case I need to upload as uniforms. It is important to note that the VertexFactory has no idea about you PrimitiveComponent and its members. Hence, any data you plan to submit to the rendering thread should be set here.
Here is my setup for CreateRenderThreadResource():
BuildStringMesh() encapsulates Slug library’s code which generates vertices and indices for the text string passed into RenderProxy by the PrimitiveComponent. TexSRV1 and TexSRV2 are texture samplers (or SRVs in DirectX dialect), generated by Slug as a part of the rendering logic and here I retrieve their refs from the function. I will show later in the article how I bind those to the uniform buffer.
Custom vertex layout and buffers.
TextRenderComponent uses generic FDynamicMeshVertex. I had to implement a custom vertex data structure to address the unique layout requirements of Slug vertex buffer. FDynamicMeshVertex is located in DynamicMeshBuilder.h . I just created a similar struct, removed tangent attributes as I didn’t need normal mapping, and added my custom vertex attributes:
Another structure which I had to implement was FStaticMeshDataType which could be found in Components.h. This struct essentially describes all the vertex attributes in terms of data access. UE4 tries to make it flexible to access any attribute as a data stream for read/write operations, hence every vertex attribute has its own FVertexStreamComponent and SRVs, which serve as an alternative, based on the comments, manual way to update vertex data. In my version of this struct I removed all the SRVs and added only FVertexStreamComponent per attribute in SlugMeshVertex.
This was the area I spent most of the time, because absolutely most of the available online examples explain either custom global shaders which are post process and aren’t part of the main rendering pipeline, or show very simple modifications within the built-in LocalVertexFactory in the main pass shaders. In my case I had to implement the custom vertex factory interface in the shader code as well.
First thing, you are going to implement a custom variation of LocalVertexFactory.ush . The really cool thing about UE4 shaders system is that it allows extension of something which conceptually similar to interface classes. For example, in SlugVertexFactory.ush I had to implement most of the functions from
LocalVertexFactory.ush because those are get called either in BasePassVertexShader ,BasePassPixelShader and in some other shaders in later passes. So any custom VF that enters the main rendering pipeline and which is supposed to have all the shading features (lighting,shadowing ,PBR etc) supported, must implement the whole interface similar to LocalVertexFactory.
Having said that, you don’t really have to implement all the functions if some of the shading features are not needed. For example,I haven’t touched the functions which handle UVs ,Tangents and shadow mapping because Slug Text wasn’t meant to support texture mapping shadows or normal mapping. I still had to have those function in SlugVertexFactory, but their content was either commented out or left unchanged.
From the other hand, the following functions are crucial to implement:
GetVertexFactoryIntermediates() — fills a struct called Intermediates with vertex shader inputs (attributes and uniforms) which is frequently accessed by different parts of the vertex shader to perform calculations. Any custom attribute in your VF you need to be accessed in the vertex shader must be set in this function.
float4 VertexFactoryGetRasterizedWorldPosition() — must return final transform of the vertex. This function is called by BasePassVertexShader and if as in Slug case, where the transform calculation involves some extra steps, all this must be done in this functions otherwise mesh won’t render accurate.
FVertexFactoryInterpolantsVSToPS VertexFactoryGetInterpolantsVSToPS() — this function returns data which is sent by the vertex shader down the pipeline into pixel (fragment) shader stage.
You can see line 120 in the BaseBaseVertexShader.usf:
Output.FactoryInterpolants = VertexFactoryGetInterpolants(Input, VFIntermediates, VertexParameters);
Where VertexFactoryGetInterpolants is a macro defined in BasePassVertexCommon.ush like this:
#define VertexFactoryGetInterpolants VertexFactoryGetInterpolantsVSToPS
Any vertex interpolated variables required by the fragment shader must be set in this function. In the case of Slug, here is where special kind of UV coordinates gets calculated which then used in FS to sample two Slug data textures I explained in the setup above.
Besides the custom VF shader, I had to add a custom code into BasePassPixelShader, as that’s where the actual text rasterization happens. I had to change nothing in BasePassVertexShader because I had implemented in SlugVertexFactory all of the interface methods that gets called from within BasePassVertexShader. That’s where you start to understand how cool this system’s design is.
BasePassPixelShader has a lot of code, but most if is not hard to understand and for us what is important is to find the right place to insert our custom shading logic. Unless one needs to modify the existing shading procedures, the right place is before the start of blending functions. At the time of this writing, it starts somewhere between the lines 1200–1400 with this macro:
For Slug case, all the related code was place above that macro. I had also to add the results of the following function:
GetTranslucencyVolumeLighting(MaterialParameters, PixelMaterialInputs, BasePassInterpolants, GBuffer, IndirectIrradiance);
To the slug rasterizer output in order to have support of PBR light sources.
Last important question I haven’t addressed is: how do we upload uniforms to GPU?
In your custom VF , void FSlugVertexFactory::InitRHI() is the function where you configure the uniform buffer. I had to create
member of FSlugVertexFactory class, where FSlugVertexFactoryUniformShaderParameters looks like this:
This is the standard UE way to create custom UBOs. You can see many examples of it in different VFs across the engine source. So, in my case I needed to pass two SRVs (texture samplers) and ViewportSize as these are the inputs required by Slug shader code.
Then I created the following function which creates the UBO:
And it gets called in void FSlugVertexFactory::InitRHI() to set SlugVertexFactory member I explained above:
UniformBuffer = CreateSlugVFUniformBuffer(this, viewportSize, Data.CurveTextureSRV, Data.BandTextureSRV);
Of course, this is not all. I also had to implement editor related interfaces for Slug Font asset files and makes sure those are cached upon import. And there was a lot of code I had to move around, fix, modify, delete as I was advancing, especially in the shaders as it was quite self-research. UE4 is a beast with most of the code undocumented, but it can be learnt by trial and error. It is just a lot of information, so it takes time. The results of the implementation can be seen in the video below: