Recommendation: Use the NSDT scene editor to quickly build a 3D application scene
Modeling and 3D Terrain
Most 3D objects are created using modeling tools, and for good reason. Creating complex objects like airplanes or even buildings is hard to do in code. Modeling tools almost always make sense, but there are exceptions! One of them may be that the case is like the rolling hills of the flying arcade island. We ended up using a technique we found simpler, and possibly even more intuitive: a heightmap.
A heightmap is a method that uses a regular two-dimensional image to describe a surface like an island or other terrain. This is a very common way to use elevation data, not only in games but also in geographic information systems (GIS) used by cartographers and geologists.
To help you get an idea of how it works, check out the heightmap in this interactive demo. Try plotting, then check out the resulting terrain.
The concept behind heightmaps is simple. In the image shown above, pure black is the "floor" and pure white is the highest peak. Grayscale colors in between indicate corresponding elevations. This gives us 256 altitudes, which is a lot of detail for our game. A real application might use the full color spectrum to store many more levels of detail (2564 = 4,294,967,296 levels of detail if the alpha channel is included).
Heightmaps have several advantages over traditional polygonal meshes:
1. The height map is much more compact. Only the most important data (elevation) is stored. It needs to be converted to a 3D object programmatically, but it's a classic deal: you save space now, pay for it later by computing. By storing the data as images, you get another space advantage: you can take advantage of standard image compression techniques and keep the data small (by comparison)!
Second, heightmaps are a convenient way to generate, visualize, and edit terrain. Very intuitive when you see one. It feels a bit like looking at a map. This proved to be especially useful for flying arcade machines. We designed and edited our island in Photoshop! This makes it very simple to make small adjustments as needed. For example, when we want to make sure the runway is completely flat, we just make sure to paint over that area with a single color.
You can see the flying arcade below the height map. See if you can spot us for the runways and villages.
Heightmap of the flying arcade island. It was created in Photoshop and it is based on the "Big Island" in the famous Pacific island chain. Any guesses?
The texture to map onto the resulting 3D mesh after decoding the heightmap. Read more below.
decode heightmap
We built flying arcades with Babylon.js, and Babylon gave us a nice easy path from heightmaps to 3D. Babylon provides an API to generate mesh geometry from heightmap images:
1 |
var ground = BABYLON.Mesh.CreateGroundFromHeightMap( |
2 |
|
3 |
'your-mesh-name', |
4 |
|
5 |
'/path/to/heightmap.png', |
6 |
|
7 |
100, // width of the ground mesh (x axis) |
8 |
|
9 |
100, // depth of the ground mesh (z axis) |
10 |
|
11 |
40, // number of subdivisions |
12 |
|
13 |
0, // min height |
14 |
|
15 |
50, // max height |
16 |
|
17 |
scene, |
18 |
|
19 |
false, // updateable? |
20 |
|
21 |
null // callback when mesh is ready |
22 |
|
23 |
); |
The amount of detail is determined by the property of the subdivision. It should be noted that the parameter refers to the number of subdivisions on both sides of the heightmap image, not the total number of cells. So increasing this number slightly can have a big impact on the total number of vertices in the mesh.
- 20 segments = 400 cells
- 50 subdivisions = 2,500 cells
- 100 segments = 10,000 cells
- 500 segments = 250,000 cells
- 1,000 segments = 1,000,000 cells
We'll see how to texture the ground in the next section, but it's useful to look at the wireframe when trying to create with heightmaps. Here's simple code to apply a wireframe texture, so it's easy to see how the heightmap data translates to the vertices of the mesh:
1 |
// simple wireframe material |
2 |
|
3 |
var material = new BABYLON.StandardMaterial('ground-material', scene); |
4 |
|
5 |
material.wireframe = true; |
6 |
|
7 |
ground.material = material; |
Create texture details
Once we have a model, mapping a texture is relatively simple. For the flying arcade, we simply created a very large image that matched the islands in our heightmap. The imagery gets stretched over the contours of the terrain, so textures and heightmaps remain associative. It's really easy to visualize, and again, all production work is done in Photoshop.
The original texture image was created at 4096x4096. That's pretty big! (We ended up reducing the size to 2048x2048 in order to keep the downloads reasonable, but all using full size images for development. This is from the original texture.
These rectangles represent the buildings of the towns on the island. We quickly noticed that we could use terrain and other 3D models. Even with our huge island textures, the difference is distractingly obvious!
To solve this, we "blend" additional detail into the terrain texture in the form of random noise. You can see the before and after below. Notice how the extra noise enhances the apparent terrain detail.
We created a custom shader to add noise. Shaders give you a rendering of a WebGL 3D scene, and this is how shaders are useful.
WebGL shaders consist of two main parts: vertex and fragment shaders. The main goal of a vertex shader is to map a vertex to a position in the rendered frame. A fragment (or pixel) shader controls the resulting color of a pixel.
Shaders are written in a high-level language called GLSL (Graphics Library Shader Language), which is similar to C. This code executes on the GPU. For an in-depth look at how shaders work, see our tutorial on how to create your own custom shaders for Babylon.js here, or see this beginner's guide to graphics shader coding.
vertex shader
We won't change our texture mapping to the ground mesh, so our vertex shader is very simple. It just calculates the standard mapping and assigns the target location.
1 |
precision mediump float; |
2 |
|
3 |
|
4 |
|
5 |
// Attributes |
6 |
|
7 |
attribute vec3 position; |
8 |
|
9 |
attribute vec3 normal; |
10 |
|
11 |
attribute vec2 uv; |
12 |
|
13 |
|
14 |
|
15 |
// Uniforms |
16 |
|
17 |
uniform mat4 worldViewProjection; |
18 |
|
19 |
|
20 |
|
21 |
// Varying |
22 |
|
23 |
varying vec4 vPosition; |
24 |
|
25 |
varying vec3 vNormal; |
26 |
|
27 |
varying vec2 vUV; |
28 |
|
29 |
|
30 |
|
31 |
void main() { |
32 |
|
33 |
|
34 |
|
35 |
vec4 p = vec4( position, 1.0 ); |
36 |
|
37 |
vPosition = p; |
38 |
|
39 |
vNormal = normal; |
40 |
|
41 |
vUV = uv; |
42 |
|
43 |
gl_Position = worldViewProjection * p; |
44 |
|
45 |
} |
Fragment shader
Our fragment shader is a bit more complicated. It combines two different images: a base image and a blended image. The base image is mapped to the entire ground grid. In Flying Arcade, this is a color image of the island. Blended images are small noise images used at close range to give the ground some texture and detail. The shader combines the values in each image to create the span islands.
飞行的最后一课 街机发生在有雾的日子,所以我们的像素着色器的另一个任务是 调整颜色以模拟雾。调整基于顶点的距离 来自相机,远处像素被“遮挡”得更厉害 在雾中。您将在函数中看到此距离计算 在主着色器代码上方。calcFogFactor
1 |
#ifdef GL_ES |
2 |
|
3 |
precision highp float; |
4 |
|
5 |
#endif |
6 |
|
7 |
|
8 |
|
9 |
uniform mat4 worldView; |
10 |
|
11 |
varying vec4 vPosition; |
12 |
|
13 |
varying vec3 vNormal; |
14 |
|
15 |
varying vec2 vUV; |
16 |
|
17 |
|
18 |
|
19 |
// Refs |
20 |
|
21 |
uniform sampler2D baseSampler; |
22 |
|
23 |
uniform sampler2D blendSampler; |
24 |
|
25 |
uniform float blendScaleU; |
26 |
|
27 |
uniform float blendScaleV; |
28 |
|
29 |
|
30 |
|
31 |
#define FOGMODE_NONE 0. |
32 |
|
33 |
#define FOGMODE_EXP 1. |
34 |
|
35 |
#define FOGMODE_EXP2 2. |
36 |
|
37 |
#define FOGMODE_LINEAR 3. |
38 |
|
39 |
#define E 2.71828 |
40 |
|
41 |
|
42 |
|
43 |
uniform vec4 vFogInfos; |
44 |
|
45 |
uniform vec3 vFogColor; |
46 |
|
47 |
|
48 |
|
49 |
float calcFogFactor() { |
50 |
|
51 |
|
52 |
|
53 |
// gets distance from camera to vertex |
54 |
|
55 |
float fogDistance = gl_FragCoord.z / gl_FragCoord.w; |
56 |
|
57 |
|
58 |
|
59 |
float fogCoeff = 1.0; |
60 |
|
61 |
float fogStart = vFogInfos.y; |
62 |
|
63 |
float fogEnd = vFogInfos.z; |
64 |
|
65 |
float fogDensity = vFogInfos.w; |
66 |
|
67 |
|
68 |
|
69 |
if (FOGMODE_LINEAR == vFogInfos.x) { |
70 |
|
71 |
fogCoeff = (fogEnd - fogDistance) / (fogEnd - fogStart); |
72 |
|
73 |
} |
74 |
|
75 |
else if (FOGMODE_EXP == vFogInfos.x) { |
76 |
|
77 |
fogCoeff = 1.0 / pow(E, fogDistance * fogDensity); |
78 |
|
79 |
} |
80 |
|
81 |
else if (FOGMODE_EXP2 == vFogInfos.x) { |
82 |
|
83 |
fogCoeff = 1.0 / pow(E, fogDistance * fogDistance * fogDensity * fogDensity); |
84 |
|
85 |
} |
86 |
|
87 |
|
88 |
|
89 |
return clamp(fogCoeff, 0.0, 1.0); |
90 |
|
91 |
} |
92 |
|
93 |
|
94 |
|
95 |
void main(void) { |
96 |
|
97 |
|
98 |
|
99 |
vec4 baseColor = texture2D(baseSampler, vUV); |
100 |
|
101 |
|
102 |
|
103 |
vec2 blendUV = vec2(vUV.x * blendScaleU, vUV.y * blendScaleV); |
104 |
|
105 |
vec4 blendColor = texture2D(blendSampler, blendUV); |
106 |
|
107 |
|
108 |
|
109 |
// multiply type blending mode |
110 |
|
111 |
vec4 color = baseColor * blendColor; |
112 |
|
113 |
|
114 |
|
115 |
// factor in fog color |
116 |
|
117 |
float fog = calcFogFactor(); |
118 |
|
119 |
color.rgb = fog * color.rgb + (1.0 - fog) * vFogColor; |
120 |
|
121 |
|
122 |
|
123 |
gl_FragColor = color; |
124 |
|
125 |
} |
我们定制的最后一件作品 Blend shader 是 Babylon 使用的 JavaScript 代码。主要目的 此代码用于准备传递给顶点和像素着色器的参数。
1 |
function BlendMaterial(name, scene, options) { |
2 |
|
3 |
this.name = name; |
4 |
|
5 |
this.id = name; |
6 |
|
7 |
|
8 |
|
9 |
this.options = options; |
10 |
|
11 |
this.blendScaleU = options.blendScaleU || 1; |
12 |
|
13 |
this.blendScaleV = options.blendScaleV || 1; |
14 |
|
15 |
|
16 |
|
17 |
this._scene = scene; |
18 |
|
19 |
scene.materials.push(this); |
20 |
|
21 |
|
22 |
|
23 |
var assets = options.assetManager; |
24 |
|
25 |
var textureTask = assets.addTextureTask('blend-material-base-task', options.baseImage); |
26 |
|
27 |
textureTask.onSuccess = _.bind(function(task) { |
28 |
|
29 |
|
30 |
|
31 |
this.baseTexture = task.texture; |
32 |
|
33 |
this.baseTexture.uScale = 1; |
34 |
|
35 |
this.baseTexture.vScale = 1; |
36 |
|
37 |
|
38 |
|
39 |
if (options.baseHasAlpha) { |
40 |
|
41 |
this.baseTexture.hasAlpha = true; |
42 |
|
43 |
} |
44 |
|
45 |
|
46 |
|
47 |
}, this); |
48 |
|
49 |
|
50 |
|
51 |
textureTask = assets.addTextureTask('blend-material-blend-task', options.blendImage); |
52 |
|
53 |
textureTask.onSuccess = _.bind(function(task) { |
54 |
|
55 |
this.blendTexture = task.texture; |
56 |
|
57 |
this.blendTexture.wrapU = BABYLON.Texture.MIRROR_ADDRESSMODE; |
58 |
|
59 |
this.blendTexture.wrapV = BABYLON.Texture.MIRROR_ADDRESSMODE; |
60 |
|
61 |
}, this); |
62 |
|
63 |
|
64 |
|
65 |
} |
66 |
|
67 |
|
68 |
|
69 |
BlendMaterial.prototype = Object.create(BABYLON.Material.prototype); |
70 |
|
71 |
|
72 |
|
73 |
BlendMaterial.prototype.needAlphaBlending = function () { |
74 |
|
75 |
return (this.options.baseHasAlpha === true); |
76 |
|
77 |
}; |
78 |
|
79 |
|
80 |
|
81 |
BlendMaterial.prototype.needAlphaTesting = function () { |
82 |
|
83 |
return false; |
84 |
|
85 |
}; |
86 |
|
87 |
|
88 |
|
89 |
BlendMaterial.prototype.isReady = function (mesh) { |
90 |
|
91 |
var engine = this._scene.getEngine(); |
92 |
|
93 |
|
94 |
|
95 |
// make sure textures are ready |
96 |
|
97 |
if (!this.baseTexture || !this.blendTexture) { |
98 |
|
99 |
return false; |
100 |
|
101 |
} |
102 |
|
103 |
|
104 |
|
105 |
if (!this._effect) { |
106 |
|
107 |
this._effect = engine.createEffect( |
108 |
|
109 |
|
110 |
|
111 |
// shader name |
112 |
|
113 |
"blend", |
114 |
|
115 |
|
116 |
|
117 |
// attributes describing topology of vertices |
118 |
|
119 |
[ "position", "normal", "uv" ], |
120 |
|
121 |
|
122 |
|
123 |
// uniforms (external variables) defined by the shaders |
124 |
|
125 |
[ "worldViewProjection", "world", "blendScaleU", "blendScaleV", "vFogInfos", "vFogColor" ], |
126 |
|
127 |
|
128 |
|
129 |
// samplers (objects used to read textures) |
130 |
|
131 |
[ "baseSampler", "blendSampler" ], |
132 |
|
133 |
|
134 |
|
135 |
// optional define string |
136 |
|
137 |
""); |
138 |
|
139 |
} |
140 |
|
141 |
|
142 |
|
143 |
if (!this._effect.isReady()) { |
144 |
|
145 |
return false; |
146 |
|
147 |
} |
148 |
|
149 |
|
150 |
|
151 |
return true; |
152 |
|
153 |
}; |
154 |
|
155 |
|
156 |
|
157 |
BlendMaterial.prototype.bind = function (world, mesh) { |
158 |
|
159 |
|
160 |
|
161 |
var scene = this._scene; |
162 |
|
163 |
this._effect.setFloat4("vFogInfos", scene.fogMode, scene.fogStart, scene.fogEnd, scene.fogDensity); |
164 |
|
165 |
this._effect.setColor3("vFogColor", scene.fogColor); |
166 |
|
167 |
|
168 |
|
169 |
this._effect.setMatrix("world", world); |
170 |
|
171 |
this._effect.setMatrix("worldViewProjection", world.multiply(scene.getTransformMatrix())); |
172 |
|
173 |
|
174 |
|
175 |
// Textures |
176 |
|
177 |
this._effect.setTexture("baseSampler", this.baseTexture); |
178 |
|
179 |
this._effect.setTexture("blendSampler", this.blendTexture); |
180 |
|
181 |
|
182 |
|
183 |
this._effect.setFloat("blendScaleU", this.blendScaleU); |
184 |
|
185 |
this._effect.setFloat("blendScaleV", this.blendScaleV); |
186 |
|
187 |
}; |
188 |
|
189 |
|
190 |
|
191 |
BlendMaterial.prototype.dispose = function () { |
192 |
|
193 |
|
194 |
|
195 |
if (this.baseTexture) { |
196 |
|
197 |
this.baseTexture.dispose(); |
198 |
|
199 |
} |
200 |
|
201 |
|
202 |
|
203 |
if (this.blendTexture) { |
204 |
|
205 |
this.blendTexture.dispose(); |
206 |
|
207 |
} |
208 |
|
209 |
|
210 |
|
211 |
this.baseDispose(); |
212 |
|
213 |
}; |
Babylon.js makes it easy to create custom shader-based materials. Our mix of materials was relatively simple, but it did make a big difference to the look of the island as the plane flew low to the ground. Shaders bring the power of the GPU to the browser, extending the scene of the types of creative effects that can be applied to 3D. In our case, it's the dragon roll call!
Original Link: Create Realistic Terrain for HTML5 Games Using WebGL (mvrlink.com)