1
\$\begingroup\$

I am working on procedural generation in Unity, and am now a bit stuck. I am using Perlin noise to generate a heightmap, including lacunarity, persistence, octaves and frequencies.

Today I have been working on adding a custom shader for water to make everything look a lot prettier. I followed this tutorial, in which he used a Shader Graph. Thank god these exist, because they are so much nicer than Unity's default ShaderLab/HLSL code.

Speaking of Unity's default shader code, the shader that I wrote for the proc gen mesh blends different textures together (sand, grass, rocks, snow etc.) together based on values from the heightmap. Here is the code from the shader that I am looking at converting.

Shader "Custom/TerrainShader" {
    Properties {
        testTexture("Texture", 2D) = "white"{}
        testScale("Scale", Float) = 1

    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200
        
        CGPROGRAM
        // Physically based Standard lighting model, and enable shadows on all light types
        #pragma surface surf Standard fullforwardshadows

        // Use shader model 3.0 target, to get nicer looking lighting
        #pragma target 3.0

        const static int maxLayerCount = 8;
        const static float epsilon = 1E-4;

        int layerCount;
        float3 baseColours[maxLayerCount];
        float baseStartHeights[maxLayerCount];
        float baseBlends[maxLayerCount];
        float baseColourStrength[maxLayerCount];
        float baseTextureScales[maxLayerCount];

        float minHeight;
        float maxHeight;

        sampler2D testTexture;
        float testScale;

        UNITY_DECLARE_TEX2DARRAY(baseTextures);

        struct Input {
            float3 worldPos;
            float3 worldNormal;
        };

        float inverseLerp(float a, float b, float value) {
            return saturate((value-a)/(b-a));
        }

        float3 triplanar(float3 worldPos, float scale, float3 blendAxes, int textureIndex) {
            float3 scaledWorldPos = worldPos / scale;
            float3 xProjection = UNITY_SAMPLE_TEX2DARRAY(baseTextures, float3(scaledWorldPos.y, scaledWorldPos.z, textureIndex)) * blendAxes.x;
            float3 yProjection = UNITY_SAMPLE_TEX2DARRAY(baseTextures, float3(scaledWorldPos.x, scaledWorldPos.z, textureIndex)) * blendAxes.y;
            float3 zProjection = UNITY_SAMPLE_TEX2DARRAY(baseTextures, float3(scaledWorldPos.x, scaledWorldPos.y, textureIndex)) * blendAxes.z;
            return xProjection + yProjection + zProjection;
        }

        void surf (Input IN, inout SurfaceOutputStandard o) {
            float heightPercent = inverseLerp(minHeight,maxHeight, IN.worldPos.y);
            float3 blendAxes = abs(IN.worldNormal);
            blendAxes /= blendAxes.x + blendAxes.y + blendAxes.z;

            for (int i = 0; i < layerCount; i ++) {
                float drawStrength = inverseLerp(-baseBlends[i]/2 - epsilon, baseBlends[i]/2, heightPercent - baseStartHeights[i]);

                float3 baseColour = baseColours[i] * baseColourStrength[i];
                float3 textureColour = triplanar(IN.worldPos, baseTextureScales[i], blendAxes, i) * (1-baseColourStrength[i]);

                o.Albedo = o.Albedo * (1-drawStrength) + (baseColour+textureColour) * drawStrength;
            }

        
        }


        ENDCG
    }
    FallBack "Diffuse"
}

The Texture data is communicated to the shader through this code here:

public void UpdateMeshHeights(Material material, float minHeight, float maxHeight)
{
    material.SetInt("layerCount", layers.Length);
    material.SetColorArray("baseColours", layers.Select(x => x.tint).ToArray());
    material.SetFloatArray("baseStartHeights", layers.Select(x => x.startHeight).ToArray());
    material.SetFloatArray("baseBlends", layers.Select(x => x.blendStrength).ToArray());
    material.SetFloatArray("baseColourStrengths", layers.Select(x => x.tintStrength).ToArray());
    material.SetFloatArray("baseTextureScales", layers.Select(x => x.textureScale).ToArray());
    Texture2DArray texturesArray = GenerateTextureArray(layers.Select(x => x.texture).ToArray());
    material.SetTexture("baseTextures", texturesArray);

    savedMinHeight = minHeight;
    savedMaxHeight = maxHeight;

    material.SetFloat("minHeight", minHeight);
    material.SetFloat("maxHeight", maxHeight);
}

Where layers is an array of a Layer objects, here

public class Layer
{
    public Texture2D texture;
    public Color tint;
    [Range(0, 1)]
    public float tintStrength;
    [Range(0, 1)]
    public float startHeight;
    [Range(0, 1)]
    public float blendStrength;
    public float textureScale;
}

The water shadergraph works all fine and dandy (tested in its own scene) and I am very pleased with the outcome. However, when brought together with the custom shader above, the water renders, but the mesh is left with an unwieldy pink colour for its material.

I understand it is something to do with the Render Pipelines; the .shadergraph files use the Universal Render Pipeline and the .shader files lie somewhere else. Is there any way to convert between the two, whether that be converting the .shader into a .shadergraph or vice versa? I can't seem to find any details on it anywhere.

If there is no way to convert, can someone talk me through how I can blend textures together based on heightmap data? At the moment the data about where the textures begin and end are held in FloatArrays, which there isn't an equivalent to using ShaderGraphs, from what I can see? I may be wrong, and there is probably a much better way of doing it.

If you want to look specifically at the code for the rest of the project, it can be found on my GitHub.

Thank you to anyone who can help or point me in the right direction!

\$\endgroup\$
  • 1
    \$\begingroup\$ In future, please edit your existing question rather than deleting and re-posting. Editing still bumps your question to the top of the list, and it avoids tripping false positives from our spam detection systems. ;) Now, have you considered using a custom function node in the shader graph to host the array code? \$\endgroup\$ – DMGregory Jun 24 at 13:20
  • \$\begingroup\$ Sorry! I didn't realise I could! Thank you for that, I wasn't aware I was able to build custom functions! I am now working on transferring everything over, however I can't seem to find an equivalent to UNITY_DECLARE_TEX2DARRAY(baseTextures). TEXTURE2D_ARRAY(baseTextures) is an 'unexpected token' \$\endgroup\$ – Tom Scott Jun 24 at 14:24
0
\$\begingroup\$

ShaderGraph generates sourcecode which you can view, copy and paste into a code-based shader, so converting or integrating a ShaderGraph shader into a code-based one is possible. However, the other way around isn't. If you want to convert a code-based shader into ShaderGraph, you have to manually rebuild it from scratch.

can someone talk me through how I can blend textures together based on heightmap data?

You can do that with a Blend Node. This node can perform various image blending algorithms between two textures which you might recognize from image editors like Photoshop. If you want to interpolate linearly between the two textures, then "Overwrite" is the mode you are looking for. But some of the other modes might also give you interesting results, so it might be worth experimenting a bit. This is the workflow to build such a shader in ShaderGraph:

  • Create 3 SampleTexture nodes for the two textures and the heightmap
  • Create the Blend node
  • Feed the two textures into the first two input nodes of the Blend node
  • Feed the heightmap into the 3rd input node
  • Feed the output into the masters albedo/color node

Shader Graph (I omitted the vertex displacement which is based on the same heightmap multiplied by 5):

Shader Graph

Result:

Result

The opacity input of the Blend node expects a value between 1 and 0 which determines the ratio between the two textures. If you only want a small band within the range of the heightmap to be blended, then you might have to send the output of the heightmap through a couple math nodes first.

For example, if you want the heightmap values between 0.5 and 0.6 to blend between the two textures and everything below that band to be texture A and everything above it to be texture B, then:

  • Subtract 0.5 (lower bound of blending zone)
  • Divide by 0.1 (width of the blending zone)
  • Clamp the results to a range of 0 to 1

Graph: Modified Heightmap

Result: enter image description here

| improve this answer | |
\$\endgroup\$
0
\$\begingroup\$

Here's a quick stab at how to convert your shader code to a custom function node we can apply in a Shader Graph.

First, we'll make a new text file to hold the shader code. I called mine "shaderGraphArray.cginc"

const static int maxLayerCount = 8;
const static float epsilon = 1E-4;

int layerCount;
float3 baseColours[maxLayerCount];
float baseStartHeights[maxLayerCount];
float baseBlends[maxLayerCount];
float baseColourStrength[maxLayerCount];
float baseTextureScales[maxLayerCount];

float3 triplanar(float3 worldPos, float scale, float3 blendAxes, Texture2DArray textures, SamplerState ss, int textureIndex) {
    float3 scaledWorldPos = worldPos / scale;
    float3 xProjection = SAMPLE_TEXTURE2D_ARRAY(textures, ss, float2(scaledWorldPos.y, scaledWorldPos.z), textureIndex) * blendAxes.x;
    float3 yProjection = SAMPLE_TEXTURE2D_ARRAY(textures, ss, float2(scaledWorldPos.x, scaledWorldPos.z), textureIndex) * blendAxes.y;
    float3 zProjection = SAMPLE_TEXTURE2D_ARRAY(textures, ss, float2(scaledWorldPos.x, scaledWorldPos.y), textureIndex) * blendAxes.z;
    return xProjection + yProjection + zProjection;
}

void layer_terrain_float(float3 worldPos, float heightPercent, float3 worldNormal, Texture2DArray textures, SamplerState ss, int layerCount, out float3 albedo) {
    float3 blendAxes = abs(worldNormal);
    blendAxes /= blendAxes.x + blendAxes.y + blendAxes.z;

    albedo = 0.0f;

    for (int i = 0; i < layerCount; i ++) {
        float drawStrength = inverseLerp(-baseBlends[i]/2 - epsilon, baseBlends[i]/2, heightPercent - baseStartHeights[i]);

        float3 baseColour = baseColours[i] * baseColourStrength[i];
        float3 textureColour = triplanar(worldPos, baseTextureScales[i], blendAxes, textures, ss, i) * (1-baseColourStrength[i]);

        albedo = albedo * (1-drawStrength) + (baseColour+textureColour) * drawStrength;
    }
}

Things to note here:

  • I refactored the main workhorse of iterating through the layers into a void function that takes all its inputs as arguments and puts its output into an out argument.

  • I changed the texture-sampling macros from the old, pre-scriptable-render-pipeline UNITY_SAMPLE_TEX2DARRAY to the new D3D11+ style SAMPLE_TEXTURE2D_ARRAY(textureName, samplerName, coord2, index). Based on this thread, I was able to find a list of all these macros here.

  • These macros now want a sampler state, so I included that as another argument to pass down.

The rest should be a pretty straightforward conversion.

Then I called this custom function from a shader graph as shown here:

Shader graph example

| improve this answer | |
\$\endgroup\$

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.