How to create a multiple textured terrain

I am looking for a way to create a terrain textured with multiple tiles, blended by a blend map.

Something like this image:

What you are looking for is a technique called texture splatting. There is no texture splatting solution available in the official three.js repository. But there are some existing solutions in the web like this one:

You should also have a look at the following topic: Basic Texture Splatting, need help

I am studying the code from Lee Stemkoski, the shader is not very complicated but it uses terrain height if I’m correct. I need to change it to use a blend map. My idea is that the blend map should work as follows:

  • There is a basic ground texture
  • four other textures should be added based on the R, G, B and A value.

Not sure yet how to get there, but I have changed my mindset from “Need to sort this stupid problem out fast” to “That’s a nice experiment!” - looking forward to the journey and will keep you posted. Please feel free to weigh in if you got ideas…


Got it working a rudimentary form:
Tile Blend Shader Experiment

This is my fragment shader:

uniform sampler2D blendTexture;
uniform sampler2D baseTexture;
uniform sampler2D sandyTexture;
uniform sampler2D grassTexture;
uniform sampler2D rockyTexture;
uniform sampler2D snowyTexture;

varying vec2 vUV;

varying float vAmount;

void main() 
	vec4 blend=texture2D( blendTexture, vUV );
	float baseWeight=1.0 - max(blend.r, max(blend.g, blend.b));

	vec4 base =  baseWeight * texture2D( baseTexture, vUV * 10.0 );
	vec4 sandy = blend.r * texture2D( sandyTexture, vUV * 10.0 );
	vec4 grass = blend.g * texture2D( grassTexture, vUV * 10.0 );
	vec4 rocky = blend.b * texture2D( snowyTexture, vUV * 20.0 );
	gl_FragColor = (vec4(0.0, 0.0, 0.0, 1.0) + base + sandy + grass + rocky) / (baseWeight+blend.r+blend.g+blend.b); 


Please note this is my first shader coding experiment ever… :wink:


Thanks for sharing your code! :+1:

Hint: When you post code in this forum, please use backticks in order to open and close a code section. This will improve formatting and readability. I’ve edited your post so you can see how it’s done.

1 Like

I’ve actually been looking for a texture splatting example that can use alpha pixels. One where I can actually use RGBA pixels & draw ‘on’ the texture. ‘Not’ the example stemkoski created.

I am now trying to implement lighting and bumpmapping. I would guess that all we need is the exact same process the built in shaders use after the final texture source color has been determined.

Does anyone know where I can find the code for the default shaders? Like the Phong shaders? Or some documentation on how to implement ShaderChunks might also come in handy.

It’s a bit silly to keep replying to my own post, but I think this may be helpful to others.

I now have a demo with working lighting and normal mapping. I took a different approach to get here. Instead of creating a custom material with my own shader, I now use onBeforeCompile to replace some shader chunks to suit my needs, leaving all others intact. This allows me to start with any material.

I then replace the mapping chunks and the normalmapping chunks for the fragment shader - leaving the vertex shader as it is.

The result is seen here: Terrain tiles blending demo #4

Navigate using W (forward), S (backward), A (turn left) and D (turn right) keys.

My shader codes:

<script id="tileblend_pars_fragment" type="x-shader/x-vertex">
	#define USE_MAP 
	uniform sampler2D texBlendMap;
	uniform sampler2D texBaseColor;
	uniform sampler2D texTile1Color;
	uniform sampler2D texTile2Color;
	uniform sampler2D texTile3Color;
	//uniform sampler2D texTile4Color;

	uniform float repeatBase;
	uniform float repeatTile1;
	uniform float repeatTile2;
	uniform float repeatTile3;
	//uniform float repeatTile4;


<script id="tileblend_fragment" type="x-shader/x-vertex"> 
	vec4 tbBlend=texture2D( texBlendMap, vUv );
	float tbBaseWeight=1.0 - max(tbBlend.r, max(tbBlend.g, tbBlend.b));

	vec4 base =  tbBaseWeight * texture2D( texBaseColor, vUv * repeatBase );
	vec4 color1 = tbBlend.r * texture2D( texTile1Color, vUv * repeatTile1 );
	vec4 color2 = tbBlend.g * texture2D( texTile2Color, vUv * repeatTile2 );
	vec4 color3 = tbBlend.b * texture2D( texTile3Color, vUv * repeatTile3 );
	vec4 newColor = (vec4(0.0, 0.0, 0.0, 1.0) + base + color1 + color2 + color3) / (tbBaseWeight+tbBlend.r+tbBlend.g+tbBlend.b);

	diffuseColor = newColor; 

<script id="normalmap_pars_fragment" type="x-shader/x-vertex"> 

	uniform sampler2D normalMap;
	uniform vec2 normalScale;

	uniform sampler2D texBaseBump;
	uniform sampler2D texTile1Bump;
	uniform sampler2D texTile2Bump;
	uniform sampler2D texTile3Bump;
	uniform sampler2D texTile4Bump;

		uniform mat3 normalMatrix;
		// Per-Pixel Tangent Space Normal Mapping
		vec3 perturbNormal2Arb( vec3 eye_pos, vec3 surf_norm ) {
			// Workaround for Adreno 3XX dFd*( vec3 ) bug. See #9988
			vec3 q0 = vec3( dFdx( eye_pos.x ), dFdx( eye_pos.y ), dFdx( eye_pos.z ) );
			vec3 q1 = vec3( dFdy( eye_pos.x ), dFdy( eye_pos.y ), dFdy( eye_pos.z ) );
			vec2 st0 = dFdx( );
			vec2 st1 = dFdy( );
			float scale = sign( st1.t * st0.s - st0.t * st1.s ); // we do not care about the magnitude
			vec3 S = normalize( ( q0 * st1.t - q1 * st0.t ) * scale );
			vec3 T = normalize( ( - q0 * st1.s + q1 * st0.s ) * scale );
			vec3 N = normalize( surf_norm );
			mat3 tsn = mat3( S, T, N );

			vec4 tbBlend=texture2D( texBlendMap, vUv );
			float tbBaseWeight=1.0 - max(tbBlend.r, max(tbBlend.g, tbBlend.b));

			float foundIdx=0.0; 
			float foundVal=tbBaseWeight;

			if (tbBlend.r>foundVal) { foundIdx=1.0; foundVal=tbBlend.r;}
  			if (tbBlend.g>foundVal) { foundIdx=2.0; foundVal=tbBlend.g;}
			if (tbBlend.b>foundVal) { foundIdx=3.0; foundVal=tbBlend.b;}

			vec3 mapN = texture2D( texBaseBump, vUv * repeatBase ).xyz * 2.0 - 1.0;
			if (foundIdx==1.0) mapN = texture2D( texTile1Bump, vUv * repeatTile1 ).xyz * 2.0 - 1.0;	
			else if (foundIdx==2.0) mapN = texture2D( texTile2Bump, vUv * repeatTile2 ).xyz * 3.0 - 1.0;
			else if (foundIdx==3.0) mapN = texture2D( texTile3Bump, vUv * repeatTile3 ).xyz * 3.0 - 1.0;

			mapN.xy *= normalScale;
			mapN.xy *= ( float( gl_FrontFacing ) * 2.0 - 1.0 );
			return normalize( tsn * mapN );


And the onBeforeCompile bit:

// --------------------------------------------------------
// Customizing Material
terrainMaterial.userData.myValue = { type:"f",value: 2.0 } //this will be our input, the system will just reference it

terrainMaterial.onBeforeCompile = shader => {
	shader.uniforms.myValue = terrainMaterial.userData.myValue //pass this input by reference
	shader.uniforms.texBlendMap=	{ type: "t", value: blendTexture };
	shader.uniforms.texBaseColor=	{ type: "t", value: baseTexture };
	shader.uniforms.texBaseBump =	{ type: "t", value: baseBump };
	shader.uniforms.repeatBase=		{ type: "f", value: 50};
	shader.uniforms.texTile1Color=	{ type: "t", value: grassTexture };
	shader.uniforms.texTile1Bump =	{ type: "t", value: grassBump };
	shader.uniforms.repeatTile1=	{ type: "f", value: 20};
	shader.uniforms.texTile2Color=	{ type: "t", value: snowyTexture };
	shader.uniforms.texTile2Bump =	{ type: "t", value: snowyBump };
	shader.uniforms.repeatTile2=	{ type: "f", value: 35};
	shader.uniforms.texTile3Color=	{ type: "t", value: rockyTexture };
	shader.uniforms.texTile3Bump =	{ type: "t", value: rockyBump };
	shader.uniforms.repeatTile3=	{ type: "f", value: 19};
	//shader.uniforms.texTile4Color=	{ type: "t", value: sandyTexture };
	//shader.uniforms.texTile4Bump =	{ type: "t", value: sandyBump };
	//shader.uniforms.repeatTile4=	{ type: "f", value: 20};

	shader.fragmentShader = document.getElementById( 'tileblend_pars_fragment'   ).textContent + shader.fragmentShader
	shader.fragmentShader = 
  		'#include <map_fragment>', 
  		document.getElementById( 'tileblend_fragment'   ).textContent
	shader.fragmentShader = 
  		'#include <normalmap_pars_fragment>', 
  		document.getElementById( 'normalmap_pars_fragment'   ).textContent


I am not happy with the shadows, but those are weird even when the material is not modified, so I’ll start a different topic for it.


@Fedor_van_Eldijk : Actually I’m glad you keep replying! It’s helping me ‘alot’ & I really appreciate all that you do & are doing! :slight_smile: Can you add slope angle collision? I just wanna use a splatmap with alpha to draw on the Terrain.


I am not sure if I need that for my purpose, this demo is just a splatmap. It uses a base tile and can handle four different splatting textures using the R, G, B and A channels.

In my project, the splatmap will be generated offline by a map generating tool. I might add a painting function to it later, but for now it is all I need. However you feel free to rip the code and extend it.

@Fedor_van_Eldijk : Do you have an example of using splat maps created from a program like / & a way to make the texture look better on the shader by decreasing the scaled texture’s size?

Thank you very much!

Thank you so much for doing this. Been looking for something like this forever!!!

Gave me a very good place to branch of.

Thanks x a billion!!!

I use this technique in my game also, found the stackoverflow question a couple of years back.
Here’s the result:
I’ve had a bit of an issue packing all 4 splat masks into a single PNG, didn’t find good tools for that. Instead, I load 4 separate grayscale masks and combine them into a single DataTexture inside the engine.


I only use diffuse textures. Based on some experiements, i came to a conclusion that even this already strains number of texture units, adding more makes your shader unusable on some of the lower-end mobile devices.


Looks great!

Yea I ran into a texture unit issue when I added normal and specular mapping (with shadows). Had to workaround by passing blendmap into the normal map perturbnormal function instead of re-fetching it. Also instead of using the “Base” diffuse,normal and specular maps as uniforms I just use those as the material map, normalMap and specularMaps.

Just for the heck of it here’s the shader code:

	terrainSplatTexture: {
		tileblend_pars_fragment: [

			'#define USE_MAP',
			'uniform sampler2D texBlendMap;',
			//'uniform sampler2D texBaseColor;',
			'uniform sampler2D texTile1Color;',
			'uniform sampler2D texTile2Color;',
			'uniform sampler2D texTile3Color;',

			'uniform float repeatBase;',
			'uniform float repeatTile1;',
			'uniform float repeatTile2;',
			'uniform float repeatTile3;',

			'uniform int tileCount;'


		tileblend_fragment: [
			'vec4 tbBlend;',
			'float tbBaseWeight;',

			'if ( tileCount > 1 ){',

				'tbBlend = texture2D( texBlendMap, vUv );',

				'tbBaseWeight = 1.0 - max(tbBlend.r, max(tbBlend.g, tbBlend.b));',											
				'vec4 texelColor = vec4(0.0, 0.0, 0.0, 1.0);',

				'texelColor += tbBaseWeight * texture2D( map, vUv * repeatBase );',
				'texelColor += tbBlend.r * texture2D( texTile1Color, vUv * repeatTile1 );',
				'tbBaseWeight += tbBlend.r;',							

				'if( tileCount > 2 ) {',
					'texelColor += tbBlend.g * texture2D( texTile2Color, vUv * repeatTile2 );',
					'tbBaseWeight += tbBlend.g;',


				'if( tileCount > 3 ) {',
					'texelColor += tbBlend.b * texture2D( texTile3Color, vUv * repeatTile3 );',
					'tbBaseWeight += tbBlend.b;',

				'texelColor = texelColor / tbBaseWeight;',

				'texelColor.a = tbBlend.a;',

				//'diffuseColor = mapTexelToLinear( texelColor );',
				'diffuseColor = texelColor;',

			'} else {',

				'vec4 texelColor = texture2D( map, vUv );',

				'texelColor = mapTexelToLinear( texelColor );',

				'diffuseColor *= texelColor;',


		normalmap_pars_fragment: [			
			'#ifdef USE_NORMALMAP',
				'uniform sampler2D normalMap;',
				'uniform vec2 normalScale;',

				//'uniform sampler2D texBaseBump;',
				'uniform sampler2D texTile1Bump;',
				'uniform sampler2D texTile2Bump;',
				'uniform sampler2D texTile3Bump;',				

					'uniform mat3 normalMatrix;',
					'// Per-Pixel Tangent Space Normal Mapping',
					'vec3 perturbNormal2Arb( vec3 eye_pos, vec3 surf_norm, vec4 tbBlend ) {',

						'// Workaround for Adreno 3XX dFd*( vec3 ) bug. See #9988',
						'vec3 q0 = vec3( dFdx( eye_pos.x ), dFdx( eye_pos.y ), dFdx( eye_pos.z ) );',
						'vec3 q1 = vec3( dFdy( eye_pos.x ), dFdy( eye_pos.y ), dFdy( eye_pos.z ) );',
						'vec2 st0 = dFdx( );',
						'vec2 st1 = dFdy( );',
						'float scale = sign( st1.t * st0.s - st0.t * st1.s ); // we do not care about the magnitude',
						'vec3 S = normalize( ( q0 * st1.t - q1 * st0.t ) * scale );',
						'vec3 T = normalize( ( - q0 * st1.s + q1 * st0.s ) * scale );',
						'vec3 N = normalize( surf_norm );',
						'mat3 tsn = mat3( S, T, N );',
						'vec3 mapN;',

						'if( tileCount > 1 ){',

							//'vec4 tbBlend=texture2D( texBlendMap, vUv );',
							'float tbBaseWeight=1.0 - max(tbBlend.r, max(tbBlend.g, tbBlend.b));',

							'float foundIdx=0.0;',
							'float foundVal=tbBaseWeight;',

							'if (tbBlend.r>foundVal) { foundIdx=1.0; foundVal=tbBlend.r;}',
							'if (tbBlend.g>foundVal) { foundIdx=2.0; foundVal=tbBlend.g;}',
							'if (tbBlend.b>foundVal) { foundIdx=3.0; foundVal=tbBlend.b;}',

							'mapN = texture2D( normalMap, vUv * repeatBase ).xyz * 2.0 - 1.0;',						
							'if (foundIdx==1.0) mapN = texture2D( texTile1Bump, vUv * repeatTile1 ).xyz * 2.0 - 1.0;',		
							'else if (foundIdx==2.0 && tileCount > 2) mapN = texture2D( texTile2Bump, vUv * repeatTile2 ).xyz * 2.0 - 1.0;',
							'else if (foundIdx==3.0 && tileCount > 3) mapN = texture2D( texTile3Bump, vUv * repeatTile3 ).xyz * 2.0 - 1.0;',
						'} else {',
							'mapN = texture2D( normalMap, vUv ).xyz * 2.0 - 1.0;',							
						'mapN.xy *= normalScale;',
						'mapN.xy *= ( float( gl_FrontFacing ) * 2.0 - 1.0 );',						
						'return normalize( tsn * mapN );',
		normalmap_fragment_maps: [
			'#ifdef USE_NORMALMAP',
					'normal = texture2D( normalMap, vUv ).xyz * 2.0 - 1.0;',
					'#ifdef FLIP_SIDED',
						'normal = - normal;',
					'#ifdef DOUBLE_SIDED',
						'normal = normal * ( float( gl_FrontFacing ) * 2.0 - 1.0 );',
					'normal = normalize( normalMatrix * normal );',
					'#ifdef USE_TANGENT',
						'mat3 vTBN = mat3( tangent, bitangent, normal );',
						'vec3 mapN = texture2D( normalMap, vUv ).xyz * 2.0 - 1.0;',
						'mapN.xy = normalScale * mapN.xy;',
						'normal = normalize( vTBN * mapN );',
						'normal = perturbNormal2Arb( -vViewPosition, normal, tbBlend );',
			'#elif defined( USE_BUMPMAP )',
				'normal = perturbNormalArb( -vViewPosition, normal, dHdxy_fwd() );',
		specularmap_pars_fragment: [
			'#ifdef USE_SPECULARMAP',
				'uniform sampler2D specularMap;',				
				//'uniform sampler2D texBaseSpecular;',
				'uniform sampler2D texTile1Specular;',
				'uniform sampler2D texTile2Specular;',
				'uniform sampler2D texTile3Specular;',	


		specularmap_fragment: [
			'float specularStrength;',
			'#ifdef USE_SPECULARMAP',
				'vec4 texelSpecular;',
				'if( tileCount > 1 ){',
					//'vec4 tbBlend=texture2D( texBlendMap, vUv );',
					//'float tbBaseWeight=1.0 - max(tbBlend.r, max(tbBlend.g, tbBlend.b));',				

					'float foundIdx=0.0;',
					'float foundVal = tbBaseWeight;',

					'if (tbBlend.r > foundVal) { foundIdx=1.0; foundVal=tbBlend.r; }',
					'if (tbBlend.g > foundVal) { foundIdx=2.0; foundVal=tbBlend.g; }',
					'if (tbBlend.b > foundVal) { foundIdx=3.0; foundVal=tbBlend.b; }',

					'vec4 texelSpecular = texture2D( specularMap, vUv * repeatBase );',
					'if (foundIdx==1.0) texelSpecular = texture2D( texTile1Specular, vUv * repeatTile1 );',
					'else if (foundIdx==2.0) texelSpecular = texture2D( texTile2Specular, vUv * repeatTile2 );',
					'else if (foundIdx==3.0) texelSpecular = texture2D( texTile3Specular, vUv * repeatTile3 );',
					'texelSpecular = texture2D( specularMap, vUv );',
				'specularStrength = texelSpecular.r;',
				'specularStrength = 1.0;',
1 Like

That is brilliant!! Thank you for posting that! I have been trying to work out a low-impact way to texture a terrain, and I think this would work wonderful. Say with mountaintops, I could have a snowy texture… then a corresponding mask that you just airbrush in where you want those bits. :slight_smile:

Can anyone modify this shader to allow for a ‘splat map’ for RGBA? Ty VERY much for this EXCELLENT project & keep up the great work!

What exactly do you mean? Is this about using the A-channel as well - because that I did not get arount yet too, so I would second that request.

@Fedor_van_Eldijk Yes, Alpha channel. Just like this example splat map image I have created that contains Red, Green, Blue, Alpha, Red-Alpha, Green-Alpha, Blue-Alpha, and FULL Alpha ( full transparency ) ::


and the ability to use 256 different types of texture channels via the shader. If not, RGBA & being able to change the texture’s quality per-texture would be just GREAT!!! :slight_smile:

Once again, thank you SO kindly for the GREAT project! :slight_smile:

I’m a bit confused. I don’t understand how you plan to be able to use 256 textures. Could you please explain?

Currently, I use 4 diffuse textures and 1 RGBA splat texture which dictates proportions of diffuse textures on each of 4 channels: R, G, B and A. So there are 256 possible “strength” contributions for each of the diffuse textures. I can imagine how a single channel can be split into 8 bands, and have basically binary blend states with a single bit: ON or OFF, but I still don’t see how to get “256 different types of texture channels”.

thanks :mermaid:

I’ve made such a technique for Tesseract, basically the possible 256 materials result by a 8 bit indices map, that could be a larger too of course, but 256 is quiet a lot and has to be in memory as well. It is quiet more complicated though, The indices management is prepared while brushing, since you don’t work with a splat-map per material.