<?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 WiFi waves on 0.75 cm cells in WebGL2 (FDTD, and correcting for a coarse grid)</title>
      <dc:creator>sishiya (しし屋)</dc:creator>
      <pubDate>Thu, 22 Oct 2026 01:31:16 +0000</pubDate>
      <link>https://dev.to/sishiya/solving-wifi-waves-on-075-cm-cells-in-webgl2-fdtd-and-correcting-for-a-coarse-grid-1lmm</link>
      <guid>https://dev.to/sishiya/solving-wifi-waves-on-075-cm-cells-in-webgl2-fdtd-and-correcting-for-a-coarse-grid-1lmm</guid>
      <description>&lt;p&gt;How much do walls weaken WiFi, and where in a home is the signal hard to reach? I made a page that &lt;strong&gt;solves the electromagnetic wave equation directly on the GPU&lt;/strong&gt; and shows the waves spreading and the received strength. Plain WebGL2, no libraries.&lt;/p&gt;

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

&lt;p&gt;You can move the router around a two-bedroom apartment and draw extra walls. This post covers how it is solved on the GPU, and how I fixed walls weakening the signal far too much because the grid was too coarse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The equation
&lt;/h2&gt;

&lt;p&gt;A 2D wave equation for only the electric field Ez perpendicular to the floor (TM mode; in 2D, Ez alone describes it exactly):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;∂²E/∂t² = (1/με) ∇²E − (σ/ε) ∂E/∂t
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;cell size DX = 0.75 cm, 1280×800 cells (9.6 m × 6 m)&lt;/li&gt;
&lt;li&gt;Courant number 0.5 → one step is 12.5 ps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The wavelength at 2.4 GHz is about 12.5 cm, so one wavelength is about 17 cells.&lt;/p&gt;

&lt;h2&gt;
  
  
  GPU: ping-pong between two textures
&lt;/h2&gt;

&lt;p&gt;State lives in two RGBA32F textures, swapping read and write each step:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Channel&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;r&lt;/td&gt;
&lt;td&gt;current Ez (u(n))&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;g&lt;/td&gt;
&lt;td&gt;previous Ez (u(n−1))&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;b&lt;/td&gt;
&lt;td&gt;exponential moving average of u² (for signal strength)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rendering to floating-point textures needs &lt;code&gt;EXT_color_buffer_float&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Materials are stored as IDs 0–7 in an R8 texture. The shader looks up coefficients from uniform arrays by ID:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;uK2[id]&lt;/code&gt; = KC²/εr (metal is −1, forcing Ez = 0)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;uA[id]&lt;/code&gt; = σ·DT/2ε (loss)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The outer 40 cells are a sponge with damping growing as the cube, to remove reflections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Converting 2D falloff to 3D
&lt;/h2&gt;

&lt;p&gt;In 2D, wave strength falls off as 1/r with distance r. In real 3D free space it is 1/r².&lt;/p&gt;

&lt;p&gt;So at startup, the average of "strength × r" in an empty field is measured as C, and received strength is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;received [dBm] = 20 dBm − FSPL_3D(d) + 10·log10(mean u² · r / C)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Distance loss uses the 3D formula (free-space path loss), and only walls, reflections and interference come from the simulation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stuck: glass at −14 dB at 5 GHz
&lt;/h2&gt;

&lt;p&gt;Material values (relative permittivity εr and conductivity σ) come from ITU-R P.2040. Used as they are, walls weakened 5 GHz far too much:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Material (5.2 GHz)&lt;/th&gt;
&lt;th&gt;Simulation without correction&lt;/th&gt;
&lt;th&gt;Analytic value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Glass&lt;/td&gt;
&lt;td&gt;14.4 dB&lt;/td&gt;
&lt;td&gt;about 3 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concrete (18 cm)&lt;/td&gt;
&lt;td&gt;59.1 dB&lt;/td&gt;
&lt;td&gt;23.0 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cause is grid coarseness. On a 0.75 cm grid, one wavelength inside glass at 5 GHz is only about 3 cells. Wave propagation (numerical dispersion) then drifts from reality.&lt;/p&gt;

&lt;p&gt;But a finer grid costs cells × steps (in 2D, halving the cell size costs 8×).&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: bisect for "values giving the same transmission loss"
&lt;/h2&gt;

&lt;p&gt;So, &lt;strong&gt;for each material and frequency, find the εr and σ that give the analytic transmission loss on this grid&lt;/strong&gt;, and use those:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;a 1D strip test that hits a slab with a plane wave, &lt;code&gt;stripDb(f, id, er, sig)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the analytic transmission through the slab with complex numbers, &lt;code&gt;slabDb()&lt;/code&gt; (normal incidence)&lt;/li&gt;
&lt;li&gt;bisection for the εr and σ where &lt;code&gt;stripDb&lt;/code&gt; equals &lt;code&gt;slabDb&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;store them in the material table as &lt;code&gt;eff: [[er, σ at 2.4GHz], [er, σ at 5GHz]]&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Checking, all 5 materials × 2 frequencies agree within 0.01 dB (e.g. concrete 13.37 dB at 2.437 GHz and 23.01 dB at 5.2 GHz).&lt;/p&gt;

&lt;p&gt;This is not "bending the physical values". It &lt;strong&gt;cancels the coarse grid's error using a measurable quantity, transmission loss&lt;/strong&gt;. The page says so as a "grid correction". Adding materials or changing thicknesses means regenerating them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap experiment
&lt;/h2&gt;

&lt;p&gt;One scene has an 8 cm-thick metal plate with gaps of 20, 8, 4 and 2 cm. When a gap is narrower than half a wavelength (6.2 cm at 2.4 GHz, 2.9 cm at 5 GHz), waves can hardly pass.&lt;/p&gt;

&lt;p&gt;In the simulation, the 4 cm gap gives about −30 dB at 2.4 GHz and about −14 dB at 5 GHz, and nothing gets through the 2 cm gap at either frequency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking for flicker
&lt;/h2&gt;

&lt;p&gt;The wave display has fine moving stripes, so a single point changes brightness 8–29 times per second. Against the photosensitive-seizure guideline (WCAG), I measured brightness changes averaged over 32 px squares. Areas changing a lot more than 3 times per second covered at most 5.9% of the screen, under the guideline (about 10% of the screen).&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;ITU-R P.2040-3 (2023) (electrical properties of building materials)&lt;/li&gt;
&lt;li&gt;ITU-R P.525 (free-space attenuation)&lt;/li&gt;
&lt;li&gt;IEEE 802.11 (WiFi frequencies)&lt;/li&gt;
&lt;li&gt;A. Taflove, S. C. Hagness (2005) &lt;em&gt;Computational Electrodynamics: The Finite-Difference Time-Domain Method&lt;/em&gt;, 3rd ed.&lt;/li&gt;
&lt;li&gt;C. Gabriel (1996) (electrical properties of the human body)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Signal thresholds, router output and body values are rough values. Nothing here indicates effects on the body.&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>webgl</category>
      <category>javascript</category>
      <category>physics</category>
      <category>simulation</category>
    </item>
    <item>
      <title>Tearing the Earth with your finger: 16,000-particle SPH in a Web Worker</title>
      <dc:creator>sishiya (しし屋)</dc:creator>
      <pubDate>Thu, 22 Oct 2026 01:25:55 +0000</pubDate>
      <link>https://dev.to/sishiya/tearing-the-earth-with-your-finger-16000-particle-sph-in-a-web-worker-49fd</link>
      <guid>https://dev.to/sishiya/tearing-the-earth-with-your-finger-16000-particle-sph-in-a-web-worker-49fd</guid>
      <description>&lt;p&gt;"God's-Eye Map" shows the Earth with CesiumJS and lets you travel from the ground to space using public data that needs no API keys, such as Japan's GSI maps and the PLATEAU 3D city models.&lt;/p&gt;

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

&lt;p&gt;Inside it there is a button that appears only while you look at Earth from space: "✋ Tear off a piece of Earth". Pinch part of the Earth and pull, and a hole opens, hot rock comes out and seawater boils.&lt;/p&gt;

&lt;p&gt;This post covers how that calculation (SPH, a particle method for fluids) is built and where I got stuck.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;At Earth's scale, gravity is more than 100 times the strength of rock, so rock can be treated as a fluid. Still, small fragments would really keep their shape better, and other things are simplified (the app labels them "estimate").&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Building an Earth of particles
&lt;/h2&gt;

&lt;p&gt;The Earth is about 16,000 particles. Spacing s is about 408 km, the smoothing length h = 1.1 s, and each particle has about 45 neighbors within 2h.&lt;/p&gt;

&lt;p&gt;Layer boundaries (inner core 1221 km, core–mantle boundary 3480 km) follow PREM, the standard Earth model.&lt;/p&gt;

&lt;p&gt;Density uses an equation of state for each layer (Murnaghan), solved so that &lt;strong&gt;gravity and pressure balance&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;Layer&lt;/th&gt;
&lt;th&gt;ρ0 (kg/m³)&lt;/th&gt;
&lt;th&gt;K0 (GPa)&lt;/th&gt;
&lt;th&gt;K′&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mantle&lt;/td&gt;
&lt;td&gt;3500&lt;/td&gt;
&lt;td&gt;160&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outer core&lt;/td&gt;
&lt;td&gt;6100&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inner core&lt;/td&gt;
&lt;td&gt;7800&lt;/td&gt;
&lt;td&gt;170&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The result: central pressure 340 GPa, mass 5.66×10²⁴ kg (5% lighter than the real Earth). Densities are close to PREM but not the same.&lt;/p&gt;

&lt;p&gt;Particles on a lattice get a mass of "density × s³", and the planet is relaxed for 300 steps (multiplying speed by 0.9 each step to stop the shaking) before starting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside the SPH
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;density is tracked with the &lt;strong&gt;continuity equation&lt;/strong&gt;; summing particle masses gave too little density at the surface and wrong pressure&lt;/li&gt;
&lt;li&gt;pressure is never below 0 (no tension), so things tear easily&lt;/li&gt;
&lt;li&gt;artificial viscosity (α = 1, β = 2), with its work turned into heat&lt;/li&gt;
&lt;li&gt;one step is about 10 seconds, set by the speed of sound (about 11 km/s) and particle spacing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Summing gravity over every pair is heavy, so it is computed as spherically symmetric. For each body (Earth, fragment), the space is cut into 256 shells from its center, and each particle is pulled only by the mass inside its radius.&lt;/p&gt;

&lt;p&gt;Temperature is tracked too. Released pressure cools by adiabatic expansion, and the energy of material collapsing inward becomes heat. Compared with the melting onset temperature (solidus), it also gives a "partly molten fraction".&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it in a Web Worker
&lt;/h2&gt;

&lt;p&gt;The calculation runs in one Web Worker. One step takes about 30 ms (on a 16-core PC). Drawing happens on the main thread, with particles drawn as round point sprites in a shader.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;window&lt;/code&gt; does not exist inside a Worker, so shared code lives on &lt;code&gt;self.GV&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stuck: how fast the "hand" moves
&lt;/h2&gt;

&lt;p&gt;The hardest part was not the calculation but how to move the pinching hand.&lt;/p&gt;

&lt;p&gt;Particles inside a sphere of radius Rg around the pinched point are pulled toward the hand with a spring (critically damped).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The hand was too fast&lt;/strong&gt;&lt;br&gt;
Using the finger's motion directly as the hand position stirred the Earth with the fragment, reaching 44 km/s and +350,000 °C. Now the hand approaches the pointed spot &lt;strong&gt;with a speed limit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The hand was too slow&lt;/strong&gt;&lt;br&gt;
With a limit of 3 km/s, the hole filled in before it tore off, and you could not see a deep hole. Now it is 8 km/s while pulling and 15 km/s while carrying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Carrying in a straight line scraped the ground&lt;/strong&gt;&lt;br&gt;
Carrying the fragment in a straight line skimmed the surface and blasted away the ground (+100,000 °C). Now it first moves out to 14,000 km from the center, then swings around.&lt;/p&gt;

&lt;p&gt;While you pull (until it tears), the calculation runs more than 300× faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stuck: deciding what "cracked"
&lt;/h2&gt;

&lt;p&gt;I wanted the cracked surface around the hole to glow as hot rock.&lt;/p&gt;

&lt;p&gt;At first, "cracked" was decided by how far a particle moved from its starting place. Then the whole Earth wobbling made a wide area count as cracked.&lt;/p&gt;

&lt;p&gt;So it now uses &lt;strong&gt;spacing to neighboring particles&lt;/strong&gt;. If the spacing to surface particles that were neighbors at the start (up to 6) changes by 35% or more, it is "cracked". When the whole Earth wobbles, neighbor spacing barely changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Oceans and steam
&lt;/h2&gt;

&lt;p&gt;The ocean averages 3.7 km, much thinner than a particle, so it is a separate set of light particles (about 3600). Ocean locations come from checking an Earth photo (NASA's Blue Marble) at 7000 points on the sphere and keeping only blue spots.&lt;/p&gt;

&lt;p&gt;At first, water particles slipped into gaps between surface particles, touched the inner rock below, and half the ocean was "boiling". The fix was to treat only rock that is truly exposed at the surface as hot.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A. M. Dziewonski, D. L. Anderson (1981) Preliminary reference Earth model, &lt;em&gt;Physics of the Earth and Planetary Interiors&lt;/em&gt; 25&lt;/li&gt;
&lt;li&gt;J. J. Monaghan (1992) Smoothed particle hydrodynamics, &lt;em&gt;Annual Review of Astronomy and Astrophysics&lt;/em&gt; 30&lt;/li&gt;
&lt;li&gt;T. Katsura et al. (2010) &lt;em&gt;Physics of the Earth and Planetary Interiors&lt;/em&gt; 183 (adiabatic temperature of the mantle)&lt;/li&gt;
&lt;li&gt;T. Lay, J. Hernlund, B. A. Buffett (2008) &lt;em&gt;Nature Geoscience&lt;/em&gt; 1 (heat at the core–mantle boundary)&lt;/li&gt;
&lt;li&gt;M. M. Hirschmann (2000) &lt;em&gt;Geochemistry, Geophysics, Geosystems&lt;/em&gt; 1 / G. Fiquet et al. (2010) &lt;em&gt;Science&lt;/em&gt; 329 (mantle melting onset)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sources and terms of use for maps, photos and 3D city models are shown 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>javascript</category>
      <category>webdev</category>
      <category>physics</category>
      <category>simulation</category>
    </item>
    <item>
      <title>Teaching spring-and-joint creatures to walk with a genetic algorithm (hand-written 2D physics)</title>
      <dc:creator>sishiya (しし屋)</dc:creator>
      <pubDate>Thu, 22 Oct 2026 01:20:33 +0000</pubDate>
      <link>https://dev.to/sishiya/teaching-spring-and-joint-creatures-to-walk-with-a-genetic-algorithm-hand-written-2d-physics-3f58</link>
      <guid>https://dev.to/sishiya/teaching-spring-and-joint-creatures-to-walk-with-a-genetic-algorithm-hand-written-2d-physics-3f58</guid>
      <description>&lt;p&gt;I made a page where creatures made only of joints (balls) and muscles (springs) evolve with a genetic algorithm (GA) to travel as far as possible. Besides flat ground, you can pick a slope, ice, the Moon or underwater. The physics and the GA are both hand-written, no libraries.&lt;/p&gt;

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

&lt;p&gt;Creatures start with random shapes and rhythms, and over the generations they find bodies and ways of moving that go forward in their environment. This post covers how the bodies and physics are built, and how I kept the GA from exploiting gaps in the physics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Genes = the body's blueprint
&lt;/h2&gt;

&lt;p&gt;One creature's genes hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;positions of 3–8 joints (inside a 1 m square) and the foot friction coefficient (0.2–1.2)&lt;/li&gt;
&lt;li&gt;for each muscle: the two joints it connects, contracted and extended lengths (0.15–1.6 m), spring constant (150–600 N/m), when in the cycle it starts contracting and for how long&lt;/li&gt;
&lt;li&gt;the body's rhythm period (0.4–2.0 seconds)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A muscle is a damped spring (damping 0.5 of critical) that heads toward the short length during its contraction window and the long length otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Physics: 15 seconds at 1/120-second steps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;joints are 2 kg balls, moved for 15 seconds at 1/120-second steps (1800 steps)&lt;/li&gt;
&lt;li&gt;the ground pushes back penetrating joints and removes downward speed, with no bounce&lt;/li&gt;
&lt;li&gt;sideways speed is reduced by friction and a "grip on the ground" force&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The score is &lt;strong&gt;the center of mass's x after 15 seconds minus its starting x&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Environments just change gravity or friction:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;What changes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Slope&lt;/td&gt;
&lt;td&gt;gravity tilted 10° (the picture is rotated)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ice&lt;/td&gt;
&lt;td&gt;friction × 0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Moon&lt;/td&gt;
&lt;td&gt;gravity 1.62 m/s²&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Underwater&lt;/td&gt;
&lt;td&gt;gravity 0 (neutral buoyancy), quadratic drag on speed perpendicular to each muscle rod&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The slope started at 15°, but even after 100 generations creatures only got 4 m, so it became 10°.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preventing bodies from passing through themselves
&lt;/h2&gt;

&lt;p&gt;The first version had no collision between body parts. That allowed motions impossible in a plane, like a joint passing through its own muscle rod. Where the physics has a gap, a GA will find solutions that use it.&lt;/p&gt;

&lt;p&gt;So from v3, there is collision, &lt;code&gt;collideBody()&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;joints are 5.5 cm-radius balls, muscles 6 cm-thick rods&lt;/li&gt;
&lt;li&gt;penetration is pushed back in proportion to mass, and approaching speed is removed (no bounce)&lt;/li&gt;
&lt;li&gt;each joint remembers which side of a muscle it is on, and is put back if it ends up on the other side (no tunneling even when fast)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After adding collision, I checked that momentum is conserved and that mechanical energy does not increase for a body with its muscles stopped.&lt;/p&gt;

&lt;p&gt;Speed is about 100 ms per generation of 100 creatures (15–35 ms before collision).&lt;/p&gt;

&lt;h2&gt;
  
  
  A body whose physics broke scores 0
&lt;/h2&gt;

&lt;p&gt;A body with strong springs might make the integration diverge and fly off. Since the score is distance traveled, that would count as "went far".&lt;/p&gt;

&lt;p&gt;So &lt;strong&gt;if speed exceeds 60 m/s, the calculation is treated as broken and the score is 0&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only bodies buildable in a plane are born
&lt;/h2&gt;

&lt;p&gt;A body whose joints are too close or whose muscles cross cannot exist in a plane. So bodies are born only if they satisfy &lt;code&gt;validBody()&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;joints at least 13 cm apart&lt;/li&gt;
&lt;li&gt;joints at least 10.5 cm from muscles not attached to them&lt;/li&gt;
&lt;li&gt;no muscles crossing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a mutation breaks the shape, it retries up to 10 times; otherwise the parent is kept. If crossover's mixing of joint positions breaks it, positions come from one parent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside the GA
&lt;/h2&gt;

&lt;p&gt;Two selection methods:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Top half&lt;/strong&gt;: the top half survives as is, and each makes one child&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tournament&lt;/strong&gt;: only the top two survive; for the rest, the stronger of two random creatures becomes a parent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mutations nudge values on a log-normal scale, and also change the body: adding or removing joints, adding or removing muscles.&lt;/p&gt;

&lt;p&gt;Crossover starts from one parent's shape and takes same-numbered joints, muscles linking the same two points, and the period from the other parent with 50% chance. Between parents of different shapes, only the overlapping parts mix (a simplification).&lt;/p&gt;

&lt;h2&gt;
  
  
  A small gotcha
&lt;/h2&gt;

&lt;p&gt;All scripts share names at the top level, so naming the body thickness &lt;code&gt;BODY&lt;/code&gt; collided with the font setting &lt;code&gt;BODY&lt;/code&gt; in the drawing code and stopped loading. Now I grep for any new uppercase name before using it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;J. H. Holland (1975) &lt;em&gt;Adaptation in Natural and Artificial Systems&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;K. Sims (1994) "Evolving Virtual Creatures", SIGGRAPH 94&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>javascript</category>
      <category>algorithms</category>
      <category>physics</category>
      <category>simulation</category>
    </item>
    <item>
      <title>Computing how 'old' the air in each room is (ventilation network + 2D flow + age of air)</title>
      <dc:creator>sishiya (しし屋)</dc:creator>
      <pubDate>Thu, 22 Oct 2026 01:15:12 +0000</pubDate>
      <link>https://dev.to/sishiya/computing-how-old-the-air-in-each-room-is-ventilation-network-2d-flow-age-of-air-2gjp</link>
      <guid>https://dev.to/sishiya/computing-how-old-the-air-in-each-room-is-ventilation-network-2d-flow-age-of-air-2gjp</guid>
      <description>&lt;p&gt;How much faster does air get replaced with two windows open instead of one? With only the exhaust fan running, where does air stagnate?&lt;/p&gt;

&lt;p&gt;I made a page that computes this for a two-bedroom apartment and colors &lt;strong&gt;how old the air is in each spot (the age of air)&lt;/strong&gt;. No libraries.&lt;/p&gt;

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

&lt;p&gt;The calculation has three stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How much air the whole home exchanges (ventilation network)&lt;/li&gt;
&lt;li&gt;How air moves through the home (2D flow)&lt;/li&gt;
&lt;li&gt;How long ago the air at each spot came in from outside (age of air)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This post covers the equations for each and where I got stuck in stage 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. How much air: find the indoor pressure by bisection
&lt;/h2&gt;

&lt;p&gt;Windows, the front door, vents and leaks use the orifice equation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Q = αA · sign(Δp) · √(2|Δp| / ρ)      Δp = wind pressure on the outer wall − indoor pressure p
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exhaust fans use a straight-line approximation of "less flow the harder they pull" (&lt;code&gt;Q = q·(1 − Δp/p_max)&lt;/code&gt;). Wind pressure comes from a wind-pressure-coefficient formula for low-rise buildings (Swami and Chandra), using the angle between the wall and the wind.&lt;/p&gt;

&lt;p&gt;Once indoor pressure p is known, every opening's flow is known. So &lt;strong&gt;bisection finds the p where inflow equals outflow&lt;/strong&gt;. Closed doors still connect through the gap underneath, so normally the whole home is treated as one space.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Flow in the home: a 2D MAC grid
&lt;/h2&gt;

&lt;p&gt;A horizontal plane about 1 m above the floor is split into 10 cm cells (96×60), on a MAC grid with a 0.05-second step.&lt;/p&gt;

&lt;p&gt;Order of one step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Forcing from jets (circulator fan, air conditioner, wind coming in through open windows)&lt;/li&gt;
&lt;li&gt;Advection (semi-Lagrangian)&lt;/li&gt;
&lt;li&gt;Viscosity and drag&lt;/li&gt;
&lt;li&gt;Projection (pressure equation, 40 SOR iterations, starting from the previous solution)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Flows from stage 1 enter as sources in cells just inside openings and sinks in fan cells.&lt;/p&gt;

&lt;p&gt;One trick: 2D flow = "real flow ÷ ceiling height 2.4 m". That keeps the volume equal to the real one, so &lt;strong&gt;the exchange time V/Q comes out right&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Closed doors stay fluid cells, but the face permeability is set to 0.05. What is pushed by pressure difference (through the gap under the door) gets through; stirring flow mostly does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Age of air: "air ages one second per second"
&lt;/h2&gt;

&lt;p&gt;The age of air is how long it has been since that air came in from outside. Thinking of it like this, it can be solved with an ordinary advection–diffusion equation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;air coming in from outside is 0 seconds old&lt;/li&gt;
&lt;li&gt;in every cell, air ages one second per second (source S = 1)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each cell gets a finite-volume, upwind, implicit (backward Euler) equation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(DX/Δt)(a − a_old) + Σout u·a − Σin u·a_n + q_out·a + (D/DX) Σ(a − a_n) = DX · S
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Smoke concentration uses the same equation with S = 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stuck 1: time would not advance
&lt;/h2&gt;

&lt;p&gt;At first I solved this system with a few Gauss–Seidel sweeps. With a large Δt per frame, the age of air barely moved.&lt;/p&gt;

&lt;p&gt;Adding diffusion makes the problem elliptic, and a few sweeps do not converge. Switching to &lt;strong&gt;BiCGSTAB (with diagonal preconditioning)&lt;/strong&gt; made it advance properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stuck 2: subtracting twice at inflow cells
&lt;/h2&gt;

&lt;p&gt;In cells where air enters through a window, I had added a term "as much air leaves this cell as enters". But entering air leaves through the faces to the neighbors, so that subtracted it twice. Now the outflow term is only for exit cells and for exchange at a single open window.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check: the age at the exit equals V/Q
&lt;/h2&gt;

&lt;p&gt;Both mistakes were found with one check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mean age of the air leaving (weighted by flow) equals the nominal time constant τ = V/Q, regardless of the flow pattern&lt;/strong&gt; (Sandberg). However much stagnation there is inside, this holds at the exit.&lt;/p&gt;

&lt;p&gt;At first it came out at 1/10 of τ, which is how I noticed. After the fixes, every scene gives 0.999–1.000 of τ.&lt;/p&gt;

&lt;p&gt;The same ratio gives the air exchange efficiency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;air exchange efficiency = τ / (2 × mean age of air in the home)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Speed tricks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Flow is computed at most 3 × 0.05 seconds per frame (about 9× speed)&lt;/li&gt;
&lt;li&gt;At faster settings (1 hour per second, etc.), the flow pattern is frozen and only the age of air and smoke advance&lt;/li&gt;
&lt;li&gt;When one frame's Δt exceeds τ/6, it is split in two&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A frame's computation stays under 15 ms.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;M. Sandberg (1981) "What is ventilation efficiency?", &lt;em&gt;Building and Environment&lt;/em&gt; 16&lt;/li&gt;
&lt;li&gt;D. Etheridge, M. Sandberg (1996) &lt;em&gt;Building Ventilation: Theory and Measurement&lt;/em&gt;, Wiley&lt;/li&gt;
&lt;li&gt;M. V. Swami, S. Chandra (1988) &lt;em&gt;ASHRAE Transactions&lt;/em&gt; 94 (wind pressure coefficients for low-rise buildings)&lt;/li&gt;
&lt;li&gt;P. R. Warren, L. M. Parkins (1985) "Single-sided ventilation through open windows"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fan flows, leak sizes and so on are typical values for products and homes.&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>simulation</category>
      <category>physics</category>
      <category>science</category>
    </item>
    <item>
      <title>Solving an influenza-and-immunity model in the browser (ODEs, RK4, and three gotchas)</title>
      <dc:creator>sishiya (しし屋)</dc:creator>
      <pubDate>Thu, 22 Oct 2026 01:09:51 +0000</pubDate>
      <link>https://dev.to/sishiya/solving-an-influenza-and-immunity-model-in-the-browser-odes-rk4-and-three-gotchas-1184</link>
      <guid>https://dev.to/sishiya/solving-an-influenza-and-immunity-model-in-the-browser-odes-rk4-and-three-gotchas-1184</guid>
      <description>&lt;p&gt;When a virus or bacterium gets into the body, how does the immune system fight it? I made a page that computes the course of an infection with &lt;strong&gt;a model made of ordinary differential equations&lt;/strong&gt; and shows it as graphs and a microscopic cross-section. Canvas 2D, no libraries.&lt;/p&gt;

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

&lt;p&gt;You can pick influenza, COVID-19, norovirus, &lt;em&gt;Staphylococcus aureus&lt;/em&gt;, pneumococcus or &lt;em&gt;E. coli&lt;/em&gt;, and try medicines and vaccines. This post covers how the influenza model is built and where the numerics tripped me up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The numbers in this app are &lt;strong&gt;rough values&lt;/strong&gt;, tuned to literature model shapes so that well-known courses (days of fever and so on) come out. They cannot be used for diagnosis or treatment decisions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Base: a target-cell-limited model
&lt;/h2&gt;

&lt;p&gt;Influenza builds on the "target-cell-limited model with an eclipse phase" of Baccam et al. (2006). Cells of the nose and throat lining are split into five fractions (summing to 1):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symbol&lt;/th&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;U&lt;/td&gt;
&lt;td&gt;not yet infected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R&lt;/td&gt;
&lt;td&gt;protected by interferon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;infected but not yet releasing virus (eclipse)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;I&lt;/td&gt;
&lt;td&gt;releasing virus&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;dead or damaged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The total is N0 = 4×10⁸ cells (Baccam 2006, upper airway). Virus V is kept as a count.&lt;/p&gt;

&lt;p&gt;Main values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;eclipse kE = 4/day (about 6 hours)&lt;/li&gt;
&lt;li&gt;infected-cell lifespan del = 1.5/day&lt;/li&gt;
&lt;li&gt;virus released per cell Y = 2000 (particles; far fewer are infectious)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On top of that I added interferon, NK cells, macrophages, killer T cells, B cells and antibodies.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small dose does not infect: saturating clearance
&lt;/h2&gt;

&lt;p&gt;In the original model, a single virus will always grow under some conditions. In reality, a small amount of virus does not cause infection.&lt;/p&gt;

&lt;p&gt;So clearance by macrophages and mucus was made &lt;strong&gt;saturating&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;clearance rate = Cm · M · V / (1 + V/Vs)     (Cm = 150, Vs = 2000)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While V is small, each virus is cleared quickly and clearance beats growth. When V gets large, clearance levels off and cannot keep up.&lt;/p&gt;

&lt;p&gt;As a result, in a healthy adult illness starts above roughly 100,000 viruses (an estimate). Human nasal challenge studies use around 10⁵–10⁷ TCID50, so I think the order of magnitude is not off.&lt;/p&gt;

&lt;p&gt;For a healthy adult with 100,000 viruses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fever on day 2, up to 38.8 °C&lt;/li&gt;
&lt;li&gt;peak on days 3–4, fever gone on day 6&lt;/li&gt;
&lt;li&gt;virus gone on day 8&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Gotcha 1: adding antibodies blew up to 10¹³
&lt;/h2&gt;

&lt;p&gt;I was using ordinary 4th-order Runge–Kutta (RK4) with a fixed step. When antibody neutralization was fast (around 1000 per day), the integration diverged and counts jumped to 10¹³.&lt;/p&gt;

&lt;p&gt;When something decays quickly, a too-large step overshoots (a stiff equation). So inside each step, the solver now &lt;strong&gt;subdivides automatically so that the fastest decay rate × step &amp;lt; 0.8&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;let&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&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;n&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="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;&amp;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;12&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;f&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;P&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;let&lt;/span&gt; &lt;span class="nx"&gt;rate&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="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;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;vs&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;k1&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="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;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;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;rate&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;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;k1&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="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="nx"&gt;v&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="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;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.8&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;rate&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;9&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="c1"&gt;// ...an ordinary RK4 step of size s (intermediate values clamped at 0)&lt;/span&gt;
  &lt;span class="nx"&gt;left&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decay rate &lt;code&gt;-k1[v] / y[v]&lt;/code&gt; is "what fraction is lost per day", so its inverse is a rough timescale for that quantity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 2: 10⁻⁵⁰ of a cell growing back
&lt;/h2&gt;

&lt;p&gt;While testing norovirus, the virus started growing again months after it should have been gone.&lt;/p&gt;

&lt;p&gt;It turned out infected cells were left at absurdly small amounts like 10⁻⁵⁰. In the equations, even that can slowly grow.&lt;/p&gt;

&lt;p&gt;So when almost nothing is left, it is set to 0:&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;// less than one particle / cell left = none (otherwise 10⁻⁵⁰ of a cell can grow back in the equations)&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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;virus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &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;V&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;3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;I&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="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;N0&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;3&lt;/span&gt;&lt;span class="p"&gt;)&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;V&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;y&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;0&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;E&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="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 model that uses continuous quantities for counts needs "counts are whole numbers" put in somewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 3: inflammation without bacteria
&lt;/h2&gt;

&lt;p&gt;In the &lt;em&gt;S. aureus&lt;/em&gt; model, inflammation never ended even after the bacteria were gone.&lt;/p&gt;

&lt;p&gt;The inflammation signal also came from "tissue damage", and damage also came from neutrophils' collateral harm. Signal → neutrophils → damage → signal made a loop. The fix: damage only produces a signal while bacteria are present.&lt;/p&gt;

&lt;h2&gt;
  
  
  Medicine: make "time" part of the state
&lt;/h2&gt;

&lt;p&gt;How well a drug works depends on time since the first dose. So time t itself is one of the state variables (dt/dt = 1). Then any equation can compute the drug effect from &lt;code&gt;y.t&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Oseltamivir blocks 85% of virus release from cells (5 days, half-life about 8 hours). Starting about 5 hours after fever begins shortens the fever by about a day, roughly the shortening reported in clinical trials.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I check it
&lt;/h2&gt;

&lt;p&gt;Every time, all six pathogens × four bodies × every memory option run to the end, checking for errors:&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;const&lt;/span&gt; &lt;span class="nx"&gt;pk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;PATHOGEN_ORDER&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;body&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;BODY_ORDER&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;mem&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PATHOGENS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;memOptions&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;B&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BODIES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nx"&gt;__ib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;inn&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;inn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;adp&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;adp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PATHOGENS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;doseDefault&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;playing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;__ib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;__ib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;P. Baccam, C. Beauchemin, C. A. Macken, F. G. Hayden, A. S. Perelson (2006) "Kinetics of influenza A virus infection in humans", &lt;em&gt;Journal of Virology&lt;/em&gt; 80, 7590&lt;/li&gt;
&lt;li&gt;P. F. M. Teunis et al. (2008) &lt;em&gt;Journal of Medical Virology&lt;/em&gt; (how many noroviruses can infect)&lt;/li&gt;
&lt;li&gt;S. D. Elek (1957) &lt;em&gt;Annals of the New York Academy of Sciences&lt;/em&gt; (how many &lt;em&gt;S. aureus&lt;/em&gt; cause skin infection)&lt;/li&gt;
&lt;li&gt;Immunology textbooks (such as &lt;em&gt;Janeway's Immunobiology&lt;/em&gt;)&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>javascript</category>
      <category>simulation</category>
      <category>science</category>
      <category>math</category>
    </item>
    <item>
      <title>Inside a pupa you can rewind: deriving every frame from t alone (Canvas 2D)</title>
      <dc:creator>sishiya (しし屋)</dc:creator>
      <pubDate>Fri, 16 Oct 2026 12:26:59 +0000</pubDate>
      <link>https://dev.to/sishiya/inside-a-pupa-you-can-rewind-deriving-every-frame-from-t-alone-canvas-2d-4hfo</link>
      <guid>https://dev.to/sishiya/inside-a-pupa-you-can-rewind-deriving-every-frame-from-t-alone-canvas-2d-4hfo</guid>
      <description>&lt;p&gt;A caterpillar becomes a pupa and then an adult. Inside the pupa, much of the larval body breaks down and the adult body is rebuilt.&lt;/p&gt;

&lt;p&gt;I made a page that shows that inside as a side-view diagram, which you can scrub back and forth with a single time slider. The model insect is the tobacco hornworm moth (&lt;em&gt;Manduca sexta&lt;/em&gt;), the best-studied species. Canvas 2D, no libraries.&lt;/p&gt;

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

&lt;p&gt;This post is about how I made sure that "dragging the slider back returns the same picture".&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: everything on screen comes from t
&lt;/h2&gt;

&lt;p&gt;Time t is days since pupation. Wandering (when the larva walks around looking for a place to burrow) is −4, and emergence is 18 (18–20 days in the lab).&lt;/p&gt;

&lt;p&gt;Ordinary animation advances the previous frame's state a little at a time. Then you cannot build the picture for a time you scrub back to.&lt;/p&gt;

&lt;p&gt;So &lt;strong&gt;everything on screen is determined only by t and the real-time clock &lt;code&gt;clock&lt;/code&gt;&lt;/strong&gt;. No previous-frame state is kept. Even the fragments scattered from breaking organs are computed from t.&lt;/p&gt;

&lt;h2&gt;
  
  
  One timetable &lt;code&gt;TL&lt;/code&gt; holds everything
&lt;/h2&gt;

&lt;p&gt;What happens when lives in one table, &lt;code&gt;TL&lt;/code&gt;. Each row is tagged with how solid its source is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;sure&lt;/strong&gt;: known with dates for &lt;em&gt;Manduca sexta&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;est&lt;/strong&gt;: an estimate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "estimate" labels in the on-screen text follow these tags. To change timing, you only edit this table.&lt;/p&gt;

&lt;p&gt;Hormone levels are functions of t too. Ecdysone (the molting hormone), for example, has a peak in the prepupa; the adult peak rises from P2.5, is highest at P7–9 and drops sharply at P10. Only the shape (0–1) is shown, not absolute amounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every stage uses "the same 15 boundaries"
&lt;/h2&gt;

&lt;p&gt;Larva, prepupa, pupa and adult look completely different. Drawing each stage separately would make it impossible to connect them.&lt;/p&gt;

&lt;p&gt;So every stage's body is described by &lt;strong&gt;the same 15 segment boundaries&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;s = 0 is the tip of the head, 1 the head/prothorax boundary, ..., 14 the tip of the tail&lt;/li&gt;
&lt;li&gt;each boundary has position X, dorsal height D, ventral V and backbone height Y&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Organs are placed at (s, v), with v = +1 on the back surface and −1 on the belly. Morphing from larva to pupa is then just blending values boundary by boundary. Organs that have both a larval and an adult position blend by each organ's own progress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breaking organs: fix the randomness by place
&lt;/h2&gt;

&lt;p&gt;Larval muscles and salivary glands break down early in the pupa. &lt;code&gt;breakingTube()&lt;/code&gt; draws this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;split the tube into points&lt;/li&gt;
&lt;li&gt;give each point a slightly shifted "progress" using randomness&lt;/li&gt;
&lt;li&gt;go "connected → fragments → gone" by that progress
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// a = how much of the organ remains (1 → 0). f = how broken each point is&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="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&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;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clamp&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;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.7&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&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="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The randomness is a fixed value (a hash) made from the point's index i. Drawing new random numbers every frame would move the fragments each time you scrub back. With a hash, the same t always gives the same fragments in the same places. Blood cells gather to clean them up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I got stuck: wings poking out of the back
&lt;/h2&gt;

&lt;p&gt;Wings start as small pouches inside the thorax and turn inside out in the prepupa.&lt;/p&gt;

&lt;p&gt;At first I placed the wings pointing "from the base toward the belly", and the wing body poked out of the back. Putting the base high on the side of the thorax, with the leading edge sloping down toward the belly, matched a real pupa.&lt;/p&gt;

&lt;p&gt;Also, rotating the wings straight to the adult orientation right after emergence made them look, from outside, like "wings appearing out of nowhere". So now they expand in the same place and orientation as in the pupa (15–70 minutes after emergence), then rotate at the base and fold (70–85 minutes). These times are estimates: I could not find a study that gives when &lt;em&gt;Manduca&lt;/em&gt; starts and finishes expanding its wings, and the app labels them "estimated" on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing the slider's time
&lt;/h2&gt;

&lt;p&gt;Many things happen within hours around emergence. In plain days, that would be a sliver of the slider. So the slider-to-time mapping changes by section:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Share of slider&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;−6 to 18 days (until emergence)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8%&lt;/td&gt;
&lt;td&gt;emergence to 15 min (out of the case, out of the soil)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7%&lt;/td&gt;
&lt;td&gt;to 100 min (wings expand)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3%&lt;/td&gt;
&lt;td&gt;to 6 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6%&lt;/td&gt;
&lt;td&gt;to 1.5 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How I check it
&lt;/h2&gt;

&lt;p&gt;Every time, I run through all time in the three views (see-through, cut open, outside) and check for errors:&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;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;__mm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VIEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&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;0&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="mi"&gt;300&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;__mm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;uToT&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;300&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;__mm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;__mm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;err&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;p&gt;Because the picture depends only on t, calling &lt;code&gt;setT&lt;/code&gt; at scattered times gives the same pictures as playing in order.&lt;/p&gt;

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

&lt;p&gt;The app's "About this app" lists 15. A few of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;J. W. Truman, L. M. Riddiford (1999) &lt;em&gt;Nature&lt;/em&gt; (hormones of complete metamorphosis)&lt;/li&gt;
&lt;li&gt;M. Jindra, S. R. Palli, L. M. Riddiford (2013) &lt;em&gt;Annual Review of Entomology&lt;/em&gt; (&lt;em&gt;Kr-h1&lt;/em&gt;, &lt;em&gt;broad&lt;/em&gt;, &lt;em&gt;E93&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;L. M. Schwartz, J. W. Truman (1982) &lt;em&gt;Science&lt;/em&gt; (muscles between abdominal segments die after emergence)&lt;/li&gt;
&lt;li&gt;K. A. Hegstrom, L. M. Riddiford, J. W. Truman (1998) &lt;em&gt;Journal of Neuroscience&lt;/em&gt; (remodeling of larval muscles and the flight muscles)&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>javascript</category>
      <category>canvas</category>
      <category>science</category>
      <category>simulation</category>
    </item>
    <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>
