<?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: sishiya (しし屋)</title>
    <description>The latest articles on DEV Community by sishiya (しし屋) (@sishiya).</description>
    <link>https://dev.to/sishiya</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%2F4184975%2F30462bf6-8971-4a75-a312-8229a601ac48.png</url>
      <title>DEV Community: sishiya (しし屋)</title>
      <link>https://dev.to/sishiya</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sishiya"/>
    <language>en</language>
    <item>
      <title>Solving the Schrödinger equation in the browser and matching tunneling to theory</title>
      <dc:creator>sishiya (しし屋)</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:28:39 +0000</pubDate>
      <link>https://dev.to/sishiya/solving-the-schrodinger-equation-in-the-browser-and-matching-tunneling-to-theory-24pn</link>
      <guid>https://dev.to/sishiya/solving-the-schrodinger-equation-in-the-browser-and-matching-tunneling-to-theory-24pn</guid>
      <description>&lt;p&gt;A particle hits a wall it should not be able to climb, and still gets through with some probability. That is quantum tunneling.&lt;/p&gt;

&lt;p&gt;I made a page that shows this by &lt;strong&gt;solving the 1D Schrödinger equation numerically, as is&lt;/strong&gt;. It uses Canvas 2D and no libraries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sishiya.github.io/manabi-lab/quantum/?lang=en#tunnel" rel="noopener noreferrer"&gt;https://sishiya.github.io/manabi-lab/quantum/?lang=en#tunnel&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When a wave packet hits the wall, part of it bounces back and part leaks through to the other side. At the end, the probability of getting through is compared with the textbook formula.&lt;/p&gt;

&lt;p&gt;This post covers the method (Visscher's method) and how I fixed the place where it disagreed with theory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Equation and units
&lt;/h2&gt;

&lt;p&gt;In units of ħ = 1 and mass m = 1, the equation is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;i ∂ψ/∂t = H ψ,   H = −(1/2) ∂²/∂x² + V(x)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The grid has N = 2000 points, dx = 0.25, time step dt = 0.02, and 80 steps per frame. The wall height is V₀ = 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visscher's method: step the real and imaginary parts in turn
&lt;/h2&gt;

&lt;p&gt;Splitting ψ = R + iI gives two equations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;∂R/∂t =  H I
∂I/∂t = −H R
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visscher's method updates R and I &lt;strong&gt;staggered by half a step, in turn&lt;/strong&gt;: once R is new, the new R is used to advance I. It is about as simple as Euler's method, yet with a small enough time step it is stable and nearly conserves probability.&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dx&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;N&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="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;I&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;I&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;I&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;V&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;I&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;N&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="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;I&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;V&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just two loops. The second loop reads the &lt;code&gt;R&lt;/code&gt; that the first loop just rewrote.&lt;/p&gt;

&lt;p&gt;The initial wave packet is a Gaussian times a wave with momentum k₀ = √(2E), normalized so the total probability is 1:&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;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;X0&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="o"&gt;/&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="nx"&gt;SIG&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;SIG&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;I&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Absorbing the wave at the edges
&lt;/h2&gt;

&lt;p&gt;If the wave bounces off the grid edges, the transmitted part comes back and spoils the count. So the 160 points at each end form an &lt;strong&gt;absorbing layer&lt;/strong&gt; that shrinks the amplitude a little at a time.&lt;/p&gt;

&lt;p&gt;The absorbed probability is summed separately for the left and right edges. What disappears on the left counts as "bounced back", and on the right as "got through", in the final fractions.&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;j&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="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;ABS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.006&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ABS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;ABS&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="k"&gt;for &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;i&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;N&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&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;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;I&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;I&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;I&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="nx"&gt;f&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;lost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dx&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;N&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="nx"&gt;absL&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;absR&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;lost&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Comparing with theory
&lt;/h2&gt;

&lt;p&gt;For E &amp;lt; V₀, the textbook formula gives the probability of passing a wall of height V₀ and thickness a:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T = 1 / (1 + V₀² sinh²(κa) / (4E(V₀ − E))),   κ = √(2(V₀ − E))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This formula is for &lt;strong&gt;a wave with a single energy&lt;/strong&gt;, though. The simulated packet has a small spread of energies, so it will not match exactly. The page labels it "theory (approximate)".&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I got stuck: thin walls were way off
&lt;/h2&gt;

&lt;p&gt;At first, grid points inside the wall had V = 1 and the rest 0. Then &lt;strong&gt;the thinner the wall, the further it drifted from theory&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The cause was that the wall thickness was being rounded to the grid spacing dx = 0.25. Tunneling depends exponentially on thickness, so even one cell at the edge makes a big difference.&lt;/p&gt;

&lt;p&gt;So cells on the wall's edge now get &lt;strong&gt;a height proportional to how much of the cell overlaps the wall&lt;/strong&gt;:&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="c1"&gt;// cells at the wall's edge get partial height, so the thickness isn't rounded to the grid&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ov&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;dx&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="nx"&gt;BC&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;w&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="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;dx&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="nx"&gt;BC&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;w&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="nx"&gt;V&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;V0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ov&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, it agrees well with theory:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Energy E&lt;/th&gt;
&lt;th&gt;Wall thickness&lt;/th&gt;
&lt;th&gt;Simulation&lt;/th&gt;
&lt;th&gt;Theory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.6&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;about 1.9%&lt;/td&gt;
&lt;td&gt;1.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.6&lt;/td&gt;
&lt;td&gt;1.25&lt;/td&gt;
&lt;td&gt;about 33.8%&lt;/td&gt;
&lt;td&gt;34.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.4&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;about 53%&lt;/td&gt;
&lt;td&gt;53.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.2 (above the wall)&lt;/td&gt;
&lt;td&gt;1.25&lt;/td&gt;
&lt;td&gt;about 64%&lt;/td&gt;
&lt;td&gt;65.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row shows that even with energy above the wall, not all of it gets over: about a third bounces back. In classical mechanics it would always get over.&lt;/p&gt;

&lt;p&gt;You can check the same values from the browser console:&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;E&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;__qx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EXPS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tunnel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fireNew&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;running&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// [bounced back, inside the wall, got through] → got through ≈ 0.338&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bonus: a Bell test fools you unless you use many pairs
&lt;/h2&gt;

&lt;p&gt;The same page has a Bell test (CHSH) for entanglement. Quantum mechanics predicts S = 2√2 ≈ 2.83, while a "hidden variables" model gives S ≤ 2.&lt;/p&gt;

&lt;p&gt;At first I used 2000 pairs each. Then even the hidden-variables model sometimes showed &lt;strong&gt;S = 2.06&lt;/strong&gt;, above 2. That was just statistical scatter.&lt;/p&gt;

&lt;p&gt;So I raised it to 20,000 pairs each and also show the standard error of S, √(Σ(1 − E²)/N). Now quantum gives S ≈ 2.83 and hidden variables S ≈ 2.0. When you say "it exceeded 2", you have to show the size of the scatter too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Main sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;P. B. Visscher (1991) &lt;em&gt;Computers in Physics&lt;/em&gt; 5, 596 (the time-stepping method)&lt;/li&gt;
&lt;li&gt;D. J. Griffiths, D. F. Schroeter (2018) &lt;em&gt;Introduction to Quantum Mechanics&lt;/em&gt;, 3rd ed., Cambridge University Press&lt;/li&gt;
&lt;li&gt;R. P. Feynman, R. B. Leighton, M. Sands (1965) &lt;em&gt;The Feynman Lectures on Physics&lt;/em&gt;, Vol. III&lt;/li&gt;
&lt;li&gt;J. S. Bell (1964) &lt;em&gt;Physics&lt;/em&gt; 1, 195&lt;/li&gt;
&lt;li&gt;J. F. Clauser, M. A. Horne, A. Shimony, R. A. Holt (1969) &lt;em&gt;Physical Review Letters&lt;/em&gt; 23, 880&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This page is part of Manabi Lab, a series of small learning apps that run in the browser (English and Japanese). It also has double-slit, polarizer and uncertainty-principle experiments. The code and this post were written together with AI (Claude).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sishiya.github.io/manabi-lab/?lang=en" rel="noopener noreferrer"&gt;https://sishiya.github.io/manabi-lab/?lang=en&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>physics</category>
      <category>simulation</category>
      <category>science</category>
    </item>
    <item>
      <title>One garden through 14 kinds of eyes: UV, heat, echoes and compound eyes as shader passes</title>
      <dc:creator>sishiya (しし屋)</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:23:27 +0000</pubDate>
      <link>https://dev.to/sishiya/one-garden-through-14-kinds-of-eyes-uv-heat-echoes-and-compound-eyes-as-shader-passes-4l00</link>
      <guid>https://dev.to/sishiya/one-garden-through-14-kinds-of-eyes-uv-heat-echoes-and-compound-eyes-as-shader-passes-4l00</guid>
      <description>&lt;p&gt;I made a page where you walk through the same garden in first person, switching between the senses of a crow, pit viper, bat, fly, honeybee, dog, cat, mantis shrimp and more. Senses humans cannot perceive (ultraviolet, heat, echoes, polarization, near-infrared) are "translated" into colors we can see. It uses Three.js with my own shaders.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sishiya.github.io/manabi-lab/animal-senses/?lang=en" rel="noopener noreferrer"&gt;https://sishiya.github.io/manabi-lab/animal-senses/?lang=en&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post covers how one garden produces pictures for many different senses.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The views are estimates based on research and cannot be used to diagnose eyes. The app labels its explanations as "established", "estimate" or "effect".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Give objects "per-sense" properties
&lt;/h2&gt;

&lt;p&gt;Besides the color the human eye sees, each vertex in the garden carries these properties (painted by &lt;code&gt;paint()&lt;/code&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;Contents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aCol&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;color seen by the human eye (linear RGB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UV reflectance, daytime temperature, night temperature, whether it glows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;opacity to visible light, UV and heat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;how well echoes return, near-infrared reflectance, whether it is a smooth polarizing surface&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For example, sunflower petals have a UV reflectance of 0.03 at the base and 0.58 at the tip. To us they are evenly yellow, but in UV the dark base forms a "target" pattern (values are estimates).&lt;/p&gt;

&lt;p&gt;Opacity is split per kind of light because of garbage bags: showing that a bag hides its contents in visible light but not in UV requires different transparency for each kind of light.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draw the same scene many times with different modes
&lt;/h2&gt;

&lt;p&gt;The scene shader switches what it draws with &lt;code&gt;uMode&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;uMode&lt;/th&gt;
&lt;th&gt;What it draws&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;visible light&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;ultraviolet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;heat (temperature / 60)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;echoes (distance / 40, and how much returns based on surface angle)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;near-infrared (sunlight + the camera's IR light, which falls off with distance squared)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;polarization (Fresnel equations for smooth surfaces, Rayleigh scattering for the sky)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the needed passes are rendered to render targets, and the compositing shader &lt;code&gt;COMP_FS&lt;/code&gt; mixes them with a formula for each animal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Crow&lt;/strong&gt;: UV overlaid in purple on visible light&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pit viper&lt;/strong&gt;: heat blurred over a 320-cell width and overlaid (the pit organ cannot see fine shapes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dog, cat&lt;/strong&gt;: dichromat color formula (Viénot 1999) and blur; the cat gets brighter and softer at night&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mantis shrimp&lt;/strong&gt;: grayscale, with color quantized to 12 hues only inside a horizontal band that sweeps up and down&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also split the screen with a human view on the left.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compound eyes: read a cube map on a hexagonal grid
&lt;/h2&gt;

&lt;p&gt;Flies and bees see almost all the way around. So the page renders a 256 px cube map at the camera position, converts it to azimuth and elevation, and packs 336° across the screen.&lt;/p&gt;

&lt;p&gt;That is divided into a hexagonal grid, and &lt;strong&gt;each hexagon reads just one direction, at its center&lt;/strong&gt;. Mipmaps blur it by each ommatidium's acceptance angle. For the bee, a UV cube is rendered too and colors are shifted: "green → red, blue → green, UV → blue".&lt;/p&gt;

&lt;h2&gt;
  
  
  Bats: echoes come back at the speed of sound
&lt;/h2&gt;

&lt;p&gt;The bat remembers up to six call times. For each pixel, when&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;age − 2d / 343 ≥ 0   (age = time since the call, d = distance to the object)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the echo has returned, so the pixel lights up and fades over 0.12 seconds. Where the call's wavefront is right now (d ≈ 343·age) is drawn as a blue line.&lt;/p&gt;

&lt;p&gt;When a moth is ahead, the call interval shortens from 0.1 to 0.05 to 0.012 seconds (the feeding buzz).&lt;/p&gt;

&lt;p&gt;With "hear the echoes" on, the real 80→25 kHz sweep is played stretched 10× as 8→2.5 kHz, with delay and volume set by each object's distance, direction and size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nearsight, farsight and presbyopia from the "distance pass"
&lt;/h2&gt;

&lt;p&gt;The distance pass made for echoes also works for the focus errors of human eyes. For each pixel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;needed power = 1/distance + hyperopia (myopia is negative)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below 0 is the myopic error; anything beyond the accommodation range (Hofstetter's minimum, 15 − 0.25×age) is the hyperopic or presbyopic error. The blur circle's diameter is "pupil diameter × error", with the pupil at 3 mm by day and 6.5 mm at night. You hold a paperback 35 cm away, so with presbyopia you can see just the book go blurry.&lt;/p&gt;

&lt;p&gt;Color vision differences use the matrices of Machado et al. (2009).&lt;/p&gt;

&lt;h2&gt;
  
  
  Careful with flicker
&lt;/h2&gt;

&lt;p&gt;Flies sense fast time, so they should see the flicker of a fluorescent lamp (100 brightness changes per second). On screen, though, the flicker becomes 2–25 times per second, inside the range most likely to trigger photosensitive seizures (3–30).&lt;/p&gt;

&lt;p&gt;Measuring the first version, about 9% of the screen changed relative luminance by 0.1 or more. So now the flicker appears only when you turn on "see the lamp flicker", and the brightness swing is reduced to 85–100% (a real lamp drops to about 50%). With &lt;code&gt;prefers-reduced-motion&lt;/code&gt;, it cannot be turned on. It is now 0%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Drawing a translucent film double-sided without depth writes put the back face over the front, so the bag's contents showed through in UV where they should be hidden → translucent objects are front-face only&lt;/li&gt;
&lt;li&gt;Having a GLSL function and a local variable share a name (&lt;code&gt;cat&lt;/code&gt;) is risky, so they became &lt;code&gt;catV&lt;/code&gt; and &lt;code&gt;dogV&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Merging static objects into one mesh (&lt;code&gt;bakeStatic()&lt;/code&gt;) cut draw calls from hundreds to dozens&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Main sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;F. Viénot, H. Brettel, J. D. Mollon (1999) (dichromat color formula)&lt;/li&gt;
&lt;li&gt;G. M. Machado, M. M. Oliveira, L. A. F. Fernandes (2009) (color vision matrices)&lt;/li&gt;
&lt;li&gt;T. H. Bullock, F. P. J. Diecke (1956) (sensitivity of the pit viper's pit organ)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other sources (about 35) are in the app.&lt;/p&gt;




&lt;p&gt;This page is part of Manabi Lab, a series of small learning apps that run in the browser (English and Japanese). The code and this post were written together with AI (Claude).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sishiya.github.io/manabi-lab/?lang=en" rel="noopener noreferrer"&gt;https://sishiya.github.io/manabi-lab/?lang=en&lt;/a&gt;&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>webgl</category>
      <category>javascript</category>
      <category>science</category>
    </item>
    <item>
      <title>Tracing light around a spinning black hole in the browser (WebGL2, no libraries)</title>
      <dc:creator>sishiya (しし屋)</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:23:14 +0000</pubDate>
      <link>https://dev.to/sishiya/tracing-light-around-a-spinning-black-hole-in-the-browser-webgl2-no-libraries-4d9d</link>
      <guid>https://dev.to/sishiya/tracing-light-around-a-spinning-black-hole-in-the-browser-webgl2-no-libraries-4d9d</guid>
      <description>&lt;p&gt;The black hole in the movie &lt;em&gt;Interstellar&lt;/em&gt; has a flat disk that seems to wrap over the top and under the bottom of the hole. That is not an artistic choice. It is what gravity does to light.&lt;/p&gt;

&lt;p&gt;To see it for myself, I made a page that &lt;strong&gt;traces light, ray by ray, around a spinning (Kerr) black hole&lt;/strong&gt; and draws the result. It is plain WebGL2 with no libraries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sishiya.github.io/manabi-lab/black-hole/?lang=en" rel="noopener noreferrer"&gt;https://sishiya.github.io/manabi-lab/black-hole/?lang=en&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click any point on the image and the page shows where that light came from, as a side view and a top view of its path. This post covers how it works and where I got stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Like ordinary ray tracing, it follows light &lt;strong&gt;backwards&lt;/strong&gt; from the camera. The difference is that light does not travel in straight lines. For every pixel, the page numerically integrates the geodesic equations of Kerr spacetime.&lt;/p&gt;

&lt;p&gt;Each ray ends in one of three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It falls into the hole → black (the shadow)&lt;/li&gt;
&lt;li&gt;It crosses the disk plane inside the disk → disk color&lt;/li&gt;
&lt;li&gt;It escapes far away → look up the star map in its final direction&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The equations
&lt;/h2&gt;

&lt;p&gt;Units are G = c = M = 1 (length and time measured in black-hole masses), in Boyer–Lindquist coordinates (r, θ, φ). With the photon energy fixed at p_t = −1, there are two conserved quantities: the angular momentum b = L/E and Carter's constant q = Q/E².&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Σ = r² + a²cos²θ,  Δ = r² − 2r + a²
P = r² + a² − ab,  K = (b − a)² + q,  R = P² − ΔK

dr/dλ   = Δ p_r / Σ
dθ/dλ   = p_θ / Σ
dφ/dλ   = (aP/Δ − a + b/sin²θ) / Σ
dp_r/dλ = (R′/Δ − RΔ′/Δ² − Δ′p_r²) / (2Σ)     (R′ = 4rP − Δ′K, Δ′ = 2r − 2)
dp_θ/dλ = (b²cosθ/sin³θ − a²sinθcosθ) / Σ
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For light the Hamiltonian is H = 0, which removes the derivative of Σ. I derived these myself and checked them against the appendix of the paper by the team that made the movie's images (James et al. 2015). The sign of φ is flipped compared with the paper, because the paper writes the equations for tracing backwards in time.&lt;/p&gt;

&lt;p&gt;In JavaScript (&lt;code&gt;y = [r, θ, φ, p_r, p_θ]&lt;/code&gt;):&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;function&lt;/span&gt; &lt;span class="nf"&gt;deriv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&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;out&lt;/span&gt;&lt;span class="p"&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;y&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="nx"&gt;th&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;y&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="nx"&gt;pr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;y&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="nx"&gt;pth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;th&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;th&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;e&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="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;1&lt;/span&gt;&lt;span class="nx"&gt;e&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="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;D&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;a&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;P&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;K&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;P&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;P&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;D&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;K&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;Dp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;r&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="nx"&gt;Rp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;P&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;Dp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;K&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;out&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="nx"&gt;D&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pr&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;out&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="nx"&gt;pth&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;out&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;P&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;D&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;out&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Rp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;D&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;Dp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;D&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;D&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;Dp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&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;This is stepped with 4th-order Runge–Kutta, with a negative step because we go backwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The camera: an observer swept along with space
&lt;/h2&gt;

&lt;p&gt;Near a spinning black hole, space itself is dragged around in the direction of the spin. So the camera is a ZAMO (zero-angular-momentum observer, Bardeen, Press and Teukolsky 1972), which rotates along with that dragging.&lt;/p&gt;

&lt;p&gt;From the pixel direction n, the page computes the photon energy E_F that this observer measures, and the conserved quantities b and q:&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;EF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;omega&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;varpi&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;nph&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// photon energy seen by the camera&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EF&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rho&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;D&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;nr&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;pth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EF&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rho&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;nth&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;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EF&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;varpi&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;nph&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                         &lt;span class="c1"&gt;// angular momentum L/E&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Carter's constant&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;alpha&lt;/code&gt; is how fast clocks tick there, &lt;code&gt;omega&lt;/code&gt; is the angular velocity of frame dragging, and &lt;code&gt;varpi&lt;/code&gt; is the circumferential radius.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step size
&lt;/h2&gt;

&lt;p&gt;Paths bend more sharply close to the hole, so the step is proportional to the distance from the horizon:&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;STEP_K&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;STEP_MIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.004&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stepLen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rh&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STEP_K&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rh&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;STEP_MIN&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;A ray stops when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;r &amp;lt; r₊ + 0.01 → the hole (r₊ is the event horizon)&lt;/li&gt;
&lt;li&gt;it crosses the equatorial plane between the disk's inner and outer edges → the disk&lt;/li&gt;
&lt;li&gt;r &amp;gt; max(2 r_cam, 60) and moving outward → the sky&lt;/li&gt;
&lt;li&gt;900 steps pass without any of these → the hole (light circling the photon orbit)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where I got stuck: a vertical line down the middle
&lt;/h2&gt;

&lt;p&gt;The first render had a &lt;strong&gt;flickering vertical line&lt;/strong&gt; down the center of the image.&lt;/p&gt;

&lt;p&gt;Rays in the central column have b ≈ 0, so they pass right over or under the spin axis. There sinθ is small, and the b/sin²θ term in dφ/dλ becomes very large. With a step based only on r, φ jumped a lot in a single step and the integration broke down.&lt;/p&gt;

&lt;p&gt;The fix was to &lt;strong&gt;shrink the step so that one step never turns more than 0.04 rad&lt;/strong&gt;:&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;function&lt;/span&gt; &lt;span class="nf"&gt;rk4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;deriv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&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;_k1&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;lim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.04&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_k1&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_k1&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;1&lt;/span&gt;&lt;span class="nx"&gt;e&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;lim&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;lim&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...then an ordinary RK4 step&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line disappeared.&lt;/p&gt;

&lt;p&gt;A related problem: rays with b exactly 0 cross the axis, and θ goes negative (or beyond π). Computing the disk hit point with θ = π/2 then swapped the near and far sides. The shader adds π to φ when sinθ &amp;lt; 0, and the CPU side interpolates between path points to find the position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disk color: the frequency shift
&lt;/h2&gt;

&lt;p&gt;The disk gas follows Keplerian orbits. The inner edge is the ISCO (innermost stable circular orbit, 3.83M for a = 0.6), and the outer edge is 18.7M, the same as the figure in the paper.&lt;/p&gt;

&lt;p&gt;The frequency shift g of the light is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;g = E_F / (u^t (1 − Ω b))
Ω = 1/(r^{3/2} + a)
u^t = (r^{3/2} + a) / (r^{3/4} √(r^{3/2} − 3r^{1/2} + 2a))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the movie's viewing angle (a = 0.6), g is about 1.36 on the left side, where the gas comes toward us, and about 0.76 on the band above the hole.&lt;/p&gt;

&lt;p&gt;For clarity, the movie left out this shift in color and brightness (the paper explains why). So the page lets you switch between "movie" and "realistic". In realistic mode the color temperature is multiplied by g and the brightness by g⁴: the left side gets bright and the right side gets dim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed: two levels
&lt;/h2&gt;

&lt;p&gt;With up to 900 integration steps per pixel, the full screen cannot be traced every frame.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coarse level&lt;/strong&gt; (about 160,000 pixels): retraced in one go whenever the camera moves&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fine level&lt;/strong&gt; (2× screen resolution, up to 5 million pixels): starts 140 ms after the camera stops, in 192 px tiles from the center outward, as many tiles per frame as the frame time allows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tracing pass only writes its result (kind, r and φ where it hit, g) to an RGBA32F texture. The disk pattern (which rotates), color and bloom are painted every frame by separate shaders.&lt;/p&gt;

&lt;p&gt;Also, the camera's φ (dragging left and right) is a rotation around the spin axis, so no retracing is needed: the page just rotates the sky and the disk pattern. Rays are retraced only when θ, distance or spin change.&lt;/p&gt;

&lt;p&gt;On my PC (NVIDIA), the whole fine level takes about 20 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the same equations twice, on GPU and CPU
&lt;/h2&gt;

&lt;p&gt;The ray diagram for a clicked point is retraced on the CPU (double precision, up to 4000 steps). Since the same equations live in both the shader and JavaScript, I keep a check that compares them at the same point: the value read back from the GPU texture against the CPU result.&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="nx"&gt;__bh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderNow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;__bh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readGeo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.04&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// GPU: [1, ~6.8, ~3.0, ~0.76]  (disk, r, φ, g)&lt;/span&gt;
&lt;span class="nx"&gt;__bh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pickAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.04&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;rHit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// CPU: ~6.8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the bands in the movie really are
&lt;/h2&gt;

&lt;p&gt;At the movie's angle (a = 0.6, r = 28, θ = 85°), tracing up the central vertical line gives:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Height on screen&lt;/th&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;How much the light bent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upper part&lt;/td&gt;
&lt;td&gt;Top face of the disk &lt;strong&gt;behind&lt;/strong&gt; the hole (the band above the hole)&lt;/td&gt;
&lt;td&gt;~41°&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Just above the shadow edge&lt;/td&gt;
&lt;td&gt;Bottom face of the disk &lt;strong&gt;in front&lt;/strong&gt; (light loops under and comes back)&lt;/td&gt;
&lt;td&gt;~210°&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Just below the shadow edge&lt;/td&gt;
&lt;td&gt;Top face of the disk in front&lt;/td&gt;
&lt;td&gt;~270°&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lower part&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Bottom&lt;/strong&gt; face of the disk behind the hole (the band below the hole)&lt;/td&gt;
&lt;td&gt;~100°&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The band above the hole is the top of the disk behind the hole. Its light bends over the top of the hole to reach us. The band below is the underside of the same disk, and its light reaches us by passing under the hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still missing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The thin rings at the shadow edge (second and higher images) are only 1–3 pixels wide and can look dotted&lt;/li&gt;
&lt;li&gt;Aberration from camera motion, disk thickness, and the disk lighting itself are not included&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Main sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;O. James, E. von Tunzelmann, P. Franklin, K. S. Thorne (2015) "Gravitational lensing by spinning black holes in astrophysics, and in the movie Interstellar", &lt;em&gt;Classical and Quantum Gravity&lt;/em&gt; 32, 065001 (&lt;a href="https://arxiv.org/abs/1502.03808" rel="noopener noreferrer"&gt;arXiv:1502.03808&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;J. M. Bardeen, W. H. Press, S. A. Teukolsky (1972) "Rotating black holes: locally nonrotating frames, energy extraction, and scalar synchrotron radiation", &lt;em&gt;The Astrophysical Journal&lt;/em&gt; 178&lt;/li&gt;
&lt;li&gt;N. I. Shakura, R. A. Sunyaev (1973) "Black holes in binary systems. Observational appearance", &lt;em&gt;Astronomy and Astrophysics&lt;/em&gt; 24&lt;/li&gt;
&lt;li&gt;K. S. Thorne (2014) &lt;em&gt;The Science of Interstellar&lt;/em&gt;, W. W. Norton&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This page is part of Manabi Lab, a series of small learning apps that run in the browser (English and Japanese). The code and this post were written together with AI (Claude).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sishiya.github.io/manabi-lab/?lang=en" rel="noopener noreferrer"&gt;https://sishiya.github.io/manabi-lab/?lang=en&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>javascript</category>
      <category>physics</category>
      <category>simulation</category>
    </item>
    <item>
      <title>Diving through the digestive tract in 3D: cutting holes where tubes meet, in the shader</title>
      <dc:creator>sishiya (しし屋)</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:13:18 +0000</pubDate>
      <link>https://dev.to/sishiya/diving-through-the-digestive-tract-in-3d-cutting-holes-where-tubes-meet-in-the-shader-jg6</link>
      <guid>https://dev.to/sishiya/diving-through-the-digestive-tract-in-3d-cutting-holes-where-tubes-meet-in-the-shader-jg6</guid>
      <description>&lt;p&gt;I made a page where you enter through the mouth or nose, as a piece of food or a capsule endoscope, and travel in 3D through the throat, esophagus, stomach, small and large intestine, or down the windpipe into the lungs. Along the way, stomach acid changes the color and the gates (sphincters) open and close. The view uses Three.js; the tube drawing and the body's calculations are my own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sishiya.github.io/manabi-lab/body-dive/?lang=en" rel="noopener noreferrer"&gt;https://sishiya.github.io/manabi-lab/body-dive/?lang=en&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post covers how the tubes are drawn, the hardest part (where tubes meet), collision, and how time runs inside the body.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tube = a center line + a radius
&lt;/h2&gt;

&lt;p&gt;Every path in the body is a center line with a radius. Each point is &lt;code&gt;[x, y, z, radius, region]&lt;/code&gt;, in centimeters. Tube widths are roughly life-size, but the small and large intestines are heavily shortened.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;prepTube()&lt;/code&gt; resamples the center line evenly and gives each sample a radius, region and a parallel-transport frame (N/B/T). The mesh is built from that, and each vertex also carries the type of fold for its region:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;nasal turbinates, tongue, cartilage rings of the windpipe, stomach folds, circular folds of the small intestine, haustra of the colon...&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We look from the inside, so tubes are drawn double-sided with normals flipped toward the viewer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving things in the shader
&lt;/h2&gt;

&lt;p&gt;The stomach swelling and shrinking, gates tightening, and peristaltic waves are done in the vertex shader by changing the tube radius. The fragment shader handles holes, noise bumps, lighting and fog.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hardest part: where tubes meet
&lt;/h2&gt;

&lt;p&gt;In the throat, the path from the nose and the path from the mouth merge, and then split into the windpipe and esophagus. If you simply overlap separate tubes, one tube's wall stands in the way inside the other.&lt;/p&gt;

&lt;p&gt;So &lt;strong&gt;each tube's shader receives the other tube's shape as a list of capsules (segment + radius), and &lt;code&gt;discard&lt;/code&gt;s any of its own wall that lies inside the other tube&lt;/strong&gt;, cutting a hole.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Capsules are only near the junction (with a quick sphere test first)&lt;/li&gt;
&lt;li&gt;Folds are removed near the junction; otherwise the hole's edge shifts and you can see the darkness outside&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That led to a rule: &lt;strong&gt;never put a gate that changes width, or the swelling stomach, at a junction&lt;/strong&gt;. The hole is static, so if the tube moves, a gap appears.&lt;/p&gt;

&lt;p&gt;Because the shape is approximated with capsules, a thin gap can still show at the hole's edge (a known issue).&lt;/p&gt;

&lt;h2&gt;
  
  
  Collision
&lt;/h2&gt;

&lt;p&gt;You can move within &lt;code&gt;radius × factor − 0.1 cm&lt;/code&gt; of each segment (every third sample of the center line), leaving 0.1 cm from the wall.&lt;/p&gt;

&lt;p&gt;A bigger margin looks safer, but &lt;strong&gt;then you cannot pass through junctions&lt;/strong&gt;. Once, I could no longer get from the mouth into the throat. After any change, I check that all three entrances (mouth, nose, anus) can be traveled through.&lt;/p&gt;

&lt;p&gt;When a gate is closed, its segments get a negative radius and are dropped. On top of that, &lt;code&gt;crossesClosed()&lt;/code&gt; cancels any move that crosses a gate. That double guard keeps you from jumping through a thin gate in a single frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time inside the body
&lt;/h2&gt;

&lt;p&gt;Food takes hours to pass the stomach, so the time speed-up differs by region:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Region&lt;/th&gt;
&lt;th&gt;Speed-up&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mouth, esophagus&lt;/td&gt;
&lt;td&gt;×1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stomach, small intestine&lt;/td&gt;
&lt;td&gt;×60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large intestine&lt;/td&gt;
&lt;td&gt;×240&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The body calculation &lt;code&gt;simStep(minutes, region)&lt;/code&gt; advances things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stomach&lt;/strong&gt;: acid changes color and breaks food down. The pylorus opens only when contents become a paste, or during the strong fasting contraction (MMC phase III)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small intestine&lt;/strong&gt;: bile turns it yellow. The ileocecal valve opens after 150 minutes in the small intestine&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large intestine&lt;/strong&gt;: bacterial fermentation, water absorption, browning. It stops in the sigmoid colon and moves to the rectum with a mass movement&lt;/li&gt;
&lt;li&gt;Stool form (Bristol scale) is decided from water content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you choose sake, blood alcohol is computed too (Widmark formula: 60 kg body, 42 L distribution volume, 6.5 g/hour elimination).&lt;/p&gt;

&lt;h2&gt;
  
  
  The third-person camera
&lt;/h2&gt;

&lt;p&gt;There is also a third-person camera that shows you (the food, capsule, etc.) from behind. At first it sat directly behind you, but near walls it had nowhere to go, moved into the body and flickered. Now the camera is placed &lt;strong&gt;behind you along the tube's center line&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Estimates and effects
&lt;/h2&gt;

&lt;p&gt;Shortened lengths, transit times, color changes and so on are "estimates" or "effects". The start screen's "About this app" separates what is established from what is estimated.&lt;/p&gt;

&lt;p&gt;For example, the app says the small intestine is "about 3 m in the living body". The often-quoted 6 m is the length after death (OpenStax).&lt;/p&gt;

&lt;h2&gt;
  
  
  Main sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;J. G. Betts et al. (2022) &lt;em&gt;Anatomy and Physiology&lt;/em&gt; 2e, OpenStax&lt;/li&gt;
&lt;li&gt;StatPearls (chapters on the esophagus, stomach, duodenum and rectum)&lt;/li&gt;
&lt;li&gt;S. J. Lewis, K. W. Heaton (1997) (Bristol stool scale)&lt;/li&gt;
&lt;li&gt;MEXT, Standard Tables of Food Composition in Japan (8th edition, 2023 supplement)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other sources (18 in all) are in the app.&lt;/p&gt;




&lt;p&gt;This page is part of Manabi Lab, a series of small learning apps that run in the browser (English and Japanese). The code and this post were written together with AI (Claude).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sishiya.github.io/manabi-lab/?lang=en" rel="noopener noreferrer"&gt;https://sishiya.github.io/manabi-lab/?lang=en&lt;/a&gt;&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>webgl</category>
      <category>javascript</category>
      <category>simulation</category>
    </item>
    <item>
      <title>Recomputing 4.6 billion years of Earth with a box model that never reads the timeline</title>
      <dc:creator>sishiya (しし屋)</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:13:13 +0000</pubDate>
      <link>https://dev.to/sishiya/recomputing-46-billion-years-of-earth-with-a-box-model-that-never-reads-the-timeline-56b8</link>
      <guid>https://dev.to/sishiya/recomputing-46-billion-years-of-earth-with-a-box-model-that-never-reads-the-timeline-56b8</guid>
      <description>&lt;p&gt;I made a page that watches 4.6 billion years of Earth, from its birth to the appearance of humans, from one fixed spot. Move the time slider and the land, sky, sea and living things change to match that age. The 3D view uses Three.js.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sishiya.github.io/manabi-lab/evolution/?lang=en" rel="noopener noreferrer"&gt;https://sishiya.github.io/manabi-lab/evolution/?lang=en&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The scenery is drawn from what research has found (the record). But the record alone does not show &lt;em&gt;why&lt;/em&gt; things happened.&lt;/p&gt;

&lt;p&gt;So I built a &lt;strong&gt;box model that never reads the timeline&lt;/strong&gt;. It computes temperature, CO2, oxygen and sea animals only from outside drivers such as the Sun, volcanoes and weathering, and you can compare it with the record. This post covers how the model is built, and where it does and does not match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inputs are only "outside drivers"
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;runModel()&lt;/code&gt; reads nothing but the outside drivers in &lt;code&gt;FORCE&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Brightness of the Sun (dimmer in the past)&lt;/li&gt;
&lt;li&gt;Volcanoes (and giant eruptions)&lt;/li&gt;
&lt;li&gt;How easily rock weathers (amount of continent, supercontinent breakup, mountain building)&lt;/li&gt;
&lt;li&gt;Weathering by plants, methane-making microbes, burial of organic carbon, how reducing volcanic gas is&lt;/li&gt;
&lt;li&gt;Earth's orbit, humans&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does not read the timeline (when snowball Earths or mass extinctions happened). The point is to see whether those come out of the calculation.&lt;/p&gt;

&lt;p&gt;The grid has 4000 points (past and future), stepped in 0.02-million-year slices. One run takes about 0.22 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CO2 thermostat
&lt;/h2&gt;

&lt;p&gt;CO2 changes with the difference between what volcanoes add and what rock weathering removes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dC = 700 × (volcanoes − weathering)
weathering = weath × bio × (plants) × (C/280)^0.3 × exp((background temp − 14)/13) × √ocean
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Weathering is faster when it is warm&lt;/strong&gt;, so warming lowers CO2 and cools the planet, and cooling raises CO2 and warms it. That is Earth's long-term thermostat. During a snowball Earth, weathering drops to 3%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Temperature and snowball Earth
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;temp = 14 + Sun 140×(S−1) + CO2 effect + methane 18×exp(−O2/0.003) + orbit + water vapor + ice −40×(I − 0.03)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With x = ln(C/280), the CO2 effect is 4.33x + 0.25x² for x ≥ 0 and −12×(1 − exp(4.33x/12)) for x &amp;lt; 0.&lt;/p&gt;

&lt;p&gt;Ice grows partially as it cools. &lt;strong&gt;Below 5 °C the whole planet freezes&lt;/strong&gt;, and it thaws when the ice-free temperature would exceed 30 °C.&lt;/p&gt;

&lt;p&gt;This includes both "ice reflects sunlight → even colder" and "weathering stops while frozen → volcanic CO2 builds up → eventually it melts".&lt;/p&gt;

&lt;h2&gt;
  
  
  Oxygen and the ozone layer
&lt;/h2&gt;

&lt;p&gt;Oxygen is set by organic carbon burial B minus reducing volcanic gases R. R was larger in the past (&lt;code&gt;0.75×(A/2.4e9)^1.2 + 0.15&lt;/code&gt;), and oxygen starts to rise once B − R exceeds 0.002. More oxygen removes methane's greenhouse effect, which changes the temperature too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results compared with the record
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What emerged on its own:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;The app's record&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Huronian glaciation&lt;/td&gt;
&lt;td&gt;snowball 2.396–2.239 billion years ago&lt;/td&gt;
&lt;td&gt;about 2.4–2.1 billion years ago&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cryogenian snowballs (two)&lt;/td&gt;
&lt;td&gt;734–687 and 683–649 million years ago&lt;/td&gt;
&lt;td&gt;two, about 720–635 million years ago&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End-Permian (P-T) extinction&lt;/td&gt;
&lt;td&gt;sea animal diversity −50%&lt;/td&gt;
&lt;td&gt;the largest mass extinction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End-Cretaceous (K-Pg) extinction&lt;/td&gt;
&lt;td&gt;−36%&lt;/td&gt;
&lt;td&gt;dinosaurs and others die out&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Late Devonian and end-Triassic extinctions also appear in the model. Today comes out at about 15.6 °C and about 440 ppm CO2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What did not match:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The end-Ordovician extinction comes out at only about −10% (the record is about −50%)&lt;/li&gt;
&lt;li&gt;Average mismatch over all time: temperature ±6 °C, CO2 ×1.7, oxygen ±1.4%, sea animals ±27%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "how it works" panel overlays the record (dotted) and the model (solid) in four graphs and shows the mismatch as it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The future and "what if"
&lt;/h2&gt;

&lt;p&gt;Since the model never reads the record, it can keep running into the future.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Human CO2 is removed with the Bern carbon-cycle response (the ocean takes thousands of years, rock weathering tens to hundreds of thousands)&lt;/li&gt;
&lt;li&gt;In the middle scenario (SSP2-4.5), 2100 is +2.45 °C (about the same as IPCC AR6's +2.7 °C)&lt;/li&gt;
&lt;li&gt;In about 600 million years CO2 drops below 150 ppm and plants can no longer grow (matches the literature)&lt;/li&gt;
&lt;li&gt;The oceans are lost in 2–3 billion years (the literature says 1–2 billion; the model is late)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also make "what if" worlds. For example, with "no oxygen-making life", methane's greenhouse effect remains and none of the three snowball Earths happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  A gotcha: bulk replace in PowerShell
&lt;/h2&gt;

&lt;p&gt;I used PowerShell to rewrite code in bulk and failed three times:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;With only one replacement pair, &lt;code&gt;@( @("a","b") )&lt;/code&gt; gets flattened and replaces one character at a time (write &lt;code&gt;@(,@("a","b"))&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Adding &lt;code&gt;'a'+"`n"+'b'&lt;/code&gt; inside an array: the comma binds first and breaks the pair&lt;/li&gt;
&lt;li&gt;PowerShell expands &lt;code&gt;${...}&lt;/code&gt; inside double quotes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now the replace function refuses to run unless each pair is a two-element array.&lt;/p&gt;

&lt;h2&gt;
  
  
  Main sources
&lt;/h2&gt;

&lt;p&gt;The sources for the record and the model's shape are listed in the app under "About this app". The future comparison uses IPCC AR6 (2021).&lt;/p&gt;




&lt;p&gt;This page is part of Manabi Lab, a series of small learning apps that run in the browser (English and Japanese). The code and this post were written together with AI (Claude).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sishiya.github.io/manabi-lab/?lang=en" rel="noopener noreferrer"&gt;https://sishiya.github.io/manabi-lab/?lang=en&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>threejs</category>
      <category>simulation</category>
      <category>science</category>
    </item>
  </channel>
</rss>
