本篇教程转载于:https://www.cntofu.com/book/71/chapter-17-outdoor-scene.md
制作3D游戏时,可以使用建模工具来雕刻大型的游戏地图。这样做能够获得非常精细的地图模型,而且创作自由度也非常高,缺陷是渲染速度较慢。
在实际开发中,经常使用高度图(Height map 来创建能够快速渲染的地形。再结合着色器实现“抛雪球算法(Texture Splatting)”算法,能够使用少量纹理贴图,绘制出效果不错的地形。
3D游戏中的地形生成有许多方法,其中应用最广泛的就是高度图。在地理学科中,一幅地图的不同海拨用不同的颜色表示,即等高线表示法。高度图基于同样的原理,只不过这里的高度值表现为图像中的亮度值。
下面两幅图分别为等高线图与高度图:
在高度图中,图像的每个象素存储了对应的高度值,取值范围为0~255。
根据图像每个象素的(x, y)坐标,以及高度值height,就可以获得3D空间中的顶点。把这些顶点连接成网格,就可以生成3D模型。
Vector3f position = new Vector3f(x, height, -y);
下面3个截图就是用过一副高度图生成的3D地形。
地形编辑器是用于制作高度图的工具。jMonkeyEngine SDK、Unity3D之类的3D游戏引擎有内置的地形编辑器,但是我更推荐专业工具。诸如:
本文使用的高度图均由 EarthSculptor 生成。下载 EarthSculptor 后,你可以在Maps目录、Textures目录中找到一些默认的资源图片。它的界面和功能都不复杂,很容易上手。免费版只能生成257*257分辨率的高度图,但也足够新手使用了。
安装首次启动后界面如下:
根据提示,按F1可以隐藏打开按键功能说明(Key Controls), 按大小写切换键Caps Lock 可以显示隐藏除主UI界面的那几个小UI界面,下面我把它调成如下:
具体使用这里就不介绍了,我们导出其高度图:
jME3内置了对高度图的支持,并提供了很多优化功能。想使用这些功能,需要在项目中添加对 jme3-terrain 模块的依赖。
maven
<dependency>
<groupId>org.jmonkeyengine</groupId>
<artifactId>jme3-terrain</artifactId>
<version>${jme3_v}</version>
</dependency>
Gradle
dependencies {
// 添加jme3地形模块的依赖库
compile 'org.jmonkeyengine:jme3-terrain:3.1.0-stable'
}
使用高度图生成3D地形时,图片的格式和分辨率是有限制的。
jme3-terrain 模块有很多功能,包括但不限于:
jME3官方教程中提供了很多使用Terrain的例子,诸如:
第一步:使用 AssetManager 加载上面导出的高度图。
// 加载高度图
Texture heightMapImage = assetManager.loadTexture("demo01.png");
第二步:根据图像生成高度数据。
ImageBasedHeightMap 的功能是解析图像数据,根据每个像素的灰度值来计算高度值。依次调用 load() 方法和 getHeightMap() 方法,可以得到一个 float[] 数组,其中存储了地图高度数据。
// 根据图像内容,生成高度数据
ImageBasedHeightMap heightMap = new ImageBasedHeightMap(heightMapImage.getImage(), 1f);
heightMap.load();
float[] heightData = heightMap.getHeightMap();
第三步:使用 TerrainQuad,根据高度数据生成3D地形。
// 根据高度图生成3D地形。
// 该地形被分解成边长65(64*64)的矩形区块,用于优化网格。
// 高度图的边长为 257,分辨率 256*256。
TerrainQuad terrain = new TerrainQuad("heightmap", 65, 257, heightData);
rootNode.attachChild(terrain);
TerrainQuad 内部使用四叉树算法来优化网格结构。它的内部由多个 TerrainPatch 组成,每个 TerrainPatch 代表网格中的一个区块。
在创建 TerrainQuad 时,需要设置4个参数,这些参数的含义如下:
注意,TerrainQuad 是 Spatial 的子类,需要添加到场景图中方可显示。
第四步: 设置材质,用于渲染地形。
TerrainQuad 是 Spatial 的子类,可以根据需要来给它设置材质,哪怕是 Unshaded.j3md都可以。
// 加载材质
Material material = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
material.getAdditionalRenderState().setWireframe(true);
terrain.setMaterial(material);
实际开发时,地形一般使用多重纹理渲染,这样起来比较美观。jme3-terrain 模块中包含了一些地形专用材质,诸如:
第五步:层次细节(LOD)优化。
TerrainLodControl 的作用是控制地形网格的层次细节(LOD)。创建 TerrainLodControl 时需要指定 Terrain 和 Camera 对象,因为它需要根据摄像机到地形的的距离来控制LOD。改变LOD的距离由 LodCalculator 来计算,默认的LOD计算器为 DistanceLodCalculator。
// 层次细节(LOD)优化
TerrainLodControl lodControl = new TerrainLodControl(terrain, cam);
// LOD计算器,一个参数代表区块大小,第二个参数代表距离系数。
// size = 65, multiplier = 2.7f, distance = 65 * 2.7f
lodControl.setLodCalculator(new DistanceLodCalculator(65, 2.7f));
terrain.addControl(lodControl);
完整代码:
import com.jme3.app.SimpleApplication;
import com.jme3.material.Material;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.terrain.geomipmap.TerrainLodControl;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.terrain.geomipmap.lodcalc.DistanceLodCalculator;
import com.jme3.terrain.heightmap.ImageBasedHeightMap;
import com.jme3.texture.Texture;
public class Jme3DGame extends SimpleApplication {
public static void main(String[] args){
Jme3DGame app = new Jme3DGame();
app.start(); //启动游戏
}
@Override
public void simpleInitApp() {
flyCam.setMoveSpeed(100);
cam.setLocation(new Vector3f(-36.88329f, 210.37311f, 348.5441f));
cam.setRotation(new Quaternion(0.006207374f, 0.9681271f, -0.24921864f, 0.02411376f));
// 加载高度图
Texture heightMapImage = assetManager.loadTexture("demo01.png");
// 根据图像内容,生成高度数据
ImageBasedHeightMap heightMap = new ImageBasedHeightMap(heightMapImage.getImage(), 1f);
heightMap.load();
float[] heightData = heightMap.getHeightMap();
// 根据高度图生成3D地形。
// 该地形被分解成边长65(64*64)的矩形区块,用于优化网格。
// 高度图的边长为 257,分辨率 256*256。
TerrainQuad terrain = new TerrainQuad("heightmap", 65, 257, heightData);
rootNode.attachChild(terrain);
terrain.center();
// 加载材质
Material material = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
material.getAdditionalRenderState().setWireframe(true);
terrain.setMaterial(material);
// 层次细节(LOD)优化
TerrainLodControl lodControl = new TerrainLodControl(terrain, cam);
// LOD计算器,一个参数代表区块大小,第二个参数代表距离系数。
// size = 65, multiplier = 2.7f, distance = 65 * 2.7f
lodControl.setLodCalculator(new DistanceLodCalculator(65, 2.7f));
terrain.addControl(lodControl);
}
}
运行结果如下:
使用高度图生成3D地形,主要优势是渲染速度比普通3D模型快。常用的渲染方法有很多,例如下面几种:
通过高度图生成的3D地形,本质上依然是一个3D物体。因此,直接使用普通的纹理贴图就可以渲染地形了。
ColorMap
相比于一般的3D模型来说,使用高度图生成的地形跟容易着色。 因为地形一定是矩形的,只要画好对应的彩色贴图(ColorMap)就可以了。
例如,使用 Unshaded.j3md设置下面的 ColorMap:
将其上面的图片下载下来,重命名为default_c, 然后放到resources目录下:
代码:
// 加载材质
Material material = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
// material.getAdditionalRenderState().setWireframe(true);
Texture colorMap = assetManager.loadTexture("default_c.png");
material.setTexture("ColorMap", colorMap);
terrain.setMaterial(material);
效果如下:
LightMap
还可以把光影烘焙成亮度图(LightMap),这样能够节省计算光影的开销,更适合手游。
同理也是将上面的图片下载下来重命名为default_1.png放到resources目录下:
// 加载材质
Material material = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
Texture colorMap = assetManager.loadTexture("Scenes/Maps/DefaultMap/default_c.png");
material.setTexture("ColorMap", colorMap);
Texture lightMap = assetManager.loadTexture("Scenes/Maps/DefaultMap/default_l.png");
material.setTexture("LightMap", lightMap);
terrain.setMaterial(material);
效果如下:
其他
想让地图看起来更加真实、细腻,还可以继续为材质加上法线贴图(NormalMap)、细节贴图(DetailMao)、发光贴图(GlowMap)等。不过 Unshaded.j3md 材质并不支持法线,需要使用 Lighting.j3md 材质了。
总的来说,这种渲染方式与普通的3D模型并没有什么区别。
default_unshaded.j3m
相比于在Java代码中加载材质、设置参数,我更喜欢使用j3m文件来记录材质参数。创建 default_unshaded.j3m 文件,写入下列内容:
Material unshaded material : Common/MatDefs/Misc/Unshaded.j3md {
MaterialParameters {
ColorMap : default_c.png
LightMap : Flip default_l.png
}
}
在Java代码中,只需要一行语句就可以加载这个材质了。
// 加载材质
Material material = assetManager.loadMaterial("default_unshaded.j3m");
terrain.setMaterial(material);
这种渲染方法的思路与等高线图一脉相承。根据高度值,把地形划分为不同的区域,每个区域使用不同的贴图。例如:
jme3-terrain 模块提供了一个 Common/MatDefs/Terrain/HeightBasedTerrain.j3md 材质,我们可以使用它来实现基于等高线的地形渲染。材质定义的内容是这样的:
MaterialDef Terrain {
MaterialParameters {
Texture2D region1ColorMap
Texture2D region2ColorMap
Texture2D region3ColorMap
Texture2D region4ColorMap
Texture2D slopeColorMap
Float slopeTileFactor
Float terrainSize
Vector3 region1
Vector3 region2
Vector3 region3
Vector3 region4
}
Technique {
VertexShader GLSL100: Common/MatDefs/Terrain/HeightBasedTerrain.vert
FragmentShader GLSL100: Common/MatDefs/Terrain/HeightBasedTerrain.frag
WorldParameters {
WorldViewProjectionMatrix
WorldMatrix
NormalMatrix
}
}
}
在这个材质中,最多可以设置4个高度区域的纹理。第X个区域的贴图用regionXColorMap表示,高度范围用regionX表示。
高度范围是一个Vector3f类型的参数,regionX.x 表示高度的起点,regionX.y表示高度的终点,regionX.z表示贴图的缩放系数。
slopeColorMap 贴图用于绘制悬崖,slopeTileFactor 表示斜坡的缩放系数。当斜率较大时,将绘制成悬崖峭壁。
terrainSize 表示地形的大小,即高度图的分辨率。
创建 default_height_based.j3m 文件,描述材质对象。
Material height based : Common/MatDefs/Terrain/HeightBasedTerrain.j3md {
MaterialParameters {
terrainSize : 257
//slopeColorMap : Repeat Scenes/Maps/DefaultMap/Textures/bigRockFace.png
//slopeTileFactor : 10
region1ColorMap : Repeat Scenes/Maps/DefaultMap/Textures/hardDirt.png
region2ColorMap : Repeat Scenes/Maps/DefaultMap/Textures/shortGrass.png
region3ColorMap : Repeat Scenes/Maps/DefaultMap/Textures/grayRock.png
region1 : 0.0 60.0 20.0
region2 : 60.0 120.0 20.0
region3 : 120.0 255.0 20.0
}
}
加载材质:
// 加载材质
Material material = assetManager.loadMaterial("default_height_based.j3m");
terrain.setMaterial(material);
运行效果如下:
Texture Splatting,中文翻译为“抛雪球”算法,也叫作“足迹法”。它是一种使用alphamap 将纹理融合到表面的技术。
一个纹理中通常有多个通道:红、绿、蓝、或者亮度。在Texture Splatting技术中,alphamap 用于控制纹理在当前位置显示颜色的强度。通过简单的乘法,很容易就能够调整纹理的颜色值:alphamap * texture(texture指代当前位置纹理的颜色值)。如果某像素的alphamap是1,则纹理显示全值,如果某像素的alphamap是0,则该纹理完全不显示。
下图是wikipedia上对texture splatting技术的演示。在这个例子中,一共有2个texture和1个alphamap。alphamap中使用黑白二色表示了2个texture各自的颜色强度,经过混合后得到了右下的纹理。
这种技术允许我们使用多种不同的纹理在地形的表面作画。通过着色器实现texture splatting算法,就可以混合出丰富的颜色。
一般来说,每个alphamap中最多有4个通道可以使用。例如 EarthSculptor(未注册版)的画刷功能,提供的就是4种纹理,恰好可以用1张alphamap来表示。
最终生成的alphamap看起来很怪异,仿佛是随意涂鸦而成。
实际上,alphamap中的每个通道都对应着一种纹理,例如下面4个。
当它们混色之后,就可以得到下面的实际纹理。
当它们混色之后,就可以得到下面的实际纹理。
jme3-terrain 提供了2个材质,都实现了 texture-splatting 算法。
Terrain.j3md
官方范例:TerrainTest.java
在 Terrain.j3md 的所定义的材质中,参数 Alpha 表示 alphamap;Tex1、Tex2、Tex3分别对应 alphamap 中红、绿、蓝三个通道的纹理;Tex1Scale、Tex2Scale、Tex3Scale对应表示每一种纹理的缩放系数。
useTriPlanarMapping 参数表示是否开启三维映射。开启后,着色器将对地形中拉伸变形的部位予以修正。
useTriPlanarMapping = false
useTriPlanarMapping = true
TerrainLighting.j3md
TerrainLighting.j3md 可以使用比 Terrain.j3md 更多的纹理,包括:
1~3个alphamap:
每个alphamap可以控制4个纹理,合计最多12个。每个纹理的定义,可以包含1个DuffuseMap、1个NormalMap以及1个缩放系数。
两种光照特效贴图:
由于在OpenGL中,每个着色器最多只能使用16个纹理,所以你并不能同时使用上面那么多贴图。使用 TerrainLighting.j3md 时,有如下限制:
下图是使用 TerrainLighting.j3md 材质渲染出来的地形。
根据前面我们在“物理引擎”章节中讲解过的知识,对地形进行碰撞检测是很容易的。首先为Terrain增加一个质量为0的刚体控制器(RigidBodyControl),然后把它添加到Bullet的物理空间(PhysicsSpace)即可。
terrain.addControl(new RigidBodyControl(0));
bulletAppState.getPhysicsSpace().add(terrain);
高度图中每个象素高度的取值范围为0~255的整数,在生成3D地形网格时,斜坡给人的感觉更像是“阶梯”。
使用高斯模糊算法,可以“抚平”这些棱角,让地形看起来更加平滑:
public class GaussianBlur {
private float[] kernel;
private double sigma = 2;
private float min = 0;
private float max = 255;
public GaussianBlur() {
kernel = new float[0];
}
public void setSigma(double a) {
this.sigma = a;
}
public void setClamp(float min, float max) {
this.min = min;
this.max = max;
}
public float[] filter(final float[] heightData, final int width, final int height) {
final int size = width * height;
makeGaussianKernel(sigma, 0.002, (int) Math.min(width, height));
float[] temp = new float[size];
blur(heightData, temp, width, height); // H Gaussian
blur(temp, heightData, height, width); // V Gaussain
return heightData;
}
/**
*
* @param inHeights
* @param outHeights
* @param width
* @param height
*/
private void blur(float[] inHeights, float[] outHeights, int width, int height) {
int subCol = 0;
int index = 0, index2 = 0;
float sum = 0;
int k = kernel.length - 1;
for (int row = 0; row < height; row++) {
float c = 0;
index = row;
for (int col = 0; col < width; col++) {
sum = 0;
for (int m = -k; m < kernel.length; m++) {
subCol = col + m;
if (subCol < 0 || subCol >= width) {
subCol = 0;
}
index2 = row * width + subCol;
c = inHeights[index2];
sum += c * kernel[Math.abs(m)];
}
outHeights[index] = clamp(sum);
index += height;
}
}
}
private float clamp(float height) {
return (height < min) ? min : (height > max) ? max : height;
}
private void makeGaussianKernel(final double sigma, final double accuracy, int maxRadius) {
int kRadius = (int) Math.ceil(sigma * Math.sqrt(-2 * Math.log(accuracy))) + 1;
if (maxRadius < 50)
maxRadius = 50; // too small maxRadius would result in inaccurate sum.
if (kRadius > maxRadius)
kRadius = maxRadius;
kernel = new float[kRadius];
for (int i = 0; i < kRadius; i++) // Gaussian function
kernel[i] = (float) (Math.exp(-0.5 * i * i / sigma / sigma));
double sum; // sum over all kernel elements for normalization
if (kRadius < maxRadius) {
sum = kernel[0];
for (int i = 1; i < kRadius; i++)
sum += 2 * kernel[i];
} else
sum = sigma * Math.sqrt(2 * Math.PI);
for (int i = 0; i < kRadius; i++) {
double v = (kernel[i] / sum);
kernel[i] = (float) v;
}
return;
}
}
使用:
// 加载地形的高度图
Texture heightMapImage = assetManager.loadTexture("Scenes/Maps/DefaultMap/default.png");
// 根据图像内容,生成高度图
ImageBasedHeightMap heightmap = new ImageBasedHeightMap(heightMapImage.getImage(), 1f);
heightmap.load();
// 高斯平滑
GaussianBlur gaussianBlur = new GaussianBlur();
float[] heightData = heightmap.getHeightMap();
int width = heightMapImage.getImage().getWidth();
int height = heightMapImage.getImage().getHeight();
heightData = gaussianBlur.filter(heightData, width, height);
/*
* 根据高度图生成实际的地形。该地形被分解成边长65(64*64)的矩形区块,用于优化网格。高度图的边长为 257,分辨率 256*256。
*/
TerrainQuad terrain = new TerrainQuad("terrain", 65, 257, heightmap.getHeightMap());
高斯模糊前:
高随模糊后: