Fast Subsurface Scattering for the Unity URP

We’ve been working on a project targeting the Oculus Quest, which comes with some tricky constraints. The device is essentially a beefy Android phone, but must render a 2880 × 1600 display at 72fps. Compared to a standard 1080p/30fps console game, that’s a factor of 5.3x more pixels that must be shaded per second, and on mobile-grade hardware.

Because of this, everything we do graphically must be extraordinarily lightweight. Our project’s target style has a softness to it, and so I’ve been looking at fast ways to add Subsurface Scattering to our materials. SS interacts with the scene lighting, so it must be written as a modification to the render pipeline itself. Luckily Unity’s URP is open source, so we can modify it right in the project!

If you’re just here for the code, take a look at the following branch here, which has the changes to the URP mentioned in this article.

Subsurface Scattering in a Forward Renderer

Most SS implementations make use of deferred rendering, adding a depth-aware blur directly onto the light buffer. We can’t afford any separate post-processing passes on the Oculus Quest, much less a full deferred pipeline. Instead, we can look towards how older games simulated subsurface scattering. I went with the approach mentioned in this 2011 paper: a modified wrapped shading model. By default, wrapped lighting adds energy, so I additionally included a normalization term, which is included at the bottom of the paper.

Ground-truth Blender render

Ground-truth Blender render

My Unity URP with wrapped lighting

My Unity URP with wrapped lighting

Wrapped lighting is a non-physical approach, and so there are a few different algorithms, each with slightly different results. The paper includes some comparisons:

I preferred the look of Valve’s approach and it also matches more closely to a normal diffuse lighting in non-wrapped areas. It uses the following formula, theta being the angle of the surface towards the light:

$$f(\theta)=0.25(\cos (\theta)+1)^{2}$$

The paper helpfully provides a generalized version to control the distance of the wrapping.

$$f(\theta, a)=\left\{\begin{array}{ll} ((\cos \theta+a) /(1+a))^{1+a} & , \text { if } \theta \leq \theta_{m} \\ 0 & , \text { otherwise } \end{array}\right.$$

We can implement this in the URP with a simple lighting function. I’ve added a ‘subsurface color’ parameter to the Lit shader. Additionally, I’ve left in wrapped_valve and wrapped_simple as two alternative wrapping functions you can play with.

// Calculates the subsurface light radiating out from the current fragment. This is a simple approximation using wrapped lighting.
// Note: This does not use distance attenuation, as it is intented to be used with a sun light.
// Note: This does not subtract out cast shadows (light.shadowAttenuation), as it is intended to be used on non-shadowed objects. (for now)
half3 LightingSubsurface(Light light, half3 normalWS, half3 subsurfaceColor, half subsurfaceRadius) {
    // Calculate normalized wrapped lighting. This spreads the light without adding energy.
    // This is a normal lambertian lighting calculation (using N dot L), but warping NdotL
    // to wrap the light further around an object.
    //
    // A normalization term is applied to make sure we do not add energy.
    // http://www.cim.mcgill.ca/~derek/files/jgt_wrap.pdf

    half NdotL = dot(normalWS, light.direction);
    half alpha = subsurfaceRadius;
    half theta_m = acos(-alpha); // boundary of the lighting function

    half theta = max(0, NdotL + alpha) - alpha;
    half normalization_jgt = (2 + alpha) / (2 * (1 + alpha));
    half wrapped_jgt = (pow(((theta + alpha) / (1 + alpha)), 1 + alpha)) * normalization_jgt;

    half wrapped_valve = 0.25 * (NdotL + 1) * (NdotL + 1);
    half wrapped_simple = (NdotL + alpha) / (1 + alpha);

    half3 subsurface_radiance = light.color * subsurfaceColor * wrapped_jgt;

    return subsurface_radiance;
}
JGT wrapped (and colored) lighting

JGT wrapped (and colored) lighting

With just this wrapped lighting, we do get a softer look, but it’s quite a far cry from the target. As it happens, this model is correct, but only if all of the light scatters into the object. However, the key subsurface scattering look (as seen in the above Blender render) comes from the mixing of scattered light and normal PBR diffuse lighting. Most objects will only scatter some portion of the light through the object, with the rest bouncing off as specular or diffuse.

We can simulate this by blending the subsurface lighting with the normal URP lighting routine. Blending rather than adding makes sure we don’t add energy:

half3 mainLightContribution = LightingPhysicallyBased(brdfData, mainLight, inputData.normalWS, inputData.viewDirectionWS);
half3 subsurfaceContribution = LightingSubsurface(mainLight, inputData.normalWS, _SubsurfaceColor, _SubsurfaceRadius);

// '_SubsurfaceScattering' controls the portion of the direct light that scatters within the object.
// When 1, all light is scattered within the object, so the full contribution of color comes from the subsurface.
// When .5, some light is scattered within, picking up the subsurface color, and is added to the normal reflectance of the surface.
color += mainLightContribution * (1-_SubsurfaceScattering);
color += subsurfaceContribution * (_SubsurfaceScattering);

This is the final look, on a ball and suzanne. Pretty close to the blender render! As it turns out, wrapped lighting is quite a good approximation for subsurface scattering on a sphere. On a more complicated mesh, not so much, but I think it still looks pretty good.

If you’re interested in taking a look at the code, I’ve hosted our branch with subsurface scattering on Github. Note that for our project, we only need to support a single sun light, and so I’ve only implemented this effect for the directional light. However, it should be fairly easy to add similar support for point lights.

One final note. This is just one part of a full subsurface scattering model, simulating the scattering of light along the surface of an object (bleeding). However, many objects exhibit translucency, where you can see the light through an object. A great technique for this from the Frostbite Engine has already been described by Alan Zucconi.

- JA