Home Blog Crafting Nature Beyond Technology: A Project from Roots to Leaves | Codrops

Crafting Nature Beyond Technology: A Project from Roots to Leaves | Codrops

0
Crafting Nature Beyond Technology: A Project from Roots to Leaves | Codrops


After the summer, I knew that I would have some free time, so I took it as an opportunity to create a website that would matter to me and also serve as a playground to test new stuff and improve my knowledge. It ended up as a WebGL experience mixing nature and technology. Let’s see how I managed to create it from design to development.


Free GSAP 3 Express Course


Learn modern web animation using GSAP 3 with 34 hands-on video lessons and practical projects — perfect for all skill levels.


Check it out

Index

  1. Building a strong concept
  2. Form and matter
  3. Stack
  4. Key features
  5. Bonus techniques
  6. Takeaways

Concept

I knew from the start that I wanted to create a visually stunning piece, nicely animated, and that would eventually include some WebGL. Nice, but I needed a strong concept on which I could build this visual experience.

I have a deep admiration for nature and its infinite complexity. Nature creates systems that are incredibly efficient and self-sustaining. So I picked one of the most iconic parts of nature as the base of my project: trees. Everybody knows them, but they are also quite mysterious.

I had my subject, but it was still not enough; it was not yet a concept. I wanted to talk about nature differently, so I needed another point of view, distant from the warm green vibe we can imagine.

Then I came across some cool projects, Aether1, Ion, and 8Bit, which are great pieces, by the way, and create a nice atmosphere around the presentation of high-tech products. Suddenly, all the pieces came together: I had my concept — trees as high-end technological creations.

Design

Now I need to transform it onto the screen. I like to take some time to seek inspiration in different fields; it can be websites but also art, design, films, or animation.

I had in the back of my mind the work of Quayola and his Remains series. I also discovered Joanie Lemercier and his Prairie and All the Trees pieces. These two references hugely influenced me in the atmosphere of the project, as well as in the respect and importance given to trees. Combined with the three websites previously cited, I had a clear picture of the project in my mind.

3D

The choice of 3D came naturally. I have a background in 3D motion design, and I wanted to push my limits with WebGL, but it also made sense to navigate through the different features of the tree.

I did not have the time to create a custom 3D model, so I grabbed one from Sketchfab and started to tweak things early in Three.js to find the right atmosphere.

Fortunately, the model had vertex groups for bark and leaves, so I could easily separate them into two objects to manipulate with ease afterward. I had to create the roots, and I chose to make them with some curves from which I could create geometry later in code.

The overall style of the 3D rendering is achieved by using some transparency on the material combined with a dark fog, giving the illusion that the scene disappears and adding more depth to the tree foliage.

I then added some tech-style assets like the icosahedron wireframe background, the low-poly floor, and the compass to track rotations, to reinforce the idea of a simulation.

UI

As I wanted a tech feeling for this piece, I looked into some video game UIs (great inspiration here) and sci-fi film screens to find inspiration. The information cards are really inspired by FPS UIs like Call of Duty.

I wanted to balance it and not make it too technological, so I chose a nice sans-serif font that works well for both headings and body text to add a touch of clean style to the piece. I also added some small features to keep track of the scrolling and the current section.

Stack

I used my usual stack for this project to start quickly, so it is a WordPress setup with Bedrock/Sage to handle it as an MVC, which uses Vite as a bundler. I kept WordPress because I eventually plan to translate the site, so it will be possible with ease.

I use a custom Docker container to handle the local server and database. I did not use it for deployment though, as the web server was not compatible.

I use Tailwind with a custom configuration to handle styling. I really like how fast you can iterate with it, and with v4 it is even easier to combine it with some touches of good old CSS styling.

Concerning the scripts, I use TypeScript and the main libraries are:

  • Three.js and PostProcessing for WebGL
  • GSAP/ScrollTrigger/TextSplit to handle timelines, scroll animations, and splitting
  • Lenis for incredible scroll smoothing
  • Piece.js for handling custom web components

Ok, for the stack, let’s see some parts of the project.

Key features

GUI

During the WebGL development process, I like to add a lot of GUI sliders and buttons to control as many parameters as I can. It makes things much faster to tweak; even if you lose some time implementing it, you gain a lot by finding the right values visually. I like to create a GUI Manager singleton that I can access everywhere in my app.

import glMainStore from '@scripts/stores/gl/glMainStore';
import { createGUI } from '@scripts/utils/loadGui';

export type GUIS = {
  parent: Awaited>;
  mainGui: Awaited>;
  animGui: Awaited>;
  effectGui: Awaited>;
};

type cbT = (guis: GUIS | undefined) => void;

export default class GUIMananger {
  static #instance: GUIMananger;

  GUIS: GUIS;

  guiCbs: cbT[];

  hideGui: boolean;

  constructor() {
    this.hideGui = false;

    if (import.meta.env.DEV) {
      this.guiCbs = [];

      this.initLilGui().then(() => {
        this.trigger();
      });
    }
  }

  public static get instance(): GUIMananger {
    if (!GUIMananger.#instance) {
      GUIMananger.#instance = new GUIMananger();
    }

    return GUIMananger.#instance;
  }

  async initLilGui() {
    const parentGui = await createGUI();
    glMainStore.parentGui = parentGui;
    this.hideGui && parentGui?.hide();

    // Init Debugger
    const mainGui = await createGUI({
      parent: parentGui,
      title: 'main',
    });
    glMainStore.gui = mainGui;

    const animGui = await createGUI({
      parent: parentGui,
      title: 'animations',
    });

    const effectGui = await createGUI({
      parent: parentGui,
      title: 'effects',
    });

    this.GUIS = {
      parent: parentGui,
      mainGui,
      animGui,
      effectGui,
    };
  }

Then I can do this anywhere in my app:

import GUIMananger, { GUIS } from '@scripts/eventManagers/GUIManager';

// calling GUIMananger.instance returns
// if already created the instance of GUIMananger 
// if not it creates it
const { animGui } = GUIMananger.instance.GUIS;
animGui?.add(object, 'property').name('name');

Which in the end makes this kind of organized mess:

Camera animations

An important part of the experience is the camera itself, as it is the point of view of the user. I created a rig around the basic Three.js perspective camera. I first created a null object whose position and rotation I tweak depending on mouse interaction. I then added my camera to it so it copies the location and rotation. It keeps things clear, and it decouples the focus of the camera from the rotation/position of the interaction. I also used another rig on top of that to handle position and rotation. The technique is the same: I parented the first rig to a new 3D object. I can now adjust the position, the radius by moving the rig1 Z position, and the rotation of my camera.

I then add tweaks to my GUI to control everything.

Particles

For the project, I rely on two kinds of particle systems: GPGPU vector field particles and curve-guided particles.

For the particles present throughout the entire scene and the ones in the climate control section, I use a vector field computed by a GPUComputationRenderer from Three.js. To be brief, it lets me calculate the XYZ position of a particle as the RGB components of a texture on the GPU, so it’s fast even if I have a lot of particles. Then I can update the position based on this texture directly inside the vertex shader.

On the other hand, to illustrate some features, I needed particles to follow curves. To do so, I exported Blender’s curves as JSON. Then in Three.js, I created a CatmullRomCurve3, and from there I could create a traditional BufferGeometry particle system.

import rainCurves from '@3D/scenes/tree-scene-1/curves/rain.json';
export default class RainParticles {
// rest of the class ...
  createCurves() {
    const rotationMatrixX = new Matrix4().makeRotationX(degToRad(90));
    const rotationMatrixY = new Matrix4().makeRotationX(degToRad(180));

    const tempVec = new Vector3();

    for (let i = 0; i < rainCurves.length; i++) {
      const curve = rainCurves[i];
      const points = curve.points.map(({ x, y, z }) =>
        tempVec
          .set(x, y, z)
          .applyMatrix4(rotationMatrixX)
          .applyMatrix4(rotationMatrixY)
          .clone()
      );

      const threeCurve = new CatmullRomCurve3(points);
      this.curves.push(threeCurve);
    }
  }
}

In parallel, I created an array of objects to store and update information for each particle. I stored the position on the curve (from 0 — start to 1 — end), the curve it relies on, the speed, and the scale.

createPoints() {
  this.points = [];

  for (let i = 0; i < this.curves.length; i++) {
    for (let j = 0; j < this.density; j++) {
      this.points.push({
        curve: this.curves[i],
        offset: Math.random(),
        speed: minmax(0.5, 0.8, Math.random()) * 0.01,
        currentPos: Math.random(),
        opacity: minmax(0.5, 0.7, Math.random()),
      });
    }
  }
}

So in my render loop, I can iterate through these particle data objects, update the position depending on the chosen speed, then calculate the coordinates of the point corresponding to the new progress on the curve, and update my position BufferAttribute.

Rainy shader

I wanted to illustrate that trees can encourage rain but also help regulate heavy rainfall. What could make it more visual than droplets falling from the sky?

To create this effect, I needed two things: the rain streaks and droplets on the virtual camera refracting the scene. Both of these are made using post-processing and a custom pass shader.

For the streaks, it is quite straightforward. I take the UV of the screen, rotate it, and scale it by a large number on the x-axis and a smaller one on the y-axis, as I want long streaks. I create a grid by getting the fract part of the new UV. Then I trace a streak using smoothstep(), animate its position a bit, and make it blink to get the desired effect.

To create the droplets, I use a UV grid as well, scaled by a large number. Then I get a UV grid by taking the fract part of it. In each cell, I draw a drop using the length to the center and a smoothstep function. I add a noise value to mimic some distortion on the drops, making them look more realistic. I also calculate a time offset for each drop to make them appear with a random delay. I then multiply my UV subset by my mask shape to get UV droplets. The last step is to subtract the UV of my render with my droplets’ UVs and apply this rainy UV to my previous render.

Leaf reveal

Another interesting effect I had to create was the holographic reveal of the leaf. Initially, it was supposed to be a scale-up combined with a fade-in. But once the animation went live, it felt a bit unappealing, so I decided to switch it to a laser-style reveal.

The effect is a combination of a mask, a noise effect on the alpha channel, and a shiny line with a bit of noise in a fragment shader.

Postprocessing

To enhance the atmosphere even a little more, I added a few post-processing passes. Firstly, a subtle noise pass helps create a numerical/technological touch to the 3D assets as well as unify the color gradients a bit.

I then added a bloom pass to give focus and strength to the brighter elements. I set the threshold relatively high to avoid too much blur on the tree. I intentionally boosted some colors to make them bloom — for example, the laser is a vec3(2.) instead of vec3(1.). The color is the same (white), but it surpasses the bloom threshold, so it gets blurred.

I finished with my custom effects — rain and cold/icy effects passes — on top of that, as they are supposed to appear directly on the camera screen.

Touches of interactivity

The project is quite narrative and linear, and the user can feel a bit passive throughout the experience, so I wanted to add some interactivity. The first interaction is the control of the scene in the intro section. It is simply a custom drag-and-drop event handler that adds some velocity to the rotation vector of the scene. I then lock and revert the rotation when the user scrolls to the sections, as I want my animations to focus on precise parts of the tree.

The second is the noisy movement of the water particles in the climate control section. First, I create a Raycaster from the camera, which I update in my render loop with the cursor XY. I get the intersection of this ray with a plane in front of the tree, which always looks at the camera. It gives me a Vector3 coordinate of the virtual cursor. I then add a noise value to every particle position if they are close enough to it.

Bonus misc techniques

Everything between [0 – 1] or [-1, 1]

I use some mapping/clamping functions extensively throughout the project to convert ranges from [x, y] to [0, 1]. It helps a lot when applying values in shaders. For example, I remap my MouseEvent clientX and clientY to a range of [0, 1] to match fragment shader UVs.

I also remap a lot of other ranges, like the ScrollTrigger.onUpdate callback progress. Depending on the need, I can speed up or slow down values, or start/stop before the end.

// convert a value from [a, b] to [A, B] keeping ratio
const map = (a: number, b: number, A: number, B: number, x: number): number =>
  (x - a) * ((B - A) / (b - a)) + A;
  
// convert value to a [0, 1] range
const normalize = (min: number, max: number, value: number): number =>
  map(min, max, 0, 1, value);
  
// clamp values
const clamp = (min: number, max: number, value: number): number =>
  value < min ? min : value > max ? max : value;

// convert from [0 - 1] to [-1, 1]
value * 2 - 1;

// convert from [-1, 1] to [0, 1]
(value + 1) * 0.5
solarPanels: {
  el: getSectionEl('solar-panels'),
  scrollTriggerOptions: {
    id: 'solar-panels',
    ...baseStartEnd,
    markers: showMarkers,
    onUpdate: (self) => {
      const pCamera = mapProgress(0, 0.5, 0, 1, self.progress); // from [0-0.5] to [0-1] for camera to finish animation when progress is 50%
      const pLeaf = mapProgress(0.3, 0.6, 0, 1, self.progress); // from [0.3-0.6] to [0-1] for the leaf reveal
      const pLeafBack = mapProgress(0.7, 1, 0, 1, self.progress); // from [0.7-1] to [0-1] for the leaf back animation

      // Circle and title number
      this.uiAnim.titleNumbers.circleProgress.progress(self.progress);
      showHideTitle(
        this.uiAnim.titleNumbersElements[0],
        self,
        0.01,
        0.99
      );

      // Camera and leaf
      anims?.firstTraveling.camera.progress(pCamera);
      anims?.firstTraveling.leaf.progress(pLeaf - pLeafBack);

      // Anim the title
      titleStore.solarPanel &&
        showHideTitle(titleStore.solarPanel, self, 0.01, 0.9);

      // Show hide Card
      showHideCard(this.uiAnim.cards.solarPanel, self, 0.4, 0.9);

      // Show hide step
      showHideStep(progressStepsObj[0]!, self, 0.683);

    },
  },
}

Custom ease functions

I often use easing functions in JavaScript, which is particularly useful in combination with map() or normalize() to get smooth ratios.

const easeInQuad = (x: number): number => x * x;
const easeOutQuad = (x: number): number => 1 - (1 - x) * (1 - x);
const easeInOutQuad = (x: number): number =>
  x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
// ... Cubic, Expo ...

I also set them in my Tailwind configuration to have more possibilities than the default.

/* ex: class="ease-in-cubic" */
@theme {
  --ease-in-sin: cubic-bezier(0.12, 0, 0.39, 0),
  --ease-out-sin: cubic-bezier(0.61, 1, 0.88, 1);
  --ease-in-out-sin: cubic-bezier(0.37, 0, 0.63, 1);
  /* ... other timing functions */
}

SVG color control

I use this utility often to tweak SVG colors on the fly.

@utility svg-color-* {
  --col : --value(--color-*);
  --col : --value([color]);

  [fill]:not([fill="none"]):not([fill="transparent"]) {
    fill: var(--col);
  }
  [stroke]:not([stroke="none"]):not([stroke="transparent"]){
    stroke: var(--col);
  }
}

Conclusion

As a main takeaway, I would say that the most important part of the project is truly the concept. Once you have a bold core, the rest comes smoothly and with consistency.

Secondly, I would say that trying new things and pushing them until they shine is another key. If you don’t yet know how to do something, you will figure it out — it is the best way to learn and progress.

And last but not least, if, like me, you start this kind of project alone, focus on the goal. You will probably have to make some compromises here and there. For me, it was using my website stack, but it allowed me to start fast and gave me time to focus on a clean style and polished animations.

To finish, I can say that this project has been a journey through all parts of website creation, from concept and design to code through content. I really learned a lot and sharpened my knowledge. I am also quite proud to deliver a piece that links form and matter.

#Crafting #Nature #Technology #Project #Roots #Leaves #Codrops

LEAVE A REPLY

Please enter your comment!
Please enter your name here