# How to make 3D LUTs and use them in Blender?

Now that I understand a little bit more about how to work with Wide Dynamic Range workflow , scene referred datas and OPS(ASC CDL) node, I'm asking myself how to go further and create my own 3D LUT to work with in Blender?

Given that Cegaton has dropped 500 on this, it would seem the right place to give a little more fleshing out than a typical post. What follows is a (hopefully) exhaustive and explanatory answer to the question. While it might look daunting, brevity has been sacrificed to clarity. The actual process is much simpler than it appears here, but it is hoped that the explanation that follows will help more imagers explore creative colour transformations in their work, as well as enable some to extend this well beyond 3D LUTs.

# What is a LUT?

LUT is merely an acronym for Look Up Table. The term is also used colloquially to refer to a technique to apply creative looks to footage, as well as the more scientific / mathematical needs of transforming colour spaces. LUTs can be generated off of mathematical functions that are sampled at intervals, or by using tools to extract changes in images.

# What is the difference between a 1D LUT and a 3D LUT?

A 1D LUT at its most basic level is a pure single input to single output transform. Transfer functions such as sRGB's OETF / EOTF and other such decodes / encodes are delivered almost exclusively via 1D LUTs. There are also multi-channel 1D LUTs, where each channel can get back a unique value based on its input, or generic single channel 1D LUTs where all channels receive the same output value given identical input values.

In the following simple single channel 1D LUT, the input value is incremented by 0.1:

# Below is a simple 1D LUT, where a single input value is
# mapped to a single output value. In the case of an RGB
# triplet, each R, G, and B value would be mapped individually
# via the lookup table.
# IN    OUT
0.0   0.1
0.1   0.2
0.2   0.3
...


In the following simple 1D triple channel LUT, the single input value determines the output based on whatever channel is fed. In this case, it increments the assumed red channel by 0.1, the green channel by 0.2, and the blue channel by 0.3:

# Below is a simple 1D by 3 LUT, where a single input value is
# mapped to a single output value. In the case of an RGB
# triplet, each R, G, and B value would be looked up identically
# via the first column, but return a different value based on the
# output column.
# IN    OUT_R OUT_G OUT_B
0.0   0.1   0.2   0.3
0.1   0.2   0.3   0.4
0.2   0.3   0.4   0.5
...


A 3D LUT on the other hand is a much more complex creature. Under a 3D LUT, the unique fingerprint of the three input channels forms a triplet that delivers a unique set of three values back. How is this different than a 1D single or multi-channel LUT? Given that the fingerprint of the input channels is unique, and that you receive three unique values back, a 3D LUT can be used to allow a single channel to adjust the other two channels, where 1D LUTs only change the input value given. This unique characteristic of 3D LUTs, the ability for a single channel to adjust other channels, is the reason that only a 3D LUT can adjust the saturation intents of a given image.

The following 3D LUT raises up all three channels as the green channel value reaches the peak of the cube. Here, the first three values represent the input combination, and the following three represent the output combination:

# Below is a simple 3D LUT exerpt, where a unique combination
# of three input values is mapped to a unique combination of
# three output values as a whole set. Input three values, get back
# three values. The numbers on the input side represent values that
# are scaled from zero to the cube size minus one. Being scaled in
# this fashion, care must be taken to make sure that the input values
# are properly mapped to the 0.0 to 1.0 range that the LUT operates
# on.
# IN_R IN_G IN_B OUT_R    OUT_G    OUT_B
...
0    0    60   0.544367 0.544367 0.9375
0    0    61   0.635998 0.635998 0.953125
0    0    62   0.74127  0.74127  0.96875
0    0    63   0.86195  0.86195  0.984375
0    0    64   1        1        1
...


# How large are the range of values in a LUT?

LUTs operate on an arbitrary range of values that are typically defined within the LUT format itself. In the case of a 1D LUT, it holds one line for each input and output, and as a result can be quite large. Typically 1D LUTs of 4096 values will deliver very accurate results using a simple linear interpolation assuming the “direction” of the transform is technically feasible. Some “directions” suffer breakage when the LUT resolution is not large enough to express the shape properly.

3D LUTs on the other hand, because they represent a combination of three values, are defined using what is known as a cube size. A cube size determines how many steps there are in the combinations. Cube sizes vary from older 17x17x17 cubes to more contemporary 65x65x65 cubes.

The cube size is the number of values that the range will increment before incrementing the next value in the entire series. This means that a cube size of 3 would iterate three times for each channel, until each channel went through the cycle. Note how the first three columns increment, while the following three columns represent the output values. In the case of this example, the 3D LUT is an identity LUT, which simply passes the exact same input values to output:

# Below is a simple 3D LUT exerpt, where a unique combination
# of three input values is mapped to a unique combination of
# three output values as a whole set. Input three values, get back
# three values.
# IN_R IN_G IN_B OUT_R OUT_G OUT_B
0    0    0    0     0     0
0    0    1    0     0     1
0    0    2    0     0     2
0    1    0    0     1     0
0    1    1    0     1     1
0    1    2    0     1     2
0    2    0    0     2     0
0    2    1    0     2     1
0    2    2    0     2     2
...


This means that your final number of entries is equal to the dimensions cubed. In the case of a 65 sized cube, that would be 65^3, or 274625 entries.

# If a LUT operates on a bounded range, how are the other values determined?

Given that both 1D and 3D LUTs operate on bounded ranges, interpolation, or sampling, is used to determine the quality and accuracy of the values between values. In the case of a 3D LUT, the interpolation technique can be extremely important for visual quality.

# How do colour transforms factor into LUTs?

Standard 1D LUTs are commonly used to perform colour transforms along intensity, also known as transfer curves. This can take many forms such as sRGB's OETF / EOTF, REC.709's OETF / EOTF, BT.1886's viewing EOTF, one of the many logarithmic transfer curve encodings, ACES encodings, etc. A 1D LUT is well suited for such application given that many of the transfer curves are piecewise functions and no simple power law will accurately describe them.

3D LUTs require special attention given their uniquely bounded constraints. Given that a 3D LUT represents an arbitrary range, care must be taken to make sure that the range maps the range within our image. This is especially important given visual effects, motion picture, or animation work, as the data is almost exclusively scene referred. Scene referred imagery from cameras or CGI represents a very large range that is mapped to the much smaller gamut of an output device domain, extending from an extremely low value to a theoretically infinitely large value. As a result, there is no one-size-fits-all approach for 3D LUT application, and care must be taken on input and output.

3D LUTs are used in some cases to change the actual colour space primaries of a given encode. Here again, it is critical that the inputs fed to such a LUT are carefully crafted.

The bottom line is that one should never use a transform unless they are absolutely certain that the LUT in question was designed for the input imagery fed. The same caveat applies to output, as a transformed image is applicable only to a single set of output contexts.

Be aware and be careful.

# How are 1D or 3D LUTs created?

How a 1D or 3D LUT is created depends on the context of usage. A 1D LUT representing a transfer function can be created using a spreadsheet or scripting language, properly formatting a function or algorithm to a specific LUT format.

3D LUTs frequently are used to take a crafted look from one series of images or a shot, and apply it equally across to other images in another series or set of shots. To achieve this, we will use a reference image that our tool will understand, apply our look to it, and calculate the 3D LUT based on the differences between the two image states.

# What will you need?

To generate a 3D LUT for use in Blender, you will require:

1. OpenColorIO . OpenColorIO has several tools that are built when you have OpenImageIO present on your system. In particular, we will be requiring ociolutimage and ocioconvert.
2. Given that the original question pertains to wide dynamic range filmic LUTs, you will also require the OpenColorIO configuration package that implements them. They can be found in my answer to Cegaton's original question here. If you have arrived here and are reading this without a familiarity of the concepts, it is heavily advised you read the original question and the ridiculously long answer I gave there. With that background, you should be much more well equipped to deal with the information provided in this solution.

# Can you summarize the steps involved?

Loosely, we will be doing the following steps to implement custom LUTs in Blender. Note this specifically pertains to LUTs, but a similar technique can be used to apply canned ASC CDL values in your own configuration.

1. We will muck creatively with a sample image and arrive at a look that we are happy with.
2. We will generate a unique all-in-one image that is used as our reference baseline, and plug that into our look compositing chain.
3. We will use OpenColorIO's tools to generate the 3D LUT from the modifications.
4. We will plug the newly crafted 3D LUT into our configuration and make it accessible from within Blender for reuse.

# 1.0 Mucking

Consider the following image. Not terribly impressive, but it has been chosen because it is a scene referred image which is a smaller region of Maxime Roz's HDRI pack. Again, scene referred imagery is a range of values that extends from zero to infinity, with no notion of black nor white, and no special meaning to values such as 1.0. Cycles, being a ray tracing engine, always generates scene referred imagery, and as such, our techniques must take this into consideration. The following attached image is of course display referred, and does not have the original scene referred values. It was created using the Filmic Blender LUT set.

After countless hours of toil and sweat, the wonderfully creative output of our grading is the following. Again, because this was generated in Blender, which as a scene referred internal model, the values extend from zero to extremely high ratios such as 50.0+ outside the window. The image attached below has been manipulated in Blender while using the views and looks present in the Filmic Blender LUT set, as above.

The work is done. Assuming we have other shots we would like to apply this look to, or build up our library of sample looks for this particular set of constraints, we need to forge ahead and craft a LUT. As we can see, there has been both a slight contrast tweak added, as well as some saturation adjustments. Knowing that only a 3D LUT can do both, we will need to create a 3D LUT for this work.

# 2.0 Generate the all-in-one

LUTs can be generated both via algorithm or, more commonly, from source imagery. The way to generate a look based off of a tweak one has given to source footage is to generate a known constant image and use it to compare against an identical constant, tweaked to the same tuning as the desired creative output.

There are demons hidden away here though. 3D LUTs, given that they operate on a specifically designed range of values, must be properly mapped in order to both meet the range, but to optimise their application. In this case, since the question pertains to the Filmic LUT set, we need to make sure that our 3D LUT is going to sufficiently match what we saw when we were grading the image. Some 3D LUT formats include the "shaper" compression component inside their actual LUT formats.

Given that the default Blender sRGB OETF / EOTF was never designed for any sort of creative work such as raytracing, it is sub-optimal on a number of levels. As a replacement, the Filmic LUT set consists primarily of two transformations that make work appear more photographic. First, it uses a much wider dynamic range from the scene referred values than the default sRGB EOTF uses. This means your work includes and requires light that more closely matches physical, real-world levels. As a result you'll notice much better radiosity, caustics, indirect bouncing, etc. Second, it uses a 3D LUT to desaturate the values as they ascend to the higher dynamic range, attempting to emulate what happens on colour film or DSLRs, and prevent the broken, wonky saturations that happen as colours get very intense under the default view.

When making a 3D LUT, care must be taken to match the exact same contexts that we are developing our work under. An all-in-one image covers a fully normalised range of values, at a cube size you choose. However, given that it covers a normalised range, and that our work was crafted on a scene referred range, we have to transform our all-in-one accordingly, so that the reference spaces match.

To prepare our environment, we must tell the OpenColorIO applications where our OpenColorIO configuration is. It should be noted that if we do this in the CLI and run Blender from that same CLI, Blender too will honour the location of the configuration, and ignore the internal configuration located in your installation, at datafiles/colormanagement/config.ocio.

Setting the environment variable might look like:

export OCIO=/path/to/local/configuration/config.ocio


That command looks as follows:

ociolutimage --generate --cubesize 65 --maxwidth 4225 --colorconvert "Desat Log Encoding" Linear --output /path/to/scenereferred_allinone.exr


What does it do?

1. --generate instructs ociolutimage that we want to create an all-in-one image.
2. --cubesize 65 instructs ociolutimage to use a cube size of 65, which is a very high quality 3D LUT.
3. --maxwidth 4225 is a somewhat optional feature to make the resulting image legible to a human. This keeps the image such that the individual cubes are small blocks of colours ascending, and is easily understandable in an imaging application. If we leave this out, the values end up unaligned and can be tricky to understand in an image viewer.
4. --colorconvert "Desat Log Encoding" Linear instructs ociolutimage to transform the all-in-one for us, from the normalized range which we interpret here as the "Desat Log Encoding" transform, to the scene referred "Linear" encoding we are feeding in our reference space. This will transform the values from 0.0 to 1.0 to the scene referred range of the desaturation shaper, which extends up to 184.32 scene referred. This is also why we need to specify our OCIO configuration above. It should point to the Filmic LUT set.
5. --output /path/to/logencoded_allinone.exr specifies where we want the output EXR file to be stored on our drive.

Using the above output file scenereferred_allinone.exr, we simply add it into our node chain right where our source enters. In the viewer, we can use the Save Image As... function to save the adjusted all-in-one as a modified version EXR. Save this file as scenereferred_modified.exr at 32 bit depth.

# 3.0 Generate the 3D LUT

Now we have a modified all-in-one that is precisely identical to the modifications in our original grading node chain. We will use this as the blueprint to create the 3D LUT.

First we must be certain that we establish the OCIO environment variable so the following transforms are understood:

export OCIO=/path/to/local/configuration/config.ocio


Next, we have a quick hop to return the image from the scene referred domain that it currently exists in, to the log-encoded nonlinear range that the transforms in the Filmic LUT set expect. Why? Because if simply scale our cube size, our 3D LUT would be biased to a range that isn't terribly perceptually uniform. This would potentially lead to posterization or poor quality. To make our 3D LUT useful, we need the reduced range to reference a more perceptually uniform set of values with our modifications.

That transform looks like this:

ocioconvert scenereferred_modified.exr Linear logencoded_modified.exr "Desat Log Encoding"


The above transforms our all-in-one image into the log encoded range our LUT set expects. Both "Linear" and "Desat Log Encoding" are transforms listed in the Filmic LUT set, and again why we have to specify the environment variable above.

Now that we have the EXR file primed for generation with our modifications, we will again lean on ociolutimage to do the heavy lifting.

ociolutimage --extract --cubesize 65 --maxwidth 4225 --input /path/to/logencoded_modified.exr --output /path/to/3dLUTdemo-LUT.spi3d


Breaking this down:

1. --extract instructs ociolutimage to extract the 3D LUT from the input file, which is assumed to be an all-in-one created with the options provided.
2. --cubesize 65 instructs ociolutimage that the image contains a cube size of 65. If we leave this out, ociolutimage will complain that the file doesn't match the default expectation.
3. --maxwidth 4225 instructs ociolutimage that the image is of the specified width. If we leave this out, ociolutimage will complain that the file doesn't match the default expectation.
4. --input /path/to/logencoded_modified.exr simply specifies the path to our input all-in-one modified image. Remember, it should be perceptually uniform if you want your 3D LUT to not look whack.
5. --output /path/to/3dLUTdemo-LUT.spi3d is the path to the solution to the question; the 3D LUT, in Sony Pictures Imageworks format.

# 4.0 Integrate the thing

As for the final step, we will be using the look section of the Filmic LUT set, and adding in our own. This again will require a few extra entries because of the slightly complex method that the views and looks are implemented to generate the desaturation.

First, make a backup copy of your Filmic LUT set just in case you mangle something up beyond recognition.

Next, copy the 3dLUTdemo-LUT.spi3d into the looks directory of the Filmic LUT set.

Now, find the config.ocio file for the set at the root of the directory and open it with a standard text editor. We are going to do the following:

1. Add a new desaturation log encoding base view to base our looks off of.
2. Add our new look to the list of looks.

To accomplish the first thing, we need to simply enable the preexisting transform for the "Desat Log Encoding" into a view. To do that, locate the section that looks like the following:

[...]
displays:
Filmlike:
- !<View> {name: sRGB EOTF, colorspace: sRGB EOTF}
- !<View> {name: Data, colorspace: Raw}
- !<View> {name: Scene Linear, colorspace: Linear}
- !<View> {name: Filmic Log Encoding Base, colorspace: Filmic Log Encoding}
- !<View> {name: Filmic High Contrast, colorspace: Filmic Log Encoding, look: +High Contrast}
- !<View> {name: Filmic Medium High Contrast, colorspace: Filmic Log Encoding, look: +Medium High Contrast}
- !<View> {name: Filmic Medium Low Contrast, colorspace: Filmic Log Encoding, look: +Medium Low Contrast}
- !<View> {name: Filmic Low Contrast, colorspace: Filmic Log Encoding, look: +Low Contrast}
- !<View> {name: Filmic Base Contrast, colorspace: Filmic Log Encoding, look: +Base Contrast}
#    - !<View> {name: Debug, colorspace: Debug}
[...]


We add the preexisting transform to the list of views as follows. We will tuck it in just after the Filmic Log Encoding Base. Again, the reasons for this additional step is the way in which the desaturation is crafted for the transform set, and therefore we require a very specific view for our LUTs:

[...]
displays:
Filmlike:
- !<View> {name: sRGB EOTF, colorspace: sRGB EOTF}
- !<View> {name: Data, colorspace: Raw}
- !<View> {name: Scene Linear, colorspace: Linear}
- !<View> {name: Filmic Log Encoding Base, colorspace: Filmic Log Encoding}
- !<View> {name: Desat Log Encoding Base, colorspace: Desat Log Encoding}
- !<View> {name: Filmic High Contrast, colorspace: Filmic Log Encoding, look: +High Contrast}
- !<View> {name: Filmic Medium High Contrast, colorspace: Filmic Log Encoding, look: +Medium High Contrast}
- !<View> {name: Filmic Medium Low Contrast, colorspace: Filmic Log Encoding, look: +Medium Low Contrast}
- !<View> {name: Filmic Low Contrast, colorspace: Filmic Log Encoding, look: +Low Contrast}
- !<View> {name: Filmic Base Contrast, colorspace: Filmic Log Encoding, look: +Base Contrast}
#    - !<View> {name: Debug, colorspace: Debug}
[...]


Finally, we add the look itself down near the bottom of the file. Looks are added in a first in, last listed fashion. We can add the look to the top of the looks section as follows:

[...]
looks:
- !<Look>
name: My Demo Look
process_space: Desat Log Encoding
transform: !<GroupTransform>
children:
- !<FileTransform> {src: 3dLUTdemo-LUT.spi3d, interpolation: best}
- !<FileTransform> {src: Base_DesatCrosstalk_DesatLog.spi3d, interpolation: best}
- !<FileTransform> {src: DesatLog_to_EncodeLog.spi1d, interpolation: best, direction: inverse}
- !<FileTransform> {src: Linear_to_0-7_1-05_Tonemap.spi1d, interpolation: best}

- !<Look>
name: Greyscale
process_space: Filmic Log Encoding
transform: !<MatrixTransform> {matrix: [0.2126729, 0.7151521, 0.0721750, 0, 0.2126729, 0.7151521, 0.0721750, 0, 0.2126729, 0.7151521, 0.0721750, 0, 0, 0, 0, 1]}

- !<Look>
name: False Colour
process_space: Filmic Log Encoding
transform: !<FileTransform> {src: Filmic_False_Colour.spi3d, interpolation: best}
[...]


Note that there are four transforms listed in our witch's brew. Why? In order:

1. - !<FileTransform> {src: 3dLUTdemo-LUT.spi3d, interpolation: best} is our LUT that we created. The process space takes care of taking us to the "Desat Log Encoding" range of values, and the 3D LUT applies our creative transform to those values.
2. - !<FileTransform> {src: Base_DesatCrosstalk_DesatLog.spi3d, interpolation: best} is the basic desaturation for the Filmic Set. This is required to match the desaturation look when we were generating the grade.
3. - !<FileTransform> {src: DesatLog_to_EncodeLog.spi1d, interpolation: best, direction: inverse} is the reduced range from the desaturation range shaper. This stores the encoded image with a display referred white that is lower than the desaturation range. The reasons for this are beyond the scope of this document.
4. - !<FileTransform> {src: Linear_to_0-7_1-05_Tonemap.spi1d, interpolation: best} applies the "Base Contrast" tonemap look to the reduced range, as they were designed. This final transform should match the tonemap transform you graded under, if you happened to have. If you didn't, you can leave this out. Pay attention to you views and looks when grading, as you must match the chain perfectly in order to get 1:1.

# Test it out

If all went well, you can now load Blender and find your newly created My Demo Look listed in your looks. To apply it to any scene referred EXR that you have lit / modified under the Filmic Set, you now must use the newly created Desat Log Encoding Base view we created, with our My Demo Look`. It will be a 1:1 match with your original grade, now nicely canned way for application against other shots or used as a base entry point.

The adventurous types can further their efforts by exploring canning ASC-CDL values into pre-baked looks, using the OCIO environment variables to set up grading sessions based on shot names and numbers, or other such powerful tricks. If anyone is interested in these sorts of drek and the further applications listed, feel free to ask them as a question. This will keep already lengthy solutions from sprawling out of control, as this one already has.

Hope this helps...

• Check out 2.79 test build, is 3D LUT now part of the official distribution? Or am I just seeing things. Commented Jul 5, 2017 at 18:45