├── 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 | }
--------------------------------------------------------------------------------