<?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: hb lai</title>
    <description>The latest articles on DEV Community by hb lai (@hblai_filmlook).</description>
    <link>https://dev.to/hblai_filmlook</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%2F3971544%2F07148009-54bc-435d-91ec-c30a2d094054.png</url>
      <title>DEV Community: hb lai</title>
      <link>https://dev.to/hblai_filmlook</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hblai_filmlook"/>
    <language>en</language>
    <item>
      <title>Where indie web tools actually get their backlinks (it isn't the directories)</title>
      <dc:creator>hb lai</dc:creator>
      <pubDate>Sun, 09 Aug 2026 07:30:58 +0000</pubDate>
      <link>https://dev.to/hblai_filmlook/where-indie-web-tools-actually-get-their-backlinks-it-isnt-the-directories-5ebc</link>
      <guid>https://dev.to/hblai_filmlook/where-indie-web-tools-actually-get-their-backlinks-it-isnt-the-directories-5ebc</guid>
      <description>&lt;p&gt;Every "launch your side project" checklist has the same list on it. Product Hunt. AlternativeTo. BetaList. There's An AI For That. Toolify. Futurepedia. G2 if you're feeling corporate.&lt;/p&gt;

&lt;p&gt;I run a small free photo-filter tool. Before spending a weekend submitting to all of them, I did something I should have done first: I pulled the backlink profiles of the competitors who are beating me, and looked at where their links actually come from.&lt;/p&gt;

&lt;p&gt;Not one of those directories showed up. Not one.&lt;/p&gt;

&lt;p&gt;Here's what I found instead, and how I checked it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Four domains, all in the same niche (browser-based retro/glitch photo effects):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;site&lt;/th&gt;
&lt;th&gt;total backlinks&lt;/th&gt;
&lt;th&gt;what I sampled&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;competitor A (direct rival)&lt;/td&gt;
&lt;td&gt;417&lt;/td&gt;
&lt;td&gt;333 (80%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;competitor B (much bigger, older)&lt;/td&gt;
&lt;td&gt;~13,000&lt;/td&gt;
&lt;td&gt;top 356 follow links by page authority&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;competitor C (new, same idea)&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;my own site&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I pulled these from Semrush's backlink report, paginating the table and scraping each row into a flat list: source URL, page authority, follow/nofollow, first seen. Then I grouped by domain and sorted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sampling caveat up front&lt;/strong&gt;, because it matters for how much you should trust this: for competitor B I only looked at 356 of its ~7,100 follow links, about 5%. That's not a random 5%. It's the highest-authority 5%. Directory listings tend to be high-authority pages, so if they existed I'd expect them near the top. Their absence there is meaningful, but it's an inference, not a census. For competitor A I have 80% of the profile, which is solid.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually there
&lt;/h2&gt;

&lt;p&gt;I grepped both profiles for every directory name from the standard checklist: &lt;code&gt;altern&lt;/code&gt;, &lt;code&gt;producthunt&lt;/code&gt;, &lt;code&gt;saashub&lt;/code&gt;, &lt;code&gt;toolify&lt;/code&gt;, &lt;code&gt;futurepedia&lt;/code&gt;, &lt;code&gt;theresanaiforthat&lt;/code&gt;, &lt;code&gt;betalist&lt;/code&gt;, &lt;code&gt;slant&lt;/code&gt;, &lt;code&gt;capterra&lt;/code&gt;, &lt;code&gt;g2.&lt;/code&gt;, &lt;code&gt;stackshare&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Zero hits.&lt;/p&gt;

&lt;p&gt;What I got instead, dominating everything:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;204 links from 185 different personal websites&lt;/strong&gt;, nearly all dofollow. Mostly Neocities and Nekoweb sites: the free-static-hosting corner of the web where people still hand-write HTML and keep a page called &lt;code&gt;/links&lt;/code&gt; or &lt;code&gt;/resources&lt;/code&gt; or &lt;code&gt;/cool-sites&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One single Neocities site linked competitor B &lt;strong&gt;168 times&lt;/strong&gt; (it's a big personal directory, one link per tool it catalogues).&lt;/p&gt;

&lt;p&gt;The striking part: those 185 sites barely overlap. Exactly one of them turned up in more than one competitor's profile. These aren't a network. They're 185 individual people who each found a tool and added it to their own list.&lt;/p&gt;

&lt;p&gt;There's a second tier that's smaller but real: Substack newsletters doing "18 free tools for X" roundups, niche wikis, a couple of &lt;code&gt;.edu&lt;/code&gt; pages where a lecturer keeps a digital-art tool list, and, in competitor B's case, a cluster of Chinese design-navigation sites that accept submissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I got wrong before I looked
&lt;/h2&gt;

&lt;p&gt;My assumption was that "free web tool" is a category that lives in tool directories. For SaaS, maybe. For a thing you use once in a browser and never sign up for, the people who link to you are the people who &lt;em&gt;collect&lt;/em&gt; things like that. And the people who collect things like that, in this niche, keep personal homepages.&lt;/p&gt;

&lt;p&gt;That's not a growth hack. It's just: your links come from wherever your actual audience hangs out, and that's a different place for every niche. The generic checklist encodes someone else's niche.&lt;/p&gt;

&lt;h2&gt;
  
  
  A warning about the noise
&lt;/h2&gt;

&lt;p&gt;Something else fell out of the data that's worth flagging if you ever run this check.&lt;/p&gt;

&lt;p&gt;Competitor A's profile contains &lt;strong&gt;122 links from a network of near-identical junk domains&lt;/strong&gt;: beer-brand names, &lt;code&gt;.xyz&lt;/code&gt; TLDs, all serving the same template. That's a third of the 333 links I pulled. My own tiny profile has the same shape: of 38 backlinks, 36 are auto-generated "Boost your Google rankings!" scraper pages that also point at everyone else in the niche.&lt;/p&gt;

&lt;p&gt;I want to be careful here, because it's easy to read that as an accusation. &lt;strong&gt;You cannot tell from a backlink report who put a link there.&lt;/strong&gt; Nobody asks permission to link to you. Scraper networks and spam farms hoover up domains and link at them automatically; showing up in one says approximately nothing about the site being linked. I'd assume mine arrived exactly the way theirs did. By robot.&lt;/p&gt;

&lt;p&gt;The practical takeaway is just: when you read a profile, filter the junk before you count anything. Competitor A looks like a 417-link site. Strip the template spam out of what I sampled and roughly 210 links are left. And don't panic-disavow. A handful of obvious template spam is noise every site accumulates.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm doing with this
&lt;/h2&gt;

&lt;p&gt;Two things.&lt;/p&gt;

&lt;p&gt;First, I stopped planning the directory sprint. Not because directories are bad, but because in &lt;em&gt;this&lt;/em&gt; niche the evidence says they pay nothing.&lt;/p&gt;

&lt;p&gt;Second, I made an actual site in that community: a small hand-written page about why old digital camera photos look the way they do, with the slider values I use, hosted on Nekoweb like everyone else there. Not a landing page. That crowd can smell a landing page from orbit.&lt;/p&gt;

&lt;p&gt;And I made an 88×31 button, because that turns out to be the actual unit of exchange. That's how those 185 links happened: someone put your button on their links page. There's no form. There's no submission. You make a thing, you make a button, and you participate.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to run this on your own niche
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Pick 2–3 competitors who are genuinely ahead of you, not aspirational giants. Their links should be links you could plausibly also get.&lt;/li&gt;
&lt;li&gt;Pull the backlink profile and export or scrape the whole table, not the summary.&lt;/li&gt;
&lt;li&gt;Filter out the spam networks first. Look for many near-identical domains sharing a URL path pattern.&lt;/li&gt;
&lt;li&gt;Group by registrable domain, but keep subdomains separate if the host is a platform (every Neocities user is a subdomain; collapsing them would have hidden this entire finding).&lt;/li&gt;
&lt;li&gt;Ignore the totals. Read the &lt;em&gt;shape&lt;/em&gt;. Ask "who is this person and why did they link this," and the pattern shows up fast.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The answer for me was "people who keep link pages." It'll be something else for you. But it's a couple of hours of scraping to find out, and it's cheaper than a weekend of directory submissions that go nowhere.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://digicamfilter.online/" rel="noopener noreferrer"&gt;digicamfilter.online&lt;/a&gt;, a free in-browser retro photo filter. It runs as a WebGL shader in your own tab, so nothing gets uploaded anywhere. The community site from this post is &lt;a href="https://deadpixelsclub.nekoweb.org/" rel="noopener noreferrer"&gt;dead pixels club&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>indiehackers</category>
      <category>marketing</category>
    </item>
    <item>
      <title>MARVEL Tōkon: Fighting Souls — a fan database that admits what it doesn't know</title>
      <dc:creator>hb lai</dc:creator>
      <pubDate>Sun, 09 Aug 2026 07:03:35 +0000</pubDate>
      <link>https://dev.to/hblai_filmlook/marvel-tokon-fighting-souls-a-fan-database-that-admits-what-it-doesnt-know-23hl</link>
      <guid>https://dev.to/hblai_filmlook/marvel-tokon-fighting-souls-a-fan-database-that-admits-what-it-doesnt-know-23hl</guid>
      <description>&lt;p&gt;Most coverage of a brand-new fighting game falls into two piles: aggregators that paywall the basics, and blogs that state guesses as fact. When &lt;a href="https://marveltokon.org/" rel="noopener noreferrer"&gt;MARVEL Tōkon: Fighting Souls&lt;/a&gt; launched, I wanted the opposite, so I built one.&lt;/p&gt;

&lt;p&gt;The idea is simple: document every launch fighter, all 169 recorded moves, the five teams and every stage, and put a source on each page. Where a fact hasn't been published, the page says so instead of inventing a number. There's still no public frame data for this game, so any site handing you startup and recovery values right now is making them up. This one leaves the column empty and says why.&lt;/p&gt;

&lt;p&gt;The same honesty rule covers the questions everyone asks first. Take &lt;a href="https://marveltokon.org/crossplay" rel="noopener noreferrer"&gt;crossplay&lt;/a&gt;: PS5 and PC matchmaking is confirmed, but cross-progression isn't, and the page keeps those two apart instead of blurring them into one confident-sounding answer.&lt;/p&gt;

&lt;p&gt;Two things on the site are tools, not documentation. The &lt;a href="https://marveltokon.org/team-builder" rel="noopener noreferrer"&gt;4v4 team builder&lt;/a&gt; drafts a point character and three assists from the whole roster, then hands you a link that rebuilds the exact squad — paste it into Discord and your teammate sees the same team. The &lt;a href="https://marveltokon.org/tier-list-maker" rel="noopener noreferrer"&gt;tier-list maker&lt;/a&gt; lets you rank the roster into S through D and share that too; the opinion is yours, because a real tier list needs match data nobody has yet.&lt;/p&gt;

&lt;p&gt;The one piece of genuine data the site has is the open beta. A snapshot of the Vibranium+ leaderboard, taken just before the servers closed, shows which fighters high-rank players actually picked — split by whether they were fielded as the point character or as an assist. Magik led on raw picks, but most were as an assist; Blade was the most-called assist. That split is the closest thing to hard data before the retail meta settles, and &lt;a href="https://marveltokon.org/character-usage" rel="noopener noreferrer"&gt;this breakdown&lt;/a&gt; is the only place that transcribed the numbers rather than the ordering.&lt;/p&gt;

&lt;p&gt;No official art, no account, no paywall.&lt;/p&gt;

</description>
      <category>gaming</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Twelve retro photo effects in one WebGL fragment shader (and why the photo never leaves the browser)</title>
      <dc:creator>hb lai</dc:creator>
      <pubDate>Tue, 04 Aug 2026 16:02:03 +0000</pubDate>
      <link>https://dev.to/hblai_filmlook/twelve-retro-photo-effects-in-one-webgl-fragment-shader-and-why-the-photo-never-leaves-the-browser-589b</link>
      <guid>https://dev.to/hblai_filmlook/twelve-retro-photo-effects-in-one-webgl-fragment-shader-and-why-the-photo-never-leaves-the-browser-589b</guid>
      <description>&lt;p&gt;Every "make my photo look like film" tool I looked at wanted me to upload the photo first. That is a server bill, a privacy promise I would have to keep, and a round trip before the user sees anything.&lt;/p&gt;

&lt;p&gt;None of it is necessary. Grain, bloom, chromatic aberration, color grading: these are per-pixel math. The GPU in the phone that took the photo can do all of it, in real time, while the file stays in memory.&lt;/p&gt;

&lt;p&gt;Here is how the shader is built, including several things I got wrong and had to correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: one pass
&lt;/h2&gt;

&lt;p&gt;No framebuffer ping-pong, no multi-pass pipeline. One fragment shader with twelve effect uniforms, each mapped to a slider, plus the image, resolution, and time:&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="k"&gt;uniform&lt;/span&gt; &lt;span class="kt"&gt;sampler2D&lt;/span&gt; &lt;span class="n"&gt;u_image&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;uniform&lt;/span&gt; &lt;span class="kt"&gt;vec2&lt;/span&gt; &lt;span class="n"&gt;u_resolution&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;uniform&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;u_grain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;uniform&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;u_softness&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;uniform&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;u_contrast&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ... u_saturation, u_warmth, u_tint, u_highlights,&lt;/span&gt;
&lt;span class="c1"&gt;//     u_shadows, u_vignette, u_aberration, u_fade, u_bloom&lt;/span&gt;
&lt;span class="k"&gt;uniform&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;u_time&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth being precise about what "single pass" means here: the texture-sampling effects (aberration, softness, bloom) all read from &lt;code&gt;u_image&lt;/code&gt;, the original. Everything after that is arithmetic on an accumulated &lt;code&gt;color&lt;/code&gt; variable, applied in sequence. So it is not "every effect reads the original" and it is not a clean chain either. It is both, split at the point where sampling stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chromatic aberration: displace radially
&lt;/h2&gt;

&lt;p&gt;The first version shifted the red and blue channels by a fixed pixel offset. It looked like a 3D movie poster.&lt;/p&gt;

&lt;p&gt;For the lateral fringing a real lens produces, the offset needs to grow with distance from the image center and point outward. Center stays corrected, corners get the color separation:&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;aberrationAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u_aberration&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="mo"&gt;003&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;center&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uv&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;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;center&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;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;center&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;r&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;u_image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uv&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dir&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;aberrationAmount&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;r&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;g&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;u_image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uv&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;g&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;b&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;u_image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uv&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;dir&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;aberrationAmount&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;b&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="kt"&gt;vec3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Green is the unshifted reference. Note the direction carefully, because it is the opposite of what it looks like: sampling red at &lt;code&gt;uv + offset&lt;/code&gt; pulls in red from further out, so red &lt;em&gt;features&lt;/em&gt; end up displaced inward, and blue outward.&lt;/p&gt;

&lt;p&gt;One cleanup I have not shipped yet: &lt;code&gt;normalize(center)&lt;/code&gt; is undefined exactly at the center pixel. Since &lt;code&gt;dir * dist&lt;/code&gt; is just &lt;code&gt;center&lt;/code&gt;, the safe equivalent is &lt;code&gt;vec2 offset = center * aberrationAmount;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This models lateral chromatic aberration specifically. Longitudinal CA is a different phenomenon, and a fixed offset is still a perfectly good stylistic choice if that is the look you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bloom: gate on luminance, ramp on the excess
&lt;/h2&gt;

&lt;p&gt;Bloom is what makes an image read as "old sensor." Bright areas bleed outward instead of clipping flat. Blurring the whole frame and mixing it back just produces mud.&lt;/p&gt;

&lt;p&gt;Gate it on luminance instead, and let the mix strength grow with how far past the threshold a pixel sits:&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;blurred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u_image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u_bloom&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;5&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;luminance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blurred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;vec3&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;299&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;587&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;114&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;luminance&lt;/span&gt; &lt;span class="o"&gt;&amp;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="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;mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blurred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u_bloom&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;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;luminance&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;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;0&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;Two details worth stealing.&lt;/p&gt;

&lt;p&gt;The luminance comes from &lt;code&gt;blurred&lt;/code&gt;, the softened sampling of the original, not from the sharp pixel. That is what lets the glow spread past the actual edge of a highlight. Measure luminance on the sharp image and the glow stops dead at the boundary.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;(luminance - 0.5) * 2.0&lt;/code&gt; remaps 0.5..1.0 back to 0..1, so a pixel barely over the threshold gets almost nothing and a blown-out one gets the maximum. There is still a hard branch at 0.5, but the weight rises continuously from zero, so no visible seam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grain: hash noise, animated by time
&lt;/h2&gt;

&lt;p&gt;A widely circulated sine-dot pseudo-random hash, plus &lt;code&gt;u_time&lt;/code&gt; so the pattern changes per frame instead of freezing into a static overlay:&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="nf"&gt;hash&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;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&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;127&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;311&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="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;43758&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5453&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="nf"&gt;grain&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;uv&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;intensity&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;noise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uv&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;u_resolution&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;u_time&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;noise&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;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;intensity&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;Multiplying &lt;code&gt;uv&lt;/code&gt; by &lt;code&gt;u_resolution&lt;/code&gt; before hashing is the part people miss. It makes the noise vary at roughly one sample per output pixel. Hash the raw normalized UV instead and the pattern's feature count stays tied to normalized coordinates, so its apparent size in pixels changes with the output resolution: enormous blobs on a large canvas, fine dust on a small one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Saturation: HSL because the control is legible
&lt;/h2&gt;

&lt;p&gt;The cheap alternative, which this shader does &lt;strong&gt;not&lt;/strong&gt; use, is to lerp away from luminance:&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="c1"&gt;// not repo code, shown for contrast&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;mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;vec3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lum&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;color&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;u_saturation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I originally wanted to claim this method drags hues toward the primaries. That is not right: scaling RGB distance from neutral gray preserves RGB hue right up until values clip.&lt;/p&gt;

&lt;p&gt;The actual reason I went with an HSL round trip is more boring: saturation is an explicit component, so the slider maps to one number and the code says what it does.&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;hsl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rgb2hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&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;u_saturation&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;hsl2rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither approach guarantees perceptually exact hue once out-of-gamut values get clamped, and this shader does not clamp &lt;code&gt;hsl.y&lt;/code&gt; before converting back. On a single full-screen pass the round trip has not been a bottleneck on anything I have tested, including a five-year-old midrange Android.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fade: just add a constant
&lt;/h2&gt;

&lt;p&gt;The washed, lifted-blacks look of old prints is the least sophisticated line in the shader:&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="n"&gt;color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;color&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="kt"&gt;vec3&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;08&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;u_fade&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raise the floor so nothing is truly black. I spent an embarrassing amount of time on tone curves before landing here. It reads correctly because lifted blacks are exactly what faded prints and cheap film scans do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Order
&lt;/h2&gt;

&lt;p&gt;The sequence runs optical, then tonal, then color, then texture:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Chromatic aberration&lt;/li&gt;
&lt;li&gt;Softness&lt;/li&gt;
&lt;li&gt;Bloom&lt;/li&gt;
&lt;li&gt;Contrast, highlights, shadows&lt;/li&gt;
&lt;li&gt;Warmth, tint, saturation&lt;/li&gt;
&lt;li&gt;Fade, vignette&lt;/li&gt;
&lt;li&gt;Grain&lt;/li&gt;
&lt;li&gt;Clamp&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The useful property is that vignette and grain land on the final graded color rather than being pushed through the color stage. Move grain before softness and the softness &lt;code&gt;mix&lt;/code&gt; attenuates it. It would not get convolved away, since &lt;code&gt;blur()&lt;/code&gt; always samples &lt;code&gt;u_image&lt;/code&gt; and never the accumulated color, which is a subtlety of the single-pass design that took me a re-read of my own shader to state correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The export bug that cost me an afternoon
&lt;/h2&gt;

&lt;p&gt;Everything looked right on screen. &lt;code&gt;canvas.toDataURL()&lt;/code&gt; returned blank.&lt;/p&gt;

&lt;p&gt;WebGL is allowed to discard the drawing buffer after compositing. If you serialize synchronously right after drawing you can get away with it, but an editor that exports on a button click, some frames later, will not. The options are to rerender immediately before export, render into an offscreen framebuffer, or ask the context to keep the buffer around:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webgl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;preserveDrawingBuffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;premultipliedAlpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;I took the third. There is a cost, but for an editor that renders on slider input rather than in a 60fps loop it is invisible. Context attributes cannot be changed after creation, so this has to be decided up front.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the blur actually is
&lt;/h2&gt;

&lt;p&gt;Calling this a "5x5 box blur" undersells how approximate it is:&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="k"&gt;for&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&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;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;x&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;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&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;float&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&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;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;y&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;0&lt;/span&gt;&lt;span class="p"&gt;)&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="kt"&gt;vec2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;result&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;tex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uv&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="n"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;total&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;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;25 equally weighted samples on a 5x5 grid, but the spacing scales with &lt;code&gt;radius&lt;/code&gt;. At maximum softness the taps sit 3 pixels apart and span 13 pixels; at maximum bloom, 5 apart spanning 21. That is a sparse box-style approximation, not a contiguous convolution and definitely not a gaussian. A separable or downsampled blur would cover the same radius with better quality per sample, and if you are doing anything precision-sensitive you should use one.&lt;/p&gt;

&lt;p&gt;For glow and softness at these radii I have not found it worth the extra complexity. That is a judgment call, not a benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it runs
&lt;/h2&gt;

&lt;p&gt;The pipeline runs on the user's device. No upload, no server-side image processing cost, and the photo is never transmitted or persisted. (Analytics and the rest of the site still need their own disclosure; local processing is not a blanket exemption from writing a privacy policy.)&lt;/p&gt;

&lt;p&gt;JPG, PNG, and WebP go through the same path everywhere. HEIC works only where the browser decodes it natively, since there is no bundled decoder. There is a Canvas 2D fallback for contexts where WebGL is unavailable, but it is honestly a reduced one: contrast, warmth, saturation, fade, grain, and vignette only. Softness, bloom, aberration, tint, highlights, and shadows are WebGL-only.&lt;/p&gt;

&lt;p&gt;If you want to see the output, the tool is at &lt;a href="https://digicamfilter.online" rel="noopener noreferrer"&gt;digicamfilter.online&lt;/a&gt; (free, no signup). Each look documents its exact twelve slider values, so you can read the numbers off and reproduce any of it in your own shader.&lt;/p&gt;

&lt;p&gt;The bloom luminance-from-blurred trick and the grain resolution scaling are the two I would prioritize if you are building something similar. Happy to answer questions on any of it.&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>javascript</category>
      <category>graphics</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a 100-question Rice Purity Test with Next.js: persona theming + a canvas share card</title>
      <dc:creator>hb lai</dc:creator>
      <pubDate>Tue, 23 Jun 2026 16:48:48 +0000</pubDate>
      <link>https://dev.to/hblai_filmlook/building-a-100-question-rice-purity-test-with-nextjs-persona-theming-a-canvas-share-card-1d7e</link>
      <guid>https://dev.to/hblai_filmlook/building-a-100-question-rice-purity-test-with-nextjs-persona-theming-a-canvas-share-card-1d7e</guid>
      <description>&lt;p&gt;I shipped a small side project last week, a Rice Purity Test, and a couple of the build details turned out more interesting than the quiz itself. Two in particular: making the result screen recolor itself based on your score, and exporting a share card to PNG entirely in the browser. Notes below in case they save someone an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;It's a Next.js 16 app on the App Router, deployed to Cloudflare Workers via OpenNext. The quiz is one client component (an "island") sitting inside otherwise static, server-rendered pages, so the SEO content stays fast and only the interactive part hydrates. 100 checkbox questions; your score is 100 minus whatever you tick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persona-adaptive theming
&lt;/h2&gt;

&lt;p&gt;This was the fun part. Instead of one result screen, the page picks a colour identity from your score band and drives everything off a single CSS custom property.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--persona&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--sage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c"&gt;/* high score, reserved */&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-band&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"wild"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--persona&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--crimson&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* low score */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result component sets a &lt;code&gt;data-band&lt;/code&gt; attribute plus the accent colour, and the card background, the borders, even the shadow tint all follow from &lt;code&gt;--persona&lt;/code&gt;. No conditional class soup. Ten bands, one variable.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bandForScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 0..9&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt; &lt;span class="na"&gt;data-band&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;band&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--persona&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;section&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;Setting a CSS var inline in React like that is underused. You compute a theme value in JS and hand it straight to plain CSS, no styled-components, no re-render storm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exporting the share card as a PNG
&lt;/h2&gt;

&lt;p&gt;People want to post their score, so the card has to become an image. I used &lt;code&gt;html-to-image&lt;/code&gt; and dynamic-imported it so it never touches the initial bundle:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;savePng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// gotcha #1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toPng&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;html-to-image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;toPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pixelRatio&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="na"&gt;cacheBust&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;  &lt;span class="c1"&gt;// gotcha #2&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rice-purity-score.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&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;Two things ate that hour. Web fonts must be fully loaded before you rasterize or the card renders in a fallback font, hence &lt;code&gt;await document.fonts.ready&lt;/code&gt;. And the default export looks soft on retina, so &lt;code&gt;pixelRatio: 2&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  i18n without moving indexed URLs
&lt;/h2&gt;

&lt;p&gt;I added five languages after launch. English stays at the root (unprefixed) so nothing already indexed moves; other locales get a prefix (&lt;code&gt;/es&lt;/code&gt;, &lt;code&gt;/pt&lt;/code&gt;, and so on). One dictionary file per locale, all matching the same TypeScript &lt;code&gt;Dict&lt;/code&gt; shape, and the client quiz reads the dict as plain serializable data. hreflang and canonical both generate from a single &lt;code&gt;LOCALES&lt;/code&gt; array, so adding a language is basically: write the dict, append the code, deploy.&lt;/p&gt;

&lt;p&gt;If you want to poke at the finished thing, it's live at &lt;a href="https://ricepuritytest.art/" rel="noopener noreferrer"&gt;ricepuritytest.art&lt;/a&gt;, and the &lt;a href="https://ricepuritytest.art/score-meaning" rel="noopener noreferrer"&gt;score-meaning breakdown&lt;/a&gt; is where those band colours come from. Happy to get into any of the build details in the comments.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>css</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Building a paint-to-hide browser minigame with Next.js and SVG</title>
      <dc:creator>hb lai</dc:creator>
      <pubDate>Tue, 16 Jun 2026 03:58:42 +0000</pubDate>
      <link>https://dev.to/hblai_filmlook/building-a-paint-to-hide-browser-minigame-with-nextjs-and-svg-18fn</link>
      <guid>https://dev.to/hblai_filmlook/building-a-paint-to-hide-browser-minigame-with-nextjs-and-svg-18fn</guid>
      <description>&lt;p&gt;There's a PC party game called Meccha Chameleon where hiders paint their body to match a wall or a crate and freeze, while seekers try to spot them. I got a bit obsessed with the camouflage idea and built two small things around it.&lt;/p&gt;

&lt;p&gt;The first is a camo "lab": pick a surface and it hands you the exact colours, a pattern, and a pose. Under the hood it's one inline SVG chameleon whose fills are driven by a per-surface palette. Recolouring an inline SVG with a few variables turned out far simpler than generating images, and each surface is a tiny data object (three hex colours, a pattern, a blend score), so adding one is a one-line change.&lt;/p&gt;

&lt;p&gt;The second is an actual playable minigame: a grid of tiles, a few of them hiding a chameleon painted to match its tile, and you tap to find them before the timer runs out. It's all React state, no canvas and no engine, which kept it tiny and instant to load.&lt;/p&gt;

&lt;p&gt;It runs as a Next.js app on Cloudflare Workers via OpenNext, a nice combo for a mostly-static site with a couple of client tools: static pages cache at the edge while the interactive bits hydrate on the client.&lt;/p&gt;

&lt;p&gt;If you want to poke at it, the &lt;a href="https://mecchachameleon.online/" rel="noopener noreferrer"&gt;Camo Lab is here&lt;/a&gt; and the &lt;a href="https://mecchachameleon.online/play" rel="noopener noreferrer"&gt;browser game is here&lt;/a&gt;. Happy to answer anything about the SVG recolour or the OpenNext setup.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>I built a free toolbox for Threads (no login, no watermark)</title>
      <dc:creator>hb lai</dc:creator>
      <pubDate>Mon, 08 Jun 2026 03:04:41 +0000</pubDate>
      <link>https://dev.to/hblai_filmlook/i-built-a-free-toolbox-for-threads-no-login-no-watermark-1ign</link>
      <guid>https://dev.to/hblai_filmlook/i-built-a-free-toolbox-for-threads-no-login-no-watermark-1ign</guid>
      <description>&lt;p&gt;I post on Threads a fair bit, and I kept hitting the same small walls: no way to save a video, no clean screenshot, and reposting from X meant retyping everything. The sites that promised to help were buried in ads or wanted a login for a five-second job.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://threadstools.app" rel="noopener noreferrer"&gt;ThreadsTools&lt;/a&gt;, a set of small, single-purpose tools for Threads. Free, no login, no watermark.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Download the video, photos, GIF, or audio from any public Threads post&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://threadstools.app/tools/threads-audio-downloader" rel="noopener noreferrer"&gt;Threads to MP3&lt;/a&gt; converter that pulls just the audio&lt;/li&gt;
&lt;li&gt;A screenshot maker, plus quote-card and mockup generators&lt;/li&gt;
&lt;li&gt;Converters that turn an X tweet or a LinkedIn post into a Threads draft&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A couple of build notes
&lt;/h2&gt;

&lt;p&gt;The media downloads stream straight from the CDN to the browser, because Meta's CDN blocks server-side fetches anyway. The MP4-to-GIF and MP4-to-MP3 conversions run client-side with ffmpeg.wasm, so nothing gets uploaded. The whole thing runs on Cloudflare Workers, mostly because there are no egress fees, which is what makes a free downloader that streams video affordable to run.&lt;/p&gt;

&lt;p&gt;Code and notes: &lt;a href="https://github.com/Nora4844/threadstools" rel="noopener noreferrer"&gt;https://github.com/Nora4844/threadstools&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Full set of tools: &lt;a href="https://threadstools.app/tools" rel="noopener noreferrer"&gt;threadstools.app/tools&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
    </item>
    <item>
      <title>The 'bad photo' look is back — here's the engineering behind it</title>
      <dc:creator>hb lai</dc:creator>
      <pubDate>Sat, 06 Jun 2026 16:46:56 +0000</pubDate>
      <link>https://dev.to/hblai_filmlook/the-bad-photo-look-is-back-heres-the-engineering-behind-it-1h59</link>
      <guid>https://dev.to/hblai_filmlook/the-bad-photo-look-is-back-heres-the-engineering-behind-it-1h59</guid>
      <description>&lt;p&gt;Every few years a visual style comes back, but this one is funny: it's literally old engineering bugs becoming features. The "2000s digicam" / CCD / disposable-camera look that's everywhere right now is just a stack of old hardware limitations — and you can recreate every one of them in code.&lt;/p&gt;

&lt;p&gt;Here's what actually made those photos look "wrong," and how each maps to something you can fake today.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Tiny sensors → noise
&lt;/h2&gt;

&lt;p&gt;Early compacts and phone cameras had minuscule sensors. Less light per pixel means more noise — that fine speckled grain in the shadows. Engineers spent ~15 years killing it with bigger sensors and computational denoising. To fake it: add per-pixel luminance noise, weighted toward the shadows.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Aggressive compression → blocky softness
&lt;/h2&gt;

&lt;p&gt;Storage was tiny and expensive, so cameras compressed hard. Low-quality JPEG smears fine detail and leaves 8×8 block artifacts. Re-encoding at low quality (or simulating DCT block quantization) gives you that soft, slightly-mushy texture.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. On-board flash → the flat night look
&lt;/h2&gt;

&lt;p&gt;A bright bulb right next to the lens gives flat, head-on lighting: bright faces, hard shadows, a background that falls to black. Approximate it with a radial highlight near the subject and a steep falloff.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Narrow dynamic range → clipped highlights
&lt;/h2&gt;

&lt;p&gt;Old sensors couldn't hold bright and dark at once, so skies blew to white and shadows crushed to black. A tone curve that clips both ends reproduces it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. CCD color science → the cool cast
&lt;/h2&gt;

&lt;p&gt;CCD sensors leaned cool, sometimes slightly green. A small channel-mixer / white-balance shift toward cyan-green nails the "CCD" feeling people are nostalgic for.&lt;/p&gt;

&lt;p&gt;The fun part: do all five at once and you don't get "a filter" — you get a photo that looks like it &lt;em&gt;remembers&lt;/em&gt; something. It reads as a real moment instead of a product shot.&lt;/p&gt;

&lt;p&gt;I've been poking at a free in-browser version that does these as one-click presets (iPhone 4, CCD, disposable, Y2K). Everything runs locally on a canvas, nothing uploads: &lt;a href="https://digicamfilter.online/" rel="noopener noreferrer"&gt;digicamfilter.online&lt;/a&gt;. Useful for seeing each effect in isolation before wiring up your own.&lt;/p&gt;

&lt;p&gt;Anyway — half of "retro" aesthetics are just old constraints we worked hard to remove, switched back on by choice. Kind of poetic.&lt;/p&gt;

</description>
      <category>photography</category>
      <category>webdev</category>
      <category>design</category>
    </item>
  </channel>
</rss>
