Hiding texture tiling of triplanar terrain shader? (for mobile)

Hey there,

im working on a terrain project. The terrain shader uses multiple textures and renders them depending on world height using triplanar mapping. This is the mapping function…

float3 triplanar(float3 pos, float scale, float3 blend, int texIndex) {
    float3 scaled = pos / scale;
    float3 x_projected = blend.x * UNITY_SAMPLE_TEX2DARRAY(baseTextures, float3(scaled.y, scaled.z, texIndex));
    float3 y_projected = blend.y * UNITY_SAMPLE_TEX2DARRAY(baseTextures, float3(scaled.x, scaled.z, texIndex));
    float3 z_projected = blend.z * UNITY_SAMPLE_TEX2DARRAY(baseTextures, float3(scaled.x, scaled.y, texIndex));
    return x_projected + y_projected + z_projected;
}

To avoid tiling of implemented this techique from here

float4 TextureNoTile(sampler2D tex, float2 uv)
{
    float2 x = uv;
    float v = 1.0; // Set your desired 'v' value here
    float k = tex2D(_Variation, 0.05 * x).r; // cheap (cache-friendly) lookup

    float2 duvdx = ddx(uv);
    float2 duvdy = ddy(uv);

    float l = k * 8.0;
    float f = frac(l);

    float ia = floor(l); // my method
    float ib = ia + 1.0;

    float2 offa = sin(float2(3.0, 7.0) * ia); // can replace with any other hash
    float2 offb = sin(float2(3.0, 7.0) * ib); // can replace with any other hash

    float4 cola = tex2Dgrad(tex, uv + v * offa, duvdx, duvdy);
    float4 colb = tex2Dgrad(tex, uv + v * offb, duvdx, duvdy);

    return lerp(cola, colb, smoothstep(0.2, 0.8, f - 0.1 * dot(cola - colb, 1.0)));
}

Tiling is no longer visible. Now I need to combine the shaders, but I see a few problems here:

  1. I’m using about 5 texture layers for the terrain and I plan on targeting mobile. I feel like sampling twice for each layer to avoid tiling is pretty expensive especially for the plattform. Do you know of any techniques to avoid having so many gpu instructions to achieve this?

  2. As far as I know UNITY_SAMPLE_TEX2DARRAY can not be called providing a gradient ddy/ddy. Is there another way without reimplement it. I didn’t find any resources explaining what TEX2DARRAY actually does behind the scenes. OR…

  3. I would like to not use texture arrays at all as it is not supported on OpenGL ES 2.0. Is it a good idea to pack all textures into one instead, and sample with tex2Dgrad (offsetting the uvs for each layer). The number of textures are about 512x512 large.

Thank you for your help!

  1. Yes. It’ll be expensive. The “alternative” is to have a much more complex setup where you calculate what texture and UV you want per a grid or voronoi cell and blend between those so you’re only ever doing 4 samples at a time.

  2. You’re correct, UNITY_SAMPLE_TEX2DARRAY itself cannot be called with gradients. But that’s because that’s just a macro that’s calling the non-gradient version of Sample(), and Unity didn’t bother to write versions that call SampleGrad().

#define UNITY_SAMPLE_TEX2DARRAY(tex,coord) tex.Sample (sampler##tex,coord)

Basically UNITY_SAMPLE_TEX2DARRAY(baseTextures, float3(scaled.y, scaled.z, texIndex)) is being converted into baseTextures.Sample(samplerbaseTextures, float3(scaled.y, scaled.z, texIndex)) at compile time. If you want to use gradients like you would with tex2Dgrad(), use this instead:

baseTextures.SampleGrad(samplerbaseTextures, float3(scaled.y, scaled.z, texIndex), duvdx, duvdy)
  1. Then you’re going to have to accept this shader being very expensive on those platforms, or have a fallback version of the shader that doesn’t use the anti-tiling, and maybe not even triplanar.
1 Like

Thank you, this was very helpful! I didn’t find any details about the grid / voronoi cell technique. Do you know of any articles about it?

I simlified the shader now, by using a 2x2 texture atlas. The shader then decides what texture offset to choose based on uvs (float4) representing each layer weight. To simlify, the shader only ever blends between two adjacent layers. Both layer indices are calculated directly from the weights. Therefore, instead of always sampling all 4 layers it samples only 2 at a time. Still this lefts me with 6 samples for triplanar mapping and in case of anti tiling it doubles to 12. I will probably only apply anti tiling on the y projection, as tiling is most visible on flat areas (resulting in 8 samples).

Furthermore, I still have issues with jumping mipmaps when looking from an angle (see attached image). I’m not sure if this is right way to calculate the derivatives for triplanar mapping but it is the best I could acheive.

If you have any ideas for improvement, I would be very happy about it. Thank you very much!

The shader looks like this now:

CGPROGRAM
#pragma surface surf Lambert noforwardadd vertex:vert

sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
sampler2D _Variation;

float _AtlasSize;
float _Padding;

struct Input {
   float3 worldPos;
   float3 worldNormal;
 
   float2 weights0;
   float2 weights1;
};

void vert(inout appdata_full v, out Input o) {
   o.worldPos = mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1)).xyz;
   o.worldNormal = mul(unity_ObjectToWorld, float4(v.normal, 0)).xyz;
    o.weights0 = v.texcoord.xy;
    o.weights1 = v.texcoord.zw;
}

float4 texTri(float3 worldPos, float scale, float index, float3 blendAxes)
{
   float2 uvx = worldPos.yz * scale;
   float2 uvy = worldPos.zx * scale;
   float2 uvz = worldPos.xy * scale;

   // Get offset to tile in atlas
   float tileSetDim = 2;
   float xpos = fmod(index, tileSetDim);
   float ypos = floor(index / tileSetDim);
   float2 uv = float2(xpos, ypos) / tileSetDim;

   // Offset to fragment position inside tile
   float2 offsetX = frac(uvx) / _Padding;
   float2 offsetY = frac(uvy) / _Padding;
   float2 offsetZ = frac(uvz) / _Padding;

   // Sample based on gradient and set output
   float2 uvr = (uvx + uvy + uvz) * 0.3333 / _Padding;

   float4 cx = tex2Dgrad(_MainTex, uv + offsetX, ddx(uvr), ddy(uvr)) * blendAxes.x;
   float4 cy = tex2Dgrad(_MainTex, uv + offsetY, ddx(uvr), ddy(uvr)) * blendAxes.y;
   float4 cz = tex2Dgrad(_MainTex, uv + offsetZ, ddx(uvr), ddy(uvr)) * blendAxes.z;

   return cx + cy + cz;
}


void surf (Input IN, inout SurfaceOutput o) {
   float w0 = IN.weights0.x;
   float w1 = IN.weights0.y;
   float w2 = IN.weights1.x;
   float w3 = IN.weights1.y;
 
   float c1 = ceil(w1);
   float c2 = ceil(w2);
   float c3 = ceil(w3);

   // Finding indices
   float indexA = c2 * 2;
   float indexB = c1 + c3 * 3;
   // test check
   // c0      => iA = 0 ; iB = x
   // c0 & c1 => iA = 0 ; iB = 1
   // c1      => iA = x ; iB = 1
   // c1 & c2 => iA = 2 ; iB = 1
   // c2      => iA = 2 ; iB = x
   // c2 & c3 => iA = 2 ; iB = 3
   // c3      => iA = x ; iB = 3

   // Finding Blending Weight
   float wA = w0 * c0 + w2 * c2;
   float wB = w1 * c1 + w3 * c3;
   // test check
   // c0      => wA = w0 ; wB = 0
   // c0 & c1 => wA = w0 ; wB = w1
   // c1      => wA = 0  ; wB = w1
   // c1 & c2 => wA = w2 ; wB = w1
   // c2      => wA = w2 ; wB = 0
   // c2 & c3 => wA = w2 ; wB = w3
   // c3      => wA = 0  ; wB = w3
 
   // normalize weights
   wA = wA / (wA + wB);
   wB = wB / (wA + wB);

   float scale = 0.1;

   float3 blendAxes = abs(IN.worldNormal);
   blendAxes /= blendAxes.x + blendAxes.y + blendAxes.z;

   o.Albedo = texTri(IN.worldPos, scale, indexA, blendAxes) * wA +
            texTri(IN.worldPos, scale, indexB, blendAxes) * wB;
}
ENDCG

9528649--1344982--Bildschirm­foto 2023-12-14 um 23.29.14.png