Helps generate PBR maps from a base/diffuse map depending on the workflow chosen. This project demonstrates the power of WebGPU
by implementing a Physically Based Rendering (PBR) map generator that runs efficiently in web browsers. It showcases the use of compute shaders for GPU-accelerated texture processing, providing a significant performance boost over traditional CPU-based methods. The output of course is not very fancy, as we don't have access to the real height information of the texture, and we're just making a guess based on the color information, which could be a hit or miss. But it's a good starting point to prototype some PBR textures quickly!
You can try out the project here- https://makra.wtf/Unity-WebGPU-PBR-Maps-Generator/.
The page might take a while to load for the first time but once it's cached, it should load quickly on subsequent visits.
pbr_intro.mp4
- Generate various PBR maps from a single base texture:
- Height Map
- Normal Map
- Ambient Occlusion (AO) Map
- Roughness Map
- Metallic Map
- Specular Map
- Glossiness Map
- Choose between Metallic-Roughness and Specular-Glossiness workflows.
- GPU acceleration using compute shaders using
Unity
's newWebGPU
backend. - CPU fallback for devices without GPU that supports 64 threads per block.
- Real-time preview of generated maps along with the option to download them.
Base | Height | Normal | AO | Metallic | Roughness | Specular | Glossiness |
PBR maps generated from a single base texture using the tool
Here's a comparison of CPU vs GPU methods for generating a normal map:
private static Texture2D CPUConvertToNormalMap(Texture2D heightMap)
{
Texture2D normalMap = new Texture2D(heightMap.width, heightMap.height, TextureFormat.RGBA32, false);
for (int y = 0; y < heightMap.height; y++)
{
for (int x = 0; x < heightMap.width; x++)
{
float left = GetPixelHeight(heightMap, x - 1, y);
float right = GetPixelHeight(heightMap, x + 1, y);
float top = GetPixelHeight(heightMap, x, y - 1);
float bottom = GetPixelHeight(heightMap, x, y + 1);
Vector3 normal = new Vector3(left - right, bottom - top, 1).normalized;
normal = normal * 0.5f + Vector3.one * 0.5f;
normalMap.SetPixel(x, y, new Color(normal.x, normal.y, normal.z, 1));
}
}
normalMap.Apply();
return normalMap;
}
#pragma kernel CSMain
Texture2D<float> HeightMap;
RWTexture2D<float4> NormalMap;
uint2 TextureSize;
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
if (id.x >= TextureSize.x || id.y >= TextureSize.y)
return;
float left = HeightMap[uint2(max(id.x - 1, 0), id.y)];
float right = HeightMap[uint2(min(id.x + 1, TextureSize.x - 1), id.y)];
float top = HeightMap[uint2(id.x, max(id.y - 1, 0))];
float bottom = HeightMap[uint2(id.x, min(id.y + 1, TextureSize.y - 1))];
float3 normal = normalize(float3(left - right, bottom - top, 1));
normal = normal * 0.5 + 0.5;
NormalMap[id.xy] = float4(normal, 1);
}
Using compute shaders we create 8x8 threads per block, which is the maximum supported by the WebGPU
backend. This allows us to process 64 pixels in parallel, providing a significant performance boost over the CPU method.
These are the results of the performance comparison using a system with an Intel Core i9-13900HX
CPU and an NVIDIA GeForce RTX 4070
GPU and 32 GB
of RAM:
pbr_compare.mp4
Map Type | Resolution | CPU Time (ms) | GPU Time (ms) | Speedup Factor |
---|---|---|---|---|
Height | 512x512 | 350 | 20 | 17.5x |
1024x1024 | 1500 | 50 | 30x | |
2048x2048 | 6200 | 150 | 41.3x | |
Normal | 512x512 | 500 | 30 | 16.7x |
1024x1024 | 2000 | 80 | 25x | |
2048x2048 | 8000 | 250 | 32x | |
AO | 512x512 | 450 | 20 | 22.5x |
1024x1024 | 1800 | 60 | 30x | |
2048x2048 | 7200 | 180 | 40x | |
Roughness | 512x512 | 550 | 30 | 18.3x |
1024x1024 | 2200 | 70 | 31.4x | |
2048x2048 | 8800 | 220 | 40x | |
Metallic | 512x512 | 400 | 20 | 20x |
1024x1024 | 1600 | 50 | 32x | |
2048x2048 | 6400 | 160 | 40x | |
Specular | 512x512 | 420 | 20 | 21x |
1024x1024 | 1700 | 60 | 28.3x | |
2048x2048 | 6800 | 180 | 37.8x | |
Glossiness | 512x512 | 600 | 40 | 15x |
1024x1024 | 2400 | 90 | 26.7x | |
2048x2048 | 9600 | 280 | 34.3x |
As we can see from the table and graph, the GPU method consistently outperforms the CPU method, with the performance gap widening as the texture resolution increases. This demonstrates the scalability and efficiency of compute shaders for texture processing tasks.
One key limitation of WebGPU
is the lack of support for synchronous GPU readback. To address this, we use AsyncGPUReadback
instead of the traditional ReadPixels
method:
private void ReadbackTexture(Texture texture, Action<Texture2D> callback)
{
AsyncGPUReadback.Request(texture, 0, readback =>
{
if (readback.hasError)
{
Debug.LogError("GPU readback error detected.");
return;
}
Texture2D texture2D = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false);
texture2D.LoadRawTextureData(readback.GetData<byte>());
texture2D.Apply();
callback(texture2D);
});
}
This asynchronous approach ensures compatibility with WebGPU while maintaining efficient GPU-to-CPU data transfer. You can see an example implementation here.
- Clone the repository or download the
.unitypackage
from here. - Open/Import the project in
Unity 2023.3
or later. - If you're using the
.unitypackage
, make sure to create a project using theBuilt-In Render Pipeline
and importTextMeshPro
. - Make sure to enable the
WebGPU
backend to take advantage of GPU acceleration. Instructions here.
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. Currently working on-
- Better algorithms for generating the maps that give better results.
- Incorporating deep learning models to estimate the height information from the color information instead of just doing a greyscale conversion.
MIT