WebGPU + Wasm + Rust > building mmo-ready procedural trees using Ambient engine (part 2/3)

Emmanuel BOTROS YOUSSEF
7 min readJun 6, 2023
pseudo randomness is everywhere in this scene. It will be covered in part 3 ;)

Welcome to the second part of our series on building MMO-ready procedural trees using WebGPU, WebAssembly (Wasm), and Rust with the Ambient engine. In this article, we will dive into the code and explain the generative system that handles tree creation, with a focus on the branching strategy. If you haven’t read the first part, we recommend checking it out to get a better understanding of the overall context. part 3 is dedicated to randomness and how it can help us building up vast realistic ecosystems.

TreeMesh Struct

First, let’s take a look at the TreeMesh struct, which represents the parameters of a tree mesh:

#[derive(Clone)]
pub struct TreeMesh {
pub seed: i32,
pub trunk_radius: f32,
pub trunk_height: f32,
pub trunk_segments: u32,
pub branch_length: f32,
pub branch_angle: f32,
pub branch_segments: u32,
pub foliage_radius: f32,
pub foliage_density: u32,
pub foliage_segments: u32,
}

This struct holds various properties of the tree, such as the seed, trunk radius and height, trunk segments, branch length and angle, foliage radius, density, and segments.

Creating the Tree Mesh

The create_tree function takes a TreeMesh object as input and returns a mesh_descriptor::MeshDescriptor, which represents the final tree mesh:

pub fn create_tree(mut tree: TreeMesh) -> mesh_descriptor::MeshDescriptor {
// Create the trunk
let (mut vertices1, top_vertices1, mut normals1, mut uvs1, _trunk_direction, mut indices) =
build_tree(&mut tree);

// Transform the vertices into the desired format
let mut vertices: Vec<Vertex> = Vec::with_capacity(vertices1.len());
for i in 0..vertices1.len() {
// Perform necessary calculations and transformations on each vertex
// ...
vertices.push(v);
}
mesh_descriptor::MeshDescriptor { vertices, indices }
}

The build_tree function is responsible for generating the tree's trunk and branches based on the provided TreeMesh parameters. It returns the necessary data for creating the mesh:

fn build_tree(tree: &mut TreeMesh) -> (Vec<Vec3>, Vec<Vec3>, Vec<Vec3>, Vec<Vec2>, Vec3, Vec<u32>) {
// Initialization and setup
// ...

// Generate trunk vertices and indices
let (trunk_positions, trunk_radii) = build_tree_ramification(
// Parameters and data for generating the trunk
// ...
);
// Generate branches and additional trunk segments
for i in 0..tree.trunk_segments {
// Determine whether a branch should be created at this segment
// ...
// Generate branch data if required
if branch_chance > 0.2 {
// Generate branch direction, position, radius, etc.
// ...
// Calculate trunk slope and branch rotation
let trunk_slope = calculate_trunk_slope(tree, branch_position);
let branch_rotation = calculate_branch_rotation(trunk_slope);
// Generate branch vertices and indices
build_tree_ramification(
// Parameters and data for generating the branch
// ...
true, // Indicate that this is a branch
branch_rotation,
);
}
}
// Return the generated data
(
vertices,
top_vertices,
normals,
uvs,
trunk_direction,
indices,
)
}

The build_tree_ramification function is the core of the tree generation process. It constructs a segment (trunk or branch) of the tree using the provided parameters. It returns the positions and radii of the center vertices:

fn build_tree_ramification(
// Parameters and data for generating a tree segment
// ...
) -> (Vec<Vec3>, Vec<f32>) {
// Iterate over the segments and create vertices, normals, etc.
// ...

// Return the generated center positions and radii
(
center_positions,
center_radii,
)
}

Branching Strategy

The branching strategy involves determining when and where branches should be created on the tree. Let’s take a closer look at the relevant code:

for i in 0..tree.trunk_segments {
let branch_chance = tooling::gen_rn(tree.seed + i as i32, 0.0, 1.0) * i as f32;
let tpos = trunk_positions[i as usize];
let trad = trunk_radii[i as usize];
if branch_chance > 0.2 {
// Generate branch direction
let branch_direction = vec3(
tooling::gen_rn(tree.seed + i as i32 + 1, -1.0, 1.0),
tooling::gen_rn(tree.seed + i as i32 + 2, -1.0, 1.0),
tooling::gen_rn(tree.seed + i as i32 + 3, 0.0, 1.0),
)
.normalize();

// Calculate branch position and radius
let branch_position = tpos
+ trunk_direction
* ((i as f32 + 0.5) / tree.trunk_segments as f32)
* tree.trunk_height;
let branch_radius = trad * 0.7;

// Update the tree's seed
tree.seed += 4;

// Calculate trunk slope and branch rotation
let trunk_slope = calculate_trunk_slope(tree, branch_position);
let branch_rotation = calculate_branch_rotation(trunk_slope);
// Generate branch vertices and indices
build_tree_ramification(
// Parameters and data for generating the branch
// ...
true, // Indicate that this is a branch
branch_rotation,
);
}
}

In this code, we iterate over the trunk segments and calculate a branch chance based on the segment index and a random value. If the branch chance exceeds a threshold (0.2 in this case), a branch is created. The branch direction is determined using random values within certain ranges. The branch position is calculated based on the current trunk segment’s position, direction, and height. The branch radius is a fraction (0.7) of the trunk radius.

The tree’s seed is updated to ensure randomization for each branch. The trunk slope is calculated using the calculate_trunk_slope function, and the branch rotation is determined based on the trunk slope using the calculate_branch_rotation function.

Finally, the build_tree_ramification function is called to generate the branch vertices and indices based on the provided parameters.

Vertices

The vertices of a procedural tree represent the 3D positions of its surface points. In our implementation, we use the vertices vector to store these positions. Let's take a closer look at how the vertices are generated in the code:

let mut vertices: Vec<Vertex> = Vec::with_capacity(vertices1.len());

for i in 0..vertices1.len() {
let px = vertices1[i].x;
let py = vertices1[i].y;
let pz = vertices1[i].z;
let _u = uvs1[i].x;
let _v = uvs1[i].y;
let nx = normals1[i].x;
let ny = normals1[i].y;
let nz = normals1[i].z;

let v = mesh::Vertex {
position: vec3(px, py, pz) + vec3(-0.5 * SIZE_X, -0.5 * SIZE_Y, 0.0),
normal: vec3(nx, ny, nz),
tangent: vec3(1.0, 0.0, 0.0),
texcoord0: if i < vertices1.len() as usize {
// Trunk UVs (bottom half of the texture)
let u = uvs1[i].x;
let v = uvs1[i].y * 0.5;
vec2(u, v)
} else {
// Foliage UVs (upper half of the texture)
let u = uvs1[i].x;
let v = uvs1[i].y * 0.5 + 0.5;
vec2(u, v)
},
};
    vertices.push(v);
}

In this code snippet, we iterate over the initial vertices vertices1 and populate the vertices vector with Vertex objects. Each Vertex consists of a position, normal, tangent, and texture coordinate.

The position of the vertex is calculated by adding the x, y, and z coordinates to an offset (vec3(-0.5 * SIZE_X, -0.5 * SIZE_Y, 0.0)) that centers the tree mesh.

The normal of the vertex is set using the corresponding values from normals1.

The tangent is set to a constant value (vec3(1.0, 0.0, 0.0)), which represents the direction along the x-axis.

The texture coordinate (texcoord0) is determined based on whether the vertex belongs to the trunk or foliage. For vertices belonging to the trunk (i < vertices1.len()), the u-coordinate remains the same (uvs1[i].x), and the v-coordinate is scaled to occupy the bottom half of the texture (uvs1[i].y * 0.5). For foliage vertices, the v-coordinate is scaled to occupy the upper half of the texture (uvs1[i].y * 0.5 + 0.5).

Finally, the generated Vertex objects are added to the vertices vector.

Indices

Indices represent the connectivity between vertices, forming triangles that define the surfaces of the tree mesh. The indices vector stores these indices. Let's explore how the indices are generated in the code:

// For branches, create additional faces connecting the segments
if is_branch {
if j < sectors {
indices.push(current_index);
indices.push(next_index);
indices.push(next_row_index);

indices.push(next_row_index);
indices.push(next_index);
indices.push(next_row_next_index);
}
} else {
// For trunk, create faces as usual
if j < sectors {
indices.push(current_index);
indices.push(current_index + 1);
indices.push(next_row_index);
        indices.push(next_row_index);
indices.push(current_index + 1);
indices.push(next_row_next_index);
} else {
// Handle the last sector of the trunk segment
let trunk_current_index = trunk_vertices_start + current_index;
let trunk_next_row_index = trunk_current_index + vertices_per_row as u32;
indices.push(trunk_current_index);
indices.push(current_index);
indices.push(trunk_next_row_index);
indices.push(current_index);
indices.push(next_row_index);
indices.push(trunk_next_row_index);
}
// Create additional faces connecting the segments
if i + 1 < segments {
indices.push(current_index);
indices.push(next_row_index);
indices.push(current_index + vertices_per_row as u32);
indices.push(next_row_index);
indices.push(next_row_next_index);
indices.push(current_index + vertices_per_row as u32);
}
}

This code snippet demonstrates the logic for generating indices based on whether the current segment is a branch or part of the trunk.

For branches (is_branch is true), additional faces are created to connect the segments. Triangles are formed by adding indices in a specific order: current_index, next_index, and next_row_index for the first triangle, and next_row_index, next_index, and next_row_next_index for the second triangle.

For the trunk (is_branch is false), faces are created as usual. The order of indices is similar to the branch case. However, there is an additional case to handle the last sector of the trunk segment. The indices are adjusted by adding an offset (trunk_vertices_start) to ensure correct indexing. This is necessary because the trunk vertices are appended to the vertices vector separately.

The code also creates additional faces to connect segments by considering the neighboring rows. These indices ensure a smooth transition between segments.

UVs (Texture Coordinates)

UV coordinates map points on the tree mesh to corresponding positions on a texture. The uvs vector stores these UV coordinates. Let's examine how the UV coordinates are generated in the code:

uvs.push(vec2(j as f32 / sectors as f32, z / height));

In this code snippet, the UV coordinate is determined by dividing the current sector index (j) by the total number of sectors (sectors). This normalizes the value to the range of [0, 1], ensuring the UV coordinates cover the entire texture horizontally.

The v-coordinate is obtained by dividing the current height (z) by the total height of the segment (height). This normalization ensures that the UV coordinates vary vertically along the segment.

Normals

Normals represent the direction perpendicular to the surface of the tree mesh at each vertex. The normals vector stores these normals. Let's explore how the normals are calculated in the code:

normals.push(vec3(x, y, z).normalize());

In this code snippet, the normal of each vertex is calculated by normalizing a vector with components x, y, and z. These values correspond to the x, y, and z coordinates of the vertex. Normalizing the vector ensures that the resulting normal has a length of 1, preserving the direction while removing any scaling effect.

By storing the calculated normals in the normals vector, we ensure that the lighting and shading on the tree mesh appear realistic when rendered.

Conclusion

In this article, we explored the code responsible for creating procedural trees using the Ambient engine, WebGPU, Wasm, and Rust. We examined the TreeMesh struct and the process of generating tree meshes, with a focus on the branching strategy.

In the next part of this series, we will dive into pseudo randomness, so that each tree is very unique ! also, we’ll see the process of rendering and optimizing the generated tree meshes using WebGPU. Stay tuned for more exciting insights!

Thank you for reading and happy coding!

GO TO part 3 to learn more about randomness :)

--

--