Fractals to Forests – Creating Realistic 3D Trees with Three.js


Ever since I started programming at the young age of 14, I’ve been fascinated by how code can be used to simulate the real world. In my sophomore year of university, I challenged myself with trying to come up with an algorithm that would generate a 3D model of a tree. It was a fun experiment that yielded some interesting results, but ultimately the code was relegated to the dark recesses of my external drive.

Fast forward a few years, I re-discovered that original code and decided to port it to JavaScript (along with a few enhancements!) so I could run it on the web.

The result is EZ-Tree, a web-based application where you can design your own 3D tree and export it to GLB or PNG to be used in your own 2D/3D projects. You can find the GitHub repo here. EZ-Tree utilizes Three.js for 3D rendering, which is a popular library that sits on top of WebGL.

In this article, I’ll be breaking down the algorithms I use to generate these trees, explaining how each part contributes to the final tree model.

What is procedural generation?

First, it might help to understand exactly what procedural generation is.

Procedural generation is simply creating “something” from a set of mathematical rules. Using a tree as our example, one of the first things we observe is a tree has a trunk which splits into one or more branches, and each of those branches split into one or more branches, and so on, finally terminating in a set of leaves. From a math/computer science perspective, we would model this as a recursive process.

Let’s keep going with our example.

If we look at a single branch of a tree in nature, we can notice a few things

  • A branch has a smaller radius and length than the branch it originates from.
  • The thickness of the branch tapers towards its end.
  • Depending on the type of tree, branches can be straight or twist and turn in all directions.
  • Branches tend to grow towards sunlight.
  • As branches extend out horizontally from the tree, gravity tends to pull them down towards the ground. The amount of force can depends on the thickness of the branches and number of leaves.

All of these observations can be codified into their own mathematical rules. We can then combine all of rules together to create something that looks like a tree branch. This is a concept called emergent behavior, where many simple rules can be combined together to create something more complex than the individual parts.

L-Systems

One area of mathematics that attempts to formalize this type of natural process is called Lindenmayer systems, or more commonly L-systems. L-Systems are a simple way to create complex patterns, often used for simulating the growth of plants, trees, and other natural phenomena. They start with an initial string (called an axiom) and apply a set of rules repeatedly to rewrite the string. These rules define how each part of the string transforms into a new sequence. The resulting string can then be turned into visual patterns using drawing instructions.

While the code I am about to show you does not use L-systems (I simply wasn’t aware of them at the time), the principles are very similar and both are based on recursive processes.

Examples of trees generated using L-systems (Source: Wikipedia)

Enough theory, let’s dive into the code!

The Tree Generation Process

The tree generation process begins with the generate() method. This method initializes the data structures for storing the branch and leaf geometry, sets up the random number generator (RNG), and starts the process by adding the trunk to the branch queue.

// The starting point for the tree generation process
generate() {
  // Initialize geometry data
  this.branches = { };
  this.leaves = { };

  // Initialize RNG
  this.rng = new RNG(this.options.seed);

  // Start with the trunk
  this.branchQueue.push(
    new Branch(
      new THREE.Vector3(),              // Origin
      new THREE.Euler(),                // Orientation
      this.options.branch.length[0],    // Length
      this.options.branch.radius[0],    // Radius
      0,                                // Recursion level
      this.options.branch.sections[0],  // # of sections
      this.options.branch.segments[0],  // # of segments
    ),
  );

  // Process branches in the queue
  while (this.branchQueue.length > 0) {
    const branch = this.branchQueue.shift();
    this.generateBranch(branch);
  }
}

Branch Data Structure

The Branch data structure holds the input parameters required for generating a branch. Each branch is represented using the following parameters:

  • origin – Defines the starting point of the branch in 3D space (x, y, z).
  • orientation – Specifies the rotation of the branch using Euler angles (pitch, yaw, roll).
  • length – The overall length of the branch from base to tip
  • radius – Sets the thickness of the branch
  • level – Indicates the depth of recursion, with the trunk beging at level 0.
  • sectionCount – Defines how many times trunk is subdivided along its length.
  • segmentCount – Controls the smoothness by setting the number of segments around the trunk’s circumference.

Understanding the Branch Queue

The branchQueue is a crucial part of the tree generation process. It holds all of the branches waiting to be generated. The first branch is pulled from the queue and its geometry is generated. We then recursively generate the Branch objects for the child branches and add them to the queue to be processed later on. This process continues until the queue is exhausted.

Generating Branches

The generateBranch() function is the heart and soul of the tree generation process. This contains all of the rules needed to create the geometry for a single branch based on the inputs contained in the Branch object.

Let’s take a look at the key parts of this function.

A Primer on 3D Geometry

Before we can generate a tree branch, we first need to understand how the 3D geometry is stored in Three.js.

When representing a 3D object, we typically do so using indexed geometry, which optimizes rendering by reducing redundancy. The geometry consists of four main components:

  1. Vertices – A list of points in 3D space that define the shape of the object. Each vertex is represented by a THREE.Vector3 containing its x, y, and z coordinates. These points form the “building blocks” of the geometry.
  2. Indices – A list of integers that define how the vertices are connected to form faces (typically triangles). Instead of storing duplicate vertices for each face, indices reference the existing vertices, significantly reducing memory usage. For example, three indices [0, 1, 2] form a single triangle using the first, second, and third vertices in the vertex list.
  3. Normals – A “normal” vector describes how the vertex is oriented in 3D space; basically, which way the surface is pointing. Normals are crucial for lighting calculations, as they determine how light interacts with the surface, creating realistic shading and highlights.
  4. UV Coordinates – A set of 2D coordinates that map textures onto the geometry. Each vertex is assigned a pair of UV values between 0.0 and 1.0 that determine how an image or material wraps around the surface of the object. These coordinates allow textures to align correctly with the geometry’s shape.

The generateBranch() function generates the branch vertices, indices, normals and UV coordinates section by section and appends the results to their respective arrays.

this.branches = {
  verts: [],
  indices: [],
  normals: [],
  uvs: []
};

Once all of the geometry has been generated, the arrays are combined together into a mesh which is the complete representation of the tree geometry plus its material.

Left: Wire-frame representation of a single branch; Right: The same branch with a simple flat lighting model applied

Looking at the above photo, we can see how the branch is composed of 10 individual sections along its length, with each section having 5 sides (or segments). We can tune specify the number of sections and segments of the branches to control the detail of the final model. Higher values produce a smoother model at the expense of performance.

With that out of the way, let’s dive into the tree generation algorithms!

Initialization

let sectionOrigin = branch.origin.clone();
let sectionOrientation = branch.orientation.clone();
let sectionLength = branch.length / branch.sectionCount;

let sections = [];

for (let i = 0; i <= branch.sectionCount; i++) {
  // Calculate section radius
  // Build section geometry
}

We begin by initialize the origin, orientation and length of the branch. Next, we define an array that will store the branch sections. Finally, we loop over each section and generate its geometry data. The sectionOrigin and sectionOrientation variables are updated as we loop over each section.

Branch Section Radius

To calculate the section’s radius, we start with the overall radius of the branch. If it is the last section of the branch, we set the radius to effectively zero since we want our tree branches to end in a point. For all other sections, we compute the how much to taper the branch based on the section’s position along the length of the branch (as we move closer to the end, the amount of taper increases) and multiply that by the previous section radius.

let sectionRadius = branch.radius;

// If last section, set radius to effectively zero
if (i === branch.sectionCount) {
  sectionRadius = 0.001;
} else {
  sectionRadius *=
    1 - this.options.branch.taper[branch.level] * (i / branch.sectionCount);
}

Build Section Geometry

Wireframe representation of geometry for a single section. The below code builds the triangle pair for each side of the cylinder. The ends of the cylinders are left open since they are hidden from view.

We now have enough information to build the section geometry This is where the math starts to get a bit complex! All you need to know is that we are using the previously computed section origin, orientation and radius to create each vertex, normal and UV coordinate.

// Create the segments that make up this section.
for (let j = 0; j < branch.segmentCount; j++) {
  let angle = (2.0 * Math.PI * j) / branch.segmentCount;

  const vertex = new THREE.Vector3(Math.cos(angle), 0, Math.sin(angle))
    .multiplyScalar(sectionRadius)
    .applyEuler(sectionOrientation)
    .add(sectionOrigin);

  const normal = new THREE.Vector3(Math.cos(angle), 0, Math.sin(angle))
    .applyEuler(sectionOrientation)
    .normalize();

  const uv = new THREE.Vector2(
    j / branch.segmentCount,
    (i % 2 === 0) ? 0 : 1,
  );

  this.branches.verts.push(...Object.values(vertex));
  this.branches.normals.push(...Object.values(normal));
  this.branches.uvs.push(...Object.values(uv));
}

sections.push({
  origin: sectionOrigin.clone(),
  orientation: sectionOrientation.clone(),
  radius: sectionRadius,
});

And that’s how you create the geometry for a single branch!

This alone doesn’t make for a very interesting tree, however. Much of what makes a procedurally generated tree look good is the rules around how sections are oriented relative to one another and the overall branch placement.

Let’s look at two parameters we use to make branches look more interesting.

Gnarly, Dude!

One of my favorite parameters is gnarliness. This controls how much a branch twists and turns. In my opinion, this has the biggest impact on making a tree feel “alive” instead of sterile and static.

Gnarliness can be expressed mathematically by controlling how much the orientation of one section deviates from the previous section. These difference accumulate over the length of the tree branch to produce some interesting results.

const gnarliness =
  Math.max(1, 1 / Math.sqrt(sectionRadius)) *
  this.options.branch.gnarliness[branch.level];

sectionOrientation.x += this.rng.random(gnarliness, -gnarliness);
sectionOrientation.z += this.rng.random(gnarliness, -gnarliness);

Looking at the expression above, we can see that the gnarliness is inversely proportional to the radius of the branch. This reflects how trees behave in nature; smaller branches tend to curl and twist more than larger branches.

We generate a random tilt angle within the range [-gnarliness, gnarliness] and then apply that to the orientation of the next section.

Use the Force!

The next parameter is the growth force, which models how trees grow towards the sunlight to maximize photosynthesis (it can also be used to model gravity acting on the branches, pulling them towards the ground). This is especially useful for modeling trees like aspen trees, which tend to have branches that grow straight up instead of away from the tree.

We define a growth direction (which you can think of as a ray pointing towards the sun) and a growth strength factor. Each section is rotated by a tiny bit to align itself along the growth direction. The amount it is rotated is proportional to the strength factor and inversely proportional to the branch radius, so smaller branches are affected more.

const qSection = new THREE.Quaternion().setFromEuler(sectionOrientation);

const qTwist = new THREE.Quaternion().setFromAxisAngle(
  new THREE.Vector3(0, 1, 0),
  this.options.branch.twist[branch.level],
);

const qForce = new THREE.Quaternion().setFromUnitVectors(
  new THREE.Vector3(0, 1, 0),
  new THREE.Vector3().copy(this.options.branch.force.direction),
);

qSection.multiply(qTwist);
qSection.rotateTowards(
  qForce,
  this.options.branch.force.strength / sectionRadius,
);

sectionOrientation.setFromQuaternion(qSection);

The above code utilizies quaternions to represent rotations, which avoid some issues that occur with the more commonly known Euler angles (pitch, yaw, roll). Quaternions are beyond the scope of this article, but just know they are a different way to represent how something is oriented in space.

Additional Parameters

Gnarliness and Growth Force aren’t the only tunable parameters available. Here’s a list of other parameters that can be tuned to control the growth of the tree

  • angle – This parameter sets the angle at which child branches grow relative to their parent branch. By adjusting this value, you can control whether the child branches grow steeply upward, almost parallel to the parent branch, or fan outward at wide angles, mimicking different types of trees.
  • children – This controls the number of child branches that are generated from a single parent branch. Increasing this value results in fuller, more complex trees with dense branching, while reducing it creates sparse, minimalist tree structures.
  • start – This parameter determines how far along a parent branch the child branches begin to grow. A low value causes the child branches to sprout closer to the base of the parent branch, whereas a higher value makes them appear closer to the tip, creating different growth patterns.
  • twist – Twist applies a rotational adjustment to the geometry of the branch around its axis. By modifying this value, you can introduce a spiral effect, which adds a dynamic, natural look to the branches, mimicking trees with twisted or curved growth patterns.

Can you think of any others you would add?

Generating Child Branches

After the branch geometry has been generated, it’s time to generate its child branches.

if (branch.level === this.options.branch.levels) {
  this.generateLeaves(sections);
} else if (branch.level < this.options.branch.levels) {
  this.generateChildBranches(
    this.options.branch.children[branch.level],
    branch.level + 1,
    sections);
}

If we are at the final level of recursion, then we generate leaves instead of branches. We’ll look at the leaf generation process in a bit, but it really doesn’t differ much from how we generate child branches.

If we are not at the final level of recursion, we call the generateChildBranches() function.

generateChildBranches(count, level, sections) {
  for (let i = 0; i < count; i++) {

    // Calculate the child branch parameters...

    this.branchQueue.push(
      new Branch(
        childBranchOrigin,
        childBranchOrientation,
        childBranchLength,
        childBranchRadius,
        level,
        this.options.branch.sections[level],
        this.options.branch.segments[level],
      ),
    );
  }
}

This function loops over each child branch, generates the values needs to populate the Branch data structure, and appends the result to the branchQueue, which will then be processed by the generateBranch() function which we have discussed in the previous section.

The generateChildBranches() function requires a few arguments

  • count – The number of child branches to generate
  • level – The current level of recursion, so we know if we need to call generateChildBranches() again or if we should stop here.
  • sections – This is an array of section data for the parent branch. This contains the origins and orientations of the sections, which will be used to help determine where to place the child branches.

Calculating the Child Branch Parameters

Let’s break down how each of the child branch parameters are calculated.

Origin

// Determine how far along the length of the parent branch the child
// branch should originate from (0 to 1)
let childBranchStart = this.rng.random(1.0, this.options.branch.start[level]);

// Find which sections are on either side of the child branch origin point
// so we can determine the origin, orientation and radius of the branch
const sectionIndex = Math.floor(childBranchStart * (sections.length - 1));
let sectionA, sectionB;
sectionA = sections[sectionIndex];
if (sectionIndex === sections.length - 1) {
  sectionB = sectionA;
} else {
  sectionB = sections[sectionIndex + 1];
}

// Find normalized distance from section A to section B (0 to 1)
const alpha =
  (childBranchStart - sectionIndex / (sections.length - 1)) /
  (1 / (sections.length - 1));

// Linearly interpolate origin from section A to section B
const childBranchOrigin = new THREE.Vector3().lerpVectors(
  sectionA.origin,
  sectionB.origin,
  alpha,
);

This one isn’t as tricky as it looks. When determining where to place a branch, we first generate a random number between 0.0 and 1.0 that tells us where along the length of the parent branch we should place the child branch. We then find the sections on either side of that point and interpolate between their origin points to find the origin point of the child branch.

Radius

const childBranchRadius =
  this.options.branch.radius[level] *
  ((1 - alpha) * sectionA.radius + alpha * sectionB.radius);

The radius follows the same logic as the origin. We look at the radius of the sections on either side of the child branch and interpolate those values to get the child branch radius.

Orientation

// Linearlly interpolate the orientation
const qA = new THREE.Quaternion().setFromEuler(sectionA.orientation);
const qB = new THREE.Quaternion().setFromEuler(sectionB.orientation);
const parentOrientation = new THREE.Euler().setFromQuaternion(
  qB.slerp(qA, alpha),
);

// Calculate the angle offset from the parent branch and the radial angle
const radialAngle = 2.0 * Math.PI * (radialOffset + i / count);
const q1 = new THREE.Quaternion().setFromAxisAngle(
  new THREE.Vector3(1, 0, 0),
  this.options.branch.angle[level] / (180 / Math.PI),
);
const q2 = new THREE.Quaternion().setFromAxisAngle(
  new THREE.Vector3(0, 1, 0),
  radialAngle,
);
const q3 = new THREE.Quaternion().setFromEuler(parentOrientation);

const childBranchOrientation = new THREE.Euler().setFromQuaternion(
  q3.multiply(q2.multiply(q1)),
);

Quaternions strike again! In determining the child branch orientation, there are two angles we need to consider

  • The radial angle around the parent branch. We want child branches to be evenly distributed around the circumference of a branch so they aren’t all pointing in one direction.
  • The angle between the child branch and parent branch. This angle is parameterized and can be tuned to get the certain effect we are looking for.
Illustration of branch angle and radial angle

Both of these angles are combined along with the parent branch orientation to determine the final orientation of the branch in 3D space.

Length

let childBranchLength = this.options.branch.length[level];

Lastly, the length of the branch is determined by a parameter set on the UI.

Once all of these values are calculated, we have enough information needed to generate the child branch. We repeat this process for each child branch until all the child branches have been generated. The branchQueue is now filled with all of the child branch data which will be processed in sequence and passed to the generateBranch() function.

Generating Leaves

The leaf generation process is nearly identical to the child branch generation process. The primary difference is that the leaves are rendered as a texture applied to a quad (i.e., rectangular plane), so instead of generating a branch, we generate a quad and position and orient it in the same manner as a branch.

In order to increase the fullness of the foliage and make the leaves visible from all angles, we use two quads instead of one an orient them perpendicular to each other.

There are several parameters for controlling the appearance of the leaves

  • type – I found several different leaf textures so a variety of different types can be generated
  • size – Controls the overall size of the leaf quad to make the leaves larger or smaller
  • count – The number of leaves to generate per branch
  • angle – The angle of the leaf relative to the branch (much like the branch angle parameter)

Environment Design

A beautiful tree needs a beautiful home, which is why I put a significant amount of effort into creating a realistic environment for EZ-Tree. While not the topic of this article, I thought I would highlight some environmental features I added to make the scene feel more alive.

If you want to learn more about how I created the environment, the link to the source code is provided and the top/bottom of this article.

Ground

The first step is adding in a ground plane. I used a smooth noise function to have it vary between a dirt texture and grass texture. When modeling anything from nature, it is always important to pay attention to the imperfections; these are what make a scene feel organic and realistic vs. sterile and fake.

Clouds

Next, I added in some clouds. The clouds are actually just another noise texture (see a pattern here?) applied to a giant quad which I’ve positioned above the scene. To make the clouds feel “alive”, the texture is varied over time to give the appearance of moving clouds. I chose a very muted, slightly overcast sky to not distract from the tree which is the focus of the scene.

Foliage and Rocks

To make the ground more interesting, I added in some grass, rocks and flowers. The grass is more dense near the tree and less dense further away from the tree to improve performance. I chose a rock models with some moss on them so they blended into the ground better. The flowers also help break up the sea of green with splotches of colors.

Forest

Our tree feels a bit lonely, so on app load I generate 100 hundred trees (pulling from the list of presets) and place them around the main tree.

Wind

Nature is always in constant movement, so it’s important to model that in our trees and grass. I wrote custom shaders that animate the geometry of the grass, branches and leaves. I define a wind direction and then add together several sine functions of varying frequencies and amplitudes and then apply that to each vertex in the geometry to get the desired effect.

Below is an excerpt of GLSL shader code that controls the wind offset applied to the vertex positions.

vec4 mvPosition = vec4(transformed, 1.0);

float windOffset = 2.0 * 3.14 * simplex3(mvPosition.xyz / uWindScale);
vec3 windSway = uv.y * uWindStrength * (
  0.5 * sin(uTime * uWindFrequency + windOffset) +
  0.3 * sin(2.0 * uTime * uWindFrequency + 1.3 * windOffset) +
  0.2 * sin(5.0 * uTime * uWindFrequency + 1.5 * windOffset)
);
mvPosition.xyz += windSway;

mvPosition = modelViewMatrix * mvPosition;
gl_Position = projectionMatrix * mvPosition;

Conclusion

I hope you enjoyed this breakdown of of my procedural tree generator! Procedural generation is a fantastic field to explore as it allows you to combine both art and science to create something beautiful and useful.

If you’d like to learn more about 3D web development, be sure to check out my YouTube channel where I post videos on Three.js, React Three Fiber, game development and more!

I will also be releasing my own course website in the near future—Three.js Roadmap. The course will be set up as a collection of small, independent learning modules, so rather than buying an entire course, you only pay for the lessons you are interested in.

Join the waitlist now to receive 30% off your first purchase!

Recent Articles

Related Stories

Leave A Reply

Please enter your comment!
Please enter your name here