Website powered by

PBR, Color space conversion and Albedo chart

Article / 20 September 2018


Why is it important

Materials on the scene should represent the real materials, this will help you to avoid completely relighting the scene when proper materials appear. Working with bad materials may affect the lighting quality significantly. Having albedo values too dark is a common mistake, out of range values would not give enough of GI, breaking the lighting. Doesn't matter if your game is stylized or not, following PBR workflow will improve your lighting quality and help you achieve your stylized yet believable result.

  • A range for albedo \ diffuse \ base color for non-metals is 60-240 sRGB. 
  • Albedo values for metals range from 0.5 to 0.98 (180 to 250 sRGB). Lower values represent matte metal and higher values represent polished metal. Rougher the metal - darker the albedo, if Metallic has values lower than 0.8, not pure metal, then the Base Color should be darker than the pure metal.

Check this post about Unreal Engine's extra View Modes for PBR debug example.

Color space conversions

Skip to the next section if you're not into math.

The formula for converting sRGB to linear:

If (0 ≤ S ≤ 0.04045):
    L = S/12.92
Else (0.04045 < S ≤ 1):
    L = ((S+0.055)/1.055)^2.4

And Linear to sRGB

If (0 ≤ L ≤ 0.0031308):
    S = L * 12.92
Else (0.0031308 < L ≤ 1):
    S = 1.055*L^(1/2.4) - 0.055

The formulas below are simplified approximations of converting sRGB to Linear and back, which should give good enough results in most cases (it gives larger relative errors in darker regions but that's where the eyes are less sensitive):

Linear RGB = ((sRGB / 255) ^ 2.2) * 255
sRGB = ((Linear RGB / 255) ^ 0.4545) * 255
    2.2 # this is gamma
    0.4545 = 1 / 2.2 # this is an inverse of gamma
    X / 255 # this means normalizing the value (convert 0-255 range to 0-1)

To check the albedo of your texture (in sRGB space):

Albedo = ((R/255)^2.2 + (G/255)^2.2 + (B/255)^2.2) / 3

For example, color-pick:


And calculate using the formula, for this example we have 0.27 albedo value (with some error):

((0/255)^2.2 + (168/255)^2.2 + (170/255)^2.2) / 3 = 0.27

To avoid doing any calculations, you can check the value in Photoshop and Substance Designer.


How to check albedo value in Photoshop

Simple way of checking texture value would be Luminosity in Histogram. Check values in the list at the end of the article (sRGB). Basically keep the textures in between the 80-240 range, roughly, the darkest albedo should be no less than 50-60 (I prefer keeping 55-60 as black surface):

If you want to convert sRGB color to linear space and see the linear albedo values, you can do this in few steps:

1 - Add Exposure correction layer with Gamma Correction set to 0.4545

2 - Then, add Channel Mixer layer, check the Monochrome box and set all channels to 33%

3 - With color picker you can see the albedo value, which in this case is 26%:


How to check albedo value in Substance Designer

It's easy to create a node for Albedo values validation:

1 - Function that converts sRGB to Linear:

2 - This function is used inside the Pixel Processor node which samples the input and converts each channel separately:

3 - In 2D view, pointing at the color with the cursor you can see it's value. In this example it's 0.26:


Albedo values

Gathered from all around the web, values are for the reference and general idea of how dark\light the common material usually is, there are always variations.

For non-metal, the darkest albedo is 0.04 for carbon, the lightest are the white paint and fresh snow with values around 0.8 (inside of 60-240 sRGB range)

Non-Metal Values

Values are represented as Linear RGB | sRGB.


Metal values

Albedo values for metals range from 0.5 to 0.98 (180 to 250 sRGB). Lower values represent matte metal and higher values represent polished metal. 

Rougher the metal - darker the albedo, if Metallic has values lower than 0.8, not clearly metal, then the Base Color should be darker than it would be for pure metal (under 180). 

Values are displayed in sRGB. These are for reference only and may vary.

  • Iron  = c4c7c7 (198, 198, 200)
  • Brass = d6b97b (214, 185, 123)
  • Copper = fad0c0 (250, 208, 192)
  • Gold = ffe29b (255, 226, 155)
  • Aluminium = f5f6f6 (245, 246, 246)
  • Chrome = c4c5c5 (196, 197, 197)
  • Silver = fcfaf5 (252, 250, 245)
  • Cobalt = d3d2cf (211, 210, 207)
  • Titanium = c1bab1 (195, 186, 177)
  • Platinum = d5d0c8 (213, 208, 200)
  • Nickel = d3cbbe (211, 203, 190)
  • Zinc = d5eaed (213, 234, 237)
  • Mercury = e5e4e4 (229, 228, 228)
  • Palladium = ded9d3 (222, 217, 211)


Lighting: Features cheat sheet

Article / 09 September 2023

A new article of mine focuses on the lighting features available in Unreal Engine.

In this article I provide a general idea of the target platforms and scalability levels where each feature should be used (covering PC, 8-9 gen consoles).

It’s designed to assist developers in deciding the feature set for their games.

Check it out on Medium

To make iterating and tests easier, use this companion tool.








Lighting: Exposure and Contrast

Article / 11 December 2022

Decided to sort and share my notes with the community, it will be series of different topics. Even though I'm working with Unreal Engine 5 here, most of the things apply to PBR \ PBL workflow in general in any engine.

Workflow for good lighting setup

  1. Make sure you have proper PBR color values on the scene.
  2. Set the exposure
  3. Set sky. Add key light - Sun or local light, depends on scene. Balance to match proper contrast.
  4. Set reflection probes (skip with Lumen, RT).
  5. Artistic freedom - add and tweak the lights to set the desired mood on the scene. Iterate with baking if it's static lighting.
  6. Setup Post Process - color correction, fog, bloom, and so on.
  7. Optimize - if you're working on a game environment, the lighting is often one of the most expensive parts of it

My older article covers the pt.1 of the workflow. This article is covering pt.2-3 principles - the exposure and contrast.


Some resources to watch:

Exposure

Exposure is important for maintaining consistent and predictable lighting between areas. It provides proper eye adaptation and consistency of emissive objects.

Tip: to keep your emissive material intensity the same in different lighting conditions, divide it by EyeAdaptation node.

To properly expose the image:

  • use the Eye Adaptation viewmode
  • have proper visual definition of the black, grey and white helpers (spheres)
  • keep the contrast in mind while setting different lighting conditions
  • find and match the references

Helper spheres (check this example) should be visible and shape should be well defined:

  • If the black sphere looses detail\shape - scene is underexposed.
  • If the white sphere is being washed out - scene is overexposed.
  • Use grey sphere to calibrate exposure (I tend to keep it's roughness at 1 for clear exposure view)
  • Chrome ball should look as a chrome, reflections should look right.

Sunny 16 and EV100

In real world, in a bright sunny day we would set our camera at Aperture 16, ISO 100 and Shutter speed 1/125. It equals EV100 = 15. 

Here's an example of the settings in Unreal Engine. Switching between Auto exposure and Manual will give you exactly the same picture:

Note: make sure to enable "Extend default luminance range" in "Auto Exposure settings" for physical values to work as intended. You can find it in Project Settings > Engine > Rendering.

We can set EV100 values and Manual Camera values to match each other exactly, using this formula:

EV100 = (log2 (Aperture² / ShutterSpeed * 100 / ISO)) [source]

While we can use real world values for lights and real exposure chart, it's not mandatory. You can work with or without physical units, both workflows are equally right. You can get exactly the same picture both ways. You can either start from setting up the sky and adapt your EV to it's intensity, or you can define EV first and adapt lighting to it.

You can work with or without physical units, both workflows are equally right.

The following chart contains standard Aperture and EV100 values. The final lighting vary depending on the setting, atmosphere, clouds coverage, sky sphere, it’s intensity, and so on. (Shutter speed is 1/125, ISO 100):

Note: EV100 is a round of float result, the round error can be a tiny bit noticeable in some cases, but these slight differences can be compensated.

Physical values are pretty huge, artists often use scaled values instead. For example, you want a bright sunny day, you may want to decide to use, let's say, sun intensity at 10, and EV100 at 2. Lighting Values in Unreal are relative to one another, so you can easily adapt and scale any lighting intensities and exposure. Artistic freedom ;) Use your eyes, don't get hooked by physical charts too much.

Example - how to find the actual exposure value

How do we know which EV are we at with unlocked Auto Exposure? Check the HDR (Eye Adaptation) visualizer. There you can see at which value the exposure currently is:

In this example we can see that it's at approximately 13, so we can lock our EV100 min\max at 13:


Example - matching physical and non-physical settings

Let's say, we have Sun Intensity 20000 and EV100 is at 13:

But we want to scale it to EV100 at 1. We can rely on our eyes and match the intensities. By tweaking the sun and sky to match the EV, we came up to sun intensity at 5, and it's (almost) the same:

But there's also a hack we can use - light meter calibration chart can help to scale the values. Use calibration chart as a reference. Look at the Nits (cd/m²), you can see it next to the sample in EyeAdaptation visializer. Use lx for approximate intensities. Thus 20480 sun intensity (1024 nits) with EV at 13 is the same as 2560 (128 nits) at 10, or 5 (0.25 nits) at 1, which confirms our observation.

Stops

What are actually those stops we're talking about? One stop is an exposure factor of 2 (2x more or 1/2 less). One EV is a step of one stop compensation value:

  • ± half or double Shutter Speed duration 
  • ± half or double ISO value
  • ± one full f/stop (aperture)
  • or their combination

Contrast

Lighting contrast ratio is the difference in stops between the bright (key) and dark (shadow). A 2:1 ratio means you have a one stop difference from light to shadow. 

Natural outdoor scenarios:

  • 4:1 (3 ± half stops) at midday
  • 3:1 (2 ± half stops) at afternoon \ evening
  • 2:1 (1 - 1.5 stops) at late evening \ overcast

These ratios are only approximate references. Conditions vary by many factors like weather, atmosphere etc. 

Tip: Having a sky sphere with real HDRI helps setting the lighting up. You can compare the scene with the sky texture, and see if your shadows and bright areas match the reference.

A high key-fill rate creates a strong contrast that suggests tension and drama. Lower ratios result in more uniform lighting and smoother distribution of tones. The key-fill rate is different than the scene brightness. Scenes with higher ratios tend to be darker than scenes with lower ratios.

  • 2:1 or 3:1 - creates natural, even, pleasant gradations in tone.
  • 4:1 ratio adds drama, creates stronger shading.
  • 7:1, 8:1 and above are usually for tension and action.

Examples of the contrast ratios

Partly cloudy day, three stops difference:

Afternoon, two stops difference:

Sunset (overcast will be the same), one stop difference:


Optimization. General theory

Article / 13 July 2019


FPS vs MS

Frames per second (FPS)

One of the most common benchmarks used in measuring the graphics performance of a video game is the frame rate or frames per second.

FPS is the number of times the image on the screen is refreshed each second.

However using FPS as the performance measurement is wrong most of the time. FPS measurement is not uniform and saying "performance increases by 5fps" when you talk about going up from 10 to 15fps means huge 33.34ms increase, while if you talk about 55 and 60 fps, it gives 1,5ms.

Having 60fps target, you have 16.67ms limit for all your processing.

The actual cost of features is a period of time, not a number of frames.


Milliseconds per frame (ms)

The inverse of the frames per second (FPS) gives us the seconds per frame (SPF); there are 1000 milliseconds in a second.

1000/fps = ms/frame

60fps means 1000/60 = 16.666ms per frame

While the frames per second increase, the number of milliseconds decreases at a slower rate:

  • 90fps = 11.11ms - for VR to reduce motion sickness 
  • 60fps = 16.67ms - action games
  • 50fps = 20ms
  • 40fps = 25ms
  • 30fps = 33.34ms - adventure, etc
  • 20fps = 50ms
  • 10fps = 100ms

Example: we have a scene running at 35fps - 1000/35 = 28.57ms, let's say we want to use HBAO+ Ultra which takes 4.8ms - 28.57 + 4.8 = 33.37ms, that means our scene is now running at 30fps.

Learn More


How do we influence this

FPS drops are caused by CPU and GPU taking longer time to process the frames.

  • For CPU, most demanding are draw calls, positions, math and visibility (physics, AI, skeletal animations, projectiles, etc.)
  • GPU is often limited by fillrate or memory bandwidth. Typical problems are shaders, lights, dynamic shadows, translucency, overdraw.

Polycount & LODs

A Level of Detail model (LOD) is a lower-resolution version of a game model. LOD0 is the original mesh, the higher the LOD the fewer vertices to be processed. This leads to big performance improvements by reducing the cost on the CPU and GPU vertex processing.

Dynamic lights calculate each vertex no matter the size on screen, you have to be careful with triangle count covered by dynamic lights, because a 10k mesh is just as expensive up close as it is far away for shadow calculation.

GDC presentation from Simplygon about LODs and how they affect overshading.

General advises:

  • Watch the number of vertices and faces of the models, check for the polycount limits. Reduce tris and remove polys of the model which are never visible.
  • Make sure that all of the models have LODs (Except the very simple ones like blocks). Look for your models LODs distances, for example you may have the model on such a distance where it's already 100px small on the screen but still is in LOD0 with 10k tris.
  • Do not leave hidden objects and vegetation under terrain or inside other objects, and preferably terrain parts under objects. It wastes the CPU and GPU processing time for nothing.
  • Make assets modular. Keep the number of different materials per scene low, and share as many materials between different objects as possible.
  • Use bilboards on distance to fake detailed geometry.

Overshading

Overshading is caused by tiny or thin triangles and can really hurt performance by wasting a significant portion of the GPU’s time. Overshading is a consequence of how GPUs process pixels in quads, blocks of 2x2 pixels. It’s done like this so the hardware can do things like comparing UVs between pixels to calculate appropriate mipmap levels. [3]

This means that if a triangle only touches a single pixel of a quad, the GPU still processes the whole quad and just throws away the other three pixels, wasting 75% of the work. [3]

Small triangles are bad for performance.

Filling the frame buffer with anything larger than 16 by 16 tiles have no effect on the GPU performance. The primitive minimum size in pixels is pretty much the same for all vendors. Covering less than 8 by 8 pixels, with two triangles starts to have a significant impact on performance and it grows exponentially. [4]

If we want some good performance, let's set the minimum size to about 8 pixels per triangle. It's recommended to have triangles at least 16 pixels in size.

Tiny triangles are also a problem because GPUs can only process and rasterize triangles at a certain rate, which is usually relatively low compared to how many pixels it can process in the same amount of time. With too many small triangles, it can’t produce pixels fast enough to keep the shader units busy, resulting in stalls and idle time – the real enemy of GPU performance. [4]

Culling

Culling explained on Crytek docs.

Culling is completely excluding objects from processing when they are outside the view. This is an effective way to reduce both the CPU and GPU load.

When multiple meshes are merged into a single object, their individual bounding volumes must be combined into a single large volume that is big enough to enclose every mesh. This increases the likelihood that the visibility system will be able to see some part of the volume, and so will consider the entire collection visible. That means that it becomes a draw call, and so the vertex shader must be executed on every vertex in the object - even if very few of those vertices actually appear on the screen. This can lead to a lot of GPU time being wasted because the vertices end up not contributing anything to the final image. [3]

Opacity & Overdraw

Overdraw happens when the same pixel is drawn multiple times (when objects are drawn on top of other).

The thing that impacts the fillrate the most is transparent stuff like particles with alpha blending. Limit the amount of opacity maps you use and their impact on the scene. If your map has a lot of opacity it’s better to add a few extra cuts and reduce the opacity area than to save a few polygons.

There's an article on particle optimizations for the further reading

Translucency is also very heavy for performance, never use translucency unless absolutely necessary.

Lighting & Shadows

Static lights are faster than dynamic lights, cull dynamic lights as early as possible, minimize the number of objects they affect. Spot lights are cheaper than the point lights. One point light costs as much as 6 spot lights. Try to avoid lighting spheres or conuses to overlap each other. Reduce lights amount and cast distance as much as possible.

Bake as much lighting effects as possible.

Lights can optionally cast shadows. This gives them greater realism but has a bigger performance cost. Lights and realtime shadows have a big impact on performance, these effects give extra draw calls for the CPU and extra processing on the GPU. Also, shadow maps resolution has a moderate performance cost.

As mentioned earlier, triangle count is rarely a problem.  But it matters for shadow casting, adding the processing costs to that.

Disable shadow casting where possible.

Soft shadows have a greater rendering overhead than hard shadows but this only affects the GPU and does not cause much extra CPU work.


Draw Calls

CPU is often limited by the number of batches that need to be rendered. [5]

Dip - DrawIndexedPrimitives. DrawElements in OpenGL

Draw call commands are given by CPU to GPU, to render a mesh. The command only points to a mesh which shall be rendered and doesn’t contain any material information. After the command is given, the GPU takes the render state values (material, textures, shaders etc…) and all the vertex data to convert this information into pixels on your screen. [13]

Every mesh with a different material will require a separate Draw Call. [13]

Reducing Draw Calls will reduce overhead for the GPU and clearing up CPU usage for other processing.

If your frame rate is fine, there is no need to worry about Draw Calls.

If you're not bound on Draw Calls, it's better not to reduce them as that affects culling, making it less precise.

How do we influence this

The main reason to make fewer draw calls is that GPUs can transform and render triangles much faster than you can submit them. If you submit few triangles with each call, the CPU won't be able to feed the GPU fast enough and the GPU will be mostly idle. [15 - actually is also quoted from forum discussion] But also, if the mesh exceeds the vertex limit for a single batch, then it will be split into more than one batch, and cause more draw calls.

Try to keep the number of UV mapping seams and hard edges (doubled-up vertices) as low as possible.

Note that the actual number of vertices that graphics hardware has to process is usually not the same as the number reported by a 3D application. Modeling applications usually display the number of distinct corner points that make up a model (known as the geometric vertex count). For a graphics card, however, some geometric vertices need to be split into two or more logical vertices for rendering purposes. A vertex must be split if it has multiple normals, UV coordinates or vertex colors. [8]

Do not make too many too trivial shapes.

Do not make millions of shapes with only a few vertices. Merge your shapes to have thousands of triangles in a single shape.

BUT:

Do not make too few shapes.

This one is explained above in the culling section.

Each shape is passed as a whole, and shapes may be culled. By using only a few very large shapes, you make this culling worthless. (In most cases you would not want to combine all the telephone poles into a single model, because then the renderer couldn't cull the unseen meshes, so ALL the triangles would have to be loaded.) [2]

Draw calls often have a more significant impact on performance than polycount.

Which means if you need some extra polys to make a model smoother - go for it.

Even if using different meshes and producing multiple draw calls, you can improve performance by avoiding multiple materials, and grouping multiple textures on a single atlas to avoid switching texture maps (which causes Render State changes with extra Draw Calls).

Note that combining two objects which don’t share a material does not give you any performance increase at all.

Nice presentation from NVidia on batching.

GPU Idle? Add Triangles For Free!

GPU Idle? Complicate Pixel Shaders For Free!

VRAM

VRAM only store image data the GPU needs to render the frame:

  • Shader Programs
  • Vertex Buffers
  • Index Buffers
  • Textures

How do we influence this

Textures impact the bandwidth the most. There are ways to optimize them to increase the speed and lower GPU memory usage:

  • texture compression
  • scale down textures
  • mipmaping
  • texture atlases
  • channel packing

Texture compression

Popular compressed formats like PNG and JPG cannot be decoded directly by the GPU and have to be decompressed to the GPU friendly format.

The formats designed for the GPU:

  • S3TC (DXT) - the oldest and most common format
  • ETC - this one is for android and not supported by iOS
  • PVRTC - for iOS, but can also be used on android and PC
  • ASTC - similar to DXT, but you can choose the size of blocks like 4 x 4 or 12 x 12. It ranges from 8 bpp to less than 1 bpp.

Learn More

Texture size and bpp

If you're using common formats that are not designed for direct GPU access, they are decompressed to their full size in memory for rendering, which means your few-kb 8bit PNG file of size 2048x2048 will take up 4mb in memory.

Playing with your image formats only affects the download size - it does not affect memory use.

So what size a pixel actually is? It measures in bits per pixel (bpp) and depends on how much color we store:

1 bit per pixel = 2 colors // either white or black

As we have 2 values in 1 bit, then to calculate the rest we have - 2 in the power of bpp.

  • 2 bpp = 4 colors 
  • 4 bpp = 16 colors 
  • 8 bpp = 256 colors // Low Color 
  • 16 bpp = 65,536 colors // High Color 
  • 24 bpp = 16,777,216 colors  // True Color

Color Depth, also known as bit depth, is either the number of bits used to indicate the color of a single pixel, or the number of bits used for each color component of a single pixel. When referring to a pixel, the concept can be defined as bits per pixel (bpp), which specifies the number of bits used. When referring to a color component, the concept can be defined as bits per component, bits per channel, bits per color (all three abbreviated bpc) [Wiki]

RGB 24bit gives us 8 bits per channel (R8G8B8), but there's no alpha, by adding it, we get RGBA 32bit (R8G8B8A8).

RGB 16bit is actually 5 bits per channel (R5G6B5) with one extra bit in the green channel, where it matters most, because human eyes are better in seeing in green spectrum. RGBA 16bit has 4 bits per channel (R4G4B4A4).

Calculating the actual texture storage size is simple: width * height * bpp

For example, we have 1k texture with 16 bits per pixel:

  • 1024 * 1024 * 16 = 16777216 bits, 
  • 16777216 / 8 = 2097152 bytes (converted bits to bytes), 
  • 2097152 / 1024 = 2048 kb (converted bytes to kilobytes), 
  • 2048 / 1024 = 2 Mb (converted kilobytes to megabytes).

That means our texture is going to take 2Mb of VRAM.

Learn More

BC1..3 (DXT1..5)

DXT compression works with texels and each texel is a 4x4 block of pixels, DXT can't work with an image with a dimension smaller than 4.

  • Uncompressed
  • BC1 (DXT1) - RGB(5:6:5), 4 bpp, no alpha or 1 bit (black or white) alpha, 8:1 compression ratio
  • BC2 (DXT2/DXT3) - RGBA(8:8:8:4), 8 bpp, with 4bit alpha, 4:1 compression ratio
  • BC3 (DXT4/DXT5) - RGBA(8:8:8:8), 8 bpp, with 8bit alpha, 4:1 compression ratio
  • BC3n(DXT5) - 8 bpp, specifically for normal maps

MIP Maps

MIP Maps are used for LOD. When rendering textures on models far from the camera, MIP Maps improve performance by using pre-scaled-down versions of textures. They are intended to increase rendering speed and reduce aliasing artifacts, improving image quality.

Texture Atlases

An Nvidia paper - Improve Batching Using Texture Atlases