Benthic Logo

Optional

This component is an optional feature in the metaverse_client crate. Projects that don't require this can compile with the feature disabled in Cargo.toml

Terrain

After completing login, the server will begin sending LayerData packets to the client. LayerData packets contain several patches, which are described by the initial Layer body header. Packet parsing specifics can be found on docs.rs.
Unlike most other packets, the packet parsing only parses the packet header, and the body header of the LayerData object. and stops when it gets to the body data. This is because the body parsing is very complex, and should be handled asyncronously to avoid creating a bottleneck.

1.

The LayerData is received from the sever from the udp_read function

2.

The LayerData packet is parsed by the PacketData::from_bytes(), determining the stride, size, and type of the contained layers.

3.

The LayerData packet is received by the mailbox here, without fully parsing the body yet.

4.

parse_layer_data is called on the unstructured body data

After the layer's Type is determined, the environment crate can begin the more complicated parsing.
Evil Benthic

LayerType

The server represents layer types as numbers. Some of them represent letters that make sense, but other seem to be picked at random. The representation goes as follows:
Land 76
LandExtended 77
Water 87
WaterExtended 88
Wind 55
WindExtended 57
Cloud 56
CloudExtended 58

Land From Packet

Now that we have determined the type of the patch, we can begin work towards generating 3d models that our UI can display. The from_packet() function handles parsing the patch data from the unstructured data.
At the beginning of each land patch is a a header. They all follow the same format, and are not compressed with the rest of the layer data. The header is parsed using the TerrainHeader::from_bytes() function.

Terrain Header

Quantized world bits Used to determine if we have reached the end of patches, along with determining the world_bits.
DC offset Scales the decompressed data back to a real-world value
Range a multiplier used for decompression
Patch IDs 4 or 10 bytes that will be used to determine the XY location.

Body:

Compressed layer data

It reads until it reaches the end of patches flag.
Evil Benthic

Patch ID

The handling of the PatchID field is extremely confusing. For some reason, this field can be is 32 bits if it is an extended patch, and 10 bits if it is not. The binary is read into a buffer, with the first half of the bytes being used as the x value, and the second half of the bytes being used as the y value.
However, the binary is laid out in big-endian format, which makes this very difficult to see. you might have raw data that looks like this after removing the headers from the packet
    
Bytes:     0    0    0   164  65   1    0    8   96 ...
           |    |    |    |   |    |    |    |    |
           |    |    |    |   |    |    |    └────└─ [8, 96] patch ID bytes
           |    |    |    |   |    └────└─ [1,0] range (2 bytes)
           |    └────└────└───└─[0, 0, 164, 65] f32 DC offset (4 bytes)
           └─[0] quantized_world_bits (1 byte)
      
    
Read as u8s, the values are
8 00001000
96 01100000
To handle 10 bytes, you read the whole of the first value, and the first two bits of the second.
00001000 01
Because it is big-endian, you have to move the two bits to the beginning of the data and then parse as a u32.
01 00001000
The resulting XY values come out to
0100 x:8
01000 y:8
Transcribed from the docs.rs page.
I have to be so for real with you right now, this is one of the most fucked up things I have ever seen in my life. All of this to save 3 bits in transport.

Parse Heightmap

Now that the TerrainHeader is taken care of, the next step is to handle the bytes that follow it.
Parse_heightmap() is called, which runs an algorithm designed by secondlife to decompress terrain. Not all terrain patches are the same size, but they can be read if the algorithm is correct.
  1. Loop over every point in the patch
  2. If there is 0 at the first position checked, that means this point is zero height, and no heights need to be decoded.
  3. If there is a 0 at the second position checked, that means the following data is all zero, and exit the loop, writing the rest of the array to zero.
  4. If we have read up to the third bit, that means one of the first two is not zero. The third bit contains the sign information, to determine if it is a positive or negative height.
  5. Following this, we read as many bits as is specified by the terrain header's world_bits, and decode those in the same way we decoded the PatchID.
  6. apply the sign to the decoded height.

Decompress Heightmap

Now with the data parsed, we can decompress the heightmap data. The LayerData is encoded in a way that is similar to JPEG compression. Instead of storing data in rows, it stores it in a zig-zag pattern. build_copy_matrix stores where those values are in the zigzag. for example: I have unencoded data
    
 a   b   c   d   e   f   g   h   i   j   k...
 0   1   2   3   4   5   6   7   8   9   10...
    
  
this would be encoded as
    
 a   b   f   g   n   o   aa  bb  rr  ss ...
 0   1   5   6   14  15  27  28  44  45 ...
    
  
the copy matrix contains the unencoded data's location.
encoded[3] would be g
copy_matrix[3] would be 6
by reading through the encoded data and the copy matrix at the same time, the data's original locations can be determined and reconstructed. This matrix is identical for each xy value it is calculated for, and is created once at compile time.
The first part of the function defines constants that will be usd to dequantize the data, returning it to its uncompressed form.

A similiar thing is done with the dequantize table. The LayerData packets are quantized and compressed. Before being sent from the server, they were divided by a certain factor in order to make the bytes small enough to send with the packet. sending floating point f32s would create an enormous packet, so the server divides each point by a factor defined by the quantize table, which is built identically on the client and server side. by multiplying the point by its corresponding factor, you can return the compressed data back to its f32 representation.

Stitch Patches

When patches are retrieved, there is a gap between them that has to be manually stitched by the client. This is to allow rendering two patches from different servers next to each other seamlessly.
In order to fill this gap, the client needs to have retreived all of the tiles to the right, to the left, and to the upper top corner between them.
This is done in the core crate. When it parses a Land patch, it inserts it the patch cache. Unrendered land patckets are added to the patch queue and the total patch list. If the North, East and Top Corner exist in the total patch list, generate mesh retrieves them, and begins the stitching.
Generate_mesh_with_indices() follows a simple algorithm to convert the heightmap data into vertices with corresponding indices. Once the mesh has been created, stitch_patches_with_indices() is called, which adds the geometry between the gaps in the patches.

this is then sent to the UI as raw JSON vertices, allowing the UI to apply its own shaders to the land.