Back to posts

Per object clipping planes shader in Unity

Unity Standard Shader with Custom Clipping Planes

2026 note

I’m in the process of migrating posts from my old blog into this new system.

Rather than rewriting everything, I’m keeping the original structure and tone where possible, and adding light context where it helps.

For a project of mine, I wanted to have custom clipping planes for objects, so that if an object is intersection with another, it would hide any part after the intersection.

It looks like this:

I decided to extend the Standard shader provided by Unity3D to achieve this effect.

If you do not care about the technical details, skip to the bottom!

Some Background

Warning, this post will get quite technical.

You can find the Unity3D shader sources from the Unity3D Download Archive.

A clipping plane can be defined by 2 vectors, a position and a normal.

These two vectors can be used to check whether parts of shapes are in front or behind the plane. We keep the parts in front, and hide the parts behind.

Wolfram Mathworld describes the algorithm to get the distance of a point to a plane. Here it is in code:

Point to plane distance calculation:

glsl
//http://mathworld.wolfram.com/Point-PlaneDistance.html
float distanceToPlane(float3 planePosition, float3 planeNormal, float3 pointInWorld)
{
    //w = vector from plane to point
    float3 w = - ( planePosition - pointInWorld );
    return ( 
        planeNormal.x * w.x + 
        planeNormal.y * w.y + 
        planeNormal.z * w.z 
    ) / sqrt ( 
        planeNormal.x * planeNormal.x +
        planeNormal.y * planeNormal.y +
        planeNormal.z * planeNormal.z 
    );
}

In order to find which parts of the objects are to be clipped, we need to extract the world coordinate of all points to be rendered. This is already done for most of the standard shader's vertex programs:

Extract world coordinates in vertex programs:

glsl
float4 posWorld = mul(_Object2World, v.vertex);

In the fragment programs, we can then use the clip function with the distance to the plane as the parameter. If the clip function is called with any number less than zero, it will discard the current pixel. This is perfect, because if the distance to the plane is less than zero, a point is behind the plane.

Using the clip function:

glsl
float4 _planePos;
float4 _planeNorm;

void PlaneClip(float3 posWorld) {
    clip(distanceToPlane(_planePos.xyz, _planeNorm.xyz, posWorld));
}

If you have more planes, you can call clip with float2, float3, float4 parameters, or call clip multiple times. For example:

Multiple clipping planes:

glsl
float4 _planePos;
float4 _planeNorm;

#if (CLIP_TWO || CLIP_THREE)
    float4 _planePos2;
    float4 _planeNorm2;
#endif

#if (CLIP_THREE)
    float4 _planePos3;
    float4 _planeNorm3;
#endif

void PlaneClip(float3 posWorld) {
    #if CLIP_THREE
        clip(float3(
            distanceToPlane(_planePos.xyz, _planeNorm.xyz, posWorld),
            distanceToPlane(_planePos2.xyz, _planeNorm2.xyz, posWorld),
            distanceToPlane(_planePos3.xyz, _planeNorm3.xyz, posWorld)
        ));
    #else //CLIP_THREE
        #if CLIP_TWO
            clip(float2(
                distanceToPlane(_planePos.xyz, _planeNorm.xyz, posWorld),
                distanceToPlane(_planePos2.xyz, _planeNorm2.xyz, posWorld)
            ));
        #else //CLIP_TWO
            clip(distanceToPlane(_planePos.xyz, _planeNorm.xyz, posWorld));
        #endif //CLIP_TWO
    #endif //CLIP_THREE
}

All we need to do now is change all passes of the Standard shader and modify the vertex and fragment programs to call this function.

We will use Unity3D's wonderful shader program variants feature for this, so that if we do not want any clipping planes it will not cause any performance hits as the code will just be eliminated in that case. The CLIP_TWO and CLIP_THREE definitions are produced by the shader variant system, because in each pass we will have this directive:

Shader variant pragma directive:

c
#pragma multi_compile __ CLIP_ONE CLIP_TWO CLIP_THREE

It basically tells Unity3D's shader compiler to generate four variants of the shader, a variant with no clipping planes, a variant with one clipping plane, another variant with two clipping planes, and the last one with three. We can choose how many clipping planes we want to use, by for example enabling the CLIP_ONE keyword, or the CLIP_TWO keyword. The method to enable keywords is: Material.EnableKeyword.

Let's go!

Create a new shader called StandardClippable.shader, and place it in your project's Assets/Shaders directory. Copy the contents of the Standard.shader file in the builtin_shaders zip, which can be found inside the DefaultResourcesExtra directory. Paste into the StandardClippable.shader. Change the first line to be:

Shader "Custom/StandardClippable"

Add the properties for the plane positions and normals, so that the properties block will look like this:

Shader properties for clipping planes:

shader
Properties
{
    _Color("Color", Color) = (1,1,1,1)
    _MainTex("Albedo", 2D) = "white" {}

    _Cutoff("Alpha Cutoff", Range(0.0, 1.0)) = 0.5

    _Glossiness("Smoothness", Range(0.0, 1.0)) = 0.5
    [Gamma] _Metallic("Metallic", Range(0.0, 1.0)) = 0.0
    _MetallicGlossMap("Metallic", 2D) = "white" {}

    _BumpScale("Scale", Float) = 1.0
    _BumpMap("Normal Map", 2D) = "bump" {}

    _Parallax ("Height Scale", Range (0.005, 0.08)) = 0.02
    _ParallaxMap ("Height Map", 2D) = "black" {}

    _OcclusionStrength("Strength", Range(0.0, 1.0)) = 1.0
    _OcclusionMap("Occlusion", 2D) = "white" {}

    _EmissionColor("Color", Color) = (0,0,0)
    _EmissionMap("Emission", 2D) = "white" {}

    _DetailMask("Detail Mask", 2D) = "white" {}

    _DetailAlbedoMap("Detail Albedo x2", 2D) = "grey" {}
    _DetailNormalMapScale("Scale", Float) = 1.0
    _DetailNormalMap("Normal Map", 2D) = "bump" {}

    [Enum(UV0,0,UV1,1)] _UVSec ("UV Set for secondary textures", Float) = 0

    // UI-only data
    [HideInInspector] _EmissionScaleUI("Scale", Float) = 0.0
    [HideInInspector] _EmissionColorUI("Color", Color) = (1,1,1)

    // Blending state
    [HideInInspector] _Mode ("__mode", Float) = 0.0
    [HideInInspector] _SrcBlend ("__src", Float) = 1.0
    [HideInInspector] _DstBlend ("__dst", Float) = 0.0
    [HideInInspector] _ZWrite ("__zw", Float) = 1.0

    _planePos ("Clipping Plane Position", Vector) = ( 0, 0, 0, 1 )
    _planePos2 ("Clipping Plane Position 2", Vector) = ( 0, 0, 0, 1 )
    _planePos3 ("Clipping Plane Position 3", Vector) = ( 0, 0, 0, 1 )

    _planeNorm ("Clipping Plane Normal", Vector) = ( 0, 1, 0, 1 )
    _planeNorm2 ("Clipping Plane Normal 2", Vector) = ( 0, 1, 0, 1 )
    _planeNorm3 ("Clipping Plane Normal 3", Vector) = ( 0, 1, 0, 1 )
}

We just added the lines after 41.

We will create a helper .cginc file named "plane_clipping.cginc". Here is its contents:

plane_clipping.cginc helper file:

shader
#ifndef PLANE_CLIPPING_INCLUDED
#define PLANE_CLIPPING_INCLUDED

//Plane clipping definitions. Uses three planes for clipping, but this can be increased if necessary.

#if CLIP_ONE || CLIP_TWO || CLIP_THREE
    //If we have 1, 2 or 3 clipping planes, PLANE_CLIPPING_ENABLED will be defined.
    //This makes it easier to check if this feature is available or not.
    #define PLANE_CLIPPING_ENABLED 1

    //http://mathworld.wolfram.com/Point-PlaneDistance.html
    float distanceToPlane(float3 planePosition, float3 planeNormal, float3 pointInWorld)
    {
        //w = vector from plane to point
        float3 w = - ( planePosition - pointInWorld );
        float res = ( planeNormal.x * w.x + 
            planeNormal.y * w.y + 
            planeNormal.z * w.z ) 
            / sqrt( planeNormal.x * planeNormal.x +
                planeNormal.y * planeNormal.y +
                planeNormal.z * planeNormal.z );
        return res;
    }

    //we will have at least one plane.
    float4 _planePos;
    float4 _planeNorm;

    //at least two planes.
    #if (CLIP_TWO || CLIP_THREE)
        float4 _planePos2;
        float4 _planeNorm2;
    #endif

    //at least three planes.
    #if (CLIP_THREE)
        float4 _planePos3;
        float4 _planeNorm3;
    #endif

    //discard drawing of a point in the world if it is behind any one of the planes.
    void PlaneClip(float3 posWorld) {
        #if CLIP_THREE
            clip(float3(
                distanceToPlane(_planePos.xyz, _planeNorm.xyz, posWorld),
                distanceToPlane(_planePos2.xyz, _planeNorm2.xyz, posWorld),
                distanceToPlane(_planePos3.xyz, _planeNorm3.xyz, posWorld)
            ));
        #else //CLIP_THREE
            #if CLIP_TWO
                clip(float2(
                    distanceToPlane(_planePos.xyz, _planeNorm.xyz, posWorld),
                    distanceToPlane(_planePos2.xyz, _planeNorm2.xyz, posWorld)
                ));
            #else //CLIP_TWO
                clip(distanceToPlane(_planePos.xyz, _planeNorm.xyz, posWorld));
            #endif //CLIP_TWO
        #endif //CLIP_THREE
    }

    //preprocessor macro that will produce an empty block if no clipping planes are used.
    #define PLANE_CLIP(posWorld) PlaneClip(posWorld);
    
#else
    //empty definition
    #define PLANE_CLIP(s)
#endif

#endif // PLANE_CLIPPING_INCLUDED

The comments in the above file should explain what it is doing.

The next step is to use the PLANE_CLIP macro in the fragment programs of all passes.

The First Pass

Let's look at the FORWARD pass for example:

Original forward pass:

shader
// ------------------------------------------------------------------
// Base forward pass (directional light, emission, lightmaps, ...)
Pass
{
    Name "FORWARD" 
    Tags { "LightMode" = "ForwardBase" }

    Blend [_SrcBlend] [_DstBlend]
    ZWrite [_ZWrite]

    CGPROGRAM
    #pragma target 3.0
    // TEMPORARY: GLES2.0 temporarily disabled to prevent errors spam on devices without textureCubeLodEXT
    #pragma exclude_renderers gles
    
    // -------------------------------------
    
    #pragma shader_feature _NORMALMAP
    #pragma shader_feature _ _ALPHATEST_ON _ALPHABLEND_ON _ALPHAPREMULTIPLY_ON
    #pragma shader_feature _EMISSION
    #pragma shader_feature _METALLICGLOSSMAP 
    #pragma shader_feature ___ _DETAIL_MULX2
    #pragma shader_feature _PARALLAXMAP
    
    #pragma multi_compile_fwdbase
    #pragma multi_compile_fog
    
    #pragma vertex vertForwardBase
    #pragma fragment fragForwardBase

    #include "UnityStandardCore.cginc"

    ENDCG
}

The lines which are important are 28, 29 and 31. This pass uses the vertForwardBase vertex program, and the fragForwardBase fragment program. These programs are defined in the UnityStandardCore.cginc file.

So, find the UnityStandardCore.cginc file in the default shaders zip. Make a copy of it, and save it as standard_clipped.cginc next to our StandardClippable.shader file.

Change all references of "UnityStandardCore.cginc" to be "standard_clipped.cginc" instead.

Also, add the line

#pragma multi_compile __ CLIP_ONE CLIP_TWO CLIP_THREE

just above the include lines. The forward pass should now look like this:

Modified forward pass:

shader
// ------------------------------------------------------------------
// Base forward pass (directional light, emission, lightmaps, ...)
Pass
{
    Name "FORWARD" 
    Tags { "LightMode" = "ForwardBase" }

    Blend [_SrcBlend] [_DstBlend]
    ZWrite [_ZWrite]

    CGPROGRAM
    #pragma target 3.0
    // TEMPORARY: GLES2.0 temporarily disabled to prevent errors spam on devices without textureCubeLodEXT
    #pragma exclude_renderers gles
    
    // -------------------------------------
    
    #pragma shader_feature _NORMALMAP
    #pragma shader_feature _ _ALPHATEST_ON _ALPHABLEND_ON _ALPHAPREMULTIPLY_ON
    #pragma shader_feature _EMISSION
    #pragma shader_feature _METALLICGLOSSMAP 
    #pragma shader_feature ___ _DETAIL_MULX2
    #pragma shader_feature _PARALLAXMAP
    
    #pragma multi_compile_fwdbase
    #pragma multi_compile_fog
    
    #pragma vertex vertForwardBase
    #pragma fragment fragForwardBase

    #pragma multi_compile __ CLIP_ONE CLIP_TWO CLIP_THREE

    #include "standard_clipped.cginc"

    ENDCG
}

And your Shaders folder should now have three files:

  • StandardClippable.shader

  • plane_clipping.cginc and

  • standard_clipped.cginc

Let's open the standard_clipped.cginc file. Add this line to the top of the file:

#include "plane_clipping.cginc"

Place it just below the include for AutoLight.cginc. This now allows us to use the functions and macros defined in that file.

We will be editing the vertex program first. Here it is as copied from the file:

Original vertex program:

shader
struct VertexOutputForwardBase
{
    float4 pos : SV_POSITION;
    float4 tex : TEXCOORD0;
    half3 eyeVec : TEXCOORD1;
    half4 tangentToWorldAndParallax[3] : TEXCOORD2; // [3x3:tangentToWorld | 1x3:viewDirForParallax]
    half4 ambientOrLightmapUV : TEXCOORD5; // SH or Lightmap UV
    SHADOW_COORDS(6)
    UNITY_FOG_COORDS(7)

    // next ones would not fit into SM2.0 limits, but they are always for SM3.0+
    #if UNITY_SPECCUBE_BOX_PROJECTION
        float3 posWorld : TEXCOORD8;
    #endif
};

VertexOutputForwardBase vertForwardBase (VertexInput v)
{
    VertexOutputForwardBase o;
    UNITY_INITIALIZE_OUTPUT(VertexOutputForwardBase, o);

    float4 posWorld = mul(_Object2World, v.vertex);
    #if UNITY_SPECCUBE_BOX_PROJECTION
        o.posWorld = posWorld.xyz;
    #endif
    o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
    o.tex = TexCoords(v);
    o.eyeVec = NormalizePerVertexNormal(posWorld.xyz - _WorldSpaceCameraPos);
    float3 normalWorld = UnityObjectToWorldNormal(v.normal);
    #ifdef _TANGENT_TO_WORLD
        float4 tangentWorld = float4(UnityObjectToWorldDir(v.tangent.xyz), v.tangent.w);

        float3x3 tangentToWorld = CreateTangentToWorldPerVertex(normalWorld, tangentWorld.xyz, tangentWorld.w);
        o.tangentToWorldAndParallax[0].xyz = tangentToWorld[0];
        o.tangentToWorldAndParallax[1].xyz = tangentToWorld[1];
        o.tangentToWorldAndParallax[2].xyz = tangentToWorld[2];
    #else
        o.tangentToWorldAndParallax[0].xyz = 0;
        o.tangentToWorldAndParallax[1].xyz = 0;
        o.tangentToWorldAndParallax[2].xyz = normalWorld;
    #endif
    //We need this for shadow receving
    TRANSFER_SHADOW(o);

    // Static lightmaps
    #ifndef LIGHTMAP_OFF
        o.ambientOrLightmapUV.xy = v.uv1.xy * unity_LightmapST.xy + unity_LightmapST.zw;
        o.ambientOrLightmapUV.zw = 0;
    // Sample light probe for Dynamic objects only (no static or dynamic lightmaps)
    #elif UNITY_SHOULD_SAMPLE_SH
        #if UNITY_SAMPLE_FULL_SH_PER_PIXEL
            o.ambientOrLightmapUV.rgb = 0;
        #elif (SHADER_TARGET < 30)
            o.ambientOrLightmapUV.rgb = ShadeSH9(half4(normalWorld, 1.0));
        #else
            // Optimization: L2 per-vertex, L0..L1 per-pixel
            o.ambientOrLightmapUV.rgb = ShadeSH3Order(half4(normalWorld, 1.0));
        #endif
        // Add approximated illumination from non-important point lights
        #ifdef VERTEXLIGHT_ON
            o.ambientOrLightmapUV.rgb += Shade4PointLights (
                unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
                unity_LightColor[0].rgb, unity_LightColor[1].rgb, unity_LightColor[2].rgb, unity_LightColor[3].rgb,
                unity_4LightAtten0, posWorld, normalWorld);
        #endif
    #endif

    #ifdef DYNAMICLIGHTMAP_ON
        o.ambientOrLightmapUV.zw = v.uv2.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw;
    #endif
    
    #ifdef _PARALLAXMAP
        TANGENT_SPACE_ROTATION;
        half3 viewDirForParallax = mul (rotation, ObjSpaceViewDir(v.vertex));
        o.tangentToWorldAndParallax[0].w = viewDirForParallax.x;
        o.tangentToWorldAndParallax[1].w = viewDirForParallax.y;
        o.tangentToWorldAndParallax[2].w = viewDirForParallax.z;
    #endif
    
    UNITY_TRANSFER_FOG(o,o.pos);
    return o;
}

The vertex program will need to pass the world position to the fragment program. It currently does so only if UNITY_SPECCUBE_BOX_PROJECTION is defined (relevant lines in above snippet: 12 and 23).

Change the lines

#if UNITY_SPECCUBE_BOX_PROJECTION

to be:

#if UNITY_SPECCUBE_BOX_PROJECTION || PLANE_CLIPPING_ENABLED

There should be one inside the struct definition just above the function, and one within the function. This way, the posWorld vector will be passed onto the fragment shader to be used by plane clipping.

The next step is the fragment program:

Original fragment program:

shader
half4 fragForwardBase (VertexOutputForwardBase i) : SV_Target
{
    FRAGMENT_SETUP(s)
    UnityLight mainLight = MainLight (s.normalWorld);
    half atten = SHADOW_ATTENUATION(i);
    
    half occlusion = Occlusion(i.tex.xy);
    UnityGI gi = FragmentGI (
        s.posWorld, occlusion, i.ambientOrLightmapUV, atten, s.oneMinusRoughness, s.normalWorld, s.eyeVec, mainLight);

    half4 c = UNITY_BRDF_PBS (s.diffColor, s.specColor, s.oneMinusReflectivity, s.oneMinusRoughness, s.normalWorld, -s.eyeVec, gi.light, gi.indirect);
    c.rgb += UNITY_BRDF_GI (s.diffColor, s.specColor, s.oneMinusReflectivity, s.oneMinusRoughness, s.normalWorld, -s.eyeVec, occlusion, gi);
    c.rgb += Emission(i.tex.xy);

    UNITY_APPLY_FOG(i.fogCoord, c.rgb);
    return OutputForward (c, s.alpha);
}

This uses the FRAGMENT_SETUP macro:

FRAGMENT_SETUP macro:

shader
#if UNITY_SPECCUBE_BOX_PROJECTION
    #define IN_WORLDPOS(i) i.posWorld
#else
    #define IN_WORLDPOS(i) half3(0,0,0)
#endif

#define IN_LIGHTDIR_FWDADD(i) half3(i.tangentToWorldAndLightDir[0].w, i.tangentToWorldAndLightDir[1].w, i.tangentToWorldAndLightDir[2].w)

#define FRAGMENT_SETUP(x) FragmentCommonData x = \
    FragmentSetup(i.tex, i.eyeVec, WorldNormal(i.tangentToWorldAndParallax), IN_VIEWDIR4PARALLAX(i), ExtractTangentToWorldPerPixel(i.tangentToWorldAndParallax), IN_WORLDPOS(i));

Which uses the IN_WORLDPOS macro in order to get the world position if necessary.

The world position is acquired only if UNITY_SPECCUBE_BOX_PROJECTION (similar to above) is defined, so change the line

#if UNITY_SPECCUBE_BOX_PROJECTION

to be:

#if UNITY_SPECCUBE_BOX_PROJECTION || PLANE_CLIPPING_ENABLED

The lines up to FRAGMENT_SETUP should now look like:

Modified fragment setup:

shader
#if UNITY_SPECCUBE_BOX_PROJECTION || PLANE_CLIPPING_ENABLED
    #define IN_WORLDPOS(i) i.posWorld
#else
    #define IN_WORLDPOS(i) half3(0,0,0)
#endif

#define IN_LIGHTDIR_FWDADD(i) half3(i.tangentToWorldAndLightDir[0].w, i.tangentToWorldAndLightDir[1].w, i.tangentToWorldAndLightDir[2].w)

#define FRAGMENT_SETUP(x) FragmentCommonData x = \
    FragmentSetup(i.tex, i.eyeVec, WorldNormal(i.tangentToWorldAndParallax), IN_VIEWDIR4PARALLAX(i), ExtractTangentToWorldPerPixel(i.tangentToWorldAndParallax), IN_WORLDPOS(i));

And finally, let's add the plane clipping to the fragment shader. Place this line

PLANE_CLIP(s.posWorld)

just below

FRAGMENT_SETUP(s)

Your fragment shader code should now look like this:

Complete modified fragment program:

shader
half4 fragForwardBase (VertexOutputForwardBase i) : SV_Target
{
    FRAGMENT_SETUP(s)
    PLANE_CLIP(s.posWorld)

    UnityLight mainLight = MainLight (s.normalWorld);
    half atten = SHADOW_ATTENUATION(i);
    
    half occlusion = Occlusion(i.tex.xy);
    UnityGI gi = FragmentGI (
        s.posWorld, occlusion, i.ambientOrLightmapUV, atten, s.oneMinusRoughness, s.normalWorld, s.eyeVec, mainLight);

    half4 c = UNITY_BRDF_PBS (s.diffColor, s.specColor, s.oneMinusReflectivity, s.oneMinusRoughness, s.normalWorld, -s.eyeVec, gi.light, gi.indirect);
    c.rgb += UNITY_BRDF_GI (s.diffColor, s.specColor, s.oneMinusReflectivity, s.oneMinusRoughness, s.normalWorld, -s.eyeVec, occlusion, gi);
    c.rgb += Emission(i.tex.xy);

    UNITY_APPLY_FOG(i.fogCoord, c.rgb);
    return OutputForward (c, s.alpha);
}

This fixes the FORWARD pass!

Other passes

The next pass is the FORWARD_ADD.

Let's make the vertex shader pass the world position to the fragment shader:

Add these lines before the closing brace of the VertexOutputForwardAdd struct:

VertexOutputForwardAdd struct additions:

shader
#if PLANE_CLIPPING_ENABLED
    float3 posWorld : TEXCOORD9;
#endif

We use TEXCOORD9 because 8 was used just above it.

And in the vertForwardAdd function, add the lines:

vertForwardAdd modifications:

shader
#if PLANE_CLIPPING_ENABLED
    o.posWorld = posWorld.xyz;
#endif

Just after this line:

float4 posWorld = mul(_Object2World, v.vertex);

The fragment shader uses the FRAGMENT_SETUP_FWDADD macro, which is defined just below FRAGMENT_SETUP.

Above the FRAGMENT_SETUP_FWDADD macro, add these lines:

#if PLANE_CLIPPING_ENABLED
    #define IN_WORLDPOS_FWDADD(i) i.posWorld
#else
    #define IN_WORLDPOS_FWDADD(i) half3(0,0,0)
#endif

And use the newly created IN_WORLDPOS_FWDADD macro instead of the half3(0,0,0) as the last parameter for FragmentSetup. Here's how the relevant lines should look:

FRAGMENT_SETUP_FWDADD macro changes:

shader
#if PLANE_CLIPPING_ENABLED
    #define IN_WORLDPOS_FWDADD(i) i.posWorld
#else
    #define IN_WORLDPOS_FWDADD(i) half3(0,0,0)
#endif

#define FRAGMENT_SETUP_FWDADD(x) FragmentCommonData x = \
    FragmentSetup(i.tex, i.eyeVec, WorldNormal(i.tangentToWorldAndLightDir), IN_VIEWDIR4PARALLAX_FWDADD(i), ExtractTangentToWorldPerPixel(i.tangentToWorldAndLightDir), IN_WORLDPOS_FWDADD(i));

And finally, you can now call the PLANE_CLIP(s.posWorld) macro right after FRAGMENT_SETUP_FWDADD(s) inside fragForwardAdd.

Here's the code for the VertexOutputForwardAdd struct, vertForwardAdd function and fragForwardAdd function altogether:

Complete forward add implementation:

shader
// ------------------------------------------------------------------
// Additive forward pass (one light per pass)
struct VertexOutputForwardAdd
{
    float4 pos : SV_POSITION;
    float4 tex : TEXCOORD0;
    half3 eyeVec : TEXCOORD1;
    half4 tangentToWorldAndLightDir[3] : TEXCOORD2; // [3x3:tangentToWorld | 1x3:lightDir]
    LIGHTING_COORDS(5,6)
    UNITY_FOG_COORDS(7)

    // next ones would not fit into SM2.0 limits, but they are always for SM3.0+
    #if defined(_PARALLAXMAP)
        half3 viewDirForParallax : TEXCOORD8;
    #endif

    #if PLANE_CLIPPING_ENABLED
        float3 posWorld : TEXCOORD9;
    #endif
};

VertexOutputForwardAdd vertForwardAdd (VertexInput v)
{
    VertexOutputForwardAdd o;
    UNITY_INITIALIZE_OUTPUT(VertexOutputForwardAdd, o);

    float4 posWorld = mul(_Object2World, v.vertex);
    #if PLANE_CLIPPING_ENABLED
        o.posWorld = posWorld.xyz;
    #endif
    o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
    o.tex = TexCoords(v);
    o.eyeVec = NormalizePerVertexNormal(posWorld.xyz - _WorldSpaceCameraPos);
    float3 normalWorld = UnityObjectToWorldNormal(v.normal);
    #ifdef _TANGENT_TO_WORLD
        float4 tangentWorld = float4(UnityObjectToWorldDir(v.tangent.xyz), v.tangent.w);

        float3x3 tangentToWorld = CreateTangentToWorldPerVertex(normalWorld, tangentWorld.xyz, tangentWorld.w);
        o.tangentToWorldAndLightDir[0].xyz = tangentToWorld[0];
        o.tangentToWorldAndLightDir[1].xyz = tangentToWorld[1];
        o.tangentToWorldAndLightDir[2].xyz = tangentToWorld[2];
    #else
        o.tangentToWorldAndLightDir[0].xyz = 0;
        o.tangentToWorldAndLightDir[1].xyz = 0;
        o.tangentToWorldAndLightDir[2].xyz = normalWorld;
    #endif
    //We need this for shadow receving
    TRANSFER_VERTEX_TO_FRAGMENT(o);

    float3 lightDir = _WorldSpaceLightPos0.xyz - posWorld.xyz * _WorldSpaceLightPos0.w;
    #ifndef USING_DIRECTIONAL_LIGHT
        lightDir = NormalizePerVertexNormal(lightDir);
    #endif
    o.tangentToWorldAndLightDir[0].w = lightDir.x;
    o.tangentToWorldAndLightDir[1].w = lightDir.y;
    o.tangentToWorldAndLightDir[2].w = lightDir.z;

    #ifdef _PARALLAXMAP
        TANGENT_SPACE_ROTATION;
        o.viewDirForParallax = mul (rotation, ObjSpaceViewDir(v.vertex));
    #endif
    
    UNITY_TRANSFER_FOG(o,o.pos);
    return o;
}

half4 fragForwardAdd (VertexOutputForwardAdd i) : SV_Target
{
    FRAGMENT_SETUP_FWDADD(s)
    PLANE_CLIP(s.posWorld)

    UnityLight light = AdditiveLight (s.normalWorld, IN_LIGHTDIR_FWDADD(i), LIGHT_ATTENUATION(i));
    UnityIndirect noIndirect = ZeroIndirect ();

    half4 c = UNITY_BRDF_PBS (s.diffColor, s.specColor, s.oneMinusReflectivity, s.oneMinusRoughness, s.normalWorld, -s.eyeVec, light, noIndirect);
    
    UNITY_APPLY_FOG_COLOR(i.fogCoord, c.rgb, half4(0,0,0,0)); // fog towards black in additive pass
    return OutputForward (c, s.alpha);
}

And here's what the pass definition in StandardClippable.shader should look like:

Forward add shader pass:

shader
// ------------------------------------------------------------------
// Additive forward pass (one light per pass)
Pass
{
    Name "FORWARD_DELTA"
    Tags { "LightMode" = "ForwardAdd" }
    Blend [_SrcBlend] One
    Fog { Color (0,0,0,0) } // in additive pass fog should be black
    ZWrite Off
    ZTest LEqual

    CGPROGRAM
    #pragma target 3.0
    // GLES2.0 temporarily disabled to prevent errors spam on devices without textureCubeLodEXT
    #pragma exclude_renderers gles

    // -------------------------------------

    
    #pragma shader_feature _NORMALMAP
    #pragma shader_feature _ _ALPHATEST_ON _ALPHABLEND_ON _ALPHAPREMULTIPLY_ON
    #pragma shader_feature _METALLICGLOSSMAP
    #pragma shader_feature ___ _DETAIL_MULX2
    #pragma shader_feature _PARALLAXMAP
    
    #pragma multi_compile_fwdadd_fullshadows
    #pragma multi_compile_fog
    
    #pragma vertex vertForwardAdd
    #pragma fragment fragForwardAdd

    #pragma multi_compile __ CLIP_ONE CLIP_TWO CLIP_THREE

    #include "standard_clipped.cginc"

    ENDCG
}

Shadow pass

The shadow pass uses another file, UnityStandardShadow.cginc. Make a copy of this file and save it as standard_shadow_clipped.cginc. In the shadow pass definition, include the standard_shadow_clipped.cginc instead of UnityStandardShadow.cginc, and don't forget the #pragma declarations!

Inside standard_shadow_clipped.cginc, include plane_clipping.cginc as usual.

In some conditions, the shadow vertex shader does not use an output struct. We want to ensure that we have the output struct so that we can pass the world position. Around line 27, change the code so that it looks like this:

Shadow vertex output struct:

shader
// Has a non-empty shadow caster output struct (it's an error to have empty structs on some platforms...)
#if PLANE_CLIPPING_ENABLED || !defined(V2F_SHADOW_CASTER_NOPOS_IS_EMPTY) || defined(UNITY_STANDARD_USE_SHADOW_UVS)
    #define UNITY_STANDARD_USE_SHADOW_OUTPUT_STRUCT 1
#endif

Inside the VertexOutputShadowCaster struct (around line 50), add the posWorld parameter:

VertexOutputShadowCaster modifications:

shader
#ifdef UNITY_STANDARD_USE_SHADOW_OUTPUT_STRUCT
struct VertexOutputShadowCaster
{
    V2F_SHADOW_CASTER_NOPOS
    #if defined(UNITY_STANDARD_USE_SHADOW_UVS)
        float2 tex : TEXCOORD1;
    #endif

    #if PLANE_CLIPPING_ENABLED
        float3 posWorld : TEXCOORD2;
    #endif
};
#endif

And finally, just after it, here's the modified vertShadowCaster and fragShadowCaster functions:

Complete shadow pass implementation:

shader
void vertShadowCaster (VertexInput v,
    #ifdef UNITY_STANDARD_USE_SHADOW_OUTPUT_STRUCT
        out VertexOutputShadowCaster o,
    #endif
    out float4 opos : SV_POSITION)
{
    #if PLANE_CLIPPING_ENABLED
        float4 posWorld = mul(_Object2World, v.vertex);
        o.posWorld = posWorld.xyz;
    #endif
    TRANSFER_SHADOW_CASTER_NOPOS(o,opos)
    #if defined(UNITY_STANDARD_USE_SHADOW_UVS)
        o.tex = TRANSFORM_TEX(v.uv0, _MainTex);
    #endif
}


half4 fragShadowCaster (
    #ifdef UNITY_STANDARD_USE_SHADOW_OUTPUT_STRUCT
        VertexOutputShadowCaster i
    #endif
    #ifdef UNITY_STANDARD_USE_DITHER_MASK
        , UNITY_VPOS_TYPE vpos : VPOS
    #endif
    ) : SV_Target
{
    PLANE_CLIP(i.posWorld)

    #if defined(UNITY_STANDARD_USE_SHADOW_UVS)
        half alpha = tex2D(_MainTex, i.tex).a * _Color.a;
        #if defined(_ALPHATEST_ON)
            clip (alpha - _Cutoff);
        #endif
        #if defined(_ALPHABLEND_ON) || defined(_ALPHAPREMULTIPLY_ON)
            #if defined(UNITY_STANDARD_USE_DITHER_MASK)
                // Use dither mask for alpha blended shadows, based on pixel position xy
                // and alpha level. Our dither texture is 4x4x16.
                half alphaRef = tex3D(_DitherMaskLOD, float3(vpos.xy*0.25,alpha*0.9375)).a;
                clip (alphaRef - 0.01);
            #else
                clip (alpha - _Cutoff);
            #endif
        #endif
    #endif // #if defined(UNITY_STANDARD_USE_SHADOW_UVS)

    SHADOW_CASTER_FRAGMENT(i)
}

Guess what we added ;)

Deferred pass

The deferred pass is very similar to the forward pass. Try to do it yourself :) If you have any trouble, write a comment below, and I will help you!

Lod 150 passes

You just need to use the correct include files and the #pragma declarations in these passes.

An example script

Here's an example that shows how to use the EnableKeyword method correctly and define the clipping planes for the shader:

Example script for enabling clipping planes:

cs
using UnityEngine;
using System.Collections;
using System.Linq;

[ExecuteInEditMode]
public class ClippableObject : MonoBehaviour {
    public void OnEnable() {
        //let's just create a new material instance.
        GetComponent<Renderer>().sharedMaterial = new Material(Shader.Find("Custom/StandardClippable")) {
            hideFlags = HideFlags.HideAndDontSave
        };
    }

    public void Start() { }

    //only 3 clip planes for now, will need to modify the shader for more.
    [Range(0, 3)]
    public int clipPlanes = 0;

    //preview size for the planes. Shown when the object is selected.
    public float planePreviewSize = 5.0f;

    //Positions and rotations for the planes. The rotations will be converted into normals to be used by the shaders.
    public Vector3 plane1Position = Vector3.zero;
    public Vector3 plane1Rotation = new Vector3(0, 0, 0);

    public Vector3 plane2Position = Vector3.zero;
    public Vector3 plane2Rotation = new Vector3(0, 90, 90);

    public Vector3 plane3Position = Vector3.zero;
    public Vector3 plane3Rotation = new Vector3(0, 0, 90);

    //Only used for previewing a plane. Draws diagonals and edges of a limited flat plane.
    private void DrawPlane(Vector3 position, Vector3 euler) {
        var forward = Quaternion.Euler(euler) * Vector3.forward;
        var left = Quaternion.Euler(euler) * Vector3.left;

        var forwardLeft = position + forward * planePreviewSize * 0.5f + left * planePreviewSize * 0.5f;
        var forwardRight = forwardLeft - left * planePreviewSize;
        var backRight = forwardRight - forward * planePreviewSize;
        var backLeft = forwardLeft - forward * planePreviewSize;

        Gizmos.DrawLine(position, forwardLeft);
        Gizmos.DrawLine(position, forwardRight);
        Gizmos.DrawLine(position, backRight);
        Gizmos.DrawLine(position, backLeft);

        Gizmos.DrawLine(forwardLeft, forwardRight);
        Gizmos.DrawLine(forwardRight, backRight);
        Gizmos.DrawLine(backRight, backLeft);
        Gizmos.DrawLine(backLeft, forwardLeft);
    }

    private void OnDrawGizmosSelected() {
        if (clipPlanes >= 1) {
            DrawPlane(plane1Position, plane1Rotation);
        }
        if (clipPlanes >= 2) {
            DrawPlane(plane2Position, plane2Rotation);
        }
        if (clipPlanes >= 3) {
            DrawPlane(plane3Position, plane3Rotation);
        }
    }

    //Ideally the planes do not need to be updated every frame, but we'll just keep the logic here for simplicity purposes.
    public void Update()
    {
        var sharedMaterial = GetComponent<Renderer>().sharedMaterial;

        //Only should enable one keyword. If you want to enable any one of them, you actually need to disable the others. 
        //This may be a bug...
        switch (clipPlanes) {
            case 0:
                sharedMaterial.DisableKeyword("CLIP_ONE");
                sharedMaterial.DisableKeyword("CLIP_TWO");
                sharedMaterial.DisableKeyword("CLIP_THREE");
                break;
            case 1:
                sharedMaterial.EnableKeyword("CLIP_ONE");
                sharedMaterial.DisableKeyword("CLIP_TWO");
                sharedMaterial.DisableKeyword("CLIP_THREE");
                break;
            case 2:
                sharedMaterial.DisableKeyword("CLIP_ONE");
                sharedMaterial.EnableKeyword("CLIP_TWO");
                sharedMaterial.DisableKeyword("CLIP_THREE");
                break;
            case 3:
                sharedMaterial.DisableKeyword("CLIP_ONE");
                sharedMaterial.DisableKeyword("CLIP_TWO");
                sharedMaterial.EnableKeyword("CLIP_THREE");
                break;
        }

        //pass the planes to the shader if necessary.
        if (clipPlanes >= 1)
        {
            sharedMaterial.SetVector("_planePos", plane1Position);
            //plane normal vector is the rotated 'up' vector.
            sharedMaterial.SetVector("_planeNorm", Quaternion.Euler(plane1Rotation) * Vector3.up);
        }

        if (clipPlanes >= 2)
        {
            sharedMaterial.SetVector("_planePos2", plane2Position);
            sharedMaterial.SetVector("_planeNorm2", Quaternion.Euler(plane2Rotation) * Vector3.up);
        }

        if (clipPlanes >= 3)
        {
            sharedMaterial.SetVector("_planePos3", plane3Position);
            sharedMaterial.SetVector("_planeNorm3", Quaternion.Euler(plane3Rotation) * Vector3.up);
        }
    }
}

The Result!

You can find all the code on Github, in Unity3D-Plane-Clipping project I created.

And here's the unitypackage file.