0
\$\begingroup\$

I'm learning Shader Graph and am trying to experiment with toon shading effects. One thing I'd love to do is make a traditional art styled crosshatching effect either inside shadows or at the edge of shadows. However, I'm lost as to how to do that. I have a sample crosshatch texture I can use as a texture2D but I'm unsure how to use this as an input in shadows and to which nodes to hook it up to. I'm very new to dabbling in shaders so I'd appreciate any tips.

\$\endgroup\$
2
\$\begingroup\$

Usually I approach this kind of effect as a post-process image effect:

  • Render the wholes scene with normal lighting, benefitting from all of Unity's built-in shading features that you might want to use.

  • Read the resulting image as a texture, and compute the brightness of each pixel.

  • Clamp that brightness into ranges, one for each pair of consecutive hatching swatches in your texture.

  • Sample from your swatch texture twice, using screenspace UV coordinates. Once for the swatch at the dark end of the range, and once for the swatch at the bright end.

  • Interpolate between the two swatch samples according to your brightness within the range.

  • Output the result back into the rendering pipeline for any subsequent passes.

Using the scriptable render pipeline, you need to define a Renderer Feature that will inject this new pass into the pipe. Based on the example here, we can write something like this:

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class ImageEffectFeature : ScriptableRendererFeature
{
    class CustomRenderPass : ScriptableRenderPass
    {
        private RenderTargetIdentifier source { get; set; }
        private RenderTargetHandle destination { get; set; }
        public Material material = null;
        RenderTargetHandle _temporaryColorTexture;

        public CustomRenderPass(Material material) {
            this.material = material;
        }

        public void Setup(RenderTargetIdentifier source, RenderTargetHandle destination) {
            this.source = source;
            this.destination = destination;
        }

        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            CommandBuffer cmd = CommandBufferPool.Get("_OutlinePass");

            RenderTextureDescriptor opaqueDescriptor = renderingData.cameraData.cameraTargetDescriptor;
            opaqueDescriptor.depthBufferBits = 0;

            if (destination == RenderTargetHandle.CameraTarget) {
                cmd.GetTemporaryRT(_temporaryColorTexture.id, opaqueDescriptor, FilterMode.Point);
                Blit(cmd, source, _temporaryColorTexture.Identifier(), material, 0);
                Blit(cmd, _temporaryColorTexture.Identifier(), source);

            } else Blit(cmd, source, destination.Identifier(), material, 0);

            context.ExecuteCommandBuffer(cmd);
            CommandBufferPool.Release(cmd);
        }

        public override void FrameCleanup(CommandBuffer cmd)
        {
            if (destination == RenderTargetHandle.CameraTarget)
                cmd.ReleaseTemporaryRT(_temporaryColorTexture.id);
        }
    }

    public Material material;
    CustomRenderPass m_ScriptablePass;

    public override void Create()
    {
        if(material == null) {
            Debug.LogWarning("Missing Image Effect Material", this);
            return;
        }
        m_ScriptablePass = new CustomRenderPass(material);

        m_ScriptablePass.renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (material == null) {            
            return;
        }
        m_ScriptablePass.Setup(renderer.cameraColorTarget, RenderTargetHandle.CameraTarget);
        renderer.EnqueuePass(m_ScriptablePass);
    }
}

This will let you select the renderer asset you're using and choose "Add Renderer Feature" to add a new image effect into your pipeline.

Renderer inspector

Here we've exposed a Material parameter we can assign to do the work of the image effect. It will get the rendered scene texture passed to it as its _MainTex, and whatever it outputs will be the result passed down the rest of the pipeline.

Now for the graph! For starters, here's the shading texture I'm using, a leftover from a shader workshop a few years back:

Pencil shading swatches

We'll start by creating a new unlit shader graph, and configure properties for...

  • The _MainTex scene texture we want to stylize (it helps to take a screenshot of your game render and use this as the default texture, so you get a representative preview in the shader graph editor)

  • The hatching swatch texture we want to use

  • A "crossfade" variable we can use to blend between our stylized version and the original. This both allows us to control the intensity of the effect, and helps with debugging the graph, by letting us peek at the unmodified input when we need to.

Then we draw the rest of the owl:

Graph overview

Okay, let's break that down step by step. First we want to clamp the image into the 0-1 range (sorry HDR - we're gonna ignore you for simplicity in our first attempt), and dot it with the relative luminance constants to reduce it to greyscale, weighting the brighter green channel much more heavily than the dark blue channel.

You can also divide by this value to recover the scene's chromaticity, with the shading removed. We can use that to preserve the colour in our image later, so that we don't double-dip on shading by layering our hatching on top of an already-shaded image.

Image brightness and chromaticity

Next we need to compute the UVs to sample inside a single hatching swatch.

Because we're mapping this in screen space, it can start to look like something painted on the camera lens, since it doesn't move as content in the scene does. Adding a pseudo-random jitter into the UVs helps mask that, making it look more like the image is being redrawn at a framerate of your choosing. But you can also delete the top box if you want your hatching to stay fixed on the screen.

UV Calculation

The "Fraction" node at the end handles wrapping the UVs into the 0-1 range for us. Since our swatch texture has bands of different shading side-by-side, we need to do that wrapping ourselves to keep from peeking into an adjacent swatch.

Here's where the magic happens:

Shading with swatches

We take our computed scene luminance in from the bottom-left, and multiply it by our number of shading ranges we have (swatch count - 1). Taking the floor of this gives us the index of the swatch at the dark end of this range.

We add this to our screenspace UV, then divide the resulting horizontal value by our swatch count to get it into the 0-1 range of our texture lookups. Then we sample the dark swatch.

Adding 1 / swatch count horizontally gets us the corresponding point in the next brighter swatch, and we sample that too.

Note I'm using the Texture 2D LOD node to say "we don't need mipmapping" - otherwise the jumps where we wrap-around inside a swatch look to the GPU like a texture being sampled from a long distance away, and it tries to mipmap them down to reduce aliasing, making line artifacts in our image.

Lastly, we subtract our floored luminance from the multiplied version, to get our fraction of the way between the dark and bright ends of the range. We use this as an interpolation weight to blend our two swatch samples.

Final output

We can multiply our sampled hatching with the chromaticity we calculated earlier to re-introduce the scene's colour, but with our new stylized shading.

And of course, lerp between the original input texture and our stylized result, using our cross-fade variable, so we can control the intensity of stylization.

Here's what this looks like in my example, using the default sample scene:

Before/After

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ Thanks Douglas. This is very cool. However, I'm having some issues customizing this. I love the effect this makes, but it cross hatches the entire screen. I only want it in the Shadows. Been trying to alter it with what little I know about Shader nodes, but I'm not able to restrict the crosshatching to just shadows. Is there a way to do that? Is there a way to toggle how deep the shadows need to be before they crosshatch. \$\endgroup\$ – JSparks Jun 16 at 9:51
  • \$\begingroup\$ The simplest way would be to change your hatching swatch texture so the top few boxes are flat white, or levels of grey if you're using the chromaticity trick, so things out of the darkest areas don't get over-bright. Sharing your swatch texture and a mock-up of what you want the result to look like would make it easier for users to zero in on the specific look you want. \$\endgroup\$ – DMGregory Jun 16 at 10:42

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.