2

background

Recently, the team is exploring the development of a 3D sandbox mini-game demo. For sandbox games, the ground is an essential element. In order to reduce the difficulty, in this demo, the ground will not involve the change of the y-axis coordinate, that is, a plane parallel to the xOz plane is used, which corresponds to the real world, which is a flat ground without any undulations. This article uses babylon.js as the framework to illustrate. The desired effect is similar to the following picture (the screenshot is from the mobile game Clash of Clans):


Target

First we need to create a rectangle on the xOz plane as the ground. In order not to make the ground look too monotonous, it is necessary to paste some textures on the ground, such as grass, cobblestone roads, etc. On this basis, the textures also need to be partially replaced, for example, a cobblestone path in the middle of the grass can be realized. At the same time, other models (such as characters, buildings, etc.) need to be placed on the ground. In order to avoid overlapping when the models are moved or added, it is necessary to know the status of the corresponding position on the current ground (whether it has been modeled or not). occupied), so when adding or moving a model, you need to obtain the specific location information of the current model on the ground. Based on the above requirements, it can be sorted into the following two major goals:

  1. Complete the ground initialization, and can change the texture of a specific position
  2. Get the position information of the model on the ground

Around these two goals, the following two realization articles will show you how to achieve it step by step~


Realized Ground Creation

First of all, we need to think about it: first, we need to create a ground. In fact, the essence of the ground is also a model. Next, we need to modify part of the texture of the ground. There is a relatively simple method, which is to subdivide the ground into grids, each grid can be textured separately, and each time the texture is changed, it will not affect other grids.

Define some constants for later explanation. These constants only need to be read first to have a basic impression, and will be explained in detail when used later.

 //地面的长度(x方向)
const GROUND_WIDTH = 64
//地面的宽度(z方向
const GROUND_HEIGHT = 64

//地面纹理的宽度 
const TEXTURE_WIDTH = 1024
//地面纹理的高度
const TEXTURE_HEIGHT = 1024

//一个方向上(s和t坐标方向),把地面分为多少个小块
const GROUND_SUBDIVISION = 32

Below are the specific steps.


1. Build a flat

Check the relevant documents of babylon.js , you can create it by calling the api directly. The code is relatively simple, and paste it directly below:

 const ground = MeshBuilder.CreateGround(
    name,
    { width: GROUND_WIDTH, height: GROUND_HEIGHT, subdivisions: GROUND_SUBDIVISION },
    scene,
)

In the above code, three constants GROUND_WIDTH, GROUND_HEIGHT and GROUND_SUBDIVISION that have been defined at the beginning of this article are used. The first two constants represent the width and height of the ground to be created, both of which are 64. The coordinate system they belong to is the clipping coordinate system. Since many coordinate systems are used in WebGL, some students may not know it very well. It is recommended to read this article WebGL Coordinate System Basics . As for the constant GROUND_SUBDIVISION, it refers to how many segments one side of the rectangle should be divided into. This article divides the x and z directions of the ground into 32 segments.

With a simple line of code, you can create a flat and see the effect:




2. Texture the "earth"

Complex functions always evolve from simple functions. First, do the easiest step first, give the ground we just created, paste the texture, find a picture of the grass, check the babylon.js material and texture part of the document, first give the ground a material, then in To map on the material, the code is as follows:

 //创建一个标准材质
const groundMaterial = new StandardMaterial('groundMaterial', scene);
//创建一个纹理
const groundTexture = new Texture('//storage.360buyimg.com/model-rendering-tool/files/jdLife/grass.jpg', scene)
//把纹理赋值给材质的漫反射纹理(这里也可以是其他类型的纹理)
groundMaterial.diffuseTexture = groundTexture
//把材质赋值给地面的材质属性
ground.material = groundMaterial

Now the ground has a texture map:




3. Divide the ground into grids

In the previous step, although the texture has been applied to the ground, the effect must not meet the predetermined requirements. If you have a relevant understanding of WebGL, you should know that if you want to paste a specific texture on a specific position of a material, you need to get the vertex data on the material, then put a pushpin, and then in the texture map, according to the pushpin corresponding to the vertex Click to get the location of the desired image. This should be a relatively complicated operation, and babylon.js is deeply encapsulated. If it is implemented directly and violently, it will be a relatively large project.

Therefore, based on the guess that babylon.js should have encapsulated classes that can implement this requirement (or can be implemented after simple changes), I read its documentation again and finally found a similar example . In order to facilitate reading, the effect diagram is directly intercepted and displayed:

Looking at the code for this example, it can be summed up as:

  1. The ground is textured using the AdvancedDynamicTexture.CreateForMesh advanced dynamic texture from babylon.js.
  2. Advanced dynamic textures provide the addControl method to add various "containers" to the texture.
  3. "Container" is a class Container of Babylon.js.
  4. "Container" also has the addControl method, you can continue to add "container" in the "container", that is, "dolls".

The implementation principle of the AdvancedDynamicTexture of babylon.js will not be discussed here, but now with the above knowledge points, combined with the demo, the ground can be divided. Go directly to the code and write the steps in the comments:

 //首先调用AdvancedDynamicTexture的api,创建纹理
const groundTexture = GUI.AdvancedDynamicTexture.CreateForMesh(ground, TEXTURE_WIDTH, TEXTURE_HEIGHT, false)

//创建最外层的Container -- panel,它的宽高和纹理的一致
const panel = new GUI.StackPanel()
panel.height = TEXTURE_HEIGHT + 'px'
panel.width = TEXTURE_WIDTH + 'px'

//把panel添加到纹理上
groundTexture.addControl(panel)

//循环的建立一列列Row,并且加到panel上面
for (let i = 0; i < GROUND_SUBDIVISION; i++) {
  const row = new GUI.StackPanel()
  row.height = TEXTURE_HEIGHT / GROUND_SUBDIVISION + 'px'
  //把row添加到panel上
  panel.addControl(row)
  row.isVertical = false
  //在循环的,在每一行里面建立一个个格子
  for (let j = 0; j < GROUND_SUBDIVISION; j++) {
    const block = new GUI.Rectangle()
    block.width = TEXTURE_WIDTH / GROUND_SUBDIVISION + 'px'
    row.addControl(block)     
  }
}

The code uses two constants, TEXTURE_WIDTH and TEXTURE_HEIGHT, which represent the width and height of the texture respectively. For students who know more about the size of the relative texture, you can refer to the third WebGL Texture Details: Texture Size and Mipmapping .

Take a look at the effect at this time:




4. Map each grid separately and store the texture Image object

Image here refers to a class in Babylon.js, which is directly called Image for the convenience of the following.
Why do you want to map each grid separately, this needs no explanation. As for why the texture Image object of each grid is stored, it is for the convenience of modifying the texture later. Since these grids are created through a loop when they are created, they already have a certain order, so as long as they are created, they are all pushed into an array (blockImageArray), and they are read in the order of creation. Just pass in the index.

When implementing, it is still the easiest to implement first, so that the texture of each grid is the same. Add on the basis of the code in the previous step, the code is as follows:

 ...
  //在循环的,在每一行里面建立一个个格子
  for (let j = 0; j < GROUND_SUBDIVISION; j++) {
    const block = new GUI.Rectangle()
    block.width = TEXTURE_WIDTH / GROUND_SUBDIVISION + 'px'
    row.addControl(block)
    //隐藏格子的边框
    block.thickness = 0
    //创建Image对象
    const blockImage = new GUI.Image('blockTexture','//storage.360buyimg.com/model-rendering-tool/files/jdLife/grass.jpg')
    //把图片添加到block上
    block.addControl(blockImage)
    //在外面的定义域里面先创建好blockImageArray
    blockImageArray.push(blockImage)
  }

It is worth noting that when the above code creates an Image object, it is directly imported dynamically through the url, which will cause a request to be sent every time an Image is created, which is obviously a performance problem.
So, check the documentation of babylon.js again to find an optimization solution. Image has a domImage property, the value type is HTMLImageElement, you can modify the image content by modifying this property. So as long as the image is loaded in advance to generate HTMLImageElement and stored in imageSource, when creating Image, assign value to its domImage property. Optimized code:

 //把需要的图片导入好,放到imageSource里面
const imageSource : { [key: string]: HTMLImageElement } = { grass: img, stone: img }

...
//创建image的时候不再传递url参数了
const blockImage = new GUI.Image()
//对domImage属性赋值
blockImage.domImage = imageSource.grass

Now look at the effect:




5. Change the texture

After the operation in the previous step, a green space has been created, and the next thing to do is the function of texture replacement. First implement the simplest one: listen to the click event on the outermost panel, determine the row and column of the ground that is currently clicked by the position of the click, then find the element corresponding to blockImageArray, and reassign its domImage Enough. code show as below:

 panel.onPointerClickObservable.add(e => {
    const { y, x } = e
    const perBlockWidth = TEXTURE_WIDTH / GROUND_SUBDIVISION
    const perBlockHeight = TEXTURE_HEIGHT / GROUND_SUBDIVISION
    const row = Math.floor(y / perBlockHeight)
    const col = Math.floor(x / perBlockWidth)
    const index = row * GROUND_SUBDIVISION + col
    blockObjArr[index].domImage = imageSource.stone
})

Take a look at the current effect:

So far, the goal of creating the ground and changing the texture has been achieved.


Implementation of Model Occupancy Calculation

noun:

  • Ground: the plane in the case
  • Model: Objects that need to be calculated in the case
  • index number: the subscript of the two-dimensional array
  • Grid coordinate system: a coordinate system formed by dividing the ground into equal grids
  • WebGL Coordinate System: Raw WebGL Coordinate System
  • Model base point: the model origin
  • Convert: Convert from one value to another
  • Offset correction: Add the original coordinate value to the offset value (here is the length or width of half a grid)
  • Bounding box: the smallest cuboid bounding that can wrap the entire model

Flowchart: https://www.processon.com/view/link/6238a14007912906f50e1ed7

After the implementation of the previous article, a ground has been created and divided into several grids. At this time, to obtain the occupancy of the model on the ground, it is necessary to convert it into the data of the grid occupied by the model on the ground. The following image shows the grid occupied by a model (house) on the ground (the occupied grid border is shown in red):

In order to look more intuitive, we will divide the ground into an 8*8 grid system in the following instructions.

Here are the constants involved:

 //重新定义一下,让地面分为 8 * 8 的网格
const GROUND_SUBDIVISION = 8

//每一个格子在相机裁剪坐标系中的宽度
const PER_BLOCK_VEC_X = GROUND_WIDTH / GROUND_SUBDIVISION
//每一个格子在相机裁剪坐标系中的高度
const PER_BLOCK_VEC_Z = GROUND_HEIGHT / GROUND_SUBDIVISION

//模型位置向量在x轴方向的偏移量
const CENTER_OFF_X = PER_BLOCK_VEC_X / 2
//模型位置向量在z轴方向的偏移量
const CENTER_OFF_Z = PER_BLOCK_VEC_Z / 2

//半个格子在相机裁剪坐标系中的宽度
const HALF_BLOCK_VEC_X = PER_BLOCK_VEC_X / 2
//半一个格子在相机裁剪坐标系中的高度
const HALF_BLOCK_VEC_Z = PER_BLOCK_VEC_Z / 2

To know exactly which grids the model on the ground occupies, you must first establish a ground grid coordinate system. Remember that in the previous article, when these grids were generated, they were generated through two for loops. In fact, when these grids were generated, indexes were also generated. For the convenience of reading, I will paste the code for generating the grid:

 for (let i = 0; i < GROUND_SUBDIVISION; i++) {
  const row = new GUI.StackPanel()
  row.height = TEXTURE_HEIGHT / GROUND_SUBDIVISION + 'px'
  //把row添加到panel上
  panel.addControl(row)
  row.isVertical = false
  //在循环的,在每一行里面建立一个个格子
  for (let j = 0; j < GROUND_SUBDIVISION; j++) {
    const block = new GUI.Rectangle()
    block.width = TEXTURE_WIDTH / GROUND_SUBDIVISION + 'px'
    row.addControl(block)     
    //创建贴图
    const blockImage = new GUI.Image()
    //对domImage属性赋值
    blockImage.domImage = imageSource.grass
    block.addControl(blockImage)
    blockImageArray.push(blockImage)
  }
}

It can be understood that the i and j of each grid correspond to their coordinates in the z and x directions.

According to the x and z coordinates of each grid in the grid coordinate system, set the index number (one coordinate for each grid), and the data structure of the index number is:

 interface Coord {
  x: number,
  z: number
} 

Putting it in the 8*8 grid coordinate system is:

Is the above picture easy to understand? It looks like the plane rectangular coordinate system we learned in junior high school. The origin is in the upper left corner, the x-axis is horizontal from left to right, and the z-axis is vertical from top to bottom.

Corresponding to the code, we can store this grid coordinate system by creating a two-dimensional array. In this way, you can use the coordinates of the ground grid as an index to find the corresponding value in the two-dimensional array to determine whether there is a model occupancy on the grid.


1. Create a grid coordinate system coordinate collection array: groundStatus

The grid coordinate system coordinate set array groundStatus , we define it as a number two-dimensional array.

groundStatus data structure is as follows:

 type GroundStatus = number[][]

Each element in the two-dimensional array corresponds to a coordinate in the grid coordinate system. The initial value corresponding to each coordinate is 0 , which means that the current coordinate is not occupied. When a model is placed on it, the value is +1; when the model is removed or deleted, the value is -1. The reason for not using boolean as the storage type is because boolean has only two states of true and false, which cannot meet more complex requirements, such as when models overlap when moving models, groundStatus There will be two models on the corresponding grid. If it is represented by boolean, there is no way to represent it, because it has only two values, true and false. However, if you use number, you can change the value to 2 in the element corresponding to the grid, indicating that there are two models occupying the grid before the single.

When designing the groundStatus index, whether the x or z coordinate is the one-dimensional index makes little difference in performance impact. For debugging purposes, it is recommended to use the z coordinate as the one-dimensional index, so that the display of the two-dimensional array in the browser's console corresponds to the grid coordinate system one-to-one.




2. Conversion between model base point vector and grid coordinate system coordinates

The model base point vector refers to the position attribute in the model data, which defines the position of the model in the WebGL coordinate system. position is a three-dimensional vector that follows the WebGL coordinate system. For example, when the position value is (0, 0, 0), the position that appears on the ground is the center point of the ground. For convenience, I will call it the base point of the model below.

Image credit: Editing the base or insertion point of a 3D model in InfraWorks

The (0, 0, 0) of the WebGL coordinate system is converted into the ground coordinate system, which is the intersection of the four grids of grid coordinates (3, 3), (3, 4), (4, 3), (4, 4) . As shown below:

The yellow squares represent the squares occupied by the dots on the ground. For a model with only 1 grid of actual occupancy, when rounding up at this position, the minimum occupancy is also 4 grids. Once collision detection and other functions are involved, the problem of model occupancy is too large. Therefore, we need to offset the center point to make the model's footprint in the grid coordinate system as close as possible to the real model's footprint. To the lower right corner - x and z are offset by half a grid unit. At this time The coordinates of the base point corresponding to (0, 0, 0) are the center points of the (4, 4) grid. The principle of offset is to ensure that the base point of the model can fall at the midpoint of a grid in the grid coordinate system, so that the model occupancy can be calculated more accurately. As shown below:

It is worth noting here that when the position vector we pass in the model is (x, y, z), we will manually change the position of the model to (x + CENTER_OFF_X, 0, z - CENTER_OFF_Z) (Y axis The amount does not involve calculation this time, so it can be omitted). The calculation of the z-vector is a subtraction because the z-axis of the WebGL coordinate system is positive up, while the z-axis of the grid coordinate system is negative up.

Here we encapsulate a function that passes in the position vector of the model and returns the ground coordinates of the point:

 function getGroundCoordByModelPos(buildPosition: Vector3): Coord {
  const { _x, _z } = buildPosition
  const coordX = Math.floor(GROUND_WIDTH / 2 / PER_BLOCK_VEC_X + (_x + CENTER_OFF_X) / PER_BLOCK_VEC_X)
  const coordZ = Math.floor((GROUND_HEIGHT / 2 - (_z - CENTER_OFF_Z)) / PER_BLOCK_VEC_Z)
  return { x: coordX, y: coordZ }
}




3. Obtain the key data of the model footprint in the WebGL coordinate system

This step is to obtain the actual occupancy-related data of the model and prepare for the subsequent occupancy transformation of the grid coordinate system.

The model has the concept of a minimum bounding box, also called the minimum bounding box, which is used to define the geometric primitives of the model. The bounding box/bounding box can be a rectangle or a more complex shape. For the convenience of description, we use a rectangular bounding box/bounding box for description here. Hereinafter referred to as the bounding box.

Image credit: 3D Collision Detection

When we project the model in the WebGL coordinate system onto the grid coordinate system, we get an area:

The yellow area represents the model footprint, and the black point is the base point of the model. babylon.js provides related APIs that can calculate the distance between the boundary of the model's bounding box and the base point. The values here are based on the WebGL coordinate system.

We store these distances in the rawOffsetMap object with the following data structure:

 interface RawOffsetMap {
  rawOffsetTop: number
  rawOffsetBottom: number
  rawOffsetLeft: number
  rawOffsetRight: number
}

The calculation code is as follows:

 /*
 @param { AbstractMesh[] } meshes 模型导入后返回的结果
 @param { Vector3 } scale 模型的缩放倍数
*/
function getRawOffsetMap(meshes: AbstractMesh[], scale: Vector3 = new Vector3(1, 1, 1)): RawOffsetMap {
  //声明最小的向量
  let min = null
  //声明最大的向量
  let max = null
  
  //对模型的meshes数组进行遍历
  meshes.forEach(function (mesh) {
    //babylon.js 提供的api,可以遍历该mesh的和mesh的所有子mesh,找到它们的边界
    const boundingBox = mesh.getHierarchyBoundingVectors()

    //如果当前的最小向量不存在,那么把当前的mesh的boundingBox的min属性赋值给它
    if (min === null) {
      min = new Vector3()
      min.copyFrom(boundingBox.min)
    }

    //如果当前的最大向量不存在,那么把当前的mesh的boundingBox的max属性赋值给它
    if (max === null) {
      max = new Vector3()
      max.copyFrom(boundingBox.max)
    }

    //对最小向量和当前的boundingBox的min属性,从x,y,z这三个分量进行比较与再赋值
    min.x = boundingBox.min.x < min.x ? boundingBox.min.x : min.x
    min.y = boundingBox.min.y < min.y ? boundingBox.min.y : min.y
    min.z = boundingBox.min.z < min.z ? boundingBox.min.z : min.z

    //对最大向量和当前的boundingBox的max属性,从x,y,z这三个分量进行比较与再赋值
    max.x = boundingBox.max.x > max.x ? boundingBox.max.x : max.x
    max.y = boundingBox.max.y > max.y ? boundingBox.max.y : max.y
    max.z = boundingBox.max.z > max.z ? boundingBox.max.z : max.z
  })

  return {
    rawOffsetRight: max.x * scale.x,
    rawOffsetLeft: Math.abs(min.x * scale.x),
    rawOffsetBottom: max.z * scale.z,
    rawOffsetTop: Math.abs(min.z * scale.z)
  }
}




4. Obtain the key data of the model footprint on the grid coordinate system: offsetMap

This step is to convert the placeholder key data from the model's WebGL coordinate system to data in the grid coordinate system.

As shown in the figure above, the yellow grid represents the grid where the model base point is located. Red is the space occupied by the model after the transformation of the grid coordinate system - when the model boundary occupies less than one grid (for example, it only occupies half of the grid), it is counted as one full grid. These four data, we use the offsetMap object to store:

 interface OffsetMap {
  offsetLeft: number,
  offsetRight: number,
  offsetTop: number,
  offsetBottom: number
} 

In the previous section, the rawOffsetTop, rawOffsetBottom, rawOffsetLeft, rawOffsetRight of the model have been calculated. Now just convert these key values one by one into the key values corresponding to the offsetMap.

The yellow area in the above figure is the model's occupancy in the WebGL coordinate system, and the red area is the set of grids occupied in the grid coordinate system after rounding up the model's occupancy. The conversion relationship between rawOffsetMap and the fields in offsetMap is: rawOffsetLeft corresponds to offsetLeft; rawOffsetRight corresponds to offsetRight; rawOffsetTop corresponds to offsetTop; rawOffsetBottom corresponds to offsetBottom. Taking the conversion of rawOffsetLeft to offsetLeft as an example, subtract the width of half a grid (HALF_BLOCK_VEC_X) from rawOffsetLeft, then divide by the width of a grid (PER_BLOCK_VEC_X), and then round up. The following is the specific code:

 function getModelOffsetMap(rawOffsetMap: RawOffsetMap): OffsetMap {
  const { rawOffsetMapLeft, rawOffsetRight, rawOffsetBottom, rawOffsetTop } = rawOffsetMap
  const offsetLeft = Math.ceil((rawOffsetLeft - HALF_BLOCK_VEC_X) / PER_BLOCK_VEC_X)
  const offsetRight = Math.ceil((rawOffsetRight - HALF_BLOCK_VEC_X) / PER_BLOCK_VEC_X)
  const offsetTop = Math.ceil((rawOffsetTop - HALF_BLOCK_VEC_Z) / PER_BLOCK_VEC_Z)
  const offsetBottom = Math.ceil((rawOffsetBottom - HALF_BLOCK_VEC_Z) / PER_BLOCK_VEC_Z)
  return {
    offsetBottom,
    offsetLeft,
    offsetRight,
    offsetTop
  }
}




5. Calculate the bounding box index of the model in the grid coordinate system: bounding

In this step, we will calculate the index subscript of the model's bounding box in groundStatus, so as to judge whether the corresponding grid has been occupied by groundStatus. Bounding is several boundary index values of the placeholder model in the groundStatus data.

The data structure of bounding is as follows:

 interface Bounding {
  minX: number,
  maxX: number,
  minZ: number,
  maxZ: number
} 

Let's first pass a picture to explain what the four values in the bounding object refer to:

In the above figure, the red area is the grid occupied by the model in the grid coordinate system. The four values in the bounding data represent the index array subscripts of the bounding box bounding grid of the model in groundStatus, which are used as the basis for updating the placeholder values in groundStatus.

Based on the offsetMap data obtained in step 4, combined with the base point coordinates in step 2, the final bounding can be calculated:

 function getModelBounding(buildPosition: Vector3, offsetMap: OffsetMap): IBounding {
  const modelGroundPosCoord = getGroundCoordByModelPos(buildPosition)
  const { x, y } = modelGroundPosCoord
  const { offsetBottom, offsetLeft, offsetRight, offsetTop } = offsetMap
  
  const minX = x - offLeft
  const maxX = x + offRight
  const minZ = y - offTop
  const maxZ = y + offBottom
  
  return {
    minX,
    maxX,
    minZ,
    maxZ
  }
}

At this point, the calculation of the bounding of the model is completed.

6. Update placeholder data

In the previous step, the bounding of the model in the ground coordinate system has been obtained. At this time, you only need to use the value of bounding to assign a value to groundstatus. The code is as follows:

 //索引边界判断
function isValidIndex(x: number, z: number): boolean {
  if (x >= 0 && x < GROUND_SUBDIVISION && z >= 0 && z < GROUND_SUBDIVISION) return true
  return false
}

function setModlePosition(groundStatus: GroundStatus, bounding: Bounding) {
  const { minX, maxX, minZ, maxZ } = bounding

  for (let i = minZ; i <= maxZ; i++) {
    for (let j = minX; j <= maxX; j++) {
      if (isValidIndex(j, i))
        groundStatus[i][j]++
    }
  }
}


Subsequent items to be optimized

The ground of the project is flat, and no depth information is considered. If the ground is undulating, the current data structure is not enough. If it is a scene with a stepped height (the ground is composed of n pieces of flat ground with different heights), then the data structure of the elements of the groundStatus array needs to be transformed, and the attribute of the ground height identification can be added to meet the requirements. But if it is the kind of undulating and sloping terrain, it is difficult to retrofit.


Finished product display


Reference link

Or pay attention to the AOTULabs official account (AOTULabs), and push articles from time to time.


凹凸实验室
2.3k 声望5.5k 粉丝

凹凸实验室(Aotu.io,英文简称O2) 始建于2015年10月,是一个年轻基情的技术团队。