<?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: Lessicon</title>
    <description>The latest articles on DEV Community by Lessicon (@lessicon).</description>
    <link>https://dev.to/lessicon</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%2F4237620%2Ffdb2bb46-d580-4065-a1ed-a8fd56f739fa.png</url>
      <title>DEV Community: Lessicon</title>
      <link>https://dev.to/lessicon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lessicon"/>
    <language>en</language>
    <item>
      <title>I ray-marched a black hole for my landing page. It goes dark on Nov 25 unless 200 people come back</title>
      <dc:creator>Lessicon</dc:creator>
      <pubDate>Tue, 10 Nov 2026 17:54:32 +0000</pubDate>
      <link>https://dev.to/lessicon/i-ray-marched-a-black-hole-for-my-landing-page-it-goes-dark-on-nov-25-unless-200-people-come-back-n5p</link>
      <guid>https://dev.to/lessicon/i-ray-marched-a-black-hole-for-my-landing-page-it-goes-dark-on-nov-25-unless-200-people-come-back-n5p</guid>
      <description>&lt;p&gt;I'm a student, and I'm building &lt;a href="https://lessicon.com/?src=devto&amp;amp;c=blackhole" rel="noopener noreferrer"&gt;Lessicon&lt;/a&gt;: you throw your lesson notes (or a photo of them, or just a topic) into a black hole, and a whole study website comes out. Explanations, a quiz, a concept map, cards with spaced repetition, progress.&lt;/p&gt;

&lt;p&gt;The black hole isn't a video. It's a fragment shader running live in your browser, and this post is how it works. At the end there's the slightly unhinged part: a public deadline.&lt;/p&gt;

&lt;h2&gt;
  
  
  One triangle, one ray per pixel
&lt;/h2&gt;

&lt;p&gt;The whole thing is a single fullscreen triangle in three.js with a &lt;code&gt;ShaderMaterial&lt;/code&gt;. Three vertices, &lt;code&gt;(-1,-1)&lt;/code&gt;, &lt;code&gt;(3,-1)&lt;/code&gt;, &lt;code&gt;(-1,3)&lt;/code&gt;, cover the screen, so the fragment shader runs once per pixel and does everything: it shoots a ray from the camera and marches it toward the hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bending light without general relativity
&lt;/h2&gt;

&lt;p&gt;A full geodesic integrator is overkill for a landing page. The well-known shortcut (you'll find it in a lot of real-time black holes) is to march the ray in flat 3D space and add one extra acceleration that pulls it toward the mass:&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;vec3&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ro&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;vec3&lt;/span&gt; &lt;span class="n"&gt;cc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cross&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="n"&gt;v&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;h2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// the ray's angular momentum, squared: constant, so compute once&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="mi"&gt;0&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="no"&gt;STEPS&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;r2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&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="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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r2&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&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="n"&gt;captured&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="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;                  &lt;span class="c1"&gt;// inside the horizon: this pixel is shadow&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="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&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="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;04&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;r2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                &lt;span class="c1"&gt;// the bend&lt;/span&gt;
  &lt;span class="n"&gt;vec3&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...check for a disk crossing between p and np...&lt;/span&gt;
  &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&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 make this cheap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;h2&lt;/code&gt; is computed once.&lt;/strong&gt; Because the pull is central, &lt;code&gt;|p × v|&lt;/code&gt; doesn't change along the ray.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The step size grows with distance.&lt;/strong&gt; &lt;code&gt;dt = clamp(0.12 * (r - 0.7), 0.04, 2.2)&lt;/code&gt;: tiny steps near the horizon where the curve is tight, big strides far away. That's what lets it run in 60 to 120 steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It isn't an exact relativistic render, but it gets the things your eye checks right: the back of the disk shows up above and below the hole, and the stars bend around the shadow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The disk
&lt;/h2&gt;

&lt;p&gt;Each step checks whether the ray crossed the disk's plane (&lt;code&gt;p.y&lt;/code&gt; and &lt;code&gt;np.y&lt;/code&gt; have opposite signs), interpolates the exact crossing point, and samples the disk if the radius is between 3 and 11.5. Samples are composited front to back with a transmittance &lt;code&gt;T&lt;/code&gt;, so one ray can pass through the thin disk in front and hit it again behind the hole. That's the arc over the top.&lt;/p&gt;

&lt;p&gt;Three tricks make it look alive:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keplerian spin.&lt;/strong&gt; Angular speed goes as &lt;code&gt;r^-1.5&lt;/code&gt;, so inner rings move faster. If you just rotate noise by &lt;code&gt;w * t&lt;/code&gt; forever, it shears into thin rings within seconds. So there are two noise phases, each sheared for a limited time, cross-faded against each other (the same idea as a flow map).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doppler beaming.&lt;/strong&gt; The side moving toward you is brighter: &lt;code&gt;dp = 1 + 0.55 * dot(velocity, toCamera)&lt;/code&gt;, and the intensity is multiplied by &lt;code&gt;dp * dp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tone mapping.&lt;/strong&gt; &lt;code&gt;col = 1 - exp(-1.25 * col)&lt;/code&gt; keeps the hot inner edge from clipping to flat white.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Making phones survive it
&lt;/h2&gt;

&lt;p&gt;This is the part that took longest.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Steps by quality tier:&lt;/strong&gt; 120, 110, 84 or 60.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer pixels:&lt;/strong&gt; the canvas renders below one pixel per CSS pixel. The glow hides it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render on demand:&lt;/strong&gt; the frame loop caps at 60 fps, drops to 30 when nothing's moving, and stops while the tab is hidden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A governor:&lt;/strong&gt; if frames run long, it steps the quality tier down, at most twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The little site cards orbiting the hole are DOM, not WebGL.&lt;/strong&gt; Drawn into the sub-pixel canvas they came out blurry, so they're HTML elements placed each frame from the same orbit math.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to read or fork the shader, there's a standalone ~9 KB version with no libraries on &lt;a href="https://codepen.io/abdul-rahman-kayali/pen/GgWzYxZ" rel="noopener noreferrer"&gt;CodePen&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes out of the hole
&lt;/h2&gt;

&lt;p&gt;The model only writes words: topics, questions, page copy and a diagram spec. The page itself is built in code from 15 layout families with a per-site seed, so no two sites look the same, and the cards and questions are fact-checked against your notes before the checked page replaces the draft. Generated pages are served sandboxed under a strict Content-Security-Policy, because they're model output built from user input.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deadline
&lt;/h2&gt;

&lt;p&gt;I've promised 200 returning users by November 25. "Returning" means someone who made a study site and came back to make another one, not a sign-up. As of Nov 10 it's 2, out of 20 people who've made a site. The live count is at &lt;a href="https://lessicon.com/200" rel="noopener noreferrer"&gt;lessicon.com/200&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Edit: I first wrote 3 (of 21). One of those was my own account, so it's 2.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it doesn't get there, the black hole page goes dark for 24 hours.&lt;/p&gt;

&lt;p&gt;It's free to start (your first site is free, Pro is $5/month): &lt;a href="https://lessicon.com/?src=devto&amp;amp;c=blackhole" rel="noopener noreferrer"&gt;lessicon.com&lt;/a&gt;. Questions about the shader are very welcome, and if it stutters on your phone, tell me which phone.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written by an AI agent that works for me (Aboud), from my code and the real numbers.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>threejs</category>
      <category>showdev</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
