Targeted In-Game Style Transfer Tutorial
- Overview
- Download Unity Project
- Method
- Create Depth Cameras
- Update StyleTransferShader
- Update StyleTransfer Script
- Assign Depth Cameras
- Assign Stylize Layer With Code
Overview
In this follow up to the in-game style transfer tutorial, we’ll cover a method to stylize specific GameObjects in a Unity. This tutorial builds on the project from the previous tutorial. If you did not follow that tutorial, you can follow the steps to download the project below.
Download Unity Project
Note: You can skip this section If you followed the previous tutorial and already have the project.
You can download the project from the previous tutorial by clicking on the link below.
- GitHub Repository: (download)
Once the download is complete, extract the project folder.
Open Unity Hub and click the Add
button.
Navigate to the extracted End-to-End-In-Game-Style-Transfer-Tutorial-Intel-main
folder and select the Kinematica_Demo_0.8.0-preview
folder.
We’ll be working in Unity 2019.4.20, but later versions are also supported.
The project will take several minutes to open the first time as it imports the project assets.
Fix AssetImporter
Error
You might get an error like the one below after opening the project in Unity.
AssetImporter is referencing an asset from the previous import. This should not happen.
You can fix this issue by rebuilding the Unit asset. Open the Kinematica
folder in the Assets section and double-click on the Unit
asset. This will open the Kinematica Asset Builder
window.
Click Build
in the pop-up window. You can close the pop-up window once the build is complete.
Back in the Assets
section, open the Biped
scene in the Scenes
folder. The project should run normally now. However, there might be some stuttering the first time it is run.
Method
For this method, we’ll still be using the entire camera frame as the input for the style transfer model. However, we can use Layers to identify what parts of the final image will be from the stylized version of the current frame.
We’ll need to capture depth data from the scene to determine what needs to be stylized at runtime. We can’t use the regular color data because the lighting is different when a camera only renders certain parts of the scene.
Standard Scene
Character Only
Here is an example of depth data captured from the camera that has been edited to be more easily viewable. Depth data is stored in the red color channel and can be difficult to see.
Create Depth Cameras
In the Hierarchy
tab, select the Main Camera
object and press Ctrl-d twice to create two copies.
Hold Ctrl and select both of the camera copies. Drag them onto the original Main Camera
object to make them child objects.
With the duplicates still selected, we will remove every component except the Camera
component. You can remove components by right-clicking on the component name and selecting Remove Component
.
Also, make sure that the position and rotation values for the Transform component for each of the depth cameras are set to to zero. We need to make sure they are in exactly the same position as the Main Camera, or the depth values will be off.
One of the duplicate cameras will capture the depth data for the GameObjects we want to stylize. We’ll call this one Style Depth
. The second duplicate will capture the depth data for the standard scene, and be called Source Depth
.
Create Stylize Layer
We’ll identify the GameObjects we want to stylize by assigning a new Layer to these objects. Select the Layers
drop-down menu and click Edit Layers...
.
In the Inspector tab open the Layers
drop-down and enter Stylize
in the User Layer 12
slot.
Set Style Depth Culling Mask
Lastly, with the Stylize Depth
camera selected, set the Culling Mask
to Stylize
only in the Inspector tab.
Assign Stylize Layer
We can assign the Stylize layer to just about any GameObject we wish. To start, let’s assign it to the Unit
parent object in the Hierarchy
tab which contains the GameObjects for the character model. With the Unit object selected in the Hierarchy tab, select the Stylize
layer from the Layer drop-down in the Inspector tab.
Note: You might get a bunch of errors in the console when you select the Unit object. You can ignore these.
You will be prompted to chose whether to set the layer for the child objects as well. Select Yes, change children
.
Update StyleTransferShader
Next, we will add a new function to our compute shader that will select what parts of the stylize frame to keep. In the Assets section, open the Shaders
subfolder located inside the Style_Transfer
folder. Open the StyleTransferShader
in your code editor.
First, we need to add three new Texture2D variables. Two of these will store the depth data from the Style Depth
and Source Depth
cameras. The third will store the original image for the current frame.
// Each #kernel tells which function to compile; you can have many kernels
#pragma kernel ProcessInput
#pragma kernel ProcessOutput
// Create a RenderTexture with enableRandomWrite flag and set it
// with cs.SetTexture
<half4> Result;
RWTexture2D// Stores the input image and is set with cs.SetTexture
<half4> InputImage;
Texture2D
// Stores the depth data from the StyleDepth camera
<half4> StyleDepth;
Texture2D// Stores the depth data from the SourceDepth camera
<half4> SrcDepth;
Texture2D// Store the original image from the current frame
<half4> SrcImage;
Texture2D
[numthreads(8, 8, 1)]
void ProcessInput(uint3 id : SV_DispatchThreadID)
{
[id.xy] = half4((InputImage[id.xy].x * 255.0h),
Result(InputImage[id.xy].y * 255.0h),
(InputImage[id.xy].z * 255.0h), 1.0h);
}
Now we can add the new function. We’ll call it Merge
since it merges the original and stylized frame. This function compares the pixel values from the StyleDepth and SrcDepth textures. If they match, that means a target GameObject is present and there is nothing in front of it. However, this does not account for parts of the scene with infinite depth like the sky. We can add another check to see if the current pixel value for the StyleDepth texture is greater than zero.
[numthreads(8, 8, 1)]
void Merge(uint3 id : SV_DispatchThreadID)
{
bool match = StyleDepth[id.xy].x == SrcDepth[id.xy].x;
if (match && (StyleDepth[id.xy].x > 0)) {
[id.xy] = half4(InputImage[id.xy].r, InputImage[id.xy].g, InputImage[id.xy].b, InputImage[id.xy].a);
Result}
else {
[id.xy] = half4(SrcImage[id.xy].r, SrcImage[id.xy].g, SrcImage[id.xy].b, SrcImage[id.xy].a);
Result}
}
Now we just need to add the #pragma kernel Merge
line at the top of the file.
// Each #kernel tells which function to compile; you can have many kernels
#pragma kernel ProcessInput
#pragma kernel ProcessOutput #pragma kernel Merge
Update StyleTransfer Script
The next step is to modify the StyleTransfer
script. The script is located in the Scripts subfolder inside the Style_Transfer folder.
Add Depth Camera Variables
The only new variables we need to add are for the two depth cameras. We’ll name them styleDepth
and sourceDepth
respectively. We can also add another bool
variable to toggle the targeted stylization.
public class StyleTransfer : MonoBehaviour
{
[Tooltip("Performs the preprocessing and postprocessing steps")]
public ComputeShader styleTransferShader;
[Tooltip("Stylize the camera feed")]
public bool stylizeImage = true;
[Tooltip("Stylize only specified GameObjects")]
public bool targetedStylization = true;
[Tooltip("The height of the image being fed to the model")]
public int targetHeight = 540;
[Tooltip("The model asset file that will be used when performing inference")]
public NNModel modelAsset;
[Tooltip("The backend used when performing inference")]
public WorkerFactory.Type workerType = WorkerFactory.Type.Auto;
[Tooltip("Captures the depth data for the target GameObjects")]
public Camera styleDepth;
[Tooltip("Captures the depth data for the entire scene")]
public Camera sourceDepth;
Assign Depth Textures
Currently, the depth cameras are just capturing standard color data. We need to manually assign Depth
textures to the targetTexture
property for both of the cameras. We’ll do this at the top of the Start()
method. The textures need to have the same dimensions as target screen. We can access this in Screen.width
and Screen.height
.
// Start is called before the first frame update
void Start()
{
// Get the screen dimensions
int width = Screen.width;
int height = Screen.height;
// Force the StyleDepth Camera to render to a Depth texture
.targetTexture = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.Depth);
styleDepth.forceIntoRenderTexture = true;
styleDepth// Force the SourceDepth Camera to render to a Depth texture
.targetTexture = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.Depth);
sourceDepth.forceIntoRenderTexture = true;
sourceDepth
// Compile the model asset into an object oriented representation
= ModelLoader.Load(modelAsset);
m_RuntimeModel
// Create a worker that will execute the model with the selected backend
= WorkerFactory.CreateWorker(workerType, m_RuntimeModel);
engine }
Update Depth Texture Dimensions
If the screen resolution changes while the project is running, the resolution for the depth textures will need to be updated as well. We can check if the screen resolution has changed in the Update()
method.
private void Update()
{
if (styleDepth.targetTexture.width != Screen.width || styleDepth.targetTexture.height != Screen.height)
{
// Get the screen dimensions
int width = Screen.width;
int height = Screen.height;
// Assign depth textures with the new dimensions
.targetTexture = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.Depth);
styleDepth.targetTexture = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.Depth);
sourceDepth}
}
Release Depth Textures
We will release the temporary Depth textures in the OnDisable()
method.
// OnDisable is called when the MonoBehavior becomes disabled or inactive
private void OnDisable()
{
// Release the resources allocated for the inference engine
.Dispose();
engine// Release the Depth texture for the StyleDepth camera
.ReleaseTemporary(styleDepth.targetTexture);
RenderTexture// Release the Depth texture for the SourceDepth camera
.ReleaseTemporary(sourceDepth.targetTexture);
RenderTexture}
Create Merge()
Method
Next, we need to add a new method to dispatch the Merge function in the compute shader. This method will be nearly identical to the existing ProcessImage()
method except that it will also set the values for the StyleDepth
, SrcDepth
, and SrcImage
variables.
/// <summary>
/// Merge the stylized frame and the original frame on the GPU
/// </summary>
/// <param name="styleImage"></param>
/// <param name="sourceImage"></param>
/// <returns>The merged image</returns>
private void Merge(RenderTexture styleImage, RenderTexture sourceImage)
{
// Specify the number of threads on the GPU
int numthreads = 8;
// Get the index for the specified function in the ComputeShader
int kernelHandle = styleTransferShader.FindKernel("Merge");
// Define a temporary HDR RenderTexture
int width = styleImage.width;
int height = styleImage.height;
= RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGBHalf);
RenderTexture result // Enable random write access
.enableRandomWrite = true;
result// Create the HDR RenderTexture
.Create();
result
// Set the value for the Result variable in the ComputeShader
.SetTexture(kernelHandle, "Result", result);
styleTransferShader// Set the value for the InputImage variable in the ComputeShader
.SetTexture(kernelHandle, "InputImage", styleImage);
styleTransferShader// Set the value for the StyleDepth variable in the ComputeShader
.SetTexture(kernelHandle, "StyleDepth", styleDepth.activeTexture);
styleTransferShader// Set the value for the SrcDepth variable in the ComputeShader
.SetTexture(kernelHandle, "SrcDepth", sourceDepth.activeTexture);
styleTransferShader// Set the value for the SrcImage variable in the ComputeShader
.SetTexture(kernelHandle, "SrcImage", sourceImage);
styleTransferShader
// Execute the ComputeShader
.Dispatch(kernelHandle, result.width / numthreads, result.height / numthreads, 1);
styleTransferShader
// Copy the result into the source RenderTexture
.Blit(result, styleImage);
Graphics
// Release the temporary RenderTexture
.ReleaseTemporary(result);
RenderTexture}
Update OnRenderImage()
Method
We’ll call the Merge()
method inside the OnRenderImage()
method. First, we need to create a copy of the current frame before it gets stylized. The Merge()
method will only be called when targetedStylization
is set to true
and stylizeImage
is set to true
. Lastly, we need to release the temporary RenderTexture
for the copy of the current frame.
/// <summary>
/// OnRenderImage is called after the Camera had finished rendering
/// </summary>
/// <param name="src">Input from the Camera</param>
/// <param name="dest">The texture for the targer display</param>
void OnRenderImage(RenderTexture src, RenderTexture dest)
{
// Create a temporary RenderTexture to store copy of the current frame
= RenderTexture.GetTemporary(src.width, src.height, 24, src.format);
RenderTexture sourceImage // Copyt the current frame
.Blit(src, sourceImage);
Graphics
if (stylizeImage)
{
StylizeImage(src);
if (targetedStylization)
{
// Merge the stylized frame and origina frame
Merge(src, sourceImage);
}
}
.Blit(src, dest);
Graphics
// Release the temporary RenderTexture
.ReleaseTemporary(sourceImage);
RenderTexture}
Assign Depth Cameras
The last step is to assign the depth cameras to the StyleTransfer
script in the Inspector tab. Select the Main Camera object in the Hierarchy tab. Drag and drop each of the depth cameras into their respective spots in the Inspector tab.
Test it Out
If you hit the play button, you should see that only the GameObjects with the Stylize layer are stylized.
Assign Stylize Layer With Code
You might want to change which objects are stylized depending on certain conditions. As an example, we’ll demonstrate how to toggle stylization on and off for a GameObject when they are clicked on with the mouse at runtime.
Method
A common method to select GameObjects with the mouse is to use the Physics.Raycast()
method. This approach involves casting a ray (i.e. a line) from the camera to the current mouse position. If that ray makes contact with a GameObject that has a collider component, we can identify that object and manipulate it in code.
This method will not work if the GameObject does not have a collider, so keep that in mind when using this approach in other projects.
The Kinematica Demo is not ideally structured for selecting individual GameObjects with the mouse. For example, the colliders for the level boundaries are not directly attached to the GameObjects like the one outlined below. That means we can’t click on any of the four level boundaries without adding new collider components.
The floor panels already have their own colliders attached and will work out of the box.
The obstacles that we can climb over are actually children of GameObjects that have a collider. That means every child object will be stylized rather than the specific child object we click on.
Steps
We’ll implement the following steps in the Update()
method.
- Check if the mouse button has been clicked with
Input.GetMouseButtonUp()
method. - Create a new
Ray
variable that contains a ray going from the main camera to the current mouse position - Create a new
RaycastHit
variable. - Check if the ray has hit any colliders with the
Physics.Raycast()
method.- Get a list of the child objects of the GameObject with the collider, including GameObject itself.
- Iterate through the list of child objects.
- Check if the child object has a
MeshRenderer
component that is enabled.- Toggle the objects layer between
Default
andStylize
.
- Toggle the objects layer between
- Check if the child object has a
if (styleDepth.targetTexture.width != Screen.width || styleDepth.targetTexture.height != Screen.height)
{
// Get the screen dimensions
int width = Screen.width;
int height = Screen.height;
// Assign depth textures with the new dimensions
.targetTexture = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.Depth);
styleDepth.targetTexture = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.Depth);
sourceDepth}
if (Input.GetMouseButtonUp(0))
{
= Camera.main.ScreenPointToRay(Input.mousePosition);
Ray ray ;
RaycastHit hitif (Physics.Raycast(ray, out hit))
{
[] allChildren = hit.transform.gameObject.GetComponentsInChildren<Transform>();
Transformfor (int i = 0; i < allChildren.Length; i++)
{
= allChildren[i].GetComponent<MeshRenderer>();
MeshRenderer meshRenderer if (meshRenderer != null && meshRenderer.enabled)
{
if (allChildren[i].gameObject.layer == 12)
{
[i].gameObject.layer = 0;
allChildren}
else
{
[i].gameObject.layer = 12;
allChildren}
}
}
}
}
If we run the game now, we can click on GameObjects with colliders to toggle the stylization effect.
I’m Christian Mills, a deep learning consultant specializing in practical AI implementations. I help clients leverage cutting-edge AI technologies to solve real-world problems.
Interested in working together? Fill out my Quick AI Project Assessment form or learn more about me.