Adding a gbuffer to URP (Example Project)

Hello everyone, I’m a long-time BRP user and I’ve created highly stylized custom deferred lighting setups before. There was not a lot of room to spare in the gbuffers but fortunately I’ve been able to shuffle some of the channels around and make room. Preferably I would have added a gbuffer instead, but this basically is not possible / feasible in BRP (@bgolus said so, so it must be true).

Well, I felt it was about time to look into Scriptable Render Pipelines so why not start by seeing if I could accomplish this elusive feat and add a Gbuffer to URP?

I’m pleased to report that it was possible. I’ve seen people ask how to do this sort of thing over the years, never with a satisfying answer, so I figured it would be useful if I did a full write-up on it and shared a working project.

How does it work?

Surface Data now has an extra float4 field called custom0:

struct SurfaceData
{
    half3 albedo;
    half3 specular;
    half  metallic;
    half  smoothness;
    half3 normalTS;
    half3 emission;
    half  occlusion;
    half  alpha;
    half  clearCoatMask;
    half  clearCoatSmoothness;
    half4 custom0; // CUSTOM: Data that goes into the extra gbuffer. You can also split this up into separate fields.
};

You can write values to it via code:

void SurfaceFunction(Varyings IN, inout SurfaceData surfaceData)
{
    float2 uv = TRANSFORM_TEX(IN.uv, _BaseMap);
    
    half3 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv).rgb * _BaseColor.rgb;
    surfaceData.albedo = baseColor;
    
    half4 metallicSmoothness = SAMPLE_TEXTURE2D(_MetallicSmoothnessMap, sampler_BaseMap, uv);
    half metallic = _Metallic * metallicSmoothness.r;
    surfaceData.metallic = metallic;
    surfaceData.smoothness = _Smoothness * metallicSmoothness.a;
    
    surfaceData.occlusion = SAMPLE_TEXTURE2D(_AmbientOcclusionMap, sampler_BaseMap, uv).g * _OcclusionStrength;
    
    #ifdef _NORMALMAP
        surfaceData.normalTS = SampleNormal(IN.uv, TEXTURE2D_ARGS(_BumpMap, sampler_BumpMap), _BumpScale);
    #endif
    
    surfaceData.emission += _Emission.rgb;

    // CUSTOM: Write to the new custom data like you would with any other SurfaceData field.
    surfaceData.custom0 = _Custom0;
}

You can write values to it via Shader Graph:

It ends up in a new gbuffer (contents displayed in the scene view via URP Debug Draw Modes, which is included in the project):

NOTE: Viewing raw gbuffer contents is an advanced feature and needs to be enabled in your preferences

This is then decoded from the Gbuffer and put inside SurfaceData and BRDFData, for you to use as you please. As an example I’ve made it invert the final colour:

Custom Gbuffer Example

Lastly I also included an example for defining shaders in a way that’s a bit more like the way Surface Shaders used to work in BRP. This is a separate feature, and you do not need to use this workflow if you don’t want to. The changes I made to support this workflow are also clearly marked and easy to undo if you wish (see below).

What changes did I make?

  • I copied the com.unity.render-pipelines.universal and com.unity.shadergraph packages from the Library/PackageCache/ folder to the Packages/ folder in order to be able to edit them.
  • I made various C# and shader code modifications to the packages to add the gbuffer and to let Shader Graph shaders write to this new surface data field. Every line of code I touched is marked with a // CUSTOM: comment above or beside it to make it clear what I changed and what I didn’t change.
  • I made a few shader code modifications to make it possible to write shader code with less duplication, similar to the way Surface Shaders used to work in BRP. Every line of code I changed for that feature is marked with a // CUSTOM: SURFACESHADERS: comment above or beside it, so it’s easy to remove this feature if you don’t want it. You can also just leave it. If you don’t opt-in with #define SURFACE_SHADER, your shader compiles like it normally would, or you can use the Shader Graph instead. This surface shader-like setup is based on a useful example URP shader code repository by Unity’s Felipe Lira. This feature is a little bit experimental, so use at your own discretion.

Compatibility

  • This project was made in Unity 6 for deferred URP projects. I did go through the trouble of supporting Forward Rendering too, but to support rendering semi-transparent objects.

Known Issues

  • The ‘Surface shader’ workflow might not support all rendering features. There’s a lot of rendering features and not all of them have been tested. It is not mandatory to use this workflow, you can still write your own vertex/fragment functions or use the Shader Graph. Use at your own discretion.
  • I’ve seen the normal maps not being used in the ‘Surface Shader’ example while the project was set to the Forward rendering path. This problem seemed to go away when I set the project to the Deferred Rendering Path and enabled Accurate G-buffer Normals and then went back to the Forward Rendering path. This project is for Deferred Rendering path projects anyway so it might not be a problem at all, but I figured I’d mention this in case this issue ever shows up again, for example in semi-transparent objects.

What did I learn from this experiment?

  • Firstly, SRP and Shader Graph are super customizable and it’s wonderful :sparkles: Generally speaking, the shader / C# code seems well thought out and deliberately structured, especially considering the absolute metric ton of features and different platforms that it has to support. This is definitely the way forward. Well done, Unity.
  • Adding a gbuffer is not easy. They clearly did not intend for users to do this, small things could have been done to make this process easier like adding code comments on some of the less intuitive bits of code and defining more constants or macros for the amount / indices of gbuffers (I’ve added them myself where possible).
  • There are methods for initializing SurfaceData objects with default values, and there’s lots of places where this is not used and a SurfaceData object is initialized right then and there with bespoke code, and even that is done in a manner that is inconsistent and therefore not very searchable. This results in very cryptic output parameter 'X' not completely initialized errors where the field of the struct in question is actually assigned in the method, but it’s assigned a value from a parameter that is uninitialized way further up the chain because it comes from an improperly constructed SurfaceData object that now has an uninitialized custom0 field. I won’t sugarcoat it: this is an oversight. Duplication should be avoided when possible, if the existing initialization methods were not satisfactory, another one should have been defined. It has cost me hours tracking down all of the compilation errors caused by this and that could have been avoided.
  • The workflow for writing code in SRP is not very good yet. It involves having to write or copy/paste a lot of duplicated code. This is definitely a step backwards from surface shaders in BRP. I do believe it’s possible to improve this workflow with further SRP modifications (the small amount of changes in this project already made a big difference), but it’s disappointing to see that this workflow is not supported much by Unity themselves right now. It seems they would prefer everybody to use Shader Graph, and while Shader Graph is great, it’s not for everybody.
  • The last few points may sound very negative but as a long-time BRP user (this is my first serious look into SRP) I’m actually coming away from it with an optimistic feeling and a willingness to switch to URP for future projects.

I’d love to hear from others who have experience customizing gbuffers in Unity or get some tips on how to improve this project further.

2 Likes

interesting, thanks for sharing!