Audio Settings
Master Volume
50%
Graphics Settings
Quality Preset
Affects: Grass density, particle effects, shadow quality, and pixel ratio
5K 100K
200 1200
1x 4x
Building Elemental Serenity
What started as a weekend experiment to render a calm, digital glade turned into a months-long exercise in pushing WebGL 2.0 and Three.js to their limits. I wanted an environment that felt alive, not just pretty-looking, but responsive, season-aware, and fast enough to run on a mid-range laptop. Below is the short version of that journey: the problems that kept me up at night, the solutions that actually worked, and the few hard lessons I carried into every subsystem.
Inspiration & Creative Direction
The original spark for Elemental Serenity came from watching **…
Audio Settings
Master Volume
50%
Graphics Settings
Quality Preset
Affects: Grass density, particle effects, shadow quality, and pixel ratio
5K 100K
200 1200
1x 4x
Building Elemental Serenity
What started as a weekend experiment to render a calm, digital glade turned into a months-long exercise in pushing WebGL 2.0 and Three.js to their limits. I wanted an environment that felt alive, not just pretty-looking, but responsive, season-aware, and fast enough to run on a mid-range laptop. Below is the short version of that journey: the problems that kept me up at night, the solutions that actually worked, and the few hard lessons I carried into every subsystem.
Inspiration & Creative Direction
The original spark for Elemental Serenity came from watching Bruno Simon’s portfolio devlogs (https://youtu.be/MXpML0B2MJc?si=rePA5w2j7CL7Jm9s), specifically his breakdowns of how a playful, interactive 3D experience can still be technically rigorous. Seeing how he approached world-building in the browser reframed how I thought about WebGL projects: not as static demos, but as places users can explore. His emphasis on performance-aware creativity pushed me to treat every visual decision as an engineering problem waiting to be solved.
At the same time, Jordan Breton’s portfolio (https://jordan-breton.com/) heavily influenced the overall mood and restraint of the scene. Where Bruno’s work inspired interactivity and technical ambition, Jordan’s reminded me of the power of atmosphere, subtle motion, carefully chosen colour palettes, and environments that feel intentional rather than busy. That balance between expressiveness and calm became a guiding principle throughout the project.
The Stack (Why These Tools)
I intentionally kept the tech simple but modern:
- Three.js 0.182 on top of WebGL 2.0 for lower-level control.
- Custom GLSL for the parts where CPU-driven tricks just wouldn’t cut it.
- GSAP 3.14 for smooth orchestration of UI and seasonal transitions.
- Vite 6.0 + ES6 modules for a snappy dev loop.
vite-plugin-glsl— absolute game changer for shader hot-reload.
These choices gave me quick iteration during development while allowing me to drop into GLSL when performance mattered.
The Grass Problem Was My Biggest Challenge
I wanted thousands of grass blades that bend and whisper in the wind. Naively creating tens of thousands of separate meshes was an instant FPS death sentence. The goal became: how do I keep visual richness with minimal geometry and GPU overhead?
What I tried
- Instanced rendering: share a single blade geometry and provide per-instance transforms. This is standard, but it alone doesn’t solve density control or varied wind behaviour.
- Billboards: keep each blade as a camera-facing quad to dramatically reduce vertex counts.
The solution that stuck
- Instanced quads + billboard rotation. Each grass blade is an instanced quad that rotates in the vertex shader to face the camera. This alone reduced the vertex count by orders of magnitude.
- RGB control texture: a single texture packed with control channels:
- R = path mask (0 = path, 1 = dense grass)
- G = thickness/scale variation
- B = wind intensity map (used to bias per-instance wind strength) This texture lets me paint where grass should be sparse (paths), where it should be thick, and where it should sway wildly.
- GPU wind physics in GLSL — per-instance attributes for base offset + a per-pixel wind strength sampled from the B channel; everything runs in the vertex shader, so the CPU only updates when instances are added/removed.
- LOD + quality presets — for Low/Medium/High/Ultra I dynamically reduce instance count, billboard complexity, and particle emissions.
Seasons — 8 Worlds in One
I didn’t want a single color toggle. I wanted completely different moods.
- Design: 4 seasons × 2 times of day = 8 full palettes. Every material, grass, rock, water, fire, and smoke responds.
- Implementation: a single
SeasonManagersingleton broadcasts events and exposes a small set of uniforms every shader subscribes to (palette colors, shadow tint, water reflectance, smoke opacity). - Performance trick: shaders receive a compact, precomputed palette (vec3 arrays + single floats). The heavy interpolation happens once in JS when switching seasons; shaders simply lerp between two provided palettes based on
u_seasonBlend.
The result is smooth, event-driven transitions with zero frame drops even on large scenes.
Audio — Making Proximity Feel Real
Visuals were only half the immersion. Sound had to be reactive, not just background music.
- Web Audio API drives the system.
- An
AmbientSoundManagercontrols both spatialized sounds (bird pings, crackling fire) and ambience tracks (wind, brook, seasonal pads). - Distance-based falloff and smart mixing prevent CPU spikes (don’t decode or play inaudible sounds).
- Crossfading between tracks and season-aware mixes keeps the audio design cohesive.
This was deceptively tricky: poorly handled crossfades or too many simultaneous sound nodes easily produce audible artefacts or memory leaks, so lifecycle management was critical.
Architecture Decisions That Actually Saved Time
A few engineering choices repeatedly paid dividends:
- Event-driven design: a lightweight
EventEmitterallowed decoupled components to react to seasonal or quality changes without washing the scene with polling logic. - Singleton SeasonManager & AudioManager: one source of truth is easier to reason about than dozens of semi-consistent objects.
- vite-plugin-glsl: shader hot-reload made iterating GLSL feel almost as fast as tweaking CSS in a web app.
- Quality presets: Low/Medium/High/Ultra mapped to concrete parameters (grass density, particle rates, shadow resolution). Users could persist settings in
localStorage. - Perf tooling: embedded three-perf for FPS tracking while tuning the grass system and particles.
Memory Management Is the Thing I Learned the Hard Way
WebGL resources are explicit. I ran into crashes and VRAM leaks until I audited the cleanup.
- Always call
.dispose()on geometries, materials, and textures when removed. - Remove event listeners and stop audio nodes on scene teardown.
- Keep visible object lists small and reuse buffers when possible (object pools for particles and temporary meshes).
After enforcing strict cleanup patterns, stability improved dramatically, especially on devices with constrained memory.
Tradeoffs & Lessons Learned
- Complex GLSL > simple CPU logic: doing wind and billboarding in the vertex shader saved CPU time but shifted complexity into shader code. Worth it for performance, but harder to debug.
- Billboards are cheap, but not always right: they work brilliantly for grass and distant foliage but fall short at close range. I mixed instance billboards with a few full meshes near the camera.
- Start profiling early: three-perf and simple bench scenes let me measure the real effect of changes. Guessing is expensive.
- One true source of state (singletons/event bus) simplifies seasonal transitions, and settings sync across systems.
- User-configurable quality matters: letting users scale down particle counts and shadow resolution kept the experience accessible.
Closing Notes
This project was equal parts art and systems engineering. The biggest wins came from moving logic onto the GPU (instancing, wind, particles), centralising state (SeasonManager), and being ruthless about cleanup. If you want to poke around the code, focus on the instanced grass shader and the event-driven SeasonManager; they’re the heart of the system.
Credits & Resources
Environment & Textures
Environment map: "citrus_orchard_road_puresky_4k.hdr" from Polyhaven
Tools and Resources
Blender - 3D modeling software
GIMP - Image editing
Version 1.0.0 | Built with 💜 for the web