Oil Painting Post-process Effect in Unreal Engine
Oil painting post-effect is a rather common filter routinely found in the graphics software such as GIMP, or Photoshop. It allows you to give your image a more "blocky" and artistic feeling. It is also surprisingly easy to create via post-processing material in Unreal Engine.
Idea
The idea behind the algorithm is relatively simple and rather similar to the Guassian blurring filter. For each pixel we need to search its neighborhood and find intensities of the neighboring pixels. The intensities are then divided into bins and the most common intensity is used to create new pixel color.
I suggest reading this (with pseudocode) or this (with C++ code) page for deeper understanding of the issue.
Code
Directly within Unreal Engine, you will only need a simple setup to make the shader work. We will use SceneTexture:PostprocessInput0 to get the image that is to be rendered to the user, a constant to tweak with the radius of the effect, texture coordinates of the processed pixel and the custom node that will hold the majority of the computation.
Setup the nodes as follows:
Use following code within Custom Expression code field:
The Custom node then returns the float4 representing the color of the processed pixel that can be directly connected to the Output node, or further processed.
Setup the nodes as follows:
Use following code within Custom Expression code field:
int TexIndex = 14;
int intensityCount[10];
float avgR[10];
float avgG[10];
float avgB[10];
for (int iLevel = 0; iLevel < 10; iLevel++){
intensityCount[iLevel] = 0;
avgR[iLevel] = 0.0;
avgG[iLevel] = 0.0;
avgB[iLevel] = 0.0;
}
//COUNT INTENSITIES
uv *= 0.5;
for (int i = 0; i < radius; ++i)
{
int offsetI = -1 *(radius / 2) + i;
float v = uv.y + offsetI * invSize.y;
int temp = i * radius;
for (int j = 0; j < radius; ++j)
{
int offsetJ = -(radius / 2) + j;
float u = uv.x + offsetJ * invSize.x;
float2 uvShifted = uv + float2(u, v);
float3 tex = SceneTextureLookup(uvShifted, TexIndex, false);
float currentIntensity = ((tex.r + tex.g + tex.b) / 3 * 10);
intensityCount[currentIntensity]++;
avgR[currentIntensity] += tex.r;
avgG[currentIntensity] += tex.g;
avgB[currentIntensity] += tex.b;
}
}
float maxIntensity = 0;
int maxIndex = 0;
//FIND CORRECT MOST COMMON INTENSITY LEVEL
for(int cLevel = 0; cLevel < 10; cLevel++){
if(intensityCount[cLevel] > maxIntensity){
maxIntensity = intensityCount[cLevel];
maxIndex = cLevel;
}
}
float newR = avgR[maxIndex] / maxIntensity;
float newG = avgG[maxIndex] / maxIntensity;
float newB = avgB[maxIndex] / maxIntensity;
float4 res = float4(newR, newG, newB, 1.0);
return res;
int intensityCount[10];
float avgR[10];
float avgG[10];
float avgB[10];
for (int iLevel = 0; iLevel < 10; iLevel++){
intensityCount[iLevel] = 0;
avgR[iLevel] = 0.0;
avgG[iLevel] = 0.0;
avgB[iLevel] = 0.0;
}
//COUNT INTENSITIES
uv *= 0.5;
for (int i = 0; i < radius; ++i)
{
int offsetI = -1 *(radius / 2) + i;
float v = uv.y + offsetI * invSize.y;
int temp = i * radius;
for (int j = 0; j < radius; ++j)
{
int offsetJ = -(radius / 2) + j;
float u = uv.x + offsetJ * invSize.x;
float2 uvShifted = uv + float2(u, v);
float3 tex = SceneTextureLookup(uvShifted, TexIndex, false);
float currentIntensity = ((tex.r + tex.g + tex.b) / 3 * 10);
intensityCount[currentIntensity]++;
avgR[currentIntensity] += tex.r;
avgG[currentIntensity] += tex.g;
avgB[currentIntensity] += tex.b;
}
}
float maxIntensity = 0;
int maxIndex = 0;
//FIND CORRECT MOST COMMON INTENSITY LEVEL
for(int cLevel = 0; cLevel < 10; cLevel++){
if(intensityCount[cLevel] > maxIntensity){
maxIntensity = intensityCount[cLevel];
maxIndex = cLevel;
}
}
float newR = avgR[maxIndex] / maxIntensity;
float newG = avgG[maxIndex] / maxIntensity;
float newB = avgB[maxIndex] / maxIntensity;
float4 res = float4(newR, newG, newB, 1.0);
return res;
The Custom node then returns the float4 representing the color of the processed pixel that can be directly connected to the Output node, or further processed.
Result
You can see the effect using Effect Radius = 10 picture below.
While following image shows the scene without applying the filter.
Following video shows the filter running on Soul City map.
While following image shows the scene without applying the filter.
Following video shows the filter running on Soul City map.
Issues
- Since the shader code needs to search the neighborhood, it's obvious that with increased "Effect Radius" parameter this computation complexity increases. Parameters between 5 and 10 should make the effect pronounced enough for the most cases and still keep performance relatively stable.
- Currently the shader doesn't use adaptive radius based on the scene depth, so objects in background seem more blurred than images in foreground (this may or may not be desirable)
- If you are using UE 4.19+ you may get a strange issue where part of the screen is black. Refer to this article for solution
- Currently the shader doesn't use adaptive radius based on the scene depth, so objects in background seem more blurred than images in foreground (this may or may not be desirable)
- If you are using UE 4.19+ you may get a strange issue where part of the screen is black. Refer to this article for solution
References
Oil Paint Effect by Santhosh_G - C++ CPU code for the Oil Painting effect along with the explanation of the filtering process.
The Supercomputing Blog on Oil Painting Algorithm - pseudocode CPU code of which my code was based off, along with short explanation of the filtering process.
Gaussian Blur Example - containing GPU code for UE4 used as the base for neighborhood search
Soul: City - free assets from Epic Games used for testing of the filter
its not working i did everything but the scene texture just has the brown error texture.
ReplyDeleteis this working in 4.20?
ReplyDeleteYes sir! Sorry for late reply, this tutorial (as most of the others I'm posting) was written for UE 4.18., but I tested it on 4.20.
DeleteCheck the issues section of the tutorial, mainly the last point, that could help you solve the problem. If it doesn't let me know and we can try to resolve it together.
Hi, thanks for the tutorial. My post process material shows up as just pure white. Wondering what I'm doing wrong. Probably a lot of things. :) Also using 4.18.
DeleteYou forgot to enter the code into the nodes code box I however have the problem of everything i see taking up only 75% of the screen
Deletehey, check this out, might be helpful https://ferkizue.blogspot.com/2018/07/fixing-wrong-scene-size-in-unreal-419.html
DeleteHi, thanks for the tutorial. My post process material shows up as just pure white. Wondering what I'm doing wrong. Probably a lot of things. :) Also using 4.18.
ReplyDeletehey, you can you post your setup and/or code? Are you getting any error? Can't say I've ever encountered screen being all white with this shader, but we will figure it out
DeleteI am having this problem as well was it ever solved?
DeleteHey, this looks great but I can't get it to work at all. In 4.18 it results in an entirely black image and in 4.20 it results in the usual "brown edge" effect for Post Process materials. Here are my material setups and such: https://imgur.com/a/8pNf9Xv Hope you an help.
ReplyDeleteHi there, I wondered if there was a way to cull this effect so that the skysphere is not effected. Am I right in thinking you could use scene depth to achieve this. Could you let me know if you have any thoughts or advice on how to achieve this as essentially all I want to effect is the landscape and the buildings and leave the sky as it is normally.
ReplyDeleteHey, certainly! You can use Scene Depth to sample the depth and if it's too much, use PostProcessing color, otherwise use the filter. I think the scene depth changed a little bit since 4.19, so you might need to give it a shot and see if it works.
DeleteAlternatively, you can use Custom Depth Buffer to pick only the objects to which you want to apply to filter, see https://ferkizue.blogspot.com/2018/08/partial-post-process-effect-application.html
If you have the actual object for the skyshape, then you can render it to Custom Depth Buffer and check in postprocessing material if given pixel is in custom depth buffer and at the same time there is not something in scene depth that is closer to camera. Hope it makes at least some sense.
Hi Zuzana, thanks for the speedy reply. I will give this a try as I am a noob but your tutorials have been really helpful in learning and understanding this topic. Hopefully I have some luck with this else no doubt I will be back asking for some help haha
DeleteAlways happy to help :) Feel free to drop me PM at my twitter account here https://twitter.com/FerkiZue if you need help, should be faster than via comments
DeleteHello Zuzana, thanks for the tutorial. I tested this code in both 4.20 and 4.18 but it only works in 4.18. Your issues section can not fix it too, could you check your code in 4.20 again? thanks!
ReplyDeleteHey!
Deletehave you tried to go through Issues section? If you add the pin and connect it with the scene texture, you also need to delete the first line in the Custom node. If this doesn't help, try turning on shader development mode and let me know what the error is.
Unfortunately I don't have UE available right now, so I won't be able to post 4.20 tweaks until New Year
Hi. I followed your instructions, but I have a weird issue where overly white areas of the image (Such as the sun, or on 2D sprites) are turquoise.
ReplyDeletehttps://imgur.com/a/todCTXk
Hey, unfortunately I never tested it on scene with 2D sprites and frankly I haven't worked with 2D sprites much when it comes to UE. I am currently swamped but I will try to take a look and get some sprites in. If this happens to you on UE sun too, then I must say never happened to me before, either, but things changed a tad in 4.20, so I will need to investigate.
DeleteGetting this error...
ReplyDelete[SM5] /Engine/Generated/Material.ush(1521,18-43): error X3004: undeclared identifier 'GetPostProcessInputSize'
when I create a new pin for the InvSize, and connect it up I get this error:
[SM5] /Engine/Generated/Material.ush(1521,18-43): error X3004: undeclared identifier 'GetPostProcessInputSize'
what line specifically do you remove of code?
DeleteOK... I changed the first line of code to this:
ReplyDeletefloat2 invSize = InvSize;
it works but cuts out all emissive materials they turn black...
uh... ok looks like its just making black any value above in the render? mainly hot reflections and glows
ReplyDeletesorry a question in the material when you have the custom node what does the output node connect to for the actual material?
ReplyDeleteSorry as a followup it seems i can get the effect to work however it scrunches the resolution, has anyone com across this?
ReplyDeleteHey, this could help https://ferkizue.blogspot.com/2018/07/fixing-wrong-scene-size-in-unreal-419.html
Delete