<?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: The L? Man</title>
    <description>The latest articles on DEV Community by The L? Man (@the_l_man).</description>
    <link>https://dev.to/the_l_man</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3904422%2F41662b31-7921-403a-9287-1a6c69bbdfdf.png</url>
      <title>DEV Community: The L? Man</title>
      <link>https://dev.to/the_l_man</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/the_l_man"/>
    <language>en</language>
    <item>
      <title>Building a Multi-Pass Phosphor Rendering Pipeline in WebGL</title>
      <dc:creator>The L? Man</dc:creator>
      <pubDate>Wed, 29 Apr 2026 13:51:50 +0000</pubDate>
      <link>https://dev.to/the_l_man/building-a-multi-pass-phosphor-rendering-pipeline-in-webgl-113o</link>
      <guid>https://dev.to/the_l_man/building-a-multi-pass-phosphor-rendering-pipeline-in-webgl-113o</guid>
      <description>&lt;p&gt;Vintage oscilloscopes have a look that's hard to fake. The phosphor coating on the CRT glows where the electron beam hits, fades slowly over time, and bleeds light into surrounding pixels. That persistence and bloom is what gives oscilloscope traces their characteristic warmth.&lt;/p&gt;

&lt;p&gt;I wanted to recreate that look in the browser. Not as a post-processing filter on top of a line, but as a physically-inspired rendering pipeline where each frame's energy accumulates, decays, and blooms the way real phosphor does.&lt;/p&gt;

&lt;p&gt;The result is &lt;strong&gt;Phosphor&lt;/strong&gt;, a web-based oscilloscope simulator with 5 signal modes, real-time audio visualization, and a 4-pass GLSL shader pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://hubertlim.github.io/oscilloscope_playground/" rel="noopener noreferrer"&gt;→ Try the Live Demo&lt;/a&gt;&lt;/strong&gt; | &lt;strong&gt;&lt;a href="https://github.com/hubertlim/oscilloscope_playground" rel="noopener noreferrer"&gt;Source Code (MIT)&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post walks through how the rendering works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pipeline
&lt;/h2&gt;

&lt;p&gt;Each frame goes through four shader passes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Signal Data
    ↓
┌─────────────────┐
│  Pass 1: BEAM   │  Soft gaussian dots, additive blending (HDR)
└────────┬────────┘
         ↓
┌─────────────────┐
│ Pass 2: PHOSPHOR│  Exponential decay persistence (linear HDR)
└────────┬────────┘
         ├──────────────────┐
         ↓                  ↓
┌─────────────────┐  ┌─────────────┐
│  Pass 3: BLOOM  │  │             │
│  (half res)     │  │             │
└────────┬────────┘  │             │
         ↓           ↓             │
┌──────────────────────────────────┐
│       Pass 4: COMPOSITE          │
│  Tone mapping, CRT curvature,    │
│  vignette, scanlines, grid       │
└──────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key design decision: &lt;strong&gt;the phosphor buffer stays in linear HDR space&lt;/strong&gt;. Tone mapping only happens once, in the composite pass. This prevents the accumulation artifacts you get when you tone-map per frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass 1: Beam
&lt;/h2&gt;

&lt;p&gt;The beam shader renders each signal point as a soft gaussian dot using &lt;code&gt;gl_PointCoord&lt;/code&gt;. Each point has two components: a tight core and a softer glow halo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="kt"&gt;vec2&lt;/span&gt; &lt;span class="n"&gt;coord&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;gl_PointCoord&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="kt"&gt;vec2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Tight core + soft glow&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;core&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;glow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;shape&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;core&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;glow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;brightness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shape&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;vIntensity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;gl_FragColor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;vec4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uBeamColor&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;brightness&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brightness&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;core&lt;/code&gt; gaussian (sigma ≈ 0.19) gives the sharp bright center. The &lt;code&gt;glow&lt;/code&gt; gaussian (sigma ≈ 0.35) adds the softer halo around it. With 4096 points rendered per frame using additive blending, overlapping regions accumulate naturally — dense parts of the trace glow brighter, just like a real CRT.&lt;/p&gt;

&lt;p&gt;The output goes to a &lt;strong&gt;HalfFloat texture&lt;/strong&gt;. This is important: standard 8-bit textures would clip at 1.0 and lose the HDR information we need for realistic phosphor behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass 2: Phosphor Persistence
&lt;/h2&gt;

&lt;p&gt;This is where the magic happens. The phosphor shader reads the previous frame's buffer, applies exponential decay, and adds the new beam energy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="kt"&gt;vec4&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;texture2D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uCurrentFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vUv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;vec4&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;texture2D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uPreviousFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vUv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Exponential decay&lt;/span&gt;
&lt;span class="kt"&gt;vec4&lt;/span&gt; &lt;span class="n"&gt;decayed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;uDecay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Add new beam energy&lt;/span&gt;
&lt;span class="kt"&gt;vec4&lt;/span&gt; &lt;span class="n"&gt;combined&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;decayed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Hard clamp at a reasonable HDR ceiling&lt;/span&gt;
&lt;span class="n"&gt;combined&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;vec4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nb"&gt;gl_FragColor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;uDecay&lt;/code&gt; uniform (typically 0.85–0.95) controls how long traces persist. At 0.95, a trace takes about 60 frames to fade to near-zero — roughly one second at 60fps, which matches the persistence of &lt;strong&gt;P31 phosphor&lt;/strong&gt; used in many real oscilloscopes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;min(combined, 2.5)&lt;/code&gt; clamp prevents infinite accumulation. Without it, a stationary beam would push values toward infinity. The ceiling of 2.5 is chosen so the composite shader's Reinhard tone mapping still has headroom to work with.&lt;/p&gt;

&lt;p&gt;This pass uses a &lt;strong&gt;ping-pong buffer&lt;/strong&gt;: two HalfFloat render targets that swap each frame. The previous frame's output becomes the next frame's input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass 3: Bloom
&lt;/h2&gt;

&lt;p&gt;The bloom pass creates the characteristic CRT glow by blurring the phosphor buffer. It uses a separable 13-tap Gaussian blur in two passes (horizontal, then vertical) at half resolution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1964825501511404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2969069646728344&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2195956136&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ... (symmetric kernel)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
    &lt;span class="kt"&gt;vec2&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uDirection&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;texelSize&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;texture2D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uTexture&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vUv&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;totalWeight&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running at half resolution is a deliberate choice: it makes the blur wider (each texel covers 2×2 pixels) while using the same number of taps, and it's cheaper to compute. The slight softness from the downscale actually helps — real CRT bloom isn't sharp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass 4: Composite
&lt;/h2&gt;

&lt;p&gt;The composite shader is where everything comes together. It samples both the phosphor buffer and the bloom texture, combines them in linear HDR space, then applies tone mapping and CRT effects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="kt"&gt;vec3&lt;/span&gt; &lt;span class="n"&gt;hdr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;phosphor&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bloom&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;uBloomIntensity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Reinhard tone mapping — the ONLY place this happens&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;exposure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;vec3&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hdr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;exposure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reinhard tone mapping (&lt;code&gt;x / (1 + x)&lt;/code&gt;) compresses HDR values into displayable range while preserving relative brightness. Bright areas stay bright, dim areas stay dim, and nothing clips.&lt;/p&gt;

&lt;p&gt;After tone mapping, the shader applies CRT effects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Barrel distortion&lt;/strong&gt; — simulates the curved glass of a CRT&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scanlines&lt;/strong&gt; — horizontal brightness modulation at display resolution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grid overlay&lt;/strong&gt; — the 10×10 graticule with major axis lines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vignette&lt;/strong&gt; — darkening toward the edges&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ambient glass glow&lt;/strong&gt; — a subtle green tint (&lt;code&gt;vec3(0.0, 0.003, 0.0)&lt;/code&gt;) that simulates light scattering in the glass&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why HDR Matters
&lt;/h2&gt;

&lt;p&gt;The single most important decision in this pipeline is keeping everything in linear HDR space until the final composite pass.&lt;/p&gt;

&lt;p&gt;Here's what happens if you tone-map per frame instead:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Frame&lt;/th&gt;
&lt;th&gt;Per-frame tone mapping&lt;/th&gt;
&lt;th&gt;HDR accumulation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;beam 1.5 → mapped to 0.6&lt;/td&gt;
&lt;td&gt;beam 1.5 → stored as 1.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;decayed 0.54 + beam 1.5 = 2.04 → mapped to 0.67&lt;/td&gt;
&lt;td&gt;decayed 1.35 + beam 1.5 = 2.85 → clamped to 2.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;decayed 0.60 + beam 1.5 = 2.10 → mapped to 0.68&lt;/td&gt;
&lt;td&gt;composite: tone map 2.5 → &lt;strong&gt;0.79&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With per-frame tone mapping, values converge to a fixed point. The phosphor persistence looks flat and lifeless.&lt;/p&gt;

&lt;p&gt;With HDR accumulation, dense persistent traces genuinely glow brighter than transient ones. The tone mapping at the end preserves the dynamic range while keeping everything displayable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audio Visualization
&lt;/h2&gt;

&lt;p&gt;Phosphor includes four audio display modes that feed signal data into the same rendering pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Waveform&lt;/strong&gt; — time-domain display using &lt;code&gt;AnalyserNode.getByteTimeDomainData()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spectrum&lt;/strong&gt; — FFT with logarithmic frequency scale&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X-Y&lt;/strong&gt; — stereo oscilloscope (left channel → X, right channel → Y)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Radial&lt;/strong&gt; — circular spectrum with beat detection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can drag-and-drop audio files or use your microphone. Auto-gain scales weak signals to fill the screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;The pipeline runs comfortably at 60fps on integrated GPUs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pass&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beam&lt;/td&gt;
&lt;td&gt;Cheap&lt;/td&gt;
&lt;td&gt;4096 point sprites with additive blending&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phosphor&lt;/td&gt;
&lt;td&gt;Cheap&lt;/td&gt;
&lt;td&gt;Full-screen quad, two texture reads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bloom&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;td&gt;Two half-res blur passes, 13 taps each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Composite&lt;/td&gt;
&lt;td&gt;Cheap&lt;/td&gt;
&lt;td&gt;Full-screen quad with math&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The HalfFloat textures are the biggest memory cost (4 at full resolution), but modern GPUs handle this without issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The whole thing runs in the browser. No install needed:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;a href="https://hubertlim.github.io/oscilloscope_playground/" rel="noopener noreferrer"&gt;→ Live Demo&lt;/a&gt;&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Or run it locally with Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/hubertlim/oscilloscope_playground.git
&lt;span class="nb"&gt;cd &lt;/span&gt;oscilloscope_playground
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.com/hubertlim/oscilloscope_playground" rel="noopener noreferrer"&gt;source is MIT licensed&lt;/a&gt;. If you're interested in the shader code, start with the &lt;a href="https://github.com/hubertlim/oscilloscope_playground/tree/main/frontend/src/shaders" rel="noopener noreferrer"&gt;shaders directory&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hubertlim.github.io/posts/phosphor-rendering-pipeline/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>javascript</category>
      <category>creativecoding</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
