background

Recently the team is exploring the development of a 3D sandbox demo. For sandbox games, the ground is essential. To ease the difficulty, in this demo, the ground will not involve changes in y coordinates, that is, using a plane that is parallel to the xOz plane, corresponding to the real world, a flat piece without any fluctuations. This article uses Babylon. Js as a framework for illustration. The desired effect is similar to the following (screenshot from mobile Clash of Clans) :


The 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, we need to put some textures on the ground, such as grass, cobblestones, etc. On top of this, the texture needs to be partially replaced, such as a pebble path in the middle of the grass. At the same time, on the ground, it is necessary to put other models (such as people, buildings, etc.), in order to avoid the model in the mobile or new, overlap, also need to know the current state of the corresponding position on the ground (whether is occupied by a model), so in new or mobile model, need to get the current model in the specific location on the ground. Based on the above requirements, it can be sorted into the following two major goals:

  1. Ground initialization is complete and textures can be changed at specific locations
  2. Get the position information of the model on the ground

Around these two goals, the following through two implementation, to show you how to achieve step by step ~


Implementation of the ground creation section

First, the idea is to create a ground, which is essentially a model. The second part of the ground texture should be modified. There is a relatively simple method, is to subdivide the ground into a grid, each grid can be individually textured, each time the texture change, will not affect the other grid.

I’ll define some constants for later. All you need to do is look at these constants, get a basic idea, and we’ll explain them later when we use them.

// Length of ground (x direction)
const GROUND_WIDTH = 64
// The width of the ground (z direction
const GROUND_HEIGHT = 64

// Width of the ground texture
const TEXTURE_WIDTH = 1024
// Height of ground texture
const TEXTURE_HEIGHT = 1024

// In one direction (s and T coordinates), how many pieces of ground can be divided
const GROUND_SUBDIVISION = 32

Copy the code

Here are the steps.


1. Build a level ground

Check out the documentation of babylu.js and call the API to create it. The code is simple and can be directly posted below:

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

The above code uses the three constants GROUND_WIDTH, GROUND_HEIGHT, and GROUND_SUBDIVISION that were defined at the beginning of this article. The first two constants represent the width and height of the ground to be created, which are both 64. The coordinate system to which they belong is the clipped coordinate system. Since there are many coordinate systems used in WebGL, some students may not be very familiar with it, so it is recommended to read the basics of WebGL coordinate system in this article. As for the constant GROUND_SUBDIVISION, it refers to the number of sections to divide an edge of the rectangle. In this chapter, both x and Z directions of the ground are divided into 32 sections.

A simple line of code can create a flat area, look at the effect:



2. Texture the earth

Complex features always evolve from simple features. First, the simplest step is to attach the texture to the land we just created. Find a random picture of the grass and check the material and texture of the Babylon. First, attach a material to the ground and then map it on the material as follows:

// Create a standard material
const groundMaterial = new StandardMaterial('groundMaterial', scene);
// Create a texture
const groundTexture = new Texture('//storage.360buyimg.com/model-rendering-tool/files/jdLife/grass.jpg', scene)
// Assign the texture to the diffuse texture of the material (this can also be other textures)
groundMaterial.diffuseTexture = groundTexture
// Assign the material to the ground material property
ground.material = groundMaterial
Copy the code

The ground now has a texture map:



3. Divide the floor into grids

In the previous step, although the ground texture has been implemented, but the effect is definitely not to meet the predetermined requirements. If you are familiar with WebGL, you will know that if you want to attach a specific texture to a specific position of the material, you need to obtain the vertex data on the material, and then put a pushpin on it. Then in the texture map, according to the corresponding pushpin points of the vertex, you can obtain the position of the required image. This should be a relatively complex operation, and the package of Babylon. Js is relatively deep, if directly implemented by violence, it will be a relatively large project.

So, with the suspicion that Babyl.js has a wrapped class that can fulfill this requirement (or with a few modifications), I go back to its documentation and find a similar example. To make it easier to read, cut the renderings and show them directly:

Taking a look at the code for this example, it can be summarized as follows:

  1. Use bobabylon. JsAdvancedDynamicTexture.CreateForMeshAdvanced Dynamic Texturing creates textures for the ground.
  2. Advanced dynamic textures provides addControl methods to add various “containers” to textures.
  3. “Container” is a class Container of Babysurb.js.
  4. “Container” also has addControl method, can continue to add “container” in the “container”, that is, can “nesting doll”.

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

// First call the AdvancedDynamicTexture API to create the texture
const groundTexture = GUI.AdvancedDynamicTexture.CreateForMesh(ground, TEXTURE_WIDTH, TEXTURE_HEIGHT, false)

// Create the outermost Container panel with the same width and height as the texture
const panel = new GUI.StackPanel()
panel.height = TEXTURE_HEIGHT + 'px'
panel.width = TEXTURE_WIDTH + 'px'

// Add a panel to the texture
groundTexture.addControl(panel)

// Create a Row loop and add it to the panel
for (let i = 0; i < GROUND_SUBDIVISION; i++) {
  const row = new GUI.StackPanel()
  row.height = TEXTURE_HEIGHT / GROUND_SUBDIVISION + 'px'
  // Add row to panel
  panel.addControl(row)
  row.isVertical = false
  // Create a grid in each row of the loop
  for (let j = 0; j < GROUND_SUBDIVISION; j++) {
    const block = new GUI.Rectangle()
    block.width = TEXTURE_WIDTH / GROUND_SUBDIVISION + 'px'
    row.addControl(block)     
  }
}
Copy the code

The code uses TEXTURE_WIDTH and TEXTURE_HEIGHT, which represent the width and height of the texture, respectively. For those who know more about texture size, please refer to WebGL texture Detail 3: Texture Size and Mipmapping. We won’t go into details here.

Take a look at this:



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

Image here refers to a class in Babybabylon. Js, which will be referred to as Image for convenience. There is no need to explain why each grid is given a separate map. The reason for storing the texture Image object for each grid is to make it easier to modify the texture later. Since these cells are created in a loop, they already have a certain order, so just push them into an array at creation time (blockImageArray) and pass in the indexes in the order they were created.

When implementing, or to implement the simplest first, let each grid texture is the same. Add the following code based on the previous step:

.// Create a grid in each row of the loop
  for (let j = 0; j < GROUND_SUBDIVISION; j++) {
    const block = new GUI.Rectangle()
    block.width = TEXTURE_WIDTH / GROUND_SUBDIVISION + 'px'
    row.addControl(block)
    // Hide the border of the grid
    block.thickness = 0
    // Create an Image object
    const blockImage = new GUI.Image('blockTexture'.'//storage.360buyimg.com/model-rendering-tool/files/jdLife/grass.jpg')
    // Add the image to the block
    block.addControl(blockImage)
    // Create blockImageArray in the outer domain
    blockImageArray.push(blockImage)
  }
Copy the code

It is worth noting that the above code dynamically imports the Image object directly through the URL when it is created. This will cause a request to be sent each time an Image is created, which is obviously a performance problem. So, look again at the documentation of babylu.js for optimizations. Image has a domImage property of type HTMLImageElement. You can modify this property to modify the Image content. So simply load the Image to generate the HTMLImageElement and store it in the imageSource. Assign its domImage attribute when creating the Image. Optimized code:

// Import the required images and place them in imageSource
const imageSource : { [key: string]: HTMLImageElement } = { grass: img, stone: img }

...
// No longer pass the URL parameter when creating an image
const blockImage = new GUI.Image()
// Assign the domImage attribute
blockImage.domImage = imageSource.grass
Copy the code

Now look at the effect:



5. Change the texture

After the operation in the previous step, we have created a well-formed green space. What we need to do next is the function of texture replacement. First implement the simplest: listen to the click event in the outermost panel, judge the current click is the row and column of the ground according to the click position, and then find the element corresponding to blockImageArray and re-assign its domImage. The code is as follows:

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
})
Copy the code

See how it looks now:

At this point, you have achieved the goal of creating a ground that can be textured.


Implementation of model placeholder calculation

Noun:

  • Ground: the plane in the case
  • Model: Objects that need to be computed in the case
  • Index number: The subscript of a two-dimensional array
  • Grid coordinate system: A coordinate system that divides the ground into equal grids
  • WebGL coordinate system: original WebGL coordinate system
  • Model base point: model origin
  • Conversion: Conversion from one value to another
  • Correction: Add a positive offset to the original coordinate value (here is the length or width of half a cell)
  • Bounding box: Smallest rectangular bounding boxes that can enclose the entire model

Flow chart: www.processon.com/view/link/6…

As a result of the implementation in the previous article, you have now created a floor and divided the floor into several grids. In order to obtain the model’s position on the ground, it is necessary to convert the data to obtain the grid occupied by the model on the ground. The image below shows the size of a model (house) on the ground (the borders of the occupied cells are shown in red) :

For the sake of intuition, we will divide the ground into 8 by 8 grids in the following description.

Here are the constants involved:

// Redefine the floor as an 8 × 8 grid
const GROUND_SUBDIVISION = 8

// The width of each grid in the camera clipping coordinate system
const PER_BLOCK_VEC_X = GROUND_WIDTH / GROUND_SUBDIVISION
// The height of each grid in the camera clipping coordinate system
const PER_BLOCK_VEC_Z = GROUND_HEIGHT / GROUND_SUBDIVISION

// The offset of the model position vector in the X-axis
const CENTER_OFF_X = PER_BLOCK_VEC_X / 2
// The z-axis offset of the model position vector
const CENTER_OFF_Z = PER_BLOCK_VEC_Z / 2

// The width of half a grid in the camera clipping coordinate system
const HALF_BLOCK_VEC_X = PER_BLOCK_VEC_X / 2
// The height of half a grid in the camera clipping coordinate system
const HALF_BLOCK_VEC_Z = PER_BLOCK_VEC_Z / 2
Copy the code

To know exactly which grids are occupied by the model on the ground, the ground grid coordinate system must be established first. Remember from the previous article that the grid was generated through two for loops, but when the grid was generated, the index was also generated. To make it easier to read, I’ll post the code to generate the grid:

for (let i = 0; i < GROUND_SUBDIVISION; i++) {
  const row = new GUI.StackPanel()
  row.height = TEXTURE_HEIGHT / GROUND_SUBDIVISION + 'px'
  // Add row to panel
  panel.addControl(row)
  row.isVertical = false
  // Create a grid in each row of the loop
  for (let j = 0; j < GROUND_SUBDIVISION; j++) {
    const block = new GUI.Rectangle()
    block.width = TEXTURE_WIDTH / GROUND_SUBDIVISION + 'px'
    row.addControl(block)     
    // Create a map
    const blockImage = new GUI.Image()
    // Assign the domImage attribute
    blockImage.domImage = imageSource.grass
    block.addControl(blockImage)
    blockImageArray.push(blockImage)
  }
}
Copy the code

In other words, 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 (each grid corresponds to one coordinate), and the data structure of the index number is as follows:

interface Coord {
  x: number.z: number
} 
Copy the code

In the 8*8 grid coordinate system:

Does this picture make sense? It looks like the 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 the grid coordinate system by creating a two-dimensional array. In this way, the coordinates of the ground grid can be used as an index to find the corresponding value in the two-dimensional array to determine whether there is a model placeholder on the grid.


1. Create grid coordinate set array: groundStatus

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[] []Copy the code

Each element in a two-dimensional array corresponds to a grid coordinate system. The initial value corresponding to each coordinate is 0, indicating that the current coordinate is not occupied. When a model is placed on it, the value is +1. Value -1 when the model is removed or deleted. The reason why Boolean is not used as the storage type is that Boolean has only true and false states, which cannot meet more complex requirements. For example, when models are moved, there will be two models on the corresponding grid of groundStatus when models overlap. If you write it in Boolean, you can’t write it because it has only true and false. But if you use number, you can change the value of the corresponding element of the cell to 2, indicating that there are two model placeholders on the single front cell.

In the design of groundStatus index, x or Z coordinates are used as one-dimensional index, which has little impact on performance. For debugging purposes, it is recommended to use the Z-coordinate as a one-dimensional index, so that the browser console’s two-dimensional array can be displayed in one-to-one mapping with the grid coordinate system.



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

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 obeys the WebGL coordinate system. For example, when the value of position is (0, 0, 0), the position that appears on the ground is the center point of the ground. I’ll call it, for convenience, the basis of the model.

Image Source: Base points or insertion points for editing 3D models in InfraWorks

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

Yellow square, represent the grid that dot occupies on the ground. If the actual space is only 1 grid, the minimum space is also 4 grids when the position is rounded up. Once collision detection and other functions are involved, the problem of model space is too large. Therefore, we need to offset the center point to make the model’s occupying position in the grid coordinate system as close as possible to the real occupying position of the model. We can offset x and Z by half a grid unit in the lower right corner. At this time, the corresponding base point coordinate of (0, 0, 0) is the center point of (4, 4) grid. The principle of migration is to ensure that the base point of the model can fall on the midpoint of a grid coordinate system, so as to calculate the model space more accurately. The diagram below:

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

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 }
}
Copy the code


3. Obtain key data of model placeholder in WebGL coordinate system

This step is to obtain the actual placeholder related data of the model and prepare for the subsequent grid coordinate placeholder conversion.

The model has the concept of minimum bounding box, also called minimum bounding box, which is used to define the geometric elements of the model. Bounding boxes/bounding boxes can be rectangles or more complex shapes. For the convenience of description, we use rectangular bounding boxes/bounding boxes here. Hereafter referred to as the containment box.

Image credit: 3D Collision Detection

When we project the model of WebGL coordinate system onto the grid coordinate system, we can get a region:

The yellow area represents the placeholder area of the model and the black dot is the base point of the model. Babibs.js provides an API to calculate the distance between the bounding box of the model and the base point, where values are based on WebGL coordinates.

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

interface RawOffsetMap {
  rawOffsetTop: number
  rawOffsetBottom: number
  rawOffsetLeft: number
  rawOffsetRight: number
}
Copy the code

The calculation code is as follows:

/* @param {AbstractMesh[]} meshes @param {Vector3} scale */
function getRawOffsetMap(meshes: AbstractMesh[], scale: Vector3 = new Vector3(1.1.1)) :RawOffsetMap {
  // Declare the smallest vector
  let min = null
  // Declare the largest vector
  let max = null
  
  // Iterate over the model's meshes array
  meshes.forEach(function (mesh) {
    // The API provided by Babylon. Js allows you to iterate over the mesh and all submeshes of the mesh to find their boundaries
    const boundingBox = mesh.getHierarchyBoundingVectors()

    // If the current minimum vector does not exist, assign the min property of the current mesh to it
    if (min === null) {
      min = new Vector3()
      min.copyFrom(boundingBox.min)
    }

    // If the current maximum vector does not exist, assign it the Max property of the current mesh's boundingBox
    if (max === null) {
      max = new Vector3()
      max.copyFrom(boundingBox.max)
    }

    // Compare and reassign the minimum vector and the current min property of boundingBox from x, y, and z components
    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

    // Compare and reassign the Max vector and the current boundingBox's Max property from x, y, and z components
    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)
  }
}
Copy the code


4. Obtain key data of model placeholder in grid coordinate system: offsetMap

This step is to convert the key placeholder data in the WebGL coordinate system of the model into data in the grid coordinate system.

As shown in the figure above, the yellow grid represents the grid where the model is based. The red color is the space occupied by the model after the transformation of the grid coordinate system. When the space occupied by the model boundary is less than one space (for example, it occupies only half of the grid), it is counted as the full space. We use the offsetMap object to store these four data:

interface OffsetMap {
  offsetLeft: number.offsetRight: number.offsetTop: number.offsetBottom: number
} 
Copy the code

In the previous section, we calculated the rawOffsetTop, rawOffsetBottom, rawOffsetLeft, and rawOffsetRight of the model. Now we just need to convert these key values into the corresponding key values of offsetMap.

In the figure above, the yellow area is the placeholder of the model in the WebGL coordinate system, and the red area is the set of the grid occupied in the grid coordinate system after the model placeholder is rounded up. Fields in rawOffsetMap and offsetMap are converted as follows: rawOffsetLeft corresponds to offsetLeft. RawOffsetRight corresponds to offsetRight; RawOffsetTop corresponds to offsetTop; RawOffsetBottom corresponds to offsetBottom. Take converting rawOffsetLeft to offsetLeft as an example, subtract the width of half a cell (HALF_BLOCK_VEC_X) from rawOffsetLeft, then divide by the width of one cell (PER_BLOCK_VEC_X) and 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
  }
}
Copy the code


5. Bounding box index of the model in grid coordinate system is calculated: bounding box index

In this step, we will calculate the index subscript of the model 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 groundStatus data.

Bounding’s data structure is as follows:

interface Bounding {
  minX: number.maxX: number.minZ: number.maxZ: number
} 
Copy the code

First of all, we need to explain what the four values in the bounding object are:

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

Based on offsetMap data obtained in Step 4 and 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
  }
}
Copy the code

At this point, the bounding calculation 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, only the bounding value is used to assign the groundstatus, and the code is as follows:

// index boundary judgment
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]++
    }
  }
}
Copy the code


Subsequent items to be optimized

The ground of the project is a flat piece of land without considering depth information. If the ground is undulating, the current data structure is not adequate. If it is a scene of stepped height (the ground is composed of N flat ground with different heights), then the data structure of the elements of the groundStatus array should be modified to add the attribute of ground height identifier to meet the requirements. However, if it is the kind of terrain with rugged and slope, it is difficult to carry out reconstruction.


The finished product to show


Refer to the link

  • Explanation # 3: WebGL texture texture size and Mipmapping – www.jiazhengblog.com/blog/2016/0…
  • WebGL Coordinate System basics – juejin.cn/post/689079…
  • Babiel.js website – www.babylonjs.com/