地形

本篇教程资料请点击这里下载

本篇教程转载于:https://www.cntofu.com/book/71/chapter-17-outdoor-scene.md

制作3D游戏时,可以使用建模工具来雕刻大型的游戏地图。这样做能够获得非常精细的地图模型,而且创作自由度也非常高,缺陷是渲染速度较慢。

image.png

在实际开发中,经常使用高度图(Height map 来创建能够快速渲染的地形。再结合着色器实现“抛雪球算法(Texture Splatting)”算法,能够使用少量纹理贴图,绘制出效果不错的地形。

高度图

3D游戏中的地形生成有许多方法,其中应用最广泛的就是高度图。在地理学科中,一幅地图的不同海拨用不同的颜色表示,即等高线表示法。高度图基于同样的原理,只不过这里的高度值表现为图像中的亮度值。

下面两幅图分别为等高线图与高度图:

image.png

在高度图中,图像的每个象素存储了对应的高度值,取值范围为0~255。

image.png

根据图像每个象素的(x, y)坐标,以及高度值height,就可以获得3D空间中的顶点。把这些顶点连接成网格,就可以生成3D模型。

Vector3f position = new Vector3f(x, height, -y);

下面3个截图就是用过一副高度图生成的3D地形。

image.png

image.png

image.png

制作高度图

地形编辑器是用于制作高度图的工具。jMonkeyEngine SDK、Unity3D之类的3D游戏引擎有内置的地形编辑器,但是我更推荐专业工具。诸如:

本文使用的高度图均由 EarthSculptor 生成。下载 EarthSculptor 后,你可以在Maps目录、Textures目录中找到一些默认的资源图片。它的界面和功能都不复杂,很容易上手。免费版只能生成257*257分辨率的高度图,但也足够新手使用了。

安装首次启动后界面如下:

image.png

根据提示,按F1可以隐藏打开按键功能说明(Key Controls), 按大小写切换键Caps Lock 可以显示隐藏除主UI界面的那几个小UI界面,下面我把它调成如下:

image.png

具体使用这里就不介绍了,我们导出其高度图:

image.png

image.png

在JME3中使用高度图

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地形时,图片的格式和分辨率是有限制的。

  • 由于四叉树优化的需要。图像的长和宽必须相等,并且分辨率最好是2的n次方+1。例如129(128+1)、257(256+1)、513(512+1)、1025(1024+1)等。
  • 使用256度的灰度图。就算你使用彩色图片,程序也会使用加权平均法把它变成灰度图,可能会导致一些诡异的结果。
  • 使用jpg或png格式的图片。

jme3-terrain 模块有很多功能,包括但不限于:

  • 根据高度数据生成3D地形。通过 AbstractHeightMap 来定义统一的高度图接口,既可以使用 ImageBasedHeightMap 来加载图像数据,也可以通过一些算法来随机生成高度数据。
  • 基于GeoMipMapping算法的层次细节(LOD)技术。 这种技术可以根据顶点到摄像机的距离来动态改变层次细节。离摄像机越近,细节越清晰;离摄像机越远,看起来越简化。

image.png

  • 四叉树(Quad Tree)网格优化。整个地形的网格由多个地形区块(TerrainPatch)组成,并归于地形四叉树(TerrainQuad)统一管理。这些区块存储了实际的网格数据,可以支持层次细节、加速视锥裁剪等优化功能。
  • Texture Splatting渲染。这是一种基于着色器的多重纹理渲染技术,jME3最大支持16张不同的纹理。
  • 实时编辑地形数据。TerrainQuad中的地形数据是可以实时编辑的,jMonkeyEngine SDK基于这个功能提供了内置的地形编辑器。

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个参数,这些参数的含义如下:

  • String name, 地形的名称。
  • int patchSize, 区块的大小。若区块大小为 64x64,则取值为 65。
  • int totalSize, 高度图的分辨率。对于分辨率为 257x257 的高度图,取值为 257。
  • float[] heightMap, 地形的高度数据。数组的长度应该为 totalSize x totalSize。

注意,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 模块中包含了一些地形专用材质,诸如:

  • Common/MatDefs/Terrain/Terrain.j3md
  • Common/MatDefs/Terrain/TerrainLighting.j3md
  • Common/MatDefs/Terrain/HeightBasedTerrain.j3md

第五步:层次细节(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);
    }
}

运行结果如下:

image.png

地形渲染

使用高度图生成3D地形,主要优势是渲染速度比普通3D模型快。常用的渲染方法有很多,例如下面几种:

  • 使用纹理贴图
  • 基于等高线渲染
  • 使用抛雪球算法

使用纹理贴图

通过高度图生成的3D地形,本质上依然是一个3D物体。因此,直接使用普通的纹理贴图就可以渲染地形了。

ColorMap

相比于一般的3D模型来说,使用高度图生成的地形跟容易着色。 因为地形一定是矩形的,只要画好对应的彩色贴图(ColorMap)就可以了。

例如,使用 Unshaded.j3md设置下面的 ColorMap:

image.png

将其上面的图片下载下来,重命名为default_c, 然后放到resources目录下:

image.png

代码:

        // 加载材质
        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);

效果如下:

image.png

LightMap

还可以把光影烘焙成亮度图(LightMap),这样能够节省计算光影的开销,更适合手游。

image.png

同理也是将上面的图片下载下来重命名为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);

效果如下:

image.png

其他

想让地图看起来更加真实、细腻,还可以继续为材质加上法线贴图(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
    }
}

image.png

在Java代码中,只需要一行语句就可以加载这个材质了。

    // 加载材质
    Material material = assetManager.loadMaterial("default_unshaded.j3m");
    terrain.setMaterial(material);

基于等高线算法

这种渲染方法的思路与等高线图一脉相承。根据高度值,把地形划分为不同的区域,每个区域使用不同的贴图。例如:

  • 山谷,高度值0~50
  • 平原,高度值50~200
  • 高原,高度值200~255

image.png

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 文件,描述材质对象。

image.png

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);

运行效果如下:

image.png

抛雪球算法

Texture Splatting,中文翻译为“抛雪球”算法,也叫作“足迹法”。它是一种使用alphamap 将纹理融合到表面的技术。

一个纹理中通常有多个通道:红、绿、蓝、或者亮度。在Texture Splatting技术中,alphamap 用于控制纹理在当前位置显示颜色的强度。通过简单的乘法,很容易就能够调整纹理的颜色值:alphamap * texture(texture指代当前位置纹理的颜色值)。如果某像素的alphamap是1,则纹理显示全值,如果某像素的alphamap是0,则该纹理完全不显示。

下图是wikipedia上对texture splatting技术的演示。在这个例子中,一共有2个texture和1个alphamap。alphamap中使用黑白二色表示了2个texture各自的颜色强度,经过混合后得到了右下的纹理。

image.png

这种技术允许我们使用多种不同的纹理在地形的表面作画。通过着色器实现texture splatting算法,就可以混合出丰富的颜色。

一般来说,每个alphamap中最多有4个通道可以使用。例如 EarthSculptor(未注册版)的画刷功能,提供的就是4种纹理,恰好可以用1张alphamap来表示。

image.png

最终生成的alphamap看起来很怪异,仿佛是随意涂鸦而成。

image.png

实际上,alphamap中的每个通道都对应着一种纹理,例如下面4个。

image.png

当它们混色之后,就可以得到下面的实际纹理。

当它们混色之后,就可以得到下面的实际纹理。

jme3-terrain 提供了2个材质,都实现了 texture-splatting 算法。

  • Common/MatDefs/Terrain/Terrain.j3md 支持1个alphamap和3个纹理,并且不能处理光照。
  • Common/MatDefs/Terrain/TerrainLighting.j3md 支持最大3张alphamap 和12张纹理,还支持法线贴图、高光贴图、发光贴图等纹理,并且可以处理光照。

Terrain.j3md

官方范例:TerrainTest.java

在 Terrain.j3md 的所定义的材质中,参数 Alpha 表示 alphamap;Tex1、Tex2、Tex3分别对应 alphamap 中红、绿、蓝三个通道的纹理;Tex1Scale、Tex2Scale、Tex3Scale对应表示每一种纹理的缩放系数。

useTriPlanarMapping 参数表示是否开启三维映射。开启后,着色器将对地形中拉伸变形的部位予以修正。

useTriPlanarMapping = false

image.png

useTriPlanarMapping = true

image.png

TerrainLighting.j3md

官方范例:TerrainTestAdvanced.java

TerrainLighting.j3md 可以使用比 Terrain.j3md 更多的纹理,包括:

1~3个alphamap:

  • AlphaMap
  • AlphaMap_1
  • AlphaMap_2

每个alphamap可以控制4个纹理,合计最多12个。每个纹理的定义,可以包含1个DuffuseMap、1个NormalMap以及1个缩放系数。

  • DiffuseMap, DiffuseMap_0_scale, NormalMap
  • DiffuseMap_1, DiffuseMap_1_scale, NormalMap_1
  • DiffuseMap_2, DiffuseMap_2_scale, NormalMap_2
  • DiffuseMap_3, DiffuseMap_3_scale, NormalMap_3
  • DiffuseMap_4, DiffuseMap_4_scale, NormalMap_4
  • ...
  • DiffuseMap_11, DiffuseMap_11_scale, NormalMap_11

两种光照特效贴图:

  • GlowMap
  • SpecularMap

由于在OpenGL中,每个着色器最多只能使用16个纹理,所以你并不能同时使用上面那么多贴图。使用 TerrainLighting.j3md 时,有如下限制:

  • 1-12 DiffuseMap。 至少得有1个DiffuseMap。
  • 1-3 AlphaMap。每使用4个DiffuseMap,就需要多用1个AlphaMap!
  • 0-6 NormalMap。DiffuseMap和NormalMap总是成对使用!
  • 0-1 GlowMap。
  • 0-1 SpecularMap。
  • 纹理的总数不能超过16个!

下图是使用 TerrainLighting.j3md 材质渲染出来的地形。

image.png

地形的碰撞检测

根据前面我们在“物理引擎”章节中讲解过的知识,对地形进行碰撞检测是很容易的。首先为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());

高斯模糊前:

image.png

高随模糊后:

image.png