Apply multiple "octaves" of textures/noise to avoid repetition.
(You can read more about a related technique as used for generating procedural terrains here: https://developer.nvidia.com/gpugems/gpugems3/part-i-geometry/chapter-1-generating-complex-procedural-terrains-using-gpu - particularly "1.3.3 Making an Interesting Density Function" which mentions other ways to break up repetition like warping coordinates for higher octaves)
But the key takeaway is that you blend multiple copies of the (texture|noise) at different scales with varying weights.
As an example, you might pick (pseudocode)
color = texture_at(x, y) * .6
+ texture_at(x/5, y/5) * .2
+ texture_at(x/10, y/10) * .1
+ texture_at(x/50, y/50) * .1;
That way, when you're up close, the detail is clearly visible and the lower amplitude blurs of lower octaves are barely visible, whilst at longer range, the lower frequency features -that span multiple tiles- tend to dominate.
Obviously you can tweak the number of samples and relative weighting to get the effect you want.
A separate technique I've used elsewhere is to use the normal of the surface to blend between three textures [Triplanar texturing].
This was quite some time ago, so excuse the vagueness but...
When texturing a mesh-based procedural terrain, I used a custom shader which calculated the normal vector of the surface in world space and used the relative weight of the x, y and z vectors to blend between three textures specified by the user.
This allowed me to specify textures for a grass floor, a rock wall and a cave roof.
I managed to dig out this shader code but please bear in mind this is one of the first things I did in Unity, I'm no shader expert and I haven't reviewed it in detail...
Properties{
//_MainTex ("Albedo (RGB)", 2D) = "white" {}
_TopWeighting("Vertical Falloff", Range(0.1, 10)) = 5
_UpTex("Top", 2D) = "red" {}
_SideTex("Side", 2D) = "green" {}
_DownTex("Bottom", 2D) = "blue" {}
}
// ....
struct Input {
float3 worldPos : SV_POSITION;
float3 worldNormal;
float3 pos;
};
void vert(inout appdata_full v, out Input o) {
UNITY_INITIALIZE_OUTPUT(Input, o);
o.pos = mul(_Object2World, v.vertex);
}
// ....
void surf(Input IN, inout SurfaceOutputStandard o) {
// Sample the various textures we're using
fixed4 up = tex2D(_UpTex, fmod(IN.pos.xz * _UpTex_ST, 1));
fixed4 sidex = tex2D(_SideTex, IN.pos.yz * _SideTex_ST);
fixed4 sidez = tex2D(_SideTex, IN.pos.xy * _SideTex_ST);
fixed4 down = tex2D(_DownTex, IN.pos.xz * _DownTex_ST);
// Work out if we're doing the top or bottom
float top = clamp(IN.worldNormal.y * 100000, 0, 1);
// Work out how much weight should be given to the various textures
float3 blending = abs(float3(IN.worldNormal.x / _TopWeighting,
IN.worldNormal.y,
IN.worldNormal.z / _TopWeighting));
// Force weights to sum to 1.0
blending = normalize(max(blending, 0.00001));
// scale the various weights proportionally
float b = (blending.x + blending.y + blending.z);
blending /= float3(b, b, b);
// Combine the textures with the appropriate weighting
float4 tex = sidex * blending.x +
(top) * up * blending.y +
(1-top) * down * blending.y +
sidez * blending.z;
o.Albedo = tex;
// Metallic and smoothness aren't currently used,
// set them to some neutral values
o.Metallic = 0;
o.Smoothness = .1;
o.Alpha = 0;
}
You should be able to combine the two techniques fairly easily by updating the colour sampling at the top of the surface shader, hopefully giving you some more varied terrain.