Multitexturing a WebGL Terrain using a Blendmap
Welcome back to another WebGL tutorial 🎉
In this tutorial we’ll learn how to use a blendmap to multitexture a terrain.
Multitexturing is the practice of using more than one texture when rendering something. This allows you to create many different effects that you couldn’t achieve with just one texture.
In its most basic form, a blend map is an image that combines red, green, blue and black in order to encode the usage of four different textures. Each texture corresponding to one of those four colors.
You can create a blend map in any image editing tool such as Photoshop, but for this tutorial we’ll create our blend map using a custom 2d canvas tool that we’ll code up and then embed into our demo.
We’ll then use that blend map to multitexture a WebGL terrain on a separate 3d canvas.
You’ll hopefully walk away with an understanding of when you might use a blendmap, as well as a reference implementation for how to write and use a blendmap WebGL shader.
Why use a blendmap?
The big advantage to using a blendmap when texturing a terrain is that it makes it easy to tile multiple smaller textures across your large terrain.
You might use this technique to add paths, patches of dirt or other details to your terrain.
Let’s say that you want your terrain to be covered in mossy grass, with a few stone paths cutting through that moss. You want both of the moss and stone textures to tile, and you also want control over where they show up. How would you approach this?
One way would be to create a texture the size of your terrain that uses both your moss and stone texture. But what if your terrain is 10,000 x 10,000
pixels wide?
Today’s hardware won’t support a 10,000 x 10,000
sized texture, so you’d need to use a smaller texture image and then stretch it to fit your 10,000 x 10,000
terrain. This would lead to a much lower quality stretched texture on your terrain.
Alternatively, you can encode the location of the stone and moss and make your stone and moss textures tile themselves across anywhere on your terrain that they’ve been encoded. To do this we would use a third texture, our blendmap.
Our blendmap encodes which texture should be visible at which places on our terrain.
The interactive demo above will help us demonstrate. Click on the stone
button and paint onto the painting canvas on the left.
The stone texture is being tiled on our WebGL terrain on the right canvas, and our blend map is encoding how much of the stone should show through. The color black tells our shader where to show the stone texture.
Now click on moss
and paint on your blend map. You’re now using the color green in order to encode where the moss tiling should be shown.
Now drag the green slider to its halfway point and paint some more.
If you look closely you’ll see that your terrain is being painted with a mixture of stone and moss. This is because you’re using a mixture of black and green.
With blend maps you can encode exactly how much you want each texture to bleed through, allowing you to blend different textures! This is very useful for adding details such as paths that have a mix of dirt and grass.
With that out of the way, let’s jump into implementing the demo above.
Downloading our dependencies
Create a directory to hold the tutorial code.
mkdir blend-map-tutorial
cd blend-map-tutorial
touch tutorial.js
Download the texture atlas that we’ll use to texture our WebGL terrain.
curl -OL https://github.com/chinedufn/\
webgl-blend-map-tutorial/raw/master/terrain.jpg
We’ll install a development server so that we can view our tutorial in our browser, as well as a 4x4
matrix math library that we’ll use to create a perspective matrix.
npm install budo@10.0.3 gl-mat4@1.1.4
Implementing our blendmapped terrain
You’ll learn more if you type out the code as we go, but if you’d like a full, commented reference you can find all of the blend mapping source code on GitHub.
Open up tutorial.js
and start by creating the canvas that we’ll be drawing onto and using as a blend map.
var blendCanvas = document.createElement('canvas')
blendCanvas.width = 256
blendCanvas.height = 256
blendCanvas.style.width = '256px'
blendCanvas.style.height = '256px'
blendCanvas.style.border = 'solid 1px rgba(0, 0, 0, 0.1)'
Next we set a variable that will hold the current brush color as we draw and change colors.
var brushColor = 'purple'
Next we create the slider controls that allow us to change the brush color and size. Dragging these controls will affect how much red, green and blue shows up in our image.
function createColorSlider () {
var colorSlider = document.createElement('input')
colorSlider.type = 'range'
colorSlider.min = 0
colorSlider.max = 255
colorSlider.step = 1
colorSlider.oninput = handleSliderChange
return colorSlider
}
var redSlider = createColorSlider()
var blueSlider = createColorSlider()
var greenSlider = createColorSlider()
function handleSliderChange () {
brushColor =
`rgb(${redSlider.value}, ${greenSlider.value}, ${blueSlider.value})`
setColorDisplays()
}
function addSliderLabel (label, colorSlider) {
var sliderLabel = document.createElement('label')
sliderLabel.display = 'inline-block'
sliderLabel.innerHTML = label
var sliderWrapper = document.createElement('div')
sliderWrapper.display = 'block'
sliderWrapper.appendChild(sliderLabel)
sliderWrapper.appendChild(colorSlider)
return sliderWrapper
}
var brushSizeSlider = document.createElement('input')
brushSizeSlider.type = 'range'
brushSizeSlider.value = 20
brushSizeSlider.min = 0
brushSizeSlider.max = 40
brushSizeSlider.step = 1
var sliderContainer = document.createElement('div')
sliderContainer.appendChild(addSliderLabel('R', redSlider))
sliderContainer.appendChild(addSliderLabel('G', greenSlider))
sliderContainer.appendChild(addSliderLabel('B', blueSlider))
sliderContainer.appendChild(addSliderLabel('Brush Size', brushSizeSlider))
Next we create the display that shows us what color we currently have selected. This is composed of a little circular div that shows that has it’s background set to the current color, and then a span that shows the rgb(x, y, z)
components of the current color.
function setColorDisplays () {
colorDisplay.innerHTML =
`rgb(${redSlider.value}, ${greenSlider.value}, ${blueSlider.value})`
swatchDisplay.style.backgroundColor = brushColor
}
var colorDisplay = document.createElement('span')
var swatchDisplay = document.createElement('div')
swatchDisplay.style.width = '20px'
swatchDisplay.style.height = '20px'
swatchDisplay.style.marginRight = '5px'
swatchDisplay.style.borderRadius = '50%'
setColorDisplays()
var colorDisplayContainer = document.createElement('div')
colorDisplayContainer.style.display = 'flex'
colorDisplayContainer.style.alignItems = 'center'
colorDisplayContainer.appendChild(swatchDisplay)
colorDisplayContainer.appendChild(colorDisplay)
Now let’s create the buttons that let us set black
red
green
and blue
as our brush color. These buttons directly correspond to our four textures - stone, lava, moss and water.
We also create the undo button. When we click this we’ll go back in time to our previous brush stroke.
var buttonContainer = document.createElement('div')
buttonContainer.style.display = 'flex'
buttonContainer.style.width = '500px'
buttonContainer.style.flexWrap = 'wrap'
function createColorButton (color, label) {
var colorButton = document.createElement('button')
colorButton.style.color = `rgb(${color.join(',')})`
colorButton.style.fontSize = '24px'
colorButton.style.cursor = 'pointer'
colorButton.style.marginTop = '5px'
colorButton.style.marginRight = '5px'
colorButton.innerHTML = label
colorButton.onclick = function () {
brushColor = `rgb(${color.join(',')})`
redSlider.value = color[0]
greenSlider.value = color[1]
blueSlider.value = color[2]
setColorDisplays()
}
buttonContainer.appendChild(colorButton)
}
var undoButton = document.createElement('button')
undoButton.style.cursor = 'pointer'
undoButton.style.marginRight = '5px'
undoButton.innerHTML = 'Undo'
undoButton.onclick = undo
buttonContainer.appendChild(undoButton)
createColorButton([0, 0, 0], 'stone')
createColorButton([255, 0, 0], 'lava')
createColorButton([0, 255, 0], 'moss')
createColorButton([0, 0, 255], 'water')
Now that we’ve made all of our controls we add them into the page.
var mountElem = document.querySelector('#webgl-blend-map-tutorial')
|| document.body
mountElem.appendChild(buttonContainer)
mountElem.appendChild(sliderContainer)
mountElem.appendChild(colorDisplayContainer)
Next we add our painting canvas and our WebGL terrain canvas into the DOM. We’ve now inserted all of our demo elements into the page.
var webGLCanvas = document.createElement('canvas')
webGLCanvas.width = 512
webGLCanvas.height = 512
webGLCanvas.style.cursor = 'not-allowed'
mountElem.appendChild(webGLCanvas)
var canvasContainer = document.createElement('div')
canvasContainer.style.display = 'flex'
canvasContainer.appendChild(blendCanvas)
canvasContainer.appendChild(webGLCanvas)
mountElem.appendChild(canvasContainer)
In order to paint onto our canvas we need to get our painting canvas’ 2d context. Our canvas’ 2d context holds all of the state and methods that determine what gets drawn onto the canvas at any given time.
var canvas2dContext = blendCanvas.getContext('2d')
canvas2dContext.fillStyle = brushColor
canvas2dContext.fillRect(
0, 0, canvas2dContext.canvas.width, canvas2dContext.canvas.height
)
Now we create the controls that allow us to paint. When we click and move our mouse we’ll add points to an array. When we draw our painting canvas every request animation frame we’ll draw and connect these points.
We’ll also keep track of the lastMouseReleaseIndices
array. Whenever we release our mouse or finger we’ll add to this array. Then when we hit our undo button we’ll use the last element in this array to know how far back to go when we remove points that have been drawn onto our painting canvas.
var isPainting = false
function startPainting (e) {
isPainting = true
addPoint(
e.pageX - blendCanvas.offsetLeft,
e.pageY - blendCanvas.offsetTop,
false,
brushSizeSlider.value
)
}
function movePaintbrush (e) {
if (isPainting) {
addPoint(
(e.pageX || e.changedTouches[0].pageX) - blendCanvas.offsetLeft,
(e.pageY || e.changedTouches[0].pageY) - blendCanvas.offsetTop,
true,
brushSizeSlider.value
)
}
}
var lastMouseReleaseIndices = []
function stopPainting () {
lastMouseReleaseIndices.push(allPoints.length - 1)
isPainting = false
}
Now that we have our painting functions set up we add event listeners to our painting canvas to start and stop painting as we click or touch it.
blendCanvas.addEventListener('mousedown', startPainting)
blendCanvas.addEventListener('touchstart', startPainting)
blendCanvas.addEventListener('mousemove', movePaintbrush)
blendCanvas.addEventListener('touchmove', movePaintbrush)
blendCanvas.addEventListener('mouseup', stopPainting)
blendCanvas.addEventListener('touchend', stopPainting)
Here we create our function that adds a new point to our canvas. If connect is true we will draw a line between a new point and the point before it. This would happen if you were dragging the mouse or your finger.
var allPoints = []
function addPoint (x, y, connectWithPrevious, size) {
allPoints.push({
x: x,
y: y,
connect: connectWithPrevious,
color: brushColor,
size: size
})
}
Here we set up our undo function. It looks at the second to last time that we lifted our mouse or finger and removes all points that have come since that time.
function undo () {
if (lastMouseReleaseIndices.length > 1) {
allPoints = allPoints.slice(
0,
lastMouseReleaseIndices[lastMouseReleaseIndices.length - 2]
)
} else if (lastMouseReleaseIndices.length === 1) {
allPoints = []
}
lastMouseReleaseIndices.pop()
}
Here we redraw our painting canvas. We’ll do this every request animation frame. Whenever we redraw our canvas we set our blendmap image to read from whatever we’ve drawn onto our canvas. This has the effect of live reloading our WebGL terrain as we paint onto our 2d canvas since our WebGL terrain uses this blendmap image.
function redrawPaintCanvas () {
canvas2dContext.fillRect(
0, 0, canvas2dContext.canvas.width, canvas2dContext.canvas.height
)
canvas2dContext.lineJoin = 'round'
for (var i = 0; i < allPoints.length; i++) {
canvas2dContext.beginPath()
if (allPoints[i].connect && allPoints[i - 1]) {
canvas2dContext.moveTo(allPoints[i - 1].x, allPoints[i - 1].y)
} else {
canvas2dContext.moveTo(allPoints[i].x - 1, allPoints[i].y)
}
canvas2dContext.lineTo(allPoints[i].x, allPoints[i].y)
canvas2dContext.closePath()
canvas2dContext.strokeStyle = allPoints[i].color
canvas2dContext.lineWidth = allPoints[i].size
canvas2dContext.stroke()
}
blendmapImage.src = blendCanvas.toDataURL()
}
We’re done with our 2d painting canvas and we can now move onto our WebGL terrain. We start by grabbing our WebGL context.
Similar to our 2d context, our WebGL context is our handle into controlling all of the state that the underlying OpenGL implementation will use when drawing to our WebGL canvas.
var gl = webGLCanvas.getContext('webgl')
gl.clearColor(0.0, 0.0, 0.0, 1.0)
gl.viewport(0, 0, 512, 512)
We create three arrays to hold our vertex positions, our vertex uvs (texture coordinates) and our vertex indices (the other of vertices to draw).
var positions = []
var indices = []
var uvs = []
Here is where we create our terrain. We’re making a grid of 64x64
tiles. The bottom left corner of the grid has a [0, 0]
uv coordinate, and the top right corner of the grid has a [1, 1]
uv coordinate. That it to say that our blend map stretches across our terrain.
We’ll end up multiplying these coordinates by 6 when applying our texture atlas in order to tile our textures.
var distanceAway = -85
var tileNum = 0
var numRowsColumns = 64
for (var y = 0; y < numRowsColumns; y++) {
for (var x = 0; x < numRowsColumns; x++) {
positions = positions.concat([
x, y, distanceAway,
1 + x, y, distanceAway,
1 + x, 1 + y, distanceAway,
x, 1 + y, distanceAway
])
uvs = uvs.concat([
x / numRowsColumns, y / numRowsColumns,
(x + 1) / numRowsColumns, y / numRowsColumns,
(x + 1) / numRowsColumns, (y + 1) / numRowsColumns,
x / numRowsColumns, (y + 1) / numRowsColumns
])
indices = indices.concat([
0, 1, 2, 0, 2, 3
].map(function (num) {
return num + (4 * tileNum)
}))
tileNum++
}
}
Now we create our blend map texture and terrain texture buffers and add our blend map and terrain images to them.
var blendmapTexture = gl.createTexture()
var blendmapImage = new window.Image()
blendmapImage.src = blendCanvas.toDataURL()
blendmapImage.onload = function () {
handleLoadedTexture(blendmapTexture, blendmapImage)
}
var terrainTexture = gl.createTexture()
var terrainImage = new window.Image()
terrainImage.crossOrigin = 'anonymous'
terrainImage.onload = function () {
handleLoadedTexture(terrainTexture, terrainImage)
}
terrainImage.src = '/terrain.jpg'
var bothImagesLoaded = false
function handleLoadedTexture (texture, image) {
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image
)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.bindTexture(gl.TEXTURE_2D, null)
if (bothImagesLoaded) {
setupWebGLState()
}
bothImagesLoaded = true
}
Let’s create our vertex shader. It is responsible for positioning all of our vertices.
var vertexShader = `
attribute vec3 aVertexPosition;
attribute vec2 aTextureCoord;
varying vec2 vTextureCoord;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
void main (void) {
gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
vTextureCoord = aTextureCoord;
}
`
Our fragment shader is where the meat of our blend mapping lives. We sample our blend map based on where our fragment is. We then get the color of at that point.
We calculate how much black, red, green and blue are in the color the we sampled from our blend map. These four weightings are then used to determine how much of our stone, lava, moss and water textures get added into our final color.
When sampling from our stone, lava, moss and water textures, we restrict our texture coordinates the the section of our texture atlas that has the relevant texture, and then multiply these texture coordinates by 6. This makes our texture tile six times.
This would mean that if you paint the entire blend map with one color, say, black, you’ll see the stone texture repeated 6 times.
var fragmentShader = `
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D uBlendmapSampler;
uniform sampler2D uTerrainSampler;
void main (void) {
vec4 blendColor =
texture2D(uBlendmapSampler, vec2(vTextureCoord.s, vTextureCoord.t));
float numRepeats = 6.0;
vec4 rockColor = texture2D(uTerrainSampler,
vec2(
mod(vTextureCoord.s * 0.5 * numRepeats, 0.5) + 0.5,
mod(vTextureCoord.t * 0.5 * numRepeats, 0.5)
));
vec4 lavaColor = texture2D(uTerrainSampler,
vec2(
mod(vTextureCoord.s * 0.5 * numRepeats, 0.5) + 0.5,
mod(vTextureCoord.t * 0.5 * numRepeats, 0.5) - 0.5
));
vec4 mossColor = texture2D(uTerrainSampler,
vec2(
mod(vTextureCoord.s * 0.5 * numRepeats, 0.5),
mod(vTextureCoord.t * 0.5 * numRepeats, 0.5) - 0.5
));
vec4 waterColor = texture2D(uTerrainSampler,
vec2(
mod(vTextureCoord.s * 0.5 * numRepeats, 0.5),
mod(vTextureCoord.t * 0.5 * numRepeats, 0.5)
));
float blackWeight = 1.0 - blendColor.x - blendColor.y - blendColor.z;
gl_FragColor = rockColor * blackWeight +
lavaColor * blendColor.x +
mossColor * blendColor.y +
waterColor * blendColor.z;
}
`
Now that we have our vertex and fragment shaders we compile them into a shader program.
var vert = gl.createShader(gl.VERTEX_SHADER)
var frag = gl.createShader(gl.FRAGMENT_SHADER)
gl.shaderSource(vert, vertexShader)
gl.compileShader(vert)
gl.shaderSource(frag, fragmentShader)
gl.compileShader(frag)
var shaderProgram = gl.createProgram()
gl.attachShader(shaderProgram, vert)
gl.attachShader(shaderProgram, frag)
gl.linkProgram(shaderProgram)
gl.useProgram(shaderProgram)
Our WebGL context is a state machine. In order to draw to the page, we must set up all of the relevant state that we need before we can start drawing. Here we push all of the data that we need to draw our terrain onto the GPU.
function setupWebGLState () {
var vertexPositionBuffer = gl.createBuffer()
var vertexIndexBuffer = gl.createBuffer()
var vertexUVsBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, vertexPositionBuffer)
gl.bufferData(
gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW
)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, vertexIndexBuffer)
gl.bufferData(
gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW
)
gl.bindBuffer(gl.ARRAY_BUFFER, vertexUVsBuffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(uvs), gl.STATIC_DRAW)
var vertexPositionAttribute = gl.getAttribLocation(
shaderProgram, 'aVertexPosition'
)
gl.bindBuffer(gl.ARRAY_BUFFER, vertexPositionBuffer)
gl.enableVertexAttribArray(vertexPositionAttribute)
gl.vertexAttribPointer(
vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0
)
var textureCoordAttribute = gl.getAttribLocation(
shaderProgram, 'aTextureCoord'
)
gl.bindBuffer(gl.ARRAY_BUFFER, vertexUVsBuffer)
gl.enableVertexAttribArray(textureCoordAttribute)
gl.vertexAttribPointer(
textureCoordAttribute, 2, gl.FLOAT, false, 0, 0
)
var terrainSamplerUniform = gl.getUniformLocation(
shaderProgram, 'uTerrainSampler'
)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, terrainTexture)
gl.uniform1i(terrainSamplerUniform, 1)
var blendmapSamplerUniform = gl.getUniformLocation(
shaderProgram, 'uBlendmapSampler'
)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, blendmapTexture)
gl.uniform1i(blendmapSamplerUniform, 0)
var mvMatrixUniform = gl.getUniformLocation(shaderProgram, 'uMVMatrix')
gl.uniformMatrix4fv(
mvMatrixUniform,
false,
[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -32, -32, 0, 1]
)
var pMatrixUniform = gl.getUniformLocation(shaderProgram, 'uPMatrix')
gl.uniformMatrix4fv(pMatrixUniform,
false,
require('gl-mat4/perspective')(
[], Math.PI / 4, 400 / 400, 0.1, 1000
)
)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, vertexIndexBuffer)
}
Lastly we start up a request animation frame loop that redraws our painting canvas and our WebGL terrain canvas every animation frame.
function drawBothCanvases () {
redrawPaintCanvas()
if (bothImagesLoaded) {
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawElements(
gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0
)
}
window.requestAnimationFrame(drawBothCanvases)
}
drawBothCanvases()
And that’s all of the code! In your command line run your development server using ./node_modules/budo/bin/cmd.js --open --live --host=127.0.0.1 tutorial.js
. You should now be viewing your work in your browser.
Well done!
You made it through. Great work. What would you like to learn next? Let me know on Twitter!
Til’ next time,
- CFN