├── css └── index.css ├── images ├── favicon.ico ├── terrain.png └── texture.png ├── index.html ├── README.md ├── LICENSE └── js ├── index.js ├── terrain.js └── controls.js /css/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-size: 0; 3 | margin: 0; 4 | padding: 0; 5 | } -------------------------------------------------------------------------------- /images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wybiral/terrain/HEAD/images/favicon.ico -------------------------------------------------------------------------------- /images/terrain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wybiral/terrain/HEAD/images/terrain.png -------------------------------------------------------------------------------- /images/texture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wybiral/terrain/HEAD/images/texture.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Terrain 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # terrain 2 | Creating a 3d terrain in WebGL, with each step being made as a Github release. 3 | 4 | - Step 1: [Setup three.js](https://github.com/wybiral/terrain/releases/tag/0.1) 5 | - Step 2: [Create terrain](https://github.com/wybiral/terrain/releases/tag/0.2) 6 | - Step 3: [Load heightmap](https://github.com/wybiral/terrain/releases/tag/0.3) 7 | - Step 4: [Texture terrain](https://github.com/wybiral/terrain/releases/tag/0.4) 8 | - Step 5: [Add "collision"](https://github.com/wybiral/terrain/releases/tag/0.5) 9 | - Step 6: [First person controls](https://github.com/wybiral/terrain/releases/tag/0.6) 10 | - Step 7: [Add fog](https://github.com/wybiral/terrain/releases/tag/0.7) 11 | - Step 8: [Add jumping & running](https://github.com/wybiral/terrain/releases/tag/0.8) 12 | 13 | **A live demo of the most recent step is available [here](https://wybiral.github.io/terrain/).** 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /js/index.js: -------------------------------------------------------------------------------- 1 | class App { 2 | constructor() { 3 | // Grab window properties 4 | let width = window.innerWidth; 5 | let height = window.innerHeight; 6 | let pixelRatio = window.devicePixelRatio; 7 | let aspect = width / height; 8 | // Setup three.js 9 | this.camera = new THREE.PerspectiveCamera(45, aspect, 0.5, 1500); 10 | this.scene = new THREE.Scene(); 11 | this.renderer = new THREE.WebGLRenderer({antialias: false}); 12 | this.renderer.setPixelRatio(pixelRatio); 13 | this.renderer.setSize(width, height); 14 | document.body.appendChild(this.renderer.domElement); 15 | // Catch resize events 16 | window.onresize = (evt) => { 17 | this.resize(window.innerWidth, window.innerHeight); 18 | }; 19 | } 20 | 21 | /* Resize viewport */ 22 | resize(width, height) { 23 | this.camera.aspect = width / height; 24 | this.camera.updateProjectionMatrix(); 25 | this.renderer.setSize(width, height); 26 | } 27 | 28 | /* Start the main loop */ 29 | start() { 30 | this.loop(); 31 | } 32 | 33 | loop() { 34 | requestAnimationFrame(() => this.loop()); 35 | let time = new Date().getTime() / 1000; 36 | let delta = 0.0; 37 | if (typeof this.lastUpdate !== 'undefined') { 38 | delta = time - this.lastUpdate; 39 | } 40 | this.update(delta); 41 | this.lastUpdate = time; 42 | this.render(); 43 | } 44 | 45 | update(delta) { 46 | // Dispatch update event for listeners 47 | window.dispatchEvent(new CustomEvent('app-update', { 48 | detail: { 49 | delta: delta 50 | } 51 | })); 52 | } 53 | 54 | render() { 55 | let scene = this.scene; 56 | let camera = this.camera; 57 | let renderer = this.renderer; 58 | renderer.render(scene, camera); 59 | } 60 | } 61 | 62 | 63 | window.onload = function() { 64 | let app = new App(); 65 | 66 | // Let there be light 67 | let light = new THREE.DirectionalLight(0xe0e0e0); 68 | light.position.set(1, 1, 0).normalize(); 69 | app.scene.add(light); 70 | 71 | let fog = new THREE.FogExp2(0x998877, 0.005); 72 | app.scene.fog = fog; 73 | app.renderer.setClearColor(fog.color, 1); 74 | 75 | let controls = new FirstPersonControls(app); 76 | 77 | Terrain.fromImage('images/terrain.png').then(function(terrain) { 78 | 79 | app.terrain = terrain; 80 | 81 | var loader = new THREE.TextureLoader(); 82 | 83 | var texture = loader.load('images/texture.png'); 84 | 85 | texture.wrapS = texture.wrapT = THREE.RepeatWrapping; 86 | texture.repeat.set(terrain.width / 100, terrain.height / 100); 87 | 88 | app.scene.add(terrain.build(texture)); 89 | 90 | // Scale terrain peaks 91 | terrain.mesh.scale.y = 50.0; 92 | 93 | // Start in middle of terrain 94 | controls.position.x = terrain.width / 2; 95 | controls.position.z = terrain.height / 2; 96 | 97 | window.addEventListener('app-update', function(evt) { 98 | controls.update(evt.detail.delta); 99 | }); 100 | 101 | app.start(); 102 | }).catch(function(e) { 103 | throw e; 104 | }); 105 | }; 106 | -------------------------------------------------------------------------------- /js/terrain.js: -------------------------------------------------------------------------------- 1 | class Terrain { 2 | constructor(width, height) { 3 | this.width = width; 4 | this.height = height; 5 | this.geometry = new THREE.PlaneBufferGeometry( 6 | width, 7 | height, 8 | width - 1, 9 | height - 1 10 | ); 11 | let rotation = new THREE.Matrix4().makeRotationX(-Math.PI / 2); 12 | this.geometry.applyMatrix(rotation); 13 | this.array = this.geometry.attributes.position.array; 14 | this.mesh = null; 15 | } 16 | 17 | static fromImage(src) { 18 | return new Promise(function(resolve, reject) { 19 | let img = new Image(); 20 | img.onload = function() { 21 | let width = img.width; 22 | let height = img.height; 23 | let canvas = document.createElement('canvas'); 24 | canvas.width = width; 25 | canvas.height = height; 26 | let ctx = canvas.getContext('2d'); 27 | ctx.drawImage(img, 0, 0); 28 | let pixels = ctx.getImageData(0, 0, width, height).data; 29 | let terrain = new Terrain(width, height); 30 | for (let i = 0; i < width * height; i++) { 31 | terrain.array[i * 3 + 1] = pixels[i * 4] / 256; 32 | } 33 | resolve(terrain); 34 | }; 35 | img.onabort = reject; 36 | img.onerror = reject; 37 | img.src = src; 38 | }); 39 | } 40 | 41 | build(texture) { 42 | this.geometry.computeBoundingSphere(); 43 | this.geometry.computeVertexNormals(); 44 | this.material = new THREE.MeshLambertMaterial({ 45 | map: texture 46 | }); 47 | this.mesh = new THREE.Mesh(this.geometry, this.material); 48 | this.mesh.position.x = this.width / 2; 49 | this.mesh.position.z = this.height / 2; 50 | return this.mesh; 51 | } 52 | 53 | getHeightAt(x, z) { 54 | /* 55 | Get height (y value) of terrain at x, z 56 | 57 | Find which "cell" x, z is in by rounding them both down since each 58 | height sample is evenly spaced at integer locations. 59 | 60 | Once we have a cell (a location between four neighboring height samples) 61 | we can figure out the offset by subtracting the rounded values from the 62 | real values. This effectively gives us the amount "into" the cell we are 63 | for both x and z. 64 | 65 | rx = x - floor(z) 66 | rz = z - floor(z) 67 | 68 | a----b 69 | | | 70 | |p | 71 | d----c 72 | 73 | Using these fractional values, if our position is marked by the p, the 74 | height can be found by first interpolating between (a->b) using rx, then 75 | interpolating between (c->d) using rx, and then between the result of 76 | both of those using rz. 77 | 78 | y = (a * (1 - rx) + b * rx) * (1 - rz) + (c * rx + d * (1 - rx)) * rz 79 | */ 80 | let width = this.width; 81 | let height = this.height; 82 | if (x < 0 || x >= width || z < 0 || z >= height) { 83 | throw new Error('point outside of terrain boundary'); 84 | } 85 | // Get integer floor of x, z 86 | let ix = Math.floor(x); 87 | let iz = Math.floor(z); 88 | // Get real (fractional) component of x, z 89 | // This is the amount of each into the cell 90 | let rx = x - ix; 91 | let rz = z - iz; 92 | // Edges of cell 93 | let a = this.array[(iz * width + ix) * 3 + 1]; 94 | let b = this.array[(iz * width + (ix + 1)) * 3 + 1]; 95 | let c = this.array[((iz + 1) * width + (ix + 1)) * 3 + 1]; 96 | let d = this.array[((iz + 1) * width + ix) * 3 + 1]; 97 | // Interpolate top edge (left and right) 98 | let e = (a * (1 - rx) + b * rx); 99 | // Interpolate bottom edge (left and right) 100 | let f = (c * rx + d * (1 - rx)); 101 | // Interpolate between top and bottom 102 | let y = (e * (1 - rz) + f * rz); 103 | return y; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /js/controls.js: -------------------------------------------------------------------------------- 1 | // Key constants 2 | const K_FORWARD = 'W'.charCodeAt(0); 3 | const K_BACKWARD = 'S'.charCodeAt(0); 4 | const K_STRAFE_LEFT = 'A'.charCodeAt(0); 5 | const K_STRAFE_RIGHT = 'D'.charCodeAt(0); 6 | 7 | const K_UP = 38; 8 | const K_DOWN = 40; 9 | const K_LEFT = 37; 10 | const K_RIGHT = 39; 11 | const K_SPACE = 32; 12 | const K_SHIFT = 16; 13 | 14 | class FirstPersonControls { 15 | 16 | constructor(app) { 17 | this.app = app; 18 | this.onGround = true; 19 | this.position = new THREE.Vector3(0, 0, 0); 20 | this.rotation = new THREE.Vector3(0, 0, 0); 21 | this.velocity = new THREE.Vector3(0, 0, 0); 22 | this.keystate = {}; 23 | this.bindEvents(); 24 | } 25 | 26 | bindEvents() { 27 | // You can only request pointer lock from a user triggered event 28 | let el = document.querySelector('canvas'); 29 | document.body.addEventListener('mousedown', function() { 30 | if (!el.requestPointerLock) { 31 | el.requestPointerLock = el.mozRequestPointerLock; 32 | } 33 | el.requestPointerLock() 34 | }, false); 35 | 36 | // Update rotation from mouse motion 37 | document.body.addEventListener('mousemove', (evt) => { 38 | let sensitivity = 0.002 39 | this.rotation.x -= evt.movementY * sensitivity; 40 | this.rotation.y -= evt.movementX * sensitivity; 41 | // Constrain viewing angle 42 | if (this.rotation.x < -Math.PI / 2) { 43 | this.rotation.x = -Math.PI / 2; 44 | } 45 | if (this.rotation.x > Math.PI / 2) { 46 | this.rotation.x = Math.PI / 2; 47 | } 48 | }, false); 49 | 50 | // Update keystate from down/up events 51 | window.addEventListener('keydown', (evt) => { 52 | this.keystate[evt.which] = true; 53 | }, false); 54 | window.addEventListener('keyup', (evt) => { 55 | this.keystate[evt.which] = false; 56 | }, false); 57 | 58 | } 59 | 60 | update(delta) { 61 | let speed = delta * 2.0; 62 | let motion = new THREE.Vector3(0, 0, 0); 63 | if (this.keystate[K_SHIFT]) { 64 | // Holding shift increases speed 65 | speed *= 1.5; 66 | } 67 | if (this.keystate[K_FORWARD]) { 68 | motion.z -= speed; 69 | } 70 | if (this.keystate[K_BACKWARD]) { 71 | motion.z += speed; 72 | } 73 | if (this.keystate[K_STRAFE_LEFT]) { 74 | motion.x -= speed; 75 | } 76 | if (this.keystate[K_STRAFE_RIGHT]) { 77 | motion.x += speed; 78 | } 79 | if (this.keystate[K_UP]) { 80 | this.rotation.x += speed * 0.5; 81 | } 82 | if (this.keystate[K_DOWN]) { 83 | this.rotation.x -= speed * 0.5; 84 | } 85 | if (this.keystate[K_LEFT]) { 86 | this.rotation.y += speed * 0.5; 87 | } 88 | if (this.keystate[K_RIGHT]) { 89 | this.rotation.y -= speed * 0.5; 90 | } 91 | if (this.keystate[K_SPACE] && this.onGround) { 92 | motion.y = delta * 60; 93 | this.onGround = false; 94 | } 95 | let rotation = new THREE.Matrix4().makeRotationY(this.rotation.y); 96 | motion.applyMatrix4(rotation); 97 | this.velocity.add(motion); 98 | let nextPosition = this.position.clone(); 99 | nextPosition.add(this.velocity); 100 | if (this.onGround) { 101 | this.velocity.x *= 0.95; 102 | this.velocity.z *= 0.95; 103 | } else { 104 | // Less friction in air 105 | this.velocity.x *= 0.97; 106 | this.velocity.z *= 0.97; 107 | // Gravity 108 | this.velocity.y -= delta * 3; 109 | } 110 | let x = nextPosition.x; 111 | let y = nextPosition.y; 112 | let z = nextPosition.z; 113 | let terrain = this.app.terrain; 114 | // Constrain position to terrain bounds 115 | if (x < 0 || x >= terrain.width - 1) { 116 | x = this.position.x; 117 | } 118 | if (z < 0 || z >= terrain.height - 1) { 119 | z = this.position.z; 120 | } 121 | this.position.x = x; 122 | this.position.z = z; 123 | let scale = terrain.mesh.scale.y; 124 | let ground = 7 + terrain.getHeightAt(x, z) * scale; 125 | if (this.onGround || y <= ground) { 126 | y = ground; 127 | this.velocity.y = 0; 128 | this.onGround = true; 129 | } 130 | this.position.y = y; 131 | // Apply current transformations to camera 132 | let camera = this.app.camera; 133 | camera.position.copy(this.position); 134 | camera.rotation.set(0, 0, 0); 135 | camera.rotateY(this.rotation.y); 136 | camera.rotateX(this.rotation.x); 137 | } 138 | 139 | } --------------------------------------------------------------------------------