<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Carson Ye</title>
    <description>The latest articles on DEV Community by Carson Ye (@_carson_ye_).</description>
    <link>https://dev.to/_carson_ye_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3984120%2F3981ae21-56d9-42fe-9cc9-25d4a676ad44.png</url>
      <title>DEV Community: Carson Ye</title>
      <link>https://dev.to/_carson_ye_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_carson_ye_"/>
    <language>en</language>
    <item>
      <title>Atmos-fx 0.4.0 - An Evolution in Visual Quality</title>
      <dc:creator>Carson Ye</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:37:38 +0000</pubDate>
      <link>https://dev.to/_carson_ye_/atmos-fx-040-an-evolution-in-visual-quality-47hj</link>
      <guid>https://dev.to/_carson_ye_/atmos-fx-040-an-evolution-in-visual-quality-47hj</guid>
      <description>&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Drops slowly slide across the card surface, merge when they touch, stretch as they slide, and catch light through highlights, shadows, and simulated refraction.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;Version 0.4.0 also brings improvements to adaptive quality, offscreen pausing, glass styling, API design, accessibility, and the project showcase.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  1. Background
&lt;/h2&gt;

&lt;p&gt;If you want a broader introduction to the project first, check out my previous post: &lt;a href="https://carsonye.com/articles/introducing-atmosfx/" rel="noopener noreferrer"&gt;Introducing atmos-fx&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1.1 What atmos-fx already supported
&lt;/h3&gt;

&lt;p&gt;Before 0.4.0, atmos-fx could already:&lt;/p&gt;

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

&lt;p&gt;But the glass cards themselves still felt a bit too clean.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.2 The goal of 0.4.0
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;To get this working smoothly, I set four main design constraints:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  2. Main Changes in 0.4.0
&lt;/h2&gt;

&lt;h3&gt;
  
  
  2.1 Surface raindrops
&lt;/h3&gt;

&lt;p&gt;To keep things optimized, the surface raindrop effect only kicks in under specific conditions:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;When enabled, the controller injects a canvas into the card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;canvas&lt;/span&gt;
  &lt;span class="na"&gt;data-atmos-layer=&lt;/span&gt;&lt;span class="s"&gt;"card-rain"&lt;/span&gt;
  &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/canvas&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It sits behind the card content as a purely visual layer, so it doesn't affect accessibility.&lt;/p&gt;

&lt;p&gt;The number of drops follows the root &lt;code&gt;density&lt;/code&gt; setting—heavier rain produces more drops on the card surface.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.2 Drop simulation: the CPU decides what happens
&lt;/h3&gt;

&lt;p&gt;Every card runs its own &lt;code&gt;RaindropSimulation&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The simulation stores scalar state for every drop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Drop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="na"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="na"&gt;momentum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="na"&gt;momentumX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="na"&gt;spreadX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="na"&gt;spreadY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="na"&gt;mergingInto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Drop&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The simulation logic behaves a lot like a traditional JS physics loop:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(dx / combinedRadiusX)² + (dy / combinedRadiusY)² &amp;lt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To avoid checking every drop against every other drop, the list is sorted by position so we only check a few nearby candidates.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  2.3 What WebGL does here
&lt;/h3&gt;

&lt;p&gt;If you aren't familiar with WebGL, you can think of the pipeline like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;First create an image that stores data, then apply a per-pixel filter to interpret it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The four main concepts are:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The complete flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CPU decides where a drop is, how large it is, and whether it is merging.&lt;/p&gt;

&lt;p&gt;The GPU answers a different question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Given that water exists here, how should it be shaded to look like water?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2.4 The water map: storing several types of data in one image
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The project uses 51 depth levels and 5 highlight-region levels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;51 × 5 = 255 derived textures
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;R and G store the two-dimensional surface normal;&lt;/li&gt;
&lt;li&gt;B stores both thickness and highlight-region levels;&lt;/li&gt;
&lt;li&gt;A stores the visible drop shape and edge opacity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shader unpacks the B channel like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;thicknessLevel = floor(packedValue / 5)
highlightLevel = packedValue mod 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's essentially like using a bit mask, but packed directly inside a single color channel.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.5 Normals, highlights, and simulated refraction
&lt;/h3&gt;

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

&lt;p&gt;The shader reconstructs the horizontal normal from the R and G channels, then calculates the Z component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;normal.z = sqrt(1 - normal.x² - normal.y²)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the 3D normal in hand, the shader handles the rest:&lt;/p&gt;

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

&lt;p&gt;To be clear, "refraction" here is a bit of a trick.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.6 From multiple renderers to one shared renderer
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;the number of WebGL contexts grows quickly;&lt;/li&gt;
&lt;li&gt;multiple RAF loops are harder to coordinate and pause;&lt;/li&gt;
&lt;li&gt;shaders, textures, and GPU state are created repeatedly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In 0.4.0, I switched to keeping simulations independent but sharing the heavy rendering machinery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;update the card’s drop simulation;&lt;/li&gt;
&lt;li&gt;upload its water map to the shared texture;&lt;/li&gt;
&lt;li&gt;render and clear the active region using viewport and scissor;&lt;/li&gt;
&lt;li&gt;copy the result into the card’s local canvas;&lt;/li&gt;
&lt;li&gt;continue with the next card.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.7 Visibility, quality, and lifecycle
&lt;/h3&gt;

&lt;p&gt;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:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  2.8 A smaller public API
&lt;/h3&gt;

&lt;p&gt;Version 0.4.0 removes the root-level &lt;code&gt;liquidGatheringPoint&lt;/code&gt; option. A gathering point describes the geometry of a specific card, not a global weather setting.&lt;/p&gt;

&lt;p&gt;It can still be set per card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AtmosCard&lt;/span&gt; &lt;span class="na"&gt;liquidGatheringPoint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or through a data attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt;
  &lt;span class="na"&gt;data-atmos-collision&lt;/span&gt;
  &lt;span class="na"&gt;data-atmos-glass&lt;/span&gt;
  &lt;span class="na"&gt;data-atmos-liquid-gathering-point=&lt;/span&gt;&lt;span class="s"&gt;"0.5"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.9 Showcase and accessibility improvements
&lt;/h3&gt;

&lt;p&gt;Here are a few other visual and accessibility updates in 0.4.0:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  3. Code Structure
&lt;/h2&gt;

&lt;p&gt;The main modules introduced in 0.4.0 are:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. How One Frame Is Rendered
&lt;/h2&gt;

&lt;p&gt;To see how this works in practice, here is the lifecycle of a single frame with two visible glass cards:&lt;/p&gt;

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

&lt;p&gt;This allows us to maintain a single GPU entry point and a single scheduling loop while keeping cards visually independent.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Performance Design and Trade-offs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  5.1 Completed optimizations
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  5.2 The remaining cost
&lt;/h3&gt;

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

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.3 Why it does not perform real DOM refraction
&lt;/h3&gt;

&lt;p&gt;Browsers don't let you grab arbitrary pixels behind a &lt;code&gt;div&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;avoid copying or hiding user DOM;&lt;/li&gt;
&lt;li&gt;avoid background screenshot dependencies;&lt;/li&gt;
&lt;li&gt;avoid reading sensitive or cross-origin pixels;&lt;/li&gt;
&lt;li&gt;preserve normal DOM content, events, and accessibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a deliberate balance between visual depth, performance, and keeping the code clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Upgrade Notes
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Projects using the root-level &lt;code&gt;liquidGatheringPoint&lt;/code&gt; should move it to the relevant &lt;code&gt;AtmosCard&lt;/code&gt; or data attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- &amp;lt;AtmosFx preset="rain" liquidGatheringPoint={0.5}&amp;gt;
-   &amp;lt;AtmosCard&amp;gt;...&amp;lt;/AtmosCard&amp;gt;
- &amp;lt;/AtmosFx&amp;gt;
&lt;/span&gt;&lt;span class="gi"&gt;+ &amp;lt;AtmosFx preset="rain"&amp;gt;
+   &amp;lt;AtmosCard liquidGatheringPoint={0.5}&amp;gt;...&amp;lt;/AtmosCard&amp;gt;
+ &amp;lt;/AtmosFx&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pages with many glass collision cards should use &lt;code&gt;quality="auto"&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Conclusion
&lt;/h2&gt;

&lt;p&gt;With 0.4.0, atmos-fx moves from "weather happening around the cards" to "weather leaving physical traces on them."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>design</category>
      <category>ui</category>
      <category>animation</category>
      <category>atmosphere</category>
    </item>
    <item>
      <title>Where WebGL Meets Liquid Glass</title>
      <dc:creator>Carson Ye</dc:creator>
      <pubDate>Thu, 25 Jun 2026 02:48:57 +0000</pubDate>
      <link>https://dev.to/_carson_ye_/where-webgl-meets-liquid-glass-401l</link>
      <guid>https://dev.to/_carson_ye_/where-webgl-meets-liquid-glass-401l</guid>
      <description>&lt;p&gt;Recently, I worked on experiments implementing the well-known liquid glass effects on the web. Most of the implementations right now use a similar idea: clone the DOM content behind a glass element, distort that cloned layer, add blur, tint, highlights, and chromatic offsets, then place the result back under the glass surface.&lt;/p&gt;

&lt;p&gt;But it breaks down when the thing behind the glass is a real canvas. Because a DOM clone can copy HTML, text, images, layout, and backgrounds. But it cannot naturally copy the actual pixels generated inside a WebGL canvas. A canvas is not a static DOM subtree. It is a framebuffer that changes every frame.&lt;/p&gt;

&lt;p&gt;To make liquid-glass work on canvas, I would either need to capture the canvas every frame, which can become expensive and easy to desync, or move the glass refraction into the canvas/WebGL pipeline itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  I needed a better WebGL demonstration
&lt;/h2&gt;

&lt;p&gt;To test and demonstrate canvas-native liquid glass properly, I needed a background that was worth refracting. I wanted something alive: something with motion, attractive and visually pleasing.&lt;/p&gt;

&lt;p&gt;Around that time, I saw a beautiful interactive water ripple demonstration on X by Konmari. The visual language was simple but very attractive: soft water motion, cursor-driven ripples, floating elements, and slightly poetic atmosphere.&lt;/p&gt;

&lt;p&gt;I borrowed the core idea and built my own version:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Water Ripples&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Demo: &lt;a href="https://ripple.carsonye.com/" rel="noopener noreferrer"&gt;https://ripple.carsonye.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/Whynotmetoo/water-ripples" rel="noopener noreferrer"&gt;https://github.com/Whynotmetoo/water-ripples&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is an interactive WebGL water scene with realistic ripple physics, floating yellow plumeria flowers, sunlight glints, and a liquid-glass settings panel that refracts the live canvas underneath it.&lt;/p&gt;

&lt;p&gt;The transparent and luminous visual effect is hard to describe in a few words, so I would suggest trying the demo directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;The demo is built around two parts: the water ripple scene, and the liquid-glass panel that refracts that scene.&lt;/p&gt;

&lt;p&gt;The water uses a simple height-field ripple simulation. Pointer movement adds disturbances to a grid, the grid spreads those waves outward, and a WebGL shader turns the height changes into distortion and light. It is not a full fluid simulation, but for this kind of visual effect, a height map plus refraction and sunlight details is enough.&lt;/p&gt;

&lt;p&gt;The flowers are rendered on a 2D canvas above the water. They are partly decoration, but they also help reveal the effect. When the glass bends something recognizable, like a flower, the refraction becomes much easier to read.&lt;/p&gt;

&lt;p&gt;For the liquid glass part, the important detail is that the panel samples pixels instead of cloning DOM. The WebGL water canvas and the 2D flower canvas are first composited into one hidden source canvas. Then the glass shader samples that source and offsets the UVs inside the panel area.&lt;/p&gt;

&lt;p&gt;In practice, the render flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;render WebGL water
render 2D flowers
composite both into one source canvas
sample that canvas in the glass shader
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the panel, the shader estimates a soft edge normal and uses it to bend the source texture. The center stays calmer, while the edges distort the scene more strongly. A little tint, highlight, and chromatic split make it feel thicker than a normal blurred card.&lt;/p&gt;

&lt;p&gt;The goal is not physical accuracy, but to make the panel feel like a material sitting above a living canvas scene.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not the end
&lt;/h2&gt;

&lt;p&gt;This demo is only an experiment for liquid glass inside canvas. It works because the scene is already pixels, so the shader can sample and bend them directly. A complete liquid-glass solution for the web is much harder, because real pages include DOM, text, images, video, scrolling, transforms, and many different rendering paths.&lt;/p&gt;

&lt;p&gt;Many people are exploring this direction, and maybe we will eventually get a mature solution. But performance will always matter. This demo is not optimized as a production implementation; it is just a small exploration, and I will keep watching where this area goes.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>liquidglass</category>
      <category>webgl</category>
      <category>design</category>
    </item>
  </channel>
</rss>
