DEV Community

Carson Ye
Carson Ye

Posted on • Originally published at carsonye.com

Atmos-fx 0.4.0 - An Evolution in Visual Quality

Summary

The highlight of atmos-fx 0.4.0 is the addition of realistic surface raindrops on glass cards, heavily inspired by Apple Weather's UI.

Drops slowly slide across the card surface, merge when they touch, stretch as they slide, and catch light through highlights, shadows, and simulated refraction.

But what looks like a simple visual upgrade actually required a brand new hybrid rendering pipeline. The CPU handles the drop physics (generation, movement, and merging), and Canvas 2D encodes these shapes into an intermediate map. A WebGL fragment shader then reads that map, using the data to calculate normals, thickness, and highlights to draw the final result.

To support multiple cards without tanking performance, the system now shares a single WebGL renderer and one requestAnimationFrame loop per atmos-fx root. Each card holds only its own simulation state and local compositor layer.

Version 0.4.0 also brings improvements to adaptive quality, offscreen pausing, glass styling, API design, accessibility, and the project showcase.

Here's a breakdown of what changed, why WebGL is involved, how data flows through the system, and the performance trade-offs behind it.

1. Background

If you want a broader introduction to the project first, check out my previous post: Introducing atmos-fx

1.1 What atmos-fx already supported

Before 0.4.0, atmos-fx could already:

  • render rain, snow, and hail with WebGL;
  • separate weather into background and foreground layers;
  • let foreground particles collide with DOM cards;
  • simulate water gathering, stretching, dripping, and splashing from card edges;
  • automatically adjust particle count and device pixel ratio based on performance;
  • control how cards interact with the weather through glass, opacity, and solid modes.

But the glass cards themselves still felt a bit too clean.

Rain could fall through the scene, collide with cards, and drip from their lower edges, but there was no trace showing that water had actually touched the glass surface.

1.2 The goal of 0.4.0

Inspired by Apple Weather, version 0.4.0 adds that missing layer of detail. Eligible glass collision cards now receive surface raindrops automatically—without users needing to deal with shaders, textures, or physics settings.

To get this working smoothly, I set four main design constraints:

  1. Each card needs its own independent drop movement.
  2. Multiple cards should not create separate WebGL contexts and RAF loops.
  3. Low-performance devices must be able to disable the effect automatically.
  4. The feature should not expand the public API. Most users should only need a rain preset and a glass card.

2. Main Changes in 0.4.0

2.1 Surface raindrops

To keep things optimized, the surface raindrop effect only kicks in under specific conditions:

  • the current weather preset is rain;
  • the effective quality level is not low;
  • the element uses data-atmos-glass and not data-atmos-solid;
  • the element is also a discovered collision target;
  • the card is at least 100 × 80 CSS pixels;
  • the card is inside the active rendering area.

I also excluded form elements like inputs, textareas, and selects. This prevents bloated DOM structures on small interactive controls and avoids messing up their focus styles.

When enabled, the controller injects a canvas into the card:

<canvas
  data-atmos-layer="card-rain"
  aria-hidden="true"
></canvas>
Enter fullscreen mode Exit fullscreen mode

It sits behind the card content as a purely visual layer, so it doesn't affect accessibility.

The number of drops follows the root density setting—heavier rain produces more drops on the card surface.

2.2 Drop simulation: the CPU decides what happens

Every card runs its own RaindropSimulation.

The simulation stores scalar state for every drop:

type Drop = {
  x: number
  y: number
  radius: number
  momentum: number
  momentumX: number
  spreadX: number
  spreadY: number
  opacity: number
  mergingInto: Drop | null
}
Enter fullscreen mode Exit fullscreen mode

The simulation logic behaves a lot like a traditional JS physics loop:

  • drops spawn at random positions;
  • larger drops receive stronger downward momentum;
  • momentum decays over time, allowing drops to stop;
  • spreadX and spreadY control stretching during spawning and movement;
  • drops are recycled after leaving the bottom of the card;
  • when two drops touch, the smaller one merges into the larger one.

I didn't want merging to feel jerky, so instead of instantly deleting a drop, the simulation tracks the merge progress and runs a cubic easing transition over about 180ms. This prevents jarring jumps and also transfers some momentum to the larger drop, making it feel like it actually gained weight.

Collision detection uses an ellipse approximation. The system calculates an ellipse from the current drop dimensions, visible texture region, and collision scale, then checks the normalized ellipse distance:

(dx / combinedRadiusX)² + (dy / combinedRadiusY)² < 1
Enter fullscreen mode Exit fullscreen mode

To avoid checking every drop against every other drop, the list is sorted by position so we only check a few nearby candidates.

Obviously, this isn't a full-blown fluid dynamics solver—just a lightweight, controlled approximation optimized for card-sized elements.

2.3 What WebGL does here

If you aren't familiar with WebGL, you can think of the pipeline like this:

First create an image that stores data, then apply a per-pixel filter to interpret it.

The four main concepts are:

  • Texture: an image or canvas that the GPU can read;
  • Vertex shader: code that decides where geometry is drawn;
  • Fragment shader: code that calculates the final color of each pixel;
  • Uniform: a value set by JavaScript and shared by all shader pixels.

The geometry itself is dead simple: the vertex shader only draws a rectangle covering the target area. Since the fragment shader handles all the lighting and distortion, we don't have to deal with complex 3D meshes for each drop.

The complete flow looks like this:

Per-card drop state
        ↓ CPU update
Canvas 2D water map
        ↓ uploaded as a texture
Fragment shader interprets the data
        ↓
Rendered output in the shared WebGL canvas
        ↓ drawImage
Per-card 2D compositor
Enter fullscreen mode Exit fullscreen mode

The CPU decides where a drop is, how large it is, and whether it is merging.

The GPU answers a different question:

Given that water exists here, how should it be shaded to look like water?

2.4 The water map: storing several types of data in one image

The simulation doesn't draw final colors directly. Instead, it uses a set of pre-generated 64x64 textures that pack multiple attributes into the RGBA channels.

The project uses 51 depth levels and 5 highlight-region levels:

51 × 5 = 255 derived textures
Enter fullscreen mode Exit fullscreen mode

Think of an RGBA image as four stacked grayscale data layers, where each channel stores a value from 0 to 255. The channels are mapped like this:

  • R and G store the two-dimensional surface normal;
  • B stores both thickness and highlight-region levels;
  • A stores the visible drop shape and edge opacity.

The shader unpacks the B channel like this:

thicknessLevel = floor(packedValue / 5)
highlightLevel = packedValue mod 5
Enter fullscreen mode Exit fullscreen mode

It's essentially like using a bit mask, but packed directly inside a single color channel.

This allows the shader to recover the shape, normal, thickness, and highlight area in a single texture lookup, keeping texture sampling to a minimum. The 255 derived textures are immutable and relatively expensive to generate, so they are cached and shared across all card simulations.

2.5 Normals, highlights, and simulated refraction

Normals tell the shader which way a surface is facing at any given pixel. Without them, a 2D circle would look completely flat.

The shader reconstructs the horizontal normal from the R and G channels, then calculates the Z component:

normal.z = sqrt(1 - normal.x² - normal.y²)
Enter fullscreen mode Exit fullscreen mode

With the 3D normal in hand, the shader handles the rest:

  1. Refraction offset: Texture coordinates are shifted along the normal. Thicker drops get a stronger offset.
  2. Directional highlights: Surfaces facing the light source become brighter.
  3. Fresnel edges: Areas viewed at a sharper angle get brighter around the edges.
  4. Shadows: The water map is sampled with a downward offset to create a soft dark area beneath the drop.
  5. Thickness lighting: Thicker drops receive stronger brightness and refraction.

To be clear, "refraction" here is a bit of a trick.

I'm not capturing the actual DOM elements behind the card. Instead, the shader samples a predefined refraction texture, cropped to cover the card's dimensions. The goal is visual believability, not a physically perfect, real-time DOM refraction.

This sidesteps the browser limitation where you can't easily read arbitrary DOM content as a WebGL texture, saving us from having to implement expensive, fragile screenshot fallback scripts.

2.6 From multiple renderers to one shared renderer

The easiest way to build this would be giving every card its own WebGL canvas, context, and requestAnimationFrame loop. But that gets slow fast on pages with multiple cards:

  • the number of WebGL contexts grows quickly;
  • multiple RAF loops are harder to coordinate and pause;
  • shaders, textures, and GPU state are created repeatedly.

In 0.4.0, I switched to keeping simulations independent but sharing the heavy rendering machinery:

AtmosFx root
├── One shared RAF
├── One shared WebGL canvas and renderer
├── One shared texture set
└── Multiple CardEntry objects
    ├── Independent RaindropSimulation
    ├── Independent clock, spawning, and merge state
    └── Local Canvas 2D compositor
Enter fullscreen mode Exit fullscreen mode

On each frame, the controller collects visible and initialized cards, resizes the shared WebGL canvas to the largest active card, and processes the cards sequentially:

  1. update the card’s drop simulation;
  2. upload its water map to the shared texture;
  3. render and clear the active region using viewport and scissor;
  4. copy the result into the card’s local canvas;
  5. continue with the next card.

It's like a photo studio with a single high-end camera and lighting setup. Each product has its own arrangement, but they share the same expensive gear.

2.7 Visibility, quality, and lifecycle

Surface raindrops are a nice detail, but they shouldn't eat into the performance budget of the main weather effect or page scrolling. To keep things lightweight, I added a few guardrails:

  • low quality disables card raindrops completely;
  • offscreen cards stop updating and copying frames;
  • RAF stops when no cards need rendering;
  • card raindrops pause when the atmosphere is paused or stopped;
  • leaving the rain preset clears the card output but keeps initialized resources;
  • destroying the controller removes local canvases and releases the shared WebGL context.

Retaining resources isn't the same as rendering in the background. When switching weather presets, we keep the compiled textures so we don't have to recreate them if the user switches back to rain. However, the compositor is cleared and updates stop immediately, ensuring drops don't linger during snow or hail.

The root element also exposes the effective quality through data-atmos-quality. For example, on low quality, CSS disables the full-screen backdrop-filter blur. This adaptive behavior saves resources on both the WebGL side and the browser compositing side.

2.8 A smaller public API

Version 0.4.0 removes the root-level liquidGatheringPoint option. A gathering point describes the geometry of a specific card, not a global weather setting.

It can still be set per card:

<AtmosCard liquidGatheringPoint={0.5} />
Enter fullscreen mode Exit fullscreen mode

Or through a data attribute:

<div
  data-atmos-collision
  data-atmos-glass
  data-atmos-liquid-gathering-point="0.5"
></div>
Enter fullscreen mode Exit fullscreen mode

When no value is provided, each card receives a stable random value between 0.33 and 0.66. This keeps things looking natural without letting drips group up right at the very edge of a card.

I also chose not to expose a new public toggle for the surface drops. The system automatically infers whether to enable them based on the active preset, glass styling, and performance state. This keeps the API clean: the effect works out of the box as a sensible default instead of adding more config options.

2.9 Showcase and accessibility improvements

Here are a few other visual and accessibility updates in 0.4.0:

  • redesigned showcase cards and their hover/active states;
  • fine-tuned main rain density, card-drop density, drop sizes, and weather blur;
  • added a new atmospheric background;
  • paused background videos when prefers-reduced-motion: reduce is active;
  • fixed a bug where multilingual playground updates overwrote runtime canvases;
  • disabled expensive full-screen blur on low quality.

3. Code Structure

The main modules introduced in 0.4.0 are:

Module Responsibility Frontend analogy
dom/cardRainEffect.ts Card discovery, gating, shared RAF, and renderer coordination Component-list controller
rain-card/rainEffect.ts Card sizing, simulation, and local composition Component instance
rain-card/raindrops.ts Drop spawning, movement, merging, and recycling Animation state manager
rain-card/dropTextures.ts Generation and indexing of 255 data textures Precomputed sprite atlas
rain-card/rainRenderer.ts Water-map upload and WebGL draw calls Rendering adapter
rain-card/glContext.ts Shader, texture, uniform, and resource management Low-level graphics client
rain-card/shaders.ts Per-pixel refraction, highlights, edges, and shadows GPU-based CSS filter

This separation keeps the physics decoupled from the rendering. I can tweak the shader style without touching the merging math, or modify drop movement without messing with WebGL resource management.

4. How One Frame Is Rendered

To see how this works in practice, here is the lifecycle of a single frame with two visible glass cards:

  1. The browser triggers the shared requestAnimationFrame.
  2. The controller selects visible, ready, and active CardEntry objects.
  3. The shared canvas is resized to the largest active card.
  4. The first card generates, moves, and merges drops based on elapsed time.
  5. Its simulation draws the drops into its water map.
  6. The renderer uploads the water map as a WebGL texture.
  7. The fragment shader calculates normals, thickness, simulated refraction, highlights, and shadows.
  8. The result is copied into the first card’s local compositor.
  9. The second card repeats the same process with its own simulation state.
  10. If cards still need rendering, the controller requests the next frame.

This allows us to maintain a single GPU entry point and a single scheduling loop while keeping cards visually independent.

5. Performance Design and Trade-offs

5.1 Completed optimizations

  • Each atmosphere root creates only one WebGL renderer for card raindrops.
  • The 255 derived textures are generated once and shared.
  • The shared canvas is sized to the largest active card, not the full page.
  • Invisible cards do not update, upload textures, or copy frames.
  • RAF stops when no active cards remain.
  • Low quality disables both card raindrops and expensive blur.
  • density controls both main weather and card-raindrop load.
  • Initialized resources are reused across preset changes and released only on destroy.

5.2 The remaining cost

Of course, sharing a renderer doesn't mean we get a single-pass draw. Every active card still needs to:

  • update its own CPU simulation;
  • draw a Canvas 2D water map;
  • upload that map as a WebGL texture;
  • run one shader pass;
  • copy the result into its local compositor.

This means performance cost still scales with the number of visible cards. However, sharing resources removes the massive overhead of multiple WebGL contexts, duplicated shader programs, and fragmented loops. Combined with visibility checks and adaptive degradation, the remaining cost stays well under control.

5.3 Why it does not perform real DOM refraction

Browsers don't let you grab arbitrary pixels behind a div and pass them straight to a WebGL texture. True background refraction usually means rendering the whole background inside WebGL, or repeatedly taking DOM screenshots. Many glass-effect libraries do the latter, but it's incredibly heavy.

Since atmos-fx is built for standard DOM layouts rather than a full Canvas/WebGL scene, I went with a static refraction texture distorted by surface normals. This trade-off allows the library to:

  • avoid copying or hiding user DOM;
  • avoid background screenshot dependencies;
  • avoid reading sensitive or cross-origin pixels;
  • preserve normal DOM content, events, and accessibility.

It's a deliberate balance between visual depth, performance, and keeping the code clean.

6. Upgrade Notes

For most projects, upgrading from 0.3.0 is a drop-in replacement. Eligible glass cards will get the surface drops automatically with no code changes needed.

Projects using the root-level liquidGatheringPoint should move it to the relevant AtmosCard or data attribute:

- <AtmosFx preset="rain" liquidGatheringPoint={0.5}>
-   <AtmosCard>...</AtmosCard>
- </AtmosFx>
+ <AtmosFx preset="rain">
+   <AtmosCard liquidGatheringPoint={0.5}>...</AtmosCard>
+ </AtmosFx>
Enter fullscreen mode Exit fullscreen mode

Pages with many glass collision cards should use quality="auto" and avoid overriding the low-quality rules that disable card rain and blur. This automatic quality scaling is critical for maintaining stable frame rates, not just a stylistic choice.

7. Conclusion

With 0.4.0, atmos-fx moves from "weather happening around the cards" to "weather leaving physical traces on them."

By dividing the work between CPU (state, physics, merging) and GPU (normal mapping, refraction, lighting), the library handles detailed weather rendering without choking the browser.

Ultimately, sharing WebGL resources and animation loops while maintaining separate simulations makes the effect highly performant. It shows that WebGL doesn't have to take over the whole page—it can just be a specialized rendering step inside a standard DOM layout.

Top comments (0)