Building an Interactive WebGL Experience: Nature Meets Technology
Discover the meticulous process behind a captivating WebGL experience that fuses the organic beauty of nature with high-tech simulation. This article delves into concept development, 3D design, UI/UX, core technical stack, and advanced animation techniques, offering insights into creative interactive web project creation.

This article details the journey of creating a unique website experience, blending nature-inspired concepts with advanced WebGL technology. The project served as a creative playground to explore new techniques and deepen knowledge, from initial design to final development.
Building a Strong Concept
The goal was to craft a visually stunning, nicely animated piece incorporating WebGL. A powerful concept was crucial. Inspired by nature's infinite complexity and efficient, self-sustaining systems, the project adopted trees as its core subject. To give it a distinct perspective, moving away from a traditional "warm green vibe," the idea evolved into portraying "trees as high-end technological creations," drawing inspiration from projects like Aether1, Ion, and 8Bit, which fuse high-tech product presentation with atmospheric design.
Design
The design phase began with seeking inspiration from diverse fields, including art, design, films, and animation. Influences included Quayola's Remains series and Joanie Lemercier's Prairie and All the Trees, which shaped the project's atmosphere and emphasized the reverence for trees. These inspirations, combined with the high-tech aesthetics of the referenced websites, provided a clear visual direction.
3D
The choice of 3D was natural, leveraging a background in 3D motion design and aiming to push WebGL boundaries. A pre-existing 3D model from Sketchfab was adapted and tweaked within Three.js to establish the desired atmosphere. Fortuitously, the model had vertex groups for bark and leaves, simplifying object separation and manipulation. Roots were created programmatically using curves that would later generate geometry.
The distinctive 3D rendering style is achieved through material transparency combined with a dark fog, creating an illusion of depth and a fading scene. Tech-style assets like an icosahedron wireframe background, a low-poly floor, and a compass were added to reinforce the simulation concept.
UI
For the user interface, inspiration was drawn from video game UIs (particularly FPS interfaces like Call of Duty) and sci-fi film screens, aiming for a "tech feeling." To balance this, a clean sans-serif font was chosen for both headings and body text, adding a touch of refined style. Small features were also implemented to track scrolling progress and current sections.
Stack
The project utilizes a familiar and efficient development stack for rapid iteration:
- CMS: WordPress, managed as an MVC with Bedrock/Sage. This choice facilitates potential future translations.
- Bundler: Vite.
- Local Environment: Custom Docker containers for the server and database. (Note: Not used for deployment due to web server incompatibility).
- Styling: Tailwind CSS with custom configuration, appreciated for its iteration speed and compatibility with traditional CSS.
- Scripting: TypeScript.
- Main Libraries:
- WebGL: Three.js and PostProcessing.
- Animations: GSAP, ScrollTrigger, TextSplit for timelines, scroll-based animations, and text manipulation.
- Scrolling: Lenis for smooth scroll effects.
- Web Components: Piece.js for custom web components.
Key Features
GUI
During WebGL development, extensive use of GUI sliders and buttons is favored to control numerous parameters. This approach, while initially time-consuming to implement, significantly accelerates the visual tweaking process. A GUIManager singleton provides centralized access across the application.
import glMainStore from '@scripts/stores/gl/glMainStore';
import { createGUI } from '@scripts/utils/loadGui';
export type GUIS = {
parent: Awaited<ReturnType<typeof createGUI>>;
mainGui: Awaited<ReturnType<typeof createGUI>>;
animGui: Awaited<ReturnType<typeof createGUI>>;
effectGui: Awaited<ReturnType<typeof createGUI>>;
};
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,
};
}
}
This allows for easy access and manipulation:
import GUIMananger, { GUIS } from '@scripts/eventManagers/GUIManager';
// calling GUIMananger.instance returns the existing instance or creates a new one
const { animGui } = GUIMananger.instance.GUIS;
animGui?.add(object, 'property').name('name');
Camera Animations
The camera, as the user's viewpoint, is a critical component. A rig was built around the basic Three.js perspective camera, starting with a null object whose position and rotation are controlled by mouse interaction. The camera is then parented to this null object, decoupling its focus from interaction. An additional rig atop the first allows further control over position, radius (via Z-position), and rotation. GUI tweaks enable comprehensive control over these camera parameters.
Particles
Two distinct particle systems enhance the scene: GPGPU vector field particles and curve-guided particles.
For ambient scene particles and those in the climate control section, a vector field is computed using Three.js's GPUComputationRenderer. This technique rapidly calculates particle XYZ positions as RGB components of a texture on the GPU, allowing for high particle counts and efficient updates directly within the vertex shader.
To illustrate specific features, particles were required to follow predefined curves. This was achieved by exporting Blender curves as JSON, then reconstructing them in Three.js as CatmullRomCurve3 objects. From these, traditional BufferGeometry particle systems could be created.
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);
}
}
}
An array of objects stores and updates information for each particle, including its position on the curve (0 to 1), the associated curve, speed, and 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()),
});
}
}
}
Within the render loop, these particle data objects are iterated, positions are updated based on speed, and corresponding curve coordinates are calculated to refresh the position BufferAttribute.
Rainy Shader
To visually represent trees encouraging and regulating rainfall, a custom post-processing shader creates rain streaks and droplets refracting the scene on the virtual camera.
For rain streaks, screen UVs are rotated and scaled (large on x, smaller on y for elongated streaks). A grid is formed using the fract part of the new UV, and streaks are drawn with smoothstep(), animated, and given a blinking effect.
Droplets are also created using a scaled UV grid. In each cell, a drop is drawn based on its distance to the center and a smoothstep function. Noise is added for distortion, enhancing realism, and a time offset introduces random delays. The final step involves multiplying the UV subset by a mask shape to generate UV droplets, then subtracting these from the render's UVs and applying the resulting rainy UV to the previous render.
Leaf Reveal
The holographic leaf reveal effect evolved from a simple scale-up/fade-in, which felt unappealing, to a more dynamic laser-style animation. This effect combines a mask, a noise effect on the alpha channel, and a shimmering line with noise, all implemented within a fragment shader.
Postprocessing
Several post-processing passes were employed to deepen the overall atmosphere:
- Noise Pass: A subtle noise effect adds a numerical/technological touch to 3D assets and helps unify color gradients.
- Bloom Pass: Applied to give focus and intensity to brighter elements. A high threshold prevents excessive blurring of the tree, while intentionally boosted colors (e.g.,
vec3(2.)for white lasers) ensure they exceed the threshold and bloom effectively. - Custom Effects: Rain and cold/icy passes are layered on top, designed to appear directly on the camera screen.
Touches of Interactivity
To enhance user engagement in the otherwise narrative and linear experience, subtle interactivity was added:
- Intro Scene Control: A custom drag-and-drop event handler allows users to control scene rotation, adding velocity to the rotation vector. This rotation is then locked and reverted when scrolling into subsequent sections to ensure focus on specific tree parts during animations.
- Water Particle Movement: In the climate control section, a noisy movement effect is applied to water particles. A
Raycasterfrom the camera, updated with cursor XY in the render loop, calculates the intersection of the ray with a plane in front of the tree. This provides a virtual cursor coordinate. Noise is then added to the position of any particle close enough to this virtual cursor.
Bonus Miscellaneous Techniques
Value Mapping and Clamping
Throughout the project, mapping and clamping functions are extensively used to convert value ranges, particularly useful for shaders. For instance, MouseEvent.clientX and clientY are remapped to a [0, 1] range to align with fragment shader UVs. ScrollTrigger.onUpdate progress values are also remapped to control animation speed, start/stop points, and custom ratios.
// 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
An example from a ScrollTrigger setup:
solarPanels: {
el: getSectionEl('solar-panels'),
scrollTriggerOptions: {
id: 'solar-panels',
// ... baseStartEnd, // Base start/end configurations
// markers: showMarkers, // Debugging option
onUpdate: (self) => {
const pCamera = mapProgress(0, 0.5, 0, 1, self.progress); // Camera animation finishes by 50% scroll progress
const pLeaf = mapProgress(0.3, 0.6, 0, 1, self.progress); // Leaf reveal animation runs between 30% and 60% progress
const pLeafBack = mapProgress(0.7, 1, 0, 1, self.progress); // Leaf back animation runs between 70% and 100% progress
// Update animations based on remapped progress values
// Example: anims?.firstTraveling.camera.progress(pCamera);
// Example: anims?.firstTraveling.leaf.progress(pLeaf - pLeafBack);
},
},
}
Custom Ease Functions
Custom easing functions are frequently used in JavaScript, especially when combined with map() or normalize() to achieve smooth animation 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 ...
These functions are also integrated into the Tailwind CSS configuration, extending beyond its default easing options.
/* Example usage: 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
A utility is often employed to dynamically adjust SVG colors.
@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
The primary takeaway from this project is the paramount importance of a strong, bold core concept. Once established, the subsequent design and development phases flow more smoothly and consistently.
Secondly, continuously experimenting with new techniques and refining them until they "shine" is crucial. Learning by doing, even when unfamiliar with a specific skill, is the most effective path to progress.
Finally, for solo projects of this scope, maintaining focus on the ultimate goal is essential. Compromises, such as leveraging a familiar website stack, can accelerate the initial setup, allowing more time to dedicate to clean styling and polished animations. This project was a comprehensive journey through all facets of website creation, significantly enhancing knowledge and proudly linking "form and matter."